集合论&位运算的算法应用
作者:灵茶山艾府
发布时间:2023.06.16
本文将扫清位运算的迷雾,在集合论与位运算之间建立一座桥梁。
在高中,我们学了集合论(set theory)的相关知识。例如,包含若干整数的集合 。在编程中,通常用哈希表(hash table)表示集合。例如 Java 中的 HashSet,C++ 中的 std::unordered_set。
在集合论中,有交集 、并集 、包含于 等等概念。如果编程实现「求两个哈希表的交集」,需要一个一个地遍历哈希表中的元素。那么,有没有效率更高的做法呢?
该二进制登场了。
集合可以用二进制表示,二进制从低到高第 位为 表示 在集合中,为 表示 不在集合中。例如集合 可以用二进制数 表示;反过来,二进制数 就对应着集合 。
正式地说,包含非负整数的集合 可以用如下方式「压缩」成一个数字:
例如集合 可以压缩成 ,也就是二进制数 。
利用位运算「并行计算」的特点,我们可以高效地做一些和集合有关的运算。按照常见的应用场景,可以分为以下四类:
- 集合与集合
- 集合与元素
- 遍历集合
- 枚举集合
一、集合与集合
Section titled “一、集合与集合”其中 & 表示按位与,| 表示按位或,^ 表示按位异或,~ 表示按位取反。
两个集合的「对称差」是只属于其中一个集合,而不属于另一个集合的元素组成的集合,也就是不在交集中的元素组成的集合。
| 术语 | 集合 | 位运算 | 集合示例 |
|---|---|---|---|
| 交集 | a & b | ||
| 并集 | a | b | ||
| 对称差 | a ^ b | ||
| 差集 | a & ~b | ||
| 差集 (子集) | a ^ b | ||
| 包含于 | (a & b) == a 或 (a | b) == b |
二、集合与元素
Section titled “二、集合与元素”通常会用到移位运算。其中 << 表示左移,>> 表示右移。
注:左移 位相当于乘以 ,右移 位相当于除以 。
| 术语 | 集合 | 位运算 | 说明/示例 |
|---|---|---|---|
| 空集 | 0 | ||
| 单元素集合 | 1 << i | ||
| 全集 | (1 << n) - 1 | ||
| 补集 | ((1 << n) - 1) ^ s | 也就是全集异或 | |
| 属于 | (s >> i) & 1 == 1 | ||
| 不属于 | (s >> i) & 1 == 0 | ||
| 添加元素 | s | (1 << i) | ||
| 删除元素 | s & ~(1 << i) | ||
| 删除元素 (一定在集合中) | s ^ (1 << i) | ||
| 删除最小元素 | s & (s - 1) | 最低位的 1 变成 0 |
特别说明 s & (s - 1):
s = 101100s-1 = 101011 // 最低位的 1 变成 0,同时右边的 0 都变成 1s & (s-1) = 101000 // 结果:最低位的 1 被移除了特别地,如果 是 的幂,那么 s & (s - 1) == 0。
编程语言提供了一些和二进制有关的库函数,调用这些函数的时间复杂度都是 。
| 术语 | Python | Java | C++ (GCC/Clang) |
|---|---|---|---|
| 集合大小 (1 的个数) | s.bit_count() | Integer.bitCount(s) | __builtin_popcount(s) |
| 集合最大元素 (二进制长度 -1) | s.bit_length() - 1 | 32 - Integer.numberOfLeadingZeros(s) - 1 | 31 - __builtin_clz(s) |
| 集合最小元素 (尾零个数) | (s & -s).bit_length() - 1 | Integer.numberOfTrailingZeros(s) | __builtin_ctz(s) |
注意:
- 请特别注意 的情况。对于 C++ 来说,
__builtin_clz(0)和__builtin_ctz(0)是未定义行为。其他语言请查阅 API 文档。- 对于 C++ 的
long long,需使用相应的__builtin_popcountll等函数(后缀添加ll)。__lg支持long long。- Lowbit:只包含最小元素的子集,即二进制最低 1 及其后面的 0,可以用
s & -s算出。 原理:~s + 1即为-s(补码定义)。s & -s=101100 & 010100=000100。
三、遍历集合
Section titled “三、遍历集合”设元素范围从 到 ,枚举范围中的元素 ,判断 是否在集合 中。
# Python3for i in range(n): if (s >> i) & 1: # i 在 s 中,处理 i 的逻辑 pass也可以直接遍历集合 中的元素:不断地计算集合最小元素、去掉最小元素,直到集合为空。
# Python3 (通用逻辑)t = swhile t: lowbit = t & -t t ^= lowbit # 去掉最低位的 1 i = lowbit.bit_length() - 1 # 获取元素数值 # 处理 i 的逻辑四、枚举集合
Section titled “四、枚举集合”§4.1 枚举所有集合
Section titled “§4.1 枚举所有集合”设元素范围从 到 ,从空集 枚举到全集 :
for s in range(1 << n): # 处理 s 的逻辑 pass§4.2 枚举非空子集
Section titled “§4.2 枚举非空子集”设集合为 ,从大到小枚举 的所有非空子集 :
sub = swhile sub: # 处理 sub 的逻辑 sub = (sub - 1) & s为什么要写成 sub = (sub - 1) & s 呢?
暴力做法是从 出发,不断减一,直到 。但这会遇到很多并不是 的子集的情况。
例如 时,减一得到 ,这是 的子集。但再减一就得到 了,这并不是 的子集(最高位变了),下一个子集应该是 。
把所有的合法子集按顺序列出来,会发现我们做的相当于**「压缩版」的二进制减法**。
例如
如果忽略掉 中的两个 ,数字的变化和二进制减法是一样的:
如何快速跳到下一个子集呢? 比如,怎么从 跳到 ?
- 普通的二进制减法:。也就是把最低位的 变成 ,同时把最低位的 右边的 都变成 。
- 压缩版的二进制减法:对于 ,也会把最低位的 变成 ,但对于最低位 右边的 ,并不是都变成 ,只有在 中的 才会变成 。
怎么做法?减一后 按位与 就行,也就是 (sub - 1) & s。
。
§4.3 枚举子集(包含空集)
Section titled “§4.3 枚举子集(包含空集)”如果要从大到小枚举 的所有子集 (从 枚举到空集 ),可以这样写:
sub = swhile True: # 处理 sub 的逻辑 if sub == 0: break sub = (sub - 1) & s其中 Java 和 C++ 的原理是,当 sub = 0 时(空集),再减一就得到 ,对应的二进制为 ,再 & s 就得到了 。所以当循环到 时,说明最后一次循环的 (空集)的所有子集都枚举到了,退出循环。
注:还可以枚举全集 的所有大小恰好为 的子集,这一技巧叫做 Gosper’s Hack。
§4.4 枚举超集
Section titled “§4.4 枚举超集”如果 是 的子集,那么称 是 的超集(superset)。
枚举超集的原理和上文枚举子集是类似的,这里通过或运算保证枚举的集合 一定包含集合 中的所有元素。
枚举 ,满足 是 的超集,也是全集 的子集。
s = twhile s < (1 << n): # 处理 s 的逻辑 s = (s + 1) | t完成 位运算题单 的第一章。
其他关联题单:
整理自 灵茶山艾府 的分享