树与图的存储
我们在算法比赛中都是以数组来模拟 树 与 图。
我们常常使用 链表 与 邻接表来存储树与图。常常看到有人使用结构体来实现链表和邻接表,但是这只适用于面试,不适用与笔试。这是因为每次创建一个新的节点都需要调用 new 函数,然而 new 函数效率是非常低的。低到什么程度?一般题目节点有 10 万 到 100 万,然而你仅仅是 new 10万个节点就有可能超时了……当然,你一开始初始化 10 万个节点,这个倒是可以,但是这和数组就区别不大了。
数组模拟链表(又称为 静态链表)最主要的好处就是快。
单链表
使用背景
**邻接表最常用的用途就是存储 图 和 树。**因此,单链表在算法题中最常用的就是存储图和树。
单链表主要用来实现邻接表,邻接表主要用来存储 图 和 树。
实现思路
一开始,一个头指针 head。头指针 head 指向空元素。
然后,向链表插入节点,head 依旧指向头元素,链表的尾节点指向空节点。
每一个节点上存一个 value 和 next 指针。
使用到的数组
根据上述思路,我们用数组进行模拟。首先,每个节点有有 value 和 next 指针。用数组 e[N] 存储 value,ne[N] 来存储 next 指针。而数组 e 与 数组 ne 使用 相同的下标 进行关联就可以表示同一个节点的不同属性了。空节点可以用 -1 来表示,即链表的尾节点的 ne 元素值为 -1。(为什么要存在尾结点还赋值为 -1?这是为了后期顺序遍历数组时,让遍历的指针知道什么时候应当停止遍历)
const int N = 100010;
int head = -1; // head指针,指向链表头节点:head == 头节点的下标
int e[N]; // 存放每个节点存储的值
int ne[N]; // 存放每个节点 下一个节点的实际下标
int idx = 0; // 存储我们当前已经用到了那个地址(实际下标),也相当于一个指针。
单链表的操作
-
单链表初始化操作。单链表一定要记得初始化啊
//* 链表初始化 void init() { head = -1; // head指针一开始指向空节点 idx = 0; // 表示链表从数组的 0 号点开始分配 }
-
头插法 。所谓头插法即把新的节点插入在head指针指向的头节点之前,所以这个新节点就变成了新的头节点了。
//* 头插法
void add_to_head(int x) {
e[idx] = x;
ne[idx] = head;
head = idx;
++idx; // 当前实际位置已经存储了新的节点,idx下标移到下一个位置
}
-
插入操作:将 x 这个值插入到 下标 为 k 的结点的后面。
//* 常规插入操作:在第 k 个节点之后插入 x void add(int k, int x){ e[idx] = x; ne[idx] = ne[k]; ne[k] = idx; ++idx; }
-
删除操作:单链表可以在 O ( 1 ) O(1) O(1) 的时间找到下一个点的位置,但是缺不能在 O ( 1 ) O(1) O(1) 的时间找到上一个点的位置。**单链表只能向后看,不能向前看。**所以我们删除下一个节点。算法题不用担心内存泄漏的问题,没必要。
//* 单链表的删除操作:将下标是k的点 的后面的节点删除 void remove(int k) { ne[k] = ne[ne[k]]; }
双链表
背景
双链表主要是用来优化某些题的。
双链表就是每个节点有两个指针,一个指向前,一个指向后,即用 l[N] 存每个节点左边的点是谁,用 r[N] 存每个节点右边的节点是谁。
双链表依旧使用多个 int 数组来实现,而不使用 结构体数组。这是因为使用结构体数组后,一行代码会变得非常的长,没有 int 数组简洁。
使用到的数组
const int N = 100010;
int e[N]; // 存每个节点的值
int l[N]; // 存每个节点前一个节点的指针
int r[N]; // 存每个节点后一个节点的指针
int idx = 2; // 实际存储的数组序号
双链表的操作
初始化
我们用 idx == 0 的数组表示头指针,idx == 1 的数组表示尾部指针。不再像单链表的实现一样使用 head 指针。
void init() {
r[0] = 1;
l[1] = 0;
idx = 2; // 因为 0 、1 序号的数组已经表示头指针和尾部指针了
}
插入点
// 在下标是 k 的点的右边插入 x
void add(int k, int x) {
e[idx] = x;
r[idx] = r[k];
l[idx] = k;
l[r[k]] = idx; // 不能与下面一行顺序写反
r[k] = idx;
++idx;
}
//* 在下标是 k 的点的左边插入 x:add(l[k], x);
删除点
让第 k 个点的左端点 的右指针指向 第 k 个点的右端点。让第 k 个点的右端点的左指针指向第 k 个点的左端点。
// 删除第 k 个节点
void remove(int k) {
r[l[k]] = r[k];
l[r[k]] = l[k];
}
邻接表
邻接表就是一堆单链表,我们也是用数组模拟的。
树是一种特殊的图,所以我们先看如何存储图即可。
绝大部分题都是用邻接表或邻接矩阵来存图。
在算法题中,把无向图看出特殊的有向图。a – b 等价于两条有向图边:a --> b、b --> a 。用邻接矩阵 g[a] [b] 来存储 a–> b。当我们计算最短路径的时候,如果样例中有多条从 a 到 b 的有向边,显然我们只需要用邻接矩阵 g[a] [b] 存储最小的边即可。
邻接矩阵因为要开二维数组,所以更适合存储 稠密图 。因为太占空间,所以算法题中用的不多。
算法图论中,用的最多的是邻接表,因为邻接表适合存储 稀疏图 。
重边
二或多条 a -> b 边。
自环
a -> a 的边。
邻接表实现思路
树的直径:任意两点之间的最大路径,即树的直径。因此,未必只有一条树的直径。
邻接表就是每个节点都会有一个单链表。每个点上的单链表就使用来存这个点能走到哪些点。单链表上每个点的次序是随意的。
如下图:
邻接表如下:
若想插入新的边,我们一般选择在单链表的头插法。如下图,h[i] 表示第 i 个节点的单链表的头节点。
例如插入一条边:a --> b。
使用到的数组
const int N = 100010, M = N * 2; // 邻接表一般存稀疏图,所以 M 不太大
int h[N]; //
int e[M]; // 存储某个单链表的一个节点的序号
int ne[M]; // 存储:指向某个单链表一个节点的下一个节点的序号
int w[M]; // 存储每条边的权重,当每条边的权重是 1 时,就可以省略这个数组了。
int idx; // 实际存储过程中的数组索引
邻接表操作
初始化
void init() {
memset(h, -1, sizeof h); // 所有单链表头指针初始化为 -1
idx = 0;
}
插入
即 在 a 点对应的邻接表里面头插一个 b 节点。如果是无向图那就再 在 b 对应的邻接表里插入 a 节点。
void add(int a, int b) {
e[idx] = b;
ne[idx] = h[a];
h[a] = idx++;
}
// 当每条边的权重可能不是 1 时
void add(int a, int b, int c) {
e[idx] = b;
ne[idx] = h[a];
w[idx] = c;
h[a] = idx++;
}
图的 DFS
void dfs(int u) {
st[u] = true; // 标记一下当前节点 u 已经被搜索过了
for (int i = head[u]; i != -1; i = ne[i]) {
int j = e[i];
if (st[j] == false) dfs(j);
}
}
int main()
{
dfs(1); // 图中的起点是任意的。
}