測試開發基礎之算法(9):散列表原理及在Python中的應用

我們知道,數組具有一個特別強大的特性是,能夠根據下標隨機訪問數組元素,時間複雜度是O(1)。散列表(Hash表)正是列用了數組的這一特性,對數組進行了擴展,實現了針對非整型下標的高效存儲和訪問。

1. 散列思想

舉個簡單例子,假設運動員編碼是6位數字,要想將99名運動員的姓名按照運動員編號存入數組a中,數組下標對應運動員編號的後兩位,也就是數組下標1的位置a[1]存放編號爲030101的運動員姓名,數組下標2的位置a[2]存放編號爲04022的運動員姓名,以此類推,數組下標k處a[k]存放編號後兩位爲k的運動員姓名。存儲方式如下圖:
在這裏插入圖片描述
這裏面有一個關鍵是將運動員編號轉換成爲了數組下標。我們將運動員編號轉化爲數組下標的映射方法就叫作散列函數,運動員編號叫做,散列函數的結果值叫做散列值,在我們的這個例子,就是數組下標。這就是典型的散列思想。

下面我們分別介紹一下散列表中如何進行插入、查找和刪除數據。

  • 散列表的插入過程

下圖展示了散列表的插入過程:
在這裏插入圖片描述
鍵040202經過hash函數後得到值是2,所以將鍵040202對應的運動員姓名“小花”插入散列表下標爲2的位置。

  • 散列表的查找過程

當我們按照鍵(040202)查詢元素時,我們用同樣的散列函數,將鍵轉化數組下標,從對應的數組下標的位置取數據。以上面例子爲例,在查詢players[‘040202’]時,將040202經過散列函數運算得到2,再到散列表中查詢下標爲2的元素 a[2] 。

  • 判斷某個元素是否存在
    通過散列函數求出要查找元素的鍵對應的散列值,然後比較數組中下標爲散列值的元素和要查找的元素。如果相等,則說明就是我們要找的元素;否則就順序往後依次查找。如果遍歷到數組中的空閒位置,還沒有找到,就說明要查找的元素並沒有在散列表中。
    在這裏插入圖片描述
  • 散列表的刪除操作
    從散列表中刪除元素,不能單純地把要刪除的元素設置爲空,而是應該特殊標記爲 deleted。爲什麼不能直接將散列表的數據設置爲空呢?因爲這樣會導致原來的查找算法失效。我們拿一張圖來解釋:
    在這裏插入圖片描述
    在刪除散列表中下標爲1的元素後,我們將下標爲1的元素置爲deleted,當線性探測查找y的時候,遇到標記爲 deleted 的空間,並不是停下來,而是繼續往下探測,從而能夠找到y。但是如果將我們將下標爲1的元素置爲空,在當線性探測查找y的時候,探測到下標爲1的時候,就停止了,這樣就找不到y了。

2. 散列函數

可以看到在散列表中,散列函數至關重要。我們可以把它定義成 hash(key),其中 key 表示元素的鍵,hash(key) 的返回值表示經過散列函數計算得到的散列值。
針對上面的例子,散列函數可以定義成如下所示:

def hash(key):
    return int(key[-2:])

上面的例子,運動員的編號是比較有規律的,但是如果是6位隨機字符串,那麼散列函數如何設計呢?
首先,因爲數組下標是從 0 開始的,所以散列函數生成的散列值也要是非負整數。
其次,相同的 key,經過散列函數得到的散列值也應該是相同的。
最後,不同的key,最好經過散列函數後得到的散列值也不相同。

滿足前面兩點要求的函數很好設計,第三點是比較難,如果不同的key 得到相同的散列值,我們叫發生了“散列衝突”。因此散列函數的設計關鍵是解決散列衝突。

常用的散列衝突解決方法有兩類,開放尋址法(open addressing)和鏈表法(chaining)。

  • 開放尋址法

開放尋址法的核心思想是,如果出現了散列衝突,我們就重新探測一個空閒位置,將其插入。重新探測新位置的方法,線性探測(Linear Probing)、二次探測和雙重散列。

線性探測,指的是某個數據經過散列函數散列之後,存儲位置已經被佔用了,我們就從當前位置開始,依次往後查找,看是否有空閒位置,直到找到爲止。用圖來表示就是:
在這裏插入圖片描述
上圖中,x經過散列函數散列後得到7,但是下標爲7的位置被佔用,所以要從下標爲7的位置順序往後查找空餘的地方,直到找到下標爲2的位置將其插入。

隨着散列表插入的數據越來越多,空閒的槽位越來越少,發生散列衝突的可能性大大增加了。極端情況下,我們要探測整個散列表才能找到插入的位置。除了線性探測之外,二次探測尋找空閒槽位的辦法是,每次探測的步長變成原來的二次方。雙重散列則是,遇到衝突後,換一個散列函數,看看新的散列函數能不能找到空閒槽位。

但是不管什麼辦法,都不能從根本上解決問題,散列衝突的根本原因還是由於空閒的槽位太少了,也就是裝載因子太高了。

  • 鏈表法

相比開放尋址法,鏈表法要簡單很多。鏈表法解決散列衝突的思路是,在散列表中,每個槽位都會對應一條鏈表,散列值相同的元素,都放到同一個槽位對應的鏈條中。

在這裏插入圖片描述
在插入時,通過散列函數計算槽位,將散列值放入到對應的鏈條中。在查找和刪除時,通過散列函數計算槽位,再遍歷鏈表進行查找和刪除。查找和刪除的時間複雜度與鏈條長度成正比O(k),鏈條長度k是n/m,n是散列值個數,m是槽位個數。

對比開放尋址法和鏈表法,鏈表法的適用性更強。但是對於小規模數據、裝載因子不高的散列表,比較適合用開放尋址法。

3.如何設計散列函數

關於散列函數的設計,我們要儘可能讓散列後的值隨機且均勻分佈,這樣會盡可能地減少散列衝突,即便衝突之後,分配到每個槽內的數據也比較均勻。除此之外,散列函數的設計也不能太複雜,太複雜就會太耗時間,也會影響散列表的性能。

4. 散列表的擴容

前面說的散列衝突的根本原因就是裝載因子過大。當裝載因子過大時,我們可以進行動態擴容,重新申請一個更大的散列表,將數據搬移到這個新散列表中,降低裝載因子。降低裝載因子,就會增加足夠的槽位,散列衝突的可能性就會降低。

前面的文章中,介紹過數組的擴容,數據搬移操作比較簡單。但是,針對散列表的擴容,數據搬移操作要複雜很多。我們需要通過散列函數重新計算每個數據的存儲位置。

插入一個數據,最好情況下,不需要擴容,最好時間複雜度是 O(1)。最壞情況下,散列表裝載因子過高,啓動擴容,我們需要重新申請內存空間,重新計算散列位置,並且搬移數據,所以時間複雜度是 O(n)。用攤還分析法,均攤情況下,時間複雜度接近最好情況,就是 O(1)。

5. 散列表的無序性

散列表這種數據結構雖然支持非常高效的數據插入、刪除、查找操作,但是散列表中的數據都是通過散列函數打亂之後無規律存儲的。

6.散列表在Python中的應用

  • 字典

Python中字典是一系列由鍵(key)和值(value)配對組成的元素的集合,相比於列表和元組,字典的性能更優,特別是對於查找、添加和刪除操作,字典都能在O(1)時間複雜度內完成。這個性能,正是來自於散列表的特性。

我們先來感受一下Python中字典相比列表和元祖這兩種數據結構,它的性能到底有多高呢?

比如電商後臺,存儲了每件產品的 ID、價格。現在的需求是,給定某件商品的 ID查找出其價格。
如果用列表數據結構,查找算法如下:

products_list = [(123, 10), (234, 8), (345, 19), (456, 20)]


def find_price(products, product_id):
    for _id, _price in products:
        if _id == product_id:
            return _price
        else:
            return None

通過這段代碼,我們可以發現,時間複雜度是O(n),即使使用二分查找,算法複雜度最快也是O(logn),更何況使用二分查找還要事先進行O(nlogn)時間複雜度的排序操作。

利用字典的數據結構,只需 O(1) 的時間複雜度就可以完成。原因就是字典的內部組成是一張哈希表,直接通過鍵的哈希值,找到其對應的值。

products_list = {123: 10, 234: 8, 345: 19, 456: 20}


def find_price(products, product_id):
    return products.get(product_id, None)
  • 集合

集合是高度優化的哈希表,裏面元素不能重複。
看下集合與列表和元祖的對比,它的性能如何。對上面的例子,提出新的需求,要找出這些商品有多少種不同的價格。如果用列表實現,代碼應該是如下:

products_list = [(123, 10), (234, 8), (345, 19), (456, 20), (567, 8)]


def find_unique_price(products):
    unique_price = []  
    for _, price in products:  # 一層循環
        if price not in unique_price:  # 二層循環,雖然沒用for但也是循環
            unique_price.append(price)
    return unique_price

代碼中有兩層循環,在最差情況下,需要 O(n^2) 的時間複雜度。

再來看看集合的實現方法。這裏少用一層循環,集合是高度優化的哈希表,裏面元素不能重複,並且其添加和查找操作只需 O(1) 的複雜度,那麼,總的時間複雜度就只有 O(n)。

products_list = [(123, 10), (234, 8), (345, 19), (456, 20), (567, 8)]


def find_unique_price(products):
    unique_price = set()
    for _, price in products:
        unique_price.add(price)
    return unique_price

7.Python字典的存儲結構和操作原理

上面通過例子以及與列表的對比,看到了字典和集合操作的高效性。不過,字典和集合爲什麼能夠如此高效,特別是查找、插入和刪除操作?因爲他們本質上都是一張散列表。

  • 字典,散列表存儲了哈希值(hash)、鍵和值這 3 個元素。
  • 集合,散列表中存儲志存出了哈希值(hash)、鍵。鍵對應的值並不關心,可以將其設置成任意值。

在老版本的Python中,字典的散列表結構如下:

--+-------------------------------+
  | 哈希值(hash)  鍵(key)  值(value)
--+-------------------------------+
0 |    hash0      key0    value0
--+-------------------------------+
1 |    hash1      key1    value1
--+-------------------------------+
2 |    hash2      key2    value2
--+-------------------------------+
. |           ...
__+_______________________________+

舉個例子,比如我有這樣一個字典:
{'name': 'mike', 'dob': '1999-01-01', 'gender': 'male'}
那麼它會存儲爲類似下面的形式:

entries = [
['--', '--', '--']
[-230273521, 'dob', '1999-01-01'],
['--', '--', '--'],
['--', '--', '--'],
[1231236123, 'name', 'mike'],
['--', '--', '--'],
[9371539127, 'gender', 'male']
]

通過前面散列表的介紹,隨着散列表的插入和刪除,散列表的存儲會越來越係數,浪費太多的空間。
爲了提高存儲空間的利用率,在新版的Python對其字典的存儲進行了改進,把索引和哈希值、鍵、值分開存儲。變成下面的樣子:

Indices
----------------------------------------------------
None | index | None | None | index | None | index ...
----------------------------------------------------

Entries
--------------------
hash0   key0  value0
---------------------
hash1   key1  value1
---------------------
hash2   key2  value2
---------------------
        ...
---------------------

那麼,剛剛的這個例子,在新的哈希表結構下的存儲形式,就會變成下面這樣:

indices = [None, 1, None, None, 0, None, 2]
entries = [
[1231236123, 'name', 'mike'],
[-230273521, 'dob', '1999-01-01'],
[9371539127, 'gender', 'male']
]

很明顯,這種存儲方式相比之前的存儲方式節約了大量內存。
清楚了字典的存儲方式,接下來看看如何進行字典的插入、查找和刪除操作。

  • 插入

當每次向字典或者集合中插入元素,Python會計算鍵的散列值hash(key),再計算這個元素應該插入哈希表的位置 index = hash(key) & mask,其中mask=PyDicMinSize - 1 ,如果哈希表中此位置是空的,那麼這個元素就會被插入其中。

這個過程可以用下面的圖來展示,比如插入a的過程,先計算a的哈希值,然後與7進行“與”運算,計算出index是0,如果哈希表中索引0處是空的,就將其插入。

而如果此位置已被佔用,Python 便會比較兩個元素的哈希值和鍵是否相等。

  • 若兩者都相等,則表明這個元素已經存在,如果值不同,則更新值。
  • 若兩個元素的鍵不相等,但是哈希值相等,也就是說明發生了哈希衝突,Python 便會繼續利用開放尋址法和鏈表法尋找表中空餘的位置,直到找到位置爲止。
    在這裏插入圖片描述
  • 查找

與插入類似,查找某一個元素時,Python先計算鍵的哈希值(hash(key),然後找到索引位置。再比較這個索引上,存儲的鍵和哈希值,是否與查找的元素和對應的哈希值一致。如果一致,則直接返回值。如果不一致,則繼續查找,直到找到空位或者拋出異常。

  • 刪除

當刪除某一個元素時,Python並不會立即刪除,而是先將其賦值爲一個特殊的值,等到重新調整散列表大小時,再將其刪除。

前面分析過,裝載因子太高時,發生散列衝突的概率就會加大,導致插入的速度降低。因此,Python爲了保證高效的字典和集合操作,通常會在保證裝載因子不高於2/3,當高於2/3時,Python就會申請更大內存,擴充散列表,這時,散列表中的所有元素都會重新計算散列值,重新排放。

字典在 Python3.7+ 是有序的數據結構,而集合是無序的,其內部的哈希表存儲結構,保證了其查找、插入、刪除操作的高效性。所以,字典和集合通常運用在對元素的高效查找、去重等場景。

8.練習題1:對 10 萬條 URL 訪問日誌,按照訪問次數對URL 排序

我們把這10萬條url的訪問數量,存入Python字典這種數據結構中。然後根據字典的值來進行排序,就可以了。方法類似於,對一個字典products_dict={“url1”: 10, 234: 8, 345: 19, 456: 20, 567: 8},按照值來進行從大到小排序:

products_dict = {"url1": 10, "url2": 8, "url3": 19, "url4": 20, "url5": 8}
print(sorted(products_dict.values(), reverse=True))
print(sorted(products_dict.items(), key=lambda x: x[1], reverse=True))

9.練習題2:有兩個字符串數組,每個數組大約有 10 萬條字符串,如何快速找出兩個數組中相同的字符串?

先遍歷一遍第一個數組,得到一個以字符串爲鍵,值爲任意值的字典A,然後遍歷第二個數組,如果元素在字典A裏,即是相同的字符串,時間複雜度是O(n)。解決思路如下:

list1 = ["hello", "world", "code"]
list2 = ["code", "change", "world"]

a = {x: 0 for x in list1}
common = []
for x in list2:  # 單層循環,時間複雜度是O(n)
    if x in a:  # 高效,時間複雜度是O(1)
        common.append(x)

print(common)

類似的,Word 文檔中單詞拼寫檢查功能,也是同樣的思路,將英文字典中所有單詞存入Python字典中。然後在word文檔中,對每一個單詞到Python字典中查找,如果找不到則表示單詞拼寫錯誤。

10. 動手實現一個類似Python字典的散列表

來自https://leetcode-cn.com/problems/design-hashmap/

不使用任何內建的哈希表庫設計一個哈希映射

具體地說,你的設計應該包含以下的功能

put(key, value):向哈希映射中插入(鍵,值)的數值對。如果鍵對應的值已經存在,更新這個值。
get(key):返回給定的鍵所對應的值,如果映射中不包含這個鍵,返回-1。 remove(key):如果映射中存在這個鍵,刪除這個數值對。

示例:

MyHashMap hashMap = new MyHashMap(); hashMap.put(1, 1);
hashMap.put(2, 2); hashMap.get(1); // 返回 1
hashMap.get(3); // 返回 -1 (未找到) hashMap.put(2, 1);
// 更新已有的值 hashMap.get(2); // 返回 1 hashMap.remove(2);
// 刪除鍵爲2的數據 hashMap.get(2); // 返回 -1 (未找到)

注意:

所有的值都在 [1, 1000000]的範圍內。 操作的總數目在[1, 10000]範圍內。 不要使用內建的哈希庫。

我們使用拉鍊法來實現一個散列表。它的思想很簡單,在哈希表中的每個位置上,用一個鏈表來存儲所有映射到該位置的元素。

  • 對於put(key,value)操作

先求出key的hash值,然後遍歷該位置上的鏈表,如果鏈表中包含key,則更新對應的value;如果鏈表中不包含key,則直接將(key,value)插入改鏈表中。

  • 對於get(key)操作:

求出key對應的hash值,遍歷該位置上的鏈表,如果key在鏈表中,則返回其對應的value,否則返回-1。

  • 對於remove(key)操作:

求出key的hash值,遍歷該位置上的鏈表,如果key在鏈表中,則將其刪除。

代碼如下:

class MyHashMap:

    def __init__(self):
        """
        Initialize your data structure here.
        """
        self.hash = [[] for _ in range(20011)]  # 開闢一個大數組,長度爲質數,注意這裏不能用 [[]] * 20011
        # 一般定義成離2的整次冪比較遠的質數,這樣取模之後衝突的概率比較低。

    def put(self, key: int, value: int) -> None:
        """
        value will always be non-negative.
        """
        t = key % 20011  # 求hash值
        for item in self.hash[t]:  # 遍歷哈希到的鏈表中,查找key,並更新值
            if item[0] == key:
                item[1] = value
                return  # 更新完之後,直接返回
        self.hash[t].append([key, value])  # 如果鏈表中找不到對應的key,將其新添到鏈表中

    def get(self, key: int) -> int:
        """
        Returns the value to which the specified key is mapped, or -1 if this map contains no mapping for the key
        """
        t = key % 20011
        for item in self.hash[t]:
            if item[0] == key:
                return item[1]
        return -1  # 可能哈希的位置,所對應的鏈表不爲空,但是不存在該值

    def remove(self, key: int) -> None:
        """
        Removes the mapping of the specified value key if this map contains a mapping for the key
        """
        t = key % 20011
        delete = []
        for item in self.hash[t]:
            if item[0] == key:
                delete = item  # remove方法,這裏可以偷懶,把對應的value值設爲-1,即表示它已經刪除
        if delete:
            self.hash[t].remove(delete)

總結

散列表示一種高效的數據結構,對於插入、查找和刪除都能做到O(1)的時間複雜度。理解散列表的原理,重點是理解散列函數的設計要求,如何解決散列衝突。散列表在Python語言中的應用是字典和集合這兩種數據結構,非常適合需要對元素進行高效查找和去重的場景。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章