20|認(rèn)識(shí)二叉樹基礎(chǔ)之二叉查找樹

再學(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()

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。