拓扑排序的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𝑉)

posted @ 2021-12-14 20:45  r1-12king  阅读(668)  评论(0编辑  收藏  举报