目录
- 前言
-
- 一、哈希表核心概念
-
- [1.1 什么是哈希表?](#1.1 什么是哈希表?)
- [1.2 哈希三要素](#1.2 哈希三要素)
- 二、哈希函数设计艺术
-
- [2.1 常见哈希函数](#2.1 常见哈希函数)
- [2.2 泛型哈希函数实现](#2.2 泛型哈希函数实现)
- 三、哈希冲突的两种解决方案
-
- [3.1 闭散列(开放定址法)](#3.1 闭散列(开放定址法))
- [3.2 开散列(链地址法/哈希桶)](#3.2 开散列(链地址法/哈希桶))
- 四、关键操作对比分析
-
- [4.1 查找操作](#4.1 查找操作)
- [4.2 删除操作](#4.2 删除操作)
- 五、性能分析与选择策略
-
- [5.1 时间复杂度对比](#5.1 时间复杂度对比)
- [5.2 空间复杂度对比](#5.2 空间复杂度对比)
- [5.3 选择指南](#5.3 选择指南)
- 六、实际应用与优化
-
- [6.1 负载因子的选择](#6.1 负载因子的选择)
- [6.2 哈希表大小的选择](#6.2 哈希表大小的选择)
- [6.3 工业级优化策略](#6.3 工业级优化策略)
- 七、总结
- 八、源码
前言
在数据结构的世界中,哈希表(Hash Table)以其接近O(1)的平均时间复杂度,成为了查找操作的"性能王者"。今天,我将带大家深入探索哈希表的核心原理,并手把手教你用C++实现两种主流方案:闭散列(开放定址法) 和 开散列(链地址法/哈希桶)。
一、哈希表核心概念
1.1 什么是哈希表?
哈希表是一种通过哈希函数 将关键码映射到表中特定位置进行访问的数据结构。它的魅力在于:不经过任何比较,一次直达目标位置。
cpp
// 理想情况下的哈希操作
元素位置 = 哈希函数(关键码)
1.2 哈希三要素
| 要素 | 说明 | 示例 |
|---|---|---|
| 哈希函数 | 关键码 → 存储位置 | h(key) = key % size |
| 哈希表 | 存储数据的容器 | 数组/vector |
| 哈希冲突 | 不同关键码映射到同一位置 | key1 ≠ key2,但h(key1) = h(key2) |
二、哈希函数设计艺术
2.1 常见哈希函数
cpp
// 1. 直接定址法:Hash(key) = a * key + b
// 适用于关键码分布连续的情况
// 2. 除留余数法(最常用):Hash(key) = key % m
// m通常取质数,减少冲突
// 3. 平方取中法:取key²的中间几位
// 适用于关键码位数较多的情况
2.2 泛型哈希函数实现
面对各种类型的关键码(int、float、string等),我们需要一个统一的处理方案:
cpp
// 基础模板:处理整型、浮点型等可直接转换的类型
template<class K>
struct DefaultHashFunc {
size_t operator()(const K& key) {
// 注意:负数通过size_t转换自然变为正数
return (size_t)key;
}
};
// 特化版本:处理字符串(BKDR算法)
template<>
struct DefaultHashFunc<string> {
size_t operator()(const string& str) {
size_t hash = 0;
for (char ch : str) {
hash = hash * 131 + ch; // 131是经验值,减少冲突
}
return hash;
}
};
三、哈希冲突的两种解决方案
3.1 闭散列(开放定址法)
核心思想
发生冲突时,在哈希表中寻找下一个"空位置"。
线性探测实现
cpp
namespace open_address {
// 节点状态枚举
enum State {
EXIST, // 存在元素
EMPTY, // 空位置
DELETE // 已删除(伪删除)
};
template<class K, class V>
struct HashNode {
pair<K, V> _kv;
State _state = EMPTY; // 初始状态为空
};
}
插入操作详解
cpp
bool Insert(const pair<K, V>& kv) {
// 1. 检查是否已存在
if (Find(kv.first)) return false;
// 2. 检查负载因子,判断是否需要扩容
if ((double)_n / _table.size() >= 0.7) {
// 扩容策略:创建新表,重新插入所有元素
size_t newSize = _table.size() * 2;
HashTable<K, V> newHash;
newHash._table.resize(newSize);
for (size_t i = 0; i < _table.size(); i++) {
if (_table[i]._state == EXIST) {
newHash.Insert(_table[i]._kv);
}
}
_table.swap(newHash._table);
}
// 3. 计算哈希地址
size_t hashi = hf(kv.first) % _table.size();
// 4. 线性探测寻找插入位置
while (_table[hashi]._state == EXIST) {
hashi++;
hashi %= _table.size(); // 循环查找
}
// 5. 插入元素
_table[hashi]._kv = kv;
_table[hashi]._state = EXIST;
_n++; // 有效元素计数增加
return true;
}
为什么需要DELETE状态?
这是闭散列的精妙之处!看这个例子:
哈希表状态:
索引: 0 1 2 3 4 5
元素: 15 25 5 35
如果我们删除25后直接置为EMPTY:
- 查找35时:h(35)=5,发现位置5为空,认为35不存在❌
- 实际上35在位置0(因为线性探测)
DELETE状态让查找过程能够继续探测,直到找到EMPTY。
3.2 开散列(链地址法/哈希桶)
核心思想
每个哈希地址对应一个链表(桶),冲突元素链在一起。
cpp
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) {}
};
}
插入操作:头插法的优势
cpp
bool Insert(const pair<K, V>& kv) {
// 1. 扩容检查(负载因子=1时扩容)
if (_n == _table.size()) {
size_t newSize = _table.size() * 2;
vector<Node*> newTable(newSize, nullptr);
// 重新哈希所有元素
for (Node* cur : _table) {
while (cur) {
Node* next = cur->_next;
size_t hashi = hf(cur->_kv.first) % newSize;
// 头插到新表
cur->_next = newTable[hashi];
newTable[hashi] = cur;
cur = next;
}
}
_table.swap(newTable);
}
// 2. 计算哈希地址并头插
size_t hashi = hf(kv.first) % _table.size();
Node* newNode = new Node(kv);
// 头插:O(1)时间复杂度
newNode->_next = _table[hashi];
_table[hashi] = newNode;
_n++;
return true;
}
四、关键操作对比分析
4.1 查找操作
cpp
// 闭散列查找:需要处理三种状态
HashNode<const K, V>* Find(const K& key) {
size_t hashi = hf(key) % _table.size();
while (_table[hashi]._state != EMPTY) {
if (_table[hashi]._state == EXIST
&& _table[hashi]._kv.first == key) {
return (HashNode<const K, V>*)&_table[hashi];
}
hashi = (hashi + 1) % _table.size(); // 线性探测
}
return nullptr;
}
// 开散列查找:只需遍历链表
HashNode<const K, V>* Find(const K& key) {
size_t hashi = hf(key) % _table.size();
Node* cur = _table[hashi];
while (cur) {
if (cur->_kv.first == key) {
return (HashNode<const K, V>*)cur;
}
cur = cur->_next;
}
return nullptr;
}
4.2 删除操作
cpp
// 闭散列删除:伪删除法
bool Erase(const K& key) {
HashNode<const K, V>* ret = Find(key);
if (ret) {
ret->_state = DELETE; // 只改状态,不实际删除
_n--;
return true;
}
return false;
}
// 开散列删除:真删除
bool Erase(const K& key) {
size_t hashi = hf(key) % _table.size();
Node* prev = nullptr;
Node* cur = _table[hashi];
while (cur) {
if (cur->_kv.first == key) {
if (prev == nullptr) {
// 头节点删除
_table[hashi] = cur->_next;
} else {
// 中间节点删除
prev->_next = cur->_next;
}
delete cur; // 释放内存
_n--;
return true;
}
prev = cur;
cur = cur->_next;
}
return false;
}
五、性能分析与选择策略
5.1 时间复杂度对比
| 操作 | 闭散列(平均) | 开散列(平均) | 备注 |
|---|---|---|---|
| 插入 | O(1) | O(1) | 负载因子影响大 |
| 查找 | O(1) | O(1) | 闭散列最坏O(n) |
| 删除 | O(1) | O(1) | 开散列需遍历链表 |
5.2 空间复杂度对比
- 闭散列:固定大小数组,空间利用率≤70%
- 开散列:动态链表,空间利用率可接近100%
- 内存开销:开散列有指针开销,闭散列有状态标记开销
5.3 选择指南
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 内存紧张 | 闭散列 | 无指针开销 |
| 频繁删除 | 开散列 | 真删除更高效 |
| 数据量波动大 | 开散列 | 扩容更灵活 |
| 追求极致查找 | 闭散列 | 缓存友好(连续内存) |
六、实际应用与优化
6.1 负载因子的选择
- 闭散列:建议α ≤ 0.7(性能与空间的平衡点)
- 开散列:建议α ≤ 1.0(链表长度可控)
6.2 哈希表大小的选择
cpp
// 使用质数作为表大小,减少冲突
const size_t primes[] = {
53, 97, 193, 389, 769, 1543, 3079, 6151,
12289, 24593, 49157, 98317, 196613, 393241,
786433, 1572869, 3145739, 6291469, 12582917,
25165843, 50331653, 100663319, 201326611
};
size_t get_next_prime(size_t num) {
for (size_t prime : primes) {
if (prime > num) return prime;
}
return primes[sizeof(primes)/sizeof(primes[0]) - 1];
}
6.3 工业级优化策略
cpp
// 1. 渐进式rehash(Redis风格)
// 扩容时新旧表并存,逐步迁移
// 2. 链表转红黑树(Java HashMap)
// 当链表长度>8时转为红黑树,保证最坏O(log n)
// 3. 缓存哈希值
struct OptimizedNode {
pair<K, V> kv;
size_t cached_hash; // 避免重复计算
Node* next;
};
七、总结
哈希表是数据结构中的"瑞士军刀",理解其原理和实现细节对于每个C++开发者都至关重要:
- 闭散列适合内存连续、查找频繁的场景,但要注意负载因子控制
- 开散列更适合动态数据、频繁删除的场景,空间利用率更高
- 哈希函数的设计直接影响性能,要兼顾速度和分布均匀性
- 负载因子是调优的关键参数,需要在空间和时间间权衡
无论是简单的数据缓存,还是复杂的数据索引,哈希表都能提供高效的解决方案。希望这篇深入剖析能帮助你更好地理解和应用哈希表!
实战建议:
- 小规模数据:闭散列更优(缓存友好)
- 大规模数据:开散列更安全(避免聚集)
- 自定义类型:务必提供良好的哈希函数
掌握哈希表,让你的程序飞起来!
八、源码
open_address.hpp:
cpp
#pragma once
template<class K>
struct DefaultHashFunc
{
size_t operator()(const K& key)
{
return key;
}
};
template<>
struct DefaultHashFunc<string>
{
size_t operator()(const string str)
{
size_t ret = 0;
for (size_t i = 0; i < str.size(); i++)
{
ret *= 131;
ret += str[i];
}
return ret;
}
};
namespace open_address
{
enum State
{
EXIST,
EMPTY,
DELETE
};
template<class K, class V>
struct HashNode
{
pair<K, V> _kv;
State _state = EMPTY;
};
template<class K, class V, class HashFunc = DefaultHashFunc<K>>
class HashTable
{
public:
HashTable()
{
_table.resize(10);
}
bool Insert(const pair<K, V>& kv)
{
HashFunc hf;
if (Find(kv.first))
{
return false;
}
if ((double)_n / _table.size() >= 0.7)
{
size_t newSize = _table.size() * 2;
HashTable<K, V> newHash;
newHash._table.resize(newSize);
for (size_t i = 0; i < _table.size(); i++)
{
if (_table[i]._state == EXIST)
{
newHash.Insert(_table[i]._kv);
}
}
_table.swap(newHash._table);
}
size_t hashi = hf(kv.first) % _table.size();
while (_table[hashi]._state == EXIST)
{
hashi++;
hashi %= _table.size();
}
_table[hashi]._kv = kv;
_table[hashi]._state = EXIST;
_n++;
return true;
}
HashNode<const K, V>* Find(const K& key)
{
HashFunc hf;
size_t hashi = hf(key) % _table.size();
while (_table[hashi]._state != EMPTY)
{
if (_table[hashi]._state == EXIST
&& _table[hashi]._kv.first == key)
{
return (HashNode<const K, V>*)&_table[hashi];
}
hashi++;
hashi %= _table.size();
}
return nullptr;
}
bool Erase(const K& key)
{
HashNode<const K, V>* ret = Find(key);
if (ret)
{
ret->_state = DELETE;
_n--;
return true;
}
return false;
}
private:
vector<HashNode<K, V>> _table;
size_t _n = 0;
};
}
hash_bucket.hpp:
cpp
#pragma once
template<class K>
struct DefaultHashFunc
{
size_t operator()(const K& key)
{
return key;
}
};
template<>
struct DefaultHashFunc<string>
{
size_t operator()(const string str)
{
size_t ret = 0;
for (size_t i = 0; i < str.size(); i++)
{
ret *= 131;
ret += str[i];
}
return ret;
}
};
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 HashFunC = DefaultHashFunc<K>>
class HashTable
{
typedef HashNode<K, V> Node;
public:
HashTable()
{
_table.resize(10, nullptr);
}
~HashTable()
{
for (int i = 0; i < _table.size(); i++)
{
Node* cur = _table[i];
while(cur)
{
Node* next = cur->_next;
delete cur;
cur = next;
}
_table[i] = nullptr;
}
}
bool Insert(const pair<K, V>& kv)
{
if (Find(kv.first))
{
return false;
}
HashFunC hf;
if (_n == _table.size())
{
size_t newsize = _table.size() * 2;
vector<Node*> newtable;
newtable.resize(newsize, nullptr);
for (int i = 0; i < _table.size(); i++)
{
Node* cur = _table[i];
while (cur)
{
Node* next = cur->_next;
size_t hashi = hf(cur->_kv.first) % newtable.size();
cur->_next = newtable[hashi];
newtable[hashi] = cur;
cur = next;
}
_table[i] = nullptr;
}
_table.swap(newtable);
}
size_t hashi = hf(kv.first) % _table.size();
Node* cur = new Node(kv);
cur->_next = _table[hashi];
_table[hashi] = cur;
_n++;
return true;
}
HashNode<const K, V>* Find(const K& key)
{
HashFunC hf;
size_t hashi = hf(key) % _table.size();
if (_table[hashi])
{
Node* cur = _table[hashi];
while (cur)
{
if (cur->_kv.first == key)
{
return (HashNode<const K, V>*)cur;
}
cur = cur->_next;
}
}
return nullptr;
}
bool Erase(const K& key)
{
if (Find(key) == nullptr)
{
return false;
}
HashFunC hf;
size_t hashi = hf(key) % _table.size();
Node* prev = nullptr;
Node* cur = _table[hashi];
while (cur)
{
if (cur->_kv.first == key)
{
if (prev == nullptr)
{
_table[hashi] = nullptr;
}
else
{
prev->_next = cur->_next;
}
delete cur;
_n--;
break;
}
prev = cur;
cur = cur->_next;
}
return true;
}
private:
vector<Node*> _table;
size_t _n = 0;
};
}