【例 2】牧场的安排(信息学奥赛一本通- P1593)三角形牧场(洛谷-P1284)

【题目描述】

原题来自: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,方案数累加。

四、 时空复杂度分析

  • 时间复杂度 : 预处理阶段枚举次计算。 DP 转移阶段,外层M循环 12 次,内层两个合法阵型循环最多。计算次数约为 次。 整体时间复杂度为,在 C++ 一秒跑10^8次的边缘极其安全,毫无悬念AC。

  • 空间复杂度 : dp15400使用 long long 存储,约占据,远低于题目要求的512MB限制。空间极度充裕。

五、 坑点与易错总结

  1. 加法取模遗漏(最致命死穴) :DP 数组的累加呈指数级增长,虽然本题数据小靠 long long 勉强兜底,但在标准的竞赛中,只要有加法的地方必须立刻取模 dp[i][o] = (dp[i][o] + dp[i-1][b]) % mod,否则必定溢出变成负数。

  2. 算术地基缺失 :如果你把合法的阵型分行单独存储(如代码中的 vec[i]),那么一定要给第 0 行手动添加一个 0 阵型 (vec[0].push_back(0))。否则第 1 行在循环上一行时,面对一个空的名单,循环根本不会启动,最终答案全是 0。

  3. 位运算优先级错觉 :在 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;
}
相关推荐
HPtiger1 小时前
交换类排序详解:冒泡排序与快速排序
数据结构·算法·排序算法
Cccp.1232 小时前
【leetcode】(十四)从暴力递归到动态规划
数据结构·算法·leetcode·动态规划
旖旎夜光2 小时前
LeetCode 215: 数组中的第K个最大元素 (快速排序) —— 题解
c++·学习·算法·leetcode·力控
技术胖2 小时前
奎爷巡演 AIGC 视频教程 文档
算法
德彪稳坐倒骑驴3 小时前
Leetcode力扣刷题记录与题解
算法·leetcode·职场和发展
lzhdim9 小时前
AI的分类
人工智能·算法·机器学习·分类·数据挖掘
凤凰院凶涛QAQ12 小时前
《Java版数据结构 & 集合类剖析》Map & Set & 反射:“从哈希碰撞到红黑树转换,反射帮你扒开 HashMap 的桶数组看个究竟”
java·数据结构·算法
w_zero_one14 小时前
算法--模拟
java·数据结构·算法
秋天的怀念_David14 小时前
算法一千零一夜 · 第十三夜 幻方,武侠世界里的数学迷宫
java·程序人生·算法