概要
二插搜索树:它或者是一棵空树,或者是具有下列性质的二叉树: 若它的左子树不空,则左子树上所有结点的值均小于它的根结点的值; 若它的右子树不空,则右子树上所有结点的值均大于它的根结点的值; 它的左、右子树也分别为二叉排序树。
下面我列举出几道最近刷letCode遇到的和二插搜索树有关的算法题:
问题描述
给你一棵树,判断这颗树是否二插搜索树。(注:题目来自letCode 98.验证二插搜索树)
问题分析
对于这道问题,根据二插搜索树的性质。我们可以递归的判断每个树节点的左子树是否小于根节点的值,每个树的右节点是否大于根节点的值。但是需要特别注意的一点是:根节点的左子树上所有节点的值都是小于根节点值的,也就是说,根节点的左子树,它的右子树的值不能大于根据节点的值,同理,根节点的右子树,它的左子树的值不能小于跟节点的值。
所以在做这道题的时候,对于判断每个节点我们加上界限,这样就可以通过递归简单的解决该问题
递归判断法
public boolean isValidBST(TreeNode root) {
return echo(root, null, null);
}
public boolean echo(TreeNode node, Integer min, Integer max) {
if (node == null) {
return true;
}
int val = node.val;
if (min != null && val <= min) {
return false;
}
if (max != null && val >= max) {
return false;
}
if (!echo(node.left, min, val)) {
return false;
}
if (!echo(node.right, val, max)) {
return false;
}
return true;
}
该方法就依照左右遍历的顺序,判断每个节点的左子树是否小于根节点值,右子树是否大于根节点值,并判断根节点是否越界。当然这里也可以通过栈来模拟遍历的过程,代替递归。使用栈的方式如下:
Stack<TreeNode> treeNodes = new Stack<>();
Stack<Integer> minStack = new Stack<>();
Stack<Integer> maxStack = new Stack<>();
public boolean isValidBST3(TreeNode root) {
pushData(root, null, null);
while (!treeNodes.isEmpty()) {
TreeNode treeNode = treeNodes.pop();
Integer min = minStack.pop();
Integer max = maxStack.pop();
if (treeNode == null) {
continue;
}
int val = treeNode.val;
if (min != null && val <= min) {
return false;
}
if (max != null && val >= max) {
return false;
}
pushData(treeNode.left, min, val);
pushData(treeNode.right, val, max);
}
return true;
}
public void pushData(TreeNode treeNode, Integer min, Integer max) {
treeNodes.push(treeNode);
minStack.push(min);
maxStack.push(max);
}
这两种解题思路的本质都是通过深度优先遍历的方式,遍历判断节点,如果所有节点都通过,说明这个树是二插搜索树。当然这道题也可以通过二插搜索树的一个非常重要的性质来寻找思路:
二插搜索树的中序遍历结果是一个从小到大的递增数列
这个性质实际上我们可以通过简单推理得出:中序遍历的遍历顺序是左中右,而对于二插搜索树来说,左子树总是比它小的,而右子树总是比它大的,所以它的中序遍历一定维持从小到大的顺序。在本题中,我们就可以通过该性质解决该问题。
中序遍历判断法
public boolean isValidBST(TreeNode root) {
if (root == null) {
return true;
}
List<Integer> ids = getCenterString(root);
for (int i = 0; i < ids.size() - 1; i++) {
if (ids.get(i) >= ids.get(i + 1)) {
return false;
}
}
return true;
}
public List<Integer> getCenterString(TreeNode node) {
List<Integer> result = new ArrayList<>();
if (node != null) {
result.addAll(getCenterString(node.left));
result.add(node.val);
result.addAll(getCenterString(node.right));
}
return result;
}
上述思路就是根据中序遍历是否维持从小到大的递增数列关系,判断该树是否二插搜索树。然而其实我们不必计算完整的中序遍历,我们也可以在遍历过程中进行判断,一旦出现非递增关系,立即返回false,因此可以对上述方案进行简单的优化:
public boolean isValidBST(TreeNode root) {
if (root == null) {
return true;
}
TreeNode temp = null;
Stack<TreeNode> stack = new Stack<>();
while (!stack.isEmpty() || root != null) {
while (root != null) {
stack.push(root);
root = root.left;
}
root = stack.pop();
if (temp != null && temp.val >= root.val) {
return false;
}
temp = root;
root = root.right;
}
return true;
}
上述方案即通过迭代法进行中序遍历,在遍历过程中,每次和前一个节点值进行比较,一单出现非递增关系,就返回false。
有了上面这些题的铺垫,下面我们来看另一道很像的题目:
题目描述
二叉搜索树中的两个节点被错误地交换。请在不改变其结构的情况下,恢复这棵树。(注:题目来自letCode 99.验证二插搜索树)
问题分析
有了上述案例的铺垫,这道题我想已经简单的很多。因为题目中明确说明有两个节点被错误地交换了地方,那么它的中序遍历结果一定不会呈现从小到大递增的关系,我们只需要找出中序遍历中被交换的那两个节点值,并找出这两个节点值对应的节点,将这两个节点的值进行交换即可。
public void recoverTree(TreeNode root) {
TreeNode x = null, y = null, temp = null;
Stack<TreeNode> stack = new Stack<>();
while (!stack.isEmpty() || root != null) {
while (root != null) {
stack.add(root);
root = root.left;
}
root = stack.pop();
if (temp != null && root.val < temp.val) {
x = root;
if (y == null) {
y = temp;
} else {
break;
}
}
temp = root;
root = root.right;
}
int val = x.val;
x.val = y.val;
y.val = val;
}
上述方法中我们使用迭代中序遍历的方法,找出二插搜索树中被交换的两个节点,并将该节点的值进行交换。其中上述方法中有一段代码比较巧妙,我列出来大概说明一下:
if (temp != null && root.val < temp.val) {
x = root;
if (y == null) {
y = temp;
} else {
break;
}
}
这里这样做是为了预防出现连续节点交换的情况:假如二插搜索树的中序遍历结果是123456,如果节点2和4交换,那么它的中序遍历结果就会变成:143256,这里我们可以很轻松的计算出交换的节点是2和4,但是如果是节点2和节点3交换,那么它的中序遍历结果就会变成132456,我们可以确定3是被交换的节点之一,但是2我们第一时间无法判断。
这里关于这两个交换节点的判断,我们可以得出两个简单的结论:
- 第一个大于前后节点值的节点一定是交换的
- 最后一个小于前后节点值的节点一定被交换的
因此上述代码通过 x 、y 来标记这两个节点,让 y 这个只能赋值一次的变量标记结论1中对应的节点,因为它一定是被交换的。让 x 这个可以二次赋值的标记结论2中对应的节点,因为它可能是后续其它节点。
最后这里我们引入一个发散思维的问题:二插搜索树交换任意任意两个节点之后,一定不是二插搜索树吗?
答案是肯定的,因为二插搜索树中任意两个节点都有大小关系,如果交换这两个节点值,会导致大小关系破裂,进而不再是二插搜索书
解决上述问题的方案其实不只这一种,理论上所有可以进行中序遍历的方法都可以解决该问题。关于中序遍历的常见方法,可以点击这里查看我之前的博客。
上述题目都是和中序遍历有密切关系的题目,下面这道题目和中序遍历密切相关,又不那么相关。
题目描述
输入一个整数数组,判断该数组是不是某二叉搜索树的后序遍历结果。如果是则返回
true
,否则返回false
。假设输入的数组的任意两个数字都互不相同。(注:题目来自剑指offer 33.二叉搜索树的后序遍历序列)
问题分析
拿到这个问题,我第一反应还是中序遍历。我刚开始思路是这样的:将参数数组从小到大排列,那么排列结果肯定是该二插搜索树的中序遍历,我根据中序遍历和后序遍历进行判断,试试能否组成二叉树即可。写到一半的时候,我发现我把问题复杂化了,因为我们知道中序遍历是左中右的顺序,而后序遍历是左右中的顺序,为什么我们不能直接拿后序遍历进行判断呢?也就是说,我们把数组最后一个元素插入到数组中,保证左边所有节点小于它的值,右边所有节点大于它的值,而它的左右子树也是二插搜索树,也就是说所有左节点和右节点也满足该性质,因此只需要递归的遍历所有情况即可。
递归计算法
public boolean verifyPostorder(int[] postorder) {
if (postorder == null || postorder.length == 0) {
return true;
}
return echo(postorder, 0, postorder.length - 1);
}
public boolean echo(int[] nums, int start, int end) {
if (start >= end) {
return true;
}
int val = nums[end];
int temp = -1;
for (int i = start; i < end; i++) {
if (temp == -1 && nums[i] > val) {
temp = i;
}
if (temp != -1 && nums[i] < val) {
return false;
}
}
if (temp == -1) {
temp = end - 1;
}
return echo(nums, start, temp) && echo(nums, temp + 1, end - 1);
}
上述代码阅读起来也比较简单,找到插入点,如果满足,就继续计算它的左右子树是否满足。如果没有找到插入点,说明数组最后一个元素现在是最大的,它没有右子树,判断左子树即可。
上述题目都是和二插搜索树的判断有关的,下面我们列出两道其它画风的二插搜索树题目扩宽一下大家的思维:
题目描述
给定一个整数 n,生成所有由 1 ... n 为节点所组成的二叉搜索树。(注:题目来自剑指LetCode 95.不同的二插搜索树)
问题分析
这里我们根据二插搜索树的根节点将二插搜索树一分为二,假如我们以 T 作为二插搜索树的根节点,那么1~T-1 就是左子树的所有值,而 T+1~n 就是右子树的所有值。我们交叉组合所有可能出现的情况,并遍历所有可能作为根节点的情况,那么所得到的的结果就是题目所求。这里我们通过递归来解决该问题:
递归判断法
public List<TreeNode> generateTrees(int n) {
List<TreeNode> result = new ArrayList<TreeNode>();
if (n == 0) {
return result;
}
return echo(1, n);
}
public List<TreeNode> echo(int start, int end) {
List<TreeNode> result = new ArrayList<>();
if (start > end) {
result.add(null);
return result;
}
for (int i = start; i <= end; i++) {
List<TreeNode> leftNodes = echo(start, i - 1);
List<TreeNode> rightNodes = echo(i + 1, end);
for (int j = 0; j < leftNodes.size(); j++) {
for (int k = 0; k < rightNodes.size(); k++) {
TreeNode root = new TreeNode(i);
root.left = leftNodes.get(j);
root.right = rightNodes.get(k);
result.add(root);
}
}
}
return result;
}
这里我们递归方法的参数分别表示开始下标和结束下标,返回值表示可以组成的二插搜索树。根据该递归方法分别计算左子树的情况和右子树的情况,然后交叉组合即可。这里需要特别注意的一点就是如果判断为空的时候,一定要加null,因为左子树为空,右子树有值也是二插搜索树的一种,如果不加null的话,左子树的结果会按0计算,也就是说会错过左子树或者右子树为空的所有情况。
有了上面这道题的铺垫,最后我们在列出一道和上题类似,但是更有趣的题目。
问题描述
给定一个整数 n,求以 1 ... n 为节点组成的二叉搜索树有多少种?(注:题目来自剑指LetCode 96.不同的二插搜索树)
问题分析
这里我们当然可以根据上题的解法来解,将每次添加树修改为种类加一即可,但是这种解法又显得有点麻烦。列举种类的问题一般都不需要遍历所有的种类来解。我们换一种思维来考虑这个问题,假如一个二插搜索树只有一个节点,那么它只能有一种情况。假如一个二插搜索树只有两个节点,那么它也就只有两种情况。说到这里我们总结出,二插搜索树的种类和它节点值的大小没有关系,只和它节点的数量有关系。也就是说,我们不需要遍历所有二插搜索树的情况,只需要知道它的左右子树的节点数量即可。那么节点数量为3的二插搜索树一共有多少种呢:节点数量为3的二插搜索树只有以下三种情况:
- 左子树1个节点,右子树一个节点
- 左子树2个节点,右子树为空
- 左子树为空,右子树两个节点
根据上面的推理,也就有
(1 * 1)+(2 * 1)+(1 * 2)= 5 种情况。
我们用 a[n] 来表示长度为 n 的二插搜索树一共有几种情况。那么就可以得出以下公式:
因此我们就可以通过dp的方式解决该问题:
动态规划
public int numTrees(int n) {
int[] record = new int[n + 1];
record[0] = 1;
record[1] = 1;
for (int i = 2; i <= n; i++) {
for (int j = 0; j <= i - 1; j++) {
record[i] = record[i] + record[j] * record[i - j - 1];
}
}
return record[n];
}
未完待续。。。