状压dp是指设计一个整型可变参数status,利用status的位信息,来表示某个样本是否还能使用,然后利用这个信息进行尝试。写出尝试的递归函数 -> 记忆化搜索 -> 严格位置依赖的动态规划 -> 空间压缩等优化。
如果有k个样本,那么表示这些样本的状态,数量是2^k。所以可变参数status的范围: 0 ~ (2^k)-1
样本每增加一个,状态的数量是指数级增长的,所以状压dp能解决的问题往往样本数据量都不大。一般样本数量在20个以内(10^6),如果超过这个数量,计算量(指令条数)会超过 10^7 ~ 10^8。
如果样本数量大到状压dp解决不了,或者任何动态规划都不可行,那么双向广搜是一个备选思路。
下面通过题目加深理解。
题目一
分析:因为可选择的数不超过20,所以采用状压dp。用一个state的位信息表示一个数取或没取。可能性的展开就是,遍历数字,如果可以取并且取完这个数后另一个玩家是必输的,则当前玩家是必赢的。下面是记忆化搜索的版本,代码如下。
class Solution {
public:
int dp[(1 << 21)] = {0};
bool f(int state, int rest, int maxChoosableInteger){
if(rest <= 0){
return false;
}
if(dp[state] != 0){
return dp[state] == 1;
}
dp[state] = -1;
for(int i = 1;i <= maxChoosableInteger;++i){
if((state & (1 << i)) && !f(state ^ (1 << i), rest-i, maxChoosableInteger)){
dp[state] = 1;
break;
}
}
return dp[state] == 1;
}
bool canIWin(int maxChoosableInteger, int desiredTotal) {
if(desiredTotal == 0){
return true;
}
if((1 + maxChoosableInteger) * maxChoosableInteger / 2 < desiredTotal){
return false;
}
return f((1 << (maxChoosableInteger+1))-1, desiredTotal, maxChoosableInteger);
}
};
其中,f方法返回在state状态和还剩rest就达到界限的情况下当前玩家是否稳赢。
题目二
测试链接:473. 火柴拼正方形 - 力扣(LeetCode)
分析:因为火柴的个数不超过15,所以也可以采用状压dp,用state的位信息表示火柴棒取或没取,只需要逐个拼完四条边就代表能够拼成一个正方形。下面是记忆化搜索的版本,代码如下。
class Solution {
public:
int dp[(1 << 15)] = {0};
bool f(int state, int rest, int nums, vector<int>& matchsticks, int edge){
if(nums == 5){
return true;
}
if(dp[state] != 0){
return dp[state] == 1;
}
dp[state] = -1;
for(int i = 0;i < matchsticks.size();++i){
if((state & (1 << i)) && matchsticks[i] <= rest &&
f(state ^ (1 << i), rest-matchsticks[i] == 0 ? edge : rest-matchsticks[i], rest-matchsticks[i] == 0 ? nums+1 : nums, matchsticks, edge)){
dp[state] = 1;
break;
}
}
return dp[state] == 1;
}
bool makesquare(vector<int>& matchsticks) {
int sum = 0;
for(int i = 0;i < matchsticks.size();++i){
sum += matchsticks[i];
}
int edge = sum / 4;
if(edge * 4 != sum){
return false;
}
return f((1 << (matchsticks.size()))-1, edge, 1, matchsticks, edge);
}
};
其中,f方法返回在state状态下,当前边还剩rest,这是第nums条边,每条边长度edge的情况下能否拼成一个正方形。
题目三
测试链接:698. 划分为k个相等的子集 - 力扣(LeetCode)
分析:这道题和上一道题思路差不多。下面是记忆化搜索的版本,代码如下。
class Solution {
public:
int dp[(1 << 16)] = {0};
bool f(int state, int every, int k, int rest, vector<int>& nums, int length, int seq){
if(seq == k+1){
return true;
}
if(dp[state] != 0){
return dp[state] == 1;
}
dp[state] = -1;
for(int i = 0;i < length;++i){
if((state & (1 << i)) && rest-nums[i] >= 0 &&
f(state ^ (1 << i), every, k, rest-nums[i] == 0 ? every : rest-nums[i], nums, length, rest-nums[i] == 0 ? seq+1 : seq)){
dp[state] = 1;
break;
}
}
return dp[state] == 1;
}
bool canPartitionKSubsets(vector<int>& nums, int k) {
int sum = 0;
int length = nums.size();
for(int i = 0;i < length;++i){
sum += nums[i];
}
int every = sum / k;
if(every * k != sum){
return false;
}
return f((1 << length)-1, every, k, every, nums, length, 1);
}
};
其中,f方法返回在state状态,每个子集和为every,共k个子集,当前子集还差rest,当前是第seq个子集的情况下能够划分完成。
题目四
测试链接:售货员的难题 - 洛谷
分析:这是一个TSP问题,但是因为村庄的个数不超过20,所以可以采用状压dp求解,用state的位信息表示一个村庄是否经过。下面是记忆化搜索的版本,代码如下。
#include <iostream>
using namespace std;
int n;
int path[20][20] = {0};
int dp[(1 << 20)][20] = {0};
int f(int state, int during){
if(state == 0){
return path[during][0];
}
if(dp[state][during] != 0){
return dp[state][during];
}
dp[state][during] = -((1 << 31) + 1);
for(int i = 1;i < n;++i){
if((state & (1 << i))){
dp[state][during] = min(dp[state][during], path[during][i]+f(state ^ (1 << i), i));
}
}
return dp[state][during];
}
int main(void){
scanf("%d", &n);
for(int i = 0;i < n;++i){
for(int j = 0;j < n;++j){
scanf("%d", &path[i][j]);
}
}
printf("%d", f((1 << n)-2, 0));
return 0;
}
其中,f函数返回在state状态,当前村庄为during的情况下完成目标的最短路径。
题目五
测试链接:1434. 每个人戴不同帽子的方案数 - 力扣(LeetCode)
分析:这道题帽子的数量超过了20,所以state并不能用来表示帽子是否被使用。观察到人的个数不超过10,所以state的位信息用来表示人是否被满足。可能性的展开就是在state状态时,对于第i个帽子要或不要。下面是记忆化搜索的版本,代码如下。
class Solution {
public:
int dp[(1 << 10)][41];
int MOD = 1000000007;
int f(int state, int color, vector<vector<int>>& hats, int person, int max_hat){
if(state == 0){
return 1;
}
if(color > max_hat){
return 0;
}
if(dp[state][color] != -1){
return dp[state][color];
}
int ans = f(state, color+1, hats, person, max_hat);
for(int i = 0;i < person;++i){
if((state & (1 << i))){
for(int j = 0;j < hats[i].size();++j){
if(color == hats[i][j]){
ans = (ans + f(state ^ (1 << i), color+1, hats, person, max_hat)) % MOD;
}
}
}
}
dp[state][color] = ans;
return ans;
}
int numberWays(vector<vector<int>>& hats) {
int person = hats.size();
int max_hat = 0;
for(int i = 0;i < person;++i){
for(int j = 0;j < hats[i].size();++j){
max_hat = max(max_hat, hats[i][j]);
}
}
for(int i = 0;i < (1 << person);++i){
for(int j = 1;j <= 40;++j){
dp[i][j] = -1;
}
}
return f((1 << person)-1, 1, hats, person, max_hat);
}
};
其中,f方法返回在state状态时,从color帽子开始能否满足所有人。
题目六
测试链接:1994. 好子集的数目 - 力扣(LeetCode)
分析:这道题需要观察到nums中的值不超过30,而不超过30的质数总共为10个(2,3,5,7,11,13,17,19,23,29)对于这十个质数做状态压缩。就是对于这10个质数所组成的不同状态产生有多少种,把所有状态的总数累加起来就是答案。因为0到30个数规模较小,可以直接用一个表结构缩短时间即对每一个数用一个10位的状态表示,每一位代表一个质数,如果它不能分解为不同的质因子,这个数为零;如果可以则相应分解的质因子的位为1。下面是记忆化搜索的版本,代码如下。
class Solution {
public:
int dp[(1 << 10)][31];
int table[31] = {
0b0000000000,
0b0000000000,
0b0000000001,
0b0000000010,
0b0000000000,
0b0000000100,
0b0000000011,
0b0000001000,
0b0000000000,
0b0000000000,
0b0000000101,
0b0000010000,
0b0000000000,
0b0000100000,
0b0000001001,
0b0000000110,
0b0000000000,
0b0001000000,
0b0000000000,
0b0010000000,
0b0000000000,
0b0000001010,
0b0000010001,
0b0100000000,
0b0000000000,
0b0000000000,
0b0000100001,
0b0000000000,
0b0000000000,
0b1000000000,
0b0000000111
};
int times[31] = {0};
int MOD = 1000000007;
int one = 1;
int f(int state, int i){
if(state == 0){
return one;
}
if(i < 2){
return 0;
}
if(dp[state][i] != -1){
return dp[state][i];
}
int ans = f(state, i-1);
if(table[i] != 0 && (state | table[i]) == state && times[i] != 0){
ans = (int)((ans + (long long)f(state ^ table[i], i-1) * times[i]) % MOD);
}
dp[state][i] = ans;
return ans;
}
int numberOfGoodSubsets(vector<int>& nums) {
int length = nums.size();
int max_num = 0;
for(int i = 0;i < length;++i){
++times[nums[i]];
max_num = max(max_num, nums[i]);
}
for(int i = 0;i < (1 << 10);++i){
for(int j = 0;j <= max_num;++j){
dp[i][j] = -1;
}
}
for(int i = 0;i < times[1];++i){
one = (one << 1) % MOD;
}
int ans = 0;
for(int i = 1;i < (1 << 10);++i){
ans = (ans + f(i, max_num)) % MOD;
}
return ans;
}
};
其中,f方法返回在状态state,从第i个数开始的情况下,产生状态state的种数。
题目七
测试链接:1655. 分配重复整数 - 力扣(LeetCode)
分析:这道题观察到顾客不超过10人所以对顾客采用状态压缩。首先做一个词频统计,然后可能性的展开即对第i个数要或不要,要的话可以满足哪些顾客,这里并不需要将i用完,只需遍历i能满足哪些顾客的种数即可。下面是记忆化搜索的版本,代码如下。
class Solution {
public:
int times[50] = {0};
int dp[(1 << 10)][50] = {0};
int table[(1 << 10)] = {0};
bool f(int state, int i, vector<int>& quantity, int number_num){
if(state == 0){
return true;
}
if(i == number_num){
return false;
}
if(dp[state][i] != 0){
return dp[state][i] == 1;
}
int ans = -1;
for(int j = state;j > 0;j = ((j - 1) & state)){
if(times[i] >= table[j] && f(state ^ j, i+1, quantity, number_num)){
ans = 1;
break;
}
}
if(ans == -1){
ans = f(state, i+1, quantity, number_num) ? 1 : -1;
}
dp[state][i] = ans;
return ans == 1;
}
bool canDistribute(vector<int>& nums, vector<int>& quantity) {
sort(nums.begin(), nums.end());
int length = nums.size();
int person = quantity.size();
int index = 0;
int number_num = 0;
int num;
while (index < length)
{
num = 1;
while (index < length-1 && nums[index] == nums[index+1])
{
++index;
++num;
}
times[number_num++] = num;
++index;
}
int sum;
for(int i = 1;i < (1 << person);++i){
sum = 0;
for(int j = 0;j < person;++j){
if(((i >> j) & 1) != 0){
sum += quantity[j];
}
}
table[i] = sum;
}
return f((1 << person)-1, 0, quantity, number_num);
}
};
其中,table数组存储每一个数相应位代表的顾客被满足需要多少个相同的数;f方法返回在状态state,从下标为i的数开始的情况下能否满足所有顾客。