题意:
求把N * M(1≤N,M ≤11)的棋盘分割成若干个1*2的长方形,有多少种方案。
例如当N=2,M = 4 时,共有5种方案。如下图所示:
当N=2,M =3时,有3种方案。如下图所示:
思路:
数据范围非常小,N<=11
,这提示我们可能用状态压缩dp求解。
本题核心:
先放横着的,后放竖着的,可以发现,当把所有横着的处理完后,竖着的就只有一种填充方案,比如下图,红色部分包含两横放的方块,剩下部分直接用竖着的方块填充即可:
因此,在计算方案数的时候,我们可以统计:如果我们只放横着的方块的话,合法的摆放方案有多少种。
(总方案数 = 只放横着的小方块的合法方案数)
接下来的问题就是:如何判断当前方案是否合法?所有剩余位置能否填充满竖着的小方块。
对此问题我们可以按列分析,每一列内部 所有 连续未填充的 小方块是偶数个。(这样才可以填满)
接下来分析一下如何用动态规划处理这道题(闫氏dp分析法)
状态表示:
f[i, j]
表示 已经将前 i - 1
列摆好,且 从第 i - 1
列伸到第 i
列的 状态为 j
的所有方案。
解释:
- “已经将前 i - 1 列摆好”:由于前
i-1
列已经摆好,因此现在不会新增方块再摆到前i-1
列了。 - “从第
i-1
列伸到第i
列的状态为j
”:如下图,i=2
,有第0、1、4
行的方块从第i-1
列伸到第i
列,其二进制表示状态为j = (11001)
, - 此时
f[2, (11001)]
则表示为:前i
列摆完 之后,从第i-1
列伸到第i
列,且 状态形如j
方案 的集合(不一定只有一种,因为在此之前的摆放方式是不确定的,这里是用某一个状态表示某一类方案,这是一个“化零为整”的过程)
状态计算:
(考虑 f[i, j]
如何计算出来,对应的是一个化整为零的过程)
将 f[i, j]
表示成一个 椭圆状集合,想办法将椭圆进行分割,使得每一部分都可以被计算出来,最终的结果应当是每一部分的方案数量之和
划分依据:最后一步不同的操作
解释:由于 最后一步从第 i-1
列到第 i
列的方案 已经固定,我们进行 集合划分 应该找的是 最后一个不同点,即 从第 i-2
列伸到第 i-1
列 的方案,
因此我们可以依据 第 i-2
列伸到第 i-1
列的状态 将椭圆分割成若干类
f[i ,j]
表示的椭圆 最坏情况下分割为 2^n
类子集,因为 n
位中每一位有 0、1
两种情况。每一类子集都表示一个 从第 i-2
列伸到第 i-1
列 的具体状态,比如我们将其设为一个二进制数 k
,
如果第 i-2
列到第 i-1
列只有第 2
行伸出,那么 k = (00100)
。如下图绿色部分
上述的划分方案是不重不漏的,划分的所有部分 相互之间无交集且一起构成了所有的方案。
现在我们想想 划分的每一类如何求解:
假设 从第 i-2
列伸到第 i-1
列 的状态为 k
,从第 i-1
列伸到第 i
列 的状态为 j
,显然 方案数量 根据定义为 f[i-1, k]
,我们更要注意的是 k、j
两个状态能 构成一个合法方案 的条件:
- 首先上图中绿色方块和红色方块不能处于同一行,否则会产生交集,不合法。即 不能在同一行都有
1
,两者相与结果为0
,代码表示为i & k == 0
- 对于第
i-1
列,空余小方格的位置已经固定(联系f[i, j]
的含义即可),这些空余的位置可以 完全被竖着的1×2
方块填满,因此条件为:所有连续空余的位置长度必须为偶数
只要满足上面两个条件,就说明 k、j
这两个状态是没有冲突的,我们将所有无矛盾的状态 k
累加起来,就得到了 f[i, j]
的值
设下标从 0
开始,共有 m
列,最后一列为 m-1
列,本题 最终答案 为:f[m, 0]
(前 m-1
列已经摆好,且从第 m-1
列伸到第 m
列的状态为 0
,其恰为摆满 n × m
棋盘的所有方案)
时间复杂度:
两维状态,第一维最大为 11,第二维最大为 2 ^ 11,状态计算最多有 2 ^ 11中情况,因此最坏情况下时间复杂度为 11 × 2 ^ 11× 2 ^ 11 ≈ 4e7,而且还有多组测试数据,因此本题时间限制开到了 3s
代码:
为了保证不超出时间限制,我们将代码做些优化:预处理对于每个状态 j 而言,有哪些状态 k 可以更新成 j ,这样一来我们就无需每次都要将 j、k 循环判断。
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N = 12; //由于最后后处理到 m+1(从0开始计数) 列,因此这里要加一
int n, m;
ll dp[N][1<<N];
vector<int> state[1<<N]; //存储合法状态,state[i] 表示一维数组中的元素可以合法转移到 i
bool st[N]; //判断某个状态是否合法:当前列是否能由 1×2 方格填充满,当前列所有连续空余的长度是否为偶数
void init1() //预处理st数组
{
for(int i=0; i<1<<n; ++i)
{
int cnt = 0; //连续空余部分0的个数
bool is_valid = true; //表示是否合法
for(int j=0; j<n; ++j) //枚举每一位
if(i>>j&1) //当前位为1
{
if(cnt&1) //判断当前这位 1 是否有奇数个
{
is_valid = false; //如为奇数说明不合法
break;
}
cnt = 0; //由于我们记录的是连续的0,因此出现 1 则要将 cnt 清 0
}
else ++cnt; //否则继续计数
if(cnt&1) is_valid = false; //最后一段0是奇数也是不合法的
st[i] = is_valid;
}
}
void init2()
{
for(int i=0; i<1<<n; ++i) //枚举所有合法状态
{
state[i].clear();
for(int j=0; j<1<<n; ++j)
{
//满足两条:①两状态的同一行不能有两个1,即两状态每行都是0对1,相与为0
//②所有填满的位置是合法的(i、j两状态取并集 如101 | 010 = 111)
if((i & j) == 0 && st[i | j])
state[i].push_back(j); //说明j可以合法转移到i
}
}
}
int main()
{
while(cin>>n>>m, n||m)
{
init1(), init2(); //预处理函数
memset(dp, 0, sizeof dp);
dp[0][0] = 1; //初始化,空棋盘时有一种方案
for(int i=1; i<=m; ++i)
{
for(int j=0; j<1<<n; ++j)
{
for(auto k : state[j])
dp[i][j] += dp[i-1][k];
}
}
cout<<dp[m][0]<<endl;
}
return 0;
}