实现JavaScript基本数据结构系列---图

266 阅读6分钟

这是我参与11月更文挑战的第8天,活动详情查看:2021最后一次更文挑战

图是网络结构的抽象模型。图是一组边连接的节点,一个图 G = (V, E) 由以下元素组成,V:一组顶点; E:一组边,连接V中的顶点。在我们生活中可以使用图来表示道路、网络通信、航班等等。

图的基本术语

  1. 相邻顶点:由一组边连接起来的顶点
  2. 度:一个顶点的度是其相邻顶点的数量
  3. 路径:是顶点V1,V2, V3,...,Vn的一个连续序列,其中Vi和V(i+1)是相邻的。简单路径要求不包含重复节点
  4. 有向图和无向图:图可以是有向的,也可以是无向的,有向图的边有一个方向。如果图中的每两个顶点间在双向上都存在路径,那么图是强相通的。
  5. 加权图和未加权的:加权的边会被赋予权值。

图的表示

图最常见的实现是邻接矩阵,除此之外,我们还可以用邻接表、关联矩阵来实现。在这节我们主要来讨论用邻接表来实现他。

创建图Graph

先介绍一下邻接表,邻接表由图中每个顶点的相邻顶点列表所组成。简单表示如下,左边表示我们的节点,右边是该节点的相邻节点。

A: B C D
B: A E F
C: A D G
...
I: E

根据上面的结构我们可以来创建我们所需要的图结构了

class Graph {
    constructor(isDirected = false) {
        this.isDirected = isDirected; //是否是有向图,默认是无向的
        this.vertices = []; // 用来存我们的节点
        // 用来存储我们的邻接表,用字典实现,键为顶点,值为他的相邻顶点列表
        this.adjList = new Map()
    }
}

创建好我们的图结构后,我们需要添加两个方法,一个方法是addVertex(v)函数,用来添加我们的节点,另一个是addEdge(v, w)函数,用来添加我们的边。

addVertex(v) {
    // 添加的节点不存在,添加进去,并且将他的相邻节点设为 【】
    if (!this.vertices.includes(v)) {
        this.vertices.push(v)
        this.adjList.set(v, [])
    }
}

// 添加两节点的边,建立起联系
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) // v ==> w

    // 无向图的话,两者互为相邻节点
    if (!this.isDirected) {
        this.adjList.get(w).push(v) // w ==> v
    }
}

除此之外,我们还需要两个函数,用来获取顶点列表和邻接表

// 返回顶点列表
getVertices() {
    return this.vertices;
}

// 返回邻接表
getAdjList() {
    return this.adjList;
}

接下来,我们可以来写点测试代码,看看我们写的对不对

const graph = new Graph();
const vertices = ["A", "B", "C", "D", "E", "F", "G", "H", "I"]

for (let index = 0; index < vertices.length; index++) {
    graph.addVertex(vertices[index])
}

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")

然后为了我们能更加清楚的看到我们构建的图对不对,可以实现一个toString方法来进行输出

toString() {
    let resultStr = '';
    this.vertices.forEach(key => {
        resultStr = resultStr + `${key} => ${this.adjList.get(key).join(' ')} \n`
    })
    return resultStr;
}

然后调用graph.toString()输出结果如下,符合我们的预期,至此,我们的图数据结构也基本实现了。接下来我们要实现图的遍历方法了,这也是重要的内容。

A => B C D 
B => A E F 
C => A D G 
D => A C G H 
E => B I 
F => B 
G => C D 
H => D 
I => E 

图的遍历

和树相似,我们可以访问图中的所有节点,有两种方法来对图进行遍历:广度优先搜索(BFS)和深度优先搜素(DFS)。图的遍历可以用来寻找特定顶点或寻找两个顶点之间的路径,检查图是否有环,是否连通等。

在此之前,我们可以理解一下图遍历的思想。图遍历算法的思想是必须追踪每个第一次访问的节点,并且追踪有哪些节点还没有被完全探索,这两种算法都必须指出第一个被访问的顶点。

两种算法基本相同,不同的是存储待访问列表的数据结构不同,BFS是队列,DFS是栈。除此之外,我们还需要一个辅助方法,来标识顶点是否被访问过。

const Colors = {
    WHITE: 0, // 白色:该节点没有被访问
    GREY: 1, // 灰色: 被访问过,没有被完全探索
    BLACK: 2 // 被完全探索了
}

同时,我们也需要定义一个方法来初始化我们的节点,用来记录节点状态,哪些节点被访问了,哪些被完全探索了,开始都设置为白色。

const initColor = vertices => {
    let color = {};
    vertices.forEach(v => {
        color[v] = Colors.WHITE;
    })

    return color;
}

接下来就可以来实现我们的BFS和DFS了。

广度优先搜索(BFS)

广度优先搜素会从指定的一个顶点开始遍历图,先访问所有相邻节点,访问图的一层,在下一层。

实现步骤:

  1. 创建一个队列Q
  2. 标注节点v为被发现的(灰色),推入v进队列Q
  3. 队列不为空时
  4. 将u从队列中取出
  5. 将u变为灰色
  6. 遍历所以没有被访问的相邻节点(白色),推入队列
  7. 标注u为被完全探索的(黑色)
/**
 * 
 * @param {*} graph 图的实例
 * @param {*} startEle 从哪个节点开始遍历
 * @param {*} callback 遍历一个节点后进行的操作
 */
const bfs = (graph, startEle, callback) => {
    const vertices = graph.getVertices();
    const adjList = graph.getAdjList();
    const color = initColor(vertices);

    const queue = []; // 用数组来模拟队列

    queue.push(startEle);

    while(queue.length !== 0) {
        const currentEle = queue.shift()
        color[currentEle] = Colors.GREY; // 被访问到了,还没有被完全探索,置为灰色
        // 遍历所有相邻节点
        const elements = adjList.get(currentEle);

        elements.forEach(ele => {
            // 相邻节点还没有被访问到,那就添加到对列
            if (color[ele] === Colors.WHITE) {
                color[ele] = Colors.GREY;
                queue.push(ele)
            }
        })
        // 相邻节点都访问了,那么该节点就被完全探索了,变为黑色
        color[currentEle] = Colors.BLACK;
        // 执行完一轮,一个节点被完全探索了
        if (callback) callback(currentEle)
    }
} 

const log = ele => { console.log('Visited: ', ele) }

bfs(graph, vertices[0], log)
/**
 *  Visited:  A
    Visited:  B
    Visited:  C
    Visited:  D
    Visited:  E
    Visited:  F
    Visited:  G
    Visited:  H
    Visited:  I
*/

深度优先搜索(DFS)

深度优先搜素会从指定的一个顶点开始遍历图,沿着路径直到这条路径的最后一个顶点被访问了,按原路回退并探索下一个路径。就是先深度后广度的访问顶点。所以DFS是递归的,意外着我们要用栈来存储他。

const defVisit = (node, color, adjList, callback) => {
    // 将该节点变成一访问,未完全探索
    color[node] = Colors.GREY;
    // 执行回调,说明当前走到了那个节点
    if (callback) callback(node);

    // 遍历所有相邻节点
    const elements = adjList.get(node);
    elements.forEach(ele => {
        if (color[ele] === Colors.WHITE) {
            defVisit(ele, color, adjList, callback)
        }
    })

    color[node] = Colors.BLACK;
}

const dfs = (graph, callback) => {
    const vertices = graph.getVertices();
    const adjList = graph.getAdjList();
    const color = initColor(vertices);

    for (let index = 0; index < vertices.length; index++) {
        // 遍历每一个节点,如果没有被访问,那么从该节点开始递归该节点
        if (color[vertices[index]] === Colors.WHITE) {
            // 递归
            defVisit(vertices[index], color, adjList, callback)
        }
    }

}
dfs(graph, log) 
/**
 *  Visited:  A
    Visited:  B
    Visited:  E
    Visited:  I
    Visited:  F
    Visited:  C
    Visited:  D
    Visited:  G
    Visited:  H
*/

广度优先搜索和深度优先搜索有着多种场景应用,最常见的有最短路径算法等,大家可以下去之后了解了解。