「哈希表」是什么?有哪些常用的解决冲突的方法?(通俗易懂)

「哈希表」是什么?有哪些常用的解决冲突的方法?

很多人谈起“哈希表”,就会直接聚焦到hash函数、“散列”/“杂凑”之类不明所以的大词上,搞的初学者一头雾水,完全不明白这东西是干嘛用的,更不知道什么时候该用它、怎么用它、出现问题如何解决。

那么,这里我就从问题开始,一步步把哈希表的来龙去脉剖析清楚。

想象一下:有一天,你开车到某商场去买东西。买完东西,你想不起自己的车在哪了。这家商场非常非常大,楼下停车场少说停了上万台车;而你的健忘症又比较厉害……

那么,问题来了:你怎样才能找到自己的车?


一个理想的情形是,你从头到尾一行一行一辆一辆按顺序走一遍就找到了;运气最差时,你搜遍楼下N辆车,发现你的车在末尾——拿术语说,这个复杂度是O(N)。

能不能更快一些呢?

很简单,假如所有车按车牌号顺序排列,你直接往停车场中间走就行了;如果你的车牌号大于中间那辆车,那么你就往停车场后半部分的中间走,否则就往前半场走……依此类推。

如此一来,你最多只需要走ln(N)次“中间”,就能找到你的车了。黑话叫复杂度O(ln N)。还能不能更给力一些?

可以。

假设这个停车场非常非常大,大到可以给每个车牌号分配一个固定的停车位;那么只要你把自己的车牌号报给看门老头,他拎着你的衣领子往后一丢,你就“吧唧”一下掉自己车顶上了——嗯,你看,一车一位,就是这么任性。

这就叫“查找复杂度O(1)”。

如果用程序实现的话,就是这么一个数组:

car park[MAX_CAR_NUM]

第一个场景,你要直接以一个循环遍历park中的每个元素。

第二个场景,你只需先访问MAX_CAR_NUM除以2的那个位置,再根据车牌号大小访问数组前半拉或者后半拉中间的元素即可。

第三个场景,你的车牌号就是数组下标,所以你只需直接访问park[CAR_NUMBER]即可。

那么,第三个设计是不是完全解决问题了呢?

并不是。

很容易看出,第三个方案需要一个超级大的存储空间。

这个空间得有多大呢?

它必须大到足以和过去未来的一切有效车牌号一一对应,你才可能做到“直接按号访问”。

假设车牌号共8位,每位可以使用26个英文字母或10个阿拉伯数字,那么不同的车牌号共有36^8=2821109907456种。

哪怕每辆车只需一个字节的存储空间,这也是接近3T的空间!

而事实上,哪怕最大的超市,修一个够停一万辆车的停车场也都太夸张了。

你看,这完全行不通啊。

那么,有没有办法在得到O(1)的查找效率的同时、又不付出太大的空间代价呢?

没错,的确是有的。这就是哈希表。

哈希表是怎么玩的呢?很简单,我们把你的车牌号看作一个8位36进制的数字;为了方便,我们可以把它转换成十进制。

那么,你的车牌号就是一个不大于2821109907456的数字。

现在,我们把你的车牌号除以一万,只取余数——你看,你的车牌号是不是就和0~10000之间的数字对应起来了?很好,你的车就停在这个数字对应的停车位上,过去开就是了——O(1)的查找效率!

这个“把你的车牌号映射进0~10000之间”的操作,就是所谓的“散列”“杂凑”“哈希”或者hash(当然,实践上,为了尽量减少冲突,哈希表的空间大小会尽量取质数)。

相对于“以key为下标直接访问的数组”,哈希表是“时间换空间”;相对于二分法查找,哈希表又是“以空间换时间”。

这种“中庸”的定位使得它在许多场合极为好用。

等等,你发觉不对:我的车尾号456,我朋友的车也是这个尾号。我们总不能停在同一个位置吧?

你这个方案有瑕疵啊!

没错,hash可能会把不同的数据映射到同一个点上,术语称其为“碰撞”。

由于hash自身的基本原理,碰撞是不可避免的。

怎么解决这个“碰撞”问题呢?

几种解决思路:

1、临时加个“立体车库”,哪里碰撞往哪放。于是车子就可以在同一位置“撂起来”存了。这叫“开链表法”。

2、车库面积肯定是够的。456号被人占了,你存457不就好了!

换句话说,过去的散列函数是 (车牌号 模除 10000),发现碰撞了就换散列函数 (车牌号加1 模除 10000)试一试——这叫“再散列法”。

3、再修个小车库,碰撞了的停小车库去(小车库可以随便停,也可以搞一套别的机制)

总之,如此一来,我们就同时得到了“O(1)的查找效率”和“可接受的空间消耗”。

任何时候,当你有“数量有限”但“不同索引数量极大”的一些数据,必需极高的访问效率同时又不想无端消耗太多的存储空间时,你就可以考虑使用哈希表了。

当然,请注意,因为冲突的存在,哈希表虽然有着优异的平均访问时间(常数访问效率!);但它的“最大访问时间”却是没有保证的——你可能一个微秒甚至几个纳秒就拿到了数据,也可能几十个毫秒了还在链表上狂奔。因此实时性要求严格的场合,用它前需要谨慎考虑。

知道了哈希表的设计思路,我们就可以进入稍微困难一些的部分了。

我们已经知道,所谓“哈希表”,实际上是我们把对象(value)的“键值(key)”转换成了“数组下标”;然后就可以借助这个下标一步到位的找到对应对象(value)了。

但这中间有“瑕疵”存在:和身份证号和公民一一对应不同,键值和下标并不是一对一的关系。就好像你的车牌尾号是3456而你朋友是5456,结果把你们安排到同一个停车位一样。很容易想到,很多数据的键值(key)分布存在一定的规律。

比如,男性身份证最后一位总是单数;你们班所有同学几乎都在97~99年之间出生,等等。

那么,如果我们的“键值转换数组下标的函数(也就是哈希函数)”选择不当(比如我前面提到的直接求余),就很容易使得“碰撞”频繁出现。这就对哈希函数的选择提出了要求。

但是哈希函数本身也不应该过于复杂,不然每次计算耗时太久——O(1)虽然是常数时间;但如果时间常数太长,它可能就不如O(lnN)查找算法快。

要知道,在一百万数据里面做二分法搜索,最差时也不过需要20次搜索而已;如果你的哈希函数本身需要的计算时间已经超过了这个限度,那么改用二分法显然是个更为理智的选择:不仅更快,还更省空间。

工程问题,向来是需要根据实际情况灵活选择、做出合理折衷的。

扯远了。继续说哈希函数。很显然,用于哈希表的哈希函数可不能是MD5或者sha1系列函数,太慢;但也不能直接模除一个整数,太容易出现冲突。

简单说就是:哈希表用到的哈希函数,一方面要能尽量把key均匀散布在表空间中(从而尽量减少冲突),另一方面又要有尽量快的计算速度。这类函数有很多种,稍微搜一搜就能找到很多。

不过,无论如何,哈希表中,碰撞无法绝对避免。当碰撞发生时,就不得不使用开链表法或再散列法存储冲突数据;而这必将影响哈希表的性能。

当哈希函数选择合适时,想要减少碰撞,就只能扩大哈希表占用的空间。

很容易想到,如果哈希表很大、里面却没存几条数据,那么它出现冲突(碰撞)的几率就会很小;反之,如果哈希表已经接近满了,那么每条新加入的数据都会产生碰撞。

哈希表实际所存数据量和哈希表最大容量之间的比值,叫做哈希表的“加载因子”。

加载因子越小,冲突的概率就越低,但浪费大量空间;加载因子越高,冲突概率越大,但空间浪费就越少。这是一个需要根据工程实践灵活选择的折衷值。很多语言的hash函数库允许你主动调节这个值。

一般来说,一个较为平衡的加载因子大约是0.7~0.8左右。

这样既不会浪费太多空间,也不至于出现太多冲突。

另一方面,因为哈希表使用的哈希函数较为简单,因此对恶意的攻击者来说,他可以精心构造一大堆数据提交给你——所有这些数据散列后全都存在一个格子里。

我们前面提到过,当遇到这种冲突/碰撞时,为了避免彼此覆盖,这些数据就要存在链表中(或者再散列后存在同一个哈希表中)。

当这些数据被存进链表时,对它们的访问效率将降到O(N)——因为链表搜索效率只有O(N)。之前就发生过这种攻击,包括Java在内的许多种语言全部落马。

解决方案也很简单:

1、提高哈希函数复杂度,想办法加入随机性(相当于每次使用一个不同的哈希函数),避免被人轻易捕捉到弱点

2、不要用开链表法存储冲突数据,采用“再散列法”,并且使用不同的哈希函数再散列、还可以把冲突数据存入另一个表——要构造同时让两个以上不同的哈希函数冲突的攻击数据,难度就大得多了。

总之,哈希表是用途广泛的一种数据结构,也是很多编程语言提供的基础服务之一(比如python的dict)。

你完全可以傻瓜式的使用它,无须搞懂它的一切;但想要把它用精、用好,你还是需要真正理解它的来龙去脉——千万不要 像某种最好的语言的作者那样,拿函数名长度当哈希值(哎呀一不小心又黑人了,顶锅逃……

作者:invalid s
链接:https://www.zhihu.com/question/330112288/answer/744362539
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值