课程表

你这个学期必须选修 numCourses 门课程,记为 0numCourses - 1

在选修某些课程之前需要一些先修课程。先修课程按数组 prerequisites 给出,其中 prerequisites[i] = [ai, bi],表示如果要学习课程 ai,则必须先学习课程 bi

例如,先修课程对 [0, 1] 表示:想要学习课程 0,你需要先完成课程 1

请你判断是否可能完成所有课程的学习?如果可以,返回 true;否则,返回 false

示例 1:

输入:numCourses = 2, prerequisites = [[1,0]]
输出:true
解释:总共有 2 门课程。学习课程 1 之前,你需要完成课程 0 。这是可能的。

示例 2:

输入:numCourses = 2, prerequisites = [[1,0],[0,1]]
输出:false
解释:总共有 2 门课程。学习课程 1 之前,你需要先完成课程 0 ;并且学习课程 0 之前,你还应先完成课程 1 。这是不可能的。

提示:

  • 1 <= numCourses <= 2000
  • 0 <= prerequisites.length <= 5000
  • prerequisites[i].length == 2
  • 0 <= ai, bi < numCourses
  • prerequisites[i] 中的所有课程对互不相同

解法一(拓扑排序 BFS):

class Solution {
public:
    bool canFinish(int numCourses, vector<vector<int>>& prerequisites) {
        vector<vector<int>> adj(numCourses);
        vector<int> indegree(numCourses, 0);

        for (auto& p : prerequisites)
        {
            int a = p[0];
            int b = p[1];
            adj[b].push_back(a);
            indegree[a]++;
        }

        queue<int> q;
        for (int i = 0; i < numCourses; ++i)
        {
            if (indegree[i] == 0)
            {
                q.push(i);
            }
        }

        int visited = 0;
        while (!q.empty())
        {
            int cur = q.front();
            q.pop();
            visited++;

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

        return visited == numCourses;
    }
};

解法二(DFS 三色标记):

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

        vector<int> state(numCourses, 0);

        for (int i = 0; i < numCourses; ++i)
        {
            if (state[i] == 0 && hasCycle(i, adj, state))
            {
                return false;
            }
        }

        return true;
    }

private:
    bool hasCycle(int cur, vector<vector<int>>& adj, vector<int>& state)
    {
        state[cur] = 1;

        for (int next : adj[cur])
        {
            if (state[next] == 1)
            {
                return true;
            }
            if (state[next] == 0 && hasCycle(next, adj, state))
            {
                return true;
            }
        }

        state[cur] = 2;
        return false;
    }
};

核心思想

课程之间存在「先修」关系,天然可以用有向图来建模。

把每门课程看成一个节点,把先修关系看成一条有向边:

如果学课程 a 之前必须先学 b,就有一条从 b 指向 a 的边 b -> a

那么「能否完成所有课程」这个问题,就转化成了:

这张有向图中是否存在环?

如果存在环,例如 A -> B -> A,说明学 A 要先学 B,学 B 又要先学 A,互相卡死,永远无法开始,也就无法完成所有课程。

如果不存在环(也就是有向无环图 DAG),那么一定存在一个合法的学习顺序,可以按拓扑序依次修完所有课程。

所以这题本质上就是「有向图判环」,有两种经典做法:拓扑排序(BFS)和三色标记(DFS)。

把问题建模成有向图

先明确边的方向。

题目中 prerequisites[i] = [ai, bi] 表示学 ai 要先学 bi

也就是「先修课 bi」指向「依赖它的课 ai」,方向是:

bi -> ai

对应到代码里:

adj[b].push_back(a);
indegree[a]++;

indegree[a] 表示课程 a 还有多少门先修课没有学。

入度为 0 的课程表示它没有任何先修课,可以直接学。

解法一:拓扑排序(BFS)

拓扑排序(Kahn 算法)的思路是「从没有先修课的课程开始,一门一门地学」。

1. 统计入度并建图

vector<vector<int>> adj(numCourses);
vector<int> indegree(numCourses, 0);

for (auto& p : prerequisites)
{
    int a = p[0];
    int b = p[1];
    adj[b].push_back(a);
    indegree[a]++;
}

2. 把所有入度为 0 的课程加入队列

queue<int> q;
for (int i = 0; i < numCourses; ++i)
{
    if (indegree[i] == 0)
    {
        q.push(i);
    }
}

这些课程不需要任何先修课,是「当前可以学」的课程。

3. 依次学习并更新后续课程的入度

int visited = 0;
while (!q.empty())
{
    int cur = q.front();
    q.pop();
    visited++;

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

每学完一门课程 cur,就把它指向的那些课程 next 的入度减 1

当某门课程的入度降到 0 时,说明它的先修课都学完了,可以加入队列继续学。

4. 判断是否学完了所有课程

return visited == numCourses;

如果最终学完的课程数等于课程总数,说明不存在环,返回 true

否则说明有一部分课程因为成环、入度永远不为 0,无法被学,返回 false

解法二:DFS 三色标记

三色标记用三种状态标记每个节点:

  • 0:未访问
  • 1:正在访问(当前 DFS 路径上)
  • 2:已访问完成(所有后继都处理完了)
vector<int> state(numCourses, 0);

从每个未访问的节点出发做 DFS:

for (int i = 0; i < numCourses; ++i)
{
    if (state[i] == 0 && hasCycle(i, adj, state))
    {
        return false;
    }
}

在 DFS 过程中:

bool hasCycle(int cur, vector<vector<int>>& adj, vector<int>& state)
{
    state[cur] = 1;

    for (int next : adj[cur])
    {
        if (state[next] == 1)
        {
            return true;
        }
        if (state[next] == 0 && hasCycle(next, adj, state))
        {
            return true;
        }
    }

    state[cur] = 2;
    return false;
}

关键判断是:在遍历 next 时,如果发现 next 的状态是 1(正在访问),说明从 next 出发又能走回当前路径上的节点,形成了一条回路,也就是有环。

如果 next2(已访问完成),说明它已经被确认无环,直接跳过即可,避免重复遍历。

边界情况

如果没有先修课程,即 prerequisites 为空:

  • 所有课程入度都是 0,拓扑排序能学完所有课程,返回 true
  • DFS 中每个节点都无后继,不会发现环,返回 true

如果只有一门课程,无论有没有先修约束,只要不存在自环就能完成。

如果课程对里出现自环,例如 [0, 0]

  • 建图后节点 0 有一条指向自己的边,入度恒为 1,拓扑排序无法学完,返回 false
  • DFS 中会走到状态为 1 的自己,判定有环,返回 false

正确性证明

我们证明:算法返回 true 当且仅当可以完成所有课程。

结论 1:能完成所有课程,当且仅当图中不存在环

如果图中存在环,环上每一门课程都依赖环上的另一门课程,没有哪门课能先开始,所以无法完成所有课程。

如果图中不存在环,也就是有向无环图,那么它一定存在至少一个入度为 0 的节点,可以按拓扑序依次修完所有课程。

因此「能否完成所有课程」等价于「图中是否有环」。

结论 2:拓扑排序能正确判断是否有环

拓扑排序每次取出入度为 0 的节点,并把它的出边指向的节点入度减 1

如果图是 DAG,每轮都能取出至少一个入度为 0 的节点,最终所有节点都会被访问,visited == numCourses

如果图中存在环,环上的节点入度永远大于 0,永远进不了队列,最终 visited < numCourses

因此通过比较 visitednumCourses 就能正确判断是否有环。

结论 3:三色标记能正确检测环

DFS 中,节点状态变化为 0 -> 1 -> 2

当遍历到状态为 1 的节点时,说明这条边指向了当前 DFS 递归栈上的某个祖先节点,必然形成环。

状态为 2 的节点已经被证明无环,不会误判。

因此三色标记能正确检测环。

得出结论

由结论 1 可知问题等价于判环。

由结论 2 和结论 3 可知,两种解法都能正确判环。

因此两个解法都能正确回答是否能完成所有课程。

举例理解

示例 1

以:

numCourses = 2, prerequisites = [[1,0]]

为例。

建图后:

0 -> 1

入度为:

indegree = [0, 1]

拓扑排序过程:

  • 课程 0 入度为 0,入队
  • 学完课程 0visited = 1,课程 1 的入度减为 0,入队
  • 学完课程 1visited = 2

visited == 2 == numCourses,返回 true

示例 2

以:

numCourses = 2, prerequisites = [[1,0],[0,1]]

为例。

建图后:

0 -> 1
1 -> 0

入度为:

indegree = [1, 1]

两门课程的入度都不为 0,队列一开始就是空的,visited 保持为 0

visited == 0 != 2,返回 false

复杂度分析

解法一

建图遍历所有先修关系,拓扑排序中每个节点和每条边都被处理一次。

  • 时间复杂度:O(n + m),其中 n = numCoursesm = prerequisites.length
  • 空间复杂度:O(n + m),邻接表占 O(n + m),入度数组占 O(n)

解法二

每个节点和每条边最多被访问一次。

  • 时间复杂度:O(n + m)
  • 空间复杂度:O(n + m),邻接表占 O(n + m),状态数组占 O(n)

其中 n 是课程数,m 是先修关系的数量。

两种解法复杂度相同,拓扑排序(BFS)更直观、更容易记忆,是推荐写法。