字符串的好分割数目 图解题解
这道题到底在问什么
- 输入
- s = "aacaba"
- 输出
- 2(切点 "aac|aba":左右都 2 种字符;切点 "aaca|ba":左右都 2 种字符)
- 输入
- s = "abcd"
- 输出
- 1(只有 "ab|cd" 左右各 2 种)
最优解:一步一步想明白
- 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++ 跟着练。
面试官可能追问
为什么左段用集合、右段用计数表,两边用的结构不一样?+
左段是只增不减的:分界线往右滑,字符只会一个个并进左段,从不离开,所以用集合 vis 记录出现过哪些字符就够,要不同数直接看集合大小。右段是只减不增的:字符一个个被交接走,可能某种字符还剩好几个、得全减完才算从右段消失,所以右段要用计数表记每种字符还剩几个,减到 0 才删行。一边只关心有没有、一边要关心还剩几个,需求不同,结构就不同。
能不能不维护右段,改成预先算好每个位置右边的不同字符数?+
完全可以,这是另一种等价写法。先从右往左扫一遍,记下每个位置右边那段有多少种不同字符,存成一个数组;再从左往右扫,维护左段不同数,在每个切点直接拿左段不同数和预存的右段不同数比较。它和本节的增量写法思路一样、复杂度一样,都是两遍 O(n) 扫描,只是把右段信息提前算好存起来,本质都是前缀后缀各算一边再对比。
这道题属于哪一类套路,以后遇到类似的怎么认?+
它属于「枚举分界点,两侧各维护一个状态」的前后缀套路。特征是:答案要在某个分割点上判断,而分割点两侧的某个量可以随分界线移动而增量更新,不用每次重算。认这类题就看两点:一是有没有一个沿着位置滑动的分界线,二是分界线两侧的统计量能不能只靠加一减一就维护住。能,就用这套左增右减、滑一步比一次的打法。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄声线把 字符串的好分割数目 一步步讲透(全站 0 节语音精讲,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。