椰程信奥·互动课件 专题10·数据结构2:vector / 优先队列 / deque
10:00 1 / 21
🧭 椰程信奥 · CSP复赛专题系列

专题10 · 数据结构2

背下容器接口只算入门,会选容器才算会做题。
本讲拿下 STL 三剑客:vector(动态连续、随机访问)、priority_queue(每次取最值)、 deque(两端都要动),并用四道真题练出「选型直觉」。

论坛帖子 · vector 哈夫曼树 WPL · 优先队列 龙的追踪 / 公交换乘 · deque
路线图

三剑客 · 四道真题(对应视频 41′56″)

🔧 vector 0–15′

动态数组:内存连续、可随机访问。
解决:长度事先未知、要按下标查询。

🏆 priority_queue 15–25′

二叉堆:每次拿最大 / 最小。
解决:反复「取两个最值再合并」。

🚪 deque 25–32′

双端队列:两端增删 O(1),还能随机访问。
解决:两头都要动、还要查中间。

四道真题(32′–42′):
① 论坛帖子(vector)→ ② 哈夫曼树 WPL(priority_queue)→ ③ 龙的追踪(deque)→ ④ 公交换乘 CSP-J2019 T2(deque)
本讲的暗线不是「记住多少接口」,而是三条选型判断: 长度不定 → vector;每次要最值 → 优先队列;两端都要动 → 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(链表)不能随机访问,就是因为它不连续。

3D🎲 vector = 连续内存 + 满了整体搬家🖱 拖拽旋转 · 双击复位
为什么用 3D:「连续」和「搬家」是 vector 的两个灵魂。用两条立体内存条带表现扩容时整排元素飞过去,学生立刻懂了为什么旧迭代器会失效。
动画①

vector 是怎么"自己长大"的?

连续点 push_back,观察 size(元素个数)与 capacity(已申请容量)的变化。

size = 0,capacity = 0。点「push_back 一个」开始。

扩容到底做了什么

容量不够 → 申请一块新空间(通常翻倍) → 把旧元素全部拷贝过去 → 释放旧空间。 所以扩容那一次是 O(n),但因为翻倍,均摊下来 push_back 仍是 O(1)。

考试怎么用这条性质

如果事先知道要放 1e5 个元素,先 v.reserve(100000);, 一次都不用搬家,常数直接小一大截。

⚠️ 扩容会让旧的引用/迭代器失效: 搬家之后,之前保存的 &v[0] 指向的是已被释放的旧空间。别跨 push_back 保存指针。
动画②

六个动作的代价,一次看清楚

当前有 5 个元素。点按钮试试每种动作,注意看「搬了多少个元素」。

尾部操作是主场

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 个桶(每个桶自己长)。
例题1

论坛帖子 · 用 vector 动态装回复

每个帖子一个 vector,ADD 追加、QUERY 按下标查。看指令一条条执行:

样例
点「执行下一条」开始。

建模关键

帖子编号就是桶的下标;"第 y 个回复"是 1-based,代码里要 [y-1]。

为什么不用二维数组

10 万个帖子 × 回复数不定 —— 开二维数组要么爆内存、要么不够用。每个帖子一个 vector,按需生长。

例题1

论坛帖子 · 完整代码

#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。
✅ 复杂度:ADD 均摊 O(1),QUERY O(1),总 O(N)。N=1e5 轻松过。
动画④

堆:一棵"永远上面大"的完全二叉树

大顶堆:每个结点 ≥ 它的孩子。插入走上浮,删除堆顶走下沉。点按钮单步看。

堆是空的。先 push 几个值,注意看数组下标与树形的一一对应。

数组 ↔ 树 的位置关系

结点 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> → 大顶堆,别想当然以为是小顶堆。
例题2

哈夫曼:每次抓最小的两个合并

核心结论:WPL = 每一次合并代价之和。所以不用真的建树,一路累加就行。

权值
点「合并一步」:取最小的两个 → 相加 → 新结点放回。
WPL = 0
✅ 为什么"每次取最小两个"最优? 越早被合并的结点,最后深度越大;让小的权值承担更大的深度,总和才最小。 这正是贪心的直觉,哈夫曼保证了它就是最优。
例题2

哈夫曼 · 完整代码(含取负数技巧)

#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 的写法时,统一取负最省事。

⚠️ n = 1 时:循环一次都不进,输出 0(只有一个结点,它是根,深度 0)—— 别写成 while(!pq.empty()),那会死循环。
⚠️ 答案会超 int:n=200、w=1000 时 WPL 接近 2e5×log,务必 long long。
动画⑥

deque:两头都是正门

对比一下:vector 在头部插入要搬多少元素?deque 又要搬多少?

deque 里有 5 个元素。试试在头部插入,看「移动次数」。
对比:vector 做同样操作需要移动 0 个元素。

deque 为什么两头都快

它不是一整块连续内存,而是若干块缓冲区 + 一个中控数组(map)。 前面要加元素就在前面新开一块,不用动已有元素。

代价是什么

因为不连续,它的常数比 vector 略大;at(i) 仍是 O(1)(中控数组定位块 + 块内偏移), 但缓存友好性不如 vector。

✅ 选型一句话: 只在尾部动 → vector;只在两头进出、不用看中间 → queue; 两头都要动、还要按下标查 → deque。
决策

一表看懂:该用哪一个

三个容器怎么选:看你在哪一端操作vector 动态数组尾端增删快 O(1)中间插入慢 O(n)随机访问 O(1)deque 双端队列两端增删都 O(1)适合滑动窗口例:公交换乘 45 分钟窗priority_queue 堆小次大默认大顶堆!要小顶堆必须写 priority_queue<int, vector<int>, greater<int>>。
容器尾部增删头部增删中间插入随机访问取最值典型题
vectorO(1) ✅O(n) ❌O(n)O(1) ✅O(n)论坛帖子、邻接表
priority_queue———❌ 不可访问top O(1) / 增删 O(log n) ✅哈夫曼、合并果子
dequeO(1) ✅O(1) ✅O(n)O(1) ✅O(n)龙的追踪、公交换乘
queueO(1) ✅O(1)(只能出)—❌O(n)BFS、约瑟夫环

① 长度不定 + 要按下标查

→ vector。
信号词:"第 k 个"、"动态追加"。

② 反复"取两个最值合并"

→ priority_queue。
信号词:"最小的两个"、"每次取最大"。

③ 一头进新、一头掉旧 + 要查

→ deque。
信号词:"滑动窗口"、"有效期"、"最近 k 个"。

⚠️ 竞赛里 90% 的情况 vector 就够了。 deque 真正无可替代的场景只有一类:两端都要增删,并且还要随机访问中间(如例题3、4)。
例题3

龙的追踪 · deque 存龙身

龙头走一步:新坐标 push_front,尾巴 pop_back;查第 k 节身体:随机访问 dq[k-1]。

查询
龙身 8 节。点方向键让龙头走一步,看 deque 两头各发生了什么。

为什么必须是 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];
例题4

CSP-J2019 T2 公交换乘 · deque 管票

坐地铁得票(入队尾),坐公交先扔掉过期票、再用最早能抵的那张。

样例
点「执行下一条」开始。
累计花费 = 0
✅ 建模关键:票按获得时间排队 → 队首最老 → 过期的一定在前面,所以能 pop_front 一路弹掉; "优先用最早的" → 从头往后扫第一个能用的。这两个动作合起来就是 deque 的形状。
例题4

公交换乘 · 完整代码

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,不能丢。
坑③ 不要用 vector 模拟队列: erase(begin()) 是 O(n),n=1e5 会 TLE。要么 deque,要么用数组 + 头指针。
✅ 为什么暴力扫不超时? 时间严格递增 → 45 分钟内最多 45 条记录 → 队列里有效票不超过 45 张 → 每次公交最多扫 45 次,总 O(45n)。
诊断

三剑客的易错辨析

① 要在 头部 频繁插入元素,且数据量 1e5,选哪个?

② priority_queue<int> q; 的 q.top() 是?

③ vector 扩容时,之前保存的 &v[0] 会怎样?

拿分

一道题 100 分是分档给的——不会正解也要先拿保底

复赛一道题的 100 分由十几到二十几个测试点组成, 前面几档专门为「没想出正解的人」准备。四道题各拿 30~40 分, 加起来往往就是一等奖和二等奖的分界。

第 1 档 特判 5~10 分 约 2 分钟 第 2 档 暴力 20~30 分 约 8 分钟 第 3 档 特殊性质 40~60 分 约 15 分钟 第 4 档 正解 100 分 约 25 分钟

① 特判档:先别想算法,看数据范围表

题目给的数据范围表就是出题人给你的送分清单。看到「n = 1」「只有一组数据」这类一行, 就先写个 if 直接输出答案。2 分钟换 5~10 分,全场最划算。

② 暴力档:按题意最直白地写一遍

多重循环、DFS 全枚举、朴素 O(n³)……不要优化,只要保证最小那档全对。 它既是保底分,又是后面对拍的标尺——没有它你无法证明正解是对的。

③ 特殊性质档:题目里那句「若……」

常见的送分性质:数据已经有序、所有值完全相同、规模小到可以 O(n²)、 只出现一种类型。题目写出来就是让你拿的,专门写一份即可。

④ 正解档:思路定了,就赢了八成

考场上的时间几乎都花在想思路上,不是写代码。写完先跑样例, 再用第 ② 档的暴力版造小数据对拍——这是唯一能证明你思路对的办法。

考场纪律:每档设时间上限,到点还没调通就 立刻提交当前版本,保住已有分数,再往上冲。最常见的翻车是"正解写了 50 分钟没过,连暴力分都没交上去"。
拿分

本讲 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 分钟内未使用的券,每张票从队首找可用券

50 分钟时间盒(一题的节奏):
0–5 分钟 读题 + 圈出数据范围表里最小的那几档 → 5–15 分钟 写完第 1、2 档并先交一次(保住 20~30 分)→ 15–40 分钟 冲第 3、4 档 → 40–50 分钟 造小数据对拍、检查 freopen 与文件名。
课堂上别让学生"从头想正解"。先要求每个人 15 分钟内交出第 1、2 档, 再放开冲正解。这个顺序一换,平均分通常能涨一大截——因为它杜绝了"想了 40 分钟,交上去 0 分"。
当堂检测

椰程信奥当堂测评

① 论坛帖子中,某帖只有 3 条回复,查询 QUERY x 4 应输出?

② 哈夫曼求 WPL 时,循环条件是?

③ 公交换乘中,一张"票价不够"的票应该?

椰程锦囊 · 考场选型三问
拿到题先别写代码,问三句:① 长度定不定?不定 → vector; ② 要不要每次取最值?要 → priority_queue; ③ 两头要不要都动、还要不要查中间?是 → deque。 三问答完,容器就定了,剩下的只是把接口写对。💡

目录