免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

力扣207课程表:图论环检测的DFS与BFS解法

力扣207课程表:图论环检测的DFS与BFS解法 第一次在力扣刷到 207 题“课程表”的时候我第一反应是这不就是个考勤系统吗点进去才发现完全不是这么回事。这道题给了一堆课程和它们的先修关系让你判断能不能把所有课都修完。说白了就是检查课程依赖图里有没有环。题目本身不长但背后的图论思想非常扎实校招面试里出现的频率相当高我身边好几个拿到大厂 Offer 的朋友都在不同的轮次里碰到过它的原题或变形。力扣 207 之所以经典是因为它把“依赖关系合法性”这个问题抽象得非常干净课程之间有前置依赖如果 A 依赖 B、B 又依赖 A那这两门课就永远排不出来。这种循环依赖在项目任务排期、软件包依赖管理、编译构建顺序里同样会出现所以学会这题不只是应付面试更是理解图论建模的一次很好的练习。这篇文章我会从暴力解法入手把 DFS 三色标记和 BFS 入度删除两条路线都拆开讲清楚配合完整代码和测试用例尽量做到看完就能写、写完就能过。1. 项目概述先修课依赖背后的环检测问题1.1 一个面试必问的经典场景先把这个题目的场景还原一下。你有 numCourses 门课编号从 0 到 numCourses-1然后给了一个数组 prerequisites里面每一项是一个长度为 2 的数组比如 [1, 0] 表示想学课程 1必须先学课程 0。这个关系翻译成日常语言就是“课程 0 是课程 1 的先修课”。你可能会觉得这有什么难的排个序不就行了但真实世界里课程的依赖关系是乱的不是一条直线。比如计算机专业的培养方案你要学操作系统通常得先学计算机组成原理要学计算机组成原理又得先学数字逻辑。这些依赖串起来之后整个关系图会变得非常复杂。更麻烦的是如果课程 A 依赖 BB 又依赖 C结果 C 反过来依赖 A那这个培养方案就直接废了因为没有任何一门课可以先开始学。力扣 207 要你做的就是输入这些依赖关系输出一个布尔值能全部修完返回 true存在循环依赖返回 false。这里“能全部修完”并不要求你给出具体的课程顺序只要求判断可行性。这就是为什么很多人第一眼觉得简单但真写起来会发现还是有不少细节要注意的。1.2 为什么叫“暴力美学”我拿这个标题说事是因为解决这道题最好的两种方法本质上都是非常“暴力”的思路。DFS 三色标记法说白了就是把图遍历一遍每个节点走一遍遇到走了一半又绕回来的节点就说明有环这几乎是纯粹靠遍历状态来解决问题。BFS 入度删除法更直接一层层把“没有前置依赖”的节点削掉如果最后所有节点都能被削掉就说明图里没环。在算法训练营里很多同学一上来就想学 A*、Tarjan 这种看起来很高级的算法。但面对力扣 207 这种规模的问题暴力解法不仅够用而且思路清晰、实现简单、不容易出错。所谓暴力美学就是先把图完整地遍历一遍用最基础的状态转移把问题啃下来。能把暴力解做到又快又稳本身就是一种很强的能力这也是我为什么愿意花篇幅把这题从里到外拆干净的原因。2. 核心本质把课程表翻译成有向图2.1 图的建模谁是节点谁是边解任何图论题第一步永远是建模。力扣 207 的建模非常标准每一门课是一个节点先修关系是一条有向边。如果课程 b 是课程 a 的先修课那就画一条从 b 指向 a 的边表示先学 b、才有资格学 a。注意这个方向非常关键画反了后续两套算法都会出问题。我见过不少同学在这一点上翻车de了半天 bug 才发现是边建反了。建图的数据结构通常有两种选择邻接矩阵和邻接表。邻接矩阵用二维数组表示graph[i][j] true 表示节点 i 有一条指向节点 j 的边。这种结构查询任意两点之间是否有边非常快只要 O(1)但缺点是空间消耗是 O(n^2)一门课数量到几千几万的时候就扛不住了。邻接表用 vectorvector 表示graph[i] 里存的是从节点 i 出发能够到达的所有邻居节点空间上只存实际存在的边复杂度是 O(V E)。力扣 207 的输入规模通常在几百到几千之间邻接矩阵不一定爆内存但邻接表显然是更合理的选择。它既保留了遍历邻居的高效性又不会浪费空间是工业届处理稀疏图的主流做法。这个选择背后的理由恰恰是很多新手容易忽略的数据结构没有绝对的好坏要看数据长什么样、算法要做什么操作。2.2 暴力解法背后的两个核心判断有向图建模完成之后问题就变成了一个非常纯粹的问题这个有向图里有没有环。判断有环无环暴力解法归根结底是在反复做两个核心判断第一个判断是“节点有没有被完整处理过”。如果已经处理完了说明这个节点的所有下游都验证过安全了那再次遇到它的时候直接跳过就行不需要重新遍历。这是避免重复劳动的关键也是 DFS 里“剪枝”思想最简单的体现。第二个判断是“当前节点是否正在被处理”。如果遍历过程中又回到了一个还没处理完的节点说明存在一条路径从某个点出发又绕回了这个点那就是有环。这是整个暴力解法最核心的判据。你可以想象成一个修路工程队如果沿着正在修的马路往前走最后又绕回到同一个施工点那这条路线一定有问题。理解了这两个判断后面所有代码几乎就是顺着思路翻译出来的。这也是为什么我总说算法题最怕的不是写不出来而是没想清楚判断条件就急着打码。3. 核心细节解析DFS与BFS的双路线3.1 三色标记法从“访问中”抓环先讲 DFS 三色标记法这是我在面试里最喜欢写的方案因为它的逻辑非常直观代码量也少。三色指的是用三个状态标记每个节点0 表示未访问1 表示正在访问2 表示已经访问完毕。这个状态定义是整个算法的灵魂。DFS 递归遍历每个节点进入一个节点时先把状态从 0 改成 1然后依次递归它的所有下游邻居。如果递归到某个邻居时发现它的状态是 1说明这个邻居是当前正在访问链路上的节点也就是说我们绕了一圈又回到了自己身上直接返回 false。如果某个邻居的状态是 2说明它之前已经被完整验证过没有环直接跳过。当这个节点的所有邻居都递归完成且都没有问题把它的状态从 1 改成 2表示这棵子树检查完毕。很多初学者不理解为什么状态是 2 的节点可以直接跳过我在这里用实际的例子解释一下。假设有课程 0、1、2依赖关系是 0 - 1 - 2同时还有一个独立的课程 3它没有任何先修课。DFS 从 0 开始走验证完 0、1、2 这条链路没问题之后0 变成 2。之后遍历到 33 没有下游直接变成 2。整个过程没有任何问题。如果这时候再加一条边让 4 依赖 0遍历 4 的时候递归到 0发现 0 的状态是 2直接返回 true 就好了不需要把 0 的子树再走一遍。这个“剪枝”看起来不起眼但在节点多、边密度高的时候能省掉海量重复计算。这里有一个非常重要的注意点递归里边与边的方向不能搞错。我们建图的时候已经从先修课指向后修课了所以在 DFS 中遍历 graph[u] 的时候u 是“先修课”graph[u] 里全是“依赖 u 的课”。如果你遍历的是 graph[某门课] 但语义理解反了很容易得到错误的结果而且很难察觉。3.2 入度削减法BFS版的拓扑排序如果说 DFS 三色标记法是从“节点状态”出发的暴力美学那 BFS 入度删除法就是另一种风格的暴力。它的核心思想来自拓扑排序在一张有向无环图里一定存在至少一个入度为 0 的节点。所谓入度就是有多少条边指向这个节点。课程 0 是课程 1 的先修课那课程 1 的入度就加 1。算法的流程很朴实先统计每个节点的入度再把所有入度为 0 的节点丢进队列。接下来每次从队列里取出一个节点把它从图里“删除”。删除这个词说得有点吓人实际做的是把这个节点所有下游邻居的入度减 1如果某个邻居的入度因此变成 0说明它的所有先修课都已经处理完了可以入队。一直循环到队列为空最后看看总共处理了多少个节点。如果处理的节点数等于课程总数说明所有的课都能排进一个合法的顺序返回 true如果小于总数说明剩下的节点入度永远不可能是 0那就是有环。这个方法为什么也归到“暴力”一类因为整个过程完全不需要什么高深技巧就是一层层把“没有前置依赖”的节点剥掉。想象你手里有一叠积木每块积木上面写着依赖哪些积木你把所有不依赖任何积木的抽掉再抽掉那些依赖已经被抽掉的积木循环反复。如果最后还剩积木抽不动那说明这些积木互相依赖成了死结。BFS 入度法的另一个好处是它天然可以输出一个具体的课程学习顺序因为出队的顺序就是一个合法的拓扑序。虽然力扣 207 只要求返回布尔值不需要输出顺序但这个特性在后续做力扣 210 课程表 II 的时候会直接用到。学会 BFS 版本等于提前把下一道题也预习了一遍。我自己在刷题的时候通常会两题连刷因为代码层面的改动非常小。3.3 两种方案的横向对比两种方法都能在 O(V E) 的时间复杂度内解决问题。V 是课程数E 是先修关系的数量。这个复杂度很容易推导不管是递归还是队列操作每个节点最多被访问一次每条边最多被遍历一次所以总操作次数和 V E 是线性关系。空间复杂度上两种方法也都需要建邻接表都是 O(V E)再加上一个额外的标记数组或者入度数组开销是 O(V)。所以从大 O 的维度来看两者几乎没有差别。但在实际使用中DFS 三色标记法的递归写法在课程数量很大的时候可能会遇到递归深度过高的问题而 BFS 入度法因为用的是队列不存在这个问题。如果面试官要求你处理规模达到上万级别的图我会优先建议你写 BFS。从代码风格来看DFS 三色标记更适合理解“为什么有环能被检测出来”因为它直观地反映了从某个节点出发能否回到自身。BFS 入度法则更适合直接生产一个合法排列而且在变体题目里非常容易扩展。我对初学者的建议是两套都写熟但先理解 DFS因为它能帮你建立对递归状态管理的肌肉记忆。用一张简单表格总结对照。对比维度DFS 三色标记BFS 入度删除核心思想状态 1 表示访问中遇到访问中说明有环不断删除入度为 0 的节点处理数不够则有环时间复杂度O(V E)O(V E)空间复杂度O(V E)O(V E)额外存储状态数组 state入度数组 indegree、队列 queue是否易生成拓扑序稍复杂需要额外记录顺序出队顺序天然就是拓扑序大规模图的风险递归深度可能过大无递归更稳代码量相对简短略微长一点但逻辑简单适合的场景理解环检测原理、代码面试手写工程实现、变体题输出具体顺序4. 实操过程完整代码与测试用例4.1 C 实现DFS三色标记先把 C 的完整实现摆出来。这个类就是力扣要求的标准函数签名可以直接复制到编辑器里跑。#include vector #include functional using namespace std; class Solution { public: bool canFinish(int numCourses, vectorvectorint prerequisites) { // 1. 建图邻接表存储graph[b] 存放所有依赖 b 的课程 a vectorvectorint graph(numCourses); for (auto pre : prerequisites) { int a pre[0]; int b pre[1]; graph[b].push_back(a); } // 2. 状态数组0 未访问1 访问中2 访问完毕 vectorint state(numCourses, 0); // 3. 定义 DFS 函数使用 std::function 以便递归调用 functionbool(int) dfs [](int u) - bool { if (state[u] 1) { // 访问中说明遇到了环 return false; } if (state[u] 2) { // 已经验证过无环剪枝直接返回 return true; } // 标记为访问中 state[u] 1; // 递归所有下游节点依赖当前课程的课 for (int v : graph[u]) { if (!dfs(v)) { return false; } } // 所有下游都没问题标记为访问完毕 state[u] 2; return true; }; // 4. 对所有节点做 DFS防止出现不连通的情况 for (int i 0; i numCourses; i) { if (state[i] 0 !dfs(i)) { return false; } } return true; } };代码核心就是那个 lambda 递归函数。注意这里用 std::function 包装是必要的因为 C 的 lambda 默认不支持直接递归自调用。如果你用的是普通函数那就要把这个函数写成类的成员函数或者传入状态数组。我在实际写的时候更习惯把 state 和 graph 放到类成员里这样 lambda 可以写得干净一些不过力扣上最稳的写法还是上面这种“全部局部变量 std::function”。另一个容易踩的坑是建图的方向。我注释里写得很清楚graph[b] 存放的是所有依赖 b 的课程 a。也就是说b 是先修课a 是下游。DFS 的时候从 b 往 a 走走的是“依赖关系链”。如果方向反了你把 graph[a].push_back(b)那 DFS 变成从后修课往先修课走环检测的语义就乱了。4.2 Python 实现BFS入度法再给一套 Python 的 BFS 入度删除实现这也是我在工程场景里非常喜欢用的版本因为 Python 写队列和图都特别简洁。from typing import List from collections import deque class Solution: def canFinish(self, numCourses: int, prerequisites: List[List[int]]) - bool: # 1. 建图 统计入度 graph [[] for _ in range(numCourses)] indegree [0] * numCourses for a, b in prerequisites: # b 是先修课b - a graph[b].append(a) indegree[a] 1 # 2. 所有入度为 0 的课程入队 q deque([i for i in range(numCourses) if indegree[i] 0]) # 3. 逐层削掉入度为 0 的节点 processed 0 while q: u q.popleft() processed 1 for v in graph[u]: indegree[v] - 1 if indegree[v] 0: q.append(v) # 4. 处理数量等于课程总数说明无环 return processed numCourses这份代码我特意写得很“朴素”没有用花哨的优化技巧目的就是让逻辑看清楚。processed 变量取代了力扣题解里常见的 count在循环里每弹出一个节点就自增一次。最后比较 processed 和 numCourses如果相等说明每个节点都被处理过了不存在环。值得一提的小优化是入队那行列表推导式它一次性把所有初始入度为 0 的节点放进来比循环再加进去要简洁得多。Python 在处理这类图问题时代码效率确实高但要注意如果 numCourses 很大deque 里元素太多也不用担心因为每个节点只会入队一次。4.3 测试用例与运行思路光有代码没有测试说服力不够。我把我实际跑过的几个用例拿出来由浅入深看一遍。第一个用例是最简单的无环场景。假设 numCourses 2prerequisites [[1, 0]]。这个输入表示课程 1 依赖课程 0那么合理顺序是先学 0 再学 1所以答案是 true。DFS 跑起来后从 0 开始它有一个邻居 1递归到 11 没有邻居状态变 2回到 0状态变 2整个过程没有碰到状态为 1 的节点返回 true。第二个用例是有环的标准场景。numCourses 2prerequisites [[1, 0], [0, 1]]。课程 1 依赖 0课程 0 依赖 1。DFS 从 0 进去状态改成 1递归邻居 11 的状态改成 1然后发现 1 的邻居里有 0而 0 的状态是 1访问中于是直接返回 false。这就是一个非常典型的“绕回自己”的检测过程。第三个用例设计得更有迷惑性一些。numCourses 4prerequisites [[1, 0], [2, 1], [3, 2]]。这是一条长链0 - 1 - 2 - 3。它没有环答案是 true。但如果你在写 BFS 入度法的时候没有正确处理初始入度为 0 的节点比如不小心把入度初始化为 0 而不是累加那第一个入队的节点就不一定是 0 了整个削除顺序会乱但好在最终 processed 数量仍然是 4结果依然正确。这个用例其实暴露了一个更重要的问题光看结果 true/false 不一定能发现逻辑缺陷所以我建议你在本地写一个小脚本把这些用例逐行打印状态变化真正搞清楚每一步发生了什么。我基于上面三个测试用例跑出来的结果整理成一个小表格。用例编号输入期望结果DFS 运行要点BFS 运行要点12, [[1,0]]true无状态 1 冲突0 入队1 入队processed222, [[1,0],[0,1]]false递归到 0 时发现 state[0]1无人队后 processed134, [[1,0],[2,1],[3,2]]true长链无环依次变 2依次削除processed45. 常见问题与排查技巧实录5.1 递归爆栈输入上万门课怎么办力扣的测试数据里课程数量最多能到几千甚至上万。如果你用 DFS 递归写法而且在遍历一条很长的依赖链时深度过大可能会触发系统栈溢出。我在本地调试时就踩过一次输入规模到一万、依赖链长度到几千的时候程序直接崩溃。排查方法很简单先用一个带环的小用例确认逻辑没问题然后把测试数据放大如果崩溃大概率就是爆栈。解决方案大概有三种。第一种是把 DFS 改成显式栈用 vector 或者 stack 模拟系统递归调用这样就把调用栈从系统栈转移到了堆上容量大了很多。第二种是干脆换 BFS 入度法它天然没有递归深度问题这也是很多线上算法库更偏向 BFS 的原因。第三种是调整编译运行参数但力扣上没法自定义栈空间所以本质上还是在写代码的时候避免过深递归。我个人的建议是面试的时候如果题目没有明确要求 DFS优先写 BFS 入度法。不是因为 DFS 不好而是因为 BFS 从头到尾不会因为递归深度而翻车代码也足够简洁面试官挑不出毛病。5.2 图不一定连通孤立节点别漏掉很多初学者会犯一个经典错误从第一个节点开始做 DFS做完就以为万事大吉了。但图是不连通的。比如 numCourses 等于 5prerequisites 只有 [[1, 0]]那课程 2、3、4 根本没有出现在任何依赖关系里它们就是孤立节点。如果程序只从 0 开始 DFS那 2、3、4 的状态永远停留在 0它们也永远不会被检查但最终结果仍然是 return true因为孤立节点没有环是安全的。这个场景下结果凑巧没问题但换个思路你就知道漏洞了。如果图里有多个连通分量其中一个分量有环而你的 DFS 恰好从一个没有环的分量开始并且提前返回 true 了那就漏掉了有环分量。所以正确做法是遍历所有节点只要状态是 0 就做一次 DFS不能只从一个点开始。我在前面给出的两份代码里都用了这个循环这个细节看起来不起眼却是最容易丢分的点之一。5.3 边界输入空数组、自环、重复边力扣的测试用例很喜欢出一些边界情况千万别小看这些。prerequisites 是空数组的时候所有课程都是孤立节点。此时 BFS 入度法里所有课程入度都是 0全部入队processed 等于 numCourses返回 true。DFS 同理每个节点都没有邻居各自变 2。结果都是 true这个应该是符合直觉的没有先修关系当然是能全部修完的。自环的情况是 prerequisites 里有类似 [0, 0] 这样的输入即课程 0 依赖课程 0。这在现实中不可能但算法题会考。DFS 里从 0 进去后状态改成 1然后遍历邻居 0发现状态是 1直接返回 false。BFS 里 0 的入度会因为这条边加 1即使课程 0 初始入度不是 0它永远无法入队processed 缺一个数也返回 false。两套算法都能正确识别出自环。重复边的情况比较有迷惑性比如 [[1, 0], [1, 0]]。建图之后节点 0 的邻居里有两条指向 1 的边入度统计时 1 的入度加了两次。DFS 因为只关心状态重复边不影响结果还是能正确判断。BFS 因为入度重复累加削除的时候也要对应地减两次处理过程比不重复的情况慢一点但最终结果依然正确。这里真正要防范的是你自己在统计入度时少算了一条边那就会导致明明该入队的节点因为入度不为 0 而无法入队错误地报 false。5.4 常见错误速查表我把平时给别人答疑时碰到的错误整理成一个速查表按频率排序。错误类型具体表现根本原因解决办法建图方向反了输出和预期不符把 graph[a].push_back(b) 当成先修边明确 b 是先修课graph[b] 存下游只从一个节点 DFS多连通分量中的环没检测到忽略图不连通遍历全部节点状态为 0 就执行 DFS忘记把状态改成 2大量重复递归严重超时没有处理“访问完毕”状态递归结束后立即设置 state[u] 2BFS 入度统计遗漏部分节点永远无法入队重复边或方向搞错用打印的方式检查每个节点最终入度禁止递归爆栈数据量一大直接崩溃递归层数过深换显式栈或直接用 BFS 入度法队列里弹错元素processed 数量对不上混淆入队与出队时机每次出队再累加 processed6. 从207到实战迁移与后续进阶6.1 真实世界的依赖关系力扣 207 的价值在校招面试里比较容易感知但放到真实工程场景里它对应的问题也到处都是。我举三个我在工作中真实接触过的例子。第一个是软件构建工具里的依赖解析。你用 npm 或者 Maven 装包的时候每个包都有依赖的其他包这些依赖之间如果出现循环依赖构建工具必须能识别出来并且报错否则整个项目会陷入死循环。底层的检测逻辑就非常接近这题的 BFS 入度版本只不过输入规模更大而且每个包还会有版本号。第二个是前端框架里的组件渲染顺序。某些框架会要求组件按依赖顺序渲染A 组件依赖 B 组件的数据那就得先渲染 B。如果组件之间互相依赖框架会陷入无限渲染循环这时候必须有一个环检测机制来兜底。第三个是任务调度系统。后台任务经常有“任务 A 需要在任务 B 完成后执行”的约束把任务抽象成节点、依赖关系抽象成边最终要判断整个任务流能不能跑通。如果存在环形依赖调度器应该提前失败并给出清晰的错误提示而不是等到运行时卡死。这些场景都有一个共同点输入数据的规模可能很大但结构本质上就是有向图。你在这题里学到的“入度为 0 就能执行”的直觉放到这些系统里依然成立。所以我一直觉得算法学习的真正产出不是背代码而是建立这种“看到依赖关系就想建模成图”的条件反射。6.2 进阶路线210、拓扑排序变体与堆优化如果你已经能把力扣 207 的两种解法都流畅写出来下一步我建议按照这个路线继续刷。第一个是力扣 210 课程表 II。这道题要求返回具体的课程学习顺序其实就是把 BFS 入度法里出队的节点记录下来。代码改动极小但你能从“判断是否存在”进阶到“构造一个可行解”这是工程上更常见的需求。第二个是判断有向图是否有环的裸题比如力扣 802 找到最终的安全状态。这道题可以从反向图的角度重新理解三色标记法是一个非常不错的巩固练习。第三个是带权依赖或者按字典序输出的变体。如果题目要求输出字典序最小的拓扑序列那就把 BFS 的普通队列换成优先队列最小堆这样每次弹出的都是当前入度为 0 的最小节点。这个思路其实就是堆排序在拓扑排序里的自然延伸理解了它之后再遇到“字典序最小拓扑序”这类要求就不会慌了。我个人实际操作中的体会是这题别看难度标记是中等但它能延伸出的题目特别多。你花一个下午把 DFS 三色标记和 BFS 入度法吃透收益远不止这一道题。面试的时候如果能主动讲出“这道题和拓扑排序的关系、换一个数据结构还能输出顺序”会是一个非常加分的展示点。
返回列表