引文
hello,今天寫的數據結構是散列表(hash表),也算是一種基礎數據結構了吧。學過計算機的人大概都能說出來這是個以空間換時間的東西,那么具體怎么實現的是今天要討論的問題。
為什么需要它?主要還是人們希望能完成O(1)時間復雜度的查詢,之前我們學習的最優秀的數據結構AVL樹也是O(lg n)量級的。很多人想到了數組這種數據結構,數組可以隨機訪問,在知道索引的情況下,可以O(1)時間訪問之。最初的思想是將關鍵字的值作為索引,在對應的位置上存儲數字,以1、3、5、8為例,建立一個8個長度的數組,索引為1、3、5、8的位置存儲true,其他為false,算是初步達到了目的。當然,后續的改進都是在此基礎上進行的,基本思想沒有變化。
如果只存兩個數,1和10000,那豈不是要創建個10000長度的數組,但是只存兩個有效數據嗎?
那如果關鍵字是string呢?
這時候就需要一個函數,完成對關鍵字的轉換工作,也即將原先的關鍵字轉化為索引,根據索引存儲數據。
定義
| 散列與散列函數 散列表是存儲數組。散列是通過推演出對象的數值散列碼,並把這個散列碼映射到散列表中的位置來在散列表中存儲對象的過程。將對象映射到散列表中位置的函數就叫散列函數。 |
散列沖突
有個問題必須要考慮,通過散列函數轉化關鍵字,兩個不同的關鍵字會不會轉換結果相同?
當然會!存儲數組位置是有限的,而輸入變量在輸入空間中是個無限可能的量,必然存在轉換結果相同的情況,這種情況我們叫做散列沖突。有問題就要解決,如何解決沖突,可以先思考一下。哈哈,鄙人第一次考慮的時候猜對過其中一類辦法,當然,不難。
說散列沖突之前,先說散列函數。有哪些散列函數?散列之前先將輸入變量轉換成整數,舉個例子,先將字符串各個位置的字母編號加一起得到結果:
| cat = 3+1+20 = 24 dog = 4+15+7 = 26 ear = 5+1+18 = 24 |
以上計算結果叫做散列碼,知道散列碼還不夠,要將其映射到數組對應的位置上。比方說,數組大小為10。使用散列函數 h(k) = k mod 10,則以上三個單詞有兩個存儲在4位置,發生了沖突。當然,你可以設計其他映射函數,但是肯定會有沖突的情況發生,如何解決散列沖突是最關鍵的。
開放尋址法
開放尋址法,在散列表內尋找另一個位置存儲數據。常用的有線性探查法和二次探查法。
線性探查法設映射函數為h,表的規模為N,被映射的關鍵字是k。如果在表中散列位置h(k)上發生沖突,那么線性探查法依次檢查位置(h(k) + i)mod N, i=1,2,...,直到某個(h(k) + i)是空位置,或者(h(k) + i)mod N = h(k)結束。 |
線性探查法有個問題,考慮最壞情況,所有存儲值都在同一個位置沖突。每次尋找一個新的位置存儲數據,第一次沖突尋找1次,第二次沖突2次,直到第N-1次沖突,需要尋找N-1次。
假設你的散列函數可以使得在表的各位置均勻地分布關鍵字。如上例中,10長度的數組中已經插入cat於第四個索引處。之后再插入一個數,各個位置的概率?發現除了4,和5之外位置為1/10,而5位置的概率為2/10。隨着沖突項繼續插入,這個概率會越來越大。
這種堆積效應使得插入和查找的復雜度都變為O(N)。
|
設散列函數為h,表的規模為N,要散列的關鍵字為k。那么,如果在散列位置h(k)發生沖突,二次探查法依次檢查位置(h(k) +i2),直到某個位置是個空位置,或者已經檢查過的位置。 |
相對線性探查法,二次探查確實可以一定程度避免堆積。但二次探查法最壞情況下,即所有關鍵字在同一個位置沖突下,數組的利用率為1/2。可以證明,對於任意素數N,一旦一個位置被檢查兩次,那么之后的所有位置都是被已檢查過的位置。
//設在i和j結束於相同位置
(h+i2) mod N = (h+j2) mod N
→ (i+j)(i-j) mod N = 0
//因為N是素數,它必須整除因子(i+j)或(i-j),只有做了N次探查,N才能整除(i-j);同時,使得N整除(i+j)的最小(i+j)為N。
→ i+j = N → j = N - i
//故而不同的探查位置數只能是N/2。
最壞情況的搜索和插入運行時間依舊是O(N)。
封閉尋址法
封閉尋址法不把關鍵字存儲在表中,而是把散列在相同位置的所有關鍵字都存儲在一個“吊掛”在那個位置上的數據結構中。最常見的就是鏈表,在java中java.util.HashMap就采用這樣的設計。盜圖盜圖:

先介紹一個量,負載因子 a = n/N,n為散列表中的實際項數,N為散列表的容量。一般來說,負載因子越大,搜索的時間就越長。
同開放尋址法,最壞的插入和搜索的時間復雜度都是O(n),當然如果是對關鍵字完美散列的散列函數,時間復雜度都是O(1)。
java中HashMap是一種字典結構,實現了散列表的功能,存儲(key,value)鍵值對,至少支持get(key)、put(key,value)、delete(key)方法。廣義上來說,列表和二叉查找樹都是字典。
HashMap的創建
// 創建默認容量為16,默認負載因子上限為0.75的hashmap HashMap<String,String> phoneBook = new HashMap<String,String>(); // 創建默認容量大於101的hashmap,但hashmap容量為2的冪,故實際容量為128 HashMap<String,String> phoneBook = new HashMap<String,String>(101); // 創建初始容量為128,負載因子上限為2.5的散列表 HashMap<String,String> phoneBook = new HashMap<String,String>(128, 2.5);
實際負載因為 a = n/N , 此處設置的上限,超過負載因子上限的時候,就會進行散列表擴展,每次擴展都為之前的2倍。
HashMap項的存儲
以鄙人的1.8版本jdk為例,其成員變量:
/* ---------------- Fields -------------- */ transient Node<K,V>[] table; transient Set<Map.Entry<K,V>> entrySet; transient int size; int threshold; final float loadFactor;
transient關鍵字聲明的成員不能被序列化和反序列化,與本文關系不大,不用在意。
size是散列表的實際存儲項數
threshold是散列表項數上限,等於容量和負載因子上限的乘積:N*t,因此size最大值為threshold。
loadFactor是初始化時設定的負載因子上限值。
Node<K,V>[] table 構建一個鏈表數組,每一個Node<K,v>都是一個節點,Node對用戶不可見,其數據結構為:
static class Node<K,V> implements Map.Entry<K,V> { final int hash; final K key; V value; Node<K,V> next;
... }
HashMap操作
源碼寫到東西太多了,代碼就用簡化版本吧,聲明一下,這個不是jdk1.8源碼啊,可能是老版本的。
添加項
1 public V put(K key, V value) { 2 K k = mashNull(key); 3 int hash = hash(k); 4 int i = indexFor(hash, table.length); 5 6 for(Node<K,V> e = table[i]; e!=null; e=e.next){ 7 if ((e.hash == hash)&&(eq(k, key)){ // eq判斷是否相等 8 V oldValue = e.value; 9 e.value = value; 10 e.recordAccess(this); 11 return oldValue; 12 } 13 } 14 ++modCount; 15 addNode(hash, k, value, i); 16 return null; 17 }
Node的數據結構如下:
static class Node<K,V> implements Map.Entry<K,V> { final int hash; final K key; V value; Node<K,V> next; Node(int hash, K key, V value, Node<K,V> next) { this.hash = hash; this.key = key; this.value = value; this.next = next; } ... }
2行mashNull()處理為空的情況,indexFor將散列碼映射到表位置上:
static int indexFor(int h, int length){ return h & (length-1); }
其作了一個位操作,按位並,length是2的冪,(length-1)與h並,就是取模,這個思考一下,很簡單的。
addNode()為插入操作。
void addNode(int hash, K key, V value, int bucketIndex){ Node<K,V> e = table[bucketIndex]; table[bucketIndex] = new Node<K,V>(hash, key, value, e); if (size++ >= threshold) resize(2*table.length); }
在表中插入的位置稱為桶,resize()是再散列方法,新表容量是原表的2倍。代碼如下:
void resize(int newCapacity){ Node[] oldtable = table; int oldCapacity = oldtable.length; if(oldCapacity == MAXIMUM_CAPACITY){ threshould = Integer.MAX_VALUE; return; } Node[] newTable = new Node[newCapacity]; transfer(newTable); table = newTable; threshould = (int)(newCapacity*loadFactor); }
如果舊容量已經達到最大可能值而沒有滿足需要,那就將最大容量上限設為最大可能整數值,然后返回。如果不是的話,就創建2倍容量的新表,並對原表中的項重新散列。考慮下散列碼1和9在容量8下索引都為1,但在16容量下索引分別為1和9,故需要重新散列。這個功能在方法transfer()中實現。
void transfer(Node[] newtable){ Node[] src = table; int newCapacity = newtable.length; for(int j=0; j<src.length; j++){ Node<K,V> e = src[j]; if(e!=null){ src[j] = null; do{ Node<K,V> next = e.next; int i = indexFor(e.hash, newCapacity); e.next = newtable[i]; newtable[i] = e; e = next; }while (e!=null); } } }
以上。
