leetcode阶段总结——拓扑排序

leetcode阶段总结——拓扑排序
 

leetcode中经常出现的题型之一。其中,拓扑排序的概念可以参考这里,这里主要总结一下前300题中出现的几个关于拓扑排序的题,以待之后复习的时候查找。

leetcode207 课程表

    现在你总共有 n 门课需要选,记为 0 到 n-1。
    在选修某些课程之前需要一些先修课程。 例如,想要学习课程 0 ,你需要先完成课程 1 ,我们用一个匹配来表示他们: [0,1]
    给定课程总量以及它们的先决条件,判断是否可能完成所有课程的学习?

    示例 1:

        输入: 2, [[1,0]]
        输出: true
        解释: 总共有 2 门课程。学习课程 1 之前,你需要完成课程 0。所以这是可能的。

    示例 2:

        输入: 2, [[1,0],[0,1]]
        输出: false
        解释: 总共有 2 门课程。学习课程 1 之前,你需要先完成​课程 0;并且学习课程 0 之前,你还应先完成课程 1。这是不可能的。

    说明:

        输入的先决条件是由边缘列表表示的图形,而不是邻接矩阵。详情请参见图的表示法。
        你可以假定输入的先决条件中没有重复的边。

    提示:

        这个问题相当于查找一个循环是否存在于有向图中。如果存在循环,则不存在拓扑排序,因此不可能选取所有课程进行学习。
        通过 DFS 进行拓扑排序 - 一个关于Coursera的精彩视频教程(21分钟),介绍拓扑排序的基本概念。
         
      

本题是一道经典的「拓扑排序」问题。

给定一个包含 nnn 个节点的有向图 GGG,我们给出它的节点编号的一种排列,如果满足:

    对于图 GGG 中的任意一条有向边 (u,v)(u, v)(u,v),uuu 在排列中都出现在 vvv 的前面。

那么称该排列是图 GGG 的「拓扑排序」。根据上述的定义,我们可以得出两个结论:

    如果图 GGG 中存在环(即图 GGG 不是「有向无环图」),那么图 GGG 不存在拓扑排序。这是因为假设图中存在环 x1,x2,⋯ ,xn,x1x_1, x_2, \cdots, x_n, x_1x1​,x2​,⋯,xn​,x1​,那么 x1x_1x1​ 在排列中必须出现在 xnx_nxn​ 的前面,但 xnx_nxn​ 同时也必须出现在 x1x_1x1​ 的前面,因此不存在一个满足要求的排列,也就不存在拓扑排序;

    如果图 GGG 是有向无环图,那么它的拓扑排序可能不止一种。举一个最极端的例子,如果图 GGG 值包含 nnn 个节点却没有任何边,那么任意一种编号的排列都可以作为拓扑排序。

有了上述的简单分析,我们就可以将本题建模成一个求拓扑排序的问题了:

    我们将每一门课看成一个节点;

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

求出该图是否存在拓扑排序,就可以判断是否有一种符合要求的课程学习顺序。事实上,由于求出一种拓扑排序方法的最优时间复杂度为 O(n+m)O(n+m)O(n+m),其中 nnn 和 mmm 分别是有向图 GGG 的节点数和边数,方法见 210. 课程表 II 的官方题解。而判断图 GGG 是否存在拓扑排序,至少也要对其进行一次完整的遍历,时间复杂度也为 O(n+m)O(n+m)O(n+m)。因此不可能存在一种仅判断图是否存在拓扑排序的方法,它的时间复杂度在渐进意义上严格优于 O(n+m)O(n+m)O(n+m)。这样一来,我们使用和 210. 课程表 II 完全相同的方法,但无需使用数据结构记录实际的拓扑排序。为了叙述的完整性,下面的两种方法与 210. 课程表 II 的官方题解 完全相同,但在「算法」部分后的「优化」部分说明了如何省去对应的数据结构。
方法一:深度优先搜索

思路

我们可以将深度优先搜索的流程与拓扑排序的求解联系起来,用一个栈来存储所有已经搜索完成的节点。

    对于一个节点 uuu,如果它的所有相邻节点都已经搜索完成,那么在搜索回溯到 uuu 的时候,uuu 本身也会变成一个已经搜索完成的节点。这里的「相邻节点」指的是从 uuu 出发通过一条有向边可以到达的所有节点。

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

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

算法

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

    「未搜索」:我们还没有搜索到这个节点;

    「搜索中」:我们搜索过这个节点,但还没有回溯到该节点,即该节点还没有入栈,还有相邻的节点没有搜索完成);

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

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

    我们将当前搜索的节点 uuu 标记为「搜索中」,遍历该节点的每一个相邻节点 vvv:

        如果 vvv 为「未搜索」,那么我们开始搜索 vvv,待搜索完成回溯到 uuu;

        如果 vvv 为「搜索中」,那么我们就找到了图中的一个环,因此是不存在拓扑排序的;

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

    当 uuu 的所有相邻节点都为「已完成」时,我们将 uuu 放入栈中,并将其标记为「已完成」。

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

class Solution {
     static Map<Integer,List<Integer>> map;
	  static int [] state;
	 public boolean canFinish(int numCourses, int[][] p) {
		    if(p==null){
		    	return true;
		    }
		    map=new HashMap<Integer,List<Integer>>();
		    state=new int[numCourses];
		    for(int i=0;i<p.length;i++){
		    		if(map.containsKey(p[i][0])){
		    			map.get(p[i][0]).add(p[i][1]);
		    		}else{
		    			List<Integer> list=new ArrayList<Integer>();
		    			list.add(p[i][1]);
		    			map.put(p[i][0], list);
		    		}
		    }
		    
		    for(int i=0;i<p.length;i++){
		    	if(state[p[i][0]]==2){
		    		continue;
		    	}
		    	if(!dfs(p[i][0])){
		    		return false;
		    	}
		    }
		    return true;
		    
	    } 
	 public static boolean dfs(Integer i){
		 if(state[i]==2) return true;
		 if(state[i]==1) return false;
		 state[i]=1;
		 if(map.containsKey(i)){
			 List<Integer> list=map.get(i);
			 for(int j:list){
				 if(!dfs(j)){
					 return false;
				 }
			 }
			 
		 }
		 state[i]=2;
		 return true;
		 
	 }
}

 

方法二入度表法:

       

题意解释

    一共有 n 门课要上,编号为 0 ~ n-1。
    先决条件[1, 0],意思是必须先上课 0,才能上课 1。
    给你 n 、和一个先决条件表,请你判断能否完成所有课程。

再举个生活的例子

    先穿内裤再穿裤子,先穿打底再穿外套,先穿衣服再戴帽子,是约定俗成的。
    内裤外穿、光着身子戴帽子等,都会有点奇怪。
    我们遵循穿衣的一条条先后规则,用一串顺序行为,把衣服一件件穿上。
    我们遵循课程之间的先后规则,找到一种上课顺序,把所有课一节节上完。

    示例:n = 6,先决条件表:[[3, 0], [3, 1], [4, 1], [4, 2], [5, 3], [5, 4]]
    课 0, 1, 2 没有先修课,可以直接选。其余的课,都有两门先修课。
    我们用有向图来展现这种依赖关系(做事情的先后关系):
   
    这种叫 有向无环图,把一个 有向无环图 转成 线性的排序 就叫 拓扑排序。
    有向图有 入度 和 出度 的概念:
        如果存在一条有向边 A --> B,则这条边给 A 增加了 1 个出度,给 B 增加了 1 个入度。
    所以,顶点 0、1、2 的入度为 0。顶点 3、4、5 的入度为 2。

每次只能选你能上的课

    每次只能选入度为 0 的课,因为它不依赖别的课,是当下你能上的课。
    假设选了 0,课 3 的先修课少了一门,入度由 2 变 1。
    接着选 1,导致课 3 的入度变 0,课 4 的入度由 2 变 1。
    接着选 2,导致课 4 的入度变 0。
    现在,课 3 和课 4 的入度为 0。继续选入度为 0 的课……直到选不到入度为 0 的课。

这很像 BFS

    让入度为 0 的课入列,它们是能直接选的课。
    然后逐个出列,出列代表着课被选,需要减小相关课的入度。
    如果相关课的入度新变为 0,安排它入列、再出列……直到没有入度为 0 的课可入列。

BFS 前的准备工作

    每门课的入度需要被记录,我们关心入度值的变化。
    课程之间的依赖关系也要被记录,我们关心选当前课会减小哪些课的入度。
    因此我们需要选择合适的数据结构,去存这些数据:
    入度数组:课号 0 到 n - 1 作为索引,通过遍历先决条件表求出对应的初始入度。
    邻接表:用哈希表记录依赖关系(也可以用二维矩阵,但有点大)
        key:课号
        value:依赖这门课的后续课(数组)

怎么判断能否修完所有课?

    BFS 结束时,如果仍有课的入度不为 0,无法被选,完成不了所有课。否则,能找到一种顺序把所有课上完。
    或者:用一个变量 count 记录入列的顶点个数,最后判断 count 是否等于总课程数。

代码

 public boolean canFinish(int numCourses, int[][]p ){
		    
		    if(p==null){
		    	return true;
		    }
		    int result=0;
//记录节点被哪些节点所依赖
		    Map<Integer,List<Integer>>  map=new HashMap<Integer,List<Integer>>();
		    int [] indegree=new int[numCourses];
		    for(int i=0;i<p.length;i++){
		    		if(map.containsKey(p[i][1])){
		    			map.get(p[i][1]).add(p[i][0]);
		    		}else{
		    			List<Integer> list=new ArrayList<Integer>();
		    			list.add(p[i][0]);
		    			map.put(p[i][1], list);
		    		}
		    		indegree[p[i][0]]++;
		    }
		    Queue<Integer> stack=new LinkedList<Integer>();
		    for(int i=0;i<numCourses;i++){
		    	if(indegree[i]==0){
		    		stack.offer(i);
		    	}
            }
            while(!stack.isEmpty()){
                int numZero=stack.poll();
                result++;
                if(map.containsKey(numZero)){
                    List<Integer> list=map.get(numZero);
                    for(int num:list){
                        indegree[num]--;
                        if(indegree[num]==0){
                            stack.offer(num);
                        }
                    }
                }
            }
    
		    return result==numCourses;
		    
	    
	    }

 

leetcode210 课程表2

    现在你总共有 n 门课需要选,记为 0 到 n-1。
    在选修某些课程之前需要一些先修课程。 例如,想要学习课程 0 ,你需要先完成课程 1 ,我们用一个匹配来表示他们: [0,1]
    给定课程总量以及它们的先决条件,返回你为了学完所有课程所安排的学习顺序。
    可能会有多个正确的顺序,你只要返回一种就可以了。如果不可能完成所有课程,返回一个空数组。

    示例 1:

        输入: 2, [[1,0]]
        输出: [0,1]
        解释: 总共有 2 门课程。要学习课程 1,你需要先完成课程 0。因此,正确的课程顺序为 [0,1] 。

    示例 2:

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

    说明:

        输入的先决条件是由边缘列表表示的图形,而不是邻接矩阵。详情请参见图的表示法。
        你可以假定输入的先决条件中没有重复的边。

    提示:

        这个问题相当于查找一个循环是否存在于有向图中。如果存在循环,则不存在拓扑排序,因此不可能选取所有课程进行学习。
        通过 DFS 进行拓扑排序 - 一个关于Coursera的精彩视频教程(21分钟),介绍拓扑排序的基本概念。
         
        拓扑排序也可以通过 BFS 完成。

如果我们要返回的不仅仅是排序的可能性,而是排序结果,应该怎么办?其实很容易想通,在入度表元素变为零/某个元素的记忆化递归完成的时候,就说明这个元素已经“无牵无挂”,没有前向节点或前向节点已经加入排序列表中,可以将这个元素加入列表中了。如果忘记了思路或是看不懂了,更详细的解析也可以看这里,写的非常清楚。

代码和之前的基本相同。

入度表法

    class Solution:
        def findOrder(self, numCourses: int, prerequisites: List[List[int]]) -> List[int]:
            ##迭代方法
            adjacent = [[] for i in range(numCourses)]
            indegree = [0] * numCourses
            result = []
            for cur,pre in prerequisites:
                indegree[cur] += 1
                adjacent[pre].append(cur)
            from collections import deque
            queue = deque()
            for i in range(numCourses):
                if not indegree[i]:
                    queue.append(i)
                    result.append(i)
            while queue:
                #element = queue.popleft()
                element = queue.pop()
                numCourses -= 1
                for neighbor in adjacent[element]:
                    indegree[neighbor] -= 1
                    if not indegree[neighbor]:
                        queue.append(neighbor)
                        result.append(neighbor)
            #print(result)
            return result if numCourses == 0 else []

递归法

    class Solution:
        def findOrder(self, numCourses: int, prerequisites: List[List[int]]) -> List[int]:
            def dfs(i):
                if flag[i] == -1:
                    return True
                if flag[i] == 1:
                    return False
                flag[i] = 1
                for neighbor in adjacent[i]:
                    if not dfs(neighbor):
                        return False
                flag[i] = -1
                result.append(i)
                return True
     
            adjacent = [[] for _ in range(numCourses)]
            flag = [0] * numCourses
            result = []
            for cur,pre in prerequisites:
                adjacent[pre].append(cur)
            for j in range(numCourses):
                if not dfs(j):
                    return []
            return result[::-1]

leetcode269 火星词典

    现有一种使用字母的全新语言,这门语言的字母顺序与英语顺序不同。
    假设,您并不知道其中字母之间的先后顺序。但是,会收到词典中获得一个 不为空的 单词列表。因为是从词典中获得的,所以该单词列表内的单词已经 按这门新语言的字母顺序进行了排序。
    您需要根据这个输入的列表,还原出此语言中已知的字母顺序。

    示例 1:

        输入:
        [
          "wrt",
          "wrf",
          "er",
          "ett",
          "rftt"
        ]
         
        输出: "wertf"

    示例 2:

        输入:
        [
          "z",
          "x"
        ]
         
        输出: "zx"
         
        示例 3:

    输入: [ "z", "x", "z" ]

        输出: ""
         
        解释: 此顺序是非法的,因此返回 ""。

    注意:

        你可以默认输入的全部都是小写字母
        假如,a 的字母排列顺序优先于 b,那么在给定的词典当中 a 定先出现在 b 前面
        若给定的顺序是不合法的,则返回空字符串即可
        若存在多种可能的合法字母顺序,请返回其中任意一种顺序即可

从拓扑排序的角度来说,这个题其实不难,难点在于如何将词典这一问题抽象成拓扑排序。实际上,输入所反映的字母的先后顺序,也就是计算图中节点的指向顺序。

    from collections import defaultdict, deque
    class Solution:
        def alienOrder(self, words: List[str]) -> str:
            ## 统计节点个数
            nodes = set("".join(words))
            ## 建立计算图
            adjacent = defaultdict(list)
            indegree = dict(zip(list(nodes),[0] * len(nodes)))
            for i in range(len(words) - 1):
                word1,word2 = words[i],words[i + 1]
                lenWord = min(len(word1),len(word2))
                for j in range(lenWord):
                    if word1[j] != word2[j]:
                        adjacent[word1[j]].append(word2[j])
                        indegree[word2[j]] += 1
                        break
            ## 拓扑排序
            result = []
            queue = [i for i in indegree if indegree[i] == 0]
            while queue:
                element = queue.pop()
                result.append(element)
                for neighbor in adjacent[element]:
                    indegree[neighbor] -= 1
                    if indegree[neighbor] == 0: queue.append(neighbor)
     
            return "".join(result) if len(result) == len(nodes) else ""

在结果是否有效的判定中,之前的判定条件是numCourses == 0,这里的判断条件是len(result) == len(nodes),其实是一样的,本质都是判断是否所有节点都被遍历到。

leetcode261 以图判树

    给定从 0 到 n-1 标号的 n 个结点,和一个无向边列表(每条边以结点对来表示),请编写一个函数用来判断这些边是否能够形成一个合法有效的树结构。

    示例 1:

        输入: n = 5, 边列表 edges = [[0,1], [0,2], [0,3], [1,4]]
        输出: true

    示例 2:

        输入: n = 5, 边列表 edges = [[0,1], [1,2], [2,3], [1,3], [1,4]]
        输出: false

    注意: 你可以假定边列表 edges 中不会出现重复的边。由于所有的边是无向边,边 [0,1] 和边 [1,0] 是相同的,因此不会同时出现在边列表 edges 中。

这个题和前面的问题有一个不同之处,就是前面的问题中,我们只需要考虑图是否成环的问题,所有的点都必然是连通的。而这个问题中,我们还要考虑是否有不被其他点连通的点存在。

其他的思路几乎相同,需要注意的就是判断条件有两个:1.所有的点都联通,这由所有的点都被遍历过来判断;2.没有环,这由边的计数来判断,和之前的numCourse对应。

    class Solution:
        def validTree(self, n: int, edges: List[List[int]]) -> bool:
            adjacent = [[] for _ in range(n)]
            for x,y in edges:
                adjacent[x].append(y)
                adjacent[y].append(x)
            visited = set()
     
            def helper(prev,node):
                if node in visited: return False
                visited.add(node)
                for neighbor in adjacent[node]:
                    if neighbor == prev: continue
                    if not helper(node,neighbor): return False
                return True
     
            return helper(None,0) and len(visited) == n

另一种方法

    class Solution:
        def validTree(self, n: int, edges: List[List[int]]) -> bool:
            ##从一个点出发,能遍历所有的点,保证的是连通性
            ##无环性是这样保证的:从一个点出发,应该只能到达另一个点一次
            ##如果不重复路径可以到达两次,那就是有环
            ##我们记录下已经遍历过的点,如果有环,那么就会有已经遍历过的点再次出现,也就是有一条边没有被从总数中减掉
            ##那么就有lenEdges != 0
            ##这一套也可以改成递归,省个栈,改起来很容易
            ##最后就是注意集合比列表快得多,如果不要求顺序,还是优先用这个
            adjacent = [[] for _ in range(n)]
            for x,y in edges:
                adjacent[x].append(y)
                adjacent[y].append(x)
            visited = {0}
            stack = [0]
            lenEdges = len(edges)
            while stack:
                element = stack.pop()
                for neighbor in adjacent[element]:
                    if not neighbor in visited:
                        lenEdges -= 1
                        visited.add(neighbor)
                        stack.append(neighbor)
            return len(visited) == n and lenEdges == 0    
————————————————
版权声明:本文为CSDN博主「菲菲小姐」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/weixin_39655021/article/details/103752005

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值