☰
分治算法实战:切割篱笆问题的区间DP与贪心优化
2026/10/21 19:54:11 网站建设 项目流程

做算法题或者搞过工程优化的人,多半会遇到这类场景:手里一根长篱笆,要求切成一堆长短不一的段子,每次下刀收的钱不是按刀数算,而是按“被切的这根当前有多长”来算。第一次看很容易懵,直觉告诉我先切最大的,后来一算才发现亏了。这个问题其实有个经典名字,叫“切割篱笆问题”,也就是 Fence Repair。网上最常见解法是优先队列模拟哈夫曼合并,从结果倒推最优,但很多人忽略了它背后的分治思维:把一个完整区间分成左右子区间,分别求解再合并,最后得到全局最优。

这篇内容会从分治算法的角度完整拆一遍:先看清问题的两种常见设定,再推导分治递推式,接着给可运行的代码和手动演算过程,最后聊聊我踩过的坑和排查思路。无论你是算法初学者,还是想在工程里处理类似的分段合并优化,都可以参考这套思路。

1. 切割篱笆问题的两种典型设定

“切割篱笆”在不同教材和题目里其实长着两张脸,先分清楚才不会后面用错模型。

1.1 设定一:按目标长度切段的最小费用问题

在这个模型里,你有一段总长度为 L 的篱笆,目标是把它切成若干个指定长度的小段,例如 2、3、5。每次切割只能选择当前某一段,把它从中间分成两段,这次切割的费用等于这一段的当前长度。问:怎样切,才能让总费用最低。

举个具体例子。总长 10 米的篱笆,要切成 2、3、5 三段。如果第一刀切成 5 和 5,成本 10;第二刀把其中一根 5 切成 2 和 3,成本 5,总成本 15。如果第一刀切成 2 和 8,成本 10;第二刀把 8 切成 3 和 5,成本 8,总成本 18。如果第一刀切成 3 和 7,成本 10;第二刀把 7 切成 2 和 5,成本 7,总成本 17。三种切法里,第一种最便宜。

这个模型的特点是:切割顺序不同,费用会差很多;段数越多,排列组合爆炸,靠枚举完全不可行。它和“石子合并”问题有异曲同工之妙——都是通过合并最轻最小的结构来达到最优。

1.2 设定二:连续篱笆的最大矩形面积问题

另一种常见设定来自直方图场景:一排竖直篱笆板,每块高度不同,现在想用一块完整的矩形模板去切割出一块最大的矩形广告区,问最大面积是多少。比如高度数组是 [2,4,5,2,3],最大矩形不一定是最高板贡献的,而更可能是跨过某一段共同高度的连续区域。

这个模型也可以分治:找到区间内最矮的板,那么最大矩形要么完全在它左侧,要么完全在它右侧,要么跨过它。跨过它的情况,最大面积就是“最矮板高度乘以区间宽度”。对左右两侧递归求解即可。这个设定和第一种模型不同,但分治的思想完全一致:大区间拆成小区间,递归处理,再合并结果。

本文主线放在第一种模型上,第二种会在最后作为拓展思路给出参考。先读懂第一种,你就能自然迁移到第二种。

2. 为什么用分治:把“切的过程”改造成“切点递归”

很多人看到这个问题的第一反应是贪心:每次选最短的两段合并。这个思路完全正确,但它更像是在“逆向拼篱笆”,不是正向设计算法。分治的切入点是:不要想第一步怎么切,而是想最后一步发生在哪。

2.1 从“最后一步”倒推:切割过程是一棵二叉树

任意一种合法的切割顺序,都可以画成一棵二叉树。树的根节点是原始的整段篱笆,叶子节点是最终的目标小段。内部节点代表某一次切完后的中间段,它的左孩子和右孩子分别是一次切割产生的两段。

反过来,任何一棵以目标段为叶子、以原始总长为根的二叉树,都对应一种切割顺序。整棵树确定后,总费用等于所有内部节点的长度之和,因为每遇到一个内部节点,就意味着有一刀切在这个长度的段上。

这个结构很重要:它把“过程”变成了“结构”,而分治算法最擅长的就是处理树形结构。大问题“整棵树的费用”可以拆成“左子树的费用 + 右子树的费用 + 根节点的费用”,天然满足分治的分解-求解-合并三步。

2.2 分治递推式的建立

假设目标段按切割顺序排成一个区间,索引从 i 到 j(半开区间,表示第 i 段到第 j-1 段),这些段现在还是一个连续的完整大段,长度等于所有目标段长度之和。定义 dp[i][j] 表示“把这段篱笆切分成 [i, j) 区间内所有目标段的最小费用”。

注意,dp[i][j] 里的区间并不是篱笆上的物理位置,而是逻辑上“当前这堆本来连在一起的目标段”的集合。当区间内只有一个目标段时,已经不需要切了,所以 dp[i][i+1] = 0。

对于更长区间,可以枚举“最后一次切割”的分界点 k。最后一次切割时,把整个区间分成了左半边 [i, k) 和右半边 [k, j),此时被切的这段篱笆长度为所有段长度之和,费用就是 sum(i, j)。在这之前,左右两半已经各自被切好,费用为 dp[i][k] 和 dp[k][j]。于是得到递推式:

dp[i][j] = min( dp[i][k] + dp[k][j] + sum(i, j) )

其中 k 从 i+1 到 j-1 枚举,sum(i, j) 表示目标段 [i, j) 的总长度,用前缀和 O(1) 计算。

这个递推式非常像矩阵链乘问题:把一个大区间拆成两个子区间,枚举所有分割点,选择最优。不同之处在于,这里的附加代价 sum(i, j) 只取决于区间范围,与分割点 k 无关。这个特性让它后续可以结合四边形不等式优化,但通常不需要。

2.3 与贪心/优先队列方法的关系

上面递推式是自顶向下的分治思维。而哈夫曼式的贪心是从叶子出发,自底向上模拟合并:每次取当前最短的两段,合并成一段新的,合并代价等于两段之和,反复直到只剩一段。总合并代价就是最小切割费用。

为什么两者等价?因为任意切割二叉树中,叶子到根的路径长度总和,等于所有内部节点长度之和。而哈夫曼树正是使“叶子加权路径长度和最小”的二叉树。叶子权重就是目标段长度,内部节点路径长度累计等价于切割费用。所以优先队列解法本质上是构造最优二叉树,也是分治树的特例。

如果你在面试或竞赛中只要结果,优先队列解法 O(n log n) 是最优的。如果题目要求你“设计分治算法”或者关注思维过程,自顶向下的递归+记忆化更合适。它更贴近人类思考方式,也更容易扩展到“切割点受限”“费用非线性”这类变形题。

3. 分治算法设计与复杂度分析

理解了递推式,接下来就是把它变成可运行的代码。这里要注意:不要天真地直接写暴力递归,否则复杂度会高到你怀疑人生。

3.1 朴素递归:枚举所有分界点

最容易想到的写法是递归函数 solve(i, j):

  • 如果 j - i == 1,返回 0。
  • 否则枚举 k,返回 min(solve(i, k) + solve(k, j) + sum(i, j))。

代码很短,但它会重复计算大量子区间。举个例子,n=4 时,solve(0,4) 会调用 solve(0,2)、solve(2,4) 等,而这些子区间在其它分支里又会被调用。子区间个数是 O(n^2),每个子区间内又要枚举 O(n) 个分割点,理论上记忆化后是 O(n^3)。但如果不记忆化,递归树会呈指数级增长,n=20 就跑不动了。

所以,朴素递归只能用来验证思路,不能作为最终方案。如果你发现自己写出的递归没有用数组缓存中间结果,请立刻意识到这是一个性能灾难。

3.2 记忆化优化:区间DP形态

把递归过程中计算过的 dp[i][j] 存下来,就是记忆化分治。本质上等价于区间动态规划。推荐直接用自底向上的填表方式,代码更稳定,也可避免递归过深。

用前缀和数组 pref 记录长度累加,则 sum(i, j) = pref[j] - pref[i](这里 j 是目标段索引的右边界,pref 下标与目标段一一对应)。

核心逻辑如下(C++ 风格):

#include <vector> #include <algorithm> using namespace std; int minFenceCost(vector<int>& segs) { int n = segs.size(); if (n <= 1) return 0; vector<int> pref(n + 1, 0); for (int i = 0; i < n; i++) pref[i + 1] = pref[i] + segs[i]; // dp[i][j] 表示把区间 [i, j) 的目标段切成型的最小费用 vector<vector<int>> dp(n + 1, vector<int>(n + 1, 0)); for (int len = 2; len <= n; len++) { // 区间内目标段个数 for (int i = 0; i + len <= n; i++) { int j = i + len; int total = pref[j] - pref[i]; dp[i][j] = INT_MAX; for (int k = i + 1; k < j; k++) { int cost = dp[i][k] + dp[k][j] + total; if (cost < dp[i][j]) dp[i][j] = cost; } } } return dp[0][n]; }

这里 len 从 2 开始,因为 len=1 时不需要切。dp 表的大小是 (n+1) x (n+1),实际用到的是 i<j 的上三角区域。时间 O(n^3),空间 O(n^2)。

3.3 三种解法的复杂度对比

解法时间复杂度空间复杂度适用场景
朴素递归枚举指数级(卡特兰数级)O(n)(递归栈)仅 n<=5 时手算用
递归 + 记忆化O(n^3)O(n^2)思维清晰,n<=100 可用
自底向上区间 DPO(n^3)O(n^2)稳定可靠,适合实现
优先队列贪心O(n log n)O(n)原始问题求最优解的最快方案

如果题目只要求最小费用,优先队列就是标准答案。但如果题目要求“输出切割方案”,理解分治/DP会让你更容易回溯出每一步切在哪里。

3.4 当分治遇上重叠子问题

有一点必须拎清楚:分治算法通常适用于子问题不重叠的场景,比如归并排序中,左右两个子数组完全独立。切割篱笆问题却存在重叠子问题——同一个区间 [i, k) 可能在不同上层区间中被反复需要。所以这个问题的分治解法必须配合记忆化,或者说,它本质上是“用分治的思路去思考、用 DP 的机制去实现”。

普通分治的时间是子问题数量之和;带重叠的“伪分治”如果不记忆化,时间会变成子问题组合数。面试中如果被问到这个题,开口先说“虽然思路是分治,但实现上需要记忆化,否则退化”,这一句就能体现出你真的理解。

4. 完整实操:从一个具体案例走通全流程

光看递推式还是不够,我建议你拿笔手动算一遍。这里用一个 3 段的例子,把所有区间值都算出来,整个过程会非常直观。

4.1 案例数据与参数

假设目标段长度为 [2, 3, 5],总长度 10。目标段索引 0,1,2。

前缀和:

pref[0] = 0 pref[1] = 2 pref[2] = 5 pref[3] = 10

区间定义:

  • dp[0][1]:只含第 0 段,长度 2,不需要切,费用 0。
  • dp[1][2]:只含第 1 段,长度 3,费用 0。
  • dp[2][3]:只含第 2 段,长度 5,费用 0。

4.2 计算长度为 2 的区间

区间 [0,2):包含目标段 2 和 3,总长度 pref[2]-pref[0]=5。当前它们连成一根长度为 5 的段,要切成 2 和 3,只需要一次切割,切割费用就是 5。枚举 k=1,dp[0][1]+dp[1][2]+5 = 0+0+5=5。所以 dp[0][2]=5。

区间 [1,3):包含目标段 3 和 5,总长度 pref[3]-pref[1]=8。切成 3 和 5,费用为 8。所以 dp[1][3]=8。

区间 [0,3):这就是最终答案区间。总长度 pref[3]-pref[0]=10。枚举 k:

  • k=1:dp[0][1]+dp[1][3]+10 = 0 + 8 + 10 = 18。
  • k=2:dp[0][2]+dp[2][3]+10 = 5 + 0 + 10 = 15。

取最小值,dp[0][3]=15。这样就得出了最优切割费用 15,对应切割方案:第一刀切出 5 和 5,第二刀把其中一个 5 切成 2 和 3。

4.3 手动模拟切割过程

为了让“15 元”看起来真实,把实际切法再过一遍:

  1. 初始整段为长度 10。在位置 5 处切一刀,得到两根长度分别为 5 的段。此时费用 10。
  2. 拿起其中一根长度为 5 的段,在位置 2 处切一刀,得到长度 2 和长度 3 的两段。此时费用 10+5=15。
  3. 最终篱笆被切成了 2、3、5 三段,总费用 15。

如果你尝试先切 2,剩下 8,再切 3 和 5,总费用是 18。多出的 3 元就来自“被切的段更长,单价更高”。这个例子虽然简单,却能把“为什么要选择合并策略”讲清楚。

4.4 用优先队列复盘

把 [2,3,5] 放进最小堆:

  • 取出 2 和 3,合并成 5,合并代价 5。堆内现在有 5,5。
  • 取出 5 和 5,合并成 10,合并代价 10。堆内现在有 10。
  • 总合并代价 5+10=15。

结果和分治/DP 一模一样。注意这里的“合并代价”并不是真正的切割动作,而是等价于切割费用的反向累计。理解了这一步,你对这个问题的理解就基本到位了。

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

我在实际写代码和帮人 review 的过程中,见过好几个反复出现的坑。整理出来供你排查。

5.1 误区一:把目标段的物理顺序固定住

很多初学者拿到这个题,会想到“把一段篱笆按顺序切成这些段”,于是递归时只允许在目标段边界处切割,把一个区间从左往右切完。这会导致结果偏大或错误。

实际上,题目(通常)并不规定最终小段必须按某种空间顺序排列。你只要切出这些长度即可,切割顺序完全自由。比如目标段 [5,2,3],最优切法是第一刀切出两个 5,然后把其中一个 5 切成 2 和 3,这里小段 2 和 3 在最终拼回去时并不一定贴着原始位置的顺序。分治区间里的“顺序”是为了枚举方便,不是物理约束。

如果只固定顺序,dp 递推式会变成线性 DP,反而错过了真正的全局最优。所以先确认题目是否允许任意顺序,再决定算法。

5.2 误区二:递归分治没有加记忆化

我见过有人把朴素递归写出来,自测 n=6 勉强通过,n=12 就开始卡顿,n=15 直接爆掉。原因就是没有缓存中间状态。

解决方法是:要么把递归函数包一层 map 或二维数组缓存;要么直接改成自底向上填表。我建议在小规模调试时用递归+记忆化,它能让你紧贴分治思维;在正式提交时用自底向上,避免递归栈带来的不必要风险。

自底向上填表时最容易写错的是循环边界。这里给个检查方法:len 从 2 到 n,i 从 0 到 n-len,j = i+len。dp 定义是“半开区间” [i, j),所以 j 最大为 n。很多 bug 就出在把 j 写成 n-1,导致最后一个目标段永远没被包含进去。

5.3 误区三:把“当前段长度”误当成“被切掉部分的长度”

有朋友问:切一刀时费用不是应该等于切下来那一段的长度吗?不是的,题目说的是“切割成本等于当前被切木料的总长度”,也就是说,无论你从一根长段上切下多么小的一截,只要这段整体长度为 X,本次费用就是 X。

举例:一根长为 8 的段,切出长度 3 和 5,费用是 8,而不是 3 或 5。这在逆向合并中对应合并 3 和 5 时代价为 8。把这一点弄错,递推式中的 sum(i, j) 就会写错,结果会系统性偏低。

5.4 误区四:认为优先队列贪心可以处理所有变体

优先队列法之所以有效,是因为每次合并最短两段能构造出全局最优二叉树。但如果问题加了一个限制,比如“某些段必须从同一根原料上切,不允许互换”,这个性质就失效了。又或者切割费用是“长度平方”这样的非线性函数,哈夫曼合并也不再成立。此时分治 DP 的价值就体现出来了:只要递推式能准确描述局部费用,枚举分割点仍然能求出最优解。

我的建议是:基础版用优先队列快速拿分,遇到变体优先考虑分治/区间DP,不要一头扎进贪心。

5.5 调试小技巧:写一个暴力枚举器

在 n 比较小的时候,可以写一个暴力程序:枚举所有可能的切割点组合,找到最小费用。把它当作标准答案,与你的分治/DP 结果比对。这种方法能快速定位递推式错误、区间边界错误、前缀和错误。

暴力枚举可以用“当前所有段长集合”做状态,每次选一段并枚举切点,使用记忆化搜索。虽然是指数级,但 n<=7 时毫无压力。我每次改完这类题目,都会先把暴力、DP、优先队列三个结果同时跑一遍,三个输出一致才敢提交。

6. 扩展:当“切割”遇到更多约束

如果你觉得上面的模型太简单,这里还有几个方向可以继续延伸,全部围绕分治思维展开。

6.1 切割点位置固定:直线上的分段问题

有些实际问题并不是任意位置都能下刀,而是在篱笆上有若干个预置的切点。比如一根长 L 的篱笆上有 m 个可切割点,每次切割费用为当前段长度,求切完所有切点的最小费用。这个问题不能用哈夫曼贪心,因为切点顺序是固定的——你只能按这些点的物理位置切。

此时恰好需要区间 DP:dp[i][j] 表示把第 i 个切点到第 j 个切点之间的部分切完的费用。枚举第一刀或最后一刀切在第 k 个切点,递推式与上面几乎一样。这类题目在工程调度里很常见,分治思维是唯一解。

6.2 非线性切割费用

如果一次切割的费用不是“段长度”的一次函数,而是平方、对数、或者“刀具磨损”相关,哈夫曼贪心失效。但只要费用可表示为当前段长度 f(len),并且满足可加性,分治/区间DP仍可套用。唯一要注意的是,递推式中的“附加代价”要改成 f(pref[j]-pref[i]),而不是直接用总长度。

这种变形题的第一步是先建模:确定状态区间是什么、子问题怎么划分、合并代价怎么计算。只要这三件事清楚,代码只是在原来的骨架上改一个函数。

6.3 篱笆最大矩形面积的分治版

回到开头提到的第二种设定。对高度数组 [h[l], ..., h[r]),分治过程:

  1. 找到区间内最小高度的下标 m。
  2. 最大矩形可能出现在三处:完全在左侧、完全在右侧、或者横跨 m。
  3. 横跨 m 时,宽度为 r-l,高度为 h[m],面积为 h[m] * (r-l)。
  4. 递归求左右两侧,取三者最大值。

这同样是把大区间拆成两个小区间,再合并答案。用线段树或稀疏表维护区间最小值,可以把复杂度做到 O(n log n)。这道题的分治关键在于“最矮板限制了跨区矩形的高度”,合并动作非常简洁。

如果你已经理解了切割篱笆的费用问题,再看这个变体就不会觉得陌生:不同的问题,相同的分治骨架。

尾声:分治是一种拆解复杂度的习惯

切割篱笆问题最让我受益的地方,不是记住那个优先队列解法,而是学会从“最后一步”倒推着思考。后来的很多工程问题,包括服务拆分、文件切片、任务调度,本质上都在做类似的事:找到某个操作的逆过程,把全局代价拆成局部代价,再用递归或者 DP 去合并。

一个小建议:亲手画一棵 4 段篱笆的切割二叉树,把每个节点标上长度,再把总费用算出来。等你看到叶子到根的路径和与内部节点总和一致时,你对这个问题的理解就真的上了台阶。之后不管遇到什么变种,都能用这套分治框架去套。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询