zoukankan      html  css  js  c++  java
  • 「算法笔记」状压 DP

    一、关于状压 dp

    为了规避不确定性,我们将需要枚举的东西放入状态。当不确定性太多的时候,我们就需要将它们压进较少的维数内。

    常见的状态:

    • 天生二进制(开关、选与不选、是否出现……)
    • 爆搜出状态,给它们编号

    1. 状态跟某一个信息集合内的每一条都有关。(如 dp 套 dp)

    2. 若干条精简而相互独立的信息压在一起处理。 (如每个数字是否出现)

    在使用状压 dp 的题目当中,往往能一眼看到一些小数据范围的量,切人点明确。而有些题,这样的量并不明显,需要更深人地分析题目性质才能找到。

    二、预备知识

    1. 位运算

    二进制数 S 从低(0-based)到高第 i 位的值:(S>>i)&1

    二进制数 S 从低(0-based)到高第 i 位的值变为 1:S|(1<<i)

    二进制数 S 从低(0-based)到高第 i 位的值变为 0:S&(~(1<<i)) 或 ~((~S)|(1<<i))

    二进制数 S 从低(0-based)到高第 i 位的值取反:S^(1<<i)

    2. 枚举子集

    for(int i=S;i!=0;i=(i-1)&S)

    复杂度:

    (2n-1) 的子集:2n

    (2n-1) 的子集的子集和:3n

    (2n-1) 的子集的子集的子集和:4n

    ……

    三、例题

    1. TSP(旅行商问题)

    题目大意:一个 n 个点的带权的有向图,求一条路径,使得这条路径经过每个点恰好一次,并且路径上边的权值和最小。n≤16。

    Solution:

    设 dp[i][S] 表示目前走到节点 i,已经经过节点的集合为 S 的最小边权。

    枚举所有的边 (i,j)∈E,令 w(i,j) 表示它的边权。

    if j∉S(因为要满足经过每个点恰好一次,即不能重复经过,所以要满足 j 没有被经过),dp[i][S]+w(i,j)→dp[j][S∪{j}]

    若 S&(1<<j) 的值为 0,则 j 不在 S 中;若 S&(1<<j) 的值不为 0,则 j 在 S 中。也可以判断 (S>>j)&1 是否为 1。

    初始化一个节点的路径 dp[i][1<<i]=0(节点编号为 0~n-1)或 dp[i][1<<(i-1)]=0(节点编号为 1~n),其他的设为 ∞。

    把 j 加入到 S:S +/|/^ (1<<j)。因为已经确认了 S 的这一位是 0,则 +,|,^ 都是可以的。

    时间复杂度:O(2n·n2)

    2. [USACO06NOV] 玉米田 Corn Fields

    题目大意:有一个 N*M 的网格图,已知若干个格子不能选、不能有相邻的格子被选。在不限制所选格子总数的前提下,求方案数。N,M≤12。

    Solution:

    由于限制在相邻的格子上,若按行或按列 dp,就不需要维护之前已经决策过的格子的状态,而是维护一行或一列的格子的状态。

    令 dp[i][j] 表示目前 dp 到第 i 行,这一行的状态是 j,所有 1~i 行内的格子被选的方案数。

    枚举 i+1 行的状态 k,判断 k 与 j 是否有冲突(是否出现某个格子的上面一个格子被选了的情况,即是否有相邻的格子被选),只需判断 j&k 是否为 1(j 的二进制数与 k 的二进制数是否有在相同的位置同时等于 1)。

    dp[i][j]→dp[i+1][k]

    预处理出没有相邻的两列同时被选的状态。在一行 12 个格子的网格图中,没有相邻的两列同时被选的合法的状态大概有 377 个。

    int ans=0;
    for(int i=0;i<(1<<12);i++){
        bool flag=1;
        for(int j=1;j<=11;j++)
            if(((i>>(j-1))&1)&&((i>>j)&1)) flag=0;
        if(flag) ++ans;
    } 
    printf("%lld
    ",ans);
    //Output:377

    所以通过预处理,可以通过此题。

    Code:

    #include<bits/stdc++.h>
    #define int long long 
    using namespace std;
    const int N=13,M=(1<<12)+5,mod=1e9;
    int n,m,a[N][N],p[N],f[N][M],ans;
    bool v[M];
    signed main(){
        scanf("%lld%lld",&m,&n),f[0][0]=1;
        for(int i=1;i<=m;i++)
            for(int j=1;j<=n;j++){
                scanf("%lld",&a[i][j]);
                p[i]=(p[i]<<1)+a[i][j];    //记录第 i 行土地的状态 
            }
        for(int i=0;i<(1<<n);i++){
            v[i]=1;
            for(int j=1;j<n;j++)
                if(((i>>(j-1))&1)&&((i>>j)&1)) v[i]=0;
        } //记录状态 i 是否合法(预处理出没有相邻的两列同时被选的状态) 
        for(int i=1;i<=m;i++)    //枚举行 
            for(int j=0;j<(1<<n);j++)    //枚举这行的状态 
                if(v[j]&&(j&p[i])==j)    //判断状态 j 是否合法以及是否在肥沃的土地上 
                    for(int k=0;k<(1<<n);k++)    //枚举上一行的状态 
                        if(!(k&j)) f[i][j]=(f[i][j]+f[i-1][k])%mod;
        for(int i=0;i<(1<<n);i++)
            ans=(ans+f[m][i])%mod;    //前 m 行的方案数累加 
        printf("%lld
    ",ans);
        return 0;
    }

    3. TopCoder RainbowGraph

    题目大意:给出一张 N 个点 M 条边的无向图。每个点有一个颜色。一条路径是合法的当且仅当它按顺序经过的节点的颜色序列写下来,同种颜色出现的位置是连续的。计算合法的经过每个节点恰好一次的路径条数。N≤100,Color≤10(颜色数),对于任意的 i,Cnt(v|Colorv=i)≤10(每种颜色的节点数)。

    Solution:

    既然同种颜色出现的位置是连续的。我们不妨将问题划分成两个小问题考虑:

    • 对于颜色 c,从 i 出发到 j 结束,同时遍历完所有该颜色节点的路径条数是多少。
    • 对于全局,有多少种安排颜色的方法、及安排颜色之间“接口”的方法。

    第一个问题:将同种颜色内的串起来。

    要求:(1)每个点恰好出现一次(2)相邻两点之间有边相连

    令 dp[i][j][S] 表示从 i 号点出发,在 j 号点结束,经过的节点集合为 S 的路径条数。

    for(int i=0;i<k;i++)    //k:当前颜色的节点个数 
        dp[i][i][1<<i]=1;    //考虑单点路径的情况 
    for(int S=1;S<(1<<k);S++)    //枚举状态 
        for(int i=0;i<k;i++)    //枚举起点 
            for(int j=0;j<k;j++)    //枚举终点 
                if(dp[i][j][S])    //判断当前的 dp 状态是否合法 
                    for(int p=0;p<k;p++)    //枚举下一个点 
                        if(!((S>>p)&1)&&G[j][p])    //p 在状态中没有出现过(之前没有经过这个点)并且之前路径的终点 j 能够到达下一个点 p 
                            dp[i][p][S|(1<<p)]+=dp[i][j][S];

    注:这里所有的节点编号,为了方便体现,直接写成了 i 之类的。事实上,需要开一个数组维护,把全局当中的颜色为当前颜色的节点转化到 0~k 的范围内。

    第二个问题:将每种颜色串起来。

    要求:(1)每种颜色恰好出现一次(则需记录颜色状态 2C)(2)相邻颜色的首尾节点间有边相连(则需记录终点 N)

    对于一种颜色 c,我们使用 f[c][i][j] 来代表之前的 dp[全局中编号为 i 的点][全局中编号为 j 的点][(1<<k)-1]。

    令 dp[i][S] 表示以节点 i 结尾,已经经过的颜色集合为 S 的总方案数。

    for(int c=0;c<C;c++)    //枚举颜色 
        for(int i=0;i<k[c];i++)    //枚举起点  k[c]:颜色为 c 的节点个数 
            for(int j=0;j<k[c];j++)    //枚举终点 
                dp[id[j]][1<<c]+=f[c][i][j]; 
    for(int S=1;S<(1<<C);S++)
        for(int i=0;i<N;i++)
            if(dp[i][S]) for(int c=0;c<C;c++)    //枚举下一个颜色  C:颜色数 
                if(!((S>>c)&1))    //这个颜色在之前的状态中没有出现过 
                    for(int j=0;j<k[c];j++)    //枚举下一个颜色的起点 
                        if(G[i][id[j]])    //当前的终点与下一个起点有边可以到达 
                            for(int q=0;q<k[c];q++)    //枚举下一个颜色的终点 
                                dp[id[q]][S|(1<<c)]+=dp[i][S]*f[c][j][q]; 

    4. TopCoder CheeseRolling

    题目大意:N 个人打淘汰赛。N 是 2 的幂次。给出两两单挑的胜负情况。一共有 N! 种赛程安排方案,对于每个人,你需要输出有多少种方案使他最终获胜。n≤16。

    Solution:

    令 dp[i][S] 表示编号为 i 的选手是选手集合 S 中的优胜者的方案数。

    那么最后每个点的答案就是 dp[i][(1<<N)-1]

    枚举 i 能够战胜的对手 j,然后将集合 S 等分为两个一个包含 i 一个包含 j 的集合。

    for(int i=0;i<N;i++)
        dp[i][1<<i]=1; 
    for(int S=1;S<(1<<n);S++)    //枚举集合 S 
        if(valid(S))    //只处理大小为 2 的幂次的集合 
            for(int i=0;i<N;i++)    //枚举集合中的胜出者 i 
                if((S>>i)&1)    //集合 S 包含 i 
                    for(int j=0;j<N;j++)    //枚举 i 所击败的对手 j 
                        if(i!=j&&G[i][j])    //i 和 j 不是同一个人并且 i 能够击败 j 
                            for(int k=(S-1)&S;k!=0;k=(k-1)&S)    //枚举子集 
                                dp[i][S]+=2LL*dp[i][k]*dp[j][S^k]; 

    复杂度为 O(3n·n2),偏高,需要优化。

    • 优化 1:valid 这部分有很多冗余状态,可以预处理出来有效的部分。
    • 优化 2:把子集改成 dfs 精确枚举等分子集。

    5. AtCoder 058E Iroha and Haiku

    题目大意:

    一个有 N 个元素且每个元素的取值都在 1~10 范围内的序列被称为好的当且仅当存在下标 x,y,z,w(0≤x<y<z<w≤N)

    • ax+ax+1+...+ay-1=X
    • ay+ay+1+...+az-1=Y
    • az+az+1+...+aw-1=Z

    计数。并对 109+7 取模。3≤N≤40,1≤X≤5,1≤Y≤7,1≤Z≤5。

    Solution:

    做法 1:当你不知道如何优雅地设计状态/不知道状态的规模有多大时,不妨暴力一点。

    因为 X≤5,Y≤7,Z≤5,所以 X+Y+Z≤5+7+5,即 X+Y+Z≤17,ax+ax+1+...+ay-1+ay+ay+1+...+az-1+az+az+1+...+aw-1≤17。

    只需维护取值 1~10,和≤17 的序列。

    #include<bits/stdc++.h>
    #define int long long
    using namespace std;
    int cnt;
    void dfs(int x){
        if(x>17) return ;
        ++cnt;
        for(int i=1;i<=10;i++) dfs(x+i);
    }
    signed main(){
        dfs(0),printf("%lld
    ",cnt);
        return 0;
    } 
    //Output:130624 

    大约 10个状态,做法就显而易见了。

    一个 dp 序列当中的元素,每次只保留最靠后的值不超过 17 的元素队列。

    dp[i][S][0/1] 表示当前 dp 到序列中第 i 个元素,队列的状态为 S,是否出现过题中所述条件。

    一些优化:

    • 优化 1:每个状态是否满足题中条件可预处理。
    • 优化 2:每个状态遇到了 1~10 后转移到什么状态可预处理。(需要一个作用在 vector上的 map 或是 set、二分等)
    • 优化 3:状态当中的第三维可以通过求补集而去掉。

    不难观察得到,算法的复杂度瓶颈主要是由状态的 vector 形态带来的。

    做法 2:思路和前面类似,只不过考虑换个形式表示“和不超过 17”这个状态。

    如果我们将这“X+Y+Z”点贡献展开看成一条长度为 X+Y+Z 的格子,一个数可以占领 1~10 个格子。那么我们关心的即为位置在 X、X+Y、X+Y+Z 的三个格子是否是一个数所占领格子的末尾。我们考虑将一个数末尾标为 1,其他格子标为 0。

    例子:X=Y=Z=2,后缀状态:

    2+2+2 010101
    1+1+2+2 110101
    2+1+1+2 011101
    1+2+2+1 101011

    Code:

    //开始的格子为 1 的写法 
    #include<bits/stdc++.h>
    #define int long long
    using namespace std; 
    const int N=50,M=(1<<17)+5,mod=1e9+7; 
    int n,x,y,z,st,dp[N][M],sum=1,k,ans;
    signed main(){
        scanf("%lld%lld%lld%lld",&n,&x,&y,&z); 
        st=((1<<(x+y+z-1)))|(1<<(y+z-1))|(1<<(z-1));    //目标从后往前数的第 X+Y+Z、Y+Z、Z 的位置为 1
        dp[0][0]=1;
        for(int i=1;i<=n;i++){
            sum=sum*10%mod;     //计算总方案数(n 位数字,就有 10^n 种方案) 
            for(int S=0;S<(1<<(x+y+z));S++)    //枚举状态 
                if(dp[i-1][S]&&(S&st)!=st)
                    for(int c=1;c<=10;c++){     //枚举第 i 位添加的数字 
                        k=((S<<c)|(1<<(c-1)))&((1<<(x+y+z))-1);
                        //(S<<c)|(1<<(c-1)): 比如 4 的二进制数为 1000,4 后面加上 2 为 4 2,用 100010 表示,即将 4 的二进制向前两位再加上 10。 
                        //...&((1<<(x+y+z))-1: 防止溢出 
                        dp[i][k]=(dp[i][k]+dp[i-1][S])%mod;
                    } 
        } 
        for(int S=0;S<(1<<(x+y+z));S++)
            if((S&st)!=st) ans=(ans+dp[n][S])%mod;
        printf("%lld
    ",(sum-ans)%mod);    //总方案数减不符合条件的方案 
        return 0;
    }

    6. AtCoder XOR Tree

    题目大意:给出一棵 N 个节点的树,每条树边有一个边权。.每次操作你可以将一条路径上的边权异或上某个值。问最少进行多少次操作可以达到边权全 0。N≤105,0≤边权≤15。

    Solution:

    首先定义一个点的点权为与其相邻的所有边的边权异或和。

    容易证明边权全为 0 等价于点权全为0。

    • 边权为 0→点权为 0:根据定义显然
    • 点权为 0→边权为 0:来自树的性质,总是存在叶子结点。叶子结点上的点权为 0 可推出一条边权为 0。删去后重复这一过程。

    每次操作就可以看成是将任意两个点异或上同一个数。

    • 如果是两个相同的数,一次操作就可以直接将这俩消去。
    • 如果是两个不同的数 a,b,那么一次操作可以消去一个,剩下一个 a^b。

    思考一下要不要考虑同时异或一个不为 a 也不为 b 的数 x。

    因为一共只有 16 种取值,我们先将重复的直接消去。剩下的信息只有 0~15 每个数字是否出现过,可以压成一个 216 的状态。

    每次枚举一下要操作哪两个数。

    • x^y∉S:转移到的状态就是 S∪x^y,即 S^(1<<(x^y))。
    • x^y∈S:此次操作之后就会产生一对相等的 pair,我们直接在此时将它们一并消去,增加一次操作,并将 x^y 从 S 中去掉即可。

    7. Codeforces 16E Fish

    题目大意:有 n 条鱼,刚开始它们都自由地生活在水里。接下来每一时刻会有两条鱼相遇。所有可能的“鱼对”之间都是等概率出现这一事件的。鱼 i 和鱼 j 相遇后,i 吃掉 j 的概率为 a[i][j]。 反之 j 吃掉 i 的概率为 1-a[i][j]。求每条鱼 i 活到最后的概率。N≤19。

    Solution:

    令 dp[S] 表示剩下集合中的鱼状态为 S 的概率。

    初始化:dp[2n-1]=1(刚开始全部的鱼都存活)

    对于任意 1≤i≤n,dp[2i] 为鱼 i 活到最后的概率。

    按从大到小枚举状态 S(按时刻进行),设 c 为 S 中 1 的个数,则有 C(c,2) 个可能的“鱼对”。枚举第一条被选的鱼 i 以及第二条被选的鱼 j,这两条鱼相遇的概率为 1/C(c,2)。

    • i 吃掉 j:1/C(c,2)·dp[S]·a[i][j]→dp[S-2j]
    • j 吃掉 i:1/C(c,2)·dp[S]·a[j][i]→dp[S-2i]

    最后输出所有的 dp[2i] 即可。

    Code:C(c,2)=c*(c-1)/2

    #include<bits/stdc++.h>
    #define int long long
    using namespace std;
    const int N=20,M=(1<<18)+5;
    int n,x,cnt;
    double a[N][N],dp[M];
    signed main(){
        scanf("%lld",&n),dp[(1<<n)-1]=1;
        for(int i=0;i<n;i++)
            for(int j=0;j<n;j++)
                scanf("%lf",&a[i][j]);
        for(int S=(1<<n)-1;S>=1;S--){    //枚举状态 
            cnt=0,x=S;
            while(x) cnt+=x&1,x>>=1;    //计算 S 中 1 的个数 
            for(int i=0;i<n;i++) if(S&(1<<i))    //枚举第一条鱼 
                for(int j=i+1;j<n;j++) if(S&(1<<j)){    //枚举第二条鱼 
                    dp[S-(1<<j)]+=dp[S]*a[i][j]/(1.0*cnt*(cnt-1)/2);    //i 吃掉 j 
                    dp[S-(1<<i)]+=dp[S]*a[j][i]/(1.0*cnt*(cnt-1)/2);    //j 吃掉 i 
                }
        }
        for(int i=0;i<n;i++)
            printf("%.6lf%c",dp[1<<i],i==n-1?'
    ':' ');
        return 0;
    }

    8. Codeforces 165E Compatible Numbers

    题目大意:给出一个长度为 N 的序列,我们称两个数 x,y 是兼容的,当且仅当 x&y=0。对于序列中的每个数,你需要求出任意一个序列中与它兼容的数,或是声明不存在。N≤106,0<a[i]≤4·106

    Solution:

    暴力做法:由于数值大小可以存下。令 M 为 >=max{a(i)} 的某个 2k-1。与序列中的某个数 x 兼容的所有数可以表示为 M xor x 的子集。做一下子集枚举,我们得到了一个 O(3k) 的做法。(本题中,k≤22), 这个做法要跑 3e10。

    一点改进:令 f(S) 表示 S 的子集中是否有数出现过。F(x) 表示 x 是否在序列中出现过。

    那么有:f(S)=F(S)|⋁i∈S f(Si)

    从小到大枚举 S, 每次计算 f 的值时就不需要枚举子集,只用枚举 S 中的元素 v 即可。还能剪枝,当 f(S) 为真时就不需要再枚举下去了。

    9. Codeforces 743E Vladik and cards

    题目大意:给出一个长度为 N 个序列,序列中的元素取值为 1~8。求一个最长的子序列满足:

    • 每种取值出现在子序列中的位置连续。
    • 每种取值出现的次数相差不超过 1。N≤1000。

    10. Codeforces 599E Sandy and Nuts

    题目大意:有一棵 N 个点的树, 1 号点为根节点。现在你知道 M 条信息:

    • 某些树上的边 (u,v)
    • 某两个点的LCA (a,b,c)

    现在你需要数满足条件的树形态数。N≤13,M≤100。

  • 相关阅读:
    一个分页的HtmlHelper
    VS中卸载当前的EF版本命令
    一个指示合同到期的进度条(mvc+jquery)
    数组反转显示
    IndexOf的用法(查找一个字符出现的次数以及索引)
    ITK Read Dicom
    LBD线段描述子
    Learning Vim in 2014: Vim as Language
    vim Register 操作,拷贝至系统剪贴板等
    linux command
  • 原文地址:https://www.cnblogs.com/maoyiting/p/13368682.html
Copyright © 2011-2022 走看看