蓝桥杯枚举算法精讲:从普通枚举到二进制枚举

目录

  • 前言
  • 一、普通枚举
    • [1.1 铺地毯](#1.1 铺地毯)
    • [1.2 回文日期](#1.2 回文日期)
    • [1.3 扫雷](#1.3 扫雷)
  • 二、二进制枚举
    • [2.1 子集](#2.1 子集)
    • [2.2 费解的开关](#2.2 费解的开关)
    • [2.3 Even Parity](#2.3 Even Parity)
  • 结语

🎬 云泽Q个人主页
🔥 专栏传送入口 : 《C语言》《数据结构》《C++》《Linux

⛺️遇见安然遇见你,不负代码不负卿~


前言

大家好啊,我是云泽Q,欢迎阅读我的文章,一名热爱计算机技术的在校大学生,喜欢在课余时间做一些计算机技术的总结性文章,希望我的文章能为你解答困惑~

枚举

顾名思义,就是把所有情况全都罗列出来,然后找出符合题目要求的那一个。因此,枚举是一种纯暴力的算法

一般情况下,枚举策略都是会超时的。此时要根据题目的数据范围来判断暴力枚举是否可以通过,如果不行的话,就要用其他算法来进行优化

使用枚举策略时,重点思考枚举的对象(枚举什么),枚举的顺序(正序还是逆序),以及枚举的方式(普通枚举?递归枚举?二进制枚举)

一、普通枚举

1.1 铺地毯

铺地毯

解法

枚举所有的地毯,判断哪一个地毯能够覆盖(x,y)这个位置

优化枚举方式:

  • 因为要的是最后一个能够覆盖(x,y)位置的地毯,那么逆序枚举所有的地毯,第一次找到覆盖(x,y)位置的就是结果
  • 如果从前往后枚举,至少要把所有地毯枚举完,才知道最后结果
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;
}

1.2 回文日期

回文日期

该题目给出三种解法:

该策略会超时,但是整体思路还是很值得学习

cpp 复制代码
#include<iostream>
#include<string>
using namespace std;

int date1, date2;
int cnt;

int main()
{
	cin >> date1 >> date2;
	for (int num = date1; num <= date2; num++)
	{
		//判断是否回文
		string s = to_string(num);
		bool pal = (s[0] == s[7] && s[1] == s[6] && s[2] == s[5] && s[3] == s[4]);
		if (!pal) continue;
		//拆分年月日
		int year = num / 10000;
		int month = (num / 100) % 100;
		int day = num % 100;
		//判断月份是否合法
		if (month < 1 || month > 12) continue;
		//判断日期是否合法
		int maxDay = 0;
		if (month == 2)
		{
			bool isLeap = (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0);
			maxDay = isLeap ? 29 : 28;
		}
		else if (month == 4 || month == 6 || month == 9 || month == 11) {
			maxDay = 30;
		}
		else {
			maxDay = 31;
		}
		if (day >= 1 && day <= maxDay) cnt++;
	}
	cout << cnt << endl;
	return 0;
}

补充:

  1. to_string(num)的功能
    to_string是 C++ 标准库中的函数,属于< string >头文件,它能把数值类型 (如int、long、float等)转换成该数值对应的字符串形式。
    比如:如果num的值是20241202,那么to_string(num)会生成字符串"20241202";如果num是123,则生成"123"。
  2. string s = ...的作用
    定义一个string类型的变量s,并把to_string(num)转换得到的字符串结果赋值给s,这样后续就能通过字符串的方式操作num的每一位字符(比如判断回文时比较对称位置的字符)。

为什么需要这样的转换

因为要判断数字是否为回文数,需要直接访问每一位数字的字符(比如第 1 位和最后 1 位、第 2 位和倒数第 2 位是否相等)。如果直接操作整数,需要通过取模、除法等运算拆分每一位,代码会更繁琐;而转换成字符串后,可以直接通过下标(如s[0]、s[7])访问每一位,判断回文会更简单直观。

cpp 复制代码
#include<iostream>
using namespace std;

int date1, date2;
int count;

bool isLeap(int year)
{
	return (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0);
}

bool isLegal(int year, int month, int day)
{
	//检验月份是否合法
	if(month < 1 || month > 12) return false;
	//每月的最大天数
	int maxDay;
	switch(month)
	{
		case 1: case 3: case 5: case 7: case 8: case 10: case 12:
			maxDay = 31; break;
		case 4: case 6: case 9: case 11:
			maxDay = 30; break;
		case 2:
			maxDay = isLeap(year) ? 29 : 28; break;
		default:
			maxDay = 0;
	}
	//检验天数是否合法
	return (day >= 1 && day <= maxDay); 
}

int main()
{
	cin >> date1 >> date2;
	//确定年份区间
	int startYear = date1 / 10000;
	int endYear = date2 / 10000;
	for(int year = startYear; year <= endYear; year++)
	{
		//拆分年的每一位
		int y1 = year / 1000;
		int y2 = (year / 100) % 10;
		int y3 = (year / 10) % 10;
		int y4 = year % 10;
		//根据回文规则生成月和日 
		int month = y4 * 10 + y3;
		int day = y2 * 10 + y1;
		//判断日期是否合法
		if(!isLegal(year, month, day)) continue;
		//将日期与年份拼接为8位
		int trueDate = year * 10000 + month * 100 + day;
		if(trueDate >= date1 && trueDate <= date2)
		{
			count++;
		} 
	}
	cout << count << endl; 
	return 0;
}

补充一下:这里日期判断合法后拼接起来的8位数看似就在date1到date2之间,实则不然,还需要校验,原因如下:

  1. 例子 1:起始年份的 "早期回文日期" 小于date1
    假设输入的date1 = 20200501(2020 年 5 月 1 日),startYear = 2020:按回文规则,2020 年生成的回文日期是20200202(2020 年 2 月 2 日)。这个日期虽然年份在startYear范围内,但20200202 < 20200501,不在输入的起始区间内,因此需要排除。
  2. 例子 2:结束年份的 "晚期回文日期" 大于date2
    假设输入的date2 = 20231001(2023 年 10 月 1 日),endYear = 2023:按回文规则,2023 年生成的回文日期是20233202(月份 32,不合法,会被过滤);再比如endYear = 2024,date2 = 20240301,2024 年的回文日期是20244202(月份 42,不合法),但如果是2025年,date2 = 20250201,2025 年的回文日期是20255202(月份 52,不合法)------若遇到合法但超期的情况(比如date2 = 20211201,2021 年的回文日期是20211202,则20211202 > 20211201),也需要排除。
cpp 复制代码
//策略三:枚举所有的月日组合 
#include<iostream>
using namespace std;

int day[] = {0, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
int x, y;

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(num >= x && num <= y)
				ret++;
		}
	}
	cout << ret << endl;
	return 0;
}

下面是策略三的一些补充解释:
为什么二月标记为29天是正确的?

  1. 回文日期的唯一性
    • 对于二月29日(i=2, j=29),生成的年份k为9220。9220年是闰年(因为9220是4的倍数且不是100的倍数),因此9220年二月29日是真实存在的日期。
    • 类似地,对于其他月日组合,生成的年份k也能确保日期有效。例如,二月28日(j=28)生成年份8220,8220也是闰年,所以二月28日也存在。
  1. 避免漏掉有效日期
    • 如果將 day 数组中的二月改为28天,代码将不会枚举二月29日,从而漏掉像92200229这样的有效回文日期。尽管在输入日期范围较小(如样例中年份在2000到2010年)时,9220年可能超出范围,不影响结果,但代码需要保持通用性,以处理任何可能的输入范围。
  1. 闰年检查的隐含性
    • 代码通过回文结构自动确保了对于二月29日,生成的年份总是闰年。因此,无需单独检查闰年,直接枚举二月29日是安全的。如果改为28天,就破坏了这种隐含的闰年保证,导致代码不完整。

如果改为28天会有什么问题?

    • 假设将 day 数组中的二月改为28天,代码只会枚举到二月28日。那么对于二月29日,即使生成的年份是闰年(如9220年),也不会生成日期92200229。这将导致在输入日期范围包含9220年时,漏掉一个有效的回文日期。
    • 在实际应用中,输入日期范围可能很大(年份从1000到9999),因此9220年可能被包含在内。例如,如果输入日期是20000101和99991231,那么92200229就应该被计数。

1.3 扫雷

扫雷

解法

第一列中,第一行的小格子的状态确定了之后,后续行的状态也跟着固定下来,而第一列中,第一行的状态要么有雷,要么没有雷,所以最终的答案就在0,1,2中

因此我们枚举第一列中,第一行的两种状态:要么有雷,要么没雷。然后依次计算剩下行的值,看看是否能满足所给的数据

cpp 复制代码
#include<iostream>
using namespace std;

const int N = 1e4 + 10;
int n;
int a[N], b[N];

//a[1]不放雷 
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;
	}
} 

//a[1]放雷
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;
}

二、二进制枚举

二进制枚举:用一个数二进制表示中的 0/1 表示两种状态,从而达到枚举各种情况

  • 利用二进制枚举,经常会牵扯一些位运算的知识
  • 关于用二进制中的 0/1 表示状态这种方法,后续也会在动态规划的文章中写
  • 二进制枚举的方式也可以用递归实现

2.1 子集

子集

解法

枚举 0 到 1 << n -1 之间所有的数,每一个数的二进制中的 1 的位置可以表示数组中对应位置选上该元素,那么 0 到 1 << n -1 就可以枚举出原数组中所有的子集

根据枚举的每一个状态,选出原数组中对应的元素,然后存在结果数组中

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;
    }
};


2.2 费解的开关

费解的开关

解法

在这个拉灯游戏中我们可以得到三个性质

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

有了这三个性质,该题目的核心思路就是:

  1. 暴力枚举第一行的所有按法
  2. 然后根据第一行的按法,计算出当前行(第一行)以及下一行被按之后的结果
  3. 根据上一行被按了之后的状态,确定当前行的按法,然后重复 2 操作
  4. 最后判断最后一行是否全部都亮



cpp 复制代码
#include<iostream>
#include<cstring>
using namespace std;

const int N = 10;
int n = 5;
int a[N];//用二进制表示,来存储灯的状态 
int t[N];//备份a数组 

//统计当前行一共有多少个 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;
				//把第j位设为1,用一个整数存下整行需要翻转的灯 
				if(ch == '0') a[i] |= 1 << j;
			} 
	 	}
	 	//统计所有合法的按法中的最小值,将ret初始化为无穷大
		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] &= (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;
}

一、先明确题目核心问题

5x5 的灯阵,每个灯有 "亮(1)" 和 "灭(0)" 两种状态。按一个灯,会翻转自身 + 上下左右的灯。目标是用 最少步数(≤6) 让所有灯变亮,求最小步数(无解则输出 - 1)。

二、解题核心逻辑:第一行决定一切

因为第 i 行的灯只能被第 i-1 行的按操作或自身行的按操作影响(第 1 行没有上一行),所以:

  1. 枚举第一行的所有按法(5 列有2^5=32种可能);
  2. 根据前一行的状态推导当前行的按法(比如第 2 行的按法由第 1 行剩下的灭灯位置决定);
  3. 模拟完所有行后,检查最后一行是否全亮,统计最小步数。

位运算的作用 :用整数的二进制位 替代数组存灯状态,用位操作替代 "逐个灯翻转" 的循环,让代码更高效、简洁。

三、逐环节拆解:逻辑 + 位运算 + 代码
环节 1:存储灯的初始状态(把 "灭灯" 标记为需要翻转)
实际需求

我们需要记录 "哪些灯需要被翻转"(灭灯需要翻转,亮灯不需要),用一个变量存一行的 5 个灯状态,避免用数组。
位运算实现

  • 用整数的第 j 位(从 0 开始数)代表 "第 j 列的灯是否需要翻转":
    • 灭灯(0)→ 需要翻转→第 j 位设为1;
    • 亮灯(1)→ 不需要翻转→第 j 位设为0。
  • 操作:a[i] |= 1 << j(把第 j 位设为 1)。
cpp 复制代码
for(int i = 0; i < 5; i++) { // 遍历每一行
    for(int j = 0; j < 5; j++) { // 遍历每一列
        char ch; cin >> ch;
        if(ch == '0') a[i] |= 1 << j; // 灭灯→第j位设为1
    }
}

例子:输入某行是0 1 0 1 0(第 0、2、4 列灭):

  • j=0:1<<0=1(二进制00001)→ a[i] = 00001;
  • j=2:1<<2=4(二进制00100)→ a[i] |= 4 → a[i] = 00101;
  • j=4:1<<4=16(二进制10000)→ a[i] |= 16 → a[i] = 10101(十进制 21)。
    最终a[i]=21,用一个整数就存下了整行需要翻转的灯!

环节 2:枚举第一行的所有按法
实际需求

第一行有 5 列,每列可 "按" 或 "不按",共 32 种按法,需要遍历所有可能。
位运算实现

  • 用整数st的二进制位代表 "第一行的按法":第 j 位是1→按第 j 列,是0→不按。
  • 枚举范围:st从0到(1<<5)-1(即 0 到 31),覆盖所有 5 位二进制数。
cpp 复制代码
for(int st = 0; st < (1 << 5); st++) { // 枚举32种按法
    memcpy(t, a, sizeof a); // 备份初始状态
    int push = st; // 当前行的按法(先赋值第一行的按法)
    int cnt = 0; // 统计步数
}

例子:st=3(二进制00011)→ 第一行按第 0、1 列;st=16(二进制10000)→ 第一行按第 4 列。

环节 3:模拟 "按灯" 的连锁翻转(自身 + 左右 + 下一行)
实际需求

按push对应的灯后,要完成:

  1. 翻转当前行的自身 + 左右相邻灯
  2. 翻转下一行的对应位置灯
  3. 确定下一行的按法(当前行剩下的需要翻转的灯 = 下一行的按法)。

位运算实现(分三步)
第一步:翻转当前行的自身 + 左右

  • 按push的灯→翻转自身:push;
  • 翻转左边灯:push >> 1(push右移 1 位,比如push=00100→00010,对应左边列);
  • 翻转右边灯:push << 1(push左移 1 位,比如push=00100→01000,对应右边列);
  • 合并翻转:用异或( ^ )(0 ^ 1=1翻转,1 ^ 1=0翻转,0 ^ 0=0不变)→ t[i] ^ = push ^ (push<<1) ^ (push>>1)。

第二步:清空无效位(避免移位越界)

左移可能超出 5 列(比如push = 10000 → push << 1 = 100000,第 5 列无效),用(1 << 5) - 1 = 31(二进制00011111)做按位与(&) ,保留低 5 位,清空高位。

第三步:翻转下一行 + 确定下一行按法

  • 翻转下一行:t[i+1] ^ = push(下一行对应位置翻转);
  • 下一行按法:push = t[i](当前行剩下的需要翻转的灯 = 下一行要按的灯)。
cpp 复制代码
for(int i = 0; i < 5; i++) { // 遍历每一行
    cnt += calc(push); // 统计当前行按的步数(后面讲calc)
    
    // 1. 翻转当前行自身+左右
    t[i] ^= push ^ (push << 1) ^ (push >> 1);
    // 2. 清空无效位
    t[i] &= (1 << 5) - 1;
    // 3. 翻转下一行+更新下一行按法
    if(i + 1 < 5) t[i+1] ^= push;
    push = t[i]; // 下一行按法=当前行剩余需要翻转的灯
}

例子:假设push = 00100(按第 2 列),当前行t[i] = 10101:

  • 翻转自身 + 左右:00100 ^ 01000 ^ 00010 = 01110;
  • t[i] ^ = 01110 → 10101 ^ 01110 = 11011;
  • 清空无效位:11011 & 00011111 = 11011(无无效位);
  • 翻转下一行:t[i+1] ^ = 00100(下一行第 2 列翻转);
  • 下一行按法:push=11011(下一行要按第 0、1、3、4 列)。

环节 4:统计步数(数按了多少个灯)
实际需求

统计push中 "按了的灯" 数量(即二进制中1的个数)。
位运算实现

用x &= x-1消除最右边的1,每消除一次计数 + 1,直到x=0。

cpp 复制代码
int calc(int x) {
    int cnt = 0;
    while(x) {
        cnt++;
        x &= x - 1; // 消除最右边的1
    }
    return cnt;
}

例子:x = 01110(二进制)→ 消除过程:

  1. 01110 & 01101 = 01100 → cnt=1;
  2. 01100 & 01011 = 01000 → cnt=2;
  3. 01000 & 00111 = 00000 → cnt=3;
    最终返回 3(按了 3 个灯)。

环节 5:判断结果 + 输出
实际需求

最后一行如果全亮(即没有需要翻转的灯→t[4]=0),说明该按法有效,记录最小步数。

cpp 复制代码
if(t[n - 1] == 0) ret = min(ret, cnt); // 最后一行全亮则更新最小步数
// 输出结果
if(ret > 6) cout << -1 << endl;
else cout << ret << endl;

四、完整逻辑串起来

  1. 用位运算把 "需要翻转的灯" 存成整数(a[i] |= 1<<j);
  2. 枚举第一行的 32 种按法(st < (1<<5));
  3. 对每种按法,用位运算模拟 "按灯→翻转当前行 + 下一行→推导下一行按法";
  4. 用位运算统计步数(x &= x-1);
  5. 最后一行全亮则记录最小步数,最终输出。

2.3 Even Parity


结语

相关推荐
一水鉴天1 小时前
整体设计中的三个“闭”概念-闭集或度量空间:有序闭环Z、有界闭域R和有限闭群C
网络·人工智能·算法
转基因1 小时前
lambda表达式
算法
明洞日记1 小时前
【VTK手册017】 深入详解 vtkImageMathematics:医学图像的基本算术运算
c++·图像处理·算法·vtk·图形渲染
杰瑞不懂代码1 小时前
【公式推导】AMP算法比BP算法强在哪(一)
python·算法·机器学习·概率论
晚风(●•σ )1 小时前
C++语言程序设计——【算法竞赛常用知识点】
开发语言·c++·算法
浅川.251 小时前
xtuoj 哈希
算法·哈希算法·散列表
AndrewHZ2 小时前
【复杂网络分析】复杂网络分析技术在图像处理中的经典算法与应用实践
图像处理·人工智能·算法·计算机视觉·图像分割·复杂网络·图论算法
free-elcmacom2 小时前
机器学习入门<4>RBFN算法详解
开发语言·人工智能·python·算法·机器学习
java修仙传2 小时前
力扣hot100:最长连续序列
算法·leetcode·职场和发展