☰
搞定拓扑排序:任务依赖高频面试题与两种经典解法
2026/10/13 23:01:42 网站建设 项目流程

有这样一个很经典的面试题:系统里有 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->20,1,2
分支依赖0->2,1->20,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 算法,因为它和真实任务调度的流程贴合得最紧密,代码也更容易避免递归带来的意外。在写完代码之后,主动指出“如果存在环,结果数量会少于节点总数,所以返回前一定要判断”这句话,能让面试官看出你踩过坑、考虑过异常分支。这个细节,往往比多背一道题更值钱。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询