數據結構(三)——散列(上)

為什么要設計散列這種數據結構呢?在現實世界中,實體之間可能存在著映射關系(key-value),比如一個訂單可能對應多個商品,對應一個配送站點。散列正是對這種映射關系的邏輯結構的表達,但同時,作為一種數據結構,在計算機中該如何實現存儲呢?

本節將重點從散列的邏輯結構和存儲結構出發,對上述涉及的散列原理及應用場景作出說明:

  1. 散列函數與散列表
  2. Java中的散列實例
  3. 保證最壞情況時間復雜度

一、散列函數與散列表

1.1 散列函數

散列函數(Hash Function)是一種從任何一種數據中創建小的數字“指紋”的方法。一般來講,散列函數的輸入包含較多的信息(比如SHA-2最高接受(264-1)/8長度的字節字符串),經過散列算法后,映射為一個更小空間的散列值(通常為格式固定的字母和數字組成的字符串),其過程如下圖所示。

散列函數

散列函數在加密、校驗等安全領域有廣泛的應用,比如,SHA(Secure Hash Algorithm)家族在TLS和SSL、PGP、SSH、S/MIME和IPsec等安全協議中的廣泛應用,MD5(Message-Digest Algorithm 5)在文件下載中校驗的應用,此外,散列表是散列函數的一個主要應用。

1.2 散列表

散列表的核心優勢是能夠按照關鍵字快速存取數據記錄,其插入、查找和刪除的平均時間復雜度為O(1)。在實現上,將關鍵字通過散列函數映射為一個數組的地址,而將數據記錄存儲在該數組單元中。對同一散列函數,要求兩個散列值如果是不相同的,那么這兩個散列值的原始輸入也是不相同的;但兩個散列值如果是相同的,卻并不能確定兩個輸入值是相同的,如果不同的輸入得到的相同的散列值,這種情況就是“散列沖突”。一種常用的散列表結構如下圖所示。

散列表數據結構

從圖中可以看出,散列表的核心結構為:數組+鏈表。直接存儲散列數據的結構稱為節點,節點包含散列值、關鍵字、數據域和指針域(指向下一個節點)。如圖中的節點13,其關鍵字經過散列函數得出在數組中的下標為0,數據域為13,指針域指向下一個節點6。節點在數組中存儲的地址稱為槽位,比如散列沖突時,37、62、52和92經過散列函數計算得出的槽位均為14。

那么,為了減少散列沖突,使數據元素在數組中均勻分布,在散列表的實現中,選擇合適的散列函數至關重要,常見的散列函數包括直接尋址法、數字分析法、平方取中法、折疊法、隨機數法及除留余數法等,其中,直接尋址法通過取key值或者key值的某個線性函數值作為散列地址,即hash(k)=k或者hash(k)=a*k+b;除留余數法通過取關鍵字被某個不大于散列表表長m的數p除后所得的余數為散列地址。即 hash(k)= k mod p, p < m。在JDK中常用除留余數法作為散列函數。

1.3 解決散列沖突

一個好的散列函數要求盡量減少散列沖突且計算簡單,但沖突總是無法避免的,遇到沖突有哪些解決辦法呢?

  • 鏈地址法。上圖中解決散列沖突的方法就是鏈地址法,即將散列到同一槽位的元素通過鏈表進行保存。JDK中就是使用這種方法來解決散列沖突的。
  • 開放定址法。假定散列函數為H,經過散列函數運算H(key)后得到散列值為Hi,過程如下:
    Hi =(H(key) + di) % m,其中i = 1,2,…,n.
    常用的開放定址法包括線性探測法和平方探測法。其區別在于di
    線程探測法:di = 1,2,3,…,m-1.
    平方探測法:di =12,-12,22,-22,…,k2,-k2 ( k<=m/2 ).
  • 再散列。顧名思義,在散列沖突發生后,采用新的散列函數對key進行重新散列。假定散列函數分別為RH1,RH2……,散列過程如下:
    Hi=RH1(key), 其中 i=1,2,…,k
    當散列值Hi=RH1(key)發生沖突時,再計算Hi=RH2(key)……,直到不沖突為止。

二、Java中的散列實例

Java中的散列實例包括HashSet、HashMap、LinkedHashSet、LinkedHashMap以及HashTable等,其中,HashSet和LinkedHashSet是基于HashMap和LinkedHashMap封裝實現的,HashTable相比于HashMap僅增加了對同步操作的支持,并且在Java 5以后建議使用ConcurrentHashMap代替HashTable(第三章會講到ConcurrentHashMap),因此本節將重點對HashMap和LinkedHashMap的實現原理進行說明。

2.1 HashMap實現原理

2.1.1 HashMap的散列函數

《Effective Java》中指出:覆蓋equals時必須覆蓋hashCode,hashCode在基于散列的集合中有重要的作用,因為HashMap的hash方法需要根據Key對象的hashCode來計算散列值的。

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

上文提到,Java中采用除留余數法作為散列函數,假定n為數組的長度,則槽位的計算方法為hash % n。但計算hash值屬于高頻操作,而取余運算較為耗時,因此在Java中采用另外一種實現:(n - 1) & hash。使得hash % n 等于 (n - 1) & hash的前提是n = 2 m(m 為任意正整數),HashMap中數組長度要求必須為2的m次冪,擴容時也是按照2的倍數進行擴展,初始長度為1 << 4 == 2 4 == 16,最大值為 1 << 30 == 2 30 == 1073741824。

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 初始值
static final int MAXIMUM_CAPACITY = 1 << 30; // 最大值

下面以Key='A'為例說明HashMap中散列的計算過程:


Key='A'的HashMap存儲地址計算過程

首先,'A'作為字符串,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計算hashcode的算法是遍歷String串中的每個字符,應用公式 h = 31 * h + val[i] (val[i]表示第i個字符的ASCII碼值)進行計算。計算hashcode是一個比較耗時的操作,因此,String采用了閃存散列代碼的方法,hashcode計算完成后會保存在hash域中,由于String是final類型的,所以再次調用時判斷如果hash值不為0則直接返回保存的hash值。

HashMap的hash方法將hashcode與hashcode>>>16進行異或,即將hashcode的高16位與低16位進行異或,然后與(n-1)進行位與操作得到該Key值在數組中的下標。在HashMap中,數組長度n始終為2的次方,比如初始長度16,n-1=15(0000 1111),那么在計算數組下標時,實際上只有低四位是有用的,這可能會使得散列沖突加劇,所以HashMap的設計者在綜合權衡速度、作用和質量的基礎上,選擇了將hashcode的高16位與低16位進行異或得到一個綜合的信息。

2.1.2 鏈表和紅黑樹在解決散列沖突時的應用

在JDK1.8之前,Java僅采用鏈表解決散列沖突,因此,在最壞情況下,假定所有節點關鍵字的hash值都相等,則所有節點插入同一槽位,導致HashMap退化為該槽位的鏈表,查找節點的時間復雜度為O(n)。JDK1.8在解決散列沖突時引入了紅黑樹,在某槽位的鏈表長度超過限額之后,則將鏈表轉換為紅黑樹。通過上一節的描述,我們知道紅黑樹能夠保證最壞情況的操作時間復雜度為O(Log(n)),因此,使得HashMap在散列沖突時的性能有較大程度的提升。(下文中無特殊說明時,HashMap均表示JDK1.8中的實現)

下面以HashMap插入和刪除元素為例,說明鏈表和紅黑樹在解決散列沖突時的應用。HashMap中采用Node和TreeNode來分別表示鏈表和紅黑樹中存儲的節點,其定義如下:

// 鏈表節點
static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    V value;
    Node<K,V> next;
}
// 紅黑樹節點
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
    TreeNode<K,V> parent;
    TreeNode<K,V> left;
    TreeNode<K,V> right;
    TreeNode<K,V> prev;
    boolean red;
}
// 將鏈表節點轉換為紅黑樹節點
TreeNode<K,V> replacementTreeNode(Node<K,V> p, Node<K,V> next) {
    return new TreeNode<>(p.hash, p.key, p.value, next);
}

在HashMap中插入節點的流程,主要包括以下幾步:

  1. 根據數組是否為空(長度為0)確定是否初始化數組;
  2. 根據hash值計算Node在數組中的下標,根據下標判斷是否散列沖突,如果不沖突,則新建節點插入數組;
  3. 如果沖突并且不是同一節點,通過鏈表存儲新的節點;
  4. 如果沖突導致鏈表過長,就把鏈表轉換為紅黑樹;
  5. 判斷節點是否已經存在,如果存在就替換該節點對應的舊值,自增HashMap的修改數modCount;
  6. 判斷是否需要擴容(超過加載因子loadFactor * 數組容量),如果需要就調用resize方法擴容。

用流程圖表示如下:

HashMap插入節點流程

可以看出,鏈表和紅黑樹的轉換發生在插入節點導致鏈表過長時,下面是HashMap中putVal方法的部分實現。

Node<K,V> e; K k;
// 待插入節點已存在
if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
    e = p;
// 需要插入紅黑樹節點
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) {
            p.next = newNode(hash, key, value, null);
            // 判斷是否需要將鏈表轉化為紅黑樹
            if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                treeifyBin(tab, hash);
            break;
        }
        // 待插入節點已存在
        if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
            break;
        p = e;
    }
}

上述代碼中,p初始為tab[i = (n - 1) & hash],即待插入節點對應槽位處鏈表的首節點,e表示已存在的待插入節點。首先判斷待插入節點是否已存在,其次判斷是否已經需要插入紅黑樹節點,最后遍歷該鏈表,找到合適的插入位置,完成后判斷鏈表長度,如果超過TREEIFY_THRESHOLD(8),則調用treeifyBin方法。在treeifyBin方法中,會判斷HashMap數組長度,如果小于MIN_TREEIFY_CAPACITY(64),則先進行擴容。否則將Node鏈轉換為TreeNode鏈,最后調用TreeNode的treeify方法生產紅黑樹。

TreeNode繼承自LinkedHashMap.Entry,而LinkedHashMap.Entry又繼承自HashMap.Node,所以TreeNode具有Node的所有屬性。TreeNode是HashMap的靜態內部類,其內部定義一系列方法用于保證紅黑樹的性質,包括轉換樹(treeify)、左旋(rotateLeft)、右旋(rotateRight),刪除后平衡(balanceDeletion)、插入后平衡(balanceInsertion)等。

同樣,在HashMap中刪除元素也涉及到鏈表和紅黑樹的轉換,HashMap的remove方法主要分為兩步:1)找到待刪除的節點;2)刪除節點。

if ((tab = table) != null && (n = tab.length) > 0 &&
    (p = tab[index = (n - 1) & hash]) != null) {
    Node<K,V> node = null, e; K k; V v;
    // 待刪除節點為該槽位首節點
    if (p.hash == hash &&
        ((k = p.key) == key || (key != null && key.equals(k))))
        node = p;
    // 繼續查找該槽位所連接的鏈表
    else if ((e = p.next) != null) {
        // 待刪除節點為紅黑樹節點,調用紅黑樹的遍歷方法
        if (p instanceof TreeNode)
            node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
        // 遍歷鏈表,找到待刪除節點
        else {
            do {
                if (e.hash == hash &&
                    ((k = e.key) == key ||
                     (key != null && key.equals(k)))) {
                    node = e;
                    break;
                }
                p = e;
            } while ((e = e.next) != null);
        }
    }
    // 刪除節點
    if (node != null && (!matchValue || (v = node.value) == value ||
                         (value != null && value.equals(v)))) {
        // 如果待刪除節點為紅黑樹節點,則調用TreeNode的刪除節點方法
        if (node instanceof TreeNode)
            ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
        // 刪除該槽位的首節點
        else if (node == p)
            tab[index] = node.next;
        // 刪除鏈表中的節點
        else
            p.next = node.next;
        ++modCount;
        --size;
        afterNodeRemoval(node);
        return node;
    }
}

值得關注的是刪除紅黑樹節點的removeTreeNode方法中,當紅黑樹規模較小時,則會調用untreeify方法將紅黑樹退化為鏈表,該過程與插入時鏈表轉換為紅黑樹的過程剛好相反。

2.1.3 擴容

HashMap中有三個關鍵參數控制著擴容的時機,分別是threshold、loadFactor和size,其中,threshold = loadFactor * size。threshold表示當前HashMap所能容納的節點的最大數量,超過threshold就會觸發擴容;loadFactor為加載因子,初始值為0.75f;size表示HashMap存儲節點的數組的容量,初始值為16。

擴容的實現主要分為兩步:1)根據新的容量初始化節點數組;2)將原數組中的元素重新散列至新數組。新容量總是在現有容量的兩倍,因此HashMap的容量總等于2的冪(比如初始容量16擴容后為32)。同時,新的擴容上限也增加為現有上限的兩倍。

根據新的容量初始化節點數組

// 初始引用oldTab、oldCap和oldThr
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
// 初始newCap、newThr
int newCap, newThr = 0;
// 原容量大于0情況的擴容
if (oldCap > 0) {
    // 超過HashMap的容量上限就不再繼續擴容
    if (oldCap >= MAXIMUM_CAPACITY) {
        threshold = Integer.MAX_VALUE;
        return oldTab;
    }
    // 新容量為原容量的2倍,新的上線為原上線的2倍
    else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
             oldCap >= DEFAULT_INITIAL_CAPACITY)
        newThr = oldThr << 1;
}
else if (oldThr > 0)
    newCap = oldThr;
else {
    // 設置初始容量為16、初始限度為12
    newCap = DEFAULT_INITIAL_CAPACITY;
    newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 計算resize的上限
if (newThr == 0) {
    float ft = (float)newCap * loadFactor;
    newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
              (int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
// 初始化新容量數組
@SuppressWarnings({"rawtypes","unchecked"})
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;

將原數組中的元素重新散列至新數組

HashMap計算插入節點槽位的方法為:(n - 1) & hash,由于HashMap的容量總是以2的倍數遞增,所以,擴容后的容量相比于原容量在二進制表達上,只是最高位前面增加了一位,并且為1。舉個例子,容量為16,n - 1為15(0000 1111),擴容后的容量為32,n - 1為31(0001 1111),0001 1111 相比于 0000 1111 只是多了最高位的 1。因此在于hash值做位與運算時,如果hash值該位為1,則新槽位 = 原槽位 + 原容量,否則槽位不變。

// 遍歷原數組中的所有槽位
for (int j = 0; j < oldCap; ++j) {
    Node<K,V> e;
    if ((e = oldTab[j]) != null) {
        // 原數組不再對節點持有引用
        oldTab[j] = null;
        // 若該節點不存在散列沖突,計算在新數組中的槽位,直接插入
        if (e.next == null)
            newTab[e.hash & (newCap - 1)] = e;
        // 插入紅黑樹節點
        else if (e instanceof TreeNode)
            ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
        // 按照原順序插入鏈表節點
        else { 
            Node<K,V> loHead = null, loTail = null;
            Node<K,V> hiHead = null, hiTail = null;
            Node<K,V> next;
            do {
                next = e.next;
                // 保持原槽位
                if ((e.hash & oldCap) == 0) {
                    if (loTail == null)
                        loHead = e;
                    else
                        loTail.next = e;
                    loTail = e;
                }
                // 原槽位+原容量
                else {
                    if (hiTail == null)
                        hiHead = e;
                    else
                        hiTail.next = e;
                    hiTail = e;
                }
            } while ((e = next) != null);
            // 原槽位插入新數組中
            if (loTail != null) {
                loTail.next = null;
                newTab[j] = loHead;
            }
            // 原槽位+原容量插入新數組中
            if (hiTail != null) {
                hiTail.next = null;
                newTab[j + oldCap] = hiHead;
            }
        }
    }
}

2.2 LinkedHashMap實現原理

在上節已經講過,LinkedHashMap支持按照插入順序對節點排序。實際上,LinkedHashMap還支持按照訪問順序排序。排序方式是由accessOrder字段決定的,如果accessOrder為true,則按照訪問順序排序,否則按照插入順序排序。LinkedHashMap按照訪問順序排序的特征為很多算法實現提供了支持,比如Android中的LruCache(緩存策略為最近最少使用最先刪除)就是基于LinkedHashMap的訪問順序實現的,其構造方法如下:

public LruCache(int maxSize) {
    if (maxSize <= 0) {
        throw new IllegalArgumentException("maxSize <= 0");
    }
    this.maxSize = maxSize;
    // accessOrder字段為true,表示按照訪問順序排序,實現最近最少訪問最先刪除
    this.map = new LinkedHashMap<K, V>(0, 0.75f, true);
}

因此,在探討LinkedHashMap的實現原理時,將重點關注LinkedHashMap是如何實現插入順序和訪問順序的?支持LinkedHashMap保持順序的基礎在于其節點Entry類自包含了before和after域,分別指向當前節點的前節點和后節點,這類似于LinkedList實現雙向鏈表的方法。

static class Entry<K,V> extends HashMap.Node<K,V> {
    Entry<K,V> before, after;
    Entry(int hash, K key, V value, Node<K,V> next) {
        super(hash, key, value, next);
    }
}

Entry繼承自HashMap.Node,因此具有HashMap節點類的所有特性。比如,LinkedHashMap插入節點是通過調用HashMap的put方法實現的。而put方法又調用了newNode和afterNodeInsertion等方法,而這些方法正好是HashMap預留給LinkedHashMap用來保持順序的方法,主要包括節點的初始化等、插入節點后的調整等。

// 新建節點
Node<K,V> newNode(int hash, K key, V value, Node<K,V> next) {
    return new Node<>(hash, key, value, next);
}
// 用鏈表節點替代紅黑樹節點
Node<K,V> replacementNode(Node<K,V> p, Node<K,V> next) {
    return new Node<>(p.hash, p.key, p.value, next);
}
// 創建紅黑樹節點
TreeNode<K,V> newTreeNode(int hash, K key, V value, Node<K,V> next) {
    return new TreeNode<>(hash, key, value, next);
}
// 用紅黑樹節點替代鏈表節點
TreeNode<K,V> replacementTreeNode(Node<K,V> p, Node<K,V> next) {
    return new TreeNode<>(p.hash, p.key, p.value, next);
}
// 重新初始化
void reinitialize() {
    // ……
}
// 節點操作后的調整
void afterNodeAccess(Node<K,V> p) { }
void afterNodeInsertion(boolean evict) { }
void afterNodeRemoval(Node<K,V> p) { }

LinkedHashMap初始化節點是通過重寫HashMap的newNode方法實現的,首先創建LinkedHashMap.Entry節點對象,其次將該節點對象鏈接到LinkedHashMap當前尾節點的后面(after域),成為新的尾節點。通過節點之間的鏈接來保證插入節點的有序性。

// LinkedHashMap的新建節點實現
Node<K,V> newNode(int hash, K key, V value, Node<K,V> e) {
    LinkedHashMap.Entry<K,V> p =
        new LinkedHashMap.Entry<K,V>(hash, key, value, e);
    // 將當前節點鏈接到尾節點的后面
    linkNodeLast(p);
    return p;
}
// 鏈接到尾節點的后面
private void linkNodeLast(LinkedHashMap.Entry<K,V> p) {
    LinkedHashMap.Entry<K,V> last = tail;
    tail = p;
    if (last == null)
        head = p;
    else {
        p.before = last;
        last.after = p;
    }
}

需要注意的是,LinkedHashMap并未改變節點存儲的順序,換句話說,在HashMap存儲節點的數組Node<K,V>[] table中,依然是按照Key的散列值決定其存儲位置的,比如將一些節點分別插入HashMap對象和LinkedHashMap對象,它們存儲在table數組中的順序是完全相同的。LinkedHashMap能夠按照插入順序進行遍歷輸出的原因是其迭代器LinkedHashIterator的的next方法,指向的是當前節點(Entry)的after節點;而HashMap迭代器HashIterator的next方法,是按照Node在table中存儲的順序進行遍歷的。

// LinkedHashMap的LinkedHashIterator實現
final LinkedHashMap.Entry<K,V> nextNode() {
    LinkedHashMap.Entry<K,V> e = next;
    if (modCount != expectedModCount)
        throw new ConcurrentModificationException();
    if (e == null)
        throw new NoSuchElementException();
    current = e;
    // next指向當前節點的after節點
    next = e.after;
    return e;
}
// HashMap的HashIterator實現
final Node<K,V> nextNode() {
    Node<K,V>[] t;
    Node<K,V> e = next;
    if (modCount != expectedModCount)
        throw new ConcurrentModificationException();
    if (e == null)
        throw new NoSuchElementException();
    // next指向當前槽位的下一個節點或者下一個槽位的首節點
    if ((next = (current = e).next) == null && (t = table) != null) {
        do {} while (index < t.length && (next = t[index++]) == null);
    }
    return e;
}

可以看出,LinkedHashMap的順序是在迭代器層面實現的。那LinkedHashMap的訪問順序又是如何實現的呢?也是通過迭代器嗎?LinkedHashMap在插入、查找以及替換元素之后都會調用afterNodeAccess方法進行重排序,下面來看下afterNodeAccess的實現。

// 將指定節點移至尾部
void afterNodeAccess(Node<K,V> e) {
    LinkedHashMap.Entry<K,V> last;
    if (accessOrder && (last = tail) != e) {
        LinkedHashMap.Entry<K,V> p =
            (LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
        // 尾節點的after節點為null
        p.after = null;
        // 指定節點為首節點,則將其after節點置為首節點
        if (b == null)
            head = a;
        // 否則將before節點的after節點置為指定節點的after節點
        else
            b.after = a;
        // 如果指定節點的after節點不為空,則將其before節點置為指定節點的before節點
        if (a != null)
            a.before = b;
        // 否則將其before節點置為last節點
        else
            last = b;
        // 如果last節點為null,則指定節點為頭結點
        if (last == null)
            head = p;
        // 否則將指定節點綁定到尾節點
        else {
            p.before = last;
            last.after = p;
        }
        tail = p;
        ++modCount;
    }
}

afterNodeAccess方法實現的核心功能是將指定節點移動到LinkedHashMap當前節點鏈的尾部,整個過程如下示意圖所示。


在28節點上調用afterNodeAccess方法的過程

由此可知,在訪問元素后,總會將該元素移動到LinkedHashMap當前節點鏈的尾部,而tail尾節點也就是最年輕(youngest)的節點,head是最老(eldest)的節點,從而實現了訪問順序的排序。回到本節開始提到的Android中LruCache基于LinkedHashMap的實現最近最少訪問最先刪除算法的問題。LruCache指定了緩存的最大值maxSize,緩存元素超過maxSize后會觸發刪除eldest節點,Android中的LinkedHashMap實現新增了eldest方法,返回的正好就是節點鏈的頭節點header(eldest),即最近最少訪問的節點。

public Entry<K, V> eldest() {
    LinkedEntry<K, V> eldest = header.nxt;
    return eldest != header ? eldest : null;
}

至此,我們分析了HashMap和LinkedHashMap的實現原理,相比于之前版本的實現,JDK 1.8中最壞情況下查找的時間復雜度已經由O(n)變為O(lgn),大大提高了性能。但在某些需要嚴格確保性能的場合,比如路由表實現,需要保證最壞情況下的時間復雜度仍為O(1),那么就需要重新設計散列算法,而不能使用標準Java庫中的鏈地址法來解決散列沖突了。

?著作權歸作者所有,轉載或內容合作請聯系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,572評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,071評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,409評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,569評論 1 307
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,360評論 6 404
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,895評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 42,979評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,123評論 0 286
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,643評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,559評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,742評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,250評論 5 356
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 43,981評論 3 346
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,363評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,622評論 1 280
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,354評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,707評論 2 370

推薦閱讀更多精彩內容