尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

状压DP解决矩阵计数问题:从蓝桥杯真题到网格填色通用解法

状压DP解决矩阵计数问题:从蓝桥杯真题到网格填色通用解法 1. 问题引入从棋盘到矩阵的约束计数如果你参加过蓝桥杯国赛或者刷过它的历年真题一定会对那种“看起来描述简单但细想之下却无处下手”的题目印象深刻。2019年国赛的这道“矩阵计数”就是典型代表。题目本身没有复杂的背景故事就是给定一个N行M列的矩阵每个格子可以填“X”或“O”但要求矩阵中不存在三个连续的“X”在横向或纵向上相邻。问满足条件的矩阵有多少种。初看之下这像是一个简单的排列组合问题。N和M的范围是多少呢题目里是1 N, M 5。这个范围小得让人有点意外似乎暴力枚举所有2^(N*M)种情况也能接受我们来算一下当NM5时总状态数是2^25大约是3355万。对于现代计算机枚举并检查这三千多万种状态在时间限制内通常是1秒或2秒是可行的甚至可以说是“暴力可解”的边界。很多选手在考场上可能真的会尝试写一个DFS深度优先搜索去枚举所有格子然后检查每个状态是否合法。但这里有一个陷阱也是算法竞赛的常见套路题目给出的参数范围往往具有误导性。N, M 5确实暗示了状态空间不大但真正的考点在于如何用一种更高效、更“优雅”的算法来解决这个问题并为更大规模的问题比如N6, M100提供思路。直接暴力枚举2^25种情况虽然对于5x5可能勉强能过但代码写起来冗长且毫无扩展性完全失去了竞赛考察“算法思维”的意义。所以这道题的核心价值在于引导我们思考当问题具有“相邻约束”且规模可以状态压缩时如何用动态规划DP来高效计数这正是“状压DP”状态压缩动态规划的经典应用场景。我们不是枚举每个格子的具体内容而是按行或按列推进用一串二进制数来表示一行的状态并通过状态转移来累计合法方案数。理解了这个思路你就掌握了解决一大类“网格填色问题”的钥匙。2. 核心难点解析状态的定义与合法性判断要使用动态规划第一步也是最关键的一步就是定义“状态”。在这道题里约束是“不能有三个连续的X”。这个约束是作用于整个矩阵的但DP需要我们找到一个可以递推的维度。最自然的想法就是按行递推。我们一行一行地填充矩阵在填充第i行时只需要关心它本身是否合法以及它和上一行第i-1行的组合是否会与更早的行比如第i-2行共同构成纵向的连续三个X。由此我们可以定义DP状态dp[i][state]表示填充完前i行并且第i行的状态为state时满足条件的矩阵总数。这里的state是一个二进制数它的位数等于列数M。如果M5那么state就是一个5位的二进制数每一位代表该列在当前行是“X”(1)还是“O”(0)。例如state (10101)_2表示第1、3、5列是X第2、4列是O。定义了状态接下来就要解决两个核心问题单行状态的合法性一个state本身作为一行不能包含连续三个或以上的1X。这是题目中的横向约束。行间转移的合法性当我们从上一行的状态prev_state转移到当前行的状态curr_state时需要确保任意一列上不能与更早的行构成连续三个1。这包含了纵向约束以及当前两行可能构成的横向约束的补充检查。第二个问题是难点。我们不能只检查prev_state和curr_state因为纵向连续三个X可能涉及到第i-2行。但是我们的状态dp[i][curr_state]只记录了第i行的状态丢失了第i-1行的信息无法判断与第i-2行的关系。这就引出了状压DP中一个非常重要的技巧升维。我们需要在状态中记录连续两行的信息。重新定义状态dp[i][s1][s2]表示填充完前i行并且第i-1行的状态是s1第i行的状态是s2时满足条件的矩阵总数。这样当我们要计算第i1行状态为s3时我们手头有第i-1行(s1)和第i行(s2)的完整信息。我们就可以严格检查s3自身是否合法无横向连续三个1。s2和s3的任意列是否与s1的对应列共同构成了纵向的连续三个1。即对于任意列j不能同时满足(s1j 1) 1 (s2j 1) 1 (s3j 1) 1。这个定义完美地捕捉了所有约束。初始状态第0行可以视为一个全0的虚拟行。我们从第1行开始实际填充。注意这里有一个非常容易出错的细节。我们还需要检查s2和s3这两行之间是否在横向上构成了连续三个1答案是不需要单独检查。因为我们的约束是“矩阵中不存在三个连续的X”这包括了同一行内和同一列内。对于相邻两行如果它们在同一水平位置上有三个连续的X那这属于同一行的横向约束应该由s2和s3各自的单行合法性检查来保证。行间转移检查只关心纵向同一列的连续性。很多初学者会在这里混淆试图去检查两行组合的横向模式这是不必要的也增加了复杂度。3. 算法实现精讲从状态枚举到递推公式理解了状态定义整个算法的骨架就清晰了。我们以N5, M5为例详细走一遍流程。3.1 预处理生成所有合法单行状态首先列数M5所有可能的状态是0到(15)-1即0到31。我们需要从中筛选出自身合法的状态即不包含连续三个或以上1的状态。如何判断一个直观的方法是遍历状态的每一位。更优雅的方式是利用位运算。对于一个状态s我们可以检查(s (s1) (s2))是否不为0。这个表达式的意思是将s右移1位后与自身按位与得到连续两个1的位置再与右移2位后的s按位与如果结果非零说明存在至少一个位置它本身、它的右边第一位、右边第二位都是1即存在连续三个1。def check_row(state, m): # 检查一个状态代表一行是否有连续三个1 # 使用位运算 state (state1) (state2) # 如果结果不为0说明存在连续三个1状态非法 return (state (state 1) (state 2)) 0遍历0到31我们可以得到一个合法单行状态的列表valid_states。对于M5合法的状态有 0(00000), 1(00001), 2(00010), 3(00011), 4(00100), 5(00101), 6(00110), 7(00111)是非法连续三个18(01000), 9(01001), 10(01010), 11(01011), 12(01100), 13(01101), 14(01110)非法16(10000), 17(10001), 18(10010), 19(10011), 20(10100), 21(10101), 22(10110), 24(11000), 25(11001), 26(11010), 28(11100)非法... 最终计算下来总共是13种合法状态。读者可以自己验证一下这个预处理大大减少了需要处理的状态数。总状态数从2^532降到了13对于DP的二维状态s1, s2来说空间从32*321024降到了13*13169优化效果显著。3.2 状态转移方程与初始化我们的状态是dp[i][s1][s2]表示前i行且第i-1行是s1第i行是s2的方案数。 递推关系是dp[i][s2][s3] dp[i-1][s1][s2]这个加和成立的条件是s2和s3都是合法的单行状态来自valid_states。对于s1, s2, s3满足纵向约束(s1 s2 s3) 0。即任意一列上这三行不能同时为1。初始化怎么办我们需要一个“第0行”通常设为一个全0的状态假设为状态0。并且我们认为状态0是合法的全O的一行当然合法。那么dp[1][s1][s2]表示填完第1行后第0行是s1第1行是s2。但第0行是我们虚拟的固定为0。所以初始化时我们枚举所有合法的第1行状态s2令dp[1][0][s2] 1。这里s1被固定为0。在实际编程中我们通常使用滚动数组来优化空间因为dp[i]只依赖于dp[i-1]。我们定义两个二维数组cur和pre分别代表当前行和前一行的DP值。初始化对应i1即实际第一行# pre[s1][s2] 初始时s1只能是虚拟的第0行状态为0 for s2 in valid_states: pre[0][s2] 1这里pre的索引s1实际上只有0是有效的但我们为了统一后续转移代码仍然保留二维结构。状态转移从第2行到第N行for i in range(2, N1): # i代表当前要填的是第i行实际行号 cur [[0]*state_num for _ in range(state_num)] # 清空当前层 for s1 in valid_states: # 第i-2行状态 for s2 in valid_states: # 第i-1行状态 if pre[s1][s2] 0: # 无效状态对跳过 continue for s3 in valid_states: # 第i行状态 # 检查纵向约束s1, s2, s3不能在同一列都是1 if (s1 s2 s3) ! 0: continue # 状态转移 cur[s2][s3] pre[s1][s2] # 滚动数组当前层变成下一轮的前一层 pre, cur cur, pre3.3 最终答案的统计经过上述循环当我们处理完第N行后pre数组中存储的就是dp[N][s1][s2]的值其中s1是第N-1行的状态s2是第N行的状态。最终满足条件的矩阵总数就是所有合法的s1和s2对应的方案数之和。即ans sum(pre[s1][s2] for s1 in valid_states for s2 in valid_states)这里s1和s2需要满足转移时的合法性吗不需要了。因为在递推过程中只有合法的状态对(s1, s2)才会被生成和传递。pre中非零的项本身就代表了合法的结尾状态对。4. 代码实现与关键细节剖析将上述思路转化为代码有几个细节需要特别注意它们直接关系到程序的正确性和效率。4.1 状态索引的优化我们的合法状态valid_states是一个列表比如[0, 1, 2, 4, 5, 8, 9, 10, 16, 17, 18, 20, 21]M5时。在DP数组中我们如果用状态值本身作为索引数组大小就需要开到(1M)会包含很多无效状态造成空间浪费。更高效的做法是建立状态值到连续索引的映射。state_list [] # 存储合法的状态值 state_index {} # 状态值 - 列表索引 的映射 idx 0 for s in range(1M): if check_row(s, M): state_list.append(s) state_index[s] idx idx 1 state_num len(state_list) # 合法状态数量这样我们的DP数组dp或pre/cur就可以定义为[state_num][state_num]的大小非常紧凑。在转移时我们遍历的是state_list中的索引通过state_list[i]获取实际的状态值进行位运算检查。4.2 位运算检查的陷阱纵向约束检查(s1 s2 s3) 0是核心。这里必须注意运算符优先级。在Python或C中的优先级低于所以if (s1 s2 s3) 0:是正确的写法。如果写成if s1 s2 s3 0:会被解释为if s1 s2 (s3 0):导致逻辑错误。保险起见总是加上括号。4.3 完整代码示例Python结合以上所有点一个清晰、高效的Python实现如下def solve(N, M): # 1. 生成所有合法单行状态 def check_row(state): # 检查是否有连续三个1 return (state (state 1) (state 2)) 0 state_list [] for s in range(1 M): if check_row(s): state_list.append(s) state_num len(state_list) # 建立状态值到索引的映射 idx_of {state: i for i, state in enumerate(state_list)} # 2. 初始化DP数组 (使用滚动数组思想pre代表前一行结果) # pre[a][b]: 前一行的状态是state_list[a]当前行的状态是state_list[b]的方案数 pre [[0] * state_num for _ in range(state_num)] # 初始化第一行虚拟的第0行状态为0全0枚举所有合法的第1行状态 zero_idx idx_of[0] # 状态0的索引假设0在合法状态列表中全0一定合法 for s2_idx, s2 in enumerate(state_list): pre[zero_idx][s2_idx] 1 # 3. DP递推 for _ in range(1, N): # 已经处理了第1行还需要处理剩下的N-1行 cur [[0] * state_num for _ in range(state_num)] for s1_idx, s1 in enumerate(state_list): for s2_idx, s2 in enumerate(state_list): if pre[s1_idx][s2_idx] 0: continue # 尝试填充下一行 s3 for s3_idx, s3 in enumerate(state_list): # 关键检查纵向不能有三个连续的1 if (s1 s2 s3) ! 0: continue # 状态转移 cur[s2_idx][s3_idx] pre[s1_idx][s2_idx] pre cur # 滚动数组 # 4. 统计答案 ans 0 for i in range(state_num): for j in range(state_num): ans pre[i][j] return ans if __name__ __main__: N, M 5, 5 print(solve(N, M)) # 输出满足条件的矩阵总数对于N5, M5运行这段代码得到的答案是****。你可以尝试其他组合比如N2, M3结果应该是**所有2^664种情况减去包含连续三个X的情况。实操心得在竞赛中对于这种小规模输入有时为了节省思考时间也可以写一个DFS暴力程序来验证DP结果的正确性作为对拍工具。但对于N,M5DFS枚举2^25种情况可能会比较慢可以用于验证更小的案例如N3,M3。这种“用暴力程序验证高效算法”的思路在调试时非常有用。5. 性能分析与算法扩展我们分析一下这个算法的时间复杂度。设合法状态数为S。对于M5S13M6时S...可以计算大约24种。DP需要迭代N层每层需要遍历三重循环S^3。所以时间复杂度是O(N * S^3)。对于N,M5S很小这个复杂度几乎可以忽略不计。即使N很大比如100M5计算量也在百万级别完全可行。空间复杂度由于使用了滚动数组只需要两个S x S的矩阵空间为O(S^2)。那么这个算法能解决M更大的情况吗比如M10。这时合法状态数S会急剧增加。M10时所有状态2^101024合法状态数大约在**量级具体需要计算。S^3将达到10^9量级这显然是不可接受的。这就需要进一步的优化。一种常见的优化是预处理转移关系。在第三层循环for s3 in states中我们每次都要检查(s1 s2 s3)0。我们可以预先计算好对于给定的s1和s2哪些s3是合法的。这样内层循环就从遍历所有S个状态变成了遍历一个预先计算好的合法后继列表。时间复杂度降为O(N * S^2 * avg_trans)其中avg_trans是平均每个(s1,s2)对的合法后继数通常会远小于S。# 预处理转移关系 trans [[[] for _ in range(state_num)] for _ in range(state_num)] for i, s1 in enumerate(state_list): for j, s2 in enumerate(state_list): for k, s3 in enumerate(state_list): if (s1 s2 s3) 0: trans[i][j].append(k) # 在DP循环中 for s1_idx in range(state_num): for s2_idx in range(state_num): if pre[s1_idx][s2_idx] 0: continue for s3_idx in trans[s1_idx][s2_idx]: # 只遍历合法的后继 cur[s2_idx][s3_idx] pre[s1_idx][s2_idx]这个优化在M较大时效果显著。更进一步如果N极大如10^9而M很小如10我们甚至可以使用矩阵快速幂来加速这个线性递推过程将复杂度降至O(S^3 * logN)。这是因为状态转移方程dp[i][s2][s3] sum(dp[i-1][s1][s2])本质上是一个线性递推可以表示为矩阵乘法。6. 举一反三状压DP的解题模式通过这道“矩阵计数”我们可以总结出解决一类网格填色/放置问题的状压DP通用思路识别约束与维度问题是否涉及网格棋盘上的放置且约束主要与相邻格子的状态有关如果是按行或按列递推是自然的。定义状态用二进制数表示一行的放置情况1表示放/某种颜色0表示不放/另一种颜色。关键在于状态需要携带多少信息才能满足转移时的约束检查本题中约束涉及连续三行所以状态需要记录两行的信息(s1, s2)。预处理合法状态根据单行内的约束如不能连续放置过滤掉大量非法状态缩小状态空间。建立转移方程明确从(s1, s2)到(s2, s3)转移的条件。这个条件通常包括s3自身的合法性。s1, s2, s3之间满足行间约束如纵向不能连续。初始化与答案统计确定边界情况如第0行并正确统计最终所有合法结尾状态对应的方案数。考虑优化状态数多时预处理转移关系递推步数极多时考虑矩阵快速幂。掌握了这个模式你就能应对诸如“棋盘放车不能互相攻击”、“铺瓷砖骨牌覆盖”、“种植方案相邻不能同种”等经典状压DP问题。核心永远是如何用最精简的二进制信息概括历史决策对未来的影响。回过头看这道题它之所以经典就在于它用一个小小的5x5矩阵清晰地展示了状压DP中“状态定义升维”这一关键技巧。在考场上如果你能绕过暴力枚举的直觉陷阱迅速构建出这个两行状态模型你就已经胜过了大多数还在纠结DFS剪枝的选手。这不仅仅是解一道题更是对算法思维的一次锤炼。
返回列表