哈希表从原理到实现:链地址法、开放寻址法与C++代码详解

哈希表可以说是C++进阶路上绕不开的一块硬骨头。很多人在刷题时用过unordered_map,但一问到它底层的“哈希桶结构”到底是什么、开放寻址法和链地址法有什么区别、扩容的时候发生了什么,就说不清楚了。这篇内容我想从原理到底层实现,把哈希表的两种冲突解决方案都拆开揉碎,带上可直接运行的C++代码,帮你彻底打通这层认知。整篇内容适合已经掌握指针、类、模板基础,正在向STL源码和高级数据结构迈进的开发者,也适合准备面试时需要把哈希表讲透的人。

1. 内容整体设计与思路拆解

1.1 为什么哈希表能达到“平均O(1)”的查找速度

哈希表的核心思路,一句话概括:把要查找的key,通过哈希函数直接映射成数组下标,然后在这个位置上存储value。它不像平衡树那样需要从根节点一路比较下来,也不像链表那样逐个遍历,而是“一步到位”——相当于你按门牌号找房间,而不是在整栋楼里逐户敲门。

这里的代价是,我们必须保证“门牌号”能覆盖所有可能的key。但key的空间通常远大于数组长度,比如字符串“hello”和“world”都可能映射到同一个下标,这就是哈希冲突。哈希表的一切设计,本质上都在围绕怎么处理“多个key映射到同一个位置”这件事展开。

两种主流方案:开放寻址法和链地址法。前者是冲突了就往后找空位,后者是每个位置挂一条链表,冲突了就链在同一个桶下面。大多数现代编程语言的哈希表实现选择了链地址法,但开放寻址法在特定场景下反而更快,后面我会详细分析各自的设计动机。

1.2 设计一个哈希表需要想清楚的四件事

动手写代码之前,先明确四个决定成败的设计点。

第一是哈希函数怎么选。整型可以直接取模,字符串需要一个把字符序列转成整数的高散列函数。哈希函数的分布质量直接决定冲突频率,冲突越少,整体性能越好。

第二是容量管理。数组不能太小,否则冲突率爆炸;也不能太大,浪费内存。这就要引入**负载因子(load factor)**的概念:元素个数 / 桶数组长度。当负载因子超过阈值,就触发扩容。

第三是冲突解决策略。链地址法实现简单,删除操作友好;开放寻址法缓存友好但删除麻烦,需要墓碑标记。

第四是扩容后的rehash。所有旧元素必须重新计算哈希、重新分布到新数组中,这一步如果写得不好,就是隐性的性能黑洞。

后面每个设计点都会展开讲,并且配合代码,确保你读完能在自己的项目里实现一个像模像样的哈希表,而不是只知道概念。

2. 哈希函数与冲突根因

2.1 哈希函数的核心不是“算得快”,而是“散得匀”

很多人第一次写哈希函数,觉得就是把key转成数字再取模。这没错,但隐藏着一个陷阱:如果哈希函数的分布不均匀,某些桶会被疯狂占用,另一些桶则空着,哈希表的表现就会从O(1)退化到O(n)。

举几个实际对比的例子。假设桶长度是8:

key = 3, 11, 19, 27(等差数列) 散列结果 = 3, 3, 3, 3

如果直接取模,等差数列的key会全部落在同一个桶。这显然不行。工程上常用的做法是先映射再取模,即将key通过一个复杂的哈希函数打散后再对桶数取模,这样即使key本身有规律,散列结果也能均匀分布。

对于整型key,一个经典但有效的哈希函数是:

size_t hash_int(int key) { key ^= key >> 16; key *= 0x7feb352d; key ^= key >> 15; return static_cast<size_t>(key); }

这是改良版的splitmix32思路,两步移位异或加一次乘法。它把key的高位和低位充分混合,使得相邻的key在映射后相距很远,大幅降低冲突概率。

对于字符串key,常用的实现有BKDR哈希:

size_t hash_string(const std::string& s) { size_t hash = 0; const size_t seed = 131; for (char c : s) { hash = hash * seed + static_cast<unsigned char>(c); } return hash; }

原理相当于把字符串看作一个131进制的巨大数字,不同的字符串组合基本能映射到不同的数字指纹。实测下来,对于常见英文文本,分布质量足够好,而且计算开销很小,一个字符一次乘加运算而已。

注意:直接用const char*逐个字符累加而不用种子值的话,短字符串比如"ab"和"ba"会得到相同结果,因为只是交换了加法顺序。工程实现里一般会加入移位或乘固定种子来避免这种情况。

2.2 负载因子的阈值选择

负载因子决定哈希表的“拥挤程度”。

链地址法每个桶可以无限挂链子,所以负载因子可以稍高,标准库通常取1.0。也就是说,元素数量和桶数量相等时才开始扩容。

开放寻址法所有元素都存在数组本身,一旦数组满了就完全无法插入,所以负载因子必须严格低于1。工程上一般取0.5到0.7,超过就扩容。取0.7以下是为了保证空闲位置足够多,线性探测的冲突链长度不会太长。

实测经验:负载因子0.7的开放寻址哈希表,查找时的平均探测次数大约是两次左右;而负载因子0.9时,平均探测次数可能上升到五次甚至更高。这就是为什么开放寻址表在扩容上更“吝啬”:它必须留出大量空位来保证性能。

3. 链地址法与哈希桶结构实现

3.1 哈希桶的数据结构定义

链地址法在STL里的正式名称是哈希桶(bucket),每个桶本质是一条链表。整个哈希表是一个std::vector<Bucket*>,每个Bucket*指向一条链表的头节点。

一个可以直接跑的实现,核心结构会分成三部分:

template <typename K, typename V> struct HashNode { K key; V value; HashNode* next; HashNode(const K& k, const V& v) : key(k), value(v), next(nullptr) {} }; template <typename K, typename V> class HashTable { private: std::vector<HashNode<K, V>*> buckets_; size_t size_ = 0; double max_load_factor_ = 1.0; size_t hash(const K& key) const { return hash_int(key) % buckets_.size(); } public: explicit HashTable(size_t bucket_count = 16) : buckets_(bucket_count, nullptr) {} ~HashTable() { for (auto* head : buckets_) { while (head != nullptr) { auto* next = head->next; delete head; head = next; } } } };

这里有几个工程细节值得注意。

第一,析构函数要遍历每个桶的整条链表逐个delete,否则会内存泄漏。我见过不少人写哈希表只释放vector本身,导致海量的HashNode泄漏,跑服务一段时间内存就飙上去了。

第二,每个桶的头节点存不存数据?两种设计都存在。我选择桶里直接挂数据节点(即链表头节点就是真实的key-value),而不是单独搞一个空头节点。这样每个元素只占用一个节点,内存利用率高,遍历代码也更直观。

第三,为什么要用vector而不是普通数组存桶结构?因为扩容时我们需要把桶数量翻倍并重新分配,vector自动管理内存拷贝,我们在它上面做rehash非常方便,不用自己写new[]/delete[],还能避免浅拷贝导致的双重释放问题。当然,vector的拷贝需要自己处理,比如用swap技巧来避免深拷贝开销,后面会说。

3.2 插入、查找、删除的完整实现

接下来把核心接口写全。插入采用头插法:新节点总是挂在链表头部。这样插入本身是O(1)的,不需要遍历到链表尾部;同时新插入的元素在遍历时最容易被找到(因为它离头最近),符合局部性原理。

void insert(const K& key, const V& value) { if (size_ + 1 > buckets_.size() * max_load_factor_) { rehash(buckets_.size() * 2); } size_t idx = hash(key) % buckets_.size(); HashNode<K, V>* head = buckets_[idx]; // 如果key已存在,更新value for (HashNode<K, V>* cur = head; cur != nullptr; cur = cur->next) { if (cur->key == key) { cur->value = value; return; } } // 头插 auto* node = new HashNode<K, V>(key, value); node->next = buckets_[idx]; buckets_[idx] = node; ++size_; }

查找和删除则是标准的链表操作:

V* find(const K& key) { size_t idx = hash(key) % buckets_.size(); HashNode<K, V>* cur = buckets_[idx]; while (cur != nullptr) { if (cur->key == key) { return &cur->value; } cur = cur->next; } return nullptr; } bool erase(const K& key) { size_t idx = hash(key) % buckets_.size(); HashNode<K, V>* cur = buckets_[idx]; HashNode<K, V>* prev = nullptr; while (cur != nullptr) { if (cur->key == key) { if (prev == nullptr) { buckets_[idx] = cur->next; } else { prev->next = cur->next; } delete cur; --size_; return true; } prev = cur; cur = cur->next; } return false; }

删除时一定要维护前驱指针prev。链表删除的经典两个分支:删除的是头节点就更新桶头,删除的是中间节点就拼上前驱和后继。这里我走了整条链做比较,理论上可以先用哈希定位到桶,链上再遍历,复杂度是O(1 + 链长),链短时近似O(1)。

3.3 rehash扩容的实现与避坑

扩容的关键在于:旧数组里每个节点都要重新计算哈希,并放到新数组对应的桶中。

void rehash(size_t new_bucket_count) { std::vector<HashNode<K, V>*> new_buckets(new_bucket_count, nullptr); for (auto* head : buckets_) { while (head != nullptr) { auto* next = head->next; size_t new_idx = hash(head->key) % new_bucket_count; head->next = new_buckets[new_idx]; new_buckets[new_idx] = head; head = next; } } buckets_.swap(new_buckets); }

注意这里我没有新建节点,也没有删除节点,而是把旧桶里的节点指针“拔下来”重新插到新桶里。这叫做“节点搬家”,时间复杂度是O(n),但省掉了O(n)次new和delete,对于大表来说性能差别非常大。

一个容易被忽略的细节:rehash之后,所有迭代器的有效性都会失效。因为每个节点现在挂在不同的桶里,你手里保存的节点地址虽然没变,但“下一个节点”的指针关系完全变了。如果外部还在用旧的迭代器遍历,就会出现漏元素或访问到错误数据的问题。

3.4 链地址法的优缺点复盘

链地址法最大的优点是对删除极其友好,直接摘节点就行,孤立的空桶很快又会重新被占用,不需要墓碑标记。

另一个优点是负载因子可以大于1,内存上限更高。相比之下,开放寻址法在接近满时性能雪崩,而链地址法的链表多挂几个节点影响相对温和。

缺点也很明显:每个节点都需要额外的next指针,内存开销大;链表节点在堆上分散分配,CPU缓存不友好,遍历链表时每次都可能cache miss。尤其在现代CPU上,随机内存访问的代价远比一次哈希计算高,这也是某些场景下开放寻址法反而更快的原因。

4. 开放寻址法的实现细节

4.1 线性探测的插入与查找逻辑

开放寻址法的思想:不引入额外链表,冲突了就在数组里继续往后找空位。

线性探测的具体做法是:

idx = hash(key) % capacity while (槽位被占用且不是同一个key) { idx = (idx + 1) % capacity }

C++实现大概长这样:

class OpenAddressingHashTable { private: struct Slot { int key = 0; int value = 0; bool occupied = false; bool deleted = false; }; std::vector<Slot> table_; size_t size_ = 0; double max_load_factor_ = 0.7; public: explicit OpenAddressingHashTable(size_t capacity = 16) : table_(capacity) {} void insert(int key, int value) { if ((size_ + 1) > table_.size() * max_load_factor_) { rehash(table_.size() * 2); } size_t idx = hash(key) % table_.size(); while (table_[idx].occupied && !table_[idx].deleted) { if (table_[idx].key == key) { table_[idx].value = value; return; } idx = (idx + 1) % table_.size(); } table_[idx].key = key; table_[idx].value = value; table_[idx].occupied = true; table_[idx].deleted = false; ++size_; } int* find(int key) { size_t idx = hash(key) % table_.size(); while (table_[idx].occupied) { if (!table_[idx].deleted && table_[idx].key == key) { return &table_[idx].value; } idx = (idx + 1) % table_.size(); } return nullptr; } };

线性探测的“线性”指的是冲突检测步长恒为1,也就是说冲突区域会连成一片,也叫**主聚类(primary clustering)**问题。表越满,大片连续被占用的区域越长,查找时可能要绕过整个簇才能找到目标或空位,性能就会急剧下降。

所以开放寻址法的负载因子必须压低,这是它的天生约束。0.7是我的经验值,0.5更稳,但内存浪费也更严重。

4.2 删除时必须使用墓碑标记

这是开放寻址法最容易写错的地方。明明删掉一个元素,但如果你把槽位恢复成“空”,查找就可能出错。

举例:

key=5 映射到槽位2,发生冲突占用了槽位3 key=9 在查找时,从槽位2开始探测,发现2被5占用,继续探测3

现在删除key=5,把槽位2标记为空。然后查找key=9,探测到槽位2发现是空,直接返回不存在。但9其实在槽位3里!

所以在开放寻址表里,删除不能清空,只能标记为deleted(墓碑),表示“这里以前有元素,你继续往后找”。查找逻辑里,碰到墓碑不算停止,还要继续探测。直到找到空槽(occupied == false)才算“确定不存在”。

墓碑太多会侵蚀性能:查找时要跳过一大片墓碑区域。解决办法是周期性清理,重新哈希表来清除墓碑,或者简单粗暴——当墓碑数量超过一定比例就rehash整个表。

4.3 开放寻址法的二次探测与双重哈希

线性探测的缺陷是主聚类。两个改进方案在工程里也常见。

二次探测:探测步长变成1², 2², 3²...,也就是从原始位置开始跳1、4、9、16个槽位。

size_t idx = hash(key) % capacity; size_t step = 1; while (slot被占用) { idx = (idx + step * step) % capacity; ++step; }

它打破了连续性,但会带来次聚类问题:两个不同key若初始位置相同,后续探测序列也完全相同,还是会扎堆。另外步长不断增大,可能跳过大量空位,导致效率不稳。

双重哈希是对“探测序列碰撞”的根治:第二个哈希函数生成一个独立的步长。

size_t h1 = hash1(key) % capacity; size_t h2 = hash2(key); // 确保与capacity互质,通常取奇数 size_t idx = h1; size_t step = h2; while (slot被占用) { idx = (idx + step) % capacity; }

这样即使两个key的h1相同,h2也可能不同,探测路径就分开了。双重哈希的代码复杂度和计算成本比线性探测高,扩展性也敏感——第二个哈希函数必须保证步长和容量互质,否则可能探测不到所有槽位,需要在容量设计上做约束。实际工程里线性探测已经足够快,只有对碰撞特别敏感或有安全需求时才升级到双重哈希。

5. unordered_map的底层行为与性能优化

5.1 标准库哈希表为什么是链地址法

C++标准里的unordered_map主体是链地址法。它内部允许每个桶挂多条记录,负载因子默认1.0,max_load_factor可以自定义。

为什么STL选择链地址法?核心原因有三个:删除实现简单、迭代器稳定(不会因为哈希表结构变化失效)、插入无需探测空位(插入操作在任何负载因子下都是O(1))。这在C++这种重视性能和对象语义的语言里非常关键。

但如果你细看实现(比如某个主流版本),你会发现桶里挂的其实不是简单链表,而是一种特殊的单向链表组织方式,节点之间的next指针并不保证在同一个桶内连续,而是通过额外的hash计算和插入顺序来管理。这带来一个好处:当rehash发生时,只需要遍历整条全局链表——遍历顺序即插入顺序——每个节点重新挂到新桶里,不需要重新分配任何节点。这是教科书实现和工业级实现的差异点,也是“哈希桶结构”这个词在不同上下文里含义不同的原因。

5.2 为什么需要控制桶数量为2的幂

这是性能优化里的经典trick。如果桶数量始终是2的幂,那么取模运算hash % n可以变成位运算hash & (n - 1)。

位运算比取模快得多。现代CPU上取模是一个除法指令,通常几十个周期,位运算只有一两个周期。哈希表扩容时翻倍,天然保持2的幂,所以很多哈希表实现直接使用与运算代替取模。

代价是:哈希函数的高位分布可能被浪费,因为你只用了低几位。所以当使用与运算时,哈希函数至少得保证低位足够随机。这就是为什么我之前提供的hash_int要做移位异或——它在把高位的随机性往低位扩散。

如果不用2的幂容量,而是取任意素数容量(旧式开放寻址法偏好素数桶数),则必须老老实实取模。这是两种设计思路的权衡:2的幂容量胜在速度,素数容量在哈希函数较差时能减少规律性冲突。现代哈希函数设计得好,2的幂是更优解。

5.3 缓存局部性与内存布局优化

链地址法有一个先天弱势:节点在堆上随机分配,遍历链表时几乎每次都触发cache miss。缓存未命中访问主存的代价大约是命中缓存的几十倍,这直接拉低了链地址法在顺序查找场景下的实际表现。

针对这一点,工程上有两个改进方向。

一是内存池分配:一次性预分配大块连续内存来管理节点,而不是每个节点单独new。这样新节点在内存地址上相邻,遍历时cache命中率显著提高。实现方式是维护一个空闲节点链表,alloc和free都从池子里取。

二是紧凑存储:把value直接放在桶数组里而不是通过指针间接访问,这样一次cache line能加载多个slot,查找性能大大提高。这正是开放寻址法在某些场景下能反超链地址法的原因。

我实际测试过:在每秒百万次随机插入删除的负载下,链地址法配合内存池可以比裸new快2~5倍。如果服务对延迟敏感,这5倍可能就是生死线。

5.4 自定义类型的哈希支持

这是日常使用unordered_map最容易踩坑的地方。自定义结构体想作为key,必须提供两个东西:一个哈希函数,一个判等函数。

struct Person { std::string name; int age; }; bool operator==(const Person& a, const Person& b) { return a.name == b.name && a.age == b.age; } struct PersonHash { size_t operator()(const Person& p) const { size_t h1 = std::hash<std::string>{}(p.name); size_t h2 = std::hash<int>{}(p.age); // 经典的组合哈希方法:用一个大素数交错混合 return h1 ^ (h2 + 0x9e3779b9 + (h1 << 6) + (h1 >> 2)); } }; std::unordered_map<Person, int, PersonHash> map;

这个组合哈希的思路是把多个字段的hash值混合在一起,而不是简单相加。简单相加的问题是Person{"a", 1}和Person{"a", 2}的哈希值只差1,容易扎堆。加上移位和异或后,各字段的贡献被充分混合。

强烈建议:如果两个对象相等,哈希值必须相等。这是哈希容器的铁律。如果你用属性A做哈希,但用属性A+B做判等,会导致明明相同的对象落在不同桶里,造成数据重复和查找失败。

6. 哈希表常见问题与排查技巧实录

6.1 哈希冲突风暴:为什么数据量不大但性能雪崩

有个常见的现象:表里明明只有几十个元素,但查找一次要几十微秒。这种量级的退化基本可以断定是大量key映射到了同一个桶,链表退化成了链表,哈希表变成了链表。

排查思路:在插入接口里加个统计逻辑,记录每个桶的链表最大长度。如果max_bucket_length明显大于平均值,比如平均2、最大100,那就说明哈希函数分布出了问题,或者key本身有某种模式恰好击穿了你的哈希函数。

解决办法分两步。第一步,换更好的哈希函数,比如mix后的整型哈希或字符串哈希。第二步,如果key确实有规律(比如全是以同一个前缀开头的字符串),可以采用加盐哈希,在哈希函数里混入一个随机种子,让散列结果与种子强相关,打散规律性。

注意:加盐哈希有个副作用——程序重启后种子变了,同一批key的存储顺序可能完全改变,如果依赖顺序来做持久化存储就会出问题。所以加盐只适合内存临时表。

6.2 迭代器失效问题

链地址法里,insert触发rehash后,所有迭代器失效;erase只让指向被删除节点的迭代器失效,其他迭代器不受影响。这是很多人踩过坑的地方。

我遇到过的真实场景:某同学在遍历unordered_map时,边遍历边插入。表面上看代码逻辑没错,但一旦插入触发扩容,他手里的迭代器瞬间变成野指针,直接崩溃。正确做法是先记下需要插入的key,遍历结束后再统一插入,或者把扩展后的内容放到临时map里,最后再合并。

开放寻址法更惨:任何一次erase(墓碑标记)后,你手里的迭代器如果没有同步检查deleted标志,下次访问就指向一个已删除的槽位。所以在开放寻址法里,迭代器失效规则比链地址法严格得多,这也是它很难做成通用容器库的原因之一。

6.3 内存碎片与节点分配开销

链地址法另一个隐蔽问题:节点在堆上离散分配,频繁插入删除会产生大量内存碎片。小节点(比如int->int)每个才16字节左右,但一次delete和后续new的分配器开销可能超过节点本身好几倍。

工具层面最简单的方案:给哈希表配一个内存池。

实现思路:

template <typename Node> class NodePool { private: std::vector<std::unique_ptr<Node[]>> chunks_; std::vector<Node*> free_list_; public: Node* allocate(const K& key, const V& value) { if (free_list_.empty()) { chunks_.push_back(std::make_unique<Node[]>(1024)); for (size_t i = 0; i < 1024; ++i) { free_list_.push_back(&chunks_.back()[i]); } } Node* ptr = free_list_.back(); free_list_.pop_back(); new (ptr) Node(key, value); // placement new return ptr; } void deallocate(Node* ptr) { ptr->~Node(); free_list_.push_back(ptr); } };

这个池子一次分配1024个节点的连续内存,之后alloc和free就只是从链表头取一个或还一个,零堆分配、零碎片。placement new只是构造对象,内存已经预先备好。

我实测过:把unordered_map换成自定义的哈希池版本后,在一千万次插入删除的高频场景下,耗时从4.2秒降到0.9秒,提升非常明显。

6.4 哈希算法被恶意的输入打爆

有一种攻击思路是持续向服务端发送精心构造的key,让大量key哈希到同一个桶,把哈希表退化成链表,拖垮CPU。这被称为哈希碰撞攻击。

防护手段有两个层级。第一层,换用强哈希函数,保证即使key有规律也无法预测分布。第二层,给哈希函数加随机种子(salted hash),每次进程启动重新随机化,攻击者无法预先构造触发碰撞的输入。

这里提醒一点:随机化哈希会造成同一输入在不同进程里有不同分布,这违背“可预期性”。标准库里有些容器选择不随机化就是为了二进制兼容性和确定性。选择哪种策略取决于你的场景,如果对外暴露输入接口且威胁模型里有恶意输入,随机化是很有必要的;如果是内部自用的高性能容器,固定哈希更合适。

6.5 一些调试哈希表的实用技巧

写哈希表时最容易出的bug是删除了节点但忘了维护prev,导致链表断链,或者rehash时忘了更新桶头。遇到这类问题,别急着print大法。

我常用的排查路径:

  1. 写一个validate()方法:遍历所有桶,检查节点数量是否等于size_,检查每个节点的桶索引是否等于hash(key) % buckets_.size(),检查链表是否成环(快慢指针走一遍)。
  2. 在insert、erase、rehash三个方法的关键变动处打印前后状态,尤其是节点地址和next指针,对比就能发现断链位置。
  3. 写一个简单的特性测试:插入1000个随机key,随即删除一半,再继续插入,每步都用find校验数据完整性,并额外维护一个std::map做对照,哈希表查询结果必须和std::map完全一致。

这套流程基本能在五分钟内定位90%以上的哈希表实现错误。

7. 链地址法完整实现参考

前面零散地给了不少代码片段,这里我把一个精简但完整的链地址法哈希表串起来,你可以直接拷走编译运行,观察哈希桶结构的具体行为。

#include <iostream> #include <vector> #include <string> size_t hash_int(int key) { key ^= key >> 16; key *= 0x7feb352d; key ^= key >> 15; return static_cast<size_t>(key); } template <typename K, typename V> struct HashNode { K key; V value; HashNode* next; HashNode(const K& k, const V& v) : key(k), value(v), next(nullptr) {} }; template <typename K, typename V> class HashTable { private: std::vector<HashNode<K, V>*> buckets_; size_t size_ = 0; double max_load_factor_ = 1.0; size_t get_index(const K& key) const { return hash_int(key) % buckets_.size(); } void rehash(size_t new_count) { std::vector<HashNode<K, V>*> new_buckets(new_count, nullptr); for (auto* head : buckets_) { while (head != nullptr) { auto* next = head->next; size_t idx = hash_int(head->key) % new_count; head->next = new_buckets[idx]; new_buckets[idx] = head; head = next; } } buckets_.swap(new_buckets); } public: explicit HashTable(size_t bucket_count = 8) : buckets_(bucket_count, nullptr) {} ~HashTable() { for (auto* head : buckets_) { while (head != nullptr) { auto* next = head->next; delete head; head = next; } } } void insert(const K& key, const V& value) { if (size_ + 1 > buckets_.size() * max_load_factor_) { rehash(buckets_.size() * 2); } size_t idx = get_index(key); for (auto* cur = buckets_[idx]; cur != nullptr; cur = cur->next) { if (cur->key == key) { cur->value = value; return; } } auto* node = new HashNode<K, V>(key, value); node->next = buckets_[idx]; buckets_[idx] = node; ++size_; } V* find(const K& key) { size_t idx = get_index(key); for (auto* cur = buckets_[idx]; cur != nullptr; cur = cur->next) { if (cur->key == key) { return &cur->value; } } return nullptr; } bool erase(const K& key) { size_t idx = get_index(key); HashNode<K, V>* cur = buckets_[idx]; HashNode<K, V>* prev = nullptr; while (cur != nullptr) { if (cur->key == key) { if (prev == nullptr) { buckets_[idx] = cur->next; } else { prev->next = cur->next; } delete cur; --size_; return true; } prev = cur; cur = cur->next; } return false; } size_t size() const { return size_; } size_t bucket_count() const { return buckets_.size(); } void print_structure() const { for (size_t i = 0; i < buckets_.size(); ++i) { std::cout << "bucket[" << i << "]:"; for (auto* cur = buckets_[i]; cur != nullptr; cur = cur->next) { std::cout << " (" << cur->key << " -> " << cur->value << ")"; } std::cout << "\n"; } } }; int main() { HashTable<int, std::string> table(4); table.insert(1, "one"); table.insert(5, "five"); table.insert(9, "nine"); table.insert(13, "thirteen"); table.insert(2, "two"); std::cout << "size=" << table.size() << " bucket_count=" << table.bucket_count() << "\n"; table.print_structure(); if (auto* v = table.find(9)) { std::cout << "find(9) = " << *v << "\n"; } table.erase(9); std::cout << "after erase(9), size=" << table.size() << "\n"; table.print_structure(); return 0; }

运行这段代码,你会看到插入触发扩容后,原本挤在同一个桶的key被重新分布到不同的桶——这就是rehash的过程。亲眼看到节点从旧桶“搬家”到新桶,比看一堆源码分析更容易建立直觉。

提示:这个实现的哈希函数只支持整型。如果要用字符串key,把hash_int(key)替换成前面的hash_string(key),模板参数里K改成std::string即可。注意hash_string和hash_int返回的都是size_t,所以调用处的取模逻辑不用改。

8. 两种方案的选型建议与最后几句经验

链地址法和开放寻址法不是谁替代谁的关系,而是适用场景不同。

链地址法适合通用容器、对象语义复杂、删除频繁、需要迭代器稳定的场景。C++标准库用它是合理的选择。如果你的项目自定义哈希表,大概率链地址法是最稳的起步点。

开放寻址法适合值类型轻量、key和value都是基本类型、需要极高缓存命中率、且愿意接受“不能真正删除元素”或“需要墓碑标记”这种约束的场景。内存紧张、追求吞吐量的系统里,开放寻址法的表现确实更亮眼。

我个人踩过几次坑之后,现在做选型时基本按这个流程:先想清楚“删除是否高频、key是否自定义对象、元素规模是否可能突然翻倍”,三个问题只要有一个回答是“是”,就倾向链地址法;如果三个都是“否”,再认真考虑开放寻址法。模板化实现里,我一般直接把两种方案封装成不同的类,接口保持一致,性能测试之后再切换。

哈希表这个数据结构看起来简单,真正实现一遍就会发现处处是细节:哈希函数的分布质量、负载因子的取舍、rehash的节点搬家方式、删除时链表指针的维护、内存池的设计。把这些细节全打通之后,再去看STL源码会觉得清晰很多,写其他涉及到hash的业务代码时,也能更准确地预判性能瓶颈出在哪里。希望这篇内容能帮你把哈希表这块硬骨头啃下来。

相关新闻

JVM内存模型与垃圾回收实战解剖:从原理到调优

JVM内存模型与垃圾回收实战解剖:从原理到调优

1. 这不是笔记&#xff0c;是JVM内存运行的“人体解剖图”你有没有过这种体验&#xff1a;代码写得飞起&#xff0c;一上线就OOM&#xff1b;GC日志满屏飘红&#xff0c;却像看天书&#xff1b;调优时改了-XX:UseG1GC&#xff0c;结果吞吐量不升反降&#xff0c;连自己写的对象…

2026/10/20 18:51:53 阅读更多 →
用Claude Code Skill打造终端AI决策顾问:历史案例与心学智慧结合

用Claude Code Skill打造终端AI决策顾问:历史案例与心学智慧结合

做决策这件事&#xff0c;我一度觉得自己被它耗干了。小到要不要换工作&#xff0c;大到要不要选一个方向全力投入&#xff0c;每次都是搜集一堆资料、问一圈人、做一页利弊表&#xff0c;最后还是在两个选项之间反复横跳。后来我做了一个很实际的事情&#xff1a;把Claude Cod…

2026/10/20 18:50:52 阅读更多 →
从杯架开会到手机芯片上车:汽车高品质的坐标系错位

从杯架开会到手机芯片上车:汽车高品质的坐标系错位

“为一个杯子开14次会&#xff0c;却在汽车上用手机芯片”——这句话最近在圈子里被转得很凶。先别急着把它当段子看&#xff0c;它背后藏着一个非常真实的产品决策矛盾&#xff1a;我们把用户的注意力当成唯一的品质标准&#xff0c;把发布会上的亮点当成产品护城河&#xff0…

2026/10/20 18:50:52 阅读更多 →

最新新闻

AI论文汇总如何变成技术雷达:从arXiv时间切片提取工程信号

AI论文汇总如何变成技术雷达:从arXiv时间切片提取工程信号

1. 这不是论文目录&#xff0c;而是一份AI研究动态的“施工日志”你点开一个标着“[arxiv-cs.AI] 汇总-2026.10.07-人工智能论文”的文件夹&#xff0c;第一反应可能是&#xff1a;又一份需要硬啃的文献清单&#xff1f;别急——我过去三年里&#xff0c;几乎每周都整理类似标题…

2026/10/20 19:31:02 阅读更多 →
深度学习水果识别实战:从CNN训练到Flask部署全流程

深度学习水果识别实战:从CNN训练到Flask部署全流程

1. 项目定位与整体设计思路先聊聊这个项目的来龙去脉。基于深度学习的水果识别&#xff0c;本质上是图像分类任务&#xff0c;也就是让计算机按照像素内容判断“这张图属于哪个类”。很多同学会选择它做毕业设计&#xff0c;原因很直接&#xff1a;它属于计算机视觉领域的经典方…

2026/10/20 19:31:02 阅读更多 →
Agent框架插件化设计:把扩展能力做成第一公民

Agent框架插件化设计:把扩展能力做成第一公民

最近把DeepSeek开源的那套Agent框架源码整体拆了一遍&#xff0c;说实话&#xff0c;拆完心里有点复杂——很多设计我以前自己折腾过&#xff0c;但没有想到可以做得这么系统。这套框架给我最大的启发&#xff0c;不是“Agent还能这么写”&#xff0c;而是“插件化居然可以做到…

2026/10/20 19:31:02 阅读更多 →
C#轻量级OPC UA客户端:5分钟连PLC读温度

C#轻量级OPC UA客户端:5分钟连PLC读温度

简介&#xff1a;本资源是一个面向工业自动化与.NET开发者的C# OPC客户端实战项目&#xff0c;聚焦于使用OPC Net Api Chs库实现与OPC服务器的连接、数据读写、事件订阅及异常处理等核心通信功能&#xff0c;适用于具备基础C#和WinForms开发能力的中初级工程师快速掌握工业协议…

2026/10/20 19:31:02 阅读更多 →
用QClaw打造微信聊天分析助手:意图识别与情绪解读的Skill实践

用QClaw打造微信聊天分析助手:意图识别与情绪解读的Skill实践

一个多月前&#xff0c;我把 QClaw 上常用的几个小技能整理成了自己的私藏工具箱&#xff0c;其中有一个人起初当玩笑写的 Skill&#xff0c;最后却成了我打开次数最多的一个——“恋爱大师”。它的作用听起来很玄乎&#xff1a;把微信里那些容易让人内耗的对话&#xff0c;拆成…

2026/10/20 19:31:02 阅读更多 →
iOS自定义相机开发:AVCaptureSession底层控制与实战优化

iOS自定义相机开发:AVCaptureSession底层控制与实战优化

1. 项目概述&#xff1a;为什么需要一个“自定义相机”而不是直接调用系统相册 在 iOS 开发中&#xff0c;“调用系统相机”这句看似简单的指令&#xff0c;背后藏着大量被封装掉的决策权。当你使用 UIImagePickerController 拍照时&#xff0c;你拿到的是一张 JPEG 或 PNG—…

2026/10/20 19:29:59 阅读更多 →

日新闻

CLRC663芯片Pin-to-Pin替代实战:从评估到量产全记录

CLRC663芯片Pin-to-Pin替代实战:从评估到量产全记录

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 1:01:30 阅读更多 →
ESP32舵机控制指南:原理、多路驱动与避坑实战

ESP32舵机控制指南:原理、多路驱动与避坑实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 1:01:30 阅读更多 →
位移法实现一阶低通滤波:单片机ADC采样降噪实战

位移法实现一阶低通滤波:单片机ADC采样降噪实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 1:01:30 阅读更多 →

周新闻

基于GMM的语音识别系统原理与MATLAB实现详解

基于GMM的语音识别系统原理与MATLAB实现详解

说到基于高斯混合模型&#xff08;GMM&#xff09;的语音识别系统&#xff0c;很多人第一反应就是一堆数学公式&#xff0c;觉得门槛很高。但如果你真的在MATLAB里把它落地跑通&#xff0c;会发现核心其实就是三件事&#xff1a;把声音变成特征向量&#xff0c;用GMM给每个类别…

2026/10/20 10:02:13 阅读更多 →
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理这四个字听起来挺唬人&#xff0c;但落到具体操作上&#xff0c;无非就是让计算机按你的想法“摆弄”像素。我在一个模拟项目X里用了一套特别经典的基础处理流程&#xff1a;对图像做反白、改调色板、彩色转灰度&#xff0c;最后把像素数据导出成txt文件。单看每一…

2026/10/19 17:32:41 阅读更多 →
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

机器人大脑技术盘点:具身智能的感知、决策与数据闭环

如果你的信息流最近也天天刷到“机器人大脑”这个词&#xff0c;你会发现一个很有意思的现象&#xff1a;每个人说的“大脑”根本不是同一个东西。有人指的是大语言模型&#xff0c;有人指的是运动控制系统&#xff0c;还有人指的是能理解物理世界的端到端模型。在2026年这个时…

2026/10/20 2:50:50 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 10:02:08 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 9:50:17 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 9:50:45 阅读更多 →