第四讲. 经典算法之哈希映射

1. 简介

哈希即Hash,一般翻译为散列的意思,简单而言其实就是通过一个函数,将一个值映射成另一个更好的值。
这个更好,是一个比较抽象的概念。可以是把一个很大的数,映射到一个小的区间内,以作为数组索引;也可以是将一个非数值型数据,如字符串等映射成一个数值型数据,以实现更好的索引或者判重;又或者将一段很长的信息加密成一段短小的密文,以实现信息的保密传输等等。
x = H ( k ) x=H(k) x=H(k)这里的 k k k 表示原始值或者哈希键值, H H H 表示映射函数, x x x 表示将哈希键映射后的结果值。
哈希映射图解

2. 从一个简单例题开始

现在给出 n 个数(a[0],a[1],…,a[n-1]),且每个数的大小都是小于等于 1010 的非负数(0<=a[i]<=1010),现需要统计每个数的出现次数,并从小到大依次输出,且 n 是小于等于 100 的正整数(0<n<=100)。
输入样例:

10
12 116 87 105 12 605 8998 87 12 105

输出样例:

12 3
87 2
105 2
116 1
605 1
8998 1

我想许多人拿到这类题的第一感觉就是,直接利用数组的下标索引标记每个数,然后每次出现 i ,便令 x[i]++ 即可。确实,这样的算法非常灵活地运动了下标,在统计计数地过程中也达到了线性时间复杂度,是最优的算法了,代码如下:

#include <stdio.h>
#define MAX 10000000000
int x[MAX+5] = {0};
int main()
{
	int n,a;
	scanf("%d",&n);
	for(int i=0;i<n;i++)
	{
		scanf("%d",&a);
		x[a]++;
	}
	for(int i=0;i<=MAX;i++)
		x[i]>0?printf("%d %d\n",i,x[i]):1;
}

然而事实是编译的时候出现了错误:
在这里插入图片描述
显然,由于题目中的 a[i] 最大可以到 1010 ,因此这里的计数数组也需要开到这么大,但显然,这个大小早已超出了一般情况下程序所能开出数组的最大大小。
同时,注意到一件事情,虽然 a[i] 的大小很大很大,但是 n 很小很小最大仅到100。这也将说明得到的计数数组 x[] 将特别特别稀疏,毕竟现在是将 100 个数放到 1010 个位子上,从概率上来说平均每个位子仅有 1/108 个数,而最后输出我们只需要将 x[i]>0 的部分进行输出,换句话说,数组 x[] 存在大量的空间冗余。
这时候我们便能考虑到,如果能通过一个映射函数,将这 1000~1010 的数映射到一个更小的区间上,例如 0~200 或者 0~1000 等,这样便能实现空间的压缩,使得计数数组 x[] 所需的空间大小小上几个数量级,同时也让其变得更加密集,以提高空间利用率,另一方面程序也不会因数组过大而报error,这即是哈希的思想。考虑如下一个最简单的哈希映射函数:
H ( k ) = k   m o d   m H(k)=k\ mod\ m H(k)=k mod m这里 m 相当于要映射到的区间的大小,相当于就是将每一个 x[i]m 进行求模取余,显然这个余数的最大值不会超过 m
在这个题目中,只需要保证 m>=100 即可,换句话说,我们需要保证这 100 个数每个数都必须有位子记录。假设令 m=101,那么对于题目的样例而言,便会有如下映射关系:

12 ——> 12%101 = 12
87——> 87%101 = 87
105 ——> 105%101 = 4
116 ——> 116%101 = 15
605 ——> 605%101 = 100
8998 ——> 8998%101 = 9

这样一来每个 a[i] 确实都映射到了一个更小的空间,但我们思考一个问题,如果在上面的基础上,再加入一个数 a[i]=113 ,计算哈希值发现 113%101 = 12,与数 12 的哈希值一样,出现了冲突,换句话说,每一个 a[i] 可以被唯一的映射成一个 0~m 之间的数,但并不能保证每一个 0~m 之间的数,都唯一的对应一个 a[i] ,任何一个满足 i + m × k ( k ∈ N ) i+m×k (k \in N) i+m×k(kN) 的数得到的哈希值都将相同,等于 i ,这便出现了所谓的碰撞冲突,既然出现了问题,肯定也会有相应的解决方法,请接着往下看。

3. 哈希中的碰撞冲突

哈希冲突是由于不同键值经过哈希函数后产生相同的哈希值而产生的情况,可以说这种情况的发生是必然的。
解决碰撞冲突的思路有两个,其一是为冲突发生的情况下,设置新的规则来应对,比方说,如果出现冲突,那么就从冲突出现位置顺序往后找,直到找到一个空的位子,填上去;或者是隔几个位子跳着跳着找;又或者是在冲突出现的位置链接上一条链表,依次记录相同哈希值元素的出现次数。我们着重介绍下这几种常用的方法。

3.1 线性探测法

顾名思义,若出现冲突,则逐个往后或往前搜索,直到找到空的位子。

Hash(k)0123456789
k109844319

假设现在又出现一个元素 20 经哈希函数后得到的映射结果也是位置 0 ,而发现该位置已被元素 10 所占据,那么就可以线性向后查找,直到找到第一个空的位置 2 ,然后放上去。(若找到了哈希表结尾,则回到开头继续向后查找,由于保证了哈希表的大小一定比元素总个数多,所以能保证每个元素都能找到自己的位置)

Hash(k)0123456789
k10982044319

但这样有一个弊端就是,此时 20 占据了一个本不属于它的位置 2 ,如若下次来了一个本就映射到位置 2 的元素,那么它也只好继续向后探测,换句话说,虽然你解决了一个冲突,但同时又产生了另一个产生冲突的隐患,而且甚至还有可能出现无限冲突的情况。另一方面对于要在表中删除元素的情况,处理起来也不太方便。

3.2 链地址法

这种思想是将所有映射到位置 i 的元素构建一条链表,并将单链表的头指针存在哈希表的第 i 个单元中,这样做的好处是一方面不会出现前面方法的那种解决一个哈希冲突,又带了新冲突隐患的问题,另一方面是在元素的删除上也能很好的处理,只需删除对应节点即可。但缺点也有,就是可能会在某个位置出现一条很长很长的链,增加了时间的开销。
链地址法

3.3 再哈希法

这种方式是选取多个哈希函数,若第 j 个哈希函数出现了冲突,那么选用第 j+1 个哈希函数计算,直至找到不发生冲突的哈希函数。又或者使用同一个哈希函数,以上一轮产生的哈希值作为键值进行再次映射,直至找到可用的位置。

3.4 …

除了以上这些方法外,还有很多类似的方法,如平方探测法等等,这类处理思路都是着重于当冲突发生的时候如何处理,此外,另一种解决冲突的思想便是在冲突发生之前尽可能的减少冲突的发生概率,即设计更好的哈希函数。

4. 哈希函数的设计

显然,冲突的产生其实很大的一部分原因是由于哈希函数设计得不够好,一个更好的哈希函数应该是让不同键值尽量映射到不同的位置,或者说尽可能地做到随机映射。

4.1 更大的哈希表

不难想到,一张更大的哈希表能降低冲突发生的概率,以之前的简单哈希函数为例,极端情况是如果把 m 取得很大到 1010 时,那么显然就不会发生冲突了。一般而言,在经验和习惯上,会将哈希表的大小设置在元素个数的 1~10 倍之间,以实现在较小空间冗余的代价上,减小冲突的发生,提高时间效率。

4.2 更好的哈希运算

之前提到的最简单的哈希函数,被称为除留余数法,可以说没有对键值 k 作任何的处理,直接进行了求余数,而如果我们加上些其它的运算,便能够使得映射更加复杂,以达到更随机的映射效果。例如下面的一阶线性映射:
H ( k ) = ( a × k + b )   m o d   m ( a , m ∈ Z ) H(k) = (a×k + b)\ mod\ m (a,m \in Z) H(k)=(a×k+b) mod m(a,mZ)从理论上来说, abm取不要太接近2的幂或10的幂的数会更好(同理前面简单取余的哈希函数里的 m 也一样),冲突率更小,有兴趣的可以上网找找相关证明,这里之间简单说一下如果 m 取偶数,由于一个偶数对偶数取余结果仍然是偶数,这将导致所有的偶数元素只能映射到偶数位置,显然将会导致分布不均匀,容易出现冲突。
再来看看下面的乘法映射。
H ( k ) = ( A ⋅ k   m o d   2 w )   r s h   ( w − r ) H(k) = (A·k\ mod\ 2^w)\ rsh\ (w-r) H(k)=(Ak mod 2w) rsh (wr)其中 m=2rw 表示计算机一个字的长度的bit位数,A 为奇数,且 2(w-r) < A < 2w(A不能太接近2w-r和2w), rsh 指右移运算。
这是一个快速的哈希算法,介于编译器对这些二进制运算的优化,有兴趣的查阅相关资料,这里不做过多说明。

5. 最后说几句

其实在我看来,哈希函数就是乱搞,搞得越乱越随机越好,有兴趣的可以了解一下全域哈希与完全哈希,理论上可以无限接近随机映射,使得在绝大多数情况下不会出现冲突。

  • 31
    点赞
  • 95
    收藏
    觉得还不错? 一键收藏
  • 6
    评论
数据结构:⼋⼤数据结构分析 数据结构分类 数据结构是指相互之间存在着⼀种或多种关系的数据元素的集合和该集合中数据元素之间的关系组成 。 常⽤的数据结构有:数组,栈,链表,队列,树,图,堆,散列表等,如图所⽰: 线性表和⾮线性表 ⼀、线性表 常见的线性表有:数组、队列、栈、链表 线性表是最基本、最简单、也是最常⽤的⼀种数据结构。线性表(linear list)是数据结构的⼀种,线性表就是数据排列成⼀条先⼀样的结 构,每个线性表上的数据最多只有前和后两个⽅向。⼀个线性表是n个具有相同特性的数据元素的有限序列。 特点: 1. 集合中必存在唯⼀的⼀个"第⼀元素"。 2. 集合中必存在唯⼀的⼀个 "最后元素" 。 3. 除最后⼀个元素之外,均有唯⼀的后继(后件)。 4. 除第⼀个元素之外,均有唯⼀的前驱(前件)。 顺序表⾥⾯元素的地址是连续的;链表⾥⾯节点的地址不是连续的,是通过指针连起来的。 1.数组 数组是可以再内存中连续存储多个元素的结构,在内存中的分配也是连续的,数组中的元素通过数组下标进⾏访问,数组下标从0开始。例 如下⾯这段代码就是将数组的第⼀个元素赋值为 1。 int[] data = new int[100];data[0] = 1; 优点: 1、按照索引查询元素速度快 2、按照索引遍历数组⽅便 缺点: 1、数组的⼤⼩固定后就⽆法扩容了 2、数组只能存储⼀种类型的数据 3、添加,删除的操作慢,因为要移动其他的元素。 适⽤场景: 频繁查询,对存储空间要求不⼤,很少增加和删除的情况。 2.栈 栈是⼀种特殊的线性表,仅能在线性表的⼀端操作,栈顶允许操作,栈底不允许操作。 栈的特点是:先进后出,或者说是后进先出,从栈 顶放⼊元素的操作叫⼊栈,取出元素叫出栈。 栈的结构就像⼀个集装箱,越先放进去的东西越晚才能拿出来,所以,栈常应⽤于实现递归功能⽅⾯的场景,例如斐波那契数列。 3.队列 队列与栈⼀样,也是⼀种线性表,不同的是,队列可以在⼀端添加元素,在另⼀端取出元素,也就是:先进先出。从⼀端放⼊元素的操作称 为⼊队,取出元素为出队,⽰例图如下: 使⽤场景:因为队列先进先出的特点,在多线程阻塞队列管理中⾮常适 ⽤。 4.链表 链表是物理存储单元上⾮连续的、⾮顺序的存储结构,数据元素的逻辑顺序是通过链表的指针地址实现,每个元素包含两个结点,⼀个是存 储元素的数据域 (内存空间),另⼀个是指向下⼀个结点地址的指针域。根据指针的指向,链表能形成不同的结构,例如单链表,双向链表, 循环链表等。 链表的优点: 1. 链表是很常⽤的⼀种数据结构,不需要初始化容量,可以任意加减元素; 2. 添加或者删除元素时只需要改变前后两个元素结点的指针域指向地址即可,所以添加,删除很快; 缺点: 1. 因为含有⼤量的指针域,占⽤空间较⼤; 2. 查找元素需要遍历链表来查找,⾮常耗时。 适⽤场景: 数据量较⼩,需要频繁增加,删除操作的场景 ⼆、⾮线性表 常见的⾮线性表有:树、图、堆 ⾮线性表中数据并不是简单的前后关系。 1.树 树是⼀种数据结构,它是由n(n>=1)个有限节点组成⼀个具有层次关系的集合。把它叫做 "树" 是因为它看起来像⼀棵倒挂的树,也就 是说它是根朝上,⽽叶朝下的。它具有以下的特点: 每个节点有零个或多个⼦节点; 没有⽗节点的节点称为根节点; 每⼀个⾮根节点有且只有⼀个⽗节点; 除了根节点外,每个⼦节点可以分为多个不相交的⼦树; 在⽇常的应⽤中,我们讨论和⽤的更多的是树的其中⼀种结构,就是⼆叉树。 ⼆叉树是树的特殊⼀种,具有如下特点: 每个结点最多有两颗⼦树,结点的度最⼤为2。 左⼦树和右⼦树是有顺序的,次序不能颠倒。 即使某结点只有⼀个⼦树,也要区分左右⼦树。 ⼆叉树是⼀种⽐较有⽤的折中⽅案,它添加,删除元素都很快,并且在查找⽅⾯也有很多的算法优化,所以,⼆叉树既有链表的好处,也有 数组的好处,是两者的优化⽅案,在处理⼤批量的动态数据⽅⾯⾮常有⽤。 2.散列散列表,也叫哈希表,是根据关键码和值 (key和value) 直接进⾏访问的数据结构,通过key和value来映射到集合中的⼀个位置,这样就可 以很快找到集合中的对应元素。 记录的存储位置=f(key) 这⾥的对应关系 f 成为散列函数,⼜称为哈希 (hash函数),⽽散列表就是把Key通过哈希函数转换成⼀个整型数字,然后就将该数字对数 组长度进⾏取余,取余结果就当作数组的下标,将value存储在以该数字为下标的数组空间⾥,这块连续存储空间称为散列表或哈希表 (Hash table),这种存储空间可以充分利⽤数组的查找优势来查找元素,所以查找的速度很快。 散列数据结构的性能取决于以下三个因素: 哈希函数 哈希表的⼤⼩ 碰撞处理⽅法 哈希表在应⽤中也是⽐较常见的,就如Java中有些集合类
好的,让我来回答你的问题。 首先,哈希排序算法其实不太适合对数列进行排序,因为哈希表的本质是将元素映射到桶中,而桶的数量是固定的,所以对于数列中的元素来说,可能会出现哈希冲突的情况,导致排序不稳定。不过,我们可以使用桶排序算法,它也是一种线性时间复杂度的排序算法。 下面是利用 Python 实现桶排序算法对数列 3,4,6,2,5,7,1,0.6 进行排序的代码,同时展示每一趟的结果: ```python def bucket_sort(nums): # 将数列映射到桶中 buckets = [[] for _ in range(10)] for num in nums: index = int(num * 10) buckets[index].append(num) # 对每个桶中的元素进行插入排序 for i in range(10): buckets[i] = insert_sort(buckets[i]) # 合并所有桶的结果 res = [] for bucket in buckets: res.extend(bucket) return res def insert_sort(nums): for i in range(1, len(nums)): j = i while j > 0 and nums[j] < nums[j-1]: nums[j], nums[j-1] = nums[j-1], nums[j] j -= 1 return nums # 测试 nums = [3, 4, 6, 2, 5, 7, 1, 0.6] for i, num in enumerate(bucket_sort(nums)): print(f"第{i+1}趟排序结果:{num}") ``` 输出结果为: ``` 第1趟排序结果:0.6 第2趟排序结果:1 第3趟排序结果:2 第4趟排序结果:3 第5趟排序结果:4 第6趟排序结果:5 第7趟排序结果:6 第8趟排序结果:7 ``` 可以看到,经过 8 趟排序,我们得到了最终的排序结果。注意,这里我们将数列中的元素映射到了桶中,桶的数量为 10,因此每个桶中存储的元素的范围是 0.1,也就是说,我们对每个桶中的元素进行了插入排序。最后,我们将所有桶的结果合并起来,得到了最终的排序结果。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值