有这样一个很经典的面试题:系统里有 n 个任务,任务之间存在依赖关系,任务 A 完成之后才能开始任务 B,请输出一种合法的执行顺序,如果存在循环依赖则输出无解。很多候选人第一眼觉得难,其实这个题的核心就是“拓扑排序”。拓扑排序在编译构建、课程安排、任务调度里到处可见,面试中更是高频考点,翻过这座山,很多图论题的逻辑都会顺畅起来。
我会把这个题从建模开始讲清楚,包括两种主流解法(Kahn 的 BFS 思路、DFS 三色标记思路)的完整模板代码和复杂度分析,再补上我实际调试时踩过的坑和面试时的扩展问法。不管你是准备面试还是想把手里的依赖调度写得更干净,这篇文章都值得看完。
1. 真题还原:依赖任务到底在考什么
1.1 典型题目长这样
题目描述通常很短,大意如下:
有 n 个任务,编号为 0 到 n-1。给定 m 个依赖条件,每个条件表示某个任务必须在另一个任务之前执行,例如 [A, B] 表示任务 A 完成之后才能开始任务 B。请输出一种合法的任务执行顺序;如果存在循环依赖(例如 A 依赖 B、B 又依赖 A),则返回空列表或抛出异常。
有些题目变体会包装成“课程顺序”“编译顺序”“维修工序”,去掉外壳之后,里面的图结构是同一套。看到这种“前驱/后继”“先做/再做”的表述,第一反应就应该是建图,然后用拓扑排序解题。
1.2 面试官真正想看的四个能力
很多候选人上来就背模板,但面试官其实在观察四件事:
第一是建模能力。能不能把零散的依赖条件转化为有向图。第二是算法选型能力。知道两种常见解法,并且能说清楚区别,而不是只会一种。第三是环检测能力。拓扑排序只有在有向无环图上才有解,考察的是对循环依赖的判断是否严谨。第四是代码功底。边界条件、复杂度分析、代码可读性,这些细节往往最后拉分。
尤其是第二点和第三点,如果只知道 BFS 模板却说不清为什么能检测环,面试官多问两句就会露馅。这个题看着简单,实际上是把“图论基础 + 队列使用 + 递归理解”打包在一起考。
1.3 这题为什么能高频出现
真正的高频题往往不是因为它难,而是因为它贴近真实世界。装一个软件要先解决依赖包,编译一个项目要先编译被依赖的模块,排大学课表要先修完先修课,这些都是典型任务。面试官挑这个题,等于在考察候选人能不能把抽象算法落实到一个有工程感的场景里,再考察能不能处理循环依赖这种异常情况,覆盖面很广。
2. 先建立直觉:依赖关系怎么建模成图
2.1 任务变节点,依赖变边
建模这一关卡住的人不少。依赖条件 [A, B] 表示 A 必须在 B 之前执行,那就在图上画一条从 A 指向 B 的有向边。如果 A 是 B 的前置条件,从 A 能走到 B,意味着执行流的方向就是从 A 流向 B。
打个比方,做饭的时候“切菜”是“炒菜”的前置条件,那就在图上画一条“切菜 → 炒菜”的边。拓扑排序输出的顺序,就是保证所有边都满足“起点在终点之前”的节点排列。只要输出的序列满足这条规则,任何一条依赖边都不会被违反,任务就能按这个顺序稳定执行。
建图时通常用邻接表结构,也就是把每个节点的后继节点放在一个列表里。这个方法简单直观,最后遍历每个节点的邻居时,时间复杂度为 O(出边数量),不会浪费内存。
2.2 入度与出度是解题武器
有了图之后,还要引入两个概念:入度表示有多少条边指向该节点,出度表示该节点指向多少个其他节点。在依赖场景里,入度为 0 的节点意味着没有任何前置任务在等它,它可以最先执行。
这个观察是 Kahn 算法的核心。入度为 0 就是“解锁”信号,当一个节点的所有前置任务都被执行完,它的入度会逐步减到 0,再把它放入可执行队列。现实中做任务调度也一样,系统每完成一个前置任务,就去检查后置任务是否所有依赖都满足了,满足了就触发执行。算法和工程在这里是同一个思想。
2.3 为什么有环就一定无解
依赖成环是最重要的情况,比如 A 依赖 B,B 又依赖 A。这种情况下,A 要等 B,B 要等 A,谁都没法先开始,系统就死锁了。用拓扑排序的话术来说,有环的图不存在拓扑序,所以每一道依赖任务题都必须考虑环检测。
有个候选人的理解误区是“有环只是输出顺序不对,调整一下就行”,不对,有环就是无解,不存在任何合法的输出顺序。面试时只要提到循环依赖,第一句话就是“存在环时无解”,然后再写实现。检测环的方法在两种算法里各有体现,后续展开讲。
3. Kahn 算法:用入度和队列逐层剥离节点
3.1 思路一句话总结
Kahn 算法的思路非常像一个剥洋葱的过程:反复找到当前所有入度为 0 的节点,把它们加入结果序列,然后把它们指向的后继节点的入度减 1。如果某个节点的入度因此变成 0,说明它的所有前置任务都完成了,又可以把它的后继继续解锁。
为什么这个做法不会破坏顺序?因为每次加入结果序列的节点,入度都是 0,没有任何前置任务在它之前还没执行完。这样新加入的节点放在当前序列末尾,不会让任何一条依赖边“倒挂”。整个过程可以理解为一个多队列同时开启的流水线,一批任务完成了,下一批任务自然解锁。
3.2 Java 模板代码
直接给一套可背诵的版本,我用 Java 写,但思路可以平移到任何语言:
public List<Integer> topoSort(int n, int[][] edges) { // 1. 建图 List<List<Integer>> graph = new ArrayList<>(); int[] inDegree = new int[n]; for (int i = 0; i < n; i++) { graph.add(new ArrayList<>()); } // edges 的每个元素是 [from, to],表示 from 必须在 to 之前执行 for (int[] edge : edges) { int from = edge[0]; int to = edge[1]; graph.get(from).add(to); inDegree[to]++; // to 多了一个前置节点 } // 2. 把入度为 0 的节点全部入队 Deque<Integer> queue = new ArrayDeque<>(); for (int i = 0; i < n; i++) { if (inDegree[i] == 0) { queue.offer(i); } } // 3. 逐个取出入度为零的节点,并且解锁后继节点 List<Integer> res = new ArrayList<>(); while (!queue.isEmpty()) { int cur = queue.poll(); res.add(cur); for (int next : graph.get(cur)) { inDegree[next]--; if (inDegree[next] == 0) { queue.offer(next); } } } // 4. 如果有节点没有进入 res,说明存在环 return res.size() == n ? res : Collections.emptyList(); }代码里的关键点有两个:一是入度数组的初始化,二是每次拿走节点后同步修改后继节点的入度。这个操作不能漏,漏了就会出现死循环或者漏掉可用节点。
3.3 正确性直觉与复杂度
为什么最终结果一定合法?每一步加入 res 的节点入度都是 0,意味着它没有尚未完成的前置任务。而其他还没执行的节点即使后面入度减到 0,也是在这个节点被移除之后才发生的,所以边 “之前节点 → 当前节点” 一定满足起点在前、终点在后。递归地看,整条链都满足拓扑序。
复杂度方面,建图要遍历所有边一次,时间 O(M);每个节点入队出队一次,时间 O(N);每个节点的出边也会被遍历一次,总共还是 O(M)。所以整体时间复杂度是 O(N+M),空间复杂度也是 O(N+M),其中 N 是任务数,M 是依赖边数。这个复杂度在面试里几乎是没有优化余地的,非常优秀。
4. DFS 解法:递归回溯与三色标记法
4.1 思路一句话总结
DFS 版本的思路也直接:从任意一个节点出发,先递归处理它依赖的所有后继节点,等这些后继都处理完了,再把这个节点放进结果序列。也就是说,加入“结果”的顺序是深度优先的完成顺序,最后整体反转,就得到一个合法的拓扑序列。
换成工作流的话来说,就是“你让我做任务 B,那我先把 B 的前置任务 A 做完,回头我再做 B”。每个任务都这样往后延展,最终顺序就是一条合法执行链。单个节点这样递归是合理的,难点在于如何检测环,这也是这个版本最容易写错的地方。
4.2 Java 模板代码
public List<Integer> topoSort(int n, int[][] edges) { List<List<Integer>> graph = new ArrayList<>(); for (int i = 0; i < n; i++) { graph.add(new ArrayList<>()); } for (int[] edge : edges) { graph.get(edge[0]).add(edge[1]); } // 0 表示未访问,1 表示正在访问中,2 表示已经访问完成 int[] state = new int[n]; List<Integer> res = new ArrayList<>(); for (int i = 0; i < n; i++) { if (!dfs(i, graph, state, res)) { return Collections.emptyList(); // 检测到环,无解 } } Collections.reverse(res); // 反转才是正确的拓扑顺序 return res; } private boolean dfs(int node, List<List<Integer>> graph, int[] state, List<Integer> res) { if (state[node] == 1) { return false; // 再次访问正在访问的节点,说明有环 } if (state[node] == 2) { return true; // 已经处理完成,不需要重复处理 } state[node] = 1; // 标记为访问中 for (int next : graph.get(node)) { if (!dfs(next, graph, state, res)) { return false; } } state[node] = 2; // 所有后继都处理完了 res.add(node); // 当前节点加入结果 return true; }很多资料会说“用一个 visited 数组”,但单靠 visited 布尔数组是不能正确检测环的,必须用三色状态法。状态 1 的含义是“这个节点还在递归栈里,它的后继还没有全部处理完”。一旦在遍历时又遇到状态为 1 的节点,说明当前路径绕回自己了,这时环路就出现了。
4.3 为什么三色状态比“是否访问过”更可靠
用简单布尔数组时,存在一种误判:节点 A 先处理完了后继,状态标记为 visited;后来从另一条路径又走到 A,这时看到 visited 就直接返回,不会发现问题。但实际上第二次访问 A 时,可能正处于一个环的检测中,只因为 A 提前完成就忽略掉,环被漏掉了。
三色状态中的状态 2 才是真正“处理完成”,状态 1 表示“正在处理中”。只有状态为 1 时遇到再访问才是环。这套逻辑天然贴合“递归栈”的概念,只要进入递归就标记 1,退出递归就标记 2,遇到 1 就是同一轮递归里绕回来。理解了这一点,DFS 写法的环检测就再也不会出错了。
5. 两种方案如何选:面试现场决策
5.1 两者对比表
| 对比维度 | Kahn(BFS) | DFS 三色 |
|---|---|---|
| 核心思路 | 从入度为 0 的节点逐层剥离 | 递归完成后反向收集 |
| 环检测方式 | 最终节点数少于 n | 递归中遇到状态 1 |
| 实现复杂度 | 代码直观,无递归风险 | 代码稍短,依赖递归深度 |
| 依赖处理 | 可方便实现字典序最小 | 默认顺序受节点遍历顺序影响 |
| 栈溢出风险 | 无 | 图过长时可能有递归溢出 |
| 工程适用性 | 任务调度常用 | 编译、依赖分析中常用 |
5.2 什么时候选哪一种
我的个人习惯是默认选 Kahn,因为它的代码逻辑和现实中的任务调度思想完全一致,面试时解释也给力。而且 Kahn 不需要递归,不用担心图中链路过长导致栈溢出。
DFS 版本适合在题目本身已经给了“按依赖深度递归”的语境,或者当面试官追问“有没有另一种写法”时展示你对递归和三色标记的掌握程度。两道都写得出来的人,面试评价会明显高一档。
5.3 边界条件与常见变体
处理这个题时最好在一开始问清楚几个边界:任务编号是否一定是连续的 0 到 n-1?依赖关系中会不会出现重复边?是否存在自环?这些看似细枝末节的问题,写代码时不处理就可能出错。
另外还有两个高频变体:
一是要求字典序最小的拓扑序列。把 Kahn 算法中的普通队列换成优先队列(最小堆),每次取编号最小的可执行节点,就能得到字典序最小的结果。这个扩展在 LeetCode 风格的题目里经常出现,熟悉优先队列版本非常加分。
二是要求输出所有可能的合法排序。这种情况不能再只用简单拓扑排序,需要在回溯的基础上尝试多个入度为 0 的节点,用 DFS 枚举所有排列。面试能聊到这一层已经超出一般要求,属于加分项中的加分项。
6. 实战中的常见坑与排查方法
6.1 循环依赖导致结果数量不对
最容易踩的坑,是 Kahn 算法最后没有判断res.size() == n。如果存在环,队列会在某一时刻变空,但还有节点没进入结果。这时候最直观的现象就是结果里缺少一些任务,而且缺少的恰好是成环的那批。
排查时可以先打印每个节点的入度变化过程,看看是不是有几个节点的入度无论如何都不会降到 0。只要存在这种情况,基本可以断定是环。还有一个细节:题目中可能出现“自环”,也就是 A 依赖 A 本身,这种情况不用特殊处理,Kahn 算法里入度永远不会降到 0,自然会被检测出来。
6.2 DFS 忘记处理状态 2 的剪枝
DFS 写法里常见的错误是递归开头只判断状态 1,不判断状态 2。如果没有状态 2 这个提前返回,已处理的节点会被反复递归,在节点多、边多的时候性能会急剧下降,甚至栈溢出。
加if (state[node] == 2) return true;这一步不是可选项,是必须的剪枝。它保证每个节点只会被完整处理一次,总复杂度才是 O(N+M)。面试时代码里少写这一行,复杂度假装没有,测试用例稍微多一点就会超时,很容易被发现。
6.3 多个入度为 0 的节点时顺序不稳定
普通 Kahn 算法用 FIFO 队列时,多个入度为 0 的节点的输出顺序取决于它们入队顺序,而入队顺序又依赖节点编号和边的枚举顺序。这在大部分场景下无所谓,但如果你希望输出稳定,比如构建系统希望尽量先执行关键路径上的任务,只靠普通队列就不够了。
解决办法是把队列换成按某种优先级排序的数据结构。最常见的优先级是节点编号,也可以自定义为“用户手动指定的权重”。这里建议在面试时主动提一句,会显得你对调度的理解不止于背板。
6.4 我建议的测试用例模板
写题时可以快速验证这几组用例:
| 用例场景 | 输入 | 预期输出 |
|---|---|---|
| 无依赖 | n=3,edges=空数组 | 任意的 0/1/2 排列都可接受 |
| 单链依赖 | 0->1->2 | 0,1,2 |
| 分支依赖 | 0->2,1->2 | 0,1,2 或 1,0,2 均可 |
| 存在环 | 0->1,1->0 | 无解,返回空列表 |
| 自环 | 0->0 | 无解 |
这组用例覆盖了没有依赖、单链、多起点、有环、自环等所有边界,只要这几组全部通过,代码基本就能扛住面试官的实际测试用例。
7. 从面试题到真实工程:拓扑排序的扩展玩法
7.1 真实场景盘点
拓扑排序不是死记硬背的面试题,它在工程里的出场率极高。比如构建工具在编译前要先解决依赖顺序,数据库迁移脚本要按依赖表的顺序执行,安装包管理器要先安装底层依赖,微服务调用链里的初始化顺序也可以抽象成拓扑序。
我做某个内部任务调度系统时就遇到过类似的真实情况:一批数据清洗任务互相依赖,任务一跑就是几小时,一旦出现循环依赖,调度器直接死锁。后来我把依赖建模为有向图,用拓扑排序做合法性校验,在任务上线前先检测一遍,很多问题提前就暴露了。这个经验让我对这个面试题格外有亲切感。
7.2 扩展一:字典序最小的拓扑排序
题面如果要求“在合法顺序中输出字典序最小的一种”,做法很简单:Kahn 算法里不要用普通队列,改用最小堆。每次从堆里取出当前入度为 0 的最小节点,再依次解锁它的后继。这样每次取的都是“当前可用节点里编号最小”的,结果数组自然就是字典序最小。
复杂度会从原来的 O(N+M) 变成 O((N+M)logN),因为每次堆操作需要 logN。面试时主动说出这个复杂度变化,面试官通常会很满意。
7.3 扩展二:动态新增依赖如何快速校验
在真实系统里,依赖关系不是一开始就全部确定的,可能会在运行中新增一条边。这时每次新增依赖后重新做一次全量拓扑排序虽然简单,但效率不高。可以改为增量判断:在原有入度基础上增加to的入度,如果to当前已在执行队列或已完成,则需要重新计算受影响的部分。
这种增量更新的细节在实际系统里更复杂,需要配合缓存和事件回调。面试时你能说出“可以增量更新,而不是全量重算”这一思路,就已经展示了工程思维,代码层面不需要现场写得那么完整。
写在最后的一点体会
每次有人问我“拓扑排序到底怎么练”,我都会建议先把手画图的直觉练出来:拿到依赖条件,先在草稿纸上画出节点和箭头,再看入度为 0 的节点在哪里。把这一步练熟之后,BFS 和 DFS 的代码都只是把图画逻辑翻译成程序。
面试时我建议优先讲 Kahn 算法,因为它和真实任务调度的流程贴合得最紧密,代码也更容易避免递归带来的意外。在写完代码之后,主动指出“如果存在环,结果数量会少于节点总数,所以返回前一定要判断”这句话,能让面试官看出你踩过坑、考虑过异常分支。这个细节,往往比多背一道题更值钱。