
先聊个场景。你用unordered_map存了几百万个键值对查一次几乎感觉不到延迟换成map可能就慢了一个量级。哈希表这个东西平时被 STL 包得严严实实很多人用了一两年都不知道它到底怎么把查找做到 O(1) 的。这篇帖子就做一件事抛开封装用 C 手写一个简单版本的哈希表——能插入、查找、删除、扩容代码量很小但每个细节都是真家伙。它不是工业级实现却足够让你搞懂哈希表的核心机制。搞懂之后你再去看unordered_map的源码会有种茅塞顿开的感觉。适合刚学完 STL 想深入一层的朋友也适合面试前临时抱佛脚复习数据结构的人。1. 哈希表的本质一次跳转而不是逐格寻找1.1 查找问题的复杂度博弈先想一个问题给你一堆键值对用什么数据结构存查找最快数组最快O(1)但要求你知道下标。链表慢O(n)因为它只能从头一个个往后找。平衡二叉树是 O(log n)已经很快了但它每查一次都要做大约 log n 次比较。而哈希表做的是一件更“暴力”的事我不比较我直接算。你给我一个 key我用一个函数算出它应该放在哪个位置然后跳过去取。这个“函数”就是哈希函数。它的本质是把“任意类型的键”映射成一个“数组下标”。一旦映射完成查找就退化成了一次数组访问。这就把问题从“搜索”变成了“计算”。但天下没有免费的午餐。哈希函数不是完美的两个不同的 key 可能算出同一个位置这叫哈希冲突。冲突一旦出现你就不能只访问一个格子了得额外想办法处理。所以哈希表的真实复杂度不是严格的 O(1)而是“冲突很少情况下的 O(1)”。所有哈希表的设计本质上都是在跟冲突做斗争。1.2 好的哈希函数需要满足什么条件写哈希之前得先明确哈希函数的要求。第一确定性。同一个 key任何时候调用返回值必须一样。这是哈希能被当作数据结构基础的前提。第二分布均匀。一组 key 经过哈希之后应该尽量散落在不同的桶里。如果 100 个 key 都被映射到同一个位置那查找就退化成了在链表里找O(n) 直接回来了。分布均匀这件事一半靠函数设计一半靠表的大小和取余方式。第三计算快。哈希函数本身不能太重。你算一个位置花的时间如果比直接遍历还久那哈希就失去意义了。C 里很多标准库实现会刻意选择简单运算而不是复杂的加密哈希就是因为这个原因。第四对不同类型友好。整数可以直接转字符串就得设计一个进制展开式的算法自定义类型就得让用户自己提供哈希逻辑。这一点在模板实现里尤为重要后面代码部分你会看到我怎么处理。2. 冲突处理方案选型我为什么直接选链地址法2.1 开放定址法与链地址法的对比处理哈希冲突业界就两招开放定址法和链地址法。开放定址法的思路是既然这个位置被占了我就在附近找下一个空位。这叫线性探测更高级一点还有二次探测、双重哈希。它的优点是省内存不用额外指针缺点是冲突一旦聚集后面的查找会连续踩到前面占过的位置性能会雪崩。而且删除非常麻烦不能直接删空否则会断开探测链需要打“墓碑”标记。这些细节写出来又长又容易错不适合做教学演示。链地址法的思路完全不同每个桶不直接存数据而是存一个链表的头指针。冲突了就往链表里挂。C 的unordered_map、Java 的HashMap、甚至 Linux 内核里很多表用的都是这个思路。它实现直观删除容易负载因子控制合理的时候性能非常稳。我做教学版的模拟实现毫不犹豫选择链地址法。理由有三条结构清晰每一行代码都能对应到一个物理概念。删除操作是标准的单链表删除这是每个 C 程序员都该熟练的基本功。后续你想升级成高效版本链地址法的模型可以直接对接 STL 的实现思路。2.2 负载因子决定表什么时候“撑不住”负载因子load factor的计算公式是已有元素个数 / 桶个数。链地址法里负载因子等于平均每条链的长度。负载因子 0.5意思是平均每条链半个人多数桶是空的负载因子 2平均每条链 2 个节点查找要做两三次比较。这看起来也还好但注意哈希函数做不到绝对均匀必然有桶的链特别长。负载因子越高这种“特别长的链”出现得越频繁。所以哈希表都会设置一个扩容阈值。我说个直觉值链地址法一般把负载因子控制在 0.7 到 1.0 之间。JDK 的HashMap用 0.75C 标准库通常也在这个量级。超过阈值就翻倍扩容、重新哈希。用整数运算判断负载因子过界也有一点讲究。不要写成浮点除法直接用乘法n * 10 / bucketCount 7就等价于n / bucketCount 0.7。整数运算快还没有浮点误差。3. C手写哈希表核心代码与逐行拆解3.1 基础结构节点、哈希仿函数、哈希表主体先写节点。单链表节点就两个成员一个键值对一个 next 指针。template class K, class V struct HashNode { pairK, V _kv; HashNodeK, V* _next; HashNode(const pairK, V kv) : _kv(kv), _next(nullptr) {} };然后写哈希函数。这里我用仿函数而不是普通函数核心原因是模板需要为不同类型提供特化入口。默认版本直接转size_t整数类型都能用string专门特化用 BKDRHash 算法。template class K struct HashFunc { size_t operator()(const K key) { return (size_t)key; } }; // string 特化版本BKDRHash template struct HashFuncstring { size_t operator()(const string key) { size_t hash 0; for (size_t i 0; i key.size(); i) { hash hash * 131 key[i]; } return hash; } };这里有两个细节值得展开。第一为什么字符串哈希要乘 131本质是把字符串当成一个 131 进制的大数来算每一位字符的权重不一样相同字符换位置之后哈希值也不一样分布自然更均匀。131 是一个经验上效果很好的素数因子还有 1313、31 等变种。乘完如果溢出也无所谓因为size_t是无符号整数溢出是循环取模标准定义的行为不是未定义行为。第二仿函数operator()的返回值是size_t它和桶数组的%运算配合才能算出下标。不要直接返回一个负数或者超出表范围的数否则取余之后分布会很差。最后是哈希表主体。成员就两个一个桶数组一个元素个数计数器。template class K, class V, class Hash HashFuncK class HashTable { public: using Node HashNodeK, V; HashTable() : _n(0) { _tables.resize(10); } ~HashTable() { Clear(); } void Clear() { for (size_t i 0; i _tables.size(); i) { Node* cur _tables[i]; while (cur) { Node* next cur-_next; delete cur; cur next; } _tables[i] nullptr; } _n 0; } private: vectorNode* _tables; size_t _n; };vectorNode*就是桶数组nullptr表示空桶。有人问为什么不直接用std::list做桶因为手写单链表能让你看清内存是谁分配的、谁释放的。STL 封装好的容器不会告诉你这些而哈希表的内存管理恰恰是最容易出事的地方。3.2 插入先查重再头插插入逻辑分三步查重、检查负载因子、头插。bool Insert(const pairK, V kv) { if (Find(kv.first)) { return false; } // 负载因子 0.7 就扩容 if (_n * 10 / _tables.size() 7) { Rehash(); } size_t idx _hash(kv.first) % _tables.size(); Node* newNode new Node(kv); newNode-_next _tables[idx]; _tables[idx] newNode; _n; return true; }头插是新节点直接成为桶里链表的第一个节点。为什么不尾插因为尾插需要先遍历到链表末尾白白浪费一次扫描。头插 O(1) 搞定而且对于哈希表这种无顺序要求的结构头插完全够用。查重这一步很多人会忽略。如果 key 已经存在还无条件插入会造成数据重复后面查找的时候会返回两条记录数据就脏了。标准容器里insert遇到重复 key 是“插入失败但不覆盖”这里我刻意做成重复返回false简洁直观。3.3 查找和删除链表操作的细节活查找很简单。先定位桶再在链表里遍历。Node* Find(const K key) { size_t idx _hash(key) % _tables.size(); Node* cur _tables[idx]; while (cur) { if (cur-_kv.first key) { return cur; } cur cur-_next; } return nullptr; }删除麻烦一点。单链表删除必须要有一个prev指针记着前一个节点否则删掉当前节点后你找不到链表入口。头结点的删除尤其要小心它是特殊分支。bool Erase(const K key) { size_t idx _hash(key) % _tables.size(); Node* cur _tables[idx]; Node* prev nullptr; while (cur) { if (cur-_kv.first key) { if (prev nullptr) { _tables[idx] cur-_next; } else { prev-_next cur-_next; } delete cur; _n--; return true; } prev cur; cur cur-_next; } return false; }这里有个非常隐蔽的坑删除之后_n必须减一但_n减到低于阈值之后并不会触发“缩容”。哈希表的缩容是一个复杂话题标准库一般不会主动缩容避免反复增删造成性能抖动。所以千万别写“删除后检查负载因子并 resize”实测下来会在大批量交替插入删除时慢得离谱。4. 扩容与重哈希性能拐点在哪怎么正确搬移4.1 为什么不能简单地把旧数据 copy 一遍最直观的扩容方案是申请一个更大的桶数组然后把旧表里的所有元素一个个Insert进去。这逻辑没错但性能是灾难。老数据重新Insert意味着对每个老节点都做一次新的内存分配和一次删除。一次扩容 N 个元素额外产生 N 次 new 和 N 次 delete。扩容本身是低频操作但一旦触发这个顿挫感会直接把交互延迟拉高好几个数量级。正确的做法是搬节点不新建节点。把旧桶链表上的节点摘下来算好新位置直接接到新桶的链表上去。指针搬运全程没有新的分配和释放只是改了next指针的指向。4.2 扩容的完整实现新表 节点搬移void Rehash() { vectorNode* newTables; newTables.resize(_tables.size() * 2); for (size_t i 0; i _tables.size(); i) { Node* cur _tables[i]; while (cur) { Node* next cur-_next; size_t newIdx _hash(cur-_kv.first) % newTables.size(); // 头插到新桶 cur-_next newTables[newIdx]; newTables[newIdx] cur; cur next; } _tables[i] nullptr; } _tables.swap(newTables); }这段代码的精华就在“先存 next再改指针”。cur-_next在头插之后会被覆盖如果不提前存下来你连下一个节点在哪都不知道。这是链表操作里最高频的一个失误面试手撕题考察链表题的时候十有八九都栽在这。搬移完成之后旧表所有桶都置空然后和新表交换。newTables的作用域结束就会自动释放资源旧表的 vector 内存也被合适地转移走了。整个过程节点本身一次都没有被重新分配。4.3 实测观察负载因子与查找性能的关系我拿这个实现做过一个简单的性能测试插入 100 万个随机整数表初始 10 个桶负载因子阈值 0.7触发扩容后桶数大约到 131072 个。100 万条数据分布到 13 万个桶平均每条链长度也就 7 到 8 左右。查找 100 万个随机 key 的时候绝大多数访问只需要比较一次。如果把阈值调成 2.0相同数据下平均链长就到了 15查找耗时明显上涨。但如果调成 0.3查找虽然更快内存空桶数量翻倍内存占用很高。这个取舍就是哈希表调优的核心你是在为速度花内存还是在为内存牺牲速度。0.7 到 0.75 这个区间是工程上无数次验证过的平衡点。所以标准库默认值不是拍脑袋定的是一整套取舍逻辑。5. 真实开发中的常见问题与避坑清单5.1 非整数类型怎么支持string 和自定义类型的哈希这一节回答一个高频问题为什么我用HashFuncstring特化能正常编译自定义类型怎么搞先说结论。你的代码里如果写了HashTablestring, int模板参数默认是HashFuncstring而HashFuncstring因为特化存在可以直接调用。如果你写HashTablepairint,int, int默认HashFuncpairint,int会走通用模板把 pair 强转成size_t编译直接报错。这时候你得自己提供一个仿函数作为第三个模板参数。struct PairHash { size_t operator()(const pairint, int p) { return (size_t)p.first * 131 (size_t)p.second; } }; HashTablepairint, int, int, PairHash table;这就是模板第三个参数存在的意义。STL 里的unordered_map也一样支持你在声明容器的时候传入自定义哈希函数对象。理解了这个机制你以后看到高级用法就不会觉得玄学了。5.2 内存管理谁负责释放那些 new 出来的节点哈希表里所有节点都是new出来的vector本身只管存指针不会替你去释放这些堆内存。如果你不写析构函数程序退出时那些节点全部泄漏。这个问题在测试阶段根本发现不了因为操作系统会回收进程内存看起来风平浪静。但如果你把哈希表对象放在一个循环里反复创建销毁内存就会肉眼可见地涨上去最后触发 OOM。所以我的建议跟很多教材不一样哪怕是玩具代码也把析构函数和 Clear 写上去。这不是过度设计这是让你形成肌肉记忆——自己 new 出来的资源必须自己负责释放。以后接触更复杂的 C 代码这种意识能救你很多次。5.3 迭代器和 delete 交叉使用的危险我这份简单实现没有提供迭代器因为迭代器一旦失效规则没想清楚会带来巨大的心智负担。但真实项目里你迟早要面对unordered_map的迭代器在插入触发扩容后全部失效在删除时只有被删元素的迭代器失效其他迭代器不受影响。如果你自己在哈希表上封迭代器最容易踩的坑是遍历到某个节点调用erase删除它然后继续迭代器自增。因为节点已经被 delete自增操作访问的是悬空指针直接崩。解法是遍历时提前保持 next 指针删除当前节点后再把迭代器指向 next。这就是前面Rehash里“先存 next 再操作”的同一个原则。这块多写两句凡是链式结构操作前先备份后继指针是保命准则。5.4 哈希分布不均素数表、重哈希和“伪装”的慢查询还有一类问题不是代码写错而是哈希函数选得不对。某个游戏公司用unordered_mapstring, int存了大量玩家 ID某天突然发现某个桶挂了上万条数据其他桶基本空着查找从 O(1) 退化到 O(n)。原因就是玩家 ID 的字符分布有规律而默认的哈希函数对这种规律不够敏感。排查这种问题的笨办法打印每个桶的链表长度。如果最大桶长度超过平均值的 10 倍哈希函数大概率不合格。解决方案有两个方向换一个更强的哈希函数或者把桶数量调整成素数。素数取余能把模运算的周期性打散减少因子重叠带来的聚集。这也是某些库把扩容后的桶数固定成一串素数比如 2、3、5、7、11、13的原因。到最后我想说一下调试哈希表的一个笨办法。别靠肉眼读代码写一个DebugPrint函数把每个桶的索引和链长打印出来插 100 个数据进去看一遍分布很多问题立刻现原形。这比盯着代码猜半天高效太多。你真正跑一遍就能直观感受到链地址法“均匀分散”这个目标到底有多重要。