"优化状态空间:状态压缩DP在蒙德里安的梦想问题中的应用"

330 阅读8分钟

蒙德里安的梦想

求把 𝑁×𝑀 的棋盘分割成若干个 1×2 的长方形,有多少种方案。

例如当𝑁=2,𝑀=4 时,共有 5 种方案。当 𝑁=2,𝑀=3 时,共有 3 种方案。

如下图所示:

输入格式

输入包含多组测试用例。

每组测试用例占一行,包含两个整数 𝑁 和 𝑀。

当输入用例 𝑁=0,𝑀=0 时,表示输入终止,且该用例无需处理。

输出格式

每个测试用例输出一个结果,每个结果占一行。

数据范围

1≤𝑁,𝑀≤11

输入样例:

1 2
1 3
1 4
2 2
2 3
2 4
2 11
4 11
0 0

输出样例:

1
0
1
2
3
5
144
51205

定义状态

dp[i][j]表示前i - 1列的方格都已完全覆盖,第i列方格被第i - 1列伸出的方块覆盖后状态为j的所有方案数

例如,上图表示的就是dp[3][010010]的状态(红色为2 * 1方块,绿色为1 * 2方块)0表示没有覆盖,1表示覆盖

状态转移

我们采用由底至上的递推方式,即由当前状态推出下一列状态的方案数 以某一列的状态而言

  1. 如果当前行的格子已被上一列伸出的方块覆盖,则跳过
  2. 如果当前行的格子未被覆盖,说明可以放一个1 * 2的方块
  3. 如果当前行的格子和下一行的格子都未被覆盖,说明可以放一个2 * 1的方块
  4. 此列所有行的格子都覆盖完后,我们便可以得出下一列的合法状态

如上图,我们对第3列的状态进行搜索后可到达的其中一种状态

为什么使用搜索?

根据dp数组的定义可知,第一列不可能被上一列伸出的方块覆盖,所以初始化为dp[1][000] = 1,搜索下一列可得:

可知第二列可到达的状态只有3种,于是进行第三列的搜索时只需从这3种状态开始dfs,当前阶段总是影响下一阶段,我们只对可到达的进行讨论,并不需要枚举每一种情况 以下是DFS搜索的代码,附详细注释

void dfs(int row, int col, int state, int next) {
    //row为当前行,col为当前列,state为当前列的状态,next为可到达的下一列的状态
    //当前列全覆盖后可到达的下一个状态加上当前状态的方案数
    if (row == n) {
        //当前列所有行都已覆盖完毕
        dp[col + 1][next] += dp[col][state];
        return;
    }
    //如果当前行的格子已被覆盖,跳过
    if (state & (1 << row)) dfs(row + 1, col, state, next);
    else {
        //当前行未被覆盖,可放一个1*2的方块
        dfs(row + 1, col, state, next | (1 << row));
        //当前行和下一行都未被覆盖,可放一个2*1的方块
        if (row + 1 < n && (state & (1 << (row + 1))) == 0) dfs(row + 2, col, state, next);
    }
}

C++代码

#include<bits/stdc++.h>
using namespace std;
int n, m;
long long dp[12][2500];
void dfs(int row, int col, int state, int next) {
    //row为当前行,col为当前列,state为当前列的状态,next为可到达的下一列的状态
    //当前列全覆盖后可到达的下一个状态加上当前状态的方案数
    if (row == n) {
        //当前列所有行都已覆盖完毕
        dp[col + 1][next] += dp[col][state];
        return;
    }
    //如果当前行的格子已被覆盖,跳过
    if (state & (1 << row)) dfs(row + 1, col, state, next);
    else {
        //当前行未被覆盖,可放一个1*2的方块
        dfs(row + 1, col, state, next | (1 << row));
        //当前行和下一行都未被覆盖,可放一个2*1的方块
        if (row + 1 < n && (state & (1 << (row + 1))) == 0) dfs(row + 2, col, state, next);
    }
}
int main()
{
    while (scanf("%d%d", &n, &m) && n && m) {
        if (n > m) swap(n, m);
        //因为n行m列和n列m行的方案数等价,所以我们不妨将min(n, m)作为二进制枚举的指数,减少方案数
        memset(dp, 0, sizeof(dp));
        dp[0][0] = 1;
        for (int i = 0; i < m; i++) {
            for (int j = 0; j < (1 << n); j++) {
                if (dp[i][j] > 0) {     //筛选出之前搜索过可到达的状态
                    dfs(0, i, j, 0);
                }
            }
        }
        //因为下标从0开始,所以dp[m][0]表示第m + 1列没有任何第m列的方块伸出的方案数
        cout << dp[m][0] << endl;
    }
    return 0;
}

带分析的代码

/*
下文对  if ((j & k ) == 0 && st[ j | k] )  有清晰的解释!!!
*/


#include <bits/stdc++.h>
using namespace std;


const int N = 12, M = 1<< N;  

long long f[N][M] ;// 第一维表示列, 第二维表示所有可能的状态

bool st[M];  //存储每种状态是否有奇数个连续的0,如果奇数个0是无效状态,如果是偶数个零置为true。

//vector<int > state[M];  //二维数组记录合法的状态
vector<vector<int>> state(M);  //两种写法等价:二维数组

int m, n;

int main() {

    while (cin >> n >> m, n || m) { //读入n和m,并且不是两个0即合法输入就继续读入

        //第一部分:预处理1
        //对于每种状态,先预处理每列不能有奇数个连续的0

        for(int i = 0; i < (1 << n); i ++) {

            int cnt = 0 ;//记录连续的0的个数

            bool isValid = true; // 某种状态没有奇数个连续的0则标记为true

            for(int j = 0; j < n; j ++) { //遍历这一列,从上到下

                 if ( (i >> j) & 1) {  
                     //i >> j位运算,表示i(i在此处是一种状态)的二进制数的第j位; 
                     // &1为判断该位是否为1,如果为1进入if
                    if (cnt & 1) { 
                    //这一位为1,看前面连续的0的个数,如果是奇数(cnt &1为真)则该状态不合法
                        isValid =false; break;
                    } 

                    cnt = 0; // 既然该位是1,并且前面不是奇数个0(经过上面的if判断),计数器清零。
                    //其实清不清零没有影响
                 }
                 else cnt ++; //否则的话该位还是0,则统计连续0的计数器++。
            }
            if (cnt & 1)  isValid = false; //最下面的那一段判断一下连续的0的个数

            st[i]  = isValid; //状态i是否有奇数个连续的0的情况,输入到数组st中
        }

        //第二部分:预处理2
        // 经过上面每种状态 连续0的判断,已经筛掉一些状态。
        //下面来看进一步的判断:看第i-2列伸出来的和第i-1列伸出去的是否冲突

        for (int j = 0; j < (1 << n); j ++) { //对于第i列的所有状态
            state[j].clear(); //清空上次操作遗留的状态,防止影响本次状态。

            for (int k = 0; k < (1 << n); k ++) { //对于第i-1列所有状态
                if ((j & k ) == 0 && st[ j | k]) 
                // 第i-2列伸出来的 和第i-1列伸出来的不冲突(不在同一行) 
                //解释一下st[j | k] 
                //已经知道st[]数组表示的是这一列没有连续奇数个0的情况,
                //我们要考虑的是第i-1列(第i-1列是这里的主体)中从第i-2列横插过来的,
                //还要考虑自己这一列(i-1列)横插到第i列的
                //比如 第i-2列插过来的是k=10101,第i-1列插出去到第i列的是 j =01000,
                //那么合在第i-1列,到底有多少个1呢?
                //自然想到的就是这两个操作共同的结果:两个状态或。 j | k = 01000 | 10101 = 11101
                //这个 j|k 就是当前 第i-1列的到底有几个1,即哪几行是横着放格子的

                    state[j].push_back(k);  
                    //二维数组state[j]表示第j行, 
                    //j表示 第i列“真正”可行的状态,
                    //如果第i-1列的状态k和j不冲突则压入state数组中的第j行。
                    //“真正”可行是指:既没有前后两列伸进伸出的冲突;又没有连续奇数个0。
            }

        }

        //第三部分:dp开始

        memset(f, 0, sizeof f);  
        //全部初始化为0,因为是连续读入,这里是一个清空操作。
        //类似上面的state[j].clear()

        f[0][0] = 1 ;// 这里需要回忆状态表示的定义
        //按定义这里是:前第-1列都摆好,且从-1列到第0列伸出来的状态为0的方案数。
        //首先,这里没有-1列,最少也是0列。
        //其次,没有伸出来,即没有横着摆的。即这里第0列只有竖着摆这1种状态。

        for (int i = 1; i <= m; i ++) { //遍历每一列:第i列合法范围是(0~m-1列)
            for (int j = 0; j < (1<<n); j ++) {  //遍历当前列(第i列)所有状态j
                for (auto k : state[j])    // 遍历第i-1列的状态k,如果“真正”可行,就转移
                    f[i][j] += f[i-1][k];    // 当前列的方案数就等于之前的第i-1列所有状态k的累加。
            }
        }

        //最后答案是什么呢?
        //f[m][0]表示 前m-1列都处理完,并且第m-1列没有伸出来的所有方案数。
        //即整个棋盘处理完的方案数

        cout << f[m][0] << endl;

    }
}