前言
使用雜湊表可以進行非常快速的查詢操作。但是,雜湊表究竟是什麼玩意兒?很多人避而不談,雖然知道經常用到,很多語言的內建資料結構像python
中的字典,java
中的HashMap
,都是基於雜湊表實現。但雜湊表究竟是啥?
雜湊是什麼?
雜湊(hashing)是電腦科學中一種對資料的處理方法,通過某種特定的函式/演算法(稱為雜湊函式/演算法)將要檢索的項與用來檢索的索引(稱為雜湊,或者雜湊值)關聯起來,生成一種便於搜尋的資料結構(稱為雜湊表)。也譯為雜湊。舊譯雜湊(誤以為是人名而採用了音譯)。它也常用作一種資訊安全的實作方法,由一串資料中經過雜湊演算法(Hashing algorithms)計算出來的資料指紋(data fingerprint),經常用來識別檔案與資料是否有被竄改,以保證檔案與資料確實是由原創者所提供。
----Wikipedia
雜湊函式
所有的雜湊函式都具有如下一個基本特性:如果兩個雜湊值是不相同的(根據同一函式),那麼這兩個雜湊值的原始輸入也是不相同的。這個特性是雜湊函式具有確定性的結果,具有這種性質的雜湊函式稱為單向雜湊函式。
雜湊表
-
若關鍵字為
k
,則其值存放在f(k)
的儲存位置上。由此,不需比較便可直接取得所查記錄。稱這個對應關係f為雜湊函式,按這個思想建立的表為雜湊表。 -
對不同的關鍵字可能得到同一雜湊地址,即
k1≠k2
,而f(k1)=f(k2)
,這種現象稱為衝突。具有相同函式值的關鍵字對該雜湊函式來說稱做同義詞。綜上所述,根據雜湊函式f(k)
和處理衝突的方法將一組關鍵字對映到一個有限的連續的地址集(區間)上,並以關鍵字在地址集中的“像”作為記錄在表中的儲存位置,這種表便稱為雜湊表,這一對映過程稱為雜湊造表或雜湊,所得的儲存位置稱雜湊地址。 -
若對於關鍵字集合中的任一個關鍵字,經雜湊函式映象到地址集合中任何一個地址的概率是相等的,則稱此類雜湊函式為均勻雜湊函式(
Uniform Hash function
),這就是使關鍵字經過雜湊函式得到一個“隨機的地址”,從而減少衝突。
建立雜湊表
總的來說,雜湊表就是一個具備對映關係的表,你可以通過對映關係由鍵找到值。有沒有現成的例子?當然有,不過你直接用就沒意思了。
反正就是要實現f(k)
,即實現key-value
的對映關係。我們試著自己實現一下:
class Map:
def __init__(self):
self.items=[]
def put(self,k,v):
self.items.append((k,v))
def get(self,k):
for key,value in self.items:
if(k==key):
return value
複製程式碼
這樣實現的Map
,查詢的時間複雜度為O(n)
。
“這太簡單了,看上去與key
沒什麼關係啊,這不是順序查詢麼,逗我呢?”
這只是一個熱身,好吧,下面我們根據定義,來搞一個有對映函式的:
class Map:
def __init__(self):
self.items=[None]*100
def hash(self,a):
return a*1+0
def put(self,k,v):
self.items[hash(k)]=v
def get(self,k):
hashcode=hash(k)
return self.items[hashcode]
複製程式碼
“這hash
函式有點簡單啊”
是的,它是簡單,但簡單不妨礙它成為一個雜湊函式,事實上,它叫直接定址法,是一個線性函式:
hash(k)= a*k+b
“為啥初始化就指定了100
容量?”
必須要指出的是,這個是必須的。你想通過下標儲存並訪問,對於陣列來說,這不可避免。在JDK
原始碼裡,你也可以看到,Java
的HashMap
的初始容量設成了16
。你可能說,你這hash
函式,我只要key
設為100
以上,這程式就廢了。是啊,它並不完美。這涉及到擴容的事情,稍後再講。
直接定址法的優點很明顯,就是它不會產生重複的hash
值。但由於它與鍵值本身有關係,所以當鍵值分佈很散的時候,會浪費大量的儲存空間。所以一般是不會用到直接定址法的。
處理衝突
假如某個hash函式產生了一堆雜湊值,而這些雜湊值產生了衝突怎麼辦(實際生產環境中經常發生)?在各種雜湊表的實現裡,處理衝突是必需的一步。
比如你定義了一個hash
函式:
hash(k)=k mod 10
假設key
序列為:[15,1,24,32,55,64,42,93,82,76]
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
---|---|---|---|---|---|---|---|---|---|
1 | 32 | 93 | 24 | 15 | 76 | ||||
42 | 64 | 55 | |||||||
82 |
一趟下來,衝突的元素有四個,下面有幾個辦法。
開放定址法
開放定址法就是產生衝突之後去尋找下一個空閒的空間。函式定義為:
其中,hash(key)
是雜湊函式,di
是增量序列,i
為已衝突的次數。
- 線性探測法
即di=i
,或者其它線性函式。相當於逐個探測存放地址的表,直到查詢到一個空單元,然後放置在該單元。
[15,1,24,32,55,64,42,93,82,76]
可以看到,在55
之前都還沒衝突:
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
---|---|---|---|---|---|---|---|---|---|
1 | 32 | 24 | 15 |
此時插入55
,與15
衝突,應用線性探測,此時i=1
,可以得到:
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
---|---|---|---|---|---|---|---|---|---|
1 | 32 | 24 | 15 | 55 |
再插入64
,衝突不少,要取到i=3
:
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
---|---|---|---|---|---|---|---|---|---|
1 | 32 | 24 | 15 | 55 | 64 |
插入42
,i=1
:
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
---|---|---|---|---|---|---|---|---|---|
1 | 32 | 42 | 24 | 15 | 55 | 64 |
插入93
,i=5
:
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
---|---|---|---|---|---|---|---|---|---|
1 | 32 | 42 | 24 | 15 | 55 | 64 | 93 |
插入82
,i=7
:
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
---|---|---|---|---|---|---|---|---|---|
1 | 32 | 42 | 24 | 15 | 55 | 64 | 93 | 82 |
插入76
,i=4
:
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
---|---|---|---|---|---|---|---|---|---|
76 | 1 | 32 | 42 | 24 | 15 | 55 | 64 | 93 | 82 |
發現越到後面,衝突的越來越離譜。所以,表的大小選擇也很重要,此例中選擇了10
作為表的大小,所以容易產生衝突。一般來講,越是質數,mod取餘就越可能分佈的均勻。
- 平方探測
這稱作平方探測法,一個道理,也是查詢到一個空單元然後放進去。這裡就不一步一步說明了=。=
- 偽隨機探測
di
是一個隨機數序列。 “隨機數?那get的時候咋辦?也是隨機數啊,怎麼確保一致?” 所以說了,是偽隨機數。其實我們在計算機裡接觸的幾乎都是偽隨機數,只要是由確定演算法生成的,都是偽隨機。只要種子確定,生成的序列都是一樣的。序列都一樣,那不就可以了麼=。=
連結串列法
這是另外一種型別解決衝突的辦法,雜湊到同一位置的元素,不是繼續往下探測,而是在這個位置是一個連結串列,這些元素則都放到這一個連結串列上。java
的HashMap
就採用的是這個。
再雜湊
如果一次不夠,就再來一次,直到衝突不再發生。
建立公共溢位區
將雜湊表分為基本表和溢位表兩部分,凡是和基本表發生衝突的元素,一律填入溢位表(注意:在這個方法裡面是把元素分開兩個表來儲存)。
說了這麼一堆,舉個例子,用開放地址法(線性探測):
class Map:
def __init__(self):
self.hash_table=[[None,None]for i in range(11)]
def hash(self,k,i):
h_value=(k+i)%11
if self.hash_table[h_value][0]==k:
return h_value
if self.hash_table[h_value][0]!=None:
i+=1
h_value=self.hash(k,i)
return h_value
def put(self,k,v):
hash_v=self.hash(k,0)
self.hash_table[hash_v][0]=k
self.hash_table[hash_v][1]=v
def get(self,k):
hash_v=self.hash(k,0)
return self.hash_table[hash_v][1]
複製程式碼
“能不能不要定死長度?11個完全不夠用啊”
這是剛才的問題,所以有了另外一個概念,叫做載荷因子(load factor
)。載荷因子的定義為:
α= 已有的元素個數/表的長度
由於表長是定值, α與“填入表中的元素個數”成正比,所以, α越大,表明填入表中的元素越多,產生衝突的可能性就越大;反之,α越小,表明填入表中的元素越少,產生衝突的可能性就越小。實際上,雜湊表的平均查詢長度是載荷因子 α
的函式,只是不同處理衝突的方法有不同的函式。
所以當到達一定程度,表的長度是要變的,即resize
=。=像java
的HashMap
,載荷因子被設計為0.75
;超過0.8
,cpu
的cache missing
會急劇上升。可以看下這篇討論:
www.zhihu.com/question/22…
具體擴容多少,一般選擇擴到已插入元素數量的兩倍,java
也是這麼做的。
接著上面,再升級一下我們的map
:
class Map:
def __init__(self):
self.capacity=11
self.hash_table=[[None,None]for i in range(self.capacity)]
self.num=0
self.load_factor=0.75
def hash(self,k,i):
h_value=(k+i)%self.capacity
if self.hash_table[h_value][0]==k:
return h_value
if self.hash_table[h_value][0]!=None:
i+=1
h_value=self.hash(k,i)
return h_value
def resize(self):
self.capacity=self.num*2 #擴容到原有元素數量的兩倍
temp=self.hash_table[:]
self.hash_table=[[None,None]for i in range(self.capacity)]
for i in temp:
if(i[0]!=None): #把原來已有的元素存入
hash_v=self.hash(i[0],0)
self.hash_table[hash_v][0]=i[0]
self.hash_table[hash_v][1]=i[1]
def put(self,k,v):
hash_v=self.hash(k,0)
self.hash_table[hash_v][0]=k
self.hash_table[hash_v][1]=v
self.num+=1 #暫不考慮key重複的情況,具體自己可以優化
if(self.num/len(self.hash_table)>self.load_factor):# 如果比例大於載荷因子
self.resize()
def get(self,k):
hash_v=self.hash(k,0)
return self.hash_table[hash_v][1]
複製程式碼
看上面的函式,可以看到resize
是一個比較耗時的操作,因為只是原理教學,所以並沒有什麼奇淫技巧在裡面。可以去看一下Java
的HashMap
的hash
方法和resize
方法,還有處理衝突時的設計(jdk8
及之後的HashMap
用到了紅黑樹),其中的思路要精妙的多。
關於雜湊表,原理的東西都基本差不多了。可以看到,它本質要解決的是查詢時間的問題。如果順序查詢的話,時間複雜度為O(n)
;而雜湊表,時間複雜度則為O(1)
!直接甩了一個次元,這也就是為什麼在大量資料儲存查詢的時候,雜湊表得到大量應用的原因。