(笔记)Sum over Subsets SOS 子集 DP 高维前缀和
本质上就是在做轮廓线 DP。这玩意是静态的,它能解决的东西轮廓线 DP 都能解决,它不能解决的东西轮廓线 DP 还能解决,所以学它干嘛。
下文集合运算 \(\oplus\) 统一表示按位异或,\(\cup\) 表示按位或(并集),\(\cap\) 表示按位与(交集)。
例题
求 \(F[mask]=\sum_{i\subseteq mask}A[i]\)
我们要解决什么问题?把集合视为 \(n\) 位二进制数,则该问题实则是每维只有 \(0,1\) 两种取值的 \(n\) 维偏序,因此可以直接高维前缀和求解。为什么这么称呼?我们来看几个实例:
\(1\) 维前缀和:\(F[i]=\sum_{a=0}^iG[a]\)
\(2\) 维前缀和:\(F[i][j]=\sum_{a=0}^i\sum_{b=0}^jG[a][b]\)
\(3\) 维前缀和:\(F[i][j][k]=\sum_{a=0}^i\sum_{b=0}^j\sum_{c=0}^kG[a][b][c]\)
\(\dots\)
\(n\) 维前缀和:\(F[i_1][i_2][\dots][i_n]=\sum_{j_1=0}^{i_1}\sum_{j_2=0}^{i_2}\dots\sum_{j_n=0}^{i_n}G[j_1][j_2][\dots][j_n]\)
对于这样的问题,我们不妨从后往前对每一维做一个前缀和,然后就可以得到 \(n\) 维前缀和了。我们的代码中也是如此实现。
朴素处理
方法1:枚举 \(set\) 和 \(subset\) (包含可能不是 \(subset\) 的集合),每个枚举为 \(O(2^n)\),两次枚举共 \(O(4^n)\)。
for(int mask=0;mask<=(1<<n)-1;mask++)
for(int i=0;i<=mask;i++)
if((mask&i)==i)F[mask]+=A[i];
方法2:枚举 \(set\) 并且只遍历是 \(subset\) 的集合,根据二项式定理,总循环数为 \(\begin{aligned} \sum_{i=0}^n \begin{pmatrix} n\\i\end{pmatrix} 2^i= \sum_{i=0}^n \begin{pmatrix} n\\i\end{pmatrix} 2^i 1^{n-i}=(2+1)^n=3^n\end{aligned}\)
时间复杂度为 \(O(3^n)\)。
for(int mask=0;mask<=(1<<n)-1;mask++)
for(int i=mask;i>=0;i--)
i&=mask,F[mask]+=A[i];
子集 DP
方法3:
留档先前讲解
Attention:下文所有 \(\oplus\) 表示位运算异或
规定 \(F(mask,i)\) 为对 \(mask\) 所有满足以下条件的子集的个数
1.子集第 \(i+1\) 或以上位与 \(mask\) 完全一致
e.g. \(mask=1101011\)
\(F(mask,3)=\{1101011,1101001,1101000,1101010\}\)
推出转移方程 \(F(mask,i)\) 当 \(mask\) 第 \(i\) 位为 \(0\) 时,
就为 \(F(mask,i-1)\)
(因为第 \(i\) 位要保持是 \(mask\) 的子集,不能取 \(1\),只能取 \(0\),所以相当于对答案没有贡献)
\(F(mask,i)\) 当 \(mask\) 第 \(i\) 位为 \(1\) 时,
\(F(mask,i)=F(mask,i-1)+F(mask\oplus 2^i,i-1)\)
其中 \(F(mask,i-1)\) 是所有子集第 \(i\) 位都保持 \(1\) 的情况
\(F(mask\oplus 2^i,i-1)\) 是所有子集第 \(i\) 位保持 \(0\) 的情况
(其实就相当于把 \(mask\) 第 \(i\) 位换成 \(0\),然后直接转移)
不难用 \(O(n2^n)\) 解决
(实例代码中代码优化相当于置换 \(i\),\(j\),通过调换求值优先级且仍然合法来优化空间复杂度)
则开始初始化\(F(i)=A(i)\)即可
实质上,我们可以采取逐位考虑的思想。我们不妨在二进制上从低到高考虑对一个 \(mask\) 的子集怎么求和。让当前考虑到第 \(i(i=0\dots n-1)\) 位,用一个类似前缀和的数组 \(F[i][mask]\) 表示对于 \(mask\) 的 \(i+1\) 到 \(n-1\) 位不变,而 \(0\) 到 \(i\) 位可以上为 \(mask\) 这些位上子集的所有子集的权值和。这样对于顺序考虑的 \(i\),每次我们只需要考虑从小到大地把每个 \(mask\) 该位上为 \(1\) 的权值和加上该位为 \(0\) 的权值和,即有转移:
时间复杂度 \(O(n2^n)\),此处使用了滚动数组优化 \(i\) 维。
for(int i=0;i<(1<<N);i++)F[i]=A[i];
for(int i=0;i<N;i++)
for(int mask=0;mask<(1<<n);mask++)
if(mask&(1<<i))
F[mask]+=F[mask^(1<<i)];
我们有更好的方法:轮廓线 DP
理论上如果涉及子集和父集关系的判断,高维前缀和一般很难维护,这时候就可以使用记忆化搜索,钦定 \(f(i,j,...,state)\) 表示在第 \(i\) 行第 \(j\) 列 时 \([i][1\rightarrow j]\) 和 \([i-1][(j+1)\rightarrow m]\) 状态拼起来为 \(state\) 的结果(称为轮廓线)。这样做更好处理子集关系,而且时间复杂度和直接高维前缀和是一样的。
在高维前缀和的实质实现过程中,我们状态定义中 \(F[i][mask]\) 实质上就是做了这一过程。我们在 \(i+1\dots n-1\) 这些位上保留了原 \(mask\) 的强制条件,在 \(0\dots i\) 的位置上条件变成了只要是原 \(mask\) 这些位上的子集即可。
衍生品
实时求子集 sum/max/min 的数据结构
发现有两种暴力方式。第一种是 \(O(1)\) 插入,\(O(2^n)\) 查询。第二种是 \(O(2^n)\) 插入,\(O(1)\) 查询。平衡一下,我们不妨挑出 \(S\) 的二进制高的那 \(10\) 位做第一种暴力,对低的那一位做第二种暴力。显然如果两个集合存在包含关系,那么它们拆开的这两部分也应都存在包含关系。
首次读入信息直接做一个 SOS DP,然后对于前 \(10\) 位开 \(2^{10}\) 个结构,每个结构里面再开 \(2^{10}\) 个结构表示固定高位对应的低 \(10\) 位的子集和。每次修改只需要改一个结构,在里面对所有超集进行加法,\(O(2^{n/2})\)。每次查询需要查 \(O(2^{n/2})\) 个结构,每个结构里面只需要调用一个信息。
时间复杂度 \(O((n+q)2^{n/2})\),单次操作/查询 \(O(2^{n/2})\)。
点击查看代码
#include<bits/stdc++.h>
using namespace std;
typedef long long LL;
const int N=20,lim=10;
int n,q,v[1<<N];
struct DS{
LL sum[1<<lim];
void init(){
for(int i=0;i<lim;i++)
for(int j=0;j<(1<<lim);j++)
if(j&(1<<i))sum[j]+=sum[j^(1<<i)];
}
void modify(int p,LL x){
for(int i=p;i<(1<<lim);i=(i+1)|p)
sum[i]+=x;
}
}T[1<<lim];
int main(){
ios::sync_with_stdio(0);
cin.tie(0);cout.tie(0);
cin>>n>>q;
for(int i=0;i<(1<<n);i++){
cin>>v[i];
int P=(i>>lim),Q=i^(P<<lim);
T[P].sum[Q]=v[i];
}
for(int i=0;i<(1<<lim);i++)
T[i].init();
while(q--){
int op,S;
cin>>op>>S;
int P=(S>>lim),Q=S^(P<<lim);
if(op==1){
LL ans=0;
for(int i=P;i;i=(i-1)&P)
ans+=T[i].sum[Q];
ans+=T[0].sum[Q];
cout<<ans<<'\n';
}
else {
LL a;cin>>a;
T[P].modify(Q,a-v[S]);
v[S]=a;
}
}
return 0;
}
封装一下这个结构大概长这样:
点击查看代码
struct SUB{
const int lim=9;
struct DS{
int sum[1<<lim];
void modify(int p,LL x){
for(int i=p;i<(1<<lim);i=(i+1)|p)
sum[i]+=x;
}
}T[1<<lim];
void modify(int S,int x){
int P=(S>>lim),Q=S^(P<<lim);
T[P].modify(Q,x);
}
int query(int S){
int P=(S>>lim),Q=S^(P<<lim);
int res=T[0].sum[Q];
for(int i=P;i;i=(i-1)&P)
res+=T[i].sum[Q];
return res;
}
}T;
and 和为 0 计数问题
解决思路是按照给出的 \(\text{bit}\) 分类为与起来至少有 \(i\) 位是 \(1\),做一个反的子集 DP
即超集 DP,就是在转移的时候把 \(1\) 视为 \(0\),\(0\) 视为 \(1\)。然后容斥原理就可以得到最终答案。
思路需要稍微转换:P6442 [COCI2011-2012#6] KOŠARE
集合推导问题
CF1208F Bits And Pieces
我们先要对所求信息进行几步归约。
-
按位运算具有交换律所以 \(a_j\cap a_k\) 不用考虑 \(j,k\) 顺序。
-
考虑固定其中一个数如 \(a_i\)(实际想题可能要都试一遍),然后进行集合推导。具体来说,我们有以下集合运算法则:
首先根据高中必修一:
这两个东西很方便我们做补集转化。
我们发现,对于两个集合运算,\(A\cap B\) 就是对于 \(A\) 去掉其在 \(A\) 中出现了但是在 \(B\) 中没有出现的元素(对 \(B\) 同理),然后 \(A\cup B\) 就是对于 \(A\) 加上了其在 \(A\) 中未出现但是在 \(B\) 中出现了的元素。
然后我们对应地扭曲一下按位与(交集)和按位或(并集):
如果集合满足子集关系,我们不妨给他们定义一下加减法则,那么就可以直观表示成:
其中我们在固定 \(A\) 的情况下,求出 \(\cap,\cup\) 运算的对应值我们都可以利用按位考虑的技巧,从高到低位判断如果 \(\overline A\cap B,A\cup\overline B\) 是一个值 \(S\),那么它是否可以被达到就可以用一个超集 DP 完成。这样的判定性对于单个 \(A\) 是 \(\log\) 级别的。
运用以上 trick,我们不难得到答案就是 \(a_i+(\overline{a_i}\cap a_j\cap a_k)\),只需要解决一个判定性问题,找到任两个 \(i<j,i<k\) 即可,记一个 \(i\) 最大值和次大值。不妨令值域 \(V=O(2^m)\),时间复杂度为 \(O((n+V)\log V)\)。
反思:在部分需要存储最大和次大问题中,需要注意转移不要遗漏:
点击查看代码
#include<bits/stdc++.h>
using namespace std;
typedef long long LL;
const int N=2.1e6+5;
int cnt,n,m,a[N],F[N],G[N],mx;
inline void chkmx(int pre,int mask){
if(F[pre]>F[mask]){
if(F[mask]>G[mask])G[mask]=F[mask];
F[mask]=F[pre];
}
else if(F[pre]>G[mask])G[mask]=F[pre];
if(G[pre]>G[mask])G[mask]=G[pre];
}
int main(){
ios::sync_with_stdio(0);
cin.tie(0);cout.tie(0);
cin>>cnt;
for(int i=1;i<=cnt;i++){
cin>>a[i],mx=max(mx,a[i]);
G[a[i]]=F[a[i]];
F[a[i]]=i;
}
n=int(floor(log2(mx)))+1;
m=1<<n;
for(int i=0;i<n;i++)
for(int mask=0;mask<m;mask++){
if((mask>>i)&1)continue;
int pre=mask^(1<<i);
chkmx(pre,mask);
}
int ans=0,all=m-1;
for(int i=1;i<=cnt;i++){
int na=all^a[i],res=0;
for(int j=n-1;j>=0;j--){
if(!((na>>j)&1))continue;
res|=(1<<j);
if(G[res]<=i)res^=(1<<j);
}
if(G[res]>i)ans=max(ans,a[i]+res);
}
cout<<ans;
return 0;
}

浙公网安备 33010602011771号