想找到一种“什么都能装”的数据结构,做算法题时立刻就能往里面塞东西、查东西?那一定绕不开哈希表这个概念,以及它在C++里的两个主力封装:unordered_map 和 unordered_set。
我第一次接触哈希表是在刷算法题的时候,被一道“两数之和”卡住。那时候我的第一反应是双重for循环,能过,但总觉得不够漂亮。直到看见别人用哈希表把时间复杂度从 O(n²) 降到 O(n),我才真正意识到:这不只是“快一点”的问题,而是换了一种思考方式——用“空间换时间”,关键是这个“换”经常划算得离谱。
这篇内容适合刚开始学 C++、或刚接触算法题、对 STL 里的 map 和 unordered_map 傻傻分不清的人。我会从哈希表的底层机制讲起,再讲 unordered_map 和 unordered_set 怎么用,并结合几个经典算法场景,把“为什么能 O(1) 查找”这件事彻底讲明白。后面还会聊到自定义哈希、reserve 调优、迭代器失效和 [] 陷阱这些实战中必踩的坑。
1. 哈希表到底解决什么问题:从“找东西”说起
如果你在没有任何工具的情况下,把 1000 本书放进一个大房间,第二天要在里面找某一本,你会怎么做?最简单的方式是一本一本翻——这就是数组/链表的线性查找,O(n)。如果书是按编号顺序排列的,你可以二分查找,O(log n)。但如果你能提前知道“这本书应该在哪个架子”,直接从那个架子上拿,那就是 O(1) 的感觉。
哈希表干的事,就是把“知道去哪找”这个能力做成通用的。
1.1 两步走:哈希函数算出位置,冲突处理兜底
哈希表的核心是一个数组(也叫 bucket 数组),外加一个哈希函数。你存一个 key,哈希函数算出它的“地址”,然后把这个 key 放到对应位置。查询时也是同一个函数算出地址,直接跳过去看有没有。
举个例子。你要存学生学号,假设哈希函数是学号 % 10,那么学号 2012 就放在下标 2 的位置。查询时也是2012 % 10 = 2,直接看下标 2 就行。这里最关键的是“同一个 key 每次哈希函数必须算出同样的值”。
但哈希函数不是完美的。两个不同的 key 可能算到同一个位置,比如学号 2021 和 3011 都对 10 取模得到 1,这就叫冲突。怎么处理冲突?C++ 标准库的做法是链地址法:数组的每个位置挂一个链表,冲突的元素就链在同一个位置后面。当链表很短时,查找只需算一次哈希再走几步链表,依然是 O(1)。反之,如果链表越来越长,复杂度就退化了。
正因为有冲突,我们才需要装载因子(load factor)这个概念。装载因子 = 已有元素数 / bucket 数组长度。元素越多,冲突概率越高,链表越长,性能越差。所以 unordered_map 会在装载因子超过阈值时自动扩容,重新把所有元素分配到更多桶里,保证平均链表长度可控。这个过程叫 rehash,后面讲性能时还会碰到它。
1.2 为什么是“均摊 O(1)”,而不是严格 O(1)
初学者最容易误解的,就是以为哈希表查一次永远是常数时间。严格来说,在没有冲突或者冲突不多时才是 O(1),一旦某个桶的链表很长,这一单次查找就是 O(链长)。只不过因为哈希函数设计合理、装载因子一般控制在 0.75 左右,链长通常非常短,均摊下来就是 O(1)。
你完全可以理解成“医院分诊”的逻辑。哈希函数把病人分到不同的科室,大部分科室只有一个人,你过去就能直接看到结果。但如果某个科室排了长队,你就要顺着队伍一个个找过去。哈希表的目标就是让每个科室尽量均匀地只有一两个人。
那么问题来了:C++ 里直接用现成的 unordered_map / unordered_set,是不是就够了?大多数时候是,但你也需要知道什么时候不能用、什么时候需要自定义哈希。带着这些疑问,我们进入实际的容器使用部分。
2. unordered_map / unordered_set 的日常使用姿势
unordered_map 是 key-value 形式的哈希表,unordered_set 只存 key 不存 value,两个头文件分别是<unordered_map>和<unordered_set>。底层的设计思路是一样的,只是 set 更轻量,专门用来判断某元素是否存在。
2.1 声明、插入、查找、删除:一张表快速上手
直接看代码比看长篇说明更直观。下面是一段非常典型的统计字符串出现次数的代码,也是哈希表在算法题里最常见的用法。
#include <iostream> #include <unordered_map> #include <string> int main() { std::unordered_map<std::string, int> freq; std::string s = "algorithm hash table algorithm"; std::string word; for (char c : s) { if (c == ' ') { freq[word]++; word.clear(); } else { word.push_back(c); } } if (!word.empty()) { freq[word]++; } for (const auto& p : freq) { std::cout << p.first << " -> " << p.second << std::endl; } return 0; }这里面最值得注意的就是freq[word]++。operator[]在 key 不存在时会自动插入一个默认值(int 就是 0),然后再自增。你不需要先 find 再 insert,这就是 unordered_map 比 map 在写代码时更顺手的地方之一。
不过这也带来一个隐患:如果你只是想查询某个 key 是否存在,直接写[]会无意之间把不存在的 key 插进去,改变容器内容。所以“纯查询”场景建议用 find:
auto it = freq.find("apple"); if (it != freq.end()) { // 存在,it->second 是值 } else { // 不存在 }删除则直接freq.erase("apple")。接受 key 的重载返回删除的元素个数,接受迭代器的重载返回下一个迭代器。这个细节在遍历删除时特别有用,后面专门讲。
2.2 遍历顺序不固定:这是“特性”不是“bug”
unordered_map 遍历时不会按 key 排序,甚至不会按插入顺序。它的顺序由 bucket 数量、哈希函数、冲突链的排布共同决定,理论上每次 rehash 后顺序都可能变。如果你需要按键从小到大遍历,应该用 map(底层红黑树,有序)。这个点对算法题很重要,因为“判断两个 unordered_map 是否相等”时,我们关心的是内容而不是顺序,一般可以用==直接用。
我自己的习惯是:需要统计、快速查找、计数、去重时,无脑 unordered_map / unordered_set;需要范围查询、找最接近的值、按键顺序输出结果时,才用 map。两者的复杂度对比可以总结成一张表:
| 维度 | map | unordered_map |
|---|---|---|
| 底层结构 | 红黑树 | 哈希表 |
| 查找复杂度 | O(log n) | 平均 O(1),最坏可能 O(n) |
| 插入复杂度 | O(log n) | 平均 O(1) |
| 遍历顺序 | 按键升序 | 无序 |
| 额外内存开销 | 每个节点含父子指针和颜色标记 | bucket 数组 + 链表的指针开销 |
| 适用场景 | 需要有序性、范围查询 | 纯查找、统计、去重 |
很多刚开始刷题的人会问:既然 unordered_map 查找更快,为什么不所有场合都用它?因为“平均 O(1)”背后有条件:哈希函数要足够好,装载因子不能太高。而 map 的性能稳定,最坏也是 O(log n),在需要保证时间上限的场景中反而更可靠。另外,如果你的 key 是不可哈希的自定义类型,用 map 需要实现operator<,而用 unordered_map 需要实现哈希函数和相等比较,哪个更方便要看具体类型。
3. 算法题里最常用的三类哈希套路(带代码)
哈希表的武器化使用方式就三种:统计频率、判断是否存在、记录位置。几乎所有入门算法题都能归到这几类里。下面我用三个经典场景拆开讲。
3.1 统计频率:一次遍历解决“出现次数”问题
求数组中出现次数最多的元素、判断两个字符串能否通过重排得到彼此、找出现次数超过一半的元素——这些都是“频率统计”的典型题。方法非常固定:遍历一遍,umap[val]++,然后再遍历一次找最大值。
字母异位词分组这类题也是同一个套路:用排序后的字符串作为 key,把每个单词分到组里。这里 key 是 string,value 是 vector ,很多时候你会觉得 unordered_map 的 key-value 类型可以任意组合,确实如此。
#include <vector> #include <unordered_map> #include <algorithm> #include <string> std::vector<std::vector<std::string>> groupAnagrams(std::vector<std::string>& strs) { std::unordered_map<std::string, std::vector<std::string>> mp; for (const std::string& s : strs) { std::string key = s; std::sort(key.begin(), key.end()); mp[key].push_back(s); } std::vector<std::vector<std::string>> res; for (auto& p : mp) { res.push_back(std::move(p.second)); } return res; }这里的核心思路是把“无法直接比较的属性”转成“可哈希的 key”,然后挂载相应的数据。算法题里很多“分组”类的题都可以用这种思路,比朴素的两两比较高效得多。
3.2 判断是否存在:unordered_set 的“去重”与“查重”
如果只是需要判断数组中某个元素是否出现过,完全没必要上 map,用 unordered_set 就够了。它比 unordered_map 少存一个 value,内存更省,语义也更清晰。
最长连续序列是这类题里的经典代表。题目要求找出数字连续的最长长度,比如数组[100, 4, 200, 1, 3, 2]里连续数列是1,2,3,4,答案是 4。最直接的想法是对数字排序然后线性扫一遍,但排序是 O(n log n)。用哈希表可以做到 O(n),前提是你只在“这个数是连续数列的起点”时才向后扩展。
int longestConsecutive(std::vector<int>& nums) { std::unordered_set<int> s(nums.begin(), nums.end()); int best = 0; for (int x : nums) { // 如果 x-1 也存在,说明 x 不是当前连续段的起点,跳过 if (s.count(x - 1)) continue; int y = x; while (s.count(y + 1)) { y++; } best = std::max(best, y - x + 1); } return best; }这个技巧很关键:不做“从每个数开始的扩展”,而是只从连续段的起点开始扩展,每个数最多被访问两次,整体是 O(n)。s.count(x)在 unordered_set 里就是 O(1) 的存在性判断,比在所有未访问元素里瞎撞优雅得多。
3.3 两数之和:用“补数”思维做空间换时间
几乎所有算法题库里都会出现的第一题就是两数之和:给定一个数组和一个目标值,找到两个数的下标让它们相加等于目标值。朴素解法是两层 for 循环,把所有 pair 都试一遍,O(n²)。当数组里有几万个元素时,这个复杂度根本不能看。
哈希表做这题的思路是:遍历到当前的数 x 时,去哈希表里找target - x是否存在,存在就直接返回结果;不存在就把 x 存进哈希表,继续往后走。因为哈希表的插入和查询都是 O(1),整体只需要一次遍历。
std::vector<int> twoSum(std::vector<int>& nums, int target) { std::unordered_map<int, int> pos; // value -> index for (int i = 0; i < nums.size(); i++) { int need = target - nums[i]; if (pos.count(need)) { return {pos[need], i}; } pos[nums[i]] = i; } return {}; }这里的“补数”思想比代码本身更重要:一次遍历里,我们边查边存,让每个元素在遇到它的时候,前面所有元素的信息都已经在哈希表里了。空间上多花 O(n),时间上从 O(n²) 降到 O(n),这就是典型的用空间换时间。
4. 自定义哈希与性能调优:让哈希表真正跑得快
初学时你会发现 unordered_map 用起来很简单,因为它已经支持基本类型和 string 的哈希。但你把一个自定义 struct 放进去,编译器会直接报错。这不是 bug,而是 C++ 的标准库没法知道你的结构体应该怎么被换算成一个整数下标。
4.1 给自定义类型做哈希:三步走
假设你有一个坐标点 Point {x, y},想用 unordered_set 判断一组点是否出现过。必须提供两样东西:一个哈希函数和一个相等判断。相等判断用operator==重载,哈希函数可以写成一个自定义仿函数,也可以做std::hash的特化。
struct Point { int x, y; bool operator==(const Point& other) const { return x == other.x && y == other.y; } }; struct PointHash { size_t operator()(const Point& p) const { // 常见的哈希组合:把 x 的哈希和 y 的哈希兼容起来 return std::hash<int>()(p.x) ^ (std::hash<int>()(p.y) << 1); } }; std::unordered_set<Point, PointHash> s;为什么要(std::hash<int>()(p.y) << 1)再异或?如果不移位,(5, 5)和(6, 6)这类 x 和 y 相同的点会得到同样的哈希结果,碰撞概率就会上升。稍微移位一下,能让两个整数的高低位错开,减少冲突。更专业的写法是借鉴一个经验常数——黄金分割数 0x9E3779B9,把它混进组合哈希里,这比简单移位更均匀。在入门阶段,移位异或已经比不做任何处理好很多。
4.2 reserve 与 max_load_factor:两个容易忽略的调优手段
当你提前知道会往哈希表里放多少个元素时,用reserve预先分配 bucket 是最该养成的习惯。因为哈希表扩容(rehash)时要重新计算所有元素的哈希并搬进新 bucket 数组,是一件开销很大的操作。如果数据量是百万级,不做 reserve 可能扩容十几次,每次都要重新搬一次,时间瞬间暴涨。
一个很简单的实测场景:往 unordered_set 里连续插入 1000 万个不重复整数。第一种做法是不做任何设置,第二种做法是先 reserve(10000000),后者往往比前者快一个数量级。原因就是扩容次数从十几次变成了零次或极少。
max_load_factor控制着装载因子的阈值。默认是 1.0,意思是 bucket 数等于元素数时才扩容。如果你把阈值调小到 0.5,存放同样数量的元素需要两倍 bucket,但查找时每个链表更短,单次访问更快。如果你的场景有大量查询、插入相对少,牺牲一点内存换速度是值得的:
std::unordered_map<int, int> mp; mp.max_load_factor(0.7); // 尽量在元素还没铺满桶之前就扩容 mp.reserve(100000); // 预知大约 10 万元素不过 reserve 和 max_load_factor 的顺序也有讲究。先设置max_load_factor再reserve,这样 reserve 才会根据预期装载因子计算出合适的 bucket 数。反过来,先 reserve 再改 factor,布局可能不是最优。
4.3 哈希退化:为什么有时候 unordered_map 反而更慢
理论上有种情况会让哈希表从 O(1) 退化成 O(n):所有 key 都映射到同一个 bucket 上。比如你的哈希函数写得极烂,把任何整数都 return 0,那所有元素都挂在一条链表上,查询复杂度就是链表的长度。
在实际的算法题里,某些输入可能被刻意构造,让默认哈希函数大量冲突,导致 unordered_map 被“卡”成 O(n²) 甚至更差。很多 OJ 选手都知道这种情况,应对办法之一是给哈希表换一个更随机、更分布均匀的哈希函数,或者换成平衡树版本来保证最坏复杂度。对初学者来说,不需要太深入这个对抗细节,但应该明白“哈希表是平均性能优秀,不是最坏性能优秀”。
5. 常用但容易踩坑的地方:迭代器失效、[]陷阱与选型建议
我在实际用 unordered_map 的过程中踩过不少坑,很多坑不是语法错误,而是“逻辑上没报错,但行为匪夷所思”。下面这几个问题绝对是出现频率最高的。
5.1 迭代器失效:rehash 是万恶之源
unordered_map 的迭代器在 rehash 之后可能全部失效。因为 rehash 会重新分配 bucket 数组,原来迭代器指向的节点虽然可能还在(某些实现里节点本身没有动),但哈希表会把它们挪到新的 bucket 链里,迭代器内部持有的桶指针就过时了。
遍历过程中如果插入了新元素,并且可能触发 rehash,此时你再继续用之前的迭代器就危险了。一个常见场景是在循环里做插入,比如:
// 危险写法:遍历时插入新元素可能触发 rehash for (auto it = mp.begin(); it != mp.end(); ++it) { if (/* 条件 */) { mp[newKey] = newVal; // 可能使 it 失效 } }安全做法是先用一个 vector 记录需要插入的 key,遍历结束后再统一插入;或者利用 insert 的返回值去递进迭代器。insert 返回pair<iterator, bool>,如果插入成功,iterator指向新节点;如果插入失败(key 已存在),iterator指向已有节点。利用这个返回值可以避免另一次查找。
删除操作也有讲究。erase(it)的返回值在 C++11 之后是下一个迭代器,但早期一些人会习惯先auto next = it; ++next; erase(it); it = next;。C++11 后直接it = mp.erase(it);就好。不过要注意,删除操作本身不会让其他迭代器失效,因为它只动了当前节点所在的链表。
5.2 [] 运算符的“自动插入”陷阱
这个坑我见过太多人踩了。你只是想去mp[key]看有没有值,结果它没有的话自动插入了一个默认构造的值。对 int 来说,就是插入了 0。结果是容器内容变了,size 变了,如果你的逻辑依赖 size 判断边界,错误就出现了。
更隐蔽的是,如果 value 类型是构造函数开销很大的类型,比如unordered_map<string, vector<int>>,你只是访问mp["foo"],结果就插入了一个空的 vector。这是完全没必要的成本。所以只查询时优先用find:
auto it = mp.find("foo"); if (it != mp.end()) { // 拿到值:it->second }如果你想“有就取,没有就设置默认值”,那operator[]是方便的。但如果你是“有就取,没有就不动”的语义,必须用 find 或者 at。at在 key 不存在时会抛 out_of_range 异常,在必须保证容错时很有用,但不要指望 at 的默认行为是“返回默认值”。
5.3 什么时候不该用哈希表:数组、map 还是 unordered_map
哈希表不是万能的。如果 key 的取值空间很小,比如字符 'a' 到 'z',直接开一个长度为 26 的数组做统计,速度比 unordered_map 还快,因为数组是真正的恒定时间、无冲突、无哈希计算,内存开销也更小。
如果 key 是整数且范围已知不超过 10^6,也可以考虑直接用 vector 或数组。只有 key 范围很大、无法预知、或者是字符串和自定义类型时,哈希表才显示出优势。很多入门算法题,比如字符串中的字符统计,用数组就已经是最优解了。
另外,如果题目要求在遍历时保持有序,或者需要频繁查前驱后继,map 是更合理的选择。比如“按分数从低到高输出学生信息”这种需求,用 map 天然按 key 排好序,直接遍历就完成了;用 unordered_map 则还要另建数组排序,反而多此一举。
5.4 自查清单:一段覆盖大多数坑的示例
最后放一个综合示例,把常见用法和避坑方式放在一起,方便照抄和自查:
#include <unordered_map> #include <unordered_set> #include <iostream> #include <vector> int main() { std::unordered_map<int, std::string> mp; // 1. 安全插入:检查返回值 auto res = mp.insert({1, "one"}); if (!res.second) { std::cout << "key 1 已存在,值为 " << res.first->second << std::endl; } // 2. 只查询不插入 auto it = mp.find(1); if (it != mp.end()) { std::cout << it->second << std::endl; } // 3. 遍历删除(C++11 之后) for (auto it = mp.begin(); it != mp.end();) { if (/* 删除条件 */ false) { it = mp.erase(it); } else { ++it; } } std::unordered_set<int> s; s.reserve(100000); // 预估数量,减少 rehash return 0; }先用 find 判断,再用 [] 写入,是一个不容易出错的组合顺序。如果你能自觉做到“查询用 find,插入才用 []”,你就绕开了哈希表操作里最经典的坑。
说到底,哈希表看起来是一行mp[key]++就能用的黑盒,但复杂度保证、内存开销和迭代器安全性都藏在底层。我第一次用的时候也完全没搞懂为什么有时快有时慢,后来花时间把 bucket、冲突、rehash 这些概念理了一遍,再看代码里那些“莫名其妙的问题”就全通了。个人建议你学这个主题时,别急着背 API,先在纸上画一画“数组+链表”的结构,模拟一次插入、一次查询、一次扩容,再去写代码。画过一遍之后,你会发现自己对 unordered_map / unordered_set 的理解,已经比很多人强一个档次了。