前言
標題用“有套路”來形容一種數據結構,似乎有點不尊重的意思。不過,我倒是覺得,一種實用的學科,就是應該產生一點套路,這才能發揮體系化研究的優勢,套路就是一種保證:在不投入更多創造性與努力的情況下,依舊能獲得比起隨意進行相關操作更好的結果。一門成熟的學科都應如是,如果研究許久,在學科所研究的許多問題的實踐上還不如一些“天賦”“靈感”,那就不得不說這門學科的“僞科學”或者“水分”還是蠻大的了。
言歸正傳,這篇文章將會是一系列尋找算法與數據結構的文章的開篇,樹由於其特性,是遞歸、分治等等重要算法思想的典型載體,同時套路性較強又具有一定規律和難度,上手後,也可以獲得總結其他算法“套路”的必要經驗。作爲一個訓練的開頭,還是很合適了。
樹的定義與理解
先簡要談談樹的抽象定義:樹本質上是一種無向圖(圖:由頂點與路徑構成的數據結構),其中,任意兩個頂點之間有且只有一條路徑。簡而言之,樹是一種具有特殊性質的圖。
樹的結構非常直觀,而且樹的大多數結構具有一個重要性質:遞歸。主要來說,就是樹具有某一性質時,往往其子樹也具有同樣的性質。比如說,一個樹如果是二叉搜索樹,其子樹也必須是二叉搜索樹。
根據這樣的性質,遇到樹的問題,很自然會考慮如何合理使用遞歸算法,其實質就是:分解爲子問題,最後解決基本情況,把複雜的遞歸過程交給計算機來處理。所以,樹類型代碼的特點就是簡潔(不過換句話說,由於太簡潔,很多思維過程又交給了計算機,有時候寫對了沒有都不知道:) )
所以,面試官往往是在用樹來考察你對遞歸算法的理解。
一般來說,樹具有以下可能的形狀: 普通二叉樹、平衡二叉樹(即葉子節點的深度相差不超過1)、完全二叉樹(除了最後一層外,每層節點全部填滿,最後一層若不滿,必須是右邊的節點缺少;每層都填滿的,被稱爲完美二叉樹)、四叉樹(quadtree,即每個節點最多有四個孩子)、N叉樹(每個節點最多有N個孩子)。
樹的遍歷
先談談爲什麼要遍歷。其實對於一個數據結構的基礎算法(不討論那些奇技淫巧),重要的不過增查改刪(CRUD)。對於樹這樣一個結構,在沒有先驗知識的情況下,爲了使得代碼量可控,考慮到樹的結構是固定的(或者說每個節點/子樹都具有一樣的結構),我們就得依照其連接關係藉助遞歸方法,以一種特定的順序進行訪問。當全部的節點都被訪問時,這樣的操作就是一個遍歷。
前序遍歷(pre-order traversal):
訪問順序:根-左子樹-右子樹
簡單實現代碼:
1public void preorderTraverse(TreeNode root){
2 visit(root); // 此步執行遍歷的功能,比如說打印、比較、存儲等等
3 preorderTraverse(root.left);
4 preorderTraverse(root.right);
5}
應用場景:在樹中進行搜索、創建一棵新的樹(沒有根,左右子樹創建後不便於連接)。
中序遍歷(inorder Traverse)與後序遍歷(postorder Traverse):
基本同前序遍歷,只是順序上,中序是左-根-右,後序是左-右-根。
中序遍歷可寫作這樣的遞歸形式:
1public void inorderTraverse(TreeNode root){
2 inorderTraverse(root.left);
3 visit(root); // 此步執行遍歷的功能,比如說打印、比較、存儲等等
4 inorderTraverse(root.right);
5}
可以看到,此時也並不複雜,只是單純地換序而已,將在根處的操作移動到了中間,規律明顯,代碼也易於閱讀。
對於中序遍歷,其應用場景最常見的是二叉搜索樹,按這樣的順序遍歷出來,輸出的結果是按大小順序的(比如說遞增)。
關於後序遍歷,如果在對某個節點進行分析時,需要其左右子樹的信息(大小、存在與否等等),那麼可以考慮後序遍歷,彷彿就是在修剪一棵樹的葉子,可以從外向內不斷深入修剪。
由於三種遍歷說的前中後都是針對根節點,國內也有教材把上述遍歷方式稱爲,先根遍歷、中根遍歷、後根遍歷,前中後決定的只是根的位置,左子樹都在右子樹之前,這樣記憶可能簡單些(雖然最好的記憶方法永遠是實現幾個代碼)。
不過,遞歸寫法雖然容易,非遞歸寫法也需要掌握。
這裏提供一種前序遍歷的非遞歸寫法的分析思路:
主要考慮用棧來模擬上面的遞歸(遞歸本質上也是操作系統/OS在幫你壓棧,所以遞歸深度過深時報錯也是所謂“stack overflow”,即棧溢出):
以下出自leetcode144題,即二叉樹前序遍歷:
1public List<Integer> preorderTraversal(TreeNode root){
2 List<Integer> res = new ArrayList<>(); // 用於保存遍歷結果
3 Stack<TreeNode> stack = new Stack<>();
4 TreeNode curr = root; // 用於指示當前節點
5 while(curr != null || !stack.isEmpty()){
6 if(curr != null){
7 res.add(curr.val);
8 stack.push(curr);
9 curr = curr.left; // 考慮左子樹
10 } else{
11 // 節點爲空,彈棧,回溯到上一層
12 curr = stack.pop();
13 // 此時考慮右子樹
14 curr = curr.right;
15 }
16 }
17 return res;
18}
類似地,中序遍歷可以寫作以下非遞歸形式,除了稍微改變順序以外,幾乎跟前序遍歷一樣:
1public List<Integer> inorderTraversal(TreeNode root) {
2 List<Integer> res = new ArrayList();
3 Stack<TreeNode> stack = new Stack();
4 TreeNode curr = root; // 指示當前位置
5
6 while(curr != null || !stack.isEmpty()){
7 if(curr != null){
8 stack.push(curr);
9 curr = curr.left; //先入棧,並指向左節點
10 } else{
11 //當前節點爲空時,出棧,並進行操作,隨後指向右節點
12 curr = stack.pop();
13 res.add(curr.val);
14 curr = curr.right;
15 }
16 }
17
18 return res;
19 }
不同於上面兩個,後序遍歷的非遞歸略微難寫一點,主要是因爲需要考慮根節點的問題,在整個遍歷過程中,根節點會被訪問兩次,需要判斷是否右子樹已經被訪問過了,才能確認根節點是否需要被保存。最簡單粗暴的想法就是用hashset保存已經訪問過的節點:
1class Solution {
2 public List<Integer> postorderTraversal(TreeNode root) {
3 List<Integer> res = new ArrayList();
4 Stack<TreeNode> stack = new Stack();
5 Set<TreeNode> set = new HashSet<>();
6 TreeNode curr = root;
7
8 while(curr != null || !stack.isEmpty()){
9 // 首先不斷向左子樹運動
10 while(curr != null && !set.contains(curr)){
11 stack.push(curr);
12 curr = curr.left;
13 }
14 // 此時不能直接保存根節點再去右子樹,所以只能利用peek來尋找右子樹
15 curr = stack.peek();
16 // 若右子樹爲空,或者已經訪問過一次這個節點,可以彈出
17 if(curr.right == null || set.contains(curr)){
18 res.add(curr.val);
19 set.add(curr);
20 stack.pop();
21 // 若棧此時已經彈空,可以返回結果
22 if(stack.isEmpty()){
23 return res;
24 }
25 curr = stack.peek();
26 curr = curr.right;
27 } else{
28 // 若不然,先向右移動,保存該點到hashset中確認已經訪問過一次
29 set.add(curr);
30 curr = curr.right;
31 }
32 }
33 return res;
34 }
35}
上述解法是利用hashset來保存根節點是否已經被越過一次,但是重要的其實只是確認根節點的右子樹是否被訪問過,所以只需要知道上一個節點是不是其右子樹就行,因此保留一個上一個節點的變量即可:
1class Solution {
2 public List<Integer> postorderTraversal(TreeNode root) {
3 List<Integer> res = new ArrayList();
4 Stack<TreeNode> stack = new Stack();
5 TreeNode curr = root;
6 TreeNode pre = null;
7
8 while(curr != null || !stack.isEmpty()){
9 if(curr != null){
10 stack.push(curr);
11 curr = curr.left;
12 } else{
13 TreeNode temp = stack.peek();
14 // 考慮是否變爲右子樹
15 if(temp.right != null && temp.right != pre){
16 curr = temp.right;
17 } else{
18 res.add(temp.val);
19 pre = temp;
20 stack.pop();
21 }
22 }
23 }
24 return res;
25 }
26}
官方題解的方法則更爲巧妙一點,即考慮將相關的內容逆序保存,這樣巧妙地規避了根節點不便處理的問題:實現一個右、左、中的保存順序,實現時還是按中、右、左來進行,保存數字時逆序。
利用了LinkedList特有的addFirst方法,將新出現的數值插入到鏈表的開頭,同事時,由於stack的順序是先進後出,所以看似是中、左、右,出棧後又是中、右、左,由於保存的順序是每次加入到鏈表開頭,所以實際上進行了一個逆序,完成了一個左、右、中的過程:
1class Solution {
2 public List<Integer> postorderTraversal(TreeNode root) {
3 LinkedList<TreeNode> stack = new LinkedList<>();
4 LinkedList<Integer> output = new LinkedList<>();
5 if (root == null) {
6 return output;
7 }
8
9 stack.add(root);
10 while (!stack.isEmpty()) {
11 TreeNode node = stack.pollLast();
12 output.addFirst(node.val);
13 if (node.left != null) {
14 stack.add(node.left);
15 }
16 if (node.right != null) {
17 stack.add(node.right);
18 }
19 }
20 return output;
21 }
22}
除了使用那麼複雜的辦法,也有一種“投機取巧”的辦法,就是先做中-右-左的遍歷,然後把遍歷結果逆序,也就得到了後序遍歷結果了。修改我們上面出現過的前序遍歷代碼也不難得到類似結果:
1public List<Integer> postorderTraversal(TreeNode root){
2 List<Integer> res = new ArrayList<>(); // 用於保存遍歷結果
3 Stack<TreeNode> stack = new Stack<>();
4 TreeNode curr = root; // 用於指示當前節點
5 while(curr != null || !stack.isEmpty()){
6 if(curr != null){
7 res.add(curr.val);
8 stack.push(curr);
9 curr = curr.right; // 考慮右子樹
10 } else{
11 // 節點爲空,彈棧,回溯到上一層
12 curr = stack.pop();
13 // 此時考慮右子樹
14 curr = curr.left;
15 }
16 }
17 Collections.reverse(res); // 直接使用內置的逆序,不必在插入時一一進行操作
18 return res;
19}
當然,如果要多做一點優化,可以模仿前一個結果,在添加到res時使用addFirst方法。對於java實現,這裏有一個小細節,在聲明時,必須使用LinkedList,因爲List不提供addFirst方法接口。
經典例題回顧:
講了基本的定義與三個遍歷的遞歸與非遞歸寫法,其實樹的基礎知識已經足夠了,基本上遇到題目稍做變化就行了,不信?直接上一些看似複雜的題目看看吧。
leetcode250題(後序遍歷):統計有多少個子樹擁有相同的數字。
Given a binary tree, count the number of uni-value subtrees.
A Uni-value subtree means all nodes of the subtree have the same value.
給定一個二叉樹,統計其uni-value子樹的個數。
一個uni-value子樹指的是這個子樹的全部節點的值相同。
一段有問題的代碼(對於測試樣例[5,1,5,5,5,null,5],正確答案4,輸出結果2):
1class Solution {
2 int count = 0;
3 public int countUnivalSubtrees(TreeNode root) {
4 postorder(root); // 從根節點開始遍歷
5 return count;
6 }
7
8 public boolean postorder(TreeNode root){
9 if(root == null) return true;
10 if(postorder(root.left) && postorder(root.right)){
11 if(root.left != null && root.left.val != root.val) return false;
12 if(root.right != null && root.right.val != root.val) return false;
13 count++;
14 return true;
15 }
16 return false;
17 }
18}
可通過的代碼:
1class Solution {
2 int count = 0;
3 public int countUnivalSubtrees(TreeNode root) {
4 postorder(root);
5 return count;
6 }
7
8 public boolean postorder(TreeNode root){
9 if(root == null) return true;
10 if(postorder(root.left) & postorder(root.right)){
11 if(root.left != null && root.left.val != root.val) return false;
12 if(root.right != null && root.right.val != root.val) return false;
13 count++;
14 return true;
15 }
16 return false;
17 }
18}
在完成這題的過程中,我犯了一些錯誤,主要在於沒有深刻理解遞歸:
- 在使用判斷條件postorder(root.left)以及postorder(root.right)時,實際上已經發生了遞歸;(所以假定這題是前序遍歷或中序遍歷會很難完成代碼的構造)
- 在java中,使用&&會出現短路,即前半部分已經false了之後,會自動不執行後半部分,因此爲了邏輯的正確,此處只能使用&。
leetcode230題:
求在二叉搜索樹(BST)中第k小的值,本題主要考慮使用中序遍歷,因爲二叉搜索樹中序遍歷是一個遞增的數列:
唯一需要注意的就是左子樹遍歷完成後,有可能還沒有達到k,那麼可以設定Integer.MAX_VALUE作爲沒有找到的標誌,防止未找到。
這個算法遍歷的時間空間複雜度是O(k),最壞的情況下有可能達到O(N)。(在leetcode平臺上打敗了100%的java程序)
1class Solution {
2 int count = 0;
3 public int kthSmallest(TreeNode root, int k) {
4 if(root.left != null){
5 int res = kthSmallest(root.left, k);
6 if(res != Integer.MAX_VALUE) return res; // 若未能尋找到,應繼續向右搜索
7 }
8 count++;
9 if(count == k) return root.val;
10 if(root.right != null) return kthSmallest(root.right, k); // 題目中保證了能搜索到,所以此處不必再加判斷
11
12 return Integer.MAX_VALUE; // 若在左側節點未尋找到,返回這個標誌
13 }
14}
相比之下,官方題解則先將全部的節點遍歷到一個數組中,然後再尋找第k個,問題就在於將時間複雜度因而擴大到了O(N)。(使用這個方法,時間上只能打敗49.01%的程序)
1class Solution {
2 public ArrayList<Integer> inorder(TreeNode root, ArrayList<Integer> arr) {
3 if (root == null) return arr;
4 inorder(root.left, arr);
5 arr.add(root.val);
6 inorder(root.right, arr);
7 return arr;
8 }
9
10 public int kthSmallest(TreeNode root, int k) {
11 ArrayList<Integer> nums = inorder(root, new ArrayList<Integer>());
12 return nums.get(k - 1);
13 }
14}
leetcode366題:
Given a binary tree, collect a tree's nodes as if you were doing this: Collect and remove all leaves, repeat until the tree is empty.
給定一個二叉樹,返回一個樹的節點的集合的集合,每次都收集並移除當前的所有的葉子節點,直到整個樹爲空。
這是一個很有趣的題目,因爲如果從上向下遍歷,是不難的,直接使用BFS(寬度優先搜索),這樣的方式又叫“層次遍歷”(level-order traversal);然而,從下往上則無這個順序,左右子樹的深度非常有可能不同,而葉子節點只需要左右子樹不存在即可。
本題可採用了剛纔所謂的“剝洋蔥”的辦法,因此可以先考慮後序遍歷(本題算是一種變體):
1class Solution {
2 public List<List<Integer>> findLeaves(TreeNode root) {
3 List<List<Integer>> res = new ArrayList();
4 while(root != null){
5 List<Integer> curr = new ArrayList();
6 root = upward(root, curr);
7 res.add(curr);
8 }
9 return res;
10 }
11
12 public TreeNode upward(TreeNode root, List<Integer> curr){
13 // 函數的功能是將所有葉子節點保存到curr中,然後把葉子節點置爲null
14 if(root == null) return null;
15 if(root.left == null & root.right == null){
16 // 如果是葉子節點,加入curr,然後返回null
17 curr.add(root.val);
18 return null;
19 }
20 // 如果不是葉子節點,遞歸調用其左右子樹,保存新的情況
21 root.left = upward(root.left, curr);
22 root.right = upward(root.right, curr);
23 return root;
24 }
25}
這一題算是使用了一種變體方法,不過本質上還是一種後序遍歷。
當然,這題也可以不修改樹,直接考慮DFS(深度優先搜索)再回溯,但是就需要引入一個判斷深度的指標來進行控制了:
1class Solution {
2 public List<List<Integer>> findLeaves(TreeNode root) {
3 List<List<Integer>> res = new ArrayList();
4 postorder(root,res);
5 return res;
6 }
7
8 public int postorder(TreeNode root, List<List<Integer>> res){
9 if(root == null) return -1; // 空節點置爲-1,因爲葉子節點是0
10 // 遞歸調用獲取當前深度(準確的說,其實是從葉子節點開始的高度)
11 int depth = 1 + Math.max(postorder(root.left,res),postorder(root.right,res));
12 // 根據java語法,如果還沒有list對象,需要先創建
13 if(depth >= res.size()) res.add(new ArrayList());
14 // 將節點加入對應深度,由於遞歸必然從最深處開始,順序不會有問題
15 res.get(depth).add(root.val);
16 return depth;
17 }
18}
leetcode285:
Given a binary search tree and a node in it, find the in-order successor of that node in the BST.
The successor of a node
p
is the node with the smallest key greater thanp.val
.給定一個二叉搜索樹,並給定一個節點,尋找這個節點的中序遍歷的後續節點。
尋找二叉搜索樹的中序遍歷後繼,可以從遞歸和迭代兩種方法完成,由於已經暗示明白了,直接使用中序遍歷即可解決這一問題:
遞歸法依舊有容易閱讀的優點,不過構造時不必想太複雜,直接使用前序節點來保存前一個數據即可。(此時已經達到了不錯的結果,時間達到了92%)
1class Solution {
2 TreeNode pre = null;
3 TreeNode ans = null;
4 public TreeNode inorderSuccessor(TreeNode root, TreeNode p) {
5 if(root == null || p == null) return null;
6 inorderSuccessor(root.left, p);
7 if(pre == p){ // 條件也可改爲 pre != null && pre.val == p.val
8 // 如果其前繼節點已經滿足條件,則保存結果
9 ans = root;
10 }
11 pre = root;
12 inorderSuccessor(root.right,p);
13 return ans;
14 }
15}
非遞歸方法:這一方法直接改自中序遍歷,不過實際上沒有充分利用BST暗含的規律,所以速度不是最快的,僅僅達到了28.88%。
1class Solution {
2 public TreeNode inorderSuccessor(TreeNode root, TreeNode p) {
3 if(curr == null || p == null) return null;
4 Stack<TreeNode> stack = new Stack();
5 TreeNode curr = root;
6 boolean foundP = false;
7 while(root != null || !stack.isEmpty()){
8 if(curr != null){
9 stack.push(curr);
10 curr = curr.left;
11 } else{
12 curr = stack.pop();
13 if(foundP == true){
14 return curr;
15 }
16 if(curr.val == p.val){
17 foundP = true;
18 }
19 curr = curr.right;
20 }
21 }
22 return null;
23 }
24}
不得不說,使用遍歷來解決樹問題,實在是頗有“一招鮮喫遍天”的感覺,甚至略加改進就能解決hard級的leetcode99題。
leetcode99題:
Two elements of a binary search tree (BST) are swapped by mistake.
Recover the tree without changing its structure.
一個二叉搜索樹的兩個元素被交換了。
復原這個樹,使得它符合BST結構,但不改變樹的整體結構。
這題就是恢復二叉搜索樹的問題。看到BST,幾乎是一箇中序遍歷的問題。本題需要注意的地方就是,被交換的節點存在兩種可能性,一種是相鄰節點的順序反了,那麼,直接交換值即可,另外一種是相隔的節點順序錯了,因此還需要保存第二次發生前序節點大於當前節點的位置。(以下主要由中序遍歷模板改出)
非遞歸:
1class Solution {
2 public void recoverTree(TreeNode root) {
3 Stack<TreeNode> stack = new Stack();
4 TreeNode prev = null, curr = root;
5 TreeNode first = null, second = null; // 用於保存改變的第一個和第二個位置
6
7 while(curr != null || !stack.isEmpty()){
8 if(curr != null){
9 stack.push(curr);
10 curr = curr.left;
11 } else{
12 curr = stack.pop();
13 // 如果發生了前序節點大於當前節點,分兩種情況
14 if(prev != null && prev.val > curr.val){
15 // 如果是第一次遇到,保存first爲前序,second爲當前
16 if(first == null){
17 first = prev;
18 second = curr;
19 } else{
20 // 如果第二次遇到,只重新保存當前值到second中
21 second = curr;
22 }
23 }
24 prev = curr;
25 curr = curr.right;
26 }
27 }
28 int temp = first.val;
29 first.val = second.val;
30 second.val = temp;
31 }
32}
遞歸:
1class Solution {
2 TreeNode first = null;
3 TreeNode second = null;
4 TreeNode prev = null;
5 public void recoverTree(TreeNode root) {
6 inorder(root);
7 int temp = first.val;
8 first.val = second.val;
9 second.val = temp;
10 }
11
12 public void inorder(TreeNode root){
13 if(root == null) return;
14 inorder(root.left);
15 // 對前序節點進行判斷
16 if(prev != null && prev.val > root.val){
17 // 第一次遇到則全部保存
18 if(first == null){
19 first = prev;
20 second = root;
21 } else{
22 // 第二次遇到只保存second
23 second = root;
24 }
25 }
26 // 更新prev節點
27 prev = root;
28 inorder(root.right);
29 }
30}
簡要總結
樹的題目大多來自三種遍歷的變體,一般而言,稍作一些修改就能得到一個能運行的結果(實在不行先全部遍歷一遍保存到數組裏來解決);不過,爲了得到最優解,往往還需要根據題目已有的一些條件或者性質,對遞歸進行一些優化(如結束遞歸的條件,遞歸的方式等等);有時遞歸不便於進行一些細節操作時,可以考慮用非遞歸寫法(當然,由於遞歸寫法解題有時太輕鬆,出於面試難度問題,面試官也往往會要求非遞歸,也算是爲學習非遞歸多一個理由吧!)。
原文出處:https://www.cnblogs.com/mingyu-li/p/12388360.html