数据结构与算法之散列表

1 散列表概述

  • 散列表(hash table):是实现字典操作的一种有效数据结构。最坏查找时间为O(n),理论上可以达到的平均查询时间是O(1)。散列表是普通数组概念的推广,当实际存储的关键字数目比全部的可能关键字总数要小时,采用散列表就成为直接数组寻址的一种有效替代,因为散列表使用一个长度与实际存储的关键字数目成比例的数组来存储。
  • 在散列表中,不是直接把关键字作为数组的下标,而是根据关键字计算出相应的下标
  • 散列:是一种极其有效和实用的技术,基本的字典操作平均只需要O(1)的时间。
  • 散列冲突,就是指多个关键字映射到数组的同一个下标。

2 直接寻址表

如果需要存储的全域(全部可能的元素)比较小时,可以使用直接寻址的方式。

举个例子

假设有十把锁和十把对应的钥匙,现在给你一把锁,你要怎么从十把钥匙中找到开锁的钥匙呢?最容易直接想到的方法就是,遍历这十把钥匙每个都试一次,这样一定能找到对应的钥匙,但是结果就是时间复杂度为O(n)。为了能够用最快的方法找到对应的钥匙,我们可以为每把锁和钥匙都进行一下编号,这样找钥匙的流程就可以编程:看到一把锁 >> 找到它的编号 >> 找到对应的钥匙的编号 >> 找到钥匙。


钥匙串
抽象一下

假设有一个数据集合U={e1,e2,e3,...,en},该数据集合里面的每一个元素ei都有一个对应的键值keyi和数据datai。集合中的任意一个keyi都是在[0,m]之间的整数。这是我们可以新建一个数组A[0..m],然后遍历一遍集合U,将其中的数据ei放到A[keyi]中。

直接寻址

简单的代码实现

下面用简单的代码实现了一个映射表,下面代码只实现了添加元素和获取元素的方法,没有实现删除元素和数组扩容的方法。

public class DirectMapTest {
    public static void main(String[] args) {
        List> elements = Arrays.asList(
                new Element<>(1, "Lucas's Lock!"),
                new Element<>(3, "ZZX's Lock!"),
                new Element<>(4, "Cassie's Lock!"));
        int key = 1;
        
        // 遍历的方式
        elements.forEach(var -> {
            if (var.getKey() == key) {
                System.out.println(var.getValue());
            }
        });

        // 映射表的方式
        DirectMap directMap = new DirectMap();
        elements.forEach(directMap::addElement);
        System.out.println(directMap.getElement(key).getValue());
    }
}

class DirectMap {
    private Element[] arr = new Element[10];

    public Element getElement(int key) {
        return arr[key];
    }

    public void addElement(Element e) {
        if (arr[e.getKey()] == null) {
            arr[e.getKey()] = e;
        } else {
            throw new IllegalArgumentException("illegal element!");
        }
    }
}

class Element {
    private int key;
    private T value;
    // constructors、getters、setters
}
映射表的不足之处
  1. 当全集U很大时,在内存中实现一个巨大的数组是很不科学的。
  2. 如果实际存储的关键值集合K相对于全集U来说是很小的一个子集时,会导致A数组中的大多数槽位会被浪费。

3 散列表

由于直接寻址表有上述的缺点,我们需要找一个映射关系,使得能够把全集U映射到一个小的子集中。在直接寻址方式下,具有关键字k的元素被存放在槽k中;而在散列方式下该元素存放在槽h(k)中;即利用散列函数(hash function),由关键字k计算出槽的位置。这里散列表的大小一般要比全集U小得多。散列函数缩小了数组的大小。

散列的冲突

在散列的过程中可能存在两个关键字可能映射到同一个槽中的问题。一方面可以通过精心设计的散列函数来尽量减少冲突的次数,另一方面需要有解决可能出现冲突的办法。

冲突解决办法
  1. 再哈希法:这种方法是同时构造多个不同的哈希函数,当第一个哈希函数冲突时,可以用其他当哈希函数
  2. 链地址法:将所有哈希地址为i的元素构成一个单链表,并将单链表的头指针存在哈希表的第k个单元中,因而查找、插入和删除主要在链表中进行。链地址法适用于经常进行插入和删除的情况。
  3. 建立公共溢出区:将哈希表分为基本表和溢出表两部分,凡是和基本表发生冲突的元素,一律填入溢出表。
好的散列函数的特点

一个好的散列函数应(近似地)满足简单均匀散列假设:每个关键字都被等可能地散列到m个槽位中的任何一个,并与其他关键字已散列到哪个槽位无关。遗憾的是,一般无法检查这一条件是否成立,因为很少能知道关键字散列所满足的概率分布,而且各关键字可能并不是完全独立的。

关键字转换为自然数

多数散列函数都假定关键字的全域为自然数集N={0,1,2,…}。因此,如果所给关键字不是自然数,就需要找到一种方法来将它们转换为自然数。

除法散列法
  • 除法散列法是通过取k除以m的余数,将关键字k映射到m个槽中的某一个上,即h(k) = k mod m。例如,如果散列表的大小为m=12,所给关键字k=100,则h(k)=4。
  • 通常一个不太接近2的整数幂的素数,是m的一个较好的选择。
乘法散列法
  • 构造散列函数的乘法散列法包含两个步骤。第一步,用关键字k乘上常数A(0
  • 乘法散列法的一个优点是对m的选择不是特别关键,一般选择它为2的某个幂次。
全域散列法
  • 如果让一个恶意的对手来针对某个特定的散列函数选择要散列的关键字,那么他会将n个关键字全部散列到同一个槽中,使得平均的检索时间为O(n)。任何一个特定的散列函数都可能出现这种令人恐怖的最坏情况。唯一有效的改进方法是随机地选择散列函数,使之独立于要存储的关键字。这种方法称为全域散列,不管对手选择了怎么样的关键字,其平均性能都很好。
  • 全域散列法在执行开始时,就从一组精心设计的函数中,随机地选择一个作为散列函数。就像在快速排序中一样,随机化保证了没有哪一种输入会始终导致最坏情况性能。因为随机地选择散列函数,算法在每一次执行时都会有所不同,甚至对于相同的输入都会如此。这样就可以确保对于任何输入,算法都具有较好的平均情况性能。

4 开放寻址法

在开放寻址法中,所有的元素都存放在散列表里。也就是说,每个表项或包含动态集合的一个元素,或包含NULL。当查找某个元素时,要系统地检查所有的表项,直到找到所需的元素,或者最终查明该元素不在表中。不像链接法,这里既没有链表,也没有元素存放在散列表外。因此在开放寻址法中,散列表可能会被填满,以至于不能插入任何新的元素。

插入元素

为了使用开放寻址法插入一个元素,需要连续地检查散列表,或称为探查(probe),直到找到一个空槽来放置待插入的关键字为止。由于需要探查多次,所以哈希函数需要有两个参数:关键字和哈希次数。

寻找元素

查找关键字k的算法的探查序列与将k插入时的算法一样。因此,查找过程中碰到一个空槽时,査找算法就停止,因为如果k在表中,它就应该在此处,而不会在探查序列随后的位置上(假定了关键字不会从散列表中删除)。

删除元素

从开放寻址法的散列表中删除操作元素比较困难。当我们从槽i中删除关键字时,不能仅将NIL置于其中来标识它为空。如果这样做,就会有问题:在插入关键字k时,发现槽i被占用了,则k就被插入到后面的位置上;此时将槽i中的关键字删除后,就无法检索到关键字k了。
有一个解决办法,就是在槽i中置一个特定的值DELETED替代NIL来标记该槽。这样就要对插入元素做相应的修改,将标注了DELETED的一个槽当做空槽,使得在此仍然可以插入新的关键字。

线性探查
  • 给定一个普通的散列函数h':U={0,1,…,m-1},称之为辅助散列函数,线性探查方法采用的散列函数为:h(k,i)=(h'(k)+i) mod m, i=0,1,…,m-1。给定一个关键字k,首先探查槽T[h'(k)],即由辅助散列函数所给出的槽位,再探查槽T[h'(k)+1],依此类推。
  • 线性探查方法比较容易实现,但它存在着一个问题,称为一次群集。随着连续被占用的槽不断增加,平均查找时间也随之不断增加。
二次探查
  • 二次探查采用如下形式的散列函数:h(k, i)= (h'(k) + ci + c2i2) mod m。其中h'(k)是一个辅助散列函数,c1和c2为正的辅助常数。
  • 这种探查方法的效果要比线性探查好得多,但是,为了能够充分利用散列表,c1、c2和m的值要受到限制。
双重探查
  • 双重散列是用于开放寻址法的最好方法之一,因为它所产生的排列具有随机选择排列的许多特性。双重散列采用如下形式的散列函数:h(k, i)=(h1(k) + h2(k)) mod m。其中h1和h2均为辅助散列函数。

5 完全散列

使用散列技术通常是个好的选择,不仅是因为它有优异的平均情况性能,而且当关键字集合是静态时,散列技术也能提供出色的最坏情况性能。所谓静态,就是指一旦各关键字存入表中,关键字集合就不再变化了。一些应用存在着天然的静态关键字集合,如程序设计语言中的保留字集合,或者CD-ROM上的文件名集合。一种散列方法称为完全散列,如果该方法进行查找时,能在最坏情况下用O(1)次访存完成。


一年又一年,字节跳动 Lark(飞书) 研发团队又双叒叕开始招新生啦!
【内推码】:GTPUVBA
【内推链接】:https://job.toutiao.com/s/JRupWVj
【招生对象】:20年9月后~21年8月前 毕业的同学
【报名时间】:6.16-7.16(提前批简历投递只有一个月抓住机会哦!)
【画重点】:提前批和正式秋招不矛盾!面试成功,提前锁定Offer;若有失利,额外获得一次面试机会,正式秋招开启后还可再次投递。

你可能感兴趣的:(数据结构与算法之散列表)