文章目录
- 二叉搜索树BST
- 什么是BST?
- 用BST做什么?
- 一、BST的特性
- BST的特性是什么?
- 1.[230. 二叉搜索树中第K小的元素](https://leetcode.cn/problems/kth-smallest-element-in-a-bst/)
- 2.[538. 把二叉搜索树转换为累加树](https://leetcode.cn/problems/convert-bst-to-greater-tree/)
- 二、BST的基础操作
- BST的基础操作有哪些?
- 1.判断BST的合法性
- [98. 验证二叉搜索树](https://leetcode.cn/problems/validate-binary-search-tree/)
- 总结所出现的问题就是:
- 那如何解决这个问题呢?
- 2.在BTS中搜索元素
- [700. 二叉搜索树中的搜索](https://leetcode.cn/problems/search-in-a-binary-search-tree/)
- 3.在BST中插入一个数
- [701. 二叉搜索树中的插入操作](https://leetcode.cn/problems/insert-into-a-binary-search-tree/)
- 4.在BST中删除一个数
- 但是找到目标节点,如何删除是一个难点!
- 情况1:
- 情况2:
- 情况3:
- 5.总结
- 三、BST的构造(如何计算所有合法BST)
- 1.[96. 不同的二叉搜索树](https://leetcode.cn/problems/unique-binary-search-trees/)
- A.那用什么方式能够正确地穷举合法BST的数量呢?
- B.那对于每个根节点又会有几种不同的BST呢?
- C.那我们如何得到组合的结果呢?
- D.为什么base case返回1?
- E.如何解决时间复杂度高问题?即如何消除重叠子问题?
- 2.[95. 不同的二叉搜索树 II](https://leetcode.cn/problems/unique-binary-search-trees-ii/)
二叉搜索树BST
什么是BST?
- 对于 BST 的每一个节点
node
,左子树节点的值都比node
的值要小,右子树节点的值都比node
的值大。 - 对于 BST 的每一个节点
node
,它的左侧子树和右侧子树都是 BST。
二叉搜索树并不算复杂,但我觉得它可以算是数据结构领域的半壁江山,直接基于 BST 的数据结构有 AVL 树,红黑树等等,拥有了自平衡性质,可以提供 logN 级别的增删查改效率;还有 B+ 树,线段树等结构都是基于 BST 的思想来设计的。
用BST做什么?
要么利用 BST 左小右大的特性提升算法效率,要么利用中序遍历的特性满足题目的要求
一、BST的特性
BST的特性是什么?
从做算法题的角度来看 BST,除了它的定义,还有一个重要的性质:BST 的中序遍历结果是有序的(升序)。
1.230. 二叉搜索树中第K小的元素
首先找一堆元素中的第k小的元素,直接的思路就是将这些元素按升序排,dik个就是满足条件的元素
根据BST的性质,所以我们就在遍历的时候找到符合条件的元素
写法1:
List<Integer> result=new ArrayList<>();
public void traverse(TreeNode root){
if(root==null) return;
traverse(root.left);
result.add(root.val);
traverse(root.right);
}
public int kthSmallest(TreeNode root, int k) {
traverse(root);
int n=result.get(k-1);
return n;
}
写法2:
int kthSmallest(TreeNode root, int k) {
// 利用 BST 的中序遍历特性
traverse(root, k);
return res;
}
// 记录结果
int res = 0;
// 记录当前元素的排名
int rank = 0;
void traverse(TreeNode root, int k) {
if (root == null) {
return;
}
traverse(root.left, k);
/* 中序遍历代码位置 */
rank++;
if (k == rank) {
// 找到第 k 小的元素
res = root.val;
return;
}
/*****************/
traverse(root.right, k);
}
但是这样的做法并不是最高效的解法
1.为什么呢?
因为按照刚刚的方法,每次寻找第
k
小的元素都要中序遍历一次,最坏的时间复杂度是O(N)
,N
是 BST 的节点个数。
要知道 BST 性质是非常牛逼的,像红黑树这种改良的自平衡 BST,增删查改都是 O(logN)
的复杂度,让你算一个第 k
小元素,时间复杂度竟然要 O(N)
,有点低效了。
所以说,计算第
k
小元素,最好的算法肯定也是对数级别的复杂度,不过这个依赖于 BST 节点记录的信息有多少。
2.为什么BST能在对数级别的复杂度内搜索某一个元素呢?
根本原因还是在 BST 的定义里,左子树小右子树大嘛,所以每个节点都可以通过对比自身的值判断去左子树还是右子树搜索目标值,从而避免了全树遍历,达到对数级复杂度。
3.如何实现?
关键也在于每个节点得知道他自己排第几。
比如说你让我查找排名为
k
的元素,当前节点知道自己排名第m
,那么我可以比较m
和k
的大小:
- 如果
m == k
,显然就是找到了第k
个元素,返回当前节点就行了。- 如果
k < m
,那说明排名第k
的元素在左子树,所以可以去左子树搜索第k
个元素。- 如果
k > m
,那说明排名第k
的元素在右子树,所以可以去右子树搜索第k - m - 1
个元素。这样就可以将时间复杂度降到
O(logN)
了。
4.如何让每一个节点知道自己的排名呢?
这就是我们之前说的,需要在二叉树节点中维护额外信息。每个节点需要记录,以自己为根的这棵二叉树有多少个节点。
也就是说,我们
TreeNode
中的字段应该如下:class TreeNode { int val; // 以该节点为根的树的节点总数 int size; TreeNode left; TreeNode right; }
有了
size
字段,外加 BST 节点左小右大的性质,对于每个节点node
就可以通过node.left
推导出node
的排名,从而做到我们刚才说到的对数级算法。
当然,size
字段需要在增删元素的时候需要被正确维护,力扣提供的 TreeNode
是没有 size
这个字段的,所以我们这道题就只能利用 BST 中序遍历的特性实现了,但是我们上面说到的优化思路是 BST 的常见操作,还是有必要理解的。
2.538. 把二叉搜索树转换为累加树
我们可以降序打印 BST 节点的值,通过维护一个外部累加变量 sum
,然后把 sum
赋值给 BST 中的每一个节点,就将 BST 转化成累加树了。
这道题的核心还是 BST 的中序遍历特性,只不过我们修改了递归顺序,降序遍历 BST 的元素值,从而契合题目累加树的要求。
二、BST的基础操作
BST的基础操作有哪些?
判断 BST 的合法性、增、删、查。其中「删」和「判断合法性」略微复杂。
BST 的基础操作主要依赖「左小右大」的特性,可以在二叉树中做类似二分搜索的操作,寻找一个元素的效率很高。
对于 BST 相关的问题,你可能会经常看到类似下面这样的代码逻辑:
void BST(TreeNode root, int target) {
if (root.val == target)
// 找到目标,做点什么
if (root.val < target)
BST(root.right, target);
if (root.val > target)
BST(root.left, target);
}
1.判断BST的合法性
以以下例题作讲解:
98. 验证二叉搜索树
判断合法性我们直接就会想到按照BST左小右大的特性,每个节点判断自己和自己的左右孩子是否满足左小右大。
因此很容易得出:
boolean isValidBST(TreeNode root) {
if (root == null) return true;
// root 的左边应该更小
if (root.left != null && root.left.val >= root.val)
return false;
// root 的右边应该更大
if (root.right != null && root.right.val <= root.val)
return false;
return isValidBST(root.left)
&& isValidBST(root.right);
}
但是这个算法是有问题的,因为BST 的每个节点应该要小于右边子树的所有节点
所以就有可能出现以下这样的二叉树我们也判定为是二叉搜索树(节点10的右子树中有一个节点6,显然这就不是二叉搜索树)
总结所出现的问题就是:
对于每一个节点
root
,代码值检查了它的左右孩子节点是否符合左小右大的原则;但是根据 BST 的定义,
root
的整个左子树都要小于root.val
,整个右子树都要大于root.val
。
那如何解决这个问题呢?
对于某一个节点
root
,他只能管得了自己的左右子节点,怎么把root
的约束传递给左右子树呢?解决方法是我们通过使用辅助函数增加函数参数列表,在参数中携带额外信息,将这种约束传递给子树的所有节点,这也是二叉树算法的一个小技巧
public boolean isValidBST(TreeNode root) {
return isValidBST(root,null,null);
}
public boolean isValidBST(TreeNode root,TreeNode min,TreeNode max){
if(root==null) return true;
if(min!=null&&root.val<=min.val) return false;
if(max!=null&&root.val>=max.val) return false;
return isValidBST(root.left,min,root)&&isValidBST(root.right,root,max);
}
2.在BTS中搜索元素
700. 二叉搜索树中的搜索
如果在一棵普通的二叉树中寻找,可以这样写代码:
TreeNode searchTree(TreeNode root, int target);
if (root == null) return null;
if (root.val == target) return root;
// 当前节点没找到就递归地去左右子树寻找
TreeNode left = searchTree(root.left, target);
TreeNode right = searchTree(root.right, target);
return left != null ? left : right;
}
这样写就相当于穷举了所有节点,适用于所有二叉树
既然BST具有特殊性,那我们是否考虑将其特性用在搜索上,方便我们更快地找到目标元素?
根据BST左小右大的特性,我们不需要递归地搜索两边,类似二分查找思想,根据 target
和 root.val
的大小比较,就能排除一边。
public TreeNode searchBST(TreeNode root, int val) {
if (root == null) return null;
int target=val;
if(root.val==target) return root;
if(root.val<target) return searchBST(root.right,target);
if(root.val>target) return searchBST(root.left,target);
return null;
}
3.在BST中插入一个数
对数据结构的操作无非遍历 + 访问,遍历就是找,访问就是改。具体到这个问题,插入一个数,就是先找到插入位置,然后进行插入操作。
我们已经直到在BST中找一个数是怎样的框架,因此我们就在此框架上完成在BST中插入一个数。
在此之前我们要知道,一旦涉及改,就类似二叉树的构造问题,函数要返回
TreeNode
类型,并且要对递归调用的返回值进行接收。
701. 二叉搜索树中的插入操作
public TreeNode insertIntoBST(TreeNode root, int val) {
// 找到空位置插入新节点
if(root==null) return new TreeNode(val);
// if (root.val == val)
// BST 中一般不会插入已存在元素
if(root.val<val) {
//对于递归调用的返回值进行接收
root.right=insertIntoBST(root.right,val);
}
if(root.val>val){
//对于递归调用的返回值进行接收
root.left=insertIntoBST(root.left,val);
}
return root;
}
4.在BST中删除一个数
这个问题稍微复杂,跟插入操作类似,先找再改,也是基于插入的框架
TreeNode deleteNode(TreeNode root, int key) {
if (root.val == key) {
// 找到啦,进行删除
} else if (root.val > key) {
// 去左子树找
root.left = deleteNode(root.left, key);
} else if (root.val < key) {
// 去右子树找
root.right = deleteNode(root.right, key);
}
return root;
}
但是找到目标节点,如何删除是一个难点!
因为删除目标节点A可能有三种情况并且删除之后不能破坏BST的性质
情况1:
A恰好是末端节点,两个子节点都为空,那就可以直接删除
if (root.left == null && root.right == null)
return null;
情况2:
A只有一个非空节点,那么就让这个节点接替自己的位置
// 排除了情况 1 之后
if (root.left == null) return root.right;
if (root.right == null) return root.left;
情况3:
A有两个子节点,为了不破坏BST的性质,A必须找到左子树中最大的那个节点或者右子树中最小的那个节点来接替自己,然后就变成删除右子树最小节点
if (root.left != null && root.right != null) {
// 找到右子树的最小节点
TreeNode minNode = getMin(root.right);
// 把 root 改成 minNode
root.val = minNode.val;
// 转而去删除 minNode
//为什么这里是minNode.val,因为我们只是将最小节点的值给了目标节点,而没有将最小节点进行替换,可能会有疑问,为什么不替换呢,这样就还是删除key。但其实这一步是没必要的,因为即使替换,还是这个位置上的节点被删除的,所以就不替换,当作删除最小节点
root.right = deleteNode(root.right, minNode.val);
}
但是我们一般不会通过修改节点内部的值来交换节点。因为在实际应用中,BST 节点内部的数据域是用户自定义的,可以非常复杂,而 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;
完整代码如下:
public TreeNode deleteNode(TreeNode root, int key) {
if (root == null) return null;
//1.
if(root.val==key){
//符合条件,删除
//以下两个if包括了第一种叶子情况和第二种只有一个孩子的节点情况
// if(root.left==null&&root.right==null) return null;
if(root.left==null){
return root.right;
}
if(root.right==null){
return root.left;
}
if(root.left!=null&&root.right!=null){
/*
// 找到右子树的最小节点
TreeNode minNode = getMin(root.right);
// 把 root 改成 minNode
root.val = minNode.val;
// 转而去删除 minNode
root.right = deleteNode(root.right, minNode.val);
*/
// 处理情况 3
// 获得右子树最小的节点
TreeNode minNode = getMin(root.right);
// 删除右子树最小的节点
root.right = deleteNode(root.right, minNode.val);
// 用右子树最小的节点替换 root 节点
minNode.left = root.left;
minNode.right = root.right;
root = minNode;
}
}
//2.
if(root.val<key){
root.right=deleteNode(root.right,key);
}
//3.
if(root.val>key){
root.left=deleteNode(root.left,key);
}
return root;
}
TreeNode getMin(TreeNode node) {
// BST 最左边的就是最小的
while (node.left != null) node = node.left;
return node;
}
5.总结
1、如果当前节点会对下面的子节点有整体影响,可以通过辅助函数增长参数列表,借助参数传递信息。
2、在二叉树递归框架之上,扩展出一套 BST 代码框架:
void BST(TreeNode root, int target) {
if (root.val == target)
// 找到目标,做点什么
if (root.val < target)
BST(root.right, target);
if (root.val > target)
BST(root.left, target);
}
3、根据代码框架掌握了 BST 的增删查改操作
三、BST的构造(如何计算所有合法BST)
1.96. 不同的二叉搜索树
由上图的例子我们很容易的出这就是一个穷举问题
A.那用什么方式能够正确地穷举合法BST的数量呢?
在前面学习过二叉树之后我们都知道二叉树算法的关键就在于明确根节点需要做什么,其实 BST 作为一种特殊的二叉树,核心思路也是一样的。
例如:输入n = 5
,也就是说用{1,2,3,4,5}
这些数字去构造 BST。
显然就有基本的5种情况,因为每个数字都可以作为根节点。
B.那对于每个根节点又会有几种不同的BST呢?
例如我们固定3作为根节点
左子树节点就是{1,2}
的组合,右子树就是{4,5}
的组合
显然左子树的组合数和右子树的组合数乘积就是3
作为根节点时的 BST 个数。
显然这对于其他节点也是一样的
C.那我们如何得到组合的结果呢?
显然这需要用递归实现
// 定义:闭区间 [lo, hi] 的数字能组成 count(lo, hi) 种 BST
int count(int lo, int hi);
结合上面的分析我们可以得到以下代码:
public int numTrees(int n) {
// 计算闭区间 [1, n] 组成的 BST 个数
return count(1,n);
}
/* 计算闭区间 [lo, hi] 组成的 BST 个数 */
public int count(int low,int high){
//base case
if(low>high) return 1;
int res=0;
for(int i=low;i<=high;i++){
//i作为根节点
int left=count(low,i-1);
int right=count(i+1,high);
// 左右子树的组合数乘积是 BST 的总数
res+=left*right;
}
return res;
}
D.为什么base case返回1?
显然当lo > hi
闭区间[lo, hi]
肯定是个空区间,也就对应着空节点 null,虽然是空节点,但是也是一种情况,所以要返回 1 而不能返回 0。
但是这个代码时间复杂度很高,因为存在重叠子问题
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-d55vqqIk-1680230716781)(D:\Development\Typora\img\image-20230331094114121.png)]
E.如何解决时间复杂度高问题?即如何消除重叠子问题?
这就想起动态规划消除重叠子问题的方法,就是加一个备忘录
备忘录记录的就是子问题,当再次遇到相同子问题的时候,把第一次遇到时得出的结果(在备忘录存着)返回就可以了
//备忘录
int[][] memory;
public int numTrees(int n) {
//备忘录初始化
memory=new int[n+1][n+1];
return count(1,n);
}
public int count(int low,int high){
//base case
if(low>high) return 1;
if(memory[low][high]!=0){
return memory[low][high];
}
int res=0;
for(int i=low;i<=high;i++){
//i作为根节点
int left=count(low,i-1);
int right=count(i+1,high);
res+=left*right;
}
memory[low][high]=res;
return res;
}
2.95. 不同的二叉搜索树 II
做法和上一题很像
1、穷举root
节点的所有可能。
2、递归构造出左右子树的所有合法 BST。
3、给root
节点穷举所有左右子树的组合
public List<TreeNode> generateTrees(int n) {
if(n==0) return new LinkedList<>();
return build(1,n);
}
public List<TreeNode> build(int low,int high){
//为什么在这里,这样每棵BST的节点才会分开分开来,可能会有一个疑问,那在递归调用左右子树的时候不就总是重新初始化吗?其实这个是没有关系的,因为在递归构造完左右子树之前result中都没有节点。
List<TreeNode> result=new LinkedList<>();
if(low>high){
result.add(null);
return result;
}
for(int i=low;i<=high;i++){
//递归生成左右子树的节点列表
List<TreeNode> leftTree=build(low,i-1);
List<TreeNode> rightTree=build(i+1,high);
//给root节点穷举所有左右子树组合
for(TreeNode left:leftTree){
for(TreeNode right:rightTree){
TreeNode root=new TreeNode(i);
root.left=left;
root.right=right;
result.add(root);
}
}
}
return result;
}