数据结构六讲 —— 从栈到线段树

📅 2026年07月19日 ✍️ YHSome
未分类

数据结构六讲 —— 从栈到线段树

题解基于 AtCoder ABC 六道真题,涵盖六种核心数据结构,难度从 200pt 逐级爬升至 525pt。
每道题提供 JavaC++ 双语言代码,C++ 版本附详细解析。


目录

讲次 数据结构 题目 分数 AtCoder 链接
Stack 栈 ABC 043 B — Unhappy Hacking 200 🔗
Hash Map 哈希表 ABC 137 C — Green Bin 300 🔗
Queue 队列 ABC 389 C — Snake Queue 300 🔗
Priority Queue 优先队列 ABC 141 D — Powerful Discount Tickets 400 🔗
Heap + Sweep Line 堆+扫描线 ABC 325 D — Printing Machine 450 🔗
Segment Tree 线段树 ABC 339 E — Smooth Subsequence 525 🔗

第一讲:栈(Stack)

📋 原题 — ABC 043 B:Unhappy Hacking (ABC Edit)

分数:200pt | 数据结构:Stack

题目描述

Sig 自制了一个只有三个键的键盘:

按键 效果
0 在屏幕字符串末尾追加一个 0
1 在屏幕字符串末尾追加一个 1
B 退格:删除最右边的字符(屏幕为空时无操作)

初始屏幕为空。给定一个按键序列字符串 s(由 01B 组成),输出最终屏幕上的字符串。

约束

  • 1 ≤ |s| ≤ 10
  • s 只包含 01B
  • 保证答案不是空串

样例

输入:01B0
过程:"" → "0" → "01" → "0" → "00"
输出:00

输入:0BB1
过程:"" → "0" → "" → "" → "1"
输出:1

📚 前置知识:什么是栈(Stack)?

定义

是一种后进先出(LIFO, Last In First Out)的线性数据结构。你可以把它想象成一摞盘子:最后放上去的盘子,最先被取走。

核心操作

操作 描述 Java C++
push(x) 将元素 x 压入栈顶 add(x) / push(x) push(x)
pop() 弹出栈顶元素 pop() / removeLast() pop()
peek() 查看栈顶元素(不弹出) peek() top()
isEmpty() 判断栈是否为空 isEmpty() empty()

图示

push(1):    push(2):    push(3):    pop()→3:
┌───┐       ┌───┐       ┌───┐       ┌───┐
│   │       │   │       │ 3 │← 栈顶   │   │
│   │       │ 2 │← 栈顶   │ 2 │       │ 2 │← 栈顶
│ 1 │← 栈底   │ 1 │       │ 1 │       │ 1 │
└───┘       └───┘       └───┘       └───┘

经典应用场景

  • 文本编辑器中的撤销/重做
  • 浏览器的前进/后退
  • 编译器中的括号匹配
  • 函数调用栈

💻 Java 代码

import java.util.ArrayList;
import java.util.Scanner;

public class A_soln {
    public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);
        char[] s = sc.next().toCharArray();
        ArrayList<Character> arrayList = new ArrayList<>();
        for (char i : s) {
            switch (i) {
                case '0':
                    arrayList.add('0');
                    break;
                case '1':
                    arrayList.add('1');
                    break;
                default:
                    if (!arrayList.isEmpty()) arrayList.removeLast();
            }
        }
        for (char i : arrayList) {
            System.out.print(i);
        }
    }
}

💻 C++ 代码(详细解析)

#include <iostream>
#include <string>
#include <vector>
using namespace std;

int main() {
    string s;
    cin >> s;

    vector<char> stk;  // 用 vector 模拟栈

    for (char c : s) {
        if (c == '0' || c == '1') {
            // 按 0 或 1 → 压栈(追加到末尾)
            stk.push_back(c);
        } else {  // c == 'B',退格
            // 栈非空时才弹栈,防止越界
            if (!stk.empty()) {
                stk.pop_back();  // 删除最后一个字符 = pop
            }
        }
    }

    // 输出最终栈中所有字符(从栈底到栈顶)
    for (char c : stk) {
        cout << c;
    }
    cout << endl;

    return 0;
}

逐段解析

vector<char> stk;  // 用 vector 模拟栈

C++ 中 std::stack 也可以,但 vector 更灵活——可以直接遍历输出。push_back()pop_back() 分别对应压栈和弹栈。

for (char c : s) {
    if (c == '0' || c == '1') {
        stk.push_back(c);    // push
    } else {
        if (!stk.empty()) {
            stk.pop_back();  // pop(需判空)
        }
    }
}

三个键只有两种操作:push'0''1')和 pop'B')。pop_back() 前检查 !empty() 是标准做法——省略这点在”连续按 B”时会导致未定义行为。

for (char c : stk) {
    cout << c;
}

vector 从头到尾的顺序恰好是栈底→栈顶,也就是屏幕上的字符串顺序。

复杂度

  • 时间:O(|s|),每个字符处理一次
  • 空间:O(|s|)

🔗 同类题目推荐(栈)

难度 题目 分数 一句话描述
⬆️ ABC 120 C — Unification 300 栈消除相邻的 01:读到与栈顶不同则弹出,相同则压入
⬆️ ABC 240 D — Strange Balls 400 栈维护 (值, 连续次数) 对,当次数等于值时整段弹出
⬆️ ABC 064 D — Insertion 400 栈统计未匹配的括号,在首尾补全成最短合法括号序列

第二讲:哈希表(Hash Map)

📋 原题 — ABC 137 C:Green Bin

分数:300pt | 数据结构:HashMap

题目描述

定义 anagram(字母异位词):将字符串中字符重新排列后得到的新字符串。例如 "greenbin""beginner" 的 anagram。

给定 N 个长度均为 10 的互不相同的小写字母字符串。统计有多少对 (i, j) 满足 i < j 且 sᵢ 与 sⱼ 互为 anagram。

约束

  • 2 ≤ N ≤ 10⁵
  • 每个 sᵢ 长度为 10,只含小写字母
  • 所有 sᵢ 互不相同

样例

输入:3
     acornistnt / peanutbomb / constraint
输出:1
解释:"acornistnt"  "constraint" 互为 anagram

📚 前置知识:什么是哈希表(HashMap)?

定义

哈希表是一种通过键(key)直接访问值(value)的数据结构。它通过哈希函数将 key 映射到数组的某个位置,实现近乎 O(1) 的查找/插入/删除。

核心操作

操作 Java HashMap C++ unordered_map
存入键值对 put(k, v) mp[k] = vmp.insert({k, v})
根据键获取值 get(k) mp[k]mp.at(k)
判断键是否存在 containsKey(k) mp.count(k)mp.find(k) != mp.end()

工作原理

put("apple", 3):
┌───┬───┬───┬───┬───┬───┬───┬───┐
│ 0 │ 1 │ 2 │ 3 │ 4 │ 5 │ 6 │ 7 │  ← 桶数组
└───┴───┴───┴───┴───┴───┴───┴───┘
                    │
                    ▼ (hash("apple") % 8 = 4)
              ┌─────────────┐
              │ "apple": 3  │  ← 桶中键值对
              └─────────────┘

经典应用

  • 频次统计:统计每个单词出现次数
  • 去重:判断元素是否已存在
  • 两数之和:一次遍历查找补数
  • 缓存:存计算结果避免重复计算

💻 Java 代码

import java.util.Arrays;
import java.util.HashMap;
import java.util.Scanner;

public class B_soln {
    public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);
        int n = sc.nextInt();
        HashMap<String, Integer> map = new HashMap<>();
        long ans = 0;

        for (int i = 0; i < n; i++) {
            char[] s = sc.next().toCharArray();
            int[] arr = new int[10];
            for (int j = 0; j < 10; j++) {
                arr[j] = s[j];
            }
            arr = Arrays.stream(arr).sorted().toArray();
            String stringArr = Arrays.toString(arr);

            if (map.containsKey(stringArr)) {
                map.put(stringArr, map.get(stringArr) + 1);
            } else {
                map.put(stringArr, 1);
            }
        }

        for (int i : map.values()) {
            ans += (long) i * (i - 1) / 2;
        }
        System.out.println(ans);
    }
}

💻 C++ 代码(详细解析)

#include <iostream>
#include <string>
#include <unordered_map>
#include <algorithm>
using namespace std;

int main() {
    int N;
    cin >> N;

    unordered_map<string, long long> freq;  // 标准形式 → 出现次数
    string s;

    for (int i = 0; i < N; i++) {
        cin >> s;

        // 将字符串排序,得到"标准形式"(anagram 的唯一代表)
        sort(s.begin(), s.end());

        // 频次 +1
        freq[s]++;
    }

    // 统计答案:每组 size=c 的 anagram 贡献 C(c, 2) 对
    long long ans = 0;
    for (auto& p : freq) {
        long long c = p.second;
        ans += c * (c - 1) / 2;
    }

    cout << ans << endl;
    return 0;
}

逐段解析

unordered_map<string, long long> freq;

C++ 标准库的哈希表。key 是排序后的字符串(标准形式),value 是该标准形式出现了几次。long long 因为 c 最大可达 10⁵,c² ≈ 10¹⁰ 超出 int。

sort(s.begin(), s.end());
freq[s]++;

核心思路:排序是 anagram 的”标准化”操作——两个互为 anagram 的字符串,排序后一定相同。例如 "greenbin""beginner" 排序后都是 "beeginnnr"

freq[s]++ 利用 C++ unordered_map 的默认值特性——key 不存在时自动初始化为 0 再 +1。

for (auto& p : freq) {
    long long c = p.second;
    ans += c * (c - 1) / 2;
}

假设某个标准形式出现了 c 次,这 c 个字符串中任意挑两个都构成 anagram 对,共 C(c,2) = c(c-1)/2 对。对所有标准形式求和即得答案。

复杂度

  • 时间:O(N × L log L),L = 10,排序开销极小
  • 空间:O(N)

🔗 同类题目推荐(哈希表)

难度 题目 分数 一句话描述
⬇️ ABC 240 B — Count Distinct Integers 200 用 Set 统计数组中不同整数的个数
⬇️ ABC 348 C — Colorful Beans 250 HashMap 按颜色分组,每种颜色取最小值
⬆️ ABC 233 D — Count Interval 400 前缀和 + HashMap 频次统计,数出和为 K 的连续子数组个数
⬆️ ABC 194 E — Mex Min 500 滑动窗口 + HashMap 维护频次,高效计算每个窗口的 mex

第三讲:队列(Queue)

📋 原题 — ABC 389 C:Snake Queue

分数:300pt | 数据结构:Queue(队列)

题目描述

维护一个蛇的队列,初始为空。处理 Q 个查询:

类型 格式 描述
1 1 l 队尾加入一条长度 l 的蛇。新蛇头部坐标 = 前一条蛇头部 + 前一条蛇长度;空队列时坐标为 0
2 2 队首的蛇离开。所有剩余蛇的头部坐标各减去离开蛇的长度
3 3 k 查询第 k 条蛇(从队首数起)的当前头部坐标

约束

  • 1 ≤ Q ≤ 3×10⁵
  • 1 ≤ l ≤ 10⁹

样例

输入:
7
1 5      → 加蛇(长5),坐标 0
1 7      → 加蛇(长7),坐标 0+5=5
3 2      → 第2条蛇坐标 → 5
1 3      → 加蛇(长3),坐标 5+7=12
1 4      → 加蛇(长4),坐标 12+3=15
2        → 队首离开(长5),剩余坐标均-5
3 3      → 第3条蛇坐标 → 10

输出:5 / 10

📚 前置知识:什么是队列(Queue)?

定义

队列是一种先进先出(FIFO, First In First Out)的线性数据结构。可以把它想象成排队买票:先来的人先被服务。

核心操作

操作 Java C++
入队(队尾) offer(x) / add(x) push(x)
出队(队首) poll() / remove() pop()
查看队首 peek() / element() front()
判空 isEmpty() empty()

图示

enqueue(1):    enqueue(2):    dequeue()→1:
队首→队尾       队首→队尾       队首→队尾
┌───┐          ┌───┬───┐      ┌───┐
│ 1 │          │ 1 │ 2 │      │ 2 │
└───┘          └───┴───┘      └───┘

经典应用

  • BFS(广度优先搜索)
  • 滑动窗口
  • 消息队列、任务调度

💻 Java 代码

import java.util.ArrayList;
import java.util.Scanner;

public class C_soln {
    public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);
        int q = sc.nextInt();

        ArrayList<Long> arrayList = new ArrayList<>();
        arrayList.add(0L);
        int size = 1;

        for (int i = 0; i < q; i++) {
            switch (sc.nextInt()) {
                case 1:
                    if (size == 0) {
                        arrayList.add(sc.nextLong());
                        size++;
                    } else {
                        arrayList.addLast(arrayList.get(size - 1) + sc.nextLong());
                        size++;
                    }
                    break;
                case 2:
                    arrayList.removeFirst();
                    size--;
                    break;
                default:
                    System.out.println(arrayList.get(sc.nextInt() - 1) - arrayList.getFirst());
            }
        }
    }
}

💻 C++ 代码(详细解析)

#include <iostream>
#include <deque>
using namespace std;
using ll = long long;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int Q;
    cin >> Q;

    deque<ll> dq;       // 双端队列,存前缀和(原始绝对坐标)
    dq.push_back(0);    // 哨兵:初始原点(相当于已离开蛇总长 = 0)

    while (Q--) {
        int type;
        cin >> type;

        if (type == 1) {
            ll len;
            cin >> len;
            // 新蛇的原始头部坐标 = 上一条原始坐标 + 上一条长度
            // 即:当前队列最后一个前缀和 + 新蛇长度
            dq.push_back(dq.back() + len);
        }
        else if (type == 2) {
            // 队首蛇离开 → 对应的前缀和被移除
            // 此后队列首元素 dq.front() 自动成为新的"偏移基准"
            dq.pop_front();
        }
        else {  // type == 3
            int k;
            cin >> k;
            // 第 k 条蛇的前缀和 - 当前队首前缀和(偏移量)
            // dq[k-1] 不用 +1 因为队首是哨兵(或已离开蛇的总长)
            cout << dq[k - 1] - dq.front() << '\n';
        }
    }
    return 0;
}

逐段解析

deque<ll> dq;
dq.push_back(0);    // 哨兵

C++ 的 deque(双端队列)支持 O(1) 的 push_backpop_frontpush_frontpop_back。初始插入 0 作为哨兵——它扮演两个角色:
- 初始状态:已离开蛇总长 = 0
- 每次队首出队后,dq[0] 自动更新为当前累积偏移量

// type 1: 队尾入队
dq.push_back(dq.back() + len);

dq.back() 是上一条蛇入队时的原始前缀和,加上新蛇长度即得新蛇在原始坐标系中的头部坐标。

举例:

初始:dq = [0]
加蛇(长5) → dq = [0, 0+5] = [0, 5]
加蛇(长7) → dq = [0, 5, 5+7] = [0, 5, 12]
  • dq[1]=5:第 1 条蛇的原始坐标
  • dq[2]=12:第 2 条蛇的原始坐标(第1条长5、第2条长7,∴ 5+7=12)
// type 2: 队首出队
dq.pop_front();

这是整道题最巧妙的地方。不更新任何坐标,只 pop_front()。举例:

出队前:dq = [0, 5, 12, 15]
                    ↑ 已离开蛇总长=0(哨兵)

pop_front() 后:dq = [5, 12, 15]
                      ↑ 已离开蛇总长=5(原来的 dq[1] 变成了新的队首)

之后查询时用 dq[k-1] - dq.front() 自动减去已离开蛇的长度。

// type 3: 查询第 k 条蛇的头部坐标
cout << dq[k - 1] - dq.front() << '\n';
  • dq[k-1]:第 k 条蛇入队时的原始绝对坐标(从 0 开始计数,所以下标 k-1)
  • dq.front():当前累积偏移量(= 所有已离开蛇的总长度)
  • 相减 = 该蛇在平移后的坐标

为什么用 deque 而不是 vector?

dequepop_front() 是 O(1),而 vectorerase(begin()) 是 O(n)。Q ≤ 3×10⁵,大量类型 2 操作下这个差异是决定性的。

复杂度

  • 时间:O(Q),每种操作 O(1)
  • 空间:O(Q)

🔗 同类题目推荐(队列)

难度 题目 分数 一句话描述
⬇️ ABC 278 A — Shift 100 数组左移一位,末尾补 0——最简单的 FIFO 模拟
⬇️ ABC 402 B — Restaurant Queue 200 显式队列:操作 1 入队,操作 2 弹出队首并输出
⬆️ ABC 247 D — Cylinder 400 批量入队(插入 c 个 x)和批量出队(弹出 c 个并求和)
⬆️ ABC 128 D — equeue 400 双端队列四种操作,K 次内最大化手中宝石价值

第四讲:优先队列(Priority Queue)

📋 原题 — ABC 141 D:Powerful Discount Tickets

分数:400pt | 数据结构:PriorityQueue(优先队列 / 堆)

题目描述

高桥要买 N 件商品,第 i 件价格 Aᵢ 円。他有 M 张打折券,买一件商品时可以用任意张。用 Y 张券买价格为 X 的商品时,实际支付 floor(X / 2^Y) 円。

券可以集中用在同一件商品上。求最小总花费。

约束

  • 1 ≤ N, M ≤ 10⁵
  • 1 ≤ Aᵢ ≤ 10⁹

样例

输入:3 3
     2 13 8
输出:9
解释:2円不用券,13→3(2张),8→4(1张),总计 2+3+4=9

输入:1 100000
     1000000000
输出:0
解释:10⁹ 用 10⁵ 张券,不断÷2 最终归 0

📚 前置知识:什么是优先队列(Priority Queue / Heap)?

定义

优先队列是一种每次出队都返回优先级最高(最大或最小)元素的数据结构,底层通常用堆(Heap)实现。

堆的性质

大顶堆(Max-Heap):            小顶堆(Min-Heap):
         99  ← 堆顶                    10  ← 堆顶
       /    \                        /    \
     50      80                    20      15
    /  \    /  \                 /  \    /  \
   30  20  60  70               45  30  25  35

核心操作

操作 Java C++ 复杂度
插入 add(x) / offer(x) push(x) O(log n)
弹出堆顶 poll() pop() O(log n)
查看堆顶 peek() top() O(1)

C++ 中的堆

// 大顶堆(默认)
priority_queue<int> maxHeap;

// 小顶堆
priority_queue<int, vector<int>, greater<int>> minHeap;

💻 Java 代码

import java.util.Comparator;
import java.util.PriorityQueue;
import java.util.Scanner;

public class D_soln {
    public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);
        int n = sc.nextInt();
        int m = sc.nextInt();

        PriorityQueue<Integer> pq = new PriorityQueue<>(Comparator.reverseOrder());

        for (int i = 0; i < n; i++) {
            pq.add(sc.nextInt());
        }

        for (int i = 0; i < m; i++) {
            pq.add(pq.poll() / 2);
        }

        long ans = 0L;
        for (int i : pq.stream().toList()) {
            ans += i;
        }
        System.out.println(ans);
    }
}

💻 C++ 代码(详细解析)

#include <iostream>
#include <queue>
using namespace std;
using ll = long long;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int N, M;
    cin >> N >> M;

    // 大顶堆:堆顶始终是当前最贵的商品
    priority_queue<ll> pq;

    for (int i = 0; i < N; i++) {
        ll price;
        cin >> price;
        pq.push(price);
    }

    // 每次取最贵的商品打折
    while (M--) {
        ll top = pq.top();   // O(1):查看当前最贵价格
        pq.pop();            // O(log N):弹出
        pq.push(top / 2);    // O(log N):打折后放回
    }

    // 求和所有剩余价格
    ll ans = 0;
    while (!pq.empty()) {
        ans += pq.top();
        pq.pop();
    }

    cout << ans << endl;
    return 0;
}

逐段解析

priority_queue<ll> pq;

C++ priority_queue 默认是大顶堆,即 top() 返回最大元素。恰好对应需求:每次挑最贵的商品用券。

while (M--) {
    ll top = pq.top();   // 查看堆顶(最大值)
    pq.pop();            // 弹出堆顶
    pq.push(top / 2);    // 半价后放回
}

这是整道题的贪心核心。每张券的操作:

  1. top() → 拿到当前最贵的价格(O(1))
  2. pop() → 从堆中移除(O(log N))
  3. top / 2 → 整数除法自动向下取整,等价于 floor(price/2)(C++ 正数除法天然向零取整)
  4. push() → 打折后的价格重新入堆(O(log N))

为什么贪心成立? 每次券省的钱 = price - floor(price/2)。price 越大,差值越大。所以永远把券用在当前最贵的商品上是最优的。

ll ans = 0;
while (!pq.empty()) {
    ans += pq.top();
    pq.pop();
}

最后堆中所有元素求和即得最小总花费。注意用 long long,N 件商品每件可到 10⁹,总和可达 10¹⁴。

复杂度

  • 时间:O((N+M) log N),每次 push/pop 都是 O(log N)
  • 空间:O(N)

🔗 同类题目推荐(优先队列)

难度 题目 分数 一句话描述
⬇️ ABC 212 D — Querying Multiset 400 小顶堆 + 全局偏移量:插入 X、全体加 X、弹出最小值
⬇️ ABC 137 D — Summer Vacation 400 按截止日排序 + 大顶堆,每天选收益最高且未过期的任务
⬆️ ABC 306 E — Best Performances 475 双堆:动态维护前 K 大元素的和,支持单点修改
⬆️ ABC 281 E — Least Elements 500 滑动双堆:维护每个长度为 M 的窗口中前 K 小元素的和

第五讲:堆 + 扫描线(Heap + Sweep Line)

📋 原题 — ABC 325 D:Printing Machine

分数:450pt | 数据结构:PriorityQueue + Sweep Line(扫描线)

题目描述

传送带上有 N 个商品。第 i 个商品在时刻 Tᵢ 进入打印机范围,在 Tᵢ + Dᵢ 时刻离开。

打印机印一个商品需要恰好 1 微秒,两次印刷间不需要冷却。可以在商品进入或离开的瞬间印刷。求最多能印几个商品。

约束

  • 1 ≤ N ≤ 2×10⁵
  • 1 ≤ Tᵢ, Dᵢ ≤ 10¹⁸

样例

输入:5
     1 1 / 1 1 / 2 1 / 1 2 / 1 4
输出:4
解释:时刻1 印商品4,时刻2 印商品1,时刻3 印商品3,时刻4 印商品5

📚 前置知识:什么是扫描线(Sweep Line)?

定义

扫描线是一种算法范式:将事件按时间(或位置)排序,然后”扫描”过去,在扫描过程中维护一个动态数据结构(通常是堆)来处理当前”活跃”的事件。

典型流程

1. 将事件按发生时间排序
2. 初始化一个数据结构如堆
3. 按时间顺序处理每个时间点
   a. "刚进入"的元素加入数据结构
   b. 从数据结构中删除"已过期"的元素
   c. 在数据结构中进行决策如取最优元素处理

本题中的扫描线

时间线:  ──●────●────●────●────→
           T=1  T=2  T=3  T=4
          商品  商品   ...
          进入  进入

随着 currentTime 推进,商品进入范围(入堆)、离开范围(过期丢弃),在每个时间点做出最优选择。


💻 Java 代码

import java.util.ArrayList;
import java.util.HashMap;
import java.util.PriorityQueue;
import java.util.Scanner;
import java.util.Collections;

public class E_soln {
    public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);
        int n = sc.nextInt();

        HashMap<Long, ArrayList<Long>> hashMap = new HashMap<>();
        for (int i = 0; i < n; i++) {
            long t = sc.nextLong();
            long d = sc.nextLong();
            long leaveTime = t + d;
            if (hashMap.containsKey(t)) {
                hashMap.get(t).add(leaveTime);
            } else {
                ArrayList<Long> list = new ArrayList<>();
                list.add(leaveTime);
                hashMap.put(t, list);
            }
        }

        ArrayList<Long> entryTimes = new ArrayList<>(hashMap.keySet());
        Collections.sort(entryTimes);

        PriorityQueue<Long> current = new PriorityQueue<>();
        int ans = 0;
        long currentTime = 0;
        int timeIndex = 0;

        while (timeIndex < entryTimes.size() || !current.isEmpty()) {
            if (current.isEmpty()) {
                currentTime = Math.max(currentTime, entryTimes.get(timeIndex));
            }
            while (timeIndex < entryTimes.size() && entryTimes.get(timeIndex) <= currentTime) {
                long entryT = entryTimes.get(timeIndex);
                for (long leaveTime : hashMap.get(entryT)) {
                    current.add(leaveTime);
                }
                timeIndex++;
            }
            if (current.isEmpty()) continue;

            long earliestLeave = current.poll();
            if (earliestLeave < currentTime) continue;

            ans++;
            currentTime++;
        }

        System.out.println(ans);
    }
}

💻 C++ 代码(详细解析)

#include <iostream>
#include <vector>
#include <queue>
#include <algorithm>
using namespace std;
using ll = long long;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int N;
    cin >> N;

    // 将每个商品的 (进入时间, 离开时间) 存入 vector
    vector<pair<ll, ll>> products(N);
    for (int i = 0; i < N; i++) {
        ll t, d;
        cin >> t >> d;
        products[i] = {t, t + d};  // {进入时间, 离开时间}
    }

    // 按进入时间升序排序
    sort(products.begin(), products.end());

    // 小顶堆,维护"当前在范围内"商品的离开时间
    priority_queue<ll, vector<ll>, greater<ll>> pq;

    int idx = 0;           // 指向下一个还没进入范围的商品
    int ans = 0;
    ll curTime = 0;        // 打印机下一次可用的时刻

    // 扫描线主循环
    while (idx < N || !pq.empty()) {

        // ① 空闲跳转:堆空说明没有可印的商品,时间跳到下一个商品进入时刻
        if (pq.empty()) {
            curTime = max(curTime, products[idx].first);
        }

        // ② 入堆:把 curTime 之前(含)进入的商品全部入堆
        while (idx < N && products[idx].first <= curTime) {
            pq.push(products[idx].second);  // 按离开时间入小顶堆
            idx++;
        }

        // ③ 过期丢弃:堆顶商品已过期(离开时间 < 当前时刻)→ 丢弃
        while (!pq.empty() && pq.top() < curTime) {
            pq.pop();
        }

        // ④ 印刷:堆顶是离开最早且未过期的商品
        if (!pq.empty()) {
            pq.pop();
            ans++;
            curTime++;  // 印刷消耗 1 微秒
        }
    }

    cout << ans << endl;
    return 0;
}

逐段解析

vector<pair<ll, ll>> products(N);
for (int i = 0; i < N; i++) {
    ll t, d;
    cin >> t >> d;
    products[i] = {t, t + d};  // {进入时间, 离开时间}
}
sort(products.begin(), products.end());

每件商品存为 (进入时间, 离开时间) 的 pair,然后按进入时间排序。这是扫描线的标准预处理——按时间顺序处理事件。

priority_queue<ll, vector<ll>, greater<ll>> pq;

小顶堆,存当前在打印机范围内的所有商品的离开时间greater<ll> 翻转比较器,使堆顶是最小值(最早离开的)。

// ① 空闲跳转
if (pq.empty()) {
    curTime = max(curTime, products[idx].first);
}

堆空 = 没有任何商品可印 = 机器空闲。与其傻等,不如直接跳到下一个商品进入的时刻。max 防止时间倒退。

// ② 入堆
while (idx < N && products[idx].first <= curTime) {
    pq.push(products[idx].second);
    idx++;
}

把所有 Tᵢ ≤ curTime 的商品按离开时间入堆。注意是 <=:商品在进入瞬间就可以被印刷。

// ③ 过期丢弃
while (!pq.empty() && pq.top() < curTime) {
    pq.pop();
}

堆顶的离开时间如果已经过去(< curTime),说明来不及印了,直接弹出丢弃。注意是 < 不是 <=:离开瞬间仍可印刷

// ④ 印刷
if (!pq.empty()) {
    pq.pop();
    ans++;
    curTime++;
}

堆顶是”当前可印商品中离开最早的”。印刷它:计数器 +1,时间推进 1(花了 1 微秒)。

扫描线四分支流程

while (还有商品未进入  堆非空):
    ┌─  堆空  跳到下一个商品进入时间
    ├─   curTime 之前进入的商品入堆
    ├─  堆顶已过期  弹出丢弃
    └─  堆顶有效  印刷,ans++curTime++

复杂度

  • 时间:O(N log N),排序 + 每个商品入堆出堆各一次
  • 空间:O(N)

🔗 同类题目推荐(堆 + 扫描线)

难度 题目 分数 一句话描述
⬇️ ABC 137 D — Summer Vacation 400 按截止日扫描天数 + 大顶堆挑收益最高任务
⬆️ ABC 214 E — Packing Under Range Regulations 500 与本题几乎同构:球放入 [Lᵢ,Rᵢ] 区间,扫描盒子 + 小顶堆存 Rᵢ
⬆️ ABC 170 E — Smart Infants 500 多堆协同:每个幼儿园大顶堆 + 全局小顶堆

第六讲:线段树(Segment Tree)

📋 原题 — ABC 339 E:Smooth Subsequence

分数:525pt | 数据结构:Segment Tree(线段树)

题目描述

给定长度为 N 的序列 A 和阈值 D。求 A 的最长子序列,满足子序列中任意相邻两项之差的绝对值 ≤ D

子序列:从原序列中删除零个或多个元素,保持剩余元素的原有顺序。

约束

  • 1 ≤ N ≤ 5×10⁵
  • 0 ≤ D ≤ 5×10⁵
  • 1 ≤ Aᵢ ≤ 5×10⁵

样例

输入:4 2
     3 5 1 2
输出:3
解释:(3, 1, 2),|3-1|=2≤2,|1-2|=1≤2

输入:5 10
     10 20 100 110 120
输出:3
解释:(100, 110, 120) 或 (10, 20, 100)

📚 前置知识:什么是线段树(Segment Tree)?

定义

线段树是一种二叉树,用于高效处理区间查询单点更新。每个节点代表一个区间,叶子节点代表单个元素,父节点由其子节点合并得到。

结构示意

假设值域为 [1, 8],线段树结构如下:

                         [1,8]                        ← 根,覆盖整个区间
                         max=5
                    /               \
               [1,4]                  [5,8]
               max=3                  max=5
            /        \              /        \
        [1,2]       [3,4]       [5,6]       [7,8]
        max=2       max=3       max=5       max=0
       /    \      /    \      /    \      /    \
     [1]   [2]   [3]   [4]   [5]   [6]   [7]   [8]   ← 叶子
     dp=2  dp=0  dp=3  dp=1  dp=5  dp=0  dp=0  dp=0
  • 每个节点存储其区间内 dp 的最大值
  • 叶子节点 [v] 存储 dp[v](以值为 v 的元素结尾的最优解)

数组存储

线段树通常用数组存储: - 根节点下标 = 1 - 左孩子 = 2 × node,右孩子 = 2 × node + 1 - 数组大小通常开 4 × MAX_VAL(保证不越界)

核心操作

操作 描述 复杂度
update(pos, val) 将位置 pos 的值更新为 val O(log n)
query(L, R) 查询区间 [L, R] 的聚合值(如最大值) O(log n)

经典应用

  • 区间求和 / 区间最值(单点修改)
  • 区间加法(懒标记 Lazy Propagation)
  • DP 优化(如本题)
  • 逆序对计数
  • 区间 GCD

💻 Java 代码

import java.util.Scanner;

public class F_soln {
    static final int MAX_VAL = 500000;
    static int[] segTree;

    static void update(int node, int start, int end, int pos, int val) {
        if (start == end) {
            segTree[node] = Math.max(segTree[node], val);
        } else {
            int mid = (start + end) / 2;
            if (pos <= mid) {
                update(2 * node, start, mid, pos, val);
            } else {
                update(2 * node + 1, mid + 1, end, pos, val);
            }
            segTree[node] = Math.max(segTree[2 * node], segTree[2 * node + 1]);
        }
    }

    static int query(int node, int start, int end, int l, int r) {
        if (r < start || end < l) return 0;
        if (l <= start && end <= r) return segTree[node];
        int mid = (start + end) / 2;
        int leftMax = query(2 * node, start, mid, l, r);
        int rightMax = query(2 * node + 1, mid + 1, end, l, r);
        return Math.max(leftMax, rightMax);
    }

    public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);
        int N = sc.nextInt();
        int D = sc.nextInt();

        int[] A = new int[N];
        for (int i = 0; i < N; i++) {
            A[i] = sc.nextInt();
        }

        segTree = new int[MAX_VAL * 4];
        int globalMax = 0;

        for (int val : A) {
            int L = Math.max(1, val - D);
            int R = Math.min(MAX_VAL, val + D);
            int maxLen = query(1, 1, MAX_VAL, L, R);
            int currentLen = maxLen + 1;
            update(1, 1, MAX_VAL, val, currentLen);
            if (currentLen > globalMax) {
                globalMax = currentLen;
            }
        }

        System.out.println(globalMax);
    }
}

💻 C++ 代码(详细解析)

#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

const int MAX_VAL = 500000;       // A_i 的最大可能值
vector<int> segTree(MAX_VAL * 4); // 4 倍空间保证不越界

// 线段树:单点更新 —— 将位置 pos 的值更新为 val(取 max)
void update(int node, int start, int end, int pos, int val) {
    if (start == end) {
        // 到达叶子节点 [pos, pos]
        segTree[node] = max(segTree[node], val);
        return;
    }
    int mid = (start + end) / 2;
    if (pos <= mid) {
        update(node * 2, start, mid, pos, val);       // 进左子树
    } else {
        update(node * 2 + 1, mid + 1, end, pos, val);  // 进右子树
    }
    // 回溯:父节点 = max(左孩子, 右孩子)
    segTree[node] = max(segTree[node * 2], segTree[node * 2 + 1]);
}

// 线段树:区间查询 —— 查询区间 [l, r] 的最大值
int query(int node, int start, int end, int l, int r) {
    // 情况 1:完全无交集 → 返回 0(不影响 max)
    if (r < start || end < l) return 0;
    // 情况 2:当前区间被查询区间完全包含 → 剪枝返回
    if (l <= start && end <= r) return segTree[node];
    // 情况 3:部分交集 → 递归左右子树
    int mid = (start + end) / 2;
    int leftMax  = query(node * 2, start, mid, l, r);
    int rightMax = query(node * 2 + 1, mid + 1, end, l, r);
    return max(leftMax, rightMax);
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int N, D;
    cin >> N >> D;

    int ans = 0;

    for (int i = 0; i < N; i++) {
        int val;
        cin >> val;

        // 可转移来源的值域区间:[val-D, val+D]
        int L = max(1, val - D);
        int R = min(MAX_VAL, val + D);

        // 查询该区间内最大 dp 值
        int best = query(1, 1, MAX_VAL, L, R);

        // 当前元素的 dp 值 = 最优前驱 dp + 1
        int cur = best + 1;

        // 更新线段树(位置 val 存入 dp[val])
        update(1, 1, MAX_VAL, val, cur);

        ans = max(ans, cur);
    }

    cout << ans << endl;
    return 0;
}

逐段解析

1. DP 定义与瓶颈

dp[i] = 以 Aᵢ 结尾的最长合法子序列长度。转移方程:

dp[i] = 1 + max{ dp[j] : j < i  |Aᵢ - Aⱼ|  D }

直接枚举 j 是 O(N²),N = 5×10⁵ 时必然超时。瓶颈在于:如何在 O(log N) 内查询”已处理过的元素中,值在 [Aᵢ-D, Aᵢ+D] 范围内的最大 dp”

2. 值域线段树的设计

关键观察:转移条件是关于 A 的值的,不是关于下标的。所以线段树的下标不是数组索引,而是元素的值

叶子节点 [v] 存储dp[v] = 以值为 v 的元素结尾的最优解
查询 query(val-D, val+D)值在合法范围内的最大 dp
更新 update(val, dp[val])把当前 dp 写入线段树

3. update 方法

void update(int node, int start, int end, int pos, int val) {
    if (start == end) {
        segTree[node] = max(segTree[node], val);  // 取 max(而不是覆盖)
        return;
    }
    int mid = (start + end) / 2;
    if (pos <= mid)
        update(node * 2, start, mid, pos, val);
    else
        update(node * 2 + 1, mid + 1, end, pos, val);
    segTree[node] = max(segTree[node * 2], segTree[node * 2 + 1]);
}

递归到叶子节点后,用 max 而非直接赋值——因为可能多次遇到同一个值,要保留最优解。回溯时每个父节点重新计算 max(左, 右)

4. query 方法

int query(int node, int start, int end, int l, int r) {
    if (r < start || end < l) return 0;     // 完全不交 → 返回 0
    if (l <= start && end <= r)              // 完全包含 → 剪枝
        return segTree[node];
    int mid = (start + end) / 2;
    return max(query(node * 2, start, mid, l, r),
               query(node * 2 + 1, mid + 1, end, l, r));
}

经典的三段式区间查询:
- 无交集r < start || end < l → 返回 0(不影响 max)
- 完全包含l <= start && end <= r → 直接返回节点值(不再递归,这是线段树高效的关键)
- 部分交集:递归左右子树,取 max

5. 主循环

for (int i = 0; i < N; i++) {
    int val;
    cin >> val;

    int L = max(1, val - D);
    int R = min(MAX_VAL, val + D);
    int best = query(1, 1, MAX_VAL, L, R);  // O(log MAX_VAL)
    int cur = best + 1;
    update(1, 1, MAX_VAL, val, cur);         // O(log MAX_VAL)
    ans = max(ans, cur);
}

每个元素做两次 O(log MAX_VAL) 操作,N=5×10⁵ 时总量约 2×10⁷ 次,在时限内轻松通过。

值域线段树 vs 普通线段树

普通线段树 本题的值域线段树
下标含义 数组索引 i 元素的值 v
叶子节点 dp[i](以第 i 个元素结尾) dp[v](以值为 v 的元素结尾)
区间查询 下标区间 [i, j] 值域区间 [val-D, val+D]
为什么 转移条件是关于的,不是关于下标的

复杂度

  • 时间:O(N log MAX_VAL),每个元素两次线段树操作
  • 空间:O(MAX_VAL),线段树数组约 2×10⁶

🔗 同类题目推荐(线段树 / BIT)

难度 题目 分数 一句话描述
⬇️ ABC 442 D — Swap and Range Sum 400 BIT 裸题:交换相邻元素 + 区间求和
⬇️ ABC 157 E — Simple String Queries 500 26 棵 BIT,单点改字符 + 区间查不同字符数
⬆️ ABC 283 F — Permutation Distance 500 线段树四个方向求 min
⬆️ ABC 223 F — Parenthesis Checking 500 线段树 + 自定义幺半群 (最小前缀和, 总和),判断子串合法性
⬆️ ABC 185 F — Range Xor Query 600 BIT 维护区间 XOR 和

总结

讲次 数据结构 题目 分数 核心技巧
Stack ABC 043 B 200 LIFO,push/pop 模拟按键
HashMap ABC 137 C 300 排序做标准形式 key,频次统计,C(n,2) 求和
Queue ABC 389 C 300 FIFO,前缀和 + 偏移量避免全局更新
PriorityQueue ABC 141 D 400 大顶堆反复取最大 ÷2
Heap + Sweep ABC 325 D 450 小顶堆 + 扫描线 + 最早截止优先调度
Segment Tree ABC 339 E 525 值域线段树优化 DP 转移,区间 max 查询

六种数据结构层层递进——从裸结构到组合应用,从 O(N) 到 O(log N)。数据结构不是答案,它只是帮你把”反复要做的事”变快。 找准题目中反复要做的那件事,数据结构自然就浮出水面。

💬 评论 (0)

加载中…