1. 题目
https://leetcode.cn/problems/delete-node-in-a-bst/
考察点
这道题的考察点主要是以下几个方面:
- 二叉搜索树的性质和操作,如何在二叉搜索树中查找、插入和删除节点,以及如何找到节点的后继(或者前驱)。
- 递归的思想和技巧,如何设计递归函数的参数、终止条件、返回值和递归调用,以及如何处理边界情况和特殊情况。
- 分治法的思想和技巧,如何将一个大问题分解成若干个小问题,并递归地解决它们,然后合并它们的结果,以及如何保证分治法的正确性和效率。
- 中序遍历的思想和技巧,如何利用中序遍历来得到一个有序数组或者构建一个平衡的二叉搜索树,以及如何优化中序遍历的空间复杂度。
2. 解法
有三种解法
- 方法一:递归查找要删除的节点,然后根据节点的情况进行删除或替换。
- 方法二:将二叉搜索树转换成一个有序数组,然后从数组中删除指定的值,再重新构建一个平衡的二叉搜索树。
- 方法三:利用二叉搜索树的性质,找到要删除的节点的后继或前驱,然后用后继或前驱替换要删除的节点,再递归地删除后继或前驱。
三种解对比如
- 时间复杂度方面,方法一和方法三都是 O(h),其中 h 是树的高度,而方法二是 O(n),其中 n 是树的节点数。因此,方法一和方法三在时间上更优。
- 空间复杂度方面,方法一和方法三都是 O(h),因为它们需要递归调用栈空间,而方法二是 O(n),因为它需要额外的数组空间。因此,方法一和方法三在空间上也更优。
- 代码难度方面,方法一和方法三都需要考虑递归的终止条件和返回值,以及如何找到后继(或者前驱),而方法二相对简单,只需要中序遍历和分治法。因此,方法二在代码难度上更低。
- 思路清晰方面,方法一和方法三都比较直观,利用了二叉搜索树的性质来进行查找和删除,而方法二比较取巧,利用了二叉搜索树和有序数组之间的关系来进行转换和重建。因此,方法一和方法三在思路清晰上更好。
综上所述,我认为方法一和方法三都是比较好的解法,它们在时间、空间和思路上都有优势,而且代码难度也不高
方法一:递归查找要删除的节点,然后根据节点的情况进行删除或替换。
思路
这个方法利用了二叉搜索树的性质,即左子树的所有节点都小于根节点,右子树的所有节点都大于根节点。因此,我们可以通过比较根节点和要删除的值来确定要删除的节点在哪个子树中,并递归地进行查找。当找到要删除的节点时,我们需要考虑四种情况:如果该节点是叶子节点,直接删除;如果该节点只有一个孩子,用孩子替换该节点;如果该节点有两个孩子,用该节点的后继(或者前驱)替换该节点,并递归地删除后继(或者前驱)。这样就可以保证删除后的树仍然是一个二叉搜索树。
代码逻辑
- 定义一个函数 deleteNode,接受一个根节点和一个要删除的值作为参数。
- 如果根节点为空,直接返回空。
- 如果根节点的值大于要删除的值,说明要删除的节点在左子树中,那么就递归地调用 deleteNode 函数,将左子节点和要删除的值作为参数,然后将返回的结果赋值给左子节点。
- 如果根节点的值小于要删除的值,说明要删除的节点在右子树中,那么就递归地调用 deleteNode 函数,将右子节点和要删除的值作为参数,然后将返回的结果赋值给右子节点。
- 如果根节点的值等于要删除的值,说明找到了要删除的节点,那么就需要处理以下四种情况:
- 如果该节点没有左子节点也没有右子节点,说明该节点是一个叶子节点,那么就直接返回空,相当于删除了该节点。
- 如果该节点只有左子节点没有右子节点,说明该节点只有一个孩子,那么就直接返回左子节点,相当于用左子节点替换了该节点。
- 如果该节点只有右子节点没有左子节点,说明该节点只有一个孩子,那么就直接返回右子节点,相当于用右子节点替换了该节点。
- 如果该节点既有左子节点又有右子节点,说明该节点有两个孩子,那么就需要找到该节点在中序遍历中的后继(或者前驱),也就是比该节点大(或者小)的最小(或者最大)的元素。这里我们选择找后继,也就是在右子树中找到最小的元素。我们定义一个函数 getMin 来实现这个功能,它接受一个根节点作为参数,并返回其最左边的孩子。然后我们将后继的值赋给当前找到的要删除的节点,并且递归地调用 deleteNode 函数,在右子树中删除后继。
- 最后返回根节点。
具体实现
递归查找要删除的节点,然后根据节点的情况进行删除或替换。这个方法的时间复杂度是 O(h),其中 h 是树的高度。
public class Solution1 {
/**
* credit: https://discuss.leetcode.com/topic/65792/recursive-easy-to-understand-java-solution
* Steps:
* 1. Recursively find the node that has the same value as the key, while setting the left/right nodes equal to the returned subtree
* 2. Once the node is found, have to handle the below 4 cases
* a. node doesn't have left or right - return null
* b. node only has left subtree- return the left subtree
* c. node only has right subtree- return the right subtree
* d. node has both left and right - find the minimum value in the right subtree, set that value to the currently found node, then recursively delete the minimum value in the right subtree
*/
public TreeNode deleteNode(TreeNode root, int key) {
if (root == null) {
return root;
}
if (root.val > key) {
root.left = deleteNode(root.left, key);
} else if (root.val < key) {
root.right = deleteNode(root.right, key);
} else {
if (root.left == null) {
return root.right;
} else if (root.right == null) {
return root.left;
}
TreeNode minNode = getMin(root.right);
root.val = minNode.val;
root.right = deleteNode(root.right, root.val);
}
return root;
}
private TreeNode getMin(TreeNode node) {
while (node.left != null) {
node = node.left;
}
return node;
}
}
方法二:将二叉搜索树转换成一个有序数组,然后从数组中删除指定的值,再重新构建一个平衡的二叉搜索树。
思路
将二叉搜索树转换成一个有序数组,然后从数组中删除指定的值,再重新构建一个平衡的二叉搜索树。这个方法利用了二叉搜索树和有序数组之间的关系,即中序遍历二叉搜索树可以得到一个有序数组。因此,我们可以通过中序遍历来将二叉搜索树转换成一个有序数组,并在遍历过程中跳过要删除的值。然后我们可以通过分治法来将有序数组转换成一个平衡的二叉搜索树。具体地,我们可以选择数组中间位置对应的元素作为新树的根,并递归地在左半部分和右半部分构建左子树和右子树。这样就可以保证构建出来的树是平衡的,并且满足二叉搜索树的性质。
代码逻辑
- 定义一个函数 deleteNode,接受一个根节点和一个要删除的值作为参数。
- 定义一个列表 list 来存储二叉搜索树中除了要删除的值之外的所有元素。我们可以通过中序遍历来实现这个功能。我们定义一个函数 dfs 来实现中序遍历,它接受一个根节点、一个要删除的值和一个列表作为参数,并返回更新后的列表。如果根节点为空,直接返回列表。如果不为空,先递归地调用 dfs 函数,在左子树中进行遍历,并将返回的结果赋值给列表。然后判断当前根节点的值是否等于要删除的值,如果不等于,则将其加入到列表中。最后递归地调用 dfs 函数,在右子树中进行遍历,并将返回的结果赋值给列表。
- 然后我们需要重新构建一个平衡的二叉搜索树。我们可以通过分治法来实现这个功能。我们定义一个函数 formBst 来实现分治法,它接受一个列表、一个左边界和一个右边界作为参数,并返回构建好的二叉搜索树。如果左边界大于右边界,直接返回空。如果不大于,则找到列表中间位置对应的元素作为新树的根,并创建一个新的 TreeNode 对象。然后递归地调用 formBst 函数,在列表左半部分构建左子树,并将返回结果赋值给新树的左孩子。同样地,在列表右半部分构建右子树,并将返回结果赋值给新树的右孩子。最后返回新树。
- 最后调用 formBst 函数,并将 list、0 和 list 的长度减一作为参数。
具体实现
将二叉搜索树转换成一个有序数组,然后从数组中删除指定的值,再重新构建一个平衡的二叉搜索树。
这个方法的时间复杂度是 O(n),其中 n 是树的节点数。
public class Solution2 {
/**
* My original, but brute force solution, time complexity: O(n) instead of O(h)
*/
public TreeNode deleteNode(TreeNode root, int key) {
List<Integer> list = new ArrayList<>();
dfs(root, key, list);
return formBst(list, 0, list.size() - 1);
}
private TreeNode formBst(List<Integer> list, int left, int right) {
if (left > right) {
return null;
}
int mid = left + (right - left) / 2;
TreeNode root = new TreeNode(list.get(mid));
root.left = formBst(list, left, mid - 1);
root.right = formBst(list, mid + 1, right);
return root;
}
private List<Integer> dfs(TreeNode root, int key, List<Integer> list) {
if (root == null) {
return list;
}
dfs(root.left, key, list);
if (root.val != key) {
list.add(root.val);
}
dfs(root.right, key, list);
return list;
}
}
方法三:利用二叉搜索树的性质,找到要删除的节点的后继或前驱,然后用后继或前驱替换要删除的节点,再递归地删除后继或前驱。
思路
利用二叉搜索树的性质,找到要删除的节点的后继或前驱,然后用后继或前驱替换要删除的节点,再递归地删除后继或前驱。这个方法和方法一类似,也是利用了二叉搜索树的性质来进行查找和删除。不同之处在于,当找到要删除的节点时,我们不是直接用其孩子替换它,而是用其后继(或者前驱)替换它。这样做的好处是,我们可以保证替换后的节点仍然满足二叉搜索树的性质,并且不会破坏原来树的结构。而且,我们只需要递归地删除后继(或者前驱),而不需要考虑其他情况。这样就简化了代码和逻辑。
代码逻辑
利用二叉搜索树的性质,找到要删除的节点的后继或前驱,然后用后继或前驱替换要删除的节点,再递归地删除后继或前驱。
- 定义一个函数 deleteNode,接受一个根节点和一个要删除的值作为参数。
- 如果根节点为空,直接返回空。
- 如果根节点等于要删除的值,则需要处理以下三种情况:
- 如果该节点没有左子节点也没有右子节点,则直接返回空。
- 如果该节点只有左子节点没有右子节
- 如果该节点只有左子节点没有右子节点,则直接返回左子节点。
- 如果该节点只有右子节点没有左子节点,则直接返回右子节点。
- 如果该节点既有左子节点又有右子节点,则需要找到该节点的后继(或者前驱),并用后继(或者前驱)替换该节点,再递归地删除后继(或者前驱)。这里我们选择找后继,也就是在右子树中找到最小的元素。我们定义一个函数 findSuccessor 来实现这个功能,它接受一个根节点作为参数,并返回其最左边的孩子。然后我们将后继的左子树赋值给当前找到的要删除的节点的左子树,并返回后继。
- 如果根节点大于要删除的值,则说明要删除的节点在左子树中,那么就递归地调用 deleteNode 函数,将左子节点和要删除的值作为参数,并将返回结果赋值给左子节点。
- 如果根节点小于要删除的值,则说明要删除的节点在右子树中,那么就递归地调用 deleteNode 函数,将右子节点和要删除的值作为参数,并将返回结果赋值给右子节点。
- 最后返回根节点。
具体实现
这个方法的时间复杂度也是 O(h),其中 h 是树的高度。
public class Solution3 {
/**
* credit: https://leetcode.com/problems/delete-node-in-a-bst/solution/
*
* Again, using a pen and paper to visualize helps a lot.
* Putting a BST into an inorder traversal array helps a lot to understand:
*
* The successor of a node is always: go the right once, and then go to the left as many times as possible,
* because the successor is the next smallest element that is larger than the current one: so going to the right one time
* makes sure that we are finding the larger one, and then keeping going to the left makes sure that we'll find the smallest one.
*
* The predecessor of a node is always: go to the left once and then go to the right as many times as possible,
* because it's just opposite of finding successor.
*/
public TreeNode deleteNode(TreeNode root, int key) {
// Return null if root is null
if (root == null)
return null;
// Delete current node if root is the target node
if (root.val == key) {
// Replace root with root->right if root->left is null
if (root.left == null)
return root.right;
// Replace root with root->left if root->right is null
if (root.right == null)
return root.left;
// Replace root with its successor if root has two children
TreeNode p = findSuccessor(root);
p.left = root.left;
return p;
}
// Find target node and delete it recursively
if (root.val > key)
// Go left if target node < current node
root.left = deleteNode(root.left, key);
else
// Go right otherwise
root.right = deleteNode(root.right, key);
return root;
}
private TreeNode findSuccessor(TreeNode curr) {
curr = curr.right;
while (curr != null && curr.left != null)
curr = curr.left;
return curr;
}
}