6.散列

散列函数

1.散列:Hashing

前面我们利用数据集中关于数据项之间排列关系的知识,来将查找算法进行了提升。如果数据项之间是按照大小排好序的话,就可以利用二分查找来降低算法复杂度。现在我们进一步来构造一个新的数据结构,能使得查找算法的复杂度降到O(1),这种概念称为“散列Hashing”
能够使得查找的次数降低到常数级别,我们对数据项所处的位置就必须有更多的先验知识。如果我们事先能知道要找的数据项应该出现在数据集中的什么位置,就可以直接到那个位置看看数据项是否存在即可。由数据项的值来确定其存放位置,如何能做到这一点呢?

散列表(hash table,又称哈希表)是一种数据表,其中数据项的存储方式尤其有利于将来快速的查找定位。散列表中的每一个存储位置,称为槽(slot),可以用来保存数据项,每个槽有一个唯一的名称。

例如:一个包含11个槽的散列表,槽的名称分别为0~10。在插入数据项之前,每个槽的值都是None,表示空槽。

# 基本概念
实现从数据项到存储操名称的转换的,称为散列函数(hash function)
下面示例中,散列函数接收数据项作为参数,返回整数值0~10,表示数据项存储的槽号(名称)

为了将数据项保存到散列表中,我们设计第一个散列函数。数据项:54,26,93,17,77,31

有一种常用的散列方法是“求余数”,将数据项除以散列表的大小,得到的余数作为槽号。实际上“求余数”方法会以不同形式出现在所有散列函数里。因为散列函数返回的槽号必须在散列表大小范围之内,所以一般会对散列表大小求余。

本例中我们的散列函数是最简单的求余:h(item) = item % 11

按照散列函数h(item),为每个数据项计算出存放的位置之后,就可以将数据项存入相应的槽中。

例子中的6个数据项插入后,占据了散列表11个槽中的6个。槽被数据项占据的比例称为散列表的“负载因子”,这里负载因子为6/11。数据项都保存到散列表后,查找就无比简单。要查找某个数据项是否存在于表中,我们只需要使用同一个散列函数,对查找项进行计算,测试下返回的槽号所对应的槽中是否有数据项即可。实现了O(1)时间复杂度的查找计算。

不过,你可能也看出这个方案的问题所在,这组数据相当凑巧,各自占据了不同槽。加入还要保存44,h(44)=0,它跟77被分配到同一个0#槽中,这种情况称为“冲突collision”,我们后面会讨论到这个问题的解决方案。

2.完美散列函数

给定一组数据项,如果一个散列函数能把每隔数据项映射到不同的槽中,那么这个散列函数就可以称为“完美散列函数”,对于固定的一组数据,总是能想办法设计出完美散列函数。但是如果数据项经常性的变动,很难有一个系统性的方法来设计对应的完美散列函数。当然,冲突也不是致命性的错误,我们会有办法处理的。

获得完美散列函数的一种方法是扩大散列表的容量,大到所有可能出现的数据项都能够占据不同的槽。但这种方法对于可能数据项范围过大的情况并不实用。加入我们要保存手机号(11位数字),完美散列函数得要求散列表具有百亿个槽!会浪费太多存储空间。退而求其次,好的散列函数需要具备特性:冲突最少(近似完美)、计算难度低(额外开销小)、充分分散数据项(节约空间)。

除了用在散列表中安排数据项得存储位置,散列技术还用在信息处理得很多领域。由于完美散列函数能够对任何不同得数据生成不同的散列值,如果把散列值当作数据的“指纹”或者“摘要”,这种特性被广泛应用在数据的一致性校验上。
  由任意长度的数据生成长度固定的“指纹’,还要求具备唯一性,这在数学上是无法做到的,但设计巧妙的“准完美”散列函数却能在实用范围内做到这一点。

# 完美散列函数的更多用途
作为一致性校验的数据“指纹”函数需要具备如下特性:
压缩性:任意长度的数据,得到的“指纹”长度是固定的;
易计算性:从原数据计算“指纹”很容易;(从指纹计算原数据是不可能的);
抗修改性:对元数据的微小变动,都会引起“指纹”的大改变;
抗冲突性:已知原数据和“指纹”,要找到相同指纹的数据(伪造)是非常困难的。

4.散列函数MD5/SHA

最著名的近似完美散列函数是MD5和SHA系列函数。
MD5(Message Digest)将任何长度的数据变换位固定长为128位(16字节)的“摘要”
128位二进制已经是一个极为巨大的数字空间:据说是地球沙粒的数量。

SHA(Secure Hash Algorithm)是另一组散列函数
SHA-0/SHA-1输出散列值160位(20字节),SHA-256/SHA-224分别输出256位、224位,SHA-512/SHA-384分别输出512位和384位

160位二进制相当于10的48次方,地球上水分子数量估计是47次方。
256位二进制相当于10的77次方,已知宇宙所有基本例子大约是72~87次方


虽然近年发现MD5/SHA-0/SHA-1三种散列函数能够以极特殊的情况来构造个别碰撞(散列冲突),但在实用中从未有实际的威胁。
关于数量级的知识:http://zh.wikipedia.org/wiki/%E6%95%B0%E9%87%8F%E7%BA%A7_(%E6%95%B0)  # 10**(-36)~10**(100)


python自带MD5和SHA系列的散列函数库:hashlib。包括了md5/sha1/sha224/sha256/sha384/sha512等6中散列函数
import hashlib

m0 = hashlib.md5('hello'.encode('utf8')).hexdigest()
print(m0)  # fc3ff98e8c6a0d3087d515c0473f8677

m1 = hashlib.md5()
m1.update("hello world!".encode("utf8"))
print(m1.hexdigest()) # fc3ff98e8c6a0d3087d515c0473f8677

m = hashlib.md5()
m.update('hello'.encode('utf8'))
m.update(' world!'.encode('utf8'))

print(m.hexdigest()) # fc3ff98e8c6a0d3087d515c0473f8677

print(hashlib.sha1("hello world!".encode('utf8')).hexdigest())
print(hashlib.sha224("hello world!".encode('utf8')).hexdigest())
print(hashlib.sha256("hello world!".encode('utf8')).hexdigest())
print(hashlib.sha384("hello world!".encode('utf8')).hexdigest())
print(hashlib.sha512("hello world!".encode('utf8')).hexdigest())
print(hashlib.sha3_224("hello world!".encode('utf8')).hexdigest())

'''
430ce34d020724ed75a196dfc2ad67c77772d169 # sha1
428c16b4309e824cfa874fe24ce2af2894fcaf8d72af4a368d492e34 # sha224
7509e5bda0c762d2bac7f90d758b5b2263fa01ccbc542ab5e3df163be08e6ca9  # sha256
d33d40f7010ce34aa86efd353630309ed5c3d7ffac66d988825cf699f4803ccdf3f033230612f0945332fb580d8af805  #sha384
db9b1cd3262dee37756a09b9064973589847caa8e53d31a9d142ea2701b1b28abd97838bb9a27068ba305dc8d04a45a1fcf079de54d607666996b3cc54f6b67c  # sha512
f8551fe638d604e2cac84f39b5c38f400d161245a16359b59a57286c  # sha3_224
'''
完美散列函数用于数据一致性校验:
1.数据文件一致性判断
    防文件篡改:当然还有更多密码学基址来保护数据文件,防篡改,防抵赖,是电子商务的信息技术基础。
    彩民投注应用:彩民下注前,机构将中将的结果散列值公布,然后彩民投注,开奖后,彩民可以通过公布的结果和事先公布的散列值对比,验证机构是否作弊。
2.为每个文件计算其散列值,仅对比其散列值即可得知文件内容是否相同;
3.用于网络文件下载完整性校验;
4.用于文件分享系统:网盘中相同的文件(尤其是电影)可以无须存储多次
5.加密形式保存密码
    仅保存密码的散列值,用户输入密码后,计算散列值并比对;无需保存密码的明文即可判断用户是否输入了正确的密码;

5.区块链技术

区块链是一种分布式数据库,通过网络链接的节点,每个节点都保存着整个数据库所有数据任何地点存入的数据都会完成同步。
区块链最本质特征是“去中心化”,不存在任何控制中心、协调中心节点,所有节点都是平等的,无法被控制。
需要解决:不需要信任权威,也可以防止篡改和破坏问题。
如何做到不需要相互信任和权威,即可防止篡改和破坏?

# 两个主要机制
用散列技术和链表技术实现了“牵一发动全身”的抗修改区块链
用“工作量证明”的奖励机制实现了“互相竞争、互相监督”的共识体系
区块链由一个个区块(block)组成,区块分为头(head)和体(body)
区块头记录了一些元数据和链接到前一个区块的信息,生成时间、前一个区块(head+body)的散列值,区块体记录了实际数据。

# 区块链的不可修改性
由于散列值具有抗修改性,任何对某个区块数据的改动必然引起散列值的变化。为了不导致这个区块脱离链条,就需要修改所有后续的区块。由于有“工作量证明”的机制,这种大规模修改不可能实现的,除非掌握了全网51%以上的计算力

工作量证明(Proof of Work,POW)
由于区块链是大规模的分布式数据库,同步较慢,新区块的添加速度需要得到控制。目前最大规模区块链Bitcoin采用的速度是平均每10分钟生成一个区块。大家不惜付出海量的计算,去抢着计算出一个区块的有效散列值。最先算出的那位“矿工”才有资格把区块挂到区块链中。

为什么有效散列值那么难算出?
因为很难算出,所以控制了新区块生成的速度,便于在整个分布式网络中进行同步。每个区块设置了一个难度系数Difficulty,用常数targetmax除以它,得到一个target,难度系数越高,target越小。

矿工的工作是,找到一个数值Nonce,把它跟整个区块数据一起计算散列,这个散列值必须小于target,才是有效的散列值。由于散列值无法回推到原值,这个Nonce的寻找只能靠暴力穷举,计算工作量+运气是唯一的方法。

Bitcoin的一个区块:https://blockexplorer.com/

为什么矿工抢着生成区块?
因为有利益!
在加密货币Bitcoin中,区块内包含的数据是“交易记录”,也就是“账本”,着对于货币体系至关重要。Bitcion规定,每个区块中包含了一定数量的比特币作为“记账奖励”,这样就鼓励了更多人加入到抢先记账的行列。

由于硬件摩尔定律的存在,计算力将持续递增,为了持续每10分钟生成要给区块的速度,难度系数Difficulty也将持续递增。
挖矿计算力升级:CPU(20MHash/s)-->GPU(400MHash/s)-->FPGA(25GHash/s)-->ASIC(3.5THash/s)-->大规模集群挖矿(3.5THash/s*X)。
另外,为了保持货币总量不会无限增加,每4年奖励的比特币减半。2008年开始是50个,2019年为12.5个


应用越来越广泛,成为信息社会的基础设施,比如:数字人民币。

6.散列函数设计

1.折叠法

折叠法设计散列函数的基本步骤是:
    将数据项按照位数分为若干段,再将几段数字相加,最后对散列表大小求余,得到散列值。
    
    例如:对电话号码62767255
    可以两位分为4段(62、67、72、55)
    相加(62+76+72+55=265)
    散列表包括11个槽,那么就是265%11=1,所以h(62767255)=1

有时候折叠法还会包括一个隔数反转的步骤:
比如:(62、76、72、55)隔数反转为(62、67、72、55)
再累加(62+67+72+55=256)
对11求余(256%113,所以h'(62767255)=3
虽然隔数反转从理论上看来毫无必要,但这个步骤确实为折叠法得到散列函数提供了一种微调手段,以便更好符合散列特性。

2.平方取中法
      平方取中法,首先将数据项做平方运算,然后取平方数的中间两位,再对散列表的大小求余。
      例如,对44进行散列
      首先44*44=1936
      然后取中间的93
      对散列表大小11求余,93%11=5

下标是两种散列函数的对比:
    两个都是完美散列函数,分散度都很好,平方取中法计算量稍大。
Item Remainder Mid-Square
54 10 3
26 4 7
93 5 9
17 6 8
77 0 4
31 9 6
3.非数项
我们也可以对非数字的数据项进行散列,把字符串中的每个字符看作ASCII码即可。
如cat,ord('c')==99,ord('a')==96,ord('t')==116
再将这些整数累加,对散列表大小求余。
99 + 97 + 116 = 312 % 11  ----> 4

def hash(astring, tablesize):
    sum = 0
    for pos in range(len(astring)):
        sum += ord(astring[pos])
    return sum%tablesize

当然,这样的散列函数对所有的变位词都返回相同的散列值。为了防止这一点,可以将字符串所在的位置作为权重因子,乘以ord值。

我们还可以设计出更多的散列函数方法,但要坚持的要给基本出发点是,散列函数不能成为存储过程和查找过程的计算负担。如果散列函数设计太过复杂,去花费大量的计算资源计算槽号,可能还不如简单地进行顺序查找或者二分查找。失去了散列本身的意义。

7.冲突解决方案

如果两个数据项被散列映射到同一个槽,需要一个系统化的方法再散列表中保存第二个数据项,这个过程称为“解决冲突”。前面提到,如果说散列函数是完美的,那就不会有散列冲突,但完美散列函数常常是不现实的。解决冲突成为散列方法中很重要的一部分。

解决散列的一种方法就是为冲突的数据项再找一个开放的空槽来保存。最简单的就是从冲突的槽开始往后扫描,直到碰到一个空槽,如果到散列表尾部还未找到,则从首部接着扫描。这种寻找空槽的技术称为“开放定址 open addressing”。向后逐个槽寻找的方法则是开放定址技术中的“线性探测linear probing”
# 线性探测Linear Probing
我们把44、55、20逐个插入到散列表中:
h(44)=0,但发现0#槽已被77占据,向后找到第一个空槽1#,保存
h(55)=0,同样0#槽被占据,向后找到第一个空槽2#,保存
h(20)==9,发现9#槽已经被31占据了,向后,再从头开始找到3#槽保存。

# 1.顺序查找空槽
采用线性探测方法来解决散列冲突的话,则散列表的查找也遵循同样的规则。
如果在散列位置没有找到查找项的话,就必须向后做顺序查找,直到找到查找项,或者碰到空槽(查找失败)。

线性探测法的一个缺点是有聚集(clustering)的趋势。即如果同一个槽冲突的数据项较多的话,这些数据项聚会在槽附近聚集起来,从而连锁式影响其它数据项的插入。

# 2.跳跃式查找
避免聚集的一种方法就是将线性探测扩展,从逐个探测改为跳跃式探测。
下图是“+3”探测插入44、55、20

重新寻找空槽的过程可以用一个更为通用的“再散列rehashing”来概括
newhashvalue = rehash(oldhashvalue)
对于线性探测来说,rehash(pos) = (pos + 1)%sizeoftable
"+3"的跳跃式探测则是:rehash(pos) = (pos+3)%sizeoftable
跳跃式探测的再散列通式是:rehash(pos) = (pos+skip)%sizeoftalble

跳跃式探测中,需要注意的是skip的取值,不能被散列表大小整除,否则会产生周期,造成很多空槽永远无法探测到。一个技巧是,把散列表的大小设为素数,如例子的11。

还可以将线性探测变为“二次探测 quadratic probing”
不再固定skip的值,而是逐步增加skip值,如1、3、5、7、9
这样槽号就会是原散列值以平方数增加:h,h+1,h+4,h+9,h+16
# 3.数据项链Chaining
除了寻找空槽的开放定址技术之外,另一种解决散列冲突的方案是将容纳单个数据项的槽扩展为容纳数据项集合(或者对数据项链表的引用),这样,散列表中的每个槽就可以容纳多个数据项,如果有散列冲突发生,只需要简单地将数据项添加到数据项集合中。查找数据项时则需要查找同一个槽中的整个集合,当然,随着散列冲突的增加,对数据项的查找时间也会相应增加。

8.映射抽象数据类型及python实现

python最有用的数据类型之一“字典”,字典是一种可以保存key-value键值对的数据类型。其中关键码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实现。
可以采用列表数据结构加顺序查找或者二分查找。当然,更为合适的是使用前述的散列表来实现,这样查找可以达到最快O(1)的性能。

下面,我们用一个HashTable类来实现ADT Map,该类包含了两个列表作为成员。其中一个slot列表用于保存key,另一个平行的data列表用于保存数据项。在slot列表查找到一个key的位置以后,在data列表对应相同位置的数据项即为关联数据。


H = HashTable()
H[54] = "cat"
H[26] = "dog"
H[93] = "lion"
H[17] = "tiger"
H[77] = "bird"
H[31] = "cow"
H[44] = "goat"
H[55] = "pig"
H[20] = "chicken"
print(H.slots) # [77,44,55,20,26,93,17,None,None,31,54]
print(H.data)  # ['bird','goat','pig','chicken','dog','lion','None','cow','cat']

print(H[20]) # chicken
print(H[17]) # tiger
H[20] = 'duck'

print(H[20]) # duck
print(H[99]) # None


保存key的列表就作为散列表来处理,这样可以迅速查找到指定的key。注意散列表的大小,虽然可以是任意数,但考虑到要让冲突解决算法能有效工作,应该选择为素数。
    
class HashTable:
    def __init__(self):
        self.size = 11
        self.slots = [None] * self.size
        self.data = [None] * self.size

    '''
    hashfunction方法采用了简单求余方法来实现散列函数,而冲突解决采用线性探测“加1”再散列函数。
    '''

    def put(self, key, data):
        hashvalue = self.hashfunction(key)

        if self.slots[hashvalue] == None:  # key不存在,未冲突
            self.slots[hashvalue] = key
            self.data[hashvalue] = data
        else:
            if self.slots[hashvalue] == key:
                self.data[hashvalue] = data  # replace
            else:  # key已存在,替换val
                nextslot = self.rehash(hashvalue)
                # 散列冲突,再散列,直到找到空槽或者key
                while self.slots[nextslot] != None and self.slots[nextslot] != key:
                    nextslot = self.rehash(nextslot)

                if self.slots[nextslot] == None:
                    self.slots[hashvalue] = key
                    self.data[hashvalue] = data
                else:
                    self.data[hashvalue] = data  # replace

    def get(self, key):
        startslot = self.hashfunction(key)  # 标记散列值为查找起点

        data = None
        stop = False
        found = False
        position = startslot
        # 找key,直到空槽或回到起点
        while self.slots[position] != None and not found and not stop:
            if self.slots[position] == key:
                found = True
                data = self.data[position]
            else: # 未找到key,再散列继续找
                position = self.rehash(position)
                if position == startslot:
                    stop = True  # 回到起点,停
        return data

    def hashfunction(self, key):
        return key % self.size

    def rehash(self, oldhash):  # 重新散列
        return (oldhash + 1) % self.size

    def __getitem__(self, key):
        return self.get(key)

    def __setitem__(self, key, data):
        self.put(key,data)



# 散列算法分析
散列在最好的情况下,可以提供O(1)常数级时间复杂度的查找性能,由于散列冲突的存在,查找比较次数就没有这么简单。
评估散列冲突的最重要信息就是负载因子λ,一般来说:
如果λ较小,散列冲突的几率较小,数据项通常会保存在其所属的散列槽中。
如果λ较大,意味着散列表填充较满,冲突会越来越多,冲突解决也越复杂,也就需要更多的比较来找到空槽;如果采用数据链的话,意味着每条链上的数据项增多。

如果采用线性探测的开放地址法来解决冲突(λ在0~1之间):
    成功的查找,平均需要比对次数为1/2 * (1 + 1/(1-λ))
    不成功的查找,平均比对次数为:1/2 * (1 + (1/(1-λ))**2)

9.MD5算法伪代码

//Note:All variables are unsigned 32 bits and wrap module 2**32 when calculating 
var int[64] r,k
//r specifies the per-round shift amounts
r[0..15] = {7,12,17,22 7,12,17,22 7,12,17,22 7,12,17,22}
r[16..31] = {5,9,14,20 5,9,14,20 5,9,14,20 5,9,14,20}
r[32..47] = {4,11,16,23 4,11,16,23 4,11,16,23 4,11,16,23}
r[48..63] = {6,10,15,21 6,10,15,21 6,10,15,21 6,10,15,21}

//Use binary integer part of the sined of integers as constants:
for i from 0 to 63
    k[i] := floor(abs(sin(i+1)) * 2**32)
    
// Initiallize variables:
var int h0 := 0x67452301
var int h1 := 0xEFCDAB89 
var int h0 := 0x98BADCFE
var int h0 := 0x10325476

// Pre-processing:
append "1" bit to message
append "0" bits until message length in bits = 448(mod 512)
append bit length of message as 64-bit little-endian integer to message

// Process the mesage in successivve 512-bit chunks:
// for each 512-bit chunk of message
//    break chunk into sixteen 32-bit little endian words w[i], 0<= i <= 1S

// Initialize hash value for this chunk:

var int a := h0
var int b := h1
var int c := h2
var int d := h3

// Main loop:
    for i from 1 to 63
        if 0<=i<=15 then
            f := (b and c) or ((not b) and d)
            g := i
		else if 16 <= i <= 31
            f := (d and b) or ((not d) and c)
            g := (5*i + 1) mod 16
		else if 32 <= i <= 47
            f := b xor c xor d
            g :=(3*i +5) mod 16
		else if 48 <= i <= 63
            f:= c xor (b or (not d))
            g := (7*i) mod 16
		
		temp := d
         d := c
		c := b
		b := leftrotate((a + f + k[i] + w[g]),r[i]) +b
		a := temp
	Next i
	//Add this chunk's hash to result so for:
    h0 := h0 + a
    h1 := h1 + b
	h2 := h2 + c
	h3 := h3 + d

10.完美散列函数的应用:非对称加密

# 基于数学难题的公开密钥加密
例如:两个特别大的素数P1+P2,其成绩PU=P1*P2用PU加密的信息,仅能用P1(或P2)解密,反之亦然。PU称为“公开密钥”--公钥--公之于众
P1(或者P2)称为“私有密钥”--私钥--要藏好勿泄露,进知道公钥PU,是无法在合理的时间内得到私钥P1的。

非对称加密通信:允许不可靠信道
假设传输信息的途径能够被任何人截取。
例如凯撒加密(对称加密),加密和解密的密钥是同一个A-(+3)-D,这个3就是密钥,如果被人截取,则加密被破解,如何让窃密者无法解密?

11.数字签名:防止篡改/抵赖

>Alice借了Bob的钱,写下一个电子拮据doc;
>Alice将Hash(doc),用自己的私钥AP1加密为AP1(Hash(doc));
>把doc和AP1(Hash(doc))一起发送给Bob;
>这样,Bob和所有人都可以用Alice的公钥APU来检查doc是不是原文;
Hash(doc) == APU(AP1(Hash(doc))) 
    (用私钥加密的信息只能用公钥解密,Alice的公钥APU是众所周知的)
>Alice也无法抵赖说,从来没写过借据doc;
    因为AP1(Hash(doc))在Bob和大家手里,没有私钥AP1的人是无法算出这个签名的,而且有Hash(doc)在大家手里,Alice当然也无法篡改借据doc的内容。

# 公钥的有效性
>某天,ALice说你们拿到的我的公钥APU,是假的!
有人冒名顶替我向大众发布了APU,这样就可以抵赖掉借据了?

>技术之外的解决方案:权威机构发行数字证书
大家信任权威机构(如证书中心、银行等)
每个人通过线下等身份认证途径,向权威机构申请证书。权威机构用自己的私钥AuthP1来加密Alice提交的个人信息(包括公钥APU),这个AuthP1(AliceInfo+APU),就是权威机构签发的数字证书,可以用来证明持证者的身份。

散列练习:

设计算法重新实现散列表的put方法,使得散列表可以在负载因子达到一个预设值时自动调整大小。
提示:算法需要处理现有在散列表中的数据项,保证在扩充散列表之后能正常访问现有的数据项。可以采用伪代码说明算法。
posted on 2022-11-25 15:53  jueyuanfengsheng  阅读(20)  评论(0编辑  收藏  举报