前面瞭解的無序鏈表和有序數組在性能方面至少在線性級別,沒法用於數據量大的場合。接下來要學習的二叉查找樹能夠將鏈表插入的靈活性和有序數組查找的高效性結合起來,是計算機科學中最重要的算法之一。 一個二叉查找樹(Binary Search Tree)是一顆二叉樹,其中每一個結點都含有一個Comparable的鍵,以及相關聯的值,且每一個結點的鍵都大於其左子樹中任意結點的鍵,小於右子樹中任意結點的鍵。算法
在二叉查找樹中查找時,若是樹是空的,則查找未命中;若是被查找的鍵和根結點的鍵相等,查找命中,不然就遞歸地在子樹中繼續查找,若是被查找的鍵小於根結點,就選擇左子樹,不然選擇右子樹。 查找算法的代碼實現爲:數組
public class BST<Key extends Comparable<Key>, Value> { private Node root; private class Node { private Key key; private Value val; private Node left, right; public int size; public Node(Key key, Value val, int size) { this.key = key; this.val = val; this.size = size; } } public Value get(Key key) { return get(root, key); } private Value get(Node x, Key key) { if (key == null) throw new IllegalArgumentException("calls get() with a null key"); if (x == null) return null; int cmp = key.compareTo(x.key); if (cmp > 0) { return get(x.right, key); } else if (cmp < 0) { return get(x.left, key); } else { return x.val; } } }
其中,Node類用來表示二叉查找樹的結點,每一個結點都含有鍵、值、左右連接和一個後面實現最大最小值等有序操做時會用到的結點計數器。性能
插入鍵值對時,首先進行查找,若是鍵已經存在於符號表中,則更新對應的值;若是查找未命中,就返回一個含有待插入鍵值對的新結點。學習
public void put(Key key, Value val) { root = put(root, key, val); } private Node put(Node x, Key key, Value val) { if (key == null) throw new IllegalArgumentException("calls put() with a null key"); if (x == null) return new Node(key, val, 1); int cmp = key.compareTo(x.key); if (cmp < 0) x.left = put(x.left, key, val); else if (cmp > 0) x.right = put(x.right, key, val); else { x.val = val; } x.size = size(x.left) + size(x.right) + 1; return x; }
插入操做的代碼與查找相似,但插入操做會更新結點值或添加新結點,而且會更新結點計數器。 x.left = put(x.left, key, val); 相似這樣的代碼利用遞歸的特性簡潔地實現告終點的添加。在遞歸調用時,至關於根據二分查找的邏輯,沿着樹的某個分支一直向下查找,若是找到,就終止遞歸,更新結點的值,若是到了樹的最底層也沒找到,此時key==null成立,遞歸也會終止,同時新初始化的結點也已經被掛在x.left或者x.right了。 在遞歸推出的過程當中,至關於沿着樹向上爬,每爬一層,*x.size = size(x.left) + size(x.right) + 1;*都會被執行,這樣在添加結點後,相關路徑上的全部結點的size都獲得了更新。測試
插入新結點和未命中的查找都須要從整顆樹的根結點搜索到樹的最底層,因此二叉查找樹的性能與樹的形狀有關,由於樹的形狀決定了樹的深度。在最好的狀況下,一個含有N個結點的樹是徹底平衡的,全部的空連接都在最底層,距離根結點的距離爲LgN;而在最壞的狀況下,樹的形狀變成了一條鏈表,樹的深度爲N,將元素按順序逐個插入到二叉查找樹時,就能夠形成這種狀況。在通常的狀況下,獲得的樹的形狀與最好狀況更加接近,二叉查找樹的性能在對數級別。 英文原版《雙城記》中大於7個字符的單詞一共14350個,這些單詞中不一樣的單詞有5737個,將這些單詞做爲鍵來測試不一樣符號表實現的性能,結果以下: 圖中橫座標表示插入單詞的數量,縱座標表示插入時的比較次數,灰點表示某次插入的實際比較次數,紅點表示平均比較次數(比較總數/插入單詞數量),前面學習過基於無序鏈表和有序數組的實現,平均次數分別爲2246和484次,能夠看到二叉查找樹不管在單詞比較次數仍是平均次數方面,都有了跨越數量級的進步。this
二叉查找樹除了擁有較好的性能,還因其可以保持鍵的有序性而支持有序性相關的操做。code
一個結點的左子樹的值都小於右子樹,因此最小值可能在左子樹中,若是左子樹爲空,則當前結點就是最小值。基於這種算法得出求最大值、最小值的代碼實現爲:對象
public Key min() { if (isEmpty()) throw new NoSuchElementException("calls min() with empty symbol table"); return min(root).key; } private Node min(Node x) { if (x.left == null) return x; else return min(x.left); } public Key max() { if (isEmpty()) throw new NoSuchElementException("calls max() with empty symbol table"); return max(root).key; } private Node max(Node x) { if (x.right == null) return x; else return max(x.right); }
關於向下取整,若是給定鍵小於根結點的鍵,那麼小於等於給的鍵的最大值在根結點的左子樹中,若是給定的鍵大於根結點,那麼只有當根結點右子樹中存在小於等於給定鍵的結點時,向下取整的值會出如今右子樹中,不然根結點就是要找的值,向上取整的方法與此相似:blog
public Key floor(Key key) { Node n = floor(root, key); if (n == null) { return null; } else { return n.key; } } private Node floor(Node x, Key key) { if (x == null) { return null; } int cmp = key.compareTo(x.key); if (cmp == 0) return x; if (cmp < 0) return floor(x.left, key); Node n = floor(x.right, key); if (n == null) { return x; } else { return n; } } public Key ceiling(Key key) { if (n == null) { return null; } else { return n.key; } } private Node ceiling(Node x, Key key) { if (x == null) { return null; } int cmp = key.compareTo(x.key); if (cmp == 0) return x; if (cmp > 0) return ceiling(x.right, key); Node n = ceiling(x.left, key); if (n == null) { return x; } else { return n; } }
排名從0開始,選擇方法select(k)會返回排名爲的鍵,樹中有k個小於它的鍵。若是左子樹中的結點數t大於k,就繼續在左子樹中查找,若是t等於k,那麼根結點就是要找的鍵,若是t小於k,就在右子樹中查找排名爲k-t-1的鍵,由此獲得的代碼爲:排序
public Key select(int k) { return select(root, k).key; } private Node select(Node x, int k) { if (x == null) { return null; } int t = size(x.left); if (t > k) { return select(x.left, k); } else if (t < k) { return select(x.right, k - t - 1); } else { return x; } }
排名rank()方法是選擇方法的逆方法,它返回給定鍵的排序。若是給定鍵與根結點相等,那麼鍵的排名就是根結點左子樹中的結點總數t;若是給定鍵小於根結點,在左子樹中繼續遞歸計算;若是給定鍵大於根結點,就返回t+1再加上它在右子樹中的排名。
public int rank(Key key) { return rank(key, root); } private int rank(Key key, Node x) { if (x == null) { return 0; } int cmp = key.compareTo(x.key); if (cmp > 0) { return size(x.left) + rank(key, x.right) + 1; } else if (cmp < 0) { return rank(key, x.left); } else { return size(x.left); } }
範圍查找要求返回給定範圍內的全部鍵,這裏會用到遍歷二叉樹的基本方法-中序遍歷。先遍歷左子樹中的全部鍵,而後遍歷根結點,最後是右子樹中的全部鍵,這一過程遞歸地進行,就能夠按從小到大的順序遍歷完全部結點。
public void keys(Node x, Queue<Key> queue, Key lo, Key hi) { if (x == null) return; int cmplo = lo.compareTo(x.key); int cmphi = hi.compareTo(x.key); if (cmplo < 0) keys(x.left, queue, lo, hi); if (cmplo <= 0 && cmphi >= 0) queue.enqueue(x.key); if (cmphi > 0) keys(x.right, queue, lo, hi); }
刪除最小鍵時,須要不斷地深刻根結點的左子樹,直到碰見一個空連接,而後將指向該結點的連接指向該結點的右子樹,要被刪除的結點由於沒有被任何對象引用,隨後就會被垃圾回收器清理掉。刪除最大鍵的過程相似。
public void deleteMin() { root = deleteMin(root); } private Node deleteMin(Node x) { if (x.left == null) return x.right; x.left = deleteMin(x.left); x.size = size(x.left) + size(x.right) + 1; return x; } public void deleteMax() { root = deleteMax(root); } private Node deleteMax(Node x) { if (x.right == null) return x.left; x.right = deleteMax(x.right); x.size = size(x.left) + size(x.right) + 1; return x; }
在不斷深刻左子樹的時候,除非碰見空連接,deleteMin(Node x)方法都返回結點x,只有最後一次遞歸纔將上個結點指向x.right,遞歸退出時,會更新路徑上的結點計數器。
二叉查找樹中最難實現的方法就是delete()方法了,刪除最大、最小鍵時,被刪除的結點的兩個子結點中,只有一個不爲空,但通常的結點都會有兩個子結點,刪除這個結點後,須要合理處理它的兩個子結點。T.Hibbard在1962年提出瞭解決這個難題的第一個方法,在刪除結點x後用它的後繼結點填補它的位置。由於x有一個右子結點,由此它的後繼結點就是其右子樹中的最小結點。這樣的替換仍然能保證樹的有序性,由於x.key和它的後繼結點之間不存在其餘的鍵。完成這個操做須要4步:
public void delete(Key key) { root = delete(root, key); } private Node delete(Node x, Key key) { if (x == null) return null; int cmp = key.compareTo(x.key); if (cmp < 0) x.left = delete(x.left, key); else if (cmp > 0) x.right = delete(x.right, key); else { if (x.right == null) return x.left; if (x.left == null) return x.right; Node t = x; x = min(t.right); x.right = deleteMin(t.right); x.left = t.left; } x.size = size(x.left) + size(x.right) + 1; return x; }