DAY24:二叉树(十四)二叉搜索树中的插入操作+删除二叉搜索树中的节点(二叉树结构修改难点)

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->leftroot->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->leftroot->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->leftroot->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->leftroot->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)和普通二叉树的时间复杂度,区别在于:

  1. 对于 BST,如果我们知道树的高度 h,那么删除操作的最坏情况下时间复杂度为 O(h),因为我们总是沿着树的高度进行搜索。如果树是平衡的(即 AVL 树或红黑树),那么 h = log(n),n 是节点的数量,所以时间复杂度为 O(log(n))。否则,如果树完全不平衡(例如,每个节点都只有一个孩子),那么 h = n,所以时间复杂度为 O(n)
  2. 对于普通二叉树,我们可能需要遍历整个树才能找到要删除的节点,所以最坏情况下的时间复杂度为 O(n),其中 n 是节点的数量。

因此,对于删除操作,BST 通常比普通二叉树更有效率,特别是当树保持较好的平衡时。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值