數據結構與算法--二叉查找樹
上節中學習了基于鏈表的順序查找和有序數組的二分查找,其中前者在插入刪除時更有優勢,而后者在查找上效率更高。能不能將這兩個優點結合起來呢?這就是接下來要學的二叉查找樹。
首先,二叉查找樹是一棵二叉樹,每個結點都只有左后兩個鏈接或者稱為子結點、子樹。每個結點的鍵都大于其左子樹任意結點的鍵,同時小于其右子樹任意結點的鍵。
如圖結點E的左子樹都比E小,其右子樹都比E要大。
在經典的二叉樹實現中,我們會增加一個結點計數器,用來表示以此結點為根的子樹中的結點總數。一棵二叉查找樹表示唯一的一組有序鍵,但是一組有序鍵可以用多棵二叉查找樹表示。如下
它們表示的都是同一組有序鍵:ACEHMRSX,細心的你可能已經發現這其實就是二叉樹中序遍歷的結果。而且由于加入了結點計數器,對于每個結點都有
size(x) = size(x.left) + size(x.right) + 1
上式+1
是因為要算上父結點x。
基本實現
我們先來定義二叉查找樹。
package Chap8;
public class BST<Key extends Comparable<Key>, Value> {
private Node root;
private class Node {
private Key key;
private Value value;
private Node left, right;
private int N; // 結點計數器,以該結點為根的子樹結點總數
public Node(Key key, Value value, int N) {
this.key = key;
this.value = value;
this.N = N;
}
}
public int size() {
return size(root);
}
private int size(Node node) {
if (node == null) {
return 0;
} else {
return node.N;
}
}
}
查找
采用遞歸算法比較容易理解:從根結點開始,如果樹是空的,則返回null表示查找未命中;如果被查找的鍵和當前根結點相等,查找命中,否則就遞歸地在適當的子樹里繼續查找——具體來說就是如果被查找的鍵小于根結點的鍵就在其左子樹繼續查找;如果大于根結點就在其右子樹繼續查找。根據上面表述,已經可以寫出get(Key key)
方法了。
public Value get(Key key) {
return get(root, key);
}
private Value get(Node node, Key key) {
if (node == null) {
return null;
}
// 和當前結點比較
int cmp = key.compareTo(node.key);
// 遞歸在左子樹查找
if (cmp < 0) {
return get(node.left, key);
// 遞歸在右子樹查找
} else if (cmp > 0) {
return get(node.right, key);
// 查找命中返回值
} else {
return node.value;
}
}
看上圖,分別是查找命中和未命中的軌跡。
get方法可以使用非遞歸實現,通常性能更佳。
public Value get(Key key) {
Node cur = root;
while (cur != null) {
int cmp = key.compareTo(cur.key);
if (cmp < 0) {
cur = cur.left;
} else if (cmp > 0) {
cur = cur.right;
} else {
return cur.value;
}
}
return null;
}
插入
put
方法和get
方法如出一轍,也是采用了遞歸的方式:從根結點開始如果樹是空的,就返回一個含有該鍵值對的新結點;如果被查找的鍵小于根結點的鍵,就在其左子樹中插入該鍵,否則在右子樹插入該鍵。
public void put(Key key, Value value) {
// 更新root
// 第一次put:本來null的root = new Node
// 以后的put:root = root
root = put(root, key, value);
}
private Node put(Node node, Key key, Value value) {
if (node == null) {
return new Node(key, value, 1); // 新結點size當然是1
}
int cmp = key.compareTo(node.key);
// 在node的左子樹插入
if (cmp < 0) {
node.left = put(node.left, key, value);
// 在node的右子樹插入
} else if (cmp > 0) {
node.right = put(node.right, key, value);
// 鍵已經存在,更新
} else {
node.value = value;
}
// 插入后更新以node為根的子樹總結點數
node.N = size(node.left) + size(node.right) + 1;
// 除了第一次put返回新結點外,都是返回root
return node;
}
注意遞歸算法中有返回值,插入時是從上往下的查找,然后在樹的底部插入,然后在遞歸方法返回的過程中,是自下而上逐漸更新查找路徑上的每個結點node的,包括node.left
或者node.right
,及node.N
,所以在公有(public)的 get方法中有root = put(root, key, value)
,表示更新后的root傳遞給原root。
看上圖,遞歸查找和get方法一樣,直到遇到空樹,在這里是M的左子結點,然后新建結點接到M的左子樹上。之后是遞歸方法的返回,在返回過程中不斷更新了每個結點的左子結點、右子結點、以此結點為根的子樹總結點數(實際上很多結點的這些值并沒有變化,但這些操作又是必須的)
最大/最小鍵
如果根結點的左子結點為空,那么該根結點就是最小的鍵;如果左子結點不為空,那么一直沿著左鏈接深入,直到遇到某個結點沒有左子結點了,那么此時該結點的鍵就是最小的。最大鍵的實現就是不斷深入右子樹,直到某結點的右子結點為空。在代碼中將left換成right即可。
// 遞歸實現min
public Key min() {
return min(root).key;
}
// 遞歸實現max
public Key max() {
return max(root).key;
}
private Node min(Node node) {
if (node.left == null) {
return node;
} else {
return min(node.left);
}
}
private Node max(Node node) {
if (node.right == null) {
return node;
} else {
return max(node.right);
}
}
當然可以采用非遞歸的版本。
public Key min() {
Node node = root;
while (node.left != null) {
node = node.left;
}
return node.key;
}
public Key max() {
Node node = root;
while (node.right != null) {
node = node.right;
}
return node.key;
}
向上/向下取整
floor(Key key)
返回小于等于key的鍵;ceiling(Key key)
返回大于等于key的鍵。
floor方法:如果key等于根結點的鍵那么直接返回根結點的鍵;如果key小于根結點,則小于等于key的最大鍵一定在根結點的左子樹中;如果key大于根結點,那么必須當右子樹中存在小于等于key的結點時,小于等于key的鍵才存在于右子樹中,若不存在則小于等于key的鍵就是根結點本身。
這兩個方法是鏡像的,理解了floor就將能順理成章寫出ceiling。
則ceiling方法就是:key等于根結點的鍵那么直接返回根結點的鍵;如果key大于根結點,則大于等于key的最大鍵一定在根結點的右子樹中;如果key小于根結點,那么必須當左子樹中存在大于等于key的結點時,大于等于key的鍵才存在于左子樹中,若不存在則大于等于key的鍵就是根結點本身。
實現如下
public Key floor(Key key) {
Node node = floor(root, key);
if (node == null) {
return null;
} else {
return node.key;
}
}
private Node floor(Node node, Key key) {
if (node == null) {
return null;
}
int cmp = key.compareTo(node.key);
// 和根結點相等直接返回根結點
if (cmp == 0) {
return node;
// 比根結點小,肯定在左子樹中
} else if (cmp < 0) {
return floor(node.left, key);
// 比根結點大,若在右子樹中就返回右子樹相應結點,否則就是根結點本身
} else {
Node temp = floor(node.right, key);
if (temp != null) {
return temp;
} else {
return node;
}
}
}
public Key ceiling(Key key) {
Node node = ceiling(root, key);
if (node == null) {
return null;
} else {
return node.key;
}
}
private Node ceiling(Node node, Key key) {
if (node == null) {
return null;
}
int cmp = key.compareTo(node.key);
// 和根結點相等直接返回根結點
if (cmp == 0) {
return node;
// 比根結點大,肯定在右子樹中
} else if (cmp > 0) {
return ceiling(node.right, key);
// 比根結點小,若在左子樹中就返回左子樹相應結點,否則就是根結點本身
} else {
Node temp = ceiling(node.left, key);
if (temp != null) {
return temp;
} else {
return node;
}
}
}
查找G,開始時G < S,所以小于等于G的最大鍵肯定在S的左子樹中,然后G > E,則小于等于G的最大鍵可能存在于E的右子樹中,經查找后不存在小于等于G的鍵,所以最后返回的是根結點E。
由于floor和ceiling方法實現十分相似,如果理解了floor的查找軌跡,ceiling也應該不在話下。
選擇和排名
select(k)
:假設我們想知道排名為k的鍵是什么(即樹中正好有k個鍵小于它)。如果左子樹中的結點數t大于k,那么繼續遞歸地在左子樹中查找排名為k的鍵;如果t等于k,就返回根結點的鍵(根結點的左子樹結點總數剛好就是根結點的排名),如果t小于k,得在右子樹遞歸地查找排名為k - t -1的鍵(因為左子樹結點個數為t,加上根結點1,共t + 1個,而k - t - 1+ t + 1 = k)依然能保證查找到的是排名為k的鍵。
rank(Key key)
:此方法可返回給定鍵的排名。是select方法的逆方法。如果給定鍵和根結點的鍵相同,就返回左子樹的結點數(根結點左子樹的結點數剛好是根結點的排名);如果給定的鍵小于根結點,遞歸運算返回該鍵在左子樹中的排名;如果給定的鍵大于根結點,返回t + 1 + 該鍵在右子樹中的排名
(t + 1是根結點的左子樹及根結點,所以三者加起來才是該鍵的正確排名)
public Key select(int k) {
if (k < 0 || k >= size()) {
throw new IllegalArgumentException("argument to select() is invalid: " + k);
}
return select(root, k).key;
}
private Node select(Node node, int k) {
if (node == null) {
return null;
}
int t = size(node.left);
// 左子樹的結點數大于k,繼續在左子樹查找
if (t > k) {
return select(node.left, k);
// 左子樹結點數小于k,得在右子樹查找
} else if (t < k) {
return select(node.right, k - t - 1);
// 左子樹的結點數剛好等于k,找到,排名為k的就是這個根結點
} else {
return node;
}
}
public int rank(Key key) {
return rank(root, key);
}
private int rank(Node node, Key key) {
if (node == null) {
return 0;
}
int cmp = key.compareTo(node.key);
// 比根結點小,應該在左子樹中繼續查找
if (cmp < 0) {
return rank(node.left, key);
// 比根結點大,應該在右子樹中查找,算排名時加上左子樹和根結點的結點總和
} else if (cmp > 0) {
return 1 + size(node.left) + rank(node.right, key);
// 和根結點相等,找到,排名就是其左子樹結點總數
} else {
return size(node.left);
}
}
S的左子樹結點個數為6,大于3,所以在S的左子樹中繼續查找,E的左子樹結點個數為2,小于3;所以應該在E的右子樹中查找排名為k - t - 1 = 3 - 2 - 1 = 0的結點。R的左子樹結點個數為2,大于0,應該在R的左子樹中查找;H的左子樹結點個數為0,且正在查找排名為0的結點,返回H。看圖中,有序鍵為ACEHMRSX,H確實是排名3。
再看rank(O)
,還是用上面的圖,O小于S所以在左子樹中查找,O大于E,轉右子樹,O在右子樹中的排名是2(HMR中有H和M小于O),則最后返回1 + size(e.left) + 2 = 5,三個值分別是結點e、結點e的左子樹結點個數、O在右子樹中的排名。
刪除
刪除最小/最大鍵
先看簡單的情況,刪除最小最大鍵,其實思路和查找最小最大鍵類似。也是不斷深入左子樹,直到某個結點沒有左子結點,現在要做的就是刪除該結點,比如該結點為x,其父結點為t,有t.lelf == x。只要使x的右結點(不管是不是空)成為t的新的左結點即可,也就是t.left = x.right
,原左結點會被垃圾回收,達到刪除的目的。刪除最小鍵的操作軌跡如下圖左邊所示。
刪除最大鍵是刪除最小鍵的鏡像問題,就不贅述了。
public void deleteMin() {
root = deleteMin(root);
}
private Node deleteMin(Node node) {
if (node.left == null) {
return node.right;
}
// 其實就是node.left = node.left.right
node.left = deleteMin(node.left);
node.N = size(node.left) + size(node.right) + 1;
return node;
}
public void deleteMax() {
root = deleteMax(root);
}
private Node deleteMax(Node node) {
if (node.right == null) {
return node.left;
}
node.right = deleteMax(node.right);
node.N = size(node.left) + size(node.right) + 1;
return node;
}
刪除任意鍵
如果要刪除的鍵只有一個子結點或者沒有子結點,可以按照上述方法刪除,但是如果要刪除的結點既有左子結點又有右子結點呢?刪除后將要同時處理兩棵子樹,但是被刪除結點的父結點只會空出一條鏈接出來。換個角度想想,二叉查找樹的中序遍歷序列就是有序鍵的集合,所以刪除了該結點,可以用該結點的后繼或者前驅結點取代它。這里我們打算用后繼結點取代被刪除結點的位置。具體步驟如下
- 如果被刪除的結點只有一個子結點或者沒有子結點,比如被刪除結點為x,其父結點為t。若x沒有左結點則
t.left = x.right
,或者x沒有右結點則t.right = x.left
。 - 如果被刪除的結點有左右子結點。先將被刪除的結點保存為t,其右子結點為t.right,然后找到右子樹中的最小結點,該結點就是被刪除結點t的后繼結點,設為x。t和m之間再無其他鍵,所以m取代t的位置后,剔除m后的t的右子樹中所有結點仍然大于m,所以只需讓m的右子樹連接剔除m后的t的右子樹,m的左子樹連接t的左子樹即可。
比如下圖右邊刪除結點E,E的左右子結點都不為空,E的右子結點是R,然后在子樹R中找到最小值H,H就為E的后繼結點。然后H取代E的位置,剔除H(調用deleteMin(R)即可)后的E的右子樹還剩下R、M,讓H的右子樹和他們相連,再讓H的左子樹和E的左子樹相連,OK~
根據描述寫出如下代碼
private Node delete(Node node, Key key) {
if (node == null) {
return null;
}
int cmp = key.compareTo(node.key);
// key大于當前根結點,在右子樹查找
if (cmp > 0) {
node.right = delete(node.right, key);
// key小于當前根結點,在左子樹查找
} else if (cmp < 0) {
node.left = delete(node.left, key);
// 找到給定的key
} else {
// 如果根結點只有一個子結點或者沒有子結點,按照刪除最小最大鍵的做法即可
if (node.left == null) {
return node.right;
}
if (node.right == null) {
return node.left;
}
// 根結點的兩個子結點都不為空
// 要刪除的結點用t保存
Node t = node;
// t的后繼結點取代t的位置
node = min(t.right);
node.right = deleteMin(t.right);
node.left = t.left;
}
node.N = size(node.left) + size(node.right) + 1;
return node;
}
范圍查找
要查找某個范圍內的所有鍵,首先需要一個遍歷二叉樹所有結點的方法,我們多次提到二叉查找樹的中序遍歷序列就是有序鍵的集合。所以得到如下思路:中序遍歷二叉查找樹,如果該鍵落在范圍內,加入到集合中。當然如果某個根結點的鍵小于該范圍的最小值,其左子樹肯定也不會在范圍內;同樣某個結點的鍵大于該范圍的最大值,其右子樹肯定也不會在范圍內。這兩種情況都無需遞歸遍歷了,直接跳過。所以為了減少比較操作,在遞歸遍歷前加上判斷條件。
public Set<Key> keys() {
return keys(min(), max());
}
public Set<Key> keys(Key low, Key high) {
Set<Key> set = new LinkedHashSet<>();
keys(root, set, low, high);
return set;
}
private void keys(Node node, Set<Key> set, Key low, Key high) {
if (node == null) {
return;
}
int cmplow = low.compareTo(node.key);
int cmphigh = high.compareTo(node.key);
// 當前結點比low大,左子樹中可能還有結點落在范圍內的,所以應該遍歷左子樹
if (cmplow < 0) {
keys(node.left, set, low, high);
}
// 在區間[low, high]之間的加入隊列
if (cmplow <= 0 && cmphigh >= 0) {
set.add(node.key);
}
// 當前結點比high小,右子樹中可能還有結點落在范圍內,所以應該遍歷右子樹
if (cmphigh > 0) {
keys(node.right, set, low, high);
}
}
結合一個圖例理解下keys方法,圖中是查找[F, T]
范圍內的所有鍵。首先S大于F,在左子樹中查找,發現E在范圍外,跳過E及其子樹;回到E的右子樹R,R大于F,會在左子樹中繼續查找,H在范圍內,所以加入到集合中,然后到H的右子樹....以此類推,最后被加入到集合的元素有HLMPRS(中序遍歷得到的,所以有序)
values
的實現和keys
完全類似,不再贅述。
至于求某范圍內的鍵的個數size(Key low, Key high)
。在有序數組的二分查找中已經有實現,直接拿過來用。如下
public int size(Key low, Key high) {
if (high.compareTo(low) < 0) {
return 0;
}
if (contains(high)) {
return rank(high) - rank(low) + 1;
} else {
return rank(high) - rank(low);
}
}
代碼測試
先重寫toString
,格式化打印所有鍵值對。
@Override
public String toString() {
Iterator<Key> keys = keys().iterator();
Iterator<Value> values = values().iterator();
if (!keys.hasNext()) {
return "{}";
}
StringBuilder sb = new StringBuilder();
sb.append("{");
while (true) {
Key key = keys.next();
Value value = values.next();
sb.append(key).append("=").append(value);
if (!keys.hasNext()) {
return sb.append("}").toString();
}
sb.append(", ");
}
}
來測試下代碼。
public static void main(String[] args) {
BST<Integer, Double> st = new BST<>();
st.put(1, 5567.5);
st.put(5, 10000.0);
st.put(3, 4535.5);
st.put(7, 7000.0);
st.put(12, 2500.0);
st.put(10, 4500.0);
st.put(17, 15000.5);
st.put(15, 12000.5);
st.deleteMax(); // 17
st.deleteMin(); // 1
st.delete(12); // 剩下[3, 5, 7, 10, 15]
System.out.println("符號表的長度為" + st.size());
System.out.println("[3, 6]之間有" + st.size(3, 6) + "個鍵");
System.out.println("比9小的鍵的數量為" + st.rank(9));
System.out.println("排在第4位置的鍵為" + st.select(4));
System.out.println("大于等于8的最小鍵為" + st.ceiling(8));
System.out.println("小于等于8的最大鍵為" + st.floor(8));
System.out.println("符號表所有的鍵和對應的值為:" + st.keys() + " -> " + st.values());
System.out.println("鍵2和鍵8之間的所有鍵及對應的值:" + st.keys(2, 8) + " -> " + st.values(2, 8));
System.out.println(st);
/*
符號表的長度為5
[3, 6]之間有2個鍵
比9小的鍵的數量為3
排在第4位置的鍵為15
大于等于8的最小鍵為10
小于等于8的最大鍵為7
符號表所有的鍵和對應的值為:[3, 5, 7, 10, 15] -> [4535.5, 10000.0, 7000.0, 4500.0, 12000.5]
鍵2和鍵8之間的所有鍵及對應的值:[3, 5, 7] -> [4535.5, 10000.0, 7000.0]
{3=4535.5, 5=10000.0, 7=7000.0, 10=4500.0, 15=12000.5}
*/
}
by @sunhaiyu
2017.10.17