資料結構與演算法Python版 熟悉雜湊表,瞭解Python字典底層實現

雲崖君發表於2021-06-15

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

相關文章