news 2026/10/14 20:28:25

合并石子问题:从区间DP到四边形不等式优化

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
合并石子问题:从区间DP到四边形不等式优化

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朴素区间DPO(n^3)O(n^2)< 0.1s
n <= 500朴素区间DPO(n^3)O(n^2)约 2-5s
n <= 3000四边形不等式优化O(n^2)O(n^2)约 1-3s
n > 3000需进一步优化或换语言———

这个表是我自己跑出来的经验值,不同机器会有差异,但量级参考没问题。如果你在面试里遇到 n=100 的合并石子,直接写朴素版就行,别炫技上优化,反而容易写错。

5. 常见问题与排查技巧实录

5.1 答案偏大或偏小:先查前缀和和初始化

最常见的错误是答案不对。排查顺序我总结为三步:

  1. 检查dp[i][i]是否为 0。如果没初始化,单堆区间会保留 INF,导致所有依赖它的区间都错。
  2. 检查前缀和下标。pre[j] - pre[i-1]对应的是第 i 到第 j 堆,如果 stones 是 0-based,pre的构造要对应好。
  3. 检查循环顺序。如果外层是 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 枚举到 jk 范围改为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。如果中间某个值不对,就能快速定位到是哪一层循环出了问题。这个笨办法,比盯着代码空想快得多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 20:27:08

大规模MIP精确算法实战:分支定界、割平面与工程调优全解析

做了这么多年运筹优化&#xff0c;见过不少把MIP模型扔给求解器就撒手不管的情况。几十个变量的小问题&#xff0c;默认参数确实能跑出结果&#xff1b;可一旦变量数涨到几十万、上百万&#xff0c;约束铺开几千甚至上万行&#xff0c;黑盒求解器往往会暴露真实水平——要么根节…

作者头像 李华
网站建设 2026/10/14 20:26:05

FastReport.Net 4.0 V2013.2.5:.NET Framework 报表引擎深度实践指南

简介&#xff1a;本资源是面向.NET开发者的FastReport.Net报表组件完整部署与定制化实践包&#xff0c;适用于VS2010/VS2012环境下WinForm及ASP.NET网页报表开发&#xff0c;重点解决正版授权受限时的本地化安装、核心DLL强签名覆盖及PDF打印精度调优等实际问题。压缩包共747个…

作者头像 李华
网站建设 2026/10/14 20:24:58

配电网韧性提升:MPS动态调度复现全解析与Matlab+Yalmip实现

聊到我最近在做的这个复现项目之前&#xff0c;先讲个有点丢人的事&#xff1a;静态预配置部分我一周就跑通了&#xff0c;MPS动态调度却拖了将近一个月。那段时间几乎每天都能碰到新问题——数学模型理解偏差、Yalmip里二阶锥约束写法不对、Cplex数值警告刷屏、韧性曲线跟论文…

作者头像 李华
网站建设 2026/10/14 20:24:55

凸优化核心概念与机器学习应用:从凸集到对偶理论

很多人以为优化问题只有在深度学习里才显得头疼&#xff0c;直到真正接触凸优化&#xff0c;才发现居然有这么一片领地&#xff1a;只要把问题写成它的标准模样&#xff0c;局部最优就是全局最优&#xff0c;算法有复杂度保证&#xff0c;收敛性有理论支撑。我最早被它吸引&…

作者头像 李华
网站建设 2026/10/14 20:24:01

嵌入式Linux SD/MMC驱动调试实战:从物理层到块设备的全链路解析

1. 这不是“驱动代码阅读课”&#xff0c;而是一场嵌入式Linux底层通信的解剖实验你打开内核源码树&#xff0c;cd到drivers/mmc/目录下&#xff0c;看到core/、host/、card/三个子目录&#xff0c;第一反应可能是&#xff1a;“哦&#xff0c;又是分层设计”。但真正动手改过S…

作者头像 李华
网站建设 2026/10/14 20:22:49

vSphere SDK 6.0.0自动化实践:从连接到批量操作与避坑指南

简介&#xff1a;VMware vSphere SDK 6.0.0是一套面向虚拟化平台开发者的管理软件开发工具包&#xff0c;集成vSphere Web Services SDK、Storage Management SDK、ESX Agent Manager SDK、SSO Client SDK及Storage Policy SDK等组件&#xff0c;可用于解决vSphere环境下虚拟机…

作者头像 李华