动态规划详解:掩码子集问题

作者: | 更新日期:

枚举掩码的子集、子集容斥、子集最大值、子集和,一次性讲清楚。

本文首发于公众号:天空的代码世界,微信号:tiankonguse

零、背景

二进制的子集问题,是动态规划中的一类重要问题。

最近在做算法比赛的时候,遇到了多次子集相关的问题,所以写一篇总结。

本文主要围绕「位运算 + 状压 DP」这一条主线,依次讲四个常见场景:

  • 枚举子集
  • 子集容斥
  • 子集最大值
  • 子集和(Sum Over Subsets,SOS DP)

希望看完之后,你在遇到「二进制 + 子集」类的题目时,可以有一个比较完整的套路。

一、枚举子集

示意图

如上图,给一个掩码 1011,总位数为 n = 4,其中有 k = 3 个 1。
这里的 k 一般用来表示「1 的个数(置位数)」,后面复杂度里的 2^k 就是指「只和这些 1 所在的位有关」。

最朴素的做法是枚举所有 n 位的二进制数,然后判断它是不是 mask 的子集:

1)枚举所有 0 ~ (1<<n)-1
2)对每个 i,用 (i & mask) == i 判断它是否是 mask 的子集。

复杂度:O(2^n)

for (int i = 0; i < (1 << n); i++) {
  if ((i & mask) == i) {
    printf("%d\n", i);
  }
}

而更优的方式是利用位运算,直接在「mask 的所有子集」之间跳转,从一个子集快速到下一个子集。

1)从 sub = mask 开始;
2)不断做 sub = (sub - 1) & mask,就能枚举完所有非空子集。

复杂度:O(2^k)

const int mask = 0b1011;
for (int sub = mask; sub; sub = (sub - 1) & mask) {
  printf("%d\n", sub);
}

二、子集容斥

子集容斥可以理解为:对普通容斥(选 / 不选某个元素)做了一次「状态压缩」。

  • 有 n 个元素,每个元素对应一位 bit;
  • 我们考虑所有 bit 的「选 / 不选」组合;
  • 每一种组合对应一个子集,它对答案有一个贡献。

对于所有单个 bit,我们先分别算出它们的贡献。

之后会发现:
两个 bit 一起选的时候,被算了两次,需要减掉;
三个 bit 一起选的时候,被多减了一次,需要加回来;

总结下来就是:
选中 bit 个数为奇数,加;
选中 bit 个数为偶数,减。

有了这个规律之后,就可以「枚举所有子集」,然后按子集的 1 的个数决定是加还是减:

for (int sub = mask; sub; sub = (sub - 1) & mask) {
  ll oneCount = __builtin_popcount(sub);
  if (oneCount % 2 == 1) {
    ans = (ans + SolverSub(sub)) % mod;
  } else {
    ans = (ans - SolverSub(sub) + mod) % mod;
  }
}

这里的 SolverSub(sub) 表示「只选中 sub 这些 bit 时,对答案的贡献」,具体怎么算由题目决定。

这一节和上一节的关系是:
第一节教你「怎么枚举所有子集」;
这一节告诉你「在所有子集上做容斥计算」的一个常见模式。

三、子集最大值

LeetCode 第 465 场算法比赛》的第三题,转化之后,可以看成一个「二进制子集最大值」问题。

子集最大值是一个非常经典的状态压缩 DP 模板。
大致意思是:

dp[mask] 表示子集 mask 的答案;
我们希望让 dp[mask] 变成「所有子集中的最大值」;
也就是「把一个子集,按 bit 拆成若干更小的子问题」,然后在这些子问题的答案中取最大值。

有两种常见的写法:

1)当前代码写法:枚举一个「子问题」有哪些「父问题」,并更新父问题的最大值。

const int MaxBitVal = 1 << maxBit;
for (auto v : nums) {
  dp[v] = v;
}
for (int i = 0; i < MaxBitVal; i++) {
  for (int j = 0; j < maxBit; j++) {
    dp[i | (1 << j)] = max(dp[i | (1 << j)], dp[i]);
  }
}

这里的含义是:

枚举所有较小的 i
i 的答案「推」到所有只多了一个 bit 的父集合 i | (1<<j) 上;
最终,每个 mask 都能拿到所有子集里的最大值。

2)更标准、更直观的写法:枚举一个「父问题」有哪些「子问题」,并从子问题中取最大值。

for (int i = 0; i < MaxBitVal; i++) {
  for (int j = 0; j < maxBit; j++) {
    if (i & (1 << j)){
      dp[i] = max(dp[i], dp[i ^ (1 << j)]);
    }
  }
}

具体含义是:
对于每个 mask,枚举它每一位为 1 的 bit;
把这个 bit 去掉得到更小的子集 mask ^ (1<<j)
在这些子集中取最大值。

两种写法本质上是同一个 DP 思想:
要么从「小集合推到大集合」;
要么从「大集合从子集中取最大值」。

四、子集和

LeetCode 第 477 场算法比赛》的第四题,问题转化之后,变成了求二进制子集的和。

子集和问题也是一个典型的状态压缩 DP,不过比「子集最大值」稍微复杂一些。

如果你按前一节的思路,直接按照 bit 拆分子问题,很快就会发现:会出现大量重复子问题;

如下图所示,如果我们「随便拆」子问题,会出现许多交叉和重叠。

示意图

为了解决这个问题,我们需要「有策略地拆分子问题」,让子问题之间没有交集。

一种显然的策略是:按前缀来划分。

例如,对于 01011,我们可以先对最高位的 1 进行划分:

1)划分为 01011,且前缀必须包含 01
2)划分为 00011,且前缀必须包含 00

两个划分的后缀都相同,都是 011,但是由于前缀不同,所以它们是不同的子问题。
这个过程是递归的,每递归一次,前缀长度增加 1。
到叶子节点的时候,就划分出所有子问题。

示意图

根据这个划分,我们可以定义状态:
f(i, mask) 表示:从第 i 位及更高位已经固定时,mask 的子集和。

状态转移方程如下:

// 前提: mask & (1 << i) != 0
f(i + 1, mask) = f(i, mask) + f(i, mask ^ (1 << i));

含义是:
对第 i 位为 1 的情况: 要么这一位在子集中选(对应 mask);
要么这一位在子集中不选(对应 mask ^ (1<<i));

两种情况的贡献相加,就得到更高一层的结果。

完整的二维 DP 写法如下:

vector<vector<ll>> dp(kMaxBit + 1, vector<ll>(kMaxMask, 0));
for (ll x : nums) {
  dp[0][x]++;
}
for (int i = 0; i < kMaxBit; i++) {
  for (int mask = 0; mask < kMaxMask; mask++) {
    if (mask & (1 << i)) {
      dp[i + 1][mask] = dp[i][mask] + dp[i][mask ^ (1 << i)];
    } else {
      dp[i + 1][mask] = dp[i][mask];
    }
  }
}

可以看到:
dp[i + 1][mask] 只依赖 dp[i][mask]dp[i][mask ^ (1 << i)]
mask 这一维只会依赖到一个更小的 mask ^ (1 << i)

因此,我们可以在第二维上做空间压缩,把二维 DP 压成一维:
用一个 dp[mask] 表示当前层的结果;
按 bit 一层一层地更新它。

for (int i = 0; i < kMaxBit; i++) {
  for (int mask = kMaxMask - 1; mask >= 0; mask--) {
    if (mask & (1 << i)) {
      dp[mask] += dp[mask ^ (1 << i)];
    }
  }
}

从依赖关系可以看出:
每一层的转移,都是把一个当前 Bit 位无关的掩码加到当前 Bit 位有关的掩码;
因此遍历顺序实际上并不影响正确性。

用图再看一次,会比较直观:

示意图

在这棵二叉树中,每个节点有两个儿子:
一个儿子是「值不变」的自己;
另一个儿子是「去掉当前 bit」之后的更小值。

由于更小的值在这一层不满足 mask & (1 << i),这一层不会对它进行运算。
所以枚举顺序可以是任意的,例如:

for (int i = 0; i < kMaxBit; i++) {
  for (int mask = 0; mask < kMaxMask; mask++) {
    if (mask & (1 << i)) {
      dp[mask] += dp[mask ^ (1 << i)];
    }
  }
}

这正是经典的 Sum Over Subsets(SOS DP)/ 高维前缀和 模板写法。
复杂度是 O(k * 2^k)。

示意图

为了进一步提高性能,还可以只枚举「总体 OR 掩码」 preOr 的子集:

for (int i = 0; i < kMaxBit; i++) {
  if ((preOr & (1 << i)) == 0) continue;
  for (int mask = preOr; mask; mask = (mask - 1) & preOr) {
    if (mask & (1 << i)) {
      dp[mask] += dp[mask ^ (1 << i)];
    }
  }
}

这里利用了第一节的「枚举子集」技巧,把不可能出现的 mask 直接跳过。

随着一步步优化,耗时也从 1607ms 降低到了 196ms。

示意图

五、最后

好了,这篇文章分享了:

  • 枚举子集的高效模板;
  • 子集容斥的经典写法;
  • 子集最大值的两种等价 DP 实现;
  • 子集和(SOS DP / 高维前缀和)的状态设计与优化。

尤其是子集和部分,对应的标准名字叫做 Sum Over Subsets,简称 SOS,对应的算法通常被称作「高维前缀和」。

不过,即使你暂时不熟悉 SOS DP 的术语,只要理解了「划分出不重叠的子问题」这个思路,用一个二维 DP 也能把问题解决;在此基础上,再做空间压缩和枚举优化即可。

《完》

-EOF-

本文公众号:天空的代码世界
个人微信号:tiankonguse
公众号 ID:tiankonguse-code

本文首发于公众号:天空的代码世界,微信号:tiankonguse
如果你想留言,可以在微信里面关注公众号进行留言。

关注公众号,接收最新消息

tiankonguse +
穿越