学习自:https://labuladong.gitee.io/algo/2/19/26/
前面用了两篇博客,讲了二叉树的相关题目和解法,后面这几篇主要对二叉搜索树(Binary Search Tree)BST,进行讲解。
一、BST的中序遍历
BST 的特性大家应该都很熟悉了:
1、对于 BST 的每一个节点 node,左子树节点的值都比 node 的值要小,右子树节点的值都比 node 的值大。
2、对于 BST 的每一个节点 node,它的左侧子树和右侧子树都是 BST。
直接基于 BST 的数据结构有 AVL 树,红黑树等等,拥有了自平衡性质,可以提供 logN 级别的增删查改效率;还有 B+ 树,线段树等结构都是基于 BST 的思想来设计的。
就拿搜索某一个元素来说,BST 能够在对数时间找到该元素的根本原因还是在 BST 的定义里,左子树小右子树大嘛,所以每个节点都可以通过对比自身的值判断去左子树还是右子树搜索目标值,从而避免了全树遍历,达到对数级复杂度。
从做算法题的角度来看 BST,除了它的定义,还有一个重要的性质:BST 的中序遍历结果是有序的(升序),这个很简单,因为BST的特性就这么要求的。
230、二叉搜索树中第k小的元素(中等)
根据BST的特性来做就行。
class Solution {
List<Integer> tmp = new LinkedList<>();
public int kthSmallest(TreeNode root, int k) {
// 说了是二叉搜索树BST,二叉搜索树的中序遍历一定是有序(从小到大)
traverse(root);
return tmp.get(k - 1);
}
void traverse(TreeNode root) {
if (root == null) return;
traverse(root.left);
// 中序位置
tmp.add(root.val);
traverse(root.right);
// 后序位置
}
}
1038、从二叉搜索树到更大和树(中等)
每个节点更新为:大于或等于该节点值的所有节点值的和,那意思是需要从大到小遍历所有结点的同时,记录全局和,还要在这个过程中修改结点数值,如何在一个函数中同时实现上面操作?还是回到BST的特性,中序遍历得到的结果是从小到大排列的,那我需要从大到小呢?那就把遍历顺序调转一下不就行了,先遍历右再根,再左,同时记录全局和,在中序位置修改当前结点值即可,因为我们求的全局和,是在中序遍历的时候得到的。
class Solution {
public TreeNode bstToGst(TreeNode root) {
traverse(root);
return root;
}
int sum = 0;
void traverse(TreeNode root) {
if (root == null) return;
// 调换中序遍历位置
traverse(root.right);
sum += root.val;
root.val = sum;
traverse(root.left);
}
}
二、判断BST的合法性
BST 的特性大家应该都很熟悉了:
1、对于 BST 的每一个节点 node,左子树节点的值都比 node 的值要小,右子树节点的值都比 node 的值大。
2、对于 BST 的每一个节点 node,它的左侧子树和右侧子树都是 BST。
先来看看这个代码是否正确?
boolean isValidBST(TreeNode root) {
if (root == null) return true;
if (root.left != null && root.val <= root.left.val)
return false;
if (root.right != null && root.val >= root.right.val)
return false;
return isValidBST(root.left)
&& isValidBST(root.right);
}
显然是不正确的,对于上面这幅图,上面的代码只check了当前结点的左右孩子,但BST要求的是当前节点的左右子树满足要求,也即当前结点的左子树的所有值都应该小于当前节点的值,右子树的值都应该大于当前值。上面的图很明显不是。
问题是,对于某一个节点 root,他只能管得了自己的左右子节点,怎么把 root 的约束传递给左右子树呢?
可以通过函数传参的方式,给出当前结点的最大值、最小值限定。
boolean isValidBST(TreeNode root) {
return isValidBST(root, null, null);
}
/* 限定以 root 为根的子树节点必须满足 max.val > root.val > min.val */
boolean isValidBST(TreeNode root, TreeNode min, TreeNode max) {
// base case
if (root == null) return true;
// 若 root.val 不符合 max 和 min 的限制,说明不是合法 BST
if (min != null && root.val <= min.val) return false;
if (max != null && root.val >= max.val) return false;
// 限定左子树的最大值是 root.val,右子树的最小值是 root.val
return isValidBST(root.left, min, root)
&& isValidBST(root.right, root, max);
}
其实思路很简单,记录下当前节点的左右子树的最大值、最小值,作为参数,因为不能够只比较左右节点。
父节点只能限制左子节点的最大值(左子节点的最小值还需要靠之前的记录),只能限制右子节点的最小值(右子节点的最大值还需要靠之前的记录)
※98、验证二叉搜索树(中等)
class Solution {
public boolean isValidBST(TreeNode root) {
return check(root, null, null);
}
boolean check(TreeNode root, TreeNode maxNode, TreeNode minNode) {
if (root == null) return true;
if (maxNode != null && root.val >= maxNode.val) return false;
if (minNode != null && root.val <= minNode.val) return false;
return check(root.left, root, minNode) && check(root.right, maxNode, root);
}
}
※※99、恢复二叉搜索树(困难)
我们知道BST的中序遍历一定是有序的,那如果我们在中序遍历的过程中发现了逆序的情况,例如:3 2 1,显然存在着3 2和2 1两对逆序,我们只需要交换1、3即可。还有一种情况:例如:2 3 1(这种不满足题意,因为只有两个节点的值被错误的交换),例如:1 3 2(中序序列),只有一对逆序对(3 2),直接交换3 2即可。那就是说,无论出现哪种错误,我们只需要交换两个节点的值,我们可以用节点记录下出现错误的节点,然后在进行值的交换即可,当然找到出现错误的节点,得靠中序遍历。
关键点在于有两种错误情况,第一种需要找到第二次错误的地方,而第二种只用找到第一次错误的地方,但我们并不知道究竟是有几次错误,所以必须得遍历完所有节点。
图源:https://leetcode-cn.com/problems/recover-binary-search-tree/solution/tu-jie-hui-fu-yi-ge-er-cha-sou-suo-shu-by-hyj8/
class Solution {
TreeNode err1 = null;
TreeNode err2 = null;
TreeNode prev = null;
public void recoverTree(TreeNode root) {
traverse(root);
// 交换两个错误位置
int tmp = err1.val;
err1.val = err2.val;
err2.val = tmp;
}
void traverse(TreeNode root) {
if (root == null) return;
traverse(root.left);
// 中序位置对于BST来说是有序的
if (prev != null && prev.val >= root.val) {
if (err1 == null) {
err1 = prev;
}
// 不能写成if else
if (err1 != null) {
err2 = root;
}
}
// 记录前一个结点
prev = root;
traverse(root.right);
}
}
700、二叉搜索树中的搜索(简单)
class Solution {
TreeNode ans;
public TreeNode searchBST(TreeNode root, int val) {
traverse(root, val);
return ans;
}
void traverse(TreeNode root, int val) {
if (root == null) {
return;
}
traverse(root.left, val);
// 以该节点为根,那不就只有在中序位置时能够遍历到根?
// 那就直接在中序位置记录ans就行
if (root.val == val) {
ans = root;
return;
}
traverse(root.right, val);
}
}
但上面这样写,还不够,我们可以利用BFS的特性,左小右大,利用二分的思想去找。
class Solution {
public TreeNode searchBST(TreeNode root, int val) {
return traverse(root, val);
}
TreeNode traverse(TreeNode root, int val) {
if (root == null) {
return null;
}
// 利用二分的思想
if (root.val > val) {
return traverse(root.left, val);
} else if (root.val < val) {
return traverse(root.right, val);
} else {
return root;
}
}
}
三、BST的插入、删除操作
BST中插入一个数
插入 = 找位置 + 插入数,找位置简单,利用BST的性质,二分找,插入数也简单,new一个TreeNode即可。
class Solution {
public TreeNode insertIntoBST(TreeNode root, int val) {
if (root == null) {
// 找到插入位置了
return new TreeNode(val);
}
// 寻找插入位置
if (root.val < val) {
root.right = insertIntoBST(root.right, val);
} else if (root.val > val) {
root.left = insertIntoBST(root.left, val);
}
return root;
}
}
※701、二叉搜索树中的插入操作(中等)
我们这里不以题目中第二种方法来插入,而是第一种,找边界位置。
class Solution {
public TreeNode insertIntoBST(TreeNode root, int val) {
if (root == null) {
// 找到插入位置了
return new TreeNode(val);
}
if (root.val < val) {
// 比根节点大,只能放右边
root.right = insertIntoBST(root.right, val);
} else if (root.val > val) {
// 比根节点小,只能放左边
root.left = insertIntoBST(root.left, val);
}
return root;
}
}
BST中删除一个数
删除 = 找位置 + 删除,可以写出这样的代码:
class Solution {
public TreeNode deleteNode(TreeNode root, int key) {
if (root.val == key) {
// 找到位置进行删除
} else if (root.val > key) {
// 比当前key大,去左子树找
deleteNode(root.left, key);
} else {
// 比当前key小,去右子树找
deleteNode(root.right, key);
}
}
}
问题就在于,删除节点,节点所在的位置不好说呀!
图源:https://labuladong.gitee.io/algo/2/19/27/
第一种情况,位于叶子节点,那就直接return null
if (root.left == null && root.right == null) return null;
图源:https://labuladong.gitee.io/algo/2/19/27/
第二种情况,只有一个非空子节点,那么它要让这个孩子接替自己的位置。
// 排除第一种情况的前提下
if (root.left == null) return root.right;
if (root.right == null) return root.left;
第三种情况,既有左孩子,又有右孩子,为了不破坏 BST 的性质,必须找到左子树中最大的那个节点,或者右子树中最小的那个节点来接替自己(这里以第二种方法来讲),再删除右子树的最小节点即可。
图源:https://labuladong.gitee.io/algo/2/19/27/
if (root.left != null && root.right != null) {
// 找到当前节点的右子树的最小节点
TreeNode minNode = findMin(root.right);
// 修改根节点值
root.val = minNode.val;
// 转去删除右子树的最小节点
// 注意,题目上写的是swap,但实际并没有实现swap,而是用的val值替换
root.right = deleteNode(root.right, minNode.val);
}
这个找最小节点怎么找呢?BST的特性,最左边的节点,一定是最小的,所以一直往左子节点遍历,直到最后遍历完即得到结果。
也可以通过类似于链表删除节点的方式,来删除节点:
// 处理情况 3
// 获得右子树最小的节点
TreeNode minNode = getMin(root.right);
// 删除右子树最小的节点
root.right = deleteNode(root.right, minNode.val);
// 用右子树最小的节点替换 root 节点
minNode.left = root.left;
minNode.right = root.right;
root = minNode;
※450、删除二叉搜索树中的节点(简单)
class Solution {
public TreeNode deleteNode(TreeNode root, int key) {
if (root == null) return null;
if (root.val == key) {
if (root.left == null && root.right == null) return null;
else if (root.left == null) return root.right;
else if (root.right == null) return root.left;
else {
// 第三种情况
// 找到右子树的最小值
TreeNode minNode = findMin(root.right);
root.val = minNode.val;
// 更新完根节点,还要把右子树的最小节点删除,还是用这个函数把这个最小节点删除
// 这个最小节点一定是叶子节点
root.right = deleteNode(root.right, minNode.val);
}
} else if (root.val > key) {
// 比key值大,找左子树
root.left = deleteNode(root.left, key);
} else {
// 比key值小,找右子树
root.right = deleteNode(root.right, key);
}
return root;
}
TreeNode findMin(TreeNode root) {
while (root.left != null) {
// BST树的特性,最左边的一定是最小的
root = root.left;
}
return root;
}
}
四、BST的重构问题
※108、将有序数组转换为二叉搜索树(简单)
注意还要保证是高度平衡的二叉搜索树,首先是要为二叉搜索树,跟之前构建二叉树的方法一样,也是通过限定数组的左右位置,来递归构建二叉搜索树。
先是要确定根,二叉搜索树的根只要左子树的所有节点小于它的值,右子树的所有节点的值都大于它的值即可,所以二叉搜索树的根可以在任何位置(数组是有序的),这样也能递归构建满足条件的二叉搜索树,问题在于,如何保证高度平衡(左右子树高度差 <= 1),那这就对根节点位置有要求了,必须取数组的中间位置!(偶数个数时,取中间第一个第二个无影响)
class Solution {
public TreeNode sortedArrayToBST(int[] nums) {
// 通过区分开左右区间进行求解
return build(nums, 0, nums.length - 1);
}
TreeNode build(int[] nums, int leftSize, int rightSize) {
if (leftSize > rightSize) return null;
// 找正中间的数字作为根节点
int idx = (leftSize + rightSize) / 2;
TreeNode root = new TreeNode(nums[idx]);
// 跟之前二叉树建树一样的方法
root.left = build(nums, leftSize, idx - 1);
root.right = build(nums, idx + 1, rightSize);
return root;
}
}
下面这道题同样用到了上面的方法,只是需要自己去获取有序数组
※1382、将二叉搜索树变平衡(中等)
如果只涉及查询,不要用LinkedList!
中序遍历构造有序数组,用有序数组递归构造平衡BST
class Solution {
List<Integer> nodes = new ArrayList<>();
public TreeNode balanceBST(TreeNode root) {
traverse(root);
// 获取到了有序数组,这个问题就转换为了:108、将有序数组转换为二叉搜索树
int sz = nodes.size();
return build(0, sz - 1);
}
// 中序遍历获取BST数组
void traverse(TreeNode root) {
if (root == null) return;
traverse(root.left);
// 中序遍历位置
nodes.add(root.val);
traverse(root.right);
}
// 根据有序数组构建高度平衡的BST
TreeNode build(int leftSize, int rightSize) {
if (leftSize > rightSize) return null;
// 找到根节点下标
int idx = (leftSize + rightSize) / 2;
// 递归构建高度平衡的BST
TreeNode root = new TreeNode(nodes.get(idx));
root.left = build(leftSize, idx - 1);
root.right = build(idx + 1, rightSize);
return root;
}
}
※109、有序链表转换二叉搜索树(中等)
把有序数组换成了有序链表,链表如何找中间节点?(快慢指针),再限定出具体的左右节点位置即可。这三道题其实本质是一样的,通过确定中间节点位置,再限定出左右节点范围。
class Solution {
public TreeNode sortedListToBST(ListNode head) {
if (head == null) return null;
return build(head, null);
}
TreeNode build(ListNode head, ListNode tail) {
if (head == tail) return null;
// 快慢指针找链表中点
ListNode fast = head;
ListNode slow = head;
while (fast != tail && fast.next != tail) {
fast = fast.next.next;
slow = slow.next;
// 快指针走两步,慢指针走一步
}
// 此时慢指针指向中间位置
TreeNode root = new TreeNode(slow.val);
// 同BST的其它建树题目,需要限定出左右位置
root.left = build(head, slow);
root.right = build(slow.next, tail);
return root;
}
}
五、如何计算所有有效 BST
※※96、不同的二叉搜索树(中等)
解析源:https://mp.weixin.qq.com/s/kcwz2lyRxxOsC3n11qdVSw
左子树{1,2},有两种BST组合方式,右子树{4,5},有两种BST组合方式,所以以3作为根节点的BST个数 = 2 * 2 = 4,注意,这里说的组合是指构造出BST的组合方式,而不是排列组合的组合。
因为题目要求为:求恰由 n 个节点组成且节点值从 1 到 n 互不相同的 二叉搜索树 ,就可以以每一个节点为根节点去找,这样当前节点的左边一定是作为左子树,右边一定作为右子树,本质是找左右边的组合数乘积。
class Solution {
public int numTrees(int n) {
return count(1, n);
}
public int count(int left, int right) {
// 空节点 和 只有一个节点的情况,都返回1
if (left >= right) return 1;
int ans = 0;
for (int i = left; i <= right; i++) { // 遍历所有可能父节点
// 看以当前节点为父节点的左、右子节点的组合方式有多少种
int leftCount = count(left, i - 1); // 框出左子节点区间
int rightCount = count(i + 1, right); // 框出右子节点区间
ans += leftCount * rightCount; // 计算当前根节点下的可能BST种类数
}
return ans;
}
}
上面的代码时间效率不高,因为存在重复计算的问题,需要用备忘录记录计算过的结果。备忘录的下标由当前状态决定,而当前状态只由left和right,左右区间决定。
class Solution {
int[][] table;
public int numTrees(int n) {
table = new int[n + 1][n + 1];
return count(1, n);
}
public int count(int left, int right) {
if (left > right) return 1; // 为空节点的情况
// 已经算过了
if (table[left][right] != 0) return table[left][right];
int ans = 0;
for (int i = left; i <= right; i++) {
int leftCount = count(left, i - 1);
int rightCount = count(i + 1, right);
ans += leftCount * rightCount;
}
// 记录下当前结果
table[left][right] = ans;
return ans;
}
}
这一题还可以转换成DP问题,关键是要找到如何转换?
问题在于把当前n个数,1 - n都能够当作根节点,然后去遍历以当前 i 作为根节点的左右子节点能够构成的BST数目,左右BST子树的组合乘积,即为当前 i 作为根节点的结果。
dp[i]表示,1-i 能够构成BST的组合种类数
import java.util.*;
public class Main {
public static void main(String[] args) {
Scanner scan = new Scanner(System.in);
int n = scan.nextInt();
int[] dp = new int[19 + 1];
dp[0] = 1; // 避免乘积=0
dp[1] = 1; // 一个节点只有一种构造方式
for (int i = 2; i <= n; i++) { // 遍历1-n的n
for (int j = 1; j <= i; j++) { // 枚举1-i中每个数(节点)作为根节点的情况
// 1 2 3,2 作为根节点,划分出1 3左右孩子
// 3 作为根节点,就没有右孩子,剩下的全是左孩子
// 左右子节点能够构成的BST的个数的乘积
dp[i] += dp[j - 1] * dp[i - j]; // j - 1左子树, i - j右子树
}
}
System.out.println(dp[n]);
}
}
上一道题只是求个数,下面需要把每个搜索树都求出来。
※95、不同的二叉搜索树Ⅱ(中等)
明白了上道题构造合法 BST 的方法,这道题的思路也是一样的:
1、穷举root节点的所有可能。
2、递归构造出左右子树的所有合法 BST。
3、给root节点穷举所有左右子树的组合。
class Solution {
public List<TreeNode> generateTrees(int n) {
return build(1, n);
}
List<TreeNode> build(int left, int right) {
List<TreeNode> ans = new LinkedList<>();
if (left > right) {
ans.add(null);
return ans;
}
// 枚举每一个节点作为根节点的情况
for (int i = left; i <= right; i++) {
// 获取左子树的BST组合情况
List<TreeNode> leftList = build(left, i - 1);
// 获取右子树的BST组合情况
List<TreeNode> rightList = build(i + 1, right);
for (TreeNode t1 : leftList) {
for (TreeNode t2 : rightList) {
// 将左右子节点和根节点组合
TreeNode root = new TreeNode(i);
root.left = t1;
root.right = t2;
ans.add(root);
}
}
}
return ans;
}
}
个人觉得,递归更考察对问题的宏观把握能力,因为你真的很难把递归的每一步想清楚,只可能把整个问题拆分成几个部分,然后考虑一下边界情况,这就是能做的事情,其余的就是相信这个递归函数,然后求解得到正确答案。
※1373、二叉搜索子树的最大键值和(困难)
注意,本题给的是二叉树,也就是说需要自己判断是否存在二叉搜索树。
注意下面这个例子,因为根节点为空也算BST,所以最大和为0。
摘自:https://labuladong.gitee.io/algo/2/19/29/
那么我们想计算子树中 BST 的最大和,站在当前节点的视角,需要做什么呢?
1、我肯定得知道左右子树是不是合法的 BST
,如果这俩儿子有一个不是 BST,以我为根的这棵树肯定不会是 BST,对吧。
2、如果左右子树都是合法的 BST,我得瞅瞅左右子树加上自己还是不是合法的 BST 了
。因为按照 BST 的定义,当前节点的值应该大于左子树的最大值,小于右子树的最小值,否则就破坏了 BST 的性质。
3、因为题目要计算最大的节点之和,如果左右子树加上我自己还是一棵合法的 BST,也就是说以我为根的整棵树是一棵 BST,那我需要知道我们这棵 BST 的所有节点值之和是多少,方便和别的 BST 争个高下,对吧。
首先需要判断BST,判断BST就需要记录遍历过程中的minNode和maxNode,只有左右子树是BST,并且根节点大于左子树的最大节点值,小于右子树的最小节点值,才能说这个树是BST。光判断BST还不够,还需要求这个BST的所有节点值之和,所以还需要记录BST的节点值之和。
综上,一共需要四个参数,是否为BST、当前BST子树的最小值、当前BST子树的最大值、当前BST子树的所有节点值之和(因为不是BST就不用考虑剩余内容)
class Solution {
int max = Integer.MIN_VALUE;
public int maxSumBST(TreeNode root) {
traverse(root);
return Math.max(max, 0);
}
// 函数返回 int[]{isBST, min, max, sum}
// min,max是当前BST树的最大最小值
int[] traverse(TreeNode root) {
if (root == null) {
// 为空,是BST,sum = 0
return new int[] {1, Integer.MAX_VALUE, Integer.MIN_VALUE, 0};
}
int[] leftNode = traverse(root.left);
int[] rightNode = traverse(root.right);
int[] ans = new int[4];
// 在后序位置,前面两个值都能够获取
// 左右子树都满足BST,再看加上根节点是否满足BST
if (leftNode[0] == 1 && rightNode[0] == 1 && root.val > leftNode[2] && root.val < rightNode[1]) {
// 当前BST树一定是BST
ans[0] = 1;
// 求当前BST树的最大值、最小值
ans[1] = Math.min(leftNode[1], root.val);
ans[2] = Math.max(rightNode[2], root.val);
// 求当前BST的最大键值和
int sum = leftNode[3] + rightNode[3] + root.val;
ans[3] = sum;
max = Math.max(max, sum);
}
return ans;
}
}
有很多同学可能会有疑问,这里为什么是min、max,如果以当前节点为根节点,左子树的最小值一定是当前BST树的最小值呀?右子树同样是这样,取min和max的原因与我们对空节点的初始化值有关!