概要
二插搜索樹:它或者是一棵空樹,或者是具有下列性質的二叉樹: 若它的左子樹不空,則左子樹上所有結點的值均小於它的根結點的值; 若它的右子樹不空,則右子樹上所有結點的值均大於它的根結點的值; 它的左、右子樹也分別爲二叉排序樹。
下面我列舉出幾道最近刷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];
}
未完待續。。。