二分搜索与前缀和组合:解决最优化问题的核心范式

📅 2026/8/27 22:15:19
二分搜索与前缀和组合:解决最优化问题的核心范式
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[r1] - pre[l]下标更统一。在二分验证函数check(mid)中的典型用法 假设问题要求能否将数组分割成若干连续子数组使得每个子数组的和不超过mid。 我们的check函数可能会这样写贪心思想遍历数组用current_sum累加元素。一旦current_sum超过mid就说明当前元素必须开启一个新的子数组同时current_sum重置为当前元素值。统计最终需要的子数组数量。 在这个过程中我们其实隐式地使用了“在线计算”并没有直接用到前缀和。但是如果问题变种为是否存在一个长度为len的连续子数组其和至少为mid这时遍历所有起点i计算sum[i, ilen-1]如果每次都用循环计算是O(n * len)。而用前缀和就是O(n)for i in range(n-len1): if pre[ilen] - 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[i1] pre[i] nums[i] # Python int 自动处理大数// C 示例 int n nums.size(); vectorlong 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[x21][y21] - pre[x1][y21] - pre[x21][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右边左边界移到mid1 return left # 循环结束时 left right即所求位置关键理解condition(mid)通常定义为“当答案设为mid时是否可行”。如果可行(True)我们想知道还有没有更小的可行解所以把上界right拉下来到mid如果不可行(False)则当前mid太小了必须把下界left往上提到mid1。最终返回的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和rightleft通常是理论最小值或0right通常是理论最大值、数组长度、或者一个足够大的数如1e91。务必确保答案一定在[left, right]区间内。有时right可以设为sum(nums)或max(nums)*n等。防止溢出计算mid时使用left (right - left) // 2而非(left right) // 2因为leftright在两者都很大时可能溢出整型范围。循环条件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^5nums[i]高达10^9的情况这个算法也能轻松应对。6. 避坑指南与性能优化实战理论懂了模板背了一写就错以下是血泪教训总结出的高频坑点。6.1 前缀和相关的坑坑1下标错位与初始化这是最常见的错误。牢记前缀和数组pre通常比原数组nums长度多1pre[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 l0 else 0)边界处理更繁琐容易出错。强烈推荐“长度1pre[0]0”的写法。坑2数值溢出如前所述使用足够大的数据类型。在C/Java中对于累加和long long是你的好朋友。在check函数中进行比较时确保比较的双方类型匹配。坑3二维前缀和的容斥原理记错二维查询公式pre[x21][y21] - pre[x1][y21] - pre[x21][y1] pre[x1][y1]可以通过画图记忆减去两个大的矩形多减了一次重叠的小矩形所以要加回来。务必自己推导一遍死记硬背容易在紧张时出错。6.2 二分搜索相关的坑坑4死循环主要发生在使用“寻找右边界”的模板二时mid的计算没有1。# 错误示例 (可能导致死循环) while left right: mid left (right - left) // 2 # 当 left3, right4 时mid3 if condition(mid): left mid # 如果condition(3)为True则 left3, right4循环不变死循环 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你需要将其切割成k1份求每份甜蜜度之和的最小值的最大可能值。思路转换这是“最大化最小值”。我们用模板二右边界。check(mid)设计给定一个猜测的最小值mid判断能否将数组切割成至少k1份且每份的和都至少为mid。贪心策略从左到右累加一旦当前段的和 mid就立即在此处切割开始新的一段。统计能得到的段数cnt。如果cnt k1说明mid这个最小值是可行的甚至可以尝试更大如果cnt k1说明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函数的设计你就能越来越熟练地运用这套强大的组合工具。