今天刷到 LeetCode 的每日一题 3719,标题是“最长平衡子数组 I”。看到“平衡子数组”这几个字,我第一反应是前缀和、滑动窗口、哈希表这些工具。但这次我没急着上大招,而是老老实实先用暴力枚举把整道题跑了一遍,发现这个带“I”字后缀的题目,用最朴素的枚举思路完全够用,而且过程特别适合用来梳理子数组问题的通用思考方式。
这篇文章我会从题目定义说起,把暴力枚举的完整链路展开:怎么枚举所有子数组、怎么判断“平衡”、代码怎么写才不容易错,以及当以后遇到“II”“III”版本时,怎么从暴力解法平滑过渡到更优方案。无论你是刚开始刷题的新手,还是想复习基础枚举技巧的老手,这篇都能给你一些参考。
1. 题目到底在问什么:最长平衡子数组的准确定义
1.1 “平衡”这个说法在不同题目里可能不一样
LeetCode 里出现过好几个带着“平衡”字眼的题目,含义并不统一。有的指左右括号数量相等,有的指奇偶位和相等,还有的是指某个区间内不同元素出现次数相同。对于 3719 这道“最长平衡子数组 I”,我按最常见的约定处理:给定一个只包含 0 和 1 的数组,如果一个连续子数组中 0 的数量和 1 的数量相等,就称它为平衡子数组,题目要求返回最长的平衡子数组长度。
为什么我会先按这个定义来?因为这个定义最符合“平衡”的直觉,也最能用暴力枚举讲到点子上。就算你的题面里“平衡”并不是指 01 数量相等,比如是 0、1、2 三种数字出现次数相同,后面讲的枚举框架依然适用,只需要把“判断平衡”的那一步换成题面要求的统计逻辑。数据结构是载体,思路是通用的。
1.2 先动手算几个例子,把平衡的直觉建立起来
我拿到题目后习惯先手算几个简单用例,让自己对答案有个预期,再上代码。这里我构造了五个例子:
- nums = [0, 1]:两个元素各出现一次,整个数组就是平衡的,答案是 2。
- nums = [1, 1, 0, 0]:0 有 2 个,1 有 2 个,整个数组平衡,答案是 4。
- nums = [1, 1, 1]:全是 1,没有 0,不可能平衡,答案应该是 0。
- nums = [0, 1, 0]:整个数组 0 有两个 1 有一个,不平衡;但子数组 [0, 1] 或 [1, 0] 是平衡的,答案是 2。
- nums = [0, 0, 1, 1, 0, 1]:整个数组 0 有 3 个 1 有 3 个,答案是 6。
注意最后这个例子,整个数组 0 和 1 都是 3 个,正好平衡,长度 6。这种情况最容易让人误以为必须找中间某个子区间,其实最长往往就是整个数组本身。
手算这几个例子的意义在于:你心里先有了标准答案,后面代码跑出来如果对不上,就说明边界条件或者判断逻辑出了问题。这比我写完代码直接交上去、靠评测机告诉我错要高效得多。
1.3 为什么这道题适合先用暴力枚举
“暴力枚举”这四个字在很多人眼里等于“笨办法”,但在我看来它先是一个验证手段,其次才是一个保底方案。这道题带“I”后缀,通常意味着题目本身不会太难,数据范围大概率比较小。当 n 的范围只有几千的时候,暴力枚举的代码量少、逻辑直白、不容易写错,交上去能过,那就没必要一开始就写一个复杂的线性解法。
而且暴力枚举能帮你把问题彻底想清楚。你必须在代码里回答“子数组怎么定义”“平衡怎么判断”“最大长度怎么更新”这三个问题。这些问题想明白了,后面优化自然有方向。所以我推荐的做法是:先写暴力枚举,再考虑有没有更快的办法,而不是反过来一上来就追求最优解。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 暴力枚举的核心思路:把一切可能都试一遍
2.1 穷举左端点和右端点:枚举所有子数组
一个连续子数组由左端点 i 和右端点 j 确定,并且 i ≤ j。所以枚举所有子数组最直接的方式就是两重循环:外层循环让 i 从 0 到 n-1,内层循环让 j 从 i 到 n-1。这样得到的 [i, j] 就是所有可能区间。
这里有一个新手很容易绕晕的点:如果数组长度是 n,那么子数组的数量是 n × (n + 1) / 2,而不是 n × n。因为内层循环 j 从 i 开始,而不是从 0 开始。这个细节直接决定了枚举次数,也影响了后续复杂度的估算。
举个直观的例子:n 为 5 时,i 为 0 对应 5 个区间,i 为 1 对应 4 个区间,依次递推,总共 15 个区间。把这些区间对应的子数组全部检查一遍,就能找到最长平衡子数组。
2.2 怎么判断一个子数组是否平衡
判断 [i, j] 这个子数组是否平衡,最朴素的做法是再开一重循环,从 i 到 j 统计 0 的个数和 1 的个数,然后比较是否相等。这自然引入三重循环,复杂度是 O(n³),后面我会分析它为什么慢。
不过这里的判断条件可以做个小小优化:既然数组里只有 0 和 1,那么只要统计出 1 的个数,0 的个数就等于区间长度减去 1 的个数。如果一个区间长度为 len,其中 1 的个数为 ones,那么平衡等价于 len - ones == ones,也就是 ones * 2 == len。
这个等式的价值不只是少统计一个变量,它还把“0 和 1 数量相等”转化成了一个纯数学条件,方便我们以后往前缀和那类方向思考。现在先记住这个条件,后面会反复用到。
2.3 三重循环的写法:最朴素版本的代码
按照最朴素的思路写出来,大概是下面这样:
cpp复制int longestBalancedSubarray(vector<int>& nums) {
int n = nums.size();
int ans = 0;
for (int i = 0; i < n; ++i) {
for (int j = i; j < n; ++j) {
int ones = 0;
int len = j - i + 1;
for (int k = i; k <= j; ++k) {
if (nums[k] == 1) ++ones;
}
if (len % 2 == 0 && ones * 2 == len) {
ans = max(ans, len);
}
}
}
return ans;
}
这段代码能跑,但效率是 O(n³)。为什么是三次方?因为最外层循环 n 次,中间循环平均 n/2 次,内层统计循环又要扫一遍区间,平均也是 n/2 次,三个一乘就是 O(n³)。
当 n 只有 100 时,大约 100 万次操作,毫无压力。当 n 到 1000 时,大约 10 亿次操作,明显吃力。所以这个版本只适合当“说明题意”的示范代码,实际提交基本用不上。
2.4 先别急着优化:暴力解的三个天然优势
很多人觉得三重循环太笨了,但我建议你至少把这个版本写一遍,因为它有三个不可替代的优势。
第一个优势是正确性容易验证。三层循环逻辑直白,没有复杂的数学推导,不容易出现隐蔽的逻辑漏洞。你只要确认枚举了所有区间、判断条件正确,答案就不会错。第二个优势是它是后续优化的基准。你后面写出了二重循环、线性解法,怎么验证它们是对的?拿随机数据和暴力版的结果对拍,一旦不一致,说明你的优化算法有 bug。第三个优势是它能帮你确认题目的真实约束。如果 n 只有 200,三重循环直接过;如果 n 是 2000,三重循环过不了,你就知道必须优化到 O(n²)。
所以我常跟朋友说,刷题别怕先写“笨代码”,笨代码有时候是你最可靠的坐标系。
3. 从 O(n³) 到 O(n²):去掉一重循环的两种姿势
3.1 边枚举边统计:增量更新的核心思想
三重循环慢在内层 k 每次都要从头统计。仔细想想,当 j 从 i 移动到 i+1、i+2、… 的过程中,区间 [i, j] 和 [i, j+1] 只相差一个元素 nums[j+1],根本没必要把前一个区间重新扫一遍。
这个思想叫增量更新,或者叫复用上一步的结果。它广泛出现在各种算法里,比如滑动窗口、前缀和、动态规划。这里我们只需要把 ones 定义成区间 [i, j] 中 1 的个数,当 j 右移一位时,如果新元素是 1,就让 ones 加 1。这样内层循环就从 O(n) 降到了 O(1),总复杂度自然变成 O(n²)。
3.2 固定左端点向右扩展:最推荐的暴力写法
把增量更新落地,代码会变得非常清爽。核心思路是:外层循环固定左端点 i,内层循环让右端点 j 不断向右扩展,同时维护当前区间 [i, j] 的 1 的个数。
cpp复制int longestBalancedSubarray(vector<int>& nums) {
int n = nums.size();
int ans = 0;
for (int i = 0; i < n; ++i) {
int ones = 0;
for (int j = i; j < n; ++j) {
if (nums[j] == 1) ++ones;
int len = j - i + 1;
if (len % 2 == 0 && ones * 2 == len) {
ans = max(ans, len);
}
}
}
return ans;
}
注意 ones 的初始化位置。它必须在每次 i 变化后重置为 0,而不是在函数开头定义后一直累加。一旦你把 int ones = 0 放到外层循环外面,整个结果就是错的,因为不同左端点对应的计数被串在一起了。这是我见过最常见的低级错误,后面我会再讲一遍。
这个版本的复杂度和实现难度都很平衡,也是我这次真正提交使用的版本。
3.3 用前缀和做区间查询:另一种 O(n²) 的姿势
除了增量更新,还可以用前缀和来快速求区间内 1 的个数。先预处理一个前缀和数组 prefix,prefix[k] 表示 nums[0] 到 nums[k-1] 中 1 的个数。那么区间 [i, j] 中 1 的个数就是 prefix[j+1] - prefix[i]。
cpp复制int longestBalancedSubarray(vector<int>& nums) {
int n = nums.size();
vector<int> prefix(n + 1, 0);
for (int i = 0; i < n; ++i) {
prefix[i + 1] = prefix[i] + nums[i];
}
int ans = 0;
for (int i = 0; i < n; ++i) {
for (int j = i; j < n; ++j) {
int len = j - i + 1;
int ones = prefix[j + 1] - prefix[i];
if (len % 2 == 0 && ones * 2 == len) {
ans = max(ans, len);
}
}
}
return ans;
}
这个版本复杂度同样是 O(n²),但多用了 O(n) 的额外空间。它的意义在于把“判断平衡”这一步变得更数学化,为后面优化提供了桥梁。
3.4 三种写法的复杂度对比与选型建议
我把三个版本放在一起做个对比,方便你根据场景选择:
| 写法 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|
| 三重循环统计 | O(n³) | O(1) | n ≤ 200 左右,或用于验证逻辑 |
| 固定左端点增量统计 | O(n²) | O(1) | n ≤ 5000 左右,日常推荐 |
| 前缀和 + 二重枚举 | O(n²) | O(n) | 需要区间查询的扩展场景 |
我建议的练习顺序是:先用三重循环把题意完全吃透,再改成二重循环增量统计,最后再往前缀和方向走。比赛或面试时直接写二重循环版本,因为在大多数“I 系列”题目中,它就是最稳的答案。
4. 完整代码实现与细节处理
4.1 C++ 暴力枚举参考代码
下面是我这次最终提交的 C++ 版本,注释写得很细,方便直接照抄并理解每一步:
cpp复制class Solution {
public:
int longestBalancedSubarray(vector<int>& nums) {
int n = nums.size();
int ans = 0;
for (int i = 0; i < n; ++i) {
int ones = 0; // 记录当前区间 [i, j] 中 1 的个数
for (int j = i; j < n; ++j) {
if (nums[j] == 1) {
++ones;
}
int len = j - i + 1;
// 平衡的必要条件是长度为偶数,然后判断 1 的个数是否等于长度一半
if (len % 2 == 0 && ones * 2 == len) {
ans = max(ans, len);
}
}
}
return ans;
}
};
要提醒一点:如果题目给出的 nums 可能包含 0 和 1 之外的数字,那判断条件就不能只统计 1 了。但 LeetCode 的“平衡子数组 I”通常会把数值范围限定在 0 和 1 之间,这样才有“平衡”的讨论价值。
4.2 Python 暴力枚举参考代码
Python 版本和 C++ 逻辑完全一致,只是语法不同:
python复制from typing import List
class Solution:
def longestBalancedSubarray(self, nums: List[int]) -> int:
n = len(nums)
ans = 0
for i in range(n):
ones = 0
for j in range(i, n):
if nums[j] == 1:
ones += 1
length = j - i + 1
if length % 2 == 0 and ones * 2 == length:
ans = max(ans, length)
return ans
Python 的写法在可读性上更有优势,尤其适合新手用来理解枚举逻辑。实际运行速度会比 C++ 慢一些,所以如果数据范围较大,优先用 C++ 提交。
4.3 边界条件与样例验证
写完代码后,我会用之前手算的测试用例过一遍:
- nums = [0, 1]:i=0,j=1 时 len=2,ones=1,满足条件,ans=2。
- nums = [1, 1, 0, 0]:i=0,j=3 时 len=4,ones=2,满足条件,ans=4。
- nums = [1, 1, 1]:所有区间 len 为奇数时直接跳过,len 为偶数(只有 len=2)时 ones 至少为 1,最多为 2,永远无法满足 ones*2=len,ans 保持 0。
- nums = []:n=0,两个循环都不会执行,ans=0。空数组的答案就是 0。
这类边界情况我建议每次都主动测一遍,特别是空数组和全相同元素的数组。全 1 数组容易让人误以为答案是“数组长度减一”之类的,实际上答案就是 0。
4.4 如果题目数据范围变大怎么办:暴力枚举的适用边界
我上一节说 n ≤ 5000 左右可以用暴力枚举,这是基于 O(n²) 的粗略估计。假设 n=5000,那么内层循环大约执行 1250 万次,每轮循环只有几次简单运算,C++ 大概在几十毫秒到一百多毫秒内完成,Python 可能要两三百毫秒,通常还能过。
但如果 n 到了 10 万,O(n²) 就是 50 亿次运算,任何一个在线评测都跑不动。此时必须优化到 O(n) 甚至 O(n log n)。这道题带了“I”后缀,多半不会让 n 大得离谱;但如果你在其他地方看到类似题名带“II”“III”的版本,一定要先检查数据范围再决定用哪种解法。
5. 常见错误与排查技巧实录
5.1 数组越界与端点取值的坑
暴力枚举最常见的错误是内层循环边界写错。比如有些人习惯写:
cpp复制for (int j = i; j < n - 1; ++j)
这样就把最后一个元素漏掉了。当 i 指向 n-1 时,j 只能取到 n-2,导致最后一个元素永远无法作为右端点。正确写法应该是 j < n,也就是说右端点能取遍所有合法位置。
另一个容易踩坑的地方是前缀和版本里 prefix[j + 1] - prefix[i],数组下标容易混淆。我的习惯是 prefix 多开一个位置,下标从 1 开始存前缀和,这样区间 [i, j] 映射到 prefix 下标就是 [j+1] 和 [i],不容易越界。
5.2 把“连续子数组”写成了“子序列”
LeetCode 里“子数组”和“子序列”是两个完全不同的概念。子数组要求连续,子序列只要求保持相对顺序。如果不小心用递归或状态压缩去枚举所有子序列,那结果会偏大,因为子序列可以跳过中间不平衡的元素。
判断自己是不是写错了很简单:你写的枚举结构如果出现了“选或不选”这类分支,那大概率在枚举子序列而不是子数组。子数组的枚举结构就是两重循环定左右端点,不会有分支选择。
5.3 O(n³) 超时的排查思路
如果提交后显示超时,第一步不是急着优化算法,而是先估算数据范围。看题目的 n 上限是多少,然后算一下你的复杂度在极限数据下大概要执行多少次操作。如果 n=2000,三重循环大概是 40 亿次,必然超时;改成二重循环就是 400 万次,稳过。
有时候超时不是因为复杂度,而是因为某个不必要的层里调用了高开销函数,比如字符串拼接、哈希表操作。遇到这种情况,先检查内层循环有没有冗余操作,再考虑算法层面的优化。
5.4 手动造数据的技巧:怎么快速验证算法正确性
我强烈建议大家养成对拍的习惯,尤其是学习新算法时。你可以写一个随机数据生成器,比如随机生成 n 从 1 到 200 的 01 数组,然后用暴力三重循环作为“标准答案”,和你的二重循环版本对比。只要随机跑几千组数据结果一致,你就有信心提交了。
这里给一个简单的手工测试数据,以后看到这类题可以直接用:nums = [0, 1, 1, 0, 1, 1, 1, 0]。手动推一下,最长平衡子数组是 [0, 1, 1, 0],长度 4。如果你代码跑出来是 2 或 6,说明判断逻辑有问题。
6. 从暴力枚举到更优解:经典的“1 变 -1”前缀和法
6.1 为什么暴力枚举不是终点
暴力枚举在数据范围小的场景下完全够用,但它不是终点。当 n 从 2000 变成 200000 时,O(n²) 是无论如何也过不去的。这时候就需要把暴力枚举里的数学条件提炼出来,去找更高效的算法。
我在第 2.2 节已经写了一个关键等式:0 和 1 数量相等,等价于 1 的个数等于区间长度的一半。如果更进一步,把数组里的 0 替换成 -1,把 1 保留为 1,那么一个区间内 0 和 1 数量相等,就等价于这个区间的元素和为 0。
这样一转换,问题就变成了:寻找和为 0 的最长连续子数组。
6.2 前缀和如何把区间和变成前缀和的差
定义一个前缀和数组 s,其中 s[0] = 0,s[k] 表示原数组前 k 个元素(下标 0 到 k-1)经过“0 变 -1”处理后的和。那么区间 [i, j] 的和就是 s[j+1] - s[i]。区间和为 0,等价于 s[j+1] == s[i]。
于是问题又变成了:在 s 数组中找到两个相同元素,并且它们的下标之差最大。这就可以用哈希表在 O(n) 时间内解决。
6.3 哈希表记录前缀和首次出现位置
具体的做法是:遍历前缀和数组 s,用哈希表记录每个前缀和值第一次出现的下标。如果某个前缀和值已经出现过了,就用当前下标减去首次出现的下标,更新答案。注意这里不能用“最后一次出现”覆盖“第一次出现”,因为我们要的是最长距离。
cpp复制class Solution {
public:
int longestBalancedSubarray(vector<int>& nums) {
int n = nums.size();
unordered_map<int, int> first;
first[0] = 0;
int sum = 0;
int ans = 0;
for (int i = 0; i < n; ++i) {
sum += (nums[i] == 0 ? -1 : 1);
if (first.count(sum)) {
ans = max(ans, i + 1 - first[sum]);
} else {
first[sum] = i + 1;
}
}
return ans;
}
};
这段代码就是经典的“连续数组”问题的解法,LeetCode 第 525 题用的也是完全相同的思路。如果你之前刷过那题,再看这题就非常亲切。
6.4 先暴力后优化的心法
从暴力枚举到 O(n) 解法,过程并不跳跃:先定义平衡条件,再把条件改写为 1 的数量等于长度一半,再把 0 换成 -1,让平衡直接对应区间和为 0,最后用前缀和加哈希表找最远相同值。每一步都很自然,关键是你要愿意先把暴力解法写出来。
我个人的习惯是,面对这种带“I”的题目,先看数据范围。如果 n 只有 2000,直接暴力枚举提交,不浪费时间去想更复杂的解法;如果 n 是 10^5,就别犹豫了,直接上 O(n) 解法。暴力枚举不是用来展示“我会写最笨的代码”,而是用来建立对问题的直觉。很多时候,把暴力版本的判断条件写在纸上,最优解的思路自己就冒出来了,这是我这几年刷题最深的体会。
