2026/8/29 20:38:27

蓝桥杯经典铺瓷砖问题:状态压缩DP解法详解

蓝桥杯经典铺瓷砖问题:状态压缩DP解法详解 1. 项目概述与核心价值看到“蓝桥杯CB组2017决赛铺瓷砖”这个标题很多参加过蓝桥杯或者正在备赛的同学应该会心一笑或者眉头一皱。这道题可以说是蓝桥杯竞赛史上的一道经典“拦路虎”它完美地融合了深度优先搜索DFS、状态压缩和动态规划DP这三种在算法竞赛中至关重要的思想。对于C B组决赛这个级别来说它考察的已经不仅仅是简单的语法和基础算法而是选手在面对一个复杂、看似无从下手的组合优化问题时如何抽丝剥茧建立数学模型并高效实现的能力。这道题的价值在于它提供了一个绝佳的范本让你理解如何将现实中的“铺砖”问题转化为计算机可以高效求解的状态转移问题。掌握它你收获的不仅仅是一道题的解法更是一种解决复杂搜索与状态压缩问题的通用思维框架。无论你是正在备赛蓝桥杯还是希望提升自己的算法功底这道题都值得你花时间彻底吃透。2. 问题解析与难点定位2.1 问题场景还原让我们先抛开代码想象一下这个场景你有一个长为N、宽为2的长条形区域可以看作一个2 x N的网格现在给你两种瓷砖一种是1x2的长方形砖竖着或横着放另一种是2x2的正方形砖。题目要求你用这些砖铺满整个区域计算一共有多少种不同的铺法。举个例子如果N1区域是2x1你只能用两块1x2的砖竖着放只有1种铺法。 如果N2区域是2x2你可以全部用1x2的砖有两种方式全部竖放两列或者全部横放两行。直接用一块2x2的砖。 所以总共有1 1 1 3种铺法。注意这里“不同铺法”指的是瓷砖的排列组合方式不同旋转、对称后相同的布局如果是由不同的瓷砖放置过程产生的通常也被视为不同在状态压缩DP的语境下我们关心的是网格的覆盖状态。2.2 核心难点剖析为什么这道题难如果N很小比如N10我们完全可以用最朴素的深度优先搜索DFS回溯尝试在每一个空位放置各种可能的瓷砖。但蓝桥杯决赛的数据规模N往往会达到10甚至更大比如N30。对于2x30的网格状态空间巨大朴素DFS的复杂度是指数级的必然超时。因此难点在于状态爆炸直接枚举每个格子的铺砖方式状态太多。后效性当前行的铺砖方式会影响到下一行。比如如果我们在当前行横放了一块1x2的砖它就会占据下一行对应位置的一个格子下一行铺砖时就必须考虑这个“被占用的”格子。解决这两个难点的钥匙就是状态压缩动态规划状压DP。其核心思想是按列推进我们不再一个格子一个格子地枚举而是按列进行决策。每一列有2个格子每个格子有两种状态0未被覆盖/属于上一列横砖的延伸部分和1已被当前列新放置的砖覆盖。压缩状态将一列2个格子的覆盖情况用一个二进制数表示例如00、01、10、11这个数就是“状态”。这样一列的状态最多只有2^2 4种。状态转移定义dp[i][s]表示铺满前i列且第i列的覆盖状态为s时有多少种铺法。我们关心的是从第i-1列的状态s1能否通过在第i-1列放置合适的瓷砖使得第i-1列被完全铺满并且第i列的状态变为s2。如果能那么dp[i][s2] dp[i-1][s1]。这样一来我们将一个二维的铺砖问题转化为了一个在有限状态4种之间进行转移的一维DP问题复杂度降为O(N * 4 * 4)对于N30是瞬间可解的。3. 状态压缩动态规划状压DP详解3.1 状态定义与编码这是最关键的一步。我们定义每一列的两个格子从上到下为一个二进制位。格子为空等待被覆盖或者被上一列的横砖占用了记为0。格子已被当前列新放置的砖覆盖记为1。那么一列的状态可以用一个0到3的整数state表示0(00): 本列两个格子都“空着”。注意这个“空着”可能意味着它们真的是空的也可能是被上一列横放的砖“预留”了但在本列的视角下它们是需要被本列或后续列覆盖的“空位”。1(01): 本列上方格子被覆盖(1)下方格子空着(0)。2(10): 本列上方格子空着(0)下方格子被覆盖(1)。3(11): 本列两个格子都被覆盖。定义dp[i][state]表示铺满前i列并且第i列的覆盖状态为state的所有方案数。 我们最终要求的是dp[N][3]即铺满前N列且第N列自身也被完全覆盖状态为11的方案数。为什么要求第N列状态是3因为我们要铺满整个区域最后一列不能留下任何空位给不存在的“下一列”。3.2 状态转移方程与预处理状态转移的核心是已知第i-1列的状态是s1我们能在第i-1列放置哪些瓷砖使得第i-1列被完全铺满同时第i列的状态变为s2。重要理解dp[i][s2]中的s2表示的是第i列被“影响”后的状态。当我们决策第i-1列时我们放置的砖块可能会覆盖到第i列的部分格子这个覆盖情况就是s2。而我们的放置必须保证第i-1列原本的空位由s1中为0的位表示被填满。我们可以通过预处理枚举出所有合法的从s1到s2的转移。具体方法是用一个DFS函数模拟在第i-1列当前列的网格上放置瓷砖。网格有2行我们按位置顺序尝试放置。参数pre_state(第i-1列初始状态即s1)col(当前正在尝试放置的格子位置从0到3编号0和1是第i-1列2和3是第i列这里我们用一维数组模拟两列4个格子)current_state(记录第i列被覆盖的状态即s2的雏形)。过程如果col已经达到2意味着第i-1列的2个格子都处理完了那么检查第i-1列是否被完全覆盖即col[0]和col[1]是否都为1。如果是则这是一个合法的转移记录(s1, s2)对其中s2就是current_state。否则找到下一个未被覆盖的格子col[0]或col[1]中为0的位置。尝试放置瓷砖竖放1x2如果当前格子在第i-1列是空的可以放一块竖砖覆盖(i-1, row)和(i, row)。这需要标记第i-1列的该格子为已覆盖并设置第i列的对应格子状态(current_state的对应位)。横放1x2如果当前格子在第i-1列是空的且它的同行下一个格子第i列的对应格子也是空的在初始状态下可以放一块横砖覆盖(i-1, row)和(i, row)。这同样会影响第i列的状态。放2x2如果当前格子是空的且它和它下方、右方、右下方的三个格子都空即覆盖了第i-1列和第i列的两行可以放一块2x2砖。这会同时覆盖第i-1列的两个格子和第i列的两个格子并将current_state设为3(11)。结果预处理得到一个列表trans[s1]里面存储所有能从状态s1转移出去的合法s2。通过这样的DFS我们可以穷举出所有在保证第i-1列被铺满的前提下对第i列产生影响的放置方式。这是状压DP解决铺砖问题的标准预处理方法。3.3 初始状态与递推初始状态第0列之前没有列我们可以认为第0列已经被“完全铺满”且没有延伸到第1列。所以只有一种状态dp[0][3] 1。这里state3 (11)是一个虚拟的“已铺满”状态。也有人定义dp[0][0] 1表示第0列之后即第1列之前的状态是“全空”。两种理解都是正确的关键在于转移方程要与之匹配。采用dp[0][3]1在编码时更直观。然后从i 1到N进行递推 对于每一对预处理得到的合法转移(s1, s2)dp[i][s2] dp[i-1][s1]最终答案dp[N][3]。因为铺完第N列后不能再有砖块延伸到第N1列所以第N列自身必须是完全被覆盖的状态(11)。4. C代码实现与逐行解析理解了原理我们来看代码实现。这里给出一个清晰、模块化的实现版本。#include iostream #include vector #include cstring using namespace std; typedef long long ll; // 结果可能很大用long long vectorint trans[4]; // trans[pre_state] {next_state1, next_state2, ...} ll dp[35][4]; // dp[i][state] i从0到Nstate 0~3 // DFS预处理所有可能的状态转移 // pre_mask: 第i-1列的初始状态二进制位表示0空1占 // col: 当前处理的格子索引0-1是第i-1列2-3是第i列我们用一个4位数组模拟两列 // cur_mask: 当前第i列的状态二进制位表示 void dfs(int pre_mask, int col, int cur_mask) { if (col 2) { // 第i-1列的2个格子都处理完了 // 检查第i-1列是否被完全覆盖即pre_mask的两位是否都变成了1 // 在我们的DFS过程中pre_mask是初始状态我们通过修改col数组来模拟覆盖。 // 我们需要一个额外的参数来记录当前第i-1列的覆盖情况。这里为了清晰我们重构一下DFS参数。 // 更清晰的写法是传递一个表示当前列覆盖情况的变量。 } } // 重构后的DFS函数 void dfs(int pre_stat, int cur_col_stat, int pos) { // pre_stat: 第i-1列初始的空闲状态1表示已被占0表示空。我们需要填满所有0的位置。 // cur_col_stat: 第i列当前被覆盖的状态二进制 // pos: 当前正在处理第i-1列的格子位置0或1 if (pos 2) { // 第i-1列所有格子处理完毕 if (pre_stat 3) { // 第i-1列必须被完全覆盖状态为11 trans[pre_stat].push_back(cur_col_stat); } return; } if ((pre_stat pos) 1) { // 如果第i-1列的pos位置已经被覆盖 dfs(pre_stat, cur_col_stat, pos 1); // 跳过处理下一个位置 return; } // 情况1竖放1x2砖覆盖第i-1列pos位和第i列pos位 dfs(pre_stat | (1 pos), cur_col_stat | (1 pos), pos 1); // 情况2横放1x2砖覆盖第i-1列pos位和pos位同行 // 横放要求第i-1列的pos位和pos位都空这已经由外层判断了pos位空。 // 但横放实际上覆盖的是第i-1列的两个格子所以这里表述有误。 // 正确的横放覆盖 (i-1, pos) 和 (i, pos)不对横放应该是覆盖同一行的两个连续列。 // 重新思考在决策第i-1列时横放1x2砖会覆盖 (i-1, pos) 和 (i, pos)这不对这是竖放。 // 横放1x2砖应该覆盖 (i-1, pos) 和 (i-1, pos)这不可能。 // 关键点我们的状态定义是按列的。横放一块砖会占据当前列的一个格子和下一列的同一个行的格子。 // 所以当我们在第i-1列放置一块横放的1x2砖时它覆盖了 // 第i-1列的某个格子以及第i列的同一个行的格子。 // 因此它会影响第i列的状态。 // 所以对于第i-1列的一个空位(pos)如果我们选择横放那么 // 1. 覆盖第i-1列的pos位。 // 2. 覆盖第i列的pos位即影响cur_col_stat。 // 这...和竖放的效果在状态影响上是一样的不对竖放还会覆盖第i列的pos位吗竖放覆盖的是 (i-1,pos)和(i,pos)没错。 // 等等我发现了之前的认知错误。在标准的“铺瓷砖”状压DP中我们通常处理的是“当前决策列”的覆盖。 // 更通用的方法是我们有一个当前列的状态cur表示当前列哪些格子被之前列的砖占用了 // 我们要决策如何放置砖块来填满当前列的空位并可能占用下一列的部分格子。 // 让我们采用更标准的思路 // 状态dp[i][s]表示前i-1列已经铺满且第i列的状态为ss的二进制位表示第i列哪些格子被从i-1列伸过来的砖占用了的方案数。 // 初始dp[0][0]1表示第0列之前没有列第0列没有被占用。 // 转移从dp[i-1][s1]转移到dp[i][s2]。我们枚举所有能铺满第i-1列同时产生s2状态的放置方式。 // 我们需要一个函数来生成所有(s1, s2)对。 // 这个函数通过DFS尝试铺满第i-1列状态为s1其中1表示已被i-2列占用0表示空并记录铺完后第i列的状态s2。 } // 标准的状态转移生成函数 void init() { for (int pre 0; pre 4; pre) { // 枚举第i-1列的初始状态被i-2列占用的情况 for (int put 0; put (1 4); put) { // 暴力枚举在当前列4个格子2行*2列的所有放置情况这太暴力。 // 更好的方法是DFS。 } } // 由于篇幅和清晰度这里直接给出经典铺砖问题的状态转移关系。 // 对于2行N列的铺砖问题砖块有1x2和2x2经典的状态转移预处理如下 // 状态0(00) - 可以转移到 状态3(11) 放一块2x2 // 可以转移到 状态0(00) 吗不行因为本列必须被铺满。 // 实际上状态0表示第i-1列两个格子都空着被i-2列占用的位为0。 // 我们需要铺满它。可以放一块2x2铺满本列并让下一列状态为3。 // 也可以竖放两块1x2铺满本列并让下一列状态为0竖放1x2会占用下一列吗不会竖放只覆盖本列。 // 重新审视在我们的状态定义下s表示第i列被第i-1列占用的格子。 // 所以从dp[i-1][s1]到dp[i][s2]我们是在决策第i-1列如何铺。 // 第i-1列的初始“空位”是 ~s1 中为1的位因为s1中1表示被占0表示空。 // 鉴于这个问题的经典性和篇幅我直接给出一个经过验证的、适用于本题含2x2砖的状态转移表。 // 以下转移表表示如果第i-1列的状态是pre即第i-1列被第i-2列占用的情况那么可以通过某种铺法使得第i-1列被铺满并且第i列的状态变为next。 // 这个表可以通过DFS生成这里为了代码简洁直接列出。 // 定义状态0(00)表示两格都空未被上一列占用状态3(11)表示两格都被占用。 vectorpairint, int classic_trans { // {pre_state, next_state} {0, 3}, // 放一块2x2铺满i-1列i列状态为11被占 {0, 0}, // 放两块竖的1x2铺满i-1列i列状态为00未被占 {0, 1}, // 不可能铺满i-1列后i列状态要么是00全竖要么是112x2或两个横放 {0, 2}, // 同上 // 实际上从0状态出发合法的next只有0和3。 // 让我们列出所有可能 // pre0 (..) i-1列空 空 // 铺法1两块竖砖 i-1列满i列状态0 (空 空) // 铺法2一块2x2砖 i-1列满i列状态3 (占 占) // 铺法3两块横砖横放1x2砖需要占用i列。两块横砖覆盖i-1列的两行同时占用i列的两行。这会导致i列状态为3。 // 所以铺法3的结果和铺法2一样i列状态3但放置方式不同方案数要算作不同的。 // 因此从pre0到next3其实对应了两种不同的铺砖方式2x2一块 或 1x2横放两块。 // 所以转移表需要能体现这种多对一的关系或者在DP时用DFS枚举放置方式来计数。 // 鉴于直接列出所有转移关系比较复杂且容易出错下面给出一个用DFS预处理的完整代码。 }; } // 正确的预处理DFS函数 // pre: 第i-1列的状态二进制1表示该格子被第i-2列伸出的砖占用0表示空 // now: 当前处理到的行号0或1 // cur: 第i-1列被覆盖后的状态二进制当铺完i-1列后其每个格子应为1 // next_state: 第i列的状态二进制记录被第i-1列伸出的砖占用的格子 void dfs(int pre, int now, int cur, int next_state) { if (now 2) { // 第i-1列的所有行都处理完毕 if (cur 3) { // 第i-1列必须被完全覆盖 trans[pre].push_back(next_state); } return; } if ((cur now) 1) { // 第i-1列的当前行已经被覆盖 dfs(pre, now 1, cur, next_state); return; } // 情况A尝试竖放1x2砖块覆盖第i-1列和第i列的当前行 // 条件第i-1列的当前行是空的cur的now位为0 // 放置后第i-1列的now位被覆盖第i列的now位被占用 dfs(pre, now 1, cur | (1 now), next_state | (1 now)); // 情况B尝试横放1x2砖块覆盖第i-1列的当前行和下一行 // 条件第i-1列的当前行空且下一行now1也存在且空 if (now 1 2 ((cur (now 1)) 1) 0) { dfs(pre, now 2, cur | (1 now) | (1 (now 1)), next_state); } // 情况C尝试放置2x2砖块覆盖第i-1列的两行和第i列的两行 // 条件第i-1列的当前行和下一行都空且第i列的当前行和下一行都未被计划占用即next_state的对应位为0 // 注意放置2x2会同时覆盖第i-1列的两行并占用第i列的两行。 if (now 1 2 ((cur (now 1)) 1) 0 (next_state (3 now)) 0) { // 第i列的对应两行未被占用 dfs(pre, now 2, cur | (1 now) | (1 (now 1)), next_state | (3 now)); // 将第i列的对应两行标记为占用 } } int main() { int N; cin N; // 初始化转移表 for (int i 0; i 4; i) { trans[i].clear(); } for (int pre 0; pre 4; pre) { // pre是第i-1列的初始状态被占用情况 // 我们需要填满的是第i-1列中未被占用的格子即pre中为0的位。 // 所以第i-1列需要被覆盖的状态目标就是 ( (~pre) 3 ) // 但我们DFS的起点是第i-1列当前的空闲状态是 (~pre) 3 吗不完全是。 // 更准确地说DFS时第i-1列的“可覆盖位置”初始是 pre中为0的位。 // 我们用一个变量start_mask (~pre) 3 来表示初始时哪些格子需要被覆盖。 // 然而我们的DFS函数参数设计为 (pre, now, cur, next_state)。 // 其中cur初始为pre表示已经被占的位然后我们在DFS过程中将需要覆盖的位start_mask中的1逐步标记为1。 // 但这样不方便。我们换一种思路DFS时我们试图覆盖所有“尚未被覆盖”的格子。 // 初始时第i-1列的覆盖状态就是pre1表示已被i-2列覆盖0表示待覆盖。 // 我们的目标是将所有0变成1。 // 所以DFS的起始状态当前处理行now0当前列覆盖状态curpre下一列状态next_state0。 dfs(pre, 0, pre, 0); } // 初始化DP数组 memset(dp, 0, sizeof(dp)); dp[0][0] 1; // 前0列已经铺满且第0列没有被占用的方案数为1这是一个边界状态 // DP递推 for (int i 1; i N; i) { for (int pre_stat 0; pre_stat 4; pre_stat) { if (dp[i-1][pre_stat] 0) continue; for (int next_stat : trans[pre_stat]) { dp[i][next_stat] dp[i-1][pre_stat]; } } } // 输出结果前N列已经铺满且第N列没有被占用的方案数因为第N列之后没有列不能被占用 cout dp[N][0] endl; return 0; }关键点解析上面的dfs函数是核心中的核心。它模拟了在已知第i-1列初始被占用状态 (pre) 的情况下尝试用各种瓷砖铺满第i-1列的所有空位并记录铺砖过程中对第i列造成的占用情况 (next_state)。dp[0][0]1是初始条件表示第0列之前的世界是“铺满”的且没有砖块延伸到第1列状态0。最终dp[N][0]表示铺满前N列且没有砖块非法延伸到第N1列的所有方案。5. 调试技巧与常见问题即使理解了原理和代码自己实现时也难免踩坑。这里分享几个关键的调试点和常见问题。5.1 状态定义混淆这是最容易出错的地方。务必明确你的dp[i][s]中s的确切含义。在本文采用的经典定义中s表示第i列被第i-1列伸出的砖块占用的格子情况1表示被占0表示空。初始状态dp[0][0] 1表示第0列没有被占用。最终答案dp[N][0]要求第N列也没有被占用因为铺砖必须在第N列结束。如果你的定义不同比如s表示第i列自身的覆盖情况那么初始状态、转移方程和最终答案都会完全不同。一旦确定了一种定义就要从头到尾保持一致。5.2 DFS预处理逻辑错误dfs函数必须严谨地枚举所有铺满当前列的可能方式。常见错误包括遗漏情况比如忘记了横放砖块有两种方向虽然对于2行横放只有一种但思想上要考虑周全。条件判断错误放置2x2砖时不仅要检查当前列的两行是否空还要检查下一列的对应两行是否尚未被本次放置的其他砖块计划占用即next_state的对应位是否为0。否则会导致一个格子被重复覆盖。递归终止条件必须检查当前列是否被完全覆盖 (cur 3)。只有完全覆盖这次放置才是合法的才能记录(pre, next_state)转移对。一个有效的调试方法是手动计算小的N如123的方案数然后用你的程序跑看结果是否匹配。如果N1结果不是1或者N2结果不是3那么预处理或DP递推肯定有问题。5.3 整数溢出方案数增长非常快N30时结果是一个很大的整数。务必使用long long(typedef long long ll) 来定义DP数组和结果。在比赛中这是必须养成的习惯。5.4 初始化与边界处理dp数组要初始化为0。trans向量数组在每次计算前要清空trans[i].clear()。理解dp[0][0] 1的物理意义这是状态定义的起点。5.5 问题排查速查表问题现象可能原因检查点输出结果为0DP递推没有发生或初始状态错误1. 检查dp[0][0]是否初始化为1。2. 检查dfs预处理函数是否生成了任何转移对trans[pre]打印出来看看。3. 检查DP循环pre_stat和next_stat的索引是否正确。输出结果比预期小遗漏了某些铺砖方式重点检查dfs函数1. 是否包含了竖放1x22. 是否包含了横放1x2条件now1 2且下一行空3. 是否包含了放置2x2条件now1 2当前列两行空且下一列对应两行未被计划占用输出结果比预期大产生了重复计数或非法状态1. 检查dfs中是否对同一格子进行了多次覆盖条件判断不严。2. 检查dfs的递归终止条件是否只将cur 3的状态加入转移表确保当前列被完全铺满。3. 检查DP递推公式是否是累加()而不是赋值()。N较大时输出负数整数溢出将dp数组和结果变量改为long long类型。6. 算法扩展与思维提升解决这道题后你的状压DP能力会得到质的飞跃。你可以尝试以下扩展进一步巩固和挑战自己扩展砖块类型如果增加L形瓷砖或其他异形砖该如何修改dfs预处理函数核心思路不变只是在枚举放置方式时增加新的分支。扩展网格高度将网格从2 x N扩展到3 x N甚至M x N。此时状态数变为2^M转移的预处理需要通过更复杂的DFS或位运算技巧来实现。这是状压DP的经典问题“铺砖问题”的通用形式。求具体方案如果题目不是求方案数而是要求输出任意一种或所有具体的铺砖方案你该如何修改代码这需要在DP过程中记录路径pre状态然后进行回溯。优化空间观察状态转移方程dp[i][s2] dp[i-1][s1]你会发现dp[i]只依赖于dp[i-1]。因此可以用滚动数组将空间复杂度从O(N * 2^M)优化到O(2^M)这对于M较大的情况是必要的。这道“铺瓷砖”题就像一把钥匙帮你打开了用状态压缩思想解决复杂组合问题的大门。其核心——将每一行的覆盖情况压缩为一个数并通过预处理枚举合法的状态转移——是解决许多棋盘覆盖、摆放问题的通用范式。下次遇到类似问题不妨先想想状态能不能压缩如何定义状态状态之间如何转移