链表
想象一下“寻宝游戏”。拿到的第一张纸条(称之为头节点 Head)指明第一个宝藏的位置,并且在纸条的末尾写着下一张纸条的藏匿地点。
这张纸条就是一个节点(Node),它包含两部分信息:
- 数据(Data):宝藏本身。
- 指针(Pointer):指向下一个节点的“地址”或“位置信息”。
当找到第二个宝藏时,那张纸条同样会指明宝藏是什么,以及第三张纸条的位置。这样顺着一张张纸条的指引,直到最后一张纸条上写着“游戏结束”,表示链表到此为止。
这就是链表的核心思想:它是一系列节点组成的链条,每个节点都知道下一个节点在哪里,但它们在内存中的物理位置不一定是连续的。
链表的主要特点:
- 动态存储:与数组需要一块连续的大空间不同,链表的每个节点可以分散在内存的任何角落。需要新节点时才申请一个,非常灵活。
- 高效的插入和删除:如果想在两个节点之间插入一个新节点,只需要改变前后两个节点的“指针”即可,无需像数组那样移动大量元素。删除同理。
- 低效的访问:如果想找第 \(100\) 个节点,无法直接跳过去。必须从第一个节点(头节点)开始,顺着指针一个一个地数,直到第 \(100\) 个。这使得随机访问性能很差。
数组模拟链表
在算法竞赛中,常用的是一种特殊的“链表”,它使用数组来模拟,这种实现方式也叫静态链表,它完美地结合了数组和链表的优点。
核心思想
预先开辟几个数组:
e[N]:value数组,存储节点i的值。ne[N]:next数组,存储节点i的下一个节点的数组下标。head:一个整型变量,存储头节点的下标。用-1代表空指针。idx:一个整型变量,充当“内存池”的指针,表示下一个可用的节点应该存放在哪个下标。
核心操作:
- 初始化:
head = -1; idx = 0; - 在链表头插入一个值为
x的节点:- 从“内存池”分配一个新节点:
e[idx] = x; - 新节点的
next指向当前的头:ne[idx] = head; - 更新头节点为新节点:
head = idx; - “内存池”指针后移:
idx++;
- 从“内存池”分配一个新节点:
- 在下标为
k的节点后插入一个值为x的节点:- 分配新节点:
e[idx] = x; - 新节点的
next指向k的下一个节点:ne[idx] = ne[k]; k的next指向新节点:ne[k] = idx;idx++;
- 分配新节点:
- 删除下标为
k的节点的下一个节点:- 直接让
k的next指向k的下下个节点:ne[k] = ne[ne[k]];
- 直接让
#include <cstdio>
const int N = 100005;
// e[i] 表示节点 i 的值
// ne[i] 表示节点 i 的 next 指针是多少
// head 表示头节点的下标
// idx 存储当前已经用到了哪个点
int e[N], ne[N], head, idx;
void init() { // 初始化
head = -1; idx = 0;
}
void add_to_head(int x) { // 将 x 插到头节点
e[idx] = x; ne[idx] = head; head = idx; idx++;
}
void add(int k, int x) { // 将 x 插到下标是 k 的节点后面
e[idx] = x; ne[idx] = ne[k]; ne[k] = idx; idx++;
}
void remove(int k) { // 将下标是 k 的节点的下一个节点删掉
ne[k] = ne[ne[k]];
}
void traverse() { // 遍历打印链表
for (int i = head; i != -1; i = ne[i]) {
printf("%d -> ", e[i]);
}
printf("null\n");
}
int main()
{
init();
add_to_head(10); // 10 -> null
add_to_head(20); // 20 -> 10 -> null
add_to_head(30); // 30 -> 20 -> 10 -> null
traverse();
// 在下标为 1 的节点(值为 20)后面插入 5
// 注意:这里的 k=1 是指数组下标,不是链表第几个元素
// 30(idx=2) -> 20(idx=1) -> 10(idx=0)
// 要在 20 后面插入,20 的下标是 1
add(1, 5); // 30 -> 20 -> 5 -> 10 -> null
traverse();
// 删除下标为 1 的节点(值为 20)的下一个节点(值为 5)
remove(1); // 30 -> 20 -> 10 -> null
traverse();
return 0;
}
链表反转
假设有一个单链表,它的连接关系是:head -> A -> B -> C -> null。
链表反转的目标就是将链表中所有节点的“指针”方向完全颠倒,变成:null <- A <- B <- C <- head。
最终,新的头节点将是原来的尾节点 C。
核心思想:迭代反转法
直接在遍历中修改指针会遇到一个核心问题:一旦修改了当前节点的 next 指针,就会丢失去往下一个节点的路径。
比如,在 A -> B -> C 中,处理节点 A。如果直接把 A 的 next 指向它之前的位置,就再也找不到节点 B 了,链表就此“断裂”。
为了解决这个问题,引入三个“指针”:
prev(前驱指针):指向当前节点反转后应该指向的那个节点。在开始时,原来的头节点将成为新的尾节点,所以它的next应该是空指针(在静态链表中是-1)。因此,prev的初始值为-1。curr(当前指针):指向当前正在处理的节点。它从head开始。next_node(临时指针):这是解决“断链”问题的关键。在修改curr的next指针之前,用next_node提前备份curr原来的下一个节点。这样,即使curr的指针被修改了,依然能通过next_node找到正确的下一个节点继续处理。
这个过程的核心是“备份后继,反转当前,整体前进”。
#include <cstdio>
const int N = 100005;
// e[i] 表示节点 i 的值
// ne[i] 表示节点 i 的 next 指针是多少
// head 表示头节点的下标
// idx 存储当前已经用到了哪个节点
int e[N], ne[N], head, idx;
void init() { // 初始化
head = -1; idx = 0;
}
void add_to_head(int x) { // 将x插到头节点
e[idx] = x; ne[idx] = head; head = idx; idx++;
}
void traverse() { // 遍历打印链表
for (int i = head; i != -1; i = ne[i]) {
printf("%d -> ", e[i]);
}
printf("null\n");
}
void reverse_list() { // 链表反转函数
if (head == -1 || ne[head] == -1) {
return; // 空链表或只有一个节点的链表不需要反转
}
int prev = -1, curr = head;
while (curr != -1) {
// 1. 备份下一个节点,防止断链
int next_node = ne[curr];
// 2. 反转当前节点的指针
ne[curr] = prev;
// 3. 三个指针集体向后移动
prev = curr; curr = next_node;
}
// 4. 循环结束后,prev指向的是新的头节点,更新head
head = prev;
}
int main()
{
init();
add_to_head(10);
add_to_head(20);
add_to_head(30);
add_to_head(40);
printf("原始链表:");
traverse(); // 输出:40 -> 30 -> 20 -> 10 -> null
reverse_list();
printf("反转后链表:");
traverse(); // 输出:10 -> 20 -> 30 -> 40 -> null
return 0;
}
合并两个升序链表
给定两个已经按升序排好序的链表,需要把它们合并成一个,并且新的链表也要保持升序。
- 链表 1(头节点
h1):1 -> 3 -> 5 - 链表 2(头节点
h2):2 -> 4 -> 6 - 合并后(新头节点
h_new):1 -> 2 -> 3 -> 4 -> 5 -> 6
核心思想:穿针引线法
- 准备工作:
- 有两个链表的头节点下标,
h1和h2。 - 需要一个新的头节点
h_new来代表合并后的链表。 - 还需要一个“尾巴”下标
tail,它始终指向新链表的最后一个节点,方便把新节点接上去。
- 有两个链表的头节点下标,
- 确定新链表的头:
- 比较
e[h1]和e[h2]的值。哪个小,哪个就是新链表的头。 - 将
h_new和tail都指向这个较小的节点,并将对应的头指针(h1或h2)向后移动一位。
- 比较
- 循环穿针:
- 当两个链表都还有节点时(
h1 != -1且h2 != -1),不断重复以下工作:- 比较
e[h1]和e[h2]的值。 - 将值较小的那个节点(假设是
h1)“穿”到tail的后面。具体操作是:ne[tail] = h1;。 - 更新
tail,让它移动到刚刚接上的新节点上:tail = h1;。 - 被选中的那个链表的头指针向后移动:
h1 = ne[h1];。
- 比较
- 当两个链表都还有节点时(
- 收尾工作:
- 循环结束后,最多只有一个链表还有剩余节点。
- 只需要把
tail的next指针指向这个剩余链表的头节点即可。即ne[tail] = (h1 != -1) ? h1 : h2;。
一个重要的细节:这个算法没有创建任何新节点。它只是巧妙地修改了原有节点的 ne 数组的值,将两个链表重新“编织”在了一起。因此,它的空间复杂度是 \(O(1)\)。
时间复杂度为 \(O(n+m)\),\(n\) 和 \(m\) 是两个链表的长度。
#include <cstdio>
#include <vector>
using std::vector;
const int N = 100005;
// e[i] 存储节点i的值
// ne[i] 存储节点i的下一个节点的下标
int e[N], ne[N];
int idx; // 全局内存池指针
// 创建一个静态链表并返回头节点下标,vals必须是升序的
int create_list(const vector<int> &vals) {
if (vals.empty()) return -1;
int head = -1, tail = -1;
for (int val : vals) {
e[idx] = val; ne[idx] = -1;
if (head == -1) {
head = tail = idx;
} else {
ne[tail] = idx; tail = idx;
}
idx++;
}
return head;
}
void traverse(int head) { // 遍历打印链表
for (int i = head; i != -1; i = ne[i]) {
printf("%d -> ", e[i]);
}
printf("null\n");
}
// 合并两个升序链表的函数
// h1:第一个链表的头节点下标
// h2:第二个链表的头节点下标
// 返回合并后链表的头节点下标
int merge_lists(int h1, int h2) {
// 如果任意一个链表为空,直接返回另一个
if (h1 == -1) return h2;
if (h2 == -1) return h1;
int new_head = -1, tail = -1;
// 1. 确定新链表的头节点
if (e[h1] < e[h2]) {
new_head = tail = h1;
h1 = ne[h1];
} else {
new_head = tail = h2;
h2 = ne[h2];
}
// 2. 循环穿针引线
while (h1 != -1 && h2 != -1) {
if (e[h1] < e[h2]) {
ne[tail] = h1; // 把h1接到尾部
tail = h1; // 更新尾部
h1 = ne[h1]; // h1后移
} else {
ne[tail] = h2; // 把h2接到尾部
tail = h2; // 更新尾部
h2 = ne[h2]; // h2后移
}
}
// 3. 处理剩余部分
ne[tail] = (h1 != -1) ? h1 : h2;
return new_head;
}
int main()
{
// 创建两个升序链表
int h1 = create_list({1, 3, 5});
int h2 = create_list({2, 4, 6, 8});
printf("链表1: "); traverse(h1);
printf("链表2: "); traverse(h2);
// 合并链表
int merged_head = merge_lists(h1, h2);
printf("合并后的链表:"); traverse(merged_head);
return 0;
}
两数相加
给两个非空的链表,表示两个非负的整数。它们每位数字都是按照逆序的方式存储的,并且每个节点只能存储一位数字。请将两个数相加,并以相同形式返回一个表示和的链表。可以假设除了数字 \(0\) 之外,这两个数都不会以 \(0\) 开头。
- 示例:
- 链表 1:
2 -> 4 -> 3,代表数字 \(342\) - 链表 2:
5 -> 6 -> 4,代表数字 \(465\)
- 链表 1:
- 计算:\(342+465=807\)
- 返回结果:一个代表 \(807\) 的逆序链表,即
7 -> 0 -> 8
为什么“逆序”?因为这样符合小学时做加法的习惯——从个位开始,逐位相加,并处理进位。链表的头部正好是数字的个位,只需要从两个链表的头部开始同步向后遍历,就可以模拟这个过程。
核心思想:模拟竖式加法
算法的核心就是模拟手算加法的每一步:
- 初始化:
- 需要一个新的链表来存储结果。因此,需要一个新链表的头
new_head和一个尾tail来方便地添加新节点。 - 需要一个变量
carry来存储进位,初始值为0。
- 需要一个新的链表来存储结果。因此,需要一个新链表的头
- 同步遍历与计算:
- 同时从两个链表的头
h1和h2开始遍历。 - 在每一步,取出当前两个节点的值
v1和v2。(如果某个链表已经遍历完了,它的当前值就看作0) - 计算当前位的和:
sum = v1 + v2 + carry。
- 同时从两个链表的头
- 处理结果与进位:
sum计算出来后,真正要存入新节点的值是sum % 10(和的个位数)。- 新的进位是
sum / 10(和的十位数)。 - 创建一个新节点,值为
sum % 10,并把它接到结果链表的尾部。
- 循环条件:
- 只要两个链表中至少还有一个没有遍历完,或者最后还有一个进位
carry,循环就应该继续。 - 这个
carry > 0的判断非常重要。考虑9 + 1 = 10,两个链表都遍历完了,但还有一个进位1需要处理,必须为它创建一个新节点。
- 只要两个链表中至少还有一个没有遍历完,或者最后还有一个进位
- 构建新链表:
- 在循环中,每计算出一位
sum % 10,就创建一个新节点,并把它链接到tail后面,然后更新tail。
- 在循环中,每计算出一位
#include <cstdio>
#include <vector>
using std::vector;
const int N = 100005;
// e[i] 存储节点i的值
// ne[i] 存储节点i的下一个节点的下标
int e[N], ne[N];
int idx; // 全局内存池指针
void init() {
idx = 0;
}
// 创建一个静态链表并返回头节点下标
int create_list(const vector<int> &vals) {
if (vals.empty()) return -1;
int head = -1, tail = -1;
for (int val : vals) {
e[idx] = val; ne[idx] = -1;
if (head == -1) {
head = tail = idx;
} else {
ne[tail] = idx; tail = idx;
}
idx++;
}
return head;
}
void traverse(int head) { // 遍历打印链表
for (int i = head; i != -1; i = ne[i]) {
printf("%d -> ", e[i]);
}
printf("null\n");
}
// 两个链表相加的函数
// h1:第一个链表的头节点下标
// h2:第二个链表的头节点下标
// 返回结果链表的头节点下标
int addTwoNumbers(int h1, int h2) {
int new_head = -1, tail = -1;
int carry = 0; // 进位
// 循环条件:只要两个链表没走完,或者还有进位,就继续
while (h1 != -1 || h2 != -1 || carry > 0) {
int sum = carry;
// 如果链表1还有节点,加上它的值
if (h1 != -1) {
sum += e[h1];
h1 = ne[h1]; // h1后移
}
// 如果链表2还有节点,加上它的值
if (h2 != -1) {
sum += e[h2];
h2 = ne[h2]; // h2后移
}
// 创建新节点来存储当前位的结果
e[idx] = sum % 10; ne[idx] = -1;
// 将新节点接到结果链表的尾部
if (new_head == -1) {
new_head = tail = idx;
} else {
ne[tail] = idx; tail = idx;
}
idx++; // 内存池指针后移
carry = sum / 10; // 更新进位
}
return new_head;
}
int main()
{
init();
int h1 = create_list({2, 4, 3}); // 链表1:2 -> 4 -> 3(代表342)
int h2 = create_list({5, 6, 4}); // 链表2:5 -> 6 -> 4(代表465)
printf("链表1: "); traverse(h1);
printf("链表2: "); traverse(h2);
int result_head = addTwoNumbers(h1, h2); // 计算结果
// 预期结果:7 -> 0 -> 8(代表807)
printf("相加结果:"); traverse(result_head);
// 测试有进位的情况
init();
int h3 = create_list({9, 9}); // 99
int h4 = create_list({1}); // 1
printf("链表3: "); traverse(h3);
printf("链表4: "); traverse(h4);
int result_head2 = addTwoNumbers(h3, h4);
// 预期结果:0 -> 0 -> 1(代表100)
printf("相加结果:"); traverse(result_head2);
return 0;
}
划分链表
给一个链表的头节点 head 和一个特定值 x,对链表进行分隔,使得所有小于 x 的节点都出现在大于或等于 x 的节点之前。需要保留两个分区中每个节点的初始相对位置。
- 示例:
- 链表:
head -> 3 -> 5 -> 8 -> 1 -> 2 -> 4 - 给定值
x = 4
- 链表:
- 分析:
- 小于
4的节点有:3, 1, 2。它们在原链表中的顺序就是3在前,1在中,2在后。 - 大于或等于
4的节点有:5, 8, 4。它们在原链表中的顺序是5, 8, 4。
- 小于
- 最终结果:
- 将这两组按顺序拼接起来:
(3 -> 1 -> 2) + (5 -> 8 -> 4) - 结果链表:
3 -> 1 -> 2 -> 5 -> 8 -> 4
- 将这两组按顺序拼接起来:
核心思想:穿针引线,分组重排
这个“保持相对顺序”的约束,给了一个明确的提示:不能简单地通过交换节点来完成,因为交换会打乱顺序。
最直接、最清晰的思路是:创建两条全新的链表。
- 小于链表(Less List):专门用来收集所有值小于
x的节点。 - 大于等于链表(Greater-Equal List):专门用来收集所有值大于或等于
x的节点。
算法流程:
- 初始化:创建两个虚拟的链表头和尾。在静态链表中,不需要真的创建节点,只需要用变量来代表它们的头和尾即可。
less_h和less_t:用于“小于链表”。ge_h和ge_t:用于“大于等于链表”。- 将这四个变量都初始化为
-1(代表空链表)。
- 遍历原链表:从头到尾遍历原始链表中的每一个节点。
- 节点归队:对于当前遍历到的节点
p:- 如果
e[p]的值小于x,就将这个节点p追加到“小于链表”的尾部。 - 如果
e[p]的值大于或等于x,就将这个节点p追加到“大于等于链表”的尾部。
- 如果
- 拼接链表:当遍历完所有节点后,就得到了两条独立的、内部有序的链表。现在,只需要将它们拼接起来:
- 将“小于链表”的尾巴
less_t的next指针,指向“大于等于链表”的头ge_h。
- 将“小于链表”的尾巴
- 处理边界:
- 如果“小于链表”是空的(即所有节点都大于等于
x),那么结果就是“大于等于链表”本身。 - 如果“大于等于链表”是空的(即所有节点都小于
x),那么结果就是“小于链表”本身。 - 一个非常重要的细节:拼接后,新的总链表的尾巴是原“大于等于链表”的尾巴
ge_t。需要确保这个尾巴的next指针是-1,以表示链表的结束,防止形成环或指向无关节点。
- 如果“小于链表”是空的(即所有节点都大于等于
#include <cstdio>
#include <vector>
using std::vector;
const int N = 100005;
// e[i] 存储节点i的值
// ne[i] 存储节点i的下一个节点的下标
int e[N], ne[N];
int idx; // 全局内存池指针
// 创建一个静态链表并返回头节点下标
int create_list(const vector<int> &vals) {
if (vals.empty()) return -1;
int head = -1, tail = -1;
for (int val : vals) {
e[idx] = val; ne[idx] = -1;
if (head == -1) {
head = tail = idx;
} else {
ne[tail] = idx; tail = idx;
}
idx++;
}
return head;
}
void traverse(int head) { // 遍历打印链表
for (int i = head; i != -1; i = ne[i]) {
printf("%d -> ", e[i]);
}
printf("null\n");
}
// 划分链表的函数
// head: 原始链表的头节点下标
// x: 划分的基准值
// 返回新链表的头节点下标
int partition(int head, int x) {
if (head == -1) return -1;
// less_h/t: 小于链表的头和尾
int less_h = -1, less_t = -1;
// ge_h/t: 大于等于链表的头和尾
int ge_h = -1, ge_t = -1;
// 1. 遍历原链表,将节点分配到两个新链表中
for (int p = head; p != -1; p = ne[p]) {
if (e[p] < x) { // 加入“小于链表”
if (less_h == -1) {
less_h = less_t = p;
} else {
ne[less_t] = p; less_t = p;
}
} else { // 加入“大于等于链表”
if (ge_h == -1) {
ge_h = ge_t = p;
} else {
ne[ge_t] = p; ge_t = p;
}
}
}
// 2. 处理边界情况和拼接
if (less_h == -1) return ge_h; // 如果小于链表为空,则结果就是大于等于链表
ne[less_t] = ge_h; // 拼接:将小于链表的尾部指向大于等于链表的头部
// 重要:将新链表的尾部(即原大于等于链表的尾部)的next置为-1
// 防止它还指向链表中的其他节点
if (ge_t != -1) ne[ge_t] = -1;
return less_h;
}
int main()
{
// 链表:3 -> 5 -> 8 -> 1 -> 2 -> 4
int head = create_list({3, 5, 8, 1, 2, 4});
int x = 4;
printf("原始链表:"); traverse(head);
printf("划分值 x = %d\n", x);
int new_head = partition(head, x); // 执行划分
// 预期结果:3 -> 1 -> 2 -> 5 -> 8 -> 4 -> null
printf("划分后链表:"); traverse(new_head);
return 0;
}
双向链表
双向链表比单向链表多一个指向前驱节点的指针。这让双向遍历和某些删除操作变得更方便。
e[N]:存储节点值。l[N]:left数组,存储节点i的前一个节点的下标。r[N]:right数组,存储节点i的后一个节点的下标。idx:内存池指针。
技巧:哨兵节点(Sentinel Nodes)
为了避免处理烦人的边界条件(比如在头节点前插入、删除尾节点等),引入两个哨兵节点。
- 初始化时,
0的右边是1,1的左边是0。即r[0] = 1; l[1] = 0; - 整个链表逻辑上存在于
0和1之间。 - 这样做的好处是,任何节点的插入和删除操作都变成了在两个已有节点之间进行,逻辑完全统一,代码更加简洁。
#include <cstdio>
const int N = 100005;
int e[N], l[N], r[N], idx;
// 初始化
// 0 是左端点/头哨兵,1 是右端点/尾哨兵
void init() {
r[0] = 1; l[1] = 0; idx = 2;
}
void insert(int k, int x) { // 在下标是k的节点右边,插入一个x
e[idx] = x;
r[idx] = r[k];
l[idx] = k;
l[r[k]] = idx; // 核心:k的右邻居的左指针指向新节点
r[k] = idx;
idx++;
}
void remove(int k) { // 删除下标为k的节点
r[l[k]] = r[k]; l[r[k]] = l[k];
}
void traverse() { // 遍历打印
printf("null <-> ");
// 从头哨兵的右边开始
for (int i = r[0]; i != 1; i = r[i]) {
printf("%d <-> ", e[i]);
}
printf("null\n");
}
int main()
{
init();
// 在头哨兵(0)右边插入10,相当于头插
insert(0, 10); // 10
// 在头哨兵(0)右边插入20,相当于头插
insert(0, 20); // 20 <-> 10
// 在尾哨兵(1)的左边插入30,相当于尾插
// 1的左边是10(下标2),所以在10的右边插入30
insert(l[1], 30); // 20 <-> 10 <-> 30
traverse();
// 删除值为10的节点(它的下标是2)
remove(2);
traverse(); // 20 <-> 30
return 0;
}
P1160
#include <cstdio>
const int N = 1e5 + 5;
int left[N], right[N];
bool del[N]; // del记录是否被删过
int main()
{
int n; scanf("%d", &n);
right[0]=1; left[1]=0;
right[1]=n+1; left[n+1]=1; // 0<->1<->n+1
for (int i=2;i<=n;i++) {
int k,p; scanf("%d%d", &k,&p);
if (p==0) {
// 插入到k的左边
int l=left[k];
// l<->k 变成 l<->i<->k
right[l]=i; left[i]=l;
right[i]=k; left[k]=i;
} else {
// 插入到k的右边
int r=right[k];
// k<->r 变成 k<->i<->r
right[k]=i; left[i]=k;
right[i]=r; left[r]=i;
}
}
// 0<->....<->n+1
int m; scanf("%d", &m);
for (int i=1;i<=m;i++) {
int x; scanf("%d",&x);
if (!del[x]) {
int l=left[x],r=right[x];
// l<->x<->r 变成 l<->r
right[l]=r; left[r]=l;
del[x]=true;
}
}
// 0<->....<->n+1
int x=right[0];
// right[x] right[right[x]] ...
while (x!=n+1) {
printf("%d ", x);
x=right[x];
}
return 0;
}

浙公网安备 33010602011771号