数据结构与算法|第十八章:动态规划(上)--- 基础篇
- [第十八章 动态规划(上)--- 基础篇](#第十八章 动态规划(上)— 基础篇)
-
- [18.1 DP 的核心思想](#18.1 DP 的核心思想)
-
- [18.1.1 最优子结构(Optimal Substructure)](#18.1.1 最优子结构(Optimal Substructure))
- [18.1.2 重叠子问题(Overlapping Subproblems)](#18.1.2 重叠子问题(Overlapping Subproblems))
- [18.1.3 从斐波那契看 DP 的本质](#18.1.3 从斐波那契看 DP 的本质)
- [18.2 自顶向下 vs 自底向上](#18.2 自顶向下 vs 自底向上)
-
- [18.2.1 自顶向下(记忆化搜索 / Memoization)](#18.2.1 自顶向下(记忆化搜索 / Memoization))
- [18.2.2 自底向上(递推 / Tabulation)](#18.2.2 自底向上(递推 / Tabulation))
- [18.2.3 两种方式对比](#18.2.3 两种方式对比)
- [18.3 DP 解题五步法](#18.3 DP 解题五步法)
- [18.4 经典实战](#18.4 经典实战)
-
- [18.4.1 斐波那契数列](#18.4.1 斐波那契数列)
- [18.4.2 爬楼梯(Climbing Stairs)](#18.4.2 爬楼梯(Climbing Stairs))
- [18.4.3 不同路径(Unique Paths)](#18.4.3 不同路径(Unique Paths))
- [18.4.4 最小路径和(Minimum Path Sum)](#18.4.4 最小路径和(Minimum Path Sum))
- [18.4.5 打家劫舍(House Robber)](#18.4.5 打家劫舍(House Robber))
- [18.5 五个经典问题的对比总览](#18.5 五个经典问题的对比总览)
- 总结与预告
上篇:第十七章、贪心算法
第十八章 动态规划(上)--- 基础篇
上一章我们学习了贪心算法------一种"只看眼前、不回退"的策略。但我们也看到了它的致命缺陷:一旦贪心选择性质不成立,局部最优就无法导向全局最优。比如 0-1 背包问题------贪心会贪婪地拿性价比最高的物品,却可能因为"拿了 A 就装不下 B+C"而错失更优解。
这时,一个更强大的武器登场了:动态规划(Dynamic Programming,简称 DP)。
动态规划:将原问题分解为若干子问题 ,先求解子问题并保存结果 ,再通过这些结果推导出原问题的解 。如果子问题之间存在重叠(同一子问题被反复求解),DP 就通过"记忆化"避免重复计算,从而把指数级复杂度压缩到多项式级。
DP 是算法设计中的 "王者"------它是 LeetCode 中题频最高的算法类型,也是大厂面试的必考项。本章作为 DP 的上篇,将从核心思想出发,教你 DP 的两种实现方式、五步解题法,并通过五个入门级经典问题带你真正"上道"。
18.1 DP 的核心思想
动态规划的核心可以概括为两个概念:最优子结构 和重叠子问题。
18.1.1 最优子结构(Optimal Substructure)
最优子结构 :原问题的最优解,可以由其子问题的最优解构造出来。
换句话说,一个大规模问题的最优决策,依赖于小规模子问题的最优决策。这是 DP 能工作的必要条件------如果子问题的最优解无法组合成原问题的最优解,DP 就无从下手。
举例:从城市 A 到城市 C 的最短路径,如果必须经过城市 B,
那么这条最短路径 = (A→B 的最短路径) + (B→C 的最短路径)
这里"最短路径"就具有最优子结构性质。
18.1.2 重叠子问题(Overlapping Subproblems)
重叠子问题 :在递归求解过程中,同一个子问题被反复计算多次。
这是 DP 区别于分治法的关键------分治法(如归并排序)中,子问题是不重叠的(每个子数组只被处理一次)。而 DP 中,子问题大量重叠,如果不加缓存,计算量会爆炸式增长。
DP 的精髓就在于:每个子问题只计算一次,结果存入表格,后续直接查表。
18.1.3 从斐波那契看 DP 的本质
斐波那契数列是理解 DP 的最佳入口。定义:
F ( 0 ) = 0 , F ( 1 ) = 1 , F ( n ) = F ( n − 1 ) + F ( n − 2 ) ( n ≥ 2 ) F(0) = 0,\quad F(1) = 1,\quad F(n) = F(n-1) + F(n-2) \;\;(n \ge 2) F(0)=0,F(1)=1,F(n)=F(n−1)+F(n−2)(n≥2)
方法一:朴素递归(指数级爆炸)
F(5)
F(4)
F(3)
F(3)
F(2)
F(2)
F(1)
F(2)
F(1)
F(1)
F(0)
F(1)
F(0)
F(1)
F(0)
一眼看去,F(3) 被计算了 2 次 ,F(2) 被计算了 3 次!随着 n 增大,重复计算次数呈指数增长------时间复杂度 O(2ⁿ)。求 F(50) 需要约 2⁵⁰ ≈ 10¹⁵ 次运算,普通计算机需要跑几十年。
方法二:记忆化搜索(自顶向下 DP)------将计算过的结果存入数组,下次直接取。
方法三:递推(自底向上 DP)------从最小子问题 F(0)、F(1) 开始,一步步推到 F(n)。
从 O(2ⁿ) 到 O(n):DP 将斐波那契的时间复杂度直接压缩到线性级------这就是"记忆化"的威力。
18.2 自顶向下 vs 自底向上
DP 有两种等价的实现方式,它们都基于同一个状态转移方程,只是计算顺序不同。
18.2.1 自顶向下(记忆化搜索 / Memoization)
思路:从原问题出发,递归分解为子问题。
遇到已计算过的子问题,直接从缓存中取。
本质:递归 + 备忘录(数组 / HashMap)
java
/**
* 斐波那契 ------ 自顶向下(记忆化搜索)
* 时间复杂度:O(n) --- 每个子问题只计算一次
* 空间复杂度:O(n) --- 递归栈 + memo 数组
*/
public int fibMemo(int n, int[] memo) {
if (n <= 1) return n;
if (memo[n] != 0) return memo[n]; // 命中缓存
memo[n] = fibMemo(n - 1, memo) + fibMemo(n - 2, memo);
return memo[n];
}
18.2.2 自底向上(递推 / Tabulation)
思路:从最小的子问题开始,逐步计算更大的子问题。
用数组(或变量)存储中间结果。
本质:迭代 + 填表
java
/**
* 斐波那契 ------ 自底向上(递推 / 空间优化)
* 时间复杂度:O(n)
* 空间复杂度:O(1) --- 只保留前两个状态
*/
public int fibTabulation(int n) {
if (n <= 1) return n;
int prev2 = 0; // F(n-2)
int prev1 = 1; // F(n-1)
for (int i = 2; i <= n; i++) {
int curr = prev1 + prev2;
prev2 = prev1;
prev1 = curr;
}
return prev1;
}
18.2.3 两种方式对比
| 对比维度 | 自顶向下(记忆化搜索) | 自底向上(递推) |
|---|---|---|
| 实现方式 | 递归 + 备忘录 | 迭代 + 填表 |
| 计算顺序 | 从大问题分解到小问题 | 从小问题推导到大问题 |
| 栈开销 | 有递归栈开销(可能 StackOverflow) | 无递归,迭代执行 |
| 计算范围 | 只计算必要的子问题 | 计算所有子问题(有时浪费) |
| 空间优化 | 较难空间优化(需保留所有递归状态) | 容易压缩到 O(1) |
| 代码直观度 | 更接近数学定义,思路更自然 | 需要手动确定计算顺序 |
选型建议 :面试中优先写自底向上------避免递归栈溢出风险,且更容易空间优化。但如果递推顺序不明显(如博弈类 DP),自顶向下的记忆化搜索更加直观。
18.3 DP 解题五步法
掌握 DP 最关键的不是背代码,而是形成一套固定的思考框架。以下五步法适用于绝大多数 DP 问题:
┌─────────────────────────────────────────────────────┐
│ Step 1 定义状态(State) │
│ ───────────────────────────────────── │
│ 明确 dp[i] 或 dp[i][j] 的「物理含义」是什么 │
│ 例:dp[i] = 爬到第 i 级台阶的方法数 │
├─────────────────────────────────────────────────────┤
│ Step 2 推导状态转移方程(Transition) │
│ ───────────────────────────────────── │
│ 找出 dp[i] 与 dp[i-1]、dp[i-2] 等子问题的关系 │
│ 例:dp[i] = dp[i-1] + dp[i-2] │
├─────────────────────────────────────────────────────┤
│ Step 3 确定边界条件(Base Case) │
│ ───────────────────────────────────── │
│ 初始化最小子问题的值 │
│ 例:dp[0] = 1, dp[1] = 1 │
├─────────────────────────────────────────────────────┤
│ Step 4 确定计算顺序(Order) │
│ ───────────────────────────────────── │
│ 从小到大?从大到小?逐行还是逐列? │
│ 确保计算 dp[i] 时,依赖的子问题已经算出 │
│ 例:for i = 2 → n(从小到大) │
├─────────────────────────────────────────────────────┤
│ Step 5 考虑空间优化(Optimization) │
│ ───────────────────────────────────── │
│ 能否用滚动变量代替数组?O(n²)→O(n) 或 O(n)→O(1) │
└─────────────────────────────────────────────────────┘
记忆口诀 :"状、转、边、序、优"------状态定义定乾坤,转移方程是核心,边界条件保起步,计算顺序不逆行,空间优化锦上花。
18.4 经典实战
18.4.1 斐波那契数列
斐波那契是 DP 的"Hello World"。上面已经展示了记忆化搜索和递推两种写法,这里给出完整的自底向上 + 空间优化版本:
java
/**
* 斐波那契数列 ------ DP 自底向上(空间优化版)
* LeetCode 509
* 时间复杂度:O(n)
* 空间复杂度:O(1)
*/
public int fib(int n) {
if (n <= 1) return n;
int prev2 = 0, prev1 = 1;
for (int i = 2; i <= n; i++) {
int curr = prev1 + prev2;
prev2 = prev1;
prev1 = curr;
}
return prev1;
}
DP 视角的五步分析:
- 状态 :
dp[i]= 第 i 个斐波那契数- 转移 :
dp[i] = dp[i-1] + dp[i-2]- 边界 :
dp[0] = 0, dp[1] = 1- 顺序 :
i从 2 到 n- 优化 :只需保留
dp[i-2]和dp[i-1],用两个变量取代数组
18.4.2 爬楼梯(Climbing Stairs)
问题描述(LeetCode 70) :假设你正在爬楼梯。需要 n 阶才能到达楼顶。每次可以爬 1 阶 或 2 阶。你有多少种不同的方法可以爬到楼顶?
输入:n = 3
输出:3(方法:1+1+1, 1+2, 2+1)
DP 分析:到达第 i 阶的方法 = 到达第 i−1 阶的方法(再走 1 步)+ 到达第 i−2 阶的方法(再走 2 步)。
d p [ i ] = d p [ i − 1 ] + d p [ i − 2 ] dp[i] = dp[i-1] + dp[i-2] dp[i]=dp[i−1]+dp[i−2]
这和斐波那契数列完全一样 !唯一的区别是边界条件:dp[0]=1(地面算一种),dp[1]=1。
dp[0] = 1 ← 起点(地面)
dp[1] = 1 ← 只有一种方式:走 1 阶
dp[2] = 2 ← 1+1 或 2
dp[3] = 3 ← 1+1+1, 1+2, 2+1
dp[4] = 5 ← ...
java
/**
* 爬楼梯 ------ DP 自底向上(空间优化版)
* LeetCode 70
* 时间复杂度:O(n)
* 空间复杂度:O(1)
*/
public int climbStairs(int n) {
if (n <= 2) return n;
int prev2 = 1; // dp[0]
int prev1 = 1; // dp[1]
for (int i = 2; i <= n; i++) {
int curr = prev1 + prev2;
prev2 = prev1;
prev1 = curr;
}
return prev1;
}
五步分析 :状态
dp[i]为爬到第 i 阶的方法数;转移dp[i]=dp[i-1]+dp[i-2];边界dp[0]=1, dp[1]=1;顺序从小到大;优化到 O(1) 空间。
18.4.3 不同路径(Unique Paths)
问题描述(LeetCode 62) :一个机器人位于
m × n网格的左上角(下标 [0,0])。机器人每次只能向下 或向右移动一步。机器人试图达到网格的右下角(下标 [m-1, n-1])。问总共有多少条不同的路径?
输入:m = 3, n = 7
输出:28
DP 分析 :到达 (i, j) 的路径数 = 从上方 (i-1, j) 来的 + 从左边 (i, j-1) 来的。
d p [ i ] [ j ] = d p [ i − 1 ] [ j ] + d p [ i ] [ j − 1 ] dp[i][j] = dp[i-1][j] + dp[i][j-1] dp[i][j]=dp[i−1][j]+dp[i][j−1]

java
/**
* 不同路径 ------ DP 自底向上
* LeetCode 62
* 时间复杂度:O(m × n)
* 空间复杂度:O(n)(滚动数组优化为一行)
*/
public int uniquePaths(int m, int n) {
if (m <= 0 || n <= 0) return 0;
// dp[j] 表示到达当前行第 j 列的路径数
int[] dp = new int[n];
// 初始化第一行:全部为 1
for (int j = 0; j < n; j++) {
dp[j] = 1;
}
// 从第二行开始逐行计算
for (int i = 1; i < m; i++) {
for (int j = 1; j < n; j++) {
// dp[j](旧值)= 上一行同列 ← 即 dp[i-1][j]
// dp[j-1](新值)= 本行前一列 ← 即 dp[i][j-1]
dp[j] = dp[j] + dp[j - 1];
}
}
return dp[n - 1];
}
DP 五步分析 :状态
dp[i][j]为到达 (i, j) 的路径数;转移dp[i][j] = dp[i-1][j] + dp[i][j-1];边界第一行/列全为 1;逐行从左到右计算;优化为一维滚动数组 O(n)。
18.4.4 最小路径和(Minimum Path Sum)
问题描述(LeetCode 64) :给定一个包含非负整数的
m × n网格grid,找出一条从左上角到右下角的路径(只能向下或向右),使得路径上的数字总和最小。
输入:
grid = [[1, 3, 1],
[1, 5, 1],
[4, 2, 1]]
输出:7
解释:路径 1→3→1→1→1 的总和最小。
DP 分析 :到达 (i, j) 的最小路径和 = grid[i][j] + 从上方或左方来的较小路径和。
d p [ i ] [ j ] = g r i d [ i ] [ j ] + min ( d p [ i − 1 ] [ j ] , d p [ i ] [ j − 1 ] ) dp[i][j] = grid[i][j] + \min(dp[i-1][j],\; dp[i][j-1]) dp[i][j]=grid[i][j]+min(dp[i−1][j],dp[i][j−1])
与"不同路径"的区别在于:前者是求和 (所有路径数),后者是求 min(最短路径)。DP 框架完全一致,只是累加方式变了。
java
/**
* 最小路径和 ------ DP 自底向上(原地修改版)
* LeetCode 64
* 时间复杂度:O(m × n)
* 空间复杂度:O(1)(直接在原数组上修改)
*/
public int minPathSum(int[][] grid) {
if (grid == null || grid.length == 0) return 0;
int m = grid.length;
int n = grid[0].length;
// 第一列:只能从上方来
for (int i = 1; i < m; i++) {
grid[i][0] += grid[i - 1][0];
}
// 第一行:只能从左方来
for (int j = 1; j < n; j++) {
grid[0][j] += grid[0][j - 1];
}
// 其余位置:取上方和左方中较小的
for (int i = 1; i < m; i++) {
for (int j = 1; j < n; j++) {
grid[i][j] += Math.min(grid[i - 1][j], grid[i][j - 1]);
}
}
return grid[m - 1][n - 1];
}
DP 五步分析 :状态
dp[i][j]为到达 (i, j) 的最小路径和;转移dp[i][j] = grid[i][j] + min(dp[i-1][j], dp[i][j-1]);边界第一行/列累加;逐行从左到右;原地修改实现 O(1) 额外空间。
18.4.5 打家劫舍(House Robber)
问题描述(LeetCode 198) :你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素是相邻的房屋装有相互连通的防盗系统 ------如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。给定一个代表每个房屋存放金额的非负整数数组,计算你不触动警报装置的情况下,一夜之内能够偷窃到的最高金额。
输入:[1, 2, 3, 1]
输出:4(偷 1 号 + 3 号:1 + 3 = 4)
输入:[2, 7, 9, 3, 1]
输出:12(偷 1 号 + 3 号 + 5 号:2 + 9 + 1 = 12)
DP 分析 :对于第 i 间房屋,你有两种选择------偷 或不偷。
- 偷 i :则不能偷 i−1,收益 =
nums[i] + dp[i-2] - 不偷 i :收益 =
dp[i-1]
d p [ i ] = max ( d p [ i − 1 ] , n u m s [ i ] + d p [ i − 2 ] ) dp[i] = \max(dp[i-1],\; nums[i] + dp[i-2]) dp[i]=max(dp[i−1],nums[i]+dp[i−2])
这是 DP 的经典模式------"选或不选",也是 DP 区别于贪心的关键:贪心只能做"局部最优"的单一决策;DP 同时计算"偷"和"不偷"两种可能,取最大值。
java
/**
* 打家劫舍 ------ DP 自底向上(空间优化版)
* LeetCode 198
* 时间复杂度:O(n)
* 空间复杂度:O(1)
*/
public int rob(int[] nums) {
if (nums == null || nums.length == 0) return 0;
if (nums.length == 1) return nums[0];
// prev2 = dp[i-2], prev1 = dp[i-1]
int prev2 = nums[0];
int prev1 = Math.max(nums[0], nums[1]);
for (int i = 2; i < nums.length; i++) {
int curr = Math.max(prev1, nums[i] + prev2);
prev2 = prev1;
prev1 = curr;
}
return prev1;
}
DP 五步分析 :状态
dp[i]为偷前 i 间房屋的最高金额;转移dp[i] = max(dp[i-1], nums[i] + dp[i-2]);边界dp[0]=nums[0], dp[1]=max(nums[0], nums[1]);顺序从小到大;优化到 O(1) 空间。
18.5 五个经典问题的对比总览
| 问题 | 状态定义 | 状态转移方程 | 维度 | 时间 | 空间优化 |
|---|---|---|---|---|---|
| 斐波那契 | dp[i]:第 i 个数 |
dp[i]=dp[i-1]+dp[i-2] |
1D | O(n) | O(1) |
| 爬楼梯 | dp[i]:爬到第 i 阶的方法数 |
dp[i]=dp[i-1]+dp[i-2] |
1D | O(n) | O(1) |
| 不同路径 | dp[i][j]:到 (i,j) 的路径数 |
dp[i][j]=dp[i-1][j]+dp[i][j-1] |
2D | O(mn) | O(n) |
| 最小路径和 | dp[i][j]:到 (i,j) 的最小和 |
dp[i][j]=grid[i][j]+min(dp[i-1][j],dp[i][j-1]) |
2D | O(mn) | O(1) 原地 |
| 打家劫舍 | dp[i]:偷前 i 间的最高金额 |
dp[i]=max(dp[i-1],nums[i]+dp[i-2]) |
1D | O(n) | O(1) |
这五个问题虽然难度递增,但DP 框架完全一致。它们的共同模式是:
- 1D DP(斐波那契/爬楼梯/打家劫舍):依赖前 1~2 个状态 → O(1) 空间
- 2D DP(不同路径/最小路径和):依赖上方和左方 → 滚动数组优化
总结与预告
本章作为 DP 入门篇,系统学习了动态规划的基石:
- 18.1 核心思想:最优子结构(大问题依赖小问题)+ 重叠子问题(用缓存避免重复计算)。斐波那契从 O(2ⁿ) 到 O(n) 的蜕变,完美诠释 DP 的威力
- 18.2 两种实现:自顶向下(记忆化搜索)思路自然、自底向上(递推)空间更优。面试优先写递推
- 18.3 五步解题法:"状转边序优"------状态定义 → 转移方程 → 边界条件 → 计算顺序 → 空间优化
- 18.4 五大实战:从斐波那契到打家劫舍,覆盖了 1D 和 2D DP 的入门模式
DP 与贪心的关系回顾:
| 特征 | 贪心 | DP |
|---|---|---|
| 有"选或不选"的两难 | ✗(只看最优) | ✓(同时计算两种) |
| 打家劫舍 | ✗ 贪心无法处理"跳过" | ✓ max(偷, 不偷) |
| 爬楼梯 | ✗ 贪心不知走几步 | ✓ dp[i-1]+dp[i-2] |
下一章我们将进入动态规划(下)--- 进阶篇 ,挑战更复杂的问题:最长递增子序列(LIS)、最长公共子序列(LCS)、0-1 背包与完全背包、编辑距离、零钱兑换,以及区间 DP 和状态压缩 DP 等高级主题。背包问题将是下一章的重头戏------它正是贪心算法"失效"而 DP "大显身手"的经典战场。
上篇:第十七章、贪心算法