拓扑排序(topological sorting)介绍及Python实现

article/2025/10/1 7:55:54

目录

1. 拓扑排序

2. 拓扑排序存在的前提

3. 拓扑排序的唯一性问题

4. 拓扑排序算法原理

4.1 广度优先遍历

4.2 深度优先遍历

5. 代码实现

5.1 Graph类的实现

5.2 广度优先搜索

5.3 深度优先搜索简易版(无loop检测)

5.4 深度优先搜索完整版

5.5 测试

参考


 

1. 拓扑排序

        对一个有向无环图(Directed Acyclic Graph简称DAG)G进行拓扑排序,是将G中所有顶点排成一个线性序列,使得图中任意一对顶点u和v,若边<u,v>∈E(G),则u在线性序列中出现在v之前。通常,这样的线性序列称为满足拓扑次序(Topological Order)的序列,简称拓扑序列。简单的说,由某个集合上的一个偏序得到该集合上的一个全序,这个操作称之为拓扑排序。

 

        拓扑排序主要用来解决有向图中的依赖解析(dependency resolution)问题。

        举例来说,如果我们将一系列需要运行的任务构成一个有向图,图中的有向边则代表某一任务必须在另一个任务之前完成这一限制。那么运用拓扑排序,我们就能得到满足执行顺序限制条件的一系列任务所需执行的先后顺序。当然也有可能图中并不存在这样一个拓扑顺序,这种情况下我们无法根据给定要求完成这一系列任务,这种情况称为循环依赖(circular dependency)。

 

        例1:如下图所示为一个有向图。

36139ac2abdcffbad6c42dd024df86f5.png

        根据图中的边的方向,我们可以看出,若要满足得到其拓扑排序,则结点被遍历的顺序必须满足如下要求:

  1. 结点1必须在结点2、3之前
  2. 结点2必须在结点3、4之前
  3. 结点3必须在结点4、5之前
  4. 结点4必须在结点5之前

        则一个满足条件的拓扑排序为[1, 2, 3, 4, 5]。 

 

2. 拓扑排序存在的前提

        当且仅当一个有向图为有向无环图(directed acyclic graph,或称DAG)时,才能得到对应于该图的拓扑排序。每一个有向无环图都至少存在一种拓扑排序。该论断可以利用反证法证明如下:

        假设我们有一由gif.latex?v_1gif.latex?v_n这n个结点构成的有向图,且图中gif.latex?v_1%2C%20v_2%2C...%2Cv_n这些结点构成一个环。这即是说对于所有1≤i<n-1,图中存在一条有向边从gif.latex?v_i指向gif.latex?v_%7Bi&plus;1%7D。同时还存在一条从gif.latex?v_n指向gif.latex?v_1的边。假设该图存在一个拓扑排序。

        那么基于这样一个有向图,显然我们可以得知对于所有1≤i<n-1gif.latex?v_i必须在gif.latex?v_%7Bi&plus;1%7D之前被遍历,也就是gif.latex?v_1必须在gif.latex?v_n之前被遍历。同时由于还存在一条从gif.latex?v_n指向gif.latex?v_1的边gif.latex?v_n必须在gif.latex?v_1之前被遍历。这里出现了与我们的假设所冲突的结果。因此我们可以知道,该图存在拓扑排序的假设不成立。也就是说,对于非有向无环图而言,其拓扑排序不存在。

 

3. 拓扑排序的唯一性问题

        一般地来说,拓扑排序不一定是唯一的。

        例2:将例1中的图删去图中4、5结点之前的有向边,上图变为如下所示:

4341a4150ecf378b3fbb8b25841afabc.png

        可以得到两个不同的拓扑排序结果:[1, 2, 3, 4, 5][1, 2, 3, 5, 4]。 

4. 拓扑排序算法原理

         为了说明如何得到一个有向无环图的拓扑排序,我们首先需要了解有向图结点的入度(indegree)和出度(outdegree)的概念。

        假设有向图中不存在起点和终点为同一结点的有向边。

        入度:设有向图中有一结点v,其入度即为当前所有从其他结点出发,终点为v的的边的数目。也就是所有指向v的有向边的数目。

        出度:设有向图中有一结点v,其出度即为当前所有起点为v,指向其他结点的边的数目。也就是所有由v发出的边的数目。

        在了解了入度和出度的概念之后,再根据拓扑排序的定义,我们自然就能够得出结论:要想完成拓扑排序,我们每次都应当从入度为0的结点开始遍历。因为只有入度为0的结点才能够成为拓扑排序的起点。否则根据拓扑排序的定义,只要一个结点v的入度不为0,则至少有一条边起始于其他结点而指向v,那么这条边的起点在拓扑排序的顺序中应当位于v之前,则v不能成为当前遍历的起点。

        拓扑排序可以用深度优先遍历或广度优先遍历算法来实现。

4.1 广度优先遍历

        与普通的广度优先遍历唯一的区别在于需要维护每一个节点对应的入度,并在遍历的每一层时选取入度为0的节点开始遍历(而普通的广度优先遍历则无此限制,可以从每一层任意一个节点开始遍历)。这个算法描述如下:

  1. 统计图的每一个节点的入度存储与数组inDegree。
  2. 选取入度为0的节点加入队列
  3. 从队列中取出一个节点,
    1. (a) 将该节点加入输出
    2. (b) 将该节点的所有邻接点的入度树减1,减1后入度数变为0的节点加入队列
  4. 重复步骤3,直到遍历完所有的结点。
  5. 如果无法遍历完所有的结点,则意味着当前的图不是有向无环图。不存在拓扑排序。

 

        由于只有入度为0的节点才会被放入队列,当存在环时,环上的节点将不会放入队列,因此不会出现在最终的拓扑排序中。比如说,在示例2的图中添加一条从节点5到节点2的边,图中出现一个环:2-->3-->5-->2。

21d878ffea3842d0a0271d9f8feb6a71.png

 

        搜索过程将如下所示:

  •         (1)由于只有1的入度为0,将1放入队列
  •         (2)从队列中取出1并放入输出拓扑排序列表
  •         (3)将节点1的两个邻接节点2和3的入度减1,但是由于存在环路,所以它们的入度没有变为0,因此不会放入队列。
  •         (4)由于队列为空,所以搜索结束

        这样最终排序输出中只有节点1。

        事实上,在基于广度优先搜索的拓扑排序中,可以根据最终拓扑排序输出列表的长度是否等于图的节点数,来判断输入图是否存在拓扑排序。详细参见以下实现代码。

 

        时间复杂度: gif.latex?O%28n%20&plus;%20e%29,其中n为图中的结点数目,e为图中的边的数目

        空间复杂度:gif.latex?O%28n%29

 

4.2 深度优先遍历

        使用深度优先搜索实现拓扑排序的基本思想是:对于一个特定节点,如果该节点的所有相邻节点都已经搜索完成,则该节点也会变成已经搜索完成的节点,在拓扑排序中,该节点位于其所有相邻节点的前面。一个节点的相邻节点指的是从该节点出发通过一条有向边可以到达的节点。

        由于拓扑排序的顺序和搜索完成的顺序相反,因此需要使用一个栈存储所有已经搜索完成的节点。深度优先搜索的过程中需要维护每个节点的状态,每个节点的状态可能有三种情况:

  •         0:未访问;
  •         1:访问中;
  •         2:已访问;

        初始时,所有节点的状态都是「未访问」。

        每一轮搜索时,任意选取一个「未访问」的节点 u,从节点 u 开始深度优先搜索。将节点 u 的状态更新为「访问中」,对于每个与节点 u 相邻的节点 v,判断节点 v 的状态,执行如下操作:

        如果节点 v 的状态是「未访问」,则继续搜索节点 v;

        如果节点 v 的状态是「访问中」,则找到有向图中的环,因此不存在拓扑排序;

        如果节点 v 的状态是「已访问」,则节点 v 已经搜索完成并加入输出排序列表,节点 u 尚未完成搜索,因此节点 u 的拓扑顺序一定在节点 v 的前面,不需要执行任何操作。

        当节点 u 的所有相邻节点的状态都是「已访问」时,将节点 u 的状态更新为「已访问」,并将节点 u 加入输出排序列表。

        当所有节点都访问结束之后,如果没有找到有向图中的环,则存在拓扑排序,所有节点从栈顶到栈底的顺序即为拓扑排序。

 

5. 代码实现

        实现方面,如果图的节点数比较少的话,可以直接用一个数组来表示队列或者栈(以下示例代码中就是这么实现的)。但是如果图的规模很大,特别是对于非显式的图的搜索,节点数未知但是输入问题规模很大,则需要用真正的队列或者栈的结构来实现,比如说python中的deque。

5.1 Graph类的实现

        首先,实现图数据结构,用于后面构建图并用于算法测试。

from collections import defaultdictclass Graph:def __init__(self, isDirected=False):# 以邻接表的方式表示一张图self.graph = defaultdict(list)self.isDirected = isDirecteddef addEdge(self, start, end):
# =============================================================================
#       添加一条边
# =============================================================================# 将end添加到start节点的邻接表self.graph[start].append(end)if self.isDirected is False:# 如果是无向图,则双向追加。这个与拓扑排序无关,是作为一般的图构建方法放在这里的。self.graph[end].append(start)else:self.graph[end] = self.graph[end]

5.2 广度优先搜索

        基于广度优先搜索的拓扑排序算法的实现(作为以上Graph类的方法,当然作为独立函数实现也可以)。基于广度优先搜索的拓扑排序是按照正常顺序确定各节点的拓扑排序的。

    def topoSortBfs(self):# 查询统计各节点的入度inDeg = {node:0 for node in self.graph}for node in self.graph:for adj in self.graph[node]:inDeg[adj] += 1# 用一个list来简单地模拟队列# 首先将所有入度为0的节点加入队列q = [node for node, d in inDeg.items() if d == 0]for u in q:# 顺序遍历u,模拟逐个从队列头部中取出各节点并移除的操作for v in self.graph[u]:# 遍历u的各邻节点,由于u被移除,所以v的入度相应减一。# 如果减一后v的入度也变为0了就将v也加入队列(添加到表的尾部)inDeg[v] -= 1if inDeg[v] == 0:q.append(v)if len(q) < len(self.graph): return Nonereturn q

5.3 深度优先搜索简易版(无loop检测)

        当确定输入图是一定存在拓扑排序的话,可以以更简单的方式实现,各节点的状态只需要分“未访问”和“已访问”两种状态。

    def topoSortvisit0(self, s, visited, sortlist):visited[s] = Truefor i in self.graph[s]:if not visited[i]:self.topoSortvisit0(i, visited, sortlist)sortlist.insert(0, s)def topoSortDfs0(self):visited = {i: False for i in self.graph}sortlist = []for u in self.graph:if not visited[u]:self.topoSortvisit0(u, visited, sortlist)return sortlist

        当输入图有可能不存在拓扑排序时,这个函数返回的结果不正确。

5.4 深度优先搜索完整版

        当不能确定输入的图是否存在拓扑排序时,搜索排序算法需要能够做出正确的判断。这就需要在算法中加入loop是否存在的检测。实现代码如下:

    def topoSortvisit1(self, s, visited, sortlist):visited[s] = 1 #将节点s的状态置为“访问中”succ = Truefor i in self.graph[s]:# 遍历节点s的邻接节点if 0 == visited[i]:succ = succ and self.topoSortvisit1(i, visited, sortlist)elif 1 == visited[i]:# 找到一个环路,不存在拓扑排序return Falseif succ:# 针对当前节点的所有邻接点的搜索都成功结束,意味着所有邻接点都已经加入拓扑排序列表# 将当前节点置为“已访问”并将它加入拓扑排序列表。visited[s] = 2 #将节点s的状态置为“已访问”sortlist.insert(0, s) # 注意是插入到表头return Trueelse:return Falsedef topoSortDfs1(self):# 所有节点状态初始化为“未访问”visited = {i: 0 for i in self.graph}sortlist = []for u in self.graph:# 最外层是对每个节点进行遍历,不会出现重复。# 中途检测到从任何一个节点出发拓扑排序失败(检测到环)的话,都算失败。if 0 == visited[u]:if not self.topoSortvisit1(u, visited, sortlist):return Nonereturn sortlist

5.5 测试

if __name__ == '__main__':g = Graph(isDirected=True)g.addEdge(1, 2)g.addEdge(1, 3)g.addEdge(2, 4)g.addEdge(2, 5)g.addEdge(3, 4)g.addEdge(3, 6)g.addEdge(4, 6)# Testcase1print("\nTestcase1 ... ")print("topological Sort:", g.topoSortDfs0())print("topological Sort:", g.topoSortDfs1())print("topological Sort:", g.topoSortBfs())# Testcase2print("\nTestcase2 ... ")g.addEdge(3, 2)print("topological Sort:", g.topoSortDfs0())print("topological Sort:", g.topoSortDfs1())print("topological Sort:", g.topoSortBfs())# Testcase3print("\nTestcase3 ... ")g.addEdge(4, 2)print("topological Sort:", g.topoSortDfs0())print("topological Sort:", g.topoSortDfs1())print("topological Sort:", g.topoSortBfs())

以上三个testcase的输入的图分别如下所示:

04688f652d3044109f7b29bd6f3d63fc.png

运行结果如下: 

 

Testcase1 ... 
topological Sort: [1, 3, 2, 5, 4, 6]
topological Sort: [1, 3, 2, 5, 4, 6]
topological Sort: [1, 2, 3, 5, 4, 6]

Testcase2 ... 
topological Sort: [1, 3, 2, 5, 4, 6]
topological Sort: [1, 3, 2, 5, 4, 6]
topological Sort: [1, 3, 2, 4, 5, 6]

Testcase3 ... 
topological Sort: [1, 3, 2, 5, 4, 6]
topological Sort: None
topological Sort: None

        Testcase1和Testcase2的图是可拓扑排序的,三种实现方法都给出了正确结果,但是可以看出,广度优先搜索方案和深度优先搜索方案给出的结果是不一样的,但是,都是合法的。 比如说,在第1个图中,节点3和节点2之间没有确定的顺序要求,两种算法给出的结果就不一样。

        Testcase3(在第2个图的基础上追加了一条边,形成了环路)是不可拓扑排序的。但是深度优先搜素简易版因为没有环路判别对策,因此仍然给出一个错误的拓扑排序。而深度优先搜素完整版以及广度优先搜索方法则做出了正确的判断。

 

        在leetcode中有一些拓扑排序的题目,比如:

        210. 课程表 II - 力扣(LeetCode)

        剑指 Offer II 114. 外星文字典(difficult)

        等

 

参考

1. 百度百科——全球领先的中文百科全书 

2. Topological Sorting in Python with Algorithm - CodeSpeedy 

3. https://www.jianshu.com/p/3347f54a3187 

4. https://leetcode.cn/problems/Jf1JuT/solution/wai-xing-wen-zi-dian-by-leetcode-solutio-to66/         

 

 

 


http://chatgpt.dhexx.cn/article/ajv7iBH8.shtml

相关文章

【算法】拓扑排序

今天学习拓扑排序。如果一个有向图的任意顶点都无法通过一些有向边回到自身&#xff0c;那么称这个有向图为有向无环图&#xff08;Directed Acyclic Graph&#xff0c;DAG&#xff09;。拓扑排序就是将有向无环图的所有顶点排序&#xff0c;使得图中任意两个点 u、v&#xff0…

拓扑排序及算法实现

一、拓扑排序概念 对一个有向无环图(Directed Acyclic Graph简称DAG)G进行拓扑排序&#xff0c;是将G中所有顶点排成一个线性序列&#xff0c;使得图中任意一对顶点u和v&#xff0c;若边<u,v>∈E(G)&#xff0c;则u在线性序列中出现在v之前。通常&#xff0c;这样的线性…

利用迭代法求平方根——迭代法开平方运算

一、题目 用迭代法求&#xff1a; 的值。要求精度为0.00001&#xff0c;即 二、迭代公式 求平方根的迭代公式为&#xff1a; 当满足 时&#xff0c;这时的即是求得的根。如果 得到是精确值。如果不为0&#xff0c;则是近似值。 三、C代码 先给出开平方运算的函数。再给出主…

【算法】牛顿迭代法求平方根的原理和误差分析

前言 在《算法(第四版)》中的P23页&#xff0c;给出了经典的利用牛顿迭代法求平方根的算法&#xff0c;牛顿迭代法在数值计算中应用十分广泛&#xff0c;但是在看书中的代码时&#xff0c;我最困惑的是其中对收敛条件的判断&#xff0c;经过查阅资料和论坛&#xff0c;找到…

使用迭代法来求a的平方根

今天朋友问我一道使用迭代法求a的平方根的题&#xff0c;感觉受益匪浅&#xff0c;与诸君相分享 首先我们来看一下题目 我们也无需了解迭代法是什么原理&#xff0c;根据这个题目可以分析得到&#xff0c;需要使用while循环&#xff0c;下面是我的代码实践 #define _CRT_SEC…

利用牛顿迭代法求平方根

求n的平方根&#xff0c;先假设一猜测值X0 1&#xff0c;然后根据以下公式求出X1&#xff0c;再将X1代入公式右边&#xff0c;继续求出X2…通过有效次迭代后即可求出n的平方根&#xff0c;Xk1 先让我们来验证下这个巧妙的方法准确性&#xff0c;来算下2的平方根 (Computed b…

牛顿迭代法求解平方根

一个实例迭代简介牛顿迭代法 牛顿迭代法简介简单推导泰勒公式推导延伸与应用 一个实例 //java实现的sqrt类和方法 public class sqrt {public static double sqrt(double n){if (n<0) return Double.NaN;double err 1e-15;double t n;while (Math.abs(t - n/t) > err…

牛顿迭代法求一个数的平方根(python)

# !/usr/bin/env python # -*- coding: utf-8 -*- """ Author: P♂boy License: (C) Copyright 2013-2017, Node Supply Chain Manager Corporation Limited. Contact: 17647361832163.com Software: Pycharm File: sqrt.py.py Time: 2018/11/19 16:22 Desc:牛顿…

c++迭代法求平方根

用迭代法求x &#xff0c;要求前后两次求出的x的差的绝对值小于10-5&#xff0c;求平方根的迭代公式为&#xff1a; 输入 一个数a。 输出 的值。 样例输入 2.0样例输出 The square root of 2.00 is 1.41421 这道题我无语了...&#xff1a; #include<bits/stdc.h> …

牛顿迭代法求平方根原理

牛顿迭代法可以求解n次方的根&#xff0c;但这里只讨论用它来求平方根。 牛顿迭代法求平方根过程 Java代码实现 /*** 求一个数的平方根* param number* return*/public static double squareRoot(double number){if(number < 0){ //小于0的数无法开平方return Double.NaN;}e…

【算法】牛顿迭代法求平方根及多次方根

1. 概述 牛顿迭代法 牛顿迭代法 牛顿迭代法 是非常高效的求解方程的根的方法。其求解原理可以参考各文献。大体的思路如下&#xff1a; 通过不断地做切线来逼近真实的根&#xff0c;直到误差小于精度。 可得迭代公式&#xff1a; x n 1 x n − f ( x n ) f ′ ( x n ) x_{…

二分法和牛顿迭代法求平方根(Python实现)

求一个数的平方根函数sqrt(int num) ,在大多数语言中都提供实现。那么要求一个数的平方根&#xff0c;是怎么实现的呢&#xff1f; 实际上求平方根的算法方法主要有两种&#xff1a;二分法(binary search)和牛顿迭代法(Newton iteration) 1&#xff1a;二分法 求根号5 a:折半…

牛顿迭代法-求平方根

牛顿迭代法 求出根号a的近似值&#xff1a;首先随便猜一个近似值x&#xff0c;然后不断令x等于x和a/x的平均数&#xff0c;迭代个六七次后x的值就已经相当精确了。 例如&#xff0c;我想求根号2等于多少。假如我猜测的结果为4&#xff0c;虽然错的离谱&#xff0c;但你可以看到…

迭代法求平方根-C

迭代法求a的平方根的近似值&#xff0c;计算公式如下&#xff1a; 迭代是重复反馈过程的活动&#xff0c;目的是为了逼近所需目标或结果。每次对过程的重复称为一次“迭代”&#xff0c;而每一次迭代得到的结果会作为下一次迭代的初始值。 算法&#xff1a;先给定一个假设的平…

用迭代法求某数a的平方根

今天晚上笔试题目最后一题很简单&#xff0c;可是自己做不出 &#xff0c;就是不用库函数&#xff0c;求一个浮点数的平方根。 立马想到用物理法&#xff0c;比如正方形的面积法等&#xff0c;可是求解出不出&#xff0c;然后就绕在里面了。归根到底还是平时的知识储备太少了&…

Java基础——运行时异常和非运行时异常

文章目录 Java中异常机制的体系结构Error&#xff08;错误&#xff09;Exception&#xff08;异常&#xff09;运行时异常和非运行时异常的区别结束 Java中异常机制的体系结构 在Java中&#xff0c;万物皆对象&#xff0c;异常也不例外。 Exception&#xff08;异常&#xff0…

Java编译时异常与运行时异常的区别

Java的异常可以分为编译异常和运行异常&#xff0c;其主要区别&#xff1a; 编译异常要求程序员必须处理&#xff08;捕获或者抛出&#xff09;&#xff0c;不然没法通过编译。 而运行异常可以不处理。 这应该是纸面最明显的区别了&#xff0c;我认为更重要的区别是在处理机…

运行时异常与非运行时异常有什么区别?

运行时异常与非运行时异常有什么区别&#xff1f; 运行时异常 RuntimeException 又称为非检查异常 uncheck exception。是 Exception 的子类。 在 Java 中&#xff0c;异常可以分为两种。Error 和 Exception&#xff0c;它们的父类是 Throwable。 Error 一些底层的类出错&…

杂谈——运行时异常和普通异常有什么区别

说到异常&#xff0c;大家都熟悉&#xff0c;只要程序出错了&#xff0c;那么肯定会说&#xff1a;“哎呀&#xff0c;我的程序出错啦~它抛出异常啦”。 但单单以“异常”的名称来称呼它们&#xff0c;未免也太粗糙了。我们毕竟是一个精致的程序员&#xff0c;当然得知道他们到…

常见的编译时异常和运行时异常

常见的编译时异常和运行是异常 1、粉红色的是编译时异常 2、绿色的异常是运行时异常 3、声明为Error的&#xff0c;则属于严重错误&#xff0c;如系统崩溃、虚拟机错误、动态链接失败等&#xff0c;这些错误无法恢复或者不可能捕捉&#xff0c;将导致应用程序中断&#xff0c;…