第 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;
}

设计循环时明确四要素:

  1. 循环变量初值;
  2. 继续条件;
  3. 循环体;
  4. 状态更新。

缺少更新就可能永远满足条件。

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 边乘边取模

计算 n! mod mn!\bmod m 时不要先算完整阶乘:

long long result = 1;
for (int i = 2; i <= n; ++i) {
    result = result * i % mod;
}

这能控制中间值,但仍要保证 result * i 本身不超过所选类型范围。

7.2 浮点求和的顺序

求 1+12+⋯+1n1+\frac12+\cdots+\frac1n 时,小数项越来越小。浮点加法不是严格结合的,大数先累积后再加极小数,极小项可能被舍掉。从小项向大项累加通常精度更好:

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 欧几里得算法

利用

gcd⁡(a,b)=gcd⁡(b,a mod b)\gcd(a,b)=\gcd(b,a\bmod b)

反复缩小问题:

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");
}

若两层规模都是 nn,总操作数通常约为 n2n^2。读到整数分解、组合配对、二维表格等问题时,要同时确认正确性和数量级。

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 影响的是哪一层?

能回答这些问题,控制结构就不再是一堆关键字,而是一条可证明的执行路径。

评论