【基础算法】穷举的艺术:在可能性森林中寻找答案

🔭 个人主页: 散峰而望

《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. 枚举对象(枚举什么)
  2. 枚举顺序(正序或逆序)
  3. 枚举方式(常规枚举、递归枚举或二进制枚举)

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 费解的开关

费解的开关

算法原理:

根据题目,我们可以获得以下一些性质

  • 每一个开关「最多只会被按一次」。因为按两次及以上是没有意义的,只会让按的次数增多;
  • 按每一个开关的「先后顺序」不会影响最后的结果。可以想象,当所有开关按的方式确定之后,每一个开关被改变的「次数」也就被确定了,也就是说不管你先按谁后按谁,改变的次数是固定的,那么结果就是固定的;
  • 如果「确定了第一行」的按法,后续行的按法就也固定下来了(这里可以参考之前《扫雷》这道题,有相似点)。因为第一行的按法固定之后,第二行的按法需要把第一行「全部点亮」;当第二行的按法确定之后,第三行的按法需要把第二行「全部点亮」...,依次类推,后续行的按法就都确定下来了。
  • 直到按到最后一行然后判断所有的灯是否全亮。

那么,我们该如何实现呢?

  1. 如何枚举第一行所有的按法:用二进制枚举所有的状态 0 ~ (1 << 5) - 1
  2. 如何计算出一共按了多少次:看二进制表示中有多少个 1 。
  3. 用二进制表示,来存储灯的初始状态:存的时候,把 0->1 1->0 ,取反存储,此时题目从全量变成全灭。之所以这样做是因为容易实现并且容易判断终止状态。
  4. 如何根据按法计算当前行 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
  5. 求出当前行被按了之后的结果,如何求出下一行的按法:当前行怎么亮,下一行就怎么按,这样就可以把当前行亮的位置暗灭 nextpush = a[i]
  6. 判断最后一行是否全灭: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

Even Parity

算法原理:

性质:

  • 因为对每一个 0 只有变成 1 和不变的两种状态
  • 当我们决定改变哪些 0 之后,改变的顺序不影响最终结果,即只需关心哪些 0 最终需要改变
  • 当第一行"最终结果"确定之后,后续的"最终结果"也跟着确定。所以我们可以「暴力枚举」第一行的最终状态,在这个最终状态「合法」的前提下,「递推」出来第二行的状态,以此类推下去。

那么,我们该如何实现呢?

  1. 暴力枚举第一行的所有最终状态 ,直接二进制枚举所有结果 0 ~ (1 << n) - 1
  2. 判断是否合法:
    一层 for 循环,判断 a[i] 和最终状态相应的二进制。如果是 0 -> 1 则合法,并记录一下;如果是 1 -> 0 则不合法,返回 -1 。
  3. 当前行的最终状态 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
  4. 重复 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 的约束验证。其优势在于将复杂的状态表示压缩为整数,大幅简化代码逻辑。

掌握枚举的关键在于分析问题规模与可行性,识别无效分支以优化效率,同时结合其他算法(如剪枝、预处理)应对更大数据量。实践中,灵活选择枚举方式能有效解决许多看似复杂的问题。

愿诸君能一起共渡重重浪,终见缛彩遥分地,繁光远缀天

相关推荐
那年我七岁2 小时前
android ndk c++ 绘制图片方式
android·c++·python
Java后端的Ai之路2 小时前
【Python教程10】-开箱即用
android·开发语言·python
心.c2 小时前
Vue3+Node.js实现文件上传分片上传和断点续传【详细教程】
前端·javascript·vue.js·算法·node.js·哈希算法
散峰而望2 小时前
【基础算法】算法的“预谋”:前缀和如何改变游戏规则
开发语言·数据结构·c++·算法·github·动态规划·推荐算法
We་ct2 小时前
LeetCode 48. 旋转图像:原地旋转最优解法
前端·算法·leetcode·typescript
爱尔兰极光2 小时前
LeetCode--长度最小的子数组
算法·leetcode·职场和发展
仰泳的熊猫2 小时前
题目1432:蓝桥杯2013年第四届真题-剪格子
数据结构·c++·算法·蓝桥杯·深度优先·图论
深蓝电商API2 小时前
异步爬虫中代理池的并发管理
开发语言·爬虫·python
hhy_smile2 小时前
Special method in class
java·开发语言