STL 容器详解 + 考研机试算法
·
# STL 容器详解 + 考研机试算法(超详细注释版)
> **目标**:985/211 机试 | **每个例子**:逐行解释 → 为什么要这么写 → 时间复杂度 → 易错点
---
## STL 总览 · 5W1H
> **Who**(谁创造了 STL?)
> — **Alexander Stepanov** 在 HP 实验室提出泛型编程思想,1994 年纳入 C++ 标准。他是 STL 之父。
>
> **What**(STL 是什么?)
> — C++ 标准模板库,包含三大核心:**容器**(存数据)、**算法**(处理数据)、**迭代器**(连接容器和算法的桥梁)。
> 一句话:STL = 别人帮你写好的、经过极致优化的数据结构和算法。
>
> **When**(什么时候用?什么时候不用?)
> — ✅ 用 STL:99% 的机试题。手写红黑树/快排 → 没人这么干。
> — ❌ 不用 STL:题目明确要求"不得使用 STL"(极少见)、嵌入式等受限环境。
>
> **Where**(用在机试的哪些地方?)
> — 每道题都用到:`vector` 存数据 → `sort` 排序 → `unordered_map` 计数 → `queue` BFS → `priority_queue` Dijkstra...
>
> **Why**(为什么必须学 STL?)
> — ① 机试拼的是**速度**,STL 一行顶手写二十行
> — ② STL 的代码经过数十亿次使用验证,比你手写的 bug 少
> — ③ 面试官看你"熟练使用 STL 容器和算法" = 工程素养
---
## 第一部分:容器底层原理与特点
### 1. vector — 动态数组
> **Who** — Alexander Stepanov(STL 之父,1994 年纳入 C++ 标准)
> **What** — 连续内存的动态数组,支持 O(1) 随机访问
> **When** — **选它**:90% 场景默认首选 | **不选它**:频繁头部插入/中间插入删除
> **Where** — 机试中用作普通数组、二维矩阵、邻接表图、DP 状态表
> **Why** — 连续内存 = 最高缓存命中率;尾插 O(1) 均摊;`v[i]` 直接地址计算最快
```
┌────────────────────────────────────────────────────────────┐
│ 底层存储结构(连续内存) │
│ │
│ _data → [_][_][_][_][_][_][_][_] ← 已分配容量 capacity │
│ ↑ ↑ │
│ size()个元素已使用 剩余预留空间 │
│ │
│ 扩容过程(push_back 触发): │
│ 旧容量2 → 申请新空间 size×2 → 拷贝旧元素 → 释放旧空间 │
│ 代价:O(n) 拷贝 │
│ 均摊:每次 push_back 仍然是 O(1) │
└────────────────────────────────────────────────────────────┘
```
**核心特点(逐条解释)**:
| 操作 | 复杂度 | 底层发生了什么 |
|------|:---:|------|
| `v[i]` | O(1) | `_data + i * sizeof(T)` — 直接计算地址,一次指针加法 |
| `push_back(x)` | O(1) 均摊 | 若 size==capacity → 触发扩容(×2),拷贝旧数据;若有余量 → 直接在尾部构造 |
| `pop_back()` | O(1) | 析构尾部元素,size--,capacity 不变 |
| `insert(pos, x)` | O(n) | pos 及之后所有元素整体后移一个位置(memmove) |
| `erase(pos)` | O(n) | pos 之后所有元素整体前移一个位置 |
**为什么 vector 是90%情况的首选?**
1. **连续内存 = 缓存命中率极高** — CPU 预取机制最友好
2. **随机访问 O(1)** — 比 list 快一个数量级
3. **尾插尾删 O(1)** — 解决大多数需求
4. **只有中间增删慢** — 这种情况才考虑 list
```cpp
#include <vector>
#include <iostream>
using namespace std;
// ==================== 例1:构造的6种方式 ====================
void demo_construct() {
// 方式① — 空构造:不分配任何元素,size=0, capacity=0
vector<int> v1;
// 适用:后续用 push_back 逐步添加,让 vector 自己扩容
// 方式② — 指定大小+默认值:分配n个元素,都初始化为0
vector<int> v2(10);
// 底层:new int[10]() — 值初始化,int被初始化为0
// size=10, capacity≥10
// 方式③ — 指定大小+指定值:5个元素,每个都是100
vector<int> v3(5, 100);
// 底层:依次构造5个int,每个拷贝100
// 适用:需要预填充特定值
// 方式④ — 初始化列表(C++11最常用)
vector<int> v4 = {1, 2, 3, 4, 5};
// 底层:从 initializer_list 构造,自动推导大小
// 适用:已知所有初始元素
// 方式⑤ — 拷贝构造
vector<int> v5(v4);
// 底层:深拷贝 — 申请新内存,拷贝v4的所有元素
// 注意:改变v5不会影响v4
// 方式⑥ — 从另一个容器的子区间构造
vector<int> v6(v5.begin() + 1, v5.begin() + 4);
// 底层:拷贝 [begin+1, begin+4) 区间(左闭右开!)
// 结果:v6 = {2, 3, 4}
// 适用:从任意容器截取一段转成vector
}
// ==================== 例2:reserve vs resize(重要!) ====================
void demo_reserve_vs_resize() {
vector<int> v;
// reserve(n) — 只改变 capacity,不改变 size
// 适用场景:你"知道"即将push_back很多元素
v.reserve(100);
// 此时:size=0, capacity≥100
// 好处:接下来的 push_back 不会触发扩容(避免反复拷贝)
// resize(n) — 改变 size(真正创建/销毁元素)
v.resize(50);
// 此时:size=50, capacity≥100
// 新元素(索引0~49)被值初始化为0
// 底层:如果 n > size,在尾部构造 n-size 个默认元素
v.resize(100, -1);
// 此时:size=100, capacity≥100
// 新增的50个元素(索引50~99)被初始化为 -1
// 底层:只初始化新增的部分,原有50个元素不动
}
// ==================== 例3:增删改查的详细注释 ====================
void demo_crud() {
vector<int> v = {10, 20, 30};
// 此时:v = [10, 20, 30], size=3, capacity≥3
// —————— 增 ——————
// push_back:在末尾添加元素(最常用!)
v.push_back(40);
// 底层步骤:
// ① 检查 capacity:如果 size==capacity,扩容×2
// ② 在 _data[size] 位置用 placement new 构造元素
// ③ size++
// 结果:v = [10, 20, 30, 40]
// emplace_back:在末尾"原地构造"(比 push_back 快!)
v.emplace_back(50);
// 与 push_back 的区别:
// push_back:先构造临时对象 → 再拷贝/移动到容器
// emplace_back:直接在容器内存位置构造,省掉一次拷贝
// 对于 int 差别不大,对于 string/自定义类差别明显
// 结果:v = [10, 20, 30, 40, 50]
// insert(pos, val):在 pos 位置之前插入 val
v.insert(v.begin() + 2, 99);
// 参数:v.begin()+2 是第3个元素(索引2)的迭代器
// 底层:
// ① 将索引2及之后的元素全部后移一个位置(memmove)
// ② 在索引2位置构造值为99的元素
// 时间复杂度:O(n) — 最坏情况插在头部,全部后移
// 结果:v = [10, 20, 99, 30, 40, 50]
// insert(pos, n, val):在 pos 前插入 n 个 val
v.insert(v.end(), 3, 100);
// 在末尾插入3个100(等价于 push_back 三次)
// 结果:v = [10, 20, 99, 30, 40, 50, 100, 100, 100]
// —————— 删 ——————
// pop_back:删除最后一个元素
v.pop_back();
// 底层:析构 _data[size-1],size--
// 时间复杂度:O(1),不涉及数据移动
// note:capacity 不变(空间不释放)
// erase(pos):删除 pos 位置的元素
v.erase(v.begin());
// 底层:
// ① 析构 _data[0]
// ② 将 _data[1]~_data[size-1] 整体前移一个位置
// 时间复杂度:O(n) — 所有后续元素都要前移
// 结果:第一个元素(10)被删了
// erase(first, last):删除区间 [first, last)
v.erase(v.begin(), v.begin() + 3);
// 删除索引0,1,2(前3个元素)
// 底层:将索引3开始的元素整体前移3个位置
// 结果:原索引3之后的元素全部前移
// clear:清空所有元素
v.clear();
// 底层:析构所有元素,size=0
// note:capacity 不变!内存还在
// —————— 改 ——————
v = {10, 20, 30, 40, 50};
// operator[]:下标访问(不检查越界)
v[2] = 300;
// 底层:_data[2] = 300(直接内存写入)
// 危险:v[100] 不会报错,但访问非法内存!
// at:下标访问(检查越界)
v.at(2) = 300;
// 底层:先检查 2 < size(),不满足则抛出 std::out_of_range 异常
// 速度比 [] 稍慢(多一次判断),但安全
// front / back:首/尾元素的引用
v.front() = 99; // 等价于 v[0] = 99
v.back() = 99; // 等价于 v[v.size()-1] = 99
// assign:重新赋值(覆盖原有内容)
v.assign(5, 7);
// 结果:v = [7, 7, 7, 7, 7]
// 原先的 {10,20,30,40,50} 被析构,换成5个7
// —————— 查 ——————
cout << v.empty(); // false — size==0 才为true
cout << v.size(); // 5 — 当前元素个数
cout << v.capacity(); // 可能 ≥5 — 已分配的内存可容纳的元素数
// size vs capacity:
// size = 你有多少元素(v[0]~v[size-1]合法)
// capacity = 你不扩容最多能装多少
// 用 find 算法查找值
auto it = find(v.begin(), v.end(), 7);
// find 对 vector 是 O(n) 的线性查找
// 如果有序 → 用 binary_search / lower_bound(O(log n))
if (it != v.end())
cout << "找到了,位置:" << (it - v.begin());
// it - v.begin() 计算索引(随机访问迭代器支持减法)
}
```
```cpp
// ==================== 例4:遍历的5种写法及对比 ====================
void demo_traversal() {
vector<int> v = {1, 2, 3, 4, 5};
// 写法①:C风格下标 — 最快,但不美观
for (size_t i = 0; i < v.size(); i++)
cout << v[i] << " ";
// note:v.size() 每次都调用(返回size_t,无符号),编译器一般会优化
// 写法②:范围for — 最推荐(C++11起)
for (int x : v)
cout << x << " ";
// 原理:编译器展开为迭代器遍历
// 本质:for(auto it=v.begin(); it!=v.end(); ++it){int x=*it;...}
// 写法②改进:用 & 引用避免拷贝(元素大时很重要)
for (const int& x : v) // const引用:只读,不拷贝
cout << x << " ";
// 面试要点:什么时候用 const &?
// → 元素 sizeof > 8 字节时(如 string/struct)
// → int/double 直接按值也可以(差别忽略不计)
// 写法③:引用修改原值
for (int& x : v) x *= 2;
// 将每个元素翻倍,x 是原元素的别名(引用)
// 写法④:传统迭代器(兼容旧代码)
for (vector<int>::iterator it = v.begin(); it != v.end(); ++it)
cout << *it << " ";
// C++11简化:
for (auto it = v.begin(); it != v.end(); ++it)
cout << *it << " ";
// note:用 ++it 不要用 it++(后置++会产生临时对象)
// 写法⑤:反向遍历
for (auto it = v.rbegin(); it != v.rend(); ++it)
cout << *it << " ";
// rbegin() → 指向最后一个元素
// rend() → 指向第一个元素的前一个位置
// 输出:5 4 3 2 1
// 写法⑥:for_each + lambda
for_each(v.begin(), v.end(), [](int x) {
cout << x << " ";
});
// for_each 不修改原序列(除非lambda用引用捕获)
}
```
```cpp
// ==================== 例5:去重 — sort + unique + erase 三步法 ====================
void demo_unique() {
vector<int> v = {3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5};
// 共11个元素,有大量重复
// 第1步:排序(unique 只能移除"连续"的重复)
sort(v.begin(), v.end());
// 结果:v = {1,1,2,3,3,4,5,5,5,6,9}
// 时间复杂度:O(n log n)
// 底层:快排(内省排序 introsort)
// 第2步:unique — 把连续重复的元素移到末尾
auto last = unique(v.begin(), v.end());
// unique 做什么?
// 遍历序列,如果当前元素≠前一个元素 → 保留;
// 如果当前元素==前一个元素 → 跳过(不保留)
// 返回:指向"不重复序列末尾+1"的迭代器
// 返回迭代器之后的部分是"垃圾数据"(未定义值)
// 此时 v = {1,2,3,4,5,6,9, ?,?,?,?}
// ↑
// last 指向这里
// 第3步:erase 真正删除垃圾数据
v.erase(last, v.end());
// 结果:v = {1, 2, 3, 4, 5, 6, 9}
// 完整写法可以写成一行:
// v.erase(unique(v.begin(), v.end()), v.end());
}
```
```cpp
// ==================== 例6:vector 当二维数组用 ====================
void demo_2d_vector() {
// 创建 3行 × 4列 的矩阵,全初始化为 -1
int rows = 3, cols = 4;
vector<vector<int>> matrix(rows, vector<int>(cols, -1));
// 解释:
// 外层 vector<int>:每个元素是一个 vector<int>(一整行)
// 内层 vector<int>(cols, -1):每行有 cols 个元素,全是 -1
// 相当于 int[3][4],但动态分配
// 访问
matrix[1][2] = 99;
// matrix[1] 返回第2行(一个 vector<int>&)
// matrix[1][2] 是第2行第3列
// 遍历(按行)
for (int i = 0; i < rows; i++) {
for (int j = 0; j < cols; j++)
cout << matrix[i][j] << " ";
cout << endl;
}
// 更优雅的遍历(范围for)
for (auto& row : matrix) { // row 是一整行
for (int val : row) // val 是行中的每个元素
cout << val << " ";
cout << endl;
}
// 注意:auto& 用引用,否则每次 auto row = matrix[i] 会拷贝一整行!
}
// ==================== 例7:vector 存图(邻接表) ====================
void demo_graph() {
int n = 5; // 5个顶点,编号 0~4
vector<vector<int>> graph(n); // graph[i] = 顶点i的所有邻居
// 添加边:0 → 1, 0 → 2
graph[0].push_back(1);
graph[0].push_back(2);
// graph[0] = {1, 2}
// 添加边:1 → 3
graph[1].push_back(3);
// 无向图:两边都加
graph[2].push_back(0); // 2 ↔ 0
graph[0].push_back(2); // 0 ↔ 2
// 如果边有权重 → 用 vector<vector<pair<int,int>>>
vector<vector<pair<int,int>>> weightedGraph(n);
weightedGraph[0].push_back({1, 5}); // 0→1 权值5
weightedGraph[0].push_back({2, 3}); // 0→2 权值3
// pair<int,int> = {目标顶点, 边的权重}
}
```
---
### 2. list — 双向链表
> **Who** — 源自 1950s 的 IPL 语言(Allen Newell),STL 标准化为 `std::list`
> **What** — 双向链表,每个结点含 data + prev 指针 + next 指针
> **When** — **选它**:频繁在任意位置插入/删除,不需要随机访问
> **Where** — LRU 缓存(配合 unordered_map)、大对象频繁增删、splice/merge 专用操作
> **Why** — 插入删除只改 4 个指针 O(1),不移动数据;但每个结点多 16 字节指针开销且缓存不友好
```
┌────────────────────────────────────────────────────────────┐
│ 底层结构(非连续内存,每个结点独立分配) │
│ │
│ head → [prev][1][next] ⇄ [prev][2][next] ⇄ [prev][3][next] │
│ ↑ ↑ ↑ │
│ 每个结点占 3×8=24字节(64位系统) │
│ 数据域 + 两个指针(prev + next) │
│ │
│ 关键特性: │
│ 任何位置插入/删除都是 O(1)(只需要改4个指针) │
│ 不支持 [] 下标(必须从头遍历到目标位置 → O(n)) │
└────────────────────────────────────────────────────────────┘
```
```cpp
#include <list>
void demo_list_basic() {
list<int> lst = {1, 2, 3, 4, 5};
// === 头尾操作(都是 O(1))===
lst.push_front(0); // 头插:创建一个新结点,next指向原头结点,原头结点prev指向新结点
lst.push_back(6); // 尾插:同理在尾部
lst.pop_front(); // 头删:删除头结点,第二个结点成为新头
lst.pop_back(); // 尾删
// === 任意位置插入(O(1),前提是你"已经有那个位置的迭代器")===
// 先找到位置(这一步是 O(n)!),再插入(O(1))
auto it = lst.begin();
// advance:把迭代器前进3步(耗时 O(n))
advance(it, 3); // 等价于:for(int i=0;i<3;i++) ++it;
// 此时 it 指向第4个位置
lst.insert(it, 99);
// insert 发生了什么?
// ① 创建一个新结点,data=99
// ② 新结点.prev = it->prev,新结点.next = it
// ③ it->prev->next = 新结点,it->prev = 新结点
// ④ 只改了4个指针,不移动任何数据 → O(1)
// === splice:O(1) 拼接整段链表 ===
list<int> other = {7, 8, 9};
lst.splice(lst.end(), other);
// splice 做了什么?
// ① 把 other 的结点"摘"下来
// ② "挂"到 lst 的尾部
// ③ 全程没有拷贝数据,只是改了边界结点的 next/prev 指针
// ④ other 变为空链表
// 时间复杂度:O(1)
}
// ==================== 经典应用:LRU 缓存 ====================
// 为什么用 list + unordered_map?
// list:维护"使用顺序",最近使用的移到头部 O(1)
// unordered_map:快速查找 key → 指向list结点的迭代器 O(1)
class LRUCache {
int cap; // 容量上限
list<pair<int, int>> lst; // 双向链表存 (key, value)
// 越靠近 begin() 表示越"最近使用";越靠近 end() 表示越"久未使用"
// 哈希表:key → 指向链表中对应结点的迭代器
// 为什么存迭代器?因为删除结点需要迭代器位置(O(1))
// 如果存 value,我们不知道它在链表的哪里(需要遍历 O(n))
unordered_map<int, list<pair<int, int>>::iterator> mp;
public:
LRUCache(int capacity) : cap(capacity) {}
int get(int key) {
auto it = mp.find(key); // 在哈希表中查找 key(O(1))
if (it == mp.end()) return -1; // 未找到 → 返回 -1
// 找到了 → 把该结点移到链表头部(标记为"最近使用")
// splice 的3个参数:(目标位置, 源链表, 要移动的结点)
lst.splice(lst.begin(), lst, it->second);
// splice 只改指针,不拷贝数据 → O(1)
return it->second->second; // 返回 value
// it->second 是 list 的迭代器
// (it->second)->second 是 pair<int,int> 的第二个值
}
void put(int key, int value) {
auto it = mp.find(key);
if (it != mp.end()) {
// key 已存在 → 更新 value + 移到头部
it->second->second = value; // 更新 value
lst.splice(lst.begin(), lst, it->second); // 移到头部
return;
}
// key 不存在 → 需要新增
if (lst.size() == cap) {
// 容量满了 → 淘汰尾部结点(最久未使用)
int oldKey = lst.back().first; // 尾结点的 key
lst.pop_back(); // 删除尾结点(O(1))
mp.erase(oldKey); // 哈希表同步删除
}
// 在头部插入新结点
lst.emplace_front(key, value); // 原地构造 pair{key, value}
mp[key] = lst.begin(); // 哈希表记录迭代器
}
};
```
---
### 3. deque — 双端队列
> **Who** — HP 实验室 STL 实现,1998 年 C++98 标准纳入
> **What** — 分段连续存储——中控器指针数组 + 多个固定大小缓冲区块
> **When** — **选它**:需要 `push_front` + `push_back` 都 O(1) | **不选它**:纯尾操作(vector 更快)
> **Where** — 滑动窗口(配合 `pop_front`)、单调队列、stack/queue 的默认底层容器
> **Why** — 头部插入不需要整体后移(vs vector),扩容不需要整体拷贝(新分配缓冲区即可)
```
┌────────────────────────────────────────────────────────────┐
│ 底层结构(分段连续 — 中控器 + 多个固定大小的缓冲区) │
│ │
│ 中控器(指针数组): [buf0*][buf1*][buf2*][buf3*]... │
│ ↓ ↓ ↓ ↓ │
│ 缓冲区(每块512B): [8个T] [8个T] [8个T] [8个T] │
│ ↑ ↑ │
│ begin end │
│ │
│ 随机访问过程: │
│ dq[20] → 20/8=2 号缓冲区 → 20%8=4 号位置 │
│ 比 vector 多一次间接跳转(中控器→缓冲区) │
│ │
│ 扩容特点: │
│ 头部不够 → 分配新缓冲区,挂到中控器前面(无需移动旧数据!) │
│ 尾部不够 → 分配新缓冲区,挂到中控器后面 │
│ 不会像 vector 那样整体拷贝! │
└────────────────────────────────────────────────────────────┘
```
```cpp
#include <deque>
void demo_deque() {
deque<int> dq;
// 头尾都是 O(1) — 这是 vs vector 最本质的区别
dq.push_front(1); // 头部插入 O(1) ✅(vector 需要 O(n)!)
dq.push_back(2); // 尾部插入 O(1)
// 支持随机访问(比 vector 稍慢,因为多一次中控器查表)
dq[0] = 100; // O(1),但 constant factor 比 vector 大
// deque 的典型应用场景:滑动窗口(需要 pop_front + push_back)
// 只用 vector 的话 pop_front 是 O(n),deque 是 O(1)
}
// ==================== 经典应用:滑动窗口最大值 ====================
// 用 deque 维护一个"候选最大值"的单调递减队列
// 思路:
// 队头 → 当前窗口的最大值
// 队列内的值单调递减
// 新元素入队时,弹出队尾所有 ≤ 新元素的值(它们永远不会是最大值了)
vector<int> maxSlidingWindow(vector<int>& nums, int k) {
int n = nums.size();
deque<int> dq; // 存的是下标(不是值!)— 因为需要判断元素是否"过期"
vector<int> ans;
// ans 预分配好大小,避免 push_back 扩容
ans.reserve(n - k + 1);
for (int i = 0; i < n; i++) {
// === 步骤1:清理过期元素 ===
// 队头下标 ≤ i-k 说明这个下标已经滑出窗口了
// 示例:k=3, i=5 → 窗口覆盖 [3,4,5],下标2已过期
while (!dq.empty() && dq.front() <= i - k)
dq.pop_front();
// 为什么用 <= 而不是 < ?
// 窗口范围为 [i-k+1, i]
// dq.front() == i-k 时已出窗口 → 用 <=
// === 步骤2:维护单调递减 ===
// 弹出队尾所有值 ≤ nums[i] 的元素
// 原因:如果 nums[队尾] ≤ nums[i],
// 那么只要 nums[i] 在窗口里,队尾元素就"不可能"成为最大值
// 直接淘汰!
while (!dq.empty() && nums[dq.back()] <= nums[i])
dq.pop_back();
// 必须是 <= 而不是 <
// 如果 = 不弹,窗口中出现相同最大值时不确定保留哪个
// === 步骤3:新元素入队 ===
dq.push_back(i);
// === 步骤4:记录答案 ===
// 窗口形成后(i >= k-1),队头就是当前窗口的最大值
if (i >= k - 1)
ans.push_back(nums[dq.front()]);
}
return ans;
// 时间复杂度:O(n) — 每个元素最多入队+出队各一次
// 空间复杂度:O(k) — deque 最多存 k 个下标
}
```
---
### 4. stack / queue / priority_queue — 容器适配器
> **Who** — 源自计算机科学基础数据结构(Dijkstra 推广栈模型,优先级队列源自堆数据结构)
> **What** — "适配器"模式——封装底层容器,只暴露特定接口(stack 只给 top/push/pop)
> **When** — LIFO 用 stack | FIFO 用 queue | 需要"最值优先"用 priority_queue
> **Where** — DFS 用 stack、BFS 用 queue、Dijkstra/TopK 用 priority_queue
> **Why** — 限制接口 = 减少出错 + 语义更清晰;底层默认 deque(两面操作都高效)
```cpp
#include <stack>
#include <queue>
void demo_adapters() {
// ===== stack — 底层默认 deque<int>,也可以指定 vector =====
stack<int> st;
// stack 只暴露5个接口:
st.push(1); // 压入栈顶
st.top(); // 查看栈顶(不弹出)
st.pop(); // 弹出栈顶(不返回值!)
st.empty(); // 是否为空
st.size(); // 元素个数
// ===== queue — 底层默认 deque<int> =====
queue<int> q;
q.push(1); // 尾部入队
q.front(); // 查看队头
q.back(); // 查看队尾
q.pop(); // 队头出队(不返回值!)
// 注意:queue 没有 clear()!需要手动清:
// queue<int> empty; swap(q, empty);
// ===== priority_queue(重点!)=====
// 默认:大顶堆(top 是最大元素)
priority_queue<int> maxHeap;
maxHeap.push(3);
maxHeap.push(1);
maxHeap.push(5);
cout << maxHeap.top(); // 5(最大)
maxHeap.pop(); // 弹出5
cout << maxHeap.top(); // 3
// 小顶堆的3种写法:
// 写法1:用 greater<int>(最常用)
priority_queue<int, vector<int>, greater<int>> minHeap1;
// 三个模板参数的顺序:元素类型 → 底层容器 → 比较器
// greater<int> 翻转比较:a>b 为true → 小的优先级高 → 小顶堆
// 写法2:存负数
priority_queue<int> minHeap2;
minHeap2.push(-x); // push 时取负
// top() 时再取负回来:-minHeap2.top()
// 写法3:自定义比较器(对复杂类型)
auto cmp = [](int a, int b) { return a > b; };
priority_queue<int, vector<int>, decltype(cmp)> minHeap3(cmp);
// decltype(cmp) 是 lambda 的类型
// 构造函数必须传 cmp 进去(因为 lambda 不是默认构造的)
}
```
```cpp
// ==================== 应用:Dijkstra 最短路径 ====================
// 为什么用 priority_queue?
// 每次取"当前距离起点最近"的顶点 → 优先队列的 top O(1)
// 更新距离后 push O(log n)
vector<int> dijkstra(vector<vector<pair<int,int>>>& graph, int start) {
int n = graph.size();
// graph[u] = {{v1, w1}, {v2, w2}, ...} 顶点u连向v,边权w
// dist[i] = 起点到i的最短距离,初始化为无穷大
vector<int> dist(n, INT_MAX);
dist[start] = 0;
// 小顶堆:存 {当前距离, 顶点编号}
// 按距离排序 → distance小的在堆顶
// greater<pair<int,int>> 让 pair 按 first 升序
using P = pair<int, int>;
priority_queue<P, vector<P>, greater<P>> pq;
pq.push({0, start}); // 起点距离为0
while (!pq.empty()) {
auto [d, u] = pq.top(); pq.pop();
// d = 目前记录的"起点到u的距离"
// u = 当前处理的顶点
// 关键优化:跳过"过期"数据
if (d > dist[u]) continue;
// 为什么会有过期数据?
// 之前 push 的 u 被后来更新的更短距离取代了
// 但旧的数据还在堆里 → 遇到就跳过
// 松弛操作:用 u 更新所有邻居
for (auto& [v, w] : graph[u]) {
// v = 邻居顶点, w = u→v的边权
if (dist[u] + w < dist[v]) {
// 找到更短的路径!
dist[v] = dist[u] + w;
pq.push({dist[v], v}); // push 新的距离进堆
// 注意:不删旧的记录(懒删除),交给上面 if(d>dist[u]) 跳过
}
}
}
return dist;
// 时间复杂度:O((V+E) log V)
// 每个顶点最多 pop 一次,每条边可能 push 一次
}
```
---
### 5. set / map — 有序(红黑树)
> **Who** — Rudolf Bayer(红黑树发明人, 1972) | STL 由 Alexander Stepanov 设计
> **What** — 基于**红黑树**(自平衡二叉搜索树),key 自动排序,O(log n) 增删查
> **When** — **选它**:需要 key 自动排序 / 范围查询(lower_bound) / 有序遍历 | **不选它**:只需快速查找→换 unordered
> **Where** — 词频统计(有序输出)、时间线(范围查询)、排名系统、区间重叠判断
> **Why** — 红黑树保证树高 ≤ 2log(n+1);插入删除旋转次数比 AVL 少,综合性能最优
```
┌────────────────────────────────────────────────────────────┐
│ 红黑树性质(5条,保证树高 ≤ 2log(n+1)) │
│ ① 结点为红或黑 │
│ ② 根为黑 │
│ ③ 叶子(NIL)为黑 │
│ ④ 红结点的两个孩子必为黑(无连续红结点) │
│ ⑤ 任一结点到其每个叶子的路径上黑结点数相同(黑高相等) │
│ │
│ 为什么用红黑树而不是AVL? │
│ AVL更严格平衡 → 查找稍快,但插入删除旋转更多 │
│ C++标准库选红黑树 → 综合性能更好 │
└────────────────────────────────────────────────────────────┘
```
```cpp
#include <set>
#include <map>
void demo_set_map() {
// ===== set =====
// 特性:自动排序 + 自动去重
set<int> s = {3, 1, 4, 1, 5}; // 自动变成 {1, 3, 4, 5}
// 第二个1被忽略(set 不允许重复)
// insert 返回 pair<iterator, bool>
auto [it, ok] = s.insert(6);
// it 指向插入的元素(或已存在的元素)
// ok = true(插入成功)/ false(key已存在,插入失败)
if (ok) cout << "插入成功: " << *it;
auto [it2, ok2] = s.insert(3);
// ok2 = false,因为3已存在
// it2 指向已存在的3
// find:O(log n),找到返回迭代器,否则返回 end()
auto it3 = s.find(4);
if (it3 != s.end()) cout << "找到了: " << *it3;
// lower_bound:第一个 ≥ x 的位置
// upper_bound:第一个 > x 的位置
auto lb = s.lower_bound(3); // 指向3
auto ub = s.upper_bound(3); // 指向4
// 配合使用:区间 [lb, ub) 就是所有等于3的元素(对set来说最多1个)
// erase 的3种用法:
s.erase(3); // ① 按值删除(O(log n))
s.erase(s.begin()); // ② 按迭代器删除(O(1) 均摊)
s.erase(s.begin(), s.end());// ③ 删除区间
// ===== map =====
// 特性:key自动排序 + key唯一
map<string, int> mp;
// 插入的4种方式:
mp["Alice"] = 90; // ① operator[]:不存在则创建,存在则修改
mp.insert({"Bob", 85}); // ② initializer_list
mp.insert(make_pair("Tom", 92)); // ③ make_pair
mp.emplace("Jerry", 88); // ④ emplace:原地构造(推荐,最快)
// ⚠️ operator[] 的陷阱:
cout << mp["Unknown"];
// 这行代码会:
// ① 查找 key="Unknown" → 不存在
// ② 创建 {"Unknown", 0}(int 值初始化为0)
// ③ 返回 0
// 如果你只想"检查是否存在",用 find 或 count!
// 安全查找:
if (mp.count("Alice")) { /* ... */ } // count返回0或1
if (mp.find("Bob") != mp.end()) { /* ... */ } // find最安全
// C++20 可以用 mp.contains("Alice")
// 遍历(永远按key升序):
for (auto& [key, val] : mp) // C++17 结构化绑定
cout << key << " → " << val << endl;
// 输出必然按 key 字典序排列
}
```
```cpp
// ==================== 应用:词频统计并排序 ====================
void wordFrequency() {
string text = "apple banana apple orange banana apple grape";
// 第1步:用 map 统计频率(自动按字典序排列)
map<string, int> freq;
istringstream iss(text);
string word;
while (iss >> word)
freq[word]++; // word不存在 → 自动创建并初始化为0 → ++ → 变成1
// word已存在 → ++ → 频率+1
// 第2步:按频率降序排列
// map 只能按 key 排序,不能按 value 排序
// 所以要把 map 转成 vector
vector<pair<string, int>> vec(freq.begin(), freq.end());
// 第3步:对 vector 按 value(频率)降序排序
sort(vec.begin(), vec.end(),
[](auto& a, auto& b) {
if (a.second != b.second)
return a.second > b.second; // 频率高的在前
return a.first < b.first; // 频率相同按字母序
});
// 输出结果
for (auto& [word, cnt] : vec)
cout << word << ": " << cnt << endl;
}
```
---
### 6. unordered_map / unordered_set — 哈希表
> **Who** — 哈希思想源自 1953 年 IBM 的 Hans Peter Luhn | STL 中 C++11 正式纳入
> **What** — 基于**哈希表**(数组+拉链法),O(1) 平均增删查,**不保证顺序**
> **When** — **选它**:纯快速查找/去重/计数 | **不选它**:需要有序遍历/范围查询/按 key 排序输出
> **Where** — 两数之和、最长无重复子串、前缀和+哈希、字符频率统计、图 visited 标记
> **Why** — O(1) 比 O(log n) 快一个数量级;但哈希冲突严重时退化到 O(n),且内存开销比 map 大
```
┌────────────────────────────────────────────────────────────┐
│ 底层结构(数组+拉链法) │
│ │
│ buckets: [0]→(k₁,v₁)→(k₂,v₂) ← 同一个桶内拉链表 │
│ [1]→(k₃,v₃) │
│ [2]→nullptr │
│ [3]→(k₄,v₄)→(k₅,v₅)→(k₆,v₆) │
│ ... │
│ │
│ 操作流程: │
│ 插入(k,v) → hash(k) % bucket_count → 找到桶 → 入链 │
│ 查找(k) → hash(k) % bucket_count → 遍历链找相等key │
│ │
│ 扩容(rehash): │
│ 当 load_factor > max_load_factor(默认1.0)时触发 │
│ → 桶数增加(通常×2)→ 重新hash所有元素 → O(n) │
└────────────────────────────────────────────────────────────┘
```
```cpp
#include <unordered_map>
#include <unordered_set>
void demo_unordered() {
// ===== unordered_set =====
unordered_set<int> us;
// 和 set 几乎一样的使用方式,但:
// ✅ 平均 O(1) — 快得多
// ❌ 无序 — 遍历顺序不可预测
// ❌ 不能 lower_bound / upper_bound
us.insert(3);
us.insert(1);
us.insert(2);
for (int x : us) cout << x << " "; // 可能是 3 1 2,也可能是 1 2 3 ...
// ===== unordered_map =====
// key→value 映射,O(1) 平均
unordered_map<string, int> ump;
ump["key"] = 100; // operator[](和 map 一样)
ump.insert({"k2", 200}); // insert
// 遍历(顺序不确定):
for (auto& [k, v] : ump)
cout << k << ":" << v << endl;
}
```
```cpp
// ==================== 应用模板1:两数之和 ====================
// 题目:找两个数使它们的和等于 target,返回下标
// 思路:
// 从左到右遍历 → 对每个 nums[i],
// 检查 target - nums[i] 是否"之前出现过"
// → 哈希表查"之前出现过"是 O(1)
vector<int> twoSum(vector<int>& nums, int target) {
// key = 数组元素的值(查找的key)
// value = 该值在数组中的下标(需要返回的结果)
unordered_map<int, int> valToIndex;
for (int i = 0; i < (int)nums.size(); i++) {
int need = target - nums[i]; // 需要的"另一半"
// 在哈希表中查找 need(O(1))
auto it = valToIndex.find(need);
if (it != valToIndex.end()) {
// 找到了!it->second 是 need 的下标
// i 是 nums[i] 的下标
return {it->second, i};
// 返回 {之前值的下标, 当前值的下标}
}
// 没找到 → 把当前值存入哈希表(为后面的元素服务)
valToIndex[nums[i]] = i;
// valToIndex.insert({nums[i], i}); // 等价写法
}
return {}; // 没找到任何答案(题目保证有解,这行不会执行)
// 时间复杂度:O(n) — 每个元素只遍历一次
// 空间复杂度:O(n) — 哈希表最多存 n 个元素
}
```
```cpp
// ==================== 应用模板2:前缀和 + 哈希 ====================
// 题目:和为 K 的连续子数组个数
// 思路:
// sum[i] = nums[0..i] 的和(前缀和)
// 子数组 [j..i] 的和 = sum[i] - sum[j-1]
// 要找 sum[i] - sum[j-1] = K
// → sum[i] - K = sum[j-1]
// → 对每个 i,统计之前出现过多少个前缀和等于 sum[i] - K
//
// 为什么不用暴力?
// 暴力:枚举所有子数组 O(n²)
// 哈希:O(n)
int subarraySum(vector<int>& nums, int k) {
// key = 前缀和的值
// value = 该前缀和出现的次数
unordered_map<int, int> prefixCount;
// 初始化:前缀和为0出现1次
// 为什么?因为如果 sum[i] == k,那么子数组 nums[0..i] 满足条件
// 此时需要 prefixCount[0] = 1 来匹配
prefixCount[0] = 1;
int sum = 0; // 当前前缀和
int ans = 0; // 满足条件的子数组个数
for (int x : nums) {
sum += x; // 更新当前前缀和
// 我们需要找"之前"有多少个前缀和等于 sum - k
// 因为 sum - (sum - k) = k
int need = sum - k;
if (prefixCount.count(need)) {
ans += prefixCount[need];
// 为什么是 += 而不是 ++?
// 因为可能有多个不同的 j 位置有相同的前缀和
// 每一个都能和当前的 i 组成一个合法子数组
}
// 记录当前前缀和(为后面的元素服务)
prefixCount[sum]++;
}
return ans;
// 时间复杂度:O(n)
// 空间复杂度:O(n)
}
```
```cpp
// ==================== 应用模板3:最长无重复子串 ====================
// 思路:
// 滑动窗口 + 哈希表记录每个字符"最后出现的位置"
// 遇到重复字符 → 窗口左边界跳到"上次出现位置+1"
int lengthOfLongestSubstring(string s) {
// key = 字符,value = 该字符在 s 中最后一次出现的下标
unordered_map<char, int> lastPos;
int maxLen = 0; // 记录最长长度
int left = 0; // 滑动窗口左边界(包含)
// right = 滑动窗口右边界(包含)
for (int right = 0; right < (int)s.size(); right++) {
char c = s[right]; // 当前处理的字符
// 检查这个字符是否在当前窗口内出现过
if (lastPos.count(c)) {
// 出现过!但要注意:可能 lastPos[c] 在 left 左边(已不在窗口内)
// 所以要取 max
left = max(left, lastPos[c] + 1);
// 为什么是 lastPos[c] + 1?
// 因为要跳过重复的那个字符
// 比如 "abcda",right=4 发现 'a' 在位置0出现过
// left 跳到 0+1=1 → 窗口变成 "bcda"
}
// 更新/记录当前字符的位置
lastPos[c] = right;
// 计算当前窗口长度并更新最大值
int curLen = right - left + 1;
maxLen = max(maxLen, curLen);
}
return maxLen;
// 时间复杂度:O(n) — 每个字符被处理一次
// 空间复杂度:O(min(n, 128)) — 最多存128个ASCII字符
}
```
---
### 7. string — 字符串类
> **Who** — C++ 标准委员会,1998 年 C++98 标准纳入 `std::string`,取代 C 风格 `char*`
> **What** — 字符的动态数组 + 字符串专属操作(查找/替换/截取/数值转换),底层连续内存
> **When** — **选它**:任何字符串处理 | **不选它**:极致的零拷贝场景(用 `string_view`,C++17)
> **Where** — 文本解析、回文判断、模式匹配、数值字符串互转、split 分割、输入格式化
> **Why** — 自动管理内存(不用 `malloc/free`),支持 STL 算法(`begin()/end()`),小字符串优化(SSO)避免堆分配
```cpp
#include <string>
void demo_string() {
// ===== 构造 =====
string s1 = "hello";
string s2("world");
string s3(5, 'a'); // "aaaaa" — 5个'a'
string s4(s1, 1, 3); // "ell" — 从位置1取3个字符
// ===== 查找(找不到返回 string::npos)=====
string s = "hello world, hello cpp";
size_t pos = s.find("hello"); // 0 — 首次出现位置
pos = s.find("hello", 1); // 13 — 从位置1开始找
pos = s.rfind("hello"); // 13 — 从右往左找(最后一次出现)
pos = s.find_first_of("aeiou"); // 1 — 第一个元音字母(e)
pos = s.find_last_of("aeiou"); // 最后一个元音字母
pos = s.find_first_not_of(" "); // 第一个非空格
// ===== 截取子串 =====
string sub = s.substr(6, 5); // "world" — 从位置6取5字符
string sub2 = s.substr(12); // "hello cpp" — 从位置12到结尾
// substr(pos, len):从pos起取len个字符
// substr(pos):从pos起取到末尾
// ===== 修改 =====
s.replace(0, 5, "HELLO"); // 位置0起5字符替换为 "HELLO"
s.erase(3, 4); // 从位置3删除4个字符
s.insert(0, "say "); // 在位置0前插入 "say "
// ===== 数值转换 =====
int i = stoi("12345"); // string to int
long l = stol("12345"); // string to long
double d = stod("3.14159"); // string to double
string ns = to_string(12345); // 数字 → 字符串
// 注意:stoi 遇到非数字字符会抛 std::invalid_argument
// ===== 字符分类 =====
// #include <cctype>
isalpha(c); // 是字母?
isdigit(c); // 是数字?
isalnum(c); // 是字母或数字?
isspace(c); // 是空白字符?
isupper(c); // 是大写?
islower(c); // 是小写?
toupper(c); // 转大写(不修改原字符,返回值是大写)
tolower(c); // 转小写
}
```
```cpp
// ==================== 应用:split 分割字符串 ====================
vector<string> split(const string& s, char delimiter) {
vector<string> tokens;
// istringstream 把字符串当作"输入流"来读
istringstream iss(s);
string token;
// getline 的第三个参数是"分隔符"(默认是换行符 \n)
while (getline(iss, token, delimiter)) {
if (!token.empty()) // 跳过空token(比如连续两个分隔符产生的空串)
tokens.push_back(token);
}
return tokens;
}
// split("a,b,c", ',') → {"a", "b", "c"}
// split("one,two,three", ',') → {"one", "two", "three"}
```
---
## 第二部分:机试核心算法(每个都带详细注释)
### 算法1:排序与定制排序
> **Who** — C++ `std::sort` 采用 David Musser 的**内省排序**(快排+堆排+插入排序混合)
> **What** — 对序列重排,默认升序;定制比较器可实现任意排序规则
> **When** — 算法题第一件事往往就是排序 | **不排序**:题目要求保留原顺序
> **Where** — 去重(先排序)、贪心预处理、二分查找前提、TopK、合并区间
> **Why** — O(n log n) 保证,比 C 的 `qsort` 快(内联比较器,无函数指针开销)
```cpp
// ==================== 结构体多关键字排序 ====================
struct Student {
string name;
int score; // 成绩
int id; // 学号
};
void demo_sort_struct() {
vector<Student> stus = {
{"Alice", 90, 3},
{"Bob", 85, 1},
{"Carol", 90, 2},
{"Dave", 85, 4}
};
// 需求1:按成绩降序,成绩相同按学号升序
sort(stus.begin(), stus.end(),
[](const Student& a, const Student& b) {
// C++ sort 的比较函数:a 应该排在 b 前面 → 返回 true
// 返回 false 表示 a 应该排在 b 后面
if (a.score != b.score)
return a.score > b.score; // 成绩高的在前(降序)
return a.id < b.id; // 成绩相同 → 学号小的在前(升序)
});
// 需求2:先按姓名长度升序,再按成绩降序
sort(stus.begin(), stus.end(),
[](const Student& a, const Student& b) {
if (a.name.size() != b.name.size())
return a.name.size() < b.name.size(); // 名字短的在前
return a.score > b.score; // 长度相同 → 成绩高的在前
});
}
```
```cpp
// ==================== 按绝对值降序排序 ====================
void demo_sort_abs() {
vector<int> v = {3, -5, 2, -8, 1, -4};
sort(v.begin(), v.end(),
[](int a, int b) {
return abs(a) > abs(b); // 绝对值大的在前
});
// 结果:{-8, -5, 4, 3, 2, 1}
// 解释:abs(-8)=8, abs(-5)=5, abs(4)=4...
}
```
```cpp
// ==================== 奇数在前(升序),偶数在后(降序) ====================
void demo_sort_odd_even() {
vector<int> v = {1, 2, 3, 4, 5, 6, 7, 8};
sort(v.begin(), v.end(),
[](int a, int b) {
bool aOdd = (a & 1); // a是奇数 → true
bool bOdd = (b & 1);
if (aOdd && bOdd) return a < b; // 两个奇数 → 升序
if (!aOdd && !bOdd) return a > b; // 两个偶数 → 降序
return aOdd; // 一个是奇数一个是偶数 → 奇数优先
// 当 aOdd=true && bOdd=false → 返回true → a在前(奇数优先)
// 当 aOdd=false && bOdd=true → 返回false → b在前(奇数优先)
});
// 结果:{1, 3, 5, 7, 8, 6, 4, 2}
}
```
---
### 算法2:二分查找
> **Who** — 二分思想源自 John Mauchly(1946 年首次提出二分查找概念)
> **What** — 在**有序序列**中每次排除一半搜索空间,O(log n) 定位目标
> **When** — 序列有序 + 找特定值/边界 | **不用**:链表/无序数据/频繁插入删除的场景
> **Where** — 查找特定值、找边界(lower_bound/upper_bound)、二分答案(最大值最小化)
> **Why** — O(log n) 比 O(n) 快指数级;n=10^6 时只需 ~20 次比较;"二分答案"是机试最难套路之一
```cpp
// ==================== 标准二分 API ====================
void demo_binary_search() {
vector<int> v = {1, 3, 5, 7, 9, 11, 13}; // 必须有序!
int target = 7;
// ① binary_search:判断是否存在(返回 bool)
bool found = binary_search(v.begin(), v.end(), target);
// 底层:等价于 lower_bound != end && *lower_bound == target
// ② lower_bound:第一个 ≥ target 的位置
auto lb = lower_bound(v.begin(), v.end(), target);
// 返回:指向第一个 >= 7 的元素 → 指向7
int idx = lb - v.begin(); // 索引 = 3
// 如果 target 大于所有元素 → 返回 end()
// ③ upper_bound:第一个 > target 的位置
auto ub = upper_bound(v.begin(), v.end(), target);
// 返回:指向第一个 > 7 的元素 → 指向9
// ④ equal_range:同时得到 lower 和 upper
auto [lo, hi] = equal_range(v.begin(), v.end(), target);
// lo = lower_bound, hi = upper_bound
// [lo, hi) 范围内都是等于 target 的元素
int count = hi - lo; // target 的出现次数
}
```
```cpp
// ==================== 手写二分:找第一个 ≥ x 的位置 ====================
// 自己写的版本(机试中有时需要特化逻辑,不能用 STL 的 lower_bound)
int firstGE(vector<int>& arr, int x) {
int lo = 0, hi = (int)arr.size() - 1;
int ans = -1; // -1 表示没找到
// 循环不变式:
// 如果 ans != -1,那么 arr[ans] >= x,且 ans 是"当前已知的"最左位置
while (lo <= hi) {
int mid = lo + (hi - lo) / 2;
// 为什么不用 (lo + hi) / 2 ?
// 因为当 lo 和 hi 都很大时(接近 INT_MAX),lo+hi 会溢出
// lo + (hi - lo)/2 永远安全
if (arr[mid] >= x) {
ans = mid; // mid 满足条件,记录答案
hi = mid - 1; // 往左找看有没有更早的
} else {
lo = mid + 1; // mid 不满足条件,往右找
}
}
return ans; // 如果始终没找到 ≥x 的 → ans = -1
}
```
```cpp
// ==================== 二分答案:最大值最小化 ====================
// 题目:把 n 个正数分成 m 段(连续),使每段和的最大值最小
// 例如:[7,2,5,10,8] 分成 2 段
// 方案1:[7,2,5] 和 [10,8] → 段和 max=18
// 方案2:[7] 和 [2,5,10,8] → max=25
// 答案:18
// 判断函数:每段和不超过 maxSum,能否用 ≤ m 段分完?
bool canSplit(vector<int>& arr, int m, long long maxSum) {
int segCount = 1; // 当前段数
long long curSum = 0; // 当前段的和
for (int x : arr) {
// 如果单个元素就超过了 maxSum → 不可能
if ((long long)x > maxSum) return false;
if (curSum + x > maxSum) {
// 当前段装不下了 → 开新段
segCount++;
curSum = x; // 新段从 x 开始
if (segCount > m) return false; // 段数超了 → 不可行
} else {
curSum += x; // 当前段还能装
}
}
return true; // m 段内分完了
}
long long minimizeMaxSum(vector<int>& arr, int m) {
// 二分答案的范围:
// 最小值 lo = 最大元素(每段至少能装下最大的那个)
// 最大值 hi = 所有元素和(全部放一段)
long long lo = *max_element(arr.begin(), arr.end());
long long hi = accumulate(arr.begin(), arr.end(), 0LL);
while (lo < hi) {
long long mid = lo + (hi - lo) / 2;
if (canSplit(arr, m, mid))
hi = mid; // mid 可行 → 尝试更小的
else
lo = mid + 1; // mid 不可行 → 需要更大的
}
return lo; // lo == hi 时就是最小可行的 maxSum
// 时间复杂度:O(n log S),S = 元素总和
}
```
---
### 算法3:单调栈
> **Who** — 单调栈技巧由算法竞赛社区总结推广,无单一发明者,属"滑动窗口/单调队列"技术家族
> **What** — 维护一个**单调递增/递减**的栈,新元素破坏单调性时弹栈,弹栈时确定被弹元素的"边界"
> **When** — **看到题干关键词**:"下一个更大/更小元素"、"左右第一个比它大/小的位置"、"柱状图"
> **Where** — 每日温度(leetcode 739)、接雨水(42)、柱状图最大矩形(84)、下一个更大元素(496)
> **Why** — 暴力法 O(n²) → 单调栈 O(n);每个元素最多入栈一次出栈一次,均摊 O(1)
```cpp
// ==================== 每日温度(找右侧第一个更大的元素)====================
// 题目:给定温度数组,对每一天,计算需要等几天才会升温
// 输入:[73,74,75,71,69,72,76,73]
// 输出:[1, 1, 4, 2, 1, 1, 0, 0]
//
// 暴力思路:对每个 i,向右扫描直到找到更大的 → O(n²)
// 单调栈思路:用一个"递减"栈,新元素比栈顶大时 → 栈顶的答案确定了
vector<int> dailyTemperatures(vector<int>& T) {
int n = T.size();
vector<int> ans(n, 0); // 答案数组,默认0(找不到则保持0)
// 栈中存的是"还没找到答案的日子"的下标
// 栈内温度保持"严格递减"(所以叫单调栈)
stack<int> st;
for (int i = 0; i < n; i++) {
// 当前温度 T[i] 比栈顶那天的温度高
// → 栈顶那天的"答案"确定了!就是 i - topDay
while (!st.empty() && T[i] > T[st.top()]) {
int prevDay = st.top(); // 之前的那天
st.pop();
ans[prevDay] = i - prevDay; // 等了 (i-prevDay) 天
}
// 当前这天还没找到答案 → 入栈
st.push(i);
}
// 栈中剩余的天 → 右侧没有更大的温度 → ans保持0
return ans;
// 时间复杂度:O(n) — 每个元素最多入栈一次、出栈一次
// 空间复杂度:O(n) — 栈的大小
}
// ==================== 手动模拟演示 ====================
// T = [73, 74, 75, 71, 69, 72, 76, 73]
//
// i=0, T[0]=73: st=[], 入栈 → st=[0]
//
// i=1, T[1]=74:
// T[1]=74 > T[0]=73 → 弹出0,ans[0]=1-0=1
// 入栈1 → st=[1]
//
// i=2, T[2]=75:
// T[2]=75 > T[1]=74 → 弹出1,ans[1]=2-1=1
// 入栈2 → st=[2]
//
// i=3, T[3]=71:
// T[3]=71 < T[2]=75 → 不弹,入栈3 → st=[2,3]
//
// i=4, T[4]=69:
// T[4]=69 < T[3]=71 → 不弹,入栈4 → st=[2,3,4]
//
// i=5, T[5]=72:
// T[5]=72 > T[4]=69 → 弹出4,ans[4]=5-4=1
// T[5]=72 > T[3]=71 → 弹出3,ans[3]=5-3=2
// T[5]=72 < T[2]=75 → 不弹,入栈5 → st=[2,5]
//
// i=6, T[6]=76:
// T[6]=76 > T[5]=72 → 弹出5,ans[5]=6-5=1
// T[6]=76 > T[2]=75 → 弹出2,ans[2]=6-2=4
// 入栈6 → st=[6]
//
// i=7, T[7]=73:
// T[7]=73 < T[6]=76 → 不弹,入栈7 → st=[6,7]
//
// 最终 ans = [1,1,4,2,1,1,0,0]
```
```cpp
// ==================== 接雨水(双指针法,O(n) O(1))====================
// 思路:
// 对每个位置 i,它能接的水量 = min(左边最高, 右边最高) - height[i]
// 如果这个差 ≤ 0 → 不接水
//
// 双指针优化:不需要预处理 leftMax[] 和 rightMax[]
// 用两个指针 l, r 和两个变量 leftMax, rightMax
// 每次移动较矮的那边
int trap(vector<int>& height) {
int n = height.size();
if (n == 0) return 0;
int l = 0, r = n - 1; // 左右指针
int leftMax = 0, rightMax = 0; // 当前已知的左右两侧最高高度
int water = 0; // 总水量
while (l < r) {
// 核心判断:哪边矮就处理哪边
if (height[l] < height[r]) {
// 左边矮 → 左边能接的水量由 leftMax 决定
// 如果 height[l] >= leftMax → 更新 leftMax,当前位置接不到水
// 如果 height[l] < leftMax → 可以接 leftMax - height[l] 的水
if (height[l] >= leftMax)
leftMax = height[l]; // 更新左边界
else
water += leftMax - height[l]; // 接到水!
l++; // 左指针右移
} else {
// 右边矮(或相等)→ 同理处理右边
if (height[r] >= rightMax)
rightMax = height[r];
else
water += rightMax - height[r];
r--;
}
}
return water;
// 时间复杂度:O(n) — 每个位置被访问一次
// 空间复杂度:O(1) — 只用了几个变量
}
```
```cpp
// ==================== 柱状图中最大矩形(单调栈经典)====================
// 题目:给定柱子高度数组,求最大的矩形面积
// 思路:
// 以每根柱子为"最矮的那根" → 向左右扩展到第一个更矮的位置
// → 矩形面积 = 高度 × 宽度
// 单调递增栈:遇到更矮的柱子 → 栈顶的右边界确定了!
int largestRectangleArea(vector<int>& heights) {
// 在末尾加一个高度为0的哨兵
// 作用:保证遍历结束时,栈中所有柱子都会被弹出处理
heights.push_back(0);
int n = heights.size();
stack<int> st; // 存下标,高度单调递增
int maxArea = 0;
for (int i = 0; i < n; i++) {
// 当前柱子比栈顶矮 → 栈顶的"右边界"确定了
while (!st.empty() && heights[i] < heights[st.top()]) {
int h = heights[st.top()]; // 以这跟柱子的高度为矩形高度
st.pop();
// 宽度计算:
// 左边界:栈中下一个元素的下标(或 -1 如果栈空了)
// 右边界:当前下标 i
int left = st.empty() ? -1 : st.top();
int width = i - left - 1;
// 为什么 -1?
// 矩形从 left+1 到 i-1(都不包含 left 和 i)
// 宽度 = (i-1) - (left+1) + 1 = i - left - 1
int area = h * width;
maxArea = max(maxArea, area);
}
st.push(i);
}
return maxArea;
// 时间复杂度:O(n) — 每个柱子最多入栈出栈各一次
}
```
---
### 算法4:BFS(广度优先搜索)
> **Who** — Edward F. Moore(1959 年提出迷宫最短路径算法,BFS 雏形)
> **What** — 从起点逐层向外扩展,用**队列**管理待访问结点,**首次访问**到的路径就是最短路径
> **When** — **题干关键词**:"最短路径"(无权图)、"最少步数"、"层序遍历"、"连通区域"
> **Where** — 迷宫最短路径、岛屿数量、二叉树层序、单词接龙、打开转盘锁
> **Why** — BFS 保证"首次到达 = 最短路径"(无权图);队列 FIFO 保证按层扩展,不会遗漏
```cpp
// ==================== 岛屿数量 ====================
// 题目:二维网格,'1'是陆地,'0'是水,问有多少个连通岛屿
// 思路:
// 遍历每个格子 → 遇到未访问过的陆地 → BFS/DFS 把整个岛"标记"掉
// → 岛屿数量++
int numIslands(vector<vector<char>>& grid) {
// 边界判断
if (grid.empty() || grid[0].empty()) return 0;
int m = grid.size(); // 行数
int n = grid[0].size(); // 列数
int count = 0; // 岛屿数量
// 四个方向的偏移量:右、下、左、上
int dx[] = {0, 1, 0, -1};
int dy[] = {1, 0, -1, 0};
// 更简洁的写法:
// int dirs[][2] = {{0,1},{1,0},{0,-1},{-1,0}};
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
// 跳过水和已访问的陆地(已标记为'2')
if (grid[i][j] != '1') continue;
// 发现一个新的岛屿!
count++;
// BFS 把整个岛屿标记为 '2'(已访问)
queue<pair<int, int>> q;
q.push({i, j});
grid[i][j] = '2'; // 入队即标记,避免重复入队
while (!q.empty()) {
auto [x, y] = q.front();
q.pop();
// 探索四个方向
for (int d = 0; d < 4; d++) {
int nx = x + dx[d]; // 新坐标 x
int ny = y + dy[d]; // 新坐标 y
// 检查:是否在网格内?是否是陆地?
if (nx >= 0 && nx < m && ny >= 0 && ny < n
&& grid[nx][ny] == '1') {
grid[nx][ny] = '2'; // 标记已访问
q.push({nx, ny}); // 入队继续探索
}
}
}
}
}
return count;
// 时间复杂度:O(m×n) — 每个格子最多被访问一次
// 空间复杂度:O(min(m,n)) — 队列最大长度(最坏情况)
}
```
---
### 算法5:动态规划 (DP)
> **Who** — Richard Bellman(1953 年提出"动态规划"一词,用于优化多阶段决策过程)
> **What** — 把大问题分解为**重叠子问题**,用表格记录子问题结果,避免重复计算
> **When** — **题干关键词**:"最长/最短/最大/最小"、"方案数"、"能否达到"、决策序列
> **Where** — 0-1背包/完全背包、LIS(最长递增子序列)、LCS(最长公共子序列)、编辑距离
> **Why** — 暴力递归 O(2^n) → DP O(n²) 甚至 O(n log n);重叠子问题 + 最优子结构 = DP 的两大前提
```cpp
// ==================== 0-1背包 ====================
// 题目:n个物品,重量 w[i],价值 v[i],背包容量 C
// 每个物品只能选0或1次,求最大总价值
//
// 状态定义:dp[j] = 容量为 j 时能获得的最大价值
// 转移方程:dp[j] = max(dp[j], dp[j-w[i]] + v[i])
// j 必须倒序遍历!为什么?
// 因为 dp[j-w[i]] 必须用的是"上一轮"(不包含物品i)的值
// 如果正序遍历,dp[j-w[i]] 可能是"本轮已更新过"的值 → 物品被多次使用!
int knapsack01(vector<int>& w, vector<int>& v, int C) {
// dp[j] 表示容量为 j 时的最大价值
vector<int> dp(C + 1, 0);
// 初始状态:容量0 → 价值0;所有物品都不选 → 价值0
// 对每个物品做决策:选 或 不选
for (int i = 0; i < (int)w.size(); i++) {
// ⚠️ 必须倒序!倒序保证了 dp[j-w[i]] 是上一轮的值
for (int j = C; j >= w[i]; j--) {
// 决策:
// 不选 i:dp[j] 不变
// 选 i: dp[j-w[i]] + v[i](价值增加,容量减少)
dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
}
}
return dp[C]; // 容量为C时的最大价值
// 时间复杂度:O(n×C)
// 空间复杂度:O(C) — 一维数组优化
}
```
```cpp
// ==================== 完全背包 ====================
// 每个物品可以选无限次!
// 和 0-1背包的唯一区别:j 正序!
int knapsackComplete(vector<int>& w, vector<int>& v, int C) {
vector<int> dp(C + 1, 0);
for (int i = 0; i < (int)w.size(); i++) {
// ⚠️ 正序!dp[j-w[i]] 可以是"本轮已选过i"的值
// 这样就允许"再选一次"物品 i
for (int j = w[i]; j <= C; j++) {
dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
}
}
return dp[C];
// 对比记忆:
// 0-1背包:j 从 C 到 w[i](倒序)
// 完全背包:j 从 w[i] 到 C(正序)
// 同一行代码,只是遍历方向不同!
}
```
```cpp
// ==================== 最长递增子序列 LIS ====================
// 题目:找最长的严格递增子序列(不要求连续)
// 输入:[10,9,2,5,3,7,101,18]
// 输出:4(子序列 [2,3,7,101])
//
// 方法:贪心 + 二分 O(n log n)
// dp[i] = 长度为 i+1 的递增子序列的"最小末尾值"
// 遍历每个 x,在 dp 中二分找 x 的位置并更新
int lengthOfLIS(vector<int>& nums) {
// dp[i] = 目前找到的长度为 i+1 的 LIS 的最小末尾元素
// dp 本身一定是严格递增的(数学归纳可证)
vector<int> dp;
for (int x : nums) {
// 在 dp 中找第一个 ≥ x 的位置
auto it = lower_bound(dp.begin(), dp.end(), x);
if (it == dp.end()) {
// x 比 dp 中所有元素都大
// → 可以延长当前最长序列
dp.push_back(x);
} else {
// x 可以替换掉某个更大的末尾值
// → 维持"末尾元素尽量小"的策略
*it = x;
}
}
return dp.size(); // dp 的长度就是 LIS 的长度
// 时间复杂度:O(n log n)
// 空间复杂度:O(n)
}
// 手动模拟:nums = [10,9,2,5,3,7,101,18]
// x=10: dp=[10] — 10比所有元素大,追加
// x=9: dp=[9] — lower_bound(9)→位置0,替换10
// x=2: dp=[2] — lower_bound(2)→位置0,替换9
// x=5: dp=[2,5] — 5比2大,追加
// x=3: dp=[2,3] — lower_bound(3)→5的位置,替换5
// x=7: dp=[2,3,7] — 7比3大,追加
// x=101:dp=[2,3,7,101] — 101比7大,追加
// x=18: dp=[2,3,7,18] — lower_bound(18)→101的位置,替换101
// 最终 dp.size()=4
```
```cpp
// ==================== 最长公共子序列 LCS ====================
// dp[i][j] = text1 前 i 个字符 与 text2 前 j 个字符 的 LCS 长度
// 转移方程:
// if text1[i-1] == text2[j-1] → dp[i][j] = dp[i-1][j-1] + 1
// else → dp[i][j] = max(dp[i-1][j], dp[i][j-1])
int longestCommonSubsequence(string text1, string text2) {
int m = text1.size(), n = text2.size();
// dp 的大小是 (m+1)×(n+1)
// 多一行一列作为边界(空字符串的 LCS = 0)
vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0));
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
// 索引偏移:dp[i][j] 对应 text1[i-1] 和 text2[j-1]
if (text1[i - 1] == text2[j - 1]) {
// 当前字符相同 → 必然在 LCS 中
dp[i][j] = dp[i - 1][j - 1] + 1;
// 含义:去掉这俩字符的子问题答案 + 1
} else {
// 当前字符不同 → 至少有一个不在 LCS 中
dp[i][j] = max(dp[i - 1][j], // 丢弃 text1[i-1]
dp[i][j - 1]); // 丢弃 text2[j-1]
}
}
}
return dp[m][n];
// 时间复杂度:O(m×n)
// 空间可优化为 O(min(m,n))(只用两行滚动数组)
}
```
---
### 算法6:贪心(区间问题)
> **Who** — 贪心思想自古有之,形式化源自 1950s 运筹学,Dijkstra/Huffman/Prim/Kruskal 都是贪心经典
> **What** — 每一步做"当前看起来最好"的选择,**不回溯**,期待最终达到全局最优
> **When** — **题干关键词**:"最多不重叠区间"、"最少箭引爆气球"、"最小字典序" | **前提**:贪心选择性质 + 最优子结构
> **Where** — 区间调度、合并区间、哈夫曼编码(合并果子)、Dijkstra(贪心选最近顶点)
> **Why** — 正确时 O(n log n) 比 DP 的 O(n²) 快;但贪心不总是对的——需要证明"局部最优 → 全局最优"
```cpp
// ==================== 最多不重叠区间 ====================
// 题目:给定 n 个区间 [start, end],选最多的不重叠区间
// 贪心策略:按结束时间排序,每次选结束最早的
//
// 为什么正确?
// 结束最早的区间 → 留给后面的时间最多 → 能放更多区间
int maxNonOverlap(vector<pair<int,int>>& intervals) {
// 按结束时间升序排序
sort(intervals.begin(), intervals.end(),
[](auto& a, auto& b) { return a.second < b.second; });
// 如果结束时间相同?随便排,因为至少有一个会被选中
int count = 0; // 已选的区间数量
int lastEnd = -1; // 上一个选中区间的结束时间(-1表示还没选)
for (auto& [start, end] : intervals) {
// 如果当前区间的开始 > 上一个区间的结束 → 不重叠,可以选
if (start > lastEnd) {
count++;
lastEnd = end; // 更新最后结束时间
}
// 如果 start <= lastEnd → 与上一个重叠,跳过
}
return count;
// 时间复杂度:O(n log n)(排序主导)
// 为什么不能用 start 排序?
// 反例:[[1,10],[2,3],[4,5]] → 按start排会选[1,10]然后后面的都冲突
// → 答案只有1,实际可以选2个
}
// ==================== 合并区间 ====================
// 输入:[[1,3],[2,6],[8,10],[15,18]]
// 输出:[[1,6],[8,10],[15,18]]
vector<vector<int>> mergeIntervals(vector<vector<int>>& intervals) {
if (intervals.empty()) return {};
// 按 start 升序排序(与"不重叠区间"相反!)
sort(intervals.begin(), intervals.end());
// 对 vector<int> 默认就是按第一个元素排
vector<vector<int>> merged;
merged.push_back(intervals[0]); // 先把第一个放进去
for (int i = 1; i < (int)intervals.size(); i++) {
auto& last = merged.back(); // 已合并的最后一个区间
int curStart = intervals[i][0];
int curEnd = intervals[i][1];
if (curStart <= last[1]) {
// 有重叠 → 扩展 last 的右边界
last[1] = max(last[1], curEnd);
} else {
// 无重叠 → 新区间
merged.push_back(intervals[i]);
}
}
return merged;
}
```
---
### 算法7:回溯 — 排列/组合/子集
> **Who** — 回溯思想源自 1960s 人工智能研究,形式化于 N-皇后问题和图着色问题
> **What** — 递归地"做选择→探索→撤销选择",遍历**所有可能解**,遇到死路就回溯
> **When** — **题干关键词**:"所有可能"、"全排列"、"所有子集"、"N皇后"、"组合总和"
> **Where** — 全排列(permutation)、子集(subsets)、组合(combine)、N皇后、数独、括号生成
> **Why** — 搜索空间小时最直接;配合剪枝可以大幅减少无效搜索;排列/组合是机试必考模板
```cpp
// ==================== 全排列 ====================
// 输入:[1,2,3]
// 输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
//
// 回溯模板:
// void backtrack(选择列表, 路径) {
// if (满足结束条件) { 记录答案; return; }
// for (选择 in 选择列表) {
// 做选择;
// backtrack(新的选择列表, 路径 + 当前选择);
// 撤销选择; ← 关键!还原状态
// }
// }
vector<vector<int>> permute(vector<int>& nums) {
int n = nums.size();
vector<vector<int>> ans; // 存所有结果
vector<int> path; // 当前路径(正在构建的一个排列)
path.reserve(n); // 预分配,避免反复扩容
vector<bool> used(n, false); // 标记哪些数字已经被选了
// C++ 中 lambda 递归需要用 function 包装(或用 auto&& 自引用)
// 这里用 function 最清晰
function<void()> dfs = [&]() {
// 终止条件:路径长度 == n → 所有数字都选完了
if (path.size() == n) {
ans.push_back(path); // 保存一个合法排列
return;
}
// 遍历所有"可选数字"
for (int i = 0; i < n; i++) {
if (used[i]) continue; // 已经选过 → 跳过
// === 做选择 ===
used[i] = true; // 标记已选
path.push_back(nums[i]); // 加入路径
// === 递归 ===
dfs(); // 进入下一层决策
// === 撤销选择(回溯的关键!)===
path.pop_back(); // 从路径中移除
used[i] = false; // 恢复为"未选"状态
}
};
dfs();
return ans;
// 时间复杂度:O(n × n!) — n! 个排列,每个拷贝到 ans 要 O(n)
// 空间复杂度:O(n) — path 和 used 以及递归栈深度
}
```
```cpp
// ==================== 子集(不含重复元素)====================
// 输入:[1,2,3]
// 输出:[[],[1],[2],[3],[1,2],[1,3],[2,3],[1,2,3]]
//
// 两种写法:
// 写法1:每个元素"选或不选"(二叉树式回溯)
// 写法2:for循环 + 逐步扩大选择范围(更常用)
vector<vector<int>> subsets(vector<int>& nums) {
vector<vector<int>> ans;
vector<int> path;
// idx 表示当前"考虑"到哪个下标
// 对 nums[idx..n-1] 这些元素做决策
function<void(int)> dfs = [&](int idx) {
// 每个"中间状态"都是一个合法子集!
ans.push_back(path);
// 注意:不需要像排列那样的 "if(path.size()==n)" 终止条件
// 因为 for 循环会自动结束
for (int i = idx; i < (int)nums.size(); i++) {
path.push_back(nums[i]); // 选 nums[i]
dfs(i + 1); // 递归:从 i+1 继续(避免重复)
path.pop_back(); // 撤销:不选 nums[i]
}
};
dfs(0);
return ans;
// 时间复杂度:O(n × 2ⁿ) — 2ⁿ 个子集
// 空间复杂度:O(n) — path 和递归栈
}
// 手动演示:nums = [1,2,3]
// dfs(0):
// ans ← [] // path=[]
// i=0: 选1 → path=[1]
// dfs(1):
// ans ← [1] // path=[1]
// i=1: 选2 → path=[1,2]
// dfs(2):
// ans ← [1,2] // path=[1,2]
// i=2: 选3 → path=[1,2,3]
// dfs(3): ans ← [1,2,3]
// 撤销3 → path=[1,2] // for结束,返回
// 撤销2 → path=[1]
// i=2: 选3 → path=[1,3]
// dfs(3): ans ← [1,3]
// 撤销3 → path=[1] // for结束,返回
// 撤销1 → path=[]
// i=1: 选2 → path=[2]
// ...(以此类推)
```
```cpp
// ==================== 组合:从 n 选 k ====================
// 输入:n=4, k=2
// 输出:[[1,2],[1,3],[1,4],[2,3],[2,4],[3,4]]
//
// 和子集唯一的区别:只在 path 长度 == k 时记录答案
vector<vector<int>> combine(int n, int k) {
vector<vector<int>> ans;
vector<int> path;
path.reserve(k);
// start: 从哪个数字开始选(避免选重复)
function<void(int)> dfs = [&](int start) {
// 终止条件:选了 k 个
if ((int)path.size() == k) {
ans.push_back(path);
return;
}
// 剪枝优化:剩余可选数字不够了 → 提前终止
// 还需要选 (k - path.size()) 个
// 从 start 到 n 共有 (n - start + 1) 个可选
// 如果可选的还不够需要的 → 肯定没法完成
for (int i = start; i <= n; i++) {
path.push_back(i);
dfs(i + 1); // 下一层从 i+1 开始(不能重复选)
path.pop_back();
}
};
dfs(1);
return ans;
}
```
---
### 算法8:并查集(Union Find)
> **Who** — Bernard Galler 和 Michael Fischer(1964 年发表"并查集"论文);Robert Tarjan(1975 年证明近乎线性复杂度)
> **What** — 维护**不相交集合**的数据结构:`find(x)` 找根 + `unite(x,y)` 合并两个集合
> **When** — **题干关键词**:"连通分量"、"是否连通"、"冗余连接"、"朋友圈"、"合并"
> **Where** — Kruskal 最小生成树(判环)、连通分量计数、冗余连接、省份数量、岛屿数量(进阶)
> **Why** — 均摊 O(α(n)) ≈ O(1),路径压缩 + 按秩合并两大优化让它几乎是最快的数据结构之一
```cpp
// ==================== 并查集模板 ====================
// 用途:维护不相交集合 → 支持快速合并 + 查询是否在同一集合
// 复杂度:均摊 O(α(n)),α 是阿克曼反函数(实际 ≈ O(1))
//
// 两个核心优化:
// ① 路径压缩(find):递归时将路径上所有结点的父节点直接指向根
// ② 按秩合并(unite):将小的集合并入大的集合
class UnionFind {
// parent[i] = i的父结点
// 如果 parent[i] < 0 → i 是根,且 abs(parent[i]) = 集合大小
vector<int> parent;
public:
// 初始化:n 个独立元素,每个都是自己的根
UnionFind(int n) : parent(n, -1) {
// 全部初始化为 -1 → 每个元素独立,集合大小为1
}
// 查找:找到 x 所在集合的根,同时路径压缩
int find(int x) {
if (parent[x] < 0)
return x; // x 就是根(parent[x] 是负数,存的是集合大小)
// 路径压缩:把 x 到根路径上所有结点的parent直接设为根
return parent[x] = find(parent[x]);
// 递归 + 赋值 = 压缩!下次再查 x 就是 O(1)
}
// 合并 x 和 y 所在的集合
// 返回 true = 合并成功,false = 本来就在同一集合
bool unite(int x, int y) {
int rx = find(x); // x 的根
int ry = find(y); // y 的根
if (rx == ry) return false; // 已经在同一集合
// 按大小合并:确保 rx 是较大的集合
// parent[rx] 是负数 → 绝对值大的集合大
// 因为负数比较:-5 < -3,所以 parent[rx] > parent[ry] 意味着 |parent[rx]| < |parent[ry]|
if (parent[rx] > parent[ry])
swap(rx, ry); // 此时 rx 指向较大的集合
// 合并:ry 并入 rx
parent[rx] += parent[ry]; // 集合大小相加(两个负数相加)
parent[ry] = rx; // ry 的父亲设为 rx
return true;
}
// 判断 x 和 y 是否在同一集合
bool same(int x, int y) {
return find(x) == find(y);
}
// 获取 x 所在集合的大小
int size(int x) {
return -parent[find(x)]; // 根节点的 parent 存的是集合大小的相反数
}
};
```
```cpp
// ==================== 应用:连通分量数量 ====================
// 题目:给定 n 个顶点和若干边,问图中有几个连通分量
int countComponents(int n, vector<pair<int,int>>& edges) {
UnionFind uf(n);
// 每条边把两个顶点所在的集合合并
for (auto& [u, v] : edges)
uf.unite(u, v);
// 统计有多少个不同的根 = 有多少个连通分量
int components = 0;
for (int i = 0; i < n; i++)
if (uf.find(i) == i) // i 是根 → 一个连通分量的代表
components++;
return components;
}
```
```cpp
// ==================== 应用:冗余连接(并查集判环)====================
// 题目:一棵树添加了一条多余边,找出并删除这条边
// 思路:逐一加边,如果加边时发现两端点已经在同一集合 → 这条边是多余的!
vector<int> findRedundantConnection(vector<vector<int>>& edges) {
int n = edges.size();
UnionFind uf(n + 1); // +1 因为顶点编号从1开始
for (auto& e : edges) {
int u = e[0], v = e[1];
if (!uf.unite(u, v)) {
// 合并失败 → u 和 v 已经在同一连通分量
// → 这条边是多余的(形成了环)
return e;
}
}
return {}; // 不会执行到
}
```
---
### 算法9:快速幂
> **Who** — 二分求幂技巧自古有之(印度数学家 Pingala 公元前200年已描述),现代算法称**快速幂/二分幂**
> **What** — 用**二进制拆分指数**:a^b = a^(b 的二进制每位),每次将指数减半,O(log b)
> **When** — 指数 b 很大(如 b≤10^9)且需要取模时 | 暴力 O(b) 循环必超时
> **Where** — 大数幂取模、费马小定理求逆元、矩阵快速幂求 Fibonacci、RSA 加密基础
> **Why** — O(log b) vs O(b):当 b=10^9 时,O(b) 约 10^9 次运算(超时),O(log b) 仅约 30 次
```cpp
// ==================== 快速幂 O(log n) ====================
// 计算 a^b mod MOD
// 思路:a^b = a^(b的二进制展开)
// 例如:a^13 = a^(1101₂) = a^8 × a^4 × a^1
//
// 为什么不用循环乘 b 次?b 可能高达 10^9,O(b) 会超时
const int MOD = 1e9 + 7; // 常用模数(素数)
long long quickPow(long long a, long long b) {
long long result = 1; // 最终结果
long long base = a % MOD; // 底数取模(防溢出)
// 注意:a % MOD 加上 MOD 再 % MOD(处理负数)
while (b > 0) {
if (b & 1) {
// 当前二进制位是 1 → 乘上 base
result = (result * base) % MOD;
}
// base 平方(对应下一位)
base = (base * base) % MOD;
b >>= 1; // b 右移一位
}
return result;
// 时间复杂度:O(log b)
}
// 手动演示:a^13, a=2
// b=13(1101₂)
// 初始化:result=1, base=2
// 第1轮:b&1=1 → result=1×2=2, base=2²=4, b>>=1 → b=6
// 第2轮:b&1=0 → result=2, base=4²=16, b>>=1 → b=3
// 第3轮:b&1=1 → result=2×16=32, base=16²=256, b>>=1 → b=1
// 第4轮:b&1=1 → result=32×256=8192, base=256², b>>=1 → b=0
// 最终 result=8192 = 2^13 ✓
```
---
## 附录:机试代码模板速查
### 常用宏定义
```cpp
#include <bits/stdc++.h> // 万能头(竞赛环境推荐)
using namespace std;
using ll = long long; // 类型别名,少打字
using pii = pair<int, int>;
#define rep(i, a, b) for(int i=(a); i<(b); i++)
#define rrep(i, a, b) for(int i=(a); i>=(b); i--)
#define all(v) (v).begin(), (v).end()
```
### 常用调试宏
```cpp
// 机试调试时用,提交时注释掉
#define debug(x) cerr << #x << " = " << x << endl
#define debugv(v) { cerr << #v << " = ["; for(auto& x:v) cerr<<x<<" "; cerr<<"]\n"; }
```
### 必须记住的边界值
```cpp
const int INF = 0x3f3f3f3f; // 约1e9,memset常用
const ll LINF = 1e18; // 大于10^18
const int MOD = 1e9 + 7; // 素数模
// memset(arr, 0x3f, sizeof arr) → arr每个元素 ≈ 0x3f3f3f3f ≈ 1.06e9
```
---
> **机试终极建议**:选了 vector 就对了90%,再配一个 unordered_map 搞定剩下9%,特殊场景才用 list/deque。算法题先想暴力,再优化。10^7 以内的运算在 1 秒内能完成。
更多推荐
所有评论(0)