LeetCode 1061. 按字典序排列最小的等效字符串
题目描述


题意分析
s1与s2相同位置的两个小写字母互相等效,这种关系还具有传递性。可以把baseStr中每个字母替换成与它等效的任意字母,求能得到的字典序最小字符串。每个位置可以独立替换,不是在原字符串中交换字符,也不受原有字符数量限制。没有参与任何等价关系的字母仍与自身等效,可以保持不变。
解法:并查集合并连通块
核心思路
[!blue]
成对等价关系经过传递后,会把字母分成若干等价类。同一类中的字母都能互相替换,所以只要知道每个字母所属的类,以及这一类中最小的字母,就能决定它的最优替换。
用并查集维护这些类,每个字母最初单独成组。处理一对关系时,先找到两个字母各自的根。如果根不同,就把较大的根连接到较小的根;这样根不仅代表集合,还始终是集合里的最小字母。
这个最小代表性质可以逐次保持:初始单元素集合显然成立;合并时两个旧根分别已经是各自集合最小值,更小的那个自然也是合并后整个集合的最小值。路径压缩只是让中间节点直接指向同一根,不改变这个代表。
全部关系合并完后,对
baseStr每个字符查找最终根,转换回相应字母即可。不能只读取一次parent[x],因为它可能仍是中间父节点,只有完整查根才能得到最新的最小代表。各位置可选字符互不影响,所以每一位都换成所属类最小值会得到全局字典序最小。与任何不同结果比较,在第一处不同的位置,本方案已经选了该位置能取的最小字母,不可能比对方更大。
解题步骤
- 初始化
26个父指针,令每个字母指向自身。- 逐对读取
s1[i]、s2[i],通过find得到它们的根。- 根不同时,把较大根挂到较小根下面;查找过程中使用路径压缩。
- 所有关系处理完后,再逐字符查询
baseStr的最终根,并把根下标转回字母。- 将替换字符按原位置顺序组成答案。
代码实现
class Solution {
public String smallestEquivalentString(String s1, String s2, String baseStr) {
int[] parent = new int[26];
for (int i = 0; i < parent.length; i++) {
parent[i] = i;
}
for (int i = 0; i < s1.length(); i++) {
int root1 = find(parent, s1.charAt(i) - 'a');
int root2 = find(parent, s2.charAt(i) - 'a');
if (root1 != root2) {
// 把较大根挂到较小根下,根始终是组内最小字母。
parent[Math.max(root1, root2)] = Math.min(root1, root2);
}
}
// 全部关系合并完后,再逐个查找最终的最小代表。
StringBuilder answer = new StringBuilder(baseStr.length());
for (int i = 0; i < baseStr.length(); i++) {
int root = find(parent, baseStr.charAt(i) - 'a');
answer.append((char) ('a' + root));
}
return answer.toString();
}
private int find(int[] parent, int x) {
if (parent[x] != x) {
// 路径压缩只缩短路径,不改变集合代表。
parent[x] = find(parent, parent[x]);
}
return parent[x];
}
}
func smallestEquivalentString(s1 string, s2 string, baseStr string) string {
parent := make([]int, 26)
for i := range parent {
parent[i] = i
}
var find func(int) int
find = func(x int) int {
if parent[x] != x {
// 路径压缩只缩短路径,不改变集合代表。
parent[x] = find(parent[x])
}
return parent[x]
}
for i := 0; i < len(s1); i++ {
root1 := find(int(s1[i] - 'a'))
root2 := find(int(s2[i] - 'a'))
// 把较大根挂到较小根下,根始终是组内最小字母。
if root1 < root2 {
parent[root2] = root1
} else if root2 < root1 {
parent[root1] = root2
}
}
// 全部关系合并完后,再逐个查找最终的最小代表。
answer := make([]byte, len(baseStr))
for i := range baseStr {
root := find(int(baseStr[i] - 'a'))
answer[i] = byte(root) + 'a'
}
return string(answer)
}
复杂度分析
- 时间复杂度:$O(n+m)$,n 为关系串长度,m 为 baseStr 长度;并查集固定只有 26 个节点。
- 空间复杂度:$O(m+26)$,固定并查集之外,Java 的 StringBuilder 和 Go 的字节缓冲都占 $O(m)$;即使不计最终返回字符串,也不能忽略这些构造缓冲。
关键点总结
[!green]
- 并查集负责等价关系的传递闭包,较小根规则同时维护每个类的最小字符。
- 合并的是两个集合的根,避免只移动一个中间节点而破坏已经建立的关系。
- 先完成全部合并,再输出最终代表,后面的等价关系也能影响前面出现过的字符。
易错点总结
[!yellow]
- 任意选择根只保证类成员连通,不能保证根字母最小;本写法需要始终让较小根成为代表。
- 直接修改原字符的父节点而不先查根,可能没有真正合并两个完整集合。
- 输出直接父节点而不是调用
find,可能停在尚未压缩的中间代表上。- 忘记初始化
parent[i] = i,默认零会错误地把所有字母都归到a。- 一边读取关系一边把输出结果固定下来,后续合并可能带来更小的等价字母。
- 像字符交换题一样保留原有频次,会额外限制本题允许的独立替换。
相似题目
| 题目 | 难度 | 关联与区别 |
|---|---|---|
| 990. 等式方程的可满足性 | 中等 | 同样先用并查集合并等价字母,本题还把每个分量的最小字母作为替换代表。 |
| 1202. 交换字符串中的元素 | 中等 | 原题只能重排已有位置的字符,需要保留频次,本题可直接把字符换成等价类的最小代表。 |
转载与许可
许可
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处!