拓扑排序的Kahn算法和DFS的深搜
DAG图和拓扑排序(Topological sorting)
一个无环的有向图称为有向无环图(DAG)。图的顶点可以表示要执行的任务,并且边可以表示一个任务必须在另一个之前执行的约束; 在这个应用程序中,拓扑排序只是任务的有效序列。 当且仅当图形没有有向循环时,即如果它是有向无环图(DAG),则可以进行拓扑排序。 任何DAG都具有至少一个拓扑排序。
在计算机科学领域,有向图的拓扑排序或拓扑排序是其顶点的线性排序。在图中假设从顶点 i 到顶点 j 中有一条有向路径 i -> j。那么我们称 i 为 j 的前驱,称 j 为 i 的后继。
定义:
对于一个DAG(有向无环图)𝐺,将 𝐺 中所有顶点排序为一个线性序列,使得图中任意一对顶点 𝑢 和 𝑣,若 𝑢 和 𝑣 之间存在一条从 𝑢 指向 𝑣 的边,那么 𝑢 在线性序列中一定在 𝑣 前。
注意:DAG的拓扑序可能并不唯一。只要满足对于(u->v),在线性序列中,u在v前面即可
用途:
- 判环
- 判链
- 处理依赖性任务规划问题
- 用在项目管理、 数据库查询优化和矩阵乘法的次序优化上。
Kahn算法
拓扑排序的算法非常简单,常用的方式为Kahn’s algorithm算法,Kahn算法有时候也叫做toposort的bfs版本。基本步骤为:
- 将入度为 0 的点组成一个集合 𝑆
- 从 𝑆 中取出一个顶点 𝑢,插入拓扑序列。
- 遍历顶点 𝑢 的所有出边,并全部删除,如果删除这条边后对方的点入度为 0,也就是没删前,𝑢→𝑣 这条边已经是 𝑣 的最后一条入边,那么就把 𝑣 插入 𝑆。
- 重复执行上两个操作,直到 𝑆=∅。此时检查拓扑序列是否正好有 𝑛 个节点,不多不少。如果拓扑序列中的节点数比 𝑛 少,说明 𝐺 非DAG,无拓扑序,返回false。如果拓扑序列中恰好有 𝑛 个节点,说明 𝐺 是DAG,返回拓扑序列。
也就是说,Kahn算法的核心就是维护一个入度为0的顶点。
代码如下:
1 def topoSort(graph, ind): 2 topo = [] 3 queue = [] 4 5 for i in range(len(ind)): 6 if ind[i] == 0: 7 queue.append(i) 8 9 while queue: 10 node = queue.pop(0) 11 topo.append(node) 12 for i in range(len(graph[node])): 13 v = graph[node][i] 14 ind[v] -= 1 15 if ind[v] == 0: 16 queue.append(v) 17 18 if len(topo) == len(ind): 19 print(topo) 20 return True 21 return False 22 23 24 if __name__ == '__main__': 25 data = [ 26 [0, 1], 27 [0, 2], 28 [1, 2], 29 [1, 3], 30 [2, 3], 31 [2, 5], 32 [3, 4], 33 [7, 6], 34 ] 35 n = 8 36 ind = [0 for _ in range(n)] 37 graph = [[] for _ in range(n)] 38 for u, v in data: 39 graph[u].append(v) 40 ind[v] += 1 41 42 topoSort(graph, ind)
利用DFS实现拓扑排序
当一个有向图无环的时候,我们可以利用DFS算法来实现拓扑排序。原理很简单,由于图中没有环,那么由图中某点出发的时候,最先退出DFS的顶点一定是出度为0的顶点,也就是拓扑排序中最后的一个顶点(逆向思维)。因此按DFS退出的先后记录下的顶点序列就是逆向的拓扑排序的序列。
从任意一个未被访问的结点出发做深搜后序遍历。遍历所有结点,回溯前记录结点,最后路径再倒序一下就是正确的拓扑排序(或者建图的时候就把边的方向倒了,最后得到的排序不用倒)。如果有多个子图,要多次深搜,直到所有结点都被访问完(所有子图都搜完)得到多个子序列,再拼接一起就是答案。
代码如下:
1 def dfs(node, graph, vis, order): 2 vis[node] = True 3 for n in graph[node]: 4 if not vis[n]: 5 dfs(n, graph, vis, order) 6 order.append(node) 7 8 9 def topoDfsSort(graph): 10 order = [] 11 vis = [False for _ in range(n)] 12 for i in range(n): 13 if not vis[i]: 14 dfs(i, graph, vis, order) 15 print(order[::-1]) 16 17 18 if __name__ == '__main__': 19 data = [ 20 [0, 1], 21 [0, 2], 22 [1, 2], 23 [1, 3], 24 [2, 3], 25 [2, 5], 26 [3, 4], 27 [7, 6], 28 ] 29 n = 8 30 ind = [0 for _ in range(n)] 31 graph = [[] for _ in range(n)] 32 for u, v in data: 33 graph[u].append(v) 34 ind[v] += 1 35 36 topoDfsSort(graph)
对于DFS方法有人可能疑惑为什么要回溯前才记录(要用后序遍历的原因),而且为什么可以从任一点开始?
- 能回溯的点说明已经把子代遍历完,确定是最后的了,于是可以记录下来,得到一个倒序记录。拓扑排序的一个节点可能有多个父结点,所以无法确定某点为先。如下图,如果我已遍历得0–>2顺序,但是在2之前还有一个点1,明显不对,先序遍历行不通。这是由结构所决定的,不同于树,拓扑排序分支节点之间可能有联系的,导致一个节点可能有多个父结点,故没有严格的先后顺序。而从后往前记录,因为节点遍历完分支,故不怕节点再指向任何节点也就不可能指回前面节点,后序遍历可行。如果要对一个二叉树做拓扑排序,代码与此大同小异,二叉树只有两个子结点,无需for循环而已。
- 从任意点开始遍历都行,是因为后序遍历保证了已记录的就是最靠后的了。后面记录的结点肯定不会后于已记录的结点。比如先从3开始搜,3->4(记录下4->3),然后无论从哪个结点再开始搜,都不会打乱顺序了,012都是先于3(可能的记录下为4->3->5->2->1->0->6->7),567放3前面也没事(可能的记录为4->3->6->7->5->2->1->0)。
拓扑排序实现的时间复杂度
Kahn算法和dfs算法的时间复杂度都为 O(𝐸+𝑉)。
另外,如果要求字典序最小或最大的拓扑序,只需要将Kahn算法中的q队列替换为优先队列即可,总时间复杂度为 O(𝐸+𝑉log𝑉)。