题目描述

:::fold-green 相关原题

✅ 207. 课程表

LeetCode 原题中 [a,b] 表示 b → a,能完成全部课程时返回 true;本文中 [a,b] 表示 a → b,存在环时返回 true。

:::

给定 n 个任务以及有向依赖数组 dependencies,请判断这些依赖关系中是否存在环。

任务编号为 0 到 n - 1,每条 [a,b] 表示有向边 a → b:任务 b 必须等待任务 a 完成后才能执行。

存在循环依赖时返回 true,否则返回 false。任务之间可以互不相连,没有出现在依赖数组中的任务也属于这 n 个任务。

示例 1:

输入:n = 3, dependencies = [[0,1],[1,2],[2,0]]
输出:true
解释:三个任务形成 0 → 1 → 2 → 0 的循环。

示例 2:

输入:n = 3, dependencies = [[0,1],[1,2]]
输出:false

提示:

  • n >= 0,所有边的端点都属于合法任务编号。
  • 自己依赖自己的任务也会形成环。
  • 本题返回“是否有环”,与返回“是否能完成所有任务”的接口含义相反。

题意分析

判断一组有向依赖关系中是否存在循环:沿依赖边出发,经过若干条边又回到原节点,就形成了环。有环时,相关任务之间会相互等待,无法按依赖顺序全部完成。

下文沿用现有 hasCycle(n, dependencies) 接口:节点编号为 0..n - 1,每条 [a, b] 按 a → b 建图,表示处理 b 之前需要先处理 a。返回 true 表示有环,不是表示所有任务都能完成;未出现在任何边中的节点也属于需要统计的节点。

解法:拓扑排序统计可处理节点数

核心思路

[!blue]

入度表示一个节点还有多少条尚未解除的前置依赖。入度为 0 的节点当前没有等待对象,可以先处理;处理它后,相当于删除它发出的所有边,让每个后继的入度减少相应次数。新出现的零入度节点也就可以继续处理。

这就是拓扑排序的 Kahn 算法。用队列保存已经可以处理的节点,用 visited 记录实际处理数量。删除过程始终维护“剩余图中的入度”,所以一个节点只有在全部前置都被删除后才会进入队列。

如果原图有环,环上每个节点至少还依赖前一个环内节点,在任何一个环节点先被处理之前,这条依赖都无法解除。因此环不可能被上述过程全部删除。

反过来,如果队列已经为空却还有节点,剩余每个节点的入度都大于 0。从任意剩余节点不断沿入边寻找前驱,因为节点数有限,最终必然重复经过某个节点,构成环。因此处理数等于 n 时无环,小于 n 时有环,两种情况覆盖全部可能。

图可能包含互不相连的部分,所以初始化时必须扫描全部节点,把所有零入度节点都入队。存在一个可处理的部分,并不能说明其他部分也没有环。

解题步骤

  1. 为每个节点建立邻接表,记录它指向哪些后继;每加入一条 a → b,同步令 indegree[b]++。
  2. 扫描所有节点,将入度为 0 的节点入队,包括孤立节点。
  3. 每次出队一个节点,将处理数加一,再遍历它的所有出边,把对应后继的入度减一。
  4. 后继入度恰好降为 0 时,将它加入队列。
  5. 队列耗尽后返回 visited != n,表示是否仍有无法消除的依赖环。

代码实现

class Solution {
    public boolean hasCycle(int n, int[][] dependencies) {
        List<Integer>[] graph = new ArrayList[n];

        for (int i = 0; i < n; i++) {
            graph[i] = new ArrayList<>();
        }

        int[] indegree = new int[n];

        for (int[] edge : dependencies) {
            graph[edge[0]].add(edge[1]);
            indegree[edge[1]]++;
        }

        Deque<Integer> queue = new ArrayDeque<>();

        for (int i = 0; i < n; i++) {
            if (indegree[i] == 0) {
                queue.offer(i);
            }
        }

        int visited = 0;

        while (!queue.isEmpty()) {
            int node = queue.poll();

            visited++;

            // 删除当前节点的出边,释放后继节点依赖。
            for (int next : graph[node]) {
                indegree[next]--;

                if (indegree[next] == 0) {
                    queue.offer(next);
                }
            }
        }

        return visited != n;
    }
}
func hasCycle(n int, dependencies [][]int) bool {
    graph := make([][]int, n)
    indegree := make([]int, n)
    for _, edge := range dependencies {
        from := edge[0]
        to := edge[1]
        graph[from] = append(graph[from], to)
        indegree[to]++
    }

    queue := make([]int, 0)
    for node := 0; node < n; node++ {
        if indegree[node] == 0 {
            queue = append(queue, node)
        }
    }

    visited := 0
    for head := 0; head < len(queue); head++ {
        node := queue[head]
        visited++
        // 后继节点入度降为 0 后才可以进入拓扑序。
        for _, next := range graph[node] {
            indegree[next]--
            if indegree[next] == 0 {
                queue = append(queue, next)
            }
        }
    }
    return visited != n
}

复杂度分析

  • 时间复杂度:$O(V+E)$。每个节点最多入队、出队一次,每条边只在建图和删除时各访问一次。
  • 空间复杂度:$O(V+E)$。邻接表保存所有边,入度数组和队列至多保存 V 个节点。

关键点总结

[!green]

  • 删除零入度节点不会遗漏任何环;处理结束后仍有节点,则剩余图一定含环。
  • 邻接表与入度必须按同一个边方向维护,删除的边要与初始计数一一对应。
  • 剩余节点可能在环上,也可能只是依赖环而被阻塞,处理数只能判环,不能直接给出全部环节点。

易错点总结

[!yellow]

  • 只从边中收集初始节点,会漏掉孤立节点,导致处理数不足而误判有环。
  • 后继第一次被访问就入队,忽略了它可能还有其他前置依赖;必须等入度降到 0。
  • 初始队列非空就判断无环,可能遗漏其他连通部分中的循环。
  • 自依赖本身就是环,不能在建图时删除。
  • 邻接表保留重复边、入度却只计一次,或反过来只在一侧去重,都会破坏计数;两处应采用一致规则。
  • 本接口问的是“是否有环”,不要把返回值写成表示可完成全部任务的 visited == n。

相似题目

题目 难度 关联与区别
207. 课程表 中等 同样用拓扑处理节点数判断环;本题报告有环时,课程表对应不能完成,返回语义相反。
210. 课程表 II 中等 判定无环后可进一步输出拓扑顺序,但输入边方向仍需按各题定义建立。
转载与许可
作者
链接 https://hgnulb.github.io/blog/2026/88516177
许可 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处!