哈希
- 一.哈希的概念与实现方法
- 二.开放定址法
-
- 1.实现逻辑
-
- [1.1 基础映射:除法散列法确定初始位置](#1.1 基础映射:除法散列法确定初始位置)
- [1.2 冲突解决:线性探测的定位规则](#1.2 冲突解决:线性探测的定位规则)
- [1.3 特点与问题](#1.3 特点与问题)
- 2.代码实现
-
- newHT.Insert(data._kv);的巧用
- [1. 复用插入逻辑,避免重复编码](#1. 复用插入逻辑,避免重复编码)
- [2. 自动适配新表容量,无需额外处理](#2. 自动适配新表容量,无需额外处理)
- 总结
- 三.哈希桶
一.哈希的概念与实现方法
1.概念
哈希(Hash)是一种将任意长度的输入(如数据、字符串、对象等)通过哈希函数转换为固定长度输出(哈希值/散列值)的技术。它的核心作用是快速映射、查找和去重,常见于哈希表、密码学(如哈希加密)、数据校验等领域。
2.实现方法
-
核心构成与负载因子
- 基本结构:由哈希桶数组、哈希函数、冲突解决机制组成。哈希桶数组是存储元素的基础容器,哈希函数负责将关键字映射到数组索引,冲突解决机制处理不同关键字映射到同一位置的问题。
- 扩容机制:当负载因子(元素数量/桶数组大小)超过阈值(如0.7)时,需重新申请更大的桶数组,将原有元素重新哈希并插入新桶,以保证查询、插入等操作的效率。
- 负载因子特性:负载因子越大,哈希表空间利用率越高,但哈希冲突概率也越高;反之,冲突概率低但空间利用率低。其计算公式为"负载因子=元素数/桶数"。
-
关键字处理与哈希函数设计
- 关键字转换 :哈希映射通常基于整数计算,非整数关键字(如字符串)需先转换为整数(例如字符串通过多项式滚动计算:
hash = hash * 131 + ch,利用质数131减少冲突)。 - 哈希函数要求:必须满足"相同输入必有相同输出",同时尽可能让不同输入的哈希值均匀分散,以减少冲突。
- 具体哈希函数 :
- 除法散列法 :
h(key) = key % M(M为桶数组大小)。需避免M为2的幂或10的幂(易因保留关键字低位导致冲突,如M=16时,63和31的哈希值均为15),建议M选接近但非2的整数次幂的质数。实践中可灵活优化(如Java HashMap用2的幂作为M,结合位运算让关键字所有位参与计算,提升均匀性)。 - 乘法散列法 :
h(key) = floor(M × ((A × key) % 1.0))(A为0~1的常数,推荐黄金分割点0.618)。对M无特殊要求,通过关键字与A的乘积小数部分映射位置(如key=1234、M=1024时,哈希值为669)。 - 全域散列法 :
h_ab(key) = ((a × key + b) % P) % M(P为大质数,a、b为随机参数)。通过随机性抵御恶意构造的冲突数据,初始化时固定a、b,确保增删查改使用同一函数。
- 除法散列法 :
- 关键字转换 :哈希映射通常基于整数计算,非整数关键字(如字符串)需先转换为整数(例如字符串通过多项式滚动计算:
-
哈希冲突解决方法
- 开放定址法 :所有元素存储在哈希表内,冲突时按规则寻找空闲位置,负载因子必须小于1。
- 线性探测 :冲突后从当前位置依次向后探测(
h_i = (h0 + i) % M,i=1,2,...),实现简单但易产生"群集"(连续冲突位置争夺后续空闲位置)。 - 二次探测 :冲突后按平方跳跃探测(
h_i = (h0 ± i²) % M),减少群集现象,需处理负数索引(如h_i < 0时加M调整)。
- 线性探测 :冲突后从当前位置依次向后探测(
- 链地址法(哈希桶):每个桶数组元素关联链表或红黑树,冲突元素直接挂载到对应链表/树中,无需寻找空闲位置,灵活性更高。此方法建议负载因子控制在1以内。
- 开放定址法 :所有元素存储在哈希表内,冲突时按规则寻找空闲位置,负载因子必须小于1。
-
实践特点
- 实际应用中多采用除法散列法作为哈希函数,结合链地址法或开放定址法解决冲突。
- 通过动态调整负载因子(扩容)和优化哈希函数,平衡空间利用率与操作效率,实现高效的增删查改功能。
二.开放定址法
1.实现逻辑
开放定址法是哈希冲突解决的重要方式,其核心是当关键字通过哈希函数映射的位置发生冲突时,按照特定规则在哈希表内寻找空闲位置存储元素,且所有元素均直接存于哈希表数组中,负载因子需小于1。结合除法散列法与线性探测,来具体理解其机制。
1.1 基础映射:除法散列法确定初始位置
采用除法散列法(除留余数法)计算关键字的初始映射位置,即 h(key) = hash0 = key % M(M为哈希表大小)。
- 为减少冲突,M的选择需避开2的幂、10的幂等特殊值(这类M会导致哈希值仅由关键字的后几位决定,例如M=16(2⁴)时,63和31的后4位均为1111,哈希值均为15,易冲突),建议选取不接近2的整数次幂的质数。
1.2 冲突解决:线性探测的定位规则
当初始位置hash0已被占用(冲突)时,线性探测通过以下规则寻找下一个空闲位置:
- 探测公式:
h_i(key, i) = (hash0 + i) % M(i=1,2,...,M-1),即从hash0开始依次向后探测,若到达表尾则回绕至表头。 - 因负载因子小于1,表中必有空闲位置,最多探测M-1次即可找到存储位置。
1.3 特点与问题
- 优点:实现简单,仅通过连续地址探测即可解决冲突。
- 缺点 :易产生"群集(堆积)"现象。例如,若
hash0、hash1、hash2已存储元素,后续映射到这些位置及hash3的关键字会集中争夺hash3,导致冲突范围扩大,降低操作效率。(如下图)

2.代码实现
cpp
#pragma once
#include<iostream>
#include<vector>
using namespace std;
namespace yl {
// 哈希表元素状态:存在/空/已删除(用于开放定址法)
enum Status
{
EXIST, // 元素存在
EMPTY, // 位置为空
DELETE // 元素已删除
};
// 哈希表存储的节点结构
template<class K, class V>
struct HashData
{
pair<K, V> _kv; // 键值对
Status _state = EMPTY; // 初始状态为空
};
// 哈希函数模板(默认处理整数类型)
template<class K>
struct HashFunc
{
size_t operator()(const K& key)
{
return (size_t)key; // 整数直接转换为哈希值
}
};
// 哈希函数特化:处理字符串类型
template<>
struct HashFunc<string>
{
size_t ret = 0;
size_t operator()(const string& str)
{
for (auto& ch : str)
{
ret = ret * 131 + ch; // 多项式滚动哈希(131为质数,减少冲突)
}
return ret;
}
};
// 哈希表类(开放定址法-线性探测)
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
public:
// 构造函数:初始容量为最小质数
HashTable()
:_tables(__stl_next_prime(1))
, _n(0)
{
}
// 查找下一个大于等于n的质数(用于扩容)
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* pos = lower_bound(__stl_prime_list, __stl_prime_list + __stl_num_primes, n);
return pos == __stl_prime_list + __stl_num_primes ? *(pos - 1) : *pos;
}
cpp
// 插入键值对(不允许重复)
bool Insert(const pair<K, V>& kv)
{
if (Find(kv.first)) // 键已存在,插入失败
return false;
// 负载因子 >= 0.7时扩容
if ((double)_n / (double)_tables.size() >= 0.7)
{
HashTable<K, V> newHT;
newHT._tables.resize(__stl_next_prime(_tables.size() + 1)); // 新表容量为下一个质数
// 迁移旧表元素到新表
for (auto& data : _tables)
{
if (data._state == EXIST)
newHT.Insert(data._kv);
}
_tables.swap(newHT._tables); // 交换新旧表
}
Hash hs;
size_t hash0 = hs(kv.first) % _tables.size(); // 初始哈希位置
size_t hashi = hash0;
// 线性探测:冲突时依次向后寻找空闲位置
while (_tables[hashi]._state == EXIST)
{
hashi = (hash0 + 1) % _tables.size(); // 线性探测公式
}
// 插入元素
_tables[hashi]._kv = kv;
_tables[hashi]._state = EXIST;
++_n;
return true;
}
newHT.Insert(data._kv);的巧用
在Insert函数的扩容逻辑中,使用newHT.Insert(data._kv)迁移旧元素是一种**"复用逻辑、简化代码"**的巧妙设计。
1. 复用插入逻辑,避免重复编码
扩容时需要将旧表中所有有效元素(state == EXIST)重新插入新表。而Insert函数本身已经实现了:
- 计算新的哈希位置(基于新表大小)
- 处理冲突(线性探测)
- 插入元素并更新状态
若不复用Insert,则需要在扩容时重复编写上述逻辑(如重新计算哈希、探测位置等),不仅代码冗余,还可能因逻辑不一致导致错误(例如哈希计算或冲突处理与Insert不一致)。
通过newHT.Insert(data._kv),直接复用已验证的插入逻辑,确保旧元素在新表中的插入规则与正常插入完全一致,减少了出错概率。
2. 自动适配新表容量,无需额外处理
新表的容量是通过__stl_next_prime计算的更大质数,与旧表容量不同。Insert函数在计算哈希位置时依赖当前表的大小(_tables.size()),而newHT的_tables已 resize 为新容量,因此:
- 调用
newHT.Insert时,会自动基于新表大小计算哈希位置 - 线性探测也会基于新表的容量进行(避免超出新表范围)
这种方式无需手动传递新表大小或修改哈希计算逻辑,让代码更简洁,且自动适配新表的参数。
总结
这种设计的核心是**"利用已有接口完成新逻辑"**,既保证了代码一致性,又减少了重复开发,是面向对象中"复用"思想的典型体现。缺点是可能带来轻微的性能开销(多次调用Insert),但对于哈希表的使用场景,这种可读性和可维护性的提升通常更重要。
cpp
// 查找键对应的节点
HashData<K, V>* Find(const K& key)
{
Hash hs;
size_t hash0 = hs(key) % _tables.size(); // 初始哈希位置
size_t hashi = hash0;
// 线性探测查找:遇到空位置则停止
while (_tables[hashi]._state != EMPTY)
{
// 找到存在的目标键
if (_tables[hashi]._state == EXIST && _tables[hashi]._kv.first == key)
return &_tables[hashi];
hashi = (hash0 + 1) % _tables.size(); // 继续探测下一个位置
}
return nullptr; // 未找到
}
// 删除键对应的元素(标记为DELETE,不实际删除)
bool Erease(const K& key)
{
auto* ptr = Find(key);
if (ptr)
{
ptr->_state = DELETE; // 标记为已删除
--_n;
return true;
}
return false; // 键不存在
}
// 打印哈希表元素(仅打印存在的元素)
void Print()
{
for (auto& e : _tables) {
if (e._state == EXIST) // 只输出存在的元素
cout << e._kv.first << ":" << e._kv.second << endl;
}
cout << endl;
}
private:
vector<HashData<K, V>> _tables; // 哈希表数组(开放定址法)
size_t _n = 0; // 有效元素个数
};
}
三.哈希桶
1.哈希桶的结构

上图展示了**链地址法(哈希桶)**的哈希表结构。
1.1 哈希桶数组
由连续的"桶"组成(图中索引0-10的方框),每个桶是哈希表的基本存储单元,用于挂载冲突的元素。
1.2 冲突元素的链式存储
当不同关键字经哈希函数映射到同一桶时,通过链表依次挂载:
- 桶1:仅存储元素
12; - 桶2:存储
24,因冲突挂载13; - 桶3:仅存储
36; - 桶5:仅存储
5; - 桶8:存储
96,因冲突依次挂载30、19; - 桶9:仅存储
20; - 桶10:仅存储
21; - 桶0、4、6、7:无元素,为空桶。
1.3 链地址法的特点
- 每个桶对应一个链表(或红黑树),冲突元素直接追加到链表尾部,无需像开放定址法那样寻找空闲位置;
- 空间利用率灵活,负载因子可超过1(图中元素数多于桶数,负载因子>1);
- 查询时需遍历链表匹配关键字,若链表过长会影响效率,因此实际中可能将链表升级为红黑树(如Java的HashMap)以优化查询速度。
2.代码实现
cpp
#pragma once
#include<iostream>
#include<vector>
#include<algorithm> // 补充lower_bound所需头文件
using namespace std;
namespace yl {
template<class K, class V>
struct HashNode
{
pair<K, V> _kv;
HashNode<K, V>* _next; // 成员变量为_next(带下划线)
// 构造函数参数改为const引用,避免拷贝
HashNode(const pair<K, V>& kv)
:_kv(kv)
, _next(nullptr)
{
}
};
template<class K>
struct HashFunc
{
size_t operator()(const K& key)
{
return (size_t)key;
}
};
template<>
struct HashFunc<string>
{
// ret改为局部变量,避免多次调用累积错误
size_t operator()(const string& str)
{
size_t ret = 0;
for (auto& ch : str)
{
ret += (char)ch;
ret *= 131;
}
return ret;
}
};
template<class K, class V,class Hash= HashFunc<K>>
class HashTable
{
typedef HashNode<K, V> Node;
public:
HashTable()
:_tables(__stl_next_prime(1), nullptr) // 初始化桶为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;
}
}
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;
}
cpp
bool Insert(const pair<K, V>& kv)
{
Hash hs;
// 检查是否已存在(避免重复插入)
if (Find(kv.first))
return false;
// 扩容条件:负载因子达到1时扩容(可根据需要调整)
if (_n == _tables.size())
{
// 新桶初始化时显式置为nullptr
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; // 访问成员变量_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]; // 新节点next指向原桶头
_tables[hashi] = newnode; // 桶头指向新节点
++_n;
return true;
}
值得注意的点:扩容
在哈希表的Insert函数中,扩容时选择"摘去旧节点放到新表"而非"直接创建新节点再交换",核心原因是避免不必要的内存开销和数据拷贝,提高效率。
1. 减少内存分配与释放的开销
- 若采用"创建新节点"的方式:需要为每个旧节点对应创建一个新节点(拷贝
kv数据),然后释放所有旧节点。这会导致双倍的内存操作 (新节点的new和旧节点的delete),尤其是当哈希表中元素较多时,会显著增加时间开销。 - 而"摘去旧节点"的方式:直接复用原有节点(仅修改节点的
_next指针),无需重新分配内存和释放旧内存,仅通过指针调整完成元素迁移,内存操作成本极低。
2. 避免数据拷贝的成本
- 哈希表中存储的是
pair<K, V>类型的键值对,若K或V是自定义类型(如字符串、结构体等),拷贝操作可能涉及深拷贝(例如字符串的字符数组复制),成本较高。 - 复用旧节点时,节点中存储的
kv数据无需拷贝,仅通过指针移动即可完成迁移,避免了数据拷贝的开销,尤其适合大数据量场景。
3. 保证指针语义的一致性
- 哈希表的节点(
Node)通常通过指针链接(_next),节点本身是动态分配的独立内存块。迁移时只需调整指针指向,即可将节点从旧桶"摘离"并"挂载"到新桶,操作简单且不易出错。 - 若重新创建新节点,需要确保新节点的指针关系与旧节点一致(例如链表的顺序),可能引入额外的逻辑复杂度。
总结
"摘去旧节点放到新表"是一种原地复用资源 的优化策略,通过减少内存操作和数据拷贝,显著提升了扩容效率。这是哈希表实现中常见的优化手段,尤其在追求高性能的场景中(如STL的unordered_map)被广泛采用。
cpp
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; // 访问_next(带下划线)
}
return nullptr;
}
bool Erase(const K& key)
{
Hash hs;
size_t hashi = hs(key) % _tables.size();
Node* cur = _tables[hashi];
Node* prev = nullptr;
while (cur)
{
if (cur->_kv.first == key)
{
// 处理头节点删除
if (!prev)
_tables[hashi] = cur->_next; // 桶头指向当前节点的下一个
else
prev->_next = cur->_next; // 前一个节点指向当前节点的下一个
delete cur; // 释放节点内存
--_n;
return true;
}
prev = cur;
cur = cur->_next; // 访问_next(带下划线)
}
return false; // 未找到返回false(原代码返回nullptr错误)
}
void Print() const // 加const确保不修改数据
{
for (size_t i = 0; i < _tables.size(); ++i)
{
cout << "哈希桶[" << i << "]: ";
Node* cur = _tables[i]; // 从当前桶的头节点开始遍历
while (cur)
{
// 打印键值对,格式为 "key:value"
cout << cur->_kv.first << ":" << cur->_kv.second << " -> ";
cur = cur->_next; // 移动到下一个节点
}
cout << "nullptr" << endl; // 链表结束标志
}
cout << endl;
}
size_t Size() { return _n; };
private:
vector<Node*> _tables; // 存储链表头指针的桶
size_t _n = 0; // 有效元素个数
};
}