散列表原理及其应用

给定若干非负整数,范围是 1~10000,编写程序使得查询一个数的时间复杂度为 O(1)。

int data[10001];

void insert(int key){
	data[key] = key;
}
int search(int key){
	return data[key];
}

上述代码的基本思想是将每个输入的key,存放在数组的key这个位置。

我们假设数组下标为a,对应位置的值为key,则上述代码的思想转换成计算式是:a == key

散列表

散列表设计初衷也是为了实现存取数据操作复杂度为 O(1),因此,很容易知道,散列表必定是基于数组的,因为只有数组具有随机访问的特性。

不同于开头代码的是,散列表并不单单面向非负整数,而是面向所有数据的存储,包括对象,文本,音频,图片,视频等以二进制形式存储于计算机中的数据。

因此,我们不能简单的像开头代码那样存储,因为散列表存储的 key 可能是任意数据,而数组的下标必定是非负整数。这个时候,需要找到一个方法,将任意数据都能转换成一个非负整数。

这个方法就是哈希算法(Hash算法),哈希算法有多种多样的实现方式,这里我们不深入探究,只要知道哈希算法的作用是将任意数据转换成一组固定长度的二进制串,例如,MD5算法可以将任意数据转换成一个128位的数据。

我们用Hash(key),来表示任意数据值key的散列值,现在我们得到了任意数据的一个特殊值,即散列值,如何将我们的散列表(底层就是一个数组)中的每个位置与散列值一一对应起来呢?最简单的做法是散列值对长度取余。即散列函数为Hash(key) % length

例如,现在我们设计了一个散列表,底层数组是data[length],散列函数是hash(key) % length,则数据x存储的位置是data[hash(x) % length]

散列冲突

前面我们讲到哈希算法可以计算出任意数据对应的一个散列值,由于散列值是固定长度的,而数据是无限多的,因此,肯定存在不同的数据具有相同的散列值。

鸽巢原理,有 10 只鸽子,9 个巢的情况下, 所有鸽子进入巢穴,则一定存在两只鸽子在同一个巢中

我们的散列表是具有一定长度的,当数据多到一定程度时,可能出现计算出散列值后,该值所对应的散列表中的位置已经有数据了,这个时候将如何应对呢?

开放寻址法

开放寻址法示意图
如图,当插入的数据计算得到的位置已经有数据时,即视为散列冲突,开放寻址法的基本思想是,当遇到散列冲突时,则向后位移(到末尾后从 0 位置开始继续寻找)直到找到空位置则存放数据。

当散列冲突很严重时,散列表的查询效率也会严重降低。
应对措施:

  1. 扩大散列表容量(扩容后已经在表中的数据的散列位置也相对改变)
  2. 改变散列策略,如二次探测法(往后寻找时每次移动两个位置),多重散列(第一个散列函数的值冲突,用第二个散列函数计算)
  3. 改变散列函数(简单的对长度取余容易引起散列冲突)
链表法

遇到冲突时,直接将数据放在该位置的链表中;
链表法解决散列冲突
链表法也存在散列冲突过多后,查询效率下降的问题。
解决措施:

  1. 每条链表超过一定数量后,转换成红黑树
  2. 扩容(同样会导致原来的数据散列位置改变的问题)
  3. 改变散列函数

散列表的扩容

前面我们说到降低散列冲突的解决方法之一是扩大散列表容量,这里引入一个概念:装载因子
装载因子 = 填入表中的元素个数 / 散列表的长度

装载因子越大,说明散列冲突越严重。
因此当装载因子超过一定程度后,我们需要对散列表进行扩容,以便减小散列冲突,提高查询效率。

问题:当散列表扩容后,原先的数据在扩容后的散列表中的散列位置已经改变,当数据量很大时如何妥善处理?
数据量不大的情况下,很容易就能想到的解决方法,类似数组扩容时,只需将原来的数据搬运到新数组中,这里,新的散列表产生后,将原来的数据重新计算散列位置搬运到新散列表中。

但是在数据量大的情况下,插入数据触发扩容时,该次插入操作响应时间会很长。

因此我们考虑扩容时同时存在新旧散列表,插入数据时存入新散列表,查询时,先查询旧散列表,再查询新散列表,每一次的操作都从旧散列表搬运一个数据到新散列表。像这样将搬运数据的操作分散在每一步操作中,对性能影响不大。

同样的,如果是对内存使用要求高的,在数据减少到一定程度时,进行缩容,具体操作类似扩容

一个通用的散列表:HashMap

HashMap 底层采用了散列表红黑树的结构。
散列冲突采用链表法解决。

散列冲突很严重时,每条链表以红黑树的形式存储,查询效率稳定在 O(log n) 以内。

HashMap 的散列函数见我的另一篇博客:https://blog.csdn.net/m0_37264516/article/details/83894038

HashMap基本结构

另一个散列表:LinkedHashMap

LinkedHashMap,从名字上看加上了 Linked 并不是指这个散列表是用链表法解决冲突的,因为 HashMap 本身就是采用链表法解决冲突,所以,这里的 Linked 是什么意思呢?

从散列表的存储方式看,散列表中的数据并不是按照插入顺序来的,而是杂乱无章的,而 LinkedHashMap 却能够按照数据的访问顺序来输出。思考一下,它是怎么做到的呢?

答案是,在散列表的基础上结合双向链表实现:
LinkedHashMap底层原理图
双向链表保留了数据的插入顺序

HashMap<Integer, Integer> m = new LinkedHashMap<>();
m.put(3, 11);
m.put(1, 12);
m.put(5, 23);
m.put(2, 22);

for (Map.Entry e : m.entrySet()) {
  System.out.println(e.getKey());
}

上述代码中,输出顺序是3152,由双向链表维护数据插入时的顺序。
LinkedHashMap 也支持按照访问顺序输出:1235

// 10 是初始大小,0.75 是装载因子,true 是表示按照访问时间排序
HashMap<Integer, Integer> m = new LinkedHashMap<>(10, 0.75f, true);
m.put(3, 11);
m.put(1, 12);
m.put(5, 23);
m.put(2, 22);

m.put(3, 26);
m.get(5);

for (Map.Entry e : m.entrySet()) {
  System.out.println(e.getKey());
}
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值