1.储存
这里仍用一个二维数组来储存顶点之间边的关系。
除此之外,我们还需要一个dis数组来储存1号顶点到其余各个顶点的初始路程。如:dis = {0,1,12,inf,inf,inf}(这里inf是无穷大)
我们将此时dis数组中的值称为最短路径的“估计值”(没有计算过最短路径之前1号顶点到其余各点之间的路程)。
2.松弛
既然是计算1号顶点到其余各点之间的最短路程,那就先找一个离1号顶点最近的顶点。通过数组可知离1号顶点最近的顶点是2号顶点。接下来看2号顶点有哪些出边呢?有2->3和2->4这两条边。先讨论通过2->3这条边能否让1号顶点到3号顶点的路程变短,也就是说现在来比较dis[3]和dis[2] + e[2][3]的大小。这里的dis[3]表示1号顶点到3号顶点的路程;dis[2] + e[2][3]中dis[2] 表示1号顶点到2号顶点的路程;e[2][3]表示2->3这条边。所以dis[2] + e[2][3]就表示从1号顶点到2号顶点,再通过2->3这条边到达3号顶点的路程。
我们发现dis[3] = 12,dis[2] + e[2][3] = 1 + 9 = 10,dis[3] > dis[2] + e[2][3],因此dis[3]要更新为10.这个过程有个专业术语叫做“松弛”,1号顶点到3号顶点的路程,即dis[3]通过2->3这条边松弛成功。
这就是Dijkstra算法的主要思想:通过“边”来松弛1号顶点到其余各个顶点的路程。
3.基本步骤
OK,现在来总结一下刚刚的算法。
**算法的基本思想是:**每次找到离源点(上面例子的源点就是1号顶点)最近的一个顶点,然后以该顶点为中心进行扩散,最终得到源点到其余所有点的最短路径。
基本步骤如下:
1.将所有的顶点分为两部分:已知最短路程的顶点的集合P,和未知最短路径的顶点的集合Q。最开始,已知最短路径的顶点集合P中只有源点一个顶点。我们这里用一个book数组来记录哪些顶点在集合P中。例如,对一个顶点i,如果book[i]为1则表示这个顶点在集合P中,如果book[i]为0则表示这个顶点在集合Q中。
2.设置源点s到自己的最短路径为0,即dis[s] = 0。若存在有源点能直接到达的顶点i,则把dis[i]设为e[s][i]。同时把所有其他(源点不能直接到达的)顶点的最短路径设置为inf(无穷大)。
3.在集合Q的所有顶点中选择一个离源点s最近的顶点u(即dis[u]最小)加入到集合P。并考察所有以u为起点的边,对每一条边进行松弛操作。例如存在一条从u到v的边,那么可以通过将边u->v添加到尾部来拓展一条从s到v的路径,这条路径的长度是dis[u] + e[u][v]。如果这个值比目前已知的dis[v]的值要小,我们可以用新值来替代当前dis[v]中的值。
4.重复第三步,如果集合Q为空,算法结束。最终dis数组中的值就是源点到所有顶点的最短路径。
4.代码
//Dijkstra算法完整代码
#include <stdio.h>
#include <iostream>
#define inf 0x3f3f3f3f
using namespace std;
int main(){
int e[10][10], dis[10], book[10], i, j, n, m, t1, t2, t3, u, v, min;
//读入n,m,n表示顶点个数,m表示边的条数。
cin >> n >> m;
//初始化
for (int i = 1; i <= n; ++i){
for (int j = 1; j <= n; ++j){
if(i == j) e[i][j] = 0;
else e[i][j] = inf;
}
}
//读入边
for (int i = 1; i <= m; ++i){
cin >> t1 >> t2 >> t3;
e[t1][t2] = t3;
}
//初始化dis数组,这里是1号顶点到其余各个顶点的初始路程
for (int i = 1; i <= n; ++i){
dis[i] = e[1][i];
}
//book数组初始化
for (int i = 1; i <= n; ++i){
book[i] = 0;
}
book[1] = 1;
//Dijkstra算法核心语句
for (int i = 1; i <= n - 1; ++i){
min = inf;
for (int j = 1; j <= n; ++j){//选择离源点最近的点u
if(book[j] == 0 && dis[j] < inf){//book[j] == 0语句判断j点是否在集合Q中,如果在集合Q中
min = dis[j];//更新离源点最近的点u
u = j;
}
}
book[u] = 1; //则把它加入集合P
for (int v = 1; v <= n; ++v){//以u为顶点,uv为边开始进行松弛操作
if(e[u][v] < inf){
if(dis[v] > dis[u] + e[u][v]) dis[v] = dis[u] + e[u][v];
}
}
}
//输出最终结果
for (int i = 1; i <= n; ++i){
cout << dis[i] << ' ';
}
return 0;
}
5.优化
通过上面的代码我们可以看出,这个算法的时间复杂度是O(N^2)。其中每次找到离1号顶点最近的顶点的时间复杂度是O(N),这里我们可以用“堆”来优化,使这一部分的时间复杂度降到O(logN)。另外对于边数M少于N2的稀疏图(我们把M远少于N2的图称为稀疏图,M比较大的图称为稠密图),我们可以用
邻接表来代替邻接矩阵,使得整个时间复杂度优化到O(M+N)logN。请注意!在最坏的情况下M就是N^2,这样的话O(M+N)logN要比N的平方还大,但是大多数情况下不会有那么多边,因此O(M+N)logN要比N的平方小很多。
用邻接表储存图的代码:
//用邻接表储存图的代码
int n, m,d i;
//u、v、w 数组的大小要根据实际情况来设置,要比m的最大值大1
int u[6], v[6], w[6];
//first和next的数组大小也要根据实际情况来定,first要比n的最大值大1,next要比m的最大值大1
int first[5], next[6];
cin >> n >> m;
//初始化first数组下标1~n的值为-1,表示1~n顶点暂时没有边
for (int i = 1; i <= n; ++i){
first[i] = -1;
}
for (int i = 1; i <= m; ++i){
cin >> u[i] >> v[i] >> w[i];//读入每一条边
//下面两句是关键
next[i] = firsy[u[i]];
first[u[i]] = i;
}
这种方法为每个顶点i(i从1~n)都设置了一个链表,里面保存了从顶点i出发的所有的边。
首先我们需要给每条边进行1~m的编号。用u,v,w三个数组来记录每条边的信息。即u[i],v[i[,w[i]表示第i条边是从u[i]号顶点到v[i]号顶点(u[i]->v[i]),且权值为w[i]。first数组的1到n号单元格分别用来储存1到n号顶点的第一条边的编号,初始的时候因为没有边加入所以都是“-1”。即first[u[i]]保存顶点u[i]的第一条边的编号,next[i]储存“编号为i的边”的“下一条边”的编号。
图片看邻接表的实现:
读入一条边后:
读入第二条边之后:
读入第三条边后:
读入第四条边后:
读入第五条边后:
如何遍历每一条边呢?我们之前说过其实first数组储存的就是每个顶点i(i从1~n)的每条边。比如1号顶点的第一条边是编号为5的边(1 3 7),2号顶点的第一条边是编号为4的边(2 4 6),3号顶点没有出向边,4号顶点的第一条边是编号为2的边(4 3 8),那么如何遍历1号顶点的每一条边呢?也很简单,请看下图:
在找到1号顶点的第一条边之后,剩下的边都可以在next数组中依次找到。
代码如下:
//遍历1号顶点的所有边
k = first[1];
while(k != -1){
cout << u[k] << v[k] << w[k] << endl;
k = next[k];
}
细心的宝贝会发现,此时遍历某个顶点的边的时候遍历的顺序和读入的顺序正好相反。因为在每个顶点插入边的时候都是直接插入“链表”的首部而不是尾部。
遍历每个顶点的每条边,代码如下:
//遍历每个顶点的所有边
for (int i = 1; i <= n; ++i){
k = first[i];
while(k != -1){
cout << u[k] << v[k] << w[k] << endl;
k = next[k];
}
}
6.总结
这是一个基于贪心策略的算法。每次新扩展一个路程最短的点,更新与其相邻的点的路程。当所有边权都为正时,由于不会存在一个更短的没有扩展过的点,所以这个点的路程永远不会被改变,因而保证了算法的正确性。
不过根据这个原理,求单源最短路径不能有负权边,因为扩展到负权边时,会产生更短的路程,有可能就已经破坏了已经更新的点路程不会改变的性质。