原文连接:Graph Data Structures for Beginnersnode
众成翻译地址:初学者应该了解的数据结构: Graphgit
系列文章,建议不了解图的同窗慢慢阅读一下这篇文章,但愿对你有所帮助~若是想深刻理解图,那不建议阅读这篇基础文章,这里有更多深刻的知识能够探索~如下是译文正文:程序员
在这篇文章中,咱们将要探索非线性的数据结构:图,将涵盖它的基本概念及其典型的应用。github
你极可能在不一样的应用中接触到图(或树)。好比你想知道从家出发怎么去公司最近,就能够利用图的(寻路)算法来获得答案!咱们将探讨上述场景与其余有趣的状况。算法
在上一篇文章中,咱们探讨了线性的数据结构,如数组、链表、集合、栈等。本文将以此(译者注:即线性数据结构,没看过前文也不要紧,其实也很好懂)为基础。数组
本篇是如下教程的一部分(译者注:若是你们以为还不错,我会翻译整个系列的文章):服务器
初学者应该了解的数据结构与算法(DSA)网络
如下是本文对图操做的小结:数据结构
邻接表 | 邻接矩阵 | |
---|---|---|
空间复杂度 | O(|V|+ |E|) | O(|V|²) |
添加顶点 | O(1) | O(|V|²) |
移除顶点 | O(|V| + |E|) | O(|V|)² |
添加边 | O(1) | O(1) |
移除边 (基于 Array 实现) | O(|E|) | O(1) |
移除边 (基于 HashSet 实现) | O(1) | O(1 |
获取相邻的顶点 | O(|E|) | O(|V|) |
判断是否相邻 (基于 Array 实现) | O(|E|) | O(1) |
判断是否相邻 (基于 HashSet 实现) | O(1) | O(1) |
图是一种(包含若干个节点),每一个节点能够链接 0 个或多个元素app
两个节点相连的部分称为边(edge)。节点也被称做顶点(vertice)。
一个顶点的**度(degree)**是指与该顶点相连的边的条数。好比上图中,紫色顶点的度是 3,蓝色顶点的度是 1。
若是全部的边都是双向(译者注:或者理解为没有方向)的,那咱们就有了一个无向图(undirected graph)。反之若是边是有向的,咱们获得的就是有向图(directed graph)。你能够将有向图和无向图想象为单行道或双行道组成的交通网。
顶点的边能够是从本身出发再链接回本身(如蓝色的顶点),拥有这样的边的图被称为自环。
图能够有环(cycle),即若是遍历图的顶点,某个顶点能够被访问超过一次。而没有环的图被称为无环图(acyclic graph)。
此外,无环无向图也被称为树(tree)。在下篇文章中,咱们将深刻套路这种数据结构。
在图中,从一个顶点出发,并不是全部顶点都是可到达的。可能会存在孤立的顶点或者是相分离的子图。若是一个图全部顶点都至少有一条边(译者注:原文表述有点奇怪,我的认为不该该是至少有一条边,而是从任一节点出发,沿着各条边能够访问图中任意节点),这样的图被称为连通图(connected graph)。而当一个图中两两不一样的顶点之间都恰有一条边相连,这样的图就是彻底图(complete graph)。
对于彻底图而言,每一个顶点都有 图的顶点数 - 1 条边。在上面彻底图的例子中,一共有7个顶点,所以每一个顶点有6条边。
当图的每条边都被分配了权重时,咱们就有了一个加权图(weighted graph)。若是边的权重被忽略,那么能够将(每条边的)权重都视为 1(译者注:权重都是同样,也就是无权重)。
加权图应用的场景不少,根据待解决问题主体的不一样,有不一样的展示。一块儿来看一些具体的场景吧:
航空线路图 (如上图所示)
GPS 导航
网络
通常而言, 图在现实世界中的应用有:
咱们学习了图的基础以及它的一些应用场景。接下来一块儿学习怎么使用代码来表示图。
图的表示有两种主要方式:
让咱们以有向图为例子,阐述这两种表示方式:
这是一个拥有四个顶点的图。当一个顶点有一条边指向它自身时(译者注:即闭合的路径),称之为自环(self-loop)。
邻接矩阵使用二维数组(N x N)来表示图。如若不一样顶点存在链接的边,就赋值两顶点交汇处为1(也能够是这条边的权重),反之赋值为 0 或者 -。
咱们能够经过创建如下的邻接矩阵,来表示上面的图:
a b c d e
a 1 1 - - -
b - - 1 - -
c - - - 1 -
d - 1 1 - -
复制代码
如你所见,矩阵水平与垂直两个方向都列出了全部的顶点。若是图中只有不多顶点互相链接,那么这个图就是稀疏图(sparse graph)。若是图相连的顶点不少(接近两两顶点都相连)的话,咱们称这种图为稠密图(dense graph)。而若是图的每一个顶点都直接链接到除此以外的全部顶点,那就是一个彻底图(complete graph)。
注意,你必须意识到对于无向图而言,邻接矩阵始终是对角线对称的。然而,对于有向图而言,并不是老是如此(反例如上面的有向图)。
那查询两个顶点是否相邻的时间复杂度是什么呢?
在邻接矩阵中,查询两个顶点是否相邻的时间复杂度是 O(1)。
那空间复杂度呢?
利用邻接矩阵存储一个图,空间复杂度是 O(n²),n 为顶点的数量,所以也能够表示为 O(|V|²)。
添加一个顶点的时间复杂度呢?
邻接矩阵根据顶点的数量存储为 V x V
的矩阵。所以每增长一个顶点,矩阵须要重建为 V+1 x V+1
的新矩阵。
(所以,)在邻接矩阵中添加一个顶点的时间复杂度是 O(|V|²)。
如何获取相邻的顶点?
因为邻接矩阵是一个 V x V
的矩阵,为了获取全部相邻的顶点,咱们必须去到该顶点所在的行中,查询它与其余顶点是否有边。
以上面的邻接矩阵为例,假设咱们想知道与顶点 b
相邻的顶点有哪些,就须要到达记录 b
与其余节点关系的那一行中进行查询。
a b c d e
b - - 1 - -
复制代码
访问它与其余全部顶点的关系,所以:
在邻接矩阵中,查询相邻顶点的时间复杂度是 O(|V|)。
想象一下,若是你须要将 FaceBook 中人们的关系网表示为一个图。你必须创建一个 20亿 x 20亿
的邻接矩阵,而该矩阵中不少位置都是空的。没有任何人可能认识其余全部人,最多也就认识几千我的。
一般,咱们使用邻接矩阵处理稀疏图时,会浪费不少空间。这就是大多时候使用邻接表而不是邻接矩阵去表示一个图的缘由(译者注:邻接矩阵也有优点的,尤为是表示有向稠密图时,比邻接表要方便得多)。
表示一个图,最经常使用的方式是邻接表。每一个顶点都有一个记录着与它所相邻顶点的表。
可使用一个数组或者 HashMap 来创建一个邻接表,它存储这全部的顶点。每一个顶点都有一个列表(能够是数组、链表、集合等数据结构),存放着与其相邻的顶点。
例如上面的图,对于顶点 a,与之相邻的有顶点 b,同时也是自环;而顶点 b 则有指向顶点 c 的边,如此类推:
a -> { a b }
b -> { c }
c -> { d }
d -> { b c }
复制代码
和想象中的同样,若是想知道一个顶点是否链接着其余顶点,就必须遍历(顶点的)整个列表。
在邻接表中查询两个顶点是否相连的时间复杂度是 O(n),n 为顶点的数量,所以也能够表示为 O(|V|)。
那空间复杂度呢?
利用邻接表存储一个图的空间复杂度是 O(n),n 为顶点数量与边数量之和,所以也能够表示为 O(|V| + |E|)。
要表示一个图,最多见的方式是使用邻接表。有几种实现邻接表的方式:
最简单的实现方式之一是使用 HashMap。HashMap 的键是顶点的值,HashMap 的值是一个邻接数组(即也该顶点相邻顶点的集合)。
const graph = {
a:[ 'a','b' ],
b:[ 'c' ],
c:[ 'd' ],
d:[ 'b','c' ]
};
复制代码
图一般须要实现如下两种操做:
添加或删除一个顶点须要更新邻接表。
假设须要删除顶点 b。咱们不但须要 delete graph['b']
,还须要删除顶点 a 与顶点 d 的邻接数组中的引用。
每当移除一个顶点,都须要遍历整个邻接表,所以时间复杂度是 O(|V| + |E|)。有更好的实现方式吗?稍后再回答这问题。首先让咱们以更面向对象的方式实现邻接表,以后切换(邻接表的底层)实现将更容易。
先从顶点的类开始,在该类中,除了保存顶点自身以及它的相邻顶点集合以外,还会编写一些方法,用于在邻接表中增长或删除相邻的顶点。
class Node {
constructor(value) {
this.value = value;
this.adjacents = []; // adjacency list
}
addAdjacent(node) {
this.adjacents.push(node);
}
removeAdjacent(node) {
const index = this.adjacents.indexOf(node);
if (index > -1) {
this.adjacents.splice(index, 1);
return node;
}
}
getAdjacents() {
return this.adjacents;
}
isAdjacent(node) {
return this.adjacents.indexOf(node) > -1;
}
}
复制代码
注意,addAdjacent
方法的时间复杂度是 O(1),但删除相邻顶点的函数时间复杂度是 O(|E|)。若是不使用数组而是用 HashSet 会怎样呢?(删除相邻顶点的)时间复杂度会降低到 O(1)。但如今先让代码能跑起来,以后再作优化。
Make it work. Make it right. Make it faster.
如今有了 Node
类,是时候编写 Graph
类,它能够执行添加或删除顶点和边。
Graph.constructor
class Graph {
constructor(edgeDirection = Graph.DIRECTED) {
this.nodes = new Map();
this.edgeDirection = edgeDirection;
}
// ...
}
Graph.UNDIRECTED = Symbol('directed graph'); // one-way edges
Graph.DIRECTED = Symbol('undirected graph'); // two-ways edges
复制代码
首先,咱们须要确认图是有向仍是无向的,当添加边时,这会有所不一样。
Graph.addEdge
添加一条新的边,须要知道两个顶点:边的起点与边的终点。
addEdge(source, destination) {
const sourceNode = this.addVertex(source);
const destinationNode = this.addVertex(destination);
sourceNode.addAdjacent(destinationNode);
if(this.edgeDirection === Graph.UNDIRECTED) {
destinationNode.addAdjacent(sourceNode);
}
return [sourceNode, destinationNode];
}
复制代码
咱们往边的起点添加了一个相邻顶点(即边的终点)。若是该图是无向图,也须要往边的终点添加一个相邻顶点(即边的起点),由于(无向图中)边是双向的。
在邻接表中新增一条边的时间复杂度是:O(1)。
若是新添加的边两端的顶点并不存在,就必需先建立(不存在的顶底),下面让咱们来实现它!
Graph.addVertex
建立顶点的方式是往 this.nodes
中新增一个顶点。this.nodes
中存储着的是一组组键值对,键是顶点的值,值是 Node
类的实例。注意看下面代码的 5-6 行(即 const vertex = new Node(value); this.nodes.set(value, vertex);
):
addVertex(value) {
if(this.nodes.has(value)) {
return this.nodes.get(value);
} else {
const vertex = new Node(value);
this.nodes.set(value, vertex);
return vertex;
}
}
复制代码
不必覆写已存在的顶点。所以先检查一下顶点是否存在,若是不存在才创造一个新节点。
在邻接表中新增一个顶点的时间复杂度是: O(1)。
Graph.removeVertex
从一个图中删除一个顶点会相对麻烦一点。咱们必须检查待删除的顶点是否为其余顶点的相邻顶点。
removeVertex(value) {
const current = this.nodes.get(value);
if(current) {
for (const node of this.nodes.values()) {
node.removeAdjacent(current);
}
}
return this.nodes.delete(value);
}
复制代码
必须访问每一个顶点及其它们的相邻顶点集合。
在邻接表中删除一个顶点的时间复杂度是: O(|V| + |E|)。
最后,一块儿来实现删除一条边吧!
Graph.removeEdge
删除一条边是十分简单的,与新增一条边相似。
removeEdge(source, destination) {
const sourceNode = this.nodes.get(source);
const destinationNode = this.nodes.get(destination);
if(sourceNode && destinationNode) {
sourceNode.removeAdjacent(destinationNode);
if(this.edgeDirection === Graph.UNDIRECTED) {
destinationNode.removeAdjacent(sourceNode);
}
}
return [sourceNode, destinationNode];
}
复制代码
删除与新增一条边主要的不一样是:
Node.removeAdjacent
而不是 Node.addAdjacent
。因为 removeAdjacent
须要遍历相邻节点的集合,所以它的运行时是:
在邻接表中删除一条边的时间复杂度是: O(|E|)。
接下来,咱们将讨论如何从图中搜索。
广度优先搜索是一种从最初的顶点开始,优先访问全部相邻顶点的搜索方法。
接下来一块儿看看如何用代码来实现它:
*bfs(first) {
const visited = new Map();
const visitList = new Queue();
visitList.add(first);
while(!visitList.isEmpty()) {
const node = visitList.remove();
if(node && !visited.has(node)) {
yield node;
visited.set(node);
node.getAdjacents().forEach(adj => visitList.add(adj));
}
}
}
复制代码
正如你所见的同样,咱们使用了一个队列来暂存待访问的顶点,队列遵循先进先出(FIFO)的原则。
同时也是用了 JavaScript generators,要注意函数名前面 *
(,那是生成器的标志)。经过生成器,能够一次迭代一个值(即顶点)。对于巨型(包含数以百万计的顶点)的图而言是颇有用的,不少状况下不用访问图的每个顶点。
如下是如何使用上述 BFS 代码的示例:
const graph = new Graph(Graph.UNDIRECTED);
const [first] = graph.addEdge(1, 2);
graph.addEdge(1, 3);
graph.addEdge(1, 4);
graph.addEdge(5, 2);
graph.addEdge(6, 3);
graph.addEdge(7, 3);
graph.addEdge(8, 4);
graph.addEdge(9, 5);
graph.addEdge(10, 6);
bfsFromFirst = graph.bfs(first);
bfsFromFirst.next().value.value; // 1
bfsFromFirst.next().value.value; // 2
bfsFromFirst.next().value.value; // 3
bfsFromFirst.next().value.value; // 4
// ...
复制代码
你能够在这找到更多的测试代码。
接下来该讲述深度优先搜索了!
深度优先搜索是图的另外一种搜索方法,经过递归搜索顶点的首个相邻顶点,再搜索其余相邻顶点,从而访问全部的顶点。
DFS 的实现近似于 BFS,但使用的是栈而不是队列:
*dfs(first) {
const visited = new Map();
const visitList = new Stack();
visitList.add(first);
while(!visitList.isEmpty()) {
const node = visitList.remove();
if(node && !visited.has(node)) {
yield node;
visited.set(node);
node.getAdjacents().forEach(adj => visitList.add(adj));
}
}
}
复制代码
测试例子以下:
const graph = new Graph(Graph.UNDIRECTED);
const [first] = graph.addEdge(1, 2);
graph.addEdge(1, 3);
graph.addEdge(1, 4);
graph.addEdge(5, 2);
graph.addEdge(6, 3);
graph.addEdge(7, 3);
graph.addEdge(8, 4);
graph.addEdge(9, 5);
graph.addEdge(10, 6);
dfsFromFirst = graph.dfs(first);
visitedOrder = Array.from(dfsFromFirst);
const values = visitedOrder.map(node => node.value);
console.log(values); // [1, 4, 8, 3, 7, 6, 10, 2, 5, 9]
复制代码
正如你所看到的,BFS 与 DFS 所用的图(的数据)是同样的,然而访问顶点的顺序却很是不同。BFS 是从 1 到 10 按顺序输出,DFS 则是先进入最深处访问顶点(译者注:其实这个例子是先序遍历,看起来可能不太像先深刻最深处)。
咱们接触了图的一些基础操做,如何添加和删除一个顶点或一条边,如下是前文涵盖内容的小结:
邻接表 | 邻接矩阵 | |
---|---|---|
空间复杂度 | O(|V|+ |E|) | O(|V|²) |
添加顶点 | O(1) | O(|V|²) |
移除顶点 | O(|V| + |E|) | O(|V|)² |
添加边 | O(1) | O(1) |
移除边 (基于 Array 实现) | O(|E|) | O(1) |
移除边 (基于 HashSet 实现) | O(1) | O(1 |
获取相邻的顶点 | O(|E|) | O(|V|) |
判断是否相邻 (基于 Array 实现) | O(|E|) | O(1) |
判断是否相邻 (基于 HashSet 实现) | O(1) | O(1) |
正如上表所示,邻接表中几乎全部的操做方法都是更快的。邻接矩阵比邻接表性能更高的方法只有一处:检查顶点是否与其余顶点相邻,然而使用 HashSet 而不是 Array 实现邻接表的话,也能在恒定时间内获取结果 :)
图能够是不少现实场景的抽象,如机场,社交网络,互联网等。咱们介绍了一些图的基础算法,如广度优先搜索(BFS)与深度优先搜索(DFS)等。同时权衡了图的不一样实现方式:邻接矩阵和邻接表。咱们将在另一篇文章(更深刻地)介绍图的其余应用,如查找图的两个顶点间的最短距离及其余有趣的算法(译者注:这篇文章介绍的比较基础,图的各类算法才是最有趣的,有兴趣的同窗能够看这个)。