图解:什么是拓扑排序?


Python实战社群

Java实战社群

长按识别下方二维码,按需求添加

扫码关注添加客服

进Python社群▲

扫码关注添加客服

进Java社群


作者丨景禹 
来源丨景禹(LifeAtaraxia)

今天景禹给你们谈一谈拓扑排序,乍一听上去,感觉很高大上,但她的确很高大上,所以一起征服她吧!在正式介绍拓扑排序之前,我们一起看一看必备基础。

拓扑排序基础篇

总觉得书上的概念有点欠缺生动,但还是需要这些基础的概念作为支撑。
什么是有向无环图?
一个 无环的有向图 称为 有向无环图(Directed Acycline Graph),简称 DAG 图。(这不等于没说吗?)所以直接看图。
图中最左边的是有向树,中间的是有向无环图,最右则的是有向图(因为图中BED三个顶点之间构成一个有向环,ACEB也存在环路)。
什么是 “活动” ?
所有的工程或者某种流程都可以分为若干个小的工程或者阶段,我们称这些小的工程或阶段为“活动”。打个比方,如何把一只大象装到冰箱里,很简单,分三步。第一,打开冰箱门;第二,将大象装进去;第三,关上冰箱门。这三步中的每一步便是一个 “活动” 。
什么是AOV网?
在一个表示工程的有向图中,用 顶点表示活动,用弧表示活动之间的优先关系的有向图 称为顶点表示活动的网(Activity On Vertex Network),简称AOV网。
AOV网中的弧表示活动之间存在的某种制约关系,比如上面说到将大象装入冰箱,必须先打开冰箱门,才能将大象装进去,大象装进去才能关上冰箱门,从而完成我们的任务。还有一个经典的例子那就是选课,通常我们是学了C语言程序设计,才能学习数据结构,这里的制约关系就是课程之间的优先关系。(之后我们会讲一个完整的例子,大家就会更清楚AOV网)
什么是拓扑序列?
设G=(V,E)是一个具有n个顶点的有向图,V中的顶点序列 满足若从顶点 有一条路径,则在顶点序列中顶点 必在顶点 之前。则我们称这样的顶点序列为一个拓扑序列。
什么是拓扑排序呢?
所谓的拓扑排序,其实就是对一个有向无环图构造拓扑序列的过程。当然这里的说法不够正式,也是为了理解方便,拓扑排序的官方定义是这样的:
由某个集合上的一个偏序得到该集合上的一个全序的操作过程称为拓扑排序。
那什么是偏序,什么又是全序呢,我希望小禹禹上课的时候好好听《离散数学》老师讲课,你就可以严格地按照数学的方式对拓扑排序进行推导了。题外话,《离散数学》真的很重要,和所有计算机基础都是一样重要,景禹研究生的硕士论文就是用离散数学的知识和数据结构中的并查集解决的,还望各位小禹禹珍重奥,景禹不会骗你的,就像我说的 “景禹想带你拥有更美好的生活,化作你的一把伞”,有些经验教训没有必要自己尝试一番才领悟。
接下来,景禹带你一起走进拓扑排序的大门。

拓扑排序思想篇

所谓思想篇,就是考试中常考的内容,所以参加考试的朋友最好跟着景禹的讲解自己在纸上画一画,景禹相信对你的考试定有帮助。
拓扑排序的算法步骤很简单,就是两步:
(1) 在有向图中选一个没有前驱的顶点且输出之。
(2) 从图中删除该顶点和所有以它为尾的弧。
重复上述两步,直至全部顶点均已输出,或者当前图不存在无前驱的顶点为止,后一种情况说明有向图中存在环。
为了清楚地理解拓扑排序思想,我们分别采用有向无环图和有向有环图进行举例说明。

有向无环图

第一步:在有向图中选择一个没有前驱的顶点并输出;观察图中的顶点,发现顶点 和顶点 都是没有前驱的顶。假设我们先输出顶点 (当然也可以先输出 ,从此处也就可以看出拓扑序列可以有多个)。
第二步:从图中删除顶点 和所有以它为尾的弧(即上图中的红色有向边)。
之后的步骤就是重复一二两步,我们接着看。
第三步:在有向图中选择一个没有前驱的顶点并输出;图中没有前驱的顶点为 和顶点 (同样的道理,我们可以选择这两个顶点的任何一个,假设我们选择顶点 )。
第四步:删除顶点   和所有以它为尾的弧。
第五步:在有向图中选择一个没有前驱的顶点并输出;图中没有前驱的顶点为 和顶点 (同样的道理,我们可以选择这两个顶点的任何一个,假设我们选择了顶点 )。
第六步:删除顶点   和所有以它为尾的弧。
第七步:在有向图中选择一个没有前驱的顶点并输出;图中没有前驱的顶点为
第八步:删除顶点   和所有以它为尾的弧。
第九步:在有向图中选择一个没有前驱的顶点并输出;图中没有前驱的顶点为 和   (同样的道理,我们可以选择这两个顶点的任何一个,假设我们选择了顶点 ) 。
第10步:删除顶点   和所有以它为尾的弧。
第11步:在有向图中选择一个没有前驱的顶点并输出;图中没有前驱的顶点为   ,选择并输出,此时所有的顶点均已经输出,算法结束,我们就得到了下图中的 一个拓扑序列 ,整个过程便叫做 拓扑排序

有向有环图

其实过程和之前类似,只是给大家提供一个思路,如果面试官问你,如何判断一个有向图中是否存在环时,你应该第一反应想到的就是拓扑排序,为什么拓扑排序可以判断一个有向图中是否存在环呢?我们看栗子。
第一步:在有向图中选择一个没有前驱的顶点并输出;图中没有前驱的顶点为A。
第二步:删除顶点A和所有以它为尾的弧。
第三步:在有向图中选择一个没有前驱的顶点并输出;图中没有前驱的顶点为C。
第四步:删除顶点B和所有以它为尾的弧。
第五步:在有向图中选择一个没有前驱的顶点并输出;发现当前图不存在无前驱的顶点,但拓扑序列中并未输出所有的顶点,所以剩下的顶点构成了环,也证明了该有向图存在环。

拓扑排序实现篇

针对于拓扑排序思想篇提到的两步操作,我们采用邻接表作为有向图的存储结构,并且在头结点中增加一个存放顶点入度的数组(indegree)。入度为零的顶点即为没有前驱的顶点,删除顶点及以它为尾的弧的操作,则则可换以弧头顶点的入度减1来实现。
为了避免重复检查入度为零的顶点,可另设一个栈暂存所有入度为领的顶点。
为了清晰地呈现拓扑排序的实现,我们还是以上面提到的有向无环图为栗子进行讲解,Step By Step。
图的邻接表表示。
第一步:遍历所有顶点,将入度为0的顶点入栈,即分别将顶点 和顶点 入栈。
第二步:弹出栈顶顶点 并输出,遍历顶点 的邻接顶点,即index == 3 和 index == 4 的顶点。将 index == 3 的顶点  的入度减 1 ,发现不为0,则不入栈;将 index == 4 的顶点  的入度减 1 ,发现不为0,则不入栈;
第三步:弹出栈顶顶点 并输出,然后遍历顶点   的邻接顶点,即 index == 1,index == 2,index == 3的顶点。将 index == 1 的顶点  的入度减 1等于1 ,不为0,则不入栈;将 index == 2 的顶点  的入度减 1等于0 ,则入栈;将 index == 3 的顶点  的入度减 1等于0 ,则入栈;
第四步:弹出栈顶顶点 并输出,然后遍历顶点   的邻接顶点,即 index == 4的顶点;将 index == 4 的顶点  的入度减 1等于1 ,不为0,则不入栈;
第五步:弹出栈顶顶点 并输出,然后遍历顶点   的邻接顶点,即 index == 1 和  index == 4 的顶点;将 index == 1 的顶点  的入度减 1等于0 ,则入栈;将 index == 4 的顶点  的入度减 1等于0 ,则入栈;
第六步:弹出栈顶顶点 并输出,顶点 没有后继顶点;
第七步:弹出栈顶顶点 并输出,顶点    没有后继顶点;
此时栈为空且所有的顶点均已输出,故算法终止。
有了上面的基础,我们再来看代码就轻松多了。
// 拓扑排序算法
// 若GL无回路,则输出拓扑排序序列并返回OK,否则返回ERROR
Status TopologicalSort(GraphAdjList GL)
{
   EdgeNode *e;
   int i, k, gettop;
   int top = 0;  // 用于栈指针下标索引
   int count = 0;  // 用于统计输出顶点的个数
   int *stack;   // 用于存储入度为0的顶点
 
   stack = (int *)malloc(GL->numVertexes * sizeof(int));
 
   for( i=0; i < GL->numVertexes; i++ )
   {
      if0 == GL->adjList[i].in )
      {
         stack[++top] = i; // 将度为0的顶点下标入栈
      }
   }
 
   while0 != top )
   {
      gettop = stack[top--]; // 出栈
      printf("%d -> ", GL->adjList[gettop].data);
      count++;    
  
      for( e=GL->adjList[gettop].firstedge; e; e=e->next )
      {
         k = e->adjvex;
   // 注意:下边这个if条件是分析整个程序的要点!
   // 将k号顶点邻接点的入度-1,因为他的前驱已经消除
   // 接着判断-1后入度是否为0,如果为0则也入栈
         if( !(--GL->adjList[k].in) ) 
         {
            stack[++top] = k;
         }
      }
   }
 
   if( count < GL->numVertexes ) // 如果count小于顶点数,说明存在环
   {
      return ERROR;
   }
   else
   {
      return OK;
   }
}

时间复杂度分析

对于包含n个顶点和 e 条弧的有向图而言,求各个顶点的入度的时间复杂度为 ;遍历所有顶点,查找入度为零并入栈的时间复杂度为 ;在拓扑排序过程中,若有向图无环,则每个顶点入一次栈,出一次栈,入度减 1 的操作在 WHILE 语句中总共执行了 e 次,所以中的时间复杂度为 .

拓扑排序实战篇

LeetCode 207. 课程表 (course-schedule))

题目描述

你这个学期必须选修 numCourse 门课程,记为 0 到 numCourse-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。这是不可能的。
题解:
请你判断是否可能完成所有课程的学习?  你的第一反应是否想到题目就相当于判断一个有向图中是否有环呢?我相信小禹禹一定想到了,比如示例二的输入就表示有向图中存在环,所以不能完成所有课程,如下图形式:
而示例一中就不存在环,即可以完成所有课程,如下图所示:
当然真实的输入可能比给的示例复杂的多,但是不论多么复杂,只需要使用拓扑排序排查一遍即可,这就是算法的魅力。
实现起来也很简单,大家可以将上面的C语言代码稍加改动即可,这里景禹给大家提供利用栈实现的Java代码:
class Solution {
    public boolean canFinish(int numCourses, int[][] prerequisites) {
        int[] indegrees = new int[numCourses];
        List<List<Integer>> adjacency = new ArrayList<>();
        LinkedList<Integer> stack = new LinkedList<>();

        for(int i = 0; i < numCourses; i++)
            adjacency.add(new ArrayList<>());

        for(int[] cp : prerequisites) {
            indegrees[cp[0]]++;
            adjacency.get(cp[1]).add(cp[0]);
        }

        for(int i = 0; i < numCourses; i++)
            if(indegrees[i] == 0) stack.add(i);

        while(!stack.isEmpty()) {
            int pre = stack.pollLast();
            numCourses--;
            for(int cur : adjacency.get(pre))
                if(--indegrees[cur] == 0) stack.add(cur);
        }
        return numCourses == 0;
    }
}
还有一道题目,LeetCode 210. 课程表 II (course-schedule)),有兴趣的小禹禹可以去试一试,其实只要在 207 的解法上增加一个输出即可,景禹就不在这里废话了,但是景禹要给大家提供一种输出所有拓扑序列的代码,可供学有余力的小禹禹抽空自己调试运行理解。
得到一个有向无环图的所有拓扑序列:
import java.util.*; 

class Graph 
    int V; 

    List<Integer> adjListArray[]; 

    public Graph(int V) 

    this.V = V; 

    @SuppressWarnings("unchecked"
    List<Integer> adjListArray[] = new LinkedList[V]; 

    this.adjListArray = adjListArray; 

    for (int i = 0; i < V; i++) { 
       adjListArray[i] = new LinkedList<>(); 
    } 
 } 

 public void addEdge(int src, int dest) 

    this.adjListArray[src].add(dest); 

 } 
 

private void allTopologicalSortsUtil(boolean[] visited, 
    int[] indegree, ArrayList<Integer> stack)
 

    boolean flag = false

    for (int i = 0; i < this.V; i++) { 
       if (!visited[i] && indegree[i] == 0) { 
        visited[i] = true
        stack.add(i); 
        for (int adjacent : this.adjListArray[i]) { 
       indegree[adjacent]--; 
    } 
    allTopologicalSortsUtil(visited, indegree, stack); 
    
    visited[i] = false
    stack.remove(stack.size() - 1); 
    for (int adjacent : this.adjListArray[i]) { 
       indegree[adjacent]++; 
    } 
    flag = true
    } 
  } 

    if (!flag) { 
       stack.forEach(i -> System.out.print(i + " ")); 
       System.out.println(); 
    } 
 } 
 

public void allTopologicalSorts() 
    boolean[] visited = new boolean[this.V]; 

    int[] indegree = new int[this.V]; 

    for (int i = 0; i < this.V; i++) { 
       for (int var : this.adjListArray[i]) { 
          indegree[var]++; 
       } 
    } 

    ArrayList<Integer> stack = new ArrayList<>(); 

    allTopologicalSortsUtil(visited, indegree, stack); 
 } 
 

 public static void main(String[] args) 

    Graph graph = new Graph(6); 
    graph.addEdge(52); 
    graph.addEdge(50); 
    graph.addEdge(40); 
    graph.addEdge(41); 
    graph.addEdge(23); 
    graph.addEdge(31); 

    System.out.println("All Topological sorts"); 
    graph.allTopologicalSorts(); 
 } 

总结

拓扑排序在实际的生产中很常见,最经典的例子就是课程表,当然还可以进行代码的依赖关系处理等等,此外,拓扑排序还是之后要将的关键路径的基础,所以希望各位小禹禹好好学习,有所进步。
程序员专栏
 扫码关注填加客服 
长按识别下方二维码进群

近期精彩内容推荐:  

 再见,纸币!中国成全球首个数字货币国家!

 张一鸣:为什么BAT挖不走我们的人才?

 记一次 Python Web 接口优化

 Java new一个对象时发生了什么?




在看点这里好文分享给更多人↓↓