js数据结构与算法——图

447 阅读8分钟

是一种非线性的数据结构。

相关术语

图是网络结构的抽象模型。图是一组由边连接的节点(或顶点)。

图的数学表示:

一个图G=(V,E) G=(V,E)由以下元素组成。:

  • V:一组顶点
  • E:一组边,图片连接V中的顶点。

下图表示一个图:

image.png

认识术语:

由一条边连接在一起的顶点称为相邻顶点。比如A和B是相邻的,A和D是相邻的,A和C是相邻的,A和E不是相邻的。

一个顶点的是其相邻顶点的数量,比如A和其他三个顶点的相连接,因此A的度为3;E和其他两个顶点相连,因此E的度为2。

路径是顶点的一个连续序列,其中连续的两点是相邻的。以上图为例,其中包含路径ABEI和ACDG。

简单路径要求不包含重复的顶点。如,ADG是一个简单路径,除去最后一个顶点(因为它和第一个顶点是同一个顶点)。也是一个简单路径,比如ADCA(最后一个顶点重新回到A)。

如果图中不存在环,则称该图是无环的,如果图中每两个顶点间都存在路径,则该图是连通的

有向图和无向图

图可以无向的(边没有方向),或是有向的(有向图)。如下图所示,有向图的边有一个方向。

image.png 如果图中每两个顶点间都在双向上都存在路径,则该图是强连通的,例如C和D是强联通的。而A和B不是强连通的。

图还可以是未加权的,如下图所示,加权图图边被赋予了权值。

image.png 图可以解决很多问题:

  • 一个搜索图中的一个特定顶点或搜索一条特定边。
  • 寻找图中的一条路径(从一个顶点到另一个顶点)。
  • 寻找两个顶点之间的最短路径。

图的表示

如何正确的表示图取决于待解决的问题和图的类型。

邻接矩阵

图最常见的实现是邻接矩阵。每个节点都和一个整数相关联,该整数将作为数组的索引。用一个二维数组来表示顶点之间的连接。如果索引为i的节点和索引为j的节点相邻,则array[i][j]===1,否则array[i][j]===0,如下图所示。

image.png 不是强连通的图(稀疏图),如果用邻接矩阵来表示,则矩阵中将会有很多0,这意味着我们浪费了计算机存储空间来表示根本不存在的边。例如找给定顶点的相邻顶点,即使该顶点只有一个相邻顶点,我们也不得不迭代一整行。邻接矩阵表示法不够好的另一个理由是图中顶点数的数量可能会改变,而二维数组不太灵活。

邻接表

我们也可以使用一种叫做邻接表的动态数据结构来表示图,邻接表由图中每个顶点的相邻顶点列表所组成。存在好几种方式来表示这种数据结构。可以用列表(数组),链表甚至散列表或是字典来表示相邻顶点列表。下面的示意图展示了邻接表的数据结构。

image.png

关联矩阵

在使用关联矩阵表示图的结构中,矩阵的行表示顶点,列表示边。使用二维数组来表示两者之间的连通性。如果顶点v是边e的入射点,则array[v][e]===1;否则array[v][e]===0

关联矩阵通常用于边的数量比顶点多的情况,可以节省内存的空间。

创建Graph类

class Graph {
	constructor(isDirected = false) {
		/**是否有向图 */
		this.isDirected = isDirected;
		/**图中所有顶点的名字 */
		this.vertices = [];
		/**使用字典来存储邻接表。 字典使用顶点的名字作为键,邻接顶点列表作为值。*/
		this.adjList = new Dictionary();
	}
}

插入顶点

	/**
	 * 向图中插入一个新顶点。
	 * @param {string} v 顶点的名字。
	 */
	addVertex(v) {
		if (!this.vertices.includes(v)) {
			this.vertices.push(v);
			this.adjList.set(v, []);
		}
	}

添加顶点之间的边

	/**
	 * 添加顶点之间的边
	 * @param {string} v 顶点的名字。
	 * @param {string} w 顶点的名字。
	 */
	addEdge(v, w) {
		if (!this.adjList.get(v)) this.addVertex(v);
		if (!this.adjList.get(w)) this.addVertex(w);
		this.adjList.get(v).push(w);
		if (!this.isDirected) {
			this.adjList.get(w).push(v);
		}
	}

返回顶点列表

	getVertices() {
		return this.vertices;
	}

返回邻接表

	getAdjList(){
		return this.adjList
	}

图的遍历

以便访问图的所有节点,可以使用广度优先搜索深度优先搜索对图进行遍历。

图遍历可以用来寻找特定的顶点或寻找两个顶点之间的路径,检查图是否连通,检查图是否有环等等。

图便利算法的思想是必须追踪每个第一次访问的节点。并且追踪有哪些节点还没有被完全探索。对于两种图遍历算法都需要明确指出第一个被访问的顶点。

完全探索一个顶点要求我们查看该顶点的每一条边。对于每一条边所连接的没有被访问过的顶点,将其标注为被发现的,并将其加进待访问顶点列表中。

为了保证算法的效率,务必访问每个顶点至多两次,连通图中每条边和顶点都会被访问到。

广度优先搜索算法和深度优先搜索算法基本上是相同的,只有一点不同,那就是待访问顶点列表的数据结构,如下表所示。

算法数据结构描述
深度优先搜索将顶点存入栈,顶点是沿着路径被探索的。存在新的相邻顶点就去访问。
广度优先搜索队列将顶点存入队列,最先入队列的顶点先被探索。

标注已经访问过的顶点时,用三种颜色来反映他们的状态。

  • 白色表示该顶点还没有被访问。
  • 灰色表示该顶点被访问过,但并未被探索过。
  • 黑色表示该顶点被访问过且被完全探索过。

顶点状态变量:

const Colors = {
	WHITE: 0,
	GREY: 1,
	BLACK: 2,
};

辅助函数初始化每每个顶点的颜色。

const initializeColor = (vertices) => {
	const color = {};
	for (let i = 0; i < vertices.length; i++) {
		color[vertices[i]] = Colors.WHITE;
	}
	return color;
};

广度优先搜索

广度优先搜索算法会从指定的第一个顶点开始遍历图,先访问其所有的邻点(相邻顶点)。就像一次访问图的一层。换句话说,就是先宽后深地访问顶点。

image.png

从顶点v开始的广度优先搜索算法应遵循的步骤:

  1. 创建一个队列Q
  2. 标注v为被发现的(灰色)并将v入队列Q
  3. 如果Q非空,则运行以下步骤:
    • 将u从Q中出队列。
    • 标注u为被发现的(灰色)。
    • 将u所有未被访问过的邻点(白色)入队列。
    • 标注u为已被探索的(黑色)。

算法实现:

/**
 * 图的广度优先搜索
 * @param {Graph} graph 图
 * @param {*} startVertex 开始顶点
 * @param {function} callback 回掉函数
 */
const breadthFirstSearch = (graph, startVertex, callback) => {
	// 图中所有顶点的名字
	const vertices = graph.getVertices();
	// 邻接表
	const adjList = graph.getAdjList();
	// 此时顶点都未被访问。初始化颜色状态为白色。
	const color = initializeColor(vertices);
	// 初始化队列,它存储带访问和带探索的顶点。
	const queue = new Queue();
	// 将起始顶点存入队列。
	queue.enqueue(startVertex);
	// 如果队列非空
	while (!queue.isEmpty()) {
		// 通过出队列操作,从队列中移出一个顶点。
		const u = queue.dequeue();
		// 获得包含其所有邻点的。点的邻接表。
		const neighbors = adjList.get(u);
		// 该顶点将被标注为灰色,表示我们发现了他。还未对其探索
		color[u] = Colors.GREY;
		// 遍历u的每个邻点。
		for (let i = 0; i < neighbors.length; i++) {
			const w=neighbors[i];
			// 如果当前节点还未被访问过,则将其标注为我们已经发现了它颜色设置为灰色。并将这个顶点加入队列。这样当其从队列中出列的时候,我们可以完成对其的探索。
			if (color[w]===Colors.WHITE) {
				color[w]=Colors.GREY;
				queue.enqueue(w)
			}
		}
		// 当完成探索该顶点和其相邻点后,我们将该顶点标注为已探索过的颜色设置为黑色。
		color[u]=Colors.BLACK;
		callback&&callback(u)
	}
};

const printVertex = (value) => console.log("Visited vertex: " + value);
breadthFirstSearch(graph, myVertices[0], printVertex);
// 输出如下:
/**
Visited vertex: A
Visited vertex: B
Visited vertex: C
Visited vertex: D
Visited vertex: E
Visited vertex: F
Visited vertex: G
Visited vertex: H
Visited vertex: I
*/

使用广度优先搜索,寻找最短路径

问题给定一个图G和源顶点v,找出每个顶点u和v之间最短路径的距离(以边的数量计)。

对于给定顶点v广度优先算法会访问所有与其距离为1的顶点。接着是距离为2的顶点,以此类推。可以用广度优先算法来解决这个问题,我们可以修改方法以返回给一些信息:

  • 从v到u的距离distances[u]
  • 前溯点predecessors[u],用来推导出从v到其他每个顶点u的最短路径。

算法实现如下:

const BFS = (graph, startVertex) => {
	// 图中所有顶点的名字
	const vertices = graph.getVertices();
	// 邻接表
	const adjList = graph.getAdjList();
	// 此时顶点都未被访问。初始化颜色状态为白色。
	const color = initializeColor(vertices);
	// 初始化队列,它存储带访问和带探索的顶点。
	const queue = new Queue();
	// 表示距离
	const distances = {};
	// 表示前溯点
	const predecessors = {};
	// 将起始顶点存入队列。
	queue.enqueue(startVertex);
	// 对图中的每一个顶点儿初始化
	for (let i = 0; i < vertices.length; i++) {
		distances[vertices[i]] = 0;
		predecessors[vertices[i]] = null;
	}
	// 如果队列非空
	while (!queue.isEmpty()) {
		// 通过出队列操作,从队列中移出一个顶点。
		const u = queue.dequeue();
		// 获得包含其所有邻点的。点的邻接表。
		const neighbors = adjList.get(u);
		// 该顶点将被标注为灰色,表示我们发现了他。还未对其探索
		color[u] = Colors.GREY;
		// 遍历u的每个邻点。
		for (let i = 0; i < neighbors.length; i++) {
			const w = neighbors[i];
			// 如果当前节点还未被访问过,则将其标注为我们已经发现了它颜色设置为灰色。并将这个顶点加入队列。这样当其从队列中出列的时候,我们可以完成对其的探索。
			if (color[w] === Colors.WHITE) {
				color[w] = Colors.GREY;
				distances[w] = distances[u] + 1;
				predecessors[w] = u;
				queue.enqueue(w);
			}
		}
		// 当完成探索该顶点和其相邻点后,我们将该顶点标注为已探索过的颜色设置为黑色。
		color[u] = Colors.BLACK;
	}
	console.log({ graph, startVertex, distances, predecessors });
	return {
		distances,
		predecessors,
	};
};

深度优先搜索

深度优先搜索算法将会从第一个指定的顶点开始遍历图,沿着路径直到这条路径最后一个顶点被访问了。接着原路回退,并探索下一条路径,换句话说,它是先深度后广度的访问节点。如下图所示。

image.png 深度优先搜索算法不需要一个源顶点。在深度优先搜索算法中,若图中顶点v未访问,则访问该顶点v。

访问顶点v,以下步骤:

  1. 标注v为被发现的(灰色)。
  2. 对于v的所有未访问(白色)的邻点w,访问顶点w
  3. 标注v为已被探索的(黑色)。

深度优先搜索的步骤是递归的,意味着深度优先搜索算法使用听懂来存储函数调用(由递归调用所创建的栈)。

代码实现:

/**
 * 深度优先搜索算法
 * @param {*} graph 图
 * @param {*} callback
 */
const depthFirstSearch = (graph, callback) => {
	// 图中所有顶点的名字
	const vertices = graph.getVertices();
	// 邻接表
	const adjList = graph.getAdjList();
	// 此时顶点都未被访问。初始化颜色状态为白色。
	const color = initializeColor(vertices);

	// 对图中的每一个顶点儿初始化
	for (let i = 0; i < vertices.length; i++) {
		if (color[vertices[i]] === Colors.WHITE) {
			depthFirstSearchVisit(vertices[i], color, adjList, callback);
		}
	}
};

const depthFirstSearchVisit = (u, color, adjList, callback) => {
	color[u] =Colors.GREY;
	callback&&callback(u);
	// 获得包含其所有邻点的。点的邻接表。
	const neighbors = adjList.get(u);
	// 遍历u的每个邻点。
	for (let i = 0; i < neighbors.length; i++) {
		const w = neighbors[i];
		// 如果当前节点还未被访问过
		if (color[w] === Colors.WHITE) {
			depthFirstSearchVisit(w, color, adjList, callback);
		}
	}
	color[u] =Colors.BLACK;
};

探索深度优先算法

现在我们理解了深度优先搜索算法的工作原理。那么我们可以用该算法做更多事情。

现在有一个图G,使用深度优先搜索算法遍历图G的所有节点,构建森林(有根树的一个集合),以及一组源顶点(根)并输出两个数组:发现时间和完成探索时间。

现在我们需要以下信息:

  • 顶点u的发现时间d[u]。
  • 当顶点u被标注为黑色时,u的完成搜索时间f[u]。
  • 顶点u的前溯点p[u]。

实现:

/**
 * 深度优先搜索算法
 * @param {*} graph 图
 */
const DFS = (graph) => {
	// 图中所有顶点的名字
	const vertices = graph.getVertices();
	// 邻接表
	const adjList = graph.getAdjList();
	// 此时顶点都未被访问。初始化颜色状态为白色。
	const color = initializeColor(vertices);
	/**顶点发现时间 */
	const d = {};
	/**顶点完成探索时间 */
	const f = {};
	/**顶点的前溯点 */
	const p = {};
	// 追踪发现时间和完成探索时间。
	const time = { count: 0 };

	for (let i = 0; i < vertices.length; i++) {
		f[vertices[i]] = 0;
		d[vertices[i]] = 0;
		p[vertices[i]] = null;
	}

	for (let i = 0; i < vertices.length; i++) {
		if (color[vertices[i]] === Colors.WHITE) {
			DFSVisit(vertices[i],color,d,f,p,time,adjList);
		}
	}
	
	return {
		discovery: d,
		finished: f,
		predecessors: p,
	};
};
/**
 * 节点探索
 * @param {*} u 节点
 * @param {*} color 顶点颜色状态
 * @param {*} d 顶点发现时间
 * @param {*} f 顶点完成探索时间
 * @param {*} p 顶点的前溯点
 * @param {*} time 追踪发现时间和完成探索时间。
 * @param {*} adjList 邻接表
 */
const DFSVisit = (u, color, d, f, p, time, adjList) => {
	color[u]=Colors.GREY;// 设置状态为发现
	d[u]=++time.count;//顶点儿,第一次被发现时,我们追踪其发现时间。
	// 获得包含其所有邻点的。点的邻接表。
	const neighbors = adjList.get(u);
	for (let i = 0; i < neighbors.length; i++) {
		const w=neighbors[i];
		// 如果当前节点还未被访问过
		if (color[w] === Colors.WHITE) {
			p[w]=u;//追踪顶点儿的前溯点儿。
			DFSVisit(w, color, d, f, p, time, adjList)
		}
	}
	color[u]=Colors.BLACK;
	f[u]=++time.count;// 顶点被完全探索后,追踪其完成时间。
};

image.png

拓扑排序

image.png

拓扑排序指安排一些任务或步骤的执行顺序。

创建图:

const graph = new Graph(true);
const myVertices = ["A", "B", "C", "D", "E", "F"];

for (let i = 0; i < myVertices.length; i++) {
	graph.addVertex(myVertices[i]);
}
graph.addEdge("A", "C");
graph.addEdge("A", "D");
graph.addEdge("C", "F");
graph.addEdge("F", "E");
graph.addEdge("B", "E");
graph.addEdge("B", "D");

接下来以倒叙来排序完成时间数组即可得出该图的拓扑排序。

const fTimes = DFS(graph).finished;
let s = "";
for (let count = 0; count < myVertices.length; count++) {
	let max = 0;
	let maxName = null;
	for (let i = 0; i < myVertices.length; i++) {
		if (fTimes[myVertices[i]] > max) {
			max = fTimes[myVertices[i]];
			maxName = myVertices[i];
		}
	}
	s += " - " + maxName;
	delete fTimes[maxName];
}

console.log(s);// B - A - D - C - F - E