
1. 从一道国赛真题说起当“搭积木”遇上“记忆化搜索”如果你参加过蓝桥杯或者刷过它的国赛真题那你一定对那种“题目描述看似简单但想拿满分却需要绞尽脑汁”的感觉不陌生。今天要聊的这道“搭积木”就是第九届蓝桥杯国赛中的一道经典题目。它没有复杂的图论也没有高深的动态规划状态设计初看之下甚至有点像一道纯粹的排列组合或者搜索题。但恰恰是这种“朴素”的题目最能考验选手对基础算法的深刻理解和优化能力。很多人一看到“搭积木”第一反应可能是回溯或者DFS暴力枚举所有搭法然后一提交——时间超限。这道题的核心考点就在于如何用“记忆化搜索”这把利器将看似不可能的指数级复杂度优化到可接受的范围。这不仅是解题的关键更是算法思维从“暴力”走向“高效”的一次典型跃迁。接下来我们就一起拆解这道题看看记忆化搜索是如何化腐朽为神奇的。2. 题目本质剖析这不是儿童游戏而是状态压缩首先我们必须跳出“搭积木”这个具象的画面。题目给出的积木通常有固定的形状比如1x1, 1x2, 2x1等我们需要将它们无重叠地放入一个给定的平面区域比如一个宽度为W高度不限的容器。求的是有多少种不同的摆放方式。这本质上是一个铺放问题的变种类似于铺瓷砖。但蓝桥杯的题目往往会增加一些限制条件例如积木的种类和数量可能有限制。积木必须连续放置不能悬空即下方必须有支撑。目标是摆出特定高度或者摆满特定区域。为什么暴力搜索会“爆炸”假设区域宽度W10我们有若干种积木。最直接的搜索思路是从最底层开始从左到右尝试放置每一块积木。每放置一块就递归地尝试放置下一块直到铺满该层然后进入下一层。这个搜索树的分支数量是极其庞大的。因为每一步都有多种积木可选放置的位置也很多。对于稍大的W比如10搜索空间轻松达到数亿甚至更多完全不可行。状态定义的突破口轮廓线解决这类铺放问题的经典技巧是使用轮廓线DP或者基于轮廓线的记忆化搜索。我们并不关心已经铺好的区域内部具体是什么样子只关心当前铺设的“前沿轮廓”是什么样。对于搭积木问题这个“轮廓”通常指的是当前各列已经达到的高度或者从当前层开始向上的“凸起”形状。但更常见且易于理解的状态定义是用一维数组height[W]记录每一列当前已经堆积的高度。当我们尝试放置一块新积木时它必须放置在当前“最高水平线”上并且放置后新积木覆盖的那些列的高度会同时增加。然而直接把这个高度数组作为状态进行记忆化仍然低效因为状态空间依然很大。我们需要进一步优化。状态压缩将高度数组编码成一个整数这是记忆化搜索能否成功的关键。一个巧妙的方法是记录每一列相对于最低列的高度差。因为积木必须连续放置底部是平的所以整个轮廓的“底部”是齐平的。我们可以找到当前所有列中的最小高度minH然后记录每一列的高度height[i] - minH。由于高度差不会无限增长受积木形状和放置规则限制这个差值可以被限制在一个较小的范围内例如0到3。这样我们就可以用一个W位的进制数来唯一表示这个轮廓状态。每一位的进制数就是高度差的范围1。例如如果宽度W3高度差范围是0~23种可能那么状态就可以用一个3位的3进制数表示。(0,1,0)这个状态表示三列的高度差分别为0、1、0假设最小高度为0。通过这种编码一个巨大的、看似无法记录的数组状态就被压缩成了一个整数state。这个整数就是我们在记忆化搜索中用来检索的键Key。3. 记忆化搜索的实现框架与核心逻辑理解了状态压缩我们就可以搭建记忆化搜索的框架了。记忆化搜索的本质是递归缓存它避免了重复计算相同的子问题。我们定义递归函数dfs(state)含义当前轮廓状态为state时继续摆放直至完成最终目标例如摆到某一高度或摆满所有位置的总方案数。参数state即压缩后的轮廓编码。返回值从state状态出发能完成目标的方案数。递归流程如下边界条件如果当前状态state已经满足题目要求的完成条件例如所有列的高度都达到了目标值H则返回1表示找到一种完成方案。如果根据规则判断不可能完成则返回0。查表如果当前状态state已经在我们建立的缓存字典如memo[state]中直接返回缓存的结果。这是记忆化搜索提升效率的核心。寻找放置位置我们需要找到当前轮廓的一个“放置点”。通常我们会选择高度最低的列因为积木需要从底部往上搭。假设我们找到了第col列高度最低。尝试放置积木遍历所有类型的积木。对于每一块积木检查是否能以第col列为起点进行放置。检查条件包括积木的宽度是否超出边界col width W。积木所要覆盖的所有列其当前高度是否都等于height[col]即底部是否平整能否严丝合缝地放上去。题目可能还有其他限制如特定积木的数量限制。状态转移与递归如果积木block可以放置则放置它。放置后被覆盖的那些列的高度会增加增加值为积木的高度通常是1。然后我们根据新的高度数组重新计算最小高度生成新的压缩状态new_state。 那么从当前状态state出发的方案数就包括了放置这块积木后从new_state状态出发的所有方案数。即total_count dfs(new_state)如果有多种积木可放则是一个累加的过程。缓存并返回将计算出的total_count存入memo[state]然后返回total_count。初始化初始状态init_state是所有列高度为0的压缩编码。我们最终要求的就是dfs(init_state)。关键点提示在步骤4中为什么总是选择高度最低的列放置这是一种“填充策略”它保证了搜索的无后效性和状态的唯一性。如果我们不固定放置顺序同一个物理状态可能会通过不同的搜索路径到达导致状态定义混乱记忆化失效。固定从最低点开始放置相当于为搜索规定了顺序确保了状态与物理布局的一一对应。4. 从理论到实践一个简化版的代码拆解为了让思路更清晰我们考虑一个简化模型区域宽度W4只有一种1x2的积木横着放目标是用它铺满整个区域高度不限但必须铺满理解为无限高但必须紧密堆积。这实际上就是求用1x2的砖块铺满4xn地面的方案数是一个经典的递推问题但我们可以用记忆化搜索来理解。在这个模型里状态只需要关注当前行的“空缺”情况。我们可以用二进制位表示一列是否有积木“凸起”即上一层的积木伸到了这一层。但为了衔接之前的思路我们依然用高度差。由于只有一种高度为1的积木高度差只能是0或1。我们规定状态编码表示当前行各列相对于上一行完成面的高度差0表示平整1表示有个“坑”需要本行用积木的凸起部分来填充。下面用伪代码展示核心逻辑W 4 blocks [(2, 1)] # 积木列表每个元素为(宽度, 高度) memo {} def encode(heights): 将高度数组压缩为整数状态。这里用一个简单示例直接拼接二进制位。 state 0 for h in heights: state (state 2) | h # 假设高度差h用2个比特表示0-3 return state def dfs(heights): # 1. 判断是否完成所有列高度一致即表面平整 if len(set(heights)) 1: return 1 state encode(heights) if state in memo: return memo[state] total 0 # 2. 找到当前最小高度的列 min_h min(heights) start_col heights.index(min_h) # 3. 尝试放置每一种积木 for width, height in blocks: # 检查是否越界 if start_col width W: continue # 检查底部是否平整要放置的这几列高度都必须等于min_h if any(heights[i] ! min_h for i in range(start_col, start_col width)): continue # 4. 生成新状态 new_heights heights.copy() for i in range(start_col, start_col width): new_heights[i] height # 放置积木高度增加 # 5. 递归计算 total dfs(new_heights) memo[state] total return total # 初始化所有列高度为0 init_heights [0] * W result dfs(init_heights) print(result)这段伪代码省略了高度重归一化减去最小值和更高效的状态编码等细节但它清晰地展示了记忆化搜索的骨架定义状态、递归尝试、缓存结果。在实际的蓝桥杯题目中积木可能不止一种可能有1x1, 1x2, 2x1, L形等。状态编码也会更复杂。但万变不离其宗核心思想就是将无限的、具体的摆放形态抽象为有限的、可编码的轮廓状态并通过递归缓存来避免重复搜索。5. 记忆化搜索的优化技巧与常见“坑点”直接套用上述框架可能仍然无法通过所有测试点尤其是当W较大或积木形状复杂时。这就需要一些优化技巧。技巧一状态编码的极致压缩如前所述用进制数编码是关键。我们需要确定高度差的最大值maxDiff。这通常由积木的最大高度决定。例如所有积木高度都是1那么任何时刻各列的高度差不会超过1因为我们必须从最低点开始放。这样每个位置只需要1比特0或1来表示整个状态可以用一个W位的二进制数表示非常紧凑。如果高度差范围是0-2就需要2比特以此类推。使用位运算左移、或运算、与运算来组装和解析状态速度极快。技巧二预处理与剪枝积木预处理将积木的所有旋转形态都生成出来并统一存储。例如一个2x1的竖条旋转后就是1x2的横条。可行性剪枝在递归开始时可以快速判断当前状态是否“有解”。例如检查剩余的空格总数是否是积木面积的整数倍。或者检查是否有某一列的高度已经远远落后于其他列导致永远无法被填平孤岛效应。这类剪枝能提前终止大量无效分支。对称性剪枝如果区域是左右对称的那么状态(h0, h1, h2, h3)和状态(h3, h2, h1, h0)的方案数是一样的。我们可以强制规定状态编码时总是将高度数组按某种规范形式存储例如字典序最小从而将对称状态归一化进一步减少状态数。技巧三递归顺序与堆栈深度记忆化搜索是深度优先搜索DFS如果状态空间很深可能会导致递归栈溢出。Python等语言对递归深度有限制通常1000层。对于这类问题W10左右时递归深度一般不会超过100问题不大。但如果W很大就需要考虑使用迭代式的动态规划或者手动维护栈来模拟递归。常见“坑点”与调试心得状态编码错误这是最容易出错的地方。一定要确保编码和解码函数是互逆的并且能唯一代表一个物理状态。一个简单的测试方法是随机生成一些高度数组编码后再解码看是否一致。同时检查在放置积木后是否正确地进行了“减去最小值”的归一化操作。归一化是必须的因为物理状态(1,1,2)和(0,0,1)在轮廓上是等价的必须映射到同一个编码。放置规则遗漏题目中“必须连续放置下方有支撑”这一条在检查时务必严格。即放置积木时它所要覆盖的所有格子其当前高度必须完全相同。不能有一部分悬空。整数溢出方案数可能非常大远超32位整数范围。在C/C中要使用long long在Python中则无需担心。但这也是一个考点需要注意。记忆化缓存键的选择缓存键state必须包含足够的信息。有时除了轮廓高度还需要额外的信息比如当前已经使用了某种积木的数量如果积木有数量限制。这时就需要将state和used_count组合成一个新的元组作为键。性能瓶颈如果还是超时不要只怀疑算法。用sys.setrecursionlimit设置更大的递归深度使用functools.lru_cache装饰器Python可能比手动字典更快输入数据使用sys.stdin.read()一次性读取。这些细节在竞赛中至关重要。6. 为何记忆化搜索是此类问题的“标准解法”我们最后来探讨一下为什么对于“搭积木”这类问题记忆化搜索或者说轮廓线DP会成为近乎标准的解法。首先它符合这类问题的无后效性特征。未来的摆放方案只依赖于当前的轮廓形状而与如何达到这个形状的历史路径无关。这满足了动态规划DP的基本条件。记忆化搜索就是一种自顶向下的DP实现方式。其次相比于直接写出状态转移方程的递推DP记忆化搜索的思维模式更自然。我们只需要思考“在当前这个局面下我能做什么操作”然后递归下去。状态转移方程被隐含在递归调用中。这对于状态定义复杂如轮廓线的问题尤其友好降低了思维难度。再者记忆化搜索能自动探索有效的状态空间。我们不需要事先知道到底有多少个可能的状态也不需要用循环去遍历所有状态有些状态可能根本不可达。递归过程会按需生成和计算状态对于状态空间稀疏的问题效率更高。最后从蓝桥杯的考察意图来看这道题完美地区分了“只会暴力搜索”和“掌握了状态压缩与优化”的选手。它不要求你知道某个特定的数学公式而是要求你具备将实际问题抽象为状态模型并运用算法进行优化的能力。这正是算法竞赛的核心价值所在。所以下次再遇到“摆放”、“覆盖”、“拼图”类的问题当数据范围暗示暴力搜索不行时不妨想想我能不能定义出一个能够描述当前“局面”的“状态”这个状态能不能被压缩成一个数字如果能那么记忆化搜索很可能就是打开通关大门的钥匙。这道“搭积木”题就是一个绝佳的训练案例它教会我们的远不止一种算法更是一种解决问题的思维范式。