Python 雜湊表的實現——字典

鹹魚Linux運維發表於2023-11-24

哈嘍大家好,我是鹹魚

接觸過 Python 的小夥伴應該對【字典】這一資料型別都瞭解吧

雖然 Python 沒有顯式名稱為“雜湊表”的內建資料結構,但是字典是雜湊表實現的資料結構

在 Python 中,字典的鍵(key)被雜湊,雜湊值決定了鍵對應的值(value)在字典底層資料儲存中的位置

那麼今天我們就來看看雜湊表的原理以及如何實現一個簡易版的 Python 雜湊表

ps:文中提到的 Python 指的是 CPyhton 實現

何為雜湊表?

雜湊表(hash table)通常是基於“鍵-值對”儲存資料的資料結構

雜湊表的鍵(key)透過雜湊函式轉換為雜湊值(hash value),這個雜湊值決定了資料在陣列中的位置。這種設計使得資料檢索變得非常快

舉個例子,下面有一組鍵值對資料,其中歌手姓名是 key,歌名是 value

+------------------------------+
|   Key        |   Value       |
+------------------------------+
| Kanye        | Come to life  |
| XXXtentacion | Moonlight     |
| J.cole       | All My Life   |
| Lil wanye    | Mona Lisa     |
| Juice WRLD   | Come & Go     |
+------------------------------+

如果我們想要將這些鍵值對儲存在雜湊表中,首先需要將鍵的值轉換成雜湊表的陣列的索引,這時候就需要用到雜湊函式了

雜湊函式是雜湊表實現的主要關鍵,它能夠處理鍵然後返回存放資料的雜湊表中對應的索引

一個好的雜湊函式能夠在陣列中均勻地分佈鍵,儘量避免雜湊衝突(兩個鍵返回了相同的索引)

雜湊函式是如何處理鍵的,這裡我們建立一個簡易的雜湊函式來模擬一下(實際上雜湊函式要比這複雜得多)

def simple_hash(key, size):
    return ord(key[0]) % size

這個簡易版雜湊函式將歌手名(即 key)首字母的 ASCII 值與雜湊表大小取餘,得出來的值就是歌名(value)在雜湊表中的索引

那這個簡易版雜湊函式有什麼問題呢?聰明的你一眼就看出來了:容易出現碰撞。因為不同的鍵的首字母有可能是一樣的,就意味著返回的索引也是一樣的

例如我們假設雜湊表的大小為 10 ,我們以上面的歌手名作為鍵然後執行 simple_hash(key, 10) 得到索引

可以看到,由於Juice WRLDJ.cole 的首字母都一樣,雜湊函式返回了相同的索引,這裡就發生了雜湊碰撞

雖然幾乎不可能完全避免任何大量資料的碰撞,但一個好的雜湊函式加上一個適當大小的雜湊表將減少碰撞的機會

當出現雜湊碰撞時,可以使用不同的方法(例如開放定址法)來解決碰撞

應該設計健壯的雜湊函式來儘量避免雜湊碰撞

我們再來看其他的鍵,Kanye 透過 simple_hash() 函式返回 index 5,這意味著我們可以在索引 5 (雜湊表的第六個元素)上找到 其鍵 Kanye 和值Come to life

雜湊表優點

在雜湊表中,是根據雜湊值(即索引)來尋找資料,所以可以快速定位到資料在雜湊表中的位置,使得檢索、插入和刪除操作具有常數時間複雜度 O(1) 的效能

與其他資料結構相比,雜湊表因其效率而脫穎而出

不但如此,雜湊表可以儲存不同型別的鍵值對,還可以動態調整自身大小

Python 中的雜湊表實現

在 Python 中有一個內建的資料結構,它實現了雜湊表的功能,稱為字典

Python 字典(dictionary,dict)是一種無序的、可變的集合(collections),它的元素以 “鍵值對(key-value)”的形式儲存

字典中的 key 是唯一且不可變的,這意味著它們一旦設定就無法更改

my_dict = {"Kanye": "Come to life", "XXXtentacion": "Moonlight", "J.cole": "All My Life"}

在底層,Python 的字典以雜湊表的形式執行,當我們建立字典並新增鍵值對時,Python 會將雜湊函式作用於鍵,從而生成雜湊值,接著雜湊值決定對應的值將儲存在記憶體的哪個位置中

所以當你想要檢索值時,Python 就會對鍵進行雜湊,從而快速引導 Python 找到值的儲存位置,而無需考慮字典的大小

my_dict = {}
my_dict["Kanye"] = "Come to life" # 雜湊函式決定了 Come to life" 在記憶體中的位置
print(my_dict["Alice"]) # "Come to life" 

可以看到,我們透過方括號[key]來訪問鍵對應的值,如果鍵不存在,則會報錯

print(my_dict["Kanye"])  # "Come to life" 

# Raises KeyError: "Drake"
print(my_dict["Drake"])

為了避免該報錯,我們可以使用字典內建的 get() 方法,如果鍵不存在則返回預設值

print(my_dict.get('Drake', "Unknown")) # Unknown

在 python 中實現雜湊表

首先我們定義一個 HashTable 類,表示一個雜湊表資料結構

class HashTable:
    def __init__(self, size):
        self.size = size
        self.table = [None]*size

    def _hash(self, key):
        return ord(key[0]) % self.size

在建構函式 __init__() 中:

  • size 表示雜湊表的大小
  • table是一個長度為 size 的陣列,被用作雜湊表的儲存結構。初始化時,陣列的所有元素都被設為 None,表示雜湊表初始時不含任何資料

在內部函式 _hash() 中,用於計算給定 key 的雜湊值。它採用給定鍵 key 的第一個字元的 ASCII 值,並使用取餘運算 % 將其對映到雜湊表的索引範圍內,以便確定鍵在雜湊表中的儲存位置。

然後我們接著在 HashTable 類中新增對鍵值對的增刪查方法

class HashTable:
    def __init__(self, size):
        self.size = size
        self.table = [None]*size

    def _hash(self, key):
        return ord(key[0]) % self.size

    def set(self, key, value):
        hash_index = self._hash(key)
        self.table[hash_index] = (key, value)

    def get(self, key):
        hash_index = self._hash(key)
        if self.table[hash_index] is not None:
            return self.table[hash_index][1]

        raise KeyError(f'Key {key} not found')

    def remove(self, key):
        hash_index = self._hash(key)
        if self.table[hash_index] is not None:
            self.table[hash_index] = None
        else:
            raise KeyError(f'Key {key} not found')

其中,set() 方法將鍵值對新增到表中,而 get() 該方法則透過其鍵檢索值。該 remove() 方法從雜湊表中刪除鍵值對

現在,我們可以建立一個雜湊表並使用它來儲存和檢索資料:

# 建立雜湊表
hash_table = HashTable(10)

# 新增鍵值對
hash_table.set('Kanye', 'Come to life')
hash_table.set('XXXtentacion', 'Moonlight')

# 獲取值
print(hash_table.get('XXXtentacion'))  # Outputs: 'Moonlight'

# 刪除鍵值對
hash_table.remove('XXXtentacion')

# 報錯: KeyError: 'Key XXXtentacion not found'
print(hash_table.get('XXXtentacion'))

前面我們提到過,雜湊碰撞是使用雜湊表時不可避免的一部分,既然 Python 字典是雜湊表的實現,所以也需要相應的方法來處理雜湊碰撞

在 Python 的雜湊表實現中,為了避免雜湊衝突,通常會使用開放定址法的變體之一,稱為“線性探測”(Linear Probing)

當在字典中發生雜湊衝突時,Python 會使用線性探測,即從雜湊衝突的位置開始,依次往後查詢下一個可用的插槽(空槽),直到找到一個空的插槽來儲存要插入的鍵值對。

這種方法簡單直接,可以減少雜湊衝突的次數。但是,它可能會導致“聚集”(Clustering)問題,即一旦雜湊表中形成了一片連續的已被佔用的位置,新元素可能會被迫放入這片區域,導致雜湊表效能下降

為了緩解聚集問題,假若當雜湊表中存放的鍵值對超過雜湊表長度的三分之二時(即裝載率超過66%時),雜湊表會自動擴容

最後總結一下:

  • 在雜湊表中,是根據雜湊值(即索引)來尋找資料,所以可以快速定位到資料在雜湊表中的位置
  • Python 的字典以雜湊表的形式執行,當我們建立字典並新增鍵值對時,Python 會將雜湊函式作用於鍵,從而生成雜湊值,接著雜湊值決定對應的值將儲存在記憶體的哪個位置中
  • Python 通常會使用線性探測法來解決雜湊衝突問題