中篇中,我们看到了两种简单的中缀表达式处理方法,但是它们或多或少存在缺点——第一种需要反复地扫描表达式,而第二种难以处理有大量不同优先级的状况;并且,它们都没有考虑到结合性的问题。

于是当年,在那遥远的河南荷兰,正在为实现ALGOL 60而忙碌的Dijkstra大神再也忍不住了。终于,他提出了调度场算法。

调度场算法

我们回到上篇,再次梳理一下问题。

这里只讨论关键的处理优先级、结合方向的部分,一个理想的算法应该能做到:

  • 能处理优先级。
  • 允许同一优先级的运算符从左向右或从右向左结合。
  • 处理的耗时、内存占用(即时间、空间复杂度)与表达式长度趋向于正比。
  • 可以接受较多的优先级、运算符个数,而复杂度不会显著增加。

另外,我们可以再加上两条:

  • 尽可能只扫描表达式一遍。
  • 允许在解析时,适度地动态添加运算符和规则。

好,继续观察例子:

1+(2+3)*4+5

首先需要注意,调度场算法是一种从左向右处理表达式、自底向上产生AST的算法。

考虑到AST中数字就是各个叶节点(最末端的节点),并且没有任何顺序变化,我们可以随时按需取用。因此,建立一个堆栈,遇到数字就直接放入。

输入:+(2+3)*4+5 数字堆栈:1

接下来,我们回想一下之前说到的朴素的算法,可以发现,优先级较低的运算符可能需要较晚地被处理:

  • 在第一种算法中,第二轮扫描中才能处理乘法,第三轮扫描中才能处理加法。
  • 第二种算法在处理过程中虽然不涉及顺序,但是在之后的计算中,优先级越低的运算符周围的括号越多,它们的执行被放在了递归外层,因此较晚。

调度场算法给出的解法是,建立一个堆栈,先行存储暂时还不需要处理的运算符:

输入:(2+3)*4+5 数字堆栈:1 符号堆栈:+

题外话,这个符号堆栈就是所谓的“调度场”。

“调度场”,是一个来自铁路的术语,又叫“编组站”。作为一个外行的我的理解下,调度场是一种主要用于货运调度、兼可以负责各种技术向的任务(比如维修)的车站。

左括号先压着,以等待右括号的到来:

输入:2+3)*4+5 数字堆栈:1 符号堆栈:+ (

继续:

输入:+3)*4+5 数字堆栈:1 2 符号堆栈:+ (

输入:3)*4+5 数字堆栈:1 2 符号堆栈:+ ( +

输入:)*4+5 数字堆栈:1 2 3 符号堆栈:+ ( +

好,看到右括号了。它会把左括号之后的一切弹出:

输入:*4+5 数字堆栈:1 2 3 符号堆栈:+ 弹出:+

括号对的使命结束,弹出的是一个加号。

对于双目符号的弹出操作,我们首先从数字堆栈取出两个数,执行计算,放回一个数:

输入:*4+5 数字堆栈:1 5 符号堆栈:+

继续,看到乘号。此时,符号堆栈顶端是一个加号,加号优先级较低,我们直接压入乘号:

输入:4+5 数字堆栈:1 5 符号堆栈:+ *

压入数字:

输入:+5 数字堆栈:1 5 4 符号堆栈:+ *

然后,调度场算法的关键步骤来了——下一个是加号——加号比乘号的优先级低,因此弹出乘号:

输入:5 数字堆栈:1 5 4 符号堆栈:+ 弹出:* 待压入:+

加号和堆栈内的加号具有相同的优先级,因此弹出堆栈内的加号:

输入:5 数字堆栈:1 20 弹出:+ 待压入:+

输入:5 数字堆栈:21 弹出:+ 待压入:+

现在可以压入了:

输入:5 数字堆栈:21 符号堆栈:+

之后,输入5,输入结束:

数字堆栈:21 5 符号堆栈:+

我们弹出符号堆栈中的符号:

数字堆栈:26

于是得到结果。

可以发现,如果在上面的过程中不执行计算,直接将数字和出栈的符号依次输出,我们就会得到后缀表达式;而如果计算时不是进行数学运算,而是生成一个二叉树节点,以符号为父节点,取出两个数字堆栈的成员为子节点并压回数字堆栈,我们就会得到AST。

我们可以体会到,调度场算法的核心就在于用堆栈暂存符号以备“调度”,比较新符号和栈顶原有的符号,选择其中更容易结合的(根据优先级、结合方向)出栈——更容易结合就意味着更早地参与计算。理解这点以后,调度场算法就仿佛显而易见、理所当然地存在了。

OPParser中出现的变种

OPParser中,我原本打算直接使用调度场算法。但是考虑到语法中一些复杂细节的处理,为了方便编写代码,对调度场算法做了一些改变。

改变的关键点在于:

  • 数字、括号,其实可以看作运算符,只是数字出栈比所有运算符更优先。
  • 运算符向左、向右的优先级可以不同。

头文件中,定义了如下的优先级:

 1 const Level levelConst = 4095;
 2 const Level levelAcceptAll = 0;
 3 const Level levelFlushAll = 1;
 4 const Level levelAddSubL = 255;
 5 const Level levelAddSubR = 256;
 6 const Level levelMulDivL = 511;
 7 const Level levelMulDivR = 512;
 8 const Level levelIMulL = 639;
 9 const Level levelIMulR = 640;
10 const Level levelPwrL = 769;
11 const Level levelPwrR = 768;
12 const Level levelFacL = 1023;
13 const Level levelFuncR = 1280;

左括号左侧、右括号右侧,使用与数字相同的优先级;左括号右侧和表达式开头(栈底)的优先级最低,表达式右侧和表达式结尾的优先级仅高于前者。

参照着这些定义,我们可以对表达式进行标注:

1 1+(2+3)*4+5
2 
3 begin[0]
4     [4095]1[4095] [255]+[256] [4095]([0]
5         [4095]2[4095] [255]+[256] [4095]3[4095]
6     [1])[4095] [511]*[512] [4095]4[4095] [255]+[256] [4095]5[4095]
7 [1]end

这样,我们就可以用同一套规则处理数字、运算符和括号了。

然后,从左到右压栈,比较栈顶符号的右侧优先级和新符号的左侧优先级,弹出较高的符号……不必多说,直接单步运行一遍吧:

 1 begin[0] [4095]1[4095] [255]+[256] ...
 2   Stack ^ Input
 3 
 4 begin[0] [4095]1[4095] [255]+[256] [4095]([0] ...
 5                 Stack ^ Input
 6 
 7 begin[0] [***********] [255]+[256] [4095]([0] ...
 8   Stack ^ Input  Output: 1
 9 
10 begin[0] [255]+[256] [4095]([0] ...
11   Stack ^ Input  Output: 1
12 
13 begin[0] [255]+[256] [4095]([0] [4095]2[4095] ...
14               Stack ^ Input  Output: 1
15 
16 begin[0] [255]+[256] [4095]([0] [4095]2[4095] [255]+[256] ...
17                          Stack ^ Input  Output: 1
18 
19 begin[0] [255]+[256] [4095]([0] [4095]2[4095] [255]+[256] [4095]3[4095] ...
20                                        Stack ^ Input  Output: 1
21 
22 begin[0] [255]+[256] [4095]([0] [***********] [255]+[256] [4095]3[4095] ...
23                          Stack ^ Input  Output: 1 2
24 
25 begin[0] [255]+[256] [4095]([0] [255]+[256] [4095]3[4095] ...
26                          Stack ^ Input  Output: 1 2
27 
28 begin[0] [255]+[256] [4095]([0] [255]+[256] [4095]3[4095] [1])[4095] ...
29                                      Stack ^ Input  Output: 1 2
30 
31 begin[0] [255]+[256] [4095]([0] [255]+[256] [4095]3[4095] [1])[4095] [511]*[512] ...
32                                                    Stack ^ Input  Output: 1 2
33 
34 begin[0] [255]+[256] [4095]([0] [255]+[256] [***********] [1])[4095] [511]*[512] ...
35                                      Stack ^ Input  Output: 1 2 3
36 
37 begin[0] [255]+[256] [4095]([0] [*********] [1])[4095] [511]*[512] ...
38                          Stack ^ Input  Output: 1 2 3 +
39 
40 begin[0] [255]+[256] [4095]([0] [1])[4095] [511]*[512] ...
41                          Stack ^ Input  Output: 1 5
42 
43 begin[0] [255]+[256] [*******************] [511]*[512] [4095]4[4095] ...
44                                     Stack ^ Input  Output: 1 5
45 
46 begin[0] [255]+[256] [511]*[512] [4095]4[4095] [255]+[256] [4095]5[4095] [1]end
47               Stack ^ Input  Output: 1 5
48 
49 begin[0] [255]+[256] [511]*[512] [4095]4[4095] [255]+[256] [4095]5[4095] [1]end
50                           Stack ^ Input  Output: 1 5
51 
52 begin[0] [255]+[256] [511]*[512] [4095]4[4095] [255]+[256] [4095]5[4095] [1]end
53                                         Stack ^ Input  Output: 1 5
54 
55 begin[0] [255]+[256] [511]*[512] [***********] [255]+[256] [4095]5[4095] [1]end
56                           Stack ^ Input  Output: 1 5 4
57 
58 begin[0] [255]+[256] [*********] [255]+[256] [4095]5[4095] [1]end
59               Stack ^ Input  Output: 1 5 4 *
60 
61 begin[0] [255]+[256] [255]+[256] [4095]5[4095] [1]end
62               Stack ^ Input  Output: 1 20
63 
64 begin[0] [*********] [255]+[256] [4095]5[4095] [1]end
65   Stack ^ Input  Output: 1 20 +
66 
67 begin[0] [255]+[256] [4095]5[4095] [1]end
68   Stack ^ Input  Output: 21
69 
70 begin[0] [255]+[256] [4095]5[4095] [1]end
71               Stack ^ Input  Output: 21
72 
73 begin[0] [255]+[256] [4095]5[4095] [1]end
74                             Stack ^ Input  Output: 21
75 
76 begin[0] [255]+[256] [***********] [1]end
77               Stack ^ Input  Output: 21 5
78 
79 begin[0] [*********] [1]end
80   Stack ^ Input  Output: 21 5 +
81 
82 begin[0] [1]end
83   Stack ^ Input  Output: 26

最后得到答案。

补充一点,括号还有一种更简单的处理方式——我们设一个变量x,每遇到一个左括号就将它加上一个大数,遇到右括号则减去那个大数,然后对每个符号的左、右优先级,分别加上x即可。

经过上中下三篇至此,本文终于接近尾声了。

我们应当能体会到,调度场算法的解析能力有一定的局限性。现在常见的编程语言通常具有更复杂的语法,因此通常会使用由以Yacc为代表的“编译器的编译器”自动生成的LR Parser。不过,调度场算法易于理解,也很适合手工编写,不失为一个居家旅行杀人灭口学习编程的好工具。