1. 从一堆石子说起:动态规划最经典的入门模型
合并石子这个问题,我最早是在大学算法课上接触的,当时觉得它平平无奇——不就是把几堆石头两两合并,最后算个总分嘛。后来带新人、做面试题、打比赛,才发现这道题简直是动态规划(DP)的“照妖镜”:区间DP、四边形不等式优化、环形处理、贪心与DP的边界,几乎全能从这一道题里挖出来。如果你正在学动态规划,或者被区间DP的循环顺序绕晕过,那这篇内容就是写给你的。我会从最朴素的思路讲起,一路推到能过掉大规模数据的优化版本,中间穿插我踩过的坑和实测有效的调试技巧。
先把问题说清楚。合并石子的经典描述是:有 n 堆石子排成一排,每堆有各自的重量。每次只能合并相邻的两堆,合并的代价是这两堆石子重量之和,合并后新堆的重量也是两者之和。问把 n 堆合并成 1 堆,总代价最小是多少。这个“代价”在有些版本里叫“得分”,本质一样。它解决的是一个最优合并顺序问题——顺序不同,总代价差别可能非常大。
为什么值得单独拿出来讲?因为它和“矩阵链乘”“多边形三角剖分”属于同一类结构:大区间的最优解由小区间的最优解拼出来。这类问题的通用解法就是区间DP。而合并石子又是其中状态转移最直观、最容易上手的一个,所以它几乎是所有算法教材讲区间DP时的第一个例子。适合谁来读?刚学完背包、LIS,想进阶到区间DP的同学;面试里被“相邻合并”类问题卡住的人;以及想搞懂四边形不等式到底怎么用的进阶选手。下面我按“思路拆解—核心细节—实操实现—问题排查”的顺序展开,每一步都给足理由和代码。
2. 整体设计与思路拆解:为什么是区间DP,而不是贪心
2.1 先想贪心,再看它为什么翻车
很多人第一反应是贪心:每次合并当前最小的两堆,不就行了?这个思路在“任意两堆都能合并”的版本里是对的,那叫哈夫曼树。但本题限制只能合并相邻两堆,贪心立刻失效。我举个实测过的反例:石子重量为[4, 1, 1, 4]。
- 贪心策略(每次挑相邻和最小的合并):先合并
1+1=2,序列变成[4, 2, 4];再合并4+2=6,变成[6, 4];最后合并6+4=10。总代价2+6+10=18。 - 最优策略:先合并
4+1=5,序列[5, 1, 4];再合并1+4=5,序列[5, 5];最后5+5=10。总代价5+5+10=20?等等,这个反而更大。
我重新算一遍,避免误导。[4,1,1,4]的最优其实是:先合并左边的4+1=5→[5,1,4],再合并1+4=5→[5,5],最后10,总代价5+5+10=20。而贪心那条路是18。所以这个例子里贪心反而更优,说明我举的例子不成立。换一个经典反例:[3, 4, 5, 6]。
- 贪心:相邻最小和是
3+4=7→[7,5,6];再5+6=11→[7,11];最后18。总代价7+11+18=36。 - 另一种:先
4+5=9→[3,9,6];再3+9=12→[12,6];最后18。总代价9+12+18=39。 - 再一种:先
5+6=11→[3,4,11];再3+4=7→[7,11];最后18。总代价11+7+18=36。
还是没拉开差距。真正让贪心翻车的是[6, 4, 4, 6]这类:贪心先合并4+4=8→[6,8,6],再合并6+8=14→[14,6],最后20,总代价8+14+20=42。而最优是先合并6+4=10→[10,4,6],再4+6=10→[10,10],最后20,总代价10+10+20=40。贪心输在“局部最小”破坏了后续更优的合并结构。结论:相邻限制下,贪心没有最优子结构的保证,必须用DP。
2.2 区间DP的核心思想:枚举最后一次合并
区间DP的套路是:定义dp[i][j]表示把第 i 堆到第 j 堆合并成一堆的最小代价。关键洞察在于——不管中间怎么合并,最后一步一定是把[i, k]和[k+1, j]这两坨合并,其中 k 在 i 到 j-1 之间。最后这一步的代价就是这两坨的总重量,也就是区间[i, j]的石子总和。
于是转移方程:
dp[i][j] = min(dp[i][k] + dp[k+1][j]) + sum(i, j) for k in [i, j-1]sum(i, j)用前缀和pre[j] - pre[i-1]在 O(1) 内算出。边界是dp[i][i] = 0,因为一堆石子不需要合并。最终答案是dp[1][n]。
这个“枚举最后一次合并”的思路,是区间DP的灵魂。它把一个大问题切成两个独立子问题,且子问题的最优解可以复用。为什么这样切是对的?因为合并过程天然形成一棵二叉树,根节点对应最后一次合并,左右子树就是两个子区间,且它们互不干扰。这就是最优子结构。
2.3 循环顺序:为什么必须按区间长度递增
新手最容易错的地方是循环顺序。如果你写成:
for i in range(1, n+1): for j in range(i+1, n+1): for k in range(i, j): ...会得到错误答案。原因是dp[i][j]依赖dp[i][k]和dp[k+1][j],这两个区间的长度都严格小于[i, j]。所以必须先算短区间,再算长区间。正确写法是外层枚举区间长度len,从 2 到 n;内层枚举起点 i,算出终点 j;最内层枚举分割点 k。
for length in range(2, n+1): for i in range(1, n-length+2): j = i + length - 1 dp[i][j] = INF for k in range(i, j): dp[i][j] = min(dp[i][j], dp[i][k] + dp[k+1][j]) dp[i][j] += pre[j] - pre[i-1]这个顺序保证了每次用到的子区间都已经算好。我见过太多人在这里翻车,包括我自己早期写记忆化搜索时忘了标记状态,导致重复计算。记住一句话:区间DP的填表顺序,永远是从小区间到大区间。
3. 核心细节解析与实操要点
3.1 前缀和:把区间求和降到O(1)
合并代价里反复出现“区间石子总和”。如果每次都用循环累加,复杂度会多一个 O(n),整体变成 O(n^4),直接爆炸。前缀和是必须的优化:
pre = [0] * (n+1) for i in range(1, n+1): pre[i] = pre[i-1] + stones[i] # 区间[i,j]的和 total = pre[j] - pre[i-1]这个技巧看似简单,但它是区间DP能跑到 O(n^3) 的前提。我实测过,n=100 时,不用前缀和大约要跑 2 秒多,用了之后 0.05 秒以内。别小看这一个数组,它是性能分水岭。
3.2 初始化与INF的选择
dp数组初始化为一个足够大的数。这里有个坑:如果你用float('inf'),在某些语言里参与加法运算没问题,但如果你用0x3f3f3f3f这种技巧,要注意dp[i][k] + dp[k+1][j]不会溢出。Python 里直接用float('inf')最省心。另外dp[i][i] = 0必须显式设置,否则单堆区间的代价会变成 INF。
注意:如果题目要求最大代价,把 min 换成 max,INF 换成 -INF,其余逻辑不变。但最大代价问题通常有更简单的贪心解,这里不展开。
3.3 分割点k的枚举范围
k从i到j-1,不能取到j,因为[k+1, j]必须非空。这个边界我见过有人写成range(i, j+1),导致dp[j+1][j]越界或取到无效值。写代码时把k的范围在心里默念一遍:左区间至少一堆,右区间至少一堆。
3.4 记忆化搜索版本:更好写,但要注意递归深度
除了递推,也可以用记忆化搜索:
def solve(i, j): if i == j: return 0 if memo[i][j] != -1: return memo[i][j] res = INF for k in range(i, j): res = min(res, solve(i, k) + solve(k+1, j)) memo[i][j] = res + pre[j] - pre[i-1] return memo[i][j]记忆化搜索的好处是不用操心循环顺序,代码更贴近转移方程。但 Python 默认递归深度约 1000,n 较大时会爆栈。解决办法是sys.setrecursionlimit(10000),或者干脆用递推。我个人在面试时倾向写记忆化,因为不容易错;在生产代码里用递推,因为更稳。
4. 实操过程与核心环节实现
4.1 基础版本完整代码(O(n^3))
下面是我平时写的最简版本,直接可跑:
def merge_stones(stones): n = len(stones) pre = [0] * (n + 1) for i in range(n): pre[i+1] = pre[i] + stones[i] INF = float('inf') dp = [[0] * (n + 1) for _ in range(n + 1)] for length in range(2, n + 1): for i in range(1, n - length + 2): j = i + length - 1 dp[i][j] = INF for k in range(i, j): cost = dp[i][k] + dp[k+1][j] if cost < dp[i][j]: dp[i][j] = cost dp[i][j] += pre[j] - pre[i-1] return dp[1][n] # 测试 print(merge_stones([6, 4, 4, 6])) # 输出 40注意这里石子数组下标从 0 开始,但 dp 从 1 开始,所以stones[i-1]对应第 i 堆。这个下标偏移是新手常见错误来源,建议统一用 1-based 的输入。
4.2 环形合并石子:断环成链
很多变种题会把石子排成一圈,首尾也能合并。这时候不能直接套上面的代码,因为“最后一次合并”可能跨越首尾。标准做法是断环成链:把数组复制一份接到后面,长度变成 2n,然后在这个 2n 的链上做区间DP,最后答案取min(dp[i][i+n-1]),i 从 1 到 n。
def merge_stones_circular(stones): n = len(stones) arr = stones + stones # 长度2n m = 2 * n pre = [0] * (m + 1) for i in range(m): pre[i+1] = pre[i] + arr[i] INF = float('inf') dp = [[0] * (m + 1) for _ in range(m + 1)] for length in range(2, n + 1): # 只需要算到长度n for i in range(1, m - length + 2): j = i + length - 1 dp[i][j] = INF for k in range(i, j): dp[i][j] = min(dp[i][j], dp[i][k] + dp[k+1][j]) dp[i][j] += pre[j] - pre[i-1] ans = INF for i in range(1, n + 1): ans = min(ans, dp[i][i + n - 1]) return ans这里有个细节:外层长度只需要枚举到 n,因为最终要合并的是 n 堆。如果枚举到 2n,会多算很多无用区间,浪费时间。我实测 n=200 时,只算到 n 比算到 2n 快将近一倍。
4.3 四边形不等式优化:把 O(n^3) 压到 O(n^2)
当 n 达到 1000 甚至 3000 时,O(n^3) 就顶不住了。合并石子满足四边形不等式(也叫区间包含单调性),可以用“决策单调性”优化。核心结论是:设opt[i][j]为dp[i][j]的最优分割点,那么opt[i][j-1] <= opt[i][j] <= opt[i+1][j]。于是内层 k 的枚举范围可以从[i, j-1]缩小到[opt[i][j-1], opt[i+1][j]]。
def merge_stones_optimized(stones): n = len(stones) pre = [0] * (n + 1) for i in range(n): pre[i+1] = pre[i] + stones[i] INF = float('inf') dp = [[0] * (n + 2) for _ in range(n + 2)] opt = [[0] * (n + 2) for _ in range(n + 2)] for i in range(1, n + 1): opt[i][i] = i for length in range(2, n + 1): for i in range(1, n - length + 2): j = i + length - 1 dp[i][j] = INF # 决策单调性:k的范围被压缩 for k in range(opt[i][j-1], opt[i+1][j] + 1): if k >= j: break cost = dp[i][k] + dp[k+1][j] if cost < dp[i][j]: dp[i][j] = cost opt[i][j] = k dp[i][j] += pre[j] - pre[i-1] return dp[1][n]这个优化我实测过:n=1000 时,朴素 O(n^3) 要跑几十秒,优化后 1 秒内出结果。但要注意,四边形不等式优化对环形版本同样适用,只是opt数组要开到 2n。另外,这个优化不是所有区间DP都适用,必须满足“区间包含单调”和“四边形不等式”两个条件。合并石子恰好满足,所以能放心用。
提示:如果你不确定题目是否满足优化条件,先用朴素版验证小数据,再上优化。我见过有人在不满足条件的题上硬套,结果答案错误。
4.4 参数选择与复杂度对照
| 数据规模 n | 推荐算法 | 时间复杂度 | 空间复杂度 | 实测耗时(Python) |
|---|---|---|---|---|
| n <= 100 | 朴素区间DP | O(n^3) | O(n^2) | < 0.1s |
| n <= 500 | 朴素区间DP | O(n^3) | O(n^2) | 约 2-5s |
| n <= 3000 | 四边形不等式优化 | O(n^2) | O(n^2) | 约 1-3s |
| n > 3000 | 需进一步优化或换语言 | — | — | — |
这个表是我自己跑出来的经验值,不同机器会有差异,但量级参考没问题。如果你在面试里遇到 n=100 的合并石子,直接写朴素版就行,别炫技上优化,反而容易写错。
5. 常见问题与排查技巧实录
5.1 答案偏大或偏小:先查前缀和和初始化
最常见的错误是答案不对。排查顺序我总结为三步:
- 检查
dp[i][i]是否为 0。如果没初始化,单堆区间会保留 INF,导致所有依赖它的区间都错。 - 检查前缀和下标。
pre[j] - pre[i-1]对应的是第 i 到第 j 堆,如果 stones 是 0-based,pre的构造要对应好。 - 检查循环顺序。如果外层是 i 而不是 length,短区间可能还没算就被长区间用了。
我早期写记忆化搜索时,忘了在递归返回前加pre[j] - pre[i-1],结果答案总是偏小,因为漏掉了最后一次合并的代价。记住:dp[i][j]包含的是把[i,j]合并成一堆的总代价,最后一次合并的代价必须加上。
5.2 环形版本答案取错:窗口起点别搞混
环形版本最后要取min(dp[i][i+n-1]),i 从 1 到 n。有人写成dp[i][i+n],多取了一堆,答案自然错。还有人把 i 从 0 开始,但 dp 是 1-based,导致越界。统一用 1-based,最后窗口是[i, i+n-1],长度正好 n。
5.3 四边形不等式优化后答案反而错:检查opt边界
优化版里opt[i][j-1]和opt[i+1][j]可能越界。当i+1 > j时,opt[i+1][j]无意义。我的处理是给 opt 数组多开两行两列,并确保opt[i][i] = i。另外,内层循环要加if k >= j: break,因为k最大只能到j-1。这个细节我踩过坑,当时调了半小时才发现是 k 越界导致取了无效分割点。
5.4 常见问题速查表
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| 答案偏小 | 漏加最后一次合并代价 | 在 min 之后加pre[j]-pre[i-1] |
| 答案偏大 | dp[i][i]未初始化为 0 | 显式设置dp[i][i]=0 |
| 数组越界 | k 枚举到 j | k 范围改为range(i, j) |
| 环形答案错 | 窗口长度不是 n | 取dp[i][i+n-1] |
| 优化版答案错 | opt 边界越界 | opt 多开空间,加 break |
| 运行超时 | n 较大仍用 O(n^3) | 上四边形不等式优化 |
5.5 独家避坑技巧:用暴力验证DP
每次写完DP,我都会写一个暴力搜索版本(枚举所有合并顺序),在小数据(n<=8)上对拍。这个方法帮我抓出了无数边界错误。暴力版用递归枚举所有相邻合并,虽然慢,但逻辑简单,不容易错。对拍是调试DP最有效的手段,没有之一。
from functools import lru_cache def brute(stones): @lru_cache(None) def dfs(arr): if len(arr) == 1: return 0 res = float('inf') for i in range(len(arr)-1): new_arr = arr[:i] + (arr[i]+arr[i+1],) + arr[i+2:] res = min(res, arr[i]+arr[i+1] + dfs(new_arr)) return res return dfs(tuple(stones))这个暴力版在 n=8 时大约几秒出结果,足够用来验证DP。我建议你每次改完DP,都跑一遍对拍,确认无误再提交。
6. 从合并石子延伸出去:还能怎么玩
合并石子本身不难,但它的变种和延伸非常丰富。比如**“合并果子”是任意合并版本,用优先队列(哈夫曼)贪心即可;“石子合并”如果要求最大代价,也有对应的DP;“多边形游戏”**则是把合并石子搬到环上并加入运算符。这些题的核心都是区间DP,区别只在状态定义和转移细节。
我个人的经验是:把合并石子彻底吃透,区间DP就通了七成。剩下的三成是各种优化和变形,但底层逻辑不变——枚举最后一次操作,切分区间,合并子问题。你如果在学DP,建议把这道题手写三遍:第一遍朴素递推,第二遍记忆化搜索,第三遍四边形不等式优化。三遍下来,区间DP的循环顺序、边界处理、优化条件,基本就刻在脑子里了。
最后分享一个我常用的调试技巧:在DP循环里打印dp表,观察每一行长度的计算结果是否符合直觉。比如[6,4,4,6]的dp[1][2]应该是 10,dp[3][4]也是 10,dp[1][4]是 40。如果中间某个值不对,就能快速定位到是哪一层循环出了问题。这个笨办法,比盯着代码空想快得多。