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

    文章

    6511

    浏览量

    87592
  • 拓扑结构
    +关注

    关注

    6

    文章

    303

    浏览量

    38958

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

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

收藏 人收藏

    评论

    相关推荐

    C语言实现经典排序算法概览

    冒泡排序(英语:Bubble Sort)是一种简单的排序算法。它重复地走访过要排序的数列,一次比较两个元素,如果他们的顺序(如从大到小、首字母从A到Z)错误就把他们交换过来。
    的头像 发表于 02-25 12:27 225次阅读
    C语言实现经典<b class='flag-5'>排序</b><b class='flag-5'>算法</b>概览

    十大排序算法总结

    排序算法是最经典的算法知识。因为其实现代码短,应该广,在面试中经常会问到排序算法及其相关的问题。一般在面试中最常考的是快速
    的头像 发表于 12-20 10:39 718次阅读

    python升序和降序排序代码

    Python是一种简洁而强大的编程语言,提供了许多实用的函数和方法来排序数据。在本文中,我们将详细讨论Python中的升序和降序排序。我们将深入探讨不同的排序算法、它们的复杂度以及如何
    的头像 发表于 11-21 15:20 963次阅读

    排序算法有哪些

    1. 归并排序(递归版) 归并排序(MERGE-SORT)是利用归并的思想实现的排序方法,该算法采用经典的分治策略,即分为两步:分与治。 分:先递归分解数组成子数组 治:将分阶段得到的
    的头像 发表于 10-11 15:49 323次阅读
    <b class='flag-5'>排序</b><b class='flag-5'>算法</b>有哪些

    排序算法之选择排序

    选择排序: (Selection sort)是一种简单直观的排序算法,也是一种不稳定的排序方法。 选择排序的原理: 一组无序待排数组,做升序
    的头像 发表于 09-25 16:30 840次阅读
    <b class='flag-5'>排序</b><b class='flag-5'>算法</b>之选择<b class='flag-5'>排序</b>

    FPGA排序-冒泡排序介绍

    排序算法是图像处理中经常使用一种算法,常见的排序算法有插入排序、希尔
    发表于 07-17 10:12 668次阅读
    FPGA<b class='flag-5'>排序</b>-冒泡<b class='flag-5'>排序</b>介绍

    Python实现的常见内部排序算法

    排序算法可以分为内部排序和外部排序,内部排序是数据记录在内存中进行排序,而外部
    发表于 07-06 12:35 256次阅读
    Python实现的常见内部<b class='flag-5'>排序</b><b class='flag-5'>算法</b>

    常见排序算法分类

    本文将通过动态演示+代码的形式系统地总结十大经典排序算法排序算法 算法分类 —— 十种常见排序
    的头像 发表于 06-22 14:49 628次阅读
    常见<b class='flag-5'>排序</b><b class='flag-5'>算法</b>分类

    详解DeepMind排序算法

    DeepMind 的这一发现确实居功至伟,但不幸的是,他们未能解释清楚算法。下面,我们来详细看看他们发布的一段汇编代码,这是一个包含三个元素的数组的排序,我们将伪汇编转换为汇编:
    的头像 发表于 06-21 15:38 258次阅读

    利用强化学习来探索更优排序算法的AI系统

    前言 DeepMind 最近在 Nature 发表了一篇论文 AlphaDev[2, 3],一个利用强化学习来探索更优排序算法的AI系统。 AlphaDev 系统直接从 CPU 汇编指令的层面入手
    的头像 发表于 06-19 10:49 391次阅读
    利用强化学习来探索更优<b class='flag-5'>排序</b><b class='flag-5'>算法</b>的AI系统

    GPT-4两句话复刻DeepMind最快排序算法

    网友的提示词有两个部分,第一个部分让GPT-4针对这段排序算法进行优化,标注出哪段指令可以删除,再一步一步解释原因,然后回头再验证一遍。
    的头像 发表于 06-12 17:04 494次阅读
    GPT-4两句话复刻DeepMind最快<b class='flag-5'>排序</b><b class='flag-5'>算法</b>?

    详细介绍8种最常用的排序算法

    在计算机科学领域中,排序算法是一种基本的算法排序算法可以将一个数据集合重新排列成一个按照某种规则有序的集合,常用于数据检索、数据压缩、数据
    的头像 发表于 06-06 14:52 2332次阅读

    C语言经典排序算法总结

    本文将通过动态演示+代码的形式系统地总结十大经典排序算法
    发表于 06-05 10:56 388次阅读
    C语言经典<b class='flag-5'>排序</b><b class='flag-5'>算法</b>总结

    ABB工业机器人排序算法

    冒泡排序的英文Bubble Sort,是一种最基础的交换排序。之所以叫做冒泡排序,因为每一个元素都可以像小气泡一样,根据自身大小一点一点向数组的一侧移动。 冒泡排序的基本概念是:
    的头像 发表于 05-22 16:12 1831次阅读
    ABB工业机器人<b class='flag-5'>排序</b><b class='flag-5'>算法</b>

    排序算法之“归并算法”介绍

    在说这个题目之前先来说说一个排序算法 “归并算法” 归并算法采取思想是分治思想,分治思想简单说就是分而治之,将一个大问题分解为小问题,将小问题解答后合并为大问题的答案。乍一看跟递归思想
    的头像 发表于 05-22 10:03 428次阅读
    <b class='flag-5'>排序</b><b class='flag-5'>算法</b>之“归并<b class='flag-5'>算法</b>”介绍