数据结构六讲 —— 从栈到线段树 ¶
题解基于 AtCoder ABC 六道真题,涵盖六种核心数据结构,难度从 200pt 逐级爬升至 525pt。
每道题提供 Java 和 C++ 双语言代码,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(由 0、1、B 组成),输出最终屏幕上的字符串。
约束 ¶
- 1 ≤ |s| ≤ 10
- s 只包含
0、1、B - 保证答案不是空串
样例 ¶
输入: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 | 栈消除相邻的 0 和 1:读到与栈顶不同则弹出,相同则压入 |
| ⬆️ | 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] = v 或 mp.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_back、pop_front、push_front、pop_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? ¶
deque 的 pop_front() 是 O(1),而 vector 的 erase(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); // 半价后放回
}
这是整道题的贪心核心。每张券的操作:
top()→ 拿到当前最贵的价格(O(1))pop()→ 从堆中移除(O(log N))top / 2→ 整数除法自动向下取整,等价于floor(price/2)(C++ 正数除法天然向零取整)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)