数据结构与算法|第十八章:动态规划(上)— 基础篇

数据结构与算法|第十八章:动态规划(上)--- 基础篇

  • [第十八章 动态规划(上)--- 基础篇](#第十八章 动态规划(上)— 基础篇)
    • [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 "大显身手"的经典战场。


上篇:第十七章、贪心算法

下篇:第十九章、动态规划(下)--- 进阶篇

相关推荐
guo_xiao_xiao_1 小时前
YOLOv11算法夜间机场跑道灯带目标检测数据集-900张-Airplane-1_5
算法·yolo·目标检测
Rabitebla1 小时前
从零实现 C++ List:带头循环双向链表的每一个细节
数据结构·c++·算法·leetcode·链表·list
ZPC82101 小时前
Linux / Ubuntu 隔离 CPU 核心 + ROS2 线程绑定
人工智能·算法·计算机视觉
学习3人组1 小时前
柔性排产:局部秒级重排 算法规划+内部拆分目标 详细对照表
算法·mes
shehuiyuelaiyuehao1 小时前
算法20,x的平方根
开发语言·python·算法
luoganttcc1 小时前
冯诺依曼体系有一天会被打破吗
算法·架构
V搜xhliang02461 小时前
【进阶篇】OpenClaw 高级技巧:定时任务 + 子 Agent + 自动化工作流
运维·人工智能·算法·microsoft·自动化
Allen_LVyingbo2 小时前
面向医疗群体智能的协同诊疗与群体决策支持系统(下)
开发语言·数据结构·windows·python·动态规划
玛卡巴卡ldf2 小时前
【LeetCode 手撕算法】(回溯)全排列DFS、子集、电话号码字母组合 九键、组合总和、括号生成、单词搜索、分割回文数
java·算法·leetcode·力扣