0. 前言
前置知识:
1. 概念与介绍
SOSDP,翻译过来就叫做子集和 DP,又称作高维前缀和,用来解决一些涉及子集和计算的问题。
我们通过一道例题来进行引入:
给你一个含2n 的集合S,对于所有的i∈[0,2n−1],求解∑j⊂isj。
有一个显然的想法是模拟即可O(4n),但是显然我们可以通过枚举子集轻松做到O(3n),还能不能可以更优?
上面枚举子集的方法我们看有没有什么问题,我们发现当一个状态的二进制位上有k 个 0,那么它将在其他状态的带的时候被访问2k−1 次,存在许多重复且无用的计算,原因是因为我们每一个对应的si 没有和状态建立起对应的练习,而是直接暴力枚举子集,我们需要添加另一个状态来避免上述的重复计算。
我们考虑状态的设计与添加,设状态S(sta)={x∣x⊂sta}。上述的语句其实就是表明这些集合存在许多相交的部分,现在我们需要把这个集合划分为不相交的组。
设状态S(sta,i)={s∣s⊂sta,sta⊕x<2i+1}。我们将二进制位数从 0 开始从低位向高维表示,那么集合S(sta,i) 表示的就是只有第i 位以及更低位与sta 不同的x 的集合。
我们尝试将sta 与x 建立起来联系,那么有如下的分类讨论:
- sta 第i 位为 0:显而易见的,sta 与x 的第i 位均为 0。因此x 仅有0∼i−1 位和sta 不同,那么就有S(sta,i)=S(sta,i−1)。
- sta 第i 位为 1:
- 若x 的对应位置为 0,那么就是S(sta⊕2i,i−1)。
- 若x 的对应位置为 1,那么就是S(sta,i−1)。
下图描述了如何将S(sta,i) 集合相互关联。任何集合S(sta,i) 的元素都是其子树中的叶子。红色前缀表示的这一部分对其所有子结点都是公共的,而黑色部分允许不同。

实现了这些关系之后,我们可以很容易地写出相应的动态规划。
1 2 3 4 5 6 7 8 9 10 11
| for(int sta=0;sta<(1<<N);sta++) { dp[sta][-1]=A[sta]; for(int i=0;i<N;i++) { if(sta&(1<<i)) dp[sta][i]=dp[sta][i-1]+dp[sta^(1<<i)][i-1]; else dp[sta][i]=dp[sta][i-1]; } F[sta]=dp[sta][N-1]; }
|
注意到空间过于无敌,考虑滚动数组:
1 2 3 4 5 6
| for(int i=0;i<(1<<N);i++) F[i]=A[i]; for(int i=0;i<N;i++) for(int sta=(1<<N)-1;sta>=0;sta--) if(sta&(1<<i)) F[sta]+=F[sta^(1<<i)];
|
注意到这里其实可以正序枚举,但是养成好习惯吧以后写倒序的。
时间复杂度为O(n⋅2n)。
上面代码所求的是子集和,其实 SOSDP 还可以求超集和。具体的,设数组f[mask] 表示每个集合 mask
的初始值,定义:
g[S]=T⊇S∑f[T]
我们要求的是所有包含S 的超集的和。
考虑每一位从低到高处理,对于第i 位:
如果当前集合 mask
不含第i 位:
- 就可以加上 含第i 位的集合的贡献(即
mask | (1 << i)
)。
这相当于枚举所有包含 mask
的超集。
代码如下:
1 2 3 4 5
| for(int i=0;i<(1<<n);i++) g[i]=f[i]; for(int i=0;i<n;i++) for(int mask=0;mask<(1<<n);mask++) if((mask>>i&1)==0) g[mask]+=g[mask|(1<<i)];
|
另一种实现:
1 2 3 4
| for(int i=0;i<N;i++) for(int sta=(1<<N)-1;sta>=0;sta--) if(!(sta&(1<<i))) F[sta]+=F[sta^(1<<i)];
|
其实超集和其实相当于就是反过来的子集求和,子集和变化相当于就是从小集合转移到大集合,而超集和变化相当于从大集合转移到小集合。
2.例题
SOSDP 模板题。
二进制下与为 0 就是二进制位全部都不一样,那么取反之后就是二进制位完全相同的集合或这个集合的子集即可,通过 SOSDP 即可解决。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| #include<bits/stdc++.h> using namespace std; constexpr int MN=23,MK=2e6+15; int n,a[MK],f[1<<MN];
int main(){ cin>>n; for(int i=1;i<=n;i++){ cin>>a[i]; f[a[i]]=a[i]; } for(int i=0;i<22;i++){ for(int j=0;j<(1<<22);j++){ if((j>>i&1)&&f[j^(1<<i)]){ f[j]=f[j^1<<i]; } } } for(int i=1;i<=n;i++){ int x=((1<<22)-1)^a[i]; cout<<(f[x]?f[x]:-1)<<" "; } return 0; }
|
考虑i∣j≤k 实际上就是两个数在二进制表示下 1 位置的集合为k 表示集合的子集。
注意到我们只需要这个子集的最大值和次大值即可,考虑 SOSDP 维护最大值和次大值的值,转移的时候后合并需要考虑最大值和次大值而变化。答案就是子集的前缀最大值,时间复杂度O(n⋅2n)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
| #include<bits/stdc++.h> #define int long long #define pir pair<int,int> using namespace std; constexpr int MN=20,INF=1e9; int n,ans; pir a[1<<MN];
pir mergep(pir x,pir y){ pir ret; if(x.first<y.first) swap(x,y); ret=x; if(y.first>ret.second) ret.second=y.first; return ret; }
signed main(){ cin>>n; for(int i=0;i<(1<<n);i++){ int x; cin>>x; a[i]=pir(x,-INF); } for(int i=0;i<n;i++){ for(int s=0;s<(1<<n);s++){ if((s>>i)&1){ a[s]=mergep(a[s],a[s^(1<<i)]); } } } for(int i=1;i<(1<<n);i++){ ans=max(ans,a[i].first+a[i].second); cout<<ans<<'\n'; } return 0; }
|
有没有感觉这个题和上面的题差不太多,但是这里有了个与操作。但其实不太对,因为上面的问题是限制,这里的问题是求值。
注意到或运算的性质,即只要有一个二进制位为 1 那么这个位数贡献的答案就是定死的,启发我们枚举或的数,这样我们只需要解决后的的与操作了。
考虑如何让答案最大,注意到二进制的与或等操作在每一位都是独立的,启发我们按位贪心,既然是最大,考虑从高位到低位贪心。如果有两个数他们的与在这一位为 1,那么最后的答案中一定有这一位。
我那么我们逐位考虑,并且考虑是否有两个在右边的数他们 “与” 的结果为当前答案的超集即可,有的话答案直接加上这一位。
用 SOSDP 求解即可。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46
| #include<bits/stdc++.h> #define int long long #define pir pair<int,int> using namespace std; constexpr int MN=22; int n,mn[1<<MN],ans;
struct NodeMX{ int fir,sec;
friend NodeMX operator +(const NodeMX &x,const NodeMX &y){ int xmx=x.fir,ymx=y.fir; if(xmx<ymx) swap(xmx,ymx); return (NodeMX){xmx,max({ymx,x.sec,y.sec})}; } }mx[1<<MN];
signed main(){ memset(mn,0x3f,sizeof(mn)); cin>>n; for(int i=1;i<=n;i++){ int x; cin>>x; mx[x]=mx[x]+(NodeMX){i,0}; mn[x]=min(mn[x],i); } for(int i=0;i<21;i++){ for(int j=(1<<21)-1;j>=0;j--){ if(!(j&(1<<i))){ mx[j]=mx[j]+mx[j^(1<<i)]; mn[j]=min(mn[j],mn[j^(1<<i)]); } } } for(int i=20;i>=0;i--){ int now=ans|(1<<i); bool flag=0; for(int j=now;;j=(j-1)&now){ if(mn[j]<mx[now^j].sec) flag=1; if(flag||!j) break; } if(flag) ans=now; } cout<<ans; return 0; }
|
注意到m≤20,启发我们进行状压。转化为选若干个数使它们按位或为全集。
设f(i,S) 表示目前到第i 个箱子,至少放了一次玩具构成的集合为S 的方案书,转移枚举选或者不选,显然超时。
考虑优化,注意到我们一开始的只需要让他们按位或Wie全集就可以了,但是按位或如果直接算贡献的话会算重,考虑容斥。
设fi 表示选取若干个箱子或起来为状态i 的方案数,设gi 表示选取若干个数火起来为状态i 的子集的方案数。显然,设cnti 表示i 有cnti 个子集,那么gi=2cnti。求解fi 可以考虑乘上容斥系数即可了,系数见代码即可,时间复杂度O(n+m⋅2m)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
| #include<bits/stdc++.h> #define int long long using namespace std; constexpr int MN=22,MM=1e6+15,MOD=1e9+7; int n,m,ans,f[1<<MN],pw2[MM];
void initpw(){ pw2[0]=1; for(int i=1;i<MM;i++){ pw2[i]=pw2[i-1]*2%MOD; } }
signed main(){ initpw(); cin>>n>>m; for(int i=1;i<=n;i++){ int k,st=(1<<m)-1; cin>>k; while(k--){ int x; cin>>x; st^=(1<<(x-1)); } f[st]++; } for(int i=0;i<21;i++){ for(int j=0;j<(1<<21);j++){ if((j>>i)&1){ (f[j^(1<<i)]+=f[j])%=MOD; } } } for(int i=0;i<(1<<m);i++){ int cnt=__builtin_popcountll(i); if(cnt&1){ ans=(ans-pw2[f[i]]+MOD)%MOD; } else ans=(ans+pw2[f[i]])%MOD; } cout<<ans; return 0; }
|