什么是散列:Hashing
现在我们进一步来构造一个新的数据结构,能使得查找算法的复杂度降到O(1),这种概念成为散列
由数据项的值来确定其存放位置:
- 如果我们事先能知道要找的数据项应该出现在数据集中的什么位置,就可以直接到那个位置看看数据项是否存在即可
- 散列表(hash table,又称哈希表)是一种数据集,其中数据项的存储方式尤其有利于将来快速的查找定位
- 散列表中的每一个存储位置,称为槽(slot),可以用来保存数据项,每个槽有一个唯一的名称,例如:一个包含11个槽的散列表,槽的名称分别为0~10,在插入数据项之前,每个槽的值都是None,表示空槽
实现从数据项到存储槽名称的转换的,称为散列函数(hash function)
- 常用的散列方法是“求余数”,将数据项除以散列表的大小,得到的余数作为槽号。
- 需要查找某个数据项是否存在于表中,我们只需要使用同一个散列函数,对查找项进行计算,测试下返回的槽号所对应的槽中是否有数据项即可
- 不过两个数据项同时分配到同一个槽中,这种情况称为“冲突”后面再讨论。
完美散列函数
给定一组数据项,如果一个散列函数能把每个数据项映射到不同的槽中,那么这个散列函数就可以称为“完美散列函数”
- 但如果数据项经常性的变动,很难有一个系统性的方法来设计对应的完美散列函数
- 好的散列函数具备特性:冲突最少(近似完美)、计算难度低(额外开销小)、充分分散数据项(节约空间)
完美散列函数的更多用途
除了用于散列表中安排数据项的存储位置,散列技术还用在信息处理的很多领域
数据一致性校验:
- 由任意长度的数据生成长度固定的“指纹”,还要求具备唯一性。
- 最著名的近似完美散列函数是MD5和SHA系列函数
- MD5(Message Digest)将任何长度的数据变换为固定长为128位(16字节)的“摘要”。128位二进制已经是一个极为巨大的数字空间:据说是地球沙粒的数量
- SHA(Secure Hash Algorithm)160位二进制相当于10的48次方,地球上水分子数量估计是47次方。256位二进制相当于10的77次方,已知宇宙所有基本粒子大约是72~87次方
数据文件一致性判断
- 为每个文件计算其散列值,仅对比其散列值即可得知是否文件内容相同;
- 用于网络文件下载完整性校验;
- 用于文件分享系统:网盘中相同的文件(尤其是电影)可以无需存储多次。
加密形式保存密码
- 仅保存密码的散列值,用户输入密码后,计算散列值并比对
- 无需保存密码的明文即可判断用户是否输入了正确的密码
防文件篡改:原理同数据文件一致性判断
彩票投注应用
Python内置的MD5和SHA的散列函数库:hashlib
包括了md5/sha1/sha224/sha256/sha384/sha512等6种散列函数
import hashlib
print(hashlib.md5('hello world!'.encode('utf8')).hexdigest())
print(hashlib.sha1('hello world!'.encode('utf8')).hexdigest())
注意:一定要加上编码格式,否则:
除了对单个字符串进行散列计算之外,还可以用update方法来对任意长的数据分部分来计算,这样不管多大的数据都不会有内存不足的问题
import hashlib
m = hashlib.md5()
m.update("hello world!".encode("utf8"))
m.update("this is part #2".encode("utf8"))
m.update("this is part #3".encode("utf8"))
print(m.hexdigest())
散列函数最酷的应用:区块链技术
区块链是一种分布式数据库
通过网络连接的节点,每个节点都保存着整个数据库所有数据,任何地点存入的数据都会完成同步。
区块链最本质的特征是“去中心化”:不存在任何控制中心、协调中心节点,所有节点都是平等的,无法被控制
区块链
区块链由一个个区块(block)组成,区块分为头(head)和体(body)
- 区块头记录了一些元数据和链接到前一个区块的信息:生成时间、前一个区块(head+body)的散列值
- 区块体记录里实际数据
由于散列值具有抗修改性,任何对某个区块数据的改动必然引起散列值的变化
- 为了不导致这个区块脱离链条,就需要修改所有后续的区块,由于有“工作量证明”Proof of Work(POW)的机制,这种大规模修改不可能实现,除非掌握了全网51%的算力
由于区块链是大规模的分布式数据库,同步较慢,新区块的添加速度需要得到控制
- 目前最大规模区块链Bitcoin采用的速度是平均每10分钟生成1个区块
+因为有效散列值很难算出,所以控制了新区块生成的速度,便于在整个分布式网络中进行同步- 每个区块设置了一个难度系数Difficulty,用常数targetmax除以它,得到一个target,难度系数越高,target越小
- 矿工的工作是,找到一个数值Nonce,把它整个区块数据一起计算散列,这个散列值必须小于target,才是有效的散列值
- 由于散列值无法回推原值,这个Nonce的寻找只能靠暴力穷举,计算工作量+运气是唯一的方法
- 大家不惜付出海量的计算,去抢着算一个区块的有效散列值,先算出来的那位“矿工”才有资格把区块挂到区块链中
- 在加密货币Bitcoin中,区块内包含的数据是“交易记录”,也就是“账本”,这对于货币体系至关重要
- Bitcoin规定,每个区块中包含了一定数量的比特币作为“记账奖励”,这样就鼓励了更多人加入到抢先记账的行列
- 打字的手已残废
- 区块链技术应用全景
个人理解:利用区块不易篡改的特性,应用在信息存储和安全上
散列函数设计
折叠法
基本步骤:
- 将数据项按照位数分为若干段,例如电话号码62767255,分成4段(62、76、72、55)
- 再将几段数字相加,62+76+72+55=265
- 最后对散列表大小求余,散列表包括11个槽,那么就是265%11=1
所以h(62767255)=1有时候折叠法还会包括一个隔数反转的步骤
- 这种微调的手段,以便更好符合散列特性
- 比如(62、67、72、55)
h’(62767255)=3
平方取中法
首先将数据项做平方运算,然后取平方数的中间两位,再对散列表的大小求余
例如,对44进行散列
- 首先44*44=1936
- 然后取中间的93
- 对散列表大小11求余,93%11=5
非数项
我们也可以对非数字的数据项进行散列,把字符串中的每个字符看作ASCII码即可
再将这些整数累加,对散列表大小求余
import hashlib
def hash(astring,tablesize):
sum = 0
for pos in range(len(astring)):
sum = sum + ord(astring[pos])
return sum%tablesize
print(hash('cat',11))
这样的散列函数对所有的变位词都返回相同的散列值,可以对位置“加权”
冲突解决方案
开放定址“open addressing”技术中的线性探测“linear probing”
解决散列的一种方法就是为冲突的数据项再找一个开放的空槽来保存
- 最简单的就是从冲突的槽开始往后扫描,知道碰到一个空槽
- 如果到散列表尾部还未找到,则从首部接着扫描
这种寻找空槽的技术称为“开放定址”,向后逐个槽寻找的方法是线性探测
散列表的查找遵循同样的规则
- 如果在散列的位置没有找到查找项的话,就必须向后做顺序查找
- 直到找到查找项,或者碰到空槽(查找失败)
线性探测的缺点:聚焦(clustering)的趋势
即如果同一个槽冲突的数据项较多的话,这些数据项就会在槽附件聚集起来,从而连锁式影响其它数据项的插入
冲突解决方案:
- 线性探测的改进
避免聚集的一种方法就是将线性探测扩展,从逐个探测改为跳跃式探测- 注意:跳跃式探测中,skip的取值不能被散列表大小整除,否则会产生周期,造成很多空槽永远无法探测到,技巧:把散列表的大小设为素数
- 再散列“rehashing”
重新寻找空槽的过程
- 变为“二次探测quadratic probing”
不再固定skip的值,而是逐步增加skip值,如1、3、5、7、9
这样槽号就会是原散列值以平方数增加:h、h+1、h+4、h+9…
数据项链Chaining
除了寻找空槽的开放定址技术之外,另一种解决散列冲突的方案是将容纳单个数据项的槽,扩展为容纳数据项集合(或者对数据项链表的引用)
- 这样散列表中的每个槽就可以容纳多个数据项,如果有散列冲突发生,只需要简单的将数据项添加到数据项集合中。
- 查找数据项时,则需要查找同一个槽的整个集合。当然,随着散列冲突的增加,对数据项的查找时间也会相应增加。
映射抽象数据类型及Python实现
抽象数据类型“映射”:ADT Map
- Python最有用的数据类型之一“字典”
- 字典是一种可以保存key-data键值对的数据类型,其中键值码key可用于查询关联的数据值data
- 这种键值关联的方法为“映射Map”
- ADT Map的结构是键-值关联的无序集合
关键码具有唯一性
通过关键码可以唯一确定一个数据值
ADT Map定义的操作如下:
Map():创建一个空映射,返回空映射对象;
put(key,val):将key-val关联加入映射中,如果key已存在,将val替换旧关联值
get(key):给定key,返回关联的数据值,如不存在,则返回None
del:通过del map[key]的语句形式删除key-val关联
len():返回映射中key-val关联的数目
in:通过key in map的语句形式,返回key是否存在于关联中,布尔值
使用字典的优势在于,给定关键码key,能够很快得到的关联的数据值data,为了达到快速查找的目标,需要一个支持高效查找的ADT实现,下面,用一个HashTable类来实现ADT Map
import hashlib
class HashTable:
def __init__(self):#init(self)这个时类的初始化函数
self.size = 11
self.slots = [None] * self.size#slot列表用于保存key
self.data = [None] * self.size#平行的data列表用于保存数据项
def hashfunction(self,key):#散列简单的求余
return key% self.size
def rehash(self,oldhash):#冲突的解决,线性探测+1
return (oldhash + 1)% self.size
def put(self,key,data):
hashvalue = self.hashfunction(key)
if self.slots[hashvalue] == None:#key不存在,未冲突
self.slots[hashvalue] = key
self.data[hashvalue] = data
else:#key已存在,替换val
if self.slots[hashvalue] == key:
self.data[hashvalue] = data #repalce
else:
nextslot = self.rehash(hashvalue)
while self.slots[nextslot] != None and self.slots[nextslot] != key:#散列冲突,再散列,直到找到空槽或者key
nextslot = self.rehash(nextslot)
if self.slots[nextslot] == None:
self.slots[nextslot] = key
self.data[nextslot] = data
else:
self.data[nextslot] = data #replace
def get(self,key):#标记散列值为查找起点
startslot = self.hashfunction(key)
data = None
stop = False
found = False
position = startslot
while self.slots[position] != None and not found and not stop:#找key,直到空槽回到起点
if self.slots[position] == key:
found = True
data = self.data[position]
else:
position = self.rehash(position)#未找到key,再散列继续找
if position == startslot:#回到起点,查找失败,停
stop = True
return data
#特殊方法实现下标式的索引值的访问
def __getitem__(self,key):
return self.get(key)
def __setitem__(self,key,data):
self.put(key,data)
H = HashTable()
H[54]="cat"
print(H.slots)
print(H.data)
print(H[54])
H[54]='duck'
print(H[54])
散列算法分析
负载因子λ