291. 蒙德里安的梦想
题目描述
求把N×M的棋盘分割成若干个1×2的的长方形,有多少种方案。
例如当N=2,M=4时,共有5种方案。当N=2,M=3时,共有3种方案。
如下图所示:
输入格式
输入包含多组测试用例。
每组测试用例占一行,包含两个整数N和M。
当输入用例N=0,M=0时,表示输入终止,且该用例无需处理。
输出格式
每个测试用例输出一个结果,每个结果占一行。
数据范围
1≤N,M≤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
分析
1. 怎么求?
方形可以横着摆,也可以竖着摆,把所有合理的横着的摆放方案数求出来,对应的竖着的方形直接对应放进去即可,即方案数不改变。
所以总方案数等价于求只摆放横着的方形的合理方案数。
2. 怎么判断当前方案是否合法?
看当前的剩余位置能否填满竖着的方形。
可以按列来看,每一列横着摆放的方形之间连续的空着的行需要是偶数个,这样才可以插入x个完整的竖着的方形。因为一个竖着的方形占两行。
一个横着的方形占两列,我们可以假设方形都是从第i-1列伸出,到第i列的,占据两列。
然后观察每行摆放的横着的方形,假设有n行,那他就有2n种状态,从0000……0到1111……1,即从0到2n -1,共占n位,代表的含义是,为1则代表该行有一个方形,为0则代表该行没有方形。
dp[2][11001]
下标本身的含义就是如图所示:
第1列伸出到第2列的方形占据的行数是第0行、第1行、第4行。
二进制数11001
就是一种状态。
这样就可以让计算机判断当前方案是否合法了,
一、 先预处理好合理的状态进行标记,即每一列连续的空行是偶数个,状态共有0000……0到1111……1,共2n种。
二、 第i列的方形从第i-1列伸出,同时第i-1列既是起点,又是终点,是第i-2列伸出方形的终点。因此对于第i-1列方形的状态合理性,必须要考虑从第i-2列伸出的到第i-1列的方形的状态k和从第i-1列伸出的到第i列的方形的状态j二者相或后的状态的合理性。即 j|k
要符合预处理的合理状态。
三、 状态k和j的方形在第i-1列不能产生冲突。即i-1列的第j行不能既作为起点又作为终点。所以 j&k==0
。
注意:状态j和k不需要合理,
j|k
合理就行。如果要求状态j和k合理反而错了。
我们要求的棋盘共有n行、m列,从下标0开始存储,即从第0行到第n-1行,从第0列到第m-1列。
状态表示:dp[i][j]
的值 表示从第i-1列伸出的,到第i列的状态是j的方案数。
在求dp[i][j]
时,说明以 前i - 2列 为起点伸出的方形的所有状态已经全部求好,所以前i-2列都是合理的;在求出dp[i][j]
后,说明在以 前i-1列 为起点伸出的方形的状态是j 已经求好,在这种状态下前i-1列的摆放是合理的。
可以归结为:在第i列时考虑第i-1列的状态的合理性。
因此我们想求出n×m的棋盘摆放的方案数时,仅仅求到第m-1列是不够的,要多求1列,把第m列也求出来,这样才能确保0~m-1列的摆放是合理的。然后dp[m][0]
就是我们要求的答案,意思是 前m-1列已经全部摆好,以m-1列为起点伸出的方形的所有状态都求出来了,但我们只需要从第m-1列到m列状态是0的方形摆放,意即从第m-1列到第m列没有伸出来方形的的所有方案,就是整个棋盘全部摆好方形的方案。
状态计算: 求每一个dp[i][j]
的值,就是求从第i-1列伸出的,到第i列的状态是j的方形摆放的方案数,将其初始化为0,我们需要找出第i-2列中所有从第i-2列伸出的,到第i-1列状态时k的方形摆放,得到所有的f[i-1][k]
,然后加到f[i][j]
上面: f [ i ] [ j ] + = f [ i − 1 ] [ k ] ; f[i][j]+=f[i-1][k]; f[i][j]+=f[i−1][k];
状态j和k要满足上面提到的条件:
j&k==0
&&j|k是合理的
初始化情况:
第0列前面没有起点,即没有一列可以作为起点伸出方形到第0列,第0列不能作为终点,所以第0列只能作为起点。
我们假设有第-1列,那么第-1列也可以向第0列伸出2n种方形的状态,但是根据题意,只有000……00的方形的状态,即第-1列不向第0列伸出方形。
因此我们可以初始化dp[0][0]=1
,其它j>0时的dp[0][j]
都为0。
我们接下来从第1列开始递推,而不是从0列,求以第0列作为起点到第1列的伸出的方形的合理状态,但是递推第1列时也比较特殊,因为我们求的是第0列的合理性,而我们的递推公式仅限于该列既是起点又是终点的情况,假想一种状态j=10100
,这是不合理的,即从第0列到第1列不能伸出这种状态的方形,但是当假想第-1列到第0列的状态k=01000
时,j|k=11100
,它就又是合理的了,这与事实不符,采取措施:
- 把第一列也预先初始化,我们注意到第0列到第1列伸出方形的状态j是不受k约束的实际上,因此只要j合理,就可以作为伸出的一种状态,也不用考虑冲突问题,可以取
dp[1][j]=1
。 - 依然这么递推,不过只有
k=0
时的一次循环有意义,其它情况下,当k>0时,不管伸出方形的状态合理不合理,都是没有意义的,好在dp[0][k]=0
,所以对dp[1][[j]的值没有影响,仅仅当k=0时,dp[0][0]
会影响dp[1][j]
的值。可能在后面找到的满足条件的状态k在第i-2列也没有伸出来对应的方形
总结,
第0列是固定的,dp[0][0]=1;
第1列的值只可能是0和1,在状态j合理的情况下,dp[1][j]=1。
后面的就可以按照常规情况考虑了,从1列开始,后面的每一列既可以作为起点,又可以作为终点,直到循环到第m列时,求出第m-1列伸到第m列方形的状态为0时的方案数时即可,这样0~m-1列就全部摆好了。
措施1就是开始就初始化两列,方法二就是只初始化一列,第1列在for循环时处理。
输出结果时,要多处理一列。假如只有一列,dp[0][j]
,那就要多处理一列,最后输出dp[1][0]
,所以当只有1列时也是要处理第1列的,所以两种措施是一样的,担心的是一列也不存在,即有0列的情况,那么措施1就不太好了,因为只要输出dp[0][0]
的值就够了,但是题目规定列数一定是大于等于1的,所以二者措施都可。
算法实现
1<<n 左移n位,是n+1位,1个1,n个0,代表十进制数2n ,而我们需要的表示的n行的二进制状态是从000……0到111……1,工n位,从n个0到n个1,从0~2n -1。
措施1:
#include <iostream>
#include <cstring>
using namespace std;
const int N=12,M=1<<N; //M=2^12
long long dp[N][M];
bool st[M];
int main()
{
int n,m; "n行m列,从0存储,第n-1行,第m-1列"
while (cin>>n>>m,n||m) {
memset(dp,0,sizeof dp); "每次都要初始化"
"预处理,去掉无效的状态,同时初始化第1列"
for (int i=0;i<(1<<n);i++) {
"0~2^n-1,共2^n种状态"
int cnt=0; st[i]=true;
for (int j=0;j<n;j++) "0~2^n-1,n位二进制表示,下标0到n-1,进行移位"
if (i>>j&1) {
if (cnt & 1) {
st[i]=false; break;} "判断两个插入的行之间的空行是否是偶数"
cnt=0;
} else cnt++;
if (cnt & 1) st[i]=false; "判断最后一次插入的行到最后一行之间的空行"
else dp[1][i]=1; "如果状态i合理的话,用以初始化第1列"
}
"初始化第0列,假想一个第-1列向第0列伸出的状态,即什么也没有伸出。所以dp[0][0]=1,其它状态dp[0][j]=0;"
dp[0][0]=1; "最好写上,预防m=0的情况,一列也不存在,需要输出dp[0][0]"
"开始从第2列处理"
for (int i=2;i<=m;i++) "多处理一列,第m列,第m-1列是数据输入的最后一列,求当从第m-1列伸出状态为0的方形时的方案数即可。"
for (int j=0;j<(1<<n);j++) "枚举从第i-1列到第i列可以伸出的状态"
for (int k=0;k<(1<<n);k++) "枚举从第i-2列到第i-1列伸出的状态"
if ((j&k)==0 && st[j|k] ) dp[i][j]+=dp[i-1][k]; "dp[i][j]初始化为0"
printf("%lld\n",dp[m][0]);
}
return 0;
}
措施2:
#include <iostream>
#include <cstring>
using namespace std;
const int N=12,M=1<<N; //M=2^12
long long dp[N][M];
bool st[M];
int main()
{
int n,m; //n行m列,从0存储,第n-1行,第m-1列
while (cin>>n>>m,n||m) {
//预处理,去掉无效的状态
for (int i=0;i<(1<<n);i++) {
int cnt=0; st[i]=true;
for (int j=0;j<n;j++) //n位数,下标0到n-1,进行移位
if (i>>j&1) {
if (cnt & 1) {
st[i]=false; break;} //判断两个插入的行之间的空行是否是偶数
cnt=0;
} else cnt++;
if (cnt & 1) st[i]=false; //判断最后一次插入的行到最后一行之间的空行
}
//初始化第0列,假想一个第-1列向第0列伸出的状态,即什么也没有伸出。所以dp[0][0]=1,其它状态dp[0][j]=0;
memset(dp,0,sizeof dp);
dp[0][0]=1;
//开始从第1列处理
for (int i=1;i<=m;i++) //多处理一列,第m列,第m-1列是数据输入的最后一列,求当从第m-1列伸出状态为0的木块时的方案数即可。
for (int j=0;j<(1<<n);j++) //枚举从第i-1列到第i列可以伸出的状态
for (int k=0;k<(1<<n);k++) //枚举从第i-2列到第i-1列伸出的状态
if ((j&k)==0 && st[j|k] ) dp[i][j]+=dp[i-1][k]; //dp[i][j]为0
printf("%lld\n",dp[m][0]);
}
return 0;
}
时间复杂度
dp的时间复杂度 =状态表示× 状态转移
状态表示 f[i,j] 第一维i可取11,第二维j(二进制数)可取211 ,所以状态表示 11×211
状态转移 也是211
所以总的时间复杂度11×211×211=11×222=44×220≈4.4×107 可以过。
注意到思想和代码实现上的差距:
思想上, 对于第i-1列伸到第i列的方形的状态j,我们要考虑第i-2列伸出的方形的状态k,使其满足我们上面要求的两个条件,以此来保证第i-1列合理。
但是,实际上, 我们并没有从第i-2列伸出的方形的状态来考虑,而是枚举所有与状态j匹配的状态k,将其套在第i-2列上,这就可能存在一种情况,第i-2列并没有伸出这样的方形的状态k,因此实际的判断条件应该写成:
if (dp[i-1][k] && (j&k)==0 && st[j|k] ) dp[i][j]+=dp[i-1][k];
如果dp[i-1][k]
为0,说明没有这样状态的方形从第i-2列伸到第i-1列,如果不为0,才要相加。
但是如果没有这样的状态,就算让它加上也没事,因为dp[i-1][k]==0
,加上也没影响。
加一个判断条件
dp[i-1][k]>0
,执行时间上就加长了。
这也就解释了计算第1列时候,虽然不存在方形伸向第0列,但是我们初始化了dp[0][0]=1
,说明假想了第-1列,它向第0列只伸出了状态为0的方形,其它dp[0][j]
都为0,所以就算从第0列伸向第1列的状态j和假想的k>0时的状态k匹配上也没事,因为dp[0][k]
的值为0,或者按照我们的思路,计算第1列时的dp[1][j]不受限于k,直接初始化即可。
优化处理
在处理时观察到,三层循环的第二层、第三层每次都枚举一样的,可以先预处理好每一种伸出方形的状态j能够匹配成功的伸出方形的状态k,这样就可以优化第三层循环了,只走一遍预处理即可。
#include <iostream>
#include <cstring>
#include <vector>
using namespace std;
const int N=12,M=1<<N;
long long dp[N][M]; "易错点一"
bool st[M];
vector<int> links[M];
int main()
{
int n,m;
while (cin>>n>>m,n||m) {
"初始化第0列"
memset(dp,0,sizeof dp);
dp[0][0]=1;
"预处理,去除无效状态,同时初始化第一列"
for (int i=0;i<(1<<n);i++) {
0~2^n-1,共2^n种状态
int cnt=0; st[i]=true;
for (int j=0;j<n;j++) 0~2^n-1,都是n位,下标从0到n-1,进行移位
if (i>>j&1) {
if (cnt&1) {
st[i]=false; break;}
cnt=0;
} else cnt++;
if (cnt&1) st[i]=false;
else dp[1][i]=1;
}
"预处理,找到所有状态下的可以和j匹配的合法的k:j&k==0 && st[j|k]==true"
for (int j=0;j<(1<<n);j++) {
links[j].clear(); 多组输入,需要先清空
for (int k=0;k<(1<<n);k++)
if ( (j&k)==0&&st[j|k] ) links[j].push_back(k);
} "易错点二:j&k==0"
递推
for (int i=2;i<=m;i++)
for (int j=0;j<(1<<n);j++)
for (auto k:links[j]) dp[i][j]+=dp[i-1][k];
printf("%lld\n",dp[m][0]);
}
return 0;
}
两个易错点:
- 虽然最多11行、11列,但是结果dp[m][0]的范围可能超过3e9,较大,建议开long long。
- 运算符优先级问题:
j&k==0
和(j&k)==0
不一样。