【图】力扣210:课程表 II-拓扑排序

给定 N 个课程和这些课程的前置必修课,求可以一次性上完所有课的顺序。

输入是一个正整数,表示课程数量;以及一个二维矩阵,表示所有的有向边(如 [1,0] 表示上课程 1 之前必须先上课程 0)。输出是一个一维数组,表示拓扑排序结果。

示例:

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

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

拓扑排序

  1. 定义

给定一个包含 n 个结点的有向图 G,给出它的结点编号的一种排列,如果满足: 对于图 G 中的任意一条有向边 (u, v),u 在排列中都出现在 v 的前面。那么称该排列是图 G 的「拓扑排序」。

拓扑排序 是【广度优先搜索】和【贪心算法】应用于【有向图】的一个专有名词

  • 贪心算法的思想是每一步最优,则全局最优。具体到拓扑排序,每一次都从图中删除没有前驱的顶点(并不需要做真正的删除操作),可以设置一个入度数组,每一轮都输出入度为 0 的结点,并移除它、修改它指向的结点的入度(减 1 即可),依次得到的结点序列就是拓扑排序的结点序列。如果图中还有结点没有被移除,则说明“不能完成所有课程的学习”。
  1. 作用
  • 得到一个拓扑序,且这个拓扑序可能不唯一

  • 检测有向图是否有环

    • 检测无向图是否有环使用的数据结构是 并查集
  1. 应用:任务调度计划、课程安排 ……

将本题建模成一个求拓扑排序的问题:

  • 先建立一个邻接矩阵表示图,方便进行直接查找

    • 注意此题可以先将所有的边反向,使得如果课程 i 指向课程 j,那么课程 i 需要在课程 j 前面先修完。更符合直观理解
  • 将每一门课看成一个结点

  • 如果想要学习课程 A 之前必须完成课程 B,就从 B 到 A 连接一条有向边。这样以来,在拓扑排序中,B 一定出现在 A 的前面

求出该图的拓扑排序,就可以得到一种符合要求的课程学习顺序。

方法一:广度优先搜索 BFS

拓扑排序可以被看成是广度优先搜索的一种情况:

  • 在开始排序之前,先遍历一遍所有结点,把入度为 0 的所有结点 u(即没有前置课程要求)放在队列中,不必注意相对顺序

  • 只要队列非空,就从队首取出入度为 0 的结点,将这个结点添加到结果集 res 中,并且移除其所有出边,即 将这个结点的所有邻接结点(它指向的结点)的入度各减 1

  • 如果在这个过程中被减 1 的结点 v 的入度变成了 0(表示有课程的所有前置必修课都已修完),就把这个结点 v 加入队列中

  • 当队列的结点都被处理完时,说明所有的结点都已排好序 因图中存在循环而无法上完所有课程

from collections import deque
class Solution:
    def findOrder(self, numCourses: int, prerequisites: List[List[int]]) -> List[int]:
        if numCourses == 0: # 此题较为特殊,已经给出了列表的长度,不需增加一个变量来存储
            return []

        edge = collections.defaultdict(list) # 将有向图处理为邻接表,edge = [set() for _ in range(numCourses)]
        indegree = [0] * numCourses # 初始化每个结点的入度
        res = list() # 结果集

        # 获取有向图中所有结点的入度,并将相邻结点加入到邻接表内
        # 想要学习课程 i 需要先完成课程 j,用列表表示为 [i,j],用图表示为 j -> i,注意不要弄反了
        for info in prerequisites:
            edge[info[1]].append(info[0])
            indegree[info[0]] += 1 # info[0] 是第一个元素,表示后学课程,添加入度
        '''
        排序之前,将所有入度为 0 的结点加入到队列中
        queue = []
        for u in range(numCourses):
            if indegree[u] == 0:
                queue.append(u)
        '''
        queue = collections.deque([u for u in range(numCourses) if indegree[u] == 0]) # 注意格式
        while queue:
            u = queue.popleft() # 从队首取出结点
            res.append(u) # 加入到结果集
            for v in edge[u]: # v 是 u 的相邻结点,取出 u 时要将 v 的入度减少 1
                indegree[v] -= 1
                if indegree[v] == 0: # 当结点的入度为 0 时入队
                    queue.append(v)

        if len(res) != numCourses: # 有向图中存在环,意味着不可能完成所有课程,结果集就是一个空数组
            res = []
        return res

时间复杂度: O(n+m),其中 n 为课程数,m 为先修课程的要求数。这其实就是对图进行广度优先搜索的时间复杂度。

空间复杂度: O(n+m)。题目中是以列表形式给出的先修课程关系,为了对图进行广度优先搜索,需要存储成邻接表的形式,空间复杂度为 O(n+m)。在广度优先搜索的过程中,需要最多 O(n) 的队列空间(迭代)进行广度优先搜索,并且还需要若干个 O(n) 的空间存储结点入度、最终答案等。

方法二:深度优先搜索 BFS

DFS 使用的是逆邻接表,需要对原滋原味的 DFS 作一些处理。作用是检测这个有向图中有没有环,只要存在环,这些课程就不能按要求学完。

假设当前搜索到了结点 u,如果它的所有相邻结点都已经搜索完成,那么这些结点都已经在栈中了,此时就可以把 u 入栈。可以发现,如果从栈顶往栈底的顺序看,由于 u 处于栈顶的位置,那么 u 出现在所有 u 的相邻结点的前面。因此对于 u 这个结点而言,它是满足拓扑排序的要求的。

这样就对图进行了一遍深度优先搜索。当每个结点进行回溯的时候,把该结点放入栈中。最终从栈顶到栈底的序列就是一种拓扑排序。

具体而言:

对于图中的任意一个结点,它在搜索的过程中有三种状态,即:

  • 「未搜索」:还没有搜索到这个结点

  • 「搜索中」:搜索过这个结点,但还没有回溯到该结点,即该结点还没有入栈,还有相邻的结点没有搜索完成

  • 「已完成」:搜索过并且回溯过这个结点,即该结点已经入栈,并且所有该结点的相邻节点都出现在栈的更底部的位置,满足拓扑排序的要求

通过上述的三种状态,可以给出使用深度优先搜索得到拓扑排序的算法流程,在每一轮的搜索搜索开始时,任取一个「未搜索」的结点开始进行深度优先搜索。

  • 将当前搜索的结点 u 标记为「搜索中」,遍历该结点的每一个相邻结点 v

    • 如果 v 为「未搜索」就搜索 v,等到搜索完成回溯到 u

    • 如果 v 为「搜索中」,那么就找到了图中的一个环,因此是不存在拓扑排序的,结果为空

    • 如果 v 为「已完成」,那么说明 v 已经在栈中了,而 u 还不在栈中,因此 u 无论何时入栈都不会影响到 (u, v) 之前的拓扑关系,不用进行任何操作

  • 当 u 的所有相邻结点都为「已完成」时,将 u 放入栈中,并将其标记为「已完成」

  • 在整个深度优先搜索的过程结束后,如果没有找到图中的环,那么栈中存储这所有的 n 个结点,从栈顶到栈底的顺序即为一种拓扑排序

算法步骤:

  • 构建逆邻接表

  • 递归处理每一个还没有被访问的结点,具体做法很简单:对于一个结点来说,先输出指向它的所有顶点,再输出自己

  • 如果这个顶点还没有被遍历过,就递归遍历它,把所有指向它的结点都输出了,再输出自己

    • 注意:当访问一个结点的时候,应当先递归访问它的前驱结点,直至前驱结点没有前驱结点为止
class Solution:
    def findOrder(self, numCourses: int, prerequisites: List[List[int]]) -> List[int]:

        edge = collections.defaultdict(list) # 邻接表存储有向图
        visited = [0] * numCourses # 标记每个节点的状态,初始化为 0:0=未搜索,1=搜索中,2=已完成
        res = list() # 用数组来模拟栈:下标 0 为栈底,n-1 为栈顶
        valid = True # 判断有向图中是否有环

        for info in prerequisites: # 填充邻接表
            edge[info[1]].append(info[0])

        def dfs(u):
            nonlocal valid # 声明变量
            visited[u] = 1 # 将结点标记为「搜索中」
            # 搜索其相邻结点。只要发现有环,立刻停止搜索
            for v in edge[u]:
                if visited[v] == 0: # 如果 v 为「未搜索」状态那么搜索相邻结点
                    dfs(v)
                    if not valid:
                        return
                elif visited[v] == 1: # 如果 v 为「搜索中」状态说明找到了环
                    valid = False
                    return
            # 将初始结点标记为「已完成」并入栈
            visited[u] = 2
            result.append(u)

        # 每次挑选一个「未搜索」的节点,开始进行深度优先搜索
        for i in range(numCourses):
            if valid and not visited[i]:
                dfs(i)

        if not valid:
            return list() # return []

        # 如果没有环,那么就有拓扑排序。注意下标 0 为栈底,因此需要将数组反序输出
        return res[::-1]

作者:LeetCode-Solution
链接:https://leetcode.cn/problems/course-schedule-ii/solution/ke-cheng-biao-ii-by-leetcode-solution/

时间复杂度: O(n+m),其中 n 为课程数,m 为先修课程的要求数。这其实就是对图进行深度优先搜索的时间复杂度。

空间复杂度: O(n+m)。将图存储成邻接表的形式,空间复杂度为 O(n+m)。在深度优先搜索的过程中,需要最多 O(n) 的栈空间(递归)进行深度优先搜索,并且还需要若干个 O(n) 的空间存储结点状态、最终答案等。

posted @   Vonos  阅读(197)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 【自荐】一款简洁、开源的在线白板工具 Drawnix
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
· Docker 太简单,K8s 太复杂?w7panel 让容器管理更轻松!
点击右上角即可分享
微信分享提示