字符串的好分割数目 图解题解
这道题到底在问什么
- 输入
- s = "aacaba"
- 输出
- 2(切点 "aac|aba":左右都 2 种字符;切点 "aaca|ba":左右都 2 种字符)
- 输入
- s = "abcd"
- 输出
- 1(只有 "ab|cd" 左右各 2 种)
最优解:为什么这么做
一句话答案:LeetCode 1525 字符串的好分割数目:一遍扫描枚举切点,增量维护左段集合与右段计数表,省去每个切点重数两段;两边不同字符数相等即好分割。时间 O(n)、空间 O(1)。
好分割到底要左右两段满足什么
给一个只含小写字母的字符串 s,在某处切成非空的左段 p 和右段 q。若 p 的不同字符种数恰好等于 q 的不同字符种数,这个切点就是「好分割」,返回好分割的总数。以 s =「aacaba」为例:「aac」和「aba」左右各 2 种,是好分割;「aaca」和「ba」也各 2 种;其余切点两边不等。答案是 2。
每个切点都把两段重新数,慢在哪
长度 n 的串有 n-1 个可切位置。对每个切点把左右两段各数一遍不同字符,单个切点要扫近 n 个字符,合起来是 O(n²)(大 O 记号,描述数据变大时操作数怎么涨)。相邻切点其实只差一个字符的交接,这么数大半是重复劳动。
左段只增右段只减,两边不同数怎么一次扫出来
相邻切点只挪一个字符,与其每次重数,不如让两段的不同数跟着分界线增量维护(顺手改一下、不推倒重来)。左段用集合 vis(自动去重,同一字符放几次只占一格)装见过的字符,大小就是左段不同数,只增不减。右段用计数表 cnt(哈希表,记每个字符在右段还剩几个),先存整串计数当起点,每交接走一个字符就把它那格减一,某格减到 0 就删这一行,cnt 的行数就是右段不同数。
右段不同数为什么只在某个字符清零时才降
最容易数错的是右段不同数何时降。右段计数减一,并不等于右段不同数减一:a 有 4 个,交接走一个后它从 4 减到 3,可 a 后面还有,这行不删、右段不同数不动。只有某字符减到 0、在右段没了,才删那行、不同数降一格。左段同理,集合只在遇到全新字符时才涨。每滑一步比 vis 大小和 cnt 行数,相等就攒一个好分割。
拿 aacaba 把每个切点的两边不同数逐个走一遍
起点:cnt 装整串,a 4 个、c 和 b 各 1 个共 3 种,vis 空 0 种。分界线从左往右挪,逐个切点算。交接第 1 个 a:vis 成 {a} 左 1 种,a 从 4 减到 3 没清零、右仍 3 种,1 ≠ 3。交接第 2 个 a:左仍 1 种,a 从 3 减到 2、右仍 3 种,1 ≠ 3。交接 c:vis 成 {a, c} 左 2 种,c 从 1 减到 0 清零删行、右剩 a、b 共 2 种,2 = 2 记 1。交接第 3 个 a:左仍 2 种,a 从 2 减到 1 没清零、右仍 2 种,2 = 2 记 2。交接 b:vis 成 {a, c, b} 左 3 种,b 从 1 减到 0 清零删行、右剩 1 种,3 ≠ 1。交接末个 a:a 从 1 减到 0 清零删行、右 0 种,3 ≠ 0。全程只有第三、第四个切点相等,答案 2,和题面对上。
扫到末尾右段空成 0 种,为什么不会被误记成好分割
最后交接完整串那步:左段整串 3 种、右段掏空 0 种,不相等,不会误算。它本就不是合法切点——右段必须非空,空右段 0 种永远配不上至少 1 种的非空左段,不用特判,扫描自己就跳过。复杂度上,建计数表与滑分界线各扫一遍,每字符只做常数功,合计 O(n);vis 和 cnt 各最多 26 项,与串长无关,空间 O(1)。另一个边界:全相同字符的串除末格外每个切点左右都 1 种、都相等,全是好分割。
▶ 动画逐步走查(共 22 步)——想跟着动画一帧帧对照就展开
- 3记牢一句话:左段的集合往大长,右段的计数往小缩,两边不同数一相等就是一个好分割。下面每一帧都在套这个思路。
- 4开滑之前先看舞台。上面这排是字符串 "aacaba" 的每个字符。右边面板是右段计数 cnt,现在分界线还在最左边,左段是空的、右段就是整串,所以 cnt 里记着整串的字符:a 有 4 个、c 有 1 个、b 有 1 个,一共 3 种不同字符,这就是右段当前的不同数。左段集合 vis 现在空空如也,不同数是 0。
- 5说清楚滑动规则。分界线会从最左边一格一格往右挪。每当它滑过一个字符,这个字符就从右段交接到左段:一方面把它加进左段集合 vis,另一方面把右段计数 cnt 里对应的那一格减一。减到 0 就代表这个字符在右段彻底没了,要把那一行删掉。挪一步、比一次两边的不同数,相等就攒一个好分割。下面正式开滑。
- 6分界线滑到下标 0,把字符 "a" 交接给左段。左段集合 vis 之前没有 "a",这一加就多了一种字符,左段不同数变成 1,现在左段是 {a}。
- 7同一个字符 "a" 从右段走掉了一个,所以把右段计数里它那一格减一,从 4 变成 3。还没减到 0,"a" 在右段后面还会出现,所以这一行保留,右段不同数仍是 3。右段不同数只在某字符彻底清零时才会降。
- 8左段是 "a",右段是 "acaba"。左段不同数 1,右段不同数 3。两边不相等,这个切点不算好分割,ans 保持 0,继续往右滑。
- 9分界线滑到下标 1,把字符 "a" 交接给左段。左段集合 vis 早就有 "a" 了,再加也是同一个,所以集合不变,左段不同数还是 1,仍是 {a}。这一步告诉你,左段不同数只在遇到全新字符时才会涨。
- 10同一个字符 "a" 从右段走掉了一个,所以把右段计数里它那一格减一,从 3 变成 2。还没减到 0,"a" 在右段后面还会出现,所以这一行保留,右段不同数仍是 3。右段不同数只在某字符彻底清零时才会降。
- 11左段是 "aa",右段是 "caba"。左段不同数 1,右段不同数 3。两边不相等,这个切点不算好分割,ans 保持 0,继续往右滑。
- 12分界线滑到下标 2,把字符 "c" 交接给左段。左段集合 vis 之前没有 "c",这一加就多了一种字符,左段不同数变成 2,现在左段是 {a, c}。
- 13同一个字符 "c" 从右段走掉了一个,所以把右段计数里它那一格减一,从 1 变成 0。减到 0 了,说明 "c" 在右段一个都不剩,要把这一行从表里删掉。表里少一行,右段不同数就降到 2。
- 14左段是 "aac",右段是 "aba"。左段不同数 2,右段不同数 2。两边正好相等,这个切点是好分割,ans 加一变成 1。屏幕上绿色高亮的就是这次切出的左段。
- 15分界线滑到下标 3,把字符 "a" 交接给左段。左段集合 vis 早就有 "a" 了,再加也是同一个,所以集合不变,左段不同数还是 2,仍是 {a, c}。这一步告诉你,左段不同数只在遇到全新字符时才会涨。
- 16同一个字符 "a" 从右段走掉了一个,所以把右段计数里它那一格减一,从 2 变成 1。还没减到 0,"a" 在右段后面还会出现,所以这一行保留,右段不同数仍是 2。右段不同数只在某字符彻底清零时才会降。
- 17左段是 "aaca",右段是 "ba"。左段不同数 2,右段不同数 2。两边正好相等,这个切点是好分割,ans 加一变成 2。屏幕上绿色高亮的就是这次切出的左段。
- 18分界线滑到下标 4,把字符 "b" 交接给左段。左段集合 vis 之前没有 "b",这一加就多了一种字符,左段不同数变成 3,现在左段是 {a, c, b}。
- 19同一个字符 "b" 从右段走掉了一个,所以把右段计数里它那一格减一,从 1 变成 0。减到 0 了,说明 "b" 在右段一个都不剩,要把这一行从表里删掉。表里少一行,右段不同数就降到 1。
- 20左段是 "aacab",右段是 "a"。左段不同数 3,右段不同数 1。两边不相等,这个切点不算好分割,ans 保持 2,继续往右滑。
- 21分界线滑到下标 5,把字符 "a" 交接给左段。左段集合 vis 早就有 "a" 了,再加也是同一个,所以集合不变,左段不同数还是 3,仍是 {a, c, b}。这一步告诉你,左段不同数只在遇到全新字符时才会涨。
- 22同一个字符 "a" 从右段走掉了一个,所以把右段计数里它那一格减一,从 1 变成 0。减到 0 了,说明 "a" 在右段一个都不剩,要把这一行从表里删掉。表里少一行,右段不同数就降到 0。
- 23左段是 "aacaba",右段是 "空"。左段不同数 3,右段不同数 0。分界线已经滑到末尾,右段是空的、不同数 0,这不是一个真正的切点,自然不相等,ans 保持 2。这一帧顺便说明:右段为空时永远配不上非空的左段,所以最后这一格不会误判成好分割。
- 24分界线滑到了最右边,右段计数全部清空,所有字符都交接给了左段。回放一下:整条路上只有两个切点让左右两段的不同数相等,分别是 "aac | aba" 和 "aaca | ba",这两个切点左右两段都各有 2 种不同字符。所以好分割一共 2 个。全程只把字符串扫了一遍,每一步只做并入、减一、比较三个常数操作。
⚠️ 容易写错的地方
✗ 错:每个切点都把左右两段重新数一遍不同字符
✓ 对:增量维护:左段集合只加,右段计数只减,各自的不同数顺手就有了
有效切点有 O(n) 个、每个再扫两段数一遍是 O(n 的平方),数据一大就慢;增量维护把它降到只扫一遍 O(n)
✗ 错:右段某字符减一后没减到 0 也去删行,或减到 0 了却忘了删
✓ 对:只在计数恰好减到 0 时删行,代表该字符在右段彻底没了
右段不同数是用「计数表还剩几行」来代表的,行多删会少算、该删不删会多算,两边比较就全错
✗ 错:担心最后一个位置右段为空会误判成好分割,特地加判断
✓ 对:不用特判:右段为空时不同数是 0,非空左段不同数至少 1,永远不相等
末尾这个位置右段空、左段含整串所有字符,它不是真正的切点、数目天然对不上,算法自动跳过,加特判反而多余
完整代码(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 numSplits(self, s: str) -> int:
cnt = Counter(s)
vis = set()
ans = 0
for c in s:
vis.add(c)
cnt[c] -= 1
if cnt[c] == 0:
cnt.pop(c)
ans += len(vis) == len(cnt)
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 numSplits(string s) {
unordered_map<char, int> cnt;
for (char& c : s) {
++cnt[c];
}
unordered_set<char> vis;
int ans = 0;
for (char& c : s) {
vis.insert(c);
if (--cnt[c] == 0) {
cnt.erase(c);
}
ans += vis.size() == cnt.size();
}
return ans;
}
};Java
import java.util.*;
class Solution {
public int numSplits(String s) {
Map<Character, Integer> cnt = new HashMap<>();
for (char c : s.toCharArray()) {
cnt.merge(c, 1, Integer::sum);
}
Set<Character> vis = new HashSet<>();
int ans = 0;
for (char c : s.toCharArray()) {
vis.add(c);
if (cnt.merge(c, -1, Integer::sum) == 0) {
cnt.remove(c);
}
if (vis.size() == cnt.size()) {
++ans;
}
}
return ans;
}
}复杂度
时间
O(n)
n 是字符串长度。先扫一遍建计数,再扫一遍滑分界线,每个字符做的事是集合加一次、计数减一次、比一次大小,都是常数时间,合计 O(n)
空间
O(1)
左段集合 vis 和右段计数 cnt 装的都是小写字母,最多各 26 项,是与 n 无关的常数,所以额外空间峰值是 O(1)(也可记作 O(字符集大小))
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 字符串的好分割数目 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
为什么右段要先把整串计数好、再逐个减,而不是每个切点直接数一遍右段?+
直接数的话,每个切点都要把右段那截字符全扫一遍,n-1 个切点合起来是 O(n²)。先给整串建一张计数表 cnt 当右段起点,之后分界线每挪一格只把交接走的那个字符减一、必要时删一行,单步是常数功。这样右段不同数跟着分界线增量更新,一遍扫描 O(n) 就够,省掉了重复数数。左段用集合只增也是同一个道理。
空间复杂度为什么算 O(1),明明用了集合和计数表?+
关键是这两个容器的大小有天花板。字符串只含小写字母,总共 26 种,左段集合 vis 最多 26 个元素,右段计数表 cnt 最多 26 行,不管字符串是 6 位还是 10 万位,它们都涨不过 26。占用的额外空间是一个和 n 无关的常数,所以记作 O(1),也可写成 O(字符集大小)。
这类枚举切点、两边各维护一个状态的题,还能怎么变形?+
它属于「枚举分界点、左右两侧各维护一份前后缀信息」的套路(前缀指从开头起的一段、后缀指到结尾的一段):左段从左往右积累、右段从整串扣减(相当于右后缀),遍历每个切点时两份信息都是现成的。本题两侧维护的是不同字符数;换成两侧的和、最大值或别的计数,就变出一批题。有的写法会把左前缀信息和右后缀信息分别预处理成两个数组再遍历比较,和这里一遍扫描增量维护是等价的,只是把空间换成了更直白的两趟。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 字符串的好分割数目 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
把这道题真正拿下
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。