动态规划算法精解(Java实现):从入门到精通
一、动态规划概述
动态规划(Dynamic Programming,DP)是一种解决复杂问题的高效算法,通过将问题分解为相互重叠的子问题,并存储子问题的解来避免重复计算。它在众多领域如计算机科学、运筹学、经济学等都有广泛应用,能够显著提升问题的求解效率。
核心思想:
- 最优子结构:问题的最优解包含子问题的最优解。这意味着可以通过求解子问题的最优解来得到原问题的最优解。例如,在求解最短路径问题时,从起点到终点的最短路径必然包含了从起点到中间某点的最短路径。
- 重叠子问题:不同的问题会重复使用相同的子问题解。如果不进行处理,这些子问题会被多次求解,造成大量的时间浪费。动态规划通过记录子问题的解,避免了这种重复计算。
- 状态转移:通过状态转移方程描述问题间的递推关系。状态转移方程是动态规划的核心,它定义了如何从已知的子问题解推导出当前问题的解。
适用场景:
- 最优化问题(最大值 / 最小值):例如求最大利润、最短路径等。在资源分配问题中,需要在一定的约束条件下,找到使某个目标函数达到最大值或最小值的方案。
- 计数问题(多少种方式):比如计算排列组合的数量、走楼梯的不同方式等。这类问题通常需要统计满足特定条件的所有可能情况的数量。
- 存在性问题(是否可行):判断是否存在满足某种条件的解。例如,在背包问题中,判断是否能在给定的背包容量下装入某些物品。
二、动态规划三要素
- DP 数组定义:明确
dp[i]
或dp[i][j]
表示的含义。dp
数组用于存储子问题的解,其定义需要根据具体问题来确定。例如,在斐波那契数列问题中,dp[i]
表示第i
个斐波那契数。 - 状态转移方程:描述问题间的递推关系。它是动态规划的关键,通过状态转移方程,可以从已知的子问题解推导出当前问题的解。状态转移方程的推导需要对问题进行深入分析,找出问题之间的内在联系。
- 初始条件:确定最小子问题的解。初始条件是动态规划的基础,它为状态转移提供了起点。在很多问题中,初始条件通常是边界情况的解。
三、经典 DP 问题实现
3.1 斐波那契数列(LeetCode 509)
斐波那契数列是一个经典的数学序列,其定义为:F(0)=0,F(1)=1,F(n)=F(n−1)+F(n−2)(n≥2)。
class Solution {public 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];}// 空间优化版public int fibOptimized(int n) {if (n <= 1) return n;int prev = 0, curr = 1;for (int i = 2; i <= n; i++) {int sum = prev + curr;prev = curr;curr = sum;}return curr;}
}
在上述代码中,fib
方法使用了一个一维数组dp
来存储中间结果,避免了重复计算。而fibOptimized
方法则对空间进行了优化,只使用了两个变量prev
和curr
来保存必要的信息,将空间复杂度从O(n)降低到了O(1)。
3.2 爬楼梯(LeetCode 70)
假设你正在爬楼梯,需要n
阶你才能到达楼顶。每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?
class Solution {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];}
}
这个问题可以转化为斐波那契数列问题。到达第n
阶楼梯的方法数等于到达第n - 1
阶楼梯的方法数加上到达第n - 2
阶楼梯的方法数。
四、二维 DP 问题
4.1 最小路径和(LeetCode 64)
给定一个包含非负整数的m x n
网格grid
,请找出一条从左上角到右下角的路径,使得路径上的数字总和为最小。每次只能向下或者向右移动一步。
class Solution {public int minPathSum(int[][] grid) {int m = grid.length, n = grid[0].length;int[][] dp = new int[m][n];// 初始化dp[0][0] = grid[0][0];for (int i = 1; i < m; i++) dp[i][0] = dp[i-1][0] + grid[i][0];for (int j = 1; j < n; j++) dp[0][j] = dp[0][j-1] + grid[0][j];// 状态转移for (int i = 1; i < m; i++) {for (int j = 1; j < n; j++) {dp[i][j] = Math.min(dp[i-1][j], dp[i][j-1]) + grid[i][j];}}return dp[m-1][n-1];}
}
在这个问题中,dp[i][j]
表示从左上角到达坐标(i, j)
的最小路径和。通过初始化第一行和第一列的dp
值,然后使用状态转移方程dp[i][j] = Math.min(dp[i-1][j], dp[i][j-1]) + grid[i][j]
来更新其他位置的dp
值。
4.2 最长公共子序列(LeetCode 1143)
给定两个字符串text1
和text2
,返回这两个字符串的最长公共子序列的长度。
class Solution {public int longestCommonSubsequence(String text1, String text2) {int m = text1.length(), 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
的前i
个字符和text2
的前j
个字符的最长公共子序列的长度。如果text1
的第i
个字符和text2
的第j
个字符相等,则dp[i][j] = dp[i-1][j-1] + 1
;否则,dp[i][j] = Math.max(dp[i-1][j], dp[i][j-1])
。
五、背包问题系列
5.1 0 - 1 背包问题
给定一组物品,每个物品有对应的重量weights
和价值values
,以及一个容量为capacity
的背包。要求在不超过背包容量的前提下,选择一些物品放入背包,使得背包中物品的总价值最大。每个物品只能选择放入或不放入背包(即 0 - 1 选择)。
class Knapsack {public int maxValue(int[] weights, int[] values, int capacity) {int n = weights.length;int[][] dp = new int[n+1][capacity+1];for (int i = 1; i <= n; i++) {for (int j = 1; j <= capacity; j++) {if (j < weights[i-1]) {dp[i][j] = dp[i-1][j];} else {dp[i][j] = Math.max(dp[i-1][j],dp[i-1][j-weights[i-1]] + values[i-1]);}}}return dp[n][capacity];}// 空间优化版(一维数组)public int maxValueOptimized(int[] weights, int[] values, int capacity) {int[] dp = new int[capacity+1];for (int i = 0; i < weights.length; i++) {for (int j = capacity; j >= weights[i]; j--) {dp[j] = Math.max(dp[j], dp[j-weights[i]] + values[i]);}}return dp[capacity];}
}
在maxValue
方法中,dp[i][j]
表示前i
个物品在背包容量为j
时的最大价值。通过两层循环遍历物品和背包容量,根据当前物品是否能放入背包来更新dp
值。maxValueOptimized
方法对空间进行了优化,使用一维数组dp
来保存中间结果,将空间复杂度从O(n∗capacity)降低到了O(capacity)。
5.2 完全背包问题(LeetCode 322 零钱兑换)
给定不同面额的硬币coins
和一个总金额amount
,编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1。每种硬币的数量是无限的。
import java.util.Arrays;class Solution {public int coinChange(int[] coins, int amount) {int[] dp = new int[amount+1];Arrays.fill(dp, amount+1); // 初始化为最大值dp[0] = 0;for (int coin : coins) {for (int i = coin; i <= amount; i++) {dp[i] = Math.min(dp[i], dp[i-coin] + 1);}}return dp[amount] > amount ? -1 : dp[amount];}
}
dp[i]
表示凑成金额i
所需的最少硬币个数。通过遍历每种硬币,更新dp
数组。如果dp[amount]
仍然大于amount
,说明无法凑成总金额,返回 -1。
六、DP 解题方法论
6.1 解题步骤
- 确定 DP 状态:明确状态表示什么含义。这是解决动态规划问题的关键一步,需要根据问题的特点来定义合适的状态。
- 定义 DP 数组:选择一维 / 二维数组存储状态。根据问题的复杂度和状态的维度,选择合适的数组来存储中间结果。
- 状态转移方程:找出状态间的关系式。通过分析问题的最优子结构,推导出状态转移方程。
- 初始化:确定边界条件。初始化是状态转移的起点,需要根据问题的定义来确定边界情况的解。
- 计算顺序:确定填表顺序。根据状态转移方程的依赖关系,确定计算
dp
数组的顺序。 - 空间优化:考虑是否可降维。在一些情况下,可以通过优化空间来降低算法的空间复杂度。
6.2 经典问题分类
问题类型 | 典型例题 | 特点 |
---|---|---|
线性 DP | 最长递增子序列 (300) | 单序列或双序列问题 |
区间 DP | 最长回文子串 (5) | 涉及子区间的最优解 |
树形 DP | 打家劫舍 III (337) | 在树结构上进行状态转移 |
状态机 DP | 买卖股票最佳时机 (121) | 状态间存在多种转移可能 |
数位 DP | 数字 1 的个数 (233) | 处理数字位上的计数问题 |
七、高频面试题精选
7.1 编辑距离(LeetCode 72)
给你两个单词word1
和word2
,请你计算出将word1
转换成word2
所使用的最少操作数。你可以对一个单词进行如下三种操作:插入一个字符、删除一个字符、替换一个字符。
class Solution {public int minDistance(String word1, String word2) {int m = word1.length(), n = word2.length();int[][] dp = new int[m+1][n+1];// 初始化for (int i = 1; i <= m; i++) dp[i][0] = i;for (int j = 1; 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] = Math.min(Math.min(dp[i-1][j], dp[i][j-1]),dp[i-1][j-1]) + 1;}}}return dp[m][n];}
}
dp[i][j]
表示将word1
的前i
个字符转换成word2
的前j
个字符所需的最少操作数。通过初始化第一行和第一列的dp
值,然后使用状态转移方程来更新其他位置的dp
值。
7.2 打家劫舍(LeetCode 198)
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。给定一个代表每个房屋存放金额的非负整数数组nums
,计算你在不触动警报装置的情况下,能够偷窃到的最高金额。
class Solution {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++) {dp[i] = Math.max(dp[i-1], dp[i-2] + nums[i]);}return dp[n-1];}// 空间优化版public int robOptimized(int[] nums) {int prev = 0, curr = 0;for (int num : nums) {int temp = Math.max(curr, prev + num);prev = curr;curr = temp;}return curr;}
}
rob
方法使用一维数组dp
来存储中间结果,dp[i]
表示偷窃前i
间房屋能够获得的最高金额。robOptimized
方法对空间进行了优化,只使用两个变量prev
和curr
来保存必要的信息。
八、DP 优化技巧
- 空间压缩:二维转一维(滚动数组)。在一些问题中,
dp
数组的更新只依赖于前一行或前几行的信息,此时可以使用滚动数组将二维数组压缩为一维数组,从而降低空间复杂度。 - 状态压缩:用位运算表示状态(如 TSP 问题)。对于一些状态数量较少的问题,可以使用位运算来表示状态,从而减少空间的使用。
- 单调队列优化:优化滑动窗口最值问题。在求解滑动窗口的最值问题时,可以使用单调队列来优化时间复杂度。
- 斜率优化:优化特定形式的状态转移方程。对于一些具有特定形式的状态转移方程,可以使用斜率优化来降低时间复杂度。
// 示例:使用滚动数组优化空间
int[][] dp = new int[2][n]; // 只保留前两行
九、常见误区与调试技巧
常见错误:
- 未正确处理边界条件。边界条件是动态规划的基础,如果处理不当,会导致结果错误。
- 状态转移方程推导错误。状态转移方程是动态规划的核心,如果推导错误,整个算法将无法得到正确的结果。
- 数组越界访问。在使用
dp
数组时,需要注意数组的下标范围,避免越界访问。 - 空间复杂度过高。在一些问题中,如果没有进行空间优化,可能会导致空间复杂度过高,从而超出内存限制。
调试建议:
- 打印 DP 表格检查中间结果。通过打印
dp
数组的中间结果,可以帮助我们理解状态转移的过程,发现问题所在。 - 从小规模测试用例开始验证。先使用小规模的测试用例来验证算法的正确性,逐步增加测试用例的规模。
- 使用 IDE 的调试功能逐步跟踪。利用 IDE 的调试功能,逐步执行代码,观察变量的变化,找出问题所在。
- 对比暴力解法的结果。如果可能的话,可以实现一个暴力解法,将其结果与动态规划的结果进行对比,验证算法的正确性。
十、学习路径建议
-
基础阶段:
- 斐波那契数列
- 爬楼梯问题
- 最小路径和
这些问题是动态规划的基础,通过解决这些问题,可以帮助我们理解动态规划的基本思想和解题步骤。
-
进阶阶段:
- 背包问题系列
- 股票买卖问题
- 字符串 DP 问题
这些问题具有一定的复杂度,需要我们深入理解状态设计和转移方程的构建。
-
高手阶段:
- 树形 DP
- 状态压缩 DP
- 数位 DP
这些问题是动态规划的高级应用,需要我们具备较强的思维能力和编程技巧。
结语
动态规划是算法学习中的难点也是重点,需要大量练习才能掌握其精髓。建议从简单问题入手,逐步理解状态设计和转移方程的构建,最终达到能够独立解决陌生 DP 问题的水平。