图论专题(十六):“依赖”的死结——用拓扑排序攻克「课程表」

哈喽各位,我是前端小L。

欢迎来到我们的图论专题第十六篇!我们告别了自由探索的"网格",来到了一所规则森严的"大学"。在这里,选课是有要求的:想修课程 A,必须先修课程 B。

这种依赖关系 (Dependency) ,在图论中被建模为有向边B -> A。 如果我们面临一堆课程和一堆依赖,最担心的是什么? 是**"死循环"**!比如:A 依赖 B,B 依赖 C,C 又依赖 A。这就形成了一个环,导致谁也无法开始。

今天的任务很简单:给定课程和依赖,判断我们能否 修完所有课程(即判断图中是否有环 )。这也是拓扑排序 (Topological Sort) 的核心应用之一。

力扣 207. 课程表

https://leetcode.cn/problems/course-schedule/

题目分析:

  • 输入 :课程总数 numCourses,先修条件数组 prerequisites[a, b] 表示修 a 必须先修 b,即 b -> a)。

  • 目标:判断是否可能完成所有课程。

  • 模型转化

    • 课程 = 节点

    • 先修要求 = 有向边

    • 能否完成 = 有向图中是否存在环 (Cycle)

    • 如果有环,返回 false;如果是 DAG (有向无环图) ,返回 true


解法一:BFS ------ Kahn 算法 (入度表法)

这是最直观、最符合人类思维的解法。想象一下,如果让你去选课,你会先选哪门? 当然是选那些"没有先修课"的课程!

这就引入了拓扑排序中的核心概念------入度 (In-degree)

  • 入度:指向该节点的边的数量(即这门课有多少门先修课)。

算法流程(剥洋葱法):

  1. 建图 & 统计入度

    • 创建邻接表 adj

    • 创建数组 indegreeindegree[i] 表示课程 i 的入度。

    • 遍历 [a, b]:添加边 b -> a,并且 indegree[a]++

  2. 寻找起点

    • 将所有 入度为 0 的课程(可以直接修的课),放入队列 q
  3. BFS 拆解

    • count = 0 (记录已修课程数)。

    • while (!q.empty()):

      • 拿出一门课 curr修完它 (count++)。

      • 核心操作curr 修完了,那么它指向的所有后续课程 next,它们的"前置障碍"就少了一个!

      • 遍历 adj[curr] 中的所有 next

        • indegree[next]--

        • 检查 :如果 indegree[next] 变成了 0,说明 next 的所有先修课都搞定了!它变成了新的"可修课程",入队

  4. 最终审判

    • 如果 count == numCourses,说明所有课都修完了(图中无环)。

    • 否则(队列空了但还有课没修),说明剩下的课这就构成了环,互相依赖,谁也入不了队。返回 false

代码实现 (Kahn's Algorithm):

C++

复制代码
#include <vector>
#include <queue>

using namespace std;

class Solution {
public:
    bool canFinish(int numCourses, vector<vector<int>>& prerequisites) {
        // 1. 建图 + 统计入度
        vector<vector<int>> adj(numCourses);
        vector<int> indegree(numCourses, 0);

        for (const auto& relation : prerequisites) {
            int course = relation[0];
            int prereq = relation[1];
            // 边:prereq -> course
            adj[prereq].push_back(course);
            indegree[course]++;
        }

        // 2. 将所有入度为 0 的节点入队
        queue<int> q;
        for (int i = 0; i < numCourses; ++i) {
            if (indegree[i] == 0) {
                q.push(i);
            }
        }

        // 3. BFS 拆解
        int finishedCount = 0;
        while (!q.empty()) {
            int curr = q.front();
            q.pop();
            finishedCount++;

            for (int next : adj[curr]) {
                indegree[next]--;
                if (indegree[next] == 0) {
                    q.push(next);
                }
            }
        }

        // 4. 判断是否所有课程都完成了
        return finishedCount == numCourses;
    }
};

解法二:DFS ------ 三色标记法

DFS 也可以检测环,但普通的 visited (bool) 数组不够用。为什么? 因为我们需要区分:

  1. 未访问的节点。

  2. 当前递归路径上正在访问的节点(如果遇到这种,说明有环!)。

  3. 已经访问过且确认安全的节点(如果遇到这种,不需要重复搜,直接跳过)。

我们引入 "三色标记法"

  • 0 (White):未访问。

  • 1 (Gray):正在访问(在当前的递归栈中)。

  • 2 (Black):已完成(及其所有子孙节点都已检查无环)。

DFS 逻辑: 对于每个节点 u

  • 如果是 1发现环了! 返回 false

  • 如果是 2:安全的,返回 true

  • 如果是 0

    • 标记为 1(开始访问)。

    • 递归访问所有邻居。如果任何邻居返回 false(有环),我们也返回 false

    • 标记为 2(访问结束,安全)。

    • 返回 true

代码实现 (DFS):

C++

复制代码
#include <vector>

using namespace std;

class Solution_DFS {
private:
    // 0: unvisited, 1: visiting, 2: visited
    bool hasCycle(int u, vector<vector<int>>& adj, vector<int>& state) {
        if (state[u] == 1) return true;  // 遇到正在访问的节点 -> 有环
        if (state[u] == 2) return false; // 遇到已完成的节点 -> 安全

        state[u] = 1; // 标记为正在访问
        for (int v : adj[u]) {
            if (hasCycle(v, adj, state)) {
                return true;
            }
        }
        state[u] = 2; // 标记为已完成
        return false;
    }

public:
    bool canFinish(int numCourses, vector<vector<int>>& prerequisites) {
        vector<vector<int>> adj(numCourses);
        for (const auto& relation : prerequisites) {
            adj[relation[1]].push_back(relation[0]);
        }

        vector<int> state(numCourses, 0);

        // 因为图可能不是连通的,需要对每个节点尝试启动 DFS
        for (int i = 0; i < numCourses; ++i) {
            if (state[i] == 0) {
                if (hasCycle(i, adj, state)) {
                    return false; // 只要发现一个环,就失败
                }
            }
        }

        return true;
    }
};

深度复杂度分析

  • V (Vertices):课程数。

  • E (Edges):先修条件的数量。

  • 时间复杂度 O(V + E)

    • BFS (Kahn):建图 O(E),每个节点入队出队一次 O(V),每条边被遍历一次 O(E)。

    • DFS:每个节点最多被访问常数次(0->1->2),每条边也是。

  • 空间复杂度 O(V + E)

    • 邻接表存储整张图。

总结:有向图的"体检"

今天这道题,是我们对有向图进行的一次"全身检查"。我们学会了两种检测"死循环"(环)的黄金标准:

  1. Kahn 算法 (BFS) :基于入度。不断移除入度为0的点,看最后能不能删光。逻辑顺畅,适合需要输出排序结果的场景。

  2. 三色 DFS :基于状态。利用递归栈捕捉"回边"(Back Edge)。

这两种方法是图论中处理依赖关系的基石。下一题,我们将不仅要判断"能否"修完,还要真正给出一张可行的课表

下期见!

相关推荐
前端小L1 小时前
图论专题(十三):“边界”的救赎——逆向思维解救「被围绕的区域」
数据结构·算法·深度优先·图论
风筝在晴天搁浅1 小时前
代码随想录 738.单调递增的数字
数据结构·算法
Miraitowa_cheems1 小时前
LeetCode算法日记 - Day 108: 01背包
数据结构·算法·leetcode·深度优先·动态规划
大千AI助手2 小时前
平衡二叉树:机器学习中高效数据组织的基石
数据结构·人工智能·机器学习·二叉树·大模型·平衡二叉树·大千ai助手
九年义务漏网鲨鱼2 小时前
【多模态大模型面经】现代大模型架构(一): 组注意力机制(GQA)和 RMSNorm
人工智能·深度学习·算法·架构·大模型·强化学习
闲人编程2 小时前
CPython与PyPy性能对比:不同解释器的优劣分析
python·算法·编译器·jit·cpython·codecapsule
杜子不疼.2 小时前
【C++】深入解析AVL树:平衡搜索树的核心概念与实现
android·c++·算法
小武~2 小时前
Leetcode 每日一题C 语言版 -- 88 merge sorted array
c语言·算法·leetcode
e***U8202 小时前
算法设计模式
算法·设计模式