算法基础篇 · 03 枚举(C++ 题解)

一句话思想:把所有情况全都罗列出来,然后找出符合题目要求的那一个------纯暴力,但往往是更优算法的起点。
适用场景:数据范围较小、可以直接遍历所有可能性的题目;也是学习二分、双指针、前缀和与差分等优化算法的基础。
⚠️ 一般情况下暴力枚举都会超时,要先根据数据范围判断是否能通过;不行就要用后续算法优化。
枚举策略三要素
使用枚举时,重点思考三件事:
- 枚举的对象:枚举什么?
- 枚举的顺序:正序还是逆序?(逆序有时可以提前找到答案,减少遍历)
- 枚举的方式:普通枚举?递归枚举?二进制枚举?
本章目录
| 题目 | 来源 | 难度 |
|---|---|---|
| 3.1.1 铺地毯 | 洛谷 P1003 | ★ |
| 3.1.2 回文日期 | 洛谷 P2010 | ★ |
| 3.1.3 扫雷 | 洛谷 P2327 | ★★ |
| 3.2.1 子集 | 力扣 | ★ |
| 3.2.2 费解的开关 | 洛谷 P10449 | ★★★ |
| 3.2.3 Even Parity | 洛谷 | ★★★ |
3.1 普通枚举
3.1.1 铺地毯
- 题目来源:洛谷
- 题目链接 :P1003 NOIP2011 提高组 铺地毯
- 难度系数:★
题目描述
为了准备一个独特的颁奖典礼,组织者在会场的一片矩形区域(可看做是平面直角坐标系的第一象限)铺上一些矩形地毯。一共有 n 张地毯,编号从 1 到 n。现在将这些地毯按照编号从小到大的顺序平行于坐标轴先后铺设,后铺的地毯覆盖在前面已经铺好的地毯之上。地毯铺设完成后,组织者想知道覆盖地面某个点的最上面的那张地毯的编号。
注意:在矩形地毯边界和四个顶点上的点也算被地毯覆盖。
输入描述
- 第一行,一个整数 n,表示总共有 n 张地毯。
- 接下来的 n 行中,第 i+1 行表示编号 i 的地毯的信息,包含四个正整数 a, b, g, k,每两个整数之间用一个空格隔开,分别表示铺设地毯的左下角的坐标 (a, b) 以及地毯在 x 轴和 y 轴方向的长度。
- 第 n+2 行包含两个正整数 x 和 y,表示所求的地面的点的坐标 (x, y)。
输出描述
输出共 1 行,一个整数,表示所求的地毯的编号;若此处没有被地毯覆盖则输出 -1。
示例
输入:
3
1 0 2 3
0 2 3 3
2 1 3 3
2 2
输出:
3
解题思路
枚举所有的地毯,判断哪一个地毯能够覆盖 (x, y) 这个位置。
优化枚举方式:
- 因为我们要的是最后一个 能够覆盖 (x, y) 位置的地毯,那么逆序枚举所有的地毯,第一次找到覆盖 (x, y) 位置的就是结果;
- 如果从前往后枚举,我们至少要把所有地毯都枚举完,才能知道最终结果。
覆盖判断:a[i] <= x && b[i] <= y && a[i] + g[i] >= x && b[i] + k[i] >= y
C++ 参考代码
cpp
#include <iostream>
using namespace std;
const int N = 1e4 + 10;
int n;
int a[N], b[N], g[N], k[N];
int x, y;
int find()
{
// 从后往前枚举
for(int i = n; i >= 1; i--)
{
// 判断是否覆盖
if(a[i] <= x && b[i] <= y && a[i] + g[i] >= x && b[i] + k[i] >= y)
{
return i;
}
}
return -1;
}
int main()
{
cin >> n;
for(int i = 1; i <= n; i++) cin >> a[i] >> b[i] >> g[i] >> k[i];
cin >> x >> y;
cout << find() << endl;
return 0;
}
复杂度分析
- 时间复杂度:O(n),逆序枚举,最坏情况遍历所有地毯。
- 空间复杂度:O(n),存储每张地毯的参数。
易错点
- 边界和顶点也算覆盖,判断条件用
<=/>=; - 逆序枚举可以提前返回,比正序更高效;
- 没被覆盖时输出 -1。
3.1.2 回文日期
- 题目来源:洛谷
- 题目链接 :P2010 NOIP2016 普及组 回文日期
- 难度系数:★
题目描述
在日常生活中,通过年、月、日这三个要素可以表示出一个唯一确定的日期。
牛牛习惯用 8 位数字表示一个日期,其中前 4 位代表年份,接下来 2 位代表月份,最后 2 位代表日期。显然:一个日期只有一种表示方法,而两个不同的日期的表示方法不会相同。
牛牛认为,一个日期是回文的,当且仅当表示这个日期的 8 位数字是回文的。现在,牛牛想知道:在他指定的两个日期之间(包含这两个日期本身),有多少个真实存在的日期是回文的。
一个 8 位数字是回文的,当且仅当对于所有的 i(1 ≤ i ≤ 8)从左向右数的第 i 个数字和第 9−i 个数字(即从右向左数的第 i 个数字)是相同的。
例如:
- 对于 2016 年 11 月 19 日,用 8 位数字 20161119 表示,它不是回文的。
- 对于 2010 年 1 月 2 日,用 8 位数字 20100102 表示,它是回文的。
- 对于 2010 年 10 月 2 日,用 8 位数字 20101002 表示,它不是回文的。
输入描述
输入包括两行,每行包括一个 8 位数字。
- 第一行表示牛牛指定的起始日期。
- 第二行表示牛牛指定的终止日期。
保证 date₁ 和 date₂ 都是真实存在的日期,且年份部分一定为 4 位数字,且首位数字不为 0。保证 date₁ 一定不晚于 date₂。
输出描述
输出一行,包含一个整数,表示在 date₁ 和 date₂ 之间,有多少个日期是回文的。
示例
示例一
输入:
20110101
20111231
输出:
1
示例二
输入:
20000101
20101231
输出:
2
解题思路
枚举所有月日的组合,然后根据回文的特性推出年份,然后比较这个数字是否在题目给定的区间内。
核心技巧:月日只有 12×31 ≈ 372 种组合,远比枚举所有年份高效。对于每个月日 (i, j),回文日期的年份就是月日的逆序:
k = j%10*1000 + j/10*100 + i%10*10 + i/10
num = k*10000 + i*100 + j
然后判断 date₁ ≤ num ≤ date₂ 即可。
C++ 参考代码
cpp
// 策略:枚举所有的月日的组合
#include <iostream>
using namespace std;
int x, y;
int day[] = {0, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
int main()
{
cin >> x >> y;
int ret = 0;
// 枚举月日的组合
for(int i = 1; i <= 12; i++)
{
for(int j = 1; j <= day[i]; j++)
{
int k = j % 10 * 1000 + j / 10 * 100 + i % 10 * 10 + i / 10;
int num = k * 10000 + i * 100 + j;
if(x <= num && num <= y) ret++;
}
}
cout << ret << endl;
return 0;
}
复杂度分析
- 时间复杂度:O(12 × 31) = O(372),枚举所有合法月日组合。
- 空间复杂度:O(1)。
易错点
- 2 月按 29 天算(因为回文推出的年份可能是闰年,且题目保证真实日期);
- 月日逆序推出年份时注意各位数字的位置;
- 区间是闭区间,包含起始和终止日期。
3.1.3 扫雷
- 题目来源:洛谷
- 题目链接 :P2327 SCOI2005 扫雷
- 难度系数:★★
题目描述
相信大家都玩过扫雷的游戏。那是在一个 n×m 的矩阵里面有一些雷,要你根据一些信息找出雷来。
万圣节到了,"余"人国流行起了一种简单的扫雷游戏,这个游戏规则和扫雷一样,如果某个格子没有雷,那么它里面的数字表示和它 8 连通的格子里面雷的数目。
现在棋盘是 n×2 的,第一列里面某些格子是雷,而第二列没有雷。由于第一列的雷可能有多种方案满足第二列的数的限制,你的任务即根据第二列的信息确定第一列雷有多少种摆放方案。
输入描述
第一行为 N,第二行有 N 个数,依次为第二列的格子中的数。(1 ≤ N ≤ 10000)
输出描述
一个数,即第一列中雷的摆放方案数。
示例
输入:
2
1 1
输出:
2
解题思路
我们发现,当第一列中第一行的小格子的状态确定之后,其实后续行的状态也跟着固定下来。而第一列中第一行的状态要么有雷,要么没有雷,所以最终的答案就在 0、1、2 中。
因此,我们枚举第一列中第一行的两种状态:要么有雷,要么没雷。然后依次计算剩下行的值,看看是否能满足所给的数据。
递推关系:a[i] = b[i-1] - a[i-1] - a[i-2](第二列第 i-1 行的数等于第一列中第 i-2、i-1、i 行雷数之和)。
最后还要判断 a[n+1] == 0(确保没有越界的雷)。
C++ 参考代码
cpp
#include <iostream>
using namespace std;
const int N = 1e4 + 10;
int n;
int a[N], b[N];
// 不放地雷
int check1()
{
a[1] = 0;
for(int i = 2; i <= n + 1; i++)
{
a[i] = b[i - 1] - a[i - 1] - a[i - 2];
if(a[i] < 0 || a[i] > 1) return 0;
}
if(a[n + 1] == 0) return 1;
else return 0;
}
// 放地雷
int check2()
{
a[1] = 1;
for(int i = 2; i <= n + 1; i++)
{
a[i] = b[i - 1] - a[i - 1] - a[i - 2];
if(a[i] < 0 || a[i] > 1) return 0;
}
if(a[n + 1] == 0) return 1;
else return 0;
}
int main()
{
cin >> n;
for(int i = 1; i <= n; i++) cin >> b[i];
int ret = 0;
ret += check1(); // a[1] 不放地雷
ret += check2(); // a[1] 放地雷
cout << ret << endl;
return 0;
}
复杂度分析
- 时间复杂度:O(n),两种状态各递推一遍。
- 空间复杂度:O(n),存储第一列和第二列数据。
易错点
- 第一行状态只有两种(有雷/没雷),枚举这两种即可;
- 递推出的 ai 必须是 0 或 1,否则不合法;
- 最后要判断 an+1 == 0,确保递推没有产生越界的雷。
3.2 二进制枚举
二进制枚举:用一个数二进制表示中的 0/1 表示两种状态,从而达到枚举各种情况。
- 利用二进制枚举时,会用到一些位运算的知识;
- 用二进制中的 0/1 表示状态这种方法,会在动态规划章节中的状态压缩 DP 中继续使用到;
- 二进制枚举的方式也可以用递归实现,后续递归课程中会再讲到。
3.2.1 子集
- 题目来源:力扣
- 题目链接 :子集
- 难度系数:★
- 模式:核心代码模式(只需实现函数接口)
题目描述
给你一个整数数组 nums,数组中的元素互不相同。返回该数组所有可能的子集(幂集)。
解集不能包含重复的子集。你可以按任意顺序返回解集。
示例
输入:
nums = [1,2,3]
输出:
[[],[1],[2],[1,2],[3],[1,3],[2,3],[1,2,3]]
解题思路
枚举 0 ~ (1<<n)−1 之间所有的数,每一个数的二进制中 1 的位置可以表示数组中对应位置选上该元素。 那么 0 ~ (1<<n)−1 就可以枚举原数组中所有的子集。
根据枚举的每一个状态,选出原数组中对应的元素,然后存在结果数组中。
C++ 参考代码(核心代码模式)
cpp
class Solution
{
public:
vector<vector<int>> subsets(vector<int>& nums)
{
vector<vector<int>> ret;
int n = nums.size();
// 枚举所有的状态
for(int st = 0; st < (1 << n); st++)
{
// 根据 st 的状态,还原出要选的数
vector<int> tmp; // 从当前选的子集
for(int i = 0; i < n; i++)
{
if((st >> i) & 1) tmp.push_back(nums[i]);
}
ret.push_back(tmp);
}
return ret;
}
};
复杂度分析
- 时间复杂度:O(n × 2ⁿ),共 2ⁿ 个子集,每个子集最多 n 个元素。
- 空间复杂度:O(n × 2ⁿ),存储所有子集。
易错点
- 状态从 0 开始枚举(0 对应空集);
(st >> i) & 1判断第 i 位是否为 1;- 核心代码模式不需要处理输入输出,只需实现函数。
3.2.2 费解的开关
- 题目来源:洛谷
- 题目链接 :P10449 费解的开关
- 难度系数:★★★
题目描述
你玩过"拉灯"游戏吗?25 盏灯排成一个 5×5 的方形。每一个灯都有一个开关,游戏者可以改变它的状态。
每一步,游戏者可以改变某一个灯的状态。游戏者改变一个灯的状态会产生连锁反应:和这个灯上下左右相邻的灯也要相应地改变其状态。
我们用数字 "1" 表示一盏开着的灯,用数字 "0" 表示关着的灯。给定一些游戏的初始状态,编写程序判断游戏者是否可能在 6 步以内使所有的灯都变亮。
输入描述
第一行有一个正整数 n,代表数据中共有 n 个待解决的游戏初始状态。
以下若干行数据分为 n 组,每组数据有 5 行,每行 5 个字符。每组数据描述了一个游戏的初始状态。各组数据间用一个空行分隔。
- 对于 30% 的数据,n ≤ 5;
- 对于 100% 的数据,n ≤ 500。
输出描述
输出数据一共有 n 行,每行有一个小于等于 6 的整数,它表示对于输入数据中对应的游戏状态最少需要几步才能使所有灯变亮。
对于某一个游戏初始状态,若 6 步以内无法使所有灯变亮,请输出 "-1"。
示例
输入:
3
00111
01011
10001
11010
11100
11101
11101
11110
11111
11111
01111
11111
11111
11111
11111
输出:
3
2
-1
解题思路
在这个"拉灯游戏"中我们可以得到三个性质:
- 每一个开关最多只会被按一次。因为按两次及以上是没有意义的,只会让按的次数增多;
- 按每一个开关的先后顺序不会影响最后的结果。当所有开关按的方式确定之后,每一个开关被改变的次数也就被确定了,结果就是固定的;
- 如果确定了第一行的按法,后续行的按法就也固定下来了。因为第一行的按法固定之后,第二行的按法需要把第一行全部点亮;第二行确定之后,第三行需要把第二行全部点亮......依次类推。
有了这三个性质,核心思路就是:
- 暴力枚举第一行的所有按法(共 2⁵ = 32 种);
- 然后根据第一行的按法,计算出当前行以及下一行被按之后的结果;
- 根据上一行被按了之后的状态,确定当前行的按法,然后重复操作 2;
- 最后判断最后一行是否全部都亮。
优美实现技巧:
- 读取数据时把所有的 1 当成 0、把所有的 0 当成 1(反着存),这样题目要求的全亮就变成全灭,后续操作更舒服;
- 用二进制存储每一行的状态,用位运算快速实现状态改变;
- 当前行被按之后的状态:
t[i] = t[i] ^ push ^ (push>>1) ^ (push<<1),再&= (1<<n)-1截断高位; - 下一行的状态:
t[i+1] ^= push; - 下一行的按法:
push = t[i](当前行怎么灭,下一行就怎么按)。
C++ 参考代码
cpp
#include <iostream>
#include <cstring>
using namespace std;
const int N = 10;
int n = 5;
int a[N]; // 用二进制表示,来存储灯的状态
int t[N]; // 备份 a 数组
// 计算 x 的二进制表示中一共有多少个 1
int calc(int x)
{
int cnt = 0;
while(x)
{
cnt++;
x &= x - 1;
}
return cnt;
}
int main()
{
int T; cin >> T;
while(T--)
{
// 多组测试时,一定要注意清空之前的数据
memset(a, 0, sizeof a);
for(int i = 0; i < n; i++)
{
for(int j = 0; j < n; j++)
{
char ch; cin >> ch;
// 存成相反的
if(ch == '0') a[i] |= 1 << j;
}
}
int ret = 0x3f3f3f; // 统计所有合法的按法中的最小值
// 枚举第一行所有的按法
for(int st = 0; st < (1 << n); st++)
{
memcpy(t, a, sizeof a);
int push = st; // 当前行的按法
int cnt = 0; // 统计当前按法下一共按了多少次
// 依次计算后续行的结果以及按法
for(int i = 0; i < n; i++)
{
cnt += calc(push);
// 修改当前行被按的结果
t[i] = t[i] ^ push ^ (push << 1) ^ (push >> 1);
t[i] &= (1 << n) - 1; // 清空高位影响
// 修改下一行的状态
t[i + 1] ^= push;
// 下一行的按法
push = t[i];
}
if(t[n - 1] == 0) ret = min(ret, cnt);
}
if(ret > 6) cout << -1 << endl;
else cout << ret << endl;
}
return 0;
}
复杂度分析
- 时间复杂度:O(n × 2⁵ × 5),每组数据枚举 32 种第一行按法,每种按法处理 5 行。
- 空间复杂度:O(5),用二进制存储 5 行状态。
易错点
- 多组测试数据一定要
memset清空上一组的数据; - 反着存(0 存成 1,1 存成 0),全亮变全灭,判断更简单;
- 位运算改变当前行状态后要
&= (1<<n)-1截断高位; - 最后判断
t[n-1] == 0(最后一行全灭即全亮); - 结果超过 6 步输出 -1。
3.2.3 Even Parity
- 题目来源:洛谷
- 题目链接 :Even Parity
- 难度系数:★★★
题目描述
给你一个 n×n 的 01 矩阵(每个元素非 0 即 1),你的任务是把尽量少的 0 变成 1,使得原矩阵变为偶数矩阵(矩阵中每个元素的上、下、左、右的元素(如果存在的话)之和均为偶数)。
输入描述
第一行为数据组数 T(T ≤ 30)。每组数据:第一行为正整数 n(1 ≤ n ≤ 15);接下来的 n 行每行包含 n 个非 0 即 1 的整数,相邻整数间用一个空格隔开。
输出描述
对于每组数据,输出被改变的元素的最小个数。如果无解,输出 -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
解题思路
我们发现:
- 每一个 0 如果变成 1,只会变一次;
- 当第一行的最终状态 确定之后,第二行的最终状态也会确定。
所以我们可以暴力枚举第一行的最终状态,在这个最终状态「合法」的前提下,「递推」出来第二行的状态,「以此类推」下去。
考虑几个关键问题:
-
如何枚举第一行所有的最终状态 st:枚举 0 ~ (1<<n)−1 之间所有的数,每一个数就是第一行的最终状态;
-
如何判断每行的最终状态 y 是否合法:本题只能 0 变 1,所以比较初始状态 x 以及最终状态 y 中二进制表示的每一位,如果是 0 变 1 就是「合法」操作并计数;如果是 1 变 0 就是「非法」操作,直接跳出本次循环,枚举第一行的下一个状态;
-
当前行的最终状态 ai 确定之后,如何递推下一行的最终状态 ai+1 :规则是当前位置「上下左右」1 的个数之和是「偶数」,根据异或运算「无进位相加」的特性,正好就是上下左右位置「异或」的结果是 0。那么下一行对应位置的状态就是「当前行右移一位」与「当前行左移一位」与「上一行对应位置」异或的结果:
a[i+1] = (a[i] >> 1) ^ (a[i] << 1) ^ a[i-1]其中
a[i] << 1会造成不合法的位置是 1 的情况,注意「高位截断」:& ((1 << n) - 1)。
C++ 参考代码
cpp
#include <iostream>
#include <cstring>
using namespace std;
const int N = 20;
int n;
int a[N]; // 用二进制存储状态
int t[N]; // 备份
// 判断 x->y 是否合法
// 返回 -1,表示不合法
// 其余的数,表示合法,并且表示 0->1 的次数
int calc(int x, int y)
{
int sum = 0;
for(int i = 0; i < n; i++)
{
if(((x >> i) & 1) == 0 && ((y >> i) & 1) == 1) sum++;
if(((x >> i) & 1) == 1 && ((y >> i) & 1) == 0) return -1;
}
return sum;
}
int solve()
{
int ret = 0x3f3f3f; // 记录最小的改变次数
// 枚举第一行的最终状态
for(int st = 0; st < (1 << n); st++)
{
memcpy(t, a, sizeof a);
int change = st;
int cnt = 0; // 统计 0->1 的次数
bool flag = 1;
for(int i = 1; i <= n; i++)
{
// 先判断 change 是否合法
int c = calc(t[i], change);
if(c == -1)
{
flag = 0;
break;
}
cnt += c; // 累加次数
// 当前行的最终状态
t[i] = change;
// 计算下一行的最终状态
change = t[i - 1] ^ (t[i] << 1) ^ (t[i] >> 1);
change &= (1 << n) - 1;
}
if(flag) ret = min(ret, cnt);
}
if(ret == 0x3f3f3f) return -1;
else return ret;
}
int main()
{
int T; cin >> T;
for(int k = 1; k <= T; k++)
{
// 多组测试数据,记得清空
memset(a, 0, sizeof a);
cin >> n;
for(int i = 1; i <= n; i++) // 避免越界访问
{
for(int j = 0; j < n; j++)
{
int x; cin >> x;
if(x) a[i] |= 1 << j;
}
}
printf("Case %d: %d\n", k, solve());
}
return 0;
}
复杂度分析
- 时间复杂度:O(T × 2ⁿ × n²),每组枚举 2ⁿ 种第一行状态,每种递推 n 行,每行 n 位判断。
- 空间复杂度:O(n),用二进制存储 n 行状态。
易错点
- 只能 0 变 1,不能 1 变 0,判断合法性时要注意;
- 第一行从下标 1 开始存储(a0 = 0),避免越界;
- 递推下一行状态时要
&= (1<<n)-1高位截断; - 多组数据
memset清空; - 无解时返回 -1。