前言
你们好,这里是《齐姐聊算法》系列之拓扑排序问题。java
Topological sort 又称 Topological order,这个名字有点迷惑性,由于拓扑排序并非一个纯粹的排序算法,它只是针对某一类图,找到一个能够执行的线性顺序。node
这个算法听起来高大上,现在的面试也很爱考,好比当时我在面我司时有整整一轮是基于拓扑排序的设计。git
但它实际上是一个很好理解的算法,跟着个人思路,让你不再会忘记她。github
有向无环图
刚刚咱们提到,拓扑排序只是针对特定的一类图,那么是针对哪类图的呢?面试
答:Directed acyclic graph (DAG),有向无环图。即:算法
- 这个图的边必须是有方向的;
- 图内无环。
那么什么是方向呢?数组
好比微信好友就是有向的,你加了他好友他可能把你删了你殊不知道。。。那这个朋友关系就是单向的。。微信
什么是环?环是和方向有关的,从一个点出发能回到本身,这是环。网络
因此下图左边不是环,右边是。数据结构
那么若是一个图里有环,好比右图,想执行 1 就要先执行 3,想执行 3 就要先执行 2,想执行 2 就要先执行 1,这成了个死循环,没法找到正确的打开方式,因此找不到它的一个拓扑序。
总结:
- 若是这个图不是 DAG,那么它是没有拓扑序的;
- 若是是 DAG,那么它至少有一个拓扑序;
- 反之,若是它存在一个拓扑序,那么这个图一定是 DGA.
因此这是一个充分必要条件。
拓扑排序
那么这么一个图的「拓扑序」
是什么意思呢?
咱们借用百度百科的这个课程表来讲明。
课程代号 | 课程名称 | 先修课程 |
---|---|---|
C1 | 高等数学 | 无 |
C2 | 程序设计基础 | 无 |
C3 | 离散数学 | C1, C2 |
C4 | 数据结构 | C3, C5 |
C5 | 算法语言 | C2 |
C6 | 编译技术 | C4, C5 |
C7 | 操做系统 | C4, C9 |
C8 | 普通物理 | C1 |
C9 | 计算机原理 | C8 |
这里有 9 门课程,有些课程是有先修课程的要求的,就是你要先学了「最右侧这一栏要求的这个课」才能再去选「高阶」的课程。
那么这个例子中拓扑排序的意思就是:
就是求解一种可行的顺序,可以让我把全部课都学了。
那怎么作呢?
首先咱们能够用图
来描述它,
图的两个要素是顶点和边
,
那么在这里:
- 顶点:每门课
- 边:起点的课程是终点的课程的先修课
画出来长这个样:
这种图叫 AOV (Activity On Vertex) 网络,在这种图里:
- 顶点:表示活动;
- 边:表示活动间的前后关系
因此一个 AOV 网应该是一个 DAG,即有向无环图,不然某些活动会没法进行。
<span style="display:block;color:orangered;">那么全部活动能够排成一个可行线性序列,这个序列就是拓扑序列
。
那么这个序列的实际意义
是:
按照这个顺序,在每一个项目开始时,可以保证它的前驱活动都已完成,从而使整个工程顺利进行。
回到咱们这个例子中:
-
咱们一眼能够看出来要先学 C1, C2,由于这两门课没有任何要求嘛,大一的时候就学呗;
-
大二就能够学第二行的 C3, C5, C8 了,由于这三门课的先修课程就是 C1, C2,咱们都学完了;
-
大三能够学第三行的 C4, C9;
-
最后一年选剩下的 C6, C7。
这样,咱们就把全部课程学完了,也就获得了这个图的一个拓扑排序
。
注意,有时候拓扑序并非惟一的,好比在这个例子中,先学 C1 再学 C2,和先 C2 后 C1 都行,都是这个图的正确的拓扑序,但这是两个顺序了。
因此面试的时候要问下面试官,是要求解任意解,仍是列出全部解。
咱们总结一下,
在这个图里的边
表示的是一种依赖关系
,若是要修下一门课,就要先把前一门课修了。
这和打游戏里同样同样的嘛,要拿到一个道具,就要先作 A 任务,再完成 B 任务,最终终于能到达目的地了。
算法详解
在上面的图里,你们很容易就看出来了它的拓扑序,但当工程愈来愈庞大时,依赖关系也会变得错综复杂,那就须要用一种系统性的方式方法来求解了。
那么咱们回想一下刚刚本身找拓扑序的过程,为何咱们先看上了 C1, C2?
由于它们没有依赖别人啊,
也就是它的入度为 0
.
入度:顶点的入度是指「指向该顶点的边」的数量;
出度:顶点的出度是指该顶点指向其余点的边的数量。
因此咱们先执行入度为 0 的那些点,
那也就是要记录每一个顶点的入度。
由于只有当它的 入度 = 0
的时候,咱们才能执行它。
在刚才的例子里,最开始 C1, C2 的入度就是 0,因此咱们能够先执行这两个。
那在这个算法里第一步就是获得每一个顶点的入度。
Step0: 预处理获得每一个点的入度
咱们能够用一个 HashMap 来存放这个信息,或者用一个数组
会更精巧。
在文中为了方便展现,我就用表格了:
C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | |
---|---|---|---|---|---|---|---|---|---|
入度 | 0 | 0 | 2 | 2 | 1 | 2 | 2 | 1 | 1 |
Step1
拿到了这个以后,就能够执行入度为 0 的这些点了,也就是 C1, C2.
那咱们把能够被执行的这些点,放入一个待执行的容器
里,这样以后咱们一个个的从这个容器里取顶点就行了。
至于这个容器
究竟选哪一种数据结构
,这取决于咱们须要作哪些操做
,再看哪一种数据结构能够为之服务。
那么首先能够把[C1, C2]
放入容器
中,
而后想一想咱们须要哪些操做吧!
咱们最常作的操做无非就是把点放进来
,把点拿出去
执行了,也就是须要一个 offer
和 poll
操做比较高效的数据结构,那么 queue
就够用了。
(其余的也行,放进来这个容器里的顶点的地位都是同样的,都是能够执行的,和进来的顺序无关,但何须非得给本身找麻烦呢?一个常规顺序的简简单单的 queue 就够用了。)
而后就须要把某些点拿出去执行了。
【划重点】当咱们把 C1 拿出来执行,那这意味这什么?
<span style="display:block;color:blue;">答:意味着「以 C1 为顶点」的「指向其余点」的「边」都消失了,也就是 C1 的出度变成了 0.
以下图,也就是这两条边能够消失了。
那么此时咱们就能够更新 C1 所指向的那些点
也就是 C3 和 C8
的 入度
了,更新后的数组以下:
C3 | C4 | C5 | C6 | C7 | C8 | C9 | |
---|---|---|---|---|---|---|---|
入度 | 1 | 2 | 1 | 2 | 2 | <span style="display:block;color:blue;">0 | 1 |
<span style="display:block;color:blue;">那咱们这里看到很关键的一步,C8 的入度变成了 0!
也就意味着 C8 此时没有了任何依赖,能够放到咱们的 queue 里等待执行了。
此时咱们的 queue
里就是:[C2, C8]
.
Step2
下一个咱们再执行 C2,
那么 C2 所指向的
C3, C5
的 入度-1
,
更新表格:
C3 | C4 | C5 | C6 | C7 | C9 | |
---|---|---|---|---|---|---|
入度 | <span style="display:block;color:blue;">0 | 2 | <span style="display:block;color:blue;">0 | 2 | 2 | 1 |
也就是 C3 和 C5 都没有了任何束缚,能够放进 queue 里执行了。
queue
此时变成:[C8, C3, C5]
Step3
那么下一步咱们执行 C8,
相应的 C8 所指的 C9 的入度-1.
更新表格:
C4 | C6 | C7 | C9 | |
---|---|---|---|---|
入度 | 2 | 2 | 2 | <span style="display:block;color:blue;">0 |
那么 C9 没有了任何要求,能够放进 queue 里执行了。
queue
此时变成:[C3, C5, C9]
Step4
接下来执行 C3,
相应的 C3 所指的 C4 的入度-1.
更新表格:
C4 | C6 | C7 | |
---|---|---|---|
入度 | <span style="display:block;color:blue;">1 | 2 | 2 |
<span style="display:block;color:blue;">可是 C4 的入度并无变成 0,因此这一步没有任何点能够加入 queue.
queue
此时变成 [C5, C9]
Step5
再执行 C5,
那么 C5 所指的 C4 和 C6 的入度- 1.
更新表格:
C4 | C6 | C7 | |
---|---|---|---|
入度 | <span style="display:block;color:blue;">0 | <span style="display:block;color:blue;">1 | 2 |
这里 C4 的依赖全都消失啦,那么能够把 C4 放进 queue 里了:
queue
= [C9, C4]
Step6
而后执行 C9,
那么 C9 所指的 C7 的入度- 1.
C6 | C7 | |
---|---|---|
入度 | <span style="display:block;color:blue;">1 | <span style="display:block;color:blue;">1 |
这里 C7 的入度并不为 0,还不能加入 queue,
此时 queue
= [C4]
Step7
接着执行 C4,
因此 C4 所指向的 C6 和 C7 的入度-1,
更新表格:
C6 | C7 | |
---|---|---|
入度 | <span style="display:block;color:blue;">0 | <span style="display:block;color:blue;">0 |
C6 和 C7 的入度都变成 0 啦!!把它们放入 queue,继续执行到直到 queue 为空便可。
总结
好了,那咱们梳理一下这个算法:
<span style="display:block;color:blue;">数据结构 这里咱们的入度表格能够用 map 来存放,关于 map 还有不清楚的同窗能够看以前我写的 HashMap 的文章哦~
Map: <key = Vertex, value = 入度>
但实际代码中,咱们用一个 int array 来存储也就够了,graph node 能够用数组的 index 来表示,value 就用数组里的数值来表示,这样比 Map 更精巧。
而后用了一个普通的 queue,用来存放能够被执行的那些 node.
<span style="display:block;color:blue;">过程 咱们把入度为 0 的那些顶点放入 queue 中,而后经过每次执行 queue 中的顶点,就可让依赖这个被执行的顶点的那些点的 入度-1
,若是有顶点的入度变成了 0,就能够放入 queue 了,直到 queue 为空。
<span style="display:block;color:blue;">细节 这里有几点实现上的细节:
当咱们 check 是否有新的顶点的 入度 == 0 时,不必过一遍整个 map 或者数组,只须要 check 刚刚改动过的就行了。
另外一个是若是题目没有给这个图是 DAG 的条件的话,那么有多是不存在可行解的,那怎么判断呢?很简单的一个方法就是比较一下最后结果中的顶点的个数和图中全部顶点的个数是否相等,或者加个计数器,若是不相等,说明就不存在有效解。因此这个算法也能够用来判断一个图是否是有向无环图。
不少题目给的条件多是给这个图的 edge list
,也是表示图的一种经常使用的方式。那么给的这个 list
就是表示图中的边
。这里要注意审题哦,看清楚是谁 depends on 谁。其实图的题通常都不会直接给你这个图,而是给一个场景,须要你把它变回一个图。
<span style="display:block;color:blue;">时间复杂度
注意 ⚠️:对于图的时间复杂度分析必定是两个参数,面试的时候不少同窗张口就是 O(n)...
对于有 v 个顶点和 e 条边的图来讲,
第一步,预处理获得 map 或者 array,须要过一遍全部的边才行,因此是 O(e);
第二步,把 入度 == 0 的点入队出队的操做是 O(v),若是是一个 DAG,那全部的点都须要入队出队一次;
第三步,每次执行一个顶点的时候,要把它指向的那条边消除了,这个总共执行 e 次;
总:O(v + e)
<span style="display:block;color:blue;">空间复杂度
用了一个数组来存全部点的 indegree,以后的 queue 也是最多把全部的点放进去,因此是 O(v).
<span style="display:block;color:blue;">代码
关于这课程排序的问题,Leetcode 上有两道题,一道是 207,问你可否完成全部课程,也就是问拓扑排序是否存在;另外一道是 210 题,是让你返回任意一个拓扑顺序,若是不能完成,那就返回一个空 array。
这里咱们以 210 这道题来写,更完整也更常考一些。
这里给的 input 就是咱们刚刚说到的 edge list
.
Example 1.
Input: 2, [[1,0]]
Output: [0,1]
Explanation: 这里一共 2 门课,1 的先修课程是 0. 因此正确的选课顺序是[0, 1].
Example 2.
Input: 4, [[1,0],[2,0],[3,1],[3,2]]
Output: [0,1,2,3] or [0,2,1,3]
Explanation:这里这个例子画出来以下图
Example 3.
Input: 2, [[1,0],[0,1]]
Output: null
Explanation: 这课无法上了
class Solution { public int[] findOrder(int numCourses, int[][] prerequisites) { int[] res = new int[numCourses]; int[] indegree = new int[numCourses]; // get the indegree for each course for(int[] pre : prerequisites) { indegree[pre[0]] ++; } // put courses with indegree == 0 to queue Queue<Integer> queue = new ArrayDeque<>(); for(int i = 0; i < numCourses; i++) { if(indegree[i] == 0) { queue.offer(i); } } // execute the course int i = 0; while(!queue.isEmpty()) { Integer curr = queue.poll(); res[i++] = curr; // remove the pre = curr for(int[] pre : prerequisites) { if(pre[1] == curr) { indegree[pre[0]] --; if(indegree[pre[0]] == 0) { queue.offer(pre[0]); } } } } return i == numCourses ? res : new int[]{}; } }
另外,拓扑排序还能够用 DFS - 深度优先搜索
来实现,限于篇幅就不在这里展开了,你们能够参考GeeksforGeeks的这个资料。
实际应用
咱们上文已经提到了它的一个 use case,就是选课系统,这也是最常考的题目。
而拓扑排序最重要的应用就是关键路径问题
,这个问题对应的是 AOE (Activity on Edge) 网络。
AOE 网络:顶点表示事件,边表示活动,边上的权重来表示活动所须要的时间。
AOV 网络:顶点表示活动,边表示活动之间的依赖关系。
在 AOE 网中,从起点到终点具备最大长度的路径称为关键路径,在关键路径上的活动称为关键活动。AOE 网络通常用来分析一个大项目的工序,分析至少须要花多少时间完成,以及每一个活动能有多少机动时间。
具体是怎么应用分析的,你们能够参考这个视频 的 14 分 46 秒,这个例子仍是讲的很好的。
其实对于任何一个任务之间有依赖关系的图,都是适用的。
好比 pom 依赖引入 jar 包时,你们有没有想过它是怎么导进来一些你并无直接引入的 jar 包的?好比你并无引入 aop 的 jar 包,但它自动出现了,这就是由于你导入的一些包是依赖于 aop 这个 jar 包的,那么 maven 就自动帮你导入了。
其余的实际应用,好比说:
- 语音识别系统的预处理;
- 管理目标文件之间的依赖关系,就像我刚刚说的 jar 包导入;
- 深度学习中的网络结构处理。
若有其余补充,欢迎你们在评论区不吝赐教。
以上就是本文的所有内容了,拓扑排序是很是重要也是很是爱考的一类算法,面试大厂前必定要熟练掌握。
若是你喜欢这篇文章,记得给我点赞留言哦~大家的支持和承认,就是我创做的最大动力,咱们下篇文章见!
我是小齐,纽约程序媛,终生学习者,天天晚上 9 点,云自习室里不见不散!
更多干货文章见个人 Github: https://github.com/xiaoqi6666/NYCSDE