动态规划从入门到精通状态定义与转移方程的设计方法论一、动态规划为什么这么难——从看懂题解但不会做新题说起动态规划DP是算法面试中公认最难的题型之一。很多人的学习路径是看题解 → 觉得有道理 → 自己做新题 → 完全没有思路。这个困境的根源在于题解只给了这道题的状态定义和转移方程但没有解释为什么这样定义状态以及怎么从题目描述推导出状态定义。DP 的核心不是背模板而是掌握一套从问题到状态定义再到转移方程的推导方法论。本文将拆解这套方法论并通过三道经典 DP 题展示从零推导的完整过程。二、DP 求解的四步方法论2.1 四步法流程flowchart TD A[Step 1: 识别最优子结构] -- B[Step 2: 定义状态] B -- C[Step 3: 推导转移方程] C -- D[Step 4: 确定边界与遍历顺序] A -- A1[原问题的最优解br/包含子问题的最优解] B -- B1[状态 子问题的描述br/通常用 dp[i] 或 dp[i][j]] C -- C1[dp[i] 如何从更小的子问题br/递推得到] D -- D1[初始条件 遍历方向br/确保依赖已计算]2.2 Step 1识别最优子结构最优子结构是 DP 的前提。判断标准原问题的最优解是否可以由子问题的最优解组合而成。有最优子结构最短路径、最大子数组和、最长递增子序列无最优子结构最长简单路径因为子路径可能共享节点不满足独立性2.3 Step 2定义状态状态定义是 DP 最关键也最难的一步。常见策略问题类型状态定义模式示例线性序列dp[i] 以 i 结尾的最优值最长递增子序列区间问题dp[i][j] 区间 [i,j] 的最优值戳气球背包问题dp[i][w] 前 i 个物品、容量 w 的最优值0-1 背包路径问题dp[i][j] 从起点到 (i,j) 的最优值最小路径和状态定义的检验标准状态能唯一描述子问题转移方程能从更小的状态推出更大的状态最终答案能从某个状态直接得出2.4 Step 3推导转移方程转移方程的本质是当前状态可以从哪些更小的状态转移而来每种转移的代价/收益是什么2.5 Step 4确定边界与遍历顺序边界条件是 DP 的地基。遍历顺序必须保证计算 dp[i] 时它依赖的所有子状态已经计算完毕。flowchart LR A[一维 DP] -- B[从左到右遍历br/dp[0] 为边界] C[二维 DP - 路径] -- D[从上到下、从左到右br/dp[0][0] 为边界] E[二维 DP - 区间] -- F[先枚举区间长度br/再枚举左端点] G[二维 DP - 背包] -- H[外层物品、内层容量br/0-1 背包逆序遍历]三、三道经典 DP 题的完整推导3.1 最长递增子序列LeetCode 300Step 1 - 最优子结构以 nums[i] 结尾的 LIS其前驱一定是某个以 nums[j] 结尾的 LISj i 且 nums[j] nums[i]。Step 2 - 状态定义dp[i] 以 nums[i] 结尾的最长递增子序列的长度。Step 3 - 转移方程dp[i] max(dp[j] 1) for all j i where nums[j] nums[i]Step 4 - 边界dp[i] 1每个元素自身构成长度为 1 的子序列。def length_of_lis(nums: list[int]) - int: 最长递增子序列 - O(n^2) DP 解法。 dp[i] 表示以 nums[i] 结尾的 LIS 长度。 时间复杂度 O(n^2)空间复杂度 O(n)。 if not nums: return 0 n len(nums) dp [1] * n # 每个元素自身构成长度为 1 的子序列 for i in range(1, n): for j in range(i): if nums[j] nums[i]: # 如果 nums[j] 可以作为 nums[i] 的前驱尝试更新 dp[i] max(dp[i], dp[j] 1) return max(dp)3.2 0-1 背包问题Step 1 - 最优子结构前 i 个物品在容量 w 下的最大价值取决于第 i 个物品选或不选。Step 2 - 状态定义dp[i][w] 前 i 个物品、容量为 w 时的最大价值。Step 3 - 转移方程dp[i][w] max( dp[i-1][w], # 不选第 i 个物品 dp[i-1][w-weight[i]] value[i] # 选第 i 个物品 ) (前提: w weight[i])def knapsack_01( weights: list[int], values: list[int], capacity: int, ) - int: 0-1 背包问题 - 二维 DP 解法。 dp[i][w] 表示前 i 个物品、容量 w 时的最大价值。 时间复杂度 O(n*W)空间复杂度 O(n*W)。 n len(weights) # 初始化 (n1) x (capacity1) 的 DP 表 dp [[0] * (capacity 1) for _ in range(n 1)] for i in range(1, n 1): for w in range(capacity 1): # 不选第 i 个物品 dp[i][w] dp[i - 1][w] # 选第 i 个物品如果容量足够 if w weights[i - 1]: dp[i][w] max( dp[i][w], dp[i - 1][w - weights[i - 1]] values[i - 1], ) return dp[n][capacity]空间优化由于 dp[i] 只依赖 dp[i-1]可以压缩为一维数组但内层循环必须逆序遍历def knapsack_01_optimized( weights: list[int], values: list[int], capacity: int, ) - int: 0-1 背包 - 一维空间优化版。 内层逆序遍历保证每个物品只选一次。 时间复杂度 O(n*W)空间复杂度 O(W)。 dp [0] * (capacity 1) for i in range(len(weights)): # 逆序遍历防止同一物品被重复选取 for w in range(capacity, weights[i] - 1, -1): dp[w] max(dp[w], dp[w - weights[i]] values[i]) return dp[capacity]3.3 编辑距离LeetCode 72Step 1 - 最优子结构将 word1[0:i] 变成 word2[0:j] 的最少操作取决于最后一个字符的操作选择。Step 2 - 状态定义dp[i][j] word1 前 i 个字符变成 word2 前 j 个字符的最少操作数。Step 3 - 转移方程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, # 删除 word1[i-1] dp[i][j-1] 1, # 插入 word2[j-1] dp[i-1][j-1] 1, # 替换 word1[i-1] 为 word2[j-1] )def min_distance(word1: str, word2: str) - int: 编辑距离 - 二维 DP 解法。 dp[i][j] 表示 word1[:i] 变为 word2[:j] 的最少操作数。 时间复杂度 O(m*n)空间复杂度 O(m*n)。 m, n len(word1), len(word2) dp [[0] * (n 1) for _ in range(m 1)] # 边界空串变成长度为 j 的串需要 j 次插入 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]四、DP 的局限与常见陷阱状态定义不是唯一的同一道题可能有多种状态定义方式不同的定义导致不同的转移方程和复杂度。例如 LIS 可以定义以 i 结尾的 LIS 长度O(n^2)也可以用贪心二分O(n log n)后者本质上换了一种状态定义。维度爆炸当状态需要 3 个或更多维度时空间和时间都会急剧增长。例如区间 DP 的 dp[i][j][k] 三维表当 n 500 时就需要 1.25 亿个状态直接超时。此时需要寻找状态压缩或单调性优化。贪心 vs DP 的选择有些问题既可以用贪心也可以用 DP但贪心不一定正确。判断标准贪心需要证明局部最优能推出全局最优如果无法证明就用 DP。初始化错误DP 的边界条件是最容易出错的地方。常见的坑dp[0] 应该是 0 还是 1dp[i][0] 和 dp[0][j] 应该怎么设初始化错误会导致整个 DP 表的结果全错。五、总结动态规划的核心方法论是四步法识别最优子结构、定义状态、推导转移方程、确定边界与遍历顺序。其中状态定义是最关键的一步它决定了转移方程的形式和算法的复杂度。掌握常见问题类型的状态定义模式是快速解题的基础。落地路线建议按类型刷题先刷线性 DP再刷背包再刷区间 DP逐步提升。每道题都手写状态定义和转移方程不要直接看题解的代码。重点练习从题目描述推导状态定义的能力这是 DP 最难也最有价值的技能。遇到多维 DP 时先画二维表格手动填几个格子帮助理解状态依赖关系。
动态规划从入门到精通:状态定义与转移方程的设计方法论
发布时间:2026/6/29 10:49:20
动态规划从入门到精通状态定义与转移方程的设计方法论一、动态规划为什么这么难——从看懂题解但不会做新题说起动态规划DP是算法面试中公认最难的题型之一。很多人的学习路径是看题解 → 觉得有道理 → 自己做新题 → 完全没有思路。这个困境的根源在于题解只给了这道题的状态定义和转移方程但没有解释为什么这样定义状态以及怎么从题目描述推导出状态定义。DP 的核心不是背模板而是掌握一套从问题到状态定义再到转移方程的推导方法论。本文将拆解这套方法论并通过三道经典 DP 题展示从零推导的完整过程。二、DP 求解的四步方法论2.1 四步法流程flowchart TD A[Step 1: 识别最优子结构] -- B[Step 2: 定义状态] B -- C[Step 3: 推导转移方程] C -- D[Step 4: 确定边界与遍历顺序] A -- A1[原问题的最优解br/包含子问题的最优解] B -- B1[状态 子问题的描述br/通常用 dp[i] 或 dp[i][j]] C -- C1[dp[i] 如何从更小的子问题br/递推得到] D -- D1[初始条件 遍历方向br/确保依赖已计算]2.2 Step 1识别最优子结构最优子结构是 DP 的前提。判断标准原问题的最优解是否可以由子问题的最优解组合而成。有最优子结构最短路径、最大子数组和、最长递增子序列无最优子结构最长简单路径因为子路径可能共享节点不满足独立性2.3 Step 2定义状态状态定义是 DP 最关键也最难的一步。常见策略问题类型状态定义模式示例线性序列dp[i] 以 i 结尾的最优值最长递增子序列区间问题dp[i][j] 区间 [i,j] 的最优值戳气球背包问题dp[i][w] 前 i 个物品、容量 w 的最优值0-1 背包路径问题dp[i][j] 从起点到 (i,j) 的最优值最小路径和状态定义的检验标准状态能唯一描述子问题转移方程能从更小的状态推出更大的状态最终答案能从某个状态直接得出2.4 Step 3推导转移方程转移方程的本质是当前状态可以从哪些更小的状态转移而来每种转移的代价/收益是什么2.5 Step 4确定边界与遍历顺序边界条件是 DP 的地基。遍历顺序必须保证计算 dp[i] 时它依赖的所有子状态已经计算完毕。flowchart LR A[一维 DP] -- B[从左到右遍历br/dp[0] 为边界] C[二维 DP - 路径] -- D[从上到下、从左到右br/dp[0][0] 为边界] E[二维 DP - 区间] -- F[先枚举区间长度br/再枚举左端点] G[二维 DP - 背包] -- H[外层物品、内层容量br/0-1 背包逆序遍历]三、三道经典 DP 题的完整推导3.1 最长递增子序列LeetCode 300Step 1 - 最优子结构以 nums[i] 结尾的 LIS其前驱一定是某个以 nums[j] 结尾的 LISj i 且 nums[j] nums[i]。Step 2 - 状态定义dp[i] 以 nums[i] 结尾的最长递增子序列的长度。Step 3 - 转移方程dp[i] max(dp[j] 1) for all j i where nums[j] nums[i]Step 4 - 边界dp[i] 1每个元素自身构成长度为 1 的子序列。def length_of_lis(nums: list[int]) - int: 最长递增子序列 - O(n^2) DP 解法。 dp[i] 表示以 nums[i] 结尾的 LIS 长度。 时间复杂度 O(n^2)空间复杂度 O(n)。 if not nums: return 0 n len(nums) dp [1] * n # 每个元素自身构成长度为 1 的子序列 for i in range(1, n): for j in range(i): if nums[j] nums[i]: # 如果 nums[j] 可以作为 nums[i] 的前驱尝试更新 dp[i] max(dp[i], dp[j] 1) return max(dp)3.2 0-1 背包问题Step 1 - 最优子结构前 i 个物品在容量 w 下的最大价值取决于第 i 个物品选或不选。Step 2 - 状态定义dp[i][w] 前 i 个物品、容量为 w 时的最大价值。Step 3 - 转移方程dp[i][w] max( dp[i-1][w], # 不选第 i 个物品 dp[i-1][w-weight[i]] value[i] # 选第 i 个物品 ) (前提: w weight[i])def knapsack_01( weights: list[int], values: list[int], capacity: int, ) - int: 0-1 背包问题 - 二维 DP 解法。 dp[i][w] 表示前 i 个物品、容量 w 时的最大价值。 时间复杂度 O(n*W)空间复杂度 O(n*W)。 n len(weights) # 初始化 (n1) x (capacity1) 的 DP 表 dp [[0] * (capacity 1) for _ in range(n 1)] for i in range(1, n 1): for w in range(capacity 1): # 不选第 i 个物品 dp[i][w] dp[i - 1][w] # 选第 i 个物品如果容量足够 if w weights[i - 1]: dp[i][w] max( dp[i][w], dp[i - 1][w - weights[i - 1]] values[i - 1], ) return dp[n][capacity]空间优化由于 dp[i] 只依赖 dp[i-1]可以压缩为一维数组但内层循环必须逆序遍历def knapsack_01_optimized( weights: list[int], values: list[int], capacity: int, ) - int: 0-1 背包 - 一维空间优化版。 内层逆序遍历保证每个物品只选一次。 时间复杂度 O(n*W)空间复杂度 O(W)。 dp [0] * (capacity 1) for i in range(len(weights)): # 逆序遍历防止同一物品被重复选取 for w in range(capacity, weights[i] - 1, -1): dp[w] max(dp[w], dp[w - weights[i]] values[i]) return dp[capacity]3.3 编辑距离LeetCode 72Step 1 - 最优子结构将 word1[0:i] 变成 word2[0:j] 的最少操作取决于最后一个字符的操作选择。Step 2 - 状态定义dp[i][j] word1 前 i 个字符变成 word2 前 j 个字符的最少操作数。Step 3 - 转移方程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, # 删除 word1[i-1] dp[i][j-1] 1, # 插入 word2[j-1] dp[i-1][j-1] 1, # 替换 word1[i-1] 为 word2[j-1] )def min_distance(word1: str, word2: str) - int: 编辑距离 - 二维 DP 解法。 dp[i][j] 表示 word1[:i] 变为 word2[:j] 的最少操作数。 时间复杂度 O(m*n)空间复杂度 O(m*n)。 m, n len(word1), len(word2) dp [[0] * (n 1) for _ in range(m 1)] # 边界空串变成长度为 j 的串需要 j 次插入 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]四、DP 的局限与常见陷阱状态定义不是唯一的同一道题可能有多种状态定义方式不同的定义导致不同的转移方程和复杂度。例如 LIS 可以定义以 i 结尾的 LIS 长度O(n^2)也可以用贪心二分O(n log n)后者本质上换了一种状态定义。维度爆炸当状态需要 3 个或更多维度时空间和时间都会急剧增长。例如区间 DP 的 dp[i][j][k] 三维表当 n 500 时就需要 1.25 亿个状态直接超时。此时需要寻找状态压缩或单调性优化。贪心 vs DP 的选择有些问题既可以用贪心也可以用 DP但贪心不一定正确。判断标准贪心需要证明局部最优能推出全局最优如果无法证明就用 DP。初始化错误DP 的边界条件是最容易出错的地方。常见的坑dp[0] 应该是 0 还是 1dp[i][0] 和 dp[0][j] 应该怎么设初始化错误会导致整个 DP 表的结果全错。五、总结动态规划的核心方法论是四步法识别最优子结构、定义状态、推导转移方程、确定边界与遍历顺序。其中状态定义是最关键的一步它决定了转移方程的形式和算法的复杂度。掌握常见问题类型的状态定义模式是快速解题的基础。落地路线建议按类型刷题先刷线性 DP再刷背包再刷区间 DP逐步提升。每道题都手写状态定义和转移方程不要直接看题解的代码。重点练习从题目描述推导状态定义的能力这是 DP 最难也最有价值的技能。遇到多维 DP 时先画二维表格手动填几个格子帮助理解状态依赖关系。