欢迎来到丘山望岳的小栈,我们闲言少叙直击主题------哈希表(桶)
目录
[2.负载因子(load factor)](#2.负载因子(load factor))
[实例 2:二进制 x=2,n=4,模数 2^4=16](#实例 2:二进制 x=2,n=4,模数 2^4=16)
[分步推演 + 表格示意图](#分步推演 + 表格示意图)
[查找操作举例:查找 46](#查找操作举例:查找 46)
[2.2 代码实现](#2.2 代码实现)
[1. 数字分析法](#1. 数字分析法)
[2. 平方取中法](#2. 平方取中法)
[3. 折叠法](#3. 折叠法)
[4. 随机数法](#4. 随机数法)
[5. 多项式哈希(字符串专用)](#5. 多项式哈希(字符串专用))
[6. 乘法取整法(计算机高速哈希)](#6. 乘法取整法(计算机高速哈希))
[二次探测(平方探测 Quadratic Probing)](#二次探测(平方探测 Quadratic Probing))
[1. 偏移公式](#1. 偏移公式)
[2. 解决了什么问题?](#2. 解决了什么问题?)
[3. 存在缺陷:二次聚集(次聚集)](#3. 存在缺陷:二次聚集(次聚集))
[4. 关键使用限制](#4. 关键使用限制)
[5. 优缺点](#5. 优缺点)
[三、双重散列(双哈希 Double Hashing)](#三、双重散列(双哈希 Double Hashing))
[1. 核心思想](#1. 核心思想)
[2. 优势:无聚集现象](#2. 优势:无聚集现象)
[3. 优缺点](#3. 优缺点)
一、定义
哈希表(桶)(Hash Table)又称散列表,是一种基于键值对(Key-Value) 存储的线性数据结构,依托哈希函数建立关键字与存储地址之间的映射关系,实现数据的高效存取。
什么意思呢?我在前面学习了诸多的数据存储的容器比如vector,我要按值查找/删除一个元素时就必须要遍历整个vector将这个素找到,时间复杂度为O(1),每次查找/删除都要浪费大量的时间。
若将 vector 比作丢三落四、毫无条理的家人衣橱:衣物随意堆放,想要找到某件衣服时,只能逐层逐件逐一翻找核对;哈希表则如同整洁规整、提前做好分类收纳的专属衣橱,收纳衣物时便依据分类规则放到对应分区,取用之时直接定位对应区域,仅需在该区域内小幅查找即可。后者的查找效率比前者高多了。
那么后者是怎么这么快找到衣柜中的元素的呢?后者将衣物和衣橱储物空间建立了一个简单的映射关系------一语中的就是衬衫放在衬衫区域,长裤放在长裤区域......
这就是哈希思维的关键,将数据的信息和存储位置建立一个映射关系。
二、相关概念
1.哈希函数(hf)
一般来说,我们在哈希结构存储的是一个键值对pair<key,val>为元素,数组为基础框架的数据结构,key是可以通过函数和数组下标(size_tl类型)建立对应的映射关系的。而这样的,以自变量为key,因变量为size_t类型数据的"转化函数"我们称为哈希函数。
实现这样的函数有两个部分:1.将key严格转为size_t类型的key0
2.将key0与数组下标建立映射关系
2.负载因子(load factor)
哈希结构具有储存数据的最大容量为M,当前存入数据个数为N,那么负载因子为N/M.
3.哈希冲突
哈希(hash),从音译上将就是散列,杂乱,核心思想就是将离散的数据聚集化,并且建立key和数组下标的映射关系,但是我们没有要求哈希函数是单射函数,即hf(key)的值是唯一的,在一堆数据存储到哈希结构中必然会存在一个下标对应多个数据的情况,这样的情况叫做哈希冲突。
举个例子:我们将这些键值对存入哈希结构<1,"hello">,<2,"world"> ,<3,"haha"> ,<1,"hehe">
我们使用vector<int,string> arr10来存放这些值,假定不让vector自动扩容,锁死容量为10,选取哈希函数hf(key) =key,首先存入前3个元素,那么这个哈希结构的负载因子为0.3,在存放最后一个元素时,hf(1)=1,最后一个元素也要放置在arr1这个位置,但是这个位置已经被<1,"hello">占领了,这就是哈希冲突。
一般来说负载因子越大,发生哈希冲突的可能性就会越高。
三、解决哈希冲突的三种方法
1.直接定址法
前面提到,哈希冲突是由于哈希函数不是单射函数导致的,要想解决哈希冲突我们就要设定单射哈希函数,最简单粗暴的方式就是建立一个足够大的数组,将数据key使用单射函数转化为size_tl类型的key0,数组大小为key0的最大值和最小值之差+1。
给定一道题目:
在一个全为小写字母的字符串中找到只出现一次的字符(这样的字符唯一),并输出该字符,若没有则输出no
解答代码:
cpp
#include<iostream>
using namespace std;
int main()
{
int arr[26]={0};
string s;
cin>>s;
for(int i=0;i<int(s.size());i++)
{
arr[s[i]-'a']++;
}
int flag=1;
for(int i=0;i<26;i++)
{
if(arr[i]==1)
{
cout<<char(i+'a');
flag=0;
break;
}
}
if(flag==1)cout<<"no"<<endl;
return 0;
}
这样的小题我们没有必要真实地开一个vector<char,int>来解决相关问题,建一个统计字符出现个数的数组int arr即可拟合哈希表。
这个"哈希表"的哈希函数 hf(key)=key-'a' 一次函数,必然单射,hf(key)的取值范围是0-25,所以arr的大小为25-0+1=26.
但是这样的方法具有普适性吗?如果key通过单射哈希函数的映射值key0的取值只有两个1,10000000000000.那么要开10000000000000个空间来存储两个数据,这是不太亏了!
还没完,已知数据范围我们可以设计双射哈希函数,空间浪费多一点也就不说了,但是在工程实践中,数据的范围是未知的,我们盲目设计不合理的哈希函数会导致空间大量浪费,所以直接定址法只适用于数据范围已知、有限、"紧凑"的数据,应付上面这种虾兵蟹将还行,大型工程实践意义不大。
_知识切片:哈希函数的设计
通过设计合理科学的哈希函数也是处理哈希冲突的关键一步,下面是几种常见的方法
除法散列法/除留余数法
通过上面的例子,我们直到,使用单射哈希函数虽然能从根源上杜绝哈希冲突,但毕竟空间利用率不高,不太符合哈希的另一个思想:将离散化的数据集中化。
我们期望的是让hf(key)尽量"均匀"覆盖,也就是说数据在哈希表中均匀分布,存在哈希冲突的概率较低。
除留余数法可以勉强符合这种效果,假定哈希表的大小为M,当前key转化为size_t的key0,那么key对应数组下标为key0%M
这里我们要从位运算的角度来理解一下%运算:
整数 M 对x的n次方求模,等价于将 M 写成 x 进制数字串,截取末尾 n 位(位数不够前面补 0),这 n 位组合成的十进制数值就是模运算结果。
实例 2:二进制 x=2,n=4,模数 2^4=16
明文 M=43,转二进制:
101011,补齐高位0010 1011
- 分割线倒数 4 位前:
0010|1011- 拆分:43=2×16+11
- 43mod16=11,二进制
1011正好后 4 位简图:
二进制M=43(101011) mod 2⁴ 位:第6位第5位 | 第4位第3位第2位第1位 码: 0 1 | 0 1 1 1 高位段 后4位段 高位=2×16,模16清零;后4位数值11=余数为什么要说这个,理解这个有助于我们理解怎样运用除留余数法设计出哈希冲突概率较低的哈希函数。
关键就在于M,我们通过上面的理论意识到,如果M是一个和数,并且是x的n次方,hf(key0)的结果是将key0转化位x进制的后n位,与key0的其它位无关,更容易出现哈希冲突,所以我们要尽量避免这种取值,通过验证,技术大牛们建议M取不太接近2的整数次方的一个质数。
在stl中给出了这样数的求解方式:
cppinline unsigned long __stl_next_prime(unsigned long n)//取接近2的n次方的质数函数 { // Note: assumes long is at least 32 bits. 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);//大于等于n的所求质数 return pos == last ? *(last - 1) : *pos;//最大即4294967291 }除留余数法法是最常用且最容易理解的方法,其他方法了解即可.
2.开放定址法--实现哈希表
开放定址法是基于直接定址法的一种改进,通过使用除留余数法实现的哈希函数,配合相关方法实现解决哈希冲突.
2.1终结哈希冲突
通过除留余数法虽然可以将散乱的元素聚集化,在哈希结构中较为均匀地分布,但是还是不能杜绝哈希冲突.这里我们可以使用线性探测的方式来解决这个问题.
线性探测就是在插入元素时,如果出现哈希冲突,就把新插入元素放置在冲突元素后面最近的一个空位置.在查找时如果通过计算得到下标为key0位置的元素不是期望查找的元素,就依次向后查找,最坏情况下会遍历整个数组..
线性探测(开放定址法)图文通俗讲解
核心规则
哈希表长度 m,哈希函数 H(key)发生冲突时,按固定步长 1 依次往后找空位:Hi(key)=(H(key)+i)modm,i=0,1,2,3...i=0:原始哈希位置;i=1:后移 1 格;i=2:后移 2 格...... 循环绕表查找,直到找到空位置存入。
示例设定
哈希表长度 m=7,下标:0 1 2 3 4 5 6哈希函数:H(key)=keymod7依次插入关键字:22,41,53,46,30
分步推演 + 表格示意图
- 插入 22:22mod7=1,下标 1 为空,直接放入
cpp下标:0 1 2 3 4 5 6 值: 22
- 插入 41:41mod7=6,下标 6 为空,直接放入
cpp下标:0 1 2 3 4 5 6 值: 22 41
- 插入 53:53mod7=4,下标 4 为空,直接放入
cpp下标:0 1 2 3 4 5 6 值: 22 53 41
- 插入 46:46mod7=4,下标 4 已有 53,冲突!i=1:(4+1)mod7=5,下标 5 空,存入 46
cpp下标:0 1 2 3 4 5 6 值: 22 53 46 41
- 插入 30:30mod7=2,下标 2 为空,直接放入最终完整哈希表:
cpp下标:0 1 2 3 4 5 6 值: 22 30 53 46 41再演示连续冲突(聚集现象,线性探测最大缺点)
继续插入关键字 11,11mod7=4
- i=0 下标 4:53(占用)冲突
- i=1 下标 5:46(占用)冲突
- i=2 下标 6:41(占用)冲突
- i=3 (4+3)mod7=0,下标 0 为空,存入 11
最终表:
cpp下标:0 1 2 3 4 5 6 值: 11 22 30 53 46 41可视化冲突探测路径图(文字绘图)
插入 11 时的查找路线:
起始点→4(53) → 下一格5(46) → 下一格6(41) → 绕回开头0(空,存入)关键概念:堆积(主聚集)
线性探测步长永远是 1,相同哈希值、邻近哈希值的元素会连成一整块连续数据。例子中 4、5、6、0 连成一片,之后再哈希到 4 的数字,要遍历一整段才能找到空位,查找速度大幅下降。
查找操作举例:查找 46
- 计算 H(46)=4,下标 4 是 53≠46;
- i=1 下标 5,等于 46,查找成功。
删除注意点
不能直接清空下标!如果把下标 4 的 53 直接删掉置空,之后查找 46 时走到下标 4 发现空,会判定 46 不存在,造成查找失败。解决方案:删除位置标记删除占位符,不置空,探测路过标记位时继续向后查找。
线性探测优缺点
优点:
- 计算最简单,只需要 + 1 取模;
- 无链表,纯数组存储,缓存友好。
缺点:
- 严重主聚集,冲突越多、查找效率越低;
- 删除逻辑复杂,不能直接清空单元格。
2.2 代码实现
2.2.1数据节点的封装
cpp
enum state
{
exist,
empty,
deleted
};
struct hashNode//封装键值对
{
state _s = exist;
pair<K, V> _kv;
hashNode(K key, V val) :_s(exist), _kv({key,val})
{
}
hashNode() :_s(empty)
{
}
};
哈希表中存放的是一个节点,节点引入一个枚举类型state来判断这个节点的状态--是否存有有效值,为了方便删除方法的实现增加一个状态deleted
2.2.2哈希函数
cpp
template<class T>
class Hashfun//哈希函数,将key严格转化为size_t,以方便取模运算
{
public:
size_t operator ()(const T&t)
{
return (size_t)t;
}
};
template <>
class Hashfun<string>
{
public:
size_t operator()(const string& s)//自定义字符串转size_t方式
{
size_t ret = 0;
for (int i = 0; i < s.size(); i++)
{
ret *= 11;
ret += (int)s[i];
}
return ret;
}
};
实现的是一个仿函数,将key严格转化为size_t类型,并对string转化为size_t的函数模板特化
2.2.3哈希表的设计
哈希表中有三个私有变量:
private:
int _n=0;//当前存放元素个数
int _size;//哈希表的容量
std::vector<hashNode<K, V>> _v;//以节点为元素类型的动态数组
2.2.4查找
由于插入逻辑中会复用查找的代码,先假定哈希表插入写好了,哈希表中有元素(元素节点在插入时状态掷为exist),开始查找.
查找的逻辑:通过哈希函数锁定key映射出的数组下标,在数组下标即之后查找.
如果key映射位置存放的值不是期望被查找的值,或者这个位置的值已经被删除,就使用线性探测遍历整个哈希表中的数组.
存在返回地址,不存在返回nullptr
cpp
hashNode<K, V>* find(K key)
{
Fun f1;
size_t hash0 = f1(key) % _v.size();
if (_v[hash0]._s == exist&& _v[hash0]._kv.first==key)return &_v[hash0];
else {
for (int i = 0; i < _v.size(); i++)
{
size_t hashi = (hash0 + i) % _v.size();//确保不出现越界访问
if(_v[hashi]._s==exist&& _v[hashi]._kv.first == key)return &_v[hashi];
}//线性探测
return nullptr;
}
}
2.2.5插入
首先查找一下这个元素在不在当前哈希表中,如果在,由于这是只允许一个key存在的哈希表,插入终止,返回false
接着,计算负载因子,决定是否扩容,若要扩容,采用现代写法:将当前表中的元素插入新扩容哈希表中,再交换二者的_v
然后通过哈希函数计算key所映射的数组坐标,通过线性探测决定插入位置,节点状态为empty或deleted直接插入,不行探测下一个位置.
一定要记得将插入位置元素状态置为exist并且增加_n才返回true
由于有自动扩容机制,如果不是key值重复的插入情况一般来说是不会插入失败的.
cpp
inline unsigned long __stl_next_prime(unsigned long n)//取接近2的n次方的质数函数
{
// Note: assumes long is at least 32 bits.
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);//大于等于n的所求质数
return pos == last ? *(last - 1) : *pos;//最大即4294967291
}
bool insert(K key, V val)
{
if (find(key) != nullptr) return false;//只允许单key值插入
size_t hf = _n * 10 / _v.size();
if (hf > 7)//计算哈希因子决定是否扩容
{
//扩容
hashTable<K, V, Fun> temp;
temp._v.resize(__stl_next_prime(_v.size()) + 1);
for (int i = 0; i < _v.size(); i++)
{
temp.insert(_v[i]._kv.first,_v[i]._kv.second);
}
_v.swap(temp._v);
}
Fun f1;
size_t hash0 = f1(key )% _v.size();
if (_v[hash0]._s== exist)//线性探测
{
for (int i = 0; i < _v.size(); i++)
{
size_t hashi = (hash0 + i) % _v.size();
if (_v[hashi]._s != exist)
{
_v[hashi] = hashNode<K,V>(key, val);
_n++;
return true;
}
}
return false;
}
else
{
_v[hash0] = hashNode<K,V>(key, val);
_n++;
return true;
}
}
2.2.6删除
通过复用查找逻辑锁定待删除元素的位置将该元素的状态置为deleted并减少_n
cpp
bool erase(K key)
{
hashNode<K, V>* ret = find(key);
if ( ret== nullptr)return false;
ret->_s = deleted;
--_n;
return true;
}
2.2.7哈希表封装
整个哈希表的实现如下:
cpp
#include<iostream>
#include<vector>
#include<string>
#include<algorithm> // lower_bound
#include<utility> // pair
using namespace std;
namespace hashTable
{
enum state
{
exist,
empty,
deleted
};
template<class T>
class Hashfun//哈希函数,将key严格转化为size_t,以方便取模运算
{
public:
size_t operator ()(const T&t)
{
return (size_t)t;
}
};
template <>
class Hashfun<string>
{
public:
size_t operator()(const string& s)//自定义字符串转size_t方式
{
size_t ret = 0;
for (int i = 0; i < s.size(); i++)
{
ret *= 11;
ret += (int)s[i];
}
return ret;
}
};
template<class K, class V>
struct hashNode//封装键值对
{
state _s = exist;
pair<K, V> _kv;
hashNode(K key, V val) :_s(exist), _kv({key,val})
{
}
hashNode() :_s(empty)
{
}
};
template<class K, class V, class Fun = Hashfun<K>>
class hashTable
{
public:
hashTable(size_t size=11):_v(size),_n(0)
{
}
hashTable(const hashTable& t)
{
_n = t._n;
_v = t._v;
}
~hashTable() = default;
hashNode<K, V>* find(K key)
{
Fun f1;
size_t hash0 = f1(key) % _v.size();
if (_v[hash0]._s == exist&& _v[hash0]._kv.first==key)return &_v[hash0];
else {
for (int i = 0; i < _v.size(); i++)
{
size_t hashi = (hash0 + i) % _v.size();//确保不出现越界访问
if(_v[hashi]._s==exist&& _v[hashi]._kv.first == key)return &_v[hashi];
}//线性探测
return nullptr;
}
}
inline unsigned long __stl_next_prime(unsigned long n)//取接近2的n次方的质数函数
{
// Note: assumes long is at least 32 bits.
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);//大于等于n的所求质数
return pos == last ? *(last - 1) : *pos;//最大即4294967291
}
bool insert(K key, V val)
{
if (find(key) != nullptr) return false;//只允许单key值插入
size_t hf = _n * 10 / _v.size();
if (hf > 7)//计算哈希因子决定是否扩容
{
//扩容
hashTable<K, V, Fun> temp;
temp._v.resize(__stl_next_prime(_v.size()) + 1);
for (int i = 0; i < _v.size(); i++)
{
temp.insert(_v[i]._kv.first,_v[i]._kv.second);
}
_v.swap(temp._v);
}
Fun f1;
size_t hash0 = f1(key )% _v.size();
if (_v[hash0]._s== exist)//线性探测
{
for (int i = 0; i < _v.size(); i++)
{
size_t hashi = (hash0 + i) % _v.size();
if (_v[hashi]._s != exist)
{
_v[hashi] = hashNode<K,V>(key, val);
_n++;
return true;
}
}
return false;
}
else
{
_v[hash0] = hashNode<K,V>(key, val);
_n++;
return true;
}
}
bool erase(K key)
{
hashNode<K, V>* ret = find(key);
if ( ret== nullptr)return false;
ret->_s = deleted;
--_n;
return true;
}
void print()
{
for (int i = 0; i < _v.size(); i++)
if (_v[i]._s==exist)cout << _v[i]._kv.first << " : " << _v[i]._kv.second << endl;
}
private:
int _n=0;
int _size;
std::vector<hashNode<K, V>> _v;
};
};
3.链接地址法
链接地址法是基于开放定址法的改进,通过对哈希表中数组存储结构的调整从而取缔了线性探测的方式:

vector的每一个元素不再存储节点pair<key,val>,而是节点的指针,用这个指针来铆钉一个链表的头节点,这个链表就是用于存储通过哈希函数计算得到key0值相同的哈希冲突的数据的。
这样一个通过vector元素链接链表list的结构叫做哈希桶,下面来示范一下用哈希桶来应对哈希冲突的情况:(节点数据只显示严格转化为size_t类型的key)这是一个vector容量为7的哈希桶,首先插入7,26,24这三个元素接着插入14元素
7%7=0 头插入到vector0指向的链表中
16%7=2 头插入到vector2指向的链表中
24%7=3 头插入到vector3指向的链表中
14%7=0 即使发生哈希冲突,还是头插入到vector0指向的链表中

这就是链接地址法的核心思想,有了这个思想,我们类比上述哈希表的实现代码,不难实现出哈希桶结构的代码:
cpp
namespace hashBucket
{
template<class K,class V>
struct hashNode//基于链表节点的哈希节点
{
pair<K, V> _kv;
hashNode<K, V>* _next;
hashNode(K key=K(),V val=V()) :_kv({key,val}),_next(nullptr)
{
}
};
template<class T>//哈希函数
class Hashfun
{
public:
size_t operator ()(const T& t)
{
return (size_t)t;
}
};
template <>
class Hashfun<string>//模板特化出key值为string的哈希函数
{
public:
size_t operator()(const string& s)
{
size_t ret = 0;
for (int i = 0; i < s.size(); i++)
{
ret *= 11;
ret += (int)s[i];
}
return ret;
}
};
template<class K,class V,class Hash=Hashfun<K>>
class hashBucket//哈希桶封装
{
typedef hashNode<K, V> node;
private:
vector<node*> _v;
size_t _n=0;
public:
inline unsigned long __stl_next_prime(unsigned long n)
{
// Note: assumes long is at least 32 bits.
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;//最大即4294967291
}
hashBucket()
{
_v.resize(11);
}
~hashBucket()
{
for (int i = 0; i < _v.size(); i++)
{
node* pcur = _v[i];
while (pcur)
{
node* next = pcur->_next;
delete pcur;
pcur = next;
}
}
_n = 0;
}
bool insert(K key, V val)//插入
{
Hash f1;
if (find(key) != nullptr)return false;
int hf = _n *10/ _v.size();
if (hf == 10)//扩容
{
size_t len=__stl_next_prime(_v.size() + 1);
vector<node*> temp;
temp.resize(len);
for (int i = 0; i < _v.size(); i++)
{
node* pcur = _v[i];
while (pcur)//深拷贝
{
node* newnode=new node(pcur->_kv.first, pcur->_kv.second);
size_t hash0 = f1(key) % temp.size();
//头插拷贝节点
node* next = temp[hash0];
newnode->_next = temp[hash0];
temp[hash0] = newnode;
//逐一拷贝到链表尾部
pcur = pcur->_next;
}
}
}
size_t hash0 = f1(key) % _v.size();
if (_v[hash0] == nullptr)//链表头为空
{
_v[hash0] = new node(key, val);
_n++;
return true;
}
else//单链表头插
{
node* newnode = new node(key, val);
newnode->_next = _v[hash0];
_v[hash0] = newnode;
_n++;
return true;
}
}
node* find(K key)//查找
{
Hash f1;
if (_v.size() == 0)return nullptr;
size_t hash0 = f1(key) % _v.size();
node* cur = _v[hash0];
while (cur)//遍历查找整个链表
{
if (cur->_kv.first == key)
return cur;
cur = cur->_next;
}
return nullptr;
}
bool erase(K key)//删除
{
if (find(key) == nullptr)return false;
Hash f1;
size_t hash0 = f1(key) % _v.size();//链表的随机删除
//找指定节点和指定节点的前驱
node*pcur = _v[hash0];
node*front = _v[hash0];
while (pcur)
{
if (pcur->_kv.first != key)
{
front = pcur;
pcur = pcur->_next;
}
else
{
if (front == nullptr)
_v[hash0] = pcur->_next;
else
front->_next = pcur->_next;
delete pcur;
--_n;
return true;
}
}
return true;
}
void print()
{
for (int i = 0; i < _v.size(); i++)
{
node* pcur = _v[i];
while (pcur)
{
cout << pcur->_kv.first << ": " << pcur->_kv.second << endl;
pcur = pcur->_next;
}
}
}
};
};
拓展阅读:
1其他哈希函数实现方法
1. 数字分析法
适用场景
全部关键字提前拿到手,都是固定长度数字,比如一批员工工号、快递单号。
原理
一串数字里,有些位永远不变、重复度极高,有些位变化很大。只挑变化最多、最乱的那几位,拼在一起当哈希下标。举例子:工号全是
202605XXX,前 6 位全一样,全扔掉;最后三位每个人都不同,单独拿出来当下标。优缺点
快、冲突少;但必须提前知道所有编号,新来未知编号没法用。
2. 平方取中法
适用场景
一串数字看不出任何规律,不知道哪几位随机。
原理
- 把数字自己乘自己(平方);
- 平方后数字会变很长,中间几位融合了原数字每一位的信息,乱度最高;
- 截取中间一段数字,当作哈希地址。举例子:key=432平方:432×432=186624截取中间两位 66,哈希下标就是 66。为什么不用首尾?原数字的高低位只会影响平方结果的首尾,随机性差,中间最均匀。
优缺点
不用提前分析数据,通用性强;缺点是大数平方容易溢出,多一步乘法计算。
3. 折叠法
适用场景
关键字特别长,比如 11 位手机号、18 位身份证,直接截取位数太少。分两种:移位折叠、反转折叠
移位折叠原理
把长数字从右往左切成均等小段,最后一段不够长前面补 0;所有小段数字相加,总和当下标。手机号 13512345678,切成三段:135、123、456、78 → 78 补 0 成 078,全部相加。
反转折叠原理
为了降低冲突,切完每一段前后颠倒再相加。上面的分段 135、123、456、078 → 反转成 531、321、654、87 再求和。
优缺点
超长数字专用,计算简单;如果很多号码分段刚好一样,容易算出相同总和,产生冲突。
4. 随机数法
适用场景
不追求极致速度,想要哈希值分布极度均匀,处理特殊 key。
原理
提前固定一套随机规则,同一个关键字丢进去,永远输出同一个 0~1 之间的小数;再缩放成哈希表下标。相当于给每个数字分配一个固定、不重复的随机编号。
优缺点
冲突概率极低;但随机计算耗时间,频繁查询的程序很少单独用。
5. 多项式哈希(字符串专用)
适用场景
处理中文、英文等字符串(密码、姓名、文本)
原理
把每个字符换算成数字(a=1,b=2...),再把整串字符当成一个超大进制数字。比如基数选 10,字符串
ab:a×10¹ + b×10⁰,算出一个大数字,再压缩到哈希表范围内。基数一般选大质数,避免大量字符串算出相同数值。优缺点
所有编程语言处理字符串哈希的标准方法;极少数不同字符串会算出同一个哈希值(哈希碰撞),可双哈希降低概率。
6. 乘法取整法(计算机高速哈希)
适用场景
高性能程序,哈希表长度固定为 2、4、8、16 这种 2 的次方。
原理
- 准备一个固定小数(黄金分割数 0.618);
- 关键字 × 这个小数,得到带整数和小数的结果;
- 丢掉整数部分,只留小数点后面的小数;
- 用哈希表总长 × 小数,取整数当下标。计算机靠移位运算实现,没有除法,运行速度远快于除留余数法。
优缺点
运行极快,数值分布稳定;硬性要求哈希表长度必须是 2 的幂。
7.全域散列
前面讲的平方取中、折叠、多项式哈希、乘法取整都属于单个固定哈希函数,有致命缺陷:只要有人故意设计一批特殊关键字,就能让所有关键字映射到同一个哈希地址,冲突爆炸、查询直接退化到链表遍历。全域散列就是专门解决这个问题的方案。
核心思想
- 提前准备一整个哈希函数集合,里面有成百上千个不同规则的哈希函数;
- 程序运行一开始,随机从中随便挑一个哈希函数固定使用;
- 不管对手怎么刻意构造坏数据,这批关键字发生严重冲突的概率极低,数学上能保证期望冲突很少。
简单比喻:普通哈希 = 你全程只用一把固定钥匙开门,坏人能专门造一堆锁芯匹配同一把钥匙,全部卡死;全域散列 = 开门前先随机抽一把钥匙,坏人提前不知道你会用哪一把,没法精准制造大批量冲突数据。
最简单实现例子(整数全域哈希族)
设哈希表长度为质数 m,提前定义一族函数:Ha,b(key)=(a⋅key+b)modm
- a 随机取 1∼m−1,b 随机取 0∼m−1
- 每一组 (a,b) 对应一个完全不同的哈希函数
程序启动时随机选一组 a、b,之后全程只用这一组计算下标。数学结论:任意两个不同 key,发生碰撞的概率不会超过 m1。
适用场景
- 对外暴露接口、需要防恶意攻击的程序(比如服务器哈希表);
- 不确定输入数据是否有人刻意构造、怕哈希性能被恶意拖慢;
- 安全相关、数据库底层哈希索引。
优点
- 理论上极强的抗冲突、抗攻击能力;
- 随机选函数后,单次哈希计算速度和普通除留余数差不多;
- 不依赖输入数据分布,不管数据有没有规律都稳定。
缺点
- 需要维护一整套哈希函数集合,理解、实现比前面所有基础哈希复杂;
- 小型本地程序、纯课堂基础哈希题几乎不用,考试一般只考概念;
- 随机选函数有极小概率抽到效果很差的哈希,但概率可以忽略。
五、和之前所有哈希方法的本质区别
- 数字分析法 / 平方取中 / 折叠 / 多项式哈希 / 乘法取整:单个固定哈希函数,数据固定后映射关系永久不变,存在被针对性制造大量冲突的风险;
- 全域散列:一组哈希函数 + 运行时随机选一个,从概率层面杜绝恶意数据造成大规模冲突。
简易区分
- 已知全部编号 → 数字分析法
- 普通无规律数字 → 平方取中法
- 超长手机号 / 身份证 → 折叠法
- 姓名、文本、字符串 → 多项式哈希
- 追求极致运行速度 → 乘法取整法
- 追求极低冲突、不心疼性能 → 随机数法
- 杜绝恶意数据造成大规模冲突->全域散列
2.线性探测其它方法
二次探测(平方探测 Quadratic Probing)
1. 偏移公式
标准形式:d(i)=±i^2常用实现(只取正偏移简化计算):hi=(h0+c1⋅i+c2⋅i^2)modm最简通用版:hi=(h0+i^2)modm,i=0,1,2,3...探测序列:h0, h0+1, h0+4, h0+9, h0+16...
举例
m=11, h0=3探测下标依次:3+0=3, 3+1=4, 3+4=7, 3+9=12mod11=1, 3+16=19mod11=8槽位跳跃分布,不会连续扎堆。
2. 解决了什么问题?
消除主聚集(一次聚集):初始哈希相近的 key,探测路径不再重叠,不会挤成连续区块。
3. 存在缺陷:二次聚集(次聚集)
不同关键字如果拥有相同初始哈希值 h0,它们的探测序列完全一致,依旧会争抢同一批槽位。仅缓解聚集,无法彻底消除。
4. 关键使用限制
想要保证一定能找到空槽,两个硬性条件:
- 哈希表长度 m 必须是质数;
- 装填因子 α=n/m≤0.5(元素数量不超过数组一半)。超过 0.5 后,存在大量空槽也可能探测完整个数组找不到空位,插入失败。
5. 优缺点
- 优点:探测跳跃分布,冲突聚集远轻于线性探测,实现简单;
- 缺点:存在次聚集;装填因子上限低,空间利用率差;删除依然需要墓碑标记。
三、双重散列(双哈希 Double Hashing)
开放寻址里综合性能最优的探测方案,彻底解决聚集问题。
1. 核心思想
使用两个独立哈希函数:
- h1(key):基础哈希,得到起始位置
- h2(key):步长哈希,每次探测的偏移步长
探测公式:hi(key)=(h1(key)+i⋅h2(key))modm,i=0,1,2...
- i=0:第一次探测,只取初始位置 h1
- i≥1:每次向后跳 h2(key) 个槽
规范约束(保证能遍历全表)
为了探测序列能覆盖整个数组,不会死循环:
- m 为质数;
- h2(key) 的结果必须和 m 互质,常用写法:h2(key)=R−(keymodR)R 是小于 m 的质数。
实例演示
设 m=11,取 R=7
h1(key)=keymod11,h2(key)=7−(keymod7)插入 key=24:h1=24mod11=2
h2=7−(24mod7)=7−3=4
探测序列:i=0:2+0∗4=2i=1:2+1∗4=6i=2:2+2∗4=10i=3:2+3∗4=14mod11=3i=4:2+4∗4=18mod11=7每次跳跃步长由 key 本身决定,不同 key 步长不一样。
2. 优势:无聚集现象
- 线性探测:主聚集
- 二次探测:次聚集
- 双重散列:不同 key 的探测路径完全不同,不会共享探测链,无任何聚集,平均探测次数最低。
3. 优缺点
优点
- 探测序列随机性最强,冲突探测次数最少;
- 无主 / 次聚集,开放寻址最优方案;
- 只要满足互质条件,能遍历整个哈希数组,装填因子上限高于二次探测。
缺点
- 需要计算两次哈希,计算开销更大;
- 哈希函数设计有约束,编码复杂度更高;
- 同样属于开放寻址,删除必须使用墓碑标记,不能直接清空槽位。
四、三种开放探测横向对比
表格
探测方式 偏移公式 聚集问题 空间上限 α 计算开销 线性探测 h0+i 严重主聚集 接近 1 最小 二次探测 h0+i2 次聚集 ≤0.5 中等(平方运算) 双重散列 h1+i⋅h2 无聚集 接近 1 最大(两次哈希) 五、统一补充:开放寻址删除规则(三者通用)
开放寻址不能直接置空槽位:如果直接清空,后续探测时会提前中断,丢失后面的数据。解决方案:**墓碑标记(删除标记)**槽位三种状态:
- 空:从未存放数据;
- 占用:存有有效 key;
- 墓碑:曾经有数据,现已删除。
- 查找:跳过墓碑,继续探测;
- 插入:墓碑槽位可直接覆盖复用。
六、核心总结
- 线性探测:简单但连续聚集,适合数据量小、冲突少场景;
- 二次探测:用平方跳跃打散槽位,消除主聚集,但同哈希 key 仍会次聚集,空间利用率低;
- 双重散列:双哈希生成可变步长,彻底消除聚集,开放寻址性能天花板,代价是两次哈希计算;
- 三者都属于开放寻址,底层依托数组,无链表,删除均需墓碑标记,和拉链法(链接地址)是两套完全独立的冲突解决思路。
