从回溯到动态规划

动态规划(DP)的套路本质上是通过 定义状态 + 状态转移 ,利用 子问题的解 逐步构建原问题的解。它的核心是 避免重复计算(类似缓存),将指数级复杂度优化到多项式级别。下面通过一段预处理代码和经典例子,拆解每一步的思考过程,总结出通用套路。


一、以预处理回文的代码为例,拆解动态规划的思路

下面的代码段中,dp[i][j] 表示子串 s[i...j] 是否是回文(出自LeetCode 131):

java 复制代码
boolean[][] dp = new boolean[n][n];
for (int i = n - 1; i >= 0; i--) {
    for (int j = i; j < n; j++) {
        if (i == j) {
            dp[i][j] = true;
        } else if (s.charAt(i) == s.charAt(j)) {
            dp[i][j] = (j - i == 1) || dp[i + 1][j - 1];
        }
    }
}

1. 定义状态(关键步骤!)

  • 问题 :判断子串 s[i...j] 是否是回文。
  • 定义状态dp[i][j] = true 表示 s[i...j] 是回文,否则为 false
  • 为什么这么定义 :回文的判断有 重叠子问题 。例如,判断 s[0..4] 是否是回文时,可能需要判断 s[1..3] 是否是回文,而 s[1..3] 可能在之前已经被计算过。

2. 状态转移方程(递推关系)

  • 基础情况
    • 单字符:i == j 时,dp[i][j] = true(单个字符是回文)。
    • 双字符:j - i == 1s[i] == s[j],则 dp[i][j] = true
  • 递推关系
    • 如果 s[i] == s[j],且内部的子串 s[i+1...j-1] 是回文(即 dp[i+1][j-1] == true),那么 s[i...j] 也是回文。
    • 即:dp[i][j] = (s[i] == s[j]) && dp[i+1][j-1]

3. 计算顺序(避免脏数据)

  • i 从后往前遍历 :因为 dp[i][j] 依赖于 dp[i+1][j-1](即左下方的值),必须保证在计算 dp[i][j] 时,dp[i+1][j-1] 已经被计算过。
  • j 从 i 到末尾遍历 :因为 j 必须大于等于 i(子串的结束位置不能小于起始位置)。

4. 初始化

  • 所有 i > j 的情况无意义(默认 false)。
  • 单字符情况 i == j 直接初始化为 true

二、动态规划的通用套路

动态规划的解题步骤可总结为以下模板:

1. 判断问题是否适用 DP

  • 重叠子问题 :问题可以被分解为多个子问题,且子问题之间存在重复计算。
    • 例如:斐波那契数列 f(n) = f(n-1) + f(n-2),计算 f(5) 时需要多次计算 f(3)
  • 最优子结构 :问题的最优解包含子问题的最优解。
    • 例如:最短路径问题中,从 A 到 C 的最短路径必须经过 B,那么 A 到 B 的路径也必须是当前状态下的最短路径。

2. 定义状态

  • 状态定义 :用一个或多个变量描述问题的某个阶段。
    • 一维状态:例如 dp[i] 表示前 i 个元素的最优解。
    • 二维状态:例如 dp[i][j] 表示从位置 ij 的最优解。
    • 更高维状态:根据问题复杂度增加维度。
  • 关键技巧 :状态的定义要能 涵盖所有可能的情况 ,同时尽量 减少冗余

3. 推导状态转移方程

  • 从子问题到原问题 :找到 dp[i]dp[i-1]dp[i-2] 等之间的关系。
  • 经典例子
    • 斐波那契数列:dp[i] = dp[i-1] + dp[i-2]
    • 背包问题:dp[i][w] = max(dp[i-1][w], dp[i-1][w - weight[i]] + value[i])
    • 最长递增子序列:dp[i] = max(dp[j] + 1),其中 j < inums[j] < nums[i]

4. 初始化与边界条件

  • 初始化 :设置初始状态的值(如 dp[0] = 0)。
  • 边界条件 :处理 i < 0j > n 等越界情况。

5. 确定计算顺序

  • 自底向上 :从基础子问题开始,逐步计算更大规模的子问题。
    • 例如:先计算 dp[0]dp[1],再计算 dp[2]
  • 自顶向下(记忆化搜索) :递归计算,但缓存已计算的子问题。
    • 例如:用备忘录(Memoization)记录 dp[i] 的值。

6. 空间优化(可选)

  • 滚动数组:如果 dp[i] 只依赖于前几个状态,可用滚动数组压缩空间。
    • 例如:斐波那契数列只需保存 dp[i-1]dp[i-2]

三、如何识别动态规划问题?

1. 常见题型

  • 求最值(最大值、最小值、最长、最短等)。
  • 计数问题(有多少种方式、路径等)。
  • 判断是否可行(能否分割、能否到达等)。

2. 题目特征

  • 问题可分解为多个阶段,每个阶段的决策影响后续阶段。
  • 问题有明显的 重叠子问题最优子结构

3. 与回溯的对比

  • 回溯:暴力穷举所有可能,时间复杂度高(指数级)。
  • 动态规划:通过缓存子问题解,避免重复计算,时间复杂度低(多项式级)。

四、经典例题强化套路

1. 爬楼梯(一维 DP)

  • 问题 :每次爬 1 或 2 阶,到第 n 阶有多少种方法?
  • 状态定义dp[i] 表示到第 i 阶的方法数。
  • 转移方程dp[i] = dp[i-1] + dp[i-2]
  • 初始化dp[0] = 1, dp[1] = 1

2. 最长公共子序列(二维 DP)

  • 问题:两个字符串的最长公共子序列长度。
  • 状态定义dp[i][j] 表示 s1[0..i-1]s2[0..j-1] 的最长公共子序列。
  • 转移方程
    • 如果 s1[i-1] == s2[j-1],则 dp[i][j] = dp[i-1][j-1] + 1
    • 否则,dp[i][j] = max(dp[i-1][j], dp[i][j-1])

五、总结

动态规划的套路可以总结为:

  1. 定义状态 :明确 dp[i][j]dp[i] 的含义。
  2. 推导转移方程:找到如何用子问题的解构建当前问题的解。
  3. 确定计算顺序 :确保在计算 dp[i][j] 时,所需子问题已被计算。
  4. 初始化和边界处理:设置初始值,处理特殊情况。
  5. 优化空间(可选):用滚动数组或压缩维度减少空间复杂度。

想要熟练掌握动态规划,需要多练习经典题型(如背包问题、最长子序列、编辑距离等),并尝试将问题抽象为状态和转移方程。一开始可能会觉得难以找到状态定义,但随着经验积累,你会逐渐形成直觉。

相关推荐
机器学习之心6 分钟前
198种组合算法+优化TCN-Transformer+SHAP分析+新数据预测+多输出!深度学习可解释分析,强烈安利,粉丝必备!
深度学习·算法·transformer·shap分析·新数据预测
狐577 分钟前
2026-01-12-LeetCode刷题笔记-1266-访问所有点的最小时间.md
笔记·算法·leetcode
Gorgous—l7 分钟前
数据结构算法学习:LeetCode热题100-栈篇(有效的括号、最小栈、字符串解码、每日温度、柱状图中最大的矩形)
数据结构·学习·算法
小郭团队8 分钟前
教育公平的探索
大数据·人工智能·嵌入式硬件·算法·硬件架构
瑞雨溪13 分钟前
力扣题解:740.删除并获得点数
算法·leetcode·职场和发展
LeeeX!15 分钟前
基于YOLO11实现明厨亮灶系统实时检测【多场景数据+模型训练、推理、导出】
深度学习·算法·目标检测·数据集·明厨亮灶
红队it16 分钟前
【Spark+Hadoop】基于spark+hadoop游戏评论数据分析可视化大屏(完整系统源码+数据库+开发笔记+详细部署教程+虚拟机分布式启动教程)✅
大数据·hadoop·分布式·算法·游戏·数据分析·spark
程序员-King.16 分钟前
day125—二分查找—寻找峰值(LeetCode-162)
算法·leetcode·职场和发展
qianbo_insist17 分钟前
基于APAP算法的图像和视频拼接
算法·数学建模·图像拼接
老鼠只爱大米17 分钟前
LeetCode算法题详解 3:无重复字符的最长子串
算法·leetcode·面试题·滑动窗口·无重复字符的最长子串·最长子串