位运算与组合搜索(一)
我们知道,一个集合的子集通常可由一个位串来表示,比如对于集合 {a, b, c, d, e},可用位串 s = ”11001” 来表示其子集 {a, b, e}。当集合的大小不大于机器的字长(在下文中将假设字长为32)时,这些位串又可用一个无符号整数来表示,比如上面的 s 可以存储为 0b10011 = 19。在此种情形下,很多集合操作都可以通过位运算来完成,一些常见操作如下:
求集合 A, B 的交集: a & b
求集合 A, B 的并集: a | b
求集合 A 的补集:~a
求集合 A – B:a & (~b)
测试集合 A 是否包含第 i 个元素:(a & (1<<i)) != 0
测试集合 A 是否是集合 B 的父集:(a & b) == b
当然,这些操作还有很多,比如求对称差就是一个异或等等,这里就不一一列举了,因为这并不是本文的重点。本文的主要目的是讨论一些集合上的组合搜索。具体来说就是怎样高效遍历一个集合的所有子集(subset),或者一个集合的含有固定元素个数的所有子集(亦即是组合,combination)。如果你对怎么实现这些操作不感兴趣,你可以直接下载本文所附的代码,里面包含了已经封装好了的类,和一些简单的例子。如果你感兴趣,而且正巧知道一点常用位运算技巧的话,请 continue。
1. 遍历所有子集
1.1 遍历全集的所有子集
若全集 U 有 n 个元素,表示成位串为 1111…1(n个1),对应的无符号整数即是 2^n – 1 = (1 << n) - 1(注意当 n 等于32 时需要做一些调整,不过我想你也大概不会要遍历到这么大一个集合的所有子集)。容易观察到,U 的所有子集刚好与区间 [0, 2^n – 1] 内的所有整数形成一一映射,于是通过下面这段代码即可依次访问 U 的所有子集:
1 | for (unsigned long i = 0; i < (1UL << n); ++i) |
2 | { |
3 | visit(i); |
4 | } |
这是每个程序员都应该知道的技巧。举个例子,若全集为 {a, b, c},那么上面这段代码所访问的子集及顺序将如下表所示:
序号 | 值 | 位串 | 子集 |
1 | 0b000 | 000 | Φ |
2 | 0b001 | 100 | {a} |
3 | 0b010 | 010 | {b} |
4 | 0b011 | 110 | {a, b} |
5 | 0b100 | 001 | {c} |
6 | 0b101 | 101 | {a, c} |
7 | 0b110 | 011 | {b, c} |
8 | 0b111 | 111 | {a, b, c} |
注意这里访问子集的顺序并不是 lexicographic order (lex),而是另外一种与 lex 关系微妙的序,被称为 colexicographic order (colex)。lex 是从左到右依次比较,而 colex 正好相反,实际上在 colex 中,如果将各子集反转一下,比如将集合 {a, b, c} 写成 {c, b, a},你会发现,反转之后的子集们正是按照 lex 排列的。在下表中列出了 {a, b, c} 的所有子集的 lex 序及 colex 序,为了看起来更方便,省略了集合符号,注意观察 lex 与 colex 之间的联系。
lex | colex |
Φ | Φ |
a | a |
ab | b |
abc | ba |
ac | c |
b | ca |
bc | cb |
c | cba |
再说说如何反向的遍历所有子集。这个其实很显然,因为 ++i 的逆操作是 --i ,因此对上面的代码做点小修改即得到 reverse colex:
1 | for (unsigned long i = (1UL << n) - 1; ; --i) |
2 | { |
3 | visit(i); |
4 | if (i == 0) break ; |
5 | } |
1.2 遍历子集的所有子集
有些时候我们不仅需要遍历全集的所有子集,还需要遍历某个子集的所有子集。你可能会想这似乎没什么区别啊,因为当针对某个子集来讨论其所有子集时,这个子集也就成了全集。的确,在数学上当我们要考虑某个集合的幂集时,并不需要区别这个集合是全集还是全集的某个子集。但是当集合是用位串来表示时,情况就发生变化了,因为正是一个约定的全集决定了位串的长度,同时也决定了位串中的每个位与全集中的哪个元素相对应,换句话说就是定义了位串与子集之间的映射关系。如果想将某个子集做为全集来处理,你就必须重新进行映射。举个例子,若全集为 U = {a, b, c, d, e},若想得到它的一个子集 S = {a, e} 的所有子集,可以暂时先将 S 做为一个全集来处理,按照上面遍历全集的算法将依次得到位串 00, 10, 01, 11,这些位串的第1位代表元素 a,第2位代表元素 e。当再回到全集 U 上时,由于 U 上的位串是第1位代表 a,第5位代表 e,因此还需要进行一个映射将 S 上的位串映射为 U 上的位串。下表按照 colex 依次列出了 S 的所有子集,注意从 S 到 U 的映射关系。
序号 | 值 | 映射 | 位串 | 子集 |
1 | 0b00 | 0b00000 | 00000 | Φ |
2 | 0b01 | 0b00001 | 10000 | {a} |
3 | 0b10 | 0b10000 | 00001 | {e} |
4 | 0b11 | 0b10001 | 10001 | {a, e} |
一个映射操作可以重新叙述如下:给定一个 n 位的二进制数 u,一个 m 位的二进制数 v,且有在 u 中1的个数等于 m 。设在 u 中为1的位的索引从低到高依次为 p1,p2,…,pm,再设下标操作符 [] 可以索引一个二进制数的某个位(最低位的索引为1),那么映射操作将返回一个 n 位二进制数 w,满足
对所有 1 <= i <= n ,若u[i] = 0,则 w[i] = 0
对所有 1 <= i <= m ,w[pi] = v[i]
比如给定 u = 0b10110100, v = 0b1100, 将得到 w = 0b10100000。这个映射操作是 non-trival 的,也就是说你不能指望通过简单的一两次位运算就能得偿所愿。具体怎么实现咱们后面再讲,因为在这里使用一个非常漂亮的技巧可以绕过这个操作。为了叙述方便,先定义一个概念(非正式):
片段:对于任意两个 n 位二进制数 x 和 mask,若在 mask 中为1的位的索引分别为 p1,p2,…,pk,那么将这些位 x[p1],x[p2],…,x[pk] 称为 x 在 mask 上的片段,不妨记为 [x@mask]。比如对于 x = 0b0001, mask = 0b1001, 那么 [x@mask] 由 x 的第1位和第4位组成,用红色标识出来即为 0b0001。
在前面我们已经看到,遍历子集的关键就是一个+1(colex)或者-1(reverse colex)操作。如果我们能在片段上直接进行+1或者-1操作,那就解决问题了。比如 0b0001 + 1 直接就得到 0b1000。事实上,这的确可以做到,而且非常简单。
先来看看片段上的-1操作(因为-1比+1简单,而且也有更多的人知道这一技巧)。若 x & mask = x,即 x 是 mask 的子集,则有:
[x@mask] - 1 = (x – 1) & mask
上面这个公式为什么是正确的?因为既然 x 是 mask 的子集,因此 mask 为0的位在 x 中也为0。这些0在-1运算中会正确的传播借位,就如同它们并不存在一样。比如 0b1000 – 1 = 0b0111(注意在第1位中产生的借位是如何传播到第4位的)。最后再与 mask 相与,清零掉那些无关的位,即得到正确的结果 0b0001。
于是,如果想要按照 reverse colex 遍历某个子集(假设表示成一个无符号整数为 s) 的所有子集,将 s 做为 mask 然后进行-1操作即可,代码如下:
1 | for (unsigned long i = s; ; i = (i - 1) & s) |
2 | { |
3 | visit(i); |
4 | if (i == 0) break ; |
5 | } |
再来看看如何在片段上进行+1操作。受上面-1操作的启发,我们意识到这里最关键的问题在于如何正确的传播进位。传播借位是将无关位设为0,那传播进位呢?——将无关位设为1,Bingo!。怎么将无关位设为1呢,将mask取反,然后再相或就成,于是有:
[x@mask] + 1
= ((x | (~mask)) + 1) & mask
= (x + (~mask) + 1) & mask // 若 x & mask = x,那么 x | (~mask) = x + (~mask)
= (x – mask) & mask // (~mask) + 1 = – mask
现在我们终于可以正向地遍历子集 s 的所有子集了:
1 | for (unsigned long i = 0; ; i = (i - s) & s) |
2 | { |
3 | visit(i); |
4 | if (i == s) break ; |
5 | } |