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

210 阅读7分钟

9.3 散列表

以下将围绕散列、散列函数以及冲突排解三个主题,逐层深入地展开介绍。

9.3.1 完美散列

  • 散列表

    散列表(hashtable)是散列方法的底层基础,逻辑上由一系列可存放词条(或其引用)的单元组成——亦称桶(bucket)或桶单元;与之对应地,各桶单元也应按其逻辑次序在物理上连续排列。

    这种线性的底层结构用向量来实现再自然不过。为简化实现并进一步提高效率,往往直接使用数组,此时的散列表亦称作桶数组(bucket array)。若桶数组的容量为R,则其中合法秩的区间[0, R)也称作地址空间(address space)。

  • 散列函数

    一组词条在散列表内部的具体分布,取决于所谓的散列(hashing)方案——事先在词条与桶地址之间约定的某种映射关系,可描述为从关键码空间到桶数组地址空间的函数:

    hash():keyhash(key)hash() : key → hash(key)

    这里的hash()称作散列函数(hash function)。反过来,hash(key)也称作key的散列地址(hashing address),亦即与关键码key相对应的桶在散列表中的秩。

  • 实例

    在时间和空间性能方面均达到最优的散列,也称作完美散列(perfect hashing)(不常见)。

9.3.2 装填因子与空间利用率

  • 电话查询系统

    空间有效利用率极低。

  • IP节点查询

    空间利用率也仅为5%左右。

  • 兼顾空间利用率与速度

    此类问题的共同特点可归纳为:尽管词典中实际需要保存的词条数N(比如25000门)远远少于可能出现的词条数R(10^8门),但R个词条中的任何一个都有可能出现在词典中。

    仿照2.4.1节针对向量空间利用率的度量方法,这里也可以将散列表中非空桶的数目与桶单元总数的比值称作装填因子(load factor)。故上述问题的实质在于散列表的装填因子太小导致空间利用率过低。

    如何克服存储空间利用率方面的不足?—— 其一就是散列函数的设计。

9.3.3 散列函数

假定关键码均为[0, R)范围内的整数。将词典中的词条数记作N,散列表长度记作M,于是通常有:

R >> M > N

如图9.6所示,散列函数hash()的作用可理解为,将关键码空间[0, R)压缩为散列地址空间[0, M)。

  • 设计原则

    作为好的散列函数,hash()应具备哪些条件呢?

    1.确定性;2.映射过程自身不能过于复杂;3.最好是满射。

    当然,因定义域规模R远远大于取值域规模M,hash()不可能是单射。这意味着关键码不同的词条被映射到同一散列地址的情况——称作散列冲突(collision)——难以彻底避免。

    9.3.5节会介绍解决冲突的办法,但设计和选择散列函数的考量也颇为重要——尽量使关键码映射到各桶的概率接近1 / M。就整体而言,这等效于将关键码空间“均匀地”映射到散列地址空间。

  • 除余法(division method)

    将散列表长度M取作为素数,并将关键码key映射至key关于M整除的余数:

    hash(key) = key mod M

    采用除余法必须将M选作素数,否则关键码被映射至[0, M)范围内的均匀度将大幅降低,发生冲突的概率将随M所含素因子的增多而迅速加大。

    image.png

    如图9.8(a)所示,M = 20,插入前10个是等差数列{1000, 1015, ..., 1135}:在实际应用中,对同一词典内词条的访问往往具有周期性,若其周期与M具有公共的素因子,则冲突的概率将急剧上升。

    词条集中到散列表内少数若干桶中(或附近)的现象,称作词条的聚集(clustering)。采用素数表长是降低聚集发生概率的捷径。一般地,散列表的长度M与词条关键码间隔T之间的公约数越大,发生冲突的可能性也越大。因此M取素数,则简便对于严格或大致等间隔的关键码序列,也不致出现冲突激增的情况,提高空间效率。

    如图9.8(b)所示,改用表长M = 19,则没有冲突,空间利用率在50%以上。

    如图9.8(c)所示,改用表长M = 11,则没有冲突且仅有一桶空闲。

    当然,若T本身足够大且被M整除,则所有被访问词条都冲突,如图9.8(d)所示。

    image.png

  • MAD法(multiply-add-divide method)

    以素数为表长的除余法尽管可在一定程度上保证词条的均匀分布,但从关键码空间到散列地址空间映射的角度看,依然残留有某种连续性——相邻关键码所对应的散列地址相邻;极小的关键码往往都被映射到散列表的起始区段,其中特别的,0值居然是一个“不动点”,散列地址为0。

    image.png

    图9.9:将关键码{2011, 2012, 2013, 2014, 2015, 2016}插入长度为M = 17的空散列表。

    为弥补这一不足,可采用所谓的MAD法将关键码key映射为:

    (a × key + b) mod M,其中M仍为素数,a > 0,b > 0,且a mod M ≠ 0

    此类散列函数需依次执行乘法、加法和除法(模余)运算,故此得名。

    图9.9(b):取a = 31和b = 2时,按MAD法的散列结果,均匀性较(a)大改善。

    除余法可以看作是MAD法取a = 1和b = 0的特殊情况。导致除余法连续性的根源亦可理解为这两个常数未发辉实质性作用。

  • 更多的散列函数

    散列函数种类繁多:

    1. 数字分析法(selecting digits)
    2. 平方取中法(mid - square)
    3. 折叠法(folding)
    4. 异或法(xor)

    当然,为保证上述函数取值落在合法的散列地址空间以内,通常都还需要对散列表长度M再做一次取余运算。

  • (伪)随机数法

    上述各具特点的散列函数,验证了我们此前的判断:越是随机、越是没有规律,就越是好的散列函数。按照这一标准,任何一个(伪)随机数发生器,本身即是一个好的散列函数。比如,可直接使用C/C++语言提供的rand()函数,将关键码映射至桶地址:

    rand(key) mod M

    其中rand(key)为系统定义的第key个(伪)随机数。

    需要注意的是,由于不同计算环境所提供的(伪)随机数发生器不尽相同,故在将某一系统中生成的散列表移植到另一系统时需格外小心。

9.3.4 散列表

  • Hashtable模板类

     0001 #include "Dictionary/Dictionary.h" //引入词典ADT
     0002 #include "Bitmap/Bitmap.h" //引入位图
     0003 
     0004 template <typename K, typename V> //key、value
     0005 class Hashtable : public Dictionary<K, V> { //符合Dictionary接口的Hashtable模板类
     0006 private:
     0007    Entry<K, V>** ht; //桶数组,存放词条指针
     0008    int M, N, L; //桶的总数、词条的数目、懒惰删除标记的数目(N + L <= M)
     0009    Bitmap* removed; //懒惰删除标记
     0010 protected:
     0011    int probe4Hit ( const K& k ); //沿关键码k对应的试探链,找到词条匹配的桶
     0012    int probe4Free ( const K& k ); //沿关键码k对应的试探链,找到首个可用空桶
     0013    void rehash(); //重散列算法:扩充桶数组,保证装填因子在警戒线以下
     0014 public:
     0015    Hashtable ( int c = 5 ); //创建一个容量不小于c的散列表(为测试暂时选用较小的默认值)
     0016    ~Hashtable(); //释放桶数组及其中各(非空)元素所指向的词条
     0017    int size() const { return N; } // 当前的词条数目
     0018    bool put ( K, V ); //插入(禁止雷同词条,故可能失败)
     0019    V* get ( K k ); //读取
     0020    bool remove ( K k ); //删除
     0021 };
    
  • 散列表构造

     0001 template <typename K, typename V> Hashtable<K, V>::Hashtable ( int c ) { //创建散列表,容量为
     0002    M = primeNLT ( c, 1048576, "../../_input/prime-1048576-bitmap.txt" ); //不小于c的素数M
     0003    N = 0; ht = new Entry<K, V>*[M]; //开辟桶数组(假定成功)
     0004    memset ( ht, 0, sizeof ( Entry<K, V>* ) * M ); //初始化各桶
     0005    removed = new Bitmap ( M ); L = 0; //用Bitmap记录懒惰删除
     0006 }
    
     0001 int primeNLT ( int c, int n, char* file ) { //根据file文件中的记录,在[c, n)内取最小的素数
     0002    Bitmap B ( file, n ); //file已经按位图格式记录了n以内的所有素数,因此只要
     0003    while ( c < n ) //从c开始,逐位地
     0004       if ( B.test ( c ) ) c++; //测试,即可
     0005       else return c; //返回首个发现的素数
     0006    return c; //若没有这样的素数,返回n(实用中不能如此简化处理)
     0007 }
    
  • 散列表析构

     0001 template <typename K, typename V> Hashtable<K, V>::~Hashtable() { //析构前释放桶数组及非空词条
     0002    for ( int i = 0; i < M; i++ ) //逐一检查各桶
     0003       if ( ht[i] ) release ( ht[i] ); //释放非空的桶
     0004    release ( ht ); //释放桶数组
     0005    release ( removed ); //释放懒惰删除标记
     0006 } //release()负责释放复杂结构,与算法无直接关系,具体实现详见代码包
    

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