最小生成树问题
什么是最小生成树(Minimum Spanning Tree)
- 是一棵树
- 无回路
- |V|个顶点一定有|V| -1 条边
- 是生成树
- 包含全部顶点
- |V| - 1条边都在图里
- 边的权重和最小
结论:最小生成树存在 图连通
贪心算法
- 什么是“贪”:每一步都要最好的
- 什么是“好”:权重最小的边
- 需要约束:
- 只能用图里有的边
- 只能正好用掉|V| - 1条边
- 不能有回路
Prim算法 - 让一棵小树长大
1. 选择V1作为根结点
2. 找最小边,和这棵树有关系的往外长的,即和v1直接相关的最小的边为1,包含了v4
3. 往外长这棵树,以v1和v4为基础,能够往外长出去的。和这棵树有关联的,边权重小的有两个选择,v2和v3都是2,先选上面的2,所以包含了v2
4. 和这棵树相关的较小的是2,包含了v3
5. 和这棵树相关,比较小的边是3,但是会形成回路,所以不行,同理v1和v3间的边也不行。于是就是v4-v7间的边,包含了v7
6. 这棵树就和v6产生了关系,1是边最小的,所以这棵树又包含了v6
7.最后是v5,就是v7-v5这个边,包含v5
8.至此,7个顶点,6条边都收录了。
这个过程和Dijstra算法很像,但是像到什么程度呢?
->
Prim算法:
(1)先随便选一个根结点s,收录进来;
(2)dist是V到生成树MST的最短距离,那么dist的初始化就分三种情况:
- 如果一开始顶点V和树是相连的,那么dist就初始化为边的权重:dist[V] = E(s,V)
- 如果一开始顶点V和s之间没有直接的边,那么就初始化为正无穷:dist[V] = +∞
(3)树的存法:不需要构建树,对每一个顶点存它的parent的编号,随便选s作为根结点,parent[s] = -1
(4)把V收录进MST:意味着V和这棵树的距离为0
(5)W未被收录:就是dist[W] != 0
(6)while执行结束的条件:这样的V不存在 --两种情况:1)所有的顶点都被收录 2)所欲未收录的顶点dist都是无穷大,图不连通,
(7)while如果结束的情况是剩下的顶点和树的距离都是无穷大,即图不连通,这种情况下就要判断一下树中收录的顶点的个数是否为|V|
(8)时间复杂度取决于“V = 未收录顶点中dist最小者”,如果使用暴力解决(扫描)的方法,那时间复杂度就是T = O(|V|²)
(9)Prim算法适合稠密图
Kruskal算法 - 将森林合并成树
针对于稀疏图(边数和点数一个数量级)
所谓将森林合并成树,就是在初始的状态下,认为每一个顶点都是一棵树。然后通过不断收录边,就把两个树并成了一棵树。每次就直接找权重最小的边,收录进来。
Kruskal算法的过程
1. 针对上图,可以看到有两条权重为1的边,所以就直接收录进来。
2. 下一条可以收录的边是两个权重为2的边:
3. 下一条不构成回路的权重最小的边是 v4-v7之间的边:
4. 下一条是v7-v5的边:
5.至此,7个顶点,6条边都包含了。所以肯定生成了最小生成树
伪代码
void Kruskal(Graph G) { MST = { }; /*开始的时候没有边,所以是空集*/ while (MST中不到|V| - 1 条 && E中还有边) { 从E中取一条权重最小的边E(v,w); /*最小堆*/ 将E(v,w)从E中删除; if (E(v,w)不在MST中构成回路) /*并查集*/ 将E(v,w)加入MST; else 彻底无视E(v,w); } if (MST中不到|V| -1 条边) Error("生成树不存在"); } |
Kruskal算法说明:
1. 如何判断E(v,w)是否在MST中构成回路,可以使用“并查集”。
(1)一开始认为每个顶点都是一棵独立的树,也就相当于每一个顶点都是一个独立的集合;
(2)当收录一条边的时候,意味着把两棵树并成一棵,意味着把两个集合并成了一个集合;
(3)当新的边往树中放的时候,要先检查v是属于哪个集合,w是属于哪个集合,如果分别属于两个不同的树,那么加入边之后就并成了一棵,一定是不构成回路的;如果v和w本身已经在同一棵树里面,那么加入边一定会构成一条回路。
2. while结束有两种情况:
(1)MST中正好有|V|-1条边
(2)MST不到|V|-1条边,原图中的没有边了,意味着生成树不存在,也就是原图不连通
3. 如果“从E中取一条权重最小的边E(v,w)”使用最小堆(O(log|E|)),判断“E(v,w)不在MST中构成回路”用并查集,那么整个Kruskal算法的时间复杂度就是T = O(|E| log |E|)
拓扑排序
例:计算机专业排课
专业课的依赖关系图:
这种图叫做 AOV(Activity On Vertex)网络。
该问题进行抽象,就是拓扑排序。
- 拓扑序:如果图中从V 到 W有一条有向路径,则V一定排在W之前。满足此条件的顶点序列称为一个拓扑序
- 获得一个拓扑序的过程就是拓扑排序
- AOV如果有合理的拓扑序,则必定是有向无环图(Directed Acyclic Graph,DAG)
针对上面提到的计算机专业排课,根据AOV网络,可以有如下排课:
1. 第一个学期可以排没有预修课程的顶点(AOV网络中没有预修课程的顶点有C1, C2, C8,C4)
先输出C1,同时将边删掉
继续输出C2,同时将边删掉:
此时可以发现图中C3和C13都没有前驱结点,但是此时输出不太好。所以不选择这两个顶点。继续输出C8和C4:
2. 同理,第二学期可以输出C3,C13,C9, C5:
3. 第三学期就可以输出C7 ,C6:
4. 第四学期就可以输出C12, C10,C11,C15
5. 最后一个学期就输出C14:
这样,就完成了一个排课的过程。这个过程也就是拓扑排序的过程。规律是选择入度为0的顶点输出,当图中的所有顶点都被抹光(边被删除)之后,拓扑过程也就结束了。
算法
void TopSort()
{
for(cnt = 0; cnt < |V|; cnt++) {
V = 未输出的入度为0的顶点;
if(这样的V不存在) { //如果外循环没有结束,而找不到这样的V了,意味着图中还有顶点没有输出,但是有边指向它
Error("图中有回路"); //不是合理的AOV
break;
}
输出V, 或者记录V的输出序号;
for(V 的每个邻接点 W)
Indegree[W]--; //就是将输出的顶点包含边抹掉,抹掉也就是将输出顶点的邻接点的入度减1
}
}
时间复杂度
取决于“V = 未输出的入度为0的顶点; ” 是怎么找的:
1. 如果用扫描所有点的方法,那么“V = 未输出的入度为0的顶点; ”的时间复杂度是 T = O(|V|),而整体的时间复杂度是T = O(|V|²)
2. 聪明的算法:
随时将入度变为0的顶点放到一个容器(数组、堆栈、队列或链表等)里 ----时间复杂度就变为常数级的 (如下是用队列保存)
void TopSort()
{
for(图中每个顶点V)
if ( Indegree[V] == 0)
Enqueue(V, Q);
while(!IsEmpty(Q)) {
V = Dequeue(Q);
输出V,或者记录V的输出序号;
cnt++; //记录输出的顶点个数,方便后续判断是否有回路
for(V的每个邻接点W) {
if(--Indegree[W] == 0)
Enqueue(W, Q);
}
}
if(cnt != |V|)
Error("图中有回路");
}
每个顶点会入队1次,因为检查了V的每个邻接点,所以每条边也被扫描了一次,所有总的时间复杂度是T = O(|V| + |E|)。此算法可以用来检测有向图是否DAG。
完整代码
/* 邻接表存储 - 拓扑排序算法 */
bool TopSort( LGraph Graph, Vertex TopOrder[] )
{ /* 对Graph进行拓扑排序, TopOrder[]顺序存储排序后的顶点下标 */
int Indegree[MaxVertexNum], cnt;
Vertex V;
PtrToAdjVNode W;
Queue Q = CreateQueue( Graph->Nv );
/* 初始化Indegree[] */
for (V=0; V<Graph->Nv; V++)
Indegree[V] = 0;
/* 遍历图,得到Indegree[] */
for (V=0; V<Graph->Nv; V++)
for (W=Graph->G[V].FirstEdge; W; W=W->Next)
Indegree[W->AdjV]++; /* 对有向边<V, W->AdjV>累计终点的入度 */
/* 将所有入度为0的顶点入列 */
for (V=0; V<Graph->Nv; V++)
if ( Indegree[V]==0 )
AddQ(Q, V);
/* 下面进入拓扑排序 */
cnt = 0;
while( !IsEmpty(Q) ){
V = DeleteQ(Q); /* 弹出一个入度为0的顶点 */
TopOrder[cnt++] = V; /* 将之存为结果序列的下一个元素 */
/* 对V的每个邻接点W->AdjV */
for ( W=Graph->G[V].FirstEdge; W; W=W->Next )
if ( --Indegree[W->AdjV] == 0 )/* 若删除V使得W->AdjV入度为0 */
AddQ(Q, W->AdjV); /* 则该顶点入列 */
} /* while结束*/
if ( cnt != Graph->Nv )
return false; /* 说明图中有回路, 返回不成功标志 */
else
return true;
}
关键路径问题
是拓扑排序的应用。
AOE(Activity On Edge) 网络,一般用于安排项目的工序
例子:
因为有虚线5到4,这个AOE网络表示持续时间为9和7的任务都要等到前面的持续时间1,1和2的任务完成后才能开始执行。
问题1:整个工期有多长? Earliest[8] = 18
递推公式:
问题2:哪几个组有机动时间?
倒推公式,从最后一个顶点开始:
完整的倒推结果:
机动时间计算公式:
如上图的顶点2和顶点0之间:D<0,2> = Latest[2] - Earliest[0] - C<0,2> = 6 - 0 - 4 = 2。
可见,有三个组有机动时间:
关键路径:由绝对不允许延误的活动组成的路径。 上图的关键路径不止一条,由那些没有机动时间的任务组成的路径。