【题目描述】
原题来自:USACO 2006 Nov. Gold
Farmer John 新买了一块长方形的牧场,这块牧场被划分成 M 行 N 列 (1≤M≤12;1≤N≤12),每一格都是一块正方形的土地。FJ 打算在牧场上的某几格土地里种上美味的草,供他的奶牛们享用。遗憾的是,有些土地相当的贫瘠,不能用来放牧。并且,奶牛们喜欢独占一块草地,于是 FJ 不会选择两块相邻的土地,即:没有哪两块草地有公共边。当然,FJ 还没有决定在哪些土地上种草。
作为一个好奇的农场主,FJ 想知道,如果不考虑草地的总块数,那么,一共有多少种种植方案可供他选择。当然,把新的牧场荒废,不在任何土地上种草,也算一种方案。请你帮 FJ 算一下这个总方案数。
【输入】
第 1 行:两个正整数 M 和 N,用空格隔开;
第 2 到 M+1 行:每行包含 N 个用空格隔开的整数,描述了每块土地的状态。输入的第 i+1 行描述了第 i 行的土地。所有整数均为 0 或 1,1 表示这块土地足够肥沃,0 则表示这块地上不适合种草。
【输出】
第 1 行:输出一个整数,即牧场分配总方案数除以 108 的余数。
【输入样例】
2 3
1 1 1
0 1 0
【输出样例】
9
【提示】
样例说明
按下图把各块土地编号:
1 2 3
0 4 0
只开辟一块草地有 4 种方案:选 1,2,3,4 中的任一块。开辟两块草地的话,有 3 种方案:13,14 以及 34。选三块草地只有一种方案:134。再加把牧场荒废的那一种,总方案数为 4+3+1+1=9 种。
数据范围与提示:
1≤N,M≤12。
一、 题意转换
本题本质上是求解在一个含有残缺节点(贫瘠土地)的网格图上,寻找所有可能的独立集(任意两个被选中的顶点不相邻)组合总方案数。
二、 思考过程与解题思路
面对这道题,我们是如何一步步推导出最优解的?
1. 为什么第一直觉的暴力会惨死? 刚学完dfs的同学,第一直觉肯定是:遍历棋盘的每一个格子,是肥沃土地就枚举"种"或"不种"。 但我们算一笔物理算力的账:当时,棋盘最多有 144 个格子。暴力搜索的极限时间复杂度高达
,连宇宙毁灭都算不完,毫无疑问会超时。
2. 破局点:无后效性与"截面理论" 仔细审题:"没有哪两块草地有公共边"。这意味着,我们在第 i 行种草时,它的限制仅仅来自于第i-1行。第i-2行及更早的历史怎么种,对第i行完全没有影响。这种"历史无关性",正是动态规划能够生效的标志。
3. 为什么是"状压 DP"? 顺着 DP 的思路,我们需要把"上一行是怎么种的"作为状态记录下来。但是,一行有 12 个格子,普通的整型变量(标量)无法表达这种复杂的"01 分布形状",无法进行上下两行的冲突检测。 因此,我们利用二进制,把一整行 12 个格子的状态(如 10100)强行压缩成一个十进制整数(如 20)。将二维形状"降维"成一维数字,这正是状态压缩 DP 的绝对核心动机!
三、 算法设计与样例推演
1. 状态定义与空间预估(斐波那契的魔法)
设dpio表示:当前推导到第i行,且第i行选用了第o种合法阵型时的总方案数。
此时面临一个关键问题:第三维(合法阵型的数量)到底该开多大? N=12时,原生状态最大为 。但题目要求"草地不能相邻",所以存在大量如
000000000011 这种废状态。 把相邻为 1 的废状态全部淘汰后,剩下的合法状态数量其实完美契合斐波那契数列。
推导过程:对于长度为n的合法 01 序列:
若第一位填 0,剩下n-1位随便填,有F(n-1)种。
若第一位填 1,第二位定死是 0(不能相邻),剩下n-2位随便填,有F(n-2)种。
故F(n) = F(n-1) + F(n-2)。
当N=12时,斐波那契数列的值恰好是 377!所以数组第二维开到 400 足矣。
2. 状态转移方程
dpio =
其中,b是上一行的阵型下标。上下两行不能冲突的物理意义在于:同一个列不能同时为 1 。 翻译成位运算就是极其优雅的判断:(s1 & s2) == 0。
3. 极简样例推演(手玩)
假设有的玉米田,地形如下:
1 1 1 (第一行全肥沃) 0 1 0 (第二行仅中间肥沃)
-
第 1 行 :合法阵型有
000(0),001(1),010(2),100(4),101(5)。这 5 种阵型完美契合地形,各产生 1 种方案。 -
第 2 行 :合法阵型本来也有 5 种,但由于地形限制,只有
000(0) 和010(2) 能用。- 若第 2 行选
010,回头看第 1 行:它不能和第一行的010、011等对接(因为010 & 010 != 0)。它只能继承第 1 行的000,001,100,101,方案数累加。
- 若第 2 行选
四、 时空复杂度分析
-
时间复杂度 : 预处理阶段枚举
次计算。 DP 转移阶段,外层M循环 12 次,内层两个合法阵型循环最多
。计算次数约为
次。 整体时间复杂度为
,在 C++ 一秒跑10^8次的边缘极其安全,毫无悬念AC。
-
空间复杂度 : dp15400使用
long long存储,约占据,远低于题目要求的512MB限制。空间极度充裕。
五、 坑点与易错总结
-
加法取模遗漏(最致命死穴) :DP 数组的累加呈指数级增长,虽然本题数据小靠
long long勉强兜底,但在标准的竞赛中,只要有加法的地方必须立刻取模dp[i][o] = (dp[i][o] + dp[i-1][b]) % mod,否则必定溢出变成负数。 -
算术地基缺失 :如果你把合法的阵型分行单独存储(如代码中的
vec[i]),那么一定要给第 0 行手动添加一个0阵型 (vec[0].push_back(0))。否则第 1 行在循环上一行时,面对一个空的名单,循环根本不会启动,最终答案全是 0。 -
位运算优先级错觉 :在 C++ 中,
&的优先级低于==。写成j & j << 1 == 0是极其致命的!必须加括号 写成(j & (j << 1)) == 0。
六、 代码进阶与优化剖析(核心教学点)
下方提供的 <我的代码>,采用了一种极其高阶的思路:"按行独家定制合法名单"。 很多状压 DP 的写法是:先全局找出 377 个合法阵型,在 DP 循环里再去判断地形。而这份代码在预处理阶段,就结合每一行的具体地形,把踩到贫瘠土地的废阵型直接"拉黑"了!这使得后面的 DP 循环纯净无比,极大降低了常数时间,非常值得学习。
但是,针对代码中"判断地形是否合法"的逻辑,我们仍然有极大的优化空间:
1. 用 O(1) 的位运算替代 O(N) 的内层循环(常数降维打击) 在原代码中,判断当前阵型 j 是否踩雷,是写了一个的内层循环,用
((j >> (k - 1)) % 2) == 1 逐位抠出来比较的。这种写法非常适合初学者理解物理意义,但在竞赛实战中略显繁琐。进阶技巧 :在读入地形时,我们可以直接把一整行的地形也压缩成一个整数 field[i](肥沃为1,贫瘠为0)。 那么,判断阵型 j 是否完美契合地形,只需要一行代码:
if ((j & field[i]) == j) // 代表 j 阵型中放牛(1)的位置,field 中必然也是肥沃(1)
这直接砍掉了一个内层循环,让预处理速度瞬间飙升 10 倍。
七、 核心代码与批注详解
cpp
//m n都小于等于12 数据范围小
//但是如果直接打暴力 最多144个格子
//2^144不太行
//我们发现每一行只与相邻行互相产生影响
//所以我们可以按行进行处理
//那么每一行的状态我们可以作为一个维度
//这个时候我们就会顺其自然的想到用状压dp了
//状态定义:
//i:当前遍历到第i行 o:第i行选用的阵型下标
//dp[i][o]:当前遍历到第i行选用o阵型的总方案数
//第二维因为如果直接存阵型(01组合)所代表的整数
//2^12-1=4095 比较大 但是我们根据观察可以发现
//有大量的01组合是有相邻的1的 但是草地不能挨着草地
//所以我们可以把大量相邻的全部淘汰
//合法的阵型最多只有377个
//所以第三维我们只需要开到400就行
//同学们肯定好奇377怎么来的,其实不相邻的01组合就是斐波那契数列
/*
1.如果第一位填0,那么后面的n-1位只需要是一个合法的n-1位序列即可
这有F(n-1)种情况。
2.如果第一位填1,因为不能有相邻的1,所以第二位必须是0,
那么前面两位定死成了10 剩下的n-2位只需要是一个合法的n-2位序列即可
这有F(n-2)种情况。
加起来就是递推式:F(n)=F(n-1)+F(n-2)
*/
//考场时候想不起来是斐波那契数列怎么办呢 我们可以直接写个循环
//直接用程序算出一个n位的二进制数 相邻位不能有1 有多少种情况
//或者就直接开4100 毕竟也不大 不会超空间
#include <iostream>
#include <vector>
using namespace std;
int m,n;
long long dp[15][400];
const int mod=100000000;
//存每一行所有合法阵型(01组合)转化为10进制所代表的数
vector <int>vec[15];//vec[i][a]代表第i行第a种合法阵型是什么
//g[i][j]==1代表第i行第j列适合种草
//g[i][j]==0代表第i行第j列不适合种草
int g[15][15];
int main(){
cin>>m>>n;
//接下来把土地是否适合种草情况预存下来
//【优化批注】:进阶写法可在此处同时压缩地形,如 field[i] = (field[i] << 1) + x;
for(int i=1;i<=m;i++){
for(int j=1;j<=n;j++){
cin>>g[i][j];
}
}
//先把每一行所有合法阵型预处理出来
//遍历每一行土地情况 1表示这块土地足够肥沃 0表示这块地上不适合种草
for(int i=1;i<=m;i++){
//一个阵型要是合法阵型 必须满足两个条件
//1阵型相邻没有草
//2不能种草的格子没有草
//遍历每一种阵型
for(int j=0;j<(1<<n);j++){
//先确保满足条件一:j阵型相邻没有草(即没有相邻的1)
//阵型j&(j<<1)如果等于0 代表相邻位没有草(没有相邻的1)
if((j&(j<<1))==0){
//标记j阵型是否满足条件2
bool flag=1;
//接下来确保满足条件2 即不适合种草的土地上不能种草
//即符合要求的j对应位置只能为0
//遍历第i行的所有土地格子k
//【优化批注】:此处整个 for 循环,可被一句极简的 if ((j & field[i]) == j) 彻底替代!
for(int k=1;k<=n;k++){
//如果x为0 代表第i行第k个格子不适合种草
int x;
x=g[i][k];
//接下来判断当前的阵型j是否在不适合种草的格子k上强行种了草
if(x==0){
//如果不适合种草的格子j阵型也种草
//就直接标记不满足条件2 退出循环
if((j>>(k-1))%2==1){
flag=0;
break;
}
}
}
//j既满足条件一也满足条件二j才作为合法阵型存入vec
if(flag==1) vec[i].push_back(j);
}
}
}
//第0行 采用第0个合法状态方案数为1
dp[0][0]=1;
//这个一定一定不能丢 向第0行塞入一个合法的全0阵型
//不然到第一行的时候去寻找上一行对接的时候
//空的vec[0] 循环连进都不会进
vec[0].push_back(0);
//最外层循环代表现在遍历到第i行
for(int i=1;i<=m;i++){
//sum为当前行有多少种预存的合法方案数
long long sum=vec[i].size();
//遍历当前行所有预存的合法方案
for(int o=0;o<sum;o++){
//s1为当前行第o种合法方案所代表的阵型
int s1=vec[i][o];
//接下来遍历上一行所有预存的合法方案
//sum2为上一行有多少种预存的合法方案数
long long sum2=vec[i-1].size();
for(int b=0;b<sum2;b++){
//s2为上一行第b种合法方案所代表的阵型
int s2=vec[i-1][b];
//如果需要s1和s2匹配
//则当前行有草的列 上一行不能有草
//即需要满足(s1&s2)==0
if((s1&s2)==0){
//把第i-1行的第b种阵型的方案累加进第i行第o种阵型的方案数
//第i行第o种阵型的方案数
dp[i][o]=(dp[i][o]+dp[i-1][b])%mod;
}
}
}
}
//最后计算总方案数
long long ans=0;
//目标是铺满m行
//总方案数就是m行草地的所有合法阵型的方案数之和
int sum3=vec[m].size();//sum3为第m行总合法阵型数
for(int o=0;o<sum3;o++) ans=(ans+dp[m][o])%mod;
cout<<ans;
return 0;
}