查找的过程,简而言之就是一个查表的过程,这这里说的表是泛指,他可以是是小文件,也可以是一张大的表格,比如数据库文件。
查找的方式有很多,我们最早接触的是顺序查找、对半查找等,斐波那契查找这种方式可能比较少见。
本文介绍几种以二叉树为底层数据的查找方式,如二叉查找树,平衡树等几种。
1. 二叉查找树
顾名思义,二叉查找树就是一种二叉树,形式,构造等属性相对于二叉树来说并么有什么变化,只是增加了一项规则——对于二叉查找树中任意结点P,其上所存储的关键字大于左子树上的所有结点所存储的关键字,其右子树上的所有结点存储的关键字都大于P上所存储的关键字。
本文所述的二叉查找树是以链接结构实现的,并且做出如下约定:
key(p):存放结点p的关键字信息。
llink(p):存放结点p的左子树指针。
rlink(p):存放结点p的右子树指针。
知道了定义,但是如何构建一个二叉查找树,这就是我们所需要思考的第一个问题:
一个无序的序列可以通过构造一棵二叉查找树而成为有序序列,这被称为二叉查找树的顺序属性。
而二叉查找树会因为输入文件所包含记录对应的关键词序列不同而有不同的二叉树形态。准确的说,一个包含N个记录的集合,其对应的关键词有N!中不同排列,可以构成C(2N, N) / (N+1)种不同的二叉查找树。所以我们可以知道,上述二叉查找树的构造过程只是其中一种。
既然这种二叉树被称为二叉查找树,那么我们构建好了一个二叉查找树又该怎么去查找某一个元素呢?假设我们现在在上述已经构造成功的二叉查找树中寻找一个关键字为21的记录:
- 21 > 13,应该在以元素值为13的结点的右子树中查找。
- 21 < 25,应该在以元素值为25的结点的左子树中查找。
- 21 < 23,应该在以元素值为23的结点的左子树中查找。
- 21 > 19,应该在以元素值为19的结点的右子树中查找。
- 元素值为19的结点无右子树,查找失败,数组中不存在该元素。
以上是查找元素值为21的过程,查找一个已经存在于该数组中的元素就交给读者来独自验证了。
构造和查找只是二叉查找树的两种基本操作,一个二叉查找树,首先是树,其次是二叉树,然后才是二叉查找树,所以我们可以知道,二叉查找树也应该具有和二叉树相同的基本操作——插入和删除。但由于二叉查找树元素的存储是有自身的规则,所以我们对于二叉查找树的插入和删除实现就略有些麻烦了。
先说插入一个元素K:
- 插入前先进行查找,如果在查找树中找到该元素,则结束插入;
- 否则,在查找不成功的最后一个位置处插入该结点;
- if (K > key(p) && rlink(p) != NULL) 在rlink(p)处插入该元素。
- if (K < key(p) && llink(p) != NULL) 在llink(p)处插入该元素。
其次是删除一个元素K:
我们分两种不同的情况进行讨论,第一种是rlink(p)=NULL。
- 删除前先进行查找,如果未能在查找树中找到该元素,则结束删除;
- 否则,在查找成功的情况下,进行如下判断;
-
if (rlink(p) == NULL && llink(p) != NULL)
则将左子树上移,替换原结点,即将指向被删除结点的指针修改为指向被删除结点的左子树。 -
if (rlink(p) == NULL && llink(p) == NULL)
则创建一个空的外结点,替换原结点。
-
第二种则是rlink(p) != NULL && rlink(llink(p)) == NULL。
- 删除前进行查找,如果未能在查找树中找到该元素,则结束删除;
- 否则,在查找成功的情况下,进行如下判断;
-
if (rlink(p) !== NULL && llink(rlink((p)) == NULL)
则将rlink(p)指向的子树上移,替换原结点。 -
if (rlink(p) == NULL && llink(rliink(p)) != NULL)
则不断的取llink(p),直到找到一个结点r使得llink(r)=NULL,即while (llink(p) != NULL) { p = llink(p); r = p; }
然后用结点r替换结点p,结点r的右子树上升成为结点s的左子树(结点s满足llink(s) = r
条件)。
-
二叉查找树的删除操作在文末给出,仅做参考。
2. 最优二叉查找树
一般的二叉查找树操作的平均时间代价为2的对数阶O(logN),如果二叉树退化(左右子树不均匀)那么就会出现最坏的情况,时间代价为线性阶。
仔细想想,一般情况下,我们数据的访问频率是不同的,就像计算机中各个指令的使用频率也有不同,从CISC指令集中删去部分不常用的指令之后就形成了RISC指令集,并不影响用户的使用。
这里所举CISC的例子可能有不妥,但是可以作为一个对比参考。
对数据访问频率的不同意味着,有些数据可能在整个程序的生命周期内都访问不到,有些访问的频率会达到100%(当然,这种情况过于极端),那么我们如果将访问频率达到100%的数据放在靠近根结点的位置,那么对于这些数据的查找耗时就大大降低,从而达到增强整个程序的效果。如此一来,我们便可以想到根据数据访问频率来构造一棵最优二叉查找树。
如何构造这种查找树变成了问题,根据数学中的包含关系,我们可以知道,一棵最优树的所有子树都是最优的,那么根据这一个性质,我们可以想到,构造一个最优树可以从最优子树开始,一步一步的构造,可以描述为,系统的寻找越来越大的最优子树,转化为动态规划问题。
如何实现,脑子里第一反应应该是为每一个元素结点域增加一个计数器用来记录访问次数,计数器值越大,说明该元素的访问频率高,摆放位置距离根结点越近,这样想是觉得没有什么问题,因为我们是基于历史记录进行构造。但是实现时,我们会发现要做到这几点:
- 按照计数器值的大小进行排序再录入。
- 程序运行期间计数器值变化,不能让二叉树结构也跟着一起变化。
- 本次程序运行时产生的数据计数值成为下一次程序运行时的参考。
如果需要实现这种方式,我们需要对数据进行一次排序,然后再构造二叉树,每次程序的运行结果需要保存到本地。不难看出,我们排序、构造以及数据的保存时间代价非常高,如果数据非常多的时候,那么这种方式就会影响整个程序的性能。
但是如果设置的更新频率很低,比如一个月进行一次数据的更新,或是设置成程序打开多少次之后再进行更新,这种方式倒也不是不可以接受。
3. 平衡树
对于一个二叉查找树来说,如果数据的输入是随机的,那么我们可以得到一个比较好的查找树,但是我们向其中插入多个新元素,这个二叉树依旧可能会出现退化导致查找的时间代价变为线性阶。
在前文中,我们说过,一个二叉树如果不退化(左右子树的分布较为均匀),那么这个二叉查找树的效率就会比较高。所以我们想到了保持其左右子树的均匀分布来达到不使二叉查找树退化的目的——亦即保持树的平衡。
这时候我们可以把树视为一个天平,插入操作就相当于向天平两端加砝码,删除操作即为拿掉砝码。
对半查找以及斐波那契查找树的判定树就是高度平衡树,平衡树本质上依旧是一棵二叉查找树,所以它具有二叉查找树的所有性质。
平衡树又细分为高度平衡树以及重量平衡树,其中高度平衡树是由单一外结点或是由两个子树T1和T2组成,并且满足如下两个条件:
- |h(T1) - h(T2)|<=1,其中h(T)表示树T的高度。
- T1和T2都是高度平衡树。
平衡树的查找路径绝对不会超过最优二叉树查找路径长度的45%,由Adelson-Velsky和Landis定理可知,一棵具有N个内结点的平衡树T,其高度h (T)由下式所限定log 2 (N +1) <= h(T) <= 1.4404 x log 2 (N +2) - 0.3277。
在知道了高度平衡树的性质之后,我们需要在进行插入、删除操作之后依旧能够使其保持平衡,我们在这里主要讨论高度平衡树在平衡被破坏之后如何调整以保持平衡:
-
LL型(R旋转): 新结点P 插到A 的左子树的左子树上
R旋转
-
RR型(L旋转): 新结点P 插入到A 的右子树的右子树上
L旋转
-
LR型(LR旋转): 结点P 插入到A 的左子树的右子树上
LR旋转
-
RL型(RL旋转): 结点P 插入到A 的右子树的左子树上
RL旋转
以上四种情况的部分图示参考自刘大有版《数据结构》。
4. 二叉查找树的部分函数实现
本节仅做参考,可以略去。
//二叉查找树的插入与查找
BSTNode *SearchAndInsert(int k)
{
if (root == NULL) //root表示根结点
{
//如果树为空,则直接插入结点,返回空
root = new BSTNode(k, NULL, NULL); //结点的创建,key=k,llink=rlink=NULL
return NULL;
}
BSTNode *p = root;
while (p != NULL)
{
if (k == p->key) return p;
if (k < p->key)
{
if (p->llink == NULL) break;
else p = p->llink;
}
else
{
if (p->rlink == NULL) break;
else p = p->rlink;
}
}
//到此处位置,查找不成功,将包含关键词k的新结点插入树中
BSTNode *q = new BSTNode(k, NULL, NULL);
if (k < q->key) p->llink = q;
else p->rlink = q;
return NULL;
}
//二叉查找树中删除指针q指向的结点
void Delete(BSTNode *q)
{
if (q == NULL) return;
BSTNode *t = q;
if (t->rlink == NULL) t = t->llink; //q的位置由他的左孩子llink(q)取代
else
{
BSTNode *r = t->rlink;
if (r->llink == NULL)
{
r->llink = q->llink;
t = r;
}
else
{
BSTNode *s = r->llink;
while (s->llink != NULL)
{
r = s;
s = r->llink;
}
s->llink = t->llink;
r->llink = s->rlink;
s->rlink = t->rlink;
t = s;
}
}
if (q == root) root = t; //把以t为根的子树嫁接到树中,同时释放结点q
else
{
BSTNode *f = Father(root, q); //寻找q的父结点
if (f->llink == q) f->llink = t;
else f->rlink = t;
}
delete q;
}
5. 总结
本文主要讲了二叉查找树的主要结构,以及如何构造一棵二叉查找树,并且根据实际情况进行最优二叉树的构造。
之前说过,二叉树的应用非常广,本文所说的二叉查找树以及平衡树都只是其中一种应用,至于红黑树,B树,B树及其变形树,这些用途比较广的都已经被很多人讲过了,我就不去凑那个热闹了。
还写什么程序,一起种树啊!
网友评论
学的时候,记的是递归查找
但是递归的层次多了会影响时间和空间效率。