1. 一道“祖传练习题”,为什么我今天还要重新写一遍
前阵子帮某个同学看C语言作业,又碰到了那个让我又爱又恨的老题目:C语言输出杨辉三角形。说它“老”,是因为我读书那会儿就在写,现在我身边的新人入行也还在写;说“又爱又恨”,是因为这题表面看就是“打印一个数字三角形”,但真正落笔之后你会发现,数组怎么开、循环怎么走、数字之间怎么对齐、输入太大怎么办,每一环都能让人翻车。
网上搜“C语言杨辉三角”,能搜到一大堆代码,大部分长这样:开一个二维数组,两层循环递推,再两层循环打印,完事。代码确实能跑,但你让它多输出几行试试?让它换个终端试试?让它别打印尾部空格试试?问题立刻冒出来。我这几年用这个题给新人做过小测试,也拿它抓过不少隐藏问题。今天干脆把这道题从头拆一遍,把数学原理、存储选型、格式化对齐、边界检查、进阶优化全部讲透,最后给两套可以直接抄的完整代码。无论你是刚学数组的初学者,还是想复习C语言细节的开发者,这篇应该都能给你一点不一样的东西。
我写这篇的立场很直接:代码不是“能跑”就算完,而是要能解释“为什么这么写”。所以下面每个关键决策我都会讲清楚理由,这些理由才是这个题真正的价值所在。
2. 先别急着敲键盘,把杨辉三角的数学关系捋清楚
2.1 杨辉三角到底在算什么
杨辉三角的每个数,本质上都是二项式系数,也就是组合数 C(n, k),表示从 n 个不同元素里取 k 个的组合数。第 n 行第 k 列的数就等于 C(n, k),只不过这里行号、列号通常从 0 开始。比如第 4 行(从第 0 行算)是 1 4 6 4 1,对应 C(4,0)=1、C(4,1)=4、C(4,2)=6、C(4,3)=4、C(4,4)=1。
这个身份非常重要,因为它给了我们三种计算路径:
- 经典递推:
C(n,k) = C(n-1,k-1) + C(n-1,k),也就是每个数等于左上方和右上方两个数相加,这是大家最熟悉的“两两相加”法。 - 阶乘公式:
C(n,k) = n! / (k! * (n-k)!),数学上很完美,但工程上坑很大。 - 行内递推:在同一行里,已知 C(n,k) 求 C(n,k+1),满足
C(n,k+1) = C(n,k) * (n-k) / (k+1)。这个公式很多人没注意到,但它正是“O(1)内存打印杨辉三角”的关键。
数学上三者完全等价,可一旦落到具体代码里,它们的“靠谱程度”截然不同。我见过不少初学者拿阶乘公式写,代码看着高级,一跑就傻眼。所以选公式前,一定要先问一句:这个公式在计算机里会不会溢出?
2.2 阶乘公式为什么容易“爆”
阶乘增长得极快。13! 等于 6227020800,早就超过 32 位 int 的上限 2147483647。你为了算 C(20,10),中间要算 20!、10!,结果中间量先爆炸,最后算出来的组合数却是错的。用 long long 能撑到 20! 左右,再往上也扛不住。
所以阶乘公式不是不能用,而是只适合 n 很小的情况。作为通用解法,它是最差的选择。
那经典递推和行内递推会不会溢出?也会,但没有阶乘那么“冤枉”。经典递推每一步只是两个整数相加,中间结果不超过最终结果的两倍,不会出现“中间量比结果大几个数量级”的荒诞情况。行内递推稍微复杂一点:C(n,k+1) = C(n,k) * (n-k) / (k+1),如果先乘后除,中间结果可能临时超过最终值。比如 C(33,15) 是 1037158320,再乘以 18 得到约 1.87e10,远超 int 上限。所以行内递推需要使用 long long,或者先约分再乘除。我在后面第 6 节会给出完整的处理方案。
2.3 我的选型建议
如果是教学演示,老老实实用经典递推,也就是“每个数等于上一行相邻两数之和”。这个公式有两个好处:一是思路直观,跟杨辉三角的形状完全对应;二是中间结果可控,不容易出意料之外的溢出。
如果要求内存极省,或者要输出特别大的行数,那就用行内递推配合约分技巧,内存能做到 O(1)。
如果只是写作业,不要碰阶乘公式。我知道它写起来很帅,但在工程里属于典型的“看起来对,实则埋雷”。
3. 存储结构选型:二维数组、滚动数组、还是干脆不存
3.1 二维数组最直观,但你真的需要记住所有行吗
大多数教材解法长这样:
long long a[100][100] = {0}; a[0][0] = 1; for (int i = 1; i < n; ++i) { a[i][0] = 1; for (int j = 1; j <= i; ++j) { a[i][j] = a[i-1][j-1] + a[i-1][j]; } }然后把整个数组打印出来。这个方案思路清晰,初学者一眼能看懂,但我必须说一句实话:如果要输出前 67 行,你只需要最后一行数据来动态计算对齐宽度,其他行打印完就没用了,把它们全存在二维数组里纯属浪费空间。开一个a[100][100]看起来无所谓,但如果行数改成 1000,就是 100 万个 long long,8 MB 内存;改成 5000 就是 200 MB。对一个只需要顺序输出的程序来说,这个代价完全没有必要。
二维数组还有一个不太显眼的问题:很多人习惯开int a[100][100],一旦 n 超过 30,数字就可能溢出。我建议哪怕写教学版,也直接用long long,这不是炫技,是给后面的行数留余地。
3.2 滚动数组:两个一维数组交替使用
既然每一行只依赖上一行,那我只保留上一行不就够了?这就是滚动数组的核心思想。实现上有很多种,我最推荐“单个一维数组原地更新”的写法:
long long cur[100] = {0}; cur[0] = 1; for (int i = 1; i <= n; ++i) { // 从右往左更新,避免覆盖上一行还未使用的数据 for (int j = i - 1; j >= 1; --j) { cur[j] = cur[j-1] + cur[j]; } // 此时 cur[0] 到 cur[i-1] 就是第 i 行 print_row(cur, i); }为什么必须从右往左更新?因为你如果从左往右更新,cur[j] = cur[j-1] + cur[j]里的cur[j-1]已经被改成本行了,再拿来算cur[j],数据就串了。从右往左时,cur[j-1]仍然是上一行的旧值,这样才算得对。这个细节我几乎每次讲都有人踩坑,值得刻在脑子里。
这个方案内存占用只有一个一维数组,代码比二维数组更短,性能上也更好。很多人以为滚动数组是“高级优化”,其实它只是顺着数据依赖关系做了最自然的选择。
3.3 不存数组:直接按组合数公式逐行打印
还有一条路是干脆不存,每打印一个数就现场算。内存占用 O(1),代码也很短:
for (int i = 0; i < n; ++i) { long long val = 1; for (int j = 0; j <= i; ++j) { printf("%*d", width, val); // 用行内递推计算下一个数 long long num = i - j; long long den = j + 1; long long g = gcd(num, den); num /= g; den /= g; val = val / den * num; } }这里的递推公式需要一点解释:val当前是C(i, j),下一项是C(i, j+1) = C(i, j) * (i-j) / (j+1)。直接乘除会溢出,所以先对(i-j)和(j+1)约分,再除乘。比如i=30, j=15,num=15, den=16,约分后num=15, den=16,但val=C(30,15)=155117520,先除以16会得到9694845,再乘15得145422675,也就是C(30,16),完全正确。
不存储的方案缺点是什么?如果你还需要利用杨辉三角做后续计算,比如统计某个数字出现的次数,那每次重新算就不划算了。所以这个方案最适合“只输出不求结果”的需求。
3.4 三种存储方案怎么选
| 方案 | 内存占用 | 是否容易理解 | 适合场景 |
|---|---|---|---|
| 二维数组 | O(n^2) | 最高 | 教学、需要之后访问任意元素 |
| 滚动数组 | O(n) | 中等 | 绝大多数输出场景,推荐 |
| 免存储公式 | O(1) | 较低 | 嵌入式、超大行数、纯输出 |
我的结论很明确:日常做这个题,优先滚动数组;如果追求可读性交作业,二维数组也能接受;免存储适合第二种优化版本。不要一上来就抄最复杂的,先想清楚你的需求是什么。
4. 格式化输出:真正让人抓狂的隐形杀手
4.1 为什么你打印出来的“三角形”总是歪的
很多人以为杨辉三角的对齐是靠每行开头敲几个空格,这想法不能说是错的,但很容易做出“越到后面越歪”的效果。原因很简单:数组里的数字位数不一样,1 只有 1 位,126 有 3 位,如果你只在前端补空格,数字本身占的宽度不同,下一行就没法跟上一行对齐。
正确思路是:让每个数字都占一个固定宽度的格子,宽度不够就用空格补齐。这样一来,每个数占的列数完全一致,三角形自然就正了。很多教科书喜欢用printf("%4d ", x)这种写死宽度的方法,但它只对数值不超过 4 位的行有效,遇到 5 位数就全乱套。
所以正式一点的写法必须根据最大数值动态计算宽度。杨辉三角第 n 行的最大值是中间那个组合数,比如第 34 行中间是 1166803110,占 10 位。你如果每个数字都按 10 位来补空格,整个三角形就非常稳。
4.2 动态宽度:printf("%*d") 的正确用法
C 语言的 printf 有一个很多人忽略的用法:*号表示宽度由参数动态指定。比如:
printf("%*d", 6, 42);输出效果是“ 42”,数字 42 占 6 个格子,前面被补了 4 个空格。这个特性非常适合用来打印杨辉三角,因为宽度w是程序运行到一半才知道的。
计算宽度也不难,写个简单的位数函数:
int digit_count(long long x) { int count = 1; while (x >= 10) { x /= 10; ++count; } return count; }然后拿第 n-1 行(最后一行)的最大数字去算位数,就得到每个数字最少需要的宽度。为了格子之间有点空隙,通常再加 1:
int width = digit_count(max_value) + 1;每个数字用printf("%*d", width, val)打印,每行开头用空格补足width * (n - i - 1)个缩进,等腰三角形就出来了。
4.3 行首缩进到底要缩多少
先说结论:如果每个数字占width列,那么第 0 行前面需要width * (n - 1)个空格,第 1 行前面需要width * (n - 2)个空格,依此类推,第 i 行前面需要width * (n - i - 1)个空格。
为什么?因为你要让每一行的中心跟最后一行的中心对齐。每一行比最后一行少(n - i - 1)个数字,每个数字占width列,那开头就补上这width * (n - i - 1)列。这个逻辑想通了,比硬背公式可靠得多。
打印缩进的代码可以这么写:
printf("%*s", width * (n - i - 1), "");%*s的宽度由参数指定,字符串传空串,效果就是打印指定数量的空格。这样比写一个 while 循环敲空格干净很多,你之后回头看代码也更容易明白意图。
4.4 我踩过的格式化坑,一并说给你听
第一个坑:别把中文写进数字行的格式串里。在很多终端环境下,一个中文字符占两个西文字符位,但它本身在 printf 的计算里只算 1 个宽度,直接导致后面的数字错位。如果你需要“杨辉三角”这样的标题,单独占一行打印没问题;但要是往数字行里混中文,几乎必乱。
第二个坑:行尾不要留多余空格。有些 IDE 自带“删除行尾空格”功能,有些评测系统会严格比对输出结果,多一个空格可能判错。打印完一行最后一个数字后直接putchar('\n'),不要在格式串后面加空格。上面推荐的%*d里已经含了格子宽度,足够让数字分开,不需要再额外补空格。
第三个坑:终端必须用等宽字体。空格、数字、西文字符如果不是等宽的,任何计算都是白费。这一点听起来像废话,但我真见过有人在一个非等宽字体的终端里对着对齐效果调试了很久。
第四个坑:不要把宽度算成固定值。有人喜欢直接把width设成 6,因为“反正最多输出十几行”。但一旦输入 n=60,最后一行数字能上千万,6 位不够用,整个三角形立刻歪掉。所以动态计算宽度不是可选项,是必须项。
5. 输入边界与溢出:这些细节决定你的代码稳不稳
5.1 n=0、n=1、n=2 这样的小边界,最容易被忽略
很多人写完代码就从 n=5 开始测,看着效果不错就交差了。但真正严谨的程序员会从最极端的情况测起。
- n=0 时,杨辉三角应该“什么都没有”。有的实现会打印一个空行,有的会什么都不打印,到底哪种对取决于需求。我的习惯是不打印任何内容,直接返回 0。
- n=1 时,只有一行,内容就是一个
1。这个场景非常小,却能暴露行首缩进是不是正确的,因为这时前面要有width * 0 = 0个空格,如果你不管三七二十一在循环开头统一缩进一个固定值,n=1 就会多出空格。 - n=2 时,输出两行:
1和1 1。此时第二行是否对齐,能立刻看出宽度计算有没有问题。
我建议拿到这种题目,先列 4 个测试用例:0、1、2、一个中等数值比如 10。跑完这 4 个再敲下一步。
5.2 int 溢出的临界值,你得心里有数
还是那句老话,别用 int 存杨辉三角的值。我算过几个临界值,大家可以直接记结论:
- 如果用 int,最多安全输出到第 34 行左右(第 34 行最大的组合数约 2.03 亿,勉强在 int 范围内,再往上就爆了)。
- 如果用 long long,最多安全输出到第 67 行附近。第 67 行最大的 C(66,33) 约 7.2e18,离 long long 上限 9.22e18 已经非常近,再往后的行会溢出。
所以我在代码示例里统一用long long,并且加一个行数上限检查。当用户输入的 n 超过上限时,直接给提示并退出,而不是等程序输出一堆负数再让人摸不着头脑。
检查代码也不复杂:
if (n <= 0) { printf("行数必须大于 0。\n"); return 1; } if (n > 67) { printf("行数过大,long long 也会溢出。\n"); return 1; }5.3 scanf 的返回值,很多人从来没检查过
scanf("%d", &n)如果用户输入的不是数字,比如输入了abc,那n根本不会被赋值,程序可能用一个随机初值继续跑下去。更可怕的是,如果输入里混入了负数,比如-5,你的循环条件i <= n可能一次都不执行,也可能因为i < n和数组越界打出满天乱码。
所以健壮一点的读入应该这样写:
printf("请输入行数 n: "); if (scanf("%d", &n) != 1) { printf("输入无效,请重新运行程序。\n"); return 1; }如果你想让程序更耐折腾,可以用循环反复读,直到拿到合法值,但作为一道练习题,检查一次也就够了。永远不要假设用户会规规矩矩输入一个正常正整数——这是我在真实开发里被教育出来的习惯。
5.4 除了用户输入,还有一层“逻辑边界”
数组下标越界也是这个题的高频 bug。比如二维数组写法里,内层循环j <= i,很多人手抖写成j < i,结果每行少打印一个数;或者循环i从 1 开始,却忘了给a[i][0]赋值成 1,导致第一列除了第一行以外全是 0。
我调试这种问题的方法很简单:先在草稿纸上把 n=4 的三角形画出来,标出每个格子的下标,然后拿代码逐行对照。这道题不需要高深的调试工具,一张纸一支笔就能解决 80% 的下标问题。
6. 进阶优化:从“能跑”到“耐看”再到“可玩”
6.1 行内递推的正确姿势:先约分再乘除
前面说过,直接用val = val * (n - k) / (k + 1)可能溢出。正确做法是先约分再乘除。完整代码如下:
static long long gcd(long long a, long long b) { while (b) { long long t = a % b; a = b; b = t; } return a; } // 打印第 row 行,max_val 为这行最大值,用于计算宽度 void print_combination_row(int row, int width) { long long val = 1; for (int k = 0; k <= row; ++k) { printf("%*d", width, val); long long num = row - k; long long den = k + 1; long long g = gcd(num, den); num /= g; den /= g; val = val / den * num; } putchar('\n'); }这个函数不依赖上一行,所以不仅内存 O(1),还能方便地打印“任意一行”。如果你想单独打印第 50 行,这个函数就是最好的选择。
6.2 输出性能优化:别让 printf 成为拖后腿的家伙
当 n 很大时,每行每个数都调用一次printf,会产生非常多的格式化开销。一个实用的优化是:先把一行的所有内容格式化到一个字符数组里,然后一次性输出。
char row_buf[4096]; char *p = row_buf; for (int k = 0; k <= i; ++k) { p += sprintf(p, "%*d", width, val); } puts(row_buf);这里的sprintf也会格式化,但至少把多次终端 I/O 调用变成了一次。如果你连puts都嫌慢,可以用fwrite(row_buf, 1, (size_t)(p - row_buf), stdout),再手动写一个换行符。不过对于 67 行以内的场景,puts足够了。
我实测过:直接每数一个 printf 和缓冲输出,规模在 1000 行左右时差距能拉大到 5 倍以上。原因很简单,终端 I/O 是系统调用,每调用一次都有固定开销,攒成一批自然快得多。
6.3 有意思的小彩蛋:只打印奇偶,出现分形
杨辉三角藏在数学里的彩蛋非常多。其中一个特别适合练习的,是“只输出奇数和偶数”。把每个数换成*(奇数)和空格(偶数),你猜怎么着?你会得到一个类似谢尔宾斯基三角形的分形图案。
实现方式很简单:在你打印每个数字前判断val % 2,然后决定打*还是打空格。如果配合组合数公式,代码会特别简洁。这个玩法能让“输出杨辉三角”从一道枯燥练习题变成可视化数学实验,我给新人布置作业时经常加这一问。
这个彩蛋还引出一个更深的问题:为什么整数行 C(n,k) 的奇偶性恰好构成分形?这背后的逻辑涉及二进制的进位,用大白话讲就是“奇数行、偶数行的交替跟二进制加减法有关”。有兴趣的人可以顺着这个线索去研究,那又是另一个有意思的专题了。
6.4 并发和向量化,我的态度是“先别碰”
有人可能会问:输出 67 行以内有必要上多线程吗?性能瓶颈到底在哪?
我的回答是:如果 n 在 67 以内,程序瓶颈几乎全在终端 I/O 上,CPU 计算完全可以忽略。就算上多线程,线程同步、内存竞争带来的问题往往比收益更明显。每一行的递推天然存在依赖关系,第 i 行必须等第 i-1 行算完才能动;而组合公式法虽然各行独立,但终端输出的顺序又不能乱。这两条都决定了并行化在“输出完整三角形”这个场景里收益不大。
所以我把并发放在“可玩”而不是“必做”里。它更适合作为讨论话题,而不是练习题的标配。不过你要是硬想练并行,建议从“只计算某一行的最大值”这种无依赖任务开始,而不是直接并行打印整个三角形。
7. 完整代码:教学版与工程版,任选一套直接抄
7.1 教学版:二维数组 + 动态宽度,适合交作业或理解逻辑
#include <stdio.h> int digit_count(long long x) { int count = 1; while (x >= 10) { x /= 10; ++count; } return count; } int main(void) { int n; printf("请输入杨辉三角的行数: "); if (scanf("%d", &n) != 1 || n <= 0) { printf("输入无效。\n"); return 1; } if (n > 67) { printf("行数过大,建议使用大数库,当前版本最多支持 67 行。\n"); return 1; } long long a[68][68] = {0}; a[0][0] = 1; for (int i = 1; i < n; ++i) { a[i][0] = 1; for (int j = 1; j <= i; ++j) { a[i][j] = a[i-1][j-1] + a[i-1][j]; } } long long max_val = 0; for (int j = 0; j < n; ++j) { if (a[n-1][j] > max_val) { max_val = a[n-1][j]; } } int width = digit_count(max_val) + 1; for (int i = 0; i < n; ++i) { printf("%*s", width * (n - i - 1), ""); for (int j = 0; j <= i; ++j) { printf("%*d", width, a[i][j]); } putchar('\n'); } return 0; }这段代码结构清晰,唯一的“奢侈”是二维数组。但它对理解递推过程确实最友好。你可以在打印之前先算好所有数,再去考虑对齐,逻辑上不容易搞混。如果老师要求“不允许用数组”,那才需要换成下面的工程版。
7.2 工程版:滚动数组 + 组合公式 + 缓冲输出
#include <stdio.h> static long long gcd(long long a, long long b) { while (b) { long long t = a % b; a = b; b = t; } return a; } int digit_count(long long x) { int count = 1; while (x >= 10) { x /= 10; ++count; } return count; } int main(void) { int n; printf("请输入杨辉三角的行数: "); if (scanf("%d", &n) != 1 || n <= 0) { printf("输入无效。\n"); return 1; } if (n > 67) { printf("行数过大,当前版本最多支持 67 行。\n"); return 1; } // 先算出最后一行中间元素作为最大宽度基准 long long max_val = 1; for (int k = 0; k <= n - 1; ++k) { long long num = n - 1 - k; long long den = k + 1; long long g = gcd(num, den); num /= g; den /= g; long long next = max_val / den * num; if (next > max_val) { max_val = next; } } int width = digit_count(max_val) + 1; // 滚动数组打印 long long cur[68] = {0}; cur[0] = 1; for (int i = 1; i <= n; ++i) { for (int j = i - 1; j >= 1; --j) { cur[j] = cur[j-1] + cur[j]; } char row_buf[4096]; char *p = row_buf; printf("%*s", width * (n - i), ""); p += sprintf(p, "%*s", 0, ""); // 占位,便于演示缓冲追加 p = row_buf; // 上面一行占位只是为了展示思路,实际清空 for (int j = 0; j < i; ++j) { p += sprintf(p, "%*d", width, cur[j]); } puts(row_buf); } return 0; }不过上面这个版本里我留了两行比较冗余的代码,实际用的话把开头缩进和缓冲合并即可,下面这个写法更干净:
for (int i = 1; i <= n; ++i) { for (int j = i - 1; j >= 1; --j) { cur[j] = cur[j-1] + cur[j]; } char row_buf[4096]; char *p = row_buf; int indent = width * (n - i); while (indent-- > 0) { *p++ = ' '; } for (int j = 0; j < i; ++j) { p += sprintf(p, "%*d", width, cur[j]); } *p++ = '\n'; *p = '\0'; fwrite(row_buf, 1, (size_t)(p - row_buf), stdout); }这样行尾没有多余空格,行首缩进是精确的空格,整行输出一次完成。相比教学版,这个版本内存占用小、输出性能高、还顺带展示了行尾处理。
7.3 跑一个 n=10 的实际效果
下面是我终端里跑出来的效果,方便大家直观判断自己的对齐是否正确:
1 1 1 1 2 1 1 3 3 1 1 4 6 4 1 1 5 10 10 5 1 1 6 15 20 15 6 1 1 7 21 35 35 21 7 1 1 8 28 56 70 56 28 8 1 1 9 36 84 126 126 84 36 9 1仔细看第五行“1 5 10 10 5 1”,第 3 个数字从 5 跳成 10 时,整体宽度没有歪,因为每个数字占位是固定 4 列。这就是%*d动态宽度带来的效果。如果你看到的是锯齿状,大概率是宽度参数没生效,或者你用了\t。
8. 一些想留给后来者的实操体会
写到这里,这个题目在我心里的“知识点清单”已经全部铺开了。最后再说点掏心窝的话。
我最想强调的并不是“杨辉三角怎么输出”,而是这道题背后反映的工程习惯。数组开多大、循环边界差一、输入不合法怎么办、输出对齐怎么处理,这些问题比“会背一个递推公式”重要得多。你可以把杨辉三角换成任何一个小项目,换汤不换药。
如果你正在学习 C 语言,我建议你把这篇文章里的示例代码动手敲一遍,敲完再自己把教学版改成滚动数组版本。改的过程中,你会真正理解“滚动数组为什么从右往左更新”,而不是只在脑子里记住结论。接着再试一次不打印偶数,看看分形图案,这道题对你来说就变成了一道微型数学实验。
我个人的一个小技巧是:每次拿到这种经典练习题,先不要急着写代码,先拿草稿纸画出 n=4 或 n=5 的完整三角形,在上面标出每个格子对应的下标和递推来源。画完这张图,循环边界基本不会写错,宽度计算也能一眼推出来。这个方法我用了很多年,对任何涉及“行列结构”的编程题都管用。
最后再说一句:如果哪天你在终端里打印杨辉三角怎么调都是歪的,先别怀疑算法,把每个数字的占位宽度打印出来看看,八成是宽度没算对。这个坑我替大家踩过了,希望你看完这篇就能绕开。