1. 从数单词这件小事说起为什么值得认真对待很多人看到统计一句话中有多少单词、一段话里有多少单词和句子这个题目第一反应是这不就是入门练习题吗一个循环加几个判断就完事了。我刚开始接触字符串处理时也是这么想的直到在实际项目里被各种边界情况反复打脸才意识到这道题远没有表面看起来那么简单。这个需求的核心是字符串解析与计数逻辑用C和C两种语言来实现能非常直观地对比两种语言在字符串处理上的思维差异。C语言没有内置的字符串类型全靠字符数组和指针手动操作你得自己处理每一个字节C有std::string和std::stringstream很多脏活累活可以交给标准库。适合谁来参考刚学完数组和循环、想找一个综合练习的初学者以及想重新审视字符串处理细节的中级开发者。它解决的问题看似是计数实际上训练的是状态判断、边界处理和输入解析这三项基本功。我见过太多人写出来的版本只能处理hello world这种理想输入一旦遇到连续空格、首尾空格、标点符号夹杂、多个句子混在一起结果就全乱了。所以这篇内容不会只给你一个能跑的代码而是把每个判断背后的逻辑拆开讲清楚让你真正理解为什么这样写以及换个输入还对不对。关键词就三个单词统计、句子统计、C与C字符串处理围绕它们把这件事做扎实。2. 单词和句子的判定标准先把规则定死再动手2.1 什么才算一个单词在动手写代码之前必须先明确一个前提单词的边界到底怎么定义。这个问题不定义清楚代码写出来就是空中楼阁。最朴素的定义是被空白字符分隔开的连续非空白字符序列。空白字符包括空格、制表符\t、换行符\n、回车\r等。按照这个定义hello world中间有三个空格仍然是两个单词 hello前面有空格仍然是一个单词。但现实输入往往更复杂。比如hello, world!如果只按空白分割hello,和world!会被当成两个单词可逗号和感叹号显然不是单词的一部分。这时候就需要引入标点符号处理把标点当作分隔符或者先把标点替换成空格再统计。我在实际处理文本时通常采用字母数字算单词字符其他一律视为分隔符的策略这样dont会被拆成don和t虽然不完美但对大多数统计场景够用了。还有一种情况是连字符比如state-of-the-art。严格来说它是一个复合词但按分隔符逻辑会被拆成四个。到底算一个还是四个取决于你的业务需求。我的建议是在代码注释里明确写出你的判定规则这样别人看代码时不会产生歧义你自己回头维护时也不会忘。2.2 句子的边界比单词更微妙句子的判定通常以句号.、问号?、感叹号!作为结束标志。但这里有几个坑第一英文缩写里的句号比如Mr. Smith这个句号不是句子结束第二小数点比如3.14这个点也不是句子结束第三连续标点比如What?!这到底算一句还是两句。对于入门级实现我们通常采用简化规则遇到.?!就认为一个句子结束连续出现的只算一次。这样What?!算一句Hello. World.算两句。至于缩写和小数点的问题在基础版本里可以先不处理但你要知道这个局限性的存在。如果要做严肃的文本分析就得引入更复杂的规则比如判断句号前面是不是常见缩写词或者句号后面是不是紧跟数字。我在实际项目里踩过一个坑有段文本是The price is 3.14 dollars. Thats cheap.如果简单地按句号切分3.14里的点会被误判成句子结束导致句子数多算一个。后来我加了一个判断如果句号前后都是数字就跳过这个句号。这个逻辑不复杂但如果你一开始没考虑到测试时就等着被数据打脸吧。2.3 把规则翻译成状态机思维不管是单词还是句子统计的本质都是状态切换。你可以把遍历字符串的过程想象成在两种状态之间跳转对于单词统计状态是当前在单词内部和当前在单词外部对于句子统计状态是当前句子还没结束和刚遇到结束符。用状态机的思路写代码逻辑会非常清晰遍历每个字符根据当前字符的类型和当前状态决定是否要计数、是否要切换状态。这种思维方式不仅适用于这道题后面你做词法分析、协议解析时都会用到。下面两章我会分别用C和C把这套逻辑落地你会发现一旦状态定义清楚了代码就是水到渠成的事。3. C语言实现用指针和状态变量硬核拆解3.1 核心思路与变量设计C语言没有字符串类型我们拿到的是一个char数组或者char*指针。统计单词的核心变量就两个一个int count记录单词数一个int inWord标记当前是否在单词内部。遍历时如果当前字符不是空白且inWord 0说明刚进入一个新单词count并把inWord置为1如果当前字符是空白就把inWord置为0。这个逻辑看起来简单但有一个关键点判断不是空白的条件要写对。很多人用c ! 来判断结果遇到制表符和换行符就失效了。正确做法是用isspace()函数它能识别空格、制表符、换行、回车、换页等所有标准空白字符。或者你自己写一个判断把 、\t、\n、\r都包含进去。句子统计稍微复杂一点。我用的方法是遍历字符如果遇到.?!并且下一个字符不是这三个符号之一就认为一个句子结束句子数加一。这个下一个字符不是结束符的判断是为了处理What?!这种连续标点的情况避免重复计数。当然如果字符串末尾没有标点最后那个句子要不要算我的处理是如果最后一个非空白字符不是结束符就把句子数加一因为那是一个没有标点结尾的句子。3.2 完整代码与逐行注释#include stdio.h #include ctype.h #include string.h void countWordsAndSentences(const char *text) { int wordCount 0; int sentenceCount 0; int inWord 0; // 标记是否在单词内部 int len strlen(text); for (int i 0; i len; i) { char c text[i]; // 单词统计逻辑 if (!isspace(c)) { if (!inWord) { wordCount; inWord 1; } } else { inWord 0; } // 句子统计逻辑 if (c . || c ? || c !) { // 检查下一个字符是否也是结束符 if (i 1 len (text[i1] . || text[i1] ? || text[i1] !)) { continue; // 连续标点跳过等最后一个再计数 } sentenceCount; } } // 处理末尾没有标点的情况 if (len 0) { char last text[len - 1]; if (last ! . last ! ? last ! !) { // 检查最后一个非空白字符 int j len - 1; while (j 0 isspace(text[j])) j--; if (j 0 text[j] ! . text[j] ! ? text[j] ! !) { sentenceCount; } } } printf(单词数: %d\n, wordCount); printf(句子数: %d\n, sentenceCount); } int main() { const char *text Hello world. How are you? I am fine!; countWordsAndSentences(text); return 0; }这段代码里inWord变量是单词统计的灵魂。你可以试着把inWord去掉只用isspace判断会发现连续空格会导致重复计数。这就是状态变量的价值它记住了上一个字符是什么类型让你不用回头去看。句子统计里那个continue也值得说一下。当遇到连续标点时我们跳过当前字符让循环继续走到最后一个标点这样What?!只会在!处计数一次。如果不加这个判断?和!会各计一次结果就是两句显然不对。3.3 边界测试与常见翻车点写完代码一定要用边界数据测试。我常用的测试集包括空字符串、全是空格、首尾都有空格、连续多个空格、只有标点没有单词、末尾没有标点、连续标点。下面这个表格是我实测时发现的问题和修复方式输入预期单词数预期句子数常见错误00数组越界访问 00单词数算成1hello11句子数算成0hello world21单词数算成3hi. bye!22句子数算成1What?!11句子数算成2最容易翻车的是空字符串。如果你在末尾处理时直接访问text[len-1]而len是0就会越界。所以我在代码里加了if (len 0)的保护。另一个坑是hello这种没有标点的输入如果不做末尾处理句子数会是0但显然它应该算一句。提示C语言里字符串操作一定要时刻关注长度和边界strlen返回的是size_t类型和int比较时要注意符号问题。我一般会显式转换成int避免隐式转换带来的警告。4. C实现用标准库把复杂度降下来4.1 stringstream的妙用与局限C处理这个问题第一反应应该是std::stringstream。它可以把字符串当成输入流用运算符自动按空白分割连续空格会被自动跳过首尾空格也不影响。代码写起来非常简洁#include iostream #include sstream #include string int countWords(const std::string text) { std::stringstream ss(text); std::string word; int count 0; while (ss word) { count; } return count; }这段代码几行就搞定了单词统计而且天然处理了连续空格和首尾空格。但stringstream有一个局限它把标点当成单词的一部分。hello, world!会被分成hello,和world!两个单词逗号和感叹号没有被剥离。如果你需要更精确的单词定义就得在之后手动清理标点或者换一种解析方式。我的做法是先用stringstream分割然后对每个word做一次过滤把首尾的非字母数字字符去掉。如果去掉后变成空字符串就不计数。这样hello,会变成helloworld!会变成world统计结果就准确了。当然如果标点在单词中间比如dont这种简单过滤就无能为力了需要更复杂的规则。4.2 手动遍历版本更精细的控制如果你想要完全掌控解析过程C也可以像C一样手动遍历。区别在于你可以用std::string的成员函数比如size()、at()、isalpha()等代码更安全。下面是我常用的一个版本#include iostream #include string #include cctype void analyzeText(const std::string text) { int wordCount 0; int sentenceCount 0; bool inWord false; for (size_t i 0; i text.size(); i) { char c text[i]; // 单词统计字母或数字算单词字符 if (std::isalnum(static_castunsigned char(c))) { if (!inWord) { wordCount; inWord true; } } else { inWord false; } // 句子统计 if (c . || c ? || c !) { if (i 1 text.size() (text[i1] . || text[i1] ? || text[i1] !)) { continue; } sentenceCount; } } // 末尾无标点处理 if (!text.empty()) { size_t j text.size() - 1; while (j 0 std::isspace(static_castunsigned char(text[j]))) j--; char last text[j]; if (last ! . last ! ? last ! !) { sentenceCount; } } std::cout 单词数: wordCount \n; std::cout 句子数: sentenceCount \n; }这个版本和C版本逻辑几乎一样但有几个改进用std::isalnum判断单词字符比单纯判断非空白更精确能自动把标点排除在单词之外用size_t遍历避免有符号无符号比较的警告用std::string管理内存不用担心越界。注意std::isalnum的参数要转换成unsigned char这是C标准里的要求。如果直接传char遇到负值比如某些扩展字符会出问题。这个细节很多人不知道但在处理非ASCII文本时就会暴露出来。4.3 两种C方案的取舍stringstream方案代码短、可读性好适合快速实现和大多数日常场景。手动遍历方案控制精细能处理标点、能自定义单词规则适合对准确性要求高的场景。我在实际项目中会根据需求选择如果只是粗略统计用stringstream如果要做文本分析用手动遍历。还有一个折中方案用stringstream分割然后用std::remove_if或者正则表达式清理标点。C11之后正则表达式用起来很方便但性能比手动遍历差不少。如果你的文本量很大比如几百万字手动遍历的优势就体现出来了。我实测过处理一百万字文本手动遍历比正则快三到五倍。5. 那些只有踩过才知道的坑5.1 中文和全角符号的干扰如果你的输入里包含中文情况会变得复杂。中文字符在UTF-8编码下占三个字节每个字节的值都大于127。如果你用char逐个遍历会把一个汉字拆成三个字符统计结果完全错乱。更麻烦的是全角标点比如中文句号。和全角问号它们的编码和英文标点完全不同用c .根本匹配不到。处理中文文本要么用宽字符wchar_t要么用专门的UTF-8解析库。如果只是简单统计我建议先把中文标点替换成对应的英文标点或者把中文标点也加入判断条件。但要注意UTF-8下你不能用单个char去匹配中文标点得用字符串匹配。这个坑我在处理混合文本时踩过当时统计出来的句子数比实际少了一半排查了半天才发现是中文句号没被识别。5.2 连续标点与省略号英文里的省略号...是三个点按我的规则会被当成一个句子结束因为连续标点只计一次。这通常没问题。但如果是Wait... What?省略号后面跟问号中间有空格我的规则会把它当成两句。这到底对不对取决于你的定义。如果省略号表示语气停顿而非句子结束那应该算一句。这种语义层面的判断靠简单规则很难做对。我的处理方式是在文档里明确写出规则并接受它的不完美。文本分析没有绝对正确的答案只有适合当前场景的答案。如果你要做严肃的NLP任务那就该上专门的工具库而不是手写状态机。5.3 性能与内存的权衡统计单词和句子本身是O(n)复杂度遍历一遍就完事性能不是瓶颈。但如果你要处理超大文件比如几个G的日志就不能一次性读入内存了。这时候需要流式处理每次读一块维护跨块的状态变量。比如一个单词刚好被切在块边界上你得把前半截存下来和下一块拼接。我在处理日志文件时用过这个方案定义一个缓冲区每次读4KB用一个std::string保存未完成的单词读到空白时再结算。这样内存占用恒定能处理任意大小的文件。代码会比一次性处理复杂一些但思路是一样的只是多了跨块状态的维护。注意流式处理时句子统计也要考虑跨块问题。如果一个句子刚好被切在块边界你得把状态传递到下一块。我通常用一个bool标记上一块是否在句子中间下一块开始时根据这个标记决定是否要合并计数。6. 从练习到实用还能怎么扩展6.1 统计词频而不只是词数单词统计的进阶版是词频统计也就是每个单词出现了多少次。这时候光计数不够了需要一个映射结构。C语言里可以用哈希表或者简单的数组加线性查找C里直接用std::mapstd::string, int或者std::unordered_map。unordered_map基于哈希平均O(1)插入和查找适合大数据量map基于红黑树有序但稍慢。用stringstream做词频统计特别方便每读一个单词map[word]就完事了。但记得先做大小写归一化把Hello和hello当成同一个词否则统计结果会很分散。我一般用std::transform配合std::tolower做转换一行代码搞定。6.2 支持多段落和复杂标点如果输入是多段文本段落之间用空行分隔你可能还想统计段落数。段落判定很简单连续两个换行符就算一个新段落。但要注意\r\n和\n的区别Windows和Unix的换行符不一样。稳妥的做法是先把\r\n统一替换成\n再按\n\n分割。复杂标点方面可以引入一个句子结束符集合把.?!以及中文的。都放进去遍历时查这个集合。这样扩展性更好以后想加新符号只需改集合不用改逻辑。C里可以用std::setchar或者std::string::find来判断。6.3 封装成可复用的工具函数最后一步是把这些逻辑封装成干净的函数或类方便在别的项目里复用。我通常会设计一个TextAnalyzer类提供countWords()、countSentences()、wordFrequency()等方法内部维护解析状态。这样调用方只需要传字符串拿结果不用关心内部实现。接口设计上我倾向于返回一个结构体包含所有统计结果而不是让调用方多次调用不同方法。这样一次遍历就能算出所有指标效率更高。结构体里可以放单词数、句子数、字符数、段落数甚至平均句长。这些指标组合起来对文本分析很有价值。struct TextStats { int wordCount; int sentenceCount; int charCount; int paragraphCount; double avgWordsPerSentence; }; TextStats analyze(const std::string text);这种设计在实际项目里很实用调用方拿到的是一份完整的文本画像而不是零散的数字。我在做内容分析工具时就是这么封装的后来加新指标只需要改结构体和内部实现调用方代码基本不用动。6.4 测试驱动先写用例再写代码如果你想让代码真正可靠我强烈建议先写测试用例再写实现。把各种边界情况列成表格每个用例写明输入和预期输出然后写代码让所有用例通过。这样做的好处是你不会遗漏边界情况而且以后修改代码时能快速验证有没有破坏原有功能。我常用的测试框架是Catch2C或者自己写一个简单的断言宏C。对于这道题测试用例至少应该覆盖空串、纯空格、单单词无标点、多单词多句子、连续空格、首尾空格、连续标点、末尾无标点、混合标点。把这些跑通你的实现就基本靠谱了。回头看这道题它表面上是在考字符串遍历实际上考的是你如何定义问题、如何处理边界、如何组织代码。这三个能力才是从写练习代码到写生产代码之间真正的鸿沟。我见过太多人能把算法思路讲得头头是道但一写代码就到处是越界和漏判。多花点时间在这些简单题的细节上比急着刷难题收获更大。
阅读完成 · 觉得有帮助?