> 文章列表 > 【数据结构】用Java实现哈希表

【数据结构】用Java实现哈希表

【数据结构】用Java实现哈希表

目录

1 概念

2 冲突-概念

3 冲突-避免

4 冲突-避免-哈希函数设计

(1)直接定制法--(常用)

(2)除留余数法--(常用)

(3)平方取中法--(了解)

(4)折叠法--(了解)

(5)随机数法--(了解)

(6)数学分析法--(了解)

5 冲突-避免-负载因子调节(重点掌握)

6 冲突-解决

(1)冲突-解决-闭散列

(2)冲突-解决-开散列/哈希桶(重点掌握)

(3)冲突严重时的解决办法

7 开散列/哈希桶实现

(1)MyHashMap 类

(2)新增 & 修改

(3)扩容

(4)查找是否包含key值

(5)删除指定key值


1 概念

顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较顺序查找时间复杂度为O(N),平衡树中为树的高度,即O( \\bg_white {\\color{Emerald} }\\log_{2}N),搜索的效率取决于搜索过程中元素的比较次数。
理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素 如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素
当向该结构中:
  • 插入元素:根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放
  • 搜索元素:对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较,若关键码相等,则搜索成功
该方式即为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(HashTable)(或者称散列表)
例如:数据集合{1,7,6,4,5,9};
哈希函数设置为:hash(key) = key % capacity; capacity为存储元素底层空间总的大小。
用该方法进行搜索不必进行多次关键码的比较,因此搜索的速度比较快。问题:按照上述哈希方式,向集合中插入元素44,会出现什么问题?

2 冲突-概念

对于两个数据元素的关键字K_{i}K_{j}(i != j),有K_{i} != K_{j},但有:Hash(K_{i} ) == Hash(K_{j} ),即:不同关键字通过相同哈希哈数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞

 把具有不同关键码而具有相同哈希地址的数据元素称为“同义词”

3 冲突-避免

首先,我们需要明确一点,由于我们哈希表底层数组的容量往往是小于实际要存储的关键字的数量的,这就导致一个问题,冲突的发生是必然的,但我们能做的应该是尽量的降低冲突率

4 冲突-避免-哈希函数设计

引起哈希冲突的一个原因可能是:哈希函数设计不够合理哈希函数设计原则
哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值域必须在0到m-1之间,哈希函数计算出来的地址能均匀分布在整个空间中哈希函数应该比较简单
常见哈希函数

(1)直接定制法--(常用)

取关键字的某个线性函数为散列地址:Hash(Key)= A*Key + B 优点:简单、均匀 缺点:需要事先知道关键字的分布情况 使用场景:适合查找比较小且连续的情况 

(2)除留余数法--(常用)

设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数,按照哈希函数:
Hash(key) = key% p(p<=m),将关键码转换成哈希地址。

(3)平方取中法--(了解)

假设关键字为1234,对它平方就是1522756,抽取中间的3位227作为哈希地址; 再比如关键字为4321,对它平方就是18671041,抽取中间的3位671(或710)作为哈希地址 平方取中法比较适合:不知道关键字的分布,而位数又不是很大的情况

(4)折叠法--(了解)

 折叠法是将关键字从左到右分割成位数相等的几部分(最后一部分位数可以短些),然后将这几部分叠加求和,并按散列表表长,取后几位作为散列地址。

折叠法适合事先不需要知道关键字的分布,适合关键字位数比较多的情况

(5)随机数法--(了解)

选择一个随机函数,取关键字的随机函数值为它的哈希地址,即H(key) = random(key),其中random为随机数函数。 通常应用于关键字长度不等时采用此法

(6)数学分析法--(了解)

设有n个d位数,每一位可能有r种不同的符号,这r种不同的符号在各位上出现的频率不一定相同,可能在某些位上分布比较均匀,每种符号出现的机会均等,在某些位上分布不均匀只有某几种符号经常出现。可根据散列表的大小,选择其中各种符号分布均匀的若干位作为散列地址。
数字分析法通常适合处理关键字位数比较大的情况,事先知道关键字的分布且关键字的若干位分布较均匀的情况

【注意】哈希函数设计的越精妙,产生哈希冲突的可能性就越低,但是无法避免哈希冲突

冲突-避免-负载因子调节(重点掌握)

所以当冲突率达到一个无法忍受的程度时,我们需要通过降低负载因子来变相的降低冲突率。
已知哈希表中已有的关键字个数是不可变的,那我们能调整的就只有哈希表中的数组的大小。

6 冲突-解决

解决哈希冲突两种常见的方法是:闭散列开散列

(1)冲突-解决-闭散列

闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以key存放到冲突位置中的“下一个” 空位置中去那如何寻找下一个空位置呢?
1. 线性探测
比如上面的场景,现在需要插入元素44,先通过哈希函数计算哈希地址,下标为4,因此44理论上应该插在该位置,但是该位置已经放了值为4的元素,即发生哈希冲突。
线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。
(1)插入通过哈希函数获取待插入元素在哈希表中的位置
(2)如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突,使用线性探测找到下一个空位置,插入新元素

采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素会影响其他元素的搜索。比如删除元素4,如果直接删除掉,44查找起来可能会受影响。因此线性探测采用标记的伪删除法来删除一个元素。
2. 二次探测
线性探测的缺陷是产生冲突的数据堆积在一块,这与其找下一个空位置有关系,因为找空位置的方式就是挨着往后逐个去找,因此二次探测为了避免该问题,找下一个空位置的方法为:H_{i} = (H_{0}
i^{2})% m, 或者:H_{i} = (H_{0} - i^{2})% m。其中:i = 1,2,3…,H_{0}是通过散列函数Hash(x)对元素的关键码 key 进行计算得到的位置,m是表的大小。 对于2.1中如果要插入44,产生冲突,使用解决后的情况为:

 研究表明:当表的长度为质数且表装载因子a不超过0.5时,新的表项一定能够插入,而且任何一个位置都不会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。在搜索时可以不考虑表装满的情况,但在插入时必须确保表的装载因子a不超过0.5,如果超出必须考虑增容。

因此:比散列最大的缺陷就是空间利用率比较低,这也是哈希的缺陷。

(2)冲突-解决-开散列/哈希桶(重点掌握)

开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。
从上图可以看出,开散列中每个桶中放的都是发生哈希冲突的元素。
开散列,可以认为是把一个在大集合中的搜索问题转化为在小集合中做搜索了。

(3)冲突严重时的解决办法

哈希桶其实可以看作将大集合的搜索问题转化为小集合的搜索问题了,那如果冲突严重,就意味着小集合的搜索性能其实也时不佳的,这个时候我们就可以将这个所谓的小集合搜索问题继续进行转化,例如:
  • 1. 每个桶的背后是另一个哈希表
  • 2. 每个桶的背后是一棵搜索树

7 开散列/哈希桶实现

(1)MyHashMap 类

public class MyHashMap {private static class Node {int key;int value;Node next;public Node(int key, int value) {this.key = key;this.value = value;}}// 当前哈希表中有效元素个数private int size;// 取模数,简单起见,和数组长度保持一致private int M;// 保存Node元素的数组private Node[] data;// 负载因子private static final double LOAD_FACTOR = 0.75;public MyHashMap(){this.data = new Node[16];}public MyHashMap(int c){this.data = new Node[c];this.M = c;}}

(2)新增 & 修改

//      在当前哈希表中添加一对新元素,返回添加前的值public int put(int key,int value){// 1.首先计算出当前新元素的下标int index = hash(key);// 2.在当前的子链表中判断key值是否存在,若存在,只需要更新value即可for (Node x = data[index];x != null;x = x.next) {if (x.key == key) {// 存在,只需要更新valueint oldValue = x.value;x.value = value;return oldValue;}}// 3.此时key第一次出现,头插到当前的子链表中Node node = new Node(key,value);node.next = data[index];data[index] = node;size ++;// 4.判断当前哈希表的冲突情况,是否需要扩容if (size >= this.data.length * LOAD_FACTOR) {resize();}return -1;}// 计算hash值private int hash(int key) {return key % M;}

(3)扩容

private void resize() {Node[] newData = new Node[data.length * 2];this.M = newData.length;// 搬移原数组的所有节点for (int i = 0; i < data.length; i++) {for (Node x = data[i]; x != null;) {Node next = x.next;// 当前x搬移到新数组的对应位置int index = hash(x.key);// 头插到新数组的对应位置x.next = newData[index];newData[index] = x;// 继续搬移原数组的下一个节点x = next;}}// 更新data的指向data = newData;}

(4)查找是否包含key值

//    查看是否包含keypublic boolean containsKey(int key){int index = hash(key);for (Node x = data[index]; x != null ; x = x.next) {if(x.key == key){return true;}}return false;}

(5)删除指定key值

// 在当前哈希表中删除指定的key值节点public boolean removeKey(int key){// 1.先求索引int index = hash(key);
//        判空if(data[index] == null){return false;}// 剩下就是链表的删除问题if (data[index].key == key) {data[index] = data[index].next;size --;return true;}Node prev = data[index];while (prev.next != null) {if (prev.next.key == key) {prev.next = prev.next.next;size --;return true;}}// 此时不存在指定的key值return false;}