做者:Nicholas C. Zakas翻譯:瘋狂的技術宅javascript
原文:https://humanwhocodes.com/blo...前端
未經容許嚴禁轉載java
計算機科學中最經常使用和討論最多的數據結構之一是二叉搜索樹。這一般是引入的第一個具備非線性插入算法的數據結構。二叉搜索樹相似於雙鏈表,每一個節點包含一些數據,以及兩個指向其餘節點的指針;它們在這些節點彼此相關聯的方式上有所不一樣。二叉搜索樹節點的指針一般被稱爲「左」和「右」,用來指示與當前值相關的子樹。這種節點的簡單 JavaScript 實現以下:node
var node = { value: 125, left: null, right: null };
從名稱中能夠看出,二叉搜索樹被組織成分層的樹狀結構。第一個項目成爲根節點,每一個附加值做爲該根的祖先添加到樹中。可是,二叉搜索樹節點上的值是惟一的,根據它們包含的值進行排序:做爲節點左子樹的值老是小於節點的值,右子樹中的值都是大於節點的值。經過這種方式,在二叉搜索樹中查找值變得很是簡單,只要你要查找的值小於正在處理的節點則向左,若是值更大,則向右移動。二叉搜索樹中不能有重複項,由於重複會破壞這種關係。下圖表示一個簡單的二叉搜索樹。git
上圖表示一個二叉搜索樹,其根的值爲 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
的值是要刪除的節點。
刪除節點時須要注意三個條件:
從二叉搜索樹中刪除除了葉節點以外的內容意味着必須移動值來對樹正確的排序。前兩個實現起來相對簡單,只刪除了一個葉子節點,刪除了一個帶有一個子節點的節點並用其子節點替換。最後一種狀況有點複雜,以便稍後訪問。
在瞭解如何刪除節點以前,你須要知道節點上究竟存在多少個子節點。一旦知道了,你必須肯定節點是否爲根節點,留下一個至關簡單的決策樹:
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
指針。
如前所述,刪除具備兩個子節點的節點是最複雜的操做。考慮二元搜索樹的如下表示。
根爲 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
循環中的 replacement
和 replacementParent
變量完成的。 replacement
中的節點最終成爲替換 current
的節點,所以經過將其父級的 right
指針設置爲替換的 left
指針,將其從當前位置移除。對於根節點,當 replacement
是根節點的直接子節點時,replacementParent
將爲 null
,所以 replacement
的 right
指針只是設置爲 root 的 right
指針。最後一步是將替換節點分配到正確的位置。對於根節點,替換設置爲新根;對於非根節點,替換被分配到原始 parent
上的適當位置。
關於此實現的說明:始終用有序前驅替換節點可能致使不平衡樹,其中大多數值會位於樹的一側。不平衡樹意味着搜索效率較低,所以在實際場景中應該引發關注。在二叉搜索樹實現中,要肯定是用有序前驅仍是有序後繼以使樹保持適當平衡(一般稱爲自平衡二叉搜索樹)。
這個二叉搜索樹實現的完整源代碼能夠在個人GitHub 中找到。對於替代實現,你還能夠查看 Isaac Schlueter 的 GitHub fork。