一看就懂的子集DP(SOSDP)、高维前缀和
参zhao考chao博客:Troverld
本博客从几何的角度详细讲解子集DP的转移过程,更加直观。
一类经典问题:
在刷题时我们经常会看到两种题型:知道二进制集合的值,求出某个集合的所有子集值之和;知道二进制集合的值,求出某个集合的所有超集值之和。
遇到这种经典问题,我们一般有两种处理方式:1.直接遍历它的所有子集(超集),暴力求和;2.将贡献以dp的形式向某方向传递。
遍历子集:
这是个一学就会的 \(3^n\) trick。
如果直接从0循环到max,每次检查是不是当前集合的子集,那么所有集合的子集复杂度加起来是 \(4^n\)。
怎么才能不重不漏找到当前集合的子集呢,一行代码就够哩:
for(int i=u; i; i=(i-1)&u) f[u] += f[i]; // i 是 u 的子集
这个方式遍历所有的子集复杂度之和是 \(3^n\),例题与复杂度证明在这篇里:NOI online R3 T3 优秀子序列
但毕竟是遍历的暴力,由于题目的特殊性不得不遍历时才有用,一般我们还是考虑dp传递贡献。
子集关系图是DAG:
求子集之和,其实就是把子集的每个点的贡献往超集上传递;
求超集之和,其实就是把超集的每个点的贡献往子集上传递。
第一次接触这种题比较容易想到的一种方式就是,去掉当前集合中的某个元素(二进制的某一位1),然后加起来,像这样:\(f[111] = f[110]+f[101]+f[011]\)。
但是很遗憾也很显然这样是错误的,因为 \(f[100]\) 的贡献通过 \(f[101]\) 传递了一次,又通过 \(f[110]\) 传递了一次,实际上 \(f[111]\) 加了两次 \(f[100]\)。
子集的关系图其实是一张DAG,DAG上的dp只能求最值,不能求和。
那么如果我们把DAG转化为一棵树呢?注意到这张DAG是分层的,我们每个点只保留连接下一层点最靠左的那条边,DAG就变成了一棵树,根节点的贡献也只会往所有的父亲(超集)传递一次了。(下一层最靠左的点可以减去lowbit来O(1)计算)
暂且称之为超集树,然而,这棵树在复杂度提升上并没有什么卵用。。。因为每个点作为根时所构成的树性质是不同的,比如010的超集树并不是子树关系(010会去连011)。所以每个点往它的超集传递贡献时,都要重新构造一遍超集树。(这个和枚举子集复杂度一样,\(3^n\))
但是请记住这两张图的样子,下面讲子集DP时会用到。
高维前缀和:
一般提到前缀和都是一维或二维的,三维在少数毒瘤题中会用到,这里的高维前缀和特指的是每一维长度只有2的前缀和。
如下图,每一维只有 “无” 和 “有” 两个坐标,对应于二进制集合中每一位是0或1。
为了方便描述,以后我们都将 f 表示每一个点的值,将 g 表示前缀和(子集和)的值。即: \(g(S)=∑\limits_{T⊆S}f(T)\)。
高维前缀和有一个枚举子集的容斥转移:$g(S)=f(S)+∑\limits_{T⊂S}(−1)^{|S|−|T|+1}g(T) $。
这个我们都熟悉,因为二维前缀和的式子就是这么容斥推的嘛。但是这个式子的本质其实是枚举了所有的子集,容斥这个子集的贡献被加了几次,复杂度还是枚举子集。
所以高维前缀和我们不能再像二维一样容斥了。我们引入一个叫子集DP的东西(Sum Over Subsets(SOS) DP )。
子集DP:
我们都玩过2048,想象一下,我们要把所有数字加给左下角,我们是怎么操作的?先向左划,再向下划。向左划的时候,所有的值加到了左边一维,再向下划,所有的值加给了左下角,这时左下角的值就是所有数字的和,不重不漏。
我们将这个过程扩展到三维:
每次,我们选取一个维度,将这一维的所有坐标为0的点加给坐标为1的点,将所有维度加完之后,每个点表示的值就是它的高维前缀和了(还是比较巧妙的)。
这是几何上的理解,如果是DP角度的理解:我们先考虑所有含第一个元素的集合,再处理所有含前两个元素的集合,再处理所有含前三个元素的集合。。。每一次我们只转移这一个新加入的元素对前缀和造成的影响,写成代码就是:g[S]+=g[S-(1<<i)];
。这个思路和Floyd有点像。
我们再来看一看几何上的有趣现象:
我们仔细看上面子集关系DAG那张图,其实就是一个立方体的轮廓。(如果你想画出一个四维的超立方体,它的轮廓其实就是1111的子集关系DAG)
而超集树,其实是保留了立方体的一些棱边,那些保留的棱边,正是我们子集DP的转移图:
数形结合的感觉就是舒服。
有了这张图,你应该就能完全理解子集DP的转移过程了,其实就是个高维的2048。
代码也是相当之好写,分为高维前缀和和高维后缀和两种:
1、高维前缀和(子集贡献传递给超集)\(g(S)=∑\limits_{T⊆S}f(T)\):
for(int k=0;k<=19;k++)
for(int i=0;i<=maxn;i++)
if((i>>k)&1) g[i] += g[i^(1<<k)]; // i 是超集,有第 k 位
2、高维后缀和(超集贡献传递给子集)\(g(S)=∑\limits_{S⊆T}f(T)\):
for(int k=0;k<=19;k++)
for(int i=0;i<=maxn;i++)
if(!((i>>k)&1)) g[i] += g[i^(1<<k)]; // i 是子集,无第 k 位
其实,前缀和与后缀和的区别就在于,我想要将贡献加给 “第k位是1” 的点还是 “第k位是0” 的点。如果你将整个序列的01取反,那么超集就会变成子集,子集就会变成超集,前后缀的计算也会颠倒。
题意:相容是指 \(x\&y=0\) ,给你一个数列,找出每一个数在数列中与它相容的另一个数。
Solution:与x相容的数,其实就是x补集的子集。做个前缀和(加法改为直接覆盖),就知道每一个数的子集有哪些值了。询问x时,输出 g[x的补集]
高维差分:
高维前缀和的逆运算,想想我们做二维前缀和的题是为了干什么,就是二维前缀和算出来之后,就可以算任意区域的面积,这个逆过程是差分。
对应于高维差分,已知 g 数组,求 f 数组,就是差分。
和子集DP的思路一样,只是每一维退回来就可以了,将加法换成减法。
虽然 f 数组表示的是一个集合,g 是表示很多集合的和,但有的题目 f 数组并不好算,应该先算出 g 数组,再差分求出 f 。
以前缀差分为例:
for(int k=0;k<=19;k++)
for(int i=0;i<=maxn;i++)
if((i>>k)&1) f[i] -= f[i^(1<<k)]; // i 的前缀和减去 i 的子集,就是 i 本身。
题意:给你数列a,求有多少子序列与起来是0。
Solution:设 f[i] 表示与起来结果是 i 的子序列个数,发现求不出一点。但是前缀和似乎好求一些?
发现如果好几个数中都含有S集合,那么这几个数的任意子序列,与起来的结果肯定包含S集合(S的超集)。所以有几个数包含S集合呢,对原数列做个高维后缀和就求出来了。
设 \(num_S\) 表示原数列中包含S的数有几个。\(\&\) 起来结果为S超集的方案数,就是这几个数的子序列个数:\(g[S] = 2^{num_S}-1\)。(为什么减一?因为子序列不能为空)
对 g 再做一次后缀差分,就是 f 。答案输出 \(f[0]\)。其实我们把与起来是任意结果都给求出来了。
从参考博客来看,关于子集DP的知识还有 FMT 和 FWT,留个坑,下辈子补上。