上一篇我们讨论了图的遍历,实际问题中图的深度遍历是我们更常用的,除了图的遍历,我们一般遇到的问题更多是关于图的路径的问题。本篇将介绍图的四种常用遍历算法
一、深度或广度优先搜索算法(解决单源最短路径)
从起始结点开始访问所有的深度遍历路径或广度优先路径,则到达终点结点的路径有多条,取其中路径权值最短的一条则为最短路径。
/***先输入n个结点,m条边,之后输入有向图的m条边,边的前两元素表示起始结点,第三个值表权值,输出1号城市到n号城市的最短距离***/
/***算法的思路是访问所有的深度遍历路径,需要在深度遍历返回时将访问标志置0***/
#include <iostream>
#include <iomanip>
#define nmax 110
#define inf 999999999
using namespace std;
int n, m, minPath, edge[nmax][nmax], mark[nmax];//结点数,边数,最小路径,邻接矩阵,结点访问标记
void dfs(int cur, int dst){
/***operation***/
/***operation***/
if(minPath < dst) return;//当前走过路径大于之前最短路径,没必要再走下去
if(cur == n){//临界条件
if(minPath > dst) minPath = dst;
return;
}
else{
int i;
for(i = 1; i <= n; i++){
if(edge[cur][i] != inf && edge[cur][i] != 0 && mark[i] == 0){
mark[i] = 1;
dfs(i, dst+edge[cur][i]);
mark[i] = 0;
}
}
return;
}
}
int main(){
while(cin >> n >> m && n != 0){
//初始化邻接矩阵
int i, j;
for(i = 1; i <= n; i++){
for(j = 1; j <= n; j++){
edge[i][j] = inf;
}
edge[i][i] = 0;
}
int a, b;
while(m--){
cin >> a >> b;
cin >> edge[a][b];
}
//以dnf(1)为起点开始递归遍历
memset(mark, 0, sizeof(mark));
minPath = inf;
mark[1] = 1;
dfs(1, 0);
cout << minPath << endl;
}
return 0;
}
二、Dijkstra算法(解决单源最短路径)
迪杰斯特拉算法是由荷兰计算机科学家狄克斯特拉于1959 年提出的,因此又叫狄克斯特拉算法。是从一个顶点到其余各顶点的最短路径算法,解决的是有向图中最短路径问题。Dijkstra算法不能处理包含负边(权重为负)的图。
基本思想:每次找到离源点(如1号结点)最近的一个顶点,然后以该顶点为中心进行扩展,最终得到源点到其余所有点的最短路径的一种贪婪算法。
基本步骤:1,设置标记数组book[]:将所有的顶点分为两部分,已知最短路径的顶点集合P和未知最短路径的顶点集合Q,很显然最开始集合P只有源点一个顶点。book[i]为1表示在集合P中;
2,设置最短路径数组dst[]并不断更新:初始状态下,令dst[i] = edge[s][i](s为源点,edge为邻接矩阵),很显然此时dst[s]=0,book[s]=1。此时,在集合Q中可选择一个离源点s最近的顶点u加入到P中。并依据以u为新的中心点,对每一条边进行松弛操作(松弛是指由结点s-->j的途中可以经过点u,并令dst[j]=min{dst[j], dst[u]+edge[u][j]}),并令book[u]=1;
3,在集合Q中再次选择一个离源点s最近的顶点v加入到P中。并依据v为新的中心点,对每一条边进行松弛操作(即dst[j]=min{dst[j], dst[v]+edge[v][j]}),并令book[v]=1;
4,重复3,直至集合Q为空。
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Dijkstra算法实现,有向图和源点作为函数的输入,(源点到图中其余点的)最短路径最为输出
"""
def dijkstra(graph,src):
if graph is None:
return None # 判断图是否为空,如果为空直接退出
nodes = [i for i in range(len(graph))] # 获取图中所有节点
visited = [] # 表示已经路由到最短路径的节点集合
if src in nodes:
visited.append(src)
nodes.remove(src)
else:
return None
distance = {src: 0} # 记录源节点到各个节点的距离
for i in nodes:
distance[i] = graph[src][i] # 初始化
path = {src: {src: []}} # 记录源节点到每个节点的路径
k = pre = src
while nodes:
mid_distance = float('inf')
for v in visited:
for d in nodes:
new_distance = graph[src][v] + graph[v][d]
if new_distance < mid_distance:
mid_distance = new_distance
graph[src][d] = new_distance # 进行距离更新
k = d
pre = v
distance[k] = mid_distance # 最短路径
path[src][k] = [i for i in path[src][pre]]
path[src][k].append(k)
# 更新两个节点集合
visited.append(k)
nodes.remove(k)
print(visited, nodes) # 输出节点的添加过程
return distance, path
if __name__ == '__main__':
# graph_list = [[0, 2, 1, 4, 5, 1],
# [1, 0, 4, 2, 3, 4],
# [2, 1, 0, 1, 2, 4],
# [3, 5, 2, 0, 3, 3],
# [2, 4, 3, 4, 0, 1],
# [3, 4, 7, 3, 1, 0]]
graph_list = [[0, 20, 50, 30, 999, 999, 999],
[999, 0, 25, 999, 999, 70, 999],
[999, 999, 0, 40, 25, 50, 999],
[999, 999, 999, 0, 55, 999, 999],
[999, 999, 999, 999, 0, 10, 70],
[999, 999, 999, 999, 999, 0, 50],
[999, 999, 999, 999, 999, 999, 0]]
distance, path = dijkstra(graph_list, 0) # 查找从源点0开始带其他节点的最短路径
print('源点到图中各点的距离:',distance,'\n源点到图中各点最短路径为:',path)
用Dijkstra算法找出以A为起点的单源最短路径步骤如下
如果是稠密图(边比点多),则直接扫描所有未收录顶点比较好,即第一种方法,每次O(V),总体算法复杂度T=O(V^2+E)
如果是稀疏图(边比点少),则使用优先队列(最小堆)比较好,即第二种方法,每次O(logV),插入更新后的dist,O(logV)。总体算法复杂度T=O(VlogV+ElogE)
当然还有更加优秀的斐波那契堆,时间复杂度为 O(e+vlogv)
无权值(或者权值相等)的单源点最短路径问题,Dijkstra算法退化成BFS广度优先搜索。
那么,为什么BFS会比Dijkstra在这类问题上表现得更加好呢?
1. BFS使用FIFO的队列来代替Dijkstra中的优先队列(或者heap之类的)。
2. BFS不需要在每次选取最小结点时判断其他结点是否有更优的路径。
BFS的时间复杂度为O(v+e)
三、Floyd算法(解决多源最短路径)
Dij算法只能得出源点到其余点的最短路径,有一些是我们不需要的,如何得到任意两点的最短路径?
Floyd算法是一个经典的动态规划算法。
算法思想:如果说两个点之间的直接路径不是最短路径的话,必然有一个或者多个点供中转,使其路径最短。
从任意节点i到任意节点j的最短路径不外乎2种可能,1是直接从i到j,2是从i经过若干个节点k到j。所以,我们假设Dis(i,j)为节点u到节点v的最短路径的距离,对于每一个节点k,我们检查Dis(i,k) + Dis(k,j) < Dis(i,j)是否成立,如果成立,证明从i到k再到j的路径比i直接到j的路径短,我们便设置Dis(i,j) = Dis(i,k) + Dis(k,j),这样一来,当我们遍历完所有节点k,Dis(i,j)中记录的便是i到j的最短路径的距离。
其实核心思想是三角不等式:
如果我要求顶点A到顶点B之间的距离的话,我可以先找一个顶点C,求解顶点A到顶点C加上顶点C到顶点B的距离和,如果这个距离和小于顶点A直接到顶点B的距离的话,那么这个时候就要更新一下距离矩阵中的值,将顶点A到顶点B的距离更新为:顶点A到顶点C加上顶点C到顶点B的距离和。这就是Folyd的核心思想了。
算法描述:
a.从任意一条单边路径开始。所有两点之间的距离是边的权,如果两点之间没有边相连,则权为无穷大。
b.对于每一对顶点 u 和 v,看看是否存在一个顶点 w 使得从 u 到 w 再到 v 比己知的路径更短。如果是更新它。
那么如果要找到全局最优的解就要在选取中间顶点的过程中遍历所有的节点才行,这样就是三层for循环的结构了,得到的时间复杂度就是O(n*n*n),空间复杂度O(n^2),n为顶点的规模,其实在具体实际的应用中,这个算法的性能还是很不错,可以正确处理有向图或负权的最短路径问题,同时也被用于计算有向图的传递闭包。
用一个数组来存储任意两个点之间的距离,注意,这里可以是有向的,也可以是无向的。
当任意两点之间不允许经过第三个点时,这些城市之间最短路程就是初始路程。
分析如下:1,首先构建邻接矩阵Floyd[n+1][n+1],假如现在只允许经过1号结点,求任意两点间的最短路程,很显然Floyd[i][j] = min{Floyd[i][j], Floyd[i][1]+Floyd[1][j]},代码如下:
for(i = 1; i <= n; i++){
for(j = 1; j <= n; j++){
if(Floyd[i][j] > Floyd[i][1] + Floyd[1][j])
Floyd[i][j] = Floyd[i][1] + Floyd[1][j];
}
}
只需判断Floyd[i][1]+Floyd[1][j]是否比Floyd[i][j]要小即可。Floyd[i][j]表示的是从i号顶点到j号顶点之间的路程。Floyd[i][1]+Floyd[1][j]表示的是从i号顶点先到1号顶点,再从1号顶点到j号顶点的路程之和。其中i是1~n循环,j也是1~n循环
在只允许经过1号顶点的情况下,任意两点之间的最短路程更新为:
通过上图我们发现:在只通过1号顶点中转的情况下,3号顶点到2号顶点(Floyd[3][2])、4号顶点到2号顶点(Floyd[4][2])以及4号顶点到3号顶点(Floyd[4][3])的路程都变短了。
2,接下来继续求在只允许经过1和2号两个顶点的情况下任意两点之间的最短距离,在已经实现了从i号顶点到j号顶点只经过前1号点的最短路程的前提下,现在再插入第2号结点,来看看能不能更新更短路径,我们需要在只允许经过1号顶点时任意两点的最短路程的结果下,再判断如果经过2号顶点是否可以使得i号顶点到j号顶点之间的路程变得更短。即判断Floyd[i][2]+Floyd[2][j]是否比Floyd[i][j]要小。在只允许经过1和2号顶点的情况下,任意两点之间的最短路程更新为:
通过上图得知,在相比只允许通过1号顶点进行中转的情况下,这里允许通过1和2号顶点进行中转,使得Floyd[1][3]和Floyd[4][3]的路程变得更短了。
3,很显然,需要n次这样的更新,表示依次插入了1号,2号......n号结点,最后求得的Floyd[n+1][n+1]是从i号顶点到j号顶点只经过前n号点的最短路程。
故核心代码如下:
#define inf 99999999
for(k = 1; k <= n; k++){
for(i = 1; i <= n; i++){
for(j = 1; j <= n; j++){
if(Floyd[i][k] < inf && Floyd[k][j] < inf && Floyd[i][j] > Floyd[i][k] + Floyd[k][j])
Floyd[i][j] = Floyd[i][k] + Floyd[k][j];
}
}
}
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
# 算法思想:
# 每个顶点都有可能使得两个顶点之间的距离变短
# 当两点之间不允许有第三个点时,这些城市之间的最短路径就是初始路径
"""
# 城市地图(字典的字典)
# 字典的第1个键为起点城市,第2个键为目标城市其键值为两个城市间的直接距离
# 将不相连点设为INF,方便更新两点之间的最小值
INF = 99999
G = {1: {1: 0, 2: 2, 3: 6, 4: 4},
2: {1: INF, 2: 0, 3: 3, 4: INF},
3: {1: 7, 2: INF, 3: 0, 4: 1},
4: {1: 5, 2: INF, 3: 12, 4: 0}
}
# Floyd-Warshall算法核心语句
# 分别在只允许经过某个点k的情况下,更新点和点之间的最短路径
for k in G.keys(): # 不断试图往两点i,j之间添加新的点k,更新最短距离
for i in G.keys():
for j in G[i].keys():
if G[i][j] > G[i][k] + G[k][j]:
G[i][j] = G[i][k] + G[k][j]
if __name__ == '__main__':
for i in G.keys():
print(G[i].values())
四、Bellman-Ford算法(解决负权边,解决单源最短路径)
上面这种思路不可行,下图即为证。Dij无法解决负权图。如果两个顶点之间的距离为正数,那这个距离成为正权。反之,如果一个顶点到一个顶点的距离为负数,那这个距离就称为负权。Bellman-Ford算法的时间复杂度是O(MN),但是我们依然可以对这个算法进行优化,在实际使用中,我们常常会发现不用循环到N-1次就能求出最短路径,所以我们可以比较前后两次松弛结果,若果两次结果都一致,可说明松弛完成,不用再继续循环了。
Bellman-Ford与Dijkstra的区别
Bellman-Ford和Dijkstra 相似,都是采用‘松弛’的方法来寻找最短的距离。
Bellman-Ford可以用于含有负权的图中而Dijkstra不可以。
为什么Dijkstra不可以?其跟本的原因在于,在Dijkstra,一旦一个顶点用来松弛过以后,其最小值已经固定不会再参与到下一次的松弛中。因为Dijkstra中全部的距离都是正权,所以不可能出现A - B - C 之间的距离比 A - B - D - C 的距离短的情况,而Bellman-Ford则在每次循环中,则会将每个点都重新松弛一遍,所以可以处理负权。
主要思想:第一,初始化所有点。每一个点保存一个值,表示从原点到达这个点的距离,将原点的值设为0,其它的点的值设为无穷大(表示不可达)。
第二,进行循环,循环下标为从1到n-1(n等于图中点的个数)。在循环内部,遍历所有的边,进行松弛计算。
第三,遍历途中所有的边(edge(u,v)),判断是否存在这样情况:
d(v) > d (u) + w(u,v)
则返回false,表示途中存在从源点可达的权为负的回路。
定理:
在一个含有n个顶点的图中,任意两点之间的最短路径最多包含n-1条边最短路径肯定是一个不包含回路的简单路径(回路包括正权回路与负权回路)
1. 如果最短路径中包含正权回路,则去掉这个回路,一定可以得到更短的路径
2. 如果最短路径中包含负权回路,则每多走一次这个回路,路径更短,则不存在最短路径
因此最短路径肯定是一个不包含回路的简单路径,即最多包含n-1条边,所以进行n-1次松弛即可
Bellman_Ford还可以检测一个图是否含有负权回路:如果在进行n-1轮松弛后仍然存在dst[e[i]] > dst[s[i]]+w[i]。
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
BellmanFord算法
"""
G = {1: {1: 0, 2: -3, 5: 5},
2: {2: 0, 3: 2},
3: {3: 0, 4: 3},
4: {4: 0, 5: 2},
5: {5: 0}}
def getEdges(G):
""" 读入图G,返回其边与端点的列表 """
v1 = [] # 出发点
v2 = [] # 对应的相邻到达点
w = [] # 顶点v1到顶点v2的边的权值
for i in G:
for j in G[i]:
if G[i][j] != 0:
w.append(G[i][j])
v1.append(i)
v2.append(j)
return v1, v2, w
def Bellman_Ford(G, v0, INF=999):
v1, v2, w = getEdges(G)
# 初始化源点与所有点之间的最短距离
dis = dict((k, INF) for k in G.keys())
dis[v0] = 0
# 核心算法
for k in range(len(G) - 1): # 循环 n-1轮
check = 0 # 用于标记本轮松弛中dis是否发生更新
for i in range(len(w)): # 对每条边进行一次松弛操作
if dis[v1[i]] + w[i] < dis[v2[i]]:
dis[v2[i]] = dis[v1[i]] + w[i]
check = 1
if check == 0: break
# 检测负权回路
# 如果在 n-1 次松弛之后,最短路径依然发生变化,则该图必然存在负权回路
flag = 0
for i in range(len(w)): # 对每条边再尝试进行一次松弛操作
if dis[v1[i]] + w[i] < dis[v2[i]]:
flag = 1
break
if flag == 1:
# raise CycleError()
return False
return dis
if __name__ == '__main__':
v0 = 1
dis = Bellman_Ford(G, v0)
print('源点到图中各点的最短距离(含负权点)', dis.values())
五、SPSASPFA算法(Shortest Path Faster Algorithm单源最短路径)
很多时候,给定的图存在负权边,这时类似Dijkstra等算法便没有了用武之地,而Bellman-Ford算法的复杂度又过高,SPFA算法便派上用场了。有人称spfa算法是最短路的万能算法。
算法思想
设立一个队列q用来保存待优化的结点,优化时每次取出队首结点u,并且用u点当前的最短路径估计值对离开u点所指向的结点v进行松弛操作,如果v点的最短路径估计值有所调整,且v点不在当前的队列中,就将v点放入队尾。这样不断从队列中取出结点来进行松弛操作,直至队列空为止。
松弛操作的原理是著名的定理:“三角形两边之和大于第三边”,在信息学中我们叫它三角不等式。所谓对结点i,j进行松弛,就是判定是否dis[j]>dis[i]+w[i,j],如果该式成立则将dis[j]减小到dis[i]+w[i,j],否则不动。
下面举一个实例来说明SFFA算法是怎样进行的:
和BFS的区别
SPFA在形式上和广度优先搜索非常类似,不同的是BFS中一个点出了队列就不可能重新进入队列,但是SPFA中一个点可能在出队列之后再次被放入队列,也就是一个点改进过其它的点之后,过了一段时间可能本身被改进(重新入队),于是再次用来改进其它的点,这样反复迭代下去。
int main() {
int n,m,s,t;//分别是节点数、边的条数、起点、终点
while(cin>>n>>m>>s>>t) {
vector<vector<int>> edge(n+1,vector<int>(n+1,0));//邻接矩阵
vector<int> dis(n+1,INT_MAX);//从起点出发的最短路径
vector<int> book(n+1,0);//某结点在队列中
queue<int> q;
for(int i=1;i<=n;i++)//初始化邻接矩阵
for(int j=1;j<=n;j++)
if(i!=j) edge[i][j]=INT_MAX;
int u,v,length;
for(int i=0;i<m;i++) {//读入每条边,完善邻接矩阵
cin>>u>>v>>length;
if(length<edge[u][v]) {//如果当前的边长比已有的短,则更新邻接矩阵
edge[u][v]=length;
edge[v][u]=length;
}
}
dis[s]=0;
book[s]=1;//把起点先放入队列
q.push(s);
int top;
while(!q.empty()) {//如果队列非空
top=q.front();//取出队首元素
q.pop();
book[top]=0;//释放队首结点,因为这节点可能下次用来松弛其它节点,重新入队
for(int i=1;i<=n;i++) {
if(edge[top][i]!=INT_MAX && dis[i]>dis[top]+edge[top][i]) {
dis[i]= dis[top]+edge[top][i]; //更新最短路径
if(book[i]==0) { //如果扩展结点i不在队列中,入队
book[i]=1;
q.push(i);
}
}
}
}
cout<<dis[t]<<endl;
}
return 0;
}
以上算法的比较
适用情况
dj和ford算法用于解决单源最短路径,而floyd算法解决多源最短路径。
dj适用稠密图(邻接矩阵),因为稠密图问题与顶点关系密切;
ford算法适用稀疏图(邻接表),因为稀疏图问题与边关系密切。
floyd在稠密图(邻接矩阵)和稀疏图(邻接表)中都可以使用;
PS:dj算法虽然一般用邻接矩阵实现,但也可以用邻接表实现,只不过比较繁琐。而ford算法只能用邻接表实现。
dj算法不能解决含有负权边的图;
而Floyd算法和Ford算法可以解决含有负权边的问题,但都要求没有总和小于0的负权环路。
SPFA算法可以解决负权边的问题,而且复杂度比Ford低。形式上,它可以看做是dj算法和BFS算法的结合。
以上算法都是既能处理无向图问题,也能处理有向图问题。因为无向图只是有向图的特例,它们只是在邻接矩阵或邻接表的初始化时有所区别。
实际项目中用到的是带启发式的A*算法,将来再介绍。