LeetCode207 课程表(带扩展)

97 阅读7分钟

leetcode.cn/problems/co…

image.png

解法一:有向图上的环检测

题意其实就是要求判断课程学习顺序之间是否存在循环依赖

看到依赖问题,首先想到的就是把问题转化成「有向图」这种数据结构,只要图中存在环,那就说明存在循环依赖

具体来说,我们首先可以把课程看成「有向图」中的节点,节点编号分别是 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… image.png 来看看这道题的进阶版,不仅让你判断是否可以完成所有课程,而是进一步让你返回一个合理的上课顺序,保证开始修每个课程时,前置的课程都已经修完。

其实也不难看出来,如果把课程抽象成节点,课程之间的依赖关系抽象成有向边,那么这幅图的拓扑排序结果就是上课顺序。

拓扑排序直观来说,就是让你把一幅图「拉平」,而且这个「拉平」的图里面,所有箭头方向都是一致的。如下图所示: image.png 很显然,如果一幅有向图中存在环,是无法进行拓扑排序的,因为肯定做不到所有箭头方向一致;反过来,如果一幅图是「有向无环图」,那么一定可以进行拓扑排序。

那么如何得到一个拓扑排序结果呢?需要借助图的后序遍历了,但是还需结合图上边的定义具体分析:

  1. 假设有向图上的边箭头是代表【依赖】关系,例如先修完 课程 0才能修课程 1,那么会有一条边 1 -> 0,表示课程 1依赖课程 0,那么这张图的一个后序遍历结果就是一个拓扑排序。
  2. 假设有向图上的边箭头是代表【被依赖】关系,例如先修完 课程 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
}

参考

图的遍历框架