1. 二叉查找树(Binary search tree)
查找是编写程序过程中最常用的操作之一,为此,出现了许多专门针对查找的数据结构与算法,旨在更加快速的定位指定元素的位置。说起查找算法,大家想必都会联想到二分查找;作为一种常见的查找算法,对于一组有序的元素,二分查找首先定位序列最中心的元素,然后将中心元素与目标值做比较,以确定目标值出现在“左半部分”或“右半部分”。
根据二分查找的流程可以知道,每次比较都可以将查找范围缩小一半,因此它的时间复杂的可以达到O(log2n) 。二分查找可以说是在算法层面上,对查找操作做了一定的优化,以获得更低的时间复杂度,那么有没有某种数据结构,可以从“结构”方面对查找做一些优化呢? 答案当然是肯定的,通过树我们就可以达到此目的;当然,为达到优化时间复杂度的目的,肯定需要诸多的限制条件,以约束树的结构。
树是一种十分常见的数据结构,关于树的定义或基本概念,可以查看树(一种分支结构的数据组织方式)。这里主要讲一下二叉查找树,它是一种“类似”二分查找的二叉树(想要真正的类似,还需要一定的平衡条件):
-
所谓的二叉查找树 T,要么是一棵空树,要么是以 r = (key, value) 为根节点的二叉树,而且其左、右子树都是二叉查找树;
-
r 的 左子树中的所有节点(如果存在的话)的 关键码均不大于key;
-
r 的 右子树中的所有节点(如果存在的话)的 关键码均不小于key。
下图展示了一个二叉查找树的简单示例:
2. 插入操作
构造二叉查找树(或者是向树中添加元素)并不复杂,只需要根据新增节点的key与当前节点的key的大小关系,就可以确定后续将新增节点放在当前节点的左子树或者右子树,也就是说,新节点总是作为叶子节点的子节点被插入。插入操作的伪代码如下所示(为方便理解,伪代码中的key均以int
为例):
public void insert(Node node) {
if (null == r) { // 如果当前树为空,则构造一个二叉查找树并返回
r = node;
return;
}
Node nowNode = r;
while (true) {
if (node.key <= nowNode.key) { // 添加到左子树
if (null == nowNode.left) {
nowNode.left = node;
break;
} else {
nowNode = nowNode.left;
}
} else { // 添加到右子树
if (null == nowNode.right) {
nowNode.right = node;
break;
} else {
nowNode = nowNode.right;
}
}
}
}
3. 查找操作
对于二叉查找树,其查找过程就变得十分简单了,只需要根据节点的key与目标key的大小,就可以找到或确定下一步的查找方向(“向左”或“向右”查找)。查找操作的伪代码如下所示:
public Node get(int key) {
Node node = r; // 当前节点为根节点
while (node != null) {
if (key == node.key) {
return node;
} else if (key < node.key) {
node = node.left;
} else {
node = node.right;
}
}
return null;
}
4. 删除操作
对于删除操作:
- 如果要删除的节点是叶子节点,则直接删除它,删除操作并不会对二叉查找树的结构造成影响;
- 如果要删除节点d的左子树(或右子树)为空,则让d的父节点直接指向d的右(左)孩子;如果想要将被删除节点移动到叶子节点再删除的话,可以让d节点与孩子节点的位置互换,然后继续递归的删除改变了位置的d节点。
- 如果要删除节点d的左右子树均非空,找到其右子树key最小的节点(后继节点w),用w代替d,然后递归的删除d(右子树key最小的节点必然没有左子树,删除操作得到简化)。
其伪代码如下所示:
public Node remove(int key) {
Node d = get(key);
if (null = d) {
return null;
}
if (null == d.left) { // 左子树为空
让d的父节点直接指向d的右孩子,相当于是删除了的节点;
// 或与子节点互换后递归的删除替换后的节点
} else { // 左子树非空
if (null == d.right) { // 右子树为空
让d的父节点直接指向d的左孩子,相当于是删除了的节点;
// 或与子节点互换后递归的删除替换后的节点
} else { // 左右子树均非空
Node w = findMin(d.right);
用w的key和element代替d的key和element;
用w的右子树代替w(不存在则用null代替);
}
}
return node;
}
private Node findMin(Node node) {
while (node.left != null) {
node = node.left;
}
return node;
}
5. 平衡二叉树
讲到这里,二叉查找树的基本结构已经比较清晰,他与二分查找类似,都是根据key的大小关系决定查找方向,从而排除一部分的“无关节点”。当然,现在的类似只能说是“形似”,它目前还不具备二分查找可以优化时间复杂度的特点,观察下面这棵树:
可能有人会怀疑,它是二叉查找树吗?很不幸的是,它是,它符合二叉查找树的定义,因此这就是一棵二叉查找树。可以看到,在一些极端的情况下,二叉查找树退化成了链表;显然,它的查找操作的时间复杂度为O(n),并不具备二分查找O(log2n)的时间复杂度的优点。
仔细观察二分查找与二叉查找树,可以发现相较于二分查找,在二叉查找树中缺少了很重要的一步,“寻找中点”。在构造二叉查找树的时候,我们并不能保证它的所有节点的左右子树都是“平衡”的,因此这里的比较操作并不是与“中点”做比较,这也是引起二叉查找树查找操作的时间复杂度没有得到优化的原因。
为解决此问题,显然我们需要对二叉查找树再增加一些限制条件,以让其达到”平衡“。比较常见的带有平衡条件的二叉查找树有AVL树、红黑树等。
- AVL树所有节点的左子树和右子树的高度差不超过1;
- 红黑树通过标红和标黑节点,并增加一系列的限制条件,以达到平衡。
当然,这里所说的平衡并没有二分查找的中点那么严格,它只是一种相对的平衡,真正的像中点那样的平衡很难(也没有必要,代价太大)达到,可以将其看成类似中点的一种平衡。通过为二叉查找树增加平衡条件,就可以将查找操作的时间复杂度优化到O(log2n)。
6. 多路查找树
至此,我们已经可以用二叉树比较好的模拟出二分查找的操作。不妨再进一步的思考,对于这里只是对二叉树进行了合理的约束,我们知道,二叉树只是树的一个特例(虽然它是最常用的,但不代表所有),如果将上述操作中的二叉树替换为M叉树会是什么样的情景呢?
- 时间复杂度可以优化到O(logM(n))。
在一些情况下(如数据库,数据被固化在磁盘上),单次的查找操作代价比较大,对树的高度有很高的要求,这时候平衡二叉树就不能满足需求了,需要用M叉树代替,典型的例子就是B-树和B+树。
当然,性能的提升带来的往往是实现复杂度的增加,AVL树、红黑树、B-树和B+树的实现都比较复杂,此处不做深究,将会在后续的文章中进行探讨。
后续
关于AVL树,可以参考:查找树(二):AVL树.
关于红黑树,可以参考:查找树(三):红黑树(red black tree).
关于B-树和B+树,可以参考:查找树(四):B-树和B+树.