LeetCode 面试题 17.07. 婴儿名字
题目描述
题意分析
输入是两份数据:
names里每个元素形如John(15),把一个名字和它的登记次数打包成字符串;synonyms里每个元素形如(Jon,John),声明这两个写法指向同一个真实名字。要输出的是「合并后的真实名字表」:属于同一个真实名字的所有写法,次数要加在一起,并且只用其中字典序最小的那个写法作为对外的代表。
关键约束信号有两个。其一,同名关系会传递:
Jon等于John、John等于Johnny,那么Jon和Johnny也必须算作同一个人,题目并不会把传递后的关系再显式列一遍。其二,字符串总长可以到十万级,说明每对同名关系只允许被处理常数次,不能反复扫描synonyms做闭包。边界要提前想清楚。
synonyms里出现的名字未必在names里登记过,这种名字次数为零,即使它字典序更小也不能凭空造出一条输出;反过来,names里的名字也可能一条同名关系都没有,它自己就是一组。输出的顺序题目不作要求,但每组的代表名字和求和结果必须唯一确定。
解法:并查集合并同义词
核心思路
先想最直接的做法:对每个名字反复扫一遍
synonyms,把能连上的名字并进当前集合,直到集合不再变大,再从集合里挑字典序最小的名字输出。这等于手工求传递闭包,一个名字最坏要扫 $m$ 轮,总代价退化到 $O(nm)$,而且写起来还得小心重复访问。瓶颈在于「等价关系」被当成了一次性的查询反复重算。但同名关系天然满足自反、对称、传递三条性质,这正是一个等价关系,而维护等价关系的划分只需要一个并查集:每来一对同义词就做一次合并,查询时一次
find就能问出所属的组。剩下的问题是代表元怎么选。并查集默认按秩或按大小合并,根是谁完全取决于合并顺序,靠不住。这里改成「按字典序合并」,并维持一条不变量:任意时刻,每个连通分量的根都是该分量内所有名字中字典序最小的那个。合并两棵树时比较的必须是两个根而不是两个原始名字,把字典序大的根挂到小的根下面;由于两个根分别是各自分量的最小值,合并后的最小值必然是二者中较小的那个,不变量得以保持。
有了这条不变量,最后只需遍历频次表,把每个名字的次数加到
find(name)上,累加结果自然就落在字典序最小的代表名字上。只遍历names而不遍历所有出现过的名字,也就顺带排除了那些次数为零的同义词写法。
解题步骤
- 扫描
names,对每个元素定位左右括号,切出名字和括号内的数字,存进频次表countMap。为什么要先解析:后面所有操作都以「名字」为单位,字符串外壳只在这一步剥掉一次。- 扫描
synonyms,对每个元素去掉首尾括号、按逗号切成两个名字,对它们做一次union。为什么可以边读边合并:并查集不要求先知道全部元素,find时懒初始化即可。union内部先各自find出两个根,相同则直接返回;不同则比较两个根的字典序,把大的挂到小的下面。为什么比较根而不是参数本身:只有根才代表整个分量当前的最小值,比较参数会让不变量在多次合并后失效。- 再遍历一次
countMap,对每个名字求根,把次数累加进sumMap[root]。为什么必须重新find:合并是持续发生的,第一步记下的归属早就过期了,必须在全部合并完成后统一查询。- 把
sumMap里的每一项拼回名字(次数)的格式返回。为什么可以跳过零值:names中不会登记次数为零的名字,这条判断只是对异常输入的兜底。以
names = ["John(15)","Jon(12)","Chris(13)","Kris(4)","Christopher(19)"]、synonyms = ["(Jon,John)","(John,Johnny)","(Chris,Kris)","(Chris,Christopher)"]走一遍:解析后countMap为John=15, Jon=12, Chris=13, Kris=4, Christopher=19。处理(Jon,John),两者的根就是自身,John < Jon,于是parent[Jon]=John。处理(John,Johnny),根分别是John与Johnny,John是Johnny的前缀且更短故更小,parent[Johnny]=John。处理(Chris,Kris),Chris < Kris,parent[Kris]=Chris。处理(Chris,Christopher),Chris是前缀更短更小,parent[Christopher]=Chris。此时两棵树的根分别是John和Chris,都符合「根是分量内字典序最小」。累加阶段:John求根得John,sumMap[John]=15;Jon求根经一次跳转得John,sumMap[John]=27;Chris求根得Chris,sumMap[Chris]=13;Kris求根得Chris,sumMap[Chris]=17;Christopher求根得Chris,sumMap[Chris]=36。注意Johnny从未出现在countMap中,因此不会产生Johnny(0)这样的多余输出。最终返回["John(27)","Chris(36)"]。
代码实现
// 用并查集把同义词连接为同一连通分量,并将字典序最小的名字作为根,保证输出稳定。
class Solution {
public String[] trulyMostPopular(String[] names, String[] synonyms) {
Map<String, Integer> countMap = new HashMap<>();
for (String item : names) {
int left = item.indexOf('(');
int right = item.indexOf(')');
String name = item.substring(0, left);
int count = Integer.parseInt(item.substring(left + 1, right));
countMap.put(name, count);
}
UnionFind uf = new UnionFind();
for (String pair : synonyms) {
int comma = pair.indexOf(',');
String a = pair.substring(1, comma);
String b = pair.substring(comma + 1, pair.length() - 1);
uf.union(a, b);
}
Map<String, Integer> sumMap = new HashMap<>();
for (Map.Entry<String, Integer> entry : countMap.entrySet()) {
String root = uf.find(entry.getKey());
sumMap.put(root, sumMap.getOrDefault(root, 0) + entry.getValue());
}
List<String> res = new ArrayList<>();
for (Map.Entry<String, Integer> entry : sumMap.entrySet()) {
if (entry.getValue() == 0) {
continue;
}
res.add(entry.getKey() + "(" + entry.getValue() + ")");
}
return res.toArray(new String[0]);
}
static class UnionFind {
private final Map<String, String> parent = new HashMap<>();
String find(String x) {
parent.putIfAbsent(x, x);
String p = parent.get(x);
if (!p.equals(x)) {
parent.put(x, find(p));
}
return parent.get(x);
}
void union(String a, String b) {
String pa = find(a);
String pb = find(b);
if (pa.equals(pb)) {
return;
}
if (pa.compareTo(pb) < 0) {
parent.put(pb, pa);
} else {
parent.put(pa, pb);
}
}
}
}
// 用并查集把同义词连接为同一连通分量,并将字典序最小的名字作为根,保证输出稳定。
func trulyMostPopular(names []string, synonyms []string) []string {
countMap := map[string]int{}
for _, item := range names {
left := strings.IndexByte(item, '(')
right := strings.IndexByte(item, ')')
name := item[:left]
cnt, _ := strconv.Atoi(item[left+1 : right])
countMap[name] = cnt
}
uf := newUnionFind()
for _, pair := range synonyms {
comma := strings.IndexByte(pair, ',')
a := pair[1:comma]
b := pair[comma+1 : len(pair)-1]
uf.union(a, b)
}
sumMap := map[string]int{}
for name, cnt := range countMap {
root := uf.find(name)
sumMap[root] += cnt
}
res := make([]string, 0, len(sumMap))
for name, cnt := range sumMap {
if cnt == 0 {
continue
}
res = append(res, fmt.Sprintf("%s(%d)", name, cnt))
}
return res
}
type unionFind struct {
parent map[string]string
}
func newUnionFind() *unionFind {
return &unionFind{parent: make(map[string]string)}
}
func (u *unionFind) find(x string) string {
if p, ok := u.parent[x]; ok {
if p != x {
u.parent[x] = u.find(p)
}
return u.parent[x]
}
u.parent[x] = x
return x
}
func (u *unionFind) union(a, b string) {
pa := u.find(a)
pb := u.find(b)
if pa == pb {
return
}
if pa < pb {
u.parent[pb] = pa
} else {
u.parent[pa] = pb
}
}
复杂度分析
- 时间复杂度:$O((n+m)\alpha(k))$,其中 $n$ 是
names长度,$m$ 是synonyms长度,$k$ 是出现过的不同名字数。凭据:解析、合并、累加各扫一遍输入,每个元素只做常数次并查集操作,路径压缩加按字典序合并使单次操作摊还为反阿克曼函数级别;此处把字符串的哈希与比较视作常数代价,若计入长度 $L$ 还要乘上一个 $O(L)$ 因子。- 空间复杂度:$O(k)$,凭据:频次表、并查集的父指针表、求和表都以不同名字为键,各存不超过 $k$ 项,递归求根的栈深也被路径压缩压在同一量级内。
关键点总结
- 「传递的等价关系」是并查集最标准的触发信号:题目只给出相邻的两两关系,却要求按整组处理,就该想到把关系合并成划分,而不是逐个求闭包。
- 代表元不是随便选的。当题目对输出的代表有额外要求(字典序最小、编号最小、值最小)时,就把这个要求写成合并规则,并明确写下「根即最优」这条不变量。
- 合并时永远比较两个根,不是两个参数。这是把「按秩合并」改成「按业务序合并」时最容易翻车的地方。
- 统计必须在所有合并完成之后再做一次
find,中途缓存的归属一定过期。这条在带权并查集、账户合并一类题里同样适用。- 面试视角:这题的隐藏考点其实是字符串解析和输出集合的确定,面试官常追问「同义词里出现但没登记次数的名字要不要输出」。答案是不输出,因为只遍历
names就自动排除了;能主动说出这一点比写对并查集更能拉开分数。- 面试视角:若被追问「不用并查集怎么做」,正确回答是把名字建成无向图后跑一遍遍历取连通分量,时间同样接近线性,但需要显式建图和访问标记,代码量更大——能给出替代方案并说清取舍,说明是真理解而非套模板。
易错点总结
- 错误写法:在
union里直接比较传入的a与b的字典序来决定谁当根。用例synonyms = ["(Jon,John)","(Johnny,Jon)"]→ 第二次合并时Johnny与Jon比较得Johnny更小,把已经是根的John挂到Johnny下,最终代表变成Johnny,不是分量内字典序最小的John。- 错误写法:沿用默认的按秩或按大小合并,最后再从每组里找最小名字。用例任意含三个以上写法的同义词组 → 根取决于合并顺序,若不额外遍历整组求最小值,输出的代表名就是错的。
- 错误写法:在解析
synonyms时忘记剥掉首尾括号,直接按逗号切分。用例"(Jon,John)"→ 得到(Jon和John),这两个带括号的串与names里的名字永远对不上,所有同义词关系全部失效。- 错误写法:把所有在并查集里出现过的名字都拿去输出。用例
synonyms含(John,Johnny)而names里没有Johnny→ 多出一条Johnny(0),答案数量对不上。- 错误写法:先算好每个名字的根缓存起来,再一边合并一边累加。用例合并顺序为
(Chris,Kris)在(Chris,Christopher)之前而累加在中间发生 → 早查到的根随后被改写,次数落在过期的代表上,同一组被拆成两条输出。- 错误写法:把次数加到名字自己头上而不是根上,指望最后再合并。用例
John(15)与Jon(12)→ 输出成John(15)与Jon(12)两条,没有完成求和。- 错误写法:
find只顺着父指针往上走却不做路径压缩。用例把一万个名字连成一条链式的同义词序列 → 每次查询退化成 $O(k)$,总代价来到平方级别,大数据点超时。- 错误写法:解析
names时用固定下标或按空格切分假定名字长度。用例Christopher(19)→ 名字长度不固定,只能靠(与)的位置切分,否则数字被截断成错误的次数。- 错误写法:拼接结果时漏掉括号或写成
名字:次数。用例John与27→ 返回John27,格式不符判定失败,这是本题最容易被忽略的低级失分点。- 错误写法:Java 里用
!=而不是equals比较名字,例如把终止条件写成parent.get(x) != x。用例任何由substring切出来的名字 → 内容相同但对象不同,根节点也被判成「还没到顶」,find无限递归直到栈溢出。
相似题目
| 题目 | 难度 | 考察点 |
|---|---|---|
| 323. 无向图中连通分量的数目 | 中等 | 只数分量个数,不需要选代表元 |
| 547. 省份数量 | 中等 | 输入是邻接矩阵,需先还原出待合并的边 |
| 684. 冗余连接 | 中等 | 用合并失败来定位成环的那条边 |
| 721. 账户合并 | 中等 | 同样是字符串归组,但组内还要排序去重 |
| 765. 情侣牵手 | 困难 | 由分量大小反推最少交换次数 |
| 839. 相似字符串组 | 困难 | 关系不是给定的,要两两判定相似后再合并 |
| 990. 等式方程的可满足性 | 中等 | 先合并等式再用不等式检验矛盾 |
| 1202. 交换字符串中的元素 | 中等 | 分量内字符可任意重排,要组内排序求最小串 |
| LCR 116. 省份数量 | 中等 | 省份数量的同题换皮,可用于对比建图写法 |
| LCR 117. 相似字符串组 | 困难 | 相似字符串组的同题换皮,练习判定与合并分离 |
| LCR 118. 冗余连接 | 中等 | 冗余连接的同题换皮,练习环检测 |