第 4 讲 · 选择、循环与结构化程序
博姆—贾可皮尼理论告诉我们:任何可计算的程序流程,都可以由顺序、选择和循环三种结构组合出来。真正的难点不是记住关键字,而是把题目的分支与重复过程拆得完整、互斥、能结束。
1. 从“程序会跳转”到结构化编程
机器指令本来按顺序执行,也能跳到另一处继续。若到处使用任意跳转,控制流很快会变成难以追踪的“面条”。结构化编程用少量有明确入口和出口的结构限制跳转:
顺序:A → B → C
选择:条件 ─真→ A
└假→ B
循环:条件为真 → 执行循环体 → 回到条件
课件以软件事故提醒:控制分支不仅决定程序是否通过 OJ,也可能决定真实系统是否安全。代码必须表达可检查的逻辑,而不是“测几个样例能跑就算了”。
2. 算法是什么
算法是把输入转换为输出的有限步骤,至少要能回答:
- 每一步操作是什么;
- 操作按什么顺序执行;
- 什么时候停止;
- 对允许的输入是否都能得到正确输出。
课件列举了枚举、分治、递归、贪心等经典思想。它们不是额外的语法,而是“怎样安排这些基本控制结构”的不同策略。
3. if:条件成立才执行
if (score >= 60) {
puts("passed");
}
C 把 0 视为假、非 0 视为真。条件最好本身就能读成一句话,避免把复杂计算和多次赋值都塞进括号。
3.1 二选一
if (score >= 60) {
puts("passed");
} else {
puts("failed");
}
两个分支互斥且覆盖全部情况。
3.2 多段区间
if (score >= 90) {
grade = 'A';
} else if (score >= 80) {
grade = 'B';
} else if (score >= 70) {
grade = 'C';
} else if (score >= 60) {
grade = 'D';
} else {
grade = 'F';
}
前面的条件不成立才会检查后面,因此此处不必重复写上界。把更严格或更高的阈值放前面,逻辑更短。
3.3 else 到底配谁
没有大括号时,else 总与最近一个尚未匹配的 if 配对。即便循环体或分支只有一句,也建议写大括号,避免缩进与真实语义不一致。
3.4 条件运算符
三目运算符适合短小的二选一表达式:
int max = a > b ? a : b;
它产生一个值;复杂流程仍应使用 if/else。
4. 条件里的四个高频坑
4.1 = 不是 ==
if (x = 0) { } /* 给 x 赋值 0,条件为假 */
if (x == 0) { } /* 判断 x 是否等于 0 */
4.2 区间不能连写
if (0 <= x && x <= 100) { }
数学式 0 <= x <= 100 在 C 中会分两次比较,含义完全不同。
4.3 浮点相等
if (fabs(x - y) <= eps) { }
二进制浮点误差会使理论上相等的计算结果存在微小差异。
4.4 短路顺序
if (i < n && a[i] == key) { }
先检查边界,再访问数组。&& 左侧为假后,右侧不会执行。
5. switch:按离散值多路分支
switch (op) {
case '+':
result = x + y;
break;
case '-':
result = x - y;
break;
case '*':
result = x * y;
break;
case '/':
if (fabs(y) < eps) {
puts("division by zero");
return 1;
}
result = x / y;
break;
default:
puts("unknown operator");
return 1;
}
switch 适合整数或字符等离散常量标签,不直接处理区间和浮点数。没有 break 会继续执行后面的 case,称为贯穿;只有确实需要时才应使用,并用注释说明。
6. while:先判断,再重复
int power = 2;
while (power <= 100) {
power *= 2;
}
设计循环时明确四要素:
- 循环变量初值;
- 继续条件;
- 循环体;
- 状态更新。
缺少更新就可能永远满足条件。
6.1 哨兵与输入返回值
已知用 -1 结束:
int score;
while (scanf("%d", &score) == 1 && score != -1) {
/* 处理 score */
}
未知组数直到 EOF:
while (scanf("%d", &score) == 1) {
/* 处理 score */
}
课件的平均分、连续整数和文本行数统计,核心都是“循环条件如何准确表达输入是否仍有效”。
7. for:计数循环更集中
long long factorial = 1;
for (int i = 1; i <= n; ++i) {
factorial *= i;
}
for (初始化; 条件; 更新) 与对应的 while 本质相同,但把计数状态集中在循环头中。
逗号运算符能在一个表达式位置依次计算多个表达式,循环头偶尔会用到:
for (int left = 0, right = n - 1; left < right; ++left, --right) {
/* 同时移动两端 */
}
不要把逗号运算符与函数实参之间的分隔逗号混为一谈。
7.1 边乘边取模
计算 时不要先算完整阶乘:
long long result = 1;
for (int i = 2; i <= n; ++i) {
result = result * i % mod;
}
这能控制中间值,但仍要保证 result * i 本身不超过所选类型范围。
7.2 浮点求和的顺序
求 时,小数项越来越小。浮点加法不是严格结合的,大数先累积后再加极小数,极小项可能被舍掉。从小项向大项累加通常精度更好:
double sum = 0.0;
for (int i = n; i >= 1; --i) {
sum += 1.0 / i;
}
8. do/while:至少执行一次
int choice;
do {
puts("1. continue 0. quit");
scanf("%d", &choice);
} while (choice != 0);
条件在循环体之后检查,所以循环体一定先执行一次。菜单、输入校验等场景常见;若是否执行第一次也取决于条件,使用 while 更直观。
9. 用循环实现几个经典过程
9.1 枚举最大公约数
int gcd = 1;
for (int d = 1; d <= a && d <= b; ++d) {
if (a % d == 0 && b % d == 0) {
gcd = d;
}
}
这个版本直观但慢。
9.2 欧几里得算法
利用
反复缩小问题:
int original_a = a;
int original_b = b;
while (b != 0) {
int r = a % b;
a = b;
b = r;
}
若求最小公倍数,先除后乘可降低溢出风险:
long long lcm = (long long)(original_a / a) * original_b;
这里循环结束后的 a 是最大公约数。
9.3 文本行数
int ch;
int lines = 0;
int has_data = 0;
int last = '\n';
while ((ch = getchar()) != EOF) {
has_data = 1;
last = ch;
if (ch == '\n') {
++lines;
}
}
if (has_data && last != '\n') {
++lines;
}
上面按“有内容的末行即使没有换行也算一行”处理。如果原题对空文件或末尾换行有其他定义,应以题意为准。这正是边界条件,不是语法细节。
10. 循环嵌套
外层每执行一次,内层会完整执行一轮:
for (int i = 1; i <= n; ++i) {
for (int j = 1; j <= i; ++j) {
printf("*");
}
printf("\n");
}
若两层规模都是 ,总操作数通常约为 。读到整数分解、组合配对、二维表格等问题时,要同时确认正确性和数量级。
11. break 与 continue
break立即结束当前最内层循环或switch;continue跳过本轮余下语句,进入下一轮。
for (int i = 0; i < n; ++i) {
if (a[i] < 0) {
continue;
}
if (a[i] == key) {
position = i;
break;
}
}
它们能减少不必要的嵌套,但使用前要确认跳转后状态仍然完整。特别是在 while 中,continue 可能跳过循环变量更新而造成死循环。
12. 为什么通常不用 goto
goto label 能跳到同一函数中的标签。课件展示了它与循环、最大公约数的关系,也明确强调应尽量避免:结构化的 if、while、for、do/while 已能完成正常控制流,而且更容易验证。
少数底层 C 代码会用 goto 汇合多层资源清理,但那是受控的单向跳转,不等于到处跳。初学与考试题中,应优先写结构化版本。
13. 控制流自检法
画一张小表手动跑程序:
| 轮次 | 条件 | 关键变量进入循环前 | 本轮变化 | 是否继续 |
|---|---|---|---|---|
| 1 | 真/假 | … | … | … |
然后追问:
- 每个输入落入且只落入一个正确分支吗?
- 循环状态是否每轮向结束条件靠近?
- 最小值、最大值、空输入、恰好在边界上的值会怎样?
break、continue影响的是哪一层?
能回答这些问题,控制结构就不再是一堆关键字,而是一条可证明的执行路径。