在 LeetCode 周赛 430 做到 3719 这道题时,我盯着题目标题后面的“I”犹豫了几秒。按 LeetCode 的出题习惯,带“I”的题往往后面还跟着数据范围更大、限制更多的“II”,换句话说这道题多半不是让你一上来就写神仙解法的。我的第一反应也很直白:先写一个接近暴力的版本,跑通思路,拿稳分数,再顺着暴力过程中暴露出来的规律去优化。后来的发展也印证了这个选择,几乎暴力的 O(n^2) 版本能在数据范围内 AC,而从暴力到最终 O(n) 解法之间的那条路,恰恰藏在我自己写的循环里。这篇就完整复盘一下我当时的思考过程,以及这道“最长平衡子数组 I”里最值得沉淀的前缀和与哈希表套路。
1. 我把(几乎)暴力放在第一版,不是摆烂,是周赛策略
很多人在周赛里有一个误区:总觉得不写出最优解就不算做完,于是一上来就死磕 O(n) 或者 O(n log n) 的算法,结果边界条件越调越乱,最后连暴力分都没拿到。我做题的习惯刚好相反,如果肉眼判断数据范围允许,我会先在编辑器里敲一版最简单、最不容易写错的代码,用它来确认自己的理解和测试用例是否正确。
1.1 做题前先花十秒看数据范围
拿到这道题之后,我第一件事不是想数学结论,而是看题目给的约束条件。如果本题的 nums 长度比较小,比如 n ≤ 2000,那么一个 O(n^2) 的枚举方案在 Python 里也只需要百万到千万级别的常数操作,完全可以在 1 到 2 秒内跑完,根本不需要上来就搞什么前缀和哈希表。
这里我通常会做一个快速判断:
- n ≤ 1000:O(n^2) 很稳,O(n^3) 可能有点悬但不一定挂。
- n ≤ 5000:O(n^2) 勉强能过,最好把常数写小一点。
- n ≥ 10^5:O(n^2) 基本没戏,必须想线性或者 O(n log n) 做法。
LeetCode 周赛里的 I 题常见套路是把数据范围控制在中等大小,让不太熟练的人也能用暴力拿到 AC。因此我的结论是:这道题可以先用暴力探路,等理解了问题本质之后再考虑优化。反正暴力代码通常很短,就算最后被淘汰,也能当优化版本的验证器用。
1.2 暴力代码承担的另一层身份:验证器
很多选手没有意识到,暴力代码不只是用来拿保底分的,它还是一个很好的“对拍器”。当你写出 O(n) 的哈希表解法之后,怎么确认自己没有把边界写错?最直接的办法就是生成一组随机测试数据,拿暴力结果跟优化结果对比,一旦不一致,立刻就能定位问题。
我后来写 O(n) 版本的时候,并没有直接删掉暴力代码,而是把它留在一个测试函数里。跑随机数组时,两个版本的结果一直保持一致,我才有信心提交优化版。如果没有暴力版本作为参照,单靠脑补很容易漏掉“前缀和相等但出现在开头”这类边界情况。所以不要瞧不起暴力,它在算法题里的价值比很多人想象中大得多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先翻译题面:0和1数量相等的子数组,可以写成和为零
这道题比普通子数组题多了一个门槛:它没有直接让你求“最大子段和”或“和为 K 的子数组”,而是用“平衡”这个词包装了一下。我当时在草稿纸上做的第一件事,就是把“平衡”翻译成一个可以计算的形式。
2.1 从0和1数量相等到和为零的转化
如果我们按最常见的 0/1 数组形式来理解,平衡子数组的定义是:子数组中 0 的个数等于 1 的个数。这个定义本身没有问题,但直接去数 0 和 1 的数量会让代码比较啰嗦,而且不太好跟“连续子数组求和”联系起来。
更简洁的等价说法是:把数组里的 0 当成 -1,把 1 当成 +1,然后看这段子数组的元素和是否为 0。
为什么可以这么转化?假设一个子数组里有 a 个 0 和 b 个 1,那么按照 0 → -1、1 → +1 的映射,这段子数组的和是 b - a。如果子数组平衡,也就是 a = b,那么 b - a = 0。反过来,如果映射后的和为 0,那么 b = a,这正好就是平衡的定义。
举个例子:
text复制nums = [0, 1, 0, 1, 1, 0]
weights = [-1, 1, -1, 1, 1, -1]
整个数组里 0 的个数是 3,1 的个数也是 3,所以它是平衡的。映射之后所有元素相加等于 0,也印证了这一点。这一步转化虽然只有一行,但非常重要,因为“连续子数组和为 0”可以直接用前缀和来处理,而“0 和 1 数量相同”这个概念相比之下就不好直接套公式。
2.2 从枚举所有子段到前缀和的思维路径
如果你的第一反应是枚举所有子数组,然后分别统计 0 和 1 的个数,这是完全可以理解的。连续子数组问题最朴素的想法就是枚举左右端点。但枚举的复杂度通常比较高,后续想优化,无非两条路:滑动窗口或者前缀和。这道题的元素不是单调的,窗口不能随便滑,所以前缀和才是更自然的工具。
前缀和的定义是:设 prefix[i] 表示从数组开头到下标 i 的映射后元素之和,那么从下标 i 到下标 j 的子段和可以写成:
text复制子段和 = prefix[j] - prefix[i-1]
注意 prefix[-1] 是空前缀,值是 0。
平衡子数组要求子段和为 0,所以条件就是:
text复制prefix[j] - prefix[i-1] = 0
也就是:
text复制prefix[j] = prefix[i-1]
到这一步,题目已经发生了本质变化:不再是“枚举子数组并求和”,而是“在前缀和序列中找到两个相等的值,让它们对应下标的距离尽可能大”。这个结论我在暴力代码运行到一半的时候才真正意识到,但提前推导出来会更有方向感。
3. 现场连续写了两个版本:O(n^3)的直觉版和O(n^2)的实战版
聊完理论,回到我当时写代码的过程。我实际写了两个暴力版本,第一个版本太笨,只用来确认题意;第二个版本才是我口中“几乎暴力”的实战版本。
3.1 第一版想法:很直接,但不能提交
第一版代码的思路就是字面意义上的暴力三层循环。外层枚举起点 i,内层枚举终点 j,然后把 nums[i:j+1] 里的元素重新数一遍,判断 0 和 1 的数量是否相等。
如果用 Python 写,最直观的版本可能是这样:
python复制def longest_balanced_subarray_brutal(nums):
n = len(nums)
ans = 0
for i in range(n):
for j in range(i, n):
if (j - i + 1) % 2 == 0:
cnt0 = 0
cnt1 = 0
for k in range(i, j + 1):
if nums[k] == 0:
cnt0 += 1
else:
cnt1 += 1
if cnt0 == cnt1:
ans = max(ans, j - i + 1)
return ans
这段代码的正确性没有问题,但算一下复杂度就知道有问题:三层循环的情况下是 O(n^3),一旦 n 到 1000 以上就会非常吃力。我当时写下这个版本只是为了确认“平衡”到底是什么意思,并没有真打算提交它。
3.2 第二版(几乎)暴力:右端点移动时顺手更新差值
看到三层循环的丑陋后,我立刻想到可以这样优化:内层循环不需要每次都重新统计 0 和 1 的数量。当起点固定时,右端点 j 是从 i 逐步向右扩展的,我只需要维护一个变量 diff,表示当前子数组里 1 的数量减去 0 的数量。每往右加入一个元素,如果是 1,diff 加 1;如果是 0,diff 减 1。当 diff == 0 时,说明当前子数组是平衡的。
这就是下面这个“几乎暴力”的版本:
python复制def longest_balanced_subarray_v2(nums):
n = len(nums)
ans = 0
for i in range(n):
diff = 0
for j in range(i, n):
if nums[j] == 1:
diff += 1
else:
diff -= 1
if diff == 0:
ans = max(ans, j - i + 1)
# 剩余长度已经不可能超过当前答案,直接剪枝
if n - i <= ans:
break
return ans
这段代码的外层循环枚举起点 i,内层循环枚举终点 j,同时只做 O(1) 的变量更新,复杂度是 O(n^2),空间复杂度是 O(1)。
我当时在本地用 nums = [0, 1, 1, 0, 1, 0, 0, 1] 测试了一遍,函数返回 8,因为整个数组里 0 和 1 各有 4 个,是平衡的。思路正确,边界也符合预期。
3.3 “几乎”在哪里:复杂度从 O(n^3) 到 O(n^2)
我之所以强调“几乎暴力”,是因为这段代码已经不是严格的“把所有子数组单独拿出来求和再判断”的暴力。它本质上还是枚举了所有起点和终点,但通过增量更新去掉了最内层的求和循环,所以只比理论下界 O(n^2) 多一点常数,当 n 在几千这个量级时完全能打。
如果真的把第二版代码称作暴力,其实有点冤枉它。很多 O(n^2) 的算法在图形上看起来都像暴力,但它们往往只需要在内层循环做常数次操作,实际速度远比 O(n^3) 或者 O(n^3 log n) 快得多。拿这道 I 题来说,如果数据范围是 n ≤ 5000,这个版本跑完也就几千万次加法,完全在可接受范围内。
不过我也很清楚,这个版本并非终点。如果题目后面还有一个 II 版本,把 n 提到 10^5 甚至 10^6,O(n^2) 肯定会超时。所以在用暴力 AC 之后,我没有急着马上提交,而是停了一下,想从这段代码里找到一个更本质的规律。
4. 停了一下,我看到了暴力代码里的规律:前缀和像水位一样重复
暴力代码写出来之后,我做的第一件事是从小例子开始手算前缀和。经过第 2 节的推导,我知道平衡子数组和“两个相等前缀和”之间有等价关系,但我还是想在真实例子上看看这些前缀和到底是怎么分布的。
4.1 一步步列出前缀和,观察重复位置
拿一个具体的例子:
text复制nums = [0, 1, 1, 0, 1, 0, 0, 1]
weights = [-1, 1, 1, -1, 1, -1, -1, 1]
从前往后算前缀和,注意我把空前缀和 0 放在最前面:
text复制位置 : -1 0 1 2 3 4 5 6 7
前缀和 : 0 -1 0 1 0 1 0 -1 0
这个序列很有规律。前缀和 0 出现在位置 -1、1、3、5、7;前缀和 -1 出现在位置 0、6;前缀和 1 出现在位置 2、4。
现在看看最长平衡子数组是什么。前缀和 0 第一次出现在 -1,最后一次出现在 7,所以从开头到结尾的整个数组一定是一个平衡子数组,长度为 7 - (-1) = 8。验证一下,这个数组确实是 4 个 0、4 个 1,平衡。
再看一个局部例子:前缀和 1 出现在位置 2 和位置 4,这意味着下标 3 到 4 这个子数组的和是 0,也就是 nums[3] = 0, nums[4] = 1 这段是平衡的,长度为 2。
这个例子清晰地展示了核心规律:当两个位置的前缀和相等时,夹在它们中间的那一段,映射后的子段和就是 0,对应到原始数组就是平衡子数组。
4.2 这个观察给了两个重要结论
第一个结论是,如果我想让子数组长度尽量大,那么对于同一个前缀和值,我应该记住它最早出现的下标,而不是最近出现的下标。因为子数组的长度等于当前下标减去最早出现下标,最早出现下标越小,长度就越大。
第二个结论是,这个问题里的“平衡条件”并不要求我枚举起点。我只需要从左往右扫一遍数组,不断计算当前前缀和,然后回头查一查这个前缀和之前有没有出现过。如果出现过,说明从之前那个位置的下一个元素开始到当前位置,是一个平衡子数组;如果没有出现过,我就把当前位置记下来,作为这个前缀和最早出现的地方。
到这一步,最优解法的框架已经很清晰了。我需要在遍历过程中快速查询一个值是否出现过,并且拿到它最早出现的下标。这显然是用哈希表。
5. 最终解法:哈希表记录最早的同水位位置,O(n) 收工
把“暴力”的思路再往前推一步,就很自然地得到 O(n) 解法。我在这里用 Python 写了一个非常简洁的版本。
5.1 哈希表版本的实现
python复制def longest_balanced_subarray(nums):
# pos 记录某个前缀和值最早出现的下标
# 空前缀的下标看作 -1,前缀和为 0
pos = {0: -1}
diff = 0
ans = 0
for i, num in enumerate(nums):
# 0 视为 -1,1 视为 +1
if num == 1:
diff += 1
else:
diff -= 1
if diff in pos:
# 从 pos[diff] + 1 到 i 这个子数组是平衡的
ans = max(ans, i - pos
