• K 短路问题详解


    (k) 短路问题简介

    所谓“(k) 短路”问题,即给定一张 (n) 个点,(m) 条边的有向图,给定起点 (s) 和终点 (t),求出所有 (s o t) 的简单路径中第 (k) 短的。而且一般来说 (n, m, k) 的范围在 (10^5) 级别,于是爆搜或者 (k) 次最短路这样的算法我们不做讨论。

    本文将介绍求解 (k) 短路问题的两种经典方法:(A^*) 算法 以及 可持久化可并堆做法

    (A^*) 算法

    (A^*) 思想简述

    很显然地,我们有一个暴力的 Bfs 做法:第 (k) 次搜到点 (t) 的就是所求。然而这样太慢了,我们考虑优化方案。

    对于搜索中的一个状态 (x),令 (g(x)) 为当前状态下该点到 (s) 的路径长。

    朴素的 Bfs 就是直接暴力地拓展,但我们可以设计一种方案,使得 相对接近终点的状态优先拓展

    具体

    A* 算法中,我们会引入一个函数 (f(x)),表示 (x)估值,那么函数 (f) 也被称为 估价函数。函数 (f) 的计算有一个通式:

    [f(x) = g(x) + h(x) ]

    其中 (g(x)) 代表 状态 (x) 当前的代价(h(x)) 表示 状态 (x) 到终点状态在最佳状态下的代价。一般而言,(h) 的计算方式由自己决定,但需要根据以下原则:

    • 必须不小于真实代价,否则没有意义,即跑出来的答案会错误;
    • 尽量向真实代价靠拢,这样使得算法尽可能的快。

    在搜索时,我们优先拓展 (f(x)) 值最小的状态。一般会选用 堆(优先队列) 实现。

    (A^*) 算法在本题的运用

    所幸,(h(x)) 的定义在本题还算比较显然——到终点 (t) 的最短距离。而且可以发现 (h) 已经是最优的了。

    于是算法就不难了:

    • 用一个小根堆存储状态 ((x, g(x)))。堆顶元素为 (f) 最小的。
    • (g) 函数的值不难预处理,只要在 反图 上以 (t) 为原点跑 Dijkstra 算法即可。
    • 开始时,很显然有 (g(s) = 0),那把 ((s, g(s))) 存入堆中。
    • 每次取出堆顶元素 ((x, g(x)))。如果 (x=t) 那么表示找到一条。
    • 向相邻点拓展并放入堆中。
    • 如此往复知道找到 (k) 条即可。

    代码实现

    struct statu { // 定义状态
        int pos; double g, f;
        bool operator < (const statu& rhs) const {
            return f > rhs.f; // 为了方便比较将 f 值也记下来
        }
    };
    
    int aStar(int k) {
        priority_queue<statu> pq;
        pq.push(statu{1, 0, dist[s]}); // 初始状态
        while (!pq.empty()) {
            statu x = pq.top(); pq.pop(); // 抽出当前最优状态
            if (x.pos == t) // 到终点
                if (--k == 0) return x.g; // 如果这是第 k 条,返回
            for (auto e : G[x.pos])
                pq.push(statu{ // 拓展状态
                    e.to, x.g + e.val,
                    x.g + e.val + dist[e.to]
                });
        }
        return -1; // 没有第 k 条
    }
    

    复杂度

    随机数据这个算法跑的很快,但如果图是一个 (n) 元环时,复杂度会达到 (O(nklog n)) 级别。

    可持久化可并堆做法

    最短路树

    所谓最短路树,就是从根通过树边到每个点的路径长和原图上的最短路径长相同,那么这样的树就是最短路树。

    最短路树可以通过求最短路的算法(Dijkstra/Spfa)求出。

    这里我们选定终点 (t) 为根,在反图上求出最短路树 (T)。那么每个点通过树边都是到 (t) 点的路径最短路。

    最短路树与一般路径之间的关联

    对于一条 (s o t) 的路径 (p),我们选取其中的 非树边,作为一个 集合 列表,记为 (side(p))。即 (side(p) = p setminus (p cap T))

    为方便说明,我们引入一些记号:

    • (front(e), back(e)):表示边 (e) 的前端点(靠近起点)和后端点(靠近终点)。
    • (len(e)):边或路径 (e) 的长度。
    • (dist(x)):点 (x) 到终点 (t) 的最短距离。

    (side(p)) 有如下性质:

    1. 设一条边 (e) 的增长量 (delta(e) = dist(front(e)) + len(e) - dist(back(e))),可以理解为把这条边换掉原来的 额外增加的长度 。那么路径 (p) 的长 (len(p) = dist(s) + sumlimits_{ein side(p)} delta(e))
    2. 我们将 (side(p)) 中的边按 路径的顺序 排列好,并选取其中相邻的两条边 (e_1, e_2)(e_1) 在前,注意 (e_1, e_2) 在原图中不一定相邻)。那么 (u=back(e_1),v=front(e_2)) 两点要么是同一个点,要么 (v)(u) 的祖先。原因比较显然:两边如果在图上相邻那么就是同一个点,反之就由树边向树根 (t) 的方向相连。
    3. 对于一个确定的 (side(p)) 只有一个 (s o t) 的路径 (p) 与之对应。因为最短路树上两点只有一条只经过树边的路径,而 (side(p)) 其他连续的路径段也是确定的。

    将问题转化

    根据性质 1,可以发现答案就是 (dist(s) + sumlimits_{ein side(p)}delta(e)) 的第 (k) 小值。

    那么我们只要不断构造出这 (k)(side(p)) 即可。

    如何构造 (side(p))

    根据第二个性质,我们可以对一个现有的 (side(p)) 推出另一个新的 (side(q))

    (side(p)) 最尾端的边为 (e),令 (u = front(e), v = back(e))。那么有两种构造策略:

    • 用与 (u) 相连的一条边 (e^prime) 替换(e),满足 (delta(e^prime)ge delta(e)) 并且 (delta(e^prime)) 尽可能小;
    • (v) 后面新接上一条 最短路树上祖先方向出去的 (delta) 值最小的边 (e^prime)

    顺带一提,这两个方法分别对应性质 2 的两种情况。

    于是我们实现了通过现有的一条 (s o t) 的路径得出另一条更长的路径。如果我们可以通过某种手段达到在可以承受的时间内完成一次构造,那么只要每次选取一个最小的 (side(p)),重复执行构造,直至选出第 (k) 个即可。这个 (side(p)) 的集合可以用小根堆维护。

    快速构造 (side(p)) 需要我们在每个节点维护一个 与之相连的所有非树边和祖先出去的边的最小边

    很自然的想到堆,堆中存储路径的尾边对应的堆节点以及路径长度。

    如果建出了每个节点的堆,那么上述的构造策略可以转化为(设当前堆节点为 (x)):(x) 的左右儿子替换掉当前的堆节点,或者 (x) 对应边的尾端点对应堆的根。

    那么我们实现了一次 (O(log k)) 的转移(维护 (side(p)) 的小根堆的大小为 (O(k)))。

    关于堆

    堆的话,可以通过最短路树从祖先向叶子的方向合并构造。

    但很显然用一般的二叉堆是很难避免 MLE 的结果的。不过对于可并堆我们可以考虑一下这个问题:可并堆之所以有问题是因为 每次合并都需要保留前两个堆的信息

    于是要解决这个问题,就得让信息保留。而整个堆复制是不现实的,只能 共用一些节点。那么显而易见我们需要——

    可持久化可并堆

    一般我们用 可持久化左偏树 实现,这样时空复杂度都是线性对数级别的。

    其实会左偏树的话这玩意也不难写。可以参考 OI-Wiki 可持久化可并堆 标签页学习。

    小结

    好像一切都明朗了。来归纳一下算法的步骤吧:

    • 在反图上跑 Dijkstra;
    • 构造可持久化左偏树:
      • 对于每个节点都扫一遍邻边(除树边),然后将其 (delta) 值与另一端点编号一并插入当前点的左偏树中;
      • 然后向树边祖先方向将堆合并到此。
    • 构造前 (k)(side(p))
      • 取出堆顶;
      • 向左偏树节点儿子拓展;
      • 向对应边结束点的左偏树的根拓展。
      • 如此在堆中取出的第 (k) 个记为答案。

    代码实现

    P2483 【模板】k短路 / [SDOI2010]魔法猪学院 代码

    #include <algorithm>
    #include <cstring>
    #include <iostream>
    #include <queue>
    #include <vector>
    
    using namespace std;
    const int N = 5e4 + 5;
    const int M = 2e5 + 5;
    const double inf = 1e16;
    const double eps = 1e-8;
    
    struct Graph {
        struct Edge {
            int to, nxt;
            double len;
        } e[M];
        int head[N], ecnt = 0;
        Graph() { memset(head, -1, sizeof(head)), ecnt = 0; }
        inline void insert(int u, int v, double w) {
            e[ecnt] = Edge{v, head[u], w}; head[u] = ecnt++;
        }
        inline int nxt(int i) { return e[i].nxt; }
        inline int to(int i) { return e[i].to; }
        inline double len(int i) { return e[i].len; }
    } G, R;
    
    int n, m;
    double E;
    
    int fa[N];
    double dist[N];
    bool book[N];
    
    struct vtx {
        int pos; double dist;
        bool operator < (const vtx& rhs) const {
            return dist > rhs.dist;
        }
    };
    priority_queue<vtx> pq;
    
    void Dijkstra() {
        fill(dist + 1, dist + 1 + n, inf);
        pq.push(vtx{n, dist[n] = 0.0});
        
        while (!pq.empty()) {
            int x = pq.top().pos; pq.pop();
            if (book[x]) continue;
            book[x] = true;
            for (int i = R.head[x]; ~i; i = R.nxt(i)) {
                int y = R.to(i); double l = R.len(i);
                if (dist[y] > dist[x] + l) {
                    dist[y] = dist[x] + l;
                    fa[y] = i;
                    pq.push(vtx{y, dist[y]});
                }
            }
        }
    }
    
    namespace LefT {
        struct lef {
            int ch[2], dist;
            int end; double delta;
        } tr[N << 5];
        int total = 0;
    
        inline int create(double d, int e) {
            int x = ++total;
            tr[x] = lef{{0, 0}, 1, e, d};
            return x;
        }
        inline int copy(int x) {
            return tr[++total] = tr[x], total;
        }
        int merge(int x, int y) {
            if (!x || !y) return x | y;
            if (tr[x].delta > tr[y].delta) swap(x, y);
            int z = copy(x);
            tr[z].ch[1] = merge(tr[x].ch[1], y);
            if (tr[tr[z].ch[0]].dist < tr[tr[z].ch[1]].dist)
                swap(tr[z].ch[0], tr[z].ch[1]);
            tr[z].dist = tr[tr[z].ch[1]].dist + 1;
            return z;
        }
    };
    int root[N];
    
    void initLefTr() {
        using namespace LefT;
        for (int i = 1; i <= n; i++)
            pq.push(vtx{i, dist[i]});
        tr[0].dist = -1;
        while (!pq.empty()) {
            int x = pq.top().pos; pq.pop();
            for (int i = G.head[x]; ~i; i = G.nxt(i)) if (fa[x] != i)
                root[x] = merge(root[x], create(G.len(i) + dist[G.to(i)] - dist[x], G.to(i)));
            root[x] = merge(root[x], root[G.to(fa[x])]);
        }
    }
    
    int calc() {
        using namespace LefT;
        int ret = 0;
    
        if (dist[1] > E) return 0;
        E -= dist[1], ++ret;
    
        if (!root[1]) return ret;
        pq.push(vtx{root[1], tr[root[1]].delta});
        while (!pq.empty()) {
            int x = pq.top().pos;
            double d = pq.top().dist;
            pq.pop();
    
            if (dist[1] + d > E) break;
            ++ret, E -= dist[1] + d;
    
            for (int* c = tr[x].ch, s = 2; s; --s, ++c) if (*c)
                pq.push(vtx{*c, d - tr[x].delta + tr[*c].delta});
            if (root[tr[x].end])
                pq.push(vtx{root[tr[x].end], d + tr[root[tr[x].end]].delta});
        }
    
        return ret;
    }
    
    signed main() {
        ios::sync_with_stdio(false);
        cin >> n >> m >> E;
        
        for (int i = 1; i <= m; i++) {
            int u, v; double w;
            cin >> u >> v >> w;
            if (u == n) continue;
            G.insert(u, v, w);
            R.insert(v, u, w);
        }
        Dijkstra(), initLefTr();
        cout << calc() << endl;
    
        return 0;
    }
    

    复杂度

    (O(nlog n+klog k)) 时间,(O(nlog n)) 空间。(n, m) 同阶

    总结

    两个算法各有优缺点:

    • A* 算法在大多数情况下表现优秀,但是可以被刻意构造的数据((n) 元环)卡爆。不过它的编写难度小,因此可能是更适合考场上选择的算法。
    • 可持久化可并堆的做法虽然编写比前者复杂的多,但是复杂度却是一定正确的,根本不会担心被卡的情况。

    两个都建议读者掌握,以便在不同情况下有更多的选择。

    后记

  • 相关阅读:
    [译]Vulkan教程(09)窗口表面
    [译]Vulkan教程(08)逻辑设备和队列
    [译]Vulkan教程(07)物理设备和队列家族
    Linux命令行文本工具
    go语言周边
    go第三方常用包
    Centos6安装gcc4.8及以上版本
    pyenv设置python多版本环境
    Redis慢日志
    PHP-CPP开发扩展(七)
  • 原文地址:https://www.cnblogs.com/-Wallace-/p/kth-path.html
Copyright © 2020-2023  润新知