如果你刷过 PTA 或者大学里那些经典的 C 语言机试题,大概率见过这道“打印沙漏”。我印象特别深,因为这题是我入行后帮一个学弟看作业时第一次认真研究了一道“水题”——当时邮箱里躺着四五版代码,几乎没有一版是一次跑对的。有人把符号写死成星号,有人空格多打了一个,还有人在剩余数是 0 的时候直接不输出。
很多人觉得这题不就是个双重循环么,但真正上手会发现,它考的根本不是你能不能写 for,而是你能不能把一个看似“形状”的问题先变成“数学”的问题,再变成“循环边界”的问题。所以我把这道题从题目分析、数学推导、代码实现到常见坑位完整过一遍。刚学编程的读者可以把它当成一道循环入门题来啃;已经工作的读者也能回头看看这道题里藏着的建模思路和边界思维。
1. 拆开题目:沙漏到底长什么样
1.1 从示例输出反推结构规则
题目给了一个很明确的例子:给定 17 个*,要求打印出这样的沙漏:
***** *** * *** *****先别急着写代码,盯着这个输出看十秒钟,你会发现几个规律:
- 第一行和最后一行完全一样,符号数最多。
- 中间一行只有一个符号,是全沙漏的中轴线。
- 从上往下看,符号数先递减,每次减 2;从中间往两边看,符号数再递增,每次加 2。
- 每一行的符号都是居中对齐的,左侧用空格填充,右侧什么都没补。
也就是说,沙漏本质上可以看作两个等腰三角形:上面一个尖朝下的倒三角,下面一个尖朝上的正三角。中间用单个符号作为连接点,两边关于中间那一行完全对称。
如果把这个规律再抽象一层,就是:每一行符号的数量一定是奇数。
为什么?因为题目要求相邻两行相差 2 个符号,而且从大到小递减到 1 之后,还要从小到大递增回来。1、3、5、7 都是奇数,差 2 的等差数列永远都是同奇偶的,所以从 1 开始往外扩,每一层必然是奇数个符号。这是很多初学者容易忽略的一个隐含条件——它不是题目直接告诉你的,而是从“对称 + 每次差 2 + 中间是 1”这三个条件里自己推出来的。
1.2 抽象成数学问题:层数、总数与剩余数
理解了形状之后,就要回答整个题目最核心的问题:给定 N 个符号,到底能打出多大的沙漏?
这里有一个非常容易踩进去的思维误区:你以为题目是“给我 17 个星号,我打印一个刚好用完的沙漏”,但实际题目说的是:给定任意 N,不一定能正好组成一个沙漏,要求打印出的沙漏用掉尽可能多的符号,然后把剩下的符号数输出。
换句话说,这变成了一个“求最大可行规模”的问题。你需要找到一个最大的层数 k,使得构造一个 k 层沙漏所需的符号总数不超过 N。
那 k 层沙漏到底需要多少个符号?
我习惯先看沙漏的一半。从中间往上看,中间是 1 个符号,往上一层是 3 个,再往上一层是 5 个……如果把沙漏的最大行看作第 k 行,那么这一半从上到下分别是 1、3、5、……、2k-1。这是一个首项为 1、公差为 2 的等差数列,前 k 项的和是:
1 + 3 + 5 + ... + (2k-1) = k²沙漏是上下对称的,所以两半合起来是 2k²,但中间那一行被数了两次,需要减掉一次。于是整个沙漏的符号总数就是:
2k² - 1这个公式是整个题目的灵魂。你只要把它记住了,后面所有代码都是围绕它展开的。
2. 关键推导:层数 k 到底该怎么算
2.1 两种计算层数的思路
拿到了总符号数公式 2k² - 1 之后,接下来的目标就非常明确了:找到一个最大的 k,满足:
2k² - 1 <= N一旦 k 定下来,沙漏的规模就定了:最大行宽度是 2k-1,总符号数是 2k² - 1,剩余符号数是 N - (2k² - 1)。
那么怎么求这个 k?常见的做法有两种。
第一种是直接开方。因为 2k² - 1 <= N,可以推出 k <= sqrt((N+1)/2),所以理论上你可以用 sqrt 函数算一个初始值,再修正。很多教材里会这么写:
int k = sqrt((N + 1) / 2.0); while (2 * (k + 1) * (k + 1) - 1 <= N) k++; while (2 * k * k - 1 > N) k--;这个思路本身没错,但有两个麻烦:一是如果你把(N + 1) / 2写成了整数除法,在 N 为偶数时结果会差 1;二是浮点数开方在边界值上存在精度隐患。比如某些环境下sqrt(9.0)返回的可能是2.999999999,转成 int 后直接变成 2,差了一个数量级。你要是没做后面那两行 while 修正,就会在 N=17、N=19 这种边界上莫名挂掉。
第二种是我个人更推荐的做法:从 k=1 开始,一层一层往上试探,直到下一层放不下为止。代码长这样:
int level = 1; while (2 * (level + 1) * (level + 1) - 1 <= n) { level++; }这个循环的退出条件非常直观:当前 level 已经能满足条件,那就再往下试探一层;如果下一层所需符号数超过了 N,就说明当前 level 已经是最大层数了。
为什么我推荐第二种?因为它全程是整数运算,不存在精度问题,你也完全不需要记忆“开方后修正”这种补丁式写法。对新手来说,这种“逐层试探”的思路和现实里“试着塞箱子,塞不下就换小一号”的直觉完全一致,不容易错。
2.2 边界输入手动验证
光说不练假把式,我们来手动验几个边界值,确保这个公式和循环逻辑在极端情况下也没问题。
先看 N=1。此时层数 level 初始是 1,判断2 * 2 * 2 - 1 = 7 <= 1不成立,所以 level 保持为 1。总符号数等于2 * 1 * 1 - 1 = 1,剩余 0。输出就是一个单层沙漏:
*然后是 N=2。循环判断同样不成立,level 还是 1,总符号数 1,剩余 1。输出还是一个*,但最后要额外输出一个数字 1。很多同学在这里会懵:明明给了两个符号,怎么只能打一个?因为沙漏没有“1.5 层”这种说法,中间一个符号加上一层对称的 3 个符号,最少需要 4 个符号(也就是 k=2 时,总符号数是 7)。给定 2 个符号,连第二层都铺不满,所以只能用 1 层。
再看题目给的 N=17。验证一下:k=3 时,总符号数2 * 3 * 3 - 1 = 17,刚好够用;再往上探,k=4 时总符号数是 31,已经超过 17,所以 level 为 3,剩余 0。输出就是题目那样的 5-3-1-3-5 结构。
最后看一个用不完的例子:N=19。level 最终也是 3,因为 k=4 需要 31 个,不够;总符号数仍是 17,剩余 2。这就是题目里那组示例输入要考察的东西:不是所有输入都能正好用完。
这几个边界验证完,基本上可以确定:只要 level 算对了,后面打印部分就是把数学规律翻译成循环。
3. 打印核心逻辑与完整代码
3.1 上半层与下半层的循环设计
层数 level 确定之后,打印就变成了一个纯粹的循环嵌套问题。这里我习惯把沙漏拆成两半来处理:上半层包含从最大行到中间单个符号,下半层包含从中间下一行到最大行。
上半层的行号 i 从 level 开始,一路递减到 1。对于第 i 行:
- 左侧空格数是
level - i。因为第 level 行是最顶行,不需要缩进,而第 i 行距离顶行越远,缩进越多。 - 符号数是
2 * i - 1。这就是等差数列的通项公式。
下半层的行号 i 从 2 开始,递增到 level。由于沙漏的对称性,第 i 行的空格数依然是level - i,符号数依然是2 * i - 1。你可以理解为:打印下半层只是把上半层的行遍历方向反过来,并且跳过中间那一行不重复打印。
为什么空格数不写成别的形式?这是很多初学者最容易怀疑的地方。你只要记住一个原则:空格数永远等于“最大行号减去当前行号”。这个公式对上下两层都成立,因为它描述的是当前行距离最宽那一行的“偏移量”。有了这个偏移量,所有符号就能保证居中对齐。
3.2 C 语言参考实现
下面是一份完整的 C 语言参考代码,我用注释标出了每个关键步骤:
#include <stdio.h> int main() { int n; char ch; scanf("%d %c", &n, &ch); // 1. 计算最大层数 level int level = 1; while (2 * (level + 1) * (level + 1) - 1 <= n) { level++; } int total = 2 * level * level - 1; // 2. 打印上半层:level 行到第 1 行 for (int i = level; i >= 1; i--) { for (int j = 0; j < level - i; j++) { printf(" "); } for (int j = 0; j < 2 * i - 1; j++) { printf("%c", ch); } printf("\n"); } // 3. 打印下半层:第 2 行到第 level 行 for (int i = 2; i <= level; i++) { for (int j = 0; j < level - i; j++) { printf(" "); } for (int j = 0; j < 2 * i - 1; j++) { printf("%c", ch); } printf("\n"); } // 4. 输出剩余符号数 printf("%d\n", n - total); return 0; }这份代码提交到 OJ 是可以直接过的。我特意把printf("%c", ch)写成输出变量而不是写死*,是为了应对题目里可能给#、&这种符号的情况。你把输入从“17 *”换成“17 #”,代码一行都不用改。
3.3 Python 参考实现
如果你学的不是 C 语言,或者想用 Python 快速验证思路,下面这份代码的逻辑完全一致,只是写法更简洁:
n, ch = input().split() n = int(n) level = 1 while 2 * (level + 1) * (level + 1) - 1 <= n: level += 1 total = 2 * level * level - 1 for i in range(level, 0, -1): print(' ' * (level - i) + ch * (2 * i - 1)) for i in range(2, level + 1): print(' ' * (level - i) + ch * (2 * i - 1)) print(n - total)Python 版本里字符串乘法的优势体现得很明显:' ' * (level - i)直接生成空格串,ch * (2 * i - 1)直接生成符号串,省去了内层两个 for 循环。但对初学者来说,我反而建议先用 C 语言的写法把双重循环跑通,因为你最终要学会的是“控制每一行输出什么内容”的思路,而不是依赖 Python 的语法糖。
3.4 代码里的几个关键点
这份代码看着简单,但有几个细节值得单独拿出来说。
第一个细节是scanf("%d %c", &n, &ch);中的空格。如果写成scanf("%d%c", &n, &ch);,%c会直接读取输入中数字后面的那个空格字符,导致 ch 的值变成空格而不是*。很多同学本地测试时输入的是“17 *”,但输出全是空格,就是因为这个原因。%d后面加一个空格,作用是让 scanf 跳过数字后面所有的空白字符,再去读取真正的符号。
第二个细节是打印后半段时,循环为什么从 2 开始而不从 1 开始。中间那一行在打印上半层时已经输出了,如果下半层再从 1 开始,就会把单个符号那一行重复打印两遍。所以下半层必须跳过 i=1。
第三个细节是最左侧的空格后面不要追加多余的空格。有些同学觉得居中对齐嘛,右边也补几个空格好了,于是在符号循环结束之后再写一个空格输出循环。这会导致 OJ 报“格式错误”,因为题目要求的输出每一行结尾就是符号,没有任何多余字符。
第四个细节是最后剩余符号数那行,即使剩余是 0 也要输出0。这不是可选项,是硬性要求。我帮人改代码时真实遇到过有人写:
if (n - total > 0) printf("%d\n", n - total);结果剩余刚好为 0 时,最后一行直接消失了,整个输出少了一行,判题直接 WA。
4. 高频错误自查手册
这题在 OJ 上的提交记录里,错误类型分布其实很有规律。我根据帮人调代码的经验,把最常见的几类问题整理成了一份“自查手册”,如果你提交后没过,按这个顺序排查基本能找到问题。
4.1 格式类错误
格式类错误在 OJ 上通常表现为 Presentation Error,也就是输出内容正确但格式不匹配。最典型的就是行尾多空格。
我之前见过一份代码,打印符号的循环完了之后又加了一个printf(" ");,理由是“觉得右边空着不好看”。但题目要求的是每行只有左侧空格和符号,符号后面什么都不需要加。判题系统比对输出时是逐字符严格匹配的,多一个空格就是错。
另一个格式问题是多余的前导提示语。有人习惯在 scanf 之前输出一句“请输入:”,这在本地控制台没问题,但 OJ 比对的是标准输出,一旦出现额外字符,就变成了 Wrong Answer。记住:提交到判题系统里的代码,输出必须严格遵循题目格式,一个字都不能多。
4.2 计算类错误
计算类错误最集中的爆发点就是层数 level 算错。
第一类是把 level 简单理解成“符号数量最大的那一行的符号数”,然后直接用 N 除以 2 之类的估算。比如有人写level = n / 2;,这在小数据下可能碰巧能过,比如 N=17 时 n/2 等于 8,完全不对。因为 level 和 n 的关系是 2k² - 1 <= n,是一个平方关系,不是线性关系。
第二类是 while 循环条件写错。常见错误写法是:
while (2 * level * level - 1 <= n) { level++; }这个写法在 level=3、n=17 时会继续加一层,level 变成 4,然后循环条件变为2 * 4 * 4 - 1 = 31 <= 17不成立,循环退出,最终 level 是 4,明显多算了一层。原因在于循环体已经先执行了level++,所以 while 条件里必须用level + 1去试探下一层,而不是用当前的 level。
4.3 输入处理类错误
输入处理类错误里最隐蔽的是符号读取问题。题目输入的格式是N 符号,中间有空格,比如17 *。如果 scanf 的格式串写错,符号变量拿到的可能是空格字符。
我见过一个同学为了省事,用getchar()去读符号,结果第一次调用时吃掉了数字后的空格,第二次才吃到*,但他只调用了一次 getchar,最终输出全成了空格。排查半天才发现是输入缓冲区的问题。
正确的做法很统一:scanf("%d %c", &n, &ch);。%d后的空格会自动跳过空白,%c读取下一个非空白字符。
还有一个输入问题:符号不一定是你熟悉的*。如果是&、#、@这种字符,代码里如果写死成printf("*")就完全错了。所以从一开始就坚持用变量输出符号。
4.4 典型错误速查表
把常见错误整理成表格,方便你提交后对照自查:
| 错误现象 | 可能原因 | 修复方法 |
|---|---|---|
| 输出全是空格 | scanf 格式串忘记在 %d 后加空格,symbol 读到了空格 | 改成scanf("%d %c") |
| 第 1 行前多了空格 | 空格循环从 1 开始而不是从 0 开始 | 空格循环j = 0; j < level - i; j++ |
| 中间行重复打印 | 下半层循环从 1 开始 | 下半层循环从 2 开始 |
| 最后剩余数为 0 时没有输出 | 用 if 包裹了 printf | 直接无条件输出n - total |
| 层数多算一 | while 条件里没有用 level + 1 | 先判断下一层是否可行再自增 |
| 符号写死成 *,输入 # 就输出错 | 代码里写死了字符常量 | 用变量 ch 输出 |
| 行尾多空格 | 符号循环后追加了空格输出 | 删掉行尾空格 |
5. 这类题怎么做才不容易翻车
5.1 图形题的通用套路
沙漏打印是一大类“图形输出题”的代表,同类型的还有打印直角三角形、打印菱形、打印数字金字塔、打印字母表三角形,本质上都遵循同一套解题流程。
第一步,画图观察规律。把 N 取几个特殊值,比如 1、3、5、7,把输出在纸上画出来,标出每一行的空格数和符号数。这个动作看起来笨,但能帮你快速写出通项公式。
第二步,归纳数学表达式。找出“空格数与行号”的关系、“符号数与行号”的关系。沙漏题里是level - i和2 * i - 1,换成菱形题可能就是abs(center - i)这种绝对值表达式。
第三步,拆成多个循环段。上半一部分、下半一部分,分别处理。对称图形通常可以复用逻辑,只是遍历方向不同;非对称图形就老老实实把每一段的循环边界写清楚。
第四步,用边界值验证。程序写完后,一定要用最小输入、中等输入、最大输入都试一遍。沙漏题的最小输入是 N=1,最大输入是 N=1000,你在本地把这两个极端值都跑通,才算比较稳妥。
5.2 往工程场景延伸一点
可能有人觉得,打印沙漏这种题目出了考场就没用了,但我不这么看。编程里“先建模、再生成输出”的思路,放到工程里到处都是。
举个我工作里真实遇到的例子:导出打印模板的时候,需要在页面上生成一个带边框和标签的清单,每行的缩进、字符宽度、填充符都需要按规则动态计算。当时我们内部也有同事直接用“拼字符串”的方式硬写,结果换个字体宽度就崩了。后来我给的方案就是类似沙漏题的思路:先用公式算出每个区域的行列坐标,再逐行生成内容。这个思维方式和沙漏打印是一模一样的。
再比如说,3D 打印里的切片软件生成填充路径,也需要预先计算每一层轮廓的偏移量。虽然它输出的是 G-code 而不是星号字符,但核心逻辑同样是“根据层号计算该层的内容参数”。图形类题练的就是这种抽象能力:把输出结果拆解成可计算的参数,再把参数翻译成循环。
5.3 如果想继续练手,可以改这几个变体
如果你已经把沙漏题刷到一遍过,我建议你动手改几个变体,验证自己是不是真的理解了。
第一个变体:打印菱形。输入 N 和符号,输出一个实心菱形而不是沙漏。区别在于菱形从第 1 行开始,符号数从小到大递增到最大,再递减回来。你会发现沙漏和菱形本质上是同一个题,只是中间层的位置不同。
第二个变体:打印数字沙漏。直接把符号换成数字,每一行用当前行号填充。比如第 i 行输出 i 个数字 i。这个变体要求你对行号和符号内容建立映射关系。
第三个变体:把打印部分封装成函数。输入层数 k 和符号,函数内部打印沙漏,主函数只负责读取输入和调用函数。这个变体练的是函数抽象能力,也是工程代码里更常见的组织方式。
第四个变体:支持任意符号序列。比如给定多个符号,让沙漏每一层轮流使用不同符号。这个变体在判题里不常见,但很适合自己练习“状态轮转”的思路。
我个人在实际操作中的体会是,这题最容易被低估的就是那个 while 循环里加不加 1 的问题。刷题平台上有大量提交挂在“层数多算一”这个点上,而且本地测试数据一多,反而因为碰巧用完了符号而看不出来。所以我现在看别人代码,第一眼就会去看他的层数计算循环,这个位置能直接反映出他是不是真的理解了公式,还是套模板套出来的。最后再分享一个小技巧:调试这种图形题,别急着用判题系统,先把 N 设置成 1、7、17、19 这几个典型值,在本地把输出重定向到文件里,肉眼对比一下每一行的空格数,问题基本十分钟之内就能定位。