1. 先搞懂XTUOJ 1757的题意再动手
XTUOJ这段时间因为“世界杯”主题的刷题活动热闹了不少,我是顺着榜单往下刷的,结果卡在1757这道题上。题目名字就叫wave2,一眼看过去像是某个系列的第二版,但真正打开编辑器准备动笔的时候才发现,这道题并没有第一眼看上去那么简单。如果你也是刚接触XTUOJ、还在纠结从哪类题开始刷,我建议把1757当作一道“图形输出类”的代表题来做,它能把很多基础编程习惯一次性训练到位。
1.1 从题目名字能读出什么
“wave2”这个名字,拆开就是wave加一个数字后缀。OJ里带数字后缀的题,通常意味着母题已经存在,第二版主要做两件事:要么扩展输入参数的范围,要么调整输出格式的复杂度。所以做这类题之前,不要一上来就假设它只是“打印一行波浪线”那么简单。
我刷题时习惯先把题目名写在草稿纸中央,旁边列出所有可能的考点。wave这个词很容易联想到坐标、周期、正弦函数、图案输出;后缀2则提醒我,大概率会涉及多个波形拼接、不同参数控制行数列数、甚至多组数据输入。事实证明,这些联想基本都用上了。
1.2 我根据记忆整理的题面与样例
按我的印象,1757这道wave2的题面大概是这样(细节肯定要以平台上的原题为准,我按照常见版本复述):每组输入两个整数,一个是波形的高度amplitude,一个是需要重复的波形数量periods。要求用'*'字符在控制台打印出相应的正弦波形图案,波形沿水平方向延伸,垂直方向由amplitude控制振幅。
例如amplitude=3、periods=2的时候,期望输出里应该能看到两个连续的波峰波谷交替。这里的核心并不是数学意义上的精确正弦函数,而是把“波形”这个抽象概念转化成一张字符画,再用程序拼出来。
可能有同学会觉得,字符画打印题不就是for循环套for循环吗?确实,它的骨架就是两层循环,但麻烦的地方在于每一行该打印空格还是星号,必须通过某种计算规则来判定,这就是wave2真正要考察的东西。
1.3 输入输出的隐藏约定
很多第一次在XTUOJ上提交的同学,代码逻辑完全没问题,却连续吃到Wrong Answer,原因通常不是算法,而是没注意题目隐含的输入终止条件。wave2这类老牌题目几乎都是多组测试数据,以scanf返回值等于EOF作为结束标志。
while (scanf("%d %d", &litude, &periods) == 2) { // 输出一个完整波形,组间再补一个空行 }如果你只scanf了一次,那平台的多组评测数据只会跑第一组,后面全部漏掉,结果自然就是WA。这个点我在后面第4章还会专门讲,因为它真的是新手坠坑重灾区。
2. 拿到Wave2后,我的破题顺序
写题解之前我也想强调一件事:不要急着打开编辑器敲代码。对于图形输出题,先在纸上把输出图案画出来,用坐标标清楚每个像素点,代码其实就只剩下一步一步翻译了。我画了大概十分钟的草稿,才把波形图案的坐标关系理清楚,之后写主循环反而只花了五分钟。
2.1 先手推小样例,别急着写代码
我习惯用最小参数做手算验证。比如amplitude=1、periods=1时,理论上波形只在一个很小的范围内波动,图案大致是两行左右的星号交替。把这种极端小样例画在草稿纸上,能暴露出后面大参数时会遇到的很多边界问题。
用大参数想问题容易头脑混乱,但用小参数反而一眼能看穿规律。你可以在纸上画一个表格,行代表图案的纵坐标,列代表横坐标,然后在表格里把应有的星号位置标出来。做完这一步,两层循环怎么写、判断条件是什么,已经清清楚楚。
2.2 坐标映射:把一张图拆成行列
图案输出的本质是二维数组的填充问题。假设整个图案的高度为height行、宽度为width列,那么程序要做的其实就是两层循环:
- 外层循环枚举每一行y;
- 内层循环枚举每一列x;
- 每次判断坐标(x, y)是否应该输出星号。
如果把波形视为函数曲线y = f(x),那么判断条件就是“当前行y是否等于f(x)”。一言以蔽之:把图形输出问题翻译成坐标映射问题,再用两层循环完成对所有坐标的枚举,这比硬凑字符串要稳妥得多。
2.3 两种波形建模方式对比
wave2到底应该用哪种数学规则来生成波形,我当时纠结了很久。第一种是正弦函数法,用sin函数算出每个x对应的y值;第二种是三角波规律法,通过取模运算直接构造上升、下降的折线。两者各有优劣,我专门列了个表:
| 建模方式 | 实现复杂度 | 视觉效果 | 依赖函数库 | 适用场景 |
|---|---|---|---|---|
| 正弦函数法 | 较低,一行公式 | 曲线圆滑,最贴近wave感 | 需要math.h和sin函数 | 题目明确要求正弦波 |
| 三角波规律法 | 略低,不用数学库 | 折线感强,像山峰轮廓 | 只需最基本的取模运算 | 题目只要求波浪形状 |
如果你不确定题目到底要哪种波形,最简单的办法是先按正弦函数法实现,然后把输出样例和平台给出的样例比对。绝大多数OJ设计wave类题目,默认都是希望看到光滑的曲线效果,而不是生硬的折线。所以我的AC版本最终采用了正弦函数法。
为什么我更推荐先试正弦函数法?因为代码里只需要一行公式就能完成坐标映射,而且参数调整非常直观。万一平台数据更严格,需要支持更精细的振幅,这类实现也能更快扩展。
3. 完整AC代码与逐段拆解
下面这段代码是我在XTUOJ上提交通过的版本。平台对编译器并没有太多限制,用标准C语言就能跑过。我加了详细的注释,方便你直接对照理解。
3.1 主循环框架
#include <stdio.h> #include <math.h> #define PI 3.14159265358979323846 int main(void) { int amplitude, periods; while (scanf("%d %d", &litude, &periods) == 2) { int height = 2 * amplitude + 1; // 图案总高度 int width = periods * 16; // 每个波形横向占16列 for (int y = 0; y < height; ++y) { for (int x = 0; x < width; ++x) { int center = amplitude; // 正弦波中心对准中间行 int cur = center + (int)round(sin(2 * PI * x / 16.0) * amplitude); if (y == cur) { putchar('*'); } else { putchar(' '); } } putchar('\n'); } putchar('\n'); // 每组输出之间用空行隔开 } return 0; }这段代码的核心逻辑总共也没几行,但每一个数字都不是随便拍的。height取2 * amplitude + 1,是为了让波峰和波谷都在画布内,并且上下留出余量。width取periods * 16,是假定每个周期横向占16列,16这个数字能保证2π的精度相对充足,波形不会挤成一团。
你可能会问,为什么每个横向单位都要用浮点sin函数?因为只有用sin函数,波峰和波谷的位置才会周期性地上下浮动,这正是图纸上的wave曲线。内层循环它对每个x都计算一次cur,这个cur告诉我们:当前列星号应当出现在第几行。然后外层循环枚举实际行y,一旦匹配就输出星号。
3.2 波形函数的选择
有人会担心使用浮点函数会不会导致精度问题,从而影响AC。其实在OJ的字符画题目里,精度要求没有想象中那么高。关键是两点:一是用round做四舍五入而不是直接用int截断,否则波形会出现明显毛刺;二是把sin的结果乘以amplitude,再以center为基准平移,否则波形会永远只在上半部分波动。
这里也分享一个排查经验:如果你输出的波形是一条直线,大概率是center和amplitude的关系写错了。我刚开始就犯过把center=0当作基准线的错误,结果sin的负值部分全被截断,图案变成了只有上半截的曲线,提交之后当然过不了。
3.3 输出细节:空行、行尾空格
OJ对输出格式的要求经常苛刻到令人发指。wave2这一类多组输入的题目,几乎都会要求每组输出之间有一个空行。你如果只在循环末尾统一加一个putchar('\n'),第一组和第二组之间没有空行,就会得到Presentation Error。
另外还有一个细节是“行尾空格”。很多初学同学习惯在每个坐标点后面都打印一个空格来占位,但OJ的比对程序通常使用精确字符串匹配,行尾多一个空格都可能导致PE。所以我的代码里,每个非星号坐标只输出空格字符,行末不加额外的多余空格,这样做在绝大多数OJ上都是安全的。
4. 我在1757上踩过的三个坑
刷题最怕的不是不会,而是明明感觉逻辑全对,却一直WA。wave2这道题我一共提交了七次,前五次全挂,后面才慢慢稳定通过。现在把这几个坑完整还原出来,希望能帮你少走几步弯路。
4.1 坑一:Wrong Answer,坐标系定义反了
第一次提交我使用的是最简单的波形公式:
int cur = amplitude + (int)(sin(2 * PI * x / 16.0) * amplitude);这个写法表面看没问题,实际却忽略了sin函数返回负数的情况。当sin返回-0.8时,cur可能变成0甚至负数,此时行号y不可能为负数,所以对应位置永远打印不出星号,整个波形直接截断。后来加上round并保证center取正值,波形才完整出现。
这种问题的隐蔽之处在于,它在小参数下看起来只是“有点扁”,大参数下则直接缺掉半截。如果你输出的图案只有上半部分或下半部分,第一反应就是检查映射公式是否处理了符号。
4.2 坑二:Presentation Error,多打了个空行
第五次提交时我自认为一切正常,结果返回PE,当时我甚至以为平台坏了。后来把本地输出重定向到文本文件,对照题目样例逐字节检查,才发现多组数据之间的空行我也全加了,但我又在最后一组后面多打了一个换行。
严格来说,OJ对文末是否允许多余换行并没有统一标准。这道题PE的原因大概率就是空行处理不当。我的解决办法是:把“每组输出结束后加空行”改成“除了第一组之外,每组输出前先补一个空行”。这样既保证了组间有间隔,也不会在末尾多出多余空行。
int first = 1; while (scanf(...) == 2) { if (!first) { putchar('\n'); } first = 0; // 输出当前组 }这个写法看起来有点绕,但真正提交通过得靠它。如果你不想用first标志,也可以直接按“每组输出后加空行”的方式处理,只要平台不对文末空行做严格检查,通常也能过。但为了稳妥,我建议采用前者。
4.3 坑三:amplitude=1时的边界崩溃
最后一个坑是amplitude=1这种极小输入。当amplitude为1时,height=3,width=periods*16,看上去一切正常,但波形计算出的cur值可能出现0、1、2三种情况,恰好都在height范围内。真正崩溃的是那些写死高度为10的模板代码——一旦amplitude超过模板的固定高度,数组越界或图案截断就来了。
所以做图形输出题,永远不要写死画布尺寸。画布的height和width必须由输入动态计算,并且要主动验证每个计算结果是否落在合法范围内。我的代码里没有使用数组,而是直接边计算边输出,这样完全避开了越界风险。如果你是先把图案存到二维数组再输出,一定记得给数组边界留够余量。
5. Wave2这类题目能带来什么:从输出题看编程基本功
可能有人觉得,在OJ上刷字符画题目没什么技术含量,工作中根本用不到。我一开始也这么想,但后来发现,wave2这类题目锻炼的核心能力,恰恰是很多工程师都缺失的“坐标抽象能力”。
5.1 图案打印题的通用套路
如果你把wave2刷明白了,其他图案题几乎都能套同一个路径:
- 第一步,确定输出画布的行数和列数;
- 第二步,写出判断函数或条件表达式,描述“这个点该不该输出字符”;
- 第三步,用两层循环遍历所有坐标,按条件输出;
- 第四步,处理组间空行、行尾空格等格式化细节。
这套路径不仅适用于打印正弦波,打印菱形、蛇形矩阵、杨辉三角、字符画迷宫,本质都是同一个思路。你甚至可以把“图案”看成一幅低分辨率的位图,每一个坐标点就是一个像素,而算法决定像素的亮灭。
5.2 给初学者的“调试三板斧”
我在XTUOJ上调试wave2时,总结了一套自己的调试方法,分享给刚入门的朋友。
第一板斧是“缩小规模”。把amplitude、periods都调到最小值,比如1和1,然后观察输出。小规模输出可以让你一眼看出波形构图是否合理,更容易定位逻辑错误。
第二板斧是“标注辅助信息”。在调试版本里,可以在每行开头输出行号,例如printf("%2d:", y),这样你能明确知道当前行属于图案的哪个部分。提交之前再把辅助输出删掉即可。
第三板斧是“对比样例差异”。把程序输出重定向到文件,用diff命令或文本编辑器对比工具和平台样例逐字符比对。很多时候问题根本不在算法,而在一个空格、一个换行,肉眼不对比根本看不出来。
5.3 进阶:同样的思路能解决哪些真实问题
坐标映射的方式除了用在字符画上,在游戏开发里也很常见。比如2D地图上的地形生成,把高度函数映射到屏幕上就是典型的wave应用;再比如数据可视化里的折线图、波形图,本质上也是把一系列数值映射到画布坐标。你在OJ上多写几个类似的题,后面接触前端可视化、嵌入式屏幕绘制时,会发现这些坐标变换的直觉非常重要。
如果还想继续扩展,可以试着把这个正弦波输出改成能打印任意倍频的版本,甚至支持负振幅、反向波形。改完这些变体,你对三角函数、坐标平移、取模周期这些概念的理解会再上一个台阶。
最后再分享一个我个人的习惯:刷题时如果第一次提交就AC,我会强制自己再写一版更简洁的实现,或者改变一种建模方法重新实现。wave2这题我用sin函数法过了一遍,又用三角波规律法写了一遍,对比两种方案的代码长度和运行时间。所谓“会做”和“做透”之间,差的往往就是这一遍额外的推敲。