读完这篇,你能做什么
能构建小型邻接表并检测依赖环,解释遍历与拓扑排序的不同目标。
学习理由、准备要求与知识点
为什么学它
图能统一表达关系和任务依赖,为 DAG 编排、可达性与顺序调度建立基础。
先读这些内容
知识点清单
- 有向与无向图
- 邻接表与矩阵成本
- BFS 队列与最短步数
- DFS 递归与访问状态
- 有向环识别
- 入度与 Kahn 算法
- 拓扑序非唯一性
- V+E 成本和执行边界
运行前先确认Java 17;源码在本机 Microsoft OpenJDK 17.0.20.1 src.zip 核验,在线链接为上游 jdk-17.0.20-ga。深入模块可在掌握基础后回读。
01先弄懂它是什么
要表示城市连接或任务依赖,可以用图:顶点代表对象,边代表它们之间的关系。有向边有起点和终点,无向边表示双向连接。邻接表给每个顶点列出直接相连的顶点,适合连接不太密集的情况;邻接矩阵用二维表记录连接,查起来直接,但通常要占用顶点数平方级的空间。还应先说明是否允许重复边、自环和权重。
想按离起点的步数逐层查找,可用 BFS:把待访问节点放进队列,在发现时就标记,避免重复入队。在无权图中它能求最少边数路径。想沿一条路先走到底再返回,可用 DFS;图太深时可自己维护栈,避免函数调用层数过多。DFS 首次找到的路线通常不保证最短,要按问题选择方法。
拓扑排序只适用于有向无环图,给出一种让每条前置边都从前到后的顶点次序。它不是按名称排序,也不一定唯一。课程依赖、构建任务和 Agent 工作流可以用 DAG 表达,但静态拓扑次序不自动处理任务失败、权限、外部副作用或重试,实际执行器还需要额外状态。
文中这几个词,先认一认
- 邻接表
- 为每个顶点列出直接相连的顶点,例如为每门课列出它的后续课程。
- 入度
- 有多少条边指向这个顶点;在任务依赖图中可表示尚未完成的前置关系数。
- 有向无环图
- 边有方向,而且沿边走不会绕回自己,简称 DAG。
- 拓扑序
- 把顶点排成一列,使每条有向边的起点都在终点前面,即先安排前置任务。
02看看它怎么工作
Kahn 算法先数每个任务还有几个前置任务,这个数量叫入度。把数量为零的任务放入候选集合,每次取出一个作为下一项,并把依赖它的任务计数减一。某个任务的计数刚降到零,才把它加入候选。这样选出的每一项,在安排它之前都已经安排了全部前置项。这里是在计算顺序,还没有实际执行任务。
如果最终输出不足全部顶点,说明剩余关系里存在环。剩下的顶点不一定全部直接位于环上,也可能只是依赖环的后续节点;需要具体定位环时,可用 DFS 当前路径标记或强连通分量分析。只返回“这些都在环里”会把阻塞后继误报成环成员。
候选放进普通队列,就能得到一种合法顺序。如果多个任务现在都能安排,而你希望每次都选编号最小的那个,可以用最小堆保存候选,这样同一输入就有明确的选择规则。代价是加入和取出候选通常要做对数级的调整,不能继续无条件写成 O(V+E)。图中互不依赖的任务可以并行执行,但拓扑排序输出的列表本身只是一种先后安排。
正在加载流程图…
查看流程图源码(Mermaid)
flowchart TD
A["读取资料"] --> C["验证结论"]
B["读取项目约束"] --> C
C --> D["生成草稿"]
D --> E["检查输出"]
E --> F["提交可审阅结果"]按顺序看,每一步做了什么
- 规定顶点、边方向和重复边语义。
- 选择邻接表或矩阵表示。
- 按需求选择 BFS、DFS 或拓扑排序。
- 为访问标记和入度写出不变量。
- 区分静态顺序、环定位和实际任务执行。
03对照实现代码
Microsoft OpenJDK 17.0.20.1;java.base/java/util/concurrent/CompletableFuture.java · java.util.concurrent.CompletableFuture#andTree(CompletableFuture[],int,int)
以下为原创 Java 17 教学实现,不是原样 JDK 源码;只保留文中明确讨论的机制,省略优化、兼容及内部接口。
import java.util.concurrent.CompletableFuture;
public final class SourceModel {
public static CompletableFuture<Void> all(CompletableFuture<?>[] items) {
return combine(items,0,items.length);
}
private static CompletableFuture<Void> combine(CompletableFuture<?>[] items, int lo, int hi) {
if (lo == hi) return CompletableFuture.completedFuture(null);
if (hi-lo == 1) return items[lo].thenApply(value -> null);
int mid = lo + (hi-lo)/2;
// 通过两半完成阶段组合,展示依赖树而非顺序阻塞等待。
return combine(items,lo,mid).thenCombine(combine(items,mid,hi),(a,b) -> null);
}
}真实 CompletableFuture.andTree 将输入范围拆成两半,建立用于 allOf 的完成依赖树,并处理已完成、异常和需要注册双完成节点等路径。模型使用公开 thenCombine 表达平衡组合形状,不复制内部结果编码、完成栈和取消细节,也不是 JDK 提供的任意图拓扑排序器。
组合阶段说明要等哪些结果完成,不要求当前线程挨个阻塞。输入任务可能早已开始,也可能由别处完成;allOf 不会自动创建所有业务任务,也不会替它们选择执行器。先分清“安排结果依赖”和“真正启动工作”,再看异步任务图会更清楚。
平衡组合树限制了组合关系的深度,但并不改变必须等待所需输入结果这一依赖事实。异常如何进入聚合结果、取消是否传播以及回调在哪个线程运行,要按公开 CompletableFuture 契约核对,不能从教学树形图推断完整调度策略。
这段 Java 源码提供了真实库如何组织完成依赖的例子,却没有证明任意用户任务图无环。业务 DAG 应在注册阶段单独验证前置关系,执行阶段再跟踪成功、失败与取消。静态图检查和异步完成机制相互配合,谁也不能完全替代另一方。
代码先调用谁,再调用谁
allOf接收已有完成阶段集合andTree递归拆分范围- 两侧结果通过双输入完成关系组合
- 输入完成或异常按库契约形成聚合完成结果
04动手写一个例子
确定平价规则的 Kahn 拓扑排序
顶点编号零到 n-1,边 [u,v] 表示 u 必须在 v 前。允许重复边并按每条边计入度,拒绝非法端点;每次选编号最小的零入度点,存在环时抛异常,空图返回空。
- 输入 1
n4,边0→2、1→2、2→3- 输出 1
[0,1,2,3]
- 输入 2
n3,无边- 输出 2
[0,1,2]
- 输入 3
0→1、1→0- 输出 3
拒绝环
构建邻接表与入度数组时,每条边同时出现在起点邻接表和终点入度中。允许重复边意味着重复计数,也会在移除时重复减回,二者必须一致;只在一边去重会制造错误入度。端点和边数组形状在构建阶段验证。
最小堆保存当前全部零入度且未输出的顶点。取出顶点并遍历它的出边,邻居入度恰好降到零时入堆一次。初始独立顶点都可候选,最小编号规则使输出稳定,但稳定顺序不代表它是唯一合法拓扑序。
完成后比较输出数量与 n。若不足,剩余顶点至少依赖某个环,不能给调用者返回看似完整的计划。算法只计算静态次序,实际并行调度必须等任务真正成功或按规则结束后才释放后继,不能在把任务加入队列时就当作前置已完成。
为什么这样写是对的当前入度等于尚未输出顶点对该节点的剩余前置边数;候选堆恰好包含未输出且剩余入度为零的顶点。
每个顶点最多入堆出堆一次,每条边构建和移除各处理一次。若换普通队列可得到 O(V+E),但选择规则不同。
保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。
展开收起代码 · Main.java · 完整可运行程序
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
import java.util.ArrayList;
import java.util.List;
import java.util.PriorityQueue;
public class Main {
static List<Integer> order(int n, int[][] edges) {
if (n < 0 || edges == null) throw new IllegalArgumentException("input");
List<List<Integer>> graph = new ArrayList<>();
for (int i = 0; i < n; i++) graph.add(new ArrayList<>());
int[] indegree = new int[n];
for (int[] edge : edges) {
if (edge == null || edge.length != 2 || edge[0] < 0 || edge[0] >= n
|| edge[1] < 0 || edge[1] >= n) throw new IllegalArgumentException("edge");
graph.get(edge[0]).add(edge[1]);
indegree[edge[1]] = Math.addExact(indegree[edge[1]],1);
}
PriorityQueue<Integer> ready = new PriorityQueue<>();
for (int i = 0; i < n; i++) if (indegree[i] == 0) ready.add(i);
List<Integer> result = new ArrayList<>();
while (!ready.isEmpty()) {
int current = ready.remove();
result.add(current);
// 每条边只移除一次;恰好变成零才将后继加入候选。
for (int next : graph.get(current))
if (--indegree[next] == 0) ready.add(next);
}
if (result.size() != n) throw new IllegalArgumentException("cycle");
return List.copyOf(result);
}
static void check(List<Integer> wanted, int n, int[][] edges) {
if (!wanted.equals(order(n,edges))) throw new AssertionError(wanted);
}
static void reject(int n, int[][] edges) {
try { order(n,edges); } catch (IllegalArgumentException e) { return; }
throw new AssertionError("must reject");
}
public static void main(String[] args) {
// 共同前置、独立点、空图和重复边都固定顺序。
check(List.of(0,1,2,3),4,new int[][]{{0,2},{1,2},{2,3}});
check(List.of(0,1,2),3,new int[0][]);
check(List.of(),0,new int[0][]);
check(List.of(0,1),2,new int[][]{{0,1},{0,1}});
// 普通环、自环和越界端点分别拒绝。
reject(2,new int[][]{{0,1},{1,0}});
reject(1,new int[][]{{0,0}});
reject(2,new int[][]{{0,2}});
System.out.println("PASS 7");
}
}这些特殊情况也要试一试
- 空图合法。
- 无边图按编号输出。
- 重复边的入度与移除次数一致。
- 自环也是环,不能执行拓扑排序。
进阶选读 · 可选深入:带并发上限的 DAG 执行器
场景多个工具任务有前置、失败与资源限制,需要真实执行。
做法维护未开始、运行、成功、失败和取消状态,只有前置满足策略才释放后继;对外部副作用加入幂等与审批边界,并限制并发数。
复杂度图管理 O(V+E) 加候选调度成本,真实耗时由关键路径与资源约束决定。
好处与代价静态无环只保证依赖结构可排序,不保证工具成功、结果可信或外部动作已获授权。
05项目里怎么用
图建模先确保边方向统一。若有的模块把边写成依赖者到前置,有的写成前置到后继,算法仍能输出一个序列却会违反业务。给边写具体句子并用小图手工核对,比只命名 from 和 to 更可靠。新增边时也要重新验证环和受影响后继。
BFS 与 DFS 的访问标记应按问题选择。BFS 通常发现时标记避免重复入队;有向环检测需要区分当前路径中的灰色节点与已完成黑色节点,单个 visited 布尔值不足以解释回边。深度很大的图还需显式栈或深度限制,避免理论正确算法被调用栈限制打断。
任务图只说明计划上的先后,执行时还要规定失败怎样重试、结果怎样缓存、取消怎样传播,以及谁有权限做什么。一个节点进了队列,不等于已经完成;工具返回也不等于结果通过检查。分别保存计划、实际状态和验证记录,故障后才知道应继续哪一步。
容易踩的坑
- 混淆依赖边方向。
- 把 DFS 首次找到的路径当作无权最短路径。
- 把所有未输出顶点都误报为直接环成员。
- 任务刚入队就释放后继,混淆规划与完成。
几种做法,怎么选
表格有四列,窄屏可左右滑动查看代价和适用条件。
| 方案 | 优势 | 代价与限制 | 适用条件 |
|---|---|---|---|
| BFS | 层次清楚且可求无权最短路 | 前沿较宽时队列占内存 | 最少步数与分层探索 |
| DFS | 适合路径、连通与结构分析 | 递归深度和路径状态需管理 | 环检测、遍历与分解 |
| 拓扑排序 | 明确依赖先后并检测无环前提 | 只适用于有向无环关系 | 构建、课程和任务前置规划 |
06检验是否读懂了
先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。
先用自己的话解释
01为什么 BFS 能求无权最短路径?
展开参考答案与推导
它先处理距离零,再处理距离一、二等层次,某节点第一次被发现时已通过最少边数到达。这个证明依赖所有边等权,带不同权重时不能直接套用。
02拓扑排序为什么可能不唯一?
展开参考答案与推导
多个零入度节点之间没有未满足前置约束,可以选择不同顺序。最小堆只是增加确定平价规则,输出其中一种合法序列,不表示图本身唯一决定了顺序。
03剩余节点是否都位于环中?
展开参考答案与推导
不一定。某些节点只是依赖环中的节点,因此也无法降到零入度。要具体标出环成员,需要额外的 DFS 路径或强连通分量分析,不能直接把剩余集合等同于环。
换个情况,还会处理吗
01Agent任务图无环却仍失败,说明拓扑检查无效吗?
展开参考答案与推导
拓扑只验证前置结构可排序,不能保证工具调用、权限、输入和结果正确。执行层仍需处理失败、重试与证据验证,两类检查解决不同问题。
02图中重复边导致节点永远不入队,可能哪里错?
展开参考答案与推导
可能构建入度时计了重复,但邻接表去重后只减一次,或反过来。必须统一重复边契约,让计数与移除严格配对;也可以在两处一致去重。
03BFS队列膨胀且节点多次出现,先查什么?
展开参考答案与推导
检查是否等出队才标记访问,导致同一节点被多个父节点重复入队。通常在发现并加入队列时标记,保证每节点最多进入一次,除非问题刻意需要多状态访问。
写代码试一试
01验证每条边都符合输出次序
- 输入 / 条件
任意成功拓扑结果- 输出 / 预期
位置[u]小于位置[v]
提示 建立顶点到下标映射。
展开参考答案与推导
遍历结果建立 position 数组,再检查每条边。这个性质测试不依赖具体平价顺序,可用于验证其它合法实现,同时另保留最小编号顺序的接口测试。
02实现无权最短距离
- 输入 / 条件
源点0,边0→1、1→2、0→2- 输出 / 预期
到2距离1
提示 BFS发现时记录距离。
展开参考答案与推导
初始化距离为未访问值,源点为零,队列每取一项就对未访问邻居设置 dist[next]=dist[current]+1 并入队。不要用 DFS 首次路径长度替代最短距离。
03输出环导致的阻塞信息
- 输入 / 条件
0↔1,1→2- 输出 / 预期
排序失败,2可能只是后继
提示 区分环成员与阻塞集合。
展开参考答案与推导
可以先返回未输出节点作为“受环阻塞的候选集合”,明确不等于直接环成员;若需要精确原因,再在剩余图中运行强连通分量或保留 DFS 回边路径。
读完之后,独立完成
课后作业:自己做一遍
本篇共 10 道作业,先完成 8 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。
能构建小型邻接表并检测依赖环,解释遍历与拓扑排序的不同目标。
本篇重点加练
重点增加最短步数与环识别的对比练习,检验访问状态是否服务于正确问题。
- BFS 队列与最短步数
- 有向环识别
重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。
先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。
基础必做
- 作业 01原理问答
把课程先修关系与好友关系分别建成图,包含孤立节点;比较邻接表与邻接矩阵在稀疏和稠密数据下的存储、查边与遍历成本。
- 提交内容
- 两张图和成本表。
- 怎样算完成
- 方向由关系语义决定
- 孤立节点未被忽略
练到的知识点有向与无向图 · 邻接表与矩阵成本
- 作业 02代码推演
给定边 A→B、A→C、B→D、C→D、D→E,分别手动执行 BFS 与 DFS,记录队列或调用栈、访问状态及发现顺序;邻居顺序须先约定。
- 提交内容
- 逐步轨迹与访问状态表。
- 怎样算完成
- 共享后继处理可追踪
- 遍历顺序前提明确
练到的知识点BFS 队列与最短步数 · DFS 递归与访问状态
综合应用
- 作业 03编程实践
实现有向依赖计划检查器,报告是否能完成全部节点;测试自环、独立环、环后依赖节点及孤立节点,不只返回已处理节点数。
- 提交内容
- Java 实现、自写边界及小图参考测试、运行证据。
- 怎样算完成
- 失败与部分结果合同明确
- 重复边政策一致
- 所有输入节点都被检查
练到的知识点有向环识别 · 入度与 Kahn 算法
- 作业 04设计评审
多个课程同时无前置依赖,目录要求每次输出相同学习顺序。设计平价规则并重新计算引入该规则后的成本,说明排序完成不保证实际学习任务成功。
- 提交内容
- 排序合同、复杂度推导与执行边界说明。
- 怎样算完成
- 确定性依据可实现
- 数据结构成本完整
练到的知识点拓扑序非唯一性 · V+E 成本和执行边界
- 作业 05编程实践
实现无权关系图的最少跳数查询,输入允许不可达节点与同一源目标;返回距离及一条路径,限制图规模,验证路径确由输入边组成。
- 提交内容
- Java 实现、自写路径/不可达/重复边测试与运行证据。
- 怎样算完成
- 最短性与路径合法性分别检查
- 输入方向被遵守
- 空间成本含访问状态
练到的知识点有向与无向图 · 邻接表与矩阵成本 · BFS 队列与最短步数
- 作业 06编程实践
实现课程关系图查询:报告两课程之间最少依赖步数,同时独立报告目标相关依赖是否含环;图可有断开的环与重复边。
- 提交内容
- Java 实现、自写可达/不可达/局部环测试与运行证据。
- 怎样算完成
- 两个问题有独立结果合同
- 图中无关部分不会被误解释
练到的知识点BFS 队列与最短步数 · 有向环识别
- 作业 07代码推演
有向图中两个起点同时作为距离零的入口,手推到各节点的最少步数;选择共享后继样本并记录入队时机与距离来源。
- 提交内容
- 多源轨迹与距离表。
- 怎样算完成
- 距离语义先定义
- 同一节点多个发现路径可追踪
练到的知识点BFS 队列与最短步数
- 作业 08设计评审
依赖检查只从一个根节点开始,图中还有未连接组件。制定“整图无环”和“本次执行范围无环”两种需求的检查合同。
- 提交内容
- 图样本、检查范围和验收条件。
- 怎样算完成
- 结论明确限定子图范围
- 孤立组件不被隐含忽略
练到的知识点有向环识别
进阶挑战 · 掌握前置后选做
- 作业 09面试场景
完成任务调度后:依赖图运行期间新增一条边,部分节点已经成功。评审是否还能使用原拓扑计划,并规定变更、失败传播与重新验证的处理规则。
- 提交内容
- 版本化依赖图设计及事件验收场景。
- 怎样算完成
- 静态图保证不被直接套用动态执行
- 已经产生副作用的节点单独考虑
练到的知识点DFS 递归与访问状态 · 有向环识别 · 入度与 Kahn 算法 · V+E 成本和执行边界
思维拓展
先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。
- 作业 10思维拓展 · 反例构造
审查“遇到已访问节点就一定发现环”以及“有环就无法计算任何最短步数”两项说法。为各项寻找最小有向图并给出逐步证据。
- 提交内容
- 两组图、访问轨迹与结论边界。
- 怎样算完成
- 证据涉及访问状态的具体含义
- 最短路问题的前提明确
练到的知识点BFS 队列与最短步数 · 有向环识别
完成后,再检验一次
给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。
继续阅读
先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。
- 必读教程英文
Princeton Algorithms:无向图 (新标签页)↗
阅读邻接表、DFS、BFS 与无权最短路径;将示例结构与访问标记逐步画出来。
- 实践教程英文
Princeton Algorithms:有向图 (新标签页)↗
阅读有向环、DAG、拓扑排序和基于入度的队列算法,连接任务依赖调度。
- 深入参考文档英文
Java 17 API:ArrayDeque (新标签页)↗
比较栈和队列方向的 offer
/poll/push/pop,并了解空值限制。 - 深入教程英文
Harvard CS50:数据结构 (新标签页)↗
阅读数组、链表、栈队列、树与哈希表;C 指针示例只用于理解结构,练习以 Java 17 改写。
- 深入参考文档英文
OpenJDK 17u java.util.concurrent.CompletableFuture (新标签页)↗
定位
CompletableFuture.andTree的平衡完成依赖组合;它是 DAG 组合思路的真实 Java 例子,不是通用图算法实现。 - 深入参考文档英文
Java17 CompletableFuture API (新标签页)↗
核对
allOf、组合阶段、异常与执行器的职责边界。