1. 动态规划的核心思想与应用场景
动态规划(Dynamic Programming)是算法设计中解决最优化问题的重要方法。我第一次接触这个概念是在解决LeetCode上的爬楼梯问题时,当时就被它"用空间换时间"的巧妙思路所吸引。
动态规划本质上是一种分治思想的延伸,但与普通分治法不同之处在于:动态规划会保存子问题的解,避免重复计算。这就像我们做数学题时把中间结果写在草稿纸上,下次需要时直接查表,而不是重新计算。
在Java中实现动态规划通常需要考虑以下几个关键点:
- 状态定义:明确dp数组每个元素的含义
- 状态转移方程:确定如何从已知状态推导新状态
- 初始条件:确定最小子问题的解
- 计算顺序:确定填表顺序以保证依赖关系
提示:动态规划问题中,90%的困难都来自于不清晰的状态定义。建议先用自然语言描述清楚每个状态代表什么,再考虑数学表达。
2. 经典动态规划问题实战解析
2.1 最长不下降子序列问题
这是动态规划的经典入门题。题目要求:给定一个整数序列,找到最长子序列的长度,使得这个子序列中的元素是不下降的(即对于子序列中任意两个相邻元素,前一个不大于后一个)。
以序列[10, 9, 2, 5, 3, 7, 101, 18]为例,最长不下降子序列是[2, 3, 7, 101],长度为4。
Java实现代码:
public int lengthOfLIS(int[] nums) { if (nums.length == 0) return 0; int[] dp = new int[nums.length]; Arrays.fill(dp, 1); int maxAns = 1; for (int i = 1; i < nums.length; i++) { for (int j = 0; j < i; j++) { if (nums[j] < nums[i]) { dp[i] = Math.max(dp[i], dp[j] + 1); } } maxAns = Math.max(maxAns, dp[i]); } return maxAns; }这段代码的时间复杂度是O(n²),空间复杂度是O(n)。dp[i]表示以nums[i]结尾的最长不下降子序列的长度。对于每个元素,我们检查它之前的所有元素,如果发现更小的元素,就尝试更新dp值。
2.2 输出最长序列本身
很多面试官会进一步要求输出这个最长序列本身,而不仅仅是长度。这需要我们在动态规划过程中记录额外的信息:
public List<Integer> lengthOfLISWithSequence(int[] nums) { List<List<Integer>> sequences = new ArrayList<>(); for (int num : nums) { sequences.add(new ArrayList<>(Collections.singletonList(num))); } for (int i = 1; i < nums.length; i++) { for (int j = 0; j < i; j++) { if (nums[j] < nums[i] && sequences.get(i).size() <= sequences.get(j).size()) { sequences.set(i, new ArrayList<>(sequences.get(j))); sequences.get(i).add(nums[i]); } } } return sequences.stream() .max(Comparator.comparingInt(List::size)) .orElse(Collections.emptyList()); }这种方法虽然直观,但空间复杂度较高。更优的做法是使用前驱数组记录每个元素的前驱,最后反向构建序列。
3. 动态规划的优化技巧
3.1 状态压缩
很多动态规划问题中,当前状态只依赖于有限的几个前驱状态,这时可以进行状态压缩,减少空间复杂度。以斐波那契数列为例:
int fib(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; }这样空间复杂度从O(n)降到了O(1)。
3.2 备忘录法
对于某些问题,我们可以采用自顶向下的递归解法,配合备忘录避免重复计算:
Map<Integer, Integer> memo = new HashMap<>(); int fibMemo(int n) { if (n <= 1) return n; if (memo.containsKey(n)) return memo.get(n); memo.put(n, fibMemo(n-1) + fibMemo(n-2)); return memo.get(n); }这种方法更符合直觉,但递归调用会有额外的栈空间开销。
4. 动态规划在面试中的常见变体
4.1 背包问题
背包问题是动态规划的经典应用。0-1背包问题的Java实现:
int knapsack(int W, int[] wt, int[] val, int n) { int[][] dp = new int[n+1][W+1]; for (int i = 1; i <= n; i++) { for (int w = 1; w <= W; w++) { if (wt[i-1] <= w) { dp[i][w] = Math.max(val[i-1] + dp[i-1][w-wt[i-1]], dp[i-1][w]); } else { dp[i][w] = dp[i-1][w]; } } } return dp[n][W]; }4.2 股票买卖问题
这是LeetCode上的热门题型,以最简单的形式为例(只能买卖一次):
public int maxProfit(int[] prices) { int minPrice = Integer.MAX_VALUE; int maxProfit = 0; for (int price : prices) { if (price < minPrice) { minPrice = price; } else if (price - minPrice > maxProfit) { maxProfit = price - minPrice; } } return maxProfit; }更复杂的变体包括多次买卖、含冷冻期、含手续费等,都可以用动态规划解决。
5. 动态规划问题调试技巧
在解决动态规划问题时,我总结了一些实用的调试方法:
- 打印DP表:这是最直观的方法。在关键步骤后打印整个DP数组,观察数值变化是否符合预期。
void printDP(int[][] dp) { for (int[] row : dp) { System.out.println(Arrays.toString(row)); } System.out.println("-----"); }边界条件检查:特别注意i=0或j=0时的边界情况,这是常见的错误来源。
小规模测试:先用小的输入测试,确保基本逻辑正确后再处理大规模数据。
逐步验证:对于复杂的状态转移方程,可以手动计算几个中间结果与程序输出对比。
注意:动态规划问题的调试往往比普通算法更困难,因为涉及的状态多且相互关联。建议在编写代码前先在纸上画出状态转移图,理清思路。
6. 动态规划与其他算法的比较
6.1 与贪心算法的区别
贪心算法每次做出局部最优选择,希望导致全局最优解。而动态规划会考虑所有可能的子问题,确保找到全局最优解。以硬币找零问题为例:
- 贪心算法:每次都选最大面额的硬币(对某些面额组合可能得不到最优解)
- 动态规划:计算所有可能的组合,确保找到硬币数最少的方案
6.2 与分治法的区别
分治法将问题分解为互不重叠的子问题,递归求解后再合并。动态规划的子问题通常有重叠,因此需要记忆化存储子问题的解。
7. 动态规划的高级应用
7.1 树形DP
动态规划也可以应用于树形结构。例如二叉树中的最大路径和问题:
class TreeNode { int val; TreeNode left; TreeNode right; TreeNode(int x) { val = x; } } class Solution { int maxSum = Integer.MIN_VALUE; public int maxPathSum(TreeNode root) { maxGain(root); return maxSum; } private int maxGain(TreeNode node) { if (node == null) return 0; int leftGain = Math.max(maxGain(node.left), 0); int rightGain = Math.max(maxGain(node.right), 0); int priceNewpath = node.val + leftGain + rightGain; maxSum = Math.max(maxSum, priceNewpath); return node.val + Math.max(leftGain, rightGain); } }7.2 状态机DP
某些问题可以用有限状态机模型配合动态规划解决。例如股票买卖问题中,可以定义持有股票和未持有股票两种状态。
8. 动态规划的学习路径建议
根据我的经验,建议按以下顺序学习动态规划:
- 先掌握递归和记忆化搜索
- 理解经典的一维DP问题(斐波那契、爬楼梯)
- 学习二维DP问题(最长公共子序列、编辑距离)
- 掌握背包问题及其变种
- 学习区间DP和树形DP
- 研究状态压缩等优化技巧
练习时建议从LeetCode的Easy题目开始,逐步过渡到Hard题目。每个问题解决后,尝试自己总结状态定义和转移方程的模式。