參考資料
《算法(java)》 — — Robert Sedgewick, Kevin Wayne
《數據結構》 — — 嚴蔚敏
上一篇文章,我介紹了實現字典的兩種方式,:有序數組和無序鏈表
這一篇文章介紹的是一種新的更加高效的實現字典的方式——二叉查找樹。
【注意】 爲了讓代碼儘量簡單, 我將字典的Key和Value的值也設置爲int類型,而不是對象, 因此在下面代碼中, 處理「操做失敗」的狀況的時候,是返回 -1 而不是返回 null 。 因此代碼默認不能選擇 -1做爲 Key或者Value
(在實際場景中,咱們會將int類型的Key替換爲實現Compare接口的類的對象,同時將「失敗」時的返回值從-1設爲null,這時是沒有這個問題的)
二叉查找樹的定義
二叉查找樹(BST)是一顆二叉樹, 其中每一個結點的鍵都大於其左子樹中任意結點的鍵而小於其右子樹中任意結點的鍵。
簡單的理解, 就是二叉查找樹在二叉樹的基礎上, 加上了一層結點大小關係的限制。
例如這是一顆二叉樹, 其中的根結點10大於其左子樹的全部結點的鍵(1,3,5,7),小於右子樹中全部結點的鍵(12,14,15,16,18)
請注意一點, 這種大小關係並非侷限在「左兒子-父節點-右兒子」的範圍裏,而是「左子樹-父節點-右子樹」的範圍中!
例以下圖這並非一顆二叉樹,關鍵在於藍色的66結點, 雖然它做爲35-40-66這顆子樹來看是一顆二叉查找樹, 但從根結點看, 由於66>55, 這違背了二叉查找樹的定義, 因此這不是一顆二叉樹
一顆二叉查找樹對應一個有序序列
對二叉查找樹進行中序遍歷, 能夠獲得一個遞增的有序序列。
經過將二叉查找樹的全部鍵投影到一條直線上,咱們就能夠很直觀地看出二叉查找樹和有序序列的對應關係。
(下面的鍵值是字母A~Z, 大小關係是A最小,Z最大)
從上面的圖示還能夠得出的一點是:
1. 一個二叉查找樹對應一個惟一的遞增序列
2. 一個遞增序列能夠對應多個不一樣的二叉查樹
二叉查找樹實現字典API的全部思路, 都將圍繞這種有序性展開。
本文的字典API
int size() 獲取字典中鍵值對的總數量
void put(int key, int val) 將鍵值對存入字典中
int get(int key) 獲取鍵key對應的值
void delete(int key) 從字典中刪去對應鍵(以及對應的值)
int min() 字典中最小的鍵
int max() 字典中最大的鍵
int rank(int key) key在鍵中的排名(小於key的鍵的數量)
int select(int k) 獲取排名爲k的鍵
BST類的基本結構
public class BST {
Node root; // 根結點
private class Node { // 匿名內部類Node
int key; // 存儲字典的鍵
int val; // 存儲字典的值
Node left,right; // 分別表示左連接和右連接
int N; // 以該結點爲根的子樹中的結點總數
public Node (int key,int val,int N) {
this.key = key;
this.val = val;
this.N = N;
}
}
public int get (int key) { }
public void put (int key,int val) { }
// 其餘方法 ... ...
}
咱們發現, 二叉查找樹的類的基本結構和鏈表很類似。
由於基本單元是結點,因此建立一個匿名內部類(Node)以便初始化結點, 結點的成員變量key和val分別用來存儲字典的鍵和值, 而由於每一個結點有兩條或如下的連接,因此用成員變量left和right表示。
在外部類BST中, 設置一個成員變量,全部的遞歸操做都從這個結點開始。
Node內部類中成員變量N的做用
但有一點使人奇怪的是:Node類裏有個成員變量N,你可能能想到,這是爲size方法(獲取字典中鍵值對的總數量)準備的, 但不妨思考一下, 若是它僅僅爲size方法而設置, 設置爲外部類BST的成員變量不是就能夠了嗎, 爲何要爲每一個結點都設置一個N屬性呢
Node類裏的成員變量N除了爲size方法服務外, 更多地是爲rank方法和select方法服務的。
以rank方法爲例( key在鍵中的排):
若是用有序數組實現字典,實現rank方法只要查找到給定的key,而後返回下標就能夠了。
但對於二叉查找樹而言,它沒有「下標」的概念,因此若是它想要計算某個結點的排名(rank),只能根據該結點左兒子的N值去判斷。
以下圖中, A結點的排名(3)等於它的左兒子B的N值(3)
實際的rank方法編碼固然不會像「rank(A)=B.N」這麼簡單, 但道理是相似的,能夠經過遞歸的方式對一系列的N進行累加,從而獲得目標key的排名。
綜上所述
N到底設爲Node類的成員變量仍是BST類的成員變量取決於你的實際需求。
- 若是你不須要rank/select方法, 那麼N徹底能夠設爲BST的成員變量, 表示的是整棵樹的結點總數, 維護N的代碼編寫很簡單:在調用put方法時候使其加1, 在調用delete方法時使其減1。
- 若是你須要rank/select方法,則需對每一個結點單獨設N,表明的是該結點爲根的子樹中的結點總數,維護N的代碼編寫將會複雜不少,但這是必要的。(具體往下看)
由於文中代碼包含rank/select方法,因此選擇的固然是後者
方法設計的共同點
下面介紹的多數方法都是按下面這個「板式」,以get方法爲例
// 針對某個結點設計的遞歸處理方法
private int get(Node x, int key) {
// 遞歸調用get方法
}
// 將root做爲上面方法的參數,從根結點開始處理整顆二叉樹
public int get(int key) {
return get(root, key)
}
基於函數重載的原理,編寫兩個同名函數, 一個向外部暴露(public), 一個隱藏在類裏(private)
size方法
size方法
獲取字典中鍵值對的總數量(結點總數量)
private int size (Node x) {
if(x == null) return 0;
return x.N;
}
public int size () {
return size(root);
}
對於private int size(Node x)
- 當結點存在的時候,返回結點所在子樹的結點總數(包括自身)
- 當結點不存在的時候,即x爲null時,返回0
結點不存在有兩種可能的狀況
1. 整棵樹爲空,即整棵樹尚未任何結點,root = null
2. 樹不爲空,但在遞歸操做的過程當中(例如put、delete),x下行至最下方的結點的左/右空連接
(一開始運行不了就多點幾遍運行,或者拷貝到本身的IDE上跑。平臺問題,不是個人鍋喲。。。)
get方法
根據二叉樹:每一個結點的鍵都大於其左子樹中任意結點的鍵而小於其右子樹中任意結點的鍵,這一大小關係,咱們能夠很容易地寫出get方法的代碼。
從根結點root開始,比較給定key和當前結點的鍵大小關係
- key小於當前結點的鍵,說明key在左子樹,向左兒子遞歸調用get
- key大於當前結點的鍵,說明key在右子樹,向右兒子遞歸調用get
- key等於當前結點的鍵,查找成功並返回對應的值
最後結果有兩種:
- 查找到給定的key,返回對應的值
- x迭代至最下方的結點也沒有查找到key,由於x.left=x.right=null,在下一次調用get返回-1,結束遞歸
private int get (Node x,int key) {
if(x == null) return -1; // 結點爲空, 未查找到
if(key<x.key) {
return get(x.left,key); // 鍵在左子樹,向左子樹查找
}else if(key>x.key) {
return get(x.right, key); // 鍵在右子樹,向右子樹查找
}else{
return x.val; // 查找成功,返回值
}
}
public int get (int key) {
return get(root,key);
}
調用軌跡
put方法
put方法的實現思路和get方法類似
從根結點root開始,比較給定key和當前結點的鍵大小關係
- key小於當前結點的鍵,向左子樹插入
- key大於當前結點的鍵,向右子樹插入
- key等於當前結點的鍵,則將值替換爲給定的val
若是到最後都沒有查找到key,則建立新結點插入二叉樹中
代碼以下
private Node put (Node x, int key, int val) {
if(x == null) return new Node(key,val,1); // 未查找到key,建立新結點,並插入樹中
if(key<x.key){
x.left = put(x.left,key,val); // 向左子樹插入
}else if(key>x.key){
x.right = put(x.right,key,val); // 向右子樹插入
}else {
x.val = val; // 查找到給定key, 更新對應val
}
x.N =size(x.left) + size(x.right) + 1; // 更新結點計數器
return x; //
}
public void put (int key,int val) {
if(root == null) root = put(root,key,val); // 向空樹中插入第一個結點
put(root,key,val);
}
解釋下put方法的代碼中比較關鍵的幾個點
1.插入新結點的操做涉及兩個遞歸層次
插入新結點的表達式要結合最後的兩個遞歸層次進行分析
倒數第二次遞歸時的 x.left = put(x.left,key,val) 或x.right = put(x.right,key,val); 要和
倒數第一次遞歸時的 return new Node(key,val,1); 結合起來
即獲得x.left = new Node(key,val,1) 或 x.right = new Node(key,val,1)
以下圖所示
後一次遞歸建立的新結點將賦給前一次遞歸中結點的左連接(或右連接),從而插入二叉樹中。
2. 更新結點計數器代碼的實際調用順序
另外一個比較難理解的多是這行代碼:
x.N =size(x.left) + size(x.right) + 1; // 更新結點計數器
關於這點, 首先咱們要分清兩段不一樣的代碼:
遞歸調用前代碼和遞歸調用後代碼
put的遞歸將一段代碼分割成兩部分: 遞歸調用前代碼和遞歸調用後代碼,如圖所示
而遞歸調用前代碼和遞歸調用後代碼的執行順序是不同的。
- 遞歸調用前代碼先執行, 而遞歸調用後代碼後執行
- 遞歸調用前代碼是一個「沿着樹向下走」的過程,即遞歸層次是由淺到深, 而遞歸調用後代碼是一個「沿着樹向上爬」的過程, 即遞歸層次是由深到淺
如圖
因此和咱們的主觀邏輯邏輯不一樣的是, x.N =size(x.left) + size(x.right) + 1;這段遞歸調用後代碼是按遞歸層次由深到淺的順序執行的,從而重新插入的結點開始,依次增長插入路徑中每一個結點上計數器N的值。 如圖所示
總體過程
從圖中能夠看出, 總體的過程:
- 先「沿着樹向下走」, 插入或更新結點
- 再「沿着樹向上爬」, 更新結點計數器N
min,max方法
min方法
由結點鍵間的大小關係可知, 鍵值最小的結點也就是整棵樹中位於最左端的結點。
因此咱們的思路是: 從根結點開始, 不斷向當前結點的左兒子遞歸,直到左兒子爲空時,返回當前結點的鍵值, 此時的鍵值就是全部鍵值中的最小值
代碼以下所示:
private Node min (Node x) {
if(x.left == null) return x; // 若是左兒子爲空,則當前結點鍵爲最小值,返回
return min(x.left); // 若是左兒子不爲空,則繼續向左遞歸
}
public int min () {
if(root == null) return -1;
return min(root).key;
}
max方法實現的思路是相同的,這裏就很少贅述了
delete方法是二叉查找樹中最複雜的一個API,在講解delete前,咱們要先實現deleteMin方法,這是實現delete的基礎
deleteMin方法
deleteMin的做用是:刪除整顆樹中鍵最小的那個結點。
deleteMin的實現思路就是在前面介紹的min方法的基礎上再對查找到的結點進行刪除。
假設查找到的鍵最小的結點爲min結點, min結點的父節點爲min.parent, min結點的右兒子爲min.right, 那麼:
刪除min結點的方法就是將min.parent的左連接指向min.right, 這樣min結點就被刪除了。
【注意】咱們不能直接對min.parent的左連接賦null: min.parent.left = null, 由於min結點可能有右子樹(如上圖所示), 這樣咱們會把不應刪除的min的右子樹也一併刪除了
代碼以下:
public Node deleteMin (Node x) {
if(x.left==null) return x.right; // 若是當前結點左兒子空,則將右兒子返回給上一層遞歸的x.left
x.left = deleteMin(x.left);// 向左子樹遞歸, 同時重置搜索路徑上每一個父結點指向左兒子的連接
x.N = size(x.left) + size(x.right) + 1; // 更新結點計數器N
return x; // 當前結點不是min ###
}
public void deleteMin () {
root = deleteMin(root);
}
這段代碼的做用有兩方面:
- 沿搜索路徑重置結點連接
- 更新路徑上的結點計數器
沿搜索路徑重置結點連接
如上文所說, 重置結點連接要結合上下兩層遞歸來看
- 在遞歸到最後一個結點前, 下一層遞歸返回值是x(代碼中###處), 這時,對上一層遞歸來講, x.left = deleteMin(x.left)等同於x.left = x.left
- 當遞歸到最後一個結點時,下一層遞歸中x = min, x.left==null斷定爲true, 返回x.right給上一層遞歸, 對上一層遞歸來講,x.left = deleteMin(x.left)等同於x.left = x.left.right;
請注意,上面表述中的上下兩層遞歸裏的x的含義是不一樣的
更新結點計數器N
同上文所述, x.N = size(x.left) + size(x.right) + 1是遞歸調用後代碼, 執行順序是從深的遞歸層次到 淺的遞歸層次執行, 調用「沿着樹往上爬」, 從下往上更新路徑上各結點的N值
調用軌跡
delete方法
delete方法: 根據給定鍵從字典中刪除鍵值對
delete方法的實現還要依賴於BST中的一種特殊的結點——繼承結點
繼承結點
繼承結點的定義以下:
例如, 下圖中14的繼承結點是15, 它是14的右子樹中的最左結點,也即它是右子樹中的最小鍵
爲何稱15爲14的繼承結點呢? 由於用它去替換14後,將仍然能保持整顆二叉查找樹的有序性
例如圖,若是咱們把15放到14的位置(至關於把14從原來位置刪除,18和16相接)
此時, 放在新位置的15:
- 相對於父節點(A)而言是有序的。
- 相對於左子樹(B)而言是有序的(15本來位於14右子樹,因此大於14的左子樹)
- 相對於右子樹(C)而言是有序的(15是原來14右子樹的最小鍵,移動後也小於C中其餘結點)
因此故名思議, 繼承結點就是某個結點被刪除後,可以「繼承」某個結點的結點
刪除的實現思路
- 查找到相應的結點
- 將其刪除
分析刪除某個結點的三種狀況
刪除結點時, 按結點的位置,能夠分三種狀況分析:
第一種狀況: 當被刪除的結點沒有子樹時, 直接將它父節點指向它的連接置爲null
第二種狀況: 當被刪除的結點有且僅有一個子樹時候,則將父節點指向該結點的連接, 改成指向該節點的子節點。
總結狀況一和二, 若是咱們把null結點也看做「結點」的話, 第一/二種狀況的處理邏輯是同樣的。
都是:在查找到待刪除結點後,判斷左子樹或右子樹是否爲空, 若其中一個子樹爲空,則將該結點的父節點指向該節點的連接, 改成指向該節點的另外一顆子樹(左子樹爲null則指向右子樹,右子樹爲null則指向右子樹)。
比較複雜的是第三種狀況
第三種狀況: 當被刪除的結點既有左子樹又有右子樹的時候
首先讓咱們思考一個問題: 在下面這種狀況中,直接的「刪除」是不可能作到的。
由於del結點被刪除後,咱們要同時處理兩顆子樹:del.left和del.right,有兩條連接須要「從新接上」,可是del的父節點卻只能提供一條連接, 這種不匹配使得「原地刪除」變成了一件不可能作到的事情
因此咱們的思路並非使del結點「原地刪除」,而是想辦法尋找樹中另外一個結點去替代它,實現覆蓋,並且但願在覆蓋後仍能保持整顆樹的有序性。
沒錯!輪到你出場了!—— 繼承結點
若是咱們先「刪除」繼承結點inherit,而後把inherit放在待刪除結點del的位置上,去覆蓋它,就能夠啦。
由繼承結點的性質可知覆蓋後整顆樹的有序性是仍可以獲得保持的, 美滋滋~~
代碼以下:
public Node delete (int key,Node x) {
if(x == null) return null;
if(key<x.key){
x.left = delete(key,x.left); // 向左子樹查找鍵爲key的結點 #1
}else if (key>x.key){
x.right = delete(key,x.right); // 向右子樹查找鍵爲key的結點 #2
}else{ // 在這個else裏結點已經被找到,就是當前的x
// 這裏處理的是上述的 第一種狀況和第二種狀況:左子樹爲null或右子樹爲null(或都爲null)
if(x.left==null) return x.right; // 若是左子樹爲空,則將右子樹賦給父節點的連接 #3
if(x.right==null) return x.left; // 若是右子樹爲空,則將左子樹賦給父節點的連接 #4
// 這裏處理的是上述的第三種狀況
Node inherit = min(x.right); // 取得結點x的繼承結點
inherit.right = deleteMin(x.right); // 將繼承結點從原來位置刪除,並重置繼承結點右連接
inherit.left = x.left; // 重置繼承結點左連接
x = inherit; // 將x替換爲繼承結點
}
x.N = size(x.left)+ size(x.right) + 1; // 更新結點計數器
return x; // #5
}
public void delete (int key) {
root = delete(key, root);
}
仍是和以前同樣, 按上下兩個遞歸層次分析代碼
在查找到和key值相等的結點後:
1.若是結點的位置是第一種狀況:即被刪除的結點沒有子子樹。對於下一層遞歸:在上面的#3處, if(x.left==null) 斷定爲true, 接着執行if語句裏的return x.right, 等同於return null, 將值返回給上一層遞歸中的x.left = delete(key,x.left); 或x.right = delete(key,x.right); (#1和#2處)。等同於x.left = null或x.right =null。結點刪除成功
2. 若是結點的位置是第二種狀況:即當被刪除的結點有且僅有一個子樹。對於下一層遞歸: 若是左子樹爲null,則執行if(x.left==null) return x.right 返回非空的右子樹,同理若是是右子樹爲null則返回非空的左子樹。 上一層的遞歸經過x.left = delete(key,x.left);或x.right = delete(key,x.right); 接收到返回值,重置連接,結點刪除成功
。
3. 若是結點的位置是第三種狀況:當被刪除的結點既有左子樹又有右子樹。那麼先經過deleteMin刪除該節點的繼承結點inherit(右子樹的最小結點)。而後,inherit有四個屬性:key,value,left,right。保持inherit的key屬性和value屬性不變,而將left,right屬性更改成和待刪除結點相同。 這時就能夠進行「覆蓋」了, 經過x = inherit重置x結點, 並在下面的return x;(#5處)將繼承結點覆蓋後的x結點賦給上一層遞歸的x.left/right
運行軌跡
rank方法
rank方法:輸入一個key,返回這個key在字典中的排名, 也就是key在查找二叉樹對應的有序序列中的排名。
rank方法的思路:從根結點開始,若是給定的鍵和根結點的鍵相等, 則返回左子樹中的結點總數t;若是給定的鍵小於根結點,則返回改鍵在左子樹的排名(遞歸計算);若是給定的鍵大於根結點,則返回t+1(根結點)加上它在右子樹中的排名。
具體解釋以下:
在查找鍵的排名的時候,分三種狀況:
1. 若是當前結點鍵小於key, 則說明key在左子樹,向左子樹遞歸。此時還沒有肯定key排名的下界,不須要增長Rank值。
2. 若是當前結點鍵大於key,說明key在右子樹, 向右子樹遞歸。此時可以對key排名的下界進行進一步的計算。 計算方法:Rank = Rank的累計值 + 左子樹結點總數+ 1, 以下圖所示:
(假設圖中查找的key爲6)
3. 若是當前結點鍵恰好等於key, 排名的遞歸計算結束,此時只要再加上左子樹的結點總數就能夠了。計算方法:Rank = Rank累計值 + 左子樹結點總數
(假設圖中查找的key爲6,接上圖)
代碼以下:
public int rank (Node x,int key) {
if(x == null) return 0;
if(key<x.key) {
return rank(x.left,key);
}else if(key>x.key) {
return size(x.left) + 1 + rank(x.right, key);
}else {
return size(x.left);
}
}
public int rank (int key) {
return rank(root,key);
}
select方法
select方法是rank的逆方法: 找到給定排名的鍵
實現思路: 查找排名爲k的鍵,若是左子樹中的結點數大於k, 那麼咱們就繼續(遞歸地)在左子樹中查找排名爲k的鍵; 若是t等於k,咱們就返回根結點中的鍵,若是t小於k,咱們就(遞歸地)在右子樹中查找排名爲k-t-1的鍵。
代碼以下:
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;
}
}
public int select (int k) {
return select(root,k).key;
}
運行軌跡
floor、ceiling方法
floor: 向下取整,取得小於或等於給定key的最大鍵
在查找過程當中,分3種狀況:
1. key小於當前結點的鍵,因此對key向下取整的結果確定會在左子樹, 因此向左兒子遞歸處理
2. key等於當前結點的鍵, 也符合floor的定義, 因此直接返回該鍵
3. key大於當前結點的鍵,這種狀況只能先排除左子樹,在此基礎上有兩種可能:floor值就是當前結點的鍵,或者floor在當前結點的右子樹中, 但因爲條件不足沒法當即給出判斷,因此只能繼續向右子樹遞歸floor方法,並取得遞歸的返回值,判斷遞歸返回的結果是否爲null
- 若是遞歸返回null,說明右子樹沒有floor值,因此floor值就是當前結點的鍵,
- 若是遞歸不爲null,說明右子樹還有比當前結點鍵更大的floor值,因此返回遞歸後的非null的floor值
代碼以下:
private Node floor (Node x,int key) {
if(x==null) return null;
if(key<x.key){ // key小於當前結點的鍵
return floor(x.left,key); // key的floor值在左子樹,向左遞歸
}else if(key==x.key) {
return x; // 和key相等,也是floor值,返回
}else { // 這裏排除floor值在左子樹,剩下兩種可能:floor值是當前結點或在右子樹
Node n = floor(x.right, key);
if(n==null) return x; // 右子樹沒有找到floor值,因此當前結點鍵就是floor
else return n; // 右子樹找到floor值,返回找到的floor值
}
}
public int floor (int key) {
if(root==null) return -1; //樹爲空, 沒有floor值
return floor(root, key).key;
}
軌跡圖示
ceiling方法實現同理,這裏就不寫代碼了
【完】