文章目录
- 前言
- 一、哈希的基本概念
-
- [1. 直接定址法:最理想的情况](#1. 直接定址法:最理想的情况)
- [2. 哈希函数与哈希冲突](#2. 哈希函数与哈希冲突)
- [3. 负载因子](#3. 负载因子)
- [4. 常见哈希函数](#4. 常见哈希函数)
- 二、冲突解决方法一:开放定址法
-
- [1. 线性探测](#1. 线性探测)
- [2. 删除的大坑:不能直接删](#2. 删除的大坑:不能直接删)
- [3. 二次探测 & 双重散列(了解)](#3. 二次探测 & 双重散列(了解))
- [4. 开放定址法代码实现](#4. 开放定址法代码实现)
- 三、冲突解决方法二:链地址法(哈希桶)
-
- [1. 原理](#1. 原理)
- [2. 扩容](#2. 扩容)
- [3. 链地址法代码实现](#3. 链地址法代码实现)
- 四、自定义类型的哈希函数
-
- [1. string 的哈希](#1. string 的哈希)
- [2. 自定义类型示例](#2. 自定义类型示例)
- 总结
前言
二叉搜索树、AVL 树、红黑树,查找效率都是 O (logN)。但还有一种更快的结构 ------哈希表,平均情况下增删查都是 O (1),靠的不是比较,而是直接通过哈希函数算出存储位置。
STL 里的unordered_map、unordered_set底层就是哈希表。这篇我们就从哈希原理、哈希函数、冲突解决,到开放定址法和链地址法两种完整实现,一步步实现哈希表。
一、哈希的基本概念
1. 直接定址法:最理想的情况
如果关键字的范围很集中,直接用关键字本身当数组下标,这就是直接定址法。
比如关键字都是 0~99 的整数,开个 100 大小的数组,key 是几就放几号位置。再比如小写字母,key - 'a'就是下标。
cpp
// 统计字符串中第一个唯一字符
int firstUniqChar(string s) {
int count[26] = {0};
for (auto ch : s)
count[ch - 'a']++; // 直接定址
// ...
}
优点:最快,O (1) 直接访问。
缺点:太受限了。关键字范围一大,要么开不起那么大数组,要么空间浪费严重。
2. 哈希函数与哈希冲突
现实中关键字范围通常很大很分散,我们需要一个哈希函数 ,把任意 key 映射到[0, M)的数组下标范围内。
h(key) = 下标
但问题来了:不同的 key,可能算出同一个下标。这就叫哈希冲突(哈希碰撞)。
冲突不可避免,我们能做的两件事:
- 设计好的哈希函数,尽量让 key 均匀分布,减少冲突
- 设计冲突解决方案,冲突了也能正常存、正常查
3. 负载因子
哈希表里存了多少数据,和表总大小的比值,叫负载因子(load factor)。
负载因子 = 已存元素数 / 哈希表大小
- 负载因子越大,冲突概率越高,空间利用率越高
- 负载因子越小,冲突概率越低,空间越浪费
一般到了某个阈值就要扩容。比如开放定址法通常 0.7 扩容,链地址法通常到 1 扩容。
4. 常见哈希函数
除留余数法(最常用)
h(key) = key % M
M 是哈希表大小。简单直接,实践中最常用。
注意点:
- M 尽量取质数,减少冲突。比如 11、53、97、193......
- 如果 M 是 2 的幂,相当于只取 key 的后几位,高位没用,冲突会多。当然也有玩法就是用 2 的幂配合位运算,再叠加异或混合高位,比如 Java 的 HashMap。
乘法散列法(了解)
用 key 乘一个 0~1 之间的常数,取小数部分再乘 M 取整。对 M 没要求,经典取值是黄金分割点(√5-1)/2 ≈ 0.618。
全域散列法(了解)
给哈希函数加随机因素,防止有人故意构造数据让所有 key 都冲突。每次初始化随机选一个哈希函数,后续统一用它。
二、冲突解决方法一:开放定址法
1. 线性探测
核心思路:冲突了就往后找,直到找到空位。
h(key, i) = (hash0 + i) % M
i 从 1 开始,依次往后探测,找到空位就插入。到表尾了就绕回表头。
优点:简单,容易实现,所有数据都在数组里,缓存友好。
缺点:容易产生堆积(群集)------ 冲突的位置连成片,后面来的都往后面挤,越挤越长,探测效率下降。
2. 删除的大坑:不能直接删
开放定址法删除不能直接把位置清空。因为后面的元素可能是因为前面冲突才挪到后面的,你把前面删空了,后面的元素查找时遇到空就停了,会以为不存在。
解决方案:加状态标记:每个位置有三种状态
EMPTY:空的,没存过东西EXIST:存着有效数据DELETE:数据被删了,不是真的空
查找的时候,遇到EMPTY才停;遇到DELETE继续往后找。插入的时候,遇到EMPTY或DELETE都可以放。
3. 二次探测 & 双重散列(了解)
- 二次探测:步长是 i²,左右跳着找,一定程度缓解堆积。
- 双重散列:用第二个哈希函数算步长,更不容易堆积。
4. 开放定址法代码实现
cpp
#include <iostream>
#include <vector>
#include <algorithm>
#include <utility>
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>
struct HashFunc {
size_t operator()(const K& key) const {
return static_cast<size_t>(key);
}
};
// string特化:BKDR哈希
template <>
struct HashFunc<string> {
size_t operator()(const string& key) const {
size_t hash = 0;
for (char ch : key) {
hash = hash * 131 + ch;
}
return hash;
}
};
namespace open_address {
template <class K, class V, class Hash = HashFunc<K>>
class HashTable {
public:
HashTable()
: _tables(__stl_next_prime(0))
, _n(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> newHash;
newHash._tables.resize(__stl_next_prime(_tables.size() + 1));
for (auto& data : _tables) {
if (data._state == EXIST)
newHash.Insert(data._kv);
}
_tables.swap(newHash._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;
}
HashData<K, V>* Find(const K& key) {
Hash hash;
size_t hash0 = hash(key) % _tables.size();
size_t hashi = hash0;
size_t i = 1;
while (_tables[hashi]._state != EMPTY && i <= _tables.size()) {
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) {
ret->_state = DELETE;
--_n;
return true;
}
return false;
}
private:
// 质数表:返回>=n的最小质数
static unsigned long __stl_next_prime(size_t n) {
static const unsigned long 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, 3221225473ul, 4294967291ul
};
const int cnt = sizeof(primes) / sizeof(primes[0]);
auto pos = lower_bound(primes, primes + cnt, n);
return pos == primes + cnt ? *(primes + cnt - 1) : *pos;
}
vector<HashData<K, V>> _tables;
size_t _n; // 有效元素个数
};
} // namespace open_address
三、冲突解决方法二:链地址法(哈希桶)
1. 原理
数组里不直接存数据,存链表头指针。冲突的 key 都挂在同一个位置的链表里,像一个个桶,所以也叫哈希桶。
优点:
- 不会有堆积问题
- 负载因子可以大于 1,空间利用率高
- 删除简单,就是链表删除
缺点:
- 链表有指针开销,缓存局部性不如开放定址
- 极端情况某个桶特别长,效率退化。Java 8 + 的 HashMap 会在桶太长时转红黑树
- 开放定址法简单紧凑,但有堆积,负载因子不能太高
- 链地址法更通用,也是
unordered_map的主流实现方式
2. 扩容
一般负载因子到 1 的时候扩容。扩容的时候,所有元素要重新哈希到新表。
优化点:扩容不用重新 new 结点,直接把旧表的结点摘下来,重新挂到新表对应位置,移动指针就行,效率更高。
3. 链地址法代码实现
cpp
#include <iostream>
#include <vector>
#include <algorithm>
#include <utility>
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)
{
}
};
namespace hash_bucket {
template <class K, class V, class Hash = HashFunc<K>>
class HashTable {
typedef HashNode<K, V> Node;
public:
HashTable()
: _tables(__stl_next_prime(0), nullptr)
, _n(0)
{
}
// 拷贝构造
HashTable(const HashTable& ht)
: _tables(ht._tables.size(), nullptr)
, _n(0)
{
for (size_t i = 0; i < ht._tables.size(); ++i) {
Node* cur = ht._tables[i];
while (cur) {
Insert(cur->_kv);
cur = cur->_next;
}
}
}
void Swap(HashTable& ht) {
swap(_tables, ht._tables);
swap(_n, ht._n);
}
HashTable& operator=(HashTable ht) {
Swap(ht);
return *this;
}
~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) {
if (Find(kv.first))
return false;
Hash hash;
// 负载因子到1扩容
if (_n == _tables.size()) {
vector<Node*> newTable(__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 = hash(cur->_kv.first) % newTable.size();
// 头插新表
cur->_next = newTable[hashi];
newTable[hashi] = cur;
cur = next;
}
_tables[i] = nullptr;
}
_tables.swap(newTable);
}
size_t hashi = hash(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 hash;
size_t hashi = hash(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 hash;
size_t hashi = hash(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:
static unsigned long __stl_next_prime(size_t n) {
static const unsigned long 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, 3221225473ul, 4294967291ul
};
const int cnt = sizeof(primes) / sizeof(primes[0]);
auto pos = lower_bound(primes, primes + cnt, n);
return pos == primes + cnt ? *(primes + cnt - 1) : *pos;
}
vector<Node*> _tables; // 指针数组
size_t _n = 0;
};
} // namespace hash_bucket
四、自定义类型的哈希函数
如果 key 不是整数,需要自己写哈希仿函数,把 key 转成能取模的整数。
1. string 的哈希
上面代码里已经特化了 string 版本,用的 BKDR 思路:每次乘一个质数再加字符。
BKDR 是字符串哈希算法,核心:把字符串当成一个基于大基数的多项式,不断累积计算哈希值。
cpp
template <>
struct HashFunc<string> {
size_t operator()(const string& key) const {
size_t hash = 0;
for (char ch : key) {
hash = hash * 131 + ch;
}
return hash;
}
};
比单纯 ASCII 相加好很多,至少 "abcd" 和 "bcad" 不会算出一样的值。
2. 自定义类型示例
比如 Date 类型:
cpp
struct Date {
int _year;
int _month;
int _day;
};
struct DateHashFunc {
size_t operator()(const Date& d) const {
size_t hash = 0;
hash = hash * 131 + d._year;
hash = hash * 131 + d._month;
hash = hash * 131 + d._day;
return hash;
}
};
核心原则:尽量让 key 的每一位都参与运算,减少冲突概率。
总结
- 哈希表核心:通过哈希函数把 key 映射成下标,平均 O (1) 增删查。
- 哈希冲突不可避免,常用解决方法:开放定址(线性探测)和链地址法(哈希桶)。
- 开放定址法:冲突往后找空位;删除要用 DELETE 标记,不能直接清空。
- 链地址法:每个位置挂链表,冲突都放链里;扩容可直接移动结点。
- 哈希函数:除留余数法最常用,表长取质数减少冲突;自定义 key 要写对应的哈希仿函数。
- 负载因子:控制冲突率,到阈值扩容,重新哈希所有元素。
哈希表是用空间换时间的典型结构。如果这篇对你有帮助,点个赞收藏一下~