【发布时间】:2026-01-27 10:15:02
【问题描述】:
我熟悉 LR(1) 解析器,它们通常在传统的编译器课程中教授。我知道存在 LR(2) 解析器,但我之前从未见过构造器。
LR(2) 解析器是如何构造的?它与 LR(1) 解析器有何不同?
【问题讨论】:
我熟悉 LR(1) 解析器,它们通常在传统的编译器课程中教授。我知道存在 LR(2) 解析器,但我之前从未见过构造器。
LR(2) 解析器是如何构造的?它与 LR(1) 解析器有何不同?
【问题讨论】:
在许多方面,LR(2) 解析器的工作方式类似于 LR(1) 解析器。它反向追踪最右边的推导,维护堆栈,在堆栈上执行移位和归约操作,具有由 LR 项集组成的状态等。但是,有一些主要区别:
为了说明这是如何工作的,让我们举一个 LR(2) 语法的例子。 (这个语法来源于@rici's excellent answer to this earlier question中提到的一个)。
S → RS |回复
R → abT
T → aT | c | ε
要为这个语法构建我们的 LR(2) 解析器,我们将像往常一样,通过使用 S' → S 形式的产生式来扩充语法开始:
S' → S
S → RS |回复
R → abT
T → aT | c | ε
现在,我们开始生成配置集。与 LR(1) 解析器一样,我们从产生式 S' → S 开始。如下所示:
(1)
S' -> .S [$$]
请注意,前瞻是 $$,表示“流结束”标记的两个副本。在传统的 LR(1)(或 SLR(1) 或 LALR(1))解析器中,我们会在此处预读 $,这只是流结束标记的一个副本。
我们现在开始扩展此配置集中的其他项目。由于我们有产生式 S → RS 和 S → R,我们添加这些项目:
(1)
S' -> .S [$$]
S -> .R [$$] // New
S -> .RS [$$] // New
现在,让我们开始追查接下来会发生什么。就像在 LR(1) 解析器中一样,由于这里的非终结符 R 之前有点,我们需要将它们展开。就像在 LR(1) 解析中一样,当我们这样做时,我们需要确定使用什么前瞻。我们将首先扩展 S -> .R [$$] 项目,如下所示:
(1)
S' -> .S [$$]
S -> .R [$$]
S -> .RS [$$]
R -> .abT [$$] // New
接下来,让我们扩展S -> .RS [$$] 选项。这是一个有趣的案例。我们需要确定这里发现的 R 产品的前瞻是什么。在 LR(1) 解析器中,这是通过查看产生式其余部分的第一个集合来找到的。在 LR(2) 解析器中,因为我们有两个前瞻标记,我们必须查看 FIRST2 集,它是 FIRST 集的泛化,列出了长度为 2 的字符串可以出现在产生式的前面,而不是长度为 1 的字符串可以出现在那里。在我们的例子中,FIRST2(S) = {ab}(你明白为什么吗?),所以我们有以下内容:
(1)
S' -> .S [$$]
S -> .R [$$]
S -> .RS [$$]
R -> .abT [$$]
R -> .abT [ab] // New
至此,我们已经完成了第一个配置集的扩展。现在是时候考虑如果我们接下来看到不同的角色我们会怎么做。幸运的是,在这种情况下,这相当容易,因为此语法生成的任何字符串的第一个字符都必须是 a。那么让我们看看如果遇到a会发生什么:
(2)
R -> a.bT [$$]
R -> a.bT [ab]
目前看来还不错。现在如果我们在这里看到b 会发生什么?这将把我们带到这个地方:
(3)
R -> ab.T [$$]
R -> ab.T [ab]
这里有两个 LR(2) 项在非终结符之前有点,所以我们需要将它们展开。让我们首先将这些扩展为 R -> ab.T [$$],给出以下内容:
(3)
R -> ab.T [$$]
R -> ab.T [ab]
T -> .aT [$$] // New
T -> .c [$$] // New
T -> . [$$] // New
接下来,我们将扩展 R -> ab.T [ab] 的生产:
(3)
R -> ab.T [$$]
R -> ab.T [ab]
T -> .aT [$$]
T -> .c [$$]
T -> . [$$]
T -> .aT [ab] // New
T -> .c [ab] // New
T -> . [ab] // New
这填写了这个配置集。这是我们第一次发现一些完整的 reduce 项目(这里,T -> . 带有两个不同的前瞻)。我们这里也有一些轮班项目。所以我们不得不问 - 我们这里有移位/归约冲突还是归约/归约冲突?
让我们从减少/减少冲突开始。与 LR(1) 解析中的情况一样,当有两个不同的 reduce 项(末尾带有点的项)具有相同的前瞻时,我们就会遇到 reduce/reduce 冲突。在这里,我们有两个不同的 reduce 项目,但它们有不同的前瞻。这意味着我们在减少/减少方面很好。
现在,有趣的案例。我们这里有任何移位/减少冲突吗?这是与 LR(1) 解析相比有所改变的地方。与 LR(1) 解析中的情况一样,我们查看集合中的所有 shift 项(终端前带有点的项)和集合中的所有 reduce 项(末尾带有点的项)。我们正在查看是否存在任何冲突:
T -> .aT [$$] // Shift
T -> .c [$$] // Shift
T -> . [$$] // Reduce
T -> .aT [ab] // Shift
T -> .c [ab] // Shift
T -> . [ab] // Reduce
不过,问题是这里的移位/减少冲突是什么样的。在 LR(2) 解析器中,我们有两个前瞻标记,我们基于这些标记来决定是移位还是归约。在减少项目的情况下,很容易看出两个前瞻标记将引导我们减少什么 - 它是括号中的两个字符前瞻。另一方面,考虑班次项目T -> .c [ab]。我们要转移的两个字符的前瞻是什么?对于 LR(1) 解析器,我们只会说“哦,点在 c 之前,所以我们转移到 c”,但这还不够。相反,我们会说与这个班次项目相关的前瞻是ca,c 来自生产本身,a 来自项目前瞻的第一个字符。
同样,考虑换档项目T -> .aT [$$]。我们需要两个前瞻字符,我们可以很容易地看到其中一个(点后的a)。要获得第二个,我们必须看看T 能够生产什么。 T 有三种产生式:一种用 ε 代替 T,一种用 aT 代替 T,另一种用 c 代替 T。这意味着可以从 T 派生的任何字符串都以a 或c 开头,或者是空字符串。结果,T -> .aT [$$] 项目告诉我们在看到 ac 或 aa(我们从 a 和 c 得到的)或 a$(如果我们得到使用产生式 T → ε,然后从正常的前瞻中选择 $ 字符之一。
更一般地说,遵循相同的一般程序 - 使用点后面的终端,项目的前瞻集中的终端,以及可以出现在任何可从未来非终端派生的字符串前面的字符 - 我们可以计算我们用来确定何时移位的两个字符的前瞻。特别是,我们留下了这个:
(3)
R -> ab.T [$$]
R -> ab.T [ab]
T -> .aT [$$] // Shift on aa, ac, a$
T -> .c [$$] // Shift on c$
T -> . [$$] // Reduce on $$
T -> .aT [ab] // Shift on aa, ac
T -> .c [ab] // Shift on ca
T -> . [ab] // Reduce on ab
幸运的是,这里没有 shift/reduce 冲突,因为每个两个字符的前瞻都告诉我们做一些不同的事情。
查看点来确定何时移位是 LR(2) 解析的新内容,它出现在 LR(k > 1) 解析中,但不是 LR(1) 解析。在 LR(1) 解析中,我们只需要查看点之后的终端。在 LR(2) 解析中,由于我们需要更多字符来确定要执行的操作,因此我们必须为每个移位项计算辅助 dot lookahead。具体来说,点前瞻的确定方式如下:
在产生式 A → α.tω [γ] 中,其中 t 是终结符,点前瞻是所有长度为 2 的字符串的集合,这些字符串可以出现在可从 tωγ 导出的字符串的开头。换句话说,产生式 A → α.tω [γ] 的点前瞻等于 FIRST2(tωγ)。
考虑到所有这些,我们可以构建完整的 LR(2) 解析器并描述与每个状态相关的动作。整个 LR(2) 解析器如下所示:
(1)
S' -> .S [$$] // Go to 10
S -> .R [$$] // Go to 8
S -> .RS [$$] // Go to 8
R -> .abT [$$] // Shift on ab, go to (2)
R -> .abT [ab] // Shift on ab, go to (2)
(2)
R -> a.bT [$$] // Shift on ba, bc, b$, go to (3)
R -> a.bT [ab] // Shift on ba, bc, go to (3)
(3)
R -> ab.T [$$] // Go to 7
R -> ab.T [ab] // Go to 7
T -> .aT [$$] // Shift on aa, ac, a$, go to (4)
T -> .c [$$] // Shift on c$, go to (5)
T -> . [$$] // Reduce on $$
T -> .aT [ab] // Shift on aa, ac, go to (4)
T -> .c [ab] // Shift on ca, go to (5)
T -> . [ab] // Reduce on ab
(4)
T -> a.T [$$] // Go to 6
T -> a.T [ab] // Go to 6
T -> . [$$] // Reduce on $$
T -> .aT [$$] // Shift on aa, ac, a$, go to (4)
T -> .c [$$] // Shift on c$, go to (5)
T -> . [ab] // Reduce on ab
T -> .aT [ab] // Shift on aa, ac, go to (4)
T -> .c [ab] // Shift on ca, go to (5)
(5)
T -> c. [$$] // Reduce on $$
T -> c. [ab] // Reduce on ab
(6)
T -> aT. [$$] // Reduce on $$
T -> aT. [ab] // Reduce on ab
(7)
R -> abT. [$$] // Reduce on $$
R -> abT. [ab] // Reduce on ab
(8)
S -> R. [$$] // Reduce on $$
S -> R.S [$$] // Go to 9
S -> .RS [$$] // Go to 8
S -> .R [$$] // Go to 8
R -> .abT [$$] // Shift on ab, go to (2)
R -> .abT [ab] // Shift on ab, go to (2)
(9)
S -> RS. [$$] // Reduce on $$
(10)
S' -> S. [$$] // Accept on $$
所以现在我们有了这个语法的 LR(2) 解析器。现在剩下要做的就是按照我们为 LR(1) 解析器所做的工作,将其编码为一个动作和转到表。
正如您所料,LR(2) 解析器的操作表与 LR(1) 解析器的操作表不同,因为它由两个字符而不是一个字符给出的前瞻来键入。这意味着 LR(2) 解析器的操作表将比 LR(1) 解析器大得多。这是这里的样子:
state | aa | ab | ac | a$ | ba | bb | bc | b$ | ca | cb | cc | c$ | $$
------+----+----+----+----+----+----+----+----+----+----+----+----+----
1 | | S | | | | | | | | | | |
------+----+----+----+----+----+----+----+----+----+----+----+----+----
2 | | | | | S | | S | S | | | | |
------+----+----+----+----+----+----+----+----+----+----+----+----+----
3 | S | R | S | S | | | | | S | | | S | R
------+----+----+----+----+----+----+----+----+----+----+----+----+----
4 | S | R | S | S | | | | | S | | | S | R
------+----+----+----+----+----+----+----+----+----+----+----+----+----
5 | | R | | | | | | | | | | | R
------+----+----+----+----+----+----+----+----+----+----+----+----+----
6 | | R | | | | | | | | | | | R
------+----+----+----+----+----+----+----+----+----+----+----+----+----
7 | | R | | | | | | | | | | | R
------+----+----+----+----+----+----+----+----+----+----+----+----+----
8 | | S | | | | | | | | | | | R
------+----+----+----+----+----+----+----+----+----+----+----+----+----
9 | | | | | | | | | | | | | R
------+----+----+----+----+----+----+----+----+----+----+----+----+----
10 | | | | | | | | | | | | | A
如您所见,这里的每个条目都只是说明是移位还是减少。在实践中,每个 reduce 项目都会被标记为您实际上要为哪个生产进行归约,但是,嗯,我懒得输入。
在 LR(1) 解析表中,您通常会将此表与“goto”表结合起来,说明在看到每个符号后要去哪里。这是由于一个偶然的巧合。在 LR(1) 解析器中,前瞻的大小为 1,这恰好与 goto 表说明在看到下一个字符后应该转换到的位置这一事实一致。在 LR(2) 解析器中,关于是移位还是归约的决定取决于前瞻的两个字符,但在读取输入的一个字符后选择下一步去哪里仅取决于单个字符。也就是说,即使您有两个前瞻标记来决定是否执行,您一次也只能移动一个字符。这意味着 LR(2) 解析器的 goto 表看起来很像 LR(0) 或 LR(1) 解析器的 goto 表,如下所示:
state | a | b | c | $ | S | R | T
-------+-----+-----+-----+-----+-----+-----+-----
1 | 2 | | | | 10 | 8 |
-------+-----+-----+-----+-----+-----+-----+-----
2 | | 3 | | | | |
-------+-----+-----+-----+-----+-----+-----+-----
3 | 4 | | 5 | | | | 7
-------+-----+-----+-----+-----+-----+-----+-----
4 | 4 | | 5 | | | | 6
-------+-----+-----+-----+-----+-----+-----+-----
5 | | | | | | |
-------+-----+-----+-----+-----+-----+-----+-----
6 | | | | | | |
-------+-----+-----+-----+-----+-----+-----+-----
7 | | | | | | |
-------+-----+-----+-----+-----+-----+-----+-----
8 | 2 | | | | 9 | 8 |
-------+-----+-----+-----+-----+-----+-----+-----
9 | | | | | | |
-------+-----+-----+-----+-----+-----+-----+-----
10 | | | | | | |
所以,总结一下:
有趣的是,一旦您知道如何构建 LR(2) 解析器,您就可以概括为任何 k ≥ 2 构建 LR(k) 解析器的想法。特别是,这里出现的所有“新惊喜”都是与您在 k 值越来越大时看到的相同。
在实践中,LR(2) 解析器很少使用,因为它们的操作表的大小以及由于增加的前瞻,它们通常比 LR(1) 解析器具有更多的状态。但是,恕我直言,看看它们如何工作仍然是值得的。它让您了解 LR 解析的工作原理。
希望这会有所帮助!
非常感谢 @AProgrammer's answer on cs.stackexchange.com 关于点前瞻与项目前瞻,这帮助我更好地了解点前瞻的功能及其用途!
如果您想阅读关于 LR(k) 解析的原始论文,其中详细说明了 LR(k) 解析的完整规则,请查看“On the Translation of Languages from Left to对”,Don Knuth。
【讨论】: