Files
hustoj/web/doc/3.3 高阶位运算.md

28 KiB
Raw Blame History

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 int0 到 $2^{32}-1$。

如果课堂里讨论的是“32 位二进制串”,那么 unsigned int 非常合适。

2. unsigned long long 是什么

unsigned long long x;

它也是无符号整数,只是位数更大。常见环境里,它通常是 64 位。

所以常见范围可以记成:

  • unsigned long long0 到 $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 int64 位用 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 + 1x - 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 位全部清零

所以,许多位运算题本质上都在做两件事:

  1. 先构造出一个合适的掩码。
  2. 再用 &|^~ 去操作它。

这一节后面几乎所有公式,都可以看成是“掩码 + 位运算”的组合。


四、最常用的 4 个单点操作

下面默认:

  • 右起最低位编号为第 0 位;
  • k 位指的是右起第 k 位;
  • unsigned int x 表示一个 32 位状态。

x 是原数,那么:

1. 把第 k 位变成 1

x |= (1u << k);

原因:1u << k 只有第 k 位是 1x 按位或以后,第 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)

思路是两步:

  1. 先把第 l 位移到最低位。
  2. 再保留低 (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

你可以把“区间替换”理解成两个连续动作:

  1. 先把目标区间擦干净;
  2. 再把新内容平移到对应位置后填进去。

对应程序 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 = 10008 & 7 = 0,所以是 2 的幂;
  • 12 = 110012 & 11 = 8,不是 0所以不是 2 的幂。

对应程序 3lowbit、统计 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 全没了。

竖式感受 1x & (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”的权值。

竖式感受 2x | (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 = 0
  • a ^ 0 = a
  • 满足交换律和结合律

这意味着:

相同的数异或两次会被“抵消掉”。

1. 找出只出现一次的数

如果一个数组里,除了一个数外,其余每个数都出现两次,那么把所有数异或起来,最后剩下的就是那个只出现一次的数。

例如:

12 ^ 7 ^ 9 ^ 12 ^ 7 = 9

因为 12 ^ 12 = 07 ^ 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. 找出两个只出现一次的数

如果数组里恰好有两个数只出现一次,其他数都出现两次,那么:

  1. 先异或全部元素,得到 xor_all = p ^ q
  2. pq 至少有一位不同,所以 xor_all 至少有一位是 1
  3. 取出这最低位的 1low = xor_all & -xor_all
  4. 用这 1 位把所有数分成两组;
  5. 两组内分别异或,就能分别得到 pq

代码:

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. 集合运算和位运算的对应关系

AB 都是状态压缩后的整数,则:

  • 并集:A | B
  • 交集:A & B
  • 对称差:A ^ B
  • 差集A 去掉 BA & ~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. 写掩码时尽量写 1u1ull

例如:

1u << k
1ull << k

1 << k 更稳,因为 1 默认是 int,移位太高时可能出问题。

4. 不要拿 pow(2, k) 代替 1 << k

pow 是浮点运算,既慢又可能有精度问题。

如果你想得到 $2^k$,应直接写:

1u << k

5. k 不能乱取

如果是 32 位整数,k 最好控制在 03031 的合理范围内。超出位宽去移位,会产生未定义行为或不符合预期的结果。

6. 异或交换知道即可,不建议滥用

a ^= b;
b ^= a;
a ^= b;

虽然能交换,但可读性差,还容易在 ab 指向同一位置时出问题。正常写题,直接 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 intunsigned long long 的区别与用法;
  • 能写出“设置某一位、清空某一位、翻转某一位、读取某一位”的代码;
  • 能用掩码提取某一段二进制位;
  • 能解释 x & (-x)x & (x - 1) 的含义;
  • 能写出判断 2 的幂、统计 1 的个数的程序;
  • 能用异或解决“只出现一次”的题;
  • 能把“选或不选”的问题写成状态压缩。

到这里,位运算就不再只是“会算”,而是真正进入“会用”的阶段了。