數據結構與算法Python版 熟悉哈希表,了解Python字典底層實現


Hash Table

散列表(hash table)也被稱為哈希表,它是一種根據鍵(key)來存儲值(value)的特殊線性結構。

常用於迅速的無序單點查找,其查找速度可達到常數級別的O(1)。

散列表數據存儲的具體思路如下:

  • 每個value在放入數組存儲之前會先對key進行計算
  • 根據key計算出一個重復率極低的指紋
  • 根據這個指紋將value放入到數組的相應槽位中

同時查找的時候也將經歷同樣的步驟,以便能快速的通過key查出想要的value。

這一存儲、查找的過程也被稱為hash存儲、hash查找。

如圖所示:

image-20210614204610051

我們注意觀察,其實散列表中的每一個槽位不一定都會被占據,它是一種稀疏的數組結構,即有許多的空位,並不像list那種順序存放的結構一樣必須密不可分,這就導致了散列表無法通過index來進行value的操作。

散列表在Python中應用非常廣泛,如dict底層就是散列表實現,而dict也是經歷了上述步驟才將key-value進行存入的,后面會進行介紹。

名詞釋義

在學習Hash篇之前,介紹幾個基本的相關名詞:

  • 散列表(hash table):本身是一個普通的數組,初始狀態全是空的
  • 槽位(slot、bucket):散列表中value的存儲位置,用來保存被存入value的地方,每一個槽位都有唯一的編號
  • 哈希函數(hash function):如圖所示,它會根據key計算應當將被存入的value放入那一個槽位
  • 哈希值(hash value):哈希函數的返回值,也就是對數據項存放位置的結算結果

還有2個比較專業性的詞匯:

  • 散列沖突:打個比方,k1經過hash函數的計算,將v1存在了1號槽位上,而k22也經過了hash函數的計算,發現v2也應該存在1號槽位上。

    現在這種情況就發生了散列沖突,v2會頂替v1的位置進行存放,原本1號槽位的存放數據項會變為v2。

  • 負載因子:說白了就說這個散列表存放了多少數據項,如11個槽位的一個散列表,存放了6個數據項,那么該散列表的負載因子就是6/11

哈希函數

如何通過key計算出value所需要插入的槽位這就是哈希函數所需要思考的問題。

求余哈希法

如果我們的key是一串電話號碼,或者身份證號,如436-555-4601:

  • 取出數字,並將它們分成2位數(43,65,55,46,01)
  • 對它們進行相加,得到結果為210
  • 假設散列表共有11個槽位,現在使用210對11求余數,結果為1

那么這個key所對應的value就應當插入散列表中的1號槽位

平方取中法

平方取中法如下,現在我們的key是96:

  • 先計算它的平方值:96^2
  • 平方值為9216
  • 取出中間的數字:21
  • 假設散列表共有11個槽位,現在使用21對11求余數,結果為10

那么這個key所對應的value就應當插入散列表中的10號槽位

字符串求值

上面舉例的key都是int類型,如果是str類型該怎么做?

我們可以遍歷這個str類型的key,並且通過內置函數ord()來將它字符轉換為int類型:

>>> k = "hello"
>>> i = 0
>>> for char in k:
	i += ord(char)
>>> i
532

然后再將其對散列表長度求余,假設散列表共有11個槽位,現在使用532對11求余數,結果為4

那么這個key所對應的value就應當插入散列表中的4號槽位。

字符串問題

如果單純的按照上面的方式去做,那么一個字符完全相同但字符位置不同的key計算的hash結果將和上面key的hash結果一致,如下所示:

>>> k = "ollhe"
>>> i = 0
>>> for char in k:
	i += ord(char)
>>> i
532

如何解決這個問題呢?我們可以使用字符的位置作為權重進行解決:

image-20210614213058063

代碼設計如下:

def getHash(string):
    idx = 0
    hashValue = 0
    while idx < len(string):
        # ord()結果 * 權重
        hashValue += ord(string[idx]) * (idx + 1)
        idx += 1
    return hashValue

if __name__ == "__main__":
    print(getHash("hello"))
    print(getHash("ollhe"))

# 1617
# 1572

完美散列函數

為了應對散列沖突現象的發生,我們必須嚴格定制hash函數根據key生產hash值的這一過程,盡量做到每一個不同key產生的hash值都是不重復的,能做到這一點的hash函數被稱為完美散列函數。

如何設計完美散列函數?主要看該散列函數產生的散列值是否有以下特性:

  1. 壓縮性:任意長度的數據,得到的“指紋”長度是固定的
  2. 易計算性:從原數據計算“指紋”很容易
  3. 抗修改性:對原數據的微小變動,都會引起“指紋”的大改變
  4. 抗沖突性:已知原數據和“指紋”,要找到相同指紋的數據(偽造)是非常困難的

介紹2種產生散列函數的方案,MD5和SHA系列函數。

  • MD5(MessageDigest)將任何長度的數據變換為固定長為128位(16字節 )的“摘要”
  • SHA(SecureHashAlgorithm)是另一組散列函數
  • SHA-0/SHA-1輸出散列值160位(20字節)
  • SHA-256/SHA-224分別輸出256位、224位
  • SHA-512/SHA-384分別輸出512位和384位

128位二進制已經是一個極為巨大的數字空間:據說是地球沙粒的數量,MD5能達到這種效果。

160位二進制相當於10的48次方,地球上水分子數量估計是47次方,SHA-0能達到這種效果。

256位二進制相當於10的77方, 已知宇宙所有基本粒子大約是72~87次方,SHA-256能達到這種效果。

所以一般來說,MD5函數作為散列函數是非常合適的,而在Python中使用它們也非常簡單:

#! /usr/local/bin/python3
# -*- coding:utf-8 -*-

import hashlib
m = hashlib.md5("salt".encode("utf8"))
m.update("HELLO".encode("utf8"))
print(m.hexdigest())

# ad24f795146b59b78c145fbd6b7f4d1f

像這種方案,通常還被應用到一致性校驗中,如文件下載、網盤分享等。

只要改變任意一個字節,都會導致散列值發生巨大的變化。

散列沖突

如果兩個不同的key被散列映射到同一個槽位,則需要一個系統化的方法在散列表中保存第2個value。

這個過程稱為“解決沖突”,除了可以使用完美散列函數進行解決之外,以下也會介紹一些常見的解決辦法。

開放定址法

所謂的開放定址法就是一旦發生了沖突,就去尋找下一個空的散列地址,只要散列表足夠大,空的散列地址總能找到,並將記錄存入。

從沖突的槽開始往后掃描,直到碰到一個空槽如果到散列表尾部還未找到,則從首部接着掃描:

  • 這種尋找空槽的技術稱為“開放定址openaddressing”
  • 逐個向后尋找空槽的方法則是開放定址技術中的“線性探測linearprobing”

如下圖所示:

image-20210614204446421

它有一個缺點,就是會造成數據項扎堆形成聚集(clustering)的趨勢,這會影響到其他數據項的插入。

比如上圖中4號和5號槽位都被占據了,下次的v3本來是要插入到5號槽位的,但是5號槽位被v1占據了,它就只能再次向后查找:

image-20210614204402670

針對這個缺點,可以做一個優化措施,即線性探測的范圍從1變為3,每次向后查找3個槽位。

或者讓線性探測的范圍不固定,而是按照線性的趨勢進行增長,如第一次跳3個,第二次跳5個,第三次跳7個等等,也是較好的解決方案。

如果采用跳躍式探測方案,則需要注意:

  • 跳躍步數的取值不能被散列表大小整除,否則會產生周期性跳躍,從而造成很多空槽永遠無法被探測到

這里提供一個技巧,把散列表的大小設為素數,如11個槽位大小的散列表就永遠不會產生跳躍式探測方案的插槽浪費。

再哈希法

再哈希法又叫雙哈希法,有多個不同的hash函數,當發生沖突時,使用第二個,第三個,等哈希函數計算槽位,直到出現空槽位后再插入value。

雖然不易發生聚集,但是增加了計算時間。

鏈地址法

每個哈希表節點都有一個next指針,多個哈希表節點可以用next指針構成一個單向鏈表,被分配到同一個索引上的多個節點可以用這個單向鏈表向后排列。

如下圖所示:

image-20210614203200247

公共溢出區

將哈希表分為基本表和溢出表兩部分,凡是和基本表發生沖突的元素,一律填入溢出表。

當要根據key查找value時,先查找基本表,再查找溢出表。

ADT Map

思路解析

Python的dict是以一種key-value的鍵值對形式進行保存,也被稱之為映射。

我們如何使用Python的list來實現一個類似的數據結構呢?參照dict,有2大因素:

  • key必須具有唯一性,不可變
  • 通過key可以唯一的確定一個value

在做ADT Map之前,思考一下它應該具有哪些方法:

方法 描述
ADTMap() 創建一個空的映射,返回空映射對象
set() 將key-val加入映射中,如果key已存在,將val替換舊關聯值
get() 給定key,返回關聯的數據值,如不存在,則返回None
pop() 給定key,刪除鍵值對,返回value,如果key不存在,則拋出KeyError,不進行縮容進制
len() 返回映射中key-val關聯的數目
keys() 返回map的視圖,類似於dict.keys()
values() 返回map的視圖,類似於dict.values()
items() 返回map的視圖,類似於dict.items()
clear() 清空所有的key-val,觸發縮容機制
in 通過key in map的語句形式,返回key是否存在於關聯中,布爾值
[] 支持[]操作,與內置dict一致
for 支持for循環,與內置dict一致

我們都知道,Python3.6之后的dict是有序的,所以ADT Map也應該實現有序,減少遍歷次數。

Ps:詳情參見Python基礎dict一章

另外還需要思考:

  • 散列表應該是什么結構?
  • 采用怎樣的哈希函數?
  • 如何解決可能出現的hash沖突?
  • 如何做到動態擴容?

首先第一個問題,我們的散列表采用二維數組方式進行存儲,具體結果如下,初始散列表長度為8,內容全為None,與Python內置的dict初始容量保持一致:

[
	[hash值, key, value],
	[hash值, key, value],
	[hash值, key, value],
	...
]

第二個問題,這里采用字符串求值的哈希函數,也就是說key支持str類型

第三個問題,解決hash沖突采用開放定址+定性的線性探測

第四個問題,動態擴容也按照Python底層實現,即當容量超過三分之二時,進行擴容,擴容策略為已有散列表鍵值對個數 * 2,而在pop()時不進行縮容,但是在clear()會進行縮容,將散列表恢復初始狀態。

map實現

下面是按照Python的dict底層實現的動態擴容map:

#!/usr/bin/env python3
# -*- coding:utf-8 -*-

class ADTMap:
    def __init__(self) -> None:
        # 初始容量為8
        self.cap = 8
        # 已有鍵值對個數為0
        self.size = 0
        # 初始map
        self.map = [[None] * 3] * self.cap
        # map順序表
        self.order = [None] * self.cap

    def set(self, key, value):
        # 求hash值
        hashValue = self.__getHash(key)
        # 求插入或者更新槽位
        slotIdx = self.__getSlot(hashValue)
        # 檢查是否需要擴容, 當容量超過三分之二時,即進行擴容(resize)機制
        if (self.size + 1 > round(self.cap * (2 / 3))):
            self.__resize()
        # 添加鍵值對
        self.map[slotIdx] = [hashValue, key, value]
        self.size += 1
        # 添加順序表,如果是更新value,則不用添加
        for i in range(len(self.order)):
            if self.order[i] is None or slotIdx == self.order[i]:
                self.order[i] = slotIdx
                break

    def get(self, key):
        # 求hash值
        hashValue = self.__getHash(key)
        # 求key所在槽位
        slotIdx = self.__getSlot(hashValue)
        return self.map[slotIdx][2]

    def pop(self, key):
        # 求hash值
        hashValue = self.__getHash(key)
        # 求key所在槽位
        slotIdx = self.__getSlot(hashValue)
        if self.map[slotIdx][2] == None:
            raise KeyError("%s" % key)

        # 移除key
        self.size -= 1
        retValue = self.map[slotIdx][2]
        self.map[slotIdx] = [None] * 3
        for idx in range(len(self.order)):
            if self.order[idx] == slotIdx:
                # 刪除
                del self.order[idx]
                # 在最后添加空的,確保前面都是有序的不會出現None
                self.order.append([None] * 3)
                break
        return retValue

    def keys(self):
        for idx in self.order:
            if idx is not None:
                yield self.map[idx][1]
            else:
                break

    def values(self):
        for idx in self.order:
            if idx is not None:
                yield self.map[idx][2]
            else:
                break

    def items(self):
        for idx in self.order:
            if idx is not None:
                yield self.map[idx][1], self.map[idx][2]
            else:
                break

    def clear(self):
        self.cap = 8
        self.size = 0
        self.map = [[None] * 3] * self.cap
        self.order = [None] * self.cap

    def __setitem__(self, name, value):
        self.set(key=name, value=value)

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

    def __delitem__(self, name):
        # del map["k1"] 無返回值
        self.pop(key=name)

    def __contains__(self, item):
        keyList = self.keys()
        for key in keyList:
            if key == item:
                return True
        return False

    def __iter__(self):
        # 直接迭代map則返回keys列表
        return self.keys()

    def __getHash(self, key):
        # int類型的keyhash值是其本身
        if isinstance(key, int):
            return key
        # str類型需要使用ord()進行轉換,並添加位權
        if isinstance(key, str):
            idx = 0
            v = 0
            while idx < len(key):
                v += ord(key[idx]) * (idx + 1)
                idx += 1
            return v

        # 暫不支持其他類型
        raise KeyError("key not supported type %s" % (type(key)))

    def __getSlot(self, hashValue):
        # 求初始槽位
        slotIdx = hashValue % (self.cap)
        # 檢測沒有hash沖突的槽位
        return self.__checkSlot(slotIdx, hashValue)

    def __checkSlot(self, slotIdx, hashValue):
        # 獲取原有槽位的hash
        slotHash = self.map[slotIdx][0]

        # 如果原有槽位不為空,且與新的key值hash不同
        if slotHash is not None and slotHash != hashValue:
            # 避免線性探測超過散列表長度
            if slotIdx < self.cap - 1:
                return self.__checkSlot(slotIdx + 1, hashValue)
            # 如果線性探測超過散列表長度,則從頭開始探測
            return self.__checkSlot(0, hashValue)

        # 否則就是空槽位,或者舊hash與新hash相同,直接返回即可
        return slotIdx

    def __resize(self):
        # 計算新容量,已有散列表鍵值對個數 * 2
        self.cap += self.size * 2
        # 執行擴容
        self.map.extend(
            [[None] * 3] * (self.size * 2)
        )
        # 順序表也進行擴容
        self.order.extend([None] * (self.size * 2))

    def __len__(self):
        return self.size

    def __str__(self) -> str:
        retStr = ""
        for idx in self.order:
            if idx is not None:
                retStr += " <%r : %r> " % (self.map[idx][1], self.map[idx][2])
            else:
                break
        retStr = "[" + retStr + "]"
        return retStr


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM