二进制枚举是算法竞赛中一种高效的状态表示方法,特别适合处理元素只有两种状态(选/不选、开/关等)且规模较小的问题。我第一次接触这个概念是在准备蓝桥杯比赛时,当时就被它简洁而强大的表达能力震撼了。今天我们就通过两个经典案例,深入剖析这种算法的核心思想与实现技巧。
子集问题(LeetCode 78题)要求给定一个不含重复元素的整数数组nums,返回所有可能的子集。这是理解二进制枚举最直观的入门题。
当数组长度为n时,子集总数正好是2^n个——这正是二进制能够完美表示的范围。每个子集可以看作一个n位的二进制数,其中第i位为1表示包含nums[i],为0则不包含。
例如nums = [1,2,3]:
cpp复制vector<vector<int>> subsets(vector<int>& nums) {
vector<vector<int>> ret;
int n = nums.size();
for(int st = 0; st < (1 << n); st++) { // 遍历所有状态
vector<int> tmp;
for(int i = 0; i < n; i++) {
if((st >> i) & 1) tmp.push_back(nums[i]); // 检查第i位
}
ret.push_back(tmp);
}
return ret;
}
关键点说明:
1 << n等价于2^n,表示状态总数(st >> i) & 1通过右移和按位与操作检查特定位是否为1- 时间复杂度O(n*2^n),空间复杂度O(n)(不考虑输出存储)
位运算优先级:位运算的优先级常常出问题,建议多用括号明确意图。例如(st>>i)&1比st>>i&1更安全。
小优化:当n较大时(虽然枚举法通常n≤20),可以用__builtin_popcount(st)快速获取1的个数,提前判断是否满足条件。
调试技巧:打印二进制状态时,可以使用bitset<10>(st).to_string()方便查看位模式。
洛谷P10449的"费解的开关"将问题提升到了二维层面。在一个5x5的灯阵中,每次切换一个灯会同时改变其上下左右相邻灯的状态,求用最少步骤使所有灯亮起。
这个问题的精妙之处在于:
cpp复制int solve() {
int ret = INF;
for(int st = 0; st < (1<<5); st++) { // 枚举第一行操作
memcpy(t, a, sizeof a);
int cnt = count_ones(st); // 当前操作次数
for(int i = 0; i < 4; i++) {
// 根据上一行状态决定本行操作
int push = t[i];
cnt += count_ones(push);
// 应用状态变化
t[i+1] ^= push;
t[i] = 0;
if(i > 0) t[i-1] ^= push;
}
if(t[4] == 0) ret = min(ret, cnt);
}
return ret > 6 ? -1 : ret;
}
状态表示:每行用一个int的二进制位表示灯的状态(0亮1灭)
位运算技巧:
t[i] ^= pusht[i] & ((1<<5)-1) 屏蔽高位干扰优化点:
常见错误警示:
- 忘记重置临时数组(每组测试用例需要清空)
- 位运算优先级混淆导致逻辑错误
- 没有处理最后一行验证(必须t[4]==0才是有效解)
通过上述两个案例,我们可以总结出二进制枚举的通用解题模式:
满足以下特征的问题适合用二进制枚举:
状态枚举:
cpp复制for(int st=0; st<(1<<n); ++st)
状态解码:
cpp复制if((st>>i)&1) // 第i位是否设置
结果收集:
cpp复制results.push_back(current)
剪枝策略:
位运算加速:
__builtin_popcount对称性利用:
当元素有k种状态时(k>2),可以采用:
例如三状态问题可以用两个二进制位表示:
当需要枚举特定大小的子集时:
cpp复制// 枚举恰好包含k个元素的子集
int st = (1<<k)-1;
while(st < (1<<n)) {
// 处理当前状态
int x = st & -st;
int y = st + x;
st = ((st & ~y)/x >>1) | y;
}
对于规模稍大的问题(n≈40),可以采用meet-in-the-middle技术:
在算法竞赛中应用二进制枚举时,这些经验可能帮你节省大量时间:
调试打印:编写一个打印二进制状态的工具函数,例如:
cpp复制void print_bin(int x, int n) {
for(int i=n-1; i>=0; --i)
cout << ((x>>i)&1);
cout << endl;
}
常见陷阱:
模板代码:准备一些常用操作的代码片段:
cpp复制// 统计1的个数
int popcount(int x) {
int cnt = 0;
while(x) { cnt++; x &= x-1; }
return cnt;
}
// 最低位1的位置
int lowbit(int x) { return x & -x; }
性能评估:在编写代码前先估算时间复杂度:
二进制枚举虽然看似简单,但在算法竞赛中有着举足轻重的地位。掌握这种技术不仅能解决特定类型的问题,更能培养对状态压缩的敏感度,为学习更高级的算法(如状态压缩DP)打下坚实基础。建议读者在理解本文示例后,尝试解决POJ 3279(另一个经典的开关问题变种)来巩固学习成果。