学习JavaScript数据结构与算法之图(七)

1. 什么是图

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

任何社交网络,例如 Facebook、Twitter 和 Google+,都可以用图来表示,图还可以表示任何二元关系,比如道路,航班

如下图:

image.png

image.png

1.1 图的一些术语

图在数学及技术上的概念

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

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

如下图:

image.png

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

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

路径是顶点 v1v_1, v2v_2, …, vkv_k的一个连续序列,其中 viv_ivi+1v_{i+1}是相邻的。以上图中的图为例,其中包含路径 ABEI 和 ACDG。

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

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

1.2 有向图和无向图

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

image.png

如果图中每两个顶点间在双向上都存在路径,则该图是强连通的。例如,C 和 D 是强连通的,而 A 和 B 不是强连通的。

图还可以是未加权的或是加权的。如下图所示,加权图的边被赋予了权值

image.png

2. 图的表示

2.1 邻接矩阵

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

image.png

不是强连通的图(稀疏图)如果用邻接矩阵来表示,则矩阵中将会有很多 0,这意味着我们浪费了计算机存储空间来表示根本不存在的边。例如,找给定顶点的相邻顶点,即使该顶点只有一个相邻顶点,我们也不得不迭代一整行。

邻接矩阵表示法不够好的另一个理由是,图中顶点的数量可能会改变,而二维数组不太灵活

2.2 邻接表

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

image.png

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

2.3 关联矩阵

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

image.png

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

3. 实现一个Graph 类

邻接表实现

export default class Graph {
  constructor(isDirected = false) {
    this.isDirected = isDirected; // 是否有向
    this.vertices = []; // 存储图中所有顶点的名字
    this.adjList = new Map(); // 存储邻接表,key为顶点,邻接顶点列表作为值
  }
  
  // 向图中添加一个新的顶点
  addVertex(v) {
    if (!this.vertices.includes(v)) {
      this.vertices.push(v); // 存入顶点数组
      this.adjList.set(v, []);
    }
  }
  
  // 添加顶点之间的边
  addEdge(a, b) {
    // 如果点不存在,将它加入顶点列表中
    if (!this.adjList.get(a)) {
      this.addVertex(a);
    }
    if (!this.adjList.get(b)) {
      this.addVertex(b);
    }
    
    // 添加(有向图)
    this.adjList.get(a).push(b);
    // 无向图,互相添加
    if (this.isDirected !== true) {
      this.adjList.get(b).push(a);
    }
  }
  
  // 返回顶点列表
  getVertices() {
    return this.vertices;
  }
  
  // 返回邻接表
  getAdjList() {
    return this.adjList;
  }
  
  toString() {
    let s = '';
    for (let i = 0; i < this.vertices.length; i++) {
      s += `${this.vertices[i]} -> `;
      const neighbors = this.adjList.get(this.vertices[i]);
      for (let j = 0; j < neighbors.length; j++) {
        s += `${neighbors[j]} `;
      }
      s += '\n';
    }
    return s;
  }
}
复制代码

测试代码:

const graph = new Graph(); 
const myVertices = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I'];
for (let i = 0; i < myVertices.length; i++) {
 graph.addVertex(myVertices[i]); 
} 
graph.addEdge('A', 'B');
复制代码

4. 图的遍历

广度优先搜索(breadth-first search,BFS)和深度优先搜索
图遍历可以用来寻找特定的顶点或寻找两个顶点之间的路径,检查图是否连通,检查图是否含有环,等等

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

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

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

  • 深度优先搜索使用栈栈,将顶点存入栈,顶点是沿着路径被探索的,存在新的相邻顶点就去访问
  • 广度优先搜索使用队列,将顶点存入队列,最先入队列的顶点先被探索

我们用三种颜色来表示顶点状态:

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

代码如下:

const Colors = { 
 WHITE: 0, 
 GREY: 1, 
 BLACK: 2 
};
复制代码

首先我么你需要将所有顶点标记为未访问

const initializeColor = vertices => { 
 const color = {}; 
 for (let i = 0; i < vertices.length; i++) { 
 color[vertices[i]] = Colors.WHITE; 
 } 
 return color; 
};
复制代码

4.1 广度优先搜索

广度优先搜索算法会从指定的第一个顶点开始遍历图,先访问其所有的邻点(相邻顶点),就像一次访问图的一层。
image.png

步骤如下:

  1. 创建一个队列 Q。
  2. 标注 v 为被发现的(灰色),并将 v 入队列 Q。
  3. 如果 Q 非空,则运行以下步骤:
  4. 将 u 从 Q 中出队列;
  5. 标注 u 为被发现的(灰色);
  6. 将 u 所有未被访问过的邻点(白色)入队列;
  7. 标注 u 为已被探索的(黑色)。
export const breadthFirstSearch = (graph, startVertex, callback) => {
  const vertices = graph.getVertices();
  const adjList = graph.getAdjList();
  // 将所有顶点标记为白色
  const color = initializeColor(vertices);
  const queue = new Queue();
  // 将顶点入队
  queue.enqueue(startVertex);

  while (!queue.isEmpty()) {
    const u = queue.dequeue();
    // 取得其所有邻点的邻接表
    const neighbors = adjList.get(u);
    // 将该顶点标为灰色
    color[u] = Colors.GREY;
    // 遍历邻接表中的值
    for (let i = 0; i < neighbors.length; i++) {
      const w = neighbors[i];
      // 如果该点未被访问过,将其标灰
      if (color[w] === Colors.WHITE) {
        color[w] = Colors.GREY;
        // 将该点加入队列
        queue.enqueue(w);
      }
    }
    // 标黑
    color[u] = Colors.BLACK;
    if (callback) {
      callback(u);
    }
  }
};
复制代码

使用BFS寻找最短路径

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

const BFS = (graph, startVertex) => {
  const vertices = graph.getVertices();
  const adjList = graph.getAdjList();
  const color = initializeColor(vertices);
  const queue = new Queue();
  // 储存距离
  const distances = {};
  // 前溯点
  const predecessors = {};
  queue.enqueue(startVertex);
  // 初始化
  for (let i = 0; i < vertices.length; i++) {
    distances[vertices[i]] = 0;
    predecessors[vertices[i]] = null;
  }
  while (!queue.isEmpty()) {
    const u = queue.dequeue();
    const neighbors = adjList.get(u);
    color[u] = Colors.GREY;
    for (let i = 0; i < neighbors.length; i++) {
      const w = neighbors[i];
      if (color[w] === Colors.WHITE) {
        color[w] = Colors.GREY;
        // 距离+1
        distances[w] = distances[u] + 1;
        // 发现顶点 u 的邻点 w 时,则设置 w 的前溯点值为 u
        predecessors[w] = u;
        queue.enqueue(w);
      }
    }
    color[u] = Colors.BLACK;
  }
  return {
    distances,
    predecessors
  };
};
复制代码

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

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

4.2 深度优先搜索

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

image.png

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

  1. 标注 v 为被发现的(灰色);
  2. 对于 v 的所有未访问(白色)的邻点 w,访问顶点 w;
  3. 标注 v 为已被探索的(黑色)。
const depthFirstSearchVisit = (u, color, adjList, callback) => {
  // 标注为灰色
  color[u] = Colors.GREY;
  if (callback) {
    callback(u);
  }
  // 取得包含顶点 u 所有邻点的列表
  const neighbors = adjList.get(u);
  for (let i = 0; i < neighbors.length; i++) {
    const w = neighbors[i];
    // 如果未访问过,递归访问
    if (color[w] === Colors.WHITE) {
      depthFirstSearchVisit(w, color, adjList, callback);
    }
  }
  color[u] = Colors.BLACK;
};

const depthFirstSearch = (graph, callback) => {
  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);
    }
  }
};
复制代码

探索过程如下图:

image.png

探索深度优先算法

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

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

改进了的 DFS 方法的实现

const DFSVisit = (u, color, d, f, p, time, adjList) => {
  // console.log('discovered ' + u);
  color[u] = Colors.GREY;
  // 当一个顶点第一次被发现时,我们追踪其发现时间
  d[u] = ++time.count;
  const neighbors = adjList.get(u);
  for (let i = 0; i < neighbors.length; i++) {
    const w = neighbors[i];
    if (color[w] === Colors.WHITE) {
      // 当它是由引自顶点 u 的边而被发现的,我们追踪它的前溯点
      p[w] = u;
      DFSVisit(w, color, d, f, p, time, adjList);
    }
  }
  color[u] = Colors.BLACK;
  // 当这个顶点被完全探索后,我们追踪其完成时间
  f[u] = ++time.count;
  // console.log('explored ' + u);
};

export const DFS = graph => {
  const vertices = graph.getVertices();
  const adjList = graph.getAdjList();
  const color = initializeColor(vertices);
  const d = {};
  const f = {};
  const p = {};
  // 声明一个变量来追踪发现时间和完成探索时间
  const time = { count: 0 };
  // 我们声明数组 d、f 和 p,为图的每一个顶点来初始化这些数组
  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) {
      DFSVisit(vertices[i], color, d, f, p, time, adjList);
    }
  }
  return {
    discovery: d,
    finished: f,
    predecessors: p
  };
};
复制代码
  • 时间(time)变量值的范围只可能在图顶点数量的一倍到两倍(2|V|)之间;
  • 对于所有的顶点 u,d[u]<f[u](意味着,发现时间的值比完成时间的值小,完成时间意思是所有顶点都已经被探索过了)

在这两个假设下,我们有如下的规则。
1 <= d [u] < f [u] <= 2|V|

image.png

如果对同一个图再跑一遍新的深度优先搜索方法,对图中每个顶点,我们会得到如下的发现/完成时间

拓扑排序——使用深度优先搜索

给定下图,假定每个顶点都是一个我们需要去执行的任务

image.png

这是一个有向图,意味着任务的执行是有顺序的。例如,任务 F 不能在任务 A之前执行。注意这个图没有环,意味着这是一个无环图。所以,我们可以说该图是一个有向无环图

当我们需要编排一些任务或步骤的执行顺序时,这称为拓扑排序(topological sorting,英文亦写作 topsort 或是 toposort)。在日常生活中,这个问题在不同情形下都会出现。例如,当我们开始学习一门计算机科学课程,在学习某些知识之前得按顺序完成一些知识储备(你不可以在上算法 I 课程前先上算法 II 课程)。

拓扑排序只能应用于 DAG。那么,如何使用深度优先搜索来实现拓扑排序呢?让我们在本节开头的示意图上执行一下深度优先搜索

graph = new Graph(true); // 有向图
myVertices = ['A', 'B', 'C', 'D', 'E', 'F']; 
for (i = 0; i < myVertices.length; i++) { 
 graph.addVertex(myVertices[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);
复制代码

这段代码将创建图,添加边,执行改进版本的深度优先搜索算法,并将结果保存到 result变量。下图展示了深度优先搜索算法执行后,该图的发现和完成时间

image.png

现在要做的仅仅是以倒序来排序完成时间数组,这便得出了该图的拓扑排序,如下所示。

const fTimes = result.finished; 
s = ''; 
for (let count = 0; count < myVertices.length; count++) { 
 let max = 0; 
 let maxName = null; 
 for (i = 0; i < myVertices.length; i++) { 
 if (fTimes[myVertices[i]] > max) { 
 max = fTimes[myVertices[i]]; 
 maxName = myVertices[i]; 
 } 
 } 
 s += ' - ' + maxName; 
 delete fTimes[maxName]; 
} 
console.log(s); 
复制代码

执行了上述代码后,我们会得到下面的输出。
B – A – D – C – F – E
注意之前的拓扑排序结果仅是多种可能性之一。如果我们稍微修改一下算法,就会有不同的结果。比如下面这个结果也是众多其他可能性中的一个。
A – B – C – D – F – E
这也是一个可以接受的结果

5. 最短路径算法

5.1 Dijkstra 算法

Dijkstra 算法是一种计算从单个源到所有其他源的最短路径的贪心算法

image.png

上图的邻接矩阵如下:

var graph = [
    [0, 2, 4, 0, 0, 0], 
    [0, 0, 1, 4, 2, 0], 
    [0, 0, 0, 0, 3, 0], 
    [0, 0, 0, 0, 0, 2], 
    [0, 0, 0, 3, 0, 2], 
    [0, 0, 0, 0, 0, 0]
]
复制代码
const INF = Number.MAX_SAFE_INTEGER;
const minDistance = (dist, visited) => {
  let min = INF;
  let minIndex = -1;
  for (let v = 0; v < dist.length; v++) {
    if (visited[v] === false && dist[v] <= min) {
      min = dist[v];
      minIndex = v;
    }
  }
  return minIndex;
};

const dijkstra = (graph, src) => {
  const dist = [];
  const visited = [];
  const { length } = graph;
  for (let i = 0; i < length; i++) { // 将所有距离初始为无限大
    dist[i] = INF;
    visited[i] = false;
  }
  dist[src] = 0; // 把源顶点到自己的距离设为 0
  for (let i = 0; i < length - 1; i++) { // 找出到其余顶点的最短路径
    const u = minDistance(dist, visited); // 从尚未处理的顶点中选出距离最近的顶点
    visited[u] = true; // 把选出的顶点标为 visited,以免重复计算
    for (let v = 0; v < length; v++) {
      if (!visited[v] && graph[u][v] !== 0 && dist[u] !== INF && dist[u] + graph[u][v] < dist[v]) { // 如果找到更短的路径,则更新最短路径的值
        dist[v] = dist[u] + graph[u][v];
      }
    }
  }
  return dist; // 处理完所有顶点后,返回从源顶点(src)到图中其他顶点最短路径的结果
};
复制代码

5.2 Floyd-Warshall 算法

Floyd-Warshall 算法是一种计算图中所有最短路径的动态规划算法。

const floydWarshall = graph => {
  const dist = [];
  const { length } = graph;
  for (let i = 0; i < length; i++) { // 把 distance 数组初始化为每个顶点之间的权值
    dist[i] = [];
    for (let j = 0; j < length; j++) {
      if (i === j) {
        dist[i][j] = 0; // 顶点到自身的距离为 0
      } else if (!isFinite(graph[i][j])) {
        dist[i][j] = Infinity; // 如果两个顶点之间没有边,就将其表示为 Infinity
      } else {
        dist[i][j] = graph[i][j];
      }
    }
  }
  for (let k = 0; k < length; k++) { // 将顶点 0 到 k 作为中间点
    for (let i = 0; i < length; i++) {
      for (let j = 0; j < length; j++) {
        if (dist[i][k] + dist[k][j] < dist[i][j]) { //  i 到 j 的最短路径经过 k
          dist[i][j] = dist[i][k] + dist[k][j]; // 如果一个最短路径的新的值被找到,我们要使用并存储它
        }
      }
    }
  }
  return dist;
};
复制代码

6. 最小生成树

最小生成树(MST)问题是网络设计中常见的问题。想象一下,你的公司有几间办公室,要以最低的成本实现办公室电话线路相互连通,以节省资金,最好的办法是什么?

这也可以应用于岛桥问题。设想你要在 n 个岛屿之间建造桥梁,想用最低的成本实现所有岛屿相互连通

image.png

var graph = [
    [0, 2, 4, 0, 0, 0], 
    [2, 0, 2, 4, 2, 0], 
    [4, 2, 0, 0, 3, 0], 
    [0, 4, 0, 0, 3, 2], 
    [0, 2, 3, 3, 0, 2], 
    [0, 0, 0, 2, 2, 0]
];
复制代码

6.1 Prim 算法

Prim 算法是一种求解加权无向连通图的 MST 问题的贪心算法。它能找出一个边的子集,使得其构成的树包含图中所有顶点,且边的权值之和最小

const INF = Number.MAX_SAFE_INTEGER;
const minKey = (graph, key, visited) => {
  let min = INF;
  let minIndex = 0;
  for (let v = 0; v < graph.length; v++) {
    if (visited[v] === false && key[v] < min) {
      min = key[v];
      minIndex = v;
    }
  }
  return minIndex;
};
export const prim = graph => {
  const parent = [];
  const key = [];
  const visited = [];
  const { length } = graph;
  for (let i = 0; i < length; i++) { // 把所有顶点(key)初始化为无限大
    key[i] = INF;
    visited[i] = false;
  }
  // 选择第一个 key 作为第一个顶点,同时,因为第一个顶点总是 MST 的根节点,所以 parent[0] = -1
  key[0] = 0;
  parent[0] = -1;
  for (let i = 0; i < length - 1; i++) { // 对所有顶点求 MST
     // 从未处理的顶点集合中选出 key 值最小的顶点
    const u = minKey(graph, key, visited);
    // 把选出的顶点标为 visited,以免重复计算
    visited[u] = true;
    for (let v = 0; v < length; v++) {
      if (graph[u][v] && !visited[v] && graph[u][v] < key[v]) {
        parent[v] = u; // 如果得到更小的权值,则保存 MST 路径
        // 更新其权值
        key[v] = graph[u][v];
      }
    }
  }
  return parent;
};
复制代码

6.2 Kruskal 算法

const INF = Number.MAX_SAFE_INTEGER;
const find = (i, parent) => {
  while (parent[i]) {
    i = parent[i]; // eslint-disable-line prefer-destructuring
  }
  return i;
};
const union = (i, j, parent) => {
  if (i !== j) {
    parent[j] = i;
    return true;
  }
  return false;
};
const initializeCost = graph => {
  const cost = [];
  const { length } = graph;
  for (let i = 0; i < length; i++) {
    cost[i] = [];
    for (let j = 0; j < length; j++) {
      if (graph[i][j] === 0) {
        cost[i][j] = INF;
      } else {
        cost[i][j] = graph[i][j];
      }
    }
  }
  return cost;
};
export const kruskal = graph => {
  const { length } = graph;
  const parent = [];
  let ne = 0;
  let a;
  let b;
  let u;
  let v;
  // 把邻接矩阵的值复制到 cost 数组,以方便修改且可以保留原始值
  const cost = initializeCost(graph);
  while (ne < length - 1) { // 当 MST 的边数小于顶点总数减 1 时
    for (let i = 0, min = INF; i < length; i++) { // 找出权值最小的边
      for (let j = 0; j < length; j++) {
        if (cost[i][j] < min) {
          min = cost[i][j];
          a = u = i;
          b = v = j;
        }
      }
    }
    // 检查 MST 中是否已存在这条边,以避免环路
    u = find(u, parent);
    v = find(v, parent);
    if (union(u, v, parent)) { // 如果 u 和 v 是不同的边,则将其加入 MST
      ne++;
    }
    cost[a][b] = cost[b][a] = INF; // 从列表中移除这些边,以免重复计算
  }
  return parent;
};
复制代码

6.2 常见深度、广度优先搜索考题

leetcode-cn.com/problems/bi…
leetcode-cn.com/problems/mi…
leetcode-cn.com/problems/ge…
leetcode-cn.com/problems/fi…
leetcode-cn.com/problems/wo…
leetcode-cn.com/problems/wo…
leetcode-cn.com/problems/nu…
leetcode-cn.com/problems/mi…
417. 太平洋大西洋水流问题
133. 克隆图

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享