ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

字母异位词分组:C++哈希表设计与两种核心解法详解

2026/9/10 16:31:37 拓冰建站 浏览量
字母异位词分组:C++哈希表设计与两种核心解法详解 作为一个常年刷题、也带过不少人入门算法的老选手我越来越觉得“字母异位词分组”这道题在哈希专题里属于那种“看起来简单但越嚼越有味道”的经典题目。它不像动规那样劝退也不像图论那样烧脑但它恰好卡在了一个绝妙的位置考察你对哈希函数设计的理解深度、对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());遍历完当前字符串后用key去unordered_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; vectorint 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里vectorint是可以用作unordered_map的键的只要提供对应的哈希函数。不过标准库没有默认提供vectorint的哈希特化需要你自定义一个。这在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: vectorvectorstring groupAnagrams(vectorstring strs) { unordered_mapstring, vectorstring mp; for (const string s : strs) { vectorint 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); } vectorvectorstring result; result.reserve(mp.size()); for (auto pair : mp) { result.push_back(std::move(pair.second)); } return result; } };这段代码有几处值得解释的地方。第一处是mp[key].push_back(s)。这行代码同时完成了“查询”和“插入”两个动作。如果key在mp里不存在operator[]会默认构造一个空的vectorstring插入进去然后返回引用紧接着我们就可以直接push_back。这一行代码是C容器操作中“优雅”和“危险”并存的典型优雅在于写起来方便危险在于如果误写了别的类型比如mp[key]就会在map里插入一个值为1的键值对而不是你想要的效果。第二处是result.reserve(mp.size())。这一步是微优化提前分配好内存避免result在push_back过程中反复扩容。数据量大时能省不少时间面试时提一句“我预留了空间”会让面试官觉得你关注性能细节。第三处是std::move(pair.second)。把哈希表里的vector直接搬进结果数组避免了一次深拷贝。面试时这个动作能体现你对C11移动语义的理解。如果面试官不要求你也可以直接result.push_back(pair.second)效果一样但少了性能上的讲究。3.2 排序法的完整可运行代码排序法代码更短适合笔试时快速秒杀class Solution { public: vectorvectorstring groupAnagrams(vectorstring strs) { unordered_mapstring, vectorstring mp; for (string s : strs) { string key s; sort(key.begin(), key.end()); mp[key].push_back(std::move(s)); } vectorvectorstring 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::arrayint, 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 C17的std::string_view能否派上用场有人会想为了减少字符串拷贝能不能把std::string_view当作哈希表的键这个想法很好但非常危险。string_view只是一个视图它不拥有底层字符串数据。如果你用string_view做键哈希表里保存的是指向原始字符串的指针和长度。如果原始strs数组在哈希表声明之后被修改或销毁所有键都会变成悬垂指针程序直接崩溃。如果你非常想用string_view做键来避免拷贝必须保证原始数据在整个哈希表生命周期内不变且你自定义了string_view的哈希函数标准库在C17里没给string_view提供std::hash特化直到C20才补上。这道题完全没必要冒这个险老老实实用std::string做键性能和安全性都兼顾。4.4 自定义哈希函数的正确姿势如果哪天你遇到一道题键是一个pairint, int或者vectorintC标准库不会帮你自动生成哈希你就得自己写。这道题虽然用不到但掌握这个姿势对后续刷题帮助很大。一段标准的pair哈希可以这样写struct PairHash { size_t operator()(const pairint, int p) const { return hashint()(p.first) ^ (hashint()(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要求你返回一个vectorvectorint每一组存的是原数组下标而不是字符串本身。思路完全一样只是哈希表的value从vectorstring变成vectorint遍历时把下标i存进去而不是把字符串存进去。最后遍历哈希表传下标即可。这里有一个小技巧如果你既需要返回分组又需要返回每个字符串所属的组号可以用两次遍历或者使用vectorint groupId(strs.size(), 0)配合哈希表映射key到组号一次性完成。5.4 现场手写时的命名与风格细节面试时写代码命名规范很重要。unordered_mapstring, vectorstring mp虽然简洁但更好的命名是mapstring, vectorstring groups这样面试官一眼就能看出这个map保存的是分组信息。变量名key比tmp好因为key承载了“唯一标识”的语义。这种细节看似无关紧要但在面试高压环境下清晰的命名能帮助你减少思维混乱也让面试官更容易理解你的思路。5.5 经典错误key在多轮循环中残留污染计数法里如果你把vectorint count(26, 0);定义在for循环外面每处理完一个字符串后必须手动fill(count.begin(), count.end(), 0)。如果忘了重置下一个字符串的频次就会叠加在之前的结果上导致key完全错误。我见过不少人在这个小小的细节上翻车。建议直接把count定义在循环内部每次循环自动初始化虽然消耗一点构造时间但大大降低了出错概率。6. 复杂度优化与工程落地扩展6.1 大规模数据下的并行加速如果把这道题放到真实工程环境里面对的是上亿条日志字符串单线程遍历可能不够快。因为每个字符串的key计算是相互独立的天然适合并行化。可以用C17的std::execution::par配合std::transform_reduce或者直接用OpenMP把for循环并行化。不过并行化处理有一个问题unordered_map的并发写入是不安全的。你需要在外层用锁或者采用“先计算key再统一分组”的两阶段策略。第一阶段并行计算每个字符串的key第二阶段单线程按key分组。这样就避开了并发写容器的问题。6.2 数字签名思路的借鉴其实这道题的核心就是在设计一个“签名”函数把字符串映射到一个固定格式的键上。这种思路在工程里很常见比如用哈希值做文件去重、用特征向量做相似图片检索本质都是“把复杂对象转化为可比较的签名”。理解了这层你就能把一道算法题的方法论迁移到实际系统中。做文件去重时如果只看文件大小不同文件可能大小相同但内容不同这是碰撞文件哈希如SHA-256就是更强大的签名。对应到这道题排序法和计数法的本质就是两种不同强度的“签名”算法。计数法在小写字母场景下是完美签名无碰撞排序法在大字符集场景下也是无碰撞因为排序结果完全保留了频次信息只是丢弃了顺序信息。6.3 如果字符集扩大到Unicode怎么办这题如果扩展到任意Unicode字符vectorint count(26)就不够了。你可以用unordered_mapchar, int来统计频次然后把这个map按字符排序后再编码成key。但这样操作的复杂度就不太好看了。更优的方案是依然用排序法因为排序法不依赖字符集。这也是为什么我在前面强调排序法有它的独特优势——它是一个在任何字符集下都通用的方案。6.4 从空间换时间与时间换空间的辩证看起这道题其实很好地体现了“空间换时间”的算法思想。无论是排序法还是计数法都在用额外的空间存储key换来的是分组时O(1)的哈希查找。如果完全不用额外空间那只能两两比较字符串是否互为异位词时间复杂度瞬间爆炸到O(n^2 * k)。这种权衡在工程中无处不在缓存、索引、预计算全是空间换时间的典型应用。7. 一些刷题之外的经验之谈最后分享一个个人体会。这道题我已经见过无数遍但每次教别人时还是会有新的收获。很多人刚开始刷题时喜欢追求解法的新奇看到别人用位运算、用质数乘法给每个字母分配一个质数乘积作为key就觉得酷。这个质数乘法的方案确实存在用一组质数替代频次统计key是一个大整数碰撞概率极低但要注意大整数溢出问题。在C里用unsigned long long也扛不住长字符串比如一万个z质数乘积会溢出成未定义行为。所以我的建议是面对这种经典题目优先掌握最稳妥、最通用的解法排序法和计数法把性能的极致探索留在课后自己玩。面试场上清晰、正确、有复杂度意识远比秀技重要。这道题你如果真的吃透了后面很多哈希表的题目都会变得轻松比如“找到字符串中所有字母异位词”、“最小覆盖子串”等它们的核心都是“如何快速判断两个字符串的字符频次是否相等”。把今天讲的计数法和滑动窗口一结合那些题其实都是这道题的一个变体。希望这篇拆解能帮你在哈希表这条路上少走一些弯路。如果你在实现中遇到了别的奇怪问题欢迎在评论区和大家交流我知道的都会尽量解答。