CSP-S 提高级课程中心 / C++进阶与STL / 第8讲
CSP-S 提高级 · C++进阶与STL · s2-05

deque、priority_queue 与 bitset

三件「专才型」武器:两端都能进出的队列、永远吐最大值的堆、把 64 个布尔压进一个机器字的位集合。

3 个核心容器 O(log n) 堆操作 n/64 bitset 提速 4 个递进案例 12 道洛谷原题
本讲 · 第 8/52 讲deque、priority_queue 与 bitset
模块 01

情景导航

用急诊分诊台的故事,把「两端进出」「永远取最急」「布尔集合」三种需求一次讲清。

故事急诊分诊台

你是市中心医院急诊科的分诊护士,值班规则有三条,看起来简单却让上一任护士天天出错。

规则一(两端都能走):救护车送来的病人从后门直接推进抢救室,普通门诊来的病人从前门排队候诊;医生叫号从前门叫,抢救则从后门直接推走。这就是双端队列——两头都能进、两头都能出。

规则二(永远先处理最急的):不管谁先来后到,屏幕上永远显示当前候诊区里病情最紧急的那一位。这不是先来先服务,而是「拿到最值」——这就是优先队列(堆)。

规则三(快速核对病史):你手上有一张卡片,100 个空格对应 100 种常见过敏原,打勾表示「该病人对此过敏」。医生问「他对青霉素过敏吗?」你只需翻到第 37 格看有没有打勾——这就是位集合 bitset:把一堆「是/否」压成一串位。

出问题的地方:某天护士用普通队列代替了优先队列,结果把轻症排在了重症前面;又用 100 个 bool 变量存过敏史,内存多用了 100 倍还容易漏改。王主任总结成一句话:「先想清楚这个场景最需要哪种能力,再选容器。」

deque 管「两头的进出」,priority_queue 管「永远取最值」,bitset 管「大量布尔位的压榨式存储」。

💡 三者的本质分工

deque(double-ended queue):解决「位置」问题——我要在序列的两端高效增删,同时还希望能按下标随机访问。它是 vector 与 list 的折中。

priority_queue:解决「优先级」问题——我不关心谁先来,只关心「当前最优的是谁」。它是一层适配器,底层用 vector + 堆算法实现,插入 O(log n)、取最值 O(1)。

bitset:解决「布尔批量运算」问题——把 N 个「是/否」压缩到 N 个 bit,并用 CPU 的整字并行指令一次处理 64 位,常数直接除以 64。

选型三问:①我要不要两端增删?→ deque;②我要不要反复取最值?→ priority_queue;③我是不是在维护一大堆布尔状态?→ bitset。

①生活类比
🚇

地铁车厢 = deque

两头都有门,前后都能上下客,中间座位按号数找得到。

→ push_front / push_back
🃏

洗牌发牌 = deque

牌从顶部抽(pop_front)也可以从底部抽(pop_back),还能查第几张。

→ 双端 + 随机访问
🚑

急诊分诊 = priority_queue

不按先来后到,永远先叫病情评分最高的那位。

→ 堆顶即最值
💡

开关面板 = bitset

一整排 64 个开关,一次拉闸就能同时改变所有位,比逐个按快得多。

→ 位并行运算
②核心定义

deque<T>(双端队列):支持在队首和队尾均以 O(1) 均摊时间插入 / 删除,同时支持 d[i] 随机访问的容器。底层是多段连续缓冲区组成的分段数组。

priority_queue<T>(优先队列):容器适配器,保证 top() 恒为当前最大(默认)元素。插入 O(log n),删除堆顶 O(log n),取堆顶 O(1)。底层是 vector + 二叉堆算法。

bitset<N>(位集合):长度固定为 N 的位序列,每一位 0/1。支持位运算 & | ^ ~ << >>、count()/any()/none() 等统计,编译期确定长度,效率极高。

懒惰删除(Lazy Deletion):不真的从堆里删元素,而是把它留到堆顶时再丢弃。用「标记 + 双堆」实现,是优先队列处理「需要删除任意元素」场景的标准技巧。

📌 本讲核心知识地图

板块核心内容对应大纲
deque 基础push_front / push_back / pop_front / pop_back / 随机访问双端队列(deque)
deque vs vector分段数组 vs 连续数组;扩容策略与迭代器失效双端队列(deque)
priority_queue默认大根堆、greater 变小根、自定义比较器、pair 入堆优先队列(priority_queue)
懒惰删除双堆 + 计数标记,实现「可删除任意元素」的堆优先队列(priority_queue)
bitset 基础声明、set / reset / flip / count / any / none / test位集合(bitset)
bitset 位运算& | ^ ~,左右移,与整型互转、to_string位集合(bitset)
bitset 优化子集枚举、传递闭包、01 背包可达性,常数 ÷ 64位集合(bitset)
③核心口诀
🔑 选容器

两头增删用 deque,反复取最值上 堆;
布尔状态一大堆,bitset 压位最省心。

🔑 防陷阱

优先队列默认大根堆,想要最小加 greater;
deque 没有 capacity,reserve 调不动;
bitset 长度编译期定死,运行时改不了 N!

模块 02

知识探究

先把三个容器的接口与陷阱逐个拆开,再用四个案例把它们用起来。

1deque 双端队列 核心

deque 的定位非常明确:它比 vector 灵活(两头都能 O(1) 增删),比 list 强(支持随机访问)。代价是内存不连续,缓存局部性稍差,常数比 vector 大一点。

1
⬅️
前端操作

push_front / pop_front,O(1) 均摊

2
➡️
后端操作

push_back / pop_back,O(1) 均摊

3
🔍
随机访问

d[i] 直接取,O(1)

4
🧩
分段存储

多块连续缓冲区 + 中控表

C++deque 全部核心接口演示
#include <bits/stdc++.h>
using namespace std;

void show(deque<int>& d, string name) {
    cout << name << " : ";
    if (d.empty()) cout << "(empty)";
    else for (int x : d) cout << x << " ";
    cout << "   [front="
         << (d.empty() ? -1 : d.front())
         << ", back="
         << (d.empty() ? -1 : d.back())
         << ", size=" << d.size() << "]\n";
}

int main() {
    deque<int> d;

    d.push_back(3);        // 队尾进:3
    d.push_back(4);        // 队尾进:3 4
    d.push_front(2);       // 队首进:2 3 4
    d.push_front(1);       // 队首进:1 2 3 4
    show(d, "四次 push 后");

    cout << "d[0]=" << d[0]            // 随机访问:1
         << ", d[3]=" << d[3] << "\n";   // 4
    cout << "front=" << d.front()
         << ", back=" << d.back() << "\n";

    d.pop_front();                // 队首出:2 3 4
    d.pop_back();                 // 队尾出:2 3
    show(d, "一前一后各出一次");

    d.insert(d.begin() + 1, 99);   // 中间插入是 O(n),尽量少用
    show(d, "中间插入 99");

    cout << "sort 前:"; for (int x : d) cout << x << " "; cout << "\n";
    sort(d.begin(), d.end());     // deque 迭代器是随机访问,可以 sort
    cout << "sort 后:"; for (int x : d) cout << x << " "; cout << "\n";

    d.clear();
    cout << "clear 后 empty = " << d.empty() << "\n";
    return 0;
}
运行结果
四次 push 后 : 1 2 3 4    [front=1, back=4, size=4]
d[0]=1, d[3]=4
front=1, back=4
一前一后各出一次 : 2 3    [front=2, back=3, size=2]
中间插入 99 : 2 99 3    [front=2, back=3, size=3]
sort 前:2 99 3 
sort 后:2 3 99 
clear 后 empty = 1
🎬 deque 的进出演示(FIFO:队尾进、队头出)

📎 上图演示的是 deque 的队列语义(队尾 push、队头 pop)。真实 deque 还能 从两端任意进出,但动画引擎只提供「一端进一端出」的示意,请在心里补上「两端都可以」这个自由度。

成员函数作用复杂度
push_back(v) / emplace_back(v)尾部插入O(1) 均摊
push_front(v) / emplace_front(v)头部插入O(1) 均摊
pop_back() / pop_front()尾部 / 头部删除O(1)
front() / back()读头 / 尾元素O(1)
operator[](i) / at(i)随机访问O(1)
size() / empty() / clear()大小 / 判空 / 清空O(1) / O(1) / O(n)
insert / erase任意位置增删O(n)
begin() / end() / rbegin()随机访问迭代器O(1)
2deque 与 vector 的差异 辨析
✅ deque 的优势

两端 O(1):push_front / pop_front 是 vector 完全没有的能力(vector 头插是 O(n))。

扩容不搬家:deque 由多个固定大小的缓冲区拼成,头部/尾部满了只需再申请一块,已有元素不动。

内存按需增长:适合「元素总量事先未知、还经常两头进出」的场景。

⚠️ deque 的代价

无 capacity / reserve:d.reserve(1000) 根本不存在,容量无法预分配。

不连续:&d[0] 拿到的指针不能当成连续数组用,传给 C 风格 API 会出事。

常数略大、缓存不友好:跨缓冲区访问会 miss cache。

维度vectordeque
内存布局单块连续数组多块缓冲区 + 中控表(分段连续)
尾部插入O(1) 均摊(可能整体搬家)O(1) 均摊(只申请新块)
头部插入O(n)O(1) 均摊
随机访问O(1)O(1)(多一次间接寻址)
capacity / reserve支持不支持
元素地址稳定扩容前稳定两端增删时中间元素地址不动
典型用途通用序列、需要连续内存双端队列、单调队列、滑动窗口
🔑 选型一句话

「只在尾部增删 + 需要连续内存」→ vector;「两头都要增删」→ deque;
「要在任意位置频繁增删」→ list;「要频繁查找 / 排序 / 求前驱后继」→ set。没有万能容器。

3priority_queue 基础:默认大根堆 核心

优先队列是一层容器适配器——它自己不存数据,而是把 vector 当成「堆数组」,用 push_heap / pop_heap 维护堆性质。默认 top() 返回最大值。

图:大根堆——每个父结点都 ≥ 它的两个儿子,根(粉色)就是全局最大值
⛰️ 堆的两条铁律
  1. 结构性:是一棵完全二叉树,因此可以用数组存:结点 i 的父亲是 i/2,儿子是 2i 与 2i+1(下标从 1 开始)。
  2. 堆序性:大根堆中 a[fa] >= a[i]。注意——堆只保证父子有序,不保证兄弟有序,所以堆数组并不是排好序的数组。
  3. 结论:top() = a[1] 是 O(1);插入 / 删除后靠 上浮 / 下沉 修复,代价是树高 O(log n)。
  4. 重要后果:堆不支持查第 k 大、也不支持任意删除,只能反复取堆顶。
C++priority_queue 基础用法(默认大根堆)
#include <bits/stdc++.h>
using namespace std;

int main() {
    priority_queue<int> pq;        // 默认就是「大根堆」

    for (int x : {3, 1, 9, 4, 7}) pq.push(x);

    cout << "size = " << pq.size() << ", top = " << pq.top() << "\n";

    cout << "依次弹堆顶(降序):";
    while (!pq.empty()) {
        cout << pq.top() << " ";
        pq.pop();
    }
    cout << "\n";

    // 注意:优先队列不能遍历、不能随机访问、不能清空单个元素
    // for (int x : pq) ... // 编译错误
    // cout << pq[0];        // 编译错误
    return 0;
}
运行结果
size = 5, top = 9
依次弹堆顶(降序):9 7 4 3 1 

🔍 代码逐段解析 · priority_queue 基础用法

声明(第 5 行) —— priority_queue<int> pq; 只写一个模板参数,默认 less<int> → 大根堆,top() 是最大值。

插入(第 7 行) —— 范围 for 把 {3,1,9,4,7} 逐个 push,每次上浮 O(log n)。

size 与 top(第 9 行) —— 输出 size=5, top=9;top 只读不删,O(1)。

弹堆顶(第 12~15 行) —— while (!pq.empty()) 循环里先读 top() 再 pop();弹出序列 9 7 4 3 1 恰为降序——这就是堆排序的原理。

易错点 —— ① 优先队列不可遍历、不可随机访问,for (int x : pq) 与 pq[0] 都编译不过;② pop() 没有返回值,取值必须先 top() 再 pop(),顺序反了就丢数据。

🎬 优先队列「进出堆顶」全过程(插入 3,1,9,4,7 再全部弹出)
1push(3) → 堆 = {3},堆顶 3
2push(1) → 挂到 3 的左儿子,1 < 3 不用上浮。堆 = {3,1},堆顶 3
3push(9) → 挂到 3 的右儿子,9 > 3 上浮,与 3 交换。堆顶变成 9
4push(4) → 挂到 1 的左儿子,4 > 1 上浮;继续和父亲 9 比,4 < 9 停。堆 = {9,1,3,4}… 注意这只是数组视图,不是有序序列
5push(7) → 挂到 1 的右儿子,7 > 1 上浮;和父亲 9 比 7 < 9 停。堆顶仍是 9
6top() → 返回 9(最大值),O(1)
7pop() → 把堆顶 9 与最后一个元素交换,删掉末尾,再把新的根下沉到合适位置。堆顶变成 7
8继续 pop:7、4、3、1 —— 弹出的顺序恰好是降序。这就是「堆排序」的原理!
📎 关键认识:堆的数组内容是「无序」的,但如果不断 pop(),弹出的序列一定是单调有序的。要全部排序需要 O(n log n)。
4小根堆、自定义比较器与 pair 入堆 高频陷阱

这是 priority_queue 最容易出错的地方:比较器的语义与直觉相反。

⚠️ 陷阱:为什么写 greater 得到的是小根堆?

优先队列的规则是:「比较器认为『较大』的元素排在堆顶」。默认 less<int> 认为数值大的更大 → 大根堆;greater<int> 认为数值小的更大 → 小根堆。记忆法:less 给「大」留位,greater 给小留位。

priority_queue<int, vector<int>, greater<int>> pq; —— 三个模板参数分别是:元素类型、底层容器、比较器,一个都不能少。

C++三种写法:大根堆 / 小根堆 / pair 默认序
#include <bits/stdc++.h>
using namespace std;

int main() {
    // ① 大根堆(默认)
    priority_queue<int> big;
    // ② 小根堆:必须写全三个模板参数
    priority_queue<int, vector<int>, greater<int>> small;

    for (int x : {5, 1, 9, 3}) { big.push(x); small.push(x); }
    cout << "大根堆 top = " << big.top() << "\n";    // 9
    cout << "小根堆 top = " << small.top() << "\n";  // 1

    // ③ pair 入堆:先按 first 比,first 相同再按 second 比
    priority_queue<pair<int, int>> pq;
    pq.push({2, 100});
    pq.push({3, 10});
    pq.push({3, 99});
    pq.push({1, 999});
    cout << "pair 堆弹出顺序:";
    while (!pq.empty()) {
        cout << "(" << pq.top().first << "," << pq.top().second << ") ";
        pq.pop();
    }
    cout << "\n";

    // ④ 存负值当小根堆用(不想写比较器时的投机写法)
    priority_queue<long long> neg;
    for (long long x : {5L, 1L, 9L}) neg.push(-x);
    cout << "用负数模拟小根堆,最小值 = " << -neg.top() << "\n";  // 1
    return 0;
}
运行结果
大根堆 top = 9
小根堆 top = 1
pair 堆弹出顺序:(3,99) (3,10) (2,100) (1,999) 
用负数模拟小根堆,最小值 = 1

🔍 代码逐段解析 · 三种堆写法

① 大根堆(第 6 行) —— 默认模板参数即 less<int>,top() 返回最大值 9。

② 小根堆(第 8 行) —— priority_queue<int, vector<int>, greater<int>> 三个参数写全,top() 返回最小值 1。

③ pair 大根堆(第 11~19 行) —— pair 默认字典序:先比 first 再比 second。压入 {2,100},{3,10},{3,99},{1,999} 后弹出顺序 (3,99)(3,10)(2,100)(1,999):first 降序,同 first 内 second 降序。

④ 负数技巧(第 23~25 行) —— 把 x 取负压进大根堆,堆顶的相反数就是原最小值;省写比较器,但值域取负时注意 INT_MIN 溢出。

易错点 —— ① 小根堆漏写中间的 vector<int> 编译不过;② pair 大根堆若想「first 大优先、second 小优先」,要存 {x, -y} 或手写比较器。

🧩 自定义比较结构体(推荐写法)
  • 写一个带 operator() 的 struct
  • 返回 true 表示 a 的优先级低于 b
  • 结构体比较器比 lambda 兼容性更好(lambda 在 C++17 前不能直接当模板参数)
  • 想改方向,只需翻转 return 里的符号
🎯 pair 入堆的口诀
  • pair 的默认比较是字典序:先比 first,再比 second
  • 想「按第一关键字从小到大、第二关键字从大到小」,就存 {first, -second} 或 {-first, second}
  • 存 {-x, val} 是把「大根堆」变「小根堆」最省事的技巧
C++自定义比较结构体:按「步数小优先、编号小优先」出堆
#include <bits/stdc++.h>
using namespace std;

struct Node {
    int step;    // 步数
    int id;      // 编号
};

// 比较器:返回 true 表示 a 的优先级「低于」b(即 a 应该排在更后面)
struct Cmp {
    bool operator()(const Node& a, const Node& b) const {
        if (a.step != b.step) return a.step > b.step;  // step 小的优先
        return a.id > b.id;                          // id 小的优先
    }
};

int main() {
    priority_queue<Node, vector<Node>, Cmp> pq;
    pq.push({5, 1});
    pq.push({2, 7});
    pq.push({2, 3});
    pq.push({9, 2});

    cout << "出堆顺序(step 升序,同 step 按 id 升序):\n";
    while (!pq.empty()) {
        Node t = pq.top(); pq.pop();
        cout << "step=" << t.step << " id=" << t.id << "\n";
    }
    return 0;
}
运行结果
出堆顺序(step 升序,同 step 按 id 升序):
step=2 id=3
step=2 id=7
step=5 id=1
step=9 id=2
🔑 方向记忆法

把比较器想成一个「谁更差谁就沉下去」的裁判:
• 返回 a.step > b.step —— step 大的更差 → 沉下去 → step 小的浮到堆顶(小根堆);
• 返回 a.step < b.step —— step 小的更差 → step 大的浮到堆顶(大根堆)。
符号方向永远和你的直觉相反,写完一定要手推两个元素验证!

5懒惰删除法:让堆支持「删掉任意一个元素」 进阶

priority_queue 只能删堆顶。但实际题目经常要「删除一个指定元素」。两种标准解法:

1
🔖
双堆 + 计数

用另一个小根堆 del 记录「待删除」的值;每次取堆顶前,先比较两个堆的堆顶,相等就同时弹出。

2
🏷️
带编号 + 标记数组

堆里存 (值, 编号),另开 alive[id] 标记。弹堆顶时若标记为已失效就直接丢弃,直到堆顶有效。

C++双堆懒惰删除:支持「插入 / 删除 x / 查最大值」
#include <bits/stdc++.h>
using namespace std;

priority_queue<int> pq;                  // 主堆:大根堆
priority_queue<int, vector<int>, greater<int>> del;  // 待删堆:小根堆

// 关键:把两个堆顶「对上账」——主堆顶等于待删堆顶的那些都是已经失效的
void cleanup() {
    while (!pq.empty() && !del.empty() && pq.top() == del.top()) {
        pq.pop();
        del.pop();
    }
}

void insert(int x) { pq.push(x); }
void erase(int x)  { del.push(x); cleanup(); }
bool empty()       { cleanup(); return pq.empty(); }
int  top()         { cleanup(); return pq.top(); }

int main() {
    for (int x : {5, 3, 9, 5, 7}) insert(x);
    cout << "top = " << top() << "\n";      // 9

    erase(9);
    cout << "删除 9 后 top = " << top() << "\n";   // 7

    erase(5);                            // 只删「一个 5」
    cout << "删除一个 5 后 top = " << top() << "\n";  // 7
    cout << "现在最大值仍是 7,说明另一个 5 还在\n";
    return 0;
}
运行结果
top = 9
删除 9 后 top = 7
删除一个 5 后 top = 7
现在最大值仍是 7,说明另一个 5 还在
为什么有效:del 堆里所有元素都曾经存在于 pq 中(作为「幽灵」留在主堆里)。两个堆必须同序:都弹「当前最大」。只有当 pq.top() == del.top() 时,才能断定主堆的最大值是个幽灵,此时同时弹出即可。易错:若把 del 写成小根堆(与主堆异序),删除多个不同值后堆顶对不上账,会返回已被删除的元素——这是懒删除最常见的隐性 bug。两个堆各 O(log n),总复杂度不变。
🎬 双堆对账动画:insert 5,3,9,5,7 → erase 9 → erase 5
1主堆 pq = {9,7,5,5,3}(大根堆视图),del = {} 空账本。top() = 9 ✅
2erase(9):把 9 记进 del → del={9};注意 9 还留在 pq 里当「幽灵」
3cleanup:pq.top()=9 与 del.top()=9 对上账 → 同时弹出,两边各少一个 9
4此时 pq = {7,5,5,3},del = {} → top() = 7 ✅
5erase(5):del = {5};pq 里有两个 5,一个是幽灵一个是活的
6cleanup:pq.top()=7 ≠ del.top()=5 → 不动。top() = 7 ✅(幽灵 5 要等浮到堆顶才被清掉,但不影响当前答案)
📎 关键:del 与主堆必须同序(都弹最大)。若把 del 写成小根堆,删除多个不同值后堆顶对不上账,会返回已删除的元素。

🔍 代码逐段解析 · 双堆懒惰删除

两个全局堆(第 5~6 行) —— pq 主堆存全部元素(含幽灵);del 与主堆同序(大根堆),记录「要删的值」。

cleanup(第 9~14 行) —— 只要 pq.top() == del.top() 就说明主堆最大值是幽灵,两边同时弹出;循环处理多个相等的幽灵。

erase(第 18 行) —— 只往 del 里记账,并不真的动 pq;随后立即 cleanup 对一次账。

top / empty(第 19~20 行) —— 取答案前必须先 cleanup,否则可能拿到幽灵。

易错点 —— ① del 必须与主堆同序,写成小根堆在删多个不同值时会出错;② cleanup 后 pq 可能为空,top() 前要判空。

6bitset 基础:把布尔数组压进位里 核心

bitset<N> 存放 N 个二进制位,N 必须是编译期常量。它支持像整数一样的位运算,同时提供 [] 随机访问和一组统计函数。

C++bitset 常用接口全览
#include <bits/stdc++.h>
using namespace std;

int main() {
    bitset<8> b;                       // 8 个位,初值全 0
    cout << "初始 b = " << b << "\n";

    b.set();                            // 全部置 1
    cout << "set() 后 = " << b << "\n";

    b.reset();                          // 全部置 0
    cout << "reset() 后 = " << b << "\n";

    b.set(0); b.set(2); b.set(3); b.set(7);   // 指定位置 1
    cout << "打点后 = " << b << "\n";      // 10001101(左边是高位)

    cout << "b[2] = " << b[2] << "\n";         // 1
    b.flip(2);                        // 翻转第 2 位
    cout << "flip(2) 后 = " << b << "\n";

    b.set(2);                          // 翻转回来(为了下面的统计)
    cout << "count() = " << b.count() << "\n";   // 4 个 1
    cout << "size()  = " << b.size()  << "\n";   // 8
    cout << "any()   = " << b.any()   << "\n";   // 1(存在 1)
    cout << "none()  = " << b.none()  << "\n";   // 0(不是全 0)
    cout << "all()   = " << b.all()   << "\n";   // 0(不是全 1)
    cout << "test(2) = " << b.test(2) << "\n";  // 1

    cout << "to_string = " << b.to_string() << "\n";
    cout << "转 unsigned long = " << b.to_ulong() << "\n";   // 141

    // 位运算与移位:整块并行,速度快 64 倍
    bitset<8> x(string("1100")), y(string("1010"));
    cout << "x & y = " << (x & y) << "\n";   // 1000
    cout << "x | y = " << (x | y) << "\n";   // 1110
    cout << "x ^ y = " << (x ^ y) << "\n";   // 0110
    cout << "~x    = " << (~x)    << "\n";   // 0011(取反补齐到 4 位)
    cout << "x << 2 = " << (x << 2) << "\n";  // 0000(1100 左移两位溢出)
    return 0;
}
运行结果
初始 b = 00000000
set() 后 = 11111111
reset() 后 = 00000000
打点后 = 10001101
b[2] = 1
flip(2) 后 = 10001001
count() = 4
size()  = 8
any()   = 1
none()  = 0
all()   = 0
test(2) = 1
to_string = 10001101
转 unsigned long = 141
x & y = 00001000
x | y = 00001110
x ^ y = 00000110
~x    = 11110011
x << 2 = 00110000

🔍 代码逐段解析 · bitset 接口全览

声明(第 5 行) —— bitset<8> b; 全 0;N 是编译期常量,不能换成运行时变量。

set/reset(第 7~11 行) —— 无参 set() 全置 1、reset() 全置 0;带下标只动一位。

打点(第 13 行) —— set(0),set(2),set(3),set(7) 后输出 10001101:bitset 打印/转字符串是高位在左,但 b[i] 下标从低位 0 数起。

flip 与单点访问(第 16~18 行) —— flip(2) 翻转第 2 位;b[2] 直接访问 O(1)。

统计(第 21~26 行) —— count=4(1 的个数)、any=1、none=0、all=0、test(2)=1;统计类全是 O(N/64)。

位运算(第 33~37 行) —— x、y 从字符串构造(字符串左端是高位);&、|、^、~、<< 都整块并行。~x = 11110011 是8 位全取反,不只是那 4 个字符。

易错点 —— ① to_ulong() 超过 unsigned long 范围会抛异常,10⁶ 位要用 to_string;② 位串 "1100" 实际是 00001100,左移后是 00110000,别当 4 位算。

用格子图直观看看这 8 个位(下标从左到右 7→0):

1
0
0
0
1
1
0
1
当前扫描的位已存储的位
接口作用复杂度
bitset<N> b;声明 N 位的位集合,初值全 0O(N/64)
b.set() / b.set(i)全部置 1 / 第 i 位置 1O(N/64) / O(1)
b.reset() / b.reset(i)全部置 0 / 第 i 位置 0O(N/64) / O(1)
b.flip() / b.flip(i)全部取反 / 第 i 位取反O(N/64) / O(1)
b.count()统计 1 的个数O(N/64)
b.any() / b.none() / b.all()是否有 1 / 是否全 0 / 是否全 1O(N/64)
b[i] / b.test(i)访问第 i 位(test 越界抛异常)O(1)
b & c b | c b ^ c ~b位与 / 位或 / 位异或 / 取反O(N/64)
b << k b >> k整体左移 / 右移 k 位O(N/64)
b.to_string() / to_ulong()转字符串 / 转无符号整数O(N)
7bitset 三大优化场景 提速 64 倍
⚡ 场景一:可达性 / 传递闭包
  • 问题:n 个点 m 条边,问能否从 u 走到 v
  • 做法:reach[i] 是 bitset,第 j 位表示 i 能否到 j
  • Floyd 变形:if (reach[i][k]) reach[i] |= reach[k];
  • 复杂度从 O(n³) 降到 O(n³/64)
⚡ 场景二:子集枚举 / 状态压缩
  • 问题:n ≤ 10⁵ 个数,问哪些和可以被凑出(值域 V)
  • 做法:bitset<V> f; f[0]=1;,对每个 x 做 f |= f << x;
  • 本质是 01 背包的「布尔可达」版
  • 复杂度 O(nV/64)

📐 为什么 bitset 能快 64 倍?

一个 unsigned long long 有 64 位。C++ 编译器会把 bitset<N> 的位运算编译成若干条整型指令,每条指令一次处理 64 个位——这就是 SIMD 的朴素版「字并行」。

所以 a | b 对 10⁵ 位的 bitset 只需约 10⁵/64 ≈ 1563 次机器运算,而手写 for 循环要 10⁵ 次。

使用前提:N 必须是编译期常量。如果 N 是运行时输入,只能用 vector<bool>(有专门的位存储特化)或手写 unsigned long long 数组。

C++bitset 做 01 背包可达性 + Floyd 传递闭包
#include <bits/stdc++.h>
using namespace std;

// ========= ① 01 背包:能否凑出和 S =========
bitset<100005> f;

// ========= ② Floyd 传递闭包:n ≤ 1000 =========
const int N = 1005;
bitset<N> reach[N];

int main() {
    // —— 01 背包演示 ——
    f[0] = 1;                    // 和为 0 总是可达
    int a[] = {2, 3, 5};
    for (int x : a)
        f |= (f << x);              // 一次位运算 = 一整轮 DP 转移
    cout << "可达和集合(≤12):";
    for (int s = 0; s <= 12; s++)
        if (f[s]) cout << s << " ";
    cout << "\n";

    // —— Floyd 传递闭包演示 ——
    int n = 4;
    int edges[][2] = {{1,2}, {2,3}, {3,4}};
    for (int i = 1; i <= n; i++) reach[i][i] = 1;   // 自己可达自己
    for (auto& e : edges) reach[e[0]][e[1]] = 1;

    for (int k = 1; k <= n; k++)
        for (int i = 1; i <= n; i++)
            if (reach[i][k]) reach[i] |= reach[k];  // 一行搞定一整行

    cout << "可达矩阵:\n";
    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= n; j++) cout << reach[i][j];
        cout << "\n";
    }
    cout << "1 能否到 4 ? " << (reach[1][4] ? "YES" : "NO") << "\n";
    cout << "4 能否到 1 ? " << (reach[4][1] ? "YES" : "NO") << "\n";
    return 0;
}
运行结果
可达和集合(≤12):0 2 3 5 7 8 10 
可达矩阵:
1111
0111
0011
0001
1 能否到 4 ? YES
4 能否到 1 ? NO

🔍 代码逐段解析 · bitset 背包 + 传递闭包

01 背包(第 9~14 行) —— f[0]=1 后对每个物品执行 f |= (f << x):一行完成一整轮「可达和」转移,物品 {2,3,5} 得 {0,2,3,5,7,8,10}。

闭包初始化(第 20~23 行) —— reach[i][i]=1 自反;每条边置单向位(无向图要置两位)。

Floyd 变形(第 25~28 行) —— 核心一行 if (reach[i][k]) reach[i] |= reach[k];:内层原本 O(n) 的 j 循环被整行或运算取代,复杂度 O(n³/64)。

输出(第 31~35 行) —— 逐位打印可达矩阵 1111/0111/0011/0001,再回答两次查询 YES/NO。

易错点 —— ① bitset 的 [i] 从低位(右端)数起,而打印是高位在左,读写方向别搞反;② 传递闭包循环里 reach[i][k] 必须先判 1 再或,白做整行或会拖慢常数。

逐行验证可达矩阵:边是 1→2、2→3、3→4。所以 1 能到 2,3,4;2 能到 3,4;3 能到 4;4 谁也到不了。矩阵第 1 行 1111、第 2 行 0111、第 3 行 0011、第 4 行 0001 —— 与输出完全一致 ✅
案例案例1:P1090 合并果子(优先队列经典) 洛谷真题

题目:有 n 堆果子,每次可以合并两堆,消耗的体力等于两堆重量之和。问把所有果子合并成一堆的最小体力消耗。n ≤ 10⁴,每堆重量 ≤ 2×10⁴。

🔍 详细分析过程

第1步:识别模型 —— 这是哈夫曼树(Huffman Tree)的经典问题。直觉:让轻的果子被多加几次、重的果子少加几次,总和最小。

第2步:贪心策略 —— 每次都取当前最小的两堆合并,把新堆放回去。

第3步:数据结构 —— 需要「反复取最小值」,并且取完两个还要把新值插回去 → 小根堆(priority_queue + greater)。

第4步:正确性直觉 —— 假设最优方案中,最小的两堆 a、b 没有先合并,那么把它们各自所在的合并链交换一下,总代价不会变大。因此「先合并最小的两堆」是安全的贪心选择。

第5步:复杂度 —— n 次插入 + (n−1) 次合并,每次 O(log n),总计 O(n log n)。n = 10⁴ 时约 1.3×10⁵ 次操作,轻松通过。

C++P1090 完整代码(小根堆)
#include <bits/stdc++.h>
using namespace std;

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

    int n; cin >> n;
    // 小根堆:三个模板参数一个都不能少
    priority_queue<long long, vector<long long>, greater<long long>> pq;

    for (int i = 0; i < n; i++) {
        long long x; cin >> x;
        pq.push(x);
    }

    long long ans = 0;
    while (pq.size() > 1) {
        long long a = pq.top(); pq.pop();   // 最小
        long long b = pq.top(); pq.pop();   // 次小
        long long s = a + b;
        ans += s;                            // 本次体力消耗
        pq.push(s);                          // 合并后的新堆放回
    }

    cout << ans << "\n";
    return 0;
}
运行结果
输入: 5
      1 2 3 4 5
输出: 33

🔍 代码逐段解析 · P1090 合并果子(小根堆)

建堆(第 7~11 行) —— 小根堆三参数写全;把 n 堆重量全部 push,每次 O(log n)。

合并循环(第 13~19 行) —— 循环条件 pq.size() > 1(每次要取两个,剩一个时停);先弹最小 a、再弹次小 b,累加 ans += s,把和 s 放回堆。

复杂度(第 21 行) —— n−1 次合并、每次 3 次堆操作 O(log n),总计 O(n log n)。

易错点 —— ① 循环条件写 >= 1 会在只剩一堆时再 pop 空堆 → RE;② 总代价最大约 10⁴×2×10⁴=2×10⁸,加强版更要 long long,ans 与堆元素都要开。

🧠 逐轮推演(1 2 3 4 5)

第 1 轮:取 1、2 → 合并成 3,ans = 3,堆 = {3, 3, 4, 5}(新 3 与原 3 并存)

第 2 轮:取 3、3 → 合并成 6,ans = 3 + 6 = 9,堆 = {4, 5, 6}

第 3 轮:取 4、5 → 合并成 9,ans = 9 + 9 = 18,堆 = {6, 9}

第 4 轮:取 6、9 → 合并成 15,ans = 18 + 15 = 33,堆 = {15}

反例验证:如果每次贪心取最大两堆(4+5=9, 3+9=12, 2+12=14, 1+14=15 合计 50),远比 33 大。这就是「小根堆」而非「大根堆」的原因。

💡 为什么体力消耗要累加「合并后的重量」?

因为被合并的这堆果子在后续每一轮都会被重新搬运一次。把每堆果子的重量乘以「它被搬运的次数」求和,恰好等于每轮合并代价的总和——这就是哈夫曼树中「带权路径长度」的含义。

案例案例2:滑动窗口最值(deque 单调队列) O(n)

题目:给定长度 n 的数组和窗口大小 k,窗口从左向右滑动,每次输出窗口内的最小值和最大值。n ≤ 10⁵。

🔍 详细分析过程

第1步:识别模型 —— 固定长度滑动窗口求最值 = 单调队列,标准做法是 deque 存下标。

第2步:单调队列的两条不变式 —— ① 队内下标严格递增(保证顺序,方便判断是否过期);② 队内对应元素值单调(求最小值就用单调递增队列)。

第3步:每个元素至多进出一次 —— 新元素入队前,先把队尾所有「比它大且比它更早」的元素弹掉(它们永远不可能成为最小值了);再检查队头是否已经滑出窗口。因此总复杂度 O(n)。

第4步:deque 的作用 —— 需要「两端操作」:队尾弹元素 + 队头弹过期元素 → 正是 deque 的用武之地。

C++deque 单调队列求滑动窗口最小 / 最大值
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 1000005;
long long a[MAXN];
int n, k;

// mode = 0 求最小值(队内单调递增); mode = 1 求最大值(队内单调递减)
vector<long long> solve(int mode) {
    deque<int> dq;                  // 存「下标」而不是值!
    vector<long long> res;

    for (int i = 1; i <= n; i++) {
        // ① 队尾维护单调性:把「不可能成为答案」的旧元素弹掉
        while (!dq.empty() &&
               (mode == 0 ? a[dq.back()] >= a[i]   // 求最小:弹掉更大的
                            : a[dq.back()] <= a[i])) // 求最大:弹掉更小的
            dq.pop_back();

        dq.push_back(i);

        // ② 队头过期判断:下标 <= i-k 的已经滑出窗口
        while (dq.front() <= i - k) dq.pop_front();

        // ③ 窗口形成后开始记录答案,队头就是最值
        if (i >= k) res.push_back(a[dq.front()]);
    }
    return res;
}

int main() {
    ios::sync_with_stdio(0);
    cin.tie(0);
    cin >> n >> k;
    for (int i = 1; i <= n; i++) cin >> a[i];

    vector<long long> mn = solve(0);
    vector<long long> mx = solve(1);

    for (long long v : mn) cout << v << " ";
    cout << "\n";
    for (long long v : mx) cout << v << " ";
    cout << "\n";
    return 0;
}
运行结果
输入: 8 3
      1 3 -1 -3 5 3 6 7
输出: -1 -3 -3 -3 3 3
      3 3 5 5 6 7

🧠 求最小值的逐步推演(k = 3)

i=1, a=1:队空 → dq=[1]

i=2, a=3:1 < 3,不弹 → dq=[1,2]

i=3, a=−1:弹掉 a[2]=3、a[1]=1(都比 −1 大)→ dq=[3],窗口已满 → 输出 a[3] = −1

i=4, a=−3:弹掉 a[3]=−1 → dq=[4],输出 −3

i=5, a=5:队尾 a[4]=−3 < 5 不弹 → dq=[4,5],输出 a[4] = −3

i=6, a=3:弹掉 a[5]=5 → dq=[4,6],队头 4 ≤ 6−3=3?不成立,不过期 → 输出 −3

i=7, a=6:不弹 → dq=[4,6,7],队头 4 ≤ 7−3=4 → 过期弹出 → dq=[6,7],输出 a[6] = 3

i=8, a=7:不弹 → dq=[6,7,8],队头 6 ≤ 8−3=5?不成立 → 输出 a[6] = 3

结果:−1 −3 −3 −3 3 3 ✅ 与运行结果一致。

易错提醒:deque 里必须存下标,不能只存值——因为要判断「队头是否滑出窗口」,只存值就无从判断下标。这是单调队列最高频的错误。
案例案例3:bitset 求可达性(44 倍加速现场) 位运算

题目:n 个点 m 条有向边(n ≤ 1000,m ≤ 10⁵),q 次询问「从 u 能否走到 v」。用朴素 Floyd 是 O(n³) = 10⁹,会超时;用 bitset 优化到 O(n³/64) ≈ 1.6×10⁷,秒过。

❌ 朴素 Floyd 传递闭包

for k: for i: for j: f[i][j] |= f[i][k] && f[k][j];

内层是「单点赋值」,每次只处理 1 个 j。

n = 1000 时约 10⁹ 次操作 → 超时。

✅ bitset 优化版

for k: for i: if (f[i][k]) f[i] |= f[k];

内层是「整行按位或」,一次处理 64 个 j。

n = 1000 时约 1.6×10⁷ 次机器操作 → 轻松通过。

C++bitset 优化传递闭包(完整可过题)
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 1005;
bitset<MAXN> f[MAXN];          // f[i] 的第 j 位 = 1 表示 i 能到 j

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

    int n, m; cin >> n >> m;
    for (int i = 1; i <= n; i++) f[i][i] = 1;   // 自反闭包

    for (int i = 0; i < m; i++) {
        int u, v; cin >> u >> v;
        f[u][v] = 1;
    }

    // 传递闭包:O(n^3 / 64)
    for (int k = 1; k <= n; k++)
        for (int i = 1; i <= n; i++)
            if (f[i][k]) f[i] |= f[k];      // ★ 核心只有这一行!

    int q; cin >> q;
    while (q--) {
        int u, v; cin >> u >> v;
        cout << (f[u][v] ? "YES\n" : "NO\n");
    }
    return 0;
}
运行结果
输入: 4 4
      1 2
      2 3
      3 4
      4 2
      4
      1 4
      4 1
      2 4
      3 2
输出: YES
      NO
      YES
      YES

🔍 代码逐段解析 · bitset 优化传递闭包

声明(第 5~6 行) —— bitset<MAXN> f[MAXN]:n 个 bitset,每个 1005 位约 128 字节,总内存 ~128KB。

初始化(第 9~13 行) —— f[i][i]=1 自反闭包(自己可达自己);每条有向边置 f[u][v]=1。

核心三行(第 16~18 行) —— if (f[i][k]) f[i] |= f[k]:若 i 能到 k,就把 k 能到的所有点并入 i 的可达集;内层整行或一次处理 64 位,总 O(n³/64)。

查询(第 20~24 行) —— 闭包建好后每次查询 O(1),直接看 f[u][v] 这一位。

易错点 —— ① 忘了 f[i][i]=1,经过「自己」的中转路径会算漏;② n ≤ 1000 用全局 bitset 数组才开得下,运行时长度不行。

🧠 逐条验证

图中有边 1→2、2→3、3→4、4→2。可见 2、3、4 构成一个环,环上任意两点互达。

1 → 4:1→2→3→4,可达 → YES ✅

4 → 1:4 只能在 {2,3,4} 里打转,出不去 → NO ✅

2 → 4:2→3→4,可达 → YES ✅

3 → 2:3→4→2,可达 → YES ✅

复杂度分析:三层循环 10⁹ 次,但每次内层是「1005 位整行或」,实际执行约 10⁹/64 ≈ 1.6×10⁷ 条机器指令。这就是 bitset 的威力——一行代码换 64 倍速度。

案例案例4:懒删除求动态第 k 大 综合

题目:维护一个可重复集合,三种操作共 q 次(q ≤ 10⁵):1 x 插入 x;2 x 删除一个 x;3 k 查询当前第 k 大的数(保证存在)。

🔍 为什么需要「懒删除 + 编号」

堆只能给出第 1 大(堆顶)。要第 K 大,若 K 很小(本题 K=3),可以「把堆顶弹出 K 个、记下第 K 个、再全部放回去」,单次 O(K log n)。

难点在删除:priority_queue 没有删除指定元素的接口。解决办法是给每个插入的副本编号 id,堆里存 (值, 编号);删除时用 ids[x] 找到一个存活的编号,把 alive[id] 置 0,堆里的「幽灵」等它浮到堆顶时再顺手清掉——这就是带编号的懒惰删除(比双堆对账更通用:能删任意值、不怕重复值)。

复杂度:插入 O(log n);删除 O(log n);查询 O(K log n)。总 O(q log n)(K 当常数)。若题目 K 任意大,请改用 pb_ds 平衡树或对顶堆。

C++(值,编号) + alive 标记:懒删除求动态第 K 大
#include <bits/stdc++.h>
using namespace std;

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

    int K, q; cin >> K >> q;

    priority_queue<pair<long long, int>> H;   // 大根堆:(值, 编号)
    map<long long, priority_queue<int, vector<int>, greater<int>>> ids;  // 值 -> 存活编号
    vector<char> alive(q + 1, 0);          // alive[id]:这个副本是否还活着
    int timer = 0;                       // 给每次插入的副本编号

    while (q--) {
        int op; cin >> op;
        if (op == 1) {
            long long x; cin >> x;
            alive[++timer] = 1;
            ids[x].push(timer);
            H.push({x, timer});               // 幽灵也留在堆里,靠标记过滤
        } else if (op == 2) {
            long long x; cin >> x;
            if (ids[x].empty()) continue;      // x 不存在,忽略
            int t = ids[x].top(); ids[x].pop();
            alive[t] = 0;                     // ★ 懒删除:只打标记,不动堆
        } else {
            vector<pair<long long, int>> back; // 弹出的前 K 个,查完放回去
            pair<long long, int> kth;
            for (int c = 0; c < K; c++) {
                while (!H.empty() && !alive[H.top().second]) H.pop();  // 清幽灵堆顶
                kth = H.top(); back.push_back(kth); H.pop();
            }
            cout << kth.first << "\n";          // 第 K 大 = 最后弹出的那个
            for (auto& p : back) H.push(p);
        }
    }
    return 0;
}
运行结果(K = 3)
输入:
3
10
1 5
1 8
1 2
1 9
3
1 7
2 9
3
2 8
3
输出:
5
5
2
🔍 代码逐段解析 · 懒删除求动态第 K 大
1三个数据结构:H 是 (值,编号) 大根堆;ids[x] 是值 x 的存活编号表;alive[id] 标记副本生死
2插入 op1:timer 自增得到新编号,alive 置 1、push 进 ids 和 H——幽灵与活物共存于堆
3删除 op2:ids[x] 为空直接忽略;否则取出一个存活编号 t,alive[t]=0——堆完全不动,O(log n)
4查询 op3:循环 K 次,每次先把堆顶的幽灵弹干净(!alive[H.top().second]),再弹真元素
5第 K 次弹出的就是第 K 大;样例三次查询分别弹出 9,8,5 / 8,7,5 / 7,5,2,各取最后一个 → 5、5、2
6放回:弹出的 K 个元素 push 回 H——查询绝不能缩小集合
⚠️ 易错:① 查询必须保证集合至少 K 个元素(题目保证);② alive 数组大小按「插入次数」开,不是 q;③ K 任意大时此法退化,改用 pb_ds 平衡树。

🧠 逐步推演(K = 3)

插入 5、8、2、9 → 活跃集合 {2,5,8,9}。3:依次弹出 9、8、5,第三个是 5 → 输出 5,随后把弹出的 3 个原样放回堆里。

插入 7 → {2,5,7,8,9};删除 9 → ids[9] 弹出它的编号、alive=0,堆里的 9 变成幽灵 → 活跃集合 {2,5,7,8}。3:弹 8、7、5 → 第 3 个是 5

删除 8 → 活跃集合 {2,5,7}。3:弹 7、5、2 → 第 3 个是 2

结论:三次查询依次输出 5 5 2。两个关键时序:① 查询时先清幽灵堆顶再弹 K 个;② 弹出的 K 个元素必须原样放回,否则集合被破坏。

注意:懒删除不会立刻清理幽灵元素,只是等它们浮到堆顶才丢弃;ids[x] 为空说明 x 已不存在,直接忽略。若题目要求 K 任意大或求任意第 k 小,请换 pb_ds 平衡树或对顶堆方案。
模块 03

实践应用

完整题目 + 样例 + 多角度分析 + 代码 + 运行结果。

题1合并果子(优先队列 / 手写堆对比)
难度普及- · 贪心 + 堆

题目背景

在一个果园里,多多已经将所有的果子打了下来,而且按果子的不同种类分成了不同的堆。多多决定把所有的果子合成一堆。每一次合并,多多可以把两堆果子合并到一起,消耗的体力等于两堆果子的重量之和。可以看出,所有的果子经过 n−1 次合并之后,就只剩下一堆了。多多在合并果子时总共消耗的体力等于每次合并所消耗体力之和。

请设计出合并的次序方案,使多多耗费的体力最少,并输出这个最小的体力耗费值。

输入格式:第一行一个整数 n,表示果子的种类数。第二行包含 n 个整数,用空格分隔,第 i 个整数是第 i 种果子的数目。

输出格式:一行,一个整数,即最小的体力耗费值。

输入样例:5
1 2 3 4 5

输出样例:33

数据范围:1 ≤ n ≤ 10⁴,1 ≤ 每堆果子数 ≤ 2×10⁴;1s / 128MB。
加强版提示:若 n ≤ 10⁵、每堆 ≤ 10⁹,体力总和可达 10¹³ 级,必须用 long long。

方法一:priority_queue(推荐)O(n log n),5 行搞定

用 priority_queue<int, vector<int>, greater<int>> 直接维护最小值,取两个、合并、放回。代码极短且不易错。

方法二:手写小根堆O(n log n),常数更小

当 n 达到 10⁶ 级别、或需要同时维护多个堆时,手写堆(shift_up / shift_down)能省掉 STL 的函数调用开销,常数约小 2~3 倍。竞赛中除非被卡常数,一般不必手写。

为什么不能用排序一次解决?因为合并后的新堆要重新参与「取最小」的比较,排好的序会被打破。必须用支持「动态取最小 + 动态插入」的结构——这就是堆存在的意义。

C++方法一:STL 小根堆(完整代码)
#include <bits/stdc++.h>
using namespace std;

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

    int n; cin >> n;
    priority_queue<long long, vector<long long>, greater<long long>> pq;
    for (int i = 0; i < n; i++) {
        long long x; cin >> x;
        pq.push(x);
    }

    long long ans = 0;
    while (pq.size() > 1) {
        long long a = pq.top(); pq.pop();
        long long b = pq.top(); pq.pop();
        ans += a + b;
        pq.push(a + b);
    }
    cout << ans << "\n";
    return 0;
}
C++方法二:手写小根堆(数组实现,下标从 1 开始)
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005;
long long h[MAXN];     // 小根堆:h[i] <= h[2i], h[i] <= h[2i+1]
int sz = 0;

void shift_up(int i) {                 // 上浮
    while (i > 1 && h[i] < h[i >> 1]) {
        swap(h[i], h[i >> 1]);
        i >>= 1;
    }
}

void shift_down(int i) {               // 下沉
    while ((i << 1) <= sz) {
        int s = i << 1;                     // 左儿子
        if (s + 1 <= sz && h[s + 1] < h[s]) s++;   // 选较小的儿子
        if (h[i] <= h[s]) break;
        swap(h[i], h[s]);
        i = s;
    }
}

void push(long long v) { h[++sz] = v; shift_up(sz); }
long long top()        { return h[1]; }
void pop()            { h[1] = h[sz--]; shift_down(1); }

int main() {
    ios::sync_with_stdio(0);
    cin.tie(0);
    int n; cin >> n;
    for (int i = 0; i < n; i++) { long long x; cin >> x; push(x); }

    long long ans = 0;
    while (sz > 1) {
        long long a = top(); pop();
        long long b = top(); pop();
        ans += a + b;
        push(a + b);
    }
    cout << ans << "\n";
    return 0;
}
运行结果(两种代码输出相同)
输入: 5
      1 2 3 4 5
输出: 33

💡 思考:三个堆一起合并(三叉合并)怎么办?

如果每次可以合并任意数量的堆,贪心策略会变成「每次取最小的 k 堆」,此时需要维护多个指针或用更复杂的数据结构。而经典的「每次合并两堆」问题,正是哈夫曼编码的基础模型,也是堆最经典的入门应用。

题2滑动窗口最大值(单调队列 / 暴力对比)
难度普及+ · 单调队列

题目背景

有一个长为 n 的序列 a,以及一个大小为 k 的窗口。现在这个窗口从左边开始向右滑动,每次滑动一个单位,求出每次滑动后窗口中的最大值和最小值。

输入格式:第一行有两个整数 n, k。第二行 n 个整数,表示序列 a。

输出格式:输出共两行。第一行为每次窗口滑动的最小值,第二行为每次窗口滑动的最大值。

输入样例:8 3
1 3 -1 -3 5 3 6 7

输出样例:-1 -3 -3 -3 3 3
3 3 5 5 6 7

数据范围:1 ≤ k ≤ n ≤ 10⁶,|a[i]| ≤ 10⁹;1s / 256MB。

方法一:暴力枚举每个窗口O(nk),n=k=10⁶ 时达 10¹² 必超时

每个窗口扫 k 个元素求最值。数据范围一放大立刻 TLE,只能拿部分分。

方法二:deque 单调队列O(n),每个元素至多进出队各一次

维护一个下标递增、值单调的 deque。求最小值时队内递增,队头即答案;求最大值时队内递减。这是滑动窗口最值的标准最优解。

方法三:priority_queue + 懒删除O(n log n),常数大但易写

把 (值, 下标) 丢进大根堆,取堆顶时如果下标已经滑出窗口就丢弃。写法比单调队列直观,但 n = 10⁶ 时可能被卡常数。

C++方法二:deque 单调队列(完整代码,O(n))
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 1000005;
long long a[MAXN];
int n, k;

void run(int mode) {          // 0 = 求最小, 1 = 求最大
    deque<int> dq;
    for (int i = 1; i <= n; i++) {
        while (!dq.empty() &&
               (mode == 0 ? a[dq.back()] >= a[i] : a[dq.back()] <= a[i]))
            dq.pop_back();
        dq.push_back(i);
        while (dq.front() <= i - k) dq.pop_front();
        if (i >= k) cout << a[dq.front()] << (i == n ? '\n' : ' ');
    }
}

int main() {
    ios::sync_with_stdio(0);
    cin.tie(0);
    cin >> n >> k;
    for (int i = 1; i <= n; i++) cin >> a[i];
    run(0);
    run(1);
    return 0;
}
C++方法三:大根堆 + 懒删除(写法更直观)
#include <bits/stdc++.h>
using namespace std;

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

    int n, k; cin >> n >> k;
    vector<long long> a(n + 1);
    for (int i = 1; i <= n; i++) cin >> a[i];

    // pair 默认先比 first(值)再比 second(下标),所以是大根堆
    priority_queue<pair<long long, int>> pq;

    for (int i = 1; i <= n; i++) {
        pq.push({a[i], i});
        if (i >= k) {
            // 懒删除:堆顶下标若已滑出窗口,直接丢弃
            while (pq.top().second <= i - k) pq.pop();
            cout << pq.top().first << (i == n ? '\n' : ' ');
        }
    }
    return 0;
}
运行结果
输入: 8 3
      1 3 -1 -3 5 3 6 7
输出(最小): -1 -3 -3 -3 3 3
输出(最大): 3 3 5 5 6 7

🔍 代码逐段解析 · 单调队列 vs 懒删除堆

方法二 · run(mode)(第 7~16 行) —— mode=0 弹队尾更大的、mode=1 弹队尾更小的,三步「压尾 → 判头过期 → 取头」与知识探究版一致;输出时用 i == n ? '\n' : ' ' 控制行末。

方法二 · 复杂度 —— 每个下标至多进队一次、出队一次,两轮总计 O(n)。

方法三 · 建 (值,下标) 堆(第 10~17 行) —— pair 默认先比 first(值)再比 second(下标),大根堆取最大;pq.top().second <= i-k 的堆顶已出窗,循环丢弃(懒删除),剩下的堆顶就是当前最大值。

方法三 · 复杂度 —— O(n log n),常数大数倍,n = 10⁶ 时可能被卡。

易错点 —— ① 方法三只演示求最大值,求最小值要换小根堆(greater 三参数);② 懒删除堆的 while 清理必须在 top() 之前,否则取到出窗元素。

复杂度对比:单调队列 O(n) ≈ 10⁶ 次操作;懒删除堆 O(n log n) ≈ 2×10⁷ 次操作,虽然同一量级但常数大数倍。n = 10⁶ 的模板题在洛谷上,懒删除堆可能会因为常数被卡到 TLE,考场上优先写单调队列。
题3bitset 求可达和(01 背包位运算版)
难度普及+ · bitset 优化 DP

题目背景

给定 n 个正整数 a₁…aₙ 和一个目标 S。问能否从中选出若干个数(每个至多选一次),使它们的和恰为 S。

输入格式:第一行两个整数 n, S。第二行 n 个正整数。

输出格式:一行,YES 或 NO;若为 YES,再输出任意一组选中方案的下标(从 1 开始、空格分隔)。

输入样例:5 11
2 3 5 4 7

输出样例:YES
1 3 4(验证:a₁+a₃+a₄ = 2+5+4 = 11 ✅)

数据范围:n ≤ 1000,S ≤ 10⁶,a[i] ≤ 10⁶;1s / 256MB。

方法一:bool 数组 DPO(nS) = 10⁹,超时

f[j] |= f[j - a[i]],内层循环 S 次,总操作 n×S = 1000×10⁶ = 10⁹,必然 TLE。

方法二:bitset 优化O(nS/64) ≈ 1.6×10⁷,秒过

f |= (f << a[i]); —— 一行代码等价于「对全部 j 同时做一次转移」,一条机器指令处理 64 个状态。这是 bitset 最经典的用途之一。

方案还原:再做一遍「逆推」——从 j = S 开始,若 f_before[i][j] 为真则说明第 i 个数没被选中;否则说明被选中,j 减去 a[i] 继续往前。为支持逆推需要保存每一轮的 bitset,或改用「自后向前」的 0/1 背包技巧。

C++bitset 01 背包:判可达 + 还原方案
#include <bits/stdc++.h>
using namespace std;

const int MAXS = 1000005;
bitset<MAXS> f;            // f[j] = 1 表示和 j 可以被凑出
bitset<MAXS> snap[1005];  // 存档,用于还原方案

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

    int n, S; cin >> n >> S;
    vector<int> a(n + 1);
    for (int i = 1; i <= n; i++) cin >> a[i];

    f[0] = 1;                     // 和为 0 永远可达
    for (int i = 1; i <= n; i++) {
        snap[i] = f;                   // 存档:加入第 i 个数之前的状态
        f |= (f << a[i]);              // ★ 核心一行:整批状态同时转移
    }

    if (!f[S]) { cout << "NO\n"; return 0; }
    cout << "YES\n";

    // 从后往前还原:若 snap[i] 里 j 已可达,说明第 i 个数没被选
    vector<int> pick;
    int j = S;
    for (int i = n; i >= 1; i--) {
        if (j >= 0 && !snap[i][j]) {     // 加之前不可达 → 是第 i 个数贡献的
            pick.push_back(i);
            j -= a[i];
        }
    }
    for (int i = (int)pick.size() - 1; i >= 0; i--)
        cout << pick[i] << (i ? ' ' : '\n');
    return 0;
}
运行结果
输入: 5 11
      2 3 5 4 7
输出: YES
      1 3 4
(验证:2 + 5 + 4 = 11 ✅)
🔍 代码逐段解析 · bitset 01 背包 + 存档还原
1两个全局量:f 是当前可达集合(f[j]=1 表示和 j 可凑出);snap[i] 是「加入第 i 个数之前」的存档,用于还原方案
2DP 主循环:每轮先 snap[i] = f 存档,再执行核心一行 f |= (f << a[i])——一条指令并行处理全部 j 的转移
3判可达:f[S] 为 0 直接输出 NO;否则开始倒序还原
4还原判据:若 snap[i][j] 为 1,说明不用第 i 个数也能凑出 j → 不选;为 0 → 第 i 个数必选,j -= a[i]
5样例还原:i=5 不选 7 → i=4 选 4(j:11→7)→ i=3 选 5(j:7→2)→ i=2 不选 3 → i=1 选 2(j:2→0),得 {1,3,4}
6输出:pick 是倒序收集的,逆序打印得升序下标 1 3 4
⚠️ 易错:① snap 必须在转移「之前」存档,顺序反了还原判据全错;② 存档还原占 O(n·S/8) 字节内存,n、S 都大时要换「记录每个和最早由哪个数达成」的 O(S) 做法。

🧠 逐轮位运算推演(前 3 个数 2, 3, 5)

初始 f = 1(只有第 0 位是 1)

加入 2:f |= f << 2 → f 的第 {0, 2} 位为 1

加入 3:f |= f << 3 → f 的第 {0, 2, 3, 5} 位为 1(2+3=5 也出现了)

加入 5:f |= f << 5 → 新增 {5, 7, 8, 10},最终 f 的第 {0,2,3,5,7,8,10} 位为 1

加入 4:f 左移 4 位后并进来,第 11 位变为 1(由原来的第 7 位 7+4=11 得到)→ 输出 YES ✅

还原过程(从 i=5 倒着走):j = 11。i=5 时 snap[5][11] 已经是 1(前 4 个数 2+5+4=11 就可达),所以第 5 个数 7 不选,j 保持 11。i=4 时 snap[4][11] 为 0(前 3 个数凑不出 11)→ 选中第 4 个数 4,j = 11−4 = 7。i=3 时 snap[3][7] 为 0(前 2 个数只有 {0,2,3,5},凑不出 7)→ 选中第 3 个数 5,j = 7−5 = 2。i=2 时 snap[2][2] 为 1(第 1 个数 2 单独可达)→ 不选 3。i=1 时 snap[1][2] 为 0 → 选中第 1 个数 2,j = 2−2 = 0,还原结束。

最终方案:{1, 3, 4} → 2 + 5 + 4 = 11 ✅ 与输出一致。

核心领悟:整个 DP 转移只有 f |= (f << a[i]) 一行,但它的语义是「对所有 j 同时做一次转移」。这就是 bitset 优化的精髓——把「对每个 j 循环」变成「整块并行移位」。

提示:本题的「存档还原」会占用 O(n × S/8) 字节内存(1000 × 125KB ≈ 125MB),刚好卡在 256MB 内。如果内存吃紧,可以改成「记录每个和最早被哪个数达成」的 last 数组,只用 O(S) 内存。
模块 04

总结提升

①核心要点与万能模板
知识板块必须记住的核心
deque 声明deque<int> d;,支持 d[i] 随机访问,迭代器可 sort
deque 双端操作push_front / push_back / pop_front / pop_back,全部 O(1) 均摊
deque 禁忌没有 capacity / reserve;内存不连续,别传裸指针给 C API
priority_queue 默认priority_queue<int> 是大根堆,top() 是最大值
小根堆写法priority_queue<int, vector<int>, greater<int>>,三参数缺一不可
比较器方向返回 a.x > b.x → x 小的浮到堆顶;符号与直觉相反,务必手推
pair 入堆默认按 first 再按 second 的字典序;存 {-x, y} 可把大根堆当小根堆
懒惰删除双堆互消(pq.top()==del.top() 同时弹)或 (值,编号) + alive 标记
bitset 声明bitset<N> b; —— N 必须是编译期常量,运行时不可变
bitset 统计count / any / none / all,全部 O(N/64)
bitset 位运算& | ^ ~ << >> 整块并行,比 for 循环快 64 倍
bitset 三大场景传递闭包 f[i] |= f[k];01 背包 f |= f << x;子集枚举
复杂度速览deque 两端 O(1);堆 push/pop O(log n)、top O(1);bitset 位运算 O(N/64)
C++万能模板:三大容器核心操作速记卡
#include <bits/stdc++.h>
using namespace std;

// ============ 一、deque 模板 ============
void dequeTemplate() {
    deque<int> d;
    d.push_back(3);          // 尾部进
    d.push_front(1);         // 头部进(vector 做不到)
    d.pop_back();              // 尾部出
    d.pop_front();             // 头部出
    int a = d.front(), b = d.back();
    int c = d[0];              // 随机访问 O(1)
    sort(d.begin(), d.end());  // 迭代器是随机访问,可以排序
    // d.reserve(1000);        // ❌ 不存在这个接口

    // ★ 单调队列(求滑动窗口最小值)核心四行
    // while (!dq.empty() && a[dq.back()] >= a[i]) dq.pop_back();
    // dq.push_back(i);
    // while (dq.front() <= i - k) dq.pop_front();
    // if (i >= k) ans = a[dq.front()];
}

// ============ 二、priority_queue 模板 ============
void pqTemplate() {
    // ① 大根堆(默认)
    priority_queue<int> big;
    // ② 小根堆(三个参数写全)
    priority_queue<int, vector<int>, greater<int>> small;
    // ③ pair 大根堆(先比 first)
    priority_queue<pair<int,int>> pp;

    big.push(5);
    int t = big.top();          // O(1) 取最值
    big.pop();                  // O(log n) 删堆顶
    bool e = big.empty();
    int s = big.size();
    // big.clear();             // ❌ 没有 clear,只能循环 pop
    // for (int x : big) ;       // ❌ 不可遍历
}

// 自定义比较器:返回 true 表示 a 优先级更低(沉下去)
struct Node { int dis, id; };
struct Cmp {
    bool operator()(const Node& a, const Node& b) const {
        if (a.dis != b.dis) return a.dis > b.dis;  // dis 小的优先
        return a.id > b.id;
    }
};
// priority_queue<Node, vector<Node>, Cmp> pq;

// ============ 三、bitset 模板 ============
const int MAXN = 100005;

void bitsetTemplate() {
    bitset<MAXN> b;
    b.set();  b.reset();  b.flip();       // 全 1 / 全 0 / 全取反
    b.set(3);  b.reset(3);  b.flip(3);    // 单点操作
    cout << b[3] << b.count() << b.any() << b.none() << b.all();

    bitset<MAXN> c;
    b &= c;  b |= c;  b ^= c;  b = ~c;    // 位运算 O(N/64)
    b <<= 3;  b >>= 3;                     // 移位
    string s = b.to_string();

    // ★ 场景 A:01 背包可达性
    // f[0] = 1;
    // for (int x : a) f |= (f << x);
    // if (f[S]) cout << "YES";

    // ★ 场景 B:传递闭包(Floyd 位运算版)
    // for (int k = 1; k <= n; k++)
    //     for (int i = 1; i <= n; i++)
    //         if (g[i][k]) g[i] |= g[k];
}

// ============ 四、手写小根堆(下标从 1 开始)============
long long h[100005]; int sz = 0;
void upHeap(int i)   { while (i > 1 && h[i] < h[i>>1]) { swap(h[i], h[i>>1]); i >>= 1; } }
void downHeap(int i) { while ((i<<1) <= sz) { int s = i<<1;
        if (s + 1 <= sz && h[s+1] < h[s]) s++;
        if (h[i] <= h[s]) break; swap(h[i], h[s]); i = s; } }
void hpPush(long long v) { h[++sz] = v; upHeap(sz); }
void hpPop()              { h[1] = h[sz--]; downHeap(1); }

🔍 代码逐段解析 · 三大容器万能模板

一、dequeTemplate —— 两端进出、d[0] 随机访问、整体 sort;注释标出 reserve 不存在;末尾附单调队列核心四行(压尾 / 去尾 / 判头过期 / 取头)。

二、pqTemplate —— 大根堆、小根堆、pair 堆三种声明;top O(1)、pop O(log n);注释标出没有 clear()、不可遍历——清空用 pq = {};。

比较器 Cmp —— operator()(const Node&, const Node&) const,返回 a.dis > b.dis 表示 dis 小的优先;dis 相同比 id,保证出堆顺序确定。

三、bitsetTemplate —— 全量/单点 set、reset、flip;count/any/none/all 统计;&=、|=、^=、移位;末尾附 01 背包与传递闭包两个「★ 场景」的骨架代码。

四、手写堆 —— upHeap 与父亲比、downHeap 选较小儿子;hpPush 放 h[++sz] 上浮,hpPop 用末元素盖顶下沉——与 q1 方法二完全一致,可直接当板子背。

易错点 —— ① 手写堆全局 sz 别忘初始化 0;② 模板里 b = ~c 是把 c 取反赋给 b,注意别和 b.flip() 混淆。

②拓展案例
拓1对顶堆求动态中位数

题目:依次读入 n 个数,每读入一个就输出当前所有数的中位数。n ≤ 10⁵。

思路:用一个大根堆 lo 存较小的一半、一个小根堆 hi 存较大的一半,并始终维持两者大小差 ≤ 1。中位数就是 lo.top()。每次插入后调整平衡,调整总次数是 O(n),所以总复杂度 O(n log n)。

C++对顶堆求中位数(P1168 模型)
#include <bits/stdc++.h>
using namespace std;

priority_queue<int> lo;                                          // 大根堆:较小的一半
priority_queue<int, vector<int>, greater<int>> hi;            // 小根堆:较大的一半

void add(int x) {
    if (lo.empty() || x <= lo.top()) lo.push(x);
    else hi.push(x);

    // 保持 lo.size() == hi.size() 或 lo.size() == hi.size()+1
    if ((int)lo.size() > (int)hi.size() + 1) { hi.push(lo.top()); lo.pop(); }
    if ((int)hi.size() > (int)lo.size())         { lo.push(hi.top()); hi.pop(); }
}

int main() {
    ios::sync_with_stdio(0);
    int n; cin >> n;
    for (int i = 0; i < n; i++) {
        int x; cin >> x;
        add(x);
        if (i % 2 == 0) cout << lo.top() << "\n";   // P1168:奇数个时输出
    }
    return 0;
}
运行结果(P1168 只在奇数个时输出中位数)
输入: 7
      3 1 5 2 8 7 4
输出:
3
3
3
4
与上一讲的联系:s2-04 里我们用两个 multiset 做了同样的事。堆版本的优势是常数更小、内存更省;multiset 版本的优势是可以顺便求第 k 小。同一思想、两种实现,这就是「一题多解」的典型对比。

🔍 代码逐段解析 · 对顶堆求动态中位数

两个堆(第 4~5 行) —— lo 大根堆存较小一半,hi 小根堆存较大一半;不变式:lo.size() == hi.size() 或 lo.size() == hi.size()+1。

add 分流(第 7~9 行) —— x <= lo.top() 进 lo,否则进 hi;lo 空时直接进 lo。

两次 if 调平(第 11~12 行) —— 单次插入只会让大小差越界 1 个单位,所以各用一次 if 搬一个堆顶即可恢复平衡。

输出时机(第 19 行) —— i % 2 == 0(i 从 0 起)即每读入奇数个数时输出 lo.top(),样例输出 3、3、3、4。

易错点 —— ① 两个 if 的顺序(先 lo 超、后 hi 超)不能颠倒;② size() 是无符号数,比较前要 (int) 强转。

拓2多路归并:求第 k 小的和

题目:两个长度均为 n 的递增数组 A、B(n ≤ 10⁵),求 A[i]+B[j] 的所有 n² 个和中最小的 n 个。

思路一:暴力全算再排序O(n²log n),n=10⁵ 时 10¹⁰ 超时

思路二:小根堆多路归并O(n log n) 把问题看成 n 条有序链:A[0]+B[0] ≤ A[0]+B[1] ≤ … 堆里先放入 (A[i]+B[0], i, 0) 共 n 项,每次弹出最小值后把同一行的下一个推进堆,弹出 n 次即得答案。

C++小根堆多路归并(P1631 模型)
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(0);
    int n; cin >> n;
    vector<int> A(n), B(n);
    for (int& x : A) cin >> x;
    for (int& x : B) cin >> x;
    sort(A.begin(), A.end());
    sort(B.begin(), B.end());

    // 小根堆:存 (和, A 的下标, B 的下标)
    priority_queue<tuple<int,int,int>, vector<tuple<int,int,int>>,
                   greater<tuple<int,int,int>>> pq;

    for (int i = 0; i < n; i++) pq.push({A[i] + B[0], i, 0});

    for (int c = 0; c < n; c++) {
        auto [sum, i, j] = pq.top(); pq.pop();
        cout << sum << (c == n - 1 ? '\n' : ' ');
        if (j + 1 < n) pq.push({A[i] + B[j + 1], i, j + 1});
    }
    return 0;
}
运行结果
输入: 3
      1 3 5
      2 4 6
输出: 3 5 5
易错:忘记先对 A、B 排序,「每行有序」的前提就不成立,堆里推出的下一个不一定是该行最小值,答案失效。
拓3bitset 做子集存在性判定

题目:给定 n 个不超过 1000 的正整数(n ≤ 10⁶,有大量重复),求这些数的子集和能否组成 [0, S] 中的全部整数?只问「能组成哪些和」,输出个数。

思路:这就是 01 背包的可达性问题,但 n 大到 10⁶。用 bitset 可以在 O(nS/64) 内完成:f |= f << x。更妙的优化:因为值域只有 1000,可以把相同的数按「多重背包二进制拆分」合并,n 秒降到 O(值域 × log n × S/64)。

C++bitset 可达和统计 + 计数数组优化
#include <bits/stdc++.h>
using namespace std;

const int MAXS = 2000005;
bitset<MAXS> f;

int main() {
    ios::sync_with_stdio(0);
    int n, S; cin >> n >> S;
    vector<int> cnt(1005, 0);
    for (int i = 0; i < n; i++) { int x; cin >> x; cnt[x]++; }

    f[0] = 1;
    for (int v = 1; v <= 1000; v++) {
        if (!cnt[v]) continue;
        // 二进制拆分:把 cnt[v] 个 v 拆成 1,2,4,8,... 组,转成 01 背包
        int c = cnt[v];
        for (int p = 1; c > 0; p <<= 1) {
            int take = min(p, c);
            f |= (f << (v * take));
            c -= take;
        }
    }
    cout << f.count() << " 种不同的和可以被凑出\n";
    return 0;
}
运行结果
输入: 6 12
      2 2 3 3 3 5
输出: 17 种不同的和可以被凑出
(总和为 18,可达集合为 0~18 中除 1 和 17 外的全部整数,共 17 个)

🔍 代码逐段解析 · bitset 可达和 + 计数数组优化

计数(第 8~9 行) —— 值域只有 1000,用 cnt[v] 统计每个值出现次数,n = 10⁶ 的输入被压成 1000 个 (值, 个数) 对。

二进制拆分(第 12~18 行) —— 把 c 个 v 拆成 1、2、4、…、take 组(take = min(p, c)),每组做一次 f |= (f << v·take):log c 次位运算覆盖 c 个副本。

统计(第 19 行) —— f.count() 一次数出所有可达和的个数,样例得 17。

易错点 —— ① 拆分循环 for (p=1; c>0; p<<=1) 里 take 用完要 c -= take,忘减会死循环;② 读入的 S 若只是「上限」而不参与统计,别把它当值域开 bitset(本题按总和 18 即可覆盖)。

要点:二进制拆分把「每种物品有 c 个」的多重背包转化成 O(log c) 个 01 背包物品,配合 bitset 让整体复杂度降到可接受范围。这是「多重背包 + bitset」的组合套路,在提高组及以上非常常见。
③思考与启发

💡 思考1:为什么堆不支持「查第 k 大」?

查看参考

因为堆只维护「父 ≥ 子」这一条偏序关系,兄弟之间、叔叔与侄子之间没有大小约束。所以在堆数组里,第 2 大一定在根的两个儿子中,但第 3 大可能在第 2 层的任意位置——没有一条 O(log n) 的路径能定位它。

替代方案:① 用 pb_ds 平衡树或线段树维护第 k 大;② 用「固定 K 的小根堆」维护前 K 大(TopK 模型);③ 用「对顶堆」维护中位数这类特殊的第 k 小。

💡 思考2:bitset 为什么必须编译期确定长度?能否运行时指定?

查看参考

因为 bitset<N> 的大小 N 是模板参数,编译器需要在编译期就知道要分配多少字节,才能把位运算展开成定长的一系列整型指令。这也正是它快的原因——没有动态内存、没有间接寻址、循环次数在编译期就确定了。

运行时怎么办:① 用 vector<bool>(标准库对它做了位存储特化),但位运算需要手写;② 用 vector<unsigned long long> 手动分块,自己实现 or / shift;③ 把 N 设成题目最大可能值的上界(如 10⁶),用固定大 bitset 覆盖所有数据。

💡 思考3:同一道滑动窗口最值题,三种解法分别适合什么场合?

查看参考

单调队列(deque):O(n),最优解。适合「固定窗口最值」以及后续的「单调队列优化 DP」(如多重背包、跳跃游戏)——它天然融合了「窗口范围 + 最值选择」两件事。

优先队列 + 懒删除:O(n log n),代码更短、更不容易写错。当窗口不固定长度(如「下标差不超过 k」)时,优先队列的写法反而更通用。

ST 表 / 线段树:适合「任意区间查询最值」而不是滑动窗口。ST 表预处理 O(n log n) 后单次查询 O(1),但只支持静态数据;线段树支持修改,单次 O(log n)。下一讲我们会专门讲 ST 表。

模块 05

学习检测

先找错,再背口诀——把易错点消灭在考试之前。

⚡高频错误诊断
易错1

小根堆只写了 greater<int>

❌ 错误
priority_queue<int, greater<int>> pq;
// 编译错误:三个模板参数不能省略中间那个
✅ 正确
priority_queue<int, vector<int>, greater<int>> pq;
// 元素类型 / 底层容器 / 比较器,一个不少
口诀:小根堆三件套,类型容器比较器;少写一个 vector,编译器立刻翻脸!
易错2

自定义比较器的方向写反

❌ 错误
struct Cmp {
    bool operator()(const Node& a, const Node& b) const {
        return a.dis < b.dis;   // 以为这样是「dist 小优先」
    }
};
// 实际得到大根堆:dis 最大的在堆顶!
✅ 正确
struct Cmp {
    bool operator()(const Node& a, const Node& b) const {
        return a.dis > b.dis;   // 这才是「小的优先」
    }
};
// 记忆:返回 true 表示「a 更差,沉下去」
口诀:比较器返回 true,意思是「a 更差往下沉」;符号与直觉永远相反,写完先拿两个数手推!
易错3

对空的优先队列调用 top() / pop()

❌ 错误
while (!pq.empty()) { ans += pq.top(); pq.pop(); }
// 这行没问题,但下面这种:
while (pq.size() >= 1) { ans += pq.top(); pq.pop(); }
// size 是 1 时仍会 pop,下一轮对空堆 top() → RE
✅ 正确
while (pq.size() > 1) {     // 每次要取两个
    long long a = pq.top(); pq.pop();
    long long b = pq.top(); pq.pop();
    pq.push(a + b);
}
口诀:每次取两个,循环写 size()>1;空堆取顶必崩,先判空再动手!
易错4

单调队列里只存值、不存下标

❌ 错误
deque<int> dq;                // 存「值」
for (int i = 1; i <= n; i++) {
    while (!dq.empty() && dq.back() >= a[i]) dq.pop_back();
    dq.push_back(a[i]);
    // 无法判断队头是否已滑出窗口!
    cout << dq.front();
}
✅ 正确
deque<int> dq;                // 存「下标」
while (!dq.empty() && a[dq.back()] >= a[i]) dq.pop_back();
dq.push_back(i);
while (dq.front() <= i - k) dq.pop_front();
if (i >= k) cout << a[dq.front()];
口诀:单调队列存下标,值比较、下标判过期;只存值不过期,答案从第三个窗口开始全错!
易错5

bitset 长度用运行时变量

❌ 错误
int n; cin >> n;
bitset<n> b;      // 编译错误:n 不是常量表达式
// 或者写成 vector<bool> b(n); 后
// b |= (b << x); // 编译错误,vector<bool> 没有位运算
✅ 正确
const int MAXN = 1000005;   // 用题目上界
bitset<MAXN> b;
// 若确实需要运行时长度,改手写分块:
vector<unsigned long long> blk((n + 63) / 64);
// 自己实现 or / shift 操作
口诀:bitset 的 N 编译期定,运行期变量当参数必然编译不过;拿不准就开到题目上界!
📋三容器速查表
需求容器关键写法复杂度
两端增删 + 随机访问dequepush_front / pop_back / d[i]O(1) 均摊
滑动窗口最值deque + 单调队列存下标,队尾压、队头过期O(n)
反复取最大值priority_queue<T>pq.top()push/pop O(log n)
反复取最小值priority_queue<T,vector<T>,greater<T>>三参数写全push/pop O(log n)
自定义优先级priority_queue + 比较器结构体返回 a 更差push/pop O(log n)
可删除任意元素的堆双堆 + 懒删除pq.top()==del.top() 时同弹O(log n)
动态中位数对顶堆(大根 + 小根)保持两堆大小差 ≤ 1O(log n)
一堆布尔状态bitset<N>set / reset / flip / countO(N/64)
01 背包可达性bitsetf |= (f << x)O(nS/64)
传递闭包bitset 数组if (g[i][k]) g[i] |= g[k];O(n³/64)
集合交并差bitseta & b / a | b / a ^ bO(N/64)
📌笔记卡
📗 必记概念
  • deque:两端 O(1) 进出 + 随机访问,无 capacity
  • priority_queue:默认大根堆,是容器适配器
  • 堆只保证父子有序,数组内容无序
  • bitset 长度编译期固定,位运算快 64 倍
  • 懒惰删除 = 双堆互消 / 标记 + 编号
📘 必背模板
  • 小根堆:priority_queue<int,vector<int>,greater<int>>
  • 比较器:返回 a.x > b.x 得到 x 小优先
  • 单调队列四行:压尾 / 去尾 / 判头过期 / 取头
  • bitset 背包:f |= (f << x)
  • bitset 闭包:if (g[i][k]) g[i] |= g[k]
📙 考场提醒
  • 取两个元素时循环写 size() > 1
  • 合并果子答案要 long long
  • 单调队列必须存下标
  • bitset 的 N 拿不准就开到题目上界
  • n ≥ 10⁶ 优先 O(n) 的单调队列,别上堆
课后练习

分层练习作业

A基础 / B提高 / C挑战,难度递增。C组后附 12 道洛谷原题。

🟢 A组 · 基础(4题)

A1概念题

为什么 vector 没有 push_front,而 deque 有?两者时间复杂度差多少?

查看参考答案

思路:从内存布局出发分析。

vector 用一整块连续内存存储元素。要在头部插入,必须把后面所有元素整体后移一格,所以是 O(n),标准库干脆不提供这个接口。

deque 用多个独立缓冲区 + 一张中控表。头部插入只需在最前面那块缓冲区里往前写;如果那块满了,再申请一块新的挂到中控表上即可,已有元素完全不动,所以是 O(1) 均摊。

考点:容器内存布局与接口能力之间的因果关系。

易错:以为 v.insert(v.begin(), x) 和 d.push_front(x) 代价相当——前者是 O(n),后者是 O(1)。

A2编程题

输入 n 个数,用优先队列把它们从小到大输出。

查看参考答案
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n; cin >> n;
    priority_queue<int, vector<int>, greater<int>> pq;
    for (int i = 0; i < n; i++) { int x; cin >> x; pq.push(x); }
    while (!pq.empty()) { cout << pq.top() << " "; pq.pop(); }
    cout << "\n";
}

思路:小根堆不断弹堆顶,弹出序列天然升序(相当于堆排序)。总复杂度 O(n log n)。

逐段:第 4~6 行建小根堆(三参数写全);第 7 行 while (!pq.empty()) 先 top() 后 pop(),弹出即升序。

易错:写成默认的大根堆会输出降序;忘记 greater 需要的三个模板参数。

A3编程题

用 deque 实现一个栈和一个队列,各支持 push / pop / 判空。

查看参考答案
#include <bits/stdc++.h>
using namespace std;
int main() {
    deque<int> stk, que;
    // 栈:只在一端进出(后进先出)
    stk.push_back(1); stk.push_back(2); stk.push_back(3);
    cout << "栈弹出:";
    while (!stk.empty()) { cout << stk.back() << " "; stk.pop_back(); }
    cout << "\n";   // 3 2 1

    // 队列:尾进头出(先进先出)
    que.push_back(1); que.push_back(2); que.push_back(3);
    cout << "队列弹出:";
    while (!que.empty()) { cout << que.front() << " "; que.pop_front(); }
    cout << "\n";   // 1 2 3
}

思路:栈用 push_back + back + pop_back;队列用 push_back + front + pop_front。同一个 deque 通过选择不同端口,就能同时扮演两种角色。

易错:用 pop_back() 取队列元素——队列必须是 pop_front(),否则变成栈。

A4编程题

用 bitset 实现集合的交、并、差,输入两个集合的位串。

查看参考答案
#include <bits/stdc++.h>
using namespace std;
int main() {
    string x, y; cin >> x >> y;
    bitset<8> a(x), b(y);       // 从 01 字符串构造
    cout << "交集 a & b = " << (a & b) << "\n";
    cout << "并集 a | b = " << (a | b) << "\n";
    cout << "对称差 a ^ b = " << (a ^ b) << "\n";
    cout << "差集 a & ~b = " << (a & ~b) << "\n";
    cout << "a 的元素个数 = " << a.count() << "\n";
}

思路:用位串直接构造 bitset,再用位运算符完成集合运算。注意 bitset<8> 的位串是高位在左,所以第 7 位对应位串的第一个字符。

逐段:第 3~4 行从 01 字符串构造 a、b;第 5~8 行分别输出与、或、对称差、差集及 count。

易错:把「差集」写成 a ^ b(那是对称差,两个集合独有元素都保留),真正的差集是 a & ~b。

🟡 B组 · 提高(4题)

B1优先队列应用

动态求第 k 大:不断插入数,随时能问「当前第 k 大是多少」。

查看参考答案
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n, k; cin >> n >> k;
    // 小根堆维护「前 k 大」,堆顶就是第 k 大
    priority_queue<int, vector<int>, greater<int>> pq;
    for (int i = 0; i < n; i++) {
        int x; cin >> x;
        pq.push(x);
        while ((int)pq.size() > k) pq.pop();
        if ((int)pq.size() == k) cout << pq.top() << " ";
    }
    cout << "\n";
}

思路:固定 K 的小根堆:堆里始终保存当前最大的 K 个数,堆顶就是第 K 大。每次插入 O(log k)。

易错:用大根堆会得到第 1 大而不是第 k 大;忘记在 size 小于 k 时不输出(此时还没有第 k 大)。

B2单调队列

求所有长度为 k 的滑动窗口的元素和的最大值(数组元素可为负)。

查看参考答案
#include <bits/stdc++.h>
using namespace std;
int main() {
    ios::sync_with_stdio(0);
    int n, k; cin >> n >> k;
    vector<long long> s(n + 1, 0);
    for (int i = 1; i <= n; i++) { long long x; cin >> x; s[i] = s[i-1] + x; }
    long long best = LLONG_MIN;
    for (int r = k; r <= n; r++)
        best = max(best, s[r] - s[r - k]);       // 固定长度窗口和 = 两次前缀和相减
    cout << best << "\n";
}

思路:固定长度窗口的和不需要单调队列——前缀和一次减法就是 O(1)。这也是重要的判断力:「固定长度求最值」中,和可以直接算,只有 max/min 这类不可逆的聚合才需要单调队列。

逐段:第 4~5 行求前缀和 s;第 7~8 行对每个右端点 r(从 k 起)用 s[r]-s[r-k] 更新 best,全程 O(n)。

易错:保险起见 best 初始化成 LLONG_MIN 而不是 0——数组可能全是负数。

B3综合应用

用优先队列模拟任务调度:任务有「耗时」和「优先级」,每次处理当前优先级最高的任务,输出完成顺序。

查看参考答案
#include <bits/stdc++.h>
using namespace std;
struct Task { int cost, prio, id; };
struct Cmp {
    bool operator()(const Task& a, const Task& b) const {
        if (a.prio != b.prio) return a.prio < b.prio;  // 优先级大的先做
        return a.id > b.id;                          // 同优先级编号小的先做
    }
};
int main() {
    int n; cin >> n;
    priority_queue<Task, vector<Task>, Cmp> pq;
    for (int i = 0; i < n; i++) {
        int c, p; cin >> c >> p;
        pq.push({c, p, i});
    }
    long long t = 0;
    while (!pq.empty()) {
        Task x = pq.top(); pq.pop();
        t += x.cost;
        cout << "任务#" << x.id << " 完成于 " << t << "(优先级 " << x.prio << ")\n";
    }
}

思路:把「谁先做」编码进比较器。注意本题是静态调度(一开始就知道所有任务),所以可以一次全塞进堆;如果任务是按时间陆续到达的,就要配合时间轴逐秒推进(P2278 的风格)。

易错:比较器里写成 a.prio > b.prio 会变成「优先级小的先做」;return a.id > b.id 才是编号小的先出。

B4bitset 技巧

n ≤ 2000 个点的无向图,判断它是否连通;再统计每个点的可达点数。

查看参考答案
#include <bits/stdc++.h>
using namespace std;
const int N = 2005;
bitset<N> g[N];
int main() {
    ios::sync_with_stdio(0);
    int n, m; cin >> n >> m;
    for (int i = 1; i <= n; i++) g[i][i] = 1;
    for (int i = 0; i < m; i++) {
        int u, v; cin >> u >> v;
        g[u][v] = g[v][u] = 1;         // 无向图对称存
    }
    for (int k = 1; k <= n; k++)
        for (int i = 1; i <= n; i++)
            if (g[i][k]) g[i] |= g[k];

    bool conn = true;
    for (int i = 2; i <= n; i++) if (!g[1][i]) conn = false;
    cout << (conn ? "连通" : "不连通") << "\n";
    for (int i = 1; i <= n; i++) cout << "点 " << i << " 可达 " << g[i].count() << " 个点\n";
}

思路:无向图的连通性判断也可用 BFS 一次 O(n+m) 解决,但 bitset 版在一次预处理(O(n³/64))后可以 O(1) 回答任意两点的连通性,适合多组询问。

逐段:第 4~8 行建对称邻接矩阵(双向置位 + 自反);第 9~11 行 Floyd 位运算闭包;第 13~14 行查 g[1][i] 判连通;第 15 行 g[i].count() 输出各点可达数。

易错:无向图必须双向置位;忘记 g[i][i]=1 会导致 count() 少算自己。

🔴 C组 · 挑战(4题)

C1初赛真题

C++ 中 priority_queue<int> 默认是( )。

A. 小根堆,top() 返回最小值
B. 大根堆,top() 返回最大值
C. 按插入顺序的队列
D. 按插入顺序的栈

查看参考答案

答案:B

priority_queue 的默认比较器是 less<T>,规则是「比较器认为较大的元素排在堆顶」,因此默认是大根堆,top() 返回最大值。

考点:优先队列的默认行为与比较器语义。

易错:凭「队列」二字以为它是先进先出。priority_queue 完全不管插入顺序,只看优先级。

C2综合编程

用 bitset 求「两个大整数集合」的交集大小,集合元素范围 [1, 10⁶],n, m ≤ 10⁵。

查看参考答案

思路:把两个集合各自用 bitset<1000005> 表示(第 x 位为 1 表示 x 在集合中),然后 (a & b).count() 一次得到交集大小。O(N/64) ≈ 1.6×10⁴ 次机器操作,比排序 + 双指针还快。

#include <bits/stdc++.h>
using namespace std;
const int MAXV = 1000005;
bitset<MAXV> a, b;
int main() {
    ios::sync_with_stdio(0);
    int n, m; cin >> n >> m;
    for (int i = 0; i < n; i++) { int x; cin >> x; a.set(x); }
    for (int i = 0; i < m; i++) { int x; cin >> x; b.set(x); }
    cout << (a & b).count() << "\n";
    cout << (a | b).count() << "\n";   // 并集大小
    cout << (a & ~b).count() << "\n";  // 差集大小
}

逐段:第 4~7 行用 a.set(x)/b.set(x) 建两个集合的位图;第 8~10 行 (a&b).count()、(a|b).count()、(a&~b).count() 一次算出交/并/差大小。

易错:值域有 10⁶,bitset<1000005> 占约 125KB 内存,两个共 250KB,完全没问题;但如果值域到 10⁹ 就用不了 bitset,只能老老实实排序 + 双指针或用 set 求交。

C3思维题

为什么 priority_queue 没有 clear()?如果要清空它,哪种方式最省事?

查看参考答案

原因:priority_queue 是容器适配器,它只暴露了「堆该有的接口」(push / pop / top / size / empty)。clear() 属于底层容器的能力,而适配器有意不暴露底层容器(那样会破坏「堆」的抽象)。

清空的三种办法:

① while (!pq.empty()) pq.pop(); —— 最直观,O(n log n)。

② pq = priority_queue<int>(); —— 用一个空的临时对象赋值,O(n),推荐。也可以写成 pq = {};(C++11 起)。

③ 如果确实需要访问底层容器,可以继承后暴露 c 成员,但这是非标准玩法,竞赛里不建议。

考点:容器适配器与容器本体在接口设计上的分工。

C4综合编程

有 n 个任务,第 i 个任务耗时 tᵢ、截止时间 dᵢ。求最多能按时完成多少个任务?n ≤ 1.5×10⁵。

查看参考答案

思路:经典「反悔贪心 + 大根堆」。按截止时间升序处理任务,用一个变量 now 记录当前已用总时间,大根堆存已选任务的耗时。

处理任务 i 时:先把它加进来(now += tᵢ,堆里 push tᵢ)。如果 now > dᵢ 说明安排不下去,就把堆里耗时最长的那个任务踢掉(now -= pq.top(); pq.pop();)。因为踢掉耗时最长的,能最大程度腾出时间,且任务个数只减少 1 个。

正确性直觉:「反悔」——每次贪心地接受,发现超时就丢掉代价最大的那个,等价于「用最少的时间代价换来可行性」。

#include <bits/stdc++.h>
using namespace std;
int main() {
    ios::sync_with_stdio(0);
    int n; cin >> n;
    vector<pair<long long,long long>> t(n);
    for (auto& p : t) cin >> p.second >> p.first;  // {截止, 耗时}
    sort(t.begin(), t.end());                        // 按截止时间升序

    priority_queue<long long> pq;                     // 大根堆:已选任务的耗时
    long long now = 0;
    for (auto& [d, cost] : t) {
        now += cost;
        pq.push(cost);
        if (now > d) { now -= pq.top(); pq.pop(); }   // 反悔:踢掉最耗时的
    }
    cout << pq.size() << "\n";
    return 0;
}

逐段:第 5~6 行读入并按截止时间排序;第 9~13 行逐个任务「先接受再反悔」:now+=cost 且入堆,若 now > d 就踢掉堆顶(最耗时的);第 14 行堆里剩下的就是能按时完成的任务数。

易错:踢任务时踢的是「耗时最长」而不是「截止最早的」;now 与耗时都要开 long long(累加可能超过 int)。

🏆 洛谷原题精选(12道)

以下 12 道洛谷题目均与 deque / priority_queue / bitset 相关,按难度从入门到提高排列。

1
P3378
【模板】堆普及-
2
P1090
[NOIP2004 提高组] 合并果子普及-
3
P1160
队列安排普及-
4
P1440
求 m 区间内的最小值普及-
5
P2251
质量检测普及-
6
P1886
滑动窗口 /【模板】单调队列普及+
7
P2085
最小函数值普及-
8
P1631
序列合并(多路归并)普及+
9
P2278
[HNOI2003] 操作系统普及+
10
P1144
最短路计数(优先队列 BFS)普及+
11
P4779
【模板】单源最短路径(标准版)提高
12
P2114
[NOI2014] 起床困难综合症(位运算)提高