状态压缩DP前提知识:位运算
感谢B站一个大佬 :一俩三四五
简述状态压缩
状态压缩就是说,可以将集合以二进制排列的形式存储,这样可以减少其复杂度。
因为二进制就是0和1的排列,所以我们很方便的将数字映射到一个二进制数上。看图
假设一个集合(数学意义上的集合,即没有相同元素),例如图中的set={ 1,3,4,6} ,如果用一个二进制数来表示,那么就像下面的a一样,在set元素的地方为1,其余位置为0,那么这个a就表示了这个集合,也就是说,一个集合被我们压缩成了一个数字,而且这个数字虽然是以二进制形式的方式存储,但是我们在后续的计算中用十进制就可以(而且绝大多数情况就是只用十进制),一个数。那么这张图里,一个集合{1,3,4,6}被压缩成了一个数91来表示。
那么既然这样表示了,我们要知道对于集合有很多的操作,比如我们要加一个数放到集合中,从集合中删掉一个数等等,那对于这样明确表示的一个十进制数,我们该怎么处理呢?
这就需要利用位运算的知识了。
位运算符号
& | ^ << >>
这几个基本的运算符是必须要掌握的,为了不用再去查资料,我在这里大体写一下。
&: 按位与,如果两个相应的二进制位都为1,则该位结果为1,否则为0;
|: 按位或,两个相应的二进制位只要有一个1,结果就为1;
^: 按位异或,如果两个二进制位相同则为0,不同则为1;
<<: 左移,将一个数的二进制全部左移N位,右侧补0;
>>: 右移,将一个数的二进制全部右移N位,右侧舍弃,无符号数左侧补0;
OK,知道了这些基本运算符之后,我们开始了解一下,在状态压缩DP问题中需要的位运算知识。
状态压缩中所需的基本知识
int A; //set,一个数代表一个集合
int c; //element,集合中的一个数
A|=1<<c; /*insert(c) 将c加入集合中。我们要把c这个数加到集合中,
也就是将集合中第c个位置设为1,那么就把1向左移c位,然后与集合A按位或,
根据按位或的计算,如果第c位是0,那么正好变为1,如果是1,
那么说明原集合中已有c这个元素,不用再加入,而其他位该是1的还是1,
该是0的还是0。*/
A&=~(1<<c);/*erase c 将c移除集合。我们看,先是1向左移c位,
那么就是在c的位置为1,然后再取反,那么就是除了c的位置,其余都是1,
然后再和A按位与,结果就是,其余位置是0的还是0,是1的还是1,
但是c的位置是0&1,结果是0,那么就把c去除了。*/
A^=1<<c;/*erase c 。这个是明确知道c这个元素已经存在于集合中,
上面那个即使不知道集合中是否存在元素c也依然可以使用。
而这个也更简单了。 异或就是不同为1,相同为0,那么左移了c位之后,
两个1相异或就是0了,所以去除。*/
a&(-a); /*lowbit of A 。这个是非常经典的操作,
就是取集合A中最小的元素,返回的不是1的位置,
而是确切的最小的元素的十进制数。在下面会用图具体解释*/
A=0;//empty set 清空集合,没有问题
A|B;//union 取并集,没有问题
A&B;//intersection 取交集,没有问题
int size=15; //size of set 建立一个大小为15的集合
int ALL=(1<<size)-1;/*universal set 求全集(也就是全为1),
就是讲1向左移size位,这时候是第十六位为1,其余位全为0,
所以我们再减1,这样就变成了15个1
*/
ALL^A; /*complementary set of A, 求全集里A的补集,
也是用异或的性质,和全集不同的为1,就是A的补集*/
(A&B)==B; /*B is A's subset B是A的子集。
如果A和B相与之后还是B,那么B就是A的子集。*/
//enumerate the subset of ALL 枚举全集中的所有子集,因为是从小到大枚举,所以肯定可以枚举所有的子集。
for(int i=0;i<=ALL;i++)
;
//enumerate the subset of A 枚举某一个集合中的所有子集。下面详解。
int subset=A;
do
{
subset=(subset-1)&A;
}while(subset!=A);
//count the number of element in A
int cnt=0;
for(int i=0;i<size;i++)
{
if(A&(1<<i)) cnt++;
}
for(int i=A;i;i>>=1)
cnt+=i&1;
int lowbit(int x)
{
return x&(-x);
}
如图中,如果a是01101011,那么进行lowbit(a)之后便得到1,如果是01100100,则得到100。
那么是如何实现的呢,我们就用01100100来看。首先我们要知道一个事情,就是负数在C语言中是以补码形式存储的,也就是说(-a)= 10011100. 那么补码我们知道是先在原码取反,取反之后我们先考虑低位开始第一个1,第一个1变为0,后面的全部变为1,但是取补码还要再加1,而正是加的这个1,让变成1的这些数重新变成了0,而原来第一个1又变成了1。那么这个1左边的高位没有受到任何影响,所以就是正常的取反。因此,a与a的补码按位与之后,高位的因为是相反的所以都变成了0,而低位只有那个1是没变的。因此得到了最小值100 .。 非常神奇。
int highbit(int x)
{
int p=lowbit(x);
while(p!=x) x-=p, p=lowbit(x);
return p;
}
而对于求最高位就没有好的方法了。我们就依赖求最低位的方法,不断的将最低位减掉,当当前位和这个数字一样的时候,那就说明是最高位了。然后返回。(这个操作很少用到)
bool powerOf2(int x) //x is the power of 2判断一个数是不是2的幂.x不能为0
{
return x&&!(x&(x-1));
}
我们就拿8来看,8的二进制是1000,8-1=7的二进制是0111,他俩相与肯定是0 。
那么我们更加一般的来看,如果任意给一个不是2的幂次的数,那么二进制中肯定有两个或以上的1对吧,就像图中那样,那么-1之后,只能对第一个1以及后面那些位产生影响,而不会影响高位,所以与之后肯定有1,那么只要不是1的值就是true。因此就可以用这个方式来判断。
int subset=A;
do
{
subset=(subset-1)&A;
}while(subset!=A);
首先明确一个概念,状态压缩中的子集是什么意思。
例如图中这个A是一个集合,它其实表示的是{0,1,3,5}这个集合对吧。那么根据数学所学的,它的子集个数应该为2的4次方个。那么对应到状态压缩中,例如一个子集是{0,1},那么他就应该是subset=000011. 对吧,子集{1,3,5}应该就是101010对吧。
好那么来看为什么这个循环可以找出所有的子集。如图中,我们先设i=A(此时i就是A,这个求子集可以求出集合A本身和空集,因为这俩也是它的子集)。那么我们就像图里画的那样来看,第一次减1与A相与,变成了101010,对应的{5,3,1},再减1与A相与,变成了101001,对应的{5,3,0},目前来看都是对的,等到了减1完变成100111时我们感觉好像有点不对劲,但是它要与A相与,那么就只会保留低位1变成了100011对于{5,1,0}。对吧,是不是很神奇。
那么如何证明这样去枚举为什么是正确的而且得到的子集是完整的呢?具体的数学证明还是有点麻烦的。如果感兴趣的可以自行去了解。
还有一个问题就是,我们如何求0到2的n次方中所有数(也就对应的集合)的子集的个数。
int cnt[0]=0;//空集没有元素
for(int i=0;i<pow(2,n-1);i++)
{
cnt[i]=cnt[i>>1] + (i&1);
}
还有一个问题就是,我们如何求0到2的n次方中所有数(也就对应的集合)的子集的个数。
int cnt[0]=0;//空集没有元素
for(int i=0;i<pow(2,n-1);i++)
{
cnt[i]=cnt[i>>1] + (i&1);
}
因为我们在算某个i的时候,其实比i小的都已经算过了,所以我们只需要把最后一位拿掉,也就是cnt[i>>1],这就是把最后一位以外的集合元素有多少个,再加上(i&1)