【代码实战】哈希表的原理及实现

因本人最近在恶补数据结构与算法,学识经验有限,如有不正之处望读者指正,不胜感激;也望借此平台留下学习笔记以温故而知新。这一篇博客主要是朋友问我哈希表学的怎样了,问了几个问题都没有答上来,借此梳理下,希望对您有所帮助。

1、哈希表的定义

哈希表(Hash table,也叫散列表),是根据关键码值(Key value)直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。

记录的存储位置=f(关键字),这里的对应关系f称为散列函数,又称为哈希(Hash函数),采用散列技术将记录存储在一块连续的存储空间中,这块连续存储空间称为散列表或哈希表(Hash table)。

而当使用哈希表进行查询的时候,就是再次使用哈希函数将key转换为对应的数组下标,并定位到该空间获取value,如此一来,就可以充分利用到数组的定位性能进行数据定位。哈希查找是一种以O(1)时间复杂为目标的查找方式,效率极高。Python中的内置的字典结构dictionary,其key值的查找就是采用了哈希查找的方式,因而查询操作能够达到O(1)的时间复杂度。

散列表中每个位置被称为 Slot,这些Slot从0开始编号,开始时散列表为空,所有Slot被初始化为None。下图为一个长度为11的空散列表。

Figure1:长度为11的空散列表

将散列表中的元素和它所在的位置对应起来的映射被称为散列函数,给定一个元素,通过散列表能够获得其在散列表中的位置。假设我们有以下元素:54, 26, 93, 17, 77, 31。通过余数法(remainder method),即使用该元素除以散列表的长度所得余数作为哈希值(hash value),\boldsymbol{h(item) = item \% 11}

计算出每个元素对应的哈希值以后,我们便可以将元素插入到哈希表中。

长度为11的散列表中有6个Slot被占用了,则该散列表的载荷因子\small \lambda = \frac{numberofitems}{tablesize}\small \frac{6}{11} 。同时,如果有不同元素的余数相同则会发生碰撞(Collision),碰撞问题很大程度会影响散列表的查找速度。

2、哈希函数方法

2.1、除法散列法

最直观的一种,上图使用的就是这种散列法,公式: 
      index = value % 16 
学过汇编的都知道,求模数其实是通过一个除法运算得到的,所以叫“除法散列法”。

2.2、平方散列法 

求index是非常频繁的操作,而乘法的运算要比除法来得省时(对现在的CPU来说,估计我们感觉不出来),所以我们考虑把除法换成乘法和一个位移操作。公式: 
      index = (value * value) >> 28   (右移,除以2^28。记法:左移变大,是乘。右移变小,是除。)
如果数值分配比较均匀的话这种方法能得到不错的结果,但我上面画的那个图的各个元素的值算出来的index都是0——非常失败。也许你还有个问题,value如果很大,value * value不会溢出吗?答案是会的,但我们这个乘法不关心溢出,因为我们根本不是为了获取相乘结果,而是为了获取index。

2.3、斐波那契(Fibonacci)散列法

平方散列法的缺点是显而易见的,所以我们能不能找出一个理想的乘数,而不是拿value本身当作乘数呢?答案是肯定的。

1,对于16位整数而言,这个乘数是40503 
2,对于32位整数而言,这个乘数是2654435769 
3,对于64位整数而言,这个乘数是11400714819323198485

这几个“理想乘数”是如何得出来的呢?这跟一个法则有关,叫黄金分割法则,而描述黄金分割法则的最经典表达式无疑就是著名的斐波那契数列,即如此形式的序列:0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233,377, 610, 987, 1597, 2584, 4181, 6765, 10946,…。另外,斐波那契数列的值和太阳系八大行星的轨道半径的比例出奇吻合。

对我们常见的32位整数而言,公式: 
            index = (value * 2654435769) >> 28

3、哈希表的构造原则

  • 哈希表中项的个数最好为质数,这有利于冲突后的重新散列。
  • 散列函数应最大限度的减少“冲突”发生。
  • 在以开放寻址的方式解决冲突问题的同时,也应尽量避免“堆积”问题。
  • 当冲突大量发生时,开放寻址的时间成本将越来越高。此时更适合使用链接解决冲突。

4、哈希表的碰撞处理

4.1、开放寻址法

当两个元素被分配到同一个位置的时候,便会发生碰撞。解决碰撞的一个简单的办法就是在原理的 slot以后逐个寻找,直到找到下一个空的 slot来存放元素,这种方法被称为开放寻址法(Open Addressing)。线性探测(linear probing)每一次只搜寻一个 Slot。

使用余数法作为哈希函数将这组数据(54,26,93,17,77,31,44,55,20)存入散列表时,当准备存入44时,44%11=0,此时0这个位置已经被77占据了。根据线性探测法,Slot 1为空,将44放了Slot 2。后面的55和20采用同学的方法。

线性探测法的一个缺点是当多个元素具有相同的 hash value时会造成元素的聚集。比如上面例子中准备放入20时,前面的 Slots因为被占满了,而不得不向后寻求空位。

为了解决聚集问题,可以对 hash value 进行 “+1” 或者“+3”的操作,这实际上是rehash的过程。,其中  或者 。更一般的写法是 。skip的取值应该使得每个Slot都被探测到,同时 散列表的长度取质数也是这个原因。

4.2、平方探测(quadratic probing)

如果一开始的 have value 为 h的话,则后续的探测地址为 h+1,h+4,h+9,h+16。

4.3、链表(Chaining)

利用链表将相同的元素连接在一个 Slot上。

5、Map抽象数据类型Python字典实现

Python中一种很重要的数据结构是字典,字典存储的其实是键值-数值的关系对,键值被用来查找数值,这种键值和数值的对应关系通常被称为 Map。下面实现将新的键值对插入到字典的功能,使用余数法构造散列函数,“+1”法进行 rehash。

class HashTable:
    """
    self.slots列表用来存储键, self.data列表用来存储值.
    当我们通过键查找值时,键在 self.slots中的index即为值
    在 self.data中的index
    """
    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,len(self.slots)) #计算 hashvalue
      
        #如果 slots当前 hashvalue 位置上的值为None,则将新值插入
        if self.slots[hashvalue] == None: 
            self.slots[hashvalue] = key
            self.data[hashvalue] = data
        else:
            # 如果 slots 当前 hashvalue 位置上的值为key,则用新值替代旧值
            if self.slots[hashvalue] == key: 
                self.data[hashvalue] = data  
            else: # 如果 slots 当前 hashvalue 位置上的值为其他值的话,则开始探测后面的位置
                nextslot = self.rehash(hashvalue,len(self.slots)) # 重新 rehash,实际相当于探测 hashvalue后一个位置
                # 如果后一个位置不为空,且不等于当前值即被其他值占用,则继续探测后一个
                while self.slots[nextslot] != None and self.slots[nextslot] != key: 
                    nextslot = self.rehash(nextslot,len(self.slots))
                    
                # 如果后一个值为空,则插入;为原来的值,则替换
                if self.slots[nextslot] == None:
                    self.slots[nextslot]=key
                    self.data[nextslot]=data
                else:
                    self.data[nextslot] = data #replace
            
    
    """余数法计算 hashvalue"""
    def hashfunction(self,key,size): 
         return key%size
    
    """使用 +1 法来重新 rehash"""
    def rehash(self,oldhash,size):
        return (oldhash+1)%size
 
    def get(self,key):
        startslot = self.hashfunction(key,len(self.slots))
 
        data = None
        stop = False
        found = False
        position = startslot
        
        while self.slots[position] != None and  not found and not stop:
            if self.slots[position] == key: #如果slots当前位置上的值等于 key,则找到了对应的 value
                found = True
                data = self.data[position]
            else: # 否则的话,rehash后继续搜寻下一个可能的位置
                position=self.rehash(position,len(self.slots))
            if position == startslot: # 如果最后又回到了第一次搜寻的位置,则要找的 key不在 slots中
                stop = True
        return data
 
    def __getitem__(self,key):
        return self.get(key)
 
    def __setitem__(self,key,data):
        self.put(key,data)

6、哈希表实现Python字典

基于哈希表使用python来实现简单的“字典”结构:

  1. 拟将哈希表的长度设定为素数13。
  2. 哈希函数选择平方取中法和余数法相结合的方式,具体为:将key作为字符串看待,将每个字符的ASCII值相加再平方,所得的结果取中间三位数,最后再将其除以13,所得的余数即为哈希值。
  3. 重新散列函数采用向前间隔为3的线性探测。
class MyDictionary(object):
    # 字典类的初始化
    def __init__(self):
        self.table_size = 13 # 哈希表的大小
        self.key_list = [None]*self.table_size #用以存储key的列表
        self.value_list = [None]*self.table_size #用以存储value的列表
    
    # 散列函数,返回散列值
    # key为需要计算的key
    def hashfuction(self, key):
        count_char = 0
        key_string = str(key)
        for key_char in key_string: # 计算key所有字符的ASCII值的和
            count_char += ord(key_char) # ord()函数用于求ASCII值
        length = len(str(count_char))
        if length > 3 : # 当和的位数大于3时,使用平方取中法,保留中间3位
            mid_int = 100*int((str(count_char)[length//2-1])) \
                    + 10*int((str(count_char)[length//2])) \
                    + 1*int((str(count_char)[length//2+1]))
        else: # 当和的位数小于等于3时,全部保留
            mid_int = count_char
            
        return mid_int%self.table_size # 取余数作为散列值返回
        
    # 重新散列函数,返回新的散列值
    # hash_value为旧的散列值
    def rehash(self, hash_value):
        return (hash_value+3)%self.table_size #向前间隔为3的线性探测
        
    # 存放键值对
    def __setitem__(self, key, value):
        hash_value = self.hashfuction(key) #计算哈希值
        if None == self.key_list[hash_value]: #哈希值处为空位,则可以放置键值对
            pass
        elif key == self.key_list[hash_value]: #哈希值处不为空,旧键值对与新键值对的key值相同,则作为更新,可以放置键值对
            pass
        else: #哈希值处不为空,key值也不同,即发生了“冲突”,则利用重新散列函数继续探测,直到找到空位
            hash_value = self.rehash(hash_value) # 重新散列
            while (None != self.key_list[hash_value]) and (key != self.key_list[hash_value]): #依然不能插入键值对,重新散列
                hash_value = self.rehash(hash_value) # 重新散列
        #放置键值对      
        self.key_list[hash_value] = key
        self.value_list[hash_value] = value

    # 根据key取得value
    def __getitem__(self, key):
        hash_value = self.hashfuction(key) #计算哈希值
        first_hash = hash_value #记录最初的哈希值,作为重新散列探测的停止条件
        if None == self.key_list[hash_value]: #哈希值处为空位,则不存在该键值对
            return None
        elif key == self.key_list[hash_value]: #哈希值处不为空,key值与寻找中的key值相同,则返回相应的value值
            return self.value_list[hash_value]
        else: #哈希值处不为空,key值也不同,即发生了“冲突”,则利用重新散列函数继续探测,直到找到空位或相同的key值
            hash_value = self.rehash(hash_value) # 重新散列
            while (None != self.key_list[hash_value]) and (key != self.key_list[hash_value]): #依然没有找到,重新散列
                hash_value = self.rehash(hash_value) # 重新散列
                if hash_value == first_hash: #哈希值探测重回起点,判断为无法找到了
                    return None
            #结束了while循环,意味着找到了空位或相同的key值
            if None == self.key_list[hash_value]: #哈希值处为空位,则不存在该键值对
                return None
            else: #哈希值处不为空,key值与寻找中的key值相同,则返回相应的value值
                return self.value_list[hash_value]
    
    # 删除键值对
    def __delitem__(self, key):
        hash_value = self.hashfuction(key) #计算哈希值
        first_hash = hash_value #记录最初的哈希值,作为重新散列探测的停止条件
        if None == self.key_list[hash_value]: #哈希值处为空位,则不存在该键值对,无需删除
            return
        elif key == self.key_list[hash_value]: #哈希值处不为空,key值与寻找中的key值相同,则删除
            self.key_list[hash_value] = None
            self.value_list[hash_value] = None
            return
        else: #哈希值处不为空,key值也不同,即发生了“冲突”,则利用重新散列函数继续探测,直到找到空位或相同的key值
            hash_value = self.rehash(hash_value) # 重新散列
            while (None != self.key_list[hash_value]) and (key != self.key_list[hash_value]): #依然没有找到,重新散列
                hash_value = self.rehash(hash_value) # 重新散列
                if hash_value == first_hash: #哈希值探测重回起点,判断为无法找到了
                    return
            #结束了while循环,意味着找到了空位或相同的key值
            if None == self.key_list[hash_value]: #哈希值处为空位,则不存在该键值对
                return
            else: #哈希值处不为空,key值与寻找中的key值相同,则删除
                self.key_list[hash_value] = None
                self.value_list[hash_value] = None
                return
    
    # 返回字典的长度
    def __len__(self):
        count = 0
        for key in self.key_list:
            if key != None:
                count += 1
        return count

def main():
    H = MyDictionary()
    H["kcat"]="cat"
    H["kdog"]="dog"
    H["klion"]="lion"
    H["ktiger"]="tiger"
    H["kbird"]="bird"
    H["kcow"]="cow"
    H["kgoat"]="goat"
    H["pig"]="pig"
    H["chicken"]="chicken"
    print("字典的长度为%d"%len(H))
    print("键 %s 的值为为 %s"%("kcow",H["kcow"]))
    print("字典的长度为%d"%len(H))
    print("键 %s 的值为为 %s"%("kmonkey",H["kmonkey"]))
    print("字典的长度为%d"%len(H))
    del H["klion"]
    print("字典的长度为%d"%len(H))
    print(H.key_list)
    print(H.value_list)
    
if __name__ == "__main__":
    main()

运行结果如下:

字典的长度为9
键 kcow 的值为为 cow
字典的长度为9
键 kmonkey 的值为为 None
字典的长度为9
字典的长度为8
[None, 'kgoat', None, 'kcat', 'kbird', 'kdog', None, 'kcow', None, 'ktiger', 'chicken', 'pig', None]
[None, 'goat', None, 'cat', 'bird', 'dog', None, 'cow', None, 'tiger', 'chicken', 'pig', None]

参考资料

博客:https://blog.csdn.net/u010891397/article/details/87891546
博客:https://www.jianshu.com/p/a4f8a6f9f541

  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

西瓜情怀总是籽

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值