前两天整理学习笔记,翻到一份标着26.3.15的C语言练习,内容是最常见的九九乘法表。说来也怪,这题在初学阶段几乎人人写过,可等你真写了三五年代码再回头看,反倒能从这十几行里挑出不少值得琢磨的细节。九九乘法表C语言实现,表面是双层循环加一个printf,背后其实踩中了循环嵌套、格式化输出、逻辑分支、编译调试这几块C语言入门时最容易翻车的区域。这篇内容适合正在上大一大二C语言课的同学,也适合自学了一段时间但总觉得输出"不够好看"、代码"不够规范"的朋友,我把当年踩过的坑和后来总结的经验一起写出来。
1. 为什么第一道编程作业总是它
1.1 九九乘法表覆盖了初学阶段九成核心语法点
学校里把九九乘法表当入门题,不是因为它简单,而是因为它"小而有代表性"。一道题做完,你几乎把C语言第一学期的知识点全碰了一遍:
- 变量定义与初始化:
int i, j; - 循环结构:外层循环管行,内层循环管列
- 循环嵌套:内层循环每执行一轮,外层循环才走一步
- 条件判断:怎么决定一行输出多少项
- 格式化输出:
printf的各种占位符 - 代码组织:函数、返回值、主函数入口
更重要的是,九九乘法表能让你第一次真正理解"循环变量"这个概念。i从1变到9,j从1变到i,i*j跟着变——你盯着输出结果就能看到程序执行的轨迹,这种"看得见摸得着"的反馈,是纯语法练习题给不了的。
1.2 老师不说的考试和面试潜规则
你去翻各高校的C语言期末试卷,九九乘法表几乎是题库钉子户,或者原题,或者换个马甲考你(比如"打印N阶乘法表"、"输出指定行列的结果")。笔试考它不是为了让你背代码,而是考你能不能讲清楚两层循环的顺序关系。
还有一类经常出没的考法,就是热搜词里那个PAT乙级1037"在霍格沃茨找零钱"的套路:题目给你三个数字,让你算差值,底层逻辑其实就是个多重嵌套的数位处理。很多人觉得这类题难,是因为平时练的都是"完整输出"的乘法表,从来没拆开看过中间的行列关系。先把九九乘法表从里到外拆透,后面遇到类似的嵌套循环题,思路会顺很多。
2. 先把循环跑通再说美观
2.1 双层for循环的基本骨架
很多初学者一上来就追求"漂亮",我的建议是先别管对齐、别管空格,跑出一个能看的版本再说。最原始的代码如下:
#include <stdio.h> int main() { int i, j; for (i = 1; i <= 9; i++) { for (j = 1; j <= 9; j++) { printf("%d*%d=%d\t", i, j, i * j); } printf("\n"); } return 0; }这段代码输出的其实是9行×9列的完整方阵,不是老师口中的经典三角。但没关系,第一步是确认循环嵌套是通的。
核心逻辑就这么三句话:
- 外层
for (i = 1; i <= 9; i++)负责控制行号,也就是乘数。 - 内层
for (j = 1; j <= 9; j++)负责控制列号,也就是被乘数。 - 在循环体里,
i * j是当前行列对应的乘积。
每次内层循环走完,printf("\n")换行,外层循环才进入i的下一个值。这就是"嵌套"的含义:内循环在外循环的每一次迭代中都完整地跑一遍。
2.2 手推一遍变量变化,比看十遍书都管用
我知道很多人觉得看代码就懂了,但真正能把这个双层循环讲明白的人,是能一口气把变量的变化轨迹写出来的。以i=2、内循环j从1取到9为例:
| i | j | i*j | 输出 |
|---|---|---|---|
| 2 | 1 | 2 | 2*1=2 |
| 2 | 2 | 4 | 2*2=4 |
| 2 | 3 | 6 | 2*3=6 |
| ... | ... | ... | ... |
| 2 | 9 | 18 | 2*9=18 |
当i等于2时,内循环完整跑9次,j从1涨到9,然后内循环退出,外层i变成3,内循环再次从j=1重新开始。如果你能不看运行结果,直接在纸上推导出这段执行的顺序,说明你对循环嵌套真的理解了;如果推不出来,建议打开调试器,一行一行看变量变化——这也是我后面第5节要重点展开的内容。
3. 四个方向的乘法表:从9x9到各种变体
3.1 标准下三角版:最常被要求输出的版本
把方阵变成经典下三角,只需要改一个条件:内层循环的结束条件从j <= 9改成j <= i。
#include <stdio.h> int main() { int i, j; for (i = 1; i <= 9; i++) { for (j = 1; j <= i; j++) { printf("%d*%d=%-2d ", i, j, i * j); } printf("\n"); } return 0; }为什么j <= i?因为我们要的是"第i行只输出i列"。第一行i=1,j最多取到1,输出1个式子;第二行i=2,j取到2,输出2个式子;第9行i=9,j取到9,输出9个式子。这样从上到下,行越来越长,最终形成一个左下三角。
这里我再刻意用了一个%-2d,后面第4节会专门讲。现在你只需要知道,它能让乘积为1位数的时候多一个空格,保证个位数和两位数放在同样宽度的格子里,表格看起来不歪。
3.2 完整方阵版:先不截断再看"全表"
有一种看法是,新手应该先写完整方阵,再去做三角。我也是这个主张。完整方阵能帮你理解"每一行的列数都一样"是什么概念,然后再去想"怎么截断列数"这个问题。
完整方阵的输出其实很壮观,每行9个式子,共81个。看完全表,你会更容易理解下三角到底切掉了哪些地方——它切掉的是每行j大于i的那部分。也就是说,当j <= i为真时输出,当j > i时不输出。这其实很自然地引出了下一节的上三角版。
3.3 上三角版:把j的起点改一下就出来了
下三角是"每行越来越多",上三角则是反过来,"每行越来越少",而且输出的式子是j从i开始的。实现方式有两种思路,我分别写一下。
思路一,直接改内层循环的起点:
#include <stdio.h> int main() { int i, j; for (i = 1; i <= 9; i++) { for (j = i; j <= 9; j++) { printf("%d*%d=%-2d ", i, j, i * j); } printf("\n"); } return 0; }这段代码里,内层循环从j = i开始,一直加到9。第一行i=1,输出j=1到9的全部式子;第二行i=2,从j=2开始到9,减少了最前面的2*1;第九行i=9,只有j=9一个式子。你会在终端里看到一个从上到下逐渐变短的三角形,斜边在左边。
思路二,沿用前面的"完整方阵+条件判断"写法,在循环体里加一个if:
for (i = 1; i <= 9; i++) { for (j = 1; j <= 9; j++) { if (j >= i) { printf("%d*%d=%-2d ", i, j, i * j); } } printf("\n"); }两种写法输出的结果一样,但思路不同:第一种是"循环条件直接控制",第二种是"先算出所有格子再过滤"。我建议你两种都跑一遍,体会一下这两种控制方式的区别,因为后面写其他图形题(菱形、三角形、杨辉三角)的时候,两种思路会反复出现。
3.4 再说说方向反着的打印方式
如果你想要一个"右下三角"或者"右上三角",也就是让三角形贴着右边对齐,就得在每行开头额外打印一些空格。原理很简单:先打印一定数量的空格,再打印式子。
比如右下三角,可以先打印(i - 1) * 4个空格?这里有个误区——空格数量不能随便拍,要看一行式子大概占多宽。最稳妥的做法是,每行先输出9 - i个固定的分隔宽度,再输出式子本身。具体代码我留到第4节对齐讲完后再给,因为那个版本里空格宽度和格式化字符强相关。
4. 排版对齐是这题真正的"深水区"
4.1 为什么\t总是不够用
我见过最多的翻车场景,就是一开始用printf("%d*%d=%d\t", i, j, i * j);,结果在终端里看还行,一拷到文件里就乱了。
原因在于\t制表符是按照当前输出位置跳到下一个制表位的,而制表位宽度由终端环境决定——有的终端8列一个制表位,有的4列一个。更麻烦的是,当乘积从一位数变成两位数时,比如"99=81"这一项明显比"11=1"长,制表符就会把下一列顶到不同的位置,整个表格的竖线完全对不上。
所以我的结论很明确:正式输出乘法表不要依赖\t,要靠格式化占位符控制列宽。
4.2 %2d、%-2d、%02d三种格式化方式的区别
C语言的printf格式化里,数字宽度控制是这块的重头戏。直接看表格:
| 格式 | 对数字12的输出 | 对数字3的输出 | 对齐方式 |
|---|---|---|---|
%d | 12 | 3 | 无宽度控制 |
%2d | 12 | (空格)3 | 右对齐,宽度2 |
%-2d | 12 | 3(空格) | 左对齐,宽度2 |
%02d | 12 | 03 | 右对齐,用0补位 |
乘法表的每一格通常包括"ij="和乘积,乘积宽度最多2位,所以用宽度2就够了。右对齐的%2d会让个位数的乘积前面补一个空格,输出看起来像"11= 1";而左对齐的%-2d会在个位数的乘积后面补一个空格,输出为"11=1 "。实践下来,乘法表更适合左对齐,因为每个式子的左边界从第1列开始,用%-2d可以让每个"ij="的位置整齐地排在一个竖线上,同时单个式子之间也不会挤在一起。
4.3 不同对齐方式的实测效果对比
我把三种输出方式跑了一遍,这是实际效果。第一种用\t:
1*1=1 1*2=2 1*3=3 ...第二种用%2d(乘积右对齐):
1*1= 1 1*2= 2 1*3= 3 ...第三种用%-2d(乘积左对齐):
1*1=1 1*2=2 1*3=3 ...第三种明显更整齐。但注意,这里每一项之间我手工加了两个空格作为列间隔。更工程化的写法是这样:
printf("%d*%d=%-2d ", i, j, i * j);%-2d保证乘积占2个字符宽度,后面再加两个空格隔开下一项。这样每一列的实际占宽是固定的——式子前半部分"i*j="长度固定,乘积部分固定占2位,再加2个空格,整列宽度完全可控。而且不管乘积是一位数还是两位数,边界纹丝不动。
实测下来,这个写法能保证任何终端尺寸下表格都是对齐的,你甚至可以进一步把间隔空格的数量调整成3个或4个,只要全表统一就行。
5. 从编译警告到gdb单步跟踪的完整排错记录
5.1 gcc编译扫描:把编译器警告当回事
很多人写九九乘法表,代码跑出结果来就完事了。我建议多走一步:打开编译警告。
gcc -Wall -Wextra -o multiplication multiplication.c在Ubuntu虚拟机上配置c语言环境时,gcc装好之后我习惯把这条命令当成默认。-Wall会告诉你哪些地方有潜在问题,比如"疑似缺少printf返回值检查"、变量未使用,甚至格式化字符串和参数不匹配。我在初学阶段就吃过一次亏:printf("%d*%d=%d", i, j)——少传了一个参数,输出结果是垃圾值,但编译器默认不报错。开了-Wall之后,这类问题立马能扫出来。
顺带说一句,用VSCode写C语言代码的同学,可以在tasks.json里把编译命令改成带-Wall的版本,每次编译自动检查,比写完再回头查省事得多。
5.2 gdb实战:单步跟踪循环变量
如果编译通过,但输出结果逻辑不对,比如列数多了、少了,或者行列关系反了,这时候就该请出GDB。调试器不是大佬的专属工具,初学者用它来观察九九乘法表这种小循环再合适不过。
下面是一个典型的调试会话,我在第2章的基础上,对下三角版本做单步跟踪。假设文件名为mul.c,编译时加-g参数保留调试信息:
gcc -g -o mul mul.c gdb ./mul进入gdb后:
(gdb) break 7 (gdb) run (gdb) next (gdb) print i (gdb) print j先在循环体那一行下断点,然后run,程序会停在第一次进入内层循环的位置。这时print i和print j可以看到当前i=1、j=1。接着反复next,观察j怎么从1涨到2、再到3,直到j变成i+1后内层循环退出,外层i变成2。这个过程很快,但看一遍之后,你对循环嵌套的信心会完全不同。
我还习惯用display i和display j让gdb每次停下时自动打印这两个变量,省去手动print的麻烦。断点条件也值得试试:
(gdb) break 7 if i == 5这条命令让程序只在i等于5时停下来,可以快速跳到第5行观察中间行的输出——这在排查"某一行特别歪"的问题时特别管用。
5.3 经典翻车现场:为什么"每一列都从1开始"
还有一个我在给学弟学妹答疑时反复见到的翻车版本:
for (i = 1; i <= 9; i++) { for (j = 1; j <= 9; j++) { if (j <= i) { printf("%d*%d=%-2d ", i, j, i * j); } } printf("\n"); }这个版本逻辑上没问题。真正的翻车写法是内层循环写成:
for (j = 1; j <= i; j++) { printf("%d*%d=%-2d ", i, j, i * j); }问题出在你可能把i和j在printf里的位置写反了,变成printf("%d*%d=%-2d", j, i, i*j)。对于乘法表,输出成"21=2"还是"12=2",单独看不觉得有问题,但整张表的行其实全反了——第一行会出现多个不同的"1*N=...",看起来特别乱。
遇到这种输出,先别急着改代码,直接在gdb里print i和print j,对照屏幕上的式子。i=2、j=1时屏幕显示"21=2",说明printf里先打印的是i,即当前行号——这符合预期;如果屏幕显示"12=2",那说明你把j和i的位置写反了。这种排查,在循环比较小的时候靠肉眼看就能出来,循环规模一大,调试器就是唯一靠谱的办法。
6. 乘法表之外的下一站
6.1 从打印到存储:让乘法表输出到文件里
九九乘法表练熟了之后,第一件可以做的升级是:把输出从终端转移到文件。这里会用到fprintf和文件缓冲区相关的知识,正好热搜词里也有这一条。
#include <stdio.h> int main() { FILE *fp; int i, j; fp = fopen("mul.txt", "w"); if (fp == NULL) { printf("文件打开失败\n"); return 1; } for (i = 1; i <= 9; i++) { for (j = 1; j <= i; j++) { fprintf(fp, "%d*%d=%-2d ", i, j, i * j); } fprintf(fp, "\n"); } fclose(fp); return 0; }这版本比纯屏幕输出的区别在哪儿?一是打开文件需要判空,这养成了处理错误返回的习惯;二是fprintf把格式化的内容和printf的写法统一,学一次就会了;三是fclose不能省,否则缓冲区里的内容可能没写完。你可以试试故意不写fclose,程序结束后打开mul.txt,会发现内容不全或者为空——这正是文件缓冲区机制导致的细节。
6.2 从9x9到NxN:把固定版本改成可交互程序
另一个自然延伸是按需求打印任意阶数的乘法表。可以配合scanf让用户输入一个n,然后输出n×n的下三角乘法表。
#include <stdio.h> int main() { int n, i, j; printf("请输入乘法表阶数: "); scanf("%d", &n); for (i = 1; i <= n; i++) { for (j = 1; j <= i; j++) { printf("%d*%d=%-2d ", i, j, i * j); } printf("\n"); } return 0; }这个版本升级之后,问题就来了:n等于99甚至999的时候,乘积超过两位数,%-2d就不够用了。这其实是个"宽度自适应"的经典练习题,我自己写过一个动态计算最大位数的版本,做法是先算出n*n结果的最大位数max,然后在printf里用%*d动态指定宽度。格式写成printf("%d*%d=%-*d", i, j, max, i * j)就能搞定。这一步能学到的不是乘法表,而是"格式化宽度可以运行时决定"这个知识点。
6.3 从乘法表到更多经典小题
九九乘法表练完循环和输出,下一步可以刷一批同样经典的题目串联巩固:
- 完数问题:判断一个数是否等于它所有真因子之和,涉及循环和取模运算
- 冒泡排序:把内层循环的终止条件从
j <= i改造成j < n - i - 1,你会发现这和乘法表的下三角逻辑有异曲同工之妙 - 字符串逆序:用双指针或者临时数组实现逆序,练的是数组和指针
- 弹球游戏小项目:如果能用两个循环控制球在二维平面上的轨迹,说明你已经完全吃透了循环嵌套
这些题目的共同点,都是把"控制循环边界条件"作为核心。乘法表给出的j <= i这个条件,你理解得越深,后面写这些算法题的时候改条件就越快。
我个人在实际操作中还有一个体会:别急着把所有变体一次性写完,把一段代码调试顺了,然后在此基础上改三五行,比从头写五个独立版本更有用。我当年就是在gdb里看着i和j反复横跳,突然想明白了"其实所有复杂循环的边界条件,都是从一个具体例子推出来的"——这个感觉比背任何现成代码都重要。
最后再分享一个小技巧:九九乘法表这个程序虽然简单,但它适合做各种"折腾"实验,比如把外层循环改成while、把内层循环改成do-while,或者干脆用递归实现一次,每一个改动都能让你对C语言执行模型的理解更深一点。所以别嫌它简单,把它当成一个可以折腾很久的小实验场,你会收获不少。