简介:山东科技大学2022年编译原理实验中的LL(1)语法分析实现资料,面向正在完成编译原理课程实验或希望掌握预测分析算法原理的本科生与自学者。资料围绕包含加减乘除、括号及变量i的表达式文法,给出了完整的LL(1)分析程序,可在Code::Blocks中直接编译运行,并支持对任意输入符号串进行语法分析,同时通过预测分析表和分析栈的变化展示推导过程,帮助理解FIRST集、FOLLOW集、预测分析表及错误处理机制。资源包大小约1.08MB,内含可直接运行的源码与完整实验报告,报告包括实验题目、算法流程、核心数据结构、测试样例及结果分析,可配合代码逐行阅读,也便于在此基础上扩展或复用。已有913人学习/下载,对于需要完成同类实验或复习LL(1)方法的读者来说,是一份实用的参考实现。
1. LL(1)分析法实现:从文法到可运行实验的完整落地
这篇笔记拆的是山东科技大学编译原理实验里的一道经典题:对给定的算术表达式文法用LL(1)分析法做语法分析,要求能在Code::Blocks里直接编译运行,实验报告也一并配齐。很多同学做这个实验时卡在同一个节点上:First集和Follow集手算没问题,一写代码就不知道预测表用什么结构存、主循环的栈操作边界在哪容易出错,这些点没人点破就得折腾一晚上。这套资源把预测表构建和栈驱动分析完整串起来,输入任意符号串能实时看到栈内容、剩余输入和每一步用到的产生式,对正在做编译原理LL(1)实验、想快速跑通又想彻底看懂原理的人非常合适。下面从手算推导到代码实现再到踩坑排查逐步展开,关键步骤都附了可直接复现的代码。
2. 计算First集与Follow集:这份文法的预测分析表是怎么来的
2.1 首先做文法体检:没有左递归和左公因子是硬前提
写代码之前,必须先确认题目给的是不是合法的LL(1)文法。题目给出的八条产生式如下:
E -> T G G -> + T G | - T G | ε T -> F M M -> * F M | / F M | ε F -> ( E ) | i这是把算术表达式常见的「加减、乘除、括号、变量」语法做了改造之后的产物。原始的表达式文法如果写成 E -> E + T | E - T | T 这种形式,直接带左递归,没法套LL(1)的分析流程,所以第一步就是消除左递归,顺便提取左公因子,最后得到上面这组产生式。
对照这组产生式做体检,结论是:E只有一个右部TG,不存在多候选分支,所以不会冲突;G的候选右部以+、-、ε开头,首符号互不相同;M的候选右部以*、/、ε开头,也是互不相同。既没有直接左递归,也没有间接左递归,因此这组文法满足LL(1)的使用前提。这个「体检」步骤在实验报告里应该明确写出来,它解释了你为什么可以放心使用预测分析表,而不是去写带回溯的递归下降分析。
我批过不少实验报告,有些同学拿到文法就开始写代码,连左递归检查都跳过了。等预测表里出现一个格子对应多个产生式的情况,才意识到是文法本身有问题。所以把这步放在最前面,是帮你省下后面一整晚的排查时间。
2.2 FIRST集手算:从F开始逐层往上推
计算First集有个固定顺序:从产生式最底层的非终结符开始,逐层往上。本题最底层是F。
F -> ( E ) | i:两个候选右部分别以(和i开头,所以FIRST(F) = {(, i}。注意这里F的First集不含ε,因为F不可能推导出空串,这一点会直接影响后面所有关于「是否可空」的判断。
然后是T -> F M:右部第一个符号是F,所以FIRST(F)里非ε的符号全部进入FIRST(T),于是FIRST(T) = {(, i}。因为F不可空,这里不需要再看M。
接着E -> T G:同理,FIRST(E) = FIRST(T) = {(, i}。E是整个文法的开始符号,但求First集不要受开始符号身份的影响,该从右部首符号取就从右部首符号取。
G -> + T G | - T G | ε:三个候选右部里有两个直接以终结符开头,一个推导为空。直接归并得到FIRST(G) = {+, -, ε}。这里ε必须单独列出,因为它标记了G是可空的,后面Follow集和预测表构造都离不开这个信息。
M -> * F M | / F M | ε:同理,FIRST(M) = {*, /, ε}。
到这里,整个文法的First集全部得到:
| 非终结符 | FIRST集 |
|---|---|
| E | { (, i } |
| G | { +, -, ε } |
| T | { (, i } |
| M | { *, /, ε } |
| F | { (, i } |
注意G和M这两行都带ε,这意味着它们在推导过程中可以选择推导为空串。这个「可空」属性是后面Follow集计算里最容易用错的一环。
2.3 FOLLOW集手算:最容易漏掉的就是ε产生式那一步
Follow集的计算规则可以浓缩成三条。第一,开始符号的Follow集里一定有#,也就是输入结束符。第二,对形如 A -> α B β 的产生式,FIRST(β)去掉ε后全部进入FOLLOW(B)。第三,如果β可以推导出ε,或者B就是右部最后一个符号,那么FOLLOW(A)整体进入FOLLOW(B)。
按这个规则逐层推导。E是开始符号,FOLLOW(E)先有{ # }。再找E出现在哪些产生式的右部:只有F -> ( E ),E后面跟的是),所以)也进入FOLLOW(E),最终FOLLOW(E) = {), #}。
然后是G。G出现在E -> T G这个产生式的右部末尾,所以FOLLOW(E)整体进入FOLLOW(G),FOLLOW(G) = {), #}。G不会出现在其他产生式右部,计算到此结束。
接着是T。T出现在E -> T G以及G -> + T G | - T G中。先看E -> T G,T后面是G,FIRST(G)去掉ε得到{+, -},进入FOLLOW(T);又因为G可空,FOLLOW(E)里的{), #}也要进入FOLLOW(T)。再看G -> + T G,T后面同样是G,用同样的两步规则推出来的还是{+, -}和FOLLOW(G) = {), #},没有新增。所以FOLLOW(T) = {+, -, ), #}。
再是M。M出现在T -> F M和M -> * F M | / F M。以T -> F M为例,M在右部末尾,FOLLOW(T)整体进入FOLLOW(M),所以FOLLOW(M) = {+, -, ), #}。
最后是F。F出现在T -> F M和M -> * F M | / F M中。以T -> F M为例,F后面是M,FIRST(M)去掉ε得到{, /}进入FOLLOW(F);又因为M可空,FOLLOW(T) = {+, -, ), #}也要进入FOLLOW(F)。汇总得到FOLLOW(F) = {, /, +, -, ), #}。
如果把M可空这一步漏掉,FOLLOW(F)就会少四个符号,后面预测表F行就会缺项,输入串只要出现对应符号就报分析失败,这是实验里最常见的隐藏bug来源。最终Follow集汇总:
| 非终结符 | FOLLOW集 |
|---|---|
| E | { ), # } |
| G | { ), # } |
| T | { +, -, ), # } |
| M | { +, -, ), # } |
| F | { *, /, +, -, ), # } |
2.4 预测分析表构造:填表规则与本题完整结果
预测分析表是二维矩阵,行由非终结符编号,列由终结符(含#)编号。填表时对每个产生式A -> α执行两步操作。
第一步,遍历FIRST(α),去掉ε,把产生式填入M[A][每个终结符]对应的格子里。第二步,如果α可空,也就是FIRST(α)里有ε,则遍历FOLLOW(A),把同样的产生式填入M[A][FOLLOW中每个终结符]对应的格子里。如果某个格子被填了两次且产生式不同,就说明文法不是LL(1)的,这也是一种自动化的冲突检测方式。
用上面手算的First和Follow逐条填入,得到完整预测表:
| 非终结符 | + | - | * | / | ( | ) | i | # |
|---|---|---|---|---|---|---|---|---|
| E | - | - | - | - | E->TG | - | E->TG | - |
| G | G->+TG | G->-TG | - | - | - | G->ε | - | G->ε |
| T | - | - | - | - | T->FM | - | T->FM | - |
| M | M->ε | M->ε | M->*FM | M->/FM | - | M->ε | - | M->ε |
| F | - | - | - | - | F->(E) | - | F->i | - |
注意M行在+列和-列各有一个M->ε,这是因为FOLLOW(M)包含了+和-。当输入符号是+或-时,M要选择推导为空,把控制权交还给上层非终结符。很多人在这一行上出错,把+和-对应的格子留空,导致分析i+i这类输入时在M这一步直接卡死。
至此手算部分完成。这套分析表在代码里既可以硬编码成二维数组,也可以写算法根据First和Follow动态构建。推荐动态构建,因为文法一旦微调,硬编码的表就得手动改,动态构建则自动更新。
3. 核心实现思路:预测表、分析栈与主循环的三块拼图
3.1 数据结构选型:用什么表示文法、分析栈和预测表
写LL(1)分析器,代码组织的核心是三个数据结构。第一个是非终结符表,第二个是终结符表,第三个是预测分析表本身。本题的终结符有8个:+、-、*、/、(、)、i、#,非终结符有5个:E、G、T、M、F。分析表用二维数组存储,行数等于非终结符个数5,列数等于终结符个数8。
C语言里最直接的做法是定义两个字符数组做索引映射:
char nonTerm[5] = {'E', 'G', 'T', 'M', 'F'}; char term[8] = {'+', '-', '*', '/', '(', ')', 'i', '#'};通过查找元素在数组中的下标,把字符符号翻译成行列索引。有一个容易被忽视的坑:不要直接用ASCII码当索引,比如table['E']['i'] = 产生式编号,这样会分配大量无用空间,而且char类型做下标还有可能越界。标准做法是写一个查找函数,返回符号对应的行列下标,找不到就返回-1。
分析栈在C语言里用数组模拟最直观,栈底放#,栈顶放在数组末尾:
char stack[MAX_STACK]; int top = 0;初始化时先把#压入,再把开始符号E压入。这样栈顶永远是当前正在处理的符号,每次读取栈顶时取stack[top]就行。为什么初始时要压#?因为分析结束的标志是栈里有#且输入串也读到#,双#相遇才算结束,少一个都会导致死循环或者提前退出。
3.2 自动构建预测分析表:核心算法与冲突检测
预测表的动态构建算法流程如下:对每条产生式求FIRST(右部),如果右部第一个符号是终结符,直接加入;如果是非终结符,递归取它的FIRST;如果整个右部可空,则确定ε属于这条产生式的FIRST。然后根据产生式的FIRST集填表,如果产生式可空,再根据左部的FOLLOW集补填。
这里最关键的递归函数是判断「某个符号串能否推导出ε」。判断条件是:符号串里每个符号都必须可空,只要有一个不可空的,整个串就不可空。这个逻辑在代码里体现为一个循环,逐个检查当前符号的FIRST集是否含ε,遇到不含ε的符号就提前终止。
构建完整之后要立刻做一个自检:遍历每个产生式尝试填写表格,如果某个格子已经填了一条产生式,又来了另一条,就报告冲突。这一步对应着LL(1)文法的判定,能在文法不满足条件时及时发现,而不是等到运行时才暴露分析失败。
提示:在Code::Blocks里新建控制台项目时,把源文件保存为 .c 后缀。如果默认创建的是 .cpp,编译器会走C++模式,个别字符串处理函数的隐式转换可能会触发不必要的警告。
3.3 栈驱动分析主循环:状态输出与边界条件
分析器的主循环是整个程序的发动机。逻辑结构如下:
栈初始:# E 输入串:i + i * i # 循环条件:栈顶非 # 或 输入指针未到末尾 若栈顶是终结符: 与当前输入符号比较 相等则弹出栈顶,输入指针后移 不等则报告语法错误 若栈顶是非终结符: 查预测表 table[非终结符][当前输入符号] 若表项为空 -> 语法错误 若表项是产生式: 弹出栈顶 若产生式右部不是 ε: 将右部符号从右往左依次压栈 打印当前栈、剩余输入和所用产生式这里有两个细节值得特别说明。第一,产生式右部为ε时要特殊处理:只弹栈,不压任何符号。很多实现把ε当作普通符号压栈,结果栈里出现ε,后续分析在ε上永远匹配不上,卡死在循环里。第二,压栈顺序必须从右往左,也就是从产生式右部的最后一个符号开始压。比如产生式G -> +TG,右部第一个符号+要最后压栈,这样分析时+才能在栈顶最先被处理。
主循环的终止条件也容易写错。正确条件是「栈顶和输入指针都到达#」,也就是栈中只剩#且输入串也恰好读完。写成「栈空」是不对的,因为初始栈里就有#,#不会真的被弹出,除非输入串末尾也是#并且代码里写了弹出#的动作。稳妥的做法是循环条件写成while(1),在循环体里判断栈顶是否为#且当前输入符为#,满足则break并打印分析成功。
3.4 输出设计:让每一步转移都看得见
实验要求里通常会写「输出分析过程」,所以主循环里每一步都要打屏。建议至少输出三列信息:当前栈内容、剩余输入串、采用哪条产生式。
栈内容的打印顺序建议从栈底到栈顶,这样和人类阅读习惯一致,也方便和手写推导过程对照。剩余输入串则是从当前指针到字符串末尾。产生式输出格式用「左部 -> 右部」的写法,例如:
步骤 栈内容 剩余输入 产生式 1 #E i+i*i# E->TG 2 #GT i+i*i# T->FM这种逐步输出对调试和实验报告截图都非常有价值,老师批改时一眼就能判断分析流程是否正确,不需要自己从头推演中间状态。
4. 完整代码走读:Code::Blocks下能直接编译的LL(1)分析器
4.1 头文件、宏定义与全局数据结构
资源包里的主文件是单个 .c 文件,编译环境是Code::Blocks自带的MinGW GCC,不需要额外配置。文件开头部分是全局数据定义。
#include <stdio.h> #include <stdlib.h> #include <string.h> #define MAX_STACK 100 #define MAX_LEN 100 // 非终结符表 char nonTerm[] = {'E', 'G', 'T', 'M', 'F'}; int nonTermNum = 5; // 终结符表,注意顺序与预测表列索引一致 char term[] = {'+', '-', '*', '/', '(', ')', 'i', '#'}; int termNum = 8; // 预测分析表,-1 表示该格为空(即语法错误) int table[5][8]; // 产生式右部,用字符串存储 char* prodRight[] = { "TG", // 0: E->TG "+TG", // 1: G->+TG "-TG", // 2: G->-TG "ε", // 3: G->ε "FM", // 4: T->FM "ε", // 5: M->ε "*FM", // 6: M->*FM "/FM", // 7: M->/FM "(E)", // 8: F->(E) "i" // 9: F->i }; // 每个产生式的左部非终结符在 nonTerm 中的下标 int prodLeft[] = {0, 1, 1, 1, 2, 3, 3, 3, 4, 4};这段代码里有几个设计点直接关系到后续算法的复杂度。第一个是「产生式右部用字符串存储,ε用特殊字符串表示」,这样压栈时只需判断strcmp(prodRight[i], "ε")是否为0,可空判断一目了然。第二个是「左部只存下标不存字符」,查找符号在表里的位置时直接O(1)取值,不用每次都遍历nonTerm数组。第三个是「预测表先初始化为-1」,-1表示空项,运行时遇到-1直接报语法错误,避免把空项当成产生式编号使用。
4.2 FIRST集求解子程序:迭代稳定化处理
FIRST集在代码里用布尔二维数组存储,多出的一列标记该非终结符是否可空。由于FIRST集之间存在间接依赖,比如FIRST(F)里裹着FIRST(T)的内容,而FIRST(T)又反过来依赖FIRST(F),单次递归可能拿不到完整结果,所以实际实现用的是迭代稳定化方法:初始化所有FIRST集为空,反复扫描全部产生式更新FIRST集,直到某一轮没有任何变化为止。
// first[i][j] = 1 表示非终结符 i 的 FIRST 集包含终结符 term[j] // first[i][termNum] = 1 表示非终结符 i 可推导出 ε int first[5][9]; // 求某个产生式右部的 FIRST 集,结果存入 dest 数组 void getFirstOfRight(int* dest, char* right) { int len = strlen(right); for (int k = 0; k < len; k++) { int tIdx = getTermIdx(right[k]); if (tIdx != -1) { dest[tIdx] = 1; break; // 以终结符开头,立即停止 } int ntIdx = getNonTermIdx(right[k]); if (ntIdx != -1) { for (int c = 0; c < termNum; c++) if (first[ntIdx][c]) dest[c] = 1; if (first[ntIdx][termNum] == 0) break; // 非终结符不可空,迭代停止 continue; // 可空,继续看下一个符号 } } // 整个右部所有符号都可空,则 ε 属于 FIRST(右部) int allEpsilon = 1; for (int k = 0; k < len; k++) { int ntIdx = getNonTermIdx(right[k]); if (ntIdx == -1 || first[ntIdx][termNum] == 0) { allEpsilon = 0; break; } } if (allEpsilon) dest[termNum] = 1; }这段逻辑的关键在两层判断上。遇到终结符直接停止,是因为终结符没有任何展开能力;遇到不可空的非终结符停止,是因为这个符号已经贡献了它能贡献的全部终结符,后面符号的FIRST被它挡住了。只有当前符号可空,才继续扫描右部下一个符号,这和手算FIRST集的思路完全一致。
外层再包一个循环,重复调用getFirstOfRight,定时检查所有FIRST集是否有变化,没有变化就退出循环。这样写虽然比单次递归多一点代码,但逻辑清晰,不容易漏掉间接依赖。
4.3 FOLLOW集求解子程序:逐条规则迭代更新
FOLLOW集用同样的布尔数组格式存储。初始化时把#放进开始符号E的FOLLOW集里,然后循环执行更新规则直到收敛。核心更新逻辑如下:
// follow[i][j] = 1 表示非终结符 i 的 FOLLOW 集包含终结符 term[j] // follow[i][termNum] = 1 表示 FOLLOW 集含 #,用 term 数组里的 '#' 来标记 int follow[5][9]; // 每一轮迭代:对每个产生式 A -> α B β,套用两条规则 void calcFollowOnePass() { for (int p = 0; p < 10; p++) { char* right = prodRight[p]; int len = strlen(right); int leftIdx = prodLeft[p]; // A 的下标 // 右部最后一个符号是非终结符,直接吸收 FOLLOW(A) int lastNt = getNonTermIdx(right[len-1]); if (lastNt != -1) { for (int c = 0; c <= termNum; c++) if (follow[leftIdx][c]) follow[lastNt][c] = 1; } // 对右部中每个位置,处理 "后跟符号串" 的 FIRST for (int k = 0; k < len; k++) { int ntIdx = getNonTermIdx(right[k]); if (ntIdx == -1) continue; // 终结符跳过 // 计算 B 后面符号串的 FIRST int betaFirst[9] = {0}; getFirstOfRight(betaFirst, right + k + 1); // 规则一:FIRST(β) 去掉 ε 后加入 FOLLOW(B) for (int c = 0; c < termNum; c++) if (betaFirst[c]) follow[ntIdx][c] = 1; // 规则二:β 可空,FOLLOW(A) 整体加入 FOLLOW(B) if (betaFirst[termNum]) { for (int c = 0; c <= termNum; c++) if (follow[leftIdx][c]) follow[ntIdx][c] = 1; } } } }这里最需要注意的就是数组索引范围。follow[i][termNum]这一列用来标记#,因为#只出现在输入串结尾,不会作为普通终结符出现在表列里,但FOLLOW集又必须记录它。我在代码里把#直接放进了term数组,所以FOLLOW集的最后一列和终结符数组里#的索引是同一位置,填表时转换行列下标就能直接对上。
外层同样做循环迭代,直到所有FOLLOW集不再变化。本题实际三轮迭代就稳定了,性能上没有压力。这种迭代法比手写递归清晰得多,尤其适合处理多产生式交叉引用的情况。
4.4 填表与主循环:可运行的核心代码
填表过程把前面算好的FIRST和FOLLOW转换成预测表。逻辑是遍历每个产生式,对产生式右部的FIRST去ε填表,如果右部可空则对左部FOLLOW填表:
void buildTable() { memset(table, -1, sizeof(table)); for (int p = 0; p < 10; p++) { int left = prodLeft[p]; int frst[9] = {0}; getFirstOfRight(frst, prodRight[p]); // 第一步:右部FIRST的非ε终结符 for (int c = 0; c < termNum; c++) { if (frst[c]) fillCell(left, c, p); } // 第二步:右部可空,再用左部FOLLOW补填 if (frst[termNum]) { for (int c = 0; c < termNum; c++) { if (follow[left][c]) fillCell(left, c, p); } } } }fillCell里有一行关键的冲突检查:
void fillCell(int row, int col, int prodIdx) { if (table[row][col] != -1 && table[row][col] != prodIdx) { printf("判定失败:文法不满足LL(1)条件 [%c][%c] 有冲突\n", nonTerm[row], term[col]); exit(1); } table[row][col] = prodIdx; }这一步把冲突检测前置到构建阶段,比运行时才报错友好得多。表填完之后可以先打印一遍确认内容,和手算结果核对无误再跑输入串。
主循环的完整代码如下:
void analyze(char* input) { char stack[MAX_STACK]; int top = 0; stack[++top] = '#'; stack[++top] = 'E'; int ip = 0; // 输入指针 while (1) { char topSym = stack[top]; char curIn = input[ip]; // 打印当前状态 printf("栈: "); for (int i = 0; i <= top; i++) printf("%c", stack[i]); printf(" 输入: %s\n", input + ip); if (topSym == '#' && curIn == '#') { printf("分析成功\n"); return; } int tIdx = getTermIdx(topSym); if (tIdx != -1) { // 栈顶是终结符 if (topSym == curIn) { top--; ip++; } else { printf("语法错误:栈顶终结符 %c ≠ 输入 %c\n", topSym, curIn); return; } } else { // 栈顶是非终结符 int row = getNonTermIdx(topSym); int col = getTermIdx(curIn); int prod = table[row][col]; if (prod == -1) { printf("语法错误:非终结符 %c 在输入 %c 下无产生式\n", topSym, curIn); return; } char* right = prodRight[prod]; printf("采用产生式: %c->%s\n", nonTerm[row], right); top--; // 弹出左部非终结符 if (strcmp(right, "ε") != 0) { int len = strlen(right); for (int k = len-1; k >= 0; k--) { stack[++top] = right[k]; // 右部从右往左压栈 } } } } }主循环里可空处理是重点。产生式右部是ε时,只弹出左部符号不压入任何新符号,这样栈顶永远是「下一次要处理的实际符号」。很多学生第一次写LL(1)时会把ε直接压栈,分析过程立刻就乱了。
提示:预测表构建完成后别急着跑完整分析,先打印一遍五行八列的表,手动核对G行在)和#列是否都有G->ε,M行在+和-列是否都有M->ε。这两处是本题最容易漏填的地方。
4.5 实验报告的结构与测试用例设计
资源包里的实验报告包含题目原文、文法分析、First集与Follow集推导过程、预测分析表、流程图说明、运行测试、实验结论几个部分。测试用例建议采用以下四组:
- 合法串 i:最短输入,验证单因子分析
- 合法串 i+i*i:混合加减乘除优先级,验证G的推导和M的ε归约
- 合法串 (i+i)*i:带括号输入,验证F->(E)这条产生式是否正确调用
- 非法串 i+i*:缺少因子,验证错误分支能否正确报错且不崩溃
每组测试在报告中附一段运行截图。每行输出里的「栈内容、剩余输入、产生式」都是依据,老师不需要跟代码推演逻辑,看输出序列就能判定算法实现正确与否。
5. 避坑排查:这个实验里最常翻车的五个细节
5.1 运行到一半报非终结符无产生式,但手算表看起来没问题
现象:输入合法串i+i,分析到某一步突然报语法错误,提示某个非终结符在当前输入符号下无产生式。
原因:预测表填充时只填了FIRST集对应的列,没填FOLLOW集对应的列。比如G->ε这条产生式的FIRST集是ε,没有实际终结符,如果只用第一步填表,G行就全是空的。正确做法是产生式右部可空时,用FOLLOW(G)里的符号补填。
解决:确认buildTable函数里第二步的FOLLOW补填逻辑加上了。填完表后打印整个预测表,检查G行在)列和#列是否出现G->ε,M行在+列和-列是否出现M->ε。
5.2 栈里出现ε,分析卡死或产生式无限循环
现象:程序不报错但一直重复输出同一条产生式,或者栈内容快速膨胀,最终栈溢出崩溃。
原因:压栈时把产生式右部的ε字符串当成普通符号压入了栈。比如strcmp判断漏写,或者把"ε"这个字符串本身压进去。后续栈顶是ε,匹配终结符规则不行,匹配非终结符规则也不行,分析流程直接乱套。
解决:压栈前用strcmp严格判断是否为ε,是则不压栈。这个判断必须在弹出左部符号之后执行,保证栈里只保留真正的终结符和非终结符。
5.3 输入串末尾的换行符导致分析失败
现象:测试printf里写死的串一切正常,用标准输入读串时总是报语法错误,打印输入串发现末尾多了一个\n。
原因:用gets或fgets读入时会保留换行符,而输入串的结束标志是#,\n在输入串里相当于一个无法识别的符号,查预测表时行列下标直接对不上。
解决:读入后立刻清理输入串末尾的换行符,把\n替换为\0。同时检查是否要求用户末尾输入#号,如果用户忘了写#,程序也应该给出明确提示而不是直接崩。
5.4 输入串合法但栈底#被弹出,导致打印不出分析成功
现象:分析到输入串末尾时栈里只剩一个#,程序还在继续循环,最后因为查表越界崩溃。
原因:终止条件只判断了输入指针到末尾,没判断栈顶是否为#。双#相遇才是结束条件,只有输入串结束而栈顶还是非终结符时,说明分析还没完成,需要继续推导或直接报错。
解决:终止条件写成topSym == '#' && curIn == '#',用栈内容加输入指针双条件判断。不能用栈是否为空来判断,因为栈底#本身就是栈的一部分,它永远不会被弹出。
5.5 实验报告手算表和程序输出表对不上
现象:报告里手算的预测表和程序跑出来的预测表有几项不一致,又说不清谁对谁错。
原因:手算Follow集时漏算ε产生式传导。本题最典型的是FOLLOW(F)少了*、/、+、-四个符号,或FOLLOW(M)少了+和-。手算一旦漏掉某个FOLLOW,预测表里对应的格子就会缺项。
解决:用程序直接打印预测表,逐行列对照报告。如果还有出入,回看Follow集推导,重点检查「β可空时FOLLOW(A)是否进入FOLLOW(B)」这一步。吃不准就在纸上把每个非终结符出现的位置圈出来,逐个推一遍。
6. 进阶验证:用栈轨迹反向校验算法实现正确性
实验代码跑通之后,别急着交作业,还有一个有效的验证方法:拿「栈轨迹」反向推演,确认分析器和预测表没有隐藏bug。
具体操作是,把主循环里每次迭代的栈顶符号和当前输入符号整理成一张轨迹表,结合输入串做人工验证。对i+i*i#,第一行栈顶E、输入i,查表得E->TG;第二行栈顶T、输入i,查表得T->FM;第三行栈顶F、输入i,查表得F->i。用这个方法走一遍,会发现一个关键事实:LL(1)分析过程的每一步都是确定的,栈顶符号和输入符号唯一决定下一步动作,不存在任何分支选择。
另一个实用的验证技巧是手工画最左推导树。LL(1)分析器的每次产生式应用,本质上就是最左推导的一步展开。把分析过程记录成节点,连起来就是输入串的语法分析树。例如i+i*i的最左推导序列是:
E => TG => FMG => iMG => i*FMG => i*iMG => ... => i+i*i把程序的输出和这个推导序列逐行对比,能直接确认栈操作是否符合定义,预测表是否在每一步都选择了正确产生式。
我在每次做LL(1)语法分析实验时都会强制走一遍「轨迹打印+最左推导对照」的过程。虽然多花几分钟,但能省掉调试时的无数猜测,尤其是那种「看起来跑对了但输入一变就崩」的隐性问题。希望帮到你。
本文还有配套的精品资源,点击获取