TypeScript实现图的遍历

1,483 阅读13分钟

前言

有一个图,我们想访问它的所有顶点,就称为图的遍历。遍历图有两种方法:广度优先搜索和深度有优先搜索。

图遍历可以用来寻找特定的顶点或寻找两个顶点之间的路径,检查图是否连通。本文将详解图的两种遍历并用TypeScript将其实现,欢迎各位感兴趣的开发者阅读本文。

写在前面

本文重点讲解图遍历的实现,对图和图两种遍历方式的概念不了解的开发者请移步我的另外几篇文章。
图的认识 | 深度优先搜索的理解与简单实现 | 广度优先搜索的理解与简单实现

图遍历思想

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

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

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

广度优先搜索算法和深度优先搜索算法基本上是相同的,唯一不同之处在于待访问顶点列表的数据结构。

  • 深度优先搜索采用来存储待访问顶点
  • 广度优先搜索采用队列来存储待访问顶点


我们用三种颜色描述各个顶点的访问状态。

  • 白色:标识这个顶点还没被访问
  • 灰色:标识这个顶点被访问过,但未被探索过
  • 黑色:标识这个顶点被访问过且被完全探索过


我们需要实现一个辅助方法,用于初始化每个顶点的颜色。这个辅助方法实现也简单,参数传一个顶点列表,函数内部声明一个颜色对象,遍历顶点列表,将每个顶点的值作为颜色对象的key,颜色对象的value为白色。最后返回这个颜色对象。

广度优先搜索

接下来我们来分析下广度优先搜索如何实现。

实现思路

广度优先搜索算法会从指定的一个顶点开始遍历图,先访问其所有的临点,一层一层的访问。

从一个顶点v开始进行广度优先搜索的实现思路如下:

  • 声明一个函数breadthFirstSearch,该函数接收三个参数:要进行遍历的图、开始顶点、回调函数
  • 获取参数图(graph)的所有顶点和邻接表,将获取到的顶点初始化为白色,声明一个变量(color)接收初始化后的顶点对象
  • 创建一个队列(queue),将开始顶点(startVertex)入队
  • 如果队列非空,则执行以下步骤
    • 取出队列里存储的顶点u,获取u的邻接表
    • 标识顶点u的颜色为灰色,即已访问但未被探索
    • 遍历u的邻接表,取出邻接表中的每个顶点w,如果w未被访问则将其标识为灰色,将w加入队列。
  • 当邻接表中所有的顶点都被标识为灰色后,标识u顶点已被完全探索,将其标识为黑色
  • 如果参数回调函数(callback)存在,则执行回调函数

实现代码

上面我们分析了广度优先搜索的实现思路,我们将上述思路转换为代码。

/**
 * 广度优先搜索
 * @param graph 需要进行搜索的图
 * @param startVertex 开始顶点
 * @param callback 得到每个节点后的回调函数
 */
export const breadthFirstSearch = (graph: Graph, startVertex: string | number, callback: (val: string | number) => void): void => {
    // 获取图的所有顶点
    const vertices = graph.getVertices();
    // 获取图的临接表
    const adjList = graph.getAdjList();
    // 将顶点进行初始化
    const color = initializeColor(vertices);
    // 实例化一个队列
    const queue = new Queue();
    // 将开始顶点入队
    queue.enqueue(startVertex);
    // 如果队列不为空就继续执行
    while (!queue.isEmpty()) {
        // 取出队列里存储的顶点u
        const u = queue.dequeue();
        // 获取取出顶点的临接表
        const neighbors = <(string | number)[]>adjList.get(u);
        // 将顶点列表里的u标识为已被访问但未被探索
        color[u] = Colors.GERY;
        // 遍历当前取出顶点的临接表
        for (let i = 0; i < neighbors.length; i++) {
            // 获取临接表中的每个顶点w
            const w = neighbors[i];
            // 如果w没被访问过
            if (color[w] === Colors.WHITE) {
                // 标识w为已被访问但未被探索
                color[w] = Colors.GERY;
                // 将w加入队列
                queue.enqueue(w);
            }
        }
        // 此时u顶点与其相邻顶点已经被探索,将u标识为已被访问且被完全探索
        color[u] = Colors.BLACK;
        // 执行回调函数
        if (callback) {
            callback(u);
        }
    }
};

完整代码请移步:Graph.ts

编写测试代码

接下来,我们通过一个例子来测试下上述代码是否正确执行。

如下图所示,我们使用广度优先搜索访问图中的所有顶点。
image.png

  • 构建图并建立顶点与顶点之间的关系
let graph = new Graph();
let vertices = ["A", "B", "C", "D", "E", "F", "G", "H", "I"];
for (let i = 0; i < vertices.length; i++) {
    graph.addVertex(vertices[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");
  • 创建回调函数,执行广度优先搜索函数
const printVertices = (val) => {
    console.log(val);
};
breadthFirstSearch(graph, vertices[0], printVertices);

image.png

求最短路径

上面我们学习了广度优先搜索的基本原理,我们还可以用该算法做更多的事情,而不只是输出被访问节点的顺序。

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

对于给定顶点v,广度优先算法会访问所有与其距离为1的顶点,接着是距离为2的顶点,以此类推。所以,可以用广度优先算法来解决这个问题。

我们修改上面实现的广度优先算法,让其返回如下信息:

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


接下来我们来分析下如何修改算法来返回我们需要的信息。

  • 声明两个对象distancespredecessors分别用来存储距离和前溯点
  • 遍历所有顶点,将每个顶点的距离初始化为0,将每个顶点的前溯点初始化为null
  • 遍历邻接表时,发现顶点u的临点w时,通过给distances[u]的值+1来增加v和w之间的距离,设置w的前溯点值为u
  • 最后,返回distances对象和predecessors对象

代码实现如下:

/**
 * 广度优先搜索优化
 * @param graph 要进行遍历的图
 * @param startVertex 开始顶点
 * @constructor
 */
export const BFS = (
    graph: Graph,
    startVertex: string | number
): { distances: { [key: string]: string | number }; predecessors: { [key: string]: string | number | null } } => {
    // 获取图的所有顶点
    const vertices = <(string | number)[]>graph.getVertices();
    // 获取图的临接表
    const adjList = graph.getAdjList();
    // 初始化顶点颜色
    const color = initializeColor(vertices);
    // 创建一个队列
    const queue = new Queue();
    // 存储每个顶点的距离
    const distances: { [key: string]: string | number } = {};
    // 存储前溯点
    const predecessors: { [key: string]: string | null | number } = {};
    // 顶点入队
    queue.enqueue(startVertex);

    // 遍历所有顶点
    for (let i = 0; i < vertices.length; i++) {
        // 用0来初始化每个顶点的距离
        distances[vertices[i]] = 0;
        // 用null来初始化每个顶点的前溯点
        predecessors[vertices[i]] = null;
    }

    while (!queue.isEmpty()) {
        // 获取队首顶点u
        const u = queue.dequeue();
        // 获取u的临接表
        const neighbors = <(string | number)[]>adjList.get(u);
        // u标识为已访问但未被探索状态
        color[u] = Colors.GERY;
        // 遍历u的临接表
        for (let i = 0; i < neighbors.length; i++) {
            // 获取临接表中遍历到的顶点w
            const w = neighbors[i];
            // 如果顶点w未被访问
            if (color[w] === Colors.WHITE) {
                // 标识顶点w为已访问但为被探索
                color[w] = Colors.GERY;
                // 给u顶点加1来增加v和w之间的距离(u是w的前溯点)
                distances[w] = <number>distances[u] + 1;
                // 发现顶点u的邻点w时,则设置w的前溯点值为u
                predecessors[w] = u;
                // w入栈
                queue.enqueue(w);
            }
        }
    }
    return {
        distances,
        predecessors
    };
};


接下来我们测试下,优化后的方法是否正常执行。

const shortestPaths = BFS(graph, vertices[0]);
console.log(shortestPaths);

执行上述代码后,我们会得到如下图所示的结果。
image.png
解释如下:

# 距离
distances: { A: 0, B: 1, C: 1, D: 1, E: 2, F: 2, G: 2, H: 2, I: 3 }
顶点A到A的距离为0
顶点A到B的距离为1
顶点A到C的距离为1
顶点A到D的距离为1
顶点A到E的距离为2
顶点A到F的距离为2
顶点A到G的距离为2
顶点A到I的距离为3

# 前溯点
 predecessors: { A: null, B: "A", C: "A", D: "A", E: "B", F: "B", G: "C", H: "D", I: "E" }
 顶点A的前溯点为null
 顶点B的前溯点为A
 顶点C的前溯点为A
 顶点D的前溯点为A
 顶点E的前溯点为B
 顶点F的前溯点为为B
 顶点G的前溯点为C
 顶点H的前溯点为D
 顶点I的前溯点为E

上面我们拿到了A到每个顶点距离和每个顶点的前溯点,接下来我们就可以根据距离和前溯点来求最短距离了。

  • 遍历除过源顶点外的顶点列表
    • 获取当前遍历到的顶点toVertex
    • 创建一个栈,用于存储路径值
    • 追溯toVertex到源顶点的路径,声明变量v默认值为toVertex,将其赋值为其前溯点的值
    • 将v入栈
  • 将源顶点入栈
  • 声明变量s用于存储最短路径,依次取出栈中的元素,将其用-拼接
  • 打印s
/**
 通过前溯点列表获取顶点A到其他顶点的路径
 */
// 用顶点A作为源顶点
const fromVertex = vertices[0];
// 遍历除过源顶点外的顶点
for (let i = 1; i < vertices.length; i++) {
    // 获取A抵达的顶点
    const toVertex = vertices[i];
    // 创建一个栈来存储路径值
    const path = new Stack();
    // 追溯toVertex到fromVertex的路径,变量v赋值为其前溯点的值
    for (let v = toVertex; v !== fromVertex; v = shortestPaths.predecessors[v]) {
        // v入栈
        path.push(v);
    }
    // 源顶点入栈
    path.push(fromVertex);
    let s = path.pop();
    while (!path.isEmpty()) {
        s += " - " + path.pop();
    }
    console.log(s);
}

完整代码请移步: GraphTest.js 执行结果如下
image.png

深度优先搜索

深度优先搜索算法将会从一个指定的顶点开始遍历图,沿着路径查找,直至这条这条路径的最后一个顶点被访问,接着原路回退并探索下一条路径。如下图所示
image.png

实现思路

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

要访问顶点v,实现思路如下。

  • 声明一个函数depthFirstSearch,该函数接收2个参数:要进行遍历的图、回调函数
  • 获取图(graph)的顶点以及临接表,将获取到的顶点初始化为白色,用一个变量color来存储初始化后的顶点
  • 遍历所有顶点,如果当前遍历到的顶点未被访问就递归访问其顶点

递归访问顶点的实现思路如下。

  • 声明一个函数depthFirstSearchVisit,该函数接收4个参数:要访问的顶点、颜色对象、图的临接表、回调函数
  • 首先,将要访问的顶点u标识为已发现状态
  • 执行回调函数
  • 获取u的临接表,遍历临接表
    • 获取临接表的每个顶点w
    • 如果w未被访问则以w为顶点继续进行递归访问
  • 最后,遍历结束u已经被完全探索,将其标识为黑色。

实现代码

接下来我们将上述思路转换为代码。

/**
 * 深度优先搜索
 * @param graph 要进行遍历的图
 * @param callback 回调函数
 */
export const depthFirstSearch = (graph: Graph, callback: (val: string | number) => void): void => {
    // 获取图的顶点
    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);
        }
    }
};

/**
 * 递归访问顶点
 * @param u 要访问的顶点
 * @param color 颜色对象
 * @param adjList 图的临接表
 * @param callback 回调函数
 */
const depthFirstSearchVisit = (
    u: string | number,
    color: { [p: string]: number },
    adjList: Dictionary<string | number, (string | number)[]>,
    callback: (val: string | number) => void
) => {
    // 顶点u访问后,标识为已访问但未被探索状态
    color[u] = Colors.GERY;
    // 执行回调函数
    if (callback) {
        callback(u);
    }
    // 获取当前顶点的临接表
    const neighbors = <string | number[]>adjList.get(u);
    // 遍历临接表
    for (let i = 0; i < neighbors.length; i++) {
        // 获取临接表的每个顶点w
        const w = neighbors[i];
        // 如果w未被访问则以w为顶点进行递归访问
        if (color[w] === Colors.WHITE) {
            depthFirstSearchVisit(w, color, adjList, callback);
        }
    }
    // u标识为已被完全探索
    color[u] = Colors.BLACK;
};

完整代码请移步:Graph.ts

编写测试代码

接下来我们用广度优先搜索的例子来测试下上述代码是否都正确执行。

console.log("深度优先搜索节点访问顺序");
depthFirstSearch(graph, printVertices);

image.png

拓扑排序

上面我们学习了深度优先搜索的基本原理,我们可以用深度优先搜索做很多事情,而不只是输出顶点的被访问顺序。

例如,给定一个图G,我们希望深度优先算法遍历图G的所有顶点,构建“森林”以及一组源顶点,并输出两个数组:发现时间和完成探索时间。

我们修改深度优先搜索算法,让其实现返回以下信息。

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


接下来我们分析下,如果通过修改代码让其返回我们需要的信息。

  • 声明3个对象d、f、p分别存储顶点的发现时间、完成探索时间、前溯点
  • 声明time对象,用于记录每个顶点的访问时间
  • 遍历顶点,将d、f、的每个顶点赋值为0,将p每个顶点的前溯点赋值为null


修改递归访问函数。

  • 递归访问函数需要多传4个参数,d、f、p、time
  • 当顶点被发现时,记录当前顶点u的初次发现时间,获取time的时间进行+1
  • 当临接点w未被访问时存储w的前溯点
  • 顶点u被完全访问后记录顶点的完成访问时间


代码实现如下

/**
 * 优化后的深度优先搜索
 * @param graph 要进行搜索的图
 * @constructor
 */
export const DFS = (
    graph: Graph
): { predecessors: { [key: string]: string | number | null }; discovery: { [key: string]: string | number }; finished: { [key: string]: string | number } } => {
    const vertices = <(number | string)[]>graph.getVertices();
    const adjList = graph.getAdjList();
    const color = initializeColor(vertices);
    const d: { [key: string]: string | number } = {};
    const f: { [key: string]: string | number } = {};
    const p: { [key: string]: string | number | null } = {};
    const time: { [key: string]: number } = { 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) {
            // 从i顶点开始递归访问
            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 临接表
 * @constructor
 */
const DFSVisit = (
    u: string | number,
    color: { [p: string]: number },
    d: { [key: string]: string | number },
    f: { [key: string]: string | number },
    p: { [key: string]: string | number | null },
    time: { [key: string]: number },
    adjList: Dictionary<string | number, (string | number)[]>
) => {
    // 顶点u被发现但未被探索
    color[u] = Colors.GERY;
    // 记录顶点u的初次发现时间
    d[u] = ++time["count"];
    // 获取顶点u的临接表
    const neighbors = <(string | number)[]>adjList.get(u);
    for (let i = 0; i < neighbors.length; i++) {
        // 获取w临接点
        const w = neighbors[i];
        // 如果w未被访问,存储w的前溯点,以w未顶点继续递归访问
        if (color[w] === Colors.WHITE) {
            p[w] = u;
            DFSVisit(w, color, d, f, p, time, adjList);
        }
    }
    // 顶点被完全访问
    color[u] = Colors.BLACK;
    // 记录顶点完成访问时间
    f[u] = ++time["count"];
};

接下来我们测试下优化后的方法是否能正常执行。

console.log("优化后的深度优先搜索");
console.log(DFS(graph));

执行上述代码后,我们会得到如下图所示的结果。
image.png
解释如下

顶点A发现时间是1,完成访问的时间是18
顶点B的发现时间是2,完成访问的时间是9
顶点C的发现时间是10,完成访问的时间17
顶点D的发现时间是11,完成访问的时间是16
顶点E的发现时间是3,完成访问的时间6
顶点F的发现时间是7,完成访问的时间8
...
顶点I的发现时间是4,完成访问的时间是5

上述执行结果也可以用下图来描述
image.png

我们通过一个例子来讲解拓扑排序,如下图所示,假定每个顶点都是一个需要去执行的任务。
image.png
上图是一个有向图,意味着任务的执行是有顺序的。例如任务F不能在任务A之前执行,这个图没有环,意味着这张图是一个无环图,因此我们可以称上图为
有向无环图。

当我们优化深度优先搜索算法后,拿到了我们需要的数据,就可以根据这些数据,稍作调整就能完成拓扑排序了。

  • 构建一个有向图,将顶点依次加入图中
  • 建立每个顶点之间的连接,执行优化后的深度优先搜索算法,获取其返回的数据result
  • 获取result中的完成访问时间fTimes
  • 声明变量s,用于存储拓扑排序最终的路径
  • 遍历顶点列表
    • 声明max和maxName
    • 如果当前顶点的完成时间大于max就将max赋值为当前节点的完成时间,maxName赋值为当前遍历到的节点
    • 拼接maxName
    • 最后,删除fTimes中的max元素
  • 打印拼接好的字符串s


实现代码如下

// 实现拓扑排序
graph = new Graph(true);
vertices = ["A", "B", "C", "D", "E", "F"];
for (let i = 0; i < vertices.length; i++) {
    graph.addVertex(vertices[i]);
}
graph.addEdge("A", "C");
graph.addEdge("A", "D");
graph.addEdge("B", "D");
graph.addEdge("B", "E");
graph.addEdge("C", "F");
graph.addEdge("F", "E");
const result = DFS(graph);
console.log("拓扑排序");
const fTimes = result.finished;
let s = "";
for (let count = 0; count < vertices.length; count++) {
    let max = 0;
    let maxName = null;
    for (let i = 0; i < vertices.length; i++) {
        if (fTimes[vertices[i]] > max) {
            max = fTimes[vertices[i]];
            maxName = vertices[i];
        }
    }
    s += maxName + " - ";
    delete fTimes[maxName];
}
console.log(s);

完整代码请移步: GraphTest.js
上述代码的执行结果如下图所示
image.png

写在最后

  • 文中如有错误,欢迎在评论区指正,如果这篇文章帮到了你,欢迎点赞和关注😊
  • 本文首发于掘金,未经许可禁止转载💌