LEETCODE 049Medium

字母异位词分组

互为异位词的字符串排序后完全相同——给每个词算出一个“规范形式”当哈希键,同键即同组。

问题拆解

把字符串数组中互为字母异位词的分到一组。异位词的定义是字母及其出现次数完全相同、只是排列不同,比如 "eat""tea""ate"

朴素做法是两两比较:判断两个词是否异位需要 O(k),配对有 O(n²) 对,而且判出“a 和 b 是一组、b 和 c 是一组”后还得维护并查集式的归并,又慢又绕。

分组问题的通用解法是找一个“同组当且仅当相等”的标签,然后按标签进哈希表。对异位词来说,这个标签唾手可得:把字母排个序,"eat""tea""ate" 都变成 "aet"。排序抹掉了排列的差异,只留下“有哪些字母、各几个”这一本质信息。

一组等价的对象里选一个统一的代表,叫作规范形式(canonical form)。判断“是否等价”从此退化为判断“规范形式是否相等”,而相等判断正是哈希表最擅长的事。

排序后的串作哈希键

一趟遍历:每个词排序得到键,追加到哈希表里对应的组,最后收集所有组。

public List<List<String>> groupAnagrams(String[] strs) {
    Map<String, List<String>> groups = new HashMap<>();
    for (String s : strs) {
        char[] chars = s.toCharArray();
        Arrays.sort(chars);
        String key = new String(chars); // 排序后的串是规范形式
        groups.computeIfAbsent(key, k -> new ArrayList<>()).add(s);
    }
    return new ArrayList<>(groups.values());
}
def groupAnagrams(strs: list[str]) -> list[list[str]]:
    groups = defaultdict(list)
    for s in strs:
        key = "".join(sorted(s))  # 排序后的串是规范形式
        groups[key].append(s)
    return list(groups.values())
func groupAnagrams(strs []string) [][]string {
    groups := map[string][]string{}
    for _, s := range strs {
        b := []byte(s)
        slices.Sort(b)
        key := string(b) // 排序后的串是规范形式
        groups[key] = append(groups[key], s)
    }
    res := make([][]string, 0, len(groups))
    for _, g := range groups {
        res = append(res, g)
    }
    return res
}
use std::collections::HashMap;

pub fn group_anagrams(strs: Vec<String>) -> Vec<Vec<String>> {
    let mut groups: HashMap<Vec<u8>, Vec<String>> = HashMap::new();
    for s in strs {
        let mut key = s.clone().into_bytes();
        key.sort_unstable(); // 排序后的串是规范形式
        groups.entry(key).or_default().push(s);
    }
    groups.into_values().collect()
}

注意进组的是原字符串,键只是路标——初学者偶尔会把排序后的串存进组里,输出就全变成 "aet" 了。哈希表的 value 直接用列表累积,省去“先查组号再定位”的二段跳。

计数签名作键

如果单词很长,排序的 O(k log k) 可以再省:字母表只有 26 个小写字母,统计每个字母的出现次数得到一个长度 26 的计数数组,它同样是异位词的规范形式,而且 O(k) 就能算出来。

public List<List<String>> groupAnagrams(String[] strs) {
    Map<String, List<String>> groups = new HashMap<>();
    for (String s : strs) {
        int[] count = new int[26];
        for (char c : s.toCharArray()) count[c - 'a']++;
        String key = Arrays.toString(count); // 计数签名,如 [1, 0, 2, ...]
        groups.computeIfAbsent(key, k -> new ArrayList<>()).add(s);
    }
    return new ArrayList<>(groups.values());
}
def groupAnagrams(strs: list[str]) -> list[list[str]]:
    groups = defaultdict(list)
    for s in strs:
        count = [0] * 26
        for c in s:
            count[ord(c) - ord("a")] += 1
        groups[tuple(count)].append(s)  # 元组可哈希,直接作键
    return list(groups.values())
func groupAnagrams(strs []string) [][]string {
    groups := map[[26]int][]string{}
    for _, s := range strs {
        var count [26]int // 数组(非切片)可作 map 键
        for i := 0; i < len(s); i++ {
            count[s[i]-'a']++
        }
        groups[count] = append(groups[count], s)
    }
    res := make([][]string, 0, len(groups))
    for _, g := range groups {
        res = append(res, g)
    }
    return res
}
use std::collections::HashMap;

pub fn group_anagrams(strs: Vec<String>) -> Vec<Vec<String>> {
    let mut groups: HashMap<[u8; 26], Vec<String>> = HashMap::new();
    for s in strs {
        let mut count = [0u8; 26]; // 定长数组实现了 Hash,可作键
        for &b in s.as_bytes() {
            count[(b - b'a') as usize] += 1;
        }
        groups.entry(count).or_default().push(s);
    }
    groups.into_values().collect()
}

这个版本的坑全在“键的可哈希性”上:Python 的 list 不可哈希,要转成 tuple;Go 的切片不能作 map 键,定长数组 [26]int 才可以;Java 没有值语义的数组相等,得把计数数组序列化成字符串。千万不要把计数简单拼接成 "1012" 这种不带分隔符的串——[1, 0, 12][10, 1, 2] 会撞在一起,要么用 Arrays.toString 这种自带分隔符的形式,要么手动插入分隔符。本题单词不长,排序版通常就够快,计数版胜在思路可扩展。

复杂度

指标 复杂度 原因
时间 O(n · k log k) 每个词排序 O(k log k);计数版为 O(n · k)
空间 O(n · k) 哈希表存全部字符串及其键

可以迁移的模式

  • 分组、去重、判等价,先问一句:这类对象的规范形式是什么?
  • 规范形式可以是排序结果、计数签名,也可以是任何“等价则必相同”的映射;
  • 用复合结构当哈希键时,先确认它在该语言里可哈希、且按值比较。

“把等价类映射到同一个键”是哈希分组的心法,判定异位词、同构字符串、轮转字符串归一化,全是同一招。