动态规划实战:从状态机DP到滚动数组优化,以画廊问题为例
2026/8/28 10:08:35 网站建设 项目流程

1. 项目概述:从一道“画廊”题看动态规划的实战拆解

最近在刷题社区里,看到不少朋友在讨论一道名为“画廊”的题目,标签是动态规划。这让我想起了自己初学DP时,面对各种状态定义和转移方程的手足无措。动态规划,这个听起来高大上的算法思想,其实核心就是“聪明的穷举”加上“记忆化”,避免重复计算。而“画廊”这道题,恰好是一个绝佳的DP入门实战案例,它不像背包问题那样模板化,也不像最长公共子序列那样经典,它需要你根据实际问题,自己定义状态,设计转移,非常锻炼思维。今天,我就结合这道题,把我自己的解题笔记和踩过的坑梳理一遍,希望能帮你打通DP的任督二脉。无论你是正在备战面试,还是单纯想提升算法能力,这篇笔记都会从最朴素的思路开始,一步步带你走到最优解,并分享那些只有自己踩过坑才知道的调试技巧和优化心得。

2. 问题解析与状态定义:画廊里到底在画什么?

2.1 问题场景还原与抽象

首先,我们得把题目描述清楚。一个典型的“画廊”问题可能是这样的:有一条长长的走廊(画廊),走廊两侧的墙上各有若干个画框(或者需要放置艺术品的位置)。每个画框可以选择挂一幅画,或者不挂。但是,画廊有审美要求:不能有连续三个(或者K个)画框是空的(或者连续挂画,具体看题目约束)。我们的目标是,在满足这个审美约束下,计算一共有多少种不同的布置方案。

这只是一个示例场景,实际题目可能会有变体,比如两侧的画框数量可能不同,约束条件可能更复杂(例如,左右两侧的空白还有关联)。但万变不离其宗,核心是:我们有一系列的位置(画框),每个位置有几种状态(挂画/不挂画),并且状态之间存在某种限制(不能连续出现某个模式),求满足限制的所有状态序列的数量

这类问题天然就是动态规划的菜。为什么?因为当我们考虑第i个位置的方案数时,它只依赖于前面有限个位置的状态(比如前两个位置是否空置),而不是依赖于整个序列。这种“无后效性”和“最优子结构”正是DP发挥威力的地方。

2.2 状态定义的“第一性原理”

DP最难也最关键的一步就是定义状态。定义不好,要么方程复杂无比,要么根本推不出来。我的经验是,从问题的约束条件入手。

以“不能有连续三个画框为空”为例。约束关注的是“连续的空画框”数量。那么,要判断在第i个位置做决策时是否合法,我必须知道以第i-1个位置结尾时,已经连续空了几个画框

因此,一个最直接的状态定义呼之欲出:dp[i][j]:表示考虑前i个画框,并且以第i个画框结尾时,末尾连续空画框的数量为j的方案总数。这里j的取值范围是0, 1, 2。因为如果连续空了3个(j=3)就已经非法了,我们不需要记录这个状态。

但是,等等!第i个画框本身有两种选择:挂画(记为状态F)或者空着(记为状态E)。

  • 如果第i个画框挂画(F),那么无论前面是什么,连续空画框的计数都会被重置为0。因为挂画打断了“空”的连续性。
  • 如果第i个画框空着(E),那么连续空画框的计数就是前一个状态连续空的数量 + 1,当然,不能超过2(本题约束)。

所以,我们的状态dp[i][j]实际上隐含了第i个位置的状态。更清晰的定义是:dp[i][j]:表示考虑前i个画框,且第i个画框的状态导致了末尾连续空画框数为j的方案数。 但这样还是有点绕。一个更常见、更清晰的定义方式是使用状态机DP

状态机定义: 我们可以定义三个状态,分别表示以当前位置结尾时,末尾连续空画框的数量。

  • 状态0 (dp[i][0]):第i个位置挂画。此时连续空画框数为0。
  • 状态1 (dp[i][1]):第i个位置空着,且这是连续的第1个空画框(即第i-1个位置挂画了)。
  • 状态2 (dp[i][2]):第i个位置空着,且这是连续的第2个空画框(即第i-1个位置也是空的)。

为什么没有状态3?因为题目不允许连续3个空,所以当连续空画框数达到2时,下一个位置绝对不能是空,必须挂画。因此,我们只需要记录到2。

注意:这里的状态定义是DP的精髓。很多新手会试图用dp[i]直接表示前i个位置的方案总数,但这样无法体现“连续空”这个约束,信息量不够,无法写出转移方程。必须把约束条件“编码”到状态里。

2.3 初始状态的思考

确定了状态,就要考虑起点。对于第一个画框(i=1):

  • 它可以挂画:对应状态0,dp[1][0] = 1
  • 它可以空着:对应状态1(因为前面没有画框,空着就是第一个空),dp[1][1] = 1
  • 它不可能处于状态2(连续两个空),所以dp[1][2] = 0

这样,我们的DP数组就可以从i=2开始递推了。

3. 状态转移方程推导与优化

3.1 画出状态转移图

根据定义,我们可以像设计一个有限状态自动机一样,画出状态之间的转移关系。这能极大帮助理清思路。

  1. 从状态0 (当前挂画) 出发

    • 下一个位置可以挂画 -> 进入新的状态0。(挂画后,连续空重置为0)
    • 下一个位置可以空着 -> 进入状态1。(空了一个)
  2. 从状态1 (当前空,且是第一个空) 出发

    • 下一个位置可以挂画 -> 进入状态0。(挂画打断连续性)
    • 下一个位置可以空着 -> 进入状态2。(空了第二个)
  3. 从状态2 (当前空,且是连续第二个空) 出发

    • 下一个位置必须挂画 -> 进入状态0。(否则就连续三个空了,非法)
    • 下一个位置不能空着。

3.2 写出转移方程

用数学语言描述上面的转移图,假设我们要求前i个画框的方案,现在已知i-1的所有状态:

  • dp[i][0](第i位挂画):第i位挂画时,前一位(i-1)可以是任何状态,因为挂画总是合法的。

    • dp[i-1][0]来:前一位挂画,这一位也挂画。
    • dp[i-1][1]来:前一位是第一个空,这一位挂画。
    • dp[i-1][2]来:前一位是第二个空,这一位必须挂画。
    • 所以:dp[i][0] = dp[i-1][0] + dp[i-1][1] + dp[i-1][2]
  • dp[i][1](第i位空,且是第一个空):这意味着第i位空着,但第i-1位必须挂画(否则就是连续空了)。

    • 只能从dp[i-1][0]来:前一位挂画,这一位选择空。
    • 所以:dp[i][1] = dp[i-1][0]
  • dp[i][2](第i位空,且是连续第二个空):这意味着第i位空着,且第i-1位也是空的(即处于状态1)。

    • 只能从dp[i-1][1]来:前一位是第一个空,这一位继续空。
    • 所以:dp[i][2] = dp[i-1][1]

3.3 初始化与最终答案

根据3.1节的初始状态:dp[1][0] = 1,dp[1][1] = 1,dp[1][2] = 0

假设画廊有N个画框。那么,考虑完所有N个画框后,最终合法的方案,是第N个画框处于任何合法状态的方案总和。因为我们的状态定义已经保证了过程的合法性。 所以最终答案:ans = dp[N][0] + dp[N][1] + dp[N][2]

实操心得:在推导方程时,一定要反复问自己:“要达到目标状态,前一个状态必须满足什么条件?” 比如dp[i][1]要求“第i位空,且是第一个空”,那么第i-1位必须不空,也就是必须挂画,对应状态0。这样思考能确保转移的正确性。

3.4 空间优化(滚动数组)

观察转移方程:dp[i][0]依赖于dp[i-1][0], dp[i-1][1], dp[i-1][2]dp[i][1]依赖于dp[i-1][0]dp[i][2]依赖于dp[i-1][1]

我们发现,计算第i层状态,只需要第i-1层状态。因此,我们完全不需要一个N x 3的二维数组,只需要两个长度为3的数组,或者用几个变量来回倒腾就行。这是DP常见的空间优化技巧——滚动数组。

具体实现时,我们可以只用三个变量来表示前一层的三个状态:pre0, pre1, pre2。 然后计算当前层的cur0, cur1, cur2

cur0 = pre0 + pre1 + pre2 cur1 = pre0 cur2 = pre1

计算完后,把(cur0, cur1, cur2)赋值给(pre0, pre1, pre2),用于下一轮计算。 初始化时,pre0, pre1, pre2 = 1, 1, 0(对应i=1的情况)。

这样,空间复杂度就从O(N)降到了O(1)。对于N很大的情况(比如上亿),这个优化至关重要。

4. 代码实现与调试实录

4.1 基础版本代码(Python)

我们先实现一个最直观的二维DP版本,便于理解。

def gallery_arrangements(N): """ 计算长度为N的画廊,禁止连续三个画框为空,有多少种布置方案。 """ if N <= 0: return 0 # dp[i][j], i从1开始计数,这里我们开N+1行方便对齐 dp = [[0] * 3 for _ in range(N + 1)] # 初始化 i=1 dp[1][0] = 1 # 挂画 dp[1][1] = 1 # 空(第一个空) dp[1][2] = 0 # 不可能有两个空 for i in range(2, N + 1): # 状态转移 dp[i][0] = dp[i-1][0] + dp[i-1][1] + dp[i-1][2] # 当前挂画 dp[i][1] = dp[i-1][0] # 当前空,且是第一个空 dp[i][2] = dp[i-1][1] # 当前空,且是第二个空 # 最终答案:第N个位置处于任何合法状态的方案总和 return dp[N][0] + dp[N][1] + dp[N][2] # 测试 print(gallery_arrangements(1)) # 输出:2 (挂 or 空) print(gallery_arrangements(2)) # 输出:? 我们可以手算验证 print(gallery_arrangements(5)) # 输出:?

4.2 空间优化版本代码

def gallery_arrangements_opt(N): if N <= 0: return 0 if N == 1: return 2 # 直接返回基础情况 # 初始化,代表 i=1 时的状态 pre0, pre1, pre2 = 1, 1, 0 for i in range(2, N + 1): cur0 = pre0 + pre1 + pre2 cur1 = pre0 cur2 = pre1 # 滚动到下一轮 pre0, pre1, pre2 = cur0, cur1, cur2 return pre0 + pre1 + pre2

4.3 调试与验证:从小数据开始

DP代码写完后,最怕的就是转移方程推错了。最好的调试方法就是从小数据开始,手动模拟,对比输出

N=1:方案有 {挂}, {空}。共2种。程序输出2,正确。

N=2:我们手动枚举一下所有合法方案(记F为挂画,E为空):

  1. FF
  2. FE
  3. EF
  4. EE? 注意,EE是连续两个空,是允许的(因为约束是禁止连续三个空)。 所以,N=2时,所有4种方案都合法。程序应该输出4。运行gallery_arrangements(2),得到4,正确。

N=3:手动枚举有点麻烦了,但我们可以用DP思想手算:

  • i=1: (0:1, 1:1, 2:0)
  • i=2:
    • dp[2][0] = 1+1+0=2(对应FF, EF)
    • dp[2][1] = dp[1][0]=1(对应FE)
    • dp[2][2] = dp[1][1]=1(对应EE)
  • i=3:
    • dp[3][0] = dp[2][0]+dp[2][1]+dp[2][2] = 2+1+1=4
    • dp[3][1] = dp[2][0] = 2
    • dp[3][2] = dp[2][1] = 1
  • 总方案 = 4+2+1 = 7。

我们验证一下,N=3时,非法方案只有一种:EEE。总共有2^3=8种可能,减去1种非法,得到7种。完美匹配。

通过这样验证前几个小数据,基本可以确定DP方程的正确性。

踩坑记录:我第一次写的时候,在初始化dp[1][2]时顺手写了0,但没仔细想。后来测试N=2时发现结果不对,才回头检查。对于边界情况,一定要结合定义反复推敲。dp[1][2]表示“第一个画框空,且是连续第二个空”,这显然不可能,所以是0。这个“显然”的步骤最容易出错。

5. 问题变体与扩展思考

“画廊”问题只是一个引子,DP的魅力在于它能解决一大类具有相似结构的问题。掌握了这个模型,你可以轻松应对许多变体。

5.1 变体一:约束条件变化

问题:如果约束变成“不能有连续两个画框为空”怎么办?分析:此时,状态“连续两个空”就是非法的了。我们的状态只需要:

  • 状态0:当前挂画。
  • 状态1:当前空,且是第一个空(即前一位挂画)。 状态2不需要了,因为一旦出现连续两个空就非法。转移方程
  • dp[i][0] = dp[i-1][0] + dp[i-1][1](当前挂画,前一位任意)
  • dp[i][1] = dp[i-1][0](当前空,前一位必须挂画) 初始化:dp[1][0]=1, dp[1][1]=1。 答案:dp[N][0] + dp[N][1]

你可以验证,N=3时,非法方案是EE?和?EE,具体有EEF, FEE, EEE。总方案8-3=5。用这个DP算出来也是5。

5.2 变体二:两侧画廊问题

问题:画廊两侧都有画框,左侧有L个,右侧有R个。约束可能是:同一侧不能连续K个空,或者左右两侧对应位置不能同时空等等。分析:状态维度需要增加。例如,我们可以定义dp[i][j][a][b],表示处理到第i个位置(可能需要一个顺序遍历两侧),左侧最后一个状态为a,右侧最后一个状态为b,且连续空的情况为j(这里j可能需要更复杂编码)。这变成了一个多维DP,本质思路不变,但状态设计和转移会更复杂。这常用于竞赛中的“状压DP”结合。

5.3 变体三:求具体方案而不仅是数量

问题:如果要求输出所有具体的布置方案,而不仅仅是数量。分析:DP通常用于计数或求最优值。要求所有具体方案,一般需要结合回溯(Backtracking)。我们可以用DP先计算出每个状态下的方案数,然后从最终状态倒推,根据转移方程和方案数,递归地构造出所有路径。这通常比纯暴力回溯高效,因为DP表帮助我们快速判断某条分支下是否真的存在合法方案,可以进行剪枝。

5.4 与经典DP模型的联系

“画廊”问题本质上是一个线性DP,并且带有状态机特征。它和以下经典问题神似:

  • 股票买卖问题(带有冷冻期):状态表示“持有股票”、“不持有股票且在冷冻期”、“不持有股票且不在冷冻期”。
  • 打家劫舍问题:状态表示“偷当前家”和“不偷当前家”,并且有“不能连续偷”的约束。
  • 解码方法问题:数字字符串解码成字母,当前字符可以单独解码,也可以和前一个字符组合解码,状态就是“以单独解码结尾”和“以组合解码结尾”。

它们的共同点是:当前决策受到前面有限步决策的影响,并且这种影响可以被几个有限的状态所概括。识别出这个模式,你就掌握了解决一大片DP问题的钥匙。

6. 常见错误与排查技巧

6.1 初始化错误

这是最常见的错误之一。一定要把i=1(或i=0,看你的下标习惯)的所有状态,根据定义一个一个手动赋值,不要想当然。比如在“画廊”题中,dp[1][2](第一个位置就是连续第二个空)明显为0,但如果你漏了,或者写成1,后面全错。

排查技巧:打印出DP表的前几行,与手动计算的结果对比。对于线性DP,前3-5行的值很容易手算验证。

6.2 转移方程遗漏或错误

推导方程时,容易遗漏某些转移路径,或者搞错转移条件。例如,在dp[i][0]的转移中,是否包含了所有可能的前置状态?

排查技巧

  1. 画状态转移图:用纸笔画出来,箭头标上转移条件,一目了然。
  2. 口语化检查:对着方程念出来。“要让我第i个位置挂画(状态0),那么第i-1个位置可以是什么情况?可以是挂画(状态0),可以是第一个空(状态1),也可以是第二个空(状态2)。好,这三种情况都加上了吗?” 这种自问自答非常有效。
  3. 小数据测试:如前所述,用N=1,2,3去测试,对比暴力枚举的结果。

6.3 模运算下的陷阱

很多题目要求结果对某个大数(如1e9+7)取模。这时要注意:

  • 加法、乘法运算后及时取模,防止溢出。
  • 初始化时也要取模(虽然初始值很小,但养成好习惯)。
  • 在滚动数组优化时,确保用于计算当前值的“前一轮状态”是已经取过模的。

6.4 索引越界

在循环中,如果状态转移用到了dp[i-2]甚至更早的状态,要确保i从足够大的值开始循环,并对i=1等边界情况单独处理。

通用调试流程

  1. 静心读题:至少读三遍,用笔划出关键约束(“连续K个空”)。
  2. 定义状态:问自己,为了判断下一个决策是否合法,最少需要知道前面哪些信息?把这些信息组合成状态。
  3. 写出方程:基于状态定义,枚举当前状态的所有可能来源。
  4. 确定初值:考虑第一个元素(或前几个元素)所有可能的状态取值。
  5. 小数据验证:用N=1,2,3手动计算DP表,并与程序输出对比。如果不符,回到步骤2-4检查。
  6. 大数据测试:如果可能,写一个暴力搜索函数(DFS)用于小范围(N<=10或15)验证,确保DP结果与暴力结果完全一致。这是最可靠的验证方法。

DP就像搭积木,状态是积木块,转移方程是拼接规则。一块没放对,整个模型就垮了。耐心和细致的推导,是解开所有DP问题的唯一捷径。这道“画廊”题,就是一个完美的起点。下次遇到类似的“连续约束”问题,不妨先想想,我们需要用几个状态来描述这个“连续”的进度。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询