编译原理里的冲突到底是什么?

复习编译原理的时候观看了 b 站视频,大概会区分各种文法(LR(0)、SLR(1)、LR(1)、LALR(1))但是不理解为什么需要这么多文法,以及有什么用?所以总结一下

问题

文法=规定语言长什么样

比如表达式可以写成:

这告诉编译器:a+b*c怎么被拆分成语法数

实际编译时,语法分析器做的就是:读token,然后决定是 移进 还是 归约。

eg:

复制代码
输入id+id*id

词法分析器首先把源码变成token

复制代码
id + id * id

语法分析器一边读,一边问自己:

复制代码
现在这个 id 是不是已经可以归约成 T / E?
还是应该先继续读后面的 +、*?

1."移近"和"归约"是什么

这是理解冲突的核心。

假设有文法:

复制代码
E -> E + E
E -> E * E
E -> id

输入:

复制代码
id + id * id

当分析到:

复制代码
id + id · * id

也就是已经看到 id + id,下一个符号是 *

这事parser(解析器)有两个选择:

选择一:归约

复制代码
id + id

可以先归约成:

复制代码
E + E

再归约成:

复制代码
E

这相当于理解成:

复制代码
(id + id) * id

选择二:移进

先不要归约,继续读 * id,最后理解成:

复制代码
id + (id * id)

这才符合通常的乘法优先级。

所以这里 parser 不知道该怎么办:
到底归约,还是继续读?

这就叫:移进-归约冲突。

2. 什么是"冲突"?

冲突就是:在同一个状态、面对同一个输入符号时,分析表里出现两个动作。

LR 分析表里每个格子本来应该只有一个动作,比如:

复制代码
ACTION[状态 i, 符号 a] = shift

或者:

复制代码
ACTION[状态 i, 符号 a] = reduce

但如果同一个格子里同时出现:

复制代码
shift(移进) 和 reduce(归约)

就叫 移进-归约冲突

如果同时出现两个 reduce:

复制代码
reduce A -> α
reduce B -> β

就叫 归约-归约冲突

冲突 = 编译器在某一步无法唯一决定下一步怎么做。

3. 为什么会冲突?

通常有两个原因。

第一种:文法本身有歧义

比如:

复制代码
E -> E + E
E -> E * E
E -> id

这个文法没有说明 +* 谁优先。

所以:

复制代码
id + id * id

可以有两种语法树:

复制代码
(id + id) * id

或者:

复制代码
id + (id * id)

编译器当然会犹豫。

解决办法是改文法,例如写成:

复制代码
E -> E + T | T
T -> T * F | F
F -> id

这样 * 在更底层,优先级更高。

第二种:文法不歧义,但 parser 看得不够远

这就是 LR(0)、SLR(1)、LR(1)、LALR(1) 的区别。

它们的区别本质是:

parser 在做决定时,能利用多少上下文信息。

4. LR(0)、SLR(1)、LR(1)、LALR(1) 怎么理解?

我们可以把它们理解成四种"眼神不同"的 parser。

LR(0):最莽

LR(0) 不看下一个输入符号。

只要看到某个项目变成:

复制代码
A -> α ·

它就想归约。

所以 LR(0) 很容易冲突。

比如状态里同时有:

复制代码
A -> α ·
B -> α · b

LR(0) 会想:

复制代码
A -> α ·      可以归约
B -> α · b    看到 b 又可以移进

但 LR(0) 不看后面到底是不是 b,所以容易冲突。


SLR(1):稍微聪明一点

SLR(1) 会看 FOLLOW 集。

如果有:

复制代码
A -> α ·

它不是在所有符号上都归约,而是只在:

复制代码
FOLLOW(A)

里的符号上归约。

也就是说:

复制代码
只有当下一个 token 可能跟在 A 后面时,才归约 A -> α

所以 SLR(1) 比 LR(0) 少很多冲突。

但是 SLR(1) 的问题是:FOLLOW(A) 是全局的,太粗糙。

它不管你当前在哪个上下文里,只要某个符号在 FOLLOW(A) 里,就认为可以归约。

所以有些情况下它还是会误判。


LR(1):最精细

LR(1) 的项目长这样:

复制代码
A -> α · β, a

后面的 a向前看符号 lookahead

意思是:

复制代码
只有当后面真的看到 a 时,才允许这个归约。

所以 LR(1) 不是用全局 FOLLOW 集,而是给每个项目单独带一个 lookahead。

因此 LR(1) 判断最准确,冲突最少。

缺点是:状态数很多,分析表很大。


LALR(1):实际编译器常用的折中

LALR(1) 的思想是:

LR(1) 太大了,那我把"核心相同"的状态合并一下。

比如两个 LR(1) 状态:

复制代码
A -> α ·, a

和:

复制代码
A -> α ·, b

核心都是:

复制代码
A -> α ·

只是 lookahead 不同。

LALR(1) 会把它们合并成:

复制代码
A -> α ·, a/b

这样表变小很多。

所以

复制代码
LR(1) ⊂ LALR(1)

就是在讲:LALR(1) 是在 LR(1) 基础上合并同心项目集。

实际中的 yacc / bison 这类工具就常用 LALR(1) 或类似技术。


5. 理论和实际编译怎么结合?

实际编译器大概是这样:

复制代码
源代码
  ↓
词法分析 lexer
  ↓
token 流
  ↓
语法分析 parser
  ↓
语法树 AST
  ↓
语义分析 / 类型检查 / 中间代码 / 优化 / 目标代码

我们现在学的 LR、SLR、LALR 主要在这一步:

复制代码
token 流 → 语法树 AST

也就是 parser 的理论基础。

比如写:

复制代码
int x = 1 + 2 * 3;

词法分析得到:

复制代码
int id = num + num * num ;

语法分析器要根据文法判断:

复制代码
1 + 2 * 3

这里的文法可能是:

复制代码
E -> E + T | T
T -> T * F | F
F -> num | id | ( E )

E 表示表达式 expression
T 表示项 term
F 表示因子 factor

应该被理解为:

复制代码
1 + (2 * 3)

而不是:

复制代码
(1 + 2) * 3

所以,文法不是为了考试凭空造出来的,它决定了编译器怎么理解代码结构。

为什么不会推成 (1 + 2) * 3

因为如果想得到:

复制代码
(1 + 2) * 3

那最外层应该是:

复制代码
T -> T * F

也就是说整个 1 + 2 必须先成为一个 T

但是在这个文法里,T 只能生成:

复制代码
F
T * F
T / F

它不能直接生成:

复制代码
1 + 2

因为 + 只在 E 这一层出现。

所以:

复制代码
1 + 2

只能是 E,不能是 T

T * F 的左边必须是 T,不是 E

因此不加括号时,文法不允许把它理解成:

复制代码
(1 + 2) * 3

如果真的想这样写,就必须输入:

复制代码
(1 + 2) * 3

因为括号规则:

复制代码
F -> ( E )

允许一个完整的 E 被包起来,变成一个 F,再进一步变成 T


这和实际编译器是一回事吗?

是的,思想是一回事。

实际编译器里一般会有类似的表达式规则,只是更复杂,比如还要处理:

复制代码
赋值 =
逻辑或 ||
逻辑与 &&
比较 < > ==
加减 + -
乘除 * /
一元运算 ! -
函数调用 f()
数组访问 a[i]
成员访问 obj.x
括号

它们通常会按优先级分很多层,例如:

复制代码
assignment
logical_or
logical_and
equality
relational
additive
multiplicative
unary
primary

比如可以写成:

复制代码
Expr           -> Assignment
Assignment     -> id = Assignment | LogicalOr
LogicalOr      -> LogicalOr || LogicalAnd | LogicalAnd
LogicalAnd     -> LogicalAnd && Equality | Equality
Equality       -> Equality == Relational | Relational
Relational     -> Relational < Additive | Additive
Additive       -> Additive + Multiplicative | Multiplicative
Multiplicative -> Multiplicative * Unary | Unary
Unary          -> - Unary | Primary
Primary        -> num | id | ( Expr )

这跟我们学的:

复制代码
E -> E + T | T
T -> T * F | F
F -> id | num | (E)

本质完全一样,只是实际语言的层级更多。


所以

语法分析器就是根据文法来理解表达式的。

1 + 2 * 3 能被正确理解,是因为文法把 + 放在 E 层,把 * 放在 T 层,从结构上规定了:

复制代码
* 的优先级高于 +

不是语法分析器自己猜出来的。


6. 一句话记住这些文法的关系

从弱到强大概是:

复制代码
LR(0) < SLR(1) < LALR(1) < LR(1)

意思是:

能处理的文法范围越来越大,冲突越来越少,但代价也越来越高。

更直观一点:

复制代码
LR(0):不看后面,直接判断
SLR(1):看 FOLLOW 集,粗略判断
LR(1):每个项目都带 lookahead,精确判断
LALR(1):接近 LR(1),但合并状态,适合实际使用

核心:

冲突就是 parser 在某个状态不知道该 shift 还是 reduce。
LR 系列算法的目的,就是构造分析表,让 parser 每一步都有唯一动作。

相关推荐
_深海凉_9 小时前
LeetCode热题100-二叉树的右视图
算法·leetcode·职场和发展
计算机安禾9 小时前
【c++面向对象编程】第29篇:定位new(placement new):在指定内存上构造对象
开发语言·c++·算法
淞綰9 小时前
c语言的练习-字符串的练习-寻找最长连续字符以及出现次数
c语言·数据结构·学习·算法·c语言的练习
计算机安禾9 小时前
【c++面向对象编程】第27篇:空类的大小为什么是1?——C++对象标识的秘密
开发语言·c++·算法
信竞星球_少儿编程题库10 小时前
2026年全国信息素养大赛算法应用主题赛 丝路新城 Python 模拟卷(三)
开发语言·python·算法
云泽80810 小时前
笔试算法 - 滑动窗口篇(二):从异位词到最小覆盖子串的通用框架
c++·算法
qq_2965532710 小时前
[特殊字符] 搜索插入位置:从O(n)到O(log n)的优雅进化
数据结构·算法·面试·分类·柔性数组
凯瑟琳.奥古斯特10 小时前
力扣3654:二维矩阵连续空位统计
数据结构·数据库·算法·职场和发展