實現一個二叉搜索樹(JavaScript 版)

二叉樹在計算機科學中應用很廣泛,學習它有助於讓我們寫出高效的插入、刪除、搜索節點算法。二叉樹的節點定義:一個節點最多隻有兩個節點,分別爲左側節點、右側節點。

二叉搜索樹是二叉樹中的一種,在二叉搜索樹中每個父節點的鍵值要大於左邊子節點小於右邊子節點。下圖展示一顆二叉搜索樹。

二叉搜索樹實現大綱

本文將使用 JavaScript 語言,實現一個二叉搜索樹,以下爲實現的方法:

  • constructor():構造函數,初始化一個二叉搜索樹
  • insert(value):二叉樹中查找一個節點,如果存在返回 true 否則返回 false
  • preOrderTraverse(cb):先序遍歷或稱前序遍歷
  • inOrderTraverse(cb):中序遍歷
  • postOrderTraverse(cb):後序遍歷
  • minNodeValue():最小節點值
  • maxNodeValue():最大節點值
  • removeNode(value):移除節點
  • destory():銷燬節點

注意:在實現二叉樹搜索

在這裏插入代碼片

的很多方法中我們將使用大量的遞歸操作,如果對它不瞭解的,可以自行查閱資料學習。

初始化一個二叉搜索樹

聲明一個 BST 類,在構造函數的 constructor() 裏聲明它的結構:

class BST {
    constructor () {
        this.root = null; // 初始化根節點
        this.count = 0; // 記錄二叉搜索的節點數量

        /**
         * 實例化一個 node 節點,在 insert 方法中你會看到
         */
        this.Node = function(value) {
            return {
                value, // 節點值
                count: 1, // 節點數量,允許節點重複
                left: null, // 左側子節點
                right: null, // 右側子節點
            }
        }
    } 

在之前的順序表文章中介紹了雙向鏈表,與之類似,我們使用 left、right 一個指向左側節點、一個指向右側節點。

二叉搜索樹插入節點

定義 insert 插入方法,接受一個 value 我們即將要插入的節點的值,在內部方法中調用 INSERT_RECUSIVE() 這個遞歸函數實現節點插入,返回結果給到 root。

/**
 * 二叉搜索樹插入元素
 * @param { Number } value 
 */
insert(value) {
    this.root = this[INSERT_RECUSIVE](this.root, value);
}

INSERT_RECUSIVE 使用 Symbol 進行聲明

const INSERT_RECUSIVE = Symbol('BST#recursiveInsert'); 

主要目的爲實現私有化,僅類內部調用,類似於 Private 聲明。

/**
 * 遞歸插入
 * 插入過程和鏈表類似,建議先學習鏈表會更容易理解
 * @param { Object } node 
 * @param { Number } value 
 */
[INSERT_RECUSIVE](node, value) {
    // {1} 如果當前節點爲空,創建一個新節點(遞歸到底)
    if (node === null) {
        this.count++; // 節點數加 1
        return new this.Node(value);
    }

    // {2} 節點數不變,說明要更新的值等於二叉樹中的某個節點值
    if (value === node.value) {
        node.count++; // 節點數加 1
    } else if (value < node.value) { // {3} 新插入子節點在二叉樹左邊,繼續遞歸插入
        node.left = this[INSERT_RECUSIVE](node.left, value);
    } else if (value > node.value) { // {4} 新插入子節點在二叉樹右邊,繼續遞歸插入
        node.right = this[INSERT_RECUSIVE](node.right, value);
    }

    return node;
}

下圖給出一個樹結構圖,我們使用剛剛寫好的代碼進行測試,生成如下結構所示的二叉搜索樹:

剛開始我需要 new 一個 bST 對象實例,執行 insert 方法插入節點

  • 第一次執行 bST.insert(30) 樹是空的,代碼行 {1} 將會被執行調用 new this.Node(value) 插入一個新節點。
  • 第二次執行 bST.insert(25) 樹不是空的,25 比 30 小(value < node.value),代碼行 {3} 將會被執行,在樹的左側遞歸插入並調用 INSERT_RECUSIVE 方法傳入 node.left,第二次遞歸時由於 node.left 已經爲 null,所以插入新節點
  • 第三次執行 bST.insert(36) 同理,執行順序爲 {4} -> 遞歸 {1}
const bST = new BST();

bST.insert(30);
bST.insert(25);
bST.insert(36);
bST.insert(20);
bST.insert(28);
bST.insert(32);
bST.insert(40);

console.dir(bST, { depth: 4 })

二叉搜索樹查找節點

在 JavaScript 中我們可以通過 hasOwnProperty 來檢測指定 key 在對象是否存在,現在我們在二叉搜索中實現一個類似的方法,傳入一個值 value 判斷是否在二叉搜索樹中存在

/**
 * 二叉樹中搜索節點
 * @param { Number } value 
 * @return { Boolean } [true|false]
 */
search(value) {
    return this[SEARCH_RECUSIVE](this.root, value);
}

同樣聲明一個 SEARCH_RECUSIVE 輔助函數實現遞歸搜索查找

  • 行 {1} 先判斷傳入的 node 是否爲 null,如果爲 null 就表示查找失敗,返回 false。
  • 行 {2} 說明已經找到了節點,返回 true。
  • 行 {3} 表示要找的節點,比當前節點小,在左側節點繼續查找。
  • 行 {4} 表示要找的節點,比當前節點大,在右側節點繼續查找。
/**
 * 遞歸搜索
 * @param { Object } node 
 * @param { Number } value 
 */
[SEARCH_RECUSIVE](node, value) {
    if (node === null) { // {1} 節點爲 null
        return false;
    } else if (value === node.value) { // {2} 找到節點
        return true;
    } else if (value < node.value) { // {3} 從左側節點搜索
        return this[SEARCH_RECUSIVE](node.left, value);
    } else { // {4} 從右側節點搜索
        return this[SEARCH_RECUSIVE](node.right, value);
    }
}

上面我們已經在樹中插入了一些節點,現在進行測試,20 在樹中是有的,返回了 true,而樹中我們沒有插入過 10 這個值,因此它返回了 false。

bST.search(20); // true
bST.search(10); // false

二叉搜索樹遍歷

遍歷是一個很常見的操作,後續再學習其它樹相關結構中也都會用到,對一顆樹的遍歷從哪裏開始?頂端、低端還是左右呢?不同的方式帶來的結果是不同的,共分爲前序、中序、後序三種方式遍歷,下面分別予以介紹。

先序遍歷

優先於後臺節點的順序訪問每個節點。

/**
 * 先序遍歷(前序遍歷)
 * @param { Function } cb 
 */
preOrderTraverse(cb) {
    return this[PRE_ORDER_TRAVERSE_RECUSIVE](this.root, cb);
}

聲明 PRE_ORDER_TRAVERSE_RECUSIVE 輔助函數進行前序遍歷,步驟如下:

  • 行 {1} 先訪問節點本身(從樹的頂端開始)
  • 行 {2} 訪問左側節點
  • 行 {3} 訪問右側節點
/**
 * 先序遍歷遞歸調用
 * @param { Object } node 
 * @param { Function } cb 
 */
[PRE_ORDER_TRAVERSE_RECUSIVE](node, cb) {
    if (node !== null) {
        cb(node.value); // {1} 先訪問節點本身(從樹的頂端開始)
        this[PRE_ORDER_TRAVERSE_RECUSIVE](node.left, cb); // {2} 訪問左側節點
        this[PRE_ORDER_TRAVERSE_RECUSIVE](node.right, cb); // {3} 訪問右側節點
    }
}

下圖左側展示了先序遍歷的訪問路徑,右側爲輸出。

中序遍歷

中序遍歷,先訪問左側節點,直到爲最小節點訪問到樹的最底端,將當前節點的 value 取出來,在訪問右側節點,適用於從小到大排序。

/**
 * 中序遍歷
 * @param { Function } cb 
 */
inOrderTraverse(cb) {
    return this[IN_ORDER_TRAVERSE_RECUSIVE](this.root, cb);
}

聲明 IN_ORDER_TRAVERSE_RECUSIVE 輔助函數進行中序遍歷,步驟如下:

  • 行 {1} 訪問左側節點
  • 行 {2} 訪問節點本身
  • 行 {3} 訪問右側節點
/**
 * 中序遍歷遞歸調用
 * @param { Object } node 
 * @param {Function } cb 
 */
[IN_ORDER_TRAVERSE_RECUSIVE](node, cb) {
    if (node !== null) {
        this[IN_ORDER_TRAVERSE_RECUSIVE](node.left, cb); // {1} 訪問左側節點
        cb(node.value); // {2} 取當前樹的子節點的值(樹的最底端)
        this[IN_ORDER_TRAVERSE_RECUSIVE](node.right, cb); // {3} 訪問右側節點
    }
}

下圖左側展示了中序遍歷的訪問路徑,右側爲其輸出結果是一個從小到大的順序排列。

後序遍歷

先訪問節點的子節點,再訪問節點本身,也就是當節點的左右節點都爲 null 時才取節點本身。

/**
 * 後序遍歷
 * @param { Function } cb 
 */
postOrderTraverse(cb) {
    return this[POST_ORDER_TRAVERSE_RECUSIVE](this.root, cb);
}
``

聲明 POST_ORDER_TRAVERSE_RECUSIVE 輔助函數進行中序遍歷,步驟如下:

  • {1} 訪問左側節點
  • {2} 訪問右側節點
  • {3} 取當前節點本身
/**
 * 後序遍歷遞歸調用
 * @param {*} node 
 * @param {*} cb 
 */
[POST_ORDER_TRAVERSE_RECUSIVE](node, cb) {
    if (node !== null) {
        this[POST_ORDER_TRAVERSE_RECUSIVE](node.left, cb); // {1} 訪問左側節點
        this[POST_ORDER_TRAVERSE_RECUSIVE](node.right, cb); // {2} 訪問右側節點
        cb(node.value); // {3} 取當前節點本身
    }
}

下圖左側展示了後序遍歷的訪問路徑,右側爲輸出結果。

後序遍歷一個應用場景適合對目錄進行遍歷計算,還適合做析構函數,從後序節點開始刪除。

二叉樹搜索銷燬

在上面最後講解了二叉搜索樹的後序遍歷,這裏講下它的實際應用,在 C++ 等面向對象編程語言中可以定義析構函數使得某個對象的所有引用都被刪除或者當對象被顯式銷燬時執行,做一些善後工作。

例如,我們本次實現的二叉搜索樹,可以利用後序遍歷的方式,逐漸將每個節點進行釋放。

定義一個 destroy 方法,以便在樹的實例上能夠調用。

/**
 * 二叉樹銷燬,可以利用後續遍歷特性實現
 */
destroy(){
    this.root = this[DESTORY_RECUSIVE](this.root);
}

定義一個 DESTORY_RECUSIVE 方法遞歸調用,本質也是一個後序遍歷。

/**
 * 銷燬二叉搜索樹遞歸調用
 * @param { Object } node 
 */
[DESTORY_RECUSIVE](node) {
    if (node !== null) {
        this[DESTORY_RECUSIVE](node.left);
        this[DESTORY_RECUSIVE](node.right);

        node = null;
        this.count--;
        return node;
    }
}

最大最小節點

在來回顧下二叉搜索樹的定義:“一個父親節點大於自己的左側節點和小於自己的右側節點”,根據這一規則可以很容易的求出最小最大值。

求二叉樹中最小節點值

查找最小值,往二叉樹的左側查找,直到該節點 left 爲 null 沒有左側節點,證明其是最小值。

/**
 * 求二叉樹中最小節點值
 * @return value
 */
minNodeValue() {
    const result = this.minNode(this.root);
    
    return result !== null ? result.value : null;
}

求最小節點

/**
 * 求最小節點
 */ 
minNode(node) {
    if (node === null) {
        return node;
    }

    while (node && node.left !== null) {
        node = node.left;
    }

    return node;
}

求二叉樹中最大節點

與上面類似,查找最大值,往二叉樹的右側查找,直到該節點 right 爲 null 沒有右側節點,證明其是最大值。

/**
 * 求二叉樹中最大節點
 */
maxNodeValue() {
    let node = this.root;

    if (node === null) {
        return node;
    }

    while(node && node.right !== null) {
        node = node.right;
    }

    return node.value;
}

刪除節點

定義一個 removeNode 方法,以便在樹的實例上能夠調用。

/**
 * 刪除節點
 * 若刪除節點爲 n,找到刪除節點的後繼 s = min(n->right)
 */
removeNode(value) {
    this.root = this[REMOVE_NODE_RECUSIVE](this.root, value);
}

同樣我們也需要定一個 REMOVE_NODE_RECUSIVE 方法遞歸調用,移除節點是二叉搜索樹中我們這實現的這些方法中最複雜的一個,代碼實現如下,也儘可能的爲你寫好了註釋,現將實現思路步驟列舉如下:

  • {1} 先判斷節點是否爲 null,如果等於 null 直接返回。
  • {2} 判斷要刪除節點小於當前節點,往樹的左側查找
  • {3} 判斷要刪除節點大於當前節點,往樹的右側查找
  • {4} 節點已找到,另劃分爲四種情況{4.1} 當前節點即無左側節點又無右側節點,直接刪除,返回 null{4.2} 若左側節點爲 null,就證明它有右側節點,將當前節點的引用改爲右側節點的引用,返回更新之後的值{4.3} 若右側節點爲 null,就證明它有左側節點,將當前節點的引用改爲左側節點的引用,返回更新之後的值{4.4} 若左側節點、右側節點都不爲空情況
/**
 * 刪除一個節點遞歸調用
 * @param { Object } node 
 * @param { Number } value 
 */
[REMOVE_NODE_RECUSIVE](node, value) {
    // {1} 未查找到直接返回 null
    if (node === null) {
        return node;
    }

    // {2} 左側節點遞歸刪除
    if (value < node.value) {
        node.left = this[REMOVE_NODE_RECUSIVE](node.left, value);
        return node;
    }

    // {3} 右側節點遞歸刪除
    if (value > node.value) {
        node.right = this[REMOVE_NODE_RECUSIVE](node.right, value);
        return node;
    }

    // {4} value === node.value 節點找到

    // {4.1} 當前節點即無左側節點又無右側節點,直接刪除,返回 null
    if (node.left === null && node.right === null) {
        node = null;
        this.count--;
        return node;
    }

    // {4.2} 若左側節點爲 null,就證明它有右側節點,將當前節點的引用改爲右側節點的引用,返回更新之後的值
    if (node.left === null) {
        node = node.right;
        this.count--;
        return node;
    }

    // {4.3} 若右側節點爲 null,就證明它有左側節點,將當前節點的引用改爲左側節點的引用,返回更新之後的值
    if (node.right === null) {
        node = node.left;
        this.count--;
        return node;
    }

    // {4.4} 若左側節點、右側節點都不爲空情況
    // s = min(n->right)
    if (node.left !== null && node.right !== null) {
        // 找到最小節點,切斷對象引用,複製一個新對象 s
        const s = new this.CopyNode(this.minNode(node.right));
        this.count++;
        s.left = node.left;
        s.right = this[REMOVE_NODE_RECUSIVE](node.right, s.value); // 刪除最小節點
        node = null; 
        this.count--;
        return s; // 返回 s 節點替換掉 node 節點
    }
}

假設現在我們將樹中節點爲 30 的刪除,下圖展示了刪除前和刪除之後的對比

二分搜索樹侷限性

同樣的數據,不同的插入順序,樹的結果是不一樣的,如下圖所示:

這就是二叉搜索樹存在的問題,它可能是極端的,並不總是向左側永遠是一個平衡的二叉樹,如果我順序化插入樹的形狀就如右側所示,會退化成一個鏈表,試想如果我需要查找節點 40,在右圖所示的樹形中需要遍歷完所有節點,相比於左側時間性能會消耗一倍。

爲了解決這一問題,可能需要一種平衡的二叉搜索樹,常用的實現方法有紅黑樹、AVL 樹等。

本節源碼:

https://github.com/Q-Angelo/project-training/tree/master/algorithm/bst.js
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章