[算法学习笔记][刷题笔记] 2023/8/26&8/27 解题报告状压 dp

题单

状压 dp

状压 dp是一种非常暴力的算法,它直接记录不同的状态,通过状态进行转移。

状压 dp可以解决 NP 类问题。它的原理是暴力枚举每一种可能的状态。所以它的复杂度是指数级的。只能求解小范围的问题。

关于记录状态:

状压 dp 通过一个二进制串来记录状态。显然二进制串可以转换成十进制在数组中作为下标。这也是它优化状态的关键。同时,二进制串可以直接通过 位运算 转移状态。

一般来讲思考状压 dp 的步骤是先判断有无后效性,即可否用朴素的 dp 例如线性 dp 解决。如果有后效性,考虑记录完整状态,也就是状压 dp。

状压 dp 在处理的时候可能有一些小技巧,比如预处理合法方案,把不合法方案排除等,当然优化需要在保证正确性的前提下。

状压 dp 大量应用位运算,需要掌握熟练。
接下来将通过一些例题来进一步理解状压 dp。

[USACO12 MAR Cows in a Skyscraper G]

给出 \(n\) 个物品,体积为 \(w_i\),现把其分成若干组,要求每组总体积\(\leq W\),问最小分组。\(n\leq 18\)

Solution

类似于 01 背包,每个物品只能分到一个组中,即后面的状态不能包含前面的状态。有后效性。无法使用朴素线性 dp 解决。

数据范围 \(n\leq 18\)。可以用指数级的算法。

定义 \(f_{i,j}\) 表示分成了 \(i\) 组,且状态为 \(j\) 时的最小体积。

关于状态:我们把状态设计为二进制串,例如 \(00110\) 表示只有第 \(3,4\) 个物品被选择过,被包含在这一个状态内。

定义状态上界:每个物品都可以是 \(0,1\)。因此最多有 \(2^n\) 个状态。我们可以把每一个状态都枚举。因为此类问题有后效性,无法简化。

接下来枚举分成了多少组,显然最多分成 \(n\) 组。

对于每个分组数,我们都可能遇到每个可能状态,所以暴力枚举。

现在,对于分成当前组数,当前状态。我们需要对每个物品进行决策,也就是更新,所以再枚举每个物品。

对于每个物品,如果它被包含在该状态,或者当前分组,当前状态下最后一个分组的体积+当前物品体积超过上限。都不可以在当前状态下选择该物品。

反之,更新状态。若设当前分成了 \(i\) 组,状态为 \(x\),当前是第 \(j\) 个物品,则更新:

\(f_{i,x|1<<j}=min(f_{i,x|1<<j,f_{i,x}+a_j})\)

对于必须将该物品单独分为一组,则:

\(f_{i+1,x|1<<j}=min(f_{i+1,x|1<<j},a_j)\)

Explanation:1<<j 表示新建一个二进制串,并且将 \(1\) 移动到第 \(k\) 位,也就是 \(2^k\)\(x|1<<j\) 也就使 \(x\) 状态的第 \(j\) 位变成 \(1\) 后的状态决策。

最后统计答案正着搜到第一个不是 INF 的组输出即可。

上述决策需要注意,必须在 \(f_{i,x}\) 被决策后才能继续。

初始化使得分成1组,状态为每个物品的体积都为它本身,具体实现见代码:

实现
#include <iostream>
#include <cstdio>
#include <algorithm>
#include <cmath>
#include <cstring>
using namespace std;
const int N = 1000010;
const int INF = 0x3f3f3f3f;
int up;
int n,w;
int a[N];
int f[20][N];
int main()
{
    ios::sync_with_stdio(false);
    cin.tie(0);
    cout.tie(0);
    cin>>n>>w;
    for(int i=0;i<n;i++) cin>>a[i];
    up = pow(2,n);
    memset(f,INF,sizeof f);
    for(int i=0;i<n;i++) f[1][1<<i] = a[i];
    for(int i=0;i<=n;i++)
    {
        for(int j=0;j<up;j++)
        {
            if(f[i][j] != INF)
            {
                for(int k=0;k<n;k++)
                {
                    if(j&(1<<k)) continue;
                    if(f[i][j]+a[k] <= w)
                    {
                        f[i][j|(1<<k)] = min(f[i][j|(1<<k)],f[i][j]+a[k]);
                    }
                    else
                    {
                        f[i+1][j|(1<<k)] = min(f[i][j|(1<<k)],a[k]);
                    }
                }
            }
        }
    }
    for(int i=0;i<=n;i++)
    {
        if(f[i][up-1] != INF)
        {
            cout<<i<<endl;
            return 0;
        }
    }
}

Luogu P1433 吃奶酪

房间里放着 \(n\) 块奶酪。一只小老鼠要把它们都吃掉,问至少要跑多少距离?老鼠一开始在 \((0,0)\) 点处。

Solution

我们发现它的位置是必要的。我们只需要记录它最后一步是在哪里即可。

定义 \(f_{i,j}\) 表示状态为 \(i\) 的前提下,最后吃的是 \(j\) 最小需要跑多少。

首先 \(n^2\) 预处理任意两点的距离。

考虑枚举每一种状态,枚举最后吃的哪一个,显然如果枚举到最后吃的这个奶酪不包含在状态中则不处理。

否则,更新它。枚举它可以从哪个节点 \(k\) 转移过来。转移的前提同理是 \(k\) 被包含在这个状态。然后枚举先吃 \(k\),然后再吃当前节点即可。

我们显然不希望重复吃,也就是不重复吃 \(j\),因为我们当前更新的是最后吃 \(j\) 的情况下,具体地:

\(f_{i,j}=min(f_{i,j},f{i^(1<<(j-1)),k}+dist_{k,j})\)

实现
/*
状压dp
一般来说 f 数组中用二进制串来表示状态。一般解决“看似有后效性” 的问题。例如本题我们需要记录一个奶酪究竟有没有被吃。

记录完状态后考虑如何转移。


*/

#include <iostream>
#include <cstdio>
#include <algorithm>
#include <cmath>
using namespace std;
const int N =18 ;
int n;
double dist[N][N];
double x[N],y[N];
double f[1<<N][N];
int main()
{
    // ios::sync_with_stdio(false);
    // cin.tie(0);
    // cout.tie(0);
    for(int i=1;i<(1<<18);i++)
    {
        for(int j=0;j<18;j++){
            f[i][j]=10000000.0;
        }
    }
    scanf("%d",&n);
    for(int i=1;i<=n;i++)
    {
        scanf("%lf%lf",&x[i],&y[i]);
        f[1<<(i-1)][i] = (double)sqrt(x[i]*x[i]+y[i]*y[i]);
       // cout<<x[i]<<" "<<y[i]<<endl;
    }
    for(int i=1;i<=n;i++)
    {
        for(int j=1;j<=n;j++)
        {
            dist[i][j] = (double)sqrt((x[j]-x[i])*(x[j]-x[i])+(y[j]-y[i])*(y[j]-y[i]));
        }
    }
    for(int i=1;i<(1<<n);i++) //最多有 2^n 种状态。
    {
        for(int j=1;j<=n;j++)  //对于每一种状态进行枚举,以当前走到该节点的前提是本次状态和该节点一致
        {
            if(i&(1<<(j-1)) == 0) continue; //当前节点被包含在该状态时才可以继续
            for(int k=1;k<=n;k++)
            {
                if(j == k) continue; //对于每一个节点考虑先吃 k在吃j 
                if((i&(1<<(k-1))) == 0) continue; //同理如果状态不包含则不能处理
                f[i][j] = min(f[i][j],f[i^(1<<(j-1))][k]+dist[j][k]); //转移,我们不希望重复吃,所以先前的状态应与当前状态相反。
              //  cout<<f[i][j]<<endl;
            }
        }
    }
    double minn = 100000000.0;
    for(int i=1;i<=n;i++) minn = min(minn,f[(1<<n)-1][i]); //状态确定,枚举以哪个节点为结尾。
    printf("%.2lf\n",minn);
  //  cout<<minn<<endl;
    return 0;
}

CF580D Kefa and Dishes

共有 \(n\) 个物品,每个物品都有一个价值。并且如果在选择 \(x_i\) 后再选择 \(x_j\),会额外获得 \(c_i\) 个价值。她可以选择 \(m\) 个物品,求如何选才能使价值最大。
原题链接:CF580D

Solution

显然一道菜最多只能被吃一次,如果直接 dp 会产生后效性。

我们需要记录状态,记录每一次他吃了什么菜,同理使用二进制记录。

对于每个状态,枚举他吃过哪些菜,以及在这道菜之后吃另一道菜,判断是否可以转移。具体地,对于枚举他吃过哪些菜,显然该状态的这一位必须为 \(1\)。对于枚举吃过这道菜后再吃另一道菜,原状态中该位不能为 \(1\)

我们需要记录一下在该状态下,最后一次吃过的菜是什么。转移时显然需要利用。

形式化地,设 \(f_{i,j}\) 表示当状态为 \(i\) 时,最后一次吃过的菜为 \(j\) 时,满意度最大值。则有:

\(f_{i|(1<<j),j}=min(f_{i|(1<<j),j},f_{i,k}+a_j)\)\(k\) 为枚举到的上一次最后吃的食品)

实现
#include <iostream>
#include <cstdio>
#include <algorithm>
#define int long long
using namespace std;
const int N = 20;
const int M=(1<<18)+5;
int n,m,k;
int a[N];
int f[M][N];
int edge[N][N];
int maxn = -1;
int count(int x)
{
    int cnt = 0;
    for(int i=0;i<=n;i++)
    {
        if(x >> i & 1) cnt ++;
    }
    return cnt;
}
signed main()
{
    ios::sync_with_stdio(false);
    cin.tie(0);
    cout.tie(0);
    cin>>n>>m>>k;
    for(int i=1;i<=n;i++) cin>>a[i],f[1<<(i-1)][i] = a[i];
    for(int i=1;i<=k;i++) 
    {
        int x,y,c;
        cin>>x>>y>>c;
        edge[x][y] = c;
    } 
    for(int i=0;i<(1<<n);i++)
    {
        if(count(i) == m)
        {
            for(int j=1;j<=n;j++) 
            {
                if(i >> (j-1) & 1) 
                {
                    maxn = max(maxn,f[i][j]);
                //    cout<<f[i][j]<<endl;
                }
            }
        }
        if(count(i) > m) continue;
        for(int j=1;j<=n;j++)
        {
            if(i >> (j-1)&1) continue;
            for(int l=1;l<=n;l++)
            {
                if(i >> (l-1)&1) f[i|1<<(j-1)][j] = max(f[i|1<<(j-1)][j] ,f[i][l]+a[j]+edge[l][j]);
            }
        }
    }
    cout<<maxn<<endl;
    return 0;
}

Luogu P1896 [SCOI2005] 互不侵犯

\(N \times N\) 的棋盘里面放 \(K\) 个国王,使他们互不攻击,共有多少种摆放方案。国王能攻击到它上下左右,以及左上左下右上右下八个方向上附近的各一个格子,共 \(8\) 个格子。
原题链接:Luogu P1896

Solution

注意到我们前面如何摆放会影响到后面的决策,朴素 dp 会产生后效性,考虑状压 dp。

如何记录状态呢?注意到一个点会影响到它八方向上的点。对于同一行中,直接预处理即可。对于不同行,我们一次只记录两行的状态,确保两行之间的状态不会矛盾即可。这样一层一层传递下去,不会产生矛盾。

因为我们只放 \(k\) 个国王,所以我们的 \(f\) 数组还需要记录当前已经使用了多少个国王。

对于转移,这是计数类 dp,只要状态之间不产生矛盾就可以累加。对于同行之间的矛盾已经预处理解决,对于不同行,也就是两行之间的矛盾,分类讨论如下:

  • 正上正下矛盾

  • 左上右下矛盾

  • 左下右上矛盾

上述矛盾利用位运算即可判断。

同时,我们在状态中仍需记录当前是在处理哪一行。

具体实现细节在代码中给出。

实现
/*

*/
#include <iostream>
#include <cstdio>
#include <algorithm>
#define int long long
using namespace std;
const int N = 2005;
int n,k;
int sit[N],sit_num[N];
int cnt = 0;
int f[15][N][N];
void dfs(int x,int num,int cur) //预处理出每一行可能的状态(?)
{
    if(cur >= n)
    {
        sit[++cnt] = x;
        sit_num[cnt] = num;
        return;
    }
    dfs(x,num,cur+1);
    dfs(x+(1<<cur),num+1,cur+2);//1<<cur 相当于 $2^{cur}$,将二进制转换成十进制存储!
}
bool check(int i,int j) 
{
    if(sit[i] & sit[j]) return false; //相邻上下有重复的!
    if((sit[i]<<1) & sit[j]) return false; //左上右下重复
    if((sit[j]<<1) & sit[i]) return false; //右上左下重复!
    return true;
}
signed main()
{
    ios::sync_with_stdio(false);
    cin.tie(0);
    cout.tie(0);
    cin>>n>>k;
    dfs(0,0,0);
    for(int i=1;i<=cnt;i++)
    {
        f[1][i][sit_num[i]] = 1;
    }
    for(int i=2;i<=n;i++)
    {
        for(int j=1;j<=cnt;j++)
        {
            for(int x=1;x<=cnt;x++) //注意变量重名!
            {
                if(!check(j,x)) continue; //枚举出状态,判断状态是否影响,然后转移!
                for(int l=sit_num[j];l<=k;l++)
                {
                    f[i][j][l] += f[i-1][x][l-sit_num[j]]; 
                //    cout<<f[i][j][l]<<endl;
                }
            }
        }
    }
    int ans = 0;
    for(int i=1;i<=cnt;i++)
    {
        ans += f[n][i][k];
    }
    cout<<ans<<endl;
    return 0;
}

AcWing291 蒙德里安的梦想

image

Solution

如果直接考虑可能非常复杂,不妨将问题简单化。

如果横着的小方块位置全部确定,那么竖着的自然也就确定了!

因此,我们就将此题转换为在能放下竖着的小方块的前提下,放横着的小方块有多少种方案

因此我们在状态转移的全程都要判断该方案是否可以放下竖着的小方块。也就是剩余的方块数是否为奇数。

如何状态设计呢?记录小方块似乎有点困难。

因为小方块全都是 \(1\times 2\) 的,所以我们只需要记录小方块的一头即可,例如我们可以记录当前位置是否有横着的小方块 “伸过来”、

当然,我们仍然是一行一行的记录。

由于在状压 dp 中,状态是确定的,我们可以预处理出合法方案。对于横着是否两个小方块冲突,首先两个状态不能一样,其次当把两个状态拼在一起后仍是合法方案。

然后直接 dp 即可。具体地,对于每一行,枚举状态数,然后枚举与该状态拼能构成合法方案的状态。由于我们已经筛去了不合法的方案,所以直接统计即可。

具体实现见代码。

实现
#include <iostream>
#include <cstdio>
#include <algorithm>
#include <vector>
#include <cstring>
#define int long long
using namespace std;
const int N = 12;
const int M = 1<<N;
vector <int> Edge[M];
int n,m;
bool flg[M];
int f[N][M];
signed main()
{
    while(cin>>n>>m,n|m)
    {
        for(int i=0;i<1<<m;i++)
        {
            int cnt = 0;
            bool flag = true;
            for(int j=0;j<m;j++)
            {
                if(i >> j & 1)
                {
                    if(cnt & 1) 
                    {
                        flag = false;
                        break;
                    }
                }
                else cnt ++;
            }
            if(cnt & 1) flag = false;
            flg[i] = flag;
        }
        for(int i=0;i<1<<m;i++)
        {
            Edge[i].clear();
            for(int j=0;j<1<<m;j++)
            {
                if((i & j) == 0&&flg[i|j]) Edge[i].push_back(j);
            }
        }
        memset(f,0,sizeof(f));
        f[0][0] = 1;
        for(int i=0;i<=n;i++)
        {
            for(int j=0;j<1<<m;j++)
            {
                for(auto k:Edge[j]) f[i][j] += f[i-1][k];
            //    cout<<f[i][j]<<endl;
            }
        }
        cout<<f[n][0]<<endl;
    }
}
posted @   SXqwq  阅读(29)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 微软正式发布.NET 10 Preview 1:开启下一代开发框架新篇章
· 没有源码,如何修改代码逻辑?
· PowerShell开发游戏 · 打蜜蜂
· 在鹅厂做java开发是什么体验
· WPF到Web的无缝过渡:英雄联盟客户端的OpenSilver迁移实战
点击右上角即可分享
微信分享提示