散列表就是咱們日常說的哈希表,英文名叫"Hash Table",其基礎依據就是:java
這裏仍是直接使用老師的例子來講事吧.中間添加本身的思想就好了.本身想例子又得半天,並且咱們的目標也不是想一個好例子,而是真正理解並掌握知識.對吧?web
用一個例子來解釋一下。假如咱們有 89 名選手參加學校運動會。爲了方便記錄成績,每一個選手胸前都會貼上本身的參賽號碼。這 89 名選手的編號依次是 1 到 89。如今咱們但願編程實現這樣一個功能,經過編號快速找到對應的選手信息。你會怎麼作呢?算法
咱們能夠把這 89 名選手的信息放在數組裏。編號爲 1 的選手,咱們放到數組中下標爲 1 的位置;編號爲 2 的選手,咱們放到數組中下標爲 2 的位置。以此類推,編號爲 k 的選手放到數組中下標爲 k 的位置。 由於參賽編號跟數組下標一一對應,當咱們須要查詢參賽編號爲 x 的選手的時候,咱們只須要將下標爲 x 的數組元素取出來就能夠了,時間複雜度就是 O(1)。這樣按照編號查找選手信息,效率是否是很高?編程
實際上,這個例子已經用到了散列的思想。在這個例子裏,參賽編號是天然數,而且與數組的下標造成一一映射,因此利用數組支持根據下標隨機訪問的時候,時間複雜度是 O(1) 這一特性,就能夠實現快速查找編號對應的選手信息。api
你可能要說了,這個例子中蘊含的散列思想還不夠明顯,那我來改造一下這個例子。數組
假設校長說,參賽編號不能設置得這麼簡單,要加上年級、班級這些更詳細的信息,因此咱們把編號的規則稍微修改了一下,用 6 位數字來表示。好比 051167,其中,前兩位 05 表示年級,中間兩位 11 表示班級,最後兩位仍是原來的編號 1 到 89。這個時候咱們該如何存儲選手信息,纔可以支持經過編號來快速查找選手信息呢?緩存
思路仍是跟前面相似。儘管咱們不能直接把編號做爲數組下標,但咱們能夠截取參賽編號的後兩位做爲數組下標,來存取選手信息數據。當經過參賽編號查詢選手信息的時候,咱們用一樣的方法,取參賽編號的後兩位,做爲數組下標,來讀取數組中的數據。 這就是典型的散列思想。其中,參賽選手的編號咱們叫做鍵(key)或者關鍵字。咱們用它來標識一個選手。咱們把參賽編號轉化爲數組下標的映射方法就叫做散列函數(或「Hash 函數」「哈希函數」),而散列函數計算獲得的值就叫做散列值(或「Hash 值」「哈希值」)。
數據結構
因此散列表的思想就是:svg
散列表用的就是數組支持按照下標隨機訪問的時候,時間複雜度是 O(1) 的特性。咱們經過散列函數把元素的鍵值映射爲下標,而後將數據存儲在數組中對應下標的位置。當咱們按照鍵值查詢元素時,咱們用一樣的散列函數,將鍵值轉化數組下標,從對應的數組下標的位置取數據。函數
從上面的例子咱們能夠看出,如何將鍵轉換爲數組的下標這是相當重要的一步,而這一步正是散列函數所起得做用.
散列函數其實就是hash(key)
,其中key
就是鍵值,hash(key)
表示通過散列函數計算獲得的散列值.
好比,改造後的例子的散列函數就是:
int hash_fun(string key) { //提取最後兩位字符 string LastTwoChars = key.substr(key.length() - 2, 2); //將其轉換爲 int 類型 int index = stoi(LastTwoChars); return index; }
以上就是散列的入門了,如今咱們來看一些複雜的操做.
若是參賽選手的編號是隨機生成的 6 位數字,又或者用的是 a 到 z 之間的字符串,該如何構造散列函數呢?我總結了三點散列函數設計的基本要求:
散列衝突
。並且,由於數組的存儲空間有限,也會加大散列衝突的機率)散列衝突:其實就是說,有時候可能key
不相同,但經過該散列函數計算出來的散列值是相同的.
再好的散列函數也沒法避免散列衝突。那究竟該如何解決散列衝突問題呢?咱們經常使用的散列衝突解決方法有兩類,開放尋址法(open addressing)和鏈表法(chaining)
開放尋址法的核心思想就是,若是出現了散列衝突,咱們就從新探測一個空閒位置,將其插入
。那如何從新探測新的位置呢?主要有如下幾種方法:
當咱們往散列表中插入數據時,若是某個數據通過散列函數散列以後,存儲位置已經被佔用了,咱們就從當前位置開始,依次日後查找,看是否有空閒位置,直到找到爲止。
舉一個例子具體說明一下。這裏面黃色的色塊表示空閒位置,橙色的色塊表示已經存儲了數據。
從圖中能夠看出,散列表的大小爲 10,在元素 x 插入散列表以前,已經 6 個元素插入到散列表中。x 通過 Hash 算法以後,被散列到位置下標爲 7 的位置,可是這個位置已經有數據了,因此就產生了衝突。因而咱們就順序地日後一個一個找,看有沒有空閒的位置,遍歷到尾部都沒有找到空閒的位置,因而咱們再從表頭開始找,直到找到空閒位置 2,因而將其插入到這個位置。
這是其插入,那麼如何查找吶?
咱們經過散列函數求出要查找元素的鍵值對應的散列值,而後比較數組中下標爲散列值的元素和要查找的元素。若是相等,則說明就是咱們要找的元素;不然就順序日後依次查找。若是遍歷到數組中的空閒位置,尚未找到,就說明要查找的元素並無在散列表中。
這是其查找,那麼如何刪除一個元素吶?
將刪除的元素,特殊標記爲 deleted。當線性探測查找的時候,遇到標記爲 deleted 的空間,並非停下來,而是繼續往下探測。
提問:爲何不把要刪除的元素設置爲空吶?
由於在查找的時候,一旦咱們經過線性探測方法,找到一個空閒位置,咱們就能夠認定散列表中不存在這個數據。可是,若是這個空閒位置是咱們後來刪除的,就會致使原來的查找算法失效。原本存在的數據,會被認定爲不存在
散列表的缺點
當散列表中插入的數據愈來愈多時,散列衝突發生的可能性就會愈來愈大,空閒位置會愈來愈少,線性探測的時間就會愈來愈久。極端狀況下,咱們可能須要探測整個散列表,因此最壞狀況下的時間複雜度爲 O(n)。同理,在刪除和查找時,也有可能會線性探測整張散列表,才能找到要查找或者刪除的數據。
二次探測與線性探測很像,線性探測每次探測的步長是 1,那它探測的下標序列就是 hash(key)+0,hash(key)+1,hash(key)+2……
而二次探測探測的步長就變成了原來的「二次方」,也就是說,它探測的下標序列就是 hash(key)+0,hash(key)+1^2,hash(key)+2^2……
所謂雙重散列,意思就是不只要使用一個散列函數。咱們使用一組散列函數 hash1(key),hash2(key),hash3(key)……
咱們先用第一個散列函數,若是計算獲得的存儲位置已經被佔用,再用第二個散列函數,依次類推,直到找到空閒的存儲位置。
以上三種方法,在遇到插入的數據愈來愈多時,散列衝突發生的可能性也會愈來愈大,通常狀況下,咱們都會在散列表中維持必定比例的空閒槽位,來保證效率.咱們用裝載因子
來表示空位的多少.
散列表的裝載因子 = 填入表中的元素個數 / 散列表的長度
裝載因子越大,說明空閒位置越少,衝突越多,散列表的性能會降低。
鏈表法是一種更加經常使用的散列衝突解決辦法,相比開放尋址法,它要簡單不少。咱們來看這個圖,在散列表中,每一個「桶(bucket)」或者「槽(slot)」會對應一條鏈表(其實就是相似於鄰接表,不是嗎?),全部散列值相同的元素咱們都放到相同槽位對應的鏈表中。
當插入的時候,直接找到對應的槽位,頭插鏈表便可,時間複雜度爲O(1)
.查找與刪除天然須要遍歷對應的整個鏈表完成,時間複雜度是O(k)
,k是指鏈表長度.對於散列比較均勻的散列函數來講,理論上講,k=n/m
,其中 n 表示散列中數據的個數,m 表示散列表中「槽」的個數。
日常所用的單詞就20萬個,而一個單詞假設有10個字母,那麼就是2百萬的字母,即2百萬字節,也就是2MB,再大點也就是200MB,計算機內存徹底能夠放得下,因此咱們使用散列表來存儲整個英文詞典
當用戶輸入某個英文單詞時,咱們拿用戶輸入的單詞去散列表中查找。若是查到,則說明拼寫正確;若是沒有查到,則說明拼寫可能有誤,給予提示。藉助散列表這種數據結構,咱們就能夠輕鬆實現快速判斷是否存在拼寫錯誤。
答:這道題剛開始我不明白是什麼個意思,後來看了答案,品了幾天,算是終於明白了.
首先咱們要搞清楚排序的對象是什麼?其實很明顯,就是URL
,只不過得按照訪問次數進行排序.好比下面這個的例子:
www.1111.xxxxx
www.2222.xxx
www.1111.xxxxx
那麼結果是什麼吶?
www.1111.xxxxx
www.2222.xxx
就是上面這樣就好了
由例子可知,咱們必須遍歷一遍,找到對應的URL
的訪問次數和去掉重複的URL
.
而後咱們去按照訪問次數排序去掉重複的URL
的URL
集合便可.
sort(set.bagin(),set.end(),[](string lhs,string rhs){ return HashTable[hashfun(lhs)] < HashTable[hashfun(rhs)]; // 按照訪問次數排序`URL`集合 }
答:以第一個字符串數組爲基準創建散列表.具體以字符串爲 key
,以出現次數爲value
,默認爲0.而後遍歷第二個字符串數組,用每個字符串去找,若是value
!= 0 ,就說明找到一個兩個數組中相同的字符串.
由以上可知,散列表的查詢效率與散列函數,裝載因子等有莫大的關係.若是使用的是鏈表,就有可能從O(1)的查詢效率退化爲O(n),這是咱們不容許的.那麼如何解決這個問題吶?或者說是咱們如何設計一個能夠應對各類異常狀況的工業級散列表,來避免在散列衝突的狀況下,散列表性能的急劇降低,而且能抵抗散列碰撞攻擊?這就是咱們這一節要談論的問題了
一個好的散列函數應該具備如下幾點:
散列函數主要有:直接尋址法、平方取中法、摺疊法、隨機數法,數據分析法等(這些只要瞭解就好了)
提問:散列函數的做用究竟是什麼?
答:將key
轉換爲數組的索引 hashfun(key)
散列表基於數組設計,在申請時就固定了大小.因此對於動態頻繁的散列表來說,裝載因子也必定會變得愈來愈大.一個能夠採起的方法就是:動態擴容(其實就和vector
同樣)
從新申請一個更大的散列表,將數據搬移到這個新散列表中。假設每次擴容咱們都申請一個原來散列表大小兩倍的空間。若是原來散列表的裝載因子是 0.8,那通過擴容以後,新散列表的裝載因子就降低爲原來的一半,變成了 0.4。
針對數組的擴容,數據搬移操做比較簡單。可是,針對散列表的擴容,數據搬移操做要複雜不少。由於散列表的大小變了,數據的存儲位置也變了,因此咱們須要經過散列函數從新計算每一個數據的存儲位置。
你能夠看我圖裏這個例子。在原來的散列表中,21 這個元素原來存儲在下標爲 0 的位置,搬移到新的散列表中,存儲在下標爲 7 的位置。
插入時間複雜度:
插入一個數據,最好狀況下,不須要擴容,最好時間複雜度是 O(1)。最壞狀況下,散列表裝載因子太高,啓動擴容,咱們須要從新申請內存空間,從新計算哈希位置,而且搬移數據,因此時間複雜度是 O(n)。用攤還分析法,均攤狀況下,時間複雜度接近最好狀況,就是 O(1)。
若是隨着刪除的元素愈來愈多,散列表能夠選擇縮容(和擴容相似),另外,裝載因子的閥值要更具實際狀況肯定.
何之爲低效:插入致使擴容,假設數據1GB,操做過程爲 **申請空間->將舊數據搬移到新的空間->從新計算哈希值->插入.**用腦子想,這確定會很慢.
解決方法:
將擴容操做穿插在插入操做的過程當中,分批完成。當裝載因子觸達閾值以後,咱們只申請新空間,但並不將老的數據搬移到新散列表中。
當有新數據要插入時,咱們將新數據插入新散列表中,而且從老的散列表中拿出一個數據放入到新散列表。每次插入一個數據(其實不必定一個數據啦)到散列表,咱們都重複上面的過程。通過屢次插入操做以後,老的散列表中的數據就一點一點所有搬移到新散列表中了。這樣沒有了集中的一次性數據搬移,插入操做就都變得很快了。
vector
是否是這樣作的吶?(我下來研究一下)如何查詢:先重新散列表中查找,若是沒有找到,再去老的散列表中查找。
優勢
缺點
適用場景
當數據量比較小、裝載因子小(< 1)的時候,適合採用開放尋址法。這也是 Java 中的ThreadLocalMap
使用開放尋址法解決散列衝突的緣由。
優勢
缺點
適用場景
基於鏈表的散列衝突處理方法比較適合存儲大對象、大數據量的散列表,並且,比起開放尋址法,它更加靈活,支持更多的優化策略,好比用紅黑樹代替鏈表。
極端狀況下,全部的數據都散列到同一個桶內,那最終退化成的散列表的查找時間也只不過是 O(logn)
。這樣也就有效避免了前面講到的散列碰撞攻擊。
HashMap 默認的初始大小是 16,固然這個默認值是能夠設置的,若是事先知道大概的數據量有多大,能夠經過修改默認初始大小,減小動態擴容的次數,這樣會大大提升 HashMap 的性能。
最大裝載因子默認是 0.75,當 HashMap 中元素個數超過 0.75*capacity(capacity 表示散列表的容量)的時候,就會啓動擴容,每次擴容都會擴容爲原來的兩倍大小。
HashMap 底層採用鏈表法來解決衝突。即便負載因子和散列函數設計得再合理,也免不了會出現拉鍊過長的狀況,一旦出現拉鍊過長,則會嚴重影響 HashMap 的性能。
因而,在 JDK1.8 版本中,爲了對 HashMap 作進一步優化,咱們引入了紅黑樹。而當鏈表長度太長(默認超過 8)時,鏈表就轉換爲紅黑樹。咱們能夠利用紅黑樹快速增刪改查的特色,提升 HashMap 的性能。當紅黑樹結點個數少於 8 個的時候,又會將紅黑樹轉化爲鏈表。由於在數據量較小的狀況下,紅黑樹要維護平衡,比起鏈表來,性能上的優點並不明顯。
int hash(Object key) { int h = key.hashCode(); return (h ^ (h >>> 16)) & (capitity -1); //capicity 表示散列表的大小 }
何爲一個工業級的散列表?工業級的散列表應該具備哪些特性?
結合已經學習過的散列知識,我以爲應該有這樣幾點要求:
如何實現這樣一個散列表呢**?根據前面講到的知識,我會從這三個方面來考慮設計思路:
只要咱們朝這三個方向努力,就離設計出工業級的散列表不遠了。
hash
表是必需要存儲在內存中才能發揮功效
,要注意這一點