K短路

在图论路径问题中,最短路是一个基础且核心的议题。然而,在诸多场景下,我们关心的并非仅仅是那条唯一的、最优的路径,而是需要一个更广阔的解空间。次短路、第三短路,乃至第 K 短路(K-th Shortest Path, KSP)的需求应运而生。KSP 不仅是基础最短路算法的自然延伸,更是对状态空间搜索、启发式方法以及数据结构运用能力的一次综合考验。
问题定义:路径的界定
在深入算法之前,一个必须首先澄清的基础问题是:我们所讨论的“路径”究竟是什么?在不同的问题背景下,“路径”的定义可能存在微妙但关键的差异,这直接决定了问题的性质、解法乃至其计算复杂度的量级。
通常,一条从顶点
核心的分歧点在于:路径中的顶点或边是否允许重复?
非严格路径 (Path / Walk): 允许顶点和边在路径中重复出现。例如,在路径
中,顶点 和边 均出现了多次。在这种定义下,如果图中存在正权环,则一个 路径可以任意地绕环,从而构造出无限多条长度不同的路径。如果存在零权环,同样可以构造出无限多条长度相同的路径。这是算法竞赛中最常见的 K 短路问题设定。 简单路径 (Simple Path): 要求路径中的所有顶点都是唯一的(除了可能的起点和终点相同,形成一个环路)。这条约束自然地排除了路径中任何环路的出现。
这个定义上的区别至关重要。两类问题的求解难度和算法复杂度有着显著差异:
非严格 K 短路:在非负权图中,该问题已有非常高效的解法。经典算法如 Eppstein 算法 (Eppstein, 1998),其时间复杂度可达到
(该复杂度依赖于使用 Fibonacci 堆等高级数据结构,在竞赛中不常用)。 K 短简单路径:此问题的求解更为复杂。一个普遍的误解是认为该问题是 NP-Hard 的。事实上,在非负权有向图中,寻找 K 短简单路径是多项式时间可解的。代表性算法如 Yen’s 算法 (Yen, 1971),其时间复杂度为
。尽管是多项式时间,但其实现复杂度和运行时间常数相较于非严格版本要大得多。只有在引入负权边(此时问题与检测负环和哈密顿路径等难题相关联)或其他特定约束时,K 短简单路径问题才会变得 NP-Hard。
绝大多数情况下,当我们讨论无特殊说明的“K短路”问题时,我们指的是允许重复顶点和边的“非严格路径”。 本文的核心讨论将围绕这一经典设定展开。
朴素思想的误区
在面对一个新问题时,我们常会尝试从已知算法进行推广。对于 K 短路,一个自然的想法是基于最短路算法进行某种形式的“迭代”或“排除”。
误区一:删除最短路
一个直观的想法是:
- 找到从
到 的最短路 。 - 从图中删除
所使用的某条边或某个点。 - 在修改后的图中,再次寻找新的
最短路,作为原图的次短路 。 - 重复此过程 K 次。
这个思路是错误的。考虑下图:
最短路是
如果按照上述思路,我们找到了
更致命的是,次短路可能与最短路共享大部分边。例如:
最短路是
次短路可能是
第三短路可能是
“删除”的思想破坏了原图的结构,丢失了大量可能构成其他K短路的路径信息,因此不可行。
误区二:简单修改 Dijkstra
Dijkstra 算法的核心是 dist[u]
数组,记录了从源点 dist[u][k]
,记录
在运行 Dijkstra 时,当我们从优先队列中取出状态 (d, u)
,并考察边 (u, v)
时,我们会得到一条到 d + w(u,v)
。在标准 Dijkstra 中,我们检查 d + w(u,v) < dist[v]
。在 K 短路的变体中,我们似乎可以将这个新路径长度插入到
这个想法方向是正确的,但它引出了一个核心问题:我们优先队列中应该存储什么?
标准 Dijkstra 优先队列存储的是 (distance, vertex)
,它总是选择当前离源点最近的、尚未确定最短路的顶点进行扩展。这个贪心策略的正确性由 dist[u]
的单调性保证——一旦一个顶点的最短路被确定,它就是最终的、不会再被更新的值。
但在 K 短路中,情况发生了变化。一条当前看起来较长的路径(例如,第5短到达 d
已经不足以支撑贪心选择了。我们需要一个更具前瞻性的估价标准,来判断哪条“部分路径”最有可能成为最终的全局 K 短路之一。
这正是启发式搜索(Heuristic Search)的用武之地,而 A* 算法为此提供了完美的理论框架。
A* 算法与 K 短路
A* 算法是一种在图形平面上进行路径查找的著名算法,但其本质是一种通用的、可用于任意图结构的最佳路径搜索的算法。它通过引入一个“启发函数”来指导搜索方向,从而显著提高效率。
A* 算法核心思想
A* 算法在扩展搜索节点时,评估每个节点的优先级所依据的函数是:
其中:
是搜索过程中的一个节点(在图搜索中,通常指一个顶点)。 是从起始节点到当前节点 的实际代价(路径长度)。 是从当前节点 到目标节点的预估代价。这个函数被称为**启发函数 (Heuristic Function)**。
A* 算法的效率和正确性与
定义 (可采纳启发函数):如果对于所有节点
A 的工作流程:
A 算法使用一个优先队列来存储待访问的节点,队列按照
- 将起始节点
放入优先队列。 - 当队列不为空时,取出
值最小的节点 。 - 如果
是目标节点,则搜索成功。 - 否则,对于
的每一个邻居 : - 计算出一条通过
到达 的新路径,其代价为 。 - 如果
之前未被访问,或这条新路径更优,则更新 的信息,并将 放入优先队列。其在队列中的优先级由 决定。
- 计算出一条通过
当
将 A* 应用于 K 短路问题
现在,我们将 K 短路的求解过程重新建模为一个在状态空间中的搜索问题。这个状态空间中的“节点”不再仅仅是图中的一个顶点 u
,而是一个二元组 (u, d)
,表示“当前已经走过一条长度为 d
的路径,并抵达了顶点 u
”。我们的目标是在这个隐式的、巨大的状态空间中,找到 K 条通往终点
让我们来定义 A* 所需的函数:
- 状态: 一个
(u, g_u)
对,其中u
是当前顶点,g_u
是从源点沿某条路径到达 u
的实际已花费代价。 : 对于状态 (u, g_u)
,其值就是 本身,即从 到 的已知路径成本。 : 这是 A* 的精髓。对于状态 (u, g_u)
,我们需要一个预估其“未来代价”的启发函数。一个完美且可采纳的选择是:从顶点到终点 的真实最短路径长度。我们记作 。 : 因此,一个状态 (u, g_u)
的总估价为。
这个
由于我们选择的
核心性质:算法从优先队列中第
严格证明:A 算法求解 K 短路的正确性*
我们要证明的核心命题是:当 A* 算法第
为方便论证,我们先定义几个符号:
: 从节点 到终点 的真实最短路径长度。在我们的算法中,预处理得到的 就等于 。 : 一条路径 的长度。 : A* 搜索过程中,沿着路径 到达节点 的状态的估价,即 。 : 从 到 的真实的第 短路长度。 : 一条长度为 的路径。 : 算法第 次从优先队列中取出的终点 的状态。其对应的路径长度为 。
我们的证明目标是:
我们将使用数学归纳法来证明。
1. 基础情况 (Base Case):
我们要证明
- 设
是一条从 到 的最短路,其长度为 。考虑 上的任意一个节点 ,以及从 沿 到 的子路径 。 - 该状态的估价为
。 - 由于
是 的最短路长度,而从 沿 到 的子路径只是众多 路径中的一条,所以 。 - 因此,
。 - 这条不等式意味着,在最短路
上的任何一个状态,其 值都不会超过最短路本身的长度 。 - A* 算法维护一个优先队列,并总是取出
值最小的节点进行扩展。在算法终止前(或找到 前), 上的某个状态(或所有状态)必然会进入优先队列。 - 设
是算法第一次从队列中取出的终点状态,其路径长度为 。这意味着在取出 之前,所有被取出过的状态 的 值都满足 。 - 假设
。这意味着 。根据我们之前推导的 ,在最短路 上必然存在某个未被扩展的节点 ,其 值 。 - 这与优先队列的性质相矛盾:一个
值更小的节点 应该在 之前被取出。 - 因此,假设不成立,必然有
。又因为 是最短路长度,所以 。 - 综上,
。基础情况成立。
2. 归纳步骤 (Inductive Step)
假设对于所有
我们要证明
设
是一条 的第 短路,长度为 。与基础情况类似,对于 上的任意节点 ,其状态的 值满足 。 这意味着,在算法的某个时刻,路径
上的某个状态必然会以一个不大于 的 值进入优先队列。 设
是算法第 次从优先队列取出的终点状态。由于优先队列的 值单调非递减性,我们有 。 **我们首先证明
**。 - 假设
。 - 考虑路径
。它必然与前 条最短路 中的至少一条不同。 - 在
上的所有状态的 值都 。因为 ,所以如果 对应的终点状态尚未被发现,那么 上一定存在一个未被扩展的节点 ,其 值 。 - 这同样与优先队列的性质矛盾,因为
应该在 之前被取出。 - 因此,假设不成立,必有
。
- 假设
**接着我们证明
**。 是第 个被从队列中取出的终点状态。它对应一条从 的路径,长度为 。 - 根据归纳假设,我们已经找到了
条不短于 的路径,它们的长度分别是 。 是我们找到的下一条路径的长度。根据 A* 算法的探索机制(它不会遗漏任何可能的路径),这条新发现的路径,其长度必然大于或等于所有已发现路径的长度。 - 因此,路径
是在 的所有路径中,长度排在前 位的一条。根据第 短路长度的定义, 必然大于或等于 。
综合两点,
且 ,因此 。
结论
通过数学归纳法,我们证明了对于任意正整数
算法流程
基于 A* 的 K 短路算法分为两步:
Step 1: 预处理计算启发函数
为了得到所有点
- 构建反向图
: 对于原图 中的每条有向边 ,其权值为 ,我们在反向图 中添加一条边 ,权值不变。 - 运行最短路算法: 在
上,从源点 开始,运行一次 Dijkstra 算法(如果图中存在负权边但无负环,则使用 SPFA)。这样,我们就得到了 到所有其他点的最短距离。在 中从 到 的最短路,等价于在原图 中从 到 的最短路。将这些距离存储在数组 h
中,即h[u]
就是我们需要的。
Step 2: A 搜索*
执行一个 Dijkstra 算法的变体来进行 A* 搜索。
- 优先队列: 建立一个最小优先队列
pq
。队列中存储三元组(f, g, u)
,分别代表状态的估价、当前实际代价和当前顶点。队列根据值从小到大排序。 - 计数器: 用一个计数器
cnt[u]
记录顶点被从优先队列中取出的次数。 - 初始化: 将初始状态
(h[s], 0, s)
放入优先队列。g
值为 0 因为我们刚从出发, f
值为0 + h[s]
。 - 主循环:
- 当
pq
不为空时,取出队首元素(current_f, current_g, u)
。 - 增加
u
的被访问计数cnt[u]++
。 - 判断终点: 如果
u == t
,那么我们找到了一条到达终点的路径,其长度为current_g
。这就是第cnt[t]
短路。将这个长度记录下来。如果cnt[t] == K
,我们已经找到了全部 K 条最短路,算法可以提前终止。 - 剪枝: 如果
cnt[u] > K
,说明我们已经找到了 K 条以上到达的路径。由于 A* 算法总是优先扩展估价 更小的状态,后续再经过 的路径其估价通常会更大,不太可能构成全局前 K 短路。这个剪枝对于允许重复顶点的 KSP 是严格安全的(不会错失最优的 K 个解),并且对于包含零权环的图至关重要,可以显著减少状态空间的无谓膨胀。 - 扩展邻居: 对于
u
的每条出边(u, v)
,权值为w
:- 计算出新状态的代价:
new_g = current_g + w
。 - 计算出新状态的估价:
new_f = new_g + h[v]
。 - 将新状态
(new_f, new_g, v)
压入优先队列pq
。
- 计算出新状态的代价:
- 当
这个过程本质上是在状态空间图上运行的 Dijkstra 算法,其中 cnt[u]
是必要的,以区分到达同一顶点的不同路径。
思考与拓展
负权边与 SPFA
如果图中存在负权边但没有负权环,A* 算法的框架依然适用。但需要进行以下调整:
- 预处理: 在反向图上,从终点
运行 SPFA 算法,计算出启发函数 。 - A* 搜索: 主循环逻辑不变。值得注意的是,此时计算出的
可能为负值,导致估价 不再保证单调非负。然而,只要 仍然满足一致性条件 (对于由 SPFA 计算出的最短路程,此条件成立),A* 算法的正确性依然有保证。算法的底层机制并不依赖于 值的非负性,只依赖于其作为“最优估价”的排序能力。
一个更鲁棒的替代方案是先使用 Johnson 算法将所有边权转化为非负,然后再运行基于 Dijkstra 的标准 A* 算法。但由于 Johnson 算法本身就需要一次 SPFA,所以整体的复杂度瓶颈依然在 SPFA。
路径包含环路
需要再次强调,本文讨论的 K 短路算法找到的是允许重复顶点和边的“非严格路径”。
- 正权环: 当路径包含一个正权环时,算法会自然地将其视为一条新的、更长的路径,并作为一个独立的候选路径进行评估。
- 零权环: 如果图中存在零权环,算法可能会陷入困境,不断地绕着零权环生成无限多条长度相同的路径。
- 负权环: 如果存在负权环,则最短路本身无定义,K 短路问题也无从谈起。
应对策略:
对于零权环,我们可以使用计数器剪枝 if (++cnt[u] > K) continue;
。这个剪枝是严格安全的,它确保了对于每个中间节点
进一步讨论: 如果题目要求将“同权但路径不同(例如,绕环次数不同)”的路径视为不同解并全部计数,那么简单的 cnt[u] > K
剪枝就不再适用。此时需要根据题意调整策略,例如将剪枝阈值放宽到一个更大的数(如
第 K 短“简单”路径
在非负权有向图中,寻找第 K 短的简单路径(无重复顶点)是一个在多项式时间内可解的问题,而非通常误解的 NP-Hard。只有在引入负权边或添加其他全局约束(如必须经过某些点)后,该问题才会变得 NP-Hard。
解决 K 短简单路径的经典算法是 **Yen’s Algorithm (1971)**。其基本思想是一种迭代式的“偏离-再寻路”策略:
- 用 Dijkstra 找到最短简单路径
。 - 迭代
从 2 到 K: - 在已找到的第 1 到第
条简单路径中,对每一条路径 上的每一个节点,都尝试从该节点偏离原始路径。 - 为了强制偏离,需要临时性地从图中移除某些边或点,然后在修改后的图上寻找从偏离点到终点的新最短路。
- 将所有这样生成的候选路径收集起来,从中选出最短的一条,作为第
短简单路径。
- 在已找到的第 1 到第
Yen’s 算法的时间复杂度为
替代方法:可持久化数据结构优化
A* 算法从一个全局的视角出发,通过启发函数评估通往终点的“希望”,是解决 K 短路问题最经典和普适的方法。然而,我们也可以从另一个角度思考,即回到我们最初的那个“朴素思想”:为每个点维护一个包含K个最短路径长度的列表。这个思想的瓶颈在于如何高效地实现和更新这个列表。
我们可以将 Dijkstra 算法的 dist[u]
升级。原先 dist[u]
是一个标量,现在我们让它成为一个能够存储 K 个最小值的容器。每次通过边 (u, v)
松弛时,我们不再是简单地比较并替换,而是将新的路径长度 dist[u] + w(u,v)
尝试“插入”到 v
的 K 小值容器中。
数据结构的选择
对于每个顶点 D_v
,它能:
- 高效地插入一个新值,复杂度为
。 - 高效地查询当前存储的最大值(为了判断新值是否可以替换之),复杂度为
。 - 高效地删除(弹出)最大值,复杂度为
。 - 维持容器内元素数量不超过 K。
一个**最大堆 (Max-Heap)**,或者 C++ 中的 std::priority_queue
,是实现这个功能的理想选择。对于每个顶点 D_v
。
算法流程
- 为每个顶点
创建一个空的最大堆 。 - 创建一个全局的最小优先队列
PQ
,用于 Dijkstra 流程。队列中存放(d, u)
,表示一条长度为的路径到达了顶点 。 - 将源点
的一条长度为 0 的路径加入,即 PQ.push({0, s})
,同时D_s.push(0)
。 - 当
PQ
不为空时,取出队首(d, u)
。 - 如果
大于 的堆 中的最大值(即 d > D_u.top()
),说明这条路径(d, u)
是在我们已经找到的到达u
的更好路径之后才被处理的,它是一条“冗余”的、更劣的路径,直接跳过。 - 遍历
u
的所有出边(u, v)
,权值为w
:- 计算新路径长度
new_d = d + w
。 - 考察
的最大堆 : - 如果
的大小小于 K,直接将 new_d
插入,并将 (new_d, v)
压入全局优先队列PQ
。 - 如果
的大小等于 K,但 new_d
小于的最大元素(即 new_d < D_v.top()
),则说明new_d
比当前已知到达的 K 条路径中最差的一条要好。于是,弹出 的堆顶,将 new_d
插入,并将(new_d, v)
压入全局优先队列PQ
。
- 如果
- 计算新路径长度
算法结束时,
可持久化堆优化
上述方法存在一个效率问题。每次成功将新路径长度 new_d
插入到 PQ
中压入了 (new_d, v)
。这意味着同一条路径 (d,u)
的扩展可能会因为更新了多个邻居,而向 PQ
中加入多个元素。更重要的是,整个算法的松弛次数大大增加。
考虑一个顶点
一个精巧的优化是使用可持久化数据结构,特别是可持久化左偏树 (Persistent Leftist Tree) 或其他可持久化堆。
其思想是将 Dijkstra 与数据结构的操作更紧密地结合。我们不为每个节点维护一个独立的堆,而是让 Dijkstra 的每个状态都指向一个代表路径信息的数据结构版本。
这是一种更为高级的技巧,其核心思想是:
- Dijkstra 运行在“决策”上,而非“顶点”上。
- 每个状态
(d, u)
扩展时,不是去更新邻居v
的信息,而是基于u
的路径信息和边(u, v)
,生成一个到v
的新路径。 - 同时,我们还可以考虑从
u
的“次优”路径进行扩展。
这种方法通常被称为“可并堆优化K短路”。算法大致如下:
- 在反图上,从
点跑 Dijkstra,得到最短路树。 - 对于每个点
,它不在最短路树上的出边 称为“非树边”。每条非树边都提供了一个偏离最短路径的机会。 - 对于每个点
,我们用一个可并堆(例如左偏树)来维护所有从 出发、不走最短路树路径、最终到达 的“岔路”的候选路径。堆顶是这些岔路中长度增加最少的那条。 - 初始时,我们将
的岔路堆加入一个全局的优先队列。 - 每次从全局优先队列中取出堆顶代价最小的岔路,这就是下一条更短的路径。假设这条路径是从
点偏离的,我们不仅找到了一个新的 路径,还需要将 的下一个候选岔路,以及从这条新路径的终点 开始的岔路合并,放入全局优先队列。
这个方法的实现细节非常复杂,涉及到可并堆的大量操作(合并、插入、删除)。它的优势在于理论复杂度更优,但实现难度和代码复杂度远高于 A* 算法,在竞赛环境中除非是作为模板深熟于心,否则 A* 是更稳妥和实用的选择。
对比 A* 与“K-堆”Dijkstra
在解决非严格 K 短路问题时,两种主流思路——A* 搜索和对 Dijkstra 的直接扩展(我们称之为“K-堆”Dijkstra)——各有其适用场景和优劣。
A* 算法:
- 优点: 思路清晰,是启发式搜索的经典应用。通过一个高质量的启发函数(从各点到终点的最短路),A* 能够进行目的性极强的搜索,极大地剪除了无效的状态扩展,因此在实践中通常具有非常高的效率。其实现复杂度适中,是解决单源单汇 K 短路问题的通用标准方案。
- 缺点: 核心优势依赖于启发函数,因此需要一次反向图上的最短路预处理。这使得它天然地专精于单源单汇 (one-to-one) 的场景。如果问题变为求解从一个源点到所有其他点的 K 短路(one-to-all),A* 算法将需要对每个终点分别运行,效率会变得低下。
“K-堆”Dijkstra:
- 优点: 算法流程是标准 Dijkstra 的自然扩展,无需任何预处理。它在一次运行中,可以计算出从源点
到所有其他顶点的 K 短路,完美解决了单源全汇 (one-to-all) 的问题。 - 缺点: 性能开销较大。每个顶点需要维护一个大小为
的数据结构(如最大堆),使得每次松弛操作的代价从 上升到 。全局优先队列中可能容纳的状态数量也远多于标准 Dijkstra。其最坏时间复杂度数量级为 ,但常数更大,在实践中对于单源单汇问题不占优势。
- 优点: 算法流程是标准 Dijkstra 的自然扩展,无需任何预处理。它在一次运行中,可以计算出从源点
结论:
两种算法的选择取决于具体的问题模型:
- 对于最常见的单源单汇 (S-T) K 短路问题,A* 算法是实现难度与运行效率之间平衡点最佳的方案,是竞赛和工程中的首选武器。
- 对于求解单源全汇 K 短路问题,“K-堆”Dijkstra 的思想则是更直接和有效的选择。
特性 | A* 算法 | “K-堆”Dijkstra |
---|---|---|
核心思想 | 启发式搜索 (Best-First Search),用 $f = g + h$ 指导方向 |
Dijkstra 的直接扩展,为每个节点记录 K 个最短距离 |
问题模型 | 单源单汇 (One-to-One) K 短路 | 单源全汇 (One-to-All) K 短路 |
预处理 | 需要。需在反向图上运行一次 Dijkstra/SPFA 计算启发函数 h(u) 。 |
不需要。直接从源点开始搜索。 |
时间复杂度 | ||
空间复杂度 | 优先队列大小与 K 和图结构相关,通常较小。 | 每个节点维护一个大小为 K 的堆,空间开销较大。 |
实践效率 | 通常更高。启发函数能有效剪枝,减少无效搜索。 | 通常更低。常数因子较大,状态扩展更盲目。 |
实现难度 | 中等。需要额外实现一次反向图上的最短路。 | 相对直接。核心是对 Dijkstra 的 dist 数组进行升级。 |
首选场景 | 求解特定起点到特定终点的 K 短路问题。 | 需要一次性求出从一个起点到所有其他点的 K 短路。 |
C++ 实现范例
P2483 【模板】k 短路 / [SDOI2010] 魔法猪学院
题目背景
注:对于
题目描述
iPig 在假期来到了传说中的魔法猪学院,开始为期两个月的魔法猪训练。经过了一周理论知识和一周基本魔法的学习之后,iPig 对猪世界的世界本原有了很多的了解:众所周知,世界是由元素构成的;元素与元素之间可以互相转换;能量守恒
iPig 今天就在进行一个麻烦的测验。iPig 在之前的学习中已经知道了很多种元素,并学会了可以转化这些元素的魔法,每种魔法需要消耗 iPig 一定的能量。作为 PKU 的顶尖学猪,让 iPig 用最少的能量完成从一种元素转换到另一种元素
注意,两个元素之间的转化可能有多种魔法,转化是单向的。转化的过程中,可以转化到一个元素(包括开始元素)多次,但是一但转化到目标元素,则一份样本的转化过程结束。iPig 的总能量是有限的,所以最多能够转换的样本数一定是一个有限数。具体请参看样例。
输入格式
第一行三个数
后跟
输出格式
一行一个数,表示最多可以完成的方式数。输入数据保证至少可以完成一种方式。
输入输出样例 #1
输入 #1
1 | 4 6 14.9 |
输出 #1
1 | 3 |
说明/提示
有意义的转换方式共
显然最多只能完成其中的
如果将
数据规模
占总分不小于
占总分不小于
所有数据满足
我们以以本题为例。该题是标准的 K 短路模板,但有一个额外的限制:总路径长度不能超过一个给定的能量值。
题意简述
给定一张
解题思路
本题要求在总能量预算内,最大化不同路径的转换次数。最优策略是采用贪心思想:总是选择当前可用的、成本最低的路径进行转换。这本质上是一个 K 短路问题,我们需要按成本从小到大的顺序找出所有路径,直到能量耗尽。
考虑到性能要求,我们采用一种基于可持久化可并堆(左偏树) 的高效算法。其核心思想是将任意路径解构为“最短路 + 一系列偏离”。首先,通过在反向图上运行 Dijkstra,我们计算出所有点到终点的最短距离 ds[u]
,并构建出最短路树。
接着,我们通过一种非递归的、基于拓扑序的动态规划方式,为每个节点 u
构建一个可持久化堆 rt[u]
。这个堆存储了所有从 u
或其下游节点出发的“偏离”选项,并按偏离的额外成本
最后,主过程模拟 A* 搜索,但状态由一个全局优先队列管理。队列中的每个元素代表一个“偏离序列”的总成本。我们从第 1 短路开始,不断从队列中取出成本最低的路径。如果能量充足,就完成一次转换,并基于当前偏离决策,通过“三叉戟式”扩展(即替换为次优偏离,或在偏离后追加新偏离)生成新的候选路径放入队列,直至能量耗尽。此方法系统地、按最优顺序生成了所有路径,精确地解决了问题。
1 |
|
注: 以上代码示例针对具体题目(魔法猪学院),使用了更高级的可持久化可并堆优化,其思路在“可持久化堆优化”一节有概念性介绍。对于更通用的 K 短路问题,基于 A* 的实现会更直观,可参考“模型拓展”部分的代码框架。
深度剖析:A* 算法的正确性与效率根源
在前文中,我们确立了 A* 算法作为解决 K 短路问题的标准武器。其核心在于巧妙地结合了已行进的实际代价
正确性:为何第 K 次到达终点即为第 K 短路?
A* 算法用于 K 短路的核心性质可以表述为:当算法运行时,第
这一性质的根基在于 A* 算法所处理的节点的
让我们考虑任意一条从
这条不等式揭示了一个关键事实:任何一条完整
现在,假设我们第
是否存在一条尚未被发现的、比
假设存在这样一条路径
因此,当第
启发函数的一致性 (Consistency)
除了可采纳性 (
**定义 (一致性启发函数)**:对于任意节点
其中
这个性质在几何上等价于三角不等式。当我们用于 K 短路的启发函数
一致性是一个非常好的性质。它保证了 A* 算法所找到的任意节点
模型拓展:当 K 短路遇上约束
纯粹的 K 短路问题在算法设计中并不算高频。其更大的价值在于,它提供了一个强大的框架来解决一类更广泛的“带约束的次优路径”问题。这类问题的核心在于,路径的选择不仅受长度影响,还需满足额外的条件。解决方法通常是在 A* 的状态定义中加入新的维度来刻画这些约束。
案例一:带限制资源消耗的 K 短路
问题背景:
在一张图中,边被分为两类:普通边和特殊边(例如,需要消耗特定资源的“传送门”)。给定起点
建模思路:
这个问题的关键在于,路径的合法性不仅取决于其长度,还取决于已使用的特殊边数量。因此,标准的状态 (u, g)
已经不足以描述搜索的进展。我们需要将已用资源量也纳入状态中。
- 状态定义: 一个三元组
(u, c, g)
,表示当前到达顶点u
,已经使用了c
条特殊边,总路径长度为g
。 - A 搜索状态*: 优先队列中存储
(f, g, u, c)
。 - 启发函数
: 对于状态 (u, c, g)
,我们需要的启发函数是预估从u
到t
的最小未来代价。最直接且保证可采纳的启发函数,依然是忽略附加约束的最小代价,即到 的普通最短路程 。因为任何一条满足约束的从 到 的路径,其长度必然不会小于不考虑任何约束的最短路 。 值: 。 - 剪枝与终止:
- 当从
u
扩展到v
经过一条特殊边时,新的状态中资源消耗量c
会加一。如果c > C
,则这条路径非法,不应加入优先队列。 - 用一个计数器
cnt[t]
记录到达终点t
的次数。当cnt[t] == K
时,找到答案。 - 为了优化,可以用
vis[u][c]
记录到达状态(u,c)
的次数,如果次数超过也可以剪枝,因为我们不太可能需要超过 条路径到达同一个中间状态。
- 当从
代码实现
题意简述
给定
1 |
|
- 时间复杂度: 预处理 Dijkstra 是
。A* 搜索的状态空间大小变为了 。每次从优先队列取出并扩展的复杂度是 ,其中 是出度。总的来说,优先队列中的状态数会与 和 相关。粗略上界为 ,但实践中由于 A* 的剪枝效果,性能会好很多。 - 空间复杂度: 存图
,A* 优先队列最坏情况可达 。
这个例子展示了 K 短路问题解决范式的强大扩展性。通过将约束条件编码到状态中,我们可以将一个复杂的约束优化问题转化为在扩展状态图上的标准 A* 搜索问题。
案例二:隐式图上的 K 短路
A* 搜索的威力远不止于处理显式给出的图。在很多问题中,图的节点和边是由问题的规则隐式定义的。K 短路框架同样适用于这类问题。
问题背景:
在一个 (sx, sy)
出发,每次可以移动到上、下、左、右四个相邻且非障碍的格子,每次移动的代价为 1。求从起点到终点 (tx, ty)
的第
建模思路:
这是一个在隐式图上求 K 短路的问题。
- 节点: 网格中的每个合法坐标
(x, y)
都是图的一个节点。 - 边: 如果格子
(x1, y1)
和(x2, y2)
相邻且均非障碍,则它们之间存在一条权值为 1 的双向边。 - 状态: A* 搜索的状态是
(g, x, y)
,表示以代价g
到达了(x, y)
。 - 启发函数
: 从 (x, y)
到终点(tx, ty)
的预估代价。一个经典且可采纳的启发函数是曼哈顿距离:。因为每次移动最多使曼哈顿距离减少 1,所以这是到达终点所需步数的下界。 值: 。
算法流程与标准 K 短路完全一致,只需将图的遍历从“访问邻接表”改为“计算四个相邻坐标并判断合法性”。
代码实现
题意简述
在
1 |
|
- 时间复杂度: 状态空间是
。A* 搜索的复杂度与优先队列大小和操作次数有关。每个点最多被有效访问 次,每次扩展 4 个方向。复杂度近似为 。 - 空间复杂度:
用于存储地图和 cnt
映射。优先队列大小也与相关。
这两个案例揭示了 K 短路问题解决思路的普遍性:识别状态,定义转移,设计启发,套用 A*。 问题的变化万变不离其宗,关键在于将具体问题抽象为状态空间图上的搜索。
核心陷阱与注意事项
在实现 K 短路算法时,一些细节问题可能导致错误或低效。
零权环的处理
A* 算法允许路径中重复经过顶点和边。如果图中存在一个零权环,会发生什么?
假设有一条路径
如果问题要求找出前 K 短路,而这 K 条路径因为零权环的存在而长度相同,A* 算法会不断地从优先队列中取出这些因绕环而产生的、g
值相同但路径不同的状态,最终可能导致超时或内存溢出。
应对策略:
在 A* 搜索中加入一个对访问次数的计数器 cnt[u]
,记录顶点 u
被从优先队列中取出的次数。当 cnt[u]
超过一个阈值时(通常是
if (++cnt[u] > k) continue;
这个剪枝的原理是:我们已经找到了到达 u
的 u
的路径,其估价很可能已经高于当前找到的全局前
浮点数精度
当边权为浮点数时,所有比较操作都必须考虑精度问题。
dist[v] > dist[u] + w
应写成dist[v] > dist[u] + w + EPS
(其中EPS
是一个很小的正数,如1e-9
)。- 在优先队列的比较函数中
f > o.f
应写成f - o.f > EPS
。
忽略精度问题是浮点数权值图论问题中最常见的错误之一。
整型溢出
K 短路的路径长度可能远大于单条边的权值。如果边权较大,即使 g
也很容易超过 int
的范围。在整个算法中,所有存储路径长度的变量(g
, f
, d
数组等)都应使用 long long
类型。
再进一步:K 短路与动态规划的融合
K 短路问题在更高阶的应用中,常常与动态规划的思想结合。当边的代价不仅是常数,而是依赖于路径历史时,问题就演变成了在状态图上的最短路/K短路问题,而这个状态图本身就是 DP 状态的集合。
问题背景:
“变色龙”问题。在一张图上,每条边有一个颜色。一只变色龙从 red -> blue -> red -> ...
。路径的起始颜色不限。求满足此规则的第
建模分析:
路径的合法性取决于上一条边的颜色。因此,到达一个顶点 u
是不够的,我们还必须知道“是经由什么颜色的边到达 u
的”。
- DP 状态 / 图节点: 将原图的每个节点
u
拆分成多个状态节点。例如,如果颜色有C
种,可以拆分成(u, color_1), (u, color_2), ...
。状态(u, c)
表示“变色龙刚刚走了一条颜色为c
的边,并到达了u
”。 - 状态转移 / 图的边: 如果原图中存在一条颜色为
c2
的边(u, v)
,并且规则允许从颜色c1
转换到c2
,那么就在扩展图中添加一条从(u, c1)
到(v, c2)
的边,权值与原边相同。 - 求解: 在这个新构建的、大小为
的状态图上,问题被转化为了一个标准的 K 短路问题。源点需要特殊处理:可以从 经过任意颜色的第一条边出发,所以可以将所有可能的起始状态 (v, c)
(其中(s,v)
是颜色为c
的出边)作为多源点,或者设立一个超级源点连接它们。
这个例子完美诠释了 DP 和图论的结合。DP 的状态定义了图的节点,DP 的转移方程定义了图的边。而 K 短路算法(A*)则是在这个由 DP 关系构成的隐式图上进行的高效搜索工具。
K 短路思想的延伸:
这种“将约束/历史信息加入状态”的思想,是解决复杂路径问题的通用范式。无论是要求路径长度奇偶性、经过特定节点集合、满足某种序列模式,其本质都是通过扩展状态空间,将复杂的约束“内化”为图结构的一部分,从而将问题转化为我们熟悉的模型。K 短路提供了一个寻找“次优解集合”的强大引擎,而建模能力则决定了我们能用这个引擎解决多复杂的问题。
写在最后
K 短路问题(KSP)是图论算法中一个承上启下的重要主题。它不仅是基础最短路算法的自然延伸,更是通向更广阔的状态空间搜索、启发式方法和动态规划建模的桥梁。
- 问题定义:明确了“非严格路径”与“简单路径”的根本区别,以及它们在复杂度上的巨大差异。
- **核心算法 A***:深入剖析了 A* 算法如何通过“实际代价+未来预估”的启发式思想,高效且正确地按顺序找出 K 条最短路。
- 方法对比:比较了 A* 算法与“K-堆”Dijkstra,明确了它们分别适用于单源单汇和单源全汇的场景。
- 模型拓展:展示了如何通过扩展状态定义,将 A* 框架应用于带约束问题和隐式图问题,体现了其强大的通用性。
掌握 K 短路,不仅仅是学会一个模板。更重要的是,通过它,我们可以建立起一种解决“带约束的最优/次优路径”问题的系统性思维方法:将具体问题抽象为状态空间图,将求解过程看作是图上的智能搜索。
- Title: K短路
- Author: YZY
- Created at : 2025-07-02 02:15:50
- Updated at : 2025-07-02 02:15:50
- Link: https://blog.dtoj.team/2025/07/02/K短路/
- License: 三金家的Y同学