LeetCode 专题:拓扑排序

知识点总结

拓扑排序并非一种排序算法,它能得到一个 AOV 网络的拓扑序列,用于判断有向无环图中是否有环,即可以判断一系列活动是否有循环依赖;

解决一个工程中的任务是否能够顺利完成,判断是否出现环。(补充:还有一种判断图中是否有环的数据结构是“并查集”)。

具体例子:去店里吃饭的问题:顾客要求先吃饭再付钱,商家要求先收钱再做菜,这就是循环依赖,拓扑排序就可以帮助我们判断是否形成环。算法4那本书里面就有拓扑排序。

步骤:找无前驱的结点(即入度为 0 的结点),一个一个地删去(使用队列或者栈),删的时候,把邻居结点的入度(即度 -1 )。借助队列实现。

“拓扑排序”用于对有先后顺序的任务的输出,如果先后顺序形成一个环,那么就表示这些任务头尾依赖,就永远不能完成。

因此“拓扑排序”还可以用于检测一个图中是否有环。

LeetCode 上拓扑排序目前(截止 2019 年 2 月 16 日早)一共有 5 题。

LeetCode 专题:拓扑排序-1

我们就做两题。

LeetCode 第 207 题:课程表

传送门:207. 课程表

现在你总共有 n 门课需要选,记为 0n-1

在选修某些课程之前需要一些先修课程。 例如,想要学习课程 0 ,你需要先完成课程 1 ,我们用一个匹配来表示他们: [0,1]

给定课程总量以及它们的先决条件,判断是否可能完成所有课程的学习?

示例 1:

输入: 2, [[1,0]] 
输出: true
解释: 总共有 2 门课程。学习课程 1 之前,你需要完成课程 0。所以这是可能的。

示例 2:

输入: 2, [[1,0],[0,1]]
输出: false
解释: 总共有 2 门课程。学习课程 1 之前,你需要先完成课程 0;并且学习课程 0 之前,你还应先完成课程 1。这是不可能的。

说明:

  1. 输入的先决条件是由边缘列表表示的图形,而不是邻接矩阵。详情请参见图的表示法
  2. 你可以假定输入的先决条件中没有重复的边。

提示:

  1. 这个问题相当于查找一个循环是否存在于有向图中。如果存在循环,则不存在拓扑排序,因此不可能选取所有课程进行学习。
  2. 通过 DFS 进行拓扑排序 - 一个关于Coursera的精彩视频教程(21分钟),介绍拓扑排序的基本概念。
  3. 拓扑排序也可以通过 BFS 完成。

思路1:Kahn 算法,即拓扑排序。构建的邻接表就是我们通常认识的邻接表,每一个结点存放的是后继结点的集合。

该方法的每一步总是输出当前无前趋(即入度为零)的顶点。为避免每次选入度为 0 的顶点时扫描整个存储空间,可设置一个队列暂存所有入度为 0 的顶点。

具体做法如下:

1、在开始排序前,扫描对应的存储空间,将入度为 0 的顶点均入队列。

2、只要队列非空,就从队首取出入度为 0 的顶点,将这个顶点输出到结果集中,并且将这个顶点的所有邻接点的入度减 1,在减 1 以后,发现这个邻接点的入度为 0 ,就继续入队。

最后检查结果集中的顶点个数是否和课程数相同即可。

Python 代码:

class Solution(object):

    # 思想:该方法的每一步总是输出当前无前趋(即入度为零)的顶点

    def canFinish(self, numCourses, prerequisites):
        """
        :type numCourses: int 课程门数
        :type prerequisites: List[List[int]] 课程与课程之间的关系
        :rtype: bool
        """
        # 课程的长度
        clen = len(prerequisites)
        if clen == 0:
            # 没有课程,当然可以完成课程的学习
            return True
        # 步骤1:统计每个顶点的入度
        # 入度数组,一开始全部为 0
        in_degrees = [0 for _ in range(numCourses)]
        # 邻接表
        adj = [set() for _ in range(numCourses)]

        # 想要学习课程 0 ,你需要先完成课程 1 ,我们用一个匹配来表示他们: [0,1]
        # [0,1] 表示 1 在先,0 在后
        # 注意:邻接表存放的是后继 successor 结点的集合
        for second, first in prerequisites:
            in_degrees[second] += 1
            adj[first].add(second)

        # print("in_degrees", in_degrees)
        # 首先遍历一遍,把所有入度为 0 的结点加入队列
        res = []
        queue = []
        # 步骤2:拓扑排序开始之前,先把所有入度为 0 的结点加入到一个队列中
        for i in range(numCourses):
            if in_degrees[i] == 0:
                queue.append(i)
        counter = 0
        while queue:
            top = queue.pop(0)
            counter += 1
            # 步骤3:把这个结点的所有后继结点的入度减去 1,如果发现入度为 0 ,就马上添加到队列中
            for successor in adj[top]:
                in_degrees[successor] -= 1
                if in_degrees[successor] == 0:
                    queue.append(successor)

        return counter == numCourses

思路2:构建逆邻接表,实现深度优先遍历。思路其实也很简单,其实就是检测这个有向图中有没有环,只要存在环,课程就不能完成。

第 1 步:构造逆邻接表;
第 2 步:递归处理每一个还没有被访问的结点;核心思想是:对于一个顶点 vertex 来说,我们先输出了指向它的所有顶点,然后再输出自己,就是这么简单。
第 3 步:如果这个顶点没有被遍历过,就递归遍历它,把所有指向它的结点都输出了,再输出自己。

注意:这个深度优先遍历得通过逆邻接表实现,当访问一个结点的时候,应该递归访问它的前驱结点,直至前驱结点没有前驱结点为止。

Python 代码:

# 207. 课程表
# 现在你总共有 n 门课需要选,记为 0 到 n-1。
# 在选修某些课程之前需要一些先修课程。 例如,想要学习课程 0 ,你需要先完成课程 1 ,我们用一个匹配来表示他们: [0,1]
# 给定课程总量以及它们的先决条件,判断是否可能完成所有课程的学习?
class Solution(object):

    # 这里使用逆邻接表

    def canFinish(self, numCourses, prerequisites):
        """
        :type numCourses: int 课程门数
        :type prerequisites: List[List[int]] 课程与课程之间的关系
        :rtype: bool
        """
        # 课程的长度
        clen = len(prerequisites)
        if clen == 0:
            # 没有课程,当然可以完成课程的学习
            return True
        # 深度优先遍历,判断结点是否访问过
        # 这里要设置 3 个状态
        # 0 就对应 False ,表示结点没有访问过
        # 1 就对应 True ,表示结点已经访问过,在深度优先遍历结束以后才置为 1
        # 2 表示当前正在遍历的结点,如果在深度优先遍历的过程中,
        # 有遇到状态为 2 的结点,就表示这个图中存在环
        visited = [0 for _ in range(numCourses)]

        # 逆邻接表,存的是每个结点的前驱结点的集合
        # 想要学习课程 0 ,你需要先完成课程 1 ,我们用一个匹配来表示他们: [0,1]
        # 1 在前,0 在后
        inverse_adj = [set() for _ in range(numCourses)]
        for second, first in prerequisites:
            inverse_adj[second].add(first)

        for i in range(numCourses):
            # 在遍历的过程中,如果发现有环,就退出
            if self.__dfs(i, inverse_adj, visited):
                return False
        return True

    def __dfs(self, vertex, inverse_adj, visited):
        """
        注意:这个递归方法的返回值是返回是否有环
        :param vertex: 结点的索引
        :param inverse_adj: 逆邻接表,记录的是当前结点的前驱结点的集合
        :param visited: 记录了结点是否被访问过,2 表示当前正在 DFS 这个结点
        :return: 是否有环
        """
        # 2 表示这个结点正在访问
        if visited[vertex] == 2:
            # 表示遇到环
            return True
        if visited[vertex] == 1:
            return False

        visited[vertex] = 2
        for precursor in inverse_adj[vertex]:
            # 如果有环,就返回 True 表示有环
            if self.__dfs(precursor, inverse_adj, visited):
                return True

        # 1 表示访问结束
        # 先把 vertex 这个结点的所有前驱结点都输出之后,再输出自己
        visited[vertex] = 1
        return False

这两种思路都可以完成 LeetCode 第 210 题。

LeetCode 第 210 题:课程表 II

传送门:210. 课程表 II

现在你总共有 n 门课需要选,记为 0n-1

在选修某些课程之前需要一些先修课程。 例如,想要学习课程 0 ,你需要先完成课程 1 ,我们用一个匹配来表示他们: [0,1]

给定课程总量以及它们的先决条件,返回你为了学完所有课程所安排的学习顺序。

可能会有多个正确的顺序,你只要返回一种就可以了。如果不可能完成所有课程,返回一个空数组。

示例 1:

输入: 2, [[1,0]] 
输出: [0,1]
解释: 总共有 2 门课程。要学习课程 1,你需要先完成课程 0。因此,正确的课程顺序为 [0,1] 。

示例 2:

输入: 4, [[1,0],[2,0],[3,1],[3,2]]
输出: [0,1,2,3] or [0,2,1,3]
解释: 总共有 4 门课程。要学习课程 3,你应该先完成课程 1 和课程 2。并且课程 1 和课程 2 都应该排在课程 0 之后。
     因此,一个正确的课程顺序是 [0,1,2,3] 。另一个正确的排序是 [0,2,1,3] 。

说明:

  1. 输入的先决条件是由边缘列表表示的图形,而不是邻接矩阵。详情请参见图的表示法
  2. 你可以假定输入的先决条件中没有重复的边。

提示:

  1. 这个问题相当于查找一个循环是否存在于有向图中。如果存在循环,则不存在拓扑排序,因此不可能选取所有课程进行学习。
  2. 通过 DFS 进行拓扑排序 - 一个关于Coursera的精彩视频教程(21分钟),介绍拓扑排序的基本概念。
  3. 拓扑排序也可以通过 BFS 完成。

思路1:拓扑排序。

Python 代码:

class Solution(object):
    def findOrder(self, numCourses, prerequisites):
        """
        :type numCourses: int 课程门数
        :type prerequisites: List[List[int]] 课程与课程之间的关系
        :rtype: bool
        """
        # 课程的长度
        clen = len(prerequisites)
        if clen == 0:
            # 没有课程,当然可以完成课程的学习
            return [i for i in range(numCourses)]
        # 入度数组,一开始全部为 0
        in_degrees = [0 for _ in range(numCourses)]
        # 邻接表
        adj = [set() for _ in range(numCourses)]
        # 想要学习课程 0 ,你需要先完成课程 1 ,我们用一个匹配来表示他们: [0,1]
        # 1 -> 0,这里要注意:不要弄反了
        for second, first in prerequisites:
            in_degrees[second] += 1
            adj[first].add(second)

        # print("in_degrees", in_degrees)
        # 首先遍历一遍,把所有入度为 0 的结点加入队列
        res = []
        queue = []
        for i in range(numCourses):
            if in_degrees[i] == 0:
                queue.append(i)

        while queue:
            top = queue.pop(0)
            res.append(top)

            for successor in adj[top]:
                in_degrees[successor] -= 1
                if in_degrees[successor] == 0:
                    queue.append(successor)
        if len(res) != numCourses:
            return []
        return res

思路2:基于逆邻接表的深度优先遍历。

Python 代码:

class Solution(object):

    def findOrder(self, numCourses, prerequisites):
        """
        :type numCourses: int 课程门数
        :type prerequisites: List[List[int]] 课程与课程之间的关系
        :rtype: bool
        """
        # 课程的长度
        clen = len(prerequisites)
        if clen == 0:
            # 没有课程,当然可以完成课程的学习
            return [i for i in range(numCourses)]

        # 逆邻接表
        inverse_adj = [set() for _ in range(numCourses)]
        # 想要学习课程 0 ,你需要先完成课程 1 ,我们用一个匹配来表示他们: [0,1]
        # 1 -> 0,这里要注意:不要弄反了
        for second, first in prerequisites:
            inverse_adj[second].add(first)

        visited = [0 for _ in range(numCourses)]
        # print("in_degrees", in_degrees)
        # 首先遍历一遍,把所有入度为 0 的结点加入队列

        res = []
        for i in range(numCourses):
            if self.__dfs(i,inverse_adj, visited, res):
                return []
        return res

    def __dfs(self, vertex, inverse_adj, visited, res):
        """
        注意:这个递归方法的返回值是返回是否有环
        :param vertex: 结点的索引
        :param inverse_adj: 逆邻接表,记录的是当前结点的前驱结点的集合
        :param visited: 记录了结点是否被访问过,2 表示当前正在 DFS 这个结点
        :return: 是否有环
        """
        # 2 表示这个结点正在访问
        if visited[vertex] == 2:
            # DFS 的时候如果遇到一样的结点,就表示图中有环,课程任务便不能完成
            return True
        if visited[vertex] == 1:
            return False
        # 表示正在访问这个结点
        visited[vertex] = 2
        # 递归访问前驱结点
        for precursor in inverse_adj[vertex]:
            # 如果没有环,就返回 False,
            # 执行以后,逆拓扑序列就存在 res 中
            if self.__dfs(precursor, inverse_adj, visited, res):
                return True

        # 能走到这里,说明所有的前驱结点都访问完了,所以可以输出了
        # 并且将这个结点状态置为 1
        visited[vertex] = 1

        # 先把 vertex 这个结点的所有前驱结点都输出之后,再输出自己
        res.append(vertex)
        # 最后不要忘记返回 False 表示无环
        return False

(本节完)


二、关键路径

重要的概念:(1)关键路径(理解为什么是权值最大的)(2)关键活动

参考资料:

1、《大话数据结构》

2、极客时间《算法与数据结构之美》,作者:王争

3、自己以前写的题解:

LeetCode 题解之 207. Course Schedule(拓扑排序模板题 1 )

https://blog.csdn.net/lw_power/article/details/80795288

LeetCode 题解之 210. Course Schedule II(拓扑排序模板题 2 )

https://blog.csdn.net/lw_power/article/details/80795355

4、深入理解拓扑排序(Topological sort)

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

拓扑排序和 dfs

解决一个有依赖的工程是否能够完成。

拓扑排序和关键路径问题。

1、AOV 网:与顶点相关。图中不允许有回路。

2、AOE 网:与边相关。整个任务是否能够完成取决于最长的关键路径。

使用拓扑排序判断 DAG 是否有回路。

LeetCode 第 207 题:课程表

参考资料:https://blog.csdn.net/ljiabin/article/details/45846837

拓扑排序的原理:在一个有向图中,每次找到一个没有前驱节点的结点(也就是入度为 0 的结点),然后把它指向的结点的边都去掉,==重复这个过程(BFS)==,直到所有结点已被找到,或者没有符合条件的节点(如果图中有环存在)。

回顾一下图的三种表示方式:

1、边表示法(即题目中表示方法);

2、邻接表法;

3、邻接矩阵表示法。

用邻接表存储图比较方便寻找入度为 0 的节点。

拓扑排序以及拓扑排序的伪代码:

LeetCode 专题:拓扑排序-2
LeetCode 专题:拓扑排序-3

拓扑排序的写法:

https://blog.csdn.net/qq508618087/article/details/50748965

LeetCode 专题:拓扑排序-4

LeetCode 第 210 题:课程表 II

要求返回一种拓扑排序的结果。

参考资料:http://zxi.mytechroad.com/blog/graph/leetcode-210-course-schedule-ii/

LeetCode 专题:拓扑排序-5

dfs 的做法:Java 的写法以被指向的课程作为键。

LeetCode 专题:拓扑排序-6

注意:下面这个 dfs 的方法,有向图的表示以先行课程作为键。

LeetCode 专题:拓扑排序-7
LeetCode 专题:拓扑排序-8

参考资料:拓扑排序:https://mp.weixin.qq.com/s/rRIz_rsp6I9zX-EiOjCCaQ

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,839评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,543评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,116评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,371评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,384评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,111评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,416评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,053评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,558评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,007评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,117评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,756评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,324评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,315评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,539评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,578评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,877评论 2 345

推荐阅读更多精彩内容