Java集合(3)一 紅黑樹、TreeMap與TreeSet(上)

knock_小新發表於2019-03-04

引言

在系列的第一篇文章中說過Map<K,V>介面與Set<E>介面,Set<E>介面定義了一組不能新增重複元素的集,不能通過索引來訪問的集;Map<K,V>介面定義了從鍵對映到值的一組物件。同時也說過了因為鍵集不能重複的特性,Map<K,V>的鍵集由Set<E>來實現。 通過檢視TreeSet<E>的建構函式,可以看出他是通過TreeMap<K,V>來實現的,只不過僅使用了key。所以在這篇文章中我們會詳細講解TreeMap<K,V>,對TreeSet<E>就不做過多說明。

public TreeSet() {
    this(new TreeMap<E,Object>());
}

public TreeSet(Comparator<? super E> comparator) {
    this(new TreeMap<>(comparator));
}
複製程式碼

框架結構

Java集合(3)一 紅黑樹、TreeMap與TreeSet(上)

Java集合(3)一 紅黑樹、TreeMap與TreeSet(上)

TreeMap<K,V>繼承了SortedMap<K,V>介面,SortedMap<K,V>提供了排序功能,通過comparator方法用來對TreeMap裡面的每個物件進行比較來達到排序的目的。預設的排序是升序排序,也可以通過建構函式傳入比較器Comparator來進行排序。

//SortedMap介面包含的比較方法comparator
public interface SortedMap<K,V> extends Map<K,V> {
    Comparator<? super K> comparator();
}

public interface Comparator<T> {
    int compare(T o1, T o2);

    boolean equals(Object obj);
}
//TreeMap建構函式傳入比較器Comparator
public TreeMap(Comparator<? super K> comparator) {
    this.comparator = comparator;
}

public Comparator<? super K> comparator() {
    return comparator;
}
//TreeSet建構函式傳入比較器Comparator
public TreeSet(Comparator<? super E> comparator) {
    this(new TreeMap<>(comparator));
}

public Comparator<? super E> comparator() {
    return m.comparator();
}
複製程式碼

資料結構

TreeMap<K,V>是有序的Map,底層使用了紅黑樹這種資料結構來實現。紅黑樹是一種應用非常廣泛的樹結構,在這裡先簡單說下紅黑樹這種資料結構相比較其他樹型別結構的優缺點:

  1. 紅黑樹是一種自平衡的二叉查詢樹,也叫對稱二叉B樹,紅黑樹的查詢、插入和刪除的時間複雜度都為O(logn),應用非常廣泛。
  2. 紅黑樹相對於AVL樹(平衡二叉樹),犧牲了部分平衡性(紅黑樹不是完全平衡的二叉樹)以換取插入/刪除操作時更少的旋轉操作,整體在插入/刪除的效能上要優於AVL樹。所以很多在記憶體中排序的資料結構都使用紅黑樹來而不是使用AVL樹來儲存。
  3. 紅黑樹相對於B-樹和B+樹,相同節點的情況下紅黑樹由於深度比B-和B+樹要深的多,對IO讀寫非常頻繁,所以適合放在記憶體中的少量讀取,而B-和B+樹由於每個節點元素非常之多,訪問IO的次數就相對少,適合儲存在磁碟中的大量資料,類似資料庫記錄的儲存結構。 由於本文篇幅有限,文章中將重點講述二叉樹和紅黑樹,對AVL樹、B-樹、B+樹不做過多講解。

二叉排序樹

在分析TreeMap<K,V>的原始碼之前,我們先從二叉排序樹說起,因為紅黑樹也是一顆二叉樹,只不過是滿足一定條件的二叉樹,理解了二叉排序樹可以更方便理解紅黑樹。 二叉樹排序樹是一種非常典型的樹結構,通常使用連結串列做為儲存結構(也可以使用陣列)。由於樹結構每個節點都會儲存父子節點的引用,用連結串列結構更容易表達。如果使用陣列來儲存,當出現空子節點時對陣列空間是一種浪費,同時在查詢特定元素時由於陣列的元素沒有父子節點的引用,只能根據一定規則來遍歷,非常不方便,所以大多數情況下都使用連結串列來儲存樹結構。 二叉樹可以通過中序遍歷得到一個有序的序列,查詢和刪除都非常方便,一般情況下時間複雜度為O(logn),最壞O(n)。 排序二叉樹要麼是一顆空樹,要麼具有以下性質:

  1. 若任意節點的左子樹不空,則左子樹上所有節點的值均小於它的根節點的值;
  2. 若任意節點的右子樹不空,則右子樹上所有節點的值均大於它的根節點的值;
  3. 任意節點的左、右子樹也分別為二叉查詢樹;
  4. 沒有鍵值相等的節點。 下圖是一顆典型的二叉樹:
    Java集合(3)一 紅黑樹、TreeMap與TreeSet(上)

二叉樹遍歷

二叉樹做為一種樹結構,遍歷的目的是為了依次訪問樹中所有的節點,並且使每個節點只被訪問一遍。 他的遍歷的方式很多,一般有前中後序和層序遍歷四種。 中序遍歷就是先訪問左子樹,再訪問根節點,最後訪問右節點,根據二叉排序樹的性質可以知道,通過中序遍歷可以得到一個由小到大(預設情況下)的排序序列。所以中序遍歷使用的最頻繁。 下圖是中序遍歷的圖例和程式碼實現:

Java集合(3)一 紅黑樹、TreeMap與TreeSet(上)

public class BinaryTree {
    public void traversalBinaryTree(TreeNode tree) {
		//如果到了葉子節點則退出當前方法,繼續向下尋找
		if (tree == null) {
			return;
		}
		//迭代查詢左節點,一直到最左邊的葉子節點
		traversalBinaryTree(tree.left);
		System.out.println(tree.value);
		//迭代查詢右節點,一直到最左邊的葉子節點
		traversalBinaryTree(tree.right);
	}

    class TreeNode {
		//節點的值
		int value;
		//左節點
		TreeNode left;
		//右節點
		TreeNode right;
		//父節點
		TreeNode parent;
		
		public TreeNode(int treeValue, TreeNode parentNode) {
			value = treeValue;
			parent = parentNode;
			left = null;
			right = null;
		}
	}
}
複製程式碼

前序遍歷和後序遍歷:

//前序遍歷
System.out.println(tree.value);
traversalBinaryTree(tree.left);
traversalBinaryTree(tree.right);
//後序遍歷
traversalBinaryTree(tree.left);
traversalBinaryTree(tree.right);
System.out.println(tree.value);
複製程式碼

二叉樹新增

明白了二叉樹的遍歷,理解二叉樹的新增就非常簡單,通過中序遍歷從小到大查詢到要新增值的空葉子節點為止,我們來實現一個二叉樹的新增方法:

public void addBinaryTreeNode(int value) {
    //根節點
    TreeNode tree = root;
    if (tree == null) {
        //根節點為空則新建一個跟節點
        root = new TreeNode(value, null);
        return;
    }
    //用來儲存新節點的父節點
    TreeNode parentNode;
    do {
        //使用上次迴圈後的節點做為引用
        parentNode = tree;
        //如果新插入的 value 小於當前value,則向左邊查詢
        if (value < tree.value) {
            tree = tree.left;
        //如果新插入的 value 大於當前value,則向右邊查詢
        } else if (value > tree.value) {
            tree = tree.right;
        //如果相等則證明有相同節點,不新增
        } else {
            return;
        }
    } while (tree != null);
    //新建節點,parentNode為新節點的父節點
    TreeNode node = new TreeNode(value, parentNode);
    //新節點為左節點或者右節點
    if (value < parentNode.value) {
        parentNode.left = node;
    } else {
        parentNode.right = node;
    }	
}

public static void main(String[] args) {

    BinaryTree binaryTree = new BinaryTree();

    binaryTree.addBinaryTreeNode(10);
    binaryTree.addBinaryTreeNode(5);
    binaryTree.addBinaryTreeNode(20);
    binaryTree.addBinaryTreeNode(7);
    binaryTree.addBinaryTreeNode(6);
    binaryTree.addBinaryTreeNode(3);
    binaryTree.addBinaryTreeNode(15);
    binaryTree.addBinaryTreeNode(30);

    binaryTree.traversalBinaryTree(binaryTree.root);
}

//	Console:
//	3
//	5
//	6
//	7
//	10
//	15
//	20
//	30
複製程式碼

通過上面二叉樹新增節點的邏輯,我們再來分析TreeMap<K,V>原始碼中新增節點的實現。 TreeMap<K,V>通過put(K key, V value)方法將key和value放在一個Entry<K,V>節點中,Entry<K,V>相當於上面程式碼中的Node節點。

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);
        //記錄Map元素的數量
        size = 1;
        modCount++;
        return null;
    }
    int cmp;
    Entry<K,V> parent;
    // split comparator and comparable paths
    Comparator<? super K> cpr = comparator;
    //如果comparator不為空則代表使用定製的比較器進行排序
    if (cpr != null) {
        do {
            parent = t;
            cmp = cpr.compare(key, t.key);
            //如果新插入的key小於當前key,則向左邊查詢
            if (cmp < 0)
                t = t.left;
            //如果新插入的key大於當前key,則向右邊查詢
            else if (cmp > 0)
                t = t.right;
            //相等則覆蓋
            else
                return t.setValue(value);
        } while (t != null);
    }
    //如果comparator為空則使用預設比較器進行排序
    else {
        if (key == null)
            throw new NullPointerException();
        @SuppressWarnings("unchecked")
            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並初始化新節點Entry<K,V>
    Entry<K,V> e = new Entry<>(key, value, parent);
    //如果新插入的key小於父節點的key,則將插入節點作為父節點的左孩子
    if (cmp < 0)
        parent.left = e;
    //如果新插入的key大於父節點的key,則將插入節點作為父節點的右孩子
    else
        parent.right = e;
    //重點:修復紅黑樹(後面會說)
    fixAfterInsertion(e);
    size++;
    modCount++;
    return null;
}
複製程式碼

二叉樹刪除

二叉樹的刪除相比新增複雜一些,因為如果刪除的節點不是葉子節點,需要考慮由那個節點來替代當前節點的位置。刪除可以分6種情況:

  1. 刪除的節點沒有左右子節點,並且沒有父節點,則為根節點,直接刪除即可;
    Java集合(3)一 紅黑樹、TreeMap與TreeSet(上)
  2. 刪除的節點沒有左右子節點,有父節點,是為葉子節點,直接刪除即可;
    Java集合(3)一 紅黑樹、TreeMap與TreeSet(上)
  3. 刪除的節點是根節點,有左節點或右節點,用左節點或者右節點替換被刪除的根節點;
    Java集合(3)一 紅黑樹、TreeMap與TreeSet(上)
  4. 刪除的節點不是根節點,只有左節點,用左節點替換被刪除的節點;
    Java集合(3)一 紅黑樹、TreeMap與TreeSet(上)
  5. 刪除的節點不是根節點,只有右節點,用右節點替換被刪除的節點;
    Java集合(3)一 紅黑樹、TreeMap與TreeSet(上)
  6. 刪除的節點有左右子節點,用刪除節點的直接後繼節點替換被刪除的節點的值,然後刪除直接後繼節點,情況轉換為2、4或者5。
    Java集合(3)一 紅黑樹、TreeMap與TreeSet(上)

下面按照二叉樹刪除的6種情況我們來實現一個二叉樹的刪除演算法:

public void removeBinaryTreeNode(int value) {
    // 根節點
    TreeNode tree = root;
    if (tree == null) {
        return;
    }
    TreeNode currentNode = findBinaryTreeNode(value);
    if (currentNode == null) {
        return;
    }
    
    if (currentNode.left == null && currentNode.right == null) {
        //情況一 刪除根節點,並且沒有左右子節點
        if (currentNode.parent == null) {
            root = null;
        } else {
            //情況二 刪除葉子節點
            if (currentNode.parent.left == currentNode) {
                currentNode.parent.left = null;
            } else {
                currentNode.parent.right = null;
            }
            currentNode.parent = null;
        }
    } else if (currentNode.left == null || currentNode.right == null) {
        TreeNode replaceNode = currentNode.left == null ? currentNode.right : currentNode.left;
        replaceNode.parent = currentNode.parent;
        //情況三 刪除根節點 並且只有一個子節點
        if (currentNode.parent == null) {
            root = replaceNode;
        //情況四 不是根節點 只有左節點
        } else if (currentNode == currentNode.parent.left) {
            currentNode.parent.left = replaceNode;
        //情況五 不是根節點 只有右節點
        } else {
            currentNode.parent.right = replaceNode;
        }
        currentNode.parent = currentNode.left = currentNode.right = null;
    }  else {
        //情況六 同時有左右節點
        //successorNode 需要刪除節點的後繼節點
        TreeNode successorNode = currentNode.right;
        TreeNode parentNode;
        //查詢後繼節點
        do {
            parentNode =  successorNode;
            successorNode = successorNode.left;
        } while (successorNode != null);
        successorNode = parentNode;
        //覆蓋需要刪除的節點的值為後繼節點的值
        currentNode.value = successorNode.value;
        //後繼節點的左節點一定為空,如果不為空則說明當前節點不是後繼節點
        if (successorNode.right != null) {
            //關聯後繼節點的右節點和後繼節點的父節點
            TreeNode replaceNode = successorNode.right;
            replaceNode.parent = successorNode.parent;
            if (successorNode.parent.left == successorNode) {
                successorNode.parent.left = replaceNode;
            }
            if (successorNode.parent.right == successorNode) {
                successorNode.parent.right = replaceNode;
            }
        }
        //刪除後繼節點
        successorNode.parent = successorNode.left = successorNode.right = null;
    }
}
//查詢當前值所對應的樹節點
public TreeNode findBinaryTreeNode(int value) {
    // 根節點
    TreeNode tree = root;
    if (tree == null) {
        return null;
    }
    // 用來儲存新節點的父節點
    TreeNode parentNode;
    do {
        // 迴圈迭代從小到大查詢直到葉子為空
        parentNode = tree;
        if (value < tree.value) {
            tree = tree.left;
        } else if (value > tree.value) {
            tree = tree.right;
        } else {
            return parentNode;
        }
    } while (tree != null);
    return null;
}
複製程式碼

通過上面二叉樹刪除節點的邏輯,再來分析TreeMap<K,V>原始碼中刪除節點的實現。 TreeMap<K,V>通過remove(Object key)來刪除key所代表的節點,先通過getEntry(key)查詢到需要刪除的節點Entry<K,V> p,然後通過deleteEntry(Entry<K,V> p)刪除p節點。

public V remove(Object key) {
    //查詢到key所代表的節點p
    Entry<K,V> p = getEntry(key);
    if (p == null)
        return null;

    V oldValue = p.value;
    deleteEntry(p);
    return oldValue;
}

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.
    //如果被刪除的節點左右孩子都不為空,則查詢到P的直接後繼節點,用後繼節點的鍵和值覆蓋P的鍵和值,然後刪除後繼節點即可(實際上是情況六)
    //這一步非常巧妙,將要刪除的節點轉換為要刪除節點的直接後繼節點,情況六轉換為情況二,四,五
    if (p.left != null && p.right != null) {
        //查詢到P的直接後繼節點
        Entry<K,V> s = successor(p);
        //後繼節點覆蓋鍵和值到P
        p.key = s.key;
        p.value = s.value;
        //將要刪除的節點變為P的後繼節點,刪除後繼節點即可
        p = s;
    } // p has 2 children

    // Start fixup at replacement node, if it exists.
    //查詢用來替換的節點
    //經過上面的步驟,如果P存在左節點,則不存在右節點,直接用左節點替換即可。因為如果左右節點都存在,則會查詢到後繼(後繼節點的左節點一定為空)。
    //如果P左節點不存在,存在右節點,則直接用右節點來替換即可。
    Entry<K,V> replacement = (p.left != null ? p.left : p.right);
    //通過上面的步驟說明左右節點只可能同時存在一個,replacement為左右子節點當中的任何一個
    if (replacement != null) {
        // Link replacement to parent
        //p節點將要刪除,將用來替換的節點的父節點指向p的父節點
        replacement.parent = p.parent;
        //p的父節點為空,則說明刪除的是根節點(情況三)
        if (p.parent == null)
            root = replacement;
        //replacement替換為左節點(情況四)
        else if (p == p.parent.left)
            p.parent.left  = replacement;
        //replacement替換為右節點(情況五)
        else
            p.parent.right = replacement;

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

        // Fix replacement
        //重點:修復紅黑樹(後面會說)
        if (p.color == BLACK)
            fixAfterDeletion(replacement);
    //如果替換的節點為空,p的父節點也為空則為根節點,直接刪除即可(情況一)
    } else if (p.parent == null) { // return if we are the only node.
        root = null;
    //如果替換的節點為空,p的父節點不為空,說明為葉子節點(情況二)
    } 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;
        }
    }
}
//查詢t節點的直接後繼節點
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;
    }
}
複製程式碼

總結

在這篇文章中我們自己實現了二叉樹的遍歷、新增節點以及刪除節點的操作邏輯,然後詳解了TreeMap<K,V>中關於節點刪除和新增的邏輯,略過了紅黑樹的操作。 看完後相信大家對二叉樹的基本操作有了一定了解,下一篇文章中將詳細講解TreeMap<K,V>中對滿足紅黑樹性質所進行的操作。

相關文章