按字典序排列最小的等效字符串 图解题解
这道题到底在问什么
- 输入
- s1="abc", s2="cde", baseStr="eed"
- 输出
- "aab" (分组 [a,c,e] 和 [b,d])
- 输入
- s1="hello", s2="world", baseStr="hold"
- 输出
- "hdld" (只有 o 变成 d)
最优解:为什么这么做
一句话答案:LeetCode 1061 按字典序最小的等效字符串:把等价字母用并查集连成组、合并时让小字母当根,baseStr 每个字母换成所在组的根,天然得到字典序最小的串,时间 O((n+m)·α)。
等价字母归成类,baseStr 换成同类里最小的谁
给两个等长字符串 s1 和 s2,位置对齐看,s1[i] 和 s2[i] 是一组等价字母,等价会传递:a 跟 b、b 跟 c 同组,a 跟 c 也同组。把能连起来的字母归成一类,再把 baseStr 每个字母换成同类里最小的那个,返回换完的串。题面例子 s1="abc"、s2="cde"、baseStr="eed",连出 {a,c,e} 和 {b,d} 两类,eed 换成 aab。
挨个把字母连上线,为什么还是理不清谁跟谁
字母两两配对不难,难在它们连成的关系网:c 这步跟 a 连、下步又跟 e 连,只攥着一串线,很难随时答出 e 该换成谁。尤其传递:第 2 列把 e 拉进来时,得先知道 e 早跟 a、c 是一家,这层关系埋在前几步里,顺着线爬才理得清。
并查集把同一类字母收到一个根底下
这种反复把元素两两归组、又要随时查两个在不在同一组的场景,正对上并查集,一种专管分组的结构。它给每个元素记一个父亲,顺着父亲走到头,指向自己的那个就是根,同组成员最后都落在同一个根上。
这题只有 26 个字母,开一个长 26 的父亲数组,起初每个字母的父亲都是自己。合并有个死约定:两字母并组时先各自找到根,让大的根挂到小的根下,永远小的当根。参考代码里比较两个根 px 和 py,px<py 就让 p[py] 指向 px。爬到顶的根恰好是这组最小字母,替换时直接换成根。
先 find 到根,再连根,永远小的坐根
分两趟。建组这趟把 s1、s2 逐列对齐,每列两字母合并一次:各自 find 到根,比字母大小让大的挂到小的下面。要点是合并两个根、不是原字母:某字母可能早并进别的组,先 find 到根才知道它归谁。替换这趟扫 baseStr,每个字母 find 到根,把根写进答案拼起来即结果。
拿 abc、cde、eed 把两趟完整推一遍
先建组。第 0 列 a 配 c,a 小让 c 指向 a,得 {a,c}、根 a。第 1 列 b 配 d,b 小让 d 指向 b,得 {b,d}、根 b。第 2 列 c 配 e:c 的父亲已是 a,find 上去根是 a、不是 c,e 的根还是 e,真正要连的是 a 和 e;a 小让 e 指向 a,c、e 都挂到 a 底下,第一组 {a,c,e}、根 a,另一组 {b,d}、根 b。
再换 baseStr="eed"。第 0 位 e,find 到根 a,写 a;第 1 位还是 e,根仍是 a,写 a;第 2 位 d,find 到根 b,写 b。拼成 "aab"。每组根都是组里最小字母,换出来的串每位都最小,字典序自然最小。
26 个字母撑起的常数空间,选根找根都别偷懒
设 s1 长 n、baseStr 长 m:建组 n 次合并、替换 m 次查询,find 带路径压缩后每次近似常数,合起来 O((n+m)·α),α 近似常数;父亲数组固定 26 格,空间 O(1)。选根松不得:随手挑或按树高选根,字母虽连通根却未必最小,字典序最小就丢了,只能认字母大小让小的坐根。找根也别省:合并替换都得 find 到最终的根,只看一层父亲会停在中间字母上,写进答案的就不是最小字母。
▶ 动画逐步走查(共 22 步)——想跟着动画一帧帧对照就展开
- 3记住这个约定:合并两类时,大字母指向小字母,小的当根。下面每一帧都在套它。
- 4等价类个数 = 5先把例子里出现的 5 个字母 a、b、c、d、e 摆出来。一开始谁也不连谁,每个字母自己是一类,所以等价类个数是 5。接下来按 s1、s2 一列一列地合并。
- 5s1="abc" s2="cde"s1 和 s2 对齐看:第 0 列 a 配 c、第 1 列 b 配 d、第 2 列 c 配 e。也就是要把 a 和 c、b 和 d、c 和 e 分别连起来。一列一列处理。
- 6准备合并 a 和 c看第 0 列:s1 的 a 和 s2 的 c 等价。先紫色点亮这两个字母,准备把它们并到一类。
- 7代表 a 与 c合并前先各自找代表元(也就是树根)。现在 a 的根是 a、c 的根是 c,两个都还是自己,谁也没被合并过。
- 8等价类个数 = 4a 比 c 小,按约定小的当根,所以让 c 指向 a。现在 a 和 c 同一类、根是 a。等价类个数从 5 降到 4。
- 9准备合并 b 和 d看第 1 列:s1 的 b 和 s2 的 d 等价。点亮 b 和 d,准备合并。
- 10代表 b 与 db 的根是 b、d 的根是 d,都还独立。它们和左边那组没关系,是另一棵树。
- 11等价类个数 = 3b 比 d 小,让 d 指向 b,根是 b。现在有两组:{a,c} 和 {b,d}。等价类个数降到 3。
- 12准备合并 c 和 e看第 2 列:s1 的 c 和 s2 的 e 等价。注意 c 之前已经和 a 一类了,这一步会把 e 也拉进来,传递性就体现在这。
- 13代表 a 与 e关键一步:找 c 的根,c 指向 a,所以 c 的根是 a,不是 c。e 还是自己。所以真正要合并的是代表 a 和代表 e,而不是 c 和 e。
- 14等价类个数 = 2a 比 e 小,让 e 指向 a。现在 e、c 都挂在 a 下面,a 这一类成了 {a,c,e}。等价类个数降到 2。
- 15a 是这类最小字母回看这棵树:c 指 a、e 指 a,a 自己是根。a 是 {a,c,e} 里字典序最小的,正因为每次合并都让小字母当根,根天然就是这一类的最小值。
- 16{a,c,e}→a {b,d}→b三列处理完,字母分成两类:{a,c,e} 都映射到 a,{b,d} 都映射到 b。这张映射表就是替换 baseStr 的依据。
- 17逐字查根并替换并查集建好了,现在处理 baseStr="eed"。逐个字母查它的根,把根写下来,拼成答案。
- 18找 e 的根第 0 个字母是 e。去并查集里查 e 的根:e 指向 a,a 是根。
- 19已拼出 "a"e 的根是 a,所以第 0 位写 a。目前答案是 "a"。
- 20又是 e第 1 个字母还是 e,根同样是 a,不用重新推。
- 21已拼出 "aa"第 1 位也写 a。答案变成 "aa"。
- 22找 d 的根第 2 个字母是 d。查 d 的根:d 指向 b,b 是根。它属于另一组 {b,d}。
- 23已拼出 "aab"d 的根是 b,所以第 2 位写 b。答案现在是 "aab"。
- 24eed → aab三个字母都替换完:e→a、e→a、d→b,baseStr "eed" 变成 "aab"。因为每一类的根都是它的最小字母,这样换出来的串字典序一定最小。
⚠️ 容易写错的地方
✗ 错:合并时随便选根 / 按大小或秩选根
✓ 对:永远让字典序小的字母当根
按秩或随意选根虽然能连通,但根不一定是最小字母,会丢掉字典序最小这个要求
✗ 错:合并时直接连两个原字母
✓ 对:先各自 find 到根,再连两个根
c 可能早已属于 a 那棵树,直接连 c 和 e 会接错位置、破坏已建好的结构
✗ 错:替换时只看一层父指针
✓ 对:一路 find 到根再替换
父指针可能是中间节点,必须找到最终的根才是这类的最小字母
完整代码(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 TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
class Solution:
def smallestEquivalentString(self, s1: str, s2: str, baseStr: str) -> str:
def find(x: int) -> int:
if p[x] != x:
p[x] = find(p[x])
return p[x]
p = list(range(26))
for a, b in zip(s1, s2):
x, y = ord(a) - ord("a"), ord(b) - ord("a")
px, py = find(x), find(y)
if px < py:
p[py] = px
else:
p[px] = py
return "".join(chr(find(ord(c) - ord("a")) + ord("a")) for c in baseStr)C++
#include <algorithm>
#include <array>
#include <cctype>
#include <climits>
#include <cmath>
#include <deque>
#include <functional>
#include <iostream>
#include <limits>
#include <map>
#include <numeric>
#include <optional>
#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;
struct TreeNode {
int val;
TreeNode *left;
TreeNode *right;
TreeNode() : val(0), left(nullptr), right(nullptr) {}
TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
};
struct ListNode {
int val;
ListNode *next;
ListNode() : val(0), next(nullptr) {}
ListNode(int x) : val(x), next(nullptr) {}
ListNode(int x, ListNode *next) : val(x), next(next) {}
};
class Solution {
public:
string smallestEquivalentString(string s1, string s2, string baseStr) {
vector<int> p(26);
iota(p.begin(), p.end(), 0);
function<int(int)> find = [&]( int x ) -> int {
if (p[x] != x) {
p[x] = find(p[x]);
}
return p[x];
};
for (int i = 0; i < s1.length(); ++i) {
int x = s1[i] - 'a';
int y = s2[i] - 'a';
int px = find(x), py = find(y);
if (px < py) {
p[py] = px;
} else {
p[px] = py;
}
}
string s;
for (char c : baseStr) {
s.push_back('a' + find(c - 'a'));
}
return s;
}
};Java
import java.util.*;
class TreeNode { int val; TreeNode left; TreeNode right; TreeNode(){} TreeNode(int val){this.val=val;} TreeNode(int val, TreeNode left, TreeNode right){this.val=val;this.left=left;this.right=right;} }
class ListNode { int val; ListNode next; ListNode(){} ListNode(int val){this.val=val;} ListNode(int val, ListNode next){this.val=val;this.next=next;} }
class Solution {
private final int[] p = new int[26];
public String smallestEquivalentString(String s1, String s2, String baseStr) {
for (int i = 0; i < p.length; ++i) {
p[i] = i;
}
for (int i = 0; i < s1.length(); ++i) {
int x = s1.charAt(i) - 'a';
int y = s2.charAt(i) - 'a';
int px = find(x), py = find(y);
if (px < py) {
p[py] = px;
} else {
p[px] = py;
}
}
char[] s = baseStr.toCharArray();
for (int i = 0; i < s.length; ++i) {
s[i] = (char) ('a' + find(s[i] - 'a'));
}
return String.valueOf(s);
}
private int find(int x) {
if (p[x] != x) {
p[x] = find(p[x]);
}
return p[x];
}
}复杂度
时间
O((n+m)·α)
n 是 s1 长度做 n 次合并、m 是 baseStr 长度做 m 次查询,α 是并查集近似常数的反阿克曼
空间
O(1)
parent 数组固定 26 个字母,与输入规模无关
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 按字典序排列最小的等效字符串 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
不用并查集,换别的方法能做吗?+
能。把每一组等价关系看成无向图的一条边,再用 DFS 或 BFS 找出每个连通分量,也就是彼此连通的一簇字母,取分量里最小的字母做映射,思路和并查集完全一样。只是并查集的合并加找根写起来最短,而且『让小字母当根』天生贴合『取组里最小字母』这个要求,所以是这题的首选写法。
需要加路径压缩或按秩合并这类优化吗?+
字母只有 26 个,规模极小,不优化也快得没感觉。路径压缩让 find 顺手更快,加不加都无所谓;但按秩合并这里恰恰用不得,它按子树高矮选根,而我们必须按字母大小选根才能保证字典序最小,两条规则会打架。所以这题只压缩、不按秩。
合并那步到底让谁指向谁,为什么要盯着根比?+
先对两个待合并的字母各做一次 find 拿到根 px 和 py,再比字母大小:px<py 就让 p[py] 指向 px、把大的挂到小的下面,否则反过来。之所以比的是根、而不是原来的两个字母,是因为其中一个字母可能早被并进别的组,它现在真正的代表是根;若直接连两个原字母,会把已经建好的组接到错位置上。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 按字典序排列最小的等效字符串 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。