分割数组的最大值:对「最大段和」二分
切法本身难枚举。给定段和上限,贪心数出最少段数却是线性——对这个上限二分,就能找到最小的可行值。
答案是各段和的最大值,下界 max(nums),上界 sum(nums)。对候选 x 做 check:从左往右装,当前段再装下一个会超过 x 就另起一段;段数 ≤ m 则 x 可行。可行收高界,不可行抬低界,收敛的 low 就是最小的最大段和。时间 O(n log Σ),空间 O(1)。
这是 LeetCode 410. Split Array Largest Sum。大白话:给你非负整数数组 nums 和整数 m,把数组切成恰好 m 个非空连续子数组,让这 m 段各自的和里那个最大的尽量小,返回这个被压到最小的「最大段和」。只能横切,不能打乱顺序,也不能跳着取。
主例 nums = [7, 2, 5, 10, 8],m = 2。一种切法是 [7,2,5] 与 [10,8],两段和是 14 和 18,最大值 18。切成 [7,2] 与 [5,10,8] 最大值是 23,更差;切成 [7,2,5,10] 与 [8] 最大值是 24,也更差。答案是 18。
第一反应是枚举 m−1 个切点。主例只要 1 个切点,4 种切法还能数完;m 和 n 变大就是组合爆炸。缺的不是「最优切在哪」,而是一个好检查的量:给定上限 x,最少要几段才能让每段和都不超过 x。检查是线性的,x 又对「能否在 m 段内切完」单调,于是对 x 二分。
给定上限,贪心数出最少段数
先别管最优切法,只问一个更窄的问题:如果每段和不得超过 x,至少要切几段?从左往右扫,手里攒着当前段和 cur,段数 segs 从 1 起。遇到下一个数 v,若 cur + v ≤ x,就装进去;若 cur + v > x,当前段到此结束,segs 加一,新段从 v 开始。扫完得到的 segs 就是这个 x 下的最少段数。
为什么贪心「能装就装」就是最少段?提前切开只会让后面的数更挤,段数只多不少。题目要的是连续子数组,不能把后面的小数挪到前面来填缝,所以从左往右尽量装满,是唯一不会浪费段数的策略。segs ≤ m,说明 x 当上限够用;segs > m,说明 x 太紧,m 段装不下。
单个元素比 x 还大,这一段永远超标。所以搜索下界必须是 max(nums),主例里是 10。代码里的 check 并不单独判断「v > x」,它依赖二分区间从 10 起步,保证每个 v 都能独自成段。
用手走主例。x = 18:7 装入;7+2=9;9+5=14,都不超;14+10=24>18,另起一段从 10 开始;10+8=18,刚好。两段,段数等于 m,18 可行。演示第一帧就是这次切分:[7,2,5] 与 [10,8]。x = 17:前三个数仍是 14;14+10=24>17,新段 10;10+8=18>17,8 再开一段。三段大于 2,17 不可行。演示第二帧停在这里。18 可行、17 不可行,已经把答案夹在 18。
最少段数「装不下就开新段」得到的是 x 约束下的最少段数。用它和 m 比,等价于问「m 段够不够把每段和压在 x 以内」。
对最大段和二分:可行就压低
x 越大越好切,所需段数单调不增:某个 x 可行,比它更大的一律可行;某个 x 不可行,比它更小的一律不可行。可行区间是一段后缀,要找这段后缀的左端点。搜索范围 [max(nums), sum(nums)]:下界是「每段至少能装下最大那个数」,上界是「切 1 段、整段不切」。主例是 [10, 32]。
每次取 mid = lo + (hi−lo)/2,跑一遍 check。可行则 hi = mid,答案不会比 mid 更大,继续往小试;不可行则 lo = mid+1,mid 和更小的全部淘汰。循环条件 lo < hi,结束时 lo == hi,落在最小可行值上。写成 hi = mid 而不是 mid−1,是因为 mid 本身可能就是答案,不能丢掉。
主例从 lo=10、hi=32 起。mid=21:7+2+5=14,10+8=18,两段可行,hi=21。mid=15:14 之后 10 开新段,8 再开一段,三段不可行,lo=16。此时区间变成 [16, 21]。演示第一帧取 mid=18:check(18) 两段可行,hi=18。再取 mid=17:三段不可行,lo=18。lo 与 hi 在 18 相遇,演示第二帧收敛。答案 18,对应 [7,2,5] 与 [10,8]。
若误把「可行」写成 lo = mid,区间收不掉,还可能死循环。不可行必须抬低界,可行必须收高界,方向反了就收敛到错误的端点。check 只回答够不够切,不给出切点;切点在贪心扫描里顺带产生,题目只要那个被最小化的最大值。
Go:答案二分
func splitArray(nums []int, m int) int {check := func(x int) bool {segs, cur := 1, 0for _, v := range nums {if cur+v > x { segs++; cur = 0 }cur += v}return segs <= m}lo, hi := slices.Max(nums), 0for _, v := range nums { hi += v }for lo < hi {mid := lo + (hi-lo)/2if check(mid) { hi = mid } else { lo = mid + 1 }}return lo}
1check(x) 从 1 段、cur=0 起步。cur+v > x 就另起一段再装 v。主例 x=18 得到 2 段,x=17 得到 3 段。
2lo 用 max(nums) 垫上,保证 check 里不会出现单元素超标。hi 是总和,对应不切。
3可行写 hi=mid,把 mid 留在候选里;不可行写 lo=mid+1,把 mid 扔掉。主例在 18 处两边碰上。
4返回 lo。它是最小的、能让段数 ≤ m 的最大段和。
总结
对最大段和二分;check 用「装不下就开新段」数最少段。主例 18 可行、17 不可行。
- 枚举切点是组合爆炸。缺口是「给定上限能否在 m 段内切完」,这个检查线性且单调。
- 下界 max(nums),上界 sum(nums)。主例 [10,32] 收敛到 18,切法是 [7,2,5] 与 [10,8]。
- 可行收高界、不可行抬低界。写成 lo=mid 会丢不掉左端,甚至收不敛。