深夜翻开编译原理教材,第一章才看了几页就想合上书——这个场景我太熟了。当年我啃完第一章的课后习题,最大的感受不是“我会了”,而是“我好像哪里都没会”。因为编译原理不像高数,它没有一大堆可以套的公式,第一章真正考的是你能不能把一个语言的规则描述变成机器能识别的状态机,这是一个非常反直觉的思维转折点。
如果你也是冲着“编译原理第一章课后习题答案”来的,我劝你先别急着找答案。不同学校用的教材版本不同,习题编号经常对不上;更重要的,第一章习题是整门课的筛子,把“背概念”和“真懂原理”的人直接分开。与其抄答案,不如把答案背后必考的那套知识吃透。这篇东西我会从第一章涉及的几个核心考点讲起,把每一类题型的标准解法一步一步推到能直接上手的程度,最后再给一份可以用代码验证的思路。无论你是刚开课的大学生、做面试准备的求职者,还是想把底层基础补扎实的开发者,都应该能从中捞到实打实的东西。
1. 第一章在整门课里的位置:先画一张大地图
很多人学第一章觉得痛苦,是因为不知道这一章在整个编译器构建流程里到底站在哪里。所以做题之前,我强烈建议你先花十分钟把编译器的整体骨架搞清楚。一个典型的编译器,从源码到目标代码,中间要经过这样几个阶段:词法分析、语法分析、语义分析、中间代码生成、代码优化、目标代码生成。第一章大部分内容都压在词法分析这一层,外加一些预备性的形式化知识,比如文法和自动机的基础概念。
1.1 为什么第一章是“劝退章”,也是“地基章”
词法分析是编译器对源代码做的第一件事:把字符序列变成有意义的 token 序列。比如你写int a = 42;,词法分析器需要把这个字符串切分成int、a、=、42、;这些词法单元。听起来简单,但问题在于,计算机并不知道“a”是一个标识符,“42”是一个数字常量,它需要一套规则去匹配。这套规则的形式化表达,就是正规式和状态机。
我见过不少同学,第一章习题能一路划水过去,结果学到第三章语法分析时彻底崩盘。因为语法分析器( parser )吃的输入就是词法分析产出的 token 流,如果你不懂 token 是怎么被识别出来的,后面“预测分析表怎么填”“LR 自动机怎么构造”全都是空中楼阁。第一章看起来不起眼,但它决定了你后面每个实验能不能顺利写出来。你把它当成“地基”来对待,后边的学习曲线会平缓很多。
1.2 第一章知识点在面试题里到底怎么考
搜索热词里有“编译原理面试题”,这也是我特别想聊的。很多人以为编译原理面试就是背概念,实际上,面试官最爱考的恰恰是第一章里那几个看起来最“简单”的点。
比如给你一个语言描述,让你写对应的正规式;或者给你一个正规式,让你画出 DFA;再或者让你手写一个极简的 tokenizer,用状态转移图表示。这些考点和课后习题几乎完全重叠。也就是说,如果你认真把第一章的题做透了,面试里那些“手写正则匹配”“解释 DFA 与 NFA 的区别”的送分题,你不但能答上来,还能答出实现细节,这是很加分的区分度。所以第一章的内容,对于立刻要面大厂实习的学生来说,并不是遥远的理论,而是性价比极高的一部分。
2. 先把第一章这堆概念彻底捋一遍
第一章涉及的概念看起来多,其实核心线索只有一条:如何描述一门语言的单词,并且让机器高效地识别它们。你只需要抓住“正规式 -> NFA -> DFA -> 最小化 DFA -> 词法分析器”这条主线,整章的习题会变得非常有套路。
2.1 正规式、正规集与正则定义:到底在表达什么
正规式( Regular Expression ,有的教材叫正则式、正则表达式)是一种描述单词结构的代数表示。它基于字母表,用三条基本运算来构成更复杂的语言:选择(|表示或)、连接(直接拼接)和闭包(*表示零次或多次出现)。一个正规式描述的语言叫做正规集。
举个例子,如果字母表是{a, b},正规式a|b描述的语言就是{a, b},正规式a*描述的语言是{ε, a, aa, aaa, ...},这里的ε表示空串。在这三个基本运算的基础上,我们还可以定义许多缩写,比如a+表示一次或多次,a?表示零次或一次,[a-z]表示 a 到 z 的任意一个字符。这些大家在写代码时都已经很熟悉了,编译原理教材里只是把它们从“工具的用法”提升到了“数学语言”的高度。
有一个地方特别容易出题,就是“用正规式描述某个语言”的构造性题目。这类题的难点不在于背语法,而在于精确理解语言描述的边界。比如“以 a 开头以 b 结尾的字符串”,很多人第一反应写a(a|b)*b,这很容易漏掉ab这种长度为 2 的字符串——但很明显ab是符合“以 a 开头以 b 结尾”的,而a(a|b)*b也能匹配ab(中间部分选零次)。真正容易漏的是ε和单字符串等边界情况,做题时把每种边界都列出来对照一遍,错误率会明显下降。
2.2 有穷自动机:一台状态机是怎么“消化”字符串的
有穷自动机( Finite Automaton , FA )是正规式的一个等价实现模型。它由状态集合、输入字母表、状态转移函数、初始状态和终态集合组成。你可以把它理解成一台“自动售货机”:你投入一个字符(硬币),机器根据当前状态和字符决定跳转到下一个状态(下一个内部状态),如果最后停在一个接受状态(出货),就说这个字符串被接受了。
有穷自动机分两种:确定的 DFA 和不确定的 NFA。DFA 的规则很严格:每个状态、每个输入符号对应的转移状态最多只有一个;NFA 则允许一个状态对应多个可能转移,甚至允许 ε 转移(不消耗字符就能跳转)。因为 NFA 有这种“同时尝试多个分支”的行为,所以更接近人类描述语言的直觉,但机器执行起来很麻烦;DFA 执行起来直接查表就行,效率高,但手工构造比较反直觉。于是就有了标准解法:先写出直觉的 NFA,再通过算法转成等价的 DFA。
2.3 词法分析器的工作流程:动手做题之前先看清 token 怎么来
词法分析器的任务刚才说了,是把字符流切分成 token 流。它的核心是一个循环:读入字符,交给一个识别器,识别器尝试匹配当前最长的合法 token,识别成功就输出 token 并回到开始状态,识别失败的场景则报错。
手工构造的词法分析器,本质上就是根据每种 token 的正规式构造出对应的状态转换图,然后把所有树状结构合并成一个大的状态机。这里有一个比较出名的点,实操中要小心:词法分析器通常采用“最长匹配”原则。举例来说,如果当前输入是>=,你不能在看到>之后就停止,因为>=是一个完整的大于等于运算符;必须再尝试多读一个字符,看看能不能匹配更长的 token。这也是习题里经常作为“陷阱”考察的地方,比如让你识别aabb时,如果状态机里有循环路径,有些马虎的同学会在aa之后就接受输出,结果破坏了 token 的完整性。
3. 课后习题最常见的四类题型与实战拆解
现在进入正题。我把第一章课后题里出现频率最高、也最能拉开分数差距的四类题型分别拆开讲。每一类我都给出可以“抄作业”的标准流程,然后配合具体例子手推一遍。你以后再做类似的题,基本可以照着这个流程往里面套。
3.1 题型一:根据语言描述构造正规式
第一类题型是“给语言描述,求正规式”,考察的是形式化表达能力。这种题看起来是在考语法,实际在考集合思维。你要把自然语言描述的条件,按照“结构拆解 + 边界检查”的步骤转成正规式。
我给你完整演示一道典型题:构造一个正规式,它描述的语言是“所有以a开头、以b结尾,且中间任意长度的由a和b构成的字符串”。
第一步,拆结构。目标串的整体形态是:开头一个a、中间任意段(可以为空)、结尾一个b。于是骨架是a + [中间任意串] + b。
第二步,描述中间任意串。它由a和b任意组成,可长可短,所以是(a|b)*。
第三步,拼起来:a(a|b)*b。
第四步,边界检查。这个正规式能匹配的最短串是ab(中间部分取零次),符合要求;再看是否可能匹配以a开头但不是以b结尾的串?因为表达式强制最后一个字符是b,所以不可能。再反方向想,它会不会漏掉语言内某个合法串?语言里任何一个以a开头、以b结尾的串,中间部分一定属于(a|b)*,所以不会漏。这就验证完毕。
这个四步流程(拆结构、写中间、拼骨架、验边界)几乎能覆盖所有“语言描述转正规式”的题目。做多了你会发现,这类题的易错点集中在三个地方:一是漏掉空串或单字符的边界;二是把“至少一个”和“零个或多个”搞混;三是选择运算符|的作用范围没有看清,导致优先级错误。所以在书写时,建议多用括号明确优先级,哪怕有些括号是多余的,也比之后写混乱要好得多。
3.2 题型二:由正规式构造 NFA,再转成 DFA
第二种题型更硬核,考察的是 Thompson 构造法和子集构造法。它的标准流程是先按正规式的语法结构递归地构造 NFA,然后用子集构造法把 NFA 确定化。
拿一个经典的正规式来演示:(a|b)*abb,这是很多教材里用来讲 DFCA 构造的经典例子,本质上描述的是“以abb结尾的由a和b组成的字符串”。第一步,为每个字符构造基础 NFA:a的 NFA 是两个状态加一条a转移,b同理。第二步,按运算顺序组合 NFA。a|b会新建一个开始状态和一个接受状态,从开始状态分别通过 ε 跳转到a子 NFA 和b子 NFA,它们各自的接受态再通过 ε 汇入新接受态。第三步处理*闭包,又新建开始和接受状态,把原来子 NFA 的首尾用 ε 连起来,并加上从接受态直接回开始态的 ε 路径,实现“可以重复、可以跳过”的语义。第四步再拼接abb的各个字符子 NFA。
这个 NFA 构造出来之后,状态会非常多,直接看容易头晕,但子集构造法是一个纯机械的过程。我们以 NFA 的初始状态集合为起点,不断看当前集合在a、b上能到达的 NFA 状态集合,每发现新集合就当成 DFA 的一个状态,直到不再产生新集合为止。
为了让你看得更清楚,我列一个子集构造法迭代过程的样例表。初始状态我先记为A:
| DFA状态 | NFA状态集合 | 输入a到达的集合 | 输入b到达的集合 |
|---|---|---|---|
| A | {0, 1, 2, 4, 7} | B | A? 这里要实际算 |
| B | ... | ... | ... |
实际操作中,这张表要反复更新好几轮才能结束。做完之后,你还要把 DFA 里所有含有 NFA 终态的状态标记为接受状态。比如abb子 NFA 的终态在某个集合里出现,那么那个 DFA 状态就是终态。这套流程虽然繁琐,但每一步都极其确定,不会给你发散的空间。只要耐心一点,几乎不可能错。
3.3 题型三:DFA 最小化(等价状态划分)
DFA 的最小化是第一章的“分水岭”题目,做对了说明你真的理解了自动机的本质。最小化的目的是把 DFA 中行为完全一致的状态合并,得到一个状态数最少但识别能力完全相同的 DFA。这里的行为一致,是指从某个状态出发读入任意字符串,最终是否到达接受状态的结果都一样。这两个状态就是不可区分的,可以合并。
最小化的经典算法叫“划分法”,流程是这样的:先把状态集划分为两个集合——终态集合和非终态集合。这是第 0 轮划分,因为终态和非终态对空串的行为就不同。然后反复检查每个集合中的状态,看它们在某个输入符号下的转移是否落入同一个“当前划分块”。如果两个状态读入a后的目标状态在同一个块里,读入b后的目标状态也在同一个块里,那它们暂时还不可区分,继续留在同一集合;否则就分裂成更细的集合。不断重复直到划分不再变化,最后每个划分块合并成一个状态。
我举个具体例子。假设一个 DFA 有五个状态{q0, q1, q2, q3, q4},其中q4是终态,其余是非终态。第 0 轮划分就是{q0, q1, q2, q3}和{q4}。接下来逐个检查。假设q0在输入a时到达q4,而q1在输入a时到达q2。因为q4和q2处于不同的划分块,这说明q0和q1之间存在某个输入串(在这里是a)能把它们区分开,所以它们必须分离。这轮下来,原来的非终态集合很可能又裂成多个小组。当某轮划分结果和上一轮完全一致,算法结束,然后把每组各选一个代表状态重建 DFA。
很多人做最小化出错,是因为一开始忘记把“终态”和“非终态”分开。这一步是整个算法的初始条件,没有这一步,后续全部白做。另一个容易忽略的是,缺失的转移要单独处理,不能随意合并到某个状态。有些教材里会引入一个“死状态”来统一表示没有转移的情况,这也是可以的,但要注意在划分时保持一致。
3.4 题型四:手工设计词法分析器(状态转换图)
第四类题稍微综合一点,一般不让你写完整代码,而是让你画出识别某组 token 的状态转换图,或者写出对应的转移表。这类题的关键是要学会“整合”:多个 token 的正规式对应多个子图,你要把它们合并成一个完整的自动机,同时处理冲突和最长匹配。
比如要求识别标识符(以字母开头的字母数字串)、整数(由数字组成)和关键字if。标识符的正规式可以写成letter(letter|digit)*,整数的正规式是digit+,关键字if其实就是标识符的一个特殊值。这里如果只画出三个独立的状态图,那是做不到题目的隐含要求的——题目大概率会要求你把它们合成一个识别器。
合并的思路是:公共前缀共享状态。if的开头和标识符的开头都是字母,所以应该从同一个“字母”状态出发。识别到i之后,如果下一个字符是f,还要再看后面的字符是什么:如果f之后是字母或数字,那么根据最长匹配原则,这个 token 应该识别为标识符if123,而不是关键字if;只有当f之后不是字母或数字时,if才能被识别为关键字。这个细节在手工状态图里要画得非常明确,否则换来的就是运行时的错误判断。
画状态图时,注意几点:每个状态标好是否是接受状态;接受状态上要注明识别出的 token 类型;如果同一个状态既是接受状态,又还有后续转移,通常要优先尝试更长的匹配。这个“既要又要”的处理,正好对应了后面 lex / flex 工具里max munch规则。习题里不怎么要求你写代码,但能把状态图画明白,就说明你已经具备了实现一个简易 tokenizer 的能力。
4. 做题时最常见的五个翻车点
做第一章习题的时候,我见过也踩过很多坑。有些问题看起来是粗心,实际上是理解有偏差。我挑最常见的五个翻车点集中说一次,因为这些问题只要提前规避,正确率能提升一大截。
第一个翻车点是“正规式优先级理解错误”。正规式里*的优先级最高,连接次之,|最低。很多人写a|b*,脑子里想的是(a|b)*,但按照正规式语法规则它其实是a|(b*)。这就是完全不同的两个语言。做题时建议立刻用括号把每层运算框清楚,不要玩“我觉得这个优先级应该是这样”的游戏。
第二个翻车点是“NFA 转 DFA 时漏掉 ε 闭包”。子集构造法里,每次计算一个 NFA 状态集合的转移,第一步永远是算目标状态的 ε 闭包,然后才能进入新集合。我看到太多作业里只写了直接跳转的下一个状态,把 ε 边全部忽视了,结果 DFA 少了一堆状态,识别的语言也跟着错了。解决方法是每次集合变化后,先手动做一遍 ε 闭包运算,形成肌肉记忆。
第三个翻车点是“最小化 DFA 时忘记重新命名和画转移边”。划分完状态只是最小化的前半段,后半段你要把所有涉及被合并状态的转移边全部调整到代表状态上,然后删掉被合并的旧状态。有个常见错误是只顾着画新状态之间的转移,却漏掉了从开始状态到某些新状态的路径,导致最终图不完整。
第四个翻车点是“直接把正则表达式引擎的规则套到 DFA 上”。写代码的人特别习惯正则表达式里的“贪婪匹配”“回溯”等概念,但 DFA 不存在回溯。DFA 对输入串只扫描一遍,每个字符至多驱动一次状态转移,所以识别效率是稳定的线性时间。课后题里如果遇到“为什么用 DFA 处理词法更快”这类简答,重点要答“无回溯、一次扫描、查表转移”,而不是扯正则引擎的优化细节。
第五个翻车点是“混淆空串 ε 和 NULL 字符”。在正规式语言里,ε是一个合法的、长度为零的字符串;但你在写程序时不能直接把ε当成输入字符去读。有些选择题会在这个地方绕弯子,比如问“空串是否被某个正规式接受”,这时候你要看的是这个正规式能不能通过零次闭包跳转到接受状态,而不是纠结输入文件中是否存在空行。搞清楚这个区分,很多概念题就顺手解决了。
5. 把“答案”变成自己的:从做题走向实验和面试
如果你已经能看到这里,说明你不只是想要一份现成答案,而是真想把这一章学明白。那我要多说一句:第一章的课后习题,本质上是在为你后面的词法分析实验做预演。很多学校的课程设计都会要求用某种工具写一个词法分析器,而实验的核心,就是把你在这章练熟的那些状态机知识直接编码。
5.1 用几十行代码验证你的 DFA 推导
为了让你对 DFA 的理解更有体感,我给你一段非常小的 Python 示例,它实现了一个识别(a|b)*abb的最小 DFA。你可以在自己电脑上跑一下,把推导题里得到的状态转移表和这段代码一一对应起来。
# 一个简单 DFA,识别以 abb 结尾的由 a/b 组成的字符串 # 状态 0 是开始状态,状态 3 是接受状态 transitions = { (0, 'a'): 1, (0, 'b'): 0, (1, 'a'): 1, (1, 'b'): 2, (2, 'a'): 1, (2, 'b'): 3, (3, 'a'): 1, (3, 'b'): 0, } accepting = {3} def match(s: str) -> bool: state = 0 for ch in s: state = transitions.get((state, ch)) if state is None: return False return state in accepting # 测试 for t in ["abb", "aabb", "ababb", "ab", "bbabb", "abba"]: print(t, "->", match(t))跑出来你会发现,识别abb的过程完全是一条直线:状态 0 -> 1 -> 2 -> 3。而识别aabb时,前面的aa会把状态压到 1,再读入b走到 2,再读入b才到 3。这种“一条状态表打天下”的感觉,和你手工推导时画的状态转移表完全吻合。这种验证方法我很推荐:如果你推导出来的 DFA 最小化结果跟代码里的状态数不一致,那一定是你中间的某一步出了问题,排查起来非常方便。
5.2 词法分析实验的常见坑:预读字符和最长匹配
以后你做词法分析实验时,第一个容易卡住的点就是“预读字符”。手工构造词法分析器常需要向前看一个字符,比如上面说的>=和>的问题。如果你用编译工具,比如 flex,它会自动处理最长匹配;如果你要手写 tokenizer,就得自己在读入下一个字符后,判断当前是否应该接受还是继续。
我自己的经验是,手写时用一个peek()函数来返回下一个字符但不消费它,然后根据 peek 的结果决定是接受当前 token 还是再读一个。这种“前看一个字符”的思路,在实现运算符和关键字识别时特别好用。很多同学的词法分析器出现问题,往往不是状态图画错,而是对预读字符的处理不够干净,导致边界条件整个乱掉。
5.3 那些不考实验的人,如何用这一章准备面试
如果你不写实验,直接面对面试题,我的建议是至少要能在一张白纸上快速写出一个小语言的正规式,并画出对应的 DFA。面试官不一定真的要你写出完美代码,但很可能会问:“你来设计一下,怎么识别一个十六进制整数?”这种题就是第一章课后题的活学活用:你要能说出十六进制整数以0x开头,然后接若干个十六进制数字,其中数字部分用(0|1|...|9|a|b|c|d|e|f)表示,最后还可能涉及字母大小写问题。
再深一点,面试官可能会延伸到“DFA 和 NFA 的区别,以及为什么词法分析用 DFA 更好”。这时候你如果能答出“NFA 构造直观但可能有多个活动状态,执行时需要维护状态集合;DFA 在任何时刻只有一个活动状态,查表即可,工程实现简单且时间复杂度为 O(n)”,基本上就能拿到这题的分数。再往后,如果面试官问“有没有办法把正则表达式直接编译成 DFA”,你就可以把 Thompson 构造法、子集构造法和最小化串成一条线讲出来——这就是整个第一章的核心技术主线。
6. 做习题时最该有的“错题本”:几条亲测有效的复****惯
最后分享一点个人经验。我在学编译原理的时候,题目做得不算多,但每一道都做得很慢,尤其是画状态机和做最小化,我会把每一步的中间结果都写在纸上,哪怕只是一个小例子。后来我发现,长期这样做最大的收获不是记住了答案,而是大脑对“状态”这件事建立了直觉——你一看到一段需要被识别的模式,脑子里大概能浮现出一串状态在跳动的画面,这种感觉是刷多少题都换不来的。
另外,我强烈建议把“构造正规式”的题目和“构造 DFA”的题目对照着做。同一个语言,先用正规式写一遍,再画状态机,再最小化,再拿代码跑一遍。这个过程做上三五道,你就能明显感觉到,原本割裂的概念开始连成一张网。等你做语法分析章节遇到 FIRST 集、FOLLOW 集那堆更繁琐的计算时,这种“先理解再算”的习惯会让你省下大量内力。
第一章真正难的地方,不是题目本身,而是思维方式的切换。从“写代码的直觉”切换到“状态机的视角”,中间需要一点顿悟,而课后习题就是促成顿悟最好的练习场。希望你今晚合上书的时候,心里冒出来的不是“我好多题不会”,而是“原来词法分析的底子是这样打的”。把这章啃透,后面你会谢它的。