自己手寫HashMap——紅黑樹的Java實現
你好,這篇文章是《自己手寫HashMap》的第一篇。 在java7之前,HashMap是用陣列(hash桶)+連結串列的形式實現的,大概的原理就是對key求hashCode,hashCode對當前陣列的大小求模即為key對應的陣列下標,hashCode值相同放在同一個下標,按連結串列的形式儲存, 大概像下面這樣

(圖片來自網路)
對,就是這麼簡單。當然java7以及以前,大家對HashMap的效能一直是存疑的,因為如果出現大量的hashCode碰撞的話,那就不可避免的要花費O(N)的查詢時間,這跟普通線性表沒啥區別。 所以在java8的時候對HashMap的結構進行了改整,基本結構依然是陣列(hash桶)+連結串列的形式,但是求hashCode的時候加入了擾動函式用來減少碰撞,之前的普通連結串列也改為了紅黑樹(在元素個數極少的時候依然是普通連結串列), 大概像下面這樣

(圖片來自網路) (以上圖片僅供參考,並不代表真實資料)
這次主要講的就是紅黑樹,進入正題。
1.紅黑樹的基本定義簡單概述
紅黑樹是一棵二叉搜尋樹(什麼是二叉搜尋樹?),跟普通的二叉樹不同的是,它在每個結點上增加了一個顏色屬性,就是red或black,然後通過顏色上的約束讓資料比較均衡的分佈在各個結點,紅黑樹就是一顆近似的平衡樹,而為什麼不直接使用平衡二叉樹,當然是為了平衡各種操作的效能。 篇幅有限,具體紅黑樹性質點這裡,但是不看也沒關係,因為後邊貼程式碼的時候會詳細講
2.為什麼選擇紅黑樹(紅黑樹的效能)
這裡直接講結論,一顆有n個內部結點的紅黑樹的高度最多為2lg(n+1), 對紅黑樹的基本增刪查操作,包括求最大最小值,其時間複雜度最壞為O(lgn)。
3.紅黑樹的基本操作(新增、遍歷、刪除)
接下來就是程式碼部分
(1)實體類組織結構
1.首先要有一個物件用來儲存當前節點的資訊,包含以下幾個屬性
- 顏色,red或者black、
- 當前結點的key和value
- 父結點(根結點的父結點為null)
- 左子結點
- 右子結點
如下
import java.io.Serializable; import java.util.Objects; public class Node<K, V> implements Serializable , Comparable<Node<K, V>>{ private int color; private K key; private V value; private Node<K, V> pro; private Node<K, V> left; private Node<K, V> right; public Node(int color, K key, V value, Node<K, V> pro, Node<K, V> left, Node<K, V> right) { this.color = color; this.key = key; this.value = value; this.pro = pro; this.left = left; this.right = right; } public Node() { } public int getColor() { return color; } public void setColor(int color) { this.color = color; } public K getKey() { return key; } public void setKey(K key) { this.key = key; } public V getValue() { return value; } public void setValue(V value) { this.value = value; } public Node<K, V> getPro() { return pro; } public void setPro(Node pro) { this.pro = pro; } public Node<K, V> getLeft() { return left; } public void setLeft(Node left) { this.left = left; } public Node<K, V> getRight() { return right; } public void setRight(Node right) { this.right = right; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; Node<?, ?> node = (Node<?, ?>) o; return Objects.equals(key, node.key); } @Override public int hashCode() { return Objects.hash(key); } @Override public int compareTo(Node<K, V> o) { return this.hashCode() - o.hashCode(); } } 複製程式碼
這裡重寫了equals、hashCode和compareTo方法,主要是為了之後的程式碼寫起來方便,因為經常要用到比較操作,並且這裡加入了泛型
2.其次就是紅黑樹物件
- 需要有一個屬性來儲存根結點
基本結構就像這樣
public class Tree<K, V> implements Serializable, Iterable<Node<K, V>> { private Node<K, V> root; private int size; private int BLACK = 0; private int RED = 1; public Tree() { this.size = 0; } public int size() { return this.size; } } 複製程式碼
繼承Iterable是因為之後要實現迭代器
目前為止要實現一棵紅黑樹,用到的實體類就這麼多。
(2)put操作
插入資料並不難,難的是必須要在資料插入之後當前樹依然符合紅黑樹的性質,所以我們先來看一下紅黑樹的約束條件
- 每個結點要麼是紅色的要麼是黑色的
- 根結點是黑色的
- 紅色結點的兩個子結點必須是黑色的
- 對於每個結點,從該結點到其所有後代葉結點(最後一個沒有分支的子結點)的簡單路徑上,均包含相同數目的黑色結點
乍一看規則還挺多,其實紅黑樹的最終追求就是希望結點可以平均分佈,每個分支上的高度不要相差太多,之所以為每個結點新增顏色約束也是為了達到這個目的。 而為了實現這個目的,我們需要在插入資料之後,調整紅黑樹以符合性質
先一步一步來,寫一個方法來將一個結點插入到當前樹
public void put(K key, V value) { Node<K, V> z = new Node<>(); z.setKey(key); z.setValue(value); z.setColor(RED); this.size++; Node<K, V> y = null; Node<K, V> x = root; while (x != null) { y = x; int sp = z.compareTo(x); if (sp < 0) { x = x.getLeft(); } else if (sp > 0) { x = x.getRight(); } else { x.setValue(value); this.size--; return; } } z.setPro(y); if (y == null) { root = z; } else if (z.compareTo(y) < 0) { y.setLeft(z); } else if (z.compareTo(y) > 0) { y.setRight(z); } //調整紅黑樹 this.fixup(z); } 複製程式碼
以上,我們一開始新建了一個紅色結點,將key value 值 set進去,並將樹的大小加一,之所以要把新結點設成紅色,是因為插入一個紅色結點要比插入一個黑色結點要簡單的多,下邊會詳細講。 之後通過迴圈將新結點和當前節點比較大小,來確定新結點插入的位置,小的插入左子節點,大的插入右子結點,當然如果結點相等,直接覆蓋就好了。 這還不算完,找到插入位置後要設定一下新結點的父結點,並將父結點的子結點指向新結點,如果當前根結點為空,就直接將新結點設定成根結點就好了, 最後,插入新的結點之後可能會違反紅黑樹的性質,所以我們呼叫fixup方法來調整紅黑樹
private void fixup(Node<K, V> z) { while (z.getPro() != null && z.getPro().getColor() == RED) { if (z.getPro().getPro() != null) { if (z.getPro().equals(z.getPro().getPro().getLeft())) { Node<K, V> y = z.getPro().getPro().getRight(); if (y != null && y.getColor() == RED) { z.getPro().setColor(BLACK); y.setColor(BLACK); z.getPro().getPro().setColor(RED); z = z.getPro().getPro(); } else { if (z.equals(z.getPro().getRight())) { z = z.getPro(); this.leftRotate(z); } z.getPro().setColor(BLACK); z.getPro().getPro().setColor(RED); this.rightRotate(z.getPro().getPro()); } } else if (z.getPro().equals(z.getPro().getPro().getRight())) { Node<K, V> y = z.getPro().getPro().getLeft(); if (y != null && y.getColor() == RED) { z.getPro().setColor(BLACK); y.setColor(BLACK); z.getPro().getPro().setColor(RED); z = z.getPro().getPro(); } else { if (z.equals(z.getPro().getLeft())) { z = z.getPro(); this.rightRotate(z); } z.getPro().setColor(BLACK); z.getPro().getPro().setColor(RED); this.leftRotate(z.getPro().getPro()); } } } } this.root.setColor(BLACK); } 複製程式碼
來簡單的分析一下,當我們插入一個新結點的時候,哪些性質可能會被破壞,性質1肯定是成立的,因為我們插入的是一個紅色結點,所以性質4也是成立的,但是性質2可能會被破壞,如果父結點也是紅色的,那麼性質3也會被破壞。 所以滿足性質2和3是我們調整當前樹的目標, 現在是不是在想,如果我們插入的是一個黑色結點,性質2、3不就滿足了,但是如果那樣的話,性質4就不滿足了,我們可能需要調整非常多的結點才能滿足所有性質,如果是插入一個紅色結點的話,我們只需要把調整目標放在左或者右一邊就好了,繼續往下看, 我們知道了現在需要做的事情,就是調整樹結構滿足性質2和3, 簡單思考一下,如果性質2被破壞,那麼原因肯定是當前根節點為空,而違反性質3,那肯定是新結點的父節點為紅色,因為根結點必須為黑色,所以新節點的父結點必然不是根結點, 所有性質2和3並不會同時違反,單次插入操作,只會違反其中一個 ,這樣推理的話,情況就變的簡單多了。 性質2比較好處理,直接將新結點或者說根結點設定成黑色就好,違反性質3就稍微了複雜一點,這裡總共存在6種情況,但是因為插入左邊和插入右邊是對稱操作的原因,我們只需要思考其中三種情況就好,另外三種反過來操作即可。
先宣告一點,違反性質3僅在新結點的父結點是紅色的情況下
- 新結點的叔結點是紅色的
- 新結點的叔結點是黑色的且新結點是一個右子結點
- 結點的叔結點是黑色的且新結點是一個左子結點
叔結點就是與當前節點的父結點平級的另一個結點
第一種情況就是新結點,父結點,叔結點都是紅色的,試想一下,如果父節點是紅色的,那麼父結點的父結點必定是黑色的,所以將父結點和叔結點都著為黑色來滿足性質3,但是因為黑色結點的增加,會違反性質4,所以將父節點的父節點著為紅色(上邊程式碼8-11行),至此情況一解決。 如下圖  第二種情況,叔結點是黑色的,新結點是右子結點,聰明的同學可以想到,這個和情況三是對稱的,所以我們可以通過一些操作,將這兩種情況互轉,然後處理其中一種即可,然而如何才能在保證其他三條性質不會違反的情況下轉換樹的結構呢,那就是旋轉。 旋轉分為左旋和右旋
左旋

解析一下左旋操作,左旋就是將當前結點(E)移動到左邊,讓它的右子結點(S)成為它的父結點並頂替之前它的位置,然後讓它的右子結點的左子結點成為它的新右子結點,說起來很繞,但是看圖其實很簡單。
private void leftRotate(Node<K, V> x) { Node<K, V> y = x.getRight(); x.setRight(y.getLeft()); if (y.getLeft() != null) { y.getLeft().setPro(x); } y.setPro(x.getPro()); if (x.getPro() == null) { this.root = y; } else if (x.equals(x.getPro().getLeft())) { x.getPro().setLeft(y); } else { x.getPro().setRight(y); } y.setLeft(x); x.setPro(y); } 複製程式碼
這樣移動後我想大家最擔心就是大小順序問題了,我們看將右子節點(S)上移成為父結點,因為右子結點是肯定比當前節點(E)大的,換句話說就是E是肯定比S小的,所以讓E成為S的左子結點並沒有什麼問題,同理S的左子結點也是比E要大的,因為比E小的節點並不會插入到S的子結點上。 左旋搞明白了,右旋就簡單了,我們把剛才的操作反向再來一遍就是右旋了。
右旋

private void rightRotate(Node<K, V> x) { Node<K, V> y = x.getLeft(); x.setLeft(y.getRight()); if (y.getRight() != null) { y.getRight().setPro(x); } y.setPro(x.getPro()); if (x.getPro() == null) { this.root = y; } else if (x.equals(x.getPro().getLeft())) { x.getPro().setLeft(y); } else { x.getPro().setRight(y); } y.setRight(x); x.setPro(y); } 複製程式碼
(圖片來自網路)
我們通過一個簡單的左旋,可以將情況2轉換為情況3(13-16),因為新結點和父節點都為紅色,所以並不會影響除性質3以外的其他性質,然後我們處理情況3,最直觀的辦法就是把父結點著為黑色,這樣性質3就不會衝突了,但是性質4又不符合了,那怎麼辦,為了平衡性質4,我們再把父結點的父結點著為紅色,這樣做性質3性質4就都滿足了。但是仔細想想,如果父結點的父結點是根結點怎麼辦,豈不是又不符合性質2了,所以目前來看只要解決最後一個問題,調整就可以立馬完成,但是該如何解決最後一個問題呢, 其實這時候我們只需要在不違反其他性質的同時,轉換樹的結構就好——右旋 如下

至此,紅黑樹的調整工作就圓滿達成了,其實以上的操作目的,都是儘量在移動最少的結點下,把紅黑樹調整到合法的狀態 現在再想想如果我們一開始插入的是黑色結點而不是紅色結點,那麼每次插入的時候性質4都會被違反,但如果插入的是一個紅色結點,那麼只會可能違反性質2或性質3,也有可能不會違反任何性質。
總結
到此,插入一條資料過操作就結束了,我們來簡單總結一下,先按照普通二叉搜尋樹的方式,將一條資料插入到合適的位置,然後如果紅黑樹的性質被違反的話,我們通過變色和旋轉,區域性調整樹的結構,使其整體符合紅黑樹的約束條件,插入操作結束
(3)遍歷、查詢
紅黑樹的遍歷跟普通二叉搜尋樹的遍歷方式一樣,遍歷方式可分為中序、先序、後序遍歷,當然也有層級遍歷,我們先用中序遍歷的方式來實現,所謂中序遍歷,就是當前輸出的結點物件,在其左子結點和右子結點的中間輸出,以下是通過遞迴實現
public void inorder(Node<K, V> x){ if(x!=null){ inorder(x.getLeft()); System.out.println(x.getKey() + ":" + x.getValue()); inorder(x.getRight()); } } 複製程式碼
然後測試一下
public static void main(String[] args) { Tree<String, Integer> tree = new Tree<>(); tree.put("1", 333); tree.put("12", 3331); tree.put("41", 3313); tree.put("21", 3133); tree.put("4", 33343); tree.put("33", 3353); tree.inorder(tree.getRoot()); } 複製程式碼

我們發現中序遍歷就是按照從小到大輸出的。
先序和後序遍歷只要移動一下當前結點輸出的位置即可。
遞迴的方式實現很簡單,但是對於大多數情況下,遞迴需要頻繁的壓棧,我們可以通過迭代的方式來改善這種情況, 既然要迭代,我們就來實現一下jdk自帶的迭代器好了,因為這樣我們以後就可以用迭代器或增強for迴圈的方式來遍歷了。
@Override public Iterator<Node<K, V>> iterator() { return new Iter(); } private class Iter implements Iterator<Node<K, V>> { List<Node<K, V>> array; int cur; public Iter() { cur = 0; array = new ArrayList<>(); Stack<Node<K, V>> stack = new Stack<>(); Node<K, V> next = root; while (true) { while (next != null) { stack.push(next); next = next.getLeft(); } if (stack.isEmpty()) break; next = stack.pop(); array.add(next); next = next.getRight(); } } @Override public boolean hasNext() { return cur < array.size(); } @Override public Node<K, V> next() { Node<K, V> node = array.get(cur); cur++; return node; } } 複製程式碼
首先我們實現了迭代器,然後通過while迴圈的方式展開遞迴,先用類似於掃描的方式,迴圈把所有左子結點放入一個臨時佇列,最後將佇列的結點取出,找到它的右子結點,進入下一次迴圈。
測試一下
public static void main(String[] args) { Tree<String, Integer> tree = new Tree<>(); tree.put("1", 333); tree.put("12", 3331); tree.put("41", 3313); tree.put("21", 3133); tree.put("4", 33343); tree.put("33", 3353); for(Node node : tree){ System.out.println(node.getKey() + ":" + node.getValue()); } } 複製程式碼
相比遍歷, 查詢 就簡單多了。
public V get(K key) { Node<K, V> node = getNode(key); return node != null ? node.getValue() : null; } private Node<K, V> getNode(K key) { if (this.root == null) return null; Node<K, V> x = this.root; while (x != null && !x.getKey().equals(key)) { if (key.hashCode() - x.getKey().hashCode() < 0) { x = x.getLeft(); } else if (key.hashCode() - x.getKey().hashCode() > 0) { x = x.getRight(); } } return x; } 複製程式碼
* 拓展 求最大值與最小值
public Node<K, V> getMaximum(Node<K, V> node) { while (node.getLeft() != null) { node = node.getRight(); } return node; } public Node<K, V> getMinimum(Node<K, V> node) { while (node.getLeft() != null) { node = node.getLeft(); } return node; } 複製程式碼