1. 广度优先遍历
图的广度优先遍历伪代码如下,其中Q为队列,visited为大小为n的bool数组。
memset(visited, false, n);//n是图G结点个数 for u∈G if !visited[u] Q.push(u); while !Q.empty() v = Q.pop(); for each v’s neighbors w if !visited[w] visited[w] = ture; Q.push(w); endif endfor endwhile endif endfor
1.1 优化队列Q
如代码所示将访问到的结点暂存入队列Q中。队列可以循环队列方式,也可以采用可变大小队列方式。前者可以节省空间,在多次存取队列时还可以提高效率。假设循环队列长度为n,加入每一个时刻在队列中的元素个数均不会超过n,则无需扩展队列长度。而使用后者可变大小队列(非循环),因每次存储元素队尾均需后移,当存储次数超过n(注意队列中的元素不超过n)时,因”溢出”需要扩展队列空间,从而还会导致数据拷贝,因此前者效率高。
而图的广度优先遍历又具有特殊性,因为每个结点进队列仅一次,因此队列的大小设置为图结点个数n,就不会溢出。
更近一步不难发现队列只要有一个队首和一个队尾即可,而若将队列封装成一个类则在图的遍历过程中会浪费大量的队列调用开销,从而降低图遍历的效率。因此在图遍历时,只需申请一块连续的大小为n(图结点个数)的空间,以及一个队尾和一个队首即可。
1.2 优化visited数组
在上述遍历过程中除了队列外还用到了另外一个用于标记结点是否被访问的变量——visited数组。遍历正式开始之前需要visited数组进行初始化,且是不可或缺的。因此,当遍历次数较少或者只有一次时,初始化操作不会影响图遍历效率。但当需要对图连续遍历k遍时,若采用上述方式需要对visited数组初始化k次,显然影响图遍历的效率。
当需要遍历图多次,会浪费大量的时间在对visited进行初始化,因此可以用int类型visited数组替换bool类型的visited数组,且初始化为-1,另外加一个用于标记遍历当前次数的变量visitedIterm,初始化为0。每次开始遍历时,visitedIterm自动加1。
经1.1、1.2优化后,图的广度优先遍历伪代码如下:
memset(visited,-1, n);//n是图G结点个数
visitedIterm =0;
//以下为图的遍历
visitedIterm ++;
for u∈G
head = tail = 0;
if visitedIterm != visited[u]
q[tail++] = u;
while head < tail
v = q[head++];
for each v’s neighbors w
if visitedIterm!= visited[w]
visited[w] = visitedIterm;
q[tail++] = w;
endif
endfor
endwhile
endif
endfor
2. 深度优先遍历
深度优先遍历伪代码如下,其中S为栈,visited为大小为n的bool数组。
memset(visited,false, n);//n是图G结点个数
for u∈G
if !visited[u]
S.push(u);
while !S.empty()
v=S’s first elem;
if v has not-visited neighbor
u=v’s first not-visited neighbor
visited[u] = ture;
S.push(u);
else
S.pop();
易知深度优先遍历与广度优先遍历相似,因此类似1.1也可以申请大小为n的数组来代替封装的栈,从而减少了函数调用的开销。与1.2相同当需要多次深度优先遍历图时,用int类型的visited数组代替bool类型的visited数组。
与广度优先遍历相比,栈中的元素不是访问时就取出,而是在其所有的邻居全部访问完时才出栈,因此S中存储结点和当前处理的孩子两个变量可以加快查找结点未访问孩子的效率。
3. 图的存储
当图规模比较小的时候采用邻接矩阵可以加快图的大部分操作,但当图的规模增长时邻接矩阵O(n2)的存储代价是计算机无法容忍的,因此图数据库中通常采用邻接表的存储图数据。邻接表通常为(结点,边集合)的二元组集合,而边集合也有不同的形式。
设n、m、d分别为图G的结点、边个数以及图中结点最大度。
(1) 链表形式存储。用这种存储方式,动态添加边的代价O(1),查询边的代价O(d),删除边代价O(d)(查找代价O(d)+删除代价O(1)),遍历图代价O(n+m)。
(2) 哈希结构存储。动态添加边的代价O(1),查询边的代价O(1),删除边的代价O(1),遍历图代价O(n+m)。
(3) 静态有序数组存储。这种方法动态添加边代价O(d+log2d),其中O(log2d)为查找代价,O(d)为插入元素其它元素后移的代价。查询边代价O(log2d),删除代价O(d+log2d),遍历代价O(m+n)。另外需要注意当增加边后个数超过数组大小需要对数组进行扩充。
(4) 静态无序数组存储。这种方法动态添加边代价O(1),查找代价O(n),删除代价O(d),遍历代价O(m+n)。类似(3)当增加边后个数超过数组大小需要对数组进行扩充。
不难发现当只需对图进行查找、添加边、删除边时使用(2)哈希结构效率最好。但图的遍历操作是图的最基本操作,例如可达性中,利用DFS求解时需要遍历图,而其他利用建立标签的方式求解可达性的方法在建立索引标签的过程中需要多次的遍历部分或整个图。例外图的其它操作如图匹配也需要对图进行大量的遍历操作。
但从时间复杂度上来说,4种存储方式的时间复杂度均为O(m+n),即只与图规模相关。(1)、(2)均属于链式存储,当访问边由第一到第二条边时可能需要跳跃多个内存地址,此时cache完全失效。而(3)、(4)每个结点的边都存储于连续的内存空间,即对内存友好,cache命中率极大地得到了提高。因此,采用(3)、(4)这两种存储结构遍历时的效率远远高于(1)、(2)。
考虑到内存友好性,有了第五种存储方式:
(5) 边集中存储方式。用一个大小为m的空间存储n个结点的边,同时用大小为n的空间记录该节点的边在边空间中的起始位置,用一个大小为n的空间记录对应结点的出度大小。著名的可达性研究” TF-Label: a Topological-Folding Labeling Scheme for ReachabilityQuerying in a Large Graph-sigmod’13”中的TF算法作者给出的源代码就采用此种形式。