本文非原创,转载自灵茶山艾府,具体出处见文章底部

前言

本文将扫清位运算的迷雾,在集合论与位运算之间建立一座桥梁。

在高中,我们学了集合论(set theory)的相关知识。例如,包含若干帧数的集合 S={0,2,3}S=\left\{ 0,2,3 \right\}。在编程中,通常用哈希表(hash table)实现集合,例如 Java 中的 HashSet,Golang 中的 map。

在集合论中,有交集 $ \cap$、并集 ∪\cup、包含于 ⊆\subseteq 等等概念。如何编程实现「求两个哈希表的交集」,需要一个个遍地遍历哈希表中的元素。有没有更高效的做法呢?

该二进制上场了。

集合可以用二进制表示,二进制从低到高第 ii 位为 1 表示 ii 在集合中,为 0 表示 ii 不在集合中。例如集合 {0,2,3}\left\{ 0,2,3 \right\} 可以用二进制数 1101 表示;反过来,二进制数 1101 就对应集合 {0,2,3}\left\{ 0,2,3 \right\}。

正式地说,包含非负整数的集合 SS 可以用如下方式「压缩」成一个数字:

f(S)=∑i∈S2if(S) =\sum_{i\in S} 2^i

上面举例的 0,2,3{0,2,3} 可以压缩成 20+22+23=132^0+2^2+2^3=13,也就是二进制数 1101。

利用位运算「并行运算」的特点,我们可以高效地做一些和集合相关的运算,按照常见的应用场景,可以分为以下四类:

  1. 集合与集合
  2. 集合与元素
  3. 遍历集合
  4. 枚举集合

一、集合与集合

其中 &\& 表示按位与,∣| 表示安慰或,⊕\oplus 表示按位异或,$ ∼ $ 表示按位取反。

其中「对称差」指仅在其中一个集合的元素。

术语 集合 位运算 举例 二进制
交集 A∩BA \cap B a&ba \& b {0,2,3}∩{0,1,2}={0,2}\left\{ 0,2,3 \right\} \cap \left\{ 0,1,2 \right\} = \left\{ 0,2 \right\} 1101&0111=01011101 \& 0111 = 0101
并集 A∪BA \cup B a∥ba \| b {0,2,3}∪{0,1,2}={0,1,2,3}\left\{ 0,2,3 \right\} \cup \left\{ 0,1,2 \right\} = \left\{ 0,1,2,3 \right\} $1101
对称差 A△BA\triangle B a⊕ba \oplus b {0,2,3}△{0,1,2}={1,3}\left\{ 0,2,3 \right\} \triangle \left\{ 0,1,2 \right\} = \left\{ 1,3 \right\} 1101⊕0111=10101101\oplus0111 = 1010
差 A\BA \backslash B a&∼ba\& \sim b {0,2,3}\{1,2}={0,3}\left\{ 0,2,3 \right\} \backslash \left\{ 1,2 \right\} = \left\{ 0,3 \right\} 1101&1001=10011101 \& 1001 = 1001
差(子集) A\B(B⊆A)A \backslash B(B \subseteq A) a⊕ba \oplus b {0,2,3}\{0,2}={3}\left\{ 0,2,3 \right\} \backslash \left\{ 0,2 \right\} = \left\{ 3 \right\} 1101⊕0101=10001101 \oplus 0101 = 1000
包含于 A⊆BA \subseteq B a&b=aa \& b = a
a∥b=aa \| b = a
{0,2}⊆{0,2,3}\left\{ 0,2 \right\} \subseteq \left\{ 0,2,3 \right\} 0101&1101=01010101 \&1101=0101
0101∥1101=11010101 \| 1101 = 1101

注1:按位取反的例子中,仅列出最低 4 个比特位取反的结果,即 0110 取反后是 1001。

注2:包含于的两周运算写法是等价的,再编程时只需要判断其中任意一种。

注3:编程时,请注意运算符的优先级。例如 == 再某些语言中优先级跟高。

二、集合与元素

通常会用到位移计算

其中 << 表示左移,>> 表示右移。

注:左移 ii 位相当于成 2i2^i,右移 ii 位相当于除以 2i2^i

术语 集合 位运算 举例 二进制
空集 ∅\emptyset 0
单元素合集 {i}\left\{ i \right\} 1<<i1 << i {2}\left\{ 2 \right\} 1<<21<<2
全集 U={0,1,2,...n−1}U=\left\{ 0,1,2,...n-1 \right\} (1<<n)−1(1 << n)-1 {0,1,2,3}\left\{ 0,1,2,3 \right\} (1<<4)−1(1<<4)-1
补集 ∁US=U\S\complement_U S = U \backslash S ∼S\sim S 或者 ((1<<n)−1)⊕s((1 << n) - 1) \oplus s
属于 i∈Si \in S (s>>i)&1=1(s >> i)\& 1 =1 2∈{0,2,3}2 \in \left\{ 0,2,3 \right\} (1101>>2)&1=1(1101 >> 2) \& 1 = 1
不属于 i∉Si \notin S (s>>i)&1=0(s >> i) \& 1 = 0 1∉{0,2,3}1 \notin \left\{ 0,2,3 \right\} (1101>>2)&1=0(1101 >> 2) \& 1 = 0
添加元素 S∪{i}S \cup \left\{ i \right\} $s (1 << i)$ $\left{ 0,3 \right} \cup \left{2 \right} $
删除元素 S\{i}S \backslash \left\{ i \right\} s&∼(1<<i)s\&\sim(1 << i) $\left{ 0,2,3 \right} \backslash \left{2 \right} $ 1101&∼(1<<2)1101 \& \sim (1<<2)
删除元素(一定在集合中) S\{i}(i∈S)S\backslash\left\{i\right\}(i \in S) s⊕(1<<i)s\oplus(1 << i) $\left{ 0,2,3 \right} \backslash \left{2 \right} $ 1101⊕(1<<2)1101 \oplus(1 << 2)
删除最小元素 s&(s−1)s\&(s-1) 见下
1
2
3
      s = 101100
s-1 = 101011 // 最低位的 1 变成 0,同时 1 右边的 0 都取反,变成 1
s&(s-1) = 101000

此外,某些数字可以借助标准库的函数算出:

术语
Python Java C++ Go
集合大小(元素个数) s.bit_count() Integer.bitcount(s) __builtin_popount(s) bits.OnesCount(s)
二进制长度(减一得到集合中的最大元素) s.bit_length() 32-Integer.numberOfLeadingZeros(s) 32-__builtin_clz(s) bits.Len(s)
集合中的最小元素 (s&-s).bit_length()-1 Integer.numberOfTrailingZeros(s) __builtin_ctz(s) bits.TrailingZeros(s)

特别地,只包含最小元素的子集,即二进制最低 1 及其后面的 0,也叫 lowbit,可以用 s & -s 算出。举例说明:

1
2
3
4
     s = 101100
~s = 010011
(~s)+1 = 010100 // 根据补码的定义,这就是 -s 最低 1 左侧取反,右侧不变
s & -s = 000100 // lowbit

三、遍历集合

设元素范围从 00 到 n−1n-1,挨个判断元素是否在集合 ss 中:

1
2
3
for i in range(n):
if (s >> i) & 1: # i 在 s 中
# 处理 i 的逻辑
1
2
3
4
5
for (int i = 0; i < n; i++) {
if (((s >> i) & 1) == 1) { // i 在 s 中
// 处理 i 的逻辑
}
}
1
2
3
4
5
for (int i = 0; i < n; i++) {
if ((s >> i) & 1) { // i 在 s 中
// 处理 i 的逻辑
}
}
1
2
3
4
5
for i := 0; i < n; i++ {
if s>>i&1 == 1 { // i 在 s 中
// 处理 i 的逻辑
}
}

四、枚举集合

设元素范围从 00 到 n−1n-1,从空集 ∅\emptyset 枚举到全集 UU:

1
2
for s in range(1 << n):
# 处理 s 的逻辑
1
2
3
for (int s = 0; s < (1 << n); s++) {
// 处理 s 的逻辑
}
1
2
3
for (int s = 0; s < (1 << n); s++) {
// 处理 s 的逻辑
}
1
2
3
for s := 0; s < 1<<n; s++ {
// 处理 s 的逻辑
}

设集合为 ss,从大到小枚举 ss 的所有非空子集 sunsun:

1
2
3
4
sub = s
while sub:
# 处理 sub 的逻辑
sub = (sub - 1) & s
1
2
3
for (int sub = s; sub > 0; sub = (sub - 1) & s) {
// 处理 sub 的逻辑
}
1
2
3
for (int sub = s; sub; sub = (sub - 1) & s) {
// 处理 sub 的逻辑
}
1
2
3
for sub := s; sub > 0; sub = (sub - 1) & s {
// 处理 sub 的逻辑
}

为什么要写成 sub = (sub - 1) &s 呢?

暴力做法是从 ss 出发,不断减一直到 0,但这样中途会遇到很多并不是 ss 的子集的情况。例如 s=10101s=10101 时,减一得到 1010010100,这是 ss 的子集。但再减一就得到 1001110011 了,这并不是 ss 的子集,下一个子集应该是 1000110001。

把所有的合法子集按顺序列出来,会发现我们做的相当于「压缩版」的二进制减法,例如

10101→10100→10001→10000→00101→...10101 \rightarrow 10100 \rightarrow 10001 \rightarrow 10000 \rightarrow 00101 \rightarrow ...

如果忽略掉 1010110101 中的两个 00,数字的变化和二进制减法是一样的,即

111→110→101→100→011→...111 \rightarrow 110 \rightarrow 101 \rightarrow 100 \rightarrow 011 \rightarrow ...

如何快速找到下一个子集呢?以 10100→1000110100 \rightarrow 10001 为例说明,普通的二进制减法会把最低位的 11 变成 00,同时 11 右边的 00 全部变成 11,即 10100→1001110100 \rightarrow 10011。「压缩版」的二进制减法也是类似的,把最低位的 11 变成 00,但同时对于 11 右边的 00,只保留在 s=10101s = 10101 中的 11,所以是 10100→1000110100 \rightarrow 10001。怎么保留? &10101\& 10101 就行

但同时对于 11 右边的 00,只保留在 s=10101s = 10101 中的 11

意思是,把 1010010100 的最低位 11 变 00,它的后面有两位 0000,都是 00,这时候按照普通二进制,会把这两个 0000 变成 1111,如果按照压缩版,就是把原来集合中有的 11 变成 11(因为求的是子集),其余的还是 00,原有的集合是1010110101,最后两位是 0101,所以只保留 0101。综合起来就是 1010010100 先变成 1000010000,然后保留 0101,变成 1000110001(按照作者的说法,应该是对于 1010110101,只有最后一位存在值,所以最后一位取反,0→10 \rightarrow 1,所以 1010010100 先变成 1000010000,然后最后一位取反)。

这样做的效果是从 1010010100 直接跳到了 1000110001,把中间的 1001110011 和 1001010010 忽略掉了(普通减法的顺序是:10100→10011→10010→1000110100 \rightarrow 10011 \rightarrow 10010 \rightarrow 10001),因为 1001110011 和 1001010010 不是有效子集

作者:roylx
链接:https://leetcode.cn/circle/discuss/CaOJ45/view/gj5DL2/

如果要从大到小枚举 ss 的所有子集 subsub(从 ss 枚举到空集 ∅\emptyset),可以这样写:

1
2
3
4
5
6
sub = s
while True:
# 处理 sub 的逻辑
sub = (sub - 1) & s
if sub == s:
break
1
2
3
4
5
int sub = s;
do {
// 处理 sub 的逻辑
sub = (sub - 1) & s;
} while (sub != s);
1
2
3
4
5
int sub = s;
do {
// 处理 sub 的逻辑
sub = (sub - 1) & s;
} while (sub != s);
1
2
3
4
5
6
7
for sub := s; ; {
// 处理 sub 的逻辑
sub = (sub - 1) & s
if sub == s {
break
}
}

原理是当 sub=0sub = 0 时(空集),再减一就得到 −1-1,对应的二进制为 111...1111...1,再&s\&s就得到了ss。所以 sub=ssub = s 时说明最后一次循环式空集,退出循环。

注:还可以枚举全集 UU 的所有大小恰好为 kk 的子集,这一技巧叫做 Gosper’s Hack,具体见作者B站的讲解【力扣双周赛 86】Gosper’s Hack | 单调队列 | LeetCode 算法刷题

练习

用位运算完成如下题目:

  • 78.子集

  • 77.组合

  • 46.全排列
    然后是一些状态压缩 DP。这类题目通常和排列/子集有关,可以先从暴力回溯开始思考,再过渡到记忆化搜索和递推。

  • 2172.数组的最大与和,题解

  • 1125.最小的必要团队,题解

  • 2305.公平分发饼干,题解

  • 1494.并行课程 II,题解

  • LCP 53.守护太空城,题解

  • 1879.两个数组最小的异或值之和

  • 1986.完成任务的最少工作时间段

更多题目,可以在题库中同时选上「动态规划」和「位运算」这两个标签:链接。