JavaScript数据结构之图

210 阅读5分钟

JavaScript数据结构之图

图的相关术语

下图表示一个图:

1.png

  • 相邻顶点 : 由一条边连接在一起的顶点称为相邻顶点。比如,A和B是相邻的,A和D是相邻的,A和C是相邻的,A和E不是相邻的。
  • : 一个顶点的度是其相邻顶点的数量。比如,A和其他三个顶点相连接,因此A的度为3; E和其他两个顶点相连,因此E的度为2。
  • 路径 : 路径是顶点v 1, v2,…, vk的一个连续序列,其中vi和vi+1是相邻的。以上一示意图中的图为例,其中包含路径A B E I和A C D G。
  • 简单路径 : 简单路径要求不包含重复的顶点。举个例子,A D G是一条简单路径。除去最后一个顶点(因为它和第一个顶点是同一个顶点),环也是一个简单路径,比如A D C A(最后一个顶点重新回到A)。
  • 无环|连通 : 如果图中不存在环,则称该图是无环的。如果图中每两个顶点间都存在路径,则该图是连通的。
  • 有向图和无向图 : 图可以是无向的(边没有方向)或是有向的(有向图)。如下图所示,有向图的边有一个方向。

2.png

  • 强连通 :如果图中每两个顶点间在双向上都存在路径,则该图是强连通的。例如,C和D是强连通的,而A和B不是强连通的。
  • : 图还可以是未加权的(目前为止我们看到的图都是未加权的)或是加权的。如下图所示,加权图的边被赋予了权值。

3.png

图的表示方式

  1. 邻接矩阵
  2. 邻接表
  3. 关联矩阵

创建Graph类

Class Graph{
    constructor(isDirected = false) {
        this.isDirected = isDirected // 是否有向
        this.vertices = [] // 顶点列表
        this.adjList = new Map() // 用 Map 对象保存邻接表
    }
    // 添加顶点
    addVertex(v) {
        if(!this.vertices.includes(v)) {
            this.vertices.push(v)
            this.adjList.set(v,[])
        }
    }
    // 连接顶点
    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的相邻顶点中
        }
    }
    // 获取顶点
    getVertices() {
        return this.vertices
    }
    // 获取顶点邻接表
    getAdjList() {
        return this.adjList
    }
    // toString 返回图
    // A -> B C D
    // B -> A C
    // C -> A B
    // D -> A
    toString() {
        let s = ''
        for(let i = 0; i < this.vertices.length; i++) {
            s = s + `${this.vertices[i]} ->`
            this.adjList.get(this.vertices[i]).map(vertex => {
                s = s + ` ${vertex}`
            })
            s = s + '\n'
        }
        return s
    }
}

图的遍历

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

在实现算法之前,让我们来更好地理解一下图遍历的思想。

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

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

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

广度优先遍历

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

4.png

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

  1. 创建一个队列Q。
  2. 标注v为被发现的(灰色),并将v入队列Q。
  3. 如果Q非空,则运行以下步骤:
    • 将u从Q中出队列;
    • 标注u为被发现的(灰色);
    • 将u所有未被访问过的邻点(白色)入队列;
    • 标注u为已被探索的(黑色)。
// 模拟枚举颜色
const Colors = {
    WHITE: 0,
    GREY: 1,
    BLACK: 2
}
// 初始化顶点颜色
const initializeColor = (vertices) => {
    let colors = {}
    vertices.map(v => {
        colors[v] = Colors.WHITE
    })
    return colors
}
const breadthFirstSearch = (graph,startVertex,cb) => {
    const vertices = graph.getVertices()
    const adjList = graph.getAdjList()
    
    const colors = initializeColor(vertices) // 初始化顶点颜色,使用colors对象保存映射
    const queue = new Queue()
    
    colors[startVertex] = Colors.GREY // 标记起点颜色
    queue.enqueue(startVertex)// 加入队列开始访问

    while(!enqueue.isEmpty()) {
        let u = queue.dequeue() // 队列首节点出队,进行探索
        let neighbors = adjList.get(u)
        neighbors.map(v => { // 检查队列首元素相邻边
            if(colors[v] === Colors.WHITE) { // 如果未被访问 则入队
                colors[v] = Colors.GREY
                queue.enqueue(v)
            }
        })
        colors[u] = Colors.BLACK // 探索完毕,修改顶点为黑色
        cb && cb(u) // 执行回调,操作节点
    }
}

以上是通过广度优先算法遍历节点,但是通过这个思想,我们可以处理很多问题,如:计算两点间的距离,计算两点间的最短路径,下面来实现一下。

通过BFS算法记录两点间距离

const BFS = (graph,startVertex) => {
    const vertices = graph.getVertices()
    const adjList = graph.getAdjList()
    const distances = {} // 保存每个节点与起始节点的最短距离
    const predecessors = {} // 保存每个节点的溯节点
    const queue = new Queue()

    const colors = initializeColor(vertices) // 初始化顶点颜色,使用colors对象保存映射
    vertices.map(v => { // init distances | predecessors
        distances[v] = 0
        predecessors[v] = null
    })
    colors[startVertex] = Colors.GREY // 标记起点颜色
    queue.enqueue(startVertex)// 加入队列开始访问
    while(!queue.isEmpty()) {
        let u = queue.dequeue()
        let neighbors = adjList.get(u)
        nerghbors.map(v => {
            if(colors[v] === Colors.WHITE) {
                colors[vertex] = Colors.GREY
                distances[vertex] = distances[u] + 1
                predecessors[vertex] = u
                queue.enqueue(vertex)
            }
        })
        colors[u] = Colors.BLACK // 探索完毕,修改顶点为黑色
    }
    return {
        distances,
        predecessors
    }
}

在上述的方法中,我们可以通过BFS返回的 distancespredecessors 对象我们可以拿到每个顶点距起点的距离和前溯节点。

通过这两个对象,我们还可以得出节点之间的具体最短路径,我们来实现一下。

const getShortestPath = (graph,v,w) => {
  let result = BFS(graph,v)
  let shortestDistance = result.distances[w]
  let final = w
  let shortestPath = `${final}`
  for(let i = 0; i < shortestDistance; i++) {
    final = result.predecessors[final]
    shortestPath = `${final} -> ` + shortestPath
  }
  return shortestPath
}

深度优先遍历

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

5.png

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

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

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

如你所见,深度优先搜索的步骤是递归的,这意味着深度优先搜索算法使用栈来存储函数调用(由递归调用所创建的栈)。

算法实现如下:

const depthFirstSearchVisit = (vertex,colors,adjList,cb) => {
    if(colors[vertex] === Colors.WHITE) {
        colors[vertex] = GREY
        cb && cb(vertex)
        let neighbors = adjList.get(vertex)
        neighbors.map(v => {
            depthFirstSearchVisit(v,colors,adjList,cb)
        })
        colors[vertex] = Colors.BLACK
    }
}

const depthFirstSearch = (graph,cb) => {
    const vertices = graph.getVertices()
    const adjList = graph.getAdjList()
    const colors = initializeColor(vertices)

    vertices.map(vertex => {
        depthFirstSearchVisit(vertex,colors,adjList,cb)
    })
}

以上是深度优先遍历算法的实现,在此基础上,我们可以解决很多问。

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

通过图G,构建森林

const DFS = graph => {
  const vertices = graph.getVertices()
  const adjList = graph.getAdjList()
  const colors = initializeColor(vertices)
  const d = {}
  const f = {}
  const p = {}
  const time = {count: 0}
  vertices.map(v => {
    f[v] = 0
    d[v] = 0
    p[v] = null
  })
  vertices.map(vertex => {
    if(colors[vertex] === Colors.WHITE) {
      DFSVisit(vertex,colors,adjList,d,f,p,time)
    }
  })
  return {
    discovery: d,
    finished: f,
    predecessors: p
  }
}

const DFSVisit = (vertex,colors,adjList,d,f,p,time) => {
    colors[vertex] = Colors.GREY
    d[vertex] = ++time.count
    let neighbors = adjList.get(vertex)
    for(let i = 0; i < neighbors.length; i++) {
      if(colors[neighbors[i]] === Colors.WHITE) {
        p[neighbors[i]] = vertex
        DFSVisit(neighbors[i],colors,adjList,d,f,p,time)
      }
    }
    colors[vertex] = Colors.BLACK
    f[vertex] = ++time.count
}

通过上述 DFS 算法得到的信息,我们可以完成拓扑排序。

拓扑排序

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

6.png

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

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

拓扑排序只能应用于DAG。下面我们来实现一下拓扑排序。

const graph = new Graph(true)
const vertices = ["A","B","C","D","E","F"]
vertices.map(v => {
  graph.addVertex(v)
})
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)
const finishedTimes = result.finished
let s = ''
// 根据结束的时间,从大到小找出完成顺序
for(let i = 0; i < vertices.length; i++) {
  let maxTime = 0;
  let maxVertex = null
  for(let j = 0; j < Object.values(finishedTimes).length; j++) {
    if(Object.values(finishedTimes)[j] > maxTime) {
      maxTime = Object.values(finishedTimes)[j]
      maxVertex = Object.keys(finishedTimes)[j]
    }
  }
  s = s + ' -> ' + maxVertex
  delete finishedTimes[maxVertex]
}
console.log(s); //  B -> A -> D -> C -> F -> E

执行了上述代码,我们可以得到拓扑排序结果:

B -> A -> D -> C -> F -> E  (排序结果不唯一)