简介:这份资源是面向计算机专业学生与编译原理学习者的C++课程设计项目,围绕根据文法自动生成LL(1)分析器展开,帮助理解自左向右扫描、仅查看一个输入符号的预测分析流程。包内共11个文件,以8个cpp源文件为核心,配合1个头文件与1个说明文档,另有许可证文件,压缩包约12KB,结构紧凑,便于直接编译运行与二次修改。内容覆盖文法解析、FIRST集与FOLLOW集构造、冲突检测、分析表生成、分析器实现及错误处理等关键环节,并借助STL容器与面向对象方式组织文法规则和分析表。已有406人学习下载,适合作为课程设计参考或编译器前端入门练手,读者可据此掌握从文法输入到自动生成分析表的完整思路,并借鉴其中的数据结构设计与排错方法。
1. 从一份 C++ 课设说起:LL(1) 分析器到底能帮你省下多少手工推导
如果你正在做编译原理课程设计,大概率绕不开一个经典题目:给定一组文法产生式,判断它是不是 LL(1) 文法,然后自动生成分析表并跑通一个输入串。手工推导 FIRST 集、FOLLOW 集、SELECT 集,再画预测分析表,文法稍微复杂一点就容易出错,改一个产生式就得从头再来一遍。这份基于 C++ 实现的 LL(1) 文法分析器,解决的正是这个重复劳动问题——你把文法规则写成文本喂进去,它自动完成预处理、FIRST/FOLLOW 集计算、冲突检测、分析表生成和输入串分析全流程。
它适合两类人:一类是正在赶编译原理课设、需要一份能跑通且结构清晰的参考实现;另一类是想搞明白 LL(1) 分析器内部数据流怎么组织的 C++ 学习者。整个项目按功能拆成了 preprocess、get_first、get_follow、generator、analyze 等独立源文件,不是一坨代码堆在 main 里,读起来能看清每一步在干什么。下面我从文法输入格式开始,把这份资源怎么用、参数怎么设、容易在哪翻车,按实操顺序拆一遍。
2. 文法预处理与数据结构:产生式怎么存、符号怎么分类
2.1 文法文本的输入格式与解析逻辑
这份实现里,文法通常以产生式文本形式输入,每条规则形如E -> T E',多个候选式用|分隔,空产生式用ε或特定标记表示。preprocess.cpp 负责把原始文本切成一个个符号,区分终结符和非终结符。常见做法是:大写字母开头的标识符视为非终结符,其余(小写字母、运算符、数字、括号等)视为终结符,ε单独处理为空串标记。
解析时最容易翻车的地方是分隔符处理。比如E->T|E'和E -> T | E'必须都能正确切分,否则后续 FIRST 集计算会拿到带空格的符号名,导致集合匹配失败。我一般会在预处理阶段统一做一次 trim 和符号规范化,把箭头统一成->,把连续空白压成一个空格,再按|拆分候选式。
// preprocess.cpp 中产生式解析的核心逻辑示意 struct Production { std::string lhs; // 左部非终结符 std::vector<std::string> rhs; // 右部符号序列,空串用 "ε" 表示 }; std::vector<Production> parseGrammar(const std::string& raw) { std::vector<Production> grammar; std::istringstream iss(raw); std::string line; while (std::getline(iss, line)) { // 去掉首尾空白和回车 trim(line); if (line.empty()) continue; // 按 "->" 切分左右部 auto pos = line.find("->"); std::string lhs = trim(line.substr(0, pos)); std::string rhsPart = trim(line.substr(pos + 2)); // 按 "|" 拆分候选式 std::vector<std::string> alts = split(rhsPart, '|'); for (auto& alt : alts) { Production p; p.lhs = lhs; p.rhs = tokenize(trim(alt)); // 按空格切符号,ε 单独识别 grammar.push_back(p); } } return grammar; }这段代码的关键点有三个:trim保证左右部没有多余空白;split按|拆候选式,每个候选式独立成一条 Production;tokenize把右部切成符号序列,遇到ε时存成单个标记而不是空 vector,这样后续 FIRST 集计算时能明确知道这是一条空产生式。参数上,lhs必须是非终结符,rhs里每个元素要么是终结符要么是非终结符,不能混入箭头或竖线残留。
2.2 符号表与集合容器的选型
符号分类完成后,需要建立非终结符集合和终结符集合。常见做法是用std::set<std::string>存符号,保证去重和有序遍历;FIRST 集和 FOLLOW 集用std::map<std::string, std::set<std::string>>,键是非终结符,值是该非终结符对应的符号集合。选std::set而不是std::vector的原因是集合运算(求并、求交、判断包含)用 set 自带的insert、count、set_intersection更直接,不用手写去重。
// common_header.h 中集合类型的定义 using SymbolSet = std::set<std::string>; using FirstMap = std::map<std::string, SymbolSet>; using FollowMap = std::map<std::string, SymbolSet>; // 全局符号分类 SymbolSet nonTerminals; // 非终结符集合 SymbolSet terminals; // 终结符集合 std::string startSymbol; // 文法开始符号,通常是第一条产生式的左部这里有个细节:开始符号的确定。一般取第一条产生式的左部作为开始符号,但有些文法会显式指定。如果你的课设要求支持显式指定,可以在输入格式里加一行START: E,预处理时单独解析。FOLLOW 集计算时,开始符号的 FOLLOW 集里必须加入结束符(通常用$或#表示),否则分析表在输入串末尾会查不到动作。
注意:符号命名不要用
ε作为普通终结符,否则和空串标记冲突。如果文法里确实需要表示空串,统一用ε,并在 tokenize 阶段单独识别。
3. FIRST 与 FOLLOW 集计算:递归不动点怎么收敛
3.1 FIRST 集的计算规则与迭代实现
FIRST 集的定义是:对任意符号串 α,FIRST(α) 是 α 能推导出的所有可能的开头终结符集合,如果 α 能推导出空串,则 ε 也在 FIRST(α) 中。对单个非终结符 A,如果存在产生式A -> X1 X2 ... Xn,则 FIRST(X1) 中所有非 ε 符号加入 FIRST(A);如果 X1 能推出 ε,则继续看 X2,以此类推;如果所有 Xi 都能推出 ε,则 ε 加入 FIRST(A)。
直接递归容易死循环,因为文法里可能有左递归或相互依赖。常见做法是迭代到不动点:反复遍历所有产生式,每次根据当前 FIRST 集更新,直到某一轮没有任何集合发生变化为止。
// get_first.cpp 中 FIRST 集迭代计算 void computeFirst(const std::vector<Production>& grammar, FirstMap& first) { bool changed = true; while (changed) { changed = false; for (const auto& p : grammar) { const std::string& A = p.lhs; // 处理空产生式 A -> ε if (p.rhs.size() == 1 && p.rhs[0] == "ε") { if (first[A].insert("ε").second) changed = true; continue; } bool allNullable = true; // 当前候选式是否所有符号都可空 for (const auto& sym : p.rhs) { if (terminals.count(sym)) { // 终结符直接加入 FIRST(A) if (first[A].insert(sym).second) changed = true; allNullable = false; break; } else { // 非终结符:把 FIRST(sym) 中非 ε 部分加入 FIRST(A) for (const auto& s : first[sym]) { if (s != "ε") { if (first[A].insert(s).second) changed = true; } } // 如果该非终结符不能推出 ε,则停止 if (!first[sym].count("ε")) { allNullable = false; break; } } } // 所有符号都可空,则 ε 加入 FIRST(A) if (allNullable) { if (first[A].insert("ε").second) changed = true; } } } }参数说明:grammar是预处理后的产生式列表,first是输出参数,键为非终结符。changed标志控制迭代终止,每轮只要有新符号加入就继续。allNullable用来判断当前候选式是否整体可空。这段代码的时间复杂度在最坏情况下是 O(迭代轮数 × 产生式数量 × 符号集大小),对于课设规模的文法(几十条产生式)完全够用。
3.2 FOLLOW 集的依赖关系与计算顺序
FOLLOW 集的定义是:对非终结符 A,FOLLOW(A) 是所有可能出现在 A 后面的终结符集合。计算规则有三条:开始符号的 FOLLOW 集包含结束符$;对产生式A -> α B β,FIRST(β) 中非 ε 符号加入 FOLLOW(B);如果 β 能推出 ε 或 β 为空,则 FOLLOW(A) 加入 FOLLOW(B)。
FOLLOW 集的计算依赖 FIRST 集,所以必须在 FIRST 集收敛之后再算。同样用迭代到不动点的方式,因为 FOLLOW 集之间也可能相互依赖。
// get_follow.cpp 中 FOLLOW 集迭代计算 void computeFollow(const std::vector<Production>& grammar, const FirstMap& first, FollowMap& follow, const std::string& start) { follow[start].insert("$"); // 开始符号加入结束符 bool changed = true; while (changed) { changed = false; for (const auto& p : grammar) { const std::string& A = p.lhs; for (size_t i = 0; i < p.rhs.size(); ++i) { const std::string& B = p.rhs[i]; if (!nonTerminals.count(B)) continue; // 只看非终结符 // 计算 β = p.rhs[i+1 ...] bool betaNullable = true; for (size_t j = i + 1; j < p.rhs.size(); ++j) { const std::string& sym = p.rhs[j]; if (terminals.count(sym)) { if (follow[B].insert(sym).second) changed = true; betaNullable = false; break; } else { for (const auto& s : first.at(sym)) { if (s != "ε") { if (follow[B].insert(s).second) changed = true; } } if (!first.at(sym).count("ε")) { betaNullable = false; break; } } } // β 可空或为空,把 FOLLOW(A) 加入 FOLLOW(B) if (betaNullable) { for (const auto& s : follow[A]) { if (follow[B].insert(s).second) changed = true; } } } } } }这里有个容易忽略的点:follow[A]在迭代过程中可能还没算完,但因为整体是迭代到不动点,后续轮次会继续传播,最终结果正确。参数start是文法开始符号,必须和预处理阶段确定的一致。如果开始符号搞错,FOLLOW 集里$会加错位置,分析表在末尾符号处直接查不到动作。
提示:调试 FIRST 和 FOLLOW 集时,先把结果打印出来和手工推导对一遍。常见错误是空产生式处理遗漏,导致 ε 没进 FIRST 集,进而 FOLLOW 集少算一批符号。
4. 分析表构造与冲突检测:什么时候文法不是 LL(1)
4.1 SELECT 集与预测分析表生成
LL(1) 分析表的核心是 SELECT 集。对产生式A -> α,如果 α 不能推出 ε,则 SELECT(A -> α) = FIRST(α);如果 α 能推出 ε,则 SELECT(A -> α) = (FIRST(α) - {ε}) ∪ FOLLOW(A)。分析表的行是非终结符,列是终结符加结束符$,单元格填产生式编号或动作。
构造时遍历所有产生式,对每条产生式计算 SELECT 集,然后把产生式填入对应单元格。如果某个单元格已经有产生式,说明冲突——同一个非终结符在同一输入符号下有两个候选动作,这个文法就不是 LL(1) 的。
// generator.cpp 中分析表构造与冲突检测 struct TableEntry { int prodIndex; // 产生式编号,-1 表示空 bool isError; // 是否冲突 }; std::map<std::string, std::map<std::string, TableEntry>> buildTable( const std::vector<Production>& grammar, const FirstMap& first, const FollowMap& follow) { std::map<std::string, std::map<std::string, TableEntry>> table; for (size_t idx = 0; idx < grammar.size(); ++idx) { const auto& p = grammar[idx]; SymbolSet select; // 计算 FIRST(α) bool nullable = true; for (const auto& sym : p.rhs) { if (sym == "ε") continue; if (terminals.count(sym)) { select.insert(sym); nullable = false; break; } else { for (const auto& s : first.at(sym)) { if (s != "ε") select.insert(s); } if (!first.at(sym).count("ε")) { nullable = false; break; } } } // α 可空,加入 FOLLOW(A) if (nullable) { for (const auto& s : follow.at(p.lhs)) { select.insert(s); } } // 填入分析表 for (const auto& term : select) { auto& cell = table[p.lhs][term]; if (cell.prodIndex != 0) { // 已有产生式,冲突 cell.isError = true; } else { cell.prodIndex = static_cast<int>(idx); } } } return table; }参数说明:prodIndex从 0 开始编号,0 表示空单元格,实际使用时可以用 -1 表示空、-2 表示冲突。isError标记冲突单元格。冲突检测的逻辑是:如果同一个单元格被两条产生式写入,就标记为冲突。实际课设里,冲突信息要打印出来,告诉用户是哪两条产生式在哪个符号上冲突,方便调整文法。
4.2 冲突类型与文法调整方向
LL(1) 冲突常见有两种:FIRST/FIRST 冲突和 FIRST/FOLLOW 冲突。FIRST/FIRST 冲突是指同一个非终结符的两条产生式,右部 FIRST 集有交集;FIRST/FOLLOW 冲突是指一条产生式右部可空,其 FIRST 集和该非终结符的 FOLLOW 集有交集。检测到冲突后,调整方向通常是提取左公因子或消除左递归。
提取左公因子:如果A -> αβ | αγ,改成A -> αA',A' -> β | γ。消除左递归:如果A -> Aα | β,改成A -> βA',A' -> αA' | ε。这两步做完再重新跑一遍 FIRST/FOLLOW/分析表,直到无冲突。
注意:消除左递归和提取左公因子会改变文法结构,产生新的非终结符。新非终结符的命名要避免和原有符号冲突,常见做法是加后缀
'或数字编号。
5. 避坑与排查:课设里最容易翻车的五个点
5.1 空产生式写成空字符串导致 FIRST 集漏算
现象:FIRST 集里没有 ε,FOLLOW 集少算一批符号,分析表在可空产生式处查不到动作。原因:预处理时把A ->或A -> ε解析成了空 rhs,后续判断p.rhs.size() == 1 && p.rhs[0] == "ε"不成立。解决:在 tokenize 阶段统一把空右部或ε转成单个"ε"标记,保证每条产生式的 rhs 至少有一个元素。
5.2 开始符号的 FOLLOW 集忘记加结束符
现象:输入串分析到最后一个符号时,分析表查不到动作,报错退出。原因:FOLLOW(startSymbol) 里没有$,分析表最后一列是空的。解决:在 computeFollow 开头显式执行follow[start].insert("$"),并确保 start 和预处理阶段确定的开始符号一致。
5.3 迭代计算不收敛或提前退出
现象:FIRST 或 FOLLOW 集结果不全,或者程序卡死。原因:changed标志更新逻辑写错,比如只在插入成功时更新但漏了某些分支;或者迭代终止条件写成固定轮数而不是集合变化。解决:每轮遍历所有产生式,任何集合插入成功都置changed = true,循环条件用while (changed),不要用固定轮数。
5.4 分析表冲突检测漏报
现象:文法明明有冲突,程序却认为无冲突,生成的分析表在运行时选错产生式。原因:冲突检测只在单元格已有产生式时标记,但初始值判断写错,比如用prodIndex != -1判断已有值,而初始值恰好是 -1。解决:明确初始值(比如 0 表示空),冲突时单独标记isError,不要依赖 prodIndex 的数值判断。
5.5 输入串符号切分与文法符号不一致
现象:分析器读入输入串后,符号匹配失败,明明文法里有这个终结符却查不到。原因:输入串按字符切分,而文法里终结符可能是多字符(如id、num),导致id被切成i和d。解决:输入串也按空格或逗号分隔的 token 切分,保证和文法里的终结符粒度一致。如果输入串是连续字符,需要先做词法分析切成 token。
6. 从能跑到好用:分析过程追踪与文法调试技巧
把分析表跑通只是第一步,真正做课设时,老师往往会要求你展示分析过程——每一步栈里有什么、当前输入符号是什么、选了哪条产生式。这份实现里 analyze.cpp 负责驱动分析,常见做法是用一个栈存符号,初始压入$和开始符号,然后循环读输入符号,查分析表决定展开或匹配。
// analyze.cpp 中带追踪输出的分析驱动 void analyze(const std::string& input, const std::map<std::string, std::map<std::string, TableEntry>>& table, const std::vector<Production>& grammar) { std::vector<std::string> stack; stack.push_back("$"); stack.push_back(startSymbol); std::vector<std::string> tokens = tokenizeInput(input); // 按 token 切分 tokens.push_back("$"); size_t pos = 0; int step = 0; while (!stack.empty()) { std::string top = stack.back(); std::string cur = tokens[pos]; printf("Step %d: stack top = %s, input = %s\n", ++step, top.c_str(), cur.c_str()); if (top == "$" && cur == "$") { printf("Accept!\n"); return; } if (terminals.count(top) || top == "$") { if (top == cur) { stack.pop_back(); ++pos; } else { printf("Error: expected %s, got %s\n", top.c_str(), cur.c_str()); return; } } else { auto it = table.find(top); if (it == table.end() || !it->second.count(cur)) { printf("Error: no action for %s on %s\n", top.c_str(), cur.c_str()); return; } const TableEntry& entry = it->second.at(cur); if (entry.isError) { printf("Error: conflict at %s on %s\n", top.c_str(), cur.c_str()); return; } const Production& p = grammar[entry.prodIndex]; printf(" apply: %s -> ", p.lhs.c_str()); for (const auto& s : p.rhs) printf("%s ", s.c_str()); printf("\n"); stack.pop_back(); // 逆序压栈,保证左部符号先处理 for (auto rit = p.rhs.rbegin(); rit != p.rhs.rend(); ++rit) { if (*rit != "ε") stack.push_back(*rit); } } } }这段代码的关键在于:栈顶是终结符时直接匹配输入;栈顶是非终结符时查分析表,找到产生式后弹栈并把右部逆序压入。逆序压栈是为了让最左符号在栈顶,下一步优先处理。追踪输出把每一步的栈顶和当前输入符号打出来,方便和手工推导对照。参数上,tokens末尾必须加$,和 FOLLOW 集里的结束符一致。
调试文法时,我习惯先把 FIRST 和 FOLLOW 集打印出来,和手工推导逐行对;再打印分析表,看每个非终结符行是否完整;最后跑几个典型输入串,包括正确串和错误串,确认错误处理能给出有意义的位置信息。如果分析表某一行大量为空,通常是 FOLLOW 集算漏了;如果某一列冲突,回去检查是否有左递归或左公因子没处理。
从那以后我每次拿到新的文法,都强制先跑一遍 FIRST/FOLLOW 打印,再手工验证两三个非终结符,确认集合没问题再往下走分析表。这个习惯帮我省掉了大量“分析表莫名其妙查不到动作”的排查时间。希望这份拆解能帮你把课设顺利跑通。
本文还有配套的精品资源,点击获取