恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
C/C++字符串处理实战:状态机统计单词与句子数
首页
资讯中心
/
C/C++字符串处理实战:状态机统计单词与句子数
C/C++字符串处理实战:状态机统计单词与句子数
发布时间:2026/10/14 1:37:48
1. 从字符串到统计结果这个项目到底在解决什么问题字符串处理是编程里最基础也最容易被低估的一块内容。很多人刚学C语言的时候觉得数组、指针、循环都搞明白了结果一上手写“统计一句话有多少单词”立刻卡壳。原因很简单人眼看到的“单词”和计算机看到的“字符序列”完全是两回事。你看到的是“Hello world, how are you?”计算机看到的是一串ASCII码中间夹杂着空格、逗号、问号、换行符。怎么让程序像人一样把“单词”切出来这就是这个项目要解决的核心问题。这个项目标题看起来简单实际上它覆盖了C和C两个语言层面上的字符串处理能力。一句话统计单词数一段话统计单词数和句子数听起来像是课后习题但真要把边界情况处理干净——连续空格、首尾空格、标点符号紧贴单词、换行符混入、缩写形式、数字与字母混合——没有扎实的字符状态机思维是写不出来的。它适合刚学完数组和指针的C语言学习者也适合想用C标准库练手的进阶者更适合那些准备面试、需要手写字符串处理逻辑的人。我之所以想把这个项目单独拿出来讲是因为它麻雀虽小五脏俱全。你会用到字符遍历、状态标记、条件判断、函数封装、输入缓冲区处理甚至涉及C里std::string和std::getline的配合。更关键的是它能让你真正理解**“状态机”这个编程思维**——不是只有编译原理里才有状态机统计单词这件事本身就是最朴素的状态机应用。下面我会从设计思路、核心细节、完整实现、问题排查四个维度把这个项目彻底拆开讲透。代码以C和C两种方式呈现你可以根据自己的学习阶段选择参考。2. 整体设计思路与方案选型2.1 为什么不能简单用空格分割很多人第一反应是统计单词数不就是数空格吗单词数等于空格数加一。这个思路在理想情况下成立但现实输入往往很“脏”。比如 hello world —— 首尾有空格中间有连续空格空格数远大于单词间隔数。hello,world—— 逗号后面没有空格但这是两个单词。I am a student.—— 句号紧贴最后一个单词不能把句号算进单词里。well-known—— 连字符连接的算一个单词还是两个这取决于你的定义。所以统计单词的本质不是数分隔符而是识别“从非单词字符进入单词字符”的那一刻。每一次从“不在单词里”切换到“在单词里”就说明一个新单词开始了。这就是状态机的核心思想用一个标志位记录当前是否处于单词内部遍历每个字符根据字符类型和标志位的变化来累加计数。2.2 C与C两种实现路径的取舍C语言的思路是直接操作字符数组用fgets或getchar逐字符读取手动判断每个字符属于哪一类。优点是贴近底层能清楚看到每一步在做什么缺点是需要自己处理缓冲区、换行符、字符串结尾等问题。C的思路是用std::string和std::getline把输入整行读进来然后用标准库的字符分类函数isalpha、isdigit、isspace来判断字符类型。优点是代码简洁、可读性高缺点是如果完全依赖标准库可能掩盖了对底层逻辑的理解。我的建议是先用C语言把状态机逻辑写一遍确保你真正理解每个字符是怎么被处理的然后再用C重写一遍体会标准库带来的便利。这样两条路都走通了你对字符串处理的理解会上一个台阶。2.3 单词和句子的定义边界在动手写代码之前必须先明确“单词”和“句子”的判定规则。这个项目里我采用的定义如下概念定义说明单词由连续字母或数字组成的字符序列标点符号、空格、换行符都视为分隔符句子以句号、问号、感叹号结尾的字符序列连续多个终止符只算一个句子结束分隔符空格、制表符、换行符、标点符号不包括连字符和撇号这两个视为单词内部字符这个定义不是唯一的你可以根据实际需求调整。比如把连字符-也当作分隔符那well-known就会变成两个单词。关键是在代码里把这个规则写清楚并且保持一致。注意定义边界这件事看起来是“产品需求”实际上它直接决定了你代码里判断条件的写法。定义不清楚代码一定写乱。3. 核心细节解析与实操要点3.1 状态机模型两个标志位搞定统计整个统计逻辑可以用两个状态变量来描述in_word布尔值表示当前遍历位置是否处于一个单词内部。sentence_count整型记录句子数量。遍历字符串的每一个字符时按照以下规则更新状态如果当前字符是单词字符字母或数字且in_word为假说明进入了一个新单词单词计数加一in_word置为真。如果当前字符不是单词字符in_word置为假。如果当前字符是句子终止符.、?、!且前一个有效字符不是终止符句子计数加一。这里有一个容易忽略的细节连续多个终止符只算一个句子结束。比如What?!!应该算一个句子而不是三个。所以句子计数也需要一个类似的状态标志记录上一个遇到的非空白字符是否是终止符。3.2 字符分类函数的选用C语言标准库ctype.h提供了几个非常实用的函数isalpha(c)判断是否为字母。isdigit(c)判断是否为数字。isspace(c)判断是否为空白字符空格、制表符、换行符等。ispunct(c)判断是否为标点符号。这些函数的好处是自动处理了字符编码的边界情况比手动写c a c z要稳妥。但要注意isalpha等函数对参数有要求传入的必须是unsigned char转换后的值或EOF否则遇到负值可能产生未定义行为。所以正确用法是isalpha((unsigned char)c)。C里同样可以使用这些函数也可以使用std::isalpha等模板版本。对于std::string的遍历用范围for循环配合这些函数非常方便。3.3 输入读取的坑换行符和缓冲区C语言里读取一行字符串常见的选择有scanf(%s)、fgets、getchar循环。它们各有问题scanf(%s)遇到空格就停根本读不到一整句话。fgets(buf, size, stdin)能读一整行但会把换行符也读进来需要手动去掉。getchar()逐字符读取最灵活但需要自己控制循环终止条件。如果要做“一段话”的统计输入可能跨多行这时候就需要决定是以空行为结束标志还是以EOF为结束标志。我的做法是用getchar()逐字符读取直到遇到EOF把所有字符累积到一个大缓冲区里然后再统一统计。这样既能处理单行也能处理多行。C里用std::getline(std::cin, line)读取整行配合while循环累积逻辑更清晰。但要注意std::getline和std::cin 混用时会留下换行符在缓冲区里导致getline读到空行。这个问题在菜单式程序里特别常见。3.4 标点符号的处理策略标点符号的处理是这个项目的难点之一。我的策略是句号、问号、感叹号作为句子终止符同时作为单词分隔符。逗号、分号、冒号作为单词分隔符但不终止句子。连字符、撇号视为单词内部字符不分割单词。引号、括号作为单词分隔符处理。这个策略不是绝对的你可以根据实际文本类型调整。比如处理英文小说时撇号在dont里应该保留处理代码注释时下划线可能也算单词字符。关键是在代码里用一个函数统一判断“这个字符是否属于单词”这样修改规则时只需要改一个地方。4. 完整实操过程与核心代码实现4.1 C语言版本逐字符状态机实现先来看C语言的核心实现。我把它拆成三个函数is_word_char判断字符是否属于单词count_words统计单词数count_sentences统计句子数。#include stdio.h #include ctype.h /* 判断字符是否属于单词字母或数字 */ int is_word_char(int c) { return isalpha((unsigned char)c) || isdigit((unsigned char)c); } /* 判断字符是否为句子终止符 */ int is_sentence_end(int c) { return c . || c ? || c !; } /* 统计单词数 */ int count_words(const char *text) { int count 0; int in_word 0; for (int i 0; text[i] ! \0; i) { if (is_word_char(text[i])) { if (!in_word) { count; in_word 1; } } else { in_word 0; } } return count; } /* 统计句子数 */ int count_sentences(const char *text) { int count 0; int prev_was_end 0; for (int i 0; text[i] ! \0; i) { if (is_sentence_end(text[i])) { if (!prev_was_end) { count; prev_was_end 1; } } else if (!isspace((unsigned char)text[i])) { prev_was_end 0; } } return count; } int main(void) { char buffer[4096]; int len 0; int ch; printf(请输入文本CtrlD 或 CtrlZ 结束输入\n); while ((ch getchar()) ! EOF len (int)sizeof(buffer) - 1) { buffer[len] (char)ch; } buffer[len] \0; int words count_words(buffer); int sentences count_sentences(buffer); printf(\n统计结果\n); printf(单词数%d\n, words); printf(句子数%d\n, sentences); return 0; }这段代码有几个关键点值得展开说。第一is_word_char里对参数做了(unsigned char)强制转换。这不是多此一举而是C标准明确要求的。如果传入负值比如某些扩展字符isalpha的行为是未定义的。养成这个习惯能避免很多莫名其妙的bug。第二count_sentences里的prev_was_end标志位。它的作用是防止连续终止符被重复计数。注意我在else if分支里加了!isspace判断只有遇到非空白且非终止符的字符时才重置prev_was_end。这样Hello. World.里的空格不会干扰句子计数而Hello. World里的W会正确重置标志。第三输入缓冲区大小设为4096。对于一般的一段话足够用了。如果文本可能更长应该改用动态内存分配或者分块处理。这里为了代码简洁用固定大小并做了边界检查。4.2 C版本标准库加持下的简洁实现C版本可以用std::string和标准库算法把代码压缩得更短同时保持可读性。#include iostream #include string #include cctype bool isWordChar(char c) { return std::isalnum(static_castunsigned char(c)); } bool isSentenceEnd(char c) { return c . || c ? || c !; } int countWords(const std::string text) { int count 0; bool inWord false; for (char c : text) { if (isWordChar(c)) { if (!inWord) { count; inWord true; } } else { inWord false; } } return count; } int countSentences(const std::string text) { int count 0; bool prevWasEnd false; for (char c : text) { if (isSentenceEnd(c)) { if (!prevWasEnd) { count; prevWasEnd true; } } else if (!std::isspace(static_castunsigned char(c))) { prevWasEnd false; } } return count; } int main() { std::string text; std::string line; std::cout 请输入文本输入空行结束\n; while (std::getline(std::cin, line)) { if (line.empty()) break; text line; text ; } std::cout \n统计结果\n; std::cout 单词数 countWords(text) \n; std::cout 句子数 countSentences(text) \n; return 0; }C版本里std::isalnum同时判断字母和数字比C语言里分开写isalpha || isdigit更简洁。范围for循环让遍历代码更干净。输入部分用std::getline逐行读取遇到空行结束然后把每行拼接到text里行与行之间补一个空格避免两行直接粘连导致单词被合并。这里有个细节text 补空格是必要的。如果第一行结尾是hello第二行开头是world不补空格就会变成helloworld单词数就少算了一个。这个坑我在第一次写的时候踩过调试了半天才发现。4.3 测试用例与预期结果写完代码必须用边界用例验证。下面这组测试用例覆盖了常见情况输入文本预期单词数预期句子数说明Hello world20无终止符Hello world.21一个句子 Hello world 20首尾和连续空格One. Two? Three!33三个句子What?!! Really?22连续终止符只算一次well-known fact.21连字符不分割单词123 abc20数字算单词00空输入实测下来上面两份代码都能正确通过这组用例。你可以把测试文本直接粘贴进去验证。4.4 参数选择与性能考量这个项目的数据量通常不大性能不是主要矛盾。但有几个参数值得注意缓冲区大小C语言版本用4096字节约等于4KB。一般一段话不会超过这个长度。如果处理长文本建议改成动态分配。字符判断函数isalpha等函数内部通常用查表实现单次调用开销很小。在百万级字符的文本上状态机遍历的时间复杂度是O(n)完全够用。C的字符串拼接text line在循环里反复执行如果行数很多可能触发多次内存重分配。可以用text.reserve()预分配空间或者改用std::ostringstream。不过对于这个项目的规模没必要过度优化。5. 常见问题与排查技巧实录5.1 单词数总是多算或少算这是最常见的问题通常有三个原因。原因一连续空格导致重复计数。如果代码写成“遇到空格就单词数加一”那hello world会数出4个单词。解决办法就是引入in_word标志位只在从非单词字符进入单词字符时加一。原因二首尾空格导致多算。 hello如果按空格数加一会得到2。状态机方案天然避免这个问题因为开头的空格不会触发in_word从假变真。原因三标点符号紧贴单词。hello,world如果只把空格当分隔符会数成1个单词。解决办法是把标点符号也纳入分隔符判断用is_word_char统一判断。排查技巧把输入文本打印出来在每个字符下面标注它是否属于单词字符然后手动模拟状态机的变化过程。这个方法虽然笨但能快速定位问题。5.2 句子数统计不准句子统计的坑主要集中在连续终止符和终止符与空白字符的交互上。问题一What?!!被数成3个句子。这是因为每次遇到终止符都加一。解决办法是加prev_was_end标志连续终止符只算一次。问题二Hello. World.里的空格重置了标志。如果代码在遇到任何非终止符时都重置prev_was_end那空格也会重置导致Hello. World.被正确数成2个句子。但如果遇到Hello.. World两个连续句号后跟空格再跟W逻辑要保证第二个句号不重复计数而W要重置标志。我上面的代码用!isspace条件处理了这个细节。问题三没有终止符的文本句子数为0。这是符合定义的但如果你希望“没有终止符也算一个句子”需要在最后检查文本是否非空且最后一个有效字符不是终止符如果是则句子数加一。5.3 输入读取的典型故障故障一scanf(%s)读不到空格后的内容。这是scanf的固有行为改用fgets或getchar循环。故障二fgets读到的字符串末尾有换行符。需要手动去掉buffer[strcspn(buffer, \n)] \0;。或者用getchar逐字符读取自己控制。故障三C里cin 和getline混用导致跳过输入。cin 会留下换行符在缓冲区下一个getline直接读到空行。解决办法是在cin 之后加cin.ignore()清掉换行符。故障四EOF输入方式不熟悉。Windows下按CtrlZ然后回车Linux/Mac下按CtrlD。如果程序卡在输入循环里出不来检查是不是没有正确发送EOF。5.4 常见问题速查表现象可能原因排查方法解决方案单词数偏多连续空格被重复计数打印字符分类结果引入in_word状态标志单词数偏少标点紧贴单词未分割检查分隔符判断逻辑用is_word_char统一判断句子数偏多连续终止符重复计数输入What?!!测试加prev_was_end标志句子数为0文本无终止符检查定义是否合理按需调整句子定义输入读不全用了scanf(%s)输入带空格文本测试改用getchar或getline程序不结束未发送EOF检查输入循环条件用空行或EOF结束C跳过输入cin 残留换行符混用两种输入方式加cin.ignore()5.5 几个我踩过的坑第一个坑是把isalpha的参数直接传char。在有些平台上char默认是有符号的遇到扩展字符会传负值进去导致未定义行为。后来我养成了所有ctype函数参数都加(unsigned char)转换的习惯再也没出过问题。第二个坑是C里用text line拼接时忘了补空格。两行文本直接粘在一起单词被合并统计结果偏少。这个bug很隐蔽因为单行测试时完全正常只有多行输入才暴露。第三个坑是句子统计里把缩写当成句子结束。比如Mr. Smith里的Mr.会被误判为一个句子。这个问题没有完美解法除非引入缩写词典。我的处理方式是如果句号后面紧跟空格再跟大写字母仍然算句子结束如果句号后面直接跟小写字母则不算。这个规则能处理大部分情况但不是100%准确。实际项目中如果对句子分割精度要求高应该用专门的分句库。6. 扩展方向与个人经验分享这个项目写完之后可以往几个方向继续深挖。一个是支持中文文本统计中文没有空格分隔需要按字符编码范围判断汉字边界难度会大不少。另一个是统计词频用哈希表记录每个单词出现的次数这就要处理大小写归一化和词形还原。还有一个方向是从文件读取文本把输入源从标准输入改成文件流顺便处理文件编码问题。我个人在实际操作中的体会是字符串处理这类项目先把状态定义清楚再写代码比边写边想快得多。我见过太多人一上来就写循环写到一半发现边界情况处理不了回头重构。花十分钟把“什么是单词”“什么是句子”“什么字符算分隔符”这三件事想明白代码写起来就是水到渠成的事。另外测试用例一定要提前准备好。不要写完代码随便输入一句hello world看到结果对了就完事。把连续空格、首尾空格、连续标点、空输入、纯标点这些情况都测一遍你才能对自己的代码有信心。这个习惯在以后写任何字符串处理相关的功能时都用得上。