乐趣区

关于算法:看动画学算法之hashtable

简介

java 中和 hash 相干并且罕用的有两个类 hashTable 和 hashMap, 两个类的底层存储都是数组,这个数组不是一般的数组,而是被称为散列表的货色。

散列表是一种将键映射到值的数据结构。它用哈希函数来将键映射到小范畴的指数(个别为 [0.. 哈希表大小 -1])。同时须要提供抵触和对抵触的解决方案。

明天咱们来学习一下散列表的个性和作用。

文末有代码地址,欢送下载。

散列表的要害概念

散列表中比拟要害的三个概念就是散列表,hash 函数,和抵触解决。

散列是一种算法(通过散列函数),将大型可变长度数据集映射为固定长度的较小整数数据集。

散列表是一种数据结构,它应用哈希函数无效地将键映射到值,以便进行高效的搜寻 / 检索,插入和 / 或删除。

散列表广泛应用于多种计算机软件中,特地是关联数组,数据库索引,缓存和汇合。

散列表必须至多反对以下三种操作,并且尽可能高效:

搜寻(v)– 确定 v 是否存在于散列表中,
插入(v)– 将 v 插入散列表,
删除(v)– 从散列表中删除 v。

因为应用了散列算法,将长数据集映射成了短数据集,所以在插入的时候就可能产生抵触,依据抵触的解决办法的不同又能够分为线性探测,二次探测,双倍散列和拆散链接等抵触解决办法。

数组和散列表

思考这样一个问题:找到给定的字符串中第一次反复呈现的的字符。

怎么解决这个问题呢?最简略的方法就是进行 n 次遍历,第一次遍历找出字符串中是否有和第一个字符相等的字符,第二次遍历找出字符串中是否有和第二个字符相等的字符,以此类推。

因为进行了 n * n 的遍历,所以工夫复杂度是 O(n²)。

有没有简略点的方法呢?

考虑一下字符串中的字符汇合其实是无限的,如果都是应用的 ASCII 字符,那么咱们能够构建一个 256 长度的数组一次遍历即可。

具体的做法就是遍历一个字符就将绝对于的数组中的相应 index 中的值 +1,当咱们发现某个 index 的值曾经是 1 的时候,就晓得这个字符反复了。

数组的问题

那么数组的实现有什么问题呢?

数组的问题所在:

键的范畴必须很小。如果咱们有(十分)大范畴的话,内存使用量会(十分的)很大。
键必须密集,即键值中没有太多空白。否则数组中将蕴含太多的空单元。

咱们能够应用散列函数来解决这个问题。

通过应用散列函数,咱们能够:

将一些非整数键映射成整数键,
将大整数映射成较小的整数。

通过应用散列函数,咱们能够无效的缩小存储数组的大小。

hash 的问题

无利就有弊,尽管应用散列函数能够将大数据集映射成为小数据集,然而散列函数可能且很可能将不同的键映射到同一个整数槽中,即多对一映射而不是一对一映射。

尤其是在散列表的密度十分高的状况下,这种抵触会常常产生。

这里介绍一个概念:影响哈希表的密度或负载因子 α = N / M,其中 N 是键的数量,M 是哈希表的大小。

其实这个抵触的概率要比咱们设想的更大,举一个生日悖论的问题:

一个班级外面有多少个学生会使至多有两人生日雷同的概率大于 50%?

咱们来计算一下下面的问题。

假如 Q(n)是班级中 n 集体生日不同的概率。

Q(n)= 365/365×364/365×363/365×…×(365-n + 1)/ 365,即第一人的生日能够是 365 天中的任何一天,第二人的生日能够是除第一人的生日之外的任何 365 天,等等。

设 P(n)为班级中 n 集体的雷同生日的概率,则 P(n)= 1-Q(n)。

计算可得,当 n =23 的时候 P(23) = 0.507> 0.5(50%)。

也就是说当班级领有 23 集体的时候,班级至多有两个人的生日雷同的概率曾经超过了 50%。这个悖论通知咱们:集体感觉常见的事件在个体中却是常见的。

好了,回到咱们的 hash 抵触,咱们须要构建一个好的 hash 函数来尽量减少数据的抵触。

什么是一个好的散列函数呢?

可能疾速计算,即其工夫复杂度是 O(1)。
尽可能应用最小容量的散列表,
尽可能平均地将键扩散到不同的基地址∈[0..M-1],
尽可能减少碰撞。

在探讨散列函数的实现之前,让咱们探讨现实的状况:完满的散列函数。

完满的散列函数是键和散列值之间的一对一映射,即基本不存在抵触。当然这种状况是十分少见的,如果咱们当时晓得了散列函数中要存储的 key,还是能够办到的。

好了,接下来咱们讨论一下 hash 中解决抵触的几种常见的办法。

线性探测

先给出线性探测的公式:i 形容为 i =(base + step * 1)%M,其中 base 是键 v 的散列值,即 h(v),step 是从 1 开始的线性探测步骤。

线性探测的探测序列能够正式形容如下:

h(v)// 基地址
(h(v)+ 1 * 1)%M // 第一个探测步骤,如果产生碰撞
(h(v)+ 2 * 1)%M // 第二次探测步骤,如果仍有碰撞
(h(v)+ 3 * 1)%M // 第三次探测步骤,如果仍有抵触

(h(v)+ k * 1)%M // 第 k 个探测步骤等 …

先看个例子,下面的数组中,咱们的基数是 9,数组中曾经有 1,3,5 这三个元素。

当初咱们须要插入 10 和 12,依据计算 10 和 12 的 hash 值是 1 和 3,然而 1 和 3 当初曾经有数据了,那么须要线性向前探测一位,最终插入在 1 和 3 的前面。

下面是删除 10 的例子,同样的先计算 10 的 hash 值 =1,而后判断 1 的地位元素是不是 10,不是 10 的话,向前线性探测。

看下线性探测的要害代码:

    // 插入节点
    void insertNode(int key, int value)
    {HashNode temp = new HashNode(key, value);

        // 获取 key 的 hashcode
        int hashIndex = hashCode(key);

        //find next free space
        while(hashNodes[hashIndex] != null && hashNodes[hashIndex].key != key
            && hashNodes[hashIndex].key != -1)
        {
            hashIndex++;
            hashIndex %= capacity;
        }
        // 插入新节点,size+1
        if(hashNodes[hashIndex] == null || hashNodes[hashIndex].key == -1) {size++;}
        // 将新节点插入数组
        hashNodes[hashIndex] = temp;
    }

如果咱们把具备雷同 h(v) 地址的间断存储空间叫做 clusters 的话,线性探测有很大的可能会创立大型主 clusters,这会减少搜寻(v)/ 插入(v)/ 删除(v)操作的运行工夫。

为了解决这个问题,咱们引入了二次探测。

二次探测

先给出二次探测的公式:i 形容为 i =(base + step * step)%M,其中 base 是键 v 的散列值,即 h(v),step 是从 1 开始的线性探测步骤。

h(v)// 基地址
(h(v)+ 1 * 1)%M // 第一个探测步骤,如果产生碰撞
(h(v)+ 2 * 2)%M // 第 2 次探测步骤,如果仍有抵触
(h(v)+ 3 * 3)%M // 第三次探测步骤,如果仍有抵触

(h(v)+ k * k)%M // 第 k 个探测步骤等 …

就是这样,探针依照二次方跳转,依据须要盘绕哈希表。

看一个二次探测的例子,下面的例子中咱们曾经有 38,3 和 18 这三个元素了。当初要向外面插入 10 和 12。大家能够自行钻研下探测的门路。

再看一个二次摸索删除节点的例子。

看下二次探测的要害代码:

    // 插入节点
    void insertNode(int key, int value)
    {HashNode temp = new HashNode(key, value);

        // 获取 key 的 hashcode
        int hashIndex = hashCode(key);

        //find next free space
        int i=1;
        while(hashNodes[hashIndex] != null && hashNodes[hashIndex].key != key
            && hashNodes[hashIndex].key != -1)
        {
            hashIndex=hashIndex+i*i;
            hashIndex %= capacity;
            i++;
        }

        // 插入新节点,size+1
        if(hashNodes[hashIndex] == null || hashNodes[hashIndex].key == -1) {size++;}
        // 将新节点插入数组
        hashNodes[hashIndex] = temp;
    }

在二次探测中,群集(clusters)沿着探测门路造成,而不是像线性探测那样围绕基地址造成。这些群集称为次级群集(Secondary Clusters)。
因为在所有密钥的探测中应用雷同的模式,所以造成次级群集。

二次探测中的次级群集不如线性探测中的主群集那样蹩脚,因为实践上散列函数实践上应该首先将键扩散到不同的基地址∈[0..M-1] 中。

为了缩小次要和主要 clusters,咱们引入了双倍散列。

双倍散列

先给出双倍散列的公式:i 形容为 i =(base + step * h2(v))%M,其中 base 是键 v 的散列值,即 h(v),step 是从 1 开始的线性探测步骤。

h(v)// 基地址
(h(v)+ 1 * h2(v))%M // 第一个探测步骤,如果有碰撞
(h(v)+ 2 * h2(v))%M // 第 2 次探测步骤,如果仍有抵触
(h(v)+ 3 * h2(v))%M // 第三次探测步骤,如果仍有抵触

(h(v)+ k * h2(v))%M // 第 k 个探测步骤等 …

就是这样,探测器依据第二个散列函数 h2(v)的值跳转,依据须要盘绕散列表。

看下双倍散列的要害代码:

    // 插入节点
    void insertNode(int key, int value)
    {HashNode temp = new HashNode(key, value);

        // 获取 key 的 hashcode
        int hashIndex = hash1(key);

        //find next free space
        int i=1;
        while(hashNodes[hashIndex] != null && hashNodes[hashIndex].key != key
            && hashNodes[hashIndex].key != -1)
        {hashIndex=hashIndex+i*hash2(key);
            hashIndex %= capacity;
            i++;
        }

        // 插入新节点,size+1
        if(hashNodes[hashIndex] == null || hashNodes[hashIndex].key == -1) {size++;}
        // 将新节点插入数组
        hashNodes[hashIndex] = temp;
    }

如果 h2(v)= 1,则双散列(Double Hashing)的工作形式与线性探测(Linear Probing)完全相同。所以咱们通常心愿 h2(v)> 1 来防止主聚类。

如果 h2(v)= 0,那么 Double Hashing 将会不起作用。

通常对于整数键,h2(v)= M’ – v%M’ 其中 M ’ 是一个小于 M 的质数。这使得 h2(v)∈[1..M’]。

二次散列函数的应用使得实践上难以产生次要或主要群集问题。

拆散链接

拆散链接法(SC)抵触解决技术很简略。如果两个键 a 和 b 都具备雷同的散列值 i,那么这两个键会以链表的模式附加在要插入的地位。

因为键(keys)将被插入的中央齐全依赖于散列函数自身,因而咱们也称拆散链接法为关闭寻址抵触解决技术。

下面是拆散链接插入的例子,向现有的 hashMap 中插入 12 和 3 这两个元素。

下面是拆散链接删除的例子,从链接中删除 10 这个元素。

看下拆散链接的要害代码:

 // 增加元素
    public void add(int key,int value)
    {int index=hashCode(key);
        HashNode head=hashNodes[index];
        HashNode toAdd=new HashNode(key,value);
        if(head==null)
        {hashNodes[index]= toAdd;
            size++;
        }
        else
        {while(head!=null)
            {if(head.key == key)
                {
                    head.value=value;
                    size++;
                    break;
                }
                head=head.next;
            }
            if(head==null)
            {head=hashNodes[index];
                toAdd.next=head;
                hashNodes[index]= toAdd;
                size++;
            }
        }
        // 动静扩容
        if((1.0*size)/capacity>0.7)
        {HashNode[] tmp=hashNodes;
            hashNodes=new HashNode[capacity*2];
            capacity=2*capacity;
            for(HashNode headNode:tmp)
            {while(headNode!=null)
                {add(headNode.key, headNode.value);
                    headNode=headNode.next;
                }
            }
        }

rehash

当负载因子 α 变高时,哈希表的性能会升高。对于(规范)二次探测抵触解决办法,当哈希表的 α > 0.5 时,插入可能失败。
如果产生这种状况,咱们能够从新散列(rehash)。咱们用一个新的散列函数构建另一个大概两倍的散列表。咱们遍历原始哈希表中的所有键,从新计算新的哈希值,而后将键值从新插入新的更大的哈希表中,最初删除较早的较小哈希表。

本文的代码地址:

learn-algorithm

本文已收录于 http://www.flydean.com/14-algorithm-hashtable/

最艰深的解读,最粗浅的干货,最简洁的教程,泛滥你不晓得的小技巧等你来发现!

欢送关注我的公众号:「程序那些事」, 懂技术,更懂你!

退出移动版