当提到表达式解析技术时,很多人第一反应可能是复杂且精细的递归下降方法。这种方法主要用于构建抽象语法树(AST),虽然功能强大,能够处理复杂的语法结构,但它通常需要较高的编程技巧和对语法分析的深入理解。对于初学者来说,这种方法可能显得有些复杂。因此,我们的目标是从简洁实用的角度出发,分享一种更适合初学者的表达式求值解析方法,即后缀表达式,也称为逆波兰表示法(Reverse Polish Notation, RPN)。RPN的优点在于可以直接通过栈操作进行高效的求值,而不需要构建复杂的语法树。
我们将从零开始,用纯C#语言实现这种后缀表达式的转换和求值方法。这种方法不仅易于理解和实现,而且在性能上也非常高效。通过这个过程,你将能够快速掌握表达式解析的基本概念和实现技巧,为后续学习更复杂的解析技术打下坚实的基础。
一、用程序写一个计算器
在编程入门阶段,实现一个简单的计算器是一个非常有价值的学习案例。以下是一个简单的示例:
- 输入: 1
- 输入: +
- 输入: 2
如果程序能够返回3,那么恭喜你,你已经掌握了变量的基本类型(如数字和字符串的转换)、基本操作符的使用(如+号求和),以及基本的输入输出操作。
如果你能够轻松实现上述简单的计算器功能,那么不妨更进一步,思考如何实现更复杂的表达式计算,例如:
csharp
CSharp
1+2*(5-1)/(2+3)^2
应该如何用程序来实现呢?
这种表达式的解析和求值是一种常见的需求。在许多场景中,我们希望能够输入公式而不仅仅是一个数值,以此来实现更多的动态配置。例如,用户可以自定义数据分析的规则和逻辑,自定义报表数据的展示,或者在开发过程中提高动态配置的灵活性,减少硬编码,增强扩展性,实现某些插件化功能。
接下来,我们将从零开始,用纯C#代码实现数值表达式的处理。所有的代码均为纯C#实现,100%无第三方依赖,免费开源,方便学习交流。希望大家关注并点赞。
二、表达式基础概念
在计算机科学中,表达式的求值是一个非常常见的任务。无论是简单的计算器程序,还是复杂的编译器设计,都离不开对表达式的解析和计算。我们首先来了解几种常见的表达式类型。
2.1 中缀表达式
我们观察公式:
csharp
"(4+2)*3"
在这个公式中,操作符(如+和*)位于操作数(如4、2、3)的中间,将要计算的前后数值连接起来。我们称这类公式为中缀表达式。中缀表达式是一种通用的算术或逻辑公式表示方法,操作符(如加号+、乘号*等)位于它所连接的两个操作数之间。我们日常使用的数学表达式大多是中缀表达式。这种对两个操作数进行运算的称为双目运算符。如果操作符只负责运算一个操作数(如-1中的负号-),我们称为单目运算符。
2.2 后缀(RPN)表达式
与中缀表达式不同,后缀表达式中的操作符位于操作数之后。例如,上述中缀表达式转换为后缀表达式为:
csharp
"3 4 2 + *"
后缀表达式的计算步骤是从左往右依次检查字符。如果是数字,则跳至下一位置;如果是操作符,则按操作符要求向前面位置取数进行计算,并将结果存储在当前位置。最终留下的就是结果。
以"3 4 2 + *"为例,我们从左往右进行计算:
- 第一位是3,跳至下一位置
- 第二位是4,跳至下一位置
- 第三位是2,跳至下一位置
- 第四位是+,为双目操作符,向前面第二和第三位置取数4、2,执行+运算,得到6
- 合并第二、第三、第四位置为位置二
- 存储计算结果6至位置2,此时原式变为 "3 6 *"
- 第三位是*,为双目操作符,向新的表达式前面第一和第二位置取数3和6,执行*运算,得到18
后缀表达式既不需要括号明确运算顺序,也不需要预先知道操作符优先级。这种无歧义的特点,特别适合计算机的存储与执行。除了中缀和后缀表达式外,还有前缀表达式,它们在各自的特定领域有其优势。
接下来,我们将详细介绍如何将中缀表达式转换为后缀表达式,以及如何对后缀表达式进行求值。
三、中缀表达式转后缀表达式(RPN)
3.1 算符优先级定义
为了理解中缀表达式,我们首先需要理解操作符的两个属性:操作目数和优先级。
- 操作目数:操作符的操作目数决定了中缀表达式中操作符可以操作多少个操作数。如果操作目数与实际操作数字不匹配,则可视为表达式出错。
- 优先级:操作符的优先级决定了中缀表达式中的运算规则。例如,乘除法优先于加减法,因为乘除操作符的优先级更高。类似地,我们可以定义幂运算的优先级比乘除操作符更高,布尔运算符的优先级最低等。如果操作优先级相同,我们默认按照从左到右的顺序执行计算。
此外,括号具有特殊的优先级。在外部操作符识别时,括号需要单独判定,但在运算时,其优先级低于其他操作符。
在C#中,我们可以定义一个Dictionary<char, int>来存储操作符的优先级:
csharp
private static Dictionary<char, int> operatorPrecedence
= new()
{
{'+', 1},
{'-', 1},
{'*', 2},
{'/', 2},
{'^', 3},
{'(', 0}, // 特殊优先级,外部单独判定
{')', 0} // 特殊优先级,外部单独判定
};
接下来,我们需要实现两个函数,分别用于判定一个字符是否为操作符,以及获取操作符的优先级:
csharp
// 判断是否是操作符
private static bool IsOperator(char c)
{
return operatorPrecedence.ContainsKey(c);
}
//获取操作符优先级
private static int GetPrecedence(char c)
{
return operatorPrecedence[c];
}
3.2 转换流程
在开始转换之前,我们需要准备一个栈operatorStack,用来临时存放操作符(如+、-、*、/),以及一个列表outputList,用来存放最终的后缀表达式:
csharp
Stack<char> operatorStack = new Stack<char>();
List<string> outputList = new List<string>();
然后,我们开始逐个处理表达式中的字符:
- 如果当前字符是数字 0-9,直接将其添加到outputList中。
- 如果当前字符是左括号 (,直接将其压入operatorStack栈中。
- 如果当前字符是右括号 ),从operatorStack栈中弹出操作符,并将其添加到outputList中,直到遇到左括号。遇到左括号后,将其从栈中弹出,但不添加到outputList中。
- 如果当前字符是操作符(如+、-、*、/),比较当前操作符和栈顶操作符的优先级。如果栈顶操作符的优先级大于或等于当前操作符的优先级,将栈顶操作符弹出,并将其添加到outputList中。重复这个过程,直到栈顶操作符的优先级小于当前操作符的优先级。然后,将当前操作符压入operatorStack栈中。
遍历完表达式后,operatorStack栈中可能还有剩余的操作符。将这些操作符依次弹出,并添加到outputList中。最终,outputList中的内容就是后缀表达式。
3.3 手动试算
让我们试算一下 "(4+2)*3":
-
第一个字符是"(", 那么压入operatorStack栈中,此时状态:
operatorStack:(
outputList: 空 -
第二个字符是4,直接加入outputList,此时状态:
operatorStack:(
outputList: 4 -
第三个字符是+,比较栈顶操作符 (, +优先级更高直接入栈,此时状态:
operatorStack:( +
outputList: 4 -
第四个字符是2,,直接加入outputList,此时状态:
operatorStack:( +
outputList: 4 2 -
第五个字符是),从operatorStack弹出操作符到outputList,直到左括号,此时状态:
operatorStack:空
outputList: 4 2 + -
第六个字符是*,此时栈为空,直接入栈,此时状态:
operatorStack:*
outputList: 4 2 + -
第七个字符是3,直接加入outputList,此时状态:
operatorStack:*
outputList: 4 2 + 3 -
字符遍历结束,operatorStack依此出栈加入outputList,此时状态:
operatorStack:空
outputList: 4 2 + 3 *
3.4 详细的代码实现
按上述逻辑设计后,具体代码实现如下:
csharp
public static List<string> InfixToPostfix(string expression)
{
Stack<char> operatorStack = new Stack<char>();
List<string> outputList = new List<string>();
foreach (char c in expression) //遍历每个字符
{
if (char.IsDigit(c)) // 如果是数字,直接输出
{
outputList.Add(c.ToString());
}
else if (c == '(') // 左括号,直接压入栈
{
operatorStack.Push(c);
}
else if (c == ')') // 右括号,弹出操作符直到遇到左括号
{
while (operatorStack.Count > 0 && operatorStack.Peek() != '(')
{
outputList.Add(operatorStack.Pop().ToString());
}
if (operatorStack.Count > 0 && operatorStack.Peek() == '(')
{
operatorStack.Pop(); // 弹出左括号
}
}
else if (IsOperator(c)) // 操作符
{
while (operatorStack.Count > 0 && GetPrecedence(operatorStack.Peek()) >= GetPrecedence(c))
{
outputList.Add(operatorStack.Pop().ToString());
}
operatorStack.Push(c);
}
}
// 将栈中剩余的操作符依次弹出并输出
while (operatorStack.Count > 0)
{
outputList.Add(operatorStack.Pop().ToString());
}
return outputList;
}
最后返回的List 就是我们的后缀表达式了。
四、后缀表达式求解(RPN)
后缀表达式的求解非常简单。首先准备一个栈用来存放数字,然后逐个读取表达式的每个部分。如果是数字,就压入栈;如果是操作符,就从栈中弹出对应的操作数进行计算,然后将结果压回栈。计算完成后,栈里剩下的最后一个数字就是表达式的结果。
以下是具体的代码实现:
csharp
public static double EvaluatePostfix(IList<string> postfix)
{
Stack<double> stack = new Stack<double>();
foreach (string token in postfix)
{
if (double.TryParse(token, out double number)) // 如果是数字,压入栈
{
stack.Push(number);
}
else // 如果是操作符
{
double b = stack.Pop(); // 弹出第二个操作数
double a = stack.Pop(); // 弹出第一个操作数
switch (token)
{
case "+":
stack.Push(a + b);
break;
case "-":
stack.Push(a - b);
break;
case "*":
stack.Push(a * b);
break;
case "^":
stack.Push(Math.Pow(a,b));
break;
case "/":
if (b == 0)
throw new DivideByZeroException("除数不能为零");
stack.Push(a / b);
break;
default:
throw new ArgumentException("无效的操作符: " + token);
}
}
}
return stack.Pop(); // 栈中剩下的最后一个元素就是结果
}
输入之前的案例效果如下:
csharp
输入: 1+2*(5-1)/(2+3)^2
输出: 1 2 5 1 - * 2 3 + 2 ^ / +
求值: 1.32
五、最后
通过上述内容,我们从零开始,用纯C#语言实现了一个简单的表达式解析器。我们首先介绍了中缀表达式和后缀表达式的基本概念,然后详细讨论了如何将中缀表达式转换为后缀表达式,以及如何对后缀表达式进行求值。这种方法不仅易于理解和实现,而且在性能上也非常高效,特别适合初学者学习和实践。希望这篇文章对你有所帮助,也欢迎大家在学习过程中提出问题和建议,欢迎随时交流!
本文中代码项目已在仓库完全开源了!点个 Star ⭐️支持一下!代码仓库地址 https://github.com/LdotJdot/RPN,其他项目可关注微信公众号,发送消息 "TDS","Json"即可查看
感谢您的耐心阅读,希望各位从零开始的新朋友和老朋友有所收获!如果你对这篇文章的内容有任何建议或想法,欢迎随时交流!关注微信公众号'萤火初芒'