动态规划系列第九篇:区间DP问题深度解析
本文是动态规划系列的第九篇文章,将深入探讨区间动态规划这一重要分支。区间DP是动态规划的一个经典类型,主要解决在一个区间内进行合并、分割等操作的最优化问题。
前言回顾
在前面的文章中,我们已经学习了动态规划的基础概念、经典问题以及各种优化技巧。今天我们将进入一个新的领域——区间动态规划(Interval Dynamic Programming),这是动态规划中一个非常重要且有趣的分支。
什么是区间DP?
区间动态规划,简称区间DP,是一种在区间上进行动态规划的方法。它的核心思想是:对于一个区间,我们可以通过划分子区间,然后合并子区间的结果来得到整个区间的最优解。
区间DP的特点
- 状态定义:通常用
dp[i][j] 表示区间 [i, j] 的最优值
- 状态转移:通过枚举分割点
k,将区间 [i, j] 分解为 [i, k] 和 [k+1, j]
- 边界条件:通常是单个元素或空区间的情况
- 填表顺序:按区间长度从小到大填充
区间DP的一般框架
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| public int intervalDP(int[] arr) { int n = arr.length; int[][] dp = new int[n][n]; for (int i = 0; i < n; i++) { dp[i][i] = ; } for (int len = 2; len <= n; len++) { for (int i = 0; i <= n - len; i++) { int j = i + len - 1; dp[i][j] = Integer.MAX_VALUE; for (int k = i; k < j; k++) { dp[i][j] = Math.min(dp[i][j], dp[i][k] + dp[k+1][j] + ); } } } return dp[0][n-1]; }
|
经典问题一:矩阵链乘法
问题描述
给定一系列矩阵 A1, A2, …, An,我们想要计算它们的乘积 A1×A2×…×An。矩阵乘法满足结合律,不同的加括号方式会导致不同的计算量。我们的目标是找到最优的加括号方式,使得标量乘法的次数最少。
LeetCode相关题目:虽然LeetCode上没有直接的矩阵链乘法题目,但这是算法教材中的经典问题。
问题分析
假设矩阵 Ai 的维度是 p[i-1] × p[i],那么计算矩阵 Ai × Ai+1 需要 p[i-1] × p[i] × p[i+1] 次标量乘法。
对于区间 [i, j],我们需要找到最优的分割点 k,使得:
- 左半部分:[i, k]
- 右半部分:[k+1, j]
- 合并代价:p[i-1] × p[k] × p[j]
算法实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84
| public class MatrixChainMultiplication {
public int matrixChainOrder(int[] p) { int n = p.length - 1; int[][] dp = new int[n][n]; for (int i = 0; i < n; i++) { dp[i][i] = 0; } for (int len = 2; len <= n; len++) { for (int i = 0; i <= n - len; i++) { int j = i + len - 1; dp[i][j] = Integer.MAX_VALUE; for (int k = i; k < j; k++) { int cost = dp[i][k] + dp[k+1][j] + p[i] * p[k+1] * p[j+1]; dp[i][j] = Math.min(dp[i][j], cost); } } } return dp[0][n-1]; }
public String printOptimalParentheses(int[][] s, int i, int j) { if (i == j) { return "A" + (i + 1); } else { return "(" + printOptimalParentheses(s, i, s[i][j]) + printOptimalParentheses(s, s[i][j] + 1, j) + ")"; } }
public int matrixChainOrderWithPath(int[] p) { int n = p.length - 1; int[][] dp = new int[n][n]; int[][] s = new int[n][n]; for (int len = 2; len <= n; len++) { for (int i = 0; i <= n - len; i++) { int j = i + len - 1; dp[i][j] = Integer.MAX_VALUE; for (int k = i; k < j; k++) { int cost = dp[i][k] + dp[k+1][j] + p[i] * p[k+1] * p[j+1]; if (cost < dp[i][j]) { dp[i][j] = cost; s[i][j] = k; } } } } System.out.println("最优加括号方案:" + printOptimalParentheses(s, 0, n-1)); return dp[0][n-1]; } public static void main(String[] args) { MatrixChainMultiplication mcm = new MatrixChainMultiplication(); int[] p = {1, 5, 4, 6, 2}; int result = mcm.matrixChainOrderWithPath(p); System.out.println("最少乘法次数: " + result); } }
|
算法复杂度分析
- 时间复杂度:O(n³),三层循环
- 空间复杂度:O(n²),存储DP表
经典问题二:石子合并
石子合并问题描述
在一个圆形操场的四周摆放着n堆石子。现在要将石子有次序地合并成一堆。规定每次只能选择相邻的两堆石子合并成新的一堆,并将新的一堆石子数记为该次合并的代价。试设计一个算法,计算出将n堆石子合并成一堆的最小代价。
相关LeetCode题目:
线性版本实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75
| public class StoneGame {
public int stoneGameLinear(int[] stones) { int n = stones.length; if (n <= 1) return 0; int[] prefixSum = new int[n + 1]; for (int i = 0; i < n; i++) { prefixSum[i + 1] = prefixSum[i] + stones[i]; } int[][] dp = new int[n][n]; for (int len = 2; len <= n; len++) { for (int i = 0; i <= n - len; i++) { int j = i + len - 1; dp[i][j] = Integer.MAX_VALUE; for (int k = i; k < j; k++) { int cost = dp[i][k] + dp[k+1][j] + (prefixSum[j+1] - prefixSum[i]); dp[i][j] = Math.min(dp[i][j], cost); } } } return dp[0][n-1]; }
public int stoneGameCircular(int[] stones) { int n = stones.length; if (n <= 1) return 0; int[] extended = new int[2 * n]; for (int i = 0; i < n; i++) { extended[i] = extended[i + n] = stones[i]; } int minCost = Integer.MAX_VALUE; for (int start = 0; start < n; start++) { int[] subArray = new int[n]; System.arraycopy(extended, start, subArray, 0, n); minCost = Math.min(minCost, stoneGameLinear(subArray)); } return minCost; } public static void main(String[] args) { StoneGame sg = new StoneGame(); int[] stones1 = {3, 4, 3, 1, 5, 8}; System.out.println("线性石子合并最小代价: " + sg.stoneGameLinear(stones1)); int[] stones2 = {4, 1, 8, 7}; System.out.println("环形石子合并最小代价: " + sg.stoneGameCircular(stones2)); } }
|
状态转移方程详解
对于石子合并问题:
1
| dp[i][j] = min(dp[i][k] + dp[k+1][j] + sum[i][j])
|
其中:
dp[i][j]:合并区间[i, j]的最小代价
sum[i][j]:区间[i, j]所有石子的总和
k:分割点,i ≤ k < j
经典问题三:戳气球
戳气球问题描述
LeetCode 312. Burst Balloons
给定n个气球,编号为0到n-1,每个气球上都标有一个数字,这些数字存在数组nums中。现在要求你戳破所有的气球。戳破第i个气球,你可以获得 nums[left] * nums[i] * nums[right] 个硬币。这里的left和right代表和i相邻的两个气球的序号。
算法思路
这个问题的关键在于:我们不是考虑先戳破哪个气球,而是考虑最后戳破哪个气球。
对于区间[i, j],如果最后戳破气球k,那么:
- 此时区间[i, k-1]和[k+1, j]的气球都已经被戳破
- 戳破气球k的收益为:
nums[i-1] * nums[k] * nums[j+1]
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75
| public class BurstBalloons {
public int maxCoins(int[] nums) { int n = nums.length; int[] arr = new int[n + 2]; arr[0] = arr[n + 1] = 1; for (int i = 0; i < n; i++) { arr[i + 1] = nums[i]; } int[][] dp = new int[n + 2][n + 2]; for (int len = 3; len <= n + 2; len++) { for (int i = 0; i <= n + 2 - len; i++) { int j = i + len - 1; for (int k = i + 1; k < j; k++) { dp[i][j] = Math.max(dp[i][j], dp[i][k] + dp[k][j] + arr[i] * arr[k] * arr[j]); } } } return dp[0][n + 1]; }
public int maxCoinsWithMemo(int[] nums) { int n = nums.length; int[] arr = new int[n + 2]; arr[0] = arr[n + 1] = 1; for (int i = 0; i < n; i++) { arr[i + 1] = nums[i]; } Integer[][] memo = new Integer[n + 2][n + 2]; return dfs(arr, 0, n + 1, memo); } private int dfs(int[] arr, int i, int j, Integer[][] memo) { if (i >= j - 1) return 0; if (memo[i][j] != null) return memo[i][j]; int maxCoins = 0; for (int k = i + 1; k < j; k++) { maxCoins = Math.max(maxCoins, dfs(arr, i, k, memo) + dfs(arr, k, j, memo) + arr[i] * arr[k] * arr[j]); } return memo[i][j] = maxCoins; } public static void main(String[] args) { BurstBalloons bb = new BurstBalloons(); int[] nums1 = {3, 1, 5, 8}; System.out.println("最大硬币数: " + bb.maxCoins(nums1)); int[] nums2 = {1, 5}; System.out.println("最大硬币数: " + bb.maxCoinsWithMemo(nums2)); } }
|
算法复杂度
区间DP的变形与扩展
1. 回文串相关问题
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
|
public int longestPalindromeSubseq(String s) { int n = s.length(); int[][] dp = new int[n][n]; for (int i = 0; i < n; i++) { dp[i][i] = 1; } for (int len = 2; len <= n; len++) { for (int i = 0; i <= n - len; i++) { int j = i + len - 1; if (s.charAt(i) == s.charAt(j)) { dp[i][j] = dp[i + 1][j - 1] + 2; } else { dp[i][j] = Math.max(dp[i + 1][j], dp[i][j - 1]); } } } return dp[0][n - 1]; }
|
2. 括号匹配问题
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
|
public int longestValidParentheses(String s) { int n = s.length(); if (n <= 1) return 0; int[][] dp = new int[n][n]; for (int len = 2; len <= n; len += 2) { for (int i = 0; i <= n - len; i++) { int j = i + len - 1; if (s.charAt(i) == '(' && s.charAt(j) == ')') { dp[i][j] = dp[i + 1][j - 1] + 2; } for (int k = i + 1; k < j; k += 2) { dp[i][j] = Math.max(dp[i][j], dp[i][k] + dp[k + 1][j]); } } } return dp[0][n - 1]; }
|
区间DP的优化技巧
1. 四边形不等式优化
对于某些特殊的区间DP问题,如果满足四边形不等式的性质,可以将时间复杂度从O(n³)优化到O(n²)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
|
public int optimizedIntervalDP(int[] arr) { int n = arr.length; int[][] dp = new int[n][n]; int[][] opt = new int[n][n]; for (int i = 0; i < n - 1; i++) { opt[i][i + 1] = i; } for (int len = 2; len < n; len++) { for (int i = 0; i < n - len; i++) { int j = i + len; dp[i][j] = Integer.MAX_VALUE; for (int k = opt[i][j - 1]; k <= opt[i + 1][j]; k++) { int cost = dp[i][k] + dp[k][j] + cost(i, k, j); if (cost < dp[i][j]) { dp[i][j] = cost; opt[i][j] = k; } } } } return dp[0][n - 1]; }
private int cost(int i, int k, int j) { return 0; }
|
2. 空间优化
对于某些问题,可以利用滚动数组来优化空间复杂度。
区间DP解题思路总结
1. 识别区间DP问题的特征
- 问题涉及一个序列或数组的连续子区间
- 可以通过合并子区间来解决原问题
- 最优子结构性质明显
- 常见关键词:合并、分割、区间、最优等
2. 状态设计
1 2 3 4 5
| dp[i][j] = 区间[i, j]的最优值
dp[i][j][k] = 区间[i, j]在某种约束k下的最优值
|
3. 状态转移
1 2 3 4 5 6
| dp[i][j] = min/max(dp[i][k] + dp[k+1][j] + cost(i, k, j))
dp[i][i] = base_case
|
4. 遍历顺序
1 2 3 4 5 6 7
| for (int len = 1; len <= n; len++) { for (int i = 0; i <= n - len; i++) { int j = i + len - 1; } }
|
实战练习推荐
初级练习
- 516. Longest Palindromic Subsequence
- 647. Palindromic Substrings
中级练习
- 312. Burst Balloons
- 1000. Minimum Cost to Merge Stones
高级练习
- 664. Strange Printer
- 1039. Minimum Score Triangulation of Polygon
总结
区间DP是动态规划中一个重要而优雅的分支,它的核心思想是将大区间分解为小区间,通过合并子问题的解来获得原问题的最优解。
关键要点回顾
- 状态定义:
dp[i][j] 表示区间 [i, j] 的最优值
- 转移方程:通过枚举分割点来转移状态
- 遍历顺序:按区间长度从小到大遍历
- 时间复杂度:通常为 O(n³)
- 应用场景:矩阵链乘法、石子合并、括号匹配、回文问题等
学习建议
- 掌握模板:熟练掌握区间DP的基本模板和框架
- 理解本质:深入理解”分治+记忆化”的思想
- 多做练习:通过大量练习来熟悉各种变形
- 优化意识:学会识别可以优化的特殊情况
区间DP虽然看起来复杂,但只要掌握了基本思路和模板,就能够灵活应对各种相关问题。在后续的学习中,我们还将继续探讨动态规划的其他重要分支,如树形DP、数位DP等。
希望这篇文章能够帮助大家深入理解区间动态规划,在算法学习的道路上更进一步!