简介:这是一份基于C++与Qt实现的词法分析器工程,面向编译原理课程设计和编译器初学者,用来解决源代码分词与标记生成的问题。工具可以将源码文本读取拆分,得到关键字、标识符、数字、运算符等词法单元,并通过图形窗口展示分析结果。压缩包内共有三十个文件,涵盖六个C++源文件、四个头文件、界面定义文件、资源文件、自动机图文件、运行截图和课设报告,整体约571KB,结构清晰便于查找。核心逻辑实现于词法模块,主窗口负责交互,图形组件完成可视化,测试代码提供功能校验,课设文档记录设计流程。已有188人学习下载,该工程将抽象编译原理落地为可直接运行的代码,对理解词法分析状态转换、匹配规则以及图形界面整合很有帮助。
1. C++ Qt 词法分析器:不只是一把正则梭子,而是完整的自动机生产线
提到词法分析器,多数人第一反应是拿正则表达式匹配关键字、数字、标识符,匹配完就出 token。但这个项目不只是这样:它把整条编译原理链路都摆了出来——正则表达式先构造成 NFA,NFA 用子集构造法转成 DFA,DFA 再做最小化,每一步都生成 Graphviz 的 dot 文件,并用 Qt 的图形界面把 NFA、DFA、最小化 DFA 三张图直接画给你看。这意味着你下载的不只是一个能识别的工具,而是一套看得见状态转换过程的编译原理实验平台。它适合正在写编译原理课设的学生,也适合想搞清楚状态机如何落到 C++ 代码的开发者——尤其是你之前只写过递归下降但没碰过自动机构造,或者想看看 Qt 怎么把理论图渲染成界面的人。
2. 拆解 lex.cpp:记号定义、关键字表与 token 的完整扫描路径
2.1 记号类型与关键字表:先定语言,再写代码
词法分析器第一步不是写扫描函数,而是定义你要分析的语言到底有哪些记号类别。常见的做法是定义一个枚举类型,把关键字、标识符、整型常量、浮点常量、运算符、分隔符全部列出来,再给一个 EOF 标记和非法字符标记。这个项目的 lex.h 里应该就有一份类似的结构,下面是我在类似课设中的典型写法:
enum class TokenType { IDENTIFIER, // 标识符 INT_CONST, // 整型常量 FLOAT_CONST, // 浮点常量 KEYWORD, // 关键字 OPERATOR, // 运算符 DELIMITER, // 分隔符 EOF_TOKEN, // 文件结束 ILLEGAL // 非法字符 };这里把 KEYWORD 单独拎出来而不是并进 IDENTIFIER,有个实际好处:语法分析阶段拿到 token 后,不需要每次都用字符串比较来判断"这个标识符是不是 if",直接看 token 类型就行,效率高,代码也干净。
关键字表建议用std::unordered_map而不是一串if-else。你从源码里读到一个标识符后,先在哈希表里查一下,命中就是关键字,没命中就是普通标识符。词法分析是高频循环,每读一个 token 都要查一次,哈希表均摊 O(1),比写十几个strcmp清爽得多:
const std::unordered_map<std::string, TokenType> keywordMap = { {"int", TokenType::KEYWORD}, {"float", TokenType::KEYWORD}, {"if", TokenType::KEYWORD}, {"else", TokenType::KEYWORD}, {"while", TokenType::KEYWORD}, {"return", TokenType::KEYWORD}, {"void", TokenType::KEYWORD} };我见过不少人把关键字判断直接写进扫描循环里,用if (lexeme == "if")一路列到底,结果加一个关键字就要改一遍扫描函数,非常容易漏。拆成枚举加哈希表之后,加关键字只需要改 keywordMap 一处,扫描逻辑完全不用动,这就是解耦的价值。
2.2 状态转移表:DFA 的核心数据结构
词法分析器的扫描过程可以不走"逐字符 if-else",而是用一张状态转移表驱动。这张表的行是状态编号,列是输入字符(或字符类别),交叉点存放下一个状态编号。这样扫描函数就变成简单的查表循环:
std::vector<std::vector<int>> transitionTable;比如一个只认数字的状态子表,可以看得非常直观,0 表示非法,-1 表示接收态。实际工程里,这张表可以直接从项目里的 mindfa.dot 解析生成,也可以手工定义二维数组。状态机表驱动的好处是逻辑和数据结构分离——你改词法规则时只需要改表,不需要动扫描器主体。这个思想在语法分析里也一样,一张 LR 分析表顶得上几百行手写逻辑。
扫描函数的核心逻辑大体是这样的:
// state 初始为 0,即起始状态 int state = 0; std::string lexeme; while (state >= 0) { char c = peekChar(); if (c == EOF) { state = -1; break; } int col = charClass(c); // 把字符映射到列号 int next = transitionTable[state][col]; if (next < 0) { // 无法继续前进,当前 lexeme 作为一个 token 结束 emitToken(lexeme); lexeme.clear(); state = 0; // 回到起始状态 } else { lexeme.push_back(c); getChar(); // 消费掉当前字符 state = next; } }这段代码有几个关键点。charClass(c)把字符映射成列号,比如字母都映射到 0,数字映射到 1,运算符映射到 2,空白映射到 3,其它字符映射到 4。这样做是为了压缩状态表体积,否则你要给 ASCII 码 128 个字符各留一列,表会变得很大且大部分是冗余列。next < 0时说明当前状态没有接收这条边,token 到这里就该切断了。
有一点必须注意:到达接收态时不能立刻消费下一个字符,否则会把下一个 token 的首字符吞掉。常见做法是先把当前 token 记下来,然后回到起始状态重新扫描,下一轮循环再读新字符。这也是词法分析器最容易翻车的地方——边界处理不对,就会出现 token 错位、漏字符、死循环。
2.3 一次完整的 token 扫描:从源码字符到 token 流
把上面两段拼起来,完整的驱动逻辑是这样的:mainwindow 拿到源码字符串后,把它转换成字符流,逐字符喂给扫描器。扫描器维护一个当前位置指针pos,每消费一个字符就pos++。遇到空白、换行、注释直接跳过,不生成 token。遇到标识符开头字符就走标识符状态子集,遇到数字开头就走数字状态子集。识别完一个 token 后,把 token 的类型和 lexeme 打包送到 token 流里:
bool scanner::nextToken(Token& out) { // 跳过空白和注释 skipTrivia(); if (pos >= src.length()) { out.type = TokenType::EOF_TOKEN; return false; } char ch = src[pos]; if (isAlpha(ch)) return scanIdentifier(out); if (isDigit(ch)) return scanNumber(out); if (isOperator(ch)) return scanOperator(out); // 其他字符按分隔符或非法字符处理 return scanMisc(out); }skipTrivia()专门处理空格、换行、//注释和/* */注释。这里有个容易被忽略的细节:注释识别优先级要放在关键字识别之前,否则遇到//会被当成两个除号运算符。项目里如果把//当作单行注释处理,必须确认 lex.cpp 里是先检查//再检查/的,顺序反了,整个注释功能就废了。
这一章看下来你会发现,词法分析器的核心其实不是字符串匹配,而是状态机跳转。正则匹配方案看起来简单,但一个字符被重复消费、规则优先级混乱、修改规则后牵一发而动全身的问题会接踵而来。状态表驱动则把"规则"和"执行"分离,这也是项目里 lex.cpp 和 lexical.cpp 分文件的原因——lex 负责扫描执行,lexical 负责规则编译和表生成。
3. NFA、DFA 与最小化:三张 dot 图的生成逻辑与可视化思路
3.1 Thompson 构造:正则表达式如何变成 NFA
项目里有 nfa.dot、dfa.dot、mindfa.dot 三个 Graphviz 文件,对应的 nfa.jpg、dfa.jpg、mindfa.jpg 三张图,说明这个课设完整走了一遍"正则表达式 → NFA → DFA → 最小化 DFA"的经典路线。第一步是用 Thompson 构造法把正则表达式转换成带 ε 转移的 NFA。
Thompson 构造法的核心思想是:每一种正则表达式结构都有对应的 NFA 片段,拼装时通过 ε 转移把它们串起来。最基本的三个片段是:单个字符匹配、连接(ab)、选择(a|b),另外还有闭包(a*)。拿选择操作a|b来说,构造出来的 NFA 片段是这样的:
digraph NFA { start -> nfa_a; start -> nfa_b; nfa_a -> accept; nfa_b -> accept; }用一个新起始状态空转移分发到两个子 NFA,两个子 NFA 的接受状态再空转移汇聚到一个新接受状态。闭包a*则是新建两个状态,起始状态空转移到子 NFA 和接受状态,接受状态空转移回子 NFA,这样既能走零次也能走多次。这个过程可以用递归实现:先把正则表达式解析成语法树,再对语法树做后序遍历,每遇到一个节点就用 Thompson 规则扩展 NFA 的边集合。
实际编码时,NFA 可以用状态集合加边集合表示。边的结构体里至少要有起始状态、目标状态、转移字符(支持\0表示 ε):
struct NFAEdge { int from; int to; char input; // '\0' 表示 ε 转移 }; std::vector<NFAEdge> edges; std::vector<int> acceptStates;写到这里要提醒一点:Thompson 构造比其他建 NFA 的方法繁琐,但它的优势是完全机械化的,每一步跟着递归走就行,不容易出错。你要是试过直接手写 NFA,会发现状态编号容易乱,边也容易漏。用递归 + 边集合的方式,最终生成的 NFA 结构是确定性的,出问题也好排查。
3.2 子集构造法:NFA 到 DFA 的 ε-closure 与 move
NFA 转 DFA 用的是子集构造法。基本思路是:DFA 的每个状态对应 NFA 状态的一个集合;两个 NFA 子集如果发出的边和转移目标相同,它们就是同一个 DFA 状态。算法核心是两步操作——ε-closure 和 move。
ε-closure(T)表示从集合 T 中的状态出发,只靠 ε 转移能到达的所有状态的集合。move(T, ch)表示从 T 中的状态出发,通过字符 ch 能直接到达的状态集合。DFA 的构建流程是:从起始状态的 ε-closure 开始,记作 DFA 的起始状态;对每个 DFA 状态和每个输入字符,计算 move 结果的 ε-closure,得到新的 DFA 状态;重复直到不产生新状态为止。整个过程用一个队列加一个映射表就能实现:
// 简化版伪代码 std::vector<std::vector<int>> dfaTrans; std::vector<int> dfaAccept; std::map<std::set<int>, int> nfaSetToDfaState; std::queue<std::set<int>> workQueue; std::set<int> startClosure = epsClosure({nfaStart}); nfaSetToDfaState[startClosure] = 0; dfaTrans.push_back({}); workQueue.push(startClosure); while (!workQueue.empty()) { auto cur = workQueue.front(); workQueue.pop(); int curState = nfaSetToDfaState[cur]; if (hasAccept(cur)) dfaAccept.push_back(curState); for (char ch : alphabet) { auto nextSet = epsClosure(move(cur, ch)); if (nextSet.empty()) continue; if (nfaSetToDfaState.count(nextSet) == 0) { int newId = dfaTrans.size(); dfaTrans.push_back({}); nfaSetToDfaState[nextSet] = newId; workQueue.push(nextSet); } dfaTrans[curState][chIndex(ch)] = nfaSetToDfaState[nextSet]; } }这段代码的精髓就在那个nfaSetToDfaState映射表上。它用std::set<int>当 key,保证相同 NFA 子集只生成一个 DFA 状态。hasAccept(cur)里有一个细节:只要 NFA 子集里包含任意一个 NFA 接受状态,这个 DFA 状态就是接受状态,不是要求所有 NFA 状态都是接受状态,很多初学者在这里搞错。另外,字符集合alphabet必须提前确定好,一般取词法规则中出现过的所有字符,否则 move 计算没法遍历完整。
3.3 最小化与 dot 输出:Hopcroft 划分和 Graphviz 渲染
DFA 最小化常用 Hopcroft 算法,核心是状态划分:先把状态分成接受状态集合和非接受状态集合两个大组,然后反复检查每个组里的状态,看它们在每个输入字符下的转移目标是否落在同一个组内。如果某个字符导致同一组内的状态跳到了不同组,就把这组拆开。重复到所有组都不能再拆,每个组就是最小化 DFA 的一个状态。
给你一个直观的划分过程示例。假设某 DFA 有 4 个状态,接受状态是 {3, 4},初始划分为 P0 = { {1, 2}, {3, 4} }。检查输入字符a时,状态 1 通过 a 转移到 3,状态 2 通过 a 转移到 4;3 和 4 恰好都在接受组里,说明状态 1 和 2 在这个字符下行为一致,暂时不需要拆分。但如果状态 1 通过 b 转移到 1,而状态 2 通过 b 转移到 3,那么它们就会落到不同的组,必须拆开。这个过程是个不动点迭代,最终得到的每组状态可以合并成一个最小化 DFA 状态。
最小化之后输出 dot 文件是让课设可视化的一步。dot 格式本身很简单,就是声明节点和边:
digraph minDFA { rankdir=LR; node [shape=circle]; 0 [label="0", shape=doublecircle]; 1 [label="1"]; 2 [label="2"]; 0 -> 1 [label="a"]; 1 -> 2 [label="b"]; }其中双圈shape=doublecircle表示接受状态。项目里的 mygraph.cpp 大概率就是解析这类 dot 文本,把节点和边读出来画到 Qt 的绘图控件上。解析方法有两种:一是写个简单的文本解析器,读digraph块里的节点声明和边声明;二是直接用 Graphviz 的命令行工具把 dot 渲染成 jpg/png,再加载图片。从项目里有dfa.jpg、nfa.jpg、mindfa.jpg这些成品图来看,很可能是两条路都走了——预生成图片用于文档展示,而 mygraph.cpp 负责交互式绘制。
如果用 Qt 绘制而不是渲染图片,做法是在 QGraphicsScene 里添加 QGraphicsEllipseItem 表示状态节点,添加 QGraphicsLineItem 或 QGraphicsPathItem 表示转移边,再叠加 QGraphicsTextItem 标注字符。节点坐标是难点,因为 dot 文件里一般没有布局坐标,需要自己做层次布局。一个简化方案是把状态按编号排成环形或者横向分层,环形布局数学上简单,坐标计算就是x = cx + r * cos(2π * i / n)和y = cy + r * sin(2π * i / n),实际效果足够课设展示。如果想让图更专业,可以调用 Graphviz 的 layout 接口(如dot -Tplain)输出节点坐标再渲染,但那个工程量就大了,需要自行权衡。
4. Qt 界面与工程配置:把状态机跑成可视化工具
4.1 mainwindow.ui 与信号槽:界面如何驱动分析流程
这个项目在界面上不是随便糊一个文本框,而是把输入、分析、结果展示、图形可视化分区域组织。mainwindow.ui 用 Qt Designer 布局,常见的结构是左上方 QTextEdit 作为源代码输入区,一个"开始分析"的 QPushButton 作为触发按钮,中间 QTableWidget 或 QPlainTextEdit 展示 token 序列,右侧或下方一个 QGraphicsView 展示当前选中的 DFA / NFA 图。
按钮触发分析的信号槽连接是 Qt 开发的标准动作:
connect(ui->btnAnalyze, &QPushButton::clicked, this, &MainWindow::onAnalyzeClicked);槽函数onAnalyzeClicked里执行三步:取输入文本、调用词法分析器生成 token 流、把结果刷新到界面控件上。这里有一个工程性的建议:分析过程如果只涉及几千字符的源码文件,放 UI 线程里问题不大;但如果你要分析一个几 MB 的源码文件,必须在工作线程里跑词法分析,然后通过信号把结果回传到主线程刷新界面。否则界面会直接卡死,看起来像程序崩溃。后面避坑章节我会专门讲这件事。
4.2 QGraphicsView 显示 DFA:边与节点的绘制方案
mygraph.cpp 和 mygraph.h 这两个文件承担了图形化展示功能。把 dot 数据转换成 Qt 图形,涉及到 QGraphicsScene 的构建和自定义 QGraphicsItem 的使用。一个相对完整的流程是:
void MyGraphView::loadGraph(const QString& dotText) { scene->clear(); auto graph = parseDotText(dotText); // 解析节点和边 double radius = 220; QMap<int, QPointF> posMap; for (int i = 0; i < graph.nodes.size(); ++i) { double angle = 2 * M_PI * i / graph.nodes.size(); QPointF pos(graph.centerX + radius * cos(angle), graph.centerY + radius * sin(angle)); posMap[graph.nodes[i].id] = pos; auto* ellipse = scene->addEllipse(pos.x() - 18, pos.y() - 18, 36, 36, QPen(Qt::blue)); auto* text = scene->addText(QString::number(graph.nodes[i].id)); text->setPos(pos.x() - 8, pos.y() - 12); } // 遍历边,画箭头线 for (const auto& edge : graph.edges) { QLineF line(posMap[edge.from], posMap[edge.to]); scene->addLine(line, QPen(Qt::darkGray, 2)); } view->setScene(scene); }这段代码有几个值得关注的点。parseDotText是自定义解析函数,核心是正则提取数字 -> 数字 [label="字符"]这样的模式,可以用QRegularExpression实现。posMap保存每个状态节点的坐标,保证后续画边时能拿到两端的准确位置。环形布局虽然简单,但当状态数超过十几个时,节点和边的重叠会非常严重,这时你可以在addLine之前判断一下两个节点之间的直线距离,如果太近就略过这条边,或者把字体调小一点。真实课设里状态数一般不超过 20 个,环形布局完全够用。
4.3 两种构建方式:qmake 与 CMake 的切换
项目里既有lexical.pro又有cmake-build-debug/CMakeFiles,说明工程同时适配 Qt Creator 的 qmake 和 CLion 的 CMake 两种构建路径。这是很多 Qt 项目的常规操作:主工程文件给 Qt Creator,CMakeLists 给 CLion。lexical.pro的典型内容长这样:
QT += core gui widgets TARGET = lexical TEMPLATE = app SOURCES += main.cpp mainwindow.cpp lex.cpp lexical.cpp mygraph.cpp HEADERS += mainwindow.h lex.h mygraph.h RESOURCES += imges.qrc dots.qrcQT += core gui widgets这一行里 widgets 很关键,你是图形界面程序就必须有它;只写 core gui 编译出来会是一个没有控件库的控制台项目,跑起来一连接口就可能报找不到组件。RESOURCES指向.qrc文件,qrc 里面注册的是资源路径映射,比如把images/dfa.jpg映射成:/images/dfa.jpg,代码里加载图片时用QPixmap(":/images/dfa.jpg")就能取到,它会被编译进可执行文件的资源段里,发布时不需要单独带着 jpg 文件走。
如果在 CLion 里用 CMake 构建,CMakeLists.txt要显式调用 Qt 的包查找命令:
cmake_minimum_required(VERSION 3.16) project(lexical) find_package(Qt5 COMPONENTS Core Gui Widgets REQUIRED) add_executable(lexical main.cpp mainwindow.cpp lex.cpp lexical.cpp mygraph.cpp ) target_link_libraries(lexical Qt5::Core Qt5::Gui Qt5::Widgets )这里最容易翻车的是find_package阶段找不到 Qt 安装路径。解决方式是定义CMAKE_PREFIX_PATH环境变量指向你的 Qt 安装根目录,比如 Windows 下的C:/Qt/5.15.2/mingw81_64。CLion 的 CMake 配置页面里加一行 CMAKE_PREFIX_PATH 就行,不用改代码。先找到 Qt 库,后面编译链接才有戏,大部分 Qt 项目编译失败都是这一层没有配好,跟代码本身没关系。
5. 实战避坑:Qt 版本、构建路径与自动机算法的五个典型问题
5.1 现象:CLion 里 CMake 构建报 "cannot mix incompatible Qt library"
CLion 下用 CMake 构建项目,链接阶段报fatal: cannot mix incompatible Qt library (version ex50601) with this library。我当时见到这个第一反应是 Qt 库文件混了,但检查发现根本不是。原因是你系统里同时装了两个 Qt 版本,CMake 的缓存里缓存了某个版本,而你客户端编译用的编译器是另一套 ABI,导致 CMake 找到的库和你实际链接的库不一致。解决方式:删除cmake-build-debug目录下的CMakeCache.txt和整个 CMakeFiles 缓存,重新配置。另外确认 CMakeLists 里的find_package指定的版本与你安装的一致,比如统一到 5.15.2 就用find_package(Qt5 5.15 COMPONENTS ...)。从那以后我每次切换 Qt 版本,都强制删掉整个构建目录重新跑一遍 cmake,不跟缓存讲道理。
5.2 现象:运行时报 "could not find the Qt platform plugin linuxfb"
同样一套代码,在开发机上跑得好好的,交叉编译到嵌入式板子上就报qt.qpa.plugin: could not find the Qt platform plugin "linuxfb"。这是典型的平台插件缺失问题。Qt 的 GUI 程序启动时要加载 platform plugin 来决定怎么跟显示系统对接,你的程序发布包里没把platforms目录拷过去就找不到插件。解决方式:在可执行文件旁建一个platforms目录,把 Qt 安装目录里对应编译器的plugins/platforms下的libqlinuxfb.so(或qwindows.dll)拷进去,然后在代码里用QApplication::setLibraryPaths指定插件搜索路径,或者直接把插件目录放在 Qt 的库搜索路径里。树莓派交叉编译 Qt 时这个问题出现频率极高,优先级排在所有运行问题前面。
5.3 现象:NFA 转 DFA 时死循环,程序不结束也不报错
子集构造法实现时最容易死循环,现象是程序运行后 CPU 占满,界面转圈,但终端没有输出任何错误。原因基本出在 ε-closure 的实现上:计算 ε 闭包时用的是递归或者 BFS,但没有标记已经访问过的状态,遇到 NFA 中存在 ε 环(比如用 Thompson 构造a*时一定会出现 ε 环),就无限递归下去。解决方式:闭包函数里加一个 visited 数组,每个状态只入队一次:
std::set<int> epsClosure(std::set<int> states) { std::set<int> closure = states; std::queue<int> q; std::set<int> visited = states; for (int s : states) q.push(s); while (!q.empty()) { int cur = q.front(); q.pop(); for (auto& edge : edges) { if (edge.from == cur && edge.input == '\0') { if (visited.find(edge.to) == visited.end()) { visited.insert(edge.to); closure.insert(edge.to); q.push(edge.to); } } } } return closure; }核心就一句话:visited 集合必须包含初始状态,且每次插入状态时同步入队,绝不能让同一个状态被处理两次。这个坑我踩过之后,每次写 BFS 类算法都先检查 visited 标记是不是能覆盖所有入队时机,多花半分钟,省一小时。
5.4 现象:分析大文件时界面卡死,拖动窗口都跟不上
词法分析器本身没问题,分析一个几百 KB 的源码文件时界面完全冻住。原因是所有分析逻辑都跑在 UI 线程里,Qt 的事件循环被占满,控件就没办法响应重绘和鼠标事件。解决方式:把词法分析放到QThread工作线程里,分析完成后用信号把 Token 列表传回主线程更新界面。注意点在于 Token 列表这类自定义类型需要通过qRegisterMetaType<Token>()注册,否则queued connection传参时 Qt 不认识这个类型会直接报错。写 Qt 界面程序,凡是遇到超过几百毫秒的计算任务,都应该默认丢到工作线程里,这是 Qt 开发的基本素养。
5.5 现象:改了一个正则规则,所有状态编号全乱,图也乱了
你在正则表达式里增加一个新的关键字或者运算符,重新生成了 NFA 和 DFA,但 nfa.dot、dfa.dot、mindfa.dot 三张图全部乱套,节点编号对不上。原因是你没有把 NFA 构造、DFA 转换、最小化、dot 输出这几步串成一个流水线,而是手工去改 dot 文件里的状态编号。解决方式:写一个自动生成脚本,从规则定义开始,依次生成 NFA 状态集、DFA 状态集、最小化状态集,最后一次性输出三张 dot。确保每一步的编号都是程序生成的,不要手改。这个过程中我最大的体会是:状态编号本质上是自动机内部细节,你越想去动它就越容易乱,把它完全交给程序是唯一正确做法。
6. 用差分验证最小化:一份测试集把 DFA 和 minDFA 钉死在等价关系上
6.1 构造最小覆盖测试集
拿到项目之后,建议先不要直接看源码,而是先造一批测试用例。写一个tests.txt,每行是一条能够覆盖某类词法规则的程序片段,比如标识符、整数、浮点数、运算符组合、注释、非法字符。行数控制在 20 条左右,覆盖这三类:正确识别的输入、边界输入(空输入、单个字符输入)、非法输入(包含@、#等未定义字符)。然后把这份测试集分别喂给原始 DFA 和最小化后的 DFA,比对两者的接受状态。你不需要手动做,可以用一个循环程序来跑:
bool runDfa(const std::string& input, const std::vector<int>& acceptStates, const std::vector<std::vector<int>>& transTable) { int state = 0; for (char ch : input) { int col = charClass(ch); if (transTable[state].size() <= col) return false; state = transTable[state][col]; } return std::find(acceptStates.begin(), acceptStates.end(), state) != acceptStates.end(); }6.2 批量回归与随机字符串反驳
很多课设做到最小化 DFA 这一步就停了,觉得"算法跑通就算完"。但最小化是否正确,恰恰是最容易出错却最难发现的。一个更有效的做法是生成随机字符串集合,对每个字符串分别跑原 DFA 和最小化 DFA,只要有一条路径接受状态不一致,就说明最小化过程中状态划分有误。随机字符串生成时要控制字符集大小,比如只从{a, b, c, 0, 1}里取,长度从 1 到 8 递增,每组生成几百个。两套 DFA 行为等价才能算通过。如果发现不一致,把那条输入打印出来,手工检查状态划分到哪一步出的问题。这个方法本质是差分测试,效果远好于你盯着状态表看半天。从那以后我每次改词法规则,都强制走一遍"重新生成 NFA → DFA → 最小化 → 随机差分验证"全流程,一次图省事跳过验证,后来改出的 bug 让我花了两小时才定位回来。希望帮到你。
本文还有配套的精品资源,点击获取