补充小细节:
a. 什么样的图可以称为“稀疏”:|E|=O(|V|),也就是说平均每个顶点的邻接顶点数量是常数;
1. 基本定义
图(Graph)的定义:G=(V,E)由顶点(vertex)集V和边(edge)集E组成。
边(弧arc)是一个点对(v, w),其中v,w∈V;
有向:点对是有序的,那么图有就有向的;
邻接:v和w邻接当且仅当(v,w)∈E,而(w,v)是指w和v邻接;
简单路径:其上的所有顶点都是互异的,但第一个顶点和最后一个顶点可能相同;
环:一条从一个顶点到其自身的边(v,v)那么路径v,v有时候也叫做一个环;
圈:有向图中的圈满足w1 = wn;且长至少为1的1条路径;圈也可以是简单路径,即简单圈;
有向无圈图(DAG);
图的连通性:
无向图中每个顶点到其他路径都存在一个条路径,该无向图是连通的;
有向图中~,该有向图是强连通的;
有向图不是强连通的,但它的基础图(underlying graph去掉方向后的无向图)是连通的,则它是弱连通的;
完全图:是每对顶点间都存在一条边;
图的表示:
邻接矩阵(空间需求O(|V|^2))和邻接表(adjacency list,空间:O(|E| + |V|));
邻接表是表示图的标准方法,对于无向图,每条边(u,v)出现在两个表中,空间因此是双倍的;
2. 拓扑排序(For 有向无圈图):
利用队列存放入度为0的顶点来遍历,过程类似层序遍历:
void Topsort( GRAPH G) {
size_t counter = 0;
Queue Q = CreateQueue(G-> NumVertex); //创建队列
MakeEmpty(Q);
Vertex v, w;
for(size_t i = 0; i < G->NumVertex; i++) { //将入度为0的顶点首先放入队列
v = G-> VERTEXS[i];
printf("入度:%d\n" , v->InDegree );
if(InDegree(v) == 0) {
EnQueue(v, Q);
}
}
while(!isEmpty(Q)) {
v = ( Vertex) DeQueue(Q);
counter++;
printf("第%d个顶点是:%d\n" , counter, v->Element);
Node d = v-> list;
while(d != NULL) {
w = d-> v;
w-> InDegree--;
if(InDegree(w) == 0)
EnQueue(w, Q);
d = d-> next;
}
}
if(counter != G->NumVertex ) {
println( "这是一个有圈图!" );
}
}
3. 最短路径算法:
最短路径求解的问题,需要根据图的情况而定:
单源最短路径问题:给定一个赋权图G = (V, E)和一个特定顶点s作为输入,找出s到G中每一个其他顶点的最短赋权路径;
这个问题有4种形态:
无权图【无权最短路径O(|V| + |E|)】;
有向赋权图/无负边【赋权最短路径O(|E|log|V|)】;
有向赋权图/有负边【O(|E|*|V|)】;
无圈图【O(|E| + |V|)】;
3.1 无权最短路径
/**
* 无权最短路径计算
*/
void Unweighted( GRAPH G, Vertex s) {
Queue Q = CreateQueue(7);
MakeEmpty(Q);
Vertex v,w;
Info *infos = CreateVertexInfo(G-> NumVertex);
EnQueue(s, Q);
infos[s-> Element - 1]-> Distance = 0;
while(!isEmpty(Q)) {
v = DeQueue(Q);
infos[v-> Element - 1]-> Known = 1; //设为已知顶点
Node d = v-> list;
while(d != NULL) { //开始遍历检查与v邻接的顶点
w = d-> v;
if(infos[w->Element - 1]->Distance == INT_MAX) { //这样可以保证每个顶点只算最先计算的一次,因为是无权的
infos[w-> Element - 1]-> Distance = infos[v-> Element - 1]->Distance + 1;
infos[w-> Element - 1]-> PreVertex = v->Element ; //设置前序顶点
EnQueue(w, Q);
}
d = d-> next;
}
}
PrintVertexInfo(infos, G-> NumVertex);
DisposeQueue(Q);
DisposeVertexInfo(infos);
}
3.2 赋权最短路径计算
这里使用了贪婪算法,每轮需要确定一个当前“最近”的顶点设为“已知”;在迭代的过程中每一轮都需要输出一个最小值,这样的话维护一个优先队列(堆)是一个很好的选择;
好的做法是:维护一个包含“未知”节点的(最小)堆,在每轮开始的时候输出堆顶,更新时修改顶点的distance值;这两个过程的时间复杂度都是O(log|V|);总的时间复杂度是,O(|E|log|V| + |V|log|E|);
可选的优先级队列的实现有:二叉堆,配对堆和斐波那契堆等;
3.3 带有负边的图:
如果负边,那么Dijkstra算法就不可行,因为可能不存在“最短”的路径【负值圈】;
如果考虑每条边的权+常数k使得每条边的权都为正数,可以将负边问题去掉,但是这样边数多的路径比边数少的路径更重了,这也可能导致错误;
解决办法是将赋权和无权结合起来;
3.4 无圈图:
无圈图可以很容易在每一阶段确定这一层的顶点的最短路径,因为不存在其他可能更小的情况;因此不需要优先级队列,时间复杂度/过程与无权图和拓扑排序类似:O(|V| + |E|);
关键路径分析法->动作节点图->事件节点图:
最早完成时间:EC1 = 0; ECw = max(ECv + Cv,w);
最晚时间:LCn = ECn; LCv = min(LCw-Cv,w);【倒转上一个问题的拓扑结构】
松弛时间:Slack(v,w) = LCw - ECv - Cv,w;
关键路径是【零-松弛边组成的路径】
3.5 所有点对的最短路径(路由、拓扑):【注意是点对】
对于稀疏图更快的是运行|V|次用优先队列编写的Dijkstra算法;
4. 网络流问题:
4.1 基本概念:
发点【source】;
收点【sink】;
发点和收点之间的最大流问题;
残余图【residual graph】Gr:表示每条边还能再添加多少流,可以从容量中减去当前的容量得到残余的流量;
残余边【residual edge】:Gr的边称为残余边;
增长通路(augmenting path):寻找图Gr中的s到t的一条路径,这条路径就叫做增长通路;
4.2 最大流算法
合理做法是:每轮(这轮流量为流f(v,w))在残余图中增添一条容量为f(v,w)的边(w,v);
终止条件:Gr中从t从s出发是不可到达的;
为了防止在迭代选择增长通路的时候,优先甚至反复选择较小流量的路径,有如下两种方法:
a. 总选择使得流增长最大的增长通路(O(|E|^2log|V|logcapmax));
b. 总选择具有最少边数的路径(O(|E|^2|V|));
5. 最小生成树(边数为|V|-1)
5.1 Prim算法(类似Dijsktra算法,使用|V|优先队列维护顶点三元组信息):
5.2 Kruskal算法(|E|大小的优先队列,不保存顶点三元组信息,维护顶点集合,不断合并顶点集合,直到成为一个集合):
6. 深度优先搜索(线性时间解决,O(|E| + |V|))
6.1 无向图:
如果不连通,那么经过多次Dfs形成深度优先生成森林(depth-first spanning forest);
6.2 双连通(biconnected):去掉一个顶点之后剩下的图仍然是连通的;
割点(articulation point)就是使图丧失连通性的顶点;
查找割点【很经典】:
在遍历的时候计算Num(w),low(w);
6.3 欧拉回路(任意顶点度为偶数):
检查算法:多次遍历:每一轮都找到一条回路,直到所有边都被遍历到为止;
6.4 有向图(背向边、前向边、交叉边):
查找强分支【很经典】:
练习总结:
9.13 二分图与二分匹配问题:
其中教师和课程的例子非常好,要牢记;
二分匹配的问题就是,两个顶点集,每个顶点匹配的边不能有两条或两条以上,如,每个教师只能上一门课,每门课只能由一个教师上;
问题c,探讨了网络流和二分匹配问题之间的关系;
可以在二分图两端加上s、t顶点,这样问题其实就等价于s到t的最大流了;
9.15 通过Prim和Kruskal两种方法求图9-82的最小生成树,可以发现最小生成树其实可能不唯一,当一个顶点它属于的边中最小权的边数大于1时;