【题解】状压 DP 选做
总述:
因为我一直不会状压 \(\text{DP}\),所以就非常苦恼。正好最近找到了七八道状压的题,做完了之后就感觉非常的通透。
对于这类题的基本特征就是:有某一个值非常的小,一般都是十几,这样就可以考虑将这一个值状压,然后进行 \(\text{DP}\)。
对于状压 \(\text{DP}\),其都会有一个数,在二进制下的 \(01\) 串叫做状态,我的习惯是记作 \(S\) 或在代码里用 \(i\) 代替。对于一般的状态而言,我们将 \(1\) 认为是已选择,将 \(0\) 认为是没有选择。
我们一般的 \(\text{DP}\) 状态就是: \(dp[S]\) 代表当状态为 \(S\) 时,也可以理解为当选择的为 \(S\) 时,我们的答案是多少。
而一般的转移就是枚举一个 \(S\) 中没有的元素设为 \(j\),然后得到 \(dp[S\and j]\) 的值。
题目详解:
A.Hie with the Pie
题目描述:
题目分析:
我们会发现这个题的 \(n\) 的范围很小,所以就考虑将节点数状压。那么就很明显的一个状态:\(dp[S]\) 代表经过了 \(S\) 中的点的最短路径,但是我们会发现很难转移,假设我们要新添加一个节点的话,那么我们的答案究竟是加什么,并不知道。
那么我们就考虑多加一个值:\(dp[S][i]\) 代表经过了 \(S\) 中的点且当前在 \(i\) 点的最短路,那么这样当我们新加入一个节点 \(k\) 的时候,就只需要在原有的基础上加上 \(dis[i][k]\) 就好了,也就代表从 \(i\) 点走向 \(k\) 点。
代码详解:
点击查看代码
#include<cstdio>
#include<algorithm>
#include<cmath>
#include<iostream>
#include<cstring>
using namespace std;
const long long INF = 1e9+5;
long long dis[20][20],dp[20][1<<18],n;
void floyed(){
for(long long k=1; k<=n; k++){ //一定要先求一下最短路啊!!!!!!!!
for(long long i=1; i<=n; i++){
for(long long j=1; j<=n; j++){
dis[i][j] = min(dis[i][j],dis[i][k] + dis[k][j]);
}
}
}
}
int main(){
while(1){
memset(dp,0x3f,sizeof(dp));
cin>>n;
if(n == 0)
break;
n++; //0 号节点不舒服所以就加上
for(long long i=1; i<=n; i++){
for(long long j=1; j<=n; j++){
cin>>dis[i][j];
}
}
floyed();
//dp[i][j] 表示经过了 i 集合里的点现在在点 j 的最优解
dp[1][1] = 0;
for(long long i=1; i<=(1<<n)-1; i++){
for(long long j=1; j<=n; j++){
if(i & (1<<(j-1))){
for(long long k=1; k<=n; k++){
dp[i|(1<<(k-1))][k] = min(dp[i|(1<<(k-1))][k],dp[i][j] + dis[j][k]);
}
}
}
}
cout<<dp[(1<<n)-1][1]<<endl;
}
return 0;
}
我们从 \(i\) 点走向 \(k\) 点肯定要走最短路,而他给出的不一定是最短路,所以就需要跑一遍 \(\text{Floyed}\) 来得到最短路。作为第一道状压 \(\text{DP}\),鬼知道我 \(Debug\) 了多久。注意减一加一、初始值、位运算的括号等看似很小却很致命的问题。
B.Sitting in Line
题目描述:
题目分析:
我们会发现 \(n\) 很小,所以我们就考虑将 \(n\) 状压,状态为 \(dp[S]\) 为先选择了 \(S\) 这些数的最大乘积。
但是这样就有一个问题,我们在加入一个新的数的时候必须知道他前面的数是什么才可以计算加入之后的贡献,所以我们就记一下。状态就变成了 \(dp[S][i]\) 表示先选择了 \(S\) 这些数,最后一个选择的是 \(i\) 的最大乘积。
假设我们 \(S\) 中有 \(cnt\) 个 \(1\),那么意味着我们现在新加入的这个数就要放在 \(cnt+1\) 的位置上,所以要判断能不能放在这里。假设原本第 \(i\) 个位置有必须放在这里的但是我们却将别的数放在了这里并转移,这样也不用担心,因为这种状态无法转移到最终的状态。
代码详解:
点击查看代码
#include<bits/stdc++.h>
using namespace std;
const long long INF = 1e18+5;
struct node{
long long val,pos;
}a[20];
long long dp[1<<20][20];
long long get_num(long long x){ //1 的个数
long long ans = 0;
while(x){
x -= x & (-x); //每次减掉一位
ans++;
}
return ans;
}
int main(){
long long t;
cin>>t;
for(long long h=1; h<=t; h++){
long long n;
scanf("%lld",&n);
for(long long i=1; i<=n; i++){
scanf("%lld%lld",&a[i].val,&a[i].pos);
}
//dp[s][i] 表示选择了 s 里面的数最后一个放的是 i 的最大贡献
for(long long i=1; i<=(1<<n)-1; i++){
for(long long j=1; j<=n; j++){
dp[i][j] = -INF;
}
}
for(long long i=1; i<=n; i++){
if(a[i].pos == -1 || a[i].pos == 0){
dp[1<<(i-1)][i] = 0;
}
}
for(long long i=1; i<=(1<<n)-1; i++){
for(long long j=1; j<=n; j++){
if(i & (1<<(j-1)) && dp[i][j] != -INF){
for(long long k=1; k<=n; k++){
if(i & (1<<(k-1)) || k == j) continue;
if(a[k].pos == get_num(i) || a[k].pos == -1)
dp[i | (1<<(k-1))][k] = max(dp[i | (1<<(k-1))][k],dp[i][j] + a[j].val * a[k].val);
}
}
}
}
long long ans = -INF;
for(long long i=1; i<=n; i++){
ans = max(ans,dp[(1<<n)-1][i]);
}
printf("Case #%lld:\n%lld\n",h,ans);
}
return 0;
}
需要注意 \(\text{DP}\) 的初值,假如我们只放了一个元素,显然贡献为 \(0\)。也要注意一下转移条件的判断。
C.Maze
题目描述:
题目分析:
看到这么少的钥匙数量自然会想到状压 \(DP\),但是我们会发现我们的转移并不是十分简单的,他是通过遍历整张图来转移的。
那么我们如果记录一个数组 \(dp[i][j][S]\) 代表到达 \((i,j)\) 这个点,拿到钥匙的情况为 \(S\) 的话是十分麻烦的,而且也没有必要。那么我们就在遍历图也就是 \(\text{BFS}\) 的时候记下当前点是什么,拿到钥匙的情况以及走到这里花了几秒。
由此可见:我们的状态压缩只是指的将状态压缩成二进制,而不是指的必须使用类似 \(DP\) 的方法转移。
代码详解:
点击查看代码
#include<bits/stdc++.h>
using namespace std;
struct node{
int x,y,key,cost;
node(){}
node(int _x,int _y,int _key,int _cost){
x = _x,y=_y,key = _key,cost = _cost;
}
};
bool vis[55][55][1<<11];
int n,m,key[55][55],wall[55][55][55][55];
int dx[7] = {0,1,-1,0,0};
int dy[7] = {0,0,0,1,-1};
void bfs(){
queue<node> q;
vis[1][1][key[1][1]] = true;
q.push(node(1,1,key[1][1],0));
while(!q.empty()){
node now = q.front();q.pop();
if(now.x == n && now.y == m){
cout<<now.cost<<endl;
return;
}
for(int i=1; i<=4; i++){
int fx = dx[i] + now.x,fy = dy[i] + now.y;
if(!(fx >= 1 && fx <= n && fy >= 1 && fy <= m)) continue;
int door = wall[now.x][now.y][fx][fy];
int key1 = now.key;
int key2 = key1 | key[fx][fy];
if(!vis[fx][fy][key2] && (door == -1 || (1<<(door-1) & key1))){
q.push(node(fx,fy,key2,now.cost + 1));
vis[fx][fy][key2] = true;
}
}
}
cout<<-1<<endl;
}
int main(){
// freopen("in.txt","r",stdin);
// freopen("out.txt","w",stdout);
int p;
while(cin>>n>>m>>p){
memset(wall,-1,sizeof(wall));
memset(key,0,sizeof(key));
memset(vis,false,sizeof(vis));
int k;
cin>>k;
for(int i=1; i<=k; i++){
int x1,y1,x2,y2,g;
cin>>x1>>y1>>x2>>y2>>g;
wall[x1][y1][x2][y2] = wall[x2][y2][x1][y1] = g;
}
int s;
cin>>s;
for(int i=1; i<=s; i++){
int x,y,z;
cin>>x>>y>>z;
key[x][y] |= (1<<(z-1));
}
bfs();
}
return 0;
}
注意因为一个位置可能有多把钥匙,所以要或一下。
D.Doing Homework
题目描述:
题目分析:
我们观察这些任务的个数非常少,所以就考虑使用状压。状态也十分显然: \(dp[S]\) 代表只完成 \(S\) 里的任务最少扣的分值,那么我们新加入一个点的时候显然不需要知道上一个是怎么来的,只需要知道前面所有的总时间就可以了,那么也就是可以转移。
那么加入一个点 \(i\) 的代价显然就是:\(S\) 内点的时间与 \(i\) 的时间之和与 \(i\) 个截止时间的差。需要注意要求输出字典序最小的方案,所以就先按名称排序然后再转移就好了,具体什么顺序可以自己尝试几次。
代码详解:
点击查看代码
#include<bits/stdc++.h>
using namespace std;
const long long MAXN = 20;
struct node{
string name;
long long time,end;
}a[MAXN];
long long dp[1<<19],from[1<<19];
bool cmp_name(node l,node r){
return l.name > r.name;
}
long long ans(long long h){
long long now = 1,res = 0;
while(h){
if(h&1) res += a[now].time;
h>>=1;
now++;
}
return res;
}
int main(){
// freopen("in.txt","r",stdin);
// freopen("out.txt","w",stdout);
long long t;
cin>>t;
while(t--){
memset(dp,0x3f,sizeof(dp));
long long n;
cin>>n;
for(long long i=1; i<=n; i++){
cin>>a[i].name>>a[i].end>>a[i].time;
}
sort(a+1,a+n+1,cmp_name); //为了使得输出字典序最小的方案
dp[0] = 0;
for(long long i=1; i<=(1<<n)-1; i++){
for(long long j=0; j<n; j++){
if(i & (1<<j)){
long long k = dp[i ^ (1<<j)] + max(1ll * 0,ans(i ^ (1<<j)) + a[j+1].time - a[j+1].end);
if(k < dp[i]){
dp[i] = k;
from[i] = j+1;
}
}
}
}
stack<string> st;
long long now = (1<<n)-1;
cout<<dp[now]<<endl;
while(now){
st.push(a[from[now]].name);
now = now ^ (1<<(from[now]-1));
}
while(!st.empty()){
cout<<st.top()<<endl;
st.pop();
}
}
return 0;
}
为了输出方案我们就记一下当前的状态是从哪里转移来的就好了。
E.Pieces
题目描述:
题目分析:
因为长度非常小所以考虑使用状压\(\text{DP}\),设 \(dp[S]\) 表示删除 \(S\) 这些位置的元素的最小操作次数。
但是转移我们会发现,如果是一个一个元素的枚举,那么还要判断是否回文就非常麻烦,那么我们就考虑转移使用子集枚举,这样就可以枚举到 \(S\) 的每一个子序列,也就不用考虑回文的问题了,因为这个子序列相当于原问题的一个规模更小的而且我们已经处理完了的子问题。需要注意的是如果当前串就是一个回文子串那么就没必要枚举了,需要进行特判。
代码详解:
点击查看代码
#include<bits/stdc++.h>
using namespace std;
string s;
int n,dp[1<<18];
char res[20];
int pre(int x){ //判断是否回文
int cnt = 0;
for(int i=0; i<n; i++){
if(x & (1<<i)){
res[++cnt] = s[i];
}
}
int l = 1,r = cnt;
while(l < r){
if(res[l] != res[r])
return cnt;
l++;r--;
}
return 1;
}
int main(){
int t;
cin>>t;
while(t--){
memset(dp,0x3f,sizeof(dp));
cin>>s;
n = s.size();
dp[0] = 0;
for(int i=1; i<=(1<<n)-1; i++){
dp[i] = pre(i);
if(dp[i] == 1) //小优化
continue;
for(int j=i; j; j = (j-1) & i){
dp[i] = min(dp[i],dp[j] + dp[i^j]);
}
}
cout<<dp[(1<<n)-1]<<endl;
}
return 0;
}
需要注意:这样写的复杂度是 \(O(3^n)\) 并不是其他的神奇的复杂度。
F.A Simple Task
题目描述:
题目分析:
我们发现 \(n\) 很小所以我们就把这一维状压,那么状态显然就是:\(dp[S]\) 代表经过了 \(S\) 内的点的环的数量,但是你会发现这个状态好复杂啊,根本没法转移,所以我们就考虑换一种思路,我们在 \(\text{DP}\) 的时候顺道统计一下答案,而不是最后放到 \(dp\) 数组里,那么状态就变成了:\(dp[S]\) 代表只经过了 \(S\) 内的节点的路径条数。
但是我们发现当我们在统计答案的时候,不好统计,所以就新加一维 \(dp[S][i]\) 代表只经过了 \(S\) 内的节点且当前在 \(i\) 点的路径条数,那么我们下一步就枚举 \(i\) 能到达的所有点,如果这个点在 \(S\) 里面那么也就意味着有环,就加上。而如果不在 \(S\) 中,那么 \(dp[S][i]\) 也可以当作经过那些点的路径条数统计起来。
代码详解:
点击查看代码
#include<bits/stdc++.h>
using namespace std;
const long long MAXN = 25;
const long long MAXM = 4e3+5;
struct edge{
long long nxt,to;
edge(){}
edge(long long _nxt,long long _to){
nxt = _nxt,to = _to;
}
}e[MAXM];
long long cnt = 0,head[MAXN],dp[1<<20][20];
void add_edge(long long from,long long to){
e[++cnt] = edge(head[from],to);
head[from] = cnt;
}
int main(){
// freopen("in.txt","r",stdin);
// freopen("out.txt","w",stdout);
long long n,m;
cin>>n>>m;
for(long long i=1; i<=m; i++){
long long from,to;
cin>>from>>to;
add_edge(from,to); //为了下文方便计算
add_edge(to,from);
}
for(long long i=1; i<=n; i++){
dp[1<<(i-1)][i] = 1;
}
long long ans = 0;
for(long long i=1; i<=(1<<n)-1; i++){
for(long long j=1; j<=n; j++){
if(dp[i][j] == 0) continue; //无论是贡献给谁都没用了
for(long long k = head[j]; k ; k = e[k].nxt){
long long to = e[k].to;
if((i & (-i)) > (1<<(to-1))) continue; //不是最小
if(i & (1<<(to-1))){
if((i & (-i)) == (1<<(to-1))){ //草 位运算的优先级真迷
ans += dp[i][j];
}
}
else{
dp[i | (1<<(to-1))][to] += dp[i][j];
}
}
}
}
cout<<(ans - m)/2<<endl;
return 0;
}
我们会发现每个长度为 \(1\) 的边都会被认为是环,因为我们在枚举所有出边的时候就会枚举到这一条边并且到达的点也在 \(S\) 中。每一个环都会被正反统计两次,因为我们同一个路径同一个状态但最后一个点不同,这就会导致每个环 \(i \to j\) 和 \(j \to i\) 分别被统计一次
G.N Queen Again
题目描述:
题目分析:
我感觉这个题还是十分神奇的。
我们都知道 \(8\) 皇后问题只能通过暴力枚举来解决,所以如果我们是想让当前的局势通过某些操作变成一个合法的局面的话是很困难的,但是我们发现当我们确定了一个局面,要让我们现在的状态变成我们确定的局面确是不困难。
而我们 \(8\) 皇后问题的可行局面也并不多,可以统计一下只有 \(92\) 个,所以我们就考虑枚举每一种可行解,想办法获得我们当前局势变成可行解的最小移动步数。我们并不确定哪个皇后移到哪个位置,而且皇后数量只有 \(8\) 个,那么我们就考虑状压。
具体为设 \(dp[S]\) 为用 \(S\) 里面的皇后去一一匹配当前的可行解里面的前几个皇后的最小移动步数,那么每次就用新加入的皇后去匹配可行解里的下一个皇后就好了。
代码详解:
点击查看代码
#include<bits/stdc++.h>
using namespace std;
int cnt = 0;
int dp[1<<10],pre[100][10],flag[10],now = 1,hang[100],lie[100];
bool vis_1[100],vis_2[100],vis_3[100];
void pre_work(int x){ //记录一下每一种八皇后的解法
if(x == 9){
++cnt;
for(int i=1; i<=8; i++){
pre[cnt][i] = flag[i]; //flag[i] 记录第 i 行是谁
}
return;
}
for(int i=1; i<=8; i++){
if(!vis_1[i] && !vis_2[i + x] && !vis_3[i - x]){
vis_1[i] = true;vis_2[i + x] = true;vis_3[i - x] = true;
flag[x] = i;
pre_work(x+1);
vis_1[i] = false;vis_2[i + x] = false;vis_3[i - x] = false;
}
}
}
int get_len(int i,int j){
if(i == hang[j] && pre[now][i] == lie[j]) return 0;
if(i == hang[j]) return 1;
if(pre[now][i] == lie[j]) return 1;
if(i + pre[now][i] == lie[j] + hang[j]) return 1;
if(i - pre[now][i] == hang[j] - lie[j]) return 1;
return 2;
}
int main(){
// freopen("in.txt","r",stdin);
// freopen("out.txt","w",stdout);
pre_work(1);
int t;
cin>>t;
for(int gh=1; gh<=t; gh++){
int sz = 0;
for(int i=1; i<=8; i++){
for(int j=1; j<=8; j++){
char c;
cin>>c;
if(c == 'q'){
sz++;
hang[sz] = i;lie[sz] = j;
}
}
}
int ans = 100000000;
for(now=1; now<=cnt; now++){ //每一种合法方案做一次状压 DP
memset(dp,0x3f,sizeof(dp));
dp[0] = 0;
for(int i=1; i<=(1<<8)-1; i++){
int cnt = __builtin_popcount(i); //dp[S] 表示用前 S 个去匹配合法的前 S 个的最小花费
for(int j=1; j<=8; j++){
if(!((1<<(j-1)) & i)) continue;
dp[i] = min(dp[i],dp[i ^ (1<<(j-1))] + get_len(j,cnt));
//这里是类似 A <- B 的转移
}
}
ans = min(ans,dp[(1<<8)-1]);
}
printf("Case %d: %d\n",gh,ans);
}
return 0;
}
__builtin_popcount(i)
就是寻找 \(i\) 在二进制下有多少个 \(1\)
H.Brush (IV)
题目描述:
题目分析:
这个题我们会发现与 愤怒的小鸟 很像,所以我们也考虑使用类似的处理方法,处理出 \(line[i][j]\) 代表经过经过 \(i,j\) 两个点的直线的点的状态。我们的状态很明显就是 \(dp[S]\) 代表经过 \(S\) 内的点最少需要多少条直线,那么我们转移就是枚举一个在 \(S\) 中的点另一个随意的点,然后将这两个之间构成的直线进行加入然后转移就好了。
但是这样的枚举会发现复杂度还是非常高的是 \(O(T2^nn^2)\),显然过不去,所以我们就要考虑怎么样能优化一下。
显然可以使用记忆化搜索,因为这样有可能可以减少一部分无用的状态,还有一点就是当我们找到了一个在 \(S\) 中的点,然后枚举完剩下的随意的点的时候就可以直接跳出循环了,因为我们剩下的随意点肯定有的在 \(S\) 里有的在 \(S\) 外,那么也就意味着所有的在 \(S\) 里的点都被枚举过了,这样可以将时间优化到很快。
代码详解:
点击查看代码
#include<bits/stdc++.h>
using namespace std;
struct node{
int x,y;
}a[20];
int dp[1<<20],n,line[20][20];
bool check(node i,node j,node k){
int x1 = i.x - j.x,y1 = i.y - j.y;
int x2 = i.x - k.x,y2 = i.y - k.y;
return y1 * x2 == y2 * x1;
}
int dfs(int S){
if(dp[S]<10000) return dp[S];
int cnt = 0;
int h = S;
while(h){
h -= (h & (-h));
cnt++;
}
if(cnt == 0) return dp[S] = 0;
if(cnt <= 2) return dp[S] = 1;
for(int i=1; i<=n; i++){
if((1<<(i-1) & S)){
for(int j=i+1; j<=n; j++){
int h = ((S | line[i][j])^line[i][j]); //去掉这些点的东西
dp[S] = min(dp[S],dfs(h)+1);
}
break;
}
}
return dp[S];
}
int main(){
// freopen("in.txt","r",stdin);
// freopen("out.txt","w",stdout);
int t;
cin>>t;
for(int gh=1; gh<=t; gh++){
memset(dp,0x3f,sizeof(dp));
memset(line,0,sizeof(line));
cin>>n;
for(int i=1; i<=n; i++){
cin>>a[i].x>>a[i].y;
}
for(int i=1; i<=n; i++){
for(int j=i+1; j<=n; j++){
for(int k=1; k<=n; k++){
if(check(a[i],a[j],a[k])){
line[i][j] |= (1<<(k-1));
}
}
}
}
printf("Case %d: %d\n",gh,dfs((1<<n)-1));
}
return 0;
}
注意要设置边界条件。