数据结构 | 第9章 词典 - 散列表 (hashtable)(下)

96 阅读12分钟

9.3.5 冲突及其排解

  • 冲突的普遍性

    散列表的基本构思,可概括为:

    开辟物理地址连续的桶数组ht[],借助散列函数hash(),将词条关键码key映射为桶地址hash(key),从而快速地确定待操作词条的物理位置。

    遗憾的是,无论散列函数设计得如何巧妙,也不可能保证不同的关键码之间互不冲突。

  • 多槽位 (multiple slots) 法

    将彼此冲突的每一组词条组织为一个小规模的子词典,分别存放于它们共同对应的桶单元中。

    一种简便的方法是,统一将各桶细分为更小的称作槽位(slot)的若干单元,每一组槽位可组织为向量或列表。

    image.png

    如图9.10所示,将各桶细分为四个槽位,只要相互冲突的各组关键码不超过4个,即可分别保存于对应桶单元的不同槽位。

    按照这一思路,针对关键码key的任一操作都将转化为对一组槽位的操作。如put(key, value):先通过hash(key)定位对应的桶单元,并在其内部一组槽位中进一步查找key。若失败,则创建新词条(key, value),并将其插至该桶单元内的空闲槽位(如果还有的话)。

    多槽位法的缺点显而易见:

    1. 绝大多数槽位处于空闲状态,若每个桶被细分为k个槽位,则装填因子将降低至原先的1 / k。
    2. 很难事先确定槽位应细分到何种程度才够用,极端情况下可能某一桶因冲突过多溢出而其余桶都处于空闲状态。
  • 独立链 (separate chaining) 法

    与多槽位法类似,也令相互冲突的每组词条构成小规模子词典,只不过采用列表(非向量)来实现各子词典。

    image.png

    优势:相对于多槽位法,独立链法可更为灵活地动态调整各子词典的容量和规模,从而有效地降低空间消耗。

    缺点:查找过程中一旦发生冲突仍需遍历整个列表。

  • 公共溢出区法 (overflow area)

    image.png

    如图9.12所示,在原散列表(图(a))之外另设一个词典的结构Doverflow(图(b)),一旦在插入词条时发生冲突就将该词条转存至Doverflow中。就效果而言,Doverflow相当于一个存放冲突词条的公共缓冲池。

    优势:

    1. 这一策略构思简单易于实现,在冲突频繁时不失为一种好的选择。
    2. 同时,既然公共溢出区本身也是一个词典结构,不妨直接套用现有的任何一种实现方式——因此就整体结构而言,此时的散列表也可理解为是一种递归形式的散列表。

9.3.6 闭散列策略

尽管就逻辑结构而言,独立链等策略便捷而紧凑,但绝非上策。比如,因需要引入次级关联结构,实现算法的代码自身的复杂程度和出错概率都将大大增加。反过来,因不能保证物理上的关联性,对于稍大规模的词条集,查找过程需要更多的I/O操作。

实际上,仅仅依靠基本的散列表结构,且就地地排解冲突,反而是更好的选择——即新词条与已有词条冲突,则只允许在散列表内部为其寻找另一空桶。如此,各桶并非注定只能存放特定的一组词条;理论上讲每个桶单元都有可能存放任一词条。

按这一策略,因为散列地址空间对所有词条开放,故称其为开放定址 (open addressing) ;同时,因可用的散列地址仅限于散列表所覆盖的范围内,故亦称之为闭散列 (closed hashing)。相应地,此前的策略亦称作封闭定址 (closed adressing) 或开散列 (open hashing)。

开放定址策略的冲突排解方法包括线性试探法、平方试探法以及再散列法等

  • 线性试探 (linear probing) 法

    开放定址策略最基本的一种形式。不断+1试探桶单元,最后统一对M取模确保地址合法。

    第i次试探的桶单元为:ht[ (hash(key) + i) mod M], i = 1, 2, 3, ...

    image.png

    如此,被试探的桶单元在物理空间上依次连贯,其地址构成等差数列。

  • 查找链

    探究开放地址策略下词条的查找问题。以线性试探法对应的查找链为例。

    image.png

    沿查找链试探的过程,与对应关键码此前的插入过程完全一致。对于长度为n的查找链,失败查找长度就是n+1。等概率假设下,平均查找长度为⌈n/2⌉。

  • 局部性

    由上可见,线性试探法中组成各查找链的词条,在物理上保持一定的连贯性,具有良好的数据局部性,故系统缓存的作用可以充分发挥,查找过程中几乎无需I/O操作。尽管闭散列策略同时也会在一定程度上增加冲突发生的可能(被填满的坑多了,非空的桶多了),但只要散列表的规模不是很小,装填因子不是很大,相对于I/O负担的降低而言,这些问题都微不足道。也正因为此,相对于独立链等开散列策略,闭散列策略的实际应用更为广泛。

  • 懒惰删除

    查找链中任何一环的缺失,都会导致后续词条因无法抵达而丢失,表现为有时无法找到实际已存在的词条。因此若采用开放定址策略,在执行删除操作时,需做特别的调整。

    image.png

    调整方法:为每个桶另设一个标志位,指示该桶尽管目前为空,但此前确存放过词条。

    具体实现:在Hashtable模板类中,名为lazyRemoval的Bitmap对象扮演这一角色。

    这一方法即可保证查找链的完整,同时所需的时间成本也极其低廉,称作懒惰删除 (lazy removal) 法。

    设有懒惰删除标志位的桶,应与普通的空桶一样参与插入操作。

  • 两类查找

    采用“懒惰删除”策略之后,get()、put()和remove()等操作中的查找算法都需要做相应的调整,分两类情况:

    1. 在删除等操作之前对某一目标词条的查找。只有在当前桶单元为空,不带有懒惰删除标记时,方可报告“查找失败”。否则沿查找链继续试探。
    2. 在插入等操作之前沿查找链寻找空桶(散列表插入要插到空桶里)。无论当前桶为空,还是带有懒惰删除标记,均可报告“查找成功”。否则沿查找链继续试探。

9.3.7 查找与删除

  • get()

     0001 template <typename K, typename V> V* Hashtable<K, V>::get ( K k ) //散列表词条查找算法
     0002 {  int r = probe4Hit ( k ); return ht[r] ? &( ht[r]->value ) : NULL;  } //禁止词条的key值雷同
    
  • probe4Hit()(其实就是probe for hit的意思,下面probe4Free同理)

     0001 /******************************************************************************************
     0002  * 沿关键码k的试探链,找到与之匹配的桶;实践中有多种试探策略可选,这里仅以线性试探为例
     0003  ******************************************************************************************/
     0004 template <typename K, typename V> int Hashtable<K, V>::probe4Hit ( const K& k ) {
     0005    int r = hashCode( k ) % M; //按除余法确定试探链起点
     0006    while ( ( ht[r] && ( k != ht[r]->key ) ) || removed->test(r) )
     0007       r = ( r + 1 ) % M; //线性试探(跳过带懒惰删除标记的桶)
     0008    return r; //调用者根据ht[r]是否为空及其内容,即可判断查找是否成功
     0009 }
    
  • remove()

     0001 template <typename K, typename V> bool Hashtable<K, V>::remove ( K k ) { //散列表词条删除算法
     0002    int r = probe4Hit( k ); if ( !ht[r] ) return false; //确认目标词条确实存在
     0003    release ( ht[r] ); ht[r] = NULL; --N; //清除目标词条
     0004    removed->set(r); ++L; //更新标记、计数器
     0005    if ( 3*N < L ) rehash(); //若懒惰删除标记过多,重散列
     0006    return true;
     0007 }
    

9.3.8 插入

  • put()

     0001 template <typename K, typename V> bool Hashtable<K, V>::put( K k, V v ) { //散列表词条插入
     0002    if ( ht[ probe4Hit( k ) ] ) return false; //雷同元素不必重复插入
     0003    int r = probe4Free( k ); //为新词条找个空桶(只要装填因子控制得当,必然成功)
     0004    ht[ r ] = new Entry<K, V>( k, v ); ++N; //插入
     0005    if ( removed->test( r ) ) { removed->clear( r ); --L; } //懒惰删除标记
     0006    if ( (N + L)*2 > M ) rehash(); //若装填因子高于50%,重散列
     0007    return true;
     0008 }
    
  • probe4Free()

     0001 /******************************************************************************************
     0002  * 沿关键码k的试探链,找到首个可用空桶;实践中有多种试探策略可选,这里仅以线性试探为例
     0003  ******************************************************************************************/
     0004 template <typename K, typename V> int Hashtable<K, V>::probe4Free ( const K& k ) {
     0005    int r = hashCode ( k ) % M; //按除余法确定试探链起点
     0006    while ( ht[r] ) r = ( r + 1 ) % M; //线性试探,直到首个空桶(无论是否带有懒惰删除标记)
     0007    return r; //只要有空桶,线性试探迟早能找到
     0008 }
    
  • 装填因子

    随着λ的上升,词条在散列表中聚集的程度亦将迅速加剧,若同时还采用懒惰删除法,则不带懒惰删除标记的桶单元必将持续减少,这也使查找成本进一步攀升。

    解决方法:只要能将装填因子控制在适当范围以内,闭散列策略的平均效率可维持在理想水平。

    一般的建议是保持装填因子λ < 0.5。

    而对于独立链法而言,建议的装填因子上限为0.9。

  • 重散列 (rehashing)

    重散列是将装填因子控制在一定范围内的常用方法。

     0001 /******************************************************************************************
     0002  * 重散列:空桶太少时对散列表重新整理:扩容,再将词条逐一移入新表
     0003  * 散列函数的定址与表长M直接相关,故不可简单地批量复制原桶数组
     0004  ******************************************************************************************/
     0005 template <typename K, typename V> void Hashtable<K, V>::rehash() {
     0006    int oldM = M; Entry<K, V>** oldHt = ht;
     0007    M = primeNLT( 4 * N, 1048576, PRIME_TABLE ); //容量至少加倍(若懒惰删除很多,可能反而缩容)
     0008    ht = new Entry<K, V>*[M]; N = 0; memset( ht, 0, sizeof( Entry<K, V>* ) * M ); //桶数组
     0009    release( removed ); removed = new Bitmap( M ); L = 0; //懒惰删除标记
     0010    for ( int i = 0; i < oldM; i++ ) //扫描原表
     0011       if ( oldHt[i] ) //将每个非空桶中的词条
     0012          put( oldHt[i]->key, oldHt[i]->value ); //转入新表
     0013    release( oldHt ); //释放——因所有词条均已转移,故只需释放桶数组本身
     0014 }
    

    可见,重散列的效果是将原词条集整体“搬迁”至容量至少加倍的新散列表中。

    与扩充向量同理,这一策略也可使重散列消耗的时间在分摊至各次操作后忽略不计。6

9.3.9 更多闭散列策略

  • 聚集现象

    线性试探法虽然简明紧凑,但各查找链均由物理地址连续的桶单元组成,因而会加剧关键码的聚集趋势。

    image.png

  • 平方试探 (quadratic probing) 法

    采用MAD法可在一定程度上缓解上述聚集现象,而平方试探法则更为有效。

    第 j 次试探的桶地址:( hash(key) + j2 ) mod M, j = 0, 1, 2, ...

    image.png

    顺着查找链,试探位置的间距将以线性(不是常数1)速度增长。

  • 局部性

    细心读者可能会担心,试探位置加速跳离起点,会使数据局部性失效。然而幸运的是,鉴于目前常规的I/O页面规模已经足够大,只有在查找链极长的时候,才可能引发额外的I/O操作。

  • 确保试探必然终止

    线性试探法:至多遍历一遍,必然会终止。

    平方试探法:λ > 50%可能找不到;λ ≤ 50%迟早必然终止于某个空桶。

    image.png

  • (伪)随机试探 (pseudo-random probing) 法

     第j次试探的桶地址取作:rand(j) mod M。
    

    跨平台时,出于兼容性的考虑,慎用。

  • 再散列 (double hashing) 法

    选取一个适宜的二级散列函数hash2(),发现占用以hash2(key)为偏移量继续尝试直至发现空桶。

    再散列法是对此前各方法的概括,如hash2(key) = 1即是线性试探法。

9.3.10 散列码转换

为扩大散列技术的适用范围,散列函数hash()必须能够将任意类型的关键码key映射为地址空间[0, M)内的一个整数hash(key),以便确定key所对应的散列地址。其过程可分解为以下两步:

  1. hashCode()将关键码key统一转换为一个整数——散列码(hash code)
  2. 再利用散列函数将散列码映射为散列地址。 image.png

hashCode()应具备哪些条件?

  1. 取值范围应覆盖系统所支持的最大整数范围。
  2. 经其映射后所得的散列码,相互之间的冲突也尽可能减少。
  3. 与判等器保持一致。

以下是若干对应的散列码转换方法:

  • 强制转换为整数

    对于byte、short、int和char本身即可表示为不超过32位整数的数据类型,可直接将它们的这种表示作为其散列码。

  • 对成员对象求和

    long long和double之类长度超过32位的基本类型,不以强制转换为整数。可以将高32位和低32位分别看作两个32位整数,将二者之和作为散列码。可推广至任意多个整数构成的组合对象,可累加再截取低32位作位散列码。

  • 多项式散列码 (polynomial hash code)

    与一般的组合对象不同,字符串内各字符之间的次序具有特定含义,故在做散列码转换时,务必考虑它们之间的次序。

    为计入各字符的出现次序,可取常数a ≥ 2,并将字符"x0x1...xn-1"的散列码取作:

    x0an-1 + x1an-2 + ... + xn-2a1 + xn-1

    这一转换等效于依次将字符串中各个字符视作一个多项式的各项系数。

    对于英语单词之类的字符串,a = 33、37、39或41都不错(实验表明)

  • hashCode()的实现

     0001 static size_t hashCode ( char c ) { return ( size_t ) c; } //字符
     0002 static size_t hashCode ( int k ) { return ( size_t ) k; } //整数以及长长整数
     0003 static size_t hashCode ( long long i ) { return ( size_t ) ( ( i >> 32 ) + ( int ) i ); }
     0004 static size_t hashCode ( char s[] ) { //生成字符串的循环移位散列码(cyclic shift hash code)
     0005    unsigned int h = 0; //散列码
     0006    for ( size_t n = strlen ( s ), i = 0; i < n; i++ ) //自左向右,逐个处理每一字符
     0007       { h = ( h << 5 ) | ( h >> 27 ); h += (int) s[i]; } //散列码循环左移5位,再累加当前字符
     0008    return ( size_t ) h; //如此所得的散列码,实际上可理解为近似的“多项式散列码”
     0009 } //对于英语单词,"循环左移5位"是实验统计得出的最佳值
    

“开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 17 天,点击查看活动详情