LR、SLR 和 LALR 分析器有什么区别?

111
LR、SLR和LALR解析器之间的实际区别是什么?我知道SLR和LALR是LR解析器的类型,但就它们的解析表而言,它们的实际区别是什么?
如何展示一个文法是LR、SLR还是LALR呢?对于LL文法,我们只需展示解析表中任何单元格不应包含多个产生式规则。对于LALR、SLR和LR是否有类似的规则?
例如,我们如何展示以下文法是LALR的:
``` S -> A S -> B A -> C d A -> e B -> C f B -> g C -> h ```
S --> Aa | bAc | dc | bda
A --> d

为什么LALR(1)可以是LALR(1),但不是SLR(1)?


编辑(ybungalobill):我没有得到关于LALR和LR之间的区别的令人满意的答案。因此,LALR的表格尺寸较小,但它只能识别LR语法的子集。有人能否更详细地解释一下LALR和LR之间的区别? LALR(1)和LR(1)将足以回答。它们都使用1个令牌向前查看,并且都是表驱动的!它们有何不同?


即使我也在寻找一个合适的答案,LALR(1)只是LR(1)的轻微修改,其中表格大小被减小,以便我们可以最小化内存使用... - vikkyhacks
9个回答

72
SLR、LALR和LR解析器都可以使用完全相同的表驱动机制来实现。
基本上,解析算法会收集下一个输入令牌T,并查询当前状态S(以及相关联的前瞻、GOTO和规约表)来决定接下来该做什么:
SHIFT:如果当前表格上说要在令牌T上进行SHIFT操作,则将(S,T)对推送到解析堆栈上,状态会根据当前标记的GOTO表格发生改变(例如,GOTO(T)),然后获取另一个输入令牌T',并重复这个过程。
REDUCE:每个状态都有0、1或多个可能发生在该状态中的规约。如果解析器是LR或LALR,则会检查令牌是否与该状态的所有有效规约的前瞻集匹配。如果令牌与语法规则G = R1 R2..Rn的规约前瞻集匹配,则进行栈规约和移位操作:调用G的语义动作,从堆栈中弹出n(从Rn)次,将(S,G)对推送到堆栈上,将新状态S'设置为GOTO(G),并使用相同的令牌T重复此过程。如果解析器是SLR解析器,则该状态最多只有一条规约规则,因此可以盲目地执行规约操作,而无需搜索以查看哪个规约适用。对于SLR解析器而言,知道是否存在规约非常有用;如果每个状态都明确记录与其相关联的规约数量,则很容易判断,而且实际上L(AL)R版本也需要该计数。
ERROR:如果既不能进行SHIFT操作也不能进行REDUCE操作,则会声明语法错误。

那么,如果它们都使用相同的机制,那有什么意义呢?

SLR 的价值在于其实现的简单性; 您不必扫描可能的规约并检查前瞻集,因为最多只有一个规约,如果从状态中没有 SHIFT 退出,则这是唯一可行的操作。应用哪个规约可以特别附加到状态上,因此 SLR 解析机器不必寻找它。实际上,L(AL)R 解析器处理了更大的语言集,并且实现起来几乎没有额外的工作量,因此除了作为学术练习外,没有人会实现 SLR。

LALR 和 LR 的区别与表格生成器有关。LR 解析器生成器跟踪特定状态的所有可能规约及其精确的前瞻集;您将得到状态,在这些状态中,每个规约都与其左上下文的精确前瞻集相关联。这往往会构建相当大的状态集。LALR 解析器生成器愿意组合状态,如果 GOTO 表和规约的前瞻集是兼容且不冲突的话,这会产生更少的状态,但代价是无法区分 LR 可以区分的某些符号序列。因此,LR 解析器可以解析比 LALR 解析器更大的语言集,但具有非常大的解析器表。实际上,人们可以找到足够接近目标语言的 LALR 语法,以使状态机的大小值得优化;LR 解析器更好的地方由解析器外部的特定检查处理。

所以:所有三种方法都使用相同的机制。SLR在某种程度上很“简单”,因为你可以忽略一小部分机制,但这是不值得费力的。LR解析了更广泛的语言集合,但状态表往往非常大。这就留下了LALR作为实际选择。
话虽如此,值得知道的是,GLR解析器可以使用更复杂的机制解析任何上下文无关语言,但使用完全相同的表格(包括LALR使用的较小版本)。这意味着GLR比LR、LALR和SLR严格更强大;如果你能编写标准的BNF语法,GLR将根据其进行解析。机制上的差异在于,GLR在GOTO表和/或前瞻集之间存在冲突时愿意尝试多个解析。(GLR如何高效地做到这一点是纯粹的天才[不是我的],但不能适用于此SO帖子)。
对我来说,这是一个极其有用的事实。我构建程序分析器、代码转换器和解析器是必要的,但“无趣”;有趣的工作是你对解析结果的处理,因此重点是在完成解析后的工作上。使用GLR意味着我可以相对容易地构建工作语法,而不是像努力将语法规则修改为LALR可用形式那样。在尝试处理诸如C++或Fortran之类的非学术语言时,这一点非常重要,因为你需要处理成千上万条规则才能很好地处理整个语言,而你不想花费你的生命来尝试破解语法规则以满足LALR(甚至LR)的限制。
作为一个著名的例子,C ++ 被认为是极难解析的... 由那些进行 LALR 解析的人。使用 GLR 机器很容易解析 C ++,几乎可以使用 C ++ 参考手册后面提供的规则。(我有一个精确的这样的解析器,它不仅可以处理普通的 C ++,还可以处理各种供应商的方言。在实践中,这只有使用 GLR 解析器才有可能,我的看法)。
[编辑 2011 年 11 月:我们扩展了我们的解析器,以处理所有的 C++11。GLR 让这变得更容易。编辑 2014 年 8 月:现在处理所有的 C++17。没有任何错误或恶化,GLR 仍然是最好的选择。]

5
GCC曾经使用Bison==LALR来解析C++。您始终可以通过添加额外的内容来增强您的解析器以处理那些让您感到痛苦的情况(向前看、这是一个类型名称等)。问题是:“这种方法有多么困难?”对于GCC而言,这相当令人痛苦,但他们使其工作了。这并不意味着这是推荐的方法,这就是我关于使用GLR的观点。 - Ira Baxter
我不明白使用GLR如何帮助你处理C ++。如果您不知道某个东西是否是类型名称,那么您就不知道如何解析“x * y;”--使用GLR如何帮助解决这个问题? - user541686
3
GLR解析器的关键在于它会生成两个语法分析(作为一个整体的语法分析“树”中的“有歧义子树”(实际上是DAG)。您可以通过引入其他上下文信息来确定要保留哪个子树。我们的C++解析器在这个问题上非常简单:它不尝试解决这个问题。这意味着我们不必将符号表构造与解析混在一起,因此我们的解析器和C++的符号表构造都非常干净,因此更易于构建和维护。 - Ira Baxter

19

LALR解析器在LR语法中合并相似的状态,以生成与等效SLR语法完全相同大小的解析器状态表,这通常比纯LR解析表小一个数量级。然而,对于过于复杂以至于不能成为LALR的LR语法,这些合并的状态会导致解析器冲突或产生未完全识别原始LR语法的解析器。

顺便说一下,在我的MLR(k)解析表算法这里我提到了关于此事的几个内容。

补充说明

简短的回答是,LALR解析表更小,但解析器机制是相同的。如果生成所有LR状态,则给定的LALR语法将产生更大的解析表,其中包含许多冗余(几乎相同)状态。

LALR表更小,因为类似的(冗余的)状态被合并在一起,有效地放弃了单独状态所编码的上下文/向前信息。优点是可以为相同的语法获得更小的解析表。

缺点是,不是所有LR语法都可以编码为LALR表,因为更复杂的语法具有更复杂的向前看,结果不是单个合并状态,而是两个或更多状态。

主要区别在于生成LR表的算法在状态之间的转换时携带更多信息,而LALR算法不会。因此,LALR算法无法确定给定的合并状态是否应该保留为两个或更多个单独状态。


3
我喜欢Honalee的想法。我的G/L(AL)R解析器生成器中有类似的思路;它产生最小的LALR机器,然后我打算在存在冲突的状态下拆分状态,但我从未完成过。这看起来像是一种产生最小尺寸“LR”解析表集合的不错方式。虽然它对于GLR能够解析的内容并没有帮助,但它可以减少GLR必须承载的并行解析数量,这将会很有用。 - Ira Baxter

11

又是一个答案(YAA)。

SLR(1),LALR(1)和LR(1)的解析算法与Ira Baxter所说的相同,但由于解析器生成算法的不同,解析器表可能会有所不同。

SLR解析器生成器创建一个LR(0)状态机,并从语法(FIRST和FOLLOW集)计算出展望符。这是一种简化的方法,可能会报告在LR(0)状态机中实际上不存在的冲突。

LALR解析器生成器创建一个LR(0)状态机,并通过终端转换从LR(0)状态机计算出展望符。这是一种正确的方法,但偶尔会报告在LR(1)状态机中不存在的冲突。

规范LR解析器生成器计算一个LR(1)状态机,并且展望符已经是LR(1)状态机的一部分。这些解析器表可能非常大。

最小LR解析器生成器计算一个LR(1)状态机,但在过程中合并兼容状态,然后从最小LR(1)状态机计算展望符。这些解析器表与LALR解析器表的大小相同或略大,提供了最佳解决方案。

LRSTAR 10.0 可以在 C++ 中生成 LALR(1)、LR(1)、CLR(1) 或 LR(*) 解析器,无论您的语法需要哪种。请参见此图表,其中显示了 LR 解析器之间的差异。

[完全披露:LRSTAR 是我的产品]


5
SLR和LR产生的解析器表格之间的基本区别在于,SLR表中的归约动作是基于Follow集合。这可能过于严格,最终导致移进-归约冲突。
另一方面,LR解析器仅基于可以实际跟随被归约的非终端符号的终端符号集合进行减少决策。这组终端符号通常是这样一个非终端符号的Follows集的真子集,因此与移进动作冲突的机会较小。
因此,LR解析器更加强大。不过,LR解析表可能会非常大。
LALR解析器从构建LR解析表的思想开始,但以一种将生成的状态组合起来的方式,以显着减少表格大小的结果。缺点是对于某些LR表格本来避免的语法可能会引入一些冲突的小机会。
LALR解析器略微比LR解析器弱,但仍然比SLR解析器强大。由于这个原因,像YACC这样的解析器生成器倾向于使用LALR。
备注:为了简洁起见,上面的SLR、LALR和LR实际上意味着SLR(1)、LALR(1)和LR(1),因此暗示了一个标记前瞻。

4
SLR解析器可以识别LALR(1)解析器可以识别的文法子集,而LALR(1)解析器又可以识别LR(1)解析器可以识别的文法子集。
每个解析器都构建为状态机,其中每个状态表示文法产生式的一些集合(以及它们在输入中的位置)。 Dragon Book 中一个不是SLR的LALR(1)文法的例子如下:
S → L = R | R
L → * R | id
R → L

这是该语法的一个状态:
S → L•= R
R → L•

表示解析器在每个可能的产生式中的位置。直到它到达结尾并尝试规约,它才知道它实际上处于哪个产生式中。

在这里,解析器可以移位一个 = 或规约 R → L

SLR(也称为LR(0))解析器将通过检查下一个输入符号是否在 R 的跟随集合中(即文法中可以跟随 R 的所有终端符号的集合)来确定是否可以进行规约。由于 = 也在此集合中,因此 SLR 解析器遇到了移位-规约冲突。

然而,LALR(1)解析器将使用可以跟随 R 的这个特定产生式的所有终端符号的集合,这只有 $(即输入结束符)。因此,没有冲突。

作为之前评论者所指出的,LALR(1)解析器与SLR解析器具有相同数量的状态。使用前瞻传播算法将前瞻符附加到来自相应LR(1)状态的SLR状态产生式上。由此产生的LALR(1)解析器可能会引入在LR(1)解析器中不存在的归约-归约冲突,但它不会引入移位-归约冲突。
在您的示例中,以下LALR(1)状态会导致SLR实现中的移位-归约冲突:
S → b d•a / $
A → d• / c

在LALR(1)分析器中,/后面的符号是每个产生式的follow集合。 在SLR中,follow(A)包括a,它也可以被移动。

4
假设一个没有前瞻的解析器正在愉快地为您的语法解析字符串。使用您提供的示例,它遇到了一个字符串dc,那么它会怎么做呢?它会将其还原为S吗?因为dc是由此文法生成的有效字符串?还是我们试图解析bdc,因为即使那也是可接受的字符串?
作为人类,我们知道答案很简单,我们只需要记住是否刚刚解析过b。但计算机很笨:)
由于SLR(1)解析器比LR(0)解析器具有额外的预读功能,因此我们知道任何数量的预读都无法告诉我们在这种情况下该怎么做;相反,我们需要回顾过去的经历。因此,规范LR解析器来拯救我们。它记得过去的上下文。
它记住这个上下文的方式是自律的,每当它遇到一个b时,它就会开始走向读取bdc的路径,作为一种可能性。因此,当它看到一个d时,它知道它是否已经走在了一条路径上。因此,CLR(1)解析器可以做到SLR(1)解析器无法做到的事情!
但现在,由于我们不得不定义很多路径,所以机器的状态变得非常大!
因此,我们合并了相同的路径,但如预期的那样,可能会引起混淆的问题。然而,我们愿意冒险以减少大小的代价。
这就是您的LALR(1)解析器。
现在如何进行算法处理。
当您为上述语言绘制配置集时,您将在两个状态中看到一个移位规约冲突。要消除它们,您可能需要考虑一个SLR(1),它根据后继条件做出决策,但您会发现它仍然无法做到。因此,您会再次绘制配置集,但这次有一个限制,即每当计算闭包时,添加的额外产生必须具有严格的后继。参考任何关于这些跟随应该是什么的教科书。

1
这不准确。 - user1524750

3
除了上面的回答外,这张图解说明了不同解析器之间的关系: enter image description here

0

在上面的答案基础上,底部向上LR解析器类中各个解析器之间的区别在于它们生成解析表时是否会产生移位/规约或规约/规约冲突。冲突越少,语法就越强大(LR(0) < SLR(1) < LALR(1) < CLR(1))。

例如,考虑以下表达式语法:

E → E + T

E → T

T → F

T → T * F

F → ( E )

F → id

它不是LR(0),但是是SLR(1)。使用以下代码,我们可以构建LR0自动机并构建解析表(我们需要扩充语法,计算具有闭包的DFA,计算操作和转移集):

from copy import deepcopy
import pandas as pd

def update_items(I, C):
    if len(I) == 0:
         return C
    for nt in C:
         Int = I.get(nt, [])
         for r in C.get(nt, []):
              if not r in Int:
                  Int.append(r)
          I[nt] = Int
     return I

def compute_action_goto(I, I0, sym, NTs): 
    #I0 = deepcopy(I0)
    I1 = {}
    for NT in I:
        C = {}
        for r in I[NT]:
            r = r.copy()
            ix = r.index('.')
            #if ix == len(r)-1: # reduce step
            if ix >= len(r)-1 or r[ix+1] != sym:
                continue
            r[ix:ix+2] = r[ix:ix+2][::-1]    # read the next symbol sym
            C = compute_closure(r, I0, NTs)
            cnt = C.get(NT, [])
            if not r in cnt:
                cnt.append(r)
            C[NT] = cnt
        I1 = update_items(I1, C)
    return I1

def construct_LR0_automaton(G, NTs, Ts):
    I0 = get_start_state(G, NTs, Ts)
    I = deepcopy(I0)
    queue = [0]
    states2items = {0: I}
    items2states = {str(to_str(I)):0}
    parse_table = {}
    cur = 0
    while len(queue) > 0:
        id = queue.pop(0)
        I = states[id]
        # compute goto set for non-terminals
        for NT in NTs:
            I1 = compute_action_goto(I, I0, NT, NTs) 
            if len(I1) > 0:
                state = str(to_str(I1))
                if not state in statess:
                    cur += 1
                    queue.append(cur)
                    states2items[cur] = I1
                    items2states[state] = cur
                    parse_table[id, NT] = cur
                else:
                    parse_table[id, NT] = items2states[state]
        # compute actions for terminals similarly
        # ... ... ...
                    
    return states2items, items2states, parse_table
        
states, statess, parse_table = construct_LR0_automaton(G, NTs, Ts)

其中文法G、非终结符和终结符符号定义如下

G = {}
NTs = ['E', 'T', 'F']
Ts = {'+', '*', '(', ')', 'id'}
G['E'] = [['E', '+', 'T'], ['T']]
G['T'] = [['T', '*', 'F'], ['F']]
G['F'] = [['(', 'E', ')'], ['id']]

这里还有几个我为LR(0)语法分析表生成实现的有用函数:

def augment(G, S): # start symbol S
    G[S + '1'] = [[S, '$']]
    NTs.append(S + '1')
    return G, NTs

def compute_closure(r, G, NTs):
    S = {}
    queue = [r]
    seen = []
    while len(queue) > 0:
        r = queue.pop(0)
        seen.append(r)
        ix = r.index('.') + 1
        if ix < len(r) and r[ix] in NTs:
            S[r[ix]] = G[r[ix]]
            for rr in G[r[ix]]:
                if not rr in seen:
                    queue.append(rr)
    return S

下图(展开查看)显示了使用上述代码构建的语法LR0 DFA:

enter image description here

下表显示了作为Pandas数据帧生成的LR(0)分析表,注意到有一些移进/规约冲突,这表明语法不是LR(0)。

enter image description here

SLR(1)解析器通过仅在下一个输入标记是被规约的非终结符的Follow集成员时才进行规约,从而避免了上述的移位/规约冲突。以下解析表由SLR生成:

enter image description here

以下动画展示了如何使用上述SLR(1)语法解析输入表达式:

enter image description here

问题中的语法也不是LR(0):

#S --> Aa | bAc | dc | bda
#A --> d    
G = {}
NTs = ['S', 'A']
Ts = {'a', 'b', 'c', 'd'}
G['S'] = [['A', 'a'], ['b', 'A', 'c'], ['d', 'c'], ['b', 'd', 'a']]
G['A'] = [['d']]

从下一个LR0 DFA和解析表中可以看出:

enter image description here

又出现了一次移位/规约冲突:

enter image description here

但是,接受形式为a^ncb^n,n≥1的字符串的以下语法是LR(0):

A → a A b

A → c

S → A

# S --> A 
# A --> a A b | c
G = {}
NTs = ['S', 'A']
Ts = {'a', 'b', 'c'}
G['S'] = [['A']]
G['A'] = [['a', 'A', 'b'], ['c']]

enter image description here

从下图可以看出,生成的分析表中没有冲突。

enter image description here

这是如何使用上述 LR(0) 解析表,使用以下代码解析输入字符串 a^2cb^2 的方法:
def parse(input, parse_table, rules):
    input = 'aaacbbb$'
    stack = [0]
    df = pd.DataFrame(columns=['stack', 'input', 'action'])
    i, accepted = 0, False
    while i < len(input):
        state = stack[-1]
        char = input[i]
        action = parse_table.loc[parse_table.states == state, char].values[0]
        if action[0] == 's':   # shift
            stack.append(char)
            stack.append(int(action[-1]))
            i += 1
        elif action[0] == 'r': # reduce
            r = rules[int(action[-1])]
            l, r = r['l'], r['r']
            char = ''
            for j in range(2*len(r)):
                s = stack.pop()
                if type(s) != int:
                    char = s + char
            if char == r:
                goto = parse_table.loc[parse_table.states == stack[-1], l].values[0]
                stack.append(l)
                stack.append(int(goto[-1]))
        elif action == 'acc':  # accept
            accepted = True
        df2 = {'stack': ''.join(map(str, stack)), 'input': input[i:], 'action': action}
        df = df.append(df2, ignore_index = True)
        if accepted:
            break
        
    return df

parse(input, parse_table, rules)

下一个动画展示了如何使用上述代码,使用LR(0)解析器解析输入字符串a^2cb^2

enter image description here


-3
一个简单的答案是所有LR(1)文法都是LALR(1)文法。 与LALR(1)相比,LR(1)在相关的有限状态机中具有更多的状态(超过两倍的状态)。这就是为什么LALR(1)文法需要更多的代码来检测语法错误的主要原因。 还有一件关于这两个文法的重要事情是,在LR(1)文法中我们可能会有较少的reduce/reduce冲突。但在LALR(1)中,reduce/reduce冲突的可能性更大。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接