83 阅读10分钟

一、图的概念

图是一组由边连接的节点(或顶点)。如下图

image.png

由一条边连接在一起的顶点称为相邻顶点。如 A 和 B 是相邻的。

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

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

简单路径要求不包含重复的顶点。如A D G 是一条简单路径。也是一个简单路径,如 A D C A(最后一个顶点重新回到 A)。

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

二、有向图和无向图

图可以是无向的(边没有方向)或是有向的(有向图)。如下图所示,有向图的边有一个方向。

image.png

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

图还可以是未加权的(目前为止我们看到的图都是未加权的)或是加权的。如下图所示,加权图的边被赋予了权值。

image.png

三、图的表示

1. 邻接矩阵

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

image.png

不是强连通的图(稀疏图)如果用邻接矩阵来表示,则矩阵中将会有很多 0,这意味着我们浪费了计算机存储空间来表示根本不存在的边。但是要找出顶点 v 和 w 是否相邻,使用邻接矩阵会比较快。

2. 邻接表

邻接表由图中每个顶点的相邻顶点列表所组成。存在好几种方式来表示这种数据结构。我们可以用列表(数组)、链表,甚至是散列表或是字典来表示相邻顶点列表。如下图:

image.png

3. 关联矩阵

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

image.png

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

四、创建 Graph 类

1. 定义基本骨架
class Graph {
  constructor(isDirected = false) {
    this.isDirected = isDirected  // 接收图是否有向,默认为无向图
    this.vertices = []  // 存储图中的所有顶点名字
    this.adjList = new Dictionary()  // 使用字典存储邻接表,使用顶点名字作为键,邻接顶点列表作为值
  }
}
2. 添加顶点
addVertex(v) {
  if (!this.vertices.includes(v)) {  // 不存在图中加入
    this.vertices.push(v)  // 加入顶点列表
    this.adjList.set(v, [])  // 设置顶点 v 作为键对应的字典值为一个空数组
  }
}
3. 添加边
//  v , w 为两个要建立连接的顶点
addEdge(v, w) {
  if (!this.adjList.get(v)) {
    // 如果顶点 v 不存在,添加它
    this.addVertex(v)
  }
  if (!this.adjList.get(w)) {
    // 如果顶点 w 不存在,添加它
    this.addVertex(w)
  }
  this.adjList.get(v).push(w) // 将w加入v的邻接列表
  if (!this.isDirected) {
    this.adjList.get(w).push(v) // 如果是无向图,将v加入w的邻接列表
  }
}
4. 返回顶点列表和邻接表
// 返回顶点列表
getVertices() {
  return this.vertices;
}

// 返回邻接表
getAdjList() {
  return this.adjList;
}
5. toString方法
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
}

五、图的遍历

有两种算法对图进行遍历:广度优先搜索(breadth-first search,BFS)和深度优先搜索(depth-first search,DFS)。

图遍历算法的思想是必须追踪每个第一次访问的节点,并且追踪有哪些节点还没有被完全探索。完全探索一个顶点要求我们查看该顶点的每一条边。对于每一条边所连接的没有被访问过的顶点,将其标注为被发现的,并将其加进待访问顶点列表中。为了保证算法的效率,务必访问每个顶点至多两次。连通图中每条边和顶点都会被访问到。

深度优先搜索使用数据结构为结构,将顶点存入栈,顶点是沿着路径被探索的,存在新的相邻顶点就去访问。

广度优先搜索使用数据结构为队列,将顶点存入队列,最先入队列的顶点先被探索。

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

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

两种搜索方法都需要初始化颜色,代码如下:

// 定义颜色枚举
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
}

六、广度优先搜索

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

image.png

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

(1) 创建一个队列 Q。

(2) 标注 v 为被发现的(灰色),并将 v 入队列 Q。

(3) 如果 Q 非空,则运行以下步骤:

    (a) 将 u 从 Q 中出队列;

    (b) 标注 u 为被发现的(灰色);

    (c) 将 u 所有未被访问过的邻点(白色)入队列;

    (d) 标注 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)
    }
  }
}
1. 使用 BFS 寻找最短路径

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

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

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

改进后的代码为:

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
        distances[w] = distances[u] + 1  // 发现顶点 u 的邻点 w 时,  v 和 w 之间的距离加一
        predecessors[w] = u  // u 是 w 的前溯点
        queue.enqueue(w)
      }
    }
    color[u] = Colors.BLACK
  }
  return {
    distances,
    predecessors
  }
}

测试代码及结果:

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');
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 shortestPathA = BFS(graph, myVertices[0]); 
console.log(shortestPathA);

image.png

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

const fromVertex = myVertices[0]; // 源顶点 
for (let i = 1; i < myVertices.length; i++) { // 其他顶点
  const toVertex = myVertices[i]  // 获取顶点的值
  const path = new Stack() // 用栈来存储路径值

  // 追溯toVertex到fromVertex的路径,变量v被赋值为其前溯点的值,这样能够反向追溯这条路径
  for (let v = toVertex; v !== fromVertex; v = shortestPathA.predecessors[v]) {
    path.push(v) // 入栈
  }
  path.push(fromVertex)  // 最后加入源顶点
  let s = path.pop()  // 弹出源顶点 
  while (!path.isEmpty()) {  // 判空 
    s += ' - ' + path.pop()  // 出栈
  }
  console.log(s)
}

结果为

image.png

七、深度优先搜索

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

image.png

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

要访问顶点 v,步骤如下:

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

深度优先搜索的步骤是递归的,这意味着深度优先搜索算法使用栈来存储函数调用。

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) {
      // 顶点未被访问过,调用递归传入访问的顶点 u、颜色数组以及回调函数
      depthFirstSearchVisit(vertices[i], color, adjList, callback)
    }
  }
}

const depthFirstSearchVisit = (u, color, adjList, callback) => {
  color[u] = Colors.GREY  // 访问时置灰

  // 如果有回调函数,执行
  if (callback) {
    callback(u);
  }

  const neighbors = adjList.get(u)  // 邻点的列表

  // 顶点u的每个未被访问过的邻点 w,将递归调用函数,传递 w 和其他参数,添加顶点 w 入栈
  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 // 访问完成置黑
}

下图为执行过程:

image.png

1. 深度优先搜索遍历例子

使用深度优先搜索算法遍历图 G 的所有节点,构建“森林”(有根树的一个集合)以及一组源顶点(根),并输出两个数组:发现时间和完成探索时间。修改 depthFirstSearch 部分信息:

  • 顶点 u 的发现时间 d[u]
  • 当顶点 u 被标注为黑色时,u 的完成探索时间 f[u]
  • 顶点 u 的前溯点 p[u]
// 修改DFS算法
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 } // 追踪发现时间和完成探索时间

  // 对每个顶点初始化
  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
  }
}

const DFSVisit = (u, color, d, f, p, time, adjList) => {
  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) {
      p[w] = u;  // 当由引自顶点 u 的边而被发现的,追踪其前溯点
      DFSVisit(w, color, d, f, p, time, adjList)
    }
  }
  color[u] = Colors.BLACK
  f[u] = ++time.count // 顶点完成探索时,追踪其完成时间
}