Files
cpp-courseware/2.Course/1.深入浅出程序设计竞赛(基础篇)/2.初涉算法/3.暴力枚举/暴力枚举 讲义.md
T

184 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## **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` 的模板,可以轻松解决全排列相关问题。