Android程序员会遇到的算法(part 7 拓扑排序)

1字数 2248阅读 310

这一期是我打算做的安卓算法面试系列的最后一期了,一来是自从来了美国之后,每天的工作实在太忙了,除了周末之外很少时间能完完整整的总结一些东西。不过第二个原因,也是最重要的原因,就是在这之后我打算好好沉淀积累一下,等有更多的心得体会再分享出来。

这期我打算聊一聊拓扑排序这个算法。在Java里面具体的实现和一些细节。这里我尽量不用太多的专业术语,用比较通俗的讲法来解释一些概念。(其实是我的狗嘴也吐不出啥象牙。。。以前学的算法知识早就还给老师了)

d2fce9868ad44bb98cb89ae4d780c369_th.jpg

其实拓扑排序和广度优先搜索算法在代码上真的很像,说穿了其实就是图的遍历,只不过遍历的顺序和规则有些少许不同。

相信各位学习计算机科学专业的同学应该都对高等数学或者大学物理有深刻的阴影。。。我还记得我当时考完大学物理2已经觉得自己要挂了,没忍住给老师打了一个电话求情,虽然最后老师说我离挂科还远,但是69分的大学物理2也让我与那个学期的奖学金无缘了。

download (1).jpeg

可能有人问为什么计算机专业不直接学Java,C++或者web开发?一定要先上大学物理或者高等数学?说了这么多废话,我想说的重点是,每个学科都有一个自己的课程安排,学习一门专业课之前必须要有一些基础课程的支撑才行。我们不能不学高等数学和线性代数直接跳去学机器学习,我们也不能不学Java或者python直接上手web项目。这也引申出了这一期的内容,拓扑排序, 怎么样在已知某些节点的前序(prerequisites) 节点的情况下,把这些节点的顺序排列出来。就好比,我知道一定课程的前后顺序的情况下,把我这四年大学的课程时间安排排列出来,最后打印成课程表。

Screen Shot 2019-04-06 at 12.12.31 PM.png

比如上面这幅图,我们怎么可以将其课程的依赖关系,按照先后顺利排列起来,这就是拓扑排序可以解决的其中一种,也是最经典的问题。


1.怎么定义数据结构

首先对于图来说,我们要知道每个节点有多少子节点,也就是后继节点,在课程安排例子里面可以理解为,学了A课程之后可以学的课程B。那么A就是B的前驱节点,B就是A的后继节点。
在Java中我们可以使用HashMap来实现,根据题目的不同,有时候也可以使用别的数据结构比如二维数组。不过我个人比较喜欢HashMap。

那么节点的关系可以用一个HashMap来表达,课程使用String 来表示

//节点的后继节点
HashMap<String, HashSet<String>>  courses = new HashMap();

同时,在拓扑排序中,我们还需要记录某个节点的前驱节点的数量,因为只有当某个节点的前驱节点为0的时候,我们才能处理该节点。对应到课程学习中,就是只有当我们学习完毕了某个课程的所有前驱课程,我们才能学习该课程。比如图中的计算机网络课程,需要先学习组成原理和通信原理一样。

//记录每个点的前驱节点数量
HashMap<String,Integer> preCount = new HashMap<String,Integer>

2.拓扑排序

假设我们已经有了这两个数据结构并且数据已经填充好了。我们就可以开始进行拓扑排序了。算法很简单,把前驱节点数量为0的节点先放入队列,每次从队列弹出的时候把自己的后继节点的preCount数量减少1,假如此时后继节点的preCount数量减少到0了,就把节点加入到队列中。在这个例子里面,弹出一个节点的意义就是学习一门课程。

这个很好理解,比如我们学习完组成原理,距离学习计算机网络还差一门课。

Screen Shot 2019-04-06 at 1.10.59 PM.png

当我们把通信原理学习完毕之后,计算机网络的前驱节点数量从1减少为0,我们才可以学习计算机网络。

用代码来表示的话,如下

//课程调度队列
        Queue<String> queue = new LinkedList<>();
        //最后课程的顺序
        List<String> sequence = new ArrayList<>();
        while (!queue.isEmpty()) {
            //获取当前队列中的第一个课程,将其加入到最后的课程顺序列表中
            String currentCourse = queue.poll();
            sequence.add(currentCourse);
            
            //每当一个课程结束学习之后,找到它的后继课程
            for (String course : courses.get(currentCourse)) {
                //加入后继课程的前驱节点数量还是大于0 的,说明该课程还没被学习
                if (preCount.get(course) > 0) {
                    //减少该后继课程的前驱节点数量
                    preCount.put(course, preCount.get(course) - 1);
                    //如果前去梳理减到0,说明我们已经可以开始学习该课程了,
                    //加到队列里面
                    if (preCount.get(course) == 0) {
                        queue.add(course);
                    }
                }
            }

        }
       return sequence;

3.和广度优先的不同

其实看代码大家也可以知道,拓扑排序其实就是广度优先搜索的一种,只不过拓扑排序在插入子节点到队列的时候,有一些限制。就是在这里:

 if (preCount.get(course) == 0) {
                        queue.add(course);
                    }

一般的广度优先只要遍历了当前节点,就要把当前节点的所有自己点都一股脑的插入到队列中。在拓扑排序里面,因为每个节点的前驱节点数量可能会大于1,所以,不能简单的插入子节点(或者说后继节点),而是需要额外的数据结构,preCount这个HashMap来决定是否可以把后继节点插入。

4.有环?

图搜索的一个经典问题是,如果有环怎么办?同样的,在拓扑排序里面,也可能出现存在环的情况。比如


Screen Shot 2019-04-06 at 1.26.07 PM.png

在下图这种情况,学生就没办法学了。。。。


download.jpeg

但是在拓扑排序下面,判断是否有环的方法还不太一样,比如宽度优先搜索的情况下,我们可以用一个叫visited的HashSet来记录已经访问过的节点。但是拓扑排序不行。

比如下图这种情况


Screen Shot 2019-04-06 at 1.32.26 PM.png

当我们学习完A之后,其实我们是不能遍历完全所有节点的,因为B和C的前驱节点数量都为1,程序在跑完第一个循环

  while (!queue.isEmpty()) {
            //获取到A
            String currentCourse = queue.poll();
            sequence.add(currentCourse);

之后,就会直接结束了。
所以其实我们判断环的方法要换成->判断我们是否能学习完所有课程。

HashMap<String, HashSet<String>>  courses = new HashMap();
//假如最后我们能学习完所有课程
if(result.size() == course.keySet().size()){
     return true;
}else{
     return false;
}

5.应用的范围

拓扑排序的题目可以出现很多种,但是都是万变不离其宗,掌握好我们需要的数据结构,熟练的写出广度优先算法的模板代码, 其实就万事大吉了。以后比如还有类似的问题,像安装软件,比如要安装A,要先安装依赖C,等等之类的问题,相信大家都可以迎刃而解了。总结的来讲,一旦我们发现需要进行对依赖之间进行排序的,用拓扑排序都没毛病。

6.题目代码

Leetcode 里面的Course Schedule, 大家可以自己练习一下。
我没有讲的部分就是数据初始化的部分,不过很简单,大家自己摸索。
我的答案

public int[] findOrder(int numCourses, int[][] prerequisites) {
        // record dependecy counts
        HashMap<Integer, Integer> dependeciesCount = new HashMap<>();
        HashMap<Integer, HashSet<Integer>> dependeciesRelation = new HashMap<>();
        for (int i = 0; i < numCourses; i++) {
            dependeciesCount.put(i, 0);
            dependeciesRelation.put(i, new HashSet<>());
        }
        for (int i = 0; i < prerequisites.length; i++) {
            int pre = prerequisites[i][1];
            int suf = prerequisites[i][0];
            dependeciesCount.put(suf, dependeciesCount.get(suf) + 1);
            dependeciesRelation.get(pre).add(suf);
        }
        Queue<Integer> queue = new LinkedList<>();
        for (Map.Entry<Integer, Integer> entry : dependeciesCount.entrySet()) {
            if (entry.getValue() == 0) {
                queue.add(entry.getKey());
            }
        }

        int[] index = new int[numCourses];
        int currentIndex = 0;
        while (!queue.isEmpty()) {
            Integer currentCourse = queue.poll();
            index[currentIndex] = currentCourse;
            currentIndex++;

            for (Integer nei : dependeciesRelation.get(currentCourse)) {
                if (dependeciesCount.get(nei) > 0) {
                    dependeciesCount.put(nei, dependeciesCount.get(nei) - 1);
                    if (dependeciesCount.get(nei) == 0) {
                        queue.add(nei);
                    }
                }
            }

        }

        int[] empty = {};
        return currentIndex == numCourses ? index : empty;
    }

后记

最后一期算法教程写完了,其实感觉如果大家能把这7个大块给充分理解,面对大部分的公司的算法面试其实也没多大问题了。这也是我2017年-2018年初面试各个公司的算法题的一些心得体会。
虽然我的标题一直都是以面试 开头,但是我觉得最重要的还是学习,或者说是复习算法的这个过程。去理解去学习的这个过程才是精髓。当然,这些内容也是上学就应该学好的,现在重新复习,也算是还债(technical debt)。。。。
回头看这个系列的初衷,也是希望大家在面对面试的同时,能回顾一些以前上学时候的知识,做到温故而知新。只要读者看了我的文章,能发出一种“挖槽这个以前好像学过啊”的感叹,我也就满足了~

2019年对我来说是一个新的起点,我也要不停的督促自己好好工作,多反思多学习,以后争取能分享更多高质量的文章和知识。希望自己永远不要忘掉当初雄心壮志面试硅谷公司的那颗赤子之心。

推荐阅读更多精彩内容