动态查找之二叉排序树
之前讲过二分、插值和斐波那契查找方式,这三种查找算法是针对有序线性表的,有序表的插入和删除操作比较复杂,它需要移动插入和删除元素之后的所有元素,所以在查找时通常不进行数据的插入和删除,也就是说表的数据不会发生变化,我们称之为 静态查找表。那有没有即可以高效的查找又可以高效的插入和删除呢?有,二叉排序树就可以实现。下面介绍一下二叉排序树。
简介
二叉排序树又称二叉查找树,它若不为空则具有如下性质:
- 若左子树不为空,则左子树上所有节点的值均小于它根节点的值;
- 若右子树不为空,则右子树上所有节点的值均大于它根节点的值;
- 它左、右子树也分别为二叉排序树。
比如我们现在有集合{62,88,58,47,35,73,51,99,37,93},试着将它构建成二叉排序树。我们把集合看成数组,从下标由小到大逐渐插入树,首先将第一个数字62定为根节点,然后第二个数字88大于62,将它定为62的右子树,然后58小于62,将它定为62的左子树。
然后下一个数字47小于58,是58的左子树,然后再下一个数字…,以此类推构建如下图二叉树。对其进行中序遍历就可得到一个有序序列{35,37,47,51,58,62,73,88,93,99}。
由上述定义可知,二叉排序树首先是一棵二叉树,采用递归方式定义,节点间满足一定次序关系。二叉排序树的目的不是为了排序,而是为了提高查找、插入和删除效率,因为无论如何,在一个有序集合中查找其效率总是高于无序集合,而且二叉排序树这种结构也有利于插入和删除操作。下面我们实现以下二叉排序树的查找、插入和删除操作。
二叉排序树的插入操作
二叉排序树的节点和普通二叉树一样。
typedef struct BiTNode
{
int data;
BiTNode* lchild, *rchild;
}* BiTree;
根据上述分析得插入操作的代码如下:
void InsertBST(BiTree &T, int key)
{
if (!T)
{
T = (BiTree)new BiTNode;
T->data = key;
T->lchild = T->rchild = NULL;
return;
}
else
{
BiTree s = T;
while (1)
{
if (s->data == key)
return;
else if (s->data > key)
{
if (s->lchild == NULL)
{
s->lchild = (BiTree)new BiTNode;
s->lchild->data = key;
s->lchild->lchild = s->lchild->rchild = NULL;
return;
}
else
s = s->lchild;
}
else
{
if (s->rchild == NULL)
{
s->rchild = (BiTree)new BiTNode;
s->rchild->data = key;
s->rchild->lchild = s->rchild->rchild = NULL;
return;
}
else
s = s->rchild;;
}
}
}
}
我们通过逐个插入建立集合为{62,88,58,47,35,73,51,99,37,93}的二叉排序树,然后通过中序遍历方式升序打印集合,以下是测试代码及输出。
int main()
{
int i;
int a[] = { 62,88,58,47,35,73,51,99,37,93 };
BiTree T = NULL;
for (i = 0; i < 10; i++)
InsertBST(T, a[i]);
InOrderTraverse(T);//中序遍历
getchar();
}
35 37 47 51 58 62 73 88 93 99
二叉排序树的查找操作
BiTree SearchBST(BiTree T, int key)
{
if (!T)
return nullptr;
while (T)
{
if (T->data == key)
return T;
else if (T->data > key)
T = T->lchild;
else
T = T->rchild;
}
return T;
}
查找函数若查找成功返回所查找节点地址,否则返回空。
我们通过查找函数查找93,调用函数SearchBST(T, 93),我们看一下调用过程。
1.首先T指向根节点,节点不为空进入while循坏,while循坏条件为T不为空;
2.第一次循环,节点值为62小于93,执行T = T->rchild,T指向88;
3.第二次循环,节点值为88小于93,执行T = T->rchild,T指向99;
4.第三次循环,节点值为99大于93,执行T = T->lchild,T指向93;
5.第四次循环,节点值为93,返回节点地址。
二叉排序树的删除操作
二叉排序树的插入和查找操作比较简单也易于实现,但所谓“请神容易送神难”,二叉排序树的删除操作就不太容易了,因为删除任意节点后树的结构和特性不能改变,所以我们需要考虑多种情况。
1.如果删除节点37,51,73,93,那是很容易的,因为这些节点为叶子节点,我们可以直接删除不用做任何其他修改。如下图所示。
2.如果删除的节点只有左子树或右子树也比较容易,只需将其左子树或右子树整个移动到删除节点的位置即可,比如删除节点35、99、58。
3.如果删除的节点既有左子树又有右子树怎么办呢?一个办法是我们先将左子树移动至删除节点,然后将右子树的所有节点逐个插入,但这个做法效率不高并可能会增加高度,所以不是一个好的做法。
比较好的做法是我们从树中找出一个节点s替换删除节点p,然后删除节点s。比如我们删除下列二叉排序树的节点47。
那我们应该用哪个节点替换47呢,答案是37和48,为什么是这两个数呢?我们将该集合进行中序遍历得到其升序序列{29,35,36,37,47,48,49,50,51,56,58,62,73,88,93,99},发现37和48是47的直接前驱和直接后继,这两个数是47节点的左子树的最右端和右子树的最左端,并且37和48两个节点要么是叶子节点要么只有一个子树,我们只需进行一次替换操作和一次删除操作。
代码如下:
Status Delete(BiTree &p)
{
BiTree q, s;
if (p->rchild == NULL)
{
q = p;
p = p->lchild;
free(q);
}
else if (p->lchild == NULL)
{
q = p;
p = p->rchild;
free(q);
}
else
{
q = p; s = p->lchild;
while (s->rchild)
{
q = s; s = s->rchild;
}
p->data = s->data;
if (q != p)
q->rchild = s->lchild;
else
q->lchild = s->lchild;
free(s);
}
return OK;
}
Status DeleteBST(BiTree &T, int key)
{
if (!T)
return ERROR;
else
{
if (key == T->data)
return Delete(T);
else if (key < T->data)
return DeleteBST(T->lchild, key);
else
return DeleteBST(T->rchild, key);
}
}
删除节点47的调用过程如下:
1.Delete函数的前两个if和else if语句处理删除节点为叶子节点或只有左子树或右子树的情况。最后的else语句处理删除节点既有左子树也有右子树的情况。
2.将要删除的节点p赋给临时变量q,再将p的左孩子赋给临时变量s,此时q指向节点47,s指向节点35
3.循环使s指向节点35子树的最右端节点37,q指向s的双亲35。
4.将p节点值替换为s节点值37。
5.判断p和q指向不同,将s->lchild赋给q->rchild,否则将s->lchild赋给q->lchild。
6.删除节点s。
性能分析
二叉排序树采用链式存储结构,继承了链式存储结构便于插入和删除的优点,所以通常用于在查找时进行插入和删除操作,我们称为动态查找表。对于查找操作,最好的情况是遍历1次,即根节点就是查找节点。最坏是遍历树的深度,所以二叉排序树的查找性能取决于树的结构,但二叉排序树的结构是不确定的。例如集合{62,88,58,47,35,73,51,99,37,93},其二叉排序树的结构如下图所示。
如果集合是升序的如{35,37,47,51,58,62,73,88,93,99},其结构如下图。
在这两个图中查找99,一个需要两次,一个需要10次,可知如果二叉排序树是平衡的查找的时间复杂度为O(logn),近于二分查找,最坏情况为O(n)。所以我们构建二叉排序树时最好把它构建成平衡的二叉排序树。我们将在后续文章中介绍平衡二叉树。