Day 20 - 最短路与差分约束

最短路

定义

  • 路径
  • 最短路
  • 有向图中的最短路、无向图中的最短路
  • 单源最短路、每对结点之间的最短路

性质

对于边权为正的图,任意两个结点之间的最短路,不会经过重复的结点。

对于边权为正的图,任意两个结点之间的最短路,不会经过重复的边。

对于边权为正的图,任意两个结点之间的最短路,任意一条的结点数不会超过 \(n\),边数不会超过 \(n-1\)

记号

为了方便叙述,这里先给出下文将会用到的一些记号的含义。

  • \(n\) 为图上点的数目,\(m\) 为图上边的数目;
  • \(s\) 为最短路的源点;
  • \(D(u)\)\(s\) 点到 \(u\) 点的 实际 最短路长度;
  • \(dis(u)\)\(s\) 点到 \(u\) 点的 估计 最短路长度。任何时候都有 \(dis(u) \geq D(u)\)。特别地,当最短路算法终止时,应有 \(dis(u)=D(u)\)
  • \(w(u,v)\)\((u,v)\) 这一条边的边权。

Floyd 算法

是用来求任意两个结点之间的最短路的。

复杂度比较高,但是常数小,容易实现(只有三个 for)。

适用于任何图,不管有向无向,边权正负,但是最短路必须存在。(不能有个负环)

实现

我们定义一个数组 f[k][x][y],表示只允许经过结点 \(1\)\(k\)(也就是说,在子图 \(V'={1, 2, \ldots, k}\) 中的路径,注意,\(x\)\(y\) 不一定在这个子图中),结点 \(x\) 到结点 \(y\) 的最短路长度。

很显然,f[n][x][y] 就是结点 \(x\) 到结点 \(y\) 的最短路长度(因为 \(V'={1, 2, \ldots, n}\) 即为 \(V\) 本身,其表示的最短路径就是所求路径)。

接下来考虑如何求出 f 数组的值。

f[0][x][y]\(x\)\(y\) 的边权,或者 \(0\),或者 \(+\infty\)f[0][x][y] 什么时候应该是 \(+\infty\)?当 \(x\)\(y\) 间有直接相连的边的时候,为它们的边权;当 \(x = y\) 的时候为零,因为到本身的距离为零;当 \(x\)\(y\) 没有直接相连的边的时候,为 \(+\infty\))。

f[k][x][y] = min(f[k-1][x][y], f[k-1][x][k]+f[k-1][k][y])f[k-1][x][y],为不经过 \(k\) 点的最短路径,而 f[k-1][x][k]+f[k-1][k][y],为经过了 \(k\) 点的最短路)。

上面两行都显然是对的,所以说这个做法空间是 \(O(N^3)\),我们需要依次增加问题规模(\(k\)\(1\)\(n\)),判断任意两点在当前问题规模下的最短路。

for (k = 1; k <= n; k++) {
    for (x = 1; x <= n; x++) {
    for (y = 1; y <= n; y++) {
        f[k][x][y] = min(f[k - 1][x][y], f[k - 1][x][k] + f[k - 1][k][y]);
    }
    }
}

因为第一维对结果无影响,我们可以发现数组的第一维是可以省略的,于是可以直接改成 f[x][y] = min(f[x][y], f[x][k]+f[k][y])

证明第一维对结果无影响:

我们注意到如果放在一个给定第一维 k 二维数组中,f[x][k]f[k][y] 在某一行和某一列。而 f[x][y] 则是该行和该列的交叉点上的元素。

现在我们需要证明将 f[k][x][y] 直接在原地更改也不会更改它的结果:我们注意到 f[k][x][y] 的涵义是第一维为 k-1 这一行和这一列的所有元素的最小值,包含了 f[k-1][x][y],那么在原地进行更改也不会改变最小值的值,因为如果将该三维矩阵压缩为二维,则所求结果 f[x][y] 一开始即为原 f[k-1][x][y] 的值,最后依然会成为该行和该列的最小值。

故可以压缩。

for (k = 1; k <= n; k++) {
    for (x = 1; x <= n; x++) {
    for (y = 1; y <= n; y++) {
        f[x][y] = min(f[x][y], f[x][k] + f[k][y]);
    }
    }
}

综上时间复杂度是 \(O(N^3)\),空间复杂度是 \(O(N^2)\)

应用

给一个正权无向图,找一个最小权值和的环?

首先这一定是一个简单环。

想一想这个环是怎么构成的。

考虑环上编号最大的结点 \(u\)

f[u-1][x][y]\((u,x)\),\((u,y)\) 共同构成了环。

\(\text{Floyd}\) 的过程中枚举 \(u\),计算这个和的最小值即可。

时间复杂度为 \(O(n^3)\)

已知一个有向图中任意两点之间是否有连边,要求判断任意两点是否连通?

该问题即是求 图的传递闭包

我们只需要按照 \(\text{Floyd}\) 的过程,逐个加入点判断一下。

只是此时的边的边权变为 \(1/0\),而取 \(\min\) 变成了 运算。

再进一步用 bitset 优化,复杂度可以到 \(O(\frac{n^3}{w})\)

// std::bitset<SIZE> f[SIZE];
for (k = 1; k <= n; k++)
    for (i = 1; i <= n; i++)
    if (f[i][k]) f[i] = f[i] | f[k];

Bellman–Ford 算法

\(\text{Bellman–Ford}\) 算法是一种基于松弛(\(\text{relax}\))操作的最短路算法,可以求出有负权的图的最短路,并可以对最短路不存在的情况进行判断。

在国内 \(OI\) 界,你可能听说过的「\(\tedt{SPFA}\)」,就是 \(\text{Bellman–Ford}\) 算法的一种实现。

过程

先介绍 \(\text{Bellman–Ford}\) 算法要用到的松弛操作(\(\text{Dijkstra}\) 算法也会用到松弛操作)。

对于边 \((u,v)\),松弛操作对应下面的式子:\(dis(v) = \min(dis(v), dis(u) + w(u, v))\)

这么做的含义是显然的:我们尝试用 \(S \to u \to v\)(其中 \(S \to u\) 的路径取最短路)这条路径去更新 \(v\) 点最短路的长度,如果这条路径更优,就进行更新。

\(\text{Bellman–Ford}\) 算法所做的,就是不断尝试对图上每一条边进行松弛。我们每进行一轮循环,就对图上所有的边都尝试进行一次松弛操作,当一次循环中没有成功的松弛操作时,算法停止。

每次循环是 \(O(m)\) 的,那么最多会循环多少次呢?

在最短路存在的情况下,由于一次松弛操作会使最短路的边数至少 \(+1\),而最短路的边数最多为 \(n-1\),因此整个算法最多执行 \(n-1\) 轮松弛操作。故总时间复杂度为 \(O(nm)\)

但还有一种情况,如果从 \(S\) 点出发,抵达一个负环时,松弛操作会无休止地进行下去。注意到前面的论证中已经说明了,对于最短路存在的图,松弛操作最多只会执行 \(n-1\) 轮,因此如果第 \(n\) 轮循环时仍然存在能松弛的边,说明从 \(S\) 点出发,能够抵达一个负环。

负环判断中存在的常见误区:

需要注意的是,以 \(S\) 点为源点跑 \(\text{Bellman–Ford}\) 算法时,如果没有给出存在负环的结果,只能说明从 \(S\) 点出发不能抵达一个负环,而不能说明图上不存在负环。

因此如果需要判断整个图上是否存在负环,最严谨的做法是建立一个超级源点,向图上每个节点连一条权值为 \(0\) 的边,然后以超级源点为起点执行 \(\text{Bellman–Ford}\) 算法。

实现

struct Edge {
    int u, v, w;
};

vector<Edge> edge;

int dis[MAXN], u, v, w;
const int INF = 0x3f3f3f3f;

bool bellmanford(int n, int s) {
    memset(dis, 0x3f, sizeof(dis));
    dis[s] = 0;
    bool flag = false;  // 判断一轮循环过程中是否发生松弛操作
    for (int i = 1; i <= n; i++) {
    flag = false;
    for (int j = 0; j < edge.size(); j++) {
        u = edge[j].u, v = edge[j].v, w = edge[j].w;
        if (dis[u] == INF) continue;
        // 无穷大与常数加减仍然为无穷大
        // 因此最短路长度为 INF 的点引出的边不可能发生松弛操作
        if (dis[v] > dis[u] + w) {
        dis[v] = dis[u] + w;
        flag = true;
        }
    }
    // 没有可以松弛的边时就停止算法
    if (!flag) {
        break;
    }
    }
    // 第 n 轮循环仍然可以松弛时说明 s 点可以抵达一个负环
    return flag;
}

队列优化:SPFA

\(\text{Shortest Path Faster Algorithm}\)

很多时候我们并不需要那么多无用的松弛操作。

很显然,只有上一次被松弛的结点,所连接的边,才有可能引起下一次的松弛操作。

那么我们用队列来维护「哪些结点可能会引起松弛操作」,就能只访问必要的边了。

\(\text{SPFA}\) 也可以用于判断 \(s\) 点是否能抵达一个负环,只需记录最短路经过了多少条边,当经过了至少 \(n\) 条边时,说明 \(s\) 点可以抵达一个负环。

实现:

struct edge {
    int v, w;
};

vector<edge> e[maxn];
int dis[maxn], cnt[maxn], vis[maxn];
queue<int> q;

bool spfa(int n, int s) {
    memset(dis, 63, sizeof(dis));
    dis[s] = 0, vis[s] = 1;
    q.push(s);
    while (!q.empty()) {
    int u = q.front();
    q.pop(), vis[u] = 0;
    for (auto ed : e[u]) {
        int v = ed.v, w = ed.w;
        if (dis[v] > dis[u] + w) {
        dis[v] = dis[u] + w;
        cnt[v] = cnt[u] + 1;  // 记录最短路经过的边数
        if (cnt[v] >= n) return false;
        // 在不经过负环的情况下,最短路至多经过 n - 1 条边
        // 因此如果经过了多于 n 条边,一定说明经过了负环
        if (!vis[v]) q.push(v), vis[v] = 1;
        }
    }
    }
    return true;
}

虽然在大多数情况下 \(\text{SPFA}\) 跑得很快,但其最坏情况下的时间复杂度为 \(O(nm)\),将其卡到这个复杂度也是不难的,所以考试时要谨慎使用(在没有负权边时最好使用 \(\text{Dijkstra}\) 算法,在有负权边且题目中的图没有特殊性质时,若 \(\text{SPFA}\) 是标算的一部分,题目不应当给出 \(\text{Bellman–Ford}\) 算法无法通过的数据范围)。

\(\text{Bellman–Ford}\) 的其他优化:

除了队列优化(\(\text{SPFA}\))之外,\(\text{Bellman–Ford}\) 还有其他形式的优化,这些优化在部分图上效果明显,但在某些特殊图上,最坏复杂度可能达到指数级。

  • 堆优化:将队列换成堆,与 \(\text{Dijkstra}\) 的区别是允许一个点多次入队。在有负权边的图可能被卡成指数级复杂度。
  • 栈优化:将队列换成栈(即将原来的 \(\text{BFS}\) 过程变成 \(\text{DFS}\)),在寻找负环时可能具有更高效率,但最坏时间复杂度仍然为指数级。
  • LLL 优化:将普通队列换成双端队列,每次将入队结点距离和队内距离平均值比较,如果更大则插入至队尾,否则插入队首。
  • SLF 优化:将普通队列换成双端队列,每次将入队结点距离和队首比较,如果更大则插入至队尾,否则插入队首。
  • D´Esopo–Pape 算法:将普通队列换成双端队列,如果一个节点之前没有入队,则将其插入队尾,否则插入队首。

更多优化以及针对这些优化的 \(\text{Hack}\) 方法,可以看 \(\text{fstqwq}\) 在知乎上的回答

Dijkstra 算法

\(\text{Dijkstra}\)(/ˈdikstrɑ/或/ˈdɛikstrɑ/)算法由荷兰计算机科学家 \(\text{E. W.}\) \(\text{Dijkstra}\)\(1956\) 年发现,\(1959\) 年公开发表。是一种求解非负权图上单源最短路径的算法。

过程

将结点分成两个集合:已确定最短路长度的点集(记为 \(S\) 集合)的和未确定最短路长度的点集(记为 \(T\) 集合)。一开始所有的点都属于 \(T\) 集合。

初始化 \(dis(s)=0\),其他点的 \(dis\) 均为 \(+\infty\)

然后重复这些操作:

  1. \(T\) 集合中,选取一个最短路长度最小的结点,移到 \(S\) 集合中。
  2. 对那些刚刚被加入 \(S\) 集合的结点的所有出边执行松弛操作。

直到 \(T\) 集合为空,算法结束。

时间复杂度

有多种方法来维护 \(1\) 操作中最短路长度最小的结点,不同的实现导致了 \(\text{Dijkstra}\) 算法时间复杂度上的差异。

  • 暴力:不使用任何数据结构进行维护,每次 \(2\) 操作执行完毕后,直接在 \(T\) 集合中暴力寻找最短路长度最小的结点。\(2\) 操作总时间复杂度为 \(O(m)\)\(1\) 操作总时间复杂度为 \(O(n^2)\),全过程的时间复杂度为 \(O(n^2 + m) = O(n^2)\)
  • 二叉堆:每成功松弛一条边 \((u,v)\),就将 \(v\) 插入二叉堆中(如果 \(v\) 已经在二叉堆中,直接修改相应元素的权值即可),\(1\) 操作直接取堆顶结点即可。共计 \(O(m)\) 次二叉堆上的插入(修改)操作,\(O(n)\) 次删除堆顶操作,而插入(修改)和删除的时间复杂度均为 \(O(\log n)\),时间复杂度为 \(O((n+m) \log n) = O(m \log n)\)
  • 优先队列:和二叉堆类似,但使用优先队列时,如果同一个点的最短路被更新多次,因为先前更新时插入的元素不能被删除,也不能被修改,只能留在优先队列中,故优先队列内的元素个数是 \(O(m)\) 的,时间复杂度为 \(O(m \log m)\)
  • \(\text{Fibonacci}\) 堆:和前面二者类似,但 \(\text{Fibonacci}\) 堆插入的时间复杂度为 \(O(1)\),故时间复杂度为 \(O(n \log n + m)\),时间复杂度最优。但因为 \(\text{Fibonacci}\) 堆较二叉堆不易实现,效率优势也不够大\(^1\),算法竞赛中较少使用。
  • 线段树:和二叉堆原理类似,不过将每次成功松弛后插入二叉堆的操作改为在线段树上执行单点修改,而 \(1\) 操作则是线段树上的全局查询最小值。时间复杂度为 \(O(m \log n)\)

在稀疏图中,\(m = O(n)\),使用二叉堆实现的 \(\text{Dijkstra}\) 算法较 \(\text{Bellman–Ford}\) 算法具有较大的效率优势;而在稠密图中,\(m = O(n^2)\),这时候使用暴力做法较二叉堆实现更优。

正确性证明

下面用数学归纳法证明,在 所有边权值非负 的前提下,\(\text{Dijkstra}\) 算法的正确性\(^2\)

简单来说,我们要证明的,就是在执行 \(1\) 操作时,取出的结点 \(u\) 最短路均已经被确定,即满足 \(D(u) = dis(u)\)

初始时 \(S = \varnothing\),假设成立。

接下来用反证法。

\(u\) 点为算法中第一个在加入 \(S\) 集合时不满足 \(D(u) = dis(u)\) 的点。因为 \(s\) 点一定满足 \(D(u)=dis(u)=0\),且它一定是第一个加入 \(S\) 集合的点,因此将 \(u\) 加入 \(S\) 集合前,\(S \neq \varnothing\),如果不存在 \(s\)\(u\) 的路径,则 \(D(u) = dis(u) = +\infty\),与假设矛盾。

于是一定存在路径 \(s \to x \to y \to u\),其中 \(y\)\(s \to u\) 路径上第一个属于 \(T\) 集合的点,而 \(x\)\(y\) 的前驱结点(显然 \(x \in S\))。需要注意的是,可能存在 \(s = x\)\(y = u\) 的情况,即 \(s \to x\)\(y \to u\) 可能是空路径。

因为在 \(u\) 结点之前加入的结点都满足 \(D(u) = dis(u)\),所以在 \(x\) 点加入到 \(S\) 集合时,有 \(D(x) = dis(x)\),此时边 \((x,y)\) 会被松弛,从而可以证明,将 \(u\) 加入到 \(S\) 时,一定有 \(D(y)=dis(y)\)

下面证明 \(D(u) = dis(u)\) 成立。在路径 \(s \to x \to y \to u\) 中,因为图上所有边边权非负,因此 \(D(y) \leq D(u)\)。从而 \(dis(y) \leq D(y) \leq D(u)\leq dis(u)\)。但是因为 \(u\) 结点在 1 过程中被取出 \(T\) 集合时,\(y\) 结点还没有被取出 \(T\) 集合,因此此时有 \(dis(u)\leq dis(y)\),从而得到 \(dis(y) = D(y) = D(u) = dis(u)\),这与 \(D(u)\neq dis(u)\) 的假设矛盾,故假设不成立。

因此我们证明了,\(1\) 操作每次取出的点,其最短路均已经被确定。命题得证。

注意到证明过程中的关键不等式 \(D(y) \leq D(u)\) 是在图上所有边边权非负的情况下得出的。当图上存在负权边时,这一不等式不再成立,\(\text{Dijkstra}\) 算法的正确性将无法得到保证,算法可能会给出错误的结果。

实现

这里同时给出 \(O(n^2)\) 的暴力做法实现和 \(O(m \log m)\) 的优先队列做法实现。

暴力实现:

struct edge {
    int v, w;
};

vector<edge> e[maxn];
int dis[maxn], vis[maxn];

void dijkstra(int n, int s) {
    memset(dis, 63, sizeof(dis));
    dis[s] = 0;
    for (int i = 1; i <= n; i++) {
    int u = 0, mind = 0x3f3f3f3f;
    for (int j = 1; j <= n; j++)
        if (!vis[j] && dis[j] < mind) u = j, mind = dis[j];
    vis[u] = true;
    for (auto ed : e[u]) {
        int v = ed.v, w = ed.w;
        if (dis[v] > dis[u] + w) dis[v] = dis[u] + w;
    }
    }
}

优先队列实现:

struct edge {
    int v, w;
};

struct node {
    int dis, u;

    bool operator>(const node& a) const { return dis > a.dis; }
};

vector<edge> e[maxn];
int dis[maxn], vis[maxn];
priority_queue<node, vector<node>, greater<node> > q;

void dijkstra(int n, int s) {
    memset(dis, 63, sizeof(dis));
    dis[s] = 0;
    q.push({0, s});
    while (!q.empty()) {
    int u = q.top().u;
    q.pop();
    if (vis[u]) continue;
    vis[u] = 1;
    for (auto ed : e[u]) {
        int v = ed.v, w = ed.w;
        if (dis[v] > dis[u] + w) {
        dis[v] = dis[u] + w;
        q.push({dis[v], v});
        }
    }
    }
}

Johnson 全源最短路径算法

\(\text{Johnson}\)\(\text{Floyd}\) 一样,是一种能求出无负环图上任意两点间最短路径的算法。该算法在 1977 年由 Donald B. \(\text{Johnson}\) 提出。

任意两点间的最短路可以通过枚举起点,跑 \(n\)\(\text{Bellman–Ford}\) 算法解决,时间复杂度是 \(O(n^2m)\) 的,也可以直接用 \(\text{Floyd}\) 算法解决,时间复杂度为 \(O(n^3)\)

注意到堆优化的 \(\text{Dijkstra}\) 算法求单源最短路径的时间复杂度比 \(\text{Bellman–Ford}\) 更优,如果枚举起点,跑 \(n\)\(\text{Dijkstra}\) 算法,就可以在 \(O(nm\log m)\)(取决于 \(\text{Dijkstra}\) 算法的实现)的时间复杂度内解决本问题,比上述跑 \(n\)\(\text{Bellman–Ford}\) 算法的时间复杂度更优秀,在稀疏图上也比 \(\text{Floyd}\) 算法的时间复杂度更加优秀。

\(\text{Dijkstra}\) 算法不能正确求解带负权边的最短路,因此我们需要对原图上的边进行预处理,确保所有边的边权均非负。

一种容易想到的方法是给所有边的边权同时加上一个正数 \(x\),从而让所有边的边权均非负。如果新图上起点到终点的最短路经过了 \(k\) 条边,则将最短路减去 \(kx\) 即可得到实际最短路。

但这样的方法是错误的。考虑下图:

\(1 \to 2\) 的最短路为 \(1 \to 5 \to 3 \to 2\),长度为 \(−2\)

但假如我们把每条边的边权加上 \(5\) 呢?

新图上 \(1 \to 2\) 的最短路为 \(1 \to 4 \to 2\),已经不是实际的最短路了。

\(\text{Johnson}\) 算法则通过另外一种方法来给每条边重新标注边权。

我们新建一个虚拟节点(在这里我们就设它的编号为 \(0\))。从这个点向其他所有点连一条边权为 \(0\) 的边。

接下来用 \(\text{Bellman–Ford}\) 算法求出从 \(0\) 号点到其他所有点的最短路,记为 \(h_i\)

假如存在一条从 \(u\) 点到 \(v\) 点,边权为 \(w\) 的边,则我们将该边的边权重新设置为 \(w+h_u-h_v\)

接下来以每个点为起点,跑 \(n\)\(\text{Dijkstra}\) 算法即可求出任意两点间的最短路了。

一开始的 \(\text{Bellman–Ford}\) 算法并不是时间上的瓶颈,若使用 priority_queue 实现 \(\text{Dijkstra}\) 算法,该算法的时间复杂度是 \(O(nm\log m)\)

正确性证明

为什么这样重新标注边权的方式是正确的呢?

在讨论这个问题之前,我们先讨论一个物理概念——势能。

诸如重力势能,电势能这样的势能都有一个特点,势能的变化量只和起点和终点的相对位置有关,而与起点到终点所走的路径无关。

势能还有一个特点,势能的绝对值往往取决于设置的零势能点,但无论将零势能点设置在哪里,两点间势能的差值是一定的。

接下来回到正题。

在重新标记后的图上,从 \(s\) 点到 \(t\) 点的一条路径 \(s \to p_1 \to p_2 \to \dots \to p_k \to t\) 的长度表达式如下:

\((w(s,p_1)+h_s-h_{p_1})+(w(p_1,p_2)+h_{p_1}-h_{p_2})+ \dots +(w(p_k,t)+h_{p_k}-h_t)\)

化简后得到:

\(w(s,p_1)+w(p_1,p_2)+ \dots +w(p_k,t)+h_s-h_t\)

无论我们从 \(s\)\(t\) 走的是哪一条路径,\(h_s-h_t\) 的值是不变的,这正与势能的性质相吻合!

为了方便,下面我们就把 \(h_i\) 称为 \(i\) 点的势能。

上面的新图中 \(s \to t\) 的最短路的长度表达式由两部分组成,前面的边权和为原图中 \(s \to t\) 的最短路,后面则是两点间的势能差。因为两点间势能的差为定值,因此原图上 \(s \to t\) 的最短路与新图上 \(s \to t\) 的最短路相对应。

到这里我们的正确性证明已经解决了一半——我们证明了重新标注边权后图上的最短路径仍然是原来的最短路径。接下来我们需要证明新图中所有边的边权非负,因为在非负权图上,\(\text{Dijkstra}\) 算法能够保证得出正确的结果。

根据三角形不等式,图上任意一边 \((u,v)\) 上两点满足:\(h_v \leq h_u + w(u,v)\)。这条边重新标记后的边权为 \(w'(u,v)=w(u,v)+h_u-h_v \geq 0\)。这样我们证明了新图上的边权均非负。

这样,我们就证明了 \(\text{Johnson}\) 算法的正确性。

不同方法的比较

最短路算法 \(\text{Floyd}\) \(\text{Bellman–Ford}\) \(\text{Dijkstra}\) \(\text{Johnson}\)
最短路类型 每对结点之间的最短路 单源最短路 单源最短路 每对结点之间的最短路
作用于 任意图 任意图 非负权图 任意图
能否检测负环? 不能
时间复杂度 \(O(N^3)\) \(O(NM)\) \(O(M\log M)\) \(O(NM\log M)\)

注:表中的 \(\text{Dijkstra}\) 算法在计算复杂度时均用 priority_queue 实现。

输出方案

开一个 pre 数组,在更新距离的时候记录下来后面的点是如何转移过去的,算法结束前再递归地输出路径即可。

比如 \(\text{Floyd}\) 就要记录 pre[i][j] = k;,Bellman–Ford 和 \(\text{Dijkstra}\) 一般记录 pre[v] = u

参考资料与注释

\(^1\): \(\text{Worst case of fibonacci heap - Wikipedia}\)

\(^2\): 《算法导论(第 3 版中译本)》,机械工业出版社,\(2013\) 年,第 \(384 - 385\) 页。

k 短路

问题描述

给定一个有 \(n\) 个结点,\(m\) 条边的有向图,求从 \(s\)\(t\) 的所有不同路径中的第 \(k\) 短路径的长度。

A * 算法

\(A^*\) 算法定义了一个对当前状态 \(x\) 的估价函数 \(f(x)=g(x)+h(x)\),其中 \(g(x)\) 为从初始状态到达当前状态的实际代价,\(h(x)\) 为从当前状态到达目标状态的最佳路径的估计代价。每次取出 \(f(x)\) 最优的状态 \(x\),扩展其所有子状态,可以用 优先队列 来维护这个值。

在求解 \(k\) 短路问题时,令 \(h(x)\) 为从当前结点到达终点 \(t\) 的最短路径长度。可以通过在反向图上对结点 \(t\) 跑单源最短路预处理出对每个结点的这个值。

由于设计的距离函数和估价函数,对于每个状态需要记录两个值,为当前到达的结点 \(x\) 和已经走过的距离 \(g(x)\),将这种状态记为 \((x,g(x))\)

开始我们将初始状态 \((s,0)\) 加入优先队列。每次我们取出估价函数 \(f(x)=g(x)+h(x)\) 最小的一个状态,枚举该状态到达的结点 \(x\) 的所有出边,将对应的子状态加入优先队列。当我们访问到一个结点第 \(k\) 次时,对应的状态的 \(g(x)\) 就是从 \(x\) 到该结点的第 \(k\) 短路。

优化:由于只需要求出从初始结点到目标结点的第 \(k\) 短路,所以已经取出的状态到达一个结点的次数大于 \(k\) 次时,可以不扩展其子状态。因为之前 \(k\) 次已经形成了 \(k\) 条合法路径,当前状态不会影响到最后的答案。

当图的形态是一个 \(n\) 元环的时候,该算法最坏是 \(O(nk\log n)\) 的。但是这种算法可以在相同的复杂度内求出从起始点 \(s\) 到每个结点的前 \(k\) 短路。

实现

#include <algorithm>
#include <cstdio>
#include <cstring>
#include <queue>
using namespace std;
const int maxn = 5010;
const int maxm = 400010;
const int inf = 2e9;
int n, m, s, t, k, u, v, ww, H[maxn], cnt[maxn];
int cur, h[maxn], nxt[maxm], p[maxm], w[maxm];
int cur1, h1[maxn], nxt1[maxm], p1[maxm], w1[maxm];
bool tf[maxn];

void add_edge(int x, int y, double z) {
  cur++;
  nxt[cur] = h[x];
  h[x] = cur;
  p[cur] = y;
  w[cur] = z;
}

void add_edge1(int x, int y, double z) {
  cur1++;
  nxt1[cur1] = h1[x];
  h1[x] = cur1;
  p1[cur1] = y;
  w1[cur1] = z;
}

struct node {
  int x, v;

  bool operator<(node a) const { return v + H[x] > a.v + H[a.x]; }
};

priority_queue<node> q;

struct node2 {
  int x, v;

  bool operator<(node2 a) const { return v > a.v; }
} x;

priority_queue<node2> Q;

int main() {
  scanf("%d%d%d%d%d", &n, &m, &s, &t, &k);
  while (m--) {
    scanf("%d%d%d", &u, &v, &ww);
    add_edge(u, v, ww);
    add_edge1(v, u, ww);
  }
  for (int i = 1; i <= n; i++) H[i] = inf;
  Q.push({t, 0});
  while (!Q.empty()) {
    x = Q.top();
    Q.pop();
    if (tf[x.x]) continue;
    tf[x.x] = true;
    H[x.x] = x.v;
    for (int j = h1[x.x]; j; j = nxt1[j]) Q.push({p1[j], x.v + w1[j]});
  }
  q.push({s, 0});
  while (!q.empty()) {
    node x = q.top();
    q.pop();
    cnt[x.x]++;
    if (x.x == t && cnt[x.x] == k) {
      printf("%d\n", x.v);
      return 0;
    }
    if (cnt[x.x] > k) continue;
    for (int j = h[x.x]; j; j = nxt[j]) q.push({p[j], x.v + w[j]});
  }
  printf("-1\n");
  return 0;
}

可持久化可并堆优化 k 短路算法

最短路树与任意路径

定义

在反向图上从 \(t\) 开始跑最短路,设在原图上结点 \(x\)\(t\) 的最短路长度为 \(dist_x\),建出 任意 一棵以 \(t\) 为根的最短路树 \(T\)

所谓最短路径树,就是满足从树上的每个结点 \(x\) 到根节点 \(t\) 的简单路径都是 \(x\)\(t\)其中 一条最短路径。

性质

设一条从 \(s\)\(t\) 的路径经过的边集为 \(P\),去掉 \(P\) 中与 \(T\) 的交集得到 \(P'\)

\(P'\) 有如下性质:

  1. 对于一条不在 \(T\) 上的边 \(e\),其为从 \(u\)\(v\) 的一条边,边权为 \(w\),定义其代价 \(\Delta e=dist_v+w-dist_u\),即为选择该边后路径长度的增加量。则路径 \(P\) 的长度 \(L_P=dist_s+\sum_{e\in P'} \Delta e\)

  2. \(P\)\(P'\) 中的所有边按照从 \(s\)\(t\) 所经过的顺序依次排列,则对于 \(P'\) 中相邻的两条边 \(e_1,e_2\),有 \(u_{e_2}\)\(v_{e_1}\) 相等或为其在 \(T\) 上的祖先。因为在 \(P\)\(e_1,e_2\) 直接相连或中间都为树边。

  3. 对于一个确定存在的 \(P'\),有且仅有一个 \(S\),使得 \(S'=P'\)。因为由于性质 \(2\)\(P'\) 中相邻的两条边的起点和终点之间在 \(T\) 上只有一条路径。

问题转化

性质 \(1\) 告诉我们知道集合 \(P'\) 后,如何求出 \(L_P\) 的值。

性质 \(2\) 告诉我们所有 \(P'\) 一定满足的条件,所有满足这个条件的边集 \(P'\) 都是合法的,也就告诉我们生成 \(P'\) 的方法。

性质 \(3\) 告诉我们对于每个合法的 \(P'\) 有且仅有一个边集 \(P\) 与之对应。

那么问题转化为:求 \(L_P\) 的值第 \(k\) 小的满足性质 \(2\) 的集合 \(P'\)

过程

由于性质 \(2\),我们可以记录按照从 \(s\)\(t\) 的顺序排列的最后一条边和 \(L_P\) 的值,来表示一个边集 \(P'\)

我们用一个小根堆来维护这样的边集 \(P'\)

初始我们将起点为 \(1\)\(1\)\(T\) 上的祖先的所有的边中 \(\Delta e\) 最小的一条边加入小根堆。

每次取出堆顶的一个边集 \(S\),有两种方法可以生成可能的新边集:

  1. 替换 \(S\) 中的最后一条边为满足相同条件的 \(\Delta e\) 更大的边。

  2. 在最后一条边后接上一条边,设 \(x\)\(S\) 中最后一条边的终点,由性质 \(2\) 可得这条边需要满足其起点为 \(x\)\(x\)\(T\) 上的祖先。

将生成的新边集也加入小根堆。重复以上操作 \(k-1\) 次后求出的就是从 \(s\)\(t\) 的第 \(k\) 短路。

对于每个结点 \(x\),我们将以其为起点的边的 \(\Delta e\) 建成一个小根堆。为了方便查找一个结点 \(x\)\(x\)\(T\) 上的祖先在小根堆上的信息,我们将这些信息合并在一个编号为 \(x\) 的小根堆上。回顾以上生成新边集的方法,我们发现只要我们把紧接着可能的下一个边集加入小根堆,并保证这种生成方法可以覆盖所有可能的边集即可。记录最后选择的一条边在堆上对应的结点 \(t\),有更优的方法生成新的边集:

  1. 替换 \(S\) 中的最后一条边为 \(t\) 在堆上的左右儿子对应的边。

  2. 在最后一条边后接上一条新的边,设 \(x\)\(S\) 中最后一条边的终点,则接上编号为 \(x\) 的小根堆的堆顶结点对应的边。

用这种方法,每次生成新的边集只会扩展出最多三个结点,小根堆中的结点总数是 \(O(n+k)\)

所以此算法的瓶颈在合并一个结点与其在 \(T\) 上的祖先的信息,如果使用朴素的二叉堆,时间复杂度为 \(O(nm\log m)\),空间复杂度为 \(O(nm)\);如果使用可并堆,每次仍然需要复制堆中的全部结点,时间复杂度同样无法承受。

可持久化可并堆优化

在阅读本内容前,请先了解可持久化可并堆的相关知识。

使用可持久化可并堆优化合并一个结点与其在 \(T\) 上的祖先的信息,
每次将一个结点与其在 \(T\) 上的父亲合并,时间复杂度为 \(O((n+m)\log m+k\log k)\),空间复杂度为 \(O(m+n\log m+k)\)。这样在求出一个结点对应的堆时,无需复制结点且之后其父亲结点对应的堆仍然可以正常访问。

注意的是,如上文所言,最终询问时不需要可并堆的合并操作。
询问时使用优先队列维护可并堆的根,对于可并堆堆顶的删除,直接将其左右儿子加入优先队列中,
就只需要 \(O(k)\) 而非 \(O(k\log m)\) 的空间。

实现

#include <algorithm>
#include <cstdio>
#include <cstring>
#include <queue>
using namespace std;
const int maxn = 200010;
int n, m, s, t, k, x, y, ww, cnt, fa[maxn];

struct Edge {
  int cur, h[maxn], nxt[maxn], p[maxn], w[maxn];

  void add_edge(int x, int y, int z) {
    cur++;
    nxt[cur] = h[x];
    h[x] = cur;
    p[cur] = y;
    w[cur] = z;
  }
} e1, e2;

int dist[maxn];
bool tf[maxn], vis[maxn], ontree[maxn];

struct node {
  int x, v;

  node* operator=(node a) {
    x = a.x;
    v = a.v;
    return this;
  }

  bool operator<(node a) const { return v > a.v; }
} a;

priority_queue<node> Q;

void dfs(int x) {
  vis[x] = true;
  for (int j = e2.h[x]; j; j = e2.nxt[j])
    if (!vis[e2.p[j]])
      if (dist[e2.p[j]] == dist[x] + e2.w[j])
        fa[e2.p[j]] = x, ontree[j] = true, dfs(e2.p[j]);
}

struct LeftistTree {
  int cnt, rt[maxn], lc[maxn * 20], rc[maxn * 20], dist[maxn * 20];
  node v[maxn * 20];

  LeftistTree() { dist[0] = -1; }

  int newnode(node w) {
    cnt++;
    v[cnt] = w;
    return cnt;
  }

  int merge(int x, int y) {
    if (!x || !y) return x + y;
    if (v[x] > v[y]) swap(x, y);
    int p = ++cnt;
    lc[p] = lc[x];
    v[p] = v[x];
    rc[p] = merge(rc[x], y);
    if (dist[lc[p]] < dist[rc[p]]) swap(lc[p], rc[p]);
    dist[p] = dist[rc[p]] + 1;
    return p;
  }
} st;

void dfs2(int x) {
  vis[x] = true;
  if (fa[x]) st.rt[x] = st.merge(st.rt[x], st.rt[fa[x]]);
  for (int j = e2.h[x]; j; j = e2.nxt[j])
    if (fa[e2.p[j]] == x && !vis[e2.p[j]]) dfs2(e2.p[j]);
}

int main() {
  scanf("%d%d%d%d%d", &n, &m, &s, &t, &k);
  for (int i = 1; i <= m; i++)
    scanf("%d%d%d", &x, &y, &ww), e1.add_edge(x, y, ww), e2.add_edge(y, x, ww);
  Q.push({t, 0});
  while (!Q.empty()) {
    a = Q.top();
    Q.pop();
    if (tf[a.x]) continue;
    tf[a.x] = true;
    dist[a.x] = a.v;
    for (int j = e2.h[a.x]; j; j = e2.nxt[j]) Q.push({e2.p[j], a.v + e2.w[j]});
  }
  if (k == 1) {
    if (tf[s])
      printf("%d\n", dist[s]);
    else
      printf("-1\n");
    return 0;
  }
  dfs(t);
  for (int i = 1; i <= n; i++)
    if (tf[i])
      for (int j = e1.h[i]; j; j = e1.nxt[j])
        if (!ontree[j])
          if (tf[e1.p[j]])
            st.rt[i] = st.merge(
                st.rt[i],
                st.newnode({e1.p[j], dist[e1.p[j]] + e1.w[j] - dist[i]}));
  for (int i = 1; i <= n; i++) vis[i] = false;
  dfs2(t);
  if (st.rt[s]) Q.push({st.rt[s], dist[s] + st.v[st.rt[s]].v});
  while (!Q.empty()) {
    a = Q.top();
    Q.pop();
    cnt++;
    if (cnt == k - 1) {
      printf("%d\n", a.v);
      return 0;
    }
    if (st.lc[a.x])  // 可并堆删除直接把左右儿子加入优先队列中
      Q.push({st.lc[a.x], a.v - st.v[a.x].v + st.v[st.lc[a.x]].v});
    if (st.rc[a.x])
      Q.push({st.rc[a.x], a.v - st.v[a.x].v + st.v[st.rc[a.x]].v});
    x = st.rt[st.v[a.x].x];
    if (x) Q.push({x, a.v + st.v[x].v});
  }
  printf("-1\n");
  return 0;
}

习题

「SDOI2010」魔法猪学院

差分约束

定义

差分约束系统是一种特殊的 \(n\) 元一次不等式组,它包含 \(n\) 个变量 \(x_1,x_2,\dots,x_n\) 以及 \(m\) 个约束条件,每个约束条件是由两个其中的变量做差构成的,形如 \(x_i-x_j\leq c_k\),其中 \(1 \leq i, j \leq n, i \neq j, 1 \leq k \leq m\) 并且 \(c_k\) 是常数(可以是非负数,也可以是负数)。我们要解决的问题是:求一组解 \(x_1=a_1,x_2=a_2,\dots,x_n=a_n\),使得所有的约束条件得到满足,否则判断出无解。

差分约束系统中的每个约束条件 \(x_i-x_j\leq c_k\) 都可以变形成 \(x_i\leq x_j+c_k\),这与单源最短路中的三角形不等式 \(dist[y]\leq dist[x]+z\) 非常相似。因此,我们可以把每个变量 \(x_i\) 看做图中的一个结点,对于每个约束条件 \(x_i-x_j\leq c_k\),从结点 \(j\) 向结点 \(i\) 连一条长度为 \(c_k\) 的有向边。

注意到,如果 \(\{a_1,a_2,\dots,a_n\}\) 是该差分约束系统的一组解,那么对于任意的常数 \(d\)\(\{a_1+d,a_2+d,\dots,a_n+d\}\) 显然也是该差分约束系统的一组解,因为这样做差后 \(d\) 刚好被消掉。

过程

\(dist[0]=0\) 并向每一个点连一条权重为 \(0\) 边,跑单源最短路,若图中存在负环,则给定的差分约束系统无解,否则,\(x_i=dist[i]\) 为该差分约束系统的一组解。

性质

一般使用 \(\text{Bellman–Ford}\) 或队列优化的 \(\text{Bellman–Ford}\)(俗称 \(\text{SPFA}\),在某些随机图跑得很快)判断图中是否存在负环,最坏时间复杂度为 \(O(nm)\)

常用变形技巧

例题 luogu P1993 小 K 的农场

题目大意:求解差分约束系统,有 \(m\) 条约束条件,每条都为形如 \(x_a-x_b\geq c_k\)\(x_a-x_b\leq c_k\)\(x_a=x_b\) 的形式,判断该差分约束系统有没有解。

题意 转化 连边
\(x_a - x_b \geq c\) \(x_b - x_a \leq -c\) add(a, b, -c);
\(x_a - x_b \leq c\) \(x_a - x_b \leq c\) add(b, a, c);
\(x_a = x_b\) \(x_a - x_b \leq 0, \space x_b - x_a \leq 0\) add(b, a, 0), add(a, b, 0);

跑判断负环,如果不存在负环,输出 Yes,否则输出 No

参考代码:

#include <algorithm>
#include <cstdio>
#include <cstring>
#include <queue>
using namespace std;

struct edge {
  int v, w, next;
} e[40005];

int head[10005], vis[10005], tot[10005], cnt;
long long ans, dist[10005];
queue<int> q;

void addedge(int u, int v, int w) {  // 加边
  e[++cnt].v = v;
  e[cnt].w = w;
  e[cnt].next = head[u];
  head[u] = cnt;
}

int main() {
  int n, m;
  scanf("%d%d", &n, &m);
  for (int i = 1; i <= m; i++) {
    int op, x, y, z;
    scanf("%d", &op);
    if (op == 1) {
      scanf("%d%d%d", &x, &y, &z);
      addedge(y, x, z);
    } else if (op == 2) {
      scanf("%d%d%d", &x, &y, &z);
      addedge(x, y, -z);
    } else {
      scanf("%d%d", &x, &y);
      addedge(x, y, 0);
      addedge(y, x, 0);
    }
  }
  for (int i = 1; i <= n; i++) addedge(0, i, 0);
  memset(dist, -0x3f, sizeof(dist));
  dist[0] = 0;
  vis[0] = 1;
  q.push(0);
  while (!q.empty()) {  // 判负环,看上面的
    int cur = q.front();
    q.pop();
    vis[cur] = 0;
    for (int i = head[cur]; i; i = e[i].next)
      if (dist[cur] + e[i].w > dist[e[i].v]) {
        dist[e[i].v] = dist[cur] + e[i].w;
        if (!vis[e[i].v]) {
          vis[e[i].v] = 1;
          q.push(e[i].v);
          tot[e[i].v]++;
          if (tot[e[i].v] >= n) {
            puts("No");
            return 0;
          }
        }
      }
  }
  puts("Yes");
  return 0;
}

例题 P4926[1007] 倍杀测量者

不考虑二分等其他的东西,这里只论述差分系统 \(\frac{x_i}{x_j}\leq c_k\) 的求解方法。

对每个 \(x_i,x_j\)\(c_k\) 取一个 \(\log\) 就可以把乘法变成加法运算,即 \(\log x_i-\log x_j \leq \log c_k\),这样就可以用差分约束解决了。

Bellman–Ford 判负环代码实现

下面是用 \(\text{Bellman–Ford}\) 算法判断图中是否存在负环的代码实现,请在调用前先保证图是连通的。

实现:

bool Bellman_Ford() {
    for (int i = 0; i < n; i++) {
    bool jud = false;
    for (int j = 1; j <= n; j++)
        for (int k = h[j]; ~k; k = nxt[k])
        if (dist[j] > dist[p[k]] + w[k])
            dist[j] = dist[p[k]] + w[k], jud = true;
    if (!jud) break;
    }
    for (int i = 1; i <= n; i++)
    for (int j = h[i]; ~j; j = nxt[j])
        if (dist[i] > dist[p[j]] + w[j]) return false;
    return true;
}

习题

Usaco2006 Dec Wormholes 虫洞

「SCOI2011」糖果

POJ 1364 King

POJ 2983 Is the Information Reliable?

posted @ 2024-07-27 16:59  So_noSlack  阅读(18)  评论(0编辑  收藏  举报