想象你经营一家珠宝加工厂,每天收到一条条没有分隔的珠子串:有字母珠、数字珠、符号珠。你需要一台自动分拣机,把珠子按花纹分门别类丢进不同盒子。手写分拣机又慢又容易漏,于是你把规则写进一本“分拣规则册子”,交给自动工厂,工厂就为你造出一台高速分拣机。这个故事里,规则册子就是正则表达式,工厂就是 Lexer 生成器,最后交付的分拣机就是词法分析器。Lex 和 Flex 是这类工厂中最著名的代表,它们把“怎样认花纹”的说明,自动变成一台可以稳定运转的机器。

岗位关联

学会 Lexer 生成器,等于掌握编译器前端的第一座自动工厂,能让你快速构建可靠的词法分析模块,为编译、代码分析乃至 AI 代码理解打下扎实工程基础。

目录
  1. 可视化演示
  2. 生成器概述
  3. 正则表达式基础
  4. Lex规则语法
  5. 状态管理策略
  6. 冲突与优先级
  7. NFA构造
  8. DFA生成优化
  9. 实战应用
  10. AI 应用场景
  11. Python 演示
  12. 常见误区
  13. 面试题

可视化演示

Lexer生成器通过正则表达式描述词法规则,自动生成高效的词法分析器代码。本文以Lex和Flex为例讲解规则定... 切换模式查看不同维度。

本节配有交互动效(进入视口后自动加载,约 3KB)。

Lexer构:核心原理 动态展示关键流程与数据流动 交互控件调节参数实时渲染 f(x) = transform(x) 核心概念的可视化呈现

生成器概述

想象你的工厂每天收到一串串字符珠,有英文、数字、符号,彼此没有天然分隔。你要的不是整串珠子,而是按花纹分好的一堆堆记号。手写一台分拣机意味着要为每一颗珠子设计判断逻辑,费时且易错。于是你决定把规则交给自动工厂,让工厂替你造分拣机——这正是 Lexer 生成器的定位。

在编译原理中,这台分拣机叫词法分析器,它读入源代码字符流,输出 Token 流。Token 由“类型 + 原文”组成,例如 (IF, "if")、(NUMBER, "42")。Lex 和 Flex 是生成器的代表:开发者用正则表达式描述规则,生成器自动生成 C/C++ 扫描代码。开发者不再手写状态机,而是像填写规则册子一样描述“什么花纹算什么记号”。

回到珠子工厂,你不再关心传送带怎么拐弯,只要把规则册子交给工厂,机器会自动装配好。这就是生成器对工程师最大的价值:把复杂的自动机设计变成声明式描述。

Token(s)=(type,s),s∈⋃i=1nL(Ri)\text{Token}(s)=(\text{type},s),\quad s\in \bigcup_{i=1}^{n} L(R_i)
这条公式说明:一个词法记号就是“类别 + 原始字符串”的二元组,原始字符串必须能被至少一条规则的正则语言接受。
  1. 记号二元组词法器输出的是类型和原文组成的配对,类型告诉后续阶段“这是什么珠子”。
    Token(s)=(type,s)\text{Token}(s)=(\text{type},s)
  2. 规则语言每一条正则规则都有一个对应的语言集合,记为 \(L(R_i)\)。
    L(Ri)L(R_i)
  3. 并集匹配所有规则语言的并集,表示“任意一条规则能接受都算匹配成功”。
    ⋃i=1nL(Ri)\bigcup_{i=1}^{n} L(R_i)
  4. 从属关系\(s\in L\) 表示字符串 \(s\) 属于某条规则的语言集合。
    s∈L(Ri)s \in L(R_i)

正则表达式基础

规则册子不是自然语言,而是一种极简的花纹语言。它只有五种基本笔画:空笔画、单个珠、连续、二选一、圈起来重复。你写“红色珠 + 蓝色珠”,分拣机就明白要先看到红再看到蓝;你写“红珠或蓝珠”,分拣机就知道两条传送带都允许。

在生成器内部,这套花纹语言就是正则表达式。字符 \(a\) 表示匹配字符本身,· 表示连接,| 表示选择,* 表示零次或多次重复。括号用来改变优先级。Lex/Flex 支持更丰富的简写,比如 [0-9] 表示数字集合,+ 表示一次或多次,但它们的底层仍然可以拆解成这五种基本运算。

有了这五种笔画,工厂就能把任何复杂花纹拆成装配图纸。你写的每一条正则,最终都会变成自动机上的一个一个“机关”。

e→ϵ∣a∣e1e2∣e1∣e2∣e∗e \to \epsilon \mid a \mid e_1 e_2 \mid e_1 \vert e_2 \mid e^{*}
这是正则表达式的语法骨架:空串、单字符、连接、选择和闭包,所有复杂模式都由这五条组合而来。
  1. 空串 \(\epsilon\)匹配一个长度为 0 的字符串,是构造中的“空转传送带”。
    ϵ\epsilon
  2. 单字符 \(a\)最基本的原子匹配,只能接受字符 \(a\)。
    aa
  3. 连接 \(e_1 e_2\)先匹配 \(e_1\) 再接 \(e_2\),对应传送带首尾相接。
    e1e2e_1 e_2
  4. 选择 \(e_1 \vert e_2\)两条传送带并轨,任一分支成功即可。
    e1∣e2e_1 \vert e_2
  5. 闭包 \(e^{*}\)把 \(e\) 这一段循环零次或多次,对应分拣机反复抓取同花纹。
    e∗e^{*}

Lex规则语法

规则册子通常分三栏:第一栏准备材料,第二栏写核心规则,第三栏放收尾代码。Lex 源文件也遵循同样的三段式结构:定义区、规则区、用户代码区。定义区可以放宏、起始状态声明和选项;规则区是 pattern { action } 的列表;用户代码区放需要原样嵌入生成文件的辅助函数。

规则区的每一条都像是分拣机上的一个传感器:pattern 描述应该抓住哪种花纹,action 是抓住之后要做的动作。动作里最常用的是 return TOKEN;,意思是“把这个花纹放进对应盒子”。Flex 还会提供 yytext 变量,里面装着当前匹配到的原文,方便你进一步处理。

册子里的每一行,都是分拣机上的一处机关。你不必亲手焊接每个机械臂,只要把“看到什么花纹、放进哪个盒子”写清楚,工厂就能完成组装。

{⟨pi,ai⟩}i=1n⇒lexer\{ \langle p_i, a_i \rangle \}_{i=1}^{n} \Rightarrow \text{lexer}
这条公式表示:一组“模式 + 动作”的规则对,经过生成器加工后,变成一台可执行的词法分析器。
  1. 规则对每一行规则都由模式 \(p_i\) 和动作 \(a_i\) 组成。
    ⟨pi,ai⟩\langle p_i, a_i \rangle
  2. 规则集合把所有规则对用花括号收集起来,形成规则册子。
    ⟨p1,a1⟩,…,⟨pn,an⟩\langle p_1,a_1\rangle,\ldots,\langle p_n,a_n\rangle
  3. 生成箭头箭头左侧是“规格说明”,右侧是“成品机器”。
    ⇒\Rightarrow
  4. 词法器成品生成器输出的 lexer 拥有统一接口,读字符流、吐 Token。
    lexer\text{lexer}

状态管理策略

当珠子进入“注释垃圾”段时,分拣机要暂时切换到另一档:此时字母、数字都不再是普通珠子,而是要吞掉的废料,直到看见结束符才切回正常档位。这种“档位”思想,对应 Flex 中的起始状态。

Flex 用 %s NAME 声明一个起始状态,用 BEGIN(NAME); 切换状态,用 <NAME> 前缀限定某条规则只在该状态下生效。比如处理 /* ... */ 注释时,进入注释状态后,普通规则不再参与竞争,只有以 <COMMENT> 开头的规则会执行,直到遇见 */ 再 BEGIN(INITIAL); 回到普通状态。

档位切换让同一台分拣机可以干多份工。遇到注释就吞料,遇到字符串就进字符串模式,遇到普通代码就正常分拣,一台机器不再需要拆成三台。

(q,s)→  c  (q′,s′)(q, s) \xrightarrow{\;c\;} (q', s')
这条公式表示词法分析器的运行不仅由 DFA 状态 \(q\) 决定,还叠加了起始状态 \(s\);读入字符 \(c\) 后,状态和起始条件都可能发生变化。
  1. 组合状态当前配置是 DFA 状态 \(q\) 与起始状态 \(s\) 的二元组。
    (q,s)(q, s)
  2. 输入字符箭头下方的 \(c\) 是当前读入的字符珠。
    cc
  3. 状态转移转移后得到新的 DFA 状态 \(q'\)。
    q′q'
  4. 条件切换\(s'\) 表示起始状态可能被 BEGIN 改变后的新档位。
    s′s'

冲突与优先级

一颗珠子串同时符合红花纹和蓝花纹怎么办?分拣机有两条铁规矩:第一,永远先抓最长的一整串;第二,如果两串一样长,就按规则册子页码靠前的盒子来分。这样机器永远不会犹豫。

Flex 的默认行为正是如此:如果多条规则都能从当前位置匹配出一段字符串,它选择最长的那个词素;当最长长度相同时,选择声明顺序最早的那条规则。例如关键字 int 和标识符 [a-z]+ 同时匹配 intx 时,由于标识符能匹配完整的 intx 长度 4,比关键字长,所以最终结果是标识符 intx,而不是先切出 int 再切出 x。

这一规矩避免分拣机犹豫,也保证了行为可预测。无论规则怎么堆叠,只要依照“最长优先、同长先声明”的规则,任何输入都能得到唯一的切分结果。

choose i=min⁡{ k∣∣sk∣=max⁡j∣sj∣ }\text{choose } i = \min\{\, k \mid |s_k| = \max_j |s_j| \,\}
这条公式把冲突解决算法压缩成一句话:在所有规则产生的匹配长度里先找最大值,再从达到最大值的规则里选序号最小的那一个。
  1. 候选匹配每条规则 \(j\) 都产生一个匹配长度 \(|s_j|\)。
    ∣sj∣|s_j|
  2. 最长长度对全体候选求最大,得到本轮匹配的天花板。
    max⁡j∣sj∣\max_j |s_j|
  3. 筛选达标者只看那些长度等于最大值的规则。
    ∣sk∣=max⁡j∣sj∣|s_k| = \max_j |s_j|
  4. 最小序号在达标者里取最小的规则序号 \(k\),也就是最早声明的那条。
    min⁡{ k∣⋯ }\min\{\,k \mid \cdots \,\}

NFA构造

如果直接把正则规则焊进机器,控制台会不知道下一步该往哪走。所以工厂先画一张带“魔法”的装配草图:一个岔路口可以同时走多条传送带,不需要精确到每一步。这张草图的数学名字叫 NFA,全称是非确定有限自动机。

Lexer 生成器通常使用 Thompson 算法把正则表达式变成 NFA。算法非常机械:每个基本字符生成一个只有两条状态的小片段,然后通过空转移把片段拼起来。所谓空转移,就是不需要读入任何珠子的传送带;它让机器能“凭空”从一个片段跳到另一个片段。正是空转移让 NFA 可以同时保留多种可能路径。

这张魔法图纸不需要立刻执行,却把正则的语义完整保存了下来。所有连接、选择和循环,都在这一步变成节点和空转移,后续的 DFA 构建才有原料可用。

N=(Q,Σ,δ,q0,F)N=(Q,\Sigma,\delta,q_0,F)
这是一个标准 NFA 五元组:状态集合、字母表、转移函数、初态和终态。生成器构造 NFA 的过程,就是填满这五个栏位的过程。
  1. 状态集合 \(Q\)所有装配节点的集合,每个节点代表分拣过程的一个“观测位”。
    QQ
  2. 字母表 \(\Sigma\)可能出现的字符珠全集,也就是词法规则关心的输入字符。
    Σ\Sigma
  3. 转移函数 \(\delta\)读到一个字符或空串后,可能跳到多个状态。
    δ(q,a)⊆Q\delta(q,a)\subseteq Q
  4. 初态与终态\(q_0\) 是起点,\(F\) 是接受状态集合,代表一条珠子串能成功分拣的终点。
    q0,  Fq_0,\; F

DFA生成优化

魔法图纸无法直接焊进机器,因为机器不能同时走两条路。于是工厂把“当前可能在哪一堆状态”打包成一个“确切状态”,用一张一眼就能查表的图替换魔法图。这个打包过程叫子集构造,得到的新机器叫 DFA,每个状态对每个字符只有唯一一条出路。

子集构造的核心操作是 ε 闭包:从一个 NFA 状态集合出发,沿着空转移把所有能到达的状态都收进来;再读入一个字符,收集所有后续状态,再做一次 ε 闭包,得到一个新的 DFA 状态。反复执行,直到不再产生新状态,DFA 转移表就成型了。之后还可以用最小化算法合并行为等价的状态,压缩表格尺寸。

把魔法图变成查表图之后,分拣机就不用再“平行宇宙式”地同时追赶所有传送带;它只需要查表,走一条确定的路。最小化再帮忙删掉重复工序,让表格尽量精简。

D(T,a)=ε-closure(⋃p∈Tδ(p,a))D(T,a)=\varepsilon\text{-closure}\left(\bigcup_{p\in T}\delta(p,a)\right)
这是子集构造的递推公式:DFA 中从状态集合 \(T\) 读入 \(a\) 到达的新状态,就是所有 NFA 后继状态并集再做 ε 闭包的结果。
  1. 当前集合 \(T\)一个 DFA 状态其实是一组 NFA 状态的集合。
    TT
  2. 读入字符 \(a\)让集合中的每个 NFA 状态都尝试读入同一个字符。
    δ(p,a)\delta(p,a)
  3. 并集合并把所有后继状态收集起来,避免遗漏任何可能路径。
    ⋃p∈Tδ(p,a)\bigcup_{p\in T}\delta(p,a)
  4. ε 闭包沿着空转移把“不用读字符也能到达”的状态补进来,得到最终集合。
    ε-closure(⋅)\varepsilon\text{-closure}(\cdot)

实战应用

工厂交付后,保养手册上写着:输入长长一串珠子,分拣机始终用固定节奏工作,因为它只需查一张转移表。实际工程中,Lex/Flex 生成的词法分析器也具有这样的线性复杂度:每个字符最多被处理一次,每次转移通过查表完成,与规则数量无直接关系。

使用 Flex 的流程很简洁:编写 lexer.l 规则文件,运行 flex lexer.l 生成 lex.yy.c,再用 C 编译器编译并链接 -lfl 库。生成后的扫描器从 yylex() 函数开始,每次调用返回一个 Token。若遇到无法匹配的字符,默认会把它复制到输出流,但你可以在规则区补一条 . 规则并调用 yyerror 来报告错误,实现更严谨的容错。

这正是生成器流水线的价值:换订单只要改规则册子,生产线不用重新发明。无论是写编译器、做代码高亮,还是解析配置和日志,Lexer 生成器都能把“手工维护状态机”的体力活,压缩成描述花纹的声明式工作。

T(n)=O(n),M=O(∣Q∣⋅∣Σ∣)T(n)=O(n),\quad M=O(|Q|\cdot|\Sigma|)
这条公式给出生成器的核心性能承诺:扫描时间与输入长度成线性关系,存储空间主要由 DFA 状态数和字母表大小决定。
  1. 扫描复杂度输入长度为 \(n\) 时,词法分析器处理总时间正比于 \(n\)。
    T(n)=O(n)T(n)=O(n)
  2. 转移表大小DFA 转移表最多有 \(|Q|\) 行、\(|\Sigma|\) 列。
    ∣Q∣⋅∣Σ∣|Q|\cdot|\Sigma|
  3. 查表常数时间每次转移只需访问表中的一个格子,不随规则数增长。
    O(1)O(1)
  4. 总空间内存占用 \(M\) 与状态数和字符集大小的乘积成正比。
    M=O(∣Q∣⋅∣Σ∣)M=O(|Q|\cdot|\Sigma|)

AI 应用场景

AI 编程助手的代码分词

阶段:数据预处理

解决的问题:训练代码大模型前,需要把各种语言的源码切分成 token。手写分词器要考虑关键字、字符串、注释、缩进等大量边界,维护成本高。用 Lexer 生成器声明语言规则,可以快速为 Python、Go 等生成统一接口的词法器,输出稳定的 token 流供模型训练。

具体案例:某代码模型团队要处理十万个 GitHub 仓库,他们为每种语言写一份 Flex 规则文件,通过生成器得到 .c 扫描器,再用脚本批量切分代码,最终把 token 序列喂给模型。

海量日志结构化解析

阶段:特征工程与数据清洗

解决的问题:运维日志通常是半结构化文本,包含时间、IP、状态码和消息。传统方式用逐行正则 split 性能差且难以覆盖变化格式。用 Lexer 生成器构造专用日志词法器,以 DFA 线性扫描日志,一次性输出结构化事件序列,后续 AI 模型可直接做异常检测。

具体案例:某可观测平台每天处理百亿行 Nginx 日志,团队用 Flex 定义了时间、IP、URL 等 token 规则,生成高性能词法器,将解析延迟降低了近一个数量级。

AI 辅助编译器前端

阶段:推理与解析流水线

解决的问题:AI 程序翻译和理解工具需要同时解析多种语言,手写词法分析器容易在语言切换时崩溃。用生成器为每种语言生成独立词法器,再用统一 Token 接口衔接语法分析器,前端稳定性大幅提升。

具体案例:一款 AI 代码翻译工具需要从 Java 翻译到 Python,团队先用 Flex 生成 Java 词法器,再配合语法分析器提取抽象语法树,最终交给翻译模型生成目标代码。

Python 演示

python51 行
import re  class Rule:    def __init__(self, name, pattern):        self.name = name        self.pattern = pattern        self.regex = re.compile(pattern)  class LexerGenerator:    def __init__(self):        self.rules = []     def add(self, name, pattern):        self.rules.append(Rule(name, pattern))     def build(self):        def lexer(text):            tokens = []            pos = 0            while pos < len(text):                best_name = None                best_len = 0                for rule in self.rules:                    m = rule.regex.match(text, pos)                    if m and len(m.group(0)) > best_len:                        best_len = len(m.group(0))                        best_name = rule.name                if best_name is None:                    raise ValueError(f"无法识别位置 {pos}: {text[pos:]!r}")                tokens.append((best_name, text[pos:pos + best_len]))                pos += best_len            return tokens         return lexer  def main():    gen = LexerGenerator()    gen.add("关键字", r"if|else")    gen.add("数字", r"[0-9]+")    gen.add("标识符", r"[a-z]+")    gen.add("空白", r"\s+")    lex = gen.build()    tokens = lex("if x42 else")    print(tokens)  if __name__ == "__main__":    main()
目的:实现一个迷你 Lexer 生成器:先“收集规则”,再通过 build() 动态生成一个采用最长匹配原则的词法分析函数。
  1. class LexerGenerator:相当于珠子工厂的厂房对象,负责积累规则并生成分拣机。
  2. def add(self, name, pattern):向规则册子追加一条规则,并预编译成正则对象。
  3. rule.regex = re.compile(pattern)提前编译正则,避免每次匹配都重新解析规则。
  4. def build(self):根据规则册子返回一个可调用的 lexer 函数,这就是“生成”动作。
  5. while pos < len(text):扫描头从字符串起点逐步前进,直到整个输入被分拣完毕。
  6. for rule in self.rules:遍历所有规则,模拟 DFA 同时考虑所有可能匹配。
  7. if m and len(m.group(0)) > best_len:用“只保留更长者”实现最长匹配;若长度相同,因为遍历顺序未变,先声明的规则自然获胜。
  8. if best_name is None:找不到任何规则匹配时抛出错误,相当于分拣机遇到未知花纹。
  9. tokens.append((best_name, text[pos:pos + best_len]))把“类别 + 原文”的 Token 对放进结果列表。

常见误区

误区Flex 规则是从左到右逐字符扫描的,先匹配到哪条就用哪条,所以把最常用的规则放前面能提高正确率和性能。

正解Flex 生成的是 DFA 扫描器,它会同时跟踪所有可能匹配,最终选择最长匹配;只有在最长长度相同的情况下,才由声明顺序决定。调整顺序不会让扫描变快,但会改变平局时的结果。

为何常见:人们习惯文本编辑器里的正则“找到即停”,以为词法分析器也是先到先得,忽略了“最长词素”对划分 token 的关键作用。

误区DFA 比 NFA 更“简单”,所以 DFA 的状态数一定比原来的 NFA 少,构造 DFA 是纯优化。

正解DFA 的状态来自 NFA 状态集合的“幂集”,最坏情况下会呈指数级增长。工程中常用正则生成的 NFA 本身较小,再经过最小化压缩,DFA 才变得精简。

为何常见:教材上的例子状态数很少,容易让人误以为这种结论是普遍规律。

误区Lexer 能识别 `if`、`else`、数字和变量名,所以它应该能发现语法错误,比如 `int x = ;` 这种句子。

正解词法分析器只负责把字符流切成 Token,不建立语法树,也判断不了这些 Token 的排列是否合法。int x = ; 会被切成 int、x、=、; 四个 Token,但语法错误要交给后续的 parser 报告。

为何常见:看到词法器能分出关键字,就误以为它“理解”了程序,实际上它只是最强悍的模式分拣工,不具备上下文判断能力。

误区`.` 在正则里代表任意字符,所以 Flex 规则中的 `.` 应该也能匹配换行符。

正解Flex 默认把输入按行处理,. 不匹配换行符。要处理跨行注释或字符串,应该使用起始状态,并自定义结束条件,而不是依赖 . 覆盖换行。

为何常见:很多语言的正则教程会强调“点匹配除换行外的任意字符”,但新手仍容易把它绝对化成“任意字符”。

误区写的正则越短,生成的词法分析器运行就越快;正则越长只会让生成代码变多,不会影响速度。

正解DFA 扫描每个字符的代价基本恒定,但更长的正则、更多的规则会增大构建期和转移表体积。运行期仍然线性,但“构建成本”和“运行成本”是两回事。

为何常见:大家对字符串匹配的直觉是“匹配文本越短越省事”,忽略了生成器先构建自动机再扫描的两阶段流程。

面试题

Q1. Flex 源文件三个段落的正确排列顺序是什么?

单选题 · easy

  • A.规则区、定义区、用户代码区
  • B.定义区、规则区、用户代码区← 正确答案
  • C.用户代码区、定义区、规则区
  • D.规则区、用户代码区、定义区

答案:B。Flex 文件的标准结构是定义区、规则区、用户代码区,用两个 %% 分隔。定义区准备宏和状态,规则区写 pattern { action },用户代码区放辅助函数,顺序颠倒会导致生成器无法正确解析。

Q2. 在 Flex 规则 ^int 中,^ 表示什么?

单选题 · easy

  • A.取反
  • B.行首← 正确答案
  • C.匹配一个空格
  • D.贪婪匹配

答案:B。Flex 中的 ^ 是行首锚点,表示该规则只在行首位置匹配。它并不是取反符号。类似地,$ 表示行尾锚点,这两者只影响匹配位置,不改变字符内容。

Q3. Lexer 生成器把规则说明自动转换成的核心数据结构是什么?

单选题 · easy

  • A.语法树
  • B.符号表
  • C.DFA 状态转移表← 正确答案
  • D.哈希表

答案:C。词法规则先被转换为 NFA,再通过子集构造变成 DFA,最终以状态转移表驱动扫描器。语法树是语法分析阶段的结构,符号表用于语义分析,都与词法生成器核心无关。

Q4. 输入为 intx,规则 1 是关键字 int,规则 2 是标识符 [a-z]+。Flex 会生成什么?

单选题 · mid

  • A.关键字 int
  • B.标识符 intx← 正确答案
  • C.先 int 再 x
  • D.报错

答案:B。Flex 采用最长匹配,规则 2 能匹配完整的 intx,长度 4;规则 1 只能匹配 int,长度 3,所以最终结果是标识符 intx。长度相等时才会使用声明顺序决定胜负。

Q5. NFA 转 DFA 时,DFA 的一个状态对应什么?

单选题 · mid

  • A.NFA 的一个输入字符
  • B.NFA 的一个状态
  • C.NFA 状态集合← 正确答案
  • D.NFA 的一条边

答案:C。子集构造的核心思想是“NFA 当前可能处在若干状态”,所以 DFA 的每个状态都是这些可能状态组成的一个集合,而不是单个 NFA 状态或字符。

Q6. 关于 Flex 起始状态,下列说法正确的是?

多选题 · mid

  • A.%s COMMENT 可以声明一个名为 COMMENT 的起始状态← 正确答案
  • B.BEGIN(COMMENT); 可以切换到该状态← 正确答案
  • C.<COMMENT> 前缀可以让规则只在当前状态下生效← 正确答案
  • D.起始状态只能在规则区结尾声明

答案:ABC。起始状态必须在定义区用 %s 或 %x 声明,而不是在规则区结尾。BEGIN 负责切换,带 <状态名> 前缀的规则只在对应状态下参与匹配,所以 D 错误。

Q7. 子集构造中,ε-closure(T) 表示什么?

单选题 · mid

  • A.从 T 中状态出发,仅通过 ε 转移能到达的状态集合← 正确答案
  • B.T 中所有状态的出边集合
  • C.与 T 等价的所有正则表达式
  • D.T 的接受状态集合

答案:A。ε 闭包是一个“补全”操作:从一个状态集合出发,把不需要读入任何字符、只沿空转移就能到达的所有状态都收进来。它是子集构造中保证不漏路径的关键步骤。

Q8. DFA 最小化时,Hopcroft 算法的初始划分通常是什么?

单选题 · hard

  • A.每个状态单独一组
  • B.所有状态一组
  • C.接受状态与不接受状态各一组← 正确答案
  • D.按入边字符分组

答案:C。接受状态和非接受状态行为已经明显不同,所以先把它们分成两组。接下来不断细分组,直到组内状态对每个字符都跳转到同一组,才算完成最小化。

Q9. 关于 Flex 规则冲突,下列说法正确的是?

多选题 · hard

  • A.相同匹配长度下,先声明的规则优先← 正确答案
  • B.最长匹配优先于声明顺序← 正确答案
  • C.如果两条规则匹配相同长度,后声明的规则胜出
  • D.规则动作可以调用 yytext 查看匹配文本← 正确答案

答案:ABD。Flex 的规则冲突首先看匹配长度,长度更长者胜出;长度相同则先声明者胜出,所以 C 错误。动作中 yytext 保存当前匹配到的原文,是开发者最常使用的内置变量。

Q10. 关于 NFA 与 DFA 的工程权衡,正确的是?

多选题 · extreme

  • A.NFA 状态数通常比对应 DFA 少← 正确答案
  • B.DFA 每个状态对每个字符只有一条确定转移← 正确答案
  • C.DFA 最坏可能指数膨胀,因此实际词法器总是用 NFA 直接执行
  • D.最小化可以压缩 DFA 状态数← 正确答案

答案:ABD。NFA 因为允许空转移和多路径,通常更紧凑;DFA 确定但可能膨胀。实际 Lexer 生成器仍然生成 DFA 以获得线性扫描性能,同时用最小化控制状态数,而不是直接用 NFA 执行,所以 C 错误。