布谷鸟哈希——数据处理的能手
以前在学习某个数据结构的时候看到了这个哈希算法,一看还挺牛逼的,就打算装进脑子里面。
本文默认读者已经学习过基础的哈希表。
简介
布谷鸟哈希是一种在时间上极为高效的哈希算法,其能够做到:
- 均摊
O(1) 插入。 - 严格
O(1) 查找和删除。
目前 OI 中比较常用的哈希冲突的处理办法是拉链法和闭散列法。拉链法就是在同一个地址下面存下所有产生冲突的数字,因为哈希表容量很大,所以说平均每个链拉下来的长度都很短,那么操作复杂度都是常数级别的,而且手写表不像 STL 和平板电视容易被卡,毕竟出题人也不知道你的哈希函数到底长什么样。所以拉链法的查插删都是均摊
布谷鸟哈希的思想和多重哈希的思想其实差不多。
背景及基本思想
首先我们要知道什么是布谷鸟,为什么这个哈希会被叫做布谷鸟哈希。
布谷鸟是自然界中的一种鸟类,它们大多不自己筑巢,而是把鸟蛋下在别的鸟巢之中,自然,鸟蛋就由原鸟巢的母亲抚养,布谷鸟幼崽会先于原鸟蛋孵化,然后幼崽会把剩下的原鸟蛋挤出巢外,独享宠爱。
布谷鸟哈希也是这样的。
在双重哈希中,若哈希函数
布谷鸟哈希有很多版本,不过最为经典的还是两个哈希函数和两个哈希桶的版本,也是最容易理解的。
其思想为:先将键值 rehash 的活动。不过 rehash 真正启用的时候很少,你也可以就让它一直循环下去。
我们发现,多重哈希就像是一个人一直想找一个酒店住下,但是他发现酒店房间一直都是满的,于是就一直换酒店一直换酒店,最后找到一个有空房间的酒店再住进去。
而布谷鸟哈希就是一个人想找一个酒店住,于是他直接破门而入,然后把住在房间里面的人丢出门外,再把门锁上,将屋子占为己有。而被丢的人就红温了,于是他就继续去其他酒店破门而入,直到每个人都住下来了。
工作过程
我们可以举一个例子,来更好的说明布谷鸟哈希的工作过程:假设我们现在有一组等待哈希映射的键值
首先我们先插入
\ 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14
1 - - - - - 20 - - - - - - - - -
2 - - - - - - - - - - - - - - -
随后我们依次插入
\ 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14
1 - - - 3 - 20 - - - - - - - 13 -
2 - - - - - - - - - - - - - - -
随后我们插入
\ 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14
1 - - - 48 - 20 - - - - - - - 13 -
2 3 - - - - - - - - - - - - - -
接着我们插入
\ 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14
1 - - 32 48 - 20 - - - - - - - 13 -
2 3 - - - 17 - - - - - - - - - -
随后我们插入
\ 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14
1 - - 32 48 - 20 - - - - 40 - - 13 -
2 3 - - - 17 - - - - - - - - - -
插入
\ 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14
1 - - 32 48 - 35 - - - - 40 - - 13 -
2 3 - - - 17 20 - - - - - - - - -
至此,所有数字映射完毕,哈希结束。
更进一步
当然我们希望有更为极端的情况出现。于是我们设 rehash,或者调整哈希函数。如果是在 OI 赛制中,我推荐使用 rehash。rehash 的思路和 STL 差不多,只需要拉伸一下哈希表,给它扩容一下即可。如果是在 IOI 赛制中,我推荐重新设置哈希函数,就不多说了。
布谷鸟哈希还有其他的拓展写法,比如设置第三个哈希函数
复杂度
插入时间复杂度:均摊
删除时间复杂度:
查询时间复杂度:
不过大家更关心的肯定是这个插入时间的具体分析,我们就来说明一下。
关于插入的几种情况的具体分析
下面我们设目前待插入的键值为
情况一很好说,就是
现在我们来证明每次插入操作所引发的扩容操作的均摊时间成本为
设我们的哈希表大小为
在所有数据全部哈希完成之后,易知容操作总执行次数为
我们继续深入。
双哈希函数的布谷鸟哈希表可以用一张有向图来描述:点表示一个地址
其中方框表示地址,圆表示键值,方框中含圆表示该地址已有键值存储,方框中无圆表示该地址为空,圆不在方框内表示待插入。
我们即将插入
我同时对边进行了染色处理,红色边表示这条边经过了反转,绿色表示我们即将沿着这条边存储进一个位置。
随后是
最后,在踢出
随后
看,我们将
我们把这个插入冲突过程用序列来表示就是
放到一般情况下来讲,一段完整的有环的插入冲突序列形如
分析时间复杂度之前,还有两个东西要提一嘴。
插入环引理:
设当前插入键值
x 构成冲突序列x_1=x,x_2,x_3,\dots,x_p 且不构成死循环情况,则其中存在一个长度为l\ge \frac{p}{3} 的连续子序列x_q,x_{q+1},x_{q+2},\dots,x_{q+l-1} 满足其中元素互异且x_q=x_1=x 。 :::info[证明]{open}
若无环,引理显然成立。若
:::
:::info[全域哈希]{open}
若你的哈希函数是固定的,设哈希表大小为
全域哈希还保证了一个性质,即对于任意两个键值
:::
我们在设定循环阈值 rehash),我们肯定会产生一个长度为
对此,有两种情况:
-
不出现环:概率上界为
\displaystyle 2(1+\epsilon)^{-\frac{k-1}{3}+1} ,其中这个\epsilon 就是我们在之前所定下的哈希表大小r\ge(1+\epsilon)\times n 中的常数。 -
出现环:概率上界为
\displaystyle O(\frac{1}{n^2}) 。
注意,这两个情况不是互斥的。
至于我们是怎么把这两个概率上界给定出来的呢。
::::info[不出现环的情况]
由插入环引理,我们得知:一个长度为
因为可以采用全域哈希,那么我们就可以假设,
那么,存在
:::info[中间那个不等关系怎么来的?]
:::
::::
:::info[出现环的情况]
我们令
在环中我们定义过
那么求和一下。
因为
:::
那么,我们可以计算循环插入中产生的序列预期长度为:
也就是说,冲突次数是
证完力!
上述文章还有一个点没有讲到:凭什么进行一次扩容操作的时间复杂度为
接下来我们再证:扩容操作的时间复杂度是均摊
我们得知:进入死循环的概率是
因此,一次插入操作导致扩容的概率为
在扩容操作时,我们会执行
证完力!
code
至此,理论部分全部完工,接下来是代码部分:
因为这是运用于 OI 之中,而非工程,所以我阉割掉了动态扩容的操作,也就是静态空间,以及全域哈希。
#include<bits/stdc++.h>
#define ll long long
#define ull unsigned long long
#define X first
#define Y second
using namespace std;
mt19937 myrand(time(0));
char buf[1<<23],*p1=buf,*p2=buf;
#define gc() (p1==p2&&(p2=(p1=buf)+fread(buf,1,1<<21,stdin),p1==p2)?EOF:*p1++)
inline ull read() {
ull x=0;
char ch=gc();
while(!isdigit(ch))ch=gc();
while(isdigit(ch)) x=x*10+(ch^48),ch=gc();
return x;
}
ull ans;
namespace ARONA{
const int SIZE=9e6;
const int Null=-1;
const int MAXLOOP=30;
struct HashTable{
pair<ull,ull>a[SIZE+5],b[SIZE+5],c[SIZE+5];
inline ull h1(const ull &x){
return (x*0x9e3779b97f4a7c15)%SIZE;
}
inline ull h2(const ull &x){
return (x/213)%SIZE;
}
inline ull h3(const ull &x){
return ((x^(x<<23))*0x1919810ddd)%SIZE;
}//三个哈希函数,两个太容易被卡了
HashTable(){for(int i=0;i<=SIZE;i++)a[i].X=a[i].Y=b[i].X=b[i].Y=c[i].X=c[i].Y=Null;}
inline void clear(){for(int i=0;i<=SIZE;i++)a[i].X=a[i].Y=b[i].X=b[i].Y=c[i].X=c[i].Y=Null;}//清零
inline ull find(const ull &x){//查询函数
if(a[h1(x)].X==x)return a[h1(x)].Y;
if(b[h2(x)].X==x)return b[h2(x)].Y;
if(c[h3(x)].X==x)return c[h3(x)].Y;
return Null;
}
void erase(const ull &x){//删除函数
if(a[h1(x)].X==x){a[h1(x)]={-1,-1};return;}
if(b[h2(x)].X==x){b[h2(x)]={-1,-1};return;}
if(c[h3(x)].X==x){c[h3(x)]={-1,-1};return;}
}
//重点!插入函数!
void insert(const ull &key,const ull &value){
if(find(key)!=Null)return;
pair<ull,ull>now={key,value};
if(a[h1(key)].X==Null){
a[h1(key)]=now;
return;
}
if(b[h2(key)].X==Null){
b[h2(key)]=now;
return;
}
if(c[h3(key)].X==Null){
c[h3(key)]=now;
return;
}//先尝试插进三个哈希表中的对应空位
for(int i=1;;i++){//没有扩容,自然没有循环上限
if(a[h1(now.X)].X==Null){
a[h1(now.X)]=now;
return;
}
if(b[h2(now.X)].X==Null){
b[h2(now.X)]=now;
return;
}
if(c[h3(now.X)].X==Null){
c[h3(now.X)]=now;
return;
}
if(now.X==-1)return;
if(i%3==0)swap(a[h1(now.X)],now);
else if(i%3==1)swap(b[h2(now.X)],now);
else swap(c[h3(now.X)],now);//这里就是替换操作了
}
assert(0);
}
};
}
using namespace ARONA;
HashTable mp;
int n;
ull x,y;
int main(){
cin>>n;
for(int i=1;i<=n;i++){
x=read();
y=read();
ull tmp=mp.find(x);
if(tmp==-1)tmp=0;
ans+=i*1ll*tmp;
mp.erase(x);
mp.insert(x,y);
}
cout<<ans;
return 0;
}
题目为 P11615。在题解区我写了一篇简短一些的题解。
参考资料
https://cs.stanford.edu/~rishig/courses/ref/l13a.pdf
https://en.wikipedia.org/wiki/Universal_hashing