樹—最“有套路”的數據結構

前言

標題用“有套路”來形容一種數據結構,似乎有點不尊重的意思。不過,我倒是覺得,一種實用的學科,就是應該產生一點套路,這才能發揮體系化研究的優勢,套路就是一種保證:在不投入更多創造性與努力的情況下,依舊能獲得比起隨意進行相關操作更好的結果。一門成熟的學科都應如是,如果研究許久,在學科所研究的許多問題的實踐上還不如一些“天賦”“靈感”,那就不得不說這門學科的“僞科學”或者“水分”還是蠻大的了。

言歸正傳,這篇文章將會是一系列尋找算法與數據結構的文章的開篇,樹由於其特性,是遞歸、分治等等重要算法思想的典型載體,同時套路性較強又具有一定規律和難度,上手後,也可以獲得總結其他算法“套路”的必要經驗。作爲一個訓練的開頭,還是很合適了。

樹的定義與理解

先簡要談談樹的抽象定義:樹本質上是一種無向圖(圖:由頂點與路徑構成的數據結構),其中,任意兩個頂點之間有且只有一條路徑。簡而言之,樹是一種具有特殊性質的圖。

樹的結構非常直觀,而且樹的大多數結構具有一個重要性質:遞歸。主要來說,就是樹具有某一性質時,往往其子樹也具有同樣的性質。比如說,一個樹如果是二叉搜索樹,其子樹也必須是二叉搜索樹。

根據這樣的性質,遇到樹的問題,很自然會考慮如何合理使用遞歸算法,其實質就是:分解爲子問題,最後解決基本情況,把複雜的遞歸過程交給計算機來處理。所以,樹類型代碼的特點就是簡潔(不過換句話說,由於太簡潔,很多思維過程又交給了計算機,有時候寫對了沒有都不知道:) )

所以,面試官往往是在用樹來考察你對遞歸算法的理解。

一般來說,樹具有以下可能的形狀: 普通二叉樹、平衡二叉樹(即葉子節點的深度相差不超過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}

在完成這題的過程中,我犯了一些錯誤,主要在於沒有深刻理解遞歸:

  1. 在使用判斷條件postorder(root.left)以及postorder(root.right)時,實際上已經發生了遞歸;(所以假定這題是前序遍歷或中序遍歷會很難完成代碼的構造)
  2. 在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 than p.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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章