2026/10/15 21:02:47

BFS解决拓扑排序:从原理到模板,掌握依赖图判环与排序

BFS解决拓扑排序:从原理到模板,掌握依赖图判环与排序 如果你在大学选过课一定对“想选操作系统必须先修完数据结构”这种规定不陌生。如果把每门课看成一个点把“先修关系”看成一条有方向的边那么整个培养方案就变成了一张有向图。我们需要找一种可行的选课顺序这件事就是拓扑排序。而用BFS广度优先搜索来解决拓扑排序问题是算法面试里最高频的套路之一也是很多同学从“看懂了”到“写得对”之间最容易出问题的一环。这篇文章就围绕BFS解决拓扑排序问题把原理、建图、模板代码、变形题和常见坑位一次讲透适合准备面试、搞竞赛以及刚学完图论基础想实战的读者。1. 拓扑排序到底在拍什么队认知先于代码1.1 先修关系、编译依赖与任务调度的共同本质抛开课本上的定义拓扑排序解决的是同一类现实问题前置依赖约束下的线性化。编译C源文件时头文件B被A依赖编译器就必须先处理B数据流水线里某个计算步骤依赖前一步的输出调度器就必须让前一步先跑微服务发版时被依赖的服务往往要先上线否则调用方一启动就报错。这些场景都能抽象成一张有向图每个任务是一个节点每条边u-v表示“u必须先于v完成”。拓扑排序就是给所有节点排出一个线性顺序使得对于图中每一条边u-vu都出现在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 算法流程入队、出队、减入度、检测我把完整流程拆成四个阶段方便记忆初始化建邻接表统计所有节点的入度。入队把所有入度为0的节点全部放入队列。注意是“全部”漏掉任何一个都会导致结果不完整。出队处理从队列取出节点cur把它追加到拓扑序尾部。然后遍历cur的所有后继nxt将nxt的入度减1。再次入队如果某个后继的入度减到了0说明它的前置全部完成此时把它放入队列。这个过程很像食堂打饭一个人前面没人了就可以打饭他打完饭排在他后面的每个人前面都会少一个人当某个人前面的人全部打完他就可以进队。如果你愿意想象一层层剥洋葱那就是先剥最外层剥掉外层后露出新的外层直到整颗洋葱剥完。循环结束后如果拓扑序列的长度等于节点总数说明图中没有环如果小于节点总数说明有环因为环上的节点永远不可能把入度减到0。2.3 最小可运行的C模板下面这个模板是我现在默写拓扑排序时最常用的版本核心逻辑完整也方便在此基础上改造成各种变形题#include bits/stdc.h using namespace std; // n: 节点数量约束为节点编号 0 ~ n-1 // edges: 每条边 u-v vectorint topoSort(int n, vectorvectorint edges) { vectorvectorint graph(n); vectorint indegree(n, 0); for (auto e : edges) { int u e[0], v e[1]; graph[u].push_back(v); indegree[v]; } queueint q; for (int i 0; i n; i) { if (indegree[i] 0) q.push(i); } vectorint 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(VE)Kahn算法的时间复杂度是O(VE)。其中V是节点数E是边数。节点入队出队各一次是O(V)每条边在它的起点出队时被访问一次是O(E)。初始化阶段扫描一次所有节点找入度为0的点也是O(V)。所以整体线性这也是拓扑排序在算法面试中被归为“模板型题目”的原因——它几乎没有优化空间能做对就赢了。空间复杂度是O(VE)主要消耗在邻接表上入度表和队列各占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 邻接表选型vectorvector 在绝大多数场景下够用对于绝大多数算法题vectorvectorint graph(n)就是最合适的邻接表。它优点是代码直观、不容易越界、调试时也容易打印检查。只有当内存限制极严、节点数非常大的竞赛场景里我才会考虑用数组模拟链式前向星但那属于进阶优化面试和常规比赛没必要。建图的同时同步更新入度表这是固定搭配graph[u].push_back(v); indegree[v];这两行必须放在同一个循环里完成否则容易出现“图建好了但入度表没更新”的低级错误。建议每次写完建图循环后马上用一个简单的图人工验证一遍入度值对不对这一步能省下后面大量调试时间。3.3 重边和自环不会误判环但会带来隐藏的麻烦先说结论Kahn算法对重边其实有天然的“账账平衡”能力。假如有两条相同的边u-v初始化时indegree[v]加了2而u出队后遍历邻接表时会访问v两次每次减1最终仍然能正确把v的入度归零。因此逻辑上重边不会直接导致环的误判。但重边会带来两个实际问题。第一是效率邻接表里重复元素会让某些节点被反复遍历如果重边数量很大边数从E膨胀成E的倍数复杂度虽然仍是O(VE)级别常数却会变差。第二也是最容易翻车的是如果你建图时用了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拓扑排序的教科书。我直接套用标准模板vectorint findOrder(int n, vectorvectorint prerequisites) { vectorvectorint graph(n); vectorint indegree(n, 0); for (auto pre : prerequisites) { int a pre[0], b pre[1]; graph[b].push_back(a); // 先学b再学a indegree[a]; } queueint q; for (int i 0; i n; i) if (indegree[i] 0) q.push(i); vectorint 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 实战二字典序最小的拓扑排序把队列换成优先队列有时候题目会对输出顺序提出额外要求最常见的是“在所有合法拓扑序中输出字典序最小的那一个”。比如节点编号是课程编号希望每次尽可能先选编号小的课程。解决办法非常简单把普通队列换成小顶堆也就是优先队列。vectorint topoSortMinOrder(int n, vectorvectorint edges) { vectorvectorint graph(n); vectorint indegree(n, 0); for (auto e : edges) { int u e[0], v e[1]; graph[u].push_back(v); indegree[v]; } priority_queueint, vectorint, greaterint pq; for (int i 0; i n; i) { if (indegree[i] 0) pq.push(i); } vectorint 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, vectorvectorint edges) { vectorvectorint graph(n); vectorint indegree(n, 0); for (auto e : edges) { int u e[0], v e[1]; graph[u].push_back(v); indegree[v]; } queueint 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。如果忽略这一点vectorvectorint graph(n)会少开一个位置访问graph[n]时直接越界。我的固定写法是先用一个变量读入n再根据编号范围决定数组长度。如果是1-based编号就开n1循环从1遍历到n。为了少出错我通常会在建图前加一个注释说明编号范围尤其是多组数据题目每次循环都要重新初始化数组漏了清空上一组数据的残留值是WA的经典原因。// 多组数据场景方案一把算法封装成函数内部创建局部变量 // 方案二使用全局数组时必须在每组数据开头 clear 和 fill5.4 多组数据与全局变量的清理策略竞赛题经常一口气给好几张图要求分别输出结果。如果图结构定义在函数外部就很容易出现“上一组的边还残留在邻接表里”的问题导致这一组的入度凭空被加高环也被误判出来。我吃过一次亏之后给自己定了一条规矩拓扑排序的建图和存储尽可能放在函数内部让局部变量自动销毁。如果因为接口设计必须使用全局数组那就在每组数据开头用clear()清理邻接表再用fill()把入度数组重置为0。相比反复检查哪里没清干净局部变量方案省心得多。6. BFS与DFS两种拓扑排序的实现对比与选型建议6.1 用DFS也能得到拓扑序后序遍历的逆序除了Kahn算法DFS也常被用来做拓扑排序。思路是从一个节点出发递归访问它的所有后继当某个节点的所有后继都访问完后才把这个节点自己记录进结果。这样得到的是一个“逆拓扑序”最后整体reverse一下就能得到合法顺序。为了检测环需要给每个节点标记三种状态0表示未访问1表示正在当前递归栈中访问2表示已经访问完毕。如果在递归过程中遇到状态为1的节点说明回到了当前路径上即存在环。vectorint color; // 0未访问1访问中2已访问 vectorvectorint graph; vectorint 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拓扑排序KahnDFS三色标记拓扑排序核心思路反复处理入度为0的节点后序遍历后取逆序环检测方式最终节点数小于总数递归中遇到状态1节点输出序列天然正向拓扑序需要整体reverse字典序扩展普通队列换成优先队列即可扩展起来比较麻烦递归风险无递归队列驱动节点数大时可能爆栈典型翻车点入度未减到0就入队漏掉对每个起点发起递归单看功能这两种方法都能解决拓扑排序和环检测。但实际工程和面试场景下我更推荐BFS。理由是它的每一步都足够直观可以通过打印入度表快速定位问题DFS的三色标记虽然代码更短但状态标记和push位置的错误很隐蔽出错时不好排查。6.3 我的选型习惯无脑BFS是更稳的长期选择现在我做题时除非题目明确要求用DFS去理解全局拓扑否则一律优先写BFS Kahn。原因有三第一它天然避免递归栈溢出遇到n10^5以上的数据也不用担心第二环检测是一个统一出口代码可读性强第三后续想改成字典序最小、分层拓扑排序都是小幅修改而DFS改这些需求往往会伤筋动骨。我帮某个同学排查过一次他写的DFS拓扑排序他把order.push_back(u)放在了进入递归之前结果输出的不是拓扑序而是前序遍历顺序。那种bug对着正确代码看半天都不一定能发现因为代码结构看起来完全合理。相比之下Kahn算法里每个判断条件都肉眼可见调试友好性高了一个档次。最后分享一个我在实际做题中养成的小习惯拿到拓扑排序题先不要急着写代码花30秒做三件事——确认边的方向、确认节点编号从0还是1开始、确认重边和自环是否可能出现。这三件事确认完BFS Kahn模板几乎不用改。遇到过太多同学在“输入的[a, b]到底谁指向谁”这种地方翻车代码写得再漂亮也白搭。拓扑排序本身不难难的是把问题干净地翻译成图。把这个翻译练熟了你会发现它只是BFS的又一次顺手应用后续想扩展成字典序最小、判断环、分层拓扑排序都是在模板上小幅修改而已。