代码部分
对于一个集合 x x x,枚举其所有子集,则有:
for(int i = x;i;i = ((i - 1) & x)){
//TODO
}
其中,i
是枚举变量,i
的每个取值都对应着
x
x
x的一个子集。TODO
部分是对于当前枚举到的子集的进一步使用、处理。
讲解部分
先来看两个边界状态:
起始边界:
很明显是i = x
,表示从原集合本身开始枚举。该状态是合法状态,可以被取到。
终止边界
在当前算法中,我们认为终止边界状态为非法状态,即空集不为任何集合的子集(数学意义下空集为任意集合的子集),该状态不可能被取到(原因:当i = ((i - 1) & x)
语句将i
更新至0
时,for
循环跳出,不再执行循环体,故不可能被取到)
再来看看迭代部分:
迭代部分
i = ((i - 1) & x)
为了方便理解,我们用一个实例手工模拟迭代部分,来观察它的工作原理:
基本起始状态(当前状态为子集)
1 0 1 0 1
减一
1 0 1 0 0
和 x x x按位与(当前状态为子集)
1 0 1 0 0
减一
1 0 0 1 1
到这里,我们先暂停。请仔细观察1 0 1 0 0
和1 0 0 1 1
之间(在形式或对应位上)的关系。
数学意义下,我们知道后者由前者-1
后得到;但我们在形式或对应位上观察发现:减一操作,就是把从低位(右侧)向高位数第一个1改为0,再把这一位右侧的所有值改为1。
我们继续模拟:
和 x x x按位与(当前状态为子集)
1 0 0 0 1
到这里暂停一下。观察1 0 0 1 1
和1 0 0 0 1
之间的关系。(提示:思考按位与作用在每一位上的效果,不必思考数学意义下按位与的含义)
在形式上观察发现,(从右向左数)第二位由1
变为了0
。
Q
Q
Q :为什么算法设计师认定,这个1
是应该且必须被去掉的呢?
A
A
A :因为这个被去掉的1
就不在
x
x
x中。
首先我们知道:我们的变量i枚举的是x的子集,也就是 i ∈ x i \in x i∈x始终成立。这个被去掉的1是不再x中的,所以我们需要将它去掉。
Q
Q
Q :但是按位与是作用在所有二进制位上的啊?在筛去刚才减一操作中右侧不应该存在的1
时,不会对左侧造成影响吗?
A A A :其实不会。
其实在枚举中,我们能发现一个规律:循环变量i
其实是在倒序枚举
x
x
x的所有子集。
理解这一点其实很简单。我们每次减1,在形式上看来其实是找到从右向左第一个1,变成0,这样只有右侧的所有可能都枚举完了,才会改变偏左的部分,继续枚举。
因为最开始有i = x
存在,而且上面我们说,我们是倒序枚举,右边先改,左侧暂时不变。而右侧原本全是1,所以按位与后可以使右侧成为x的“子集”(或者说将右侧某些在x中不存在的1改为0),这就成了x的一个子集。
再往后计算时,就找到当前最右边的1,以它为基准继续操作就好了。
其实到这里,我们就发现了这个迭代部分的工作原理。
其实,这感觉有点像在一位数组上用dfs算法枚举所有情况,对于每一层,都有0
和1
两种状态,对于每个状态进入下一层dfs进一步处理。
好了,今天的博客就到这里了。如果你感兴趣,可以将上边的例子自己推下去,看看是不是这样的。
感谢阅读。