0
  • 聊天消息
  • 系统消息
  • 评论与回复
登录后你可以
  • 下载海量资料
  • 学习在线课程
  • 观看技术视频
  • 写文章/发帖/加入社区
会员中心
创作中心

完善资料让更多小伙伴认识你,还能领取20积分哦,立即完善>

3天内不再提示

拓扑排序算法原理是什么

新材料在线 来源:labuladong 作者:labuladong 2021-08-16 15:02 次阅读
加入交流群
微信小助手二维码

扫码添加小助手

加入工程师交流群

很多读者留言说要看「图」相关的算法,那就满足大家,结合算法题把图相关的技巧给大家过一遍。

前文 学习数据结构的框架思维 说了,数据结构相关的算法无非两点:遍历 + 访问。那么图的基本遍历方法也很简单,前文 图算法基础 就讲了如何从多叉树的遍历框架扩展到图的遍历。

图这种数据结构还有一些比较特殊的算法,比如二分图判断,有环图无环图的判断,拓扑排序,以及最经典的最小生成树,单源最短路径问题,更难的就是类似网络流这样的问题。

不过以我的经验呢,像网络流这种问题,你又不是打竞赛的,除非自己特别有兴趣,否则就没必要学了;像最小生成树和最短路径问题,虽然从刷题的角度用到的不多,但它们属于经典算法,学有余力可以掌握一下;像拓扑排序这一类,属于比较基本且有用的算法,应该比较熟练地掌握。

那么本文就结合具体的算法题,来说说拓扑排序算法原理,因为拓扑排序的对象是有向无环图,所以顺带说一下如何判断图是否有环。

判断有向图是否存在环

函数签名如下:

int[] findOrder(int numCourses, int[][] prerequisites);

题目应该不难理解,什么时候无法修完所有课程?当存在循环依赖的时候。

其实这种场景在现实生活中也十分常见,比如我们写代码 import 包也是一个例子,必须合理设计代码目录结构,否则会出现循环依赖,编译器会报错,所以编译器实际上也使用了类似算法来判断你的代码是否能够成功编译。

看到依赖问题,首先想到的就是把问题转化成「有向图」这种数据结构,只要图中存在环,那就说明存在循环依赖。

具体来说,我们首先可以把课程看成「有向图」中的节点,节点编号分别是0, 1, ..., numCourses-1,把课程之间的依赖关系看做节点之间的有向边。

比如说必须修完课程1才能去修课程3,那么就有一条有向边从节点1指向3。

所以我们可以根据题目输入的prerequisites数组生成一幅类似这样的图:

0275083a-fe50-11eb-9bcf-12bb97331649.jpg

如果发现这幅有向图中存在环,那就说明课程之间存在循环依赖,肯定没办法全部上完;反之,如果没有环,那么肯定能上完全部课程。

好,那么想解决这个问题,首先我们要把题目的输入转化成一幅有向图,然后再判断图中是否存在环。

如何转换成图呢?我们前文 图论基础 写过图的两种存储形式,邻接矩阵和邻接表。

以我刷题的经验,常见的存储方式是使用邻接表,比如下面这种结构:

List《Integer》[] graph;

graph[s]是一个列表,存储着节点s所指向的节点。

所以我们首先可以写一个建图函数:

List《Integer》[] buildGraph(int numCourses, int[][] prerequisites) {

// 图中共有 numCourses 个节点

List《Integer》[] graph = new LinkedList[numCourses];

for (int i = 0; i 《 numCourses; i++) {

graph[i] = new LinkedList《》();

}

for (int[] edge : prerequisites) {

int from = edge[1];

int to = edge[0];

// 修完课程 from 才能修课程 to

// 在图中添加一条从 from 指向 to 的有向边

graph[from].add(to);

}

return graph;

}

图建出来了,怎么判断图中有没有环呢?

先不要急,我们先来思考如何遍历这幅图,只要会遍历,就可以判断图中是否存在环了。

前文 图论基础 写了 DFS 算法遍历图的框架,无非就是从多叉树遍历框架扩展出来的,加了个visited数组罢了:

// 防止重复遍历同一个节点boolean[] visited;

// 从节点 s 开始 BFS 遍历,将遍历过的节点标记为 truevoid traverse(List《Integer》[] graph, int s) {

if (visited[s]) {

return;

}

/* 前序遍历代码位置 */

// 将当前节点标记为已遍历

visited[s] = true;

for (int t : graph[s]) {

traverse(graph, t);

}

/* 后序遍历代码位置 */

}

那么我们就可以直接套用这个遍历代码:

// 防止重复遍历同一个节点boolean[] visited;

boolean canFinish(int numCourses, int[][] prerequisites) {

List《Integer》[] graph = buildGraph(numCourses, prerequisites);

visited = new boolean[numCourses];

for (int i = 0; i 《 numCourses; i++) {

traverse(graph, i);

}

}

void traverse(List《Integer》[] graph, int s) {

// 代码见上文

}

注意图中并不是所有节点都相连,所以要用一个 for 循环将所有节点都作为起点调用一次 DFS 搜索算法。

这样,就能遍历这幅图中的所有节点了,你打印一下visited数组,应该全是 true。

前文 学习数据结构和算法的框架思维 说过,图的遍历和遍历多叉树差不多,所以到这里你应该都能很容易理解。

那么如何判断这幅图中是否存在环呢?

我们前文 回溯算法核心套路详解 说过,你可以把递归函数看成一个在递归树上游走的指针,这里也是类似的:

你也可以把traverse看做在图中节点上游走的指针,只需要再添加一个布尔数组onPath记录当前traverse经过的路径:

boolean[] onPath;

boolean hasCycle = false;

boolean[] visited;

void traverse(List《Integer》[] graph, int s) {

if (onPath[s]) {

// 发现环!!!

hasCycle = true;

}

if (visited[s]) {

return;

}

// 将节点 s 标记为已遍历

visited[s] = true;

// 开始遍历节点 s

onPath[s] = true;

for (int t : graph[s]) {

traverse(graph, t);

}

// 节点 s 遍历完成

onPath[s] = false;

}

这里就有点回溯算法的味道了,在进入节点s的时候将onPath[s]标记为 true,离开时标记回 false,如果发现onPath[s]已经被标记,说明出现了环。

PS:参考贪吃蛇没绕过弯儿咬到自己的场景。

这样,就可以在遍历图的过程中顺便判断是否存在环了,完整代码如下:

// 记录一次 traverse 递归经过的节点boolean[] onPath;

// 记录遍历过的节点,防止走回头路boolean[] visited;

// 记录图中是否有环boolean hasCycle = false;

boolean canFinish(int numCourses, int[][] prerequisites) {

List《Integer》[] graph = buildGraph(numCourses, prerequisites);

visited = new boolean[numCourses];

onPath = new boolean[numCourses];

for (int i = 0; i 《 numCourses; i++) {

// 遍历图中的所有节点

traverse(graph, i);

}

// 只要没有循环依赖可以完成所有课程

return !hasCycle;

}

void traverse(List《Integer》[] graph, int s) {

if (onPath[s]) {

// 出现环

hasCycle = true;

}

if (visited[s] || hasCycle) {

// 如果已经找到了环,也不用再遍历了

return;

}

// 前序遍历代码位置

visited[s] = true;

onPath[s] = true;

for (int t : graph[s]) {

traverse(graph, t);

}

// 后序遍历代码位置

onPath[s] = false;

}

List《Integer》[] buildGraph(int numCourses, int[][] prerequisites) {

// 代码见前文

}

这道题就解决了,核心就是判断一幅有向图中是否存在环。

不过如果出题人继续恶心你,让你不仅要判断是否存在环,还要返回这个环具体有哪些节点,怎么办?

你可能说,onPath里面为 true 的索引,不就是组成环的节点编号吗?

不是的,假设下图中绿色的节点是递归的路径,它们在onPath中的值都是 true,但显然成环的节点只是其中的一部分:

0280b39c-fe50-11eb-9bcf-12bb97331649.jpg

那么接下来,我们来再讲一个经典的图算法:拓扑排序。

拓扑排序

这道题就是上道题的进阶版,不是仅仅让你判断是否可以完成所有课程,而是进一步让你返回一个合理的上课顺序,保证开始修每个课程时,前置的课程都已经修完。

函数签名如下:

int[] findOrder(int numCourses, int[][] prerequisites);

这里我先说一下拓扑排序(Topological Sorting)这个名词,网上搜出来的定义很数学,这里干脆用百度百科的一幅图来让你直观地感受下

直观地说就是,让你把一幅图「拉平」,而且这个「拉平」的图里面,所有箭头方向都是一致的,比如上图所有箭头都是朝右的。

很显然,如果一幅有向图中存在环,是无法进行拓扑排序的,因为肯定做不到所有箭头方向一致;反过来,如果一幅图是「有向无环图」,那么一定可以进行拓扑排序。

但是我们这道题和拓扑排序有什么关系呢?

其实也不难看出来,如果把课程抽象成节点,课程之间的依赖关系抽象成有向边,那么这幅图的拓扑排序结果就是上课顺序。

首先,我们先判断一下题目输入的课程依赖是否成环,成环的话是无法进行拓扑排序的,所以我们可以复用上一道题的主函数:

public int[] findOrder(int numCourses, int[][] prerequisites) {

if (!canFinish(numCourses, prerequisites)) {

// 不可能完成所有课程

return new int[]{};

}

// 。..

}

PS:简单起见,canFinish 直接复用了之前实现的函数,但实际上可以把环检测的逻辑和拓扑排序的逻辑结合起来,同时在 traverse 函数里完成,这个可以留给大家自己去实现。

那么关键问题来了,如何进行拓扑排序?是不是又要秀什么高大上的技巧了?

其实特别简单,将后序遍历的结果进行反转,就是拓扑排序的结果。

直接看解法代码:

boolean[] visited;

// 记录后序遍历结果

List《Integer》 postorder = new ArrayList《》();

int[] findOrder(int numCourses, int[][] prerequisites) {

// 先保证图中无环

if (!canFinish(numCourses, prerequisites)) {

return new int[]{};

}

// 建图

List《Integer》[] graph = buildGraph(numCourses, prerequisites);

// 进行 DFS 遍历

visited = new boolean[numCourses];

for (int i = 0; i 《 numCourses; i++) {

traverse(graph, i);

}

// 将后序遍历结果反转,转化成 int[] 类型

Collections.reverse(postorder);

int[] res = new int[numCourses];

for (int i = 0; i 《 numCourses; i++) {

res[i] = postorder.get(i);

}

return res;

}

void traverse(List《Integer》[] graph, int s) {

if (visited[s]) {

return;

}

visited[s] = true;

for (int t : graph[s]) {

traverse(graph, t);

}

// 后序遍历位置

postorder.add(s);

}

// 参考上一题的解法boolean canFinish(int numCourses, int[][] prerequisites);

// 参考前文代码

List《Integer》[] buildGraph(int numCourses, int[][] prerequisites);

代码虽然看起来多,但是逻辑应该是很清楚的,只要图中无环,那么我们就调用traverse函数对图进行 BFS 遍历,记录后序遍历结果,最后把后序遍历结果反转,作为最终的答案。

那么为什么后序遍历的反转结果就是拓扑排序呢?

我这里也避免数学证明,用一个直观地例子来解释,我们就说二叉树,这是我们说过很多次的二叉树遍历框架:

void traverse(TreeNode root) {

// 前序遍历代码位置

traverse(root.left)

// 中序遍历代码位置

traverse(root.right)

// 后序遍历代码位置

}

二叉树的后序遍历是什么时候?遍历完左右子树之后才会执行后序遍历位置的代码。换句话说,当左右子树的节点都被装到结果列表里面了,根节点才会被装进去。

后序遍历的这一特点很重要,之所以拓扑排序的基础是后序遍历,是因为一个任务必须在等到所有的依赖任务都完成之后才能开始开始执行。

你把每个任务理解成二叉树里面的节点,这个任务所依赖的任务理解成子节点,那你是不是应该先把所有子节点处理完再处理父节点?这是不是就是后序遍历?

下图是一个二叉树的后序遍历结果:

02cd8668-fe50-11eb-9bcf-12bb97331649.jpg

结合这个图说一说为什么还要把后序遍历结果反转,才是最终的拓扑排序结果。

我们说一个节点可以理解为一个任务,这个节点的子节点理解为这个任务的依赖,但你注意我们之前说的依赖关系的表示:如果做完A才能去做B,那么就有一条从A指向B的有向边,表示B依赖A。

那么,父节点依赖子节点,体现在二叉树里面应该是这样的

是不是和我们正常的二叉树指针指向反过来了?所以正常的后序遍历结果应该进行反转,才是拓扑排序的结果。

以上,我简单解释了一下为什么「拓扑排序的结果就是反转之后的后序遍历结果」,当然,我的解释虽然比较直观,但并没有严格的数学证明,有兴趣的读者可以自己查一下。

总之,你记住拓扑排序就是后序遍历反转之后的结果,且拓扑排序只能针对有向无环图,进行拓扑排序之前要进行环检测,这些知识点已经足够了。

责任编辑:haq

声明:本文内容及配图由入驻作者撰写或者入驻合作网站授权转载。文章观点仅代表作者本人,不代表电子发烧友网立场。文章及其配图仅供工程师学习之用,如有内容侵权或者其他违规问题,请联系本站处理。 举报投诉
  • 数据
    +关注

    关注

    8

    文章

    7314

    浏览量

    93941
  • 拓扑结构
    +关注

    关注

    6

    文章

    332

    浏览量

    40757

原文标题:拓扑排序,YYDS!

文章出处:【微信号:xincailiaozaixian,微信公众号:新材料在线】欢迎添加关注!文章转载请注明出处。

收藏 人收藏
加入交流群
微信小助手二维码

扫码添加小助手

加入工程师交流群

    评论

    相关推荐
    热点推荐

    C语言的常见算法

    # C语言常见算法 C语言中常用的算法可以分为以下几大类: ## 1. 排序算法 ### 冒泡排序 (Bubble Sort) ```
    发表于 11-24 08:29

    SM4算法实现分享(一)算法原理

    SM4分组加密算法采用的是非线性迭代结构,以字为单位进行加密、解密运算,每次迭代称为一轮变换,每轮变换包括S盒变换、非线性变换、线性变换、合成变换。加解密算法与密钥扩展都是采用32轮非线性迭代结构
    发表于 10-30 08:10

    PPEC Workbench 平台拓扑全覆盖,满足各类电源开发需求

    ) 智能化拓扑开发工具 ▌拓扑参数预配置: 针对选定拓扑,自动生成初始参数范围(如电感 / 电容值、开关频率等),减少基础参数计算的工作量。 ▌算法自定义支持: AI 智能助手可辅助
    发表于 10-23 11:44

    PPEC电源DIY套件:图形化算法编程,解锁电力电子底层算法实践

    开关电源拓扑的搭建与验证。 2、进阶调试与优化 ▌电源参数可调: 通过PPEC Workbnch 电力电子智能化设计平台调节输出电压、电流、开关频率等,实现恒压/恒流模式切换。 ▌底层算法可视化自定义
    发表于 08-14 11:30

    开关电源设计如何选择合适拓扑

    在设计你的变换器前,你必须首先选择电路拓扑。因为其它所有电路元件设计,像元件选择,磁芯设计,闭环补偿等等都取决于拓扑。所以在设计开始之前,你得首先仔细研究所要开发的电源的要求和技术规范:输入、输出电压,输出功率、输出纹波、电磁兼容要求等等,以保证选择适当的
    的头像 发表于 05-23 09:55 2.3w次阅读
    开关电源设计如何选择合适<b class='flag-5'>拓扑</b>

    低成本电源排序器解决方案

    绝大多数负载点DC-DC转换器可以将上一个转换器的电源就绪输出连接至下一个转换器的使能输入,实现上电排序。这种方法只适合比较简单的设计,不能满足多数现代微处理器和DSP的要求一这类器件要求断电顺序必须与上电顺序相反。许多厂商针对这类应用推出了可编程排序IC,但器件价格较为
    的头像 发表于 05-21 09:55 955次阅读
    低成本电源<b class='flag-5'>排序</b>器解决方案

    开关电源功率变换器拓扑与设计

    详细讲解开关电源功率变换器的各种拓扑电路,通过实例详细讲解。 共分为12章,包括功率变换器的主要拓扑介绍和工程设计指南两大部分内容。其中,拓扑部分主要包括正激、反激、对称驱动桥式、隔离Boost
    发表于 05-19 16:26

    开关电源拓扑结构介绍

    一 、绪论开关电源电路拓扑是指功率器件和电磁元件连接在电路中的方式,而磁性元件设计、闭环补偿电路以及所有其他电路元件的设计都依赖于拓扑拓扑可分为:开关型和非开关型两大类。其中开关型拓扑
    发表于 05-12 16:04

    反向降压拓扑如何替代非隔离反激式拓扑 德州仪器反向降压拓扑详细解析

    欢迎来到 《电源设计小贴士集锦》系列文章   本期,我们将介绍 反向降压拓扑 的详细知识   最常见的电源之一是 离线电源 ,也称为交流电源。随着旨在集成典型家庭功能的产品越来越多,市场对输出能力
    发表于 05-10 10:19 786次阅读
    反向降压<b class='flag-5'>拓扑</b>如何替代非隔离反激式<b class='flag-5'>拓扑</b> 德州仪器反向降压<b class='flag-5'>拓扑</b>详细解析

    常见的PFC拓扑架构及控制方法

    本期,芯朋微技术团队将为各位fans分享常见的PFC拓扑架构及控制方法,为设计选型提供参考。
    的头像 发表于 04-27 18:03 5915次阅读
    常见的PFC<b class='flag-5'>拓扑</b>架构及控制方法

    开关电源各种拓扑

    六种基本 DC/DC 变换器拓扑 依次为 buck,boost,buck-boost,cuk,zeta,sepic 变换器 正激变换器 绕组复位正激变换器 LCD 复位正激变换器 有源钳位正激变换器 文件过大,需要完整版资料可下载附件查看哦!
    发表于 03-11 14:22

    移相全桥ZVS及ZVZCS拓扑结构分析

    移相全桥 ZVS 及 ZVZCS 拓扑结构分析 1.引言 移相控制方式是控制型软开关技术在全开关 PWM 拓扑的两态开关模式(通态和断态)通过控制方法变为三态开关工作模式(通态断态和续流态),在
    发表于 03-04 16:42

    详解Linux sort命令之掌握排序技巧与实用案例

    在linux系统使用过程中,提供了sort排序命令,支持常用的排序功能。 常用参数 sort命令支持很多参数,常用参数如下:   短参数 长参数 说明 -n – number-sort 按字符串数值
    的头像 发表于 01-09 10:10 1580次阅读

    TimSort:一个在标准函数库中广泛使用的排序算法

    在计算机科学的领域,排序算法是每位学生必学的基础,而排序的需求是每位程序员在编程过程中都会遇到的。 在你轻松调用 .sort() 方法对数据进行排序时,是否曾好奇过,这个简单的方法背后
    的头像 发表于 01-03 11:42 947次阅读

    DHB拓扑出现输出电压噪声怎么解决

    本人小白,在设计DHB拓扑的时候,输出电压波形遇到了如图所示的波形,输出电压为100V,现在输出电压波形好像有开关频率的噪声,使用单移相调制,发生在开关管关断的时候,这个问题要怎么解决
    发表于 12-21 11:47