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