【轉】前端
這一節涉及數學超級多,各類數論知識,各類不明覺厲! 看了幾遍,才勉強看懂一些,因此這算法
篇稍微簡單的介紹着兩種hash table, 省得瞎說說錯了。數組
這一講的主要知識點是: 1. 全域哈希及構造 2. 完美哈希 數據結構
1. 全域哈希及構造框架
介紹全域哈希以前,要先討論一下普通哈希的一個缺點。 舉個charles舉得那個例子:若是你函數
和一個競爭對手同時爲一家公司作compiler的symbol table, 公司要求大家代碼共享性能
(o(╯□╰)o),大家作好後公司評判的標準就是 你倆互相提供一些測試樣例,誰的效率高就買 誰的。學習
而後, 普通哈希的缺點 就出來了:對任意的hash函數h,總存在一組keys,使得測試
, 對某個槽i。即我總能夠找到一組鍵值,讓他們都映射到同一個槽裏面,這樣效率搜索引擎
就跟離鏈表差很少了
解決的思想就是:獨立於鍵值, 隨機 的選擇hash 函數。這就跟快排中爲避免最差狀況時隨機化
版本差很少。可是選取hash function的全局域是不能亂定的,不然也打不到理想的性能。
下面就給出全域哈希的定義:
設U是key的全局域, 設\(\mathcal{H}\) 是哈希函數的有限集合,每個都是將U映射到
{0,1,..,m-1},即table的槽內。 若是對全部不等的\(x,y\in U\),有
換句話說,就是對於任意的不相等key的x和y, 從哈希函數集中選擇一個哈希函數,這兩個key
發生衝突的機率是1/m
更形象的,當我隨機選一個哈希函數時,就像在上圖區域亂扔一個飛鏢,落在下面紅色區域中
就會發生 衝突,這個機率是1/m
下面給一個定理,說明爲何全域函數就是好的:
設h是從哈希函數全域集\(\mathcal{H}\)中隨機選出的函數h. h被用做把任意n個鍵映射到表T的m個
槽中,對給定鍵值x,咱們有:
定理: E[#collision with x]<n/m
Proof: 設\(C_x\)是表示與key x衝突的鍵值數量的隨機變量,設\(c_{xy}\)是指示變量,即
則,\(E[c_{xy}]=1/m\) 且\(C_x=\sum_{y\in T-\{x\}}c_{xy}\),則
證畢!
這個定理想要說明的是,這種全域哈希的隨機化選擇能夠達到哈希表理想的效果。注意這裏
n/m是之 前定義過的load factor
如今給出一種 構造全域哈希 的方法:
首先選擇一個足夠大的質數p,使得全部的鍵值都在0-p-1之間。且設\(Z_p\)表示{0,1,...,p-1},設
\(Z_p^*\)表示{1,2,..,p-1}. 由於槽m的數量少於key的數量,全部m<p.
而後咱們就能夠設計哈希函數了,設任意的\(a\in Z_P^*,b\in Z_p\),而後
\(h_a,b(k)=((ak+b)mod p)mod m\)
全部這樣的哈希函數族爲:
\(\mathcal{H}_{p.m}=\{h_{a,b}:a\in Z_p^*, b\in Z_p\}\)
例如:選定p=17,m=6,\(h_{3,4}(8)=5\). 每一個哈希函數都是將\(Z_p\)映射到\(Z_m\). 咱們還
能夠看到這個哈希函數族共有p(p-1)個哈希函數
針對這種構造方法構造出的是全域哈希函數的證實就略過了,涉及數學知識確實比較多,講很差。
2. 完美哈希
當鍵值是static(即固定不變)的時候,咱們能夠涉及方案使得最差狀況下的查詢性能也很出色,這就是
完美哈希 。實際上,不少地方都會用到靜態關鍵字集合。好比一種語言的保留字集合,一張CD-ROM
裏的文件名集合。 而完美哈希能夠在最壞狀況下以O(1)複雜度查找,性能很是出色的。
完美哈希的思想就是採用兩級的框架,每一級上都用全域哈希
完美哈希的結構如上圖。具體來講,第一級和帶鏈表的哈希很是的類似,只是第一級發生衝突後後面接
的不是鏈表,而是一個新的哈希表。後面那個哈希結構,咱們能夠看到前端存儲了一些哈希表的基本
性質:m 哈希表槽數;a,b 全域哈希函數要肯定的兩個值(通常是隨機選而後肯定下來的),後面跟着
哈希表。
爲了保證不衝突,每一個二級哈希表的數量是第一級映射到這個槽中元素個數的平方,這樣能夠保證整個
哈希表很是的稀疏。 下面給出一個定理,能更清楚的看到設置m=n^2的做用
定理: 設\(\mathcal{H}\)是一類全域哈希函數,哈希表的槽數m=n^2. 那麼,若是咱們用一個隨機
函數\(h\in\mathcal{H}\)把n個keys映射到表中。衝突次數的指望最可能是1/2.
Proof:根據全域哈希的定義,對任意選出的哈希函數h,表中2個給定keys衝突的機率是1/m,即1/n^2
且總共有\(C_n^2\)可能的鍵值對,那麼衝突次數的指望就是
\(C_n^2\cdot 1/n^2=n(n-1)/2\cdot 1\n^2 < 1/2\) 證畢!
爲了衝突的理解從指望轉換到機率,引入下面這個推論
推論: 完美哈希沒有衝突的機率至少是1/2
Proof: 這裏主要要用到一個不等式Markov's inequality-對任意非負隨機變量X,咱們有
Pr{X≥t}≤E[x]/t
利用這個不等式,讓t=1,便可獲得衝突次數大於1的機率最多爲1/2
由於第二層每一個表槽的個數是這個表中元素n^2,可能會感受到這樣存儲空間會很大,實際上,能夠證
明\(E[\sum_{i=0}^{m-1}\Theta(n_i^2)]=\Theta(n)\), 由於證起來蠻複雜,因此我也略過了%>_<%
【轉】
先來看一個TopK題目: 搜索引擎會經過日誌文件把用戶每次檢索使用的全部檢索串都記錄下來,每一個查詢串的長度爲1-255字節。 假設目前有一千萬個記錄(這些查詢串的重複度比較高,雖然總數是1千萬,但若是除去重複後,不超過3百萬個。一個查詢串的重複度越高,說明查詢它的用戶越多,也就是越熱門。),請你統計最熱門的10個查詢串,要求使用的內存不能超過1G。 如何解答?Topk以前已經說過,尋找最小的K個數。 但是咱們如何處理Query呢?一千萬條記錄,每條記錄是255Byte,很顯然要佔據2.375G內存,很明顯不能用內部的排序,不管是什麼內部排序。這個時候能夠用外排序,歸併排序能夠解決。但是題目也說了除去重複最多300W,300W徹底能夠放入內存,但是如何把1000W的字符串放入內存呢?這就是咱們接下來要說的了,Hsah Table徹底能夠解決。 不要着急,聽我細細道來。 說哈希以前先來講一下直接尋址表,這個相似BloomFilter和位向量。若是關鍵字域比較小,也就是說關鍵字很少,並且都在必定範圍內。那咱們能夠徹底把關鍵字當成數組下標,每個關鍵字放入哈希表的一個槽。這也便是一一映射,映射結果不變化。
這個看起來蠻不錯的,操做也很簡單,每一個操做時間代價都是O(1).
確實很不錯。但是這只是關鍵字分佈較小範圍的時候纔會有做用,並且還要求關鍵字都不能相等。。。若是有100個整數,都是64位的,有的很小,有的超大。這個時候你定義的數組的大小豈不是2^64-1,你能忍受嗎?你還會用這種方法嗎?2個數,1和100000000,你定義的數組大小也必須是100000000,這樣才符合剛纔的直接尋址法。太浪費內存了吧。。 因此來講,直接尋址當然不錯,但是限制太多。關鍵字不重複,關鍵字的範圍要小。 接下來我正式的介紹一下哈希表。 什麼是哈希表?Hash Table也叫散列表。 哈希表是根據關鍵碼值(Key value)而直接進行訪問的數據結構。也就是說,它經過把關鍵碼值映射到表中一個位置來訪問記錄,以加快查找的速度,存放記錄的數組叫作哈希表。建好的Hash表的查詢速度是常數級O(1),這樣就比較nice了。 Hash映射就是用一個Hash函數將關鍵字key映射到Hash表的槽裏。這個映射不是一一映射了,即使是一個字符串也能映射成一個整數。對於剛纔的直接尋址表來講就是key映射到key槽,而Hash函數將key映射到H(key)槽裏。而咱們要定義的表的大小隻是關鍵字的數量,沒必要是關鍵字範圍的大小和關鍵字的重複,這樣就不會浪費內存。並且插入一個須要的時間也是O(1),不過咱們用Hash表大部分是爲了查詢,查詢的時間複雜度也是O(1)。
不幸的是這可能會出現問題,什麼問題?不一樣的關鍵字映射到相同的槽裏,碰撞collision出現了。這咋辦啊?關鍵字總不能捨棄吧?難道咱們指望他們不會碰撞?這是不現實的。不過不要着急,咱們能夠在那個槽的地方拉一個鏈表,將全部映射到這個槽的關鍵字都放到這個鏈表裏面。這就是所謂的拉鍊法,也叫連接法。
對於每個槽都給他拉一個鏈表,到時候凡是Hash值相同的映射到同一個槽的都要放到鏈表裏,鏈表的大小隨時變化。
查詢和插入的時間複雜度一樣是常數級O(1)的。解決了?NO。通常狀況下還好,若是全部的Hash值都相同了,也就說因此的關鍵字都映射到一個相同的槽裏,這Hash表就變成了鏈表了,並且內存佔用比鏈表還大。查詢的時間複雜度是O(N)。這不是坑嗎?那咱們還用它作什麼?不用了?唉唉唉,凡事總有例外,這個不能怪Hash表,只能說Hash函數太差了,若是選一個好的Hash函數,這個狀況就不會出現(NO,下文會有說明,想一想)。一個理想的Hash函數會將全部的關鍵字都平均均勻的映射到Hash表的不一樣槽裏。也就是說這個狀況是最壞的狀況,拉鍊法的平均狀況仍是很好的。 咱們來分析一下拉鍊法的平均狀況:假設對於每個關鍵字key都用相同的記錄映射到Hash表的任何一個槽裏,並且每個關鍵字key都是相互獨立的,這就是簡單一致Hash。假設有n個關鍵字,Hash表有m個槽。那麼有兩個key被Hash函數映射到同一個槽裏的機率有多大?1/m,互相獨立,互不影響。定義裝載因子α=n/m,即Hash表中每個槽中關鍵字的平均數量。(α>1 <1 =1)則對於Hash表的下標i的鏈表中的關鍵字數ni=α。則此時查詢用時O(1+α),包括查找成功和查找失敗。 定理:在簡單一致Hash的假設下,對於連接法解決碰撞的Hash表,平均狀況下成功查找和查找失敗用時都是O(1+α)。 α是槽裏關鍵字的平均數量。若是n=O(m),α=n/m=O(m)/m=O(1)。那麼查詢的時間複雜度就是常數級O(1)。通常狀況下m和n都是一個數量級。 爲了不連接法的最壞狀況,選擇一個好的Hash函數是相當重要的。
咱們來看一下經常使用的Hash函數。 除法哈希法,也叫除留餘數法。經過關鍵字除以槽數m將關鍵字映射到槽裏的方法。哈希函數是H(k)=k Mod m。 舉個例子,m=12,k=100,H(100)=4。 而若是m=2k,那麼不管k是什麼,H(K)的值都是一個0和奇數,也便是說只要奇數槽和0槽被佔用,其餘的偶數槽都是浪費掉了。若是m=2^r,那麼H(k)的值就是k的低r位(化成二進制)。這樣形成的後果是某一個槽有不少的關鍵字。因此來講通常的m取值儘可能不要接近2的整數冪,並且還要是質數。 這樣雖然很好了,但是除法畢竟在計算機運算是不快的,因此咱們再講一個乘法Hash。 乘法哈希法:用關鍵字乘A(0<A<1),取其結果的小數再乘以m取整。 Hash函數是H(k)=[m(kA Mod 1)].其優勢是對m沒有什麼要求,通常選擇2的整數冪(呵呵)。 假設計算機字長爲w位,把k化爲w位的二進制,A=s/2^w(<0s<2^w),m=2^p,則
這樣就比較好了,A取值沒啥要求。最好的A=(√5-1)/2。還有一些平方取中法,摺疊法等等。
說了這麼些Hash函數。再來看一下避免碰撞的方法。除了連接法以外還有別的方法避免collision嗎?固然,開放尋址法。 開放尋址,和連接法不一樣的是這沒有鏈表,全部的關鍵字都放入槽內,若是Hash值相同此槽已有關鍵字,則再次Hash查詢,直到找到一個空槽放入關鍵字key爲止。查詢序列也很關鍵,不過這是和第一次Hash值是有關係的。查詢序列不必定是0 1 2 3....m,但其實只是m!中的一個,Hash表有m!種查詢序列。對於每個關鍵字查詢序列是h(k,0),h(k,1),h(k,2)...h(k,m)。 查詢和插入都很方便,但是刪除確實很麻煩。
刪除麻煩在哪裏呢?由於咱們要Hash不少次,好比k=496,第一次Hash(496,0)=586,但是發現槽586處有關鍵字370,第二次Hash(496,1)=204,發現槽204處有關鍵字37,第二次Hash(496,2)=304,發現槽304空,放入關鍵字496.若是刪除值370,370在槽586處。而後我再查詢496,第一次Hash獲得586,發現槽位空,則說明496不存在,但是496明明是剛纔插入的。因此來講刪除不是僅僅刪除就完事了,要作一個標記DEL,以避免影響Hash,並且再次插入的時候這個標記表示是空槽能夠插入,查詢的時候看到此標記能夠繞過去。 那麼咱們如何構造開放尋址Hash函數呢? 線性探測:H(k,i)=(H1(k,0)+i)Mod m,H和H1能夠相同也能夠不一樣。這樣咱們的查詢序列就是從第一次Hash值開始一個接一個的查詢空槽直到找到爲止,只須要第一次Hash值便可,很簡單。但是這個函數會出現問題,羣集問題。就是說會形成一個很長的連續序列都不是空槽,而以前以後都有一連串的空槽,這樣若是關鍵字的Hash值在這個序列中的話將會形成無用的遍歷,甚至會到m槽,而0開始的序列有不少的空槽。這樣無謂的浪費了不少的時間。
二次探測:咱們不要這樣一個接一個的查詢空槽,而是間隔的查詢。能夠把i換成i^2或者變成H(k,i)=(H(k)+c*i+c*i^2),這樣會好不少的,不過這樣也會形成羣集,二次羣集。若是兩個關鍵字的初始查詢值相同,那麼他們的查詢序列也是相同的,二次羣集的長度稍微短些,危害小些。不過這兩種探測方法的查詢序列都只是m種罷了,而Hash表的查詢序列但是m!種。不過接下來咱們說一個更好的,有m^2種查詢序列。
雙重哈希:H(k,i)=(H1(K)+i*H2(k))Mod m,這種Hash方法能夠大幅度的減輕羣集現象。H1和H2都有m種查詢序列,因此H有m^2種查詢序列。這時候取m的值爲2的整數冪,並且要H2函數的Hash值要老是產生奇數。
不過儘管開放尋址很好,但是最壞的狀況依然仍是不好,避免不了最壞的狀況。因此咱們來分析一下平均狀況,看一看指望值。α=n/m是槽裏關鍵字的平均數量,對於開放尋址來講α必然是小於等於1的,由於每一槽最多放入一個關鍵字。 假設對於每個關鍵字key都用相同的記錄映射到Hash表的任何一個槽裏,並且每個關鍵字key都是相互獨立的,這就是簡單一致Hash。假設有n個關鍵字,有m個槽的Hash表。對於失敗的查找,第一次查找失敗的機率是n/m(由於此時m中有n個數),那麼第二次查找失敗的機率是多少?(n-1)/(m-1),由於以前那個已經排除,再也不查詢。第i次查找失敗的機率是(n-i+1)/(m-i+1)(<n/m)。 那麼指望查找的次數E=1+n/m(1+(n-1)/(m-1)(1+...)+))=1+α(1+(1+α(1+...)))<1+α+α^2+α^3+...=1/(1-α)。因此來講失敗查找的次數是1/(1-α),而成功的查找也是同樣的,查找失敗不是能夠插入嗎?那但是空槽啊。 定理:在一致哈希的假設下,對於一個開放尋址的Hash表,平均狀況下成功查找和查找失敗的次數都是1/(1-α)。 若是1/(1-α)=0.5,須要查詢2次,而1/(1-α)=0.9,須要查詢10次,因此通常狀況但願1/(1-α)小一些比較好,這樣查詢次數才少。千萬不要覺得Hash的利用率越高,Hash很稠密纔好,那樣會使查詢速度變的很慢。咱們用Hash是爲了什麼,不就是爲了快速的查找和插入嗎?若是速度都沒有了,咱們還要它幹什麼呢? 好的Hash函數確實很重要啊,但是再好的Hash函數也不可避免碰撞,老是能找到一組關鍵字能夠用你給定的Hash函數映射到同一個槽,查詢時間變成O(n)最壞狀況。這一點,與Hash函數沒有任何關係,難道咱們說了半天,P用沒有?是有點坑啊。這個時候咱們不禁得想起了隨機性,若是咱們隨機給你一個Hash函數,那你就沒辦法必定給我致使最壞的狀況。這就是全域哈希。 全域哈希的思想就是執行算法開始從一個設計好的Hash函數集中隨機選出一個函數,對於給定的關鍵字集合就沒有辦法致使最壞的狀況。 若是全域哈希函數集合爲H,而關鍵字集合爲U,則對於U中關鍵字不一樣的key碰撞的機率是1/m。那麼也就是說有|H|/m個哈希函數知足這個狀況。 定理:若是h選自全域哈希函數集H的哈希函數,那麼將n個關鍵字映射到m個槽中。則查詢失敗次數的指望查詢次數就是α,而成功查詢的指望次數是α+1。 利用全域哈希能夠獲得常數級的時間複雜度,由於n=O(m),α=O(1); 咱們之因此使用Hash,看中的就是它的平均時間複雜度能夠達到O(1)。大部分地方狀況下咱們利用Hash就是爲了查詢,若是咱們僅僅但願建立一個靜態的查詢Hash Table,那麼咱們能夠獲得更好的效果。那就是徹底哈希。 徹底哈希:在最壞的狀況下進行查找的時間複雜度是O(1)的哈希技術。 實現辦法就是利用二級哈希表, 每一級的Hash函數都使用全域哈希函數。第一級的Hash表和以前沒有什麼區別,將關鍵字映射到槽裏,可是若是發生碰撞了,咱們利用拉鍊的思想,可是不用鏈表作,對於每個碰撞的槽i再創建一個小型Hash表hi,而Hash表的大小mi是碰撞關鍵字ni的平方,即mi=ni^2.
定理:對於一個從全域哈希函數集選擇的哈希函數h,將n個關鍵字映射到m=n^2個槽裏的哈希表,發生碰撞的機率小於1/2. 簡單證實一下:對於m個槽兩個不一樣的關鍵字碰撞的機率是1/m=1/n^2,而從n個關鍵字選出2個關鍵字的組合數是n(n-1))/2,則n(n-1))/2*1/n^2<1/2。 那麼對於二級哈希表來講只要知足m=n^2,那就能夠實現低機率碰撞的常量時間的查詢。 但是二級哈希表的空間複雜度會不會太大?固然不會啦,若是一級哈希表作的好,那麼二級的空間複雜度確定會好的。 定理:對於一個從全域哈希函數集選擇的哈希函數h,將n個關鍵字映射到m=n個槽裏的哈希表,並且對於二級哈希表的大小爲ni=mi^2,則一個徹底哈希的哈希表的指望空間複雜度小於2n,便是O(n). 在這裏要擴展一個哈希算法,便是d-left hashing。d-left hashing中的d是多個的意思,先看一看2-left hashing。2-left hashing指的是將一個哈希表分紅長度相等的兩半,分別叫作T1和T2,給T1和T2分別配備一個哈希函數,h1和h2。在存儲一個新的key時,同時用兩個哈希函數進行計算,得出兩個地址h1[key]和h2[key]。這時須要檢查T1中的h1[key]位置和T2中的h2[key]位置,哪個位置已經存儲的(有碰撞的)key比較多,而後將新key存儲在負載少的位置。比較的是兩個哈希函數映射的位置中已經存儲的key(包括碰撞的狀況)的個數,而不是兩個子表中已有key的個數。若是兩邊同樣多,好比兩個位置都爲空或者都存儲了一個key,就把新key存儲在左邊的T1子表中,2-left也由此而來。在查找一個key時,必須進行兩次hash,同時查找兩個位置。 瞭解了2-left hashing,d-left hashing就很好理解,它只是對前者的擴展。2-left hashing固定了子表的個數是2,d-left hashing更加靈活,子表的個數是一個變量d,同時也意味着哈希函數的個數是d。在d-left hashing中,整個哈希表被分紅d個從左到右依次相鄰的子表,每一個子表對應一個相互獨立的哈希函數。在加入新key時,這個key被d個哈希函數同時計算,產生d個相互獨立的位置,而後將key加入到負載最輕的位置(bucket)中。若是負載最輕的位置有多個,就把key加入到最左邊的負載最輕的子表中。一樣地,若是要查找一個key,須要同時查找d個位置。 OK,Hash表說先到這裏,之後學到新的知識仍是update。由於Hash算法博大精深,這只是九牛一毛而已。之後還要多多學習。 明白了這些 剛纔的TopK問題就變的很好解決了,本身想一下吧,我就很少說了。