咱們要經過一個鍵key
來查找相應的值value
。有一種最簡單的方式,就是將鍵值對存放在鏈表裏,而後遍歷鏈表來查找是否存在key
,存在則更新鍵對應的值,不存在則將鍵值對連接到鏈表上。git
這種鏈表查找,最壞的時間複雜度爲:O(n)
,由於可能遍歷到鏈表最後也沒找到。github
有一種算法叫散列查找,也稱哈希查找,是一種空間換時間的查找算法,依賴的數據結構稱爲哈希表或散列表:HashTable
。算法
Hash: 翻譯爲散列,哈希,主要指壓縮映射,它將一個比較大的域空間映射到一個比較小的域空間。 簡單的說就是把任意長度的消息壓縮到某一固定長度的消息摘要的函數。Hash 算法雖然是一種算法,但更像一種思想,沒有一個固定的公式,只要符合這種思想的算法都稱 Hash 算法。
散列查找,主要是將鍵進行hash
計算得出一個大整數,而後與數組長度進行取餘,這樣一個比較大的域空間就只會映射到數組的下標範圍,利用數組索引快的特徵,用空間換時間的思路,使得查找的速度快於線性查找。編程
首先有一個大數組,每當存一個鍵值對時,先把鍵進行哈希,計算出的哈希值是一個整數,使用這個整數對數組長度取餘,映射到數組的某個下標,把該鍵值對存起來,取數據時按一樣的步驟進行查找。segmentfault
有兩種方式實現哈希表:線性探測法和拉鍊法。數組
線性探測法實現的哈希表是一個大數組。安全
首先,哈希表數據結構會初始化N
個大小的數組,而後存取鍵key
時,會求鍵的哈希值hash(key)
,這是一個整數。而後與數組的大小進行取餘:hash(key)%N
,將會知道該鍵值對要存在數組的哪一個位置。數據結構
若是數組該位置已經被以前的鍵值對佔領了,也就是哈希衝突,那麼會偏移加1,探測下個位置是否被佔用,若是下個位置爲空,那麼佔位,不然繼續探測。查找時,也是查看該位置是否爲該鍵,不是則繼續往該位置的下一個位置查找。由於這個步驟是線性的,因此叫線性探測法。併發
由於線性探測法不多使用,咱們接下來主要分析拉鍊法。app
拉鍊法實現的哈希表是一個數組鏈表,也就是數組中的元素是鏈表。數組鏈表很像一條條拉鍊,因此又叫拉鍊法查找。
首先,哈希表數據結構會初始化N
個大小的數組,而後存取鍵key
時,會求鍵的哈希值hash(key)
,這是一個整數。而後與數組的大小進行取餘:hash(key)%N
,將會知道該鍵值對要存在數組的哪一個位置。
若是數組該位置已經被以前的鍵值對佔領了,也就是哈希衝突,那麼鍵值對會追加到以前鍵值對的後面,造成一條鏈表。
好比鍵51
的哈希hash(51)
假設爲4
,那麼hash(51) % 4 = 4 % 4 = 0
,因此放在數組的第一個位置,一樣鍵43
的哈希hash(43)
假設爲8
,那麼hash(43) % 4 = 8 % 4 = 0
,一樣要放在數組的第一個位置。
由於哈希衝突了,因此鍵43
連接在鍵51
後面。
查找的時候,也會繼續這個過程,好比查找鍵43
,進行哈希後獲得位置0
, 定位到數組第一位,而後遍歷這條鏈表,先找到鍵51
,發現不到,往下找,直到找到鍵43
。
Golang
內置的數據類型:字典map
就是用拉鍊法的哈希表實現的,但相對複雜,感興趣的可參考標準庫runtime
下的map.go
文件。
當哈希衝突不嚴重的時候,查找某個鍵,只須要求哈希值,而後取餘,定位到數組的某個下標便可,時間複雜度爲:O(1)
。
當哈希衝突十分嚴重的時候,每一個數組元素對應的鏈表會愈來愈長,即便定位到數組的某個下標,也要遍歷一條很長很長的鏈表,就退化爲查找鏈表了,時間複雜度爲:O(n)
。
因此哈希表首先要解決的問題是尋找相對均勻,具備很好隨機分佈性的哈希函數hash()
,這樣纔不會扎堆衝突。
Golang
語言實現的哈希函數參考瞭如下兩種哈希算法:
固然還有其餘哈希算法如MurmurHash
:https://code.google.com/p/smhasher。
還有哈希算法如Md4
和Md5
等。
由於研究均勻隨機分佈的哈希算法,是屬於數學專家們的工做,咱們在此不展開了。
咱們使用號稱計算速度最快的哈希xxhash
,咱們直接用該庫來實現哈希:https://github.com/OneOfOne/xxhash:
實現以下:
package main import ( "fmt" "github.com/OneOfOne/xxhash" ) // 將一個鍵進行Hash func XXHash(key []byte) uint64 { h := xxhash.New64() h.Write(key) return h.Sum64() } func main() { keys := []string{"hi", "my", "friend", "I", "love", "you", "my", "apple"} for _, key := range keys { fmt.Printf("xxhash('%s')=%d\n", key, XXHash([]byte(key))) } }
輸出:
xxhash('hi')=16899831174130972922 xxhash('my')=13223332975333369668 xxhash('friend')=4642001949237932008 xxhash('I')=12677399051867059349 xxhash('love')=12577149608739438547 xxhash('you')=943396405629834470 xxhash('my')=13223332975333369668 xxhash('apple')=6379808199001010847
拿到哈希值以後,咱們要對結果取餘,方便定位到數組下標index
。若是數組的長度爲len
,那麼index = xxhash(key) % len
。
咱們已經尋找到了計算較快,且均勻隨機分佈的哈希算法xxhash
了,如今就是要解決取餘操做中的數組長度選擇的問題,數組的長度len
應該如何選擇?
好比數組長度len=8
,那麼取餘以後可能有這些結果:
xxhash(key) % 8 = 0,1,2,3,4,5,6,7
若是咱們選擇2^x
做爲數組長度有一個很好的優勢,就是計算速度變快了,以下是一個恆等式:
恆等式 hash % 2^k = hash & (2^k-1),表示截斷二進制的位數,保留後面的 k 位
這樣取餘%
操做將變成按位&
操做:
哈希表數組長度 len=8, 存在一個哈希值 hash=165,二進制表示爲 1010 0101 因此: 165 % 8 = 165 % 2^3 = 165 & (2^3-1) = 165 & 7 = 1010 0101 & 0000 0111 = 0000 0000 0101 = 5
選擇2^x
長度會使得計算速度更快,可是至關於截斷二進制後保留後面的k
位,若是存在不少哈希值的值很大,位數超過了k
位,而二進制後k
位都相同,那麼會致使大片哈希衝突。
即便如此,存在很大哈希值的狀況不多發生,大部分哈希值的二進制位數都不會超過k
位,所以編程語言Golang
使用了這種2^x
長度做爲哈希表的數組長度。
實際上hash(key) % len
的分佈是和len
有關的,一組均勻分佈的hash(key)
在len
是素數時才能作到均勻。
素數(prime number
),也叫質數,是指在大於1
的天然數中,除了1
和它自己之外再也不有其餘因數的天然數,也就是與任何數的最大公約數都爲1。
舉例以下:
f(n)爲哈希表的下標,哈希表的長度是 m,而哈希值是 n,記 w=gcd(m,n) 爲兩個數的最大公約數, 那麼: f(n) = n % m = n - a*m (a=0,1,2,3,4...) = w * (n/w-a*m/w) 由於 w=gcd(m,n),因此 (n/w-a*m/w) 是一個整數。 因此哈希表的下標 f(n) 只會是 w=gcd(m,n) 的倍數,倍數就註定了不會均勻分佈在 `[0,m-1]`,除非 w=1。 在哈希值數列數量特別多的狀況,對偶數和奇數數列進行取餘求下標,如長度 m=5 和 m=6: 哈希數值:2 4 6 8 10 12 14 16 18 20 22... m=5時下標:2 4 1 3 0 2 4 1 3 0 2... m=6時下標:2 4 0 2 4 0 2 4 0 2 4... 哈希數值:1 3 5 7 9 11 13 15 17... m=5時下標:1 3 0 2 4 1 3 0 2... m=6時下標:1 3 5 1 3 5 1 3 5... 偶數隊列能夠看到素數5一直重複 `2 4 1 3 0`,而合數6一直重複 `2 4 0`,只有素數均勻分佈。 奇數隊列能夠看到素數5一直重複 `1 3 0 2 4`,而合數6一直重複 `1 3 5`,只有素數均勻分佈。 將偶數和奇數數列合併起來,步長爲1時,素數和奇數都同樣均勻,僅當步長不爲1時的隨機數列,素數會更均勻點。
咱們實現拉鍊哈希表的時候,爲了數組擴容和計算更方便,仍然仍是使用2^x
的數組長度。
咱們將實現一個簡單的哈希表版本。
實現拉鍊哈希表有如下的一些操做:
2^x
個長度的數組,一開始x
較小。hash(key) & (2^x-1)
,定位到數組下標,查找數組下標對應的鏈表,若是鏈表有該鍵,更新其值,不然追加元素。hash(key) & (2^x-1)
,定位到數組下標,查找數組下標對應的鏈表,若是鏈表不存在該鍵,返回 false,不然返回該值以及 true。hash(key) & (2^x-1)
,定位到數組下標,查找數組下標對應的鏈表,若是鏈表不存在該鍵,直接返回,不然刪除該鍵。哈希查找的速度快,主要是利用空間換時間的優勢。若是哈希表的數組特別大特別大,那麼哈希衝突的概率就會下降。然而哈希表中的數組太大或過小都不行,太大浪費了空間,過小則哈希衝突太嚴重,因此須要對哈希表中的數組進行縮容和擴容。
如何伸縮主要根據哈希表的大小和已添加的元素數量來決定。假設哈希表的大小爲16
,已添加到哈希表中的鍵值對數量是8
,咱們稱8/16=0.5
爲加載因子factor
。
咱們能夠設定加載因子factor <= 0.125
時進行數組縮容,每次將容量砍半,當加載因子factor >= 0.75
進行數組擴容,每次將容量翻倍。
大部分編程語言實現的哈希表只會擴容,不會縮容,由於對於一個常常訪問的哈希表來講,縮容後會很快擴容,形成的哈希搬遷成本巨大,這個成本比起存儲空間的浪費還大,因此咱們在這裏只實現哈希表擴容。
咱們使用結構體HashMap
來表示哈希表:
const ( // 擴容因子 expandFactor = 0.75 ) // 哈希表 type HashMap struct { array []*keyPairs // 哈希表數組,每一個元素是一個鍵值對 capacity int // 數組容量 len int // 已添加鍵值對元素數量 capacityMask int // 掩碼,等於 capacity-1 // 增刪鍵值對時,須要考慮併發安全 lock sync.Mutex } // 鍵值對,連成一個鏈表 type keyPairs struct { key string // 鍵 value interface{} // 值 next *keyPairs // 下一個鍵值對 }
其中array
爲哈希表數組,capacity
爲哈希表的容量,capacityMask
爲容量掩碼,主要用來計算數組下標,len
爲實際添加的鍵值對元素數量。
咱們還使用了lock
來實現併發安全,防止併發增刪元素時數組伸縮,產生混亂。
使用expandFactor = 0.75
做爲擴容因子,沒什麼其餘的理由,只是它剛恰好,你也能夠設置成0.72
等任何值。
// 建立大小爲 capacity 的哈希表 func NewHashMap(capacity int) *HashMap { // 默認大小爲 16 defaultCapacity := 1 << 4 if capacity <= defaultCapacity { // 若是傳入的大小小於默認大小,那麼使用默認大小16 capacity = defaultCapacity } else { // 不然,實際大小爲大於 capacity 的第一個 2^k capacity = 1 << (int(math.Ceil(math.Log2(float64(capacity))))) } // 新建一個哈希表 m := new(HashMap) m.array = make([]*keyPairs, capacity, capacity) m.capacity = capacity m.capacityMask = capacity - 1 return m } // 返回哈希表已添加元素數量 func (m *HashMap) Len() int { return m.len }
咱們能夠傳入capacity
來初始化當前哈希表數組容量,容量掩碼capacityMask = capacity-1
主要用來計算數組下標。
若是傳入的容量小於默認容量16
,那麼將16
做爲哈希表的初始數組大小。不然將第一個大於capacity
的2 ^ k
值做爲數組的初始大小。
// 求 key 的哈希值 var hashAlgorithm = func(key []byte) uint64 { h := xxhash.New64() h.Write(key) return h.Sum64() } // 對鍵進行哈希求值,並計算下標 func (m *HashMap) hashIndex(key string, mask int) int { // 求哈希 hash := hashAlgorithm([]byte(key)) // 求下標 index := hash & uint64(mask) return int(index) }
首先,爲結構體生成一個hashIndex
方法。
根據公式hash(key) & (2^x-1)
,使用xxhash
哈希算法來計算鍵key
的哈希值,而且和容量掩碼mask
進行&
求得數組的下標,用來定位鍵值對該放在數組的哪一個下標下。
如下是添加鍵值對核心方法:
// 哈希表添加鍵值對 func (m *HashMap) Put(key string, value interface{}) { // 實現併發安全 m.lock.Lock() defer m.lock.Unlock() // 鍵值對要放的哈希表數組下標 index := m.hashIndex(key, m.capacityMask) // 哈希表數組下標的元素 element := m.array[index] // 元素爲空,表示空鏈表,沒有哈希衝突,直接賦值 if element == nil { m.array[index] = &keyPairs{ key: key, value: value, } } else { // 鏈表最後一個鍵值對 var lastPairs *keyPairs // 遍歷鏈表查看元素是否存在,存在則替換值,不然找到最後一個鍵值對 for element != nil { // 鍵值對存在,那麼更新值並返回 if element.key == key { element.value = value return } lastPairs = element element = element.next } // 找不到鍵值對,將新鍵值對添加到鏈表尾端 lastPairs.next = &keyPairs{ key: key, value: value, } } // 新的哈希表數量 newLen := m.len + 1 // 若是超出擴容因子,須要擴容 if float64(newLen)/float64(m.capacity) >= expandFactor { // 新建一個原來兩倍大小的哈希表 newM := new(HashMap) newM.array = make([]*keyPairs, 2*m.capacity, 2*m.capacity) newM.capacity = 2 * m.capacity newM.capacityMask = 2*m.capacity - 1 // 遍歷老的哈希表,將鍵值對從新哈希到新哈希表 for _, pairs := range m.array { for pairs != nil { // 直接遞歸Put newM.Put(pairs.key, pairs.value) pairs = pairs.next } } // 替換老的哈希表 m.array = newM.array m.capacity = newM.capacity m.capacityMask = newM.capacityMask } m.len = newLen }
首先使用鎖實現了併發安全:
m.lock.Lock() defer m.lock.Unlock()
接着使用哈希算法計算出數組的下標,並取出該下標的元素:
// 鍵值對要放的哈希表數組下標 index := m.hashIndex(key, m.capacityMask) // 哈希表數組下標的元素 element := m.array[index]
若是該元素爲空表示鏈表是空的,不存在哈希衝突,直接將鍵值對做爲鏈表的第一個元素:
// 元素爲空,表示空鏈表,沒有哈希衝突,直接賦值 if element == nil { m.array[index] = &keyPairs{ key: key, value: value, } }
不然,則遍歷鏈表,查找鍵是否存在:
// 鏈表最後一個鍵值對 var lastPairs *keyPairs // 遍歷鏈表查看元素是否存在,存在則替換值,不然找到最後一個鍵值對 for element != nil { // 鍵值對存在,那麼更新值並返回 if element.key == key { element.value = value return } lastPairs = element element = element.next }
當element.key == key
,那麼鍵存在,直接更新值,退出該函數。不然,繼續往下遍歷。
當跳出for element != nil
時,表示找不到鍵值對,那麼往鏈表尾部添加該鍵值對:
// 找不到鍵值對,將新鍵值對添加到鏈表尾端 lastPairs.next = &keyPairs{ key: key, value: value, }
最後,檢查是否須要擴容,若是須要則擴容:
// 新的哈希表數量 newLen := m.len + 1 // 若是超出擴容因子,須要擴容 if float64(newLen)/float64(m.capacity) >= expandFactor { // 新建一個原來兩倍大小的哈希表 newM := new(HashMap) newM.array = make([]*keyPairs, 2*m.capacity, 2*m.capacity) newM.capacity = 2 * m.capacity newM.capacityMask = 2*m.capacity - 1 // 遍歷老的哈希表,將鍵值對從新哈希到新哈希表 for _, pairs := range m.array { for pairs != nil { // 直接遞歸Put newM.Put(pairs.key, pairs.value) pairs = pairs.next } } // 替換老的哈希表 m.array = newM.array m.capacity = newM.capacity m.capacityMask = newM.capacityMask } m.len = newLen
建立了一個新的兩倍大小的哈希表:newM := new(HashMap)
,而後遍歷老哈希表中的鍵值對,從新Put
進新哈希表。
最後將新哈希表的屬性賦予老哈希表。
// 哈希表獲取鍵值對 func (m *HashMap) Get(key string) (value interface{}, ok bool) { // 實現併發安全 m.lock.Lock() defer m.lock.Unlock() // 鍵值對要放的哈希表數組下標 index := m.hashIndex(key, m.capacityMask) // 哈希表數組下標的元素 element := m.array[index] // 遍歷鏈表查看元素是否存在,存在則返回 for element != nil { if element.key == key { return element.value, true } element = element.next } return }
一樣先加鎖實現併發安全,而後進行哈希算法計算出數組下標:index := m.hashIndex(key, m.capacityMask)
,取出元素:element := m.array[index]
。
對鏈表進行遍歷:
// 遍歷鏈表查看元素是否存在,存在則返回 for element != nil { if element.key == key { return element.value, true } element = element.next }
若是鍵在哈希表中存在,返回鍵的值element.value
和true
。
// 哈希表刪除鍵值對 func (m *HashMap) Delete(key string) { // 實現併發安全 m.lock.Lock() defer m.lock.Unlock() // 鍵值對要放的哈希表數組下標 index := m.hashIndex(key, m.capacityMask) // 哈希表數組下標的元素 element := m.array[index] // 空鏈表,不用刪除,直接返回 if element == nil { return } // 鏈表的第一個元素就是要刪除的元素 if element.key == key { // 將第一個元素後面的鍵值對鏈上 m.array[index] = element.next m.len = m.len - 1 return } // 下一個鍵值對 nextElement := element.next for nextElement != nil { if nextElement.key == key { // 鍵值對匹配到,將該鍵值對從鏈中去掉 element.next = nextElement.next m.len = m.len - 1 return } element = nextElement nextElement = nextElement.next } }
刪除鍵值對,若是鍵值對存在,那麼刪除,不然什麼都不作。
鍵值對刪除時,哈希表並不會縮容,咱們不實現縮容。
一樣先加鎖實現併發安全,而後進行哈希算法計算出數組下標:index := m.hashIndex(key, m.capacityMask)
,取出元素:element := m.array[index]
。
若是元素是空的,表示鏈表爲空,那麼直接返回:
// 空鏈表,不用刪除,直接返回 if element == nil { return }
不然查看鏈表第一個元素的鍵是否匹配:element.key == key
,若是匹配,那麼對鏈表頭部進行替換,鏈表的第二個元素補位成爲鏈表頭部:
// 鏈表的第一個元素就是要刪除的元素 if element.key == key { // 將第一個元素後面的鍵值對鏈上 m.array[index] = element.next m.len = m.len - 1 return }
若是鏈表的第一個元素不匹配,那麼從第二個元素開始遍歷鏈表,找到時將該鍵值對刪除,而後將先後兩個鍵值對鏈接起來:
// 下一個鍵值對 nextElement := element.next for nextElement != nil { if nextElement.key == key { // 鍵值對匹配到,將該鍵值對從鏈中去掉 element.next = nextElement.next m.len = m.len - 1 return } element = nextElement nextElement = nextElement.next }
// 哈希表遍歷 func (m *HashMap) Range() { // 實現併發安全 m.lock.Lock() defer m.lock.Unlock() for _, pairs := range m.array { for pairs != nil { fmt.Printf("'%v'='%v',", pairs.key, pairs.value) pairs = pairs.next } } fmt.Println() }
遍歷哈希表比較簡單,粗暴的遍歷數組,若是數組中的鏈表不爲空,打印鏈表中的元素。
func main() { // 新建一個哈希表 hashMap := NewHashMap(16) // 放35個值 for i := 0; i < 35; i++ { hashMap.Put(fmt.Sprintf("%d", i), fmt.Sprintf("v%d", i)) } fmt.Println("cap:", hashMap.Capacity(), "len:", hashMap.Len()) // 打印所有鍵值對 hashMap.Range() key := "4" value, ok := hashMap.Get(key) if ok { fmt.Printf("get '%v'='%v'\n", key, value) } else { fmt.Printf("get %v not found\n", key) } // 刪除鍵 hashMap.Delete(key) fmt.Println("after delete cap:", hashMap.Capacity(), "len:", hashMap.Len()) value, ok = hashMap.Get(key) if ok { fmt.Printf("get '%v'='%v'\n", key, value) } else { fmt.Printf("get %v not found\n", key) } }
輸出:
cap: 128 len: 35 '20'='v20','16'='v16','4'='v4','32'='v32','2'='v2','28'='v28','24'='v24','10'='v10','9'='v9','15'='v15','12'='v12','29'='v29','3'='v3','19'='v19','30'='v30','27'='v27','14'='v14','13'='v13','22'='v22','7'='v7','11'='v11','23'='v23','1'='v1','31'='v31','18'='v18','17'='v17','8'='v8','26'='v26','25'='v25','0'='v0','5'='v5','34'='v34','21'='v21','6'='v6','33'='v33', get '4'='v4' after delete cap: 128 len: 34 get 4 not found
首先hashMap := NewHashMap(16)
新建一個16
容量的哈希表。而後往哈希表填充35
個鍵值對,遍歷打印出來hashMap.Range()
。
能夠看到容量從16
一直翻倍到128
,而打印出來的鍵值對是隨機的。
獲取鍵值對時:value, ok := hashMap.Get(key)
能正常獲取到值:get '4'='v4'
。
刪除鍵值對:hashMap.Delete(key)
後,哈希表的容量不變,但元素數量變少:after delete cap: 128 len: 34
。
嘗試再一次獲取鍵4
,報錯:get 4 not found
。
哈希表查找,是一種用空間換時間的查找算法,時間複雜度能達到:O(1)
,最壞狀況下退化到查找鏈表:O(n)
。但均勻性很好的哈希算法以及合適空間大小的數組,在很大機率避免了最壞狀況。
哈希表在添加元素時會進行伸縮,會形成較大的性能消耗,因此有時候會用到其餘的查找算法:樹查找算法。
樹查找算法在後面的章節會進行介紹。
我是陳星星,歡迎閱讀我親自寫的 數據結構和算法(Golang實現),文章首發於 閱讀更友好的GitBook。