參考資料
《算法(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方法實現同理,這里就不寫代碼了
【完】
