☰
平衡数组与前缀和:从双重循环到O(n)遍历的最优解法
2026/10/5 7:18:26 网站建设 项目流程

前两天我在整理自己的刷题笔记,翻到“平衡数组”这一页,发现当年第一次做这道题时,我用两层循环硬算,还觉得自己写得挺顺。后来才知道,这道题考察的是前缀和思想,属于数组类问题里最经典的入门题型之一。今天把这道题彻底拆开讲一遍,从暴力解法一路推到最优解,再把边界条件、相关题型和工程里的实际场景都串起来聊。

如果你是一个刚开始刷算法题的新手,或者想认真复习数组前缀和技巧的开发者,这篇文章应该能帮你省不少时间。我会把我自己踩过的坑、面试时被问到过的细节、以及实际项目中类似问题的处理思路都写出来,尽量做到“看完就能写对、写对还能讲清”。

1. 先搞懂“平衡数组”到底在问什么:一道题背后的真实场景

1.1 题目定义与一个最直观的例子

所谓平衡数组,常见出现在程序设计类题目中的描述是这样的:给定一个整数数组 nums,找到一个下标 i,使得下标 i 左侧所有元素的和,等于右侧所有元素的和。如果存在这样的下标就返回它,不存在就返回 -1。

举个例子,数组[1, 7, 3, 6, 5, 6],下标从 0 开始数。当 i = 3 时,左侧1 + 7 + 3 = 11,右侧5 + 6 = 11,两边相等,所以 3 就是平衡点。

这道题在很多平台上有不同的名字,“寻找中心下标”“找支点索引”“Find Pivot Index”说的都是它。题面简单到一句话就能讲完,但它特别适合拿来考察一个人对“累计量”这个概念的理解——你到底是在老老实实重复求和,还是能想到只遍历一次就完成判断。

1.2 这类问题在现实中解决什么

我第一次刷到这道题时,觉得它就是个纯粹的教学题。后来在项目里做数据报表,才发现这类“找均分切割点”的逻辑其实很常见。

举几个我实际遇到过的场景:

  • 日志系统按时间片拆分时,需要根据每个时间片的日志条数,找到从哪一行切分能让前后两段数据量尽量接近。
  • 存储分片时,要把一组大小不等的文件分配到两个目录,希望两边的总大小差距最小,这时候就要先定位累计量过半的位置。
  • 计费系统里按用量分摊成本,经常需要判断某个时间点或某个用户 ID 是不是“累计占比刚好到一半”的临界点。
  • 音视频处理里按时长切分一段素材,找一个让前后两部分时长相等的帧位置。

本质上它们都是同一个问题:给定一个序列,寻找某个位置,让这个位置两侧的累计量相等。只是在工程里,数据可能是无序的,可能需要排序后处理,也可能要处理浮点数,但核心思想完全一致。

1.3 为什么它在面试和刷题平台里反复出现

因为这道题看似简单,却能考出一个程序员的基本功。暴力解法谁都能写,但你能不能写出 O(n) 的解法,能不能把边界条件处理干净,知不知道用更大范围的数值类型来承接累加和,这些细节才是拉开差距的地方。

面试官喜欢用它做热身题,因为它不会让候选人冷场,但又能很快暴露出思维习惯:是拿到题就动手写循环,还是先思考一下能不能在遍历过程中维护状态。对于初学者,这道题的推导过程也是学习前缀和思想最平滑的切入点之一。

2. 从暴力双重循环到单次遍历:前缀和思路的完整推导

2.1 大多数人第一反应:双重循环硬算

假设面试官把这道题放在你面前,第一次接触的话,最直觉的写法是:遍历每个下标 i,分别计算 i 左侧的和与右侧的和,比较是否相等。

用 Python 写出来是这样的:

def pivot_index_bruteforce(nums): n = len(nums) for i in range(n): left_sum = sum(nums[:i]) right_sum = sum(nums[i + 1:]) if left_sum == right_sum: return i return -1

这段代码逻辑完全正确,对于[1, 7, 3, 6, 5, 6]也能正确返回 3。但它的时间复杂度是 O(n^2):每个位置都要重新计算两侧的和,切片本身又是 O(n) 的操作。

如果你在 LeetCode 这类平台上提交,数组长度一上来,立刻会超时。我自己第一次就是栽在这上面,当时还纳闷,明明逻辑没问题,怎么就是跑不过。原因很简单:平台要的是能在大规模数据下工作的算法,而不是“看起来对”的代码。

2.2 核心优化思想:累计和复用

我们观察一下,当 i 从 0 移动到 n-1 时,左侧的和并不是每次都要从零开始加,它其实是在上一个位置的左侧和基础上,加上nums[i-1]。这是个非常经典的滑动累计思路。

同理,整个数组的总和 total 只需要计算一次。当我们在位置 i 时,右侧的和可以用公式表达:

right_sum = total - left_sum - nums[i]

为什么右边要减掉nums[i]?因为平衡点本身既不属于左侧,也不属于右侧,两侧比较的都是不含 i 位置元素的部分。

于是判断条件变成了:

left_sum == total - left_sum - nums[i]

这个式子成立,就说明当前位置就是平衡点。整个数组只需要遍历一次,时间复杂度降到 O(n),额外空间 O(1)。

2.3 可复现的标准实现:Python 和 C++ 两种写法

Python 版本,最简洁的写法:

def pivot_index(nums): total = sum(nums) left_sum = 0 for i, num in enumerate(nums): if left_sum == total - left_sum - num: return i left_sum += num return -1

C++ 版本,我最常用的写法:

class Solution { public: int pivotIndex(vector<int>& nums) { long long total = 0; for (int x : nums) total += x; long long leftSum = 0; for (int i = 0; i < nums.size(); ++i) { if (leftSum == total - leftSum - nums[i]) { return i; } leftSum += nums[i]; } return -1; } };

注意我在 C++ 里用了long long而不是int,这个细节后面会专门讲。两个版本的核心逻辑完全一致:第一遍求总和,第二遍边移动边维护左侧累计值,同时用公式判断右侧是否相等。

2.4 为什么这个优化不是“小聪明”,而是方法论

很多人看完这题会觉得,“哦,原来只要算个总和,然后边遍历边比较就行了”。但我想强调,这不仅仅是这一道题的技巧,它是“前缀和”这个更大方法论的首次登场。

所谓前缀和,就是用一个数组或者一个变量,记录序列到当前位置为止的累计值。有了它,任意区间[l, r]的和都可以用prefix[r] - prefix[l - 1]快速求出,从 O(n) 变成 O(1)。

平衡数组这道题,本质上就是用前缀和求某一段区间和的特例。你把前缀和数组构建出来,左侧和就是prefix[i-1],右侧和就是prefix[n-1] - prefix[i],一比较就出来了。理解了这层关系,后面遇到连续子数组和、二维矩阵区域和等问题时,你会自然地往这个方向想。

3. 边界条件与溢出陷阱:为什么正确算法在 OJ 上仍然会挂

3.1 最容易翻车的几个输入场景

算法思路没问题,代码也写对了,但提交上去还是错,大概率是卡在下面这些边界场景里。

我整理了一个测试矩阵,你可以直接拿来验证自己的实现:

输入数组期望结果原因分析
[]-1空数组没有任何位置可以作为平衡点
[5]0左侧和为 0,右侧和为 0,相等
[1, 2, 3]-1没有任何位置的左右两侧和相等
[0, 0, 0, 0]0所有位置都满足,按题目约定返回最左侧
[-1, -1, -1, -1, -1]2负数情形下公式依然成立
[1, 100, 1]1经典单点平衡
[100, 1, -1, 100]1左侧 100,右侧 -1 + 100 = 99?这里没有平衡点,答案是 -1,用来验算等式

逐个验证完,基本能把实现里的低级错误暴露出来。

3.2 空数组和单元素数组的特殊讨论

空数组很简单,直接返回 -1,因为连位置都没有。

单元素数组是常被忽略的边界。按照经典 LeetCode 724 题的语义,[5]的平衡点是 0,因为下标 0 左侧为空,和视为 0,右侧也为空,和视为 0,两边相等。

但有些题目或者面试官会额外限制“左右两侧都必须至少有一个元素”,这种情况下平衡点只能从 1 取到 n-2。遇到这类题干,直接把遍历范围改成range(1, n - 1)就行。我建议你编码前先和面试官确认,或者看题目描述里有没有“非空”字眼,这种细节往往是隐藏的扣分点。

3.3 整数溢出问题:用 int 会炸吗

很多人写 C++ 或 Java 时习惯性用int存储总和,这在大多数测试数据下没问题,但并不是安全的习惯。

假设数组长度为 10^5,每个元素最大是 10^9,总和就达到 10^14,远超 32 位int的上限约 2.1 x 10^9。一旦超限,累加和变成负数,判断就全乱了。

所以我个人建议:

  • C++ 里总和用long long,
  • Java 里用long,
  • Python 不需要担心,因为整数会自动扩容。

同时,左侧累计值也要用同样的大类型维护,不要只在总和上用long long,左侧累加一样可能溢出。

这里有一个小判断技巧:如果你的输入范围是n <= 10^5、nums[i] <= 10^4,那int确实够用,因为最大总和是10^9。但只要题目没有明确保证,我默认一律用更宽的类型,不给自己留隐患。

3.4 多个平衡点存在时,返回哪一个

还有一种情况,数组里有不止一个位置满足条件,例如[0, 0, 0, 0],每个下标都是平衡点。多数平台题目的约定是返回最左侧的那个,也就是下标最小的。

我们的实现天然优先返回最左侧的位置,因为遍历是从下标 0 开始,第一个满足条件的位置会立即return。这正是题目想要的语义。如果你的变体要求返回最右侧,只需要把遍历顺序反过来,从右往左扫,同时维护右侧累计值即可。

4. 平衡数组与同类“分割类”题型的家族图谱:别只会背模板

4.1 几种高度相关的变体

刷题最忌讳的是孤立地记题解。平衡数组看起来只是一个前缀和入门题,但它和好几个经典题型共享同一套思维底子。我列一下常见变体,以及它们和平衡数组的关系:

题型核心思路时间复杂度和平衡数组的关系
寻找中心下标 / 平衡点前缀和 + 单次遍历O(n)本体
分割等和子集0/1 背包动态规划O(n x sum/2)不要求连续,只要求能分成两个和相等的子集
连续子数组和等于 K前缀和 + 哈希表O(n)关注区间的累计差,而非单一分割点
左右两侧乘积相等变体前缀积O(n)思路相同,但要额外处理 0 和精度

4.2 从单点分割到“分割等和子集”

平衡数组要求分割点是连续的,也就是左边连续一段、右边连续一段。而“分割等和子集”问题问的是:能不能把一个数组分成两个子集,使得两个子集的和相等,子集不要求连续,元素只要被分配到两边即可。

举例,[1, 5, 11, 5]可以分成[1, 5, 5]和[11],两边和都是 11。这个问题没法再用简单的一遍扫描解决,因为元素可以任意组合。它的标准做法是转化成 0/1 背包问题:先判断总和的奇偶性,如果总和是奇数,直接不可能平分;如果是偶数,就看能否从数组里选出一些元素,让它们的和等于总和的一半。

动态规划的状态定义是dp[j]表示能否选出若干元素使得和为 j。这是平衡数组问题的第一个“升级版本”,适合在掌握基础前缀和之后进阶。

4.3 从单点分割到“连续子数组和为 K”

另一类问题是给你一个数组和一个目标值 K,问有多少个连续子数组的和恰好等于 K。典型题目如 LeetCode 560。

平衡数组判断的是某个单点左侧和等于右侧和,而这里是找任意区间[l, r]的和等于目标值。如果每次都枚举左右端点,复杂度是 O(n^2)。更聪明的做法是边遍历边把前缀和存进哈希表,对于当前前缀和cur,只需要查之前出现过多少次cur - K。

这两个题都用了前缀和,但一个是“遍历时维护左侧累计量做比较”,另一个是“用哈希表记录历史前缀和出现次数”。把这两个题放在一起对比着刷,你会真正理解前缀和不是某一个公式,而是一种表达区间信息的方式。

4.4 换成乘积怎么处理

有些变体把“和”换成“乘积”,比如找某个位置让左侧所有元素的乘积等于右侧所有元素的乘积。

数学上,思路和求和完全一致,只需要维护左侧乘积,然后判断左侧乘积是否等于总乘积除以当前元素再除以左侧乘积。但这里有新的坑:

  • 如果数组里有 0,总乘积变成 0,公式的除法会失效。
  • 如果数组乘积很大,可能超过 long long。

实用的处理方式是:先把 0 单独讨论,或者取对数把乘法变成加法,再用前缀和思路判断。我实际做这类题时,会先和面试官确认数据范围里有没有 0,如果有,就直接分段讨论,不强行套公式。

5. 面试官视角与工程应用延伸:从刷题到落地

5.1 面试时这道题真正考察的是什么

在面试中,面试官抛出平衡数组这道题,重点往往不在“能不能想出最优解”,而在于几个递进的问题:

第一,写出的代码能不能处理空数组、负数、单元素数组。很多人主逻辑写对了,却在边界上翻车。

第二,能不能解释清楚为什么右侧和等于总和减去左侧和再减去当前元素。这一步考察的是你对公式推导过程的理解,而不是背结论。

第三,当被追问“如果数组非常大,甚至无法全部载入内存怎么办”,你能不能想到分块处理或者流式累计的思路。平衡数组的解法本来就只需要一遍遍历,如果数据从磁盘流式读入,你完全可以维护一个总和一个左侧累计值,跑完就出答案。

第四,如果要返回所有平衡点而不是单个,你只需要在循环里把满足条件的位置收集到结果数组里,而不是直接return。这种小扩展能快速检验候选人是不是真的理解代码控制流。

5.2 工程里我实际用过的“累计量定位”场景

前文提过的日志切分和文件分片,我具体展开一次。当时的情况是要把一个很大的 CSV 文件按行拆成两个部分,希望两部分的行数尽量相等,同时不能从文件中间截断一行。

由于每行的字节数不一样,不能简单按文件大小对半切。我们的做法是:

  1. 先扫一遍文件,记录每行的累计字节数。
  2. 拿到总字节数后,再扫一遍,找到第一个累计字节数超过总量一半的位置。
  3. 在该行处切分,就能保证两部分的字节总量差距最小。

这和平衡数组的思路一模一样:第一次遍历算总和,第二次遍历维护累计量,找到临界点。唯一区别是这里没有严格的“相等”,只有“找一个让我们最接近目标的位置”。

我还在一个近似负载均衡的小工具里用过类似逻辑。当时有一批任务,每个任务耗时不同,需要把它们分成两组并行执行,让两组的总耗时尽量接近。因为我们允许打乱顺序,所以可以先排序,再从两侧交替分配,这和平衡数组的连续分割思路略有不同,但底层都是“累计量逼近目标值”的思想。

5.3 我自己的几个编码小习惯

最后分享几个我刷这道题以及同类题时养成的习惯,算是对这篇文章的实践收尾。

写任何数组题之前,先列出至少三组特殊输入:空数组、单元素、全零数组。这不是浪费时间,它能直接避免提交后花十分钟查边界。

涉及累加和的题目,哪怕题目数据范围看起来很小,我也优先用 long 类型。这不是性能问题,而是减少一类“隐藏错误”的最低成本手段。

比较左右两侧关系时,尽量把判断条件写成对称形式,例如left_sum == total - left_sum - nums[i],不要先算右和再比较,因为这样更容易发现公式里的符号错误。

多花一分钟去想“如果题目再加一个条件,我的解法还能不能成立”。比如改成求所有平衡点,比如要求左右两侧非空,比如数组变成浮点数。这些扩展思考能让你真正把一道题吃透,而不只是记住一个答案。

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

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

立即咨询