
1. 一道看似最简单的图案题其实考的是坐标思维1.1 平行四边形图案到底在考什么C 语言练手题里打印平行四边形几乎是和九九乘法表、水仙花数一起出现的老面孔。很多人第一眼看过去觉得没什么难度不就是打几行星星吗真正动手写的时候才发现卡住自己的从来不是for循环的语法而是第 i 行到底该缩进几个空格这个看似简单的换算。这个换算背后其实是一套离散坐标系到字符网格的映射。终端屏幕是一个字符格子铺成的大棋盘每个格子只能放一个字符横向固定列数纵向固定行数。你想在屏幕上画出一个平行四边形本质上就是给每一行算出两个数字这一行前面要空出多少个格子以及这一行连续填充多少个格子。这两个数字定下来剩下的就是把它们翻译成两个内层循环。所以这道题真正的训练目标是把一个二维图形的几何约束拆解成可以用行号表达的函数关系。这个能力一旦建立起来菱形、沙漏、空心三角、锯齿图案全都能用同一套思路快速推导出来反之如果只是抄一段代码凑出形状换个参数马上就傻眼。我见过不少同学能背出平四的代码但让他改成一个上宽下窄、向右倾斜的梯形立刻写不出来——因为背的是字符不是关系。1.2 真正容易翻车的三个环节从实际教学和大量练习反馈来看这道题卡人的地方集中在三处而且这三处都不是算法问题。第一处是边界计数。行数从 0 开始还是从 1 开始缩进量是n-1-i还是n-i差一个数图形就歪一格或者少一行。这类错误特别隐蔽因为结果看起来差不多肉眼扫一下觉得对实际对不齐。第二处是符号输入。题目说输入平行四边形的符号很多人顺手写scanf(%c, ch)然后发现程序不等人输入就跳过去了或者输入一个中文全角符号屏幕上直接乱码、错位、只剩半个字。这一块涉及输入缓冲区和字符编码是典型的课本没讲但一写就撞墙的区域。第三处是对齐与显示宽度。半角星号占一格全角符号占两格中文字符占两格一旦混用格子上算得好好的空格数在屏幕上就是歪的——代码逻辑完全正确肉眼看到的却是斜边不齐。这个问题跟编译器无关、跟算法无关纯粹是字符显示宽度 ≠ 字符字节数造成的。下面这几节我按先建立坐标关系、再解决输入、最后处理显示的顺序拆开讲每一段都会给出可以照着敲的代码和我自己踩过的坑。2. 从纸面格子到 for 循环偏移量的推导过程2.1 在纸上画方格比在脑子里想快十倍我习惯的做法是先在纸上画一个 4 行 5 列的方格把这四个字填进去然后数空格。***** 第 0 行3 个空格 5 个星 ***** 第 1 行2 个空格 5 个星 ***** 第 2 行1 个空格 5 个星 ***** 第 3 行0 个空格 5 个星数完立刻能看出两个规律星号数量恒定都是 5空格数量随行号线性递减从 3 递减到 0。把行号记作i从 0 开始总行数记作rows 4那么缩进量就是rows - 1 - i。这个-1不是凭空来的它等价于最后一行缩进为 0这个边界条件。你可以这样验证代入i 3得到4 - 1 - 3 0对上了代入i 0得到4 - 1 - 0 3也对上了。只要两端的边界值都对中间必然是线性的因为这里的位置关系本身就是等差的。如果题目要求往另一边倾斜也就是右斜变左斜规律就变成缩进量等于行号i第一行不缩进最后一行缩进最多。这两种形式在代码上只差一个表达式但视觉上完全相反写之前一定要先确认题目要的方向别写完才发现斜反了。2.2 通用公式把行数、列数、方向拆成三个独立参数真实的练习场景往往不会只让你打印一个固定形状。更实用的写法是把三个量拆开rows行数、cols每行星号数、dir倾斜方向。这样改一处参数就能得到不同形状不用重写循环。参数含义常用取值影响rows图形高度即总行数1 ~ 20决定纵向高度与最大缩进量cols每行连续填充的字符个数1 ~ 40决定横向宽度与rows无关dir倾斜方向0 左斜 / 1 右斜决定缩进量随行号递增还是递减sym填充用的符号任意单字节或双字节符号见第 3 节注意rows和cols是完全解耦的。平行四边形的高度和宽度没有几何上的绑定关系你把rows改成 10、cols保持 5得到的就是一个又高又窄的斜条。这一点和菱形不同——菱形的高宽必须匹配才能对称所以菱形的两个循环是耦合的平四是自由的。搞清楚这个区别后面做菱形的时候才不会把公式混着用。缩进量的通用表达式可以写成int indent (dir 0) ? (rows - 1 - i) : i;一行三目运算搞定方向。别小看这种写法它比写两个if分支各带一套循环要干净得多而且调试的时候只需要盯一个变量。2.3 第一版可运行代码与逐行验证#include stdio.h int main(void) { int rows 4; int cols 5; int dir 0; /* 0: 左斜(向右下方倾斜) 1: 右斜 */ int i, j; for (i 0; i rows; i) { int indent (dir 0) ? (rows - 1 - i) : i; for (j 0; j indent; j) putchar( ); for (j 0; j cols; j) putchar(*); putchar(\n); } return 0; }dir 0的输出***** ***** ***** *****dir 1的输出***** ***** ***** *****写完之后一定要做的一件事是逐行验证在循环体里临时加一句printf(i%d indent%d\n, i, indent);把每一行的缩进量打出来和纸上数的数字逐个对照。这个动作看起来笨但它是排查差一格问题最快的办法。我见过太多人盯着屏幕上的图形反复看看到眼睛花也判断不出到底差在哪一行结果加一句打印三秒钟定位。提示调试图形类代码时不要靠肉眼看图形判断对错要打印出每行的关键参数缩进量、填充长度、行号拿数字和纸面推演对照。图形是结果参数才是原因。另外还有一个更省事的写法用printf的宽度控制直接输出空格printf(%*s, indent, ); /* 输出 indent 个空格 */%*s的意思是宽度由参数给出内容为空串等价于打印indent个空格。这行代码比写一个只循环打空格的for短读起来也更直白。不过初学阶段我建议还是老老实实写循环因为你得亲手感受一次空格是怎么一个个被吐出去的等理解透彻了再用技巧。3. 符号输入环节scanf 到底替你吃掉了什么3.1 为什么scanf(%c)会莫名其妙跳过这是这道题最高频的翻车点。典型现场是这样的代码int n; char ch; scanf(%d, n); scanf(%c, ch); /* 期望用户输入一个符号 */运行起来用户输完数字按回车程序立刻往下跑ch拿到的是一个换行符\n图形里全是空行。用户以为自己没输入其实是输入缓冲区里还留着上一轮的回车。原因很直白%d在读取数字时会跳过前导空白并在遇到第一个非数字字符时停手但它不会把这个非数字字符从缓冲区里拿走。那个回车就安静地躺在缓冲区开头等着下一个%c来收。而%c是唯一一个不跳过空白字符的转换说明符——它就是要原样接收下一个字节包括空格和换行。解决办法是在格式串前面加一个空格scanf( %c, ch); /* 注意 %c 前面那个空格 */这个空格的含义是跳过任意数量的空白字符空格、制表符、换行之后再读取。它和%d、%s、%f的默认行为一致而%c是特例所以必须手动补上。如果你不想在这类细节上反复纠结更稳的做法是统一用fgets读整行然后自己解析。fgets一次性把整行含换行读进缓冲区不会留下残留也不存在上一轮的回车影响下一轮的问题。char line[64]; fgets(line, sizeof(line), stdin); line[strcspn(line, \r\n)] \0; /* 掐掉行尾换行 */strcspn返回第一个出现在\r\n中的字符的下标把它置为\0就等于截断了行尾。这一招我几乎在每一个需要读字符串的小程序里都会用比手工写循环找\n再替换要短且不容易写错。3.2 用char存全角符号为什么一定出问题题目里说的巧妙输入平行四边形的符号很多人会想到用★、■、◆这类更漂亮的符号。这时候如果把符号存进char麻烦就来了。在 UTF-8 编码下一个 ASCII 字符比如*、#、占1 个字节而一个中文全角符号或者常用特殊符号通常占3 个字节。char只有 1 个字节装不下你存进去的只是三个字节里的第一个打印出来必然乱码或者显示成半个方块。符号示例UTF-8 字节数终端显示宽度能否直接用char存*#11 格可以★●◆3通常 1~2 格不可以■□32 格不可以全角32 格不可以正确的做法是把符号当作字符串处理char sym[8]; /* 留足空间 */ /* 假设已经从某处读到了 sym例如 strcpy(sym, *); */ for (j 0; j cols; j) printf(%s, sym); /* 用 %s 反复输出而不是 putchar */这里的关键转变是填充的基本单位从一个字符变成了一个字符串。原来用putchar(ch)打印一次是一个字符现在用printf(%s, sym)打印一次是一个完整的符号可能是 1 字节也可能是 3 字节但对人来说都是一个符号。代码结构不用变只是把输出语句换掉。这一步想通了巧妙输入符号这件事其实就落地了你不需要针对每种符号写不同的分支只要把符号统一当字符串存、当字符串打印就能同时支持*和★。3.3 从输入里安全地取出第一个符号的完整写法用户输入的时候可能会多打空格、回车甚至输入一长串字符。我们的目标是取出他输入内容里的第一个显示符号无论它是单字节还是多字节的。下面这个函数做了这件事#include stdio.h #include string.h /* 从标准输入读取一个符号写入 buf成功返回 0失败返回 -1 */ int read_symbol(char *buf, size_t size) { char line[128]; unsigned char c; size_t len; if (fgets(line, sizeof(line), stdin) NULL) return -1; line[strcspn(line, \r\n)] \0; /* 跳过前导空白找到第一个可见字符 */ char *p line; while (*p || *p \t) p; if (*p \0) return -1; c (unsigned char)p[0]; /* 依据 UTF-8 首字节判断这个字符占几个字节 */ if (c 0x80) len 1; /* ASCII */ else if ((c 5) 0x06) len 2; /* 两字节序列 */ else if ((c 4) 0x0E) len 3; /* 三字节序列中文常见 */ else if ((c 3) 0x1E) len 4; /* 四字节序列 */ else return -1; /* 非法首字节 */ if (len 1 size) return -1; memcpy(buf, p, len); buf[len] \0; return 0; }用法char sym[8]; printf(请输入填充符号); if (read_symbol(sym, sizeof(sym)) ! 0) { strcpy(sym, *); /* 输入异常时给个默认值别让程序崩 */ } printf(将使用符号 [%s] 绘制\n, sym);这段代码的价值在于它同时解决了三件事吃掉残留换行fgets天生不残留、跳过用户误打的前导空格、按 UTF-8 规则截取完整的符号而不是截半个。第三点是重点memcpy的长度由首字节决定不能简单地取p[0]一个字节。提示如果你不确定源文件编码和终端编码是否一致先用*这类 ASCII 符号把整条链路跑通确认图形结构完全正确再换成全角符号测试。把结构错误和编码错误混在一起排查效率会低很多。3.4 显示宽度为什么代码对了图形还是歪的这是最容易被忽略的一层。假设你算出来某一行需要缩进 3 个空格然后连续打印 5 个■。空格占 1 格■占 2 格那么这一行的实际显示宽度是3 5×2 13格而下一行缩进 2 个空格、同样 5 个■显示宽度是2 10 12格。右边界的落点确实在左移斜边看起来是对的但如果你的填充字符宽度不统一比如混用*和■整个图形就会撕裂。更麻烦的是有一类符号的宽度是模糊的不同终端的处理方式不同有的按 1 格显示有的按 2 格显示。这就是为什么同一份代码在你机器上整整齐齐换一台机器打开就歪了。我的处理原则是同一次绘制中绝不允许混用不同宽度的符号。行首缩进统一用半角空格填充统一用同一种符号。如果确实需要处理宽度写一个粗略的宽度判断函数static int sym_width(const char *s) { unsigned char c (unsigned char)s[0]; if (c 0x80) return 1; /* ASCII 一律按 1 格 */ return 2; /* 其余按 2 格估算需按终端实测校正 */ }这个函数只做估算不追求精确。真正在做表格对齐或者复杂排版时需要更细的规则但拿来做一个平行四边形够用了。4. 做成一个真正好用的小工具参数化与输出优化4.1 把散落的变量收进一个结构体代码写到这一步参数已经有四个了行数、列数、方向、符号。再往下加是否空心起始缩进之类的选项函数参数就会膨胀。用结构体把它们打包起来后面扩展会舒服很多。typedef struct { int rows; /* 行数 */ int cols; /* 每行填充个数 */ int dir; /* 0 左斜 / 1 右斜 */ char sym[8]; /* 填充符号支持多字节 */ } ParaCfg;对应的绘制函数void draw_parallelogram(const ParaCfg *cfg) { int i, j; for (i 0; i cfg-rows; i) { int indent; if (cfg-dir 0) indent (cfg-rows - 1 - i); else indent i; for (j 0; j indent; j) putchar( ); for (j 0; j cfg-cols; j) printf(%s, cfg-sym); putchar(\n); } }结构体传指针而不是传值一是避免拷贝二是以后加字段不会影响调用方签名。这类小程序看起来没必要但养成习惯之后你写大一点的程序会少很多改一个参数要动五个地方的痛苦。4.2 参数校验别让用户输 0 和负数rows为 0 的时候外层循环一次都不执行程序静默退出什么也不打印。用户会以为程序坏了。rows为负数同理。cols为 0 会打出一堆只有空格的空行。所以入口处必须校验if (cfg-rows 0 || cfg-rows 40) { printf(行数需在 1~40 之间\n); return; } if (cfg-cols 0 || cfg-cols 80) { printf(每行个数需在 1~80 之间\n); return; } if (cfg-sym[0] \0) { strcpy(cfg-sym, *); }上限不是随便定的。终端一般 80 列宽cols加上最大缩进量超过 80 就会折行图形直接被截断成两截看起来像是程序出错了。设一个上限比让用户自己撞墙要友好。同样的道理输入数字的时候不能直接scanf(%d, n)就完事因为用户可能输入字母。一旦输入的不是数字scanf会失败并把这个非法字符留在缓冲区后面的读取全部错乱。稳妥的读法是int read_int(const char *prompt, int def) { char line[64]; int v; printf(%s直接回车用默认值 %d, prompt, def); if (fgets(line, sizeof(line), stdin) NULL) return def; if (sscanf(line, %d, v) ! 1) return def; return v; }用fgets读整行再用sscanf从中解析。这样无论用户输什么缓冲区都不会被污染解析失败还能退回默认值。这套组合我在几乎所有交互式小工具里都用比直接用scanf省心太多。4.3 空心的平行四边形怎么画画出空心版本是检验你是否真正理解边界条件的好办法。平行四边形的四条边里两条是斜边由每行的缩进决定两条是平边第一行和最后一行。思路是只在边界位置打印符号中间打空格。判断条件这样写如果是第一行i 0或最后一行i rows - 1整行打满符号否则先打缩进空格打一个符号再打cols - 2个空格最后打一个符号。for (i 0; i cfg-rows; i) { int indent (cfg-dir 0) ? (cfg-rows - 1 - i) : i; for (j 0; j indent; j) putchar( ); if (i 0 || i cfg-rows - 1) { for (j 0; j cfg-cols; j) printf(%s, cfg-sym); } else { printf(%s, cfg-sym); for (j 0; j cfg-cols - 2; j) putchar( ); if (cfg-cols 2) printf(%s, cfg-sym); } putchar(\n); }这里有个坑cols等于 1 或者 2 的时候cols - 2是负数或 0内层循环一次都不执行图形会退化成一个奇怪的东西。所以要么在参数校验里强制cols 3要么在中间加判断。我一般直接限定空心模式下cols 3并给出提示而不是让代码去兼容这种没有意义的情况——该拒绝的输入就明确拒绝比默默输出一个畸形图形要好。5. 我踩过的几个坑以及完整的排查链路5.1 少一行、多一行从边界值入手现象屏幕上打出来的图形最后一行的缩进应该是 0结果缩进了 1 格或者本该 4 行只出来了 3 行。排查顺序我是固定的先看循环条件再看表达式最后看边界值。第一步确认for (i 0; i rows; i)的边界。i rows会执行rows次i rows会执行rows 1次。这种错误在写成i rows - 1的时候尤其容易看漏因为它看起来更严谨实际效果一样但更容易让人误解。第二步把indent的表达式在纸面上代两个端点值进去。i 0时应该等于rows - 1i rows - 1时应该等于 0。两个端点错一个说明表达式里的常数项有问题两个端点都对但中间歪说明有别的逻辑介入比如你在循环里又改了一次indent。第三步在循环里加参数打印把每一行的i、indent打出来和纸面推演的表格逐行对照。这个动作我在第 2 节提过这里再说一遍是因为它确实是最有效的。图形类 bug 的排查核心就是把视觉问题转成数字问题一旦转成数字对错就是一眼的事。5.2 输入符号后图形变成一列竖线现象用户输入符号之后本来应该展开成平行四边形的图形变成了一竖列奇怪的字符。原因几乎百分之百是用了char存多字节符号然后用putchar打印。char只截住了 UTF-8 序列的第一个字节putchar把这个字节当独立字符输出终端解析不了要么显示乱码要么被吞掉。因为每一行都只输出这一个坏字节看起来就成了一条竖线。修复路径很清晰把存放符号的变量从char改成char 数组把putchar(ch)改成printf(%s, sym)把输入的读取方式改成第 3.3 节里那种按 UTF-8 首字节截取完整符号的写法。改完之后先拿*测一遍确认图形结构正常再换回全角符号。这个顺序很重要一次只改一个变量否则出问题你分不清是输入逻辑的锅还是文字编码的锅。5.3 输入阶段的死循环还有一种更烦人的情况程序卡在输入提示那里不停地刷屏或者你输入什么都不管用。这通常出在这类代码上while (scanf(%d, n) ! 1) { printf(请输入数字\n); }用户输入一个字母scanf失败字母留在缓冲区里循环再执行一次scanf又读到同一个字母又失败……无限循环而且因为提示语在刷屏你连自己在输什么都看不清。解决的关键是失败时必须清空缓冲区int ch; while (scanf(%d, n) ! 1) { printf(输入无效请重新输入\n); while ((ch getchar()) ! \n ch ! EOF) ; /* 把这一行剩下的内容全部吃掉 */ }那行空的while循环就是在清空缓冲区。注意ch必须是int类型因为getchar()可能返回EOF通常是 -1用char接会丢失信息某些平台上EOF和某个合法字符的编码冲突导致循环永远退不出去。提示任何读取失败后重试的循环都必须在失败分支里清空缓冲区否则就是死循环的温床。这条经验适用于所有交互式命令行程序不只是这道题。5.4 一个和代码无关的坑编辑器把全角空格混进来了这个坑我栽过一次排查了快半小时。代码看起来完全正常编译也过但图形整体往右偏了一大截。原因是我从某个网页复制空格的时候顺手带进了几个全角空格U3000它们看起来和半角空格几乎一样但占 2 个显示宽度。行首那几个空格实际上是全角字符缩进量直接翻倍。识别办法是打开显示不可见字符的功能或者用cat -A之类的工具看一眼。更彻底的办法是养成习惯行首缩进只用键盘直接敲不从外部复制。同理代码里的空格和制表符也不要混用混用之后同一份代码在不同编辑器里缩进层级看起来完全不一样。6. 同一套思路还能怎么用6.1 菱形把两个平行四边形拼在一起理解平四之后菱形其实就是上半部分越打越宽、下半部分越打越窄。设总高度为奇数h中间那一行的缩进是 0符号数最多往两边每走一行缩进加 1符号数减 2。int h 7, mid h / 2; for (int i 0; i h; i) { int dist (i mid) ? (mid - i) : (i - mid); /* 距中心行有多远 */ int indent dist; int count h - 2 * dist; for (int j 0; j indent; j) putchar( ); for (int j 0; j count; j) printf(%s, sym); putchar(\n); }注意dist这个变量——它把离中心有多远抽象出来了上下两半就能共用一个公式不用写两个循环。这种找对称轴用距离表达的思路在处理沙漏、正三角、倒三角时都能直接套。沙漏跟菱形几乎一样只是把count从中间最宽改成中间最窄本质是count关于dist的函数从减变成了增。6.2 用字符表做出层次感既然填充单位已经是字符串了那就不一定非得全程用同一个符号。可以做一件事按行从一张字符表里取不同的符号。const char *palette[] {*, #, %, }; int idx i % (int)(sizeof(palette) / sizeof(palette[0])); strcpy(cfg.sym, palette[idx]);这样每一行的填充符号都不同图形会呈现出一层一层的横条纹。再进一步按列切换符号就会变成纵向条纹按i j的奇偶切换就成了棋盘格。这条线的意义不在于好看而在于它说明了一件事图案生成的本质是对每个格子 (i, j) 计算一个输出。一旦你脑子里建立起这个二维坐标模型任何规则都可以往里塞。想画棋盘就往里塞奇偶判断想画渐变就往里塞距离计算想画随机噪点就往里塞rand()。前面推导平四缩进量的那套思维到这里就复用上了。6.3 后续可以继续往下做的几件事第一件是把绘制结果同时输出到文件。fprintf和printf的用法一模一样把所有的printf换成一个FILE *参数指向的流就能既打印到屏幕又写进文本文件。这一步做完你的小程序就从一个练习变成了能产出素材的工具。第二件是加上命令行参数解析让./prog 5 8 * 1这样的调用直接生效配合argc / argv判断参数个数比交互式输入更适合批量使用。第三件也是最值得花时间的一件把缩进量和填充长度的计算抽成两个独立函数比如calc_indent(row, rows, dir)和calc_fill(row, rows, cols)。抽出来之后你会发现改形状只需要改这两个函数绘制主循环一个字都不用动。能画出多少种图形取决于你把多少规则从主循环里拆了出去——这个体会是我写完七八种字符图案之后才真正明白的比任何教程都管用。最后分享一个我自己一直在用的小习惯每写完一个图形程序不要只跑默认参数就结束至少手动跑三组极端值——最小1 行 1 列、刚好为奇数的中等值、以及接近上限的大值。这三组跑下来没出问题代码基本就稳了。图形类代码的 bug 特别喜欢躲在边界上正常参数下跑得漂漂亮亮一遇到rows 1立刻原形毕露。