184 lines
13 KiB
Markdown
184 lines
13 KiB
Markdown
## **C++算法 暴力枚举 讲义**
|
||
|
||
#### **1. 引言**
|
||
|
||
* **问题引入**
|
||
|
||
设想一下,你觉得家门口的山非常碍事,决心发扬“愚公移山”的精神,一镐一担地要把山移走。这个故事告诉我们,只要目标明确、方法单一,并且有无限的时间,理论上任何有限的困难都可以被克服。这就是“暴力”思想的雏形:**不计代价,穷举所有操作**。
|
||
|
||
然而,现实中时间是有限的。但如果问题规模变了呢?如果你要移走的不是一座山,而是一个不到半人高的小沙堆,那一镐一担的笨办法虽然累,但很快就能完成。再切换一个场景:你忘记了行李箱的三位数字密码,从 `000` 试到 `999`,这1000次尝试对人来说可能很繁琐,但对每秒能进行上亿次计算的计算机来说,简直就是一眨眼的功夫。
|
||
|
||
这堂课,我们就是要学习如何让计算机来做这个“不知疲倦的愚公”,用最直接、最朴素的方式,在有限且可控的“解空间”内,系统性地找出我们想要的答案。
|
||
|
||
* **价值阐述**
|
||
|
||
很多初学者看不起“暴力”,觉得它“没技术含量”。这种想法是极其危险的。一个成熟的程序员必须深刻理解并掌握暴力枚举,因为它:
|
||
|
||
1. **兜底与抢分**:在算法竞赛中,你可能无法立刻想出最优解。但只要问题的数据规模不大,编写一个简单的暴力程序往往能让你稳稳拿到30%甚至70%的分数,这远比因为想不出低复杂度解法而交白卷要强得多。
|
||
|
||
2. **高级算法的基石**:未来要学习的**深度优先搜索(DFS)、回溯法**,**动态规划(DP)**,其本质都是在暴力枚举的基础上,增加了“**剪枝**”(砍掉无效的搜索分支)或“**记忆化**”(记住已经算过的结果,避免重复计算)等优化手段。
|
||
|
||
3. **是检验正确性的标准**:当你实现了一个复杂的、高度优化的算法后,你如何100%确定它是对的?一个常见且可靠的方法就是,用一个逻辑简单、绝对正确的暴力算法,在小数据规模下和你的最优解进行“对拍”(比对最终输出结果是否一致),来验证你复杂算法的正确性。
|
||
|
||
---
|
||
### **3. 实例**
|
||
|
||
#### **案例一:统计棋盘中的矩形和正方形 (例10-1)**
|
||
|
||
这个问题是理解“暴力”如何演进为“优雅”的最佳范例。
|
||
|
||
* **问题**: 在一个 `n x m` 的棋盘上,有多少个矩形?多少个正方形?
|
||
* **示例**: 一个 `2 x 3` 的棋盘。
|
||
|
||
##### **解法A:暴力四重循环枚举“两个角点”**
|
||
|
||
* **核心思路**: 一个矩形可以被它左上角顶点 `(x1, y1)` 和右下角顶点 `(x2, y2)` 唯一确定。那么,我们只要能枚举出所有合法的 `(x1, y1, x2, y2)` 组合,就等同于找到了所有矩形。
|
||
|
||
* **分步演示 (思维过程)**:
|
||
1. **枚举左上角行 `x1`**: 它可以从第1行到第n行。`for (int x1 = 1; x1 <= n; ++x1)`。
|
||
2. **枚举左上角列 `y1`**: 它可以从第1列到第m列。`for (int y1 = 1; y1 <= m; ++y1)`。
|
||
3. **枚举右下角行 `x2`**: `x2` 必须在 `x1` 的下方或同一行,所以它从 `x1` 开始,到第n行结束。`for (int x2 = x1; x2 <= n; ++x2)`。
|
||
4. **枚举右下角列 `y2`**: `y2` 必须在 `y1` 的右方或同一列,所以它从 `y1` 开始,到第m列结束。`for (int y2 = y1; y2 <= m; ++y2)`。
|
||
5. **检验逻辑 (`check`)**: 在四重循环的内部,我们得到了一个矩形。
|
||
* 计算宽度 `width = y2 - y1 + 1`。
|
||
* 计算高度 `height = x2 - x1 + 1`。
|
||
* 如果 `width == height`,那么正方形数量 `square_count++`。
|
||
* 否则,它是一个非正方形的矩形,`rectangle_count++`。
|
||
|
||
* **代码实现**
|
||
|
||
```cpp
|
||
long long square_count = 0;
|
||
long long rectangle_count = 0;
|
||
|
||
// 枚举左上角 (x1, y1)
|
||
for (int x1 = 1; x1 <= n; ++x1) {
|
||
for (int y1 = 1; y1 <= m; ++y1) {
|
||
// 枚举右下角 (x2, y2)
|
||
for (int x2 = x1; x2 <= n; ++x2) {
|
||
for (int y2 = y1; y2 <= m; ++y2) {
|
||
// 这是一个合法的矩形,进行判断
|
||
if ((x2 - x1) == (y2 - y1)) {
|
||
square_count++;
|
||
} else {
|
||
rectangle_count++;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
* **复杂度分析**: 四层循环,每一层循环的次数都与 `n` 或 `m` 相关。总的时间复杂度为 $O(N^2 \times M^2)$。如果 `N` 和 `M` 是1000,计算次数将是 $1000^4 = 10^{12}$(万亿)级别。**这在任何比赛中都是不可接受的,必定超时!**
|
||
|
||
##### **解法B:枚举“两条边”和“边长”**
|
||
|
||
暴力法的低效根源在于枚举的对象(点)和问题(矩形)的关联太间接。我们换一个视角。
|
||
|
||
* **核心思路 (矩形)**: 一个矩形是由“两条不平行的水平线”和“两条不平行的垂直线”框出来的。
|
||
* 在一个 `n` 行的棋盘上,有多少条水平线?答案是 `n+1` 条。
|
||
* 在一个 `m` 列的棋盘上,有多少条垂直线?答案是 `m+1` 条。
|
||
* **问题转化**: 找矩形 = 从 `n+1` 条水平线中选2条 $\times$ 从 `m+1` 条垂直线中选2条。
|
||
* **数学公式**: 组合学告诉我们,从 `k` 个物品中选2个的组合数是 $C(k, 2) = \frac{k \times (k-1)}{2}$。
|
||
* **总矩形数量** = $(\frac{(n+1) \times n}{2}) \times (\frac{(m+1) \times m}{2})$。
|
||
* 这个公式可以在 $O(1)$ 时间内计算出所有矩形(包括正方形)的总数。
|
||
|
||
* **核心思路 (正方形)**: 改变枚举对象,我们不枚举角点,而是枚举**正方形的边长**。
|
||
* **边长为 1** 的正方形有多少个?显然是 `n * m` 个。
|
||
* **边长为 2** 的正方形有多少个?想象一个 `2x2` 的正方形在棋盘上滑动。它的右下角可以在 `(n-1) * (m-1)` 个位置上。所以有 `(n-1) * (m-1)` 个。
|
||
* **边长为 k** 的正方形有多少个?它的右下角可以在 `(n-k+1) * (m-k+1)` 个位置上。
|
||
* **总正方形数量**: 我们只需写一个循环,让边长 `k` 从1遍历到 `min(n, m)` 即可。
|
||
`for (int k = 1; k <= min(n, m); ++k) { square_count += (n-k+1) * (m-k+1); }`
|
||
这个循环的复杂度是 $O(\min(N, M))$,与 $O(N^2 M^2)$ 相比,是天壤之别。
|
||
|
||
* **最终答案**:
|
||
1. 用 $O(\min(N,M))$ 的循环计算出 `square_count`。
|
||
2. 用 $O(1)$ 的公式计算出 `total_rectangle_count`。
|
||
3. 非正方形的矩形数量 = `total_rectangle_count - square_count`。
|
||
|
||
---
|
||
#### **案例二:位运算枚举子集 (选数问题)**
|
||
|
||
* **问题**: $n=4$ 个数 `x = {3, 7, 12, 19}`,选出 `k=3` 个,求和为素数的方案数。
|
||
|
||
* **核心思路**: 计算机世界里,万物皆比特。我们可以用一个整数的二进制形态,来“压缩”一个集合的选取状态(有些像之前学习的桶数组思想)。
|
||
|
||
* **分步演示 (深度解析)**
|
||
1. **建立映射**: 我们有4个数,需要4个“开关”。一个4位的二进制数正好可以表示。`b₃b₂b₁b₀`。
|
||
* `b₀` 对应 `x[0]` (3) 的状态。
|
||
* `b₁` 对应 `x[1]` (7) 的状态。
|
||
* `b₂` 对应 `x[2]` (12) 的状态。
|
||
* `b₃` 对应 `x[3]` (19) 的状态。
|
||
|
||
2. **遍历所有可能性**: 所有的选取方案(子集)共有 $2^4=16$ 种。这16种方案,正好对应从 `0000` 到 `1111` 这16个二进制数,也就是十进制的 `0` 到 `15`。
|
||
* `for (int i = 0; i < 16; ++i)` 这个循环,就是在不重不漏地遍历所有子集!`i` 在这里被称为“**掩码 (mask)**”。
|
||
|
||
3. **筛选符合条件的子集 (剪枝)**: 题目要求只选 `k=3` 个数。在我们的模型里,这就等价于:**掩码 `i` 的二进制表示中,必须恰好有3个`1`**。
|
||
* 例如,`i=13 (1101)`,有3个`1`,是我们要考虑的。
|
||
* 例如,`i=9 (1001)`,只有2个`1`,不符合`k=3`的要求,直接跳过。
|
||
* `__builtin_popcount(i)` 这个函数就是用来干这个的,它能快速统计`1`的个数。
|
||
|
||
4. **解码掩码,计算和**: 对于一个通过了筛选的掩码 `i` (比如`13`),我们如何知道它代表了哪几个数呢?这就需要用到 `(i >> j) & 1` 这个核心公式。
|
||
* **目标**: 检查掩码 `i` 的每一位(从第0位到第3位)。
|
||
* `j=0` (检查第0位):
|
||
* `i >> 0`: `1101` 右移0位,还是 `1101`。
|
||
* `(1101) & 1`: `1101` 按位与 `0001`,结果是 `0001` (即1)。
|
||
* `if (1)` 为真,说明第0位是1,所以把 `x[0]` (即3) 加入 `current_sum`。
|
||
* `j=1` (检查第1位):
|
||
* `i >> 1`: `1101` 右移1位,变成 `0110`。
|
||
* `(0110) & 1`: `0110` 按位与 `0001`,结果是 `0000` (即0)。
|
||
* `if (0)` 为假,说明第1位是0,跳过 `x[1]`。
|
||
* `j=2` (检查第2位):
|
||
* `i >> 2`: `1101` 右移2位,变成 `0011`。
|
||
* `(0011) & 1`: `0011` 按位与 `0001`,结果是 `0001` (即1)。
|
||
* `if (1)` 为真,说明第2位是1,把 `x[2]` (即12) 加入 `current_sum`。
|
||
* `j=3` (检查第3位):
|
||
* `i >> 3`: `1101` 右移3位,变成 `0001`。
|
||
* `(0001) & 1`: `0001` 按位与 `0001`,结果是 `0001` (即1)。
|
||
* `if (1)` 为真,说明第3位是1,把 `x[3]` (即19) 加入 `current_sum`。
|
||
* 至此,我们成功通过掩码 `13` 解码出子集 `{3, 12, 19}`,并计算出和 `3+12+19 = 34`。
|
||
|
||
5. **检验 (`check`)**: 将 `current_sum` (34) 传递给 `isPrime` 函数判断。`isPrime(34)` 返回`false`。所以这个方案不计数。
|
||
|
||
程序会继续遍历其他 `popcount` 为3的掩码,如 `i=11 (1011)` -> `{3,7,19}` -> sum=29 -> `isPrime(29)`为`true`,`total++`。
|
||
|
||
---
|
||
#### **案例三:全排列函数 `next_permutation`**
|
||
|
||
* **问题**: 给定集合 `{1, 2, 3}`,输出它的所有排列。
|
||
* **排列 vs. 组合**:
|
||
* **组合**: `{1, 2}` 和 `{2, 1}` 是同一个组合。
|
||
* **排列**: `{1, 2}` 和 `{2, 1}` 是两个不同的排列。顺序很重要!
|
||
* 位运算枚举的是组合(子集),它无法表达顺序信息。
|
||
|
||
* **核心思路**: C++ STL 提供了一个函数 `next_permutation`,它可以将一个序列转换成它的**下一个字典序排列**。
|
||
|
||
* **分步演示 (代码执行流)**
|
||
1. **初始化**: 创建一个 `vector<int> v = {1, 2, 3};`。**至关重要的一步**:在使用 `next_permutation` 前,必须保证你的序列是**字典序最小**的,也就是排好序的。否则将错过在它之前的所有排列。
|
||
2. **使用 `do-while` 模板**:
|
||
```cpp
|
||
do {
|
||
// ... 处理当前排列 v ...
|
||
} while (next_permutation(v.begin(), v.end()));
|
||
```
|
||
3. **执行过程**:
|
||
* **第一次进入循环**: `do` 会无条件执行一次。此时 `v` 是 `{1, 2, 3}`。程序处理(比如打印)这个初始排列。
|
||
* **第一次 `while` 判断**: `next_permutation` 被调用。它发现 `{1, 2, 3}` 的下一个排列是 `{1, 3, 2}`。于是,它将 `v` **原地修改**为 `{1, 3, 2}`,并返回 `true`。循环继续。
|
||
* **第二次进入循环**: `do` 块执行。此时 `v` 是 `{1, 3, 2}`。程序处理这个排列。
|
||
* **第二次 `while` 判断**: `next_permutation` 被调用。它将 `v` 修改为 `{2, 1, 3}`,并返回 `true`。循环继续。
|
||
* ...这个过程不断重复...
|
||
* **最后一次有效循环**: 假设当前 `v` 是 `{3, 2, 1}`,这是字典序最大的排列。`do` 块处理它。
|
||
* **最后的 `while` 判断**: `next_permutation` 被调用。它发现 `{3, 2, 1}` 已经没有下一个更大的排列了。于是,它会做两件事:
|
||
1. 将 `v` 变回**字典序最小**的排列,即 `{1, 2, 3}`。
|
||
2. 返回 `false`。
|
||
* 因为返回 `false`,`while` 循环终止。整个全排列过程结束。
|
||
|
||
### **6. 总结与巩固**
|
||
|
||
1. 暴力枚举是解决问题的基本盘,是学习更高级搜索算法(如DFS)的必经之路。不要轻视它,要熟练掌握它。
|
||
2. **不重不漏**:设计枚举方案时,首要确保的是覆盖所有可能解,既不能重复计算,也不能遗漏任何一种情况。
|
||
3. **优化**:纯粹的暴力是愚公,而如果带着智慧的暴力是巧匠:
|
||
* **数学思维**:尽可能能推导出公式解决的,绝不动循环。
|
||
* **剪枝意识**:在枚举过程中,尽早判断并排除那些明显不可能是答案的分支。
|
||
4. **子集问题 -> 位运算**:当N较小(一般<20)时,使用 `for (int i = 0; i < (1 << n); ++i)` 遍历掩码,并用 `(i >> j) & 1` 解析每一位,是比较方便的解法。
|
||
5. **排列问题 -> `next_permutation`**:熟练掌握 `do-while` + `next_permutation` 的模板,可以轻松解决全排列相关问题。
|