拓扑排序

一、例题

在这里插入图片描述

这是一道典型的拓扑排序题:

拓扑关系是,只有前导课程学习完毕,才能学习后序课程。

二、拓扑排序

先了解一些必要的概念:

  1. 图的度

    图的度是指图中元素所连接的节点数量。在有向图中,分为入度和出度;

    • 入度:
      • 指向当前结点的所有结点数量;
    • 出度:
      • 当前结点指向的结点数量

    如上图,0的入度为0,出度为1

  2. 有向无环图

    1. 不管按照怎样的路径,都最终会到达一个终点(不一定是同一个终点),而不会前进一定的步数还能回到前遍历过得节点的图。
    2. 上图就是一个有向无环图
  3. 拓扑排序

    1. 将一个有向无换图按照其内部的逻辑转化为一个线性序列的过程。[逻辑一般是,只有当前节点的入度节点全部访问过了,才能访问当前节点]
    2. 如,对于上图,可以得到一个序列:0,1,2,3,4,5
  4. 拓扑排序的流程

    1. 首先找到任一个入度为0的节点,访问他;
    2. 将该节点“删除”,即以他作为入度的节点的入度数减一【可能会使得入度减一的节点入度变为0,他也就可以访问了】
    3. 继续执行上述步骤

    结果可能有两种:

    (1)所有节点都访问过了,证明这个图可以进行拓扑排序,且按照访问顺序可以得到一个拓扑排序序列,这个图是个有向无环图

    (2)进行到一定程度,没有发现入度为0的节点,因此拓扑排序无法进行下去,证明该图是个有环图。【如,将上图中的2节点指向1,1节点指向0,0节点指向2,这样,无论无核都找不到一个入度为0的节点,拓扑排序失败】

    因此拓扑排序能否成功是检验图是否有环的一个好方法


对于课程表问题:

没有先导课程的课就可以视为一个入度为0的节点,而先上完这节课之后,让将他作为先导课的入度-1。再如此反复,若所有节点全空,返回true,否则返回false。


方法已经确定好了,那如何实现拓扑排序呢?

拓扑排序的实现与BFS极其类似:先入队入度为0的节点,对队列中节点进行遍历,并将入队其出度节点的入度减一;

从剩余的入度为0的节点中取出一个(或者全部),再进行上述步骤

一趟循环访问度为0还是一个度为0取决于算法实现【这点和二叉树的两种层序遍历方法有点类似】

  • 数据结构:

    • 入度表:需要一个记录所有入度信息的序列,因为节点数量(即课程数是确定的),可以通过一维数组实现【下标为课程号,值为入度】
    • 邻接表:需要记录将当前节点作为入度的节点列表,可以使用hash表实现:key使用当前节点下标,val使用他出度的节点们下标组成的数组。
  • 结束条件,使用count记录遍历过的节点数,若 == num,返回true,否则为false

    • 或者,在while循环结束后,遍历入度表,发现还有入度为0的节点,即为拓扑失败。

三、代码实现

static class Solution {
        public boolean canFinish(int numCourses, int[][] prerequisites) {
            //入度表,记录当前节点的入度值
            int[] inDgree = new int[numCourses]; //默认数组的初始值为全0

            //邻接表,记录当前节点的所有出度节点
            Map<Integer, List<Integer>> adj = new HashMap<>();

            //遍历前置课程信息表,记录入度表和邻接表
            for (int i = 0; i < prerequisites.length; i++) {
                int pre = prerequisites[i][1]; //当前前置课程号
                int post = prerequisites[i][0]; //当前后置课程号 后置会在前置上完后才上
                if (adj.containsKey(pre)) {
                    List<Integer> list = adj.get(pre);
                    list.add(post); //保存其一个出度节点
                    adj.put(pre, list);
                } else {
                    List<Integer> list = new ArrayList<>(){{add(post);}};
                    adj.put(pre, list);
                }
                inDgree[post]++; //当前后置节点有前置节点,他的入度加一。
            }
            System.out.println(Arrays.toString(inDgree));
            System.out.println(adj);
            Queue<Integer> queue = new LinkedList<>();

            //queue的初始化,选择一次遍历将所有入度为0的节点存入
            for (int i = 0; i < numCourses; i++) {
                if (inDgree[i] == 0) queue.offer(i);
            }

            int count = 0; //最终结果判定
            //BFS经典条件
            while (!queue.isEmpty()) {
                int p = queue.poll(); //遍历一个节点
                count++;

                //遍历其所有后置节点,将其入度减一,若因此降为0.将其加入到队列中
                List<Integer> posts = adj.get(p);
                if (posts == null) continue; //存在那种只有没有入度也没有出度的节点【比如说排序中最后一个节点就是】
                for (int post: posts) {
                    inDgree[post]--;
                    if (inDgree[post] == 0) {
                        queue.offer(post);
                    }
                }
            }
            return count == numCourses; //根据有无遍历最大课程数作为返回
         }
    }

四、拓扑排序总结

  1. 构建入度列表,节点与出度节点关联的邻接表;
  2. 初始化队列,将0入度的节点入队,进行遍历,并将其出度节点的入度减一,若为0,入队;
  3. 重复执行,直到队列为空;
  4. 根据遍历的节点数量与入度列表的入度是否全部为0查看拓扑排序成功与否。

课后练习:

一个完整的软件项目往往会包含很多由代码和文档组成的源文件。编译器在编译整个项目的时候,可能需要按照依赖关系来依次编译每个源文件。比如,A.cpp 依赖 B.cpp,那么在编译的时候, 编译器需要先编译 B.cpp,才能再编译 A.cpp。 假设现有 0,1,2,3 四个文件,0号文件依赖1号文件,1号文件依赖2号文件,3号文件依赖1号文件,则源文件的编译顺序为 2,1,0,3 或 2,1,3,0。

​ \ 现给出文件依赖关系,如 1,2,-1,1,表示0号文件依赖1号文件,1号文件依赖2号文件,2号文件没有依赖,

​ \ 3号文件依赖1号文件。请补充完整程序,返回正确的编译顺序。注意如有同时可以编译多个文件的情况,

​ \ 按数字升序返回一种情况即可,比如前述案例输出为:2,1,0,3

​ 输入例子1:

​ “1,2,-1,1”

​ 输出例子1:

​ “2,1,0,3”

参考答案:

public String compileSeq (String input) {

            input = input.replace(" ", ""); //去除所有空格
            int[] nums = Arrays.stream(input.split(",")).mapToInt(Integer:: parseInt).toArray();
            int num = nums.length;
            int[] inDgree = new int[num];
            Map<Integer, List<Integer>> adj = new HashMap<>();

            for (int i = 0; i < num; i++) {
                int idx; //s.charAt(i)表示: i文件的前置是idx
                if ((idx = nums[i]) > 0) {
                    //下标对应的指向为正数,证明是存在前置节点的
                    inDgree[i]++; //存在前置,因此入度加一

                    //根据前置、后置关系更新邻接表
                    if (adj.containsKey(idx)) {
                        List<Integer> list = adj.get(idx);
                        list.add(i);
                        adj.put(idx, list);
                    } else {
                        List<Integer> list = new ArrayList<>();
                        list.add(i);
                        adj.put(idx, list);
                    }
                }
            }

            System.out.println(Arrays.toString(inDgree));
            System.out.println(adj);

            Queue<Integer> queue = new LinkedList<>();
            
            StringBuilder sb = new StringBuilder();

            //将所有入度为零的节点加入队列
            for (int i = 0; i < num; i++) {
                if (inDgree[i] == 0) {
                    queue.offer(i);

                    //加入这些为0的为首先编译的对象
                    sb.append(i);
                }
            }

            // int count = 0;
            while (!queue.isEmpty()) {
                int p = queue.poll();
                //本题的逻辑是找到序列, 无需做额外的遍历,只需要按顺序收集那些入度为零的节点即可

                List<Integer> posts = adj.get(p);
                if (posts == null) continue;
                for (int post: posts) {
                    inDgree[post]--;
                    if (inDgree[post] == 0) {
                        queue.offer(post);
                        //时刻记得本题的目标
                        sb.append(post);
                    }
                }
            }
            return sb.toString();
        }

是不是感觉一个磨子刻出来的。。。毕竟出自一人之手。

queue.offer(post);
//时刻记得本题的目标
sb.append(post);
}
}
}
return sb.toString();
}


是不是感觉一个磨子刻出来的。。。毕竟出自一人之手。





本文参考至[笨猪爆破组](https://leetcode-cn.com/problems/course-schedule/solution/bao-mu-shi-ti-jie-shou-ba-shou-da-tong-tuo-bu-pai-/)大佬的题解。这位大佬的题解都很浅显易懂,而且总结解题套路,欢迎大家前去访问
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值