其实从学习 C 语言开始,我们或多或少都学过位运算。在学过算法、读过一些源码、也学过计算机组成原理之后,我越来越觉得位运算很巧妙。
基本操作 · 集合映射 · 状压状态设计,以及 11 条容易踩中的坑
代码环境 C++11(蓝桥杯考场为 Dev-C++ 5.11,不支持 C++17/20 写法)
1:总览:什么时候该想位运算
位运算不是一个「算法」,它是一层表示法。它的用途可以归纳成一句话:
用一个整数,表示一个集合。
然后所有集合运算,都变成一次位运算。
什么时候该想到它?最可靠的信号是数据范围里的暗号。
| 题目里出现 | 信号 | 用什么 |
|---|---|---|
| 元素个数 ≤ 20,要枚举「选哪些」 | 2²⁰ ≈ 100 万,正好能开一张状态表 |
位掩码表示集合 |
| 只有 26 个小写字母 | 2²⁶ 太大,但「出现过哪些字母」只要 26 位 |
mask 表示字符集合 |
| 状态是「已经访问过哪些点 / 拿走了哪些数」 | 集合本身就是状态 | 状压 DP |
| 问「有没有公共元素 / 是不是子集」 | 集合的比较 | 一次 & 搞定 |
一句话选型
看到 20 或 26 ,先想「是不是要用一个整数装一个集合」。 然后问:这个集合是要反复比较 ,还是只比一次?这决定了要不要预存。(其实有时候也可以用哈希表)
2:五个操作 + 一张能力表
2.1 一个int就是32个开关
25 的二进制 = 0000 0000 0000 0000 0000 0000 0001 1001
↑↑↑ ↑
bit4=1, bit3=1, bit0=1
约定:bit 0 是最右边那一位 (最低位),往左依次 bit 1、bit 2......
这一条是所有位运算的地基,也是最别扭的地方------写代码时脑子里要「从右往左数」。
2.2 一个单格操作1<<i
1 << i 就是一个「只有第 i 位是 1、其他位全是 0」的数,等于 2^i。
| i | 1 << i 的二进制 |
十进制 |
|---|---|---|
| 0 | 0001 |
1 |
| 1 | 0010 |
2 |
| 2 | 0100 |
4 |
| 3 | 1000 |
8 |
2.3 五个基本操作
| 想做什么 | 写法 | 说明 |
|---|---|---|
| 判断第 i 位是不是 1 | mask & (1 << i) |
结果是 0 或 1<<i,不是 0/1 |
| 置位(变 1) | `mask | (1 << i)` |
| 清位(变 0) | mask & ~(1 << i) |
~(1<<i) 是「只有第 i 位是 0」的反向笔刷 |
| 翻转 | mask ^ (1 << i) |
翻两次回原样(x ^ k ^ k = x) |
| 取最低位的那个 1 | mask & (-mask) |
当公式记,不用推(涉及补码) |
2.4 一张能力表
把注意力放在一位上,看三个符号分别能和 0 / 1 干出什么:
| 符号 | 和 1 运算 | 和 0 运算 | 它的能力 |
|---|---|---|---|
& |
x & 1 = x(保不住) |
x & 0 = 0 |
能强制变 0 |
| ` | ` | `x | 1 = 1` |
^ |
x ^ 1 = ~x |
x ^ 0 = x |
能翻转 |
想「变 1」用 | 想「变 0」用 & 想「翻转」用 ^。
有了这张表,另两个操作就能自己推出来:
- 判断某一位 ------ 本质是「把其他位全清掉,只留目标位」。要「变 0」,所以用
&。 - 把某一位设成 1 ------ 要「变 1」,所以用
|。
而这两个恰好是最容易写反的一对。& 和 | 互换之后,代码不报错,但行为完全变了:
| 写反的方式 | 实际行为 |
|---|---|
| 判断位用了 ` | return mask |
置位用了 & mask = mask & (1<<i); |
会把其他位全清 0 → 不加反而删 :0b1010 置第 3 位后变成 0b1000,bit1 被抹掉了 |
所以这张能力表值得记住------它是判断「该用哪个符号」的唯一依据,比死记四个操作可靠。
3:集合运算 → 位运算
把「集合」压成 mask 之后,集合运算全部退化成一次位运算。这张表值得背:
| 集合运算 | 位运算 | 读法 |
|---|---|---|
| 并集 | `A | B` |
| 交集 | A & B |
两边都是 1 才是 1 |
| 差集 | A & ~B |
从 A 里去掉 B 的元素 |
| A 是 B 的子集 | (A & B) == A |
& 会把 A 里「B 没有的位」清掉;清完没变就说明是子集 |
| A、B 不相交 | (A & B) == 0 |
交集为空 |
| A 是空集 | A == 0 |
一个元素都没有 |
3.1 子集判断不是万能的
一个常见的误解是:判子集就该一直用 (A & B) == A。实际上要看集合被比较几次:
| 场景 | 更好的写法 | 为什么 |
|---|---|---|
每个集合只比一次 (LC 1684:判断每个单词的字符是否都在 allowed 里) |
逐字符检查 + break |
一发现不合法的字符就退出,不用看完整个单词;而子集判据必须把整个单词压成 mask 才能判 |
| 所有 pair 都要比 (LC 318:找两个没有公共字母的单词) | 预压成 mask | n 个单词要比较 n² 对。逐字符比是 O(n² × 26);预压之后每对只要一次 & → O(L + n²) |
判据
比较次数多到「不值得每次重算集合」时,就预压成 mask。 只比一次的话,「逐字符 + 早退出」反而更快。
3.2 LC318的完整写法
题目 :找两个没有公共字母的不同单词,使长度之积最大。
cpp
class Solution {
public:
int maxProduct(vector<string>& words) {
int n = words.size();
// ① 预压:每个单词 → 一个 26 位的 mask
vector<int> m(n, 0);
for (int i = 0; i < n; i++)
for (char c : words[i]) m[i] |= 1 << (c - 'a');
int ret = 0;
for (int i = 0; i < n; i++)
for (int j = i + 1; j < n; j++)
if ((m[i] & m[j]) == 0) // ② 交集为空 = 没有公共字母
ret = max(ret, (int)(words[i].size() * words[j].size()));
return ret; // ③ 长度直接取 size()
}
};
这段代码里有三处高频错误,都收进了后面的清单:
- 把
&写成|(并集 vs 交集) - 想用「数 mask 里 1 的个数」当长度------重复字母会丢 (
"aaa"长度 3,但只有 1 个 1) - 判断语句没加括号,撞上优先级问题(见清单第 9 条)
4:位掩码当状态:一道题的完整拆解(看不懂可以先看5)
LC 464 我能赢吗 :从 1..maxChoosableInteger 里轮流取数(取过不能再取),谁先让累计和 ≥ desiredTotal 谁赢,问先手是否必胜。
这道题要处理四件事:
| 要处理的 | 在这道题里 |
|---|---|
| 状态怎么表示 | 哪些数已经被拿走 → 一个整数 mask |
| 状态怎么变 | 拿走一个没拿过的数 i → `mask |
| 什么时候算赢 | 拿走 i 之后总和够到目标 → 这一步就赢;否则交给对手 |
| 怎么省重复计算 | memo[mask]:同一个局面只算一次 |
其中只有第一项是位运算的部分,其余三项都是普通的记忆化搜索写法。下面分四小节展开,重点在第一项:状态怎么定。
顺带说一个反直觉的结论:难度标签(简单 / 中等 / 困难)衡量的不是「涉及多少知识点」,而是「思路从零想出来有多难」。 LC 464 官方是 Medium,因为它有一个很明确的信号(maxChoosableInteger ≤ 20)提示用状压,拿到信号之后剩下的都是固定套路。
真正 Hard 的题,难在「有一个想不到的转化」。比如 LC 887 鸡蛋掉落,关键是把问题反过来说:「k 个鸡蛋试 t 次,最多能覆盖几层楼」------ 这个转化想不到就没办法做。
4.1 状态怎么定
先不要想「用什么表示」,先问:往后会发生什么,由什么决定?
| 候选信息 | 会影响往后吗 | 结论 |
|---|---|---|
| 已拿走的数的顺序 | 不会 | 不进状态 |
| 谁拿的 | 不会(「轮到谁」由「拿了几个」决定) | 不进状态 |
| 当前总和 sum | 它不是独立信息 ------ 它是「哪些数被拿走了」的函数,可以直接算出来 | 不进状态 |
| 哪些数被拿走了 | 会。它决定了剩下能拿什么、还差多少 | 这就是状态 |
判断标准:把候选信息分成两类 ------
「必须交代的前提 」→ 进状态;「能被算出来的答案」→ 不进状态。
sum 属于第二类。而且它不进状态不只是写法问题:如果状态写成 (mask, sum),记忆化表要开 2²⁰ × 300 ≈ 3 亿格,直接爆内存。
4.2 递归函数表示什么
dfs(mask)= 从 mask 这个局面出发,当前要动手的那个人能不能必胜
这句话有三个直接推论:
| 推论 | 为什么 |
|---|---|
| 不需要「轮到谁」这个参数 | 函数描述的是「当前要动手的人」------ 谁动手,它就在说谁。两个人轮流用同一个函数,函数体一个字都不用改 |
不需要 sum 参数 |
它是 mask 的函数,在函数里现算就行 |
dfs(新局面) 拿到的是对手的结论 |
拿走一个数,局面变成 `mask |
第三条直接决定了那个 !:
对手「不能必胜」 == 我「必胜」
这个 ! 不是技巧,是上面那句定义的直接结果。 (对比一下:LC 486 用「分数」表示,写的是 x − 对手的分差;这道题用「胜负」表示,写的是 !dfs(...)。同一个意思,两种写法。)
4.3 完整代码
cpp
class Solution {
int m, target;
vector<int> memo; // memo[mask]:-1 没算过 / 0 输 / 1 赢
public:
bool canIWin(int maxChoosableInteger, int desiredTotal) {
m = maxChoosableInteger, target = desiredTotal;
memo.assign(1 << (m + 1), -1); // 入口复位;位号用 1..m,所以开 m+1 位
if (target <= 0) return true; // 前提一
if ((long long)m * (m + 1) / 2 < target) return false; // 前提二
return dfs(0); // 起始局面:一个数都没拿
}
bool dfs(int mask) {
if (memo[mask] != -1) return memo[mask]; // ① 查表
int sum = 0; // ② sum 从 mask 推出来
for (int i = 1; i <= m; i++)
if (mask & (1 << i)) sum += i;
for (int i = 1; i <= m; i++) { // ③ 试每一个还没拿的数
if (mask & (1 << i)) continue;
if (sum + i >= target) // 分支 A:这一手就赢
return memo[mask] = 1;
if (!dfs(mask | (1 << i))) // 分支 B:对手赢不了 → 我赢
return memo[mask] = 1;
}
return memo[mask] = 0; // ④ 都赢不了
}
};
4.4 两个前提
| 前提 | 结论 | 为什么 |
|---|---|---|
target <= 0 |
true |
开局总和就是 0,已经达标 → 先手直接赢 |
1+2+...+m < target |
false |
全部数字加起来都够不到目标 → 这局没人能赢(平局) |
第二个前提为什么必须写
转移里有「对手赢不了 ⇒ 我赢 」,而平局时对手确实赢不了 ------ 会被误判成「我赢」。
实测:漏掉这一条,
m=1、T=2..13这一整片的结论都会反过来。
5:学习顺序:零件->半步->综合
上面这些内容,按「零件 → 半步 → 综合」三层来学,会比直接啃综合题快很多。
① 零件 把知识点拆成最小的操作,逐个写、逐个验证
位运算的零件:判断 / 置位 / 清位 / 翻转 / 数 1 的个数 / 打印二进制
② 半步 只综合【本次学的零件】,不引入任何旧知识
位运算的半步:用 mask 打印所有子集(不涉及 DP、记忆化、博弈)
③ 综合 零件 + 旧知识
LC 464 = 位掩码 + 记忆化 + 博弈
只做 ① 和 ③ 的话,最容易卡在 ③。因为「会写零件」和「能把零件拼成一道题」是两种不同的能力,中间还差一层。
5.1 第一步:把五个操作数写成函数
位运算的零件就是 2.3 节那五个操作。要求很简单:每个都单独写成一个函数,并且单独验证。
cpp
void printBinary(int x, int bitsize = 32); // 把一个数打成二进制
bool getBit(int mask, int i); // 判断第 i 位
void setBit(int& mask, int i); // 第 i 位置 1
void clearBit(int& mask, int i); // 第 i 位清 0
void flipBit(int& mask, int i); // 第 i 位翻转
int countOnes(unsigned mask); // 数有几个 1
其中 printBinary 看起来最简单,但最值得先写 ------ 后面每个操作都要用它来核对结果。
cpp
#pragma once
#include <iostream>
using namespace std;
//打印2进制
void printBinary(int x, int bitsize = 32)
{
for (int i = bitsize-1; i >= 0; i--) {
cout << ((x >> i) & 1);
if (i % 4 == 0 && i > 0) cout << ' ';
}
cout << endl;
}
//判断mask的第i位是不是1
//从低位到高位
bool getBit(int mask, int i)
{
return mask & (1 << i);
}
//把mask的第i位设成1
void setBit(int& mask, int i)
{
mask = mask | (1 << i);
}
void cleanBit(int& mask, int i)
{
mask = mask & ~(1 << i);
}
// 把 mask 的第 i 位翻转(0→1,1→0)
void flipBit(int& mask, int i)
{
mask = mask^(1 << i);
}
// 数一数 mask 里有几个 1
int countOnes(unsigned int mask)
{
int count = 0;
while (mask)
{
if ((1 & mask ) == 1)
{
count++;
}
mask >>= 1;
}
return count;
}
5.2 第二步:先用mask打印所有子集
五个操作都写熟之后,直接来做 LC 464,可能还是没法下手。这时候缺的不是再讲一遍状态设计,而是一个不涉及 DP、不涉及记忆化、不涉及博弈的小程序。
最合适的就是:打印 n 个元素的全部子集。
元素编号 0、1、2 一个 mask 就代表一个子集
mask = 0 000 { }
mask = 1 001 { 0 }
mask = 2 010 { 1 }
mask = 3 011 { 0, 1 }
mask = 4 100 { 2 }
mask = 5 101 { 0, 2 }
mask = 6 110 { 1, 2 }
mask = 7 111 { 0, 1, 2 }
程序本身只有两层循环:
cpp
// 把一个数字翻译成"选中了哪些元素"
// mask : 那个数字(比如 5)
// n : 一共有几个元素,编号 0 ~ n-1(比如 3)
void printSet(int mask, int n)
{
cout << "{ ";
for (int i = 0; i < n; i++) // 逐位检查:第 0 位 → 第 1 位 → ... → 第 n-1 位
{
if (getBit(mask, i)) // getBit 返回非 0 → 那一位是 1 → 元素 i 被选中
{
cout << i << " ";
}
}
cout << "}" << endl;
}
为什么这 6 行值得单独练
它正好是状压 DP 的前半截 :
· 外层 for (mask ...) = 把所有 2ⁿ 个局面走一遍
· 内层 mask & (1<<i) = 从一个局面里读出「现在是什么情况」
剩下的(做决策 + 存结果)才是 DP。先把这半截跑通,「mask 就是一个集合」就清楚了。
做完这张表之后,LC 464 剩下的只是:在每个局面上试每个选择、把结果缓存起来 ------ 而这部分就是普通的记忆化搜索写法。
5.3 三层的关系
| 层 | 内容 | 做到什么程度算过 |
|---|---|---|
| ① 零件 | 单个操作,不涉及任何算法 | 能不看资料写出来,并且能自己造用例验证 |
| ② 半步 | 只综合本次零件的小程序 | 不引入旧知识;输出的结果能肉眼核对 |
| ③ 综合 | 零件 + 旧知识 | 如果卡住,先回到 ②,不要继续硬啃 ③ |
另外,动手做综合题之前先列零件清单:
这道题需要几个零件?其中几个是没见过的?
没见过 ≥ 2 个 → 先拆开练零件,不要直接做。
6:易错清单
以下 11 条全部来自实测,附错误代码、后果、修正。它们的共同点是:代码都能编译,有的甚至能通过官方样例。
6.1 算了但没接住
第 1 条 x >> 1; 单独成句 = 什么都没干
cpp
while (x) {
cout << (x & 1);
x >> 1; // ← 算了一下,然后扔掉
}
实测 x 做完三次还是原来的值 → 死循环 。要改 x 就必须写 x >>= 1;。
用 -Wall 编译会直接报 statement has no effect ------ 这个错不该留到运行期。
这个错在两类任务里都会出现(打印二进制、数 1 的个数)。值得变成一个条件反射:
写
x >> 1、x + 1、x | y这种单独成句的时候,先问一句 ------「是改 x,还是只看一眼?」
要改 → 必须有
=;只看 → 结果必须被用掉。
6.2 符号优先级类型
| # | 错误 | 后果 / 修正 |
|---|---|---|
| 2 | & 和 ` |
` 用反 |
| 3 | 翻转多包一层 ~ mask = ~(mask ^ (1<<i)) |
^ 本身就是「只翻转第 i 位」;外面再套 ~ 会把其他 31 位也翻一遍 。实测 10 翻转 bit0 得到 -12(正确是 11),16 位下显示 1111 1111 1111 0100 |
| 4 | 取位时多移一位 if ((1 & (mask >> 1)) == 1) |
循环末尾已经有 mask >>= 1 在推进,判断里再 >> 1 就永远看不到 bit0 。实测 9 个用例错 5 个,规律是「只要 bit0 是 1 就少算一个」。 修正:if (mask & 1) |
| 5 | 数 1 的个数时传入负数 countOnes(-1) 死循环 |
负数右移是算术右移 ,符号位一直补 1,-1 >> 1 还是 -1。 修正:参数改 unsigned(实测 countOnes(-1) = 32) |
| 6 | 类内成员用 () 给初值 vector<vector<int>> memo(21, ...); |
编译不过 :error: expected identifier before numeric constant。C++ 会把它当成函数声明 。类内只能写 = 或 {};最稳的做法是成员只声明,在入口 assign |
| 7 | memset(memo, INT_MIN, sizeof(memo)) |
memset 是按字节 填的,INT_MIN 的最低字节是 0x00 → 实测填出 0 0 0 0。只对「每个字节都一样」的值安全 :0x00(=0)、0xFF(=-1)、0x3F。其他初值用循环或 vector 构造函数 |
| 8 | 成员容器没在入口复位 | 同一个对象连续调用时会读到上一轮的残留值。实测:一个「返回所有子集」的函数,第二次调用得到 10 个结果(应 2 个);一个「算博弈分差」的函数连调 2000 次,有 1237 次(62%)算错 。 力扣每题新建对象,所以提交页面上看不到这个错,但本地多测 / 面试 / 考场一定会出问题。这个错在多道题里都出现过 |
6.3 逻辑与语义
| # | 错误 | 后果 / 修正 |
|---|---|---|
| 9 | 优先级问题 `if (m[i] | m[j] == 0)` |
| 10 | 把 mask 当成 sum mask + i >= target |
mask 是「位模式」,不是「那些位代表的数字之和」。实测 mask = 0b0110 时,mask 的数值是 6,但已拿数字之和是 3 。 修正:单独一个循环先算 sum |
6.4 「能过但不对」
第 11 条 mask += i ------ 能 AC,但只是碰巧
int mask = 0;
for (int i = 0; i < pow(2, n); i++) {
mask += i; // ← 想要的是 mask = i
check(mask, nums);
}
mask += i 得到的是三角数 0, 1, 3, 6, 10, 15, 21, 28, ...,不是 0..2ⁿ-1。
但它居然能 AC。 实测:T_i mod 2ⁿ 恰好是 0..2ⁿ-1 的一个排列 (n=1..16 全部成立),而 mask & (1<<i) 只看低 n 位 ------ 等价于取模 ,所以结果集合完全正确。
(改成 mask += 1 同样能 AC,因为 1,2,...,2ⁿ-1,0 的低 n 位还是一个排列。)
代码里「说不清为什么对、但结果对」的部分,是隐患。
只要循环起点一改、检查方式一改、范围一改,立刻全错,而且不知道该往哪查。
「能过」不是标准,「能说清它为什么对」才是标准。
同类案例:拓扑排序里有向边建反了,但「有没有环」这个判断对方向不敏感 ,所以照样 AC。换一道要求输出具体顺序的题,同一张反图 1651 个无环用例里 997 个(60%)非法。
这说明:**验证模型的办法,是拿去解要求更细的姊妹题。**只跑官方示例不算验证。
7:总结
7.1 四种用法
把「用 mask 表示集合」的四种用法排一下,基本就覆盖完了:
| 题 | mask 在里面是什么 |
|---|---|
| LC 464 | 状压 DP:状态就是一个集合(哪些数被拿走了)+ 博弈 |
| LC 78 | mask 就是子集本身(枚举所有 2ⁿ 个) |
| LC 1684 | 表示字符集合,只比一次 → 逐字符检查更优 |
| LC 318 | 表示字符集合,反复两两比较 → 必须先压好存起来 |
一句话总括:
集合 → 一个整数;集合运算 → 一次位运算;要反复比较 → 先压成 mask 存起来。
7.2 写完代码的自查清单
- 编译开
-Wall------x >> 1;那种「算了没用」的错,编译期就能抓。 - 位运算和比较混用时加括号 ------ 永远写
if ((a & b) == 0),别省。 - 成员容器在入口复位 ------
vector/string/ 数组当返回值或缓存时,第一件事就是clear()/assign()。 - 哨兵值要选「不可能是合法答案」的 ------ 答案是 0/1 时,哨兵就不能是 0。
- 跑随机对拍,不只跑官方示例 ------ 而且要跟一份「写法不同」的实现对拍。
- 问一句:这一句「为什么对」,说得清吗? ------ 说不清的部分就是隐患。