前言
前面我们介绍线性表的时候,如果要查找某个元素,比如key,就需要从表头开始,逐个比较记录a[i]和key的值是相等还是不相等,知道有相等才算查找成功,返回i。那么我们有没有一种方法不用逐个比较就能查找到要查询的元素呢?答案是肯定的,我们可以通过关键字key得到要查找记录的内存位置,这就是我们这篇要介绍的哈希表,其实哈希表算是线性存储结构的范畴,因为哈希表用到数组作为存储结构。
1.什么是哈希表
哈希表(Hash Table),也叫散列表。哈希表是用数组作为存储结构,具有数组按下表进行随机访问的特性,所以哈希表是数组的一种扩展。哈希表通过哈希函数对我们要存储的元素计算哈希值,将这个哈希值作为数组的下标,然后将数据存储在数组中对应的下标位置。当我们按照键值查询元素时,我们用同样的哈希函数,将键值转换为数组下标,从对应的 数组下标位置取数据。那么哈希表到底有什么用途呢?
当然,哈希表也是用来存储数据的,比如,我们维护一个班级的所有学生的信息,为了方便维护数据,我们可以把一个班级的学生的信息放在一个数组里,假如这个班有40人,学号为1的学生放在数组下标为1的位置,学号为2的学生放在数组下标为2的位置,依次类推,学号为40的学生放在数组下标为40的位置。因为学生学号和下标一一对应,所以当我们需要查询学号为x的学生信息时,只需要将数组下标为x的数组元素取出来 即可,时间复杂度为O(1)。
2.哈希函数
说到哈希表,我们自然绕不开哈希函数,哈希函数是哈希表中非常重要的一个函数,哈希表用这个哈希函数对键值计算哈希值,每次存取都通过计算哈希值找到元素在数组中对应的下标位置。哈希函数设计的好坏直接决定了哈希表冲突的概率大小,也直接决定了哈希表的性能的好坏。那么什么样的哈希函数才算是好的哈希函数呢?首先哈希函数的设计不能太复杂,过于复杂的哈希函数本身就增加了计算时间,影响哈希表的性能。其次,哈希函数生成的值要尽可能随机并且均匀分布,这样才能避免或者最小化哈希冲突,散列到每个槽或桶里的数据才会比较平均,不至于出现某个槽内的数据特别多。
3.哈希冲突
再好的哈希函数也无法避免哈希冲突的,随着数据量的增大,哈希冲突就有可能发生,那么发生了哈希冲突,我们该如何解决呢?
其实,关于哈希冲突的方法早就有人研究好了,解决的方法也有很多种,不过我们常用的有链地址法和开放地址法。
- 链地址法:顾名思义,链地址法就是利用链表来作为冲突后的存储结构,相比于开放地址法,它更加简单,接下来,我们通过图示方法,介绍下链地址法。
如图,在哈希表中,每个槽或桶会对应一条链表,所有哈希值相同的元素我们都放在相同的槽位对应的链表中。

当插入的时候,我们只需要通过哈希函数计算出对应的哈希槽位,将其插入对应的链表中即可,所以插入的时间复杂度为O(1)。当查找或删除一个元素时,我们同样通过哈希函数计算出对应的槽,然后遍历链表 查找或删除,这连个操作的时间复杂度和链表的长度成k正比,也就是O(k),当链表的长度越长时,性能随之越差,所以,java中的HashMap底层的哈希表解决哈希冲突的方法是链表加红黑树。当链表的长度大于8时,就转化成红黑树,当小于8时又转化为链表。
- 开放地址法:开放地址法的核心思想是,如果出现哈希冲突,我们就重新探测一个空闲位置,将其插入,探测新位置的常用方法有线性探测,二次探测和多重哈希法。
所谓的线性探测就是当我们往哈希表中插入元素时,如果某个元素经过哈希函数计算哈希值后,存储位置已经被占用了,我们就从当前位置开始,依次往后查找,看是否有空闲位置,知道找到空闲位置为止。
二次探测和线性探测很像,只是线性探测每次增长的步长为1,二次探测增长的步长为原来的平方,比如下标为1的位置占用了,下一个探测的位置就是2,下标为2的位子被占用了,下次探测的位置就是4,依次类推,直到找到空闲位置为止。
所谓的多重哈希就是哈希函数不止一个,而是有多个哈希函数可以计算哈希值,当然我们先用第一个哈希函数计算哈希值,如果计算得到的存储位置已经被占用,就用第二个哈希函数重新计算哈希值,如果得到的存储位置仍然被占用就用第三个,依次类推,直到找到空闲位置为止。
上面介绍了链地址法和开放地址法两种解决哈希冲突的方法,两种方法各有其优缺点,那么我们在实际开发中到底应该选择哪种冲突解决方法呢?下面对两种方法的优缺点和使用场景做一个介绍。
1.开放地址法
首先我们先看一下开放地址法的优点。
开放地址法不像链地址法需要很多的链表,哈希表中的数据都存储在数组中,在内存中是连续的存储空间,可以有效的利用CPU的缓存加快查询速度。
接下来我们看一下开放地址法的缺点。
在开放地址法中,所有的数据都存储在一个数组中,比起链表来,冲突代价更高,更加浪费存储空间,装载因子(元素个数/存储位置的个数)的上限不能太高。
所以当数据量比较小,装载因子比较小的时候,适合采用开放地址法,Java中的ThreadLocalMap就是采用开放地址法来解决哈希冲突的。
2.链地址法
首先,链表法对内存的利用率比开放寻址法要高。因为链表结点可以在需要的时候再创建,并不需要像开放寻址法那样事先申请好。其次, 链表法比起开放寻址法,对大装载因子的容忍度更高。
但是链表要存储指针,所以对于比较小的对象的存储,是比较消耗内存的,还有可能会让内存的消耗翻倍。而且,因为链表中的结点是零散分布在内存中的,不是连续的,所以对CPU缓存是不友好的,这方面对于执行效率也有一定的影响。
基于链表的散列冲突处理方法比较适合存储大对象、大数据量的散列表,而且,比起开放寻址法,它更加灵活,支持更多的优化策略,比如用红黑树代替链表。
4.代码实现
public class HashTable<K,V> {
private final int[] capacity=
{53,97,193,389,769,1543,3079,6151,12289,49157,98317,196613,393241,786433,1572869,3145739,6291469,
12582917,25165843,50331653,100663319,201326611,402653189,805306457,1610612741};//初始化容量数组
private static final int upperTol = 10; //最大上界,超过最大上界数组扩容
private static final int lowerTol = 2; //最小下界,低于最小下界数组缩容
private int capacityIndex = capacity[0]; //初始化容量数组下标
private TreeMap<K,V>[] hashtable;
private int M; //hash表中有M个位置
private int size; //hash表中元素的个数
public HashTable(){
this.M = capacity[capacityIndex];
size = 0;
hashtable = new TreeMap[M];
for (int i = 0; i < M; i++) {
hashtable[i] = new TreeMap<>();
}
}
//哈希函数
private int hash(K key){
return (key.hashCode() & 0x7fffffff) % M; //key.hashCode() & 0x7fffffff将key的hashcode值的符号去掉
}
//获取哈希表的元素个数
public int getSize(){
return size;
}
//向哈希表中添加元素
public void add(K key,V value){
TreeMap<K,V> map = hashtable[hash(key)];
if(map.containsKey(key))
map.put(key,value);
else{
map.put(key,value);
size++;
//判断是否需要扩容
if(size >= upperTol*M && capacityIndex+1 < capacity.length){
capacityIndex++;
resize(capacity[capacityIndex]);
}
}
}
//从哈希表中删除元素
public V remove(K key){
TreeMap<K,V> map = hashtable[hash(key)];
V ret = null;
if(map.containsKey(key)){
ret = map.remove(key);
size--;
//判断是否需要缩容
if(size < lowerTol * M && capacityIndex-1 >= 0){
capacityIndex--;
resize(capacity[capacityIndex]);
}
}
return ret;
}
//扩容
private void resize(int newM) {
TreeMap<K,V>[] newHashTable = new TreeMap[newM];
for (int i = 0; i < newM; i++) {
newHashTable[i] = new TreeMap<>();
}
int oldM = M;
this.M = newM;
for (int i = 0; i < oldM; i++) {
for (K key : hashtable[i].keySet()) {
newHashTable[hash(key)].put(key,hashtable[i].get(key));
}
}
this.hashtable = newHashTable;
}
//将哈希表中键为key的元素值修改为value
public void set(K key,V value){
TreeMap<K,V> map = hashtable[hash(key)];
if(!map.containsKey(key))
throw new IllegalArgumentException("要修改的键不存在");
map.put(key,value);
}
//判断哈希表中是否存在key
public boolean contains(K key){
return hashtable[hash(key)].containsKey(key);
}
//获取哈希表中键为key的value值
public V get(K key){
return hashtable[hash(key)].get(key);
}
public static void main(String[] args) {
HashTable<String,Integer> hashTable = new HashTable();
hashTable.add("黎明",100);
hashTable.add("赵薇",98);
hashTable.add("周凯",79);
System.out.println(hashTable.get("周凯"));
}
}
5.总结
这篇我们介绍了哈希表的相关概念,哈希函数,哈希冲突以及解决哈希冲突的方法,最后还贴出了哈希表的代码实现以供参考,关于哈希表的实现,可以参看Java中的HashMap的底层实现。

网友评论