你有没有在玩具店里见过一套庞大的乐高机械组?盒子里的零件拆开来,是几百颗基础砖块:红色 2×4 板、蓝色长轴、白色面板。真正让这些砖块变成一辆跑车的,是一本薄薄的《拼装手册》——它告诉你“车轮组件”由哪些砖块拼成,“车身组件”又由哪几个组件组合。你照着手册层层拆解,从“最终产品”一路拆到每一颗砖块,最终得到一棵漂亮的拼装结构树。 在计算机科学里,描述“怎样从基础符号合法地组合成复杂句子”的这本手册,就叫上下文无关文法(Context-Free Grammar,CFG)。它规定什么算“合法句子”,编译器靠它判断代码对不对,聊天机器人靠它理解命令的骨架,数据处理工具也靠它校验配置文件的嵌套结构。本文就带你用乐高拼装车间这条线索,把 CFG 从四元组定义、推导与归约、二义性判定,到乔姆斯基范式与 GNF 转换,再到 Yacc 解析器生成器,完整走一遍。
学会 CFG,你就掌握了编译器与 NLP 工具背后的语言骨架。当你要用 LLM 构建结构化输出、设计领域 DSL 或解析器时,不会再被“语法分析”这个黑盒卡住——这是 AI 从业者最硬核的底层素养之一。
可视化演示
上下文无关文法是语法分析的理论基础,通过产生式规则定义语言结构。本文详解CFG的四元组形式化定义、最左推导与最... 切换模式查看不同维度。
本节配有交互动效(进入视口后自动加载,约 3KB)。
CFG四元组:核心原理 动态展示关键流程与数据流动 交互控件调节参数实时渲染 f(x) = transform(x) 核心概念的可视化呈现
文法核心概念
想象你刚被乐高工厂录用为拼装员,工位前是一堆亮闪闪的基础砖块:红色 2×4 板、蓝色长轴、白色面板。这些砖块在车间里被称为“终端件”,因为任何工序都无法再拆分它们——在编译原理里,它们就叫终结符(Terminal)。而墙上贴着的“车身组件”“引擎组件”则不是实物,它们是工人们拼装过程中的半成品状态,最终都要被拆解成砖块——这就是非终结符(Nonterminal)。
把目光从车间拉回编译器。编译器要判断一段源代码 a + b * c 是不是“合法句子”,就需要一本“拼装手册”,也就是文法(Grammar)。文法由四条核心概念组成:终结符是句子里的最小原子(如 id、+、*、括号);非终结符是“表达式”“项”这类待展开的半成品;开始符号(Start Symbol)是整本手册指定的唯一初始目标;而产生式(Production)就是手册上的一条条指令,比如“表达式可以拼成 表达式 加 表达式”。
形式化地说,一个上下文无关文法用一套产生式规则描述语言。看一个最简单的算术表达式文法:
在乐高车间里,这张文法就是《拼装手册》第 1 页的指令:一个“车身组件”可以是一块底盘加一个驾驶室,也可以是一块底盘加一个车斗,还可以直接就是一个现成的车壳。工人看着手册,按订单选择最合适的那条指令。
| 表示“或”。- E 作为唯一的非终结符它代表“一个表达式”这个半成品状态,需要被继续展开成具体砖块。
- 产生式右部的四种候选每条
E → ...都提供了一种把 E 展开的合法方式。 - 竖线表示“或”候选四种右部任选其一,就像手册同一页写着四种拼法。
- id 是终结符它代表任意变量名或数字字面量,是整个推导树的叶子节点。
CFG四元组定义
乐高工厂为了规范化管理,会把《拼装手册》拆成四份台账:一份登记所有半成品夹具名(V),一份登记所有基础砖块类型(T),一份登记所有拼装指令(P),还有一份在封面标明最终产品名(S)。任何一本合法的拼装手册,都必须能装进这四个抽屉里——CFG 的形式化定义正是这“四个抽屉”。
这个定义叫做四元组:
正是这四个抽屉,把“拼装手册”变成机器可检索的数学对象。无论多复杂的语言规则,只要你能填满这四个抽屉,就得到了一个上下文无关文法。
- V 变量集合V 是所有“半成品夹具”的名单,这里的集合里只有 E 一个非终结符。
- T 终结符集合T 是所有“最小砖块”的名单,它们是句子的字母表,与 V 互不相交。
- P 产生式集合P 是拼装指令清单,每条指令左部只有一个非终结符,右部是终结符与非终结符组成的串。
- S 开始符号S 是“最终产品”的名字,所有拼装都从它出发,且 S 必须属于 V。
推导与归约
拼装员拿到一张订单——目标串 id + id。他翻开手册,从封面上的“最终产品”E 出发,一路按指令把半成品拆成组件:E 先变成 E + T,再把左边的 E 变成 T,然后 T 变成 id……这种逐步展开的过程就叫推导(Derivation)。
推导就是反复用产生式的右部替换串中的某个非终结符。选择哪个非终结符替换,会带来不同的策略:最左推导(Leftmost Derivation)每步总替换当前串中最左边的非终结符;最右推导(Rightmost Derivation)每步替换最右边的非终结符;而归约(Reduction)是推导的逆过程——把一串符号“合并”回一个非终结符,这正是各类移进-归约分析器的核心动作。
考察文法 E → E + T | T、T → id,对串 id + id 做最左推导:
这就像流水线上永远先处理“最左边”的半成品工位。与之相对的最右推导则永远先处理最右边;对于无二义文法,两种策略最终会得到同一棵语法分析树——这个性质在后面讲 Yacc 时会成为“如何构造唯一语法树”的基石。
⇒ 表示应用一条产生式替换当前串的最左非终结符。最终得到只含终结符的串,推导结束。把箭头反过来看,就是从 id + id 归约回 E。- 第一次替换把最左的 E 用产生式
E → E + T展开成两项之和。 - 第二次替换现在串是
E + T,最左非终结符还是 E,用E → T替换它。 - 第三次替换串变成
T + T,最左的 T 用T → id替换成终结符。 - 第四次替换剩下的 T 也替换成 id,得到纯终结符串,推导完成。
二义性判定
工厂收到一份特殊订单 id + id + id,两组工人各按手册拼装。A 组组长说“按左结合,从左往右加”;B 组组长说“按右结合,从右往左加”。两组都产出了合法产品,但内部装配关系完全不同——车间主任震怒了:同一张图纸怎么能有两种拼法?这就叫二义性(Ambiguity)。
一个 CFG 是二义的,如果存在某个由它生成的串,对应两棵不同的语法分析树。注意二义性是“文法”的性质,而不是“语言”的必然属性:同一个语言可以有一个二义文法,也可能存在无二义文法。考察文法 E → E + E | id 和串 id + id + id,可以构造两个不同的最左推导:
判定一个文法是否二义,理论上不可判定,但实践里只要找出一个具体的“双拼案例”就能敲定。在 Yacc 中,二义性会表现为 shift/reduce 冲突,工程师通常通过优先级声明或改写文法来消除它——就像组长开会统一“默认左结合”的规矩。
- 推导一的第二步在串
E + E中继续展开最左的非终结符 E,形成E + E + E,对应“左结合”。 - 推导二的第二步同样在串
E + E中,选择把最左的 E 替换成终结符 id,而不是继续展开。 - 两条推导殊途同归两个不同的最左推导最终都得到同一个终结符串
id + id + id,但语法树结构不同。
乔姆斯基范式
为了全工厂统一质量,品控部推出“标准件制度”:每一条拼装指令最多只能涉及“两个半成品”,或者直接使用“一个基础砖块”。任何复杂的手册都可以改造成这种标准格式,而产出的产品集合一字不差。这就是乔姆斯基范式(Chomsky Normal Form,CNF)。
CNF 规定每条产生式只能有两种形状:A → BC(右部恰为两个非终结符)或 A → a(右部恰为一个终结符)。另外允许 S → ε,但仅当空串属于该语言时才允许。把任意 CFG 转成 CNF 通常分三步:先消除 ε 产生式,再消除单元产生式(A → B 这种“直接改名”),最后把长度大于 2 的候选拆成二元链。
CNF 的意义不只是品控洁癖——CYK 动态规划算法就建立在这种“二元拆分”之上:一段字符串要归约成 A,就必须能拆成两段,分别归约成 B 和 C。这就像检验复杂拼装是否合规,只需递归检查每一个二元节点是否合规。
- 二元产生式 A → BC右部恰为两个非终结符,这是“标准件”的第一种型号。
- 终结符产生式 A → a右部恰为一个终结符,这是“标准件”的第二种型号。
- S → ε 特例只有当空串属于该语言时,才允许 S 产生空串,并且此时 S 不得出现在任何产生式的右部。
- 等价性保真变换前后文法生成的语言完全一致,只是“拼装指令”被细化成标准动作。
Greibach范式
如果说 CNF 是“标准件制度”,那么 Greibach 范式(Greibach Normal Form,GNF) 是更严格的新规:每条拼装指令的“第一步动作必须是拿起一个基础砖块”,后续才是继续拼半成品。也就是说,任何合法产品的拼装过程,第一个被放上去的永远是一片砖,而不是一个半成品。
GNF 规定每条产生式形如 A → aβ,其中 a 是终结符,β 是若干个非终结符组成的串(可以为空)。转换的核心难点是消除左递归:像 A → Aα 这样的规则会让最左推导永远卡在 A 上停不下来;标准做法是引入新非终结符 Z,把 A → Aα | β 改写成 A → βZ、Z → αZ | ε。
GNF 在理论上很优雅:它让“最左推导的每一步都消耗一个终结符”,从而与“下推自动机读入字母”形成一一对应。直观地说,GNF 相当于把每个半成品的拼装过程都规范成“第一块必然是砖头”,这让从文法到自动机的构造畅通无阻。
- 顶部的终结符 a右部第一位必须是终结符,这是 GNF 最醒目的标志。
- 后续非终结符串每个
X_i都是非终结符,它们会在后续推导中被继续展开。 - n = 0 的退化情况产生式退化为
A → a,与 CNF 的终结符产生式一致。 - 左递归消除技巧把
A → Aα | β改写成引入新符号 Z 的右递归形式,是转换 GNF 的关键一步。
乔姆斯基分类
乐高总部发现世界上的拼装手册种类繁多,于是把手册按指令力度分成四个等级:最弱的手册只允许“把一个零件整体替换成另一个零件”(正则文法);稍强的手册允许“按上下文情境替换”(上下文有关文法);我们这本车间手册恰好属于第二强等级(上下文无关文法);而最强的 0 型手册几乎不加限制,能描述图灵机的全部运算过程。这个分级就是乔姆斯基谱系(Chomsky Hierarchy)。
谱系把形式文法分为四型:0 型无限制文法(对应图灵机),1 型上下文有关文法(产生式形如 αAβ → αγβ,替换时要求 A 身边有 α 和 β),2 型上下文无关文法(即 CFG,左部只有一个非终结符),3 型正则文法(产生式形如 A → aB | a,对应有限自动机)。四种语言集合严格嵌套:
理解了这条谱系,你就明白为什么编译器前端选择 CFG 而非正则表达式——正则文法“记不住嵌套”,而 CFG 拥有“括号嵌套、表达式中套子表达式”的递归生成能力。这正是拼装手册被选为车间主手册的根本原因。
aⁿbⁿ 是上下文无关语言,却不是正则语言。- 3 型语言是 2 型的真子集每个正则语言都能用 CFG 生成,但存在 CFG 能生成而正则文法不能生成的语言。
- 2 型语言是 1 型的真子集所有上下文无关语言都能用上下文有关文法描述。
- 1 型语言是 0 型的真子集上下文有关语言只是无限制语言世界中的一小片。
解析器应用
光有手册还不够,工厂里还需要一台“自动拼装机器人”。给机器人输入文法,它就能自动识别订单里的目标串并检查是否合规——这台机器就是解析器生成器(Parser Generator),最著名的当属 Yacc/Bison。工程师们写的不是分析算法,而是“手册的电子版”。
在 Yacc 的规则文件里,一条语法规则长这样:
其中 expression 是一个非终结符,冒号等价于产生式箭头 →,每个 | 分隔一种候选右部,分号收尾。Yacc 会根据这些产生式自动生成一个 LALR 分析器:它从左到右扫描输入串,把终结符“移进”栈里,当栈顶形成某个产生式右部时,将其“归约”成左部非终结符——这正是前面讲过的“归约”概念在工程中的直接体现。
当你在项目里用 Yacc/Bison 定义语法时,其实就是在填那四个抽屉:终结符是 token 声明,非终结符写在规则左侧,开始符号写在 %start 里,而 P 就是这一整段规则清单。从“乐高手册”到“自动拼装机”,CFG 理论就这样完整地落到了工程里。
| 表示“或”,分号结束一条规则。小写名称(如 expr)是非终结符,词法层返回的 token(如 number)是终结符。- expr 作为左部它是非终结符,对应 Yacc 规则里写在冒号左侧的名字。
- 右部的两种运算候选加法和减法各对应一种展开方式,都是“表达式 加/减 表达式”。
- 竖线连接多个候选Yacc 的
|与数学上的“或”同义,分隔同一条规则的多种右部。 - number 终结符由词法分析器产生的 token 是终结符,在右部直接出现,不再被展开。
expression : expression '+' expression | expression '-' expression | number ;
AI 应用场景
编译器与 AI 编译器的前端语法分析
阶段:语法分析(编译器前端第二环)
解决的问题:手写递归下降解析器要维护大量回溯逻辑,极易出错;用 Yacc/ANTLR 这类解析器生成器,只需把 CFG 产生式声明清楚,工具就会自动生成 LALR 分析器,把“怎么解析”交给工具,把“解析什么”交给文法。AI 编译器(如 TVM、MLIR)的 IR 语法同样用类 CFG 描述,工程师每天都在与文法打交道。
具体案例:某团队为内部 DSL 设计语法,写下 30 条产生式,Yacc 自动生成 1500 行 C 代码的解析器,解析速度提升 3 倍,逻辑全部集中在文法声明里,维护成本大幅下降。
大模型输出中的结构化命令校验
阶段:AI Agent 的意图解析与输出约束
解决的问题:LLM 生成的文本自由度太高,直接交给下游系统可能触发非法操作。用 CFG 定义一组“合法指令模板”,例如“播放 [歌曲名]”“查询 [城市] [天气]”,模型输出先经过 CFG 校验,非法结构直接拦截,比纯正则表达式可靠得多。
具体案例:某智能客服系统用 ANTLR 为指令集定义 CFG,LLM 生成的用户意图被解析成结构化调用参数,槽位抽取准确率从 78% 提升到 95%,且天然防注入攻击。
Python 演示
# -*- coding: utf-8 -*-def cyk_parse(grammar, start, string): # CYK 算法: 判断 string 能否由 CNF 文法生成 n = len(string) if n == 0: return False # 本示例文法不产生空串, 简化处理 # table[i][j] 是能推导出 string[i:j+1] 的非终结符集合 table = [[set() for _ in range(n)] for _ in range(n)] # 第一步: 处理 A -> a 形式的终结符产生式 for i, ch in enumerate(string): for A, prods in grammar.items(): for rhs in prods: if len(rhs) == 1 and rhs[0] == ch: table[i][i].add(A) # 第二步: 按子串长度从小到大填表 for length in range(2, n + 1): for i in range(n - length + 1): j = i + length - 1 for k in range(i, j): for A, prods in grammar.items(): for rhs in prods: if len(rhs) == 2 and rhs[0] in table[i][k] and rhs[1] in table[k + 1][j]: table[i][j].add(A) return start in table[0][n - 1] def main(): # 教材经典文法 G: # S -> AB | BC # A -> BA | a # B -> CC | b # C -> AB | a grammar = { 'S': ['AB', 'BC'], 'A': ['BA', 'a'], 'B': ['CC', 'b'], 'C': ['AB', 'a'] } for w in ['baaba', 'baa', 'abab']: print("{} 属于 L(G): {}".format(w, cyk_parse(grammar, 'S', w))) if __name__ == '__main__': main()
table = [[set() for _ in range(n)] for _ in range(n)]建立 n×n 三角表,规则是“能推出string[i:j+1]的所有非终结符”。if len(rhs) == 1 and rhs[0] == ch:先处理A → a这类终结符产生式,一个字符能被谁直接推出。for k in range(i, j):枚举切分点 k,把子串[i..j]分成两段,这是 CYK 的“二元拆分”核心。if len(rhs) == 2 and rhs[0] in table[i][k]只有当左半能归约成 B、右半能归约成 C,且存在A → BC时,整段才能归约成 A。return start in table[0][n - 1]整串若能归约成开始符号 S,则该串属于文法语言。
常见误区
误区“上下文无关文法不就是随便写规则吗?规则两边不用管周围是什么,这名字取得太随意了。”
正解“上下文无关”的准确含义是:产生式左侧只能有一个非终结符,即 A → α 这种替换不依赖 A 出现在什么样的上下文中。对比 1 型上下文有关文法 αAβ → αγβ,替换 A 时要求周围必须有 α 和 β。类比乐高车间:CFG 的规则就像“只要看到'车身组件'这个夹具名,不管它在哪条流水线上,都按同一条指令展开”;而上下文有关规则则会说“只有在驾驶室后面的车身组件才这样拼”。
为何常见:名字太过口语化,初学者把它理解成否定句,忽略了它真正强调的是产生式左部形式的限制。
误区“这个文法有二义性,所以它不能用于编译器,整个语言设计都得换掉。”
正解二义性说的是同一个文法存在某个串对应多棵不同的语法树,这是文法的属性,不是语言的属性。实际工程里,很多语言(比如 C 语言的 if-else 悬空问题)用二义文法描述更自然;Yacc 会报 shift/reduce 冲突,工程师通过优先级声明或改写文法来消除冲突,而不是换语言。类比:同一本手册有两种拼法,车间主任不需要换产品,只需在手册上加一条备注“默认左结合”。
为何常见:初学者把“二义性”当成文法的死罪,其实一个二义文法往往存在等价的、无二义的其他文法,语言本身仍可被优雅描述。
误区“转成乔姆斯基范式后,产生式被拆得七零八落,能识别的字符串肯定也变少了吧?”
正解CNF 转换是等价变换,不改变生成的语言。只是产生式的粒度变细了:长产生式被拆成多步,终结符也被包了一层非终结符。类比:把“熟练工一步装好发动机”改成“流水线分三步装”,最终产品一模一样,只是工序标准化了。CYK 算法恰恰建立在 CNF 上,因为“二元拆分”正好匹配字符串的两段划分——这是把文法转成 CNF 的最大价值。
为何常见:看到语法树变深、中间符号变多,容易误以为“语言变小了”;实际上原有句子的所有生成路径都仍存在,只是多拐了几道弯。
误区“最左推导和最右推导得到的语法树不一样吧?所以选用不同的推导顺序会生成不同的句子。”
正解对于无二义文法,任何推导顺序(最左、最右或其它顺序)都对应同一棵语法分析树,只是节点展开的先后次序不同。对于二义文法,不同推导确实得到不同树,但那是二义性本身的定义,而不是推导策略造成的。类比:无论先拼左轮还是先拼右轮,只要按同一张图纸,最终车的装配结构是一样的。
为何常见:把“推导过程的先后顺序”和“树的结构”混为一谈——顺序是时间维度的操作,结构是空间维度的结果。
面试题
Q1. 一个上下文无关文法 G = (V, T, P, S) 中,S 代表什么?
- A.终结符集合
- B.产生式集合
- C.开始符号← 正确答案
- D.变量集合
答案:C。S 是 Start Symbol 的缩写,它是推导的起点,属于 V 且必须是非终结符。在乐高比喻里,S 就是手册封面上的“最终产品名”。A 选项是 T,B 选项是 P,D 选项是 V。
Q2. 以下哪一组是 CNF 产生式的两种合法形状?
- A.
A → BC与A → a← 正确答案 - B.
A → aB与A → Ba - C.
A → ABCD与A → ε - D.
AB → CD与A → B
答案:A。CNF 只允许右部为“两个非终结符”或“一个终结符”。B 是右线性(3 型)文法;C 的长产生式必须先拆成二元链,且除 S 外不允许 ε;D 的 AB → CD 左侧有两个符号,破坏了“上下文无关”的单符号左部要求。
Q3. 文法 E → E + E | id 关于串 id + id + id 是( )。
- A.正规文法
- B.无二义的
- C.二义的← 正确答案
- D.无法生成的
答案:C。该串存在两个不同的最左推导,对应两棵不同的语法树(左结合与右结合),所以该文法是二义的。类比:同一本手册,两组工人拼出了内部装配关系不同的产品。
Q4. 在乔姆斯基谱系中,上下文无关文法是第几型文法?
- A.0 型
- B.1 型
- C.2 型← 正确答案
- D.3 型
答案:C。0 型对应图灵机,1 型对应上下文有关,2 型对应上下文无关,3 型对应正则。CFG 属于 2 型,覆盖了绝大多数编程语言的核心语法结构。
Q5. 最左推导每一步替换的是( )。
- A.最左边的终结符
- B.最右边的非终结符
- C.最左边的非终结符← 正确答案
- D.任意一个非终结符
答案:C。最左推导要求每一步都替换当前串中最左边的非终结符,直到串中不再有非终结符。类比流水线:永远先处理最左边的半成品工位。A 错在终结符不可替换;B 是最右推导;D 是脱离定义的任意策略。
Q6. 在 Yacc 规则文件 expression : expression '+' expression ; 中,冒号等价于( )。
- A.赋值运算符
- B.产生式箭头 →← 正确答案
- C.相等比较
- D.归约动作
答案:B。Yacc 的冒号就是把左侧非终结符连接到右侧候选,数学上写作 A → α,分号结束一条规则,| 表示“或”。冒号既不是赋值、比较,也不是归约动作,而是文法产生式的写代码方式。
Q7. CYK 算法要求文法呈 CNF 形式,最根本的原因是( )。
- A.CNF 能生成更多的语言
- B.二元产生式恰好对应“子串划分为两段”← 正确答案
- C.CNF 消除了所有终结符
- D.CNF 文法一定是无二义的
答案:B。CYK 按子串长度从小到大填表,检查 string[i..j] 能否归约成 A 时,需要找切分点 k 把子串分成两段;若左半归约成 B、右半归约成 C 且存在 A → BC,则整段可归约。二元产生式与“两段子串”的对应是 CYK 高效填表的根基。CNF 不改变语言,也不消除二义性,更不会消灭终结符。
Q8. 以下哪些属于 CFG 四元组的组成部分?
- A.变量集合 V← 正确答案
- B.终结符集合 T← 正确答案
- C.产生式集合 P← 正确答案
- D.终止状态集合 F
答案:ABC。四元组是 (V, T, P, S),分别对应半成品名单、最小砖块名单、拼装指令清单和最终产品名。D 的终止状态集合 F 属于自动机五元组,不出现在 CFG 的定义里。
Q9. 文法 E → E + E | id 是二义文法,消除二义性最常用的工程手段是( )。
- A.删掉一条产生式
- B.声明优先级与结合性← 正确答案
- C.把所有非终结符改成终结符
- D.增加更多终结符
答案:B。工程实践中,Yacc 等工具允许通过 %left %right 声明运算符的优先级与结合性,让冲突自动消解。类比:车间主任不改图纸,只在手册上加一条“默认左结合”的备注。删产生式会缩小语言,C 和 D 则完全改变了文法结构。
Q10. 关于 Greibach 范式(GNF),下列哪些说法正确?
- A.每条产生式以终结符开头← 正确答案
- B.任意上下文无关语言都能转换为 GNF← 正确答案
- C.GNF 只能描述正则语言
- D.转换 GNF 通常需要先消除左递归← 正确答案
答案:ABD。GNF 的定义就是右部第一个符号必须是终结符(A 对);理论上任意 CFL 都能转换成 GNF(B 对);转换前要消除左递归,否则最左推导会陷入无限循环(D 对)。C 错在 GNF 描述的是上下文无关语言,其能力远强于正则语言——终结符开头的产生式+递归非终结符,依然能生成 aⁿbⁿ 这类非正则语言。