引言
树是一个很重要的数据结构,它的作用不仅仅是在于存储文件路径方面。在存储其他数据方面,它可以把很多问题的时间复杂度降低,变成O(logn),但是如果仅仅是普通的建立树,是达不到这种效果的,因为数据和数据之间并没有任何联系,我们查找数据还是需要遍历所有数据,时间复杂度还是O(n),所以为了解决这个问题,我们推出了平衡二叉树,B树,红黑树等等,而这些数据结构我们在之后的文章中会单独讲解。
我们这一次要讲的是这所有数据结构的基础,二叉排序树。
这是一个有很大缺点的数据结构,但是这也是后面数据结构的基础,所以我会把递归和非递归的所有函数全部实现一遍,希望可以对大家有所帮助。
结构
二叉排序树的结构其实很简单,它的遍历选择的是中序遍历,所以对于数据来说,比根节点大的数据放在右边,比根节点小的数据放在左边。之后我们要查找数据就可以减少一半的时间,每次通过比较与结点的大小,可以完全锁定是在这个结点的左边还是在这个节点的右边。所以它的结构体很简单:
cpp
struct BSTNode {
int data;
BSTNode* left;
BSTNode* right;
};
代码
cpp
// 创建新节点
BSTNode* CreateNode(int value) {
BSTNode* s = new BSTNode();
if(s == nullptr) {
return nullptr;
}
s->data = value;
s->left = s->right = nullptr;
return s;
}
我们先实现一下查找函数,假设我们已经有了一棵二叉排序树
先是递归函数的实现,我们递归的出口放在了最前面,也就是处理根节点的操作
cpp
// 在以root为根节点的树中找到value所在的结点,并返回(递归)
BSTNode* Find1(BSTNode* root, int value){
if(root == nullptr || root->data == value) {
return root;
}
if(value < root->data) {
return Find1(root->left, value);
} else {
return Find1(root->right, value);
}
}
然后是非递归的函数
这个函数就是移动指针,只要这个指针不是空,并且没找到这个数据,就通过比较与结点的大小,确定寻找的方向。即使没有找到,那么最后p的值也是nullptr,返回的也正是nullptr
cpp
// 非递归(效率其实是一样的)
BSTNode* Find2(BSTNode* root, int value){
BSTNode* p = root;
while(p != nullptr && p->data != value) {
if(value < p->data) {
p = p->left;
} else {
p = p->right;
}
}
return p;
}
然后是插入函数(递归)
对于二叉排序树(不是平衡二叉树),其实插入函数我们没必要想太多,大家可以自己画一个图,我们可以保证每一个插入的数据都是在叶子结点上,也就是说我们根本没有必要对于节点的移动,我们的操作就是根据二叉排序树的性质,一路找,直到找到nullptr,然后创建这个要插入的结点,然后return,这个时候根据递归函数的性质,我们就会不断的返回这个带有这个新结点的新树(可能大家会对这个过程感到疑惑,如果大家对于底层有一定的了解的话,一定知道函数是在栈中的,而栈的特点大家也都知道,所以为什么会有这种回溯的机制,也不必多说了吧~)
cpp
// 在以root为根的树中插入数据value(递归)
BSTNode* Insert1(BSTNode* root, int value) {
if(root == nullptr) {
return CreateNode(value);
} else {
if(value < root->data) {
root->left = Insert1(root->left, value);
} else {
root->right = Insert1(root->right, value);
}
}
return root;
}
对于非递归实现的插入操作,我们就需要双指针了,因为我们需要知道插入结点的父亲是谁,这个过程需要我们手动自己实现,而不是靠程序内部的机制来实现。思路大概都差不多,只是最后一步的时候,p为空的时候,pre就是p要插入的父节点,这个时候比较一下value和pre->data的大小,来确定value应该插入到这个结点的左边还是右边。
cpp
// 在以root为根的树中插入数据value(非递归)
/*
双指针,先查找,因为插入的数据肯定不在树中,所以查找结束时,pre指向的是插入位置的父一个结点,p指向的是插入位置
*/
BSTNode* Insert2(BSTNode* root, int value) {
// 特殊情况,树为空
if(root == nullptr) {
return CreateNode(value);
}
BSTNode* p = root;
BSTNode* pre = nullptr; // 查找过程中记录p的父亲
while(p != nullptr) {
pre = p;
if(value < p->data) {
p = p->left;
} else {
p = p->right;
}
}
// 循环结束时,p为空,pre刚好指向x的父亲,判断插入的结点是在左边还是在右边
if(value < pre->data) {
pre->left = CreateNode(value);
} else {
pre->right = CreateNode(value);
}
return root;
}
然后就是我们这一部分最重要的操作了:删除!!!!
为了方便大家理解,我们选择先写递归的方法
思路如下:
我们要删除的结点有三种情况:
0个孩子 1个孩子 2个孩子
第一种情况:我们的操作十分easy,拿到一个指针指向这个结点,然后delete,然后把指针指向nullptr,是不是很easy~~
第二种情况:这个就稍微复杂那么一点点,因为有一个孩子,所以我们需要在删除这个结点之前,把孩子交给这个结点的父亲,所以我们需要判断一下这个孩子是左孩子还是右孩子。
所以,我们需要两个指针,一个指针pre指向父亲,一个指针指向删除的结点p,最后把pre指向p的指针指向p的孩子。
说到这里,想必聪明的各位已经发现,其实情况二和情况一可以合并,因为情况一可以看成有一个孩子,但是这个孩子是nullptr罢了,嘿嘿嘿~
第三种情况:这个就比较复杂了,它就是它,到底是连左边还是右边呢?
因为我们是中序遍历,所以我们有一个很大的优势,就是一个结点的前驱和后继很好找。至于怎么找,我们在写文章-CSDN创作中心里面已经介绍了。找前驱和找后继的意义是什么呢?
1 2 3 4 5 6 7 8 9 如果按中序遍历输出一个二叉排序树,就是这个样子。所以当我们删除其中一个结点,按照数组来理解就是要么这个数的前面全部往后面移动一个,要么这个数的后面全部往前面移动一个。所以我们只需要找到这个数的前驱或者后继,然后把这个前驱或者后继的数据直接覆盖在我们要删除的数据上(这里不要移动指针,因为这样会把问题变得复杂),然后把这个前驱或者后继删除就可以。
这个其实就像把要删除的数据改成了它的前驱或后继,然后把前驱或后继删除。
那么我们又可以想一下,一个数可以作为别人的前驱和后继,那么这个数必然只有一个孩子或者没有孩子。呦呦呦~~
那么问题不又转化成了前面的情况了嘛~~所以代码也就出来了
先用双指针找到要删除的结点,然后判断一下这个结点的度,如果只有0或1个孩子,好说,直接利用pre和child指针,改变结构,然后删除p,如果有两个孩子,找到前驱,然后把前驱标记成p,这样问题直接转为上一个情况,pre标记为这个结点的父亲,就不用单独再写一个函数了
cpp
/*
删除结点(非递归)
p的度是0或者1,都看成1,让唯一的孩子继承p的一切关系
声明一个孩子指针,如果p的左孩子是非空就指向左,否则就指向右孩子
p的度是2,让前去或者后继结点t继承p的一切关系
先找到t,然后删除t,而删除t的问题就变成了删除度为0或者1的结点
*/
BSTNode* Delete1(BSTNode* root, int value) {
if(root == nullptr) {
return nullptr;
}
// 先找到value所在的结点p,以及p的父亲pre
BSTNode* p = root;
BSTNode* pre = nullptr;
BSTNode* child = nullptr;
while(p != nullptr &&p->data != value) {
pre = p;
if(value < p->data) {
p = p->left;
} else {
p = p->right;
}
}
if(p == nullptr) {
std::cout << "删除失败,结点不存在" << std::endl;
return root;
}
// 如果p的度是2,进行一个转化
if(p->left != nullptr && p->right != nullptr) {
// 找到p的前驱结点t
BSTNode* t = p->left;
BSTNode* tf = p;
while(t->right != nullptr){
tf = t;
t = t->right;
}
p->data = t->data; // 把前驱节点复制过来
// 问题转化为删除t,仍然用p指向被删除的结点,pre指向被删除的结点的父亲
p = t;
pre = tf;
}
// 此时被删除的结点p的度一定是1或者0
if(p->left != nullptr) {
child = p->left;
} else {
child = p->right;
}
if(pre->left == p) {
pre->left = child;
} else {
pre->right = child;
}
delete p;
p = nullptr;
return root;
}
递归
递归的精髓一直都是把最后的结果不断地向上传递,所以我们可以理解成我们不断地去删除一颗树,这颗树越来越小,直到最后找到了这个结点root,然后对root进行情况1和2的判断,把情况2转化为情况1,也就是else分支进行处理
cpp
// 删除结点(递归)
BSTNode* Delete2(BSTNode* root, int value) {
if(root == nullptr) {
std::cout << "删除失败,树为空" << std::endl;
return nullptr;
}
if(value < root->data){
root->left = Delete2(root->left, value);
} else if(value > root->data) {
root->right = Delete2(root->right, value);
} else {
// 此时root就是我们要删除的结点
if(root->left != nullptr && root->right != nullptr) {
// 找到root的后继结点
BSTNode* t = root->right;
while(t->left != nullptr) {
t = t->left;
}
root->data = t->data; // 把后继节点复制过来
root->right = Delete2(root->right, t->data);
} else {
// 如果root的度是0或者1,直接删除
BSTNode* p = root;
if(root->left != nullptr) {
root = root->left;
} else {
root = root->right;
}
delete p;
p = nullptr;
}
}
return root;
}
总结
二叉排序树其实并不完美,因为如果一颗树只有左子树,也就是有n个结点,高度也是n,那么时间复杂度并没有减少,所以才有了后面的平衡二叉树,把这个树变得又矮又胖(当然现实里面我们还是比较希望自己。。。嗯)。但是作为基石,我们很有必要掌握它。
感谢大家阅读!!!希望这篇文章可以帮助到大家理解平衡二叉树~~~~