图的邻接表存储方式:链式前向星
注:图片和资料主要参考至这位这位大佬的博客
1. 邻接表
图最常见的两种存储方式是邻接表和邻接矩阵。
邻接表存储方式适合存储边稀疏的图,有多少边就使用多少空间,比较节省空间,但是判断两点之间是否有边不方便;【某些时候,若只遍历存在的边,其实复杂度相较于遍历二维矩阵更低】
邻接矩阵适合存储边稠密的,固定的O(N*N)[N为顶点数量]的空间复杂度,若边稀疏很浪费空间,但是判断边和权值都很方便。
邻接表的逻辑结构:
主要有两个部分组成:
1)顶点数组;
2)顶点所邻的边形成的链表【所以叫“邻接表” = 邻接点链表 + 节点表】。
乍一看,很像哈希表的数据结构。
存储链表的方式有两种:
1)若不知道边的数量范围,使用node + next指针的经典链表方式;
2)若知道边数量的范围,可以使用数组来代替链表存储。【因为边的数量是有限的,我们只要保证一条边占据数组一个位置
,即给边一个唯一的编号,那么边就能用这个唯一的下标来表示一条边】。
数组相较于链表,符合局部性原理,缓存预读的概率更高,往往具有更高的效率。所以优先选择方式2)。
采用数组+数组链表的方式来存储图的邻接表的物理结构叫做链式前向星
。
好吧,这个名字真够中二的。
2. 链式前向星
先说一下这个存储结构的组成部分:
- head数组:记录i节点的编号最大的邻边存储在next中的下标;
- next:以数组式链表的形式存储所有边的编号【可能还有其他信息】
这时候,按照next数组的存储单元有两种流派:
1)next存储一个复合的数据结构,假设为Node;
node可以记录i号边指向的顶点,也可以记录权值,记录路径数量等等信息;
2)next单纯存储一个边序号,若需要记录更多的数据,就使用多个数组,保证下标和next同步即可。
其中,2)方式书写更简洁,但是不适合存储大量信息的场合,1)方式需要额外定义数据结构,可以按照自己的想法记录数据,适合比较复杂的题目。
这里我们采用方式2)。
1. 初始化
定义三个数组:
EDGE_NUMBER = 5; //边的最大数量,题设一般会给出
NODE_NUMER = 5;//点的最大数量,题设一般会给出
idx = 0; //记录当前边的自增序号
head = new int[NODE_NUMER];
next = new int[EDGE_NUMBER];
edge = new int[EDGE_NUMBER]; //i号边的入度;
w = new int[EDGE_NUMBER]; //i号边的权值
//同理,若有更多的数据要存储,就定义更多的new int[EDGE_NUMBER]
//更通用的情况下,需要将head初始化为-1,表示这个顶点不存在边
//上图可以不用,只要我们用0表示不存边的顶点即可【若节点从0开始,就只能用-1表示】
//这里我们采用通用的做法
Arrays.fill(head, -1);
由于idx是自增
的,因此边的序号不会重复[当然,必须保证每次传入的边不和之前的重复]
注:下图中head标注为了first,因为是直接用的别人的图。
假设我们给图一中的图的每条边编号:
1: <1, 4, 9> //分别表示出度from,入度to,权值weight
2: <2, 4, 6>
3: <1, 2, 5>
…
2. 插入边
链式前向星的核心算法只有两句:
next[++idx] = head[from];
head[from] = idx;
前面说过,head[i]是记录节点i的到目前为止出现的最后一条出度边所在的下标,若这个点没有边,则默认为-1;
那么next[++idx] = head[from]就表示将next数组的范围拓宽一个,并记录下当前插入的这条边的出度的上一条出度边的下标。
同时,from的出度边又新出现了一条,head[from]记录下这最新一条边的地址。
我们也可以记录下额外的信息:
w[idx] = weight;
edge[idx] = to;
idx保持与next中的一致即可。
以上就是添加一条有向边
的全部代码了。
我们可以将他封装为一个函数
void add(int from, int to, int weight) {
next[++idx] = head[from];
head[from] = idx;
w[idx] = weight;
edge[idx] = to;
}
那么记录一条有向边就是一个对称地调用:
add(from, to, weight);
add(to, from, weight);
来看一波插入的图示:
插入边<1,4,9>:
- 将next的范围扩充一个【下标自增1】,并缓存head[1]之前的数,即-1,表示next[1]存储的是1号顶点的第一条出度边;
- 将head[1]置为1,表示1号顶点当前出现的最后一条出度边的信息存储在1号位置【不仅是next,还有w,edge都是如此】
插入边<2, 4, 6>:
也是类似,此时idx自增到了2;
插入边<1, 2, 5>:
- 由于1号顶点插入过一次,因此他的first是大于0的,即表示next数组中存储了它的上一条边,我们先使用新拓充的next数组即next[2]去存储上一条边,即next[2] = 1;
- 再使用head[1]记录最新一条出度边的下标,即为2;
- 在其他数组记录信息。
剩下的也是类似的过程。
3. 遍历
遍历的代码也很固定, 假如要遍历节点i的所有邻边:
for (int j = head[i]; j != -1; j = next[j]) {
//do sth
}
首先,利用指针j指向i节点的最后一条出度边,由于next[j]记录了倒数第二条边的下标,由此可以得到倒数第二条边…依次类推,最终可以遍历完一个顶点的所有边,直到next[j] == -1为止,表示j已经是i的最后一条出度边了。
全图的遍历,只需要对所有的节点都遍历一次就可以了:
for (int i = 0; i <= NODE_NUMER; i++) {
for (int j = head[i]; j != -1; j = next[j]) {
//do sth
}
}
遍历的次数就是有向边的数量,即O(EDGE_NUMBER)
3. 使用举例
以leetcode 863为例:
这题,无论利用树的前序遍历和后序遍历都没法写,因为要综合考虑前面的信息和后面的信息。
我们无法把树当树的题目做,那就转换一种思路,将树看做为图,从target节点开始进行一波搜索,找到距离为k的节点即可;
题设中给出了节点的数量最大值MAX_NODE_NUMBER,且根据二叉树的性质我们可以知道,一个节点最多有两个子节点,由于要考虑前后的关系,我们应该将边看做是无向边
,即一个节点最多需要存储四条边,即MAX_NODE_NUMBER * 4就是边的最大数量。
考虑建图: 利用一次前序遍历,将父节点和子节点的连线当做无向边存储进去即可。
考虑解题:如何找到距离为k的边呢?有两种思路:
- bfs:利用队列记录第i轮搜索可以触及到所有点,即为距离i的所有顶点;当i==k时,跳出循环,queue中剩余的便是结果集;
- dfs:每个位置不断向后搜索,记录没有访问过的邻接点,直到搜索到距离为k,将其加入结果集
bfs和dfs都需要避免将节点重复遍历,所有需要一个额外的flag数组记录访问过的节点。
代码如下:
class Solution {
/**
* 存在0节点,那么只能使用-1代替结束了。。。
*/
private static final int MAX_NODE_NUMBER = 501;
//点集合,指向当前点的最后一条出现的邻边
private int[] head = new int[MAX_NODE_NUMBER];
/**
* next: j = head[i]指向next中记录的该点的最后一条临边, 则倒数第二条邻边就是next[j]...直到next[?] = 0, 表示不再有邻边
* edge[j]: 记录边head[j]所对应的邻点
* idx: 当前next的最大下标
*/
private int[] next = new int[MAX_NODE_NUMBER * 4];
private int[] edge = new int[MAX_NODE_NUMBER * 4];
private int idx = -1;
/**
* dfs方式,递归的进行搜索
* 需要利用一个flag数组记录访问过的点
*/
private List<Integer> res;
private boolean[] flag = new boolean[MAX_NODE_NUMBER];
public List<Integer> distanceK(TreeNode root, TreeNode target, int k) {
res = new ArrayList<>();
Arrays.fill(head, -1);
dfs(root);
flag[target.val] = true;
dfsGraph(target.val, k);
return res;
}
private void dfsGraph(int node, int k) {
if (k == 0) {
res.add(node);
return;
}
if (head[node] == -1) {
return;
}
for (int i = head[node]; i != -1; i = next[i]) {
if (flag[edge[i]]) {
continue;
}
flag[edge[i]] = true;
dfsGraph(edge[i], k - 1);
}
}
/**
* bfs方式,将邻接点加入queue
* 需要利用一个flag数组记录访问过的点
*/
public List<Integer> distanceK1(TreeNode root, TreeNode target, int k) {
// List<Integer> res = new ArrayList<>();
Arrays.fill(head, -1);
dfs(root);
Queue<Integer> queue = new ArrayDeque<>();
boolean[] flag = new boolean[MAX_NODE_NUMBER];
queue.offer(target.val);
flag[target.val] = true;
int times = 0;
while (!queue.isEmpty()) {
if (times == k) {
break;
}
int n = queue.size();
for (int i = 0; i < n; i++) {
int t = queue.poll();
for (int j = head[t]; j != -1; j = next[j]) {
if (!flag[edge[j]]) {
flag[edge[j]] = true;
queue.offer(edge[j]);
}
}
}
times++;
}
return new ArrayList<>(queue);
}
/**
* 将二叉树的每个节点与他的两个可能的子节点以`无向边`[2 * 2 = 4]的形式加入链式前向星
*/
private void dfs(TreeNode root) {
if (root == null) {
return;
}
if (root.left != null) {
add(root.val, root.left.val);
add(root.left.val, root.val);
}
if (root.right != null) {
add(root.val, root.right.val);
add(root.right.val, root.val);
}
dfs(root.left);
dfs(root.right);
}
/**
* 将边<from, to>加入链式前向星
*
*/
private void add(int from, int to) {
//next记录上条邻边的地址
next[++idx] = head[from];
//head记录当前最后一条临边存储在next中的位置
head[from] = idx;
//edge记录最后一条邻边的对点
edge[idx] = to;
}
}
ps:可能无法一下子明白邻接点为什么是edge[j], 画图模拟一下插入过程就好理解了。
时间复杂度:O(N)[N为节点个数,插入复杂度为O(N),遍历复杂度为O(N);本身链式前向星的复杂度为O(E),E为边的数量,但是二叉树的边的数量为4N,所以复杂度为O(N)]
空间复杂度:O(N)
4. 总结
- 链式前向星是一种完全用数组存储图的邻接表的存储结构,插入、遍历的复杂度都是O(E)[E为边的数量];
- 用树的思维无法解决的问题,可以使用图的思维来解决。