拓扑排序原理和习题分析

什么是拓扑排序

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

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

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

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

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


实现原理

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

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 数组了,并且咱们须要去用另外一种方式去判断图中是否存在环,先放上代码模版:ui

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 数组)是记录咱们访问过的节点,避免重复访问,另一个是防止环的出现,怎么避免,深度优先搜索是沿着一条路径一直搜索下去,咱们须要保证这一条路径不会通过某个节点两次,注意我这里说的是一条路径。编码

两种算法算是两种实现的方式,可是目的都是同样的,思想是相似的。spa


相关习题

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));
                }
            }
        }
    }
}
复制代码

总结

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

相关文章
相关标签/搜索
本站公众号
   欢迎关注本站公众号,获取更多信息