算法竞赛入门经典授课教案第11章图论模型与算法.docx
《算法竞赛入门经典授课教案第11章图论模型与算法.docx》由会员分享,可在线阅读,更多相关《算法竞赛入门经典授课教案第11章图论模型与算法.docx(64页珍藏版)》请在冰豆网上搜索。
算法竞赛入门经典授课教案第11章图论模型与算法
第11章图论模型与算法
【教学内容相关章节】
11.1再谈树11.2最短路问题
11.3网络流初步11.4进一步学习的参考
【教学目标】
(1)掌握无根树的常用存储法和转化为有根树的方法;
(2)掌握由表达式构造表达式树的算法;
(3)掌握Kruskal算法及其正确性证明,并用并查集实现;
(4)掌握基于优先队列的Dijkstra算法实现;
(5)掌握基于FIFO队列的Bellman-Ford算法实现;
(6)掌握Floyd算法和传递闭包的求法;
(7)理解最大流问题的概念、流量的3个条件、残流网络的概念和求法;
(8)理解增广路定理与最小割最大流定理的证明方法,会实现Edmonds-Karp算法;
(9)理解最小费用最大流问题的概念,以及平行边和反向弧可能造成的问题;
(10)会实现基于Bellman-Ford的最小费用路算法。
【教学要求】
掌握Kruskal算法及其正确性证明,并用并查集实现;掌握基于优先队列的Dijkstra算法实现;掌握基于FIFO队列的Bellman-Ford算法实现;掌握Floyd算法和传递闭包的求法;理解最大流问题的概念、流量的3个条件、残流网络的概念和求法;理解增广路定理与最小割最大流定理的证明方法,会实现Edmonds-Karp算法;理解最小费用最大流问题的概念,以及平行边和反向弧可能造成的问题;会实现基于Bellman-Ford的最小费用路算法。
【教学内容提要】
本章主要介绍了一些常见的图论模型和算法,包括最小生成树、单源最短路、每对结点的最短路、最大流、最小费用最大流等。
对于树型结构介绍了无根树转有根树、表达式树、最小生成树和并查集。
对图状结构,主要介绍了求单源最短路的Dijkstra算法、每对结点的最短路的Floyd算法。
对于网络流主要介绍了最大流的增广路算法、最小割最大流定理、最小费用最大流问题的Bellman-Ford算法。
【教学重点、难点】
教学重点:
(1)掌握Kruskal算法及其正确性证明,并用并查集实现;
(2)掌握基于优先队列的Dijkstra算法实现和基于FIFO队列的Bellman-Ford算法实现;
(3)掌握Floyd算法和传递闭包的求法;
(4)理解增广路定理与最小割最大流定理的证明方法,会实现Edmonds-Karp算法;
(5)会实现基于Bellman-Ford的最小费用路算法。
教学难点:
(1)掌握Kruskal算法及其正确性证明,并用并查集实现;
(2)掌握基于优先队列的Dijkstra算法实现和基于FIFO队列的Bellman-Ford算法实现;
(3)掌握Floyd算法和传递闭包的求法;
(4)理解增广路定理与最小割最大流定理的证明方法,会实现Edmonds-Karp算法;
【课时安排】
11.1再谈树11.2最短路问题
11.3网络流初步11.4进一步学习的参考
11.1再谈树
在第6章介绍了二叉树,后面的章节中介绍了解答树、BFS树等其它树状结构。
有n个顶点的树具有以下3个特点:
连通、不含圈、恰好包含n-1条边。
有意思的是,具备上述3个特点中的任意两个,就可以推导出第3个。
11.1.1无根树转有根树
输入一个n结点的无根树的各条边,并指定一个根结点,要求把该树转化为有根树,输出各个结点的父亲编号。
n≤106,如图11-1所示。
图11-1无根树转有限树
【分析】
树是一种特殊的图,可用邻接矩阵来存储,但是邻接矩阵要用n2个元素的空间,空间开销很大。
由于n个结点树只有n-1条边,所以可以用C++的STL(StandardTemplateLibrary,标准模板库存)中的顺序容器vector(向量)来存储。
vectorG[maxn];
voidread_tree(){
intu,v,n;
scanf("%d",&n);//输入结点数n
for(inti=0;i<=n-1;i++){
scanf("%d%d",&u,&v);//输入边对应的结点u和v
G[u].push_back(v);//存入u结点的邻接点为v
G[v].push_back(v);//存入v结点的邻接点为u
}
}
vector是STL中的可变长数组,可理解成STL中的可变长数组,因此G是一个“包含maxn行,但每行长度可以不同”的“二维数组”。
结点u的所有相邻点都放在G[u]中,用G[u].size()获取u的相邻点个数,G[u][i]表示其中第i个相邻点。
由于vector是变长的,这个“二维数组”占用的空间是O(n),而非O(n2)。
下面是转化过程:
voiddfs(intu,intfa){//递归转化以u为根的子树,u的父亲为fa
intd=G[u].size();
for(inti=0;iintv=G[u][i];//结点u的第i个相邻点v
if(v!
=fa)
dfs(v,p[v]=u);//把v的父亲设为u,然后递归转化为以v为根的子树
}
}
无根树转有根树的完整程序如下:
#include
#include
usingnamespacestd;
constintmaxn=10;//表示最大的结点数为10
vectorG[maxn];//G为超级数组,用作邻接表
intp[maxn-1];//保存每一个节点的父亲
voidread_tree(){
intu,v;
intn;//n个结点
cout<<"输入n个点:
"<cin>>n;//输入结点数n
for(inti=0;icin>>u>>v;//输入边对应的结点u和v
G[u].push_back(v);//存入u结点的邻接点为v
G[v].push_back(u);//存入v结点的邻接点为u
}
}
voiddfs(intu,intfa){//递归转化以u为根的子树,u的父亲为fa
intd=G[u].size();
for(inti=0;iintv=G[u][i];//结点u的第i个相邻点v
if(v!
=fa){
dfs(v,p[v]=u);//把v的父亲设为u,然后递归转化为以v为根的子树
cout<}
}
}
intmain(){
memset(p,-1,sizeof(p));//将数组p中元素初始化为-1
read_tree();//读入边对应的结点
dfs(1,-1);//递归调用dfs,初次调用时1表示根结点,-1表示结点1的无父结点
return0;
}
在主程序中设置p[root]=-1(表示根结点的父亲不存在),然后调用dfs(root,-1)即可,如果没有判断结点v是否和其父亲相等,将引起无限递归。
本程序的思想仍然是DFS的思想,搜索每一个节点的邻接点,然后保存父亲,之后递归。
运行结果:
输入数据:
8
01
02
03
14
15
56
57
输出结果:
2的父结点为0
3的父结点为0
0的父结点为1
4的父结点为1
6的父结点为5
7的父结点为5
5的父结点为1
说明:
(1)vector向量容器简介
vector向量容器不但能像数组一样对元素进行随机访问,还能在尾部插入元素,是一个种简单、高效的容器,完全可以代替数组。
值得注意的是,vector具有内存自动管理的功能,对于元素的插入和删除,可动态调整所占用的内存空间。
使用vector向量容器,需要头文件包含“#inlcude”。
vector文件在C:
\Prog
ramFiles\MicrosoftVisualStudio\VC98\Include文件夹中可以找到。
vector容器的下标是从0开始计数的,也就是说,如果vector容器的大小是n,那么,元素的下标是0~n-1。
对于vector容器的容量定义,可以事先定义一个固定大小,事后,可以随时调整其大小;也可以事先不定义,随时使用push_back()方法从尾部扩张元素,也可以使用insert()在某个元素位置前插入新元素。
vector容器有两个重要的方法:
begin()和end()。
begin()返回的是首元素位置的迭代器;end()返回的是最后一个元素的下一元素位置的迭代器。
(2)创建vector对象
创建vector对象常用的有三种方式:
①不指定容器的元素个数,如定义一个用来存储整型的容器:
vectorv;
②创建时,指定容器的大小,如定义一个用来存储10个double类型元素的向量容器:
vectorv(10);
注意,元素的下标为0~9;另外,每个元素的值初始化为0.0。
③创建一个具有n个元素的向量容器对象,每个元素具有指定的初始值:
vectorv(10,8.6);
上述语句定义了v向量容器,共有10个元素,每个元素的值是8.6。
(3)尾部元素扩张
通常使用push_back()对vector容器在尾部追加新元素。
尾部追加元素,vector容器会自动分配新内存空间。
可对空的vector对象扩张,也可对已有元素的vector对象扩张。
但在vector中无push_front(),因为对vector进行push_front()会造成所有元素的迁移,不符合vector设计的初衷。
例如,将2、7、9三个元素从尾部添加到v容器中,这样,v容器中就有三个元素,其值依次2、7、9。
部分代码如下:
vectorv;//一个存放int元素的向量,一开始里面没有元素
v.push_back
(2);
v.push_back(7);
v.push_back(9);
(4)下标方式访问vector元素
访问或遍历vector对象是常要做的事情。
对于vector对象,可以采用下标方式随意访问它的某个元素,当然,也可以以下标方式对某元素重新赋值,这点类似于数组的访问方式。
下面的代码就是采用下标方式对数组赋值,再输出元素的值2、7、9。
部分代码如下:
vectorv(3);
v[0]=2;
v[1]=7;
v[2]=9;
cout<(5)用迭代器(iterator)访问vector元素
常使用迭代器配合循环语句来对vector对象进行遍历访问,迭代器的类型一要与它要遍历的vector对象的元素类型一致。
部分代码如下:
vectorv(3);
v[0]=2;
v[1]=7;
v[2]=9;
//定义迭代器
vector:
:
iteratorit;
for(it=v.begin();it!
=v.end();it++){
//输出迭代器上的元素值
cout<<*it<<"";
}
STL提供三种类型的组件:
容器、迭代器和算法,它们都支持泛型程序设计标准。
STL设计的精髓在于,把容器(Containers)和算法(Algorithms)分开,而迭代器(iterator)是连接容器和算法的纽带,可见迭代器在STL中的重要程度。
迭代器的作用其实相当于一个智能指针,它指向顺序容器或关联容器中的任意元素,还能遍历整个容器。
可以通过operator*操作符来解指针获得数据的值,也可以通过operator->操作符来获取数据的指针,还能够重载++,--等运算符来移动指针。
不同的容器可能需要不同的迭代器,实际上,在STL中,为每种容器都typedef了一个迭代器,名为iterator。
例如,vector的迭代器类型为vector:
:
iterator(是一种随机访问迭代器)、list的迭代器类型为list:
:
iterator(是一种双向迭代器)。
例如,定义一个容器类的迭代器的方法可以是:
容器类名:
:
iterator变量名;
所以定义一个vector向量容器类的迭代器的方法如下:
vector:
:
iterator变量名;
如vector:
:
iteratorit;。
++it表示向前移动迭代器,使其指向容器的下一个元素。
而*it返回iterator指向元素的值。
每种容器类型提供一个begin()和一个end()成员函数。
begin()返回一个iterator,它指向容器的第一个元素。
end()返回一个iterator,它指向容器的末元素的下一个位置。
(6)元素的插入
insert()方法可以在vector对象的任意位置前插入一个新的元素,同时,vector自动扩张一个元素空间,插入位置后的所有元素依次向后挪动一个位置。
要注意的是,insert()方法要求插入的位置,是元素的迭代器位置,而不是元素的下标。
下面的部分代码输出的结果是8,2,1,7,9,3:
vectorv(3);
v[0]=2;
v[1]=7;
v[2]=9;
//在最前面插入新元素,元素值为8
v.insert(v.begin(),8);
//在第2个元素前插入新元素1
v.insert(v.begin()+2,1);
//在向量末尾追加新元素3
v.insert(v.end(),3);
//定义迭代器变量
vector:
:
iteratorit;
for(it=v.begin();it!
=v.end();it++){
//输出迭代器上的元素值
cout<<*it<<"";
}
(7)元素的删除
erase()方法可以删除vector中迭代器所指的一个元素或一段区间中的所有元素。
clear()方法则一次性删除vector中的所有元素。
下面的代码演示了vector元素的删除方法:
//删除第2个元素(即删除单个元素),从0开始计数
v.erase(v.begin()+2);
//删除迭代器第1到第5区间的所有元素
//即删除一对iterator标记的一段范围内的元素
v.erase(v.begin()+1,v.begin()+5);
//清空向量
v.clear();
清空完向量后,向量中一个元素都没有,即v.size()的值为0。
(8)使用reverse反向排列算法
reverse反向排列算法,需要定义头文件“#inlcude”。
algorithm文件位于C:
\ProgramFiles\MicrosoftVisualStudio\VC98\Include文件夹中。
reverse算法可将向量中某段迭代器区间元素反向排列,部分代码如下:
//反向排列向量的从首到尾间的元素
reverse(v.begin(),v.end());
(9)使用sort算法对向量元素排序
使用sort算法,需要声明头文件“#inlcude”。
sort算法要求使用随机访问迭代器进行排序,在默认的情况下,对向量元素进行升序排列,部分如下:
//排序,升序排列
sort(v.begin(),v.end());
还可以自己设计排序比较函数,然后,把这个函数指定给sort算法,那么,sort就根据这个比较函数指定的排序规则进行排序。
下面的部分代码自己设计了一个排序比较函数Comp,要求对元素的值由大到小排序:
//自己设计排序比较函数:
对元素的值进行降序排列
boolComp(constint&a,contint&b){
if(a!
=b)returna>b;
elsereturna>b;
}
在main()函数就可以用sort算法调用Comp()函数:
//按Comp函数比较规则排序
sort(v.begin(),v.end(),Comp);
(10)向量的大小
使用size()方法可以返回向量的大小,即元素的个数。
使用empty()方法返回向量是否为空。
下面的部分代码演示了size()方法和empty()方法的用法:
//输出向量的大小,即包含了多少个元素
cout<//输出向量是否为空,如果非空,则返回逻辑假,即0,否则返回逻辑真,即1
cout<11.1.2表达式树
二叉树是表达式处理的常用工具。
例如,a+b*(c-d)-e/f可以表示成如图11-2所示的二叉树。
图11-2表达式树
其中,每个非叶结点表示一个运算符,左子树是第1个运算符对应的表达式,而右子树则是第2个运算数对应的表达式。
给表达式建立表达式树的方法很多,其中的一种方法是:
找到“最后计算”的运算符(它是整棵表达式树的根),然后递归处理。
下面的函数buildTree是根据算术表达式来建立算术表达式树。
部分代码如下:
constintMAXN=1000;
intlch[MAXN],rch[MAXN];//每个结点的左右儿子
charop[MAXN];//存放每个结点的字符
charinput[MAXN];//input数组存放输入的表达式字符串
intnc=0;//nc存放表达式树的结点,开始时结点数为0
intbuildTree(char*s,intx,inty){//根据表达式建立表达式树
//c1、c2分别记录“最右”出现的加减号和乘除号
//标志p=0避免记录括号内的+、-、*、/
inti,c1=-1,c2=-1,p=0;
intu;
if(y-x==1){//仅一个字符,建立单独结点
u=nc++;
lch[u]=rch[u]=0;//单独结点无左右子树,所以左右子树的编号为0
op[u]=s[x];//此时把s[x]值赋给数组op第u个元素
returnu;
}
for(i=x;iswitch(s[i])
{
case'(':
p++;
break;
case')':
p--;
break;
case'+':
case'-':
if(!
p)c1=i;
break;
case'*':
case'/':
if(!
p)c2=i;
break;
}
}
if(c1<0)c1=c2;//找不到括号外的加减号,就用乘除号
if(c1<0)returnbuildTree(s,x+1,y-1);//整个表达式被一对括号括起来
u=nc++;
//对结点的左子树区间[x,c1],对表达式进行处理
lch[u]=buildTree(s,x,c1);
//对结点的右子树区间[c1+1,y],对表达式进行处理
rch[u]=buildTree(s,c1+1,y);
op[u]=s[c1];//
returnu;
}
代码围绕一个核心思想:
对于一个算式,找到最后一个被使用的运算符作为划分,以此运算符为界,递归计算左边的值,递归计算右边的值,然后以此运算符进行运算,即可得到结果。
主要任务是找最后一个被使用的运算符。
例如:
2+3*(4-1)-5/1,肯定先算括号里的,然后算*、/法,最后才考虑+、-法。
所以,先考虑+、-,再考虑*、/,括号外的+、-、*、/可能很多,所以确定一个原则,找整个算术表达式中最右边+、-、*、/。
由于要找的是括号外的+、-、*、/,所以得想办法避免记录括号内的+、-、*、/,所以设置了一个标志p,初始0。
一旦遇到一个左括号,p+1,这时候说明目前在括号内,不应该记录+-、*、/,当遇到右括号,p-1,p恢复为0,这时候说明目前已经走出括号,可以记录+、-、*、/。
扫描完整个算术表达式的时候,c1记录了最右边的括号外的+、-号,c2记录了最右边的括号外的*、/号。
如果c1<0,说明没扫描到括号外的+、-号,那么只能考虑*、/号作为最后一个运算的运算符了。
把c1=c2,然后判断c1<0,如果还<0,说明括号外也没有*、/号,说明整个算式被括号包围起来了。
所以可以递归运算时忽略这对括号,即递归(x+1,y-1)的算式,返回它的子树根。
这样,就找到了最后计算的运算符s[c1],它的左子树是区间[x,c1],右区间[c1,y]。
提示11-1:
建立表达式树的一种方法是每次找到最后计算的运算符,然后递归建树。
“最后计算”的运算符是在括号外的、优先级最低的运算符。
如果有多个,根据结合性来选择:
左结合的(如加、减、乘、除)选最右边;右结合的(如乘方)选最左边。
根据规定,优先级相同的运算符的结合性总是相同。
建立表达式树的完整程序如下:
#include
usingnamespacestd;
constintMAXN=1000;
intlch[MAXN],rch[MAXN];//每个结点的左右儿子
charop[MAXN];//存放每个结点的字符
charinput[MAXN];//input数组存放输入的表达式字符串
intnc=0;//nc存放表达式树的结点,开始时结点数为0
/*****************************************************
递归构造表达式树,每次找括号外的+、-、*、/
优先+、-划分,其次*、/划分,原则是找最后运算的符号
如果所有运算符均在括号内,忽略这个括号,继续递归内层
*****************************************************/
intbuildTree(char*s,intx,inty){//根据表达式建立表达式树
//c1、c2分别记录“最右”出现的加减号和乘除号
//标志p=0避免记录括号内的+、-、*、/
inti,c1=-1,c2=-1,p=0;
intu;
if(y-x==1){//仅一个字符,建立单独结点
u=nc++;
lch[u]=rch[u]=0;//单独结点无左右子树,所以左右子树的编号为0
op[u]=s[x];//此时把s[x]值赋给数组op[u]
returnu;
}
for(i=x;iswitch(s[i])
{
case'(':
p++;
break;
case')':
p--;
break;
case'+':
case'-':
if(!
p)c1=i;
break;
case'*':
case'/':
if(!
p)c