
| 🔭 个人主页: 散峰而望 |
|---|
《C语言:从基础到进阶》《编程工具的下载和使用》《C语言刷题》《算法竞赛从入门到获奖》《人工智能》《AI Agent》
愿为出海月,不做归山云
🎬博主简介



【基础算法】穷举的艺术:在可能性森林中寻找答案
- 前言
- 枚举
-
- [1.1 普通枚举](#1.1 普通枚举)
-
- [1.1.1 铺地毯](#1.1.1 铺地毯)
- [1.1.2 回文日期](#1.1.2 回文日期)
- [1.1.3 扫雷](#1.1.3 扫雷)
- [1.2 二进制枚举](#1.2 二进制枚举)
-
- [1.2.1 子集](#1.2.1 子集)
- [1.2.2 费解的开关](#1.2.2 费解的开关)
- [1.2.3 Even Parity](#1.2.3 Even Parity)
- 结语
前言
枚举作为一种基础而强大的算法思想,广泛应用于解决各类实际问题。通过系统地遍历所有可能的解或状态,枚举能够确保答案的准确性,尤其适用于数据规模较小或约束条件明确的问题。
普通枚举涵盖经典场景,如铺地毯问题中确定覆盖关系、回文日期的日期格式验证,以及扫雷游戏中的相邻格子分析。这类问题通常需要细致的条件判断和循环控制,以高效筛选有效解。
二进制枚举则利用位运算的特性,高效处理子集生成、状态压缩等问题。例如,求解集合子集、优化"费解的开关"中的操作步骤,或验证 Even Parity 的矩阵约束。这种方法通过二进制数的每一位表示状态,显著减少代码复杂度与运行时间。
掌握枚举技巧不仅能提升解决基础问题的能力,也为学习回溯、动态规划等高级算法奠定基础。本部分内容将通过典型例题,逐步拆解枚举的应用逻辑与优化策略。
枚举
枚举法,顾名思义就是穷举所有可能情况,从中筛选出符合题目要求的解。这是一种典型的暴力算法。
在实际应用中,枚举法往往会面临时间复杂度过高的问题。因此,在使用前需要仔细评估题目给出的数据规模是否允许暴力枚举。如果数据量过大导致超时,就需要考虑使用更高效的算法(如二分查找、双指针、前缀和等优化手段)。
运用枚举法时,需要重点关注三个核心要素:
- 枚举对象(枚举什么)
- 枚举顺序(正序或逆序)
- 枚举方式(常规枚举、递归枚举或二进制枚举)
1.1 普通枚举
1.1.1 铺地毯

由题目要求四个整数 a,b,g,k 中,地毯的左下角的坐标 (a, b) 以及地毯在 x 轴和 y 轴方向的长度。根据题目给的示例一,我们可以锁定全部覆盖的点。

算法原理:
枚举所有的地毯,判断哪一个地毯能覆盖的 (x, y) 的位置

优化:
因为要输出的是覆盖地面某个点的最上面的那张地毯的编号,所以我们直接逆序遍历所有地毯,第一次覆盖该位置的的地毯便是我们所需要的结果。
参考代码:
cpp
#include <iostream>
using namespace std;
const int N = 1e5 + 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;
}
1.1.2 回文日期

算法原理:
- 策略一:枚举 x ~ y 之间所有的数字,然后判断是否回文。如果回文就拆成年月日,判断是否为合法日期
- 策略二:仅枚举所有的年份,然后拆成回文的月日,然后判断日期是否合法
- 策略三:枚举所有的月日,然后回文相对应的年份,判断是否合法
因为策略一和策略二的时间复杂度比较高,所以使用策略三的方法。至于如何转换,见下图:

注意: 因为 0229 对应的年份是 9220 ,而 9220 是合法闰年,所以我们不需要判断闰年了
参考代码:
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;
}
1.1.3 扫雷

扫雷的规则我们都知道,一个数字周围会有其相对应的地雷数

算法原理:
由上面演示的图片中可以发现当第一列中,第一行的小格子的状态确定了之后,其实后续行的状态也跟着固定下来。而第一列中,第一行的状态要么有雷,要么没有雷,所以最终的答案就在 0, 1, 2 中。
因此,我们枚举第一列中,第一行的两种状态:要么有雷,要么没雷。然后依次计算剩下行的值,看看是否能满足所给的数据。

由图,我们可以发现:
a[1] = 0
a[2] = b[1] - a[0] - a[1]
a[3] = b[2] - a[1] - a[2]
a[4] = b[3] - a[2] - a[3]
...
a[i] = b[i - 1] - a[i - 2] - a[i - 1]
故计算相关的数,我们就可以借助这列算式。

参考代码:
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 - 2] - a[i - 1];
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 - 2] - a[i - 1];
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[2]放地雷
cout << ret << endl;
return 0;
}
1.2 二进制枚举
二进制枚举:用一个数二进制表示中的 0/1 表示两种状态,从而达到枚举各种情况。
- 利用二进制枚举时,会用到一些位运算的知识。不熟需要去补一下位运算相关的知识。
- 关于用二进制中的 0/1 表示状态这种方法,会在动态规划中的状态压缩 dp 中继续使用到。
- 二进制枚举的方式也可以用递归实现,后续递归课程中会再讲到。
二进制枚举的最佳时机:
-
问题规模:n ≤ 20(或经折半后每半≤20)
-
状态简单:每个元素只有两种状态
-
需要枚举:必须检查所有可能性
-
没有更优解法:贪心、DP不适用
1.2.1 子集

算法原理:
枚举 1 ~ 1 << n 之间所有的数,每一个数的二进制中 1 的位置可以表示数组中对应位置选上该元素。那么 1 ~ 1 << n 就可以枚举出原数组中所有的子集。
根据枚举的每一个状态,选出原数组中对应的元素,然后存在结果数组中。

参考代码:
cpp
class Solution {
public:
vector<vector<int>> subsets(vector<int>& nums) {
vector<vector<int>> ret;
int n = nums.size();
//枚举所有状态
for(int i = 0; i < (1 << n); i++) {
vector<int> tmp;
for(int j = 0; j < n; j++) {
if((i >> j) & 1) tmp.push_back(nums[j]);
}
ret.push_back(tmp);
}
return ret;
}
};
1.2.2 费解的开关

算法原理:
根据题目,我们可以获得以下一些性质
- 每一个开关「最多只会被按一次」。因为按两次及以上是没有意义的,只会让按的次数增多;
- 按每一个开关的「先后顺序」不会影响最后的结果。可以想象,当所有开关按的方式确定之后,每一个开关被改变的「次数」也就被确定了,也就是说不管你先按谁后按谁,改变的次数是固定的,那么结果就是固定的;
- 如果「确定了第一行」的按法,后续行的按法就也固定下来了(这里可以参考之前《扫雷》这道题,有相似点)。因为第一行的按法固定之后,第二行的按法需要把第一行「全部点亮」;当第二行的按法确定之后,第三行的按法需要把第二行「全部点亮」...,依次类推,后续行的按法就都确定下来了。
- 直到按到最后一行然后判断所有的灯是否全亮。
那么,我们该如何实现呢?
- 如何枚举第一行所有的按法:用二进制枚举所有的状态
0 ~ (1 << 5) - 1。 - 如何计算出一共按了多少次:看二进制表示中有多少个 1 。
- 用二进制表示,来存储灯的初始状态:存的时候,把
0->11->0,取反存储,此时题目从全量变成全灭。之所以这样做是因为容易实现并且容易判断终止状态。 - 如何根据按法计算当前行 a[i] 和下一行 a[i + 1] 被按之后的状态:
a. 当前行:被按的位置会影响「当前位置」以及「左右两个位置」的状态,如果状态是 0 会被变成 1,如果状态是 1 会被变成 0 ,恰好是 x ^ 1 之后的值因为会改变「当前位置」以及「左右两个位置」,所以 a[i] 的最终状态就是:a[i]=a[i] ^ push ^ (push>>1) ^ (push<<1)
其中,push << 1有可能会让第 5 位变成 1 ,这一位是一个「非法」的位置,有可能影响后续判断,我们要「截断高位」:a[i] & ((1 << 5) - 1)
最终:a[i]=(a[i] ^ push ^ (push>>1) ^ (push << 1)) & ((1 << 5) - 1)。
b.下一行:当前行的 push 只会对下一行「对应的位置」做修改:a[i + 1] = a[i + 1] ^ push。 - 求出当前行被按了之后的结果,如何求出下一行的按法:当前行怎么亮,下一行就怎么按,这样就可以把当前行亮的位置暗灭
nextpush = a[i]。 - 判断最后一行是否全灭:
a[4] == 0开头「反着存储」的优势就体现出来了。
参考代码:
cpp
#include <iostream>
#include <cstring>
using namespace std;
const int N = 10;
int n = 5;
int a[N];
int t[N];
//计算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 = 0x3f3f3f3f;//统计所有合法按法的最小值
//第一行的按法
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] = t[i] & (1 << n) - 1;
//修改下一行
t[i + 1] ^= push;
//下一行按法
push = t[i];
}
if(t[n - 1] == 0) ret = min(cnt, ret);
}
if(ret > 6) cout << -1 << endl;
else cout << ret << endl;
}
return 0;
}
1.2.3 Even Parity

算法原理:
性质:
- 因为对每一个 0 只有变成 1 和不变的两种状态
- 当我们决定改变哪些 0 之后,改变的顺序不影响最终结果,即只需关心哪些 0 最终需要改变。
- 当第一行"最终结果"确定之后,后续的"最终结果"也跟着确定。所以我们可以「暴力枚举」第一行的最终状态,在这个最终状态「合法」的前提下,「递推」出来第二行的状态,以此类推下去。
那么,我们该如何实现呢?
- 暴力枚举第一行的所有最终状态 ,直接二进制枚举所有结果
0 ~ (1 << n) - 1。 - 判断是否合法:
一层 for 循环,判断 a[i] 和最终状态相应的二进制。如果是0 -> 1则合法,并记录一下;如果是1 -> 0则不合法,返回 -1 。 - 当前行的最终状态 a[i] 确定之后,则合法推导下一行的最终状态 a[i + 1]
规则是当前位置「上下左右」1 的个数之和是「偶数」,根据「异或」运算「无进位相加」的特性,正好就是上下左右位置「异或」的结果是 0 。那么下一行对应位置的状态就是「当前行右移一位」与「当前行左移一位」与「上一行对应位置」异或的结果:a[i + 1] = a[i - 1] ^ (a[i] >> 1) ^ (a[i] << 1)
其中 a[i] << 1 会造成不合法的位置是 1 的情况,注意「高位截断」:a[i + 1] &= (1 << n) - 1。 - 重复 2. 和 3. 的过程,直到推到最后一行是否合法
参考代码:
cpp
#include <iostream>
#include <cstring>
using namespace std;
const int N = 20;
int n;
int a[N];
int t[N];
//判断是否合法
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 = 0x3f3f3f3f;
//枚举第一行最终状态
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(cnt, ret);
}
if(ret == 0x3f3f3f3f) 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;
}
结语
枚举作为一种基础而强大的算法思想,能够通过系统地遍历所有可能情况来解决各类问题。无论是普通枚举还是二进制枚举,其核心在于以合理的顺序和方式覆盖解空间,避免遗漏或重复。
普通枚举适用于直观的线性或多维场景,如铺地毯的位置确认、回文日期的筛选或扫雷游戏的相邻计算。这类问题通常需要清晰的边界定义和循环结构,确保高效覆盖目标范围。
二进制枚举则擅长处理组合问题,通过位运算高效生成子集或状态组合,如子集构造、开关灯问题的状态翻转或 Even Parity 的约束验证。其优势在于将复杂的状态表示压缩为整数,大幅简化代码逻辑。
掌握枚举的关键在于分析问题规模与可行性,识别无效分支以优化效率,同时结合其他算法(如剪枝、预处理)应对更大数据量。实践中,灵活选择枚举方式能有效解决许多看似复杂的问题。
愿诸君能一起共渡重重浪,终见缛彩遥分地,繁光远缀天。
