1. 从一道国赛真题说起:当二分搜索遇上前缀和
去年带学生备战国赛,复盘历年真题时,有一道2021年的题目让我印象特别深刻。这道题本身没有复杂的算法包装,题干描述甚至有些“朴素”,但恰恰是这种朴素,让很多队伍在赛场上栽了跟头。它的核心,就是将两个最基础的数据结构与算法——二分搜索和前缀和——进行了一次看似简单、实则精妙的结合。很多同学一看到“二分”就想着去猜答案,一看到“前缀和”就想着去预处理区间和,但如果没有理解清楚这两者在这个具体问题中“为什么”要这么用,以及“如何”协同工作,写出来的代码要么超时,要么答案错误。
这道题的价值,远不止于教会你两个知识点。它更像一个经典的思维模型,展示了在面对“最优化问题”且数据范围极大时,我们如何通过前缀和进行高效的数据转换与状态表示,再通过二分搜索在答案空间中进行快速定位。这种“预处理+二分答案”的范式,在解决“最小化最大值”或“最大化最小值”这类问题时威力巨大,从资源分配、负载均衡到生产调度,其思想内核随处可见。
今天,我们就以这道2021年国赛题为蓝本,彻底拆解“二分搜索+前缀和”的组合拳。我不会直接给你题目和答案(遵守竞赛规则),而是提炼其核心场景与解题框架,并注入大量我在实战编码和教学指导中积累的细节、易错点和优化技巧。无论你是正在备赛的选手,还是希望深化算法理解的开发者,相信这篇“老兵”的复盘笔记,都能让你对这两个基础工具有全新的认识。
2. 场景还原:为什么是它们俩?
要理解一个算法组合为什么有效,必须先回到问题本身。我们抽象一下这类问题的典型特征:
问题特征:
- 答案的单调性:存在一个临界值
X。当我们的“目标值”小于X时,无论如何都无法满足条件;当目标值大于等于X时,总存在一种方案可以满足条件。这个X就是我们要求的最优解(通常是满足条件的最小值或最大值)。 - 验证的复杂性:给定一个猜测的答案
mid,判断“是否存在一种方案使得结果不超过(或不小于)mid”这个过程(我们称之为check(mid)函数),本身可能就是一个需要精心设计的子问题。 - 数据的大范围:答案的可能范围或者输入数据的规模非常大,使得枚举所有可能答案变得不可行。
二分搜索的角色:它高效地解决了“搜索空间巨大”的难题。我们不再傻傻地从1枚举到1e9,而是利用答案的单调性,每次将搜索区间对半砍掉,在O(log N)的时间复杂度内锁定最终答案。这里的N是答案的可能范围。
前缀和的角色:它高效地解决了check(mid)函数中的“区间信息快速查询”难题。在验证某个mid是否可行的过程中,我们往往需要频繁计算某个连续子数组的和、平均值或其他统计量。如果每次都用循环累加,check函数的时间复杂度会变成O(n),再乘上二分的O(log N),总复杂度O(n log N)在n很大时可能依然危险。前缀和通过一次O(n)的预处理,将任意区间和的查询降至O(1),从而确保check(mid)函数本身尽可能高效,通常是O(n)或O(n log n)。
一个生活化的类比:想象你要把一堆长度不一的木材,切割成等长的小段去售卖,目标是让每段尽可能长(最大化最小值),但总共要切出至少K段。你猜一个长度L,然后需要快速计算所有木材按这个长度能切出多少段。这里,“计算总段数”就是check(L)。如果一根木材长10米,你猜的长度是3米,那么这根木材能贡献floor(10 / 3) = 3段。你需要对每一根木材做这个除法并求和。这个过程本身是O(n)。前缀和在这个例子里似乎不直接适用,但它解决的是另一类问题:当你的约束是“连续区间”的属性时,比如“任意一段连续木材的总长度不能超过某个值”,这时快速计算任意区间总和就需要前缀和了。
所以,“二分搜索”是战略,它决定了我们寻找答案的方向和效率;“前缀和”是战术,它为我们验证每一步的猜测提供了强大的武器。两者结合,就能解决一大类复杂的优化问题。
3. 前缀和:不止是快速求和,更是状态压缩
很多人对前缀和的认知停留在sum[i] = arr[0] + ... + arr[i],然后sum[r] - sum[l-1]得到区间和。这没错,但在这个二分搜索的框架下,我们需要更深入地理解它的本质。
3.1 一维前缀和与差分:静态区间操作的基石
一维前缀和是最简单的形式。给定数组nums,我们预处理出数组pre,其中pre[i] = nums[0] + nums[1] + ... + nums[i](通常会让pre[0] = 0,pre[i]表示前i个元素的和,这样区间[l, r]的和就是pre[r+1] - pre[l],下标更统一)。
在二分验证函数check(mid)中的典型用法: 假设问题要求:能否将数组分割成若干连续子数组,使得每个子数组的和不超过mid。 我们的check函数可能会这样写(贪心思想):
- 遍历数组,用
current_sum累加元素。 - 一旦
current_sum超过mid,就说明当前元素必须开启一个新的子数组,同时current_sum重置为当前元素值。 - 统计最终需要的子数组数量。 在这个过程中,我们其实隐式地使用了“在线计算”,并没有直接用到前缀和。但是,如果问题变种为:是否存在一个长度为
len的连续子数组,其和至少为mid?这时,遍历所有起点i,计算sum[i, i+len-1],如果每次都用循环计算,是O(n * len)。而用前缀和,就是O(n):for i in range(n-len+1): if pre[i+len] - pre[i] >= mid: return True。
关键技巧:前缀和数组的数据类型这是第一个坑。当nums中的元素和可能很大时(比如每个元素最大1e9,数组长度1e5),前缀和pre很容易超出32位整型(int)的范围。务必使用64位整型(如long longin C++,int64in Go,intin Python默认无限精度但需注意)。在check函数中进行比较时,所有相关变量都应提升到相同的大类型,避免溢出。
# Python示例 (Python int 无此问题,但其他语言需注意) n = len(nums) pre = [0] * (n + 1) for i in range(n): # 假设nums[i]可能很大 pre[i+1] = pre[i] + nums[i] # Python int 自动处理大数// C++ 示例 int n = nums.size(); vector<long long> pre(n + 1, 0); // 使用 long long for (int i = 0; i < n; ++i) { pre[i + 1] = pre[i] + nums[i]; }3.2 二维前缀和:平面区域问题的降维打击
当问题扩展到矩阵(二维数组)上,要求快速计算任意子矩阵的元素和时,二维前缀和就登场了。定义pre[i][j]为以(0,0)为左上角,(i-1, j-1)为右下角的矩形区域和(同样采用pre[0][*] = pre[*][0] = 0的边界定义)。
递推公式:pre[i][j] = matrix[i-1][j-1] + pre[i-1][j] + pre[i][j-1] - pre[i-1][j-1]查询子矩阵(x1,y1)到(x2,y2)(行列号,通常从0开始)的和:sum = pre[x2+1][y2+1] - pre[x1][y2+1] - pre[x2+1][y1] + pre[x1][y1]
在二分搜索场景下的应用: 问题可能变成:在给定的n x m矩阵中,能否找到一个面积至少为S的子矩阵,使得其所有元素的最大值不超过mid?或者,所有元素的和不超过mid? 对于“和不超过mid”的情况,我们可以在check(mid)中:
- 如果原矩阵元素值大于
mid,则视为不可用(或赋一个极大值)。 - 构建该矩阵的二维前缀和。
- 枚举所有可能的子矩阵,利用前缀和
O(1)计算其和,判断是否满足条件。枚举子矩阵起点和终点是O(n^2 * m^2),这通常不可接受。需要结合其他优化,比如固定一边长,用滑动窗口或二分查找另一边长,将复杂度降至O(n^2 log m)或O(n*m)。
注意:二维前缀和的预处理是
O(n*m),在check函数中,如果mid不同导致矩阵值发生变化(例如上述的“大于mid则视为不可用”),那么每次check都需要重新计算前缀和。这是一个常见的性能瓶颈点。有时我们可以通过预处理“矩阵中不超过某个值的元素个数”的二维前缀和(即将其转化为0/1矩阵)来避免每次重建,但这依赖于问题的具体形式。
4. 二分搜索:细节决定成败,模板拯救头发
二分搜索的思想简单,但边界条件的处理堪称“玄学”。左闭右开还是左闭右闭?while条件是<还是<=?更新left和right时是mid还是mid±1?一个不小心就是死循环或者差一错误。
4.1 两种主流模板及其选择
经过无数竞赛和工程实践的检验,下面两种模板最为可靠:
模板一:寻找第一个满足条件的值(左边界)适用于求“最小值”或“满足条件的最左位置”。
def binary_search_left(condition, left, right): # left, right 为搜索范围的左右边界,通常 right 是理论最大值或数组长度 while left < right: mid = left + (right - left) // 2 # 防止溢出 if condition(mid): right = mid # 条件满足,说明答案可能在mid或左边,将右边界移到mid else: left = mid + 1 # 条件不满足,答案一定在mid右边,左边界移到mid+1 return left # 循环结束时 left == right,即所求位置关键理解:condition(mid)通常定义为“当答案设为mid时,是否可行”。如果可行(True),我们想知道还有没有更小的可行解,所以把上界right拉下来到mid;如果不可行(False),则当前mid太小了,必须把下界left往上提到mid+1。最终返回的left是第一个满足condition的位置。
模板二:寻找最后一个满足条件的值(右边界)适用于求“最大值”或“满足条件的最右位置”。
def binary_search_right(condition, left, right): while left < right: mid = left + (right - left + 1) // 2 # 注意这里要+1,防止死循环 if condition(mid): left = mid # 条件满足,答案可能在mid或右边,左边界移到mid else: right = mid - 1 # 条件不满足,答案一定在mid左边,右边界移到mid-1 return left关键理解:condition(mid)定义同上。如果可行(True),我们想知道还有没有更大的可行解,所以把下界left推到mid;如果不可行(False),则当前mid太大了,必须把上界right往下拉到mid-1。注意mid的计算要向上取整 (+1),否则当left和right相邻时可能陷入死循环。
如何选择?
- 题目要求“最小化最大值”(例如,最小的最大子数组和):我们通常寻找第一个可行的值,用模板一。因为随着
mid增大,条件会从不可行变为可行,我们要找这个转折点。 - 题目要求“最大化最小值”(例如,最大的最小木材切割长度):我们通常寻找最后一个可行的值,用模板二。因为随着
mid增大,条件会从可行变为不可行,我们要找最后一个可行的点。
4.2 二分搜索的边界与溢出
- 初始边界
left和right:left通常是理论最小值或0,right通常是理论最大值、数组长度、或者一个足够大的数(如1e9+1)。务必确保答案一定在[left, right]区间内。有时right可以设为sum(nums)或max(nums)*n等。 - 防止溢出:计算
mid时,使用left + (right - left) // 2而非(left + right) // 2,因为left+right在两者都很大时可能溢出整型范围。 - 循环条件:
while left < right对于上述两种模板是黄金搭配。循环结束时left == right,就是我们要的答案。不需要再单独判断left或right。 - 验证最终答案:二分搜索结束后,得到的
left是理论答案。有时需要额外检查一下这个left是否真的满足条件。因为搜索区间可能包含无解的情况(虽然根据问题单调性,通常不会),或者condition函数的定义边界模糊。安全的做法是:if not condition(left): return -1 or handle_error。
5. 实战拆解:构建高效的check(mid)函数
这是整个组合技的灵魂,也是最考验对问题理解深度和编程功底的部分。check(mid)的效率直接决定了算法的总效率。我们以一个典型问题为例进行构建:
假设问题:给定一个正整数数组nums和一个整数k,请将数组分割成至多k个连续的非空子数组。你的目标是最小化这些子数组的最大和。
思路分析:
- 单调性:如果“最大子数组和”的限额
X越大,我们就越容易用更少的段数分割数组(甚至一段就行)。当X小到一定程度,我们需要的段数就会超过k。存在一个临界值X_min,使得当限额>= X_min时,可以在k段内完成分割;当限额< X_min时,则无法在k段内完成。我们需要找到这个最小的X_min。这是典型的“最小化最大值”,用模板一。 check(mid)设计:给定一个猜测的限额mid,判断“能否将数组分割成不超过k段,且每段的和都不超过mid”。- 贪心策略是有效的:从左到右遍历数组,尽可能多地往当前段里加元素,直到加入下一个元素会使段和超过
mid,则在此处切一刀,开始新的一段。 - 统计按此贪心法需要的段数
cnt。 - 如果
cnt <= k,说明mid这个限额是可行的(甚至可能有点宽松);如果cnt > k,说明mid太小了,必须放宽限额。
- 贪心策略是有效的:从左到右遍历数组,尽可能多地往当前段里加元素,直到加入下一个元素会使段和超过
check(mid)函数实现与优化:
def can_split(nums, k, limit): """ 检查在最大段和不超过limit的情况下,能否用不超过k段分割nums。 """ current_sum = 0 count = 1 # 至少有一段 for num in nums: # 如果单个元素已经大于限制,那么任何包含它的段都会超限,直接失败 if num > limit: return False if current_sum + num > limit: # 当前段已满,开启新的一段 count += 1 current_sum = num # 如果段数已经超过k,可以提前结束,返回False if count > k: return False else: current_sum += num return True # 成功用不超过k段分割完复杂度:O(n),其中n是数组长度。非常高效。
为什么贪心策略是正确的?这是一个需要理解的关键点。对于“最小化最大段和”这个问题,为了使得最大段和尽可能小,我们应尽可能让每一段在不超过限额的前提下装得尽可能满,这样可以让段数尽可能少。贪心地尽可能延长当前段,正是为了达到这个目的。可以反证:如果存在一个最优分割,在某处比贪心算法更早地进行了分割,那么它必然导致前一段的和更小,后一段的起始更早,这可能会增加总段数或者让后面某一段的和更大,不会得到更优的“最大段和”。
整合二分搜索:
def split_array(nums, k): # 确定二分搜索的边界 left = max(nums) # 最小可能答案:至少要比数组中的最大值大,否则那个元素自成一段都超限 right = sum(nums) # 最大可能答案:整个数组作为一段的和 while left < right: mid = left + (right - left) // 2 if can_split(nums, k, mid): # mid可行,尝试寻找更小的可行解 right = mid else: # mid不可行,需要更大的限额 left = mid + 1 return left这个split_array函数的时间复杂度是O(n log R),其中R是sum(nums) - max(nums)的数量级。对于n高达10^5,nums[i]高达10^9的情况,这个算法也能轻松应对。
6. 避坑指南与性能优化实战
理论懂了,模板背了,一写就错?以下是血泪教训总结出的高频坑点。
6.1 前缀和相关的坑
坑1:下标错位与初始化这是最常见的错误。牢记前缀和数组pre通常比原数组nums长度多1,pre[i]对应nums[0...i-1]的和。
# 正确初始化 n = len(nums) pre = [0] * (n + 1) for i in range(n): pre[i + 1] = pre[i] + nums[i] # pre[1] = nums[0], pre[2] = nums[0]+nums[1] # 查询区间 [l, r] 的和 (0-indexed) range_sum = pre[r + 1] - pre[l]如果使用pre[i]表示nums[0...i]的和,那么查询公式会变成pre[r] - (pre[l-1] if l>0 else 0),边界处理更繁琐,容易出错。强烈推荐“长度+1,pre[0]=0”的写法。
坑2:数值溢出如前所述,使用足够大的数据类型。在C++/Java中,对于累加和,long long是你的好朋友。在check函数中进行比较时,确保比较的双方类型匹配。
坑3:二维前缀和的容斥原理记错二维查询公式pre[x2+1][y2+1] - pre[x1][y2+1] - pre[x2+1][y1] + pre[x1][y1]可以通过画图记忆:减去两个大的矩形,多减了一次重叠的小矩形,所以要加回来。务必自己推导一遍,死记硬背容易在紧张时出错。
6.2 二分搜索相关的坑
坑4:死循环主要发生在使用“寻找右边界”的模板二时,mid的计算没有+1。
# 错误示例 (可能导致死循环) while left < right: mid = left + (right - left) // 2 # 当 left=3, right=4 时,mid=3 if condition(mid): left = mid # 如果condition(3)为True,则 left=3, right=4,循环不变,死循环! else: right = mid - 1记住:模板二,mid要加1。
坑5:条件判断函数的单调性假设错误二分搜索的前提是condition(mid)关于mid具有单调性。你必须确保你设计的check函数是单调的。例如在上面的分割数组问题中,如果limit增大,can_split更容易返回True(或至少不会从True变回False)。如果你设计的check函数不满足单调性(比如存在波动),二分搜索将得到错误结果。在动手写二分前,花一分钟思考或简单验证单调性。
坑6:搜索区间设置不当left和right的初始值必须覆盖所有可能的答案,并且要合理。例如,在上例中,left不能设为0,因为最大段和至少要和数组中的最大值一样大。如果设小了,二分搜索可能永远找不到正确答案,或者需要额外的最终检查。
6.3 性能优化技巧
技巧1:在check函数中尽早退出如上面can_split函数中的if count > k: return False。一旦发现段数已经超标,立刻返回False,避免无谓的后续遍历。
技巧2:合理缩小二分搜索的初始范围精确的初始范围可以减少二分迭代次数。例如:
left = max(nums)(理论下界)right = sum(nums)(理论上界) 有时可以根据问题性质进一步收紧。比如,如果必须分成k段,那么最大段和至少是ceil(sum(nums) / k),这个值可能比max(nums)大,可以作为更紧的left。
技巧3:避免在check中重复构建前缀和如果check函数本身依赖于前缀和,且前缀和随着mid变化(例如,需要将大于mid的值视为障碍),那么每次check都O(n)重建前缀和是必要的开销。但有时我们可以转换思路。例如,问题不是“和不超过mid”,而是“最大值不超过mid”,那么我们可以预处理出“哪些位置的值 <= mid”的布尔数组,然后基于这个布尔数组计算前缀和(统计连续“可行”区域的长度)。这样,check(mid)时只需要查询这个预处理好的结构,或者使用滑动窗口,可能更高效。
技巧4:二分答案的“答案”不一定在数组里我们二分的是“最大子数组和”这个值,它是一个整数,但它的可能取值是连续的整数范围。最终二分找到的答案,不一定等于原数组中某个子数组的和,它只是一个满足条件的最小限额。理解这一点有助于避免一些思维误区。
7. 举一反三:经典问题变种与思路迁移
掌握了“二分答案+前缀和验证”的核心范式后,我们可以解决一系列变种问题。关键在于如何根据新问题,设计出正确的check(mid)函数。
变种1:最大化最小和(“礼盒的最大甜蜜度”)问题:给你一个正整数数组sweetness,你需要将其切割成k+1份,求每份甜蜜度之和的最小值的最大可能值。
- 思路转换:这是“最大化最小值”。我们用模板二(右边界)。
check(mid)设计:给定一个猜测的最小值mid,判断能否将数组切割成至少k+1份,且每份的和都至少为mid。- 贪心策略:从左到右累加,一旦当前段的和
>= mid,就立即在此处切割,开始新的一段。统计能得到的段数cnt。 - 如果
cnt >= k+1,说明mid这个最小值是可行的(甚至可以尝试更大);如果cnt < k+1,说明mid太大了,必须调小。
- 贪心策略:从左到右累加,一旦当前段的和
- 与最小化最大和的对比:前者是“不超过limit,段数尽可能少”,后者是“至少达到limit,段数尽可能多”。贪心方向相反。
变种2:带权值或平均值限制问题:给定数组,要求分割成若干段,使得每段的平均值不超过某个值T。
- 思路转换:平均值不超过
T等价于(sum of segment) / (length of segment) <= T,即sum of segment <= T * length。定义一个新的数组b[i] = nums[i] - T。那么条件转化为:每段的b[i]之和<= 0。 check(mid)设计:这里mid就是T。我们构建b数组及其前缀和pre_b。问题转化为:能否将数组分割,使得每个子数组的pre_b的区间和<= 0。这依然可以用贪心去判断,或者结合最小子段和的思想。
变种3:二维矩阵中的最大平均子矩阵问题:在m x n的矩阵中,找出一个边长至少为L的子矩阵,使其平均值最大。
- 思路转换:“最大值”问题通常难以直接优化,但我们可以二分这个平均值
mid。问题转化为:是否存在一个子矩阵,其平均值>= mid。 check(mid)设计:将矩阵中每个元素减去mid,得到新矩阵diff。那么“平均值 >= mid”等价于diff矩阵的对应子矩阵和>= 0。现在问题变成:在diff矩阵中,是否存在一个边长至少为L的子矩阵,其和>= 0。- 这一步需要用到二维前缀和来快速计算任意子矩阵的和。
- 枚举子矩阵的复杂度是
O(m^2 * n^2),需要优化。我们可以固定上下边界(或左右边界),将二维问题压缩为一维。例如,固定了行的范围[r1, r2],那么对于每一列j,我们可以计算出从第r1行到第r2行在第j列的和,这形成一个一维数组col_sum[j]。问题进一步转化为:在这个一维数组col_sum中,寻找一个长度至少为L的子数组,其和>= 0。这可以用前缀和结合单调队列或维护最小值的方法在O(n)内解决。
- 总复杂度:二分
O(log(MAX_VAL)),每次check需要O(m^2 * n)或O(m * n^2)。这是一个经典的将二分答案、前缀和、维度压缩结合的例子。
通过这些变种,你会发现,核心永远是两步:1) 通过二分将最优化问题转化为判定问题;2) 设计一个高效的check函数,而前缀和往往是这个函数中加速查询的关键工具。多练习,多思考不同问题下check函数的设计,你就能越来越熟练地运用这套强大的组合工具。