☰
BFS解决拓扑排序:从原理到模板,掌握依赖图判环与排序
2026/10/15 21:02:45 网站建设 项目流程

如果你在大学选过课,一定对“想选操作系统,必须先修完数据结构”这种规定不陌生。如果把每门课看成一个点,把“先修关系”看成一条有方向的边,那么整个培养方案就变成了一张有向图。我们需要找一种可行的选课顺序,这件事就是拓扑排序。而用BFS(广度优先搜索)来解决拓扑排序问题,是算法面试里最高频的套路之一,也是很多同学从“看懂了”到“写得对”之间最容易出问题的一环。这篇文章就围绕BFS解决拓扑排序问题,把原理、建图、模板代码、变形题和常见坑位一次讲透,适合准备面试、搞竞赛以及刚学完图论基础想实战的读者。

1. 拓扑排序到底在拍什么队:认知先于代码

1.1 先修关系、编译依赖与任务调度的共同本质

抛开课本上的定义,拓扑排序解决的是同一类现实问题:前置依赖约束下的线性化。编译C++源文件时,头文件B被A依赖,编译器就必须先处理B;数据流水线里某个计算步骤依赖前一步的输出,调度器就必须让前一步先跑;微服务发版时,被依赖的服务往往要先上线,否则调用方一启动就报错。

这些场景都能抽象成一张有向图:每个任务是一个节点,每条边u->v表示“u必须先于v完成”。拓扑排序就是给所有节点排出一个线性顺序,使得对于图中每一条边u->v,u都出现在v前面。换句话说,它把一个“谁先谁后”的偏序关系,变成了一条完整的顺序链。

1.2 为什么只有有向无环图(DAG)才有拓扑序

这里有一个很关键的结论:一张有向图存在拓扑排序,当且仅当它是有向无环图(DAG)。

你可以先有一个直觉:如果图中存在环,比如A依赖B、B又依赖A,那么无论怎样排序,都必然有一个方向是矛盾的。A想在B前面,B又想在A前面,这种约束本身就自相矛盾,不可能找到合法顺序。反过来,如果一张有向图没有环,那么一定至少存在一个入度为0的节点(没有任何前置),从这个点开始往外剥,最终一定能剥完,这其实就是Kahn算法的雏形。

所以判断一个图能不能拓扑排序,本质上就是在判断它有没有环。很多人把拓扑排序当作一个独立的算法,其实它也是一把好用的判环工具,后面实战部分会反复用到这一点。

1.3 拓扑排序不唯一,但任何合法解都满足同一条性质

同一张DAG的拓扑序往往不止一个。最典型的例子是完全没有任何边的图,n个节点全是孤立的,那么任意排列都是合法拓扑序。即使有边约束,只要中间某一步有多个入度为0的节点可选,就可能产生不同的结果。

这一点对调试很重要。我见过同学拿着自己跑出的结果和别人对答案,发现顺序不同就立刻认为代码错了,其实两个结果可能都合法。判断一段输出是不是拓扑序,只需要验证每条边u->v是否都满足u在v前面,不需要和别人的顺序完全一致。只有当你明确要求“字典序最小”或“按编号优先”时,才需要额外约束。

2. BFS版拓扑排序(Kahn算法)为什么是“贪心剥洋葱”

2.1 入度:每个节点还剩多少个前置没完成

BFS解决拓扑排序的核心数据结构是入度表。一个节点的入度,含义是“指向它的边的数量”,放在依赖场景里,就是“它还有多少个前置没完成”。

建图时,每次读入一条边u->v,就在邻接表里记下u可以通向v,同时给v的入度加1。等所有边读完后,入度为0的节点意味着它没有任何前置依赖,可以立刻处理。

这个“入度减到0才能进行”的思路,是整个Kahn算法的灵魂。它本质上是一种贪心:每次都挑一个当前没有任何约束的节点先做,做完之后解除它指向的所有后续节点的约束。

2.2 算法流程:入队、出队、减入度、检测

我把完整流程拆成四个阶段,方便记忆:

  1. 初始化:建邻接表,统计所有节点的入度。
  2. 入队:把所有入度为0的节点全部放入队列。注意是“全部”,漏掉任何一个都会导致结果不完整。
  3. 出队处理:从队列取出节点cur,把它追加到拓扑序尾部。然后遍历cur的所有后继nxt,将nxt的入度减1。
  4. 再次入队:如果某个后继的入度减到了0,说明它的前置全部完成,此时把它放入队列。

这个过程很像食堂打饭:一个人前面没人了,就可以打饭;他打完饭,排在他后面的每个人前面都会少一个人;当某个人前面的人全部打完,他就可以进队。如果你愿意想象一层层剥洋葱,那就是先剥最外层,剥掉外层后露出新的外层,直到整颗洋葱剥完。

循环结束后,如果拓扑序列的长度等于节点总数,说明图中没有环;如果小于节点总数,说明有环,因为环上的节点永远不可能把入度减到0。

2.3 最小可运行的C++模板

下面这个模板是我现在默写拓扑排序时最常用的版本,核心逻辑完整,也方便在此基础上改造成各种变形题:

#include <bits/stdc++.h> using namespace std; // n: 节点数量,约束为节点编号 0 ~ n-1 // edges: 每条边 u->v vector<int> topoSort(int n, vector<vector<int>>& edges) { vector<vector<int>> graph(n); vector<int> indegree(n, 0); for (auto& e : edges) { int u = e[0], v = e[1]; graph[u].push_back(v); indegree[v]++; } queue<int> q; for (int i = 0; i < n; i++) { if (indegree[i] == 0) q.push(i); } vector<int> order; while (!q.empty()) { int cur = q.front(); q.pop(); order.push_back(cur); for (int nxt : graph[cur]) { if (--indegree[nxt] == 0) { q.push(nxt); } } } return order; }

很多新手在这里会犯一个经典错误:出队一个节点后,只要遍历到它的后继,就立刻把后继入队,而不是等入度减到0再入队。这里必须强调,如果后继的入度是2,说明它有两个前置,只完成一个时它还处于“差一个前置”的状态,不能提前入队。只有--indegree[nxt] == 0这一个条件成立时,才说明所有前置都完成了。

2.4 时间复杂度与空间复杂度:理想模板就是O(V+E)

Kahn算法的时间复杂度是O(V+E)。其中V是节点数,E是边数。节点入队出队各一次,是O(V);每条边在它的起点出队时被访问一次,是O(E)。初始化阶段扫描一次所有节点找入度为0的点,也是O(V)。所以整体线性,这也是拓扑排序在算法面试中被归为“模板型题目”的原因——它几乎没有优化空间,能做对就赢了。

空间复杂度是O(V+E),主要消耗在邻接表上,入度表和队列各占O(V)。在稀疏图上这个复杂度非常舒服,遇到n达到10^5甚至10^6级别的题目,只要没有环,BFS拓扑排序都能轻松过。

3. 必须掌握的建图思路:邻接表与入度表的正确打开方式

3.1 先搞清楚边的方向,这题就成功了一半

我在指导别人做题时最常问的一句话是:“你说的这个[a, b],到底是先做a再做b,还是先做b再做a?”题目描述不同,输入含义完全不同。在课程先修场景里,[a, b]常被定义为“要学a必须先学b”,那么应该建边b->a;也有平台定义成“a在b之前”,那才建a->b。

我的建议是:拿到题目先不要急着写循环,花10秒在注释里写清楚“边u->v表示u先于v”,然后严格按照这个约定建图。方向一但建反,整个拓扑序也会反向,环检测的结果也会变得不可信,而且这种bug极难通过肉眼看出来。

3.2 邻接表选型:vector<vector >在绝大多数场景下够用

对于绝大多数算法题,vector<vector<int>> graph(n)就是最合适的邻接表。它优点是代码直观、不容易越界、调试时也容易打印检查。只有当内存限制极严、节点数非常大的竞赛场景里,我才会考虑用数组模拟链式前向星,但那属于进阶优化,面试和常规比赛没必要。

建图的同时同步更新入度表,这是固定搭配:

graph[u].push_back(v); indegree[v]++;

这两行必须放在同一个循环里完成,否则容易出现“图建好了但入度表没更新”的低级错误。建议每次写完建图循环后,马上用一个简单的图人工验证一遍入度值对不对,这一步能省下后面大量调试时间。

3.3 重边和自环:不会误判环,但会带来隐藏的麻烦

先说结论:Kahn算法对重边其实有天然的“账账平衡”能力。假如有两条相同的边u->v,初始化时indegree[v]加了2,而u出队后遍历邻接表时会访问v两次,每次减1,最终仍然能正确把v的入度归零。因此逻辑上重边不会直接导致环的误判。

但重边会带来两个实际问题。第一是效率:邻接表里重复元素会让某些节点被反复遍历,如果重边数量很大,边数从E膨胀成E的倍数,复杂度虽然仍是O(V+E)级别,常数却会变差。第二,也是最容易翻车的是:如果你建图时用了unordered_set去重,却没有同步修改入度计数,那么入度表算的是“去重后的边数”,而邻接表里存的却是“去重后的边”,看似一致。真正的问题出现在另一种蠢写法——邻接表没去重、入度表却按set去重后的数量初始化,导致入度永远减不到位,最后误判成环。所以要么完全不去重,要么去重时保证邻接表和入度表口径一致。

自环(i->i)就更好玩了。它会让i的入度至少为1,而i想要入队又必须让入度减到0,可这个减入度操作又依赖i自己出队。这形成了一个“先有鸡还是先有蛋”的死锁,最终结果就是这个节点永远不会进拓扑序,BFS结束后长度小于n,自动被检测为有环。

3.4 隐式建图:从非图数据里提取边

不是所有拓扑排序题都会直接把边给你。有一类经典题,给了若干个字符串,要求根据它们之间的字典序推断字符顺序。这种题不会说“u指向v”,而是说“字符串A排在字符串B前面,第一个不同的字符c1在c2前面”。你需要自己扫描相邻字符串,提取出一条条字符之间的依赖边。

我处理这类题的习惯是分成两步。第一步,先把所有出现过的字符收集起来,作为图的节点集合。第二步,对每一对相邻字符串逐字符比较,在第一个不同位置建一条较小字符指向较大字符的边,然后立刻break。等全部边提取完,再跑标准Kahn模板。这种题真正的难点不在算法,而在建边部分逻辑是否严密——比如一个字符串是另一个的前缀时,应该怎么处理边界,需要格外小心。

4. 三道高频题目实战:从裸题到变形

4.1 实战一:课程先修类裸题,返回任一合法学习顺序

先看最经典的场景。某高校教务系统要把n门课程排出一个可行的学习顺序,给定若干条先修约束,形式是[a, b],含义是“学a之前必须先学b”。如果不存在合法顺序,就输出提示。这道题几乎是BFS拓扑排序的教科书。

我直接套用标准模板:

vector<int> findOrder(int n, vector<vector<int>>& prerequisites) { vector<vector<int>> graph(n); vector<int> indegree(n, 0); for (auto& pre : prerequisites) { int a = pre[0], b = pre[1]; graph[b].push_back(a); // 先学b,再学a indegree[a]++; } queue<int> q; for (int i = 0; i < n; i++) if (indegree[i] == 0) q.push(i); vector<int> order; while (!q.empty()) { int cur = q.front(); q.pop(); order.push_back(cur); for (int nxt : graph[cur]) { if (--indegree[nxt] == 0) q.push(nxt); } } if ((int)order.size() != n) { // 存在环,无法学完所有课程 return {}; } return order; }

这里最容易被忽略的是最后那个order.size() != n的判断。有些同学跑完while循环后直接返回order,即使图有环,也会返回一个不完整的序列,在部分判题场景下会得到错误答案。环检测不是可选项,是拓扑排序题的必需步骤。

4.2 实战二:字典序最小的拓扑排序,把队列换成优先队列

有时候题目会对输出顺序提出额外要求,最常见的是“在所有合法拓扑序中,输出字典序最小的那一个”。比如节点编号是课程编号,希望每次尽可能先选编号小的课程。

解决办法非常简单:把普通队列换成小顶堆,也就是优先队列。

vector<int> topoSortMinOrder(int n, vector<vector<int>>& edges) { vector<vector<int>> graph(n); vector<int> indegree(n, 0); for (auto& e : edges) { int u = e[0], v = e[1]; graph[u].push_back(v); indegree[v]++; } priority_queue<int, vector<int>, greater<int>> pq; for (int i = 0; i < n; i++) { if (indegree[i] == 0) pq.push(i); } vector<int> order; while (!pq.empty()) { int cur = pq.top(); pq.pop(); order.push_back(cur); for (int nxt : graph[cur]) { if (--indegree[nxt] == 0) pq.push(nxt); } } return order; }

这里面的逻辑也很好理解:Kahn算法本来就是“从当前所有可做任务里挑一个做”,普通队列只能保证按入队顺序;优先队列则能保证每次从所有入度为0的节点里取出编号最小的那个。如果题目要求按自定义优先级输出,同理,只需要修改优先队列的比较方式。这是BFS拓扑排序一个非常有价值的扩展点,面试官很喜欢在这个基础上加条件。

4.3 实战三:只问有环没环?拓扑排序就是判环工具

有一类题不要求输出完整顺序,只问“这个依赖图能不能完成”,本质上就是判环。你可以继续用模板,但不用保存整个order,只需要统计出队节点数量。

bool canFinish(int n, vector<vector<int>>& edges) { vector<vector<int>> graph(n); vector<int> indegree(n, 0); for (auto& e : edges) { int u = e[0], v = e[1]; graph[u].push_back(v); indegree[v]++; } queue<int> q; int cnt = 0; for (int i = 0; i < n; i++) { if (indegree[i] == 0) q.push(i); } while (!q.empty()) { int cur = q.front(); q.pop(); cnt++; for (int nxt : graph[cur]) { if (--indegree[nxt] == 0) q.push(nxt); } } return cnt == n; }

这套判环写法比DFS判环更让我放心,因为它不涉及递归深度,也不会出现“颜色标记没重置”之类的问题。遇到带有依赖死锁检测的题目时,直接拿这段改一改就能用。

5. 环、孤立点、编号偏移:这些边界条件才是拉开差距的地方

5.1 环检测的触发时机:不是入队时,而是结束后

很多人在Kahn算法的while循环里试图判断“当前节点是不是环的一部分”,这其实是误解。环上的节点永远不会进入队列,所以你根本等不到“处理环节点”的那一刻。正确时机是BFS结束后,看拓扑序列长度是否等于总节点数。

还有一个细节值得提醒:有些题目给出的依赖约束可能是不连通的,图由好几块独立部分组成。这种情况不影响正确性,Kahn算法初始化时会把所有连通块里入度为0的节点全部入队,每个连通块独立剥壳,最终仍然能得到完整拓扑序。

5.2 孤立节点:它要不要进拓扑序?

答案是“要”。一个没有任何依赖、也不被任何节点依赖的孤立点,它的入度是0,初始化阶段就会被丢进队列,然后被正常输出。从依赖语义上看,没有前置约束的任务当然可以安排在任何位置,所以它出现在拓扑序里完全合法。

有些同学会担心孤立点影响环判断,其实不会。它只会把order的长度撑大,最终cnt == n的判断依然准确。如果题目要求返回的序列必须包含所有节点,那么孤立点一定不能漏。

5.3 节点编号从0开始还是从1开始:最常见的运行时错误来源

很多图论题的节点编号是1到n,而不是0到n-1。如果忽略这一点,vector<vector<int>> graph(n)会少开一个位置,访问graph[n]时直接越界。

我的固定写法是:先用一个变量读入n,再根据编号范围决定数组长度。如果是1-based编号,就开n+1,循环从1遍历到n。为了少出错,我通常会在建图前加一个注释说明编号范围,尤其是多组数据题目,每次循环都要重新初始化数组,漏了清空上一组数据的残留值是WA的经典原因。

// 多组数据场景:方案一,把算法封装成函数,内部创建局部变量 // 方案二,使用全局数组时必须在每组数据开头 clear 和 fill

5.4 多组数据与全局变量的清理策略

竞赛题经常一口气给好几张图,要求分别输出结果。如果图结构定义在函数外部,就很容易出现“上一组的边还残留在邻接表里”的问题,导致这一组的入度凭空被加高,环也被误判出来。

我吃过一次亏之后,给自己定了一条规矩:拓扑排序的建图和存储尽可能放在函数内部,让局部变量自动销毁。如果因为接口设计必须使用全局数组,那就在每组数据开头用clear()清理邻接表,再用fill()把入度数组重置为0。相比反复检查哪里没清干净,局部变量方案省心得多。

6. BFS与DFS两种拓扑排序的实现对比与选型建议

6.1 用DFS也能得到拓扑序:后序遍历的逆序

除了Kahn算法,DFS也常被用来做拓扑排序。思路是:从一个节点出发,递归访问它的所有后继;当某个节点的所有后继都访问完后,才把这个节点自己记录进结果。这样得到的是一个“逆拓扑序”,最后整体reverse一下就能得到合法顺序。

为了检测环,需要给每个节点标记三种状态:0表示未访问,1表示正在当前递归栈中访问,2表示已经访问完毕。如果在递归过程中遇到状态为1的节点,说明回到了当前路径上,即存在环。

vector<int> color; // 0未访问,1访问中,2已访问 vector<vector<int>> graph; vector<int> order; bool dfs(int u) { color[u] = 1; for (int v : graph[u]) { if (color[v] == 1) return false; if (color[v] == 0 && !dfs(v)) return false; } color[u] = 2; order.push_back(u); return true; } bool topoByDFS(int n) { color.assign(n, 0); for (int i = 0; i < n; i++) { if (color[i] == 0 && !dfs(i)) return false; } reverse(order.begin(), order.end()); return true; }

有一个极其隐蔽的坑是:必须对每个未访问节点都调用一次dfs,不能只从一个点开始。因为图可能是不连通的,只从一个点出发会漏掉其他连通块里的节点。

6.2 两种写法的横向对比

我整理了一张表格,方便你根据场景快速选型:

对比维度BFS拓扑排序(Kahn)DFS三色标记拓扑排序
核心思路反复处理入度为0的节点后序遍历后取逆序
环检测方式最终节点数小于总数递归中遇到状态1节点
输出序列天然正向拓扑序需要整体reverse
字典序扩展普通队列换成优先队列即可扩展起来比较麻烦
递归风险无递归,队列驱动节点数大时可能爆栈
典型翻车点入度未减到0就入队漏掉对每个起点发起递归

单看功能,这两种方法都能解决拓扑排序和环检测。但实际工程和面试场景下,我更推荐BFS。理由是它的每一步都足够直观,可以通过打印入度表快速定位问题;DFS的三色标记虽然代码更短,但状态标记和push位置的错误很隐蔽,出错时不好排查。

6.3 我的选型习惯:无脑BFS是更稳的长期选择

现在我做题时,除非题目明确要求用DFS去理解全局拓扑,否则一律优先写BFS Kahn。原因有三:第一,它天然避免递归栈溢出,遇到n=10^5以上的数据也不用担心;第二,环检测是一个统一出口,代码可读性强;第三,后续想改成字典序最小、分层拓扑排序,都是小幅修改,而DFS改这些需求往往会伤筋动骨。

我帮某个同学排查过一次他写的DFS拓扑排序,他把order.push_back(u)放在了进入递归之前,结果输出的不是拓扑序而是前序遍历顺序。那种bug对着正确代码看半天都不一定能发现,因为代码结构看起来完全合理。相比之下,Kahn算法里每个判断条件都肉眼可见,调试友好性高了一个档次。

最后分享一个我在实际做题中养成的小习惯:拿到拓扑排序题,先不要急着写代码,花30秒做三件事——确认边的方向、确认节点编号从0还是1开始、确认重边和自环是否可能出现。这三件事确认完,BFS Kahn模板几乎不用改。遇到过太多同学在“输入的[a, b]到底谁指向谁”这种地方翻车,代码写得再漂亮也白搭。拓扑排序本身不难,难的是把问题干净地翻译成图。把这个翻译练熟了,你会发现它只是BFS的又一次顺手应用,后续想扩展成字典序最小、判断环、分层拓扑排序,都是在模板上小幅修改而已。

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

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

立即咨询