1. 项目概述:从一道国赛题看状态压缩动态规划
“铺瓷砖”这个题目,乍一看像是装修工地的活儿,但在算法竞赛的语境里,尤其是蓝桥杯国赛这个级别,它立刻变成了一块检验选手对“状态压缩动态规划”理解深度的试金石。这道题通常出现在决赛阶段,意味着它已经脱离了基础语法和简单数据结构的考察,直指算法设计与优化的核心能力。我当年第一次在模拟赛里遇到它时,也是挠头半天,感觉状态千头万绪,无从下手。但一旦你掌握了其背后的核心思想——状态压缩,你会发现它其实是一类非常经典且“套路”清晰的问题。这类问题不仅频繁出现在蓝桥杯、ACM-ICPC等赛事中,其思想在解决某些棋盘覆盖、资源调度乃至电路布局等实际工程问题时,也有着异曲同工之妙。简单来说,题目会给你一个固定大小的矩形区域(比如 N 行 M 列),以及若干种形状的瓷砖(最常见的是 1x2 的矩形砖,也就是多米诺骨牌),要求你用这些瓷砖恰好铺满整个区域,计算一共有多少种不同的铺设方案。旋转、翻转通常视为不同的方案。这里的“恰好铺满”和“所有方案数”就是动态规划(DP)的典型特征,而“每一行的铺设状态”如何表示,就是状态压缩技术大显身手的地方。
2. 核心思路拆解:为什么是状态压缩DP?
面对一个 N x M 的网格,最暴力的想法是回溯搜索,枚举每一格铺砖的所有可能性。但稍微计算一下就知道不可行:格子数稍多(比如10x10),状态空间就爆炸了。动态规划是优化这类计数问题的利器,其关键在于找到合适的“状态”定义和状态转移方程。
2.1 状态定义的困境与突破
我们本能地会想,用dp[i][j]表示铺到第i行第j列时的方案数。但问题立刻来了:砖块是横着铺(占两列)或竖着铺(占两行)。当你决定在(i, j)铺一块横砖时,它会影响(i, j+1);铺竖砖则会影响(i+1, j)。这意味着当前格子的决策严重依赖于后面格子的状态,简单的线性DP难以处理这种后效性。
一个关键的思路转变是:按行进行DP。我们不再一格一格地推进,而是一行一行地铺。定义dp[i][s]表示已经铺完前i-1行,并且第i行的铺设状态为s时,总共的方案数。这里的“状态s”就是一个压缩的表示。
2.2 状态压缩的精髓
什么是“第i行的状态”?想象一下,第i行有 M 个格子。每个格子只有两种可能:被铺满(来自上一行延伸下来的竖砖的“下半部分”),或者空着(等待本行或下一行的砖来铺)。我们用二进制位来表示:1 表示该格子已经被铺了(是竖砖的下半部分),0 表示该格子还空着。
例如,M=4时,状态s=5(二进制 0101) 表示第 i 行的第1、3列(从0或1开始计数依个人习惯)的格子已经被上一行的竖砖“占用了”,而第2、4列是空的。
这样,一个s就是一个 0 到(1<<M)-1之间的整数,完美地用一个小整数编码了一整行的格子占用情况。这就是“状态压缩”——将一行 M 个格子的布尔状态压缩成一个整数。
2.3 状态转移的逻辑
有了dp[i][s],如何转移到dp[i+1][t]呢?这代表了从第 i 行状态 s 铺到第 i+1 行状态 t 的过程。这个过程可以分解为两步:
填充本行剩余空位:状态 s 中的 0 表示本行(第 i 行)的空位。这些空位必须由从本行开始的砖来铺满,因为下一行的砖够不到它们。铺砖有两种选择:
- 铺横砖:覆盖两个连续的 0。这会将这两个 0 变成 1(表示已铺),但注意,这个“1”是铺砖的结果,不是来自上一行的占用。为了区分,我们在填充过程中可以用另一个变量来表示填充后的状态。
- 铺竖砖:覆盖一个 0。竖砖的下半部分在本行,上半部分在下一行。所以,铺一个竖砖意味着把本行的这个 0 变成 1(已铺),同时在下一行的对应位置产生一个 1(被占用)。这个“下一行的1”正好对应了下一行状态 t 中的某个位为1。
生成下一行状态:在填充完本行所有空位后,本行所有格子都应该是 1(已铺)。此时,那些因为竖砖而产生的、对下一行的“占用标记”,就构成了下一行的初始状态 t。换句话说,t 的二进制表示中,所有为 1 的位,都对应着从第 i 行“长上来”的竖砖的上半部分。
因此,状态转移就是:枚举所有能从状态 s 出发,通过铺砖(横砖和竖砖)使得本行被完全铺满,并且同时生成下一行状态 t 的所有合法方式。每找到一种方式,就将dp[i][s]的方案数加到dp[i+1][t]上。
注意:这里有一个非常重要的预处理技巧。我们并不需要在DP过程中实时计算从 s 到 t 的转移。因为行宽 M 是固定的,所有可能的 s 和 t 也是有限的(最多 2^M 种)。我们可以预先计算出所有合法的
(s, t)转移对。这个预处理过程本身也是一个深度优先搜索(DFS):以当前行状态 s 和下一行状态 t(初始为0)为起点,逐列扫描,根据 s 当前位是1还是0,决定如何铺砖并更新 t。
3. 算法实现细节与代码解析
理解了核心思想后,我们来看具体的代码实现。这里以最经典的 1x2 砖块铺满 N x M 地面为例,其中 M 通常较小(因为状态数是 2^M),N 可以较大。
3.1 预处理:生成所有状态转移
这是整个算法中最精妙也最容易出错的部分。我们写一个 DFS 函数,参数是当前列索引col、当前行状态s、下一行状态t。
s是已知的,表示上一行留给本行的占用情况。- 我们在函数中试图铺满本行,并生成
t。 - 从第0列开始,逐列处理。
/** * 预处理所有合法的状态转移 * @param M 列数 * @return 一个列表,下标为 s,值为所有能从 s 转移到的 t 的集合 */ List<Integer>[] preprocess(int M) { int stateCount = 1 << M; // 状态总数 List<Integer>[] transfer = new ArrayList[stateCount]; for (int i = 0; i < stateCount; i++) { transfer[i] = new ArrayList<>(); } for (int s = 0; s < stateCount; s++) { dfs(s, 0, 0, M, transfer); } return transfer; } /** * DFS深搜,寻找从状态s出发的所有合法下一行状态t * @param s 当前行状态 * @param col 当前处理到的列 (0-indexed) * @param t 正在构建的下一行状态 * @param M 总列数 * @param transfer 状态转移表 */ void dfs(int s, int col, int t, int M, List<Integer>[] transfer) { if (col == M) { // 已经处理完所有列,本行必须被完全铺满(即s中所有位在过程中都被处理成了1的等价形式) // 实际上,我们的递归逻辑保证了当col==M时,s的所有空位已被铺满。 // 此时生成的t就是一个合法的下一行状态。 transfer[s].add(t); return; } // 情况1:s在当前列是1(被上一行竖砖占用) if ((s & (1 << col)) != 0) { // 这个位置已经被占了,不能放砖,直接跳到下一列 // 注意:t的当前列保持为0,因为这里没有新的竖砖开始 dfs(s, col + 1, t, M, transfer); } else { // 情况2:s在当前列是0(空位),必须用砖铺满 // 选项2.1:尝试铺横砖(1x2),需要当前列和下一列都是空位 if (col + 1 < M && (s & (1 << (col + 1))) == 0) { // 横砖覆盖了col和col+1列,这两列在本行都被铺满,对下一行t没有影响 dfs(s, col + 2, t, M, transfer); } // 选项2.2:尝试铺竖砖(2x1) // 竖砖覆盖本行col列和下一行col列。本行col被铺满,下一行col列被标记为占用(t中对应位设为1) dfs(s, col + 1, t | (1 << col), M, transfer); } }这个 DFS 函数需要仔细理解:参数s是固定的,我们通过递归尝试所有铺砖方式。当col == M时,意味着我们已经成功用砖(或跳过被占位)处理完了本行所有列,此时构建出的t就是一个合法的、从s能转移到的下一行状态。
3.2 动态规划主过程
预处理得到转移表后,DP过程就非常清晰了。
long solve(int N, int M) { // 确保M不大于N,否则交换,因为状态数是2^M,我们希望M更小 if ((N & 1) == 1 && (M & 1) == 1) { // 如果N和M都是奇数,面积是奇数,不可能用1x2砖铺满 return 0; } if (N < M) { // 交换,使得M是较小的那个维度 int temp = N; N = M; M = temp; } int stateCount = 1 << M; List<Integer>[] transfer = preprocess(M); // dp[i][s]: 铺完前i行,且第i行状态为s的方案数 long[][] dp = new long[N + 1][stateCount]; // 初始化:第0行(虚拟行)的状态必须是“全部被占满”,因为没有任何砖从上一行伸过来。 // 对于第0行,我们认为它已经被完全铺满,没有任何空位需要本行砖来铺,其状态就是0(没有竖砖的上半部分)。 // 但更准确地说,我们考虑铺第1行时,依赖于第0行的状态。第0行作为起点,应该是一个“已经被完美铺完,且没有砖头伸出来”的状态。 // 这个状态就是 s=0。所以 dp[0][0] = 1。 dp[0][0] = 1; for (int i = 0; i < N; i++) { for (int s = 0; s < stateCount; s++) { if (dp[i][s] == 0) continue; // 剪枝 // 遍历所有能从s转移到的状态t for (int t : transfer[s]) { dp[i + 1][t] += dp[i][s]; } } } // 最终,铺完第N行后,不应该再有砖头伸向第N+1行。 // 也就是说,第N行的状态必须是0(没有未完成的竖砖)。 return dp[N][0]; }关键点提示:初始化
dp[0][0]=1表示一种“空”的初始状态。最终答案dp[N][0]要求最后一行状态为0,确保了所有砖块都完整地铺在N行之内,没有超出边界。
3.3 大数处理与性能优化
对于较大的 N 和 M(比如 M=10,N=100),方案数可能非常巨大,通常会要求输出结果对某个大数(如1e9+7)取模。我们只需在累加时进行取模运算即可:dp[i+1][t] = (dp[i+1][t] + dp[i][s]) % MOD。
此外,如果 N 特别大(比如 10^9),而 M 很小(比如 <=10),我们可以利用矩阵快速幂来加速。因为状态转移方程dp[i+1][t] = Σ dp[i][s] * A[s][t]本质上是一个线性递推,其中A是状态转移矩阵(A[s][t]=1表示可从 s 转移到 t)。那么dp[N] = dp[0] * (A)^N。用矩阵快速幂可以在O((2^M)^3 * logN)时间内解决,这对于 N 巨大而 M 小的情况是可行的。不过,在蓝桥杯国赛环境下,N 通常不会大到需要矩阵快速幂,掌握基础的状压DP足以应对。
4. 从解题到举一反三:状态压缩DP的常见变体
“铺瓷砖”是状压DP的入门经典题。一旦掌握,你可以解决一系列类似问题:
4.1 变体一:砖块形状变化
题目可能将 1x2 砖块换成 1x3、L形砖(俄罗斯方块)等。处理思路不变,但 DFS 预处理函数中的“铺砖”选项需要改变。你需要根据新砖块的形状,修改递归分支。例如对于 1x3 砖,你需要检查连续三列是否为空;对于 L 形砖,你需要枚举其所有旋转形态,并检查对应格子是否为空。
4.2 变体二:棋盘限制
有些格子可能被禁止铺设(比如坏了)。这可以在状态s中融入进来。一种方法是将棋盘障碍也视为一种“预先占用”,在预处理 DFS 时,如果当前列是障碍格,那么即使s对应位是0,也不能铺砖,只能跳过(如果障碍格在s中对应位是1,那本身就是矛盾的,该状态无效)。更常见的是,将障碍信息作为参数传入DFS,在递归时判断。
4.3 变体三:求具体方案或最优解
原题是计数问题。如果改为输出一种具体方案,需要在DP过程中记录路径(即从哪个状态转移而来)。如果改为求最优解(比如每种砖有成本,求最小总成本),那么将 DP 数组的值从方案数改为最小成本,状态转移时的加法改为取最小值即可。
4.4 与其他模型的结合
状压DP的思想可以迁移到很多其他问题,例如:
- 旅行商问题(TSP):
dp[s][i]表示已访问城市集合为s(压缩状态),当前位于城市i的最短路径。 - 任务调度:
dp[s]表示完成任务集合s的最短时间或最大收益。 - 棋盘覆盖/炮兵阵地:和铺瓷砖高度相似,只是摆放规则和状态定义略有不同。
5. 实战调试与常见“坑点”
即使理解了算法,实现时也难免踩坑。下面是我在多次练习和教学中总结的几个常见问题:
5.1 初始化与最终状态理解错误
这是最容易出错的地方。dp[0][0] = 1的物理意义是:第0行是虚拟的、已经铺好的行,并且没有任何砖块伸到第1行。最终状态dp[N][0]表示第N行铺完后,也没有砖块伸向不存在的第N+1行。一定要想清楚这个“行”的索引含义。有些人会定义dp[i][s]为铺完前 i 行后,第 i 行的状态,这时 i 从1开始,初始化dp[0][0]=1可能就更直观。
5.2 DFS预处理中的位运算错误
在DFS中,检查s的第col位是否为1,用的是(s & (1 << col)) != 0。设置t的第col位为1,用的是t | (1 << col)。务必注意运算符的优先级,不确定时就加括号。例如s & (1 << col) == 0在Java中是错误的,因为==优先级高于&,必须写成(s & (1 << col)) == 0。
5.3 横砖放置的边界检查
在尝试铺横砖时,一定要检查col + 1 < M,否则会数组越界。这是递归中的一个边界条件,容易遗漏。
5.4 状态空间过大与内存优化
当 M=10 时,状态数有1024个,DP数组是dp[N+1][1024],对于 N=100 是可行的。但如果 M 更大(比如12),状态数4096,可能就需要考虑优化。一种常见的优化是“滚动数组”,因为dp[i+1]只依赖于dp[i],所以只需要两个一维数组交替使用即可,能将空间复杂度从 O(N * 2^M) 降到 O(2^M)。
long[] current = new long[stateCount]; long[] next = new long[stateCount]; current[0] = 1; for (int i = 0; i < N; i++) { Arrays.fill(next, 0); // 清空next数组 for (int s = 0; s < stateCount; s++) { if (current[s] == 0) continue; for (int t : transfer[s]) { next[t] = (next[t] + current[s]) % MOD; } } // 交换数组,准备下一次迭代 long[] temp = current; current = next; next = temp; } return current[0]; // 最终状态在current数组中5.5 整数溢出问题
方案数增长极快,即使 N 和 M 不大,结果也可能超出long的范围(2^63-1)。务必在比赛时看清题目要求,如果要求取模,从一开始就进行取模运算。如果题目不要求取模且结果可能很大,可能需要使用BigInteger,但这在算法竞赛中非常罕见,通常都会要求取模。
6. 性能分析与竞赛策略
在蓝桥杯等竞赛中,遇到此类题目,可以遵循以下步骤:
- 识别模型:看到网格覆盖、计数、数据范围(N大M小),立刻联想到状压DP。
- 确定状态:定义
dp[i][s],其中 s 是当前行状态。 - 预处理转移:编写 DFS 函数,生成所有合法的
(s, t)对。这是核心,写完后可以用小数据(如M=2,3)手动验证。 - DP循环:初始化
dp[0][0]=1,循环 N 次,根据转移表更新。 - 输出答案:
dp[N][0]。
时间复杂度为O(N * 2^M * T),其中 T 是平均每个状态 s 能转移到的状态 t 的数量。由于有效的转移并不多,这个复杂度对于 M<=10, N<=100 是绰绰有余的。
最后,再分享一个调试小技巧:当你的程序输出结果不对时,不要急于看代码。先尝试 N=1, M=1,2,3 等极小情况,手动计算答案,然后与程序输出对比。往往能快速定位是预处理错误还是DP循环错误。对于状压DP,画图是理解状态转移的最好帮手,在纸上画出一个 N=2, M=3 的网格,手动模拟一下 DFS 和 DP 的过程,比干看代码有效得多。这道题吃透后,你会对“状态”和“压缩”有更深的理解,再遇到类似的题目,思路就会清晰很多。