news 2026/10/23 5:46:58

编译原理课设:手写DFA词法分析器完整版实现与调试复盘

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
编译原理课设:手写DFA词法分析器完整版实现与调试复盘

编译原理课的课设任务里,"词法分析器"往往是最容易被低估的老大难。我拿到"希冀编译原理词法分析器完全版"这个项目时,第一反应是:不就是把一串字符串拆成一个个token吗?可真到了在线评测系统上反复提交、一次次被红叉打回来之后,我才明白"完全版"这三个字的重量。

这篇文章,我把整个完成过程复盘一遍:从需求拆解、方案选型、核心代码实现,到评测调试和踩坑记录,尽量写清楚每一步背后的"为什么"。无论你是正在为编译原理课设发愁,还是单纯想弄清楚词法分析器到底是怎么工作的,这份记录应该都能让你直接照抄、少走弯路。

1. 项目到底要做什么:需求拆解与目标解析

1.1 词法分析器在编译器里的位置

几乎所有编译器教材都会画这样一张图:源程序 → 词法分析 → 语法分析 → 语义分析 → 中间代码生成 → 优化 → 目标代码生成。词法分析处于最前面,它的任务说穿了就是一件事:把源代码这个字符流,按规则切分成一个个有意义的"单词",也就是token。

你可以把它理解成中文分词。"今天天气真好"这句话,人一眼就知道切成"今天/天气/真好";但计算机不认识。词法分析器就是给计算机装上这种"切词"能力。它读入原始字符,输出一串token流,每个token包含两个关键信息:这是什么类型(关键字、标识符、数字、运算符还是分隔符),以及它对应的文本内容是什么。

为什么第一步这么重要?因为后面所有阶段的解析,都不再关心字符层面的事情,只针对token工作。哪怕是最简单的语法分析,如果词法阶段给出的token类型错了、漏了、或者多了,后面全盘崩溃。这也是评测系统考察"完全版"的原因:它要的不是一个能跑通hello world的玩具,而是一个覆盖完整token类型、能处理边界情况、遇到错误不崩掉的东西。

1.2 "完全版"三个字的真实含义

项目标题里的"完全版",我一开始没当回事,后来被评测系统教育得很深刻。它至少包含这四层意思:

第一,token类型要覆盖全。如果只识别标识符和数字,那叫半成品。一个像样的C语言子集词法分析器,至少得覆盖:关键字、标识符、数字常量(整数、浮点数,甚至还可能有科学计数法)、字符串常量、字符常量、运算符(算术、关系、逻辑、赋值、自增减)、分隔符(括号、分号、逗号、花括号等)。有些题目还会把注释也列入要求,虽然注释最终不产生token,但你必须正确跳过。

第二,规则要符合最长匹配原则。什么意思?遇到>=这个输入,你不能把它拆成>和=两个token,而要整体识别为>=。遇到++、+=、<<、&&这类连续字符组成的算符,都要按最长匹配原则处理。这是词法分析器评测用例里最喜欢考查的点,也是初次实现时最容易翻车的地方。

第三,要能处理各种边界情况。比如末尾没有分号的代码、数字后面紧跟字母、字符串中有转义字符、注释跨越了多行、空文件、只有注释没有代码的文件。评测系统不会跟你讲情面,所有正常输入和异常输入都会跑一遍。

第四,错误出现时不能直接崩。评测系统一般要求:遇到非法字符或非法格式,输出错误信息,然后尽可能继续分析后面的内容。这就要求代码里对错误路径有明确处理。

1.3 先弄清评测系统到底在比对什么

做这种课设项目,最重要的一步其实是先搞清楚最终交付物的格式。同一个词法分析器,不同平台要求的输出格式千差万别。我见过要求输出(类别编号, 文本)的,也见过要求输出<类别名, 文本>或者纯文本列表的。

所以动手写代码之前,我的建议是先确认三件事:

  • 输出一行token时,类型名是英文单词还是数字编号还是自定义简写?
  • 标识符和关键字是不是要分成两个类型输出?还是关键字也归入标识符、用附加标记区分?
  • 语法错误的输出格式是什么?要不要包含行号和列号?

我这份实现最终采用的输出格式是:每行一个token,格式为类型名 文本,例如KEYWORD if、IDENFR a。这个格式易于扩展,也方便对拍比对。如果你面对的评测系统有不同的格式要求,只需要改一个打印函数,后面我会专门讲这个适配点。

2. 方案选型:手写DFA和自动生成工具到底选哪个

2.1 两条技术路线的真实对比

词法分析器怎么实现,业内其实就两条路:一条是用现成的词法分析器生成工具,比如flex、lex;另一条是手写状态机。课堂上一般两边都会教,但课设环节你真正要提交的东西往往有讲究。

用工具生成的思路是:把token模式写成正则表达式,工具自动构造出对应的NFA、再确定化为DFA、最后生成C代码。优点非常明显,代码量小,模式好改;缺点是在评测环境里你可能根本没法安装工具,而且生成出来的代码不是"你的实现"——如果课设要求手写,直接交生成代码可能被判定为不符合要求。

手写状态机的思路是:自己写一个或多个识别函数,按字符逐个推进,用if/switch或者状态转移表的方式实现DFA逻辑。优点是可控性强、不依赖外部工具、代码完全是自己掌控的;缺点是模式越复杂,需要的状态和分支就越多,逻辑容易写乱。

我在这个项目里选择的是手写路径。原因只有两点:一是评测系统直接编译.c文件,我不想引入任何前置工具链;二是手写实现对token类型、错误恢复、输出格式的掌控度最高,遇到评测用例不通过时能快速定位问题。

2.2 手工DFA的核心思想:状态转移不复杂

听起来高大上,其实思维模型很简单。每个token的识别可以看成一个自动机:

  • 标识符:第一个字符是字母或下划线,后续字符是字母、数字或下划线,命中一个字符就继续留在"标识符中"这个状态。
  • 数字:以数字开头,后续可能继续是数字;如果遇到小数点且后面还紧跟数字,进入浮点数状态;如果遇到e/E,进入指数状态。
  • 运算符:从起始状态看当前字符,如果它是>,说明有可能出现>=或>>,需要再看下一个字符才能决定识别什么。

手工实现的关键就一个:用变量表示当前状态,每读入一个字符,根据当前状态与字符类型决定下一个状态。为了不把代码写成大型蜘蛛网,我建议把状态转移封装成几个独立的识别函数,而不是全局搞一个巨大的switch。

2.3 数据结构与Token类型设计

我先把基础数据结构固定下来,这决定了后面所有代码长什么样:

#include <stdio.h> #include <stdlib.h> #include <ctype.h> #include <string.h> typedef enum { TK_IDENTIFIER, // 标识符 TK_KEYWORD, // 关键字 TK_NUMBER, // 数字常量 TK_STRING, // 字符串常量 TK_OPERATOR, // 运算符 TK_DELIMITER, // 分隔符 TK_EOF, // 文件结束 TK_ERROR // 错误 } TokenType; typedef struct { TokenType type; char text[64]; int line; int col; } Token;

type保存token类别,text保存原始文本,line和col记录位置信息,用于报错和日志。很多评测系统并不要求输出行列号,但自己调试时非常有用。

2.4 输出格式做成一个独立函数

前面说过格式会变,所以我把打印逻辑单独提出来:

const char* tokenTypeName(TokenType type) { switch (type) { case TK_IDENTIFIER: return "IDENFR"; case TK_KEYWORD: return "KEYWORD"; case TK_NUMBER: return "NUMBER"; case TK_STRING: return "STRING"; case TK_OPERATOR: return "OPERATOR"; case TK_DELIMITER: return "DELIMITER"; case TK_EOF: return "EOF"; case TK_ERROR: return "ERROR"; default: return "UNKNOWN"; } } void printToken(Token t) { printf("%s %s\n", tokenTypeName(t.type), t.text); }

我这份实现把标识符和关键字分开输出,因为很多编译原理课设的语法分析阶段会单独拿这两类做判断。如果你的评测系统要求把它们合并,只需要在打印函数里做一次转换即可。

3. 核心识别函数的实现细节

3.1 输入管理与单字符读写

词法分析器从头到尾就是在做"读一个字符 → 判断 → 再读一个字符"的循环,所以输入管理部分是整个项目的地基。我见过有人用getchar()逐个读,这样代码也能跑,但遇到需要"往前看一个字符再回退"的场景会很痛苦,比如识别>=和>的差异时,读完>后发现后面是=,需要把=退回去或者保存状态。

我的做法是把整个源文件一次性读入内存,用一个全局指针扫描:

static char* source; static size_t pos; static int line = 1; static int col = 0; char peek_char() { return source[pos]; } char get_char() { char c = source[pos++]; if (c == '\n') { line++; col = 1; } else { col++; } return c; }

peek_char()只返回当前位置的字符但不推进指针,get_char()返回字符并推进。需要回退时,直接操作pos、line、col三个变量即可。这个设计让代码在边界处理上特别舒服。

读取整个文件的方法也很简单:

char* readSource(const char* path) { FILE* fp = fopen(path, "r"); if (!fp) { perror("open source failed"); exit(1); } fseek(fp, 0, SEEK_END); long size = ftell(fp); fseek(fp, 0, SEEK_SET); char* buf = (char*)malloc(size + 2); fread(buf, 1, size, fp); buf[size] = '\0'; fclose(fp); return buf; }

注意结尾加了一个'\0'哨兵字符。这样扫描到'\0'就认为是文件结束,不需要频繁检查pos是否越界。

3.2 标识符与关键字:先识别、后查表

大多数编译原理教材都会强调一个细节:关键字也是由字母组成的字符串,所以词法分析器遇到字母开头时,先把整个标识符扫出来,然后再查一张关键字表,判断它到底是保留字还是普通标识符。

这个顺序千万不能搞反。如果先判断"前缀是不是关键字",ifx会变成if+x两个token,这显然是错误的分词。

关键字表用静态数组即可:

static const char* keywords[] = { "break", "case", "char", "const", "continue", "default", "do", "else", "float", "for", "if", "int", "return", "void", "while" }; static const int keywordCount = sizeof(keywords) / sizeof(keywords[0]);

识别函数如下:

Token lexIdentifier() { int start = (int)pos; int startLine = line, startCol = col; while (isalnum(peek_char()) || peek_char() == '_') { get_char(); } int len = (int)(pos - start); if (len >= 64) len = 63; char text[64]; strncpy(text, source + start, len); text[len] = '\0'; for (int i = 0; i < keywordCount; i++) { if (strcmp(text, keywords[i]) == 0) { Token t = { TK_KEYWORD, "", startLine, startCol }; strcpy(t.text, text); return t; } } Token t = { TK_IDENTIFIER, "", startLine, startCol }; strcpy(t.text, text); return t; }

有一个细节:标识符的第一个字符必须是字母或下划线,不能在数字后面直接加字母,这个判断留给主循环去完成。另外C语言是区分大小写的,If不是关键字,只是普通标识符,查表时用strcmp严格相等就可以了。

3.3 数字常量:整数、浮点、科学计数法

数字识别是所有部分里最需要细心的地方,它涉及"什么时候允许读后续字符"和"什么时候必须回退"。

我按三个状态来设计:整数部分、小数点部分、指数部分。

Token lexNumber() { int start = (int)pos; int startLine = line, startCol = col; // 整数部分 while (isdigit(peek_char())) { get_char(); } // 浮点部分:只有小数点后紧跟数字才认为是浮点数 if (peek_char() == '.') { int savePos = (int)pos; int saveLine = line, saveCol = col; get_char(); // 吃掉 '.' if (isdigit(peek_char())) { while (isdigit(peek_char())) get_char(); } else { // 小数点后不是数字,说明 '.' 不是这个数字的一部分,回退 pos = savePos; line = saveLine; col = saveCol; } } // 指数部分:e/E 后允许有 +/-,然后必须有数字 if (peek_char() == 'e' || peek_char() == 'E') { int savePos = (int)pos; int saveLine = line, saveCol = col; get_char(); if (peek_char() == '+' || peek_char() == '-') { get_char(); } if (isdigit(peek_char())) { while (isdigit(peek_char())) get_char(); } else { // 指数格式不合法,回退到 e/E 之前 pos = savePos; line = saveLine; col = saveCol; } } int len = (int)(pos - start); if (len >= 64) len = 63; char text[64]; strncpy(text, source + start, len); text[len] = '\0'; Token t = { TK_NUMBER, "", startLine, startCol }; strcpy(t.text, text); return t; }

这个"先保存位置、发现不合适再回退"的操作,实际就是手工实现最长匹配。做这个项目时,我最初没有对1..2这种输入做处理,结果它把整个1.识别成数字,后面的.2当成以点开头的token,评测直接出现格式错乱。加上回退逻辑后,1..2会被正确拆成NUMBER 1、OPERATOR .、NUMBER .2这种相对合理的序列。虽然C语言本身不接受1..2,但词法分析器的职责是切分,不是判定语法合法性,遇到这种输入保证不崩溃就够了。

3.4 运算符与分隔符:最长匹配的考场

运算符识别是另一个把"最长匹配"考到极致的地方。=,==,+=,++这些都需要看下一个字符才能定。

我的做法是先准备一张双字符运算符表,遇到可能的开头字符时先查两字符组合:

static const char* twoCharOps[] = { "==", "!=", "<=", ">=", "&&", "||", "++", "--", "+=", "-=", "*=", "/=", "%=", "<<", ">>" }; int isTwoCharOp(const char* s) { for (int i = 0; i < sizeof(twoCharOps)/sizeof(twoCharOps[0]); i++) { if (strcmp(twoCharOps[i], s) == 0) return 1; } return 0; } Token lexOperatorOrDelimiter() { int start = (int)pos; int startLine = line, startCol = col; char two[3] = { peek_char(), '\0', '\0' }; if (source[pos + 1] != '\0') { two[1] = source[pos + 1]; if (isTwoCharOp(two)) { get_char(); get_char(); Token t = { TK_OPERATOR, "", startLine, startCol }; strncpy(t.text, two, 2); t.text[2] = '\0'; return t; } } char one = get_char(); Token t = { TK_OPERATOR, "", startLine, startCol }; t.text[0] = one; t.text[1] = '\0'; return t; }

这里有一个值得注意的坑:像.这种字符,既可能作为小数点参与数字,也可能作为成员访问运算符出现(如结构体访问),还可能只是一个独立的标点。因此运算符识别函数里不能把所有单字符都当成操作符处理,需要留一份分隔符表,如; , ( ) { } [ ] :,这些应该归类为TK_DELIMITER而不是TK_OPERATOR。具体的归类标准看你的课程规范,我建议把以下字符视为分隔符:; , ( ) { } [ ]。:视具体语言而定,我这份实现把它归为分隔符。

3.5 字符串常量与转义处理

如果题目要求支持字符串,那就必须在读字符串时处理转义字符。C语言里\"表示字符串中的引号,\\表示反斜杠,\n表示换行。如果不处理转义,遇到"a\"b"时,词法分析器会在第二个引号那儿提前结束,导致后面的字符全乱套。

字符串识别的基本逻辑:

Token lexString() { int start = (int)pos; int startLine = line, startCol = col; get_char(); // 吃掉开头的 " while (peek_char() != '\0') { char c = get_char(); if (c == '\\') { if (peek_char() != '\0') { get_char(); // 吃掉转义后面的字符 } } else if (c == '"') { int len = (int)(pos - start); if (len > 64) len = 64; char text[64]; strncpy(text, source + start, len); text[len] = '\0'; Token t = { TK_STRING, "", startLine, startCol }; strcpy(t.text, text); return t; } } Token t = { TK_ERROR, "unclosed string", startLine, startCol }; return t; }

这个函数遇到未闭合的字符串会直接返回TK_ERROR,让主循环进入错误处理分支。很多评测系统对字符串内的换行处理要求不同,有的允许字符串跨行,有的不允许,这需要看题目给的语法定义。我这份实现没有对字符串内换行做特殊限制,按"读入到闭合引号为止"处理。

3.6 注释与空白的过滤

空白字符(空格、制表符、换行)严格来说不属于任何token,必须跳过。注释也一样,//和/* */在词法分析阶段就是被丢弃的内容,但丢弃前要正确识别它们的边界。

void skipWhitespaceAndComments() { while (1) { while (isspace(peek_char())) { get_char(); } if (peek_char() == '/' && source[pos + 1] == '/') { while (peek_char() != '\n' && peek_char() != '\0') { get_char(); } continue; } if (peek_char() == '/' && source[pos + 1] == '*') { get_char(); // '/' get_char(); // '*' int closed = 0; while (peek_char() != '\0') { if (peek_char() == '*' && source[pos + 1] == '/') { get_char(); get_char(); closed = 1; break; } get_char(); } if (!closed) { // 遇到未闭合注释,输出错误信息并继续 fprintf(stderr, "line %d: unclosed comment\n", line); } continue; } break; } }

注释容易出的问题有两个:一是块注释跨越多行时,行号统计不能乱;二是在注释外层判断/*时,如果代码里同时有除号/,判断顺序必须严格。上面这段代码先检查//,再检查/*,最后才是普通字符,顺序对了就不会把/开头的运算符漏掉。

4. 把各模块串起来:完整主流程与代码拼装

4.1 主控制循环

有了各类识别函数,主循环就非常清爽了:

void tokenize(const char* path) { source = readSource(path); pos = 0; line = 1; col = 1; while (1) { skipWhitespaceAndComments(); Token t; char c = peek_char(); if (c == '\0') { t = (Token){ TK_EOF, "EOF", line, col }; printToken(t); break; } if (isalpha(c) || c == '_') { t = lexIdentifier(); } else if (isdigit(c)) { t = lexNumber(); } else if (c == '"') { t = lexString(); } else { t = lexOperatorOrDelimiter(); } printToken(t); if (t.type == TK_ERROR) { // 错误输出后继续,避免死循环 get_char(); } } }

注意最后那个get_char():当识别函数返回错误token时,必须保证至少消费一个字符,否则会出现相同的错误位置反复输出、死循环。如果你在lexOperatorOrDelimiter里对未知字符没有消费任何字符,这里就起到了兜底作用。

4.2 错误处理策略

错误处理的追求不是让程序绝对不出错,而是出错后还能继续跑。我的策略分三级:

  • 非法字符:比如源码里出现@、#、`这类没有明确定义的字符,把它作为一个TK_ERRORtoken输出,然后继续。
  • 未闭合注释:输出错误日志到标准错误流,但分析不中止。
  • 未闭合字符串:返回TK_ERROR,主循环消费一个字符后继续。

有些评测系统要求的是"遇到非法字符立即停止", 有的要求"继续分析"。这两种策略我都实践过,我的建议是:把错误出口封装成函数,方便一键切换行为。比如你可以在printToken里加一个全局开关,当某行错误出现时决定是否立即退出。

4.3 一个可以直接提交的主函数示例

int main(int argc, char* argv[]) { if (argc < 2) { fprintf(stderr, "usage: %s <source-file>\n", argv[0]); return 1; } tokenize(argv[1]); return 0; }

如果你需要从标准输入读取,把readSource的路径参数去掉,改用fread(stdin)即可。很多评测系统会直接调用程序并传入文件路径,也可能把源码重定向到标准输入,两种模式都支持会更稳妥。

5. 评测系统上的踩坑与排查

5.1 回溯问题:比想象中容易出错的边界

我在3.3节里提过的"保存位置再回退",是整个词法分析器里让我掉头发最多的地方。除了数字,运算符识别也同样需要回退。举个例子,输入是1 < = 2,如果代码在识别<时先偷看了后面的空格,发现不是<=,那这个空格并没有被消费,不会有问题。但输入是1 <= 2,识别到<后偷看下一个字符是=,这时必须消费两个字符,否则会漏掉=。

这个"偷看"和"消费"的平衡,本质就是DFA里的前瞻。我的代码里大量使用peek_char()和source[pos+1]来避免不必要的状态变量。

实测下来最稳妥的调试方式是:在开发阶段临时加一个"忠实打印每次读取字符"的函数,眼睛看着字符流出错时,能很快定位是哪里多消费了一个字符。等你跑通全部分支,再把这个调试输出删掉。

5.2 关键字的坑:大小写和下划线

有一类评测用例专门考察关键字与标识符的边界。比如:

intIf = 1;

正确的分词应该是IDENTIFIER intIf、OPERATOR =、NUMBER 1、DELIMITER ;。如果代码在读到int时就急着查关键字表,就会错误地切出KEYWORD int、IDENTIFIER If。这个问题就是识别顺序没掌握好。整个标识符必须先完整扫出来,再整体与关键字表比对,不要在中途截断。

还有一类隐蔽用例是_和__开头的标识符,很多语言规范允许,我在识别函数里也把它们作为标识符处理。如果你的课程定义下划线不能开头,就把主循环的c == '_'分支删掉,同时把它当作错误字符处理。

5.3 浮点数与点运算符的冲突

这个坑我在3.3节已经埋下伏笔。很多同学写的数字识别函数,一看到小数点就吞进去,结果遇到1..2、1.f、1.e2这类输入,输出的token完全不对。比如1.f,正确做法应该是先识别出数字1,再识别出点运算符.,最后识别出标识符f。如果贸然把1.整体识别成数字,就会把f吞掉或者产生一个非法数字。

我在数字识别里专门做了"小数点后必须有数字才算浮点数"的判断,就是为了应对这种情况。类似地,指数部分e/E后面也必须有有效数字,否则回溯。

5.4 注释未闭合与多行处理

很多评测系统会特意准备一个只有注释、没有任何代码的文件来测试。如果注释处理逻辑不完善,可能会输出一行错误信息,或者在文件末尾进入死循环。我建议对"只有/* hello没有闭合"的输入做专门测试。

未闭合块注释的处理可以自定义:有的评测系统要求忽略整个未闭合注释,有的要求报错但继续。我在实现里选择了打印错误到stderr、注释内容全部丢弃、分析继续执行,这样既不会阻塞主流程,也能留下报错线索。

多行注释特别注意行号列号更新。由于我在get_char()统一维护line、col,块注释跨行时行号会自动更新,不会出现后续token行号错乱的问题。

5.5 常见问题速查表

症状可能原因解决方案
输出结果比预期多一个token运算符识别时多消费了一个字符检查两字符运算符的匹配分支是否回退
数字后面跟字母变成两个token数字识别把字母吞了或者产生非法token数字识别函数遇到字母立即收尾,不做强制消费
关键字全部被识别成标识符关键字表没匹配或字母大小写不一致确认关键字表内容和语言规范一致
注释块导致程序卡死块注释未闭合且没有退出机制在未闭合分支里输出错误信息并强制跳出
错误token导致死循环出错时没有消费任何字符主循环里对TK_ERROR调用get_char()
行号列号全错换行时没有更新行列变量统一在get_char()中维护行列

5.6 一个容易被忽略的评测细节:空文件与纯空白文件

空文件或者只有空白的文件,评测系统也会测。正确的输出应该只有一个EOFtoken。如果你在主循环开头没有处理好peek_char() == '\0'的分支,空文件可能什么都不输出,或者出现意外的错误token。我的建议是无论什么输入,都保证有且只有一个EOF输出作为收尾。

6. 自测用例设计与提交建议

6.1 构造一个覆盖矩阵

我建议在正式提交前,建一个专门的测试目录,里面放上下列类型的输入文件:

  • 基础代码:包含变量声明、赋值、if/else、while、for、函数定义,确保常用token类型都覆盖。
  • 运算符密集:把==、!=、<=、>=、&&、||、++、--、+=、-=等全部写进一个文件,验证最长匹配。
  • 数字边界:整数、浮点数、科学计数法、指数前有正负号、小数点不接数字、数字后直接跟字母。
  • 字符串边界:含转义引号、转义反斜杠、空字符串、未闭合字符串。
  • 注释边界:行注释、块注释、多行块注释、注释里有除号和乘号、未闭合块注释。
  • 杂项:空文件、只有注释、只有空白、非法字符、超长标识符。
  • 综合样例:一个接近真实C代码的文件,比如冒泡排序、链表插入函数。

6.2 如何与参考输出对拍

如果你身边有同学已经通过评测的代码,或者课程提供了参考例子,最好的办法就是做对拍:同一份输入,你的输出和参考输出逐行比较。

没有参考代码时,我自己调试的做法是:把每一个测试文件的人工预期输出写成一个.txt,然后用diff命令和程序实际输出做比对。比如:

./lexer test/op.c > my_output.txt diff my_output.txt expected_op.txt

这个diff会在第一时间圈出差异行。方法论很简单:先保证小文件全对,再逐步扩大到复杂文件。不要一上来就对着大文件瞎调。

6.3 提交前的核对清单

最后提交之前,我再过一遍这份清单,每一项都是我用红叉换来的教训:

  • 程序是否能处理标准输入和文件输入两种模式。
  • 输出格式是否和题目要求完全一致,包括空格、换行、大小写。
  • 空文件和纯注释文件是否能正常输出EOF。
  • 错误时是否会死循环。
  • 所有源文件是否能一次性编译通过,是否需要额外的依赖库。
  • 边界token文本是否会被截断(我设的64字节上限,测试时最好包含一个超过64字节的标识符来验证)。
  • 是否有残留的调试输出污染了标准输出。

如果你面对的是在线评测系统,还有一个很重要的点:标准错误流和多标准输出流是分开的,我把注释未闭合、非法字符等错误信息输出到stderr,这样既不会污染token结果,也能在日志里看到问题。如果你的评测系统要求错误也输出到标准输出,就把fprintf(stderr, ...)换成printf(...)即可,但这个开关一定要提前确认。

最后再分享一个我在实际操作里体会很深的经验:不要试图一遍写出全对的核心代码,更值得花时间的,是把你生成的测试文件、预期输出和整个词法分析器一起做成一个小工程。哪怕评测系统不要求提交测试用例,这个过程也会让你对DFA、最长匹配、回溯这些抽象概念的理解深一个层次。这个项目做完之后,再往后看语法分析,很多纠结都会迎刃而解。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/23 5:45:48

PLC程序加密与访问级别配置:从密码体系到硬件绑定的工程实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/23 5:44:18

RAG文档切分优化:从LangChain到LangGraph的工程实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/23 5:43:18

Docker容器化部署入门与排错:镜像、数据卷、网络、编排实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/23 5:41:49

Linux TCP编程中errno与连接状态的5个硬核陷阱

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/23 5:41:09

Kubernetes Pod启动从90秒优化到8秒:本地Harbor私有镜像仓库加速实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/23 5:40:36

ESP32-S3量产交付:Windows一键烧录包完整方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华