Python數據結構——解析樹及樹的遍歷

解析樹

完成樹的實現以後,如今咱們來看一個例子,告訴你怎麼樣利用樹去解決一些實際問題。在這個章節,咱們來研究解析樹。解析樹經常用於真實世界的結構表示,例如句子或數學表達式。 python

nlParse.png

圖 1:一個簡單句的解析樹 算法

圖 1 顯示了一個簡單句的層級結構。將一個句子表示爲一個樹,能使咱們經過利用子樹來處理句子中的每一個獨立的結構。 函數

meParse.png

圖 2: ((7+3)*(5−2)) 的解析樹 post

如圖 2 所示,咱們能將一個相似於 ((7+3)*(5−2)) 的數學表達式表示出一個解析樹。咱們已經研究過全括號表達式,那麼咱們怎樣理解這個表達式呢?咱們知道乘法比加或者減有着更高的優先級。由於括號的關係,咱們在作乘法運算以前,須要先計算括號內的加法或者減法。樹的層級結構幫咱們理解了整個表達式的運算順序。在計算最頂上的乘法運算前,咱們先要計算子樹中的加法和減法運算。左子樹的加法運算結果爲 10,右子樹的減法運算結果爲 3。利用樹的層級結構,一旦咱們計算出了子節點中表達式的結果,咱們可以將整個子樹用一個節點來替換。運用這個替換步驟,咱們獲得一個簡單的樹,如圖 3 所示。 ui

meSimple.png

圖 3: ((7+3)*(5−2)) 的化簡後的解析樹 lua

在本章的其他部分,咱們將更加詳細地研究解析樹。尤爲是:spa

  • 怎樣根據一個全括號數學表達式來創建其對應的解析樹設計

  • 怎樣計算解析樹中數學表達式的值3d

  • 怎樣根據一個解析樹還原數學表達式code

創建解析樹的第一步,將表達式字符串分解成符號保存在列表裏。這裏有四種符號須要咱們考慮:左括號,操做符和操做數。咱們知道讀到一個左括號時,咱們將開始一個新的表達式,所以咱們建立一個子樹來對應這個新的表達式。相反,每當咱們讀到一個右括號,咱們就得結束這個表達式。另外,操做數將成爲葉節點和他們所屬的操做符的子節點。最後,咱們知道每一個操做符都應該有一個左子節點和一個右子節點。經過上面的分析咱們定義如下四條規則:

  1. 若是當前讀入的字符是'(',添加一個新的節點做爲當前節點的左子節點,並降低到左子節點處。

  2. 若是當前讀入的字符在列表['+', '-', '/', '*']中,將當前節點的根值設置爲當前讀入的字符。添加一個新的節點做爲當前節點的右子節點,並降低到右子節點處。

  3. 若是當前讀入的字符是一個數字,將當前節點的根值設置爲該數字,並返回到它的父節點。

  4. 若是當前讀入的字符是’)’,返回當前節點的父節點。

在咱們編寫 Python 代碼以前,讓咱們一塊兒看一個上述的例子。咱們將使用 (3+(4*5))
這個表達式。咱們將表達式分解爲以下的字符列表:['(', '3', '+', '(', '4', '*', '5' ,')',')']。一開始,咱們從一個僅包括一個空的根節點的解析樹開始。如圖 4,該圖說明了隨着每一個新的字符被讀入後該解析樹的內容和結構。

buildExp1.png
buildExp2.png
buildExp3.png
buildExp4.png
buildExp5.png
buildExp6.png
buildExp7.png
buildExp8.png

圖 4:解析樹結構的步驟圖

觀察圖 4,讓咱們一步一步地過一遍:

  1. 建立一個空的樹。

  2. 讀如(做爲第一個字符,根據規則 1,建立一個新的節點做爲當前節點的左子結點,並將當前節點變爲這個新的子節點。

  3. 讀入3做爲下一個字符。根據規則 3,將當前節點的根值賦值爲3而後返回當前節點的父節點。

  4. 讀入+做爲下一個字符。根據規則 2,將當前節點的根值賦值爲+,而後添加一個新的節點做爲其右子節點,而且將當前節點變爲這個新的子節點。

  5. 讀入(做爲下一個字符。根據規則 1,建立一個新的節點做爲當前節點的左子結點,並將當前節點變爲這個新的子節點。

  6. 讀入4做爲下一個字符。根據規則 3,將當前節點的根值賦值爲4而後返回當前節點的父節點

  7. 讀入*做爲下一個字符。根據規則 2,將當前節點的根值賦值爲*,而後添加一個新的節點做爲其右子節點,而且將當前節點變爲這個新的子節點。

  8. 讀入5做爲下一個字符。根據規則 3,將當前節點的根值賦值爲5而後返回當前節點的父節點

  9. 讀入)做爲下一個字符。根據規則 4,咱們將當前節點變爲當前節點*的父節點。

  10. 讀入)做爲下一個字符。根據規則 4,咱們將當前節點變爲當前節點+的父節點,由於當前節點沒有父節點,因此咱們已經完成解析樹的構建。

經過上面給出的例子,很明顯咱們須要跟蹤當前節點和當前節點的父節點。樹提供給咱們一個得到子節點的方法——經過getLeftChildgetRightChild方法,可是咱們怎麼樣來跟蹤一個節點的父節點呢?一個簡單的方法就是在咱們遍歷整個樹的過程當中利用棧跟蹤父節點。當咱們想要降低到當前節點的子節點時,咱們先將當前節點壓入棧。當咱們想要返回當前節點的父節點時,咱們從棧中彈出該父節點。

經過上述的規則,使用棧和二叉樹來操做,咱們如今編寫函數來建立解析樹。解析樹生成函數的代碼以下所示。

from pythonds.basic.stack import Stack
from pythonds.trees.binaryTree import BinaryTree

def buildParseTree(fpexp):
    fplist = fpexp.split()
    pStack = Stack()
    eTree = BinaryTree('')
    pStack.push(eTree)
    currentTree = eTree
    for i in fplist:
        if i == '(':
            currentTree.insertLeft('')
            pStack.push(currentTree)
            currentTree = currentTree.getLeftChild()
        elif i not in ['+', '-', '*', '/', ')']:
            currentTree.setRootVal(int(i))
            parent = pStack.pop()
            currentTree = parent
        elif i in ['+', '-', '*', '/']:
            currentTree.setRootVal(i)
            currentTree.insertRight('')
            pStack.push(currentTree)
            currentTree = currentTree.getRightChild()
        elif i == ')':
            currentTree = pStack.pop()
        else:
            raise ValueError
    return eTree

pt = buildParseTree("( ( 10 + 5 ) * 3 )")
pt.postorder()  #defined and explained in the next section

這四條創建解析樹的規則體如今四個if從句,它們分別在第 11,15,19,24 行。如上面所說的,在這幾處你都能看到規則的代碼實現,並須要調用一些BinaryTreeStack的方法。這個函數中惟一的錯誤檢查是在else語句中,一旦咱們從列表中讀入的字符不能辨認,咱們就會報一個ValueError的異常。如今咱們已經創建了一個解析樹,咱們能用它來幹什麼呢?第一個例子,咱們寫一個函數來計算解析樹的值,並返回該計算的數字結果。爲了實現這個函數要利用樹的層級結構。從新看一下圖 2,回想一下咱們可以將原始的樹替換爲簡化後的樹(圖 3)。這提示咱們寫一個經過遞歸計算每一個子樹的值來計算整個解析樹的值。

就像咱們之前實現遞歸算法那樣,咱們將從基點來設計遞歸計算表達式值的函數。這個遞歸算法的天然基點是檢查操做符是否爲葉節點。在解析樹中,葉節點老是操做數。由於數字變量如整數和浮點數不須要更多的操做,這個求值函數只須要簡單地返回葉節點中存儲的數字就能夠。使函數走向基點的遞歸過程就是調用求值函數計算當前節點的左子樹、右子樹的值。遞歸調用使咱們朝着葉節點,沿着樹降低。

爲了將兩個遞歸調用的值整合在一塊兒,咱們只需簡單地將存在父節點中的操做符應用到兩個子節點返回的結果。在圖 3 中,咱們能看到兩個子節點的值,分別爲 10 和 3。對他們使用乘法運算獲得最終結果 30。

遞歸求值函數的代碼如 Listing1 所示,咱們獲得當前節點的左子節點、右子節點的參數。若是左右子節點的值都是 None,咱們就能知道這個當前節點是一個葉節點。這個檢查在第 7 行。若是當前節點不是一個葉節點,查找當前節點的操做符,並用到它左右孩子的返回值上。

爲了實現這個算法,咱們使用了字典,鍵值分別爲'+','-','*''/'。存在字典裏的值是 Python 的操做數模塊中的函數。這個操做數模塊爲咱們提供了不少經常使用函數的操做符。當咱們在字典中查找一個操做符時,相應的操做數變量被取回。既然是函數,咱們能夠經過調用函數的方式來計算算式,如function(param1,param2)。因此查找opers['+'](2,2)就等價於operator.add(2,2)

Listing 1

def evaluate(parseTree):
    opers = {'+':operator.add, '-':operator.sub, '*':operator.mul, '/':operator.truediv}

    leftC = parseTree.getLeftChild()
    rightC = parseTree.getRightChild()

    if leftC and rightC:
        fn = opers[parseTree.getRootVal()]
        return fn(evaluate(leftC),evaluate(rightC))
    else:
        return parseTree.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)。咱們來給出它們的詳細定義,而後舉例看看它們的應用。

  1. 先序遍歷
    在先序遍歷中,咱們先訪問根節點,而後遞歸使用先序遍歷訪問左子樹,再遞歸使用先序遍歷訪問右子樹。

  2. 中序遍歷
    在中序遍歷中,咱們遞歸使用中序遍歷訪問左子樹,而後訪問根節點,最後再遞歸使用中序遍歷訪問右子樹。

  3. 後序遍歷
    在後序遍歷中,咱們先遞歸使用後序遍歷訪問左子樹和右子樹,最後訪問根節點。

如今咱們用幾個例子來講明這三種不一樣的遍歷。首先咱們先看看先序遍歷。咱們用樹來表示一本書,來看看先序遍歷的方式。書是樹的根節點,每一章是根節點的子節點,每一節是章節的子節點,每一小節是每一章節的子節點,以此類推。圖 5 是一本書只取了兩章的一部分。雖然遍歷的算法適用於含有任意多子樹的樹結構,但咱們目前爲止只談二叉樹。

clipboard.png

圖 5:用樹結構來表示一本書

設想你要從頭至尾閱讀這本書。先序遍歷剛好符合這種順序。從根節點(書)開始,咱們按照先序遍歷的順序來閱讀。咱們遞歸地先序遍歷左子樹,在這裏是第一章,咱們繼續遞歸地先序遍歷訪問左子樹第一節 1.1。第一節 1.1 沒有子節點,咱們再也不遞歸下去。當咱們閱讀完 1.1 節後咱們回到第一章,這時咱們還須要遞歸地訪問第一章的右子樹 1.2 節。因爲咱們先訪問左子樹,咱們先看 1.2.1 節,再看 1.2.2 節。當 1.2 節讀完後,咱們又回到第一章。以後咱們再返回根節點(書)而後按照上述步驟訪問第二章。

因爲用遞歸來編寫遍歷,先序遍歷的代碼異常的簡潔優雅。Listing 2 給出了一個二叉樹的先序遍歷的 Python 代碼。

Listing 2

def preorder(tree):
    if tree:
        print(tree.getRootVal())
        preorder(tree.getLeftChild())
        preorder(tree.getRightChild())

咱們也能夠把先序遍歷做爲BinaryTree類中的內置方法,這部分代碼如 Listing 3 所示。注意這一代碼從外部移到內部所產生的變化。通常來講,咱們只是將tree換成了self。可是咱們也要修改代碼的基點。內置方法在遞歸進行先序遍歷以前必須檢查左右子樹是否存在。

Listing 3

def preorder(self):
    print(self.key)
    if self.leftChild:
        self.leftChild.preorder()
    if self.rightChild:
        self.rightChild.preorder()

內置和外置方法哪一種更好一些呢?通常來講preorder做爲一個外置方法比較好,緣由是,咱們不多是單純地爲了遍歷而遍歷,這個過程當中老是要作點其餘事情。事實上咱們立刻就會看到後序遍歷的算法和咱們以前寫的表達式樹求值的代碼很類似。只是咱們接下來將按照外部函數的形式書寫遍歷的代碼。後序遍歷的代碼如 Listing 4 所示,它除了將print語句移到末尾以外和先序遍歷的代碼幾乎同樣。

Listing 4

def postorder(tree):
    if tree != None:
        postorder(tree.getLeftChild())
        postorder(tree.getRightChild())
        print(tree.getRootVal())

咱們已經見過了後序遍歷的通常應用,也就是經過表達式樹求值。咱們再來看 Listing 1,咱們先求左子樹的值,再求右子樹的值,而後將它們利用根節點的運算連在一塊兒。假設咱們的二叉樹只存儲表達式樹的數據。咱們來改寫求值函數並儘可能模仿後序遍歷的代碼,如 Listing 5 所示。

Listing 5

def postordereval(tree):
    opers = {'+':operator.add, '-':operator.sub, '*':operator.mul, '/':operator.truediv}
    res1 = None
    res2 = None
    if tree:
        res1 = postordereval(tree.getLeftChild())
        res2 = postordereval(tree.getRightChild())
        if res1 and res2:
            return opers[tree.getRootVal()](res1,res2)
        else:
            return tree.getRootVal()

咱們發現 Listing 5 的形式和 Listing 4 是同樣的,區別在於 Listing 4 中咱們輸出鍵值而在 Listing 5 中咱們返回鍵值。這使咱們能夠經過第 6 行和第 7 行將遞歸獲得的值存儲起來。以後咱們利用這些保存起來的值和第 9 行的運算符一塊兒運算。

在這節的最後咱們來看看中序遍歷。在中序遍歷中,咱們先訪問左子樹,以後是根節點,最後訪問右子樹。 Listing 6 給出了中序遍歷的代碼。咱們發現這三種遍歷的函數代碼只是調換了輸出語句的位置而不改動遞歸語句。

Listing 6

def inorder(tree):
  if tree != None:
      inorder(tree.getLeftChild())
      print(tree.getRootVal())
      inorder(tree.getRightChild())

當咱們對一個解析樹做中序遍歷時,獲得表達式的原來形式,沒有任何括號。咱們嘗試修改中序遍歷的算法使咱們獲得全括號表達式。只要作以下修改:在遞歸訪問左子樹以前輸出左括號,而後在訪問右子樹以後輸出右括號。修改的代碼見 Listing 7。

Listing 7

def printexp(tree):
  sVal = ""
  if tree:
      sVal = '(' + printexp(tree.getLeftChild())
      sVal = sVal + str(tree.getRootVal())
      sVal = sVal + printexp(tree.getRightChild())+')'
  return sVal

咱們發現printexp函數對每一個數字也加了括號,這些括號顯然不必加。

相關文章
相關標籤/搜索