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函数对每一个数字也加了括号,这些括号显然不必加。

相关文章
相关标签/搜索