Java面试17-算法与数据结构-拓扑排序

228 阅读7分钟

你这个学期必须选修 numCourses 门课程,记为 0 到 numCourses - 1 在选修某些课程之前需要一些先修课程。 先修课程按数组 prerequisites 给出,其中 prerequisites[i] = [ai, bi] ,表示如果要学习课程 ai 则 必须 先学习课程 bi 。 例如,先修课程对 [0, 1] 表示:想要学习课程 0 ,你需要先完成课程 1 。 请你判断是否可能完成所有课程的学习?如果可以,返回 true ;否则,返回 false 。

boolean canFinish(int numCourses, int[][] prerequisites);

看到依赖问题,首先想到的就是把问题转化成「有向图」这种数据结构,只要图中存在环,那就说明存在循环依赖
具体来说,我们首先可以把课程看成「有向图」中的节点,节点编号分别是 0, 1, ..., numCourses-1,把课程之间的依赖关系看做节点之间的有向边。
比如说必须修完课程 1 才能去修课程 3,那么就有一条有向边从节点 1 指向 3
所以我们根据题目输入的 prerequisites 数组生成图
如果发现这幅有向图中存在环,那就说明课程之间存在循环依赖,肯定没办法全部上完;反之,如果没有环,那么肯定能上完全部课程

  • 首先我们要把题目的输入转化成一幅有向图
  • 然后再判断图中是否存在环。

如何转换为图呢?

邻接矩阵和邻接表
常用的为邻接表

     List<Integer>[] graph;
  • 写一个建图函数
List<Integer>[] buildGraph(int numCourses, int[][] prerequisites) { 
    // 图中共有 numCourses 个节点  代表着有多少门课,创建一个表
    List<Integer>[] graph = new LinkedList[numCourses]; 
    
    for (int i = 0; i < numCourses; i++) {
        graph[i] = new LinkedList<>(); 
        } 
        
    for (int[] edge : prerequisites) { 
        int from = edge[1], to = edge[0]; 
         // 添加一条从 from 指向 to 的有向边 // 边的方向是「被依赖」关系,即修完课程 from 才能修课程 to 
        graph[from].add(to); 
        } 
    return graph; 
    }
  • 图建出来了,怎么判断图中有没有环呢?运用图的遍历DFS,无非就是从多叉树遍历框架扩展出来的,加了个 visited 数组罢了:
// 防止重复遍历同一个节点
boolean[] visited; 
// 从节点 s 开始 DFS 遍历,将遍历过的节点标记为 true 
void traverse(List<Integer>[] graph, int s) {
    if (visited[s]) { 
        return; 
    }
    /* 前序遍历代码位置 */
    // 将当前节点标记为已遍历 
    visited[s] = true; 
    for (int t : graph[s]) {
            traverse(graph, t); 
        } 
    /* 后序遍历代码位置 */ 
 }

注意图中并不是所有节点都相连,所以要用一个 for 循环将所有节点都作为起点调用一次 DFS 搜索算法。

  • 如何判断有环? 把 traverse 看做在图中节点上游走的指针,只需要再添加一个布尔数组 onPath 记当前 traverse 经过的路径
    这里就有点回溯算法的味道了,在进入节点 s 的时候将 onPath[s] 标记为 true,离开时标记回 false,如果发现 onPath[s] 已经被标记,说明出现了环。
    注意 visited 数组和 onPath 数组的区别,因为二叉树算是特殊的图,所以用遍历二叉树的过程来理解下这两个数组的区别:
    类比贪吃蛇游戏,visited 记录蛇经过过的格子,而 onPath 仅仅记录蛇身。onPath 用于判断是否成环,类比当贪吃蛇自己咬到自己(成环)的场景。

  • 完整代码

// 记录一次递归堆栈中的节点 
boolean[] onPath; 
// 记录遍历过的节点,防止走回头路 
boolean[] visited;
// 记录图中是否有环 
boolean hasCycle = false;

boolean canFinish(int numCourses, int[][] prerequisites) {
    List<Integer>[] graph = buildGraph(numCourses, prerequisites);
    visited = new boolean[numCourses]; 
    onPath = new boolean[numCourses]; 
    for (int i = 0; i < numCourses; i++) { 
            // 遍历图中的所有节点 
            traverse(graph, i); 
        }
    // 只要没有循环依赖可以完成所有课程
    return !hasCycle; 
    }
    
 void traverse(List<Integer>[] graph, int s) {
     if (onPath[s]) { 
         // 出现环 
         hasCycle = true;
    } 
    if (visited[s] || hasCycle) { 
        // 如果已经找到了环,也不用再遍历了 return;
    } 
    // 前序代码位置
    visited[s] = true;
    onPath[s] = true;
    for (int t : graph[s]) { 
        traverse(graph, t);
    }
        // 后序代码位置
        onPath[s] = false; 
    } 
    List<Integer>[] buildGraph(int numCourses, int[][] prerequisites) {
    // 代码见前文
    }

补充图的理解:

例如,微信有数亿个用户,大部分用户只有几百个好友。可以用一个图表示微信用户的好友关系,每个用户是图中的一个节点,如果两个用户是好友那么他们的节点之间有一条边。

  • 如果用邻接矩阵表示这个图,每个用户在矩阵中对应一行,每行有数亿个格子,而且绝大多数格子的值都是0。
  • 如果用邻接表表示该图,那么一个用户有多少个好友,邻接表就只需要将多少个好友保存到他的好友列表中。
  • 总结:邻接表可很快的显示当前节点的关系

图的搜索

在图中搜索,如找出一条从起始节点到目标节点的路径或遍历所有节点,是与图相关的最重要的算法。
按照搜索顺序不同可以将搜索算法分为广度优先搜索深度优先搜索。\

BFS

实现广度优先搜索算法需要一个先进先出的队列

  • 搜索的第 1 步是把起始节点添加到队列中
  • 接下来每次从队列中取出一个节点,然后将与该节点相邻并且之前还没有到达过的节点添加到队列中
  • 重复这个过程,直到所有节点搜索完毕 *和层序遍历差不多

DFS

深度优先搜索算法沿着图中的边尽可能深入地搜索。

  • 深度优先搜索访问图中的某个起始节点 v1 后,**从节点v1 出发访问任一相邻并且尚未访问过的节点 v2,**再从节点 v2 出发访问相邻并且尚未访问过的节点 v3,以此类推。
  • 如果所有与某个节点 vi 相邻的节点都已经被访问,那么回到节点 vi 的前一个节点 vi-1,继续访问与节点 vi-1 相邻并且还没有访问过的节点。重复这个过程,直到所有节点都搜索完毕。

解题小经验

一般来说用 DFS 解决的问题都可以用BFS来解决

BFS=队列,入队列,出队列;DFS = 栈,压栈,出栈 (递归的写法和回溯差不多)

BFS 是按一层一层来访问的,所以适合求最短路径的步数,你想想层层搜索每进入一层就代表了一步。BFS 优先访问的是兄弟节点,只有这一层全部访问完才能访问下一层,也就是说 BFS 第几层就代表当前可以走到的位置(结点)。而 DFS 是按递归来实现的,它优先搜索深度,再回溯优先访问的是没有访问过的子节点。

DFS 多用于连通性问题,因为其运行思想与人脑的思维很相似,故解决连通性问题更自然。BFS多用于解决最短路径问题看到 “最短” 就要想到 BFS),其运行过程中需要储存每一层的信息,所以其运行时需要储存的信息量较大,如果人脑也可储存大量信息的话,理论上人脑也可运行 BFS
总的来说多数情况下运行 BFS 所需的内存会大于 DFS 需要的内存 (DFS一次访问一条路,BFS一次访问多条路),DFS 容易爆栈 (栈不易"控制"),BFS 通过控制队列可以很好解决 "爆队列" 风险。

它们两者间各自的优势需要通过实际的问题来具体分析,根据它们各自的特点来应用于不同的问题中才能获得最优的性能。

  • 树也可以看成图。实际上,树是一类特殊的图,树中一定不存在环。但图不一样,图中可能包含环。

  • 当沿着图中的边搜索一个图时,一定要确保程序不会因为沿着环的边不断在环中搜索而陷入死循环。

  • 避免死循环的办法是记录已经搜索过的节点在访问一个节点之前先判断该节点之前是否已经访问过,如果之前访问过那么这次就略过不再重复访问

  • 假设一个图有 v 个节点、e 条边。不管是采用广度优先搜索还是深度优先搜索,每个节点都只会访问一次,并且会沿着每条边判断与某个节点相邻的节点是否已经访问过,因此时间复杂度是 O(v+e)
    广度优先搜索使用邻接矩阵深度优先搜索使用逆邻接矩阵

20200107092104386.png
这样一个图邻接矩阵为:

1 -> 2 -> 3 -> null
2 -> 4 -> null 
3 -> 4 -> null
4 -> null

逆邻接矩阵为:

1 -> null
2 -> 1 -> null
3 -> 1 -> null
4 -> 2 -> 3 -> null