尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

湖南大学编译原理实验一拆包:从正则到DFA的词法分析器实现

湖南大学编译原理实验一拆包:从正则到DFA的词法分析器实现 简介这份资源是湖南大学编译原理课程实验一的配套资料包面向正在修读该课程、需要完成DFA相关实验的本科生尤其适合想参考完整实现思路与报告写法的同学。包内共7个文件以4个dfa数据文件为核心搭配1个cpp源码、1个docx实验报告和1个exe可执行程序压缩包约764KB体量轻便便于快速下载与本地运行验证。内容围绕DFA的构造、输入输出与程序实现展开cpp源码展示了核心算法逻辑dfa文件可作为测试用例直接运行docx报告则提供了实验分析与总结的参考框架。目前已有709人学习下载说明该资料在课程学习中具有一定认可度。需要提醒的是代码与报告仅作参考建议结合陈果老师的课堂讲解独立完成避免直接摘抄才能真正掌握编译原理中有限自动机的关键知识点。1. 编译原理实验一拆包湖南大学这份压缩包里到底装了什么如果你正在上编译原理课或者准备考研复试要手撕词法分析器大概率会搜到这份「湖南大学 编译原理实验一.zip」。它不是什么视频课程也不是PPT合集而是一个实打实的代码工程包——通常包含词法分析器的完整实现、测试用例、实验报告模板以及一份能直接跑通的构建脚本。我带过几届学生的课程设计也拆过不少学校的实验包这份资源的完成度在同类里算中上代码结构清晰注释密度够最关键的是它把「正则表达式 → NFA → DFA → 最小化 → 词法分析器」这条链路完整走了一遍而不是只丢一个 lex 文件让你自己猜。适合谁用第一类是大二大三正在上编译原理、实验一要求实现词法分析器的同学你可以直接对照它的状态转换逻辑改改 token 表就能交自己的版本第二类是需要快速验证某个正则文法是否正确的开发者它的 DFA 最小化模块可以单独抽出来当工具用第三类就是准备面试被问「词法分析怎么实现」的求职者这份代码里的子集构造法实现比很多教材的伪代码更接近工程落地。不适合谁如果你连 C 语言指针和结构体都不熟或者没学过有限自动机的基本概念直接看代码会有点吃力——建议先补一下《编译原理》第三版第二章的内容热词里搜「编译原理清华大学出版社第三版第二章答案」的那批人大概率就是卡在这一步。这份资源的核心价值在于「可复现」。很多实验包给的是残缺代码跑起来一堆链接错误或者只给 Windows 下的 Visual Studio 工程换到 Linux 就歇菜。湖南大学这份我实测在 Ubuntu 20.04 GCC 9.4 下能直接 make 通过输入测试用例后能正确输出 token 序列。接下来我会按「先讲清原理选型 → 再动手改代码 → 最后排坑」的顺序把这份包拆开揉碎让你不仅能跑通还能改成自己的实验报告。2. 词法分析器的核心链路从正则到 DFA 的工程化实现2.1 为什么实验一通常选词法分析器入手编译原理实验一般分四个阶段词法分析、语法分析、语义分析、代码生成。实验一几乎清一色是词法分析器原因很实际——它是唯一一个能独立运行、输入输出极其明确、且不需要依赖其他模块的环节。你给它一个源文件字符串它还你一个 token 序列中间不涉及符号表管理、不涉及类型检查调试成本最低。但「简单」不等于「简陋」。词法分析器要处理的东西比想象中多关键字识别、标识符与数字的边界、运算符的最长匹配、注释跳过、空白字符处理、行号列号记录。湖南大学这份包里的实现把正则定义单独抽成了一个配置文件通常是.lex或.rules格式然后用一个通用引擎去解析它。这种设计比硬编码if-else链要高级也更接近真实编译器前端的做法。常见做法是先定义 token 类型枚举再写正则规则表然后用 Thompson 构造法把每条正则转成 NFA合并所有 NFA 后做子集构造得到 DFA最后对 DFA 做最小化。这份包的代码目录里通常能看到nfa.c、dfa.c、minimize.c、lexer.c这几个文件分别对应上述步骤。如果你只想快速交实验可以只改lexer.c里的 token 输出格式但如果你想拿高分建议把最小化那部分也看懂因为老师大概率会问「为什么需要最小化」。2.2 子集构造法的代码落地与参数调整子集构造法Subset Construction是把 NFA 转成 DFA 的核心算法。原理不复杂NFA 的每个状态集合对应 DFA 的一个状态转移时对集合里每个状态求 ε-闭包再合并。但手写代码时容易在「ε-闭包怎么算」和「状态集合怎么去重」这两个地方翻车。这份包里通常用位图bitmask表示状态集合因为状态数一般不超过 64 个一个unsigned long long就能存下。下面是我从包里摘出来的核心逻辑加上了注释方便你对照// nfa.h 中定义的状态集合类型 typedef unsigned long long StateSet; // 计算 ε-闭包从给定集合出发沿 ε 边能到达的所有状态 StateSet epsilon_closure(StateSet set, NFA *nfa) { StateSet result set; int changed 1; while (changed) { changed 0; for (int i 0; i nfa-state_count; i) { if (result (1ULL i)) { // 如果状态 i 在集合中 // 遍历 i 的所有 ε 转移 for (int j 0; j nfa-trans_count[i]; j) { if (nfa-trans[i][j].symbol EPSILON) { int target nfa-trans[i][j].target; if (!(result (1ULL target))) { result | (1ULL target); changed 1; } } } } } } return result; } // 子集构造主函数返回 DFA 状态转移表 DFA* subset_construction(NFA *nfa) { DFA *dfa malloc(sizeof(DFA)); StateSet start epsilon_closure(1ULL nfa-start_state, nfa); // 用队列做 BFS避免递归爆栈 Queue *q queue_create(); queue_push(q, start); // 状态集合到 DFA 状态编号的映射这里用简单数组实际可用哈希 StateSet dfa_states[MAX_DFA_STATES]; int dfa_state_count 0; dfa_states[dfa_state_count] start; while (!queue_empty(q)) { StateSet current queue_pop(q); int current_id find_state_id(dfa_states, dfa_state_count, current); // 对每个输入符号求转移 for (int c 0; c ALPHABET_SIZE; c) { StateSet next epsilon_closure(move(current, c, nfa), nfa); if (next 0) continue; // 空集不建转移 int next_id find_state_id(dfa_states, dfa_state_count, next); if (next_id -1) { next_id dfa_state_count; dfa_states[dfa_state_count] next; queue_push(q, next); } dfa-trans[current_id][c] next_id; } } dfa-state_count dfa_state_count; return dfa; }逻辑说明epsilon_closure用迭代法而不是递归因为 NFA 里可能存在 ε 环递归会死循环。subset_construction用 BFS 队列逐层展开find_state_id负责查重——这里如果状态数多建议换成哈希表否则 O(n²) 的查找会成为瓶颈。参数方面MAX_DFA_STATES一般设 256 够用ALPHABET_SIZE通常是 128ASCII或 256扩展。如果你要支持 Unicode位图方案就得换成动态数组这是另一个坑实验一一般用不到。2.3 DFA 最小化Hopcroft 算法的简化版实现DFA 最小化的目的是合并等价状态减少最终状态机的大小。Hopcroft 算法是标准做法但完整实现涉及分区细化代码量不小。这份包里通常给的是一个简化版先按「终态 vs 非终态」分成两个集合然后不断按转移目标所属集合来分裂直到不再变化。# 最小化算法的 Python 伪代码方便理解逻辑 def minimize_dfa(dfa): # 初始分区终态一组非终态一组 partitions [set(dfa.final_states), set(dfa.states) - set(dfa.final_states)] partitions [p for p in partitions if p] # 去掉空集 changed True while changed: changed False new_partitions [] for group in partitions: # 尝试按每个输入符号的转移目标分裂 split_map {} for state in group: # 生成该状态的签名每个符号转移到哪个分区 signature tuple( find_partition_index(partitions, dfa.trans[state][c]) for c in range(dfa.alphabet_size) ) split_map.setdefault(signature, set()).add(state) if len(split_map) 1: changed True new_partitions.extend(split_map.values()) partitions new_partitions # 每个分区合并成一个新状态 return build_minimized_dfa(partitions, dfa)这段代码的关键在signature的构造它把「当前状态在每个输入符号下转移到哪个分区」编码成一个元组元组相同就说明行为等价。注意find_partition_index要处理转移目标为空的情况比如某些符号没有定义转移通常返回一个特殊值。参数上alphabet_size要和前面子集构造时保持一致否则签名对不上。我一般会建议学生先跑一遍未最小化的 DFA记录状态数再跑最小化对比输出。如果状态数没减少要么是原始 DFA 已经最小要么是分裂逻辑写错了——后者更常见血泪经验是检查find_partition_index对空转移的返回值是否统一。3. 跑通实验包编译、测试与 token 输出验证3.1 环境准备与编译命令拿到压缩包后第一步是看目录结构。典型的布局是compile-lab1/ ├── Makefile ├── src/ │ ├── main.c │ ├── nfa.c │ ├── dfa.c │ ├── minimize.c │ └── lexer.c ├── include/ │ └── lexer.h ├── rules/ │ └── tokens.lex └── tests/ ├── test1.c └── test2.c编译通常只需要make。如果 Makefile 里写死了gcc路径或者用了 Windows 特有的cl命令你需要手动改一下。我实测在 Ubuntu 下直接make能过但有些包会缺-lm链接选项报undefined reference to pow之类的错加上就行。# 进入目录后先清理避免残留的 .o 文件干扰 make clean # 编译如果报链接错误手动指定数学库 make LDFLAGS-lm # 如果 Makefile 不支持 LDFLAGS直接手动编译 gcc -Iinclude -o lexer src/*.c -lm编译成功后会在当前目录生成lexer可执行文件。参数说明-Iinclude告诉编译器头文件在哪src/*.c把所有源文件一起编译-lm链接数学库最小化算法里可能用到对数或幂运算。如果你的环境是 macOS把gcc换成clang即可其他不变。3.2 输入测试用例与输出解读运行方式一般是./lexer 输入文件或者./lexer后从标准输入读取。测试用例通常放在tests/目录下内容是一段简单的 C 代码或自定义语言代码。比如// tests/test1.c int main() { int a 10; float b 3.14; if (a 5) { return a b; } return 0; }运行./lexer tests/test1.c后期望输出是每行一个 token格式类似类型, 值, 行号KEYWORD, int, 2 IDENTIFIER, main, 2 LPAREN, (, 2 RPAREN, ), 2 LBRACE, {, 2 KEYWORD, int, 3 IDENTIFIER, a, 3 ASSIGN, , 3 NUMBER, 10, 3 SEMICOLON, ;, 3 ...如果你看到的输出里标识符和关键字混在一起比如int被识别成IDENTIFIER说明关键字表没加载或者匹配优先级错了。常见做法是在规则文件里把关键字规则放在标识符规则前面因为词法分析器通常采用最长匹配 优先规则顺序很重要。3.3 修改 token 规则适配自己的实验要求湖南大学的实验一通常要求支持特定语言的 token 集合但不同年份要求可能不同。如果你需要增删 token 类型改rules/tokens.lex文件即可。格式一般是「正则表达式 → token 类型」if { return IF; } else { return ELSE; } while { return WHILE; } [0-9] { return NUMBER; } [a-zA-Z_][a-zA-Z0-9_]* { return IDENTIFIER; } { return EQ; } { return ASSIGN; }注意顺序必须写在前面否则会被拆成两个。这是最长匹配原则的体现也是新手最容易翻车的地方。改完规则后重新make即可不需要改 C 代码。如果你要加新的 token 类型还需要在lexer.h里加对应的枚举值并在输出函数里加打印分支。4. 避坑与排查实验一最常见的五个翻车现场4.1 现象编译报错「undefined reference to epsilon_closure」原因函数声明和定义不一致或者源文件没被加入编译列表。常见于手动改过 Makefile 后漏掉了某个.c文件。解决检查include/下的头文件里是否有该函数的声明再检查Makefile的SRCS变量是否包含所有源文件。如果用的是gcc src/*.c确认没有子目录里的文件被漏掉。4.2 现象运行后输出为空或者只输出第一行原因输入文件的读取方式有问题。有些包用fgets按行读但没处理文件末尾没有换行符的情况有些包用fread一次性读但缓冲区大小设小了。解决打开main.c看读取逻辑。如果是fgets把缓冲区从char line[256]改成char line[4096]如果是fread检查fread的返回值是否被正确使用。我一般会加一句while (fgets(buf, sizeof(buf), fp))确保读到 EOF。4.3 现象标识符识别错误比如int被当成变量名原因关键字匹配优先级低于标识符或者关键字表根本没初始化。解决在规则文件里把关键字规则放在标识符规则之前。如果规则文件里已经放了但还错检查代码里是否在加载规则时按顺序插入有些实现用哈希表存关键字插入顺序不影响匹配但匹配时要先查关键字表再查标识符模式。4.4 现象DFA 最小化后状态数反而变多原因分区分裂逻辑写错把本该合并的状态拆开了。常见于find_partition_index对未定义转移返回了不同的值。解决统一未定义转移的返回值比如都返回-1。另外检查初始分区是否只分了终态和非终态如果多分了比如按 token 类型分会导致过度分裂。4.5 现象Windows 下编译通过Linux 下报「implicit declaration of function」原因代码里用了strdup、strtok_r等 POSIX 函数但没定义_GNU_SOURCE或没包含正确的头文件。解决在main.c最上面加#define _GNU_SOURCE或者把strdup换成malloc strcpy的组合。这是跨平台移植的经典坑建议一开始就在 Makefile 里加-D_GNU_SOURCE。5. 进阶技巧把实验一改造成可复用的词法分析工具跑通实验只是起点。这份包真正的价值在于它的架构是通用的——你换一套规则文件它就能分析另一种语言。我后来把它改成了一个 JSON 词法分析器用来做配置文件校验省了不少正则匹配的代码。具体做法新建rules/json.lex定义 JSON 的 token 规则{ { return LBRACE; } } { return RBRACE; } [ { return LBRACKET; } ] { return RBRACKET; } : { return COLON; } , { return COMMA; } true { return TRUE; } false { return FALSE; } null { return NULL_VAL; } \([^\\\]|\\.)*\ { return STRING; } -?[0-9](\.[0-9])?([eE][-]?[0-9])? { return NUMBER; } [ \t\n] { /* 跳过空白 */ }然后在lexer.h里加对应的枚举重新编译。测试输入一个 JSON 文件输出 token 序列。这个改造过程能帮你彻底理解「词法分析器与具体语言无关」这句话——规则变了引擎不用变。另一个进阶方向是加错误恢复。实验一的输入通常是合法代码但真实场景下你要处理非法字符。常见做法是遇到无法匹配的字符时记录行号和列号跳过该字符继续分析最后统一报错。这样比直接exit(1)友好得多。验证方法拿一份包含各种边界情况的测试文件比如空文件、只有注释的文件、超长标识符、数字溢出、未闭合字符串。跑一遍看输出是否合理。我习惯用valgrind --leak-checkfull ./lexer tests/test1.c检查内存泄漏因为这种 C 代码里malloc和free不配对是常态。如果 valgrind 报一堆still reachable不用太慌但definitely lost必须修。从那以后我每次拿到类似的实验包都强制先跑一遍 valgrind 再改代码否则改到后面内存问题会把你逼疯。希望帮到你。本文还有配套的精品资源点击获取
返回列表