专题10 · 数据结构2
背下容器接口只算入门,会选容器才算会做题。
本讲拿下 STL 三剑客:vector(动态连续、随机访问)、priority_queue(每次取最值)、
deque(两端都要动),并用四道真题练出「选型直觉」。
三剑客 · 四道真题(对应视频 41′56″)
🔧 vector 0–15′
动态数组:内存连续、可随机访问。
解决:长度事先未知、要按下标查询。
🏆 priority_queue 15–25′
二叉堆:每次拿最大 / 最小。
解决:反复「取两个最值再合并」。
🚪 deque 25–32′
双端队列:两端增删 O(1),还能随机访问。
解决:两头都要动、还要查中间。
① 论坛帖子(vector)→ ② 哈夫曼树 WPL(priority_queue)→ ③ 龙的追踪(deque)→ ④ 公交换乘 CSP-J2019 T2(deque)
普通数组的三宗罪
罪一:长度写死
开 int a[100],第 101 个数据来了怎么办?开大了浪费、开小了 RE。
vector:push_back 自己长大。
罪二:删中间很痛
删掉 a[3] 要把后面全部前移,代码一多就写错边界。
vector:erase 帮你搬。
罪三:两端不能动
数组头部插入 = 全体后移;queue 又只能一头进一头出。
deque:两头都是 O(1)。
vector 的一句话画像
一块连续内存 + 自动扩容。v[i] 是 O(1)(因为连续,直接算地址)。
为什么"连续"这么重要
连续才能指针 +i 直接定位、才能二分、才能被 CPU 缓存预读。 list(链表)不能随机访问,就是因为它不连续。
vector 是怎么"自己长大"的?
连续点 push_back,观察 size(元素个数)与 capacity(已申请容量)的变化。
扩容到底做了什么
容量不够 → 申请一块新空间(通常翻倍) → 把旧元素全部拷贝过去 → 释放旧空间。 所以扩容那一次是 O(n),但因为翻倍,均摊下来 push_back 仍是 O(1)。
考试怎么用这条性质
如果事先知道要放 1e5 个元素,先 v.reserve(100000);,
一次都不用搬家,常数直接小一大截。
&v[0] 指向的是已被释放的旧空间。别跨 push_back 保存指针。六个动作的代价,一次看清楚
尾部操作是主场
push_back / pop_back:O(1),不牵动任何其它元素。
v.front() / v.back() / v[i] / v.size() / v.empty()
中间/头部操作是客场
insert / erase 在头部或中间:O(n),后面的元素要整体搬家。
v.begin() 首 v.end() 尾后一位
v.rbegin() 反向首
v.end() 指向最后一个的后面,解引用它直接 RE;
② pop_back() 不返回被删的值,要先 back() 再 pop_back();
③ v.size() 是 size_t(无符号),写 v.size()-1 当 v 为空时会变成天文数字。vector 的三种常见形态
① 二维 vector
int n, m; cin >> n >> m; vector<vector<int>> a(n, vector<int>(m, 0)); // a[i][j] 直接用,行数列数运行时才定
比 int a[505][505] 好在:不浪费、不越界、能当参数传。
② 结构体 vector
struct Node{ int x, y, w; }; vector<Node> v; v.push_back({1, 2, 3}); sort(v.begin(), v.end(), [](Node a, Node b){ return a.w < b.w; // 按 w 升序 });
排序 + 结构体是复赛最高频组合。
③ 邻接表存图
vector<int> g[100005]; // 加一条 u → v 的边 g[u].push_back(v); // 遍历 u 的所有邻居 for(int v : g[u]) { ... }
每个点一条"边链表",点数不定也能存。
vector<T> v(n, 初值) 造 n 个;vector<vector<T>> a(n, vector<T>(m)) 造 n 行 m 列;
vector<T> g[N] 造 N 个桶(每个桶自己长)。
论坛帖子 · 用 vector 动态装回复
每个帖子一个 vector,ADD 追加、QUERY 按下标查。看指令一条条执行:
建模关键
帖子编号就是桶的下标;"第 y 个回复"是 1-based,代码里要 [y-1]。
为什么不用二维数组
10 万个帖子 × 回复数不定 —— 开二维数组要么爆内存、要么不够用。每个帖子一个 vector,按需生长。
论坛帖子 · 完整代码
#include <iostream> #include <vector> #include <string> using namespace std; // 每个帖子一个 vector,全局区自动清零 vector<int> post[100001]; int main(){ ios::sync_with_stdio(false); cin.tie(nullptr); int n; if(!(cin >> n)) return 0; while(n--){ string op; int x, y; cin >> op >> x >> y; if(op == "ADD"){ post[x].push_back(y); // O(1) } else { // QUERY if((int)post[x].size() < y){ // ★ 先判够不够 cout << "-1\n"; } else { cout << post[x][y-1] << '\n'; // ★ y-1 } } } return 0; }
两个 ★ 就是全部得分点
① 先比 size 再取下标:直接 post[x][y-1] 会越界,行为未定义(可能不崩,直接 WA)。
② 第 y 个 = 下标 y−1:题目按 1 开始数,数组按 0 开始。
post[x].size() 是 size_t(无符号),
直接和 y 比较问题不大,但写 (int)size() - y >= 0 更安全;
最稳的写法就是上面的 (int)post[x].size() < y。堆:一棵"永远上面大"的完全二叉树
大顶堆:每个结点 ≥ 它的孩子。插入走上浮,删除堆顶走下沉。点按钮单步看。
数组 ↔ 树 的位置关系
结点 i 的父:(i-1)/2;左孩子:2i+1;右孩子:2i+2(0-based)。
完全二叉树不会在中间留洞,所以能用一个紧凑数组表示。
复杂度
插入 / 删除:O(log n)(只沿一条父子链上下移动,树高 log n)。
取最值 top():O(1)。
建堆 O(n)。
priority_queue 默认是大顶堆,怎么变小顶堆?
① greater(推荐)
priority_queue< long long, vector<long long>, greater<long long> > pq;
顶部是最小值。记得 #include <functional>。
② 取负数(常用技巧)
priority_queue<long long> pq; pq.push(-w); // 存负数 long long mn = -pq.top(); // 取回
大顶堆堆顶是"最大的负数"=最小的原值。
③ 结构体自定义比较
struct Node{ int w, id; }; struct Cmp{ bool operator()( const Node& a, const Node& b) const{ return a.w > b.w; // w 小的优先 } }; priority_queue<Node, vector<Node>, Cmp> q;
注意方向:返回 true 表示 a 的优先级更低(反直觉,写反了会调半天)。
priority_queue 没有 clear(),要清空只能 while(!q.empty()) q.pop(); 或重新定义一个;
② 它不能遍历(没有迭代器),想看全部内容只能一个个 pop 出来;
③ 默认比较是 less<T> → 大顶堆,别想当然以为是小顶堆。哈夫曼:每次抓最小的两个合并
核心结论:WPL = 每一次合并代价之和。所以不用真的建树,一路累加就行。
哈夫曼 · 完整代码(含取负数技巧)
#include <iostream> #include <queue> #include <vector> #include <functional> using namespace std; // 小顶堆:每次 top() 拿到当前最小的结点 priority_queue<long long, vector<long long>, greater<long long> > pq; int main(){ int n; cin >> n; for(int i=0;i<n;i++){ long long w; cin >> w; pq.push(w); } long long wpl = 0; while((int)pq.size() > 1){ // ★ 剩 1 个就结束 long long a = pq.top(); pq.pop(); long long b = pq.top(); pq.pop(); long long s = a + b; wpl += s; // ★ WPL = 合并代价和 pq.push(s); } cout << wpl << '\n'; return 0; }
只想用默认大顶堆?取负数
priority_queue<long long> pq; // 默认大顶堆 pq.push(-w); // 存负的 long long a = -pq.top(); pq.pop(); // 取反回来 long long b = -pq.top(); pq.pop(); wpl += a + b; pq.push(-(a + b)); // 新结点也存负的
视频里的技巧:不想记 greater 的写法时,统一取负最省事。
while(!pq.empty()),那会死循环。⚠️ 答案会超 int:n=200、w=1000 时 WPL 接近 2e5×log,务必
long long。deque:两头都是正门
对比一下:vector 在头部插入要搬多少元素?deque 又要搬多少?
deque 为什么两头都快
它不是一整块连续内存,而是若干块缓冲区 + 一个中控数组(map)。 前面要加元素就在前面新开一块,不用动已有元素。
代价是什么
因为不连续,它的常数比 vector 略大;at(i) 仍是 O(1)(中控数组定位块 + 块内偏移),
但缓存友好性不如 vector。
一表看懂:该用哪一个
| 容器 | 尾部增删 | 头部增删 | 中间插入 | 随机访问 | 取最值 | 典型题 |
|---|---|---|---|---|---|---|
| vector | O(1) ✅ | O(n) ❌ | O(n) | O(1) ✅ | O(n) | 论坛帖子、邻接表 |
| priority_queue | — | — | — | ❌ 不可访问 | top O(1) / 增删 O(log n) ✅ | 哈夫曼、合并果子 |
| deque | O(1) ✅ | O(1) ✅ | O(n) | O(1) ✅ | O(n) | 龙的追踪、公交换乘 |
| queue | O(1) ✅ | O(1)(只能出) | — | ❌ | O(n) | BFS、约瑟夫环 |
① 长度不定 + 要按下标查
→ vector。
信号词:"第 k 个"、"动态追加"。
② 反复"取两个最值合并"
→ priority_queue。
信号词:"最小的两个"、"每次取最大"。
③ 一头进新、一头掉旧 + 要查
→ deque。
信号词:"滑动窗口"、"有效期"、"最近 k 个"。
龙的追踪 · deque 存龙身
龙头走一步:新坐标 push_front,尾巴 pop_back;查第 k 节身体:随机访问 dq[k-1]。
为什么必须是 deque
龙头移动 Q 次(1e5),每次都要头部加、尾部删:vector 头插是 O(n) 直接 TLE; queue 又不能按下标查第 k 节。只有 deque 两头 O(1) 且能 at(k)。
代码骨架
deque<pair<int,int>> body; // 走一步 body.push_front({nx, ny}); body.pop_back(); // 查第 k 节(1-based) auto p = body[k-1];
CSP-J2019 T2 公交换乘 · deque 管票
坐地铁得票(入队尾),坐公交先扔掉过期票、再用最早能抵的那张。
pop_front 一路弹掉;
"优先用最早的" → 从头往后扫第一个能用的。这两个动作合起来就是 deque 的形状。公交换乘 · 完整代码
struct Ticket{ int t; // 获得票的时刻 int price; // 能抵多少钱 bool used; // 是否已用过 }; deque<Ticket> q; long long cost = 0; if(type == 0){ // 地铁 cost += price; q.push_back({t, price, false}); } else { // 公交 while(!q.empty() && t - q.front().t > 45) q.pop_front(); // ★1 弹过期 bool ok = false; for(size_t j = 0; j < q.size(); j++){ if(!q[j].used && q[j].price >= price){ q[j].used = true; // ★2 用最早 ok = true; break; } } if(!ok) cost += price; // ★3 没有就付钱 }
t - t_sub > 45 才过期。
差正好 45 分钟仍然有效,写成 >= 45 会全错。used,不能丢。erase(begin()) 是 O(n),n=1e5 会 TLE。要么 deque,要么用数组 + 头指针。三剑客的易错辨析
① 要在 头部 频繁插入元素,且数据量 1e5,选哪个?
② priority_queue<int> q; 的 q.top() 是?
③ vector 扩容时,之前保存的 &v[0] 会怎样?
一道题 100 分是分档给的——不会正解也要先拿保底
复赛一道题的 100 分由十几到二十几个测试点组成, 前面几档专门为「没想出正解的人」准备。四道题各拿 30~40 分, 加起来往往就是一等奖和二等奖的分界。
① 特判档:先别想算法,看数据范围表
题目给的数据范围表就是出题人给你的送分清单。看到「n = 1」「只有一组数据」这类一行,
就先写个 if 直接输出答案。2 分钟换 5~10 分,全场最划算。
② 暴力档:按题意最直白地写一遍
多重循环、DFS 全枚举、朴素 O(n³)……不要优化,只要保证最小那档全对。 它既是保底分,又是后面对拍的标尺——没有它你无法证明正解是对的。
③ 特殊性质档:题目里那句「若……」
常见的送分性质:数据已经有序、所有值完全相同、规模小到可以 O(n²)、 只出现一种类型。题目写出来就是让你拿的,专门写一份即可。
④ 正解档:思路定了,就赢了八成
考场上的时间几乎都花在想思路上,不是写代码。写完先跑样例, 再用第 ② 档的暴力版造小数据对拍——这是唯一能证明你思路对的办法。
本讲 3 道题,各自的四档怎么走
论坛帖子(vector) · FORUM
第1档 · 2 分钟n == 1 → 直接输出那一条
第2档 · 8 分钟
定长数组开大 + 手动移动元素,n ≤ 10⁴ 稳过
第3档 · 10 分钟
只有新增、没有删除/插入 → 就是顺序输出
第4档 · 15 分钟
vector + 迭代器按条件查找与删除
哈夫曼树的带权路径长度 · HUFFMAN
第1档 · 2 分钟n == 1 → 0
第2档 · 8 分钟
每次线性扫描找最小两个合并,O(n²),n ≤ 10³ 稳过
第3档 · 10 分钟
所有权重相同 → 就是满二叉树,WPL 可直接算
第4档 · 15 分钟
小顶堆 priority_queue,每次取两个最小的合并,累加
公交换乘 · BUS
第1档 · 2 分钟
没有任何地铁(优惠券)记录 → 答案 = 所有票价之和
第2档 · 8 分钟
每张公交票往前扫所有未用券,n ≤ 10³ 时 O(n²) 稳过
第3档 · 10 分钟
所有票价相同 → 直接配对计数
第4档 · 15 分钟
deque 按时间维护 45 分钟内未使用的券,每张票从队首找可用券
0–5 分钟 读题 + 圈出数据范围表里最小的那几档 → 5–15 分钟 写完第 1、2 档并先交一次(保住 20~30 分)→ 15–40 分钟 冲第 3、4 档 → 40–50 分钟 造小数据对拍、检查
freopen 与文件名。椰程信奥当堂测评
① 论坛帖子中,某帖只有 3 条回复,查询 QUERY x 4 应输出?
② 哈夫曼求 WPL 时,循环条件是?
③ 公交换乘中,一张"票价不够"的票应该?
拿到题先别写代码,问三句:① 长度定不定?不定 → vector; ② 要不要每次取最值?要 → priority_queue; ③ 两头要不要都动、还要不要查中间?是 → deque。 三问答完,容器就定了,剩下的只是把接口写对。💡