[聊聊算法]拓扑排序原理及实战

这档专栏打算整理一些比较容易归纳总结的模板题,让这些知识点不仅仅是我的笔记,希望也能帮助到大家

拓扑排序(topological sorting)并不是一个纯粹的排序算法,它只是针对有向无环图,找到一个可以执行的线性顺序。这类型的题有了一定的方法,其实很容易写出来。

Directed acyclic graph (DAG),有向无环图是指:

  1. 这个图是有方向的;
  2. 这个图内没有环;

DAG 既然是有向,并且是没有环的,那就可以用它来表示一种依赖关系,就像洗衣机洗衣服一样,先倒洗衣液,再加水洗衣服。在软件领域,我们也会不知不觉地碰到DAG,例如程序模块之间的依赖关系、makefile脚本、区块链、git分支等。

那么这一组先后的依赖的关系可以用数组来表示,比如 n=6 表示有6个节点,一个依赖关系可以用一个数组表示,例如:[2, 1]表示做2之前先要完成1;那么一组依赖关系就可以用一个二维数组表示:[[2,1],[4,1],[4,2],[3,2],[5,4],[5,3]],通过对数组的表述可以构成一张图:

DAG

由这种有向无环图转成线性的排序就叫拓扑排序,在上图中 1->2->4->3->5就是一个正确的序列,而1->4->2->3->5就不是,因为做4之前要先做1和2。

拓扑排序的实现算法有两种:入度表(广度优先遍历)、DFS(深度优先遍历),其时间复杂度均为O(N+M)N 和 M 分别为节点数量和边数量。

入度表(广度优先遍历)

先来看看入度表(广度优先遍历),对于有向图来说有 入度出度 的概念:

  • 入度:有多少条边直接指向该节点;
  • 出度:由该节点指出边的有多少条。

因此,对于有向图的拓扑排序可以先建图,生成 入度表 indegrees;然后借助一个队列 queue,将所有入度为 0 的节点入队;当 queue 非空时,依次将队首节点出队,并减少相关元素入度;如果入度表中有新的元素入度变成 0 了,那么再入队,依次循环直到没有入度为 0 的元素为止。

其中还需要一个 邻接表 来辅助我们遍历出最终结果,这张表是一个 map 存放的与该节点的依赖关系。

这么说有点抽象,我们可以看一道题 《207. 课程表 https://leetcode.cn/problems/course-schedule/ 》,这道题主要就是说一共有 n 门课要上,编号为 0~ n-1;给一个条件 [1, 0],表示必须先上课 0,才能上课 1,然后让你判断是否能完成所有课程。

那么对于这道题,我们需要:

  • 创建一个数组 indegrees ,用来记录每个课程的入度;
  • 用一个 map 记录每个课程的依赖关系,这个 map 就是邻接表,key 是课号,value 是依赖这门课的后续课,value可能存在多个,所以是个数组;
  • 队列 queue 用来记录所有入度为 0 的元素;
    • 当 queue 非空时,依次将队首节点出队,用一个 count 变量记录入度为 0 的课程个数;
    • 然后从 map 里面将依赖这门课的后续课找出来,然后将这些课程的入度减一;
    • 如果有课程的入度为 0 那么再加入队列 queue 中;

比如上面这个例子:[[2,1],[4,1],[4,2],[3,2],[5,4],[5,3]]DAG

// 没有课程0所以入度为0;1课程入度为0;先做2之前要先做1,所以2课程入度为1;依次往下
indegrees = {0,0,1,1,2,2}
//第一轮 
queue = {1}
count = 0 

//第二轮
queue = {2}
count = 1 

//第三轮
queue = {3,4}
count = 2 

//第三轮
queue = {4}
count = 3 

//第四轮
queue = {5}
count = 4

//第五轮
queue = {}
count = 5

那代码其实就是:

func canFinish(numCourses int, prerequisites [][]int) bool {
    ingress := make([]int,numCourses)       //入度表
    graph := make(map[int][]int,numCourses) //邻接表
    for _,v:=range prerequisites {
        ingress[v[0]]++  //入度加1
        if _,ok := graph[v[1]];!ok{
            graph[v[1]] = []int{}
        }
        // 建立关联关系,存储和v[1]课关联课程集
        graph[v[1]] = append(graph[v[1]], v[0])
    }

    qu := []int{}
    for i,v:=range ingress{ 
        if v == 0 {//找出所有入度为0的课程
            qu = append(qu,i)
        }
    }

    count := 0
    for len(qu)>0 {
        //依次将队首节点出队
        first := qu[0]
        qu = qu[1:]
        count++ // 记录入度为 0 的课程个数,表示这个课程已经可以修炼了

        for _, v:=range graph[first] { //找出与这个课程关联的所有课程
            ingress[v]-- //因为它的前提课程可以修炼了,所以这个课程的入度减一
            if ingress[v] == 0 { //如果入度都为0了,那么代表这个课程可以修炼了,入队
                qu = append(qu, v)
            }
        }
    }
    return count == numCourses //如果可以修炼的课程和课程总数相等,那么表示可以完成所有课程的修炼
}

DFS(深度优先遍历)

DFS 实际上就是判断图中有没有环。

我们用一个 flags 用于判断每个元素的状态:

  • 未被访问的为 0;
  • 已被本轮搜索访问的为 1;
  • 已被不是本轮搜索访问的为 -1;

然后和上面一样建立一个图,用一个 map 记录每个课程的依赖关系,key 是课号,value 是依赖这门课的后续课,value可能存在多个,所以是个数组;

终止条件就是:

  • 当判断 flags[i] == 1,说明已被本轮搜索访问过,那么表示有环,直接返回 false;
  • 当判断 flags[i] == -1,说明已被其他轮搜索访问过,无需重复搜索,这是个优化项;
func canFinish(numCourses int, prerequisites [][]int) bool { 
    graph := make(map[int][]int,numCourses)
    for _,v:=range prerequisites { // 建图
        if _,ok := graph[v[1]];!ok{
            graph[v[1]] = []int{}
        }
        // 建立关联关系,存储和v[1]课关联课程集
        graph[v[1]] = append(graph[v[1]], v[0])
    }
    flags := make([]int, numCourses) //用来做标记用
    for i:=0;i<numCourses;i++{
         ret := dfs(graph, flags, i)
         if !ret {
            return false
        }
    }
    return true
}

func dfs(graph map[int][]int, flags []int, index int) bool {
    if flags[index] == -1 { //已被其他轮搜索过,停止搜索
        return true
    }
    if flags[index] == 1 { // 已被本轮搜索过,表明有环
        return false
    }
    flags[index] = 1 //标记本轮搜索过
    for _, v := range graph[index] { // 遍历所有与该节点关联的节点
        ret := dfs(graph,flags , v)
        if !ret {
            return false
        }
    }
    flags[index] = -1 //本轮搜索完之后,设置为-1,因为要开启下一轮搜索了
    return true
}

题目实战

210. 课程表 II

剑指 Offer II 114. 外星文字典

剑指 Offer II 115. 重建序列

外星文字典

剑指 Offer II 114. 外星文字典

这虽然是一道 hard 难度的题目,但是还是那个套路,这道题主要是难在如何将这个字母关系转换成为使用拓扑排序解决。

这题实际上就是对字符串 s1 和字符串 s2 进行排序时,从两者的首字母开始逐位对比,先出现较小的字母的单词排序在前。假设有这样数组 words = ["ac","ab","bc","zc","zb"] 为例,由于 "ac" < "ab" 所以 'c' < 'b',由于 "ab" < "bc" 所以 'a' < 'b',依次可得 'b' < 'z''c' < 'b'

首先我们可以把每个字母看作图的节点,若已知两个字母之间的大小关系,那么图中就存在一条从较小的字母指向较大字母的边。

那么对于这道题,我们需要:

  • 和其他拓扑排序一样,创建一个 inGress 入度表;
  • 创建一个 map 作为邻接表,记录各个字母的依赖关系,key 是字母 byte,value 是与 key 相邻的字母;
  • 我们这里还多了一步建图,因为需要将所有的字母添加到入度表中;

下面是代码实现:

func alienOrder(words []string) string {
    inGress := make(map[byte]int)
    graph := make(map[byte][]byte)
    // 建图
    for _, v := range words {
        for k := range v {
            c := v[k]
            inGress[c] = 0
            if _, ok := graph[c]; !ok {
                graph[c] = []byte{}
            }
        }
    }
    // 计算邻接表和入度
    for i:=1;i<len(words);i++{
        w1 := words[i-1]
        w2 := words[i]
        minLen := min(len(w1),len(w2))
        j:=0
        for ;j<minLen;j++{
            c1 := w1[j]
            c2 := w2[j]
            if c1 != c2 {
                graph[c1] = append(graph[c1], c2)
                inGress[c2]++ 
                // 这里要终止掉,因为只会第一个不同的字母
                // 比如 abc 和 acd, 第二字母不同可以建立顺序关系,但是第三不同就不行了
                break; 
            }  
        }
        // 若两者的前缀相同,但前者的单词长度长于后者,如 "abc" 和 "ab"。这是不符合排序规则的
        if j == minLen && len(w1) > minLen {
            return ""
        }
    }

    res := []byte{}
    qu := []byte{}
    // 入度为 0 的点
    for k,v := range inGress{
        if v == 0 {
            qu = append(qu, k)
        }
    }

    for len(qu) > 0 {
        cu := qu[0]
        qu = qu[1:]
        res = append(res, cu)

        for _, v := range graph[cu] {
            inGress[v]--
            if inGress[v] == 0 {
                qu = append(qu, v)
            }
        }
    }
    if len(res) != len(inGress) {
        return ""
    }
    return string(res)
}

func min ( a,b int) int {
    if a< b{
        return a
    }
    return b
}

重建序列

剑指 Offer II 115. 重建序列

其实这题也是套路化的,需要注意的一点是由于题目中已经说明了seq[i]是 nums 的子序列,所以我们只需要判断该拓扑排序是否有唯一解就行了。

那么对于这道题,我们需要:

  • 首先用 seq 建图,但是seq[i]里面可能存在多个元素,这一点和以往的建图方式不太一样,需要用循环遍历;
  • 队列 queue 用来记录所有入度为 0 的元素,在遍历队列出队的时候需要判断一下队列里面的元素是不是大于1,大于1说明同时有两个元素的入度是0,表示可以有多种解法;
func sequenceReconstruction(nums []int, sequences [][]int) bool {
    graph := make(map[int][]int)
    inGress := make([]int,len(nums)+1) 
    for i:=0;i<len(sequences);i++{
        cur := sequences[i]
         //注意这里是一个子序列,有多个元素,不是两个
        for j:=1;j<len(cur);j++ {
            s1 := cur[j-1]
            s2 := cur[j] 
            graph[s1] = append(graph[s1],s2) 
            inGress[s2]++
        }
    }
    qu := []int{}
    for i:=1;i<= len(nums);i++{
        //入度为0的点入队
        if inGress[i] == 0 {
            qu = append(qu,i)
        }
    }

    for len(qu)>0{
        //存在多个入度为0的点 会有多个超序列 直接返回false
        if len(qu)>1 {
            return false
        }
        cur := qu[0]
        qu = qu[1:]
        for _, v := range graph[cur] {
            //和此点连通的点入度减一
            inGress[v]--
            if inGress[v] == 0 {
                qu = append(qu, v)
            }
        }
    }
    return true
} 

总结

要解决拓扑排序问题,通过上面的讲解我们可以知道主要分为这么几步:首先建图和建立入度表;然后找出所有入度为0的元素加入队列中;将队列中的元素循环出队,遍历与之关联的节点,并将入度减1。很多题目主要难在建图和入度表,这需要观察题目中给出的元素之间的依赖关系。

Reference

https://en.wikipedia.org/wiki/Topological_sorting

https://zhuanlan.zhihu.com/p/135094687

https://leetcode.cn/problems/ur2n8P/solutions/1691032/zhong-jian-xu-lie-by-capital-worker-n6ti/?orderBy=most_votes

https://mp.weixin.qq.com/s?__biz=MzU4NDE3MTEyMA==&mid=2247489706&idx=1&sn=771cd807f39d1ca545640c0ef7e5baec

https://leetcode.cn/problems/course-schedule/solutions/18806/course-schedule-tuo-bu-pai-xu-bfsdfsliang-chong-fa/

https://zhuanlan.zhihu.com/p/93647900

扫码_搜索联合传播样式-白色版 1