【数据结构与算法】->算法->拓扑排序->如何确定代码源文件的编译依赖?

Ⅰ 前言

我们一般写程序的时候,一个完整的项目往往会包含很多代码源文件,编译器在编译整个项目的时候,需要按照依赖关系,依次编译每个源文件。比如 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 都要考虑在内。

©️2020 CSDN 皮肤主题: 游动-白 设计师:上身试试 返回首页