解法一:有向图上的环检测
题意其实就是要求判断课程学习顺序之间是否存在循环依赖
看到依赖问题,首先想到的就是把问题转化成「有向图」这种数据结构,只要图中存在环,那就说明存在循环依赖。
具体来说,我们首先可以把课程看成「有向图」中的节点,节点编号分别是 0, 1, ..., numCourses-1,把课程之间的依赖关系看做节点之间的有向边。比如说,要求必须修完课程 1 才能去修课程 3,那么,就有一条有向边从节点 1 指向节点 3。
如果发现这幅有向图中存在环,那就说明课程之间存在循环依赖,肯定没办法全部上完;反之,如果没有环,那么肯定能上完全部课程。
图的存储形式主要有邻接矩阵和邻接表,对于一幅有 V个节点,E条边的图,二者的空间复杂度分别是:
- 邻接矩阵:O(V^2)
- 邻接表:O(V+E)
所以,如果一幅图的边数远小于V^2(稀疏图),那么邻接表会更节省空间,反之如果边数很接近V^2,那实际上二者差不多。
分析该题意可发现,并不是所有课程之间都有依赖关系,也就是说图中并不是所有节点都相连,这是一张稀疏图,我们采用邻接表进行存储。
那么如何找环呢,其实就是要去遍历这张图,如果过程中遇到重复的节点,那不就说明成环了。遍历图有DFS和BFS两种方式。
DFS遍历
func canFinish(numCourses int, prerequisites [][]int) bool {
graph := buildGraph(numCourses, prerequisites) // 构建一张有向图
onPath := make([]bool, numCourses) // 记录每次递归遍历路径上的节点
visited := make([]bool, numCourses) // 记录已经遍历过的节点,避免重复计算
var hasCycle bool
for i := 0; i < numCourses; i++ { // 由于图中并不是所有节点都相连,因此需要将每个节点都作为起点搜索一遍
traverse(graph, i, onPath, visited, &hasCycle)
}
return !hasCycle
}
func traverse(graph [][]int, nodeID int, onPath []bool, visited []bool, hasCycle *bool) {
if *hasCycle { // 已经找到了一个环即可确认答案
return
}
if onPath[nodeID] { // 该节点之前遍历过,再次相遇,说明成环了
*hasCycle = true
return
}
if visited[nodeID] { // 不重复遍历已遍历判断过的节点
return
}
// 标记当前节点已遍历过
onPath[nodeID] = true
visited[nodeID] = true
// DFS遍历所有相邻节点
for _, neighbor := range graph[nodeID] {
traverse(graph, neighbor, onPath, visited, hasCycle)
}
// 回溯,撤销选择
onPath[nodeID] = false
}
func buildGraph(numCourses int, prerequisites [][]int) [][]int {
graph := make([][]int, numCourses) // 每个课程即一个图上的节点
for idx := range graph {
graph[idx] = make([]int, 0)
}
// 根据课程依赖关系构建图上的边
for _, edge := range prerequisites {
// eg: [0, 1]表示要学习课程0,需要先修课程1,因此应该是 1->0的路径
from := edge[1]
to := edge[0]
graph[from] = append(graph[from], to)
}
return graph
}
BFS遍历
BFS一遍借助队列实现,这里为了判断是否有环,需要借助一些技巧,图有入度和出度的概念,如果一个节点 x有 a 条边指向别的节点,同时被 b条边所指向,则称节点 x的出度为 a,入度为 b。
func canFinish(numCourses int, prerequisites [][]int) bool {
graph := buildGraph(numCourses, prerequisites)
inDegrees := make([]int, numCourses) // 记录每个节点的入度
for _, edge := range prerequisites {
// eg: [0, 1]表示要学习课程0,需要先修课程1,因此图上应该是 1->0的路径
to := edge[0]
inDegrees[to]++ // 被指向的节点入度加1
}
// 入度为0的才可以作为BFS遍历起点,即没有前置课程依赖可先开始学习,参考拓扑排序
queue := make([]int, 0)
for nodeID, inDegree := range inDegrees{
if inDegree == 0{
queue = append(queue, nodeID)
}
}
count := 0 // 记录遍历的总节点数
for len(queue) > 0{
size := len(queue)
for i:=0; i<size; i++{
// 弹出队头节点nodeID,遍历节点计数+1,并将其指向的节点入度都减1
nodeID := queue[0]
queue = queue[1:]
count++
for _, neighbor := range graph[nodeID]{
inDegrees[neighbor]--
if inDegrees[neighbor] == 0{ // 更新后入度为0,说明所有依赖neighbor的节点都已经遍历过了,即前置课程都已经修完了,那么当前课程也可以学习了
queue = append(queue, neighbor)
}
}
}
}
// 是否完成所有课程的学习
return count == numCourses
}
func buildGraph(numCourses int, prerequisites [][]int) [][]int{
graph := make([][]int, numCourses)
for idx := range graph{
graph[idx] = make([]int, 0)
}
for _, edge := range prerequisites{
// eg: [0, 1]表示要学习课程0,需要先修课程1,因此图上应该是 1->0的路径
from := edge[1]
to := edge[0]
graph[from] = append(graph[from], to)
}
return graph
}
扩展:课程表 II
leetcode.cn/problems/co…
来看看这道题的进阶版,不仅让你判断是否可以完成所有课程,而是进一步让你返回一个合理的上课顺序,保证开始修每个课程时,前置的课程都已经修完。
其实也不难看出来,如果把课程抽象成节点,课程之间的依赖关系抽象成有向边,那么这幅图的拓扑排序结果就是上课顺序。
拓扑排序直观来说,就是让你把一幅图「拉平」,而且这个「拉平」的图里面,所有箭头方向都是一致的。如下图所示:
很显然,如果一幅有向图中存在环,是无法进行拓扑排序的,因为肯定做不到所有箭头方向一致;反过来,如果一幅图是「有向无环图」,那么一定可以进行拓扑排序。
那么如何得到一个拓扑排序结果呢?需要借助图的后序遍历了,但是还需结合图上边的定义具体分析:
- 假设有向图上的边箭头是代表【依赖】关系,例如先修完 课程 0才能修课程 1,那么会有一条边 1 -> 0,表示课程 1依赖课程 0,那么这张图的一个后序遍历结果就是一个拓扑排序。
- 假设有向图上的边箭头是代表【被依赖】关系,例如先修完 课程 0才能修课程 1,那么会有一条边 0 -> 1,表示课程 0被课程 1所依赖,那么需要对图的后序遍历结果进行反转,才是拓扑排序结果。
下面解法均采用第一种思路构造图
DFS遍历
func findOrder(numCourses int, prerequisites [][]int) []int {
graph := buildGraph(numCourses, prerequisites)
onPath := make([]bool, numCourses) // 表示递归路径上遍历过的节点
visited := make([]bool, numCourses) // 记录全局已经遍历过的节点
var hasCycle bool
var postOrder []int // 图的后序遍历结果
for nodeID := 0; nodeID < numCourses; nodeID++{
dfs(graph, nodeID, onPath, visited, &hasCycle, &postOrder)
}
if hasCycle{ // 成环不可能修完所有课程
return []int{}
}
return postOrder
}
func dfs(graph [][]int, nodeID int, onPath []bool, visited []bool, hasCycle *bool, postOrder *[]int){
if *hasCycle{ // 找到一个环就提前退出
return
}
if onPath[nodeID]{ // 说明成环了
*hasCycle = true
return
}
if visited[nodeID]{ // 已经判断过无环的起点,避免重复计算
return
}
visited[nodeID] = true
// 回溯法
onPath[nodeID] = true // 当前节点加入选择
for _, neighbor := range graph[nodeID]{ // 递归遍历其所有相邻节点
dfs(graph, neighbor, onPath, visited, hasCycle, postOrder)
}
onPath[nodeID] = false // 撤销选择
// 后序遍历
*postOrder = append(*postOrder, nodeID)
}
func buildGraph(numCourses int, prerequisites [][]int) [][]int{
graph := make([][]int, numCourses)
for idx := range graph{
graph[idx] = make([]int, 0)
}
for _, edge := range prerequisites{
// [0, 1]表示要学习课程0,需要先修课程1,构建一条 0 -> 1表示依赖关系
from := edge[0]
to := edge[1]
graph[from] = append(graph[from], to)
}
return graph
}
BFS遍历
对BFS搜索策略来说,若图上的边箭头是表示【被依赖】关系,那么节点的BFS遍历顺序就是拓扑排序的结果,只需要在前面的环检测算法中加上一个记录遍历节点的数组即可
func findOrder(numCourses int, prerequisites [][]int) []int {
graph := buildGraph(numCourses, prerequisites)
inDegrees := make([]int, numCourses) // 下标为节点,元素值表示该节点入度
for _, edge := range prerequisites{ // 所有被依赖的节点入度增加
to := edge[0]
inDegrees[to]++
}
queue := make([]int, 0)
// 初始队列中只加入入度为0的节点,表示没有任何前置依赖的课程
for node, inDegree := range inDegrees{
if inDegree == 0{
queue = append(queue, node)
}
}
// 执行BFS遍历
res := []int{}
count := 0
// 不断弹出队列中的节点,减少相邻节点的入度,并将入度变为 0 的节点加入队列
for len(queue) > 0{
size := len(queue)
for i := 0; i<size; i++{
node := queue[0]
queue = queue[1:]
count++
res = append(res, node)
for _, neighbor := range graph[node]{
inDegrees[neighbor]--
if inDegrees[neighbor] == 0{
queue = append(queue, neighbor)
}
}
}
}
if count != numCourses{
return []int{}
}
return res
}
func buildGraph(numCourses int, prerequisites [][]int) [][]int{
graph := make([][]int, numCourses)
for idx := range graph{
graph[idx] = make([]int, 0)
}
for _, edge := range prerequisites{
// [0, 1]表示要学习课程0,需要先修课程1,构建一条 1 -> 0表示学习顺序
from := edge[1]
to := edge[0]
graph[from] = append(graph[from], to)
}
return graph
}