☰
动态规划边界条件避坑指南:从记忆化搜索到自底向上
2026/10/10 20:00:14 网站建设 项目流程

1. 为了一行 dp[0]=1,我盯着屏幕到凌晨两点

如果你刷过一段时间动态规划,大概率经历过这种场景:递推公式推得毫无问题,转移方程写出来自己也觉得天衣无缝,一提交却连着挂三次,最后发现崩在"dp[0] 到底该初始化成 0 还是 1"这种细节上。我印象最深的一次,是为了解决一道中等难度的路径计数题,断断续续查了整整一个晚上,最后原因说出来自己都想笑——二维数组的列循环里,内层 for 从 0 写成了从 1 开始,导致第一列的状态永远停留在初始值。那一刻我对着屏幕沉默了半分钟,然后默默把题解里的边界条件摘抄到笔记本上。

记忆化搜索、自底向上、边界条件,这三个词放在一起,基本就是动态规划从入门到进阶的"劝退三件套"。很多人递推公式能默写,框架能背,一到边界就翻车。而且翻车方式极其统一:不是数组越界,就是初始值不对,或者循环方向反了。更气人的是,这类错误往往不会导致程序崩溃,只会让你得到一套"看起来没毛病但就是不对"的结果,排查起来特别费劲。

这篇文章我想认真聊聊三件事:边界条件为什么会成为 DP 的头号杀手;在记忆化搜索(自顶向下)和自底向上这两种经典写法里,它分别以什么形态出现;以及我这些年总结出来的、一套能稳定对付它的排查方法。适合正在刷题准备面试的人、在竞赛里被 DP 边界坑过的人、以及想真正理解动态规划而不是背模板的人。内容不追求炫技,只求你看完能少踩几个坑。

顺便说一句,"边界条件永远算不对"这个说法其实不太公平。它算不对,往往是因为我们根本没想清楚自己定义的 dp 状态到底是什么。这个问题不解决,写一万遍边界条件也还是会错。

2. 记忆化搜索:边界条件长在递归的"家门口",天然难踩雷

2.1 记忆化到底在做什么:递归加缓存

记忆化搜索的本质,是把 DP 的状态转移方程直接写成递归函数,再额外加一个缓存来避免重复计算。它的思路是这样的:你要算一个大问题,就先假设子问题已经算好了,通过调用自己来获取子问题的答案,最后把子问题的答案按转移方程组装起来。

和自底向上最大的区别在于,你不需要手动安排"哪些状态先算、哪些状态后算"。递归天然会从大状态一路追问到最小状态,然后在最小状态处停下,再一层层把答案带回来。这个"最小状态处停下"的动作,就是边界条件(base case)。

它长什么样?拿最经典的斐波那契数列来说:

memo = {} def fib(n): if n in memo: return memo[n] if n <= 1: return n memo[n] = fib(n-1) + fib(n-2) return memo[n]

注意看边界条件的写法。if n <= 1: return n写在函数最前面,和后面的转移逻辑完全分开。你思考的时候只需要回答一个问题:当状态小到不能再通过递推拆解时,这个函数应该直接返回什么?想清楚这一句话,边界就写完了,不需要考虑数组开多大、循环从哪开始从哪结束。

对比自底向上版本的斐波那契:

dp = [0] * (n + 1) dp[0], dp[1] = 0, 1 for i in range(2, n + 1): dp[i] = dp[i-1] + dp[i-2]

这里边界条件被拆成了两处:一个是数组初始化时的dp[0], dp[1] = 0, 1,一个是循环起点range(2, n+1)。它们必须和递推公式严格自洽,任何一处不一致,答案就会错。在记忆化版本里,这两处被合并成了一个简单的条件判断,出错的概率自然就低了。

2.2 上楼梯问题:边界条件自己会说话

再举个更贴近生活的例子——上楼梯问题。假设一次可以跨 1 级或 2 级台阶,问到第 n 级台阶有多少种走法。用记忆化搜索写是这样的:

from functools import lru_cache @lru_cache(None) def climb(n): # 站在第0级:什么都不做,算一种方案 # 第1级:直接跨一步,也算一种方案 if n <= 1: return 1 return climb(n-1) + climb(n-2)

这个 base case 为什么写成n <= 1?因为它说的是两件非常具体的事:站在地上(第 0 级)是一种方案,跨一步到第 1 级也是一种方案。后面每级台阶的方案数,由"从上一级跨一步过来"和"从上上级跨两步过来"相加得到。

你发现没有,在记忆化写法里,边界条件几乎是在"自我解释"。因为递归函数本身就是问题的自然语言描述,边界条件就是描述中的"最小情况"。很多人在递归里不会写错边界,是因为他们不需要在脑内模拟整个计算顺序——只需要判断"这种情况还有没有子问题可以拆",没有就直接返回,非常简单。

这也是我为什么一直建议初学者先学记忆化搜索:它让你把注意力集中在转移方程本身,而不是被初始化、循环边界这些机械细节分心。

2.3 记忆化的隐藏边界:递归深度、负下标与缓存键

但要说记忆化搜索完全没有边界问题,那也是骗人的。它只是把边界错误换了一种形式出现。我至少踩过三种:

第一种是递归深度。Python 默认递归深度限制大概在 1000 层左右,遇到 n 比较大的题,比如斐波那契算到第 10 万项,记忆化搜索会直接 RecursionError。解决办法要么调sys.setrecursionlimit,要么老老实实转自底向上。在竞赛环境里,递归深度不是小事,一个 10^5 级别的 DP 问题,自顶向下在很多语言里都会被栈限制卡死。

第二种是负下标。有些转移方程里会出现dfs(n-1)这种调用,如果 base case 写得不够宽,比如只处理了n == 0而没有处理n < 0,一旦输入走到负数,函数就会带着负下标继续递归,轻则算错,重则越界崩溃。我见过不少人在写"最少硬币找零"这类问题时会遇到这个坑。正确的做法是在 base case 里把所有"非法状态"统一拦截,比如:

def dfs(amount): if amount < 0: return float('inf') if amount == 0: return 0 # 继续递归...

第三种是缓存键设计。如果状态由多个参数组成,比如dfs(i, j),你得确保缓存把两个维度都考虑进去。用@lru_cache(None)一般没问题,但如果你手写字典,记住要用(i, j)这种元组做键,而不是只存i。这种错误很像自底向上的数组越界——都属于"状态表示不完整"的毛病。

3. 自底向上的边界雷区:定义、初始化、循环顺序一个都不能错

3.1 先写一句人话定义,再写任何代码

自底向上之所以边界容易错,核心原因是你把计算顺序、状态存储方式全都"手工实现"了,任何一个环节和状态定义不一致,结果就是错的。而绝大多数错误的根源,都可以追溯到同一个问题:你根本没想清楚 dp[i] 到底代表什么。

所以我现在有个习惯,写任何自底向上的解法,第一行一定是注释,不是代码。比如上楼梯问题,我会写:

# dp[i] = 爬到第 i 级台阶的走法总数

就这一句话,后面所有关键决策都有了依据:数组要多长?——因为要能存到第 n 级,所以长度是 n+1,不是 n。dp[0] 应该是什么?——第 0 级是起点,站在起点有一种"不走"的方案,所以 dp[0]=1。循环从哪开始?——dp[0]、dp[1] 已经确定,从 2 开始。你看,只要定义写清楚了,这些曾经让你抓狂的问题全都有唯一答案。

反过来,如果你脑子里只有"dp[0]=1,dp[1]=1,然后从2循环"这种机械记忆,换一道题就会立刻失效。比如有一类题问"从 0 到达第 n 个格子的方案数",规则相同,但起点变成第 1 个格子,dp[0] 的含义就变了,整个初始化逻辑跟着变。定义不清,代码必错。

3.2 初始化大坑:dp[0] 是 0 还是 1,取决于你怎么定义

自底向上的边界 bug 里,初始化问题至少占一半。而且这类错误特别隐蔽——程序不报错,数组不越界,就是答案不对。

最常见的争议就是 dp[0]。拿上楼梯举例,我看到过三种写法:

# 写法A:dp[0]=1, dp[1]=1, dp[i]=dp[i-1]+dp[i-2] # 写法B:dp[0]=0, dp[1]=1, dp[i]=dp[i-1]+dp[i-2] # 写法C:dp[1]=1, dp[2]=2, dp[i]=dp[i-1]+dp[i-2]

这三种写法可能全对,也可能全错,关键看你对 dp[i] 的定义。如果 dp[i] = "恰好站在第 i 级台阶的方案数",那么站在起点第 0 级就是一种方案,写法 A 对。如果你强行把 dp[0] 定义成"第 0 级不存在",那上面的递推在 i=2 时就算错了。

这里我想说一个特别反直觉的点:边界条件不是凭空来的,它必须和你的递推公式组成一个自洽的系统。递推公式负责从旧状态算新状态,边界条件负责提供整个链条的"起点"。如果起点和递推逻辑对不上,就像一根链条一端挂着错的锚,后面每一环都会偏。

还有一个高频坑是"哨兵值"。很多题目里状态一开始是"不可达"的,需要用一个特殊值标记。最常见的错误是用 0 来标记不可达,但 0 本身可能是合法答案。举个例子,求最短路径长度时,dp 初始化为很大的数是对的;但如果你求的是方案数,初始化为 0 反而正确。到底用什么值,取决于你的题目。这个我在下一节会用背包的"恰好装满"变种详细展开,那是这类错误的经典教材。

3.3 循环方向和滚动数组:压缩状态的代价

初始化搞定了,接下来是循环顺序。自底向上的依赖关系决定了循环方向:你必须保证计算 dp[i] 时,它依赖的所有状态都已经算出来了。

这句话听着简单,实操中到处都是反例。最典型的就是 01 背包。它的递推里,dp[c] 更新时要依赖 dp[c-weight[i]],如果你让容量从左往右循环,就会在同一次物品迭代里反复使用刚更新过的值,等于同一个物品被拿了多次——完美地把 01 背包变成了完全背包,而且答案还"看似合理"。正确的是容量从大到小循环。

滚动数组是另一个重灾区。滚动数组节省内存的本质,是把多维 dp 压缩成一维,代价是你必须精确控制覆盖顺序,否则旧状态会被新状态覆盖。我见过很多人在压缩 LCS 的二维数组时,把dp[i-1][j-1]对应的值弄丢,因为它在更新dp[j]之前已经被覆盖了。这种 bug 调试难度极高,因为状态看起来都是"正常的数",就是结果差一两个单位。

我的建议是:第一版代码永远写完整版的多维数组,确认逻辑全对之后再考虑滚动数组优化。优化引入的 bug 和算法思路的 bug 混在一起,排查成本会乘以两倍以上,得不偿失。

4. 三个经典题里的边界错误:我都替你踩了一遍

4.1 上楼梯和斐波那契:同一套递推,两套边界

这个例子最能说明"边界必须与定义自洽"。下面这段代码,用来算斐波那契数列 F(n) 是完全正确的:

def fib(n): dp = [0] * (n + 1) dp[0], dp[1] = 0, 1 for i in range(2, n + 1): dp[i] = dp[i-1] + dp[i-2] return dp[n]

但如果直接拿它去算上楼梯方案数,n=2 时会返回 1,而真实答案是 2。问题在哪?不在递推公式,在于两个问题的 dp 定义不同。斐波那契数列里 F(0)=0 是定义好的;上楼梯问题里,到达第 0 级台阶的方案数是 1(什么都不做)。同一个数组大小、同一个循环结构,只改一行初始化,整个结果就对了:

def climb(n): dp = [0] * (n + 1) dp[0], dp[1] = 1, 1 # 唯一区别 for i in range(2, n + 1): dp[i] = dp[i-1] + dp[i-2] return dp[n]

这道题我还见过一种更隐蔽的错法:把 dp[1] 初始化成 0,dp[2] 初始化成 1,然后从 3 开始循环。对于 n=1 的输入直接返回 0,错得离谱。为什么有人会这么写?因为把"下标 1"和"第一级台阶"混为一谈了。在 dp[i-1] 这种写法里,下标本来就是"台阶数",不是"第几个台阶"。这类混淆几乎贯穿所有 DP 边界错误:你用了哪个维度做下标,那个维度的 0 和 1 就分别代表什么,从定义到初始化到循环,必须全程一致。

4.2 最长公共子序列:索引偏移和哨兵行列

LCS 是二维 DP 里边界坑最多的一道题,没有之一。很多人第一次写都会栽在"多出来的一行一列"上。

正确写法是这样:

def lcs(s1, s2): m, n = len(s1), len(s2) # dp[i][j] = s1 前 i 个字符 与 s2 前 j 个字符的 LCS 长度 dp = [[0] * (n + 1) for _ in range(m + 1)] for i in range(1, m + 1): for j in range(1, n + 1): if s1[i-1] == s2[j-1]: dp[i][j] = dp[i-1][j-1] + 1 else: dp[i][j] = max(dp[i-1][j], dp[i][j-1]) return dp[m][n]

最容易犯的两个错误我都在真实代码里见过。第一个是数组开成m x n而不是(m+1) x (n+1),然后循环里还要特判if i > 0 and j > 0,代码丑不说,状态含义瞬间变得说不清。第二个是忘了下标偏移——比较字符时写成了s1[i] == s2[j],由于 dp 表比字符串长一行一列,不仅可能越界,就算不越界,比较的位置也是错的。

这里的第 0 行和第 0 列就是"哨兵",代表空字符串。空字符串和任何字符串的 LCS 长度都是 0,所以初始化为 0 是水到渠成的。但是很多人不理解哨兵的存在意义,以为"全初始化为 0"只是顺手写的。一旦题目从"子序列"换成"子串",同样初始化成 0 的数组,转移公式就要从max变成"不匹配就重置为 0"。边界看起来完全一样,代码逻辑却完全不同。这说明什么?说明 0 哨兵本身无所谓对错,关键是你知不知道这个 0 代表的是"空串的 LCS 长度"。

4.3 01背包的"恰好装满"变种:无穷小才是对的

背包问题里的边界雷区,比前两道加起来都精彩。先说标准 01 背包:

def knapsack(weights, values, capacity): # dp[c] = 容量为 c 时能装下的最大价值 dp = [0] * (capacity + 1) for i in range(len(weights)): for c in range(capacity, weights[i] - 1, -1): dp[c] = max(dp[c], dp[c - weights[i]] + values[i]) return dp[capacity]

这里全部初始化为 0 是对的,因为"什么都不装"也是一种合法方案,价值为 0。但如果你把题目改成"恰好装满背包"(即最终状态必须正好占用 capacity 容量),同样的初始化就会给出完全错误的结果。原因很微妙:全部初始化为 0,等于从"任意容量都可达且价值为 0"开始推导,最后你会算出一个"不一定装满"的最大价值。

"恰好装满"的正确初始化是:

dp = [float('-inf')] * (capacity + 1) dp[0] = 0

只有容量 0 是可达的(什么都不装,正好装满 0),其他容量一开始都不可达。转移时只有从可达状态才能继续:

for i in range(len(weights)): for c in range(capacity, weights[i] - 1, -1): if dp[c - weights[i]] != float('-inf'): dp[c] = max(dp[c], dp[c - weights[i]] + values[i])

这里float('-inf')就是"不可达"的哨兵。用 0 当哨兵是错的,因为"装满某个容量且价值为 0"可能是合法状态,你会把不可达和可达混在一起。这类"0 到底是不是合法值"的判断,几乎决定了所有带约束条件 DP 的边界写法。

另外还有一个不容忽视的细节:如果用float('-inf'),在转移时要小心负数溢出或精度问题。竞赛里我一般用一个足够小的负数比如-10**9,然后配合显式的可达性判断,比纯靠 inf 参与运算更稳妥。

5. 排查边界条件的实操链路:从小样本到全量验证

5.1 自查五问清单

被边界坑了太多次之后,我给自己整理了一份自查清单,每次写完 DP 都从头到尾过一遍。如果你经常在边界上翻车,建议把它抄下来贴在屏幕旁边:

  • 第一问:dp[i](或 dp[i][j])的定义,能否用一句完整的人话写出来?
  • 第二问:下标 0 对应的问题场景是什么?是空数组、空字符串、还是容量为 0?这个场景下的答案是什么?
  • 第三问:dp 数组每个维度的大小,和定义是否匹配?比如定义用到"前 i 个元素",数组长度就得是 n+1,不是 n。
  • 第四问:有哪些状态是"不走转移方程也能直接得出答案"的入口状态?它们是否已经正确初始化?
  • 第五问:循环的上下界有没有覆盖所有需要计算的状态?左闭右开还是闭区间?会不会漏掉第一个或最后一个?

这五问里,前两问解决的是"边界语义"问题,后三问解决的是"边界机械实现"问题。根据我的经验,90% 的边界 bug 都能被这五问筛出来。剩下的 10%,交给下面的对拍调试法。

5.2 对拍调试法:让两份 DP 互相指认错误

这个方法是跟我当年的竞赛队友学的,后来我用它解决过至少二十个"怎么看都对的错解"。核心思路很简单:用两种完全不同的实现去解同一道题,随机生成小规模测试数据,比对输出。如果输出不一致,错的版本就藏不住了。

具体操作是这样的。假设你写好了自底向上版本,那就在本地再写一个记忆化搜索版本,或者更暴力一点,直接写暴力枚举版本(题目规模小的时候完全可行)。然后用随机数生成数据,循环跑一百遍:

import random for _ in range(1000): n = random.randint(1, 10) s1 = ''.join(random.choice('abc') for _ in range(n)) s2 = ''.join(random.choice('abc') for _ in range(n)) r1 = lcs_bottom_up(s1, s2) r2 = lcs_memo(s1, s2) if r1 != r2: print(s1, s2, r1, r2) break

一旦发现不一致,就把那组数据单独拎出来,在两份代码里把 dp 表打印出来对比。这一步几乎能直接定位到出错的那个状态。为什么这个方法好用?因为两份代码基于同一个转移方程,如果输出一致,可以确认你的整体逻辑没问题;如果不一致,那必然有一份的某个状态写错了,而差异点通常就是边界状态的错误。

我特别想强调一点:不要觉得自己"这次没问题"就跳过对拍。省这几分钟,后面可能要花一个小时在 LeetCode 的提交记录里反复横跳。

5.3 预防式编程:注释定义、断言和边界特判

最后说三个预防手段,成本极低,收益极高。

第一,把 dp 定义写进注释,而不是只在脑子里想。前面已经强调过一次,这里再重复是因为它真的能救命。写注释这件事本身就是在逼你把定义说清楚,很多写着写着就发现自己的初始化不对。

第二,用断言锁住已知性质。比如上楼梯问题里,dp 的每一项都不应该小于前一项(方案数随台阶数单调不减),如果计算中出现递减,说明前面某处已经错了。在循环结束后加几个 assert,跑一遍小数据就能提前暴露问题。

第三,对最小输入做特判。很多边界 bug 只在 n=0、n=1、空字符串、空数组这些极限情况暴露。写完先别急着提交,手动测这几个用例。别嫌麻烦,大多数 WA 都是这么救回来的——包括我那次凌晨两点查出来的列循环 bug,当时只要手动跑一个 n=1 的用例就能立刻发现。

6. 记忆化还是自底向上:我现在的选型判断标准

6.1 记忆化不可替代的三个场景

如果你还在纠结"到底应该用哪种写法",我的经验是:不少场景下根本没得选。

第一个场景是状态图不规则的问题。比如树形 DP、棋盘上的博弈 DP、区间 DP 的某些变体,状态之间的依赖关系根本不是简单的"小下标依赖大下标"。这种时候自底向上的人工拓扑排序会写到怀疑人生,而记忆化搜索只需要自然地递归,让系统帮你处理调用顺序。

第二个场景是状态稀疏的问题。你的 dp 状态可能非常大,但实际会被访问到的只有一小部分子集。自底向上必须把整个表填满,哪怕大部分状态用不上;记忆化搜索只计算被递归触达的状态,能省下大量时间。比如某些带剪枝的搜索类 DP,记忆化几乎是唯一可行方案。

第三个场景是状态参数不易排序的情况。如果状态是(i, j, k)这种多元组,而且三个维度的大小、语义各不一样,自底向上写多重循环时很容易把自己绕晕。递归写法里,状态参数就是函数参数,按名称访问,出错的概率远低于一堆嵌套 for 循环里的数组下标。

6.2 自底向上不可替代的三个场景

反过来,自底向上也有绝对优势的场景。

第一个是状态空间巨大且必须压缩内存。滚动数组只能在自底向上里实现,记忆化搜索的缓存字典开销大到没法做这种优化。当你面对 10^6 级别甚至更大的状态空间时,list 数组比 dict 省一个量级的内存,这是决定性的。

第二个是递归深度受限的场景。Python 的递归深度限制是硬伤,n 超过几千就可能爆栈。能调sys.setrecursionlimit,但调太高有段错误风险。遇到 10^5 级别的线性 DP,老老实实写循环比折腾递归安全得多。

第三个是极致性能要求的场景。每次递归调用都有函数调用开销,缓存查找也有哈希开销。同样的算法,自底向上的纯循环通常比记忆化快几倍。竞赛里时间卡得紧的时候,这个差距可能就是 TLE 和 AC 的区别。

6.3 我的习惯:先记忆化验证,再按需转底向上

说了这么多,分享一下我现在实际操作中的流程。拿到一道 DP 题,我的第一步永远是写记忆化搜索。为什么?因为它和转移方程长得一模一样,写完基本就等同于证明了递推逻辑是对的。边界条件用 base case 写在函数开头,自查也容易。

确认记忆化版本的答案没问题之后,再回答一个问题:这个解法在时间和空间上会被卡吗?如果题目规模很小,或者状态天然稀疏,记忆化直接提交就行,省事。如果 n 很大,或者需要滚动数组优化,我再把它转成自底向上。

转换的时候有一个重要技巧:不要凭空改代码,从递归定义反推循环结构。递归里dfs(i)依赖dfs(i-1)和dfs(i-2),那自底向上的循环就一定是从小到大;递归里dfs(i, j)依赖dfs(i-1, j-1),那双重循环的内外层方向也基本能被定死。初始化值就是递归 base case 的返回值,数组大小就是状态参数的取值范围加一(或按定义的偏移量调整)。把递归代码当作"设计文档"来推导,几乎不会推导出和递归逻辑矛盾的循环代码。

还有一个我自己用了很久的经验:转底向上之后,再拿原递归版本对拍一次。这一步能抓住转换过程中引入的 90% 错误。尤其是滚动数组版本,覆盖顺序带来的隐蔽 bug,只有对拍能快速暴露。

说到最后,我想提一个心态问题。很多人觉得边界条件写不对是自己笨,其实完全不是。动态规划的边界错误本质上是"状态语义"和"代码实现"之间的映射偏差,而人类在这种多环节一致性检查上本来就不擅长。我写了这么多年题,第一次写 DP 时仍然会把边界写错。区别只是,现在的我不会再对着屏幕怀疑人生了——我知道所有的边界错误,最终都能通过"把定义写清楚、用最小用例验证、拿两份实现互相指认"这三步解决掉。你踩过的那些坑,每一个都是你以后分辨同类题目的经验包,记住它们的形态,比记住正确答案本身更值钱。

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

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

立即咨询