算法随笔-拓扑排序

75 阅读8分钟

www.bilibili.com/video/BV17g…

学了忘,忘了学,学了再忘,忘了再学。

图的算法分成两种,一种是网格图,另一种是有向图(无向图)。

深搜

  • 对于有向图而言,意味着一条路径
  • 对于网格图而言,意味着一个连通区域

广搜

  • 二者的含义差不多。

对于有向图,题干的形式一般是{node, edge}这样的数据结构,不利于dfs和bfs,我们一般需要转换为邻接矩阵。

对于网格图,一般给出一个数组


有向图我们应该额外注意到的两个性质是 {入度和出度},有些题干利用好入度和出度可以更方便的理解题意。

除此以外,选择dfs/bfs,如何去重,如何回溯,如何构造临时的中间存储结构,就依靠刷题提升感觉了。


拓扑排序是有向图上的一个算法,主要用于判断是否存在环路。

拓扑序: 给定一个顺序序列,如果序列里的每个node的顺序,在edges里的每条边{x, y}中,不会有任何y比x先出现,则是一个合法的拓扑序。要注意,有些结点之间没有明显的边关系,对于这样的结点在序列里的顺序是随意的。

Kahn算法,统计入度 + bfs

流程

  1. 统计所有结点的入度,将入度为0的加入队列。入度为零意味着该结点"无拘无束",不依赖之前的任何一个结点。
  2. 依次弹出队列的结点,将该结点加入结果,并将该结点为入度的所有边的出度结点的入度-1(等价于删除以当前结点为入度的所有边),如果删除后某个结点的入度为0,则加入该结点。
  3. 退出时(队列为空),如果结果的长度刚好就是结点个数,则构成一个合法的拓扑序。

leetcode 207: 课程表

leetcode.cn/problems/co…

需要理解的是第29行,其实就是在证明"每个node至多被加入到queue里一次"

如果无环,那么等价于从一开始入度为0的点(多源)到达他们出发的所有路径,每个结点只会被加入一次。

如果有环,遍历过程中就会发现某个中间状态,任何node的入度都不为0,此时提前退出,部分结点一次都没被加入。

class Solution {
    // 拓扑排序
    // {node, edges} 构造成邻接矩阵,同时构造入度列表
    public boolean canFinish(int numCourses, int[][] prerequisites) {
        int[] din = new int[numCourses];
        List<List<Integer>> graph = new ArrayList<>();
        for (int i = 0; i < numCourses; i++) graph.add(new ArrayList<>());
        for (int[] edge : prerequisites) {
            int from = edge[0];
            int to = edge[1];
            din[to]++; // 入度数组
            graph.get(from).add(to); // 邻接矩阵
        }

        // 拓扑序列
        List<Integer> res = new ArrayList<>();

        // bfs
        Queue<Integer> q = new LinkedList<>();
        for (int i = 0; i < numCourses; i++) {
            if (din[i] == 0) {
                q.add(i);
            }
        }


        while (!q.isEmpty()) {
            int cur = q.poll();
            res.add(cur);  // 证明: 每个node至多被加入一次

            // 找到所有cur为起点的边
            for (int to : graph.get(cur)) {
                // 移除{cur, to} 这条边,如果移除后to的入度为0,则to加入
                if (--din[to] == 0) q.add(to);
            }
        }

        // 如果退出时, res.size() != numsCourse,则存在环路
        // 也就是在某个过程,我们发现每个结点的入度都不为1,此时表示无论如何也没法满足彼此的依赖关系
        // todo: 依赖管理工具是不是就这个原理
        return res.size() == numCourses;

    }
}

dfs版本:

  1. 所有结点 初始化为染色成0
  2. 首次到达结点时,如果此时染色为0,将结点染色为-1,如果此时发现染色为-1,说明存在环路,一路返回0
  3. 遍历结点的所有孩子(dfs),如果孩子返回0,一路返回0
  4. 返回结点时(所有的孩子都访问完了),将结点染色为1,并返回1

注意几点

  1. 每次dfs意味着 整条路径上的结点都会被染色。所以我们for (all node) { 去重 then dfs() } 实际上访问了所有有效路径(多源dfs)
  2. 只有所有路径都没有环,才能保证结果返回true。
  3. 对于单条路径(一个dfs过程),只要其中一个过程有环,则不需要继续递归,直接一路返回false
  4. 如果遍历完所有孩子,必须让自己变成1,1表示"遍历过但是无环",如果一个结点同时位于两个路径上,不及时更新的话,会误判。e.g. {1->2->4}, {3->2->5} 2会被遍历两次,但是合法的,关键在于1->2->3遍历后,2的颜色为1,而不是-1。

状态机

  1. -1 表示 "同一条路径存在环"

  2. 0 表示结点从没被遍历过

  3. 1 表示 "一个结点位于不同路径"

  4. 如果进入递归前,发现结点颜色为 -1 表示非法,一路返回false

  5. 如果进入递归前,发现结点颜色为1, 则继续递归不返回,注意这个设计很精妙,如果一个node颜色为1,则说明从这个node出发的所有路径已经被证明无环了,那就没必要再继续下去了。

  6. 如果进入递归前,发现结点颜色为0,染成-1再继续递归

  7. 如果某个结点返回时,颜色从-1 染成 1


class Solution {
    public boolean canFinish(int numCourses, int[][] prerequisites) {
        // 同样的,邻接矩阵用于dfs, 单纯的{node, edges} 不可以用于dfs
        List<List<Integer>> g = new ArrayList<>();
        for (int i = 0; i < numCourses; i++) g.add(new ArrayList<>());
        for (int[] edge : prerequisites) {
            g.get(edge[0]).add(edge[1]);
        }


        // 一开始所有的node染色为0
        // 我们要从所有node出发进行dfs, 因为有些结点可能没有关联
        // 一次dfs = 遍历一条路径,一次dfs会把路径上的所有结点都染色成1,
        // 所以尽管我们好像是从所有node出发复杂度O(len of node),实际上复杂度是O(len of path)
        int[] color = new int[numCourses];
        

        for (int i = 0; i < numCourses; i++) {
            // 如果该位置是1,表示这个结点在之前的某次dfs路径被访问过了,直接退出
            if (color[i] != 0) continue;

            // 有任何一个路径不满足,结果都是不满足的
            if(!dfs(g, i, color)) return false;
        }

        return true;


    }

    public boolean dfs(List<List<Integer>> g, int cur, int[] color) {

        // 如果该位置已经是-1了,然后又进来一次,此时直接返回false
        if (color[cur] == -1) return false;

        // 将位置染色成-1
        color[cur] = -1;

        // 遍历所有孩子
        for (int y : g.get(cur)) {
            if (color[y] == 1) continue; // 跳过
            if (!dfs(g, y, color)) return false; // 如果自己的一个孩子有环,那自己一路返回false,不需要继续递归了
        }

        // 如果遍历完所有孩子, 将自己染色为1,返回true
        color[cur] = 1;
        return true;


    }
}

210: 课程表II

就是把207的具体的拓扑顺序求出来

class Solution {
    // 和207 完全一样,就是求出具体的拓扑序
    public int[] findOrder(int numCourses, int[][] prerequisites) {
        List<List<Integer>> g = new ArrayList<>();
        for (int i = 0; i < numCourses; i++) g.add(new ArrayList<>());
        int[] din = new int[numCourses];
        for (int[] edge : prerequisites) {
            g.get(edge[0]).add(edge[1]);
            din[edge[1]]++;
        }

        List<Integer> res = new ArrayList<>();
        Queue<Integer> q = new LinkedList<>();

        for (int i = 0; i < numCourses; i++) if (din[i] == 0) q.add(i);

        while (!q.isEmpty()) {
            int cur = q.poll();
            res.add(cur);
            for (int to : g.get(cur)) {
                if (--din[to] == 0) q.add(to);
            }
        }
        int[] ret = new int[res.size()];
        if (ret.length != numCourses) return new int[0];
        
        // 注意题意,[a, b]的含义是a依赖b
        // 我们res其实是存储了 a->b->c->d 的顺序,因此最后的需要最先修
        for (int i = numCourses - 1; i >= 0; i--) ret[i] = res.get(numCourses - i + - 1);
        return ret;
    }
}

递归做法不需要翻转数组。刚好递归的含义就是题干的意思

class Solution {

    List<Integer> ret = new ArrayList<>();
    public int[] findOrder(int numCourses, int[][] prerequisites) {
        // 同样的,邻接矩阵用于dfs, 单纯的{node, edges} 不可以用于dfs
        List<List<Integer>> g = new ArrayList<>();
        for (int i = 0; i < numCourses; i++) g.add(new ArrayList<>());
        for (int[] edge : prerequisites) {
            g.get(edge[0]).add(edge[1]);
        }


        // 一开始所有的node染色为0
        // 我们要从所有node出发进行dfs, 因为有些结点可能没有关联
        // 一次dfs = 遍历一条路径,一次dfs会把路径上的所有结点都染色成1,
        // 所以尽管我们好像是从所有node出发复杂度O(len of node),实际上复杂度是O(len of path)
        int[] color = new int[numCourses];
        

        for (int i = 0; i < numCourses; i++) {
            // 如果该位置是1,表示这个结点在之前的某次dfs路径被访问过了,直接退出
            if (color[i] != 0) continue;

            // 有任何一个路径不满足,结果都是不满足的
            if(!dfs(g, i, color)) return new int[0];
        }
        int[] res = new int[ret.size()];
        if (ret.size() != numCourses) return new int[0];
        for (int i = 0; i < numCourses; i++) res[i] = ret.get(i);
        return res;


    }

    public boolean dfs(List<List<Integer>> g, int cur, int[] color) {

        // 如果该位置已经是-1了,然后又进来一次,此时直接返回false
        if (color[cur] == -1) return false;

        // 将位置染色成-1
        color[cur] = -1;

        // 遍历所有孩子
        for (int y : g.get(cur)) {
            if (color[y] == 1) continue; // 不继续遍历这个孩子 但尝试访问其他孩子(找到没访问过的孩子)
            if (!dfs(g, y, color)) return false; // 如果自己的一个孩子有环,那自己一路返回false,不需要继续递归了
        }

        // 如果遍历完所有孩子, 将自己染色为1,返回true
        color[cur] = 1;

        // 同时把自己加入结果集
        // 注意: 这里的顺序就是题目的顺序, 首先加入的是路径里最深的结点,也就是依赖的首个结点
        ret.add(cur);
        return true;


    }
}