数据结构与算法---哈希表

数据结构与算法—哈希表

1. 概述
1.1 哈希表概念
散列表(Hash table,也叫哈希表),是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。
给定表M,存在函数f(key),对任意给定的关键字值key,代入函数后若能得到包含该关键字的记录在表中的地址,则称表M为哈希(Hash)表,函数f(key)为哈希(Hash) 函数。
哈希表是一种通过哈希函数将特定的键映射到特定值的一种数据结构,他维护者键和值之间一一对应关系。
1.2 键—key
键(key):又称为关键字。唯一的标示要存储的数据,可以是数据本身或者数据的一部分。
1.3 槽–slot/bucket
槽(slot/bucket):哈希表中用于保存数据的一个单元,也就是数据真正存放的容器。
1.4 哈希函数
哈希函数(hash function):将键(key)映射(map)到数据应该存放的槽(slot)所在位置的函数。
1.5 哈希冲突
哈希冲突(hash collision):哈希函数将两个不同的键映射到同一个索引的情况。
举一个简单的例子,如下图所示:
这里写图片描述
左边很明显是个数组,数组的每个成员包括一个指针,指向一个链表的头,当然这个链表可能为空,也可能元素很多。我们根据元素的一些特征把元素分配到不同的链表中去,也是根据这些特征,找到正确的链表,再从链表中找出这个元素。
1.6 哈希表的优缺点
优点:不论哈希表中有多少数据,查找、插入、删除(有时包括删除)只需要接近常量的时间即0(1)的时间级。实际上,这只需要几条机器指令。
哈希表运算得非常快,在计算机程序中,如果需要在一秒种内查找上千条记录通常使用哈希表(例如拼写检查器)哈希表的速度明显比树快,树的操作通常需要O(N)的时间级。哈希表不仅速度快,编程实现也相对容易。
如果不需要有序遍历数据,并且可以提前预测数据量的大小。那么哈希表在速度和易用性方面是无与伦比的。
缺点:它是基于数组的,数组创建后难于扩展,某些哈希表被基本填满时,性能下降得非常严重,所以程序员必须要清楚表中将要存储多少数据(或者准备好定期地把数据转移到更大的哈希表中,这是个费时的过程)。
2. 常用的散列法
元素特征转变为数组下标的方法就是散列法。散列法当然不止一种,有三种比较常用的散列法,下面一一介绍。
2.1 除法散列法
最直观的一种,上图使用的就是这种散列法,公式:

      index = value % 16 

学过汇编的都知道,求模数其实是通过一个除法运算得到的,所以叫“除法散列法”。
2.2 平方散列法
求index是非常频繁的操作,而乘法的运算要比除法来得省时(对现在的CPU来说,估计我们感觉不出来),所以我们考虑把除法换成乘法和一个位移操作。公式:

    index = (value * value) >> 28   (右移,除以2^28。记法:左移变大,是乘。右移变小,是除。)

如果数值分配比较均匀的话这种方法能得到不错的结果,但我上面画的那个图的各个元素的值算出来的index都是0——非常失败。也许你还有个问题,value如果很大,value * value不会溢出吗?答案是会的,但我们这个乘法不关心溢出,因为我们根本不是为了获取相乘结果,而是为了获取index。
2.3 斐波那契(Fibonacci)散列法
平方散列法的缺点是显而易见的,所以我们能不能找出一个理想的乘数,而不是拿value本身当作乘数呢?答案是肯定的。
1,对于16位整数而言,这个乘数是40503
2,对于32位整数而言,这个乘数是2654435769
3,对于64位整数而言,这个乘数是11400714819323198485
这几个“理想乘数”是如何得出来的呢?这跟一个法则有关,叫黄金分割法则,而描述黄金分割法则的最经典表达式无疑就是著名的斐波那契数列,即如此形式的序列:0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233,377, 610, 987, 1597, 2584, 4181, 6765, 10946,…。另外,斐波那契数列的值和太阳系八大行星的轨道半径的比例出奇吻合。
对我们常见的32位整数而言,公式:

         index = (value * 2654435769) >> 28

如果用这种斐波那契散列法的话,那上面的图就变成这样了:
注:用斐波那契散列法调整之后会比原来的取摸散列法好很多。
适用范围: 快速查找,删除的基本数据结构,通常需要总数据量可以放入内存。
3. 哈希冲突
哈希化之后难免会产生一个问题,那就是对不同的关键字,可能得到同一个散列地址,即同一个数组下标,这种现象称为冲突,那么我们该如何去处理冲突呢?一种方法是开放地址法,即通过系统的方法找到数组的另一个空位,把数据填入,而不再用哈希函数得到的数组下标,因为该位置已经有数据了;另一种方法是创建一个存放链表的数组,数组内不直接存储数据,这样当发生冲突时,新的数据项直接接到这个数组下标所指的链表中,这种方法叫做链地址法。
3.1 链地址法
链地址法又叫:Open Hashing 拉链法。
上图所示的都是链地址法
优点:
拉链法处理冲突简单,且无堆积现象,即非同义词决不会发生冲突,因此平均查找长度较短;
在用拉链法构造的散列表中,删除结点的操作易于实现
缺点:
在对链表进行存储空间分配的时候,会降低整个程序的运行速率
3.2 开地址法
Closed Hashing 开地址法 (Open Addressing)
名词解释:叫Closed,是因为哈希冲突后,并不会在本身之外开拓新的空间,而是继续顺延下去某个位置来存放,所以是一个密闭的空间,所以叫“Closed”,至于开地址(Open Addressing),这个应该相对于那种通过链表来开拓新空间,它是在本身地址上,另外找个位置。所以叫开地址。
3.2.1 线性探测法
进行再探测。就是在其他地方查找。探测的方法也可以有很多种。
(1)在找到查找位置的index的index-1,index+1位置查找,index-2,index+2查找,依次类推。这种方法称为线性再探测。
(2)在查找位置index周围随机的查找。称为随机在探测。
(3)再哈希。就是当冲突时,采用另外一种映射方式来查找。
线性探测有个弊端,即数据可能会发生聚集。一旦聚集形成,它会变得越来越大,那些哈希化后落在聚集范围内的数据项,都要一步步的移动,并且插在聚集的最后,因此使聚集变得更大。聚集越大,它增长的也越快。这就导致了哈希表的某个部分包含大量的聚集,而另一部分很稀疏。
为了解决这个问题,我们可以使用二次探测:二次探测是防止聚集产生的一种方式,思想是探测相隔较远的单元,而不是和原始位置相邻的单元。线性探测中,如果哈希函数计算的原始下标是x, 线性探测就是x+1, x+2, x+3, 以此类推;而在二次探测中,探测的过程是x+1, x+4, x+9, x+16,以此类推,到原始位置的距离是步数的平方。二次探测虽然消除了原始的聚集问题,但是产生了另一种更细的聚集问题,叫二次聚集:比如讲184,302,420和544依次插入表中,它们的映射都是7,那么302需要以1为步长探测,420需要以4为步长探测, 544需要以9为步长探测。只要有一项其关键字映射到7,就需要更长步长的探测,这个现象叫做二次聚集。二次聚集不是一个严重的问题,但是二次探测不会经常使用,因为还有好的解决方法,比如再哈希法。
再哈希法
为了消除原始聚集和二次聚集,现在需要的一种方法是产生一种依赖关键字的探测序列,而不是每个关键字都一样。即:不同的关键字即使映射到相同的数组下标,也可以使用不同的探测序列。再哈希法就是把关键字用不同的哈希函数再做一遍哈希化,用这个结果作为步长,对于指定的关键字,步长在整个探测中是不变的,不同关键字使用不同的步长、经验说明,第二个哈希函数必须具备如下特点:
(1)和第一个哈希函数不同;
(2)不能输出0(否则没有步长,每次探索都是原地踏步,算法将进入死循环)。
专家们已经发现下面形式的哈希函数工作的非常好:stepSize = constant - key % constant; 其中constant是质数,且小于数组容量。
4. 简单的代码实现
4.1 链地址法

这里写代码片

4.1 线性探测法

public class MyHashtable {
    private Item[] items; //DateItem类是数据项,封装数据信息
    private int size;
    private int count; //数组中目前存储了多少项
    private Item nonItem; //用于删除项的
    public MyHashtable() {
        size = 16;
        items = new Item[size];
    }
    public boolean isFull() {
        return (count == size);
    }
    public boolean isEmpty() {
        return (count == 0);
    }
    public int hashFunction(int key) {
        return key % size;     //hash function
    }

    public void insert(Item item) {
        if(isFull()) {
            //扩展哈希表
            extendHashTable();
        }
        int key = item.getKey();
        int hashVal = hashFunction(key);
        while(items[hashVal] != null && items[hashVal].getKey() != -1) {
            ++hashVal;
            hashVal %= size;
        }
        items[hashVal] = item;
        count++;
    }
    /*
     * 数组有固定的大小,而且不能扩展,所以扩展哈希表只能另外创建一个更大的数组,然后把旧数组中的数据插到新的数组中。但是哈希表是根据数组大小计算给定数据的位置的,所以这些数据项不能再放在新数组中和老数组相同的位置上,因此不能直接拷贝,需要按顺序遍历老数组,并使用insert方法向新数组中插入每个数据项。这叫重新哈希化。这是一个耗时的过程,但如果数组要进行扩展,这个过程是必须的。
     */
    public void extendHashTable() { //扩展哈希表
        int num = size;
        count = 0; //重新记数,因为下面要把原来的数据转移到新的扩张的数组中
        size *= 2; //数组大小翻倍
        Item[] oldHashArray = items;
        items = new Item[size];
        for(int i = 0; i < num; i++) {
            insert(oldHashArray[i]);
        }
    }
    public Item delete(int key) {
        if(isEmpty()) {
            System.out.println("Hash table is empty!");
            return null;
        }
        int hashVal = hashFunction(key);
        while(items[hashVal] != null) {
            if(items[hashVal].getKey() == key) {
                Item temp = items[hashVal];
                items[hashVal] = nonItem; //nonItem表示空Item,其key为-1
                count--;
                return temp;
            }
            ++hashVal;
            hashVal %= size;
        }
        return null;
    }

    public Item find(int key) {
        int hashVal = hashFunction(key);
        while(items[hashVal] != null) {
            if(items[hashVal].getKey() == key) {
                return items[hashVal];
            }
            ++hashVal;
            hashVal %= size;
        }
        return null;
    }
}
class Item {
    private int value;
    public Item (int value) {
        value = value;
    }
    public int getKey() {
        return value;
    }
}
  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值