按字典序排列最小的等效字符串 图解题解
这道题到底在问什么
- 输入
- s1="abc", s2="cde", baseStr="eed"
- 输出
- "aab" (分组 [a,c,e] 和 [b,d])
- 输入
- s1="hello", s2="world", baseStr="hold"
- 输出
- "hdld" (只有 o 变成 d)
最优解:一步一步想明白
- 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 更快,代码里顺手加上没坏处;但按秩合并这里反而不能用,因为我们必须按字母大小选根来保证字典序最小,不能按树高选根。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄声线把 按字典序排列最小的等效字符串 一步步讲透(全站 0 节语音精讲,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。