系列文章目录
提示:这里是系列文章的专栏
提示:以下是文章目录哦!
文章目录
目录
[一. 哈希表基础核心概念](#一. 哈希表基础核心概念)
[1.1 哈希表定义与核心思想](#1.1 哈希表定义与核心思想)
[1.2 直接定址法](#1.2 直接定址法)
[力扣 -字符串中的第一个唯一字符](#力扣 -字符串中的第一个唯一字符)
[1.3 哈希冲突(哈希碰撞)](#1.3 哈希冲突(哈希碰撞))
[1.4 负载因子](#1.4 负载因子)
[1.5 关键字转整数规则](#1.5 关键字转整数规则)
[二. 常见哈希函数详解](#二. 常见哈希函数详解)
[2.1 除留余数法(除法散列法)](#2.1 除留余数法(除法散列法))
[2.2 乘法散列法(了解)](#2.2 乘法散列法(了解))
[2.3 全域散列法(了解)](#2.3 全域散列法(了解))
[2.4 其他哈希方法](#2.4 其他哈希方法)
[三. 哈希冲突解决方案一:开放定址法](#三. 哈希冲突解决方案一:开放定址法)
[3.1 核心思想](#3.1 核心思想)
[3.2 三种探测方式](#3.2 三种探测方式)
[3.2.1 线性探测](#3.2.1 线性探测)
[3.2.2 二次探测](#3.2.2 二次探测)
[3.2.3 双重散列](#3.2.3 双重散列)
[3.3 开放定址法特殊状态设计](#3.3 开放定址法特殊状态设计)
[3.4 哈希仿函数与 string 哈希特化](#3.4 哈希仿函数与 string 哈希特化)
[三、重点:string 特化版本 HashFunc](#三、重点:string 特化版本 HashFunc)
[3.5 质数扩容表](#3.5 质数扩容表)
[3.6 开放定址法完整实现(带详细注释)](#3.6 开放定址法完整实现(带详细注释))
[3.7 开放定址法难点总结](#3.7 开放定址法难点总结)
[四. 哈希冲突解决方案二:链地址法(拉链法 / 哈希桶)](#四. 哈希冲突解决方案二:链地址法(拉链法 / 哈希桶))
[4.1 核心思想](#4.1 核心思想)
[4.2 特性对比](#4.2 特性对比)
[4.3 极端场景优化](#4.3 极端场景优化)
[4.4 链地址法完整实现(带详细注释)](#4.4 链地址法完整实现(带详细注释))
[4.5 链地址法扩容优势](#4.5 链地址法扩容优势)
[五. 哈希表核心难点深度拆解](#五. 哈希表核心难点深度拆解)
[六. 知识点总结与面试高频考点](#六. 知识点总结与面试高频考点)
[6.1 两种冲突解决方式对比](#6.1 两种冲突解决方式对比)
[6.2 哈希函数设计原则](#6.2 哈希函数设计原则)
[6.3 面试高频问答](#6.3 面试高频问答)
[6.4 实际应用场景](#6.4 实际应用场景)
前言
提示:这里可以添加本文要记录的大概内容:
哈希表是数据结构中空间换时间的经典代表,凭借平均 O (1) 的增删查效率,成为算法刷题、STL 底层(unordered_map/unordered_set)、工程开发的核心结构。很多开发者只会直接调用库函数,却不懂哈希映射原理、哈希冲突成因、负载因子作用,更不会手写底层实现
本文基于 C++ 模板从零拆解哈希表完整知识体系:从哈希基础概念、常用哈希函数,到开放定址法(线性探测)、链地址法(哈希桶)两种冲突解决方案,附带完整可运行代码 + 逐行详细注释,对负载因子扩容、质数表设计、字符串哈希转换、删除状态标记等难点层层拆分,帮你彻底吃透哈希表底层逻辑,搞定面试手撕与原理问答
提示:以下是本篇文章正文内容
一. 哈希表基础核心概念
1.1 哈希表定义与核心思想
哈希也叫散列,是一种特殊的数据组织方式。 本质核心:通过哈希函数,把关键字 Key 和数组存储位置建立固定映射关系。插入数据时用哈希函数算出下标存入,查找时同样通过函数直接计算位置,实现快速访问,理想情况下做到 O (1) 查找效率
1.2 直接定址法
原理
当关键字范围高度集中、离散度小时,使用直接定址法最简单高效: 直接用关键字本身、或关键字偏移量,作为数组下标进行存储
典型场景
- 关键字集中在
[0,99],直接开 100 大小数组,key 就是下标 - 小写字母 a~z,用
字符ASCII - 'a'ASCII映射为 0~25 下标
力扣 -字符串中的第一个唯一字符

优缺点总结
- 优点:实现简单、无哈希冲突、访问极快
- 缺点:关键字范围分散时极度浪费内存,甚至内存无法承受
1.3 哈希冲突(哈希碰撞)
当关键字范围分散,不能用直接定址法时,引入哈希函数 h(key),把 key 映射到哈希表 [0,M) 下标区间。
哈希冲突定义:两个不同的 Key,经过哈希函数计算后,映射到了同一个存储位置。
理想中可以设计完美哈希函数完全避免冲突,但实际工程中冲突不可避免。我们只能:
- 设计优秀哈希函数,尽量减少冲突;
- 配套成熟的冲突解决策略。
1.4 负载因子
负载因子也叫载荷因子、装载因子,英文 load factor
公式:

特性规律:
- 负载因子越大 → 哈希冲突概率越高 → 空间利用率越高
- 负载因子越小 → 哈希冲突概率越低 → 空间利用率越低
开放定址法负载因子必须小于 1;链地址法负载因子可以大于 1。工程中一般控制负载因子在 0.7~1 之间触发扩容,平衡冲突与空间
1.5 关键字转整数规则
哈希映射计算依赖整数下标,若 Key 不是整型(string、日期、自定义类型),需要先通过哈希算法转换成一个合法整型,再做取模映射。后续所有哈希函数讨论,都默认 Key 已经转为整型值
二. 常见哈希函数详解
一个优秀哈希函数目标:让所有关键字等概率、均匀散列分布在哈希表各个位置,降低冲突概率。
2.1 除留余数法(除法散列法)
公式
h(key)=key%M
M 为哈希表容量,取余数作为存储下标。
原理本质
相当于保留 key 二进制 / 十进制后若干位,后几位相同的 key 一定会冲突。
避坑要点
- 尽量不要让 M 为 2 的幂、10 的幂 :

- 教材推荐:M 取不接近 2 整数次幂的质数;
- 工程灵活用法:Java HashMap 刻意用 2 的整数次幂做容量,利用位运算替代取模提升效率,再通过高低位异或让哈希值分布更均匀,属于实战优化,不必死扣书本理论。
2.2 乘法散列法(了解)
对哈希表容量 M 无特殊要求。 步骤:
- 关键字乘常数 A(0<A<1),取出小数部分
- 用 M 乘以小数部分,向下取整为哈希下标
公式: h(key)=floor(M×((A×key)%1.0)) 业界常用黄金分割点:A=(5−1)/2≈0.618
2.3 全域散列法(了解)
若哈希函数固定公开,容易被恶意构造数据,让所有 key 映射到同一位置,造成严重哈希攻击
解决思路:引入随机性 ,初始化时随机选一个散列函数使用,攻击者无法预判。 公式: hab(key)=((a×key+b)%P)%M P 选大质数,a、b 随机选取;注意:初始化选定后全程固定,不能每次增删查都换函数,否则查找失败
2.4 其他哈希方法
教材中还有平方取中法、折叠法、随机数法、数学分析法等,多用于特定业务场景,常规哈希表开发只需掌握除留余数法即可
三. 哈希冲突解决方案一:开放定址法
3.1 核心思想
所有元素全部存放在哈希表数组内部 ; 发生冲突时,按照固定规则向后探测,找到一个空闲位置存入。 特点:负载因子严格小于 1
3.2 三种探测方式
3.2.1 线性探测
冲突后从当前位置开始,依次向后逐个探测,到表尾则循环绕到表头
公式:

缺点:容易产生群集 / 堆积现象,连续冲突位置会扎堆,后续冲突都争抢后方位置,查找效率下降
下面演示 {19,30,5,36,13,20,21,12} 等这一组值映射到M=11的表中
1.题目条件拆解
- 哈希表容量:M=11(下标 0~10)
- 待插入数据:
{19, 30, 5, 36, 13, 20, 21, 12} - 哈希函数:
h(key) = key % 11
2.先算每个 key 的初始哈希位置
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.逐个插入过程(按题目给的顺序)


3.2.2 二次探测
为改善线性探测堆积问题,采用平方跳跃探测
公式:

正负双向跳跃,有效缓解群集问题
下面演示 {19,30,52,63,11,22} 等这⼀组值映射到M=11的表中
1.题目条件拆解
- 哈希表容量:
M=11(下标 0~10) - 待插入数据:
{19, 30, 52, 63, 11, 22} - 哈希函数:
h(key) = key % 11
2.先算每个 key 的初始哈希位置
h(19) = 8, h(30) = 8, h(52) = 8, h(63) = 8, h(11) = 0, h(22) = 0
3.按顺序插入,一步步看二次探测的过程


3.2.3 双重散列
用两个哈希函数:
- h1(key) 计算初始位置;
- h2(key) 计算探测偏移量。
公式: hashi=(hash0+i×h2(key))%M 要求 h2(key) 与容量 M 互质,保证能遍历到哈希表所有位置
下面演示 {19,30,52,74} 等这一组值映射到M=11的表中,设 h 2 ( key ) = key %10 + 1

3.3 开放定址法特殊状态设计
不能直接物理删除元素,否则会打断线性探测路径,导致后续元素查找失败。 引入三种状态:
EMPTY:位置为空,从未存放数据EXIST:元素正常存在DELETE:元素已逻辑删除,位置保留,不阻断探测路径
3.4 哈希仿函数与 string 哈希特化
普通整型可以直接强转做哈希,string 等类型需要自定义哈希转换。 采用 BKDR 哈希:乘质数累加字符 ASCII,让每个字符和顺序都参与计算,避免简单累加带来的冲突
哈希仿函数完整代码

先看通用模板:HashFunc<K>

1. 它是什么?
这是一个仿函数(函数对象) ,本质就是个重载了operator()的结构体,所以它可以像函数一样被调用
2. 它的作用
对于int、long、char这类本身就是整数的类型,直接把 key 转成size_t(无符号整数)返回就行
- 比如
int key = 100,直接返回(size_t)100,哈希表再用这个值对容量取模,就能得到下标 size_t是无符号整数,避免负数下标,是 C++ 里专门给数组下标用的类型
3. 为什么要这么写?
因为哈希表的底层逻辑,是把 key 映射成数组下标,下标必须是无符号整数
- 整型 key:本身就是数字,直接转成无符号数就能用
- 非整型 key(比如
string):没法直接转,需要额外处理,这就是下面特化版本要做的事
重点:string 特化版本 HashFunc<string>

1. 为什么 string 不能直接像 int 那样转?
string本质是一串字符,比如"abc",你没法直接把它当成一个数字用。 如果直接简单累加字符的 ASCII 值,会有个致命问题:
- 比如
"abc"和"cba",字符相同顺序不同,累加结果会一样,哈希冲突严重。 - 再比如
"ab"和"ba",也会出现同样的问题。
所以我们需要一个更聪明的算法,让不同顺序、不同字符的字符串,得到不同的哈希值 ,这就是BKDR哈希。
2. BKDR 哈希的原理

初始化哈希值为 0,从 0 开始计算

auto e : key:遍历字符串里的每个字符,比如"abc"会依次取'a'、'b'、'c'hash *= 131:乘一个质数 131(也可以用 13331、31 等,都是工程里常用的质数)hash += e:加上当前字符的 ASCII 值
举个例子,算一下"abc"的过程:
- 初始
hash = 0 - 取
'a':hash = 0 * 131 + 'a' = 97 - 取
'b':hash = 97 * 131 + 'b' = 97*131 + 98 = 12807 + 98 = 12905 - 取
'c':hash = 12905 * 131 + 'c' = 12905*131 + 99 = 1690555 + 99 = 1690654
再算一下"cba":
- 初始
hash = 0 - 取
'c':hash = 0 * 131 + 'c' = 99 - 取
'b':hash = 99 * 131 + 'b' = 99*131 + 98 = 12969 + 98 = 13067 - 取
'a':hash = 13067 * 131 + 'a' = 13067*131 + 97 = 1711777 + 97 = 1711874
可以看到,"abc"和"cba"得到了完全不同的哈希值,避免了简单累加的冲突问题
3. 为什么选 131 这个质数?
- 质数的特性是,它和其他数相乘时,能让结果分布更均匀,减少哈希冲突;
- 131 是个工程里很常用的质数,和
size_t的范围适配,溢出后也能得到比较均匀的分布; - 其他常用的还有 31、13331,效果都差不多,选 131 是个约定俗成的写法
三、重点:string 特化版本 HashFunc<string>
3.5 质数扩容表
为了让哈希表容量始终为质数、减少除留余数法冲突,借鉴 SGI STL 预设质数表,每次扩容取下一个更大质数
3.6 开放定址法完整实现(带详细注释)
cpp
#include <vector>
#include <string>
#include <algorithm>
using namespace std;
// 哈希位置状态
enum State
{
EXIST, // 元素存在
EMPTY, // 位置为空
DELETE // 逻辑删除
};
// 哈希表存储单元
template<class K, class V>
struct HashData
{
pair<K, V> _kv;
State _state = EMPTY;
};
// 开放定址哈希表
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
public:
// 获取下一个质数(STL质数表)
inline unsigned long __stl_next_prime(unsigned long n)
{
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
};
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;
}
// 构造:初始化质数容量
HashTable()
{
_tables.resize(__stl_next_prime(0));
}
// 插入键值对
bool Insert(const pair<K, V>& kv)
{
// 重复元素不插入
if (Find(kv.first))
return false;
// 负载因子 >=0.7 触发扩容
if (_n * 10 / _tables.size() >= 7)
{
HashTable<K, V, Hash> newHT;
// 扩容到下一个质数
newHT._tables.resize(__stl_next_prime(_tables.size() + 1));
// rehash:旧表有效数据重新映射插入新表
for (size_t i = 0; i < _tables.size(); ++i)
{
if (_tables[i]._state == EXIST)
{
newHT.Insert(_tables[i]._kv);
}
}
// 交换新旧表
_tables.swap(newHT._tables);
}
Hash hash;
// 初始哈希位置
size_t hash0 = hash(kv.first) % _tables.size();
size_t hashi = hash0;
size_t i = 1;
// 线性探测:找空闲位置
while (_tables[hashi]._state == EXIST)
{
hashi = (hash0 + i) % _tables.size();
++i;
}
// 存入数据并标记状态
_tables[hashi]._kv = kv;
_tables[hashi]._state = EXIST;
++_n;
return true;
}
// 查找key
HashData<K, V>* Find(const K& key)
{
Hash hash;
size_t hash0 = hash(key) % _tables.size();
size_t hashi = hash0;
size_t i = 1;
// 遇到EMPTY停止查找
while (_tables[hashi]._state != EMPTY)
{
if (_tables[hashi]._state == EXIST && _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 == nullptr)
return false;
ret->_state = DELETE;
--_n;
return true;
}
private:
vector<HashData<K, V>> _tables;
size_t _n = 0; // 有效元素个数
};
3.7 开放定址法难点总结
- 必须设置 DELETE 状态,防止探测路径断裂;
- 负载因子控制在 0.7,平衡冲突与空间;
- 扩容必须用质数表,降低除留余数法冲突;
- 线性探测简单但易堆积,二次 / 双重探测优化堆积问题。
四. 哈希冲突解决方案二:链地址法(拉链法 / 哈希桶)
4.1 核心思想
哈希底层是指针数组 ,每个位置称为一个桶; 映射到同一位置的冲突元素,挂载成单链表挂在桶下
所有冲突元素不占用哈希表数组本身空间,而是用链表链式存储
4.2 特性对比
- 负载因子可以大于 1,无严格上限
- 不存在开放定址法的群集堆积问题
- 删除节点直接物理释放,不需要 DELETE 标记
- STL
unordered_map底层默认采用链地址法
4.3 极端场景优化
个别桶链表过长时,查找效率退化到 O (n); Java8 HashMap 优化:链表长度超过阈值自动转为红黑树,把复杂度降到 O (logN)
4.4 链地址法完整实现(带详细注释)
cpp
#include <vector>
#include <string>
#include <algorithm>
using namespace std;
// 哈希桶链表节点
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:
// 获取下一个质数
inline unsigned long __stl_next_prime(unsigned long n)
{
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
};
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;
}
// 构造:初始化指针数组为空
HashTable()
{
_tables.resize(__stl_next_prime(0), nullptr);
}
// 析构:释放所有链表节点
~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;
}
}
// 插入元素
bool Insert(const pair<K, V>& kv)
{
Hash hs;
size_t hashi = hs(kv.first) % _tables.size();
// 负载因子等于1 触发扩容
if (_n == _tables.size())
{
// 新建更大容量指针数组
vector<Node*> newtables(__stl_next_prime(_tables.size() + 1), nullptr);
// 遍历旧表,节点原地迁移 rehash
for (size_t i = 0; i < _tables.size(); ++i)
{
Node* cur = _tables[i];
while (cur)
{
Node* next = cur->_next;
// 重新计算新下标
size_t newHash = hs(cur->_kv.first) % newtables.size();
// 头插到新表对应桶
cur->_next = newtables[newHash];
newtables[newHash] = cur;
cur = next;
}
_tables[i] = nullptr;
}
_tables.swap(newtables);
}
// 头插法插入链表
Node* newnode = new Node(kv);
newnode->_next = _tables[hashi];
_tables[hashi] = newnode;
++_n;
return true;
}
// 查找key
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;
}
// 删除key
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;
delete cur;
--_n;
return true;
}
prev = cur;
cur = cur->_next;
}
return false;
}
private:
vector<Node*> _tables; // 桶数组:存链表头指针
size_t _n = 0; // 总有效元素个数
};
4.5 链地址法扩容优势
扩容时不新建节点,直接把旧表链表节点重新计算哈希、迁移到新表,节省内存开销和创建销毁开销,效率更高。
五. 哈希表核心难点深度拆解
-
开放定址法为什么要 DELETE 状态? 直接清空位置会截断线性探测路径,导致后续元素查找不到;逻辑标记删除保留探测链路。
-
哈希表容量为什么优先选质数? 除留余数法中,质数可以让 key 散列更均匀,避免 2 的幂、10 的幂带来的高位失效、集中冲突问题。
-
string 哈希为什么不能直接累加 ASCII? 不同字符串字符相同、顺序不同时,累加和一致,冲突严重;BKDR 乘质数加权,让字符顺序和每个字符都参与哈希计算,分布更均匀。
-
开放定址负载因子 <1,链地址可以> 1? 开放定址所有元素挤在数组里,满了就无位置探测;链地址用链表挂载,一个桶可以挂无限元素,不受容量物理限制。
-
Java HashMap 为什么用 2 的幂次容量? 利用位运算替代取模,计算更快;再通过高低位异或扰动,弥补 2 的幂次哈希分布不均的缺陷,属于工程实战优化。
六. 知识点总结与面试高频考点
6.1 两种冲突解决方式对比
| 方式 | 存储结构 | 负载因子 | 冲突表现 | 删除方式 | 工程常用度 |
| 开放定址法 | 数组内部存储 | <1 | 易堆积 | 逻辑标记删除 | 较少 |
| 链地址法 | 数组 + 链表 | 可 > 1 | 无堆积 | 物理直接删除 | STL 底层常用 |
|---|
6.2 哈希函数设计原则
- 让关键字所有位都参与计算;
- 映射结果均匀散列,避免集中扎堆;
- 计算尽量高效,兼顾速度与冲突率。
6.3 面试高频问答
- 什么是哈希冲突?怎么解决?
- 负载因子的作用?为什么要扩容?
- 开放定址法删除为什么不能直接清空?
- 链地址法扩容怎么做 rehash?
- string 如何自定义哈希函数减少冲突?
6.4 实际应用场景
算法刷题两数之和、字符统计; STL unordered_map/unordered_set 底层; 业务缓存、路由映射、去重场景等
