..

第十章 最短路

最短路是图论中最经典的模型之一,在生活中也有很多应用。

除了解决给定图的最短路问题,最短路模型还可以解决许多看起来不是图的问题。如果解决一个问题的最佳方案的过程中涉及很多状态,这些状态是明确的且数量合适,而且状态之间可以转移,可以考虑建立图论模型,使用最短路算法求解。

单源最短路径

例1:P3371 【模板】单源最短路径(弱化版)

解法 $1$:$\text{Dijkstra}$ 算法。

  1. 将起始点的 $dis$ 置 $0$;
  2. 选择当前为标记的点中 $dis$ 值最小的一个;
  3. 对该点的所有连边依次进行松弛操作;
  4. 对该点进行标记;
  5. 重复第二步置第四步,直到不存在一条从已标记点通往未标记点的连边。
void Dijkstra(int s) {
    std::priority_queue<Vertex> q;
    for (int i = 1; i <= n; i++) vertex[i].dis = inf;
    vertex[s].dis = 0;
    q.push(vertex[s]);
    while (!q.empty()) {
        int u = q.top().vertex;
        q.pop();
        if (vertex[u].vis) continue;
        for (int e = vertex[u].head; e; e = edge[e].next) {
            int v = edge[e].head;
            int w = edge[e].weight;
            if (vertex[v].dis > vertex[u].dis + w) {
                vertex[v].dis = vertex[u].dis + w;
                q.push(vertex[v]);
            }
        }
    }
    return;
}

如果是无向图,每加入一条边,都可以认为是加入两条方向相反的有向边。这个时候需要特别注意,链式前向星需要定义的数组元素数量上限,需要超过边数的两倍。

由于每条边最多被入堆、出堆一次,而且堆内元素最多为 $m$ 个,其时间复杂度为 $\mathcal O(m \log m)$。

$\text{Dijkstra}$ 算法的优势是时间复杂度较低,缺点是一旦边长出现负数其正确性就无法保证。

解法 $2$:$\text{SPFA}$ 算法。

  1. 将起始点的 $dis$ 置 $0$ 并放入队列;
  2. 选择当前队列中的第一个点并将其从队列中删除;
  3. 对该点的所有连边依次进行松弛操作;
  4. 将第三步中松弛成功且不在队列内的点放入队列;
  5. 重复第二步置第四步,直到队列为空。
void SPFA(int s) {
    std::queue<int> q;
    for (int i = 1; i <= n; i++) vertex[i].dis = inf;
    vertex[s].dis = 0;
    vertex[s].inq = true;
    q.push(s);
    while (!q.empty()) {
        int u = q.front();
        q.pop();
        vertex[u].inq = false;
        for (int e = vertex[u].head; e; e = edge[e].next) {
            int v = edge[e].head;
            int w = edge[e].weight;
            if (vertex[v].dis > vertex[u].dis + w) {
                vertex[v].dis = vertex[u].dis + w;
                if (!vertex[v].inq) {
                    q.push(v);
                    vertex[v].inq = true;
                }
            }
        }
    }
    return;
}

在 $\text{SPFA}$ 的过程中会有一个点可能多次入对,这使寻找最短路的效率大大降低。实际上,$\text{SPFA}$ 的时间复杂度在最劣情况下可以达到 $\mathcal O(nm)$,因此只要数据稍大且构造特定图就可能超时,但它在随机数据下速度较快,且当边长出现负数时依然正确。因此在算法竞赛中,如果确定图中边长非负且没有特殊的限制时,不应使用 $\text{SPFA}$ 算法。

例2:P4568 [JLOI2011] 飞行路线

由于购买机票需要花费金钱,所以肯定不会重复乘坐多次同样的航线或者多次访问同一个城市。如果 $k = 0$,本题就是最基本的最短路问题。但题目中提供了一些特殊的情况(对有限条边设置为免费),可以使用分层图的方式。将图多复制 $k$ 次,原编号为 $i$ 的结点复制为编号 $i + jn (1 \le j \le k)$ 的结点,然后对于原图存在的边,第 $j$ 层第 $j + 1$ 层的对应结点也需要连上,看起来就是相同的图上下堆叠起来,因此被称为分层图。

从上面一层跳到下面一层就是乘坐免票的飞机,花费的代价是 $0$。这个过程时不可逆的,每乘坐一次飞机就跳到下一层图中,因此上一层到下一层到边是单向边。从编号为 $s$ 到结点开始,计算到其他结点的单源最短路径。因为并不一定要坐满 $k$ 次免费航班,所以查找所有编号 $t + jn (0 \le j \le n)$ 的结点,找到最小的值就是的答案。

    for (int i = 1; i <= m; i++) {
        int a, b, c;
        scanf("%d%d%d", &a, &b, &c);
        graph::addEdge(a, b, c);
        graph::addEdge(b, a, c);
        for (int j = 1; j <= k; j++) {
            graph::addEdge(j * n + a, j * n + b, c);
            graph::addEdge(j * n + b, j * n + a, c);
            graph::addEdge((j - 1) * n + a, j * n + b, 0);
            graph::addEdge((j - 1) * n + b, j * n + a, 0);
        }
    }
    graph::Dijkstra();
    for (int i = 0; i <= k; i++) ans = std::max(ans, graph::vertex[t + i * n].dis);

例3:P1629 邮递员送信

由于邮递员必须每次都要从结点 $1$ 出发,到达结点 $i$,然后返回 $1$,因此就是求从 $1$ 到 $i$ 的最短路,加上 $i$ 到 $1$ 的最短路。考虑到是有向图,从 $1$ 到 $i$ 的距离不一定等于从 $i$ 到 $1$ 的距离。于是可以建立另外一个图,将读入的边起点和终点对调后存入这个新图中,从 $1$ 开始计算到其他点的单源最短路径,得到的距离就是其他点到起点到最短路径。一来一回加起来就是送货的最短路径。

    for (int i = 1; i <= m; i++) {
        int u, v, w;
        std::cin >> u >> v >> w;
        graph::addEdge(u, v, w);
        graph::addEdge(n + v, n + u, w);
    }
    Graph::Dijkstra(1);
    Graph::Dijkstra(n + 1);
    for (int i = 2; i <= n; i++) ans += Graph::vertex[i].dis;
    for (int i = n + 2; i <= n * 2; i++) ans += Graph::vertex[i].dis;

一些最短路相关问题的小技巧:

  1. 某些边只能走有限的次数,可以将图复制多遍,成为分层图;
  2. 给出若干个关键点,求其他点离这些关键点的距离,可以建立一个超级源点,并使用长度为 $0$ 的边将超级源点和这些关键点相连;
  3. 查找有向图中,所有结点到某个节点的最短路,可以将图反向建边;
  4. 注意图中是否存在负环、自环、重边等情况。

负环与差分约束

例4:P3385 【模板】负环

在图论中边权之和为负数的环称为负环。显然,如果一个连通图中存在一个从顶点 $s$ 出发能到的负环,$s$ 到该负环上的任意一点不存在最短路。

可以从最短路径上点数入手。如果一个点进队超过 $n$ 次,它的当前最短路径上的点数就超过了 $n$ 个点,这就意味着出现了负环。

		if (vertex[v].dis > vertex[u].dis + w) {
			vertex[v].dis = vertex[u].dis + w;
			if (!vertex[v].inq) {
				vertex[v].inq = true;
			    vertex[v].cnt++;
			    if (vertex[v].cnt >= n) return true;
				q.push(v);
			}
		}
		if (vertex[v].dis > vertex[u].dis + w) {
			vertex[v].dis = vertex[u].dis + w;
            vertex[v].cnt = vertex[u].cnt + 1;
			if (vertex[v].cnt >= n) return true;
			if (!vertex[v].inq) {
				vertex[v].inq = true;
				q.push(v);
			}
		}

例5:P5960 【模板】差分约束算法

对于形如 $x_u - x_v \le y$ 的条件,可以将其转化为 $x_u \le x_v + y$。容易发现这个不等式很像最短路中的 $dis_v \le dis_u + w$。因此可以对于每一组 $x_u - x_v \le y$ 建一条 $v$ 到 $u$ 边权为 $y$ 的边,使途中的边和不等式一一对应,于是问题就被引到了最短路的方向上。

如果出现了形如 $x_i - x_j \ge y$ 的条件,处理方法是在不等式两旁同时乘 $-1$,得到 $x_j - x_i \le -y$。如果出现了形如 $x_i \ x_j = y$,则只需将它拆分成 $x_i - x_j \le y$ 与 $x_i - x_j \ge y$ 两个约束条件进行处理。

    for (int i = 1; i <= n; i++) graph::addEdge(0, i, 0);
	for (int i = 1; i <= m; i++) std::cin >> c >> c_ >> y, graph::addEdge(c_, c, y);
	if (graph::SPFA(0)) std::cout << "NO";
	else for (int i = 1; i <= n; i++) std::cout << graph::vertex[i].dis << ' ';

次短路

例6:P2865 [USACO06NOV]Roadblocks G

在任何时刻,如果发现了一条新的从 $1$ 到 $i$ 的路径,则该路径可能存在以下几种情况:

  1. 该路径长度小于目前的 $1$ 到 $i$ 的最短路。此时的处理方法是将目前的次短路用目前的最短路代替,而最短路替换为新路径;
  2. 该路径长度等于目前的最短路,直接舍去;
  3. 该路径长度大于目前的最短路且小于目前的最短路。此时的处理方式是直接用这条路径替换当前的次短路径;
  4. 该路径长度大于或等于目前的次短路。

由于只有最短路和次短路有效,因此这个点的所有可能的有效路径全部都在优先队列中。当这个点第一次从优先队列中出来时,其最短路值就不会再被更改;第二次从优先队列中出来时,其次短路值就不会再更改。

        for (int e = vertex[u].head; e; e = edge[e].next) {
            int v = edge[e].head;
            int w = edge[e].weight;
            if (vertex[v].dis1 > d + w) {
                vertex[v].dis2 = vertex[v].dis1;
                vertex[v].dis1 = d + w;
                q.push((Node) {v, vertex[v].dis1});
            }
            if (vertex[v].dis2 > d + w && vertex[v].dis1 < d + w) {
                vertex[v].dis2 = d + w;
                q.push((Node) {v, vertex[v].dis2});
            }
        }

多源最短路径与传递闭包

例7:P2910 [USACO08OPEN]Clear And Present Danger S

对于多源最短路,可以使用 $\text{Floyd-Warshall}$ 算法更便捷地解决此类问题。

$\text{Floyd}$ 算法既可以处理无向图,也可以处理有向图,注意设置 $dis$ 的初始值即可。

例8:B3611 【模板】传递闭包

许多算法竞赛题目看起来和图没什么关系,但是如果可以找到状态结点和连接不同结点的边,也可以将其建立图模型,使用图论算法解决。