数据结构基础07——线性数据结构之Hash表

散列表(Hash table,也叫哈希表),是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。
给定表M,存在函数f(key),对任意给定的关键字值key,代入函数后若能得到包含该关键字的记录在表中的地址,则称表M为哈希(Hash)表,函数f(key)为哈希(Hash) 函数。

若关键字为k,则其值存放在f(k)的存储位置上。由此,不需比较便可直接取得所查记录。称这个对应关系f为散列函数,按这个思想建立的表为散列表。
对不同的关键字可能得到同一散列地址,即k1≠k2,而f(k1)=f(k2),这种现象称为冲突(英语:Collision)。具有相同函数值的关键字对该散列函数来说称做同义词。综上所述,根据散列函数f(k)和处理冲突的方法将一组关键字映射到一个有限的连续的地址集(区间)上,并以关键字在地址集中的“像”作为记录在表中的存储位置,这种表便称为散列表,这一映射过程称为散列造表或散列,所得的存储位置称散列地址。
若对于关键字集合中的任一个关键字,经散列函数映象到地址集合中任何一个地址的概率是相等的,则称此类散列函数为均匀散列函数(Uniform Hash function),这就是使关键字经过散列函数得到一个“随机的地址”,从而减少冲突。

反正我看着上面的感觉有点晕@-@

哈希表

在我刚开始学习数组和链表时就在想,既然数组随机访问速度快,链表插入删除快那么为什么不可以将数组和链表相互结合彼此互补呢?后来学了散列表才知道这就是我之前设计的东西- -!

数组和链表相互结合,也就是数组访问,链表插入修改数据。比如在我刚学完链表时就设计出了这样一种结构基于数据对象id的byte值的个位数(0-9)创建了一个大小为10的数组,然后在这些数组后面放插入的数据,这个数据用链表进行保存(因为会有相同的)。这要每次查找的时候先找数组,再找到链表里面遍历id值相同的节点。(当然因为当时我刚学完链表,现在这个hash算法并不太合理...)。

这里我们将对象的id称之为key,取id的byte值的最后一位的个位数的操作称之为hash算法。

而这种通过对关键key(称之为“键”)和value(“值”)映射,利用数组的随机访问特性(更确切的说是利用桶的概念,将数据放在key对应的桶中),将key放到对应数组下标(基于hash算法),对数组内数据进行访问修改的线性数据结构就被称为hash映射表(所谓的映射:就是key和存储对象的关联,但二者不一定要真的有关系)。再简单点就是hash(key)等于数组下标,value为数组里面的值或值组成的链表(链表法)。
另外哈希表分为带key值的哈希映射表(如HashMap),和值不重复的哈希集合表(如HashSet)

image.png

其中最关键的几个地方在于:hash算法,散列冲突的处理。

Hash算法

Hash,一般翻译做散列、杂凑,或音译为哈希,是把任意长度的输入(又叫做预映射pre-image)通过散列算法变换成固定长度的输出,该输出就是散列值。这种转换是一种压缩映射,也就是,散列值的空间通常远小于输入的空间,不同的输入可能会散列成相同的输出,所以不可能从散列值来确定唯一的输入值。 简单的说就是一种将任意长度的消息压缩到某一固定长度的消息摘要的函数。

实际工作中需视不同的情况采用不同的哈希函数,通常考虑的因素有:

· 计算哈希函数所需时间
· 关键字的长度
· 哈希表的大小
· 关键字的分布情况(尽量少的hash冲突)
· 记录的查找频率

常用的hash算法设计方法:

1.直接寻址法:取关键字或关键字的某个线性函数值为散列地址。

2.数字分析法(找规律):分析一组数据,比如一组员工的出生年月日,这时我们发现出生年月日的前几位数字大体相同,这样的话,出现冲突的几率就会很大,但是我们发现年月日的后几位表示月份和具体日期的数字差别很大,如果用后面的数字来构成散列地址,则冲突的几率会明显降低。因此数字分析法就是找出数字的规律,尽可能利用这些数据来构造冲突几率较低的散列地址。

3.平方取中法:当无法确定关键字中哪几位分布较均匀时,可以先求出关键字的平方值,然后按需要取平方值的中间几位作为哈希地址。这是因为:平方后中间几位和关键字中每一位都相关,故不同关键字会以较高的概率产生不同的哈希地址。

4.折叠法:将关键字分割成位数相同的几部分,最后一部分位数可以不同,然后取这几部分的叠加和(去除进位)作为散列地址。数位叠加可以有移位叠加和间界叠加两种方法。移位叠加是将分割后的每一部分的最低位对齐,然后相加;间界叠加是从一端向另一端沿分割界来回折叠,然后对齐相加。

5.随机数法:选择一随机函数,取关键字的随机值作为散列地址,即H(key)=random(key)其中random为随机函数,通常用于关键字长度不等的场合。

6.除留余数法(取模运算):取关键字被某个不大于散列表表长m的数p除后所得的余数为散列地址。即 H(key) = key MOD p,p<=m。不仅可以对关键字直接取模,也可在折叠、平方取中等运算之后取模。对p的选择很重要,一般取素数或m,若p选的不好,容易产生同义词。

常见的hash算法:MD5,SHA-1

其中最简单的当然就是取模 - -!在日常生活中我们计算时分秒,年月日都是用的这个办法。(比如:我们日常会说今天是2020年1月27日,而不是说今天是公元1234567890天...事实上当面对需要将大规模数据映射或限制到指定范围内时,首先想到的就应该是取模运算。比如我们可以把公元第任意天换成今天星期几...)

hash冲突

从hash算法本身就可以看出,将大数据压缩到固定大小必然会有两个key的hash相同的情况(今天周一,七天后也是周一)产生冲突。那么此时两个hash值相同的数据该怎么处理呢?

常用hash冲突解决方法:

开放寻址法

1.线性探测

当我们往散列表中插入数据时,如果某个数据经过散列函数散列之后,存储位置已经被占用了,我们就从当前位置开始,依次往后查找,看是否有空闲位置,直到找到为止。

在散列表中查找元素的过程有点儿类似插入过程。我们通过散列函数求出要查找元素的键值对应的散列值,然后比较数组中下标为散列值的元素和要查找的元素。如果相等,则说明就是我们要找的元素;否则就顺序往后依次查找。如果遍历到数组中的空闲位置,还没有找到,就说明要查找的元素并没有在散列表中。
你可能已经发现了,线性探测法其实存在很大问题。当散列表中插入的数据越来越多时,散列冲突发生的可能性就会越来越大,空闲位置会越来越少,线性探测的时间就会越来越久。极端情况下,我们可能需要探测整个散列表,所以最坏情况下的时间复杂度为 O(n)。同理,在删除和查找时,也有可能会线性探测整张散列表,才能找到要查找或者删除的数据。

2.二次探测
所谓二次探测,跟线性探测很像,线性探测每次探测的步长是 1,那它探测的下标序列就是 hash(key)+0,hash(key)+1,hash(key)+2……而二次探测探测的步长就变成了原来的“二次方”,也就是说,它探测的下标序列就是 hash(key)+0,hash(key)+12,hash(key)+22……

3.双重散列
双重散列就是不仅要使用一个散列函数。我们使用一组散列函数 hash1(key),hash2(key),hash3(key)……我们先用第一个散列函数,如果计算得到的存储位置已经被占用,再用第二个散列函数,依次类推,直到找到空闲的存储位置。不管采用哪种探测方法,当散列表中空闲位置不多的时候,散列冲突的概率就会大大提高。为了尽可能保证散列表的操作效率,一般情况下,我们会尽可能保证散列表中有一定比例的空闲槽位。我们用装载因子(load factor)来表示空位的多少。

装载因子的计算公式是:散列表的装载因子=填入表中的元素个数/散列表的长度

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

链表法

简单粗暴的解决冲突,直接在相同的hash值的节点上保存值的链表,取的时候去遍历链表,即可。

但是当hash冲突较多时,链表会急剧扩大,导致最后下降成O(n)。所以在如:Java的HashMap中为了进行优化,链表长度大于8时,会将链表自动转换为红黑树(红黑色见后面平衡二叉树相关内容)。默认16的数组,当装载因子大于0.75时也