
看到“BFS解决拓扑排序问题”这个专题名估计不少人和我最初的反应一样拓扑排序不是图论里挺抽象的概念吗怎么还要拉上BFS来做其实这两个东西天生就是一对。我当年刷题时被“拓扑排序”四个字唬住过直到把课程表那一类依赖题吃透才意识到所谓BFS拓扑排序说白了就是“每次从没有前置依赖的点下手一层层把顺序剥出来”。本专题围绕BFS解拓扑排序的核心思路展开从排课表这类经典场景讲起拆解Kahn算法的完整套路再通过四个高频题型带你把模板用到熟适合正在刷图论专项、准备算法面试或者刚学完二叉树想进阶图的同学。1. 拓扑排序到底在排什么概念、场景与BFS直觉1.1 课程表场景什么是有向无环图和拓扑序拓扑排序的前提是“有向无环图”也就是DAG。拿课程表场景举例假设你要学五门课有些课必须先修前置课程。比如学《数据库》之前必须学完《数据结构》学《数据结构》之前又必须学完《程序设计基础》。这些依赖关系画成图就是一条条有向边从“先修的课”指向“后续的课”。把这些依赖全部画出来之后一张合乎常理的依赖图必须是无环的。为什么因为正常的选课逻辑里不可能存在一门课直接或间接依赖它自己也不可能两门课互相作为前置——真要那样课程就永远排不出来。换句话说合法的依赖图一定是有向无环图简称DAG。拓扑排序做的事情就是给定一张有向图找出一个线性的顶点序列使得图中每一条边u→vu都出现在v的前面。注意这个序列不一定唯一。课程A和课程B互不依赖先排A再排B合法先排B再排A也合法。DAG一定存在至少一个拓扑序而有环图一定不存在拓扑序。所以拓扑排序天然兼任两件事排顺序以及判环。生活里这类场景到处都是。项目构建工具需要按依赖顺序编译模块调度系统要等到小任务完成才能启动依赖它的大任务程序分析工具会先处理被依赖的类再处理依赖它的类。面试题里则通常包装成“课程表”“编译顺序”“完成所有任务需要的最少学期”这类壳子。剥掉壳子之后核心都是同一张DAG。1.2 Kahn算法解读为什么BFS能一层层“剥”出顺序BFS做拓扑排序的学名叫Kahn算法思路极其朴素。先把所有没有依赖、顶着“入度为0”头衔的顶点收进队列然后弹出队首顶点把它放进答案序列同时假装这门课已经学完所有依赖它的后续课程的“前置数量”都减1一旦某门课的前置数量减到0说明它的所有前置都搞定了就可以放心入队去学它。如此循环直到队列变空。如果最终答案序列的长度等于顶点数说明所有顶点都被排进了序列图里没有环。如果答案长度不足说明有些顶点永远等不到它的前置归零图里必定存在环。这里“入度”是整个算法最核心的概念。一个顶点的入度就是指向它的边的数量反映“做这件事之前还有多少件事没做完”。入度为0的点就是当前没有任何前置依赖、可以立刻处理的任务。BFS的层级感在这里特别自然第一轮入队的点是第一批能直接做的事处理完第一层可能又解锁新一批入度为0的点那就是第二批能做的事。拓扑排序的过程就像一层层剥洋葱或者说像迭代地删除图中没有依赖的叶子节点。为什么这个算法正确因为每次入队的点它的所有前驱都已经进入答案序列了。入度减到0意味着“该点的所有依赖均已完成”此时把它加入序列不会破坏任何边的前后关系。刷题阶段不用纠结严格证明理解到“入度归零就是前置清零前置清零就可以处理”这个程度就足够用了。1.3 BFS与DFS两条路为什么先掌握BFS拓扑排序也有DFS版本对每个未访问节点做DFS用后序压栈最终栈的逆序就是拓扑序判定有环则依赖DFS时发现“路径上重复访问”的标记。但DFS版本相比BFS有几个明显的短板。第一代码细节多需要维护三种节点状态也就是未访问、访问中、已访问还要区分“当前递归路径”和“全局已访问”两套信息写错一点就容易得到莫名的WA。第二后序压栈再逆序的思路对新手不友好很多人会在一段时间里始终绕不过弯。第三出题人经常追加“字典序最小”“输出一种合法顺序”这类要求DFS版本改造起来没有BFS顺手。而BFS版只需要一个数组记录入度、一个队列做扩散注意力全部放在“入度归零”这一个规则上。遇到要求字典序最小的题把普通队列换成优先队列就行遇到要求分层计数的题记录每层大小即可。刷题是为了快速建立解题直觉初始阶段优先把BFS模板吃透性价比最高。2. BFS拓扑排序五步模板从建图到验环2.1 核心五步建图、统计入度、零度入队、剥离、判环第一步是建图。拿到数据先想清楚“谁指向谁”。在课程表这类题里数据给的是二元组[a, b]含义通常是要学a必须先学b那么应该在原图里连一条b→a的边。建图最常用的是邻接表adj[u]里存放所有被u指向的顶点v表示“u完成后v才能继续”。用vectorvector 就能解决绝大多数题目遇到字符节点就用map或set辅助。第二步是统计入度。遍历所有边每当有u→v就让indegree[v]自增表示v多了一个前置依赖。第三步是零度入队。把所有入度为0的顶点全部放入队列。这一步最容易漏的是孤立点——一个顶点既没有前置也没有后继它入度是0同样需要入队。第四步是剥离。队列弹出顶点u把它计入答案然后遍历u的所有后继v让indegree[v]减1一旦减到0就入队。这个“减到0再入队”的动作保证了每个顶点只会在所有前置完成后才被处理。第五步是判环。剥离完成后如果答案长度等于顶点总数n说明拓扑成功否则图里有环。常见题目要么返回布尔值判环要么返回答案序列用空序列表示有环。2.2 可以直接套用的C模板代码// 通用模板n个顶点有向边列表edges每条边u-v #include vector #include queue using namespace std; vectorint topologicalSort(int n, vectorvectorint edges) { vectorvectorint adj(n); vectorint indegree(n, 0); // 建图 统计入度 for (auto e : edges) { int u e[0], v e[1]; adj[u].push_back(v); indegree[v]; } queueint q; // 所有入度为0的顶点入队 for (int i 0; i n; i) { if (indegree[i] 0) { q.push(i); } } vectorint topo; while (!q.empty()) { int u q.front(); q.pop(); topo.push_back(u); for (int v : adj[u]) { indegree[v]--; if (indegree[v] 0) { q.push(v); } } } // 判环长度不足即有环 if (topo.size() ! n) return {}; return topo; }这个模板里唯一需要根据题目微调的是“边的方向”。我见过太多次因为方向理解出错导致的返工。教你一个验证方法找个链式样例比如3门课课1依赖课0课2依赖课1。数据给成[[1,0],[2,1]]如果你建图的边是0→1、1→2拓扑序0,1,2结果正确。只要方向反了要么得到有环的假象要么输出完全乱序。每次动手写代码前先手动推一条链确认方向省下的调试时间远大于那几秒钟。2.3 复杂度分析与两个省时优化复杂度非常稳定每条边在统计入度时被扫一次在BFS弹出阶段又被后继遍历扫一次每个顶点进出队各一次所以时间是O(VE)。空间用邻接表存储是O(VE)额外的队列和入度数组是O(V)。这个复杂度对于绝大多数拓扑题都足够友好通常不会成为瓶颈。两个优化点值得说。第一如果题目允许重复边vector邻接表会冗余存储。虽然正确性不受影响但内存和遍历时间会浪费。遇到稠密图或边数很大的题可以在建边时用set去重或者先对边排序再决定要不要重复建图。第二如果题目要求“字典序最小的拓扑序列”把queue改成priority_queue小根堆每次弹出编号最小的入度零点即可。这个改动经常是面试官的追问点能当场说出来会显得基础很扎实。3. 四个经典拓扑题拆解从判环到输出合法序列3.1 课程表只判断能不能学完在线评测平台里有一道标志性的入门题“课程表”输入n门课和一个前置课程列表问能否学完所有课程。本质就是判断给定有向图是否存在环。把上面的模板直接搬过来BFS后看topo长度是否等于nbool canFinish(int numCourses, vectorvectorint prerequisites) { vectorvectorint adj(numCourses); vectorint indegree(numCourses, 0); // prerequisites里[ai, bi]表示学ai必须先学bi所以边 bi - ai for (auto p : prerequisites) { int u p[1], v p[0]; adj[u].push_back(v); indegree[v]; } queueint q; for (int i 0; i numCourses; i) { if (indegree[i] 0) q.push(i); } int cnt 0; while (!q.empty()) { int u q.front(); q.pop(); cnt; for (int v : adj[u]) { if (--indegree[v] 0) q.push(v); } } return cnt numCourses; }不需要完整序列时用cnt计数即可省掉存放拓扑序的vector。这道题也可以写DFS三色标记判环但BFS版本在环检测上更直观拓扑能完整跑完就是无环跑不完就是有环没有第二种情况。判题平台上的测试数据往往比较大BFS的迭代写法也不容易爆栈这也是个隐性优势。3.2 课程表II把合法顺序打出来进阶版要求输出一种合法课程顺序学名“课程表II”。整体框架和判环版本一模一样唯一区别是BFS过程中把弹出来的顶点记进结果数组。注意结果数组长度可能等于n也可能小于n。等于n就直接返回它小于n说明有环按题目要求返回空数组。vectorint findOrder(int numCourses, vectorvectorint prerequisites) { vectorvectorint adj(numCourses); vectorint indegree(numCourses, 0); for (auto p : prerequisites) { int u p[1], v p[0]; adj[u].push_back(v); indegree[v]; } queueint q; for (int i 0; i numCourses; i) { if (indegree[i] 0) q.push(i); } vectorint topo; while (!q.empty()) { int u q.front(); q.pop(); topo.push_back(u); for (int v : adj[u]) { if (--indegree[v] 0) q.push(v); } } if (topo.size() ! numCourses) return {}; return topo; }这道题有一个容易被忽略的点拓扑序不唯一判题时只要你的序列合法就行。所以千万别在返回前自作聪明地对结果做排序或重排BFS自然给出的顺序本来就是合法的。唯一需要变通的情况是如果题目附加了“编号小的课程优先输出”那就用优先队列替代queue代码改动集中在定义类型上。3.3 外星词典从相邻单词比较建图“外星词典”是一道很有代表性的字符串拓扑题给出一本外星语字典中按字典序排好的单词列表要求推断出外星字母的相对顺序。它比课程表难在“图怎么建”需要自己挖掘而图一旦建出来后续依然是标准BFS处理。关键观察是相邻两个单词既然按字典序排列那么从左往右找到它们第一个不同的字符a和b一定有a排在b前面也就是存在一条从a指向b的边。为什么只看相邻单词因为字典序的定义本质就是逐个比较相邻词相邻词之间的信息足以拼出所有顺序约束跨词比较往往冗余甚至可能引入矛盾。实现时三个细节别踩。第一只需要处理实际出现过的字母要先建一个字符集合存下所有出现字母。第二比较两个单词时如果较短单词是较长单词的前缀比如abc和ab按理说abc应当排在ab之后但实际顺序却是abc在前这说明单词表自相矛盾直接返回空串。第三重复边要用set去重虽然前面说过重复边不破坏正确性但用set会让代码语义更干净。string alienOrder(vectorstring words) { unordered_setchar all; for (auto w : words) { for (char c : w) all.insert(c); } unordered_mapchar, unordered_setchar adj; unordered_mapchar, int indegree; for (char c : all) indegree[c] 0; for (int i 0; i 1 words.size(); i) { string a words[i]; string b words[i 1]; int n min(a.size(), b.size()); int p 0; while (p n a[p] b[p]) p; if (p n) { if (a.size() b.size()) return ; continue; } char u a[p], v b[p]; if (!adj[u].count(v)) { adj[u].insert(v); indegree[v]; } } queuechar q; for (auto it : indegree) { if (it.second 0) q.push(it.first); } string res; while (!q.empty()) { char u q.front(); q.pop(); res.push_back(u); for (char v : adj[u]) { if (--indegree[v] 0) q.push(v); } } return res.size() all.size() ? res : ; }最后那句res.size() all.size()是整道题的判环出口。推断出的顺序长度一旦比出现的字母总数少就说明约束之间冲突了存在环。不少第一次写这题的人都栽在“没考虑前缀矛盾”或“忘了只统计出现过的字母”上这两点值得在动笔前先用小样例自测一遍。3.4 矩阵最长递增路径把拓扑BFS当扩散层数用如果以为拓扑BFS只能处理“依赖关系边”那就太小看它了。经典题“矩阵中的最长递增路径”可以换成一种很漂亮的拓扑视角。给定一个m行n列的矩阵从一个格子出发只能往上下左右移动到值更大的格子问最长路径长度。把每个格子当成图节点从值小的格子指向值大的格子连边。这时候入度的含义就变成了“周围比当前格子值小的格子数量”。入度为0的格子就是局部最小值点它们是所有递增路径的起点。接下来做分层BFS先处理所有入度为0的格子这是第1层处理完后哪些格子入度变成0它们就是第2层以此类推总共扩散了几层最长递增路径就是几。因为每一层格子的值都严格比上一层大路径必然是从低层走向高层的。int longestIncreasingPath(vectorvectorint matrix) { int m matrix.size(), n matrix[0].size(); vectorvectorint indegree(m, vectorint(n, 0)); int dirs[4][2] {{1,0},{-1,0},{0,1},{0,-1}}; // 建图周围比当前格子小的数量就是当前格子的入度 for (int i 0; i m; i) { for (int j 0; j n; j) { for (auto d : dirs) { int ni i d[0], nj j d[1]; if (ni 0 || ni m || nj 0 || nj n) continue; if (matrix[ni][nj] matrix[i][j]) { indegree[i][j]; } } } } queuepairint, int q; for (int i 0; i m; i) { for (int j 0; j n; j) { if (indegree[i][j] 0) q.push({i, j}); } } int level 0; while (!q.empty()) { level; int sz q.size(); while (sz--) { int x q.front().first; int y q.front().second; q.pop(); for (auto d : dirs) { int nx x d[0], ny y d[1]; if (nx 0 || nx m || ny 0 || ny n) continue; if (matrix[nx][ny] matrix[x][y]) { indegree[nx][ny]--; if (indegree[nx][ny] 0) { q.push({nx, ny}); } } } } } return level; }这里的分层BFS和普通拓扑别混淆。普通拓扑用一个不区分层级的队列就够了而这题要统计“路径走了几步”必须记录当前层大小sz一次性处理完一整层level才能准确累加。最简单的判断标准题目问“长度”“层数”“需要几轮”就用分层BFS题目只问“顺序”“能否完成”就用普通BFS。这个标准可以套用到很多变式题上。4. BFS拓扑排序实战避坑常见问题与排查技巧4.1 为什么返回结果长度不足就一定成环有同学会问只用topo.size() ! n就把结果判给“有环”会不会漏掉其他情况其实拓扑排序结束时结果长度不足只有一种原因有顶点永远等不到入度归零。而入度永远不归零这件事在DAG里不可能发生。任何DAG都一定存在至少一个入度为0的顶点。否则从任意顶点出发沿着反向边一直走由于顶点数有限必然走回某个已经访问过的顶点形成环。拓扑BFS不断消去入度为0的点如果图无环这个过程就会一直持续到所有点被消去。所以一旦BFS提前结束剩下的顶点入度全都大于0且它们之间互相牵制构成了环。用这个逻辑去理解判环比死记“长度不够就是环”要稳得多。4.2 重复边会把入度算爆吗单独看统计阶段同一对u→v出现两次indegree[v]确实加了两次感觉v的前置变多了。但别忘了邻接表里也存了两次u→v当u被弹出后v的入度也会被减两次。一个加两次一个减两次逻辑抵消最终v依然会等它真正的依赖全部完成之后才入队拓扑序不会错。所以用vector邻接表处理重复边正确性并不依赖去重。但有个前提建图用的结构要和统计入度的方式保持一致。如果建图用set存储但统计入度时不管重逢不计重复边只要set最终保存了边入度虚高的问题就会冒出来。最稳的约定是用什么方式保存边就用什么方式统计入度。日常刷题时重复边一般不影响结果只有发现重复边带来超时或空间浪费时才考虑用set去重。4.3 入度数组的初始化与更新时机入度数组最容易犯的低级错误有三类。第一类是漏初始化。数组大小给错访问越界这类错误在在线评测里最常见也最好修。第二类是忘了把所有入度为0的点入队尤其是孤立点。比如课程表II里n门课中有一门无依赖也无后继它的入度是0如果不入队结果长度少1直接误判成环。第三类是更新时机混乱。入度必须在遍历邻接表时同步递减不能在统计阶段提前处理。还有人在代码里把“入度减到0就入队”误写成“入度为0就入队”少一次自减同一个顶点被反复入队多次答案里出现重复顶点。这种问题非常隐蔽建议每次写完BFS循环之后自查一遍计数器确保cnt或者topo里没有重复值。4.4 建图选型邻接表还是邻接矩阵大多数课程和依赖题顶点数量从几百到几千边数呈稀疏分布用邻接表最合适。邻接矩阵需要O(V²)空间顶点一多内存立刻捉襟见肘。但顶点数量很小的时候邻接矩阵反而更清晰。比如外星词典里字母最多26个矩阵只有26×26判断一条边是否存在是O(1)代码写起来也更简洁。选型口诀是顶点多边少的稀疏图用邻接表顶点少且需要频繁查询边是否存在时用邻接矩阵。另外有些题目节点不是整数而是字符或字符串这时可以用map或unordered_map管理邻接表整体逻辑完全不变只是哈希表开销略高。写这类代码时要注意别在循环里反复查找同一个键先把引用取出来再遍历邻接列表。最后说一个我自己的做法。每次拿到拓扑相关的题我不会急着写BFS而是先在草稿纸上画清楚三件事谁是点边的方向是什么入度怎么定义。画清楚这三样大部分题其实已经把图扣出来了剩下的就是套模板。尤其是“边的方向”这是拓扑题最容易错、也最隐蔽的错误来源手动推一条三个节点的链条能帮你省下大量测试时间。把这四道例题都能独立手写出来之后再遇到编译顺序、任务依赖、项目排期这些变式基本就是换个外壳的事。拓扑排序没有想象中难难的是建图那一步是否想清楚了。