跳到主要内容哈希表核心实现:开放定址法与链地址法详解 | 极客日志C++算法
哈希表核心实现:开放定址法与链地址法详解
哈希表利用哈希函数建立键值映射以实现高效存取。文章解析哈希冲突处理机制与负载因子影响,阐述直接定址、除法散列等函数设计策略。重点对比开放定址法与链地址法的实现差异,包含线性探测、二次探测及哈希桶的具体代码逻辑,涵盖增删查改与扩容优化,为理解 unordered_map 等底层结构提供实践参考。
暖阳16 浏览 哈希表的两种灵魂:深入探索开放定址与链地址法的核心机密

前言
哈希表是数据结构中的'效率王者',通过哈希函数建立 key 与存储位置的映射,实现增删查改平均 O(1) 的时间复杂度,广泛应用于 unordered_map、缓存、字典等场景。但很多开发者只知其然不知其所以然——哈希冲突如何解决?不同哈希函数有何差异?开放定址法和链地址法该怎么实现?本文结合其中的核心知识点,从哈希表的基本概念入手,详解哈希函数设计、哈希冲突解决策略,最终完整实现**开放定址法(线性探测)和链地址法(哈希桶)**两种哈希表,帮你吃透哈希表的底层实现逻辑。
一、哈希表核心概念
1.1 哈希表的本质
哈希表(散列表)是一种'key-value'存储结构,核心是哈希函数和冲突解决策略:
- 哈希函数:将 key 映射到哈希表的存储位置(下标),公式为
h(key) = 存储位置;
- 核心目标:让 key 均匀分布,减少冲突,保证 O(1) 平均效率。
1.2 哈希冲突
两个不同的 key 通过哈希函数计算出相同的存储位置,称为哈希冲突(哈希碰撞)。冲突无法避免,只能通过优化哈希函数和冲突解决策略减少影响。
1.3 负载因子
衡量哈希表拥挤程度的指标,公式为:负载因子 (λ) = 存储的元素个数 (N) / 哈希表大小 (M)。
- λ 越大:冲突概率越高,空间利用率越高;
- λ 越小:冲突概率越低,空间利用率越低;
- 实践中:开放定址法 λ 通常控制在 0.7 以内,链地址法 λ 控制在 1 以内。
分析:假如哈希表中已经映射存储了 N 个值,哈希表的大小为 M,那么通过负载因子 = N/M,负载因子有些地方也翻译为载荷因子/装载因子等,他的英文为 load factor。负载因子越大,哈希冲突的概率越高,空间利用率越高;负载因子越小,哈希冲突的概率越低,空间利用率越低。
1.4 将关键字转为整数
我们将关键字映射到数组中位置,一般是整数好做映射计算,如果不是整数,我们要想办法转换成整数,这个细节我们后面代码实现中再进行细节展示。下面哈希函数部分我们讨论时,如果关键字不是整数,那么讨论的 Key 是关键字转换成的整数。
二、哈希函数设计
好的哈希函数能让 key 均匀分布,减少冲突,常用设计方法如下:
2.1 直接定址法
直接用 key 或 key 的线性变换作为存储位置,公式:h(key) = a*key + b。
- 适用场景:key 范围集中(如 0-99、a-z);
- 优点:无冲突,效率高;
- 缺点:key 范围分散时浪费内存(如 key 为 1、10000,需开 10001 大小的数组)。
分析:在关键字的范围比较集中时,直接定址法就是非常高效的方法,比如一组关键字都在 [0, 99] 之间,那么我们开一个 100 个数的数组,每个关键字 1 的值直接就算存储位置的下标。再比如一组关键字值都在 [a, z] 的小写字母,那么我们开一个 26 个数的数组,每个关键字 ascii 码和 -a ascii 码就是存储位置的下标。也就是说直接定址法本质就是用关键字计算出一个绝对位置或者相对位置。这个方法我们在计数排序部分已经用过了,其次在 string 章节的下面 OJ 也用过了。
class Solution {
public:
int firstUniqChar(string s) {
int count[26] = {0};
for (auto ch : s) {
count[ch - 'a']++;
}
for (int i = 0; i < s.size(); i++) {
if (count[s[i] - 'a'] == 1) {
return i;
}
}
return -1;
}
};
2.2 除法散列法(除留余数法)
最常用的哈希函数,公式:h(key) = key % M(M 为哈希表大小)。
- 关键优化:M 建议取不接近 2 的整数次幂的质数,避免 key 的后几位重复导致冲突(如 M=16 时,63 和 31 的余数都是 15);
- 例外:Java HashMap 用 2 的整数次幂作为 M,通过位运算优化效率,同时用异或操作让 key 所有位参与计算,保证分布均匀。
2.3 其他方法(了解)
- 乘法散列法:
h(key) = floor(M * ((A*key) % 1.0)),A 取黄金分割点 0.618,对 M 无要求;
- 全域散列法:通过随机选取哈希函数避免恶意攻击,公式:
h_ab(key) = ((a*key + b) % P) % M(P 为大质数);
- 字符串哈希:将字符串转为整数(如 BKDR 哈希,hash = hash*131 + 字符 ASCII 码),避免'abcd'和'bcad'这类字符串冲突。
2.4 字符串哈希实现(特化仿函数)
当 key 为字符串时,需手动实现哈希函数,将其转为整数:
template<class K>
struct HashFunc {
size_t operator()(const K& key) {
return (size_t)key;
}
};
template<>
struct HashFunc<string> {
size_t operator()(const string& key) {
size_t hash = 0;
for (auto ch : key) {
hash += ch;
hash *= 131;
}
return hash;
}
};
三、哈希冲突解决策略
冲突解决是哈希表实现的核心,主流分为 开放定址法 和 链地址法,其中链地址法更加重要一点,下面分别详解实现。
3.1 实现一:开放定址法(线性探测,二次探测)
开放定址法的核心:所有元素存储在哈希表数组中,冲突时按规则探测下一个空闲位置。
3.1.1 线性探测核心设计
- 状态标识:每个位置需标记
EMPTY(空)、EXIST(已存储)、DELETE(已删除),避免删除元素后影响后续查找;
- 探测规则:线性探测(冲突后依次向后查找空闲位置);
- 扩容策略:负载因子≥0.7 时扩容,哈希表大小取质数(参考 SGI STL 的质数表,后面的代码实现里面有)。
分析补充:从发生哈希冲突的位置开始,依次线性向后探测,直到寻找到下一个没有存储数据的位置为止,如果走到哈希表尾,就回绕到哈希表头的位置。
h(19) = 8,h(30) = 8,h(5) = 5,h(36) = 3,h(13) = 2,h(20) = 9,h(21) = 10,h(12) = 1
3.1.2 完整代码实现
#pragma once
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
enum State {
EMPTY,
EXIST,
DELETE
};
static const int __stl_num_primes = 28;
static const unsigned long __stl_prime_list[__stl_num_primes] = {
53, 97, 193, 389, 769, 1543, 3079, 6151, 12289, 24593, 49157, 98317,
196613, 393241, 786433, 1572869, 3145739, 6291469, 12582917, 25165843,
50331653, 100663319, 201326611, 402653189, 805306457, 1610612741,
3221225473, 4294967291
};
inline unsigned long __stl_next_prime(unsigned long n) {
const unsigned long* first = __stl_prime_list;
const unsigned long* last = __stl_prime_list + __stl_num_primes;
const unsigned long* pos = lower_bound(first, last, n);
return pos == last ? *(last - 1) : *pos;
}
template<class K, class V>
struct HashData {
pair<K, V> _kv;
State _state = EMPTY;
};
template<class K>
struct HashFunc {
size_t operator()(const K& key) {
return (size_t)key;
}
};
template<>
struct HashFunc<string> {
size_t operator()(const string& key) {
size_t hash = 0;
for (auto ch : key) {
hash += ch;
hash *= 131;
}
return hash;
}
};
template<class K, class V, class Hash = HashFunc<K>>
class HashTable {
public:
HashTable() : _tables(__stl_next_prime(1)) {}
bool Insert(const pair<K, V>& kv) {
if (Find(kv.first)) return false;
if ((double)_n / (double)_tables.size() >= 0.7) {
HashTable<K, V, Hash> newht;
newht._tables.resize(__stl_next_prime(_tables.size() + 1));
for (size_t i = 0; i < _tables.size(); i++) {
if (_tables[i]._state == EXIST) {
newht.Insert(_tables[i]._kv);
}
}
_tables.swap(newht._tables);
}
Hash hs;
size_t hash0 = hs(kv.first) % _tables.size();
size_t i = 1;
size_t hashi = hash0;
while (_tables[hashi]._state == EXIST) {
hashi = (hash0 + i) % _tables.size();
++i;
}
_tables[hashi]._kv = kv;
_tables[hashi]._state = EXIST;
++_n;
return true;
}
HashData<K, V>* Find(const K& key) {
Hash hs;
size_t hash0 = hs(key) % _tables.size();
size_t i = 1;
size_t hashi = hash0;
while (_tables[hashi]._state != EMPTY) {
if (_tables[hashi]._state != DELETE && _tables[hashi]._kv.first == key) {
return &_tables[hashi];
}
hashi = (hash0 + i) % _tables.size();
++i;
}
return nullptr;
}
bool Erase(const K& key) {
HashData<K, V>* ret = Find(key);
if (ret) {
ret->_state = DELETE;
--_n;
return true;
} else {
return false;
}
}
private:
std::vector<HashData<K, V>> _tables;
size_t _n = 0;
};
#define _CRT_SECURE_NO_WARNINGS 1
#include <iostream>
#include <unordered_map>
using namespace std;
#include "HashTable.h"
void TestHT1() {
int a[] = { 19, 30, 5, 36, 13, 20, 21, 12, 58 };
HashTable<int, int> ht;
for (auto e : a) {
ht.Insert({ e, e });
}
ht.Insert({ -2, 2 });
ht.Insert({ 22, 22 });
cout << "删除 5 之前查找 5,58 结果:" << endl;
cout << ht.Find(5) << endl;
cout << ht.Find(58) << endl;
ht.Erase(5);
cout << "删除 5 之后查找 5,58 结果:" << endl;
cout << ht.Find(5) << endl;
cout << ht.Find(58) << endl;
}
struct HashFuncString {
size_t operator()(const string& key) {
size_t hash = 0;
for (auto ch : key) {
hash += ch;
hash *= 131;
}
return hash;
}
};
void TestHT2() {
HashTable<string, string> dict;
dict.Insert({ "string", "字符串" });
dict.Insert({ "string", "字符串 1" });
dict.Insert({ "left", "左边" });
dict.Insert({ "right", "右边" });
cout << dict.Find("string") << endl;
cout << dict.Find("left") << endl;
cout << dict.Find("left ") << endl;
HashFuncString hfs;
cout << hfs("abcd") << endl;
cout << hfs("acbd") << endl;
cout << hfs("aadd") << endl;
unordered_map<string, string> dictmap;
dictmap.insert({ "string", "字符串" });
}
int main() {
cout << "测试一:删除 5 后再查找" << endl;
TestHT1();
cout << endl;
cout << "测试二:测试 string 类型" << endl;
TestHT2();
return 0;
}
- 状态标识:删除元素时不直接清空,而是标记为
DELETE,确保后续查找冲突元素时能继续探测;
- 扩容逻辑:扩容后需重新映射所有元素(因为哈希表大小变化,存储位置改变);
- 线性探测缺陷:容易出现'群集(堆积)'(连续位置被占用,后续冲突集中争夺同一位置),可通过二次探测、双重探测优化。
3.1.3 二次探测
- 从发生冲突的位置开始,依次左右按二次方跳跃式探测,直到寻找下一个没有存储数据的位置为止,如果往右走到哈希表尾,则回绕到哈希表头的位置;如果往左走到哈希表头,则回绕到哈希表尾的位置;
- h(key) = hash0 = key%M,hash0 位置冲突了,则二次探测公式为:
h(key,i) = hashi = (hash0 +(-) i^2)% M,i = {1,2,3,……,M/2}
- 二次探测当 hashi = (hash0 - i^2) % M 时,当 hashi<0 时,需要 hashi+=M
- 下面演示
{19,30,52,63,11,22} 等这一组值映射到 M=11 的表中
h(19) = 8, h(30) = 8, h(52) = 8, h(63) = 8, h(11) = 0, h(22) = 0
3.1.4 双重探测(了解)
- 第一个哈希函数计算出的值发生冲突,使用第二个哈希函数计算出一个跟 key 相关的偏移量值,不断往后探测,直到寻找到下一个没有存储数据的位置为止。
- h1(key) = hash0 = key % M,hash0 位置冲突了,则双重探测公式为:
hc(key,i) = hashi = (hash0 + i ∗ h2 (key)) % M,i = {1, 2, 3, ..., M}、
- 要求 h2(key) < M 且 h2(key)和 M 互为质数,有两种简单的取值方法:1. 当 M 为 2 整数幂时,h2(key) 从 [0,M-1] 任选一个奇数;2. 当 M 为质数时,h2(key) = key % (M-1) + 1
- 保证 h2(key)与 M 互质是因为根据固定的偏移量所寻址的所有位置将形成一个群,若最大公约数 p = gcd(M,h1(key)) > 1,那么所能寻址的位置的个数为 M/P < M,使得对于一个关键字来说无法充分利用整个散列表。举例来说,若初始探查位置为 1,偏移量为 3,整个散列表大小为 12,那么所能寻址的位置为 {1,4,7,10},寻址个数为 12/gcd(12,3) = 4
- 下面演示
{19,30,52,74} 等这⼀组值映射到 M=11 的表中,设 h2 (key) = key%10 + 1
3.2 实现二:链地址法(哈希桶)
链地址法的核心:哈希表数组存储链表头指针,冲突元素链接成链表(哈希桶),不占用其他位置。
- 结构:哈希表是指针数组,每个元素是链表头指针,冲突元素通过链表链接;
- 扩容策略:负载因子≥1 时扩容(链地址法负载因子可大于 1);
- 优势:无群集问题,空间利用率高,实现简单。
- 分析补充:


#pragma once
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
static const int __stl_num_primes = 28;
static const unsigned long __stl_prime_list[__stl_num_primes] = {
53, 97, 193, 389, 769, 1543, 3079, 6151, 12289, 24593, 49157, 98317,
196613, 393241, 786433, 1572869, 3145739, 6291469, 12582917, 25165843,
50331653, 100663319, 201326611, 402653189, 805306457, 1610612741,
3221225473, 4294967291
};
inline unsigned long __stl_next_prime(unsigned long n) {
const unsigned long* first = __stl_prime_list;
const unsigned long* last = __stl_prime_list + __stl_num_primes;
const unsigned long* pos = lower_bound(first, last, n);
return pos == last ? *(last - 1) : *pos;
}
template<class K>
struct HashFunc {
size_t operator()(const K& key) {
return (size_t)key;
}
};
template<>
struct HashFunc<string> {
size_t operator()(const string& key) {
size_t hash = 0;
for (auto ch : key) {
hash += ch;
hash *= 131;
}
return hash;
}
};
namespace hash_bucket {
template<class K, class V>
struct HashNode {
pair<K, V> _kv;
HashNode<K, V>* _next;
HashNode(const pair<K, V>& kv) : _kv(kv), _next(nullptr) {}
};
template<class K, class V, class Hash = HashFunc<K>>
class HashTable {
typedef HashNode<K, V> Node;
public:
HashTable() : _tables(__stl_next_prime(1), nullptr), _n(0) {}
~HashTable() {
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;
}
bool Insert(const pair<K, V>& kv) {
if (Find(kv.first)) return false;
Hash hs;
if (_n == _tables.size()) {
std::vector<Node*> newtables(__stl_next_prime(_tables.size() + 1), nullptr);
for (size_t i = 0; i < _tables.size(); i++) {
Node* cur = _tables[i];
while (cur) {
Node* next = cur->_next;
size_t hashi = hs(cur->_kv.first) % newtables.size();
cur->_next = newtables[hashi];
newtables[hashi] = cur;
cur = next;
}
_tables[i] = nullptr;
}
_tables.swap(newtables);
}
size_t hashi = hs(kv.first) % _tables.size();
Node* newnode = new Node(kv);
newnode->_next = _tables[hashi];
_tables[hashi] = newnode;
++_n;
return true;
}
Node* Find(const K& key) {
Hash hs;
size_t hashi = hs(key) % _tables.size();
Node* cur = _tables[hashi];
while (cur) {
if (cur->_kv.first == key) {
return cur;
}
cur = cur->_next;
}
return nullptr;
}
bool Erase(const K& key) {
Hash hs;
size_t hashi = hs(key) % _tables.size();
Node* prev = nullptr;
Node* cur = _tables[hashi];
while (cur) {
if (cur->_kv.first == key) {
if (prev == nullptr) {
_tables[hashi] = cur->_next;
} else {
prev->_next = cur->_next;
}
--_n;
delete cur;
return true;
}
prev = cur;
cur = cur->_next;
}
return false;
}
private:
std::vector<Node*> _tables;
size_t _n;
};
}
- 节点迁移:扩容时直接移动旧表节点到新表,不新建节点,减少内存开销;
- 链表操作:插入用头插法(效率高),删除需记录前驱节点;
- 极端优化:Java8 的 HashMap 中,当链表长度超过 8 时,会将链表转为红黑树,避免单桶过长导致效率退化到 O(N)。
namespace hash_bucket {
void TestHT1() {
int a[] = { 19, 30, 5, 36, 13, 20, 21, 12, 58 };
HashTable<int, int> ht;
for (auto e : a) {
ht.Insert({ e, e });
}
ht.Insert({ -2, 2 });
ht.Insert({ 22, 22 });
ht.Insert({ 44, 44 });
ht.Erase(58);
ht.Erase(36);
}
void TestHT2() {
HashTable<string, string> dict;
dict.Insert({ "string", "字符串" });
dict.Insert({ "string", "字符串 1" });
dict.Insert({ "left", "左边" });
dict.Insert({ "right", "右边" });
cout << dict.Find("string") << endl;
cout << dict.Find("left") << endl;
cout << dict.Find("left ") << endl;
}
}
int main() {
cout << "测试 2:" << endl;
hash_bucket::TestHT1();
cout << endl;
cout << "测试 3:" << endl;
hash_bucket::TestHT2();
}
3.3 两种实现对比
| 对比维度 | 开放定址法(线性探测) | 链地址法(哈希桶) |
|---|
| 空间利用率 | 较低(需预留空闲位置,装载因子λ通常≤0.7) | 较高(冲突元素链成链表,装载因子λ可以≥1) |
| 冲突处理 | 线性探测,易产生'一次群集'现象 | 链表存储,冲突元素被归入同一桶中,无群集问题 |
| 实现复杂度 | 较高(需处理状态标识、扩容迁移逻辑复杂) | 较低(主要是链表操作,逻辑相对简单) |
| 查找效率 | 平均 O(1),最坏 O(N)(群集严重时退化) | 平均 O(1),最坏 O(k)(k 为单个桶的链表长度) |
| 适用场景 | 空间充足、数据量固定或可预测的场景 | 高频插入删除、数据量动态变化的场景(如 C++ unordered_map) |
| 缓存性能 | 更好(数据连续存储, locality 高) | 较差(链表节点在内存中不连续,访问可能跳跃) |
| 扩容操作 | 成本高(所有元素需要重新哈希并迁移到新表) | 成本相对较低(只需重新哈希,节点可重新挂载) |
结语
哈希表的核心是'哈希函数 + 冲突解决':好的哈希函数保证 key 均匀分布,合理的冲突策略保证效率稳定。开放定址法适合空间充足的场景,链地址法因实现简单、无群集问题,成为工业级实现的首选(如 C++ 的 unordered_map、Java 的 HashMap)。本文实现的两种哈希表,覆盖了哈希表的核心细节:质数扩容、字符串哈希、元素迁移、状态标识等。掌握这些细节后,不仅能理解 STL 容器的底层实现,更能根据实际场景选择合适的哈希表设计。
相关免费在线工具
- 加密/解密文本
使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
- Gemini 图片去水印
基于开源反向 Alpha 混合算法去除 Gemini/Nano Banana 图片水印,支持批量处理与下载。 在线工具,Gemini 图片去水印在线工具,online
- Base64 字符串编码/解码
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
- Base64 文件转换器
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
- Markdown转HTML
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
- HTML转Markdown
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online