晚上刷题刷到一道新题,LeetCode 3713「最长的平衡子串 I」,难度标的是中等。这道题看起来短小精悍,但里面藏着一个很典型的算法套路:前缀和 + 哈希表。如果你刷过 LC 525(连续数组)、LC 1371(每个元音包含偶数次的最长子字符串),会对这个套路相当眼熟。这篇文章我把完整思考过程拆开揉碎讲清楚,从暴力解法到 O(n) 最优解,还有那一堆让人莫名其妙的边界条件,一次性说明白。
1. 先别急着写代码:把「平衡子串」翻译成人话
1.1 为什么「平衡」就是两种字符数量相等
题目本身很短,核心就是这句话:给定一个由 0 和 1 组成的字符串 s,找出最长的连续子串,使得子串中 0 和 1 的数量相等。这种子串就叫平衡子串。
举个例子,s = "00110":
- 子串 "00":两个 0,零个 1,不平衡。
- 子串 "011":一个 0,两个 1,不平衡。
- 子串 "01":一个 0,一个 1,平衡,长度 2。
- 子串 "0110":两个 0,两个 1,平衡,长度 4。
- 子串 "0011":两个 0,两个 1,平衡,长度 4。
所以最长平衡子串长度为 4。整个字符串呢?三个 0、两个 1,不平衡,因为数量不相等。
如果你再观察一下,会发现一个隐藏规律:既然 0 和 1 数量相等,那平衡子串的总长度一定是偶数。这一条看起来像是废话,但后面写代码时可以拿来做剪枝,也可以用来提前检查答案的合理性。
1.2 这道题适合谁来练
说实话,这道题你说它难,它没有复杂的递归也没有树和图,但你说它简单,第一次碰到前缀和思路的人,可能要在暴力的坑里卡很久。
我个人的判断:这道题非常适合用来补「子数组问题」的短板。你已经能熟练写出滑动窗口解决那种"窗口内满足某条件"的题,但会发现滑动窗口在这里并不好用,因为窗口收缩条件没法简单定义——0 和 1 的数量差是动态变化的,你不清楚该缩左边还是缩右边。这时候就需要换一个切入点:前缀和。这是从「暴力枚举」到「数学等价」的典型转型练习,值得花时间吃透。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 暴力解法的天花板:O(n^2) 也扛不住的数据规模
2.1 从双层循环出发,先写一个能算对的版本
遇到子串问题,第一反应通常是枚举左右端点。最粗暴的写法是三层循环:枚举起点 l,枚举终点 r,然后统计 s[l..r] 中 0 和 1 的数量。这个写法复杂度 O(n^3),一般只用来对拍验证。
稍微优化一下,枚举 l 和 r 时顺带更新计数,能把复杂度降到 O(n^2):
python复制def brute_force(s: str) -> int:
n = len(s)
ans = 0
for l in range(n):
cnt0 = cnt1 = 0
for r in range(l, n):
if s[r] == '0':
cnt0 += 1
else:
cnt1 += 1
if cnt0 == cnt1:
ans = max(ans, r - l + 1)
return ans
这段代码逻辑完全正确。对于超短字符串,比如长度只有几十,结果也完全没问题。
2.2 为什么这个复杂度注定过不了
问题在于数据规模。力扣中等题,字符串长度通常到 10^5 这个量级。O(n^2) 意味着需要执行大约 10^10 次操作,也就是百亿次级别。就算每次操作只是简单判断,在 Python 里跑完也要几分钟起步,在 C++ 里也要好几秒甚至更久,完全无法通过。
到这里你就发现了,枚举所有子串这个思路本身就是死路。真正的解法不是去「试试每个子串」,而是找到一个数学条件,让我们能快速判断「以 i 结尾的最长平衡子串从哪里开始」。
这也是为什么需要引入前缀和:把「区间内 0 和 1 数量相等」这个判断,转换成一个 O(1) 就能查出来的条件。
3. 关键一步:用前缀和把「平衡」变成「相等」
3.1 核心套路:0 变成 -1,1 变成 1
现在引入一个非常经典的变换:把字符 '0' 映射为 -1,把字符 '1' 映射为 +1。
定义前缀和数组 pre,其中 pre[i] 表示字符串前 i 个字符经过映射后的累加和,特别地 pre[0] = 0。
举个例子,s = "00110",映射后就是 [-1, -1, +1, +1, -1]。
对应的前缀和:
- pre[0] = 0
- pre[1] = -1
- pre[2] = -2
- pre[3] = -1
- pre[4] = 0
- pre[5] = -1
用生活化的类比来说:把 0 当成欠一分的欠款,把 1 当成赚一分的收入,前缀和就是某个时刻的「累计结余」。如果结余为 0,说明收入和欠款一样多。
3.2 为什么前缀和相等等价于平衡子串
这是整个算法的灵魂。假设有前缀和 pre[l] 和 pre[r],其中 l < r。那么从位置 l+1 到位置 r 这一段区间的「累计贡献」就是:
pre[r] - pre[l]
如果区间内 0 和 1 数量相等,映射后这一段的贡献一定是 0。所以:
pre[r] - pre[l] = 0
也就是:
pre[r] = pre[l]
换句话说:两个前缀和相等,中间夹着的那一段就是平衡子串。这就像两个人站在同一海拔高度,中间走过的上坡和下坡总高度差必然为 0。
还是用 s = "00110" 来验证:
- pre[0] = 0,pre[4] = 0,说明位置 1 到 4 之间的子串 "0110" 是平衡的,长度 4。
- pre[1] = -1,pre[3] = -1,说明位置 2 到 3 之间的子串 "01" 是平衡的,长度 2。
- pre[2] = -2,这个值没有重复出现,所以以它为边界的区间没有平衡子串。
找出所有相等的前缀和,取最大位置差,就是答案。这一步的等价转换,直接让我们把一个「区间统计问题」变成了「数组相等元素求最大距离问题」。
4. 用哈希表记录「第一次出现」,把复杂度压到 O(n)
4.1 为什么只记录第一次出现的位置
既然问题是求两个相同前缀和的最大距离,那贪心想法就来了:对于某个前缀和值 val,如果我们从左往右遍历,遇到 val 第一次出现的位置是 pos,那么之后无论哪次再遇到 val,与 pos 之间的距离一定是最长的。因为 pos 是最靠左的,距离 pos 越远,长度越大。
所以思路是:用哈希表记录每个前缀和值第一次出现的位置,遍历过程中每算出一个新的前缀和,就去哈希表里查它第一次出现的位置,更新答案。如果没出现过,就把它当前位置记入哈希表。
如果你反过来记录最后一次出现的位置,那就麻烦了。因为等到遍历结束,我们得再回扫一遍,等于多一次 O(n) 遍历。虽然复杂度还是 O(n),但代码更啰嗦,也没必要。
4.2 完整代码与逐行解释
下面给出 Python 的最终解法:
python复制class Solution:
def findTheLongestBalancedSubstring(self, s: str) -> int:
# 哈希表:前缀和 -> 第一次出现的下标
first = {0: -1}
pre = 0
ans = 0
for i, ch in enumerate(s):
pre += 1 if ch == '1' else -1
if pre in first:
ans = max(ans, i - first[pre])
else:
first[pre] = i
return ans
逐行说:
first = {0: -1}:初始化前缀和为 0 的第一次出现位置是 -1。为什么是 -1 而不是 0?因为 pre[0] = 0 对应的是「一个字符都没选」的状态,它的位置应该在整个字符串之前,也就是 -1。这个细节非常关键,后面单独讲。pre用来记录当前前缀和。ans保存最大长度。- 遍历每个字符时,把 '1' 当作 +1,'0' 当作 -1,更新 pre。
- 如果 pre 之前出现过,说明从上一次出现位置的后一个字符到当前位置,是一段平衡子串,更新 ans。
- 如果 pre 没出现过,说明这是一条新路,记录它的位置。
以 s = "00110" 手工模拟一遍:
| 下标 i | 字符 | 映射 | pre | first 中已存在? | 尝试更新 ans |
|---|---|---|---|---|---|
| 初始 | - | - | 0 | - | - |
| 0 | 0 | -1 | -1 | 否,记录 first[-1]=0 | 不动 |
| 1 | 0 | -1 | -2 | 否,记录 first[-2]=1 | 不动 |
| 2 | 1 | +1 | -1 | 是,first[-1]=0,长度 = 2-0=2 | ans=2 |
| 3 | 1 | +1 | 0 | 是,first[0]=-1,长度 = 3-(-1)=4 | ans=4 |
| 4 | 0 | -1 | -1 | 是,first[-1]=0,长度 = 4-0=4 | ans=4 |
结果 ans = 4,正确。
4.3 复杂度和空间取舍
时间上,一次遍历,每个字符只处理常数次哈希操作,所以时间复杂度 O(n)。
空间上,哈希表最多存放 n+1 个不同前缀和,所以空间复杂度 O(n)。
有没有办法把空间压到 O(1)?在某些特殊情况下可以,因为前缀和的取值范围是 [-n, n],可以开一个长度为 2n+1 的数组,把前缀和加上偏移 n 当作下标来用。这样空间还是 O(n),但哈希表换成数组后常数更小,访问更快。不过要注意,如果 n 很大,数组的长度是 2n+1,内存占用也不小。哈希表的好处是只存出现过的值,实际更节省空间,而且代码可读性更好。两种写法都可以,我一般默认用哈希表。
5. 边界条件与失分点:这些坑我替你踩过了
5.1 first[0] = -1 还是 first[0] = 0?这是最容易翻车的地方
很多第一次写这道题的人,会把初始化写成 first = {0: 0},然后遍历时 i 从 0 开始。如果这样写,当平衡子串从字符串开头就开始,比如 s = "01",算出来是什么结果?
- pre[0] = 0,first[0] = 0
- 遍历 i=0,字符 '0',pre = -1,first[-1] = 0
- 遍历 i=1,字符 '1',pre = 0,first[0] 存在,ans = max(0, 1 - 0) = 1
结果是 1,但正确答案明显是 2。问题出在:s[0..1] 这一段 "01" 是平衡的,对应的前缀和相等点应该是 pre[-1](空串状态)和 pre[1],但 first[0] 被错误地当成了下标 0,于是区间被算成了从下标 1 到下标 1,只有 "1" 这一个字符。
解决方案有两种:
第一种,像我上面的代码,初始化 first[0] = -1,i 从 0 开始遍历。这样 s[0..1] 对应 first[0] = -1 到 i=1,长度为 2。
第二种,初始化 first[0] = 0,i 从 1 开始遍历,并且用 enumerate(s, 1):
python复制class Solution:
def findTheLongestBalancedSubstring(self, s: str) -> int:
first = {0: 0}
pre = 0
ans = 0
for i, ch in enumerate(s, 1):
pre += 1 if ch == '1' else -1
if pre in first:
ans = max(ans, i - first[pre])
else:
first[pre] = i
return ans
两种写法都对,但必须保持一致。我个人的习惯是第一种,因为 pre[0] = 0 在数学定义上的位置就是「下标 -1」,这样前缀和数组的下标和原字符串下标对得上,不容易乱。
5.2 特殊用例对照表:从常规到刁钻
我把测试用例整理成一张表,写完代码可以直接拿这些用例来自测。
| 输入 s | 说明 | 期望输出 |
|---|---|---|
| "" | 空串 | 0 |
| "0" | 单个字符 | 0 |
| "1" | 单个字符 | 0 |
| "000" | 全 0 | 0 |
| "111" | 全 1 | 0 |
| "01" | 整体平衡 | 2 |
| "10" | 整体平衡 | 2 |
| "00110" | 前面例子 | 4 |
| "01000111" | 整串 0 和 1 各 4 个 | 8 |
| "111000" | 三种划分都平衡,最长 6 | 6 |
| "101010" | 任意偶数前缀都平衡,最长 6 | 6 |
其中 "101010" 这个例子我特别喜欢。它的前缀和序列是 1, 0, 1, 0, 1, 0,会频繁触发哈希命中。你可以自己模拟一遍,观察为什么整个串就是答案。
5.3 奇数长度可以直接跳过的启发式优化
前面说过,平衡子串长度必然是偶数。所以理论上可以在更新 ans 时加一个判断:
python复制if (i - first[pre]) % 2 == 0:
ans = max(ans, i - first[pre])
不过这属于「看起来合理、实战中没必要」的优化。因为当两个前缀和相等时,它们之间的区间累积贡献为 0,而每个字符贡献是 +1 或 -1,因此区间长度必然是偶数。也就是说,哈希命中时自然满足偶数长度条件,没必要额外判断。倒是在你肉眼测试的时候,如果某个答案长度是奇数,那一定是代码写错了,这是个很好的自查信号。
6. 这题的后续:从二进制扩展到多字符的通用解法
6.1 如果平衡子串不是只有 0 和 1 呢
这种平衡子串问题,一旦吃透了 0/1 版本,就可以自然想到推广:如果字符串由多种字符组成,要求子串中所有字符出现次数都相等,那怎么求最长子串?
这里的平衡条件就复杂了:比如要求 'a'、'b'、'c' 的出现次数两两相等,就不能再用单个前缀和了。一个可行方案是把每个字符的出现次数相对于第一个字符做差,得到一个 k-1 维的差值状态。只有当两个位置的状态完全相同时,中间区间的每个字符数量之差才保持不变,从而所有字符数量相等。
举个例子,对于字符集 {a, b, c},前缀中记录 cnt_a - cnt_b 和 cnt_a - cnt_c 两个差值,组成一个二元组状态。用哈希表记录每个状态第一次出现的位置。遍历时遇到相同状态,就说明从上次位置到当前位置,a、b、c 的增加量相同,于是这是一个「平衡子串」。这就是 LC 1371 那类题的通用思路,本质上还是前缀和 + 哈希表,只是状态从单个整数升级成了元组。
6.2 和经典题的对照:525、1371 其实是一家人
- LC 525:给定一个二进制数组,求含有相同数量的 0 和 1 的最长连续子数组。这道题和 3713 几乎一模一样,只是输入从字符串换成了数组。
- LC 1371:找出最长子串,使得每个元音字母出现的次数都是偶数。解法是把每个元音出现次数的奇偶性压成一个 5 位二进制状态,遇到相同状态时更新答案。
- LC 325:求和为 k 的最长子数组长度,用的也是前缀和 + 哈希表,只是把判断条件从 pre[r] == pre[l] 改成 pre[r] - pre[l] == k。
我的体会是:这几道题共用的核心是「寻找两个前缀状态的相同点」。面试时如果能把 3713 讲清楚,再顺带说一句 LC 1371 的状态压缩思想,会显得你确实理解了这个套路,而不是只会背模板。
6.3 面试时怎么由浅入深地把这道题讲出来
如果在面试中遇到类似题目,我建议按这个顺序表达:
- 先抛出暴力解,说明复杂度 O(n^2) 或 O(n^3),指出瓶颈在于重复统计。
- 提出 0 变 -1、1 变 +1 的映射,画出前缀和数组的直观含义。
- 用「两个前缀和相等夹出来的区间就是平衡子串」这个结论,完成数学等价转换。
- 引入哈希表记录最早位置,给出 O(n) 解法,复杂度分析一次说清。
- 最后聊扩展,提到多字符场景可以升级为多维状态或位压缩。
这套话术的好处是每一步都有明确理由,不至于被面试官连续追问「为什么」时卡壳。实际写代码时,注意先把 first[0] 初始化的边界情况想清楚,不要一上来就写循环,否则很容易栽在开头位置的定义上。
个人的刷题习惯是遇到这类题,先把暴力版写出来做对拍,然后用随机小规模数据对比优化版的结果。等两边输出完全一致,再提交到平台。这样可以有效避免因为边界条件疏漏导致的反复罚时。这道 3713 不算特别难,但它是检验你是否真正理解前缀和思想的一道好题——能独立不看题解写出来,说明子数组相关的题型你已经入门了。
