想象你是一家全国快递公司的总调度员,办公桌上摊开一张巨大的公路网:城市是节点,道路是带里程的边,你的任务是算出从总部城市出发、抵达每一座城市的最短总里程。最直接的笨办法是把候选城市按远近“人工翻名册”,但城市上百万个时,光找出下一个最近城市就很耗时。于是你请来一位“自动叫号”的智能助手——堆(Heap),它能让队伍从小到大自动排好,每次直接叫最前面的号。本文沿着这条主线,从朴素 Dijkstra 讲到二叉堆、斐波那契堆、Pairing Heap,最后介绍借“修路重赋权”实现全源最短路的 Johnson 算法。整篇文章用快递调度、叫号排队和修路打比方,让复杂的数据结构变得像日常调度一样自然。

岗位关联

掌握最短路堆优化与全源最短路算法,是现代地图导航、网络路由和编译器数据流分析的核心基础,也是大厂算法面试的高频考点,值得每个 AI 从业者投入时间。

目录
  1. 可视化演示
  2. 最短路基础
  3. 朴素Dijkstra
  4. 堆优化思路
  5. 二叉堆实践
  6. 高级堆结构
  7. Johnson算法
  8. 工程与面试
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

Dijkstra算法借助优先队列可将时间复杂度优化至O((V+E)logV)。本文对比二叉堆与斐波那契堆在Di... 切换模式查看不同维度。

本节配有交互动效(进入视口后自动加载,约 3KB)。

Dijkst:核心原理 动态展示关键流程与数据流动 交互控件调节参数实时渲染 f(x) = transform(x) 核心概念的可视化呈现

最短路基础

快递调度员的第一步是看懂地图。所谓“最短路问题”,就是在给定一张带权图、指定一个起点后,找到从起点到每个节点的最小总权重路径。如果道路没有里程,只有“通或不通”,广度优先搜索(BFS)就能像水波扩散一样逐层找到最短路径;一旦道路有了不同里程,就需要更有策略的工具。

Dijkstra 算法把整个过程变成一场“贪心确认仪式”:每一次都从尚未确定的城市中选出当前距离最小的那个,直接认定它的最短路已经找到,然后沿着它的出边去松一口气——试着更新相邻城市的距离。这个“找到最小、更新邻居”的动作,就是整个算法的心脏。

对调度员来说,这个公式就是“绕一下会不会更近”的决策规则;而“道路里程非负”则是贪心确认的定心丸。只要所有边权大于等于 0,被确认的城市就不会因为以后绕路而变得更近;一旦出现负权边,这个“确认”就可能被推翻,算法就会失效。

d(v)←min⁡{d(v),d(u)+w(u,v)}\displaystyle d(v) \leftarrow \min\{ d(v), d(u) + w(u,v) \}
松弛公式:从 u 绕到 v 如果比已知的 d(v) 更短,就把 d(v) 更新为更短的值。
  1. 当前距离d(v) 表示目前从起点到城市 v 的已知最短里程估计值。
    d(v)\displaystyle d(v)
  2. 候选绕行d(u) + w(u,v) 是从 u 出发经过边 (u,v) 到 v 的候选里程。
    d(u)+w(u,v)\displaystyle d(u) + w(u,v)
  3. 大小比较用 min 在旧里程和候选里程之间挑选更小者。
    min⁡{d(v),d(u)+w(u,v)}\displaystyle \min\{ d(v), d(u) + w(u,v) \}
  4. 写回更新把更短的里程写回 d(v),完成一次“松弛”操作。
    ←\displaystyle \leftarrow

朴素Dijkstra

没有智能叫号窗口时,快递调度员只能自己盯着候选名单:每确定一个城市,就从头到尾扫一遍所有没确定的城市,找出距离最短的那一个。这种实现就是朴素 Dijkstra。

朴素版维护一个距离数组和一个“已确定”标记数组。初始只有起点距离为 0,其余为无穷大。每一轮都线性扫描未确定节点,取出最小距离节点加入已确定集合,然后用它松弛所有出边。因为每确定一个节点要扫描 V 个节点,共确定 V 次,所以总时间大约为 $O(V^2)$ 加上松弛所有边消耗的 $O(E)$。

在规模很小或图非常稠密的场景,O(V²) 的朴素实现反而干净利落:它不需要维护复杂的堆结构,常数极小。可一旦 V 达到百万量级,“每轮全表扫描”就成了调度员面前最刺眼的瓶颈——每次翻名册的成本,已经远远超过真正处理一条道路的时间。

T(V,E)=O(V2+E)=O(V2)\displaystyle T(V,E) = O(V^2 + E) = O(V^2)
朴素实现的时间复杂度:当边数 E 远小于 V² 时,主要开销来自 V 次线性扫描。
  1. V 次迭代每轮确定一个节点,一共 V 轮,对应所有城市都确认完毕。
    V\displaystyle V
  2. 线性扫描每轮从最多 V 个未确定节点中找出距离最小者。
    O(V)\displaystyle O(V)
  3. 总扫描开销V 轮 × 每轮 O(V),得到主导项 O(V²)。
    V×O(V)=O(V2)\displaystyle V \times O(V) = O(V^2)
  4. 松弛边每条边在算法执行中至多被成功松弛一次,贡献 O(E)。
    +O(E)\displaystyle + O(E)

堆优化思路

调度员决定升级系统:引入一个“自动叫号窗口”——小顶堆。所有候选城市按照当前距离住在堆里,堆顶就是距离最小者。每次要确认下一个城市,不再扫描全表,而是直接叫堆顶的号;确认完再把它邻居的“新号牌”丢进堆里。每一次叫号或插号,堆都会自动维护,成本只要 O(log V)。

这里有个实用技巧叫“惰性删除”。推送新距离时并不删除堆里的旧记录,而是让它们留着;弹出时如果发现堆顶这条记录已经过期(距离大于当前已知的最短距离),就直接扔掉再弹下一个。这样虽然堆里可能积压旧记录,但实现极其简单,避免了维护“节点在堆中位置”的复杂索引。

堆优化的本质是用“维护有序集合”的代价换掉“全表扫描”的代价。对小顶堆而言,插入和取最小都只需要对数时间,于是 Dijkstra 的核心瓶颈从 O(V) 降低到 O(log V)。这正是“智能叫号窗口”对“人工翻名册”的降维打击。

T(V,E)=O((V+E)log⁡V)\displaystyle T(V,E) = O((V+E)\log V)
堆优化复杂度:V 次弹出加 E 次插入/松弛,每次堆操作 O(log V),总计 O((V+E)log V)。
  1. V 次弹出每个节点最终都会作为堆顶被弹出一次。
    V×O(log⁡V)\displaystyle V \times O(\log V)
  2. E 次入堆每条边松弛成功时会把新距离推入堆中,最多 E 次。
    E×O(log⁡V)\displaystyle E \times O(\log V)
  3. 合并两项把弹出与插入的开销相加。
    (V+E)O(log⁡V)\displaystyle (V + E)O(\log V)
  4. 简写结果即 O((V+E)log V),稀疏图时接近 O(V log V)。
    O((V+E)log⁡V)\displaystyle O((V+E)\log V)

二叉堆实践

小顶堆最常见的落地形态是二叉堆:用一片连续数组模拟一棵完全二叉树。节点 i 的孩子是 2i 和 2i+1,父亲是 i//2,代码简单到可以手写。调度员的“排队簿”从此变成一维表格,只需记住“父节点永远不大于子节点”这一条规则。

插入新号牌时,把元素放到数组末尾,然后不断和父节点比较,小了就上浮;弹出堆顶时,把末尾元素搬到堆顶,再和两个子节点中较小的比较,大了就下沉。上浮和下沉每一步只沿树的一条链路走,深度约 $\log V$,所以单次插入或弹出都是 O(log V)。

二叉堆的优点在于常数极小、缓存友好、实现难度低,因此它是工程中使用最广的堆。哪怕斐波那契堆在理论上更漂亮,许多真实系统依然偏爱“虽然多一个 log V,但每个常数都更小”的二叉堆——就像调度员宁可要一台皮实耐用的叫号机,也不愿伺候一台精密但娇贵的设备。

insert/pop:O(log⁡V),build heap:O(V)\displaystyle \text{insert/pop}: O(\log V),\quad \text{build heap}: O(V)
二叉堆操作复杂度:插入和删除堆顶均为 O(log V);把 V 个元素一次性建成堆只需要 O(V)。
  1. 插入上浮新元素从数组尾部开始,逐级与父亲比较交换,直到堆序满足。
    O(log⁡V)\displaystyle O(\log V)
  2. 删除下沉删除堆顶后,末尾元素补位,再逐级与孩子比较交换。
    O(log⁡V)\displaystyle O(\log V)
  3. 一次建堆从最后一个非叶子节点开始向下调整,总代价线性。
    O(V)\displaystyle O(V)
  4. Dijkstra 整体V 次弹出加最多 E 次插入,得到 O((V+E)log V)。
    O((V+E)log⁡V)\displaystyle O((V+E)\log V)

高级堆结构

当理论家们嫌“多一个 log V 不够优雅”时,斐波那契堆登场了。它不像二叉堆那样严格排序,而是用一棵松散的森林管理节点,把很多工作延后到以后一次性摊还。其中最关键的能力是 decrease-key(减小键值)在摊还分析下只需 O(1):相当于调度员在待办板上改一个数字,不用立刻调整整棵树。

于是基于斐波那契堆的 Dijkstra 能把复杂度压到 O(E + V log V),比二叉堆在边数巨大时更优越。但它是出了名的“理论贵族”:实现复杂、常数大、内存开销高,实际中较少直接使用。真正在工程界流行的是 Pairing Heap——一种结构更简单、同样支持高效 decrease-key 的配对堆,摊还性能在实践中往往能和斐波那契堆掰手腕,却容易实现得多。

调度员的经验是:纸面上的复杂度不是唯一标准。斐波那契堆像一台功能强大的“智能待办板”,但组装和维修成本太高;Pairing Heap 像拼车配对账本,实现容易、实际速度快,是许多竞赛与开源库的心头好。

T(V,E)=O(E+Vlog⁡V)\displaystyle T(V,E) = O(E + V\log V)
斐波那契堆版 Dijkstra 复杂度:V 次弹出各 O(log V),E 次减小键各 O(1) 摊还,合计 O(E + V log V)。
  1. V 次弹出extract-min 每次摊还 O(log V),贡献 V log V。
    V×O(log⁡V)\displaystyle V \times O(\log V)
  2. E 次键减小decrease-key 每次摊还 O(1),贡献 E。
    E×O(1)=O(E)\displaystyle E \times O(1) = O(E)
  3. 相加得到总复杂度为 O(E + V log V)。
    O(E+Vlog⁡V)\displaystyle O(E + V\log V)
  4. 对比二叉堆当 E 远大于 V 时,二叉堆的 E log V 与这里相比差了一个 log V 因子。
    O(Elog⁡V)vsO(E+Vlog⁡V)\displaystyle O(E\log V) \quad \text{vs} \quad O(E + V\log V)

Johnson算法

现在调度员面临新任务:不光要知道从总部到每个城市的距离,还要一次性算出任意两城之间的最短路,即“全源最短路”。一个朴素想法是对每个城市分别跑一次 Dijkstra,但 Dijkstra 要求非负边权;如果地图里有逆风路(负权边)甚至负环,就不能直接套用。

Johnson 算法的解决思路充满智慧:派一支修路队,先给每个城市一个“海拔高度” h(v),然后调整每条道路的里程:新里程 = 原里程 + 出发地海拔 - 目的地海拔。只要这个差值为非负,就能安全跑 Dijkstra;而整条路径的“海拔差”首尾抵消,所以最短路路径结构完全不变。求海拔的过程通常用 Bellman-Ford 从一个超级源点出发做多次松弛,顺便还能检测负环。

Johnson 算法把“负权难题”化解为“先修路、再逐城跑 Dijkstra”。Bellman-Ford 预处理是 O(VE),随后用二叉堆对每个源点跑 Dijkstra,整体常写作 O(VE log V);若用斐波那契堆则可达 O(V² log V + VE)。一步重赋权,换来所有边非负,这是最短路领域最优雅的组合拳之一。

w′(u,v)=w(u,v)+h(u)−h(v)≥0\displaystyle w'(u,v) = w(u,v) + h(u) - h(v) \ge 0
重赋权公式:调整后的边权非负,且任意两点间最短路路径保持不变,只需在最后用势函数还原真实长度。
  1. 势函数h(v) 是从超级源点到 v 的最短距离估计,由 Bellman-Ford 得到。
    h(v)\displaystyle h(v)
  2. 重赋权对每条边 (u,v) 计算 w + h(u) - h(v)。
    w′(u,v)=w(u,v)+h(u)−h(v)\displaystyle w'(u,v) = w(u,v) + h(u) - h(v)
  3. 非负保证Bellman-Ford 的三角不等式保证新边权不小于 0。
    w′(u,v)≥0\displaystyle w'(u,v) \ge 0
  4. 路径不变性一条路径上所有 h 首尾抵消,总长度关系为 $d'(s,t)=d(s,t)+h(s)-h(t)$。
    d(s,t)=d′(s,t)−h(s)+h(t)\displaystyle d(s,t) = d'(s,t) - h(s) + h(t)

工程与面试

调度员的知识最终要落到真实的货运网络。在互联网路由协议 OSPF 中,路由器就是用 Dijkstra 计算到各子网的最短路径;手机地图导航在实时路网中计算 ETA,同样需要堆优化甚至 A* 变体;编译器构造支配树、做数据流分析时,也会用到类似的最短路与优先队列思想。

选型时最关键的是看清图的形态。稀疏图(E≈V)用堆优化优势巨大,复杂度从 O(V²) 降到 O(V log V);但如果是完全图级别的密集图,O(V²) 的朴素实现反而可能快过带 log V 因子的堆优化。面试官最爱问的正是这种“复杂度 vs 常数”的取舍。

对面试者来说,能准确说出“朴素 O(V²)、二叉堆 O((V+E)log V)、斐波那契堆 O(E+V log V)、Johnson 重赋权 O(VE log V)”,并解释何时该选哪个,就足以证明你把调度员的理论工具箱吃透了。

O((V+E)log⁡V)vsO(V2)\displaystyle O((V+E)\log V) \quad \text{vs} \quad O(V^2)
复杂度取舍:稀疏图选堆优化,密集图可保持朴素版;真实工程还要算上堆的常数和内存。
  1. 稀疏图当 E≈V 时,堆优化约 O(V log V),远优于 O(V²)。
    O(Vlog⁡V)\displaystyle O(V\log V)
  2. 密集图当 E≈V² 时,堆优化变成 O(V² log V),朴素 O(V²) 反而更好。
    O(V2)\displaystyle O(V^2)
  3. 常数因素堆操作的 log 因子被常数放大后,可能抵消理论优势。
    practical constant\displaystyle \text{practical constant}
  4. 场景决定路由表、导航和编译器各自选择最合适的堆与实现。
    choose by graph shape\displaystyle \text{choose by graph shape}

AI 应用场景

地图导航:实时路网 ETA 计算

阶段:路径规划与决策模块中的图搜索组件。

解决的问题:传统导航在大规模城市路网上,若用朴素 Dijkstra 每轮线性扫描,响应延迟随节点数上升显著;AI 系统需要在毫秒级响应中同时考虑路况权重和实时更新,堆优化的 Dijkstra 配合启发式搜索可把候选节点控制在低成本范围内。

具体案例:高德/百度地图在用户输入起点终点后,路网引擎先加载数十万级道路节点,用小顶堆维护候选路口,逐层松弛得到最快路径;拥堵路段权值变化后,只需将道路权重更新并重新计算受影响区域的局部最短路。

网络路由:OSPF 最短路径树

阶段:网络拓扑发现后的链路状态计算。

解决的问题:OSPF 路由器需要快速计算到所有子网的下一跳;传统方法若全表扫描,路由表收敛时间会随网络规模膨胀,威胁网络稳定性。

具体案例:在数据中心拓扑中,每台路由器用 Dijkstra 堆优化构建最短路径树,优先队列保存候选路由器,堆顶取最小开销邻居,实现秒级收敛;斐波那契堆和 Pairing Heap 的复杂度对比帮助网络设备厂商决定实现策略。

编译器优化:支配树与数据流分析

阶段:编译器中端优化阶段的控制流图分析。

解决的问题:编译器需要找出每个基本块的支配者,构建支配树;这类图算法本质上是带优先级的传播过程,与 Dijkstra 的贪心松弛同构。

具体案例:LLVM 在构建支配树时使用类似 Dijkstra 的迭代数据流算法,并用工作队列按处理优先级调度基本块;理解堆优化思想有助于编译器工程师分析 Pass 的复杂度瓶颈,优化大型函数的编译时间。

Python 演示

python70 行
import heapq  def dijkstra_heap(graph, src):    n = len(graph)    dist = [float('inf')] * n    dist[src] = 0    pq = [(0, src)]    while pq:        d, u = heapq.heappop(pq)        if d > dist[u]:            continue        for v, w in graph[u]:            if dist[v] > dist[u] + w:                dist[v] = dist[u] + w                heapq.heappush(pq, (dist[v], v))    return dist  def dijkstra_naive(graph, src):    n = len(graph)    dist = [float('inf')] * n    used = [False] * n    dist[src] = 0    for _ in range(n):        best = float('inf')        u = -1        for i in range(n):            if not used[i] and dist[i] < best:                best = dist[i]                u = i        if u == -1:            break        used[u] = True        for v, w in graph[u]:            if dist[v] > dist[u] + w:                dist[v] = dist[u] + w    return dist  def johnson(graph):    n = len(graph)    h = [0] * (n + 1)    super_graph = graph + [[(i, 0) for i in range(n)]]    for _ in range(n):        updated = False        for u in range(n + 1):            for v, w in super_graph[u]:                if h[v] > h[u] + w:                    h[v] = h[u] + w                    updated = True        if not updated:            break    reweight = [[(v, w + h[u] - h[v]) for v, w in graph[u]] for u in range(n)]    all_dist = []    for s in range(n):        d = dijkstra_heap(reweight, s)        all_dist.append([d[t] - h[s] + h[t] for t in range(n)])    return all_dist  if __name__ == '__main__':    graph = [[(1, 4), (2, 2)],             [(2, -1), (3, 5)],             [(3, 8), (4, 10)],             [(4, 2)],             []]    print(dijkstra_heap(graph, 0))    print(dijkstra_naive(graph, 0))    print(johnson(graph))
目的:用 heapq 实现堆优化 Dijkstra,与朴素版对比,并实现 Johnson 算法处理含负权边的全源最短路。
  1. pq = [(0, src)]堆中存放二元组(当前距离,节点编号),保证按距离排序。
  2. d, u = heapq.heappop(pq)每次弹出距离最小的候选节点。
  3. if d > dist[u]: continue惰性删除:跳过已经过期的旧记录。
  4. dist[v] = dist[u] + w松弛操作,更新 v 的最短距离估计。
  5. super_graph = graph + [[(i, 0) for i in range(n)]]添加超级源点,用于 Bellman-Ford 求势函数。
  6. reweight = [[(v, w + h[u] - h[v]) for v, w in graph[u]] for u in range(n)]重赋权,使所有边权非负。
  7. all_dist.append([d[t] - h[s] + h[t] for t in range(n)])还原真实路径长度。

常见误区

误区Dijkstra 每次从堆顶取最小距离节点,看起来很稳妥,所以有负权边也应该能用吧?只要多松弛几次就行。

正解Dijkstra 的贪心确认依赖“边权非负”这一前提。一旦存在负权边,已确认节点的距离可能被后续更短的路径推翻,堆里的顺序也随之失效。例如 A→B 权 5,A→C 权 2,C→B 权 -5,若先确认 B 得 5,之后再经过 C 得到 -3 就推翻了结论。负权图应改用 Bellman-Ford 或 SPFA。

为何常见:因为堆优化实现看起来很“自动化”,初学者容易忽略算法正确性的数学前提,把“反复松弛”误当成万能补丁。

误区网上资料都说 Dijkstra 堆优化是 O((V+E)logV),所以任何堆实现都是这个复杂度,斐波那契堆也一样。

正解这个复杂度默认使用 decrease-key 或每次只对成功松弛的边入堆的简化模型。如果每次 push 不删除旧记录,堆中可能积累 O(E) 条记录,整体是 O(E log E);密集图时 O(V² log V),比朴素 O(V²) 更差。斐波那契堆则是 O(E + V log V)。

为何常见:多数教材只展示标准写法,忽略了实现细节对复杂度的影响,导致读者把“标准结论”当成“全部事实”。

误区既然斐波那契堆在复杂度上最优秀,那 Dijkstra 工程实现就应该优先选它。

正解斐波那契堆虽然理论复杂度漂亮,但实现复杂、指针操作多、内存占用大、常数极高;在中等规模图上,二叉堆或 Pairing Heap 往往更快。工程中更常用二叉堆,或类似 Pairing Heap 的简单摊还堆,只有在 E 远大于 V 且 decrease-key 操作极多的场景,斐波那契堆才可能胜出。

为何常见:初学者容易被大 O 记号迷惑,忽略了常数和实际机器行为对运行时间的影响。

误区只要小顶堆里堆顶距离最小,就能直接把它标记为已确定,不用管它的距离是否过期。

正解堆优化中常采用惰性删除,堆里可能残留旧记录。弹出堆顶后必须检查“记录距离是否等于当前 dist[u]”,如果更大则说明这条记录已经过期,直接忽略并继续弹出。否则可能把已确定节点再次当作候选,破坏算法流程。

为何常见:因为教材简化了实现,省略了过期记录细节,读者照抄简单版本时,没有意识到需要在弹出时做距离校验。

误区Johnson 给每条边重新赋了权,那算出来的最短路径长度已经不是原始长度,直接当作答案就行。

正解重赋权改变了边的权重值,但每条完整路径首尾的势函数差值互相抵消,所以路径选择不变,只是长度整体平移。最终必须用公式 $d(s,t) = d'(s,t) - h(s) + h(t)$ 还原真实距离。如果直接返回重赋权后的距离,结果就会系统性偏移。

为何常见:因为初学者只见“重赋权”这个操作,忽略了算法输出需要“还原”的收尾步骤。

面试题

Q1. Dijkstra 算法能够正确求解最短路的先决条件是什么?

单选题 · easy

  • A.图中不能有环
  • B.图中边权必须非负← 正确答案
  • C.图必须是有向图
  • D.图中只能有一个起点

答案:B。Dijkstra 的贪心确认依赖“已确认节点不会被更短路径推翻”,只有边权非负才保证这一点。负权边会导致已确认节点被后续路径更新,负环则使最短路无定义。无环、有向图都不是必要前提。

Q2. 朴素 Dijkstra 算法的时间复杂度是多少?

单选题 · easy

  • A.O(V)
  • B.O(V²)← 正确答案
  • C.O(E log V)
  • D.O(V E)

答案:B。每轮在线性扫描中找最小距离节点需要 O(V),共进行 V 轮,加上松弛所有边 O(E),总复杂度为 O(V²+E)=O(V²)。E log V 是堆优化版本的复杂度,O(VE) 是 Bellman-Ford 的典型复杂度。

Q3. 在小顶堆优化的 Dijkstra 中,堆顶元素代表什么?

单选题 · easy

  • A.距离起点最远的候选节点
  • B.距离起点最近的候选节点← 正确答案
  • C.距离起点最近的已确定节点
  • D.路径上最后一个节点

答案:B。小顶堆按当前距离从小到大排列,堆顶就是未被确认节点中距离起点最近的一个,因此每次取堆顶即可完成“找最小”这一步。堆中并不存储完整路径,也不能代表已确定节点。

Q4. 以下哪一项是堆优化 Dijkstra 中“惰性删除”的做法?

单选题 · mid

  • A.弹出堆顶时原地调整堆结构
  • B.更新距离时删除堆中的旧记录
  • C.不主动删除旧记录,弹出时检查距离是否过期← 正确答案
  • D.延迟到所有节点确定后再清理堆

答案:C。惰性删除允许堆中残留旧记录,弹出堆顶后比较记录距离和当前 dist[u],若更大说明过期则忽略。这样避免了维护节点在堆中位置的复杂索引,牺牲少量空间换取实现简洁。

Q5. 对于稀疏图 E≈V,二叉堆优化 Dijkstra 的复杂度约为?

单选题 · mid

  • A.O(V)
  • B.O(V log V)← 正确答案
  • C.O(V²)
  • D.O(V² log V)

答案:B。堆优化复杂度 O((V+E)log V),当 E≈V 时约 O(2V log V),即 O(V log V)。O(V²) 是朴素实现,O(V² log V) 是密集图下堆优化的退化结果。

Q6. 斐波那契堆优化的 Dijkstra 时间复杂度是?

单选题 · mid

  • A.O(V²)
  • B.O((V+E)log V)
  • C.O(E + V log V)← 正确答案
  • D.O(E log V)

答案:C。斐波那契堆的 extract-min 摊还 O(log V),decrease-key 摊还 O(1),因此 V 次弹出加 E 次减小键得到 O(E + V log V)。二叉堆是 O((V+E)log V),朴素实现是 O(V²)。

Q7. Johnson 算法中,重赋权公式 $w'(u,v)=w(u,v)+h(u)-h(v)$ 的作用是什么?

单选题 · hard

  • A.将所有边权变为偶数
  • B.保证所有边权非负,且不改变最短路路径结构← 正确答案
  • C.消除所有环
  • D.将图转化为无向图

答案:B。Bellman-Ford 求出的 h(v) 满足三角不等式 $h(v) \le h(u)+w(u,v)$,移项即得 $w(u,v)+h(u)-h(v)\ge0$。一条完整路径的势函数差值首尾抵消,因此最短路路径不变,长度只需最后还原。

Q8. Johnson 算法使用二叉堆实现时,典型时间复杂度是?

单选题 · hard

  • A.O(V E log V)← 正确答案
  • B.O(V²)
  • C.O(V E)
  • D.O(E + V log V)

答案:A。Bellman-Ford 预处理 O(VE),每个源点跑二叉堆 Dijkstra O((V+E)log V),V 个源点总 O(VE + V(V+E)log V),通常简写为 O(VE log V)。选项 B 是朴素单源,C 是 Bellman-Ford 单源,D 是斐波那契堆单源。

Q9. 哪些堆结构可以用于优化 Dijkstra 算法?

多选题 · mid

  • A.二叉堆← 正确答案
  • B.斐波那契堆← 正确答案
  • C.Pairing Heap← 正确答案
  • D.哈希表

答案:ABC。二叉堆、斐波那契堆、Pairing Heap 都支持高效的取最小操作,适合做 Dijkstra 的优先队列。哈希表不支持有序取最小,无法直接作为 Dijkstra 的调度容器。

Q10. 关于 Johnson 算法,下列说法正确的有?

多选题 · hard

  • A.需要用 Bellman-Ford 求势函数并检测负环← 正确答案
  • B.重赋权后所有边权非负← 正确答案
  • C.重赋权会改变最短路路径的选边结果
  • D.最后要用 $d(s,t)=d'(s,t)-h(s)+h(t)$ 还原真实长度← 正确答案

答案:ABD。Johnson 先加超级源点,用 Bellman-Ford 得到势函数 h,同时检测负环;重赋权公式保证新边权非负,且任意整条路径的势差首尾抵消,因此路径选边不变,C 错误;最后需要减去势差才能得到原始距离。