做者:Nicholas C. Zakasjavascript
翻译:疯狂的技术宅前端
原文:humanwhocodes.com/blog/2009/0…java
未经容许严禁转载node
计算机科学中最经常使用和讨论最多的数据结构之一是二叉搜索树。这一般是引入的第一个具备非线性插入算法的数据结构。二叉搜索树相似于双链表,每一个节点包含一些数据,以及两个指向其余节点的指针;它们在这些节点彼此相关联的方式上有所不一样。二叉搜索树节点的指针一般被称为“左”和“右”,用来指示与当前值相关的子树。这种节点的简单 JavaScript 实现以下:git
var node = {
value: 125,
left: null,
right: null
};
复制代码
从名称中能够看出,二叉搜索树被组织成分层的树状结构。第一个项目成为根节点,每一个附加值做为该根的祖先添加到树中。可是,二叉搜索树节点上的值是惟一的,根据它们包含的值进行排序:做为节点左子树的值老是小于节点的值,右子树中的值都是大于节点的值。经过这种方式,在二叉搜索树中查找值变得很是简单,只要你要查找的值小于正在处理的节点则向左,若是值更大,则向右移动。二叉搜索树中不能有重复项,由于重复会破坏这种关系。下图表示一个简单的二叉搜索树。github
上图表示一个二叉搜索树,其根的值为 8。当添加值 3 时,它成为根的左子节点,由于 3 小于 8。当添加值 1 时,它成为 3 的左子节点,由于 1 小于 8(因此向左)而后 1 小于3(再向左)。当添加值 10 时,它成为跟的右子节点,由于 10 大于 8。不断用此过程继续处理值 6,4,7,14 和 13。此二叉搜索树的深度为 3,表示距离根最远的节点是三个节点。算法
二叉搜索树以天然排序的顺序结束,所以可用于快速查找数据,由于你能够当即消除每一个步骤的可能性。经过限制须要查找的节点数量,能够更快地进行搜索。假设你要在上面的树中找到值 6。从根开始,肯定 6 小于 8,所以前往根的左子节点。因为 6 大于 3,所以你将前往右侧节点。你就能找到正确的值。因此你只需访问三个而不是九个节点来查找这个值。前端工程化
要在 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
。此方法遵循基本的二叉搜索算法来肯定该值是否存在:
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。