文章目录
- 701.二叉搜索树中的插入操作
- 思路
- 递归法
- 如何保证连接的节点就是空节点的父节点?
- 迭代法
- 迭代法注意
- debug测试
- 450.删除二叉搜索树中的节点(坑较多,注意复盘)
- 思路
- 最开始的写法
- debug测试
- 1.使用了释放后的空间ERROR: AddressSanitizer: heap-use-after-free on address
- 2.if-else if-else的问题
- 3.c++释放内存的问题
- 二叉树的节点默认创建在堆上的问题
- 4.逻辑问题:要找的是右子树最左下角的节点,不仅仅是右子树的左节点
- 5.奇怪的递归错误
- 修改后的完整版
- 连接的问题
- 普通二叉树的版本
- 普通二叉树与BST的删除操作,时间复杂度区别
701.二叉搜索树中的插入操作
- 本题要注意思路,首先,插入节点一定是放在叶子节点上;第二,插入节点是找到空节点之后建立新节点,再把这个新节点返回给上一层;第三,上一层的节点需要把新节点进行连接。
- 注意递归插入连接节点的时候的逻辑,不管连接节点是不是新建的节点,都需要把返回的节点和上一层连接。对于已经连接的节点,再次连接不会有问题;而对于未连接的新建节点,需要连接到上一层里。
给定二叉搜索树(BST)的根节点 root 和要插入树中的值 value ,将值插入二叉搜索树。 返回插入后二叉搜索树的根节点。 输入数据 保证 ,新值和原始二叉搜索树中的任意节点值都不同。
注意,可能存在多种有效的插入方式,只要树在插入后仍保持为二叉搜索树即可。 你可以返回 任意有效的结果 。
输入:root = [4,2,7,1,3], val = 5
输出:[4,2,7,1,3,5]
解释:另一个满足题目要求可以通过的树是:
示例 2:
输入:root = [40,20,60,10,30,50,70], val = 25
输出:[40,20,60,10,30,50,70,null,null,25]
示例 3:
输入:root = [4,2,7,1,3,null,null,null,null,null,null], val = 5
输出:[4,2,7,1,3,5]
思路
我们只需要按照二叉搜索树的规则去遍历,遇到空节点就插入节点就可以了。
注意,我们插入任意一个节点,其实都可以在叶子节点里找到对应位置!因为BST本身就是有序的,大于和小于都会体现在元素叶子节点里,所以插入的新元素一定是在叶子节点上!
画几个例子试一下就会发现这一点了。
新插入节点,在叶子节点插入就可以了。
递归法
- 当遇到空的时候,说明找到了插入节点的位置!
- 二叉树插入节点的方式是定义新节点,然后给节点赋值,再把新建立的节点向上一层返回!
- 向上一层return 在递归的过程中,节点7向左遍历遇到null,空节点return了一个新的节点,此刻7就接收到了这个节点!
class Solution {
public:
TreeNode* insertIntoBST(TreeNode* root, int val) {
//遇到空的时候,说明找到了插入的位置!递归终止
//只有遇到空的时候,才会有返回值
if(root==nullptr){
//建立新节点
TreeNode* node = new TreeNode(val);
//新建立的节点向上一层返回
return node;
}
//如果值比较小,插入左子树
if(root->val > val){
//接收下层返回的节点
TreeNode* left = insertIntoBST(root->left,val);
//把节点连接起来
root->left = left;
}
//值比较大,插入右子树
if(root->val < val){
TreeNode* right = insertIntoBST(root->right,val);
root->right = right;
}
//每一层都会返回,连接上层节点
return root;
}
};
如何保证连接的节点就是空节点的父节点?
函数 insertIntoBST
总是返回一个 TreeNode
指针。
当我们找到了一个空的节点(也就是找到了插入位置),会创建一个新的节点,并返回这个新节点。但是,在这之前,我们可能已经在二叉树中遍历了很多节点。这些节点在遍历过程中都是被返回的,因为最后return root。也就是说,当调用 insertIntoBST(root->left, val)
或 insertIntoBST(root->right, val)
时,如果子树 root->left
或 root->right
不是空的,那么返回的就是这个子树的根节点。这是因为我们没有在这个子树中插入新的节点,所以原来的子树没有发生改变。
也就是说,如果 insertIntoBST(root->left,val)
或 insertIntoBST(root->right,val)
返回了一个节点,那么这个节点要么是新插入的节点,要么就是原来的子树的根节点。在两种情况下,都需要把返回的节点连接到 root
节点上,因为这样可以保证树的结构。
- 这种逻辑能跑通的原因就是,即使root->left已经存在,把root->left和root重新连接也是没有问题的,即执行
root->left = left
,对于left本身就是root左孩子的情况,这也是不会有问题的。 - 如果
root->left
已经存在,那么insertIntoBST(root->left,val)
返回的就是root->left
,因为在这个子树中没有插入新的节点。因此,当执行root->left = left
时,只是把root->left
指向它原来就指向的节点,这是完全没有问题的。同样的道理也适用于root->right
。 - 二叉树的递归插入算法要确保所有的节点都被正确地连接。对于已经正确连接的节点,再次连接不会产生任何副作用。而对于新插入的节点,这个连接操作就会把新节点正确地连接到树中。
迭代法
- 本题的迭代法用的依然是两个指针pre和front的做法,一个指向当前一个指向当前的前一个,再进行连接
- 迭代法的下层逻辑里面,一定要避免出现root,因为迭代法的root和递归不一样,迭代的root指的就是单纯的根节点!
class Solution {
public:
TreeNode* insertIntoBST(TreeNode* root, int val) {
if(root==nullptr){
TreeNode* node = new TreeNode(val);
return node;
}
//需要定义一个指针存放当前节点的前一个节点
TreeNode* pre = root;
TreeNode* front = root;
while(pre!=nullptr){
front = pre;//存放变化之前的数值
if(val > pre->val){
pre = pre->right;
}
else{
//题目里说了新数值不等于任意一个值
pre = pre->left;
}
}
//当跳出while循环之后,说明遍历到空节点
TreeNode* node = new TreeNode(val);
if(front->val < val){
front->right = node;
}
else{
front->left = node;
}
//迭代法直接修改了二叉树,所以返回root就行
return root;
}
};
迭代法注意
//迭代法的逻辑里一定要尽量避免root,迭代法的root并不像递归一样指的是每一层的节点,迭代法的root指的就是根节点!
if(val > pre->val){
pre = pre->right;
}
这里一开始写成了pre = root->right
,导致运行超时。也就是一直在while循环里面走。因为迭代法里root->right是不变的。
迭代法里面需要写成pre = pre->right
,这里要特别注意不要和递归写混了。
debug测试
运行超时的错误就是死循环了,需要重点检查循环!
因为迭代法逻辑里面pre写成了root,导致预期输出出现了很奇怪的错误,就是少了一个null。
这种情况的报错光看用例输出是看不出来的,需要重新去看代码的逻辑是不是出了问题,比如迭代法的当前节点是不是想当然地写成递归的root了。
450.删除二叉搜索树中的节点(坑较多,注意复盘)
- 本题注意删除的方法,可参考链表删除操作,父节点直接指向其左/右孩子(左右孩子只有一个的情况)
- 本题需要注意的点很多,删除节点涉及到结构的大改,需要多复盘!
给定一个二叉搜索树的根节点 root 和一个值 key,删除二叉搜索树中的 key 对应的节点,并保证二叉搜索树的性质不变。返回二叉搜索树(有可能被更新)的根节点的引用。
一般来说,删除节点可分为两个步骤:
首先找到需要删除的节点;
如果找到了,删除它。
输入:root = [5,3,6,2,4,null,7], key = 3
输出:[5,4,6,2,null,null,7]
解释:给定需要删除的节点值是 3,所以我们首先找到 3 这个节点,然后删除它。
一个正确的答案是 [5,4,6,2,null,null,7], 如下图所示。
另一个正确答案是 [5,2,6,null,4,null,7]。
示例 2:
输入: root = [5,3,6,2,4,null,7], key = 0
输出: [5,3,6,2,4,null,7]
解释: 二叉树不包含值为 0 的节点
示例 3:
输入: root = [], key = 0
输出: []
思路
二叉搜索树添加节点的思路比较简单,因为添加节点不需要改二叉树的结构。
但是,删除节点,涉及到结构调整的问题。比如示例1,删除了节点3之后,需要处理节点3的两个左右孩子,把他们其中一个重新变成节点3原来的位置,并且保证还是BST。
分情况讨论
-
没找到要删除的key值,直接返回原来的root
-
要删除的节点是叶子节点,那么直接删除,不需要改结构
-
要删除的节点是左不为空右为空的节点,那么就把左子树的节点直接填补上来即可,也就是父节点直接指向左孩子。
-
要删除的节点是右不为空左为空的节点,那么把右子树的节点直接填补上来就行,也就是父节点直接指向右孩子
-
要删除的节点是左右都不为空的节点,这是最复杂的情况,此时要判断左右孩子的大小,以及哪个孩子需要来填补空缺的位置。
示例:假如我们要删除节点7:
7被删除后,7的左右孩子都可以继位,我们选择右孩子9来继位。我们也可以让右孩子的左孩子8来继位,但是右孩子直接继位简单一些,因为右孩子并不是一定都有左孩子,但是待删除节点运行到这一步了,一定有右孩子。
右孩子继位之后,左孩子放在哪里呢?
由于7的左子树全部都<7,所以我们需要选择一个大于7,但是不能大于继位的右孩子9的元素,也就是右孩子的左子树!我们可以把7的左子树移动到7的右孩子的左子树中。
此处注意,右子树的左孩子找到空的节点,我们就可以直接把7的左子树移动过来!
注意:为了避免覆盖的问题,必须找到右子树最左下角的左孩子,此时该左孩子必须是叶子节点,否则会发生覆盖
我们没有选择直接用右子树左孩子去继位,就是因为右子树左孩子可能不存在。但是右孩子继位的话,即使其左孩子不存在,也可以直接把左子树移动过来。
最开始的写法
- cpp删除节点需要手动释放内存
- 右子树的左孩子即使是空的也可以直接移过来
- 注意:树中找不到节点的情况,已经被包含在if(root==nullptr)return nullptr;的逻辑里面了!遍历的过程就是这棵树如果都不满足
- 并不是所有的BST都必须中序遍历,涉及到单调递增才需要,本题可以前序
//bool travelsal(TreeNode* root,int val){
//注意:不需要单独的遍历函数!
//}
TreeNode* deleteNode(TreeNode* root, int key){
//先找到节点,第一种情况是找不到,如果下面的情况都不满足,递归到最后就会返回空
if(root==nullptr){
return nullptr;
}
//此时是找到了
if(root->val==key){
//叶子节点,左右都为空
if(root->left==nullptr&&root->right==nullptr){
delete root;
return nullptr;
}
//左不为空右为空的节点
if(root->left!=nullptr&&root->right==nullptr){
delete root;
return root->left;//把左孩子返回给父节点进行连接
}
//左为空右不为空的节点
if(root->right!=nullptr&&root->left==nullptr){
delete root;
return root->right;//右孩子返回给父节点
}
//左右都不为空的节点
//如果右节点的左孩子存在,就让左子树移动到右节点左孩子下面
if(root->right->left){
root->right->left = root->left;
}
//右节点左孩子不存在,让左子树移动为右节点的左孩子
if(!root->right->left){
root->right->left = root->left;
}
//右节点继位
return root->right;
}
//遍历左侧和右侧
TreeNode* left = deleteNode(root->left, key);
TreeNode* right = deleteNode(root->right, key);
if(left){
root->left = left;
}
if(right){
root->right = right;
}
return root;
}
debug测试
1.使用了释放后的空间ERROR: AddressSanitizer: heap-use-after-free on address
内存错误"heap-use-after-free",这是因为在C++中,当使用delete
关键字释放对象的内存后,该对象仍然会保留指向已经被释放内存的指针。这个指针称为悬挂指针(Dangling Pointer)。如果我们试图访问已经被释放的内存,就会触发"heap-use-after-free"错误。
错误的写法中,我们检查了 root->left
和 root->right
是否为空,然后删除了 root
,但是试图返回 root->left
,这就使得 root
成为一个悬挂指针,因为它的内存已经被释放,但你仍然试图通过它访问内存。
在最开始的写法中,我们把root delete掉了,再调用root->right就会出现内存报错。这是一个非常常见的编程错误,我们需要确保不再使用任何你已经释放的内存。
错误写法:
if(root->left!=nullptr&&root->right==nullptr){
delete root;
return root->left;//把左孩子返回给父节点进行连接
}
修改:
- 删除之前保存需要返回的和删除节点相关的值
if(root->left!=nullptr&&root->right==nullptr){
//删除之前保存需要返回的和删除节点相关的值
TreeNode* node = root->left;
delete root;
return node;//把左孩子返回给父节点进行连接
}
“悬挂指针”(Dangling Pointer)是一种常见的编程错误,它发生在当一个指针指向的内存已经被释放或者已经超出范围时。在这种情况下,指针仍然存在,但它所指向的内存可能已经被操作系统重新分配给其他地方,或者根本就不能访问。如果试图通过悬挂指针访问这块内存,就可能会导致未定义的行为,比如程序崩溃或者数据损坏。
2.if-else if-else的问题
在删除节点后,继续使用了已删除的节点,这会导致不确定的行为。例如,首先删除 root
,然后尝试访问 root->left
或 root->right
。这在C++中是不允许的。所以这里最好用if-else if-else的结构才能避免操作空节点。
但是,我们的写法,全写if也是没有问题的,因为每一个if里面都有return语句,所以满足一个if的时候,直接return出去了,下面的都不执行了。在执行上,是和if-else if-else没有区别的。
3.c++释放内存的问题
delete
操作是在处理 C++ 中的动态内存管理。在 C++ 中,使用 new
来动态创建对象,相应的,当这个对象不再需要的时候,就需要使用 delete
来释放掉它占用的内存。否则,如果只是简单地丢弃了指向它的指针,那么这部分内存将无法再次使用,这就产生了内存泄漏。
本题中,当找到了需要删除的节点 root
时,会用一个新的节点 node
来取代它,并返回 node
,这个过程就是删除节点。但是,只是这样做的话,被删除的 root
节点实际上并没有被真正地删除,它仍然占用着内存。因此,需要使用 delete root;
来真正地释放 root
所占用的内存。
这样做可以确保程序不会因为无法释放内存而出现问题。在处理大量数据或长时间运行的程序中,内存管理尤为重要,因为如果内存泄漏累积到一定程度,会导致程序运行缓慢,甚至崩溃。
二叉树的节点默认创建在堆上的问题
我们默认它的输入是一个在堆上动态创建的二叉搜索树的节点。在C++中,二叉树的节点并不一定都在堆上。不过在实际应用中,通常会在堆上创建二叉树的节点,因为二叉树通常会包含大量的节点,如果全部创建在栈上,可能会导致栈溢出。而且,二叉树的节点数量在创建时可能无法确定,所以使用动态内存分配来创建节点会更加灵活。
4.逻辑问题:要找的是右子树最左下角的节点,不仅仅是右子树的左节点
错误代码:
- delete的操作必须提前把元素值存一下
class Solution {
public:
TreeNode* deleteNode(TreeNode* root, int key) {
//先找到节点,第一种情况是找不到,如果下面的情况都不满足,递归到最后就会返回空
if(root==nullptr){
return nullptr;
}
//此时是找到了,注意这里面必须用else if,因为会操作root,如果root已经被删除就没有意义
if(root->val==key){
//叶子节点,左右都为空
if(root->left==nullptr&&root->right==nullptr){
delete root;
return nullptr;
}
//左不为空右为空的节点
else if(root->left!=nullptr&&root->right==nullptr){
TreeNode* node = root->left;
delete root;
return node;//把左孩子返回给父节点进行连接
}
//左为空右不为空的节点
else if(root->right!=nullptr&&root->left==nullptr){
TreeNode* node = root->right;
delete root;
return node;//右孩子返回给父节点
}
//左右都不为空的节点
else{
//如果右节点的左孩子存在,就让左子树移动到右节点左孩子下面
if(root->right->left){
root->right->left = root->left;
}
//右节点左孩子不存在,让左子树移动为右节点的左孩子
if(!root->right->left){
root->right->left = root->left;
}
//右节点继位
return root->right;
}
}
//遍历左侧和右侧
TreeNode* left = deleteNode(root->left, key);
TreeNode* right = deleteNode(root->right, key);
//最开始超时报错是因为没有遍历下去
if(root->val > key){
root->left = left;
}
if(root->val < key){
root->right = right;
}
return root;
}
};
这里存在的问题是,我们要找的并不是右子树的左孩子,而是右子树最左下角的孩子,因为右子树可能有很多层,为了防止覆盖掉原有的元素,必须遍历到叶子节点才行!
也就是说,左右孩子都不为空的逻辑,应该改成:
//左右都不为空的节点
else{
//找到右子树最左下角的节点,一直找到空的位置!
TreeNode* cur = root->right;
while(cur->left!=nullptr){
cur = cur->left;
}
//当cur->left是空的时候,左子树移动到cur->left
cur->left = root->left;
//右节点继位
TreeNode* node = root->right;
delete root;
return node;
}
5.奇怪的递归错误
最开始的写法,在每次递归调用后,都更新了 root->left
和 root->right
,即使它们并没有发生改变。这将导致无限递归,因为总是在原地进行修改,而不是向下递归。
//遍历左侧和右侧,错误写法,没有递归下去
TreeNode* left = deleteNode(root->left, key);
TreeNode* right = deleteNode(root->right, key);
if(left){
root->left = left;
}
if(right){
root->right = right;
}
修改成如图
或者
这种递归写法很奇怪,错误也很奇怪,现在也没有搞懂为什么会内存报错,建议是一定要避免写这种奇怪的递归!!
修改后的完整版
class Solution {
public:
TreeNode* deleteNode(TreeNode* root, int key) {
//如果找不到,递归到最后返回空
if(root==nullptr){
return nullptr;
}
//找到了,注意这里面必须用else if,因为会操作root,如果root已经被删除就没有意义
if(root->val==key){
//叶子节点,左右都为空
if(root->left==nullptr&&root->right==nullptr){
delete root;
return nullptr;
}
//左不为空右为空的节点
else if(root->left!=nullptr&&root->right==nullptr){
//因为要delete掉来释放内存,所以先用node存储root,再delete root
TreeNode* node = root->left;
delete root;
return node;//把左孩子返回给父节点进行连接
}
//左为空右不为空的节点
else if(root->right!=nullptr&&root->left==nullptr){
TreeNode* node = root->right;
delete root;
return node;//右孩子返回给父节点
}
//左右都不为空的节点
else{
//找到右子树最左下角的节点,一直找到空的位置!
TreeNode* cur = root->right;
while(cur->left!=nullptr){
cur = cur->left;
}
//当cur->left是空的时候,左子树移动到cur->left
cur->left = root->left;
//右节点继位
TreeNode* node = root->right;
delete root;
return node;
}
}
//遍历左右侧,确认key该往哪个方向去找
if(root->val > key){
//key在左子树里,接收并连接左子树节点
root->left = deleteNode(root->left, key);
}
if(root->val < key){
root->right = deleteNode(root->right, key);
}
return root;
}
};
连接的问题
我们直接使用root->left = deleteNode(root->left, key);就可以完成连接,并不需要单独定义left变量来接收返回值。
普通二叉树的版本
普通二叉树和BST的写法区别仅仅在于递归遍历。普通二叉树找key的时候需要遍历整棵树。
BST的写法
//遍历左右侧,确认key该往哪个方向去找
if(root->val > key){
//key在左子树里,接收并连接左子树节点
root->left = deleteNode(root->left, key);
}
if(root->val < key){
root->right = deleteNode(root->right, key);
}
return root;
递归遍历的部分修改成:
//普通的前序遍历+返回的节点连接
root->left = deleteNode(root->left, key);
root->right = deleteNode(root->right, key);
即可。
普通二叉树与BST的删除操作,时间复杂度区别
对于二叉搜索树(BST)和普通二叉树的时间复杂度,区别在于:
- 对于 BST,如果我们知道树的高度 h,那么删除操作的最坏情况下时间复杂度为 O(h),因为我们总是沿着树的高度进行搜索。如果树是平衡的(即 AVL 树或红黑树),那么 h = log(n),n 是节点的数量,所以时间复杂度为 O(log(n))。否则,如果树完全不平衡(例如,每个节点都只有一个孩子),那么 h = n,所以时间复杂度为 O(n)。
- 对于普通二叉树,我们可能需要遍历整个树才能找到要删除的节点,所以最坏情况下的时间复杂度为 O(n),其中 n 是节点的数量。
因此,对于删除操作,BST 通常比普通二叉树更有效率,特别是当树保持较好的平衡时。