
数据结构里能让我又爱又恨的哈希表绝对算一个。面试的时候它是常客刷题的时候它是利器工程上几乎所有高性能场景都有它的影子。今天就把这个老朋友从头到尾捋一遍从底层原理到工程实现从冲突处理到扩容机制最后再盘点几个高频面试题的实际用法保证看完之后你对哈希表的理解能上个台阶。1. 哈希表的底层原理一个数组加上一个魔法函数很多人初学哈希表的时候会觉得它像变魔术一样神奇一个字符串、一个对象扔进去马上就能 O(1) 拿到对应的值。但实际上哈希表的本质特别简单就是“数组 哈希函数”。1.1 哈希函数到底做了什么数组的寻址是 O(1) 的这是计算机组成原理就教过的基本事实——因为数组在内存里是连续分配的只要知道起始地址和下标一次加减法就能算出目标内存地址。哈希表想享受这个待遇就必须把“任意键”转换成“合法的数组下标”。这个转换动作就是哈希函数。常见的做法是两步走// 第一步把任意类型的键转成一个整数哈希码 // 对 int 来说就是本身对字符串来说要设计一个算法算出来 int hash hashCode(key); // 例如字符串 hello // 第二步把整数压缩到数组范围内取模 int index hash % capacity; // capacity 是数组长度你可能会问为什么要分两步而不是一步搞定因为哈希函数要解决的是“平等对待所有可能的键”而取模只是负责“把范围压缩”。分开设计的好处是哈希码本身可以尽可能均匀分布压缩这一步只是最后的物理落地。1.2 为什么查询能 O(1)哈希表的查询过程堪称暴力但优雅计算哈希码取模落到数组下标如果这个位置上恰好有元素就返回如果没元素就说明键不存在。整个过程不依赖数据规模 n无论表里有一万个数据还是有一亿个数据计算步骤几乎一样多所以是 O(1)。这就是哈希表最核心的卖点。对比一下数组查询是 O(1) 但要按位置存插入慢链表插入快但要查着找查询 O(n)而哈希表把这两者的优点结合了靠的就是哈希函数这个“中间人”。1.3 哈希冲突是逃不掉的宿命既然数组长度有限而键的可能取值是无限的那一定会出现两个不同的键算出来的下标相同的情况这就是哈希冲突。以 int 为例你的数组长度是 10那么 7 和 17 取模 10 都是 7它俩就撞车了。这不是设计失误而是数学上的必然——把无穷映射到有限必然会有碰撞。所以哈希表真正的技术含量体现在两个地方哈希函数设计得好不好决定了冲突多不多冲突发生之后怎么处理决定了哈希表能不能继续工作2. 冲突处理策略拉链法、开放寻址法以及为什么生产环境偏爱前者处理哈希冲突业界主要有两条技术路线。一条叫拉链法也叫链地址法一条叫开放寻址法。我在写自己的哈希表和学习 STL 源码的过程中对这两种方案的理解算是比较透彻的这里分别展开说说。2.1 拉链法数组加链表简单粗暴有效拉链法的思路非常容易理解数组的每个桶bucket不直接存键值对而是存一条链表的头节点。当多个键冲突到同一个桶时就在这条链表后面追加节点。struct Node { int key; int value; Node* next; Node(int k, int v) : key(k), value(v), next(nullptr) {} }; class HashMap { private: vectorNode* buckets; int size; float loadFactor; public: HashMap(int cap 16, float lf 0.75f) : buckets(cap, nullptr), size(0), loadFactor(lf) {} void put(int key, int value) { int idx hash(key) % buckets.size(); Node* head buckets[idx]; // 先查是否已存在存在则更新 for (Node* p head; p ! nullptr; p p-next) { if (p-key key) { p-value value; return; } } // 头插法插入新节点 Node* newNode new Node(key, value); newNode-next head; buckets[idx] newNode; size; // 检查是否触发扩容 if (size buckets.size() * loadFactor) { resize(); } } };拉链法的优势在于实现简单、对哈希函数的要求相对宽松而且删除操作也好做。缺点就是极端情况下链表会变得很长从 O(1) 退化成 O(n)所以后来 Java 8 的 HashMap 引入了红黑树优化当链表长度超过 8 就转树。C 这边的 std::unordered_map 实现也类似不过用的是单向链表和迭代器指针的组合。2.2 开放寻址法没有链表只在数组里腾挪开放寻址法和拉链法思路完全不一样。它不引入任何额外存储结构当冲突发生时就在数组里继续往后找空位最常见的探测方式是线性探测。class OpenAddressingHashTable { private: vectorint keys; vectorint values; vectorbool used; int size; int capacity; public: OpenAddressingHashTable(int cap 16) : keys(cap), values(cap), used(cap, false), size(0), capacity(cap) {} void put(int key, int value) { int idx hash(key) % capacity; // 线性探测往后找空位 while (used[idx] keys[idx] ! key) { idx (idx 1) % capacity; } if (!used[idx]) { used[idx] true; size; } keys[idx] key; values[idx] value; } int get(int key) { int idx hash(key) % capacity; while (used[idx]) { if (keys[idx] key) return values[idx]; idx (idx 1) % capacity; } return -1; // not found } };开放寻址法的好处是存储紧凑、缓存友好因为所有数据都在数组里没有指针跳转。但它的缺点也明显删除操作很麻烦不能直接清空位置否则会断掉探测链需要引入“墓碑”标记另外它对装载因子非常敏感一旦超过 0.7 左右探测步数会急剧上升。2.3 两种方案怎么选生产环境怎么做的从我的实际观察来看生产环境里拉链法明显更流行。Java 的 HashMap、C 的 unordered_map、Go 的 map虽然不是严格意义的拉链法但底层也是桶链结构基本都是链式思路的变体。开放寻址法最常见的应用场景是 Redis 的字典实现以及一些对缓存命中率要求极高的内存库。核心考量是这样的拉链法需要额外的指针存储内存占用高一点但对装载因子的容忍度高开放寻址法省内存、缓存友好但是得严格控制装载因子不适合频繁删除的场景所以你要是在高性能服务端做选型优先拉链法准没错如果你在做嵌入式或者超大内存的字典可以研究下开放寻址法。3. 哈希函数设计为什么字符串哈希和整数哈希是两回事哈希函数是整个哈希表的心脏。一个设计得好的哈希函数能让冲突概率降得很低设计得差的会让哈希表名存实亡直接退化成链表。3.1 整数哈希取模就够了没那么简单对整数键最简单的做法就是直接取模。但直接用 key % capacity 有个隐患如果 key 的分布有规律比如全是偶数那么取模之后的结果也会呈现规律性。比如 capacity 是 16所有键都是 4 的倍数那么哈希结果就只会落在 0、4、8、12 这四个桶里其余 12 个桶全部浪费。这时候就需要“扰动”——把高位信息混合到低位里。Java 的 HashMap 里有一个经典的扰动函数hash key ^ (key 16)。它的思路是把高 16 位和低 16 位做异或这样即使低位相同高位不同整体哈希值也会有区分度。3.2 字符串哈希BKDRHash 与乘法散列字符串哈希就要复杂一些了。最常见的做法是类似 BKDRHash 的多项式滚动哈希思想是像算一个多项式一样把每个字符当系数用一个大素数做基size_t bkdrHash(const char* str) { size_t hash 0; const size_t seed 131; // 也可以是 31, 1313, 13131 等 while (*str) { hash hash * seed *str; } return hash; }这个 seed 的选择有讲究太小容易产生大量冲突太大的话中间溢出可能性增加不过 unsigned int 的溢出是取模的所以也没关系。实测 131 这个种子在大部分场景下表现都很稳冲突率低性能也够快。另外要特别提醒一点不要用简单的字符相加作为字符串哈希函数。比如 abc 和 cba 在这种情况下算出来是一样的因为加法满足交换律这是很容易踩的坑。3.3 自定义对象做 key必须同时重写 equals 和 hash这个知识点在面试里几乎是必考的而且也是工程里最容易出 bug 的地方。如果你定义了一个自定义对象作为哈希表的 key必须同时正确实现 equals 方法和 hashCode 方法。原因很简单哈希表先通过 hashCode 定位桶再通过 equals 在桶内找精确匹配。如果两个对象 hashCode 相同但 equals 返回 false它们会乖乖待在同一个桶里如果 hashCode 不同但 equals 返回 true那它们永远无法被同一个哈希表找到这违背了键的唯一性。举例说明假设你用二维平面上的 Point 类做 key只重写了 equals 没重写 hashCode那么两个内容相同的 Point 对象放到哈希表里会算出来不同的桶get 永远返回 null。这个 bug 排查起来非常隐蔽非常容易让人崩溃。4. 负载因子与扩容机制为什么 Java 默认 0.75哈希表有一个关键参数叫负载因子load factor定义为“已存储元素数 / 桶的数量”。负载因子越高空间利用率越高但冲突概率也越高负载因子越低空间浪费越多但查询效率有保障。4.1 为什么 0.75 是普遍共识Java 的 HashMap 默认负载因子是 0.75C 的 unordered_map 默认 max_load_factor 也是 1.0 左右Go 的 map 装载因子是 6.5/8 ≈ 0.8125。为什么大家都不约而同选了一个接近 0.75 的值这里有个数学背景对理想哈希函数当负载因子为 0.75 时单个桶出现冲突的概率仍然很低。用泊松分布近似可以算出桶里链表长度超过 8 的概率大约是千万分之六。这也是为什么 Java 8 的 HashMap 把“链表转红黑树”的阈值设成 8——链表长度到 8 几乎不可能真到了说明哈希函数出了问题或者有恶意攻击。从实际工程角度来说0.75 是时间与空间的平衡点再大一点比如到 0.9空间确实省了但查询变慢再小一点比如 0.5查询更快了但一半内存空着浪费。4.2 扩容时发生了什么rehash 是重灾区当元素数量超过 capacity × loadFactor 时哈希表必须扩容。扩容不是简单地数组翻倍再拷贝旧数据而是要把所有旧元素重新计算哈希、重新分配到新数组里这个过程叫 rehash。void resize() { size_t newCapacity buckets.size() * 2; vectorNode* newBuckets(newCapacity, nullptr); // 重新哈希每个桶里的节点 for (Node* p : buckets) { while (p ! nullptr) { Node* next p-next; size_t newIdx hash(p-key) % newCapacity; p-next newBuckets[newIdx]; newBuckets[newIdx] p; p next; } } buckets.swap(newBuckets); }注意一个关键细节扩容后数组长度变了取模的结果也会变所以每个元素的桶位置大概率会改变。这个过程是 O(n) 的单个插入操作的摊还成本才是 O(1)。这就是摊还分析amortized analysis的思想——偶尔的一次扩容虽然贵但平均下来每次插入的成本依然常数。4.3 预分配容量是热门优化手段如果你能预估元素数量最好在初始化时就指定容量避免多次扩容。假设你要存 10000 个元素而且使用默认负载因子 0.75那么初始化容量应该设为 10000 / 0.75 ≈ 13334向上取一个 2 的幂就是 16384。// 预先分配足够容量避免多次 rehash std::unordered_mapint, int mp; mp.reserve(16384);rehash 是非常昂贵的一次不仅要把旧元素全部重新摆放还涉及内存分配和数据迁移。在循环里往哈希表扔数据的时候如果没预先 reserve你会发现性能忽高忽低那就是在反复扩容。5. C 里的哈希表unordered_map 使用与源码思路刷题和工程里C 用得最多的哈希表容器是 unordered_map 和 unordered_set。很多新手总把这两个和 map/set 搞混这里把区别和底层思路讲清楚。5.1 unordered_map 和 map 的区别面试里高频考题之一就是 unordered_map 和 map 的区别。一句话总结map 底层是红黑树元素有序操作 O(log n)unordered_map 底层是哈希表元素无序操作平均 O(1)。对比项mapunordered_map底层结构红黑树哈希表元素顺序按键排序无序插入/查询复杂度O(log n)平均 O(1)最坏 O(n)适用场景需要有序遍历、范围查询纯查找、频繁插入内存占用节点额外存储左右指针和颜色桶数组 节点指针特别提醒刷算法题时如果题目不要求有序输出用 unordered_map 绝对比 map 快不少尤其是大量插入的场景。我在 LeetCode 上做哈希表专题时很多题用 map 写会超时换 unordered_map 就过了差距就在这。5.2 STL 哈希表的实现要点C 标准库里的 unordered_map 用的是拉链法底层是一个 __detail::_Mod_range_hashing 的哈希函数和 hashtable 结构。它的设计相比 Java 的 HashMap 要复杂一些因为它需要支持迭代器在桶之间跳转所以桶里的节点还有一个指向上一个桶尾节点的前向指针这是为了支持 O(1) 的 erase 和迭代器自增。使用层面有三个经验值得分享// 1. 查键是否存在注意 operator[] 的坑 if (mp.find(key) ! mp.end()) { // 存在 } // 2. 避免 operator[] 隐式插入 int val mp[key]; // 如果 key 不存在会插入一个默认值 // 正确写法 auto it mp.find(key); int val (it ! mp.end()) ? it-second : 0; // 3. 批量插入时先 reserve mp.reserve(100000);5.3 哈希表和字典、关联数组的关系很多热词里都有“哈希表和字典的区别”。实际上字典dictionary是一个逻辑概念——一种通过键来查找值的抽象数据类型哈希表是实现字典的一种具体数据结构。其他实现字典的方式还包括平衡树、跳表、字典树等。在 Python 里叫 dict在 Java 里叫 HashMap在 C 里叫 unordered_map在 Go 里叫 map——它们都是字典接口的哈希表实现。你可以把“字典”理解成“接口”把“哈希表”理解成“实现方式”这两者不是同一层级的概念。6. 哈希表在算法题中的高频应用从两数之和到前缀和优化哈希表在算法题中的存在感极强。很多看起来需要 O(n^2) 甚至更多时间的问题引入哈希表就能降到 O(n)。这里盘点几类最经典的应用模式每一类都有固定的套路可循。6.1 经典入门两数之和LeetCode 第 1 题“两数之和”算是哈希表应用的教科书案例。题目就在一个数组里找两个数让它们的和等于 target。暴力做法是两层循环枚举每一对数时间复杂度 O(n^2)。用哈希表优化的话思路变成了“一边遍历一边记录”每遍历到一个数 x就去查 target - x 是否已经出现过。vectorint twoSum(vectorint nums, int target) { unordered_mapint, int hash; // 值 - 下标 for (int i 0; i nums.size(); i) { int complement target - nums[i]; if (hash.count(complement)) { return {hash[complement], i}; } hash[nums[i]] i; } return {}; }这个解法的精妙之处在于它把“未来的查询”变成了“过去的记录”。遍历到后面的元素时前面所有元素已经被塞进哈希表了所以每次查询只需要 O(1) 的时间。空间换时间这是哈希表最朴素也最核心的思想。6.2 去重与计数字母异位词分组LeetCode 第 49 题“字母异位词分组”是另一个高频题。它要求把互为异位词的字符串比如 eat、tea、ate分到同一组。常见的解法有两种一种是把字符串排序后作为 key排序是 O(k log k)其中 k 是字符串长度另一种是用字符计数数组拼字符串当 key相比排序可以优化到 O(k)。vectorvectorstring groupAnagrams(vectorstring strs) { unordered_mapstring, vectorstring mp; for (string s : strs) { string key s; sort(key.begin(), key.end()); mp[key].push_back(s); } vectorvectorstring res; for (auto kv : mp) { res.push_back(kv.second); } return res; }这个题的启示是哈希表的 key 不一定非得是原始输入你可以根据题目需要把输入变换成一个“规范化形式”作为 key。这种“自定义 key”思维比背模板更重要。6.3 区间问题最长连续序列LeetCode 第 128 题“最长连续序列”要求在线性时间内找出数组中最长的连续数字序列长度。没有哈希表几乎不可能做到线性时间。经典解法是先把所有数放进 unordered_set然后遍历每个数只从连续序列的起点开始统计长度。判断起点的依据是“当前数的前一个数是否存在于集合中”。int longestConsecutive(vectorint nums) { unordered_setint numSet(nums.begin(), nums.end()); int best 0; for (int num : numSet) { // 只从连续段起点开始 if (!numSet.count(num - 1)) { int curNum num; int curLen 1; while (numSet.count(curNum 1)) { curNum; curLen; } best max(best, curLen); } } return best; }别看这个题代码不长它考察了两个层面的能力第一是认识到集合是哈希表实现的查找 O(1)第二是去重优化——“起点只处理一次”避免内部 while 造成整体 O(n^2) 的复杂度。6.4 前缀和优化连续子数组和另一类高频题是“和为 K 的子数组”这类题的核心优化手段是“前缀和 哈希表”。前缀和数组 prefix[i] 表示前 i 个元素的和那么区间 [j1, i] 的和等于 prefix[i] - prefix[j]。要快速判断有多少个 j 能让差值为 K就需要哈希表来记录每个前缀和出现的次数。int subarraySum(vectorint nums, int k) { unordered_mapint, int prefixCount; prefixCount[0] 1; // 前缀和为0出现一次 int sum 0, res 0; for (int num : nums) { sum num; res prefixCount[sum - k]; prefixCount[sum]; } return res; }这类题目看起来和哈希表没直接关系但本质上是把“暴力枚举区间起点”的问题转换成“查找前缀和差值”的问题。前缀和加哈希表是一对黄金搭档遇到子数组、子序列满足某种条件的问题可以优先往这个方向想。6.5 LRU 缓存哈希表加双向链表LRU最近最少使用缓存是面试中出现频率极高的设计题它需要哈希表和双向链表的结合。哈希表负责 O(1) 查找双向链表负责 O(1) 的插入删除和顺序维护。struct DLinkedNode { int key, value; DLinkedNode* prev; DLinkedNode* next; DLinkedNode() : key(0), value(0), prev(nullptr), next(nullptr) {} DLinkedNode(int k, int v) : key(k), value(v), prev(nullptr), next(nullptr) {} }; class LRUCache { private: int capacity; int size; DLinkedNode* head; DLinkedNode* tail; unordered_mapint, DLinkedNode* cache; void moveToHead(DLinkedNode* node) { removeNode(node); addToHead(node); } // ... 省略部分实现 };为什么不用数组或单纯链表因为哈希表只负责快速定位节点是否在缓存里但拿到节点后还需要 O(1) 地把它挪到链表头部——这就要靠双向链表完成单向链表做不到 O(1) 删除指定节点。这种“哈希表 链表”的组合是很多高级数据结构的雏形。7. 哈希表的复杂度分析到底哪些操作是 O(1)哪些会退化网上关于哈希表复杂度的说法五花八门很多写得不严谨。这里给出我认为最准确的理解框架。7.1 平均情况、摊还情况与最坏情况说“哈希表读写是 O(1)”严格来说是“平均情况”下的结论。这个 O(1) 成立的前提是哈希函数设计良好能均匀分布负载因子被控制在合理范围扩容发生的频率足够低如果这些前提被打破最坏情况下所有元素哈希到同一个桶哈希表就退化成一条链表复杂度退化为 O(n)。这就是为什么有些注入攻击选择精心构造碰撞字符串恶意地把服务端哈希表打挂。7.2 摊还分析理解扩容的均摊成本扩容虽然是 O(n) 的操作但扩容次数很稀疏。假设每次容量翻倍从一个容量为 1 的表开始插入 n 个元素总共扩容 log(n) 次每次扩容复制那个时刻的已有元素数量加起来是 1 2 4 ... n/2 ≈ n。所以摊还到每次插入额外成本仍然是常数。这个推理过程就是摊还分析里的“聚合分析”。理解了这个你就能解释为什么 STL 的 unordered_map 插入的均摊复杂度能维持在 O(1)即使它的扩容看起来很昂贵。7.3 空间复杂度哈希表的空间复杂度不是 O(n) 这么简单。桶数组本身占用 capacity 的空间每个节点还要存键、值、指针。在拉链法下实际内存开销是“桶数组 节点对象”。这也是为什么哈希表不适合存超大对象的原因——空间浪费比例不好控制。我做过一个实际对比实验把 100 万个整数分别存进 vector 和 unordered_set后者大概多占用 40% 左右的内存。这在大多数场景下是可接受的但如果你在做内存敏感的大数据系统就要仔细考虑这个成本。8. 工程实践与避坑指南哈希表生产中容易踩的坑哈希表看起来简单但真正在生产环境用的时候坑比想象中多得多。这里把我踩过的和一些同事踩过的坑总结出来全是血泪教训。8.1 自定义类型当 key 必须重写哈希和相等这个在前面已经提过但还是要单独拎出来强调一遍。尤其是在 C 里自定义类作为 unordered_map 的 key你要么提供 std::hash 的特化要么传入自定义哈希函数对象。struct Person { string name; int age; bool operator(const Person other) const { return name other.name age other.age; } }; // 自定义哈希函数 struct PersonHash { size_t operator()(const Person p) const { return hashstring()(p.name) ^ (hashint()(p.age) 1); } }; // 使用自定义哈希 unordered_mapPerson, int, PersonHash personMap;很多人重写了 operator 但忘了给哈希函数编译器直接就报编译错误倒是不会在运行期出问题。但最诡异的是那种重写了 hash 忘了重写 equals 的结构编译器不会报错运行时候查不到已插入的值这种 bug 排查起来非常痛苦。8.2 避免使用可变对象当 key如果你把对象作为哈希表的 key而这个对象在插入后被修改了哈希值也变了哈希表就“找不到”这个元素了。这是一个经典的坑在代码评审中我抓到过好多次。// 错误示范key 的哈希值在插入后变化 string key hello; mp[key] 1; key[0] j; // 此时 key 的哈希值变了 mp.count(hello); // 可能为 0 mp.count(jello); // 可能也为 0正确做法是哈希表 key 一旦插入就应该保持不可变。对于可变对象要么做成快照要么从设计上杜绝修改 key 的可能性。8.3 哈希碰撞攻击黑客的玩具了解安全领域的同学可能知道哈希碰撞拒绝服务攻击。攻击者构造大量字符串它们的哈希值完全相同全部扔进同一个桶哈希表的 O(1) 查询就退化成了 O(n) 扫描进而拖垮整个服务。在 C 里std::hash 的种子是固定的所以这种攻击在理论上可行。Java 的 HashMap 用引入了随机种子来缓解这个问题Python 的 dict 也做了字符串哈希随机化。应对方案通常有两个一是限制哈希表的总量比如提前设置 max_load_factor 和容量上限二是使用带随机种子的哈希函数。无论哪种方案都要意识到哈希表不是天然的“防攻击”数据结构。8.4 浮点数做 key 的陷阱我不止一次看到有人用 double 或 float 作为哈希表的 key然后发现查不到想要的值。原因非常简单浮点数存在精度问题0.1 0.2 在计算机里并不等于 0.3。正确做法是不要直接用浮点数做 key可以转成整数乘一个缩放系数再取整再哈希或者用十进制字符串表示。// 不推荐浮点数直接做 key map[0.1 0.2] 1; // 查 map[0.3] 可能失败 // 推荐把金额换算成分整数 map[30] 1; // 0.30 元 30 分8.5 线程安全哈希表不是线程安全的C 的 unordered_map 在多线程环境下并发读写会产生未定义行为这是初学者踩得最多的坑之一。解决方案有几种加互斥锁简单粗暴但并发能力差使用读写锁读多写少时有优势用 C17 的 std::shared_mutex 做细粒度控制更高性能的方案是自己实现分段锁哈希表类似 Java ConcurrentHashMap 的思路9. 哈希表的面试考点与刷题思路从入门到进阶的路线图哈希表相关的面试题可以说是算法面试的半壁江山。根据我的观察面试官从浅到深一般会问这么几层9.1 基础层原理与复杂度这一层的问题通常是“哈希表底层怎么实现的”“为什么查询是 O(1)”“冲突怎么解决”。关键是能画出来并说清楚拉链法和开放寻址法的区别最好能顺手写出一个带扩缩容的最小哈希表。9.2 应用层经典题型这一层就是各种应用场景题目。除了前面提到的两数之和、异位词分组、最长连续序列、前缀和与 LRU还有这些高频题值得练手LeetCode 1. Two Sum两数之和LeetCode 49. Group Anagrams字母异位词分组LeetCode 128. Longest Consecutive Sequence最长连续序列LeetCode 560. Subarray Sum Equals K和为 K 的子数组LeetCode 146. LRU CacheLRU 缓存LeetCode 380. Insert Delete GetRandom O(1)常数时间插入删除随机获取每道题做完之后建议都思考一个问题如果不用哈希表能怎么做复杂度会变成什么这样才叫真正理解。9.3 进阶层哈希表与并发、持久化再往上就会问一些有难度的问题比如“如何设计一个线程安全的哈希表”“哈希表如何支持持久化到磁盘”“哈希表容量怎么动态调整且不长时间阻塞服务”。这些需要你真正理解哈希表内部机制尤其是扩容与 rehash 的过程。9.4 刷题时的两个实用技巧第一个技巧是识别题目特征。一般来说看到“判断是否存在”“统计出现次数”“快速查找匹配项”这些关键词基本就可以考虑哈希表。尤其是题目数据范围较大、要求 O(n) 解法时哈希表往往是隐藏的答案方向。第二个技巧是注意空间换时间的代价。哈希表虽然快但空间开销不小。面试的时候如果你给出了哈希表方案最好能顺带分析一下内存占用这会给面试官留下好印象——说明你不只背了模板而是真懂权衡。10. 手写一个完整的哈希表从零到一的全过程演练最后用一个可以动手练习的完整案例收尾。我建议每个人都至少手写一次哈希表不要只是用 STL。手写一遍你对哈希表的理解会完全不同。10.1 设计目标与接口定义这里我写一个基于拉链法、支持自动扩容、支持删除操作的泛型哈希表。为了可读性简化成 int 到 int 的映射核心逻辑和通用版本完全一致。#include iostream #include vector using namespace std; class MyHashMap { private: struct Node { int key; int val; Node* next; Node(int k, int v) : key(k), val(v), next(nullptr) {} }; vectorNode* table; int size; int capacity; float loadFactor; int hash(int key) { // 用 unsigned 避免负数取模问题 return (int)(((unsigned)key * 2654435761u) 28) (capacity - 1); } public: MyHashMap() : size(0), capacity(64), loadFactor(0.75) { table.resize(capacity, nullptr); } void put(int key, int value) { int idx hash(key); Node* p table[idx]; while (p) { if (p-key key) { p-val value; return; } p p-next; } Node* newNode new Node(key, value); newNode-next table[idx]; table[idx] newNode; size; if (size capacity * loadFactor) { resize(); } } int get(int key) { int idx hash(key); Node* p table[idx]; while (p) { if (p-key key) return p-val; p p-next; } return -1; } void remove(int key) { int idx hash(key); Node* p table[idx]; Node* prev nullptr; while (p) { if (p-key key) { if (prev) prev-next p-next; else table[idx] p-next; delete p; size--; return; } prev p; p p-next; } } void resize() { int oldCap capacity; capacity * 2; vectorNode* newTable(capacity, nullptr); // 重新哈希 for (int i 0; i oldCap; i) { Node* p table[i]; while (p) { Node* next p-next; int newIdx hash(p-key); p-next newTable[newIdx]; newTable[newIdx] p; p next; } } table.swap(newTable); } int getSize() { return size; } ~MyHashMap() { for (Node* p : table) { while (p) { Node* next p-next; delete p; p next; } } } };这个实现里用的是乘法散列也叫斐波那契散列。乘数 2654435761 是黄金比例的二进制近似把 32 位 key 映射到高位再取前几位作为桶下标。这么做的好处是即使 key 本身有规律比如全是 2 的倍数映射后依然能均匀分布比直接取模更稳。10.2 测试用例与验证写完哈希表之后一定要做几组测试验证正确性。我的习惯是至少覆盖这些场景插入后能查到、更新已有 key、删除后查不到、触发扩容后依然能正确读写、大量随机数据与 STL 对比结果一致。int main() { MyHashMap mp; // 基本功能 mp.put(1, 100); mp.put(2, 200); cout mp.get(1) mp.get(2) endl; // 100 200 // 更新 mp.put(1, 300); cout mp.get(1) endl; // 300 // 删除 mp.remove(2); cout mp.get(2) endl; // -1 // 扩容测试 for (int i 0; i 100; i) { mp.put(i, i * 10); } cout mp.get(99) endl; // 990 // 负数 key mp.put(-5, 555); cout mp.get(-5) endl; // 555 return 0; }我在编写过程中碰到的最多的坑就是负数 key 的取模问题。C 里负数取模的结果可能是负数这会导致数组越界。解决思路就是先把 key 转成无符号数再参与计算或者直接取绝对值。乘法散列这个方案天然避免了这个坑因为先转成 unsigned 了。10.3 手写哈希表的收获手写一遍哈希表你对这些概念的理解是纯看书没办法比的你会理解为什么哈希函数和取模要分开设计你会理解扩容为什么是 O(n)以及摊还分析为什么成立你会理解链表的删除为什么需要前驱指针你会理解负数取模的坑是怎么来的最后再分享一个小技巧如果你是刷题刚入门的新手建议把所有哈希表相关的题目都集中在一个时段刷。刷完之后自己尝试把 STL 实现换成手写版本再跑一遍所有题目。这个练习做完哈希表这块基本就拿捏住了。另外一个实用的线上小工具是可以在一些可视化算法网站上输入数据亲眼看一看哈希表的插入、冲突、扩容过程。这种直观印象对建立心智模型非常有帮助比对着代码空想高效得多。哈希表确实是一个越用越觉得精妙的数据结构它的思想和演进过程绝对值得你花时间深挖。