LeetCode 49. 字母异位词分组 | 从排序到计数的哈希表优化之路

在 LeetCode 的字符串类题目中,「字母异位词分组」是一道经典的中等难度题,它不仅考察字符串处理的基础能力,更是对哈希表键值设计思路 的深度检验。这道题的核心是找到字母异位词的 共性特征,并通过这个特征实现分组。今天我们就从最直观的暴力思路出发,一步步拆解到时间复杂度更优的计数法,带你吃透这道题的解题逻辑~

📌 题目重述

给你一个由小写字母组成的字符串数组 strs,要求把数组中字母异位词 归为一组,最后以任意顺序返回分组后的列表。

这里的关键是理解字母异位词 :两个字符串如果包含的字母完全相同,只是排列顺序不同,那它们就是字母异位词。比如 eattea,都由 eat 组成,只是顺序不一样,就属于一组;而 bat 没有对应的异位词,单独成组。

举个例子,输入 ["eat", "tea", "tan", "ate", "nat", "bat"],输出就是 [["bat"],["nat","tan"],["ate","eat","tea"]]

🚶 阶梯思路拆解

第一步:暴力思路(两两对比)🥾

刚开始接触这道题,最直接的想法是检查每两个字符串是否为字母异位词,然后手动分组。这是暴力解法的核心逻辑,虽然容易理解,但效率极低。

💡 核心逻辑

  1. 初始化一个结果列表,用于存储最终的分组;
  2. 遍历数组中的每个字符串 s
    • 如果 s 还未被分组,创建一个新的子列表,将 s 加入;
    • 再遍历数组中剩下的字符串 t,检查 t 是否与 s 是字母异位词,若是则加入同一个子列表,并标记 t 为已分组;
  3. 最终返回结果列表。

判断两个字符串是否为字母异位词的方法:将两个字符串排序后比较是否相等(比如 eat 排序后是 aettea 排序后也是 aet,则为异位词)。

✅ 代码实现(Java)

java 复制代码
import java.util.*;

public class Solution {
    public List<List<String>> groupAnagrams(String[] strs) {
        List<List<String>> result = new ArrayList<>();
        boolean[] isGrouped = new boolean[strs.length]; // 标记是否已分组

        for (int i = 0; i < strs.length; i++) {
            if (isGrouped[i]) continue; // 跳过已分组的字符串

            List<String> group = new ArrayList<>();
            group.add(strs[i]);
            isGrouped[i] = true;

            // 遍历剩余字符串,找异位词
            for (int j = i + 1; j < strs.length; j++) {
                if (!isGrouped[j] && isAnagram(strs[i], strs[j])) {
                    group.add(strs[j]);
                    isGrouped[j] = true;
                }
            }
            result.add(group);
        }
        return result;
    }

    // 判断两个字符串是否为字母异位词
    private boolean isAnagram(String s, String t) {
        if (s.length() != t.length()) return false;
        char[] sArr = s.toCharArray();
        char[] tArr = t.toCharArray();
        Arrays.sort(sArr);
        Arrays.sort(tArr);
        return Arrays.equals(sArr, tArr);
    }
}

⚙️ 复杂度分析

复杂度类型 计算结果 说明
时间复杂度 O(n² * k log k) n 是数组长度,k 是字符串的最大长度。两层嵌套循环是 O (n²),每次判断异位词的排序操作是 O (k log k)
空间复杂度 O(n) 除了结果存储,仅使用了 isGrouped 数组,空间为 O (n)

🚫 遇到的问题

暴力解法的效率问题非常突出:当数组长度 n 达到 10⁴ 时,n² 就是 10⁸ 次运算,再加上字符串排序的开销,必然会超时。问题的核心在于重复的异位词判断 (比如判断 eattea 后,又会判断 teaate),我们需要找到一种方式,让所有异位词能 自动归组,避免重复比较。

第二步:排序 + 哈希表(优化思路)🗺️

既然字母异位词排序后是完全相同的字符串,那我们可以把排序后的字符串作为哈希表的键,对应的值存储该组的所有异位词。这样遍历一次数组就能完成分组,彻底解决重复比较的问题。

💡 核心逻辑

  1. 初始化一个 HashMap,键为排序后的字符串 ,值为该组异位词的列表
  2. 遍历数组中的每个字符串 s
    • s 进行排序,得到 key
    • 如果 key 不在 HashMap 中,创建一个新的列表并放入 HashMap
    • s 添加到 key 对应的列表中;
  3. 遍历结束后,将 HashMap 中的所有值取出,即为最终的分组结果。

📊 图文演示(以 strs=["eat","tea","tan","ate","nat","bat"] 为例)

(如图所示)我们一步步看 HashMap 的变化过程:

  1. 遍历 eat:排序后为 aet,HashMap 中无 aet,创建列表 ["eat"],存入 {aet: ["eat"]}
  2. 遍历 tea:排序后为 aet,HashMap 中有 aet,将 tea 加入列表,变为 {aet: ["eat", "tea"]}
  3. 遍历 tan:排序后为 ant,HashMap 中无 ant,创建列表 ["tan"],存入 {aet: [...], ant: ["tan"]}
  4. 遍历 ate:排序后为 aet,加入列表, aet 对应的列表变为 ["eat", "tea", "ate"]
  5. 遍历 nat:排序后为 ant,加入列表, ant 对应的列表变为 ["tan", "nat"]
  6. 遍历 bat:排序后为 abt,创建列表 ["bat"],最终 HashMap 为 {aet: [...], ant: [...], abt: ["bat"]}
  7. 取出 HashMap 的值,得到结果 [["eat","tea","ate"], ["tan","nat"], ["bat"]]

✅ 代码实现(Java)

java 复制代码
import java.util.*;

public class Solution {
    public List<List<String>> groupAnagrams(String[] strs) {
        Map<String, List<String>> map = new HashMap<>();

        for (String s : strs) {
            // 将字符串排序作为键
            char[] charArr = s.toCharArray();
            Arrays.sort(charArr);
            String key = new String(charArr);

            // 不存在则创建新列表
            if (!map.containsKey(key)) {
                map.put(key, new ArrayList<>());
            }
            // 将当前字符串加入对应列表
            map.get(key).add(s);
        }

        // 将map的值转换为结果列表
        return new ArrayList<>(map.values());
    }
}

⚙️ 复杂度分析

复杂度类型 计算结果 说明
时间复杂度 O(n * k log k) n 是数组长度,k 是字符串最大长度。遍历数组是 O (n),每个字符串排序是 O (k log k)
空间复杂度 O(n * k) HashMap 需要存储所有字符串,空间为 O (n * k)

✨ 优化亮点

这种方法将时间复杂度从 O (n² * k log k) 降到了 O (n * k log k),在 n 较大时效率提升非常明显,也是这道题的常用解法 。但它仍有优化空间:字符串排序的 O (k log k) 开销可以通过字符计数进一步降低。

第三步:计数 + 哈希表(最优解法)🔢

由于题目规定字符串仅包含小写字母(共 26 个),我们可以用一个长度为 26 的数组 统计每个字符出现的次数,再将这个计数数组转换为唯一的键(比如拼接成字符串 #1#0#0#...#1),这样就能避免排序的开销。

💡 核心逻辑

  1. 初始化一个 HashMap,键为字符计数的拼接字符串 ,值为该组异位词的列表
  2. 遍历数组中的每个字符串 s
    • 创建长度为 26 的数组 count,统计 s 中每个小写字母的出现次数( count[0] 对应 acount[1] 对应 b,以此类推);
    • count 数组拼接为字符串(如 eat 的计数数组是 [1,0,0,0,1,0,...1],拼接为 #1#0#0#...#1),作为 key
    • 如果 key 不在 HashMap 中,创建新列表;将 s 加入对应列表;
  3. 最终将 HashMap 的值转换为结果列表。

📊 图文演示(以 strs=["eat","tea"] 为例)

(如图所示)计数法的键生成过程:

  1. 处理 eat
    • e 是第 4 个字母, count[4] +=1a 是第 0 个字母, count[0] +=1t 是第 19 个字母, count[19] +=1
    • 计数数组为 [1,0,0,0,1,0,...,1](仅展示关键位置),拼接为 #1#0#0#0#1#...#1 作为 key
  2. 处理 tea
    • 统计后计数数组与 eat 完全相同,拼接的 key 也一致,因此被加入同一个列表。

✅ 代码实现(Java)

java 复制代码
import java.util.*;

public class Solution {
    public List<List<String>> groupAnagrams(String[] strs) {
        Map<String, List<String>> map = new HashMap<>();

        for (String s : strs) {
            int[] count = new int[26]; // 统计26个小写字母的出现次数
            for (char c : s.toCharArray()) {
                count[c - 'a']++; // 'a'对应0,'b'对应1...
            }

            // 将计数数组转换为字符串作为键
            StringBuilder sb = new StringBuilder();
            for (int num : count) {
                sb.append('#').append(num); // 用#分隔避免数字混淆(如1和10)
            }
            String key = sb.toString();

            if (!map.containsKey(key)) {
                map.put(key, new ArrayList<>());
            }
            map.get(key).add(s);
        }

        return new ArrayList<>(map.values());
    }
}

⚙️ 复杂度分析

复杂度类型 计算结果 说明
时间复杂度 O(n * k) n 是数组长度,k 是字符串最大长度。遍历数组是 O (n),每个字符串的计数和拼接是 O (k)
空间复杂度 O(n * k) HashMap 存储所有字符串,空间为 O (n * k)

✨ 优化亮点

这种方法彻底去掉了排序的 O (k log k) 开销,时间复杂度降到了线性的 O (n * k),是这道题的最优解法 。需要注意的是,拼接计数数组时要使用分隔符(如 #),避免出现 110 拼接后混淆的情况(比如 count=[1,10]count=[11,0],不加分隔符都会变成 110)。

📝 总结

「字母异位词分组」的解题思路,核心是找到异位词的唯一标识作为哈希表的键,我们从暴力的两两对比,到用排序生成键,再到用计数生成更高效的键,一步步实现了优化。这里的关键技巧可以总结为:

  1. 哈希表的键设计:针对同类元素的 **共性特征 **设计唯一键,是哈希表分组问题的核心;
  2. 利用题目约束优化 :本题中 仅包含小写字母 的约束,让计数法替代排序成为可能;
  3. 时间与空间的权衡 :三种解法的空间复杂度逐渐升高,但时间效率大幅提升,这是 空间换时间 的典型应用。

同类题扩展建议

掌握了这道题的思路后,可以尝试这些进阶题目:

  1. LeetCode 242. 有效的字母异位词:本题的基础子问题,练习字符计数的基本用法;
  2. LeetCode 438. 找到字符串中所有字母异位词 | ... :滑动窗口 + 字符计数的综合应用;
  3. LeetCode 76. 最小覆盖子串 | 滑动窗口最优解全... :滑动窗口与哈希表结合的经典难题。

算法学习的本质就是从基础思路出发,不断根据题目特征优化解法。把这道题的键设计思路吃透,面对其他哈希表分组问题就能快速找到突破口啦~

相关推荐
月明长歌2 小时前
【码道初阶】【LeetCode 160】相交链表:让跑者“起跑线对齐”的智慧
java·算法·leetcode·链表
共享家95272 小时前
每日一题(一)
算法
fufu03112 小时前
Linux环境下的C语言编程(四十一)
linux·c语言·算法
bing.shao2 小时前
Golang 之闭包
java·算法·golang
顾子羡_Gu2 小时前
算法进阶指南:搜索与数论基础
数据结构·算法
唯道行2 小时前
计算机图形学·25 消隐2 区域子分算法-光线投射算法
人工智能·算法·计算机视觉·计算机图形学·opengl
jinxinyuuuus3 小时前
FIRE之旅 财务计算器:实时交互式建模与前端性能工程
前端·人工智能·算法·自动化
千丈之松3 小时前
能力和法律
算法
2401_841495643 小时前
【LeetCode刷题】缺失的第一个正数
数据结构·python·算法·leetcode·数组·哈希·缺失最小正整数