跳转到内容

Lesson 06: 九九乘法表 CNB

练习任务

编写一个 C 程序,打印九九乘法表(下三角形式):外层循环控制行(i 从 1 到 9),内层循环控制列(j 从 1 到 i),每项格式为 j*i=结果,用制表符 \t 分隔,每行末尾换行。

期望输出:

1*1=1
1*2=2	2*2=4
1*3=3	2*3=6	3*3=9
1*4=4	2*4=8	3*4=12	4*4=16
1*5=5	2*5=10	3*5=15	4*5=20	5*5=25
1*6=6	2*6=12	3*6=18	4*6=24	5*6=30	6*6=36
1*7=7	2*7=14	3*7=21	4*7=28	5*7=35	6*7=42	7*7=49
1*8=8	2*8=16	3*8=24	4*8=32	5*8=40	6*8=48	7*8=56	8*8=64
1*9=9	2*9=18	3*9=27	4*9=36	5*9=45	6*9=54	7*9=63	8*9=72	9*9=81

提示:这是 Lesson 05 单层 for 循环的升级版——你需要两层嵌套循环,一层控制「行」,一层控制「列」。仔细观察期望输出中 ji 的顺序——是 j*i 不是 i*j

核心知识点

  • 嵌套循环 for-for — 外层控制行,内层控制列,二层逻辑嵌套
  • 循环执行顺序追踪 — 外层先进入 → 内层完整执行一轮 → 外层推进 → 内层重新执行,完整步进追踪表
  • printf 格式化输出 — %d*%d=%d 的三个参数对应关系,宽度修饰符 %2d
  • 制表符 \t 与列对齐 — \t 跳到下一个 8 字符边界(tab stop),多位数列偏移问题与 %2d 改进方案
  • 循环边界控制 — j <= i(三角形)vs j < 10(方形),数学对称性与交换律
  • break 在嵌套循环中的行为 — 只跳出最近一层循环,跳出外层的三种替代方案(标志变量、goto、函数封装)
  • continue vs break 的区别 — 跳过本次迭代剩余部分 vs 终止整个循环
  • 花括号 { } 的必要性 — 嵌套结构中省略花括号的常见陷阱,编译器理解 vs 缩进欺骗
  • 循环变量命名与作用域 — i/j 的 Fortran 历史与数学矩阵惯例,C89 vs C99 声明差异,循环结束后变量值
  • C99 循环内变量声明 — for 初始化部分声明,作用域最小化,避免命名冲突
  • 乘法表的数学结构与矩阵对称性 — 下三角本质、交换律与重复项消除、从数学到代码的思维映射
  • 时间复杂度 O(n²) 分析 — 总执行次数推导(45 vs 81),n² 规模的直观含义
  • printf 缓冲与 stdout 刷新机制 — 行缓冲 vs 全缓冲,\n 触发刷新,fflush(stdout)
  • 多行表格格式化选项 — 字段宽度、左对齐 vs 右对齐、制表符替代方案

代码框架

multiplication_table.c
c
#include <stdio.h>

int main(void)
{
    int i, j;

    // 外层循环: i 从 1 到 9,每行一个被乘数
    for (i = 1; i <= 9; i++)
    {
        // 内层循环: j 从 1 到 ???,每列一个乘数
        for (j = 1; j <= ???; j++)
        {
            // 打印每一项,格式: j*i=结果,用 \t 分隔
            printf(/* 格式化字符串 */, j, i, i * j);
        }
        // 每行结束后换行
        printf(/* 换行 */);
    }

    return 0;
}

填充以上框架的关键思考:内层循环 j 的上界应该是多少——为什么不是 9 而是 iprintf 的三个 %d 分别对应什么参数?制表符 \t 在字符串中怎么表示?

TIP

先不要往下翻看参考解答。尝试自己填完框架后编译运行,观察输出是否与期望格式一致。


深度讲解

1. 嵌套循环 for-for:二层逻辑嵌套

1.1 从单层循环到双层循环

回顾 Lesson 05 中我们用单层 for 循环计算 1 到 100 的累加——一个循环变量 i,一条循环路径。九九乘法表需要两个维度:行和列。一个循环变量只能走一条线,两个循环变量才能编织一张网格。

single_vs_double.c
c
// 单层循环:一条线
for (i = 1; i <= 9; i++)
    printf("%d\n", i);       // 1, 2, 3, ..., 9 —— 一列数字

// 双层循环:一张网格
for (i = 1; i <= 9; i++)              // 外层:行
    for (j = 1; j <= i; j++)          // 内层:列
        printf("%d*%d=%d\t", j, i, i * j);  // 每个格子

核心思想:外层循环每推进一步,内层循环完整执行一轮。就像织布——经线(外层)每走一根,纬线(内层)从头织到尾。

把嵌套循环想象成钟表机械:

外层循环 时针,每走一格(i++),内层完成一整圈
内层循环 分针,在时针每格内走完自己的全部范围(j 1 i)

这个类比帮助我们理解:外慢内快,外进内满——外层每推进一次,内层从头到尾完整运行一轮。

1.2 执行顺序追踪(完整步进表)

理解嵌套循环的关键是追踪执行顺序。以下追踪表展示 i = 1i = 3 时内外层循环的完整执行过程:

外层 i=1 开始(i <= 9 为真,进入外层循环体)

├── 内层 j=1 开始(j <= 1 为真)
   ├── printf("1*1=1\t")      输出 "1*1=1\t"
   └── j++ j=2(j <= 1 为假,退出内层)

├── printf("\n")               输出换行

├── i++ i=2(i <= 9 为真,外层继续)

外层 i=2 开始

├── 内层 j=1 开始(j <= 2 为真)
   ├── printf("1*2=2\t")      输出 "1*2=2\t"
   └── j++ j=2(j <= 2 为真,继续内层)
   ├── printf("2*2=4\t")      输出 "2*2=4\t"
   └── j++ j=3(j <= 2 为假,退出内层)

├── printf("\n")               输出换行

├── i++ i=3(i <= 9 为真,外层继续)

外层 i=3 开始

├── 内层 j=1 开始(j <= 3 为真)
   ├── printf("1*3=3\t")      输出 "1*3=3\t"
   └── j++ j=2(j <= 3 为真)
   ├── printf("2*3=6\t")      输出 "2*3=6\t"
   └── j++ j=3(j <= 3 为真)
   ├── printf("3*3=9\t")      输出 "3*3=9\t"
   └── j++ j=4(j <= 3 为假,退出内层)

├── printf("\n")               输出换行

├── i++ i=4(i <= 9 为真,外层继续)

... 以此类推,直到 i=9 内层执行完毕,i++ i=10,i <= 9 为假,整个程序结束

逐行分析追踪表的含义

  1. 外层 i=1:内层 j 只取 1(因为 j <= 1),内层循环体执行 1 次,输出 1*1=1,然后换行。
  2. 外层 i=2:内层 j12(因为 j <= 2),内层循环体执行 2 次,输出 1*2=22*2=4,然后换行。
  3. 外层 i=3:内层 j123,内层循环体执行 3 次,输出 3 项,然后换行。

以此类推,第 i 行的内层循环体恰好执行 i 次。

总执行次数:内层循环体执行了 ( 1 + 2 + 3 + \cdots + 9 = 45 ) 次——恰好是九九乘法表的项数。公式验证:等差数列求和 ( \frac{9 \times (1 + 9)}{2} = 45 )。

IMPORTANT

嵌套循环的执行顺序不是「外层走一步,内层走一步」——而是「外层走一步,内层走完一轮」。初学者最常见的误解是把两层循环想象成交替执行,实际上内层总是在外层的一次迭代中完整完成

1.3 嵌套层次与时间复杂度

嵌套循环的层数直接影响时间复杂度。用 n 表示输入规模(这里 n = 9):

嵌套层数内层体执行次数时间复杂度示例
单层 fornO(n)累加 1 到 n
双层 for-forn(n+1)/2 ~ n²O(n²)九九乘法表(下三角)
双层(方形)O(n²)完整 9×9 乘法表(81 项)
三层 for-for-forO(n³)三维遍历(如三层密码)

对于九九乘法表(n = 9),下三角模式执行 45 次。但如果打印 n × n 的完整方形乘法表,内层上界改为 n,总操作次数就是 ——当 n = 10000 时,就是 1 亿次操作。

O(n²) 的直观感受:输入翻倍,工作量翻四倍。例如:

  • n = 10 → 100 次(方形表)
  • n = 100 → 10,000 次
  • n = 1000 → 1,000,000 次

这就是为什么程序员需要对嵌套循环保持警惕——如果内外层都遍历完整范围,增长速度非常快。下三角(j <= i)将 O(n²) 的常数因子从 1 降低到约 1/2,虽然复杂度级别不变,但在实际编程中,能省一半操作总是值得的。


2. printf 格式化输出与制表符 \t

2.1 格式字符串与参数对应

printf("%d*%d=%d\t", j, i, i * j) 包含三个 %d 和三个对应参数:

printf_params.c
c
printf("%d*%d=%d\t", j, i, i * j);
//     ↑1  ↑2  ↑3    ↑1  ↑2  ↑3
//     j   i   i*j   j   i   i*j

对应规则:格式字符串中的 %d 按从左到右的顺序,依次对应后续参数。第一个 %dj,第二个 %di,第三个 %di * j

格式字符串中的普通字符(*=)原样输出。完整的字符串形成过程:

参数值: j=2, i=3, i*j=6
格式串: "%d*%d=%d\t"

输出:   "2*3=6\t"

WARNING

注意参数顺序是 j, i, i*j 而非 i, j, i*j——这是因为期望输出格式是 j*i=结果(如 2*3=6),而不是 i*j=结果。如果参数顺序弄错,输出会变成 3*2=6——虽然结果值一样,但格式与期望不符,评测系统会判定为错误。

2.2 制表符 \t 的机制

\t 不是「插入几个空格」——它是跳到下一个制表位(tab stop)。制表位是每 8 个字符位置的固定锚点:

位置:  0         8         16        24        32        40
       |         |         |         |         |         |
       制表位    制表位    制表位    制表位    制表位    制表位

\t 的行为取决于当前光标位置:

"1*1=1\t" 光标在位置 5,\t 跳到位置 8(插入 3 个空格)
"1*2=2\t" 光标在位置 5,\t 跳到位置 8(插入 3 个空格)
"2*2=4\t" 光标在位置 13,\t 跳到位置 16(插入 3 个空格)
"3*4=12\t" 光标在位置 22,\t 跳到位置 24(插入 2 个空格)
"4*4=16\t" 光标在位置 30,\t 跳到位置 32(插入 2 个空格)
"9*9=81\t" 光标在位置 38,\t 跳到位置 40(插入 2 个空格)

这解释了为什么 \t 后面的内容总是从固定的制表位开始——不同长度的字符串会自动「对齐到下一个锚点」。

历史背景:制表符起源于打字机时代。打字机上有「Tab」键,按下后纸张滑动到预设的固定位置(tab stop),用于快速对齐表格的列。C 语言继承了这一传统,\t 的行为精确模拟了打字机的制表机制。

2.3 多位数列的偏移问题

观察实际输出:

1*4=4	2*4=8	3*4=12	4*4=16

3*4=12 占 6 字符而非 5,\t 只跳 2 个位置就到了下一个制表位(位置 16)。而 4*4=16 也是 6 字符,\t 从位置 24 跳到位置 32。视觉效果上,包含两位数的列和一位数的列对齐得不够整齐:

1*1=1
1*2=2	2*2=4
1*3=3	2*3=6	3*3=9
1*4=4	2*4=8	3*4=12	4*4=16 "12" "16" 打破了整齐感

问题根源\t 只关心光标当前位置到下一个制表位的距离,不关心列内容的实际宽度。当列内容宽度不一致时(如 916),就会出现视觉不对齐。

2.4 printf 宽度修饰符 %2d

解决方案:用 printf 的宽度修饰符 %2d 保证每位数列占固定宽度:

width_specifier.c
c
printf("%d*%d=%2d\t", j, i, i * j);  // %2d:至少占 2 字符位,不足补空格

%2d 的含义:输出的整数至少占 2 个字符宽度,不足则在左侧补空格(默认右对齐)。具体行为:

%d 输出宽度%2d 输出宽度
111 12
991 92
10102102
81812812

输出效果对比:

# 仅用 \t(列不对齐)
1*4=4	2*4=8	3*4=12	4*4=16

# %2d + \t(列对齐)
1*4= 4 	2*4= 8 	3*4=12	4*4=16

printf 还支持更多宽度修饰符变体:

修饰符含义示例(值=9)
%2d至少 2 位,右对齐(默认) 9
%-2d至少 2 位,左对齐9
%02d至少 2 位,不足补 0(零填充)09
%+d正数前显示 ++9

TIP

本课练习题的期望输出使用 \t 而非 %2d,因此你的提交代码只需 \t 即可。但理解 \t 的对齐局限和 %2d 的改进方案,是格式化输出的重要知识——在实际项目中,用宽度修饰符做表格对齐比依赖 \t 更可靠。


3. 循环边界控制:三角形 vs 方形乘法表

3.1 下三角模式:j <= i

九九乘法表使用 j <= i 作为内层上界,意味着第 i 行只打印 1*ii*i,形成下三角:

i=1:  1*1=1 1
i=2:  1*2=2  2*2=4 2
i=3:  1*3=3  2*3=6  3*3=9 3
...
i=9:  1*9=9  2*9=18 ... 9*9=81 9

关键设计决策j <= i 而非 j <= 9。内层上界不是固定常数,而是依赖外层变量 i 的动态值——这正是嵌套循环的核心技巧:让内层循环的边界由外层循环的当前状态决定。

3.2 方形模式:j < 10

如果内层上界改为 j < 10(或 j <= 9),每行打印 9 项,形成方形:

square_table.c
c
for (j = 1; j <= 9; j++)
    printf("%d*%d=%d\t", j, i, i * j);

输出:

1*1=1	2*1=2	3*1=3	...	9*1=9
1*2=2	2*2=4	3*2=6	...	9*2=18
...
1*9=9	2*9=18	3*9=27	...	9*9=81

方形表包含 81 项——每一项都在,但有很多重复2*3=63*2=6 是同一个乘法事实,只是乘数和被乘数交换了位置。

3.3 数学对称性:为什么选择下三角

乘法满足交换律:( a \times b = b \times a )。方形表中的 ( 9 \times 9 = 81 ) 项,去掉重复后只剩 ( \frac{9 \times 10}{2} = 45 ) 项——正好是下三角表的项数。

下三角表的每一项 j*i(其中 ( j \le i ))都是唯一的乘法事实。没有重复,没有遗漏——这是数学上最紧凑的表示。

方形表:     9 × 9 = 81 项(含重复)
下三角表:   9 × 10 / 2 = 45 项(无重复)
节省:       81 - 45 = 36 项重复

为什么是 36 项重复? 因为对于每一对 (a, b) 其中 a ≠ b,方形表会同时包含 a*bb*a,形成 36 个重复对。对角线上的 1*1, 2*2, ..., 9*9(共 9 项)没有对称配对,是唯一的。

用矩阵视角来看:

方形 9×9 矩阵(81 个元素):
    1   2   3   4   5   6   7   8   9
1   1   2   3   4   5   6   7   8   9
2   2   4   6   8   10  12  14  16  18
3   3   6   9   12  15  18  21  24  27
...
主对角线下方 = 上三角上方(交换律)
去掉上三角(含对角线)= 下三角 45

NOTE

中国传统九九乘法表采用下三角形式,从「一一得一」开始到「九九八十一」——这种形式既无重复又方便朗读记忆,体现了数学的简洁之美。代码中的 j <= i 精确实现了这一数学结构。

3.4 边界设计的编程思维

j <= i 的设计不是随意的——它来自对问题数学结构的理解。编程不只是写语法正确的代码,更是用代码表达数学和逻辑结构

数学结构                    代码实现
─────────────────────────────────────────
 i 行有 i 个乘法事实  for (j = 1; j <= i; j++)
j 始终 i(消除重复)  循环条件 j <= i
 i 行第 j = j × i  printf("%d*%d=%d", j, i, i*j)
行结束换行  printf("\n")

这体现了编程的核心思维训练:将数学结构精确映射为代码结构。理解「为什么是 j <= i 而不是 j <= 9」需要从数学角度思考——这不是语法问题,是设计问题。


4. break 在嵌套循环中的行为

4.1 break 只跳出最近一层

README 中展示了一种用 break 实现下三角的替代写法:

break_in_nested.c
c
for (i = 1; i < 10; i++)
{
    for (j = 1; j < 10; j++)
    {
        if (j > i)
            break;          // 跳出内层循环,而非外层
        printf("%d*%d=%d\t", j, i, i * j);
    }
    printf("\n");
}

break 的规则:只跳出最近的一层循环——这里 break 跳出的是内层 for (j = ...),外层 for (i = ...) 继续推进。

执行流程追踪(以 i = 3 为例):

外层 i=3 开始

├── 内层 j=1: j > 3? printf("1*3=3\t")
├── 内层 j=2: j > 3? printf("2*3=6\t")
├── 内层 j=3: j > 3? printf("3*3=9\t")
├── 内层 j=4: j > 3? break 跳出内层!

├── printf("\n")
├── i++ i=4,外层继续

这与直觉可能不同——有人期望 break 能跳出两层循环,但 C 语言没有 break 2 这样的语法(某些语言如 Java 有标签化 break,但 C 没有)。

4.2 跳出多层循环的三种方案

如果确实需要从内层跳出外层循环,有以下三种方法:

方法 1:标志变量(flag variable)

break_flag.c
c
int done = 0;
for (i = 1; i <= 9 && !done; i++)
{
    for (j = 1; j <= 9; j++)
    {
        if (some_condition) {
            done = 1;       // 设置标志
            break;          // 跳出内层
        }
    }
    // 外层下次检查 !done,条件为假,自动退出
}

优点:逻辑清晰,易于理解。缺点:需要额外变量,外层条件需要检查标志。

方法 2:goto(C 语言中跳出多层循环的经典正当用法)

break_goto.c
c
for (i = 1; i <= 9; i++)
{
    for (j = 1; j <= 9; j++)
    {
        if (some_condition)
            goto outer_end;     // 直接跳到外层循环之后
    }
}
outer_end:
    // 继续执行

方法 3:函数封装(最推荐)

break_function.c
c
int find_value(void)
{
    int i, j;
    for (i = 1; i <= 9; i++)
    {
        for (j = 1; j <= 9; j++)
        {
            if (some_condition)
                return i * j;   // 直接退出整个函数
        }
    }
    return -1;  // 未找到
}

三种方案的比较

方案清晰度额外开销适用场景
标志变量中等一个 int简单条件,两层以内
goto中等深层嵌套,内核代码,性能敏感场景
函数封装最高函数调用栈几乎所有场景(推荐首选)

CAUTION

goto 在一般情况下应避免使用,但跳出多层嵌套循环是 C 语言中 goto 的经典正当用途——Linux 内核代码中大量使用这种模式。其他场景(如替代 if-else、替代循环控制流)不应使用 goto

4.3 continue vs break 的区别

这是一个初学者常混淆的概念:

语句行为循环是否继续类比
break立即终止整个循环不再执行「这堂课到此结束」
continue跳过本次迭代的剩余部分继续下一次「这道题跳过,看下一道」
break_vs_continue.c
c
// break:遇到第一个偶数就停止
for (i = 1; i <= 10; i++) {
    if (i % 2 == 0)
        break;          // i=2 时跳出,后续都不执行
    printf("%d ", i);   // 只输出 1
}
// 输出: 1

// continue:跳过偶数,继续奇数
for (i = 1; i <= 10; i++) {
    if (i % 2 == 0)
        continue;       // i=2 时跳过 printf,但继续 i=3,4,...
    printf("%d ", i);   // 输出所有奇数
}
// 输出: 1 3 5 7 9

在嵌套循环中,continuebreak 一样,只作用于最近的一层循环

continue_in_nested.c
c
for (i = 1; i <= 3; i++) {
    for (j = 1; j <= 3; j++) {
        if (j == 2)
            continue;       // 只跳过内层 j=2 的 printf,外层 i 不受影响
        printf("(%d,%d) ", i, j);
    }
    printf("\n");
}
// 输出:
// (1,1) (1,3)
// (2,1) (2,3)
// (3,1) (3,3)

5. 花括号 { } 的必要性

5.1 内层循环的花括号

九九乘法表的参考代码中,内层循环体只有一条 printf——理论上可以省略花括号:

braces_optional.c
c
// 简洁写法(内层只有一条语句)
for (i = 1; i <= 9; i++) {
    for (j = 1; j <= i; j++)
        printf("%d*%d=%d\t", j, i, i * j);
    printf("\n");
}

但如果你之后想在内层循环中添加更多操作(比如调试输出、计数等),省略花括号的写法会导致只有第一条语句属于循环

braces_trap.c
c
for (j = 1; j <= i; j++)
    printf("%d*%d=%d\t", j, i, i * j);  // 属于循环体
    count++;                              // 不属于循环体!每次外层推进只执行一次

缩进让人误以为 count++ 在循环内,但编译器看到的结构是:

c
for (j = 1; j <= i; j++)
    printf("%d*%d=%d\t", j, i, i * j);  // 循环体
count++;                                  // 循环外的语句

关键原则C 语言的缩进对人眼有意义,对编译器无意义。编译器只看花括号和分号,不看缩进。缩进是写给程序员看的,花括号才是写给编译器看的。

5.2 外层循环的花括号

外层循环体包含两条语句(内层循环 + 换行),必须用花括号:

c
for (i = 1; i <= 9; i++)
    for (j = 1; j <= i; j++)
        printf("%d*%d=%d\t", j, i, i * j);
    printf("\n");  // 这条不属于外层循环!

编译器的理解:

c
for (i = 1; i <= 9; i++)
    for (j = 1; j <= i; j++)        // 外层循环体(只有一个内层循环)
        printf("%d*%d=%d\t", j, i, i * j);  // 内层循环体
printf("\n");                        // 循环结束后只输出一个换行

结果:所有 45 项挤在同一行,最后只有一个换行——完全不符合期望。

5.3 常见陷阱总结

陷阱代码特征后果
内层花括号省略后添加语句for (j=...)\n stmt1;\n stmt2;stmt2 不在循环内,缩进造成错觉
外层花括号省略(多语句外层体)for (i=...)\n for(...);\n printf(...)外层体只含内层循环,printf 在循环外
嵌套中花括号不对称{ { ... }(少一个 })编译错误或逻辑完全错误
在单语句循环体后添加分号(空语句陷阱)for (j=...);\n printf(...);循环体为空,printf 只执行一次

WARNING

嵌套循环中花括号的遗漏是极常见的错误源。核心规则:外层循环体包含多条语句时必须加 { };内层只有一条时可以省略,但建议始终使用 { } 以避免后续修改时的遗漏。记住一句话:花括号是编译器的眼睛,缩进是你的眼睛——两者必须一致。


6. 循环变量命名与作用域

6.1 i/j 的惯例与历史

ij 是循环变量的最常见命名——从 Fortran 时代延续至今。

Fortran 的隐式类型规则:Fortran(1957 年发布,是最早的高级编程语言之一)规定,以 IJKLMN 开头的未声明变量默认为 INTEGER(整数)类型,而以其他字母开头的变量默认为 REAL(浮点类型)。程序员习惯性地使用 IJK 作为循环变量——不需要额外声明,编译器自动识别为整数。

这一惯例传到了 C 语言,尽管 C 需要显式声明类型,但 ijk 作为循环变量的命名习惯已经深入人心。

与数学矩阵的关联:在数学中,矩阵元素记为 ( A_{ij} ),其中 i 表示行号(row),j 表示列号(column)。嵌套循环中 i 通常表示外层(行),j 表示内层(列)——这与矩阵下标惯例完全一致:

matrix_convention.c
c
// 遍历 3×3 矩阵的每个元素 A[i][j]
for (int i = 0; i < 3; i++)          // i: 行 (row)
    for (int j = 0; j < 3; j++)      // j: 列 (column)
        printf("%d ", matrix[i][j]);

6.2 C89 vs C99 循环变量声明

C 语言的不同标准版本对变量声明位置有不同要求。这在嵌套循环中尤为明显:

C89/C90 模式(传统风格):所有变量必须在块({ })的开头声明,不能在 for 中声明:

c89_style.c
c
int main(void)
{
    int i, j;           // 必须在块开头声明所有变量

    for (i = 1; i <= 9; i++)
    {
        for (j = 1; j <= i; j++)
            printf("%d*%d=%d\t", j, i, i * j);
        printf("\n");
    }
    // i 和 j 在 main 的整个作用域内都可见
    return 0;
}

C99/C11 模式(现代风格):允许在 for 的初始化部分声明变量,变量作用域仅限循环体内:

c99_style.c
c
#include <stdio.h>

int main(void)
{
    for (int i = 1; i <= 9; i++)        // i 在 for 中声明
    {
        for (int j = 1; j <= i; j++)    // j 在 for 中声明
            printf("%d*%d=%d\t", j, i, i * j);
        printf("\n");
    }
    // 此处 i 和 j 已不可访问——编译器报错
    // printf("%d\n", i);  // 错误:'i' undeclared

    // 可以重新声明同名变量,互不冲突
    for (int i = 0; i < 5; i++)
        printf("%d ", i);

    return 0;
}

C99 风格的优势

  1. 作用域最小化——变量只存在于需要它的范围内,减少误用
  2. 避免命名冲突——多个独立循环可以各自声明 int i,互不干扰
  3. 代码更紧凑——声明和使用在同一行,减少上下滚动查找声明
  4. 安全——循环外意外使用循环变量会在编译期被捕获

IMPORTANT

C89/C90 模式下(如某些嵌入式编译器或旧项目),变量声明必须放在块开头,不能在 for 中声明。编译时加 -std=c99-std=c11 可启用此特性。如果遇到 'for' loop initial declarations are only allowed in C99 mode 错误,就是编译器在使用 C89 模式。

6.3 循环结束后的变量值

循环结束后,循环变量的值是什么?这是一个常见的考察点:

loop_var_after.c
c
int i;
for (i = 1; i <= 9; i++)
    printf("%d ", i);
// 循环结束后: i == 10
// 原因: i++ 将 i 从 9 增到 10,然后 i <= 9 为假,退出循环

int j;
for (j = 1; j <= i; j++)    // 这里 i 是 10,所以 j 从 1 到 10
    printf("%d ", j);
// 循环结束后: j == 11

通用规律:对于 for (init; condition; increment),循环结束时循环变量的值是第一个使 condition 为假的值

这和 Lesson 05 中的分析一致——for 循环的终止条件是「条件为假时退出」,退出时的循环变量值是让条件变假的那个值。

C99 模式下的区别:如果在 for 中声明变量,循环结束后变量不可访问,因此不会有「循环结束后变量值是多少」的问题——这是 C99 风格的另一个安全优势。


7. 乘法表的数学结构与编程思维

7.1 下三角的数学本质

九九乘法表的下三角结构来自一个数学事实:对于 ( j \times i )(其中 ( 1 \le j \le i \le 9 )),每一项对应一个唯一的乘法事实。如果 ( j > i ),则 ( j \times i ) 已经在 ( i \times j ) 中出现过(交换律)。

集合论视角:定义集合 ( S = { (a, b) \mid 1 \le a \le 9, 1 \le b \le 9 } ),包含 81 个有序对。定义等价关系 ( (a, b) \sim (b, a) )(乘法交换律)。选择代表元:取每个等价类中 ( a \le b ) 的那个(即下三角)。则代表元个数为 ( \frac{9 \times 10}{2} = 45 )。

内层上界 j <= i 的设计不是随意的——它精确地过滤掉了所有重复项,只保留 ( j \le i ) 的那些。

7.2 从数学到代码的思维映射

数学概念代码实现
行号 ( i )(被乘数)外层循环变量 i
列号 ( j )(乘数)内层循环变量 j
( j \le i )(去掉重复)内层循环条件 j <= i
表项 ( j \times i )printf("%d*%d=%d\t", j, i, i * j)
行结束printf("\n")
表的总行数 ( n = 9 )外层循环条件 i <= 9
第 i 行有 i 项内层循环条件 j <= i

编程思维的训练:将一个数学结构(乘法表)映射为代码结构(嵌套循环),是编程中最基础也最重要的能力。理解「为什么 j <= i 而不是 j <= 9」需要从数学角度思考——这不是语法问题,是设计问题。

7.3 矩阵视角:对称性与存储

用矩阵的视角看乘法表:

 j
       1   2   3   4   5   6   7   8   9
    ┌────────────────────────────────────
 1│ 1   2   3   4   5   6   7   8   9
i  2│ 2   4   6   8   10  12  14  16  18
   3│ 3   6   9   12  15  18  21  24  27
   4│ 4   8   12  16  20  24  28  32  36
   5│ 5   10  15  20  25  30  35  40  45
   6│ 6   12  18  24  30  36  42  48  54
   7│ 7   14  21  28  35  42  49  56  63
   8│ 8   16  24  32  40  48  56  64  72
   9│ 9   18  27  36  45  54  63  72  81

这是一个对称矩阵:元素 (i, j) = 元素 (j, i)。对角线上的元素 (1, 1), (2, 2), ..., (9, 9) 是平方数(1, 4, 9, 16, 25, 36, 49, 64, 81)。

下三角表(j <= i)提取了主对角线及以下的部分,正好覆盖了所有不重复的乘法事实。这为后续学习二维数组和矩阵操作埋下伏笔——Lesson 07 及后续课程将涉及更多二维数据结构。


8. printf 缓冲与 stdout 刷新机制

8.1 标准输出的缓冲策略

你可能好奇:为什么 printf 的输出会「准时」出现在屏幕上?这背后是 C 标准库的缓冲机制。

printf_buffering.c
c
#include <stdio.h>

int main(void)
{
    // 以下输出可能不会立即显示在屏幕上
    printf("Hello");            // 没有 \n,可能留在缓冲区
    printf(" World");           // 仍在缓冲区

    // 以下会触发刷新(flush)
    printf("\n");               // \n 触发行缓冲刷新

    // 显式刷新
    printf("Processing...");
    fflush(stdout);             // 强制刷新,立即输出

    return 0;
}

三种缓冲模式

缓冲模式触发刷新的条件典型场景
全缓冲缓冲区满(通常 4096/8192 字节)文件写入(磁盘)
行缓冲遇到 \n 或缓冲区满标准输出(终端)
无缓冲每次写入立即输出标准错误 stderr

标准输出 stdout 连接到终端时是行缓冲——这意味着每次遇到 \n,缓冲区的内容会被刷新到屏幕。这就是为什么九九乘法表的每行末尾 printf("\n") 不仅换行,还确保该行内容被实际输出。

8.2 为什么需要关心缓冲

在以下场景中,理解缓冲很重要:

场景 1:进度提示不显示

progress_no_flush.c
c
for (int i = 0; i < 1000000; i++) {
    // 复杂计算...
    printf(".");        // 没有 \n,缓冲未刷新
    // 用户可能长时间看不到任何输出
}
// 修正:每 1000 次手动刷新
for (int i = 0; i < 1000000; i++) {
    // 复杂计算...
    printf(".");
    if (i % 1000 == 0)
        fflush(stdout); // 强制刷新
}

场景 2:九九乘法表中的隐式刷新

multiplication_flush.c
c
for (i = 1; i <= 9; i++) {
    for (j = 1; j <= i; j++)
        printf("%d*%d=%d\t", j, i, i * j);  // \t 不触发刷新
    printf("\n");                              // \n 触发刷新!
}

每行末尾的 printf("\n") 不仅产生换行,还触发行缓冲刷新——确保该行内容立即显示在屏幕上。如果去掉 \n,整个输出可能在程序结束时才一次性出现。

TIP

九九乘法表中,printf("\n") 的双重作用:一是产生换行使表格分行,二是触发行缓冲刷新使输出逐行显示。这是一个优雅的设计——一行代码同时满足格式化需求和 I/O 需求。


9. 多行表格格式化选项

9.1 制表符 vs 字段宽度

我们已经看到了两种列对齐方法:

方法 A:制表符 \t

tab_format.c
c
printf("%d*%d=%d\t", j, i, i * j);
  • 优点:代码简洁,无需计算宽度
  • 缺点:依赖制表位(每 8 字符),内容宽度不一致时不对齐

方法 B:宽度修饰符 %Nd

width_format.c
c
printf("%d*%d=%-2d  ", j, i, i * j);  // 左对齐,固定 2 字符宽
  • 优点:精确控制列宽,对齐可靠
  • 缺点:需要预先知道数据最大宽度

9.2 多行表格的完整格式化方案

对于一个更复杂的表格输出(如包含表头、分隔线的完整乘法表),可以使用组合格式化:

formatted_table.c
c
#include <stdio.h>

int main(void)
{
    int i, j;

    // 表头
    printf("     ");
    for (j = 1; j <= 9; j++)
        printf("%4d", j);
    printf("\n");

    // 分隔线
    printf("    +");
    for (j = 1; j <= 9; j++)
        printf("----");
    printf("\n");

    // 表体
    for (i = 1; i <= 9; i++) {
        printf("%2d |", i);             // 行号
        for (j = 1; j <= 9; j++)
            printf("%4d", i * j);       // 右对齐 4 字符宽
        printf("\n");
    }

    return 0;
}

输出效果:

        1   2   3   4   5   6   7   8   9
    +------------------------------------
 1 |   1   2   3   4   5   6   7   8   9
 2 |   2   4   6   8  10  12  14  16  18
 3 |   3   6   9  12  15  18  21  24  27
 ...

9.3 对齐方式对比

修饰符对齐方式示例(值=9,宽度=4)适用场景
%4d右对齐 9数字列(常规)
%-4d左对齐9 文本列、标签
%04d零填充0009固定位数(如编号)
%+4d右对齐 +9需要显式正负号

九九乘法表的练习中使用 \t 是为了简洁,但在实际项目中,用宽度修饰符做表格对齐是更可靠和专业的做法。


参考解答

九九乘法表(for-for 嵌套循环)
table99.c
c
#include <stdio.h>

int main(void)
{
    int i, j;

    for (i = 1; i <= 9; i++)
    {
        for (j = 1; j <= i; j++)
        {
            printf("%d*%d=%d\t", j, i, i * j);
        }
        printf("\n");
    }

    return 0;
}

核心逻辑:外层 i 从 1 到 9 控制行,内层 j 从 1 到 i 控制每行的项数。j <= i 保证下三角形状,printf 的三个 %d 分别对应 jii*j——注意顺序是 j*i=结果\t 分隔各项,\n 结束每行。

使用 break 的替代写法
table99_break.c
c
#include <stdio.h>

int main(void)
{
    int i, j;

    for (i = 1; i < 10; i++)
    {
        for (j = 1; j < 10; j++)
        {
            if (j > i)
                break;
            printf("%d*%d=%d\t", j, i, i * j);
        }
        printf("\n");
    }

    return 0;
}

这种写法让内层循环上界固定为 9,但在 j > i 时用 break 提前跳出内层——效果与 j <= i 相同。break 只跳出内层循环,外层继续推进。

C99 风格(for 中声明变量)
table99_c99.c
c
#include <stdio.h>

int main(void)
{
    for (int i = 1; i <= 9; i++)
    {
        for (int j = 1; j <= i; j++)
        {
            printf("%d*%d=%d\t", j, i, i * j);
        }
        printf("\n");
    }

    return 0;
}

C99 风格在 for 初始化部分声明变量,作用域限于循环体内。编译时可能需要 -std=c99-std=c11 选项。

while-while 实现
table99_while.c
c
#include <stdio.h>

int main(void)
{
    int i = 1, j;

    while (i <= 9)
    {
        j = 1;
        while (j <= i)
        {
            printf("%d*%d=%d\t", j, i, i * j);
            j++;
        }
        printf("\n");
        i++;
    }

    return 0;
}

while-while 实现与 for-for 在功能上等价,但初始化、条件、递增分散在多处。注意内层 while 前需要 j = 1 重新初始化——这是 whilefor 的一个关键区别:for 每次进入循环自动执行初始化,while 需要手动重置。

对照检查:内层上界是否用了 i 而非 9printf 参数顺序是否是 j, i, i*j\t 是否在格式字符串内?每行是否都输出了 \n


课堂讨论

  1. 列举出 3 种可以用 for-for 两重循环来解决的问题场景,并说明为什么单层循环不够用。
  2. 示例中的 { } 是必须的吗?如果去掉外层或内层的 { },会有什么问题?给出具体代码示例。
  3. 如果用 while-while 两重循环来编写这个程序,如何实现?与 for-for 写法相比有什么不同?
  4. break 在嵌套循环中能跳出两层吗?如果需要从内层直接跳出外层循环,有哪三种方法?各自的适用场景是什么?
  5. 如果要打印一个完整的 9×9 方形乘法表(包含所有 81 项),只需要修改哪一处代码?为什么这一处修改就能产生截然不同的输出?
  6. continuebreak 在嵌套循环中的行为有什么区别?如果在内层循环中使用 continue,外层循环会受影响吗?
  7. 为什么 printf("%d*%d=%d\t", j, i, i * j) 中参数顺序是 j, i, i*j 而不是 i, j, i*j?两种写法输出的结果有什么不同?
  8. 如果要求每列宽度固定为 4 个字符,且数字右对齐,应该如何修改 printf 的格式字符串?

讨论答案

Q1: 列举 3 种可以用 for-for 两重循环解决的问题
  1. 矩阵/二维数组遍历:遍历棋盘、图像像素、电子表格。外层遍历行、内层遍历列。单层循环只能处理一维序列,无法同时定位行和列。
  2. 排序算法(如冒泡排序):外层控制轮次,内层逐对比较交换。每轮将当前最大值「冒泡」到末尾。
  3. 组合生成(如密码穷举):生成所有可能的两位密码组合(00~99),外层十位、内层个位。三层循环可扩展到三位密码。

共同特征:这些问题的数据天然具有二维结构,或需要两维度的穷举。单层循环只能处理一维序列,双层循环才能处理二维网格。

Q2: 示例中的 { } 是必须的吗?

外层 { } 是必须的,因为外层循环体包含两条语句(内层循环 + printf("\n"))。省略花括号后,只有第一条语句(内层循环)属于外层循环体,printf("\n") 会变成循环外的语句——最终所有输出挤在一行,只有一个换行:

c
for (i = 1; i <= 9; i++)          // 外层循环
    for (j = 1; j <= i; j++)      // 外层循环体(唯一一条语句)
        printf("%d*%d=%d\t", j, i, i * j);
printf("\n");                      // 循环外!只执行一次
// 输出: 1*1=1  1*2=2  2*2=4  ... 9*9=81  (全部在一行,最后才换行)

内层 { } 可以省略,因为内层循环体只有一条 printf。但建议始终使用花括号——后续添加语句(如调试输出)时不容易遗漏。这是一个防御性编程的好习惯。

Q3: 用 while-while 两重循环如何实现?
while_while_table.c
c
#include <stdio.h>

int main(void)
{
    int i = 1, j;

    while (i <= 9)
    {
        j = 1;                  // 关键:每次进入内层前重置 j
        while (j <= i)
        {
            printf("%d*%d=%d\t", j, i, i * j);
            j++;
        }
        printf("\n");
        i++;
    }

    return 0;
}

与 for-for 的区别

特性forwhile
初始化在循环头部自动执行在循环前手动初始化(j = 1
条件在循环头部,每次迭代前检查while 括号中,每次迭代前检查
递增在循环头部,每次迭代后自动执行在循环体内手动递增(j++
可读性三要素集中,一目了然分散在多处,需要仔细查找
适用计数型循环(次数已知)条件型循环(次数不确定)

for 循环将「初始化、条件、递增」三要素集中在头部,更适合计数型循环。while 更适合条件不确定的循环(如「读到文件末尾为止」)。九九乘法表的循环次数确定(i 从 1 到 9),因此 for 是更自然的选择。

Q4: break 能跳出两层循环吗?跳出多层循环的三种方法

不能。 break 只跳出最近的一层循环。

要跳出多层循环,有三种方法:

方法 1:标志变量

c
int done = 0;
for (i = 1; i <= 9 && !done; i++) {
    for (j = 1; j <= 9; j++) {
        if (condition) {
            done = 1;
            break;
        }
    }
}

方法 2:goto

c
for (i = 1; i <= 9; i++) {
    for (j = 1; j <= 9; j++) {
        if (condition)
            goto outer_end;
    }
}
outer_end:

方法 3:函数封装(最推荐)

c
int search(void) {
    for (int i = 1; i <= 9; i++)
        for (int j = 1; j <= 9; j++)
            if (condition)
                return i * j;
    return -1;
}
方法适用场景推荐度
标志变量两层以内,条件简单★★★
goto深层嵌套,内核代码,性能敏感★★
函数封装几乎所有场景★★★★★
Q5: 打印方形乘法表只需修改哪一处?

只需修改内层循环上界:j <= i 改为 j <= 9(或 j < 10)。

c
for (j = 1; j <= 9; j++)    // 原来是 j <= i,现在固定为 9
    printf("%d*%d=%d\t", j, i, i * j);

这一处修改让每行打印 9 项而非 i 项,从下三角变为方形。修改量极小(改一个字符),但效果截然不同——从 45 项变为 81 项,从无重复变为有 36 个重复项。

这体现了良好代码结构的标志修改一处,效果明确。内层循环上界从 i(动态值)改为 9(固定值),改变了输出形状——边界设计是循环结构中最有表达力的部分。

Q6: continue 和 break 在嵌套循环中的区别
语句行为外层是否受影响类比
break立即终止内层循环否,外层继续「下课,离开教室」
continue跳过内层本次迭代剩余部分否,外层继续「这题跳过,下一题」

在内层循环中使用 continue

c
for (i = 1; i <= 3; i++) {
    for (j = 1; j <= 3; j++) {
        if (j == 2)
            continue;       // 只跳过 j=2 的 printf,外层 i 不受影响
        printf("(%d,%d) ", i, j);
    }
    printf("\n");
}
// 输出:
// (1,1) (1,3)       ← j=2 被跳过
// (2,1) (2,3)       ← j=2 被跳过
// (3,1) (3,3)       ← j=2 被跳过

continuebreak 都只作用于最近的一层循环,外层循环完全不受影响。

Q7: 为什么参数顺序是 j, i, ij 而不是 i, j, ij?

期望输出格式是 j*i=结果(如 2*3=6),而不是 i*j=结果printf 的参数与格式字符串中的 %d 从左到右一一对应:

c
// 正确:输出 "2*3=6"
printf("%d*%d=%d\t", j, i, i * j);
//     ↑1  ↑2  ↑3    ↑1  ↑2  ↑3

// 错误:输出 "3*2=6"(虽然结果值相同,但乘数和被乘数顺序反了)
printf("%d*%d=%d\t", i, j, i * j);
//     ↑1  ↑2  ↑3    ↑1  ↑2  ↑3

两种写法数学结果相同(交换律),但输出格式不同。在自动评测系统中,格式必须精确匹配期望输出——顺序错误会导致判定失败。这也提醒我们:代码不仅要结果正确,格式也要正确。

Q8: 固定列宽 4 字符、数字右对齐的格式字符串
c
printf("%d*%d=%4d", j, i, i * j);
//                   ↑
//                   %4d:至少 4 字符宽,右对齐,不足左侧补空格

如果需要左对齐:

c
printf("%d*%d=%-4d", j, i, i * j);
//                   ↑
//                   %-4d:至少 4 字符宽,左对齐,不足右侧补空格

输出效果对比:

%4d (右对齐):  "1*1=   1"  "2*2=   4"  "9*9=  81"
%-4d (左对齐): "1*1=1   "  "2*2=4   "  "9*9=81  "

右对齐是数字列的常规做法(个位对齐便于比较大小),左对齐适用于文本列。


课后练习

  1. 矩形星号图案:编写程序打印一个 m × n 的星号矩形(mn 列的 *),mn 由用户输入。

    知识点提示:双层 for 循环,外层控制行数,内层控制列数。与乘法表结构相同,只是循环体改为打印 *

  2. 下三角星号图案:打印一个 n 行的下三角星号图案(第 1 行 1 个星号,第 2 行 2 个,...),n 由用户输入。

    知识点提示:内层上界 j <= i——与乘法表相同的边界逻辑。这是九九乘法表的「图案化」应用。

  3. 完全数查找:一个数如果等于其所有真约数(不包括自身)之和,则称为完全数。例如 6 = 1 + 2 + 3。找出 1~1000 以内的所有完全数。

    知识点提示:外层遍历每个数,内层遍历其可能的约数(1 到 n-1),用 n % i == 0 判断是否为约数。双层循环加条件判断的经典组合。

  4. 水仙花数:一个 n 位数(n ≥ 3)每个位上的数字的 n 次幂之和等于它本身。例如 1³ + 5³ + 3³ = 153。找出 n = 3 的所有水仙花数(即 100~999)。

    知识点提示:拆解三位数的百位 n/100、十位 (n/10)%10、个位 n%10,计算每位立方之和。单层循环即可,但拆解数字是后续课程的重要基础。

  5. 棋盘打印:打印一个 5×5 的棋盘(0 表示空位,1 表示有子),用户输入一个坐标(如 2 3),在该位置放置棋子后重新打印棋盘。

    知识点提示:二维数组(int board[5][5])、双层循环遍历、scanf 读坐标。注意数组下标从 0 开始,需要转换。

  6. 九九加法表:仿照九九乘法表,打印一个九九加法表(下三角形式),格式为 j+i=结果,用 \t 分隔。

    知识点提示:只需将乘法 i*j 改为加法 i+j,格式字符串中 * 改为 +。理解「模式复用」——同一结构应用于不同运算。

练习1: 矩形星号图案
rectangle_stars.c
c
#include <stdio.h>

int main(void)
{
    int m, n, i, j;

    printf("请输入行数和列数: ");
    scanf("%d %d", &m, &n);

    for (i = 1; i <= m; i++)
    {
        for (j = 1; j <= n; j++)
            printf("*");
        printf("\n");
    }

    return 0;
}

逻辑:外层 i 控制行数,内层 j 控制每行的星号数。与乘法表结构相同,只是循环体改为打印 * 而非 j*i=结果。这个练习展示了嵌套循环的「模板化」特性——改变循环体的内容,同一结构可以生成完全不同的输出。

练习2: 下三角星号图案
triangle_stars.c
c
#include <stdio.h>

int main(void)
{
    int n, i, j;

    printf("请输入行数: ");
    scanf("%d", &n);

    for (i = 1; i <= n; i++)
    {
        for (j = 1; j <= i; j++)
            printf("*");
        printf("\n");
    }

    return 0;
}

内层上界 j <= i 与九九乘法表完全一致——第 i 行打印 i 个星号。掌握了一种模式(j <= i 下三角),就能应用到多种场景:星号图案、数字三角、乘法表等。

练习3: 完全数查找
perfect_numbers.c
c
#include <stdio.h>

int main(void)
{
    int num, i, sum;

    for (num = 2; num <= 1000; num++)
    {
        sum = 0;
        for (i = 1; i < num; i++)
        {
            if (num % i == 0)
                sum += i;
        }
        if (sum == num)
            printf("%d is a perfect number\n", num);
    }

    return 0;
}

双层循环嵌套:外层遍历 2~1000 的每个候选数,内层遍历其可能的约数。num % i == 0 判断 i 是否为约数,累加所有真约数之和,最后检查是否等于自身。

输出:6、28、496(第四个完全数是 8128,超出 1000 范围)。

练习4: 水仙花数
narcissistic_numbers.c
c
#include <stdio.h>

int main(void)
{
    int n, hundreds, tens, units;

    for (n = 100; n <= 999; n++)
    {
        hundreds = n / 100;          // 百位
        tens = (n / 10) % 10;        // 十位
        units = n % 10;              // 个位

        if (hundreds * hundreds * hundreds +
            tens * tens * tens +
            units * units * units == n)
            printf("%d\n", n);
    }

    return 0;
}

输出:153、370、371、407。

核心技巧是拆解三位数的各位数字:百位 n/100、十位 (n/10)%10、个位 n%10。虽然本题只用单层循环,但拆解数字的方法在后续多位数处理中非常实用。

练习5: 棋盘打印
chessboard.c
c
#include <stdio.h>

int main(void)
{
    int board[5][5] = {0};  // 全部初始化为 0
    int row, col, i, j;

    printf("请输入坐标 (行 列): ");
    scanf("%d %d", &row, &col);

    if (row >= 1 && row <= 5 && col >= 1 && col <= 5)
        board[row - 1][col - 1] = 1;   // 数组下标从 0 开始

    for (i = 0; i < 5; i++)
    {
        for (j = 0; j < 5; j++)
            printf("%d ", board[i][j]);
        printf("\n");
    }

    return 0;
}

二维数组 board[5][5] 存储棋盘状态,双层循环遍历打印。注意数组下标从 0 开始,所以用户输入的坐标需要减 1 转换。{0} 初始化将整个数组置零。这引入了数组和二维遍历的概念——后续课程会详细讲解。

练习6: 九九加法表
addition_table.c
c
#include <stdio.h>

int main(void)
{
    int i, j;

    for (i = 1; i <= 9; i++)
    {
        for (j = 1; j <= i; j++)
        {
            printf("%d+%d=%d\t", j, i, i + j);
        }
        printf("\n");
    }

    return 0;
}

与乘法表相比,只改了两处:* 改为 +i * j 改为 i + j。其余结构完全不变——这就是「模式复用」的力量。理解了一种嵌套循环模式后,稍作修改就能适配多种场景。


参考资料

"First, solve the problem. Then, write the code." — John Johnson

Released under the MIT License.