程序员,你应该知道的数据结构之哈希表

哈希表简介

哈希表也叫散列表,哈希表是一种数据结构,它提供了快速的插入操做和查找操做,不管哈希表总中有多少条数据,插入和查找的时间复杂度都是为O(1),由于哈希表的查找速度很是快,因此在不少程序中都有使用哈希表,例如拼音检查器。java

哈希表也有本身的缺点,哈希表是基于数组的,咱们知道数组建立后扩容成本比较高,因此当哈希表被填满时,性能降低的比较严重。算法

哈希表采用的是一种转换思想,其中一个中要的概念是如何将或者关键字转换成数组下标?在哈希表中,这个过程有哈希函数来完成,可是并非每一个或者关键字都须要经过哈希函数来将其转换成数组下标,有些或者关键字能够直接做为数组的下标。咱们先来经过一个例子来理解这句话。数组

咱们上学的时候,你们都会有一个学号1-n号中的一个号码,若是咱们用哈希表来存放班级里面学生信息的话,咱们利用学号做为或者关键字,这个或者关键字就能够直接做为数据的下标,不须要经过哈希函数进行转化。若是咱们须要安装学生姓名做为或者关键字,这时候咱们就须要哈希函数来帮咱们转换成数组的下标。微信

哈希函数

哈希函数的做用是帮咱们把非int的或者关键字转化成int,能够用来作数组的下标。好比咱们上面说的将学生的姓名做为或者关键字,这是就须要哈希函数来完成,下图是哈希函数的转换示意图。数据结构


哈希函数的写法有不少中,咱们来看看HashMap中的哈希函数多线程

static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

HashMap中利用了hashCode来完成这个转换。哈希函数无论怎么实现,都应该知足下面三个基本条件:分布式

  • 散列函数计算获得的散列值是一个非负整数
  • 若是 key1 = key2,那 hash(key1) == hash(key2)
  • 若是 key1 ≠ key2,那 hash(key1) ≠ hash(key2)

第一点:由于数组的下标是从0开始,因此哈希函数生成的哈希值也应该是非负数函数

第二点:同一个key生成的哈希值应该是同样的,由于咱们须要经过key查找哈希表中的数据微服务

第三点:看起来很是合理,可是两个不同的值经过哈希函数以后可能才生相同的值,由于咱们把巨大的空间转出成较小的数组空间时,不能保证每一个数字都映射到数组空白处。因此这里就会才生冲突,在哈希表中咱们称之为哈希冲突性能

哈希冲突

哈希冲突是不可避免的,咱们经常使用解决哈希冲突的方法有两种开放地址法链表法

开放地址法

在开放地址法中,若数据不能直接存放在哈希函数计算出来的数组下标时,就须要寻找其余位置来存放。在开放地址法中有三种方式来寻找其余的位置,分别是线性探测二次探测再哈希法

线性探测

线性探测的插入

在线性探测哈希表中,数据的插入是线性的查找空白单元,例如咱们将数88通过哈希函数后获得的数组下标是16,可是在数组下标为16的地方已经存在元素,那么就找17,17还存在元素就找18,一直往下找,直到找到空白地方存放元素。咱们来看下面这张图

线性探测
咱们向哈希表中添加一个元素钱多多钱多多通过哈希函数后获得的数组下标为0,可是在0的位置已经有张三了,因此下标往前移,直到下标4才为空,因此就将元素钱多多添加到数组下标为4的地方。

线性探测哈希表的插入实现起来也很是简单,咱们来看看哈希表的插入代码

/**
 * 哈希函数
 * @param key
 * @return
 */
private int hash(int key) {
    return (key % size);
}
/**
 * 插入
 * @param student
 */
public void insert(Student student){
    int key = student.getKey();
    int hashVal = hash(key);
    while (array[hashVal] !=null && array[hashVal].getKey() !=-1){
        ++hashVal;
        // 若是超过数组大小,则从第一个开始找
        hashVal %=size;
    }
    array[hashVal] = student;
}

测试插入

public static void main(String[] args) {
    LinearProbingHash hash = new LinearProbingHash(10);
    Student student = new Student(1,"张三");
    Student student1 = new Student(2,"王强");
    Student student2 = new Student(5,"张伟");
    Student student3 = new Student(11,"宝强");
    hash.insert(student);
    hash.insert(student1);
    hash.insert(student2);
    hash.insert(student3);
    hash.disPlayTable();
}

按照上面学习的线性探测知识,studentstudent2哈希函数获得的值应该都为1,因为1已经被student占据,下标为2的位置被student1占据,因此student2只能存放在下标为3的位置。下图为测试结果。

线性探测的查找

线性探测哈希表的查找过程有点儿相似插入过程。咱们经过散列函数求出要查找元素的键值对应的散列值,而后比较数组中下标为散列值的元素和要查找的元素。若是相等,则说明就是咱们要找的元素;不然就顺序日后依次查找。若是遍历到数组中的空闲位置,尚未找到,就说明要查找的元素并无在哈希表中。

线性探测哈希表的查找代码

/**
 * 查找
 * @param key
 * @return
 */
public Student find(int key){
    int hashVal = hash(key);
    while (array[hashVal] !=null){
        if (array[hashVal].getKey() == key){
            return array[hashVal];
        }
        ++hashVal;
        hashVal %=size;
    }

    return null;
}

线性探测的删除

线性探测哈希表的删除相对来讲比较复杂一点,咱们不能简单的把这一项数据删除,让它变成空,为何呢?

线性探测哈希表在查找的时候,一旦咱们经过线性探测方法,找到一个空闲位置,咱们就能够认定哈希表中不存在这个数据。可是,若是这个空闲位置是咱们后来删除的,就会致使原来的查找算法失效。原本存在的数据,会被认定为不存在。?

所以咱们须要一个特殊的数据来顶替这个被删除的数据,由于咱们的学生学号都是正数,因此咱们用学号等于-1来表明被删除的数据。

这样会带来一个问题,如何在线性探测哈希表中作了屡次操做,会致使哈希表中充满了学号为-1的数据项,使的哈希表的效率降低,因此不少哈希表中没有提供删除操做,即便提供了删除操做的,也尽可能少使用删除函数。

线性探测哈希表的删除代码实现

/**
 * 删除
 * @param key
 * @return
 */
public Student delete(int key){
    int hashVal = hash(key);
    while (array[hashVal] !=null){
        if (array[hashVal].getKey() == key){
            Student temp = array[hashVal];
            array[hashVal]= noStudent;
            return temp;
        }
        ++hashVal;
        hashVal %=size;
    }
    return null;
}

二次探测

在线性探测哈希表中,数据会发生汇集,一旦汇集造成,它就会变的愈来愈大,那些哈希函数后落在汇集范围内的数据项,都须要一步一步日后移动,而且插入到汇集的后面,所以汇集变的越大,汇集增加的越快。这个就像咱们在逛超市同样,当某个地方人不少时,人只会愈来愈多,你们都只是想知道这里在干什么。

二次探测是防止汇集产生的一种尝试,思想是探测相隔较远的单元,而不是和原始位置相邻的单元。在线性探测中,若是哈希函数获得的原始下标是x,线性探测就是x+1,x+2,x+3......,以此类推,而在二次探测中,探测过程是x+1,x+4,x+9,x+16,x+25......,以此类推,到原始距离的步数平方,为了方便理解,咱们来看下面这张图

二次探测
仍是使用线性探测中的例子,在线性探测中,咱们从原始探测位置每次日后推一位,最后找到空位置,在线性探测中咱们找到钱多多的存储位置须要通过4步。在二次探测中,每次是原始距离步数的平方,因此咱们只须要两次就找到钱多多的存储位置。

二次探测的问题

二次探测消除了线性探测的汇集问题,这种汇集问题叫作原始汇集,然而,二次探测也产生了新的汇集问题,之因此会产生新的汇集问题,是由于全部映射到同一位置的关键字在寻找空位时,探测的位置都是同样的。

好比讲一、十一、2一、3一、41依次插入到哈希表中,它们映射的位置都是1,那么11须要以一为步长探测,21须要以四为步长探测,31须要为九为步长探测,41须要以十六为步长探测,只要有一项映射到1的位置,就须要更长的步长来探测,这个现象叫作二次汇集。

二次汇集不是一个严重的问题,由于二次探测不怎么使用,这里我就不贴出二次探测的源码,由于双哈希是一种更加好的解决办法。

双哈希

双哈希是为了消除原始汇集和二次汇集问题,无论是线性探测仍是二次探测,每次的探测步长都是固定的。双哈希是除了第一个哈希函数外再增长一个哈希函数用来根据关键字生成探测步长,这样即便第一个哈希函数映射到了数组的同一下标,可是探测步长不同,这样就可以解决汇集的问题。

第二个哈希函数必须具有以下特色

  • 和第一个哈希函数不同
  • 不能输出为0,由于步长为0,每次探测都是指向同一个位置,将进入死循环,通过试验得出stepSize = constant-(key%constant);形式的哈希函数效果很是好,constant是一个质数而且小于数组容量

咱们将上面的添加改变成双哈希探测,示意图以下:

双哈希探测

双哈希的哈希表写起来来线性探测差很少,就是把探测步长经过关键字来生成

添加第二个哈希函数
/**
 * 根据关键字生成探测步长
 * @param key
 * @return
 */
private int stepHash(int key) {
    return 7 - (key % 7);
}
双哈希的插入
/**
 * 双哈希插入
 *
 * @param student
 */
public void insert(Student student) {
    int key = student.getKey();
    int hashVal = hash(key);
    // 获取步长
    int stepSize = stepHash(key);
    while (array[hashVal] != null && array[hashVal].getKey() != -1) {
        hashVal +=stepSize;
        // 若是超过数组大小,则从第一个开始找
        hashVal %= size;
    }
    array[hashVal] = student;
}
双哈希的查找
/**
 * 双哈希查找
 *
 * @param key
 * @return
 */
public Student find(int key) {
    int hashVal = hash(key);
    int stepSize = stepHash(key);
    while (array[hashVal] != null) {
        if (array[hashVal].getKey() == key) {
            return array[hashVal];
        }
        hashVal +=stepSize;
        hashVal %= size;
    }

    return null;
}
双哈希的删除
/**
 * 双哈希删除
 *
 * @param key
 * @return
 */
public Student delete(int key) {
    int hashVal = hash(key);
    int stepSize = stepHash(key);
    while (array[hashVal] != null) {
        if (array[hashVal].getKey() == key) {
            Student temp = array[hashVal];
            array[hashVal] = noStudent;
            return temp;
        }
        hashVal +=stepSize;
        hashVal %= size;
    }
    return null;
}

双哈希的实现比较简单,可是双哈希有一个特别高的要求就是表的容量须要是一个质数,为何呢?

为何双哈希须要哈希表的容量是一个质数?

假设咱们哈希表的容量为15,某个关键字通过双哈希函数后获得的数组下标为0,步长为5。那么这个探测过程是0,5,10,0,5,10,一直只会尝试这三个位置,永远找不到空白位置来存放,最终会致使崩溃。

若是咱们哈希表的大小为13,某个关键字通过双哈希函数后获得的数组下标为0,步长为5。那么这个探测过程是0,5,10,2,7,12,4,9,1,6,11,3。会查找到哈希表中的每个位置。

使用开放地址法,无论使用那种策略都会有各类问题,开放地址法不怎么使用,在开放地址法中使用较多的是双哈希策略。

链表法

开放地址法中,经过在哈希表中再寻找一个空位解决冲突的问题,还有一种更加经常使用的办法是使用链表法来解决哈希冲突。链表法相对简单不少,链表法是每一个数组对应一条链表。当某项关键字经过哈希后落到哈希表中的某个位置,把该条数据添加到链表中,其余一样映射到这个位置的数据项也只须要添加到链表中,并不须要在原始数组中寻找空位来存储。下图是链表法的示意图。

链表法

链表法解决哈希冲突代码比较简单,可是代码比较多,由于须要维护一个链表的操做,咱们这里采用有序链表,有序链表不能加快成功的查找,可是能够减小不成功的查找时间,由于只要有一项比查找值大,就说明没有咱们须要查找的值,删除时间跟查找时间同样,有序链表可以缩短删除时间。可是有序链表增长了插入时间,咱们须要在有序链表中找到正确的插入位置。

有序链表操做类
public class SortedLinkList {
    private Link first;
    public SortedLinkList(){
        first = null;
    }
    /**
     *链表插入
     * @param link
     */
    public void insert(Link link){
        int key = link.getKey();
        Link previous = null;
        Link current = first;
        while (current!=null && key >current.getKey()){
            previous = current;
            current = current.next;
        }
        if (previous == null)
            first = link;
        else
            previous.next = link;
        link.next = current;
    }

    /**
     * 链表删除
     * @param key
     */
    public void delete(int key){
        Link previous = null;
        Link current = first;
        while (current !=null && key !=current.getKey()){
            previous = current;
            current = current.next;
        }
        if (previous == null)
            first = first.next;
        else
            previous.next = current.next;
    }

    /**
     * 链表查找
     * @param key
     * @return
     */
    public Link find(int key){
        Link current = first;
        while (current !=null && current.getKey() <=key){
            if (current.getKey() == key){
                return current;
            }
            current = current.next;
        }
        return null;
    }
    public void displayList(){
        System.out.print("List (first-->last): ");
        Link current = first;
        while (current !=null){
            current.displayLink();
            current = current.next;
        }
        System.out.println(" ");
    }
}
链表法哈希表插入

在链表法中因为产生哈希冲的元素都存放在链表中,因此链表法的插入很是简单,只须要在对应下标的链表中添加一个元素便可。

/**
 * 链表法插入
 *
 * @param data
 */
public void insert(int data) {
    Link link = new Link(data);
    int key = link.getKey();
    int hashVal = hash(key);
    array[hashVal].insert(link);
}
链表法哈希表查找
/**
 * 链表法-查找
 *
 * @param key
 * @return
 */
public Link find(int key) {
    int hashVal = hash(key);
    return array[hashVal].find(key);
}
链表法哈希表删除

链表法中的删除就不须要向开放地址法那样将元素置为某个特定值,链表法中只须要找到相应的链表将这一项直接移除。

/**
 * 链表法-删除
 *
 * @param key
 */
public void delete(int key) {
    int hashVal = hash(key);
    array[hashVal].delete(key);
}

哈希表的效率

在哈希表中执行插入和搜索操做均可以达到O(1)的时间复杂度,在没有哈希冲突的状况下,只须要使用一次哈希函数就能够插入一个新数据项或者查找到一个已经存在的数据项。

若是发生哈希冲突,插入和查找的时间跟探测长度成正比关系,探测长度取决于装载因子,装载因子是用来表示空位的多少

装载因子的计算公式:

装载因子 = 表中已存的元素 / 表的长度

装载因子越大,说明空闲位置越少,冲突越多,散列表的性能会降低。

开放地址法和链表法的比较

若是使用开放地址法,对于小型的哈希表,双哈希法要比二次探测的效果好,若是内存充足而且哈希表一经建立,就再也不修改其容量,在这种状况下,线性探测效果相对比较好,实现起来也比较简单,在装载因子低于0.5的状况下,基本没有什么性能降低。

若是在建立哈希表时,不知道将来存储的数据有多少,使用链表法要比开放地址法好,若是使用开放地址法,随着装载因子的变大,性能会直线降低。

当二者均可以选时,使用链表法,由于链表法对应不肯定性更强,当数据超过预期时,性能不会直线降低。

哈希表在JDK中有很多的实现,例如HahsMapHashTable等,对哈希表感兴趣的能够阅读本文后去查看JDK的相应实现,相信这能够加强你对哈希表的理解。

最后

打个小广告,平头哥给你们整理了一份较全面的 Java 学习资料,包含Java基础、Java进阶、多线程、虚拟机、微服务、Springboot、分布式组件等,欢迎扫码关注微信公众号:「平头哥的技术博文」领取,一块儿进步吧。
平头哥的技术博文公众号

相关文章
相关标签/搜索