
如果你刷过 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((N1)/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 修正就会在 N17、N19 这种边界上莫名挂掉。第二种是我个人更推荐的做法从 k1 开始一层一层往上试探直到下一层放不下为止。代码长这样int level 1; while (2 * (level 1) * (level 1) - 1 n) { level; }这个循环的退出条件非常直观当前 level 已经能满足条件那就再往下试探一层如果下一层所需符号数超过了 N就说明当前 level 已经是最大层数了。为什么我推荐第二种因为它全程是整数运算不存在精度问题你也完全不需要记忆“开方后修正”这种补丁式写法。对新手来说这种“逐层试探”的思路和现实里“试着塞箱子塞不下就换小一号”的直觉完全一致不容易错。2.2 边界输入手动验证光说不练假把式我们来手动验几个边界值确保这个公式和循环逻辑在极端情况下也没问题。先看 N1。此时层数 level 初始是 1判断2 * 2 * 2 - 1 7 1不成立所以 level 保持为 1。总符号数等于2 * 1 * 1 - 1 1剩余 0。输出就是一个单层沙漏*然后是 N2。循环判断同样不成立level 还是 1总符号数 1剩余 1。输出还是一个*但最后要额外输出一个数字 1。很多同学在这里会懵明明给了两个符号怎么只能打一个因为沙漏没有“1.5 层”这种说法中间一个符号加上一层对称的 3 个符号最少需要 4 个符号也就是 k2 时总符号数是 7。给定 2 个符号连第二层都铺不满所以只能用 1 层。再看题目给的 N17。验证一下k3 时总符号数2 * 3 * 3 - 1 17刚好够用再往上探k4 时总符号数是 31已经超过 17所以 level 为 3剩余 0。输出就是题目那样的 5-3-1-3-5 结构。最后看一个用不完的例子N19。level 最终也是 3因为 k4 需要 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 开始就会把单个符号那一行重复打印两遍。所以下半层必须跳过 i1。第三个细节是最左侧的空格后面不要追加多余的空格。有些同学觉得居中对齐嘛右边也补几个空格好了于是在符号循环结束之后再写一个空格输出循环。这会导致 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;这在小数据下可能碰巧能过比如 N17 时 n/2 等于 8完全不对。因为 level 和 n 的关系是 2k² - 1 n是一个平方关系不是线性关系。第二类是 while 循环条件写错。常见错误写法是while (2 * level * level - 1 n) { level; }这个写法在 level3、n17 时会继续加一层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)这种绝对值表达式。第三步拆成多个循环段。上半一部分、下半一部分分别处理。对称图形通常可以复用逻辑只是遍历方向不同非对称图形就老老实实把每一段的循环边界写清楚。第四步用边界值验证。程序写完后一定要用最小输入、中等输入、最大输入都试一遍。沙漏题的最小输入是 N1最大输入是 N1000你在本地把这两个极端值都跑通才算比较稳妥。5.2 往工程场景延伸一点可能有人觉得打印沙漏这种题目出了考场就没用了但我不这么看。编程里“先建模、再生成输出”的思路放到工程里到处都是。举个我工作里真实遇到的例子导出打印模板的时候需要在页面上生成一个带边框和标签的清单每行的缩进、字符宽度、填充符都需要按规则动态计算。当时我们内部也有同事直接用“拼字符串”的方式硬写结果换个字体宽度就崩了。后来我给的方案就是类似沙漏题的思路先用公式算出每个区域的行列坐标再逐行生成内容。这个思维方式和沙漏打印是一模一样的。再比如说3D 打印里的切片软件生成填充路径也需要预先计算每一层轮廓的偏移量。虽然它输出的是 G-code 而不是星号字符但核心逻辑同样是“根据层号计算该层的内容参数”。图形类题练的就是这种抽象能力把输出结果拆解成可计算的参数再把参数翻译成循环。5.3 如果想继续练手可以改这几个变体如果你已经把沙漏题刷到一遍过我建议你动手改几个变体验证自己是不是真的理解了。第一个变体打印菱形。输入 N 和符号输出一个实心菱形而不是沙漏。区别在于菱形从第 1 行开始符号数从小到大递增到最大再递减回来。你会发现沙漏和菱形本质上是同一个题只是中间层的位置不同。第二个变体打印数字沙漏。直接把符号换成数字每一行用当前行号填充。比如第 i 行输出 i 个数字 i。这个变体要求你对行号和符号内容建立映射关系。第三个变体把打印部分封装成函数。输入层数 k 和符号函数内部打印沙漏主函数只负责读取输入和调用函数。这个变体练的是函数抽象能力也是工程代码里更常见的组织方式。第四个变体支持任意符号序列。比如给定多个符号让沙漏每一层轮流使用不同符号。这个变体在判题里不常见但很适合自己练习“状态轮转”的思路。我个人在实际操作中的体会是这题最容易被低估的就是那个 while 循环里加不加 1 的问题。刷题平台上有大量提交挂在“层数多算一”这个点上而且本地测试数据一多反而因为碰巧用完了符号而看不出来。所以我现在看别人代码第一眼就会去看他的层数计算循环这个位置能直接反映出他是不是真的理解了公式还是套模板套出来的。最后再分享一个小技巧调试这种图形题别急着用判题系统先把 N 设置成 1、7、17、19 这几个典型值在本地把输出重定向到文件里肉眼对比一下每一行的空格数问题基本十分钟之内就能定位。