Lesson 08: 数字 9 的个数 CNB
练习任务
编写一个 C 程序,统计 1~100 的所有整数中,数字 9 一共出现了多少次。你需要设计一个函数 find(num, digit),用逐位提取算法计算某个整数中包含多少个指定的数字,然后在 main 中遍历 1~100 累加调用结果。
期望输出:
sum = 20提示:把问题拆成两个子问题——(1)如何从一个数中逐位提取数字?(提示:
% 10取最后一位,/ 10去掉最后一位)(2)如何遍历 1~100 并累加结果?先设计find函数再写main。注意:99 中包含两个 9,你的函数必须能正确识别。
核心知识点
- 函数定义与调用 — 声明(declaration)、定义(definition)、调用(call)、返回(return)四阶段
- 形参与实参 — 形参是函数签名中的占位变量,实参是调用时传入的具体值,按位置绑定
- 按值传递(pass-by-value) — C 语言默认的参数传递方式,实参被复制到形参
- 函数返回值 —
return将结果传回调用处,类型必须与声明一致 - 函数签名设计 — 参数类型选择、参数个数确定、返回类型语义
- 逻辑分解 — 将复杂问题拆为独立子问题,每个函数负责一件事
- 单一职责原则(SRP) — 每个函数只做一件事,做好一件事
- 逐位提取算法 —
num % 10取个位 →num / 10去掉个位 → 循环至 0 do-while循环 — 先执行后判断,保证循环体至少运行一次whilevsdo-while— 选择原则与实际应用场景对比- 累加器模式 — 遍历序列,对每个元素调用函数并累加返回值
- 累加器变量初始化 —
sum = 0的必要性与常见错误 - begin/end 命名常量 — 用命名变量替代魔数(Magic Number),提高可读性与可维护性
#define符号常量 — 编译期文本替换,零运行时开销const变量 — 运行时类型安全常量,与#define的对比- 计数器变量命名 —
counter、count、cnt的命名语义与习惯 - K&R 注释风格 —
/* */块注释用于函数头文档,@param参数逐行说明 - 单行注释 —
//风格(C99 引入),适用于简短说明 - 函数隔离测试 — 独立验证函数逻辑正确性的单元测试思维
- 函数复用性 —
find(num, digit)设计适用于任意数字、任意范围 - 执行流程追踪表 — 用表格记录变量变化,理解程序执行过程
- 性能分析初步 — 迭代次数计数与大 N 场景下的算法复杂度直觉
代码框架
#include <stdio.h>
/*
* find - calculate how many digit in num
* @num: the number we want to find
* @digit: the digit we search in num
*
* Return value: how many digit in this num
*/
int find(int num, int digit)
{
int counter = 0;
// 用循环逐位提取 num 的每一位
// 每次: 取 num % 10 与 digit 比较,相等则 counter++
// 然后 num = num / 10 去掉最后一位
// 直到 num == 0 退出
return counter;
}
int main(void)
{
int begin = 1;
int end = 100;
int i;
int sum = 0;
for (i = begin; i <= end; i++)
{
sum += find(i, 9);
}
printf("sum = %d\n", sum);
return 0;
}填充以上框架的关键思考:逐位提取用什么循环——while 还是 do-while?提取数字的顺序是从个位到高位,这对计数结果有影响吗?如果 num 初始为 0,循环应该执行多少次?counter 的初始值为什么必须是 0?sum 的初始值为什么也必须是 0?
TIP
先不要往下翻看参考解答。尝试实现 find 函数体,想想为什么使用 do-while 而非 while——如果 num 初始为 0 会怎样?99 中包含两个 9,你的循环能正确检测到吗?
深度讲解
1. 函数:C 语言的模块化基石
函数是 C 语言中最核心的抽象机制。它将一段逻辑封装为一个命名单元,使得代码可以复用、测试和独立理解。本课通过设计 find 函数,系统性地讲解函数的所有关键概念。
1.1 函数的四要素
C 语言中一个完整的函数由四个部分组成——每一个部分都有明确的语义和语法要求:
int find(int num, int digit) // 1. 返回类型 + 2. 函数名 + 3. 形参列表
{ // 4. 函数体(花括号内)
int counter = 0;
do {
if (num % 10 == digit)
counter++;
num = num / 10;
} while (num != 0);
return counter; // 返回语句
}| 要素 | 说明 | 本例 |
|---|---|---|
| 返回类型 | 函数计算结果的数据类型,写在函数名之前 | int |
| 函数名 | 标识函数用途的命名,遵循标识符命名规则 | find |
| 形参列表 | 函数需要的输入,每个参数必须声明类型和名称 | int num, int digit |
| 函数体 | 花括号 { ... } 中的语句序列,定义函数的具体行为 | 逐位提取与计数逻辑 |
返回类型的选择:find 的返回值是"数字在数中出现的次数",这是一个非负整数,所以用 int 是自然的选择。如果函数不返回任何值(如只打印输出),则返回类型为 void。
void say_hello(void) // 无返回值,无参数
{
printf("Hello!\n");
// 没有 return 语句,或可以写 return; (不带值)
}
int get_answer(void) // 返回 int,无参数
{
return 42;
}
double average(int a, int b) // 返回 double,两个 int 参数
{
return (a + b) / 2.0;
}IMPORTANT
C89 标准中,如果函数没有显式声明返回类型,编译器默认返回类型为 int。但这是一种不良实践——始终显式写出返回类型,即使是 int。C99 及之后的标准已废弃隐式 int。
1.2 函数的声明与定义:分离关注点
C 语言区分函数声明(declaration)和函数定义(definition):
// 函数声明(declaration)——只告诉编译器函数"长什么样"
int find(int num, int digit); // 注意末尾的分号!没有函数体
// 函数定义(definition)——提供函数的完整实现
int find(int num, int digit) // 注意这里没有分号
{
int counter = 0;
// ... 函数体 ...
return counter;
}声明告诉编译器函数的签名(返回类型 + 函数名 + 参数类型列表),让编译器能够在函数被定义之前就检查调用是否正确。定义提供函数体的完整实现。
为什么需要分离?考虑以下场景:
#include <stdio.h>
// 声明:告诉编译器 find 函数存在
int find(int num, int digit);
int main(void)
{
// 此时 find 尚未定义,但编译器知道它的签名
printf("%d\n", find(99, 9)); // OK:编译器已知道 find 的签名
return 0;
}
// 定义:在 main 之后才给出具体实现
int find(int num, int digit)
{
int counter = 0;
do {
if (num % 10 == digit) counter++;
num /= 10;
} while (num != 0);
return counter;
}这种"先声明后定义"的模式称为前向声明(forward declaration),在大型项目中非常常见——头文件(.h)中包含声明,源文件(.c)中包含定义。
NOTE
在本课的代码框架中,find 的定义写在 main 之前,因此不需要额外的声明——编译器在遇到 main 中 find(i, 9) 调用时,已经见过了 find 的定义。
1.3 函数调用机制:从调用到返回
当 main 中执行 sum += find(i, 9) 时,发生了以下步骤:
1. 调用方(main):
- 计算实参表达式 i 和 9 的值
- 将实参的值按位置复制给形参 num 和 digit
- 保存当前执行位置(返回地址)
- 跳转到 find 函数的第一条指令
2. 被调方(find):
- 执行函数体中的语句
- 遇到 return counter; 时:
a. 计算 counter 的值
b. 将返回值放入约定的位置(通常是寄存器 eax/rax)
c. 跳转回调用方保存的返回地址
3. 调用方(main):
- 从约定的位置取出返回值
- 将返回值用于 sum += ... 表达式
- 继续执行下一条语句这个过程在汇编层面体现为 call 和 ret 指令,参数通过寄存器或栈传递(取决于调用约定)。虽然初学阶段不需要深入汇编,但理解这个流程有助于建立"函数调用不是魔法"的直觉。
2. 形参与实参:接口的契约
2.1 术语辨析:Parameter vs Argument
这两个词在中文中常被混用,但在 C 语言中有严格区别:
int find(int num, int digit) // num, digit 是形参(parameter)
{ // ——函数签名中的占位符变量
// ...
}
int main(void)
{
int result = find(99, 9); // 99, 9 是实参(argument)
// ↑ ↑ ——调用时传入的具体值
// 实参 99 绑定到形参 num
// 实参 9 绑定到形参 digit
}| 维度 | 形参(Parameter) | 实参(Argument) |
|---|---|---|
| 出现位置 | 函数定义/声明的参数列表中 | 函数调用的参数列表中 |
| 本质 | 变量声明——在函数被调用时才分配内存 | 表达式——调用时求值后传给形参 |
| 生命周期 | 函数调用开始时创建,返回时销毁 | 调用方的作用域决定 |
| 类比 | 酒店房间的"预订名" | 实际入住的"客人" |
2.2 按位置绑定:顺序决定一切
C 语言的参数绑定是严格按位置的——第一个实参绑定第一个形参,第二个实参绑定第二个形参,以此类推。形参名称不影响绑定:
int find(int num, int digit) { /* ... */ }
// 以下两个调用等价——形参名不影响绑定:
find(99, 9); // 99→num, 9→digit ✓
find(9, 99); // 9→num, 99→digit ✗ 语义完全错误!在 find(99, 9) 中,我们想表达"在 99 中找数字 9"。如果写成 find(9, 99),就变成了"在 9 中找数字 99"——虽然编译不会报错(类型匹配),但语义完全错误。
WARNING
参数顺序错误是最隐蔽的 bug 之一——编译器无法检测语义层面的错误。防御策略:(1)参数命名要有区分度(num vs digit 而非 a vs b);(2)调用时思考每个实参的含义是否与形参名匹配。
2.3 实参与形参的类型兼容性
实参的类型必须能够隐式转换为形参的类型:
void process(int x) { /* ... */ }
int main(void)
{
process(42); // ✓ int → int,完全匹配
process(3.14); // ⚠ double → int,小数部分被截断,结果为 3
process('A'); // ⚠ char → int,'A' 的 ASCII 值 65
process("hello"); // ✗ char* → int,编译错误或警告
return 0;
}C 语言会进行一些隐式类型转换(如 double → int 截断、char → int 提升),但这些转换可能丢失信息或产生意外结果。最佳实践是确保实参类型与形参类型完全匹配。
2.4 形参数量必须匹配
find(99); // ✗ 编译错误:实参太少
find(99, 9, 5); // ✗ 编译错误:实参太多
find(99, 9); // ✓ 正确编译器会严格检查参数数量——每个形参必须有对应的实参,不能多也不能少。这一点与 Python 的默认参数或 JavaScript 的 arguments 对象完全不同。
3. 按值传递(Pass by Value):C 语言的参数传递机制
3.1 核心原理:复制而非引用
C 语言中所有函数参数都是按值传递(pass by value)的——实参的值被复制到形参中,函数内部对形参的修改不影响调用方的实参变量:
#include <stdio.h>
void try_modify(int x)
{
printf(" inside: before x = %d\n", x);
x = 100; // 修改形参 x
printf(" inside: after x = %d\n", x);
}
int main(void)
{
int a = 5;
printf("before call: a = %d\n", a);
try_modify(a); // 传递 a 的值(5)给形参 x
printf("after call: a = %d\n", a);
return 0;
}输出:
before call: a = 5
inside: before x = 5
inside: after x = 100
after call: a = 5 ← a 没有被修改!内存视角:调用 try_modify(a) 时,系统在栈上为形参 x 分配了独立的内存空间,并将 a 的值 5 复制进去。函数内部修改的是 x 的副本,a 的原始内存位置从未被触及。
栈内存布局(简化):
调用前: 调用 try_modify 时:
┌──────┐ ┌──────────┐
│ a: 5 │ │ a: 5 │ ← main 的栈帧
└──────┘ ├──────────┤
│ x: 5 │ ← try_modify 的栈帧
│ (副本) │ (x = 100 只修改这里)
└──────────┘3.2 按值传递在 find 中的体现
在 find 函数中,我们直接修改了形参 num:
int find(int num, int digit)
{
int counter = 0;
do {
if (num % 10 == digit)
counter++;
num = num / 10; // 修改形参 num
} while (num != 0);
return counter;
}num = num / 10 直接修改了形参——这在按值传递下是完全安全的。因为 num 是调用方实参的一个副本,无论我们在函数内怎么改,调用方的原始值都不会受影响。如果 C 语言使用"按引用传递",那 find(i, 9) 之后 i 的值也会变成 0——这显然不是我们想要的。
3.3 为什么 scanf 需要 &:按值传递的必然结果
回顾 Lesson 04 中学到的 scanf 需要 & 取地址符:
int x;
scanf("%d", &x); // 必须传 &x,不能传 x这正是按值传递的直接后果:如果传 x,scanf 拿到的是 x 值的副本,修改副本无法影响 x 本身。传 &x 则传递了 x 的地址——虽然地址本身也是按值传递的(地址值被复制),但通过地址可以间接修改 x 的内容。这引出了 C 语言中"模拟按引用传递"的技术——传指针。
#include <stdio.h>
// 通过指针模拟按引用传递
void real_modify(int *p)
{
*p = 100; // 通过解引用修改 p 指向的变量
}
int main(void)
{
int a = 5;
printf("before: a = %d\n", a);
real_modify(&a); // 传递 a 的地址
printf("after: a = %d\n", a); // a 被修改了!
return 0;
}NOTE
关于指针的详细讲解将在后续课程中展开。目前只需记住:C 语言所有参数都是按值传递的。这一事实影响深远——它决定了哪些操作会影响调用方,哪些不会。
4. 逐位提取算法:%10 与 /10 的数学原理
4.1 核心思想:用除法和取余拆解数字
逐位提取算法利用的是十进制数的位权结构。对于任意非负整数 num:
num % 10给出num的个位数字(除以 10 的余数)num / 10给出去掉个位后的剩余部分(整数除法,舍去小数)
这个过程可以反复进行,每次"剥离"出当前的个位:
num = 395
第 1 步: num % 10 = 395 % 10 = 5 (提取个位: 5)
num / 10 = 395 / 10 = 39 (去掉个位后剩余: 39)
第 2 步: num % 10 = 39 % 10 = 9 (提取十位: 9)
num / 10 = 39 / 10 = 3 (去掉十位后剩余: 3)
第 3 步: num % 10 = 3 % 10 = 3 (提取百位: 3)
num / 10 = 3 / 10 = 0 (去掉百位后: 0 → 停止)每一步都在"折叠"数字的十进制表示——这就是为什么 num / 10 最终会变为 0:每一次除以 10,数字就少一位。
4.2 为什么 /10 最终一定到达 0
这涉及整数的阿基米德性质:对于任意正整数 n,反复除以 10 必然在有限步内到达 0。因为每次除法至少将数字缩小 10 倍(严格来说是缩小到 floor(n/10)),而正整数不能无限缩小。
n 的位数 = floor(log10(n)) + 1
循环次数 = n 的位数(对于 n > 0)
例如:
n = 1 → 1 位 → 1 次循环
n = 99 → 2 位 → 2 次循环
n = 100 → 3 位 → 3 次循环
n = 10000 → 5 位 → 5 次循环这意味着对于任意数字,算法的循环次数等于该数字的十进制位数——这是一个非常高效的性质。
4.3 提取顺序:从低位到高位
逐位提取是从个位向高位进行的。这对计数结果有影响吗?
提取 395 的顺序: 5 → 9 → 3(个位→十位→百位)
提取 399 的顺序: 9 → 9 → 3(个位→十位→百位)
数字 9 出现 2 次: 个位和十位各一个 9因为我们只关心"某个数字出现了多少次",而不关心它出现在什么位置,所以提取顺序对计数结果没有影响。无论从高位到低位还是从低位到高位,只要每一位都被检查到,计数就是正确的。
但如果需求变为"数字 9 出现在十位上的次数",那提取顺序(或额外的位置信息)就变得重要了——这体现了需求决定算法设计的原则。
4.4 逐位提取算法的通用性
这个算法的本质是以 10 为基数的进制分解。如果将基数从 10 换成其他值,就能提取其他进制下的"位":
// 以 10 为基数:逐位提取十进制数字
int find(int num, int digit)
{
int counter = 0;
do {
if (num % 10 == digit) counter++;
num /= 10;
} while (num != 0);
return counter;
}
// 以 2 为基数:逐位提取二进制位(检查是否为 1)
int count_ones(int num)
{
int counter = 0;
do {
if (num % 2 == 1) counter++; // % 2 取最低二进制位
num /= 2; // / 2 右移一位
} while (num != 0);
return counter;
}这种"进制无关"的通用性是 % base 和 / base 组合的核心优势。
5. do-while 循环:先执行,后判断
5.1 语法结构与语义
do {
// 循环体——至少执行一次
语句1;
语句2;
} while (条件); // ← 注意这个分号!绝对不能省略与 while 的对比:
// while:先判断,后执行(可能执行 0 次)
while (条件) {
循环体;
}
// do-while:先执行,后判断(至少执行 1 次)
do {
循环体;
} while (条件);执行流程图:
while: do-while:
┌─────────┐ ┌─────────┐
│ 判断条件 │ │ 循环体 │
└────┬─────┘ └────┬─────┘
│ true │
▼ ▼
┌─────────┐ ┌─────────┐
│ 循环体 │ │ 判断条件 │
└────┬─────┘ └────┬─────┘
│ │ true
└──────→ 回到判断 │
│ false ▼ false
▼ 退出循环
退出循环5.2 为什么逐位提取适合 do-while
关键原因:任何非负整数至少有一位数字。即使是 num = 0,它也有数字"0"这一位。因此循环体至少需要执行一次——先提取 num % 10,再判断 num / 10 后是否还有数字。
// do-while 版本(正确且自然)
int counter = 0;
do {
if (num % 10 == digit)
counter++;
num = num / 10;
} while (num != 0);如果把 do-while 改成 while:
// while 版本——对 1~100 也能工作
int counter = 0;
while (num != 0) { // 如果 num 初始为 0,循环体一次也不执行!
if (num % 10 == digit)
counter++;
num = num / 10;
}对于本课的范围(1~100),num 永远不为 0,所以两种写法结果相同。但如果 find 被调用为 find(0, 0):
| 写法 | find(0, 0) 的行为 | 结果 |
|---|---|---|
do-while | 先执行一次:0 % 10 == 0 成立 → counter++ → num = 0/10 = 0 → num != 0 假 → 退出 | counter = 1 |
while | 先判断:num != 0 为假 → 跳过循环体 | counter = 0 |
find(0, 0) 的正确结果应该是 1(数字 0 在 0 中出现了 1 次)。do-while 给出了正确答案,while 则给出了错误的 0。
IMPORTANT
这个边界条件揭示了 do-while 的核心价值:当循环体至少需要执行一次时,用 do-while 表达这种语义,让代码更准确地反映设计意图。即使当前的数据范围碰巧让 while 也能工作,选择正确的循环结构是对未来维护者的尊重。
5.3 do-while 与 while 的选择原则
| 判断标准 | 用 while | 用 do-while |
|---|---|---|
| 循环体是否至少执行一次? | 不一定 | 一定 |
| 终止条件是否依赖于循环体内的计算? | 可能在循环前已知 | 通常在循环后才知道 |
| 循环次数可能为 0 吗? | 是(如空文件读取) | 否 |
典型应用场景:
| 场景 | 推荐 | 原因 |
|---|---|---|
| 逐位提取数字 | do-while | 数字至少有一位,必须至少处理一次 |
| 菜单交互 | do-while | 先显示菜单,再判断用户是否选择退出 |
| 输入验证 | do-while | 先读入一次数据,再判断是否有效,无效则重新读入 |
| 读取文件 | while | 文件可能为空,先判断是否读到 EOF 再处理内容 |
| 遍历链表 | while | 链表可能为空(head == NULL),不能假设至少有一个节点 |
| 游戏主循环 | do-while | 先运行一局,再问是否再来一局 |
5.4 do-while 的常见错误
// 错误 1:漏掉分号
do {
printf("Hello\n");
} while (count < 10) // ✗ 缺少分号!编译错误
// ↑ 这里必须有分号
// 错误 2:花括号不匹配
do
printf("A\n");
printf("B\n"); // ✗ 这一行不在循环体内!
while (count < 10); // 只有 printf("A\n"); 被循环
// 正确写法:用花括号明确循环体范围
do {
printf("A\n");
printf("B\n");
} while (count < 10);CAUTION
do-while 末尾的分号是最容易被遗忘的语法元素之一。while 循环的花括号后没有分号,但 do-while 的 while(条件) 后必须有分号。这是 C 语法中少数几个"看似多余的"分号之一。
6. 逻辑分解与模块化思维
6.1 问题拆解树
面对"统计 1~100 中数字 9 的总出现次数"这个问题,直接写一大段代码是低效的。更有效的方法是逻辑分解——将宏观问题拆为独立子问题,每个子问题单独解决,最后组合:
统计 1~100 中数字 9 的总出现次数
│
├── 子问题 1: 给定一个数和一个目标数字,数出这个数中包含多少个目标数字
│ └── 方案: find(num, digit) 函数
│ ├── 输入: 被搜索的数 num、目标数字 digit
│ ├── 算法: do-while + %10 + /10 逐位提取
│ └── 输出: 该数中目标数字的出现次数
│
└── 子问题 2: 对 1~100 的每个数,累加 find(i, 9) 的结果
└── 方案: for 循环 + sum 累加器
├── 遍历: for (i = 1; i <= 100; i++)
├── 累加: sum += find(i, 9)
└── 输出: printf("sum = %d\n", sum)6.2 为什么拆分而不写一个大函数
如果把所有逻辑写在一个大函数中:
// 不推荐的「大杂烩」写法
int main(void)
{
int i, sum = 0;
for (i = 1; i <= 100; i++)
{
int n = i;
do {
if (n % 10 == 9) sum++;
n /= 10;
} while (n != 0);
}
printf("sum = %d\n", sum);
return 0;
}这段代码虽然能正确输出 20,但有严重的设计问题:
- 无法复用:如果想找数字
7的个数,需要修改内层逻辑(9→7)。如果想统计 1~1000,需要修改循环边界。 - 难以测试:无法单独验证"逐位提取"逻辑是否正确——你只能运行整个程序看最终结果。如果最终结果是 19 而非 20,你无法快速定位是逐位提取错了还是累加错了。
- 可读性差:混合了两个层次(逐位匹配 + 遍历累加)的逻辑,阅读者必须同时理解两件事。
- 修改风险高:改动内层逻辑可能意外影响外层循环的行为。
拆分为 find 函数后,所有这些问题迎刃而解:
// 推荐:模块化写法
sum += find(i, 9); // 找 9——函数名和参数直接表达意图
sum += find(i, 7); // 找 7——只需改一个参数
// find 可以被独立测试、独立优化、独立复用6.3 单一职责原则(SRP)在函数设计中的应用
单一职责原则(Single Responsibility Principle)是软件工程中的核心设计原则:每个函数只负责一个明确的任务,并且做好这一件事。
find 函数的职责边界非常清晰:
find 的职责: 在单个数字中统计某个 digit 的出现次数
find 不负责: 遍历数字范围、累加结果、打印输出、读取输入判断一个函数是否违反 SRP 的方法:尝试用一句话描述它的职责。如果描述中出现了"并且"、"或者"、"以及"等连接词,那它很可能做了不止一件事。
// ✓ 单一职责: "统计 num 中 digit 的出现次数"
int find(int num, int digit) { /* ... */ }
// ✓ 单一职责: "判断 n 是否为素数"
int is_prime(int n) { /* ... */ }
// ✗ 违反 SRP: "判断素数并且打印结果并且统计个数"
int process_primes(int start, int end) {
// 做了太多事情...
}TIP
SRP 不仅适用于函数设计,也适用于变量命名、代码块组织等各个层面。养成"一个实体只做一件事"的习惯,代码的可维护性会大幅提升。
7. 函数签名设计哲学
7.1 为什么用 find(int, int) → int
函数签名的设计是对问题理解的直接体现:
int find(int num, int digit)
// ↑ ↑ ↑
// 返回个数 被搜索的数 目标数字参数选择的分析:
- 两个参数:函数需要知道"在哪个数里找"(
num)和"找什么"(digit),这两个信息缺一不可。既不能多(不需要额外的上下文),也不能少(缺少任何一个都无法工作)。 - 两个都是
int:被搜索的数是整数,目标数字 0~9 虽然只有一位,但用int是最自然的——不需要引入char或short带来的隐式转换问题。 - 命名
num和digit:清晰区分了"被搜索的数字"和"目标数字"。相比之下,a和b或x和y会让人困惑——谁是被搜索的?谁是目标?
返回类型选择的分析:
- 返回值是"数字出现的次数",这是一个非负整数(0, 1, 2, ...)。用
int最自然。 - 理论上可以返回
unsigned int来表达"不能为负"的语义,但int更通用、更少意外(如与printf("%d")的兼容性)。
7.2 函数签名的对称性与直觉性
一个好的函数签名应该具有对称性——参数的顺序和命名让调用者凭直觉就能正确使用:
find(99, 9) // "在 99 中找 9" —— 被搜索的数在前,目标数字在后
find(9, 99) // "在 9 中找 99" —— 虽然编译通过但语义荒谬参数顺序反映了思维的自然顺序:先确定"在什么范围内找"(num),再确定"找什么"(digit)。这种设计让调用代码自文档化——读代码的人不需要查阅函数定义就能理解调用的意图。
7.3 返回值的使用:累加器模式回顾
find 的返回值直接用于累加器模式(回顾 Lesson 05 中的 sum += i):
int sum = 0; // 累加器初始化
for (i = begin; i <= end; i++)
{
sum += find(i, 9); // find 的返回值作为 += 的右操作数
}
// ↑ ↑
// 累加器 每个数的计数结果C 语言的表达式设计允许函数调用直接嵌入算术表达式——sum += find(i, 9) 等价于 sum = sum + find(i, 9),编译器先调用 find(i, 9) 获取返回值,再进行加法。
7.4 累加器变量初始化的必要性
int sum; // ✗ 未初始化!sum 的值是随机的(垃圾值)
int sum = 0; // ✓ 显式初始化为 0如果累加器未初始化,sum += find(i, 9) 会在垃圾值的基础上累加——结果是不可预测的。这是一个非常常见的新手错误。
CAUTION
C 语言的局部变量不会自动初始化为 0。声明 int sum; 后,sum 的值是栈上那块内存中的随机值。任何依赖未初始化变量的行为都是未定义行为(undefined behavior)。
8. 注释的写法:从 K&R 到 Doxygen
8.1 K&R 风格的函数头注释
本课代码中的注释模板源自 Unix/Linux 社区的传统风格:
/*
* find - calculate how many digit in num
* @num: the number we want to find
* @digit: the digit we search in num
*
* Return value: how many digit in this num
*/
int find(int num, int digit)这种风格的要素:
| 元素 | 说明 | 示例 |
|---|---|---|
| 第一行 | 函数名 - 一句话描述 | find - calculate how many digit in num |
@param 字段 | 每个参数一行,格式 @参数名: 说明 | @num: the number we want to find |
@return 字段 | 返回值的含义说明 | Return value: how many digit in this num |
每行开头的 * | 对齐星号——让注释块在视觉上更整洁 | 视觉上形成一条竖线 |
这种风格后来演变为 Linux 内核的 kerneldoc 格式,以及更通用的 Doxygen 格式:
/**
* find - calculate how many digit in num
* @num: the number we want to find
* @digit: the digit we search in num
*
* Return: how many digit in this num
*/
int find(int num, int digit)Doxygen 风格用 /** 开头(两个星号),并可以生成 HTML/PDF 文档。虽然初学阶段不需要使用 Doxygen 工具,但养成这种结构化的注释习惯对后续学习(如阅读开源项目源码、参与团队协作)至关重要。
8.2 C 语言中的两种注释
// 单行注释(C99 标准引入,C89 不支持)
// 适用于简短的、一行的说明
int counter = 0; /* 块注释(C89 标准支持) */
/*
* 多行块注释
* 适用于函数头文档、长段说明
* 星号对齐是社区约定,非语法要求
*/
/* 也可以写成紧凑形式,但可读性较差 */
/* 这段注释没有星号对齐 */
/* 阅读时不容易找到注释的起止 */选择原则:
- 函数头文档:用
/* */块注释(或/** */Doxygen 格式),因为需要多行说明参数和返回值 - 行内简短说明:用
//单行注释,如int counter = 0; // 计数器 - 临时注释掉代码:用
//单行注释更方便(不会与代码中已有的/* */嵌套冲突)
NOTE
/* */ 块注释不支持嵌套。如果在一段已经被 /* */ 注释掉的代码中再出现 /* */,会导致编译错误。这是 C 语言注释系统的一个历史遗留限制。// 单行注释没有这个问题——从 // 到行尾的所有内容都是注释。
8.3 写好注释的原则
注释不是越多越好——糟糕的注释比没有注释更危险:
// ✗ 无意义的注释——只是复述代码
counter++; // 将 counter 加 1
// ✓ 有意义的注释——解释"为什么"而非"是什么"
counter++; // 找到了一处匹配的数字 digit
// ✗ 过时的注释——代码已改但注释未更新
counter = 1; // 初始化计数器为 0 ← 注释说 0,实际是 1!
// ✓ 注释与代码同步
counter = 1; // 从 1 开始计数(因为第一个元素已预先处理)好注释的原则:
- 解释"为什么"而非"是什么":代码本身已经说明了"是什么"(
counter++就是加 1),注释应该解释背后的意图 - 保持同步:修改代码时同步更新注释,过时注释比没有注释更危险
- 简洁精准:一行能说清楚的事不要写三行
- 用英文或中文保持一致:不要在同一个文件的注释中混用中英文
9. 常量定义:begin/end 与魔数之战
9.1 什么是魔数(Magic Number)
魔数(magic number)是直接出现在代码中的、没有名称解释的数字常量:
// 魔数版本——数字的含义需要读者猜测
for (i = 1; i <= 100; i++) // 100 是什么?为什么是 1 和 100?
sum += find(i, 9); // 9 是什么?为什么是 9 而不是 7?100 和 9 本身不携带任何语义信息。阅读者必须通过上下文推断它们的含义——这在简单代码中还不算太糟,但在复杂程序中会成为理解障碍和 bug 来源。
9.2 用命名变量替代魔数
// 命名变量版本——语义自明
int begin = 1; // 范围的起点
int end = 100; // 范围的终点
for (i = begin; i <= end; i++)
sum += find(i, 9);命名变量带来的好处:
- 自文档化:
begin和end清楚地说明了范围的含义,不需要额外注释 - 单点修改:如果要统计 1~200 中的 9,只需改
end = 200一处——而不是在整个文件中搜索100 - 避免遗漏:如果代码中有多处硬编码的
100(边界检查、输出格式等),改漏了一处就会导致 bug - 无运行时代价:
int begin = 1;和直接写1在编译优化后通常产生相同的机器码——可读性提升是"免费"的
9.3 #define 符号常量:编译期文本替换
在更成熟的项目中,通常使用 #define 预处理器指令定义常量:
#include <stdio.h>
#define BEGIN 1
#define END 100
#define DIGIT 9
int find(int num, int digit) { /* ... */ }
int main(void)
{
int i;
int sum = 0;
for (i = BEGIN; i <= END; i++)
sum += find(i, DIGIT);
printf("sum = %d\n", sum);
return 0;
}#define 的工作机制:
- 预处理阶段:在编译之前,预处理器将所有出现
BEGIN的地方文本替换为1,将END替换为100,将DIGIT替换为9 - 无类型检查:
#define只是文本替换,不进行类型检查。#define END 100中的100可以是任何上下文中的int - 无运行时开销:替换发生在编译前,最终生成的机器码与直接写数字完全相同
- 约定大写命名:
#define常量通常全大写(BEGIN、END、DIGIT),以区别于变量
9.4 #define vs const 变量
C 语言中定义常量的另一种方式是 const 关键字:
// #define: 编译前文本替换,无类型,无内存分配
#define END 100
// const: 运行时类型安全的只读变量,有内存分配
const int end = 100;| 维度 | #define END 100 | const int end = 100; |
|---|---|---|
| 处理阶段 | 预处理期(编译前) | 编译期 |
| 类型检查 | 无(纯文本替换) | 有(类型安全) |
| 内存分配 | 不分配(直接嵌入代码) | 分配(作为只读变量) |
| 作用域 | 从定义处到文件尾或 #undef | 遵循 C 作用域规则 |
| 调试支持 | 调试器中不可见 | 调试器中可见 |
| 数组大小 | 可用于数组大小声明 | C89 中不可用,C99 VLA 中可用 |
| 取地址 | 不可取地址 | 可以取地址(&end) |
IMPORTANT
本课用 int begin = 1; 是为了降低入门门槛——避免引入预处理器概念。但 #define 是 C 语言中定义常量的正统方式,后续课程(如整型转字符串、shell-parser)中会大量使用。const 变量则更适合需要类型安全的场景。
9.5 计数器变量命名规范
代码中出现了两个"计数"变量——counter(在 find 中)和 sum(在 main 中)。它们的命名体现了不同的语义:
int counter = 0; // 计数器:记录某个操作发生的次数
int sum = 0; // 累加和:记录一系列值的总和常见的计数器命名及其语义:
| 命名 | 语义 | 示例 |
|---|---|---|
counter | 通用的计数变量,记录事件发生次数 | counter++ 每次找到匹配的数字 |
count | 与 counter 同义,更简洁 | count++ 计数循环迭代次数 |
cnt | count 的缩写,在紧凑代码中常见 | cnt++ 不推荐初学使用,含义不够明确 |
sum | 累加和,用于 += 操作 | sum += value 累加一系列值 |
total | 总和,与 sum 同义 | total = a + b + c |
result | 最终结果,通常作为返回值 | return result; |
TIP
命名是代码可读性的第一道防线。好的命名让代码"读起来像句子"——sum += find(i, 9) 读作"sum 累加 find(i, 9) 的结果"。不要在命名上偷懒——多打几个字母的代价远小于理解混乱代码的代价。
10. 执行流程追踪:Trace Table
10.1 什么是 Trace Table
Trace Table(追踪表)是一种用表格记录程序执行过程中变量变化的技术。它帮助你理解程序的执行流程,也是调试时"用纸笔推演"的基本方法。
10.2 本课的完整 Trace Table
以下是 main 中 for 循环的部分追踪(只展示关键迭代):
| 迭代 | i | find(i, 9) 调用 | num 的变化过程 | 返回值 | sum(累加后) |
|---|---|---|---|---|---|
| 初始 | - | - | - | - | 0 |
| 1 | 1 | find(1, 9) | 1 % 10 = 1 ≠ 9 → 1/10 = 0 → 退出 | 0 | 0 |
| 2 | 2 | find(2, 9) | 2 % 10 = 2 ≠ 9 → 2/10 = 0 → 退出 | 0 | 0 |
| ... | ... | ... | ... | ... | ... |
| 9 | 9 | find(9, 9) | 9 % 10 = 9 == 9 → counter=1 → 9/10 = 0 → 退出 | 1 | 1 |
| 10 | 10 | find(10, 9) | 10 % 10 = 0 ≠ 9 → 10/10 = 1 → 1 % 10 = 1 ≠ 9 → 1/10 = 0 → 退出 | 0 | 1 |
| ... | ... | ... | ... | ... | ... |
| 19 | 19 | find(19, 9) | 19 % 10 = 9 == 9 → counter=1 → 19/10 = 1 → 1 % 10 = 1 ≠ 9 → 1/10 = 0 → 退出 | 1 | 2 |
| ... | ... | ... | ... | ... | ... |
| 89 | 89 | find(89, 9) | 89 % 10 = 9 == 9 → counter=1 → 89/10 = 8 → 8 % 10 = 8 ≠ 9 → 8/10 = 0 → 退出 | 1 | 9 |
| 90 | 90 | find(90, 9) | 90 % 10 = 0 ≠ 9 → 90/10 = 9 → 9 % 10 = 9 == 9 → counter=1 → 9/10 = 0 → 退出 | 1 | 10 |
| ... | ... | ... | ... | ... | ... |
| 99 | 99 | find(99, 9) | 99 % 10 = 9 == 9 → counter=1 → 99/10 = 9 → 9 % 10 = 9 == 9 → counter=2 → 9/10 = 0 → 退出 | 2 | 20 |
| 100 | 100 | find(100, 9) | 100 % 10 = 0 ≠ 9 → 100/10 = 10 → 10 % 10 = 0 ≠ 9 → 10/10 = 1 → 1 % 10 = 1 ≠ 9 → 1/10 = 0 → 退出 | 0 | 20 |
最终 sum = 20——恰好是正确的答案。
10.3 如何手工构建 Trace Table
- 列出所有变量:表格的列包含所有会变化的变量
- 逐行执行:按代码顺序,一行一行地更新变量的值
- 记录每次变化:变量每次变化都在新行中记录
- 特别注意循环:循环的每次迭代对应一行或多行
- 验证边界:检查第一次和最后一次迭代的值是否正确
Trace Table 也是发现 bug 的有力工具——当程序输出不符合预期时,构建 Trace Table 通常能定位到哪个变量的哪次变化出了问题。
11. 函数复用性与可测试性
11.1 find 的复用性设计
find(int num, int digit) 的设计使其具有天然的复用性——它不绑定到任何特定的数字或范围:
find(12345, 7); // 在 12345 中找数字 7 → 0
find(77777, 7); // 在 77777 中找数字 7 → 5
find(2024, 2); // 在 2024 中找数字 2 → 2
find(100, 0); // 在 100 中找数字 0 → 2(百位和十位都是 0)这种通用性意味着 find 可以服务于各种不同的问题:
// 场景 1: 统计 1~1000 中数字 7 的个数
for (i = 1; i <= 1000; i++)
sum += find(i, 7);
// 场景 2: 统计一个数的所有数位中数字 3 的个数
int threes_in_333 = find(333, 3); // 结果: 3
// 场景 3: 统计 1~N 中数字 0 的个数
for (i = 1; i <= N; i++)
sum += find(i, 0);
// 场景 4: 判断一个数是否包含数字 5
if (find(num, 5) > 0)
printf("num contains digit 5\n");find 函数一次编写,处处使用——这正是良好函数设计的标志。
11.2 函数隔离测试:单元测试的雏形
因为 find 是独立函数,我们可以单独测试它,而不需要运行整个程序:
#include <stdio.h>
int find(int num, int digit) { /* 实现同上 */ }
// 手动测试 find 函数的各种情况
int main(void)
{
printf("Test 1: find(99, 9) = %d (expected: 2)\n", find(99, 9));
printf("Test 2: find(100, 0) = %d (expected: 2)\n", find(100, 0));
printf("Test 3: find(1, 9) = %d (expected: 0)\n", find(1, 9));
printf("Test 4: find(9, 9) = %d (expected: 1)\n", find(9, 9));
printf("Test 5: find(0, 0) = %d (expected: 1)\n", find(0, 0));
printf("Test 6: find(909, 9) = %d (expected: 2)\n", find(909, 9));
return 0;
}这种"隔离测试"的思想是单元测试(unit testing)的雏形——将程序拆分为独立可测的单元(函数),逐个验证其正确性。在专业的软件开发中,单元测试是保障代码质量的核心实践。
11.3 可测试性与函数设计的关系
函数是否容易测试,取决于其设计质量:
| 设计特征 | 可测试性 | 示例 |
|---|---|---|
| 纯函数(相同输入→相同输出) | 高 | find(99, 9) 永远返回 2 |
| 依赖全局变量 | 低 | 函数内部依赖全局 int g_counter——需要先设置全局状态 |
| 有副作用(修改外部状态) | 中 | 函数修改传入的数组内容——需要检查修改后的数组状态 |
| 有 I/O 操作 | 低 | 函数内部调用 printf 或 scanf——需要重定向 I/O |
find 是"纯函数"的典型——它不依赖任何外部状态,不修改任何全局变量,不做任何 I/O 操作。给定相同的 num 和 digit,永远返回相同的结果。这种特性让测试变得极其简单。
12. 性能分析初步
12.1 迭代次数分析
对于本课的程序,总操作次数可以精确计算:
外层循环: 100 次迭代(i 从 1 到 100)
每次迭代调用 find(i, 9),find 内部循环次数 = i 的十进制位数
各数字位数的分布:
- 1 位数 (1~9): 9 个数字 × 1 次循环 = 9 次
- 2 位数 (10~99): 90 个数字 × 2 次循环 = 180 次
- 3 位数 (100): 1 个数字 × 3 次循环 = 3 次
总逐位比较次数 = 9 + 180 + 3 = 192 次对于现代计算机,192 次操作在微秒级别内完成——你完全感觉不到任何延迟。
12.2 扩展到更大范围
如果将范围从 100 扩展到 1000000(一百万):
位数分布:
- 1 位数 (1~9): 9 × 1 = 9
- 2 位数 (10~99): 90 × 2 = 180
- 3 位数 (100~999): 900 × 3 = 2700
- 4 位数 (1000~9999): 9000 × 4 = 36000
- 5 位数 (10000~99999): 90000 × 5 = 450000
- 6 位数 (100000~1000000): 900001 × 6 ≈ 5400006
总逐位比较次数 ≈ 5888895 次约 589 万次操作——对于现代 CPU(每秒数十亿次操作),这仍然只需几毫秒。但你可以直观感受到:范围扩大 10000 倍,操作次数扩大了约 30000 倍。
12.3 算法复杂度直觉
这个分析的实质是算法复杂度分析的雏形。对于统计 1~N 中某个数字的出现次数:
- 外层循环:N 次迭代
- 内层循环:每次迭代约
log10(i)次(数字的位数) - 总复杂度:约 O(N × log N)
"O(N × log N)" 的意思是:当 N 增长时,运行时间的增长速度略快于 N 本身,但远慢于 N²。这是一种相当高效的算法。
NOTE
复杂度分析(Big-O Notation)是计算机科学中评估算法效率的标准工具。初学阶段只需要建立"不同算法的增长速度不同"的直觉——有的算法在数据量大时会变得极慢(如 O(N²)),有的则保持高效(如 O(N log N))。
参考解答
统计 1~100 中数字 9 的个数(do-while 逐位提取)
#include <stdio.h>
/*
* find - calculate how many digit in num
* @num: the number we want to find
* @digit: the digit we search in num
*
* Return value: how many digit in this num
*/
int find(int num, int digit)
{
int counter = 0;
do {
if (num % 10 == digit)
counter++;
num = num / 10;
} while (num != 0);
return counter;
}
int main(void)
{
int begin = 1;
int end = 100;
int i;
int sum = 0;
for (i = begin; i <= end; i++)
{
sum += find(i, 9);
}
printf("sum = %d\n", sum);
return 0;
}核心逻辑:find 用 do-while 逐位提取数字——num % 10 取个位与 digit 比较,num /= 10 去掉个位。main 中遍历 1~100 累加每次 find(i, 9) 的返回值,最终 sum = 20。do-while 保证即使 num = 0 也能正确处理。
使用 while 的替代写法
int find(int num, int digit)
{
int counter = 0;
while (num != 0)
{
if (num % 10 == digit)
counter++;
num = num / 10;
}
return counter;
}用 while 替代 do-while——对于正数,两种写法效果完全相同。但注意:如果调用 find(0, 0),while 版本循环体一次都不执行,返回 0(错误);do-while 版本至少执行一次,会检测到 0 % 10 == 0 成立,返回 1(正确)。对于 1~100 的范围,两者等价。
对照检查:
find函数的形参列表是否正确(类型、个数)?逐位提取用了% 10和/ 10组合吗?main中的累加用了+=吗?最终输出格式是"sum = %d\n"吗?counter和sum都初始化为 0 了吗?
课堂讨论
- 示例中的
1和100为何要用begin、end来定义,直接写在for循环中可以吗?用#define常量会不会更好? find中的do-while改成while可以吗?什么时候用do-while比while更合适?- 为什么不写一个函数直接就能计算出 1~100 中 9 的个数?这样做有什么好处和坏处?
- 如果要统计 1~1000 中数字 7 的个数,程序需要改动几处?为什么能这么少?
- 逐位提取算法中,提取顺序是从个位到高位。如果需要从高位到低位提取(如确定数字在哪个数位上),应该怎么改进?
- C 语言的参数传递是"按值传递"——这意味着什么?如果要在函数内部修改调用方的变量,应该怎么做?
讨论答案
Q1: begin/end 能否直接写成具体数字?用 #define 会不会更好?
可以,但不推荐。 直接写 for (i = 1; i <= 100; i++) 程序能正确运行,但有以下问题:
可读性:
begin和end明确表达了"范围的起点和终点"这个语义,而1和100只是两个孤立的数字。阅读者看到begin就知道"这是循环的起始值",看到end就知道"这是循环的终止值"。可维护性:如果要统计 1~200,用
begin/end只需改两处赋值(begin = 1; end = 200;),硬编码则需要找到所有出现100的地方逐一修改——容易遗漏。无运行时代价:
int begin = 1;和直接写1在编译优化后通常产生相同的机器码——可读性提升是"免费"的。
至于 #define vs 普通变量:#define BEGIN 1 更好——它明确表达了"这是一个常量,不会在运行时被修改"的语义,且遵循 C 社区的命名惯例(全大写)。但初学阶段用 int begin = 1; 也完全可以接受——它避免了引入预处理器的概念,让学生先专注于函数和循环。
Q2: do-while 改成 while 可以吗?什么时候各用哪个?
对于本题可以,但有细微差异。 1~100 范围中的 num 都不为 0,while (num != 0) 与 do { ... } while (num != 0) 效果完全相同。
差异体现在极端情况:如果调用 find(0, 0):
| 写法 | 行为 | 结果 |
|---|---|---|
do-while | 先执行一次:0 % 10 == 0 成立 → counter++ → num = 0/10 = 0 → 判定 num != 0 为假退出 | counter = 1 ✓ |
while | 先判断:num != 0 为假,跳过循环体 | counter = 0 ✗ |
选择原则:
- do-while 更合适的场景:循环体至少需要执行一次(逐位取数字、菜单交互、输入验证、游戏主循环)
- while 更合适的场景:循环次数可能为 0(读取文件——文件可能为空、遍历可能为空的链表)
选择的关键不是"哪种也能工作",而是"哪种更准确地表达了程序员的意图"。
Q3: 为什么不写一个函数直接计算 1~100 中的 9 的个数?
好处:复用性、可测试性、可读性。
如果把遍历和逐位提取合并在一个函数中:
int count_nines_1_to_100(void) // 专用函数,只能统计 1~100 中的 9
{
int i, sum = 0;
for (i = 1; i <= 100; i++)
{
int n = i;
do {
if (n % 10 == 9) sum++;
n /= 10;
} while (n != 0);
}
return sum;
}这个函数只能做一件事——统计 1~100 中的 9。要统计 1~1000 中的 7,必须写新函数或修改现有函数。
拆分为 find(int num, int digit) 后:
- 可以调用
find(12345, 7)检查任意数字中的任意 digit find可以被独立测试:手动验证find(99, 9) == 2、find(0, 0) == 1等- 组合更灵活:
find+ 遍历 → 统计任意范围内的 digit 个数 main变得简洁清晰:sum += find(i, 9)一行就表达了核心逻辑
核心理念:设计函数时,让它做好一件事——不要太宽(一次做太多)也不要太窄(只能做唯一场景)。find 恰好处于理想的粒度:足够通用以复用,又足够专注以简单。
Q4: 统计 1~1000 中数字 7 的个数需要改动几处?
只需改动两处——这就是逻辑分解和函数复用性的直接优势:
int end = 1000; // 改 1: 范围终点(原来是 100)
// ...
sum += find(i, 7); // 改 2: 目标数字(原来是 9)find 函数无需任何改动——它的设计与具体数字和范围无关,只关注"在某个数中找某个数字"这一通用任务。
如果当初把逻辑全部写在 main 中,则需要找到所有硬编码的 100 和 9 并逐一修改——在大型程序中,这种搜索-替换的操作很容易遗漏。好的设计让修改的影响范围最小化——这是软件工程中"封装"思想的雏形。
Q5: 如何从高位到低位逐位提取数字?
逐位提取从个位开始,是由 % 10 和 / 10 的数学性质决定的——它天然从低位开始拆解。要反过来(从高位开始),需要先确定数字的位数,然后从最高位逐级向下:
// 从高位到低位逐位提取
int find_high_to_low(int num, int digit)
{
int counter = 0;
int divisor = 1;
// 第一步:找到最高位的除数
// 如 num=395 → divisor=100(因为 395/100=3,395/1000=0)
while (num / divisor >= 10)
divisor *= 10;
// 第二步:从高位到低位逐位提取
while (divisor > 0)
{
int current_digit = num / divisor; // 取出当前最高位
if (current_digit == digit)
counter++;
num = num % divisor; // 去掉当前最高位
divisor /= 10; // 除数降一级
}
return counter;
}对于 395 的高位到低位提取过程:
divisor=100: 395/100=3 → 3==9? → 395%100=95 → divisor=10
divisor=10: 95/10=9 → 9==9? ✓ → 95%10=5 → divisor=1
divisor=1: 5/1=5 → 5==9? → 结束这个例子说明:算法的设计取决于需求。只关心出现次数时用低位到高位更简洁;需要输出数字的原始顺序时(如整型转字符串),用高位到低位更自然。
Q6: C 语言的按值传递意味着什么?如何修改调用方的变量?
按值传递意味着:函数调用时,实参的值被复制到形参中。函数内部对形参的任何修改,都只影响这个副本,不影响调用方的原始变量。
void modify(int x)
{
x = 999; // 只修改了局部副本 x
}
int main(void)
{
int a = 5;
modify(a);
printf("%d\n", a); // 输出 5,a 没有被修改!
return 0;
}如何在函数内部修改调用方的变量? 答案是:传递变量的地址(即指针)。地址本身虽然是按值传递的,但通过地址可以间接修改原变量:
void modify(int *p) // p 是一个指针,指向一个 int
{
*p = 999; // 通过解引用 *p 修改 p 指向的变量
}
int main(void)
{
int a = 5;
modify(&a); // 传递 a 的地址(&a 取 a 的地址)
printf("%d\n", a); // 输出 999,a 被修改了!
return 0;
}这就是为什么 scanf("%d", &x) 需要 &——scanf 需要通过地址来修改 x 的值。关于指针的详细讲解将在后续课程中展开。
课后练习
用函数改写素数判断:将 Lesson 07 中"求 100 以内最大素数"的程序,用一个
is_prime(int n)函数改写——函数返回1表示是素数,0表示不是。注意:return可以在发现约数时提前退出。知识点提示:函数返回值设计(
int表示真/假)、模块化改写、return的提前退出机制、单一职责原则的应用同位置数字匹配:用户输入两个数字,按从个位对齐的方式,统计这 2 个数在相同位置处数字也相同的个数。例如 123 和 5173,对齐后比较个位 3==3 ✓、十位 2==7 ✗、百位 1==1 ✓、千位 0==5 ✗——结果为 2。
知识点提示:两数同步逐位提取、
% 10和/ 10同时操作两个数、循环直到两数都为 0(用||而非&&)、较短数的高位视为 0数字频率统计:统计 1~1000 中每个数字(0~9)出现的总次数,输出每个数字的频率分布。复用本课的
find函数。知识点提示:遍历 1~1000、
find函数复用、长度为 10 的计数数组freq[10]、嵌套循环(外层遍历范围,内层遍历 0~9)、数组元素通过索引访问freq[d]数字自幂数检查:输入一个整数 N,判断 N 是否是"自幂数"——即 N 的每一位数字的(位数)次幂之和等于 N 本身。例如 153 = 1³ + 5³ + 3³ = 153,所以 153 是自幂数(3 位数)。
知识点提示:先确定数字的位数(循环除以 10 计数)、再逐位提取并计算幂次、幂运算用循环实现(不用
pow)、累加比较、find思路的变形应用数字黑洞验证:验证"数字黑洞"现象——任选一个不完全相同的四位数,按数字降序排列得最大数,升序排列得最小数,用最大减最小得到新数,重复此过程最终会到达 6174(卡普雷卡常数)。编写程序验证任意四位数是否最终到达 6174。
知识点提示:逐位提取四位数字存入数组、数组排序(冒泡或选择排序)、从排序后的数组构造最大数和最小数(
max = d[0]*1000 + d[1]*100 + d[2]*10 + d[3])、循环迭代直到收敛、函数分解(get_digits、build_number、sort)
练习1: 用函数改写素数判断
#include <stdio.h>
/*
* is_prime - check if n is a prime number
* @n: the number to check
*
* Return value: 1 if n is prime, 0 otherwise
*/
int is_prime(int n)
{
int i;
if (n <= 1)
return 0; // 1 和更小的数不是素数
for (i = 2; i * i <= n; i++)
{
if (n % i == 0)
return 0; // 发现约数,不是素数,提前退出
}
return 1; // 没有约数,是素数
}
int main(void)
{
int num;
int max = 0;
for (num = 2; num <= 100; num++)
{
if (is_prime(num))
max = num; // 记录当前找到的最大素数
}
printf("max prime is %d\n", max);
return 0;
}模块化后 main 变得极简——只负责遍历和记录最大值,素数判断的复杂性完全封装在 is_prime 中。return 0 在发现约数时提前退出——这比用 break 更直接,因为它直接跳出了整个函数。i * i <= n 是判断素数的高效写法——只需检查到 sqrt(n)。
练习2: 同位置数字匹配
#include <stdio.h>
int main(void)
{
int a, b;
int count = 0;
printf("Enter two numbers: ");
scanf("%d %d", &a, &b);
while (a != 0 || b != 0) // 两个数都处理完才停止
{
if (a % 10 == b % 10)
count++; // 当前个位数字相同
a /= 10;
b /= 10;
}
printf("match count = %d\n", count);
return 0;
}两个数同步逐位提取——每次比较两者的个位,然后同时去掉个位。终止条件用 || 而非 &&:当较短的数变为 0 后,0 % 10 = 0,可以继续与较长的数比较(较短数的高位视为 0)。如果用 &&,较短的数变为 0 后循环立即停止,较长数的剩余高位将不被比较。
练习3: 数字频率统计
#include <stdio.h>
int find(int num, int digit)
{
int counter = 0;
do {
if (num % 10 == digit)
counter++;
num /= 10;
} while (num != 0);
return counter;
}
int main(void)
{
int i, d;
int freq[10] = {0}; // freq[d] 记录数字 d 的出现次数
for (i = 1; i <= 1000; i++)
{
for (d = 0; d <= 9; d++)
{
freq[d] += find(i, d);
}
}
for (d = 0; d <= 9; d++)
printf("digit %d: %d times\n", d, freq[d]);
return 0;
}复用 find 函数,用长度为 10 的数组 freq[10] 记录每个数字的频率。freq[10] = {0} 将所有元素初始化为 0——这是 C 语言数组初始化的简洁语法。数组是 C 语言中管理多项同类型数据的基本工具——后续课程会详细讲解。
练习4: 数字自幂数检查
#include <stdio.h>
int main(void)
{
int n, original, digit;
int digits = 0; // 数字的位数
int sum = 0;
printf("Enter a number: ");
scanf("%d", &n);
original = n;
// 第一步:计算位数
int temp = n;
do {
digits++;
temp /= 10;
} while (temp != 0);
// 第二步:逐位提取并计算幂次和
temp = n;
do {
digit = temp % 10;
// 计算 digit^digits(用循环,不用 pow)
int power = 1;
int j;
for (j = 0; j < digits; j++)
power *= digit;
sum += power;
temp /= 10;
} while (temp != 0);
if (sum == original)
printf("%d is an Armstrong number\n", original);
else
printf("%d is NOT an Armstrong number\n", original);
return 0;
}自幂数(Armstrong number / Narcissistic number)是逐位提取算法的一个有趣应用。先确定位数(除以 10 计数),再逐位计算幂次和。注意不能用 pow()(那需要 math.h 和链接 -lm),而是用循环自己实现幂运算——这也是在练习基本控制结构。
练习5: 数字黑洞验证
#include <stdio.h>
// 将 n 的四位数字存入数组 d[0..3]
void get_digits(int n, int d[])
{
int i;
for (i = 0; i < 4; i++)
{
d[i] = n % 10;
n /= 10;
}
}
// 冒泡排序(升序)
void sort_asc(int d[], int len)
{
int i, j, tmp;
for (i = 0; i < len - 1; i++)
{
for (j = 0; j < len - 1 - i; j++)
{
if (d[j] > d[j + 1])
{
tmp = d[j];
d[j] = d[j + 1];
d[j + 1] = tmp;
}
}
}
}
// 从升序排列的数组构造最大数和最小数
// 升序: d[0] 最小, d[3] 最大
// 最大数 = d[3]*1000 + d[2]*100 + d[1]*10 + d[0]
// 最小数 = d[0]*1000 + d[1]*100 + d[2]*10 + d[3]
int build_max(int d[])
{
return d[3] * 1000 + d[2] * 100 + d[1] * 10 + d[0];
}
int build_min(int d[])
{
return d[0] * 1000 + d[1] * 100 + d[2] * 10 + d[3];
}
int main(void)
{
int n, d[4];
int max, min, next;
int steps = 0;
printf("Enter a 4-digit number (not all digits same): ");
scanf("%d", &n);
printf("Steps:\n");
do {
get_digits(n, d);
sort_asc(d, 4);
max = build_max(d);
min = build_min(d);
next = max - min;
printf(" %d: %d - %d = %d\n", steps + 1, max, min, next);
n = next;
steps++;
} while (n != 6174 && steps < 20); // 最多 20 步防死循环
if (n == 6174)
printf("Reached Kaprekar's constant 6174 in %d steps!\n", steps);
else
printf("Did not reach 6174.\n");
return 0;
}卡普雷卡常数(6174)是数学中著名的"数字黑洞"现象——任何不完全相同的四位数经过有限次"最大排列减最小排列"操作后都会到达 6174。这个练习综合运用了逐位提取、数组操作、排序算法和循环控制——是检验本课知识的综合挑战。
参考资料
- ISO C99 Standard, Section 6.9.1 — 函数定义的语法规范(参数列表与复合语句)
- Linux Kernel Coding Style, Section 8 — 内核 kerneldoc 注释风格与
@param格式 - The C Programming Language, Section 1.7 — Kernighan & Ritchie 对函数定义的经典讲解
- GNU C Library: Program Basics —
main函数的返回值约定与EXIT_SUCCESS - Doxygen Manual: Documenting the code — Doxygen 注释格式标准与
@param、@return标签规范
"I don't think that I have any special insight, but it has always seemed to me best to do something that you really enjoy doing." — Brian W. Kernighan