【刷穿 LeetCode】详解何为拓扑排序,以及求拓扑排序方法的正确性证明

简介: 【刷穿 LeetCode】详解何为拓扑排序,以及求拓扑排序方法的正确性证明

网络异常,图片无法展示
|


题目描述



这是 LeetCode 上的 802. 找到最终的安全状态 ,难度为 中等


Tag : 「图」、「拓扑排序」


在有向图中,以某个节点为起始节点,从该点出发,每一步沿着图中的一条有向边行走。


如果到达的节点是终点(即它没有连出的有向边),则停止。


对于一个起始节点,如果从该节点出发,无论每一步选择沿哪条有向边行走,最后必然在有限步内到达终点,则将该起始节点称作是 安全 的。


返回一个由图中所有安全的起始节点组成的数组作为答案。答案数组中的元素应当按 升序 排列。


该有向图有 n 个节点,按 0 到 n - 1 编号,其中 n 是 graph 的节点数。图以下述形式给出:graph[i] 是编号 j 节点的一个列表,满足 (i, j) 是图的一条有向边。


示例 1:


网络异常,图片无法展示
|


输入:graph = [[1,2],[2,3],[5],[0],[5],[],[]]
输出:[2,4,5,6]
解释:示意图如上。
复制代码


示例 2:


输入:graph = [[1,2,3,4],[1,2],[3,4],[0,4],[]]
输出:[4]
复制代码


提示:


  • n == graph.length
  • 1 <= n <= 10^4104
  • 0 <= graph[i].length <= n
  • graph[i] 按严格递增顺序排列。
  • 图中可能包含自环。
  • 图中边的数目在范围 [1, 4 * 10^4104] 内。


基本分析 & 拓扑排序



为了方便,我们令点数为 nn,边数为 mm


在图论中,一个有向无环图必然存在至少一个拓扑序与之对应,反之亦然。


如果对拓扑排序不熟悉的小伙伴,可以看看 拓扑排序


简单来说,就是将图中的所有节点展开成一维序列,对于序列中任意的节点 (u, v)(u,v),如果在序列中 uuvv 的前面,则说明在图中存在从 uu 出发达到 vv 的通路,即 uu 排在 vv 的前面。反之亦然。


同时,我们需要知晓「入度」和「出度」的概念:


  • 入度:有多少条边直接指向该节点;
  • 出度:由该节点指出边的有多少条。


因此,对于有向图的拓扑排序,我们可以使用如下思路输出拓扑序(BFS 方式):


  1. 起始时,将所有入度为 00 的节点进行入队(入度为 00,说明没有边指向这些节点,将它们放到拓扑排序的首部,不会违反拓扑序定义);
  2. 从队列中进行节点出队操作,出队序列就是对应我们输出的拓扑序。 对于当前弹出的节点 xx,遍历 xx 的所有出度,即遍历所有由 xx 直接指向的节点 yy,对 yy 做入度减一操作(因为 xx 节点已经从队列中弹出,被添加到拓扑序中,等价于从 xx 节点从有向图中被移除,相应的由 xx 发出的边也应当被删除,带来的影响是与 xx 相连的节点 yy 的入度减一);
  3. yy 进行入度减一之后,检查 yy 的入度是否为 00,如果为 00 则将 yy 入队(当 yy 的入度为 00,说明有向图中在 yy 前面的所有的节点均被添加到拓扑序中,此时 yy 可以作为拓扑序的某个片段的首部被添加,而不是违反拓扑序的定义);
  4. 循环流程 2233 直到队列为空。


网络异常,图片无法展示
|


证明



上述 BFS 方法能够求得「某个有向无环图的拓扑序」的前提是:我们必然能够找到(至少)一个「入度为 00 的点」,在起始时将其入队。


这可以使用反证法进行证明:假设有向无环图的拓扑序不存在入度为 00 的点。


那么从图中的任意节点 xx 进行出发,沿着边进行反向检索,由于不存在入度为 00 的节点,因此每个点都能够找到上一个节点。


当我们找到一条长度为 n + 1n+1 的反向路径时,由于我们图中只有 nn 个节点,因此必然有至少一个节点在该路径中重复出现,即该反向路径中存在环,与我们「有向无环图」的起始条件冲突。


得证「有向无环图的拓扑序」必然存在(至少)一个「入度为 00 的点」。


即按照上述的 BFS 方法,我们能够按照流程迭代下去,直到将有向无环图的所有节点从队列中弹出。


反之,如果一个图不是「有向无环图」的话,我们是无法将所有节点入队的,因此能够通过入队节点数量是否为 nn 来判断是否为有向无环图。


反向图 + 拓扑排序



回到本题,根据题目对「安全节点」的定义,我们知道如果一个节点无法进入「环」的话则是安全的,否则是不安全的。


另外我们发现,如果想要判断某个节点数 xx 是否安全,起始时将 xx 进行入队,并跑一遍拓扑排序是不足够的。


因为我们无法事先确保 xx 满足入度为 00 的要求,所以当我们处理到与 xx 相连的节点 yy 时,可能会存在 yy 节点入度无法减到 00 的情况,即我们无法输出真实拓扑序中,从 xx 节点开始到结尾的完整部分。


但是根据我们「证明」部分的启发,我们可以将所有边进行反向,这时候「入度」和「出度」翻转了。


对于那些反向图中「入度」为 00 的点集 xx,其实就是原图中「出度」为 00 的节点,它们「出度」为 00,根本没指向任何节点,必然无法进入环,是安全的;同时由它们在反向图中指向的节点(在原图中只指向它们的节点),必然也是无法进入环的,对应到反向图中,就是那些减去 xx 对应的入度之后,入度为 00 的节点。


因此整个过程就是将图进行反向,再跑一遍拓扑排序,如果某个节点出现在拓扑序列,说明其进入过队列,说明其入度为 00,其是安全的,其余节点则是在环内非安全节点。


另外,这里的存图方式还是使用前几天一直使用的「链式前向星」,关于几个数组的定义以及其他的存图方式,如果还是有不熟悉的小伙伴可以在 这里 查阅,本次不再赘述。


代码:


class Solution {
    int N = (int)1e4+10, M = 4 * N;
    int idx;
    int[] he = new int[N], e = new int[M], ne = new int[M];
    int[] cnts = new int[N];
    void add(int a, int b) {
        e[idx] = b;
        ne[idx] = he[a];
        he[a] = idx++;
    }
    public List<Integer> eventualSafeNodes(int[][] g) {
        int n = g.length;
        // 存反向图,并统计入度
        Arrays.fill(he, -1);
        for (int i = 0; i < n; i++) {
            for (int j : g[i]) {
                add(j, i);
                cnts[i]++;
            }
        }
        // BFS 求反向图拓扑排序
        Deque<Integer> d = new ArrayDeque<>();
        for (int i = 0; i < n; i++) {
            if (cnts[i] == 0) d.addLast(i);
        }
        while (!d.isEmpty()) {
            int poll = d.pollFirst();
            for (int i = he[poll]; i != -1; i = ne[i]) {
                int j = e[i];
                if (--cnts[j] == 0) d.addLast(j);
            }
        }
        // 遍历答案:如果某个节点出现在拓扑序列,说明其进入过队列,说明其入度为 0
        List<Integer> ans = new ArrayList<>();
        for (int i = 0; i < n; i++) {
            if (cnts[i] == 0) ans.add(i);
        }
        return ans;
    }
}
复制代码


  • 时间复杂度:O(n + m)O(n+m)
  • 空间复杂度:O(n + m)O(n+m)


最后



这是我们「刷穿 LeetCode」系列文章的第 No.802 篇,系列开始于 2021/01/01,截止于起始日 LeetCode 上共有 1916 道题目,部分是有锁题,我们将先把所有不带锁的题目刷完。


在这个系列文章里面,除了讲解解题思路以外,还会尽可能给出最为简洁的代码。如果涉及通解还会相应的代码模板。


为了方便各位同学能够电脑上进行调试和提交代码,我建立了相关的仓库:github.com/SharingSour…


在仓库地址里,你可以看到系列文章的题解链接、系列文章的相应代码、LeetCode 原题链接和其他优选题解。

相关文章
|
2月前
|
存储 算法
LeetCode第83题删除排序链表中的重复元素
文章介绍了LeetCode第83题"删除排序链表中的重复元素"的解法,使用双指针技术在原链表上原地删除重复元素,提供了一种时间和空间效率都较高的解决方案。
LeetCode第83题删除排序链表中的重复元素
|
2月前
|
算法 索引
LeetCode第34题在排序数组中查找元素的第一个和最后一个位置
这篇文章介绍了LeetCode第34题"在排序数组中查找元素的第一个和最后一个位置"的解题方法,通过使用双指针法从数组两端向中间同时查找目标值,有效地找到了目标值的首次和最后一次出现的索引位置。
LeetCode第34题在排序数组中查找元素的第一个和最后一个位置
|
2月前
|
存储 算法 Java
LeetCode初级算法题:反转链表+统计N以内的素数+删除排序数组中的重复项Java详解
LeetCode初级算法题:反转链表+统计N以内的素数+删除排序数组中的重复项Java详解
22 0
|
2月前
|
算法 索引 Python
【Leetcode刷题Python】34. 在排序数组中查找元素的第一个和最后一个位置(二分查找)
解决LeetCode "在排序数组中查找元素的第一个和最后一个位置" 问题的方法。第一种方法是使用两次二分查找,首先找到目标值的最左边界,然后找到最右边界。第二种方法是利用Python的list.index()方法,先正序找到起始位置,再逆序找到结束位置,并给出了两种方法的Python实现代码。
48 0
|
4月前
力扣随机一题 哈希表 排序 数组
力扣随机一题 哈希表 排序 数组
30 1
|
4月前
|
算法 Python
LeetCode 常用方法
LeetCode 常用方法
|
4月前
|
算法
【经典LeetCode算法题目专栏分类】【第10期】排序问题、股票问题与TOP K问题:翻转对、买卖股票最佳时机、数组中第K个最大/最小元素
【经典LeetCode算法题目专栏分类】【第10期】排序问题、股票问题与TOP K问题:翻转对、买卖股票最佳时机、数组中第K个最大/最小元素
|
4月前
|
存储 算法 数据可视化
【模拟面试问答】深入解析力扣164题:最大间距(桶排序与排序方法详解)
【模拟面试问答】深入解析力扣164题:最大间距(桶排序与排序方法详解)
|
4月前
|
存储 算法 数据可视化
力扣156题最全解法:如何上下翻转二叉树(递归与迭代方法详解,附图解)
力扣156题最全解法:如何上下翻转二叉树(递归与迭代方法详解,附图解)
|
4月前
|
SQL 算法 数据挖掘
深入解析力扣183题:从不订购的客户(LEFT JOIN与子查询方法详解)
深入解析力扣183题:从不订购的客户(LEFT JOIN与子查询方法详解)