字母异位词分组:C++哈希表设计与两种核心解法详解
2026/9/10 16:31:25 网站建设 项目流程

作为一个常年刷题、也带过不少人入门算法的老选手,我越来越觉得“字母异位词分组”这道题在哈希专题里属于那种“看起来简单,但越嚼越有味道”的经典题目。它不像动规那样劝退,也不像图论那样烧脑,但它恰好卡在了一个绝妙的位置:考察你对哈希函数设计的理解深度、对C++容器底层差异的敏感度,以及面试时能否写出既简洁又健壮的代码。这篇博文,我就以这道题为核心,从题目拆解到两种主流解法的C++实现,再到实际刷题和面试中容易踩的坑,一次性讲透。

1. 题目本质与解题思路的“第一性原理”

1.1 异位词到底是什么

先给刚接触的朋友把概念钉死。字母异位词指的是两个字符串包含的字符种类和数量完全一致,只是排列顺序不同。比如“eat”、“tea”、“ate”这三个词,拆开来看都是1个‘e’、1个‘a’、1个‘t’,那它们就是一组异位词。再比如“listen”和“silent”,也是一组经典的异位词。

这里有一个容易混淆的点:空字符串。按照定义,两个空字符串互为异位词,因为它们的字符频次都是0。但实际工程中,如果输入数组里有两个空串,它们也应该被分到同一组。这个边界在写代码时虽然不影响主逻辑,但在面试追问时往往会被拎出来单独问,所以要提前有数。

1.2 为什么哈希表是这道题的内定主角

这道题的核心需求是“分组”。分组意味着我们需要一种机制,让同一组的所有字符串都能映射到同一个“标识”上,而不同组的字符串映射到不同的“标识”上。这不就是哈希表的天职吗?C++里std::unordered_map的底层实现是哈希表,平均O(1)的查找和插入复杂度,完美适配这种“判断两个字符串是否同类”的高频场景。

有人说用std::map行不行?行,但没必要。std::map底层是红黑树,插入和查找都是O(log n)。在数据量大的时候,用std::map做分组,时间复杂度会多出一个log因子。刷题时我们追求的是最优解,面试官也会默认你选unordered_map。而且std::unordered_map对键的要求是“可哈希”,对std::string这类内置类型完全无压力,根本不用你手写哈希函数。

1.3 把“分组”翻译成代码逻辑

整个算法的思路用一个公式概括就是:遍历每个字符串,将其转化为一个唯一标识,然后以该标识为键,将原字符串追加到哈希表对应键的值(一个字符串数组)中。

难点就在这个“唯一标识”上。怎么把一个字符串变成一个能唯一代表其字符频次的键?这里衍生出两条经典的路线,我在下一节详细拆解。

2. 两种核心标识设计:排序法与计数法

2.1 排序法:最简单直白的“标准化”

排序法的逻辑非常朴素:既然是异位词,那把它们各自的字符按字典序排序后,得到的结果一定完全一样。例如“eat”排序后是“aet”,“tea”排序后也是“aet”,于是“aet”就成了这组的唯一标识。

C++实现只需要一行关键代码:

string key = str; sort(key.begin(), key.end());

遍历完当前字符串后,用keyunordered_map里查,查到了就把原始字符串str塞进对应的vector,没查到就新建一个键值对。

这个解法的时间复杂度是O(n * k log k),其中n是字符串个数,k是字符串的最大长度。排序的时间复杂度是k log k,外层还有n个字符串的遍历。优点是代码量极小,几乎不会写错,特别适合笔试时赶时间。

但它的短板也很明显:如果字符串特别长(比如几千个字符),排序的开销就会非常大。如果你在面试中只给出排序法,面试官大概率会追问一句:“能不能优化到O(n * k)?”

2.2 计数法:用频次数组打造O(n * k)的解法

计数法的思路是把字符频次编码成一个固定长度的字符串。因为题目通常限定字符串只包含小写字母,所以我们可以用一个长度为26的数组统计每个字符出现的次数,然后把次数拼接成一个新字符串作为key。

具体做法:

string key; vector<int> count(26, 0); for (char c : str) { count[c - 'a']++; } for (int num : count) { key += to_string(num) + "#"; }

这里我每拼接一个数字就加一个#分隔符,这是必须的。如果不加分隔符,就会产生一个经典的bug:比如一个字符串有两个'a'和十一个'b',拼接出来的数字是“211”;另一个字符串有二十一个'a'和一个'b',拼接出来是“211”。两个key完全一样,但它们是不同的异位词组吗?当然不是,实际上它们根本不该被分到一组,但由于“211”这个歧义,它们被错误地分到了一起。加#之后,前者是“2#11#0#...”,后者是“21#1#0#...”,一眼就能区分。

如果你觉得用to_string拼接太慢,还可以用另一种思路:把频次数组直接作为一个26维的向量当键。C++里vector<int>是可以用作unordered_map的键的,只要提供对应的哈希函数。不过标准库没有默认提供vector<int>的哈希特化,需要你自定义一个。这在LeetCode上能过,但面试手写时容易因为模板特化语法不熟而出错,所以还是推荐用字符串拼接法,简单、直观、不出错。

2.3 两种方案的对比与选型建议

维度排序法计数法
时间复杂度O(n * k log k)O(n * k)
空间复杂度O(n * k)O(n * k)
代码复杂度极简中等
适用场景字符串长度短、笔试题字符串长、面试追求最优解
是否依赖字符集不依赖依赖已知字符集(如26个小写字母)

如果面试中没有额外说明字符集范围,但题目说“仅包含小写字母”,计数法是最优选择。如果字符集不确定(比如包含Unicode字符),计数法就需要动态计算字符集大小,此时排序法反而更通用可靠。

3. 完整C++实现与核心代码解析

3.1 计数法的完整可运行代码

我用计数法演示一个可直接提交的版本,这也是我在面试中推荐优先写的方案:

class Solution { public: vector<vector<string>> groupAnagrams(vector<string>& strs) { unordered_map<string, vector<string>> mp; for (const string& s : strs) { vector<int> count(26, 0); for (char c : s) { count[c - 'a']++; } string key; for (int num : count) { key += to_string(num); key += '#'; } mp[key].push_back(s); } vector<vector<string>> result; result.reserve(mp.size()); for (auto& pair : mp) { result.push_back(std::move(pair.second)); } return result; } };

这段代码有几处值得解释的地方。

第一处是mp[key].push_back(s)。这行代码同时完成了“查询”和“插入”两个动作。如果keymp里不存在,operator[]会默认构造一个空的vector<string>插入进去,然后返回引用,紧接着我们就可以直接push_back。这一行代码是C++容器操作中“优雅”和“危险”并存的典型:优雅在于写起来方便,危险在于如果误写了别的类型,比如mp[key]++,就会在map里插入一个值为1的键值对,而不是你想要的效果。

第二处是result.reserve(mp.size())。这一步是微优化,提前分配好内存,避免result在push_back过程中反复扩容。数据量大时能省不少时间,面试时提一句“我预留了空间”会让面试官觉得你关注性能细节。

第三处是std::move(pair.second)。把哈希表里的vector直接搬进结果数组,避免了一次深拷贝。面试时这个动作能体现你对C++11移动语义的理解。如果面试官不要求,你也可以直接result.push_back(pair.second),效果一样,但少了性能上的讲究。

3.2 排序法的完整可运行代码

排序法代码更短,适合笔试时快速秒杀:

class Solution { public: vector<vector<string>> groupAnagrams(vector<string>& strs) { unordered_map<string, vector<string>> mp; for (string s : strs) { string key = s; sort(key.begin(), key.end()); mp[key].push_back(std::move(s)); } vector<vector<string>> result; for (auto& pair : mp) { result.push_back(std::move(pair.second)); } return result; } };

这里注意我把循环变量写成了string s而不是const string& s,因为后面要std::move(s),需要它是一个可修改的局部对象。当然,你完全可以直接写const string& s,然后mp[key].push_back(s),只是会多一次拷贝。对于追求极致性能的选手,用move可以显著减少大数据量下的开销。

3.3 内存与性能洞察

你可能好奇,计数法的key最长是多少?如果你把26个数字全部拼接,最多会有26 * (数字位数 + 1)个字符。当字符串长度很长时,数字的位数也会增加。假设一个字符串有一万个'a',那count[0]就是10000,占5位。加上分隔符,key可能就有上百个字符。所以计数法并不是严格意义上省内存,它的优势主要体现在时间上。

如果进一步优化,可以考虑用更紧凑的方式编码key,比如把频次数组编码成std::string,直接使用count数组的底层内存转成二进制字符串。这个太底层了,实战中用不到,但作为思维扩展你可以了解一下:有些追求极致速度的选手会把key设计成std::array<int, 26>并自定义哈希函数,这样key的比较速度比字符串比较更快,因为std::array的比较可以直接用内存比对。不过这需要写特化哈希,面试场上通常不值得为这点速度牺牲可读性。

4. 哈希表选型与C++底层机制盘点

4.1 unordered_map vs map:刷题时为什么锁死前者

C++里关联容器分两大类:有序的std::map/std::multimap(红黑树)和无序的std::unordered_map/std::unordered_multimap(哈希表)。在字母异位词分组这道题里,我们完全不关心键的顺序,只关心键是否存在和值能否快速访问。所以unordered_map是理论上更优的选择。

但有一个场景你要注意:如果面试官要求输出结果按字典序排序,你可能会想,那直接用std::map,它自动按key排好序了,岂不是更方便?这里有个陷阱:std::map是按key排序,但题目要求每组内部的字符串保持原顺序,整个结果数组的顺序通常不要求排序。所以即使按key排序了,对最终结果也没有实际帮助,反而拖慢了速度。不要为了一个不存在的需求引入额外的复杂度。

4.2 C++哈希表的自动扩容与迭代器失效问题

unordered_map在元素数量超过负载因子时会自动rehash,这个过程会使所有迭代器失效。但在这道题中,我们全程只使用operator[]push_back,没有保存任何迭代器,所以根本不会踩到迭代器失效的坑。

不过如果你想炫技,在遍历unordered_map的同时尝试修改它(比如删除某些元素),就会触发未定义行为。这道题不需要,但在别的哈希表题中这是一个高频考点。C++的unordered_map在rehash之后,连end()迭代器都会变,所以任何时候都不要在遍历中修改容器结构。

4.3 C++17的std::string_view能否派上用场

有人会想,为了减少字符串拷贝,能不能把std::string_view当作哈希表的键?这个想法很好,但非常危险。string_view只是一个视图,它不拥有底层字符串数据。如果你用string_view做键,哈希表里保存的是指向原始字符串的指针和长度。如果原始strs数组在哈希表声明之后被修改或销毁,所有键都会变成悬垂指针,程序直接崩溃。

如果你非常想用string_view做键来避免拷贝,必须保证原始数据在整个哈希表生命周期内不变,且你自定义了string_view的哈希函数(标准库在C++17里没给string_view提供std::hash特化,直到C++20才补上)。这道题完全没必要冒这个险,老老实实用std::string做键,性能和安全性都兼顾。

4.4 自定义哈希函数的正确姿势

如果哪天你遇到一道题,键是一个pair<int, int>或者vector<int>,C++标准库不会帮你自动生成哈希,你就得自己写。这道题虽然用不到,但掌握这个姿势对后续刷题帮助很大。一段标准的pair哈希可以这样写:

struct PairHash { size_t operator()(const pair<int, int>& p) const { return hash<int>()(p.first) ^ (hash<int>()(p.second) << 1); } };

注意这里用了“左移一位再异或”的方式,避免了两个相同元素对(如(1,2)和(2,1))哈希值相同导致的冲突增多。面试时如果你能现场写出这段代码,对哈希函数的理解会加分不少。

5. 刷题与面试场景中的实战避坑指南

5.1 边界条件:空数组与空字符串

题目给了空数组[],你的代码应该返回一个空数组。上面的实现天然满足这一点:遍历循环不执行,mp为空,result为空,直接返回。

如果输入是[""],即一个空字符串,计数法的count数组全部为0,拼接出来的key是26个“0#”。它会被单独放到一组,结果是[[""]]。这个行为符合定义,但很多人在手动测试时容易想当然,以为空串应该和别的什么分成一组,其实不会。

5.2 典型错误:忘记处理超大字符串导致超时

有些人用计数法时会把key设计成直接拼接数字不加分隔符,前面已经说过这是致命的。但也有人加了分隔符还是超时,原因在于用了一个效率极低的操作:key += to_string(num) + "#"在C++里需要临时构造一个string再追加,效率略低于直接两次+=。大数据量下这也能造成肉眼可见的性能差异。建议写成:

key += std::to_string(num); key.push_back('#');

或者更狠一点,直接用key.append(std::to_string(num)).push_back('#')。这种微优化在LeetCode上可能不明显,但在面试手撕代码时,你可以顺嘴提一句“这里我避免创建临时对象”,能加分。

5.3 面试追问:如果你被要求输出每个字符串所在组的下标

这是一个变体题,我在面试中实际遇到过关卡:groupAnagrams要求你返回一个vector<vector<int>>,每一组存的是原数组下标,而不是字符串本身。

思路完全一样,只是哈希表的value从vector<string>变成vector<int>,遍历时把下标i存进去,而不是把字符串存进去。最后遍历哈希表传下标即可。这里有一个小技巧:如果你既需要返回分组,又需要返回每个字符串所属的组号,可以用两次遍历或者使用vector<int> groupId(strs.size(), 0)配合哈希表映射key到组号,一次性完成。

5.4 现场手写时的命名与风格细节

面试时写代码,命名规范很重要。unordered_map<string, vector<string>> mp虽然简洁,但更好的命名是map<string, vector<string>> groups,这样面试官一眼就能看出这个map保存的是分组信息。变量名keytmp好,因为key承载了“唯一标识”的语义。这种细节看似无关紧要,但在面试高压环境下,清晰的命名能帮助你减少思维混乱,也让面试官更容易理解你的思路。

5.5 经典错误:key在多轮循环中残留污染

计数法里,如果你把vector<int> count(26, 0);定义在for循环外面,每处理完一个字符串后必须手动fill(count.begin(), count.end(), 0)。如果忘了重置,下一个字符串的频次就会叠加在之前的结果上,导致key完全错误。我见过不少人在这个小小的细节上翻车。建议直接把count定义在循环内部,每次循环自动初始化,虽然消耗一点构造时间,但大大降低了出错概率。

6. 复杂度优化与工程落地扩展

6.1 大规模数据下的并行加速

如果把这道题放到真实工程环境里,面对的是上亿条日志字符串,单线程遍历可能不够快。因为每个字符串的key计算是相互独立的,天然适合并行化。可以用C++17的std::execution::par配合std::transform_reduce或者直接用OpenMP把for循环并行化。

不过并行化处理有一个问题:unordered_map的并发写入是不安全的。你需要在外层用锁或者采用“先计算key,再统一分组”的两阶段策略。第一阶段并行计算每个字符串的key,第二阶段单线程按key分组。这样就避开了并发写容器的问题。

6.2 数字签名思路的借鉴

其实这道题的核心就是在设计一个“签名”函数,把字符串映射到一个固定格式的键上。这种思路在工程里很常见:比如用哈希值做文件去重、用特征向量做相似图片检索,本质都是“把复杂对象转化为可比较的签名”。理解了这层,你就能把一道算法题的方法论迁移到实际系统中。

做文件去重时,如果只看文件大小,不同文件可能大小相同但内容不同,这是碰撞;文件哈希(如SHA-256)就是更强大的签名。对应到这道题,排序法和计数法的本质就是两种不同强度的“签名”算法。计数法在小写字母场景下是完美签名(无碰撞),排序法在大字符集场景下也是无碰撞(因为排序结果完全保留了频次信息,只是丢弃了顺序信息)。

6.3 如果字符集扩大到Unicode怎么办

这题如果扩展到任意Unicode字符,vector<int> count(26)就不够了。你可以用unordered_map<char, int>来统计频次,然后把这个map按字符排序后再编码成key。但这样操作的复杂度就不太好看了。更优的方案是依然用排序法,因为排序法不依赖字符集。这也是为什么我在前面强调排序法有它的独特优势——它是一个在任何字符集下都通用的方案。

6.4 从空间换时间与时间换空间的辩证看起

这道题其实很好地体现了“空间换时间”的算法思想。无论是排序法还是计数法,都在用额外的空间存储key,换来的是分组时O(1)的哈希查找。如果完全不用额外空间,那只能两两比较字符串是否互为异位词,时间复杂度瞬间爆炸到O(n^2 * k)。这种权衡在工程中无处不在:缓存、索引、预计算,全是空间换时间的典型应用。

7. 一些刷题之外的经验之谈

最后分享一个个人体会。这道题我已经见过无数遍,但每次教别人时还是会有新的收获。很多人刚开始刷题时,喜欢追求解法的新奇,看到别人用位运算、用质数乘法(给每个字母分配一个质数,乘积作为key)就觉得酷。这个质数乘法的方案确实存在,用一组质数替代频次统计,key是一个大整数,碰撞概率极低,但要注意大整数溢出问题。在C++里用unsigned long long也扛不住长字符串(比如一万个'z',质数乘积会溢出成未定义行为)。

所以我的建议是:面对这种经典题目,优先掌握最稳妥、最通用的解法(排序法和计数法),把性能的极致探索留在课后自己玩。面试场上,清晰、正确、有复杂度意识,远比秀技重要。

这道题你如果真的吃透了,后面很多哈希表的题目都会变得轻松,比如“找到字符串中所有字母异位词”、“最小覆盖子串”等,它们的核心都是“如何快速判断两个字符串的字符频次是否相等”。把今天讲的计数法和滑动窗口一结合,那些题其实都是这道题的一个变体。

希望这篇拆解能帮你在哈希表这条路上少走一些弯路。如果你在实现中遇到了别的奇怪问题,欢迎在评论区和大家交流,我知道的都会尽量解答。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询