Java實現紅黑樹(平衡二叉樹)

qzlzzz發表於2021-10-13

前言

在實現紅黑樹之前,我們先來了解一下符號表。

符號表的描述借鑑了Algorithms第四版,詳情在:https://algs4.cs.princeton.edu/home/

符號表有時候被稱為字典,就如同英語字典中,一個單詞對應一個解釋,符號表有時候又被稱之為索引,即書本最後將術語按照字母順序列出以方便查詢的那部分。總的來說,符號表就是將一個鍵和一個值聯絡起來,就如Python中的字典,JAVA中的HashMap和HashTable,Redis中以鍵值對的儲存方式。

在如今的大資料時代,符號表的使用是非常頻繁的,但在一個擁有著海量資料的符號表中,如何去實現快速的查詢以及插入資料就是高效的演算法去完成的事情,可以說沒有這些演算法的發明產生,資訊時代無從談起。

既然是資料結構去實現符號表,這就要求我們對符號表的API,也就是符號表的功能去定義,前面我們說到既然符號表的使用是如何在海量資料中去查詢,插入資料,那麼我們便定義符號表的API有增刪改查這四個基本功能。

/**
 * <p>
 *     符號表的基本API
 * </p>
 * @author qzlzzz
 * @version 1.0
 * @since 2021/10/8
 */
public interface RedBlackBST<Key extends Comparable<Key>,Value> {

    /**
     * 根據Key在符號表中找到Value
     * @param key the key
     * @return the value of key
     */
    Value get(Key key);

    /**
     * 插入key-value,如果符號表中有Key,且Key不為空則將該Key的Value轉為傳入的Value
     * @param key the-key
     * @param value the-value
     */
    void put(Key key,Value value);

    /**
     * 根據Key去符號表中刪除Key
     * @param key the key
     */
    void delete(Key key);

}

這裡由於紅黑樹是平衡二叉樹,即意味著其有平衡性和有序性,因為其有序性的特點,因此我們可以範圍或根據位置去需找鍵,也可以查詢到樹中的最小鍵和最大鍵。

至於什麼是平衡性,文章後講,這裡先停一停。

因此我們可以額外的定義:

    /**
     * 根據位置返回鍵,如果沒有返回null
     * @param k the index of key
     * @return the key
     */
    Key select(int k);

    /**
     * 返回紅黑樹中最小的鍵
     * @return the min key in this tree
     */
    Key min();

    /**
     * 返回紅黑樹中最大的鍵
     * @return the max key in this tree
     */
    Key max();

    /**
     * 返回小於該鍵的數量
     * @param key the key
     * @return amount of key small than the key
     */
    int rank(Key key);

接下來我們說說紅黑樹。

紅黑二叉查詢樹

紅黑二叉查詢樹實際上基於二叉查詢樹上實現了2-3樹,也就是說紅黑二叉查詢樹是一個2-3樹。所以在認識紅黑二叉查詢樹之前,我們得了解2-3樹的原理,以及組成結構。

2-3樹

我們把含有一個鍵,兩個連結的結點稱為2-結點,標準的二叉查詢樹其每個結點都是2-結點,在考慮好的情況下,我們構造標準二叉查詢樹,一般能夠得到樹高為總鍵樹的對數的一個查詢樹,其查詢和插入操作都是對數級別的,但標準二叉查詢樹的基本實現的良好效能取決於鍵值對分佈的足夠亂以致於打消長路徑帶來的問題,但我們不能保證插入情況是隨機的,如果鍵值對的插入時順序插入的,就會帶來下面的問題:

從圖中我們可以看到,我們將A,B,C,D,E按順序插入的話,會得到一個鍵值與樹高成正比的二叉查詢樹,其插入和查詢的會從對數級別提到O(N)級別

當然我們希望的肯定是無論鍵值對的情況是怎樣的,我們都能構造一個樹高與總鍵數成對數,插入查詢等操作均能夠在對數時間內完成的資料結構。也就是說,在順序插入的情況下,我們希望樹高依然為~lgN,這樣我們就能保證所有的查詢都能在~lgN次比較結束。

為了保證查詢樹的平衡性,我們需要一些靈活性,因此在這裡我們允許樹中的一個結點儲存多個鍵,我們引入3-結點,所謂的3-結點就是一個結點中有2個鍵,3個連結。

因此一顆2-3查詢樹或為一顆空樹,或由2-結點和3-結點組成。在介紹2-3樹的操作前,我們將A,B,C,D,E,F,G,H順序插入得到的樹如下圖所示:

從圖中我們可以看出2-3樹的平衡性,靈活性,它保證了任意的插入得到的樹高依舊是總鍵的對數。

2-3樹的插入操作

理解2-3樹的插入操作,有利於去構造紅黑樹,在這裡分三種情況:

  1. 插入新鍵,底層結點是2-結點
  2. 插入新鍵,底層結點是3-結點,父結點是2-結點
  3. 插入新鍵,底層結點是3-結點,父結點是3-結點

第一種情況

若插入新鍵,底層結點是2-結點的話,該底層結點變為3-結點,將插入的鍵儲存其中即可。

第二種情況

若插入新鍵,底層結點是3-結點,底層結點先變成臨時的4-結點(3個鍵,4條連結),後4-結點中的中鍵吐出,使得父節點由2-結點變為3-結點,原4-結點中鍵兩邊的鍵變成兩個2-結點,原本由父結點指向子結點的一個連結,替換為原4-結點中鍵左右兩邊的連結,分別指向兩個新的2-結點。

第三種情況

若插入新鍵,底層結點是3-結點,其父結點也是3-結點的話,使得底層結點變為臨時的4-結點,後4-結點中的中鍵吐出,使得父節點由3-結點變為臨時的4-結點,原4-結點中鍵兩邊的鍵變成兩個2-結點,原本由父結點指向子結點的一個連結,替換為原4-結點中鍵左右兩邊的連結,分別指向兩個新的2-結點,隨後父節點也要吐出中鍵,重複上述的步驟,如果父節點的父節點也是3-結點,則繼續持續上述步驟,若根結點也是3-結點,根節點吐出中鍵,生成兩個2-結點後,整個樹高+1,但各個底層結點到根結點的路徑始終相等。

以上的三種變化是2-3樹的動態變化的核心,非常關鍵,我們可以在推演的過程種看到這種變化是自下向上的,而且是區域性的變化,這種區域性的變化並沒有影響2-3樹的有序性和平衡性。

同時我們也可以看出,如果要以程式碼來實現2-3樹的話相當的麻煩,因為需要處理的情況實在太多。我們需要維護兩種不同型別的結點,將被查詢的鍵和結中的每個鍵進行比較,將連結和其他資訊從一個結點複製到另一個結點。實現這些需要大量的程式碼,實現的這些程式碼所帶來開銷或許還會比標準二叉查詢樹要多。因此後麵人們想出了結合標準二叉樹來實現2-3樹的資料結構,這便是紅黑樹

實現紅黑二叉樹

紅黑樹是基於標準二叉樹來實現的,它實現2-3樹的關鍵點在於它把二叉樹的連結分為了紅和黑。它將兩個用紅鏈相連結的結點看為3-結點,而黑鏈連結的結點則視為2-結點。這也意味著我們完全不用去重新寫一個紅黑樹的get()方法,只需要使用標準二叉樹的get()方法就可以實現查詢,不同點在於,要在put()方法中改動一下便能夠去實現一個紅黑二叉查詢樹。實現紅黑樹程式碼改動量少,但其後面的思想其實很複雜,由於篇幅的原因,對紅黑樹如何去實現2-3樹的三種變化的原理就不做過多描述。

首先定義結點

/**
 * <h3>
 *     紅黑樹的實現,部落格:https://www.cnblogs.com/qzlzzz/p/15395010.html
 * </h3>
 * @author qzlzzz
 * @since 2021/10/12
 * @version 1.0
 */
public class RedBlackBST<Key extends Comparable<Key>,Value> {
    
        
    private Node root;//根節點

    //<父結點>指向自己<子結點>的連結是黑色的
    private static final boolean RED = true;

    //<父結點>指向自己<子結點>的連結是黑色的
    private static final boolean BLACK = false;

    /**
     * <p>紅黑樹的結點定義</p>
     * @author qzlzzz
     */
    private class Node{
        
        private boolean color;//指向該結點的連結的顏色
        
        private Key key;//鍵
        
        private Value value;//值
        
        private Node left,right;//該結點指向左結點的連結和右結點的連結
        
        private int n;//該子樹的結點樹

        public Node(Key key,Value value,boolean color,int n){
            this.key = key;
            this.value = value;
            this.color = color;
            this.n = n;
        }
    }
    
}

若紅連結為右連結,使連結轉左。

在這裡我們需要保持紅連結為左連結。但使紅連結保持為右連結也行,只不過左連結更好實現。

    /**
     * 計算紅黑樹的結點總數,內部呼叫了{@link RedBlackBST#size(Node)}
     * @return
     */
    public int size(){
        return size(root);
    }

    //計算某個子樹的結點總數
    private int size(Node x){
        if (x == null) return 0;
        else return x.n;
    }

    /**
     * 將紅色右連結變為左連結,總體有序性不變,子樹結點數量不變
     * @param h
     * @return
     */
    private Node rotateLeft(Node h){
        Node t = h.right;
        h.right = t.left;
        t.left = h;
        t.color = h.color;
        h.color = RED;
        t.n = h.n;//轉換後子樹的結點是不變的,
        h.n = size(h.left) + size(h.right) + 1;
        return t;
    }

轉換的程式碼圖是這樣的:

這裡的1 2 3指的是鍵的大小,並不是值,紅黑樹各個底層到根節點的黑連結總數的相同的,這符合了2-3樹中各個底層結點到根節點的距離相等。

這裡將紅左連結轉換為右連結的思想是一樣的,讀者可以自己嘗試去實現。

判斷連結是否為紅連結

    //判斷連結是否為紅色,不是返回false
    private boolean isRed(Node x){
        if (x == null) return false;
        return x.color;
    }

若左右兩邊的連結皆為紅色,將兩邊連結顏色設定為黑色,並使指向自己連結的顏色設為紅

    /**
     * <p>若左右兩邊的連結皆為紅色,將兩邊連結顏色設定為黑色,並使指向自己連結的顏色設為紅</p>
     * @param x
     */
    private void changeColor(Node x){
        x.color = true;
        x.left.color = false;
        x.right.color = true;
    }

為什麼要這樣呢?

  • 其實跟上述2-3樹的第二個操作脫不開關係。當結點為臨時4-結點時,吐出中鍵,兩邊的鍵變為兩個2-結點,原指向臨時4-結點的連結變為原4-結點中間兩邊的連結並指向新的2-結點,如果父結點為2-結點,則於原4-中鍵一起變成3-結點,若父節點是3-結點,則迴圈上述操作,由於我們要保持紅連結為做連結,中途若有右紅連結產生還需要使用rotateLeft()方法去轉換。

接下來讓我們以紅黑二叉樹實現符號表的get、put

    /**
     * 通過鍵來查詢值,內部呼叫{@link RedBlackBST#get(Node, Comparable)}
     * @param key
     * @return
     */
    public Value get(Key key){
        if (key == null) throw new IllegalArgumentException("argument to get() is null");
        return get(root,key);
    }
    
    private Value get(Node x,Key key){
        for (;;){
            if (x == null) return null;
            int cmp = key.compareTo(x.key);
            if (cmp == 0) return x.value;
            else if (cmp < 0) x = x.left;
            else x = x.right;
        }
    }
    /**
     * 插入鍵值對,內部使用{@link RedBlackBST#put(Node, Comparable, Object)}
     * @param key
     * @param value
     */
    public void put(Key key,Value value){
        if (key == null) throw new IllegalArgumentException("argument to put() is null");
        root = put(root,key,value);
        root.color = false;
    }

    private Node put(Node x,Key key,Value value){
        if (x == null) return new Node(key,value,RED,1);
        int cmp = key.compareTo(x.key);
        if (cmp == 0) {x.value = value;}
        else if (cmp < 0) x.left = put(x.left,key,value);
        else x.right = put(x.right,key,value);
        
        if (isRed(x.right) && !isRed(x.left)) x = rotateLeft(x);
        if (isRed(x.left) && isRed(x.left.left)) x = rotateRight(x);
        if (isRed(x.left) && isRed(x.right)) changeColor(x);
        
        x.n = size(x.left) + size(x.right) + 1;
        return x;
    }

至於put方法,後面的三個if語句則是:

  • 當前結點的右連結為紅色的話,將其轉為左紅連結。當左右連結皆為紅色,呼叫changeColor()方法,使得其完成2-3樹的區域性動態變化,也就是上述說的2-3樹的插入新鍵,底層結點是3-結點,父結點是2-結點的操作。
  • 當前結點的左連結,以及左連結的左連線都為紅色的話,說明這是一個臨時的4-結點,我們需要將第一個左紅連結轉為右紅連結,然後得到一個左右連結都為紅的子樹,呼叫changeRed()方法使得其完成2-3樹的區域性動態變化,也就是上述說的2-3樹的插入新鍵,底層結點是3-結點,父結點是2-結點的操作。
  • 當左右連結都為紅色,呼叫changeColor()方法。

最後實現符號表的rank,select

    /**
     * 根據位置返回鍵,內部呼叫{@link RedBlackBST#select(Node, int)}
     * @param k
     * @return
     */
    public Key select(int k){
        return select(root,k);
    }

    private Key select(Node x,int k){
        while(x != null){
            int t = x.left.N;
            if (t > k) x = x.left;
            else if (t < k){
                x = x.right;
                k = k - t - 1;
            }
            else return x.key;
        }
        return null;
    }

    /**
     * 根據鍵,返回該鍵的數量,內部呼叫{@link RedBlackBST#rank(Node, Comparable)}
     * @param key
     * @return
     */
    public int rank(Key key){
        return rank(root,key);
    }

    private int rank(Node x,Key key){
        while (x != null){
            int cmp = key.compareTo(x.key);
            int count = x.left.N;
            if (cmp == 0) return (count < root.N ? count : 1 + root.left.N + count);
            else if (cmp < 0) x = x.left;
            else x = x.right;
        }
        return 0;
    }

最後以紅黑二叉樹的符號表實現完成了,讀者也可以嘗試將put()方法中的後三個語句放在判斷結點x為空的語句後面,有意思的是,此樹會變成一個2-3-4樹,也就說存在4-結點的一顆樹。

結尾

感謝zsh帥哥,若本文有什麼需要改進或不足的地方請聯絡我。
本文參考了:https://algs4.cs.princeton.edu/30searching/

相關文章