戳气球 图解题解
这道题到底在问什么
- 输入
- nums=[3,1,5,8]
- 输出
- 167
先想最直接的笨办法
区间 (0,3) 里枚举「最后戳谁」k=1…2:每个候选 = 左半已戳完 dp[0][k] + 这一戳 1×nums[k]×5 + 右半 dp[k][j]。取最大的那条路。(动画第 13 步)
最优解:为什么这么做
一句话答案:LeetCode 312 戳气球的标准解法是区间 DP:把「先戳谁」倒过来想成「谁最后戳」——区间里最后被戳的气球,左右一定紧贴区间边界,这一戳的硬币是定值,左右两半互不干扰,问题才能拆成独立子问题。两端补 1 后按区间长度从短到长填表,时间 O(n³)、空间 O(n²)。
戳气球这道题真正难在哪
题目给一排气球 nums,戳爆第 i 个能拿 nums[左邻] × nums[i] × nums[右邻] 枚硬币,戳爆之后它的左右两个邻居会接续相邻;数组两端越界的位置按值为 1 的气球算。求把气球全部戳完,最多能拿多少硬币。难点在于:每戳一个气球,剩下气球的邻居关系就变了,同一个气球在不同的戳法顺序里得分完全不同——顺序影响收益。
为什么枚举「先戳谁」的思路走不通
最直觉的想法是递归枚举第一个戳谁:戳掉它,剩下的气球接成新的一排,再对新排递归。但这条路有两个致命伤。其一,戳法顺序有 n! 种,纯枚举指数爆炸。其二,更本质的是子问题没法复用:先戳掉一个气球后,它两侧的气球变成邻居,后续每一步的得分都依赖你之前戳了谁,拆不出独立的子问题,动态规划无从下手。
症结在于先戳的气球会改变局面。那就反着问:有没有哪个决策,做出来之后不影响别人?
为什么倒过来枚举「最后戳谁」就能拆开
关键观察:对一段区间来说,与其想谁先被戳,不如想谁最后被戳。假设开区间 (i, j) 内最后戳爆的是位置 k——戳它的那一刻,区间内其他气球早都没了,k 的左右邻居必然就是边界 i 和 j,所以这一戳的硬币是 nums[i] × nums[k] × nums[j],一个不依赖任何顺序的定值。
更妙的是,既然 k 最后才被戳,那么在此之前 k 一直立在原地当「隔板」:左半段 (i, k) 里的气球无论怎么戳,右邻居最多碰到 k,绝不会越过去碰到右半段;右半段同理。左右两半彻底解耦,各自独立求最优——这就是区间 DP 的典型思路。
dp 数组为什么定义成开区间、两端为什么补 1
先在原数组两端各补一个 1,得到 a = [1] + nums + [1],这两个哨兵代表「越界处值为 1 的气球」,且永远不被戳,只当墙用——补了它们,nums[i] × nums[k] × nums[j] 这条公式对边界气球也成立,不用特判。
定义 dp[i][j] 为「戳光开区间 (i, j) 内全部气球能拿的最大硬币」,i、j 本身不戳。转移就是枚举区间内最后戳的位置 k:dp[i][j] = max(dp[i][k] + a[i] × a[k] × a[j] + dp[k][j]),k 取 i+1 到 j-1。填表必须按区间长度从短到长:dp[i][j] 依赖的 dp[i][k] 和 dp[k][j] 都是更短的区间,短的没算好,长的就没依据。答案是 dp[0][n-1],即戳光整排原始气球;对示例 [3, 1, 5, 8],这个值是 167。
戳气球的复杂度怎么算,哪里容易翻车
时间 O(n³):区间 (i, j) 共约 n² 个,每个区间要枚举 O(n) 个候选 k。空间 O(n²),存整张二维区间表。
两个高频翻车点:一是忘了两端补 1,边界气球的得分公式就会越界或算错;二是填表顺序按普通的行列双重循环来写,会在算长区间时用到还没填的短区间——正确写法是外层循环区间长度 L 从 2 到 n-1,内层再枚举左端点。记住「先戳会缠住、最后戳才独立」,转移方程可以自己推出来。
▶ 动画逐步走查(共 23 步)——想跟着动画一帧帧对照就展开
- 3关键转念:不去想「先戳谁」,而是想「谁最后戳」。最后戳的那个,左右一定是区间边界,硬币就定死了——这样子问题才不互相缠住。
- 4补边界后行/列都是这 6 个气球(角标:值)。dp[i][j] 只管「i 和 j 之间」那些气球,i、j 本身不戳、只当墙。答案最后落在 dp[0][5]。
- 5区间 (0,2) 里只有 1 个气球(位置 1,值 3):它就是最后戳的,左右紧贴边界 0、2 → 硬币 = 1×3×1 = 3。
- 6落子:dp[0][2] = 3,最优是「最后戳位置 1(值 3)」那条路。
- 7区间 (1,3) 里只有 1 个气球(位置 2,值 1):它就是最后戳的,左右紧贴边界 1、3 → 硬币 = 3×1×5 = 15。
- 8落子:dp[1][3] = 15,最优是「最后戳位置 2(值 1)」那条路。
- 9区间 (2,4) 里只有 1 个气球(位置 3,值 5):它就是最后戳的,左右紧贴边界 2、4 → 硬币 = 1×5×8 = 40。
- 10落子:dp[2][4] = 40,最优是「最后戳位置 3(值 5)」那条路。
- 11区间 (3,5) 里只有 1 个气球(位置 4,值 8):它就是最后戳的,左右紧贴边界 3、5 → 硬币 = 5×8×1 = 40。
- 12落子:dp[3][5] = 40,最优是「最后戳位置 4(值 8)」那条路。
- 13区间 (0,3) 里枚举「最后戳谁」k=1…2:每个候选 = 左半已戳完 dp[0][k] + 这一戳 1×nums[k]×5 + 右半 dp[k][j]。取最大的那条路。
- 14落子:dp[0][3] = 30,最优是「最后戳位置 1(值 3)」那条路。
- 15区间 (1,4) 里枚举「最后戳谁」k=2…3:每个候选 = 左半已戳完 dp[1][k] + 这一戳 3×nums[k]×8 + 右半 dp[k][j]。取最大的那条路。
- 16落子:dp[1][4] = 135,最优是「最后戳位置 3(值 5)」那条路。
- 17区间 (2,5) 里枚举「最后戳谁」k=3…4:每个候选 = 左半已戳完 dp[2][k] + 这一戳 1×nums[k]×1 + 右半 dp[k][j]。取最大的那条路。
- 18落子:dp[2][5] = 48,最优是「最后戳位置 4(值 8)」那条路。
- 19区间 (0,4) 里枚举「最后戳谁」k=1…3:每个候选 = 左半已戳完 dp[0][k] + 这一戳 1×nums[k]×8 + 右半 dp[k][j]。取最大的那条路。
- 20落子:dp[0][4] = 159,最优是「最后戳位置 1(值 3)」那条路。
- 21区间 (1,5) 里枚举「最后戳谁」k=2…4:每个候选 = 左半已戳完 dp[1][k] + 这一戳 3×nums[k]×1 + 右半 dp[k][j]。取最大的那条路。
- 22落子:dp[1][5] = 159,最优是「最后戳位置 4(值 8)」那条路。
- 23区间 (0,5) 里枚举「最后戳谁」k=1…4:每个候选 = 左半已戳完 dp[0][k] + 这一戳 1×nums[k]×1 + 右半 dp[k][j]。取最大的那条路。
- 24落子:dp[0][5] = 167,最优是「最后戳位置 4(值 8)」那条路。
- 25右上角 dp[0][5] = 167:戳爆全开区间 (0,5)(也就是原始 [3,1,5,8] 全部气球)的最大硬币。两端那两个虚拟 1 永不被戳,只当墙。
⚠️ 容易写错的地方
✗ 错:枚举「先戳谁」
✓ 对:枚举「最后戳谁」k
先戳会改变两邻、子问题互相缠住;最后戳的 k 两边一定是区间边界,硬币定死、子问题独立
✗ 错:忘记两端补 1
✓ 对:balloons=[1]+nums+[1]
边界外当作值 1 的气球,补上后 nums[i]*nums[k]*nums[j] 公式统一不用特判
✗ 错:按行/列顺序填表
✓ 对:按区间长度从短到长填
dp[i][j] 依赖更短的 dp[i][k]、dp[k][j],短区间必须先算好
完整代码(Python / C++ / Java)
Python
def maxCoins(nums):
a = [1] + nums + [1]
n = len(a)
dp = [[0]*n for _ in range(n)]
for L in range(2, n): # L = j - i
for i in range(0, n - L):
j = i + L
for k in range(i+1, j):
v = dp[i][k] + a[i]*a[k]*a[j] + dp[k][j]
if v > dp[i][j]: dp[i][j] = v
return dp[0][n-1]C++
int maxCoins(vector<int>& nums){
vector<int> a; a.push_back(1);
for(int x: nums) a.push_back(x);
a.push_back(1);
int n = a.size();
vector<vector<int>> dp(n, vector<int>(n, 0));
for(int L = 2; L < n; L++)
for(int i = 0; i + L < n; i++){
int j = i + L;
for(int k = i+1; k < j; k++)
dp[i][j] = max(dp[i][j], dp[i][k] + a[i]*a[k]*a[j] + dp[k][j]);
}
return dp[0][n-1];
}Java
int maxCoins(int[] nums){
int m = nums.length, n = m + 2;
int[] a = new int[n];
a[0] = 1; a[n-1] = 1;
for (int i = 0; i < m; i++) a[i+1] = nums[i];
int[][] dp = new int[n][n];
for (int L = 2; L < n; L++)
for (int i = 0; i + L < n; i++) {
int j = i + L;
for (int k = i+1; k < j; k++)
dp[i][j] = Math.max(dp[i][j], dp[i][k] + a[i]*a[k]*a[j] + dp[k][j]);
}
return dp[0][n-1];复杂度
时间
O(n³)
n² 个区间,每个枚举 O(n) 个最后戳的 k
空间
O(n²)
整张二维区间表
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 戳气球 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
为什么枚举「最后戳」而不是「最先戳」?+
最先戳的气球,戳完会让它左右邻接,影响后续所有计算,子问题不独立;而最后戳的 k,戳它时区间内别的都没了,左右紧邻边界 i、j,硬币 nums[i]*nums[k]*nums[j] 是定值,左右两半 (i,k) 与 (k,j) 完全独立,正好拆成两个子问题。
为什么要在两端补 1?+
原数组两端的气球,戳它时一侧越界,题目规定越界当作值 1 的气球。补上两个 1 当哨兵后,所有气球的左右邻都存在,nums[i]*nums[k]*nums[j] 公式不用对边界特判。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 戳气球 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
把这道题真正拿下
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。