1. 程式人生 > >python中的散列表演算法

python中的散列表演算法

python中的散列表演算法

內容摘自《流暢的python》一書

為了獲取 my_dict[search_key] 背後的值,Python 首先會呼叫
hash(search_key) 來計算 search_key 的雜湊值,把這個值最低
的幾位數字當作偏移量,在散列表裡查詢表元(具體取幾位,得看
當前散列表的大小)。若找到的表元是空的,則丟擲 KeyError 異
常。若不是空的,則表元裡會有一對 found_key:found_value。
這時候 Python 會檢驗 search_key == found_key 是否為真,如
果它們相等的話,就會返回 found_value。
如果 search_key 和 found_key 不匹配的話,這種情況稱為雜湊
衝突。發生這種情況是因為,散列表所做的其實是把隨機的元素映
射到只有幾位的數字上,而散列表本身的索引又只依賴於這個數字
的一部分。為了解決雜湊衝突,演算法會在雜湊值中另外再取幾位,
然後用特殊的方法處理一下,把新得到的數字再當作索引來尋找表
元。 若這次找到的表元是空的,則同樣丟擲 KeyError;若非
空,或者鍵匹配,則返回這個值;或者又發現了雜湊衝突,則重複
以上的步驟。圖 3-3 展示了這個演算法的示意圖
在這裡插入圖片描述

圖 3-3:從字典中取值的演算法流程圖;給定一個鍵,這個演算法要麼返回一個值,要麼丟擲 KeyError 異常新增新元素和更新現有鍵值的操作幾乎跟上面一樣。只不過對於前者,在發現空表元的時候會放入一個新元素;對於後者,在找到相對應的表元后,原表裡的值物件會被替換成新值。另外在插入新值時,Python 可能會按照散列表的擁擠程度來決定是否要重新分配記憶體為它擴容。如果增加了散列表的大小,那雜湊值所佔的位數和用作索引的位數都會隨之增加,這樣做的目的是為了減少發生雜湊衝突的概率。表面上看,這個演算法似乎很費事,而實際上就算 dict 裡有數百萬個元素,多數的搜尋過程中並不會有衝突發生,平均下來每次搜尋可能會有一到兩次衝突。在正常情況下,就算是最不走運的鍵所遇到的衝突的次數用一隻手也能數過來。瞭解 dict 的工作原理能讓我們知道它的所長和所短,以及從它衍生而來的資料型別的優缺點。下面就來看看 dict 這些特點背後的原因.

9:既然提到了整型,CPython 的實現細節裡有一條是:如果有一個整型物件,而且它能被存進
一個機器字中,那麼它的雜湊值就是它本身的值。

3.9.3 dict的實現及其導致的結果

下面的內容會討論使用散列表給 dict 帶來的優勢和限制都有哪些。

  1. 鍵必須是可雜湊的
    一個可雜湊的物件必須滿足以下要求。
    (1) 支援 hash() 函式,並且通過 hash() 方法所得到的雜湊
    值是不變的。
    (2) 支援通過 eq() 方法來檢測相等性。
    (3) 若 a == b 為真,則 hash(a) == hash(b) 也為真。
    所有由使用者自定義的物件預設都是可雜湊的,因為它們的雜湊值由
    id() 來獲取,而且它們都是不相等的。
    如果你實現了一個類的 eq
    方法,並且希望它是可
    雜湊的,那麼它一定要有個恰當的 hash 方法,保證在 a
    == b 為真的情況下 hash(a) == hash(b) 也必定為真。否則
    就會破壞恆定的散列表演算法,導致由這些物件所組成的字典和
    集合完全失去可靠性,這個後果是非常可怕的。另一方面,如
    果一個含有自定義的 eq 依賴的類處於可變的狀態,那就
    不要在這個類中實現 hash 方法,因為它的例項是不可散
    列的。
  2. 字典在記憶體上的開銷巨大
    由於字典使用了散列表,而散列表又必須是稀疏的,這導致它在空
    間上的效率低下。舉例而言,如果你需要存放數量巨大的記錄,那
    麼放在由元組或是具名元組構成的列表中會是比較好的選擇;最好
    10
    不要根據 JSON 的風格,用由字典組成的列表來存放這些記錄。用
    元組取代字典就能節省空間的原因有兩個:其一是避免了散列表所
    耗費的空間,其二是無需把記錄中欄位的名字在每個元素裡都存一
    遍。
    在使用者自定義的型別中,slots 屬性可以改變例項屬性的儲存
    方式,由 dict 變成 tuple,相關細節在 9.8 節會談到。
    記住我們現在討論的是空間優化。如果你手頭有幾百萬個物件,而
    你的機器有幾個 GB 的記憶體,那麼空間的優化工作可以等到真正需
    要的時候再開始計劃,因為優化往往是可維護性的對立面。
  3. 鍵查詢很快
    dict 的實現是典型的空間換時間:字典型別有著巨大的記憶體開
    銷,但它們提供了無視資料量大小的快速訪問——只要字典能被裝
    在記憶體裡。正如表 3-5 所示,如果把字典的大小從 1000 個元素增
    加到 10 000 000 個,查詢時間也不過是原來的 2.8 倍,從 0.000163
    秒增加到了 0.00456 秒。這意味著在一個有 1000 萬個元素的字典
    裡,每秒能進行 200 萬個鍵查詢。
  4. 鍵的次序取決於新增順序
    當往 dict 裡新增新鍵而又發生雜湊衝突的時候,新鍵可能會被安
    排存放到另一個位置。於是下面這種情況就會發生:由
    dict([key1, value1), (key2, value2)] 和 dict([key2,
    value2], [key1, value1]) 得到的兩個字典,在進行比較的時
    候,它們是相等的;但是如果在 key1 和 key2 被新增到字典裡的
    過程中有衝突發生的話,這兩個鍵出現在字典裡的順序是不一樣
    的。
    示例 3-17 展示了這個現象。這個示例用同樣的資料建立了 3 個字
    典,唯一的區別就是資料出現的順序不一樣。可以看到,雖然鍵的
    次序是亂的,這 3 個字典仍然被視作相等的。
    示例 3-17 dialcodes.py 將同樣的資料以不同的順序新增到 3
    個字典裡

世界人口數量前10位國家的電話區號

在這裡插入圖片描述
➊ 建立 d1 的時候,資料元組的順序是按照國家的人口排名來決定
的。
➋ 建立 d2 的時候,資料元組的順序是按照國家的電話區號來決定
的。
➌ 建立 d3 的時候,資料元組的順序是按照國家名字的英文拼寫來
決定的。
➍ 這些字典是相等的,因為它們所包含的資料是一樣的。示例 3-
18 裡是上面例子的輸出。
示例 3-18 dialcodes.py 的輸出中,3 個字典的鍵的順序是不
一樣的
在這裡插入圖片描述
05. 往字典裡新增新鍵可能會改變已有鍵的順序
無論何時往字典裡新增新的鍵,Python 直譯器都可能做出為字典擴
容的決定。擴容導致的結果就是要新建一個更大的散列表,並把字
典裡已有的元素新增到新表裡。這個過程中可能會發生新的雜湊衝
突,導致新散列表中鍵的次序變化。要注意的是,上面提到的這些
變化是否會發生以及如何發生,都依賴於字典背後的具體實現,因
此你不能很自信地說自己知道背後發生了什麼。如果你在迭代一個
字典的所有鍵的過程中同時對字典進行修改,那麼這個迴圈很有可
能會跳過一些鍵——甚至是跳過那些字典中已經有的鍵。
由此可知,不要對字典同時進行迭代和修改。如果想掃描並修改一
個字典,最好分成兩步來進行:首先對字典迭代,以得出需要新增
的內容,把這些內容放在一個新字典裡;迭代結束之後再對原有字
典進行更新。
在 Python 3 中,.keys()、.items() 和 .values() 方
法返回的都是字典檢視。也就是說,這些方法返回的值更像集
合,而不是像 Python 2 那樣返回列表。檢視還有動態的特性,
它們可以實時反饋字典的變化。
現在已經可以把學到的有關散列表的知識應用在集合上面了

3.9.4 set的實現以及導致的結果

set 和 frozenset 的實現也依賴散列表,但在它們的散列表裡存放的
只有元素的引用(就像在字典裡只存放鍵而沒有相應的值)。在 set 加
入到 Python 之前,我們都是把字典加上無意義的值當作集合來用的。
在 3.9.3 節中所提到的字典和散列表的幾個特點,對集合來說幾乎都是
適用的。為了避免太多重複的內容,這些特點總結如下。
集合裡的元素必須是可雜湊的。
集合很消耗記憶體。
可以很高效地判斷元素是否存在於某個集合。
元素的次序取決於被新增到集合裡的次序。
往集合裡新增元素,可能會改變集合裡已有元素的次序。