简介:西安交通大学2022年《编译原理》作业考核试题以选择题形式覆盖文法推导、算符优先关系、程序基本块、LR(0)分析表、Chomsky文法分类、符号表与中间代码生成等核心考点,面向高校计算机专业学生和考研/期末备考者,用于快速检验编译原理基础掌握程度。资料内含1个docx文档,压缩包仅13KB,轻量易用;题目对关键概念做了系统梳理,覆盖无二义文法、三元式、下推自动机、语义规则及静态分派等易混淆点,并涉及Pascal语言特性等细节,便于读者在刷题同时对照理解。题目按知识点归类,适合快速定位薄弱环节,可配合教材深入复习。已有214人学习下载,适合课后自测、考前冲刺和编译原理课堂伴学使用。
1. 拿到一份2022年西安交大编译原理考核题时,先别急着刷答案
2022年西安交通大学编译原理作业考核试题这类 docx,我在复习阶段最常把它当“考点密度图”用。它看起来是一套作业题,实际价值是帮你把词法分析、语法分析、语义分析、中间代码生成这些章节压缩成几张需要动手推导的卷子。对正在修编译原理、准备期末补考或考研复试的人来说,这套题能直接暴露“课听懂了但做题不会”的缺口。相比一章章翻教材,先把题目过一遍,再决定接下来三天复习哪里,效率要高得多。下面我把对着这类考核题做系统复习、把大题改成可运行实验、以及避坑的思路完整讲一遍。
2. 把考卷拆成考点地图:先看题面再定复习顺序,比直接背书高效
2.1 从卷面题型反推编译原理课程的重点模块
编译原理的期末考核题经过多年沉淀,题型已经比较固定。见到一份往年题,第一件事不是拿笔就做,而是把每一道题对应到课程知识点,形成一个“考点地图”。常见题型和模块对照关系大致如下。
| 题面特征 | 对应模块 | 最容易丢分的位置 |
|---|---|---|
| 给正则表达式构造 NFA/DFA | 词法分析 | epsilon 闭包、子集构造漏状态 |
| 计算 FIRST/FOLLOW 集合 | 语法分析 | 可空非终结符的传导 |
| 构造 LL(1) 预测分析表 | 语法分析 | 同一格子多条产生式未发现 |
| 求 LR 项目集、构造分析表 | 语法分析 | 闭包少算、GOTO 转移对不上 |
| 给语句生成四元式/三元式 | 语义分析 | 临时变量编号顺序 |
| 划分基本块、画 DAG | 代码优化 | 入口语句判断错误 |
拿 2022 年西安交通大学编译原理作业考核试题这类卷子做模板时,我会先按这个表格在题号旁边做标记。比如“第一题正则转 DFA”对应第三行,“第三题 LL(1)”对应第四行。标记完就能看清这份卷子更侧重语法分析还是代码生成。
做完这一步,你会发现自己对某些模块完全没有题感。我见过不少同学第一轮复习一章一章看,看到中间代码生成时已经忘了词法分析。但考核题是综合性的,前一道题的正则表达式可能直接成为后面词法分析器代码的基础。所以正确的做法是先看题面,再确定复习顺序。
2.2 按“词法→语法→语义→中间代码”的重叠顺序复习
《编译原理》教材一般按章节顺序从词法分析讲到代码优化。但考核题不是按章节出题的,它会把一个完整编译过程拆成多道相互关联的小题。比如词法题可能让你写一个识别标识符的正则表达式,语法题再让你基于这个正则构造语法树,语义题要求你生成中间代码。这种“重叠”特征决定了复习顺序必须按编译流水线走。
我一般建议的复习节奏是:词法分析 1 天,语法分析 3 天,语义分析 2 天,代码生成和优化 2 天,共 8 天。每天保证 2 小时有效推导时间,不是“看答案”而是“掩卷重算”。具体安排可以是:
| 天次 | 复习内容 | 可复现动作 |
|---|---|---|
| 第 1 天 | 正则表达式、NFA、DFA、最小化 | 把往年题里的 3 个正则全部转成 DFA |
| 第 2 天 | FIRST/FOLLOW、LL(1) 表 | 选 2 个典型文法手算 |
| 第 3 天 | LR(0)/SLR(1) 项目集 | 手画状态转移图 |
| 第 4 天 | 递归下降分析与错误恢复 | 用 Java 写一个小解析器 |
| 第 5 天 | 语法制导翻译、四元式 | 把句子逐句翻译成中间代码 |
| 第 6 天 | 符号表、活动记录 | 画运行时栈 |
| 第 7 天 | 基本块、DAG、优化 | 给一段三地址码划分基本块 |
| 第 8 天 | 综合模拟 | 完整做一份往年题并判分 |
注意第 4 天的“用 Java 写小解析器”很重要。很多人把“编译原理实验”和“作业考核”割裂开,实际上考核题里很多结论,比如预测分析表对不对、四元式顺序对不对,写一个程序跑一遍立刻见分晓。这也是为什么后面我会专门讲如何用 Java 把考核大题变成可运行项目。
这里要强调:复习顺序不是单向的。做语法分析时如果发现词法的状态转移不会画,要回头补词法,不要硬撑。练习时允许“回跳”,但最后必须能在不看答案的情况下把整个流水线走通。
2.3 教材答案只能辅助,真正要建立的是自行推导能力
很多人在复习时会去搜“编译原理清华大学出版社第三版第二章答案”。说实话,我之前也干过这事,但后来发现纯对答案的效果极差。第二章讲词法分析和有限自动机,答案通常只给出最终状态表,不展示 epsilon 闭包怎么传导、子集构造时为什么某个状态会归并。如果你只记答案,换一道题立即不会做。
我见过的血泪经验是:辅导书答案可以用于“对答案”,但不能替代“讲题给自己听”。所谓自行推导,就是拿到一个正则表达式后,不翻任何资料,从画 NFA 开始,标状态编号,列转移矩阵,再手工做子集构造。做完后可以和答案对比,但对比时要把中间每一步状态集合都复现一遍,找出差异发生在哪个环节。比如你很可能会发现(a|b)*abb转 DFA 时,初态的闭包不是{0,1,2}而是{0,1,2,4,7}。这些细节只有手算才会暴露。
我也建议把教材中的经典正则表达式和文法当作“训练集”。即使不是同一本教材,这些训练集仍然有效。但要注意教材版本之间对 FIRST/FOLLOW 集合的定义存在细微差别。比如有的教材不把$加入 FIRST 集,有的则把#当输入结束符。做往年题时,要看清题目使用的是哪种约定,否则会得出不同的答案。
3. 按题型逐个复现:正则转NFA、LL(1)分析表和LR分析器的手工推导
3.1 词法题:把正则表达式转成NFA再转DFA的固定套路
词法分析题几乎必考“正则转 NFA、NFA 转 DFA、DFA 最小化”。题目一般会给你一个不超过 3 个运算符的正则表达式,例如(a|b)*abb。这个表达式是经典考点,很多学校的作业考核题都会换汤不换药。
我做这类题的固定套路分四步。
第一步,画 NFA。每个字符和每个运算符都有固定结构。并运算a|b引入两个空转移分支;闭包*引入回边;连接则直接串联。我建议从右向左拆解:abb是三个连接,(a|b)*是一个可重复并运算。画完后给每个状态编号,并用ε标出所有空转移。
第二步,列出转移关系。这一步最容易出错,我习惯用一张表记录state + 输入符号 -> 状态集合,避免看图时漏边。例如:
| 当前状态 | 输入 a | 输入 b | 输入 ε |
|---|---|---|---|
| 0 | ∅ | ∅ | {1,4} |
| 1 | {2} | ∅ | ∅ |
| 2 | ∅ | {3} | ∅ |
| 3 | ∅ | ∅ | {6} |
| 4 | {5} | ∅ | {7} |
第三步,求 epsilon 闭包。闭包的计算方法是把所有空转移能到达的状态全部收进来。代码实现可以很轻量:
def epsilon_closure(states, epsilon_trans): stack = list(states) closure = set(states) while stack: s = stack.pop() for t in epsilon_trans.get(s, []): if t not in closure: closure.add(t) stack.append(t) return frozenset(closure)这个函数里epsilon_trans是字典,键为状态,值为该状态通过空转移到达的状态集合。参数states是初始集合。它的逻辑很朴素:用一个栈展开所有可达状态,直到不再产生新状态。这样做的好处是手工推导时可以模仿同样的顺序,不至于漏掉远处隔着两步的空转移。
第四步,用子集构造法得到 DFA。从初始状态的闭包出发,逐个输入字符求移进后的闭包,把新集合编号为 DFA 状态,直到没有新集合。这个过程中要特别注意:一个 DFA 状态集合里只要包含某个 NFA 接受状态,这个 DFA 状态就是接受状态。手工算完后再用程序校验,能节省大量核对时间。
3.2 语法题:手工构造LL(1)预测分析表的关键步骤
语法分析题通常给一个上下文无关文法,要求消除左递归、提取左因子,然后构造 LL(1) 预测分析表。去年那份 2022 年西安交通大学编译原理作业考核试题里,这类题往往分值最高,也是区分度最大的题目。
构造 LL(1) 表的流程是固定的。先消除左递归。常见做法是把直接左递归A -> Aα | β改写为A -> βA'和A' -> αA' | ε。比如表达式文法E -> E + T | T改写后变成E -> T E',E' -> + T E' | ε。改写后别忘了提取左因子,否则后面填表会冲突。
下一步计算 FIRST 集合。计算时有一个隐蔽的传导关系:如果X -> Y Z且Y可以推出空串,那么Z的 FIRST 集合也要加入X的 FIRST 集合。漏掉这个传导正是很多人拿不到分的原因。我习惯用一张表记录“可以推出空串的非终结符”清单,然后逐个代入。
计算 FOLLOW 集合时,规则里有两条最关键:如果产生式右侧某个非终结符后面有终结符,把这个终结符加进该非终结符的 FOLLOW;如果后面没有终结符或后面是非终结符且可空,则把产生式左侧的 FOLLOW 加进去。这里的“可空”是个连锁条件,需要反复迭代直到集合不再变大。
最后填预测分析表。行是非终结符,列是终结符和$。对每个产生式A -> α,如果终结符a属于FIRST(α),则把产生式填入A行a列;如果α能推出空串,则对FOLLOW(A)中的每个终结符填入该产生式。填完后必须检查同一个格子是否有多条产生式。一旦出现,说明文法不是 LL(1)。
| 非终结符 | $ | a | b | c |
|---|---|---|---|---|
| S | S -> ε | S -> aS | S -> b | 报错 |
| A | ... | ... | ... | ... |
上表只示意结构,实际做题时每个格子的产生式必须来自手算。如果你填出来某个格子有两条产生式,不要硬删,要回到 FIRST/FOLLOW 集合找错误,通常是可空传导漏掉了。
3.3 句法树与移进-归约:LR题先画状态机再写表
LR 类题目比 LL(1) 更机械,但计算量巨大。常见考法是给一个无二义文法,让你构造 LR(0) 项目集规范族,再判断是否为 SLR(1)。判断标准是:每个项目集内不存在“移进-归约冲突”或“归约-归约冲突”,或者冲突能通过 FOLLOW 集合解决。
我画项目集的顺序是:先写增广文法,S' -> S,然后从初始项目S' -> .S开始求闭包。闭包规则很简单:如果项目形如A -> α . B β,并且点号后面是非终结符,就把所有B -> .γ加进来。加的时候要小心,B的产生式右部如果又以非终结符开头,还要继续展开,形成递归闭包。
举个例子,文法S -> aS | b的初始项目集包含S' -> .S、S -> .aS、S -> .b。这个集合看上去简单,但遇到S -> .aS时点号后是终结符a,不需要展开;遇到S -> .b同理。如果文法里有S -> A,A -> B,就要一路展开到底。
得到项目集后,用 GOTO 函数连接状态。每个状态i遇到符号X转移到的状态,是“点号越过X后所得项目”的闭包。把状态转移图画出来后,SLR 分析表就是查图填表。动作表用终结符列,GOTO 表用非终结符列。归约动作只填写在FOLLOW(A)对应的终结符位置。
我经常在 LR 题上翻车,因为状态一多,闭包很容易算漏一个项目。后来发现一个好习惯:每求完一个项目集,立刻核对项目数量。初始闭包至少包含增广文法的初始项目,如果某个非终结符有多个产生式,每个产生式的点后项目都必须出现。核对数量能提前发现算漏。
4. 把考核大题改成可运行的小项目:用Java复现词法分析和递归下降
4.1 为什么是Java:课程设计常用语言与题目判读方式
“Java + 编译原理”是很多学校课程设计里的常见搭配。选 Java 不是因为它最适合写编译器,而是因为它强类型、类库丰富、IDE 调试方便。写状态转移表、递归下降解析器时,Java 的类型检查能帮你提前发现“状态列越界”“未处理空字符”这类低级错误。
更重要的是,考核题和实验往往能联动。试卷里要求手工构造的 DFA,可以直接变成一个二维数组;要求计算的 FIRST/FOLLOW 集,可以写成几个 HashSet;要求生成的四元式,可以用一个对象列表保存。把大题变成代码,本质上是把手算答案变成可执行验证。
我一般会引导学生用“最小可运行项目”思路:不追求完整优化编译器,只针对试卷中出现的语法写一个 100 行左右的分析程序。输入是一个字符串或小文件,输出是 token 序列、分析树或中间代码。这一套程序写完后,试卷上的大部分结果都能自动校验。
4.2 最小词法分析器:状态转移表驱动的实现
词法分析器最稳妥的实现方式,是把手工推导得到的 DFA 状态转移表直接写进代码。比如识别“标识符和数字”的最小 DFA,状态 0 是初态,遇到字母转到状态 1,遇到数字转到状态 2,状态 1 和状态 2 是接受态。
// 状态转移表:行是状态,列是字符类别:0=字母,1=数字,2=其他 // -1 表示报错 int[][] dfa = { { 1, 2, -1 }, // 状态0:初态 { 1, 1, 3 }, // 状态1:标识符中 { 2, 2, 3 }, // 状态2:数字中 { -1, -1, -1 } // 状态3:分隔符/结束 };这段代码中dfa[state][col]表示当前状态遇到某类字符后转移到哪个状态。状态 1 遇到数字仍然留在状态 1,表示标识符可以包含数字;状态 2 遇到字母也留在状态 2,表示数字后跟字母在简单词法规则里会报错。实际做作业时,这张表必须和你试卷上手工构造的 DFA 一一对应。
驱动词法分析的循环也很短:
int state = 0; StringBuilder lexeme = new StringBuilder(); while (state != -1 && state != 3) { char c = nextChar(); lexeme.append(c); int col = (Character.isLetter(c) ? 0 : Character.isDigit(c) ? 1 : 2); state = dfa[state][col]; } if (state == 3) { System.out.println("识别到: " + lexeme.substring(0, lexeme.length() - 1)); }这里有个隐藏问题:循环把多读的字符放进lexeme,回退时要去掉最后一个字符。这也是词法分析器最常见的坑之一。建议在nextChar()里维护一个pushback字符缓冲区,读到分隔符时先压回去,再结束当前 token。
4.3 递归下降子程序:用试卷文法直接驱动
语法分析部分,如果试卷给出的是 LL(1) 文法,递归下降程序是最容易对照答案的写法。它的核心规律是:每个非终结符对应一个方法,每个方法按产生式右侧展开,遇到终结符就匹配,遇到非终结符就调用对应方法。
// 文法:expr -> term ((+|-) term)* // lookahead 是当前 token void expr() { term(); while (lookahead.type == Token.PLUS || lookahead.type == Token.MINUS) { match(lookahead.type); term(); } }写这个方法前,必须先确认文法已经消除左递归。如果直接用原始文法expr -> expr + term写递归下降,会出现无限递归:expr()第一行调用expr(),栈溢出。试卷上让你“消除左递归”的考点,在实验代码里就是这一处关键选择。
match方法负责消费一个终结符并更新lookahead:
void match(int expectedType) { if (lookahead.type != expectedType) { throw new RuntimeException("语法错误,期望 " + expectedType + ",实际 " + lookahead.type); } lookahead = lexer.nextToken(); }这段代码体现了语法错误处理的原理:当预测分析表要求某个终结符,而输入 token 不匹配时,分析器必须报错并进入错误恢复。作业考核题偶尔会在错误处理上设问,比如“当输入出现非法符号时,递归下降分析器如何跳过?”你可以在match的 catch 分支里实现 panic 模式:丢弃 token,直到遇到同步标记。
4.4 实验与考核联动:如何用输出验证答案
写完词法分析器和递归下降分析器后,最直接的价值是拿它校验试卷里的手工答案。比如试卷让你对句子a + b * c构造分析树,你可以写个小程序让expr()在每次归约时打印动作序列:
void term() { factor(); while (lookahead.type == Token.MUL || lookahead.type == Token.DIV) { System.out.println("匹配运算符: " + lookahead.lexeme); match(lookahead.type); factor(); } }输出的匹配顺序就是最左推导的顺序。你把手写分析树的遍历顺序和程序输出对比,很容易找到语法分析步骤里的错误。类似地,四元式生成实验可以定义Quadruple类,保存运算符、两个操作数和一个结果,每次归约时生成一行四元式,最后输出整个列表。这份列表可以直接和试卷答案逐行对照。
5. 做题踩坑排查:从FIRST/FOLLOW到语义动作的5个高频错因
5.1 现象:FIRST集漏掉可空非终结符的传导路径
做题时经常出现“我算的 FIRST(A) 比答案少一个终结符”的情况。比如文法中有A -> B,而B -> ε,那么FIRST(A)必须包含FIRST(B)中除ε之外的所有符号,同时也要加入ε。但我经常看到有人算到B -> ε后直接停下,没有把ε的“可空”属性继续向上传递。
原因是对“可空非终结符”的定义理解不完整:只要一个非终结符存在某个产生式右部全为空或全为可空非终结符,它就是可空的。这个定义会连锁。解决方法是每次给可空非终结符列表增加一个新成员后,重新扫描所有产生式,直到列表不再增长。手算时用铅笔在产生式上方标记✓空,每标记一个就重新检查一遍全部产生式。
5.2 现象:LL(1)分析表同一个格子出现两条产生式,却还在继续填
有的同学填完 LL(1) 表后发现A行和终结符c列同时有A -> c和A -> ε两条产生式,第一反应是“找哪个答案对”,其实这已经说明文法不是 LL(1)。这里的误区是总想通过删掉一条产生式来强行得到一张表。
原因通常是 FIRST 集合计算错误,或者提取左因子不彻底。比如S -> aB | aC没有提取左因子,那么S行a列必然冲突。解决方法是回到文法层面提取左因子:S -> a (B | C),如果 B 和 C 有公共前缀继续提取。注意,真正的 LL(1) 文法在每张表里只允许一个产生式,这不是“参考答案不一致”,而是“文法不满足 LL(1) 条件”。
5.3 现象:LR项目集闭包算不全,状态数和参考答案对不上
构造 LR 项目集时,最容易出现的现象是两个同学算出来的状态数量不同,差一两个状态。问题常常出在初始闭包的递归展开上。比如产生式A -> B C,项目A -> .B C会把B -> .γ加进闭包,而B -> .γ如果右侧以D开头,又要把D -> .δ加进来,这层递归容易停得太早。
解决方法是把闭包计算拆成“待展开项目队列”。先放入初始项目,每次从队列取出一个项目,如果点号后是非终结符,就把该非终结符所有产生式的初始项目加入队列。凡是已经在闭包里的项目不要重复加入,用一个集合记录。这个过程模拟了程序里的队列展开,手算时在纸上用铅笔划掉已展开的项目,直到队列为空。
5.4 现象:语法制导翻译的语义动作写在右边,归约时临时变量顺序乱掉
中间代码生成题常给一个带语义动作的产生式,比如E -> E1 + E2 { E.place = newTemp(); emit(E.place, E1.place, '+', E2.place); }。很多人在手写四元式时,会把newTemp()的编号搞混,生成的中间代码出现先使用后赋值。
原因是对“语法制导翻译”的求值时机理解错位:语义动作在产生式右部所有符号处理完后才执行,不是从左到右一边读一边执行。如果右侧有多个非终结符,它们的place属性必须先算好。解决方法是把动作写成“先取子结点的 place,再申请新临时变量,再 emit”。每次 emit 后立刻给临时变量编号加一。四元式编号从 100 开始还是从 1 开始,以试卷要求为准,但我建议在每行四元式前写序号,避免调整顺序时看错。
5.5 现象:写代码时把终结符当作非终结符处理,递归不终止
在写递归下降解析器时,我见过最典型的翻车是:把match方法内部调用lexer.nextToken(),却在expr方法里忘了在入口先读取第一个 token,导致lookahead为 null,运行时直接空指针。这个现象表面上是空指针,根因是终结符和非终结符的处理没有分层。
解决方法是把“读取下一个 token”的职责限定在match方法内。非终结符方法只负责决策和调用匹配,终结符匹配由match统一处理。在parse入口处先调用lexer.nextToken()初始化lookahead,然后调用根非终结符方法。这样整个递归过程的边界就清晰了。类似的,递归终止条件要落在“当前 token 是否属于某个 FOLLOW 集合”上,避免无限循环。写实验代码前,先把试卷上的 FIRST/FOLLOW 集合表放在编辑器旁边,代码里每个 while 循环的退出条件都应该是某个终结符不在 FIRST 集合里。
6. 用往年题做一次模拟考核:时间分配、判分自检与错题卡
做完上述复习后,真正的检验是按时模拟。我建议选一份 2022 年西安交通大学编译原理作业考核试题这类完整卷子,预留两小时整块时间,关闭所有教材和笔记,按以下顺序作答。
先做词法分析题,大约 20 分钟;再做语法分析题,40 分钟;语义分析和中间代码题 30 分钟;最后留 30 分钟做代码生成与优化题。剩下 10 分钟检查 FIRST/FOLLOW 集合和状态编号。如果某个题超过 15 分钟还没动笔,先跳过,拿确定能拿的分。
判分时不要只看最终答案。我习惯用一张自检表:每题检查“过程是否存在”“状态集合是否完整”“产生式是否对应表格位置”三项。只要过程清晰,即使最终状态编号差一位,也能得到大部分分数。模拟后把错题按“计算性错误”和“概念性错误”分类。计算性错误重算一遍即可,概念性错误必须回到教材对应章节重读。我个人的教训是:考前最怕的不是不会,而是会一半,比如 FIRST 集合算对了但 FOLLOW 集合里没加$。每次模拟后我都要把这类“半错”单独记录,考前再看一遍。希望这份思路能帮你在备考中少走弯路,把精力真正花在最容易提分的地方。
本文还有配套的精品资源,点击获取