尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
C/C++程序注释删除实战:手写状态机实现词法分析
1. 项目概述这道题到底在考什么1.1 题面理解不只是“把注释删掉”这么简单PTA平台上的“删除C/C程序中的注释”是一道非常经典的词法分析题。我把它“存档”是因为这道题看起来像是字符串替换的入门操作实际上却暗藏不少坑很适合用来检验自己对C/C语法细节的理解。题面通常这样描述输入一段C/C源代码输出删除所有注释后的代码。注释包括两种//开头的行注释以及/* */包裹的块注释。难点在于注释标志必须是在代码上下文里真正作为注释出现的而不是出现在字符串字面量或字符常量里的普通字符。举个例子下面这段代码const char* url http://example.com; // 这是个网址 int a /* 注释 */ 10; char c /; // 斜杠字符删除注释后程序的输出应该是const char* url http://example.com; int a 10; char c /;如果把http://example.com里的//当成注释开头处理输出就会变成const char* url http:这显然是错的。所以这道题表面上考“删除注释”实际上是在考词法分析中的状态转换。1.2 三个隐藏考点词法分析、状态维护、边界测试我在平台上刷这道题的时候发现它的通过率并没有想象中那么高很多人在第一轮提交时都会挂在“字符串、字符常量内的//和/*被误删”这个用例上。把它拆开看这道题至少有三个隐藏考点第一个是词法分析的基本功。你得理解C/C源码里注释、字符串、字符、预处理指令是不同词法单元不能靠简单的子串匹配去处理。第二个是状态维护。因为注释标志不是孤立的它是否生效取决于当前处于什么状态是在普通代码里、在字符串里、还是在字符常量里。状态之间的切换是顺序进行的必须逐字符扫描才能正确处理。第三个是边界测试。比如\转义字符导致的引号是否闭合、//注释是不是到行尾就结束、块注释跨行时换行符要不要保留、/单独出现时不能被误判成注释起始符。这些边界情况如果不专门设计测试用例很容易漏。所以这道题非常适合作为“手写一个迷你词法分析器”的入门练习。理解了它后面看编译原理的状态机、词法分析章节会轻松很多。2. 解题思路为什么逐字符扫描状态机才是正解2.1 直觉方案的坑正则替换一用就翻车很多人拿到题目后的第一反应是用正则表达式把//.*和/*.*?*/替换掉不就完了我一开始也这么试过结果一跑测试用例就翻车。原因是正则表达式在处理字符串和字符常量时特别脆弱。正则本质上是模式匹配它并不真正理解C/C的语法上下文。比如要处理char c 这样的字符常量里面的并不会开启字符串再比如\这个字符常量里面包含了一个单引号正则很难判断这个单引号是常量内容还是常量结束符。你当然可以写一个极其复杂的长正则试图覆盖所有情况但那种正则可读性差而且几乎必然在某些奇奇怪怪的输入上出错。更关键的问题是源码中的字符串常量本身可能包含//和/*比如URL、文件路径、注释标记文本。正则无法感知“当前是否在字符串内部”除非你额外写回调函数去逐字符扫描那其实已经是状态机的雏形了。所以与其硬撑着用正则不如从一开始就老老实实实现一个逐字符扫描器。2.2 状态机的五个状态详解状态机的核心思想是程序在扫描源码时每一时刻都处于某种“词法状态”中不同状态下遇到同一字符处理方式完全不一样。这道题最少需要五个状态普通代码状态NORMAL这是初始状态碰到普通代码字符就原样输出。当遇到/且下一个字符是/时进入行注释状态遇到/且下一个字符是*时进入块注释状态遇到双引号时进入字符串状态遇到单引号时进入字符常量状态。行注释状态LINE_COMMENT从//开始一直到换行符结束。期间的所有字符都应当被“删除”但换行符本身要保留否则下一行代码会拼接到上一行末尾破坏代码结构。块注释状态BLOCK_COMMENT从/*开始直到遇到*/结束。块注释可以跨越多行在这期间所有字符都应当被“删除”但换行符通常也要保留避免行号语义变化太大。字符串状态STRING进入条件是在普通代码状态下遇到。在字符串内部只有未转义的双引号会结束字符串其他字符包括//、/*、换行符等都应该原样保留。字符常量状态CHAR进入条件是在普通代码状态下遇到。在字符常量内部只有未转义的单引号会结束字符常量。之所以要把字符串和字符常量单独拆成两个状态是因为它们内部的结束字符和转义规则不同但处理逻辑非常相似。在实际设计里可以合并成一个IN_QUOTE状态再用一个变量记录当前是哪种引号。不过初学者最好分成两个状态避免混乱。2.3 一个关键设计注释必须替换成空格这道题还有一个特别容易踩的坑删除注释时如果直接把注释内容从输出中“抹掉”会让前后代码拼接在一起。比如源码里写的是int a/**/b;如果直接把/**/删掉输出就是int ab;。这在C/C里是完全合法的标识符但和原代码的含义完全不同。原意大概是int a b;可能声明两个变量也可能是一个乘法和一个变量名组合总之不能变成ab。很多PTA用例会专门测试这一点。正确做法是注释中的每个字符在输出中用一个空格代替如果注释中包含换行符则换行符本身仍然输出为换行符。这样可以避免单词/标识符被意外合并也能让代码行数尽量保持不变。同样的逻辑也适用于行注释。比如int a//comment\nb;如果行注释里的字符全部替换成空格就得到int a \nb;a和b之间隔着空格语义安全。3. 实操实现手写一个C版本的状态机3.1 状态定义与初始化下面我给出一个可以直接跑的C实现。这个实现不代表唯一解法但结构清晰、容易测试适合用来理解状态机的运作方式。#include iostream #include string using namespace std; enum State { NORMAL, LINE_COMMENT, BLOCK_COMMENT, STRING, CHAR }; string removeComments(const string src) { string out; State state NORMAL; int n src.length(); for (int i 0; i n; i) { char ch src[i]; char next (i 1 n) ? src[i 1] : \0; if (state NORMAL) { // 检查注释起始符 if (ch / next /) { state LINE_COMMENT; out ; // 用两个空格替换// i; // 跳过第二个/ continue; } if (ch / next *) { state BLOCK_COMMENT; out ; // 用两个空格替换/* i; // 跳过* continue; } // 进入字符串或字符常量 if (ch ) { state STRING; out ch; continue; } if (ch \) { state CHAR; out ch; continue; } // 其他字符原样输出 out ch; } else if (state LINE_COMMENT) { if (ch \n) { state NORMAL; out \n; // 保留换行符 } else { out ; // 注释内容替换为空格 } } else if (state BLOCK_COMMENT) { if (ch * next /) { state NORMAL; out ; // 用两个空格替换*/ i; // 跳过/ } else if (ch \n) { out \n; // 保留换行 } else { out ; // 注释内容替换为空格 } } else if (state STRING) { out ch; // 处理转义字符 if (ch \\) { if (next ! \0) { out next; i; } } else if (ch ) { state NORMAL; } } else if (state CHAR) { out ch; if (ch \\) { if (next ! \0) { out next; i; } } else if (ch \) { state NORMAL; } } } return out; } int main() { string src; // 实际场景中请用 getline 循环读取全部输入 string line; while (getline(cin, line)) { src line; src \n; } cout removeComments(src); return 0; }这段代码跑常规用例基本没问题。但我建议你别急着提交先自己动手改一改、测一测。因为PTA的测试点往往比你预想的更刁钻直接把代码背下来意义不大。3.2 主循环普通代码状态的判定细节普通代码状态的判定是整个程序的核心。你可能会想/后面不跟/或*时直接输出/不就行了吗确实如此。但需要注意这里有一个常见的简化写法导致的bug有人会写成if (ch /)然后在这个分支里判断下一个字符如果下个字符不是注释起始符就把ch输出。这个逻辑本身没错但如果你不小心在进入分支后提前i跳过下一个字符就会把除法表达式a / b里的/正常输出却把后面的空格或b丢掉了。我在实际测试中曾经遇到一个很隐蔽的错误把if (ch / next /)和if (ch / next *)分开写但第一个分支里已经i跳过了/然后第二个分支又判断当前ch是不是/这时候ch已经变成*了导致/*被误判。所以两个分支必须互相独立每个分支内只跳过一个字符。另外字符串和字符常量状态的进入条件必须是普通代码状态。如果当前已经处于行注释或块注释中遇到双引号、单引号都不能改变状态否则注释里的引号会让状态机误入字符串状态后面真正的字符串内容又会触发错误的行为。这一点在实现时一定要确认引号切换状态的分支放在NORMAL状态的处理器里而不是全局判断。3.3 主循环注释状态的清理逻辑行注释的处理逻辑相对简单从//之后一直到换行符之前所有字符都用空格替代换行符保留。这里有一个很细节的问题//所在的那一行末尾没有换行符比如文件最后一行那LINE_COMMENT状态会一直持续到循环结束。这种情况不需要特殊处理循环结束自然就停了但要注意后面如果拼接了其他内容不会残留状态。块注释的处理则要格外小心跨行。源码里可能出现/* 第一行 第二行 */在BLOCK_COMMENT状态下每个非换行字符替换成空格换行符要保持原样输出。这样最终输出的行数和原始代码行数一致后续调试和对比都更直观。还有一点当遇到*时不只是看它后面是否跟着/因为*在注释内容里可能正常出现比如/* a*b */。如果写成if (ch *)就输出空格并进入结束判断其实没有问题因为无论*后面是不是/这个*本身都是注释内容应当替换为空格。但如果你在这个分支里把*也原样输出那就错了注释内容不能保留。3.4 主循环字符串和字符常量的保护逻辑字符串和字符常量状态是这道题最容易出错的地方核心原因是转义字符。在STRING状态下遇到双引号时应该结束字符串状态回到普通代码状态。但是如果这个双引号前面有一个反斜杠\它就不是结束符而是字符串内容的一部分。比如abc\def第二个双引号是转义过的字符串并没有结束后面必须再遇到一个真正的未转义双引号才结束。处理办法是在字符串状态下遇到\时直接把\和它的下一个字符都输出并跳过这两个字符。这里要特别小心“跳过”和“处理”的顺序。上面代码里的实现是遇到\时先把\输出因为已经在循环开始时输出过ch了然后判断next把下一个字符也输出接着i跳过它。注意这里有个微妙之处因为进入字符串状态时当前字符已经输出了所以遇到\后需要额外输出next并跳过next而不是跳过\本身。我再解释得更直白一点循环每次处理一个字符进入STRING分支时第一行代码是out ch;这意味着ch已经被写入输出。如果ch是\那么下一个字符next是转义目标也应该被写入输出所以out next; i;让它不被下一次循环重复处理。对于CHAR状态逻辑完全一样只是结束符从双引号变成单引号。一个常见的例子是\这个字符常量的内容是一个单引号里面的\是转义序列。如果不处理转义遇到第二个就会错误地结束字符状态导致后续代码全部乱套。还需要注意C里允许字符串字面量跨行比如const char* s abc\ def;反斜杠加换行是续行符字符串并没有结束。这种情况下遇到\时next就是换行符\n应当原样输出并继续保持在STRING状态。我的实现里对这一点是天然支持的因为\后不管是什么字符都会直接输出并跳过它换行符不会触发状态切换。3.5 处理续行符与EOF等边界情况说到续行符其实还有一个边界情况值得讲普通代码状态下的续行。比如宏定义#define SUM(a, b) \ ((a) (b))这里反斜杠加换行不是字符串内容而是预处理指令的续行。对于删除注释这个任务来说我们不需要关心宏展开只需原样输出即可。所以普通代码状态下的反斜杠不需要特殊处理它会作为普通字符原样输出。但要注意如果反斜杠出现在//行注释的末尾它也不能让注释继续到下一行因为C/C的行注释就是到换行符结束反斜杠续行不适用于行注释。这一点很多初学者会搞混然而PTA的测试用例通常不会设计这么深的场景但了解总没有坏处。EOF情况主要是块注释没有闭合。PTA题目一般保证输入合法但我习惯在代码里多做一层保险如果块注释一直到文件结束都没看到*/那循环自然结束剩余字符全部被替换成空格。这个行为虽然不标准但至少不会崩。类似的还有行注释到EOF也不会崩。4. 常见问题与排错实录4.1 经典bug一字符串提前闭合导致注释误删这是我最初写这道题时犯的第一个错误。当时我把扫描逻辑简化成看到//就删看到/*就删别的一律输出。结果用例里有一个字符串常量const char* s http://a.com;输出直接变成了const char* s http:。后面的一大段全部被当成注释吃掉字符串根本没有闭合状态机完全失控。后来我发现代码里只要出现一个就要一直保持字符串状态直到遇到未转义的。这其实就是词法分析里说的“引号保护”。排查这类问题时我建议在调试输出里临时加上状态标记比如每次状态切换时打印一行日志。实际中我写了一个简单的跟踪版本把状态枚举值输出到cerr里交错着就能看出哪个位置状态切换不对。4.2 经典bug二转义字符里的引号与反斜杠组合解决了字符串保护后我又在新用例上栽了跟头。用例长这样char c1 \; char c2 \\; const char* s3 a\\\b;第一次看到\时我的第一反应是“这什么乱码”。实际上它表示一个值为单引号的字符常量第一个开启字符常量\是转义的单引号最后一个结束常量。如果我简单判断“遇到单引号就切换状态”那么在\中的第二个就会错误地把状态切回普通代码后面真正的结束符又会开启一个新的、多余的字符状态最终输出完全错乱。处理转义的关键就是在字符串/字符状态下遇到反斜杠时必须吞掉下一个字符。这个规则一定要记住不管下一个字符是引号、反斜杠、字母还是换行符都不能让状态机单独处理它。我用表格总结一下常见测试点和预期结果输入片段期望输出说明abcabc普通字符串原样保留a//ba//b字符串内的//不能当作注释a/*ba/*b字符串内的/*不能当作注释\\字符常量的转义单引号//字符常量内容是斜杠//\n\n行注释两个字符替换为空格换行保留/\*\n*/\n块注释跨行换行保留这张表是我后来整理的回归测试用例每次修改代码后都会跑一遍避免引入新问题。4.3 经典bug三换行符处理错位导致输出粘连还有一个很隐蔽的坑行注释或块注释把换行符删掉了。看这个例子int a; // comment int b;如果行注释状态下把换行符也替换成空格输出会变成int a; int b;虽然这看起来还是合法的C语句但行号信息变了而且如果原代码里某些地方依赖换行符比如预处理指令、宏定义结果可能会不一致。PTA的判题一般不是严格比较字符数而是比较最终输出字符串的精确内容如果你把换行符替换成空格输出就和标准答案不一致直接判错。所以注释中的换行符必须原样保留这一点在状态机的每个分支里都要一致处理。同样地块注释内的换行符也要保留。比如/* 第一行 第二行 */ int a;正确的输出应当保留块注释里那两个换行符只是把注释内容换成空格。这样输出里的int a;仍停留在原始的行号位置对比起来也更直观。4.4 其他技巧多行注释嵌套、重复斜杠、性能考量有人会问C语言标准不支持注释嵌套但测试数据里万一出现/* /* */ */怎么办按标准第一个/*开启块注释遇到的第一个*/就结束注释后面的*/会被当作普通代码输出。这意味着你不需要实现嵌套注释的判断。我在实现时就采用了这个标准行为也不建议去额外做嵌套计数因为那会引入额外的复杂度而且不符合C/C语义。重复斜杠的问题也要注意。比如源码里出现////四个斜杠按C/C语法前两个斜杠开启行注释后面两个斜杠是注释内容应该被替换成空格。我的状态机天然满足这个行为进入LINE_COMMENT后剩余字符全部按注释内容处理。性能方面这道题的数据量一般不超过几万字符逐字符扫描完全足够。不要用字符串拼接来不断构造out但要小心out ch的效率。C的std::string追加单字符通常很高效不需要额外优化。如果确实遇到超大输入可以预先out.reserve(src.size())避免多次扩容。这个优化不算必须但加上也没有坏处。再分享一个我实测过的技巧如果想验证输出和输入的差异可以用命令行的diff工具对比。把源码存成in.cpp运行程序输出out.txt再准备一份expected.txt然后diff out.txt expected.txt。这样能快速定位第一个不一致的位置比肉眼盯屏幕强太多。结尾一点个人经验这道题我在不同阶段写过三个版本。第一版是简单的字符串查找加删除结果被字符串内部的//打得体无完肤第二版加了引号保护但没有处理转义又挂在\上第三版老老实实画状态转换图把普通代码、行注释、块注释、字符串、字符这五个状态一一列出来才终于把测试点全部跑通。回过头看这道题最值得收获的不是“会写删除注释的程序”而是理解了一个道理处理任何带有上下文语法的文本都不能只看局部字符必须维护住当前的语义状态。这种状态机思想在后面的JSON解析、模板引擎、SQL解析里都会用到。如果你正在刷这道题我的建议是先别急着写代码拿笔在纸上画一下状态转换图把每个状态下遇到哪些字符会跳转到哪个状态写清楚。画完图代码基本就是按图施工。再过一遍四类边界场景字符串里的注释符、转义引号、注释跨行、注释替换为空格。这四类覆盖了PTA的绝大多数隐藏用例剩下的就是跑测试、看diff、修细节。最后留一个小练习给你能不能在现有代码的基础上让它在遇到未闭合的块注释时输出一行警告但不影响其他代码输出这个扩展点虽然PTA不会考但能帮你更深入理解词法分析器的错误恢复机制。我自己试过加这个功能踩了几个小坑但也因此搞明白了状态机在异常输入下应该如何优雅退出。希望你也能从这道题里拿到的不仅仅是一个AC成绩。
RELATED

相关推荐

污水处理站用施耐德TM218 PLC开发程序:流程、逻辑与避坑指南

污水处理站用施耐德TM218 PLC开发程序:流程、逻辑与避坑指南

污水处理站用施耐德TM218 PLC做程序开发,我来聊聊完整流程和踩过的坑我自己做过几个中小型污水处理站的电控项目,从早期的继电器控制一路做到PLC集中控制,接触施耐德TM218那批PLC也有五六年了。很多刚接触这个型号的朋友,一上来就…

📅 2026/10/19 8:11:15
openGauss在CentOS与openEuler上的完整安装指南与避坑实践

openGauss在CentOS与openEuler上的完整安装指南与避坑实践

把一个开源数据库老老实实装进 CentOS 或者 openEuler,说难不难,说简单也真不简单。openGauss 这几年在国产数据库里关注度一直很高,尤其是它从内核层面就针对企业级场景做了一堆优化,比如并行查询、备机强一致、内存表、数据压缩…

📅 2026/10/19 8:11:14
从哄到驯服:AI助手稳定落地的工程化实践

从哄到驯服:AI助手稳定落地的工程化实践

说起来有点丢人。我们那个AI助手在内部测试的时候,简直是人见人夸。同事问什么,它答什么,引经据典,头头是道。当时大家一致觉得这项目稳了,甚至开始畅想“AI客服替代人工”“AI助理全员上岗”的画面。结果真正推到客户…

📅 2026/10/19 8:06:14
MORE NEWS

更多资讯

📰

数据结构期末考什么?从一道综合题拆解复习重点与考场策略

1. 从一道期末题看数据结构到底在考什么每年一到期末季,数据结构这门课的讨论热度就会直线上升。我带的几届学生里,几乎所有人都会在复习阶段问同一个问题:这门课到底在考什么?是背概念,还是写代码?是记结论…

📰

GCC编译选项实战指南:从-O0到-O3的优化与调试

1. 为什么你需要重新认识GCC编译选项很多人第一次接触GCC,都是从一句gcc hello.c -o hello开始的。这条命令能跑通,于是很多人就默认自己“会用GCC”了。但真正到了要排查一个段错误、要定位一个只在特定优化级别下才出现的诡异Bug、要把一个几十万行的C…

📰

GCC编译选项实战指南:从调试到优化的高频选项详解

1. 为什么你需要吃透gcc编译选项很多人写C/C代码,编译命令永远是那一句gcc test.c -o test,能跑就行。直到某天遇到一个段错误,排查了整整两天,最后发现是编译时没开调试信息,core dump拿到手里连行号都看不到&#xf…

📰

内核启动早期打印:从原理到实战的完整实现指南

1. 从一行乱码说起:内核启动早期打印到底在解决什么问题如果你曾经在调试一块新板子,或者给一个嵌入式系统做底层适配,大概率遇到过这样的场景:串口工具打开,上电,屏幕上刷出来几行字,然后卡死。…

📰

Java开发者大模型应用转型指南:AutoGen与CrewAI多Agent实战

1. 为什么是这个时候转?Java开发者跨入大模型应用的真实动机与筛选逻辑先讲一个我最近遇到的事。一个写了五年Java后端的朋友来找我,手里已经用Spring Boot撸过一个客服工单系统,问我大模型应用到底值不值得转。我没急着回答,先问…

📰

MFC集成ChartCtrl实现实时波形显示与性能优化

简介:这份源码资源面向具备一定C基础的Windows开发者,尤其是需要在MFC工程中快速实现数据可视化的初学者与工程人员。它通过一个可运行的对话框程序,演示如何借助ChartCtrl控件完成波形数据的采集、绘制与实时刷新,解决传统绘图方…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬