一、题目背景
在动态规划问题中,有一类非常经典的问题叫做 区间动态规划。
它的特点是:
问题的答案不是由单个位置决定的,而是由一个连续区间的最优解推导出来。
本文要讲解的题目是 LeetCode 中非常经典的一道区间 DP 题目:
312. 戳气球
题目大意如下:
给你一个数组 nums,其中每个元素表示一个气球上的数字。
当你戳破第 i 个气球时,可以获得的硬币数量为:
nums[i - 1] * nums[i] * nums[i + 1]
也就是说,戳破当前气球时,获得的硬币数取决于它左右两边还没有被戳破的气球。
如果某个气球左边或右边没有气球,则默认那个位置的值为 1。
要求:
戳破所有气球后,最多可以获得多少硬币?
二、示例理解
例如:
nums = [3, 1, 5, 8]
一种最优戳法可以得到:
167
最终输出:
167
乍一看,这道题似乎可以用贪心解决,比如每次戳破能获得最多硬币的气球。
但实际上,这样是不对的。
因为戳破一个气球之后,它左右两边的气球会发生变化,这会影响后续的收益。
所以这道题不能只看当前一步的收益,而要考虑整个区间的最优结果。
三、为什么这道题不能直接正向思考?
普通思路可能是:
枚举第一个戳破哪个气球。
但是这样会遇到一个问题:
当我们戳破一个气球后,原数组中的相邻关系会发生变化。
例如:
[3, 1, 5, 8]
如果先戳破 1,数组变成:
[3, 5, 8]
此时 3 和 5 变成了相邻元素。
也就是说,数组结构一直在变化。
这会让状态非常难定义。
所以这道题的关键不是考虑:
第一个戳破哪个气球
而是反过来考虑:
最后一个戳破哪个气球
这是本题最核心的思想。
四、核心思想:假设 k 是区间中最后一个被戳破的气球
假设我们现在研究一个区间:
[i, j]
也就是从第 i 个气球到第 j 个气球。
如果我们假设 k 是这个区间里最后一个被戳破的气球,那么在戳破 k 之前,区间 [i, j] 中除了 k 之外的气球都已经被戳破了。
这时,k 左边最近的气球就是:
i - 1
k 右边最近的气球就是:
j + 1
所以最后戳破 k 能获得的硬币数就是:
val[i - 1] * val[k] * val[j + 1]
而在戳破 k 之前,区间 [i, j] 被分成了两个独立的小区间:
[i, k - 1]
[k + 1, j]
因此,总收益可以表示为:
左区间最大收益 + 右区间最大收益 + 最后戳破 k 的收益
也就是:
dp[i][k - 1] + dp[k + 1][j] + val[i - 1] * val[k] * val[j + 1]
这就是本题的状态转移核心。
五、为什么要在数组两边加 1?
题目中说:
如果气球的左边或右边没有气球,则默认值为 1。
为了方便计算,我们可以在原数组的两边各加一个虚拟气球 1。
例如原数组是:
nums = [3, 1, 5, 8]
处理后变成:
val = [1, 3, 1, 5, 8, 1]
这样做的好处是:
无论戳破哪个区间,我们都可以直接使用:
val[i - 1]
val[j + 1]
不用单独判断边界情况。
代码中对应部分是:
int n = nums.size();
vector<int> val(n + 2, 1);
for (int i = 0; i < n; i++) {
val[i + 1] = nums[i];
}
其中:
val[0] = 1
val[n + 1] = 1
分别表示左右两个虚拟边界。
六、状态定义
定义二维 DP 数组:
dp[i][j]
表示:
戳破区间
[i, j]内所有气球,能够获得的最大硬币数。
注意,这里的区间 [i, j] 指的是处理后的 val 数组中的真实气球区间。
也就是说:
i >= 1
j <= n
两个虚拟气球 val[0] 和 val[n + 1] 不会被戳破,只是用来辅助计算。
七、状态转移方程
对于区间 [i, j],枚举其中每一个位置 k,假设 k 是最后一个被戳破的气球。
那么:
dp[i][j] = max(
dp[i][j],
dp[i][k - 1] + dp[k + 1][j] + val[i - 1] * val[k] * val[j + 1]
);
其中:
dp[i][k - 1]
表示戳破左半部分 [i, k - 1] 的最大收益。
dp[k + 1][j]
表示戳破右半部分 [k + 1, j] 的最大收益。
val[i - 1] * val[k] * val[j + 1]
表示最后戳破 k 时获得的硬币数。
八、遍历顺序为什么要按区间长度从小到大?
由于:
dp[i][j]
依赖于:
dp[i][k - 1]
dp[k + 1][j]
这两个都是比 [i, j] 更小的区间。
所以我们必须先计算小区间,再计算大区间。
因此代码中采用了按照区间长度从小到大的遍历方式:
for (int len = 1; len <= n; len++) {
for (int i = 1; i + len - 1 <= n; i++) {
int j = i + len - 1;
for (int k = i; k <= j; k++) {
dp[i][j] = max(
dp[i][j],
dp[i][k - 1] + dp[k + 1][j] + val[i - 1] * val[k] * val[j + 1]
);
}
}
}
这里:
len
表示当前枚举的区间长度。
i
表示区间左端点。
j
表示区间右端点。
k
表示当前假设的最后一个被戳破的气球。
九、完整代码实现
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
class Solution {
public:
int maxCoins(vector<int>& nums) {
int n = nums.size();
// 在原数组两边各加一个虚拟气球 1
vector<int> val(n + 2, 1);
for (int i = 0; i < n; i++) {
val[i + 1] = nums[i];
}
// dp[i][j] 表示戳破区间 [i, j] 内所有气球能够获得的最大硬币数
vector<vector<int>> dp(n + 2, vector<int>(n + 2, 0));
// 枚举区间长度
for (int len = 1; len <= n; len++) {
// 枚举区间左端点
for (int i = 1; i + len - 1 <= n; i++) {
int j = i + len - 1;
// 枚举最后一个被戳破的气球 k
for (int k = i; k <= j; k++) {
dp[i][j] = max(
dp[i][j],
dp[i][k - 1] + dp[k + 1][j] + val[i - 1] * val[k] * val[j + 1]
);
}
}
}
return dp[1][n];
}
};
int main() {
Solution sol;
vector<int> nums1 = {3, 1, 5, 8};
cout << "Input: nums = [3,1,5,8]" << endl;
cout << "Output: " << sol.maxCoins(nums1) << endl;
vector<int> nums2 = {1, 5};
cout << "Input: nums = [1,5]" << endl;
cout << "Output: " << sol.maxCoins(nums2) << endl;
return 0;
}
十、样例分析
以:
nums = [3, 1, 5, 8]
为例。
添加虚拟气球后:
val = [1, 3, 1, 5, 8, 1]
我们要求的最终答案是:
dp[1][4]
也就是戳破真实气球区间:
[3, 1, 5, 8]
程序会从长度为 1 的区间开始计算:
dp[1][1]
dp[2][2]
dp[3][3]
dp[4][4]
然后计算长度为 2 的区间:
dp[1][2]
dp[2][3]
dp[3][4]
再计算长度为 3 的区间:
dp[1][3]
dp[2][4]
最后计算整个区间:
dp[1][4]
最终得到答案:
167
十一、复杂度分析
1. 时间复杂度
代码中有三层循环:
for len
for i
for k
分别用于枚举:
-
区间长度;
-
区间左端点;
-
最后戳破的气球位置。
所以时间复杂度为:
O(n^3)
2. 空间复杂度
使用了一个二维数组:
vector<vector<int>> dp(n + 2, vector<int>(n + 2, 0));
所以空间复杂度为:
O(n^2)
十二、本题的关键难点总结
这道题的难点不在代码,而在于状态设计。
很多人刚开始会想:
我应该先戳哪个气球?
但这样思考会让问题变得非常复杂,因为每戳破一个气球,数组的相邻关系都会改变。
正确的思路是反过来想:
在某个区间中,最后戳破哪个气球?
当我们确定 k 是最后一个被戳破的气球时,它左右两边的边界就确定了,分别是:
val[i - 1]
val[j + 1]
这样问题就可以被拆分成两个独立的子问题:
[i, k - 1]
[k + 1, j]
这正是区间动态规划的核心思想。
十三、区间 DP 模板总结
类似这种问题,一般可以考虑区间 DP。
常见的状态定义方式是:
dp[i][j] 表示区间 [i, j] 的最优解
常见的枚举方式是:
for (int len = 1; len <= n; len++) {
for (int i = 1; i + len - 1 <= n; i++) {
int j = i + len - 1;
for (int k = i; k <= j; k++) {
// 状态转移
}
}
}
区间 DP 的核心通常是:
枚举一个分割点
k,把大区间拆成两个小区间。
在本题中,k 表示的是:
区间 [i, j] 中最后一个被戳破的气球
十四、容易出错的地方
1. 不要把 k 理解成第一个戳破的气球
在状态转移中:
val[i - 1] * val[k] * val[j + 1]
成立的前提是:
k 是最后一个被戳破的气球
如果把 k 理解成第一个戳破的气球,那么这个公式是不成立的。
因为第一个戳破 k 时,它左右两边不一定是 i - 1 和 j + 1。
2. 不要忘记添加虚拟边界
如果不添加两个虚拟气球 1,边界情况会非常麻烦。
比如第一个气球或最后一个气球被戳破时,左右边界需要单独判断。
添加虚拟边界后,代码会简洁很多:
vector<int> val(n + 2, 1);
3. 区间长度必须从小到大枚举
因为大区间依赖小区间。
如果遍历顺序错误,可能会导致计算 dp[i][j] 时,它所依赖的子区间还没有被计算出来。
十五、最终总结
戳气球问题是一道非常经典的区间动态规划题目。
它的核心思想可以概括为三句话:
第一,给数组两边添加虚拟气球 1,方便处理边界。
第二,定义:
dp[i][j]
表示戳破区间 [i, j] 内所有气球能够获得的最大硬币数。
第三,枚举 k 作为区间 [i, j] 中最后一个被戳破的气球,进行状态转移:
dp[i][j] = max(
dp[i][j],
dp[i][k - 1] + dp[k + 1][j] + val[i - 1] * val[k] * val[j + 1]
);
这道题的真正难点不是代码实现,而是如何想到"最后戳破"这个逆向思维。
一旦理解了这个思想,整个区间 DP 的状态设计和转移方程就会变得非常自然。