拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

位运算:从一个整数到一个集合

位运算:从一个整数到一个集合

其实从学习 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。

i1 << i的二进制十进制
000011
100102
201004
310008

2.3 五个基本操作

想做什么写法说明
判断第 i 位是不是 1mask & (1 << i)结果是0或1<<i,不是 0/1
置位(变 1)mask | (1 << i)只动第 i 位,其他位不变
清位(变 0)mask & ~(1 << i)~(1<<i)是「只有第 i 位是 0」的反向笔刷
翻转mask ^ (1 << i)翻两次回原样(x ^ k ^ k = x)
取最低位的那个 1mask & (-mask)当公式记,不用推(涉及补码)

2.4 一张能力表

把注意力放在一位上,看三个符号分别能和 0 / 1 干出什么:

符号和1运算和0运算它的能力
&x & 1 = x(保不住)x & 0 = 0能强制变 0
|x | 1 = 1x | 0 = x(保不住)能强制变 1
^x ^ 1 = ~xx ^ 0 = x能翻转

想「变 1」用|想「变 0」用&想「翻转」用^。

有了这张表,另两个操作就能自己推出来:

  • 判断某一位—— 本质是「把其他位全清掉,只留目标位」。要「变 0」,所以用&。
  • 把某一位设成 1—— 要「变 1」,所以用|。

而这两个恰好是最容易写反的一对。&和|互换之后,代码不报错,但行为完全变了:

写反的方式实际行为
判断位用了|
return mask | (1<<i);
mask | (1<<i)永远不为 0 → 转 bool 恒为true→问哪一位都回答「是 1」
置位用了&
mask = mask & (1<<i);
会把其他位全清 0 →不加反而删:0b1010置第 3 位后变成0b1000,bit1 被抹掉了

所以这张能力表值得记住——它是判断「该用哪个符号」的唯一依据,比死记四个操作可靠。

3:集合运算 → 位运算

把「集合」压成 mask 之后,集合运算全部退化成一次位运算。这张表值得背:

集合运算位运算读法
并集A | B两边有一个是 1 就是 1
交集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:找两个没有公共字母的单词)
预压成 maskn 个单词要比较 n² 对。逐字符比是O(n² × 26);预压之后每对只要一次&→O(L + n²)

判据

比较次数多到「不值得每次重算集合」时,就预压成 mask。只比一次的话,「逐字符 + 早退出」反而更快。

3.2 LC318的完整写法

题目:找两个没有公共字母的不同单词,使长度之积最大。

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 | (1<<i)
什么时候算赢拿走 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 | (1<<i),此时轮到对手动手。所以那个返回值说的是「对手能不能赢」

第三条直接决定了那个!:

对手「不能必胜」 == 我「必胜」

这个!不是技巧,是上面那句定义的直接结果。(对比一下:LC 486 用「分数」表示,写的是x − 对手的分差;这道题用「胜负」表示,写的是!dfs(...)。同一个意思,两种写法。)

4.3 完整代码

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 <= 0true开局总和就是 0,已经达标 → 先手直接赢
1+2+…+m < targetfalse全部数字加起来都够不到目标 → 这局没人能赢(平局)

第二个前提为什么必须写

转移里有「对手赢不了 ⇒ 我赢」,而平局时对手确实赢不了—— 会被误判成「我赢」。
实测:漏掉这一条,m=1、T=2..13这一整片的结论都会反过来。

5:学习顺序:零件->半步->综合

上面这些内容,按「零件 → 半步 → 综合」三层来学,会比直接啃综合题快很多。

① 零件 把知识点拆成最小的操作,逐个写、逐个验证 位运算的零件:判断 / 置位 / 清位 / 翻转 / 数 1 的个数 / 打印二进制 ② 半步 只综合【本次学的零件】,不引入任何旧知识 位运算的半步:用 mask 打印所有子集(不涉及 DP、记忆化、博弈) ③ 综合 零件 + 旧知识 LC 464 = 位掩码 + 记忆化 + 博弈

只做 ① 和 ③ 的话,最容易卡在 ③。因为「会写零件」和「能把零件拼成一道题」是两种不同的能力,中间还差一层。

5.1 第一步:把五个操作数写成函数

位运算的零件就是 2.3 节那五个操作。要求很简单:每个都单独写成一个函数,并且单独验证。

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看起来最简单,但最值得先写 —— 后面每个操作都要用它来核对结果。

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

程序本身只有两层循环:

// 把一个数字翻译成"选中了哪些元素" // 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;单独成句 = 什么都没干

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&和|用反判断位用|→ 恒为真;置位用&→ 不加反删。回到能力表:变 1 用|,变 0 用&
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
7memset(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)
&、|、^的优先级都低于==、!=、<、>。这句会被解析成m[i] | (m[j] == 0),基本恒为真。
修正: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 78mask就是子集本身(枚举所有 2ⁿ 个)
LC 1684表示字符集合,只比一次→ 逐字符检查更优
LC 318表示字符集合,反复两两比较→ 必须先压好存起来

一句话总括:

集合 → 一个整数;集合运算 → 一次位运算;要反复比较 → 先压成 mask 存起来。

7.2 写完代码的自查清单

  1. 编译开-Wall——x >> 1;那种「算了没用」的错,编译期就能抓。
  2. 位运算和比较混用时加括号—— 永远写if ((a & b) == 0),别省。
  3. 成员容器在入口复位——vector/string/ 数组当返回值或缓存时,第一件事就是clear()/assign()。
  4. 哨兵值要选「不可能是合法答案」的—— 答案是 0/1 时,哨兵就不能是 0。
  5. 跑随机对拍,不只跑官方示例—— 而且要跟一份「写法不同」的实现对拍。
  6. 问一句:这一句「为什么对」,说得清吗?—— 说不清的部分就是隐患。
返回列表