【翻译】图 | 掘金技术征文-双节特别篇

344 阅读19分钟

第九章 图

书籍出处: 《Learning JavaScript Data Structures and Algorithm》

作者: Loiane Groner

在这一章,我们要学习另一个非线性的数据结构。这也是我们讲解排序和搜索算法前的最后一个数据结构。

图的术语

图是对网络结构的抽象。图是由一系列被边连接的节点(顶点)组成的。学习树是非常重要的,因为任何二元关系都可以用图来表示。

我们可以用图来展示路线、航班和通讯,如下图所示:

让我们看看图的数学概念:

下图展示了一个图:

在介绍图的算法前,我们先介绍一下图的术语

由一个边连接的两个节点,可以称为领接节点。比如说,节点A和节点B是领接节点,节点A和节点C是领接节点,节点A和节点D是领接节点,而节点A和节点E不是领接节点。

用来衡量一个节点有几个领接节点。比如说,节点A有三个领接节点,所以其度为3;节点E有两个领接节点,所以其度为2。

路径是由一系列领接节点组成的。以刚刚的图为例,我们可以知道ABEI是一个路径,ACDA也是一个路径。

一个简单路径内部是不存在重复的节点的。以路径ACD为例,就是一个简单路径。而的特点是,除了第一个和最后一个节点是一样的,其他节点都不重复。

有向图与无向图

图可以被分为无向图(其边没有方向)和有向图(其边有方向),有向图如下图所示:

如果图中有两个节点之间互相连接,我们称之为强连接。比如,节点C和节点D之间有强连接,而节点A和节点B之间就没有强连接。

图还可以被分为无权重的图(刚刚的例子都是无权重图),和有权重的图(其边有权重),有权重图如下所示:

在计算机科学中,我们可以使用图来解决很多问题,比如寻找一个节点、寻找一个边、寻找一条路径、找到最低路径,以及侦测环结构。

表示一个图的方法

表示图的方法有几种。在这几种方法中,并不存在唯一正确的表示方法。使用什么表示方法,取决于我们要解决的问题,以及图本身的特点。

邻接矩阵

最常使用的表示方法,就是邻接矩阵。每一个节点,都有一个关联的整数(这个整数是数组索引)。我们用一个二维数组来表示节点间的连接。例如,array[i][j]===1表示节点i和节点j之间有一个边,而array[i][j]===0表示节点i和节点j之间没有连接,如下图所示:

如果一个图中的节点的连接不是很紧密(稀疏图),那么它在邻接矩阵上将有很多的值为0。这也意味着,我们要浪费计算机内存来储存并不存在的边。使用这个表示法有两个麻烦:第一,如果我们要在只有一对领接节点的图中找到这对节点,那么还是要把整个二维数组遍历一次,即便图中只有一对领接节点;第二,在二维数组中改变节点的个数是非常麻烦的。

邻接列表

我们也可用动态数据结构来表示图,也就是邻接列表。领结列表的纵轴为每个节点,横轴为对应节点的领接节点。下图为邻接列表的图示:

邻接矩阵和邻接列表各有特色,其中邻接列表可以更快地判断两个节点是否为领接节点。本书接下来将使用邻接列表来表示图结构。

关联矩阵

我们也可以用关联矩阵来表示一个图。在关联矩阵中,每一行代表一个节点,每一列代表一个边。我们可以用二维数组来表示两个节点之间的联系。如array[a][e]===1代表节点a和节点e之间有一个边,而array[a][e]===0代表节点a和节点e之间没有边,如下图所示:

一般情况下,在边比节点多的时候,我们会采用关联矩阵,因为这样可以节省空间和内存。

生成一个图

首先,我们先生成图的骨架:

function Graph(){
	var vertices = []; // {1}
    var adjList = new Dictionary(); // {2}
}

我们讲使用一个数组来储存链图里每一个成员的名字(行 {1}),并使用在第七章实现的字典来储存节点的邻接节列表(行 {2})。在行 {2}的字典中,节点的名字为字典的key,而节点的邻接列表为字典的value。当然了,vertices和ajList都是图的私有特征。

接下来,我们要实现两个方法:给图增加一个节点(因为我们在初始化一个图时,其节点为空),和在两个节点之间加一个边。现在,让我来实现addVertex方法:

this.addVertex = function(v){
  vertices.push(v); // {3}
  adjList.set(v,[]); // {4}
}

这个方法以v为参数。我们会把v参数加入到vertices中(行 {3}),并给新加入的节点一个空的邻接列表(行 {4})。

接下来,让我们实现addEdge方法:

this.addEdge = function(v,w){
   adjList.get(v).push(w);// {5}
   adjList.get(w).push(v);// {6}
}

这个方法以两个节点为参数。首先,我们通过在v的adjList中加入w,来给节点v添加一个到w的边(行 {5})。如果我们要实现的是有向图,程序到行 {5}就够了。而我们要实现的是无向图,所以还要加入行 {6}的代码。

现在,我们在图中加入节点:

var graph = new Graph();
var myVertices = ['A','B','C','D','E','F','G','H','I']; //{7}
for (var i = 0; i<myVertices.length;i++){ //{8}
	graph.addVertex(myVertices[i]);
}
graph.addEdge('A','B');
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');

为了简化操作,我们生成了一个包含要添加的节点的数组(行 {7})。之后,我们遍历该数组,讲数组中所有的成员添加到图中。(行 {8})。最后,我们给相应的节点加上边(行 {9})。之后,这些代码就可以生成一个无向图了。

为了让操作更加简化,让我们为吐添加toString方法,以至于我们可以在控制台看到整个图:

this.toString = function(){
	var s ='';
    for (var i = 0;i< vertices.length;i++){ //{10}
    	s += vertices[i] + '>';
        var neighbors  = adjList.get(vertices[i]); //{11}
        for (var j = 0;j<neighbors.length;j++){ //{12}
        	s += neighbors[j] + ' ';
        }
        s += '\n'; //{13}
    }
    return s;
};

我们将使用字符变量s来接收链表的成员。首先,我们要遍历vertices数组(行 {10}),之后将节点添加到s中。之后,我们要得到节点的邻接列表(行 {11}),并遍历这个邻接列表、将列表里的成员添加到s中(行 {12})。遍历完内部的邻接列表后,我们要进入到下一行(行 {13})。这样,我们就可以得到图的输出结果了。让我执行一下看看:

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 -> F

这样,我们就完成了一个了邻接列表了。从输出结果可知,节点A的临近节点为B、C和D。

图遍历

与树结构类似,我们可以访问一个图中所有的节点。有两种方法可以遍历一个图:广度优先搜索深度优先搜索。当我们要寻找一个节点、寻找两个节点间的边或者确认图中是否有环时,我们会使用到图遍历。

在我们进入遍历算法之前,我们先进一步理解图的遍历。

图遍历的意识是,我们必须追踪图中的每一个节点,并遍历还没有被访问的节点。在这两种遍历算法中,我们需要明晰接下来要被访问的第一个节点是哪一个。

为了彻底的遍历一个节点,我们需要查看节点的每一个边。当沿着该节点发现未被访问的节点时,我们会把这个节点标记为被发现,并把这个节点加入到被访的节点的列表中。

BFS(广度优先搜索)和DFS(深度优先搜索)搜索大体上是相似的。它们的不同点在于,用于存储被访问节点的数据不一样:

算法使用的数据结构相关特点
深度优先搜索xxxx
广度优先搜索队列xxxx

在标记我们已经访问过的节点时,我们会用三种不同的颜色来表示他们的状态:

  • 白色:表示该节点未被访问
  • 灰色:表示该节点被访问,但是临接节点未被遍历
  • 黑色:表示该节点被探索,其临接节点已经全部被遍历

广度优先搜索

广度优先搜索会优先访问特定节点的所有邻接节点,也就是依次访问每一层。换言之,广度优先搜索是先注重广度,再注重深度,如下图所示:

从v节点开始的广度优先搜索,会按照以下几个步骤执行:

1.生成队列Q
2.把v节点标记为被发现(灰色),并把v节点推入队列Q3.当队列不为空,执行以下步骤:
	1.将节点u从队列Q中推出
    2.将节点u标记为被发现(灰色)
    3.将节点u的所有未标记节点w(白色)加入队列Q
    4.将节点u标记为已经被探索(黑色)

现在我们来实现广度优先搜索:

var initializeColor = function(){
	var color = [];
   for (var i =0; i<vertices.length; i++){
   	color[vertices[i]]='white'; // {1}
   }
   return color;
};

this.bfs = function(v,callback){

	var color = initializeColor(),		 // {2}
   	queue = new Queue();			// {3}
   queue.enqueue(v);				// {4}
   
   while(!queue.isEmpty()){		// {5}
   	var u = queue.dequeue(),	// {6}
       	neighbors = adjList.get(u); 	// {7}
       color[u] = 'grey';				// {8}
       for (var i=0; i<neighobrs.length;i++){  		// {9}
       	var w= neighobrs[i];				// {10}
           if (color[w] === 'white'){			// {11}
           	color[w] = 'grey';			// {12}
               queue.enqueue(w);			// {13}
           }
   }
   color[u] = 'black'; 					// {14}
   if (callback) { 					// {15}
   	callback(u);
   }
}

对于深度优先搜索和广度优先搜索,我们都需要标记已经访问过的节点。为此,我们需要一个帮手函数来帮我们生成color数组。唯其如此,当我们在执行深度优先算法或者广度优先算法时,所有的节点的颜色为白色(行 {1})。

现在,让我们深入广度优先搜索。首先,我们调用initializeColor来生成值都为white的color数组(行 {2})。我们还要生成一个队列(行 {3})来存储需要要访问的节点。

下面的步骤在本节开头已经讲过了,bfs函数会接收一个参数作为遍历的起点。我们需要一个节点作为起点,所有我们会把这个节点放入队列中(行 {4})。

只要该队列不为空(行 {5}),我们会通过调用dequeue函数来删除队列中的节点(行 {6}),并获得包含该节点所有邻接节点的邻结列表(行 {7})。当然,我们会把这个节点标记为灰色,来代表我们已经发现了这个节点,但是并没有完全探索这个节点。

之后,我们会获得u节点的所有邻接节点(行 {9}),并获取其值(行 {10})。如果这些临接节点中,存在未被访问的节点(颜色为白色--行 {11}),我们会将之标记为被发现(颜色改为灰色-行 {12}),并将之加入队列(行 {13})。

当我们访问节点和其邻接节点后,我们把该节点标记为被探索(颜色设置为黑色-行 {14})。

bfs函数还接收callback作为参数(这一点和第八章的遍历算法很相似)。当然这个参数是非强制的,只要我们放函数进去(行 {15}),机器就会执行这个函数。

现在,让我们通过下面的代码来检验广度优先搜索:

function printNode(value){ // {16}
	console.log('Visited vertex: ' + value); // {17}
}
graph.bfs(vertices[0],printNode); // {18}

首先,我们声明了一个printNode函数,用来在浏览器的控制台打印算法所访问到值(行 {17})。之后,我们会以vertices[0]printNode为参数调用bfs方法 。执行这些代码后,其在浏览器控制台的输出结果如下:

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

以上便是广度优先搜索算法的执行结果了!

使用广度优先搜索寻找最短路径

现在,我们已经知道广度优先算法如何实现。我们不仅仅可以使用这个算法来遍历图的所有成员。比如说,我们可以用广度优先搜索解决下面的问题。

已知给定一个图G,和起点v,寻找从v至u之间的最短路径。

我们知道,广度优先搜索会先访问距离为1的节点,再访问距离为2的节点,然后继续往后遍历。所以,我们可以使用广度优先搜索来解决这个问题。我们可以调整广度优先搜索算法,让算法返回这些信息给我们:

  • 从v到u的距离d[u]
  • 用于发现从v到u最短路径的pred[u]

现在,让我们来实现升级版的广度优先算法:

this.BFS = function(){
	var color = new initializeColor(),
    	queue = new Queue,
        d= [],    // {1]
        pred = [];// {2}
    queue.enqueue(v);
    
    for(var i=0; i<vertices.length;i++){ // {3}
    	d[vertices] = 0; 		 // {4}
        pred[vertices[i]] = null;	 // {5}
    }
    
    while(!queue.isEmpty()){
    	var u = queue.dequeue(),
        	neighbors = adjList.get(u);
        color[u]='grey';
        for(i = 0; i<neighobors.length;i++){
        	var w = neighobrs[i];
            if (color[w] === 'white'){
            	color[w] = 'grey';
                d[w] = d[u] + 1;    	// {6}
                pred[w] = u;		// {7}
                queue.enqueue(w);	
            }
        }
        color[u] = 'black';
    }
    return {				 // {8}
    	distances:d,
        predecessors: pred
    }
};

那么,这个版本的广度优先算法发生了哪些变化呢?

Note

我们需要声明一个数组d(行 {1})来表示距离,并声明pred(行 {2})来表示predcessors。接下来,在遍历每一个节点时,我们要将d初始化为0.pred初始化为null。

当我们发现了节点v的邻接节点w,我们把w节点的pre的值设为u(行 {7}),并为v和w之间的距离加1(行 {6})。

在该方法的最后一行,我们可以返回一个包含d和pred的对象(行 {8})。

现在,我们再执行一次广度优先搜索算法:

var shortestPathA = graph.BFS(myVertices[0]);
console.log(shortestPathA);

以下为代码的执行结果:

distances[A:0,B:1,C:1,D:1,E:2,F:2,G:2,H:2,I: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;从节点A到节点E、F、G的距离为2;从节点A到节点I的距离为3。

有了pred数组后,我们可以使用以下代码,构建出从节点A到其他节点的路径:

var fromVertex = myVertices[0];		// {9}
for (var i =0; i<myVertices[0];i++){	// {10}
	var toVertex = myVertices[i],	// {11}
    	path = new Stack ();		// {12}
    for (var v = toVertex; v !== fromVertex;		// {13}
    		v = shortestPathA.predecessors[v]){	// {14}
    }
    path.push(fromVertex);				// {15}
    var s = path.pop();					// {16}
    while(!path.isEmpyt()){
    	s +=  ' - ' + path.pop();			// {18}
    }
    console.log(s);					// {19}
}

我们将使用节点A作为起点(行 {9})。我们会计算从节点A到其他节点到距离(行 {10})。为此,我们会从vertices数组来获取toVertex的值(行 {11}),并生成一个栈来存储相关路径(行 {12})。

之后,我们会获得从fromVertex到toVertex的路径(行 {13})。变量v会接收predecessors的值。之后将v变量推入栈中。最后,我们会把fromVertex加入栈中,以完成路径。

之后,我们会生成一个字符串,并将栈顶部的值加入到这个字符串中(这个值为栈中最新被加入的值,所以我们要使用pop方法来实现——行 16)。只要这个栈不为空(行 {17}),我们会删除栈顶部的成员,并将被删除的成员加到字符串中(行 {18})。最后,我们就可以把路径输出在电脑的控制台了(行 {19})。

执行以上代码后,我们会得到如下的结果:

A - B
A - C
A - D
A - B - E
A - B - F
A - D - H
A - B - E - I

这样,我们就得到了从节点A到其他节点的最短路径了。

深入最短路径算法

在本书所使用的图并不是权重图。如果我们要计算的是权重图的最短路径,广度优先搜索并不是合适的算法。

Dijkstra 算法,用于计算单原权重图的最短路径问题,而Bellman-Ford算法,用于计算存在负权重时的最短路径问题。A * search为使用了探索法的一对节点提供了最短路径计算方案,而Floyd-WarWarshall为一对节点提供了最短路径的计算方案。

如本章第一节所述,图是一个很庞大的主题,对于最短路径问题及其相关问题,我们还有很多种解法。在深入其他图算法之前,我们需要掌握本章的图的基本术语。其他的解法本书没有提交,当然大家可以自行开启图的深入学习。

深度优先搜索

深度优先搜索算法在遍历时,会从一个节点开始,直到走到该路径的最后一个节点,之后会后退再沿着另一条路走。换言之,广度优先搜索是先注重深度,再注重广度,如下图所示:

深度优先搜索并不需要一个起点。

深度优先搜索会做以下事情:

 1.将节点v标记为被发现(灰色)

 2.将节点v的所以未被访问的邻接节点w:
	1.访问w

 3.将v标记为已经被探索(黑色)
 

如大家所见,深度优先搜索需要用到递归,这意味着深度优先算法需要一个栈来存储相关调用。

让我们来实现深度优先搜索:

this.dfs = function(callback){
	var color = initializeColor(); // {1}
    
    for (var i= 0; i<vertices.length;i++){ 		// {2}
    	if(color[vertices[i]] === 'white')		// {3}
        	dfsVisit(vertices[i],color,callback); 	// {4}
        }
    }
};

var dfsVisit = function(u, color, callback){
	color[u] = 'grey'; // {5}
    if (callback) {	   // {6}
    	callback(u);
    }
    var neighbors = adjList.get(u);			// {7}
    for (var i=0; i<neighobrs.length; i++){		// {8}
    	var w = neighobrs[i];				// {9}
        if (color[w] === 'white'){			// {10}
        	dfsVisit(w,color,callback)		// {11}
        }
    }
    color[u] = 'black';	// {12}
};

首先,我们要生成并初始化color数组,就像在广度优先算法做的那样。之后,对于每一个没有被访问的节点(行 {2} 和 行 {3}),我们将对之调用递归函数dfsVisit(行 {4})。

只要我们访问了一个节点u,我们就会将之标记为被发现(灰色-行 {5})。如果参数里有回调函数,我们会对被发现的节点u执行该回调函数。之后,要获得该节点u的邻接节点w(行 {7}),并对这些未被访问的邻接节点(行 {10}和行 {8})执行递归函数(行 {11})。最后,当我们深访问完该节点及其邻接节点后,我们会进行回溯,这意味着这个节点已经被探索过了,并被标记为黑色(行 {12})。

现在,我们来检验一下深度优先搜索算法:

 graph.dfs(printNode);

一下为代码的执行结果:

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

下图展示了深度优先算法的执行过程:

深入深度优先搜索

目前,我们还只知道深度优先搜索算法是如何实现的。但是,我们可以用这个算法做比遍历节点更多的事情。

给定一个图G,深度优先会遍历图G的所以节点,并构建一个有原节点的森林(根树的集合),并输出两个数组: 发现的时间和完成探索的时间。我们可以改进dfs方法,让他们为我们返回这些信息:

  • 发现节点u的时间d[u]
  • 探索完节点u的时间f[u]
  • 节点u的前身p[u]

现在,让我们看看升级后的BFS算法是如何实现的:

var time = 0;	// {1}
this.DFS = function(){
	var color = initializeColor(),//{2}
    	d = [],
        f = [],
        p = [];
     time = 0;
     
     for (var i =0; i<vertices.length; i++){ // {3}
     	f[vertices[i]] = 0;
        d[vertices[i]] = 0;
        p[vertices[i]] = null;
     }
     for (i = 0;i<vertices.length; i++){
     	if (color[vertices[i]] === 'white'){
        	DFSVisit(vertices[i],color,d,f,p);
        }
     }
     return {				   // {4}
     	discovery: d,
        finished: f,
        predecessors: p
     };
};

var DFSVisit = function(u, color, d, f, p){
	console.log('discovered ' + u);
   	 color[u] = 'grey';
    	d[u] = ++time; 			   // {5}
   	 var neighobrs = adjList.get(u);
     for (var i= 0; i<neighobrs.length; i++){
     	var w = neighbors[i];
        if (color[w] === 'white'){
        	p[w] = u;		   // {6}
            DFSVisit(w, color, d, f,p );
        }
     }
     color[u] = 'black';
     f[u] = ++tiem;			   // {7}
     console.log('explored ' + u);
}

因为我们想要追踪发现节点的时间和完成探索的时间,所以我们需要声明一个变量来存储相关信息(行 {1})。接下来,我们还要声明d,f和p等数组(行 {2})。接下来,我们要给三个数组加入图里的每一个成员(行 {3})。在结束该函数前,我们会返回这些数组(行 {4})。

当一个节点第一次被发现,我们会记录它的被发现时间(行 {5})。当它被作为u的边发现,我们也会把它记录在它的前身(行 {6})。最后,当这个节点被完全探索后,我们会记录完成的时间(行 {7})。

以下为BFS的执行图示:

用深度优先搜索算法实现拓扑搜索

已知给定下图,假设每个节点代表一个要执行的任务:

Note 这是一个有向图,这意味着这些顺序是要按顺序执行的。比如说,任务F是不能先于任务A执行的。注意,这个图并没有环,这意味着这是一个非环图。所以,我们可以说,这个图是一个有向非环图(DAG)。

如果我们要明晰任务的执行顺序,我们称呼这为拓扑搜索。这个问题会出现在我们生活中不同的场景。比如说,如果我们要学习计算机课程,那么我们不能在没学算法I的情况下学习算法II。当我们在开放一个项目时,也是有特定任务的执行步骤的:首先,我们要了解用户的需求,之后按照客户的要求开放,再上传这个项目。我们无法在得到客户需求之前就上传项目。

拓扑搜索只能在DAG实现。那么,我们该如何借助深度优先搜索来实现拓扑搜索呢。让我看看下面的代码吧:

graph = new Graph();
myVertices = ['A','B','C','D','E','F'];
for (var i =0; i<myVertices; 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');
var result = graph.DFS();

下图展示了代码的执行结果:

现在,我们要依据节点的完成访问时间进行降序排列,这样我们就得到了该图的拓扑排序结果:

B - A - D - C - F - E

小结

在这一章,我们讲述了图的基本概念。我们已经知道了表示图的不同方法。我们也知道了如何实现广度优先搜索和深度优先搜索。这一章也讲到了广度优先搜索和深度优先搜索的应用:寻找最短路径和实现拓扑排序。

在下一章,我们将会学到计算机常用的排序算法。

🏆 掘金技术征文|双节特别篇