再學(xué)習(xí)具體的內(nèi)容之前,我們先思考一個(gè)問題。有了如此高效的散列表,為什么還需要二叉樹?
一、什么是二叉查找樹
??????二叉查找樹是二叉樹中最常用的一種類型,也叫二叉搜索樹。顧名思義,二叉查找樹是為了實(shí)現(xiàn)快速查找而生的。
??????二叉查找樹要求,在樹中的任意一個(gè)節(jié)點(diǎn),其左子樹中的每個(gè)節(jié)點(diǎn)的值,都要小于這個(gè)節(jié)點(diǎn)的值,而右子樹節(jié)點(diǎn)的值都大于這個(gè)節(jié)點(diǎn)的值。
二、二叉樹的增刪改查
1. 二叉查找樹的查找操作
??????首先,我們看如何在二叉查找樹中查找一個(gè)節(jié)點(diǎn)。我們先取根節(jié)點(diǎn),如果它等于我們要查找的數(shù)據(jù),那就返回。如果要查找的數(shù)據(jù)比根節(jié)點(diǎn)的值小,那就在左子樹中遞歸查找;如果要查找的數(shù)據(jù)比根節(jié)點(diǎn)的值大,那就在右子樹中遞歸查找。
class Node {
constructor(value){
this.value = value;
this.left = null;
this.right = null;
}
}
class SearchTree{
constructor(){
this.root = null;
}
}
find(data){
let p = this.root;
while(p != null){
if(data < p.data) {
p = p.left ;
} else if(data > p.data){
p = p.right;
} else {
return p;
}
}
return null;
}
2. 二叉查找樹的插入操作
??????如果要插入的數(shù)據(jù)比節(jié)點(diǎn)的數(shù)據(jù)大,并且節(jié)點(diǎn)的右子樹為空,就將新數(shù)據(jù)直接插到右子節(jié)點(diǎn)的位置;如果不為空,就再遞歸遍歷右子樹,查找插入位置。同理,如果要插入的數(shù)據(jù)比節(jié)點(diǎn)數(shù)值小,并且節(jié)點(diǎn)的左子樹為空,就將新數(shù)據(jù)插入到左子節(jié)點(diǎn)的位置;如果不為空,就再遞歸遍歷左子樹,查找插入位置。
insert(data){
let p = this.root;
while(p != null){
if(data > p.data){
if(p.right = null){
p.right = new Node(data);
return;
}
p = p.right;
}
if(data < p.data){
if(p.left = null){
p.left = new Node(data);
return;
}
p = p.left;
}
}
}
3. 二叉查找樹的刪除操作
??????二叉查找樹的查找、插入操作都比較簡(jiǎn)單易懂,但是它的刪除操作就比較復(fù)雜了 。針對(duì)要?jiǎng)h除節(jié)點(diǎn)的子節(jié)點(diǎn)個(gè)數(shù)的不同,我們需要分三種情況來處理。
??????第一種情況是,如果要?jiǎng)h除的節(jié)點(diǎn)沒有子節(jié)點(diǎn),我們只需要直接將父節(jié)點(diǎn)中,指向要?jiǎng)h除節(jié)點(diǎn)的指針置為 null。比如圖中的刪除節(jié)點(diǎn) 55。
??????第二種情況是,如果要?jiǎng)h除的節(jié)點(diǎn)只有一個(gè)子節(jié)點(diǎn)(只有左子節(jié)點(diǎn)或者右子節(jié)點(diǎn)),我們只需要更新父節(jié)點(diǎn)中,指向要?jiǎng)h除節(jié)點(diǎn)的指針,讓它指向要?jiǎng)h除節(jié)點(diǎn)的子節(jié)點(diǎn)就可以了。比如圖中的刪除節(jié)點(diǎn) 13。
??????第三種情況是,如果要?jiǎng)h除的節(jié)點(diǎn)有兩個(gè)子節(jié)點(diǎn),這就比較復(fù)雜了。我們需要找到這個(gè)節(jié)點(diǎn)的右子樹中的最小節(jié)點(diǎn),把它替換到要?jiǎng)h除的節(jié)點(diǎn)上。然后再刪除掉這個(gè)最小節(jié)點(diǎn),因?yàn)樽钚」?jié)點(diǎn)肯定沒有左子節(jié)點(diǎn)(如果有左子結(jié)點(diǎn),那就不是最小節(jié)點(diǎn)了),所以,我們可以應(yīng)用上面兩條規(guī)則來刪除這個(gè)最小節(jié)點(diǎn)。比如圖中的刪除節(jié)點(diǎn) 18。
4. 二叉查找樹的其他操作
??????除了插入、刪除、查找操作之外,二叉查找樹中還可以支持快速地查找最大節(jié)點(diǎn)和最小節(jié)點(diǎn)、前驅(qū)節(jié)點(diǎn)和后繼節(jié)點(diǎn)。
??????二叉查找樹除了支持上面幾個(gè)操作之外,還有一個(gè)重要的特性,就是中序遍歷二叉查找樹,可以輸出有序的數(shù)據(jù)序列,時(shí)間復(fù)雜度是 O(n),非常高效。因此,二叉查找樹也叫作二叉排序樹。
5.支持重復(fù)數(shù)據(jù)的二叉查找樹
??????前面講二叉查找樹的時(shí)候,我們默認(rèn)樹中節(jié)點(diǎn)存儲(chǔ)的都是數(shù)字。很多時(shí)候,在實(shí)際的軟件開發(fā)中,我們?cè)诙娌檎覙渲写鎯?chǔ)的,是一個(gè)包含很多字段的對(duì)象。
??????我們利用對(duì)象的某個(gè)字段作為鍵值(key)來構(gòu)建二叉查找樹。我們把對(duì)象中的其他字段叫作衛(wèi)星數(shù)據(jù)。前面我們講的二叉查找樹的操作,針對(duì)的都是不存在鍵值相同的情況。那如果存儲(chǔ)的兩個(gè)對(duì)象鍵值相同,這種情況該怎么處理呢?我這里有兩種解決方法。
??????第一種方法比較容易。二叉查找樹中每一個(gè)節(jié)點(diǎn)不僅會(huì)存儲(chǔ)一個(gè)數(shù)據(jù),因此我們通過鏈表和支持動(dòng)態(tài)擴(kuò)容的數(shù)組等數(shù)據(jù)結(jié)構(gòu),把值相同的數(shù)據(jù)都存儲(chǔ)在同一個(gè)節(jié)點(diǎn)上。
??????第一種方法是每個(gè)節(jié)點(diǎn)仍然只存儲(chǔ)一個(gè)數(shù)據(jù)。在查找插入位置的過程中,如果碰到一個(gè)節(jié)點(diǎn)的值,與要插入數(shù)據(jù)的值相同,我們就將這個(gè)要插入的數(shù)據(jù)放到這個(gè)節(jié)點(diǎn)的右子樹,也就是說,把這個(gè)新插入的數(shù)據(jù)當(dāng)作大于這個(gè)節(jié)點(diǎn)的值來處理。
??????當(dāng)要查找數(shù)據(jù)的時(shí)候,遇到值相同的節(jié)點(diǎn),我們并不停止查找操作,而是繼續(xù)在右子樹中查找,直到遇到葉子節(jié)點(diǎn),才停止。這樣就可以把鍵值等于要查找值的所有節(jié)點(diǎn)都找出來。
??????對(duì)于刪除操作,我們也需要先查找到每個(gè)要?jiǎng)h除的節(jié)點(diǎn),然后再按前面講的刪除操作的方法,依次刪除。
remove(data){
let p = this.root; // 指向要?jiǎng)h除的節(jié)點(diǎn),初始化指向節(jié)點(diǎn)
let pp = null; // pp記錄的是p的父節(jié)點(diǎn)
let child = null; // 要?jiǎng)h除節(jié)點(diǎn)的子節(jié)點(diǎn)
p = this.find(data)
// 要?jiǎng)h除的節(jié)點(diǎn)有兩個(gè)自節(jié)點(diǎn)
if(p.left != null && p.right != null){
let minP = p.right;
let minPP = p; // minPP表示minP的父節(jié)點(diǎn)
while(minP.left != null){
minPP = minP;
minP = minP.left;
}
p.data = minP.data; // 將minPP的數(shù)據(jù)替換到p中
p = minP; // 下面變成了刪除minP了
pp = minPP;
}
// 刪除節(jié)點(diǎn)是葉子節(jié)點(diǎn)或者僅有一個(gè)子節(jié)點(diǎn)
if(p.left != null){
child = p.left;
} else if(p.right != null){
child = p.right;
} else {
child = null;
}
if(pp == null) this.root = child; //刪除的是跟節(jié)點(diǎn)
else if(pp.left == p) pp.left == child;
else pp.right = child
}
三、二叉查找樹的時(shí)間復(fù)雜度分析
??????實(shí)際上,二叉查找樹的形態(tài)各式各樣。比如這個(gè)圖中,對(duì)于同一組數(shù)據(jù),我們構(gòu)造了三種二叉查找樹。它們的查找、插入、刪除操作的執(zhí)行效率都是不一樣的。圖中第一種二叉查找樹,根節(jié)點(diǎn)的左右子樹極度不平衡,已經(jīng)退化成了鏈表,所以查找的時(shí)間復(fù)雜度就變成了 O(n)。
??????我剛剛其實(shí)分析了一種最糟糕的情況,我們現(xiàn)在來分析一個(gè)最理想的情況,二叉查找樹是一棵完全二叉樹(或滿二叉樹)。這個(gè)時(shí)候,插入、刪除、查找的時(shí)間復(fù)雜度是多少呢?
??????從我前面的例子、圖,以及還有代碼來看,不管操作是插入、刪除還是查找,時(shí)間復(fù)雜度其實(shí)都跟樹的高度成正比,也就是 O(height)。既然這樣,現(xiàn)在問題就轉(zhuǎn)變成另外一個(gè)了,也就是,如何求一棵包含 n 個(gè)節(jié)點(diǎn)的完全二叉樹的高度?
??????樹的高度就等于最大層數(shù)減一,為了方便計(jì)算,我們轉(zhuǎn)換成層來表示。從圖中可以看出,包含 n 個(gè)節(jié)點(diǎn)的完全二叉樹中,第一層包含 1 個(gè)節(jié)點(diǎn),第二層包含 2 個(gè)節(jié)點(diǎn),第三層包含 4 個(gè)節(jié)點(diǎn),依次類推,下面一層節(jié)點(diǎn)個(gè)數(shù)是上一層的 2 倍,第 K 層包含的節(jié)點(diǎn)個(gè)數(shù)就是 2^(K-1)。
??????不過,對(duì)于完全二叉樹來說,最后一層的節(jié)點(diǎn)個(gè)數(shù)有點(diǎn)兒不遵守上面的規(guī)律了。它包含的節(jié)點(diǎn)個(gè)數(shù)在 1 個(gè)到 2^(L-1) 個(gè)之間(我們假設(shè)最大層數(shù)是 L)。如果我們把每一層的節(jié)點(diǎn)個(gè)數(shù)加起來就是總的節(jié)點(diǎn)個(gè)數(shù) n。也就是說,如果節(jié)點(diǎn)的個(gè)數(shù)是 n,那么 n 滿足這樣一個(gè)關(guān)系:
n >= 1+2+4+8+...+2^(L-2)+1
n <= 1+2+4+8+...+2^(L-2)+2^(L-1)
??????借助等比數(shù)列的求和公式,我們可以計(jì)算出,L 的范圍是[log2(n+1), log2n +1]。完全二叉樹的層數(shù)小于等于 log2n +1,也就是說,完全二叉樹的高度小于等于 log2n。
??????顯然,極度不平衡的二叉查找樹,它的查找性能肯定不能滿足我們的需求。我們需要構(gòu)建一種不管怎么刪除、插入數(shù)據(jù),在任何時(shí)候,都能保持任意節(jié)點(diǎn)左右子樹都比較平衡的二叉查找樹,這就是我們下一節(jié)課要詳細(xì)講的,一種特殊的二叉查找樹,平衡二叉查找樹。平衡二叉查找樹的高度接近 logn,所以插入、刪除、查找操作的時(shí)間復(fù)雜度也比較穩(wěn)定,是 O(logn)。
四、解答開篇
??????我們?cè)谏⒘斜砟枪?jié)中講過,散列表的插入、刪除、查找操作的時(shí)間復(fù)雜度可以做到常量級(jí)的 O(1),非常高效。而二叉查找樹在比較平衡的情況下,插入、刪除、查找操作時(shí)間復(fù)雜度才是 O(logn),相對(duì)散列表,好像并沒有什么優(yōu)勢(shì),那我們?yōu)槭裁催€要用二叉查找樹呢?
??????第一,散列表中的數(shù)據(jù)是無序存儲(chǔ)的,如果要輸出有序的數(shù)據(jù),需要先進(jìn)行排序。而對(duì)于二叉查找樹來說,我們只需要中序遍歷,就可以在 O(n) 的時(shí)間復(fù)雜度內(nèi),輸出有序的數(shù)據(jù)序列。
??????第二,散列表擴(kuò)容耗時(shí)很多,而且當(dāng)遇到散列沖突時(shí),性能不穩(wěn)定,盡管二叉查找樹的性能不穩(wěn)定,但是在工程中,我們最常用的平衡二叉查找樹的性能非常穩(wěn)定,時(shí)間復(fù)雜度穩(wěn)定在 O(logn)。
??????第三,籠統(tǒng)地來說,盡管散列表的查找等操作的時(shí)間復(fù)雜度是常量級(jí)的,但因?yàn)楣_突的存在,這個(gè)常量不一定比 logn 小,所以實(shí)際的查找速度可能不一定比 O(logn) 快。加上哈希函數(shù)的耗時(shí),也不一定就比平衡二叉查找樹的效率高。
??????第四,散列表的構(gòu)造比二叉查找樹要復(fù)雜,需要考慮的東西很多。比如散列函數(shù)的設(shè)計(jì)、沖突解決辦法、擴(kuò)容、縮容等。平衡二叉查找樹只需要考慮平衡性這一個(gè)問題,而且這個(gè)問題的解決方案比較成熟、固定。
??????最后,為了避免過多的散列沖突,散列表裝載因子不能太大,特別是基于開放尋址法解決沖突的散列表,不然會(huì)浪費(fèi)一定的存儲(chǔ)空間。
五、思考題
如何通過編程,求出一棵給定二叉樹的確切高度呢?
??????確定二叉樹高度有兩種思路:第一種是深度優(yōu)先思想的遞歸,分別求左右子樹的高度。當(dāng)前節(jié)點(diǎn)的高度就是左右子樹中較大的那個(gè)+1;第二種可以采用層次遍歷的方式,每一層記錄都記錄下當(dāng)前隊(duì)列的長(zhǎng)度,這個(gè)是隊(duì)尾,每一層隊(duì)頭從0開始。然后每遍歷一個(gè)元素,隊(duì)頭下標(biāo)+1。直到隊(duì)頭下標(biāo)等于隊(duì)尾下標(biāo)。這個(gè)時(shí)候表示當(dāng)前層遍歷完成。每一層剛開始遍歷的時(shí)候,樹的高度+1。最后隊(duì)列為空,就能得到樹的高度。
下面是完整的代碼,使用JavaScript,您可以參考一下,還是有一點(diǎn)難度的。
class Node {
constructor(value){
this.value = value;
this.left = null;
this.right = null;
}
}
/**
* 搜索二叉樹
* 允許重復(fù)添加
* 實(shí)現(xiàn)有點(diǎn)彎彎繞繞
*/
class SearchTree{
constructor(){
this.root = null;
}
insert(num){
let node = new Node(num);
if(this.root === null){
this.root = node;
return
}
let parent = this.getPrev(num);
if(num < parent.value){
parent.left = node;
} else {
parent.right = node;
}
}
getPrev(num, find = false){
let point = this.root;
let res = [];
while(true){
if(point.left){
if(num < point.value || num < point.left.value){
point = point.left;
continue
}
}
if(point.right){
if(num >= point.value || num >= point.right.value){
// 搜索時(shí)如果有多個(gè)值,則緩存
if(find && num === point.value){
res.push(point.value)
}
point = point.right;
continue
}
}
// 如果是搜索
if(find){
if (point.value === num) {
res.push(point.value)
}
if(res.length === 0){
return null
}
if(res.length === 1){
return res[0]
}
return res;
}
// 如果是添加,返回的是應(yīng)該添加的各節(jié)點(diǎn)的父節(jié)點(diǎn)
return point;
}
}
remove(num){
let point = this.root;
let parent = null;
let tree = this;
let res = null;
while(true){
if(point.left){
if(num < point.value || num < point.left.value){
parent = point;
point = point.left;
continue
}
}
if(point.right){
if(num >= point.value || num >= point.right.value){
if(num === point.value){
delMethod(point, parent);
if(parent === null){
point = this.root;
} else {
parent = parent;
point = parent.right;
}
res = true;
continue
}
parent = point;
point = point.right;
}
}
if(point.value === num){
res = true;
delMethod(point, parent)
}
break;
}
return res;
function delMethod(delNode, parent){
let p = delNode; // p指向要?jiǎng)h除的節(jié)點(diǎn)
let pp = parent; // pp記錄的是p的父節(jié)點(diǎn)
// 要?jiǎng)h除的節(jié)點(diǎn)有兩個(gè)子節(jié)點(diǎn)
// 查找右子樹中最小節(jié)點(diǎn)
if(p.left != null && p.right != null){
let minP = p.right;
let minPP = p; // minPP表示minP的父節(jié)點(diǎn)
while(minP.left != null){
minPP = minP;
minP = minP.left;
}
p.value = minP.value;// 將minP的數(shù)據(jù)替換到p中
p = minP; //下面就變成了刪除minP了
pp = minPP;
}
// 刪除的節(jié)點(diǎn)是葉子節(jié)點(diǎn)或者僅有一個(gè)子節(jié)點(diǎn)
let child; // p的子節(jié)點(diǎn)
if(p.left != null){
child = p.left;
} else if(p.right != null){
child = p.right
} else {
child = null
}
if(pp == null){
tree.root = child;
} else if(pp.left == p){
pp.left = child;
} else {
pp.right = child;
}
}
}
//中序遍歷
print(){
let point = this.root;
if(point){
printAll(point.left)
console.log(point.value)
printAll(point.right)
}
function printAll(point){
if(point === null){
return
}
printAll(point.left)
console.log(point.value)
printAll(point.right)
}
}
find(num){
if(this.root === null){
this.root = new Node(num)
return
}
return this.getPrev(num, true)
}
}
function baseTest(){
let searchTree = new SearchTree()
console.log('step 1:')
searchTree.insert(4);
searchTree.insert(1);
searchTree.insert(2);
searchTree.insert(5);
searchTree.print();
console.log(searchTree)
console.log('step 2:')
console.log('5', searchTree.find(5)) //5
console.log('null:', searchTree.find(6)) //null
searchTree.insert(5);
searchTree.insert(5);
console.log('5,5,5:', searchTree.find(5))
}
// baseTest()
//刪除測(cè)試
function delTest() {
let searchTree = new SearchTree();
console.log('add: 4 1 2 5 ')
searchTree.insert(4);
searchTree.insert(1);
searchTree.insert(2);
searchTree.insert(5);
searchTree.print(); //1 2 4 5
//console.log('del 3 null:', searchTree.remove(3));
console.log('del 1 true:', searchTree.remove(1));
searchTree.print(); //2 4 5
}
delTest()