C++ 动态规划经典题:戳气球问题详解——从区间 DP 到状态转移

一、题目背景

在动态规划问题中,有一类非常经典的问题叫做 区间动态规划

它的特点是:

问题的答案不是由单个位置决定的,而是由一个连续区间的最优解推导出来。

本文要讲解的题目是 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]

此时 35 变成了相邻元素。

也就是说,数组结构一直在变化。

这会让状态非常难定义。

所以这道题的关键不是考虑:

复制代码
第一个戳破哪个气球

而是反过来考虑:

复制代码
最后一个戳破哪个气球

这是本题最核心的思想。


四、核心思想:假设 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

分别用于枚举:

  1. 区间长度;

  2. 区间左端点;

  3. 最后戳破的气球位置。

所以时间复杂度为:

复制代码
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 - 1j + 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 的状态设计和转移方程就会变得非常自然。

相关推荐
洛水水5 小时前
数据库连接池详解
数据库·c++·mysql
码小猿的CPP工坊5 小时前
AI时代C++软件开发工程师的思考
c++·人工智能
蜡笔小马5 小时前
13.C++设计模式-策略模式
c++·设计模式·策略模式
计算机安禾5 小时前
【c++面向对象编程】第36篇:析构函数应永远不抛出异常——原因与最佳实践
开发语言·c++
ゆづき5 小时前
假如编程语言们有外号
java·c语言·c++·python·学习·c#·生活
REDcker14 小时前
有限状态机与状态模式详解 FSM建模Java状态模式与C++表驱动模板实践
java·c++·状态模式
basketball61615 小时前
C++ 构造函数完全指南:从入门到进阶
java·开发语言·c++
想唱rap16 小时前
IO多路转接之poll
服务器·开发语言·数据库·c++
落羽的落羽17 小时前
【算法札记】练习 | Week4
linux·服务器·数据结构·c++·人工智能·算法·动态规划