1. 前言
或称为,是一种常见的、使用频率非常高的数据存储方案。
属于抽象数据结构,需要开发者按数据结构的存储要求进行 定制,对于大部分高级语言而言,都会提供已经实现好的、可直接使用的 ,如 中有 集合、 中的 容器, 中的字典……
使用者可以使用 中的方法完成对的增、删、改、查……一系列操作。
如何学习哈希表?
可以从 个角度开始:
使用者角度:只需要知道是基于、对存储的解决方案,另需要熟悉不同计算机语言提供的基于数据结构的 实现,学会使用 中的方法。
开发者的角度:则需要知道底层实现原理,以及实现过程中需要解决的各种问题。本文将站在开发者的角度,带着大家一起探究的世界。
2. 哈希表
什么是哈希表?
是基于、对存储的数据结构,底层一般采用的是。
大家都知道,基于的查询速度非常快,时间复杂度是 ,常量级别的。
列表的底层存储结构是连续的内存区域,只要给定数据在列表(数组)中的位置,就能直接查询到数据。理论上是这么回事,但在实际操作过程,查询数据的时间复杂度却不一定是常量级别的。
如存储下面的学生信息,学生信息包括学生的姓名和学号。在存储学生数据时,如果把学号为 的学生存储在列表 位置,学号为 的学生存储在列表 位置……
这里把学生的学号和列表的索引号进行关联,查询某一个学生时,知道了学生的学号也就知道了学生数据存储在列表中的位置,可以认为查询的时间复杂度为 。
之所以可以达到常量级,是因为这里有信息关联(学生学号关联到数据的存储位置)。
还有一点,学生的学号是公开信息也是常用信息,很容易获取。
但是,不是存储任何数据时,都可以找到与列表位置相关联的信息。比如存储所有的英文单词,不可能为每一个英文单词编号,即使编号了,编号在这里也仅仅是流水号,没有数据含义的数据对于使用者来讲是不友好,谁也无法记住哪个英文单词对应哪个编号。
所以使用列表存储英文单词后需要询时,因没有单词的存储位置。还是需要使用如、……之类的查询算法,这时的时间复杂度由使用的查询算法的时间复杂度决定。
如果对上述存储在列表的学生信息进行了、……等操作,改变了数据原来的位置后,因破坏了学号与位置关联信息,再查询时也只能使用其它查询算法,不可能达到常量级。
是否存在一种方案,能最大化地优化数据的存储和查询?
通过上述的分析,可以得出一个结论,要提高查询的速度,得想办法把与进行关联。而的核心思想便是如此。
2.1 哈希函数
引入了概念,可以认为是数据的别名。如上表,可以给每一个学生起一个别名,这个就是。
Tip: 这里的是姓名的缩写,和的关联性较强,方便记忆和查询。
有了后,再把映射成列表中的一个有效位置,映射方法就是中最重要的概念。
是一个桥梁,即关联到真正数据又关联到哈希表中的位置。
也可以是需要保存的数据本身。
的功能:提供把映射到中的位置算法,是存储数据的核心所在。如下图,演示、、之间的关系,可以说是数据进入的入口。
数据最终会存储在列表中的哪一个位置,完全由决定。
当需要查询学生数据时,同样需要调用对进行换算,计算出数据在列表中的位置后就能很容易查询到数据。
如果忽视的时间复杂度,基于的数据存储和查询时间复杂度是 。
如此说来算法设计的优劣是影响性能的关键所在。
2.2 哈希算法
决定了数据的最终存储位置,不同的设计方案,也关乎的整体性能,所以,就变得的尤为重要。
下文将介绍并纵横比较几种常见的 的设计方案。
Tip: 无论使用何种,都有一个根本,后的结果一定是一个数字,表示列表(哈希表)中的一个有效位置。也称为。
使用存储数据时,可以是数字类型也可以是非数字类型,其实,可以是任何一种类型。这里先讨论当为非数字类型时设计的基本思路。
如前所述,已经为每一个学生提供了一个以姓名的拼音缩写的。
现在如何把映射到的一个有效位置?
这里可以简单地把拼音看成英文中的字母,先分别计算每一个字母在字母表中的位置,然后相加,得到的一个数字。
使用上面的对每一个学生的进行哈希:
的哈希值为 。
的哈希值为 。
的哈希值为 。
的哈希值为 。
前文说过是表示数据在列表中的存储位置,现在假设一种理想化状态,学生的姓名都是 个汉字,意味着也是 个字母,采用上面的的,最大的应该是,意味着至少应该提供一个长度为 的列表 。
如果,现在仅仅只保存 名学生,虽然只有 名学生,因无法保证学生的不出现,所以列表长度还是需要 。
采用这种会导致列表的空间浪费严重,最直观想法是对再做约束,如除以 再取余数,把限制在 之内, 个数据对应 个哈希值。我们称这种取余数方案为。
取余数法中,被除数一般选择小于哈希表长度的素数。本文介绍其它哈希算法时,也会使用取余数法对哈希值进行适当范围的收缩。
重新对 名学生的关键字进行哈希。
的哈希值为 , 除以 取余数,结果是。
的哈希值为 , 除以 取余数,结果是。
的哈希值为 , 除以 取余数,结果是。
的哈希值为 , 除以 取余数,结果是。
演示图上出现了一个很奇怪的现象,没有看到的存储信息。
个存储位置存储 学生,应该是刚刚好,但是,只存储了 名学生。且还有 个位置是空闲的。现在编码验证一下,看是不是人为因素引起的。
'''
哈希函数
'''
def hash_code(key):
# 设置字母 A 的在字母表中的位置是 1
pos = 0
for i in key:
i = i.lower()
res = ord(i) - ord('a') + 1
pos += res
return pos % 4
测试代码:
# 哈希表
hash_table = [None] * 4
# 计算关键字的哈希值
idx = hash_code('zjl')
# 根据关键字换算出来的位置存储数据
hash_table[idx] = '周杰伦'
idx = hash_code('llj')
hash_table[idx] = '李连杰'
idx = hash_code('cl')
hash_table[idx] = '成龙'
idx = hash_code('zzz')
hash_table[idx] = '张志忠'
print('哈希表中的数据:', hash_table)
'''
输出结果:
哈希表中的数据: ['周杰伦', None, '张志忠', '成龙']
'''
执行代码,输出结果,依然还是没有看到的信息。
原因何在?
这是因为和的哈希值都是 ,导致在存储时,后面存储的数据会覆盖前面存储的数据,这就是哈希中的典型问题,。
所谓,指不同的在进行后得到相同的,这意味着,不同所对应的数据会存储在同一个位置,这肯定会发生数据丢失,所以需要提供算法,解决冲突问题。
Tip: 研究,归根结底,是研究如何计算以及如何解决的问题。
针对上面的问题,有一种想当然的冲突解决方案,扩展列表的存储长度,如把列表扩展到长度为 。
直观思维是:扩展列表长度,哈希值的范围会增加,冲突的可能性会降低。
'''
哈希函数
'''
def hash_code(key):
# 设置字母 A 的在字母表中的位置是 1
pos = 0
for i in key:
i = i.lower()
res = ord(i) - ord('a') + 1
pos += res
return pos % 8
# 哈希表
hash_table = [None] * 8
# 保存所有学生
idx = hash_code('zjl')
hash_table[idx] = '周杰伦'
idx = hash_code('llj')
hash_table[idx] = '李连杰'
idx = hash_code('cl')
hash_table[idx] = '成龙'
idx = hash_code('zzz')
hash_table[idx] = '张志忠'
print('哈希表中的数据:', hash_table)
'''
输出结果:
哈希表中的数据: ['周杰伦', None, '李连杰', None, None, None, '张志忠', '成龙']
'''
貌似解决了冲突问题,其实不然,当试着设置列表的长度为、、、、时,只有当长度为 时没有发生冲突,这还是在要存储的数据是已知情况下的尝试。
如果数据是动态变化的,显然这种扩展长度的方案绝对不是本质解决冲突的方案。即不能解决冲突,且产生大量空间浪费。
如何解决,会在后文详细介绍,这里还是回到上。
综上所述,我们对的理想要求是:
为每一个生成一个唯一的,保证每一个数据都有只属于自己的存储位置。
哈希算法的性能时间复杂度要低。
现实情况是,同时满足这 个条件的几乎是不可能有的,面对数据量较多时,是常态。所以,只能是尽可能满足。
因冲突的存在,即使为 个数据提供 个有效存储空间,还是会有空间闲置。这里把实际使用空间和列表提供的有效空间相除,得到的结果,称之为哈希表的。
如上述,当列表长度为 时, 占有率为 ,当列表长度为 时,占有率为 ,一般要求占率控制 在之间。
2.3 常见哈希算法
前面在介绍什么是时,提到了,除此之外,还有几种常见的。
2.3.1 折叠法
折叠法:将分割成位数相同的几个部分(最后一部分的位数可以不同)然后取这几部分的叠加和(舍去进位)作为哈希值。
折叠法又分移位叠加和间界叠加。
移位叠加:将分割后的每一部分的最低位对齐,然后相加。
间界叠加:从一端沿分割线来回折叠,然后对齐相加。
因有相加求和计算,折叠法适合数字类型或能转换成数字类型的关键字。假设现在有很多商品订单信息,为了简化问题,订单只包括订单编号和订单金额。
现在使用用存储订单数据,且以订单编号为,订单金额为。
订单编号 | 订单金额 |
---|---|
20201011 | 400.00 |
19981112 | 300.00 |
20221212 | 200 |
移位叠法换算关键字的思路:第一步:把订单编号 按每位一组分割,分割后的结果:。
按 位一组还是 位一组进行分割,可以根据实际情况决定。
第二步: 把分割后的数字相加 ,得到结果:。再使用取余数法,如果哈希表的长度为 ,则除以 后的余数为。
这里除以 仅是为了简化问题细节,具体操作时,很少选择列表的长度。
第三步:对其它的采用相同的处理方案。
关键字 | 哈希值 |
---|---|
20201011 | 3 |
19981112 | 2 |
20221212 | 6 |
编码实现保存商品订单信息:
'''
移位叠加哈希算法
'''
def hash_code(key, hash_table_size):
# 转换成字符串
key_s = str(key)
# 保存求和结果
s = 0
# 使用切片
for i in range(0, len(key_s), 3):
s += int(key_s[i:i + 3])
return s % hash_table_size
# 商品信息
products = [[20201011, 400.00], [19981112, 300], [20221212, 200]]
# 哈希表长度
hash_size = 10
# 哈希表
hash_table = [None] * hash_size
# 以哈希表方式进行存储
for p in products:
key = hash_code(p[0], hash_size)
hash_table[key] = p[1]
# 显示哈希表中的数据
print("哈希表中的数据:",hash_table)
# 根据订单号进行查询
hash_val = hash_code(19981112, hash_size)
val = hash_table[hash_val]
print("订单号为{0}的金额为{1}".format(19981112, val))
'''
输出结果
哈希表中的数据: [None, None, 300, 400.0, None, None, 200, None, None, None]
订单号为19981112的金额为300
'''
间界叠加法:
间界叠加法,会间隔地把要相加的数字进行反转。
如订单编号 按位一组分割,分割后的结果:,间界叠加操作求和表达式为 ,再把结果 。
编码实现间界叠加算法:
'''
间界叠加哈希算法
'''
def hash_code(key, hash_table_size):
# 转换成字符串
key_s = str(key)
# 保存求和结果
s = 0
# 使用切片
for i in range(0, len(key_s), 3):
# 切片
tmp_s = key_s[i:i + 3]
# 反转
if i % 2 != 0:
tmp_s = tmp_s[::-1]
s += int(tmp_s)
return s % hash_table_size
# 商品信息(数据样例)
products = [[20201011, 400.00], [19981112, 300], [20221212, 200]]
# 哈希表长度
hash_size = 10
# 哈希表
hash_table = [None] * hash_size
# 以哈希表方式进行存储
for p in products:
key = hash_code(p[0], hash_size)
hash_table[key] = p[1]
# 显示哈希表中的数据
print("哈希表中的数据:", hash_table)
# 根据订单号进行查询
hash_val = hash_code(19981112, hash_size)
val = hash_table[hash_val]
print("订单号为{0}的金额为{1}".format(19981112, val))
'''
输出结果:
哈希表中的数据: [None, None, None, 400.0, None, None, 200, None, None, 300]
订单号为19981112的金额为300
'''
2.3.2 平方取中法
平方取中法:先是对求平方,再在结果中取中间位置的数字。
求平方再取中算法,是一种较常见的哈希算法,从数学公式可知,求平方后得到的中间几位数字与关键字的每一位都有关,取中法能让最后计算出来的哈希值更均匀。
因要对求平方,只能是数字或能转换成数字的类型,至于本身的大小范围限制,要根据使用的计算机语言灵活设置。
如下面的图书数据,图书包括图书编号和图书名称。现在需要使用哈希表保存图书信息,以图书编号为关键字,图书名称为值。
图书编号 | 图书名称 |
---|---|
58 | python 从入门到精通 |
67 | C++ STL |
78 | Java 内存模型 |
使用平方取中法计算关键字的哈希值:
第一步:对图书编号 求平方,结果为 。 第二步:取 的中间值,然后再使用取余数方案。如果哈希表的长度为 ,则 。 第三步:对其它的关键字采用相同的计算方案。 编码实现平方取中算法:
'''
哈希算法
平方取中
'''
def hash_code(key, hash_table_size):
# 求平方
res = key ** 2
# 取中间值,这里取中间 2 位(简化问题)
res = int(str(res)[1:3])
# 取余数
return res % hash_table_size
hash_table_size = 10
hash_table = [None]*hash_table_size
# 图书信息
books = [[58, "python 从入门到精通"], [67, "C++ STL"], [78, "Java 内存模型"]]
for b in books:
hash_val = hash_code(b[0],hash_table_size)
hash_table[hash_val]=b[1]
# 显示哈希表中的数据
print("哈希表中的数据:", hash_table)
# 根据编号进行查询
hash_val = hash_code(67, hash_table_size)
val = hash_table[hash_val]
print("编号为{0}的书名为{1}".format(67, val))
上述求平方取中间值的算法仅针对于本文提供的图书数据,如果需要算法具有通用性,则需要根据实际情况修改。
不要被 的字所迷惑,不一定是绝对中间位置的数字。
2.3.3 直接地址法
直接地址法:提供一个与相关联的。如针对上述图书数据,可以提供线性函数 。
系数和常数的选择会影响最终生成的哈希值的大小。可以根据哈希表的大小和操作的数据含义自行选择。
为图书编号。当不相同时,使用得到的值也是唯一的,所以,不会产生哈希冲突,但是会要求的存储长度比实际数据要大。
这种算法在实际应用中并不多见。
实际应用时,具体选择何种,完全由开发者定夺,的选择没有固定模式可循,虽然上面介绍了几种算法,只是提供一种算法思路。
2.4 哈希冲突
是怎么引起的,前文已经说过。现在聊聊常见的几种解决方案。
2.4.1 线性探测
当发生后,会在冲突位置之后寻找一个可用的空位置。如下图所示,使用,保存数据到哈希表中。
哈希表的长度设置为 ,除数设置为 。
解决冲突的流程:
和的哈希值都是 。而因为在的前面,先占据哈希表的 位置。
当存储 时,只能以 位置为起始位置,向后寻找空位置,因 位置没有被其它数据占据,最终保存在哈希表的位置。
当存储数字 时,通过哈希算法计算,其哈希值是,本应该要保存在哈希表中的位置,因位置已经被所占据,只能向后寻找空位置,最终落脚在位置。
线性探测法让发生的数据保存在其它数据的哈希位置,如果冲突的数据较多,则占据的本应该属于其它数据的哈希位置也较多,这种现象称为。
查询流程:
以查询数据为例。
计算 的哈希值,得到值为 ,根据哈希值在哈希表中找到对应位置。
查看对应位置是否存在数据,如果不存在,宣告查询失败,如果存在,则需要提供数据比较方法。
因 位置的数据 并不等于。于是,继续向后搜索,并逐一比较。
最终可以得到结论在哈希表的编号为的位置。
所以,在查询过程中,除了要提供哈希函数,还需要提供数据比较函数。
删除流程:
以删除数字为例。
按上述的查询流程找到数字在哈希表中的位置。
设置位置为删除状态,一定要标注此位置曾经保存过数据,而不能设置为空状态。为什么?
如果设置为空状态,则在查询数字时,会产生错误的返回结果,会认为 不存在。为什么?自己想想。
编码实现线性探测法:
添加数据:
'''
线性探测法解决哈希冲突
'''
def hash_code(key, hash_table, num):
# 哈希表的长度
size = len(hash_table)
# 取余数法计算哈希值
hash_val = key % num
# 检查此位置是否已经保存其它数据
if hash_table[hash_val] is not None:
# 则从hash_val 之后寻找空位置
for i in range(hash_val + 1, size + hash_val):
if i >= size:
i = i % size
if hash_table[i] is None:
hash_val = i
break
return hash_val
# 哈希表
hash_table = [None] * 15
src_nums = [25, 78, 56, 32, 88, 26, 73, 81, 14]
for n in src_nums:
hash_val = hash_code(n, hash_table, 13)
hash_table[hash_val] = n
print("哈希表中的数据:", hash_table)
'''
输出结果:
哈希表中的数据: [78, 26, 14, 81, 56, None, 32, None, 73, None, 88, None, 25, None, None]
'''
Tip:为了保证当哈希值发生冲突后,如果从冲突位置查到哈希表的结束位置还是没有找到空位置,则再从哈希表的起始位置,也就是 位置再搜索到冲突位置。冲突位置是起点也是终点,构建一个查找逻辑环,以保证一定能找到空位置。for i in range(hash_val + 1, size + hash_val): pass
基于线性探测的数据查询过程和存储过程大致相同:
def get(key, hash_table, num):
# 哈希表的长度
size = len(hash_table)
# 取余数法计算哈希值
hash_val = key % num
is_exist = False
# 检查此位置是否已经保存其它数据
if hash_table[hash_val] is None:
# 不存在
return None
if hash_table[hash_val] != key:
# 则从hash_val 之后寻找空位置
for i in range(hash_val + 1, size + hash_val):
if i >= size:
i = i % size
if hash_table[i] == key:
hash_val = i
is_exist = True
break
else:
is_exist=True
if is_exist:
return hash_val
# 测试
res = get(25, hash_table, 13)
print(res)
为了减少数据聚集,可以采用增量线性探测法,所谓指当发生哈希冲突后,探测空位置时,使用步长值大于 的方式跳跃式向前查找。目的是让数据分布均匀,减小数据聚集。
除了采用增量探测之外,还可以使用的方案。也就是提供 个哈希函数,第 次哈希值发生冲突后,再调用第 个哈希函数再哈希,直到冲突不再产生。这种方案会增加计算时间。
2.4.4 链表法
上面所述的冲突解决方案的核心思想是,当冲突发生后,在哈希表中再查找一个有效空位置。
这种方案的优势是不会产生额外的存储空间,但易产生数据聚集,会让数据的存储不均衡,并且会违背初衷,通过计算出来的并不能准确描述数据正确位置。
应该是所有解决中较完美的方案。所谓,指当发生后,以冲突位置为首结点构建一条链表,以链表方式保存所有发生冲突的数据。如下图所示:
链表方案解决冲突,无论在存储、查询、删除时都不会影响其它数据位置的和,且因链表的操作速度较快,对于哈希表的整体性能都有较好改善。
使用链表法时,哈希表中保存的是链表的首结点。首结点可以保存数据也可以不保存数据。
编码实现链表法:链表实现需要定义 2 个类,1 个是结点类,1 个是哈希类。
'''
结点类
'''
class HashNode():
def __init__(self, value):
self.value = value
self.next_node = None
'''
哈希类
'''
class HashTable():
def __init__(self):
# 哈希表,初始大小为 15,可以根据需要动态修改
self.table = [None] * 15
# 实际数据大小
self.size = 0
'''
存储数据
key:关键字
value:值
'''
def put(self, key, value):
hash_val = self.hash_code(key)
# 新结点
new_node = HashNode(value)
if self.table[hash_val] is None:
# 本代码采用首结点保存数据方案
self.table[hash_val] = new_node
self.size+=1
else:
move = self.table[hash_val]
while move.next_node is not None:
move = move.next_node
move.next_node = new_node
self.size+=1
'''
查询数据
'''
def get(self, key):
hash_val = self.hash_code(key)
if self.table[hash_val] is None:
# 数据不存在
return -1
if self.table[hash_val].value == key:
# 首结点就是要找的数据
return self.table[hash_val].value
# 移动指针
move = self.table[hash_val].next_node
while move.value != key and move is not None:
move = move.next_node
if move is None:
return -1
else:
return move.value
def hash_code(self, key):
# 这里仅为说明问题,13 的选择是固定的
hash_val = key % 13
return hash_val
# 原始数据
src_nums = [25, 78, 56, 32, 88, 26, 39, 82, 14]
# 哈希对象
hash_table = HashTable()
# 把数据添加到哈希表中
for n in src_nums:
hash_table.put(n, n)
# 输出哈希表中的首结点数据
for i in hash_table.table:
if i is not None:
print(i.value,end=" ")
print("\n-------------查询-----------")
print(hash_table.get(26))
'''
输出结果:
78 14 56 32 88 25
-------------查询-----------
26
'''
3.总结
是一种高级数据结构,其存储、查询性能非常好,在不考虑哈希哈希算法和哈希冲突的时间复杂度情况下,哈希查找时间复杂度可以达到常量级,成为很多实际应用场景下的首选。
研究,着重点就是搞清楚以及如何解决。在算法的世界时,没有固定的模式,开发者可以根据自己的需要自行设计哈希算法。