
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, n1)。它们必须和递推公式严格自洽任何一处不一致答案就会错。在记忆化版本里这两处被合并成了一个简单的条件判断出错的概率自然就低了。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 级所以长度是 n1不是 n。dp[0] 应该是什么——第 0 级是起点站在起点有一种不走的方案所以 dp[0]1。循环从哪开始——dp[0]、dp[1] 已经确定从 2 开始。你看只要定义写清楚了这些曾经让你抓狂的问题全都有唯一答案。反过来如果你脑子里只有dp[0]1dp[1]1然后从2循环这种机械记忆换一道题就会立刻失效。比如有一类题问从 0 到达第 n 个格子的方案数规则相同但起点变成第 1 个格子dp[0] 的含义就变了整个初始化逻辑跟着变。定义不清代码必错。3.2 初始化大坑dp[0] 是 0 还是 1取决于你怎么定义自底向上的边界 bug 里初始化问题至少占一半。而且这类错误特别隐蔽——程序不报错数组不越界就是答案不对。最常见的争议就是 dp[0]。拿上楼梯举例我看到过三种写法# 写法Adp[0]1, dp[1]1, dp[i]dp[i-1]dp[i-2] # 写法Bdp[0]0, dp[1]1, dp[i]dp[i-1]dp[i-2] # 写法Cdp[1]1, dp[2]2, dp[i]dp[i-1]dp[i-2]这三种写法可能全对也可能全错关键看你对 dp[i] 的定义。如果 dp[i] 恰好站在第 i 级台阶的方案数那么站在起点第 0 级就是一种方案写法 A 对。如果你强行把 dp[0] 定义成第 0 级不存在那上面的递推在 i2 时就算错了。这里我想说一个特别反直觉的点边界条件不是凭空来的它必须和你的递推公式组成一个自洽的系统。递推公式负责从旧状态算新状态边界条件负责提供整个链条的起点。如果起点和递推逻辑对不上就像一根链条一端挂着错的锚后面每一环都会偏。还有一个高频坑是哨兵值。很多题目里状态一开始是不可达的需要用一个特殊值标记。最常见的错误是用 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]但如果直接拿它去算上楼梯方案数n2 时会返回 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] 初始化成 0dp[2] 初始化成 1然后从 3 开始循环。对于 n1 的输入直接返回 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而不是(m1) x (n1)然后循环里还要特判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 个元素数组长度就得是 n1不是 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 只在 n0、n1、空字符串、空数组这些极限情况暴露。写完先别急着提交手动测这几个用例。别嫌麻烦大多数 WA 都是这么救回来的——包括我那次凌晨两点查出来的列循环 bug当时只要手动跑一个 n1 的用例就能立刻发现。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 时仍然会把边界写错。区别只是现在的我不会再对着屏幕怀疑人生了——我知道所有的边界错误最终都能通过把定义写清楚、用最小用例验证、拿两份实现互相指认这三步解决掉。你踩过的那些坑每一个都是你以后分辨同类题目的经验包记住它们的形态比记住正确答案本身更值钱。