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

13 KiB
Raw Blame History

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++。
  • 代码实现

    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 模板:
      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 的模板,可以轻松解决全排列相关问题。