二叉排序树
查找表分为静态查找表和动态查找表,其中动态查找表的含义就是表结构本身是在查找过程中动态生成的。即若表中存在其关键字等于给定值 k e y key key的记录,表明查找成功,否则插入关键字等于 k e y key key的记录。
而动态查找表中最为典型的就是二叉排序树和哈希表,接下来重点对于二叉排序树的内容进行学习。
二叉排序树-定义
空树或者是具有如下特性的二叉树被称为二叉排序树:
- 若它的左子树不空,则左子树上所有结点的值均小于根节点的值。
- 若它的右子树不空,则右子树上所有结点的值均大于根结点的值。
- 它的左右子树也都分别是二叉排序树。
接下来通过两个例子对于二叉排序树进行进一步的认识。
二叉排序树-查找
那么在有了二叉排序树的基本定义之后,前面说到二叉排序树也是一个动态查找表,那么在二叉排序树中应该如何进行查找。
根据二叉排序树的规则,因为对于每个结点而言,左子树中的所有结点的值均小于当前结点的值,右子树中所有结点的值均大于当前结点的值。故在对于某个值进行查找的时候,如果该值等于当前结点的值,说明找到了对应的值,如果大于当前结点的值,那么就应该去当前结点的右子树中去查找,如果小于当前结点的值,那么就应该去当前结点的左子树中去查找。
总结上述查找方法,如下所示:
- 若查找值与根节点值相等,则代表查找成功。
- 若查找值小于根节点的值,则查找左子树。
- 若查找值大于根节点的值,则查找右子树。
以下列三个查找为例,展示二叉排序树中的查找过程。
接下来就是代码实现部分,首先是补充结点结构的定义。
// 结点
typedef struct Node
{
int data; // 数据域
Node* lchild; // 左孩子
Node* rchild; // 右孩子
// 构造函数初始化
Node()
{
data = -1;
lchild = NULL;
rchild = NULL;
}
}Node;
查找部分应该是在树结构中实现,故树结构的定义和查找代码如下所示。
// 二叉排序树
typedef struct Tree
{
Node* root; // 根节点
int info[100]; // 数据数组
int sum; // 结点个数
// 构造函数
Tree()
{
root = NULL;
for (int i = 0; i < 100; i++)
info[i] = -1;
sum = 0;
}
// 查找数据,返回结点指针
Node* find_node(int ele)
{
Node* t = root; // 从根开始找
int count = 1; // 同时记录查找次数
while (true)
{
// 如果找到了元素 或者 不能继续往下查找, 则不需要再找了
if (t == NULL || t->data == ele)
break;
// 如果当前结点的值大于要查找的值,说明该值可能在左子树中
else if (t->data > ele)
{
t = t->lchild;
count++;
}
// 如果当前结点的值小于要查找的值,说明该值可能在右子树中
else
{
t = t->rchild;
count++;
}
}
// cout << count << endl;
return t; // 返回查询结果(查询失败返回NULL,查询成功返回对应结点的指针)
}
}
二叉排序树-插入
前面提到二叉排序树是可以在查找失败时插入新元素的,那么如果在二叉排序树中进行插入。
首先在二叉排序树中插入一个结点之后,整个树依旧保持二叉排序树的规则。故插入结点的位置是需要根据该结点的值来确定的,而不是随意插入的。
根据二叉排序树的定义,对于一个给定的结点,其可以插入的位置有很多,可以是叶子结点,也可以是两个结点中间,那么在二叉排序树中有这样一个规定,新插入的结点一定是叶子结点,因为这样方便插入,即插入的时候只需要动一个指针,出错的机率较小。
在规定了插入的结点只能是叶子结点之后,插入就变的很简单了,过程类似于查找,首先还是需要根据待插入的值和根节点的值进行比较,如果大于根节点的值就需要再去右子树中找位置,如果小于根节点的值需要再去右子树中去找位置。那么什么时候才算是找到了对应的位置呢,当待插入的值大于根结点的值,且根节点的右子树为空时,则将待插入的结点当作右子树插入;当待插入的值小于根节点的值,且根节点的左子树为空时,则将待插入的点当作左子树插入。
综合上述原理,其实待插入叶子结点是查找不成功时路径上访问的最后一个结点左孩子或右孩子(新结点值小于或大于该结点值) 。 接下来通过一个例子进行理解。
接下来对于代码实现,其实也很简单,但是需要考虑到几个点,首先就是查找这个点,如果这个点不存在,才执行插入;其次就是如果原本的树就是空的话,则不需要进行数值比较,直接当作根节点插入即可。其余的的情况按照上述过程进行实现即可。
// 插入数据
void insert_node(int ele)
{
// 首先查看该结点是否已存在
Node* t = find_node(ele);
if (t) // 如果已经存在了就直接返回,不执行插入
return;
// 判断该树是否为空,若为空直接插入到根即可
if (root == NULL)
{
root = new Node();
root->data = ele;
root->lchild = NULL;
root->rchild = NULL;
return;
}
// 如果树不为空则从根节点开始往下找
Node* p = root;
while (true)
{
// 如果当前结点的值大于要查找的值,说明应该插入到该节点的左子树中
if (p->data > ele)
{
// 如果左孩子为空,则可以直接添加
if (p->lchild == NULL)
{
Node* s = new Node();
s->data = ele;
s->lchild = NULL;
s->rchild = NULL;
p->lchild = s;
break;
}
// 左孩子不为空则往左走
else
{
p = p->lchild;
}
}
else // 如果结点的值小于要查找的值,说明要插入到该节点的右子树中
{
// 如果当前结点右孩子为空,则直接插入
if (p->rchild == NULL)
{
Node* s = new Node();
s->data = ele;
s->lchild = NULL;
s->rchild = NULL;
p->rchild = s;
break;
}
else // 如果右孩子不为空,则继续往右走
{
p = p->rchild;
}
}
}
}
二叉排序树-建立
一般情况下需要根据一些初始数据先建立一棵二叉排序树,之后才会进行查找,那么在建立该二叉排序树的时候,其实就相当于对于每个数据调用一次插入函数即可。
接下来对初始化过程进行一个展示,如下所示。
代码实现较为简单,直接调用前面写好的插入函数即可。
// 初始化二叉排序树
void init_tree(int num[], int n)
{
// 初始化一些参数
sum = n;
for (int i = 0; i < n; i++)
info[i] = num[i];
// 逐个插入
for (int i = 0; i < n; i++)
{
insert_node(info[i]);
}
}
二叉排序树-遍历
根据二叉排序树的定义可知,当使用中序遍历来遍历二叉排序树时,得到的序列就是一个有序的序列,同时也可以借助中序遍历来判断该树是否是一棵二叉排序树,以及插入删除等是否正确执行。
代码实现可以参考前面二叉树中的中序遍历。
// 输出二叉排序树内容,中序遍历为顺序输出
void inOrderMethod(Node* t)
{
if (t)
{
inOrderMethod(t->lchild);
cout << t->data << endl;
inOrderMethod(t->rchild);
}
}
// 中序遍历入口
void inOrder()
{
inOrderMethod(root);
}
二叉排序树-删除
二叉排序树中也包含删除操作,删除和插入操作的原则相同,即删除某个结点之后的树仍然要保持二叉排序树的性质。换句话来说就是保持中序遍历输出的序列仍然是有序序列。
被删除的结点具有以下三种情况:
- 叶子结点
- 只有左子树(右子树)
- 同时有左右子树
首先对于被删除结点是叶子结点的情况进行分析,这种情况最为简单,只需要将其父节点指向该节点的 指针变为空即可。以下图为例。
对于被删除的结点只有左子树或右子树的情况,这种情况也很简单,在删除该结点的时候,让其父节点指向该节点的指针指向其左子树(或右子树),即用孩子结点来代替被删除的结点即可。通过下列两个例子来进行理解。
对于被删除的结点既有左子树又有右子树的情况,这种情况较为复杂,这里假设被删除结点为 p p p,那么在删除这种结点时,以中序遍历时的直接前驱 s s s代替被删除结点 p p p,然后再删除 s s s即可。
对于上述删除方法,该方法其实就是采用了一个代替的方法,即执行最少的操作。如果直接删除
p
p
p,为了继续保持二叉排序树的性质,需要在
p
p
p左右子树中找到一个大于左子树中所有结点的,且小于右子树中所有结点的值,这个值有两个选择,第一个选择就是左子树中最大的元素,第二个选择是右子树中最小的元素。这里以左子树中最大的元素为例,该元素应该位于
p
p
p的左子树中,“最右边”的元素,即从
p
p
p左拐一次之后,需要一直右拐,直到无法右拐为止。在找到该结点
s
s
s之后,用
s
s
s的值来更新结点
p
p
p的值,之后需要删除结点
s
s
s,可以知道的是,
s
s
s一定只可能有左子树,故接下来删除
s
s
s按照前面的方法来进行删除即可。
上述即为删除结点的三种情况的全部总结,总结的均为一些基本情况,在代码实现中会有一些特殊情况进行处理,之后会更细致的分析。
接下来来到代码实现部分,前面一些方法中需要找到某个结点的双亲结点,可以通过改进前面的查找函数来进行实现,实现起来较为简单,如下所示。
// 查找父母节点
Node* find_parent(int ele)
{
Node* t = root; // 从根开始找
Node* par = NULL; // 父节点,初始为空
while (true)
{
// 如果找到了元素 或者 不能继续往下查找, 则不需要再找了
if (t == NULL || t->data == ele)
break;
// 如果当前结点的值大于要查找的值,说明该值可能在左子树中
else if (t->data > ele)
{
par = t; // 更新父节点
t = t->lchild;
}
// 如果当前结点的值小于要查找的值,说明该值可能在右子树中
else
{
par = t; // 更新父节点
t = t->rchild;
}
}
return par;
}
之后就是删除函数的主体部分,首先需要进行查找,如果都找不到待删除的结点,那么就没有删除的意义。如果该节点存在,则先找到该结点的双亲,这里将删除没有子树的情况和删除只有一个子树的情况进行了合并,因为在代码实现时确实可以合并在一起进行实现。需要注意的是,需要额外判断删除的结点是否是根节点,如果是根节点的话,其双亲结点是空,此时不能简单的将双亲的孩子设置为被删除结点的孩子,因为双亲根本不存在,故这种情况需要特殊处理。
最复杂的应该就是左右子树都存在的情况,这种情况下,首先左拐,然后不停的右拐找到结点 s s s,但是这里也有一种情况,那就是左拐之后的结点没有右子树,这种情况下,左子树中最大的结点就是 p p p的左孩子,即 s s s就是 p p p的左孩子,那么只需要将 s s s的左孩子接给 p p p即可。对于一般的情况,找到结点 s s s的同时,也找到结点 s s s的双亲结点,然后按照之前对于一般情况的分析进行实现即可。
// 删除结点(删除成功返回true,删除失败返回false)
bool delete_node(int ele)
{
// 先查找结点,
Node* node = find_node(ele);
// 结点不存在则无法删除
if (node == NULL)
return false;
// 找到该结点的双亲结点
Node* par = find_parent(ele);
// 左子树为空,直接将右子树接到双亲结点上即可
if (node->lchild == NULL)
{
// 被删除的结点不是根节点
if (par != NULL)
{
// 被删除结点是双亲结点的左孩子
if (par->lchild->data == ele)
{
par->lchild = node->rchild;
delete node;
}
// 被删除结点是双亲结点的右孩子
else if (par->rchild->data == ele)
{
par->rchild = node->rchild;
delete node;
}
}
else // 被删除的是根节点,则孩子结点作为新的根节点
{
Node* p = root;
root = root->rchild;
delete p;
}
}
// 右子树为空,直接将左子树接到双亲节点上即可
else if (node->rchild == NULL)
{
// 被删除的结点不是根节点
if (par != NULL)
{
// 被删除结点是双亲结点的左孩子
if (par->lchild->data == ele)
{
par->lchild = node->lchild;
delete node;
}
// 被删除结点是双亲结点的右孩子
else if (par->rchild->data == ele)
{
par->rchild = node->lchild;
delete node;
}
}
else // 被删除的是根节点,则孩子结点作为新的根节点
{
Node* p = root;
root = root->lchild;
delete p;
}
}
else // 左右子树均不为空
{
// 首先左拐
Node* t = node->lchild;
// 如果右子树为空,则直接将该节点的值与待删除结点的值互换,并重接子树,最后删除
if (t->rchild == NULL)
{
node->data = t->data;
node->lchild = t->lchild;
delete t;
return true;
}
// 右子树不为空,则一直往后遍历
Node* par_t = node;
while (t->rchild)
{
t = t->rchild;
par_t = par_t->rchild;
}
// 将最右边的结点值与待删除结点值互换
node->data = t->data;
// 重接子树
par_t->rchild = t->lchild;
// 删除最右边结点
delete t;
}
return true;
}
二叉排序树-性能分析
对于每一棵特定的二叉排序树,均可按照平均查找长度的定义来求它的
A
S
L
ASL
ASL 值,显然,由值相同的
n
n
n 个关键字,构造所得的不同形态的各棵二叉排序树的平均查找长 度的值不同,甚至可能差别很大。
在最好的情况下,二叉排序树为一近似完全二叉树时,其查找深度为量级
log
2
n
\log _{2}^{n}
log2n,即其时间复杂性为
O
(
log
2
n
)
O(\log _{2}^{n})
O(log2n)。
在最坏的情况下,二叉排序树为近似线性表时(如以升序或降序输入结点时),其查找深度为
n
n
n量级,即其时间复杂性为
O
(
n
)
O(n)
O(n)。
结合之前二叉排序树的一些内容,总结二叉排序树的一些特性:
- 一个无序序列可以通过构造一棵二叉排序树而变成一个有序序列(通过中序遍历)
- 插入新记录时,只需改变一个结点的指针,相当于在有序序列中插入一个记录而不需要移动其它记录
- 二叉排序树既拥有类似于折半查找的特性,又采用了链表作存储结构
- 但当插入记录的次序不当时(如升序或降序),则二叉排序树深度很深,增加了查找的时间