計算機程式的思維邏輯 (43) – 剖析TreeMap

swiftma發表於2019-03-04

本系列文章經補充和完善,已修訂整理成書《Java程式設計的邏輯》(馬俊昌著),由機械工業出版社華章分社出版,於2018年1月上市熱銷,讀者好評如潮!各大網店和書店有售,歡迎購買:京東自營連結

計算機程式的思維邏輯 (43) –  剖析TreeMap

40節介紹了HashMap,我們提到,HashMap有一個重要侷限,鍵值對之間沒有特定的順序,我們還提到,Map介面有另一個重要的實現類TreeMap,在TreeMap中,鍵值對之間按鍵有序,TreeMap的實現基礎是排序二叉樹,上節我們介紹了排序二叉樹的基本概念和演算法,本節我們來詳細討論TreeMap。

除了Map介面,因為有序,TreeMap還實現了更多介面和方法,下面,我們先來看TreeMap的用法,然後探討其內部實現。

基本用法

構造方法

TreeMap有兩個基本構造方法:

public TreeMap()
public TreeMap(Comparator<? super K> comparator)
複製程式碼

第一個為預設構造方法,如果使用預設構造方法,要求Map中的鍵實現Comparabe介面,TreeMap內部進行各種比較時會呼叫鍵的Comparable介面中的compareTo方法。

第二個接受一個比較器物件comparator,如果comparator不為null,在TreeMap內部進行比較時會呼叫這個comparator的compare方法,而不再呼叫鍵的compareTo方法,也不再要求鍵實現Comparable介面。

應該用哪一個呢?第一個更為簡單,但要求鍵實現Comparable介面,且期望的排序和鍵的比較結果是一致的,第二個更為靈活,不要求鍵實現Comparable介面,比較器可以用靈活複雜的方式進行實現。

需要強調的是,TreeMap是按鍵而不是按值有序,無論哪一種,都是對鍵而非值進行比較。

除了這兩個基本構造方法,TreeMap還有如下構造方法:

public TreeMap(Map<? extends K, ? extends V> m)
public TreeMap(SortedMap<K, ? extends V> m) 
複製程式碼

關於SortedMap介面,它擴充套件了Map介面,表示有序的Map,它有一個comparator()方法,返回其比較器,待會我們會進一步介紹。

這兩個構造方法都是接受一個已有的Map,將其所有鍵值對新增到當前TreeMap中來,區別在於,第一個構造方法中,比較器會設為null,而第二個,比較器會設為和引數SortedMap中的一樣。

接下來,我們來看一些簡單的使用TreeMap的例子。

基本例子

程式碼為:

Map<String, String> map  = new TreeMap<>();
map.put("a", "abstract");
map.put("c", "call");
map.put("b", "basic");

map.put("T", "tree");

for(Entry<String,String> kv : map.entrySet()){
    System.out.print(kv.getKey()+"="+kv.getValue()+" ");
}
複製程式碼

建立了一個TreeMap,但只是當做Map使用,不過迭代時,其輸出卻是按鍵排序的,輸出為:

T=tree a=abstract b=basic c=call 
複製程式碼

T排在最前面,是因為大寫字母都小於小寫字母。如果希望忽略大小寫呢?可以傳遞一個比較器,String類有一個靜態成員CASE_INSENSITIVE_ORDER,它就是一個忽略大小寫的Comparator物件,替換第一行程式碼為:

Map<String, String> map  = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
複製程式碼

輸出就會變為:

a=abstract b=basic c=call T=tree 
複製程式碼

正常排序是從小到大,如果希望逆序呢?可以傳遞一個不同的Comparator物件,第一行程式碼可以替換為:

Map<String, String> map  = new TreeMap<>(new Comparator<String>(){

    @Override
    public int compare(String o1, String o2) {
        return o2.compareTo(o1);
    }
});
複製程式碼

這樣,輸出會變為:

c=call b=basic a=abstract T=tree 
複製程式碼

為什麼這樣就可以逆序呢?正常排序中,compare方法內,是o1.compareTo(o2),兩個物件翻過來,自然就是逆序了,Collections類有一個靜態方法reverseOrder()可以返回一個逆序比較器,也就是說,上面程式碼也可以替換為:

Map<String, String> map  = new TreeMap<>(Collections.reverseOrder());
複製程式碼

如果希望逆序且忽略大小寫呢?第一行可以替換為:

Map<String, String> map  = new TreeMap<>(
        Collections.reverseOrder(String.CASE_INSENSITIVE_ORDER));
複製程式碼

需要說明的是,TreeMap使用鍵的比較結果對鍵進行排重,即使鍵實際上不同,但只要比較結果相同,它們就會被認為相同,鍵只會儲存一份。比如,如下程式碼:

Map<String, String> map  = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
map.put("T", "tree");
map.put("t", "try");

for(Entry<String,String> kv : map.entrySet()){
    System.out.print(kv.getKey()+"="+kv.getValue()+" ");
}
複製程式碼

看上去有兩個不同的鍵”T”和”t”,但因為比較器忽略大小寫,所以只會有一個,輸出會是:

T=try 
複製程式碼

鍵為第一次put時的,這裡即”T”,而值為最後一次put時的,這裡即”try”。

日期例子

我們再來看一個例子,鍵為字串形式的日期,值為一個統計數字,希望按照日期輸出,程式碼為:

Map<String, Integer> map  = new TreeMap<>();
map.put("2016-7-3", 100);
map.put("2016-7-10", 120);
map.put("2016-8-1", 90);

for(Entry<String,Integer> kv : map.entrySet()){
    System.out.println(kv.getKey()+","+kv.getValue());
}
複製程式碼

輸出為:

2016-7-10,120
2016-7-3,100
2016-8-1,90
複製程式碼

7月10號的排在了7月3號的前面,與期望的不符,這是因為,它們是按照字串比較的,按字串,2016-7-10就是小於2016-7-3,因為第一個不同之處1小於3。

怎麼解決呢?可以使用一個自定義的比較器,將字串轉換為日期,按日期進行比較,第一行程式碼可以改為:

Map<String, Integer> map  = new TreeMap<>(new Comparator<String>() {
    SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
    
    @Override
    public int compare(String o1, String o2) {
        try {
            return sdf.parse(o1).compareTo(sdf.parse(o2));
        } catch (ParseException e) {
            e.printStackTrace();
            return 0;
        }
    }
});
複製程式碼

這樣,輸出就符合期望了,會變為:

2016-7-3,100
2016-7-10,120
2016-8-1,90
複製程式碼

基本用法小結

以上就是TreeMap的基本用法,與HashMap相比:

  • 相同的是,它們都實現了Map介面,都可以按Map進行操作。
  • 不同的是,迭代時,TreeMap按鍵有序,為了實現有序,它要求:要麼鍵實現Comparable介面,要麼建立TreeMap時傳遞一個Comparator物件。

不過,由於TreeMap按鍵有序,它還支援更多介面和方法,具體來說,它還實現了SortedMap和NavigableMap介面,而NavigableMap介面擴充套件了SortedMap,我們來看一下這兩個介面。

高階用法

SortedMap介面

SortedMap介面的定義為:

public interface SortedMap<K,V> extends Map<K,V> {
    Comparator<? super K> comparator();
    SortedMap<K,V> subMap(K fromKey, K toKey);
    SortedMap<K,V> headMap(K toKey);
    SortedMap<K,V> tailMap(K fromKey);
    K firstKey();
    K lastKey();
}
複製程式碼

firstKey返回第一個鍵,而lastKey返回最後一個鍵。

headMap/tailMap/subMap都返回一個檢視,檢視中包括一部分鍵值對,它們的區別在於鍵的取值範圍:

  • headMap:為小於toKey的所有鍵
  • tailMap:為大於等於fromKey的所有鍵
  • subMap:為大於等於fromKey且小於toKey的所有鍵。

NavigableMap介面

NavigableMap擴充套件了SortedMap,主要增加了一些查詢鄰近鍵的方法,比如:

Map.Entry<K,V> floorEntry(K key);
Map.Entry<K,V> lowerEntry(K key);
Map.Entry<K,V> ceilingEntry(K key);
Map.Entry<K,V> higherEntry(K key);
複製程式碼

引數key對應的鍵不一定存在,但這些方法可能都有返回值,它們都返回一個鄰近鍵值對,它們的區別在於,這個鄰近鍵與引數key的關係。

  • floorEntry:鄰近鍵是小於等於key的鍵中最大的
  • lowerEntry:鄰近鍵是嚴格小於key的鍵中最大的
  • ceilingEntry:鄰近鍵是大於等於key的鍵中最小的
  • higherEntry:鄰近鍵是嚴格大於key的鍵中最小的

如果沒有對應的鄰近鍵,返回值為null。這些方法也都有對應的只返回鍵的方法:

K floorKey(K key);
K lowerKey(K key);
K ceilingKey(K key);
K higherKey(K key);
複製程式碼

相比SortedMap中的方法headMap/tailMap/subMap,NavigableMap也增加了一些方法,以更為明確的方式指定返回值中是否包含邊界值,如:

NavigableMap<K,V> headMap(K toKey, boolean inclusive);
NavigableMap<K,V> tailMap(K fromKey, boolean inclusive);
NavigableMap<K,V> subMap(K fromKey, boolean fromInclusive,
                             K toKey,   boolean toInclusive);
複製程式碼

相比SortedMap中對頭尾鍵的基本操作,NavigableMap增加了如下方法:

Map.Entry<K,V> firstEntry();
Map.Entry<K,V> lastEntry();
Map.Entry<K,V> pollFirstEntry();
Map.Entry<K,V> pollLastEntry();
複製程式碼

firstEntry返回第一個鍵值對,lastEntry返回最後一個。pollFirstEntry刪除並返回第一個鍵值對,pollLastEntry刪除並返回最後一個。

此外,NavigableMap有如下方法,可以方便的逆序訪問:

NavigableMap<K,V> descendingMap();
NavigableSet<K> descendingKeySet();
複製程式碼

示例程式碼

我們看一段簡單的示例程式碼,邏輯比較簡單,就不解釋了,主要是增強直觀感受,其中輸出用註釋說明了:

NavigableMap<String, String> map  = new TreeMap<>();
map.put("a", "abstract");
map.put("f", "final");
map.put("c", "call");

//輸出:a=abstract
System.out.println(map.firstEntry());

//輸出:f=final
System.out.println(map.lastEntry());

//輸出:c=call
System.out.println(map.floorEntry("d"));

//輸出:f=final
System.out.println(map.ceilingEntry("d"));

//輸出:{c=call, a=abstract}
System.out.println(map.descendingMap()
        .subMap("d", false, "a", true));
複製程式碼

瞭解了TreeMap的用法,接下來,我們來看TreeMap的實現原理。

基本實現原理

TreeMap內部是用紅黑樹實現的,紅黑樹是一種大致平衡的排序二叉樹,上節我們介紹了排序二叉樹的基本概念和演算法,本節我們主要看TreeMap的一些程式碼實現,先來看TreeMap的內部組成。

內部組成

TreeMap內部主要有如下成員:

private final Comparator<? super K> comparator;
private transient Entry<K,V> root = null;
private transient int size = 0;
複製程式碼

comparator就是比較器,在構造方法中傳遞,如果沒傳,就是null。size為當前鍵值對個數。root指向樹的根節點,從根節點可以訪問到每個節點,節點的型別為Entry。Entry是TreeMap的一個內部類,其內部成員和構造方法為:

static final class Entry<K,V> implements Map.Entry<K,V> {
    K key;
    V value;
    Entry<K,V> left = null;
    Entry<K,V> right = null;
    Entry<K,V> parent;
    boolean color = BLACK;

    /**
     * Make a new cell with given key, value, and parent, and with
     * {@code null} child links, and BLACK color.
     */
    Entry(K key, V value, Entry<K,V> parent) {
        this.key = key;
        this.value = value;
        this.parent = parent;
    }
}    
複製程式碼

每個節點除了鍵(key)和值(value)之外,還有三個引用,分別指向其左孩子(left)、右孩子(right)和父節點(parent),對於根節點,父節點為null,對於葉子節點,孩子節點都為null,還有一個成員color表示顏色,TreeMap是用紅黑樹實現的,每個節點都有一個顏色,非黑即紅。

瞭解了TreeMap的內部組成,我們來看一些主要方法的實現程式碼。

儲存鍵值對

put方法的程式碼稍微有點長,我們分段來看,先看第一段,新增第一個節點的情況:

public V put(K key, V value) {
    Entry<K,V> t = root;
    if (t == null) {
        compare(key, key); // type (and possibly null) check

        root = new Entry<>(key, value, null);
        size = 1;
        modCount++;
        return null;
    }
    ...
複製程式碼

當新增第一個節點時,root為null,執行的就是這段程式碼,主要就是新建一個節點,設定root指向它,size設定為1,modCount++的含義與之前幾節介紹的類似,用於迭代過程中檢測結構性變化。

令人費解的是compare呼叫,compare(key, key);,key與key比,有什麼意義呢?我們看compare方法的程式碼:

final int compare(Object k1, Object k2) {
    return comparator==null ? ((Comparable<? super K>)k1).compareTo((K)k2)
        : comparator.compare((K)k1, (K)k2);
}
複製程式碼

其實,這裡的目的不是為了比較,而是為了檢查key的型別和null,如果型別不匹配或為null,compare方法會丟擲異常。

如果不是第一次新增,會執行後面的程式碼,新增的關鍵步驟是尋找父節點,找父節點根據是否設定了comparator分為兩種情況,我們先來看設定了的情況,程式碼為:

int cmp;
Entry<K,V> parent;
// split comparator and comparable paths
Comparator<? super K> cpr = comparator;
if (cpr != null) {
    do {
        parent = t;
        cmp = cpr.compare(key, t.key);
        if (cmp < 0)
            t = t.left;
        else if (cmp > 0)
            t = t.right;
        else
            return t.setValue(value);
    } while (t != null);
}
複製程式碼

尋找是一個從根節點開始迴圈的過程,在迴圈中,cmp儲存比較結果,t指向當前比較節點,parent為t的父節點,迴圈結束後parent就是要找的父節點。

t一開始指向根節點,從根節點開始比較鍵,如果小於根節點,就將t設為左孩子,與左孩子比較,大於就與右孩子比較,就這樣一直比,直到t為null或比較結果為0。如果比較結果為0,表示已經有這個鍵了,設定值,然後返回。如果t為null,則當退出迴圈時,parent就指向待插入節點的父節點。

我們再來看沒有設定comparator的情況,程式碼為:

else {
    if (key == null)
        throw new NullPointerException();
    Comparable<? super K> k = (Comparable<? super K>) key;
    do {
        parent = t;
        cmp = k.compareTo(t.key);
        if (cmp < 0)
            t = t.left;
        else if (cmp > 0)
            t = t.right;
        else
            return t.setValue(value);
    } while (t != null);
}
複製程式碼

基本邏輯是一樣的,當退出迴圈時parent指向父節點,只是,如果沒有設定comparator,則假設key一定實現了Comparable介面,使用Comparable介面的compareTo方法進行比較。

找到父節點後,就是新建一個節點,根據新的鍵與父節點鍵的比較結果,插入作為左孩子或右孩子,並增加size和modCount,程式碼如下:

Entry<K,V> e = new Entry<>(key, value, parent);
if (cmp < 0)
    parent.left = e;
else
    parent.right = e;
fixAfterInsertion(e);
size++;
modCount++;
複製程式碼

程式碼大部分都容易理解,不過,裡面有一行重要呼叫fixAfterInsertion(e);,它就是在調整樹的結構,使之符合紅黑樹的約束,保持大致平衡,其程式碼我們就不介紹了。

稍微總結一下,其基本思路就是,迴圈比較找到父節點,並插入作為其左孩子或右孩子,然後調整保持樹的大致平衡。

根據鍵獲取值

程式碼為:

public V get(Object key) {
    Entry<K,V> p = getEntry(key);
    return (p==null ? null : p.value);
}
複製程式碼

就是根據key找對應節點p,找到節點後獲取值p.value,來看getEntry的程式碼:

final Entry<K,V> getEntry(Object key) {
    // Offload comparator-based version for sake of performance
    if (comparator != null)
        return getEntryUsingComparator(key);
    if (key == null)
        throw new NullPointerException();
    Comparable<? super K> k = (Comparable<? super K>) key;
    Entry<K,V> p = root;
    while (p != null) {
        int cmp = k.compareTo(p.key);
        if (cmp < 0)
            p = p.left;
        else if (cmp > 0)
            p = p.right;
        else
            return p;
    }
    return null;
}
複製程式碼

如果comparator不為空,呼叫單獨的方法getEntryUsingComparator,否則,假定key實現了Comparable介面,使用介面的compareTo方法進行比較,找的邏輯也很簡單,從根開始找,小於往左邊找,大於往右邊找,直到找到為止,如果沒找到,返回null。getEntryUsingComparator方法的邏輯是類似,就不贅述了。

檢視是否包含某個值

TreeMap可以高效的按鍵進行查詢,但如果要根據值進行查詢,則需要遍歷,我們來看程式碼:

public boolean containsValue(Object value) {
    for (Entry<K,V> e = getFirstEntry(); e != null; e = successor(e))
        if (valEquals(value, e.value))
            return true;
    return false;
}
複製程式碼

主體就是一個迴圈遍歷,getFirstEntry方法返回第一個節點,successor方法返回給定節點的後繼節點,valEquals就是比較值,從第一個節點開始,逐個進行比較,直到找到為止,如果迴圈結束也沒找到則返回false。

getFirstEntry的程式碼為:

final Entry<K,V> getFirstEntry() {
    Entry<K,V> p = root;
    if (p != null)
        while (p.left != null)
            p = p.left;
    return p;
}
複製程式碼

程式碼很簡單,第一個節點就是最左邊的節點。

上節我們介紹過找後繼的演算法,successor的具體程式碼為:

static <K,V> TreeMap.Entry<K,V> successor(Entry<K,V> t) {
    if (t == null)
        return null;
    else if (t.right != null) {
        Entry<K,V> p = t.right;
        while (p.left != null)
            p = p.left;
        return p;
    } else {
        Entry<K,V> p = t.parent;
        Entry<K,V> ch = t;
        while (p != null && ch == p.right) {
            ch = p;
            p = p.parent;
        }
        return p;
    }
}
複製程式碼

如上節後繼演算法所述,有兩種情況:

  • 如果有右孩子(t.right!=null),則後繼為右子樹中最小的節點。
  • 如果沒有右孩子,後繼為某祖先節點,從當前節點往上找,如果它是父節點的右孩子,則繼續找父節點,直到它不是右孩子或父節點為空,第一個非右孩子節點的父親節點就是後繼節點,如果父節點為空,則後繼為null。

程式碼與演算法是對應的,就不再贅述了,下面重複一下上節的一個後繼圖(綠色箭頭表示後繼)以方便對照:

計算機程式的思維邏輯 (43) –  剖析TreeMap

根據鍵刪除鍵值對

刪除的程式碼為:

public V remove(Object key) {
    Entry<K,V> p = getEntry(key);
    if (p == null)
        return null;

    V oldValue = p.value;
    deleteEntry(p);
    return oldValue;
}
複製程式碼

根據key找到節點,呼叫deleteEntry刪除節點,然後返回原來的值。

上節介紹過節點刪除的演算法,節點有三種情況:

  1. 葉子節點:這個容易處理,直接修改父節點對應引用置null即可。
  2. 只有一個孩子:就是在父親節點和孩子節點直接建立連結。
  3. 有兩個孩子:先找到後繼,找到後,替換當前節點的內容為後繼節點,然後再刪除後繼節點,因為這個後繼節點一定沒有左孩子,所以就將兩個孩子的情況轉換為了前面兩種情況。

deleteEntry的具體程式碼也稍微有點長,我們分段來看:

private void deleteEntry(Entry<K,V> p) {
    modCount++;
    size--;

    // If strictly internal, copy successor`s element to p and then make p
    // point to successor.
    if (p.left != null && p.right != null) {
        Entry<K,V> s = successor(p);
        p.key = s.key;
        p.value = s.value;
        p = s;
    } // p has 2 children
複製程式碼

這裡處理的就是兩個孩子的情況,s為後繼,當前節點p的key和value設定為了s的key和value,然後將待刪節點p指向了s,這樣就轉換為了一個孩子或葉子節點的情況。

再往下看一個孩子情況的程式碼:

// Start fixup at replacement node, if it exists.
Entry<K,V> replacement = (p.left != null ? p.left : p.right);

if (replacement != null) {
    // Link replacement to parent
    replacement.parent = p.parent;
    if (p.parent == null)
        root = replacement;
    else if (p == p.parent.left)
        p.parent.left  = replacement;
    else
        p.parent.right = replacement;

    // Null out links so they are OK to use by fixAfterDeletion.
    p.left = p.right = p.parent = null;

    // Fix replacement
    if (p.color == BLACK)
        fixAfterDeletion(replacement);
} else if (p.parent == null) { // return if we are the only node.
複製程式碼

p為待刪節點,replacement為要替換p的孩子節點,主體程式碼就是在p的父節點p.parent和replacement之間建立連結,以替換p.parent和p原來的連結,如果p.parent為null,則修改root以指向新的根。fixAfterDeletion重新平衡樹。

最後來看葉子節點的情況:

} else if (p.parent == null) { // return if we are the only node.
    root = null;
} else { //  No children. Use self as phantom replacement and unlink.
    if (p.color == BLACK)
        fixAfterDeletion(p);

    if (p.parent != null) {
        if (p == p.parent.left)
            p.parent.left = null;
        else if (p == p.parent.right)
            p.parent.right = null;
        p.parent = null;
    }
}
複製程式碼

再具體分為兩種情況,一種是刪除最後一個節點,修改root為null,否則就是根據待刪節點是父節點的左孩子還是右孩子,相應的設定孩子節點為null。

實現原理小結

以上就是TreeMap的基本實現原理,與上節介紹的排序二叉樹的基本概念和演算法是一致的,只是TreeMap用了紅黑樹。

TreeMap特點分析

與HashMap相比,TreeMap同樣實現了Map介面,但內部使用紅黑樹實現,紅黑樹是統計效率比較高的大致平衡的排序二叉樹,這決定了它有如下特點:

  • 按鍵有序,TreeMap同樣實現了SortedMap和NavigableMap介面,可以方便的根據鍵的順序進行查詢,如第一個、最後一個、某一範圍的鍵、鄰近鍵等。
  • 為了按鍵有序,TreeMap要求鍵實現Comparable介面或通過構造方法提供一個Comparator物件。
  • 根據鍵儲存、查詢、刪除的效率比較高,為O(h),h為樹的高度,在樹平衡的情況下,h為log2(N),N為節點數。

應該用HashMap還是TreeMap呢?不要求排序,優先考慮HashMap,要求排序,考慮TreeMap。

小結

本節介紹了TreeMap的用法和實現原理,在用法方面,它實現了Map介面,但按鍵有序,同樣實現了SortedMap和NavigableMap介面,在內部實現上,它使用紅黑樹,整體效率比較高。

HashMap有對應的TreeMap,HashSet也有對應的TreeSet,下節,我們來看TreeSet。


未完待續,檢視最新文章,敬請關注微信公眾號“老馬說程式設計”(掃描下方二維碼),深入淺出,老馬和你一起探索Java程式設計及計算機技術的本質。用心原創,保留所有版權。

計算機程式的思維邏輯 (43) –  剖析TreeMap

相關文章