ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

上下文无关文法与下推自动机:从正则局限到语法分析实战

上下文无关文法与下推自动机:从正则局限到语法分析实战

1. 从“正则不够用”说起:为什么需要上下文无关语言

很多人学计算理论,第一道坎就是正则语言和有限自动机。那套东西确实漂亮,状态机画出来一目了然,词法分析器写起来也顺手。但只要你动手写过一个稍微像样的解析器,就会立刻撞上一堵墙:正则表达式根本描述不了嵌套结构。

举个最经典的例子,括号匹配。你想用正则表达“n个左括号后面跟n个右括号”,写不出来。因为有限自动机没有“记忆”,它数不了数。你可能会说,那我限制嵌套深度不超过10层不就行了?确实可以,但那是权宜之计,不是通用解法。编程语言里的表达式嵌套、代码块嵌套、JSON里的对象套数组套对象,深度理论上是无上限的,你不可能预先枚举所有情况。

这就是上下文无关语言(Context-Free Language, CFL)登场的地方。它对应的计算模型是下推自动机(Pushdown Automaton, PDA),核心区别就一个:多了一个栈。别小看这个栈,它让机器有了“无限记忆”的能力,只不过这个记忆是后进先出的。正是这个后进先出的限制,让CFL比正则强,但又没强到图灵机那个级别——它刚好卡在一个非常实用的位置上。

你日常用的所有编程语言语法、配置文件格式、数学表达式、甚至HTML的标签嵌套结构,底层都是上下文无关文法在描述。编译器前端的两大核心步骤——词法分析和语法分析——词法分析用正则,语法分析用CFG,分工明确。所以搞懂CFL,不是应付考试,是你真正理解“程序语言是怎么被机器读懂的”这件事的必经之路。

这篇文章我打算按自己当年啃这一章的思路来写:先搞清楚CFG到底怎么定义、怎么推导,再聊语法树和歧义性这些绕不开的坑,然后重点讲PDA的两种接受方式和它们跟CFG的等价性,最后落到实际写文法时那些书上不教但一写就错的经验。适合正在学计算理论的学生,也适合工作几年后想回头补基础的开发者。

2. 上下文无关文法:把“规则”写成数学

2.1 四元组定义里每个符号到底在说什么

上下文无关文法(Context-Free Grammar, CFG)的形式定义是一个四元组 G = (V, Σ, R, S)。教科书上这四个符号一摆,很多人就懵了。我用大白话拆一遍。

V 是变元(variable)集合,也叫非终结符。你可以把它理解成“中间概念”,比如“表达式”“语句”“条件”这些还没落到具体字符上的抽象东西。Σ 是终结符集合,就是最终真正出现在字符串里的那些字符,比如+、(、id、if。注意 V 和 Σ 必须不相交,一个符号不能既是中间概念又是最终字符,否则推导会乱套。

R 是产生式规则集合,每条规则长这样:A → α,其中 A 是 V 里的一个变元,α 是 (V ∪ Σ)* 里的任意字符串。这个“任意字符串”很关键,它意味着左边永远只有一个变元,右边可以是任意混合。这就是“上下文无关”这个名字的来源——你替换 A 的时候,不需要看 A 周围有什么符号,A 自己就能决定怎么展开。对比一下上下文有关文法,那边规则可能是 αAβ → αγβ,替换 A 还得看左右邻居,那就复杂多了。

S 是起始变元,整个推导从它开始。

我当年理解这套定义的时候,最大的困惑是:为什么非要强调“左边只有一个变元”?后来想明白了,这个限制恰恰是CFG好用的原因。因为左边单一,推导过程可以表示成一棵树,每个节点独立展开,互不干扰。如果左边有多个符号,展开就得考虑上下文,树结构就崩了,解析算法也会指数级爆炸。所以这个限制不是数学家的洁癖,是工程可行性的底线。

2.2 推导、句型与句子:从S到终结符串的完整路径

有了文法,接下来就是“怎么用”。推导(derivation)就是从起始变元 S 出发,反复挑一个变元,用某条产生式的右边替换它,直到字符串里全是终结符为止。

这里有几个术语要分清,考试和面试都爱考:

  • 句型(sentential form):推导过程中出现的任何字符串,允许里面还有变元。比如S ⇒ aSb ⇒ aaSbb,中间那两步的结果都是句型。
  • 句子(sentence):只含终结符的句型。aabb就是句子。
  • 语言 L(G):所有能从 S 推导出来的句子的集合。

推导还分最左推导和最右推导。最左就是每次替换最左边那个变元,最右反之。为什么要区分?因为后面讲歧义性的时候,判断标准就是“是否存在两棵不同的最左推导树”。如果两种推导路径对应同一棵树,那不算歧义;如果对应不同的树,那就是歧义。

我建议你手推几个例子找找感觉。比如文法 S → aSb | ε,推导aabb的过程是 S ⇒ aSb ⇒ aaSbb ⇒ aabb。这个文法生成的语言是 {a^n b^n | n ≥ 0},也就是n个a后面跟n个b。你试试用正则表达这个语言,写不出来。这就是CFL比正则强的最直观证据。

再比如经典的回文语言,S → aSa | bSb | a | b | ε,生成所有回文串。这个用正则同样没戏,因为回文要求前后对称,有限状态机记不住前面读过什么。

2.3 文法设计实战:从语言描述反推产生式

书上给文法让你推语言,考试考得多。但实际工作中更常见的是反过来:给你一个语言描述,让你设计文法。这个能力才是真本事。

我总结了一个自己常用的套路,分三步走:

第一步,先找“递归结构”。几乎所有非正则的语言,核心都在某个递归上。括号匹配的递归是“外面套一层括号”,回文的递归是“两头加相同字符”,表达式的递归是“左递归或右递归”。找到这个递归,文法就成了一半。

第二步,确定“基准情况”。递归总得有终止条件,对应文法里的 ε 或者单个终结符产生式。比如括号匹配的基准是空串,回文的基准是空串、单字符。

第三步,检查“边界和优先级”。如果语言里有多种结构混合,比如表达式里加减和乘除优先级不同,那就得分层写文法,一层一个变元,低优先级变元引用高优先级变元。

拿一个实际点的例子:设计一个文法,生成所有由相同数量的0和1组成的字符串。这个语言不是正则的,但它是CFL。递归结构怎么找?考虑第一个字符和最后一个字符的关系,或者考虑把串拆成两半。一种写法是 S → 0S1 | 1S0 | SS | ε。这个文法里 SS 这条规则很关键,它允许你把两个平衡串拼起来,覆盖了所有情况。你可以验证一下0011、0101、1001都能推出来。

注意:设计文法时最容易犯的错是“漏情况”。比如上面那个例子,如果只写 S → 0S1 | 1S0 | ε,那0011能推,但0101推不出来。SS 这条规则就是补漏的。所以写完文法一定要拿几个刁钻的串试推,特别是那些“交错”结构的。

3. 语法树与歧义性:同一个串,两种人生

3.1 语法树怎么画,为什么它比推导更直观

推导是一步一步的字符串替换,语法树(parse tree)则是把整个过程画成一棵树。根是起始变元,每个内部节点是一个变元,它的子节点就是这条产生式右边的符号,叶子节点从左到右读出来就是最终句子。

语法树的好处是“结构一目了然”。比如表达式1 + 2 * 3,如果文法设计得不好,可能画出两棵不同的树:一棵是先算加法再算乘法,一棵是先算乘法再算加法。这两棵树对应的计算结果完全不同,前者是9,后者是7。这就是歧义性的实际危害。

画树的时候有个细节:叶子节点必须是终结符,内部节点必须是变元。如果某个变元推导出 ε,那它在树里就是一个没有子节点的叶子,但它是变元不是终结符,读串的时候要跳过。这个点很多人初学时会搞混。

3.2 歧义性:编译器最怕的“一句话两种理解”

一个文法如果对某个句子存在两棵不同的语法树,就叫歧义文法(ambiguous grammar)。注意,是“存在”一个句子有歧义,整个文法就算歧义。哪怕只有一个句子有歧义,也不行。

为什么歧义性这么要命?因为编译器需要唯一确定程序的含义。如果a + b * c有两种解析方式,那生成的机器码就不唯一,程序行为就不可预测。所以实际用的编程语言文法,要么设计成无歧义的,要么用优先级和结合性规则来消歧。

消除歧义有几种常见手法:

  • 分层文法:把表达式分成多层,加减一层,乘除一层,括号一层。低层引用高层,强制优先级。
  • 引入优先级声明:在解析器生成工具里直接声明运算符优先级和结合性,工具自动消歧。
  • 改写文法结构:比如把左递归改成右递归,或者引入新的变元来区分不同情况。

我拿表达式文法举个例子。有歧义的版本是 E → E + E | E * E | (E) | id。这个文法里id + id * id有两棵树。无歧义的版本是:

E → E + T | T T → T * F | F F → (E) | id

这里 E 管加法,T 管乘法,F 管括号和原子。因为 E 的产生式里加法右边是 T,T 不能直接展开成加法,所以乘法天然比加法“紧”。你试试推id + id * id,只有一棵树。

提示:判断一个分层文法是否真的消歧,最可靠的方法是尝试构造两棵不同的最左推导。如果怎么试都只有一条最左推导路径,那基本可以确认无歧义。但要注意,判断一个CFG是否歧义在理论上不可判定,所以实际中只能靠经验和工具验证。

3.3 固有歧义语言:有些语言天生就“说不清”

更让人头疼的是,有些语言本身就是固有歧义的(inherently ambiguous),意思是任何生成它的CFG都必然有歧义。这不是文法写得不好,是语言本身的性质。

经典例子是 {a^i b^j c^k | i = j 或 j = k}。这个语言里,当一个串同时满足 i=j 和 j=k 时,比如abc、aabbcc,它就有两种“身份”:既可以按 i=j 解析,也可以按 j=k 解析。你没法设计一个文法强制它只能按一种方式解析,因为语言定义本身就是“或”的关系。

固有歧义语言的存在说明一件事:CFG的表达能力有边界,不是所有CFL都能被无歧义地描述。这在编译器设计里是个重要约束——如果你设计的语言不小心落进了固有歧义区,那解析器就没法做到确定性解析,只能靠回溯或者更复杂的算法,性能会大打折扣。

4. 下推自动机:给有限状态机加个栈

4.1 PDA的非形式理解:栈到底解决了什么问题

有限自动机的死穴是“记不住”。它只有有限个状态,读过的字符一旦过去就忘了。PDA的解法很直接:加一个栈。栈是后进先出的,可以无限增长,所以PDA能记住“之前发生过什么”,只要这个记忆符合后进先出的顺序。

为什么后进先出刚好够用?因为CFG的推导本质上是嵌套的。你展开一个变元,里面的内容处理完了再回到外层,这天然就是栈的行为。左括号入栈,右括号出栈,嵌套深度就是栈深度。所以PDA和CFG能对上,不是巧合,是结构同构。

PDA有两种接受方式:一种是“读完输入后处于接受状态”,叫终态接受;另一种是“读完输入后栈为空”,叫空栈接受。这两种方式在能力上等价,但写起来各有方便之处。终态接受更接近有限自动机的思维习惯,空栈接受在证明等价性时更好用。

4.2 PDA的形式定义与转移函数拆解

PDA的形式定义是六元组 (Q, Σ, Γ, δ, q0, F)。Q 是状态集,Σ 是输入字母表,Γ 是栈字母表,δ 是转移函数,q0 是起始状态,F 是接受状态集。

重点是 δ。它的形式是 δ(q, a, X) = (p, γ),意思是:当前状态 q,读入字符 a(a 可以是 ε,表示不读入),栈顶是 X,那么可以转移到状态 p,并把栈顶 X 替换成字符串 γ。注意 γ 是替换,不是压入。如果 γ 是 ε,就是弹栈;如果 γ 是 X,就是栈不变;如果 γ 是 YX,就是压入 Y。

这个“替换”的设定比“压栈/弹栈”更通用,写证明时方便。但初学的时候容易绕晕,我建议你先按“弹栈再压入”来理解:把 X 弹掉,然后把 γ 里的字符从右到左压进去。比如 γ = YZ,就是先压 Z 再压 Y,最后 Y 在栈顶。

PDA还分确定型(DPDA)和非确定型(NPDA)。DPDA对每个 (q, a, X) 最多只有一个转移,而且如果存在 ε 转移,就不能有读入字符的转移。NPDA允许有多个选择。关键结论是:NPDA和CFG等价,但DPDA只对应确定型上下文无关语言(DCFL),是CFL的真子集。也就是说,有些CFL没有DPDA。这个结论很重要,它解释了为什么有些语法用LL或LR解析器搞不定,得用更通用的算法。

4.3 从CFG构造PDA:等价性证明的构造性思路

CFG和PDA等价,这个定理的证明是构造性的:给定一个CFG,能机械地造出一个PDA接受同样的语言;反过来也行。我重点讲从CFG到PDA的构造,因为这个方向更直观。

构造思路是“模拟最左推导”。PDA的栈里始终存着当前句型中“还没处理完”的部分。初始时栈里是起始变元 S。然后循环做:

  • 如果栈顶是变元 A,就非确定地选一条 A 的产生式 A → α,把栈顶的 A 替换成 α。这一步不读输入。
  • 如果栈顶是终结符 a,就读入一个字符,如果和 a 匹配就弹栈,否则这条路径死掉。
  • 如果栈空了,接受。

这个构造的精髓在于“非确定性”:PDA不知道哪条产生式是对的,所以它同时试所有可能。只要有一条路径能走通,就接受。这正好对应CFG推导的“存在一条推导路径”。

我当年理解这个构造的时候,卡在一个点上:为什么栈顶是终结符时要读输入?因为终结符是“已经确定要出现在最终串里”的符号,它必须和输入的下一个字符匹配。而变元是“还没展开”的,所以不读输入,先展开再说。这个区分是理解整个构造的关键。

反过来从PDA到CFG的构造就复杂多了,要用到“三元组”技巧,把PDA的每一步都编码成文法里的变元。考试一般考从CFG到PDA,反向的知道结论就行。

5. 写文法时踩过的坑与实战经验

5.1 左递归:递归下降解析器的天敌

左递归是指形如 A → Aα 的产生式。这种规则在CFG理论里完全合法,推导也能正常进行。但如果你用递归下降法写解析器,左递归会导致无限递归——因为函数parseA()一进来就调用parseA(),栈直接爆掉。

消除左递归有标准手法。对于 A → Aα | β,改成 A → βA',A' → αA' | ε。这个变换保持语言不变,但把左递归变成了右递归。我实测下来,这个变换在手工写解析器时几乎是必做的第一步。

但要注意,消除左递归后语法树的结构会变。原来左递归生成的树是左倾的,变换后变成右倾的。如果你后续要做语义动作,比如计算表达式值,树结构变了,遍历顺序也得跟着调。这个坑我在写计算器的时候踩过,算出来的结果结合律反了,查了半天才发现是树结构的问题。

5.2 ε产生式:方便但危险

ε产生式就是 A → ε,让变元直接消失。它在描述“可选”结构时非常方便,比如 if 语句的 else 部分可选,就可以用 ε。但滥用 ε 会产生两个问题:

一是歧义性。如果多个变元都能推出 ε,那一个空串可能有多棵语法树。二是解析效率。ε 转移会让解析器在不确定的时候多出很多分支,回溯成本上升。

我的经验是:能用非 ε 方式表达的就别用 ε。比如“零个或多个”可以用递归表达成 A → BA | ε,但如果 B 本身也能推出 ε,那就麻烦了。这时候最好重新设计文法,把可选部分显式化。

5.3 二义性文法的调试方法

当你怀疑一个文法有歧义时,怎么验证?我常用的方法是“找反例”。挑几个结构上可能有多种解释的串,手动构造两棵不同的语法树。如果能构造出来,就确认有歧义;如果试了很多都构造不出来,那大概率无歧义,但不能百分百保证。

更工程化的方法是借助工具。有些解析器生成器会报告冲突,比如移进-归约冲突或归约-归约冲突,这些冲突往往就是歧义的信号。但工具报告冲突不代表一定有歧义,有时候只是解析策略的问题。所以工具报告要结合手动分析。

还有一个技巧:把文法改写成最左推导的形式,然后看每一步是否有多个可选的产生式。如果某个句型下有两个不同的变元可以展开,而且展开后能到达同一个句子,那就有歧义。这个方法比画树快,适合快速筛查。

5.4 常见问题速查表

问题现象可能原因排查思路解决方法
解析器无限递归文法存在左递归检查是否有 A → Aα 形式消除左递归,改成右递归
同一表达式算出不同结果文法有歧义尝试构造两棵语法树分层文法,明确优先级
解析器回溯爆炸ε产生式过多统计每个变元的ε推导减少ε,显式化可选结构
某些合法串解析失败文法漏情况拿边界串试推补充产生式,特别是拼接规则
PDA构造后不接受栈操作顺序错检查γ替换方向确认从右到左压栈

提示:这张表是我自己整理在笔记里的,每次写新文法前扫一眼,能省不少调试时间。特别是“漏情况”那一行,几乎每次设计复杂文法都会遇到。

6. CFL的边界:泵引理与不可判定性

6.1 泵引理:证明一个语言“不是”CFL

正则语言有泵引理,CFL也有。CFL的泵引理说的是:如果 L 是CFL,那存在一个泵长度 p,任何长度 ≥ p 的串 s 都能拆成 s = uvxyz,满足 |vy| ≥ 1,|vxy| ≤ p,且对所有 i ≥ 0,uv^i xy^i z 都在 L 里。

这个引理用来证明某个语言不是CFL。经典例子是 {a^n b^n c^n | n ≥ 0}。假设它是CFL,取 s = a^p b^p c^p,按引理拆分后,v 和 y 只能覆盖至多两个字母区域(因为 |vxy| ≤ p),泵一下就会导致三个字母数量不等,矛盾。

我学这个引理的时候,最大的困惑是“为什么 |vxy| ≤ p”。后来想明白了,这个限制是为了保证 vxy 不会同时跨越三个字母块。因为 p 是泵长度,而 a^p b^p c^p 里每个块长度都是 p,所以长度为 p 的窗口最多覆盖两个块。这个设计很巧妙,是证明的关键。

6.2 CFL的封闭性与判定问题

CFL对并、连接、星号封闭,但对交、补不封闭。这个不对称性经常考。并和连接封闭好理解,因为你可以把两个文法的起始变元合起来。星号封闭也简单,加一条 S → SS | ε 就行。

但交不封闭。反例就是 {a^n b^n c^m} 和 {a^m b^n c^n},两个都是CFL,但它们的交是 {a^n b^n c^n},不是CFL。补不封闭也是类似的反例。

判定问题方面,CFL的成员性问题(给定串是否属于语言)是可判定的,用CYK算法或者Earley算法都能做,复杂度多项式级。但等价性问题(两个CFG是否生成同一语言)是不可判定的。歧义性问题也是不可判定的。这两个不可判定性结论在实际中很重要——它意味着你没法写一个通用工具自动检查两个文法是否等价,也没法自动检测歧义。只能靠人工加测试。

6.3 从CFL到实际解析算法

理论归理论,实际写解析器的时候,CFL的结论怎么用?

首先,成员性可判定意味着语法分析一定有算法。CYK算法要求文法先转成乔姆斯基范式(CNF),然后动态规划填表。Earley算法更通用,不要求CNF,能处理左递归,但实现复杂一些。LL和LR是工程上最常用的,但它们只适用于DCFL,不是所有CFL。

其次,歧义不可判定意味着你不能指望工具自动帮你消歧。实际做法是:设计文法时就遵循分层原则,用工具检查冲突,写大量测试用例覆盖边界情况。我自己的习惯是每写一个新文法,先拿十几个“刁钻”串跑一遍,确认解析结果符合预期。

最后,CFL的表达能力边界提醒你:有些语言结构就是CFG描述不了的,比如需要跨层级匹配的变量声明和作用域检查。这些得靠语义分析阶段处理,不能硬塞进文法里。硬塞的结果要么是文法爆炸,要么是解析性能崩盘。

7. 我个人在实际操作中的体会

学完CFL这一章,最大的收获不是会推几个文法,而是建立了一种“分层描述”的思维。任何复杂结构,先找递归骨架,再定基准情况,最后补边界规则。这个套路不只用在编译器上,写配置文件格式、设计API参数结构、甚至整理复杂的业务规则,都能套。

另外一点体会是:理论里的“非确定性”和工程里的“确定性”之间的鸿沟,比想象中大。NPDA很强大,但工程上我们几乎只用DPDA,因为确定性解析才能保证线性时间。所以实际设计语言时,要有意识地把语法限制在DCFL范围内,避免用那些需要非确定性才能解析的结构。这个约束在写文法的时候可能感觉不到,但到了实现解析器的时候,会省下大量麻烦。

最后分享一个小技巧:如果你要手写一个文法的解析器,先用Earley算法快速验证文法是否正确,确认无误后再改写成LL或LR。Earley算法实现简单,虽然慢但正确性容易保证,适合做原型验证。等文法稳定了,再上性能更好的算法。这个流程我用了很多次,比一上来就写LR省时间。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进