从回溯到动态规划

动态规划(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. 优化空间(可选):用滚动数组或压缩维度减少空间复杂度。

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

相关推荐
日暮南城故里11 分钟前
常用的排序算法------练习4
java·数据结构·算法
电科_银尘14 分钟前
【Matlab】-- 基于MATLAB的灰狼算法优化支持向量机的回归算法
算法·支持向量机·matlab
梭七y43 分钟前
【力扣hot100题】(017)矩阵置零
算法·leetcode·矩阵
podongfeng1 小时前
leetcode每日一题:数组美丽值求和
java·算法·leetcode·数组·前后缀
hanpfei1 小时前
PipeWire 音频设计与实现分析二——SPA 插件
算法·音视频
董董灿是个攻城狮1 小时前
Transformer 通关秘籍6:词汇表:文本到数值的转换
算法
₍˄·͈༝·͈˄*₎◞ ̑̑码1 小时前
数组的定义与使用
数据结构·python·算法
Fanxt_Ja2 小时前
【LeetCode】算法详解#2 ---和为k的子数组
java·数据结构·算法·leetcode·idea·哈希表
熊峰峰2 小时前
1.3 斐波那契数列模型:LeetCode 746. 使用最小花费爬楼梯
算法·leetcode·动态规划
藍海琴泉2 小时前
贪心算法经典应用:最优答疑调度策略详解与Python实现
算法·贪心算法