【学习笔记】K 短路问题详解
\(k\) 短路问题简介
所谓“\(k\) 短路”问题,即给定一张 \(n\) 个点,\(m\) 条边的有向图,给定起点 \(s\) 和终点 \(t\),求出所有 \(s\to 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\) 的计算有一个通式:
\]
其中 \(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(nk\log n)\) 级别。
可持久化可并堆做法
最短路树
所谓最短路树,就是从根通过树边到每个点的路径长和原图上的最短路径长相同,那么这样的树就是最短路树。
最短路树可以通过求最短路的算法(Dijkstra/Spfa)求出。
这里我们选定终点 \(t\) 为根,在反图上求出最短路树 \(T\)。那么每个点通过树边都是到 \(t\) 点的路径最短路。
最短路树与一般路径之间的关联
对于一条 \(s\to 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)\) 有如下性质:
- 设一条边 \(e\) 的增长量 \(\delta(e) = dist(front(e)) + len(e) - dist(back(e))\),可以理解为把这条边换掉原来的 额外增加的长度 。那么路径 \(p\) 的长 \(len(p) = dist(s) + \sum\limits_{e\in side(p)} \delta(e)\)。
- 我们将 \(side(p)\) 中的边按 路径的顺序 排列好,并选取其中相邻的两条边 \(e_1, e_2\)(\(e_1\) 在前,注意 \(e_1, e_2\) 在原图中不一定相邻)。那么 \(u=back(e_1),v=front(e_2)\) 两点要么是同一个点,要么 \(v\) 是 \(u\) 的祖先。原因比较显然:两边如果在图上相邻那么就是同一个点,反之就由树边向树根 \(t\) 的方向相连。
- 对于一个确定的 \(side(p)\) 只有一个 \(s\to t\) 的路径 \(p\) 与之对应。因为最短路树上两点只有一条只经过树边的路径,而 \(side(p)\) 其他连续的路径段也是确定的。
将问题转化
根据性质 1,可以发现答案就是 \(dist(s) + \sum\limits_{e\in side(p)}\delta(e)\) 的第 \(k\) 小值。
那么我们只要不断构造出这 \(k\) 个 \(side(p)\) 即可。
如何构造 \(side(p)\)
根据第二个性质,我们可以对一个现有的 \(side(p)\) 推出另一个新的 \(side(q)\):
\(side(p)\) 最尾端的边为 \(e\),令 \(u = front(e), v = back(e)\)。那么有两种构造策略:
- 用与 \(x\) 相连的一条不短于 \(e\) 的边 替换 掉 \(e\)。
- 在 \(y\) 后面新接上一条 最短路树上祖先方向出去的最小边 \(e^\prime\)。
顺带一提,这两个方法分别对应性质 2 的两种情况。
于是我们实现了通过现有的一条 \(s\to 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(n\log n+k\log k)\) 时间,\(O(n\log n)\) 空间。设 \(n, m\) 同阶。
总结
两个算法各有优缺点:
- A* 算法在大多数情况下表现优秀,但是可以被刻意构造的数据(\(n\) 元环)卡爆。不过它的编写难度小,因此可能是更适合考场上选择的算法。
- 可持久化可并堆的做法虽然编写比前者复杂的多,但是复杂度却是一定正确的,根本不会担心被卡的情况。
两个都建议读者掌握,以便在不同情况下有更多的选择。
后记
- 原文地址:https://www.cnblogs.com/-Wallace-/p/13693289.html
- 本文作者:@-Wallace-
- 转载请附上出处。
【学习笔记】K 短路问题详解的更多相关文章
- IP2——IP地址和子网划分学习笔记之《子网掩码详解》
2018-05-04 16:21:21 在学习掌握了前面的<进制计数><IP地址详解>这两部分知识后,要学习子网划分,首先就要必须知道子网掩码,只有掌握了子网掩码这部分内容 ...
- [读书笔记]C#学习笔记三: C#类型详解..
前言 这次分享的主要内容有五个, 分别是值类型和引用类型, 装箱与拆箱,常量与变量,运算符重载,static字段和static构造函数. 后期的分享会针对于C#2.0 3.0 4.0 等新特性进行. ...
- CDN学习笔记二(技术详解)
一本好的入门书是带你进入陌生领域的明灯,<CDN技术详解>绝对是带你进入CDN行业的那盏最亮的明灯.因此,虽然只是纯粹的重点抄录,我也要把<CDN技术详解>的精华放上网.公诸同 ...
- C#学习笔记二: C#类型详解
前言 这次分享的主要内容有五个, 分别是值类型和引用类型, 装箱与拆箱,常量与变量,运算符重载,static字段和static构造函数. 后期的分享会针对于C#2.0 3.0 4.0 等新特性进行. ...
- 【Java学习笔记之三十三】详解Java中try,catch,finally的用法及分析
这一篇我们将会介绍java中try,catch,finally的用法 以下先给出try,catch用法: try { //需要被检测的异常代码 } catch(Exception e) { //异常处 ...
- jQuery学习笔记之Ajax用法详解
这篇文章主要介绍了jQuery学习笔记之Ajax用法,结合实例形式较为详细的分析总结了jQuery中ajax的相关使用技巧,包括ajax请求.载入.处理.传递等,需要的朋友可以参考下 本文实例讲述了j ...
- MyBatis学习笔记2--配置环境详解
1.MyBatis-config.xml详解 一个完整的配置文件如下所示 <configuration> <!-- <properties resource="jdb ...
- [Spring学习笔记 5 ] Spring AOP 详解1
知识点回顾:一.IOC容器---DI依赖注入:setter注入(属性注入)/构造子注入/字段注入(注解 )/接口注入 out Spring IOC容器的使用: A.完全使用XML文件来配置容器所要管理 ...
- CSS学习笔记(9)--详解CSS中:nth-child的用法
详解CSS中:nth-child的用法 前端的哥们想必都接触过css中一个神奇的玩意,可以轻松选取你想要的标签并给与修改添加样式,是不是很给力,它就是“:nth-child”. 下面我将用几个典型的实 ...
随机推荐
- mysql 触发器的创建和使用
什么是触发器 触发器(TRIGGER)是MySQL的数据库对象之一,从5.0.2版本开始支持.该对象与编程语言中的函数非常类似,都需要声明.执行等.但是触发器的执行不是由程序调用,也不是由手工启动,而 ...
- 手写一个Web服务器,极简版Tomcat
网络传输是通过遵守HTTP协议的数据格式来传输的. HTTP协议是由标准化组织W3C(World Wide Web Consortium,万维网联盟)和IETF(Internet Engineerin ...
- 痞子衡嵌入式:JLink Script文件基础及其在IAR下调用方法
大家好,我是痞子衡,是正经搞技术的痞子.今天痞子衡给大家分享的是JLink Script文件基础及其在IAR下调用方法. JLink可以说是MCU开发者最熟悉的调试工具了,相比于其他调试器(比如DAP ...
- SQL Server 不同数据间建立链接服务器进行连接查询
在平时查询以及导数据时,经常会遇到需要使用两个数据库里数据的情况,这时就会用到在两个服务器之间建立一个链接,进行操作,脚本语句如下: 举例:例如你在测试服务器上想要查询业务库里的数据信息,此脚 ...
- [USACO14JAN]Ski Course Rating G
题目链接:https://www.luogu.com.cn/problem/P3101 Slove 这题我们可以尝试建立一个图. 以相邻的两个点建边,边的权值为两个点高度差的绝对值,然后把边按照边权值 ...
- 从txt中提取子域名
import re DOMAIN =[] f = open('test.txt','r',encoding='UTF-8') w = open('domain.txt','w') for data i ...
- mongo命令行操作
- 匹配p后面不是h的单词
$string = 'python perl pear php'; // 获取p后面不是h的单词 $preg = '/\bp(?!h)[a-z]+\b/'; $status = preg_match_ ...
- 金九银十想去跳槽面试?那这份Java面经你真得看看了,写的非常详细!
前言 前两天在和朋友吃饭的时候聊到时间这个东西是真的过的好坏啊,金三银四仿佛还在昨天.一眨眼金九银十又快到了,对程序员来说这两个是一年最合适的跳槽涨薪环节了,今年的你已经做好准备了吗?不妨看看这篇文章 ...
- 网络拓扑实例之交换机基于接口地址池作为DHCP服务器(六)
组网图形 DHCP服务器简介 通常用户希望网络中的每台终端能够动态获取IP地址.DNS服务器的IP地址.路由信息.网关信息等网络参数,不需要手动配置终端的IP地址等网络参数:另外,针对一些移动终端(手 ...