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

哈喽各位,我是前端小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)。

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

下期见!

相关推荐
长安er16 分钟前
LeetCode876/141/142/143 快慢指针应用:链表中间 / 环形 / 重排问题
数据结构·算法·leetcode·链表·双指针·环形链表
Aaron158821 分钟前
电子战侦察干扰技术在反无人机领域的技术浅析
算法·fpga开发·硬件架构·硬件工程·无人机·基带工程
zhglhy41 分钟前
Jaccard相似度算法原理及Java实现
java·开发语言·算法
workflower1 小时前
PostgreSQL 数据库的典型操作
数据结构·数据库·oracle·数据库开发·时序数据库
仰泳的熊猫1 小时前
1140 Look-and-say Sequence
数据结构·c++·算法·pat考试
handuoduo12341 小时前
SITAN中avp必要性分析
人工智能·算法·机器学习
zl_vslam1 小时前
SLAM中的非线性优-3D图优化之相对位姿Between Factor右扰动(八)
人工智能·算法·计算机视觉·3d
EXtreme351 小时前
栈与队列的“跨界”对话:如何用双队列完美模拟栈的LIFO特性?
c语言·数据结构·leetcode·双队列模拟栈·算法思维
电饭叔1 小时前
如何代码化,两点之间的距离
笔记·python·算法
TL滕1 小时前
从0开始学算法——第十三天(Rabin-Karp 算法练习)
笔记·学习·算法·哈希算法