拓扑排序(Topological Sorting)
Ⅰ 前言
我们一般写程序的时候,一个完整的项目往往会包含很多代码源文件,编译器在编译整个项目的时候,需要按照依赖关系,依次编译每个源文件。比如 a.java 依赖 b.java,那在编译的时候,编译器需要先编译 b.java,才能编译 a.java。
原来我写 C 语言的程序的时候,经常要用到联合编译,当时我们运行程序都是手动在命令行上编译和运行,所以如果要联合编译而且有依赖关系,比如 a.c 里用到了 b.c 的函数,我们就要先把 b.c 编译,生成一个 obj 文件,然后再用 b.obj 和 a.c 一起编译。大家如果看我最早的文章,一定会经常看到。现在用 Java 就舒服很多,IDE 会自动编译这个文件所依赖的其他文件,不用我再一个一个按照依赖关系手动编译了。
编译器通过分析源文件或者程序员事先写好的编译配置文件(比如 Makefile)文件,来获取这种局部的依赖关系。那么编译器又该如何通过源文件两两之间的局部依赖关系,确定一个全局的编译顺序呢?
这就要用到我们这篇文章要讲的这个算法了。
Ⅱ 拓扑排序算法解析
上面提出的问题的解决思路与 “图” 这种数据结构的一个经典算法 “拓朴排序算法” 有关。那什么是拓扑排序呢?这个概念很好理解,我们来看一个生活中的拓扑排序的例子。
我们在穿衣服的时候都有一定的顺序,我们可以把这种顺序想成,衣服与衣服之间有一定的依赖关系。比如说,你必须先穿内裤,才能穿外裤,不能反过来,不是每个人都能穿出超人的感觉。
假设我们现在有八件衣服要穿,它们之间的两两依赖关系我们已经很清楚了,那如何安排一个穿衣序列,能够满足所有的两两之间的依赖关系?
这就是个拓朴排序的问题。从这个例子里,你应该能想到,在很多时候,拓扑排序的序列并不是唯一的。你可以看一下下面这张图,这两种排序都满足这些局部先后关系的穿衣序列。
弄懂了生活中的这个例子,你应该对开篇讲的编译依赖关系有思路了,它和这个问题一样,都可以抽象成一个拓扑排序问题。
拓扑排序的原理非常简单,我们的重点放在拓扑排序的实现上面。
我们知道,算法是构建在具体的数据结构上的,针对这个问题,我们先来看看,如何将问题背景抽象成具体的数据结构。
如果 a 先于 b 执行,也就是说 b 依赖于 a,那么就在顶点 a 和顶点 b 之间,构建一条从 a 指向 b 的边。而且,这个图不仅要是有向图,还要是一个有向无环图,也就是不能存在像 a->b->c->d->a 这样的循环依赖关系。因为图中一旦出现环,拓扑排序就无法工作了。实际上,拓扑排序本身就是基于有向无环图的一个算法。
package com.tyz.about_topo.core;
import java.util.LinkedList;
/**
* 构造有向无环图
* @author Tong
*/
public class Graph {
private int vertex; //顶点数
private LinkedList<Integer> adj[]; //邻接表
public Graph() {
}
@SuppressWarnings("unchecked")
public Graph(int vertex) {
this.vertex = vertex;
this.adj = new LinkedList[this.vertex];
for (int i = 0; i < vertex; i++) {
this.adj[i] = new LinkedList<Integer>();
}
}
public void addEdge(int start, int end) {
//加有向边
this.adj[start].add(end);
}
public LinkedList<Integer>[] getAdj() {
return adj;
}
public int getVertex() {
return vertex;
}
public void setVertex(int vertex) {
this.vertex = vertex;
}
}
数据结构定义好了,现在我们来看,如何在这个有向无环图上,实现拓扑排序。
拓扑排序有两种实现方法,都不难理解,它们分别是 Kahn 算法 和 DFS 深度优先搜索算法。我们依次来看看。
A. Kahn 算法
Kahn 算法实际上用的是贪心算法的思想,思路也比较清晰。
定义数据结构的时候,如果 s 需要先于 t 执行,那就添加一条 s 指向 t 的边。所以,如果某个顶点的入度为 0,也就表示,没有任何顶点必须先于这个顶点执行,那么这点顶点就可以执行了。
我们先从图中,找出一个入度为 0 的顶点,将其输出到拓扑排序的结果序列中(对应代码中就是把它打印出来),并且把这个顶点从图中删除(也就是把这个顶点可达的顶点的入度都减 1)。我们循环执行上面的过程,直到所有的顶点都被输出。最后输出的序列,就是满足局部依赖关系的拓扑排序。
package com.tyz.about_topo.core;
import java.util.LinkedList;
/**
* 拓扑排序
* @author Tong
*/
public class TopoSort {
private Graph graph; //有向无环图
private int vertex; //图的顶点数
public TopoSort(int vertex) {
this.vertex = vertex;
this.graph = new Graph(vertex);
}
/**
* 用 Kahn算法 实现拓扑排序
*/
public void topoSortByKahn() {
int[] inDegree = new int[this.vertex]; //统计每个顶点的入度
for (int i = 0; i < this.vertex; ++i) {
for (int j = 0; j < this.graph.getAdj()[i].size(); ++j) {
int w = this.graph.getAdj()[i].get(j);
inDegree[w]++;
}
}
LinkedList<Integer> queue = new LinkedList<Integer>();
for (int i = 0; i < this.vertex; ++i) {
if (inDegree[i] == 0) {
queue.add(i);
}
}
while (!queue.isEmpty()) {
int i = queue.remove();
System.out.println("->" + i);
for (int j = 0; j < this.graph.getAdj()[i].size(); ++j) {
int k = this.graph.getAdj()[i].get(j);
inDegree[k]--;
if (inDegree[k] == 0) {
queue.add(k);
}
}
}
}
}
代码就是根据我们上面说的思路实现的,比较简单,大家可以做一个参考。
B. DFS 算法
图的深度优先搜索算法在我之前的文章里写过,有兴趣或者有疑惑的同学可以跳转过去看。
实际上,拓扑排序也可以用深度优先搜索算法来实现,不过这里更准确的说法不是深度优先搜索,而是深度优先遍历,遍历图中的所有顶点,而非只是搜索一个顶点到另一个顶点的路径。
package com.tyz.about_topo.core;
import java.util.LinkedList;
/**
* 拓扑排序
* @author Tong
*/
public class TopoSort {
private Graph graph; //有向无环图
private int vertex; //图的顶点数
public TopoSort(int vertex) {
this.vertex = vertex;
this.graph = new Graph(vertex);
}
/**
* 用 DFS算法 实现拓扑排序
*/
public void topoSortByDFS() {
@SuppressWarnings("unchecked")
LinkedList<Integer> inverseAdj[] = new LinkedList[this.vertex]; //创建逆邻接表
for (int i = 0; i < this.vertex; ++i) {
for (int j = 0; j < this.graph.getAdj()[i].size(); ++i) {
int w = this.graph.getAdj()[i].get(j);
inverseAdj[w].add(i);
}
}
boolean[] visited = new boolean[this.vertex];
for (int i = 0; i < this.vertex; ++i) {
//深度优先遍历图
if (visited[i] == false) {
visited[i] = true;
dfs(i, inverseAdj, visited);
}
}
}
/**
* 递归实现深度优先遍历
* @param vertex 当前顶点
* @param inverseAdj 逆邻接表
* @param visited 顶点状态
*/
private void dfs(int vertex, LinkedList<Integer> inverseAdj[], boolean[] visited) {
for (int i = 0; i < inverseAdj[this.vertex].size(); ++i) {
int w = inverseAdj[this.vertex].get(i);
if (visited[w] == true) {
continue;
}
visited[i] = true;
dfs(w, inverseAdj, visited);
}
System.out.println("->" + vertex); //输出完逆邻接表中这个顶点所达到的所有顶点后输出自己
}
这个算法包含两个关键部分。
第一部分是通过邻接表构造逆邻接表。邻接表中,边 s->t 表示 s 先于 t 执行,也就是 t 要依赖 s。在逆邻接表中,边 s->t 表示 s 依赖于 t ,s 后于 t 执行。
第二部分是这个算法的核心,也就是递归处理每个顶点。对于顶点 vertex 来说,我们先输出它可达的所有顶点,也就是说,先把它依赖的所有的顶点输出了,然后再输出自己。
大家可以想一下这个逻辑,深度优先遍历是先找到这个顶点,然后输出它指向的所有顶点,如果遇到死路再回溯回去,但是如果这样的话,我们是没办法把一个顶点依赖的所有顶点都先输出来的。但如果我们把邻接表反过来,我们将一个顶点设置为最后执行的,这样我们可以通过递归遍历找到它所有的子顶点,也就是它依赖的所有顶点,都先输出完,最后再输出自己,这样就可以保证拓扑排序不会出错。
C. 算法时间复杂度
现在我们已经了解了 Kahn 算法和 DFS 算法求拓扑排序的原理,我们再来看看它们的时间复杂度。
从 Kahn 算法代码中可以看出来,每个顶点被访问了一次,每个边也都被访问了一次,所以,Kahn 算法的时间复杂度就说 O(V+E),V 表示顶点的个数,E 表示边的个数。
DFS 算法的时间复杂度在图的搜索算法中我详细分析过,每个顶点被访问了两次,每条边被访问了一次,所以时间复杂度也是 O(V+E)。
需要注意的是,这里的图可能不是连通的,有可能是好几个不连通的子图构成,所以 E 并不一定大于 V,两者的大小关系不确定。所以,在时间复杂度的表示里,V、E 都要考虑在内。