最短路径问题
这是整本书学得我最痛苦的地方…
而最后一章“其他算法”中有些代码已经看不明白了,也先搁置,以后系统学习数据结构和算法时再涉及。下面一起来看看几个精彩的最短路径算法。
Floyd-Warshall——多源最短路径
问题如下:
根据题意要求任意两个城市之间的最短路径,我们称这类问题为“多源最短路径问题”。
解决这类问题的弗洛伊德算法在初看时给我留下了深刻的印象,因为它的核心代码仅仅四行。
for (z = 1; z <= n; z++)
for (i = 1; i <= n; i++)
for (j = 1; j <= n; j++)
if (e[i][j] > e[i][z] + e[z][j])
e[i][j] = e[i][z] + e[z][j];
我们关注最里面的判断条件,翻译一下就是:是否i到z再到j,会比i直接到j的距离更近,如果更近,就更新i到j的距离。
也就是不断的在两点之间插入另一个点,然后判断是否更新最短路径。在z的循环视角下,最后将所有额外的点都试着插入各个两点之间,使得所有点与点之间的路径都经过尝试与判断,于是求得了所有的最短路径。
来看完整代码:
#include <stdio.h>
int main()
{
int n, m;
int e[20][20] = {0};
int book[20] = {0};
int min = 999;
int a, b, c;
int i, j, z;
int start, end;
printf("请输入图的结点数和边数:\n");
scanf("%d %d", &n, &m);
for (i = 1; i <= n; i++)
for (j = 1; j <= n; j++)
{
if (i == j)
e[i][j] = 0;
else
e[i][j] = 999;
}
printf("请输入起始点和终点:\n");
scanf("%d %d", &start, &end);
printf("请输入图中相邻的结点和两者的距离:\n");
for (i = 1; i <= m; i++)
{
scanf("%d %d %d", &a, &b, &c);
e[a][b] = c;
}
for (z = 1; z <= n; z++) //插入中转结点
for (i = 1; i <= n; i++)
for (j = 1; j <= n; j++)
if (e[i][j] > e[i][z] + e[z][j]) //如果i~z~j的距离比直接的i~j短,则更新i~j
e[i][j] = e[i][z] + e[z][j];
//这里e[i][z]+e[z][j],因为e的值本来是要设成正无穷来说明两结点之间没有边的
//严谨的想,这里可能出现设的两个正无穷,可能是999999之类的,相加可能超出int范围,所以我们可以在判断条件上再
//加两条, e[i][z]<inf && e[z][j]<inf 保证i~z和z~j都是有边的,就不用担心超出范围的情况了。
printf("最短路径是%d", e[start][end]);
return 0;
}
虽然弗洛伊德算法的思路很巧妙,很好理解,实现也很简单,但明显它的时间复杂度高的吓人,高达O(N^3)。
需要注意的是,它是能解决“负边权”情况的。可以理解为结点与结点之间的距离为负。但不能存在负权回路——后续会提到,负权回路情况是求不出最短路径的。
Dijkstra(迪杰斯特拉)——单源最短路径
这个算法的名字听起来就很霸气,它是用来解决单源最短路径问题的。即求图中的一个点到其他各点的最短路径。
#include <stdio.h>
int main()
{
int e[10][10], book[10] = {0};
int dis[10] = {0};
int n, m;
int a, b, c;
int i, j;
int inf = 999999; // infinity的缩写,无穷的意思
printf("请输入图的结点数和边数:\n");
scanf("%d %d", &n, &m);
//图的初始化
for (i = 1; i <= n; i++)
for (j = 1; j <= n; j++)
{
if (i == j)
e[i][j] = 0;
else
e[i][j] = inf;
}
printf("请输入有边的两结点和其间的距离:\n");
for (i = 1; i <= m; i++)
{
scanf("%d %d %d", &a, &b, &c);
e[a][b] = c;
}
// dis数组初始化
for (i = 1; i <= n; i++)
dis[i] = e[1][i]; //注意,从此以后,1~其他结点的距离只由dis实时更新,而e[1][i]不准确,因为1~i可能本没有边
//中间因为经过了一些结点中转,才有路可言,所以只需更新dis
book[1] = 1;
int min, u, v;
for (i = 1; i <= n - 1; i++)
{
min = inf;
for (j = 1; j <= n; j++)
{
if (book[j] == 0 && dis[j] < min) //找到最短距离未确定的结点中,目前离起始点最近的结点
{
min = dis[j];
u = j;
}
}
book[u] = 1; //将该点的最短距离定下
for (v = 1; v <= n; v++)
{
if (e[u][v] < inf) //判断如果u与v之间有路,再考虑后面的
{
if (dis[u] + e[u][v] < dis[v]) //由刚刚确定下来的结点往不确定的地方延伸。
{
dis[v] = dis[u] + e[u][v];
}
}
}
}
for (i = 1; i <= n; i++)
{
printf("%d ", dis[i]);
}
return 0;
}
简单分析一下:相比于上一篇博客中提到的图的遍历,Dijkstra算法多了一个dis数组,用来存储特定结点到其他各个结点的距离,初始除了自己到自己,都设成无穷大inf(假设)。待后续一系列“松弛”操作更新所有距离后,可进行打印。
在我看来,Dijkstra其实有点bfs的影子,但它是有选择性的,因为最终目的是最短路径,所以它每一次松弛操作的对象都是离确定点最近的那个结点。
另外,第一个for循环找到离特点结点最近的结点后,直接将它确定(book[u]=1),我们可以发现,Dijkstra的一个局限性——不能处理有负权的情况。因为如果出现负权,book[u]=1的操作就是有误的,它的距离值不能被确定,因为后续遍历会出现源点经过负权边后到达结点的过程可松弛,导致不一定源点直达u结点的距离最短。
- 简单介绍完这个特性后,书中还介绍了用邻接表来存储图信息:
int n, m, i;
int u[6], v[6], w[6];
int first[6], next[6];//first的角标代表结点,存储的是边的序号
//next的角标是边的编号,与first链接
scanf("%d %d", &n, &m);
for (i = 0; i <= n;i++)
first[i] = -1;
for (i = 1; i <= m;i++)//存储边
{
scanf("%d %d %d", &u[i], &v[i], &w[i]);
next[i] = first[u[i]];
first[u[i]] = i;
}
这一段代码并不好懂,简单来说就是给每一个结点都准备了一个链表,链表里存储着以这个结点为起点的所有边。使用邻接表存储图的信息,当我们要访问一个结点的所有边,跟邻接矩阵相比,就变成了一个线性的过程**,时间复杂度从O(N^2)变成了O(M)**,空间复杂度也变小不少。
- 另外,循环中每次找到离特殊结点最近的结点u,我们都是循环n次,不难发现,如果已经有一部分结点被确定,那么遍历中就会存在越来越多的无效操作。这也是可以优化的。即运用到堆结构。这一点在书的最后一章有提到,不过较为复杂,仍是暂时搁置。
Dijkstra算法虽然不能处理负权边,但它有良好的可扩展性,从以上我们能进行堆优化、邻接表存储就能看出来,后续也许有很多题目都会有它的变式延伸。
Bellman-Ford——解决负权边
书中作者评价它:无论是思想上还是代码上都堪称完美的最短路算法。贝尔曼算法既能解决负权边,核心代码还很少。
for (k = 1; k <= n - 1;k++)
for (i = 1; i <= m;i++)
if(dis[v[i]]>dis[u[i]]+w[i])
dis[v[i]] = dis[u[i]] + w[i];
观察它的判断条件,我们很容易联想到Dijkstra的“松弛”操作,确实如此,在我看来它有点像Dijkstra和Floyd的结合,因为外层循环就是在两两节点间遍历式地添加一个点。而k的值,我们可以理解为,从特定结点只能经过k条边,到达其余各顶点。而为什么只用循环n-1次呢,稍微想一想可以得出,总共n个结点,起始结点最多经过n-1条边就能到达任意结点。
概括一下流程只有1句话:对所有的边进行n-1次松弛操作。
完整代码:
#include <stdio.h>
int main()
{
int dis[20];
int i, j;
int n, m;
int u[20];
int v[20];
int w[20];
int inf = 999999;
printf("请输入图的结点数和边数:\n");
scanf("%d %d", &n, &m);
printf("请输入有边的相邻结点序号和边的权数:\n");
for (i = 1; i <= m; i++)
scanf("%d %d %d", &u[i], &v[i], &w[i]);
for (i = 1; i <= n; i++)
dis[i] = inf;
dis[1] = 0;
int check;
for (i = 1; i <= n - 1; i++)
{
check = 0;
for (j = 1; j <= m; j++)
{
if (dis[v[j]] > dis[u[j]] + w[j])
{
dis[v[j]] = dis[u[j]] + w[j];
check = 1;
}
}
if (check == 0)
break;
}
//最短路径中一定不包含回路,因为如果包含正权回路,去掉这个回路路径会更短
//如果包含负权回路的话,一直加入回路路径会越来越短
// for(i=1;i<=m;i++)
// if(dis[v[i]]>dis[u[i]+w[i]])
// flag = 1;
//以上代码可以检验n-1轮松弛后是否还有边可以松弛,如果有,这说明存在负权回路
for (i = 1; i <= n; i++)
printf("%d ", dis[i]);
return 0;
}
和Dijkstra类似,上述代码也有无效操作,存在优化空间,比如存在和起始点相邻的边,不用松弛,却在循环中多次判断,无效松弛,怎么改善呢?
Bellman-Ford 队列优化
书中有一个很好的概括,为什么会出现无效松弛?即每一次松弛后都会有顶点已经求得最短路,要是换作在Dijkstra,是要被book数组标记1的。那这些就是后续不用松弛的,而哪些边需要继续松弛?即最短路径信息dis[]发生变化的顶点的所有出边都要松弛。
简单解释一下:如果有一个顶点(非起始点)到起始点的距离变化了——即它的dis[]数组变化了,那么它的所有出边(即以它为起点的边)都要进行松弛,即再次判断这些出边抵达的点,到起始点的距离是否能够缩短。
如何记录哪些是发生变化的点,怎么样找到与发生变化的点的所有出边?这样我们用到队列来优化,并用邻接表进行存储(这样就能线性找到相关的所有出边)
代码如下:
#include <stdio.h>
int main()
{
int i, j, k;
int n, m;
int first[10], next[10]; // first应该比n大1,next应该比m大1
int u[10], v[10], w[10];
int que[10];
int head = 1, tail = 1;
int book[10] = {0};
int dis[10];
int inf = 99999;
printf("请输入图的结点数和边数:\n");
scanf("%d %d", &n, &m);
for (i = 1; i <= n; i++)//dis数组初始化
dis[i] = inf;
dis[1] = 0;
for (i = 1; i <= n; i++)//邻接表初始化
first[i] = -1;
printf("请输入相邻有边的结点和其边权:\n");
for (i = 1; i <= m; i++)
{
scanf("%d %d %d", &u[i], &v[i], &w[i]);
next[i] = first[u[i]];//将边的信息存进邻接表
first[u[i]] = i;
}
que[tail] = 1;//初始化队列
que[tail] = 1;
tail++;
book[1] = 1;
while (head < tail)
{
k = first[que[head]];
while (k != -1) //只分析当前头结点的每一条边
{
if (dis[v[k]] > dis[u[k]] + w[k])
{
dis[v[k]] = dis[u[k]] + w[k];
if (book[v[k]] == 0)//如果能够松弛,且边所抵达的结点还没确定
{
que[tail] = v[k];//让它入队,进队后,就能再判断它的所有出边了
tail++;
book[v[k]] = 1;
}
}
k = next[k];//找相关结点的下一条出边
}
book[head] = 0;//这步很关键,后续还可能松弛。
head++;//分析完所有出边后,头结点出队
}
for (i = 1; i <= n; i++)
printf("%d ", dis[i]);
}
一定要领会队列优化的关键:只有哪些在前一遍松弛中改变了最短路程的顶点,才可能引起他们邻接点最短路程发生改变。
测试数据:
5 7
1 2 2
1 5 10
2 3 3
2 5 7
3 4 4
4 5 5
5 3 6
运行结果:
0 2 5 9 2
最短路径算法对比
对比前,再补充一个知识——稠密图/稀疏图。稠密和稀疏是通过图的结点数和边数来评判的,规定边数m<结点数n2,为稀疏图,边相对比较少。m>n2的,为稠密图,边相对较多。
为什么要区分这个?因为需要根据不同的情况来运用合适的算法,比如Floyd算法,往两个结点中遍历式插入结点,看看距离是否变短,即算法和结点关系密切,更加适用于稠密图(边多不好处理,通过结点处理),而Bellman算法,边用邻接表存,所有的操作也和松弛边有关,即算法和边关系密切,更加适用于稀疏图(边少,处理起来方便)。
最后用书里章节末尾的对比表总结一下:
每一种算法都各有各的特点,后续做题应该也会碰到它们的各种变式运用,希望能早点灵活地掌握并运用它们。
感谢你能看到这里,希望你有所收获,祝好!