生于平庸,不甘于平庸,回归于平庸
前言
上一篇 数据结构预算法系列——查找(一) 主要讲的是静态查找,接下来的篇章主要讲动态查找,即在查找的过程中还要进行插入或删除操作。我们考虑,对于数据的两种存储结构:顺序存储结构方便查找数据元素,链式存储结构方便插入或删除数据元素。而动态查找既要满足方便查找的同时还能高效地进行插入或删除操作,所以需要结合两种存储结构来实现,树的存储结构就充分利用了顺序存储与链式存储结构的特点,因为一般的树也都能简单转换为二叉树,而二叉树的存储结构及其算法都较为简单,所以二叉树特别重要。那下面我们就来一起看看二叉排序树是如何实现动态查找的。
二叉排序树
二叉排序树,又称二叉搜索树、二叉查找树,即BST树。其或者是一棵空树;或者是具有下列性质的二叉树:
-
若左子树不空,则左子树上所有结点的值均小于它的根结点的值;
-
若右子树不空,则右子树上所有结点的值均大于它的根结点的值;
-
左、右子树也分别为二叉排序树;
-
没有键值相等的结点。
如下图就是一颗二叉排序树:
我们对它进行中序遍历时,就可以得到一个有序序列[2,3,4,6,7,9,13,15,17,18,20]。当然,我们构建这样一颗二叉排序树,目的不是为了排序,而是为了提高查找和插入删除关键字的速度。有序的查找肯定比无序查找快,因为你可以运用折半等算法,而树这种非线性结构,也有利于插入和删除的实现。
创建
我们要进行查找的数据集大多数情况下是无序的,首先我们需要将数据集转换为二叉排序树的结构,这其实就是插入操作,构建一颗空树,然后将节点按规则插入,从而创建出二叉排序树。代码如下:
function BinaryTree() {
// 定义根节点
let root = null;
// 每一个节点的数据结构
let Node = function(key){
this.key = key;
this.left = null;
this.right = null;
}
// 插入操作
this.insert = function(key){
let newNode = new Node(key);
if(root === null){
root = newNode;
}else{
insertNode(root, newNode);
}
}
// 插入操作的辅助函数实现
insertNode = function(node, newNode){
if(newNode.key < node.key){
if(node.left === null){
node.left = newNode;
}else{
insertNode(node.left, newNode);
}
}else{
if(node.right === null){
node.right = newNode;
}else{
insertNode(node.right, newNode);
}
}
}
// 从 root 根节点开始通过 left 和 right 跟其他节点连接一起,得到root就相当于得到整颗树了
this.getRoot = function(){
return root;
}
}
代码很简单,我们以上图数据为例,来生成一颗二叉排序树:
let nodes = [15, 6, 18, 3, 7, 17, 20, 2, 4, 13, 9];
let binaryTree = new BinaryTree(); // 生成一颗空树
for(let node of nodes){ // 插入节点
binaryTree.insert(node);
}
// 查看整棵树的结构
console.log(binaryTree.getRoot());
遍历
中序遍历
添加如下代码:
this.inOrderTraverse = function(callback){
inOrderTraverseNode(root, callback);
}
// 辅助函数
let inOrderTraverseNode = function(node, callback){
if(node !== null){
inOrderTraverseNode(node.left, callback);
callback(node.key);
inOrderTraverseNode(node.right, callback);
}
}
执行:
let printNode = function(value){
console.log(value);
}
binaryTree.inOrderTraverse(printNode);
// 2 3 4 6 7 9 13 15 17 18 20
前序遍历
前序与后序遍历只是简单的改变代码执行位置,如下:
this.preOrderTraverse = function(callback){
preOrderTraverseNode(root, callback);
}
// 辅助函数
let preOrderTraverseNode = function(node, callback){
if(node !== null){
callback(node.key);
preOrderTraverseNode(node.left, callback);
preOrderTraverseNode(node.right, callback);
}
}
执行:
let printNode = function(value){
console.log(value);
}
binaryTree.preOrderTraverse(printNode);
// 15 6 3 2 4 7 13 9 18 17 20
后序遍历
代码如下:
this.postOrderTraverse = function(callback){
postOrderTraverseNode(root, callback);
}
// 辅助函数
let postOrderTraverseNode = function(node, callback){
if(node !== null){
postOrderTraverseNode(node.left, callback);
postOrderTraverseNode(node.right, callback);
callback(node.key);
}
}
执行:
let printNode = function(value){
console.log(value);
}
binaryTree.postOrderTraverse(printNode);
// 2 4 3 9 13 7 6 17 20 18 15
层序遍历
代码如下:
this.levelOrder = function(callback){
levelOrderNode(root, callback);
}
// 辅助函数
let levelOrderNode = function(node, callback){
if(node !== null){
let arr = [];
arr.push(node);
while(arr.length > 0){
let node = arr.shift();
callback(node.key);
if(node.left){
arr.push(node.left);
}
if(node.right){
arr.push(node.right);
}
}
}
}
执行:
let printNode = function(value){
console.log(value);
}
binaryTree.levelOrder(printNode);
// 15 6 18 3 7 17 20 2 4 13 9
查找
查找操作也比较简单,通过递归实现:
this.search = function(key){
return searchNode(root, key);
}
// 辅助函数
let searchNode = function(node, key){
if(node === null) return false;
if(key < node.key){
return searchNode(node.left, key);
}else if(key > node.key){
return searchNode(node.right, key);
}else{
return true;
}
}
最小值节点
最小值节点即二叉排序树最下层最左边节点,实现代码如下:
this.getMin = function(){
return getMinNode(root);
}
// 辅助函数
let getMinNode = function(node){
if(node){
while(node.left){
node = node.left;
}
return node;
}
return null;
}
最大值节点
最大值节点即二叉排序树最下层最右边节点,实现代码如下:
this.getMax = function(){
return getMaxNode(root);
}
// 辅助函数
let getMaxNode = function(node){
if(node){
while(node.right){
node = node.right;
}
return node;
}
return null;
}
删除
删除操作比较麻烦,因为需要移动节点。这里综合考虑以下三种情况:
- 删除的节点为叶子节点:直接删除,因为其他节点的结构并未受到影响;
- 删除的节点只有左子树或右子树:直接将它的左子树或右子树移动到删除节点的位置即可;
- 删除的节点既存在左子树也存在右子树:找到左子树最大节点或右子树最小节点
S,用S替换删除节点,然后再删除原来的节点S。
这里的疑问:针对上述第三种情况,为什么这么做?
答:在第二种情况的基础上,我们很容易想到,将要删除节点P的左子树直接移动到P的位置,然后将P的右子树所有节点再进行插入操作,但这么做,效率不高并且整棵树的结构可能发生重大变化,比如树的深度增加,深度增加最直接的影响就是查找效率降低(折半查找次数增多),所以这种做法不可取。
那最好的做法当然是替换了,主张对树结构造成的影响最小。对于选取左子树最大节点或右子树最小节点S的解释:在做中序遍历时,以[2, 3, 4, 6, 7, 9, 13, 15, 17, 18, 20]为例,此时删除节点6,可以看到4和7是最接近6的值,4和7为6的直接前驱、直接后继,所以它们是最合适的选择。用它俩中的任意一个来替换删除节点,我们可以看到,树的结构基本上不会发生什么本质改变。
有了思路,代码的实现就比较简单了:
this.remove = function(key) {
root = removeNode(root, key); // 因为根节点代表了整棵树,所以返回值为 root
};
// 辅助函数
let removeNode = function(node, key){
if(node === null) { // 判断是空树
return null;
}
if(key < node.key){
node.left = removeNode(node.left, key);
return node;
}else if(key > node.key){
node.right = removeNode(node.right, key);
return node;
}else{ // 找到了要删除的节点位置
if(node.left === null && node.right === null){ // 叶子节点
node = null;
return node;
}
if(node.right === null){ // 只有左子树
node = node.left;
return node
}
if(node.left === null){ // 只有右子树
node = node.right;
return node
}
// 既有左子树也有右子树情况,我们用左子树最大值节点来替换,你也可以用右子树最小值节点
let maxNode = this.getMax(node.left);
node.key = maxNode.key; // 只替换删除节点的值,其余不变
node.left = removeNode(node.left, maxNode.key); // 删除左子树最大值节点
return node;
}
}
总结
二叉排序树在较平衡的情况下,查找时间复杂度近似于 O(log2n),像极了折半查找;而极端不平衡情况下,如左斜树与右斜树,查找时间复杂度为 O(n),等于按顺序查找,所以我们需要让二叉排序树尽可能的平衡,这样效率才高,由此下一篇我们将进入平衡二叉树的领域。