拓扑排序原理和习题分析

788 阅读10分钟

什么是拓扑排序

其实最开始学习算法,听到拓扑排序这几个字我也是懵逼的,后来学着学着才慢慢知道是怎么一回事。关于拓扑这个词,在网上找到这么一段解释:

所谓“拓扑”就是把实体抽象成与其大小、形状无关的“点”,而把连接实体的线路抽象成“线”,进而以图的形式来表示这些点与线之间关系的方法,其目的在于研究这些点、线之间的相连关系。表示点和线之间关系的图被称为拓扑结构图。拓扑结构与几何结构属于两个不同的数学概念。在几何结构中,我们要考察的是点、线之间的位置关系,或者说几何结构强调的是点与线所构成的形状及大小。如梯形、正方形、平行四边形及圆都属于不同的几何结构,但从拓扑结构的角度去看,由于点、线间的连接关系相同,从而具有相同的拓扑结构即环型结构。也就是说,不同的几何结构可能具有相同的拓扑结构。

拓扑在计算机领域研究的就是图,既然是图,那就会有节点和边,节点表示的是现实生活中抽象的东西,边表示的是这些东西之间的关系

仔细想想,其实现实生活中的很多东西都能够抽象成计算机世界当中的图,比如说对于实际的地图,我们可以用节点表示岔路口,边表示岔路口之间的连线;人的朋友圈,我们用节点表示人,用边表示人与人之间的关系;再比如历史事件,我们用节点表示事件,用边表示事件之间的联系;不管是人或者事,只要找到了其对应的联系,往往都可以抽象成图。

拓扑排序解决的是依赖图问题,依赖图表示的是节点的关系是有依赖性的,比如你要做事件 A,前提是你已经做了事件 B。除了 “先有鸡还是先有蛋” 这类问题,一般来说事件的依赖关系是单向的,因此我们都用有向无环图来表示依赖关系。拓扑排序就是根据这些依赖来给出一个做事情,或者是事件的一个顺序,就举个例子,朋友来家里吃饭,你准备做饭,你要做饭,首先得买菜,买菜得去超市,去超市要出门搭车,因此这个顺序就是 出门搭车 -> 到超市 -> 买菜 -> 回家做饭。当然我举的这个例子你不需要计算机的帮助也能很清楚地知道这个顺序,但是有些事情并不好直接看出来,比如常见的 “一个非常大的项目中,如何确定源代码文件的依赖关系,并给出相应的编译顺序?”,我们要学的是一个解决一类问题的普适的方法,而不是学习怎么快速得到一个具体问题的结果,换句话说,在学习之中,思考过程往往比结果和答案更重要


实现原理

和图相关的问题常见的算法就是搜索,深度和广度,拓扑排序也不例外,我们首先来看看稍微简单,好理解的广度优先搜索,先放上代码模版:

public List<...> bfsTopologicalSort() {
    // 边界条件检测
    if (...) {
        return true;
    }
    
    
    Map<..., List<...>> graph = new HashMap<>();
    
    // 构建图
    ...
    
    // 这里表示的是对于每个节点,其有多少前置条件(前置节点)
    int[] inDegree = new int[totalNodeNumber];
    
    // 根据图中节点的依赖关系去改变 inDegree 数组
    for (Entry.Map<..., List<...>> entry : graph.entrySet()) {
        ...
    }
    
    Queue<...> queue = new LinkedList<>();
    for (int i = 0; i < numCourses; ++i) {
        if (inDegree[i] == 0) {
            queue.offer(...);
        } 
    }
    
    List<...> result = new ArrayList<...>();
    while (!queue.isEmpty()) {
        int cur = queue.poll();
        
        // 记录当前结果
        result.add(...);
        
        // 对于当前节点,解除对其下一层节点的限制
        for (... i : map.getOrDefault(cur, new ArrayList<...>())) {
            inDegree[i]--;
            if (inDegree[i] == 0) {
                queue.offer(...);
            }
        }
    }
    
    return result;
}

对于拓扑排序问题,我们之前讲过,它是基于图的算法,那么首先我们要做的就是将问题抽象为图,这里我用了一个 HashMap 来表示图,其中 Key 表示的是具体的一个节点,Value 表示的是这个节点其下层节点,也就是 List 里面的节点依赖于当前节点,之所以这样表示依赖关系是为了我们后面实现的方便。接下来我们会用一个 inDegree 数组来表示每个节点有多少个依赖的节点,选出那些不依赖任何节点的节点,这些节点应该被最先输出,按照一般的广度优先搜索思维,我们开一个队列,将这些没有依赖的节点放进去。最后就是广度优先搜索的步骤,我们保证从队列里面出来的节点是当前没有依赖或者依赖已经被解除的节点,我们将这些节点输出,这个节点输出,其下一层节点的依赖就要相应的减少,我们改变 inDegree 数组中对应的值即可,如果改变后,对应节点没有任何依赖了,表明这个节点可以被输出了,就把它加进队列,等待被输出。最后的最后就是输出我们得到的答案,但是这里要提醒的是,我们要考虑出现环的情况,就类似 “鸡生蛋,蛋生鸡” 的问题,比如 A 依赖于 B,B 也依赖于 A,这时我们是得不到答案的。

我们再来看看深度优先搜索,其思想和前面讲的广度优先搜索略微不同,我们不再需要 inDegree 数组了,而且我们需要去用另一种方式去判断图中是否存在环,先放上代码模版:

public ... dfsTopologicalSort() {
    // 边界条件检测
    if (...) {
        
    }
    
    Map<..., List<...>> graph = new HashMap<>();
    
    // 构建图
    ...
    
    boolean[] visited = new boolean[numCourses];
    boolean[] isLooped = new boolean[numCourses];
    
    List<...> result = new ArrayList<...>();
    for (int i = 0; i < totalNodeNumber; ++i) {                       
        if (!visited[i] && !dfs(result, graph, visited, isLooped, i)) {
            return new ArrayList<...>();
        }
    }
    
    return result;
}

private ... dfs(List<...> result,
                Map<..., List<...>>[] graph,
                boolean[] visited,
                boolean[] isLoop,
                ... curNode) {
    // 判断是否有环
    if (isLoop[curNode]) {
        return false;
    }
    
    isLoop[curNode] = true;
    
    // 遍历当前节点的前置节点,也就是依赖节点
    for (int i : graph.get(curNode) {
        if (visited[i]) {
            continue;
        }
        
        if (!dfs(graph, visited, isLoop, i)) {
            return false;
        }
    }
    
    isLoop[curNode] = false;
    
    // record answer
    result.add(curNode)
    visited[curNode] = true;
    
    return true;
}

有一点需要注意的是,构建图的时候,我们需要和之前的广度优先搜索反转一下,也就是这里的 Key 表示的是一个节点,Value 中存的是其所依赖的节点,这也是和我们的实现方式有关,我们需要用到递归,递归用到函数栈,先处理的函数(节点)后输出结果。理解了上面这一点,下面就是用递归去解决这个深度优先搜索问题,但是有一点是我们需要用到两个 boolean 数组,一个(visited 数组)是记录我们访问过的节点,避免重复访问,另外一个是防止环的出现,怎么避免,深度优先搜索是沿着一条路径一直搜索下去,我们需要保证这一条路径不会经过某个节点两次,注意我这里说的是一条路径。

两种算法算是两种实现的方式,但是目的都是一样的,思想是类似的。


相关习题

LeetCode 210. Course Schedule II

题目分析
有一些课程,每个课程都有前置课程,必须把前置课程修完了才能修这门课程。这道题就是单纯使用了拓扑排序,这里我把两种实现方式都列下来:

代码实现:
基于深度优先搜索版本:

public int[] findOrder(int numCourses, int[][] prerequisites) {        
    List<Integer> result = new ArrayList<>();
    
    List<Integer>[] graph = new ArrayList[numCourses];
    for (int i = 0; i < numCourses; ++i) {
        graph[i] = new ArrayList<Integer>();
    }
    
    for (int i = 0; i < prerequisites.length; ++i) {
        graph[prerequisites[i][0]].add(prerequisites[i][1]);
    }
    
    boolean[] visited = new boolean[numCourses];
    boolean[] isLooped = new boolean[numCourses];
    for (int i = 0; i < numCourses; ++i) {
        if (!visited[i] && !dfs(graph, visited, isLooped, i, result)) {
            return new int[0];
        }
    }
    
    int[] output = new int[numCourses];
    int index = 0;
    for (int i : result) {
        output[index++] = i;
    }
    
    return output;
}

private boolean dfs(List<Integer>[] graph,
                    boolean[] visited,
                    boolean[] isLooped,
                    int curCourse,
                    List<Integer> result) {
    if (isLooped[curCourse]) {
        return false;
    }
    
    isLooped[curCourse] = true;
    for (int i : graph[curCourse]) {
        if (!visited[i] && !dfs(graph, visited, isLooped, i, result)) {
            return false;
        }
    }
    
    isLooped[curCourse] = false;
    visited[curCourse] = true;
    result.add(curCourse);
    
    return true;
}

基于广度优先搜索版本:

public int[] findOrder(int numCourses, int[][] prerequisites) {
    if (prerequisites == null) {
        return new int[0];
    }

    int[] inDegree = new int[numCourses];

    Map<Integer, List<Integer>> map = new HashMap<>();
    
    for (int i = 0; i < numCourses; ++i) {
        map.put(i, new ArrayList<Integer>());
    }
    
    for (int i = 0; i < prerequisites.length; ++i) {
        map.get(prerequisites[i][1]).add(prerequisites[i][0]);
        inDegree[prerequisites[i][0]]++;
    }

    Queue<Integer> queue = new LinkedList<>();
    for (int i = 0; i < numCourses; ++i) {
        if (inDegree[i] == 0) {
            queue.offer(i);
        } 
    }

    List<Integer> result = new ArrayList<>();
    while (!queue.isEmpty()) {
        int cur = queue.poll();
        result.add(cur);
        for (int i : map.getOrDefault(cur, new ArrayList<Integer>())) {
            inDegree[i]--;
            if (inDegree[i] == 0) {
                queue.offer(i);
            }
        }
    }
    
    int[] output = new int[result.size()];
    int index = 0;
    for (int i : result) {
        output[index++] = i;
    }

    return output.length == numCourses ? output : new int[0];
}

LeetCode 269. Alien Dictionary

题目分析
看完了简单的题,我们现在看看稍微有点难度的题。这道题的题意是,现在有一种新的语言,输入参数是一个按字符大小顺序排好的单词数组,注意这里的排序是依据这个新语言来说的,而不是英语,题目要我们输出一个字符大小的可能顺序,这里我用了 “一个” 和 “可能” 这两个词,拓扑排序过后可能会有多种解,比如说 A 依赖于 C,B 也依赖于 C,那么输出可能就是 CAB 或者 CBA,我们只需要输出其中的一个解即可。

这道题的实现可以分为两个部分,构建图,还有就是拓扑排序,构建图我使用的就是最最直接的办法,让单词前后两两比较,找出两个单词第一个不相同的字符,排在前面的单词中的字符就要比排在后面单词的字符小。当我们构建完图,其他的就是老样子。

代码实现

public String alienOrder(String[] words) {
    if (words == null || words.length == 0) {
        return "";
    }
    
    Map<Character, Set<Character>> graph = new HashMap<>();
    
    for (int i = 0; i < words.length; ++i) {
        for (char c : words[i].toCharArray()) {
            if (!graph.containsKey(c)) {
                graph.put(c, new HashSet<Character>());
            }
        }
    }
    
    buildGraph(words, graph);
    
    Set<Character> visited = new HashSet<>();
    Set<Character> isLooped = new HashSet<>();

    StringBuilder sb = new StringBuilder();
    for (int i = 0; i < words.length; ++i) {
        for (char c : words[i].toCharArray()) {
            if (!visited.contains(c) && !dfs(graph, visited, isLooped, sb, c)) {
                return "";
            }                         
        }
    }
    
    return sb.toString();
}

private boolean dfs(Map<Character, Set<Character>> graph, 
                    Set<Character> visited,
                    Set<Character> isLooped,
                    StringBuilder sb,
                    char cur) {
    if (isLooped.contains(cur)) {
        return false;
    }
    
    isLooped.add(cur);
    
    for (char c : graph.get(cur)) {
        if (!visited.contains(c) && !dfs(graph, visited, isLooped, sb, c)) {
            return false;
        }
    }
    
    isLooped.remove(cur);
    visited.add(cur);
    
    sb.append(cur);
    
    return true;
}

private void buildGraph(String[] words, 
                        Map<Character, Set<Character>> graph) {
    int maxLen = 0;
    for (int i = 0; i < words.length; ++i) {
        maxLen = Math.max(words[i].length(), maxLen);
    }
    
    for (int i = 0; i < maxLen; ++i) {            
        for (int j = 1; j < words.length; ++j) {
            if (words[j].length() <= i || words[j - 1].length() <= i) {
                continue;
            }                
            
            if (i == 0) {
                if (words[j].charAt(0) != words[j - 1].charAt(0)) {
                    graph.get(words[j].charAt(0)).add(words[j - 1].charAt(0));
                }
            } else {
                if (words[j].substring(0, i).equals(words[j - 1].substring(0, i)) 
                        && words[j].charAt(i) != words[j - 1].charAt(i)) {                        
                    graph.get(words[j].charAt(i)).add(words[j - 1].charAt(i));
                }
            }
        }
    }
}

总结

拓扑排序其实就是图类问题当中的一个简单应用,它其实是有固定的实现方式的,我们只需要掌握这些实现方式中的算法思想,相信它不再是一个难题。还是想说说自己对做算法题的认识,我们做题不是为了训练我们的做题速度,编码能力,更重要的是学习算法里面的一种思考问题的方式和方法,这种先进的思想或者说是思维模式可以引领着我们朝计算机领域更广阔、更深层次的地方去