分割平衡字符串 图解题解
这道题到底在问什么
- 输入
- s = "RLRRLLRLRL"
- 输出
- 4
- 输入
- s = "LLLLRRRR"
- 输出
- 1
- 输入
- 本节演示 s = "LRLRLLRRLR"
- 输出
- 4
先想最直接的笨办法
上面这排是 "LRLRLLRRLR" 拆开的 10 个字符,前面有交错,中间也会出现连续的 L 和 R。开扫前把差值计数器清成 0,它代表「目前 L 的个数减 R 的个数」。从最左边第 0 个字符开始,一个一个往右看,差值一回到 0 就收下一段。(动画第 4 步)
最优解:为什么这么做
一句话答案:LeetCode 1221 分割平衡字符串:一遍扫维护差值,遇 L 加一遇 R 减一,差值一归零就切一段计数加一——能切就切段数最多,时间 O(n)、空间 O(1)。
怎样算平衡串,最多能切几段
先把「平衡」说清楚:一个由 L 和 R 组成的串,里头 L 的个数和 R 的个数一样多,就叫平衡。题目给的整串本身已经平衡,要你把它剪成若干段,每一段单独拿出来也得平衡——L 和 R 照样一样多,问最多能剪成几段。题面例子 s='RLRRLLRLRL' 能剪成 4 段,而 s='LLLLRRRR' 怎么剪都只剩一整段。
枚举所有剪法要试多少种
长 n 的串,每两个字符之间都能剪或不剪,剪法有 2^(n−1) 种,一种种试再逐段检查平不平衡,串一长就爆。上回溯或动态规划去搜切点,做的也是同一批重复功。可它们全都没注意到一件更省心的事:判断一段平不平衡,根本不用记它里头具体是哪些字符、排在什么位置。
一个计数器记差值,什么时候能剪
既然只关心一段里 L 和 R 是不是一样多,就用一个计数器记它俩的差:从左往右扫,遇 L 加一、遇 R 减一。这个差从上一个剪口之后开始累计,等它重新回到 0,就说明这一段里加进去的和减掉的抵消干净了,L 和 R 恰好相等,这一段平衡,可以剪。
那要不要一见差值归零就剪?会剪。差值每归零一次,都是一个合法剪口。假如在某个归零处忍着不剪、留到后面,只会把本可以分开的两小段并成一大段,段数只减不增。所以一到 0 就剪,把整串拆成一连串最短的平衡块,段数自然到顶。差值中途变成负数也不用管——串要是从 R 开头,差值会先掉到负的,但只要某一刻回到 0,这一段就配平了,正负号不改结论。
'RLRRLLRLRL' 一位一位过,4 段是怎么落袋的
拿题面的 'RLRRLLRLRL' 走一遍,差值和段数都从 0 起。第 0 位 R,差值 −1;第 1 位 L,差值回 0,剪下 'RL',段数 1。第 2 位 R 差值 −1、第 3 位 R 差值 −2、第 4 位 L 差值 −1、第 5 位 L 差值回 0,剪下 'RRLL',段数 2。第 6 位 R 差值 −1、第 7 位 L 差值回 0,剪下 'RL',段数 3。第 8 位 R 差值 −1、第 9 位 L 差值回 0,剪下 'RL',段数 4。十位扫完差值停在 0,答案 4 段。
换 'LLLLRRRR' 试:四个 L 把差值推到 4,再四个 R 一路减回,差值直到最后一位才碰到 0,中间从没归零,整串只能算一段,答案 1,和贪心的剪法对得上。
被负号唬住、给计数器加戏、拿回溯兜底,全是白费,还有跑多快
别被差值的负号吓退、以为算错了回头重数——它只是记 L 减 R 的即时落差,正负都行,只有等不等于 0 才有意义。也别为了「看着清楚」另开两个计数器分别数 L 和 R 再逐位比较,一个差值计数器已经把「是否相等」压成「是否为 0」,多一个变量只是多一处能写错的地方。还有人怕贪心不稳,非要回溯验证一圈,其实每个归零位都是铁的合法剪口,能剪就剪不会漏掉更优解。
复杂度上,每个字符只看一次,做一次加减和一次归零判断,时间 O(n);全程只有差值和段数两个整数,没有额外数组或栈,空间 O(1)。
▶ 动画逐步走查(共 23 步)——想跟着动画一帧帧对照就展开
- 3记牢一句话:差值遇 L 加一、遇 R 减一,只要差值回到 0 就收下一个平衡段。下面每一帧都在套这句。
- 4上面这排是 "LRLRLLRRLR" 拆开的 10 个字符,前面有交错,中间也会出现连续的 L 和 R。开扫前把差值计数器清成 0,它代表「目前 L 的个数减 R 的个数」。从最左边第 0 个字符开始,一个一个往右看,差值一回到 0 就收下一段。
- 5扫到第 0 位,是 "L",差值加一,现在差值是 1。绿色这一段是从上一段结尾到这里、还没收走的部分。
- 6差值是 1,不等于 0,说明这一段里 L 和 R 还没配齐,现在收下两边不平衡。所以先不收,把这一位并进当前这段(红框),继续往右扫。
- 7扫到第 1 位,是 "R",差值减一,现在差值是 0。绿色是当前还没收走的这一段。
- 8差值回到 0 了,说明从上一段结尾到第 1 位这一段里,L 和 R 一样多,是个完整的平衡段。收下这一段,段数变成 1 段,这段(变蓝)收走,下一段从第 2 位重新开始数。
- 9扫到第 2 位,是 "L",差值加一,现在差值是 1。绿色这一段是从上一段结尾到这里、还没收走的部分。
- 10差值是 1,不等于 0,说明这一段里 L 和 R 还没配齐,现在收下两边不平衡。所以先不收,把这一位并进当前这段(红框),继续往右扫。
- 11扫到第 3 位,是 "R",差值减一,现在差值是 0。绿色是当前还没收走的这一段。
- 12差值回到 0 了,说明从上一段结尾到第 3 位这一段里,L 和 R 一样多,是个完整的平衡段。收下这一段,段数变成 2 段,这段(变蓝)收走,下一段从第 4 位重新开始数。
- 13扫到第 4 位,是 "L",差值加一,现在差值是 1。绿色这一段是从上一段结尾到这里、还没收走的部分。
- 14差值是 1,不等于 0,说明这一段里 L 和 R 还没配齐,现在收下两边不平衡。所以先不收,把这一位并进当前这段(红框),继续往右扫。
- 15扫到第 5 位,是 "L",差值加一,现在差值是 2。绿色这一段是从上一段结尾到这里、还没收走的部分。
- 16差值是 2,不等于 0,说明这一段里 L 和 R 还没配齐,现在收下两边不平衡。所以先不收,把这一位并进当前这段(红框),继续往右扫。
- 17扫到第 6 位,是 "R",差值减一,现在差值是 1。绿色是当前还没收走的这一段。
- 18差值是 1,不等于 0,说明这一段里 L 和 R 还没配齐,现在收下两边不平衡。所以先不收,把这一位并进当前这段(红框),继续往右扫。
- 19扫到第 7 位,是 "R",差值减一,现在差值是 0。绿色是当前还没收走的这一段。
- 20差值回到 0 了,说明从上一段结尾到第 7 位这一段里,L 和 R 一样多,是个完整的平衡段。收下这一段,段数变成 3 段,这段(变蓝)收走,下一段从第 8 位重新开始数。
- 21扫到第 8 位,是 "L",差值加一,现在差值是 1。绿色这一段是从上一段结尾到这里、还没收走的部分。
- 22差值是 1,不等于 0,说明这一段里 L 和 R 还没配齐,现在收下两边不平衡。所以先不收,把这一位并进当前这段(红框),继续往右扫。
- 23扫到第 9 位,是 "R",差值减一,现在差值是 0。绿色是当前还没收走的这一段。
- 24差值回到 0 了,说明从上一段结尾到第 9 位这一段里,L 和 R 一样多,是个完整的平衡段。收下这一段,段数变成 4 段,这段(变蓝)收走,下一段从第 10 位重新开始数。
- 2510 个字符全扫完,差值正好停在 0,整串被切成 4 段:开头两段各是一个 "LR",中间攒出一个 "LLRR",最后又一个 "LR"。每段 L 和 R 都一样多,段数最多就是 4,答案站得住。
⚠️ 容易写错的地方
✗ 错:以为要枚举所有切法或用回溯、动态规划,担心贪心不是最优
✓ 对:直接贪心:从左扫,差值一归零就切,这样切出的段数最多
每个让差值归零的位置都是合法切点,能早切就早切,剩下的部分仍然平衡,不浪费任何一个可切位置,段数自然最多
✗ 错:另开两个计数器分别数 L 和 R,再时时比较,写得很绕
✓ 对:一个计数器记差值就够,遇 L 加一遇 R 减一
我们只关心 L 和 R 是否相等,也就是差值是否为 0,一个计数器记差值最直接,省一半变量
✗ 错:看到差值变成负数就慌,以为算错了
✓ 对:只判断差值等不等于 0,正负都无所谓
如果串从 R 开头,差值会先变负;但只要某一刻回到 0,就说明这一段 L 和 R 相等,可以切,中间的正负号不影响结论
完整代码(Python / C++ / Java)
Python
from __future__ import annotations
from typing import *
from collections import *
from functools import *
from itertools import *
from math import *
from heapq import *
from bisect import *
class Solution:
def balancedStringSplit(self, s: str) -> int:
ans = l = 0
for c in s:
if c == 'L':
l += 1
else:
l -= 1
if l == 0:
ans += 1
return ansC++
#include <algorithm>
#include <array>
#include <cctype>
#include <climits>
#include <cmath>
#include <deque>
#include <functional>
#include <iostream>
#include <limits>
#include <map>
#include <numeric>
#include <queue>
#include <set>
#include <sstream>
#include <stack>
#include <string>
#include <tuple>
#include <unordered_map>
#include <unordered_set>
#include <utility>
#include <vector>
using namespace std;
class Solution {
public:
int balancedStringSplit(string s) {
int ans = 0, l = 0;
for (char c : s) {
if (c == 'L')
++l;
else
--l;
if (l == 0) ++ans;
}
return ans;
}
};Java
import java.util.*;
class Solution {
public int balancedStringSplit(String s) {
int ans = 0, l = 0;
for (char c : s.toCharArray()) {
if (c == 'L') {
++l;
} else {
--l;
}
if (l == 0) {
++ans;
}
}
return ans;
}
}复杂度
时间
O(n)
n 为字符串长度。从左到右每个字符只看一遍,做一次加减和一次「是否归零」判断,整体是线性扫描
空间
O(1)
自始至终只用了差值计数器和段数计数器两个整数,没有额外的数组或栈,峰值占用是常数
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 分割平衡字符串 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
为什么一见差值归零就剪,段数一定最多?+
每个让差值归零的位置都是合法剪口,那一刻这段里 L 和 R 正好相等。要是碰到归零却不剪、想留着凑更大的段,只会把两个能分开的小平衡块粘成一个大段,段数只会变少。所以能剪就剪,把串拆成一串最短的平衡块,得到的段数最大。
计数器记「L 减 R」和记「R 减 L」有区别吗?+
没有本质区别,两种记法只是中间的正负号相反。我们盯的是它什么时候等于 0,而无论哪种记法,归零的时刻完全一样,剪口也一样,最后段数相同。挑一个顺手的写就行。
时间和空间复杂度是多少?+
时间 O(n),n 是串长,每个字符只扫一遍,做一次加减和一次判断。空间 O(1),全程只用差值和段数两个整数,不开额外的数组或栈。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 分割平衡字符串 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
把这道题真正拿下
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。