• 为什么我要放弃javaScript数据结构与算法(第九章)—— 图


    本章中,将学习另外一种非线性数据结构——图。这是学习的最后一种数据结构,后面将学习排序和搜索算法。

    第九章 图

    图的相关术语

    图是网络结构的抽象模型。图是一组由边连接的节点(或顶点)。学习图是重要的,因为在任何二元关系都可以用图来表示。

    任何社交网络都可以用图来表示。

    我们还可以用图来表示道路、航班以及通信状态

    道路

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

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

    道路

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

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

    路径是顶点v1,v2,...vk的一个连续序列,其中 vi 和 vi+1 (下标)是相邻的。以上一示意图为例,其中包含的路径A B E I 和 A C D G。

    简单路径要求不包含重复的顶点。举个例子,A D G是一条简单路径。除去最后一个顶点(因为它和第一个顶点是同一个顶点),环也是简单路径,比如A D C A(最后一个顶点重新回到A)

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

    有向图和无向图

    图可以是无向的(边没有方向)或是有向的(有向图)。下图就是有向图。

    有向图

    有向图的边有一个方向。如果图中每两个顶点间在双向上都存在路径,则该图是强连通的。例如,C和D就是强连通的。图还可以是未加权的或者加权的。加权图的边被赋予了权值。

    加权图

    我们可以使用图来解决计算机科学世界中的很多问题,比如搜索图中的一个特定顶点或搜索一条特定边,寻找图中的一条路径(从一个顶点到另一个顶点),寻找两个顶点之间的最短路径。

    图的表示

    从数据结构角度来说,我们有多种方式来表示图。在所有表示法中,不存在绝对正确的方法方式。图的正确表示法取决于解决的问题和图的类型。

    邻接矩阵

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

    邻接矩阵

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

    邻接表

    邻接表

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

    邻接表

    尽管邻接表可能对大多数问题来说都是更好的选择,但以上两种表示法都有用,且它们有着不同的性质(例如,要找出顶点 vw是否相邻,使用邻接矩阵会比较快)。在本章中,就会使用邻接表表示法。

    关联矩阵

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

    关联矩阵

    创建 Graph 类

    我们先创建类的骨架

    function Graph(){
        var vertices = [];
        var adjList = new Dictionary();
    }
    

    我们使用一个数组来存储图中所有顶点的名字,以及一个字典来存储邻接表。字典将会使用顶点的名字作为键,邻接顶点列表作为值。 vertices数组和 adjList字典两者都是我们 Graph类的私有属性。

    接着我们实现两个方法:一个用来向图中添加一个新的顶点(因为图实例化后是空的),另外一个方法用来添加顶点之间的边。我们先实现 addVertex 方法

    this.addVertex = function(v){
        vertices.push(v);
        adjList.set(v,[]);
    }
    

    这个方法接受顶点 v 作为参数。我们将该顶点添加到顶点列表中,并且在邻接表中,设置顶点 v 作为键对应的字典值为一个空数组。

    实现 addEdge 方法

    this.addEdge = function(v,w){
        adjList.get(v).push(w);			
        adjList.get(w).push(v);			
    }
    

    这个方法接受两个顶点作为参数。首先,通过将 w 加入到 v 的邻接表中,我们添加了一条自顶点 v 到顶点 w 的边。如果你想实现一个有向图,则(adjList.get(v).push(w))就足够了。但是本章中大多数的例子都是基于无向图,我们需要添加一条自w向v的边。

    测试

    const graph = new Graph();
    const myVertices = ['A','B','C','D','E','F','G','H','I'];
    for(var 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');
    

    实现 Graph类的 toString 方法,便于在控制台输出图

    this.toString = function(){
        var s = '';
        for(var i = 0; i < vertices.length; i++){
            s += vertices[i] + ' -> ';
            var neighbors = adjList.get(vertices[i]);
            for(var j = 0; j < neighbors.length; j++){
                s += neighbors[j] + ' ';
            }
            s += '
    ';
        }
        return s;
    }
    

    我们为邻接表表示法构建了一个字符串,首先迭代 vertices 数组列表,将顶点的名字加入字符串中,接着取得该顶点的邻接表,同样也迭代该邻接表,将相邻顶点加入我们的字符串。邻接表迭代完成后,给我们的字符串添加一个换行符。这样就可以在控制看到一个漂亮的输出了。

    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 
    

    图的遍历

    和树数据结构相似,我们可以访问图的所有节点。有两种算法可以对图进行遍历:广度优先搜索(Breadth-First Search,BFS)和深度优先搜索(Depth-First Search,DFS)。图遍历可以用来寻找特定的顶点或者是寻找两个顶点之间的路径,检查图是否连通,检查图是否含有环等。

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

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

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

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

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

    当要标注已经访问过的顶点时,我们可以用三种颜色来放映它们的状态

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

    广度优先搜索

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

    广度优先搜索

    以下是从顶点 v 开始的广度优先搜索算法所遵循的步骤

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

    实现广度优先搜索算法

    // 颜色辅助-广度优先搜索算法
    this.initializeColor = function(){
        var color = [];
        for(var i = 0; i < vertices.length; i++){
            color[vertices[i]] = 'white';
        }
        return color;
    }	
    // 广度优先搜索算法
    this.bfs = function(v,callback){
        var color = this.initializeColor(),
        queue = new Queue();
        queue.enqueue(v);
        while(!queue.isEmpty()){
            var u = queue.dequeue(),
            neighbors = adjList.get(u);
            color[u] = 'grey';
            for(var i = 0; i < neighbors.length; i++){
                var w = neighbors[i];
                if(color[w] === 'white'){					
                    color[w] = 'grey';
                    queue.enqueue(w);
                }
            }
            color[u] = 'black';
            if(callback){
                callback(u)
            }
        }
    }
    

    广度优先搜索和深度优先搜索多需要标注被访问过的顶点,为此,我们将使用一个辅助数组 color,由于当算法开始执行时,所有的顶点颜色都是白色,所以我们可以创建一个辅助函数 initializeColor 为这两个算法执行此初始化操作。

    我们要的第一件事情是用 initializeColor 函数来将 color 数组初始化为 white ,我们还需要声明和创建一个 Queue 实例,它将会存储待访问和待探索的顶点。

    bfs 方法接受一点顶点作为算法的起始点。起始顶点是必要的,我们将此顶点如队列。

    如果队列为空,我们将通过出队列操作从队列中移除一个顶点,并取得一个包含其所有邻点的邻接表。该顶点将被标注为 grey,表示我们已经发现了它(但还未被完全对其的探索)。

    对于 u 的每个邻点,我们取得其值,如果它还未被访问过,则将其标注了grey,并将这个顶点加入队列中,这样当从队列中出列的时候,我们可以完成对其的探索。

    当完全探索该顶点和及其邻点后,我们将标注该顶点为已探索过(黑色)。

    我们实现的这个 bfs 方法也接受一个回调。这个参数是可选的,如果我们传递了回调函数,会用到它。

    测试

    function printNode(value){
        console.log('访问了顶点:' + value);
    }
    graph.bfs(myVertices[0],printNode);
    

    得到下面的结果

    访问了顶点:A
    访问了顶点:B
    访问了顶点:C
    访问了顶点:D
    访问了顶点:E
    访问了顶点:F
    访问了顶点:G
    访问了顶点:H
    访问了顶点:I
    

    顶点访问顺序和之前的示意图所展示的一致。

    使用 BFS 寻找最短路径

    到目前为止,我们只展示了 BFS 算法的基本原理。我们可以用该算法做更多事情,而不只是输出被访问顶点的顺序。例如,考虑如何来解决下面的问题。

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

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

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

    实现:

    // 广度优先搜索算法优化版本
    this.BFS = function(v){
        var color = this.initializeColor(),
        queue = new Queue(),
        d = [],
        pred = [];
        queue.enqueue(v);
    
        for(var i = 0; i < vertices.length; i++){
            d[vertices[i]] = 0;
            pred[vertices[i]] = null;
        }
        while(!queue.isEmpty()){
            var u = queue.dequeue();
            neighbors = adjList.get(u);
            color[u] = 'grey';
            for(var i = 0; i < neighbors.length; i++){
                // w相邻顶点
                var w = neighbors[i];
                if(color[w] === 'white'){
                    color[w] == 'grey';
                    d[w] = d[u] + 1;
                    pred[w] = u;
                    queue.enqueue(w);
                }
            }
            color[u] = 'black';
        }
        return {
            distance:d,
            predecessors: pred
        }
    }
    

    首先需要声明数组 d 来表示距离,以及 pred 数组来表示前溯点。下一步用0来初始化数组d,把pred赋值为 null。

    当我们发现 顶点u的相邻点w时,则设置w的前溯点值为u。我们还通过给d[u]加1来设置顶点v和相邻点w之间的距离。

    方法的最后返回一个包含d和pred的对象。

    测试

    var shortestPathA = graph.BFS(myVertices[0]);
    console.log(shortestPathA);
    // distance: [A: 0, B: 1, C: 1, D: 2, E: 2, F: 2, G: 2, H: 3, I: 3]
    // predecessors: [A: null, B: "A", C: "A", D: "C", E: "B", F: "B",G: "D", , H: "D", , I: "E"]
    

    通过前溯数组,我们可以用下面这段代码来构建从顶点A到其他顶点的路径:

    var fromVertex = myVertices[0];
    for(var i = 1; i < myVertices.length; i++){
    	var toVertex = myVertices[i],
    	path = new Stack();
    	for(var v = toVertex; v !== fromVertex;v = shortestPathA.predecessors[v]){
    		path.push(v);
    	}
    	path.push(fromVertex);
    	var s = path.pop();
    	while(!path.isEmpty()){
    		s += '-' + path.pop();
    	}
    	console.log(s);
    }
    

    使用顶点A作为源顶点。对于每个其他顶点,我们会J计算顶点A到它的路径。我们从顶点数组得到toVertex ,然后会创建一个栈来 存储路劲值。

    接着,我们追溯 toVertext 到 fromVertext 的路径。变量v被赋值为前溯点的值。这样我们就可以方向追溯这条路径。将变量v添加到栈中。最后,源顶点也会被添加到栈中,以得到完整的路径。

    这之后,我们创建了一个s字符串,并将源顶点赋值给它。当栈是非空的时候,我们从栈中移出一个项并将其拼接到字符串s的后面。最后在控制台上输出路径。

    A-B
    A-C
    A-C-D
    A-B-E
    A-B-F
    A-C-D-G
    A-C-D-H
    A-B-E-I
    

    深入学习的最短路径算法

    本章中的图不是加权图。如果要计算加权图中的最短路径(例如,城市A 和城市B之间的最短路径——GPS和Google Map 中用到的算法),广度优先搜索未必合适。

    举个栗子,Dijkstra 算法解决了单源中最短路径问题。Bellman-Ford 算法解决了边权值为负的单源最短路径问题。A*搜索算法解决了求仅一对顶点间的最短路径问题,它用经验法则来加速搜索过程。Floyd-Warshall算法解决了求所有顶点对间的最短路径的这一问题。

    图是一个广泛的主体,对最短路径及其变种问题,我们有很多的解决方案。

    深度优先搜索

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

    深度优先搜索

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

    要访问顶点v,照下列的步骤

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

    实现

    // 深度优先探索算法
    this.dfs = function(callback){
        var color = this.initializeColor();
        for(var i = 0 ; i < vertices.length; i++){
            if(color[vertices[i]] === 'white' ){
                this.dfsVisit(vertices[i],color,callback);
            }
        }		
    }
    this.dfsVisit =function(u,color,callback){
        color[u] = 'grey';
        if(callback){
            callback(u);
        }
        var neighbors = adjList.get(u);
        for(var i = 0 ;i < neighbors.length; i++){
            var w = neighbors[i];
            if(color[w] === 'white'){
                arguments.callee(w,color,callback);
            }
        }
        color[u] = 'black';
    }
    

    首先,我们创建了颜色数组,并用值white 为图中的每个顶点对其进行了初始化,广度优先搜索也是这么做的。接着,对于图实例中每一个未被访问过的顶点,我们调用递归函数 dfsVisit ,传递的参数为顶点、颜色数组和回调函数。

    当访问u顶点时,我们标注其为被发现的grey。如果有callback 函数的话,则执行该函数输出已访问过的顶点。接下来一步是取得包含顶点u的所有邻点的列表。对于顶点u的每一个未被访问过的邻点w,我们将调用dfsVisit 函数,传递w和其他参数。最后,在该2顶点和邻点按深度访问之后,我们回退,意思是该顶点已经被完全探索了,并将其标注为black

    测试

    graph.dfs(printNode); 
    // 访问了顶点:A
    // 访问了顶点:B
    // 访问了顶点:E
    // 访问了顶点:I
    // 访问了顶点:F
    // 访问了顶点:C
    // 访问了顶点:D
    // 访问了顶点:G
    // 访问了顶点:H
    

    下面这个示意图展示了该算法每一步的执行过程

    深度优先搜索执行过程

    探索深度优化算法

    我们现在只是展示了深度优先搜索算法的工作原理。我们可以用该算法做更多的事情,而不是只输出被访问顶点的顺序。

    对于给定的图G,我们希望深度优先探索算法遍历图G的所有节点,构建“深林”(有根树的一个集合)已经一组源顶点(根),并输出两个数组:发现时间和完成探索时间。我们可以修改 dfs 方法来返回给我们一些信息:

    • 顶点u的发现时间d[u]
    • 当顶点u被标注为黑色时,u的完成探索时间f[u]
    • 顶点u的前溯点p[u]
    // 追踪发现事件和完成探索时间
    var time = 0;
    // 深度优先探索算法优化版本
    this.DFS = function(){
        var color = this.initializeColor(),
        d = [],
        f = [],
        p = [];
        time = 0;
    
        for(var i = 0; i < vertices.length; i++){
            f[vertices[i]] = 0;
            d[vertices[i]] = 0;
            p[vertices[i]] = null;
        }
    
        for(i = 0; i< vertices.length; i++){
            if(color[vertices[i]] === 'white'){
                this.DFSVisit(vertices[i],color,d,f,p)
            }
        }
        return {
            discovery:d,
            finished:f,
            predecessors:p
        }
    }
    this.DFSVisit = function(u,color,d,f,p){
        console.log('发现了'+u);
        color[u] = 'grey';
        d[u] = ++time;
        var neighbors = adjList.get(u);
        for(var i = 0; i < neighbors.length; i++){
            var w = neighbors[i];
            if(color[w] === 'white'){
                p[w] = u;
                arguments.callee(w,color,d,f,p);
            }
        }
        color[u] = 'black';
        f[u] = ++time;
        console.log('探索了'+u);
    }
    

    首先我们需要一个变量来追踪发现时间和完成探索时间。时间变量不能被作为参数传递,因为非对象的变量不能作为引用传递给其他Js方法。接下来,我们声明数组d、f和p。我们需要为图的每一个顶点来初始化这些数组。在这个方法结尾返回这些值。

    当一个顶点第一次被发现时,我们要追踪其发现时间。当它是由引自顶点u的边而被发现的。我们追踪它的前溯点。最后,当这个顶点被完全探索之后,我们追踪其完成时间。

    深度优先算法背后的思想是什么?

    边是从最近发现的u处被向外探索的。只有连接到未发现的顶点的边被探索了。当u所有的边都被探索了,该算法返回u被发现的地方去探索其他的边。这个过程持续到我们发现了虽偶有从原始顶点能够触及的顶点。如果还留有其他未被发现的顶点。我们对新的源顶点将重复这个过程。直到图中所有的顶点都被探索了。

    测试

    var deepPath = graph.DFS();
    console.log(deepPath);
    /**
    发现了A
    发现了B
    发现了E
    发现了I
    探索了I
    探索了E
    发现了F
    探索了F
    探索了B
    发现了C
    发现了D
    发现了G
    探索了G
    发现了H
    探索了H
    探索了D
    探索了C
    探索了A
    
    discovery: [A: 1, B: 2, C: 10, D: 11, E: 3, F: 7, G: 12, H: 14, I: 4]
    finished: [A: 18, B: 9, C: 17, D: 16, E: 6, F: 8, G: 13, H: 15, I: 5]
    predecessors: [A: null, B: "A", C: "A", D: "C", E: "B", G: "D", H: "D", I: "E"]
    */ 
    

    小结

    本章学习了几种不同的方式来表示图这一数据结构。并实现了用邻接表表示图的算法。还学习了广度优先搜索和深度优先搜索的实际应用。

    书籍链接: 学习JavaScript数据结构与算法

  • 相关阅读:
    bzoj3796
    bzoj2186
    bzoj3769
    bzoj2660
    bzoj2245
    bzoj2916
    bzoj1261
    在IDEA中以TDD的方式对String类和Arrays类进行学习
    2018-2019-2 实验二《Java面向对象程序设计》实验报告
    《Java程序设计》第 6 周学习总结
  • 原文地址:https://www.cnblogs.com/lbh2018/p/JavaScript_Data_Structures_and_Algorithms_Part9.html
Copyright © 2020-2023  润新知