ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C++哈希表从原理到手写实现:冲突处理与负载因子详解

C++哈希表从原理到手写实现:冲突处理与负载因子详解 哈希表这玩意儿C程序员早晚要正面刚一次。你刷题时用的unordered_map写业务时碰到的缓存系统甚至数据库索引的底层设计十有八九都跟它脱不了关系。但绝大多数人只停留在“调用find()查一下”的层面真让你自己实现一个很多人当场就懵了。我当年学的时候也是这个状态直到亲手拆了一遍、写了一遍、踩了一遍坑才算真正吃透。这篇文章我打算用最直白的方式讲清楚哈希表的核心原理然后从零手写一个可用的哈希表C 实现不依赖 STL 容器再把我在实际编码中遇到的经典问题和排查经验一并分享出来。不管你是刚学 C 的初学者还是想补数据结构短板的工程师照着这篇文章走一遍都能对哈希表有实打实的掌握。1. 哈希表到底是什么一个新手也能听懂的比喻很多教科书一上来就扔专业术语散列表、Hash Table、键值映射……听起来高大上其实编辑器里的“字典”功能就是很典型的哈希表应用。你输入一个单词它立刻能返回释义这个“立刻”背后就是哈希表的功劳。1.1 数组查找的痛点与哈希表的设计思路先说痛点。如果让你在十万个整数里快速查找某个数是否存在最简单的办法是把这十万个数放进数组然后遍历——每次都可能扫到十万个元素平均耗时 O(n)。如果先排序再用二分查找能做到 O(log n)但插入元素时需要保持有序代价很高。有没有办法把“查找任意一个元素”的时间压到 O(1)哈希表的思路是我能不能不给元素安排固定位置而是根据元素本身的值计算出一个位置这个计算过程就是“哈希函数”Hash Function算出来的结果叫作“哈希值”再映射到数组下标。举个例子假设我有一个长度 10 的数组哈希函数就取value % 10。现在要存数字 42算出来下标是 2直接放到arr[2]。下次查 42同样计算42 % 10得到 2直接看arr[2]是不是 42——整个过程只算一次取模不需要跟其他元素比较。这就是哈希表的核心用空间换时间。通过哈希函数把“任意可比较的键”映射成“数组下标”让查找、插入、删除的平均复杂度都是 O(1)。1.2 生活化的类比图书馆找书你去图书馆还书工作人员不是一本一本翻书架而是根据书号哈希函数算出对应的书架层数组下标直接走过去放到指定位置。等你下次来借同样通过书号算出位置一步到位。这个“书号到架位”的换算规则就是哈希函数“书架层”的容量和摆放方式就是哈希表的结构。但有个问题两本不同的书书号可能算出来指向同一个架位怎么办这就是“哈希冲突”。我后面会用一整节专门讲冲突怎么处理。1.3 哈希表的优势与限制哈希表的优势很明显平均 O(1) 的插入、查找、删除不像平衡树那样有严格排序要求时性能极佳。但它也有局限无法快速遍历有序数据因为元素的存储顺序跟插入顺序、键大小都没关系对哈希函数敏感设计不好会让冲突剧增退化成链表需要额外空间负载因子过高时性能会明显下滑。所以实际项目中哈希表通常用在“频繁按键值查询”的场景比如字典、缓存、去重、索引。如果还需要范围查询、排序输出多半选map或set。2. 核心原理拆解哈希函数、冲突处理与负载因子我刚才说了哈希表的基本思想但要把一个哈希表做成工程上能用的东西还得解决三个关键问题哈希函数怎么设计、冲突怎么处理、数组什么时候扩容。下面逐个拆。2.1 哈希函数怎么选简单与高效的平衡哈希函数的作用是把一个任意大小的输入映射到一个固定范围内的整数。合格的哈希函数要满足两点计算快、分布均匀。计算快影响每次操作的耗时分布均匀影响冲突的多少。最常用的三个方向取模法hash key % table_size。简单粗暴适合整数键。注意如果 table_size 是 2 的幂取模等价于位与更快但会让低位分布更集中容易冲突。乘法哈希用某个无理数小数部分相乘后取整比如floor(table_size * (frac(key * 0.6180339887)))。分析起来复杂实际用得不算多。位运算/混合哈希比如把 key 右移、异或、乘大质数做“雪崩效应”处理。我常用的一个整数混合函数是size_t hash_int(int key) { key key ^ (key 16); key key * 0x45d9f3b; key key ^ (key 16); return static_castsize_t(key); }它的思路是让每一位的变化都尽可能影响到最终结果降低规律性。这套思路在很多开源库里都能看到变种。对于字符串键一个经典简单的实现是Brian Kernighan 的 BKDRHashsize_t bkdr_hash(const char* str) { size_t seed 131; // 也可以是 13131、131313 等质数 size_t hash 0; while (*str) { hash hash * seed (*str); } return hash; }为什么用质数做乘数数学上讲质数与哈希值进行乘法运算能更好地混洗信息减少周期性冲突。这里不需要背公式你只需要记住一个结论哈希函数要尽量让不同键的结果均匀撒在表里。2.2 冲突处理开放寻址与链地址法不管哈希函数多好只要表长是有限的冲突就不可避免。处理冲突基本就两大流派。链地址法拉链法每个数组元素不直接存数据而是存一个链表头冲突的键全部挂到同一链表里。查找时先算哈希找到对应链表再链表内顺序查找。这里得区分一下拉链法里的链表有单向链表、双向链表、头插法、尾插法而且新的元素通常插在链表头因为刚插入的元素在后续查找时大概率会被频繁访问头插法可以省掉遍历。我手写哈希表时偏好单向链表配合头插法实现简单删除时按 key 找节点再移除即可均摊开销可控。但千万别忽略一个问题拉链法的链表如果太长退化就不可控了。所以需要负载因子来限制长度。开放寻址法遇到冲突时不是挂链表而是向后寻找下一个空槽位。常见的有线性探测找(hash i) % size、二次探测hash i^2、双重哈希用第二个哈希函数决定步长。开放寻址对缓存友好但删除标记很麻烦不能真的置空否则会断掉探测链所以实际工业级哈希表用拉链法的更多。两种方式对比特性拉链法开放寻址内存分配每个节点动态分配缓存不友好完全使用连续数组缓存友好删除操作直接改链表指针需要墓碑标记易产生‘假满’最坏情况退化退化为链表但仍可工作表满后直接溢出实现难度较低较高我做自实现时首选拉链法因为可控性和清晰度都更好。2.3 负载因子与扩容哈希表的“容错度”负载因子load factor 已存元素数量 / 表容量。它能衡量哈希表的“拥挤度”。负载因子越大链表长度越长冲突概率越高负载因子太小空间浪费严重。一般实践值在 0.5 到 1.0 之间STL 的unordered_map多数实现取 1.0Java 的HashMap取 0.75。因为当链表平均长度超过 1 以后查找复杂度就从 O(1) 开始往 O(n) 靠拢了。从数学上说链地址法的查找平均长度是1 load_factor/2。负载因子 0.75 时平均查找 1.375 次1.0 时则 1.5 次。所以追求更快查询就设低一点比如 0.7追求省内存就设高一点比如 1.0 或 1.25。当插入后负载因子超过阈值就要扩容。扩容不是简单把数组变大而是重新创建一个更大的数组然后把旧数据重新哈希一遍。为什么必须重新哈希因为哈希函数里的取模和表长度有关表长变了元素的新下标大概率会变。这个过程也叫 rehash。扩容的代价是 O(n)但每次扩容后拉链因子回落到初始值后续 n 次插入均摊下来依然是 O(1)。很多初学朋友担心扩容导致操作偶发变慢这在工程上完全能接受。比如 Redis 的字典也是增量式 rehash 来平滑开销但那是更高级的话题了。3. 自我实现从零写一个能跑的 C 哈希表理论聊完了直接上代码。我准备实现一个用链地址法 动态扩容的 C 哈希表模板支持任意可哈希的类型通过模板特化哈希函数。这应该是一个能放到工程里日常使用的基础版。3.1 定义数据结构与接口整个哈希表由两部分组成内部节点数组和节点本身。我选择用vectorlistpairK, V不直接用裸链表手写节点这样对链表操作理解更深而且实现删除时对大鹏也有帮助。但在实际工程中用std::vectorstd::liststd::pairconst K, V会更安全。为了教学价值我按两组都写教学版用裸链表实用版用 STL list 封装。先定义一个模板类MyHashMapK, V, Hash#include iostream #include vector #include list #include utility #include stdexcept template typename K, typename V, typename Hash std::hashK class MyHashMap { private: using Node std::pairconst K, V; struct Bucket { std::listNode chain; }; std::vectorBucket buckets_; size_t size_ 0; float max_load_factor_ 0.75f; Hash hash_fn_; public: MyHashMap() : buckets_(16) {} size_t bucket_count() const { return buckets_.size(); } size_t size() const { return size_; } bool empty() const { return size_ 0; } float load_factor() const { return static_castfloat(size_) / static_castfloat(buckets_.size()); } void set_max_load_factor(float mlf) { if (mlf 0.0f || mlf ! mlf) { // 防 NaN throw std::invalid_argument(invalid max load factor); } max_load_factor_ mlf; } float max_load_factor() const { return max_load_factor_; } size_t get_bucket_index(const K key) const { size_t h hash_fn_(key); return h % buckets_.size(); } // ... 后续插入、查找、删除、扩容 };这里用vectorBucket每个 Bucket 内部包含一个std::liststd::pairconst K, V。list的优点是插入删除节点不会使已有迭代器失效除了被删除节点这跟hash_map对迭代器的承诺保持一致。Node的键是const K这样从内存上就限制了外部不能随便改键。如果你用裸pairK,V某个函数拿到引用后改掉 key哈希表内部就乱套了。3.2 实现插入、查找与删除插入逻辑插入分三步算出哈希值取模找桶在对应链表中找是否已有相同 key有则更新 value没有则新建节点插入。V operator[](const K key) { // 确保在插入前不会超负载 if (load_factor() max_load_factor_) { rehash(buckets_.size() * 2); } size_t idx get_bucket_index(key); auto chain buckets_[idx].chain; for (auto node : chain) { if (node.first key) { return node.second; } } chain.emplace_front(key, V{}); size_; return chain.front().second; } std::pairIterator, bool insert(const K key, const V value) { if (load_factor() max_load_factor_) { rehash(buckets_.size() * 2); } size_t idx get_bucket_index(key); auto chain buckets_[idx].chain; for (auto it chain.begin(); it ! chain.end(); it) { if (it-first key) { return {Iterator{this, idx, it}, false}; // 已存在 } } chain.emplace_front(Node(key, value)); size_; auto it chain.begin(); return {Iterator{this, idx, it}, true}; }为什么不先检查size_1再判断负载因为扩容本身可能影响迭代器这里先做了也行实际中先扩容再插入能避免扩容后再插入造成二次开销。你也可以写成“插入后若超载再扩容”两种都对我习惯前插前查因为可以尽量少触发一次 rehash 边界问题。emplace_front是头插法利用链表更新到缓存友好这一特点。如果你要强调排序稳定性可以改用push_back但没必要。查找逻辑查找就简单多了V* find(const K key) { size_t idx get_bucket_index(key); auto chain buckets_[idx].chain; for (auto node : chain) { if (node.first key) { return node.second; } } return nullptr; } const V* find(const K key) const { size_t idx get_bucket_index(key); const auto chain buckets_[idx].chain; for (const auto node : chain) { if (node.first key) { return node.second; } } return nullptr; }这里返回指针是方便判断是否存在但要注意返回的指针可能因为后续插入触发 rehash 而失效因为 rehash 会重新分配桶数组。实际 STL 的unordered_map::find返回迭代器插入后如果发生 rehash 也会失效。所以使用时最好在短周期内使用返回的引用。删除逻辑删除更复杂涉及到桶内链表的移除bool erase(const K key) { size_t idx get_bucket_index(key); auto chain buckets_[idx].chain; for (auto it chain.begin(); it ! chain.end(); it) { if (it-first key) { chain.erase(it); --size_; return true; } } return false; }如果只有单链表实现你需要一个 prev 指针这里用std::list的内置erase就很省事。有的面试题会要求不用 list 而是自定义节点可以看 3.4 部分的裸链表版本对比理解。这里有个值得注意的细节删除后不缩容。很多自实现都不缩容因为缩容又会引发全量 rehash代价高而且实际使用中哈希表通常长期存活扩容后收缩的收益不大。STL 里也没有自动缩容。如果你有强烈缩容需求可以显式调用rehash(n)。3.3 扩容与 rehash 实现void rehash(size_t new_bucket_count) { size_t new_bc std::maxsize_t(new_bucket_count, 16); // 如果新容量不小于当前大小则扔到下一个质数为了简化直接使用 2 的幂次或传入值 std::vectorBucket new_buckets(new_bc); for (auto bucket : buckets_) { for (auto node : bucket.chain) { size_t new_idx hash_fn_(node.first) % new_bc; new_buckets[new_idx].chain.emplace_front(std::move(node)); } } buckets_.swap(new_buckets); // size_ 在这里不变因为还是那么多元素 }扩容后原来链表节点全部移动到新桶。这里用了std::move(node)避免拷贝字符串或复杂对象优化很关键。移动后旧桶的chain释放时不会再析构数据而是把节点所有权转交给新桶了。注意std::list::emplace_front(std::move(node))需要pair支持移动构造标准库没问题。rehash 后之前获取到的迭代器或指针都会失效。所以如果你一边遍历一边插入导致扩容就会访问非法地址。真实项目中一定要避开这种操作。3.4 完整可运行代码裸链表版上面用的是std::list便于安全实现。但有些朋友练手喜欢自己写节点链表我下面给一个裸链表 头插法的版本能更直观地看到指针操作#include iostream #include vector #include functional #include cassert templatetypename K, typename V, typename Hash std::hashK class HandHashMap { private: struct Node { K key; V value; Node* next; Node(const K k, const V v) : key(k), value(v), next(nullptr) {} }; std::vectorNode* buckets_; size_t size_ 0; float max_load_factor_; Hash hash_fn_; size_t idx(const K key) const { return hash_fn_(key) % buckets_.size(); } void insert_to_bucket(size_t i, Node* node) { node-next buckets_[i]; buckets_[i] node; } void rehash(size_t new_size) { std::vectorNode* new_buckets(new_size, nullptr); for (Node* head : buckets_) { Node* cur head; while (cur) { Node* next cur-next; size_t new_idx hash_fn_(cur-key) % new_size; insert_to_bucket(new_idx, cur); cur next; } } buckets_.swap(new_buckets); } public: explicit HandHashMap(size_t init_size 16, float mlf 0.75f) : buckets_(init_size, nullptr), max_load_factor_(mlf) {} ~HandHashMap() { for (Node* head : buckets_) { Node* cur head; while (cur) { Node* next cur-next; delete cur; cur next; } } buckets_.clear(); } V* find(const K key) { size_t i idx(key); Node* cur buckets_[i]; while (cur) { if (cur-key key) return cur-value; cur cur-next; } return nullptr; } bool insert(const K key, const V value) { if (static_castfloat(size_ 1) / buckets_.size() max_load_factor_) { rehash(buckets_.size() * 2); } size_t i idx(key); Node* cur buckets_[i]; while (cur) { if (cur-key key) { cur-value value; return false; } cur cur-next; } Node* n new Node(key, value); insert_to_bucket(i, n); size_; return true; } bool erase(const K key) { size_t i idx(key); Node** pp buckets_[i]; while (*pp) { Node* cur *pp; if (cur-key key) { *pp cur-next; delete cur; --size_; return true; } pp cur-next; } return false; } };裸链表版的erase用的是二级指针Node**这样能直接改写前一个节点的 next 指针不用记录 prev 指针。这个写法很常见第一次看不懂不要紧多画两遍pp一开始指向head指针本身循环时改成指向cur-next的地址。裸链表版的注意点内存在new/delete手动管理务必写析构函数释放全部节点否则内存泄漏。拷贝构造、赋值操作默认会浅拷贝造成二次释放必须要显式删除或实现深拷贝。为了篇幅教学版本可以禁掉拷贝。rehash 时遍历链表时先保存next再移动当前节点防止断链。3.5 一个泛型哈希函数的小问题C 标准库的std::hash默认支持整数、浮点、指针、string 等但不支持自定义结构体。你想用自定义类型做 key优先选择两个办法一是给自定义类型定义operator然后自己写一个仿函数传递进去struct Student { int id; string name; bool operator(const Student other) const { return id other.id name other.name; } }; struct StudentHash { size_t operator()(const Student s) const { return std::hashint()(s.id) ^ (std::hashstring()(s.name) 1); } }; MyHashMapStudent, int, StudentHash map;二是特化std::hashnamespace std { template struct hashStudent { size_t operator()(const Student s) const noexcept { return hashint()(s.id) ^ (hashstring()(s.name) 1); } }; }特化方案的好处是可以直接用默认模板参数std::hashStudent代码更简洁。但要注意哈希函数不能随便返回 0否则所有键挤到同一个桶里和遍历链表没区别了。4. 实操过程测试、调优与踩坑纪实理论说一百遍不如跑一遍真实的数据。我把上面的裸链表版跑起来做了正确性测试和性能测试记录了下几个重要的观察。4.1 功能自测与边界我写一个简单的驱动int main() { HandHashMapstd::string, int map; map.insert(apple, 3); map.insert(banana, 5); map.insert(cherry, 8); if (auto* v map.find(apple)) std::cout *v \n; // 3 map.insert(apple, 10); // 更新 if (auto* v map.find(apple)) std::cout *v \n; // 10 map.erase(banana); if (map.find(banana) nullptr) std::cout erase ok\n; // 批量插入让负载因子超过阈值触发扩容 for (int i 0; i 10000; i) { map.insert(key std::to_string(i), i); } std::cout size map.size() \n; // 10000-11 ... return 0; }我建议加一段输出桶数量的日志能直观看到扩容发生初始容量 16插入 14 个左右(size1)/16 0.75就会扩容到 32再往后 64、128…… 你可以在 rehash 函数里打印new_size会看到容量翻倍的轨迹。边界点测试包括插入相同的 key 更新 value不增加 size删除不存在的 key返回 false不崩溃空表查找、删除返回空指针、false负载因子设为 0 或负数时抛出异常用字符串 key 和整数 key 分别测试重复哈希。4.2 性能观察哈希函数质量影响巨大我拿 20 万条随机整数做插入和查找测试整数哈希用了std::hashint质量还不错耗时大概 60ms若我故意用一个很烂的哈希函数return 020 万数据会让所有节点串在一条链表上耗时直接飙到 1.8 秒差了 30 倍。所以哈希函数的分布性是性能的第一决定因素负载因子第二。测试中发现 C 标准库std::hashint对连续整数的分布并没那么均匀特别是在桶数是 2 的幂时只取了低位信息。很多库实现会选择素数桶数量来缓解这种规律性。我上面自实现里用 2 的幂扩容是有风险的实际工程里可以改成“扩容到下一个质数”或者使用混合哈希函数把高位信息搅到低位。这就是为什么我在前面特意写了混合函数工程上不能懒。你可以在自己的Hash模板参数里传入一个更好的混合函数struct FastMixHash { size_t operator()(int x) const { x ^ x 16; x * 0x7feb352d; x ^ x 15; x * 0x846ca68b; x ^ x 16; return x; } };这个函数就是典型的“乘加异或”混合开销很低但对连续整数非常友好。4.3 值得记录的几个典型坑坑 1删除节点后忘了处理指针导致悬垂访问裸链表版里erase中如果直接用了cur-value再delete cur如果外部还保存着旧指针就会悬垂。这正是为什么 STLunordered_map规定 erase 后除了被删元素的迭代器之外其他迭代器保持有效但被删元素本身绝对不能再用。坑 2rehash 时没有std::move导致性能骤降最初版我写emplace_back(node)对于std::string大 value 会拷贝一份20 万数据测试时内存翻倍、时间多了一倍。换成std::move(node)后list 节点转移只搬指针效率立竿见影。下面标注版本// 错误示范深拷贝 new_buckets[new_idx].chain.push_back(node); // 正确示范转移所有权 new_buckets[new_idx].chain.emplace_front(std::move(node));坑 3size_在 rehash 中意外变化我一开始图省事在 rehash 里清零了size_再重新计算结果后续插入判断负载出错。实际上 rehash 只是搬移节点元素数量不变坚持“rehash 不动 size_”这个原则能避免大量逻辑混乱。坑 4负载因子判断写反if (size_ / buckets_.size() max_load_factor_)在 size 小于 bucket_count 时永远为 0因为整数除法。要写成static_castfloat(size_) / buckets_.size()或者size_ max_load_factor_ * buckets_.size()。这个 bug 挺隐蔽尤其是压测数据量不够大的时候跑不出来。坑 5VSCode 环境下的编译问题很多在 VSCode 里写 C 的朋友会遇到一个常见困惑明明代码看着没问题编译报错“未找到 identifier”或者头文件标红。这不一定是哈希表代码错误八成是编译器配置没到位。最稳妥的做法是确认已安装 MinGW-w64 或 MSVC在.vscode/tasks.json里配置好了编译命令比如g -stdc17 main.cpp -o main如果碰到 “Microsoft Visual C Redistributable” 找不到的问题那是运行库缺失去官网装对应版本即可跟代码无关。毕竟我们写的是模板类模板代码必须在头文件中完整定义不能分h和cpp编译链接。很多新手把模板实现写进.cpp然后在另一个.cpp里使用直接报链接错误。这就是为什么上面所有实现都放在一个.h里。模板的定义和实现必须在同一个编译单元里可见这是初学 C 模板的一个大坑。5. 与 STL unordered_map 对比我们写的差在哪很多人会问既然 C17 已经有现成的std::unordered_map为什么还要自己实现直接回答不是为了造轮子是为了理解轮子。但你也要清醒地知道你写的和 STL 的差别在哪别拿着自实现去生产环境硬顶。5.1 STL unordered_map 的实现特性标准库的unordered_map基于哈希表通常实现为“桶数组 链表/红黑树混合”比如 GCC 的std::unordered_map在 C11 以后把桶内的链表定义为“单向链表”且用了一种带指针的扩展节点。它的接口完整支持迭代器、局部桶操作、观察负载因子等。它有几个我们自实现没有的点迭代器不失效保证除非 rehash否则操作不会使其他迭代器失效只有被 erase 的元素迭代器失效局部分析bucket_count()、bucket_size(i)、max_load_factor()等可以方便调优异构查找C20 起支持find K 的透明哈希比如查找std::string时传入const char*不需要构造临时字符串异常安全保证rehash 过程采用了更强的异常安全承诺避免中途抛异常导致数据丢失。我们写的版本在这些方面是零。工程化要么选 STL要么用 absl、folly 等优化库而不是自己手搓。手搓的价值在于教学和特定场景比如你需要定制内存池、更激进的哈希函数。5.2 性能对比数据我简单测了一下同样的 20 万条随机键值对插入 查找我们自实现的裸链表版耗时约 55msstd::unordered_map耗时约 38ms。差距主要在于自实现没有使用局部内存优化桶数组分配后每次节点new都是堆分配而 STL 的实现通常会缓存友好地分配节点块。如果你要追赶 STL可以考虑给节点写一个内存池但这就超出本文范围了。另一个明显差距是迭代器。STL 的迭代器可以for (auto p : mymap)遍历所有键值对而我的自实现连begin()/end()都没写只能通过底层桶遍历。后者当然可以补但补起来要和扩容逻辑保持一致性操作会比较繁琐。这也是为什么自实现哈希表适合学习和特定实验不适合日常替代 STL。5.3 什么时候该选择自实现哈希表业务需要精确控制内存比如嵌入式环境不能随便堆分配哈希逻辑特殊需要自定义哈希算法和冲突策略比如减少哈希碰撞的布隆过滤器场景学习数据结构内部机制为了更好地完成面试题或加深理解极小的表规模项目只需要几十个键值对可以手写固定数组哈希表省去库依赖。除此之外默认std::unordered_map。不要因为写了这篇文章你就真去生产环境自造除非你足够清楚所有代价。6. 个人实操心得与后续进阶方向这是我的几次手写哈希表经历中最值得记住的三个经验。第一先写测试再写实现。不是先有哈希表再去补测试而是一开始就把边界条件列出来查找不存在的键、插入多个同 key、删除最后元素、触发数次扩容。这样可以逼着自己提前把逻辑理清楚而不是边写边改。第二用size_、bucket_count、load_factor的日志来辅助理解。我把 load_factor 变化打印出来跟着跑一遍数据立刻就能感受到为什么工程上要设定负载因子阈值。你甚至可以做个实验把阈值从 0.9 改成 0.3看看内存占用和耗时如何变化。实测中阈值过高时查找时间在冲突严重时呈曲线上升阈值过低时 rehash 次数变多总耗时也上升存在一块最优区间。第三不要忽略哈希函数的种子。自实现题目里常用不到随机种子但真实应用中如果哈希函数固定恶意输入可能构造大量碰撞导致哈希表退化为链表。中文社区里常说的“小雨伞攻击”就是类似的思路。更稳妥的做法是加入运行时随机种子让攻击者无法预测哈希值。如果这篇文章让你产生了继续往下钻的兴趣我建议下一步可以做这三个进阶挑战在自实现哈希表上添加完整迭代器支持for (auto p : ht)把节点改用内存池分配对比堆分配的耗时实现循环探测法的开放寻址版本和拉链法比较性能曲线。把这些都做完你对哈希表的理解就真的是“血肉俱全”而不是“背诵八股”了。我自己就是靠这样一遍遍手写、测试、比较才在刷 LeetCode 和做项目时对哈希表有了瞬间的直觉看到高频查找需求第一反应就是哈希看到范围查询需求第一反应就是树。多写几遍你也一样。
返回列表