特殊等价字符串组 图解题解
这道题到底在问什么
- 输入
- words=["abcd","cdab","cbad","xyzz","zzxy","zzyx"]
- 输出
- 3
- 输入
- words=["abc","acb","bac","bca","cab","cba"]
- 输出
- 3
最优解:为什么这么做
一句话答案:LeetCode 893 特殊等价字符串组:只能交换同奇偶下标的字符,于是给每个串按偶数位排序加奇数位排序拼出签名,扔进集合去重,组数就是集合大小,时间 O(n·L log L)。
只能同奇偶换字符,求分成几组
给一个字符串数组 words,每个串等长。允许的操作只有两种:把某个串里两个偶数下标处的字符对调,或两个奇数下标处的对调。两个串若能靠这些操作变得完全一样,就叫特殊等价。按这层关系分组,返回组数。题面例子 words=["abcd","cdab","cbad","xyzz","zzxy","zzyx"] 的答案是 3。
整串排序当签名,会把两组并成一组
判两串等不等价,很容易想到把整串字符排序,排完一样就归一组,这会把不该并的串并到一起。拿 ab 和 ba:整串排序都得 ab,看着一组;可 a 在偶数下标、b 在奇数下标,把 ab 挪成 ba 要让偶数位 a 和奇数位 b 对调,属跨奇偶交换,题目不许,它们其实是两组。整串排序默认任意两字符都能换,比题目宽,才会误判。
偶数位排序加奇数位排序,就是签名
操作只发生在同奇偶位之间,所以不管怎么换,一个串偶数下标上那堆字符的构成永远不变,奇数下标上那堆也不变。这里的构成指多重集,也就是只数每种字母各出现几次、不看排列顺序。于是两串特殊等价,当且仅当偶数位、奇数位的字符多重集分别相同。
要给每个串造个能比对的代表,就把偶数位字符单独排序、奇数位字符单独排序,偶在前奇在后接成一个字符串,这就是签名,也就是把所有互相等价的串映成同一个字符串、方便去重。签名相同即特殊等价,不同即不等价。
每串现算签名,扔进集合数一数
遍历每个串,按下标奇偶拆成两组。下标从 0 起数,0 是偶数位,于是下标 0、2、4… 归偶数位那组,1、3、5… 归奇数位那组。两组各自排序,偶在前奇在后拼成签名,塞进哈希集合。所有串走完,集合自动合掉重复签名,剩几个不同签名就是几组,返回集合大小。
六个串逐个算签名,落到 3 组
拿题面的 words=["abcd","cdab","cbad","xyzz","zzxy","zzyx"] 手算。abcd 偶数位 a、c 排成 ac,奇数位 b、d 排成 bd,签名 acbd。cdab 偶数位 c、a 也排成 ac,奇数位 d、b 也排成 bd,签名同为 acbd。cbad 同理得 acbd,前三个并成一组。
接着 xyzz 偶数位 x、z 排成 xz,奇数位 y、z 排成 yz,签名 xzyz;zzxy 偶数位 z、x 得 xz,奇数位 z、y 得 yz,签名同样 xzyz,和 xyzz 并一组。最后 zzyx 偶数位 z、y 排成 yz,奇数位 z、x 排成 xz,签名 yzxz,跟前面都不同,自己一组。集合留下 acbd、xzyz、yzxz 三个,答案 3。
L log L 排序 n 次,ab 和 ba 为何分家
设 n 个串、每串长 L。每串把两半排序,代价 L·log L,合起来 O(n·L log L);集合最多存 n 个签名、每个长 L,空间 O(n·L)。
两个本来相同的串签名一致,并进同一组。ab 和 ba 字母虽一样,a、b 分处偶奇两位、跨位换不动,签名一个 ab 一个 ba,判成两组,整串排序正会栽在这。长度不同的两串更省事,操作改不了长度,签名长度也不同,必然分开,无需特判。
▶ 动画逐步走查(共 23 步)——想跟着动画一帧帧对照就展开
- 3记住这把钥匙:签名 = 排序后的偶数位 加 排序后的奇数位。下面每个串都现算它的签名,再看集合里有没有见过。
- 4先把 4 个串摆出来。我们要逐个算签名、放进集合。集合现在是空的,0 组。
- 5处理「abcd」。先看偶数下标 0 和 2,绿色这两个字符是 a 和 c,奇数位先灰着不动。
- 6把偶数位的 a、c 排个序,得到 ac。这就是签名的前半截,顺序固定下来了。
- 7再看奇数下标 1 和 3,绿色换到这两个字符 b 和 d,偶数位这回灰掉。
- 8奇数位的 b、d 排序得 bd。前半 ac 接上后半 bd,「abcd」的签名就是 ac·bd。
- 9拿 ac·bd 去集合里查,没见过,于是加进去,组数变成 1。
- 10处理「cdab」。先看偶数下标 0 和 2,绿色这两个字符是 c 和 a,奇数位先灰着不动。
- 11把偶数位的 c、a 排个序,得到 ac。这就是签名的前半截,顺序固定下来了。
- 12再看奇数下标 1 和 3,绿色换到这两个字符 d 和 b,偶数位这回灰掉。
- 13奇数位的 d、b 排序得 bd。前半 ac 接上后半 bd,「cdab」的签名就是 ac·bd。
- 14拿 ac·bd 去集合里查,发现第 1 组里已有同款签名,「cdab」和它们特殊等价,并进去,组数不变还是 1。
- 15处理「xyzz」。先看偶数下标 0 和 2,绿色这两个字符是 x 和 z,奇数位先灰着不动。
- 16把偶数位的 x、z 排个序,得到 xz。这就是签名的前半截,顺序固定下来了。
- 17再看奇数下标 1 和 3,绿色换到这两个字符 y 和 z,偶数位这回灰掉。
- 18奇数位的 y、z 排序得 yz。前半 xz 接上后半 yz,「xyzz」的签名就是 xz·yz。
- 19拿 xz·yz 去集合里查,没见过,于是加进去,组数变成 2。
- 20处理「zzyx」。先看偶数下标 0 和 2,绿色这两个字符是 z 和 y,奇数位先灰着不动。
- 21把偶数位的 z、y 排个序,得到 yz。这就是签名的前半截,顺序固定下来了。
- 22再看奇数下标 1 和 3,绿色换到这两个字符 z 和 x,偶数位这回灰掉。
- 23奇数位的 z、x 排序得 xz。前半 yz 接上后半 xz,「zzyx」的签名就是 yz·xz。
- 24拿 yz·xz 去集合里查,没见过,于是加进去,组数变成 3。
- 254 个串算完,集合里留下 3 个不同签名。abcd 和 cdab 撞同一个签名归一组,xyzz 和 zzyx 各自单独一组(注意 xyzz 与 zzyx 并不等价),最终 3 组。
⚠️ 容易写错的地方
✗ 错:把整个串排序当签名
✓ 对:偶数位、奇数位分别排序再拼
跨奇偶位不能交换,整串排序会把本不等价的串错判成一组
✗ 错:偶数位和奇数位拼接顺序在不同串间不一致
✓ 对:所有串统一「偶在前奇在后」或统一「奇在前偶在后」
顺序规则必须全程一致,签名才有可比性
✗ 错:用下标从 1 开始数奇偶
✓ 对:题目按 0 起始下标,下标 0 是偶数位
错位会把偶数位当奇数位,签名全错
完整代码(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 numSpecialEquivGroups(self, words: List[str]) -> int:
s = {''.join(sorted(word[::2]) + sorted(word[1::2])) for word in words}
return len(s)C++
#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 numSpecialEquivGroups(vector<string>& words) {
unordered_set<string> s;
for (auto& word : words) {
string a = "", b = "";
for (int i = 0; i < word.size(); ++i) {
if (i & 1)
a += word[i];
else
b += word[i];
}
sort(a.begin(), a.end());
sort(b.begin(), b.end());
s.insert(a + b);
}
return s.size();
}
};Java
import java.util.*;
class Solution {
public int numSpecialEquivGroups(String[] words) {
Set<String> s = new HashSet<>();
for (String word : words) {
s.add(convert(word));
}
return s.size();
}
private String convert(String word) {
List<Character> a = new ArrayList<>();
List<Character> b = new ArrayList<>();
for (int i = 0; i < word.length(); ++i) {
char ch = word.charAt(i);
if (i % 2 == 0) {
a.add(ch);
} else {
b.add(ch);
}
}
Collections.sort(a);
Collections.sort(b);
StringBuilder sb = new StringBuilder();
for (char c : a) {
sb.append(c);
}
for (char c : b) {
sb.append(c);
}
return sb.toString();
}
}复杂度
时间
O(n·L·log L)
n 个串,每串长 L,各排序一次
空间
O(n·L)
集合里最多存 n 个签名,每个长 L
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 特殊等价字符串组 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
不排序,改用计数当签名行不行?+
行,而且更快。给偶数位维护一个 26 个字母的计数、奇数位再维护一个,把两个计数拼成定长字符串当签名。这样省掉排序,单个串的处理从 L·log L 降到 O(L),串一长就更划算。排序版胜在一行切片就写完、好记,面试里两种都能说。
题目保证所有串等长,万一不等长会出问题吗?+
不会。长度不同的两个串绝不可能特殊等价,因为对调操作从不改变串长。签名里天然带着各奇偶位的字符个数,长度不同签名的长度就不同,会被分到不同组,逻辑照样成立,不需要为不等长写特判。
为什么偶数位和奇数位一定要分开排,不能合在一起?+
因为题目只准同奇偶位互换,偶数位的字符永远到不了奇数位、反之亦然。合在一起排等于假设它们能互相流动,就把 ab 和 ba 这种其实不等价的串错判成一组。分开排,才让签名精确对应『偶数位多重集相同且奇数位多重集相同』这个真正的等价条件。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 特殊等价字符串组 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
把这道题真正拿下
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。