注意:这里是JAVA自学与了解的同步笔记与记录,如有问题欢迎指正说明
目录
一、关于邻接表
虽然说邻接矩阵可以表示图结构,但是终归来说具有一些不便。在定义里存在一种名为稀疏图(sparse graph) 的图结构,这种图有很少条边或弧,即边的条数\(\left |E \right |\)远小于\(\left | V \right |\);反之,边的条数\(\left | E \right |\)接近\(\left | V \right |\),称为稠密图(dense graph)。在稀疏图中采用邻接矩阵的开销是非常不理想的,因为顶点与弧数目的比例非常大,这就到导致了在保证弧数目是我们可用的范围内时,点的个数可能超过我们的预计范围。在加上邻接矩阵的二维存储,则存储负担进一步加大。
正如上图所示的这个图结构,准确说,如果我们认为每个边能承担自己的权值,那么这准确说应当是个网。按照以前思路,存储这个结构我们可能会将以顶点集为中心,建立一个4*4的二维数组来存储。今天,我们引入下图这样的邻接表。
邻接表结构就灵活结合了边的特征,不再一味地以顶点为中心,而是纳入了边结构。首先我们依据顶点集建立一个二元值域的顺序表(支持顺序存取),顺便表的数据项又叫做顶点表结点。
令data域表示当前结点的含义,比如某些节点有顶点权就可以在这里记录。当然,若结点本身不存储信息则可以去掉data域而只保留firstarc域;firstarc域主要是一个边表结点指针,用于指向代表边的结点。边表结点的定义如下:
边表结点的特性在于表示边,众所周知,边最多可由三个信息组成:前驱顶点,后继顶点,权。后继顶点可以结点通过adjvex域来表示,权可以用weight域表示,而nextarc域主要用于表示结点之间的关系,是指向边表结点的结点,并无实际值的含义。那么谁来表示前驱顶点呢?
对于邻接表来说,我们的每个边表结点都是从顶点表结点延伸出来的,换言之,通过顶点表结点遍历得到的任何数据中,我们都是默认已知(或者说携带)最初的开始顶点的,而在邻接表中,这样的最初开始结点便是接下来所有边表结点的前驱顶点。
以上是一种理论化的邻接表定义,而在实际使用中,我们可以不用过度区分边表结点和顶点边结点而统一采用一种结点形式,这样的话代码会简介而且使用也方便。只要合理舍去一些不必要的信息就好了,我们接下来的代码就仅考虑data值域和指针域就好了,顶点的标号通过顺序表的下标来对应就好了。
二、邻接表相比邻接矩阵的效率分析
邻接表的存储空间从\(O(|V|^2)\)优化为\(O(|V|+|E|)\),对于稀疏图来说,因为\(|V|>>|E|\),那么\(O(|V|+|E|)\)可以近似表示为\(O(|V|)\)。可见对于稀疏图来说,空间的复杂度近乎线性,而且就算非稀疏图,\(|E|\)的值足够大,但是\(O(|V|+|E|)\)理论上依旧是线性级别的。总的来说,邻接表的本身的存储性质要完全优于邻接矩阵,对于大型项目,邻接矩阵是最优的选项。只不过缺点就在于邻接表的创建不如邻接矩阵那般简单,而且无法使用矩阵的一些特性。
1.在遍历一个结点相邻边时,若采用邻接矩阵的话需要每次都把所有的边都遍历一遍,然后在遍历中途还需要用条件语句来筛选邻边复杂度是\(O(|V|)\)。而邻接表对于单个结点的邻边遍历的复杂度非常小,此结点有多少邻边就访问多少次,只有把所有结点的邻边都遍历一遍的复杂度才是\(O(|E|)\)。
2.对于邻接表和邻接矩阵来说,若空间分配有预留,那么顶点插入操作的复杂度都是\(O(1)\)。但是控件不够的话就涉及动态分配策略,邻接表的复杂度为\(O(|V|)\);而邻接矩阵因为二维静态的特征,复杂度为\(O(|V|^2)\)。
3.至于删除操作,对于邻接矩阵,有的方案建议采用标记删除法,但是这种方法虽然可以实现\(O(1)\)复杂度,但是浪费比较大,而对于邻接矩阵而言可能需要\(O(|E|)\)复杂度去挑战删除结点的领边。
但是单独判断图中的两个点是否连通这件事邻接表需要遍历一定的出边,复杂度与这个结点的出边有关,而邻接矩阵可以通过随机访问通过\(O(1)\)得到。灵活使用这两个数据结构可以在必要时方便我们的代码与保证程序整体的运行良好。
三、代码实现
邻接表主要是要明白这个概念,代码的话并没有什么特别,只要认真了学习了线性表的知识,这里无非就是构建个顺序表,然后顺序表的每个表项构造一个链表即可(有点像哈希构造的拉链法)
结点定义如下:
/**
* An inner class for adjacent node.
*/
class AdjacencyNode {
/**
* The column index.
*/
int column;
/**
* The next adjacent node.
*/
AdjacencyNode next;
/**
*********************
* The first constructor.
*
* @param paraColumn
* The column.
*********************
*/
public AdjacencyNode(int paraColumn) {
column = paraColumn;
next = null;
}// Of AdjacencyNode
}// Of class AdjacencyNode
上述代码中,统一用二元的方式构造结点,无论其是定义中的顶点表结点还是边表结点。
/**
* The number of nodes. This member variable may be redundant since it is
* always equal to headers.length.
*/
int numNodes;
/**
* The headers for each row.
*/
AdjacencyNode[] headers;
上述是定义的整个邻接表的数据体,主要构造一个顺序表和表长(顶点个数)即可。
/**
*********************
* The first constructor.
*
* @param paraMatrix The the matrix indicating the graph.
*********************
*/
public AdjacencyList(int[][] paraMatrix) {
numNodes = paraMatrix.length;
// Step 1. Initialize. The data in the headers are not meaningful.
AdjacencyNode tempPreviousNode, tempNode;
headers = new AdjacencyNode[numNodes];
for (int i = 0; i < numNodes; i++) {
headers[i] = new AdjacencyNode(-1);
tempPreviousNode = headers[i];
for (int j = 0; j < numNodes; j++) {
if (paraMatrix[i][j] == 0) {
continue;
} // Of if
// Create a new node.
tempNode = new AdjacencyNode(j);
// Link.
tempPreviousNode.next = tempNode;
tempPreviousNode = tempNode;
} // Of for j
} // Of for i
}// Of class AdjacentTable
第一个构造函数功能是邻接矩阵转换到邻接表。
- 对邻接表的顺序表初始化。因为顺序表每个表项的首元素的data域无实际含义,因此固定用-1定义,就如同链表的表头结点一般。这些顺序表表头的定位交给顺序表的随机存取下标即可。
- 基于当前结点\(v_i\)创建了表头\(Header_i\),然后采用邻接矩阵的遍历思想,一次遍历得到\(v_i\)的相邻结点,每次得到的结点就像创建链表一样尾插到链表\(Header_i\)中。
/**
*********************
* Overrides the method claimed in Object, the superclass of any class.
*********************
*/
public String toString() {
String resultString = "";
AdjacencyNode tempNode;
for (int i = 0; i < numNodes; i++) {
tempNode = headers[i].next;
while (tempNode != null) {
resultString += " (" + i + ", " + tempNode.column + ")";
tempNode = tempNode.next;
} // Of while
resultString += "\r\n";
} // Of for i
return resultString;
}// Of toString
基本的,任何数据结构基本都会完成的打印重载,这里我们仿造邻接表的构造进行换行逐个答应邻接表的每个链。
四、代码重构与数据测试
为了方便测试,这里我将前几日的BFS与DFS通过邻接表来重新实现,从而深入体会下邻接表的使用特点。
下面先给出BFS的核心代码部分的邻接表版本:
AdjacencyNode tempNode;
while (tempInteger != null) {
tempIndex = tempInteger.intValue();
// Enqueue all its unvisited neighbors. The neighbors are linked
// already.
tempNode = headers[tempIndex].next;
while (tempNode != null) {
if (!tempVisitedArray[tempNode.column]) {
// Visit before enqueue.
tempVisitedArray[tempNode.column] = true;
resultString += tempNode.column;
tempQueue.enqueue(new Integer(tempNode.column));
} // Of if
tempNode = tempNode.next;
} // Of for i
// Take out one from the head.
tempInteger = (Integer) tempQueue.dequeue();
} // Of while
第六行代码通过tempIndex查询邻接表获得了顶点在顺序表中位置,同时也是邻边链的表头。然后用tempNode接收到第一个有效邻边,再通过while逐步访问邻边从而达到遍历的作用。这个途中会不断取出data域(这里就是column属性)来用与BFS的基本遍历。
下面先给出DFS的核心代码部分的邻接表版本:
// Now visit the rest of the graph.
int tempIndex = paraStartIndex;
int tempNext;
Integer tempInteger;
while (true) {
// Find an unvisited neighbor.
tempNext = -1;
AdjacencyNode tempNode = headers[tempIndex].next;
while (tempNode != null) {
if (!tempVisitedArray[tempNode.column]) {
// Visit this one.
tempVisitedArray[tempNode.column] = true;
resultString += tempNode.column;
tempStack.push(new Integer(tempNode.column));
System.out.println("Push " + tempNode.column);
tempNext = tempNode.column;
// One is enough.
break;
} // Of if
tempNode = tempNode.next;
} // Of while
if (tempNext == -1) {
if ((Integer) tempStack.size() == 1) {
break;
} // Of if
int tempPopElement = (Integer) tempStack.pop(); // The current node has no adjacent edges
System.out.println("Pop " + tempPopElement);
tempIndex = (Integer) tempStack.top(); // Back to the previous node, however do not remove it.
} else {
tempIndex = tempNext;
} // Of if
} // Of while
这里邻接表的便利实现细节与BFS的是类似的,这里不再赘述。BFS与DFS的其余部分欢迎见我的博客:
DFS的迭代实现https://blog.csdn.net/qq_30016869/article/details/124200110
BFS实现https://blog.csdn.net/qq_30016869/article/details/124143614
主函数:
/**
*********************
* The entrance of the program.
*
* @param args Not used now.
*********************
*/
public static void main(String args[]) {
int[][] tempMatrix = { { 0, 1, 0 }, { 1, 0, 1 }, { 0, 1, 0 } };
AdjacencyList tempTable = new AdjacencyList(tempMatrix);
System.out.println("The data are:\r\n" + tempTable);
breadthFirstTraversalTest();
depthFirstTraversalTest();
}// Of main
输出结果测试(注意,主函数中的图与单元测试中的邻接表是不一样的):
总结
邻接表和邻接矩阵都是实现图的关键工具,就我个人的使用习惯来看,应用于具体的算法问题上也许邻接表体现的会直观一些,而且存储体量也会轻松很多。当然最关键的还是对于复杂度的优化。曾经在参加一些代码比赛的时候,都会在笔记本上记上邻接表实现代码,具体遇到图的问题时先不管三七二十一先抄上再说。因为有些题目给出图顶点的个数过多导致无法构造出可存储的二维数组,或者说,每次都是\(O(N)\)的邻边遍历一旦结合某些图的算法会存在时间超标现象。所以大多数写代码竞赛的学生相比于用邻接矩阵,更喜欢邻接表。
但是就像我刚刚说的,这两个算法虽然存在一些复杂度差异性,但是本质上它们都各有可取的优点。邻接矩阵作为矩阵可以结合线代的特性完成一些邻接表很难完成的任务,例如前几日博客中实现的连通性矩阵。优劣各异,在合理情况下合理使用这些结构才是良策!