目录
1、哈希的概念
哈希(hash),又称散列,是一种高效的数据组织方式。其核心思想是通过哈希函数建立关键字(Key)与存储位置之间的映射关系,从而实现快速查找。
Key → [哈希函数] → 存储位置
1.1 直接定址法
当关键字的范围比较集中时,直接定址法是一种非常简单高效的方法。例如:
- 如果一组关键字都在 [0,99] 之间,那么我们可以开一个包含100个数的数组,每个关键字的值直接作为存储位置的下标。
- 如果一组关键字值都在 [a,z] 的小写字母范围内,那么我们可以开一个包含26个数的数组,每个关键字的 ASCII 码减去 'a' 的 ASCII 码,结果就是存储位置的下标。
1.2 哈希冲突
直接定址法的缺点也非常明显,当关键字的范围比较分散时,就很浪费内存甚至内存不够用。假设我们只有数据范围是 [0, 9999] 的 N 个值,我们要映射到一个 M 个空间的数组中(一般情况下 M ≥ N),那么就要借助哈希函数(hash function)hf,关键字 key 映射到数组的 h(key) 位置,这里要注意的是 h(key) 计算出的值必须在 [0, M) 之间。
这里存在的一个问题就是,两个不同的 key 可能会映射到同一个位置,这种问题我们叫做哈希冲突,或者哈希碰撞。
哈希冲突不可避免,可以选择发生哈希冲突较少的哈希函数和比较好的处理哈希冲突的方法。
1.3 负载因子
假设哈希表中已经映射存储了 N 个值,哈希表的大小为 M,那么
负载因子 = N / M。
负载因子有些地方也翻译为载荷因子/装载因子等,它的英文为 load factor。
负载因子越大,哈希冲突的概率越高,空间利用率越高;
负载因子越小,哈希冲突的概率越低,空间利用率越低。
1.4 将关键字转为整数
我们将关键字映射到数组中的位置,通常使用整数便于映射计算。如果关键字不是整数,需要先将其转换为整数。
2、哈希函数
2.1 除法散列法/除留余数法
除法散列法也叫做除留余数法,假设哈希表的大小为 M,那么通过 key 除以 M 的余数作为映射位置的下标,哈希函数为:h(key) = key % M。
当使用除法散列法时,要尽量避免 M 为某些值,如 2 的幂,10 的幂等。如果是 2^X,那么 key % M 本质相当于保留 key 的后 X 位,那么后 X 位相同的值,计算出的哈希值都是一样的,就冲突了。如:{63, 31} 看起来没有关联的值,如果 M 是 16,也就是 2^4,那么计算出的哈希值都是 15,因为 63 的二进制后 8 位是 00111111,31 的二进制后 8 位是 00011111。如果是 10^X,就更明显了,保留的都是 10 进制的后 X 位,如:{112, 12312},如果 M 是 100,也就是 10^2,那么计算出的哈希值都是 12。
当使用除法散列法时,一般建议 M 取不太接近 2 的整数次幂的一个质数(素数)。
注意:哈希表大小 M 是HashTable.size(),因为%size,就放到[0,size-1)。
Java 的 HashMap 为什么用 2 的幂次?(了解)
可以位运算,相对而言位运算比模更高效一些。比如 M 是 2^16 次方,本质是取后 16 位,那么用 key' = key >> 16,然后把 key 和 key' 异或的结果作为哈希值。映射出的值还是在 [0, M) 范围内,但是尽量让 key 所有的位都参与计算,这样映射出的哈希值更均匀一些。
2.2 乘法散列法(了解)
乘法散列法对哈希表大小 M 没有要求,
他的大思路第一步:key * A (0 < A < 1)。第二步:再用 M 乘以 key * A 的小数部分,再向下取整。
h(key) = floor(M × ((A × key) % 1.0)),
其中 floor 表示对表达式进行下取整,A ∈ (0,1),这里最重要的是 A 的值应该如何设定,Knuth 认为 A = (√5 − 1)/2 ≈ 0.6180339887...(黄金分割点)比较好。
乘法散列法对哈希表大小 M 是没有要求的,假设 M 为 1024,key 为 1234,A = 0.6180339887,A * key = 762.6539420558,取小数部分为 0.6539420558,M × ((A × key) % 1.0) = 0.6539420558 * 1024 = 669.6366651392,那么 h(1234) = 669。
2.3 全域散列法(了解)
如果存在一个恶意的对手,他针对我们提供的散列函数,特意构造出一个发生严重冲突的数据集,比如,让所有关键字全部落入同一个位置中。这种情况是可以存在的,只要散列函数是公开且确定的,就可以实现此攻击。解决方法自然是见招拆招,给散列函数增加随机性,攻击者就无法找出确定可以导致最坏情况的数据。这种方法叫做全域散列。
h_ab(key) = ((a × key + b) % P) % M,
P 需要选一个足够大的质数,a 可以随机选 [1, P-1] 之间的任意整数,b 可以随机选 [0, P-1] 之间的任意整数,这些函数构成了一个 P × (P-1) 组全域散列函数组。假设 P=17,M=6,a=3,b=4,则 h_34(8) = ((3 × 8 + 4) % 17) % 6 = 5。
需要注意的是每次初始化哈希表时,随机选取全域散列函数组中的一个散列函数使用,后续增删查改都固定使用这个散列函数。
2.4 其他方法(了解)
上面的几种方法是《算法导论》书籍中讲解的方法。
《殷人昆 数据结构:用面向对象方法与C++语言描述(第二版)》和《数据结构(C语言版) 严蔚敏 吴伟民》等教材型书籍上面还给出了平方取中法、折叠法、随机数法、数学分析法等,这些方法相对更适用于一些局限的特定场景,有兴趣可以去看看这些书籍。
3、处理哈希冲突
3.1 开放定址法
3.1.1 线性探测
h(key) = hash0 = key % M ,
若hash0位置冲突了,就向后走,hc(key,i) = hashi = (hash0 + i) % M , i = {1,2,3,...,M −1} ,
3.1.2 二次探测
h(key) = hash0 = key % M ,
若hash0位置冲突了,就前后跳跃走,hc(key,i) = hashi = (hash0 ± i^2 ) % M , i = {1,2,3,..., M/2}
当 hashi<0 时,需要 hashi+=M
3.1.3 双重探测(了解)
h1(key) = hash0 = key % M ,
若hash0位置冲突了,hc(key,i) = hashi = (hash0 + i∗h2(key) ) % M , i = {1,2,3,..., M}
要求 h2(key) < M 且 h(key)与M互质。
两种简单取值方法:
当 M 为 2 的整数幂时,h₂(key)可以随机选 [0, M-1] 之间的任意奇数。
当 M 为质数时,取 h2(key) = key % (M −1) + 1 。
3.2 开放地址法的代码实现
1. 开放定址法在实践中,不如下面链地址法,所以我们简单选择线性探测实现即可。
2. 这里需要给每个存储值的位置加一个状态标识{EXIST,EMPTY,DELETE},否则真的删除一些值以后,会影响后面冲突的值的查找。
3. 开放定址法,相互占位置,负载因子一定<1。一般负载因子>=0.7就扩容,扩容使用sgi版本的方法,给了一个近似2倍的质数表,每次去质数表获取扩容后的大小。
4. 不是整数的key,通过仿函数,转成整数。
#pragma once
#include <iostream>
#include <vector>
using namespace std;
namespace Lzc
{
inline size_t __stl_next_prime(size_t n) {
static const int __stl_num_primes = 28;
static const size_t __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 size_t* first = __stl_prime_list;
const size_t* last = __stl_prime_list + __stl_num_primes;
const size_t* pos = lower_bound(first, last, n);
return pos == last ? *(last - 1) : *pos;
}
template<class K>
struct HashFunc
{
size_t operator()(const K& key)
{
return key;
}
};
template<>
struct HashFunc<string>
{
// 字符串转换成整形,可以把字符ASCII码相加即可
// 但是直接相加的话,类似"abcd"和"bcad"这样的字符串计算出是相同的
// 这里使用BKDR哈希,用上次的计算结果去乘以一个质数,
// 这个质数一般取31, 131等效果会比较好
size_t operator()(const string& key)
{
size_t hash = 0;
for (auto& ch : key)
{
hash = hash * 131 + ch;
}
return hash;
}
};
namespace open_address
{
enum State
{
EMPTY,
EXIST,
DELETE
};
template<class K, class V>
struct HashData
{
pair<K, V> _kv;
State _state;
HashData()
:_kv()
,_state(EMPTY)
{ }
};
template<class K, class V,class Hash = HashFunc<K>>
class HashTable
{
public:
HashTable()
:_table(__stl_next_prime(0), HashData<K,V>())
, _n(0)
{ }
Hash hash;
bool Insert(const pair<K, V>& kv)
{
if (Find(kv.first))
return false;
if (_n >= _table.size() * MAX_LOAD_FACTOR)
{
vector<HashData<K,V>> newtable(__stl_next_prime(_table.size() + 1), HashData<K, V>());
for (size_t i = 0; i < _table.size(); ++i)
{
if (_table[i]._state == EXIST)
{
size_t hash0 = hash(_table[i]._kv.first) % newtable.size();
size_t hashi = hash0;
int j = 1;
while (newtable[hashi]._state == EXIST)
{
hashi = (hash0 + j) % newtable.size();
++j;
}
newtable[hashi] = _table[i];
}
}
_table.swap(newtable);
}
size_t hash0 = hash(kv.first) % _table.size();
size_t hashi = hash0;
int i = 1;
while (_table[hashi]._state == EXIST)
{
hashi = (hash0 + i) % _table.size();
++i;
}
_table[hashi]._kv = kv;
_table[hashi]._state = EXIST;
++_n;
return true;
}
HashData<K,V>* Find(const K& key)
{
size_t hash0 = hash(key) % _table.size();
size_t hashi = hash0;
int i = 1;
while (_table[hashi]._state!=EMPTY)
{
if (_table[hashi]._state == EXIST && _table[hashi]._kv.first == key)
return &_table[hashi];
hashi = (hash0 + i) % _table.size();
++i;
if (hashi == hash0)
break;
}
return nullptr;
}
bool Erase(const K& key)
{
HashData<K,V>* data = Find(key);
if (data)
{
data->_state = DELETE;
return true;
}
else
return false;
}
private:
static constexpr double MAX_LOAD_FACTOR = 0.7; // 负载因子
vector<HashData<K,V>> _table;
size_t _n;
};
}
}
3.3 链地址法
开放定址法中所有的元素都放到哈希表里,链地址法中所有的数据不再直接存储在哈希表中,哈希表中存储指针,没有数据映射这个位置时,这个指针为空,有多个数据映射到这个位置时,我们把这些冲突的数据链接成一个链表,挂在哈希表这个位置下面,链地址法也叫做拉链法或者哈希桶。
下面演示{19,30,5,36,13,20,21,12,24,96} 这一组值映射到M=11的表中。
3.4 连地址法的代码实现
1. 链地址法的负载因子就没有限制,STL中 unordered_xxx的负载因子>=1就扩容,我们也使用这个方式。
2. 极端场景,如果被针对,哈希函数使用全域散列法,如果不是被针对,在Java8的HashMap中当桶的长度超过一定阀值(8)时就把链表转换成红黑树。下面我们实现就不搞这么复杂了,这个解决极端场景的思路,大家了解一下。
#pragma once
#include <iostream>
#include <vector>
using namespace std;
namespace Lzc
{
inline size_t __stl_next_prime(size_t n) {
static const int __stl_num_primes = 28;
static const size_t __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 size_t* first = __stl_prime_list;
const size_t* last = __stl_prime_list + __stl_num_primes;
const size_t* pos = lower_bound(first, last, n);
return pos == last ? *(last - 1) : *pos;
}
template<class K>
struct HashFunc
{
size_t operator()(const K& key)
{
return key;
}
};
template<>
struct HashFunc<string>
{
// 字符串转换成整形,可以把字符ASCII码相加即可
// 但是直接相加的话,类似"abcd"和"bcad"这样的字符串计算出是相同的
// 这里使用BKDR哈希,用上次的计算结果去乘以一个质数,
// 这个质数一般取31, 131等效果会比较好
size_t operator()(const string& key)
{
size_t hash = 0;
for (auto& ch : key)
{
hash = hash * 131 + ch;
}
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()
:_table(__stl_next_prime(0), nullptr)
, _n(0)
{
}
~HashTable()
{
for (size_t i = 0; i < _table.size(); ++i)
{
if (_table[i])
{
Node* cur = _table[i];
while (cur)
{
Node* next = cur->_next;
delete cur;
cur = next;
}
}
_table[i] = nullptr;
}
_n = 0;
}
Hash hash;
bool Insert(const pair<K, V>& kv)
{
if (Find(kv.first))
return false;
// 负载因子>=1扩容
if (_n >= _table.size())
{
vector<Node*> newtable(__stl_next_prime(_table.size() + 1), nullptr);
for (size_t i = 0; i < _table.size(); ++i)
{
if (_table[i])
{
Node* cur = _table[i];
while (cur)
{
Node* next = cur->_next;
size_t hash0 = hash(cur->_kv.first) % newtable.size();
cur->_next = newtable[hash0]; // 头插
newtable[hash0] = cur;
cur = next;
}
}
_table[i] = nullptr;
}
_table.swap(newtable);
}
size_t hash0 = hash(kv.first) % _table.size();
Node* newnode = new Node(kv);
newnode->_next = _table[hash0];// 头插
_table[hash0] = newnode;
++_n;
return true;
}
Node* Find(const K& key)
{
size_t hash0 = hash(key) % _table.size();
Node* cur = _table[hash0];
while (cur)
{
if (cur->_kv.first == key)
return cur;
cur = cur->_next;
}
return nullptr;
}
bool Erase(const K& key)
{
size_t hash0 = hash(key) % _table.size();
Node* prev = nullptr;
Node* cur = _table[hash0];
while (cur)
{
if (cur->_kv.first == key)
{
if (prev)
prev->_next = cur->_next;
else
_table[hash0] = cur->_next;
delete cur;
--_n;
return true;
}
prev = cur;
cur = cur->_next;
}
return false;
}
private:
vector<Node*> _table;
size_t _n;
};
}
}
4、测试代码
4.1 Test.cpp
#include "HashTable.h"
#include <assert.h>
void TestInsertAndFind()
{
//Lzc::open_address::HashTable<int, string> table;
Lzc::hash_bucket::HashTable<int, string> table;
table.Insert({ 1, "one" });
table.Insert({ 2, "two" });
table.Insert({ 3, "three" });
auto* data1 = table.Find(1);
auto* data2 = table.Find(2);
auto* data3 = table.Find(3);
auto* data4 = table.Find(4); // 不存在的键
assert(data1 != nullptr && data1->_kv.second == "one");
assert(data2 != nullptr && data2->_kv.second == "two");
assert(data3 != nullptr && data3->_kv.second == "three");
assert(data4 == nullptr); // 应返回 nullptr
cout << "TestInsertAndFind passed." << endl;
}
void TestErase()
{
//Lzc::open_address::HashTable<int, string> table;
Lzc::hash_bucket::HashTable<int, string> table;
table.Insert({ 1, "one" });
table.Insert({ 2, "two" });
bool Erased1 = table.Erase(1);
bool Erased2 = table.Erase(3); // 不存在的键
assert(Erased1 == true);
assert(Erased2 == false);
assert(table.Find(1) == nullptr); // 应已被删除
cout << "TestErase passed." << endl;
}
void TestHashCollision()
{
// 使用固定大小的哈希表(如大小为 3)强制触发冲突
//Lzc::open_address::HashTable<int, string> table;
Lzc::hash_bucket::HashTable<int, string> table;
table.Insert({ 1, "one" });
table.Insert({ 4, "four" }); // 假设 1 和 4 哈希冲突
auto* data1 = table.Find(1);
auto* data4 = table.Find(4);
assert(data1 != nullptr && data1->_kv.second == "one");
assert(data4 != nullptr && data4->_kv.second == "four");
cout << "TestHashCollision passed." << endl;
}
void TestReuseAfterErase()
{
//Lzc::open_address::HashTable<int, string> table;
Lzc::hash_bucket::HashTable<int, string> table;
table.Insert({ 1, "one" });
table.Erase(1);
table.Insert({ 1, "new_one" }); // 应复用被删除的位置
auto* data = table.Find(1);
assert(data != nullptr && data->_kv.second == "new_one");
cout << "TestReuseAfterErase passed." << endl;
}
void TestResize()
{
//Lzc::open_address::HashTable<int, int> table;
Lzc::hash_bucket::HashTable<int, int> table;
// 记录插入前的状态(无法直接获取 size,只能通过行为推断)
// 插入足够多的元素触发扩容(初始大小为 __stl_next_prime(0) = 53)
// 负载因子 0.7,所以触发扩容的临界值是 53 * 0.7 ≈ 37 个元素
const int trigger_count = 38; // 确保超过负载因子
// 插入元素并验证
for (int i = 0; i < trigger_count; ++i) {
bool success = table.Insert({ i, i * 10 });
assert(success == true);
}
// 验证所有插入的元素仍然可找到
for (int i = 0; i < trigger_count; ++i) {
auto* data = table.Find(i);
assert(data != nullptr && data->_kv.second == i * 10);
}
cout << "TestResize passed." << endl;
}
void TestStringKey()
{
//Lzc::open_address::HashTable<string, int> table;
Lzc::hash_bucket::HashTable<string, int> table;
table.Insert({ "apple", 1 });
table.Insert({ "banana", 2 });
assert(table.Find("apple")->_kv.second == 1);
assert(table.Find("banana")->_kv.second == 2);
assert(table.Find("orange") == nullptr);
cout << "TestStringKey passed." << endl;
}
void TestEmptyTable()
{
//Lzc::open_address::HashTable<int, int> table;
Lzc::hash_bucket::HashTable<int, int> table;
assert(table.Find(1) == nullptr);
assert(table.Erase(1) == false);
cout << "TestEmptyTable passed." << endl;
}
void TestDuplicateInsert()
{
//Lzc::open_address::HashTable<int, string> table;
Lzc::open_address::HashTable<int, string> table;
table.Insert({ 1, "one" });
bool success = table.Insert({ 1, "new_one" }); // 重复插入
assert(success == false); // 应返回 false(或 true,取决于你的设计)
assert(table.Find(1)->_kv.second == "one"); // 值未被覆盖
cout << "TestDuplicateInsert passed." << endl;
}
void RunAllTests()
{
TestInsertAndFind();
TestErase();
TestHashCollision();
TestReuseAfterErase();
TestResize();
TestStringKey();
TestEmptyTable();
TestDuplicateInsert();
cout << "All tests passed!" << endl;
}
int main()
{
RunAllTests();
return 0;
}