【图】力扣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] 。
可能会有多个正确的顺序,只要返回 任意一种 就可以了。如果不可能完成所有课程,返回 一个空数组。
拓扑排序
- 定义
给定一个包含 n 个结点的有向图 G,给出它的结点编号的一种排列,如果满足: 对于图 G 中的任意一条有向边 (u, v),u 在排列中都出现在 v 的前面。那么称该排列是图 G 的「拓扑排序」。
拓扑排序 是【广度优先搜索】和【贪心算法】应用于【有向图】的一个专有名词
- 贪心算法的思想是每一步最优,则全局最优。具体到拓扑排序,每一次都从图中删除没有前驱的顶点(并不需要做真正的删除操作),可以设置一个入度数组,每一轮都输出入度为 0 的结点,并移除它、修改它指向的结点的入度(减 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) 的空间存储结点状态、最终答案等。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 【自荐】一款简洁、开源的在线白板工具 Drawnix
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
· Docker 太简单,K8s 太复杂?w7panel 让容器管理更轻松!