百日算法修炼 · Day 18

百日算法修炼 · Day 18

主题:图的 BFS、DFS、拓扑排序、Kruskal、Prim、Dijkstra

语言:Java | 建议用时:120~180 分钟


阅读方式

本文继续采用"先练习、后答案"的方式:

  • 上半篇是独立练习区:只给题目、示例、思考问题和可折叠提示;
  • 下半篇是参考答案区:包含代码诊断、优化代码、过程图解、复杂度分析与易错点;
  • 推荐先独立写完 6 题,再通过"查看参考答案"链接逐题核对;
  • 图算法很容易因为"重复访问、环、方向、权重"等细节出错,建议每题都先说清楚数据结构的语义再写代码。

今天的核心不是记住 6 段代码,而是建立一套"图上的信息推进"思维:

  1. BFS / DFS:决定"下一个访问谁";
  2. 拓扑排序 :不断消除入度为 0 的节点;
  3. 最小生成树:不断选择"安全的最小边";
  4. Dijkstra:不断确定"当前最短距离已经不会再变"的节点。

今日任务地图

#mermaid-svg-zHR2xMw91BWeid12{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-zHR2xMw91BWeid12 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-zHR2xMw91BWeid12 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-zHR2xMw91BWeid12 .error-icon{fill:#552222;}#mermaid-svg-zHR2xMw91BWeid12 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-zHR2xMw91BWeid12 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-zHR2xMw91BWeid12 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-zHR2xMw91BWeid12 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-zHR2xMw91BWeid12 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-zHR2xMw91BWeid12 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-zHR2xMw91BWeid12 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-zHR2xMw91BWeid12 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-zHR2xMw91BWeid12 .marker.cross{stroke:#333333;}#mermaid-svg-zHR2xMw91BWeid12 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-zHR2xMw91BWeid12 p{margin:0;}#mermaid-svg-zHR2xMw91BWeid12 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-zHR2xMw91BWeid12 .cluster-label text{fill:#333;}#mermaid-svg-zHR2xMw91BWeid12 .cluster-label span{color:#333;}#mermaid-svg-zHR2xMw91BWeid12 .cluster-label span p{background-color:transparent;}#mermaid-svg-zHR2xMw91BWeid12 .label text,#mermaid-svg-zHR2xMw91BWeid12 span{fill:#333;color:#333;}#mermaid-svg-zHR2xMw91BWeid12 .node rect,#mermaid-svg-zHR2xMw91BWeid12 .node circle,#mermaid-svg-zHR2xMw91BWeid12 .node ellipse,#mermaid-svg-zHR2xMw91BWeid12 .node polygon,#mermaid-svg-zHR2xMw91BWeid12 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-zHR2xMw91BWeid12 .rough-node .label text,#mermaid-svg-zHR2xMw91BWeid12 .node .label text,#mermaid-svg-zHR2xMw91BWeid12 .image-shape .label,#mermaid-svg-zHR2xMw91BWeid12 .icon-shape .label{text-anchor:middle;}#mermaid-svg-zHR2xMw91BWeid12 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-zHR2xMw91BWeid12 .rough-node .label,#mermaid-svg-zHR2xMw91BWeid12 .node .label,#mermaid-svg-zHR2xMw91BWeid12 .image-shape .label,#mermaid-svg-zHR2xMw91BWeid12 .icon-shape .label{text-align:center;}#mermaid-svg-zHR2xMw91BWeid12 .node.clickable{cursor:pointer;}#mermaid-svg-zHR2xMw91BWeid12 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-zHR2xMw91BWeid12 .arrowheadPath{fill:#333333;}#mermaid-svg-zHR2xMw91BWeid12 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-zHR2xMw91BWeid12 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-zHR2xMw91BWeid12 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-zHR2xMw91BWeid12 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-zHR2xMw91BWeid12 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-zHR2xMw91BWeid12 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-zHR2xMw91BWeid12 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-zHR2xMw91BWeid12 .cluster text{fill:#333;}#mermaid-svg-zHR2xMw91BWeid12 .cluster span{color:#333;}#mermaid-svg-zHR2xMw91BWeid12 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-zHR2xMw91BWeid12 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-zHR2xMw91BWeid12 rect.text{fill:none;stroke-width:0;}#mermaid-svg-zHR2xMw91BWeid12 .icon-shape,#mermaid-svg-zHR2xMw91BWeid12 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-zHR2xMw91BWeid12 .icon-shape p,#mermaid-svg-zHR2xMw91BWeid12 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-zHR2xMw91BWeid12 .icon-shape .label rect,#mermaid-svg-zHR2xMw91BWeid12 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-zHR2xMw91BWeid12 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-zHR2xMw91BWeid12 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-zHR2xMw91BWeid12 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 开始 Day 18
BFS 宽度优先
DFS 深度优先
拓扑排序 Kahn
Kruskal 最小生成树
Prim 最小生成树
Dijkstra 单源最短路
统一理解 图上的贪心与搜索

题号 主题 核心能力
1 图的 BFS 队列、去重、按层扩散
2 图的 DFS 栈、去重、深入再回退
3 拓扑排序 入度表、零入度队列、环检测
4 Kruskal 边排序、并查集、避免成环
5 Prim 点解锁边、小根堆、切分思想
6 Dijkstra 距离松弛、确定节点、改写堆

第一部分:独立练习区

先不要向下翻到答案区。做图算法时,建议先回答三个问题:

我用什么结构保存"下一批候选"?如何避免重复处理?什么时候一个状态可以被最终确定?

统一图结构说明

今天默认使用如下抽象:

text 复制代码
Node
├── value   节点编号
├── in      入度
├── out     出度
├── nexts   从当前点可直接到达的邻接点
└── edges   从当前点出发的边

Edge
├── from
├── to
└── weight

Graph
├── nodes
└── edges

对 BFS、DFS、拓扑排序与 Dijkstra,可以直接理解为有向图。

对 Kruskal、Prim,本节讨论的是无向带权图 ;如果代码结构用有向 Edge 表示无向边,通常需要为一条无向边建立两个方向的边。


练习 1:图的宽度优先遍历 BFS

给定图中的起点 root,按照宽度优先的方式访问所有从 root 可达的节点,并保证:

  • 每个节点只处理一次;
  • 即使图中存在环,也不能死循环;
  • 使用迭代方式实现。

例如:

text 复制代码
1 -> 2 -> 4
|    |
v    v
3 -> 5

并且存在一条边:5 -> 1

若邻接表顺序为:

text 复制代码
1: [2, 3]
2: [4, 5]
3: [5]
5: [1]

一种 BFS 输出为:

text 复制代码
1 2 3 4 5

思考问题

  1. 为什么树的 BFS 可以不写 visited,而一般图的 BFS 几乎一定需要?
  2. visited 应该在"入队时"标记,还是"出队时"标记?两者有什么差别?
  3. 为什么队列适合表达"距离起点更近的节点优先处理"?
  4. 如果一个节点在发现时输出一次、出队时又输出一次,会发生什么?

提示 1:先占坑,再排队

一个邻居第一次被发现时就立即放入 visited,再入队。这样其他节点即使也指向它,也不会再次把它加入队列。
提示 2:只选一个明确的"处理时机"

建议把"节点出队"定义为真正处理节点的时刻,输出也只发生在这里。

写完后再看:[查看练习 1 参考答案](#查看练习 1 参考答案)


练习 2:图的深度优先遍历 DFS

仍从 root 出发,使用显式栈完成 DFS。

要求:

  • 每个可达节点只处理一次;
  • 图中有环时仍能正确结束;
  • 尽量让访问顺序与 nexts 中的邻接顺序一致。

例如:

text 复制代码
1 -> 2 -> 4
|    |
v    v
3 -> 5

若:

text 复制代码
1: [2, 3]
2: [4, 5]

希望优先沿 1 -> 2 -> 4 深入,而不是一开始就先处理 3。

思考问题

  1. DFS 为什么用栈而不是队列?
  2. 如果按 nexts 正序压栈,真正弹出的顺序会怎样?
  3. 为了让 nexts 中靠前的邻居先被处理,为什么常常需要倒序压栈?
  4. visited 在入栈时标记有什么好处?

提示:注意 LIFO 会反转顺序

栈是后进先出。如果希望 nexts 中第 0 个邻居先被弹出,就可以从 nexts 的末尾向前压栈。

写完后再看:[查看练习 2 参考答案](#查看练习 2 参考答案)


练习 3:拓扑排序

给定一个有向图 graph,如果它是 DAG(Directed Acyclic Graph,有向无环图),请返回一种合法的拓扑序。

例如:

text 复制代码
A ---> C ---> D
 \           ^
  \-> B -----|

约束关系可以理解为:

text 复制代码
A 必须在 B、C 之前
B、C 必须在 D 之前

合法拓扑序之一:

text 复制代码
A B C D

也可能是:

text 复制代码
A C B D

思考问题

  1. 为什么"入度为 0"意味着一个节点当前没有前置依赖?
  2. 从结果中"删除"一个节点,应该如何体现在其他节点上?
  3. 为什么不能直接修改原图里的 node.in?
  4. 如果最终输出节点数小于图中节点数,说明了什么?

提示 1:Kahn 算法

先把所有入度为 0 的节点放进队列。每弹出一个节点,就把它指向节点的剩余入度减 1;谁减到 0,谁进入队列。
提示 2:环不会产生新的零入度节点

如果剩余节点互相形成有向环,它们的入度无法通过删除环外节点降到 0,因此最终结果会缺节点。

写完后再看:[查看练习 3 参考答案](#查看练习 3 参考答案)


练习 4:Kruskal 最小生成树

给定一个无向连通带权图,求它的一棵最小生成树(MST)。

例如:

text 复制代码
      2
  A ----- B
  | \     |
4 |  \ 1  | 5
  |   \   |
  C ----- D
      3

你可以把 Kruskal 理解为:

text 复制代码
把所有边按权重从小到大检查

但是,不能为了便宜就把所有小边都拿走,因为生成树不能有环。

思考问题

  1. 每次拿到当前最小边时,怎样判断加入它会不会形成环?
  2. 为什么并查集特别适合回答"两个点是否已经连通"?
  3. 路径压缩与按大小合并分别优化了什么?
  4. 如果原图不连通,Kruskal 最终得到的是什么?

提示 1:连通性就是"集合"

如果边的两个端点已经属于同一个并查集,那么它们之间本来就有路径,再加这条边一定形成环。
提示 2:最小生成森林

对不连通图,同样的过程会在每个连通分量中得到一棵最小生成树,整体称为最小生成森林。

写完后再看:[查看练习 4 参考答案](#查看练习 4 参考答案)


练习 5:Prim 最小生成树

仍然求无向带权图的最小生成树,但这一次从"点"出发思考。

核心过程:

text 复制代码
选一个起点
-> 解锁它的边
-> 取最小候选边
-> 如果这条边能带来新节点,就收下
-> 新节点继续解锁新的边

思考问题

  1. Prim 为什么需要一个"已进入生成树的节点集合"?
  2. 小根堆里保存的是节点还是边?
  3. 为什么弹出的最小边也可能被丢弃?
  4. Kruskal 与 Prim 都是贪心,它们的"候选集合"有什么本质区别?
  5. 如何让 Prim 支持不连通图?

提示 1:边便宜还不够

如果一条最小边的目标节点已经在生成树中,那么加入它只会形成环,因此应该跳过。
提示 2:森林需要重新开局

遍历所有节点;如果某个节点还没访问过,就把它当作新连通分量的起点重新启动 Prim。

写完后再看:[查看练习 5 参考答案](#查看练习 5 参考答案)


练习 6:Dijkstra 单源最短路径

给定起点 from,求它到所有可达节点的最短距离。

例如:

text 复制代码
1 --2--> 2 --1--> 3 --1--> 4
 \       |
       |4
   v     v
   3     4

从 1 出发:

text 复制代码
dist(1) = 0
dist(2) = 2
dist(3) = 3
dist(4) = 4

今天建议写两版:

  1. 朴素版:每轮扫描所有候选距离,找出最小且未确定的节点;
  2. 堆优化版 :用支持 add / update / ignore 的小根堆维护候选节点。

思考问题

  1. Dijkstra 为什么要求边权不能为负数?
  2. 什么叫"松弛一条边"?
  3. 一个节点什么时候可以认为最短距离已经最终确定?
  4. 第一次发现邻居 to 时,新距离应该是 edge.weight,还是 dist(current) + edge.weight?
  5. 改写堆为什么需要同时保存"节点位置"和"节点当前距离"?

提示 1:松弛公式

经过 current 到达 next 的候选距离是 distcurrent + weight(current, next)。如果它比 next 已知距离更小,就更新 next。
提示 2:三种堆状态

一个节点可能从未进堆、仍在堆中、已经弹出。三种状态分别对应 add、update 或 ignore。
重要前提

Dijkstra 不适用于包含负权边的图。负权场景需要考虑 Bellman-Ford 等其他算法。

写完后再看:[查看练习 6 参考答案](#查看练习 6 参考答案)


第二部分:参考答案与图解

到这里再开始核对。每题先看"题目回顾"和"核心思路",再看"原代码诊断"和优化代码。

重点比较的是:容器的语义、状态何时确定、去重发生在哪里。


答案 1:图的宽度优先遍历 BFS

[返回练习 1](#返回练习 1)

题目回顾

从起点出发,按"离起点更近的节点优先"的方式扩散,并保证每个节点只处理一次。

核心思路:队列 + visited

#mermaid-svg-G3KcwHjUL5pr8AgU{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-G3KcwHjUL5pr8AgU .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-G3KcwHjUL5pr8AgU .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-G3KcwHjUL5pr8AgU .error-icon{fill:#552222;}#mermaid-svg-G3KcwHjUL5pr8AgU .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-G3KcwHjUL5pr8AgU .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-G3KcwHjUL5pr8AgU .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-G3KcwHjUL5pr8AgU .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-G3KcwHjUL5pr8AgU .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-G3KcwHjUL5pr8AgU .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-G3KcwHjUL5pr8AgU .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-G3KcwHjUL5pr8AgU .marker{fill:#333333;stroke:#333333;}#mermaid-svg-G3KcwHjUL5pr8AgU .marker.cross{stroke:#333333;}#mermaid-svg-G3KcwHjUL5pr8AgU svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-G3KcwHjUL5pr8AgU p{margin:0;}#mermaid-svg-G3KcwHjUL5pr8AgU .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-G3KcwHjUL5pr8AgU .cluster-label text{fill:#333;}#mermaid-svg-G3KcwHjUL5pr8AgU .cluster-label span{color:#333;}#mermaid-svg-G3KcwHjUL5pr8AgU .cluster-label span p{background-color:transparent;}#mermaid-svg-G3KcwHjUL5pr8AgU .label text,#mermaid-svg-G3KcwHjUL5pr8AgU span{fill:#333;color:#333;}#mermaid-svg-G3KcwHjUL5pr8AgU .node rect,#mermaid-svg-G3KcwHjUL5pr8AgU .node circle,#mermaid-svg-G3KcwHjUL5pr8AgU .node ellipse,#mermaid-svg-G3KcwHjUL5pr8AgU .node polygon,#mermaid-svg-G3KcwHjUL5pr8AgU .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-G3KcwHjUL5pr8AgU .rough-node .label text,#mermaid-svg-G3KcwHjUL5pr8AgU .node .label text,#mermaid-svg-G3KcwHjUL5pr8AgU .image-shape .label,#mermaid-svg-G3KcwHjUL5pr8AgU .icon-shape .label{text-anchor:middle;}#mermaid-svg-G3KcwHjUL5pr8AgU .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-G3KcwHjUL5pr8AgU .rough-node .label,#mermaid-svg-G3KcwHjUL5pr8AgU .node .label,#mermaid-svg-G3KcwHjUL5pr8AgU .image-shape .label,#mermaid-svg-G3KcwHjUL5pr8AgU .icon-shape .label{text-align:center;}#mermaid-svg-G3KcwHjUL5pr8AgU .node.clickable{cursor:pointer;}#mermaid-svg-G3KcwHjUL5pr8AgU .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-G3KcwHjUL5pr8AgU .arrowheadPath{fill:#333333;}#mermaid-svg-G3KcwHjUL5pr8AgU .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-G3KcwHjUL5pr8AgU .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-G3KcwHjUL5pr8AgU .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-G3KcwHjUL5pr8AgU .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-G3KcwHjUL5pr8AgU .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-G3KcwHjUL5pr8AgU .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-G3KcwHjUL5pr8AgU .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-G3KcwHjUL5pr8AgU .cluster text{fill:#333;}#mermaid-svg-G3KcwHjUL5pr8AgU .cluster span{color:#333;}#mermaid-svg-G3KcwHjUL5pr8AgU div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-G3KcwHjUL5pr8AgU .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-G3KcwHjUL5pr8AgU rect.text{fill:none;stroke-width:0;}#mermaid-svg-G3KcwHjUL5pr8AgU .icon-shape,#mermaid-svg-G3KcwHjUL5pr8AgU .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-G3KcwHjUL5pr8AgU .icon-shape p,#mermaid-svg-G3KcwHjUL5pr8AgU .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-G3KcwHjUL5pr8AgU .icon-shape .label rect,#mermaid-svg-G3KcwHjUL5pr8AgU .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-G3KcwHjUL5pr8AgU .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-G3KcwHjUL5pr8AgU .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-G3KcwHjUL5pr8AgU :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
否
是
起点入队并标记
队首出队
处理当前节点
扫描所有邻居
第一次访问?
标记并入队
跳过
队列为空?
结束

完整代码

java 复制代码
package class10;

import java.util.ArrayDeque;
import java.util.HashSet;
import java.util.Queue;
import java.util.Set;

/**
 * 图的宽度优先遍历(BFS)。
 *
 * 核心结构:
 * 1. 队列控制"先进先出";
 * 2. visited 防止有环图中的节点被重复处理。
 */
public class Code01_BFS {

    public static void bfs(Node root) {
        if (root == null) {
            return;
        }

        Queue<Node> queue = new ArrayDeque<>();
        Set<Node> visited = new HashSet<>();

        queue.offer(root);
        visited.add(root);

        while (!queue.isEmpty()) {
            Node current = queue.poll();

            // 一个节点只在真正出队处理时输出一次。
            System.out.println(current.value);

            for (Node next : current.nexts) {
                // add 返回 true,说明 next 是第一次被访问。
                if (visited.add(next)) {
                    queue.offer(next);
                }
            }
        }
    }
}

过程图解

假设:

text 复制代码
1: [2, 3]
2: [4, 5]
3: [5]
5: [1]
步骤 出队 新发现 队列 visited
1 1 2, 3 [2, 3] (1, 2, 3)
2 2 4, 5 [3, 4, 5] (1, 2, 3, 4, 5)
3 3 无 [4, 5] 不变
4 4 无 [5] 不变
5 5 1 已访问 [] 不变

复杂度

设可达点数为 V,可达边数为 E:

  • 时间复杂度:O(V + E);
  • 额外空间:O(V) ,来自队列与 visited。

易错点

  • 忘记 visited,有环图可能无限循环;
  • 一个节点重复入队,导致大量重复工作;
  • 同时在"发现"和"出队"两个阶段输出;
  • 把 BFS 和"按层返回列表"混为一谈:图 BFS 的核心是搜索顺序,不一定需要分层收集结果。

答案 2:图的深度优先遍历 DFS

[返回练习 2](#返回练习 2)

题目回顾

使用显式栈从起点不断深入,无法继续时再回到仍有候选分支的位置。

核心思路:栈 + visited

#mermaid-svg-kEQxCklfJtMQbDXa{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-kEQxCklfJtMQbDXa .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-kEQxCklfJtMQbDXa .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-kEQxCklfJtMQbDXa .error-icon{fill:#552222;}#mermaid-svg-kEQxCklfJtMQbDXa .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-kEQxCklfJtMQbDXa .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-kEQxCklfJtMQbDXa .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-kEQxCklfJtMQbDXa .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-kEQxCklfJtMQbDXa .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-kEQxCklfJtMQbDXa .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-kEQxCklfJtMQbDXa .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-kEQxCklfJtMQbDXa .marker{fill:#333333;stroke:#333333;}#mermaid-svg-kEQxCklfJtMQbDXa .marker.cross{stroke:#333333;}#mermaid-svg-kEQxCklfJtMQbDXa svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-kEQxCklfJtMQbDXa p{margin:0;}#mermaid-svg-kEQxCklfJtMQbDXa .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-kEQxCklfJtMQbDXa .cluster-label text{fill:#333;}#mermaid-svg-kEQxCklfJtMQbDXa .cluster-label span{color:#333;}#mermaid-svg-kEQxCklfJtMQbDXa .cluster-label span p{background-color:transparent;}#mermaid-svg-kEQxCklfJtMQbDXa .label text,#mermaid-svg-kEQxCklfJtMQbDXa span{fill:#333;color:#333;}#mermaid-svg-kEQxCklfJtMQbDXa .node rect,#mermaid-svg-kEQxCklfJtMQbDXa .node circle,#mermaid-svg-kEQxCklfJtMQbDXa .node ellipse,#mermaid-svg-kEQxCklfJtMQbDXa .node polygon,#mermaid-svg-kEQxCklfJtMQbDXa .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-kEQxCklfJtMQbDXa .rough-node .label text,#mermaid-svg-kEQxCklfJtMQbDXa .node .label text,#mermaid-svg-kEQxCklfJtMQbDXa .image-shape .label,#mermaid-svg-kEQxCklfJtMQbDXa .icon-shape .label{text-anchor:middle;}#mermaid-svg-kEQxCklfJtMQbDXa .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-kEQxCklfJtMQbDXa .rough-node .label,#mermaid-svg-kEQxCklfJtMQbDXa .node .label,#mermaid-svg-kEQxCklfJtMQbDXa .image-shape .label,#mermaid-svg-kEQxCklfJtMQbDXa .icon-shape .label{text-align:center;}#mermaid-svg-kEQxCklfJtMQbDXa .node.clickable{cursor:pointer;}#mermaid-svg-kEQxCklfJtMQbDXa .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-kEQxCklfJtMQbDXa .arrowheadPath{fill:#333333;}#mermaid-svg-kEQxCklfJtMQbDXa .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-kEQxCklfJtMQbDXa .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-kEQxCklfJtMQbDXa .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-kEQxCklfJtMQbDXa .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-kEQxCklfJtMQbDXa .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-kEQxCklfJtMQbDXa .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-kEQxCklfJtMQbDXa .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-kEQxCklfJtMQbDXa .cluster text{fill:#333;}#mermaid-svg-kEQxCklfJtMQbDXa .cluster span{color:#333;}#mermaid-svg-kEQxCklfJtMQbDXa div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-kEQxCklfJtMQbDXa .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-kEQxCklfJtMQbDXa rect.text{fill:none;stroke-width:0;}#mermaid-svg-kEQxCklfJtMQbDXa .icon-shape,#mermaid-svg-kEQxCklfJtMQbDXa .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-kEQxCklfJtMQbDXa .icon-shape p,#mermaid-svg-kEQxCklfJtMQbDXa .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-kEQxCklfJtMQbDXa .icon-shape .label rect,#mermaid-svg-kEQxCklfJtMQbDXa .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-kEQxCklfJtMQbDXa .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-kEQxCklfJtMQbDXa .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-kEQxCklfJtMQbDXa :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否
是
否
是
起点压栈并标记
栈为空?
弹出栈顶节点
处理当前节点
倒序扫描邻居
邻居未访问?
标记并压栈
结束

为什么要倒序压栈?

假设:

text 复制代码
nexts = [2, 3, 4]

如果正序压栈:

text 复制代码
push 2
push 3
push 4

栈顶是 4,所以真实弹出顺序会变成:

text 复制代码
4 -> 3 -> 2

如果希望访问顺序更接近邻接表本身的 2 -> 3 -> 4,就倒序压栈。

完整代码

java 复制代码
package class10;

import java.util.ArrayDeque;
import java.util.Deque;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

/**
 * 图的深度优先遍历(DFS),使用显式栈实现。
 */
public class Code02_DFS {

    public static void dfs(Node root) {
        if (root == null) {
            return;
        }

        Deque<Node> stack = new ArrayDeque<>();
        Set<Node> visited = new HashSet<>();

        stack.push(root);
        visited.add(root);

        while (!stack.isEmpty()) {
            Node current = stack.pop();
            System.out.println(current.value);

            List<Node> nexts = current.nexts;

            // 栈是后进先出。倒序压栈后,弹出顺序与 nexts 原顺序一致。
            for (int i = nexts.size() - 1; i >= 0; i--) {
                Node next = nexts.get(i);
                if (visited.add(next)) {
                    stack.push(next);
                }
            }
        }
    }
}

复杂度

  • 时间复杂度:O(V + E);
  • 额外空间:O(V)。

易错点

  • 没有去重导致环上死循环;
  • 正序压栈却误以为会正序访问;
  • 把 visited 放得太晚,导致同一个节点被多个前驱重复压栈。

答案 3:拓扑排序

[返回练习 3](#返回练习 3)

题目回顾

在 DAG 中找出一个线性顺序,使每条有向边:

text 复制代码
u -> v

都满足:

text 复制代码
u 出现在 v 之前

核心思路:不断消除零入度节点

#mermaid-svg-rXNsmqAPhl5xwFpC{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-rXNsmqAPhl5xwFpC .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-rXNsmqAPhl5xwFpC .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-rXNsmqAPhl5xwFpC .error-icon{fill:#552222;}#mermaid-svg-rXNsmqAPhl5xwFpC .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-rXNsmqAPhl5xwFpC .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-rXNsmqAPhl5xwFpC .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-rXNsmqAPhl5xwFpC .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-rXNsmqAPhl5xwFpC .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-rXNsmqAPhl5xwFpC .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-rXNsmqAPhl5xwFpC .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-rXNsmqAPhl5xwFpC .marker{fill:#333333;stroke:#333333;}#mermaid-svg-rXNsmqAPhl5xwFpC .marker.cross{stroke:#333333;}#mermaid-svg-rXNsmqAPhl5xwFpC svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-rXNsmqAPhl5xwFpC p{margin:0;}#mermaid-svg-rXNsmqAPhl5xwFpC .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-rXNsmqAPhl5xwFpC .cluster-label text{fill:#333;}#mermaid-svg-rXNsmqAPhl5xwFpC .cluster-label span{color:#333;}#mermaid-svg-rXNsmqAPhl5xwFpC .cluster-label span p{background-color:transparent;}#mermaid-svg-rXNsmqAPhl5xwFpC .label text,#mermaid-svg-rXNsmqAPhl5xwFpC span{fill:#333;color:#333;}#mermaid-svg-rXNsmqAPhl5xwFpC .node rect,#mermaid-svg-rXNsmqAPhl5xwFpC .node circle,#mermaid-svg-rXNsmqAPhl5xwFpC .node ellipse,#mermaid-svg-rXNsmqAPhl5xwFpC .node polygon,#mermaid-svg-rXNsmqAPhl5xwFpC .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-rXNsmqAPhl5xwFpC .rough-node .label text,#mermaid-svg-rXNsmqAPhl5xwFpC .node .label text,#mermaid-svg-rXNsmqAPhl5xwFpC .image-shape .label,#mermaid-svg-rXNsmqAPhl5xwFpC .icon-shape .label{text-anchor:middle;}#mermaid-svg-rXNsmqAPhl5xwFpC .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-rXNsmqAPhl5xwFpC .rough-node .label,#mermaid-svg-rXNsmqAPhl5xwFpC .node .label,#mermaid-svg-rXNsmqAPhl5xwFpC .image-shape .label,#mermaid-svg-rXNsmqAPhl5xwFpC .icon-shape .label{text-align:center;}#mermaid-svg-rXNsmqAPhl5xwFpC .node.clickable{cursor:pointer;}#mermaid-svg-rXNsmqAPhl5xwFpC .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-rXNsmqAPhl5xwFpC .arrowheadPath{fill:#333333;}#mermaid-svg-rXNsmqAPhl5xwFpC .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-rXNsmqAPhl5xwFpC .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-rXNsmqAPhl5xwFpC .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-rXNsmqAPhl5xwFpC .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-rXNsmqAPhl5xwFpC .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-rXNsmqAPhl5xwFpC .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-rXNsmqAPhl5xwFpC .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-rXNsmqAPhl5xwFpC .cluster text{fill:#333;}#mermaid-svg-rXNsmqAPhl5xwFpC .cluster span{color:#333;}#mermaid-svg-rXNsmqAPhl5xwFpC div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-rXNsmqAPhl5xwFpC .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-rXNsmqAPhl5xwFpC rect.text{fill:none;stroke-width:0;}#mermaid-svg-rXNsmqAPhl5xwFpC .icon-shape,#mermaid-svg-rXNsmqAPhl5xwFpC .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-rXNsmqAPhl5xwFpC .icon-shape p,#mermaid-svg-rXNsmqAPhl5xwFpC .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-rXNsmqAPhl5xwFpC .icon-shape .label rect,#mermaid-svg-rXNsmqAPhl5xwFpC .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-rXNsmqAPhl5xwFpC .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-rXNsmqAPhl5xwFpC .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-rXNsmqAPhl5xwFpC :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否
是
否
是
是
否
复制所有节点入度
零入度节点入队
队列为空?
弹出一个零入度节点
加入拓扑序
所有后继剩余入度减 1
某后继变为 0?
该后继入队
结果数等于节点数?
得到拓扑序
存在有向环

完整代码

java 复制代码
package class10;

import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Queue;

/**
 * Kahn 算法实现拓扑排序。
 *
 * 只适用于有向无环图(DAG)。
 */
public class Code03_TopologySort {

    public static List<Node> topologicalSort(Graph graph) {
        List<Node> result = new ArrayList<>();
        if (graph == null || graph.nodes.isEmpty()) {
            return result;
        }

        // 使用副本记录"剩余入度",避免修改原图中的 node.in。
        Map<Node, Integer> inDegree = new HashMap<>();
        Queue<Node> zeroInQueue = new ArrayDeque<>();

        for (Node node : graph.nodes.values()) {
            inDegree.put(node, node.in);
            if (node.in == 0) {
                zeroInQueue.offer(node);
            }
        }

        while (!zeroInQueue.isEmpty()) {
            Node current = zeroInQueue.poll();
            result.add(current);

            // 删除 current 等价于:它指向的所有节点入度减 1。
            for (Node next : current.nexts) {
                int nextIn = inDegree.get(next) - 1;
                inDegree.put(next, nextIn);

                if (nextIn == 0) {
                    zeroInQueue.offer(next);
                }
            }
        }

        // 若仍有节点无法被"消掉",说明存在有向环。
        if (result.size() != graph.nodes.size()) {
            throw new IllegalArgumentException(
                    "Graph contains a cycle; topological order does not exist.");
        }

        return result;
    }
}

为什么不修改 node.in?

因为拓扑排序只是一次计算。

如果直接对原图做:

text 复制代码
node.in--

下一次再调用拓扑排序时,图的入度信息已经被污染。

因此使用:

java 复制代码
Map<Node, Integer> inDegree

保存副本更安全。

如何检测环?

如果:

text 复制代码
result.size() < graph.nodes.size()

说明还有节点从未进入零入度队列。

最常见原因就是这些剩余节点处在有向环中。

复杂度

  • 时间复杂度:O(V + E);
  • 额外空间:O(V)。

易错点

  • 拓扑排序只针对有向图;
  • 有合法拓扑序的前提是无环;
  • 拓扑序通常不唯一;
  • 忘记维护入度副本,污染原图。

答案 4:Kruskal 最小生成树

[返回练习 4](#返回练习 4)

题目回顾

在无向带权图中选择若干条边,使所有节点连通、无环,并让总权重最小。

核心思路:边从小到大 + 并查集判断成环

#mermaid-svg-eSJKKoLuueEXJv2I{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-eSJKKoLuueEXJv2I .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-eSJKKoLuueEXJv2I .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-eSJKKoLuueEXJv2I .error-icon{fill:#552222;}#mermaid-svg-eSJKKoLuueEXJv2I .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-eSJKKoLuueEXJv2I .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-eSJKKoLuueEXJv2I .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-eSJKKoLuueEXJv2I .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-eSJKKoLuueEXJv2I .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-eSJKKoLuueEXJv2I .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-eSJKKoLuueEXJv2I .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-eSJKKoLuueEXJv2I .marker{fill:#333333;stroke:#333333;}#mermaid-svg-eSJKKoLuueEXJv2I .marker.cross{stroke:#333333;}#mermaid-svg-eSJKKoLuueEXJv2I svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-eSJKKoLuueEXJv2I p{margin:0;}#mermaid-svg-eSJKKoLuueEXJv2I .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-eSJKKoLuueEXJv2I .cluster-label text{fill:#333;}#mermaid-svg-eSJKKoLuueEXJv2I .cluster-label span{color:#333;}#mermaid-svg-eSJKKoLuueEXJv2I .cluster-label span p{background-color:transparent;}#mermaid-svg-eSJKKoLuueEXJv2I .label text,#mermaid-svg-eSJKKoLuueEXJv2I span{fill:#333;color:#333;}#mermaid-svg-eSJKKoLuueEXJv2I .node rect,#mermaid-svg-eSJKKoLuueEXJv2I .node circle,#mermaid-svg-eSJKKoLuueEXJv2I .node ellipse,#mermaid-svg-eSJKKoLuueEXJv2I .node polygon,#mermaid-svg-eSJKKoLuueEXJv2I .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-eSJKKoLuueEXJv2I .rough-node .label text,#mermaid-svg-eSJKKoLuueEXJv2I .node .label text,#mermaid-svg-eSJKKoLuueEXJv2I .image-shape .label,#mermaid-svg-eSJKKoLuueEXJv2I .icon-shape .label{text-anchor:middle;}#mermaid-svg-eSJKKoLuueEXJv2I .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-eSJKKoLuueEXJv2I .rough-node .label,#mermaid-svg-eSJKKoLuueEXJv2I .node .label,#mermaid-svg-eSJKKoLuueEXJv2I .image-shape .label,#mermaid-svg-eSJKKoLuueEXJv2I .icon-shape .label{text-align:center;}#mermaid-svg-eSJKKoLuueEXJv2I .node.clickable{cursor:pointer;}#mermaid-svg-eSJKKoLuueEXJv2I .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-eSJKKoLuueEXJv2I .arrowheadPath{fill:#333333;}#mermaid-svg-eSJKKoLuueEXJv2I .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-eSJKKoLuueEXJv2I .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-eSJKKoLuueEXJv2I .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-eSJKKoLuueEXJv2I .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-eSJKKoLuueEXJv2I .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-eSJKKoLuueEXJv2I .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-eSJKKoLuueEXJv2I .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-eSJKKoLuueEXJv2I .cluster text{fill:#333;}#mermaid-svg-eSJKKoLuueEXJv2I .cluster span{color:#333;}#mermaid-svg-eSJKKoLuueEXJv2I div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-eSJKKoLuueEXJv2I .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-eSJKKoLuueEXJv2I rect.text{fill:none;stroke-width:0;}#mermaid-svg-eSJKKoLuueEXJv2I .icon-shape,#mermaid-svg-eSJKKoLuueEXJv2I .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-eSJKKoLuueEXJv2I .icon-shape p,#mermaid-svg-eSJKKoLuueEXJv2I .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-eSJKKoLuueEXJv2I .icon-shape .label rect,#mermaid-svg-eSJKKoLuueEXJv2I .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-eSJKKoLuueEXJv2I .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-eSJKKoLuueEXJv2I .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-eSJKKoLuueEXJv2I :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
是
否
所有边进入小根堆
取当前最小边
两端已连通?
跳过 会成环
选择这条边
并查集合并两端集合
还有候选边?
结束

为什么"两个端点已连通"就一定会成环?

假设当前已经存在一条路径:

text 复制代码
A -> ... -> B

此时再加入边:

text 复制代码
A -- B

就会构成一个闭环。

所以 Kruskal 的关键问题不是:

text 复制代码
"这条边够不够小?"

而是:

text 复制代码
"这条边够小,并且安全吗?"

并查集图解

#mermaid-svg-WkSaLXUb2eWxIfCj{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-WkSaLXUb2eWxIfCj .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-WkSaLXUb2eWxIfCj .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-WkSaLXUb2eWxIfCj .error-icon{fill:#552222;}#mermaid-svg-WkSaLXUb2eWxIfCj .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-WkSaLXUb2eWxIfCj .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-WkSaLXUb2eWxIfCj .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-WkSaLXUb2eWxIfCj .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-WkSaLXUb2eWxIfCj .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-WkSaLXUb2eWxIfCj .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-WkSaLXUb2eWxIfCj .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-WkSaLXUb2eWxIfCj .marker{fill:#333333;stroke:#333333;}#mermaid-svg-WkSaLXUb2eWxIfCj .marker.cross{stroke:#333333;}#mermaid-svg-WkSaLXUb2eWxIfCj svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-WkSaLXUb2eWxIfCj p{margin:0;}#mermaid-svg-WkSaLXUb2eWxIfCj .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-WkSaLXUb2eWxIfCj .cluster-label text{fill:#333;}#mermaid-svg-WkSaLXUb2eWxIfCj .cluster-label span{color:#333;}#mermaid-svg-WkSaLXUb2eWxIfCj .cluster-label span p{background-color:transparent;}#mermaid-svg-WkSaLXUb2eWxIfCj .label text,#mermaid-svg-WkSaLXUb2eWxIfCj span{fill:#333;color:#333;}#mermaid-svg-WkSaLXUb2eWxIfCj .node rect,#mermaid-svg-WkSaLXUb2eWxIfCj .node circle,#mermaid-svg-WkSaLXUb2eWxIfCj .node ellipse,#mermaid-svg-WkSaLXUb2eWxIfCj .node polygon,#mermaid-svg-WkSaLXUb2eWxIfCj .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-WkSaLXUb2eWxIfCj .rough-node .label text,#mermaid-svg-WkSaLXUb2eWxIfCj .node .label text,#mermaid-svg-WkSaLXUb2eWxIfCj .image-shape .label,#mermaid-svg-WkSaLXUb2eWxIfCj .icon-shape .label{text-anchor:middle;}#mermaid-svg-WkSaLXUb2eWxIfCj .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-WkSaLXUb2eWxIfCj .rough-node .label,#mermaid-svg-WkSaLXUb2eWxIfCj .node .label,#mermaid-svg-WkSaLXUb2eWxIfCj .image-shape .label,#mermaid-svg-WkSaLXUb2eWxIfCj .icon-shape .label{text-align:center;}#mermaid-svg-WkSaLXUb2eWxIfCj .node.clickable{cursor:pointer;}#mermaid-svg-WkSaLXUb2eWxIfCj .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-WkSaLXUb2eWxIfCj .arrowheadPath{fill:#333333;}#mermaid-svg-WkSaLXUb2eWxIfCj .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-WkSaLXUb2eWxIfCj .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-WkSaLXUb2eWxIfCj .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-WkSaLXUb2eWxIfCj .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-WkSaLXUb2eWxIfCj .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-WkSaLXUb2eWxIfCj .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-WkSaLXUb2eWxIfCj .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-WkSaLXUb2eWxIfCj .cluster text{fill:#333;}#mermaid-svg-WkSaLXUb2eWxIfCj .cluster span{color:#333;}#mermaid-svg-WkSaLXUb2eWxIfCj div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-WkSaLXUb2eWxIfCj .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-WkSaLXUb2eWxIfCj rect.text{fill:none;stroke-width:0;}#mermaid-svg-WkSaLXUb2eWxIfCj .icon-shape,#mermaid-svg-WkSaLXUb2eWxIfCj .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-WkSaLXUb2eWxIfCj .icon-shape p,#mermaid-svg-WkSaLXUb2eWxIfCj .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-WkSaLXUb2eWxIfCj .icon-shape .label rect,#mermaid-svg-WkSaLXUb2eWxIfCj .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-WkSaLXUb2eWxIfCj .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-WkSaLXUb2eWxIfCj .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-WkSaLXUb2eWxIfCj :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
节点各自一个集合
find 查询代表节点
代表节点相同?
已经连通
union 合并
小集合挂到大集合
find 时路径压缩

完整代码

java 复制代码
package class10;

import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Deque;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.PriorityQueue;

/**
 * Kruskal 最小生成树算法。
 *
 * 思路:按边权从小到大尝试加入边;
 * 若一条边的两个端点已经连通,则跳过它以避免形成环。
 *
 * 对不连通图,返回最小生成森林。
 */
public class Code04_Kruskal {

    public static List<Edge> kruskal(Graph graph) {
        List<Edge> result = new ArrayList<>();
        if (graph == null || graph.nodes.isEmpty()) {
            return result;
        }

        PriorityQueue<Edge> minEdgeHeap =
                new PriorityQueue<>(Comparator.comparingInt(edge -> edge.weight));
        minEdgeHeap.addAll(graph.edges);

        UnionFind<Node> unionFind = new UnionFind<>(graph.nodes.values());

        while (!minEdgeHeap.isEmpty()) {
            Edge edge = minEdgeHeap.poll();

            if (!unionFind.isSameSet(edge.from, edge.to)) {
                unionFind.union(edge.from, edge.to);
                result.add(edge);
            }
        }

        return result;
    }

    /**
     * 并查集:路径压缩 + 按集合大小合并。
     */
    public static class UnionFind<T> {
        private final Map<T, T> parent = new HashMap<>();
        private final Map<T, Integer> size = new HashMap<>();

        public UnionFind(Iterable<T> values) {
            for (T value : values) {
                parent.put(value, value);
                size.put(value, 1);
            }
        }

        /**
         * 找到代表节点,并把沿途节点直接挂到代表节点上。
         */
        public T find(T value) {
            if (!parent.containsKey(value)) {
                return null;
            }

            Deque<T> path = new ArrayDeque<>();
            T current = value;

            while (parent.get(current) != current) {
                path.push(current);
                current = parent.get(current);
            }

            while (!path.isEmpty()) {
                parent.put(path.pop(), current);
            }

            return current;
        }

        public boolean isSameSet(T a, T b) {
            T rootA = find(a);
            T rootB = find(b);
            return rootA != null && rootA == rootB;
        }

        public void union(T a, T b) {
            T rootA = find(a);
            T rootB = find(b);

            if (rootA == null || rootB == null || rootA == rootB) {
                return;
            }

            T big = size.get(rootA) >= size.get(rootB) ? rootA : rootB;
            T small = big == rootA ? rootB : rootA;

            parent.put(small, big);
            size.put(big, size.get(big) + size.get(small));
            size.remove(small);
        }

        public int getSetCount() {
            return size.size();
        }
    }
}

复杂度

若使用小根堆装入所有边:

  • 建堆 / 取边整体可近似理解为 O(E log E);
  • 并查集操作摊还接近常数;
  • 总体常写为 O(E log E)。

易错点

  • MST 是无向图概念;
  • 只按权重选边、不检查连通性会形成环;
  • 对不连通图不存在"一棵覆盖全部节点的生成树",只有生成森林;
  • 如果无向边用两个方向的 Edge 表示,Kruskal 会看到重复方向,但并查集仍会阻止重复成环。

答案 5:Prim 最小生成树

[返回练习 5](#返回练习 5)

题目回顾

从某个点出发,让"已经进入生成树的节点集合"逐步扩大;每次选择一条最便宜、且能带来新节点的边。

核心思路:点解锁边

#mermaid-svg-4SAmdqjYZd2pDGvw{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-4SAmdqjYZd2pDGvw .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-4SAmdqjYZd2pDGvw .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-4SAmdqjYZd2pDGvw .error-icon{fill:#552222;}#mermaid-svg-4SAmdqjYZd2pDGvw .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-4SAmdqjYZd2pDGvw .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-4SAmdqjYZd2pDGvw .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-4SAmdqjYZd2pDGvw .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-4SAmdqjYZd2pDGvw .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-4SAmdqjYZd2pDGvw .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-4SAmdqjYZd2pDGvw .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-4SAmdqjYZd2pDGvw .marker{fill:#333333;stroke:#333333;}#mermaid-svg-4SAmdqjYZd2pDGvw .marker.cross{stroke:#333333;}#mermaid-svg-4SAmdqjYZd2pDGvw svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-4SAmdqjYZd2pDGvw p{margin:0;}#mermaid-svg-4SAmdqjYZd2pDGvw .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-4SAmdqjYZd2pDGvw .cluster-label text{fill:#333;}#mermaid-svg-4SAmdqjYZd2pDGvw .cluster-label span{color:#333;}#mermaid-svg-4SAmdqjYZd2pDGvw .cluster-label span p{background-color:transparent;}#mermaid-svg-4SAmdqjYZd2pDGvw .label text,#mermaid-svg-4SAmdqjYZd2pDGvw span{fill:#333;color:#333;}#mermaid-svg-4SAmdqjYZd2pDGvw .node rect,#mermaid-svg-4SAmdqjYZd2pDGvw .node circle,#mermaid-svg-4SAmdqjYZd2pDGvw .node ellipse,#mermaid-svg-4SAmdqjYZd2pDGvw .node polygon,#mermaid-svg-4SAmdqjYZd2pDGvw .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-4SAmdqjYZd2pDGvw .rough-node .label text,#mermaid-svg-4SAmdqjYZd2pDGvw .node .label text,#mermaid-svg-4SAmdqjYZd2pDGvw .image-shape .label,#mermaid-svg-4SAmdqjYZd2pDGvw .icon-shape .label{text-anchor:middle;}#mermaid-svg-4SAmdqjYZd2pDGvw .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-4SAmdqjYZd2pDGvw .rough-node .label,#mermaid-svg-4SAmdqjYZd2pDGvw .node .label,#mermaid-svg-4SAmdqjYZd2pDGvw .image-shape .label,#mermaid-svg-4SAmdqjYZd2pDGvw .icon-shape .label{text-align:center;}#mermaid-svg-4SAmdqjYZd2pDGvw .node.clickable{cursor:pointer;}#mermaid-svg-4SAmdqjYZd2pDGvw .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-4SAmdqjYZd2pDGvw .arrowheadPath{fill:#333333;}#mermaid-svg-4SAmdqjYZd2pDGvw .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-4SAmdqjYZd2pDGvw .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-4SAmdqjYZd2pDGvw .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-4SAmdqjYZd2pDGvw .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-4SAmdqjYZd2pDGvw .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-4SAmdqjYZd2pDGvw .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-4SAmdqjYZd2pDGvw .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-4SAmdqjYZd2pDGvw .cluster text{fill:#333;}#mermaid-svg-4SAmdqjYZd2pDGvw .cluster span{color:#333;}#mermaid-svg-4SAmdqjYZd2pDGvw div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-4SAmdqjYZd2pDGvw .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-4SAmdqjYZd2pDGvw rect.text{fill:none;stroke-width:0;}#mermaid-svg-4SAmdqjYZd2pDGvw .icon-shape,#mermaid-svg-4SAmdqjYZd2pDGvw .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-4SAmdqjYZd2pDGvw .icon-shape p,#mermaid-svg-4SAmdqjYZd2pDGvw .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-4SAmdqjYZd2pDGvw .icon-shape .label rect,#mermaid-svg-4SAmdqjYZd2pDGvw .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-4SAmdqjYZd2pDGvw .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-4SAmdqjYZd2pDGvw .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-4SAmdqjYZd2pDGvw :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否
是
否
是
是
否
选一个未访问起点
起点加入 visited
起点的边进入小根堆
堆为空?
弹出最小边
目标节点已访问?
选择该边
新节点加入 visited
新节点的边全部解锁
还有未访问节点?
结束

Kruskal 与 Prim 的区别

对比 Kruskal Prim
贪心对象 全图最小边 当前生成树边界上的最小边
防环方式 并查集 visited 节点集合
核心容器 边小根堆 + 并查集 边小根堆 + visited
视角 从边出发 从点扩张
不连通图 最小生成森林 逐分量启动后得到森林

完整代码

java 复制代码
package class10;

import java.util.Comparator;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.PriorityQueue;
import java.util.Set;

/**
 * Prim 最小生成树算法。
 *
 * 每次从"已访问节点集合"向外选择权重最小、且能引入新节点的边。
 *
 * 对不连通图,外层循环会从每个未访问连通分量重新启动,
 * 因而返回最小生成森林。
 */
public class Code05_Prim {

    public static Set<Edge> prim(Graph graph) {
        Set<Edge> result = new LinkedHashSet<>();
        if (graph == null || graph.nodes.isEmpty()) {
            return result;
        }

        Set<Node> visited = new HashSet<>();
        PriorityQueue<Edge> minEdgeHeap =
                new PriorityQueue<>(Comparator.comparingInt(edge -> edge.weight));

        // 支持森林:每个尚未访问的节点都可以作为新连通分量的起点。
        for (Node start : graph.nodes.values()) {
            if (!visited.add(start)) {
                continue;
            }

            minEdgeHeap.addAll(start.edges);

            while (!minEdgeHeap.isEmpty()) {
                Edge edge = minEdgeHeap.poll();
                Node next = edge.to;

                // edge.to 已在树中,则这条边会形成环,直接跳过。
                if (!visited.add(next)) {
                    continue;
                }

                result.add(edge);

                // 新节点进入生成树后,解锁它能够提供的所有候选边。
                minEdgeHeap.addAll(next.edges);
            }
        }

        return result;
    }
}

关于无向图的表示

这份 Node / Edge 结构中:

text 复制代码
edge.from -> edge.to

本身带有方向。

因此如果要用它表达无向边:

text 复制代码
A -- B

常见做法是同时建立:

text 复制代码
A -> B
B -> A

Prim 才能从任意一端"解锁"到另一端。

复杂度

使用二叉堆时:

  • 每条候选边最多进入堆;
  • 时间复杂度通常写作 O(E log E);
  • 也常根据具体实现表述为 O(E log V);
  • 额外空间:O(V + E)。

易错点

  • 只看最小边,不看它能否引入新节点;
  • 忘记支持不连通图时重新选择起点;
  • 图结构是有向边表示,却直接把它当无向图使用;
  • 误以为 Prim 需要并查集:标准 Prim 用 visited 就足够防环。

答案 6:Dijkstra 单源最短路径

[返回练习 6](#返回练习 6)

题目回顾

从起点出发,计算到所有可达节点的最短距离。

前提:

text 复制代码
所有边权 >= 0

一句话理解 Dijkstra

每一轮从"还没最终确定"的节点中,选当前距离最小者。

因为没有负权边,所以以后再绕路也不可能把它变得更短。

核心流程

#mermaid-svg-5UMzPJgXjRkiGjCt{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-5UMzPJgXjRkiGjCt .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-5UMzPJgXjRkiGjCt .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-5UMzPJgXjRkiGjCt .error-icon{fill:#552222;}#mermaid-svg-5UMzPJgXjRkiGjCt .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-5UMzPJgXjRkiGjCt .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-5UMzPJgXjRkiGjCt .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-5UMzPJgXjRkiGjCt .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-5UMzPJgXjRkiGjCt .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-5UMzPJgXjRkiGjCt .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-5UMzPJgXjRkiGjCt .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-5UMzPJgXjRkiGjCt .marker{fill:#333333;stroke:#333333;}#mermaid-svg-5UMzPJgXjRkiGjCt .marker.cross{stroke:#333333;}#mermaid-svg-5UMzPJgXjRkiGjCt svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-5UMzPJgXjRkiGjCt p{margin:0;}#mermaid-svg-5UMzPJgXjRkiGjCt .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-5UMzPJgXjRkiGjCt .cluster-label text{fill:#333;}#mermaid-svg-5UMzPJgXjRkiGjCt .cluster-label span{color:#333;}#mermaid-svg-5UMzPJgXjRkiGjCt .cluster-label span p{background-color:transparent;}#mermaid-svg-5UMzPJgXjRkiGjCt .label text,#mermaid-svg-5UMzPJgXjRkiGjCt span{fill:#333;color:#333;}#mermaid-svg-5UMzPJgXjRkiGjCt .node rect,#mermaid-svg-5UMzPJgXjRkiGjCt .node circle,#mermaid-svg-5UMzPJgXjRkiGjCt .node ellipse,#mermaid-svg-5UMzPJgXjRkiGjCt .node polygon,#mermaid-svg-5UMzPJgXjRkiGjCt .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-5UMzPJgXjRkiGjCt .rough-node .label text,#mermaid-svg-5UMzPJgXjRkiGjCt .node .label text,#mermaid-svg-5UMzPJgXjRkiGjCt .image-shape .label,#mermaid-svg-5UMzPJgXjRkiGjCt .icon-shape .label{text-anchor:middle;}#mermaid-svg-5UMzPJgXjRkiGjCt .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-5UMzPJgXjRkiGjCt .rough-node .label,#mermaid-svg-5UMzPJgXjRkiGjCt .node .label,#mermaid-svg-5UMzPJgXjRkiGjCt .image-shape .label,#mermaid-svg-5UMzPJgXjRkiGjCt .icon-shape .label{text-align:center;}#mermaid-svg-5UMzPJgXjRkiGjCt .node.clickable{cursor:pointer;}#mermaid-svg-5UMzPJgXjRkiGjCt .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-5UMzPJgXjRkiGjCt .arrowheadPath{fill:#333333;}#mermaid-svg-5UMzPJgXjRkiGjCt .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-5UMzPJgXjRkiGjCt .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-5UMzPJgXjRkiGjCt .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-5UMzPJgXjRkiGjCt .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-5UMzPJgXjRkiGjCt .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-5UMzPJgXjRkiGjCt .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-5UMzPJgXjRkiGjCt .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-5UMzPJgXjRkiGjCt .cluster text{fill:#333;}#mermaid-svg-5UMzPJgXjRkiGjCt .cluster span{color:#333;}#mermaid-svg-5UMzPJgXjRkiGjCt div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-5UMzPJgXjRkiGjCt .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-5UMzPJgXjRkiGjCt rect.text{fill:none;stroke-width:0;}#mermaid-svg-5UMzPJgXjRkiGjCt .icon-shape,#mermaid-svg-5UMzPJgXjRkiGjCt .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-5UMzPJgXjRkiGjCt .icon-shape p,#mermaid-svg-5UMzPJgXjRkiGjCt .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-5UMzPJgXjRkiGjCt .icon-shape .label rect,#mermaid-svg-5UMzPJgXjRkiGjCt .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-5UMzPJgXjRkiGjCt .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-5UMzPJgXjRkiGjCt .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-5UMzPJgXjRkiGjCt :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
是
否
起点距离设为 0
选未确定节点中距离最小者
当前节点距离最终确定
遍历当前节点所有出边
计算 新距离 = 当前距离 + 边权
新距离更短?
更新邻居距离
保持原距离
还有未确定可达节点?
结束

什么叫"松弛"?

对边:

text 复制代码
u --w--> v

如果当前已经知道:

text 复制代码
dist[u]

那么经过 u 到 v 的候选距离是:

text 复制代码
candidate = dist[u] + w

若:

text 复制代码
candidate < dist[v]

就更新:

text 复制代码
dist[v] = candidate

这一步叫做松弛(relaxation)。

朴素版为什么是 O(V² + E)?

每确定一个节点,都要扫描 distanceMap:

text 复制代码
从未确定节点中找到当前距离最小者

这个查找最坏要看 O(V) 个节点,一共做 O(V) 轮,因此产生 O(V²)。

边的松弛总共 O(E)。

改写堆的三个状态

#mermaid-svg-YwYs6odnd5XQHDP4{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-YwYs6odnd5XQHDP4 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-YwYs6odnd5XQHDP4 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-YwYs6odnd5XQHDP4 .error-icon{fill:#552222;}#mermaid-svg-YwYs6odnd5XQHDP4 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-YwYs6odnd5XQHDP4 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-YwYs6odnd5XQHDP4 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-YwYs6odnd5XQHDP4 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-YwYs6odnd5XQHDP4 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-YwYs6odnd5XQHDP4 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-YwYs6odnd5XQHDP4 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-YwYs6odnd5XQHDP4 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-YwYs6odnd5XQHDP4 .marker.cross{stroke:#333333;}#mermaid-svg-YwYs6odnd5XQHDP4 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-YwYs6odnd5XQHDP4 p{margin:0;}#mermaid-svg-YwYs6odnd5XQHDP4 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-YwYs6odnd5XQHDP4 .cluster-label text{fill:#333;}#mermaid-svg-YwYs6odnd5XQHDP4 .cluster-label span{color:#333;}#mermaid-svg-YwYs6odnd5XQHDP4 .cluster-label span p{background-color:transparent;}#mermaid-svg-YwYs6odnd5XQHDP4 .label text,#mermaid-svg-YwYs6odnd5XQHDP4 span{fill:#333;color:#333;}#mermaid-svg-YwYs6odnd5XQHDP4 .node rect,#mermaid-svg-YwYs6odnd5XQHDP4 .node circle,#mermaid-svg-YwYs6odnd5XQHDP4 .node ellipse,#mermaid-svg-YwYs6odnd5XQHDP4 .node polygon,#mermaid-svg-YwYs6odnd5XQHDP4 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-YwYs6odnd5XQHDP4 .rough-node .label text,#mermaid-svg-YwYs6odnd5XQHDP4 .node .label text,#mermaid-svg-YwYs6odnd5XQHDP4 .image-shape .label,#mermaid-svg-YwYs6odnd5XQHDP4 .icon-shape .label{text-anchor:middle;}#mermaid-svg-YwYs6odnd5XQHDP4 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-YwYs6odnd5XQHDP4 .rough-node .label,#mermaid-svg-YwYs6odnd5XQHDP4 .node .label,#mermaid-svg-YwYs6odnd5XQHDP4 .image-shape .label,#mermaid-svg-YwYs6odnd5XQHDP4 .icon-shape .label{text-align:center;}#mermaid-svg-YwYs6odnd5XQHDP4 .node.clickable{cursor:pointer;}#mermaid-svg-YwYs6odnd5XQHDP4 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-YwYs6odnd5XQHDP4 .arrowheadPath{fill:#333333;}#mermaid-svg-YwYs6odnd5XQHDP4 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-YwYs6odnd5XQHDP4 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-YwYs6odnd5XQHDP4 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-YwYs6odnd5XQHDP4 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-YwYs6odnd5XQHDP4 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-YwYs6odnd5XQHDP4 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-YwYs6odnd5XQHDP4 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-YwYs6odnd5XQHDP4 .cluster text{fill:#333;}#mermaid-svg-YwYs6odnd5XQHDP4 .cluster span{color:#333;}#mermaid-svg-YwYs6odnd5XQHDP4 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-YwYs6odnd5XQHDP4 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-YwYs6odnd5XQHDP4 rect.text{fill:none;stroke-width:0;}#mermaid-svg-YwYs6odnd5XQHDP4 .icon-shape,#mermaid-svg-YwYs6odnd5XQHDP4 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-YwYs6odnd5XQHDP4 .icon-shape p,#mermaid-svg-YwYs6odnd5XQHDP4 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-YwYs6odnd5XQHDP4 .icon-shape .label rect,#mermaid-svg-YwYs6odnd5XQHDP4 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-YwYs6odnd5XQHDP4 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-YwYs6odnd5XQHDP4 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-YwYs6odnd5XQHDP4 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} add
更短距离 update
pop
再次遇到 ignore
节点从未进堆
节点在堆中
最短距离已确定

为了支持 update,普通 PriorityQueue 不方便直接修改内部某个节点的优先级。

因此改写堆维护两张表:

text 复制代码
indexMap:
节点 -> 当前在堆数组中的位置
-1 表示已经弹出

distanceMap:
节点 -> 当前候选最短距离

距离变小时:

text 复制代码
更新 distanceMap
-> 从 indexMap 找到位置
-> heapInsert 向上调整

完整代码

java 复制代码
package class10;

import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;

/**
 * Dijkstra 单源最短路径。
 *
 * 前提:所有可达边的权重都必须 >= 0。
 */
public class Code06_Dijkstra {

    /**
     * 朴素版:每轮在线性扫描中找到尚未确定的最短节点。
     *
     * 时间复杂度:O(V^2 + E)
     */
    public static HashMap<Node, Integer> dijkstra1(Node from) {
        HashMap<Node, Integer> distanceMap = new HashMap<>();
        if (from == null) {
            return distanceMap;
        }

        Set<Node> selected = new HashSet<>();
        distanceMap.put(from, 0);

        Node minNode =
                getMinDistanceNodeWithoutSelected(distanceMap, selected);

        while (minNode != null) {
            int baseDistance = distanceMap.get(minNode);

            for (Edge edge : minNode.edges) {
                checkNonNegative(edge);

                Node to = edge.to;
                int newDistance = safeAdd(baseDistance, edge.weight);

                // 第一次发现:写入"起点到 minNode 的距离 + 当前边权";
                // 再次发现:只保留更小的距离。
                distanceMap.merge(to, newDistance, Math::min);
            }

            // minNode 的最短距离从此确定,不再被更新。
            selected.add(minNode);
            minNode =
                    getMinDistanceNodeWithoutSelected(distanceMap, selected);
        }

        return distanceMap;
    }

    private static Node getMinDistanceNodeWithoutSelected(
            Map<Node, Integer> distanceMap,
            Set<Node> selected) {

        Node minNode = null;
        int minDistance = Integer.MAX_VALUE;

        for (Map.Entry<Node, Integer> entry : distanceMap.entrySet()) {
            if (!selected.contains(entry.getKey())
                    && entry.getValue() < minDistance) {
                minNode = entry.getKey();
                minDistance = entry.getValue();
            }
        }

        return minNode;
    }

    /**
     * 加强版:使用支持 decrease-key 的小根堆。
     *
     * 时间复杂度:O((V + E) log V)
     *
     * @param from 起点
     * @param initialCapacity 初始堆容量;容量不足时会自动扩容
     */
    public static HashMap<Node, Integer> dijkstra2(
            Node from,
            int initialCapacity) {

        HashMap<Node, Integer> result = new HashMap<>();
        if (from == null) {
            return result;
        }

        NodeHeap heap = new NodeHeap(initialCapacity);
        heap.addOrUpdateOrIgnore(from, 0);

        while (!heap.isEmpty()) {
            Record record = heap.pop();

            // 从堆中弹出的节点,其最短距离已经确定。
            result.put(record.node, record.distance);

            for (Edge edge : record.node.edges) {
                checkNonNegative(edge);

                int newDistance =
                        safeAdd(record.distance, edge.weight);
                heap.addOrUpdateOrIgnore(edge.to, newDistance);
            }
        }

        return result;
    }

    private static void checkNonNegative(Edge edge) {
        if (edge.weight < 0) {
            throw new IllegalArgumentException(
                    "Dijkstra does not allow negative edge weights.");
        }
    }

    private static int safeAdd(int a, int b) {
        long sum = (long) a + b;
        if (sum > Integer.MAX_VALUE) {
            throw new ArithmeticException(
                    "Shortest-path distance exceeds Integer.MAX_VALUE.");
        }
        return (int) sum;
    }

    /**
     * 支持三种操作的改写小根堆:
     * 1. add:从未入堆 -> 加入;
     * 2. update:仍在堆中且距离变小 -> 更新并上浮;
     * 3. ignore:已经弹出,或新距离不更优 -> 忽略。
     */
    public static class NodeHeap {
        private Node[] nodes;
        private final Map<Node, Integer> indexMap;
        private final Map<Node, Integer> distanceMap;
        private int size;

        public NodeHeap(int initialCapacity) {
            nodes = new Node[Math.max(1, initialCapacity)];
            indexMap = new HashMap<>();
            distanceMap = new HashMap<>();
        }

        public void addOrUpdateOrIgnore(Node node, int distance) {
            if (!hasEntered(node)) {
                ensureCapacity(size + 1);

                nodes[size] = node;
                indexMap.put(node, size);
                distanceMap.put(node, distance);
                heapInsert(size);
                size++;
                return;
            }

            if (inHeap(node)
                    && distance < distanceMap.get(node)) {
                distanceMap.put(node, distance);
                heapInsert(indexMap.get(node));
            }

            // 已经弹出,或新距离没有更短:忽略。
        }

        public Record pop() {
            if (isEmpty()) {
                throw new IllegalStateException("Heap is empty.");
            }

            Node minNode = nodes[0];
            int minDistance = distanceMap.get(minNode);

            swap(0, size - 1);

            Node removed = nodes[size - 1];
            indexMap.put(removed, -1);
            distanceMap.remove(removed);
            nodes[size - 1] = null;

            size--;
            if (size > 0) {
                heapify(0);
            }

            return new Record(minNode, minDistance);
        }

        private void heapInsert(int index) {
            while (index > 0) {
                int parent = (index - 1) / 2;

                if (distanceMap.get(nodes[index])
                        >= distanceMap.get(nodes[parent])) {
                    break;
                }

                swap(index, parent);
                index = parent;
            }
        }

        private void heapify(int index) {
            int left = index * 2 + 1;

            while (left < size) {
                int right = left + 1;
                int smallest =
                        right < size
                                && distanceMap.get(nodes[right])
                                < distanceMap.get(nodes[left])
                                ? right
                                : left;

                if (distanceMap.get(nodes[index])
                        <= distanceMap.get(nodes[smallest])) {
                    break;
                }

                swap(index, smallest);
                index = smallest;
                left = index * 2 + 1;
            }
        }

        private void swap(int i, int j) {
            if (i == j) {
                return;
            }

            Node tmp = nodes[i];
            nodes[i] = nodes[j];
            nodes[j] = tmp;

            indexMap.put(nodes[i], i);
            indexMap.put(nodes[j], j);
        }

        private boolean hasEntered(Node node) {
            return indexMap.containsKey(node);
        }

        private boolean inHeap(Node node) {
            return hasEntered(node) && indexMap.get(node) >= 0;
        }

        public boolean isEmpty() {
            return size == 0;
        }

        private void ensureCapacity(int required) {
            if (required <= nodes.length) {
                return;
            }

            int newCapacity =
                    Math.max(required, nodes.length * 2);
            nodes = Arrays.copyOf(nodes, newCapacity);
        }
    }

    public static class Record {
        public final Node node;
        public final int distance;

        public Record(Node node, int distance) {
            this.node = node;
            this.distance = distance;
        }
    }
}

为什么 Dijkstra 不能有负权边?

假设某个节点 A 已经以距离 5 被确定。

Dijkstra 的逻辑依赖:

text 复制代码
未来从更远节点绕回来,
只会让距离更大,不会让 5 变小。

如果允许负权边:

text 复制代码
更远节点 -> A 的边权 = -10

就可能在 A 已经"确定"以后,突然出现更短路径。

这会破坏贪心正确性的基础。

复杂度

朴素版
  • 时间:O(V² + E);
  • 空间:O(V)。
改写堆版
  • 每个节点入堆 / 出堆;
  • 每次有效距离下降触发上浮;
  • 时间复杂度:O((V + E) log V);
  • 额外空间:O(V)。

易错点

  • 忘记加上 dist[current],只记录当前边权;
  • 在负权图上使用 Dijkstra;
  • 把"被发现"误认为"最短距离已经确定";
  • 自定义堆更新距离后忘记上浮;
  • 节点弹出后没有标记为"已确定",导致再次进入堆;
  • int 距离相加可能溢出,因此代码中用 long 做中间计算并主动检查。

第三部分:把 6 道题串起来理解

1. "候选集合"是图算法的核心

算法 候选集合 每次取谁 如何防止错误
BFS 队列 最早发现的节点 visited
DFS 栈 最近发现的节点 visited
拓扑排序 零入度队列 当前无依赖节点 剩余入度
Kruskal 全图候选边 全局最小边 并查集防环
Prim 当前边界候选边 边界最小边 visited 防环
Dijkstra 候选距离 当前距离最小节点 selected / 出堆确定

#mermaid-svg-yg6aFkjXOaqPGPHS{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-yg6aFkjXOaqPGPHS .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-yg6aFkjXOaqPGPHS .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-yg6aFkjXOaqPGPHS .error-icon{fill:#552222;}#mermaid-svg-yg6aFkjXOaqPGPHS .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-yg6aFkjXOaqPGPHS .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-yg6aFkjXOaqPGPHS .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-yg6aFkjXOaqPGPHS .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-yg6aFkjXOaqPGPHS .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-yg6aFkjXOaqPGPHS .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-yg6aFkjXOaqPGPHS .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-yg6aFkjXOaqPGPHS .marker{fill:#333333;stroke:#333333;}#mermaid-svg-yg6aFkjXOaqPGPHS .marker.cross{stroke:#333333;}#mermaid-svg-yg6aFkjXOaqPGPHS svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-yg6aFkjXOaqPGPHS p{margin:0;}#mermaid-svg-yg6aFkjXOaqPGPHS .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-yg6aFkjXOaqPGPHS .cluster-label text{fill:#333;}#mermaid-svg-yg6aFkjXOaqPGPHS .cluster-label span{color:#333;}#mermaid-svg-yg6aFkjXOaqPGPHS .cluster-label span p{background-color:transparent;}#mermaid-svg-yg6aFkjXOaqPGPHS .label text,#mermaid-svg-yg6aFkjXOaqPGPHS span{fill:#333;color:#333;}#mermaid-svg-yg6aFkjXOaqPGPHS .node rect,#mermaid-svg-yg6aFkjXOaqPGPHS .node circle,#mermaid-svg-yg6aFkjXOaqPGPHS .node ellipse,#mermaid-svg-yg6aFkjXOaqPGPHS .node polygon,#mermaid-svg-yg6aFkjXOaqPGPHS .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-yg6aFkjXOaqPGPHS .rough-node .label text,#mermaid-svg-yg6aFkjXOaqPGPHS .node .label text,#mermaid-svg-yg6aFkjXOaqPGPHS .image-shape .label,#mermaid-svg-yg6aFkjXOaqPGPHS .icon-shape .label{text-anchor:middle;}#mermaid-svg-yg6aFkjXOaqPGPHS .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-yg6aFkjXOaqPGPHS .rough-node .label,#mermaid-svg-yg6aFkjXOaqPGPHS .node .label,#mermaid-svg-yg6aFkjXOaqPGPHS .image-shape .label,#mermaid-svg-yg6aFkjXOaqPGPHS .icon-shape .label{text-align:center;}#mermaid-svg-yg6aFkjXOaqPGPHS .node.clickable{cursor:pointer;}#mermaid-svg-yg6aFkjXOaqPGPHS .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-yg6aFkjXOaqPGPHS .arrowheadPath{fill:#333333;}#mermaid-svg-yg6aFkjXOaqPGPHS .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-yg6aFkjXOaqPGPHS .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-yg6aFkjXOaqPGPHS .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-yg6aFkjXOaqPGPHS .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-yg6aFkjXOaqPGPHS .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-yg6aFkjXOaqPGPHS .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-yg6aFkjXOaqPGPHS .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-yg6aFkjXOaqPGPHS .cluster text{fill:#333;}#mermaid-svg-yg6aFkjXOaqPGPHS .cluster span{color:#333;}#mermaid-svg-yg6aFkjXOaqPGPHS div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-yg6aFkjXOaqPGPHS .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-yg6aFkjXOaqPGPHS rect.text{fill:none;stroke-width:0;}#mermaid-svg-yg6aFkjXOaqPGPHS .icon-shape,#mermaid-svg-yg6aFkjXOaqPGPHS .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-yg6aFkjXOaqPGPHS .icon-shape p,#mermaid-svg-yg6aFkjXOaqPGPHS .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-yg6aFkjXOaqPGPHS .icon-shape .label rect,#mermaid-svg-yg6aFkjXOaqPGPHS .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-yg6aFkjXOaqPGPHS .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-yg6aFkjXOaqPGPHS .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-yg6aFkjXOaqPGPHS :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} BFS DFS
控制访问顺序
拓扑排序
控制依赖消除顺序
Kruskal Prim
控制安全最小边选择
Dijkstra
控制最短距离确定顺序

2. visited 的语义并不总一样

BFS / DFS

text 复制代码
visited = 已经发现,不要重复加入容器

Prim

text 复制代码
visited = 已经进入生成树

Dijkstra

朴素版中的 selected:

text 复制代码
selected = 最短距离已经最终确定

所以看到一个 Set<Node> 时,不要机械理解成"访问过"。

先问:

这个集合到底承诺了什么不变量?


3. 三种贪心为什么都能"选当前最小"?

Kruskal、Prim、Dijkstra 都在选某种"当前最小",但对象不同:

text 复制代码
Kruskal:全局剩余安全边里的最小边
Prim:当前生成树切分边界上的最小边
Dijkstra:当前未确定节点中的最短距离

它们看起来相似,但正确性依据不同。

不要把模板记成:

text 复制代码
"小根堆 = 贪心"

而应该记成:

text 复制代码
先证明"当前最小可以安全地被确定"
再决定用什么数据结构高效找到它

第四部分:统一图结构

今天使用的三个基础类可以整理为:

Node.java

java 复制代码
package class10;

import java.util.ArrayList;
import java.util.List;

/**
 * 图中的点。
 */
public class Node {
    public int value;          // 节点标识
    public int in;             // 入度
    public int out;            // 出度
    public List<Node> nexts;   // 从当前点可直接到达的节点
    public List<Edge> edges;   // 从当前点出发的边

    public Node(int value) {
        this.value = value;
        this.in = 0;
        this.out = 0;
        this.nexts = new ArrayList<>();
        this.edges = new ArrayList<>();
    }
}

Edge.java

java 复制代码
package class10;

/**
 * 图中的边。
 */
public class Edge {
    public Node from;
    public Node to;
    public int weight;

    public Edge(Node from, Node to, int weight) {
        this.from = from;
        this.to = to;
        this.weight = weight;
    }
}

Graph.java

java 复制代码
package class10;

import java.util.HashMap;
import java.util.HashSet;

/**
 * 图结构。
 */
public class Graph {
    public HashMap<Integer, Node> nodes; // 点集
    public HashSet<Edge> edges;          // 边集

    public Graph() {
        nodes = new HashMap<>();
        edges = new HashSet<>();
    }
}

结构关系图

#mermaid-svg-96QQALv0qutFQtA8{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-96QQALv0qutFQtA8 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-96QQALv0qutFQtA8 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-96QQALv0qutFQtA8 .error-icon{fill:#552222;}#mermaid-svg-96QQALv0qutFQtA8 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-96QQALv0qutFQtA8 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-96QQALv0qutFQtA8 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-96QQALv0qutFQtA8 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-96QQALv0qutFQtA8 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-96QQALv0qutFQtA8 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-96QQALv0qutFQtA8 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-96QQALv0qutFQtA8 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-96QQALv0qutFQtA8 .marker.cross{stroke:#333333;}#mermaid-svg-96QQALv0qutFQtA8 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-96QQALv0qutFQtA8 p{margin:0;}#mermaid-svg-96QQALv0qutFQtA8 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-96QQALv0qutFQtA8 .cluster-label text{fill:#333;}#mermaid-svg-96QQALv0qutFQtA8 .cluster-label span{color:#333;}#mermaid-svg-96QQALv0qutFQtA8 .cluster-label span p{background-color:transparent;}#mermaid-svg-96QQALv0qutFQtA8 .label text,#mermaid-svg-96QQALv0qutFQtA8 span{fill:#333;color:#333;}#mermaid-svg-96QQALv0qutFQtA8 .node rect,#mermaid-svg-96QQALv0qutFQtA8 .node circle,#mermaid-svg-96QQALv0qutFQtA8 .node ellipse,#mermaid-svg-96QQALv0qutFQtA8 .node polygon,#mermaid-svg-96QQALv0qutFQtA8 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-96QQALv0qutFQtA8 .rough-node .label text,#mermaid-svg-96QQALv0qutFQtA8 .node .label text,#mermaid-svg-96QQALv0qutFQtA8 .image-shape .label,#mermaid-svg-96QQALv0qutFQtA8 .icon-shape .label{text-anchor:middle;}#mermaid-svg-96QQALv0qutFQtA8 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-96QQALv0qutFQtA8 .rough-node .label,#mermaid-svg-96QQALv0qutFQtA8 .node .label,#mermaid-svg-96QQALv0qutFQtA8 .image-shape .label,#mermaid-svg-96QQALv0qutFQtA8 .icon-shape .label{text-align:center;}#mermaid-svg-96QQALv0qutFQtA8 .node.clickable{cursor:pointer;}#mermaid-svg-96QQALv0qutFQtA8 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-96QQALv0qutFQtA8 .arrowheadPath{fill:#333333;}#mermaid-svg-96QQALv0qutFQtA8 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-96QQALv0qutFQtA8 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-96QQALv0qutFQtA8 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-96QQALv0qutFQtA8 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-96QQALv0qutFQtA8 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-96QQALv0qutFQtA8 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-96QQALv0qutFQtA8 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-96QQALv0qutFQtA8 .cluster text{fill:#333;}#mermaid-svg-96QQALv0qutFQtA8 .cluster span{color:#333;}#mermaid-svg-96QQALv0qutFQtA8 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-96QQALv0qutFQtA8 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-96QQALv0qutFQtA8 rect.text{fill:none;stroke-width:0;}#mermaid-svg-96QQALv0qutFQtA8 .icon-shape,#mermaid-svg-96QQALv0qutFQtA8 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-96QQALv0qutFQtA8 .icon-shape p,#mermaid-svg-96QQALv0qutFQtA8 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-96QQALv0qutFQtA8 .icon-shape .label rect,#mermaid-svg-96QQALv0qutFQtA8 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-96QQALv0qutFQtA8 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-96QQALv0qutFQtA8 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-96QQALv0qutFQtA8 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Graph
Node 集合
Edge 集合
nexts 邻接点
edges 出边
from
to
weight


第五部分:建议测试用例

图算法特别适合用"小图手推 + 边界图"测试。

BFS / DFS

text 复制代码
1. root == null
2. 单节点
3. 一条链
4. 一个节点指向多个邻居
5. 多个节点共同指向同一个节点
6. 存在自环
7. 存在大环:1 -> 2 -> 3 -> 1
8. 图不连通:确认只遍历 root 可达部分

拓扑排序

text 复制代码
1. 空图
2. 单节点
3. 一条依赖链
4. 多个零入度起点
5. 一个节点依赖多个前驱
6. DAG 有多种合法拓扑序
7. 2 节点环:1 -> 2 -> 1
8. 自环:1 -> 1

Kruskal / Prim

text 复制代码
1. 单节点
2. 两点一边
3. 三角形:检查是否跳过成环边
4. 多条相同权重边
5. 连通图
6. 不连通图:检查最小生成森林
7. 无向边使用双向 Edge 表示
8. 含负权边:MST 算法本身可以处理负权

Dijkstra

text 复制代码
1. 单节点
2. 只有一条边
3. 同一节点存在多条候选路径
4. 后发现的路径更短,需要松弛更新
5. 存在不可达节点
6. 边权为 0
7. 出现负权边:应拒绝使用 Dijkstra
8. 距离累加接近 Integer.MAX_VALUE

今日自测

1. BFS 为什么推荐在"入队时"就标记 visited?

查看答案

因为一个节点可能被多个前驱同时发现。第一次发现时立即标记,可以保证它只进入队列一次,避免重复入队和重复处理。

2. DFS 为什么经常倒序压栈邻居?

查看答案

栈是后进先出。若希望邻接表中靠前的节点先被弹出,就需要让靠后的节点先入栈,因此通常从 nexts 末尾向前压栈。

3. 拓扑排序为什么能用结果长度检测环?

查看答案

有向环中的节点无法通过删除环外节点把剩余入度全部降为 0,因此不会进入零入度队列。若最终结果节点数少于总节点数,就说明还有节点无法被消除,图中存在环。

4. Kruskal 为什么需要并查集?

查看答案

每次尝试加入最小边时,需要快速判断两个端点是否已经连通。若已连通,再加入该边必然形成环;并查集正好高效维护这种动态连通性。

5. Prim 为什么不需要并查集?

查看答案

Prim 始终维护"已经进入生成树的节点集合"。候选边若指向已访问节点就丢弃,指向新节点才接纳,因此 visited 已经足够完成防环判断。

6. Dijkstra 中"发现一个节点"和"确定一个节点"有什么区别?

查看答案

发现只表示目前存在一条到达它的路径,因此得到的是候选距离;当它成为所有未确定节点中距离最小者并被选中或从最小堆弹出后,才表示最短距离最终确定。

7. Dijkstra 第一次发现邻居时为什么不能只保存边权?

查看答案

边权只表示 current 到 next 的局部代价。起点到 next 的完整候选距离必须是 distcurrent + edge.weight。

8. Kruskal、Prim、Dijkstra 的"最小"分别是什么?

查看答案

Kruskal 选全局剩余安全边中的最小边;Prim 选当前生成树边界上的最小边;Dijkstra 选当前未确定节点中候选距离最小的节点。


加餐挑战

  1. 把 BFS 改成返回 List<Node>,不要直接打印;
  2. 为 BFS 增加 distance,返回起点到每个节点的无权最短距离;
  3. 用递归方式实现 DFS,并比较显式栈与递归栈;
  4. 拓扑排序除了返回顺序,再返回一个 hasCycle 状态;
  5. 用优先队列重写 Dijkstra,但允许重复 Record 入堆,通过"过期记录检查"避免自定义 decrease-key;
  6. 比较 O(E log E) 的 Kruskal 与堆版 Prim 在稀疏图、稠密图中的表现;
  7. 思考:如果图中存在负权边但没有负环,应该选择什么最短路算法?
  8. 尝试实现 Bellman-Ford,并与 Dijkstra 的"确定节点"思想做对比。

挑战 5 提示:允许重复候选

普通 PriorityQueue 不支持 decrease-key 时,可以直接把更短的新 Record 再压入堆。弹出时如果该 Record 的距离已经不是当前最佳距离,就把它视为过期记录并跳过。


Day 18 复盘清单

  • 我能写出 Queue + visited 的图 BFS
  • 我知道 BFS 中节点为什么只应该输出一次
  • 我能用显式栈写 DFS,并解释为什么倒序压栈
  • 我能写出 Kahn 拓扑排序,并检测有向环
  • 我知道拓扑排序为什么不应该直接修改原始入度
  • 我能解释 Kruskal 中并查集的作用
  • 我能写出路径压缩与按大小合并
  • 我能解释 Prim 的"点解锁边"过程
  • 我能说清 Kruskal 与 Prim 的候选集合差异
  • 我能写出 Dijkstra 的松弛公式
  • 我知道第一次发现邻居时必须加上当前节点距离
  • 我能解释 Dijkstra 为什么不能处理负权边
  • 我能理解改写堆的 add / update / ignore
  • 我能说清 BFS、DFS、MST、最短路中 visited 的不同语义

一句话总结: Day 18 真正要建立的是"图上的候选状态"意识:BFS 用队列控制扩散顺序,DFS 用栈控制深入顺序,拓扑排序用入度控制依赖消除,Kruskal 与 Prim 用安全的最小边扩张结构,而 Dijkstra 用最小候选距离逐步确定最短路。

相关推荐
无线通信科研笔记21 分钟前
IEEE TVT 2026 论文精读与完整复现|相位误差如何重塑近场 RIS 的幅相响应
论文阅读·人工智能·python·算法·论文笔记
mmmmath_31 小时前
二叉树の递归遍历
算法
All for pursuit.2 小时前
【位运算-1】338.比特位计数
数据结构·c++·算法·leetcode
小师兄吃牛肉2 小时前
C语言中的nullptr有必要吗
c语言·c++·算法
一只小小的芙厨4 小时前
【线性DP】
学习·算法·动态规划
奋发向前wcx4 小时前
CSP-J复赛模拟赛5 王晨旭补题 2026.10.5
算法
罗湖老棍子6 小时前
C Looooops(信息学奥赛一本通- P1640)
算法·数论·同余·裴蜀定理·扩展欧几里得
飘尘8 小时前
从"算子"到"AI Infra":大模型背后看不见的那群人在忙什么
人工智能·算法·面试
夏幻灵9 小时前
每日一题:LeetCode 3:无重复字符的最长子串——滑动窗口思路详解
算法·leetcode·职场和发展
CarIise9 小时前
Java实训阶段查漏补缺复习笔记2
java·linux·算法