首页 > 代码库 > 状态压缩DP

状态压缩DP

若元素数量比较小(不超过 20)时,想要存储每个元素取或不取的状态时,可以借助位运算将状态压缩。

需要借助状态压缩过程的动态规划就是状态压缩 DP(很多地方会简称为 状压 DP)。

取若干元素,也就是对应的位置记为 1,其余位置记为 0。

例如,一共有 5 个元素 a,b,c,d,e,我们分别用 1,2,4,8,16 表示这五个元素,

则集合{a,c,e} 可以用 (10101)_2=21 来表示,而集合 {b,c,d} 可以用 (01110)_2=14 表示。

对于元素个数为 n 的情况,其空间复杂度为 O(2^n)

 

 

例题 1

给定一个 n×m 的矩阵,行数和列数都不超过 20,其中有些格子可以选,有些格子不能选。现在你需要从中选出尽可能多的格子,且保证选出的所有格子之间不相邻(没有公共边)。

例如下面这个矩阵2×3 的矩阵,可选的位置标记为 1,不可选的位置标记为 0:

1 1 1
0 1 0

最多可选 3 个互不相邻的格子,方案如下(选中的位置标记为x):

x 1 x
0 x 0

例题解析

我们可以自上而下,一行行地选择格子。在一行内选择格子的时候,只和上一行的选择方案有关,我们就可以将“当前放到第几行、当前行的选择方案”作为状态进行动态规划。

这里,我们就要用到刚刚提到的状态压缩:一行里选择格子的方案实际上是一个集合,我们要将这个集合压缩为一个整数。

比如,对于一个 3 列的矩阵,如果当前行的状态是(5)?10??=(101)?2??,那么就意味着当前行选择了第一个和第三个格子;

类似地,如果当前行的状态是 (6)?10??=(110)?2??,那么就意味着当前行选择了第一个和第二个格子。

如果上一行的状态是now,下一行的状态是prev,那么我们只需要确保上下两行的选择方案里没有重复的元素,也就是(now & prev) == 0就可以了。

此外,我们还需要判断当前行的状态是否合法,因为读入的矩阵中并不是每个格子都可以选择的,如果我们将矩阵中每行的值也用状态压缩来存储,

不妨记为flag,那么当前行选择的格子集合一定包含于当前行合法格子的集合,也就是说,(now | flag) == flag必须成立。

这样,我们就可以通过枚举上一行的所有状态,来更新当前行、当前状态的最优解了。直到算完最后一行,统计一下所有状态的最大值即可。

C++ 示例代码如下:

const int MAX_N = 20;
const int MAX_M = 20;
int state[MAX_N];
int dp[MAX_N + 1][1 << MAX_M];
bool not_intersect(int now, int prev) {
    return (now & prev) == 0;
}
bool fit(int now, int flag) {
    return (now | flag) == flag;
}
int count(int now) {
    int s = 0;  // 统计 now 的二进制形式中有多少个 1
    while (now) {
        s += (now & 1);  // 判断 now 二进制的最后一位是否为 1,如果是则累加
        now >>= 1;  // now 右移一位
    }
    return s;
}
int main() {
    // 初始化所有数组
    for (int i = 0; i < n; ++i) {
        for (int j = 0; j < m; ++j) {
            int flag;
            cin >> flag;
            state[i] |= (1 << j) * flag;  // 将 (i,j) 格子的状态放入 state[i] 中,state[i] 表示第 i 行的可选格子组成的集合
        }
    }
    for (int i = 0; i < n; ++i) {
        for (int j = 0; j < (1 << MAX_M); ++j) {  // 枚举当前行的状态
            if (!fit(j, state[i])) {  // 如果当前行状态不合法则不执行后面的枚举
                continue;
            }
            int cnt = count(j);  // 统计当前行一共选了多少个格子
            for (int k = 0; k < (1 << MAX_M); ++k) {  // 枚举前一行的状态
                if (!not_intersect(j, k)) {  // 判断当前行和前一行的方案是否冲突
                    dp[i][j] = max(dp[i][j], dp[i - 1][k] + cnt);  // 更新当前行、当前状态的最优解
                }
            }
        }
    }
    int ans = 0;  // 保存最终答案
    for (int i = 0; i < (1 << MAX_M); ++i) {
        ans = max(ans, dp[n - 1][i]);  // 枚举所有状态,更新最大值
    }
    cout << ans << endl;
    return 0;
}

 

例题 2

给定一个长度不超过 16 字符串 s,如果 s 中的一个子序列是回文,那么我们就可以从 s 中移除这个子序列,

求最少经过多少步我们可以移除整个字符串 s。如:我们可以从"dqewfretd"中移除 "defed",剩下的字符串即:"qwrt"

例题解析

使用状态压缩 DP 求解,当某状态对应的子序列是回文时,答案为 1;否则是其两个互补的子集操作步骤数之和的最小值。

在更新时我们需要枚举要求解的状态的所有的子集,可以仿照如下的代码进行求解。时间复杂度是 O(pow(3,n)+n×pow(2,n)??)。

 
for (int t = 1; t < (1 << n); t++) {  // 枚举当前状态
    dp[t] = IsPalindrome(t) ? 1 : inf;  // 判断当前状态是否是回文,如果是回文则步骤数为 1
    for(int i = t; i; i = (i - 1) & t) { // 枚举 t 的所有子集
        dp[t] = min(dp[t], dp[i] + dp[t ^ i]);  // 更新当前状态的解的最小值
    }
}
printf("%d\n", dp[(1 << n) - 1]);  // 输出最终答案

 

 

状态压缩DP