您的位置:首页 > 理论基础 > 数据结构算法

基于python的数据结构和算法(北京大学)散列表

2020-08-25 17:12 1336 查看
  • 散列表的引入
    通过构造一个新的数据结构,能使得查找算法的复杂度降到O(1),这种概念称为“散列Hashing”。
    能够使得查找的次数降低到常数级别,我们对数据项所处的位置就必须有更多的先验知识。
    如果我们事先能知道要找的数据项应该出现在数据集中的什么位置,就可以直接到那个位置看看数据项是否存在即可。

  • 什么是散列表?

    散列表(Hash table)又称哈希表,是一种种数据集,其中数据项的存储方式尤其有利于将来快速的查找定位。
    散列表中有多个“槽”(slot),用来保存数据项,每个槽都有唯一的名称,通过散列函数,将数据项放入特定的槽中,想要查找该数据项只需查找其在对应的槽中是否存在。

  • 散列函数:
    实现从数据到存储槽名称的转换的,成为散列函数(hash function)
    散列函数设计的核心是:不能成为存储过程和查找过程的计算负担。
    例如:一个包含了11个槽的空散列表,槽名称分别对应0~10,数据项54,26,33,17,77,31,通过散列函数 h(item) = item % 11,将其填入对应槽中。在该组数据项中33,77对11求余都是0,这里就存在一个散列冲突问题。
    一个好的散列函数需要具备:冲突最少、计算难度低、充分分散数据项。

  • 散列函数的一个例子:


    例子中的6个数据项插入后,占据了散列表11个槽中的6个。槽被数据项占据的比例称为散列表的“负载因子”,这里负载因子为 6/11。
    数据项都保存到散列表后,查找就无比简单。要查找某个数据项是否存在与表中,我们只需要使用同一个散列函数,对查找项进行计算,测试下返回的槽号所对应的槽中是否有数据项即可。实现了O(1)时间复杂度的查找算法。
    假如还要保存44,h(44)=0,他会跟77被分配到同一个槽中,这种情况称为“冲突collosion”。

  • 完美散列函数

    给定一个数据项,如果一个散列函数能把每个数据项映射到不同的槽中,那么这个散列函数就可以称为“完美散列函数”。对于固定的一组数据,总是能想办法设计出完美的散列函数。
  • 但如果数据项经常性的变动,很难有一个系统性的方法来设计对应的完美散列函数。当然,冲突也不是致命性的错误,有办法解决。
  • 获得完美散列函数的一种方法是扩大散列表的容量,大到所有可能出现的数据项都能够占据不同的槽。但这种方法对于可能数据项范围过大的情况并不适用。假如我们要保存手机号(11位数字),完美散列函数得要求散列表具有百亿个槽,会浪费太多存储空间。
  • 退而求其次,好的散列函数需要具备特性:冲突最少(近似完美)、计算难度低(额外开销小)、充分分散数据项(节约空间)。
  • 由于完美散列函数能够对任何不同的数据生成不同的散列值,如果把散列值当作数据的“指纹”或者“摘要”,这种特性被广泛应用在数据的一致性校验上。
  • 作为一致性校验的数据“指纹”函数需要具备如下的特性:
  1. 压缩性:任意长度的数据,得到的“指纹”长度是固定的。
  2. 易计算性:从原数据计算“指纹”很容易,从指纹计算原数据是不可能的。
  3. 抗修改性:对原数据的微小变动,都会引起“指纹”的大改变。
  4. 抗冲突性:已知原数据和“指纹”,要找到相同指纹的数据(伪造)是非常困难的。
  • 著名的散列函数:
    最著名的近似完美散列函数是MD5和SHA系列函数。
    MD5(Message Digest)将任何长度的数据变换为固定长为128位(16字节)的摘要。
    SHA(Secure Hash Algorithm)是另一组散列函数。
    SHA-0 / SHA-1 输出散列值160位(20字节)
    SHA-256 / SHA-224 分别输出256位、224位
    SHA-512 / SHA-384分别输出512位和384位

  • python中的散列库:

  • 完美散列函数的应用:


    • 区块链:
      区块链是一种分布式数据库,通过网络连接的节点,每个节点都保存着整个数据库所有数据,任何地点存入的数据都会完成同步。

    区块链最本质特征是“去中心化”,不存在任何控制中心、协调中心节点,所有节点都是平等的,无法被控制。

    区块链由一个个区块(block)组成,区块分为头(head)和体(body)。

    区块头记录了一些元数据和链接到前一个区块的信息,生成时间、前一个区块(head+body)的散列值。区块体记录了实际数据。

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

    工作量证明:Proof of Work(POW)

    由于区块链是大规模的分布式数据库,同步较慢,新区块的添加速度需要得到控制。目前最大规模区块链Bitcoin采用的速度是平均每10分钟生成一个区块。
    大家不惜付出海量的计算,去抢着算出一个区块的有效散列值,最先算出的那位“矿工”才有资格把区块挂到区块链中。
    因为有效散列值很难算出,所以控制了新区块生成的速度,便于在整个分布式网络中进行同步。
    每个区块设置了一个难度系数Difficulty,用常数targetmax除以它,得到一个target,难度系数越高,target越小。
    矿工的工作是,找到一个数值Nonce,把它跟整个区块数据一起计算散列,这个散列值必须小于target,才是有效的散列值。
    由于散列值无法回推原值,这个Nonce的寻找只能靠暴力穷举,计算工作量+运气是唯一的方法。
    在加密货币Bitcoin中,区块内包含的数据是“交易记录”,也就是“账本”,这对于货币体系至关重要。
    Bitcoin规定,每个区块中包含了一定数量的比特币作为“记账奖励”,这样就鼓励了更多人加入到抢先记账的行列。
    由于硬件摩尔定律的存在,计算力将持续递增,为了维持每10分钟生成一个区块的速度,难度系数Difficulty也将持续递增。另外,为了保持货币总量不会无限增加,每四年奖励的比特币减半。

    • 散列函数的设计:

      折叠法

    • 平方取中法

    • 非数项

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

    • 冲突解决方案:

      开放定址

    如果两个数据项被散列映射到同一个槽,需要一个系统化的方法在散列表中保存第二个数据项,这个过程称为“解决冲突”。前面提到,如果说散列函数是完美的,那就不会有散列冲突,但完美散列函数常常是不现实的,解决散列冲突成为散列方法中很重要的一部分。
    解决散列的一种方法就是为冲突的数据项再找一个开放的空槽来保存。最简单的就是从冲突的槽开始往后扫描,直到碰到一个空槽,如果到散列表尾部还未找到,则从首部接着扫描。
    这种寻找空槽的技术称为“开放定址 open addressing”,向后逐个槽寻找的方法则是开放定址技术中的“线性探测 linear probing”。

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

    线性探测法的一个缺点是有聚集(clustering)的趋势,即如果同一个槽冲突的数据较多的话,这些数据项就会在槽附近聚集起来,从而连锁式影响其他数据项的插入。
    
    避免聚集的一种方法就是将线性探测扩展从逐个探测改为跳跃式探测。下图是“+3”探测插入44,55,20。
    ![在这里插入图片描述](https://img-blog.csdnimg.cn/20200825170747482.png#pic_center)
    重新寻找空槽的过程可以用一个更为通用的“再散列rehashing”来概括。
    newhashvalue = rehash(oldhashvalue)
    对于线性探测:rehash(pos) = (pos+1) % sizeoftable
    对于跳跃式探测的再散列通式是:rehash(pos) = (pos+skip) % sizeoftable

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

    还可以将线性探测变为“二次探测quadratic probing”,不再固定skip的值,而是逐步增加skip值,如1,3,5,7,9,这样槽号就会是原散列值以平方数增加:h, h+1, h+4, h+9, h+16…

    • 数据项链Chaining
      除寻找空槽的开放定址技术之外,另一种解决散列冲突的方案是将容纳单个数据项的槽扩展为容纳数据项集合(或者对数据项链表的引用)。

      这样散列表中的每个槽就可以容纳多个数据项,如果有散列冲突发生,只需要简单地将数据项添加到数据项集合中。

      查找数据项时则需要查找同一个槽中的整个集合,当然,随着散列冲突的增加,对数据项的查找时间也会相应增加。

    • 抽象数据类型“映射”:ADT Map
      ADT Map的结构是键-值关联的无序集合。关键码具有唯一性,通过关键码可以唯一确定一个数据值。

      下面我们用一个HashTable类来实现ADT Map,该类包含了两个列表作为成员,其中一个slot列表用于保存key,另一个平行的data列表用于保存数据项。

      在slot列表查找到一个key的位置以后,在data列表对应相同位置的数据项即为关联数据。保存key的列表就作为散列表来处理,这样可以迅速查找到指定的key。
      hashfunction方法采用了简单求余方法来实现散列函数,而冲突解决则采用线性探测“加1”再散列函数。

    class HashTable:
    def __init__(self):
    self.size = 11
    self.slots = [None] * self.size
    self.data = [None] * 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:
    if self.slots[hashvalue] == key:  # key已存在,替换val
    self.data[hashvalue] = data
    else:
    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[nextslot] = key
    self.data[nextslot] = data
    else:
    self.data[nextslot] = data
    
    def hashfunction(self,key):
    return key % self.size
    
    def rehash(self,oldhash):
    return (oldhash+1) % self.size
    
    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 __getitem__(self, key):
    return self.get(key)
    
    def __setitem__(self, key, data):
    self.put(key,data)
    • 散列算法总结:
      散列在最好的情况下,可以提供O(1)常数级时间复杂度的查找性能,由于散列冲突的存在,查找比较次数就没有那么简单。

    评估散列冲突的最重要信息就是负载因子λ,一般来说:

    如果λ较小,散列冲突的几率就小,数据项通常会保存在其所属的散列槽中。
    如果λ较大,意味着散列表填充较满,冲突会越来越多,冲突解决也越复杂,也就需要更多的比较来找到空槽;如果采用数据链的话,意味着每条链上的数据项增多。
    如果采用线性探测的开放定址法来解决冲突(λ在0~1之间)。


    如果采用数据链来解决冲突(λ可大于1)。
    成功的查找,平均需要比对次数为:1+λ/2
    不成功的查找,平均比对次数为:λ

    内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
    标签: