状压DP
状态压缩 \(\operatorname{DP}\) 是将比较复杂的状态映射成数字后进行 \(\operatorname{DP}\) 。
难点:设计状态
基本位运算
枚举子集
for(int i=s;i;i=(i-1)&s)
O(1) 计算 int 以内每一个数含有多少个 1
预处理出 \(2^{16}\) 次方以内每一个数含有多少 \(1\) :
int All=(1<<16)-1;
for(int i=1;i<=All;i++) cnt[i]=cnt[i>>1]+(i&1);
查询的时候将每一个数分为高 \(16\) 位与低 \(16\) 为分别查询:
int Count(int x) { return cnt[x>>16]+cnt[x&All]; }
例题
P4163 [SCOI2007]排列
$\texttt{solution}$
\(dp[s][j]\) 表示已近选走的数的选择情况,\(j\) 表示选走的数所组成的数 \(\mod {d}\) 以后的值。
注意:在转移时不能从直接是
需要记录值为 \(a[i]\) 的数是否已近被转移过了,同一个数铺两次会重复计算。
核心代码:
memset(dp,0,sizeof(dp)),dp[0][0]=1;
for(int s=0;s<(1<<len);s++)
{
memset(used,0,sizeof(used));
for(int i=1;i<=len;i++)
if(!used[num[i]] && !(s & (1<<(i-1))))
{
used[num[i]]=true;
for(int j=0;j<d;j++) dp[s | (1<<(i-1))][(j*10+num[i])%d]+=dp[s][j];
// k 是第 i 位之前的数组成的数 mod d 的值
}
}
P2831 愤怒的小鸟
$\texttt{solution}$
设 \(Line[i][j]\) 表示以第 \(i\) 只与第 \(j\) 只小猪构成的抛物线能射中小猪的集合( \(2\) 进制)可以通过推公式在预处理 \(O(n^3)\) 时间内求出:
推导得:
考虑状压:\(dp[s]\) 表示已经死了的猪的集合状态为 \(s\) 时最少要发射的鸟数,容易推导出:
然而仅仅这样的话复杂度是 \(O(Tn^22^n)\) 的,通过不了。。
优化:
若令 \(x\) 为满足 \(s \& (1<<(x-1))=0\) 的最小正整数,则由 \(s\) 扩展的转移的所有线都要经过 \(x\) 。
原因(例子):先打 \(\{1,4\}\) 再打 \(\{2,3\}\) 和先打 \(\{2,3\}\) 再打 \(\{1,4\}\) 不是一样的吗?
如果这一次转移不打 \(x\) ,那以后还要再回过头来打 \(x\) ,这就是多余的转移。
所以转移时默认 \(x\) 必选 。
这样复杂度就是 \(O(Tn2^n)\) 的了。
核心代码:
void solve(double &a,double &b,double a1,double b1,double a2,double b2,double Div)
{
a=(b1*a2-b2*a1)/Div;
b=(b2*a1*a1-b1*a2*a2)/Div;
}
memset(cnt,0,sizeof(cnt));
memset(dp,inf,sizeof(dp));
dp[0]=0;
for(int i=1;i<n;i++)
{
for(int j=i+1;j<=n;j++)
{
double a,b;
solve(a,b,x[i],y[i],x[j],y[j],x[i]*x[j]*(x[i]-x[j]));
if(a>=-zero) continue;
for(int k=1;k<=n;k++)
{
double tmp=a*x[k]*x[k]+b*x[k]-y[k];
if(tmp>=-zero && tmp<=zero) cnt[i][j] |= (1<<(k-1)),cnt[j][i] |= (1<<(k-1));
}
}
}
for(int s=0;s<(1<<n);s++)
{
ll i=1;
while(i<=n && (s & (1<<(i-1)))) i++;
dp[s | (1<<(i-1))]=min(dp[s | (1<<(i-1))],dp[s]+1);
for(int j=i+1;j<=n;j++)
dp[s | cnt[i][j]]=min(dp[s | cnt[i][j]],dp[s]+1);
}
printf("%d\n",dp[(1<<n)-1]);
P3959 宝藏
$\texttt{solution}$
题目的意思就是找一棵生成树,使得代价和最小。
状态:设 \(dp[s][i]\) 为当前生成树已经包含集合 \(s\) 中的点,并且树高是 \(i\) 。
转移:
\(s_0\) 满足:\(s_0\) 能够通过连边成为 \(s\) 。若要判断一个集合是否是合法的 \(s_0\) 可以预处理出这个集合所能够连边构成的连通块的集合(即:\(Go\_to\) 数组),再判断:若
则这个集合是合法的。
考虑 \(cost\) 怎样计算:设 \(ss=s \oplus s_0\) ,即 \(ss\) 是在 \(s\) 但不在 \(s_0\) 中的元素。\(cost\) 的计算就是对于每个 \(ss\) 中的元素取 \(s_0\) 中的元素向它连一条最短的边求和后 \(\times i\)。
核心代码:
memset(dis,inf,sizeof(dis));
for(int i=1;i<=m;i++)
{
u=rd(),v=rd(),d=rd();
dis[u][v]=dis[v][u]=min(dis[u][v],d);
}
for(int i=1;i<=n;i++) dis[i][i]=0;
for(int i=1;i<(1<<n);i++)
{
for(int j=1;j<=n;j++) if(i & (1<<(j-1)))
for(int k=1;k<=n;k++) if(dis[j][k]!=inf) Go_to[i] |= (1<<(k-1));
}
memset(dp,inf,sizeof(dp));
for(int i=1;i<=n;i++) dp[1<<(i-1)][0]=0;
// 如果直接挖到每个点,那个点的深度是 0
for(int s=1;s<(1<<n);s++)
{
for(int s0=s-1;s0;s0=(s0-1) & s) if((Go_to[s0] | s) == Go_to[s0]) // 通过 s0 加边变为 s
{
int add=0,ss=s^s0; // s 是 s 对于 s0 的补集
for(int i=1;i<=n;i++) if(ss & (1<<(i-1)))
{
int tmp=inf;
for(int j=1;j<=n;j++) if(s0 & (1<<(j-1))) tmp=min(tmp,dis[i][j]);
add+=tmp;
}
for(int i=1;i<n;i++) dp[s][i]=min(dp[s][i],dp[s0][i-1]+add*i);
}
}
for(int i=0;i<n;i++) ans=min(ans,dp[(1<<n)-1][i]);
printf("%d\n",ans);
P2157 [SDOI2009]学校食堂
$\texttt{solution}$
状态:设 \(f[i][j][k]\) 表示第 \(1\) 个人到第 \(i-1\) 个人已经打完饭,第 \(i\) 个人以及后面 \(7\) 个人是否打饭的状态为 \(j\) ,当前最后一个打饭的人的编号为 \(i+k\) ( \(k\) 的范围为 \(-8\) 到 \(7\) ,所以用数组存时要加上 \(8\) )。
转移:
- 当 \(j\&1 = \operatorname{True}\) :
表示第 \(i\) 个人已经打完饭,\(i\) 之后的 \(7\) 个人中,还没打饭的人就再也不会插入到第 \(i\) 个人前面了。(此时不用消耗时间)
- 当 \(j\&1 = \operatorname{False}\) :
可以把 \(i\) 以及 \(i\) 之后的 \(7\) 个人中选出一个人打饭,也就是枚举选 \(0\) 到 \(7\)
其中 \(Time(i,j)\) 表示如果上一个人编号为 \(i\) ,当前的人编号为 \(j\) ,那么做编号为 \(j\) 的人的菜需要的时间。
然而,转移还需要考虑到忍耐度的问题。可以被选中的 \(x\) 在他之前的所有未打饭的人必须能忍受这个人先打饭。
可以算出到目前为止的 未打饭的人的忍受最大位置 的最小值( \(pos\) )。对于任何一个人,如果 \(i+x > pos\) ,就表示他无法满足编号在他之前的所有人,就不要考虑这个人了。
注(一个奇怪的问题):\((a[x]∣a[y]) - (a[x] \& a[y]) = a[x] \oplus a[y]\)
核心代码:
for(int i=1;i<=n;i++) a[i]=rd(),ren[i]=rd();
memset(dp,inf,sizeof(dp)),dp[1][0][7]=0;
for(int i=1;i<=n;i++)
for(int j=0;j<(1<<8);j++)
for(int k=-8;k<=7;k++) if(dp[i][j][k+8]!=inf)
{
if(j & 1) dp[i+1][j>>1][k-1+8]=min(dp[i+1][j>>1][k-1+8],dp[i][j][k+8]);
else
{
int pos=n;
for(int x=0;x<=7;x++) if(!((j>>x) & 1)) pos=min(pos,i+x+ren[i+x]);
for(int x=0;x<=7;x++) if(!((j>>x) & 1))
{
if(i+x>pos) break;
dp[i][j | (1<<x)][x+8]=min(dp[i][j | (1<<x)][x+8],dp[i][j][k+8]+((i+k)?(a[i+x] ^ a[i+k]):0));
}
}
}
for(int i=-8;i<=0;i++) ans=min(ans,dp[n+1][0][i+8]);
printf("%d\n",ans);
CF16E Fish
$\texttt{solution}$
状态:设 \(dp[s]\) 表示剩下的鱼的集合。
转移:枚举一条被吃掉的鱼和一条把它吃掉的鱼,累加到 \(dp[s]\) 。
方程:
\(\operatorname{cnt}\) 是 \(s\) 中 \(1\) 的个数。
核心代码:
for(int j=1;j<=n;j++) scanf("%lf",&a[i][j]);
dp[(1<<n)-1]=1;
for(int s=(1<<n)-2;s>=0;s--)
{
int cnt=0,tmp=s;
while(tmp) cnt+=(tmp&1),tmp/=2;
for(int i=1;i<=n;i++)
{
if(!(s & (1<<(i-1)))) continue;
for(int j=1;j<=n;j++) // i eat j
{
if(s & (1<<(j-1))) continue;
dp[s]+=dp[s | (1<<(j-1))]*a[i][j]/(cnt*(cnt+1)/2);
}
}
}
for(int i=1;i<=n;i++) printf("%.6lf%c",dp[1<<(i-1)],(i==n)?'\n':' ');
CF165E Compatible Numbers
$\texttt{solution}$
这道题写代码不难,关键在于找到递推的方法(DP方程)
思路:
如果两个数 \(x\) 与 \(y\) 是相容的,那么从 \(x\) 的二进制里去掉了一些 \(1\) 的 \(x'\) 一定也能与 \(y\) 相容。也就是说,如果我们不知道 \(a\) 与哪个数相容,我们可以尝试把它添上一些 \(1\) 变成 \(a'\) ,如果 \(a'\) 的答案已知,那么 \(a\) 的答案就可以直接借用 \(a'\) 的答案。
比如,我们不知道 \(1001\) 与哪个数相容,但是知道 \(1011\) 与 \(0100\) 相容,那么 \(1001\) 必定与 \(0100\) 相容。
实现:
状态转移方程:
前提是 \(dp[i | 1<<j-1 ] \ne -1\)
初始化(边界): \(dp\left[2^{num_i-1} \& \infty\right] = num[i]\)
\(\infty\) 是 \((1<<22)-1\) (上界)
核心代码:
memset(dp,-1,sizeof(dp));
for(int i=1;i<=n;i++) num[i]=rd(),dp[(~num[i]) & inf]=num[i];
for(int i=inf;i>=0;i--) if(dp[i]==-1) // 需要转移
for(int j=1;j<=22;j++) if(!(i & (1<<(j-1))) && dp[i | (1<<(j-1))]!=-1) // 可以转移
{
dp[i]=dp[i | (1<<(j-1))];
break; // 只用转移一次就可以了
}
for(int i=1;i<=n;i++) printf("%d%c",dp[num[i]],(i==n)?'\n':' ');
P2150 [NOI2015] 寿司晚宴
咕咕咕
XJOI 模拟赛 2021.10.30
有 \(n\) 个数,要求选出最多的数使得选出的数两两互质(\(n,a_i\le 1000\))。
$\texttt{solution}$
注意到值域的范围比较小,最多 \(1\) 个 \(\ge \sqrt{1000}=31\) 的质因子。
因此我们将每个数 \(\le 31\) 的质因子状压,留下剩余的大因数。
我们发现如果剩下的数为 \(1\),即没有大因数,那么可以非常容易地用状压 dp 完成。
而加上有大因数的情况后,实际上就变为对于每个含有这个大因数的数字,最多只能选择一个作为答案。
因此这就转化为了一个类似于背包的状压 dp,滚动一维后暴力转移即可。
#define Maxn 1005
#define Maxpown 5005
#define pb push_back
int T,n,m,ans,All;
int a[Maxn],dp[Maxpown],tmp[Maxpown];
vector<int> Left[Maxn];
int prime[11]={2,3,5,7,11,13,17,19,23,29,31};
int main()
{
T=rd(),All=(1<<11)-1;
while(T--)
{
n=rd(),ans=0;
for(int i=1;i<=1000;i++) Left[i].clear();
for(int i=1,Now;i<=n;i++)
{
a[i]=rd(),Now=0;
for(int j=0;j<11;j++)
while(a[i]%prime[j]==0) Now|=1<<j,a[i]/=prime[j];
Left[a[i]].pb(Now);
}
memset(dp,-inf,sizeof(dp)),dp[0]=0;
for(int i:Left[1])
{
memset(tmp,-inf,sizeof(tmp));
for(int s=0;s<=All;s++) if(!(s & i))
tmp[s | i]=max(tmp[s | i],dp[s]+1);
for(int j=0;j<=All;j++) dp[j]=max(dp[j],tmp[j]);
}
for(int i=2;i<=1000;i++) if(Left[i].size())
{
memset(tmp,-inf,sizeof(tmp));
for(int j:Left[i])
for(int s=0;s<=All;s++) if(!(s & j))
tmp[s | j]=max(tmp[s | j],dp[s]+1);
for(int j=0;j<=All;j++) dp[j]=max(dp[j],tmp[j]);
}
for(int i=0;i<=All;i++) ans=max(ans,dp[i]);
printf("%d\n",ans);
}
return 0;
}
P2473 [SCOI2008] 奖励关
$\texttt{solution}$
如果我们假设 \(dp(i,s)\) 表示进行了 \(i\) 轮,当前选择过的物品集合为 \(s\) 的价值期望,会发现这样转移很难维护。
因为我们的转移一定是从一个集合转移到另一个集合,最终答案为第一轮,没有去物品,这让我们想到要倒过来求。
我们在转移的时候方便求出这一次增加的价值,那就不妨这样假设:设 \(dp(i,s)\) 表示在 \([1,i-1]\) 轮中选择了集合 \(s\),在 \([i,k]\) 轮中获得价值的期望。
这样就可以枚举每一个点是否能够去来转移了。
注意:期望是倒推的,概率是顺推的,而期望和概率都是相加后再除去的。
#define Maxn 20
#define Maxk 105
#define Maxsta 100005
int k,n,All;
int a[Maxn],pre[Maxn];
double dp[Maxk][Maxsta];
int main()
{
k=rd(),n=rd(),All=(1<<n)-1;
for(int i=1;i<=n;i++)
{
a[i]=rd();
for(int tmp=rd();tmp;tmp=rd()) pre[i]|=(1<<(tmp-1));
}
for(int i=k;i>=1;i--)
for(int s=0;s<=All;s++)
{
for(int j=1;j<=n;j++)
{
if((pre[j] & s) != pre[j]) dp[i][s]+=dp[i+1][s];
else dp[i][s]+=fmax(dp[i+1][s],dp[i+1][s | (1<<(j-1))]+a[j]);
}
dp[i][s]/=1.0*n;
}
printf("%.6lf\n",dp[1][0]);
return 0;
}