荐 用Python解决数据结构与算法问题(三):线性数据结构之栈

欢迎关注WX公众号:【程序员管小亮】

python学习之路 – 从入门到精通到大师

文章目录

  • 欢迎关注WX公众号:【程序员管小亮】
    • [python学习之路 – 从入门到精通到大师](https://blog.csdn.net/TeFuirnever/article/details/90017382)
    • 七、十进制转换成二进制
    • 八、中缀,前缀和后缀表达式

〇、写在前面

哼哼,勤劳的日更博主又回来了,我的小粉丝们期待一下吧 :smiley:

一、线性数据结构

我们这个系列的前两期:

新的开始准备从四个 简单但重要 的概念开始研究数据结构,分别是:

  • 队列
  • deques
  • 列表

这四个是一类数据的容器,数据项之间的顺序由添加或删除的顺序决定。一旦一个数据项被添加,它相对于前后元素一直保持该位置不变,诸如此类的数据结构被称为 线性数据结构

线性数据结构有两端,有时被称为 左右 ,某些情况被称为 前后 ,也可以称为 顶部底部 ,名字这个东西不重要,只要你能知道是什么意思就行哈。将两个 线性数据结构 区分开的方法是添加和移除项的方式,特别是添加和移除项的位置,例如一些结构允许从一端添加项,另一些允许从另一端移除项。这些变种的形式产生了计算机科学最有用的数据结构。

二、什么是栈

(有时称为 后进先出栈 )是一个项的有序集合,其中添加移除新项总发生在同一端,这一端通常称为 顶部 ,与 顶部 对应的端称为 底部

栈的 底部 很重要,因为在栈中靠近 底部 的项是存储时间最长的,相对的最近添加的项是最先会被移除的,所以很多时候这种排序原则被称为 LIFO ,即 后进先出

为什么会出现这种情况?主要是因为 基于在集合内的时间长度做的排序 ,即较新的项靠近顶部,较旧的项靠近底部。

栈的例子很常见,就用一个通俗的例子来说明吧,现在想象桌上有一堆书,如下图:

只有顶部的那本书的封面是可见的,如果想要看到其他书的封面,只有先移除它们上面的书。

或者看一个稍稍专业一丢丢的例子,下图展示了另一个 ,包含了很多 Python 对象。

如果我们想要对这个 进行操作的话,移除的顺序跟放置的顺序是刚好相反的,下图展示了 Python 数据对象创建和删除的过程,注意观察它们的顺序:

栈之所以重要的原因之一,也是它能 反转项的顺序 。想想这种反转的属性,你可以想到使用计算机的时候所碰到的例子,例如,每个 web 浏览器都有一个返回按钮,当浏览网页时,这些网页被放置在一个 中(实际是网页的网址)。当前查看的网页是放在顶部的,而第一个查看的网页在放在底部的,这个时候如果按 返回 按钮,将会按照相反的顺序浏览刚才的页面,也就是先出来的是刚才的页面,然后才是再之前的页面!!!

三、抽象数据类型

的抽象数据类型由以下结构和操作定义:

如上面所述, 被构造为项的有序集合,如果想要对 进行添加和移除项,则操作如下:

  • Stack() 创建一个空的新栈。 它不需要参数,并返回一个空栈
  • push(item) 将一个新项添加到栈的顶部。它需要 item 做参数并不返回任何内容
  • pop() 从栈中删除顶部项。它不需要参数并返回 item ,栈被修改
  • peek() 从栈返回顶部项,但不会删除它。它不需要参数,不修改栈
  • isEmpty() 测试栈是否为空。它不需要参数,并返回布尔值
  • size() 返回栈中的 item 数量。它不需要参数,并返回一个整数

例如, s 是已经创建的空栈,下表展示了 操作序列的结果,其中 的顶部项在项列的最右边。

四、用Python实现栈

到现在,到这里,已经将 清楚地解释并定义了,并且还定义了 抽象数据类型 ,那么接下来让我们将把注意力转向使用 Python 实现

回想一下,当给 抽象数据类型 一个物理实现时,这个实现可以称为 数据结构 。而在 Python 中,与任何面向对象编程语言一样, 抽象数据类型 (如 )的选择的实现就是创建一个新类,即用类的方法来实现 操作。此外,为了实现作为元素集合的 ,使用由 Python 提供的原语集合的能力是有意义的,最后,我们将使用 列表 作为底层实现。

Python 中的列表类提供了有序集合机制和一组方法。例如,如果有列表 [2, 5, 3,6, 7, 4],只需要确定列表的哪一端将被认为是 顶部 。一旦确定,可以使用诸如 appendpop 的列表方法来实现操作。

假定列表的结尾将保存 顶部 元素,随着 增长( push 操作),新项将被添加到列表的末尾, pop 也操作列表末尾的元素。以下代码为 的实现:

class Stack:
     def __init__(self):
         self.items = []

     def isEmpty(self):
         return self.items == []

     def push(self, item):
         self.items.append(item)

     def pop(self):
         return self.items.pop()

     def peek(self):
         return self.items[len(self.items)-1]

     def size(self):
         return len(self.items)

记住只需要定义类的实现,从而创建了一个 ,然后使用它。

下面的代码展示了通过实例化 Stack 类执行上表中的操作。注意, Stack 类的定义是从 pythonds 模块导入的。

Note

pythonds 模块包含本书中讨论的所有数据结构的实现,它根据以下部分构造:基本数据类型,树和图。

该模块可以从 pythonworks.org 下载。

from pythonds.basic.stack import Stack

s=Stack()

print(s.isEmpty())
s.push(4)
s.push('dog')
print(s.peek())

s.push(True)
print(s.size())
print(s.isEmpty())

s.push(8.4)
print(s.pop())
print(s.pop())
print(s.size())

五、简单括号匹配

现在把注意力继续转向使用 去解决一个真正的计算机问题——算术表达式,你大概会这么写: (5+6)*(7+8)/(4+3) ,其中括号用于命令操作的执行。可以看到在这个例子中,括号必须以匹配的方式出现。括号匹配意味着每个开始符号具有相应的结束符号,并且括号能被正确嵌套。

然后考虑下面正确,

  • 匹配的括号字符串:
(()()()())

(((())))

(()((())()))
  • 对比那些不匹配的括号:
((((((())

()))

(()()(()

区分括号是否匹配的能力是识别很多编程语言结构的重要部分。最具挑战的是如何编写一个算法,能够从左到右读取一串符号,并决定符号是否平衡。

为了解决这个问题,需要做一个认真地观察——从左到右处理符号时,最近开始的符号必须与下一个关闭符号相匹配(见下图)。

此外,处理的第一个开始符号必须等待,直到其匹配最后一个符号。结束符号以相反的顺序匹配开始符号,它们从内到外匹配,这是一个可以用 解决问题的线索。

一旦你认为 是保存括号的恰当的数据结构,那么算法就是很直接的了,即从 空栈 开始,由左到右地处理括号字符串。如果一个符号是一个开始符号,将其作为一个信号,表示对应的结束符号稍后会出现;如果一个符号是一个结束符号,将其作为一个信号,对应前面的开始符号,表示弹出

只有弹出 的开始符号可以匹配对应的结束符号,否则括号将保持匹配状态。如果任何时候 上没有出现符合开始符号的结束符号,则字符串不匹配。最后,当所有符号都被处理后, 应该是空的。

实现此算法的 Python 代码如下:

from pythonds.basic.stack import Stack

def parChecker(symbolString):
    s = Stack()
    balanced = True
    index = 0
    while index < len(symbolString) and balanced:
        symbol = symbolString[index]
        if symbol == "(":
            s.push(symbol)
        else:
            if s.isEmpty():
                balanced = False
            else:
                s.pop()

        index = index + 1

    if balanced and s.isEmpty():
        return True
    else:
        return False

print(parChecker('((()))'))
print(parChecker('(()'))

六、符号匹配

上面讲到的括号匹配问题是许多编程语言都会出现的,是一种符号匹配的特定情况。匹配和嵌套不同种类的开始和结束符号的情况是经常发生的,比如,在 Python 中,方括号 [] 用于列表,花括号 {} 用于字典,括号 () 用于元组和算术表达式,只要每个符号都能保持自己的开始和结束关系,就可以混合符号。

符号字符串如:

{ { ( [ ] [ ] ) } ( ) }

[ [ { { ( ( ) ) } } ] ]

[ ] [ ] [ ] ( ) { }

这些符号被恰当的匹配了,因为不仅每个开始符号都有对应的结束符号,而且符号的类型也匹配。

相反下面这些字符串是没法匹配的:

( [ ) ]

( ( ( ) ] ) )

[ { ( ) ]

其实上一节的简单括号检查程序可以轻松地扩展处理这些新类型的符号。回想一下,每个开始符号被简单的压入 中,等待匹配的结束符号出现。当出现结束符号时, 唯一的区别 是必须检查确保它能正确地匹配 顶部 的开始符号的类型:

  • 如果两个符号不匹配,整个字符串没都被处理完而是有什么留在 中,则字符串不匹配;
  • 如果两个符号匹配,整个字符串都被处理完并且没有什么留在 中,则字符串匹配。

Python 程序如下:

from pythonds.basic.stack import Stack

def parChecker(symbolString):
    s = Stack()
    balanced = True
    index = 0
    while index < len(symbolString) and balanced:
        symbol = symbolString[index]
        if symbol in "([{":
            s.push(symbol)
        else:
            if s.isEmpty():
                balanced = False
            else:
                top = s.pop()
                if not matches(top,symbol):
                       balanced = False
        index = index + 1
    if balanced and s.isEmpty():
        return True
    else:
        return False

def matches(open,close):
    opens = "([{"
    closers = ")]}"
    return opens.index(open) == closers.index(close)


print(parChecker('{{([][])}()}'))
print(parChecker('[{()]'))

唯一的变化是 16 行,我们称之为 辅助函数匹配 ,即必须检查栈中每个删除的符号,以查看它是否与当前结束符号匹配。如果不匹配,则布尔变量 balanced 被设置为 False

小结一下:

这两个例子表明, 是计算机语言结构处理非常重要的数据结构,几乎你能想到的任何嵌套符号必须按照平衡匹配的顺序。当然了, 还有其他重要的用途,接下来会继续讨论。。。

七、十进制转换成二进制

在学习计算机的过程中,你可能或者说是一定已经接触过了二进制。可以这么肯定地说,二进制在计算机科学中是非常重要的,因为存储在计算机内的所有值都是以 0 和 1 存储的。所以如果没有能力在二进制数和普通字符串之间转换,那么我们与计算机之间的交互就会非常棘手。

其实我们平时最常用到的应该是整数值,它是常见的数据项,它一直用于计算机程序和计算。尤其记得的是在数学课上学习它,进行加减乘除以及平方开方等等,当然最后是用十进制或者基数是 10 来表示它们的。

十进制

23 3 10 233_{10}

2 3 3 1 0 以及对应的二进制表示

1110100 1 2 11101001_2

1 1 1 0 1 0 0 1 2 ,如果想要用计算公式去分别解释的话,是:

23 3 10 = 2 × 1 0 2 + 3 × 1 0 1 + 3 × 1 0 0 233_{10} = 2\times10^{2} + 3\times10^{1} + 3\times10^{0}

2 3 3 1 0 = 1 0 2 + 1 0 1 + 1 0 0

1110100 1 2 = 1 × 2 7 + 1 × 2 6 + 1 × 2 5 + 0 × 2 4 + 1 × 2 3 + 0 × 2 2 + 0 × 2 1 + 1 × 2 0 11101001_2 = 1\times2^{7} + 1\times2^{6} + 1\times2^{5} + 0\times2^{4} + 1\times2^{3} + 0\times2^{2} + 0\times2^{1} + 1\times2^{0}

1 1 1 0 1 0 0 1 2 = 2 0

但是如何能够容易并迅速地将整数值转换为二进制呢?答案是 除 2 算法(如果你还记得计算机基础和数字电子电路等等这些课的话,应该会很容易地想起它来),不过在这里我们使用这个算法是因为它用 来跟踪二进制结果的数字(其实原理和以前学过的类似,不过这里我们是使用算法来实现这个过程)。

除 2算法假定我们从大于 0 的整数开始,不断地进行迭代,将十进制数除以 2,并跟踪该算式的余数,具体操作如下:第一个除以 2 的余数说明了这个值是偶数还是奇数,偶数有 0 的余数,记为 0;奇数有余数 1,记为 1。记录下这些余数,它们就是我们想要的二进制数的元素,把它们构建为一个数字序列,要记得这里的排序是一个倒序!!!也就是说第一个余数实际上是序列中的最后一个数字,如下图:

看到这里没学过的小伙伴估计是懵逼max,学过的小伙伴也可能懵逼ing,我来解释一下怎么回事,上图左侧的计算过程显示了最终的结果中的元素应该是什么,但是如果从上到下写下这些余数的话,那么应该是:

10010111 10010111

1 0 0 1 0 1 1 1

到这里没问题是吧,但是实际的结果应该是反着读,别问我为什么,记住就完事了。

11101001 11101001

1 1 1 0 1 0 0 1

到这里,我们再次看到了反转的属性,这表示 可能是解决这个问题的数据结构,或者说它可能非常适合解决这个问题。

开始实现这个算法,最重要的 除 2 算法,函数 divideBy2 传入了一个十进制的参数,并重复除以 2。

from pythonds.basic.stack import Stack

def divideBy2(decNumber):
    remstack = Stack()

    while decNumber > 0:
        rem = decNumber % 2
        remstack.push(rem)
        decNumber = decNumber // 2

    binString = ""
    while not remstack.isEmpty():
        binString = binString + str(remstack.pop())

    return binString

print(divideBy2(42))

其中第 7 行使用内置的模运算符 % 来提取余数,第 8 行将余数压到栈上,当除到 0 后,11-13 行构造了一个二进制字符串。

这个用于二进制转换的算法可以很容易的扩展以执行任何基数的转换。在计算机科学中,通常会使用很多不同的编码,其中最常见的是二级制,八进制和十六进制。

十进制

233 233

2 3 3 和它对应的八进制和十六进制

35 1 8 351_8

3 5 1 8 ,

E 9 16 E9_{16}

E 9 1 6 ,解释如下:

35 1 8 = 3 × 8 2 + 5 × 8 1 + 1 × 8 0 351_8 = 3\times8^{2} + 5\times8^{1} + 1\times8^{0}

3 5 1 8 = 8 0

E 9 16 = 14 × 1 6 1 + 9 × 1 6 0 E9_{16} = 14\times16^{1} + 9\times16^{0}

E 9 1 6 = 1 4 × 1 6 1 + 1 6 0

我们可以通过修改 divideBy2 函数,使它不仅能接受十进制参数,还能接受预期转换的基数。 除 2 的概念被简单的替换成更通用的 除基数 。所以在下面的代码中展示的是一个名为 baseConverter 函数,采用十进制数和 2 到 16 之间的任何基数作为参数,余数部分仍然入栈,直到被转换的值为 0,同时创建一组数字,用来表示超过 9 的余数,十进制以上的基数需要这些数字来表示。

from pythonds.basic.stack import Stack

def baseConverter(decNumber,base):
    digits = "0123456789ABCDEF"

    remstack = Stack()

    while decNumber > 0:
        rem = decNumber % base
        remstack.push(rem)
        decNumber = decNumber // base

    newString = ""
    while not remstack.isEmpty():
        newString = newString + digits[remstack.pop()]

    return newString

print(baseConverter(25,2))
print(baseConverter(25,16))
print(baseConverter(31,16))

八、中缀,前缀和后缀表达式

当编写一个算术表达式如 B*C 时,表达式的形式使你能够正确理解它。在这种情况下,你知道它的意思是 B 乘以 C, 因为乘法运算符 * 出现在表达式中,这种类型的符号称为 中缀 ,因为运算符在它处理的两个操作数之间。再来看另外一个 中缀 示例, A+B*C ,运算符 +* 仍然出现在操作数之间。这里面有个问题是,它们分别作用于哪个运算数上, + 作用于 A 和 B,还是 * 作用于 B 和 C?表达式似乎有点模糊。

不过事实上这是个非常 easy 的问题,因为你已经读写过这些类型的表达式很长一段时间,所以它们不会导致什么问题。这是因为你知道它们分别是运算符 +* ,而每个运算符都有一个计算的优先级,优先级较高的运算符在优先级较低的运算符之前使用,而 唯一改变顺序 的是括号的存在。算术运算符的优先顺序是将乘法和除法置于加法和减法之上,而如果出现具有相等优先级的两个运算符,则使用从左到右的顺序 排序关联

现在来使用运算符优先级解释下几个表达式:

A+B*C
(A+B)*C
A+B+C

到这里了,你可能会觉得这一切对你来说都很明显,但请记住,计算机需要准确知道要执行的操作符和顺序!!!

一种保证不会出问题(对操作顺序产生混淆)的方法是创建一个称为 完全括号表达式 的表达式,这种类型的表达式对每个运算符都使用一对括号,因为括号没有歧义的指示操作顺序,也没有必要记住任何优先规则,所以只要按照括号有限就行了。举个例子:表达式 A+B*C+D 可以重写为 ((A + (B * C)) + D) ,表明先乘法,然后是左边的加法, A + B + C + D 可以写为 (((A + B) + C) + D) ,因为加法操作从左向右相关联。

有两种非常重要的表达式格式,你可能一开始不会很明显的看出来,以中缀表达式 A+B 为例:如果我们将运算符移动到开头会发生什么?结果表达式变成 + A B ;同样,我们也可以将运算符移动到结尾,得到 A B + ,这样看起来真的有点奇怪,但是并不是没有意义的啊。改变操作符的位置得到了两种新的表达式格式,前缀和后缀:前缀表达式符号要求所有运算符在它们处理的两个操作数之前;后缀则要求其操作符在相应的操作数之后。

具体例子可以看下表:

其中:

  • A+B*C 将在前缀中写为 + A * B C ,乘法运算符紧接在操作数 B 和 C 之前,表示 * 优先于 + ,然后加法运算符出现在 A 和乘法的结果之前。
  • 在后缀中,表达式将是 A B C * + ,再次,操作的顺序被保留,因为 * 紧接在 B 和 C 之后出现,表示 * 具有高优先级, + 优先级低。

虽然操作符在它们各自的操作数前后移动,但是顺序相对于彼此保持完全相同。

现在考虑中缀表达式 (A + B) * C ,回想下,在这种情况下,中缀需要括号在乘法之前强制执行加法。然而,当 A+B 写到前缀中时,加法运算符简单的移动到操作数 + A B 之前。这个操作的结果成为乘法的第一个操作数,乘法运算符移动到整个表达式的前面,得出 * + A B C ;同样,在后缀 A B + 中,强制先加法,可以直接对该结果和剩余的操作数 C 相乘,然后,得出后缀表达式为 A B + C *

再次考虑这三个表达式(见下表),为什么在前缀和后缀的时候就不需要括号了呢?答案是操作符对于它们的操作数不再模糊,只有中缀才需要括号,前缀和后缀表达式的操作顺序完全由操作符的顺序决定。

下表展示了一些其他的例子:

小结一下:其实这个改写,无论是前缀还是后缀,离字母越近的,运算符的优先级就越高。。。我个人是这么理解的哈 :smiley:

8.1、中缀表达式转换前缀和后缀

到目前为止,我们已经使用特定方法在中缀表达式和等效前缀和后缀表达式符号之间进行转换。不过你一定有些吐槽,这个改写实在是。。。我懂得,所以正如你期望的一样,是有一些算法来执行转换的,允许任何复杂表达式的转换。

考虑的第一种技术使用前面讨论的 完全括号表达式 的概念,回想一下, A + B * C 可以写成 (A +(B * C)) ,以明确标识乘法优先于加法。然而,仔细观察,你可以看到每个括号对还表示操作数对的开始和结束,中间有相应的运算符。

来看看上面的子表达式 (B * C) 中的右括号,如果将乘法符号移动到那个位置,并删除匹配的左括号,得到 B C * ,实际上已经将子表达式转换为后缀符号。 如果加法运算符也被移动到其相应的右括号位置并且匹配的左括号被去除,则将得到完整的后缀表达式(如下图)。

如果不是将符号移动到右括号的位置,而是将它向左移动,就可以得到前缀符号(如下图)。

圆括号对应的位置实际上是包含运算符最终位置的线索的,或许这就是括号生效的方式呢?

所以为了转换表达式,无论是对前缀还是后缀符号,先根据操作的顺序把表达式转换成 完全括号表达式 。然后将包含的运算符移动到左或右括号的位置,具体取决于需要前缀或是后缀符号。

这里面有个更复杂的例子—— (A + B) * C - (D - E) * (F + G) ,下图显示了如何将其转换为前缀和后缀:

就是把对应的运算符移动到前面或者后面的括号上。。。你懂了吗???

8.2、中缀转后缀通用法

在使用中,我们需要开发一个算法来将任何中缀表达式转换为后缀表达式,而不是像上面说的那样去一个一个的去转换。 为了做到这一点,来仔细看看这个转换过程。

再次考虑这个例子,也就是表达式 A + B * C ,如上所示, A B C * + 是其等价的后缀表达式。 你应该已经注意到,操作数 A,B 和 C 保持在它们的相对位置,只有操作符改变位置。再看中缀表达式中的运算符,从左到右出现的第一个运算符为 +,然而在后缀表达式中,+ 在结束位置,因为下一个运算符 * 的优先级高于加法,即原始表达式中的运算符的顺序在生成的后缀表达式中相反。

当处理表达式时,操作符必须保存在某处,因为它们相应的右操作数还没有看到。 此外,这些保存的操作符顺序可能由于它们的优先级而需要反转,这是在该示例中的加法和乘法的情况,由于加法运算符在乘法运算符之前,并且具有较低的优先级,因此需要在使用乘法运算符之后出现。 由于这种顺序的反转,考虑使用 来保存运算符直到用到它们是有意义的。

再考虑一个复杂一些的例子, (A + B)* C 的情况会是什么样呢? 回想一下, A B + C * 是等价的后缀表达式。从左到右处理此中缀表达式,先看到的是 + ,在这种情况下,当我们看到 *+ 已经放置在结果表达式中,由于括号的优先级高于 * ,所以当看到左括号时,保存它,表示高优先级的另一个运算符将出现。该操作符需要等到相应的右括号出现以表示其位置(回忆完全括号的算法),当右括号出现时,可以从 中弹出操作符。

当从左到右扫描中缀表达式时,我们将使用 来保留运算符,这将提供在第一个例子中注意到的反转。 堆栈顶部 将始终是最近保存的运算符。每当读取一个新的运算符时,我们需要考虑该运算符如何与已经在 上的运算符(如果有的话)比较优先级。

假设中缀表达式是一个由空格分隔的标记字符串。 操作符标记是 * / +- ,以及左右括号,操作数是单字符 A,B,C 等,以下步骤将后缀顺序生成一个字符串:

  1. 创建一个名为 opstack 的空栈以保存运算符,给输出创建一个空列表;
  2. 通过使用字符串方法拆分,将输入的中缀字符串转换为标记列表;
  3. 从左到右扫描标记列表;
    • 如果标记是操作数,将其附加到输出列表的末尾
    • 如果标记是左括号,将其压到 opstack
    • 如果标记是右括号,则弹出 opstack ,直到删除相应的左括号。将每个运算符附加到输出列表的末尾
    • 如果标记是运算符, * / +- ,将其压入 opstack 。但是,首先删除已经在 opstack 中具有更高或相等优先级的任何运算符,并将它们加到输出列表中
  4. 当输入表达式被完全处理时,检查 opstack ,仍然在 上的任何运算符都可以删除并加到输出列表的末尾。

下图展示了对表达式 A * B + C * D 的转换算法:

注意,第一个 * 在看到 + 运算符时被删除,另外,当第二个 * 出现时, + 保留在 中,因为乘法优先级高于加法。在中缀表达式的末尾, 被弹出两次,删除两个运算符,并将 + 作为后缀表达式中的最后一个运算符。

为了在 Python 中编写算法,我们使用一个名为 prec 的字典来保存操作符的优先级,这个字典将每个运算符映射到一个整数,可以与其他运算符的优先级(使用整数3,2和1)进行比较。左括号将赋予最低的值,这样,与其进行比较的任何运算符将具有更高的优先级,将被放置在它的顶部,第15行将操作数定义为任何大写字符或数字。

完整的转换函数如下:

from pythonds.basic.stack import Stack

def infixToPostfix(infixexpr):
    prec = {}
    prec["*"] = 3
    prec["/"] = 3
    prec["+"] = 2
    prec["-"] = 2
    prec["("] = 1
    opStack = Stack()
    postfixList = []
    tokenList = infixexpr.split()

    for token in tokenList:
        if token in "ABCDEFGHIJKLMNOPQRSTUVWXYZ" or token in "0123456789":
            postfixList.append(token)
        elif token == '(':
            opStack.push(token)
        elif token == ')':
            topToken = opStack.pop()
            while topToken != '(':
                postfixList.append(topToken)
                topToken = opStack.pop()
        else:
            while (not opStack.isEmpty()) and \
               (prec[opStack.peek()] >= prec[token]):
                  postfixList.append(opStack.pop())
            opStack.push(token)

    while not opStack.isEmpty():
        postfixList.append(opStack.pop())
    return " ".join(postfixList)

print(infixToPostfix("A * B + C * D"))
print(infixToPostfix("( A + B ) * C - ( D - E ) * ( F + G )"))
print(infixToPostfix("( A + B ) * ( C + D )"))
print(infixToPostfix("( A + B ) * C"))
print(infixToPostfix("A + B * C"))

执行结果如下

8.3、后缀表达式求值

作为最后 的示例,我们考虑对后缀符号中的表达式求值。在这种情况下, 再次是我们选择的数据结构。但是,在扫描后缀表达式时,它必须等待操作数,而不像上面的转换算法中的运算符。 解决问题的另一种方法是,每当在输入上看到运算符时,计算两个最近的操作数。

要详细的了解这一点,考虑后缀表达式 4 5 6 * + , 首先遇到操作数 45 ,此时,你还不确定如何处理它们,直到看到下一个符号时,将它们放置到 上,确保它们在下一个操作符出现时可用。在这种情况下,下一个符号是另一个操作数,所以,像先前一样,压入 中并检查下一个符号。现在我们看到了操作符 * ,这意味着需要将两个最近的操作数相乘,通过弹出 两次,我们可以得到正确的两个操作数,然后执行乘法(这种情况下结果为 30)。现在可以通过将其放回栈中来处理此结果,以便它可以表示为表达式后面的运算符的操作数。当处理最后一个操作符时,栈上只有一个值,弹出并返回它作为表达式的结果。

下图展示了整个示例表达式的栈的内容:

下图是一个稍微复杂的示例—— 7 8 + 3 2 + /

在这个例子中有两点需要注意:

  • 首先, 的大小增长收缩,然后再子表达式求值的时候再次增长。
  • 第二,除法操作需要谨慎处理。

回想下,后缀表达式的操作符顺序没变,仅仅改变操作符的位置。当用于除法的操作符从 中弹出时,它们被反转,由于除法不是交换运算符,换句话说 15/55/15 不同,因此必须保证操作数的顺序不会交换。

假设后缀表达式是一个由空格分隔的标记字符串。 运算符为 * / +- ,操作数假定为单个整数值,输出将是一个整数结果,步骤如下:

  1. 创建一个名为 operandStack 的空栈;
  2. 拆分字符串转换为标记列表;
  3. 从左到右扫描标记列表;
    • 如果标记是操作数,将其从字符串转换为整数,并将值压到 operandStack
    • 如果标记是运算符 * / +- ,它将需要两个操作数,故弹出 operandStack 两次,第一个弹出的是第二个操作数,第二个弹出的是第一个操作数,执行算术运算后,将结果压到操作数栈中
  4. 当输入的表达式被完全处理后,结果就在栈上,弹出 operandStack 并返回值。

用于计算后缀表达式的完整函数如下:

from pythonds.basic.stack import Stack

def postfixEval(postfixExpr):
    operandStack = Stack()
    tokenList = postfixExpr.split()

    for token in tokenList:
        if token in "0123456789":
            operandStack.push(int(token))
        else:
            operand2 = operandStack.pop()
            operand1 = operandStack.pop()
            result = doMath(token,operand1,operand2)
            operandStack.push(result)
    return operandStack.pop()

def doMath(op, op1, op2):
    if op == "*":
        return op1 * op2
    elif op == "/":
        return op1 / op2
    elif op == "+":
        return op1 + op2
    else:
        return op1 - op2

print(postfixEval('7 8 + 3 2 + /'))

为了辅助计算,定义了一个函数 doMath,它将获取两个操作数和运算符,执行相应的计算。

运行结果如下:

九、总结

  • 线性数据结构以有序的方式保存它们的数据。
  • 栈是维持 LIFO,后进先出,排序的简单数据结构。
  • 栈的基本操作是 pushpopisEmpty
  • 队列是维护 FIFO(先进先出)排序的简单数据结构。
  • 队列的基本操作是 enqueuedequeueisEmpty
  • 前缀,中缀和后缀都是写表达式的方法。
  • 栈对于设计计算解析表达式算法非常有用。
  • 栈可以提供反转特性。

参考文章

  • 《problem solving with algorithms and data structure using python》
  • https://github.com/facert/python-data-structure-cn