今天來認真談談「樹」的各種遍歷方式以及深入理解下遞迴的思維方式

迷失技術de小豬發表於2021-07-05

LeetCode樹提計劃開始有幾天了。

今天對「樹」的進度做一個簡短的小結,群裡親愛的小夥伴進行的怎麼樣了呢?我這邊預計在整個「樹」的階段,預計會進行四個小結以及一個完整的覆盤,所以,應該是 5 份總結資料。

分佈如下:

  • 「樹」的基礎遍歷,重點在於「樹」的遞迴的理解
  • 模組1:基礎遍歷,對LeetCode中進行刷題標記
  • 模組2:遍歷變種-自頂向下,對這些題目進行解釋和程式碼編寫
  • 模組3:遍歷變種-非自頂向下,同樣也是對這些題目進行解釋和程式碼編寫
  • 最終的覆盤總結「最重要」

還是把我們們的計劃列出來:

刷題路線圖.png

所以,今天會是先序、中序、後續、層次遍歷的基礎程式碼編寫

今天內容相對來說比較容易,就是「樹」的 4 種遍歷。但是,再強調,多看看遞迴的寫法,多深入理解遞迴的程式碼流程,因為,可以說這是後面大多數題目的基礎思維邏輯。

後面基本都會使用Python來進行程式碼的邏輯實現,比較容易以及大眾,畢竟演算法方面學習的是思想,至於怎麼實現的,任何語言都可以對其進行復現

今天是「樹」的遍歷,我們們先來定義一個樹的結構類,以及一顆完整的二叉樹!

# 樹結點類
class TreeNode:
    def __init__(self, value):
        self.value = value
        self.left = None
        self.right = None

構建一棵完整的二叉樹:

if __name__ == "__main__":
    # 新建節點
    root = TreeNode('A')
    node_B = TreeNode('B')
    node_C = TreeNode('C')
    node_D = TreeNode('D')
    node_E = TreeNode('E')
    node_F = TreeNode('F')
    node_G = TreeNode('G')
    node_H = TreeNode('H')
    node_I = TreeNode('I')

    # 構建二叉樹
    #        A
    #      /   \
    #     B     C
    #    / \   / \
    #   D   E F   G
    #  / \
    # H   I

    root.left, root.right = node_B, node_C
    node_B.left, node_B.right = node_D, node_E
    node_C.left, node_C.right = node_F, node_G
    node_D.left, node_D.right = node_H, node_I

首先有一個小提醒:

今天的程式碼會使用或者佇列來輔助實現,在 Python 中,這裡使用 list 來操作

# 棧
stack = []
# 棧 - 壓棧
stack.append('結點')
# 棧 - 彈出棧頂元素
stack.pop()

# 佇列
queue = []
# 棧 - 入隊
queue.append('結點')
# 棧 - 出隊
queue.pop(0)

甜點

很甜,試著深入理解遞迴

遞迴在很多人看來不容易理解,尤其是處於學生時期的同學,以及一些初學者。其實很多工作幾年的人也不是太容易理解遞迴,而且遞迴有時候真的會很不容易解釋,非得自己去想清楚才能真正轉化為自己的一個思維邏輯。

這裡我想試著說說看,能不能說清楚,咳、、、儘量吧...

我們們這裡用後續遍歷舉例子,其他遞迴方式自己燒腦理解哈!

樹的遍歷-1.png

核心程式碼(以下用程式碼1、2、3、4來表示每一行):

def post_order_traverse(root):
    程式碼1 | if not root: return							
    程式碼2 | post_order_traverse(root.left)
    程式碼3 | post_order_traverse(root.right)
    程式碼4 | print(root.value, end=" ")

當執行到圖中步驟 1 的時候,一定是執行了程式碼1程式碼2,遞迴呼叫到最後,判斷結點H左右孩子都為空,執行了 if not root: return,隨後又執行了程式碼4,將結點 H 列印了出來。

同理,當執行到圖中的步驟 2 的時候,也是相同的邏輯,遞迴呼叫,判斷兩個孩子都為空,直接返回,隨後將結點 I 列印了出來。

再往上,結點H 和 結點I 列印並且返回之後,進行回溯,將 結點D 進行列印

依次類推...

(上述描述理解起來還是不太容易,有需要討論的,下面直接加我微信,備註“LeetCode刷題”,我拉群裡一起討論哈)

【我的二維碼】

先序遍歷

遞迴遍歷過程:

a. 訪問根節點

b. 先序遍歷其左子樹;

c. 先序遍歷其右子樹;

然後就是一直遞迴下去,在訪問到節點的時候,可以進行節點的相關處理,比如說簡單的訪問節點值

下圖是一棵二叉樹,我們來手動模擬一下遍歷過程

2.二叉樹的先序遍歷(遞迴和非遞迴)-1.png

按照上圖中描述,根據順序能夠得到它的一個先序遍歷的過程,得到先序遍歷序列:

A B D H I E C F G 

復現上述邏輯:

class Solution:
    def pre_order_traverse(self, root):
        if not root:
            return
        print(root.value, end=" ")
        self.pre_order_traverse(root.left)
        self.pre_order_traverse(root.right)

在整個遞迴中,看似整齊,閱讀性極高的 3 行程式碼,其實對於初學者來說,腦子裡理解它的的實現流程是比較困難的!

如果不太清晰,建議深入理解上面給到的【甜點】,用心理解,不懂的可以群裡直接討論哈!

下面再看看非遞迴的遍歷過程:

a. 訪問根結點。
b. 判斷是否有右孩子,如果有右孩子,壓棧
c. 判斷否則有左孩子,如果有左孩子,訪問它,否則,彈出棧頂元素
d. 迴圈執行 2 和 3

非遞迴的遍歷,重點在於利用來實現將稍後要訪問結點入棧,先遍歷根結點,再將右孩子入棧,最後訪問左孩子這樣的思想

class Solution:  
    def pre_order_traverse_no_recursion(self, root):
        if not root:
            return
        stack = [root]
        while stack:
            print(root.value, end=" ")  	# 訪問根結點
            if root.right:
                stack.append(root.right)  # 判斷是否有右孩子,如果有右孩子,壓棧
            if root.left:  								# 判斷否則有左孩子,如果有左孩子,訪問它,否則,彈出棧頂元素
                root = root.left
            else:
                root = stack.pop()

這種思路其實也是遞迴的變形,將遞迴中使用到的棧自己定義了出來。

中序遍歷

我們們還是先來遞迴的實現流程

a. 先序遍歷其左子樹

b. 訪問根節點

c. 先序遍歷其右子樹

然後就是一直遞迴下去,在訪問到節點的時候,可以進行節點的相關處理,比如說簡單的訪問節點值

下圖是一棵二叉樹,我們來手動模擬一下中序遍歷過程

3.二叉樹的中序遍歷(遞迴和非遞迴)-1.png

按照上述中序遍歷的遞迴過程,得到中序遍歷序列:

H D I B E A F C G 

下面繼續用 Python 來複現上述邏輯:

class Solution:
    def in_order_traverse(self, root):
        if not root:
            return
        self.in_order_traverse(root.left)
        print(root.value, end=" ")
        self.in_order_traverse(root.right)

和先序遍歷很類似,只是把要被訪問結點的 print 語句進行了位置置換。

下面再來看中序遍歷的非遞迴過程:

a. 當遍歷到一個結點時,就壓棧,然後繼續去遍歷它的左子樹;
b. 當左子樹遍歷完成後,從棧頂彈出棧頂元素(左子樹最後一個元素)並訪問它;
c. 最後按照當前指正的右孩子繼續中序遍歷,若沒有右孩子,繼續彈出棧頂元素。

class Solution:
  	def in_order_traverse_no_recursion(self, root):
        if not root:
            return
        stack = []
        while stack or root:
            while root:
                stack.append(root)
                root = root.left
            if stack:
                tmp = stack.pop()
                print(tmp.value, end=" ")
                root = tmp.right

相信上述的 3 個步驟已經說的足夠清楚了,但是還是用更加樸素的語言簡單描述一下:

中序遍歷的非遞迴過程也是利用了一個「棧」來實現,由於是中序遍歷,那麼首先要訪問左孩子,進而一定要把每個子結構的根結點入棧,然後訪問左孩子,彈出棧頂元素(訪問根結點),再進行訪問右孩子,訪問右孩子的時候,繼續將每個子結構的根結點入棧,然後訪問左孩子...這樣迴圈下去,直到棧為空或者指向的根結點為空。

後續遍歷

依然先用遞迴來實現

a. 先序遍歷其左子樹

b. 先序遍歷其右子樹

c. 訪問根節點

然後就是一直遞迴下去,在訪問到節點的時候,可以進行節點的相關處理,比如說簡單的訪問節點值

下圖是一棵二叉樹,我們來手動模擬一下後序遍歷過程

4.二叉樹的後序遍歷(遞迴和非遞迴)-1.png

按照上述後序遍歷的過程,得到後序遍歷序列:

H I D E B F G C A

我們們用程式碼來實現一下邏輯:

class Solution:
    def post_order_traverse(self, root):
        if not root:
            return
        self.post_order_traverse(root.left)
        self.post_order_traverse(root.right)
        print(root.value, end=" ")

依然是很簡潔,依然是將訪問結點的程式碼語句的位置進行了調整。

下面來輪到非遞迴來實現的流程

後續遍歷的非遞迴過程比較曲折,後續遍歷需要先訪問左右子結點後,才能訪問該結點,而這也是非遞迴的難點所在。可以使用一個輔助棧來實現,但理解起來沒有使用 2 個棧實現起來清晰,今天就用 2 個棧來實現非遞迴的後續遍歷。

藉助2個棧:s1 和 s2
a. 初始化根結點到s1中
b. 將 s1 棧頂元素 T 彈出,到棧 s2 中
c. 判斷 T 是否有左右孩子,如果有依次入棧 s1,否則,執行 b

下面藉助圖,還是一樣的樹結構,來梳理一下思路(長圖發放,耐心看完,看完之後會發現思路很清晰):

4.二叉樹的後序遍歷(遞迴和非遞迴)-2.jpg

有了這個思路就應該會很清晰了,下面就按照這個思維邏輯來編寫程式碼:

class Solution:
    def post_order_traverse_no_recursion1(self, root):      
        s1, s2 = [], []
        s1.append(root)             # 初始化根結點到S1中
        while s1:
            T = s1.pop()            # 將 S1 棧頂元素 T 彈出,到棧 S2 中
            s2.append(T)
            if T.left:              # 判斷 T 是否有左右孩子,如果有依次入棧 s1
                s1.append(T.left)
            if T.right:
                s1.append(T.right)
        while s2:
            print(s2.pop().value, end=" ")

看起來 2 個棧像是在忽悠人,其實思路很清晰,程式碼很容易就實現了!

層次遍歷

層次遍歷屬於 BFS 的範疇,層次遍歷就是按照「樹」的層級進行每一層的掃蕩。

遍歷從根結點開始,首先將根結點入隊,然後開始執行迴圈:

  1. 將頭結點入隊
  2. 彈出隊首元素,如果被彈出的隊首元素有左右孩子,將它們依次入隊
  3. 迴圈第 2 直到佇列為空

下面藉助一幅圖來描述其遍歷過程:

5.二叉樹的層次遍歷.jpg

這樣是不是很清晰,有時候會覺得這種長圖會比動圖好看一些,能清晰看到每一步,而且中間可以有很詳細的解釋。關於影像展示方面大家可以給出參考意見,這方面確實可以更進一步。

先看程式碼吧:

class Solution:
    def level_order_traverse(self, head):
        if not head:
            return
        queue = [head]
        while len(queue) > 0:
            tmp = queue.pop(0)
            print(tmp.value, end=" ")
            if tmp.left:
                queue.append(tmp.left)
            if tmp.right:
                queue.append(tmp.right)

今天全部描述完畢!

最後

1.深入理解遞迴,一定一定多思考,咳咳、、我都上了每天上午10點的鬧鈴了(書讀百遍,其義自見);

2.「樹」的非遞迴遍歷所引導的思維方式很重要;

3.下期進行【基礎遍歷】中LeetCode題目羅列以及利用樹遞迴的方式,會產生一些計算樹相關的變形問題

注:後面會把程式碼放到github中,今天可以後臺私信「樹」進行程式碼獲取哈!

相關文章