Initial commit: C++ courseware

This commit is contained in:
xiaolinquan committed 2026-11-05 22:51:02 +08:00
commit 4a4ee7c287
1789 files changed
+373489

No files matched your search

@@ -0,0 +1,168 @@
### **C++ 算法:哈希表**
#### **一、 引言**
##### **1.1 问题引入:字符串的查重**
【例17-3 字符串哈希】给定 $N$ 个字符串,求共有多少个**不同**的字符串。
**常规思路**:每拿到一个新字符串,都去已有的列表中从头到尾比对一遍。如果字符串数量达到十万、百万级别,这种时间复杂度为 $O(N^2)$ 的方法必将超时。
我们需要**看一眼字符串的名字,就能瞬间知道它应该放在哪里,以及它是否已经存在。**
##### **1.2 核心思想:图书管理员**
想象你是一个图书管理员,面前有海量的书(字符串)。你不想每次都跑遍整个图书馆找书。你希望有一个**规则**,让你一看到书名,就能立刻算出它应该放在编号为几的书架上。
* **字符串** -> *(通过某个神奇规则)* -> **唯一的书架编号**
这个神奇的规则,就是**哈希函数**。通过它,我们可以将查找的时间复杂度从线性的 $O(N)$ 降低到近乎 **$O(1)$**。
---
#### **二、 核心原理解析**
一个完整的哈希表系统,由两个核心部分组成:**哈希函数** 和 **桶数组(加冲突解决机制)**。
##### **2.1 哈希函数:将字符串变成数字**
我们要解决的第一个问题是:如何把一个字符串(如 `"abc"`)转换成一个唯一的数字索引(键值/哈希码)?
* **错误思路**:直接将字符的 ASCII 码相加。这样 `"abc"` 和 `"cba"` 的结果一样,会导致严重的混淆。
* **正确思路:字符串哈希 (进制转换法)**。位置很重要!就像十进制的 `123` ($1\times10^2 + 2\times10^1 + 3\times10^0$)。我们把字符串看作一个 **P进制数**。
**哈希公式**:$hash("abc") = ('a' * P^2 + 'b' * P^1 + 'c' * P^0) % MOD$
* $P$ 通常取质数(如 131),$MOD$ 取一个很大的质数(如 $10^9 + 7$)以防止溢出并压缩数值范围。
**代码实现(迭代法)**:
我们不需要真去算 $P$ 的次方,只需像从高位读数一样,每次将旧结果乘以 $P$,再加上新字符即可。
```cpp
const int P = 131;
const long long MOD = 1e9 + 7;
int hash_string(const string& str) {
long long h = 0;
for (char c : str) {
h = (h * P + c) % MOD; // 迭代公式
}
return h;
}
```
##### **2.2 桶数组与哈希冲突**
算出哈希值后,我们需要把它放入实际的存储空间——**桶数组 **。
为了把巨大的哈希值塞进有限的数组里,我们必须进行取模运算:`index = hash_val % SIZE`(`SIZE` 为桶数组的大小)。
**哈希冲突**:
根据鸽巢原理,必定会有两个**不同**的字符串,计算出的 `index` **完全相同**。这就好比图书管理员发现,两本不同的书被分配到了同一个书架号。难道我们直接扔掉一本吗?
##### **2.3 解决冲突:链地址法**
遇到冲突时,最经典的解决方案是**链地址法 **。
书架不能只放一本书了。我们在每个书架的位置放一个“箱子”或“链表”。所有被分配到这个书架号的书,统统扔进这个箱子里。
**数据结构设计**:
```cpp
// SIZE 是桶的数量
// vector<string> 就是那个"箱子",里面可以装多本哈希值冲突的书
vector<vector<string>> hashTable(SIZE);
```
**查找/插入流程**:
1. 计算新字符串 `s` 的下标:`index = hash_string(s) % SIZE`。
2. 直接走到第 `index` 个箱子前。
3. **遍历这个箱子** `hashTable[index]`。
* 如果找到了 `s`,说明重复了,什么都不做。
* 如果找遍了箱子也没找到,说明是个全新的字符串,把它装进去:`hashTable[index].push_back(s)`。
只要哈希函数设计得好,冲突就会很少,每个箱子里的书极少,查找速度依然是 **$O(1)$**。但如果哈希函数很糟,所有书都挤在一个箱子里,哈希表就会退化成一个长数组,查找速度降回 **$O(N)$**。
---
#### **三、 STL: `unordered_map`**
手写哈希表能深刻理解原理,但在实战中,C++ 标准库(STL)已经为我们准备好了封装的哈希表——**`unordered_map`**。
它隐藏了复杂的哈希函数计算、内存分配和冲突处理,提供了一套极简、高效的操作接口。
##### **3.1 定义与核心特性**
* **头文件**:`#include <unordered_map>`
* **特性**:键(Key)到值(Value)的映射,键是**唯一**的。内部元素是**无序**的。
* **定义语法**:`unordered_map<KeyType, ValueType> umap;`
* 例如:`unordered_map<string, int> count;` (通常用于统计字符串出现的次数)
##### **3.2 操作符:`[]`**
当我们写下 `umap[key]` 时,会发生:
1. **如果 `key` 已经存在**:
它返回该 `key` 对应的值(Value)的引用。你可以直接读取或修改它。
`cout << umap["apple"];` 对应输出 apple 的数量
`umap["apple"]++;` 对应 apple 的数量加 1
2. **如果 `key` 不存在(重要)**:
它会**自动**在哈希表中创建一个新的键值对 `(key, 默认值)`!如果是 `int`,默认值就是 0。
`umap["banana"]++;` // 如果原本没有 banana,它会创建并设为 `int` 类型的初始值 0,然后执行++变成1。
##### **3.3 其他常用成员函数**
* **查找**:`umap.count(key)`。存在返回1,不存在返回0。比 `[]` 更安全,因为 `count` **不会**自动创建不存在的键。
* **插入**:`umap.insert({"apple", 10})`。如果键已存在,插入会失败。
* **删除**:`umap.erase(key)`。删除指定键的元素。
* **状态**:`umap.size()`(元素个数),`umap.empty()`(是否为空)。
##### **3.4 遍历**
使用基于范围的 `for` 循环遍历,每次拿到的是一个 `pair`(包含 `.first` 和 `.second`)。
```cpp
unordered_map<string, int> fruit = {{"apple", 5}, {"banana", 3}};
for (const auto& pair : fruit) {
cout << "水果: " << pair.first << ", 数量: " << pair.second << endl;
}
```
---
#### **四、 案例:Cities and States (B0250)**
**题意简述**:寻找特殊的城市对。城市1的前两个字母 == 城市2的州代码,且 城市2的前两个字母 == 城市1的州代码,且两者不在同一个州。
```cpp
int main() {
int n;
cin >> n;
// 键:组合字符串, 值:该组合出现的次数
unordered_map<string, int> counts;
long long ans = 0;
for (int i = 0; i < n; i++) {
string city, state;
cin >> city >> state;
string city_prefix = city.substr(0, 2); // 截取前两个字母
if (city_prefix != state) { // 必须来自不同的州
string self_key = city_prefix + state; // 我自己的特征
string partner_key = state + city_prefix; // 我需要的伴侣特征
// 查找伴侣:去哈希表里找,我需要的伴侣之前出现了几次?
// 注意利用了 [] 运算符的自动创建特性,如果不存在就返回0,不会报错
ans += counts[partner_key];
// 登记自己:把自己加入哈希表,供后面的城市查找
counts[self_key]++;
}
}
cout << ans << endl;
return 0;
}
```
@@ -0,0 +1,163 @@
### **C++ 算法:并查集**
#### **一、 引言**
##### **1.1 问题引入:朋友圈的合并与查询**
想象一下,我们班里有 $N$ 个同学,刚开学时大家互不相识,每个人都是一个独立的“小团体”。随着时间推移,不断有两人成为好朋友(例如“小明和小红成了好朋友”)。我们规定,**朋友的朋友也是朋友**,这样就会形成一个个越来越大的“朋友圈”。
现在,班主任需要随时进行两种操作:
1. **合并 (Union)**:当两个人成为新朋友时,快速把他们各自的“朋友圈”合并成一个更大的圈子。
2. **查询 (Find)**:随时提问:同学 A 和同学 B 现在属于同一个朋友圈吗?
##### **1.2 常规思路的困境**
我们尝试用已学的知识来解决这个问题:
* **思路一:用数组存圈子编号**
* 定义 `group_id[i]` 表示 $i$ 号同学所在的圈子编号。
* **查询 (A, B)**:只需比较 `group_id[A] == group_id[B]`。速度极快,时间复杂度 **O(1)**。
* **合并 (A, B)**:假设A在1号圈,B在2号圈。为了合并,我们必须**遍历所有学生**,把所有组号为 2 的人都改成 1。速度慢,时间复杂度 **O(N)**。
* 如果进行 $M$ 次合并,总耗时 $O(M \times N)$,数据量稍大就会超时。
* **思路二:用图的邻接表 + DFS/BFS**
* 把同学看作顶点,朋友关系看作双向边。
* **合并 (A, B)**:在图里加一条边 `adj[A].push_back(B)`。快,时间复杂度 **O(1)**。
* **查询 (A, B)**:要判断A和B是否连通,必须从A开始做一次完整的 DFS 或 BFS 去找 B。慢,时间复杂度 **O(N+M)**。
**总结**:常规方法总是顾此失彼(查询快合并就慢,合并快查询就慢)。我们需要一种全新的数据结构,能让**合并和查询都非常快**。它就是我们今天要学习的——**并查集**。
---
#### **二、 核心思想**
并查集的核心思想,就像现实生活中的组织架构:
每个圈子(集合)都推选出一个唯一的**“老大”(代表元)**,这个圈子里的所有事情,都由老大出面代表。
我们只需要一个非常简单的一维数组:
`int parent[N];`
* `parent[i]` 存储的是 $i$ 号同学的**直接上级**是谁。
##### **2.1 初始化**
一开始,所有人都不认识,每个人都是自己圈子的老大。
```cpp
void init(int n) {
for (int i = 1; i <= n; i++) {
parent[i] = i; // 初始时,每个人的上级就是他自己(自己是老大)
}
}
```
##### **2.2 查询操作:`find(x)`**
**目标**:找出 $x$ 所在圈子的**最终老大**是谁。
怎么找?顺藤摸瓜,逐级上报!
1. 问 $x$ 他的直接上级 `parent[x]` 是谁。
2. 如果上级就是他自己 (`parent[x] == x`),说明 $x$ 就是最高级别的老大,寻找结束。
3. 如果上级不是自己,就把寻找老大的任务抛给他的上级,去问 `find(parent[x])`。
4. 这样一条链式地问上去,必定能找到最终老大。
如果只做到这一步,并查集还是慢的。试想一条长长的单线联系:1的上级是2,2的上级是3... 99的上级是100(老大)。1找老大要汇报99次!
##### **2.3 优化:路径压缩**
小兵 1 心想:既然我最终的老大是司令 100,那我干嘛每次都要通过班长、排长、连长逐级传话呢?我直接越级,**把司令设为我的直接上级**不行吗?
这就是并查集的优化——**路径压缩**。在 `find(x)` 函数**递归返回**的过程中,顺手把经过的所有节点,直接挂到最终老大的名下。
```cpp
int find(int x) {
// 递归边界:如果我自己就是老大,返回我自己
if (parent[x] == x) {
return x;
}
// 核心代码:
// 1. find(parent[x]) 会递归找到最终老大。
// 2. 将最终老大的编号直接赋值给 parent[x]。
// 3. 返回这个最终老大。
return parent[x] = find(parent[x]);
}
```
经过路径压缩后,原本长长的一条链,就会被压缩到扁平。下次再执行 `find` 时,只需一步,时间复杂度接近 **O(1)**!
##### **2.4 合并操作:`unite(u, v)`**
有了 `find` 工具,合并操作实现起来就非常轻松。
$u$ 和 $v$ 成为朋友,要求合并他们的朋友圈。怎么做?**让一个圈子的老大,去认另一个圈子的老大当新老大!**
```cpp
void unite(int u, int v) {
int rootU = find(u); // 找到 u 的老大
int rootV = find(v); // 找到 v 的老大
// 如果老大不一样,说明本来不在一个圈子,需要合并
if (rootU != rootV) {
parent[rootU] = rootV; // 让 u 的老大 认 v 的老大 当上级
}
}
```
**思考**:只改了 `parent[rootU] = rootV` 这一行,整个圈子就合并了吗?
**答案是肯定的!** 因为原来以 `rootU` 为老大的圈子里的小弟们,下次调用 `find` 找老大时,会先找到 `rootU`,然后发现 `rootU` 上面又有新老大了(`rootV`),于是大家都会顺藤摸瓜找到 `rootV`,并在路径压缩的帮助下,直接把自己挂到 `rootV` 名下。
---
#### **三、 实战演练**
##### **案例一:亲戚 (P238页 例17-1)**
**需求**:给出 $n$ 个人,$m$ 对亲戚关系(若 $x,y$ 是亲戚,$y,z$ 是亲戚,则 $x,z$ 也是亲戚)。随后有 $p$ 次询问,判断两个人是否是亲戚。
**分析**:
1. “亲戚关系传递” 就是把人合并到一个圈子。
2. “判断是否是亲戚” 就是判断两人是不是在同一个圈子(老大是不是同一个人)。
**解题流程**:
1. 读取 $n$,执行 `init(n)` 初始化并查集。
2. 读取 $m$ 条关系,对每对关系执行 `unite(u, v)`。
3. 读取 $p$ 次询问,对每对查询,判断 `find(u) == find(v)`。是则输出 Yes,否则输出 No。
##### **案例二:村村通 (B0248)**
**需求**:现有 $n$ 个城镇,$m$ 条已有道路。要求整个城镇网络全部连通(任意两城互达)。问:**最少还需要建设多少条道路?**
**分析**:
城镇是节点,道路是边。这 $n$ 个节点初始时可能由几个互不连通的子图(岛屿)组成。
**一个非常重要的图论结论**:要把 $K$ 个互不连通的岛屿连接成一个完整的连通图,最少需要 **$K - 1$** 条边。
现在的核心问题变成了:**如何计算当前图中,有多少个独立连通的“岛屿”(集合)?**
**并查集的妙用**:
1. **初始状态**:$n$ 个城镇没有任何道路,就是 $n$ 个独立的集合,每个城镇自己是老大。
2. **处理已有道路**:每输入一条道路 `(u, v)`,如果它们本来不连通(老大不同),我们就 `unite(u, v)` 将它们合并。
3. **统计孤岛数量**:所有道路处理完后,我们如何知道还剩多少个集合?
* 在并查集中,每个独立的集合**有且仅有一个最终老大**。
* 老大的特征是什么?**`parent[i] == i`**!只有老大的上级是他自己。
* 因此,我们只需遍历 $1$ 到 $n$,统计有多少个满足 `parent[i] == i` 的节点即可,假设数量为 $K$。
4. **得出答案**:最少需要的道路数 = $K - 1$。
**核心代码片段**:
```cpp
int island_count = 0;
for (int i = 1; i <= n; i++) {
// 如果我的上级还是我自己,说明我是某个 孤岛集合 的老大
if (parent[i] == i) {
island_count++;
}
}
cout << island_count - 1 << endl; // 需要的边数 = 孤岛数 - 1
```
---
#### **四、 总结**
* **并查集 (DSU)** 是一种用于处理**不相交集合的合并及查询**问题的数据结构。
* **核心操作**:
* **`init`**:初始化 (`parent[i] = i`)。
* **`find`**:找老大,并配合**路径压缩** (`parent[x] = find(parent[x])`),将长链压扁,保证效率。
* **`unite`**:合并集合,老大认老大 (`parent[rootU] = rootV`)。
* **应用场景**:朋友关系传递、连通性判断、图的连通块数量统计(如村村通问题)、以及未来将要学习的 Kruskal 最小生成树算法。
* **复杂度**:在应用了路径压缩后,并查集的查询和合并操作的时间复杂度几乎可以看作是 **O(1)**(准确说复杂度应该是反阿克曼函数)。
@@ -0,0 +1,178 @@
### **C++ STL 核心容器讲义:`set` 集合**
#### **一、 引言**
##### **1.1 问题引入:智能的木材仓库**
【例17-5 木材仓库】我们来管理一个特殊的木材仓库。这个仓库有以下几条严格的规则:
1. **唯一性**:仓库里不会有两根长度完全相同的木材。
2. **动态变化**:需要频繁地“进货”(加入新木材)和“出货”(取出木材)。
3. **智能出货**:当需要取出一根长度为 `Length` 的木材时:
* 如果有,就取出它。
* 如果没有,不能随便拿,必须取出仓库中与之长度**最接近**的一根。
* 如果最接近的有两根(一长一短),则**优先取短**的那一根。
##### **1.2 价值阐述:为什么需要 `set`?**
我们来分析一下这个仓库对数据容器的需求:
1. **元素唯一**:这要求容器能自动处理重复元素。
2. **动态增删**:容器必须支持高效的插入和删除。
3. **快速查找“最接近”**:这是最核心、最难的需求。要找“最接近”,数据最好是**有序**的,这样我们才能快速定位到一个值和它的“左邻右舍”。
常规的 `vector` 或数组,虽然可以排序,但每次插入新元素都会破坏顺序,需要重新排序,效率极低。我们需要一个**“时刻保持有序”**且能**“自动去重”**的智能容器。
C++ STL (Standard Template Library) 中,就提供了这样一个完美的工具——`std::set`。
##### **1.3 学习目标**
学完本讲义,你将能够:
1. 理解 `set` 的三大核心特性:有序性、唯一性、动态性。
2. 熟练掌握 `set` 的创建、插入(`insert`)、查找(`find`)、删除(`erase`)等基本操作。
3. 掌握使用 `lower_bound` 在 `set` 中高效查找“第一个大于或等于”目标值的元素。
4. 运用 `set` 解决需要维护动态有序集合的复杂问题。
---
#### **二、 `set` 详解:一个自动排序与去重的集合**
##### **2.1 `set` 的核心特性**
`set` 是一个容器,你可以把它想象成一个“数学集合”。它最重要的三个特性是:
1. **元素唯一性 (Uniqueness)**:`set` 中不允许存在重复的元素。如果你尝试插入一个已经存在的元素,`set` 会自动忽略这次插入操作。
2. **自动排序 (Ordering)**:无论你以何种顺序插入元素,`set` 内部都会**始终**按照元素的值(从小到大)来维护一个有序的序列。
3. **动态增删 (Dynamic)**:可以高效地添加或删除元素,并且在操作后依然保持有序。
**内部原理揭秘**:`set` 之所以能拥有这些神奇的特性,是因为它的底层通常是由一种叫做**“红黑树”**(一种高度自平衡的二叉搜索树)的数据结构实现的。这保证了它的各项操作(插入、删除、查找)都具有非常高效的对数时间复杂度 $O(\log N)$。
##### **2.2 基本操作**
使用 `set` 前,必须包含头文件:`#include <set>`
1. **创建 `set`**:
```cpp
#include <set>
using namespace std;
set<int> wood_set; // 创建一个名为 wood_set 的集合,专门存放 int 类型的木材长度
```
语法:`set<DataType> variable_name;`
2. **插入元素:`insert()`**
* **作用**:将一个元素添加到集合中。
* **用法**:`wood_set.insert(length);`
* `set` 会自动完成排序和去重工作。
3. **查找元素:`find()`**
* **作用**:在集合中查找指定的元素。
* **返回值**:返回一个指向元素的**迭代器 (Iterator)**。迭代器可以理解为指向容器内部元素的一个“指针”或“书签”。
* 如果**找到了**,返回指向该元素的迭代器。
* 如果**没找到**,返回一个特殊的迭代器 `wood_set.end()`。`.end()` 本身不指向任何有效元素,它只是一个“集合末尾之后”的标志,专门用来表示“未找到”。
* **用法**(判断元素是否存在):
```cpp
if (wood_set.find(length) != wood_set.end()) {
cout << "Already Exist\n";
} else {
// 元素不存在,可以进行插入
wood_set.insert(length);
}
```
4. **删除元素:`erase()`**
* **作用**:从集合中移除指定的元素。
* **用法**:`wood_set.erase(length);`
5. **检查是否为空:`empty()`**
* **作用**:判断集合中是否有元素。
* **用法**:`if (wood_set.empty()) { ... }`
---
#### **三、 实战演练:解决木材仓库问题**
##### **3.1 处理“进货”操作 (格式 `1 Length`)**
这个需求与 `set` 的 `find` 和 `insert` 操作契合。
```cpp
int type, length;
cin >> type >> length;
if (type == 1) {
if (wood_set.find(length) != wood_set.end()) {
cout << "Already Exist\n";
} else {
wood_set.insert(length);
}
}
```
##### **3.2 处理“出货”操作 (格式 `2 Length`)**
这是本题的核心难点,也是 `set` 有序性大放异彩的地方。
**第一步:处理空仓库的边界情况**
```cpp
if (wood_set.empty()) {
cout << "Empty\n";
continue; // 直接处理下一条操作
}
```
**第二步:寻找“最接近”的两个候选者**
在一个有序的集合中,与目标 `Length` 最接近的数,只可能是:
* **候选者 A (后继)**:在**大于或等于** `Length` 的数中,最小的那一个。
* **候选者 B (前驱)**:在**严格小于** `Length` 的数中,最大的那一个。
如何高效地找到这两个候选者?答案是**二分查找**。`set` 的有序性让它天生支持二分。
**寻找候选者 A (后继):`lower_bound()`**
`lower_bound` 是 C++ 算法库中的一个函数,它能在有序容器中进行二分查找,找到第一个**不小于**(即大于或等于)目标值的位置。
```cpp
// 在 wood_set 中查找第一个不小于 length 的元素
auto it_A = wood_set.lower_bound(length);
```
* `it_A` 是一个迭代器,指向候选者A。
**寻找候选者 B (前驱)**
候选者 B 正是候选者 A 在有序序列中的**前一个元素**。
```cpp
// 如果 it_A 不是集合的第一个元素,那么它一定有前驱
if (it_A != wood_set.begin()) {
auto it_B = prev(it_A); // C++11 之后,用 prev(it) 获取前一个迭代器
// 或者 it_B = it_A; it_B--; (C++11 之前)
// ... 此时 it_B 指向候选者 B ...
}
```
**第三步:比较候选者,做出最终选择**
现在我们最多有两个候选者(由 `it_A` 和 `it_B` 指向),接下来就是简单的比较:
1. 如果只有一个候选者(比如 `it_A` 是集合的第一个元素,没有前驱 `it_B`),那就直接选它。
2. 如果两个都有,就分别计算它们与 `Length` 的差值。
3. 如果差值不同,选差值小的那个。
4. 如果差值相同,按题目要求,**优先选短的**,即选候选者B。
**第四步:执行出货**
确定了最终要取出的木材 `best_choice` 之后,将其从集合中删除。
```cpp
cout << best_choice << "\n";
wood_set.erase(best_choice);
```
---
#### **四、 总结**
`set` 是 C++ STL 中一个极其强大的容器,是解决**动态有序集合**相关问题。
* **核心原理**:底层由**平衡二叉搜索树**实现,保证了**有序性**和各项操作的**对数时间复杂度 ($O(\log N)$)**。
* **三大特性**:**元素唯一**、**自动排序**、**高效增删**。
* **核心操作回顾**:
* **创建**: `set<T> s;`
* **插入**: `s.insert(val);`
* **判断存在**: `s.find(val) != s.end()`
* **删除**: `s.erase(val);`
* **查找下界**: `s.lower_bound(val);` (极其重要,用于在有序集合中定位)
当你遇到的问题需要维护一个不允许重复且时刻需要保持有序的数据集合时,第一时间就应该想到 `set`。