状态压缩 (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]排列
$ exttt{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 愤怒的小鸟
$ exttt{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
",dp[(1<<n)-1]);
P3959 宝藏
$ exttt{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) 中的元素向它连一条最短的边求和后 ( imes 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
",ans);
P2157 [SDOI2009]学校食堂
$ exttt{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
",ans);
CF16E Fish
$ exttt{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)?'
':' ');
CF165E Compatible Numbers
$ exttt{solution}$
这道题写代码不难,关键在于找到递推的方法(DP方程)
思路:
如果两个数 (x) 与 (y) 是相容的,那么从 (x) 的二进制里去掉了一些 (1) 的 (x') 一定也能与 (y) 相容。也就是说,如果我们不知道 (a) 与哪个数相容,我们可以尝试把它添上一些 (1) 变成 (a') ,如果 (a') 的答案已知,那么 (a) 的答案就可以直接借用 (a') 的答案。
比如,我们不知道 (1001) 与哪个数相容,但是知道 (1011) 与 (0100) 相容,那么 (1001) 必定与 (0100) 相容。
实现:
状态转移方程:
前提是 (dp[i | 1<<j-1 ] e -1)
初始化(边界): (dpleft[2^{num_i-1} & infty ight] = 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)?'
':' ');
P2150 [NOI2015] 寿司晚宴
咕咕咕
XJOI 模拟赛 2021.10.30
有 (n) 个数,要求选出最多的数使得选出的数两两互质((n,a_ile 1000))。
$ exttt{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
",ans);
}
return 0;
}
P2473 [SCOI2008] 奖励关
$ exttt{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
",dp[1][0]);
return 0;
}