💎 动态规划(DP):用空间换时间的艺术,面试难点突破!

67 阅读7分钟

"动态规划就像走迷宫时做标记,走过的路不用再走第二遍!" 🧭


🤔 什么是动态规划?

生活中的例子:爬楼梯

问题:爬10层楼梯,每次可以爬1层或2层,有多少种爬法?

暴力递归(会超时)

10层 = 爬9层的方法数 + 爬8层的方法数
         ↓                 ↓
      爬8层+爬7层        爬7层+爬6层
      
重复计算了很多次!😱

比如"爬7层"被计算了2"爬6层"被计算了3次
...

动态规划(用数组记录)

用dp数组记录结果:
dp[1] = 1  (爬1层:1种方法)
dp[2] = 2  (爬2层:2种方法)
dp[3] = dp[1] + dp[2] = 3
dp[4] = dp[2] + dp[3] = 5
...
dp[10] = dp[8] + dp[9] = 89 ✅

时间:O(n),超快!⚡

🎯 动态规划的核心思想

三要素

  1. 重叠子问题 - 有重复计算
  2. 最优子结构 - 大问题的最优解包含小问题的最优解
  3. 状态转移方程 - 找到递推关系

解题步骤

1步:定义状态
  dp[i]表示什么?

第2步:找状态转移方程
  dp[i]和dp[i-1]、dp[i-2]...的关系?

第3步:初始化
  dp[0]、dp[1]是什么?

第4步:确定遍历顺序
  从前往后?从后往前?

第5步:返回结果
  dp[n]max(dp)?

📚 经典DP问题

1. 斐波那契数列 ⭐

问题:f(n) = f(n-1) + f(n-2),f(0)=0, f(1)=1

// 递归(会超时)
int fib(int n) {
    if (n <= 1) return n;
    return fib(n-1) + fib(n-2);
}
// 时间:O(2^n) 😱

// DP(自底向上)
int fib(int n) {
    if (n <= 1) return n;
    
    int[] dp = new int[n + 1];
    dp[0] = 0;
    dp[1] = 1;
    
    for (int i = 2; i <= n; i++) {
        dp[i] = dp[i-1] + dp[i-2];
    }
    
    return dp[n];
}
// 时间:O(n) ⚡

// 空间优化(只需要两个变量)
int fib(int n) {
    if (n <= 1) return n;
    
    int prev = 0, curr = 1;
    
    for (int i = 2; i <= n; i++) {
        int next = prev + curr;
        prev = curr;
        curr = next;
    }
    
    return curr;
}
// 空间:O(1) 👍

2. 爬楼梯(LeetCode 70)⭐⭐

public int climbStairs(int n) {
    if (n <= 2) return n;
    
    int[] dp = new int[n + 1];
    dp[1] = 1;
    dp[2] = 2;
    
    for (int i = 3; i <= n; i++) {
        dp[i] = dp[i-1] + dp[i-2];
    }
    
    return dp[n];
}

// 状态转移方程:dp[i] = dp[i-1] + dp[i-2]
// 含义:到第i层 = 从第i-1层跨1步 + 从第i-2层跨2步

3. 打家劫舍(LeetCode 198)⭐⭐⭐

问题:小偷不能偷相邻的房子,求最大金额

房子:[2, 7, 9, 3, 1]
     
不能偷相邻的,怎么偷最多?

分析:
偷第0个房子:2
偷第1个房子:7(不偷第0个)
偷第2个房子:2+9=11(偷第0个+第2个)
偷第3个房子:max(7+3, 11)=11(第1个+第3个 或 只偷到第2个)
偷第4个房子:max(11+1, 11)=12(第2个+第4个 或 只偷到第3个)

最多偷12!✅
public int rob(int[] nums) {
    int n = nums.length;
    if (n == 0) return 0;
    if (n == 1) return nums[0];
    
    int[] dp = new int[n];
    dp[0] = nums[0];
    dp[1] = Math.max(nums[0], nums[1]);
    
    for (int i = 2; i < n; i++) {
        // 偷第i个 或 不偷第i个
        dp[i] = Math.max(dp[i-2] + nums[i], dp[i-1]);
    }
    
    return dp[n-1];
}

// 状态转移方程:dp[i] = max(dp[i-2] + nums[i], dp[i-1])
// 偷第i个房子 vs 不偷第i个房子

4. 零钱兑换(LeetCode 322)⭐⭐⭐

问题:凑出金额需要最少多少个硬币?

硬币:[1, 2, 5]
金额:11

分析:
amount=1: 1个硬币(1)
amount=2: 1个硬币(2)
amount=3: 2个硬币(1+2)
amount=4: 2个硬币(2+2)
amount=5: 1个硬币(5)
amount=6: 2个硬币(5+1)
...
amount=11: 3个硬币(5+5+1) ✅
public int coinChange(int[] coins, int amount) {
    int[] dp = new int[amount + 1];
    Arrays.fill(dp, amount + 1);  // 初始化为一个大值
    dp[0] = 0;  // 金额0需要0个硬币
    
    for (int i = 1; i <= amount; i++) {
        for (int coin : coins) {
            if (i >= coin) {
                dp[i] = Math.min(dp[i], dp[i - coin] + 1);
            }
        }
    }
    
    return dp[amount] > amount ? -1 : dp[amount];
}

// 状态转移方程:dp[i] = min(dp[i], dp[i-coin] + 1)
// 含义:凑出金额i = 凑出金额(i-coin) + 1个硬币coin

5. 最长递增子序列 LIS(LeetCode 300)⭐⭐⭐⭐

问题:找出数组中最长的递增子序列

数组:[10, 9, 2, 5, 3, 7, 101, 18]

最长递增子序列:[2, 3, 7, 101][2, 3, 7, 18]
长度:4
public int lengthOfLIS(int[] nums) {
    int n = nums.length;
    int[] dp = new int[n];
    Arrays.fill(dp, 1);  // 每个元素本身长度为1
    
    int maxLen = 1;
    
    for (int i = 1; i < n; i++) {
        for (int j = 0; j < i; j++) {
            if (nums[i] > nums[j]) {
                dp[i] = Math.max(dp[i], dp[j] + 1);
            }
        }
        maxLen = Math.max(maxLen, dp[i]);
    }
    
    return maxLen;
}

// dp[i]:以nums[i]结尾的最长递增子序列长度
// 状态转移:dp[i] = max(dp[j] + 1) where j < i and nums[j] < nums[i]
// 时间:O(n²)

6. 0-1背包问题 ⭐⭐⭐⭐⭐

问题:背包容量W,有n个物品,每个物品有重量w[i]和价值v[i],求最大价值

背包容量:4
物品:
  重量:[1, 3, 4]
  价值:[15, 20, 30]

分析:
背包容量4:
  选物品0(重115) + 物品1(重320) = 重435 ✅
  或选物品2(重430) = 重430

最大价值:35
public int knapsack(int W, int[] weights, int[] values) {
    int n = weights.length;
    int[][] dp = new int[n + 1][W + 1];
    
    // dp[i][w]:前i个物品,容量为w时的最大价值
    for (int i = 1; i <= n; i++) {
        for (int w = 1; w <= W; w++) {
            if (weights[i-1] <= w) {
                // 可以选第i个物品
                dp[i][w] = Math.max(
                    dp[i-1][w],                          // 不选第i个
                    dp[i-1][w-weights[i-1]] + values[i-1] // 选第i个
                );
            } else {
                // 不能选第i个物品
                dp[i][w] = dp[i-1][w];
            }
        }
    }
    
    return dp[n][W];
}

// 状态转移方程:
// dp[i][w] = max(dp[i-1][w], dp[i-1][w-weights[i]] + values[i])

空间优化(一维数组)

public int knapsack(int W, int[] weights, int[] values) {
    int n = weights.length;
    int[] dp = new int[W + 1];
    
    for (int i = 0; i < n; i++) {
        // 从后往前遍历(避免重复使用)
        for (int w = W; w >= weights[i]; w--) {
            dp[w] = Math.max(dp[w], dp[w - weights[i]] + values[i]);
        }
    }
    
    return dp[W];
}
// 空间:O(W) 👍

7. 最长公共子序列 LCS(LeetCode 1143)⭐⭐⭐⭐

问题:找出两个字符串的最长公共子序列

s1 = "abcde"
s2 = "ace"

LCS = "ace"
长度:3 ✅
public int longestCommonSubsequence(String text1, String text2) {
    int m = text1.length();
    int n = text2.length();
    int[][] dp = new int[m + 1][n + 1];
    
    for (int i = 1; i <= m; i++) {
        for (int j = 1; j <= n; j++) {
            if (text1.charAt(i-1) == text2.charAt(j-1)) {
                dp[i][j] = dp[i-1][j-1] + 1;  // 字符相同
            } else {
                dp[i][j] = Math.max(dp[i-1][j], dp[i][j-1]);
            }
        }
    }
    
    return dp[m][n];
}

// dp[i][j]:text1[0..i-1]和text2[0..j-1]的LCS长度
// 状态转移:
//   if text1[i-1] == text2[j-1]: dp[i][j] = dp[i-1][j-1] + 1
//   else: dp[i][j] = max(dp[i-1][j], dp[i][j-1])

8. 编辑距离(LeetCode 72)⭐⭐⭐⭐⭐

问题:将word1转换成word2的最少操作次数(插入、删除、替换)

word1 = "horse"
word2 = "ros"

操作:
1. horse → rorse (替换h为r)
2. rorse → rose  (删除r)
3. rose  → ros   (删除e)

最少3步 ✅
public int minDistance(String word1, String word2) {
    int m = word1.length();
    int n = word2.length();
    int[][] dp = new int[m + 1][n + 1];
    
    // 初始化
    for (int i = 0; i <= m; i++) dp[i][0] = i;  // word1删除所有字符
    for (int j = 0; j <= n; j++) dp[0][j] = j;  // word1插入所有字符
    
    for (int i = 1; i <= m; i++) {
        for (int j = 1; j <= n; j++) {
            if (word1.charAt(i-1) == word2.charAt(j-1)) {
                dp[i][j] = dp[i-1][j-1];  // 字符相同,不需要操作
            } else {
                dp[i][j] = Math.min(
                    Math.min(
                        dp[i-1][j] + 1,    // 删除
                        dp[i][j-1] + 1     // 插入
                    ),
                    dp[i-1][j-1] + 1       // 替换
                );
            }
        }
    }
    
    return dp[m][n];
}

🎯 DP的套路总结

线性DP

一维数组:
dp[i] 表示以第i个元素为结尾的...
例子:最长递增子序列、打家劫舍

二维数组:
dp[i][j] 表示...
例子:背包问题、编辑距离、LCS

区间DP

dp[i][j] 表示区间[i, j]的...
例子:最长回文子串

状态压缩DP

用二进制表示状态
例子:旅行商问题

💡 DP优化技巧

1. 空间优化

// 二维数组 → 一维数组
// 背包问题、LCS等

// 原始:dp[i][j]
// 优化:dp[j] (滚动数组)

2. 记忆化搜索

// 递归 + 备忘录
int[] memo = new int[n];
Arrays.fill(memo, -1);

int dfs(int i) {
    if (memo[i] != -1) return memo[i];
    
    // 计算结果
    int result = ...;
    
    memo[i] = result;
    return result;
}

📝 总结

🎓 记忆口诀

动态规划有三要,
重叠子问题找到。
最优子结构要有,
状态转移方程巧。
先定义状态含义,
再找递推关系。
初始化要清楚,
遍历顺序别忘记。
背包LCS经典题,
编辑距离也要会!

核心模板

// DP模板
public int dp(参数) {
    // 1. 定义dp数组
    int[] dp = new int[n];
    
    // 2. 初始化
    dp[0] = ...;
    
    // 3. 状态转移
    for (int i = 1; i < n; i++) {
        dp[i] = f(dp[i-1], dp[i-2], ...);
    }
    
    // 4. 返回结果
    return dp[n-1];
}

恭喜你!🎉 你已经攻克了动态规划这个难点!

记住:DP就是用空间换时间,把计算过的结果存起来! 💎


📌 重点记忆:背包问题、LCS、编辑距离

🤔 思考题:什么问题适合用DP?

(答案:有重叠子问题和最优子结构的问题!)