数据结构 - 图

图是网络结构的抽象模型。图是一组由边连接的节点(或顶点),任何二元关系都可以用图来表示。

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

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

下图表示一个图:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-T6j4akhD-1584450013346)(C:\Users\lin\AppData\Roaming\Typora\typora-user-images\1584445277875.png)]

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

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

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

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

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

有向图和无向图

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-8QXMce3h-1584450013369)(C:\Users\lin\AppData\Roaming\Typora\typora-user-images\1584445540939.png)]

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

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-WSvmCLtu-1584450013372)(C:\Users\lin\AppData\Roaming\Typora\typora-user-images\1584445721483.png)]

我们可以使用图来解决计算机科学世界中的很多问题,比如搜索图中的一个特定顶点或搜索 一条特定边,寻找图中的一条路径(从一个顶点到另一个顶点),寻找两个顶点之间的最短路径, 以及环检测。

从数据结构的角度来说,我们有多种方式来表示图。在所有的表示法中,不存在绝对正确的 方式。图的正确表示法取决于待解决的问题和图的类型。

邻接矩阵

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-QqXWCYMf-1584450013376)(C:\Users\lin\AppData\Roaming\Typora\typora-user-images\1584445856364.png)]

不是强连通的图(稀疏图)如果用邻接矩阵来表示,则矩阵中将会有很多0,这意味着我们 浪费了计算机存储空间来表示根本不存在的边。例如,找给定顶点的相邻顶点,即使该顶点只有 一个相邻顶点,我们也不得不迭代一整行。邻接矩阵表示法不够好的另一个理由是,图中顶点的 数量可能会改变,而2维数组不太灵活。

邻接表

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-s1GHZrdI-1584450013383)(C:\Users\lin\AppData\Roaming\Typora\typora-user-images\1584445963798.png)]

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

关联矩阵

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-togZF3Vl-1584450013387)(C:\Users\lin\AppData\Roaming\Typora\typora-user-images\1584446742425.png)]

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

创建图类:

class Graph() {

    constructor() {
        this.vertices = []
        this.adjList = new Dictionary()
    }

    // 添加顶点
    addVertex(v) {
        this.vertices.push(v)
        this.adjList.set(v, [])
    }

    // 添加线
    addEdge(v, w) {
        this.adjList.get(v).push(w)
        this.adjList.get(w).push(v)
    }

    toString() {
        return this.vertices.reduce((r, v, i) => {
            return this.adjList.get(v).reduce((r, w, i) => {
                return r + `${w} `
            }, `${r}\n${v} => `)
        }, '')
    }
}

使用图类:

const graph = new Graph()

;['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I'].forEach(c => graph.addVertex(c))

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')

console.log(graph.toString())

// 输出
/*
A => B C D 
B => A E F 
C => A D G 
D => A C G H 
E => B I 
F => B 
G => C D 
H => D 
I => E 
*/

图的遍历

和树数据结构类似,我们可以访问图的所有节点。有两种算法可以对图进行遍历:

  • 广度优先搜索(Breadth-First Search,BFS)

  • 深度优先搜索(Depth-First Search,DFS)

图遍历可以用来寻找特定的顶点或寻找两个顶点之间的路径,检查图是否连通,检查图是否含有环等。

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

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

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

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

广度优先搜索算法和深度优先搜索算法基本上是相同的,只有一点不同,那就是待访问顶点 列表的数据结构。

  • 深度优先搜索:桟,通过将顶点存入桟中,顶点是沿着路径被探索的,存在新的相邻顶点就去访问

  • 广度优先搜索 :队列,通过将顶点存入队列中,最先入队列的顶点先被探索

广度优先搜索

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Rg8P6a7U-1584450013390)(C:\Users\lin\AppData\Roaming\Typora\typora-user-images\1584447758347.png)]

以下是我们的方法实现的。

维护两个队列,分别用于存储已读和待读顶点,两者具有互斥性,即某顶点在访问时只会属于一种类型,本质是通过不断递归将相邻的顶点进行访问和维度标为已读。

让我们来实现广度优先搜索算法:

// breadth first search
bfs(v, callback) {
    const read = []
    const adjList = this.adjList
    let pending = [v || this.vertices[0]]
    const readVertices = vertices => {
        vertices.forEach(key => {
            read.push(key)
            pending.shift()
            adjList.get(key).forEach(v => {
                if (!pending.includes(v) && !read.includes(v)) {
                    pending.push(v)
                }
            })
            if (callback) callback(key)
            if (pending.length) readVertices(pending)
        })
    }
    readVertices(pending)
}

让我们执行下面这段代码来测试一下这个算法:

graph.bfs(graph.vertices[0], value => console.log('Visited vertex: ' + value))

输出结果:

Visited vertex: A
Visited vertex: B
Visited vertex: C
Visited vertex: D
Visited vertex: E
Visited vertex: F
Visited vertex: G
Visited vertex: H
Visited vertex: I

使用BFS寻找最短路径

到目前为止,我们只展示了BFS算法的工作原理。我们可以用该算法做更多事情,而不只是输出被访问顶点的顺序。例如,考虑如何来解决下面这个问题。

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

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

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

让我们来看看改进过的广度优先方法的实现:

bfs(v, callback) {
    const read = []
    const distances = []
    const predecessors = []
    const adjList = this.adjList
    const pending = [v || this.vertices[0]]
    const readVertices = vertices => {
        vertices.forEach(key => {
            read.push(key)
            pending.shift()
            distances[key] = distances[key] || 0
            predecessors[key] = predecessors[key] || null
            adjList.get(key).forEach(v => {
                if (!pending.includes(v) && !read.includes(v)) {
                    pending.push(v)
                    distances[v] = distances[key] + 1
                    predecessors[v] = key
                }
            })
            if (callback) callback(key)
            if (pending.length) readVertices(pending)
        })
    }
    readVertices(pending)
    return { distances, predecessors }
}

输出结果:

distances: [A: 0, B: 1, C: 1, D: 1, E: 2, F: 2, G: 2, H: 2 ,工:3]
predecessors: [A: null, B: "A", C: "A", D: "A", E: "B", F: " B", G: " C", H: "D", I: "E"]

这意味着顶点A与顶点B、C和D的距离为1;与顶点E、F、G和H的距离为2;与顶点I的距离
通过前溯点数组,我们可以用下面这段代码来构建从顶点A到其他顶点的路径:

distance(fromVertex) {
    const vertices = this.vertices
    const { distances, predecessors } = this.bfs(fromVertex)
    vertices.forEach(toVertex => {
        if (!!distances[toVertex]) {
            let preVertex = predecessors[toVertex]
            let slug = ''
            while (fromVertex !== preVertex) {
                slug = `${preVertex} - ${slug}`
                preVertex = predecessors[preVertex]
            }
            slug = `${fromVertex} - ${slug}${toVertex}`
            console.log(slug)
        }
    })
}

执行该代码段,我们会得到如下输出:

graph.distance(graph.vertices[0])
// 输出如下:
// A - B
// A - C
// A - D
// A - B - E
// A - B - F
// A - C - G
// A - D - H
// A - B - E - I

这里,我们得到了从顶点A到图中其他顶点的最短路径(衡量标准是边的数量)。

深度优先搜索

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7R454VVM-1584450013395)(C:\Users\lin\AppData\Roaming\Typora\typora-user-images\1584449439749.png)]

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

深度优先搜索算法核心是递归,普通的对象递归模型即可满足需求,对比已读顶点是否已完全覆盖即可。

深度优先算法的实现:

// depth first search
dfs(callback) {
    const read = []
    const adjList = this.adjList
    const readVertices = vertices => {
        vertices.forEach(key => {
            if (read.includes(key)) return false
            read.push(key)
            if (callback) callback(key)
            if (read.length !== this.vertices.length) {
                readVertices(adjList.get(key))
            }
        })
    }
    readVertices(adjList.keys)
}

让我们执行下面的代码段来测试一下df s方法:

graph.dfs(value => console.log('Visited vertex: ' + value))

// 输出如下:
// Visited vertex: A 
// Visited vertex: B 
// Visited vertex: E 
// Visited vertex: I
// Visited vertex: F 
// Visited vertex: C 
// Visited vertex: D 
// Visited vertex: G 
// Visited vertex: H

下图展示了该算法每一步的执行过程:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-a7r3Rli3-1584450013401)(C:\Users\lin\AppData\Roaming\Typora\typora-user-images\1584449527034.png)]

探索深度优先算法

到目前为止,我们只是展示了深度优先搜索算法的工作原理。我们可以用该算法做更多的事 情,而不只是输出被访问顶点的顺序。

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

  • 顶点 u 的发现时间 d[u]
  • 当顶点 u 被标注为已读时,u 的完成探索时间
  • 顶点 u 的前溯点 p[u]

让我们来看看改进了的 DFS 方法的实现:

// depth first search
dfs(callback) {
    let readTimer = 0
    const read = []
    const readTimes = []
    const finishedTimes = []
    const predecessors = []
    const adjList = this.adjList
    const readVertices = (vertices, predecessor) => {
        vertices.forEach(key => {
            readTimer++
            if (adjList.get(key).every(v => read.includes(v)) && !finishedTimes[key]) {
                finishedTimes[key] = readTimer
            }
            if (read.includes(key)) return false
            readTimes[key] = readTimer
            read.push(key)
            if (callback) callback(key)
            predecessors[key] = predecessors[key] || predecessor || null
            if (read.length !== this.vertices.length) {
                readVertices(adjList.get(key), key)
            }
        })
    }
    readVertices(adjList.keys)
    return { readTimes, finishedTimes, predecessors }
}

深度优先算法背后的思想是什么?边是从最近发现的顶点 u 处被向外探索的。只有连接到未发现的顶点的边才会探索。当 u 所有的边都被探索了,该算法回退到 u 被发现的地方去探索其他的边。这个过程持续到我们发现了所有从原始顶点能够触及的顶点。如果还留有任何其他未被发现的顶点,我们对新源顶点重复这个过程,直到图中所有的顶点都被探索了。

对于改进过的深度优先搜索,有两点需要我们注意:

  • 时间(time)变量值的范围只可能在图顶点数量的一倍到两倍之间
  • 对于所有的顶点 u,d[u] < f[u] 意味着,发现时间的值比完成时间的值小,完成时所有顶点都已经被探索过了

学习于:JavaScript中的数据结构与算法学习

  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值