
1. 从“最优子结构”说起为什么动态规划是解决复杂问题的利器如果你曾经尝试过解决一些看似简单、但穷举起来计算量却大到离谱的问题比如规划一条最短的旅行路线或者给一堆物品打包以最大化背包的价值你很可能已经与动态规划Dynamic Programming简称DP擦肩而过。它不是什么高深莫测的数学魔法而是一种极其强大且优雅的算法思想核心在于“聪明地避免重复计算”。很多初学者第一次接触DP时会被它那些看似复杂的递推公式和状态定义吓退觉得这玩意儿只存在于算法竞赛中。但事实上从我们日常用的搜索引擎、导航软件的路径规划到金融领域的资产定价模型再到生物信息学的基因序列比对DP的身影无处不在。简单来说动态规划是用来解决一类具有“重叠子问题”和“最优子结构”特性的最优化问题的。什么叫“重叠子问题”就是你在求解大问题的过程中会反复遇到、需要计算很多次一模一样的小问题。如果每次都傻傻地重新算一遍效率会低得可怕。而“最优子结构”意味着一个大问题的最优解可以由其分解出的若干个小问题的最优解组合而成。DP的智慧就在于它把那些小问题的答案我们称之为“状态”记在一个表格数组里下次再需要时直接查表用空间换时间从而将原本可能是指数级的时间复杂度降低到多项式级别。网络上热门的“最长上升子序列”和“01背包问题”正是理解DP精髓的绝佳入口。前者帮你理解如何定义“状态”和找到“状态转移方程”后者则展示了如何巧妙地处理“选择”与“不选择”的决策过程。接下来我将抛开教科书式的定义带你从这两个经典问题入手一步步拆解DP的思考框架、实现细节以及那些只有实际编码时才会遇到的“坑”。我们不仅要会写代码更要理解为什么这样设计状态、为什么这样转移以及如何将这种思想应用到更广泛的场景中。2. 最长上升子序列理解状态定义与转移的基石最长上升子序列Longest Increasing Subsequence, LIS问题是动态规划入门的第一道“思考题”。问题描述很简单给定一个无序的整数序列找到其中最长的、严格递增的子序列的长度。注意子序列不要求连续。例如序列[10, 9, 2, 5, 3, 7, 101, 18]的最长上升子序列之一是[2, 3, 7, 101]长度为4。2.1 暴力搜索的困境与DP的破局思路最直观的想法是穷举所有可能的子序列然后检查它们是否递增并记录最长的长度。对于一个长度为n的序列子序列总数是2^n个这显然是不可接受的。我们需要更聪明的方法。动态规划的思考起点永远是如何定义“状态”状态就是我们用来描述和记忆子问题解的那个东西。对于LIS一个很自然的想法是定义dp[i]为以第i个数字结尾的最长上升子序列的长度。为什么这么定义因为“以某个位置结尾”这个条件为我们提供了一个固定的“终点”使得问题变得可分解。我们只需要关心对于当前位置i前面有哪些位置jj i的数字比nums[i]小那么我们就可以把nums[i]接在dp[j]所代表的那个子序列后面形成一个更长的上升子序列。由此我们得到了状态转移方程dp[i] max(dp[j]) 1 其中0 j i且nums[j] nums[i]。这个方程的意思是为了找到以i结尾的最长上升子序列我需要遍历i之前的所有位置j。如果nums[j]比nums[i]小说明nums[i]可以接在j后面。那么以i结尾的LIS长度至少可以是dp[j] 1。我们遍历所有满足条件的j取其中最大的dp[j]再加1就是dp[i]的值。2.2 从方程到代码实现细节与初始化陷阱根据上面的分析我们可以写出标准的DP解法def lengthOfLIS(nums): if not nums: return 0 n len(nums) # 初始化dp数组每个位置至少可以以自己为一个子序列长度为1 dp [1] * n # 记录最终答案 max_length 1 for i in range(1, n): # 从第二个元素开始 for j in range(i): # 遍历i之前的所有元素 if nums[j] nums[i]: # 状态转移尝试用dp[j]来更新dp[i] dp[i] max(dp[i], dp[j] 1) # 更新全局最大值 max_length max(max_length, dp[i]) return max_length这段代码的时间复杂度是 O(n²)空间复杂度是 O(n)。这里有几个非常关键的实操要点初始化dp数组初始化为1。这是最容易忽略但至关重要的细节。因为每个元素本身就是一个长度为1的上升子序列。如果你初始化为0整个逻辑就全错了。内层循环的遍历for j in range(i)确保了j严格在i之前。状态转移的方向是从已知的小问题dp[j]推导出未知的大问题dp[i]。答案的位置最终答案并不是dp[n-1]因为最长上升子序列不一定以最后一个元素结尾。所以我们需要一个max_length变量在遍历过程中持续记录最大值。注意这是最基础的DP解法。实际上LIS问题存在一种利用“贪心二分查找”的 O(n log n) 优化算法它维护一个“潜在上升子序列”的数组。但作为理解DPO(n²)的解法已经足够清晰。先彻底理解基础版本再去看优化版本你会对“状态”的本质有更深的认识——优化算法实际上是改变了状态的定义和存储方式。2.3 举一反三变种问题与思维延伸掌握了基础的LIS模型你可以解决一系列变种问题最长不下降子序列只需将状态转移条件nums[j] nums[i]改为nums[j] nums[i]。俄罗斯套娃信封问题这是一个二维的LIS问题。你先对宽度升序排序当宽度相同时按高度降序排序这是一个关键技巧目的是防止宽度相同的信封被错误地“套”进去。然后在高度数组上跑一遍LIS即可。最大整除子集给你一个正整数数组找出最大的子集满足子集中任意两个数都有“一个能整除另一个”的关系。你可以先排序然后定义dp[i]为以nums[i]为最大元素的最大整除子集的大小转移条件变为nums[i] % nums[j] 0。核心心得LIS问题教会我们DP的状态定义不一定直接是问题的最终答案而可以是一个与答案强相关的中间量以i结尾。找到那个“不变”的终点或起点是设计状态的第一步。3. 01背包问题决策的艺术与空间优化如果说LIS展示了如何“描述状态”那么01背包问题则完美诠释了如何“做出决策”。问题描述有一个容量为C的背包和n件物品。第i件物品的重量是weight[i]价值是value[i]。每件物品只能选择**放1或不放0**一次。问在不超过背包容量的前提下能装入物品的最大总价值是多少3.1 二维DP最直观的思考模型最经典的状态定义是使用一个二维数组dp[i][w]。它的含义是考虑前i件物品物品编号从1到i在背包容量为w的情况下可以获取的最大价值。注意这里的“考虑前i件物品”并不意味着这i件物品全都必须放进去而是我们对它们做出了选择放或不放。对于每件物品i我们面对一个决策放还是不放不放如果不放第i件物品那么问题就退化成了“考虑前i-1件物品容量为w”的子问题。此时的最大价值就是dp[i-1][w]。放如果放第i件物品那么首先需要背包有足够的容量w weight[i]。放入后背包剩余容量为w - weight[i]并且我们获得了value[i]的价值。那么总价值就是dp[i-1][w - weight[i]] value[i]。这里dp[i-1][w - weight[i]]代表在放入当前物品之前用剩余容量能获得的最大价值。我们的目标是最大化总价值所以状态转移方程为dp[i][w] max(dp[i-1][w], dp[i-1][w - weight[i]] value[i]) 其中第二项仅在w weight[i]时有效。初始化dp[0][...] 0表示考虑0件物品时任何容量下的价值都是0。def knapsack_2d(C, weight, value): n len(weight) # 创建 (n1) x (C1) 的二维数组多出一行一列用于简化初始化 dp [[0] * (C 1) for _ in range(n 1)] # 物品索引从1开始对应weight和value数组时记得-1 for i in range(1, n 1): for w in range(C 1): # 决策1不放物品i dp[i][w] dp[i-1][w] # 决策2如果容量够尝试放入物品i if w weight[i-1]: # 注意索引偏移 dp[i][w] max(dp[i][w], dp[i-1][w - weight[i-1]] value[i-1]) return dp[n][C]3.2 一维DP滚动数组极致的空间优化观察上面的二维DP代码你会发现在计算dp[i][w]时它只依赖于上一行dp[i-1][...]的数据。也就是说我们并不需要保存整个二维表格只需要一个一维数组用来代表“上一行”的结果然后在计算新的一行时覆盖它。这就是“滚动数组”的思想。但这里有一个至关重要的细节内层循环必须逆序从大到小遍历容量w。为什么我们来看状态转移方程dp[w] max(dp[w], dp[w - weight[i]] value[i])。这里的dp[w]在更新前存储的其实是dp[i-1][w]上一轮的结果。如果我们正序更新假设w从0遍历到C当更新到某个较大的w时它用到的dp[w - weight[i]]可能已经被本轮更新过了即变成了dp[i][w - weight[i]]而不是我们想要的dp[i-1][w - weight[i]]。这就相当于同一件物品被重复放入了多次这解决的是“完全背包”问题而不是“01背包”。逆序遍历保证了在更新dp[w]时dp[w - weight[i]]仍然是上一轮i-1的值符合01背包“每个物品仅用一次”的规则。def knapsack_1d(C, weight, value): n len(weight) # 一维dp数组dp[w]表示容量为w时的最大价值 dp [0] * (C 1) for i in range(n): # 关键逆序遍历容量 for w in range(C, weight[i] - 1, -1): # 状态转移 dp[w] max(dp[w], dp[w - weight[i]] value[i]) return dp[C]提示一维DP的写法更简洁效率也更高空间复杂度O(C)。但初学者务必理解逆序的原因这是01背包的核心考点也是面试中常问的“为什么”。你可以画一个简单的例子比如物品(重量2价值3)容量为4分别用正序和逆序手动模拟一下dp数组的变化就能深刻体会其中的区别。3.3 背包问题的千变万化01背包是背包问题的基础其变体极其丰富恰好装满背包初始化时dp[0] 0 其他dp[w] -inf。这样只有恰好能凑出容量w的方案其值才不会是负无穷最终dp[C]就是恰好装满的最大价值。方案数问题问有多少种方式能装满背包或达到某个价值。将状态dp[w]定义为方案数转移方程变为dp[w] dp[w - weight[i]]初始化dp[0] 1。分割等和子集给定一个数组判断是否能分割成两个和相等的子集。这等价于一个背包容量为sum/2物品重量和价值都是nums[i]的01背包问题看最后dp[sum/2]是否等于sum/2。最后一块石头的重量 II有一堆石头每次选两块相撞求最后剩下的最小可能重量。这本质上是要将石头分成两堆使得两堆重量差最小。也就是一个背包容量为总重量/2物品重量和价值均为石头重量的01背包问题求能装下的最大价值重量那么最小剩余重量就是总重量 - 2 * dp[总重量/2]。核心心得01背包问题的精髓在于“决策”和“状态压缩”。一维DP的逆序遍历是一个必须刻在脑子里的模式。遇到新问题时多思考是否能将其“映射”到背包模型有没有“容量”限制有没有“物品”及其“重量/价值”决策是不是“选”或“不选”4. 动态规划的通用解题框架与心法通过前面两个例子我们已经看到了DP的核心部件状态定义、状态转移方程、初始化和边界条件。现在我们将其系统化形成一个可以应对大多数DP问题的思考框架。4.1 四步解题法第一步定义状态最重要也是最难的一步状态就是描述问题某个阶段情况的变量。好的状态定义应该具备明确性dp[i]或dp[i][j]所代表的含义必须清晰、无歧义。通常下标代表“规模”或“条件”值代表“最优解”或“计数”。完备性状态要能涵盖所有可能影响最终答案的情况。无后效性当前状态一旦确定后续的决策就只依赖于这个状态而不依赖于这个状态是如何达到的。这是DP能成立的根本。常见的状态定义维度线性dp[i]表示以第i个位置为结尾/开头的某种最优解如LIS。区间dp[i][j]表示区间[i, j]上的某种最优解如石子合并、最长回文子串。双序列dp[i][j]表示在第一个序列的前i个元素和第二个序列的前j个元素上某种最优解如最长公共子序列LCS、编辑距离。背包dp[i][w]或dp[w]表示容量限制下的最优解。状态压缩用一个整数的二进制位来表示一组物体的选取状态如旅行商问题TSP。第二步确定状态转移方程这是DP的“引擎”。需要找到从已知的、更小的状态推导出当前状态的方法。通常是一个递推关系式形式如dp[i] F(dp[i-1], dp[i-2], ...)或dp[i][j] F(dp[i-1][j], dp[i][j-1], dp[i-1][j-1], ...)。思考的关键是“要得到当前状态有哪些前置状态可以贡献它们之间通过什么操作取max/min 相加逻辑或等联系起来”第三步初始化与边界处理这是保证递推起点的正确性。通常需要手动设置最小规模子问题的解。对于dp[0]、dp[0][0]这类起点要根据状态定义赋予合理的值通常是0或1也可能是无穷大。对于可能越界的访问如dp[i-1]当i0时要在循环中判断或者通过增加数组维度如上文的n1来规避。第四步确定计算顺序与输出答案计算顺序必须保证在计算dp[i]时它所依赖的所有子状态都已经被计算出来。对于线性DP通常是从左到右对于区间DP可能是按区间长度从小到大的顺序对于二维DP可能是按行或按列遍历。 最终答案不一定存储在dp数组的最后一个位置可能是数组中的最大值、最小值或者某个特定位置的值需要根据问题仔细分析。4.2 调试与优化技巧即使思路正确实现时也常会出错。以下是一些实用的调试技巧打印DP表这是最直观的方法。在代码中关键步骤后打印出整个dp数组或矩阵与你自己手动推导的前几行进行对比很容易发现哪里算错了。从小样例开始不要一上来就用复杂用例。先用题目给的示例甚至自己构造一个只有2-3个元素的极简例子手动算出答案再与程序输出对比。关注初始化很多错误源于初始化不对。反复确认dp[0]、dp[...][0]等边界值的设置是否符合状态定义。空间优化后的陷阱使用一维数组优化时务必再次确认遍历顺序正序/逆序是否正确这常常是错误的重灾区。关于优化除了前面提到的滚动数组空间优化还有状态定义优化有时可以通过改变状态定义来降低维度。例如LIS的 O(n log n) 解法。记忆化搜索自顶向下如果你觉得设计递推顺序很困难可以尝试递归记忆化的方式。用递归函数表达状态转移同时用一个缓存如字典或数组存储已经计算过的子问题结果。这通常更符合直觉但可能会有递归栈开销。它和自底向上的递推是等价的只是思考方向不同。5. 经典模型实战从编辑距离到股票买卖掌握了框架我们来看两个更复杂的经典模型它们能极大地拓宽你解决实际问题的能力。5.1 编辑距离双序列DP的典范编辑距离Levenshtein distance是衡量两个字符串相似度的经典算法。问题给定两个单词word1和word2计算将word1转换成word2所使用的最少操作次数。操作包括插入一个字符、删除一个字符、替换一个字符。状态定义dp[i][j]表示将word1的前i个字符转换成word2的前j个字符所需的最少操作次数。状态转移我们考虑对word1的第i个字符和word2的第j个字符注意索引从1开始代码中需处理偏移如果word1[i-1] word2[j-1]这两个字符相同不需要操作所以dp[i][j] dp[i-1][j-1]。如果字符不同我们有三种操作选择取最小值删除删除word1的第i个字符。那么就是用word1的前i-1个字符去匹配word2的前j个字符然后加上一次删除操作dp[i-1][j] 1。插入在word1的第i个位置后插入一个与word2[j]相同的字符。这等价于用word1的前i个字符去匹配word2的前j-1个字符然后加上一次插入操作dp[i][j-1] 1。可以理解为word2消耗了一个字符替换将word1的第i个字符替换成word2的第j个字符。那么就是用word1的前i-1个字符去匹配word2的前j-1个字符然后加上一次替换操作dp[i-1][j-1] 1。初始化dp[0][j] j将空字符串转换为word2的前j个字符需要j次插入。dp[i][0] i将word1的前i个字符转换为空字符串需要i次删除。def minDistance(word1, word2): m, n len(word1), len(word2) dp [[0] * (n 1) for _ in range(m 1)] # 初始化 for i in range(m 1): dp[i][0] i for j in range(n 1): dp[0][j] j # 状态转移 for i in range(1, m 1): for j in range(1, n 1): if word1[i-1] word2[j-1]: dp[i][j] dp[i-1][j-1] else: dp[i][j] min( dp[i-1][j] 1, # 删除 dp[i][j-1] 1, # 插入 dp[i-1][j-1] 1 # 替换 ) return dp[m][n]这个模型广泛应用于拼写检查、DNA序列比对、自然语言处理等领域。理解它你就掌握了处理两个序列关联问题的通用思路。5.2 股票买卖系列状态机DP的思维股票买卖问题是练习“状态机”DP思想的绝佳材料。我们以最经典的“买卖股票的最佳时机 IV限定交易k次”为例。问题给定股票价格数组prices你最多可以完成k笔交易买卖算一笔求最大利润。你不能同时参与多笔交易必须在再次购买前出售掉之前的股票。状态定义这是问题的难点。我们需要描述“天数”、“交易次数”和“持有状态”。定义两个三维实际上可以压缩为二维的状态数组dp[i][k][0]在第i天结束时最多进行了k次交易且不持有股票的最大利润。dp[i][k][1]在第i天结束时最多进行了k次交易且持有股票的最大利润。状态转移核心思想dp[i][k][0]今天不持有有两种可能昨天就不持有今天休息dp[i-1][k][0]昨天持有今天卖了完成一次交易dp[i-1][k][1] prices[i]取两者最大值dp[i][k][0] max(dp[i-1][k][0], dp[i-1][k][1] prices[i])dp[i][k][1]今天持有有两种可能昨天就持有今天休息dp[i-1][k][1]昨天不持有今天买了注意买入操作会开启一笔新交易所以交易次数上限k要减1dp[i-1][k-1][0] - prices[i]取两者最大值dp[i][k][1] max(dp[i-1][k][1], dp[i-1][k-1][0] - prices[i])初始化dp[0][...][0] 0第0天还没开始不持有股票利润为0。dp[0][...][1] -inf第0天不可能持有股票用负无穷表示不可能状态。dp[...][0][0] 0交易次数为0不持有股票利润为0。dp[...][0][1] -inf交易次数为0不可能持有股票。def maxProfit(k, prices): if not prices: return 0 n len(prices) # 如果k很大超过n/2相当于无限次交易可以用贪心简化 if k n // 2: return sum(max(prices[i] - prices[i-1], 0) for i in range(1, n)) # 初始化三维dp这里用两个二维数组分别表示持有和不持有 # dp0[k] 表示不持有dp1[k]表示持有 dp0 [0] * (k 1) dp1 [-float(inf)] * (k 1) for price in prices: # 注意这里需要倒序更新k因为dp1[k]依赖于dp0[k-1]昨天的值正序会覆盖 for k_idx in range(k, 0, -1): dp0[k_idx] max(dp0[k_idx], dp1[k_idx] price) # 卖 dp1[k_idx] max(dp1[k_idx], dp0[k_idx - 1] - price) # 买 # k0的情况dp0[0]始终为0dp1[0]始终为-inf无需更新 return dp0[k]这个模型的美妙之处在于通过定义“持有”和“不持有”两种状态清晰地刻画了所有可能的操作路径买、卖、休息。掌握了这个通用框架股票买卖的几乎所有变体冷冻期、手续费、无限次交易等都只是在这个状态转移方程上稍作修改。6. 从理论到实践如何训练DP思维与应对新题学了一堆模型遇到新题还是没思路这很正常。DP思维的培养需要时间和练习。训练方法分类刷题按专题刷。先把前面提到的几个经典模型LIS、背包、LCS/编辑距离、股票、打家劫舍、矩阵路径等的经典题目做熟理解每一道题的状态定义和转移方程的推导过程而不是背代码。画图分析对于复杂问题在纸上画出状态转移图。比如区间DP画一个矩阵标出dp[i][j]依赖于哪些子区间。对于状态机DP画出状态节点和操作边。总结归纳做完一道题问自己几个问题这道题和哪个经典模型相似状态定义为什么这样设计有没有其他定义方式转移方程的逻辑是什么初始化为什么那样设刻意练习“定义状态”拿到新题先别想方程花80%的时间思考如何定义状态。尝试多种定义看哪种能满足“无后效性”和“最优子结构”。应对新题的思考链路判断是否可用DP问题是否求最值最大、最小、最长、最短或计数暴力搜索的解空间是否巨大问题是否能被分解为相似的子问题尝试定义状态从问题的最后一步/最后一个元素开始思考。影响结果的关键变量有哪些通常是位置、次数、容量、状态等。尝试用这些变量组合成一个状态dp[...]。推导转移方程假设已知所有更小的子问题的解如何利用它们得到当前状态的解通常需要考虑在“最后一步”有哪些选择。确定初始与边界最小、最简单的情况下的解是什么验证与编码用一个小例子手动走一遍你的DP过程验证逻辑。然后编码并通过打印DP表来调试。最后分享一个我个人的深刻体会动态规划的难点往往不在于写出那个递推公式而在于第一步如何用一组简洁的变量准确地描述出我们所处的“局面”。这个“局面”就是状态。一旦状态定义对了方程常常是水到渠成的。所以下次卡壳时不妨回头再审视一下你的状态定义它很可能就是打开整个问题的钥匙。