四则运算表达式求值

目录

原创声明

著作权归作者 Handy 所有。商业转载请联系作者获得授权,非商业转载请注明出处。

简介

在提到栈的应用时,有一个很典型的例子就是表达式求值

具体应用时体现在:

  • 中缀表达式转后缀表达式:运算符栈
  • 后缀表达式求值:操作数栈

若直接进行中缀表达式求值,需同时操作两个栈,而将中缀表达式转为后缀表达式再求值时,每个步骤只需要专注于一个栈,操作起来更简单。本文就介绍这种方法。

预备知识

逆波兰表示法

逆波兰表示法(Reverse Polish Notation, RPN),也称为后缀表示法,是一种数学表达式的书写方式,其中运算符位于操作数之后。

例如中缀表达式 2 + 3 的逆波兰表达式写作 2 3 +

其实,在《离散数学》的学习中,我们是在《树》一章学到的逆波兰表示法。还记得吗? 其中所谓 r 叉正则树,就是每个分支点恰有r 个儿子。

对2叉有序正则树的周游方式:
① 中序行遍法——次序为:左子树、根、右子树
② 前序行遍法(波兰符号法)——次序为:根、左子树、右子树
③ 后序行遍法(逆波兰符号法)——次序为:左子树、右子树、根

逆波兰表达式有什么好处?没有括号,每当遇到运算符时,所需的操作数已经准备就绪,可以立即执行计算,具体的规则是,从左往右扫描:

  1. 遇到操作数:压入栈中
  2. 遇到运算符:弹出栈顶两个元素,执行运算,将结果压回栈中

最终栈中剩余的一个元素就是计算结果。

中缀表达式转后缀表达式

后缀表达式求值比较容易,如果遇到的是中缀表达式呢?需要进行转换,核心思想是使用运算符栈来重新排列运算符和操作数:

  1. 遇到操作数:直接输出
  2. 遇到运算符:
    1. 栈为空:入栈
    2. 栈不为空:与栈顶运算符比较优先级
      1. 当前运算符优先级 > 栈顶运算符优先级:入栈
      2. 当前运算符优先级 ≤ 栈顶运算符优先级:弹出栈顶运算符并输出,重复比较
    3. 遇到左括号:入栈
    4. 遇到右括号:弹出栈中运算符直到遇到左括号

表达式结束时弹出栈中所有运算符,就得到了对应的后缀表达式。

代码

基本计算器

class Solution:
  def __init__(self):
    self.operators = {'+', '-', '*', '/'}  # 集合

  def precedence(self, op: str):
    """定义运算符优先级"""
    if op in ('+', '-'):
      return 1
    if op in ('*', '/'):
      return 2
    return 0
  
  def get_value(self, a: int, b: int, op: str) -> int:
    """四则运算取值"""
    if op == "+":
      return a + b
    elif op == "-":
      return a - b
    elif op == "*":
      return a * b
    elif op == "/":  # 注意:题目没有除法运算,Python 中的除法运算符 / 总是返回一个浮点数,如果需要整数除法(即向下取整),可以使用 // 运算符
      return int(a / b)
    return 0
  
  def infix_to_postfix(self, s: str) -> list:
    """中缀表达式转后缀表达式"""
    s = s.replace(' ', '') # 移除空格
    stack = []  # 运算符栈
    result = []  # 后缀表达式以列表表示
    i = 0
    while i < len(s):
      if s[i].isdigit():
        # 遇到操作数直接打印
        j = i + 1
        while j < len(s) and s[j].isdigit():
          j += 1
        num = s[i:j]
        result.append(num)
        i = j  # 注意:走一大步
        continue
      elif s[i] in self.operators:
        # 遇到加减乘除:栈空否,空进栈,不空看优先级重复比较
        while stack and stack[-1] != '(' and self.precedence(stack[-1]) >= self.precedence(s[i]):
            result.append(stack.pop())  # 注意:这里不断重复比较是精华
        stack.append(s[i])
      elif s[i] == '(':
        # 遇到左括号:进栈
        stack.append(s[i])
      elif s[i] == ')':
        # 遇到右括号:出栈直到左括号
        while stack and stack[-1] != '(':
          result.append(stack.pop())
        stack.pop()  # 弹出左括号
      # 继续扫描
      i += 1
    # 收尾,全部出栈
    while stack:
      result.append(stack.pop())  # 注意:不能 extend
    return result

  def evaluate_postfix(self, postfix: list) -> int:
    """后缀表达式求值"""
    stack = []  # 操作数栈
    for token in postfix:
      if token in self.operators:
        b = stack.pop()
        a = 0
        if stack:
          a = stack.pop() # 正常两数计算,而非负数
        val = self.get_value(a, b, token)
        stack.append(val)
      else:
        stack.append(int(token))
    return stack.pop()

  def calculate(self, s: str) -> int:
    """完整计算流程"""
    postfix = self.infix_to_postfix(s)
    result = self.evaluate_postfix(postfix)
    return result

举例推导: 中缀表达式: 3 + 4 * 2 对应后缀表达式: 3 4 2 * +

求值过程:

  1. 遇到 3: 压栈 → [3]
  2. 遇到 4: 压栈 → [3, 4]
  3. 遇到 2: 压栈 → [3, 4, 2]
  4. 遇到 *: 弹出 2 和 4,计算 4*2=8,压栈 → [3, 8]
  5. 遇到 +: 弹出 8 和 3,计算 3+8=11,压栈 → [11]

对应题目:

高级计算器:支持一元运算

上述基础计算器存在的一个问题是:不支持负数,比如 5-(-2) 的计算。

为此,我们需要进行算法的改进,主要流程变为先分词,再转为后缀表达式,最后求值。这里添加了分词的预处理步骤,目的就是识别负数(或一元运算符)、小数、变量等。

基础计算器的问题在于把负数的 - 当成了减法运算,从而导致计算错误。在处理负数时有两种思路:

  • 一种是用不同的符号表示负数,如~,本质是引入了一元运算,通用性好。
  • 另一种是把负数识别出来,视作一个整体作为操作数,容易理解和实现,但这最大的局限性就是「只允许出现常数负数但不允许出现表达式负数」,即进行复杂的一元运算,例如 "- (3 - (- (4 + 5) ) )" 就无法处理,它包含了表达式负数,单独处理常数负数的算法是满足不了这种需求的。

Note

如果按第二种思路,把负数作为一个整体:

class Solution:
  def __init__(self):
    self.operators = {'+', '-', '*', '/'} 

  def precedence(self, op: str):
    """定义运算符优先级(不变)"""
    if op in ('+', '-'):
      return 1
    if op in ('*', '/'):
      return 2
    return 0
  
  def get_value(self, a: int, b: int, op: str) -> int:
    """四则运算取值(不变)"""
    if op == "+":
      return a + b
    elif op == "-":
      return a - b
    elif op == "*":
      return a * b
    elif op == "/":  # 注意:题目没有除法运算,Python 中的除法运算符 / 总是返回一个浮点数,如果需要整数除法(即向下取整),可以使用 // 运算符
      return int(a / b)
    return 0
  
  def tokenize(self, s: str) -> list:
    """增加:分词,识别负数,作为一个整体"""
    s = s.replace(' ', '') # 移除空格
    result = []
    i = 0
    while i < len(s):
      if (s[i] == '-' and 
          (i < len(s) - 1 and s[i+1].isdigit()) and 
          ((i > 0 and s[i - 1] == '(') or i == 0) ) or s[i].isdigit():
        # 识别负数(-开头并且紧跟着数字并在(后面 or -在最开头)、后面识别正数
        j = i + 1
        while j < len(s) and s[j].isdigit():
          j += 1
        result.append(s[i:j])
        i = j
        continue
      elif s[i] in {'+', '-', '*', '/', '(', ')'}:
        # 识别减号等运算符
        result.append(s[i])
      i += 1
    return result
  
  def infix_to_postfix(self, tokens: list) -> list:
    """修改:中缀表达式转后缀表达式"""
    stack = []  # 运算符栈
    result = []  # 后缀表达式以列表表示
    for token in tokens:
      if token in self.operators:
        # 遇到加减乘除:栈空否,空进栈,不空看优先级重复比较
        while stack and stack[-1] != '(' and self.precedence(stack[-1]) >= self.precedence(token):
          result.append(stack.pop())
        stack.append(token)
      elif token == '(':
        stack.append(token)
      elif token == ')':
        while stack and stack[-1] != '(':
          result.append(stack.pop())
        stack.pop()  # 弹出左括号
      else:
        # 遇到操作数直接打印
        result.append(token)
    # 收尾,全部出栈
    while stack:
      result.append(stack.pop())  # 注意:不能 extend
    return result

  def evaluate_postfix(self, postfix: list) -> int:
    """后缀表达式求值(不变)"""
    stack = []  # 操作数栈
    for token in postfix:
      if token in self.operators:
        b = stack.pop()
        a = 0
        if stack:
          a = stack.pop() # 开头一个负号的边界情况
        val = self.get_value(a, b, token)
        stack.append(val)
      else:
        stack.append(int(token))
    return stack.pop()

  def calculate(self, s: str) -> int:
    """修改:完整计算流程"""
    tokens = self.tokenize(s)
    postfix = self.infix_to_postfix(tokens)
    result = self.evaluate_postfix(postfix)
    return result

常数负数的运算都能成功,但是遇上表达式负数时,就不好使了,例如 "- (3 - (- (4 + 5) ) )",经由该程序得到的 tokens 为 ['-', '(', '3', '-', '(', '-', '(', '4', '+', '5', ')', ')', ')'],得到的 postfix 为 ['3', '4', '5', '+', '-', '-', '-'] ,计算结果为 -6,而正确结果是 -12

按更通用的第一种思路,把负数的 - 换成 ~,为计算器添上一元运算的功能。

class Solution:

  def precedence(self, op: str):
    """修改:定义运算符优先级,一元运算符目前最高"""
    if op in {'+', '-'}:
      return 1
    elif op in {'*', '/'}:
      return 2
    elif op in {'~'}:
      return 3
    return 0
  
  def get_value(self, a: int, b: int, op: str) -> int:
    """修改:四则运算取值,加上一元运算"""
    if op == "+":
      return a + b
    elif op == "-":
      return a - b
    elif op == "*":
      return a * b
    elif op == "/":  # 注意:题目没有除法运算,Python 中的除法运算符 / 总是返回一个浮点数,如果需要整数除法(即向下取整),可以使用 // 运算符
      return int(a / b)
    elif op == "~":
      return -b  # 一元运算取第二个操作数
    return 0
  
  def tokenize(self, s: str) -> list:
    """修改:分词,识别负号是二元运算还是一元运算"""
    s = s.replace(' ', '') # 移除空格
    result = []
    i = 0
    while i < len(s):
      if s[i] == '-':
        # 表示一元运算的负号出现在以下情况:
        # 1. 表达式开头: "-5 + 3"
        # 2. 左括号后: "(-5 + 3)" 或 "(-5)"
        # 3. 其他运算符后: "3 * -5" 或 "3 + -5" (可以支持,虽然一般不允许这样连续)
        if i == 0 or (s[i - 1] in {'(', '+', '-', '*', '/'}):
          result.append('~') # 一元运算
        else:
          result.append('-') # 二元运算
      elif s[i].isdigit():
        # 识别数字
        j = i + 1
        while j < len(s) and s[j].isdigit():
          j += 1
        result.append(s[i:j])
        i = j
        continue
      elif s[i] in {'+', '-', '*', '/', '(', ')'}:
        # 识别其他运算符
        result.append(s[i])
      i += 1
    return result
  
  def infix_to_postfix(self, tokens: list) -> list:
    """修改:中缀表达式转后缀表达式,仅小小修改"""
    stack = []  # 运算符栈
    result = []  # 后缀表达式以列表表示
    for token in tokens:
      if token in {'+', '-', '*', '/', '~'}:
        # 遇到加减乘除~:栈空否,空进栈,不空看优先级重复比较
        while stack and stack[-1] != '(' and self.precedence(stack[-1]) >= self.precedence(token):
          result.append(stack.pop())
        stack.append(token)
      elif token == '(':
        stack.append(token)
      elif token == ')':
        while stack and stack[-1] != '(':
          result.append(stack.pop())
        stack.pop()  # 弹出左括号
      else:
        # 遇到操作数直接打印
        result.append(token)
    # 收尾,全部出栈
    while stack:
      result.append(stack.pop())  # 注意:不能 extend
    return result

  def evaluate_postfix(self, postfix: list) -> int:
    """修改:后缀表达式求值,对一元运算取值做特化"""
    stack = []  # 操作数栈
    for token in postfix:
      if token in {'+', '-', '*', '/', '~'}:
        b = stack.pop()
        a = 0
        if token != '~':
          a = stack.pop()
        val = self.get_value(a, b, token)
        stack.append(val)
      else:
        stack.append(int(token))
    return stack.pop()

  def calculate(self, s: str) -> int:
    """完整计算流程"""
    tokens = self.tokenize(s)
    # print(tokens)
    postfix = self.infix_to_postfix(tokens)
    # print(postfix)
    result = self.evaluate_postfix(postfix)
    return result

再回到那个例子 "- (3 - (- (4 + 5) ) )",经由该程序得到的 tokens 为 ['~', '(', '3', '-', '(', '~', '(', '4', '+', '5', ')', ')', ')'],得到的 postfix 为 ['3', '4', '5', '+', '~', '-', '~'] ,计算结果为 -12,而正确结果是 -12,完美 。

对应题目:

  • 224. 基本计算器
  • 770. 基本计算器 IV:知晓后缀表达式求值后,本质上是设计怎样的数据结构来表示多项式以及如何处理多项式之间的计算,因为此时的「操作数已然不再是常数,而是变成了多项式」。提示:可用 map[string]int 来表示一个多项式,每一项的变量作为键,系数作为值(会出现负数),其中常数项的键定义为 @。每个键与对应的值相乘后得到每一项,再把全部项加起来即可表示一个多项式,而多项式对应的四则运算可举例推导其规律,加减就是每一项的系数做四则运算,但乘除可能生成新的项或合并同类项。
标签 :
comments powered by Disqus

相关文章

如何在 React 中轻松路由?使用 React Router!

开始 ¶ import React from "react"; import ReactDOM from "react-dom/client"; import { createBrowserRouter, RouterProvider, } from "react-router-dom"; import Root from "./routes/root"; const router = createBrowserRouter([ { path: "/", element: <Root />, }, ]); ReactDOM.createRoot(document.getElementById("root")).render( <React.StrictMode> <RouterProvider router={router} /> </React.StrictMode> ); root 路由比较特殊。 Not Found ¶ import ErrorPage from "./error-page"; const router = createBrowserRouter([ { path: "/", element: <Root />, **errorElement: <ErrorPage />,** }, ]); ReactDOM.createRoot(document.getElementById("root")).render( <React.StrictMode> <RouterProvider router={router} /> </React.StrictMode> ); 嵌套路由 ¶ 让 <Contact /> 渲染在 <Root /> 的 children(<Outlet />) 中: const router = createBrowserRouter([ { path: "/", element: <Root />, errorElement: <ErrorPage />, **children**: [ { path: "contacts/:contactId", element: <Contact />, }, ], }, ]); 使用 Layout 也能解决。

阅读更多
beancount-gs 一款 self-hosted 复式记账程序,简化你的记账方式!

本程序将部署在 macOS 上,不使用 Docker(mac 上的 Docker 太卡了) 动机 ¶ 我利用 beancount 来记账已经有一段时间了,但有些痛点问题困扰着我: 没有 web 界面,fava 真的只能用来展示和分析且不好理解,需要有一个方便记账的界面 text 记账的方式,在 vscode 插件能力有限的情况下,很容易忘记 assets 和 expenses 到底叫啥名,如果有 web 界面那么一定会好很多,一个下拉列表就可以解决 有没有解决方案,不需要自己造轮子的那种?

阅读更多
用 Beancount 复式记账,Double-Entry!

🤔动机 ¶ 已经有很多次记账的尝试,包括各种 App 如圈子账本(现已倒闭下架)等。但有几个痛点还没法解决: 大多数都是简单的记载收支,这对手握多个银行账户、信用卡等的我来说,显得不够用 数据安全问题,数据在服务提供者手上,既有隐私担忧,又受制于人,谁知道什么时候就删库跑路了 数据报表,大多数 App 的统计都比较垃,甚至需要 VIP 才能看

阅读更多