第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 知识体系总览

下面这张图总结了本章的核心知识点:

字符串单词计数知识体系 核心问题:什么是单词? 方法一:状态机法 方法二:strtok() 方法三:自定义字符判断 ✅ 不修改原字符串 ✅ 可处理连续空格 ⚠️ 会修改原字符串 ⚠️ 非线程安全 ✅ 可自定义单词规则 ✅ 灵活处理标点 性能优化:指针操作 → 自定义宏 → SIMD向量化

14.7 避坑总结

最后,我把这些年踩过的坑总结一下:

常见错误 后果 正确做法
只按空格分割 漏掉制表符、换行符分隔的单词 使用 isspace() 判断所有空白字符
isspace() 参数未转 unsigned char 负值字符导致未定义行为 强制转换为 (unsigned char)
使用 strtok() 后继续使用原字符串 数据被破坏,逻辑错误 先拷贝字符串,或使用 strtok_r()
忽略前导/尾随空格 多计或少计单词 使用状态机法,天然免疫
未处理空字符串或 NULL 指针 程序崩溃 函数开头做参数检查

嗯,关于单词计数,今天就讲到这里。这个功能虽然基础,但写得好不好,直接反映出你对字符串处理的理解深度。下次面试如果被问到这道题,希望你能给出一个考虑周全、性能优异的实现。


公众号:蓝海资料掘金营,微信deep3321