28 KiB
3.3 高阶位运算
一、这一节要学什么?
在上一节里,我们已经会了最基础的按位与、按位或、按位异或、取反、左移、右移。接下来要进入真正“能拿来做题”的部分。
这一节的重点不是再背几个符号,而是学会把位运算当成一套工具箱来使用。你会看到:
- 怎么只改某一位、不动别的位;
- 怎么一次性提取一段二进制位;
- 怎么快速找到“最低位的 1”;
- 怎么在
O(1)时间判断一个数是不是 2 的幂; - 怎么用异或找“只出现一次”的数;
- 怎么把“选或不选”压成一个整数,枚举所有方案。
如果说 3.2 还是“认识工具”,那 3.3 更像是“学会用工具解题”。
二、先介绍 unsigned
这一节我们主要讨论“非负整数”的位运算,所以在 C++ 里,优先推荐使用 unsigned 类型。
原因很简单:
- 它本来就不表示负数;
- 做位运算时,不容易被“符号位”干扰;
- 像右移这样的操作,课堂里更容易讲清楚。
1. unsigned int 是什么
unsigned int x;
它表示无符号整数,也就是只能存 0 和正整数,不能存负数。
在大多数 OJ 和常见编译环境里:
int通常是 32 位;unsigned int也通常是 32 位。
区别在于:
int要留一部分范围给负数;unsigned int把全部 32 位都拿来表示非负数。
所以常见范围可以记成:
int:大约-2^{31}到 $2^{31}-1$;unsigned int:0到 $2^{32}-1$。
如果课堂里讨论的是“32 位二进制串”,那么 unsigned int 非常合适。
2. unsigned long long 是什么
unsigned long long x;
它也是无符号整数,只是位数更大。常见环境里,它通常是 64 位。
所以常见范围可以记成:
unsigned long long:0到 $2^{64}-1$。
它适合下面这些场景:
- 位数可能超过 32 位;
- 要写
1 << k,而k可能比较大; - 需要把更长的二进制状态装进一个整数。
例如:
unsigned long long x = 1ull << 40;
这里如果你写成 1 << 40,就很危险,因为 1 默认是 int,位宽不够。写成 1ull 才表示“这是一个 64 位无符号整数 1”。
3. 什么时候用哪一个?
- 只讨论 32 位以内的普通位运算题:优先用
unsigned int。 - 可能涉及更高位,或者要安全地写较大的移位:优先用
unsigned long long。
课堂里可以先把这条经验记住:
32 位用
unsigned int,64 位用unsigned long long。
4. 输入和输出怎么写
unsigned int
unsigned int x;
cin >> x;
cout << x << '\n';
unsigned long long
unsigned long long x;
cin >> x;
cout << x << '\n';
和普通整数一样直接输入输出就可以。
5. 为什么这一节更推荐 unsigned
例如下面这个式子:
x >> 1
如果 x 是无符号数,我们就可以直接把它理解成“所有二进制位整体右移一格,左边补 0”。
如果 x 是有符号数,右移时很多语言还会涉及“符号扩展”,课堂理解会麻烦得多。
所以这一节讨论位运算技巧时,默认主要站在 unsigned 的角度来讲。
6. 顺便提一句 bitset
虽然这一节主线不用 bitset,但你以后看题解或写调试代码时,还是会经常见到它。
最常见的用法是:
#include <bitset>
#include <iostream>
using namespace std;
int main() {
bitset<8> bs(13); // 把十进制 13 转成 8 位二进制 00001101
cout << bs << '\n'; // 直接输出整串二进制
cout << bs.count() << '\n'; // 统计二进制里有几个 1
cout << bs[0] << '\n'; // 读取最低位
return 0;
}
它比较适合做这些事:
- 把一个整数的二进制形式直接打印出来;
- 快速观察某几位的变化;
- 写一些“看位”的小工具。
但是它也有很明显的缺陷,所以这节不把它当主角:
- 长度必须在编译时写死,不像数组或
vector那样灵活; - 像
x & (-x)、x & (x - 1)、x + 1、x - 1这类高阶技巧,本质上还是整数运算更直接; - 若频繁在
bitset和整数之间来回转换,代码会更绕; bs[0]是最低位,但输出时最高位在左,初学者很容易看反。
所以可以把它理解成一句话:
bitset适合“展示二进制”,不太适合替代这一节里的核心整数技巧。
三、先建立一个核心概念:掩码 mask
高阶位运算里最常见的词就是“掩码”。
你可以把掩码理解成一张“筛子”:
- 掩码某一位是 1,表示这一位要参与操作;
- 掩码某一位是 0,表示这一位尽量保持不动,或者被过滤掉。
例如,设
x = 11010110
mask = 00001111
那么:
x & mask = 00000110 // 只保留末 4 位
x | mask = 11011111 // 末 4 位全部变成 1
x ^ mask = 11011001 // 末 4 位全部翻转
x & ~mask = 11010000 // 末 4 位全部清零
所以,许多位运算题本质上都在做两件事:
- 先构造出一个合适的掩码。
- 再用
&、|、^、~去操作它。
这一节后面几乎所有公式,都可以看成是“掩码 + 位运算”的组合。
四、最常用的 4 个单点操作
下面默认:
- 右起最低位编号为第 0 位;
- 第
k位指的是右起第k位; - 用
unsigned int x表示一个 32 位状态。
设 x 是原数,那么:
1. 把第 k 位变成 1
x |= (1u << k);
原因:1u << k 只有第 k 位是 1,和 x 按位或以后,第 k 位一定变成 1,其余位不受影响。
2. 把第 k 位变成 0
x &= ~(1u << k);
原因:1u << k 只有第 k 位是 1,取反后只有第 k 位是 0。再和 x 按位与,第 k 位就会被清成 0,其余位保持不变。
3. 把第 k 位翻转
x ^= (1u << k);
原因:异或 1 会翻转,异或 0 保持不变。
4. 读取第 k 位是 0 还是 1
unsigned int bit = (x >> k) & 1u;
先把第 k 位移到最低位,再和 1 相与,只保留这一位。
演示
设:
x = 00101101
若 k = 3,那么第 3 位原本是 1。
1u << 3 = 00001000
x | (1u << 3) = 00101101 // 本来就是 1,不变
x & ~(1u << 3) = 00100101 // 第 3 位清零
x ^ (1u << 3) = 00100101 // 第 3 位翻转
(x >> 3) & 1 = 1
对应程序 1:单点位编辑器
下面程序支持 4 类操作:设置某位、清零某位、翻转某位、查询某位。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
unsigned int x;
int q;
cin >> x >> q; // 读入初始状态和操作次数
while (q--) {
string op;
int k;
cin >> op >> k; // 读入操作名和位编号
if (op == "SET") {
x |= (1u << k); // 把第 k 位设为 1
} else if (op == "CLEAR") {
x &= ~(1u << k); // 把第 k 位清成 0
} else if (op == "FLIP") {
x ^= (1u << k); // 把第 k 位翻转
} else if (op == "TEST") {
cout << ((x >> k) & 1u) << '\n'; // 输出第 k 位当前是 0 还是 1
}
}
cout << x << '\n'; // 输出最终状态的十进制值
return 0;
}
五、连续区间上的位操作
真正的题目里,很多时候不是只改 1 位,而是要改“连续一段”。这时就要学会构造“连续若干个 1”的掩码。
1. 保留末 k 位
x & ((1u << k) - 1)
因为:
1u << k = 1000...000 (1 后面 k 个 0)
(1u << k)-1 = 0111...111 (低 k 位全是 1)
例如:
x = 11010110
k = 4
(1u << 4) - 1 = 00001111
x & 00001111 = 00000110
2. 把末 k 位全部清零
x & ~((1u << k) - 1)
例如:
x = 11010110
mask = 00001111
~mask = 11110000
x & ~mask = 11010000
3. 取出第 l 到第 r 位这段二进制
假设 $0 \le l \le r$,并且位编号仍然从右往左、从 0 开始。
公式:
(x >> l) & ((1u << (r - l + 1)) - 1)
思路是两步:
- 先把第
l位移到最低位。 - 再保留低
(r-l+1)位。
例如:
x = 11010110
取第 2 到第 5 位
x >> 2 = 00110101
保留低 4 位 = 00000101
所以第 2 到第 5 位组成的数是 0101,即十进制 5。
竖式感受 1:为什么“右移再与掩码”能取出一段位
还是以 x = 11010110,取第 2 到第 5 位为例:
x = 1 1 0 1 0 1 1 0
x >> 2 = 0 0 1 1 0 1 0 1
mask = 0 0 0 0 1 1 1 1
------------------------
结果 = 0 0 0 0 0 1 0 1
从右往左看,原来第 2 到第 5 位的内容是 0101,右移后它被搬到了最低 4 位,再与上 1111,其他位就被全部滤掉了。
4. 用 y 替换 x 的第 l 到第 r 位
公式:
unsigned int updateBits(unsigned int x, unsigned int y, int l, int r) {
unsigned int mask = (1u << (r - l + 1)) - 1;
x &= ~(mask << l); // 先清空这一段
x |= ((y & mask) << l); // 再把 y 的低若干位填进去
return x;
}
例如:
x = 10000000000
y = 10101
l = 2, r = 6
替换后得到:
10001010100
这正是“更新二进制位”这类题的标准做法。
竖式感受 2:区间替换到底做了哪两步
设 x = 11101101,把第 1 到第 3 位替换成 y = 010。
x = 1 1 1 0 1 1 0 1
mask = 0 0 0 0 1 1 1 0
clear x = 1 1 1 0 0 0 0 1
y << 1 = 0 0 0 0 0 1 0 0
------------------------
结果 = 1 1 1 0 0 1 0 1
你可以把“区间替换”理解成两个连续动作:
- 先把目标区间擦干净;
- 再把新内容平移到对应位置后填进去。
对应程序 2:区间取位与区间替换
#include <bits/stdc++.h>
using namespace std;
unsigned int extractBits(unsigned int x, int l, int r) {
unsigned int mask = (1u << (r - l + 1)) - 1; // 低 len 位全是 1
return (x >> l) & mask; // 先右移,再保留低位
}
unsigned int updateBits(unsigned int x, unsigned int y, int l, int r) {
unsigned int mask = (1u << (r - l + 1)) - 1; // 先得到这一段的基础掩码
x &= ~(mask << l); // 把 x 的 [l, r] 区间清零
x |= ((y & mask) << l); // 把 y 的低若干位平移后填进去
return x; // 返回替换后的结果
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
unsigned int x, y;
int l, r;
cin >> x >> y >> l >> r; // 读入原数、新数和区间端点
cout << extractBits(x, l, r) << '\n'; // 输出取出的这一段
cout << updateBits(x, y, l, r) << '\n'; // 输出区间替换后的结果
return 0;
}
说明:上面程序默认
r - l + 1 < 32。如果可能用到更高位,建议把1u改成1ull,并把类型换成unsigned long long。
六、x & (-x):只保留最低位的 1
这是位运算里最经典的高阶技巧之一,也常被叫做 lowbit(x)。
公式:
x & (-x)
它的作用是:
只保留
x二进制中最右边的那个 1,其余位全部清零。
例子 1
x = 44 = 101100
x & (-x) = 000100 = 4
说明最低位的 1 在第 2 位,它的权值是 4。
例子 2
x = 72 = 1001000
x & (-x) = 0001000 = 8
竖式感受:x & (-x) 为什么只剩一个 1
以 8 位下 x = 44 为例:
x = 0 0 1 0 1 1 0 0
~x = 1 1 0 1 0 0 1 1
~x + 1 = 1 1 0 1 0 1 0 0 (这就是 -x 的补码)
------------------------
x & (-x) = 0 0 0 0 0 1 0 0
最右边那个 1 被单独保留下来,其余位都在按位与时被消掉了。
为什么成立?
设一个数的二进制长成这样:
x = ?????1000...000
也就是:
- 最低位的 1 左边是什么无所谓;
- 最低位的 1 右边全是 0。
那么它的相反数 -x 在补码里,会恰好保留这一位对应的权值,和 x 相与以后,就只剩下这一个 1。
你不必每次都重新推补码,记住结论即可:
unsigned int lowbit = x & -x;
在这里把 x 看成无符号整数,重点理解“二进制位模式”就够了。
lowbit 有什么用?
- 找最低位的 1;
- 判断某个数能被多少个 2 连续整除;
- 树状数组里跳步;
- 把一个状态拆成若干个单独的二进制位。
七、x & (x - 1):消掉最低位的 1
公式:
x & (x - 1)
它的作用是:
把
x最低位的那个 1 清掉,其他更高位尽量保持不变。
为什么成立?
设:
x = ?????1000...000
x - 1 = ?????0111...111
那么:
x & (x - 1) = ?????0000...000
也就是说,最低位的 1 被清掉了。
例子
x = 90 = 1011010
第一次:1011010 & 1011001 = 1011000 = 88
第二次:1011000 & 1010111 = 1010000 = 80
第三次:1010000 & 1001111 = 1000000 = 64
第四次:1000000 & 0111111 = 0000000 = 0
共做了 4 次才变成 0,所以 90 的二进制里有 4 个 1。
竖式感受:x - 1 会先改掉哪些位
以 8 位下 x = 90 = 01011010 为例:
x = 0 1 0 1 1 0 1 0
x - 1 = 0 1 0 1 1 0 0 1
------------------------
x & (x - 1) = 0 1 0 1 1 0 0 0
从右往左看,最低位那个 1 被清掉了,而它右边发生变化的部分在按位与后也全部消失,于是只剩“去掉最低位 1”后的结果。
用它统计 1 的个数
int popcount(unsigned int x) {
int cnt = 0;
while (x) {
x &= (x - 1); // 每次清掉一个最低位的 1
++cnt; // 记录一共清掉了多少次
}
return cnt;
}
循环几次,就有几个 1。
用它判断 2 的幂
如果一个正整数是 2 的幂,那么它的二进制里恰好只有一个 1。
所以:
x > 0 && (x & (x - 1)) == 0
成立,当且仅当 x 是 2 的幂。
例如:
8 = 1000,8 & 7 = 0,所以是 2 的幂;12 = 1100,12 & 11 = 8,不是 0,所以不是 2 的幂。
对应程序 3:lowbit、统计 1 的个数、判断 2 的幂
#include <bits/stdc++.h>
using namespace std;
unsigned int lowbit(unsigned int x) {
return x & (~x + 1u); // 只保留最低位的 1
}
int popcount(unsigned int x) {
int cnt = 0;
while (x) {
x &= (x - 1); // 每次消掉一个最低位的 1
++cnt; // 消掉几次,就说明原来有几个 1
}
return cnt;
}
bool isPowerOfTwo(unsigned int x) {
return x > 0 && (x & (x - 1)) == 0; // 正数且只含一个 1
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
unsigned int x;
cin >> x; // 读入一个非负整数
cout << lowbit(x) << '\n'; // 输出最低位 1 的权值
cout << popcount(x) << '\n'; // 输出二进制中 1 的个数
cout << (isPowerOfTwo(x) ? "YES" : "NO") << '\n'; // 输出是否为 2 的幂
return 0;
}
这里有两个细节值得注意:
lowbit(x)和x & (-x)是同一个意思;- 统计 1 的个数时,循环次数正好等于 1 的个数。
八、右边连续的 1 和连续的 0
很多位运算题,专门喜欢考“最右边连续的一段”。下面这些式子都很常见。
1. x & (x + 1):把右边连续的 1 清零
例如:
x = 10101111
x + 1 = 10110000
结果 = 10100000
也就是最右边那一串连续的 1 全没了。
竖式感受 1:x & (x + 1) 和 x | (x + 1)
以 x = 10101111 为例:
x = 1 0 1 0 1 1 1 1
x + 1 = 1 0 1 1 0 0 0 0
------------------------
x & (x + 1) = 1 0 1 0 0 0 0 0
x | (x + 1) = 1 0 1 1 1 1 1 1
所以:
x & (x + 1)会把最右边连续的 1 全清掉;x | (x + 1)会把右起第一个 0 变成 1。
2. x | (x + 1):把右起第一个 0 变成 1
例如:
x = 10101111
x + 1 = 10110000
结果 = 10111111
右起第一个 0 被点亮了,而它右边那一串 1 本来就是 1。
3. x | (x - 1):把右边连续的 0 变成 1
例如:
x = 10110000
x - 1 = 10101111
结果 = 10111111
于是最右边那一串 0 全被填成 1。
4. ~x & (x + 1):取出最低位的 0
例如:
x = 10101111
~x = 01010000
x + 1 = 10110000
结果 = 00010000
这表示“最右边的那个 0”的权值。
竖式感受 2:x | (x - 1) 和 ~x & (x + 1)
先看 x | (x - 1),取 x = 10110000:
x = 1 0 1 1 0 0 0 0
x - 1 = 1 0 1 0 1 1 1 1
------------------------
x | (x - 1) = 1 0 1 1 1 1 1 1
这说明右边连续的 0 被全部填成了 1。
再看 ~x & (x + 1),取 x = 10101111:
x = 1 0 1 0 1 1 1 1
~x = 0 1 0 1 0 0 0 0
x + 1 = 1 0 1 1 0 0 0 0
------------------------
~x & (x + 1) = 0 0 0 1 0 0 0 0
可以看到,最后只留下了最右边那个 0 对应的位置。
这些式子有什么用?
- 做状态转移时修改最右边某一段位;
- 写一些构造题;
- 做二进制规律观察题;
- 快速处理“连续 1”或“连续 0”。
先别急着死背,建议自己随手写几个二进制例子验证一下,印象会更深。
九、异或的高阶用法
异或在基础运算里看起来只是“不同为 1,相同为 0”,但它在算法里特别有力量,因为它满足下面这些重要性质:
a ^ a = 0a ^ 0 = a- 满足交换律和结合律
这意味着:
相同的数异或两次会被“抵消掉”。
1. 找出只出现一次的数
如果一个数组里,除了一个数外,其余每个数都出现两次,那么把所有数异或起来,最后剩下的就是那个只出现一次的数。
例如:
12 ^ 7 ^ 9 ^ 12 ^ 7 = 9
因为 12 ^ 12 = 0,7 ^ 7 = 0,只剩 9。
竖式感受:为什么异或能把成对元素消掉
还是看:
12 ^ 7 ^ 9 ^ 12 ^ 7
利用交换律和结合律,我们可以先把相同的数挪到一起:
12 ^ 7 ^ 9 ^ 12 ^ 7
= 12 ^ 12 ^ 7 ^ 7 ^ 9
= 0 ^ 0 ^ 9
= 9
所以异或在“找只出现一次的数”这类题里特别好用,因为成对元素会自动抵消。
代码:
int singleNumber(const vector<int>& a) {
int ans = 0;
for (int x : a) ans ^= x; // 成对元素会两两抵消
return ans; // 最后剩下的就是只出现一次的数
}
2. 找出两个只出现一次的数
如果数组里恰好有两个数只出现一次,其他数都出现两次,那么:
- 先异或全部元素,得到
xor_all = p ^ q; p和q至少有一位不同,所以xor_all至少有一位是 1;- 取出这最低位的 1:
low = xor_all & -xor_all; - 用这 1 位把所有数分成两组;
- 两组内分别异或,就能分别得到
p和q。
代码:
pair<int, int> twoSingles(const vector<int>& a) {
int xor_all = 0;
for (int x : a) xor_all ^= x; // 得到两个目标数的异或值
int low = xor_all & -xor_all; // 取出它们最低位上不同的那一位
int p = 0, q = 0;
for (int x : a) {
if (x & low) p ^= x; // 这一组中成对元素抵消后,剩下其中一个目标数
else q ^= x; // 另一组同理
}
if (p > q) swap(p, q); // 输出时按从小到大排列
return {p, q};
}
3. 不用 + 求两数之和
这也是一道经典题。
思路:
a ^ b相当于“不进位加法”;(a & b) << 1就是所有进位;- 不断把“当前和”和“进位”重新相加,直到没有进位。
代码:
int addWithoutPlus(int a, int b) {
while (b != 0) {
unsigned int carry = (static_cast<unsigned int>(a & b) << 1); // 先算出进位
a = a ^ b; // 再算不带进位的和
b = static_cast<int>(carry); // 下一轮继续把进位加进去
}
return a;
}
对应程序 4:异或工具箱
下面程序演示三个常见功能:单异常数、双异常数、无加号求和。
#include <bits/stdc++.h>
using namespace std;
int singleNumber(const vector<int>& a) {
int ans = 0;
for (int x : a) ans ^= x; // 成对出现的数会两两抵消
return ans; // 最后剩下的就是只出现一次的数
}
pair<int, int> twoSingles(const vector<int>& a) {
int xor_all = 0;
for (int x : a) xor_all ^= x; // 得到两个目标数的异或值
int low = xor_all & -xor_all; // 取出二者最低位上不同的那一位
int p = 0, q = 0;
for (int x : a) {
if (x & low) p ^= x; // 这一组里成对元素抵消,剩下其中一个目标数
else q ^= x; // 另一组同理
}
if (p > q) swap(p, q); // 方便输出时从小到大
return {p, q};
}
int addWithoutPlus(int a, int b) {
while (b != 0) {
unsigned int carry = (static_cast<unsigned int>(a & b) << 1); // 这一轮产生的进位
a = a ^ b; // 不进位加法
b = static_cast<int>(carry); // 下一轮继续把进位加进去
}
return a;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n; // 读入数组长度
vector<int> a(n);
for (int i = 0; i < n; ++i) cin >> a[i]; // 读入数组元素
cout << singleNumber(a) << '\n'; // 输出“只出现一次”的单个数
pair<int, int> ans = twoSingles(a);
cout << ans.first << ' ' << ans.second << '\n'; // 输出两个只出现一次的数
int x, y;
cin >> x >> y; // 再读入两个整数,演示无加号求和
cout << addWithoutPlus(x, y) << '\n';
return 0;
}
十、状态压缩:把“选或不选”压成一个整数
这已经是竞赛和算法里非常重要的一类位运算应用了。
1. 什么叫状态压缩?
如果有 n 个对象,每个对象只有两种状态:
- 选 / 不选
- 在 / 不在
- 开 / 关
那么就可以用一个整数的二进制位来表示整个状态。
例如有 4 个学生,编号为 0、1、2、3:
mask = 0101
表示:
- 第 0 位是 1,选了 0 号;
- 第 1 位是 0,没选 1 号;
- 第 2 位是 1,选了 2 号;
- 第 3 位是 0,没选 3 号。
2. 集合运算和位运算的对应关系
设 A、B 都是状态压缩后的整数,则:
- 并集:
A | B - 交集:
A & B - 对称差:
A ^ B - 差集(A 去掉 B):
A & ~B
3. 枚举所有子集
如果一共有 n 个元素,那么所有子集一共有 2^n 个,对应的状态正好就是:
for (int mask = 0; mask < (1 << n); ++mask) {
// mask 表示一种选法
}
每个 mask 都代表一种方案。
4. 枚举某个集合 S 的所有子集
这是更高阶、也更常考的一种写法:
for (int sub = S; ; sub = (sub - 1) & S) {
// sub 是 S 的一个子集
if (sub == 0) break;
}
它的作用是:
按从大到小的顺序,把
S的所有子集恰好枚举一遍。
演示
设:
S = 1101
那么它的子集会依次枚举出:
1101
1100
1001
1000
0101
0100
0001
0000
这些数正好都是 1101 的子集状态。
竖式感受:为什么 (sub - 1) & S 能继续跳到下一个子集
还是取 S = 1101,看前两步:
sub = 1 1 0 1
sub - 1 = 1 1 0 0
(sub - 1) & S = 1 1 0 0
继续下一步:
sub = 1 1 0 0
sub - 1 = 1 0 1 1
(sub - 1) & S = 1 0 0 1
可以发现:
sub - 1会先把最低位的 1 消掉;- 再和
S按位与,就会把不属于S的那些位全部抹掉; - 于是就自然跳到了下一个合法子集。
对应程序 5:用状态压缩枚举所有选法
下面是一个很典型的“子集枚举”程序。
题意模型:有 n 个活动,每个活动有一个得分,最多能选若干个,要求总分不超过上限 limit,问最大总分是多少。
为了方便演示,下面先假设 n <= 20。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, limit;
cin >> n >> limit; // 读入活动个数和分数上限
vector<int> a(n);
for (int i = 0; i < n; ++i) cin >> a[i]; // 读入每个活动的得分
int best = 0;
for (int mask = 0; mask < (1 << n); ++mask) {
int sum = 0;
for (int i = 0; i < n; ++i) {
if ((mask >> i) & 1) {
sum += a[i]; // 第 i 位是 1,说明第 i 个活动被选中
}
}
if (sum <= limit) {
best = max(best, sum); // 在不超上限的前提下更新最优答案
}
}
cout << best << '\n'; // 输出最大可行总分
return 0;
}
这个程序就是“状态压缩 + 枚举所有子集”的最经典入门模型。
十一、位运算中的常见坑
高阶位运算很强,但也很容易出错。下面这些坑必须提前知道。
1. 混合运算时一定加括号
例如:
a & b == 0
这不是你想的 (a & b) == 0,而是:
a & (b == 0)
正确写法必须是:
(a & b) == 0
2. 讨论纯位模式时,优先使用无符号数
unsigned int x;
这样可以避开一部分负数右移、符号扩展之类的问题。
3. 写掩码时尽量写 1u 或 1ull
例如:
1u << k
1ull << k
比 1 << k 更稳,因为 1 默认是 int,移位太高时可能出问题。
4. 不要拿 pow(2, k) 代替 1 << k
pow 是浮点运算,既慢又可能有精度问题。
如果你想得到 $2^k$,应直接写:
1u << k
5. k 不能乱取
如果是 32 位整数,k 最好控制在 0 到 30 或 31 的合理范围内。超出位宽去移位,会产生未定义行为或不符合预期的结果。
6. 异或交换知道即可,不建议滥用
a ^= b;
b ^= a;
a ^= b;
虽然能交换,但可读性差,还容易在 a 和 b 指向同一位置时出问题。正常写题,直接 swap(a, b) 更好。
7. unsigned 只适合讨论“非负数位模式”
如果题目本身涉及负数语义,比如有符号数大小比较、带符号右移效果,就不能简单地一股脑全换成 unsigned。
这一节之所以大量使用 unsigned,是因为重点在“观察位”“操作位”“利用二进制规律”。
8. unsigned long long 写移位更安全,但也不是无限大
1ull << k
确实比 1 << k 安全得多,但它仍然只有 64 位。若 k 取得太离谱,依然会出问题。
9. bitset 适合辅助观察,但不适合硬套到所有题里
如果你只是想把一个数的二进制打印出来,bitset 很方便;但如果题目核心是 lowbit、清最低位的 1、连续 0/1 的构造,这些操作直接用整数写通常更短、更清楚。
课堂里最容易犯的错,就是为了“看起来高级”而硬把整数题改写成 bitset 题,结果把本来简单的规律绕复杂了。
十二、课堂速查表
下面把这一节最常用的表达式放在一起,方便查阅。
| 表达式 | 含义 |
|---|---|
unsigned int x; |
适合 32 位无符号位运算 |
unsigned long long x; |
适合 64 位无符号位运算 |
| `x | (1u << k)` |
x & ~(1u << k) |
把第 k 位设为 0 |
x ^ (1u << k) |
把第 k 位翻转 |
(x >> k) & 1u |
读取第 k 位 |
x & ((1u << k) - 1) |
保留末 k 位 |
x & ~((1u << k) - 1) |
清空末 k 位 |
(x >> l) & ((1u << (r - l + 1)) - 1) |
取出第 l 到第 r 位 |
x & (-x) |
只保留最低位的 1 |
x & (x - 1) |
清掉最低位的 1 |
x > 0 && (x & (x - 1)) == 0 |
判断是否为 2 的幂 |
x & (x + 1) |
把右边连续的 1 清零 |
| `x | (x + 1)` |
| `x | (x - 1)` |
~x & (x + 1) |
取出最低位的 0 |
a ^ a = 0 |
相同的数异或会抵消 |
1u << k |
构造 32 位掩码中的单个 1 |
1ull << k |
构造 64 位掩码中的单个 1 |
十三、这一节学完后,应该会什么?
如果你已经能独立完成下面这些事,就说明这节真正学会了:
- 能说清
unsigned int和unsigned long long的区别与用法; - 能写出“设置某一位、清空某一位、翻转某一位、读取某一位”的代码;
- 能用掩码提取某一段二进制位;
- 能解释
x & (-x)和x & (x - 1)的含义; - 能写出判断 2 的幂、统计 1 的个数的程序;
- 能用异或解决“只出现一次”的题;
- 能把“选或不选”的问题写成状态压缩。
到这里,位运算就不再只是“会算”,而是真正进入“会用”的阶段了。