数据结构之----图

279 阅读5分钟

基本概念

图是顶点集合(vertex) 和顶点间的关系(edge)组成的一种数据结构:Graph=(V, E),下图展示了几个图的示例

1.1 有向图

在有向图中,顶点对<x, y>是有序的,<x, y>是从顶点x到顶点y的一条有向边,顶点x是始点,顶点y是终点,<x, y>与 <y, x>是两条不同的边,上图中的c就是一个有向图,它的顶点集合为V(c) = {0,1,2},边集合为E(c) = {<0, 1>, <1,0>,<1,2>}。

1.2 无向图

在无向图中,顶点对(x,y)是无序的,(x,y)和(y,x)是同一条边,上图中的a就是一个无向图,它的顶点集合为V(a) = {0, 1, 2, 3},它的边集合为E(a)={(0,1), (0,2), (0,3),(1,2),(1,3), (2,3)}。

本章节在讨论图时,有两种类型的图不在讨论范围,一个是顶点与自身相连的图,另一个是无向图中顶点之间有多条边的图,两种图结构如下

1.3 完全图

一个无向图有n个顶点,有n(n-1)/2条边,那么它就是无向完全图,一个有向图中,有n个顶点,有n(n-1)条边,则是有向完全图,两种图的结构如下

1.4 权

边具有与之相关的数值,可以表示从一个顶点到另一个顶点的距离,耗费的时间,这个数值称之为权重。

1.5 邻接顶点

在无向图中,一条边是(u,v),那么u和v互为邻接顶点,在有向图中,是一条边,那么顶点u邻接到顶点v,顶点v邻接自顶点u。

1.6 子图

以1.3中的无向完全图为例,下面的图展示了它的几个子图

1.7 度

与顶点v关联的边数称为度。在有向图中,以v为终点的有向边的数量是v的入度,以v为始点有向边的数量是出度,v的度是入度与出度之和。

1.8 路径

从顶点vi 出发,沿着一些边经过vp1, vp2 ,..., vpm 到达vj,则称(vi, vp1, vp2 ,..., vpm, vj)是从顶点vi到vj的一条路径。

1.9 路径长度

对于不带权的图,路径长度是指路径上边的条数,其实对于不带权的图,可以认为边的权值都为1;对于带权的图,路径长度等于路径上各条边的权值之和。

1.10 连通图和连通分量

在无向图中,如果任意两个顶点之间都是连通的,就称这个图是连通图。非连通图的极大连通子图叫做连通分量,所谓极大连通子图,是指包含顶点个数极大。

在非连通图中,从一个顶点开始,采用深度优先或是广度优先无法遍历图的所有顶点,只能访问到该顶点所在的最大连通子图的所有顶点,这些顶点构成一个连通分量。

在一个连通图中,只有一个连通分量,而在非连通图中,则有多个连通分量,下图中展示的图是一个非连通图,它有两个连通分量,分别是{0,1,2,3},{5,6}。

1.11 强连通图和强连通分量

在有向图中,任意两点(x, y)之间都是连通的,即存在和,那么该图为强连通图,非强连通图,由多个强连通子图构成,非强连通图的极大强连通子图叫做强连通分量,下图是一个非强连通图,它有两个强连通分量。

1.12 生成树

一个无向连通图的生成树,是它的极小连通子图,这里的极小,是指边的数量最小,如果图有n个顶点,则其生成树有n-1条边构成,在有向图中,则可能得到由它的若干有向树组成的生成森林。

2.图的存储结构

2.1 邻接矩阵表示

下图是一个无向连通图的结构

所谓邻接矩阵,就是一个二维数组,上图中的图结构可以用下面的二维数组来表示

var max_value = 9999;
var maps = [
    [0,  28, max_value, max_value, max_value,  10, max_value],
    [28, 0, 16, max_value, max_value,max_value, 14 ],
    [max_value, 16, 0, 12, max_value, max_value, max_value],
    [max_value, max_value, 12, 0, 22, max_value, 18],
    [max_value, max_value, max_value, 22, 0, 25, 24],
    [10, max_value, max_value, max_value, 25, 0, max_value],
    [max_value, 14, max_value, 18, 24, max_value, 0]
];

对于maps[i][j]:

  • 如果i==j, 则maps[i][j]=0
  • 如果i!=j,同时(i,j)存在或者<i,j>存在,则maps[i][j]等于i到j的权重
  • 如果i!=j,同时(i,j)不存在或者<i,j>不存在,则maps[i][j] = max_value,这个max_value表示无限大,本教程用4个9表示。

2.2 邻接表表示

邻接矩阵虽然可以表示图的结构,但存在缺陷,如果图中顶点的数量非常多,而边的数量非常少,那么矩阵就变的非常稀疏,存储效率就低,此时可以改为邻接表的形式来存储数据,下图是一个有向图

如果使用邻接表的形式来存储图,则连接表结构如下

NodeTable用数组来表示,出边表用链表来表示

3.图的遍历

图的遍历有两种方法,一种是深度优先遍历,另一种是广度优先遍历,其实在学习树的时候,就已经接触过这两种遍历方法,二叉搜索树的搜索过程,就是深度优先遍历,而分层打印二叉树则是广度优先遍历。为了讲述这两种遍历方法,先给出用邻接矩阵存储图的一般类定义

const Queue = require("./queue");
var max_value = 9999;
function Graph(){
    var maps = [];
    var node_num = 0;
    var edge_num = 0;
    this.init = function(input_maps){
        maps = input_maps;
        node_num = this.get_node_num();
        edge_num = this.get_edge_num();
    };
    // 获得顶点的个数
    this.get_node_num = function(){
        if(node_num !=0){
            return node_num;
        }
        return maps.length;
    };
    //获得边的个数 
    this.get_edge_num = function(){
        if(edge_num !=0){
            return edge_num;
        }
        var count = 0;
        for(var i = 0;i < node_num;i++){
            for(var j = i+1; j< node_num;j++){
                if(maps[i][j]>0 && maps[i][j]<max_value){
                    count++;
                }
            }
        }
        return count;
    };
    // 获得边的权重
    this.get_weight = function(u, v){
        return maps[u][v];
    };
};

以2.1 中的带权无向连通图为例

NodeTable用数组来表示,出边表用链表来表示

3.图的遍历

图的遍历有两种方法,一种是深度优先遍历,另一种是广度优先遍历,其实在学习树的时候,就已经接触过这两种遍历方法,二叉搜索树的搜索过程,就是深度优先遍历,而分层打印二叉树则是广度优先遍历。为了讲述这两种遍历方法,先给出用邻接矩阵存储图的一般类定义

const Queue = require("./queue");
var max_value = 9999;
function Graph(){
    var maps = [];
    var node_num = 0;
    var edge_num = 0;
    this.init = function(input_maps){
        maps = input_maps;
        node_num = this.get_node_num();
        edge_num = this.get_edge_num();
    };
    // 获得顶点的个数
    this.get_node_num = function(){
        if(node_num !=0){
            return node_num;
        }
        return maps.length;
    };
    //获得边的个数 
    this.get_edge_num = function(){
        if(edge_num !=0){
            return edge_num;
        }
        var count = 0;
        for(var i = 0;i < node_num;i++){
            for(var j = i+1; j< node_num;j++){
                if(maps[i][j]>0 && maps[i][j]<max_value){
                    count++;
                }
            }
        }
        return count;
    };
    // 获得边的权重
    this.get_weight = function(u, v){
        return maps[u][v];
    };
};

以2.1 中的带权无向连通图为例

3.1 深度优先遍历

不同于树的遍历,图中各点有可能互相连通,为了不重复遍历,必须对已经遍历过的点进行标识,示例中使用数组visited[i]=1标识i已经遍历过。
树的遍历默认从root根节点开始,而图不存在根节点的概念,因此在遍历时,要指定起始顶点v,先找出v所能连接的所有顶点,遍历这些顶点,并对这些顶点做同v一样的操作。

    var graph_dfs = function(v, visited, component){
        visited[v] = 1;   //表示v已经访问过
        console.log(v);
        component.push(v);
        var row = maps[v];
        for(var i=0; i<row.length;i++){
            if(row[i]<max_value && visited[i]==0){
                // v 与i 是连通的,且i还没有被遍历过
                graph_dfs(i, visited, component);
            }
        }
    };
    //从顶点v开始深度优先遍历图
    this.dfs = function(v){
        var visited = new Array(node_num);
        var component = [];   //存储连通分量
        for(var i=0;i<node_num;i++){
            visited[i] = 0;
        }
        graph_dfs(v, visited, component);
        return component;
    };

3.2 广度优先遍历

同样使用数组visited[i]=1标识i已经遍历过,和树的分层打印节点一样,需要借助队列,将顶点v所能连通的其他顶点放入到队列中,而后出队列,对这个刚刚对队列的顶点做和v相同的操作

    var graph_bfs = function(v, visited, component){
        var queue = new Queue.Queue();
        queue.enqueue(v);
        visited[v] = 1;   //表示v已经访问过
        console.log(v);
        component.push(v);
        while(!queue.isEmpty()){
            var visited_v = queue.dequeue();
            var row = maps[visited_v];
            for(var i=0; i<row.length;i++){
                if(row[i]<max_value && visited[i]==0){
                    // v 与i 是连通的,且i还没有被遍历过
                    queue.enqueue(i);
                    visited[i] = 1;   //表示v已经访问过
                    console.log(i);
                    component.push(i);
                }
            }
        }
    };

    this.bfs = function(v){
        var visited = new Array(node_num);
        var component = [];
        for(var i=0;i<node_num;i++){
            visited[i] = 0;
        }
        graph_bfs(v, visited, component);
        return component;
    };

3.3 连通分量

下图是一个非连通图

一个图可以有多个互不连通的子图,也就存在多个连通分量,比如从1开始遍历图,会得到一个连通分量,而从7开始,会得到一个连通分量,那么具体从哪个开始呢,你是无法确定的,因此图的存储结构里并没有标识有几个连通分量以及各个连通分量里的顶点集合。

因此,要对所有的顶点进行检测,如果已经被访问过,那么这个点一定会落在图中已经求得的连通分量上,否则,从该顶点触发遍历图,就可以得到另一个连通分量

    this.components = function(){
        var visited = new Array(node_num);
        var component_lst = [];
        for(var i=0;i<node_num;i++){
            visited[i] = 0;
        }

        for(var i=0;i<node_num;i++){
            if(visited[i]==0){
                var component = [];
                graph_bfs(i, visited, component)
                component_lst.push(component);
            }
        }
        return component_lst;
    };

4.最小生成树

连通图中的每一棵生成树,都是原图的一个极大无环子图,删去任何一条边,生成树就不再连通了,增加一条边,就会形成回路。

一个图有很多生成树,不同的生成树所对应的权值总和也不一样,权值之和最小的那棵生成树被称为最小生成树,最小生成树可以解决路径规划问题。

假设在n个城市之间建立通信网络,则至少需要n-1条路线需要架设,如果任意两个城市之间的建立通信线路的成本已经确定下来,那么如何规划路线,才能使得成本是最低的?这里仍然使用2.1 中的图为例

每个顶点代表一座城市,权重代表建设成本,那么该如何规划路线?

4.1 Kruskal算法

克鲁斯卡尔算法与哈夫曼树的生成算法非常相似,先将所有的边都存入到一个最小堆中,用权值做关键码,那么堆顶的边,一定会被至少一棵最小生成树所采用,于是将堆顶删除放入到最小生成树中,现在,堆顶是剩余的边中权值最小的,继续删除并放入到最小生成树中。

在反复的删除堆顶的边并加入到最小生成树的过程中,要判断边的两个顶点是否在同一个连通分量中,如果是,那么这个边就不能使用,否则会形成回路。

为什么这样就可以创建最小生成树呢?以(0,5)这条边为例,假设最小生成树里没有这条边,那么,必然是其他的两条边将顶点0和5接入到最小生成树,可是,(0,5)这两条边是能够找到的权值最小的边,如果前面假设的那两条边的权值大于10,那么显然不如用(0,5),如果前面假设的那两条边的权值等于10呢?(0,5)这条边确实可以不在最小生成树中,但是当你选取了另外两条边将顶点0和5带入最小生成树中,你依然遵循着选取权重最小的边这个规则, 而且另外两条边的选取依然面临着(0, 5)这条边的问题,所以,总是选取权重最小的边,必然构建出最小生成树。
下图是构建最小生成树的过程

在从d图向e图的构建过程中,顶点3到顶点6的距离是18,小于顶点3到顶点4的22,但是如果选择了3到6这条边,就会形成环路,判断两个顶点是否在同一个连通分量中,可以是使用并查集,示例代码如下

const MinHeap = require("./minheap");
const UFSets = require("./ufset");
const Graph = require("./graph");

var max_value = 9999;

var Edge = function(head, tail, cost){
    this.head = head;
    this.tail = tail;
    this.cost = cost;
};


function kruskal(graph){
    var mst = [];
    var node_num = graph.get_node_num();
    var edge_num = graph.get_edge_num();
    var min_heap = new MinHeap.MinHeap(edge_num);
    var ufset = new UFSets.UFSets(node_num);

    for(var i = 0;i < node_num;i++) {
        for (var j = i + 1; j < node_num; j++) {
            var cost = graph.get_weight(i, j);
            if(graph.get_weight(i, j) != max_value){
                var ed = new Edge(i, j, cost);
                min_heap.insert(ed);
            }
        }
    }

    var count = 1;
    while(count<node_num){
        var ed = min_heap.remove_min();
        var head_root = ufset.find(ed.head);
        var tail_root = ufset.find(ed.tail);
        if(head_root != tail_root){
            ufset.union(head_root, tail_root);
            mst.push(ed);
            count++;
        }else{
            console.log("构成环路");
            console.log(ed);
        }

    }
    return mst;
};

var maps = [
    [0,  28, max_value, max_value, max_value,  10, max_value],
    [28, 0, 16, max_value, max_value,max_value, 14 ],
    [max_value, 16, 0, 12, max_value, max_value, max_value],
    [max_value, max_value, 12, 0, 22, max_value, 18],
    [max_value, max_value, max_value, 22, 0, 25, 24],
    [10, max_value, max_value, max_value, 25, 0, max_value],
    [max_value, 14, max_value, 18, 24, max_value, 0]
];
var graph = new Graph.Graph();
graph.init(maps);

var mst = kruskal(graph);
console.log(mst);

程序最终输出结果为

构成环路
{ head: 3, tail: 6, cost: 18 }
构成环路
{ head: 4, tail: 6, cost: 24 }
[ { head: 0, tail: 5, cost: 10 },
  { head: 2, tail: 3, cost: 12 },
  { head: 1, tail: 6, cost: 14 },
  { head: 1, tail: 2, cost: 16 },
  { head: 3, tail: 4, cost: 22 },
  { head: 4, tail: 5, cost: 25 } ]

4.2 prim算法

prim(普里姆)算法要求指定一个顶点v,从这个顶点开始构建最小生成树。
与kruskal算法类似,也需要一个最小堆存储边,存储图的边,顶点v是第一个加入到最小生成树顶点集合的顶点,记做b_mst[v]=1。

用数组b_mst[i]=1 表示顶点i在最小生成树顶点集合中,每次选出一个端点在生成树中,而另一个端点不在生成树的权值最小的边(u,v),而它恰好是堆顶的边,将其从最小堆中删除,并加入到生成树中,然后将新出现的所有一个端点在生成树中,另一个端点不在生成树中的边加入到最小堆中,如此重复,直到找到n-1条边。

为什么这样就能创建最小生成树呢?以顶点1为例,以顶点1为基础,扩充创建最小生成树时,选取(1,6)的代价是最小的,以此为基础,继续扩充创建,选取(1,2)这条边的代价是最小的,以此类推,每一步都是代价最小的,那么最终必然生成一棵最小生成树。

假设在选取了(1,6)之后,不选取(1,2),而是选取了(6,3),虽然(6,3)的权重更大,但是是否存在一种可能,虽然这一步权重大了,但由于路径变化,导致后面的可以选取到权重比(1,2)更小的边,从而使得可以生成一棵更小的生成树?答案是不可能,因为顶点2终究是要被加入到最小生成树中,既然用(1,2),就只能用(2,3)这条边,可是这样一来,(2,3)的权重加上(6,3)的权重必然会大于(1,2)的权重加上(2,3)的权重,明显不如用(1,2),(2,3)两条边。
下图是用prim算法创建最小生成树的过程:

示例代码:

const MinHeap = require("./minheap");
const Graph = require("./graph");

var max_value = 9999;

var Edge = function(head, tail, cost){
    this.head = head;
    this.tail = tail;
    this.cost = cost;
};

// 从顶点v开始构建最小生成树
function prim(graph, v){
    var mst = [];
    var node_num = graph.get_node_num();
    var edge_num = graph.get_edge_num();
    var b_mst = new Array(node_num);
    // b_mst标识哪些点已经
    for(var i =0;i<node_num;i++){
        b_mst[i] = 0;
    }
    b_mst[v] = 1;
    var count = 1;
    var start_v = v;
    var min_heap = new MinHeap.MinHeap(edge_num);

    while(count < node_num){
        // 先找到所有start_v 能够到达的顶点
        for(var i = 0;i < node_num;i++) {
            if(b_mst[i]==0){
                var cost = graph.get_weight(start_v, i);
                if(cost != max_value){
                    var ed = new Edge(start_v, i, cost);
                    min_heap.insert(ed);
                }
            }
        }

        while(min_heap.size()!=0){
            var ed = min_heap.remove_min();
            // ed.tail还没有加入到生成树的顶点集合中
            if(b_mst[ed.tail] == 0){
                mst.push(ed);
                start_v = ed.tail;    //新的起点
                b_mst[start_v] = 1;
                count++;
                break;
            }
        }
    }

    return mst;
};

var maps = [
    [0,  28, max_value, max_value, max_value,  10, max_value],
    [28, 0, 16, max_value, max_value,max_value, 14 ],
    [max_value, 16, 0, 12, max_value, max_value, max_value],
    [max_value, max_value, 12, 0, 22, max_value, 18],
    [max_value, max_value, max_value, 22, 0, 25, 24],
    [10, max_value, max_value, max_value, 25, 0, max_value],
    [max_value, 14, max_value, 18, 24, max_value, 0]
];
var graph = new Graph.Graph();
graph.init(maps);

var mst = prim(graph, 1);
console.log(mst);

5. 最短路径问题

交通路网,可以视为一个带权图,下图表示一片路网,顶点是路口,边上的权值为从一个路口到达另一个路口所需要的时间。

dijkstra(迪杰斯特拉)算法按路径长度的递增次序,逐步产生最短的路径,它可以算出始点到其他各点的最短距离。关于dijkstra算法,有两点基础认知你必须掌握

  • 假设从A到B存在一条最短路径AB,而C恰好在这条路径上,那么AB的子路径AC一定是从A到C的最短路径

    ,如果存在子路径AC不是最短的,那么就存在另一条更短的从A到C的路径,成为'AC,那么'AC+CB小于AB,AB就不是最短的路径了,与假设矛盾。

  • 从顶点1出发,可以到达顶点 2,3, 4, 5 ,路径长度分别为 7, 7, 2, 2 ,这其中,到达顶点4和顶点5的长度是2,是所有能够到达顶点的最短路径,那么我们可以确定,顶点1到达顶点4的最短路径长度是2,到达顶点5的最短路径长度是2,至于到达顶点3和顶点2的路径长度是否为最短,则目前还无法确认。原理是顶点1到达顶点4的路径长度已经是从顶点1出发的边的权重中最小的了,走其他任何路线,其路径长度都大于2。之所以不能确定顶点1到顶点3的路径长度是否最短,是因为可以从顶点1出发经过像顶点4这样的顶点绕一圈来到顶点3,例如图中,从顶点1出发,经过顶点4,7来到顶点3,路径长度为6,小于直接从顶点1直接到达顶点3。

为了方便演示算法,我们使用字典来表示一个图结构

var graph_dict = {
    "0":{"5":2, "4":3},    // 表示从0可以到5,权值为2, 0可以到4,权值为3
    "1":{"2":7, "3":7, "4":2, "5":2},
    "2":{"8":8, "6":7, "1":7},
    "3":{"6":2, "10":3, "7":1, "1":7},
    "4":{"1":2, "7":3, "0":3},
    "5":{"14":10, "1":2, "0":2},
    "6":{"9":1, "12":4, "3":2, "2":7},
    "7":{"3":1, "11":2, "4":3},
    "8":{"9":4, "2":8, "14":1},
    "9":{"13":9, "6":1, "8":4},
    "10":{"12":6, "11":8, "3":3},
    "11":{"10":8, "7":2},
    "12":{"13":2, "10":6, "6":4},
    "13":{"12":2, "9":9}
};

计算从顶点v到其余个顶点最短距离的算法思路如下:
使用数组v_arr 存储已经找到最短路径长度的顶点,使用字典dis 存储顶点v到达各个顶点的最短路径长度,用path存储最短路径
第一步,初始化dis,从顶点v到达每一个顶点的距离都设置为INF=9999

第二步,从顶点v开始,以顶点v为中心,找到所能到达的顶点及其路径长度,然后更新dis,将顶点v加入到v_arr中

第三步,从剩余的顶点中,找到距离顶点v最近顶点w,然后以w为中心向外寻找所有能到达的顶点及其路径长度,并更新dis,并将w加入到v_arr中,不难看出,对顶点w的操作和顶点v的操作是一样的,找到的顶点w其实相当于第二步中的v,循环这个过程,直到所有的顶点都存放在了v_arr 中。

下一条最短路径,总是在“由已经产生的最短路径上再扩充一条边”形成的最短路径中找到。由于每一次循环,都是从距离顶点v最近顶点出发,而且每次都更新dis,所以,最终,dis保存的就是顶点v到达所有顶点的最短路径长度。

比如对于顶点0来说,能够到达它的顶点有5和4,而已知顶点1到达顶点4和5的最短路径长度都是2,那么以顶点4为新的中心向外寻找最短路径时会找到顶点0,且顶点4到顶点0的最短路径是3,那么此时顶点1到达顶点0的最短路径长度就是2+3=5。以顶点5为中心向外寻找最短路径时也会找到顶点0,且顶点5到达顶点0的最短距离是2,那么此时顶点1到达顶点0的最短路径长度就是2+2=4,比5小,于是更新dis。
示例代码

var graph_dict = {
    "0":{"5":2, "4":3},    // 表示从0可以到5,权值为2, 0可以到4,权值为3
    "1":{"2":7, "3":7, "4":2, "5":2},
    "2":{"8":8, "6":7, "1":7},
    "3":{"6":2, "10":3, "7":1, "1":7},
    "4":{"1":2, "7":3, "0":3},
    "5":{"14":10, "1":2, "0":2},
    "6":{"9":1, "12":4, "3":2, "2":7},
    "7":{"3":1, "11":2, "4":3},
    "8":{"9":4, "2":8, "14":1},
    "9":{"13":9, "6":1, "8":4},
    "10":{"12":6, "11":8, "3":3},
    "11":{"10":8, "7":2},
    "12":{"13":2, "10":6, "6":4},
    "13":{"12":2, "9":9}
};


var INF = 9999;
function dijkstra(graph, start, end){
    var v_arr = [];     // 记录已经考察过的点
    var dis = {};       // 记录从start到各个点的最小距离
    var path = {}       // 记录路径

    for(var key in graph){
        dis[key] = INF;
        path[key] = start;
    }
    dis[start] = 0;

    var min_v = start;
    while(true){
        v_arr.push(min_v);
        // 得到min_v所连接的点
        var v_link = graph[min_v];
        for(var key in v_link){
            // 从 start出发,经过min_v到达 key这个点的长度小于之前发现的最短路径
            if(dis[min_v] + v_link[key] < dis[key]){
                dis[key] = dis[min_v] + v_link[key];
                path[key] = min_v;   //从start出发到达key的最短路径中,一定要通过min_v到达key
            }
        }

        // 从剩余的没有处理过的点中选取具有最短路径的顶点
        var min_dis = INF;
        for(var key in graph){
            if(v_arr.indexOf(key) >= 0){
                continue;
            }

            if(dis[key] < min_dis){
                min_dis = dis[key];
                min_v = key;
            }
        }
        if(min_dis == INF){
            break;
        }
    }

    // 输出最短路径
    var link_path = [];
    var tmp_v = path[end];
    link_path.push(end);
    while(tmp_v){
        link_path.push(tmp_v);
        tmp_v = path[tmp_v];
        if(tmp_v === start){
            link_path.push(start);
            break;
        }
    }
    console.log(link_path);
};

dijkstra(graph_dict, "1", "13");