☰
从零实现LR分析器:编译原理实验的自动机思维与冲突排查
2026/10/15 11:59:32 网站建设 项目流程

1. 从“手写解析器”到“自动机思维”的转变

很多人第一次接触编译原理,最发怵的就是语法分析这一章。词法分析还能靠正则表达式和状态机硬扛,到了语法分析,尤其是自下而上的LR分析,一堆术语砸过来——项目集规范族、闭包、GOTO函数、移进归约冲突、SLR、LALR——直接把人劝退。我当年做这个实验的时候,对着龙书那几十页推导看了整整两天,才把“为什么需要自下而上”这件事想明白。

这篇博文就是围绕“自下而上的语法分析——LR分析”这个实验展开的。我会把整个实验从设计思路、核心原理、代码实现到调试踩坑,完整地拆一遍。适合正在做编译原理实验的在校学生,也适合想重新捡起编译基础、理解解析器底层机制的开发者。读完你至少能搞清楚三件事:LR分析器到底是怎么“自动”工作的、怎么从零把它写出来、以及遇到冲突时该怎么排查和解决。

先说一个我自己的感受:LR分析这个实验,难的不是写代码,难的是理解它背后的“自动机思维”。你一旦把状态栈和符号栈的配合想通了,剩下的就是机械的填表工作。但如果你没想通,代码写出来也是抄的,调bug的时候完全不知道从哪下手。

2. 为什么自下而上的语法分析值得单独做一个实验

2.1 自下而上和自上而下的本质区别

自上而下的分析,比如递归下降,思路很直观:从文法的开始符号出发,试图推导出输入的句子。你看到E -> T + E,就先去匹配T,再匹配+,再匹配E。这个过程是“预测”式的,你需要提前知道该用哪条产生式。

自下而上的分析反过来:从输入的终结符串出发,试图归约到开始符号。你手里有一串token,看到能匹配某条产生式右部的片段,就把它替换成左部的非终结符。这个过程是“识别”式的,你不需要提前预测,而是等模式出现后再决定归约。

打个生活化的比方。自上而下就像你拿着菜谱做菜,先看菜谱说“需要鸡蛋”,再去冰箱找鸡蛋。自下而上就像你打开冰箱,看到鸡蛋、番茄、葱,然后判断“这些东西能做个番茄炒蛋”,再决定把它们组合起来。LR分析就是后者,它不预设你要做什么菜,而是根据手头的食材(输入符号)和当前状态,决定下一步是继续拿食材(移进)还是把已有的食材组合成一道菜(归约)。

2.2 LR分析为什么是“最通用”的自下而上方法

LR分析的全称是Left-to-right scan, Rightmost derivation in reverse,意思是从左到右扫描输入,构造最右推导的逆过程。它的核心优势在于:能处理的文法范围比LL(k)大得多,而且能在分析过程中及时发现语法错误。

LR分析家族有几个变体,按能力从弱到强排列:

类型全称能力状态数适用场景
LR(0)最简单的LR最弱最少教学演示
SLR(1)Simple LR较弱较少小型文法
LALR(1)Look-Ahead LR较强中等实际编译器常用
CLR(1)Canonical LR最强最多理论完备但状态爆炸

实际工程中,LALR(1)是性价比最高的选择。它比SLR(1)多了一个“向前看”符号的能力,能解决更多冲突,同时状态数又比CLR(1)少很多。很多编译器生成工具(比如yacc/bison)默认就是LALR(1)。

这个实验通常要求实现SLR(1)或LALR(1)。我建议先从LR(0)项目集规范族开始,把基础框架搭好,再逐步加入向前看符号的处理。这样每一步都有明确的验证点,不会一上来就被复杂度淹没。

2.3 这个实验到底在训练什么能力

表面上看,这个实验是在训练你实现一个算法。但更深层地看,它在训练三种能力:

第一是形式化思维能力。你需要把自然语言描述的文法规则,转化成机器能处理的数据结构。产生式、项目、状态、转移函数,这些都是形式化的抽象,你得习惯用这种思维方式。

第二是数据结构设计能力。LR分析器涉及多个核心数据结构:项目集、状态转移表、分析栈。怎么表示项目、怎么高效地求闭包、怎么存储和查询分析表,这些设计决策直接影响代码的复杂度和性能。

第三是调试和问题定位能力。LR分析器的bug往往不是崩溃,而是“分析结果不对”。你可能得到一个错误的归约序列,或者卡在某个状态出不来。这时候你需要能读懂分析表、追踪状态栈的变化,定位到底是哪个项目集算错了。

3. LR分析的核心原理拆解

3.1 项目、项目集和闭包

LR分析的基本单位是“项目”(item)。一个项目就是一条产生式加上一个“点”,表示当前分析进行到了产生式右部的哪个位置。比如产生式E -> E + T,对应的项目有:

  • E -> · E + T(还没开始匹配)
  • E -> E · + T(已经匹配了E,等待+)
  • E -> E + · T(已经匹配了E+,等待T)
  • E -> E + T ·(完全匹配,可以归约)

点的位置很关键。点在最左边表示还没开始,点在最右边表示可以归约了,点在中间表示部分匹配。

闭包(closure)操作是LR分析的核心。给定一个项目集,如果某个项目的点后面是一个非终结符,那么该非终结符的所有产生式对应的“初始项目”(点在最左边)都要加入这个集合。这个过程要递归进行,直到没有新项目加入为止。

我举个具体例子。假设项目集里有S -> · A B,而A有产生式A -> c和A -> d,那么闭包操作后,项目集里会加入A -> · c和A -> · d。如果d后面又是非终结符,继续展开。

闭包操作的意义在于:它把所有“可能接下来要匹配的东西”都提前放进了当前状态。这样分析器在某个状态下,就知道所有可能的下一步动作。

3.2 GOTO函数和状态转移

GOTO函数描述的是:在当前项目集下,如果遇到某个符号X,应该转移到哪个新项目集。计算方法是:把当前项目集中所有“点后面是X”的项目,把点向右移动一位,然后对新集合求闭包。

这个过程和NFA到DFA的转换非常像。你可以把每个项目集看作DFA的一个状态,GOTO函数就是状态转移函数。整个LR分析器本质上就是一个DFA,输入是token流,输出是移进和归约动作。

我当初理解这一点的时候,画了一张状态转移图,把每个项目集标成圆圈,GOTO关系标成箭头。画完之后,整个分析器的行为就一目了然了。强烈建议你也画一遍,比看代码管用。

3.3 分析表的构造逻辑

LR分析表有两部分:ACTION表和GOTO表。

ACTION表告诉分析器:在当前状态下,遇到某个终结符时该做什么。动作有四种:

  • 移进(shift):把当前符号压入符号栈,转移到新状态
  • 归约(reduce):用某条产生式进行归约,弹出相应数量的符号和状态,压入左部非终结符
  • 接受(accept):分析成功
  • 报错(error):语法错误

GOTO表告诉分析器:归约之后,如果压入了某个非终结符,应该转移到哪个状态。

构造ACTION表的关键在于判断什么时候该归约。对于LR(0),只要项目集中有“点在最右边”的项目,就填归约动作。但这样很容易产生冲突,因为一个项目集里可能有多个可归约项目,或者既有可归约项目又有可移进项目。

SLR(1)的改进是:归约时不仅看项目,还要看产生式左部非终结符的FOLLOW集。只有当前输入符号在FOLLOW集中时,才进行归约。这样能消除一部分冲突。

LALR(1)更进一步,它计算的是每个项目的“向前看符号集”,比FOLLOW集更精确。但计算复杂度也更高。

3.4 冲突的类型和本质

冲突是LR分析中绕不开的话题。主要有两种:

移进-归约冲突:在当前状态下,既可以移进某个符号,又可以归约某条产生式。这通常是因为文法有歧义,或者LR(0)的向前看信息不够。

归约-归约冲突:在当前状态下,可以按两条不同的产生式归约。这通常意味着文法有更深层的结构问题。

解决冲突的方法有几种:

  • 用优先级和结合性规则(比如算术表达式的乘除优先于加减)
  • 改写文法,消除歧义
  • 升级到更强的LR分析方法(SLR到LALR到CLR)

我在实验中遇到最多的是表达式文法的移进-归约冲突。经典的E -> E + E | E * E | id文法就是歧义的,LR(0)和SLR(1)都会冲突。解决办法要么改写文法为分层结构,要么在分析表中手动指定优先级。

4. 从零实现LR分析器的完整流程

4.1 数据结构设计

在动手写代码之前,先把数据结构定好。我当时的实现用了以下几个核心结构:

# 产生式 class Production: def __init__(self, lhs, rhs): self.lhs = lhs # 左部非终结符 self.rhs = rhs # 右部符号列表 self.id = -1 # 产生式编号 # 项目 class Item: def __init__(self, prod_id, dot_pos, lookahead=None): self.prod_id = prod_id # 所属产生式编号 self.dot_pos = dot_pos # 点的位置 self.lookahead = lookahead # 向前看符号(LALR用) # 项目集(状态) class ItemSet: def __init__(self): self.items = [] # 项目列表 self.id = -1 # 状态编号 self.transitions = {} # 转移表:符号 -> 目标状态id

产生式用编号管理,方便在分析表中引用。项目用(产生式编号,点位置)唯一标识。项目集用列表存储,同时维护一个转移字典。

这里有个细节:项目的相等性判断。两个项目如果产生式编号和点位置都相同,就是同一个项目。如果用LALR,还要考虑向前看符号。我建议在Item类里实现__eq__和__hash__方法,这样可以用集合来去重,求闭包的时候会方便很多。

4.2 求闭包的具体实现

闭包操作的伪代码:

function closure(items): result = copy(items) changed = true while changed: changed = false for item in result: symbol = symbol_after_dot(item) if symbol is nonterminal: for prod in productions_of(symbol): new_item = Item(prod.id, 0) if new_item not in result: result.add(new_item) changed = true return result

这个实现是迭代式的,每次扫描发现新项目就加入,直到一轮扫描没有新项目为止。时间复杂度在最坏情况下是O(n²),但对于教学用的文法规模,完全够用。

有个优化技巧:可以预先建立“非终结符到产生式列表”的映射,这样不用每次遍历所有产生式。我在第二次实现的时候加了这个优化,闭包计算速度快了不少。

4.3 构造项目集规范族

这是整个实验最核心也最容易出错的部分。算法流程:

  1. 初始化:对增广文法S' -> S,求closure({S' -> · S}),作为初始状态I0
  2. 对每个已发现的状态I,对每个符号X(终结符和非终结符),计算GOTO(I, X)
  3. 如果GOTO结果非空且不在已有状态集合中,加入状态集合
  4. 重复直到没有新状态产生

用工作队列的方式实现:

def build_canonical_collection(): states = [] start = closure([Item(0, 0)]) # 增广文法的初始项目 start.id = 0 states.append(start) queue = [start] while queue: current = queue.pop(0) symbols = get_all_symbols_after_dot(current) for X in symbols: next_set = goto(current, X) if not next_set.items: continue # 检查是否已存在 existing = find_same_set(states, next_set) if existing: current.transitions[X] = existing.id else: next_set.id = len(states) states.append(next_set) current.transitions[X] = next_set.id queue.append(next_set) return states

这里有个坑:判断两个项目集是否相同,不能只比较项目数量,要比较项目内容。而且如果项目集是无序存储的,比较前要先排序。我当初就是因为没排序,导致同一个项目集被重复创建了好几次,状态数暴涨,分析表完全对不上。

4.4 构造分析表

有了项目集规范族和GOTO关系,构造分析表就相对机械了:

def build_table(states, productions, follow_sets): action = {} # (state_id, terminal) -> action goto_table = {} # (state_id, nonterminal) -> state_id for state in states: for item in state.items: symbol = symbol_after_dot(item) if symbol is terminal: # 移进 target = state.transitions[symbol] action[(state.id, symbol)] = ('shift', target) elif symbol is None: # 点在末尾,归约 if item.prod_id == 0: action[(state.id, '$')] = ('accept',) else: prod = productions[item.prod_id] for a in follow_sets[prod.lhs]: action[(state.id, a)] = ('reduce', item.prod_id) # GOTO表 for symbol, target in state.transitions.items(): if symbol is nonterminal: goto_table[(state.id, symbol)] = target return action, goto_table

注意增广文法的产生式编号为0,它的归约动作就是接受。FOLLOW集的计算是另一个经典算法,这里不展开,但要注意FOLLOW集里要包含结束符$。

4.5 驱动分析过程

分析器的主循环:

def parse(input_tokens, action, goto_table, productions): state_stack = [0] symbol_stack = ['$'] pos = 0 while True: current_state = state_stack[-1] current_token = input_tokens[pos] if pos < len(input_tokens) else '$' act = action.get((current_state, current_token)) if act is None: return f"Error at position {pos}, token {current_token}" if act[0] == 'shift': state_stack.append(act[1]) symbol_stack.append(current_token) pos += 1 elif act[0] == 'reduce': prod = productions[act[1]] # 弹出产生式右部长度个符号和状态 for _ in range(len(prod.rhs)): state_stack.pop() symbol_stack.pop() # 压入左部非终结符 symbol_stack.append(prod.lhs) # 查GOTO表 top_state = state_stack[-1] next_state = goto_table[(top_state, prod.lhs)] state_stack.append(next_state) elif act[0] == 'accept': return "Accept"

这个循环的逻辑很清晰:每次看栈顶状态和当前输入符号,查ACTION表决定动作。移进就压栈前进,归约就弹栈再压入非终结符,接受就结束。

调试的时候,我建议在每次循环打印状态栈、符号栈和当前输入位置。这样一旦出错,你能立刻看到是哪一步走偏了。

5. 实操中遇到的典型问题和排查方法

5.1 项目集构造错误导致状态数异常

这是最常见的bug。症状是:状态数比预期多很多,或者分析表里出现大量空项。

排查方法:打印每个项目集的内容,人工检查是否有重复。重点检查闭包操作是否正确,GOTO计算是否遗漏了符号。我当初的问题是项目集比较时没有考虑项目顺序,导致{A, B}和{B, A}被当成两个不同的集合。

解决方法是给项目集内的项目排序,或者用frozenset来存储。用frozenset的话,比较和哈希都是自动的,省心很多。

5.2 移进-归约冲突的处理

如果文法有冲突,构造分析表时会发现同一个(状态, 符号)对应多个动作。

排查方法:定位到冲突的状态,打印该状态的所有项目,看看是哪个可归约项目和哪个移进项目冲突。然后检查FOLLOW集是否计算正确。

如果FOLLOW集没问题,那就是文法本身的问题。对于表达式文法,可以通过改写来消除冲突:

# 有冲突的版本 E -> E + E | E * E | id # 改写后 E -> E + T | T T -> T * F | F F -> id

改写后的文法分层了,乘除的优先级高于加减,LR分析就不会冲突了。

5.3 归约时弹栈数量错误

症状是分析过程中状态栈和符号栈长度不一致,或者GOTO查表失败。

原因通常是归约时弹出的数量不对。产生式右部有几个符号,就弹几个状态和符号。注意空产生式(右部为空)不需要弹栈,直接压入左部即可。

我建议在代码里加一个断言:每次归约后,状态栈长度应该等于符号栈长度。如果不等,说明弹栈逻辑有问题。

5.4 输入结束符处理不当

很多同学忘记在输入末尾加结束符$,导致分析器在最后一步找不到动作。

解决方法:在token流末尾统一追加$,并且在ACTION表中,接受动作对应的就是$符号。

5.5 常见问题速查表

问题现象可能原因排查方法解决方案
状态数异常多项目集去重失败打印项目集内容对比排序或使用frozenset
分析表大量空项GOTO计算遗漏检查符号遍历是否完整补全终结符和非终结符
移进-归约冲突文法歧义或FOLLOW集错误打印冲突状态的项目改写文法或检查FOLLOW
归约后GOTO失败弹栈数量错误检查产生式右部长度修正弹栈逻辑
分析卡死输入未加结束符检查token流末尾追加$符号
归约序列不对分析表填错对比手工推导重新构造分析表

6. 几个让实验加分的优化思路

6.1 可视化状态转移图

把项目集规范族用图形展示出来,每个状态一个节点,GOTO关系用箭头连接。可以用Graphviz生成,也可以用简单的文本缩进展示。这个可视化对调试帮助极大,而且答辩的时候很加分。

6.2 支持多种LR方法切换

在代码里加一个参数,支持LR(0)、SLR(1)、LALR(1)三种模式。LR(0)不做向前看检查,SLR(1)用FOLLOW集,LALR(1)用项目自带的向前看符号集。这样你可以对比不同方法的状态数和冲突情况,实验报告会充实很多。

6.3 错误恢复机制

基本的LR分析器遇到错误就停了。可以加入简单的错误恢复:比如跳过当前token继续分析,或者弹出栈顶状态尝试恢复。虽然教学实验不要求,但加上去能体现你对实际编译器行为的理解。

6.4 性能优化

对于较大的文法,闭包计算和项目集比较是性能瓶颈。可以用以下优化:

  • 预计算非终结符到产生式的映射
  • 用哈希表存储项目集,O(1)查找
  • 闭包计算时用工作列表代替全量扫描

我在实验中测试了一个约50条产生式的文法,优化前构造分析表要3秒多,优化后降到0.2秒左右。虽然教学实验不追求性能,但这个过程能让你更深入理解算法的复杂度。

7. 我踩过的坑和总结的经验

第一个坑是过早优化。我一开始就想直接实现LALR(1),结果闭包和向前看符号的计算搅在一起,调试了整整一个下午都没搞对。后来退回去先实现LR(0),跑通了再逐步加SLR和LALR,半天就全部搞定了。所以我的建议是:从最简单的版本开始,逐步增加复杂度。每一步都有可验证的结果,不会一锅粥。

第二个坑是忽视手工验证。我一开始完全依赖代码输出,结果分析表错了也不知道。后来我拿一个只有3条产生式的小文法,手工推导了完整的项目集和分析表,再和代码输出对比,才发现问题所在。手工验证一个小例子,比盯着代码看一小时都管用。

第三个坑是不打印中间结果。LR分析的中间数据很多:项目集、闭包、GOTO、FOLLOW集、分析表。如果不打印出来,出了问题根本不知道是哪一步错了。我的做法是在每个关键步骤后加打印,用缩进和分隔线让输出清晰可读。虽然输出多,但定位问题快。

最后一个经验是:理解比实现重要。这个实验的代码量其实不大,核心算法加起来可能就两三百行。但如果你不理解每个步骤在做什么,代码写出来也是死的。我建议在写代码之前,先拿纸笔把一个小文法的完整分析过程走一遍,包括项目集构造、分析表填写、输入串的分析过程。走完一遍,你对LR分析的理解会有一个质的飞跃。

这个实验后续还可以扩展的方向很多:比如加入语义动作实现简单的计算器,或者把分析器做成通用的,支持从文件读取文法。但那是下一步的事了,先把基础的分析器跑通,比什么都重要。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询