对比 - 开放定址法 & 封闭定址法

<mark>通常</mark>的<mark>散列表</mark>实现,如HashMap,使用的是 <mark>分离链接法</mark>(separate chaining)(<mark>封闭定址法</mark>),<mark>其做法是将散列到同一值得所有元素保留到一个表中</mark>。

分离链接散列算法<mark>缺点</mark>:

  • 需要指针,由于给新单元分配地址需要时间,这就导致了速度减慢,
  • 动态分配的节点在内存里不一定是连续的
    (对于稍大规模的词条集合,查找中将做大量的I/O操作,无法利用系统预先缓存,导致效率低下)

现在,讨论<mark>另一种</mark>的<mark>散列表</mark>的实现,<mark>探测散列表</mark>(probing hash table) (<mark>开放定址策略</mark>)。


开放定址法 - 冲突解决策略

策略也可以称作为<mark>闭散列</mark>。

<mark>如果有冲突发生</mark>,就要尝试选择另外的单元,直到找到一个可供存放的空单元。
具体存放在哪个单元,是有不同优先级的,优先级最高的是他原本归属的那个单元。
从这个单元往后,都按照某种优先级规则排成一个序列,而在查找的时候也是按着这个序列行进.

每个词条对应的这个序列被称为探测序列or查找链。


策略 - 公式

<mark>抽象来说,就是我们遇到冲突后,会相继尝试h0(x),h1(x),h2(x)这些单元,其中hi(x)= ( Hash( x ) + F ( I ) ) % TableSize,并且约定F(0)=0</mark>

F(x)是解决冲突的方法,就是刚才说的那个“<mark>优先级规则</mark>”。


策略 - 特点

  • 因为所有的数据都要放在这块空间,所以<mark>开放定址所需要的表规模比分离链接要大</mark>。
  • 通常而言开放定址法的<mark>装填因子</mark>lambda应该<mark>低于0.5</mark>。

而根据对不同F(x)的选择,学界划分出三种常用的探测序列:线性探测法、平方探测法、双散列


开放定址法 - 优点

  • 散列表所占用的空间在物理上始终是地址连续的一块,相应的所有的冲突都在这块连续空间中加以排解。无需向分离链接那样申请额外的空间

三种方法






线性探测法

在线性探测法中,函数F是关于i的线性函数,<mark>典型的情形是F(i)=i</mark>。这相当于逐个探测每个单元(必要时可以绕回)以查找出一个空单元。

下面显示了将{89,18,49,58,69}插入到一个散列表中的情况(竖着看),使用了和之前一样的散列函数hash(x)=x%size

他们有<mark>冲突怎么办</mark>?

用F(i)=i这个方法,每次从i=0开始尝试,那么根据hi(x)= ( Hash( x ) + F ( I ) ) % TableSize就可以计算出各自不相冲突的地址了。

完美!(暂时的)


我们脑内单步调试一下:
<mark>第一个冲突</mark>在49产生:(49%10+0)%10=9,被89占了,那接着往后试,i=1,(49%10+1)%10=0,空的,放入这个空闲地址,这个地址是开放的。

<mark>第二次冲突</mark>在58产生:58依次和18,89,49产生冲突,试选三次后才找到一个空单元。对69的冲突也如此解决,一旦冲突,试探紧邻其后的单元,直至找到空单元or抵达散列表末尾。

线性探测序列0->1->2->3在物理上保持连贯性的,具有局部性,这样一来系统的缓存作用将得到充分发挥,而对于大规模的数据集,这样一来更是可以减少I/O的次数。只要表足够大,总能找到一个空闲单元,但是这<mark>太费时间</mark>了。

更糟的是——就算一开始空闲区域多,经过多次排解冲突后,数据所占据的单元也会开始形成一些区块,聚集在一起,被称为一次<mark>聚集(primary clustering)</mark>,但在前面动机篇里说过, <mstyle mathcolor="&#35;ff0011"> </mstyle> \color{#ff0011}{散列函数的初衷是避免数据扎堆,所以后面必须改进}

那么总体看来散列到区块的任何关键字都需要多次试选单元才能解决冲突,然后被放到对应的那个区块里。下面做一个总结


优点:

无需附加空间(指针、链表、溢出区)
探测序列具有局部性,可以利用系统缓存,减少IO

缺点:

耗费时间>O(1)
冲突增多——以往的冲突会导致后续的连环冲突,发生惨烈的车祸、


光看这个图可能没什么感觉,举个例子吧,这样感触更深。我们开一个size=7的散列表,也保证了size是素数。把{0,1,2,3,7},就按这个顺序依次插入。前四个数都没问题,依次插入没有冲突。


但是为了插入7,我们先试探0发现非空,往后走,依次试探1,2,3都非空,直到4可以放进去。

<mark>在这个散列表的生存期里只有1个发生冲突。看似很棒对吧,再来看另一插入次序:{7,0,1,2,3}。</mark>


插入7没问题,
但插入0的时候就有冲突了,实际上自此之后每一个数插入都会遇到冲突,前后对比可以看出,
第二种插入顺序发生的很多冲突本来是可以避免的。

<mark>这个时候想必我们改进这种策略的意愿就十分迫切</mark>了。


致命问题 - 删除元素

要支持词条的删除则需要格外的小心,现在我们来做一探讨。
按照线性探测的规则,先后插入彼此冲突的一组词条都将存放在同一个查找序列中,而

更确切的讲: <mstyle mathcolor="&#35;ff0011"> </mstyle> \color{ff0011}{它们应该按照逻辑次序构成整个查找链的一个前缀,其中不得有任何的空桶缝隙。}

因此词条的删除操作需要做额外的一些处理,如果我们不做一些事先准备,直接将词条删除(就类似对于链表,删除节点的时候不做链条调整,而直接free那个单元,那不直接凉了),就会造成查找链断裂,后续词条丢失——明明存在但访问不到。


如何删除? - 懒惰删除

对于这种连续空间的单元删除,一个直观的构想是:将后续词条悉数取出,再重新插入。但这太特么慢了,时间复杂度爆炸。

其实对于这个问题有一种典型的处理手法:lazy delete,仅做一个删除标记,比如里面预留一个del变量,设置为TRUE。

那么在日后查找中,遇到他之后就应该越过继续往后查找而不是在此返回。
在插入时遇到,就把它视作一个空单元,数据覆盖即可。

应该说针对开放定址策略,<mark>懒惰删除</mark>不仅是“不得已而为之”的方法,甚至可以说是针对这种情况的最优方法。

因为毕竟在开放定址策略中,每一个桶单元都同时属于多个查找链。


性能 - 分析 - (查找性能)

下面咱们聊聊严肃点的事,做一些细致分析。

可以证明的是,使用线性探测的预期探测次数和装填因子存在函数关系,对于插入、查找失败的情况,大约需要 ↓↓↓↓

查找 1 2 ( 1 + 1 ( 1 λ ) 2 ) \frac{1}{2}\left ( 1+\frac{1}{\left ( 1-\lambda \right )^{2}}\right ) 21(1+(1λ)21)次。

( λ ) \left ( \lambda \right) (λ) = 加载因子 (小于0.5)

对于查找成功的情况需要 ↓↓↓↓

查找 1 2 ( 1 + 1 ( 1 λ ) ) \frac{1}{2}\left ( 1+\frac{1}{\left ( 1-\lambda \right )}\right ) 21(1+(1λ)1)次。

相关的证明过程和计算推导有点复杂…这里就直接给出结论。

那这个性能如何呢,我们仔细来推敲一下

虽然刚才从感性认识的角度,我们能察觉到线性探测是有必要改进的,
因为:<mark>我们能感知到,表中已有元素越多,新插入时需要探测的次数就越多</mark>,这貌似不是个好兆头。

但是用数学背景作为背书才是有说服力的。
(下面可能有点难以理解,但尽量试着理解吧)


假设1:对于随机冲突的解决方法而言,可以假设每次探测与之前的探测无关,这是成立的,因为随机。
(白话:<mark>因为随机,所以我们假设每次探测与之前的探测无关</mark>)

假设2:并且假设有一个很大规模的表


先计算单次失败查找的期望探测次数——这也是找到一个空单元的期望次数。

已知空单元所占比例是1-λ,那么预计需要探测的单元数量是 1 ( 1 λ ) \frac{1}{\left ( 1-\lambda \right )} (1λ)1

证明:

  • 假设期望探测T次能找到空,
  • 找到一个空概率(1-λ)

1 = (1-λ)*T
所以,
T = 1 ( 1 λ ) \frac{1}{\left ( 1-\lambda \right )} (1λ)1

<mark>一次成功查找的探测次数等于该特定元素插入时所需要的探测次数</mark>
(白话:插入时候探测多少次,查找就要探测多少次)

<mark>当一个元素被插入时,可以看成进行一次不成功查找的结果</mark>

因此我们可以使用单次失败查找的开销来计算查找成功的平均开销。

这句话的内在逻辑是这样的
<mark>“失败查找的探测次数 = 插入时探测次数 = 查找成功的探测次数”</mark>,
看似挺矛盾的,我一开始也不太理解,但我们仔细分析一下就能认识到它的道理:

  • 首先,右式,<mark>一次成功查找的探测次数就等于这个元素插入的探测次数</mark>,
    这个不难理解,插入的时候探测n次,然后放入空单元;
    之后查找时也是探测n次,第n+1次探测直接命中,两者相等。
  • 然后说左式,在插入之前,<mark>即将插入时的的探测次数=失败查找的探测次数</mark>,因为插入前没有这个元素,自然查找失败。所以左式=右式,这就能大概理解了吧。

还有一件事,早期的λ比较小,所以造次插入开销较低,从更降低了平均开销。比如在上面那个表中,λ=0.5。

访问18的开销是在18被插入时确定的,此时λ=0.2,而由于18是插入到一个比较稀疏的表中,因此对他的访问比更晚插入的元素(e.g. 69)更容易。

我们可以通过积分来估计平均的插入时间:

这就比之前线性探测的公式更好了,另外,聚集这个问题,不仅是理论上棘手,在具体实现中也时隐时现,就像幽灵一样。一个幽灵,数据聚集的幽灵,在开放定址表里徘徊。为了对这个幽灵进行神圣的围剿,学界的一切势力,计算机科学家,数学家,还有各路工程师都联合起来了。(这个幽灵的确也为散列理论的创新发展提供了动力,是有一定进步意义的)

我们再来看,如果λ=0.75,那么上面的公式指出,线性探测中1次插入预计要进行8.5次探测。如果λ=0.9,你猜猜我们要找多少次能找到空单元?50次!这绝对不合理。从这些公式我们可以窥见:如果整个表>50%的区域被填满,那么线性探测就不是个好办法。但另一方面,如果是个稀疏表,λ很小,那么线性探测可谓如鱼得水了——我们就算按这个“小”的概念里撑死了说,λ=0.5,插入时平均只用探测……猜猜….2.5次!,并且对于成功查找平均只需要探测1.5次,酷不酷! 要以时间条件地点为转移。

这再次说明了CS里权衡之术的地位,也是矛盾论的胜利,在矛盾普遍性原理的指导下,具体分析矛盾的特殊性,并找出解决矛盾的正确方法2333(扯远了扯远了)

下一篇我们讨论一个更优化的方案——平方探测法。

(转载 )https://www.cnblogs.com/hongshijie/p/9419387.html




参考: