06 Javascript数据结构与算法 之 图

1,619 阅读8分钟

1. 定义

图是网络结构的抽象模型,是一组由链接的节点(或定点)。


1.1 图的表示

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

  • V: 一组定点
  • E: 一组边,连接V中的定点


1.2 图的术语

下面我们先熟悉一下的术语:

  • 由一条链接在一起的顶点,称为相邻节点。上图中 AB是相邻节点,AD是相邻节点。
  • 一个定点的是其相邻节点数量。 上图中A3。=>【有向图才会有入度和出度】
  • 路径是顶点v1,v2,v3...vk的一个连续序列。其中vivi+1是相邻的。上图中包含有ABEI,ABF,ACGDH,ACDG,ACDH,ADG,ADH,这些都是简单路径
  • 简单路径:不包含重复的定点。例如:ADG是一条简单路径。除去最后一个节点,也是一个简单路径。例如ADCA(除去最后一个顶点只剩ADC)。

1.3 有向图 / 无向图

图分为无向图(边没有方向)和有向图(边有方向)。上面的图是无向图。下图是有向图:


不对称矩阵是有向图

如果图中每两个顶点间在双向都存在路径,则该图为强联通的。例如下图:AC不是强联通,而CD是强联通的。
图还可以使加权未加权的。加权图的边被赋予了权值。下图是为加权的。


2. 图的表示

图的正确表示法取决于待解决的问题和图的类型。

2.1 邻接矩阵

图最常见的实现是邻接矩阵

  • 通过二维数组来表示顶点之间的关系
  • 每个节点 和 一个整数相关,该整数作为数组的索引
  • 索引为i的节点 和索引为j的节点相邻,则array[i][j] === 1,不相邻时array[i][j] === 0


特性:

  • 不是强联通的图 (稀疏图)使用邻接矩阵表示,矩阵中存储了很多0浪费了计算机存储空间来存储不存在的边。
  • 图的定点的数量可能会改变,二维数组不太灵活
  • 查询具体两个顶点是否是相邻节点,比较
  • 查询某个顶点的所有相邻节点,比较

2.2 邻接表

我们可以使用一种叫邻接表的动态数据结构表示图。领接表由图的每个顶点相邻顶点的列表所组成。相邻顶点的列表数据结构可以通过列表(数组)链表字典散列表来表示。


特性:

  • 查询具体两个顶点是否是相邻节点,比较,需要获取所有相邻节点列表,再查询具体顶点
  • 查询某个顶点的所有相邻节点,比较,直接获取列表
function Graph() {
    // 使用数组存放所有顶点
    let vertices = [];
    // 使用字典存放 相邻顶点 的列表
    let adjList = new Map(); // 这里使用ES6的Map,也就是之前的字典类型数据

    // 用于初始化一个顶点:该顶点需要添加到vertices中,并在adjList中创建一个保存相邻节点的数据
    this.addVertex = (v) => {
        vertices.push(v);
        adjList.set(v, []);
    };

    // 实现添加两个顶点之间的路径(互相添加,表示无向图, 只添加一个顶点,则表示有向图)
    this.addEdge =(v, w) => {
        adjList.get(v).push(w);
        // 有向图:不需要这条设置
        adjList.get(w).push(v);
    };

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

// 测试
var graph = new Graph();
var myVertices = ['A','B','C','D','E','F','G','H','I']; //{7}
for (var i=0; i<myVertices.length; i++){ //{8}
graph.addVertex(myVertices[i]);
}
graph.addEdge('A', 'B'); //{9}
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);

结果:


2.3 关联矩阵

关联矩阵: 矩阵的表示顶点,矩阵的表示。我们使用二维数组来表示顶点的连通性。
如果v 顶点边 e 的入射点(任意一个连接的定点),则array[v][e] === 1,否则array[v][e] === 0


关联矩阵通常用于边的数量顶点多的情况下,以节省空间和内存

3. 图的遍历

图的遍历方式有两种:深度优先(Breadth-First Search,BFS)和广度优先(Depth-First Search,DFS)。
图的用途:寻找特定的顶点、寻找两个顶点之间的路径、检查图是否联通、检查图是否含有环等等。
图遍历算法思想:

  • 必须追踪每个第一次访问的节点,并且追踪哪些节点没有被完全探索
  • 两种算法需要明确指出第一个被访问的顶点

完全探索一个顶点:要求我们查看该顶点的每一条边,对于每一条边连接而未被访问的顶点,标注为发现并添加进待访问顶点列表
为保准效率:每个顶点务必访问至多两次。连通图中每条边和顶点都会被访问到


当标注已访问的顶点时,使用三种颜色反应它们的状态,这也是为什么上面说一个顶点务必最多访问2次的原因:

  • 白色: 表示该顶点 未被访问
  • 灰色: 表示该顶点 被访问但未被探索过
  • 黑色: 表示该顶点 被访问且被探索过

3.1 广度优先(Breadth-First-Search, BFS)

从上面可以知道:从顶点v开始广度优先的步骤如下:

  1. 创建一个队列Q: 用于存放被访问,未被探索【灰色】的顶点
  2. 从顶点v开始,将v设置为灰色,存放如Q
  3. 如果Q为非空【处理队列数据】:
    • 将w从Q队列中中取出
    • 将w的所有相邻节点(白色)标记为灰色,放入Q队列
    • 将w标记为黑色


// --- 该部分代码 直接复制到Graph中

// 将所有的顶点颜色初始化为白色
let initializeColor = () => {
    // 存储所有带颜色的顶点
    let colors = [];
    vertices.map(vertice => {
        colors[vertice] = 'white';
    })
    return colors;
};

// BFS
this.bfs = (v, callback) => {
    // 存放所有访问但未被探索过的节点
    let queue = new Queue();
    let colors = initializeColor();
    queue.enqueue(v);

    // 循环处理所有顶点
    while(!queue.isEmpty()) {
        let u = queue.dequeue();
        colors[u] = 'grey';
        let neighbors = adjList.get(u);
        neighbors.map(w => {
            if (colors[w] === 'white') {
                colors[w] = 'grey';
                queue.enqueue(w);
            }
        });
        colors[u] = 'black';
        if (callback) {
            callback(u);
        }
    }
}


// --- 测试遍历
function printNode(value){ //{16}
console.log('Visited vertex: ' + value); //{1
}
graph.bfs(myVertices[0], printNode); //{18}

3.2 通过 BFS 计算最短路径

通过上面的遍历,我们可以通过小的改造,添加两个属性来记录 传入节点v[被计算的开始节点] 和 任意其他节点w[任意其他节点]之间的距离。

  • distance[w]: 存放v节点到w节点的距离。 初始化distance[w] = 0
  • predecessors[w]: 存放w节点的前辈。 初始化predecessor[w] = null
this.bfs = (v) => {
    console.log(this.toString())
    let colors = {},  // 初始化颜色,后续变化中:未被访问 白色,访问未被探索 grey, 被探索 black
        distance = {}, // 初始化每个顶点离v的距离
        predecessors = {}, // 初始化每个顶点的前辈顶点
        queue = new Queue(); // 存放待探索的节点
    
    
    let init = () => {
        vertices.map(w => {
            colors[w] = 'white';
            distance[w] = 0;
            predecessors[w] = null;
        });
    };

    // 需要被探索的v
    queue.enqueue(v);
    init();

    while(!queue.isEmpty()) {
        let u = queue.dequeue();
        colors[u] = 'grey';
        let neighbors = adjList.get(u); 
        neighbors.map(w => {
            if (colors[w] === 'white') {
                queue.enqueue(w);
                distance[w] = distance[u] + 1; // v => w 的具体,通过前辈节点+1
                predecessors[w] = u; // 添加组件节点
            }
            colors[w] = 'grey';
        });
        colors[u] = 'black';
    }

    return {
        distance,
        predecessors
    };
}
}

// 获取所有的定点的最短路径的所经过的点
this.getRoutes = (v) => {
    // 根据bfs获取 图中各个祖先节点的关系
    let { predecessors } = this.bfs(v);
    // 存放所有最短路径 
    let allRoute = {};

    vertices.map((w) => {
        // 除去顶点自己,不需要计算 自己到自己的轨迹
        if (w != v) {
            // 存放当前 v -> w 的路径
            let route = [w], item = w;

            // 从 predecessors 中获取祖先节点,直到最顶层祖先节点为v,表示v -> w的路径统计完毕
            while (item != v) {
                // 获取自己的祖先顶点
                let preW = predecessors[item];
                route.push(preW);
                item = preW;
            }
            allRoute[w] = route.reverse().join('-');
        }
    });
    return allRoute;
}

按照这种算法:例如查找A -> D 的距离:看起来有两种, AD, ACD。 但是在遍历A的邻接点时,D已被探索过,因此当探索C顶点时,C的D邻接点为黑色,表明已经先到达了D顶点,因此不再探索。最终结果为AD路径。


3.3 DFS 深度优先

深度优先搜索算法:将会从第一个指定的顶点开始遍历图,沿着直到这条路径的最后一个顶点访问,按着原路返回并探索下一条路径。


访问途中的v顶点的步骤如下:

  • 所有顶点,初始化颜色为white
  • 开始访问v顶点,标记v顶点为grey。此时记录顶点v发现时间
  • 访问v所有未被访问的邻接点w
    1. 访问w,此时记录w前溯点
  • v被探索完毕,标记v为黑色。 此时记录顶点v完成探索时间
// --- 该部分代码 直接复制到Graph中

this.dfs =() => {
    let colors = {}, // 初始化所有节点颜色: key:顶点, value: grey, white, black
    d = [], // 记录 发现时间 集合
    f = [], // 记录 完成探索时间 集合
    p = {}, // 记录 前溯点 
    time = 0; // 计时开始时间
    // 初始化颜色
    let init = () => {
        vertices.map(w => {
            colors[w] = 'white';
        });
    };

    // 访问处理
    let dfsVisit = (u) => {

        // 当被访问,修改为grey,表示开始访问,但邻接点未访问完毕
        colors[u] = 'grey';

        // 记录u的开始时间
        d[u] = ++time;
         console.log('discovered ' + u);

        // 当邻接点访问完毕,自己才算完成
        let neighbors = adjList.get(u);
        neighbors.map(w => {
            if (colors[w] === 'white') {
                dfsVisit(w);
                // 记录w的前溯点
                p[w] = u;
            }
        });
        // 自己访问完毕,修改为black
        colors[u] = 'black';
        // 记录u的完成时间
        f[u] = ++time;
        console.log('explored ' + u);
    };

    init();
    vertices.map(w => {
        if (colors[w] === 'white') {
            dfsVisit(w);
        }
    })
    return {
        discovery: d,
        finished: f,
        predecessors: p
    };
}

访问路径:


深度搜索结果: