浅析散列设计

作者: 贪婪的君子 | 来源:发表于2017-08-07 22:58 被阅读64次

    说道散列,我们可能觉得是个新奇的事物,但实际上学过Java的人就会知道,在equal函数中会使用到hashcode,那就是所谓的散列码,而将对应的字转换成散列码的过程,我们称之为散列过程,这个过程函数被称之为散列函数。

    理想情况下,不同的关键字的散列码是不会相同的。

    1. 散列表


    现在让我们存储一些数据,要求满足:

    • 通过关键字来决定存储的位置。
    • 关键字即为索引。
    • 不同的关键字所对应的内容不同。
    • 通过关键字直接查找到其对应的数据内容。

    阅读完要求,我们立即就能想到,通过创建一个大小为n(n大于所要存储数据的个数)的数组,数组元素的下标对应于不同的关键字(这里假设关键字为自然数,关键字i对应数组第i个元素),这样的一个数组便可以满足上述的所有要求。

    但是往深里想,如果我们要存储的数据非常多,而内存空间又是有限的情况下,这样的行为很可能就会撑爆内存。虽然在理想情况下,使用数组存储的效果非常显著,但是实际的情况总会有些出入。

    上述过程是一种静态集合的表述,相对的,我们还有动态集合,这种集合形式也是很常见的(比如Java程序员所熟知的HashMap),与静态集合大体相似,其不同在于,动态集合可能并不会将全域中所有的关键字都存储在表中。

    假设我们所存储的关键字是从一个集合中取得的一部分,将其存储在一个数组中的对应位置。这样的一个集合我们称为全域,这个存储关键字的数组也可以被称为直接寻址表,该表中的每个位置都被称为

    也正是因为这个可能,也就造成了实际存储时关键字集合K相对于全域U来说可能很小的情况,此时若采用直接寻址表(表大小=全域的关键字个数),会造成空间浪费;而如果存储全域(假设该域非常大)中所有的值,那么这个表又会非常的大,那么又会撑爆内存空间,于是我们便开始研究其他替代方案——散列表。

    散列表本质上也是一个数组,只是关键字到数组中位置的转换关系是使用散列函数来确定的。我们知道,函数的映射关系是1-1或是多-1的关系,散列函数也有这种情况,不同的关键字在散列后可能对应相同的散列码(即key1 ≠ key2,hashcode(key1) = hashcode(key2)),这种情况就被称之为散列冲突。对于散列冲突,我们也有对应的解决方法如,链接法和开放寻址法。

    不过,要尽可能的去减少散列冲突,使关键字尽可能的均匀分布,这样散列表的性能才能更好的发挥出来。
    Java库函数中就有通过再散列的方式使关键字能够均匀分布。

    2. 散列冲突


    散列冲突是不可避免的,但是如果是散列函数的设计不当,那么我们可以通过改进散列函数来降低散列冲突,但当我们现阶段所使用的散列函数已经优化的很好时,如何处理散列冲突才是我们需要考虑的问题。

    2.1 链接法解决散列冲突

    所谓链接法,说的比较笼统,其实在我们学习图的数据结构时接触过,就是邻接表的形式

    邻接表

    不过,邻接表在图的数据结构中是为了存储其中一个顶点到其余各顶点的路径长度(也可说两点间是否存在一条路径),在这里,我们称其为索引链表也许更为合适。

    链接法解决散列冲突的主要思想是:(假设所有的关键字是互异的)将散列到同一个槽的关键字放在一个链表中,该链表的头结点指向索引表中对应的槽。

    链接法解决散列冲突

    从上图中我们可以发现,关键字域包含于全域(一般情况下|U|<<k),给出向散列表中插入、删除以及查找的操作:

    bool insert(Node *key)
    {
        int hashcode = hash(key->getKey());//先得到结点关键字key的散列码
        if (T[hashcode]) == NULL) //判断槽位是否已经存储元素,即冲突
        {
            key->setNext(T[hashcode].getNext());//插入槽位对应链表的头部,这样便不用循环查找链表尾结点
            T[hashcode].setNext(key);
            return true; //插入成功
        }
        else 
        {
            T[hashcode].setNext(key);
            key.setNext(NULL);
            return true;
        }
    }
    
    Node *search(int key)//假设查找关键字为key的结点
    {
        int hashcode = hash(key);
        if (T[hashcode] == NULL)
            return NULL;//查找失败,表中无该结点
        Node *t = T[hashcode]; //记录槽位指针信息
        while (t->getNext() != NULL && t->getNext()->getKey() == key)
        { 
            return t;  //返回该结点的前一个结点,方便删除操作
            t = t->getNext();
        }
    }
    
    bool delete(int key)//删除关键字为key的结点,双向链表的删除操作会比较简单
    {
        Node *d = search(key);//先进行查找
        if (d == NULL)
            return true;  //表中无该结点,认为删除成功
        Node *dn = d->getNext();//获取真正需要删除的结点
        d->setNext(dn->getNext());
        dn->setNext(NULL);
        delete dn;
    }
    

    散列函数如果设计的好,使得元素能够均匀的分布在散列表中,那么这种情况下,散列表查找一个元素的时间代价就是常数阶,但如果设计的不好,元素未能均匀分布,在一种极端的情况下,查找一个元素的时间代价就变为线性阶,这也就失去了散列表的优势。

    2.2 开放寻址法解决散列冲突

    在开放寻址法中,所有的元素都存储在散列表中,但这里所指的所有元素并不是全域中的所有元素,而是所有需要存储在散列表中的元素,即使冲突。散列表也不是索引+链接的形式,而是去掉了链接表,故而散列表是可以被填满的。

    这里的所有元素不好理解,不过依据链接法中的图示,我们可以说是将链表中的所有元素都存入索引表中,无论是否存在冲突。

    没有了链表存储冲突的元素,此时我们就需要在索引表中为冲突元素找到一个槽位存放(一般来说,冲突元素会从冲突的位置开始向后移动,直到找到一个空位再放入,这种方式就称为探查),检查空位的顺序是依赖于待插入的关键字。

    我们需要计算出一个探查序列来保证,当散列表逐渐被填满时,每一个表位最终都能被考虑为用来插入新关键字的槽。一般来说,我们有三种方式来计算开放寻址法的探查序列:线性探查二次探查双重探查

    1. 线性探查
      给定一个辅助散列函数h'(就是普通的散列函数),使全域的关键字在散列表的槽中找到相应的映射,该方法采用的散列函数为:h(k,i) = (h'(k) + i) mod m, i = 0, 1, 2…… ,其中,k表示关键字,i表示探查序号,m表示槽的总数(表大小),记住这里的mod m,后续会省略。
      这种线性探查的过程可以表述为:首先探查由辅助散列函数所给出的槽位T[h'(k)],再探查槽T[h'(k) + 1],依次类推,直到到达槽T[m-1],然后回到T[0]开始,到T[h'(k) -1]结束。不难发现,如果出现很长的连续空槽,就会使查找时间不断的增加。

    2. 二次探查
      二次探查的散列函数为:h(k,i) = (h'(k) + c1·i + c2·i^2)mod m 步骤类似于线性探查,只是首次探查结束后,后续的探查位置加上的是一个以二次的方式依赖于探查序号i的偏移量(为了充分利用散列表,我们要限制c1,c2以及m的值),但由于是加一个偏移量,故而我们可以发现,如果两个不同的关键字初始探查位置相同,那么其探查序列也是相同的。

    3. 双重散列
      这种方式所产生的排列具有随机选择排列的很多特性,形式如下:h(k,i) = h'(k) + i·h''(k))mod m ,其中h'和h''都是辅助散列函数,这就是所谓的双重,探查步骤与上述两种探查方式类似,只是由于两个辅助散列函数的存在使得初始探查位置、偏移量均可能发生变化,故而导致探查序列几乎不存在相同的情况。

    上述三种探查方式的函数形式参考自《算法导论》第三版

    3. 散列函数的设计


    一个好的散列函数能够使关键字均匀分布在表中,而均匀分布又意味着在查找等操作中的时间代价会大幅降低,很难出现最坏的情况。

    对于那种字符类的关键字,我们可以将其通过ASCII表或是Unicode码表进行转换得到自然数再散列。

    设计一个散列函数不难,随手写的h(key) = key + 1 这种都可以说是散列函数,但是这种散列函数并没有什么意义,加一个常数或是减一个常数,就仅仅是向后\向前移动了一个常数位,但是这种么没有意义的函数却能够较为充分的利用散列表。

    加减乘除四则运算中,将加减运算的任意一种单独拿出来做散列函数并没有什么太多的意义,因为组成过于简单。而乘除法不同,虽然组成同样简单,但却有其特殊的意义。

    仅用除法设计散列函数,我们可以简单的写为h(key) = key / m ,这样的关键字总能找到一个位置存放,再对这个形式进行加强处理,可以得到h(key) = ( (float)key / (float)x ) / m ,这个x是一个小数,在这个函数中,不难看出第一个除法运算实质上是一个乘法的运算,这样我们也做到了混合运算。

    在通过除法设计散列函数的时候,我们要记住如果m尽量选为不太接近2的整数幂的素数,选择2的幂次作为m的值是需要避免的,这并不是因为这是错的,而是因为在二进制中2的幂次表示为key的低位,而我们又难以确定低位的排列都是等可能的,这样会使冲突的可能性无法被保证。

    以乘法设计散列函数时,最简单的形式为h(key) = A * key ,当然了,我们需要进行一次取模运算,否则不能保证散列码会有对应的槽位,所以以乘法为基础设计散列函数时,并不能保证其运算的单一性。对简单形式加强后得到h(key) = m * (k * A mod 1) ,这里括号内的运算是为了取k*A的小数部分。这种方式没有对A的值进行限制,但是对一些值来说效果会更加出众,Kunth[211]认为取黄金分割率为A值会得到较为理想的结果。

    通过以上的分析,我们可以看出,散列函数的设计不会单纯的依靠单一的运算法则,混合运算设计的散列函数相对而言更加有用。

    相关文章

      网友评论

        本文标题:浅析散列设计

        本文链接:https://www.haomeiwen.com/subject/qadzlxtx.html