第14章:字符串中单词计数——按空格分割统计单词个数
各位同学,今天我们来聊一个看似简单、实则暗藏玄机的问题——字符串中单词计数。
说白了,就是给你一句话,比如 "Hello world, welcome to C!",让你数一数里面有几个单词。很多新手第一反应就是:这还不简单?数空格不就完了?
嗯,我当年刚入行时也是这么想的。直到有一次,我在做一个日志分析工具时,遇到了一个包含连续空格、制表符、换行符的文本文件……结果统计出来的单词数,跟人工数出来的差了整整一倍。从那以后,我再也不敢小看这个「简单」的问题了。
14.1 问题本质:什么是「单词」?
在开始写代码之前,我们得先搞清楚一个核心问题:到底什么算一个单词?
常见的定义是:单词是由非空白字符组成的连续序列,单词之间由空白字符分隔。
这里的「空白字符」包括:
- 空格(' ')
- 制表符('\t')
- 换行符('\n')
- 回车符('\r')
- 换页符('\f')
- 垂直制表符('\v')
你想想看,如果只按空格分割,遇到 "Hello\tworld\n" 这种字符串,就会漏掉单词。所以,严谨的做法是使用 isspace() 函数来判断空白字符。
核心思路:遍历字符串,遇到非空白字符时,标记「进入单词」状态;遇到空白字符时,标记「离开单词」状态。每次从空白切换到非空白,单词数加1。
14.2 基础实现:状态机法
我个人最喜欢用状态机的方式来实现单词计数。为什么?因为逻辑清晰,不容易出错。
我们定义两个状态:
- IN_WORD:当前在单词内部
- OUT_WORD:当前在单词外部(空白区域)
状态转换规则:
- OUT_WORD → 遇到非空白字符 → IN_WORD,单词数+1
- IN_WORD → 遇到空白字符 → OUT_WORD
- 其他情况状态不变
来看代码:
#include <stdio.h>
#include <ctype.h>
int count_words(const char *str) {
if (str == NULL) return 0;
int count = 0;
int in_word = 0; // 0: OUT_WORD, 1: IN_WORD
while (*str != '\0') {
if (isspace((unsigned char)*str)) {
in_word = 0; // 遇到空白,离开单词
} else {
if (!in_word) {
in_word = 1; // 从空白进入单词
count++;
}
}
str++;
}
return count;
}
int main() {
char text[] = " Hello world, welcome to C! ";
printf("单词个数: %d\n", count_words(text));
return 0;
}
这段代码能处理前导空格、尾随空格、连续多个空格的情况。我在项目中用这个逻辑处理过上百万行的日志文件,从来没出过问题。
小技巧:isspace() 的参数要强制转换为 unsigned char,否则在某些平台上,传入负值(比如扩展ASCII字符)会导致未定义行为。这是很多老手都会忽略的细节。
14.3 另一种思路:利用 strtok() 函数
C标准库提供了 strtok() 函数,可以按分隔符分割字符串。用它来实现单词计数,代码会更简洁。
#include <stdio.h>
#include <string.h>
int count_words_strtok(char *str) {
if (str == NULL) return 0;
int count = 0;
char *token = strtok(str, " \t\n\r\f\v");
while (token != NULL) {
count++;
token = strtok(NULL, " \t\n\r\f\v");
}
return count;
}
int main() {
char text[] = " Hello world, welcome to C! ";
printf("单词个数: %d\n", count_words_strtok(text));
return 0;
}
但是,这里有一个大坑:strtok() 会修改原字符串!它把分隔符替换成了 '\0'。如果你后续还需要使用原字符串,那就麻烦了。
避坑指南:我曾经在一个网络协议解析模块里用了 strtok(),结果解析完发现原始报文被破坏了,导致后续的校验和计算全部出错。排查了整整一个下午才找到原因。从那以后,我只要用 strtok(),一定会先拷贝一份字符串。
如果你不想破坏原字符串,可以用 strtok_r()(可重入版本)或者自己实现一个不修改原串的分割函数。
14.4 进阶:处理标点符号和特殊字符
上面的实现有一个问题:像 "world," 和 "C!" 这种带标点符号的单词,会被正确识别为一个单词。但有些场景下,你可能希望把标点符号也当作分隔符。
比如,统计一篇文章中不同单词的出现频率时,"hello" 和 "hello," 应该算同一个单词。
这时候,我们可以自定义一个「单词字符」的判断函数:
#include <stdio.h>
#include <ctype.h>
int is_word_char(char c) {
return isalnum((unsigned char)c) || c == '_' || c == '\'';
}
int count_words_custom(const char *str) {
if (str == NULL) return 0;
int count = 0;
int in_word = 0;
while (*str != '\0') {
if (is_word_char(*str)) {
if (!in_word) {
in_word = 1;
count++;
}
} else {
in_word = 0;
}
str++;
}
return count;
}
int main() {
char text[] = "Don't stop! Can't you see?";
printf("单词个数: %d\n", count_words_custom(text));
return 0;
}
这里我把字母、数字、下划线和单引号都算作单词字符。这样 "Don't" 和 "Can't" 会被识别为一个单词,而不是两个。这在英文文本处理中很常见。
14.5 性能考量与优化
如果你需要处理超长字符串(比如几MB甚至几GB的文本),那就要考虑性能问题了。
状态机法的时间复杂度是 O(n),空间复杂度是 O(1),已经是最优了。但有几个细节可以优化:
- 减少函数调用:isspace() 虽然快,但频繁调用也有开销。可以自己写一个简单的空白字符判断宏。
- 使用指针而非数组索引:指针自增比 arr[i] 的索引访问更快。
- 考虑 SIMD 指令:在 x86 平台上,可以用 SSE/AVX 指令一次处理16/32个字符,大幅提升吞吐量。
我曾在一次性能调优中,把单词计数函数从每秒处理 200MB 提升到了 1.2GB,靠的就是 SIMD 向量化。不过那是另一个故事了,今天先不展开。
14.6 知识体系总览
下面这张图总结了本章的核心知识点:
14.7 避坑总结
最后,我把这些年踩过的坑总结一下:
| 常见错误 | 后果 | 正确做法 |
|---|---|---|
| 只按空格分割 | 漏掉制表符、换行符分隔的单词 | 使用 isspace() 判断所有空白字符 |
| isspace() 参数未转 unsigned char | 负值字符导致未定义行为 | 强制转换为 (unsigned char) |
| 使用 strtok() 后继续使用原字符串 | 数据被破坏,逻辑错误 | 先拷贝字符串,或使用 strtok_r() |
| 忽略前导/尾随空格 | 多计或少计单词 | 使用状态机法,天然免疫 |
| 未处理空字符串或 NULL 指针 | 程序崩溃 | 函数开头做参数检查 |
嗯,关于单词计数,今天就讲到这里。这个功能虽然基础,但写得好不好,直接反映出你对字符串处理的理解深度。下次面试如果被问到这道题,希望你能给出一个考虑周全、性能优异的实现。
公众号:蓝海资料掘金营,微信deep3321