Lesson 17: 统计单词个数 CNB
练习任务
编写一个 C 程序,从标准输入读取一行文本,统计其中有多少个单词,并依次打印每个单词。单词的定义是:由字母(a-z, A-Z)组成的连续字符序列,单词之间由非字母字符(空格、标点、换行等)分隔。
期望输入输出示例:
输入: This is a book.
输出:
word 1 found!
This
word 2 found!
is
word 3 found!
a
word 4 found!
book
there is 4 words found!提示:核心问题是——程序怎么「知道」一个新单词开始了?想象你在一个字符序列中行走,只有两种状态:你正在单词「里面」还是在单词「外面」。当从「外面」遇到字母时,一个新单词就开始了;当从「里面」遇到非字母时,当前单词结束。用变量记录当前状态,这种编程模式叫做状态机。
核心知识点
- 状态机编程思想 — 用
state变量记录系统当前状态,根据输入决定状态转换 - IN/OUT 状态模型 — 两种状态(在单词内 / 在单词外),四种转换分支
- 字符分类函数 —
get_input_type()将字符抽象为输入类型(字母 / 非字母) - 指针记录单词起始 —
p = &buf[i]记录新单词的首字符地址 - 计数器累计长度 —
counter从 0 递增,记录当前单词的字符数 - switch-case 状态机 — 用
switch(state)替代多层if-else,语义更清晰 - fgets 读取输入 — 从 stdin 读一行,处理换行符和 EOF
- 状态转换表 — 将 (状态, 输入) → (新状态, 动作) 形式化为二维表格
- ctype.h 标准库 —
isalpha()、isspace()等可移植字符分类函数 - K&R 状态机范例 —
\b退格符与单词计数的经典实现
代码框架
#include <stdio.h>
int get_input_type(char c)
{
// 判断 c 是否是字母 (a-z 或 A-Z)
// 如果是 → 返回 1
// 如果不是 → 返回 0
}
int main(void)
{
char buf[512];
int state = 0; // 0: 单词外, 1: 单词内
int i = 0;
int words = 0; // 单词计数
char *p = NULL; // 指向当前单词首字符
int counter = 0; // 当前单词长度
fgets(buf, sizeof(buf), stdin);
// 去掉末尾的换行符
for (i = 0; buf[i]; i++)
if (buf[i] == '\n')
{
buf[i] = '\0';
break;
}
i = 0;
// 在这里实现状态机主循环:
// state == 0 && input == 0 → 连续空白, 保持 state=0
// state == 0 && input == 1 → 新单词开始, state=1, 记录 p, counter=1
// state == 1 && input == 0 → 单词结束, state=0, words++, 打印单词
// state == 1 && input == 1 → 单词继续, counter++
// 遇到 '\0' → 退出循环, 处理最后一个可能未结束的单词
printf("there is %d words found!\n", words);
return 0;
}填充框架的关键思考:p 应该在什么时刻赋值?counter 什么时候清零?如果最后一个单词后面没有空格(如 "hello"),循环结束后需要额外处理吗?
TIP
先不要往下翻看参考解答。尝试先独立实现状态机主循环的四个分支。状态机的核心是「当前状态 + 当前输入 → 下一个状态 + 输出动作」,画一个简单的状态转换图可以帮助理清逻辑。
深度讲解
1. 状态机:程序如何「记住」上下文
1.1 问题本质:程序为什么需要「状态」
回顾 Lesson 08 的逐位提取算法——num % 10 取个位、num / 10 去个位——每次循环只关心当前位的数字,不需要记住之前发生了什么。再回顾 Lesson 13 的 switch-case,它根据一个确定的值(枚举星期)选择分支执行,也是「看一眼就知道怎么办」。
但统计单词不同——程序读到字符 'a' 时,它需要知道:这个 'a' 是一个新单词的第一个字母,还是当前单词的第三个字母?答案取决于之前发生了什么。
// 考虑以下两个位置读到的字母 'o':
// "hello world" — 第二个 'o' 是单词 "hello" 的延续
// " one two" — 第一个 'o' 是新单词 "one" 的开始
//
// 两个 'o' 的处理方式不同,但程序不能靠 'o' 本身来区分——
// 它必须记住「我读到 'o' 之前是在单词里面还是在单词外面」这就是上下文依赖——当前输入的处理方式取决于历史。状态机正是解决这类问题的标准工具。
1.2 状态机的基本概念
一个有限状态自动机(Finite State Automaton, FSA)由三个要素组成:
- 状态(State):系统在某时刻的「位置」或「模式」——有限个离散值
- 输入(Input):驱动状态转换的外部信号——来自字符、传感器、网络等
- 转换规则(Transition):在状态 S 下收到输入 I,系统转换到新状态 S',并可附带动作
用我们的话说:状态机 = 状态集合 + 输入集合 + 转换表。
// 伪代码:状态机的基本结构
int state = INITIAL_STATE; // 初始状态
while (input_available())
{
int input = read_input(); // 获取当前输入
// 根据 (state, input) 决定下一步
switch (state)
{
case STATE_A:
if (input == X) { do_action_1(); state = STATE_B; }
else { do_action_2(); state = STATE_C; }
break;
case STATE_B:
// ...
}
}这不是一个花哨的算法——它就是「用一个变量记住你在哪里」这种朴素思想的系统化表达。
1.3 单词计数的状态机模型
对于「统计单词」这个任务,我们定义:
状态集合:
| 状态值 | 含义 | 说明 |
|---|---|---|
0 (OUT) | 在单词「外面」 | 当前处于空白区域,还没进入或已经离开一个单词 |
1 (IN) | 在单词「里面」 | 当前正在遍历一个单词的字符 |
输入集合:
| 输入值 | 含义 | 说明 |
|---|---|---|
0 | 非字母字符 | 空格、标点、数字、换行等——「分隔符」 |
1 | 字母字符 | a-z 或 A-Z——「单词内容」 |
转换规则:
当前状态 输入 → 新状态 → 动作
─────────────────────────────────────────
OUT(0) 0 → OUT(0) → 什么也不做(连续空白)
OUT(0) 1 → IN(1) → 新单词开始!记录起始位置,counter=1
IN(1) 0 → OUT(0) → 单词结束!计数器++,打印单词
IN(1) 1 → IN(1) → 单词继续,counter++这就是本课的核心——全部四个分支覆盖了所有可能的情况。
状态转换图:
input=0 input=1
┌──────────┐ ┌──────────┐
│ ▼ │ ▼
┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐
│ OUT │◄───│ OUT │ │ OUT │───►│ IN │
│ (0) │ │ (0) │ │ (0) │ │ (1) │
└─────┘ └─────┘ └─────┘ └─────┘
▲ ▲ │ │
│ │ │ │
└──────────┘ └────┘
input=1 input=1
┌──────────┐ ┌──────────┐
│ ▼ │ ▼
┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐
│ IN │───►│ OUT │ │ IN │───►│ IN │
│ (1) │ │ (0) │ │ (1) │ │ (1) │
└─────┘ └─────┘ └─────┘ └─────┘
input=0 input=1理解这张图就理解了本课 80% 的内容。后续的所有代码都是这四种转换的具体实现。
2. 字符分类:从字符到输入类型
2.1 get_input_type 的设计动机
为什么需要一个专门的函数来「分类」字符?直接在 main 中写 if (c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z') 不行吗?
// 方案 A:直接在 main 中判断
while ((c = buf[i++]) != '\0')
{
if (c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z')
{
// 字母的处理逻辑...
}
else
{
// 非字母的处理逻辑...
}
}
// 方案 B:用函数封装字符分类
while ((c = buf[i++]) != '\0')
{
int input = get_input_type(c); // 1 或 0
// 现在用 input 而不是原始字符做判断
}方案 B 的优势:
| 方面 | 方案 A(直接判断) | 方案 B(函数封装) |
|---|---|---|
| 可读性 | if (c >= 'a' && ...) 冗长 | if (input == 1) 简洁 |
| 可修改性 | 改分类规则要改所有出现处 | 只改一个函数 |
| 语义层 | 混在字符层面 | 提升到「输入类型」抽象层 |
| 扩展性 | 增加新字符类型很麻烦 | 扩展函数返回值即可 |
这是软件工程中「抽象分层」思想的体现——状态机不应该关心 'a' 和 'Z' 的 ASCII 值,它只需要知道「这是字母」还是「这不是字母」。
2.2 从手写判断到 ctype.h 标准库
C 标准库 <ctype.h> 提供了一系列字符分类函数,它们比手写的范围判断更可靠、更可移植:
#include <ctype.h>
#include <stdio.h>
int main(void)
{
printf("isalpha('A') = %d\n", isalpha('A')); // 非零(真)
printf("isalpha('9') = %d\n", isalpha('9')); // 0(假)
printf("isalpha(' ') = %d\n", isalpha(' ')); // 0(假)
printf("isspace(' ') = %d\n", isspace(' ')); // 非零(真)
printf("isspace('\\n') = %d\n", isspace('\n')); // 非零(真)
printf("isdigit('5') = %d\n", isdigit('5')); // 非零(真)
printf("islower('g') = %d\n", islower('g')); // 非零(真)
printf("isupper('G') = %d\n", isupper('G')); // 非零(真)
return 0;
}为什么 ctype.h 比手写更好:
- 可移植性:不同平台的字符编码可能不同(虽然现在大多是 ASCII/UTF-8),
isalpha由标准库保证正确行为 - 性能优化:标准库实现通常用查表法——一个 256 字节的位标记数组,比多个
if判断快得多 - 可读性:
isalpha(c)的意图比c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z'清晰百倍
ctype.h 查表法的原理:
// 标准库 isalpha 的典型实现方式(简化示意)
static const unsigned char _ctype[256] = {
// ... 每个字符对应一个位标记
// bit 0: 大写, bit 1: 小写, bit 2: 数字, bit 3: 空白, ...
};
#define _U 0x01 // 大写字母标记位
#define _L 0x02 // 小写字母标记位
int isalpha(int c)
{
return (_ctype[(unsigned char)c] & (_U | _L));
// 查表 位运算判断
// O(1) 常数时间,无需逐个比较
}对比:
| 方法 | 时间复杂度 | 代码量 | 可移植性 |
|---|---|---|---|
if (c >= 'a' && c <= 'z' || ...) | O(1)(但多条件) | 1 行(但冗长) | 依赖 ASCII 假设 |
isalpha(c) | O(1)(查表) | 1 行(简洁) | 标准保证 |
TIP
在实际项目中,永远优先使用 <ctype.h> 中的标准函数。本课练习中手写 get_input_type 是为了让你理解字符分类的底层逻辑——知其然,更知其所以然。理解之后,你应该知道 isalpha() 背后在做什么。
2.3 扩展字符分类:更多输入类型
如果我们不仅要区分「字母」和「非字母」,还想区分「数字」、「标点」、「空白」等更细的类型,get_input_type 的返回值可以从 0/1 扩展到枚举:
// 定义更多输入类型
enum InputType {
TYPE_SPACE = 0, // 空白字符(空格、\t、\n)
TYPE_ALPHA = 1, // 字母 (a-z, A-Z)
TYPE_DIGIT = 2, // 数字 (0-9)
TYPE_PUNCT = 3, // 标点符号
TYPE_OTHER = 4, // 其他
};
int get_input_type_extended(char c)
{
if (c == ' ' || c == '\t' || c == '\n')
return TYPE_SPACE;
if (c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z')
return TYPE_ALPHA;
if (c >= '0' && c <= '9')
return TYPE_DIGIT;
if (c == '.' || c == ',' || c == '!' || c == '?'
|| c == ';' || c == ':')
return TYPE_PUNCT;
return TYPE_OTHER;
}有了更多输入类型,状态机可以处理更复杂的任务——比如同时统计单词数、数字个数、标点数量。这正是词法分析器的雏形(Lesson 21 会深入探讨)。
3. 状态机的实现:if-else 链
3.1 四个转换分支
回到本课的核心——实现单词计数状态机。以下是完整的 if-else 链实现:
#include <stdio.h>
int get_input_type(char c)
{
if (c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z')
return 1;
return 0;
}
int main(void)
{
char buf[] = "This is a book.";
int state = 0; // 0: OUT, 1: IN
int i = 0;
int words = 0;
char *p = NULL;
int counter = 0;
int k;
while (1)
{
char c = buf[i];
int input;
if (c == '\0')
break;
input = get_input_type(c);
// 分支 1: OUT + 非字母 → 保持 OUT(连续空白)
if (state == 0 && input == 0)
{
state = 0;
}
// 分支 2: OUT + 字母 → 进入 IN(新单词开始!)
else if (state == 0 && input == 1)
{
state = 1;
p = &buf[i]; // 记录单词起始地址
counter = 1; // 计数器从 1 开始
}
// 分支 3: IN + 非字母 → 回到 OUT(单词结束!)
else if (state == 1 && input == 0)
{
state = 0;
words++;
printf("word %d found!\n", words);
for (k = 0; k < counter; k++)
printf("%c", p[k]); // 逐字符打印单词
printf("\n");
}
// 分支 4: IN + 字母 → 保持 IN(单词继续)
else if (state == 1 && input == 1)
{
state = 1;
counter++;
}
i++;
}
printf("there is %d words found!\n", words);
return 0;
}3.2 逐行解析:指针 p 和计数器 counter
**分支 2(新单词开始)**是理解本程序的关键:
else if (state == 0 && input == 1)
{
state = 1;
p = &buf[i]; // ← 核心操作:记住单词的首字符地址
counter = 1; // ← 计数器从 1 开始(已经包含第一个字符)
}p = &buf[i]:用指针p保存当前字符在buf中的地址。之后打印单词时,通过p[0]、p[1]、...、p[counter-1]依次访问单词的每个字符counter = 1:计数器从 1 开始而不是 0——因为当前字符buf[i]已经是单词的一部分
**分支 4(单词继续)**中 counter++ 让计数器递增,记录单词长度。
**分支 3(单词结束)**中用 for (k = 0; k < counter; k++) printf("%c", p[k]) 打印整个单词——这就是 p 和 counter 的配合:p 告诉我们从哪里开始,counter 告诉我们有多长。
3.3 处理特殊情况:末尾没有空格
考虑输入 "hello"——只有一个单词,末尾没有空格或标点:
字符: h e l l o \0
状态: 0→1 1→1 1→1 1→1 1→1 ?
↑
循环在 \0 处 break,但 state 仍是 1!循环在遇到 '\0' 时 break 退出,但此时 state 可能仍是 1(IN)——最后一个单词还没被「结束」。这会导致漏计:
// 有 bug 的版本:最后一个单词可能被漏掉
while (1)
{
char c = buf[i];
if (c == '\0')
break; // 直接退出,不管 state 是不是 1
// ... 状态机逻辑 ...
}修复:在循环结束后检查 state,如果为 1 说明还有未结束的单词:
while (1)
{
char c = buf[i];
if (c == '\0')
break;
// ... 状态机逻辑 ...
i++;
}
// 处理最后一个可能未结束的单词
if (state == 1)
{
words++;
printf("word %d found!\n", words);
for (int k = 0; k < counter; k++)
printf("%c", p[k]);
printf("\n");
}WARNING
忘记处理循环结束后 state == 1 的情况是一个常见 bug。在测试时不仅要测 "a b c"(单词后都有空格),还要测 "a b c" 末尾不带空格的情况。
3.4 处理连续多个空格
输入 " one two " 中有多个连续空格——状态机如何应对?
让我们追踪状态变化:
字符: ' ' ' ' 'o' 'n' 'e' ' ' ' ' ' ' 't' 'w' 'o' ' ' ' '
↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓
input: 0 0 1 1 1 0 0 0 1 1 1 0 0
state:0→0 0→0 0→1 1→1 1→1 1→0 0→0 0→0 0→1 1→1 1→1 1→0 0→0
分支1 分支1 分支2 分支4 分支4 分支3 分支1 分支1 分支2 分支4 分支4 分支3 分支1关键观察:分支 1(state=0, input=0 → state=0) 默默吞掉了所有连续的空白字符。无论开头、中间还是结尾有多少个空格,状态机都在 OUT(0) 状态下安静地路过它们,直到遇到第一个字母触发分支 2。
这正是状态机的优雅之处——连续空白的处理不需要任何特殊逻辑,它只是「保持 OUT 状态」的自然结果。
4. 从 if-else 到 switch-case
4.1 if-else 链的问题
回顾上面的实现,四个分支用 if / else if / else if / else if 链表达:
if (state == 0 && input == 0)
state = 0;
else if (state == 0 && input == 1)
{
state = 1;
p = &buf[i];
counter = 1;
}
else if (state == 1 && input == 0)
{
state = 0;
words++;
// 打印单词...
}
else if (state == 1 && input == 1)
{
state = 1;
counter++;
}这能工作,但有几个问题:
- 条件重复:每个分支都重复
state == 0或state == 1,阅读时需要在脑中匹配 - 顺序依赖:
else if的求值顺序是固定的,虽然这里不影响正确性,但弱化了「这是四种并列情况」的语义 - 扩展困难:如果要增加第三个状态(比如
state == 2表示「在数字中」),if-else 链会进一步膨胀
4.2 switch-case 重构
回顾 Lesson 13 中学到的 switch-case 语法——它按整数值多路分支,天然适合状态机的 state 变量:
int state = 0;
int i = 0;
int words = 0;
char *p = NULL;
int counter = 0;
while (1)
{
char c = buf[i];
if (c == '\0') break;
int input = get_input_type(c);
switch (state) // 先按 state 分大类
{
case 0: // state == OUT
if (input == 0)
state = 0; // 保持 OUT
else // input == 1
{
state = 1;
p = &buf[i];
counter = 1;
}
break;
case 1: // state == IN
if (input == 0)
{
state = 0;
words++;
printf("word %d found!\n", words);
for (int k = 0; k < counter; k++)
printf("%c", p[k]);
printf("\n");
}
else // input == 1
{
state = 1;
counter++;
}
break;
}
i++;
}对比两种写法:
| 方面 | if-else 链 | switch-case |
|---|---|---|
| 结构 | 四个平级的 else if | 两个 case + 各自内部的 if |
| 分组逻辑 | 按 (state, input) 组合分 | 先按 state 分大类,再按 input 分小类 |
| 新增状态 | 增加 2+ 个 else if | 增加 1 个 case 块 |
| 语义 | 四种情况的列表 | 状态驱动的行为定义 |
switch-case 版本更清晰地表达了「状态驱动」的语义——先看当前在哪个状态,再根据输入决定做什么。这与状态机的数学模型(状态 → 输入 → 新状态 + 动作)更接近。
TIP
在 Lesson 13 中我们学了 switch-case 的穿透(fall-through)特性——不写 break 时会继续执行下一个 case。在本课的状态机中我们不利用穿透,因为每个 case 块内部用 if-else 做了完整的逻辑分叉。但在更复杂的状态机中,穿透可以让多个状态共享代码(回顾 Lesson 13 中多个尾号共享同一限行日的例子)。
5. 状态转换表:将逻辑数据化
5.1 从代码到表格
状态机的四种转换规则可以形式化为一张二维表:
input=0 (非字母) input=1 (字母)
┌─────────────────────┬─────────────────────┐
state=0 │ 新状态: 0 (保持OUT) │ 新状态: 1 (进入IN) │
(OUT) │ 动作: 无 │ 动作: 记录p, counter=1│
├─────────────────────┼─────────────────────┤
state=1 │ 新状态: 0 (回到OUT) │ 新状态: 1 (保持IN) │
(IN) │ 动作: words++, 打印 │ 动作: counter++ │
└─────────────────────┴─────────────────────┘5.2 查表法实现
如果我们把这张表用 C 语言的数据结构表示,可以实现「零分支」的状态机:
#include <stdio.h>
// 状态转换表: next_state[state][input]
int next_state[2][2] = {
// input=0 input=1
{ 0, 1 }, // state=0 (OUT)
{ 0, 1 }, // state=1 (IN)
};
int main(void)
{
char buf[] = "This is a book.";
int state = 0;
int i = 0;
int words = 0;
char *p = NULL;
int counter = 0;
while (buf[i] != '\0')
{
int input = (buf[i] >= 'a' && buf[i] <= 'z')
|| (buf[i] >= 'A' && buf[i] <= 'Z');
// 检测状态转换:从 OUT 到 IN → 新单词开始
if (state == 0 && input == 1)
{
p = &buf[i];
counter = 1;
}
// 检测状态转换:从 IN 到 OUT → 单词结束
else if (state == 1 && input == 0)
{
words++;
printf("word %d found!\n", words);
for (int k = 0; k < counter; k++)
printf("%c", p[k]);
printf("\n");
}
// 保持在 IN 且收到字母 → 单词延续
else if (state == 1 && input == 1)
{
counter++;
}
// 状态更新:查表
state = next_state[state][input];
i++;
}
printf("there is %d words found!\n", words);
return 0;
}查表法的核心优势:
| 特性 | 说明 |
|---|---|
| 状态转换逻辑与动作逻辑分离 | next_state[][] 只管「到哪去」,if 只负责「做什么」 |
| 可验证性 | 表格一目了然——覆盖了所有 (2×2) 组合吗? |
| 可扩展性 | 增加第三个状态只需扩展数组到 [3][2] |
| 性能 | 数组访问 O(1),编译器可能进一步优化 |
5.3 查表法的动作函数化
更进一步,动作也可以纳入表中——用函数指针:
#include <stdio.h>
// 定义动作函数类型
typedef void (*action_func)(void *ctx, int pos);
// 动作函数声明
void action_new_word(void *ctx, int pos);
void action_end_word(void *ctx, int pos);
void action_continue_word(void *ctx, int pos);
void action_nothing(void *ctx, int pos);
// 完整的状态转换表
struct Transition {
int next_state;
action_func action;
};
struct Transition fsm[2][2] = {
// state=0 (OUT)
{
{0, action_nothing}, // input=0: 保持 OUT, 无动作
{1, action_new_word}, // input=1: 进入 IN, 记录单词起始
},
// state=1 (IN)
{
{0, action_end_word}, // input=0: 回到 OUT, 单词结束
{1, action_continue_word}, // input=1: 保持 IN, 计数器++
},
};NOTE
函数指针数组在 C 中很强大但语法稍复杂。本课不需要掌握这种写法——它展示的是「状态机可以被完全数据化」的思想。在实际项目中(如网络协议解析器、游戏 AI 行为树),表驱动状态机是工业级方案。
6. 从定长字符串到标准输入
6.1 fgets:安全地读一行
本课的练习使用 fgets 从标准输入读取一行:
#include <stdio.h>
int main(void)
{
char buf[512];
printf("请输入一行文本: ");
fgets(buf, sizeof(buf), stdin);
// ↑ ↑ ↑
// 缓冲区 缓冲区大小 从哪里读
printf("你输入了: %s", buf); // 注意:buf 末尾包含换行符 \n
return 0;
}fgets 的特性:
- 读取最多
sizeof(buf)-1个字符,保证不越界 - 遇到换行符
'\n'时停止,换行符会被存入缓冲区 - 读到文件末尾(EOF)时返回
NULL - 与
gets()不同——gets()无法限制读取长度,已被 C11 标准废弃
6.2 去掉末尾的换行符
fgets 读到的字符串末尾带有 '\n',在状态机处理前需要去掉:
char buf[512];
fgets(buf, sizeof(buf), stdin);
// 方法 1:遍历找到 \n 并替换为 \0
for (int i = 0; buf[i]; i++)
if (buf[i] == '\n')
{
buf[i] = '\0';
break;
}
// 方法 2:用 strlen 定位末尾(需要 #include <string.h>)
size_t len = strlen(buf);
if (len > 0 && buf[len - 1] == '\n')
buf[len - 1] = '\0';为什么去换行符很重要?因为 '\n' 在 get_input_type 中会被分类为 input=0(非字母)。如果不去掉它,末尾的换行符会触发分支 3(IN → OUT),导致一个多余的「单词结束」事件——虽然在这个特定程序中影响不大(因为 \0 在前),但保留换行符在 buf 中是一个坏习惯,可能在其他场景引起 bug。
6.3 getchar 逐字符读取
另一种输入方式是用 getchar() 逐字符读取,直到遇到 EOF:
#include <stdio.h>
int get_input_type(char c)
{
if (c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z')
return 1;
return 0;
}
int main(void)
{
int state = 0;
int words = 0;
char *p = NULL;
int counter = 0;
char buf[512];
int i = 0;
int c;
// 逐字符读取并存入 buf
while ((c = getchar()) != EOF && c != '\n')
{
buf[i++] = c;
}
buf[i] = '\0'; // 手动添加终止符
// ... 后续状态机逻辑 ...
}getchar() 返回 int 而不是 char——这是为了能表示 EOF(通常是 -1),它与任何合法字符值(0~255)都不冲突。
两种输入方式对比:
| 方面 | fgets | getchar 循环 |
|---|---|---|
| 代码复杂度 | 一行搞定 | 需要手动管理缓冲区和终止符 |
| 换行符处理 | 自动包含 \n,需手动去掉 | 循环条件中过滤掉,无需后处理 |
| 缓冲区安全 | 自动(传入大小) | 需手动确保不越界 |
| 适用场景 | 读一行文本 | 逐字符处理,流式输入 |
6.4 delspace.c 的启示:去除连续空格
练习包中还有一个 delspace.c 文件,它的任务不同——去除连续空格,只保留一个:
// delspace.c 的状态机有 3 个状态:
// state=0: 正常输出(OUT)
// state=1: 遇到第一个空格(输出它)
// state=2: 连续空格中的后续空格(不输出,跳过)
// 转换表:
// state=0, input=0 (非空格) → state=0, 输出
// state=0, input=1 (空格) → state=1, 输出这个空格
// state=1, input=0 → state=0, 输出
// state=1, input=1 → state=2, 不输出(跳过)
// state=2, input=0 → state=0, 输出
// state=2, input=1 → state=2, 不输出(跳过)这展示了状态机的一个重要特性:增加状态数量可以处理更复杂的模式。从 2 个状态(IN/OUT)扩展到 3 个状态(NORMAL/FIRST_SPACE/SUBSEQUENT_SPACE),程序就能区分「第一个空格」和「后续空格」。
7. K&R 状态机:单词计数的经典
7.1 《The C Programming Language》中的 wc 程序
Kernighan 和 Ritchie 在《The C Programming Language》中展示了一个经典的状态机示例——统计行数、单词数和字符数(wc 命令的核心逻辑):
#include <stdio.h>
#define IN 1 // 在单词内
#define OUT 0 // 在单词外
int main(void)
{
int c, nl, nw, nc, state;
state = OUT;
nl = nw = nc = 0; // 多重赋值:nl=0, nw=0, nc=0
while ((c = getchar()) != EOF)
{
++nc; // 每读一个字符,字符数 +1
if (c == '\n')
++nl; // 遇到换行,行数 +1
if (c == ' ' || c == '\n' || c == '\t')
state = OUT;
else if (state == OUT)
{
state = IN;
++nw; // 从 OUT 进入 IN → 新单词开始
}
}
printf("%d %d %d\n", nl, nw, nc);
return 0;
}K&R 版本的巧妙之处:
- 用
state == OUT检测单词开始:不需要else if (state == OUT && input == 1)这种双重条件。当c不是分隔符(空格、换行、制表符)且当前state == OUT时,意味着一个新单词开始了——此时++nw计数 - 不需要记录单词内容:因为只统计数量,不需要
p和counter - 不需要处理单词结束:不需要
else if (state == IN && input == 0)。分隔符简单地设置state = OUT,下一个非分隔符字符会触发新单词计数 - 极简而完整:三行计数(字符、行、单词)交织在一起,所有逻辑浓缩在不到 20 行
IMPORTANT
K&R 的状态机是「检测进入 IN 的时刻」来计数单词,而不是「检测从 IN 离开的时刻」。这是两种等价的计数策略——前者在单词开始时计数,后者在单词结束时计数。本课练习选择了后者(因为需要打印完整单词后再计数),但两种思想都值得理解。
7.2 两种计数策略的对比
// 策略 A:进入时计数(K&R 风格)
// 优点:不需要处理循环结束后的残留状态
// 缺点:单词刚开始时还不知道它的长度
if (state == OUT && c_is_alpha)
{
state = IN;
words++; // 立即计数
}
// 策略 B:离开时计数(本课风格)
// 优点:计数时已经知道完整单词内容
// 缺点:需要处理循环结束后 state==IN 的情况
if (state == IN && !c_is_alpha)
{
state = OUT;
words++; // 单词完整了才计数
print_word(p, counter);
}| 方面 | 进入时计数(K&R) | 离开时计数(本课) |
|---|---|---|
| 计数时机 | 遇到单词首字符 | 遇到单词后的分隔符 |
| 代码简洁度 | 更简洁(不需要后处理) | 稍复杂(需要后处理) |
| 单词内容 | 计数时未知 | 计数时已知 |
| 适用场景 | 只统计数量 | 需要记录/打印单词内容 |
参考解答
练习:单词计数状态机
#include <stdio.h>
int get_input_type(char c)
{
if (c >= 'a' && c <= 'z')
return 1;
if (c >= 'A' && c <= 'Z')
return 1;
return 0;
}
int main(void)
{
char buf[512];
int state = 0;
int i = 0;
int words = 0;
char *p = NULL;
int counter = 0;
fgets(buf, sizeof(buf), stdin);
/* 去掉换行 */
for (i = 0; buf[i]; i++)
if (buf[i] == '\n') { buf[i] = '\0'; break; }
i = 0;
while (1)
{
char c = buf[i];
int input;
if (c == '\0')
break;
input = get_input_type(c);
// 分支 1: OUT + 非字母 → 保持 OUT
if (state == 0 && input == 0)
{
state = 0;
}
// 分支 2: OUT + 字母 → 进入 IN(新单词开始)
else if (state == 0 && input == 1)
{
state = 1;
p = &buf[i]; // 记录单词起始位置
counter = 1; // 包含当前字符
}
// 分支 3: IN + 非字母 → 回到 OUT(单词结束)
else if (state == 1 && input == 0)
{
state = 0;
words++;
printf("word %d found!\n", words);
for (int k = 0; k < counter; k++)
printf("%c", p[k]);
printf("\n");
}
// 分支 4: IN + 字母 → 保持 IN(单词继续)
else if (state == 1 && input == 1)
{
state = 1;
counter++;
}
i++;
}
// 处理循环结束后仍处于 IN 状态的最后一个单词
if (state == 1)
{
words++;
printf("word %d found!\n", words);
for (int k = 0; k < counter; k++)
printf("%c", p[k]);
printf("\n");
}
printf("there is %d words found!\n", words);
return 0;
}核心要点回顾:
p = &buf[i]用指针记住单词的起始地址counter跟踪单词长度,每次遇到字母递增- 循环结束后检查
state == 1处理最后一个无分隔符结尾的单词 - 四个 if-else 分支覆盖了 (state × input) 的 2×2 全部组合
switch-case 版本
#include <stdio.h>
int get_input_type(char c)
{
if (c >= 'a' && c <= 'z')
return 1;
if (c >= 'A' && c <= 'Z')
return 1;
return 0;
}
int main(void)
{
char buf[512];
int state = 0;
int i = 0;
int words = 0;
char *p = NULL;
int counter = 0;
fgets(buf, sizeof(buf), stdin);
for (i = 0; buf[i]; i++)
if (buf[i] == '\n') { buf[i] = '\0'; break; }
i = 0;
while (1)
{
char c = buf[i];
int input;
if (c == '\0')
break;
input = get_input_type(c);
switch (state)
{
case 0: // OUT 状态
if (input == 0)
state = 0; // 保持 OUT
else // input == 1
{
state = 1;
p = &buf[i];
counter = 1;
}
break;
case 1: // IN 状态
if (input == 0)
{
state = 0;
words++;
printf("word %d found!\n", words);
for (int k = 0; k < counter; k++)
printf("%c", p[k]);
printf("\n");
}
else // input == 1
{
state = 1;
counter++;
}
break;
}
i++;
}
if (state == 1)
{
words++;
printf("word %d found!\n", words);
for (int k = 0; k < counter; k++)
printf("%c", p[k]);
printf("\n");
}
printf("there is %d words found!\n", words);
return 0;
}switch-case 版本按状态分大类,语义更清晰。当状态数增加时(如 delspace.c 的 3 状态),switch-case 的优势会更明显。
课堂讨论
- 为什么要把字符分类的逻辑抽成
get_input_type()函数?直接在主循环中判断字母范围不行吗?这样设计有什么好处? - 如果不引入
state变量,你会怎么实现单词计数?与状态机方案相比,是更简单还是更复杂? - 如果把
(state, input)到(新状态, 动作)的映射做成一张表格,程序结构会发生什么变化?这种「表驱动」方式有什么优缺点? - K&R 经典 wc 程序在遇到单词首字符时就计数(
++nw),而本课在单词结束时计数——两种策略各有什么优劣?什么场景下应该选哪一种? - 本课的单词定义为「字母序列」。如果扩展定义为「以字母开头、可包含字母和数字」的序列,
get_input_type和状态机需要怎样修改?
讨论答案
Q1: 为什么要把字符分类抽成 get_input_type() 函数?
这是抽象分层思想的典型应用。
把字符分类逻辑单独抽成函数,让状态机主循环只处理「输入类型」这个抽象概念,而不关心字符的 ASCII 值范围。这带来了几个好处:
- 可读性提升:主循环中
if (input == 1)比if (c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z')简洁得多,状态机的转换逻辑一目了然 - 单一职责:
get_input_type只负责「这个字符属于什么类型」,状态机只负责「根据类型和状态决定做什么」——各司其职 - 易于修改:如果以后要把单词定义从「纯字母」改为「字母+数字」,只需修改
get_input_type一个函数,状态机主循环一行不用改 - 易于测试:可以单独测试
get_input_type('a')是否返回 1、get_input_type(' ')是否返回 0,不依赖整个程序
这正是「策略模式」的朴素形态——把易变的部分(分类规则)封装起来,让稳定的部分(状态转换逻辑)不受影响。回顾 Lesson 08 的 find(num, digit) 函数设计——把「找数字」的逻辑抽成独立函数,main 中只需关心遍历和累加,思想一脉相承。
Q2: 不用 state 变量怎么实现?
不用 state 的最直接方案是用一个 in_word 标志位——但这其实就是 state 变量。
让我们尝试几种「无状态」的方案:
方案 A:扫描分隔符:遍历字符串,每遇到一个非字母字符,就检查它后面是否跟着字母——如果是,就说明一个新单词开始了。
int words = 0;
for (int i = 0; buf[i]; i++)
{
// 检查当前字符是否是单词首字符:
// buf[i] 是字母,且 (i==0 或 buf[i-1] 不是字母)
if (isalpha(buf[i]) && (i == 0 || !isalpha(buf[i-1])))
words++;
}这确实不需要显式的 state 变量,但它本质上仍然是一种状态机的思想——i == 0 || !isalpha(buf[i-1]) 等价于「之前的字符状态是 OUT」。而且这种写法有局限:它只能计数,无法记录单词内容(因为你「回头看」时,单词已经过去了)。
方案 B:用标志位:
int in_word = 0; // ← 这其实就是一个 state 变量!
while (...)
{
if (isalpha(c) && !in_word) { in_word = 1; words++; }
if (!isalpha(c)) { in_word = 0; }
}这与状态机方案几乎一模一样,只是变量名不同。in_word 是一个布尔状态,state 是一个整数状态——本质相同。当状态多于两个时(如 delspace.c 的三个状态),布尔标志位就不够用了——而 state 可以取任意多个整数值,这正是状态机相比简单标志位的优势。
结论:对于需要「记住之前发生了什么」的问题,某种形式的「状态记录」是不可避免的。状态机把这种需求显式化、系统化,让程序结构更清晰。
Q3: 表驱动状态机的优缺点?
表驱动状态机把转换逻辑从代码中提取到数据中:
// 状态转换表
int next_state[2][2] = {
{0, 1}, // state=0: input=0→0, input=1→1
{0, 1}, // state=1: input=0→0, input=1→1
};
// 主循环中只需一行查表
state = next_state[state][input];优点:
- 可验证性:表格一目了然——
2×2 = 4个格子是否全部填了?是否有遗漏?是否有冲突? - 易于修改:增加一个新状态只需扩展数组维度,不需要修改主循环逻辑
- 数据驱动:表格可以从外部文件读取,实现运行时配置——比如从配置文件加载状态机规则
- 与数学模型对应:有限状态自动机在数学上就是
(Q, Σ, δ, q0, F)五元组,表格直接对应转移函数 δ
缺点:
- 动作耦合困难:状态转换通常伴随「动作」(如打印单词),单纯查表只能得到新状态,动作仍需额外处理
- 调试不直观:出错时你看到的是「数组索引越界」而不是「第 3 个分支逻辑错误」
- 小规模过度设计:对于只有 2 状态 2 输入的简单场景,查表法反而比 if-else 更绕
在工业级场景(如 TCP 协议状态机有 11 个状态、数十种事件),表驱动是标准做法——Linux 内核的 TCP 实现就用了状态转换表。但在教学和简单场景中,if-else 或 switch-case 的可读性更好。
Q4: K&R 的「进入时计数」vs 本课的「离开时计数」
进入时计数(K&R):
if (c == ' ' || c == '\n' || c == '\t')
state = OUT;
else if (state == OUT)
{
state = IN;
++nw; // 刚进入单词就计数
}- 优点:代码极简,不需要处理循环结束后的残留状态(因为进入 IN 时已经计数了,即使循环在 IN 状态下退出也没关系)
- 缺点:计数时还不知道单词的内容和长度,无法在计数前处理单词
离开时计数(本课):
if (state == IN && input == 0)
{
state = OUT;
words++;
print_word(p, counter); // 可以访问完整单词
}- 优点:计数时已经拥有完整单词(起始地址
p+ 长度counter),可以打印、保存、分析 - 缺点:需要处理循环结束后
state == IN的边界情况
选择建议:如果只统计数量(如 wc -w),用 K&R 风格更简洁;如果需要记录或处理单词内容(如本课练习要求打印每个单词),用「离开时计数」更自然。
这两种策略在更大的程序中可以共存——进入时递增计数器,离开时保存单词到数组。这正是灵活运用状态机的体现:同一个状态转换可以触发多个动作。
Q5: 单词定义扩展为「字母开头+可含数字」怎么改?
需要修改两个地方:字符分类函数和状态机逻辑。
修改 1:扩展 get_input_type
当前单词定义为纯字母序列,如果允许单词包含数字(但必须以字母开头),输入类型需要从 2 种扩展到 3 种:
enum InputType {
TYPE_DELIM = 0, // 分隔符(空格、标点等)
TYPE_ALPHA = 1, // 字母(可作单词首字符或内容)
TYPE_DIGIT = 2, // 数字(只能出现在单词内部)
};
int get_input_type_v2(char c)
{
if (c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z')
return TYPE_ALPHA;
if (c >= '0' && c <= '9')
return TYPE_DIGIT;
return TYPE_DELIM;
}修改 2:状态机增加状态
当前只有 IN/OUT 两个状态不够了——因为数字在 OUT 状态和 IN 状态下的含义不同(OUT 下遇到数字是分隔符,IN 下遇到数字是单词内容):
OUT + ALPHA → IN(新单词开始)
OUT + DIGIT → OUT(数字不能开头,视为分隔符)
OUT + DELIM → OUT(连续分隔符)
IN + ALPHA → IN(单词继续)
IN + DIGIT → IN(单词内部的数字是合法的) ← 新增
IN + DELIM → OUT(单词结束)或者引入一个新状态 IN_NUM 来区分:
enum State { OUT = 0, IN = 1, IN_NUM = 2 };
// 转换表:
// OUT + ALPHA → IN (新单词,只含字母)
// OUT + DIGIT → OUT (数字不能开头)
// OUT + DELIM → OUT
// IN + ALPHA → IN
// IN + DIGIT → IN_NUM (单词内部出现数字)
// IN + DELIM → OUT (单词结束)
// IN_NUM + ALPHA → IN_NUM
// IN_NUM + DIGIT → IN_NUM
// IN_NUM + DELIM → OUT (单词结束)这说明当「同一输入在不同上下文中有不同含义」时,就需要更多状态来区分上下文。这本质上就是正则表达式到 DFA(确定有限状态自动机)的转换过程——词法分析器正是这样工作的。
课后练习
统计数字个数:修改程序,统计输入文本中「数字」(由 0-9 组成的连续序列)的个数,而不是单词。需要修改
get_input_type的分类逻辑和状态机的输出格式。知识点提示:修改
get_input_type的返回值(字母→0,数字→1),状态机逻辑不变,只改输出提示文字delspace 连续空格去重:实现
delspace.c,从标准输入读取文本,输出时将连续多个空格压缩为一个空格。输入"This is a book."→ 输出"This is a book."。知识点提示:3 状态状态机(NORMAL/FIRST_SPACE/SUBSEQUENT_SPACE),参考
delspace.c文件中的注释同时统计单词和数字:扩展状态机,同时统计文本中单词和数字的个数。要求一次遍历完成两种统计。
知识点提示:扩展
get_input_type返回 3 种类型(字母/数字/分隔符),状态机需要 3 个状态(OUT/IN_WORD/IN_NUM),两组计数器单词长度统计:修改程序,在统计单词个数的同时,记录并输出每个单词的长度。输出格式:
"word 1: This (length=4)"。知识点提示:
counter变量已经记录了当前单词的长度,在分支 3(单词结束)时直接使用即可K&R wc 完整实现:实现一个简化版
wc命令,统计标准输入的行数、单词数和字符数。单词分隔符包括空格、制表符和换行符。知识点提示:参考 K&R 的「进入时计数」策略,用
IN/OUT宏代替魔数 0/1,一次遍历同时更新三个计数器
练习1: 统计数字个数
#include <stdio.h>
int get_input_type(char c)
{
// 修改:数字返回 1,其他返回 0
if (c >= '0' && c <= '9')
return 1;
return 0;
}
int main(void)
{
char buf[512];
int state = 0;
int i = 0;
int numbers = 0;
char *p = NULL;
int counter = 0;
fgets(buf, sizeof(buf), stdin);
for (i = 0; buf[i]; i++)
if (buf[i] == '\n') { buf[i] = '\0'; break; }
i = 0;
while (1)
{
char c = buf[i];
int input;
if (c == '\0')
break;
input = get_input_type(c);
if (state == 0 && input == 0)
state = 0;
else if (state == 0 && input == 1)
{
state = 1;
p = &buf[i];
counter = 1;
}
else if (state == 1 && input == 0)
{
state = 0;
numbers++;
printf("number %d found: ", numbers);
for (int k = 0; k < counter; k++)
printf("%c", p[k]);
printf("\n");
}
else if (state == 1 && input == 1)
{
state = 1;
counter++;
}
i++;
}
if (state == 1)
{
numbers++;
printf("number %d found: ", numbers);
for (int k = 0; k < counter; k++)
printf("%c", p[k]);
printf("\n");
}
printf("there is %d numbers found!\n", numbers);
return 0;
}只需修改 get_input_type 的判定条件(从字母改为数字),状态机主体完全不变——这正是抽象分层的威力。
练习2: delspace 连续空格去重
#include <stdio.h>
int get_input_type(char c)
{
if (c == ' ')
return 1;
return 0;
}
int main(void)
{
char buf[512];
int state = 0; // 0: NORMAL, 1: FIRST_SPACE, 2: SUBSEQUENT_SPACE
int i = 0;
int c;
// 逐字符读取直到 EOF 或换行
while ((c = getchar()) != EOF && c != '\n')
buf[i++] = c;
buf[i] = '\0';
i = 0;
while (1)
{
char ch = buf[i];
int input;
if (ch == '\0')
break;
input = get_input_type(ch);
// 状态转换表:
// state=0(NORMAL), input=0(非空格) → state=0, 输出
// state=0(NORMAL), input=1(空格) → state=1, 输出空格
// state=1(FIRST), input=0 → state=0, 输出
// state=1(FIRST), input=1 → state=2, 不输出
// state=2(SUB), input=0 → state=0, 输出
// state=2(SUB), input=1 → state=2, 不输出
if (state == 0 && input == 0)
{
state = 0;
printf("%c", ch);
}
else if (state == 0 && input == 1)
{
state = 1;
printf("%c", ch);
}
else if (state == 1 && input == 0)
{
state = 0;
printf("%c", ch);
}
else if (state == 1 && input == 1)
{
state = 2;
// 不输出,跳过连续空格
}
else if (state == 2 && input == 0)
{
state = 0;
printf("%c", ch);
}
else if (state == 2 && input == 1)
{
state = 2;
// 不输出,继续跳过
}
i++;
}
return 0;
}3 状态 6 分支的状态机。关键点:state=2(SUBSEQUENT_SPACE)下所有空格都被跳过不输出,只有遇到非空格才回到 state=0。
练习3: 同时统计单词和数字
#include <stdio.h>
// 扩展输入类型
#define TYPE_DELIM 0
#define TYPE_ALPHA 1
#define TYPE_DIGIT 2
int get_input_type(char c)
{
if (c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z')
return TYPE_ALPHA;
if (c >= '0' && c <= '9')
return TYPE_DIGIT;
return TYPE_DELIM;
}
int main(void)
{
char buf[512];
int state = 0; // 0: OUT, 1: IN_WORD, 2: IN_NUM
int i = 0;
int words = 0, numbers = 0;
char *p = NULL;
int counter = 0;
fgets(buf, sizeof(buf), stdin);
for (i = 0; buf[i]; i++)
if (buf[i] == '\n') { buf[i] = '\0'; break; }
i = 0;
while (1)
{
char c = buf[i];
int input;
if (c == '\0')
break;
input = get_input_type(c);
switch (state)
{
case 0: // OUT
if (input == TYPE_ALPHA)
{
state = 1;
p = &buf[i];
counter = 1;
}
else if (input == TYPE_DIGIT)
{
state = 2;
p = &buf[i];
counter = 1;
}
// TYPE_DELIM: 保持 state=0
break;
case 1: // IN_WORD
if (input == TYPE_ALPHA)
{
counter++;
}
else // TYPE_DIGIT 或 TYPE_DELIM
{
state = 0;
words++;
printf("word %d: ", words);
for (int k = 0; k < counter; k++)
printf("%c", p[k]);
printf("\n");
}
break;
case 2: // IN_NUM
if (input == TYPE_DIGIT)
{
counter++;
}
else // TYPE_ALPHA 或 TYPE_DELIM
{
state = 0;
numbers++;
printf("number %d: ", numbers);
for (int k = 0; k < counter; k++)
printf("%c", p[k]);
printf("\n");
}
break;
}
i++;
}
// 处理最后一个未结束的序列
if (state == 1)
{
words++;
printf("word %d: ", words);
for (int k = 0; k < counter; k++)
printf("%c", p[k]);
printf("\n");
}
else if (state == 2)
{
numbers++;
printf("number %d: ", numbers);
for (int k = 0; k < counter; k++)
printf("%c", p[k]);
printf("\n");
}
printf("total: %d words, %d numbers\n", words, numbers);
return 0;
}三个状态(OUT/IN_WORD/IN_NUM)对应三种输入类型(字母/数字/分隔符),switch-case 结构清晰地组织了 3×3 种可能的转换。
练习4: 输出单词长度
#include <stdio.h>
int get_input_type(char c)
{
if (c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z')
return 1;
return 0;
}
int main(void)
{
char buf[512];
int state = 0;
int i = 0;
int words = 0;
char *p = NULL;
int counter = 0;
fgets(buf, sizeof(buf), stdin);
for (i = 0; buf[i]; i++)
if (buf[i] == '\n') { buf[i] = '\0'; break; }
i = 0;
while (1)
{
char c = buf[i];
int input;
if (c == '\0')
break;
input = get_input_type(c);
if (state == 0 && input == 0)
state = 0;
else if (state == 0 && input == 1)
{
state = 1;
p = &buf[i];
counter = 1;
}
else if (state == 1 && input == 0)
{
state = 0;
words++;
// 关键改动:在输出中加入长度信息
printf("word %d: ", words);
for (int k = 0; k < counter; k++)
printf("%c", p[k]);
printf(" (length=%d)\n", counter);
}
else if (state == 1 && input == 1)
{
state = 1;
counter++;
}
i++;
}
if (state == 1)
{
words++;
printf("word %d: ", words);
for (int k = 0; k < counter; k++)
printf("%c", p[k]);
printf(" (length=%d)\n", counter);
}
printf("there is %d words found!\n", words);
return 0;
}counter 变量天然记录了当前单词的长度——在打印时直接输出即可,不需要额外计算。这体现了状态机变量设计的复用性。
练习5: K&R wc 简化实现
#include <stdio.h>
#define IN 1
#define OUT 0
int main(void)
{
int c, nl, nw, nc, state;
state = OUT;
nl = nw = nc = 0;
while ((c = getchar()) != EOF)
{
++nc; // 字符计数
if (c == '\n')
++nl; // 行计数
if (c == ' ' || c == '\n' || c == '\t')
state = OUT; // 分隔符 → 退出单词
else if (state == OUT)
{
state = IN; // 非分隔符 + 之前在 OUT → 新单词
++nw; // 单词计数
}
// else: state == IN && 非分隔符 → 仍在单词内,什么都不做
}
printf("%d %d %d\n", nl, nw, nc);
return 0;
}极简而优雅——17 行代码完成行数、单词数、字符数的统计。核心思想:单词数 = 「从 OUT 进入 IN」的次数。K&R 用 #define IN 1 和 #define OUT 0 代替魔数,提升可读性——这也呼应了 Lesson 08 中 begin/end 符号常量的设计理念。
参考资料
- The C Programming Language, 2nd Edition, Section 1.5.4 — K&R 经典状态机 wc 程序的原版实现与讲解
- Finite-state machine — Wikipedia — 有限状态自动机的数学定义、类型(DFA/NFA)与应用领域
- ISO C11 Standard, Section 7.4 —
<ctype.h>字符分类函数的标准规范 - C Interfaces and Implementations, Chapter 19 — David R. Hanson 对表驱动状态机与字符分类的工程实践
- Writing a Lexer in C — Crafting Interpreters — 从状态机到词法分析器的完整演进路径(Lesson 21 的预备阅读)
"The best way to have a good idea is to have lots of ideas." — Linus Pauling