简介:这份资源面向学习编译原理、需要完成LL(1)语法分析实验的高校学生与自学者,核心解决给定文法后自动构造预测分析表的问题。内容围绕FIRST集、FOLLOW集的迭代计算以及分析表数据结构设计展开,可配合《编译原理教程》(第四版)胡元义教材中的例题进行对照练习,帮助理解自顶向下语法分析的整体流程。压缩包共12个文件,以11个txt文本和1个c源码为主,txt多为测试输入与结果输出样例,c文件为完整实现代码,整体约15KB,轻量便于直接编译运行。目前已有967人学习下载,说明该实验在课程中具有较高参考价值。读者可借助源码理清集合迭代求解与表结构构建思路,通过多组测试样例验证输出结果,并在此基础上自行替换文法进行扩展实验,适合作为课程实验的借鉴与排错参考。
1. 从一份 C 语言课设说起:LL(1) 预测分析表到底怎么自动生成
如果你正在做编译原理课设,大概率绕不开这个场景:老师给一个文法,要求你手写出 FIRST 集、FOLLOW 集,再填一张预测分析表,最后用程序验证。手写一遍能过,但文法稍微复杂一点,集合迭代就算错,表也跟着崩。这份「实现预测分析表的自动生成.zip」就是冲着这个痛点来的——它用 C 语言把从文法读入到分析表输出的整条链路跑通,配套了多组测试输入和结果文件,可以直接对照验证自己的实现。适合正在啃 LL(1) 文法、需要一份可运行参考的在校生和自学者。它不替你写实验报告,但能让你看清每一步的数据结构长什么样、迭代什么时候收敛、表项冲突长在哪。
2. 拆开压缩包:文件结构、数据结构与 FIRST/FOLLOW 迭代逻辑
2.1 测试文件与结果文件的对应关系
拿到压缩包先别急着编译,把文件按用途分一下类,后面调试会省很多事。从目录看,输入侧是test.txt、test1.txt到test4.txt,输出侧是testout.txt、testout1.txt到testout4.txt,外加一份readme.txt和源码test4.c。这种命名方式很典型:testN.txt是第 N 组文法输入,testoutN.txt是对应的预测分析表输出,testout.txt通常是无编号的默认用例结果。
| 文件 | 角色 | 用途 |
|---|---|---|
| test.txt / test1~4.txt | 输入 | 存放产生式文法,每行一条 |
| testout.txt / testout1~4.txt | 输出 | 对应文法的 LL(1) 分析表 |
| test4.c | 源码 | 主程序,含集合计算与表构造 |
| readme.txt | 说明 | 编译与运行提示 |
我一般会先打开test.txt和testout.txt对读一遍,确认输入文法的格式约定——终结符和非终结符怎么区分、产生式用什么符号分隔、空串 ε 怎么表示。这一步不做,后面改代码就是盲改。
2.2 文法在内存里怎么存:产生式表与符号集
LL(1) 自动生成的第一道坎不是算法,是数据结构。产生式如果用字符串硬解析,每算一次 FIRST 都要重新扫一遍,效率低还容易出错。常见做法是先把文法读进一个结构体数组,每条产生式拆成「左部非终结符」和「右部符号序列」两部分。
// 产生式结构:左部一个非终结符,右部一串符号 typedef struct { char left; // 左部非终结符,如 'E' char right[32][8]; // 右部符号序列,每个符号最多 8 字符 int len; // 右部符号个数 } Production; Production grammar[64]; // 最多 64 条产生式 int prodCount = 0; // 实际产生式数量这里right用二维字符数组而不是单个字符串,是为了处理E->T E'这种右部有多个符号的情况。len记录右部长度,遍历时直接按长度走,不用每次找结束符。参数上,64和32是经验值,课设级别的文法够用;如果你的文法产生式超过 64 条,把数组开大即可,但要注意栈上大数组可能溢出,必要时改成static或动态分配。
符号集这边,终结符和非终结符各维护一个字符数组加计数,读文法时顺便收集,避免后面反复扫描。
2.3 FIRST 集迭代:什么时候算收敛
FIRST 集的本质是「一个符号串能推导出的首终结符集合」。对单个符号,规则很直接:终结符的 FIRST 就是它自己;非终结符要看它所有产生式的右部首符号。真正麻烦的是右部首符号也是非终结符,甚至整个右部都能推出 ε,这时候要把下一个符号的 FIRST 并进来。
// 迭代计算 FIRST 集,直到某一轮没有任何集合发生变化 int changed = 1; while (changed) { changed = 0; for (int i = 0; i < prodCount; i++) { char A = grammar[i].left; int k = 0; // 逐个符号处理,遇到不能推空串的符号就停 while (k < grammar[i].len) { char X = grammar[i].right[k][0]; int before = firstSet[A].count; addFirstSet(A, X); // 把 FIRST(X) 并入 FIRST(A) if (firstSet[A].count != before) changed = 1; if (!canDeriveEpsilon(X)) break; // X 不能推 ε,后续符号不再并入 k++; } if (k == grammar[i].len) { // 右部所有符号都能推 ε,则 ε 属于 FIRST(A) addEpsilonToFirst(A); } } }逻辑说明:外层while(changed)是迭代到不动点,这是 FIRST 集计算的标准做法,因为非终结符之间可能相互依赖,一轮算不完。canDeriveEpsilon(X)判断某个符号能否推出空串,终结符恒为假,非终结符查它是否已有 ε 在 FIRST 里。参数上,addFirstSet要做去重,否则集合计数永远在变,迭代不收敛——这是新手最容易翻车的地方。
2.4 FOLLOW 集与预测分析表的填表规则
FOLLOW 集比 FIRST 多一层「上下文」:它描述某个非终结符后面可能紧跟哪些终结符。规则有三条——开始符号的 FOLLOW 含$;若A->αBβ,则 FIRST(β) 去掉 ε 并入 FOLLOW(B);若 β 能推 ε 或不存在,则 FOLLOW(A) 并入 FOLLOW(B)。这三条同样要迭代到收敛。
表构造是最后一步,规则一句话:对每条产生式A->α,对 FIRST(α) 里每个终结符 a,把A->α填进M[A][a];如果 α 能推 ε,则对 FOLLOW(A) 里每个符号 b,把A->ε填进M[A][b]。填表时如果目标格子已经有内容且不是同一条产生式,就是 LL(1) 冲突,说明这个文法不是 LL(1) 的。
// 填预测分析表 M[非终结符][终结符] for (int i = 0; i < prodCount; i++) { char A = grammar[i].left; // 情况一:右部首符号的 FIRST 集 for (int t = 0; t < firstSetOfRight[i].count; t++) { char a = firstSetOfRight[i].terms[t]; if (a != EPSILON) setTable(A, a, i); // 冲突检测在 setTable 内 } // 情况二:右部能推 ε,用 FOLLOW(A) 填 if (rightCanDeriveEpsilon(i)) { for (int t = 0; t < followSet[A].count; t++) { setTable(A, followSet[A].terms[t], i); } } }setTable里要做冲突判断:如果M[A][a]已有值且不等于当前产生式编号,打印冲突位置并标记该文法非 LL(1)。这一步别省,课设里老师往往就看你有没有处理冲突。
3. 编译运行与结果验证:从 test.txt 到 testout.txt 的完整走查
3.1 在 VS2019 下编译这份 C 代码
源码是test4.c,单文件,没有额外依赖,VS2019 下新建空项目把文件加进去即可。注意两点:一是文件编码,如果test.txt里有中文注释或特殊符号,用 UTF-8 保存,否则读进来会乱码;二是工作目录,程序用相对路径读test.txt,默认工作目录是项目目录,不是exe所在目录,跑之前确认test.txt在正确位置。
# 如果不想开 VS,用 gcc 命令行也能编 gcc test4.c -o ll1.exe -Wall ./ll1.exe-Wall打开全部警告,集合数组越界、未初始化变量这类问题会直接报出来,比运行时崩溃好查。常见做法是先在命令行跑通,再挪进 VS 调试,因为 VS 的默认工作目录和命令行不一致,容易读不到输入文件。
3.2 用 test.txt 跑一遍并对照 testout.txt
跑通之后,程序会读test.txt里的文法,输出 FIRST、FOLLOW 和预测分析表。验证方法很直接:打开testout.txt,逐行对照。重点看三处——FIRST 集里有没有漏掉 ε、FOLLOW 集里$有没有加、分析表里空串产生式填的位置对不对。
如果输出和testout.txt不一致,先别改算法,按这个顺序排查:文法读入的符号切分对不对、canDeriveEpsilon的判断有没有漏、迭代终止条件是不是提前退出。我一般会在每轮迭代后打印一次集合内容,看它是第几轮稳定的,和手算的轮数对一下,很快能定位。
3.3 换 test1~4.txt 验证通用性
单组用例跑通不代表程序对。test1.txt到test4.txt大概率覆盖了不同情况:有的文法含 ε 产生式,有的右部多符号,有的可能存在 LL(1) 冲突。逐个跑,对照testout1~4.txt。如果某组结果对不上,先判断是程序 bug 还是这组文法本身不是 LL(1)——后者的话,testoutN.txt里应该有冲突提示,程序输出冲突位置也算正确行为。
提示:验证时优先看集合的「元素个数」而不是「顺序」,集合输出顺序依赖遍历顺序,不同实现可能不一样,但元素集合必须一致。
4. 避坑与排查:集合不收敛、表项冲突、文件读入乱码
4.1 FIRST 集迭代不收敛,程序卡死
现象:程序跑起来一直不退出,CPU 占满。原因:addFirstSet没有去重,每次并入都让count变化,changed永远为 1。解决:并入前先查目标集合里是否已有该符号,有就跳过,只有真正新增元素时才把changed置 1。
4.2 预测分析表出现冲突却当成正常输出
现象:某个M[A][a]被填了两次,程序没报错,直接覆盖。原因:setTable里没做冲突检测,后填的覆盖了先填的。解决:填表前判断该格是否已有产生式编号,有且不同就打印Conflict at M[A][a],并标记文法非 LL(1)。这是课设的得分点,别漏。
4.3 读 test.txt 时符号切分错误
现象:产生式右部T E'被当成一个符号,或者E'被拆成E和'。原因:按空格切分时没考虑带撇的非终结符,或者分隔符约定和文件实际格式不符。解决:先打印读入的每条产生式确认切分结果,再调整切分逻辑;带撇符号建议整体作为一个 token 处理。
4.4 输出文件乱码或覆盖原结果
现象:testout.txt打开是乱码,或者跑一次就被覆盖。原因:写文件时用了文本模式但编码不一致,或者输出文件名硬编码成同一个。解决:输出文件按输入文件名派生,比如test1.txt对应testout1.txt;编码统一用 UTF-8,VS 里在「高级保存选项」确认。
4.5 FOLLOW 集漏掉开始符号的$
现象:分析表里$列全空,遇到输入结束符无法分析。原因:初始化 FOLLOW 集时忘了给开始符号加$。解决:在读文法确定开始符号后,第一件事就是把$加进它的 FOLLOW 集,再进入迭代。
5. 进阶技巧:把分析表变成可执行的分析器
集合和表都对了,其实离一个能跑的分析器只差一个栈。LL(1) 分析过程就是「栈顶符号 vs 当前输入符号」查表:栈顶是非终结符就查M,查到产生式就把栈顶替换成右部逆序;栈顶是终结符就和输入符号比对,相同则双双弹出。这一步做完,你的课设就不只是「生成表」,而是「用表分析句子」。
// LL(1) 分析主循环(简化版) stack[++top] = '$'; stack[++top] = startSymbol; // 开始符号入栈 int ip = 0; // 输入串指针 while (top >= 0) { char X = stack[top]; char a = input[ip]; if (X == '$' && a == '$') { printf("Accept\n"); break; } if (isTerminal(X) || X == '$') { if (X == a) { top--; ip++; } // 匹配,双双前进 else { printf("Error at %c\n", a); break; } } else { int p = table[X][a]; // 查预测分析表 if (p == -1) { printf("Error: no rule for M[%c][%c]\n", X, a); break; } top--; // 弹出栈顶非终结符 for (int k = grammar[p].len - 1; k >= 0; k--) stack[++top] = grammar[p].right[k][0]; // 右部逆序入栈 } }参数说明:table[X][a]存产生式编号,-1表示无规则;右部逆序入栈是为了让第一个符号先被处理。跑的时候拿test.txt里的文法生成表,再随便写个句子当输入,看能不能走到Accept。如果中途报错,回头查表里对应格子是不是空的——多半是 FOLLOW 集算漏了。
从那以后我每次做完集合计算,都会强制拿一个最短句子走一遍分析栈,因为表对不对,跑一个句子比盯十遍输出都管用。希望帮到你。
本文还有配套的精品资源,点击获取