1. 从一道“看起来很简单”的题目说起
“统计一句话中有多少单词,以及一段话里有多少单词和句子”——这个需求第一次看到的时候,很多人脑子里蹦出来的第一反应是:这有什么难的?一个循环,遇到空格就加一,遇到句号就加一,完事。我当年也是这么想的,直到真正动手写、真正拿各种“脏数据”去测,才发现这里面藏着的坑比想象中多得多。空格不止一种、标点不止一类、缩写、连字符、换行、连续空格、空字符串、纯标点输入……每一个都能让一个“看起来没问题”的版本当场翻车。
这篇文章就是围绕这个题目展开的。我会用C和C++两种语言分别实现,把从最朴素的版本到相对健壮的版本一步步拆开讲清楚。核心关键词就三个:单词统计、句子统计、字符串处理。它适合刚学完数组和循环、想找一个练手项目的同学,也适合已经工作但想回头把字符串边界处理这块补扎实的开发者。读完之后你应该能做到:拿到任意一段英文文本,能准确说出它有多少个单词、多少个句子,并且清楚自己的程序在什么情况下会出错、为什么出错、怎么修。
我先把结论性的东西摆出来,方便你建立整体印象。这个任务本质上是一个状态机问题:我们不是在“数空格”,而是在判断“当前字符处于单词内部还是单词外部”。一旦把思维从“数分隔符”切换到“跟踪状态”,很多边界问题就迎刃而解了。句子统计同理,它跟踪的是“是否已经进入了一个句子”。下面我会把这个思路贯穿到每一段代码里。
2. 需求拆解与整体设计思路
2.1 到底什么算一个“单词”
这是整个项目里第一个必须拍板的问题,也是最容易被忽略的问题。如果不定清楚规则,代码写出来就是一笔糊涂账。我一般会先跟需求方(或者自己)确认下面这几条:
- 连续的非空白字符算一个单词。这是最通用的定义,
hello、world、abc123、don't都算一个单词。 - 空白字符包括空格、制表符
\t、换行\n、回车\r、换页\f、垂直制表\v。C标准库里的isspace()正好覆盖这些,所以直接用它最省事。 - 标点符号怎么处理?这里有两种流派。一种是把标点当作单词的一部分,
hello,和hello算不同的东西;另一种是把标点剥离,hello,里的hello才算一个单词。对于“统计单词数”这个目标来说,两种流派数出来的个数通常是一样的,因为标点要么紧贴单词、要么单独成串。但如果标点单独出现(比如hello , world),第一种流派会把,也算成一个单词,第二种不会。我个人的选择是:只按空白切分,标点不特殊处理,因为这样实现最简单,也最符合“单词数”这个语义的直觉。
提示:如果你的场景是自然语言处理,标点处理会复杂得多,需要考虑缩写、连字符、所有格等。但那是另一个量级的任务,本文不展开。
2.2 什么算一个“句子”
句子的判定比单词更主观。常见的做法是:以.、!、?作为句子结束标志,连续出现的结束标点(比如...或?!)算作一个句子结束。这里有几个必须提前想清楚的边界:
Mr. Smith里的.不是句子结束。但要做对这一点需要缩写词典,成本很高。对于练手项目,我建议不做缩写识别,接受这个误差,并在文档里写清楚。- 小数点
3.14里的.也不是句子结束。这个可以通过“点号前后都是数字则跳过”来简单规避,属于性价比很高的优化。 - 句末没有标点的文本怎么算?比如
Hello world没有句号。我的处理是:如果扫描结束后状态显示“还有未结束的句子内容”,就补计一句。这样Hello world算1句,空字符串算0句。
2.3 为什么用状态机而不是计数器
很多人第一版会写成这样:遍历字符串,遇到空格就wordCount++。这个写法在hello world上没问题,但在hello world(两个空格)上就会多算一次,在hello(开头有空格)上也会多算。根本原因是它把“分隔符出现次数”和“单词个数”混为一谈了。
正确的模型是:维护一个布尔状态inWord,表示“当前是否正在一个单词内部”。
- 遇到非空白字符:如果
inWord是 false,说明一个新单词开始了,wordCount++,然后把inWord置为 true。 - 遇到空白字符:把
inWord置为 false。
这个模型天然处理了连续空格、开头空格、结尾空格,因为计数只发生在“从非单词进入单词”的那一次跳变上。句子统计用的是完全一样的思路,只不过状态变量换成inSentence,触发条件换成句末标点。把这两个状态机想明白,代码就是水到渠成的事。
3. C语言版本:从朴素实现到健壮版本
3.1 最朴素的版本长什么样
先看一个能跑但很脆弱的版本,目的是让你看清问题在哪:
#include <stdio.h> #include <string.h> int main(void) { char text[1024]; fgets(text, sizeof(text), stdin); int words = 0, sentences = 0; for (int i = 0; text[i] != '\0'; i++) { if (text[i] == ' ') words++; if (text[i] == '.') sentences++; } printf("words=%d sentences=%d\n", words + 1, sentences); return 0; }这段代码的问题一抓一大把:只认空格不认制表符和换行;连续空格会重复计数;开头有空格会多算;words + 1这个补丁在空输入时会输出1;句号在3.14里也会被算成句子结束。它能通过“hello world.”这种理想输入,但稍微换个输入就崩。我把它放出来不是让你学,而是让你对照着看后面的版本改进了什么。
3.2 用状态机重写单词统计
下面是我实际会用的版本,核心就是前面说的inWord状态:
#include <stdio.h> #include <ctype.h> int main(void) { char text[4096]; if (fgets(text, sizeof(text), stdin) == NULL) { printf("words=0 sentences=0\n"); return 0; } int words = 0, sentences = 0; int inWord = 0; int inSentence = 0; for (int i = 0; text[i] != '\0'; i++) { unsigned char c = (unsigned char)text[i]; if (isspace(c)) { inWord = 0; } else { if (!inWord) { words++; inWord = 1; } inSentence = 1; } if (c == '.' || c == '!' || c == '?') { if (inSentence) { sentences++; inSentence = 0; } } } if (inSentence) sentences++; printf("words=%d sentences=%d\n", words, sentences); return 0; }这段代码有几个关键点值得说。第一,isspace的参数必须是unsigned char转成int,直接传char在遇到高位字符时是未定义行为,这是个很多人不知道的坑。第二,句子计数放在单词处理之后,并且用inSentence做守卫,这样...这种连续标点只会算一句。第三,循环结束后检查inSentence,处理没有句末标点的收尾情况。
3.3 处理小数点这个高频误判
3.14被算成一句是很多人会遇到的尴尬。修法很简单:在判断句末标点之前,先看这个点号是不是夹在数字中间。
if (c == '.') { int prevIsDigit = (i > 0 && isdigit((unsigned char)text[i-1])); int nextIsDigit = (text[i+1] != '\0' && isdigit((unsigned char)text[i+1])); if (prevIsDigit && nextIsDigit) { continue; // 小数点,跳过句子判定 } }把这段插到句末标点判断之前就行。注意text[i+1]要先判\0,否则越界。这个改动成本极低,但能挡掉一大类误判,性价比很高。至于Mr. Smith这种缩写,我前面说了不做,因为要做对需要词典,投入产出比不划算。
3.4 输入方式的坑:fgets 与换行
用fgets读一行,它会把结尾的\n也读进来。这个\n是空白字符,会被isspace正确处理,不影响单词和句子计数,所以不用特意去掉。但如果你用scanf("%s"),它遇到空格就停,根本读不到一整句话,这个任务里绝对不能用。如果文本可能超过缓冲区大小,fgets会截断,这时候要么把缓冲区开大,要么用循环分块读取并维护状态跨块延续。对于练手,4096字节够用了,但你要知道这个限制的存在。
注意:
gets这个函数已经被标准废弃,任何情况下都不要用,它没有任何边界检查,是缓冲区溢出的经典来源。
4. C++版本:用标准库把代码写短写稳
4.1 用 std::string 和 std::getline 读整行
C++的第一层改进是输入。std::getline(std::cin, line)读一整行,配合std::string自动管理内存,不用再操心缓冲区大小。如果要读多行直到文件结束,可以循环getline并把内容拼起来,或者逐行处理、把状态变量提到循环外面。
#include <iostream> #include <string> #include <cctype> int main() { std::string text, line; while (std::getline(std::cin, line)) { text += line; text += '\n'; } // 后续处理 text }这里把每行拼成一个带换行的大字符串,换行本身是空白,不影响计数,但能保证跨行的单词不会被错误地粘在一起(其实粘在一起也不影响单词数,但保留换行更符合直觉)。
4.2 用 std::isspace 和范围 for 循环
C++里遍历字符串最舒服的写法是范围 for:
int words = 0, sentences = 0; bool inWord = false, inSentence = false; for (size_t i = 0; i < text.size(); ++i) { unsigned char c = static_cast<unsigned char>(text[i]); if (std::isspace(c)) { inWord = false; } else { if (!inWord) { ++words; inWord = true; } inSentence = true; } if (c == '.' || c == '!' || c == '?') { if (c == '.' && i > 0 && i + 1 < text.size() && std::isdigit(static_cast<unsigned char>(text[i-1])) && std::isdigit(static_cast<unsigned char>(text[i+1]))) { continue; } if (inSentence) { ++sentences; inSentence = false; } } } if (inSentence) ++sentences;逻辑和C版本一模一样,只是用了std::string和std::isspace。这里用索引而不是范围 for,是因为小数点判断需要看前后字符,索引更方便。static_cast<unsigned char>那两处转换同样不能省,原因和C版本一样。
4.3 把逻辑封装成函数
真正写项目的时候,我会把统计逻辑抽成一个函数,输入是const std::string&,输出是一个结构体:
struct TextStats { int words = 0; int sentences = 0; }; TextStats analyze(const std::string& text) { TextStats stats; bool inWord = false, inSentence = false; for (size_t i = 0; i < text.size(); ++i) { unsigned char c = static_cast<unsigned char>(text[i]); if (std::isspace(c)) { inWord = false; } else { if (!inWord) { ++stats.words; inWord = true; } inSentence = true; } if (c == '.' || c == '!' || c == '?') { if (c == '.' && i > 0 && i + 1 < text.size() && std::isdigit(static_cast<unsigned char>(text[i-1])) && std::isdigit(static_cast<unsigned char>(text[i+1]))) { continue; } if (inSentence) { ++stats.sentences; inSentence = false; } } } if (inSentence) ++stats.sentences; return stats; }封装成函数的好处是:可以单独写测试用例,可以在不同输入源之间复用,主函数只负责读输入和打印结果。这是从“写个能跑的脚本”到“写一段能维护的代码”的分界线。
4.4 C与C++版本怎么选
如果你的环境是纯C(比如嵌入式、某些竞赛平台),用C版本,注意手动管理缓冲区和unsigned char转换。如果是日常开发、刷题、写工具,C++版本更省心,std::string和std::getline能帮你挡掉大量内存和输入问题。两者核心算法完全一致,学会一个另一个就是语法差异。我个人的习惯是:算法逻辑用C写一遍确保理解透彻,实际项目用C++写一遍确保可维护。
5. 边界情况与常见问题排查
5.1 一张表看清所有坑
下面这张表是我在实际测试中积累的,左边是输入,右边是期望输出和容易出错的地方。你可以直接拿它当测试用例集。
| 输入 | 期望 words | 期望 sentences | 常见错误 |
|---|---|---|---|
hello world | 2 | 1 | 忘记补计无标点收尾 |
hello world | 2 | 1 | 连续空格重复计数 |
hello | 1 | 1 | 开头空格多算 |
hello. | 1 | 1 | 正常 |
hello... | 1 | 1 | 连续标点算成3句 |
3.14 is pi. | 3 | 1 | 小数点算成句子结束 |
Hi! How are you? | 4 | 2 | 正常 |
| 0 | 0 | 空输入输出1 | |
| `` | 0 | 0 | 空字符串崩溃或输出1 |
a\tb\nc | 3 | 1 | 只认空格不认制表符换行 |
5.2 空输入和纯空白输入
空字符串和纯空白是最容易被忽略的。用状态机版本,空字符串循环一次都不进,words和sentences都是0,inSentence是 false,不会补计,输出正确。纯空白同理,所有字符都是isspace,inWord一直是 false,不计数。如果你用的是words + 1那种补丁写法,这里就会输出1,这就是为什么我一直强调不要用补丁,要用状态机。
5.3 标点紧贴单词的情况
hello,world这种没有空格的输入,按“非空白即单词”的规则会被算成1个单词。这符合大多数人的直觉吗?其实有争议。有人觉得逗号应该分隔单词,应该算2个。我的处理是:保持规则简单一致,只按空白切分,并在文档里写明。如果你确实需要按标点也切分,那就在isspace判断之外,再加一个“是否标点”的判断,把标点也当作分隔符。但要注意,这样don't会被切成don和t,反而更糟。所以规则的选择取决于你的实际场景,没有绝对的对错。
5.4 多行文本的状态延续
如果文本跨多行,而你是逐行处理、每行单独调用统计函数,那么状态会在行与行之间丢失。比如第一行结尾是hello(没有标点),第二行开头是world.,逐行处理会把它们算成两个不完整的片段。正确做法是把状态变量提到循环外面,或者先把所有行拼成一个大字符串再统一处理。我一般选后者,代码更简单,代价是内存占用,对于正常文本完全可接受。
提示:如果文本非常大(比如几百MB的日志),拼成一个大字符串会爆内存,这时候必须用流式处理,把
inWord和inSentence作为跨块状态传递。这是进阶话题,练手阶段不用考虑。
5.5 中文和UTF-8的注意事项
这个题目说的是“单词”,默认是英文语境。如果输入包含中文,按字节遍历会把一个汉字拆成多个字节,每个字节都不是空白,会被算成多个“单词”,结果完全不对。要正确处理中文,需要按UTF-8解码成码点,再判断码点范围。这超出了本文范围,但你必须知道:当前所有版本都只适用于ASCII英文文本。如果你的场景有中文,要么先明确需求(中文按字算还是按词算),要么引入专门的文本处理库。
6. 实测记录与性能观察
6.1 测试环境与数据
我在一台普通开发机上做了几组测试,编译器用gcc -O2和g++ -O2。测试数据分三档:短句(几十字节)、段落(几KB)、长文本(约1MB的英文文章拼接)。统计逻辑本身是 O(n) 的,每个字符只做常数次判断,所以性能瓶颈几乎全在输入读取上。1MB文本用C版本处理,耗时在毫秒级,肉眼无法感知差异。C++版本因为std::string的拼接和可能的重新分配,会略慢一点,但同样在毫秒级。
6.2 一个容易忽略的性能点
如果你用text += line在循环里拼接大量行,std::string会多次重新分配内存。对于1MB以内的文本无所谓,但如果要处理几十MB,建议先reserve一个预估大小,或者改用流式状态机。我实测过,拼接100万行短行时,不reserve的版本比reserve的版本慢好几倍。这个细节在练手时不用管,但你要有这个意识:字符串拼接是有成本的。
6.3 状态机版本为什么更稳
我把朴素版本和状态机版本跑了同一组边界测试。朴素版本在10个用例里错了6个,状态机版本全对。差距不在代码长度,而在模型是否正确。状态机版本多出来的代码主要是inWord和inSentence两个布尔变量和几行判断,但它把“计数”这个动作绑定到了“状态跳变”上,从根本上杜绝了重复计数和漏计。这个思路可以迁移到很多类似问题:统计连续重复字符、解析简易格式、词法分析入门,都是同一套东西。
7. 可以继续扩展的方向
这个项目本身很小,但它是一个很好的起点。如果你想继续深挖,有几个方向值得尝试。第一,把统计逻辑做成一个命令行工具,支持从文件读入、输出单词数句子数,再加一个-v参数输出每个单词。第二,加入更聪明的句子边界识别,比如处理Mr.、Dr.、etc.这些常见缩写,做法是维护一个小型缩写表,遇到点号时查表决定是否算句末。第三,支持UTF-8,按码点遍历,这样中文、日文、emoji都能正确处理。第四,写一套单元测试,把第5节那张表里的用例全部自动化,每次改代码跑一遍,保证不回归。
我个人的体会是,这类“看起来简单”的字符串处理题,最能暴露一个人对边界条件的敏感度。能把单词和句子统计写对、写稳、写清楚为什么这么写的人,在处理更复杂的文本解析任务时,通常也不会差。反过来,如果这里含糊过去,后面遇到真正的解析需求,坑会以更大的代价还回来。所以别嫌它小,把它吃透。