题目描述

✅ 802. 找到最终的安全状态

题意分析

没有出边的节点是终端节点。从某个节点出发,无论怎样选择后续边,最终都会停在终端节点,它才是安全节点。只要能够进入环,就可以沿环一直行走,因此该节点不安全。

需要按编号升序返回所有安全节点。关键是“所有路径”都终止,不能只找到一条通往终端的路径就判定安全。

解法:反图上的出度消除

核心思路

[!blue]

终端节点天然安全。进一步,如果一个节点的所有直接后继都已安全,那么无论下一步走向哪个后继,之后都能终止,这个节点也安全。因此可以从原图出度为零的节点开始,向前驱逐步传播安全性。

建立反图 reverse[v],保存所有能一步走到 v 的前驱,同时令 degree[u] 记录原图节点 u 的出度。初始将全部出度为零的节点入队。

每次取出安全节点 v,相当于把它从待处理图中消除。对每个前驱 u,将 degree[u] 减一,表示边 u→v 已经不需要继续保留。某个前驱的剩余出度降为零时,它的所有后继都已被安全消除,因此也可以确认安全并入队。

这一过程既不会误收,也不会漏收。入队节点的所有后继都已经安全,所以归纳可知每个被消除节点都安全。队列耗尽后,若某节点仍有正的剩余出度,它就还有一条边走向未消除节点;沿剩余边不断前进,在有限节点中必然重复到达某个节点,形成一个可达环。因此所有未被消除的节点都不安全。

处理完成后,剩余出度为零的节点正好就是全部安全节点。队列的处理顺序不保证编号递增,所以最后再按编号从小到大扫描,将出度为零的节点加入答案即可。

解题步骤

  1. 对每条原图边 u→v,将 u 加入 reverse[v],并记录每个节点的原始出度。
  2. 将所有出度为零的终端节点放入队列。
  3. 取出节点 v,遍历它在反图中的前驱,将这些前驱的剩余出度各减一。
  4. 前驱出度刚好降为零时入队,继续向它的前驱传播。
  5. 队列为空后按节点编号扫描,收集最终出度为零的节点。若初始没有终端节点,队列为空,所有节点都会留在包含可达环的部分中。

代码实现

class Solution {
    public List<Integer> eventualSafeNodes(int[][] graph) {
        int n = graph.length;
        List<List<Integer>> reverse = new ArrayList<>();
        int[] degree = new int[n];

        for (int i = 0; i < n; i++) {
            reverse.add(new ArrayList<>());
        }

        for (int u = 0; u < n; u++) {
            degree[u] = graph[u].length;

            for (int v : graph[u]) {
                reverse.get(v).add(u);
            }
        }

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

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

        while (!queue.isEmpty()) {
            int v = queue.remove();

            for (int u : reverse.get(v)) {
                if (--degree[u] == 0) {
                    queue.add(u);
                }
            }
        }

        List<Integer> answer = new ArrayList<>();

        for (int i = 0; i < n; i++) {
            if (degree[i] == 0) {
                answer.add(i);
            }
        }

        return answer;
    }
}
func eventualSafeNodes(graph [][]int) []int {
    n := len(graph)
    reverse := make([][]int, n)
    degree := make([]int, n)
    for u, neighbors := range graph {
        degree[u] = len(neighbors)
        for _, v := range neighbors {
            reverse[v] = append(reverse[v], u)
        }
    }
    queue := []int{}
    for i := range graph {
        if degree[i] == 0 {
            queue = append(queue, i)
        }
    }
    for head := 0; head < len(queue); head++ {
        for _, u := range reverse[queue[head]] {
            degree[u]--
            if degree[u] == 0 {
                queue = append(queue, u)
            }
        }
    }
    answer := []int{}
    for i := range graph {
        if degree[i] == 0 {
            answer = append(answer, i)
        }
    }
    return answer
}

复杂度分析

  • 时间复杂度:$O(V+E)$。建图和最后扫描各为线性,每个节点最多入队一次,每条反向边也只在其对应后继出队时处理一次。
  • 空间复杂度:$O(V+E)$。反图保存全部节点和边,度数数组、队列与答案均为 $O(V)$。

关键点总结

[!green]

  • 所有后继安全,当前节点才安全,对应剩余出度必须降为零。
  • 反图用来找到安全节点的前驱,度数记录的始终是原图出度的剩余量。
  • 未消除部分每个节点都有剩余出边,沿边前进必能到达环,说明保留下来的恰好是不安全节点。
  • 最后按编号扫描就能满足升序要求,无需依赖队列顺序或另行排序。

易错点总结

[!yellow]

  • 只要一个后继安全就把当前节点判为安全,会漏掉其他后继能进入环的情况。
  • 从原图入度为零的节点开始消除,处理的是前置依赖,无法表达本题所有后续路径都会结束的条件。
  • 只判断节点自己是否在环上,会遗漏能够走到环、但不属于环的节点。
  • 图允许自环,自环节点至少保留一条指向自己的出边,不会变成安全节点。
  • 按出队顺序直接输出,可能违反结果按编号升序排列的要求。

相似题目

题目 难度 关联与区别
207. 课程表 中等 同样使用度数归零的拓扑消除,课程表从入度 0 开始,本题从原图出度 0 开始。
210. 课程表 II 中等 对照拓扑过程中队列顺序与答案顺序;本题需要最终按节点编号输出。
转载与许可
作者
链接 https://hgnulb.github.io/blog/2026/56423593
许可 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处!