[图 拓扑排序 DFS BFS] 207. 课程表(有向图判断是否有环:DFS、BFS)210. 课程表 II (有向图找拓扑排序:BFS)

6 篇文章 0 订阅
1 篇文章 0 订阅

207. 课程表(有向图判断是否有环)

题目链接:https://leetcode-cn.com/problems/course-schedule/


关键点/分类

  • 图(有向图:边缘列表转邻接表);
  • 有向图的遍历(DFS(递归 + 记录节点访问状态)、BFS(队列));
  • 判断有向图是否有环:DFS(判断同一趟DFS是否访问一个节点两次), BFS(即拓扑排序,引入入度数组)

在这里插入图片描述

题目分析:把问题转化为拓扑排序问题

本题是一道经典的「拓扑排序」问题。

题目给定一个匹配关系[0,1]表示先学习课程0,需要先完成课程1,如果把课程看做图的节点,这一匹配关系相当于:1 -> 0。

那么什么情况下会导致无法完成所有课程的学习?

例如:输入[[1,0],[0,1]],表示学习课程 1 之前,你需要先完成​课程 0;并且学习课程 0 之前,你还应先完成课程 1。这是矛盾的。相当于 1 -> 0和0 -> 1不能同时存在。

这一要求和拓扑排序的定义是一样的:给定一个包含 n 个节点的有向图 G,我们给出它的节点编号的一种排列,如果满足:

对于图 G 中的任意一条有向边 (u,v),u 在排列中都出现在 v 的前面。

那么称该排列是图 G 的「拓扑排序」。

而拓扑排序有一个结论:

一个有向图如果存在拓扑排序,则图中不会有环;如果图中有环则不可能存在拓扑排序。

因此题目可以转化为一个拓扑排序问题:所有课程构成一张有向图,判断该有向图是否存在拓扑排序,即判定图中是否存在回路。

题目难点在于
1、如何处理用边缘列表表示的图?
题目给定的是边缘列表:
- 优点:可以在O(1)时间下知道当前节点和哪个节点相连;
- 缺点:如果要随机访问某个节点,需要O(N)的时间来寻找。

这样在访问每个指定节点时,都需要遍历整个边缘列表才能找到目标节点,效率低。

所以将边缘列表转换成邻接表,邻接表的类型是List<List<Integer>>,存放的是以每个节点为起点所能直接到达的所有邻接节点。后续两个思路都是基于邻接表。

2、如何判断图中是否有回路?

前置问题:如何对图做DFS和BFS。(见各思路的算法设计)

那么,如何判断图中是否存在回路的问题:

  • 思路1:使用DFS判断图中是否有回路,使用一个变量来标记节点是被当前这一趟DFS访问过还是被之前其他趟DFS访问过,如果遇到的是同一趟DFS访问过的节点,则说明出现环;(DFS判断是否有环其实没有用到拓扑排序,所以不适用于210题
  • 思路2:使用BFS(入度表)判断图中是否有回路,(BFS判断是否有环使用到了拓扑排序的思想)

所以,下文介绍了:边缘列表转邻接表、对有向图的邻接表进行常规DFS,BFS、如何改造DFS,BFS以判断有向图是否有环。

思路1:DFS + 标记数组 判断是否存在回路(并未用到拓扑排序思想)

算法设计
1、先将边缘列表转化为邻接表

邻接表使用双重列表:

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

使用之前先根据节点数numCourses在adjacency中初始化同样个数的列表元素,以便存放每个节点和与其通过有向边连接的所有相邻节点;

因为课程编号都是从0开始的,所以adjacency[i]就表示存放的是第i个课程的邻接节点。

2、如何对有向图进行DFS遍历?(对图做DFS的框架,不涉及DFS内部实现)

首先,理解什么是一趟DFS:

一趟DFS是以图中某个节点为起点,遍历节点的邻接表,每次选择一个邻接点,然后以邻接点为起点继续遍历它的所有邻接点,以此类推,直到以这个节点为起点所能到达的所有节点都被访问过。

接着,有向图可以看做是几棵树交织在一起,对其中一棵树做DFS,能够把这棵树的所有节点都遍历到,但却可能无法从这棵树到达其他树,

例如:

图中的第一棵树0 -> 1 -> 2,以0为起点进行一趟DFS,但到达2后,无论如何也无法再到达其他节点,所以这一趟DFS到此为止。

但图中还有剩余的节点没有被访问到,所以要再进行一趟DFS,选择另一个节点3作为起点开始新一趟DFS,以此类推。

只有当图中没有还未访问的节点,才说明对整个图的遍历完成。

因此DFS遍历图时,需要设置一个状态数组存放每个节点的访问状态,执行一次DFS,就选择一个从未被访问过的节点为起点,进行一趟DFS。这一趟DFS完成后,再从图中选择一个还未被访问过的节点作为新的起点,进行下一趟DFS,直到所有节点都被访问过。

代码框架

  //枚举邻接表的每个节点作为起点执行DFS
  for(int i = 0; i < numCourses; i++){
      //如果节点在之前的DFS被访问过,则直接跳过
      if(status[i] == -1) continue;
      
      //执行一趟DFS
      dfs();//内部实现见下文
  }
  • 按起点不同可以分为多趟DFS,每个起点对应一趟DFS。
  • 被访问过的节点不会再成为一趟DFS的起点。
3、如何实现一趟深度优先遍历DFS + 有环判断?

以邻接表中的第i个节点为起点,进行一趟深度优先遍历DFS:

每个节点在整个DFS过程中存在三种状态:

  • 未被访问过;
  • 被之前其他趟DFS访问过;
  • 被当前这一趟DFS访问过;

为什么要划分这三种状态?

因为只有一个节点在同一趟DFS中被访问2次才说明出现环路,如果一个节点在之前其他趟DFS被访问过,并不能说明存在回路,反而说明该节点在之前的DFS筛选下可以认为是安全的,不会出现环路的节点,可以在遇到这样的节点时直接跳过,避免重复工作。

这里要明确:有向图的环和无向图的环是不一样的。

例如:

如下图,如果在无向图中,0,1,2,3就构成了一个环;但在有向图中,0,1,2,3并不构成环。
其中,
节点3在一趟DFS:[0->3->4->5]中被访问过,所以status[2] = -1;
之后节点3又在另一趟DFS:[0->1->2->3]中被访问,遍历到节点3时,会发现此时的status[3] == -1,但并不会构成环路。

因为并不是同一趟DFS访问两次节点3。

记录每个节点的访问状态,可以提供判断是否出现环路的依据,可以减少重复工作,可以用于选择一趟DFS的起点,所以DFS遍历图的关键就在于 节点访问状态的记录

如何在DFS中使用节点的状态?

我们设置一个数组status来存放所有节点的状态,下标 i 对应第 i 个节点,数组的元素有三个取值,分别表示节点的三种状态,设置0表示未被访问过,-1表示被其他趟DFS访问过,1表示被当前趟DFS访问过。

在DFS遍历过程中:

  • 如果节点未被访问过,则在DFS过程中遇到该节点时直接访问即可;

  • 如果节点被之前其他趟DFS访问过,则说明在之前几趟DFS中并没有发现该节点存在环路,安全地进行到了当前这一趟DFS,所以该节点是安全的,可以直接跳过,避免做重复工作;

  • 如果节点被当前这一趟DFS访问过,则说明图中出现环路,直接返回false。

一趟带环路判断的DFS流程要如何进行?

乔杉说:常规的DFS都做的有点倦怠了,这个带环路判断的DFS,我不得其解。那么下面我们就来看看该怎么实现。

选择一个节点作为起点,遍历邻接表中该节点对应的邻接点,每枚举一个节点 i,就先判断它在status[i]中对应的状态:

  • 如果status[i] == 0,表示还未访问过,置status[i]=1,表示在这一趟DFS得到访问,继续后续的工作;
  • 如果status[i] == -1,表示在之前的DFS被访问过,可以直接跳过,返回true;
  • 如果status[i] == 1,则说明出现环路,返回false。(和常规DFS相比只增加了这个分支)

如果status[i] == 0,就置status[i]=1,然后继续遍历该节点在邻接表中的所有邻接点,依次选择其中一个邻接点,进入下一层DFS。

直到某个节点的邻接表为空,或所有邻接点都被访问过,则退出这一趟DFS,返回本趟遍历的结果,在开始下一趟DFS之前,要把本趟所访问到的节点的status[i]置为 -1,为下一轮DFS做准备。

实现代码
class Solution {
    public boolean canFinish(int numCourses, int[][] prerequisites) {
        //创建邻接表
        List<List<Integer>> adjacency = new ArrayList<>();
        //初始化每个节点对应的邻接表,则adjacency.get(i)表示课程i的邻接表
        for(int i = 0; i < numCourses; i++){
            adjacency.add(new ArrayList<Integer>());
        }
        //将边缘列表转换为邻接表:边缘列表[i,j]对应j->i
        for(int i = 0; i < prerequisites.length; i++){
            adjacency.get(prerequisites[i][1]).add(prerequisites[i][0]);
        }
        //节点状态数组
        int[] status = new int[numCourses];
        //枚举邻接表的每个节点作为起点执行DFS
        for(int i = 0; i < numCourses; i++){
            //如果节点在之前的DFS被访问过,则直接跳过
            if(status[i] == -1) continue;
            //以节点i为起点调用dfs,如果dfs返回false,则直接退出循环返回false;如果dfs返回true,则继续枚举其他节点
            if(!dfs(i, status, adjacency)) return false;
        }
        //所有节点都遍历完也没有遇到环路,则返回true
        return true;
    }
    //DFS递归实现
    public boolean dfs(int node, int[] status, List<List<Integer>> adjacency){
        //如果当前节点在之前的DFS被访问过,直接返回true
        if(status[node] == -1) return true;
        //如果当前节点在当前的DFS被访问过,直接返回false
        if(status[node] == 1) return false;
        //如果当前节点还未被访问过,
        else{
            status[node] = 1;//表示在当前DFS被访问了
            //从邻接表中依次获取它的邻接节点作为下一层DFS的起点
            for(int i = 0; i < adjacency.get(node).size(); i++){//邻接点数量=邻接表长度
                boolean res = dfs(adjacency.get(node).get(i), status, adjacency);
                if(!res) return false;
            }
            //把刚刚置1的节点状态置为-1,为下一趟dfs做准备
            status[node] = -1;//实质就是恢复现场
            //运行到这里说明以node为起点的dfs没有遇到环路,返回true
            return true;
        }
    }
}

思路2:BFS + 入度数组(拓扑排序思想)

广度优先遍历判断是否有环使用到了拓扑排序的思想。

拓扑排序的工作流程就是:不断将当前有向图中的一个入度=0的节点删除,直到无法再删除为止,判断此时图中是否还有剩余的节点,如果还有剩余节点,说明图中存在环路;如果没有剩余,说明有向图不含环路。

  • 可以发现BFS要判断是否有环,就需要知道每个节点的入度,因此引入一个入度数组存放每个节点的入度,在BFS过程中入度数组会实时更新。
算法设计
1、边缘列表转换为邻接表(同思路1)
2、对有向图的常规BFS要如何实现?

和树的BFS(即层次遍历)一样,使用一个队列保存当前节点的所有邻接节点,每次从队列中弹出队首,访问队首节点,然后把队首节点的所有邻接节点入队。

接着,继续弹出队首,以此类推,直到队列为空,说明BFS结束。

  • 注意:对于图而言,BFS结束并不代表图中所有节点都被遍历到。
3、如何用BFS判断有向图是否存在环路?(引入入度数组)

BFS判断是否存在环路,就需要用到拓扑排序的思想,常规的BFS入队的是当前节点的所有邻接节点,这里的BFS入队的是当前图中入度为0的节点。

所以需要设置一个入度数组indegrees存放每个节点的入度。

由此带来一个问题:入度数组的初始值如何设置?

入度数组的初始化时,边缘列表就可以利用起来了,例如:[0,1]表示1->0,这样就能得到0的入度+1。

BFS开始时,先将所有入度为0的节点都入队。

然后,弹出队首,按照拓扑排序的思想,将该节点从图中删除,但并不需要真的从图上删除,只需要将它的所有邻接点的入度-1,之后将入度为0的邻接点入队。

以此类推。

直到队列为空,说明遍历结束:

  • 如果此时删除的节点数 == numCourses,说明图被完全删除干净,图不包含环路,返回true。
  • 如果删除的节点数 < numCourses,说明图中还有未被删除的节点,即图中包含环路,返回false。
实现代码
class Solution {
    public boolean canFinish(int numCourses, int[][] prerequisites) {
        //创建邻接表
        List<List<Integer>> adjacency = new ArrayList<>();
        //初始化每个节点对应的邻接表,后面adjacency.get(i)表示课程i的邻接表
        for(int i = 0; i < numCourses; i++){
            adjacency.add(new ArrayList<Integer>());
        }
        //入度数组
        int[] indegrees = new int[numCourses];
        //将边缘列表转换为邻接表:边缘列表[v,u]对应u->v;入度数组初始化
        for(int i = 0; i < prerequisites.length; i++){
            //对于每个边缘列表元素[v,u]中将v的入度+1
            indegrees[prerequisites[i][0]]++;
            adjacency.get(prerequisites[i][1]).add(prerequisites[i][0]);
        }

        Queue<Integer> queue = new LinkedList<>();//BFS专用的队列
        //初始时将当前所有入度为0的节点入队
        for(int i = 0; i < numCourses; i++){
            if(indegrees[i] == 0) queue.offer(i);
        }
        int count = 0;//记录删除节点的次数
        //开始执行BFS
        while(!queue.isEmpty()){
            int n = queue.size();
            for(int i = 0; i < n; i++){
                int top = queue.poll();//队首节点
                count++;//每删除一个队首节点,计数器+1
                //将队首节点删除,则它的所有邻接点的入度-1
                for(int j = 0; j < adjacency.get(top).size(); j++){
                    int next = adjacency.get(top).get(j);//队首节点的邻接点
                    indegrees[next]--;//邻接点入度-1
                    //如果邻接点入度=0,将其加入队列中
                    if(indegrees[next] == 0) queue.offer(next);
                }
            }
        }
        //队列为空时,说明对有向图的BFS结束,但不一定遍历了图中所有节点,需要进一步判断
        return count == numCourses;//如果删除的节点数==图中节点数,说明图中不含环路
    }
}

210. 课程表 II(有向图寻找拓扑排序)

题目链接:https://leetcode-cn.com/problems/course-schedule-ii/


关键点/分类

  • 图(有向图:边缘列表转邻接表);
  • 有向图的遍历(BFS(队列));
  • 寻找有向图中的拓扑序列: BFS + 入度表

在这里插入图片描述

题目分析

这题和207.课程表类似,不同点在于207题只需要判断能否完成所有课程;本题需要给出一个正确的学习顺序,即给出一个拓扑排序。

但具体做法都是一样的,问题都可以转化为拓扑排序问题,解法包括:DFS、BFS,DFS的实现有更多细节问题,所以这里只介绍BFS。

BFS的实现和207基本相同,增加一个存放拓扑序列的一维数组res而已。

前置工作:

1、边缘列表转邻接表:
邻接表用List<List> adjacency 表示;
边缘列表的一个元素为[1,0]表示的匹配关系是0->1,所以1是0的一个邻接点,要加入0的邻接表。

2、对图做BFS的常规实现

更具体的分析见207的解析,下面直接介绍解题思路。

思路1:BFS + 入度表 (拓扑排序思想)

1、使用一个队列实现BFS,设置一个一维数组res存放正确的课程学习顺序

2、创建一个数组indegrees存放每个节点的入度数,数组的初始化要依赖边缘列表。

3、在BFS之前,先将所有入度为0的节点加入队列。
进入BFS:

弹出队首,相当于从图上删除该节点,就将它加入到res中,然后将它所有邻接点的入度都-1,如果邻接点的入度 == 0就将该邻接点也入队。后面的流程以此类推。

直到队列为空,判断加入res的元素个数是否 == numCourses,如果等于,说明得到一个正确的顺序,如果不等于,则返回res。

  • BFS拓扑排序可以解决用例:2,[],的情况,[]表示没有给出课程匹配关系,即课程之间没有先后顺序,先读谁都行。
知识点:如何返回空数组

返回空的一维数组:

return new int[0];

因为一旦开辟的数组大小>0,元素就会默认填充0,而不是空,所以大小要填0才能得到空数组。

实现代码
class Solution {
    
    public int[] findOrder(int numCourses, int[][] prerequisites) {
        int[] res = new int[numCourses];//存放一个正确学习顺序的数组
        int num = 0;//记录加入res的课程数
        //邻接表 + 初始化
        List<List<Integer>> adjacency = new ArrayList<>();
        for(int i = 0; i < numCourses; i++){
            adjacency.add(new ArrayList<Integer>());
        }
        //边缘列表转邻接表
        for(int i = 0; i < prerequisites.length; i++){
            adjacency.get(prerequisites[i][1]).add(prerequisites[i][0]);
        }
        //入度表:[0,1]表示1->0,0入度+1
        int[] indegrees = new int[numCourses];
        for(int i = 0; i < prerequisites.length; i++){
            indegrees[prerequisites[i][0]]++;
        }

        //BFS需要的队列,将所有入度为0的点入队
        Queue<Integer> queue = new LinkedList<>();
        for(int i = 0; i < numCourses; i++){
            if(indegrees[i] == 0) queue.offer(i);
        }
        while(!queue.isEmpty()){
            int n = queue.size();
            for(int i = 0; i < n; i++){
                int top = queue.poll();
                res[num++] = top;
                //将所有邻接点的入度-1
                for(int j = 0; j < adjacency.get(top).size(); j++){
                    int next = adjacency.get(top).get(j);
                    indegrees[next]--;
                    if(indegrees[next] == 0) queue.offer(next);
                 }
            }
        }

        return num == numCourses ? res : new int[0];
    }

}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值