Python資料結構——解析樹及樹的遍歷
解析樹
完成樹的實現之後,現在我們來看一個例子,告訴你怎麼樣利用樹去解決一些實際問題。在這個章節,我們來研究解析樹。解析樹常常用於真實世界的結構表示,例如句子或數學表示式。
圖 1:一個簡單句的解析樹
圖 1 顯示了一個簡單句的層級結構。將一個句子表示為一個樹,能使我們通過利用子樹來處理句子中的每個獨立的結構。
圖 2: ((7+3)*(5−2)) 的解析樹
如圖 2 所示,我們能將一個類似於 ((7+3)*(5−2)) 的數學表示式表示出一個解析樹。我們已經研究過全括號表示式,那麼我們怎樣理解這個表示式呢?我們知道乘法比加或者減有著更高的優先順序。因為括號的關係,我們在做乘法運算之前,需要先計算括號內的加法或者減法。樹的層級結構幫我們理解了整個表示式的運算順序。在計算最頂上的乘法運算前,我們先要計運算元樹中的加法和減法運算。左子樹的加法運算結果為 10,右子樹的減法運算結果為 3。利用樹的層級結構,一旦我們計算出了子節點中表達式的結果,我們能夠將整個子樹用一個節點來替換。運用這個替換步驟,我們得到一個簡單的樹,如圖 3 所示。
圖 3: ((7+3)*(5−2)) 的化簡後的解析樹
在本章的其餘部分,我們將更加詳細地研究解析樹。尤其是:
- 怎樣根據一個全括號數學表示式來建立其對應的解析樹
- 怎樣計算解析樹中數學表示式的值
- 怎樣根據一個解析樹還原數學表示式
建立解析樹的第一步,將表示式字串分解成符號儲存在列表裡。這裡有四種符號需要我們考慮:左括號,操作符和運算元。我們知道讀到一個左括號時,我們將開始一個新的表示式,因此我們建立一個子樹來對應這個新的表示式。相反,每當我們讀到一個右括號,我們就得結束這個表示式。另外,運算元將成為葉節點和他們所屬的操作符的子節點。最後,我們知道每個操作符都應該有一個左子節點和一個右子節點。通過上面的分析我們定義以下四條規則:
- 如果當前讀入的字元是
'('
,新增一個新的節點作為當前節點的左子節點,並下降到左子節點處。 - 如果當前讀入的字元在列表
['+', '-', '/', '*']
中,將當前節點的根值設定為當前讀入的字元。新增一個新的節點作為當前節點的右子節點,並下降到右子節點處。 - 如果當前讀入的字元是一個數字,將當前節點的根值設定為該數字,並返回到它的父節點。
- 如果當前讀入的字元是’)’,返回當前節點的父節點。
在我們編寫 Python 程式碼之前,讓我們一起看一個上述的例子。我們將使用 (3+(4*5))
這個表示式。我們將表示式分解為如下的字元列表:['(', '3', '+', '(', '4', '*', '5' ,')',')']
圖 4:解析樹結構的步驟圖
觀察圖 4,讓我們一步一步地過一遍:
- 建立一個空的樹。
- 讀如
(
作為第一個字元,根據規則 1,建立一個新的節點作為當前節點的左子結點,並將當前節點變為這個新的子節點。- 讀入
3
作為下一個字元。根據規則 3,將當前節點的根值賦值為3
然後返回當前節點的父節點。- 讀入
+
作為下一個字元。根據規則 2,將當前節點的根值賦值為+
,然後新增一個新的節點作為其右子節點,並且將當前節點變為這個新的子節點。- 讀入
(
作為下一個字元。根據規則 1,建立一個新的節點作為當前節點的左子結點,並將當前節點變為這個新的子節點。- 讀入
4
作為下一個字元。根據規則 3,將當前節點的根值賦值為4
然後返回當前節點的父節點- 讀入
*
作為下一個字元。根據規則 2,將當前節點的根值賦值為*
,然後新增一個新的節點作為其右子節點,並且將當前節點變為這個新的子節點。- 讀入
5
作為下一個字元。根據規則 3,將當前節點的根值賦值為5
然後返回當前節點的父節點- 讀入
)
作為下一個字元。根據規則 4,我們將當前節點變為當前節點*
的父節點。- 讀入
)
作為下一個字元。根據規則 4,我們將當前節點變為當前節點+
的父節點,因為當前節點沒有父節點,所以我們已經完成解析樹的構建。
通過上面給出的例子,很明顯我們需要跟蹤當前節點和當前節點的父節點。樹提供給我們一個獲得子節點的方法——通過getLeftChild
和getRightChild
方法,但是我們怎麼樣來跟蹤一個節點的父節點呢?一個簡單的方法就是在我們遍歷整個樹的過程中利用棧跟蹤父節點。當我們想要下降到當前節點的子節點時,我們先將當前節點壓入棧。當我們想要返回當前節點的父節點時,我們從棧中彈出該父節點。
通過上述的規則,使用棧和二叉樹來操作,我們現在編寫函式來建立解析樹。解析樹生成函式的程式碼如下所示。
12345678910111213141516171819202122232425262728293031 | from pythonds.basic.stack import Stackfrom pythonds.trees.binaryTree import BinaryTreedef buildParseTree(fpexp):fplist=fpexp.split()pStack=Stack()eTree=BinaryTree('')pStack.push(eTree)currentTree=eTreeforiinfplist:ifi=='(':currentTree.insertLeft('')pStack.push(currentTree)currentTree=currentTree.getLeftChild()elifinotin['+','-','*','/',')']:currentTree.setRootVal(int(i))parent=pStack.pop()currentTree=parentelifiin['+','-','*','/']:currentTree.setRootVal(i)currentTree.insertRight('')pStack.push(currentTree)currentTree=currentTree.getRightChild()elifi==')':currentTree=pStack.pop()else:raise ValueErrorreturneTreept=buildParseTree("( ( 10 + 5 ) * 3 )")pt.postorder()#defined and explained in the next section |
這四條建立解析樹的規則體現在四個if
從句,它們分別在第 11,15,19,24 行。如上面所說的,在這幾處你都能看到規則的程式碼實現,並需要呼叫一些BinaryTree
和Stack
的方法。這個函式中唯一的錯誤檢查是在else
語句中,一旦我們從列表中讀入的字元不能辨認,我們就會報一個ValueError
的異常。現在我們已經建立了一個解析樹,我們能用它來幹什麼呢?第一個例子,我們寫一個函式來計算解析樹的值,並返回該計算的數字結果。為了實現這個函式要利用樹的層級結構。重新看一下圖 2,回想一下我們能夠將原始的樹替換為簡化後的樹(圖 3)。這提示我們寫一個通過遞迴計算每個子樹的值來計算整個解析樹的值。
就像我們以前實現遞迴演算法那樣,我們將從基點來設計遞迴計算表示式值的函式。這個遞迴演算法的自然基點是檢查操作符是否為葉節點。在解析樹中,葉節點總是運算元。因為數字變數如整數和浮點數不需要更多的操作,這個求值函式只需要簡單地返回葉節點中儲存的數字就可以。使函式走向基點的遞迴過程就是呼叫求值函式計算當前節點的左子樹、右子樹的值。遞迴呼叫使我們朝著葉節點,沿著樹下降。
為了將兩個遞迴呼叫的值整合在一起,我們只需簡單地將存在父節點中的操作符應用到兩個子節點返回的結果。在圖 3 中,我們能看到兩個子節點的值,分別為 10 和 3。對他們使用乘法運算得到最終結果 30。
遞迴求值函式的程式碼如 Listing1 所示,我們得到當前節點的左子節點、右子節點的引數。如果左右子節點的值都是 None,我們就能知道這個當前節點是一個葉節點。這個檢查在第 7 行。如果當前節點不是一個葉節點,查詢當前節點的操作符,並用到它左右孩子的返回值上。
為了實現這個演算法,我們使用了字典,鍵值分別為'+','-','*'
和'/'
。存在字典裡的值是 Python 的運算元模組中的函式。這個運算元模組為我們提供了很多常用函式的操作符。當我們在字典中查詢一個操作符時,相應的運算元變數被取回。既然是函式,我們可以通過呼叫函式的方式來計算算式,如function(param1,param2)
。所以查詢opers['+'](2,2)
就等價於operator.add(2,2)
。
Listing 1
1234567891011 | def evaluate(parseTree):opers={'+':operator.add,'-':operator.sub,'*':operator.mul,'/':operator.truediv}leftC=parseTree.getLeftChild()rightC=parseTree.getRightChild()ifleftC andrightC:fn=opers[parseTree.getRootVal()]returnfn(evaluate(leftC),evaluate(rightC))else:returnparseTree.getRootVal() |
最後,我們將在圖 4 中建立的解析樹上遍歷求值。當我們第一次呼叫求值函式時,我們傳遞解析樹引數parseTree
,作為整個樹的根。然後我們獲得左右子樹的引用來確保它們一定存在。遞迴呼叫在第 9 行。我們從檢視樹根中的操作符開始,這是一個'+'
。這個'+'
操作符找到operator.add
函式呼叫,且有兩個引數。通常對一個 Python 函式呼叫而言,Python 第一件做的事情就是計算傳給函式的引數值。通過從左到右的求值過程,第一個遞迴呼叫從左邊開始。在第一個遞迴呼叫中,求值函式用來計算左子樹。我們發現這個節點沒有左、右子樹,所以我們在一個葉節點上。當我們在葉節點上時,我們僅僅是返回這個葉節點儲存的數值作為求值函式的結果。因此我們返回整數 3。
現在,為了頂級呼叫operator.add
函式,我們計算好其中一個引數了,但我們還沒有完。繼續從左到右計算引數,現在遞迴呼叫求值函式用來計算根節點的右子節點。我們發現這個節點既有左節點又有右節點,所以我們查詢這個節點中儲存的操作符,是'*'
,然後呼叫這個運算元函式並將它的左右子節點作為函式的兩個引數。此時再對它的兩個節點呼叫函式,這時發現它的左右子節點是葉子,分別返回兩個整數 4 和 5。求出這兩個引數值後,我們返回operator.mul(4,5)
的值。此時,我們已經計算好了頂級操作符'+'
的兩個操作數了,所有需要做的只是完成呼叫函式operator.add(3,20)
即可。這個結果就是整個表示式樹 (3+(4*5)) 的值,這個值是 23。
樹的遍歷
之前我們已經瞭解了樹的基本功能,現在我們來看一些應用模式。按照節點的訪問方式不同,模式可分為 3 種。這三種方式常被用於訪問樹的節點,它們之間的不同在於訪問每個節點的次序不同。我們把這種對所有節點的訪問稱為遍歷(traversal
)。這三種遍歷分別叫做先序遍歷(preorder
),中序遍歷(inorder
)和後序遍歷(postorder
)。我們來給出它們的詳細定義,然後舉例看看它們的應用。
- 先序遍歷
在先序遍歷中,我們先訪問根節點,然後遞迴使用先序遍歷訪問左子樹,再遞迴使用先序遍歷訪問右子樹。 - 中序遍歷
在中序遍歷中,我們遞迴使用中序遍歷訪問左子樹,然後訪問根節點,最後再遞迴使用中序遍歷訪問右子樹。 - 後序遍歷
在後序遍歷中,我們先遞迴使用後序遍歷訪問左子樹和右子樹,最後訪問根節點。
現在我們用幾個例子來說明這三種不同的遍歷。首先我們先看看先序遍歷。我們用樹來表示一本書,來看看先序遍歷的方式。書是樹的根節點,每一章是根節點的子節點,每一節是章節的子節點,每一小節是每一章節的子節點,以此類推。圖 5 是一本書只取了兩章的一部分。雖然遍歷的演算法適用於含有任意多子樹的樹結構,但我們目前為止只談二叉樹。
圖 5:用樹結構來表示一本書
設想你要從頭到尾閱讀這本書。先序遍歷恰好符合這種順序。從根節點(書)開始,我們按照先序遍歷的順序來閱讀。我們遞迴地先序遍歷左子樹,在這裡是第一章,我們繼續遞迴地先序遍歷訪問左子樹第一節 1.1。第一節 1.1 沒有子節點,我們不再遞迴下去。當我們閱讀完 1.1 節後我們回到第一章,這時我們還需要遞迴地訪問第一章的右子樹 1.2 節。由於我們先訪問左子樹,我們先看 1.2.1 節,再看 1.2.2 節。當 1.2 節讀完後,我們又回到第一章。之後我們再返回根節點(書)然後按照上述步驟訪問第二章。
由於用遞迴來編寫遍歷,先序遍歷的程式碼異常的簡潔優雅。Listing 2 給出了一個二叉樹的先序遍歷的 Python 程式碼。
Listing 2
12345 | def preorder(tree):iftree:print(tree.getRootVal())preorder(tree.getLeftChild())preorder(tree.getRightChild()) |
我們也可以把先序遍歷作為BinaryTree
類中的內建方法,這部分程式碼如 Listing 3 所示。注意這一程式碼從外部移到內部所產生的變化。一般來說,我們只是將tree
換成了self
。但是我們也要修改程式碼的基點。內建方法在遞迴進行先序遍歷之前必須檢查左右子樹是否存在。
Listing 3
123456 | def preorder(self):print(self.key)ifself.leftChild:self.leftChild.preorder()ifself.rightChild:self.rightChild.preorder() |
內建和外接方法哪種更好一些呢?一般來說preorder
作為一個外接方法比較好,原因是,我們很少是單純地為了遍歷而遍歷,這個過程中總是要做點其他事情。事實上我們馬上就會看到後序遍歷的演算法和我們之前寫的表示式樹求值的程式碼很相似。只是我們接下來將按照外部函式的形式書寫遍歷的程式碼。後序遍歷的程式碼如 Listing 4 所示,它除了將print
語句移到末尾之外和先序遍歷的程式碼幾乎一樣。
Listing 4
12345 | def postorder(tree):iftree!=None:postorder(tree.getLeftChild())postorder(tree.getRightChild())print(tree.getRootVal()) |
我們已經見過了後序遍歷的一般應用,也就是通過表示式樹求值。我們再來看 Listing 1,我們先求左子樹的值,再求右子樹的值,然後將它們利用根節點的運算連在一起。假設我們的二叉樹只儲存表示式樹的資料。我們來改寫求值函式並儘量模仿後序遍歷的程式碼,如 Listing 5 所示。
Listing 5
1234567891011 | def postordereval(tree):opers={'+':operator.add,'-':operator.sub,'*':operator.mul,'/':operator.truediv}res1=Noneres2=Noneiftree:res1=postordereval(tree.getLeftChild())res2=postordereval(tree.getRightChild())ifres1 andres2:returnopers[tree.getRootVal()](res1,res2)else:returntree.getRootVal() |
我們發現 Listing 5 的形式和 Listing 4 是一樣的,區別在於 Listing 4 中我們輸出鍵值而在 Listing 5 中我們返回鍵值。這使我們可以通過第 6 行和第 7 行將遞迴得到的值儲存起來。之後我們利用這些儲存起來的值和第 9 行的運算子一起運算。
在這節的最後我們來看看中序遍歷。在中序遍歷中,我們先訪問左子樹,之後是根節點,最後訪問右子樹。 Listing 6 給出了中序遍歷的程式碼。我們發現這三種遍歷的函式程式碼只是調換了輸出語句的位置而不改動遞迴語句。
Listing 6
12345 | def inorder(tree):iftree!=None:inorder(tree.getLeftChild())print(tree.getRootVal())inorder(tree.getRightChild()) |
當我們對一個解析樹作中序遍歷時,得到表示式的原來形式,沒有任何括號。我們嘗試修改中序遍歷的演算法使我們得到全括號表示式。只要做如下修改:在遞迴訪問左子樹之前輸出左括號,然後在訪問右子樹之後輸出右括號。修改的程式碼見 Listing 7。
Listing 7
1234567 | def printexp(tree):sVal=""iftree:sVal='('+printexp(tree.getLeftChild())sVal=sVal+str(tree.getRootVal())sVal=sVal+printexp(tree.getRightChild())+')'returnsVal |
我們發現printexp
函式對每個數字也加了括號,這些括號顯然沒必要加。