從陣列到HashMap之演算法解釋

Xcafe發表於2017-01-03

 一 陣列是什麼?

  忘了在哪本書裡曾看到過類似這樣的一句話“所有的資料結構都是陣列的演化”,想想其實是有道理的,因為計算機的記憶體其實就是線性的儲存空間。

  Java示例程式碼:int[] array = new int[5]

  忽略物件頭資訊和陣列長度資訊,JVM執行時會在堆中分配20個位元組的記憶體空間,看起來就是這樣的:


  

  這樣的資料結構可以很方便地通過陣列下標存取資料,但在查詢時需要遍歷陣列,平均時間複雜度為O(n/2)。

  當資料量很大或者查詢操作頻繁的時候,這樣的遍歷操作幾乎是不可接受的。那麼,如何才能夠在更短的時間內快速找到資料呢?

 二 二分查詢

  假如陣列內的元素是已經排序的,那麼很自然的方式就是使用二分查詢。

  譬如有一個整形陣列的長度為1000,陣列內的整數從小到大排列,如果我想要知道6000是否在此陣列中。那麼我可以先檢視array[500]的數字是否為6000,array[500]的數字比6000小,那麼就檢視array[750]位置的數字……依次類推,那麼最多經過10次,就可以確定結果。

  此演算法的時間複雜度為O(logN)。

  然而,大部分情況下陣列元素都是無序的,而排序所需要的時間複雜度O(NlogN)通常都遠遠超過遍歷所需要的時間。

  那麼,問題又回到了原點。該如何在無序的資料中快速找到資料呢?

 三 懵懂的思考

  還記得剛學程式設計不久時看《程式設計珠璣》,其中有一段描述:20世紀70年代,Mike Lesk為電話公司做了電話號碼查詢功能,譬如輸入LESK*M*對應的數字鍵5375*6*,可以返回正確的電話號碼或可選列表,錯誤匹配率僅0.2%。

  怎麼才能做到?

  當時我還完全不瞭解資料結構和演算法,還原下當時天馬行空思考的過程,現在看起來仍然是很有意思的。

  ㈠ 加法

  所有數字相加(*鍵為11),5375*6*的和為48。大多數輸入的和不超過100,意味著幾萬個電話號碼都會聚集在陣列前100的位置,所以是不可行的。

  ㈡ 乘法

  所有數字相乘,積為381150。這似乎是可行的,但出現了這樣的問題:lesk、lsek、slke……的積是一樣的,每一個數字鍵還對應著3個字母,意味著重複的概率會很高。

  ㈢ 改進後的乘法

  ①字母相同但字母序不同的字串,可以通過這樣的方式來避免衝突:每一個數字先乘以序號,然後再與其它值相乘。

  ②每一個數字鍵對應了3個英文字母,如果使用者沒有輸入字母在數字鍵中的序號,是沒辦法再進一步精確計算的。能考慮的方向只能是根據給定的單詞表來做概率統計,所以不予考慮。

  ㈣ 位置衝突

  即使用改進後的乘法,不同的姓名字母計算得出的積依然還是可能會發生重複,那麼當發生衝突後應該怎麼辦?

  順序儲存到下一個空白位置?仔細想想這是行不通的。如果下一個空白位置剛好又是另外的字母集合的積,那就產生了二次衝突。

  幸好,還有質數。

  質數只能被1和自身整除,那麼上述乘法得出的任何積都不可能是質數,所以質數位置是沒有儲存電話號碼的。

  因此,以當前積為起點向右搜尋下一個質數,如果該質數位置依然被使用,那麼就繼續查詢下一個最近的質數……

  至此,問題似乎是解決了。

  使用者輸入一串數字,根據公式計算得到積,返回該下標位置的電話號碼。如果不正確,再順序查詢後面的質數,直到某個質數下標的陣列元素為空為止,最後返回所有查詢到的電話號碼。大部分情況下,只需要O(1)的時間複雜度就可以找到電話號碼。

  ㈤ 陣列太大

  唯一的問題是,按照上述思路建立的陣列實在是太大了。一個姓名有10個字母,假如每一個字母對應的數字都是9,最後得到的積約是9的17次方。這意味著要建立9^17大小的陣列,這是完全不可行的。

  ㈥ 後來

  即使不考慮陣列過大因素,以我當時初學程式設計的水平,這樣的程式也是沒有能力寫出來的。

  我之所以還原這個思考的過程,是覺得獨立思考的過程非常有趣也很有價值。想想,其實現有的演算法都是當年那些大牛在實際工程中一步一步尋求解決方案而最終推導得到的。

  因此,當在工程中碰到一些棘手的難題時,只要樂於思考分解問題並尋求每一個小問題解決方案,那麼很多所謂的難題都是可以解決的。

  後來看了《資料結構與演算法分析.Java語言描述》,我才知道原來我的思路其實就是開放定址法(Open Addressing)。

  JDK的HashMap使用的則是分離連結法(separate chaining)。不同:增加了桶的概念來儲存衝突的資料;進行求餘運算來降低陣列大小。

  那麼,就談談Java中的HashMap吧。

 四 HashMap結構及演算法詳解

  HashMap的本質依然是陣列,而且是一個不定長的多維陣列,近似於下圖這樣的結構:

  ㈠ 簡單介紹

  HashMap中的陣列儲存連結串列的頭節點。

  儲存資料:

  計算key的hashCode,再與陣列長度進行求餘運算得到陣列下標(連結串列頭節點)。

  如果該位置為空,生成一個新的連結串列節點並儲存到該陣列。

  如果該位置非空,迴圈比對連結串列的每一個節點:已經存在key相同的節點,覆蓋舊節點;不存在key相同的節點,將新節點作為連結串列的尾節點(注:檢視JDK1.8中的原始碼,新節點總是加入到連結串列末尾,而不是如JDK1.6一般作為連結串列頭節點)。

  查詢資料:

  計算key的hashCode,再與陣列長度進行求餘運算得到陣列下標(連結串列頭節點)。如果連結串列不為空,先比對首節點,如果首節點key相同(hashCode相同且equals為true),直接返回首節點的value;首節點key不同則順序遍歷比對連結串列其它節點,返回key相同的節點的value(未找到key相同的節點則返回null)。

  HashMap引入連結串列的目的就是為了解決上一節討論過的重複衝突問題。

  注意事項:

  如果所有key的hashcode相同,假定均為0,則0%4 = 0,所有元素就會都儲存到連結串列0,儲存和查詢每一個資料都需要遍歷連結串列0。那麼,此時的HashMap實質上已經退化成了連結串列,時間複雜度也從設計的O(1)上升到了O(n/2)。

  為了儘可能地讓HashMap的查詢和儲存的時間複雜度保持在O(1),就需要讓元素均勻地分佈在每一個連結串列,也就是每一個連結串列只儲存一個元素。

  那麼影響因素有哪些?

  一是key的hashcode不能重複,如果重複就肯定會有連結串列儲存至少2個元素;

  二是雜湊函式設計,如果只是簡單的求餘,那麼餘數會有大量重複;

  三是陣列的容量,如果100個元素要分佈在長度為10的陣列,無論怎麼計算都會導致其中有連結串列儲存多個元素,最好的情況是每個連結串列儲存10個;

  下面分別詳細介紹這三個因素的演算法設計。

  ㈡ hashcode生成

  這是String類的hashCode生成程式碼。

  public int hashCode() {
    int h = hash;
    if (h == 0 && value.length > 0) {
      char val[] = value;
      for (int i = 0; i < value.length; i++) {
        h = 31 * h + val[i];
      }
      hash = h;
    }
    return h;
  }

  String類的value是char[],char可以轉換成UTF-8編碼。譬如,’a’、’b’、’c’的UTF-8編碼分別是97,98,99;“abc”根據上面的程式碼轉換成公式就是:

  h = 31 * 0 + 97 = 97;

  h = 31 * 97 + 98 = 3105;

  h = 31 * 3105 + 99 = 96354;

  使用31作為乘數因子是因為它是一個比較合適大小的質數:如值過小,當參與計算hashcode的項數較少時會導致積過小;如為偶數,則相當於是左位移,當乘法溢位時會造成有規律的位資訊丟失。而這兩者都會導致重複率增加。

  如果使用32作為乘數因子(相當於 << 5),以字串“abcabcabcabcabc”為例,它的hashcode的每一步計算結果如下圖:

  

  如上圖所示,字串末尾每3個字母就會產生一個重複的hashcode。這並不是一個巧合,即使換成其它的英文字母,也有很容易產生重複,而使用質數則會大大地減少重複可能性。有興趣的可以參照上圖去作一下左位移運算,會發現這並不是偶然。

  《Effective Java》一書中詳細描述了hashcode的生成規則和注意事項,有興趣的可以去看看。

  從原始碼的hashCode()方法可知,String類物件儲存了已經計算好的hashCode,如果已經呼叫過hashCode()方法,那麼第二次呼叫時不會再重新生成,而是直接返回已經計算好的hashCode。

  String物件總是會存放到String類私有維護的常量池中,不顯式使用new關鍵字時,如果常量池中已經有value相同的物件,那麼總是會返回已有物件的引用。因此,很多情況下生成hashCode這種比較昂貴的操作實際上並不需要執行。

  ㈢ 雜湊函式設計

  現在,已經得到了重複率很低的hashCode,還有什麼美中不足的地方嗎?

  ⑴ 擾動函式

  static final int hash(Object key) {
      int h;
      return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
  }

  下圖還是以字串“abcabcabcabcabc”為例,使用上面方法得到的運算過程和結果。

  

  為什麼要先無符號右位移16位,然後再執行異或運算?看看下圖的二進位制的與運算,你就會明白。

  

  你會發現只要二進位制數後4位不變,前面的二進位制位無論如何變化都會出現相同的結果。為了防止出現這種高位變化而低位不變導致的運算結果相同的情況,因此需要將高位的變化也加入進來,而將整數的二進位制位上半部與下半部進行異或操作就可以得到這個效果。

  為什麼要使用與運算?

  因為雜湊函式的計算公式就是hashCode % tableSize,當tableSize是2的n次方(n≥1)的時候,等價於hashCode & (tableSize - 1)。

  擾動函式優化前:1954974080 % 16 = 1954974080 & (16 - 1) = 0

  擾動函式優化後:1955003654 % 16 = 1955003654 & (16 - 1) = 6

  這就是為什麼需要增加擾動函式的原因。

  ⑵ 原始碼詳解

  程式碼解釋之前需要補充說明一下,jdk1.8引入了紅黑樹來解決大量衝突時的查詢效率,所以當一個連結串列中的資料大到一定規模的時候,連結串列會轉換成紅黑樹。因此在jdk1.8中,HashMap的查詢和儲存資料的最大時間複雜度實際上就是紅黑樹的時間複雜度O(logN)。

  以下是HashMap中的儲存資料的方法原始碼,相信經過以上的描述,已經非常容易看懂這一段程式碼。

  final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
    Node<K,V>[] tab;    //HashMap陣列
    Node<K,V> p;      //初始化需要儲存的資料
    int n;         //陣列容量
    int i;         //陣列下標

    /* 如果陣列為空或0,初始化容量為16 */
    if ((tab = table) == null || (n = tab.length) == 0){
      n = (tab = resize()).length;
    }

    /* 使用雜湊函式獲取陣列位置(如果為空,儲存新節點到陣列) */
    if ((p = tab[i = (n - 1) & hash]) == null){
      tab[i] = newNode(hash, key, value, null);
    }

    /* 如果陣列位置已經有值,則使用下列方式儲存資料 */
    else {
      Node<K,V> e;    //臨時節點儲存新值
      K k;        //臨時變數用於比較key

      //如果頭節點與新節點的key的hash值相同 且 key的值相等,e賦值為舊節點
      if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))){
        e = p;
      }

      //如果頭節點是一個紅黑樹節點,那麼e將儲存為樹節點
      else if (p instanceof TreeNode){
      	e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
      }

      //如果頭節點與新節點不同,且頭節點不是紅黑樹節點,迴圈比對連結串列的所有節點
      else {
        for (int binCount = 0; ; ++binCount) {
          if ((e = p.next) == null) {
            //如果直到連結串列尾未找到相同key的節點,將新結點作為最後一個節點加入到連結串列
            p.next = newNode(hash, key, value, null);

            //如果連結串列節點數大於等於8-1,轉換成紅黑樹;轉換成紅黑樹的最小節點數是8
            if (binCount >= TREEIFY_THRESHOLD - 1){
              treeifyBin(tab, hash);
            }
            break;
          }
          //如果迴圈過程中發現新舊key的值相同,跳轉:是否覆蓋舊值
          if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))){
            break;
          }
          p = e;
        }
      }

      //是否覆蓋舊值
      if (e != null) {
        V oldValue = e.value;
        //如果新值不為空且(允許修改舊值 或 舊值為空),覆蓋舊節點的值
        if (!onlyIfAbsent || oldValue == null){
          e.value = value; 	
        }
        afterNodeAccess(e);  //回撥函式,這裡是空函式,但在linkedHashMap中實現了此方法
        return oldValue;    //返回舊值
      }
    }

    //用於比較判斷是否在遍歷集合過程中有其它執行緒修改集合,詳情請網上搜尋fail-fast快速失敗機制
    ++modCount;

    //當key數量大於允許容量時進行擴容。允許容量在HashMap中是陣列長度 * 裝填因子(預設0.75)
    if (++size > threshold){
    	resize();
    }

    //回撥函式,這裡是空函式,但在linkedHashMap中實現了此方法
    afterNodeInsertion(evict);
    return null;
  }

  ⑶ 簡化後的虛擬碼

  putval(key, value){
    index = key.hashcode % tablesize;
    if(null == table[index]){
      table[index] = new node(key, value);
    }else{
      firstNode = table[index];
      nextNode = firstNode.next;
      while(nextNode.hasNextNode()){
        //如果找到相同key的舊節點,覆蓋舊節點
        if(key == nextNode.key){
          nextNode = new node(key, value);  
          break;
        }
        //如果到佇列末尾依然沒有找到相同key的舊節點,將新結點加入到最後一個節點的末尾
        if(nextNode.next == null){
          nextNode.next = new node(key, value);
          break;
        }
        nextNode = nextNode.next;
      }
    }
  }

  ⑷ 陣列大小設計

  程式碼註釋中已經稍有提及,這裡再分別展開討論。

  ①陣列容量選擇

  HashMap的初始容量是 1 << 4,也就是16。以後每次擴容都是size << 1,也就是擴容成原來容量的2倍。如此設計是因為 2^n-1(n≥1)的二進位制表示總是為重複的1,方便進行求餘運算。

  《資料結構與演算法分析.Java語言描述》一書中的建議是容量最好是質數,有助於降低衝突,但沒有給出證明或實驗資料。

  質數雖然是神奇的數字,但個人感覺在這裡並沒有特別的用處。

  根據公式index = hashCode % size可知,無論size是質數還是非質數,index的值區間都是0至(size-1)之間,似乎真正起決定作用的是hashCode的隨機性要好。

  這裡先不下結論,待以後再寫一個隨機函式比較下質數和非質數重複率。

  ②裝填因子

  裝填因子預設是0.75,也就是說如果陣列容量為16,那麼當key的數量大於12時,HashMap會進行擴容。

  裝填因子設計成0.75的目的是為了平衡時間和空間效能。過小會導致陣列太過於稀疏,空間利用率不高;過大會導致衝突增大,時間複雜度會上升。

  ㈣ 關於其它

  紅黑樹是在JDK 1.8中引入的,想用簡單的語言來講清楚紅黑樹的資料結構、增刪改查操作及時間複雜度分析,這是一個複雜且艱難的任務,更適合單獨來描述,因此留待以後吧。

 五 最小完美雜湊函式(Minimal Perfect Hash Function, MPHF)

  Jdk中的HashMap解決了任意資料集的時間複雜度問題,所設計的雜湊函式在未知資料集的情況下有良好的表現。

  但如果有一個已知資料集(如Java關鍵字集合),如何設計一個雜湊函式才能同時滿足以下兩方面的要求:

  ⑴ 容器容量與資料集合的大小完全一致;

  ⑵ 沒有任何衝突。

  也就是說,當給定一個確定的資料集時,如果一個雜湊函式能讓容器的每一個節點有且僅有一個資料元素,這樣的雜湊函式便稱之為最小完美雜湊函式。

  最近在研究編譯原理,提到說如何解決關鍵字集合的O(1)時間複雜度的查詢問題,提到了可以採用最小完美雜湊函式。看到一個這樣的名詞,瞬間就覺得很好很高大上。

相關文章