当前位置: 首页 > news >正文

算法基础篇:(三)基础算法之枚举:暴力美学的艺术,从穷举到高效优化

目录

前言

一、枚举算法的本质与核心思想

1.1 什么是枚举算法?

1.2 枚举算法的核心要素

1.3 枚举算法的适用场景

1.4 枚举算法的优缺点

        优点

        缺点

1.5 枚举算法的通用解题步骤

二、普通枚举:逐一枚举,筛选有效解

2.1 案例 1:铺地毯(洛谷 P1003 [NOIP2011 提高组])

2.1.1 题目分析

2.1.2 解题思路

2.1.3 完整代码实现(ACM 模式)

2.1.4 代码解析

2.1.5 易错点分析

2.2 案例 2:回文日期(洛谷 P2010 [NOIP2016 普及组])

2.2.1 题目分析

2.2.2 解题思路

2.2.3 关键辅助函数

1. 反转数字(如 2010 → 0102)

2. 判断闰年(用于确定 2 月天数)

3. 验证日期有效性

2.2.4 完整代码实现(ACM 模式)

2.2.5 代码解析

2.2.6 易错点分析

2.3 案例 3:扫雷(洛谷 P2327 [SCOI2005])

2.3.1 题目分析

2.3.2 解题思路

2.3.3 完整代码实现(ACM 模式)

2.3.4 代码解析

2.3.5 易错点分析

三、二进制枚举:用位表示状态,枚举所有子集

3.1 二进制枚举的核心原理

3.1.1 状态映射

3.1.2 枚举范围

3.1.3 位运算操作

3.2 案例 1:子集(力扣 78. 子集)

3.2.1 题目分析

3.2.2 解题思路

3.2.3 完整代码实现(核心代码模式)

3.2.4 代码解析

3.2.5 易错点分析

3.3 案例 2:费解的开关(洛谷 P10449)

3.3.1 题目分析

3.3.2 解题思路

3.3.3 关键辅助函数

1. 反转灯的状态(按开关)

3.3.4 完整代码实现(ACM 模式)

3.3.5 代码解析

3.3.6 易错点分析

3.3 案例3:Even Parity(UVA11464)(重点)

3.3.1 题目描述

3.3.2 题目关键分析

(1)偶数矩阵的核心性质

(2) 为什么用二进制枚举?

(3) 修改次数的计算

3.3.3 解题思路详解

(1) 核心步骤

(2) 关键推导公式推导

3.3.4 完整代码实现(ACM 模式)

3.3.5 代码解析

(1) 二进制状态映射(第一行)

(2) 合法性验证

(3) 修改次数计算

3.3.6 易错点与避坑指南

(1) 状态映射错误

(2) 推导公式边界处理遗漏

(3) 合法性验证不完整

(4) 最后一行未验证

(5) 矩阵存储方式错误

总结


前言

        在算法世界中,有一类算法看似 “简单粗暴”,却能在很多场景下快速解决问题 —— 这就是枚举算法。它不依赖复杂的数学推导或数据结构优化,而是通过 “穷尽所有可能情况,筛选出符合条件的解” 的思路,直接找到问题的答案。

        很多初学者会认为枚举是 “低效”“暴力” 的代名词,甚至不屑于使用。但实际上,枚举算法是解决问题的 “第一思路”:当问题规模较小时,枚举能以最简单的逻辑快速得到结果;当问题复杂时,枚举也能作为基础框架,通过优化逐步提升效率。在算法竞赛中,枚举更是 “签到题” 和 “中等题” 的常用解法,掌握枚举的思路和优化技巧,能帮你快速拿下基础分。

        本文将从枚举算法的本质出发,详细讲解枚举的核心思想、常见类型(普通枚举、二进制枚举)、解题步骤、优化技巧及实战案例。全文采用 C++ 实现,兼容 ACM 竞赛提交格式,同时包含大量实例、易错点分析和练习建议,无论你是编程新手还是竞赛选手,都能彻底掌握枚举算法的应用。下面就让我们正式开始吧!


一、枚举算法的本质与核心思想

1.1 什么是枚举算法?

        枚举算法(Enumeration Algorithm),又称 “穷举算法”,是指将问题的所有可能解逐一列举出来,检查每个解是否符合问题的条件,最终筛选出所有有效解的算法。它的核心逻辑可以概括为:“遍历所有可能,验证条件是否成立”。

        例如:

  • 求 1~100 中所有能被 7 整除的数:枚举 1 到 100 的每个数,判断是否满足 “%7==0”;
  • 找出数组中所有和为 10 的两个数:枚举所有可能的数对,判断和是否为 10;
  • 铺地毯问题中找到覆盖目标点的最上面地毯:枚举所有地毯,判断是否覆盖目标点,记录最后一个符合条件的地毯编号。

        这些问题的共同特点是:可能解的范围明确,且验证条件简单。只要能明确 “枚举什么” 和 “如何验证”,就能用枚举算法解决。

1.2 枚举算法的核心要素

        要写出高效、正确的枚举代码,必须把握以下 3 个核心要素,缺一不可:

  1. 明确枚举对象:确定要遍历的 “可能解” 是什么(如数字、数对、地毯、日期等);
  2. 确定枚举范围:划定可能解的边界,避免遗漏或多余遍历(如 1~100、0~2ⁿ-1);
  3. 设计验证条件:判断当前枚举的可能解是否符合问题要求(如是否被 7 整除、是否覆盖目标点)。

1.3 枚举算法的适用场景

        枚举算法并非 “万能算法”,它有明确的适用场景,主要包括:

  • 问题规模较小:可能解的数量在 10⁵~10⁶以内,遍历不会超时;
  • 可能解范围明确:能清晰界定枚举的边界(如日期范围、数组下标范围等);
  • 验证条件简单:判断一个可能解是否有效时,时间复杂度低;
  • 编程竞赛中的基础题:作为 “签到题” 或 “中等题” 的解法,快速拿分。

1.4 枚举算法的优缺点

        优点

  1. 逻辑简单:无需复杂的算法设计,直接按 “列举 - 验证” 的思路编写,易于理解和实现;
  2. 正确性高:只要枚举范围不遗漏,验证条件正确,就能找到所有有效解,无逻辑漏洞;
  3. 调试方便:每一步枚举的过程都可跟踪,出现错误时能快速定位问题。

        缺点

  1. 时间复杂度高:最坏情况下需要遍历所有可能解,时间复杂度通常为 O (k)(k 为可能解的数量),当 k 较大时(如 1e7 以上)会超时;
  2. 空间复杂度高:若需存储所有可能解,空间复杂度会随 k 增长而增加。

1.5 枚举算法的通用解题步骤

        无论面对何种枚举问题,都可以按照以下 4 个步骤编写代码,确保逻辑清晰、无遗漏:

  1. 分析问题,明确枚举对象:回答 “要枚举什么?”(如枚举地毯、枚举日期、枚举二进制状态);
  2. 划定枚举范围,确定遍历顺序:回答 “从哪里枚举到哪里?按什么顺序枚举?”(如正序、逆序、二进制位序);
  3. 设计验证条件,筛选有效解:回答 “如何判断当前枚举的解是否符合要求?”(如覆盖判断、回文判断);
  4. 处理结果,输出答案:回答 “如何记录有效解?是否需要去重、排序或取最优解?”(如记录最后一个符合条件的解、统计有效解的数量)。

二、普通枚举:逐一枚举,筛选有效解

        普通枚举是枚举算法的基础形式,指按顺序逐一枚举可能解,验证条件后筛选出有效解。它的核心是 “顺序遍历 + 条件判断”,适用于可能解范围连续、有序的场景。

2.1 案例 1:铺地毯(洛谷 P1003 [NOIP2011 提高组])

2.1.1 题目分析

        题目描述

        为了准备颁奖典礼,组织者在矩形区域(第一象限)铺设 n 张地毯,编号从 1 到 n,按编号从小到大顺序铺设(后铺的地毯覆盖前铺的)。每张地毯的信息包括左下角坐标 (a, b) 和 x 轴、y 轴方向的长度 (g, k)。要求找到覆盖目标点 (x, y) 的最上面的地毯编号,若未被覆盖则输出 - 1。

        输入

  • 第一行:整数 n(地毯数量);
  • 接下来 n 行:第 i+1 行包含 4 个整数 a_i, b_i, g_i, k_i(第 i 张地毯的左下角坐标和长度);
  • 最后一行:两个整数 x, y(目标点坐标)。

        输出

        覆盖目标点的最上面地毯编号,或 - 1。

        示例输入

3
1 0 2 3  # 地毯1:左下角(1,0),x长2(右到3),y长3(上到3)
0 2 3 3  # 地毯2:左下角(0,2),x长3(右到3),y长3(上到5)
2 1 3 3  # 地毯3:左下角(2,1),x长3(右到5),y长3(上到4)
2 2      # 目标点(2,2)

        示例输出

3

        核心难点

  • 地毯是按顺序铺设的,后铺的覆盖前铺的,需找到 “最后一个覆盖目标点的地毯”;
  • 判断地毯是否覆盖目标点:目标点 (x,y) 需满足 “a ≤ x ≤ a+g 且 b ≤ y ≤ b+k”(左下角到右上角的矩形区域)。

        题目链接:https://www.luogu.com.cn/problem/P1003

2.1.2 解题思路

  1. 明确枚举对象:枚举所有地毯(编号 1~n);
  2. 确定枚举范围与顺序
    • 范围:1~n(所有地毯);
    • 顺序:逆序枚举(从 n 到 1),因为后铺的地毯编号更大,一旦找到覆盖目标点的地毯,就是最上面的,可直接返回,无需继续枚举;
  3. 设计验证条件:判断目标点 (x,y) 是否在当前地毯的矩形区域内(a ≤ x ≤ a+g 且 b ≤ y ≤ b+k);
  4. 处理结果:若找到符合条件的地毯,立即返回编号;枚举结束未找到,返回 - 1。

2.1.3 完整代码实现(ACM 模式)

#include <iostream>
using namespace std;const int N = 1e4 + 10;  // 地毯数量最大为1e4
int a[N], b[N], g[N], k[N];  // 存储每张地毯的信息:a[i]左下角x,b[i]左下角y,g[i]x长,k[i]y长// 查找覆盖(x,y)的最上面地毯编号
int findCarpet(int n, int x, int y) {// 逆序枚举:从最后一张地毯开始,找到第一个覆盖的就是最上面的for (int i = n; i >= 1; --i) {// 验证条件:x在[a_i, a_i+g_i],y在[b_i, b_i+k_i]if (a[i] <= x && x <= a[i] + g[i] && b[i] <= y && y <= b[i] + k[i]) {return i;  // 找到,立即返回}}return -1;  // 未找到
}int main() {int n;cin >> n;for (int i = 1; i <= n; ++i) {cin >> a[i] >> b[i] >> g[i] >> k[i];}int x, y;cin >> x >> y;cout << findCarpet(n, x, y) << endl;return 0;
}

2.1.4 代码解析

  • 存储设计:用 4 个数组分别存储每张地毯的左下角坐标和长度,下标对应地毯编号(1~n),便于直接访问;
  • 逆序枚举优化:相比正序枚举(需遍历所有地毯才能确定最后一个符合条件的),逆序枚举找到第一个符合条件的即可返回,时间复杂度从 O (n) 优化为 “最好 O (1),最坏 O (n)”;
  • 验证条件:严格按照矩形区域的定义判断,包含边界(题目明确了“边界和顶点上的点也算被覆盖”)。

2.1.5 易错点分析

  1. 顺序错误:正序枚举后未记录最后一个符合条件的解,直接返回第一个,导致答案错误;
  2. 边界判断错误:将 “x ≤ a+g” 写成 “x < a+g”,遗漏边界点(如地毯右上角点);
  3. 数组下标错误:地毯编号从 1 开始,但数组从 0 开始存储,导致访问错误(代码中数组下标从 1 开始,与地毯编号一致,避免此问题)。

2.2 案例 2:回文日期(洛谷 P2010 [NOIP2016 普及组])

2.2.1 题目分析

题目描述

        用 8 位数字表示日期(YYYYMMDD),其中前 4 位是年份,中间 2 位是月份,最后 2 位是日期。一个日期是回文的,当且仅当 8 位数字从左到右读和从右到左读完全相同(如 20100102,即 2010 年 1 月 2 日)。给定两个 8 位日期 date1 和 date2,求两者之间(包含边界)的回文日期数量。

输入

  • 两行,每行一个 8 位整数(date1 ≤ date2,且均为有效日期)。

输出

        回文日期的数量。

示例输入 1

20110101
20111231

示例输出 1

1  # 只有20111102(2011年11月2日)是回文日期

示例输入 2

20000101
20101231

示例输出 2

2  # 20011002(2001年10月2日)和20100102(2010年1月2日)

核心难点

  • 直接枚举所有日期(date1 到 date2)会超时(若 date2-date1 达到 1e7,遍历耗时过长);
  • 需验证日期的有效性(如月份 1~12,日期根据月份判断:2 月闰年 29 天,平年 28 天,4/6/9/11 月 30 天,其余 31 天);
  • 回文日期的 8 位数字需满足 “第 i 位 = 第 9-i 位”(如第 1 位 = 第 8 位,第 2 位 = 第 7 位,…,第 4 位 = 第 5 位)。

题目链接:https://www.luogu.com.cn/problem/P2010

2.2.2 解题思路

  1. 优化枚举对象
    • 直接枚举日期(date1 到 date2)效率低,可利用回文特性 “构造回文日期”:8 位回文日期的前 4 位(年份)决定后 4 位(月份 + 日期),即 “YYYY” → “YYYY” + “反转 (YYYY 的前 4 位)” 的后 4 位?不,8 位回文的结构是 “ABCCBA”?不,8 位回文是 “ABCDEFGH” 满足 A=H,B=G,C=F,D=E,即前 4 位决定后 4 位:后 4 位是前 4 位的反转(如前 4 位 2010 → 后 4 位 0102,组成 20100102);
    • 因此,枚举对象可改为 “前 4 位年份(YYYY)”,构造出 8 位回文日期,再验证:
      1. 构造规则:8 位回文日期 = YYYY * 10000 + reverse (YYYY)(如 YYYY=2010 → reverse=0102 → 20100102);
      2. 验证条件 1:构造的日期是否在 [date1, date2] 范围内;
      3. 验证条件 2:构造的日期是否为有效日期(月份、日期合法);
  2. 确定枚举范围
    • 年份范围:从 date1 的前 4 位(date1//10000)到 date2 的前 4 位(date2//10000);
    • 例如 date1=20110101,date2=20111231 → 年份范围 2011~2011,只需枚举 2011 一个年份;
  3. 设计验证条件
    • 回文构造:通过年份构造 8 位日期;
    • 范围验证:构造的日期是否在 [date1, date2];
    • 有效性验证:拆分出月份(mm=date//100%100)和日期(dd=date%100),判断:
      • 月份 mm∈[1,12];
      • 日期 dd∈[1, 当月最大天数](需处理闰年 2 月);
  4. 处理结果:统计所有符合条件的回文日期数量。

2.2.3 关键辅助函数

1. 反转数字(如 2010 → 0102)
// 反转数字:如num=2010 → 返回102(注意:2010反转后是0102,即102,后续需补前导零到4位)
int reverseNum(int num) {int res = 0;while (num > 0) {res = res * 10 + num % 10;num /= 10;}return res;
}
2. 判断闰年(用于确定 2 月天数)
// 判断是否为闰年:能被4整除且不能被100整除,或能被400整除
bool isLeapYear(int year) {return (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0);
}
3. 验证日期有效性
// 验证日期是否有效:year-年份,mm-月份,dd-日期
bool isValidDate(int year, int mm, int dd) {// 月份不合法if (mm < 1 || mm > 12) return false;// 每月最大天数int maxDay[] = {0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};// 闰年2月多1天if (mm == 2 && isLeapYear(year)) maxDay[2] = 29;// 日期不合法return dd >= 1 && dd <= maxDay[mm];
}

2.2.4 完整代码实现(ACM 模式)

#include <iostream>
using namespace std;// 反转数字:如2010 → 102(后续补前导零到4位)
int reverseNum(int num) {int res = 0;while (num > 0) {res = res * 10 + num % 10;num /= 10;}return res;
}// 判断是否为闰年
bool isLeapYear(int year) {return (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0);
}// 验证日期有效性
bool isValidDate(int year, int mm, int dd) {if (mm < 1 || mm > 12) return false;int maxDay[] = {0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};if (mm == 2 && isLeapYear(year)) maxDay[2] = 29;return dd >= 1 && dd <= maxDay[mm];
}// 统计[date1, date2]之间的回文日期数量
int countPalindromeDate(int date1, int date2) {int count = 0;// 枚举年份:从date1的前4位到date2的前4位int startYear = date1 / 10000;int endYear = date2 / 10000;for (int year = startYear; year <= endYear; ++year) {// 构造回文日期:YYYY * 10000 + 反转(YYYY)(补前导零到4位)int reversed = reverseNum(year);int palindromeDate = year * 10000 + reversed;// 验证1:是否在范围内if (palindromeDate < date1 || palindromeDate > date2) {continue;}// 验证2:是否为有效日期int mm = palindromeDate / 100 % 100;  // 月份(第5-6位)int dd = palindromeDate % 100;       // 日期(第7-8位)if (isValidDate(year, mm, dd)) {count++;}}return count;
}int main() {int date1, date2;cin >> date1 >> date2;cout << countPalindromeDate(date1, date2) << endl;return 0;
}

2.2.5 代码解析

  • 枚举优化:通过 “年份构造回文日期”,将枚举范围从 “可能的日期数” 缩减到 “年份数”(如 date1 到 date2 跨度 10 年,仅需枚举 10 个年份),效率大幅提升;
  • 回文构造:利用 8 位回文的特性,前 4 位年份决定后 4 位,避免直接判断 8 位数字是否回文(减少计算量);
  • 有效性验证:拆分月份和日期,结合闰年判断,确保构造的日期是真实存在的(如避免 20230230 这种无效日期)。

2.2.6 易错点分析

  1. 回文构造错误:将 “YYYY 反转” 直接作为后 4 位,但未补前导零(如 year=2000 → reversed=0 → 构造日期 20000000,而非 20000002);
  2. 闰年判断错误:遗漏 “能被 400 整除” 的条件(如 2000 年是闰年,1900 年不是);
  3. 范围验证顺序错误:先验证日期有效性,再判断是否在 [date1, date2] 范围内,导致无效日期也参与范围判断,浪费时间;
  4. 月份 / 日期拆分错误:将 mm 拆分为 “palindromeDate%10000/100”(正确),但代码中写成 “palindromeDate//100%100”(也是正确的,两种写法等价),需注意拆分逻辑。

2.3 案例 3:扫雷(洛谷 P2327 [SCOI2005])

2.3.1 题目分析

题目描述

        扫雷游戏的棋盘是 n×2 的矩阵,第一列有若干地雷,第二列没有地雷。第二列的每个格子中的数字表示 “与该格子连通的 8 个格子中地雷的数量”(但由于是 n×2 矩阵,实际连通的是上下左右及斜对角的第一列格子)。给定第二列的数字,求第一列地雷的摆放方案数(地雷用 1 表示,无地雷用 0 表示)。

输入

  • 第一行:整数 n(矩阵行数,1≤n≤1e4);
  • 第二行:n 个整数,依次为第二列格子的数字(b [1]~b [n])。

输出

        第一列地雷的摆放方案数(0、1 或 2)。

示例输入

2
1 1

示例输出

2  # 方案1:第一列[0,1];方案2:第一列[1,0]

核心难点

  • 第二列的数字由第一列相邻格子的地雷数量决定,需找到所有满足条件的第一列 01 序列;
  • 第一列的地雷摆放具有 “连锁性”:一旦确定第一行的状态(有雷 / 无雷),后续所有行的状态都能通过第二列的数字推导出来;
  • 需验证推导的状态是否合法(地雷状态只能是 0 或 1),且最后一行的状态需满足边界条件。

题目链接:https://www.luogu.com.cn/problem/P2327

2.3.2 解题思路

  1. 明确枚举对象:枚举第一列第一行的状态(只有两种可能:0 或 1);
  2. 确定枚举范围与顺序
    • 范围:仅两种可能(0 或 1);
    • 顺序:分别枚举两种状态,独立验证是否能推导出合法的完整序列;
  3. 设计验证条件
    • 推导规则:对于第 i 行(2≤i≤n),第一列的状态 a [i] = b [i-1] - a [i-1] - a [i-2](因为第二列第 i-1 行的数字 b [i-1] = a [i-2] + a [i-1] + a [i],整理得 a [i] = b [i-1] - a [i-1] - a [i-2]);
    • 合法性验证:推导的 a [i] 必须是 0 或 1,且最后一行的状态需满足 b [n] = a [n-1] + a [n](第二列最后一行的数字由第一列最后两行的地雷数量决定);
  4. 处理结果:统计两种枚举状态中,能推导出合法序列的数量。

2.3.3 完整代码实现(ACM 模式)

#include <iostream>
using namespace std;const int N = 1e4 + 10;
int a[N];  // 第一列地雷状态:a[1]~a[n]
int b[N];  // 第二列的数字:b[1]~b[n]
int n;// 验证第一行状态为first(0或1)时,是否存在合法方案
bool check(int first) {a[1] = first;// 推导第2~n行的状态for (int i = 2; i <= n; ++i) {// 公式:a[i] = b[i-1] - a[i-1] - a[i-2](a[0]默认0,因为第一行上方无格子)a[i] = b[i-1] - a[i-1] - (i >= 2 ? a[i-2] : 0);// 状态必须是0或1,否则不合法if (a[i] < 0 || a[i] > 1) {return false;}}// 验证最后一行:b[n]必须等于a[n-1] + a[n](a[n+1]默认0)return b[n] == a[n-1] + a[n];
}int main() {cin >> n;for (int i = 1; i <= n; ++i) {cin >> b[i];}// 枚举第一行的两种可能状态,统计合法方案数int count = 0;count += check(0);  // 第一行无地雷count += check(1);  // 第一行有地雷cout << count << endl;return 0;
}

2.3.4 代码解析

  • 枚举简化:仅枚举第一行的两种状态,而非所有 n 行的 2ⁿ种可能,时间复杂度从 O (2ⁿ) 骤降为 O (n)(n≤1e4,完全可行);
  • 推导逻辑:利用第二列数字与第一列相邻状态的关系,通过递推公式推导后续状态,避免暴力枚举;
  • 边界处理:第一行上方无格子(a [0]=0),最后一行下方无格子(a [n+1]=0),确保推导和验证的完整性。

2.3.5 易错点分析

  1. 边界条件遗漏:推导 a [2] 时未考虑 a [0]=0(第一行上方无格子),导致公式错误;
  2. 状态合法性判断延迟:未在推导 a [i] 后立即判断是否为 0 或 1,而是等到最后验证,导致无效状态继续推导,浪费时间;
  3. 最后一行验证错误:将验证条件写成 “b [n] == a [n]”(忽略 a [n-1]),导致判断错误。

三、二进制枚举:用位表示状态,枚举所有子集

        二进制枚举是枚举算法的进阶形式,指用一个整数的二进制位表示 “是否选择某个元素”,通过遍历所有可能的整数,枚举所有子集或组合情况。它的核心是 “位运算 + 状态映射”,适用于 “元素是否被选择” 的二选一场景。

3.1 二进制枚举的核心原理

3.1.1 状态映射

        假设有 n 个元素(编号 0~n-1),用一个 n 位的二进制数表示一个子集:

  • 二进制数的第 i 位(从 0 开始,右数第 i+1 位)为 1,表示选择第 i 个元素;
  • 二进制数的第 i 位为 0,表示不选择第 i 个元素。

        例如:

  • n=3,元素为 [1,2,3];
  • 二进制数 011(十进制 3)→ 第 0 位和第 1 位为 1 → 子集 [1,2];
  • 二进制数 101(十进制 5)→ 第 0 位和第 2 位为 1 → 子集 [1,3];
  • 二进制数 111(十进制 7)→ 所有位为 1 → 子集 [1,2,3]。

3.1.2 枚举范围

        对于 n 个元素,二进制数的范围是 0~2ⁿ-1(共 2ⁿ种可能,对应所有子集,包括空集):

  • 0 → 二进制 000...0 → 空集;
  • 2ⁿ-1 → 二进制 111...1 → 全集。

3.1.3 位运算操作

        在二进制枚举中,常用的位运算操作包括:

  1. 判断第 i 位是否为 1(st >> i) & 1(st 为当前二进制数,将 st 右移 i 位,与 1 按位与,结果为 1 表示第 i 位是 1);
  2. 遍历所有位:循环 i 从 0 到 n-1,检查每一位的状态;
  3. 统计 1 的个数:计算当前子集的大小(如__builtin_popcount(st),C++ 内置函数,统计 st 的二进制中 1 的个数)。

3.2 案例 1:子集(力扣 78. 子集)

3.2.1 题目分析

题目描述

        给你一个整数数组 nums(元素互不相同),返回该数组所有可能的子集(幂集)。解集不能包含重复的子集,可以按任意顺序返回。

输入

  • 一行,整数数组 nums(如 [1,2,3])。

输出

  • 所有子集,如 [[],[1],[2],[1,2],[3],[1,3],[2,3],[1,2,3]]。

核心难点

  • 枚举所有子集,包括空集和全集;
  • 确保子集不重复(由于元素互不相同,二进制枚举天然不重复)。

题目链接:https://leetcode.cn/problems/subsets/description/

3.2.2 解题思路

  1. 明确枚举对象:枚举二进制数 st(0~2ⁿ-1,n 为数组长度);
  2. 确定枚举范围:st 从 0 到 (1<<n)-1(1<<n 表示 2ⁿ);
  3. 设计状态映射与验证
    • 映射规则:st 的第 i 位为 1 → 选择 nums [i] 加入当前子集;
    • 验证:无需额外验证(所有子集均有效);
  4. 处理结果:对每个 st,生成对应的子集,加入结果列表。

3.2.3 完整代码实现(核心代码模式)

#include <vector>
using namespace std;class Solution {
public:vector<vector<int>> subsets(vector<int>& nums) {vector<vector<int>> result;int n = nums.size();// 枚举所有二进制状态:0 ~ 2^n - 1for (int st = 0; st < (1 << n); ++st) {vector<int> currentSubset;// 检查每一位是否为1,若是则加入子集for (int i = 0; i < n; ++i) {if ((st >> i) & 1) {currentSubset.push_back(nums[i]);}}result.push_back(currentSubset);}return result;}
};

3.2.4 代码解析

  • 二进制状态遍历for (int st = 0; st < (1 << n); ++st) 遍历所有 2ⁿ种状态,对应所有子集;
  • 位运算判断(st >> i) & 1 检查第 i 位是否为 1,若为 1 则将 nums [i] 加入当前子集;
  • 结果收集:每个 st 对应一个子集,直接加入结果列表,无需去重(元素互不相同,状态唯一)。

3.2.5 易错点分析

  1. 枚举范围错误:将(1 << n)写成(1 << n) - 1,导致遗漏全集(st=2ⁿ-1);
  2. 位运算顺序错误:将(st >> i) & 1写成st >> (i & 1),逻辑错误;
  3. 数组下标错误:i 从 1 开始遍历,而非 0,导致遗漏第一个元素。

3.3 案例 2:费解的开关(洛谷 P10449)

3.3.1 题目分析

题目描述

        有一个 5×5 的灯阵,每个灯有 “开(1)” 和 “关(0)” 两种状态。按下一个灯的开关,会使该灯及其上下左右相邻的灯状态反转(1 变 0,0 变 1)。给定初始状态,求最少需要按多少次开关才能使所有灯变亮(全 1),若超过 6 次则输出 - 1。

输入

  • 第一行:整数 n(测试用例数,n≤500);
  • 接下来 n 组数据:每组 5 行,每行 5 个字符('0' 或 '1'),表示灯阵的初始状态。

输出

  • 每组数据输出最少按开关次数,或 - 1。

示例输入

3
00111
01011
10001
11010
11100
11101
11101
11110
11111
11111
01111
11111
11111
11111
11111

示例输出

3
2
-1

核心难点

  • 灯的状态反转具有 “连锁性”,按下一个灯影响多个灯;
  • 直接枚举所有灯的按法(2²⁵种)会超时(2²⁵≈3.3e7,n=500 时总操作量过大);
  • 需找到最优解(最少按次数),且超过 6 次则无效。

题目链接:https://www.luogu.com.cn/problem/P10449

3.3.2 解题思路

  1. 优化枚举对象
    • 第一行的按法决定后续所有行的按法:要使第一行的灯全亮,第二行的开关必须按 “第一行未亮的灯正下方的灯”(因为第一行的灯只能被第二行的开关影响,第一行自身的开关已确定);
    • 因此,枚举对象仅为 “第一行的按法”(5 个灯,共 2⁵=32 种可能),而非所有 25 个灯;
  2. 确定枚举范围:第一行的按法(0~31,对应 32 种状态);
  3. 设计验证与计算步骤
    1. 备份初始状态:每次枚举第一行按法前,备份原始灯阵(避免修改原始数据);
    2. 模拟第一行按法:根据当前按法,反转第一行对应灯及其相邻灯的状态;
    3. 推导后续行按法
      • 对于第 i 行(2~5),遍历每一列 j:若第 i-1 行第 j 列的灯是灭的(0),则必须按第 i 行第 j 列的开关(反转该灯及其相邻灯);
    4. 统计按次数:记录总按次数,若超过 6 次则跳过;
    5. 验证结果:检查最后一行的灯是否全亮,若全亮则更新最少按次数;
  4. 处理结果:每组测试用例取最少按次数,若未找到则输出 - 1。

3.3.3 关键辅助函数

1. 反转灯的状态(按开关)
// 反转(x,y)位置的灯及其相邻灯的状态(x,y从0开始,5×5矩阵)
void flip(int x, int y, vector<vector<int>>& grid) {// 定义当前灯及上下左右的坐标偏移int dx[] = {0, 0, 0, 1, -1};int dy[] = {0, 1, -1, 0, 0};for (int d = 0; d < 5; ++d) {int nx = x + dx[d];int ny = y + dy[d];// 确保坐标在5×5范围内if (nx >= 0 && nx < 5 && ny >= 0 && ny < 5) {grid[nx][ny] ^= 1;  // 异或1反转状态(0→1,1→0)}}
}

3.3.4 完整代码实现(ACM 模式)

#include <iostream>
#include <vector>
#include <cstring>
#include <climits>
using namespace std;// 反转(x,y)及其相邻灯的状态
void flip(int x, int y, vector<vector<int>>& grid) {int dx[] = {0, 0, 0, 1, -1};int dy[] = {0, 1, -1, 0, 0};for (int d = 0; d < 5; ++d) {int nx = x + dx[d];int ny = y + dy[d];if (nx >= 0 && nx < 5 && ny >= 0 && ny < 5) {grid[nx][ny] ^= 1;}}
}// 计算初始状态grid下的最少按次数
int minPresses(vector<vector<int>>& original) {int minCnt = INT_MAX;// 枚举第一行的所有按法(0~31,32种可能)for (int st = 0; st < (1 << 5); ++st) {vector<vector<int>> grid = original;  // 备份初始状态int cnt = 0;  // 记录当前按次数// 1. 模拟第一行的按法for (int j = 0; j < 5; ++j) {if ((st >> j) & 1) {  // 第j列需要按flip(0, j, grid);cnt++;if (cnt > 6) break;  // 超过6次,无需继续}}if (cnt > 6) continue;// 2. 推导第2~5行的按法for (int i = 1; i < 5; ++i) {for (int j = 0; j < 5; ++j) {// 若上一行第j列是灭的,必须按当前行第j列的开关if (grid[i-1][j] == 0) {flip(i, j, grid);cnt++;if (cnt > 6) break;}}if (cnt > 6) break;}if (cnt > 6) continue;// 3. 验证最后一行是否全亮bool allOn = true;for (int j = 0; j < 5; ++j) {if (grid[4][j] == 0) {allOn = false;break;}}if (allOn && cnt < minCnt) {minCnt = cnt;}}// 若未找到有效方案,返回-1return minCnt == INT_MAX ? -1 : minCnt;
}int main() {int n;cin >> n;while (n--) {vector<vector<int>> original(5, vector<int>(5));// 读取初始状态:'0'→0(灭),'1'→1(亮)for (int i = 0; i < 5; ++i) {string s;cin >> s;for (int j = 0; j < 5; ++j) {original[i][j] = s[j] - '0';}}// 计算并输出最少按次数int res = minPresses(original);cout << res << endl;}return 0;
}
3.3.5 代码解析
  • 枚举优化:仅枚举第一行的 32 种按法,后续行按法由前一行状态推导,时间复杂度从 O (2²⁵) 骤降为 O (32×5×5) = O (800),每组测试用例耗时极短;
  • 状态备份:每次枚举前备份原始灯阵,避免修改原始数据(多组测试用例或多次枚举需独立状态);
  • 剪枝优化:按次数超过 6 次时立即跳过,避免无效计算;
  • 结果验证:最后检查第五行是否全亮,确保方案有效。

3.3.6 易错点分析

  1. 状态未备份:直接修改原始灯阵,导致后续枚举使用的状态错误;
  2. 反转范围错误:flip 函数中遗漏 “当前灯自身”(dx [0]=0, dy [0]=0),导致状态反转不完整;
  3. 剪枝不及时:未在按次数超过 6 次时立即跳过,导致无效计算;
  4. 最后一行验证错误:检查前 4 行是否全亮,而非最后一行,导致判断错误。

3.3 案例3:Even Parity(UVA11464)(重点)

3.3.1 题目描述

题目核心要求

        给定一个 n×n 的 01 矩阵(每个元素非 0 即 1),你可以将某些 0 修改为 1(不允许 1 修改为 0),使得最终矩阵成为 “偶数矩阵”。偶数矩阵的定义是:每个元素的上下左右相邻元素(若存在)之和为偶数。请计算最少的修改次数,若无法实现则输出 - 1。

输入格式

  • 第一行:数据组数 T(T≤30);
  • 每组数据:
    • 第一行:正整数 n(1≤n≤15);
    • 接下来 n 行:每行 n 个非 0 即 1 的整数,代表初始矩阵。

输出格式

  • 每组数据输出 “Case X: 最少修改次数”,若无解则输出 “Case X: -1”。

示例输入

3
3
0 0 0
0 0 0
0 0 0
3
0 0 0
1 0 0
0 0 0
3
1 1 1
1 1 1
0 0 0

示例输出

Case 1: 0
Case 2: 3
Case 3: -1

题目链接:https://www.luogu.com.cn/problem/UVA11464

3.3.2 题目关键分析

(1)偶数矩阵的核心性质

        对于矩阵中的任意元素a[i][j](行号 i、列号 j,从 1 开始),其相邻元素之和为偶数,即:

  • 若 i=1 且 j=1(左上角,仅右下相邻):a[i+1][j] + a[i][j+1] 为偶数;
  • 若 i=n 且 j=n(右下角,仅左上相邻):a[i-1][j] + a[i][j-1] 为偶数;
  • 普通位置(上下左右均存在):a[i-1][j] + a[i+1][j] + a[i][j-1] + a[i][j+1] 为偶数;

        但通过推导可发现:矩阵的第一行状态确定后,后续所有行的状态均可唯一推导。原因如下:

        对于第 i 行第 j 列的元素a[i][j](i≥2),其上方元素a[i-1][j]的相邻元素之和需为偶数。a[i-1][j]的相邻元素包括a[i-2][j](上方,若存在)、a[i][j](下方)、a[i-1][j-1](左方)、a[i-1][j+1](右方)。整理可得推导公式:a[i][j] = a[i-2][j] ^ a[i-1][j-1] ^ a[i-1][j+1](“^” 为异或运算,异或结果为 0 表示和为偶数,1 表示和为奇数,符合偶数矩阵要求)。

(2) 为什么用二进制枚举?

        n 的最大值为 15,第一行有 n 个元素,每个元素有 “保持原状态” 或 “修改为 1” 两种可能(但需满足 “不允许 1 变 0”)。因此第一行的可能状态数为 2ⁿ,当 n=15 时为 32768 种,完全在枚举范围内(不会超时)。

(3) 修改次数的计算

        修改次数仅统计 “0 变 1” 的次数:对于每个位置,若初始状态为 0 且最终状态为 1,计数 + 1;若初始状态为 1 且最终状态为 0,属于非法操作(直接排除该方案)。

3.3.3 解题思路详解

(1) 核心步骤
  1. 枚举对象:第一行的所有可能状态(用二进制数表示,第 j 位为 1 表示第一行第 j 列最终状态为 1);
  2. 枚举范围:二进制数从 0 到 (1<<n)-1(共 2ⁿ种状态);
  3. 状态合法性验证
    • 第一步:检查第一行状态是否合法(仅允许 0 变 1,即初始为 1 的位置,最终状态不能为 0);
    • 第二步:根据第一行状态,用推导公式计算后续所有行的状态,每一步检查合法性(初始为 1 的位置不能变为 0);
  4. 修改次数计算:统计所有 “0 变 1” 的次数;
  5. 结果筛选:在所有合法方案中,选择修改次数最少的,若无合法方案则输出 - 1。
(2) 关键推导公式推导

        以第 i 行第 j 列(i≥2,1≤j≤n)为例:

  • 偶数矩阵要求a[i-1][j]的上下左右相邻元素之和为偶数;
  • 相邻元素包括:上方a[i-2][j](若 i≥3)、下方a[i][j]、左方a[i-1][j-1](若 j≥2)、右方a[i-1][j+1](若 j≤n-1);
  • 当 i=2 时,a[i-2][j]不存在(视为 0);当 j=1 时,a[i-1][j-1]不存在(视为 0);当 j=n 时,a[i-1][j+1]不存在(视为 0);
  • 异或运算特性:“偶数个 1 异或为 0,奇数个 1 异或为 1”,恰好对应 “和为偶数 / 奇数”;
  • 最终推导公式:a[i][j] = (i >= 3 ? a[i-2][j] : 0) ^ (j >= 2 ? a[i-1][j-1] : 0) ^ (j <= n-1 ? a[i-1][j+1] : 0)

3.3.4 完整代码实现(ACM 模式)

#include <iostream>
#include <cstring>
#include <climits>
using namespace std;const int N = 20;  // n最大为15,预留冗余
int n;
int initial[N][N];  // 初始矩阵(1-based)
int current[N][N];  // 当前枚举的最终矩阵(1-based)// 检查并计算:第一行状态为st时的修改次数,非法返回-1
int check(int st) {memset(current, 0, sizeof current);int modify = 0;  // 修改次数(0变1的次数)// 1. 处理第一行,验证合法性并计算修改次数for (int j = 1; j <= n; ++j) {// 提取st的第j位(注意:st的第0位对应j=1,第1位对应j=2,...)int bit = (st >> (j - 1)) & 1;current[1][j] = bit;// 合法性检查:初始为1,最终为0 → 非法if (initial[1][j] == 1 && bit == 0) {return -1;}// 计算修改次数:初始为0,最终为1 → +1if (initial[1][j] == 0 && bit == 1) {modify++;}}// 2. 推导第2~n行的状态,验证合法性并计算修改次数for (int i = 2; i <= n; ++i) {for (int j = 1; j <= n; ++j) {// 推导公式:current[i][j] = 上上行[j] ^ 上一行[j-1] ^ 上一行[j+1]int up_up = (i >= 3) ? current[i-2][j] : 0;  // 上上行(i-2),不存在为0int up_left = (j >= 2) ? current[i-1][j-1] : 0;  // 上一行左列(j-1),不存在为0int up_right = (j <= n-1) ? current[i-1][j+1] : 0;  // 上一行右列(j+1),不存在为0current[i][j] = up_up ^ up_left ^ up_right;// 合法性检查:初始为1,最终为0 → 非法if (initial[i][j] == 1 && current[i][j] == 0) {return -1;}// 计算修改次数:初始为0,最终为1 → +1if (initial[i][j] == 0 && current[i][j] == 1) {modify++;}}}// 3. 额外验证:最后一行的每个元素是否满足偶数矩阵要求(避免推导遗漏)for (int j = 1; j <= n; ++j) {int sum = 0;// 最后一行的元素,仅需检查上方、左方、右方(无下方)if (n >= 2) sum += current[n-1][j];  // 上方if (j >= 2) sum += current[n][j-1];  // 左方if (j <= n-1) sum += current[n][j+1];  // 右方if (sum % 2 != 0) {  // 之和为奇数,不满足偶数矩阵return -1;}}return modify;  // 返回合法方案的修改次数
}int main() {int T;cin >> T;for (int case_num = 1; case_num <= T; ++case_num) {cin >> n;// 读取初始矩阵(1-based存储,方便后续推导)for (int i = 1; i <= n; ++i) {for (int j = 1; j <= n; ++j) {cin >> initial[i][j];}}int min_modify = INT_MAX;  // 最少修改次数// 枚举第一行的所有可能状态(0 ~ 2^n - 1)for (int st = 0; st < (1 << n); ++st) {int res = check(st);if (res != -1 && res < min_modify) {min_modify = res;}}// 输出结果if (min_modify == INT_MAX) {printf("Case %d: -1\n", case_num);} else {printf("Case %d: %d\n", case_num, min_modify);}}return 0;
}

3.3.5 代码解析

(1) 二进制状态映射(第一行)
  • 用整数st表示第一行的最终状态:st的第j-1位(从 0 开始)对应第一行第j列的状态(1 表示最终为 1,0 表示最终为 0);
  • 例如 n=3,st=5(二进制101)→ 第一行状态为[1,0,1](j=1 对应 bit0=1,j=2 对应 bit1=0,j=3 对应 bit2=1)。
(2) 合法性验证
  • 第一行验证:初始为 1 的位置,最终状态不能为 0(不允许 1 变 0);
  • 后续行验证:推导过程中,每一步都检查 “初始为 1→最终为 0” 的情况,一旦出现立即返回 - 1;
  • 最后一行额外验证:推导完成后,检查最后一行是否满足偶数矩阵要求(避免因推导公式的边界处理遗漏问题)。
(3) 修改次数计算
  • 仅统计 “初始为 0 且最终为 1” 的位置,每次满足条件时modify++
  • 合法方案的修改次数返回后,更新min_modify(初始为无穷大),最终取最小值。

3.3.6 易错点与避坑指南

(1) 状态映射错误
  • 问题:将st的第 j 位对应第一行第 j 列(而非第 j-1 位),导致第一行状态错位;
  • 解决:明确st的位序与列号的对应关系(bit0→j=1,bit1→j=2,...,bit (n-1)→j=n),提取位时用(st >> (j-1)) & 1
(2) 推导公式边界处理遗漏
  • 问题:忽略 “i=2 时无上行”“j=1 时无左列”“j=n 时无右列” 的情况,直接使用公式导致错误;
  • 解决:用三目运算符判断边界,不存在的相邻元素视为 0(如up_up = (i >= 3) ? current[i-2][j] : 0)。
(3) 合法性验证不完整
  • 问题:仅验证第一行,未验证后续行的 “1 变 0” 情况,导致非法方案被计入;
  • 解决:推导后续行时,每计算一个位置的状态,立即检查 “初始为 1→最终为 0”,若有则返回 - 1。
(4) 最后一行未验证
  • 问题:推导完成后未检查最后一行是否满足偶数矩阵要求,导致部分非法方案被误判为合法;
  • 解决:额外遍历最后一行,计算每个元素的相邻元素之和,确保为偶数。
(5) 矩阵存储方式错误
  • 问题:使用 0-based 存储(行号从 0 开始),导致推导公式中的行号计算混乱;
  • 解决:采用 1-based 存储(行号、列号从 1 开始),与推导公式中的 “i-1”“i-2” 对应更直观,减少边界错误。

总结

        枚举算法的关键不是 “暴力遍历”,而是 “聪明地枚举”—— 通过分析问题特性,减少枚举次数,提升验证效率。在实际应用中,枚举往往是解决问题的 “第一思路”:当问题规模较小时,枚举能快速得到结果;当问题复杂时,枚举也能作为基础框架,逐步优化。

        如果本文对你有帮助,欢迎点赞、收藏、转发,也欢迎在评论区交流讨论~

http://www.dtcms.com/a/580920.html

相关文章:

  • 【大模型学习3】预训练语言模型详解
  • 《Linux系统编程之开发工具》【实战:倒计时 + 进度条】
  • 【Frida Android】实战篇1:环境准备
  • 【2025 CVPR】EmoEdit: Evoking Emotions through Image Manipulation
  • 如何创建网站内容网站名称不能涉及
  • 编写微服务api
  • Flutter Transform.rotate 与动画控制器 实现旋转动画
  • Flutter进行命令打包各版本程序(2025.11)
  • 【基于 WangEditor v5 + Vue2 封装 CSDN 风格富文本组件】
  • 网站建设的重要性意义徐州建站公司模板
  • Scrapy源码剖析:下载器中间件是如何工作的?
  • vi 编辑器命令大全
  • AI 预测 + 物联网融合:档案馆温湿度监控系统发展新趋势
  • Vue JSON结构编辑器组件设计与实现解析
  • 14_FastMCP 2.x 中文文档之FastMCP高级功能:MCP中间件详解
  • 软考中级软件设计师(下午题)--- UML建模
  • 机械臂时间最优规划
  • 【LeetCode刷题】两数之和
  • 10 月热搜精选
  • 郑州商城网站开发摄影网站源码 国外
  • Docker 加载镜像时报 no space left on device 的彻底解决方案
  • 5、prometheus标签
  • python+django/flask基于机器学习的就业岗位推荐系统
  • Mysql作业5
  • 为什么Vue 3需要ref函数?它的响应式原理与正确用法是什么?
  • STM32外设学习--TIM定时器--输入捕获---测频方法(代码编写)
  • 如何设置JVM参数避开直接内存溢出的坑?
  • (七)嵌入式面试题收集:8道
  • AI搜索营销破局:光引GEO多平台适配与实时优化引擎开发详解
  • 【有源码】基于Hadoop+Spark的起点小说网大数据可视化分析系统-基于Python大数据生态的网络文学数据挖掘与可视化系统