本文为技术学习的笔记-《Learning JavaScript Data Structures and Algorithms, Third Edition》
1.图的相关术语
图是网络结构的抽象模型。图是由一组边连接的节点(或顶点)。
一个图G=(V, E)由以下元素组成:
-
V:一组顶点
-
E:一组边,连接V中的顶点
下图表示一个图:
由一条边连接在一起的顶点称为相邻顶点;
一个顶点的度是其相邻顶点的数量;
路径是顶点v1,v2, …, vk的一个连续序列,其中vi和vi+1是相邻的;
简单路径要求不包含重复的顶点。比如ADG是一条简单的路径。
环也是一个简单路径,比如ADCA;
如果图中不存在环,则称该图是无环的;
如果图中每两个顶点间都存在路径,则该图是联通的。
有向图和无向图
图可以是无向的或是有向的。
如果图中两个顶点在双向上都存在路径,则改图是强联通的。例如C和D是强联通的,而A和B不是强联通的。
图可以是加权的或者是未加权的。如下图所示,每个边都赋予了权值:
2.图的表示
2.1 邻接矩阵
图中最常见的实现是邻接矩阵。每个节点都和一个整数关联,将该整数作为数组的索引。我们使用一个二维数组来表示顶点之间的连接。如下图所示:
不是强连通图(稀疏图)如果用邻接矩阵表示,矩阵中就会出现很多0,这意味着我们浪费了计算机存储空间来存储不存在的边。
2.2 邻接表
邻接表由图中每个顶点的相邻顶点列表组成。存在好几种方式来表示这种数据结构,我们可以使用列表,链表,甚至三联表或是字典来表示相邻顶点列表。
2.3 关联矩阵
在关联矩阵中,矩阵的行表示顶点,列表示边。如下图所示:
关联矩阵通常适合用于边的数量比顶点多的情况,以节省空间内存和内存。
3.创建Graph类
class Graph {
constructor(isDirected = false) {
this.isDirected = isDirected;
this.vertices = [];
this.adjList = new Dictionary();
}
}
构造函数的第一个参数isDirected表示是否为有向;
vertices来存储图中所有顶点的名字;
adjList来存储邻接表,字典将会使用顶点的名字作为键,邻接列表作为值。
接着实现两个方法,一个用来向图中添加新顶点,另一个用来添加顶点之间的边;
添加新顶点:
addVertex(v) {
if(!this.vertices.includes(v)) {
this.vertices.push(v);
this.adjList.set(v, []); // 设置key->和链表
}
}
下面添加新的边:
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);
}
}
这个方法,会首先添加v和w,然后判断是否为有向图,如果是则两个顶点都要添加。
我们还需要声明两个取值的方法:一个返回顶点列表,一个返回邻接表。
getVertices() {
return this.vertices;
}
getAdjList() {
return this.adjList;
}
为了更加方便打印出图的结构,设计了toString()方法:
toString() {
let s = '';
// 遍历顶点
for(let i = 0; i< this.vertices.length; i++) {
// 获取边key
s += `${this.vertices[i]} -> `;
// 获取领接表
cosnt neighbors = this.adjList.get(this.vertices[i]);
// 遍历相邻节点
for(let j = 0; j < neighbors.length; j++) {
s += `${neighbors[j]}`;
}
s += `\n`;
}
return s;
}
4.图的遍历
和树的结构类似,我们可以访问树的所有节点。有两种算法可以对图进行遍历:广度优先遍历(breadth-first search, BFS)和深度优先遍历 (depth-first search, DFS)。
图遍历算法的思想是必须追踪每个第一次访问的节点,并且追踪有哪些节点还没有被完全探索。对于两种遍历算法,都需要明确指明第一个被访问的节点。
算法 | 数据结构 | 描述 |
---|---|---|
深度优先搜索 | 栈 | 将顶点存入栈,顶点是沿着路径被探索的,存在新的相邻顶点就访问 |
广度优先搜索 | 队列 | 将顶点存入队列,最先入队列的顶点先被搜索 |
当要标注已经访问过的节点的时候,我们需要用到三种颜色来反映他们的状态:
-
白色:表示顶点还没有被访问过
-
灰色:表示顶点已经被访问过,但是没有被探索过
-
黑色:表示顶点已经被访问过且被探索过
于是,我们定义了下面的Colors常量用于标记这些不同的状态:
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;
}
4.1 广度优先搜索
广度优先搜索算法会从指定的每一个顶点位置开始遍历图,先访问其所有邻点,就像一层一层访问图的一层。如下图所示:
以下是从顶点v开始的广度优先搜索算法所遵循的步骤:
(1)创建一个队列Q
(2)标注v为被发现的(灰色),并将v入队列Q
(3)如果Q非空,则运行以下步骤:
(a)将u从Q中出队列
(b)标注u为被发现的(灰色)
©将u所有未被访问过的邻点(白色)入队列
(d)标注u为为已经被探索的(黑色)
具体代码如下:
const gbreadthFirstSearch = (graph, startVertex, callback) => {
// 获取所有的顶点
const vertices = graph.getVertices();
// 获取邻接表
const adjList = graph.getAdjList();
// 初始化颜色
const color = initializaColor(vertices);
// 新建一个队列
const queue = new Queue();
// 将起始节点放入队列
queue.enqueue(startVertex);
// 如果队列不为空
while(!queue.isEmpty()) {
// 取出元素
const u = queue.dequeue();
// 获取邻接点
const neighbors = adjList.get(u);
// 标记颜色被访问过
color[u] = Colors.GREY;
// 遍历子节点
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函数存在
if(callback) {
// 调用callback进行打印之类对的处理
callback(u);
}
}
}
使用BFS寻找最短路径
给定一个图G和源节点v,找出每个顶点u和v的最短路径的距离。
对于给定的节点v,广度优先算法会访问所有距离为1的顶点,接着是距离为2的顶点,以此类推。所以可以使用广度优先算法来解决该问题。
我们可以修改breadthFirstSearch方法来返回给我们一些信息:
-
从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 = {};
// 推导从v到其他每个顶点的最短路径
const predecessors = {};
// 把第一个节点放入队列
queue.enqueue(startVertex);
// 遍历所有节点
for(let i = 0; i< vertices.length; i++) {
// 标记距离累加器为0
distances[vertices[i]] = 0;
// 标记所有节点的前溯节点
precidessors[vertices[i]] = null;
}
while(!queue.isEmpty()) {
// 出队列
const u = queue.dequeue();
// 获取邻接点数组
const neighbors = adjList.get(u);
// 标记已经被访问
color[u] = Color.GREY;
// 遍历邻接节点
for(let i = 0; i< neighbors.lenght; i++) {
// 获取当前一个邻节点
const w = neighbors[i];
// 如果当前节点未曾被访问
if(color[w] === Colors.WHITE) {
// 标记当前节点被访问
color[w] = Colors.GREY;
// 累加节点计数
distances[w] = distances[u] + 1;
// 设置w的前溯节点为u
predecessors[w] = u;
// 将此邻节点放入队列中
queue.enqueue(w);
}
}
// 标记当前节点为黑色,代表已经被访问且被探索
color[u] = Colors.BLACK;
}
// 返回最终的最小距离和所有的前溯点
return {
distances,
predecessors
}
}
通过前溯数组我们可以使用下面的代码来构建从A到其他顶点的路径:
// 使用广度优先遍历计算出结果放入对象shortestpathA中
const shortestpathA = BFS(graph, myVertices[0]);
// 起始节点
const fromVertex = myVertices[0];
// 遍历所有的邻接点
for(let i = 0; i < myVertices.length; i++) {
// 获取目标节点
const toVertex = myVertices[i];
// 定义一个栈(先进后出)
const path = new Stack();
// 从目的节点开始,利用回溯predcessors 一直往前推到开始节点,压入栈中。
for(let v = toVertex; v!== fromVertex;
v = shortestPathA.predcessors[v]) {
path.push(v);
}
// 把起始节点也放入栈中
path.push(fromVertex);
// 后面依次弹出栈的内容就表示了节点的真正的路径
let s = path.pop();
while(!path.isEmpty()) {
s += ' - ' + path.pop();
}
// 输出最终的字符串
console.log(s);
}
深入学习最短路径算法
在上述例子中不是加权图,如果要计算加权中的最短路径,广度优先搜索未必合适。
Dijkstra算法解决了单源最短路径问题。
Bellman-Ford算法解决了边权值为负的单源最短路径问题。
A*搜索算法解决了求仅一对顶点间的最短路径问题,用经验法加速搜索。
Floyd-Warshall算法解决了求所有顶点之间的最短路径这一问题。
4.2 深度优先搜索
深度优先搜索算法那将会从第一个指定的顶点开始,沿着路径到这条路径最后一个顶点被访问,接着原路回退并搜索下一条路径。如下图所示:
深度优先算法不需要一个源节点。在深度优先搜索算法中,如果图中顶点v未访问,则访问该顶点v。
要访问顶点v,照如下步骤做:
(1)标注v为被发现的(灰色);
(2)对于v的所有未访问的(白色)的邻点w,访问顶点w;
(3)标注v为已被探索的(黑色)。
深度优先算法实现如下:
// 深度优先遍历
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) => {
// 标记u已经被访问
color[u] = Colors.GREY;
// 如果回调函数可用
if(callback) {
callback(u);
}
// 获取邻节点
const neighbors = adjList.get(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;
}
最终汇总测试的代码如下:
class Graph {
constructor(isDirected = false) {
this.isDirected = isDirected;
this.vertices = [];
this.adjList = new Map();
}
addVertex(v) {
if(!this.vertices.includes(v)) {
this.vertices.push(v);
this.adjList.set(v, []); // 设置key->和链表
}
}
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;
}
toString() {
let s = '';
// 遍历顶点
for(let i = 0; i< this.vertices.length; i++) {
// 获取边key
s += `${this.vertices[i]} -> `;
// 获取领接表
const neighbors = this.adjList.get(this.vertices[i]);
// 遍历相邻节点
for(let j = 0; j < neighbors.length; j++) {
s += ` ${neighbors[j]}`;
}
s += `\n`;
}
return s;
}
}
class Queue {
constructor () {
this.count = 0;
this.lowestCount = 0;
this.items = {};
}
enqueue(element) {
this.items[this.count] = element;
this.count++;
}
dequeue() {
if(this.isEmpty()) {
return undefined;
}
const result = this.items[this.lowestCount];
delete this.items[this.lowestCount]
this.lowestCount++;
return result;
}
peek() {
if(this.isEmpty()){
return undefined;
}
return this.items[this.lowestCount];
}
isEmpty() {
return this.count - this.lowestCount === 0;
}
size() {
return this.count - this.lowestCount;
}
clear() {
this.count = 0;
this.lowestCount = 0;
this.items = {};
}
toString() {
if(this.isEmpty()) {
return '';
}
// 先获取第一个元素
let objString = `${this.items[this.lowestCount]}`;
// 然后依次遍历,利用模板技术可以代替传统的字符串拼接的操作
for(let i = this.lowestCount+1;i < this.count; i++) {
objString = `${objString}, ${this.items[i]}`;
}
return objString();
}
}
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;
}
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;
// 遍历子节点
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函数存在
if(callback) {
// 调用callback进行打印之类对的处理
callback(u);
}
}
}
测试广度优先和深度优先遍历算法/
const graph = new Graph();
const myVertices = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I'];
for(let i = 0; i < myVertices.length; i++) {
graph.addVertex(myVertices[i]);
}
graph.addEdge('A', 'B');
graph.addEdge('A', 'C');
graph.addEdge('A', 'D');
graph.addEdge('C', 'D');
graph.addEdge('C', 'G');
graph.addEdge('D', 'G');
graph.addEdge('D', 'H');
graph.addEdge('B', 'E');
graph.addEdge('B', 'F');
graph.addEdge('E', 'I');
console.log(graph.toString());
// 广度优先遍历
const printVertex = value => console.log('Visited vertex:' +value);
breadthFirstSearch(graph, myVertices[0], printVertex)
// 深度优先遍历
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) => {
// 标记u已经被访问
color[u] = Colors.GREY;
// 如果回调函数可用
if(callback) {
callback(u);
}
// 获取邻节点
const neighbors = adjList.get(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;
}
console.log("\n ");
depthFirstSearch(graph, printVertex);