浅谈表达式的求值(Vol.3 使用AST进行代码解析和运行)
tiger2005
2020-02-15 21:54:06
本文同步发布于[Github Blog](https://tiger2005.github.io/post/qian-tan-biao-da-shi-de-qiu-zhi-vol3/)和[Zhihu](https://zhuanlan.zhihu.com/p/108698849)
---
Update: 2020/5/29 22:45
本作为此系列的第三部分,前两部分可以从下面的索引进入。
[> Vol.1 后缀表达式](https://www.luogu.com.cn/blog/tiger2005/qian-tan-biao-da-shi-di-qiu-zhi-hou-zhui-biao-da-shi-post)
[> Vol.2 进阶](https://www.luogu.com.cn/blog/tiger2005/qian-tan-biao-da-shi-di-qiu-zhi-jin-jie-post)
**强烈建议** 先弄懂`Vol.1`的知识,`Vol.2`在此文中未被用到。
Update: 2020/5/29 23:01
加入关于 [P5067](https://www.luogu.com.cn/problem/P5067) 的思考以及模糊解决思路,详见文章末尾。
---
> 哪个OIer不想做出模拟C++解析器([UOJ 98](http://uoj.ac/problem/98))呢?
我也有过这个梦想。
打开这道题的统计,稍稍扫过前10,虽然我们不能像第一一样使用很风骚的方式 AC 这一道题,但是我们可以利用表达式树解决(这个东西虽然慢,但是很鲁棒好想,对于没有接触过这种大模拟的人是不二之选)。
**前方高能,请务必确认你知道(抽象)表达式树**(**A**bstract **S**yntax **T**ree,简称 AST) 。如果不知道,看看这个系列的 `Vol.1` 就可以了。
那么,开始吧。
---
首先,我们可以在题目的最后获得一份上下文无关文法,地址如下:[here](https://www.luogu.com.cn/paste/b7s6rtl0)
这看起来很头疼。我在这里给大家一个入门介绍(P.S.:其他上下文无关文法可能和这个有点不一样,但本质相同)。
举个例子吧。
```cpp
FUNC_AND_VAR ::=
| ε
| int NAME ( OPTPARAMS ) { STATEMENTS } FUNC_AND_VAR
| int DEFINEVAR DEFINEVARS ; FUNC_AND_VAR
```
这一块中,`::=`前面的是符号名字,这里是`FUNC_AND_VAR`
之后三行是 `FUNC_AND_VAR` 可能出现的三种情况,相当于 OR 运算。在其他上下文无关文法中会遇到用 `|` 分开的几种情况,本质是相同的。
第一行,$ε$ 字符代表“空空如也”,也就是空串。
第二行的意思是,开头是字符串 `int`,之后一个名字(也就是函数名),括号内装着函数的参数列表,之后大括号内装着一些语句。再然后又是 `FUNC_AND_VAR`。
第三行是变量定义,具体在后面一点会讲到。
在这里用比较形象的方式解释一下为什么后面要加 `FUNC_AND_VAR`。
当我们拿到一个 `FUNC_AND_VAR` 的时候,我们可以选择将它变成一个空串结束分裂,或者将它变成一个函数紧跟着一个 `FUNC_AND_VAR`,这个 `FUNC_AND_VAR` 还可以继续分解回到上面的情况……
由于分裂可以产生一个函数或者一个变量定义。所以, `FUNC_AND_VAR` 将会产生一个由函数和变量定义组合而成的代码——这就是一份 C++ 代码除去头文件、define等东西之后剩下的了。
之后看看 `DEFINEVARS`:
```cpp
DEFINEVARS ::=
| ε
| , DEFINEVAR DEFINEVARS
```
和上面的道理一样,`DEFINEVARS` 将会产生由一堆`, DEFINEVAR` 构成的语句。
那么 `int DEFINEVAR DEFINEVARS ;` 就是:
一个字符串 `int`,之后一个变量,然后是若干个 `, [变量]`,最后一个`;`。
---
利用上面的信息,我们大概可以读懂上下文无关文法了。
但是,按照表达式树的概念来说,我们应该找到运算符并让它成为根。
两者如何兼容呢?
此时我们有三种思路。
### 1:类递归
假设现在来到了只有 `+`,`-` 或者更高运算级的运算符(你可以理解为只有 `+,-,*,/,%` 的表达式)。
我们找到对应的文法:
```cpp
UNIT3 ::=
| UNIT2
| UNIT3 + UNIT2
| UNIT3 - UNIT2
```
我们可以按照这三种情况分别考虑:
从左到右扫这个序列,跳过括号。
如果找到 `+` 或者 `-`,说明有运算符符合条件2和条件3,那么:
按照`UNIT3 [OPERATOR] UNIT2`的规则,等效替换成`UNIT2 [OPERATOR] UNIT3`(你可以尝试一下,这两个是真的一样的!)之后就可以递归了。
新建一个该运算符的节点,左儿子连运算符左边的式子用 `UNIT2` 文法匹配出的 AST,右儿子连运算符右边的式子用 `UNIT3` 文法匹配出的 AST。这就是这个表达式代表的 AST 了(请多读几遍这句话)。
之后我们就可以得到伪代码:
```cpp
Unit3 ( string Exp )
N = Exp的长度
for c from 1 to N
if(Exp[c]=='(')
寻找和c匹配的括号下标c1
c = c1
continue
else if(Exp[c]=='+' || Exp[c]=='-')
定义ret为一个新AST
将Exp[c]的信息填入ret
//这里的信息指的是加号或者减号
//这是AST在非叶子节点储存的状态
//详见Vol.1 的AST定义
ret的第一个参数设为Unit2(Exp在位置c前面的字符串)
ret的第二个参数设为Unit3(Exp在位置c后面的字符串)
return ret
return Unit2(Exp)
```
P.S.:真正的题目中会遇到中括号,也是直接跳过就行了。或者在对变量处理之后再计算 AST。
递归的终止状态就是名字、数字等不会延申、数值一定的东西。
### 2:类分治
还是假设现在来到了只有 `+`,`-`或者更高运算级的运算符(你可以理解为只有 `+,-,*,/,%` 的表达式)。
对应的文法:
```cpp
UNIT3 ::=
| UNIT2
| UNIT3 + UNIT2
| UNIT3 - UNIT2
```
我们直接分析这段代码的本质。
这份代码实际上是把字符串按照将要运算(不在括号中)的 `+` 和 `-`,之后将代码分成几个小块,在计算值后带回原式子计算。
在 AST 的眼中就是:将表达式按照将要运算的 `+,-` 分开,求出每一个小块的 AST,之后用左结合的方式将它们合并在一起( `=` 是右结合)
简单解释一下左结合和右结合:
```
a + b + c <-> ( a + b ) + c
a = b = c <-> a = ( b = c )
```
之后我们就可以得到伪代码(注:表达式的基础层相当于无括号包围,是我自己定义的说法):
```cpp
Unit3 ( string Exp )
if(在Exp的基础层中没有'+'或'-')
return Unit2(Exp)
将Exp按照 Exp基础层中的'+'和'-' 分割为若干个字符串
并储存在S集合中
对S的所有元素运行Unit2函数
并储存在ASTSet集合中
while(ASTSet.size()>1)
从ASTSet中取出第一个元素和第二个元素
并设为u1和u2
定义v为一个新AST
将Exp中u1和u2中间的符号储存在v中
//这里的符号就是'+'或者'-'
将v的第一个参数设为u1
将v的第二个参数设为u2
将v储存为ASTSet的第一个元素
return ASTSet的唯一一个元素
```
### 3:中后树
实际上就是将表达式转换为后缀表达式,之后变成AST,不需要递归,也不用分治。具体的方法可以参考该系列的 Vol.2,那里已经够清楚了。
大概画一下最后的树。
例子1: `1+2-3`
```
-
/ \
+ 3
/ \
1 2
```
例子2: `a=b=c`
```
=
/ \
a =
/ \
b c
```
在多参数的运算中,使用多叉树(或说,一个指针列表)储存参数,这样就可以将一个运算符所需要的所有参数都储存下来。
这是在敲完代码后的函数一览(这说明 AST 实际上是很麻烦的,但可以视情况使用 Ctrl+C/V 让自己轻松一点):
![](https://cdn.luogu.com.cn/upload/image_hosting/bk9dnqjj.png)
---
在你实现的过程中,可能会出现如下问题(这些问题在程序分析里很常见):
Q1 : 如何判断 `+,-` 在表达式中是加减号还是正负号?
A1 : 这是我在代码中的方法:如果前一个字符是运算符而且非 `)` 或 `]`,那么这个字符是正负号,否则为加减号。如果有 hacker 的可以直接告诉我。
Q2 : 如何将表达式分割成名字、运算符、数字呢?
A2 : 使用**贪婪匹配**原则。在出现字母和下划线开头说明这是变量,直接匹配到运算符或者空格;在出现数字开头时说明是数字,打法和快读差不多;出现运算符说明就是运算符,往下数一两个字符,看看是不是符合的运算符,取最长的就行了。
Q3 : 直接使用 struct 赋值会爆炸啊!怎么处理啊……
A3 : 使用指针。~~我之前就犯了这个错误还用替换换了半个小时才变成指针……~~
---
接下来是设计变量值的储存载体。
对于一个变量来说,有以下几个重要的参数:
名字、指针位置、数组维度,以及值。
我们有两种设计思路。
一是选择将信息用一个 struct 打包起来,之后用字符串- struct 映射获取。
```cpp
class Var
string name
int index
vector < int > dimensionality
define MemoryPool as map < string , Var >
```
二是直接用一个大 struct,里面装着四个映射,分别对应变量的四个参数。
```cpp
class MemoryPool
map < string , int > indexMap
map < string , vector < int > > dimensionalityMap
```
具体的值使用 vector 就行了,每一次根据变量大小开对应数量的内存。
关于多维度值的获取就要有点讲究了。
假设要取值的参数存在 `A` 数组,变量维度存在 `B` 数组,维度大小为 `N`。
我们先处理后缀积:$C_x=\Pi_{i=x+1}^NB_i$
之后需要的值位置就是:$\Sigma_{i=1}^NC_i \times A_i$
换句话说,我们得到了一个长为 `N` 的数字 `A`,每一位遵循 $B_i$ 进制原则,满 $B_i$ 进一。我们需要求 `A` 代表的数字。
我们在运算后将会得到一个数字,这个加上变量的参数指针后就是一个独一无二的位置了。
……好像这样讲优点不清楚。我们直接上代码。
这是我的内存载体中找值的函数:
```cpp
int findNum(string name,vector<int> argv){
// Var_name A
int id=index[name],qwq=1;
vector<int> rr=info[name]; // B
for(int i=argv.size()-1;i>=0;i--){
id+=qwq*argv[i];
qwq*=rr[i];
}
return memory[id];
}
```
之后是变量的重名。
在 UOJ98 中,作者很贴心的给了一个条件:
> 没有函数和变量重名
但这还不够。我们还要处理全局变量和局部变量的重名。
首先,在运行 AST 求值时,要加入一个内存载体作为参数。这个内存载体在运行函数时新建一个,新建的时候要加入函数的参数。
其次,找值的时候,要现在局部变量(就是刚刚的内存载体参数)中查看有没有该变量,之后去全局变量查看。
最后,`for` 和代码块(就是用 `{}` 包起来的语句)内的变量在运行结束后要清空(也就是开一个 string 为元素的数组,将变量名存进去,最后在内存载体内直接销毁)。请牢牢记住这一点 ~~,我在这个上面调了大半天~~。
---
变量都不成问题了,最后就是运行求值了。
这一部分十分简单,但写起来会有一点痛苦。
这一部分只需要按照当前的 AST 节点的属性分类就行了。
伪代码显示代码的一部分:
```cpp
runAST ( AST x )
if x是数字
return x代表的数字
if x是加号 then
将a设为runAST(x的第一个参数)
将b设为runAST(x的第二个参数)
return a + b
if x是减号 then
将a设为runAST(x的第一个参数)
将b设为runAST(x的第二个参数)
return a - b
```
这时候,你会遇到最后一个问题。
Q : 函数返回值怎么处理?
A : 将函数的返回值设为二元组,一个设定为是否为 `return` 发出的值,一个设定为有用的值(如果前者为 `true` 则设为为返回值,为 `false` 则为运算结果)。传参在遇到 `FUNCTION` 类型的 AST 节点结束,否则清空临时变量后直接返回。
---
最后,拿着辛辛苦苦敲出来的代码,完成最后的调试。
这个时候你就要有足够的耐心调试了。(笑)
如果你凭自己成功拿到深绿色答复的话,你已经掌握了一种表达式求值方法,并且可以运用它分析、运行代码。现在,你可以尝试将它改成一个更开放的程序,使得你可以根据客户给出的上下文无关文法和运算法则运行代码,或是自己创造一种编程语言并用这份代码改编成解释器。
Last Question : 这不是只是讲了一遍 UOJ98 吗?标题党实锤!
A : 实际上,这道题的解题过程和使用AST进行代码解析和运行的本质一样。这道题的解题流程是:找到上下文无关文法 $\rightarrow$ 根据文法写出 AST 构造函数 $\rightarrow$ 通过 AST 节点类型计算、修改值。实际上,表达式可以通过一个上下文无关文法构造,根据这个方法也可以计算表达式的值。
虽然在前面的学习中可以用后缀表达式快速计算出表达式的值,但是一些区间表达式求值的题目上就可能需要 $O(n^2)$ 的复杂度。但是,在 AST 进行树高处理使得表达式树的高度在 $O(\log(n))$ 的复杂度后,可以只用 $O(n\log(n))$ 的复杂度解决问题。(`from 2020/5/29 22:48` 此方法 **仅供参考** ,本人将会深入分析并努力找寻方案,为[P5067](https://www.luogu.com.cn/problem/P5067)寻找解题方法)(`from 2020.5.29 23:00` 方法已找到,具体为 FHQ-Treap 基于表达式的改造,感觉是一个很好玩的主题,考虑出一个`Vol.(3.5)`)
`Vol.4`是简单正则表达式和带输出类有限状态机,是一种快速、灵活计算 AST 的方法,可以学习一下 Trie,理解 Trie 随字符而定的状态转移。