在开发过程中,编译器编译整个项目的时候,会按照源文件的依赖顺序,依次编译。比如 A 文件的执行依赖 B 文件,那就先编译 B 文件然后才能编译 A 文件。
那么编译器是如何通过文件之间的依赖关系,确定一个全局的编译顺序呢?这就用到了拓扑排序。下面在举一个例子,理解什么是拓扑排序。
日常我们穿衣服的时候,衣服与衣服之间也有这种依赖关系。比如,一定要先穿内裤才可以再穿秋裤,一定要先穿短袖才可以再穿外套,但是外套与秋裤哪个先穿,都可以。所以,假如要穿五件衣服,那么先穿哪个后穿哪个,就是一个拓扑排序的过程。当然,同一身衣服的穿戴顺序可以有多种,对应的拓扑排序同样是可以有多个解。
算法是构建在具体的数据机构之上的,对此我们需要将上述问题抽象成具体的数据结构。
我们把事务的依赖关系,抽象成一个有向图。a 依赖于 b,就是 b 先于 a 执行,对应图中的关系就是:顶点 b 指向顶点 a。另外需要注意的是,这个图必须是无环图,不能存在 a 依赖于 b,b 依赖于 c,c 依赖于 a 这种循环依赖关系。
构建数据结构
public class Graph {
// 顶点的个数
private int v;
// 邻接表
private LinkedList<Integer> adj[] = new LinkedList[v];
//初始化图
public Graph(int v) {
this.v = v;
for (int i = 0; i < v; ++i) {
adj[i] = new LinkedList<>();
}
}
//添加依赖关系 s 先于 t,边 s->t
public void addEdge(int s, int t) {
adj[s].add(t);
}
}
Kahn 算法
Kahn 算法实际上用的是贪心算法思想,思路简单易懂。
如果图中的某个顶点的入度为 0,就表示没有任何顶点必须优先于它执行。那么就先执行这个顶点,然后把该顶点从图中删除,然后从剩下的顶点中找另一个入度为 0 的顶点。循环执行这个过程,直至输出所有的顶点。
//拓扑排序
public void topoSortByKahn() {
// 统计每个顶点的入度,入度为零代表该顶点被删除
int[] inDegree = new int[v];
//遍历顶点
for (int i = 0; i < v; ++i) {
//遍历顶点的入度
for (int j = 0; j < adj[i].size(); ++j) {
int w = adj[i].get(j); // i->w
inDegree[w]++;
}
}
//入度为 0 的顶点的队列(输出队列)
LinkedList<Integer> queue = new LinkedList<>();
//遍历筛选出入度为 0 的顶点
for (int i = 0; i < v; ++i) {
if (inDegree[i] == 0)
queue.add(i);
}
//输出排序
while (!queue.isEmpty()) {
int i = queue.remove();
System.out.print("->" + i);
//遍历依赖于顶点 i 的所有顶点
for (int j = 0; j < adj[i].size(); ++j) {
int k = adj[i].get(j);
//i->k,k的入度减一
inDegree[k]--;
//如果顶点 k 的入度为 0,则放入输出队列中
if (inDegree[k] == 0)
queue.add(k);
}
}
}
上述代码有着详细的注释,足以理解整个过程。
DFS 算法
深度优先搜索也可以用来实现拓扑排序,不过这里叫做深度优先遍历更合适。遍历所有的顶点,而非只搜索一个顶点到另一个顶点的路径。
第一步:先构建逆邻接表。为了第二步做准备。
第二步:递归处理顶点。输出自己可达的顶点,然后输出自己。
//深度优先遍历
public void topoSortByDFS() {
// 先构建逆邻接表,边 s->t 表示,s 依赖于 t,t 先于 s(其实就是出度表)
LinkedList<Integer> inverseAdj[] = new LinkedList[v];
for (int i = 0; i < v; ++i) { // 申请空间
inverseAdj[i] = new LinkedList<>();
}
// 通过邻接表生成逆邻接表
for (int i = 0; i < v; ++i) {
//遍历指向该顶点的集合
for (int j = 0; j < adj[i].size(); ++j) {
int w = adj[i].get(j); // i->w
inverseAdj[w].add(i); // w->i
}
}
//顶点访问标识
boolean[] visited = new boolean[v];
// 深度优先遍历图
for (int i = 0; i < v; ++i) {
//判断是否被访问过
if (visited[i] == false) {
//记录访问状态
visited[i] = true;
//递归顶点的出度
dfs(i, inverseAdj, visited);
}
}
}
//递归顶点的出度
private void dfs(int vertex, LinkedList<Integer> inverseAdj[], boolean[] visited) {
//遍历该顶点指向的所有顶点
for (int i = 0; i < inverseAdj[vertex].size(); ++i) {
int w = inverseAdj[vertex].get(i);
if (visited[w] == true){
continue;
}
visited[w] = true;
//递归顶点w 的出度
dfs(w, inverseAdj, visited);
}
// 先把 vertex 这个顶点可达的所有顶点都打印出来之后,再打印它自己
// 确定了 vertex 向下的依赖关系
System.out.print("->" + vertex);
}
上述代码有着详细的注释,足以看懂整个过程。
时间复杂度分析:Kahn 算法对边和顶点个访问一次,所以时间复杂度为 O(V + E) V 代表顶点的个数,E 代表边的个数。DFS 算法,顶点访问两次,边访问一次,所以时间复杂度也是 O(V + E)。
因为这里面的图可能不是一个图,而是多个不连通的子图构建起来的图。所以 V 和 E 的数量不确定,计算时间复杂的时候需要都考虑上。
总结
凡是需要通过局部顺序来推导全局顺序的,一般都能用拓扑排序来解决。
拓扑排序还可以用来检测图中环的存在。对于 Kahn 算法,如果最后输出的顶点个数少于图中顶点个数,证明图中还有入度不是 0 的顶点,这就说明图中存在环。
本文创作灵感来源于 极客时间 王争老师的《数据结构与算法之美》课程,通过课后反思以及借鉴各位学友的发言总结,现整理出自己的知识架构,以便日后温故知新,查漏补缺。
初入算法学习,必是步履蹒跚,一路磕磕绊绊跌跌撞撞。看不懂别慌,也别忙着总结,先读五遍文章先,无他,唯手熟尔~
与诸君共勉