单源最短路径问题 (Single Source Shortest Path, SSSP问题) 是说,给定一张有向图 G = ( V , E ) G=(V,E) G=(V,E), V V V 是点集, E E E 是边集, ∣ V ∣ = n |V|=n ∣V∣=n, ∣ E ∣ = m |E|=m ∣E∣=m,节点以 [ 1 , n ] [1,n] [1,n] 之间的连续整数编号, ( x , y , z ) (x,y,z) (x,y,z) 描述一条从 x x x 出发,到达 y y y,长度为 z z z 的有向边。设 1 1 1 号点为起点,求长度为 n n n 的数组 d i s t dist dist,其中 d i s t [ i ] dist[i] dist[i] 表示从起点 1 1 1 到节点 i i i 的最短路径的长度。
Dijkstra 算法(可读作迪杰斯特拉算法)只能应对所有边权都是非负数的情况,如果边权出现负数,那么 Dijkstra 算法很可能会出错,这时最好使用SPFA算法。
Dijkstra算法的流程如下:
Dijkstra 算法基于贪心思想,它只适用于所有边的长度都是非负数的图。当边长 z z z 都是非负数时,全局最小值不可能再被其他节点更新,故在第 2 2 2 步中选出的节点 x x x 必然满足: d i s t [ x ] dist[x] dist[x] 已经是起点到 x x x 的最短路径。我们不断选择全局最小值进行标记和扩展,最终可得到起点 1 1 1 到每个节点的最短路径的长度。
将点分为两类,一类是已确定最短路径的点,称为:白点;一类是未确定最短路径的点,称为:蓝点。
求一个点的最短路径,就是把这个点由蓝点变为白点,从起点到蓝点的最短路径上的中转点在这个时刻只能是白点。
Dijkstra 算法的思想,就是一开始将起点到终点的距离标记为 0 0 0,而后进行 n n n 次循环,每次找出一个到起点距离 d i s [ u ] dis[u] dis[u] 最短的点 u u u ,将它从蓝点变为白点,随后枚举所有白点 V i V_i Vi,如果以此白点为中转到达蓝点 V i V_i Vi 的路径 d i s [ u ] + w [ u ] [ v i ] dis[u]+w[u][v_i] dis[u]+w[u][vi] 更短的话,这将它作为 V i V_i Vi 的更短路径(此时还不能确定是不是 V i V_i Vi 的最短路径)。
以此类推,每找到一个白点,就尝试用它修改其他所有蓝点,中转点先于终点变成白点,故每一个终点一定能被它的最后一个中转点所修改,从而求得最短路径。
以下图为例
算法开始时,作为起点的 d i s [ 1 ] = 0 dis[1]=0 dis[1]=0,其他的点 d i s [ i ] = 0 x 3 f 3 f 3 f 3 f dis[i]=0x3f3f3f3f dis[i]=0x3f3f3f3f
第一轮循环找到 d i s [ 1 ] dis[1] dis[1] 最小,将 1 1 1 变为白点,对所有蓝点进行修改,使得: d i s [ 2 ] = 2 , d i s [ 3 ] = 4 , d i s [ 4 ] = 7 dis[2]=2,dis[3]=4,dis[4]=7 dis[2]=2,dis[3]=4,dis[4]=7
此时, d i s [ 2 ] 、 d i s [ 3 ] 、 d i s [ 4 ] dis[2]、dis[3]、dis[4] dis[2]、dis[3]、dis[4] 被它的最后一个中转点 1 1 1 修改了最短路径。
第二轮循环找到 d i s [ 2 ] dis[2] dis[2] 最小,将 2 2 2 变成白点,对所有蓝点进行修改,使得: d i s [ 3 ] = 3 、 d i s [ 5 ] = 4 dis[3]=3、dis[5]=4 dis[3]=3、dis[5]=4
此时, d i s [ 3 ] 、 d i s [ 5 ] dis[3]、dis[5] dis[3]、dis[5] 被它的最后一个中转点 2 2 2 修改了最短路径。
第三轮循环找到 d i s [ 3 ] dis[3] dis[3] 最小,将 3 3 3 变成白点,对所有蓝点进行修改,使得: d i s [ 4 ] = 4 dis[4]=4 dis[4]=4。
此时, d i s [ 4 ] dis[4] dis[4] 被它的最后一个中转点 3 3 3 修改了最短路径,但发现以 3 3 3 为中转不能修改 5 5 5,说明 3 3 3 不是 5 5 5 的最后一个中转点。
接下来两轮循环将 4 、 5 4、5 4、5 也变成白点。
N轮循环结束,所有点的最短路径均可求出。
对于无向图,可以把无向边看作两条方向相反的有向边,因此,只需重点关注有向图的最短路即可。
(1)朴素Dijkstra算法
时间复杂度是 O ( n 2 ) O(n^2) O(n2), n n n 表示点数, m m m 表示边数
适用于稠密图,用邻接矩阵存储。
注意处理重边。
int g[N][N]; // 存储每条边
int dist[N]; // 存储1号点到每个点的最短距离
bool st[N]; // 存储每个点的最短路是否已经确定
// 求1号点到n号点的最短路,如果不存在则返回-1
int dijkstra()
{
memset(dist, 0x3f, sizeof dist);
dist[1] = 0;
for (int i = 0; i < n - 1; i ++ )
{
int t = -1; // 在还未确定最短路的点中,寻找距离最小的点
for (int j = 1; j <= n; j ++ )
if (!st[j] && (t == -1 || dist[t] > dist[j]))
t = j;
// 用t更新其他点的距离
for (int j = 1; j <= n; j ++ )
dist[j] = min(dist[j], dist[t] + g[t][j]);
st[t] = true;
}
if (dist[n] == 0x3f3f3f3f) return -1;
return dist[n];
}
(2)堆优化版的 Dijkstra 算法
外层循环的 O ( V ) O(V) O(V) 时间无法避免,但是寻找最小 d [ u ] d[u] d[u] 的过程使用堆优化来降低复杂度。
时间复杂度 O ( m l o g n ) O(mlogn) O(mlogn), n n n 表示点数, m m m 表示边数
适用于稀疏图,用邻接表存储。用邻接表存储就不需要对重边进行特殊处理了
typedef pair<int, int> PII;
int n; // 点的数量
int h[N], w[N], e[N], ne[N], idx; // 邻接表存储所有边
int dist[N]; // 存储所有点到1号点的距离
bool st[N]; // 存储每个点的最短距离是否已确定
// 求1号点到n号点的最短距离,如果不存在,则返回-1
int dijkstra()
{
memset(dist, 0x3f, sizeof dist);
dist[1] = 0;
priority_queue<PII, vector<PII>, greater<PII>> heap;
heap.push({0, 1}); // first存储距离,second存储节点编号
while (heap.size())
{
auto t = heap.top();
heap.pop();
int ver = t.second, distance = t.first;
if (st[ver]) continue;
st[ver] = true;
for (int i = h[ver]; i != -1; i = ne[i])
{
int j = e[i];
if (dist[j] > distance + w[i])
{
dist[j] = distance + w[i];
heap.push({dist[j], j});
}
}
}
if (dist[n] == 0x3f3f3f3f) return -1;
return dist[n];
}
(3)输出路径
“以 u u u 为中介点可以使起点 s s s 到顶点 v v v 的最短距离 d [ v ] d[v] d[v] 更优”,隐含了这样一层意思:使 d [ v ] d[v] d[v] 变得更小的方案是让 u u u 作为从 s s s 到 v v v 最短路径上 v v v 的前一个结点。
不妨把这个前驱信息记录下来,于是,可以设置数组 p r e [ ] pre[\ ] pre[ ],令 p r e [ v ] pre[v] pre[v] 表示从起点 s s s 到顶点 v v v 的最短路径上 v v v 的前一个顶点的编号。
以邻接矩阵为例:
int n, G[MAXV][MAXV]; // n为顶点数,MAXV为最大顶点数
int d[MAXV]; // 起点到达各点的最短路径长度
int pre[MAXV]; // pre[v]表示从起点到顶点v的最短路径上v的前一个顶点
bool vis[MAXV] = {false}; // 标记数组, vis[i]==true表示已访问。初值均为false
void Dijkstra(int s) // s为起点
{
fill(d, d + MAXV, INF); // fill函数将整个d数组赋为INF
for(int i = 0; i < n; i++)
pre[i] = i; // 初始状态设每个点的前驱为自身(新添加)
d[s] = 0; // 起点s到达自身的距离为0
for(int i = 0; i < n; i++) // 循环n次
{
int u = -1, MIN = INF; // u使d[u]最小,MIN存放该最小的d[u]
for(int j = 0;j < n; j++) // 找到未访间的顶点中d[]最小的
{
if(vis[j] == false && d[j] < MIN)
{
u = j;
MIN = d[j];
}
}
if(u == -1) // 找不到小于INF的d[u],说明剩下的顶点和起点s不连通
return;
vis[u] = true; // 标记 u为已访问
for(int v = 0; v < n; v++)
{
//如果v未访问 && u能到达v &&以u为中介点可以使d[v]更优
if(vis[v] == false && G[u][v] != INF && d[u] + G[u][v] < d[v])
{
d[v] = d[u] + G[u][v]; // 优化d[v]
pre[v] = u; // 记录v的前驱顶点是u
}
}
}
}
// 然后用递归不断利用 pre[] 的信息寻找前驱,直至到达起点后从递归深处开始输出
void DFS(int s, int v) // s为起点编号,v为当前访问的顶点编号(从终点开始递归)
{
if(v == s) // 如果当前已经到达起点s, 则输出起点并返回
{
printf("%d\n", s);
return;
}
DFS(s, pre[v]); // 递归访问v的前驱顶点pre[v]
printf("%d\n", v); // 从最深处return回来之后,输出每一层的顶点号
}
Bellman-Ford 算法可解决单源最短路径问题,能处理有负权边的情况,但无法处理存在负权回路的情况。
根据环中边的边权之和的正负,可以将环分为零环、正环、负环。
显然,图中的零环和正环不会影响最短路径的求解,因为零环和正环的存在不能使最短路径更短;
而如果图中有负环,且从源点可以到达,那么就会影响最短路径的求解;但如果图中的负环无法从源点出发到达,则最短路径的求解不会受到影响。
Bellman-Ford算法的时间复杂度是 O ( V E ) O(VE) O(VE),其中 V V V 是顶点个数, E E E 是边数。
给定一张有向图, 若对于图中的某一条边 ( x , y , z ) (x,y,z) (x,y,z),有 d i s t [ y ] ≤ d i s t [ x ] + z dist[y] \leq dist[x]+z dist[y]≤dist[x]+z 成立,则称该边满足三角形不等式。若所有边都满足三角形不等式,则 d i s t dist dist 数组就是所求最短路。
基于迭代思想的Bellman-Ford算法,它的流程如下:
(1)邻接表
由于 Bellman-Ford 算法需要遍历所有边,显然使用邻接表会比较方便;如果使用邻接矩阵,则时间复杂度会上升到 O ( V 3 ) O(V^3) O(V3)。
struct Node {
int v, dis; // v为邻接边的目标顶点,dis为邻接边的边权
};
vector<Node> Adj[MAXV]; // 图G的邻接表
int n; // n为顶点数, MAXV为最大顶点数
int d[MAXV]; // 起点到达各点的最短路径长度
bool Bellman(int s) // s为源点
{
fill(d, d + MAXV, INF); // fill函数将整个d数组赋为INF (慎用memset)
d[s] = 0; // 起点s到达自身的距离为0
// 以下为求解数组d的部分
for(int i = 0; i < n - 1; i++) // 执行n-1轮操作,n为顶点数
{
for{int u = 0; u < n; u++) // 每轮操作都遍历所有边
{
for(int j = 0; j < Adj[u].size(); j++)
{
int v = Adj[u][j].v; // 邻接边的顶点
int dis = Adj[u][j].dis; // 邻接边的边权
if(d[u] + dis < d[v]) // 以u为中介点可以使d[v]更小
{
d[v] = d[u] + dis; // 松弛操作
}
}
}
}
// 以下为判断负环的代码
for(int u = 0; u < n; u++) // 对每条边进行判断
{
for(int j = 0; j < Adj[u].size(); j++)
{
int v = Adj[u][j].v; // 邻接边的顶点
int dis = Adj[u][j].dis; // 邻接边的边权
if(d[u] + dis < d[v]) // 如果仍可以被松驰
{
return false; // 说明图中有从源点可达的负环
}
}
}
return true; // 数组d的所有值都已经达到最优
}
(2)不需要用邻接表存储,开一个结构体数组存储所有的边即可。
时间复杂度 O ( n m ) O(nm) O(nm), n n n 表示点数, m m m 表示边数
int n, m; // n表示点数,m表示边数
int dist[N]; // dist[x]存储1到x的最短路距离
struct Edge // 边,a表示出点,b表示入点,w表示边的权重
{
int a, b, w;
}edges[M];
// 求1到n的最短路距离,如果无法从1走到n,则返回-1。
int bellman_ford()
{
memset(dist, 0x3f, sizeof dist);
dist[1] = 0;
// 如果第n次迭代仍然会松弛三角不等式,就说明存在一条长度是n+1的最短路径,
// 由抽屉原理,路径中至少存在两个相同的点,说明图中存在负权回路。
for (int i = 0; i < n; i ++ )
{
for (int j = 0; j < m; j ++ )
{
int a = edges[j].a, b = edges[j].b, w = edges[j].w;
if (dist[b] > dist[a] + w)
dist[b] = dist[a] + w;
}
}
if (dist[n] > 0x3f3f3f3f / 2) return -1;
return dist[n];
}
SPFA (Shortest Path Faster Algrithm) 算法在国际上通称为“队列优化的Bellman-Ford算法”,仅在中国大陆地区流行“SPFA算法”的称谓。
基本原理:由 Bellman-Ford 算法中 d i s t [ b ] = m i n ( d i s t [ b ] , d i s t [ a ] + w [ i ] ) dist[b] = min(dist[b], dist[a]+w[i]) dist[b]=min(dist[b],dist[a]+w[i]) 可知,只有 a a a 变小了, b b b 才能变小,因此可以用一个队列存储变小了的节点。
SPFA 算法的流程如下:
在任意时刻,该算法的队列都保存了待扩展的节点。每次入队相当于完成一次 d i s t dist dist 数组的更新操作,使其满足三角形不等式。一个节点可能会入队、出队多次。最终,图中节点收敛到全部满足三角形不等式的状态。
这个队列避免了 Bellman-Ford 算法中对不需要扩展的节点的冗余扫描,在稀疏图上运行效率较高,为 O ( k m ) O(km) O(km)级别,其中 k k k 是一个较小的常数。但在稠密图或特殊构造的网格图上,该算法仍可能退化为 O ( n m ) O(nm) O(nm)。
(1)邻接表
// 如果事先知道图中不会有环,那么 num 数组的部分可以去掉
vector<Node> Adj[MAXV]; // 图G的邻接表
int n, d[MAXV], num[MAXV]; // num数组记录顶点的入队次数
bool inq[MAXV]; // 顶点是否在队列中
bool SPFA(int s)
{
memset(inq, false, sizeof(inq)); // 初始化部分
memset(num, 0, sizeof(num));
fill(d, d + MAXV, INF);
// 源点入队部分
queue<int> Q;
Q.push(s); // 源点入队
inq[s] = true; // 源点已入队
num[s]++; // 源点入队次数加1
d[s] = 0; // 源点的d值为0
// 主体部分
while(!Q.empty())
{
int u = Q.front(); // 队首顶点编号为u
Q.pop(); // 出队
inq[u] = false; // 设置u为不在队列中
// 遍历u的所有邻接边v
for(int j = 0; j < Adj[u].size(); j++)
{
int v = Adj[u][j].v;
int dis = Adj[u][j].dis;
if(d[u] + dis < d[v]) // 松弛操作
{
d[v] = d[u] + dis;
if(!inq[v]) // 如果v不在队列中
{
Q.push(v); // v入队
inq[v] = true; // 设置v为在队列中
num[v]++; // v的入队次数加1
if(num[v] >= n) return false; // 有可达负环
}
}
}
}
return true; //无可达负环
}
(2)数组模拟邻接表
int n; // 总点数
int h[N], w[N], e[N], ne[N], idx; // 邻接表存储所有边
int dist[N]; // 存储每个点到1号点的最短距离
bool st[N]; // 存储每个点是否在队列中
// 求1号点到n号点的最短路距离,如果从1号点无法走到n号点则返回-1
int spfa()
{
memset(dist, 0x3f, sizeof dist);
dist[1] = 0;
queue<int> q;
q.push(1);
st[1] = true;
while (q.size())
{
auto t = q.front();
q.pop();
st[t] = false;
for (int i = h[t]; i != -1; i = ne[i])
{
int j = e[i];
if (dist[j] > dist[t] + w[i])
{
dist[j] = dist[t] + w[i];
if (!st[j]) // 如果队列中已存在j,则不需要将j重复插入
{
q.push(j);
st[j] = true;
}
}
}
}
if (dist[n] == 0x3f3f3f3f) return -1;
return dist[n];
}
Bellman-Ford也可以判负环,但时间复杂度有点高,常用spfa判负环。
时间复杂度是 O ( n m ) O(nm) O(nm), n n n 表示点数, m m m 表示边数
基本原理:如果某条最短路径上有 n n n 个点(除了自己),那么加上自己之后一共有 n + 1 n+1 n+1 个点,由抽屉原理一定有两个点相同,所以存在环。
注意:判断图中是否存在负权回路,并不是判断是否存在从 1 1 1 开始的负环,所以,初始时我们要把所有的点都放到队列里。
int n; // 总点数
int h[N], w[N], e[N], ne[N], idx; // 邻接表存储所有边
bool st[N]; // 存储每个点是否在队列中
int dist[N], cnt[N];
// dist[x]存储1号点到x的最短距离,cnt[x]存储1到x的最短路中经过的点数
// 如果存在负环,则返回true,否则返回false
bool spfa()
{
// 不需要初始化dist数组
queue<int> q;
for (int i = 1; i <= n; i ++ )
{
q.push(i);
st[i] = true;
}
while (q.size())
{
auto t = q.front();
q.pop();
st[t] = false;
for (int i = h[t]; i != -1; i = ne[i])
{
int j = e[i];
if (dist[j] > dist[t] + w[i])
{
dist[j] = dist[t] + w[i];
cnt[j] = cnt[t] + 1;
// 如果从1号点到x的最短路中包含至少n个点(不包括自己),则说明存在环
if (cnt[j] >= n) return true;
if (!st[j]) // 如果队列中已存在j,则不需要将j重复插入
{
q.push(j);
st[j] = true;
}
}
}
}
return false;
}
Floyd 算法 (可读作“弗洛伊德算法”),用来解决全源最短路问题,即对给定的图 G ( V , E ) G(V, E) G(V,E),求任意两点 u , v u, v u,v 之间的最短路径长度。
为了求出图中任意两点间的最短路径,当然可以把每个点作为起点,求解 N N N 次单源最短路径问题。不过,在任意两点间最短路问题中,图一般比较稠密。使用 Floyd 算法可以在 O ( N 3 ) O(N^3) O(N3) 的时间内完成求解,并且程序实现非常简单。
由于 n 3 n^3 n3 的复杂度决定了顶点数 n n n 的限制约在 200 200 200 以内,因此使用邻接矩阵来实现 Floyd 算法是非常合适且方便的。
设 D [ k , i , j ] D[k,i,j] D[k,i,j] 表示“经过若干个编号不超过 k k k 的节点”从 i i i 到 j j j 的最短路长度。
该问题可划分为两个子问题,经过编号不超过 k − 1 k-1 k−1 的节点从 i i i 到 j j j,或者从 i i i 先到 k k k 再到 j j j。于是:
D [ k , i , j ] = m i n ( D [ k − 1 , i , j ] , D [ k − 1 , i , k ] + D [ k − 1 , k , j ] ) D[k,i,j] = min(D[k- 1,i,j],\ D[k- 1,i,k] + D[k- 1,k,j]) D[k,i,j]=min(D[k−1,i,j], D[k−1,i,k]+D[k−1,k,j])
初值为 D [ 0 , i , j ] = A [ i , j ] D[0,i,j]= A[i,j] D[0,i,j]=A[i,j],其中 A A A 为邻接矩阵。
可以看到,Floyd 算法的本质是动态规划。 k k k 是阶段,所以必须置于最外层循环中。 i i i 和 j j j 是附加状态,所以应该置于内层循环。
与背包问题的状态转移方程类似, k k k 这一维可被省略。最初,我们可以直接用 D D D 保存邻接矩阵,然后执行动态规划的过程。当最外层循环到 k k k 时,内层有状态转移:
D [ i , j ] = m i n ( D [ i , j ] , D [ i , k ] + D [ k , j ] ) D[i,j] = min(D[i,j],\ D[i,k] + D[k,j]) D[i,j]=min(D[i,j], D[i,k]+D[k,j])
最终 D [ i , j ] D[i,j] D[i,j] 就保存了 i i i 到 j j j 的最短路长度。
(1)求最短路
时间复杂度是 O ( n 3 ) O(n^3) O(n3), n n n 表示点数
注意的是:不能将最外层的 k k k 循坏放到内层,这会导致最后结果出错。
// 初始化
for (int i = 1; i <= n; i ++ )
for (int j = 1; j <= n; j ++ )
if (i == j) d[i][j] = 0;
else d[i][j] = INF;
// 算法结束后,d[a][b]表示a到b的最短距离
void floyd()
{
for (int k = 1; k <= n; k ++ )
for (int i = 1; i <= n; i ++ )
for (int j = 1; j <= n; j ++ )
d[i][j] = min(d[i][j], d[i][k] + d[k][j]);
}
(2)求字典序最小的最短路径
int G[N][N];
int path[N][N]; // path[i][j]=x表示i到j的路径上除i外的第一个点是x
void init(int n)
{
for(int i = 1; i <= n; i++)
{
for(int j = 1; j <= n; j++)
{
if(i == j)
G[i][j] = 0;
else
G[i][j] = INF;
path[i][j] = j;
}
}
}
void floyd(int n)
{
for(int k = 1; k <= n; k++) // 枚举中间点
{
for(int i = 1; i <= n; i++) // 枚举起点
{
for(int j = 1; j <= n; j++) // 枚举终点
{
if(G[i][k] < INF && G[k][j] < INF)
{
if(G[i][j] > G[i][k] + G[k][j]) // 松弛操作
{
G[i][j] = G[i][k] + G[k][j];
path[i][j] = path[i][k]; // 更新路径
}
// 在最短路相同的情况下,更新字典序最小的路径
else if (G[i][j] == G[i][k] + G[k][j] && path[i][j] > path[i][k])
{
path[i][j] = path[i][k]; // 最终path中存的是字典序最小的路径
}
}
}
}
}
}
int main()
{
int n, m;
while(scanf("%d%d", &n, &m) != EOF)
{
init(n);
for(int i = 1; i <= m; i++)
{
int x, y, dis;
scanf("%d%d%d", &x, &y, &dis);
// 无向图添边一次,有向图添边两次
G[x][y] = dis;
G[y][x] = dis;
}
floyd(n);
for(int i = 1; i <= n; i++)
{
for(int j = 1; j <= n; j++)
printf("%d ", G[i][j]);
printf("\n");
}
}
return 0;
}