链表

想象一下“寻宝游戏”。拿到的第一张纸条(称之为头节点 Head)指明第一个宝藏的位置,并且在纸条的末尾写着下一张纸条的藏匿地点。

这张纸条就是一个节点(Node),它包含两部分信息:

  1. 数据(Data):宝藏本身。
  2. 指针(Pointer):指向下一个节点的“地址”或“位置信息”。

当找到第二个宝藏时,那张纸条同样会指明宝藏是什么,以及第三张纸条的位置。这样顺着一张张纸条的指引,直到最后一张纸条上写着“游戏结束”,表示链表到此为止。

这就是链表的核心思想:它是一系列节点组成的链条,每个节点都知道下一个节点在哪里,但它们在内存中的物理位置不一定是连续的

链表的主要特点

  • 动态存储:与数组需要一块连续的大空间不同,链表的每个节点可以分散在内存的任何角落。需要新节点时才申请一个,非常灵活。
  • 高效的插入和删除:如果想在两个节点之间插入一个新节点,只需要改变前后两个节点的“指针”即可,无需像数组那样移动大量元素。删除同理。
  • 低效的访问:如果想找第 \(100\) 个节点,无法直接跳过去。必须从第一个节点(头节点)开始,顺着指针一个一个地数,直到第 \(100\) 个。这使得随机访问性能很差。

数组模拟链表

在算法竞赛中,常用的是一种特殊的“链表”,它使用数组来模拟,这种实现方式也叫静态链表,它完美地结合了数组和链表的优点。

核心思想

预先开辟几个数组:

  • e[N]value 数组,存储节点 i 的值。
  • ne[N]next 数组,存储节点 i 的下一个节点的数组下标
  • head:一个整型变量,存储头节点的下标。用 -1 代表空指针。
  • idx:一个整型变量,充当“内存池”的指针,表示下一个可用的节点应该存放在哪个下标。

核心操作

  • 初始化head = -1; idx = 0;
  • 在链表头插入一个值为 x 的节点
    1. 从“内存池”分配一个新节点:e[idx] = x;
    2. 新节点的 next 指向当前的头:ne[idx] = head;
    3. 更新头节点为新节点:head = idx;
    4. “内存池”指针后移:idx++;
  • 在下标为 k 的节点后插入一个值为 x 的节点
    1. 分配新节点:e[idx] = x;
    2. 新节点的 next 指向 k 的下一个节点:ne[idx] = ne[k];
    3. knext 指向新节点:ne[k] = idx;
    4. idx++;
  • 删除下标为 k 的节点的下一个节点
    • 直接让 knext 指向 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。如果直接把 Anext 指向它之前的位置,就再也找不到节点 B 了,链表就此“断裂”。

为了解决这个问题,引入三个“指针”:

  1. prev(前驱指针):指向当前节点反转后应该指向的那个节点。在开始时,原来的头节点将成为新的尾节点,所以它的 next 应该是空指针(在静态链表中是 -1)。因此,prev 的初始值为 -1
  2. curr(当前指针):指向当前正在处理的节点。它从 head 开始。
  3. next_node(临时指针):这是解决“断链”问题的关键。在修改 currnext 指针之前,用 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

核心思想:穿针引线法

  1. 准备工作:
    • 有两个链表的头节点下标,h1h2
    • 需要一个新的头节点 h_new 来代表合并后的链表。
    • 还需要一个“尾巴”下标 tail,它始终指向新链表的最后一个节点,方便把新节点接上去。
  2. 确定新链表的头:
    • 比较 e[h1]e[h2] 的值。哪个小,哪个就是新链表的头。
    • h_newtail 都指向这个较小的节点,并将对应的头指针(h1h2)向后移动一位。
  3. 循环穿针:
    • 当两个链表都还有节点时(h1 != -1h2 != -1),不断重复以下工作:
      • 比较 e[h1]e[h2] 的值。
      • 将值较小的那个节点(假设是 h1)“穿”到 tail 的后面。具体操作是:ne[tail] = h1;
      • 更新 tail,让它移动到刚刚接上的新节点上:tail = h1;
      • 被选中的那个链表的头指针向后移动:h1 = ne[h1];
  4. 收尾工作:
    • 循环结束后,最多只有一个链表还有剩余节点。
    • 只需要把 tailnext 指针指向这个剩余链表的头节点即可。即 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\)
  • 计算\(342+465=807\)
  • 返回结果:一个代表 \(807\) 的逆序链表,即 7 -> 0 -> 8

为什么“逆序”?因为这样符合小学时做加法的习惯——从个位开始,逐位相加,并处理进位。链表的头部正好是数字的个位,只需要从两个链表的头部开始同步向后遍历,就可以模拟这个过程。

核心思想:模拟竖式加法

算法的核心就是模拟手算加法的每一步:

  1. 初始化:
    • 需要一个新的链表来存储结果。因此,需要一个新链表的头 new_head 和一个尾 tail 来方便地添加新节点。
    • 需要一个变量 carry 来存储进位,初始值为 0
  2. 同步遍历与计算:
    • 同时从两个链表的头 h1h2 开始遍历。
    • 在每一步,取出当前两个节点的值 v1v2。(如果某个链表已经遍历完了,它的当前值就看作 0
    • 计算当前位的和:sum = v1 + v2 + carry
  3. 处理结果与进位:
    • sum 计算出来后,真正要存入新节点的值sum % 10(和的个位数)。
    • 新的进位sum / 10(和的十位数)。
    • 创建一个新节点,值为 sum % 10,并把它接到结果链表的尾部。
  4. 循环条件:
    • 只要两个链表中至少还有一个没有遍历完,或者最后还有一个进位 carry,循环就应该继续。
    • 这个 carry > 0 的判断非常重要。考虑 9 + 1 = 10,两个链表都遍历完了,但还有一个进位 1 需要处理,必须为它创建一个新节点。
  5. 构建新链表:
    • 在循环中,每计算出一位 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

核心思想:穿针引线,分组重排

这个“保持相对顺序”的约束,给了一个明确的提示:不能简单地通过交换节点来完成,因为交换会打乱顺序。

最直接、最清晰的思路是:创建两条全新的链表

  1. 小于链表(Less List):专门用来收集所有值小于 x 的节点。
  2. 大于等于链表(Greater-Equal List):专门用来收集所有值大于或等于 x 的节点。

算法流程:

  1. 初始化:创建两个虚拟的链表头和尾。在静态链表中,不需要真的创建节点,只需要用变量来代表它们的头和尾即可。
    • less_hless_t:用于“小于链表”。
    • ge_hge_t:用于“大于等于链表”。
    • 将这四个变量都初始化为 -1(代表空链表)。
  2. 遍历原链表:从头到尾遍历原始链表中的每一个节点。
  3. 节点归队:对于当前遍历到的节点 p
    • 如果 e[p] 的值小于 x,就将这个节点 p 追加到“小于链表”的尾部。
    • 如果 e[p] 的值大于或等于 x,就将这个节点 p 追加到“大于等于链表”的尾部。
  4. 拼接链表:当遍历完所有节点后,就得到了两条独立的、内部有序的链表。现在,只需要将它们拼接起来:
    • 将“小于链表”的尾巴 less_tnext 指针,指向“大于等于链表”的头 ge_h
  5. 处理边界:
    • 如果“小于链表”是空的(即所有节点都大于等于 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 的右边是 11 的左边是 0。即 r[0] = 1; l[1] = 0;
  • 整个链表逻辑上存在于 01 之间。
  • 这样做的好处是,任何节点的插入和删除操作都变成了在两个已有节点之间进行,逻辑完全统一,代码更加简洁。
#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;
}

posted @ 2024-08-05 08:24  RonChen  阅读(131)  评论(0)    收藏  举报