深入浅出Java算法 动态规划

176 阅读5分钟

深入浅出Java算法 动态规划

动态规划(DP)是算法面试中最常考也最难掌握的部分,今天我就用最通俗的大白话,帮你彻底搞懂动态规划的套路。

一、动态规划是啥?

动态规划就像你打游戏时的存档点:

  1. 把大问题拆成小问题(关卡分解)
  2. 记住已经解决过的小问题(存档)
  3. 遇到相同小问题直接读取结果(读档)
  4. 避免重复计算(省时间)

二、动态规划三大特征

  1. 重叠子问题:问题可以分解,且子问题会重复出现
  2. 最优子结构:大问题的最优解可以由小问题的最优解推出
  3. 状态转移方程:明确怎么从小问题推导出大问题

三、动态规划解题四步走

1. 定义状态(开什么存档位)

// 通常用数组表示状态
int[] dp = new int[n+1]; 

2. 初始化状态(初始存档)

dp[0] = 0; // 根据问题设置初始值
dp[1] = 1;

3. 状态转移方程(关卡攻略)

// 斐波那契数列的例子
dp[i] = dp[i-1] + dp[i-2];

4. 返回结果(最终BOSS掉落)

return dp[n];

四、高频DP问题套路

1. 爬楼梯问题

问题:每次爬1或2阶,到n阶有几种方法?

public int climbStairs(int n) {
    if (n <= 2) return n;
    
    int[] dp = new int[n+1];
    dp[1] = 1; // 1阶1种方法
    dp[2] = 2; // 2阶2种方法
    
    for (int i = 3; i <= n; i++) {
        dp[i] = dp[i-1] + dp[i-2]; // 从i-1爬1阶或从i-2爬2阶
    }
    
    return dp[n];
}

// 空间优化版(只用3个变量)
public int climbStairsOpt(int n) {
    if (n <= 2) return n;
    
    int a = 1, b = 2, c = 0;
    for (int i = 3; i <= n; i++) {
        c = a + b;
        a = b;
        b = c;
    }
    return c;
}

白话解释

  • 到第n阶的方法数 = 到n-1阶的方法数(再爬1阶) + 到n-2阶的方法数(再爬2阶)

2. 打家劫舍问题

问题:不能偷相邻房子,求最大收益

public int rob(int[] nums) {
    if (nums.length == 0) return 0;
    if (nums.length == 1) return nums[0];
    
    int[] dp = new int[nums.length];
    dp[0] = nums[0];
    dp[1] = Math.max(nums[0], nums[1]);
    
    for (int i = 2; i < nums.length; i++) {
        dp[i] = Math.max(dp[i-1], dp[i-2] + nums[i]);
        // 选择:不偷当前(取前一个最大值) 或 偷当前(前前个最大值+当前值)
    }
    
    return dp[nums.length - 1];
}

// 空间优化版
public int robOpt(int[] nums) {
    int prev = 0, curr = 0;
    for (int num : nums) {
        int temp = curr;
        curr = Math.max(curr, prev + num);
        prev = temp;
    }
    return curr;
}

白话解释

  • 每到一家店,选择:偷这家(加上前前家的最大值)或者不偷(保持前家的最大值)

3. 零钱兑换问题

问题:用最少的硬币凑出金额

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 (coin <= i) {
                dp[i] = Math.min(dp[i], dp[i - coin] + 1);
            }
        }
    }
    
    return dp[amount] > amount ? -1 : dp[amount];
}

白话解释

  • 对于每个金额,尝试每种硬币,看用这个硬币后剩下的金额需要多少硬币

4. 最长递增子序列(LIS)

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

public int lengthOfLIS(int[] nums) {
    if (nums.length == 0) return 0;
    
    int[] dp = new int[nums.length];
    Arrays.fill(dp, 1); // 每个元素本身就是一个子序列
    int maxLen = 1;
    
    for (int i = 1; i < nums.length; 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;
}

白话解释

  • 对于每个数,看前面比它小的数能组成多长的序列,然后+1

5. 编辑距离问题

问题:把一个单词变成另一个单词的最少操作次数(增删改)

public int minDistance(String word1, String word2) {
    int m = word1.length(), n = word2.length();
    int[][] dp = new int[m+1][n+1];
    
    // 初始化:空串变空串0次,变i个字符需要i次操作
    for (int i = 0; i <= m; i++) dp[i][0] = i;
    for (int j = 0; j <= n; j++) dp[0][j] = j;
    
    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] = 1 + Math.min(
                    dp[i-1][j],   // 删除word1的字符
                    Math.min(
                        dp[i][j-1],   // 插入word2的字符
                        dp[i-1][j-1]  // 替换字符
                    )
                );
            }
        }
    }
    
    return dp[m][n];
}

白话解释

  • 两个字符相同:继承左上角的值
  • 不同:取左(插入)、上(删除)、左上(替换)中的最小值+1

五、动态规划优化技巧

  1. 空间压缩:如果当前状态只依赖前几个状态,可以用滚动数组
// 二维DP优化为一维
int[] dp = new int[n];
// 替代
int[][] dp = new int[m][n];
  1. 备忘录法:自顶向下递归+记忆化,适合不容易找到迭代顺序的问题
int[] memo = new int[n+1];
Arrays.fill(memo, -1);

int helper(int n) {
    if (memo[n] != -1) return memo[n];
    // 计算并存储
    memo[n] = helper(n-1) + helper(n-2);
    return memo[n];
}
  1. 打印路径:有时候需要记录选择路径而不仅是结果
// 额外数组记录选择
int[] choice = new int[n];

六、必刷DP题目清单

  1. 简单难度:

    • LeetCode 70:爬楼梯
    • LeetCode 198:打家劫舍
    • LeetCode 746:使用最小花费爬楼梯
  2. 中等难度:

    • LeetCode 322:零钱兑换
    • LeetCode 300:最长递增子序列
    • LeetCode 1143:最长公共子序列
    • LeetCode 416:分割等和子集
  3. 困难难度:

    • LeetCode 72:编辑距离
    • LeetCode 312:戳气球
    • LeetCode 10:正则表达式匹配

记住:动态规划就是"聪明的递归",先想清楚暴力解法,再找重复子问题,最后用记忆化优化。多练习各种状态转移方程,面试时就能游刃有余!