线性表

链表

想象一下“寻宝游戏”。拿到的第一张纸条(称之为头节点 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;
}

在超市购物的时候,超市入口处往往会放一排购物车。这一排购物车一个插在另一个的后面,顺序排列,第一个一般靠着墙,不好拿。这时候,如果想取走一辆,一般是先取走最后面的一辆,下一个来购物的顾客取走倒数第二辆。与之对应的,如果顾客用完了购物车,要把购物车还回去,那么归还的这辆购物车通常也是放在最后面。在通常情况下,最后面的车会被反复取用,而第一辆很少被用到,除非其他购物车都被取走了。

image


选择题:今有一空栈 S,对下列待进栈的数据元素序列 a,b,c,d,e,f 依次进行:进栈,进栈,出栈,进栈,进栈,出栈的操作,则此操作完成后,栈底元素为?

  • A. b
  • B. a
  • C. d
  • D. c
答案

B

[] → [a] → [a,b] → [a] → [a,c] → [a,c,d] → [a,c]

所有操作完成后,栈中还剩下两个元素,从栈底到栈顶依次是 a 和 c。


平常写表达式,一般运算符在数的中间,比如 \(1 + 3 \times 5\),其中 \(+\)\(1\)\(3 \times 5\) 之间,\(\times\)\(3\)\(5\) 中间,这种表达式称为中缀表达式。中缀表达式对人类友好,但对计算机没那么友好。对计算机友好的表达式是“后缀表达式”,顾名思义,后缀表达式中运算符在参数的后面。对于计算机而言,后缀表达式比中缀表达式更容易计算,另外,后缀表达式的运算机制可以避免掉括号,这也是它相对于中缀表达式的一大优势。

image

计算题:中缀表达式 ((6-3)*2+7)/(5^(3*4+2)) 对应的后缀表达式为?

答案

image


选择题:表达式 a*d-b*c 的前缀形式是?

  • A. ad*bc*-
  • B. -*ad*bc
  • C. a*d-b*c
  • D. -**adbc
答案

B

转换的核心是遵循运算符的优先级

在表达式 a*d - b*c 中,乘法 * 的优先级高于减法 -。因此,必须先计算两个乘法,再计算减法。可以为表达式加上括号来明确这个运算顺序:((a * d) - (b * c))

中缀表达式 (a * d) 转前缀则是将运算符 * 提到前面,得到 * a d。同理,中缀表达式 (b * c) 转换成前缀表达式得到 * b c

经过上一步,表达式现在可以看作是 (子表达式1) - (子表达式2),而这个表达式转前缀将运算符 - 提到最前面,得到 - 子表达式1 子表达式2

所以最终结果是:- * a d * b c


例题:P1449 后缀表达式

#include <cstdio>
#include <cstring>
#include <stack>
using std::stack;
const int N = 55;
char s[N];
int main()
{
	scanf("%s", s + 1);
	int len = strlen(s + 1);
	int num=0;
	stack<int> st; // 存储运算数的栈
	for (int i = 1; i <= len; i++) {
		if (s[i]>='0'&&s[i]<='9') {
			// 更新数字
			num=num*10+(s[i]-'0');
		} else if (s[i]=='.') {
			// num就作为一个运算的数字加入到栈中
			st.push(num);
			num=0;
		} else if (s[i]=='@') {
			break;
		} else {
			// +-*/
			// 取出栈顶的两个元素
			// top() 表示取栈顶
			// pop() 表示弹出栈顶
			// empty() 返回一个栈是否为空
			// true对应空,false对应非空
			int x=st.top(); st.pop();
			int y=st.top(); st.pop();
			// x和y就是最近两次压入栈中的运算数
			int z;
			if (s[i]=='+') {
				z=y+x;
			} else if (s[i]=='-') {
				z=y-x;
			} else if (s[i]=='*') {
				z=y*x;
			} else {
				z=y/x;
			}
			st.push(z);
		}
	}
	printf("%d\n", st.top());
    return 0;
}


例题:P4387 【深基15.习9】验证栈序列

给定两个序列,保证序列一定是 \(1\)\(n\) 的某个排列,判断按照第一个序列中的顺序入栈是否有可能产生出栈序列去匹配第二个序列。

解决这个问题的最直接方法就是模拟整个过程,可以借助一个辅助栈,严格按照入栈序列的顺序将元素压入,并在适当的时候检查是否能匹配出栈序列。

具体步骤如下:

  1. 创建一个空的辅助栈 \(s\)
  2. 使用两个索引,一个 \(p\) 指向入栈序列 \(in\) 的当前元素,一个 \(i\) 指向出栈序列 \(out\) 的当前元素。
  3. 遍历出栈序列 \(out\) 中的每一个元素 \(out_i\),对于当前的 \(out_i\),考虑它能被合法地弹出吗?
  4. 一个元素能被弹出的唯一前提是它正位于栈顶。
  5. 因此,检查辅助栈 \(s\) 的栈顶。
    • 如果栈为空,或者栈顶元素不是期望弹出的 \(out_i\),就必须从入栈序列 \(in\) 中继续压入元素,直到栈顶是想要的 \(out_i\) 为止,不断地执行入栈动作并递增 \(p\)
    • 如果在所有 \(in\) 序列的元素都压入栈后,栈顶依然不是 \(out_i\),那就意味着 \(out_i\) 永远无法在这个时间点被弹出。因此,这个出栈序列是不合法的。
    • 如果经过若干次(可能 0 次)压栈后,栈顶元素成功变成了 \(out_i\),那就说明这个弹出操作是合法的。立即将该元素从辅助栈 \(s\) 中弹出,并继续考察出栈序列的下一个元素 \(out_{i+1}\)
  6. 如果能顺利地遍历完整个出栈序列 \(out\),并成功匹配和弹出了所有 \(n\) 个元素,那么这个序列就是合法的。反之,如果在任何一步卡住了,它就是不合法的。
参考代码
#include <cstdio>
#include <stack> // 引入栈容器

using namespace std;

const int N = 100005; // 定义数组最大长度
int in[N];  // 存储入栈序列
int out[N]; // 存储出栈序列

// 处理单次查询的函数
void solve() {
    int n; 
    scanf("%d", &n); // 读取序列长度

    // 读取 n 个数作为入栈序列
    for (int i = 1; i <= n; i++) {
        scanf("%d", &in[i]);
    }
    // 读取 n 个数作为出栈序列
    for (int i = 1; i <= n; i++) {
        scanf("%d", &out[i]);
    }

    stack<int> s; // 辅助栈,用于模拟实际的栈操作
    int p = 1;      // 指向下一个要入栈的元素在 in 序列中的位置

    // 遍历给定的出栈序列 out
    for (int i = 1; i <= n; i++) {
        // 当栈为空,或者栈顶元素不等于当前期望出栈的元素时,
        // 就需要从入栈序列 in 中继续获取元素并压入栈中。
        // 同时要保证 p 没有越界。
        while (p <= n && (s.empty() || s.top() != out[i])) {
            s.push(in[p]); // 将 in[p] 压入辅助栈
            p++;           // p 指向下一个
        }

        // 经过上面的 while 循环后,检查栈顶元素是否是期望出栈的元素
        // 如果是,说明本次出栈操作合法
        if (!s.empty() && s.top() == out[i]) {
            s.pop(); // 模拟出栈
        } 
        // 如果不是,说明已经无法让 out[i] 合法出栈,提前结束
        else {
            printf("No\n");
            return;
        }
        
    }

    // 如果前面没有判断出 No,说明这个出栈序列能匹配成功
    printf("Yes\n");
}

int main()
{
    int q; 
    scanf("%d", &q); // 读取询问次数
    // 循环处理 q 次询问
    for (int i = 1; i <= q; i++) {
        solve();
    }
    return 0;
}

选择题:若元素 a、b、c、d、e、f 依次进栈,允许进栈、退栈操作交替进行,但不允许连续三次退栈操作,则不可能得到的出栈序列是?

  • A. dcebfa
  • B. cbdaef
  • C. bcaefd
  • D. afedcb
答案

正确答案是 D

  1. a 进栈,栈:[a]
  2. a 出栈(第 1 次连续出栈),序列:a,栈:[]
  3. 为了让 f 出栈,必须将 b, c, d, e, f 全部依次进栈,栈:[b, c, d, e, f]
  4. f 出栈(第 1 次连续出栈),序列:af,栈:[b, c, d, e]
  5. e 出栈(第 2 次连续出栈),序列:afe,栈:[b, c, d]
  6. d 出栈(第 3 次连续出栈),序列:afed,栈:[b, c]此时发生了连续三次出栈(f, e, d),违反了“不允许连续三次退栈操作”的规则

因此,序列 afedcb 是不可能得到的。


P2201

#include <cstdio>
#include <stack>
#include <algorithm>
using std::stack;
using std::max;
const int N = 1e6 + 5;
struct Data {
	int x,s,maxs;
};
Data a[N]; // 管理光标前的元素、前缀和、最大前缀和
int top; // 给数组a配套的栈顶位置
char op[5];
int main()
{
	a[0].s=0; a[0].maxs=-2000; // 要给一个比-1000小的数
	int n; scanf("%d",&n);
	stack<int> s2; // 管理光标后的元素
	for (int i=1;i<=n;i++) {
		scanf("%s",op);
		char ch=op[0];
		if (ch=='I') {
			int x; scanf("%d",&x);
			// 这里要push一个Data类型的元素
			// {x,s,maxs}
			top++;
			a[top].x = x;
			a[top].s = a[top-1].s+x;
			a[top].maxs = max(a[top-1].maxs, a[top].s);
 		} else if (ch=='D') {
			top--;
		} else if (ch=='L') {
			s2.push(a[top].x); top--;
		} else if (ch=='R') {
			int x=s2.top();
			top++;
			a[top].x = x;
			a[top].s = a[top-1].s+x;
			a[top].maxs = max(a[top-1].maxs, a[top].s);
			s2.pop();
		} else {
			int k; scanf("%d",&k);
			printf("%d\n", a[k].maxs);
		}
	}
    return 0;
}

队列

队列是一种 先进先出(First-In, First-Out, FIFO) 的数据结构。可以把它想想成现实生活中的排队。

  • 入队(Enqueue/Push):新来的人总是站到队伍的末尾
  • 出队(Dequeue/Pop):每次办理业务的总是站在队伍最前面的人。
  • 队头(Front):队伍的最前面。
  • 队尾(Back/Rear):队伍的末尾。

image


数组实现

最直接、最朴素的实现队列的想法是什么?

  1. 准备一个足够大的数组 q
  2. 用一个指针 tail(队尾)来记录新元素应该插入的位置。每次入队,tail 就向后移动。
  3. 用一个指针 head(队头)来记录队头元素的位置。每次出队,head 就向后移动。

选择题:下述代码实现的数据结构是

int data[100], f = 1, r;
void insert(int value) {
	data[++r] = value;
}
void pop() {
	f++;
}

A. 链表
B. 栈
C. 队列
D. 平衡树

答案

C


P1540

#include <cstdio>
#include <queue>
using std::queue;
const int N = 1005;
bool inq[N]; // inq[i]表示i是否在队列中
int main()
{
	int m, n; scanf("%d%d",&m,&n);
	queue<int> q;
	int cnt=0; // 队列中元素个数
	int ans=0;
	for (int i=1;i<=n;i++) {
		int x; scanf("%d",&x);
		if (!inq[x]) {
			if (cnt>=m) {
				inq[q.front()]=false;
				cnt--;
				q.pop();
			} 
			q.push(x); inq[x]=true; cnt++;
			ans++;
		}
	}
	printf("%d\n",ans);
    return 0;
}

P2058

#include <cstdio>
#include <queue>
using std::queue;
const int N = 1e5 + 5;
struct Person {
	int t, id;
};
int cnt[N]; // cnt[i] i国家的人在船上有多少个
int main()
{
	queue<Person> q;
	int n; scanf("%d",&n);
	int ans=0; // 船上不同国籍数
	for (int i=1;i<=n;i++) {
		int t,k; scanf("%d%d",&t,&k);
		// 上船
		for (int j=1;j<=k;j++) {
			int x; scanf("%d",&x);
			q.push({t,x}); cnt[x]++;
			if (cnt[x]==1) { // 只有0->1说明国籍数+1
				ans++;
			}
		}
		// 一天前的人下船
		while (q.front().t <= t-86400) {
			int x=q.front().id;
			cnt[x]--;
			if (cnt[x]==0) { // 只有1->0说明国籍数-1
				ans--;
			}
			q.pop();
		}
		printf("%d\n",ans);
	}
    return 0;
}

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