位运算遍历二进制数表示的集合的子集

本文介绍了如何使用位运算快速遍历一个由二进制数表示的集合的所有子集,对比了暴力循环的方法并提供了优化后的算法,该算法的时间复杂度为O(2^popcount(i)),其中popcount(i)是二进制数i中1的个数。通过实例展示了位运算在寻找子集中的优势,并用Python代码进行了验证。
摘要由CSDN通过智能技术生成

问题描述

在一些算法问题中,我们常常需要用计算机中的二进制数来表示一个集合。在程序设计问题中这常被称作是状态压缩(即把一个集合压缩为便于存储的(二进制)数)。

举个例子来说,如果有三个人{A, B, C},我们就可以用 11 1 2 111_2 1112 来表示这三个人组成的集合 { A,B,C }。这里的下标 2 表示是二进制数。而二进制数 11 0 2 110_2 1102 就可以表示集合{A,B}。二进制数 10 0 2 100_2 1002 就可以表示集合 {A}。

有时候我们须要遍历一个集合的子集,这如何用程序实现呢?这篇短文就来介绍一个小的技巧,用这个技巧就可以遍历一个二进制数表示的集合的子集。

用二进制数表示集合

如果一个集合有 n n n 个元素,我们知道这个集合的子集的个数是 2 n 2^n 2n。这里面包含了空集和集合本身。比如说对于集合 { 1 , 2 , 3 } \{1, 2, 3\} {1,2,3},它的子集包括 ∅ \empty , { 1 } \{1\} {1}, { 1 , 2 } \{1, 2\} {1,2}, { 1 , 3 } \{1, 3\} {1,3}, { 1 , 2 , 3 } \{1, 2, 3\} {1,2,3}, { 2 } \{2\} {2}, { 2 , 3 } \{2, 3\} {2,3}, { 3 } \{3\} {3}, 一共有 2 3 = 8 2^3 = 8 23=8 个。

如何用二进制数来表示集合呢?在“问题描述”中我们已经提到,如果有一个集合 { 1 , 2 , 3 } \{1, 2, 3 \} {1,2,3},我们可以用二进制数 11 1 2 111_2 1112 表示集合 { 1 , 2 , 3 } \{1, 2, 3 \} {1,2,3},二进制数 10 1 2 101_2 1012 表示集合 { 1 , 3 } \{1, 3\} {1,3},等等。

我们的问题是,如果给了我们一个二进制集合对应的二进制数 i i i,我们如何得到 i i i 所表示集合的子集呢。

比如说,给定 i = 5 i = 5 i=5,即 10 1 2 101_2 1012,我们希望得到其所有的子集(这里我们约定不包括空集),即我们希望得到 10 1 2 101_2 1012(自身), 10 0 2 100_2 1002,以及 1 2 1_2 12

我们可以看的,如果 j j j i i i 的子集的话,那么在二进制下, j j j 的 1 的位对应于 i i i 也是 1。

注意,为了表述方便,这里我们可以把子集当作一个数来看。比如我们说 10 0 2 100_2 1002 10 1 2 101_2 1012 的子集,我们也可以说 4 4 4 5 5 5 的子集。

下面我们看这个问题的程序实现。

暴力循环

如果一个数 j j j i i i 的子集,上面我们知道 在二进制下, j j j 的 1 的位对应于 i i i 也是 1。从而,我们有对于位运算 & \& & j & i = j j \& i = j j&i=j. 利用这个性质我们可以写一个简单的 for 循环来找出所有的子集。

程序

根据上述的暴力搜寻方法,我们用 for 循环从 1 到 i i i 暴力搜寻所有 i i i 的子集。简单的 c++ 代码如下。

vector<int> subset;
for (int j = i; j > 0; j--) {
    if (j & i == j) {
      subset.push_back(j);
  }
}

这样程序虽然容易理解,但是时间复杂度很高。我们会发现我们的 for 循环计算了很多不是 i i i 的子集的数,这就导致程序的效率很低。如果 i i i 的二进制有 n n n 位,那么时间复杂度是 O ( 2 n ) \mathcal{O}(2^n) O(2n)

位运算寻找子集

下面我们来看一个优化的算法。

我们希望找到 i i i 的所有的子集。首先 i i i 就是一个子集。其次,我们观察 i − 1 i - 1 i1 i i i 在二进制的不同。我们从简单的例子出发。

如果 i = 5 i = 5 i=5,那么 i − 1 = 4 i - 1 = 4 i1=4。在二进制下,即 10 1 2 − 1 = 10 0 2 101_2 - 1 = 100_2 10121=1002

如果 i = 6 i = 6 i=6,那么 i − 1 = 5 i - 1 = 5 i1=5。在二进制下,即 11 0 2 − 1 = 10 1 2 110_2 - 1 = 101_2 11021=1012

如果 i = 12 i = 12 i=12,那么 i − 1 = 11 i - 1 = 11 i1=11。在二进制下,即 110 0 2 − 1 = 101 1 2 1100_2 - 1 = 1011_2 110021=10112

我们发现,在二进制下, i − 1 i - 1 i1 就是找到原来二进制下 i i i 的最右面的 1 的位置,记为 right_most_pos,然后把最右面的 1 改成 0。然后,如果在 right_most_pos 的右面还有 0,就把这些 0 全部变成 1。

观察到了这个规律,我们来把这个规律应用到寻找 i i i 的子集上面。

我们从 i i i 出发,因为 i i i 本身是一个子集,并且是数值最大的一个子集。也就是说,假设 i i i 的最右面的 1 的位置记为right_most_pos,那么在 right_most_pos 右面(即更低位)是没有 1 出现的。从而对于 i − 1 i - 1 i1,就是把 i i i 的 right_most_pos 位设置成 0,把后面的位都变成 1。接下来我们看 ( i − 1 ) & i (i - 1) \& i (i1)&i,因为 i − 1 i - 1 i1 是把 i i i 的 right_most_pos 右面的数字全部变成了 1,而 i i i 的 right_most_pos 右面的位数上只有 0。从而, ( i − 1 ) & i (i - 1) \& i (i1)&i 就相当于是把 i i i 的 right_most_pos 的 1 变成了 0。

举一个例子来说明。如果 i = 21 i = 21 i=21,那么在二进制下, i = 1010 1 2 i = 10101_2 i=101012。对于 i − 1 i - 1 i1,我们就有 ( i − 1 ) & i = 1010 0 2 (i - 1) \& i = 10100_2 (i1)&i=101002。即把最右面的1变成0。如果 i = 6 i = 6 i=6,即 i = 11 0 2 i = 110_2 i=1102,那么 ( i − 1 ) & i = 10 0 2 (i - 1) \& i = 100_2 (i1)&i=1002. 即把原来 i i i 的最右面的 1 变成 0。

我们下面证明,如果 j j j i i i 的一个子集,那么
(a) ( j − 1 ) & i (j - 1) \& i (j1)&i 也是 i i i 的子集;
(b) ( j − 1 ) & i (j - 1) \& i (j1)&i 是 小于 j j j i i i 的最大的子集。

同时满足。

对于 (a),通过上文的分析,我们知道, ( j − 1 ) & i (j - 1) \& i (j1)&i 就相当于是把 j j j 的 right_most_pos 的 1 变成了 0,并且保留 i i i 的 right_most_pos 右面的所有的 1。 那么 ( j − 1 ) & i (j - 1) \& i (j1)&i 自然是 i i i 的一个子集,也即 (a) 成立。

而对于 (b),同样的,既然 ( j − 1 ) & i (j - 1) \& i (j1)&i 保留了 i i i 的 right_most_pos 右面的所有的 1,那么 ( j − 1 ) & i (j - 1) \& i (j1)&i 就是小于 j j j i i i 的子集中最大的一个。

由于算法保证了 (a),(b) ,我们从 j = i j = i j=i 开始,依次用 ( j − 1 ) & i (j - 1) \& i (j1)&i 就能得到一个 i i i 的子集。并且根据 (b) 我们知道 ( j − 1 ) & i (j - 1) \& i (j1)&i 是小于 j j j 的最大的那一个。从而一直循环下去,直到我们得到 0,就遍历完了 i i i 的所有的子集。

按照这个算法的思路,比如 i = 21 i = 21 i=21,即 i = 1010 1 2 i = 10101_2 i=101012。那么从 i i i 开始,依次得到的子集是:

10101 10101 10101

10100 10100 10100

10001 10001 10001

10000 10000 10000

00101 00101 00101

00100 00100 00100

00001 00001 00001

程序

有了算法的思路,程序实现起来就很简单了。

vector<int> subset;
for (int j = i; j > 0; j = (j - 1) & i) {
      subset.push_back(j);
}

通过上述问题的分析,我们知道上述算法的时间复杂度是 O ( 2 p o p c o u n t ( i ) ) \mathcal{O}(2^{popcount(i)}) O(2popcount(i)),这里 p o p c o u n t ( i ) popcount(i) popcount(i) i i i 的二进制中的 1 的个数。这是因为上述算法所遍历的数均是 i i i 的子集,即没有进行多余的计算。

我们用简单的 python 程序来看看暴力枚举与用位运算算法的时间复杂度的比较。

s = 123456789
res1 = []
for i in range(s, 0, -1):
    if (i & s == i):
        res1.append(i)

上述程序的运行时间是 12.9s。

而利用位运算的算法

s = 123456789
res2 = []
i = s
while i > 0:
    res2.append(i)
    i = (i - 1) & s

只需要运行 22 ms!

并且 res1res2 是一样的。

print(res1 == res2)

True

参考文献

https://oi-wiki.org/math/bit/#_14

评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值