拓扑排序
在一个大型 Java 工程中,通常存在很多类之间的依赖关系,那在编译整个项目的时候,编译器是如何确定这些类的编译顺序呢?
上面的问题类似于穿衣服的顺序,例如首先要穿内衣,再是裤子,鞋子,毛衣,外套。当然顺序可以有多种并不唯一。
如何确定类的编译顺序,如何得到穿衣顺序,这里面涉及到图论中的拓扑排序算法。
拓扑排序原理分析
在穿衣服的例子中,我们可以通过衣服之间的依赖关系得到一个正确的穿衣顺序,在编译很多类的时候,通过类与类之间的依赖关系,可以得到一个正确的类的编译顺序。
很容易发现,拓扑排序的结果并不是唯一的。并且拓扑排序的原理很简单,难的是如何实现拓扑排序。
实现一个算法的第一步是将实际的数据转换成数据结构,这里我们可以将依赖关系转换成图中的边,即有向无环图。
有向无环图数据结构:
public class Graph {
private int v; // 顶点数
private LinkedList<Integer> adj[]; //邻接表
public Graph(int v) {
this.v = v;
adj = new LinkedList[v];
for (int i=0; i<v; i++) {//初始化邻接表
adj[i] = new LinkedList<>();
}
}
public void addEdge(int s, int t) { //t依赖s,存储为s->t,即s先
adj[s].add(t);
}
}
实现拓扑排序的算法有两种:DFS 深度优先搜索和 Kahn 算法
Kahn 算法
Kahn 算法使用的是贪心算法的思想。
在一个有向无环图中,入度为 0 的排在最前面,其次是入度为 1 的,以此类推。
算法的实现思路是:首先遍历整个图,找到所有入度为 0 的,将它们从图中剔除,同时这些节点指向的其他节点入度都减一。
public void Kahn() {
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);
inDegree[w]++;//统计每个顶点的入度
}
}
LinkedList<Integer> queue = new LinkedList<>();//创建一个队列
for (int i=0; i<v; i++) {
if (inDegree[i] == 0)
queue.add(i);//将入度为0的入队
}
while (!queue.isEmpty()) {
int i = queue.remove();//依次出队
System.out.print("->" + i);
for (int j=0; j<adj[i].size(); j++) {
int k = adj[i].get(j);
inDegree[k]--;//将这个顶点所指向的所有顶点入度减1
if (inDegree[k] == 0) queue.add(k);//若减一后入度为0则入队
}
}
}
DFS 深度优先搜索
这里是利用深度优先搜索的思想实现深度优先遍历,这个算法分为两步操作:
- 将邻接表转换成逆邻接表,邻接表中 s->t 表示t依赖 s,在逆邻接表中则转换成了 s->t。
- 深度优先遍历逆邻接表,即首先将最深处(一条依赖关系的终点)输出,再递归往前输出其余顶点
public void topoSortByDFS() {
// 先构建逆邻接表
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);
inverseAdj[w].add(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;//没有遍历过,则继续往下找
dfs(w, inverseAdj, visited);
}
System.out.print("->" + vertex);
}
算法复杂度分析
Kahn 算法中,每个顶点,每条边都被访问了一遍,因此时间复杂度是O(V+E)
,V是顶点数,E是边数。
DFS 算法中,每个顶点被访问了 2 次,边被访问了一次,因此时间复杂度为O(V+E)
。
注意:这里的有向无环图中,可以存在多个连通分量,因此边数并不一定大于顶点数。
拓扑排序实际上是用于找到序列的优先顺序,除了使用上述两种算法实现,也可以使用 BFS 广度优先搜索实现。
拓扑排序的应用
- 用于确定类的编译顺序
- 确定图中是否存在环,若存在环,则输出的顶点数一点小于顶点总数