数据结构第六章(四)
图的应用(一)
一、最小生成树
连通图的生成树
是包含图中全部顶点的一个极小连通子图(边尽可能地少,但要保持连通)。
若图中顶点数为n,则它的生成树含有n-1条边(还记得我们之前说的吗?顶点为n的图要保持连通的最少边就是n-1条)。对生成树而言,若砍去它的一条边,则会变成非连通图,若加上一条边则会形成一个回路。
最小生成树
是指在一个连通的加权图中,选取一些边,使得这些边连接所有的顶点,并且总权重最小,Prim算法和Kruskal算法就是用来解决这个问题的。
我们来看看官方定义:
对于一个带权连通无向图
G=(V,E),生成树不同,每棵树的权(即树中所有边上的权值之和)也可能不同。设R为G的所有生成树的集合,若T为R中边的权值之和最小的生成树
,则称T为G的最小生成树
(Minimum-Spanning-Tree,MST)
其实就是上面说的。
而且要注意一下,最小生成树其实也是生成树,也就是说它也满足 边数=顶点数-1 ,砍掉一条则不连通,增加一条边则一定会出现回路。
还有就是:最小生成树可能有多个,但边的权值之和总是唯一且最小的。
如下所示:
还有我们要知道,树是特殊的图,树没有环,而且树如果少了什么边,就不连通了是吧,所以:
- 如果一个连通图本身就是一棵树,则其最小生成树就是它本身
- 只有连通图才有生成树,非连通图只有生成森林
1.Prim算法
Prim算法说白了就是从一个顶点开始,逐步扩展,每次选择一条权值最小的边,连接一个未被访问过的顶点。
Prim算法:从某一个顶点开始构建生成树;每次将代价最小的新顶点纳入生成树,直到所有顶点都纳入为止。
比如下面这个图,用Prim算法构造一个最小生成树:
我们从顶点“P城”出发,首先P城加入我们构造的最小生成树(只有这一个顶点),然后我们找一个顶点加入这个最小生成树,使得代价最小,那么我们可以看到把“学校”这个顶点加入到最小生成树中的代价只有1,这个是最小的代价(此时我们的最小生成树变成了“P城-学校”),接着我们再看把哪个顶点加入到现在的最小生成树代价最小,环顾四周有个最小的代价是4(“矿场”“渔村”都可以),我们就把“矿场”加入到最小生成树里(此时我们的最小生成树变成了“P城-学校-矿场”),之后我们再再看把哪个顶点加入到现在的最小生成树代价最小,看到把“渔村”加入代价只有2,我们就把“渔村”加入到最小生成树里(此时我们的最小生成树变成了“P城-学校-矿场-渔村”),以此类推,把“农村”加进去,再以此类推,把“电站”加进去,遂完成。
我们通过Prim算法构造的最小生成树如下图所示:
当然,也可以在当时不加入“矿场”,加入“渔村”(把他们加入的代价是相等的,都是4),构造的最小生成树就不一样了,但是代价是一样的,最小生成树可以有多个但是代价都是最小的。
如果我们要用算法实现该怎么实现呢?我们首先要看各个结点是不是在树里,还要知道哪个加入代价最小。所以我们设置两个数组,一个isJoin数组,标记各节点是否已加入树;一个lowCost数组,存放各节点加入树的最低代价,用下面这个图来说明:
从V0开始,总共需要n-1轮处理
标记各节点是否已加入树(isJoin[6])
v0 | v1 | v2 | v3 | v4 | v5 |
---|---|---|---|---|---|
√ | × | × | × | × | × |
各节点加入树的最低代价(lowCost[6])
0 | 6 | 5 | 1 | ∞ | ∞ |
---|
每一轮处理:循环遍历所有个结点,找到lowCast最低的,且还没加入树的顶点
再次循环遍历,更新还没加入各个顶点的lowCast值
说是不考算法,我就说一句话,然后就不说了:就是比如第1轮,循环遍历所有个结点,找到lowCast最低的,且还没加入树的顶点,我们找到了“1”,然后标记把v3加入树,即:
isJoin[6]数组:
v0 | v1 | v2 | v3 | v4 | v5 |
---|---|---|---|---|---|
√ | × | × | √ | × | × |
然后我们循环遍历,更新还没加入各个顶点的lowCast值:首先看v1,它和v0距离为6,和v3距离为5,5比较小,所以它的lowCost值更新为5;再看v2,它和v0距离为5,和v3距离为4,4比较小,所以它的lowCost值更新为4;之后看v4,它和v0距离为∞,和v3距离为6,6比较小,所以它的lowCost值更新为6;最后看v5,它和v0距离为∞,和v3距离为4,4比较小,所以它的lowCost值更新为4;
故我们的lowCost[6]数组:
0 | 5 | 4 | 1 | 6 | 4 |
---|
……
以此类推,直到遍历完所有顶点(所有的isJoin都为TRUE)
我们可以看到,每次都要循环遍历数组找到lowCost最低的,找完以后还要循环遍历更新各个顶点的lowCost值,所以我们的每一轮时间复杂制度都是O(2n)(即O(n)),从V0开始,总共需要n-1轮处理,所以总时间复杂度为O(n2).
时间复杂度是O( |V|^2^ ),适合用于边稠密图
2.Kruskal算法
Kruskal算法其实就是从最小的边开始,逐步加入到生成树中的,但要确保不会形成环。如果加入一条边后会形成环,就跳过这条边,继续处理下一条最小的边。
Kruskal算法:每次选择一条权值最小的边,使这两条边的两头连通(原本已经连通的就不选),直到所有的结点都连通。
再请出我们的老演员这个图:
我们用Kruskal算法构造一个最小生成树,首先把们的代价从小到大排列:1,2,3,4,4,5,5,6,6,6,现在我们开始选择一条最小的边“1”,选择后把边及对应的顶点加入最小生成树(P城-学校),再看“2”这个边,这个边不连通不在生成树内,所以把这条边及对应顶点加入最小生成树……(其实肉眼看特别明显,选边就行了)以此类推,我们得到Kruskal算法构造的最小生成树:
如果我们要用算法实现该怎么实现呢? 这个Kruskal算法显然需要先把各条边都按照权值进行排序,但是我们也不是看谁小就加谁,还要看这个边对应的顶点是不是已经在最小生成树里。
按权值排序:
一样是不考算法,我只说一句:就是比如第1轮,我们检查第1条边的两个顶点是否连通(是否属于同一个集合<可以用并查集实现>),发现不连通就连起来,连通就接着找下一个边……以此类推。
总共执行e轮,每轮都判断两个顶点是否属于用一个集合(用并查集),时间复杂度为O(log2e),所以总时间复杂度为O(elog2e)。
时间复杂度:O( |E|log~2~|E| ),适合用于边稀疏图
再说一句题外话:
这两个算法的主要区别是什么呢?
首先就是选择边的方式不同。Prim算法是从顶点出发,每次选择连接到当前生成树的最小边;而Kruskal算法是从边出发,按边的权重从小到大排序,逐个检查是否能加入生成树。
其次是使用的数据结构不同。Prim算法通常使用优先队列(最小堆)来选择当前最小的边,而Kruskal算法则需要一个并查集数据结构来检测是否有环。
这两个算法都是贪心算法,都通过局部最优选择来达到全局最优。
二、最短路径问题
所谓单源最短路径就是找这个固定的顶点到其他顶点的最短距离
,所谓各顶点间的最短路径就是找每个顶点互相之间怎么走的最短距离
。
1.单源最短路径
1.1 BFS算法(无权图)
介缩写是广度优先算法。
我们广度优先遍历其实就是一层一层这样,那我们每一层都没有距离,就当做一层是1好了,于是我们在遍历到的时候走过的层数其实就是顶点到它的距离。
还记得我们广度优先算法怎么写的吗?
#define MaxVertexNum 8 //顶点数目的最大值
bool visited[MaxVertexNum]; //访问标记数组
//广度优先遍历
void BFS(Graphic G, int v){ //从顶点v出发,广度优先遍历图G
visit(v); //访问初始顶点v
visited[v] = TRUE; //对v做已访问标记
Enqueue(Q,v); //顶点v入队列Q
while(!isEmpty(Q)){
DeQueue(Q,v); //顶点v出队列
for(w = FirstNeighbor(G,v) ; w >= 0; w = NextNeighbor(G,v,w)){
//检测v所有的邻接点
if(!visited[w]){ //w为v的尚未访问的邻接顶点
visit(w); //访问顶点w
visited[w] = TRUE; //对w做已访问标记
Enqueue(Q,w); //顶点w入队列
}//if
}//for
}//while
}
我们在走到这一层才访问这个顶点,所以我们可以从visit函数做文章。当然在这之前肯定要一个存放距离的数组,并且我们还要知道是怎么走到这里来的,也就是存放它的“来路”。
还是拿这个图为例:
首先我们按照代码流程走,把2号结点入队,那么我们开始访问邻接点,也就是1号和6号结点。访问的时候把路径长度在入队的那个结点的基础上加1,然后更新“来路”的值,再设为已标记,再让该顶点入队。修改visit为:
//求顶点v到其他顶点的最短路径
void BFS_MIN_Distance(Graphic G, int V){ //从顶点v出发,广度优先遍历图G
// visit(v); //访问初始顶点v
for(int i = 0;i< G.vexNum; i++){
d[i] = ∞; //初始化路径长度
path[i] = -1; //最短路径从哪个顶点过来
}
d[v]=0;
visited[v] = TRUE; //对v做已访问标记
Enqueue(Q,v); //顶点v入队列Q
while(!isEmpty(Q)){
DeQueue(Q,v); //顶点v出队列
for(w = FirstNeighbor(G,v) ; w >= 0; w = NextNeighbor(G,v,w)){
//检测v所有的邻接点
if(!visited[w]){ //w为v的尚未访问的邻接顶点
d[w] = d[v] + 1;//路径长度+10
path[w] = v; //最短路径应该从v到w
// visit(w); //访问顶点w
visited[w] = TRUE; //对w做已访问标记
Enqueue(Q,w); //顶点w入队列
}//if
}//for
}//while
}
记得先初始化d和path数组。
那么我们以2号顶点为初始顶点,它的最短距离数组和“来路”就是这个样子:
1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | |
---|---|---|---|---|---|---|---|---|
d[] | ∞ | 0 | ∞ | ∞ | ∞ | ∞ | ∞ | ∞ |
path[] | -1 | -1 | -1 | -1 | -1 | -1 | -1 | -1 |
现在把2号结点入队,那么我们开始访问邻接点,也就是1号和6号结点。访问的时候把路径长度在入队的那个结点的基础上加1,然后更新“来路”的值,再设为已标记,再让该顶点入队,则我们的d和path数组就变为了:
1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | |
---|---|---|---|---|---|---|---|---|
d[] | 1 | 0 | ∞ | ∞ | ∞ | 1 | ∞ | ∞ |
path[] | 2 | -1 | -1 | -1 | -1 | 2 | -1 | -1 |
path值是从哪个结点来的。
…………
以此类推,我们就会得到最终的d和path数组:
1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | |
---|---|---|---|---|---|---|---|---|
d[] | 1 | 0 | 2 | 3 | 2 | 1 | 2 | 3 |
path[] | 2 | -1 | 6 | 3 | 1 | 2 | 6 | 7 |
有了d[]和path[],我们想要找顶点2到其他顶点的最短路径就会很方便。
比如如果我们想要找2到8的最短路径,那就是d[8]也就是3,通过path数组可知,2到8的最短路径为8<—— 7<—— 6<——2
当然这个最短路径也就是我们把这个图化为顶点为2的广度优先生成树时,其他顶点所在的层数。
1.2 Dijkstra算法(带权图、无权图)
Dijkstra算法!Dijkstra算法!Dijkstra算法!
Dijkstra!yyds
这个人超级牛,他提出很多东西,比如
- 提出“goto有害理论”——操作系统の虚拟存储技术
- 信号量机制PV原语——操作系统の进程同步
- 银行家算法——操作系统の死锁
- 解决哲学家进餐问题——操作系统の死锁
- Dijkstra最短路径算法——数据结构
这是真大佬。
下面我们来说下他的Dijkstra算法,首先看下面这个图:
这就会发现BFS算法的局限性,比如我们要找到v0到v1的最短路径,其实可以看出我们最短路径是v0——>v4——>v1,但是用BFS算法就只能从v0——>v1,所以BFS算法只能用于无权图,或者所有边的权值都相同的图。
那么我们Dijkstra算法是怎么求带权图的单源最短路径的呢?其实简单来说,就是先找到到那数值最小的那个点,找到后再看看通过这个点能不能比现在的最短路径要小,小的话就替换,不小就不变,然后再找除去已找到点的到那数值最小的点,找到后再再通过这个点看看能不能比现在的最短路径要小……以此类推,直到全部的点都找完。
当然光靠描述肯定是不行的,我们来一步一步根据上面那个图看Dijkstra算法到底是个什么东西:
首先肯定是需要数组,我们需要三个数组,分别是:
- final[5] ——标记各顶点是否已找到最短路径
- dist[5]——最短路径长度
- path[5]——路径上的前驱
首先我们来初始化这三个数组(假设要找v0到其他点的最短路径)
final[5](标记各顶点是否已找到最短路径):
v0 | v1 | v2 | v3 | v4 |
---|---|---|---|---|
√ | × | × | × | × |
dist[5](最短路径长度):
v0 | v1 | v2 | v3 | v4 |
---|---|---|---|---|
0 | 10 | ∞ | ∞ | 5 |
path[5](路径上的前驱):
v0 | v1 | v2 | v3 | v4 |
---|---|---|---|---|
-1 | 0 | -1 | -1 | 0 |
v0只有从v0到v1和v4的路径,所以只有path[1]和path[4]有值,dist[1]和dist[4]是距离值,dist[0]是自己到自己也就是0,final[0]是TRUE表示已经找到自己到自己最短路径(即dist[0],0)。
现在我们开始第1轮:循环遍历所有结点,找到还没确定最短路径,且dist最小的顶点 vi ,令final[i]=TRUE。
显然我们找到的是v4 ,因为还没确定最短路径的顶点(v1,v2,v3,v4)中,v4 的dist 最小,为dist[4] 是 5。现在我们令final[4] 为 TRUE
final[5](标记各顶点是否已找到最短路径):
v0 | v1 | v2 | v3 | v4 |
---|---|---|---|---|
√ | × | × | × | √ |
检查所有邻接自 vi 的顶点,若其final值为false,则更新 dist 和 path 信息
检查所有邻接自 v4 的顶点且final为false的,我们发现v1,v2,v3都是false,所以我们更新dist和path的信息,看从v4到它们的话路径会不会比从v0到更短(当然从v4到也是从v0开始,v0经过v4到)。
然后我们发现v4——>v3的值为2,加上v0——>v4的dist为5,v0——>v4——>v1——>v1的值是7,比∞小,所以我们更新dist[3]为7,更新path[3]为4;
我们还发现v4——>v2的值为9,加上v0——>v4的dist为5,v0——>v4——>v1的值是14,比∞小,所以我们更新dist[2]为14,更新path[2]为4;
我们又发现v4——>v1的值为2,加上v0——>v4的dist为5,v0——>v4——>v1的值是8,比10小,所以我们更新dist[1]为7,更新path[1]为4;
所以我们的dist数组和path数组为:
dist[5](最短路径长度):
v0 | v1 | v2 | v3 | v4 |
---|---|---|---|---|
0 | 8 | 14 | 7 | 5 |
path[5](路径上的前驱):
v0 | v1 | v2 | v3 | v4 |
---|---|---|---|---|
-1 | 4 | 4 | 4 | 0 |
现在我们开始第2轮:循环遍历所有结点,找到还没确定最短路径,且dist最小的顶点 vi ,令final[i]=TRUE。
显然我们找到的是v3 ,因为还没确定最短路径的顶点(v1,v2,v3)中,v3 的dist 最小,为dist[3] 是 7。现在我们令final[3] 为 TRUE
final[5](标记各顶点是否已找到最短路径):
v0 | v1 | v2 | v3 | v4 |
---|---|---|---|---|
√ | × | × | √ | √ |
检查所有邻接自 vi 的顶点,若其final值为false,则更新 dist 和 path 信息
检查所有邻接自 v3 的顶点且final为false的,我们发现 v2 是false(v1不邻接v3,我感觉其实不看它就是因为无法通过v3到v1,所以路径不会缩短这样子),所以我们更新dist和path的信息,看从v3到它们的话路径会不会比从v0到更短(当然从v3到也是从v0开始,v0经过v4再经过v3到)。
然后我们发现v3——>v2的值为6,加上v0——>v3的dist为7,v0——>v4——>v3——>v1的值是13,比14小,所以我们更新dist[2]为13,更新path[2]为3;
所以我们的dist数组和path数组为:
dist[5](最短路径长度):
v0 | v1 | v2 | v3 | v4 |
---|---|---|---|---|
0 | 8 | 13 | 7 | 5 |
path[5](路径上的前驱):
v0 | v1 | v2 | v3 | v4 |
---|---|---|---|---|
-1 | 4 | 3 | 4 | 0 |
两轮过去了,图在上面不好翻,把图放下来好看点:
现在我们开始第3轮:循环遍历所有结点,找到还没确定最短路径,且dist最小的顶点 vi ,令final[i]=TRUE。
显然我们找到的是v1 ,因为还没确定最短路径的顶点(v1,v2)中,v1 的dist 最小,为dist[1] 是 8。现在我们令final[1] 为 TRUE
final[5](标记各顶点是否已找到最短路径):
v0 | v1 | v2 | v3 | v4 |
---|---|---|---|---|
√ | √ | × | √ | √ |
检查所有邻接自 vi 的顶点,若其final值为false,则更新 dist 和 path 信息
检查所有邻接自 v1 的顶点且final为false的,我们发现 v2 是false,所以我们更新dist和path的信息,看从v1到它们的话路径会不会比从v0到更短(当然从v1到也是从v0开始,v0经过v4再经过v1到)。
然后我们发现v1——>v2的值为1,加上v0——>v1的dist为8,v0——>v4——>v1——>v2的值是9,比13小,所以我们更新dist[2]为9,更新path[2]为1;
所以我们的dist数组和path数组为:
dist[5](最短路径长度):
v0 | v1 | v2 | v3 | v4 |
---|---|---|---|---|
0 | 8 | 9 | 7 | 5 |
path[5](路径上的前驱):
v0 | v1 | v2 | v3 | v4 |
---|---|---|---|---|
-1 | 4 | 1 | 4 | 0 |
再开一轮,胜利的曙光就在前方:
现在我们开始第4轮:循环遍历所有结点,找到还没确定最短路径,且dist最小的顶点 vi ,令final[i]=TRUE。
显然我们找到的是v21 ,因为还没确定最短路径的顶点(v2)中,v2 的dist 最小,为dist[2] 是 9。现在我们令final[1] 为 TRUE
final[5](标记各顶点是否已找到最短路径):
v0 | v1 | v2 | v3 | v4 |
---|---|---|---|---|
√ | √ | √ | √ | √ |
检查所有邻接自 vi 的顶点,若其final值为false,则更新 dist 和 path 信息
检查所有邻接自 v2 的顶点且final为false的,我们发现所有都是TRUE,结束。
*此时可以发现我们的三个数组为:
final[5](标记各顶点是否已找到最短路径):
v0 | v1 | v2 | v3 | v4 |
---|---|---|---|---|
√ | √ | √ | √ | √ |
dist[5](最短路径长度):
v0 | v1 | v2 | v3 | v4 |
---|---|---|---|---|
0 | 8 | 9 | 7 | 5 |
path[5](路径上的前驱):
v0 | v1 | v2 | v3 | v4 |
---|---|---|---|---|
-1 | 4 | 1 | 4 | 0 |
如果我们要找从v0到v2的最短(带权)路径长度,我们怎么找?
首先可以从 dist 数组 中得到,最短路径长度为 dist[2]=9;
接着通过 path 数组可以知道,v0到v2的最短(带权)路径,因为 path[2] 是 1 ,path[1] 是 4 ,path[4] 是 0 ,因为我们是要找 v0 到 其他的路径长度,所以到path 值为0结束,故v0到v2的最短(带权)路径为:v2 <—— v1 <—— v4 <—— v0
好了,现在我们已经会Dijkstra算法了,我们已经和Dijkstra一样牛*了。
那我们要是想用代码实现,步骤是什么呢?
首先就是初始化我们的三个数组了:
如果从v0开始,就令 final[0]=true; dist[0]=0;path[0]=-1;
其余顶点 final[k]=false; dist[k]=arcs[0][k];path[k]=(arcs[0][k] == ∞)?-1:0;
(arcs[i][j]表示vi 到 vj 的弧的权值)
然后开始我们的n-1轮处理:
循环遍历所有顶点,找到还没确定最短路径,且dist最小的顶点vi,令 final[i] = true。
并检查所有邻接自 vi 的顶点,对于邻接自 vi 的顶点 vj ,若 final[j] == false 且 dist[i] + arcs[i][j] < dist[j] ,则令 dist[j] = dist[i] + arcs[i][j]; path[j] = i。
其实就是上述的实现过程,一毛一样的。
那我们Dijkstra算法的时间复杂度是多少呢?我们看一轮的时间,每一轮都是要遍历dist表,还要遍历邻接矩阵表找路径长度,所以我们每一轮都是O(n)+O(n),又因为要n-1轮,所以我们的时间复杂度为O(n2)。
弱弱说一句:有没有发现这个和Prim算法的时间复杂度是一样的,其实这个开数组也和Prim算法异曲同工,我们Prim算法开了个lowCost数组,它是要循环遍历各个顶点,找到lowCost值最低的并且没加入最小生成树的顶点啥的,很像,时间复杂度也是一样的都是O(n2)。
但是!!!Dijkstra算法它不适合用于负权值带权图
!比如下面这张图:
事实上v0到v2的最短带权路径长度为5,但是用Dijkstra算法求出来是7,所以Dijkstra算法它不适合用于负权值带权图。
那其实可能会想,为什么距离还有负值?别忘了我们的权其实代表的是某种抽象的意义,不一定是距离,也可能是消耗的东西,或者存储的东西,或者一些其他的,比如如果权值是你去一个地方消耗的油箱里的油的数量,到v1有个加油站,加了一点油,从v0到v2消耗7个油,从v0到v1消耗10个油,绕远路去加了个油,再从v1到v2,发现比刚到v1的时候还多了5个油,那从v1绕一下其实宏观来看你只消耗了10+(-5)= 5个油,当然举个栗子不严谨,漏洞百出哈,反正就这个意思。
2.各顶点间的最短路径
2.1 Floyd算法(带权图、无权图)
Floyd!他也yyds,这也是个大佬,还提出了堆排序算法(后面排序会说),他和Dijkstra都是图灵奖得主,看了下一个死于2001年(Floyd),一个死于2002年(Dijkstra),先后走的,致敬。
我们 Floyd 算法是求出每一对顶点之间的最短路径,采用的是动态规划思想,将问题的求解分为多个阶段-
插一嘴,什么是动态规划呢?动态规划(Dynamic Programming,简称DP),是一种通过将复杂问题分解为更简单子问题来求解的方法。它特别适用于具有“最优子结构”和“重叠子问题”特性的优化问题。
思想:
最优子结构:问题的最优解包含其子问题的最优解,通过解决子问题,可以构建着整个问题的最优解;
重叠子问题:在分解问题时,许多子问题会被多次计算,动态规划通过记录这些子问题的解,避免重复计算,提高效率。
实现方法:
- 自顶向下(备忘录法)
从问题本身出发,分解为子问题;使用递归或记忆优化记录已解决的子问题,避免重复计算。(典型的就是青蛙跳台阶,就是这个样子。) - 自底向上(迭代法)
从最简单的子问题开始,逐步构建更大的问题的解;使用表格或数组记录子问题的解,逐步填充至整个问题解决。(比如背包问题,还有我们马上要说的这个Floyd算法)
回到正题,刚刚说我们 Floyd 算法是求出每一对顶点之间的最短路径,采用的是动态规划思想,将问题的求解分为多个阶段,那么分为什么阶段呢?
对于n个顶点的图G,求任意一对顶点vi ——> vj 之间的最短路径可分为以下几个阶段:
- 初始:不允许在其他顶点中专,最短路径是?
- #0:若允许在v0 中转,最短路径是?
- #1:若允许在v0 、v1 中转,最短路径是?
- #2:若允许在v0 、v1 、v2 中转,最短路径是?
- …………
- #n-1:若允许在v0 、v1 、v2 、…… vn-1 中转,最短路径是?
现在我们来看一个图,这个图是这样的:
并且我们给出了初始的各顶点最短路径长度表和出事的两个顶点间的中转点表,下面我们按照以下程序来更新这两个表:
若 A(k-1)[i][k] + A(k-1)[k][j] < A(k-1)[i][j] ;(以新的顶点vk为中转点,路径更短)
则 A(k-1)[i][j] = A(k-1)[i][k] + A(k-1)[k][j] ;(更新路径)
path(k)[i][j] = k;(更新中转点)
否则 Ak 和 pathk 保持原值
现在我们来根据这个进行 #0
若允许在 v0 中转,最短路径是?——求A(0)和path(0)
我们来看这个图,当允许在v0 中转时,v1到v2可能会变,v2到v1也可能会变,那我们来先看v1到v2:
A(-1)[1][0
] + A(-1)[0
][2] 为23 > A(-1)[1][2] 为 4 ,遂保持原值不变
我们来再看v2到v1:
A(-1)[2][0
] + A(-1)[0
][1] 为 11< A(-1)[2][1] 为 ∞
所以 A(0)[2][1] = A(-1)[2][0
] + A(-1)[0
][1] = 11
path(0)[2][1] = 0
更新路径值与中转点
所以A0 和 path0 值为:
现在我们来进行 #1
若允许在 v0、v1 中转,最短路径是?——求A(1)和path(1)
我们来看这个图,当允许在v1 中转时,v0到v2可能会变
(我感觉看v几到v几可能会变,就是看以这个顶点为中转点有哪些入边和出边,如果有出入边不就是需要看以这个点为中转点会不会变嘛,所以就方便许多。)
所以我们来看v0到v2:
A(0)[0][1
] + A(0)[1
][2] 为 10< A(0)[0][2] 为 13
所以 A(1)[0][2] = A(0)[0][1
] + A(0)[1
][2] = 10
path(0)[0][2] = 1
更新路径值与中转点
所以A1 和 path1 值为:
现在我们来进行 #2
若允许在 v0、 v1、v2 中转,最短路径是?——求A(2)和path(2)
我们来看这个图,当允许在v2 中转时,v1到v0可能会变
所以我们来看v1到v0:
A(1)[1][2
] + A(1)[2
][0] 为 9< A(1)[1][0] 为 10
所以 A(2)[1][0] = A(1)[1][2
] + A(1)[2
][0] = 9
path(2)[1][0] = 2
更新路径值与中转点
所以A2 和 path2 值为:
所以!从 A(-1) 和 path(-1) 开始,经过n轮递推,得到 A(n-1) 和 path(n-1) (n为顶点个数)
就是上面那个 A(2) 和 path(2)
那我们得到了这两个矩阵(一个最短路径长度矩阵,一个中转点矩阵),我们该怎么看呢?
?举个栗子:如果我们想知道v1到v2的最短路径长度,那么根据A(2)可知,v1到v2的最短路径长度为4,根据path(2)可知,完整路径信息为v1——>v2;如果我们想知道v0到v2的最短路径长度,那么根据A(2)可知,v0到v2的最短路径长度为10,根据path(2)可知,完整路径信息为v1——>v1——>v2;如果我们想知道v1到v0的最短路径长度,那么根据A(2)可知,v1到v0的最短路径长度为9,根据path(2)可知,完整路径信息为v1——>v2——>v0……
但是如果不止三个顶点,我们可能会拐几次才找到路径完整信息。比如v1到v0,我们会发现其实path是2,也就是说v1到v0中间会经过v2,那我们就得看看v1到v2会经过什么,v2到v0会经过什么。v1到v2的path是-1,说明这中间不经过什么;v2到v0的path也是-1,也说明这中间不经过什么。如果path不是-1,接着上述步骤递归求就可以了。
当然我们的代码更加清晰,这是初始图:
//……准备工作,根据图的信息初始化矩阵A和path(如上图)
for(int k = 0; k < n; k ++){ //考虑以vk作为中转点
for(int i = 0; i < n; i ++){ //遍历整个矩阵,i为行号,j为列号
for(int j = 0; j < n; j ++){
if(A[i][k] + A[k][j] < A[i][j]){ //以vk作为中转点的路径更短
A[i][j] = A[i][k] + A[k][j]; //更新最短路径长度
path[i][j] = k; //中转点
}
}
}
}
其实写个这个倒是没有自己手算过程那么麻烦了。
不过我们由代码可以看出,我们Floyd算法
的时间复杂度是O(|V|3),空间复杂度是O(|V|2)
但是!!!它解决了Dijkstra算法无法解决的问题,它可以用于负权值带权图!
(其实想想也能知道,因为它以每一个为顶点中转取最小,所以就算有负值也是可以直接找到最小之后覆盖的)
不过它也有无法做到的,Floyd算法无法解决带有“负权回路”的图(就是有负权值的边组成回路),这种图有可能没有最短路径(如下:)
这样的图就比较特殊。
BFS算法 | Dijkstra算法 | Floyd算法 | |
---|---|---|---|
无权图 | √ | √ | √ |
带权图 | × | √ | √ |
带负权值的图 | × | × | √ |
带负权回路的图 | × | × | × |
时间复杂度 | O(V2) 或O(V+E) | O(V2) | O(V3) |
通常用于 | 求无权图的单源最短路径 | 求带权图的单源最短路径 | 求带权图中各顶点间的最短路径 |
BFS算法时间复杂度的不同是因为可能用邻接矩阵存储,也可能用邻接表存储。如果用邻接矩阵就是O(|V|2),如果用邻接表就是O(|E|)。
注:也可以用
Dijkstra算法
求所有顶点间的最短路径,重复|V|次即可,总的时间复杂度也是O(|V|3)
总结
讲了求最小生成树的Prim算法、Kruskal算法和求最短路径的BFS算法、Dijkstra算法与Floyd算法,主要还是得知道过程。BFS求无权图,Dijkstra是单源带权图,Floyd是多源带权图,这些过程还是有点麻烦的,别搞错就行了。