☰
LL(1)语法分析器设计与实现:从预测分析表到C++完整实验报告
2026/10/18 4:03:37 网站建设 项目流程

简介:这是一份面向编译原理课程实验的完整文档,围绕算术表达式简化子集的语法分析程序设计与实现展开。文档以LL(1)分析法为例,先改写文法消除左递归,再构建预测分析表和相应的C++源程序,给出分析栈、剩余串的逐步输出过程,并结合实验一的词法分析结果完成语法检查与结构分析;同时包含实验目的、基本实验内容与要求、实验步骤、问题分析、源程序、结论等完整模块。资源包内共1个doc文档,约127KB,内容精练,方便直接参考或按需修改。目前已有143人学习下载,适合计算机专业学生、实验报告撰写者以及需要快速理解LL(1)分析器实现思路的读者,有助于厘清预测分析表中的冲突处理、栈操作逻辑及递归下降等常见语法分析方法的程序落地细节。

1. 语法分析程序设计与实现:一份能跑通 LL(1) 的实验报告

编译原理的实验课里,语法分析器往往是第一道坎,网上流传的版本要么只有残缺代码,要么报告与代码对不上。这份实验二文档倒是罕见地完整——它把 LL(1) 分析法从文法改造、预测分析表构造到 C++ 源程序实现全链路串了起来,针对算术表达式的四则运算,最终能输出每一步的分析栈、剩余串和所用产生式,对合法输入打印 RIGHT,对非法输入给出 ERROR 及出错位置。无论你是正在做课设的学生,还是想快速理解 LL(1) 预测分析器内部机制的开发者,这份带完整源码的实验报告都值得拉下来跑一遍。

2. 文法改造与预测分析表:LL(1) 分析的第一步

2.1 为什么原始文法不能直接用于 LL(1)

实验给出的原始 BNF 是经典的算术表达式定义:

E → T | E+T | E-T T → F | T*F | T/F F → i | (E)

这里的 E 和 T 都含有左递归——E → E+T 中,E 的推导直接以自身开头。LL(1) 分析要求每个非终结符在面临同一终结符时只能有一条可选产生式,而左递归会让预测分析表同一格子里出现多条产生式,根本无法确定选哪条。这就是为什么实验里要把文法改写成 LL(1) 形式:

E → T G G → + T G | - T G | ε T → F S S → * F S | / F S | ε F → ( E ) | i

改写思路是标准的左递归消除:把 E → E+T | E-T 拆成"第一个操作数 T + 尾部 G",G 负责递归地吃掉所有 + 和 - 开头的后缀;T 和 S 同理。改写后的文法每个产生式右部都以终结符开头,且同一非终结符的不同产生式首符集合互不相交,这才满足 LL(1) 的判定条件。

2.2 预测分析表的填充逻辑

实验源码里的C[10][10]就是预测分析表,行是非终结符(E、G、T、S、F),列是终结符(i、+、-、*、/、(、)、#),每个格子存放一条产生式。填充规则来自 FIRST 集和 FOLLOW 集:对产生式 A → α,把 α 的 FIRST 集中每个终结符对应的格子填入这条产生式;如果 α 能推导出 ε,则把 A 的 FOLLOW 集中每个终结符对应的格子也填入这条产生式。

对照源码里的填充代码:

C[0][0] = e; // E → TG 填入 E 行 i 列 C[0][5] = e; // E → TG 填入 E 行 ( 列 C[1][1] = g; // G → +TG 填入 G 行 + 列 C[1][2] = g1; // G → -TG 填入 G 行 - 列 C[1][6] = g2; // G → ε 填入 G 行 ) 列 C[1][7] = g2; // G → ε 填入 G 行 # 列

这里g2对应的产生式右部是^,表示空串 ε。G 行的)列和#列都填 ε,正是因为)和#在 G 的 FOLLOW 集中——当 G 后面没有可匹配的 + 或 - 时,直接选择空产生式,把分析权交还给上层。这个细节也是 LL(1) 分析和递归下降最本质的区别:预测分析表把"何时选择空产生式"固化成了查表动作。

2.3 数据结构设计:结构体数组与表驱动的配合

源码里用了一个结构体来统一描述产生式的三要素:

class type { public: char origin; // 产生式左部非终结符 char array[5]; // 产生式右部符号串 int length; // 右部符号个数 }; type e, t, g, g1, g2, s, s1, s2, f, f1; type C[10][10]; // 预测分析表,10x10 足够覆盖 5 个非终结符 + 8 个终结符

每个产生式实例单独定义对象,比如e代表E → TG,g代表G → +TG,g1代表G → -TG,g2代表G → ^。通过C[m][n]把非终结符 m 与当前输入符 n 映射到具体产生式对象,这就是表驱动分析的经典结构。用 C++ 的 class 而非结构体,其实没用到封装特性,但好处是对象可以直接赋值——C[0][0] = e把整个产生式对象拷贝进表格,而不需要额外管理指针。

3. 核心代码拆解:分析栈、剩余串与查表驱动的完整流程

3.1 主循环:一个请求从栈顶到接受的完整链路

程序的主分析逻辑集中在一个 do-while 循环里,核心思路是:每次取栈顶符号 x,如果 x 是终结符,就与当前输入字符 ch 比对;如果 x 是非终结符,就查预测分析表决定展开哪条产生式。循环终止条件是栈顶遇到 # 且输入也读到 #,此时输出 acc。

do { x = A[top--]; // 取出栈顶符号 cout << k++; // 打印步骤号 for (j = 0; j <= 7; j++) // 判断 x 是否为终结符 if (x == v1[j]) { flag = 1; break; } if (flag == 1) { // 终结符分支 if (x == '#') { finish = 1; cout << "acc!" << endl; // 接受 exit(1); } if (x == ch) { // 栈顶终结符与输入匹配 print(); print1(); cout << "匹配" << endl; ch = B[++b]; // 输入指针后移 flag = 0; } else { // 不匹配则报错 print(); print1(); cout << "出错" << endl; exit(1); } } else { // 非终结符分支 for (j = 0; j <= 4; j++) if (x == v2[j]) { m = j; break; } // 确定行号 for (j = 0; j <= 7; j++) if (ch == v1[j]) { n = j; break; } // 确定列号 cha = C[m][n]; // 查预测分析表 if (cha.origin != 'N') { print(); print1(); cout << cha.origin << "→"; for (j = 0; j < cha.length; j++) cout << cha.array[j]; cout << endl; for (j = cha.length - 1; j >= 0; j--) // 逆序入栈 A[++top] = cha.array[j]; if (A[top] == '^') top--; // 空产生式不入栈 } else { print(); print1(); cout << "出错" << endl; exit(1); } } } while (finish == 0);

这里有个容易忽略的关键点:取栈顶用的是top--,入栈前先++top,栈顶指针始终指向最后一个有效符号。查表时若格子里的origin是N(初始化时的标记),说明这个位置的符号组合没有对应产生式,直接判定语法错误。空产生式^的逆序入栈后,栈顶变成^,再用top--把它弹出,这样 ε 产生式相当于只打印不压栈。

3.2 分析栈与剩余串的输出函数

调试语法分析器,最重要的就是能看到每一时刻的内部状态。源码里的 print 和 print1 就是为了这目的设计的:

void print() { int a; for (a = 0; a <= top + 1; a++) cout << A[a]; cout << "\t\t"; } void print1() { int j; for (j = 0; j < b; j++) // 输出对齐符 cout << " "; for (j = b; j <= l; j++) cout << B[j]; cout << "\t\t\t"; }

print 直接遍历 A 数组从 0 到 top+1,输出分析栈的全部内容;print1 先输出 b 个空格把输入串对齐到当前读指针位置,再输出剩余串。这里的 b 就是输入串的消费指针,l 是输入串总长度。这种输出方式让每一步的"栈内容 vs 剩余输入"一眼可见,是调试 LL(1) 程序时最实用的手段——比单步断点更直观,因为能同时看到栈的变化和输入消费的进度。

3.3 输入处理与词法接口的设计

程序读入分析串的部分值得注意:

do { cin >> ch; if ((ch != 'i') && (ch != '+') && (ch != '-') && (ch != '*') && (ch != '/') && (ch != '(') && (ch != ')') && (ch != '#')) { cout << "输入串中有非法字符\n"; exit(1); } B[j] = ch; j++; } while (ch != '#');

这里读入的是单个字符,i代表标识符或常数——就是实验一里词法分析输出的 ID 和 UCON 被抽象成了一个统一的终结符 i。注意cin >> ch会跳过空白字符,所以输入i+i*i#时,空格会被自动忽略,但换行不会。如果想支持从词法分析器直接传入单词流,需要把这里的cin >> ch改成从文件或缓冲区读取 token 类型。

4. 避坑记录:LL(1) 程序调试中的四个常见问题

4.1 分析表初始化导致的查表错误

现象:程序运行时,某些合法输入(比如i+i#)在某个查表步骤突然报"出错",但文法明明能推导出这个句子。

原因:源码里初始化分析表时,把所有格子赋成origin='N'作为空标记。但如果填充表时漏掉了某个格子,比如C[2][6](T 行 ) 列)没有填产生式,那么在分析(i)#时,T 遇到右括号查表会得到 N,直接报错。这是 LL(1) 分析器最经典的翻车点——FIRST 和 FOLLOW 集算错一个,表格就少填一个格。

解决:先用纸笔手工推导一遍 FIRST 和 FOLLOW 集,再对照源码里的填充代码逐格验证。每个非终结符的行,至少要有针对 FOLLOW 集中每个终结符的 ε 产生式,否则括号表达式和结束符场景会翻车。

4.2 cin 读入与字符串边界的坑

现象:输入i+i*i#能正确分析,但输入i + i #(带空格)时报错或者行为异常。

原因:cin >> ch会自动跳过空白,所以空格不是问题。真正的问题是输入串后如果没有#结束符,循环不会停止,程序会一直等待输入。另外,如果输入了中文标点或全角字符,非法字符检查会直接退出。

解决:运行时务必以半角字符输入,并以#结尾。我给这个程序写测试脚本时,习惯用管道传入:echo "i+i*i#" | ./parser,避免手动输入时的各种手误。

4.3 空产生式入栈的栈指针异常

现象:分析i#时,G 查表得到g2(即 ε),程序能输出G→^,但后续栈状态混乱,甚至栈指针越界。

原因:代码里逆序入栈后,A[top]如果是^,执行top--把它弹掉。但如果产生式右部不止一个符号且中间包含空串(本例中没有),或者入栈顺序写错,top的增减会不匹配。

解决:空产生式统一用^表示,在处理时先入栈再弹出,保证top最后回到正确位置。我一般会在逆序入栈后加一行if (A[top] == '^') top--;,同时把top的初始值设为 -1,让数组下标从 0 开始,避免 off-by-one。

4.4 压缩包文件格式与乱码问题

现象:从网上下载的实验文档,代码里出现大量?和乱码,比如cout〈<这种。

原因:原文档使用了全角符号〈〉,在部分编辑器里显示异常。代码中的<被替换成全角符号,导致编译失败。

解决:代码里的输出符号和比较符必须统一改成半角。我拆这份实验时,先把文档里的全角尖括号替换成半角,再用g++编译,一次通过。如果报错提示中文字符串,记得检查文件编码,用 UTF-8 无 BOM 保存。

5. 二次开发思路:从实验程序到可复用的语法分析模块

把这份实验代码改造成真实可用的语法分析器,我建议分三步走:第一步,把字符输入改成 token 流输入,对接实验一的词法分析器;第二步,把死板的分析表改成可配置的多文法支持;第三步,把错误处理从"遇错即退"改成"错误恢复"。

最关键的一步是输入抽象。实验里直接cin >> ch,每个字符就是一个终结符,但真实场景下词法分析器输出的是(ID, "count")或(UCON, "123")这样的二元组。改造方法很简单:把终结符从字符升级为枚举,i对应 ID 和 UCON 两类 token,+-*/()对应运算符和括号。这样四则运算表达式count + 123 * price就能被正确分析。常见做法是定义一个Token结构体,包含type和value,语法分析器只消费者type,语义分析时才需要value。

第二步,预测分析表可以用二维 vector 动态构造。实验里用C[10][10]硬编码,文法稍微变大就得改数组大小。使用vector<vector<Production>>后,表的行和列可以随终结符、非终结符集合动态生成,这样支持拓展文法——比如加入赋值运算符=或比较运算符<>时,不用重新设计数据结构。

最后是错误处理。实验代码在遇到语法错误时exit(1)直接退出,这对课设够了,但真实编译器需要"错误恢复"——跳过若干输入符号或弹出若干栈顶符号,让分析继续下去,一次编译报告多处错误。采用的常见策略是恐慌模式:某个非终结符查表失败时,丢弃输入直到遇到它的 FOLLOW 集中的符号,然后从栈里恢复分析。

我自己的教训是:LL(1) 分析器的表现完全取决于预测分析表的质量,而表的质量取决于 FIRST 与 FOLLOW 集计算的准确度。后来我写任何递归下降或 LL(1) 生成器,都会先写一个自动计算 FIRST/FOLLOW 集的小工具,再基于结果生成分析表,人工一格格填表终究容易踩坑。现在每次验证文法改造,我都会强制走一遍"左递归消除 → 首符集合检查 → 分析表填充 → 测试用例跑通"这个流程,希望这个习惯对你有帮助,也希望这份实验代码能成为你理解 LL(1) 的好起点。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询