拓扑排序详解:从力扣 207 题看有向图环检测
文章目录
- 问题背景:课程表问题
- 问题建模与算法推导
- 问题分析
- 核心思想推导
- 方法一:DFS 实现拓扑排序
- 算法推导演进
- 数据结构选择
- DFS 代码实现与解析
- 正确性证明
- 方法二:BFS 实现拓扑排序(Kahn 算法)
- 算法推导演进
- 数据结构选择
- BFS 代码实现与解析
- 正确性证明
- 两种算法的比较
- 总结
拓扑排序(Topological Sorting)是图论中一种重要的排序算法,主要用于解决有向无环图(DAG)的节点排序问题。在实际应用中,它常被用于任务调度、课程安排等存在依赖关系的场景。本文将以力扣 207 题 “课程表” 为例,详细讲解拓扑排序的两种实现方法:BFS(广度优先搜索)和 DFS(深度优先搜索)。
问题背景:课程表问题
力扣 207 题描述如下:
现在你总共有 n 门课需要学习,记为 0 到 n-1。有些课程需要先修其他课程,例如,想要学习课程 0 ,你需要先学习课程 1 ,表示为 [0,1]。给定课程总数和一个先修课程的列表,判断是否可能完成所有课程的学习?
这个问题本质上是判断一个有向图是否存在环。如果存在环,则不可能完成所有课程;如果是有向无环图,则可以通过拓扑排序确定学习顺序。
问题建模与算法推导
问题分析
题目本质上是判断一个有向图是否存在环。我们可以将问题抽象为:
- 每门课程视为图中的一个节点(顶点)
- 先修关系[a, b]表示从 b 到 a 的一条有向边(必须先修 b 才能修 a)
- 问题转化为:判断该有向图是否为有向无环图(DAG)
核心思想推导
如果图中存在环,那么环上的节点之间形成了相互依赖,无法确定合理的学习顺序。例如,若存在课程 A→B→C→A 的环,则三门课程互相依赖,永远无法完成。
拓扑排序的核心思想是寻找一种线性排序,使得对于图中的任意有向边 (u, v),节点 u 都排在节点 v 之前。这种排序仅在有向无环图中存在。
我们可以通过逆向思维推导:
- 必须存在至少一个节点没有前驱(入度为 0),否则会形成环
- 移除这个节点及其所有出边,剩余图仍需满足相同性质
- 重复以上过程,直到所有节点都被处理(无环)或无法找到入度为 0 的节点(有环)
方法一:DFS 实现拓扑排序
算法推导演进
DFS 实现拓扑排序的思路源于对图的深度遍历特性:
- 状态标记:为每个节点标记三种状态
- 0:未访问
- 1:正在访问(处于当前递归调用栈中)
- 2:已访问(所有子节点都已处理)
- 环检测逻辑:
- 当访问节点 u 时,将其标记为正在访问(1)
- 递归访问 u 的所有邻接节点 v
- 若 v 处于正在访问状态(1),说明从 u 到 v 存在路径,且 v 到 u 也存在路径(因为 v 在当前递归栈中),即存在环
- 若 v 未访问(0),则继续递归访问
- 回溯时,将 u 标记为已访问(2)
- 拓扑序列构建:
- 按节点被标记为已访问(2)的顺序反向排列,即可得到拓扑序列
数据结构选择
根据推导,我们需要:
- 邻接表:存储图的结构
- 状态数组:记录每个节点的访问状态
- 递归栈:隐式存储当前访问路径(用于环检测)
DFS 代码实现与解析
class Solution {
private:vector<vector<int>> edges; // 邻接表vector<int> status; // 状态数组:0=未访问,1=正在访问,2=已访问bool hasCycle; // 是否存在环public:bool canFinish(int numCourses, vector<vector<int>>& prerequisites) {edges.resize(numCourses);status.resize(numCourses, 0); // 初始化所有节点为未访问hasCycle = false;// 构建邻接表for (auto &info : prerequisites) {edges[info[1]].push_back(info[0]);}// 对每个未访问的节点进行DFSfor (int i = 0; i < numCourses && !hasCycle; ++i) {if (status[i] == 0) {dfs(i);}}return !hasCycle;}void dfs(int u) {status[u] = 1; // 标记为正在访问// 遍历所有邻接节点for (int v : edges[u]) {if (status[v] == 0) {dfs(v);if (hasCycle) return;} else if (status[v] == 1) {// 发现正在访问的节点,存在环hasCycle = true;return;}}status[u] = 2; // 标记为已访问}
};
正确性证明
假设算法错误地判定一个有环图为无环图,则环上所有节点都会被标记为已访问(2)。但在环中,当访问某个节点 u 时,必然会遇到一个处于正在访问状态(1)的节点 v(环上的前驱节点),此时算法会检测到环,与假设矛盾。因此算法正确。
方法二:BFS 实现拓扑排序(Kahn 算法)
算法推导演进
Kahn 算法是基于 BFS 的拓扑排序经典实现,其推导过程如下:
- 初始状态:找到所有入度为 0 的节点,这些节点没有前驱依赖,可以立即处理
- 处理过程:
- 选择一个入度为 0 的节点 u,将其加入拓扑序列
- 对于 u 的每个邻接节点 v,由于 u 已处理,v 的依赖减少,因此 v 的入度减 1
- 若 v 的入度变为 0,说明 v 的所有前驱都已处理,v 可以加入处理队列
- 终止条件:
- 若所有节点都被处理(拓扑序列长度等于节点总数),则无环
- 若仍有节点未处理但已无入度为 0 的节点,则存在环
数据结构选择
根据上述推导,我们需要:
- 邻接表:存储图的结构,快速访问每个节点的后继节点
- 入度数组:记录每个节点当前的入度,便于判断是否可处理
- 队列:存储待处理的入度为 0 的节点,实现 BFS
BFS 代码实现与解析
class Solution {
private:vector<vector<int>> edges; // 邻接表:edges[u]存储u的所有后继节点vector<int> indeg; // 入度数组:indeg[v]表示v的前驱节点数量public:bool canFinish(int numCourses, vector<vector<int>>& prerequisites) {edges.resize(numCourses); // 初始化邻接表大小indeg.resize(numCourses); // 初始化入度数组大小// 构建邻接表和入度数组for (auto &info : prerequisites) {edges[info[1]].push_back(info[0]); // 前驱课程指向后继课程++indeg[info[0]]; // 增加后继课程的入度}// 将所有入度为0的节点加入队列queue<int> q;for (int i = 0; i < numCourses; ++i) {if (indeg[i] == 0) {q.push(i);}}int visited = 0; // 记录已访问的节点数while (!q.empty()) {++visited;int u = q.front();q.pop();// 遍历u的所有后继节点for (int v : edges[u]) {--indeg[v]; // 减少入度if (indeg[v] == 0) { // 若入度为0,加入队列q.push(v);}}}// 若所有节点都被访问,则无环return visited == numCourses;}
};
正确性证明
假设算法错误地判定一个有环图为无环图,则所有节点都会被加入拓扑序列。但环中的节点入度永远不会变为 0(每个节点都至少有一个前驱在环内),导致这些节点无法被处理,与假设矛盾。因此算法正确。
两种算法的比较
特性 | DFS 实现 | BFS 实现 |
---|---|---|
时间复杂度 | O(V + E) | O(V + E) |
空间复杂度 | O(V + E) | O(V + E) |
适用场景 | 检测环、求逆后序拓扑序 | 求任意拓扑序 |
实现特点 | 借助递归和状态数组 | 借助队列和入度数组 |
总结
拓扑排序算法的推导过程体现了从问题抽象到具体实现的完整思维链:
- 将实际问题(课程安排)抽象为图论模型(有向图)
- 基于图的性质推导出判断有向无环图的核心逻辑
- 根据逻辑思路选择合适的数据结构
- 实现算法并证明其正确性
BFS 方法(Kahn 算法)通过维护入度和队列,直观地模拟了依赖关系的逐步解除过程;DFS 方法则利用递归栈的特性,通过状态标记巧妙地检测环的存在。两种方法各有千秋,但本质上都体现了拓扑排序的核心思想 —— 在满足所有前驱依赖的前提下,逐步构建合法的节点序列。
掌握拓扑排序不仅能解决课程表问题,更能为处理各种依赖关系问题提供通用思路,是计算机科学中不可或缺的基础算法。