图-最短路径-Dijkstra及其变种
最短路径
最短路径问题:
给定任意的图G(V,E)
和起点 S,终点 T,如何求从 S 到 T 的最短路径。
解决最短路径的常用方法有
- Dijkstra 算法
- Bellman-Ford 算法
- SPFA 算法
- Floyd 算法
这里主要对 Dijkstra 算法及其变种进行总结。
Dijkstra 算法
算法思想
Dijkstra 算法用来解决单源最短路径问题,即给定图 G 和起点 s,通过算法得到 S 到达其他每个顶点的最短距离。
Dijkstra 算法的基本思想:
对于图 G(V,E)设置集合 S,存放已被访问的顶点,然后每次从集合 V-S 中选择与起点 s 的最短距离最小的一个顶点(记为u),访问并加入集合 S。之后,令顶点 u 为中介点,优化起点 s 与所有从 u 能到达的顶点的最短距离。这样的操作执行 n (n为顶点个数),直到集合 S 已经包含所有顶点。
详细策略:
首先设置集合 S 存放已经被访问的顶点,然后执行 n 次(顶点数)下面的两个步骤
- 每次从集合 V - S 中选择与起点 s 最短距离的一个顶点(记为 u),访问并且加入集合 S
- 令顶点 u 为中介点,优化所有能从 u 到达的顶点 v 之间的最短距离
具体实现
在实现过程中,有两个主要问题需要考虑:
- 集合 S 的实现
- 起点 s 到达顶点 Vi ( 0<= i <= n-1)的最短距离的实现
- 集合 S可以用一个 bool 型数组 vis[] 来实现,即当 vis[i] == true 时表示顶点 Vi 已经被访问
- 令 int 型数组 d[] 表示起点到达顶点 Vi 的最短距离,初始时除了起点 s 的 d[s] = 0 以外,其余顶点都赋为一个很大的数
伪代码如下:
void Dijkstra(G, d[], s) {
初始化;
for(循环n次) {
u = 使 d[u] 最小的,还未访问的顶点标号;
记录 q 被访问;
for(从u出发能够到达的所有顶点v){
if(v未被访问 && 以u为中介点使得s到v的最短距离d[v]更优) {
优化d[v];
}
}
}
}
邻接矩阵版
const int MAXV = 1000; // 最大顶点数
const int INF = 1e9; // INF很大的数字
int n, G[MAXV][MAXV]; // n 为顶点数,MAXV为最大顶点数
int d[MAXV]; // 起点到达各点的最短路径长度
bool vis[MAXV] = {false}; // 是否被访问过
// s 为起点
void Dijkstra(int s) {
fill(d, d+MAXV, INF); // 初始化距离为最大值
d[s] = 0;
for(int i = 0; i < n; i++) { // 重复 n 次
int u = -1, MIN = INF;
// 遍历找到未访问顶点中 d[] 最小的
for(int j = 0; j < n; j++) {
if(vis[j] == false && d[j] < MIN) {
u = j;
MIN = d[j];
}
}
if(u == -1) return; // 找不到小于 INF 的 d[u],说明剩下的顶点和起点 s 不连通
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];
}
}
}
}
时间复杂度:外层循环 O(V),内层循环 O(V),枚举 v 需要 O(V) ,总复杂度为 O(V*(V+V)) = O(V2 )。
邻接表版
struct Node {
int v, dis; // v 为目标顶点,dis 为边权
};
vector<Node> Adj[MAXV]; //图G,Adj[u]存放从顶点 v 出发可以到达的所有顶点
int n; // n为顶点数
int d[MAXV]; // 起点到达各点的最短路径长度
bool vis[MAXV] = {false};
void Dijkstra(int s) {// s 为起点
fill(d, d+MAXV, INF);
d[s] = 0; // 到自身为 0
for(int i = 0; i < n; i++) { // 循环 n 次
int u = -1, MIN = INF;
// 遍历找到未访问顶点中 d[] 最小的
for(int j = 0; j < n; j++) {
if(vis[j] == false && d[j] < MIN) {
u = j;
MIN = d[j];
}
}
if(u == -1) return; // 找不到小于 INF 的 d[u],说明剩下的顶点和起点 s 不连通
vis[u] = true; // 标记 u 为已访问
// 和邻接矩阵不同
for(int j=0; j < Adj[u][j].size(); j++) {
int v = Adj[u][j].v; // 获得u能直接到达的顶点
// v 未访问 && 以 u 为中介点到达 v 比 d[v] 更短
if(vis[v] == false && d[u] + Adj[u][j].dis < d[v]) {
// 更新
d[v] = d[u] + Adj[u][j].dis;
}
}
}
}
当题目给的是无向边时(双向边)而不是有向边时,只需要把无向边当成两条指向相反的有向边即可。
最短路径
我们这时候还没说到最短路径如何记录,我们回到伪代码,有这样一步
for(从u出发能够到达的所有顶点v){
if(v未被访问 && 以u为中介点使得s到v的最短距离d[v]更优) {
优化d[v];
}
}
我们在这个时候吧这个信息记录下来,也就是设置一个 pre[]
数组,令 pre[v]
表示从起点 s 到顶点 v 的最短路径上的前一个顶点(前驱结点)的编号,这样,当伪代码中条件成立时,就把 u 赋给 pre[v] ,最终就记录下来了。
伪代码变成了:
for(从u出发能够到达的所有顶点v){
if(v未被访问 && 以u为中介点使得s到v的最短距离d[v]更优) {
优化d[v];
令 v 的前驱为 u
}
}
以邻接矩阵为例:
const int MAXV = 1000; // 最大顶点数
const int INF = 1e9; // INF很大的数字
int n, G[MAXV][MAXV]; // n 为顶点数,MAXV为最大顶点数
int d[MAXV]; // 起点到达各点的最短路径长度
int pre[MAXV]; // 记录最短路径
bool vis[MAXV] = {false}; // 是否被访问过
// s 为起点
void Dijkstra(int s) {
fill(d, d+MAXV, INF); // 初始化距离为最大值
d[s] = 0;
for(int i = 0; i < n; i++) { // 重复 n 次
int u = -1, MIN = INF;
// 遍历找到未访问顶点中 d[] 最小的
for(int j = 0; j < n; j++) {
if(vis[j] == false && d[j] < MIN) {
u = j;
MIN = d[j];
}
}
if(u == -1) return; // 找不到小于 INF 的 d[u],说明剩下的顶点和起点 s 不连通
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];
pre[v] = u; // 记录 v 的前驱结点是 u
}
}
}
}
// 输出结点
void DFS(int s, int v) {
if(v == s) { // 已经递归到起点 s
printf("%d\n", s);
return;
}
DFS(s, pre[v]); // 递归访问 v 的前驱顶点 pre[v]
printf("%d\n", v); // 从最深处 return 回来之后输出每一层的结点号
}
多条最短路径
我们此时已经学会了 Dijkstra 和最短路径的求法,但是通常情况下最短路径不止一条。
于是碰到这种有两条以上可以达到的最短路径,题目就会给出第二标尺(第一标尺是距离),要求在所有最短路径中选择第二标尺最优的一条路径。
通常有以下三种方式:
- 给每条边再增加一个边权(比如花费)
- 给每个点增加一个点权
- 直接问有多少条最短路径
对于这三种提问,都只需要增加一个数组来存放新增的边权或点权或最短路径数,然后修改优化 d[v]
的那个步骤即可。
对于以上三种提问,分别的解决办法:
- 新增边权。以新增边权代表花费为例,用
cost[u][v]
代表从 u->v 的花费,增加一个数组c[]
,令从起点 s 到顶点 u 的最少花费为 c[u],初始化时只有c[s] = 0
,其余都为 INF(距离最大)。然后在d[u] + G[u][v] < d[v]
时,更新d[v]
和c[v]
,而当d[u] + G[u][v] == d[v]
且c[u]+cost[u][v] < c[v]
时更新c[v]
。
for(int v = 0; v < n; v++) {
// 如果v未访问 && u 能到达 v && 以 u 为中介点可以使 d[v] 更优
if(vis[v] == false && G[u][v] != INF) {
if(d[u] + G[u][v] < d[v]) {
d[v] = d[u] + G[u][v];
c[v] = c[u] + cost[u][v];
}else if(d[u] + G[u][v] == d[v] && c[u] + cost[u][v] < c[v]) {
c[v] = c[u] + cost[u][v];
}
}
}
- 同上,就是换成权重数组。
- 只需要增加一个数组
num[]
,令从起点 s 到达顶点 u 的最短路径条数为num[u]
,初始化时,num[s] = 1
,其余为 0。当d[u] + G[u][v] < d[v]
时,让 num[v] 继承 num[u]。而当d[u] + G[u][v] == d[v]
,将 num[u] 加到 num[v] 上。代码如下:
for(int v = 0; v < n; v++) {
// 如果v未访问 && u 能到达 v && 以 u 为中介点可以使 d[v] 更优
if(vis[v] == false && G[u][v] != INF) {
if(d[u] + G[u][v] < d[v]) {
d[v] = d[u] + G[u][v];
num[v] = num[u];
}else if(d[u] + G[u][v] == d[v] && c[u] + cost[u][v] < c[v]) {
num[v] += num[u]; //最短距离相同时累加 num
}
}
}
通过两个题目来巩固最短路径:
PTA 1003,这个题目非常值得一做,考虑 Dijkstra 算法三种变种,能够很好的熟悉 Dijkstra。
Dijkstra+DFS
上述题目一般都用了 Dijkstra 来做,然后有多个标尺的情况下很容易出错,这里介绍一种更通用、模板化的方法——Dijkstra+DFS。
在算法中 pre 数组总是保持着最优路径,而这显然需要在执行 Dijkstra 算法的过程中来确定何时更新每个节点 v 的前驱结点 pre[v] 。更简单的方法是:先在Dijkstra算法中记录下所有最短路径(只考虑距离),然后从这些路径中选择一条第二标尺最优的路径。
- 使用 Dijkstra 算法记录所有最短路径
由于需要记录所有最短路径,所以每个节点就会存在多个前驱结点,这样可以使用 vector<int> pre[MAXV]
来保存前驱结点。对于每个节点 v 来说,pre[v] 就是一个变长数组 vector ,里面用来存放结点 v 的所有能产生最短路径的前驱结点。
接下来考虑更新 d[v] 的过程中 pre 数组的变化。首先,如果 d[u]+G[u][v] < d[v]
,说明以 u 为中介点可以使 d[v] 更优,此时令 v 的前驱结点为 u,并且即使之前 pre[v] 中已经存放了若干结点,此处也应该清空,然后再添加 u,因为此时之前保存的不是最优路径了。
if(d[u] + G[u][v] < d[v]) {
d[v] = d[u] + G[u][v];
pre[v].clear();
pre[v].push(u);
}else if(d[u] + G[u][v] == d[v]) {
pre[v].push(u);
}
那么我们就可以编写完整的代码如下:
vector<int> pre[MAXV];
// s 为起点
void Dijkstra(int s) {
fill(d, d+MAXV, INF); // 初始化距离为最大值
d[s] = 0;
for(int i = 0; i < n; i++) { // 重复 n 次
int u = -1, MIN = INF;
// 遍历找到未访问顶点中 d[] 最小的
for(int j = 0; j < n; j++) {
if(vis[j] == false && d[j] < MIN) {
u = j;
MIN = d[j];
}
}
if(u == -1) return; // 找不到小于 INF 的 d[u],说明剩下的顶点和起点 s 不连通
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) {
if(d[u] + G[u][v] < d[v]) {
d[v] = d[u] + G[u][v];
pre[v].clear();
pre[v].push_back(u);
}else if(d[u] + G[u][v] == d[v]) {
pre[v].push_back(u);
}
}
}
}
}
- 遍历所有最短路径,找出一条第二标尺最优的路径。
由于每个结点的前驱结点可能有多个,遍历的过程就会形成一递归树,我们可以使用DFS来寻找到最优路径。对树进行遍历时,每次到达叶子结点时就会产生一条完整的最短路径,每次得到一条路径,就可以计算第二标尺的值,令其和当前第二标尺的最优值进行比较,如果比最优值更优,则更新最优值,并用这条路径覆盖当前的最优路径。
我们考虑一下这个递归函数该如何实现。
- 作为全局变量的第二标尺最优值 optValue
- 记录最优路径的数组 path(使用 vector 来存储)
- 临时记录 DFS 遍历到叶子结点时的路径 tempPath(使用vector存储)
然后考虑递归函数的两大构成:递归边界和递归式,如果访问的结点是叶子结点(起点st),那么说明到达了递归边界,此时 tempPath 存放了一条路径,求出第二标尺的值和optValue比较。
在递归过程中生成 tempPath。只要在访问当前结点 v 时将 v 加到 tempPath 的最后面,然后遍历 pre[v] 进行递归,等 pre[v] 的所有结点遍历完毕后再把 tempPath 最后面的 v 弹出。
int optValue;
vector<int> pre[MAXV];
vector<int> tempPath, path;
int st; // 出发结点
void DFS(int v) {
if(st == v) {
// 递归到了出发结点
tempPath.push(v);
int value;
计算路径 tempPath 上的value值
if(value优于optValue) {
optValue = value;
path = tempPath;
}
tempPath.pop_back(); // 把刚刚加入的结点弹出来哦
return;
}else {
tempPath.push_back(v); // 把当前访问结点加入临时路径 tempPath 的最后面
for(int i = 0; i < pre[v].size(); i++) {
DFS(pre[v][i]);
}
tempPath.pop_back();
}
}
当我们遇到的是点权或者边权的时候,我们只需要修改计算value值的过程。
但是需要注意的是,存放在 tempPath 中路径的结点是逆序的,因此访问结点需要倒着进行。
// 边权之和
int value = 0;
for(int i = tempPath.size() - 1; i > 0; i--) {
int id = tempPath[i], idNext = tempPath[i-1];
value += V[id][nextId];
}
// 点权之和
int value = 0;
for(int i = tempPath.size() - 1; i > 0; i--) {
int id = tempPath[i];
value += W[id];
}
如果需要记录最短路径的条数,也可以在 DFS 的过程中,每到达一次叶子结点令该全局变量加 1。
PTA 1030 ,这个题使用了 Dijkstra + DFS 的思想,可以好好学习借鉴一下。
可以和之前的 PTA 1003 结合起来看,基本上 Dijkstra 就没啥毛病了。
但是 DIjkstra 的缺点就是遇到负权图的时候就很无力了,所以这个时候出现了新的算法。