一、概念
理想搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素。 如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素。当向该结构中:
插入元素:根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放
搜索元素:对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较,若关键码相等,则搜索成功
该方式即为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(HashTable)(或者称散列表)
二、核心——冲突
1.概念:不同关键字通过相同哈希函数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。冲突越少越好,但在理论上不可能不存在冲突。
2.避免冲突:
(1)合理设计哈希函数
- 哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值域必须在0到m-1之间
- 哈希函数计算出来的地址能均匀分布在整个空间中
- 哈希函数应该比较简单
常见哈希函数:除留余数法:
设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数,按照哈希函数:Hash(key) = key% p(p<=m),将关键码转换成哈希地址。
(2)调节负载因子(越小冲突越少),一般阈值为 0.75,超过该值就要进行 resize()散列表。
负载因子 = 填入元素个数 / 数组长度
所以当冲突率达到一个无法忍受的程度时,我们需要通过降低负载因子来变相的降低冲突率。
已知哈希表中已有的关键字个数是不可变的,那我们能调整的就只有哈希表中的数组的大小。
3.解决冲突
(1)闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去。
线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。
插入:通过哈希函数获取待插入元素在哈希表中的位置,如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突,使用线性探测找到下一个空位置,插入新元素。
二次探测:线性探测的缺陷是产生冲突的数据堆积在一块,这与其找下一个空位置有关系,因为找空位置的方式就是挨着往后逐个去找,因此二次探测为了避免该问题。找下一个空位置的方法为:$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)开散列/哈希桶:开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。
开散列,可以认为是把一个在大集合中的搜索问题转化为在小集合中做搜索了。
import javax.swing.*;
import java.util.List;
public class HashBucket {
private static class Node{
private int key;
private int value;
Node next;
public Node(int key,int value){
this.key = key;
this.value = value;
}
}
private Node[] array;
private int size;// 当前数据个数
private static final double factor = 0.75;//阈值
//计算负载因子
private double loadFactor(){
return size * 1.0 / array.length;
}
public HashBucket(){
array = new Node[8];
size = 0;
}
// 查找,时间复杂度 O(1)
/*过程:1.关键字经过变换得到 int 类型的值,如果不是 int 型,需要使用 key.hashCode()来转化为int型值
2.将 int值转化为合法下标
3.把关键字放入该合法下标位置的链表中*/
public int get(int key){
int index = key % array.length;
Node head = array[index];
for(Node cur = head;cur != null;cur = cur.next){
if(key == cur.key){
return cur.value;
}
}
return -1;
}
// 在链表中查找 key 所在的节点,找到更新 value 并返回原来的 value ,没找到插入新节点,时间复杂度O(1)
public int put(int key,int value){
int index = key % array.length;
for(Node cur = array[index];cur != null;cur = cur.next){
if(key == cur.key){
int oldValue = cur.value;
cur.value = value;
return oldValue;
}
}
// 头插
Node node = new Node(key,value);
node.next = array[index];
array[index] = node;
size++;
if(loadFactor() >= factor ){
resize();//将数组大小扩大,重新哈希
}
return -1;
}
private void resize() {
// 双层循环搬数据,外层循环数组,内层循环链表
Node[] newArray = new Node[array.length * 2];
for(int i = 0;i < array.length;i++){
Node next;
for(Node cur = array[i];cur != null;cur = next){
next = cur.next;// 保存 cur.next
int index = cur.key % newArray.length;
cur.next = newArray[index];
newArray[index] = cur;
}
}
array = newArray;
}
/* 删除,如果找到删除并返回该 key 关联的 value,没找到返回 -1*/
public int remove(int key){
int index = key % array.length;
Node head = array[index];
if(head != null && head.key == key){// 头删
array[index] = array[index].next;
return head.value;
}
Node prev = null;
for(Node cur = head;cur != null;cur = cur.next){
if(key == cur.key){
prev.next = cur.next;
return cur.value;
}
prev = cur;
}
return -1;
}
}
4.冲突严重时的解决办法:
如果冲突严重,就意味着小集合的搜索性能其实也时不佳的,这个时候我们就可以将这个所谓的小集合搜索问题继续进行转化,例如:
1. 每个桶的背后是另一个哈希表
2. 每个桶的背后是一棵平衡二叉搜索树(红黑树),阈值一般设为 8
5.性能分析
虽然哈希表一直在和冲突做斗争,但在实际使用过程中,我们认为哈希表的冲突率是不高的,冲突个数是可控的,也就是每个桶中的链表的长度是一个常数,所以,通常意义下,我们认为哈希表的插入/删除/查找时间复杂度是O(1) 。
6.和 java 类集的关系
1. HashMap 和 HashSet 即 java 中利用哈希表实现的 Map 和 Set
2. java 中使用的是哈希桶方式解决冲突的
3. java 会在冲突链表长度大于一定阈值后,将链表转变为搜索树(红黑树)
4. java 中计算哈希值实际上是调用的类的 hashCode 方法,进行 key 的相等性比较是调用 key 的 equals 方法。所以如果要用自定义类作
为 HashMap 的 key 或者 HashSet 的值,必须覆写 hashCode 和 equals 方法,而且要做到 equals 相等的对象,hashCode 一定是一样
的。(自动生成:Generate ---> equals()and hashCode() )
习题练习:数组长度为 10,放入关键字:21,15,30,36,25,40,26,37,用线性探测法解决冲突
求:查找成功的平均查找长度 = 每个数据查找次数之和 / 数据个数。(1+1+3+1+1+3+3+3)/ 8 = 2
查找失败的平均查找次数 = 每个下标查找任意数据失败次数之和 / 数组长度:(4+3+2+1+1+9+8+7+6+5)/ 10 = 3.6
搜索树与哈希表的比较:搜索树有序,哈希表快