用JavaScript實現二叉搜索樹

做者:Nicholas C. Zakas

翻譯:瘋狂的技術宅javascript

原文:https://humanwhocodes.com/blo...前端

未經容許嚴禁轉載java

計算機科學中最經常使用和討論最多的數據結構之一是二叉搜索樹。這一般是引入的第一個具備非線性插入算法的數據結構。二叉搜索樹相似於雙鏈表,每一個節點包含一些數據,以及兩個指向其餘節點的指針;它們在這些節點彼此相關聯的方式上有所不一樣。二叉搜索樹節點的指針一般被稱爲「左」和「右」,用來指示與當前值相關的子樹。這種節點的簡單 JavaScript 實現以下:node

var node = {
    value: 125,
    left: null,
    right: null
};

從名稱中能夠看出,二叉搜索樹被組織成分層的樹狀結構。第一個項目成爲根節點,每一個附加值做爲該根的祖先添加到樹中。可是,二叉搜索樹節點上的值是惟一的,根據它們包含的值進行排序:做爲節點左子樹的值老是小於節點的值,右子樹中的值都是大於節點的值。經過這種方式,在二叉搜索樹中查找值變得很是簡單,只要你要查找的值小於正在處理的節點則向左,若是值更大,則向右移動。二叉搜索樹中不能有重複項,由於重複會破壞這種關係。下圖表示一個簡單的二叉搜索樹。git

clipboard.png

上圖表示一個二叉搜索樹,其根的值爲 8。當添加值 3 時,它成爲根的左子節點,由於 3 小於 8。當添加值 1 時,它成爲 3 的左子節點,由於 1 小於 8(因此向左)而後 1 小於3(再向左)。當添加值 10 時,它成爲跟的右子節點,由於 10 大於 8。不斷用此過程繼續處理值 6,4,7,14 和 13。此二叉搜索樹的深度爲 3,表示距離根最遠的節點是三個節點。程序員

二叉搜索樹以天然排序的順序結束,所以可用於快速查找數據,由於你能夠當即消除每一個步驟的可能性。經過限制須要查找的節點數量,能夠更快地進行搜索。假設你要在上面的樹中找到值 6。從根開始,肯定 6 小於 8,所以前往根的左子節點。因爲 6 大於 3,所以你將前往右側節點。你就能找到正確的值。因此你只需訪問三個而不是九個節點來查找這個值。github

要在 JavaScript 中實現二叉搜索樹,第一步要先定義基本接口:面試

function BinarySearchTree() {
    this._root = null;
}

BinarySearchTree.prototype = {

    //restore constructor
    constructor: BinarySearchTree,

    add: function (value){
    },

    contains: function(value){
    },

    remove: function(value){
    },

    size: function(){
    },

    toArray: function(){
    },

    toString: function(){
    }

};

基本接與其餘數據結構相似,有添加和刪除值的方法。我還添加了一些方便的方法,size()toArray()toString(),它們對 JavaScript 頗有用。算法

要掌握使用二叉搜索樹的方法,最好從 contains() 方法開始。 contains() 方法接受一個值做爲參數,若是值存在於樹中則返回 true,不然返回 false。此方法遵循基本的二叉搜索算法來肯定該值是否存在:segmentfault

BinarySearchTree.prototype = {

    //more code

    contains: function(value){
        var found       = false,
            current     = this._root

        //make sure there's a node to search
        while(!found && current){

            //if the value is less than the current node's, go left
            if (value < current.value){
                current = current.left;

            //if the value is greater than the current node's, go right
            } else if (value > current.value){
                current = current.right;

            //values are equal, found it!
            } else {
                found = true;
            }
        }

        //only proceed if the node was found
        return found;
    },

    //more code

};

搜索從樹的根開始。若是沒有添加數據,則可能沒有根,因此必需要進行檢查。遍歷樹遵循前面討論的簡單算法:若是要查找的值小於當前節點則向左移動,若是值更大則向右移動。每次都會覆蓋 current 指針,直到找到要找的值(在這種狀況下 found 設置爲 true)或者在那個方向上沒有更多的節點了(在這種狀況下,值不在樹上)。

contains() 中使用的方法也可用於在樹中插入新值。主要區別在於你要尋找放置新值的位置,而不是在樹中查找值:

BinarySearchTree.prototype = {

    //more code

    add: function(value){
        //create a new item object, place data in
        var node = {
                value: value,
                left: null,
                right: null
            },

            //used to traverse the structure
            current;

        //special case: no items in the tree yet
        if (this._root === null){
            this._root = node;
        } else {
            current = this._root;

            while(true){

                //if the new value is less than this node's value, go left
                if (value < current.value){

                    //if there's no left, then the new node belongs there
                    if (current.left === null){
                        current.left = node;
                        break;
                    } else {
                        current = current.left;
                    }

                //if the new value is greater than this node's value, go right
                } else if (value > current.value){

                    //if there's no right, then the new node belongs there
                    if (current.right === null){
                        current.right = node;
                        break;
                    } else {
                        current = current.right;
                    }       

                //if the new value is equal to the current one, just ignore
                } else {
                    break;
                }
            }
        }
    },

    //more code

};

在二叉搜索樹中添加值時,特殊狀況是在沒有根的狀況。在這種狀況下,只需將根設置爲新值便可輕鬆完成工做。對於其餘狀況,基本算法與 contains() 中使用的基本算法徹底相同:新值小於當前節點向左,若是值更大則向右。主要區別在於,當你沒法繼續前進時,這就是新值的位置。因此若是你須要向左移動但沒有左側節點,則新值將成爲左側節點(與右側節點相同)。因爲不存在重複項,所以若是找到具備相同值的節點,則操做將中止。

在繼續討論 size() 方法以前,我想深刻討論樹遍歷。爲了計算二叉搜索樹的大小,必需要訪問樹中的每一個節點。二叉搜索樹一般會有不一樣類型的遍歷方法,最經常使用的是有序遍歷。經過處理左子樹,而後是節點自己,而後是右子樹,在每一個節點上執行有序遍歷。因爲二叉搜索樹以這種方式排序,從左到右,結果是節點以正確的排序順序處理。對於 size() 方法,節點遍歷的順序實際上並不重要,但它對 toArray() 方法很重要。因爲兩種方法都須要執行遍歷,我決定添加一個能夠通用的 traverse() 方法:

BinarySearchTree.prototype = {

    //more code

    traverse: function(process){

        //helper function
        function inOrder(node){
            if (node){

                //traverse the left subtree
                if (node.left !== null){
                    inOrder(node.left);
                }            

                //call the process method on this node
                process.call(this, node);

                //traverse the right subtree
                if (node.right !== null){
                    inOrder(node.right);
                }
            }
        }

        //start with the root
        inOrder(this._root);
    },

    //more code

};

此方法接受一個參數 process,這是一個應該在樹中的每一個節點上運行的函數。該方法定義了一個名爲 inOrder() 的輔助函數用於遞歸遍歷樹。注意,若是當前節點存在,則遞歸僅左右移動(以免屢次處理 null )。而後 traverse() 方法從根節點開始按順序遍歷,process() 函數處理每一個節點。而後可使用此方法實現size()toArray()toString()

BinarySearchTree.prototype = {

    //more code

    size: function(){
        var length = 0;

        this.traverse(function(node){
            length++;
        });

        return length;
    },

    toArray: function(){
        var result = [];

        this.traverse(function(node){
            result.push(node.value);
        });

        return result;
    },

    toString: function(){
        return this.toArray().toString();
    },

    //more code

};

size()toArray() 都調用 traverse() 方法並傳入一個函數來在每一個節點上運行。在使用 size()的狀況下,函數只是遞增加度變量,而 toArray() 使用函數將節點的值添加到數組中。 toString()方法在調用 toArray() 以前把返回的數組轉換爲字符串,並返回 。

刪除節點時,你須要肯定它是否爲根節點。根節點的處理方式與其餘節點相似,但明顯的例外是根節點須要在結尾處設置爲不一樣的值。爲簡單起見,這將被視爲 JavaScript 代碼中的一個特例。

刪除節點的第一步是肯定節點是否存在:

BinarySearchTree.prototype = {

    //more code here

    remove: function(value){

        var found       = false,
            parent      = null,
            current     = this._root,
            childCount,
            replacement,
            replacementParent;

        //make sure there's a node to search
        while(!found && current){

            //if the value is less than the current node's, go left
            if (value < current.value){
                parent = current;
                current = current.left;

            //if the value is greater than the current node's, go right
            } else if (value > current.value){
                parent = current;
                current = current.right;

            //values are equal, found it!
            } else {
                found = true;
            }
        }

        //only proceed if the node was found
        if (found){
            //continue
        }

    },
    //more code here

};

remove()方法的第一部分是用二叉搜索定位要被刪除的節點,若是值小於當前節點的話則向左移動,若是值大於當前節點則向右移動。當遍歷時還會跟蹤 parent 節點,由於你最終須要從其父節點中刪除該節點。當 found 等於 true 時,current 的值是要刪除的節點。

刪除節點時須要注意三個條件:

  1. 葉子節點
  2. 只有一個孩子的節點
  3. 有兩個孩子的節點

從二叉搜索樹中刪除除了葉節點以外的內容意味着必須移動值來對樹正確的排序。前兩個實現起來相對簡單,只刪除了一個葉子節點,刪除了一個帶有一個子節點的節點並用其子節點替換。最後一種狀況有點複雜,以便稍後訪問。

在瞭解如何刪除節點以前,你須要知道節點上究竟存在多少個子節點。一旦知道了,你必須肯定節點是否爲根節點,留下一個至關簡單的決策樹:

BinarySearchTree.prototype = {

    //more code here

    remove: function(value){

        var found       = false,
            parent      = null,
            current     = this._root,
            childCount,
            replacement,
            replacementParent;

        //find the node (removed for space)

        //only proceed if the node was found
        if (found){

            //figure out how many children
            childCount = (current.left !== null ? 1 : 0) + 
                         (current.right !== null ? 1 : 0);

            //special case: the value is at the root
            if (current === this._root){
                switch(childCount){

                    //no children, just erase the root
                    case 0:
                        this._root = null;
                        break;

                    //one child, use one as the root
                    case 1:
                        this._root = (current.right === null ? 
                                      current.left : current.right);
                        break;

                    //two children, little work to do
                    case 2:

                        //TODO

                    //no default

                }        

            //non-root values
            } else {

                switch (childCount){

                    //no children, just remove it from the parent
                    case 0:
                        //if the current value is less than its 
                        //parent's, null out the left pointer
                        if (current.value < parent.value){
                            parent.left = null;

                        //if the current value is greater than its
                        //parent's, null out the right pointer
                        } else {
                            parent.right = null;
                        }
                        break;

                    //one child, just reassign to parent
                    case 1:
                        //if the current value is less than its 
                        //parent's, reset the left pointer
                        if (current.value < parent.value){
                            parent.left = (current.left === null ? 
                                           current.right : current.left);

                        //if the current value is greater than its 
                        //parent's, reset the right pointer
                        } else {
                            parent.right = (current.left === null ? 
                                            current.right : current.left);
                        }
                        break;    

                    //two children, a bit more complicated
                    case 2:

                        //TODO          

                    //no default

                }

            }

        }

    },

    //more code here

};

處理根節點時,這是一個覆蓋它的簡單過程。對於非根節點,必須根據要刪除的節點的值設置 parent 上的相應指針:若是刪除的值小於父節點,則 left 指針必須重置爲 null (對於沒有子節點的節點)或刪除節點的 left 指針;若是刪除的值大於父級,則必須將 right 指針重置爲 null 或刪除的節點的 right指針。

如前所述,刪除具備兩個子節點的節點是最複雜的操做。考慮二元搜索樹的如下表示。

clipboard.png

根爲 8,左子爲 3,若是 3 被刪除會發生什麼?有兩種可能性:1(3 左邊的孩子,稱爲有序前身)或4(右子樹的最左邊的孩子,稱爲有序繼承者)均可以取代 3。

這兩個選項中的任何一個都是合適的。要查找有序前驅,即刪除值以前的值,請檢查要刪除的節點的左子樹,並選擇最右側的子節點;找到有序後繼,在刪除值後當即出現的值,反轉進程並檢查最左側的右子樹。其中每一個都須要另外一次遍歷樹來完成操做:

BinarySearchTree.prototype = {

    //more code here

    remove: function(value){

        var found       = false,
            parent      = null,
            current     = this._root,
            childCount,
            replacement,
            replacementParent;

        //find the node (removed for space)

        //only proceed if the node was found
        if (found){

            //figure out how many children
            childCount = (current.left !== null ? 1 : 0) + 
                         (current.right !== null ? 1 : 0);

            //special case: the value is at the root
            if (current === this._root){
                switch(childCount){

                    //other cases removed to save space

                    //two children, little work to do
                    case 2:

                        //new root will be the old root's left child
                        //...maybe
                        replacement = this._root.left;

                        //find the right-most leaf node to be 
                        //the real new root
                        while (replacement.right !== null){
                            replacementParent = replacement;
                            replacement = replacement.right;
                        }

                        //it's not the first node on the left
                        if (replacementParent !== null){

                            //remove the new root from it's 
                            //previous position
                            replacementParent.right = replacement.left;

                            //give the new root all of the old 
                            //root's children
                            replacement.right = this._root.right;
                            replacement.left = this._root.left;
                        } else {

                            //just assign the children
                            replacement.right = this._root.right;
                        }

                        //officially assign new root
                        this._root = replacement;

                    //no default

                }        

            //non-root values
            } else {

                switch (childCount){

                    //other cases removed to save space 

                    //two children, a bit more complicated
                    case 2:

                        //reset pointers for new traversal
                        replacement = current.left;
                        replacementParent = current;

                        //find the right-most node
                        while(replacement.right !== null){
                            replacementParent = replacement;
                            replacement = replacement.right;
                        }

                        replacementParent.right = replacement.left;

                        //assign children to the replacement
                        replacement.right = current.right;
                        replacement.left = current.left;

                        //place the replacement in the right spot
                        if (current.value < parent.value){
                            parent.left = replacement;
                        } else {
                            parent.right = replacement;
                        }          

                    //no default

                }

            }

        }

    },

    //more code here

};

具備兩個子節點的根節點和非根節點的代碼幾乎相同。此實現始終經過查看左子樹並查找最右側子節點來查找有序前驅。遍歷是使用 while 循環中的 replacementreplacementParent 變量完成的。 replacement中的節點最終成爲替換 current 的節點,所以經過將其父級的 right 指針設置爲替換的 left 指針,將其從當前位置移除。對於根節點,當 replacement 是根節點的直接子節點時,replacementParent 將爲 null,所以 replacementright 指針只是設置爲 root 的 right 指針。最後一步是將替換節點分配到正確的位置。對於根節點,替換設置爲新根;對於非根節點,替換被分配到原始 parent 上的適當位置。

關於此實現的說明:始終用有序前驅替換節點可能致使不平衡樹,其中大多數值會位於樹的一側。不平衡樹意味着搜索效率較低,所以在實際場景中應該引發關注。在二叉搜索樹實現中,要肯定是用有序前驅仍是有序後繼以使樹保持適當平衡(一般稱爲自平衡二叉搜索樹)。

這個二叉搜索樹實現的完整源代碼能夠在個人GitHub 中找到。對於替代實現,你還能夠查看 Isaac SchlueterGitHub fork


本文首發微信公衆號:前端先鋒

歡迎掃描二維碼關注公衆號,天天都給你推送新鮮的前端技術文章

歡迎掃描二維碼關注公衆號,天天都給你推送新鮮的前端技術文章

歡迎繼續閱讀本專欄其它高贊文章:


相關文章
相關標籤/搜索