PAT 乙级 1051,完整题名叫“复数乘法”。这道题在乙级里不算难,但它在“一看就会、一交就错”这个榜单上绝对排得上号。很多人在 PAT 刷题群抱怨过:明明数学公式背得滚瓜烂熟,样例也和自己跑出来的输出一模一样,结果一提交就是个答案错误,回头检查半天才发现,问题出在一个不起眼的 “-0.00” 上。也有人因为 scanf 里写错一个格式符,卡了整整一个晚上。
这篇文章就拿 1051 当标本,把复数乘法从数学原理、代码实现,到格式化输出那几个要命的坑,完整拆一遍。还会顺带聊到 1037“在霍格沃茨找零钱”、1041 考试座位号这类同门师兄弟,帮你把 PAT 乙级最常见的“输出细节题”一网打尽。适合正在刷 PAT 乙级、准备考研机试,或者刚跟着翁恺老师的 C 语言课想找题练手的同学。看完之后,你不但能 AC 这道题,还等于顺手养成了一套对付格式化输出题的标准流程。
1. 题目复述与考点拆解
1.1 题目到底在说什么
先花一分钟把题读懂。题目说复数可以写成常规形式 A+Bi,其中 A 是实部,B 是虚部,i 是虚数单位,满足 i² = -1。也可以写成极坐标下的指数形式 R×e^(Pi),R 是复数模,P 是辐角,i 还是那个虚数单位。
注意这里的写法很容易让新手误会。题目里写的 e^(Pi),其实是 e^(P×i),P 是变量,不是圆周率 π。整个式子等价于三角形式 R(cosP + i·sinP)。换句话说,一个复数可以用“长度和角度”来描述:R 决定它离原点多远,P 决定它朝哪个方向。这个几何视角是所有公式推导的钥匙。
题目输入给两个复数的 R1、P1、R2、P2,要求输出这两个复数乘积的常规形式 A+Bi。输入样例是2.3 3.5 5.2 0.4,输出是-8.68-8.23i。看到这个样例,你要立刻意识到一件事:乘法不是让你把两个复数都转成直角坐标再展开硬算,而是用极坐标下的一个漂亮性质——模相乘,辐角相加。
于是乘积的模 R = R1 × R2,乘积的辐角 P = P1 + P2。最后把 R 和 P 转回直角坐标,实部 A = R × cos(P),虚部 B = R × sin(P)。整个核心流程就这四步。所谓“难”的地方,根本不是数学,而是你能不能把最后那串输出字符串按评委的要求原样交上去。
1.2 明面考点和隐藏考点
从应试角度拆,这道题有两层考点。
明面考点是大家都清楚的:
- 认得极坐标指数形式和三角形式,能写出转换公式;
- 会用 math.h 里的 cos、sin;
- 会保留两位小数输出;
- 虚部为负时输出 A-|B|i,虚部非负时输出 A+Bi。
隐藏考点才是拉开差距的地方:
- 浮点数做三角函数运算后可能产生“负零”,printf 会把 -0.0001 输出成 -0.00,直接导致格式错误;
- scanf 读 double 用 %lf,printf 输出 double 用 %f,这个细节很多人会搞混;
- 对输出边界的把握,比如什么时候该把近似 0 的数值归零;
- 最后还有一项工程习惯:拿到题先设计边界用例再提交,而不是样例一过就冲。
乙级里不少题都是这个调性:算法难度只有入门级,但评测机按字符串严格比对,一个字符不对就是零分。所以看清明面考点只能保你“会做”,处理清隐藏考点才能保证“能过”。后面章节我会把这两个层面交叉着讲,重点放在隐藏考点上。
2. 数学原理与转换思路
2.1 为什么直接用极坐标乘法公式
有人会问:老老实实把两个复数都从极坐标转成直角坐标,再用 (a+bi)(c+di) = (ac-bd) + (ad+bc)i 展开,不是也行吗?行,但没必要。
展开做法在逻辑上没有问题,但你需要先把输入给的 R1、P1 转成 a、b,再把 R2、P2 转成 c、d,中间经历四次乘法、两次加减法,最后还得再把角度这种东西重新藏起来。代码一长,中间某一步的符号写错的概率就上来了。更关键的是,这种算法完全没利用极坐标的几何意义,属于“会算但不理解”。
极坐标乘法公式的本质,是把复平面上的乘法拆成两个动作:长度缩放和角度旋转。第一个复数把第二个复数的长度放大 R1 倍,同时把方向旋转 P1 角。所以两个复数相乘,长度就是 R1×R2,角度就是 P1+P2。代码上只需要两行:
double r = r1 * r2; double p = p1 + p2;后面再转直角坐标,总共就四行核心代码。这种“先理解数学结构,再决定怎么编码”的顺序,才是刷题的正确姿势。
拿题目样例手工算一遍,你能更直观地看到这个公式在起作用。
R = 2.3 × 5.2 = 11.96 P = 3.5 + 0.4 = 3.9(弧度) A = 11.96 × cos(3.9) ≈ 11.96 × (-0.7259) ≈ -8.68 B = 11.96 × sin(3.9) ≈ 11.96 × (-0.6878) ≈ -8.23
输出就是 -8.68-8.23i。整个过程不需要做任何“复数相乘”的硬算,角度加完、长度乘完,再让 cos 和 sin 把直角坐标带出来,一切顺理成章。
2.2 从极坐标回到直角坐标的公式与实现
极坐标转直角坐标的公式,网上一搜一大把,但真正“手上不抖”地写对,得注意三个细节。
第一,角度单位。PAT 给的是弧度,不是角度。千万别画蛇添足地乘一个 180/π。弧度制下,π 对应 180°,cos(π) 等于 -1,sin(π) 等于 0,这是判断结果对不对的天然标尺。你可以用1 3.14159265 1 0这组输入去自测,如果输出不是 -1.00+0.00i 附近,那说明某个环节出了偏差。
第二,计算顺序。先把乘积的模算出来,再算 cos 和 sin。写成r * cos(p)和r * sin(p),比写成r1 * r2 * cos(p1 + p2)更清晰,也方便调试。对程序运行效率来说差别不大,但对你读代码和排错来说差别很大。
第三,赋值变量名。建议用 a、b 表示实部和虚部,不要用 x、y。PAT 题里讲的是复数,a、b 和题目描述直接对应,看你代码的人(包括熟悉后的你自己)能少费很多脑细胞。
2.3 浮点误差与“-0.00”的根源
现在聊这道题真正阴险的地方。计算机里的浮点数是二进制近似表示,不是十进制精确值。很多我们以为“等于 0”的运算,在机器内部会变成一小撮靠近 0 的数。典型的例子:cos(π/2) 的理论值是 0,但 double 算出来可能是 6.123233995736766e-17 这种正的小数,也可能是 -0.0000000000000001 这种负的小数。
如果你直接把这样的数交给 printf,并且要求保留两位小数,结果会是什么样?
0.0000000000000001 保留两位是 0.00,没问题;但 -0.0000000000000001 保留两位,printf 会诚实地输出 -0.00。你看着莫名其妙,评测系统可不管,它拿你的输出字符串跟标准答案一比,发现一个是0.00另一个是-0.00,直接判错。这就是负零陷阱。
解决办法是在输出前,把绝对值小于某个阈值的实部和虚部强制归零。阈值怎么取?因为我们只保留两位小数,一个数要想在输出时显示为 0.00,它的绝对值必须小于 0.005。超过这个范围,四舍五入之后就不是 0.00 了。所以最稳妥的阈值是 0.005:
if (fabs(a) < 0.005) a = 0.0; if (fabs(b) < 0.005) b = 0.0;有人会问,为什么不是 0.0005?因为保留两位小数的舍入边界是看小数点后第三位。0.0049 显示为 0.00,0.0051 显示为 0.01。所以 0.005 是分界线,用它做阈值最贴合题意。
3. C语言实现与关键细节
3.1 主体代码与每一步说明
先给出一份我实际提交过的通版代码,带注释:
#include <stdio.h> #include <math.h> #define EPS 0.005 int main() { double r1, p1, r2, p2; // 注意:读 double 必须用 %lf scanf("%lf %lf %lf %lf", &r1, &p1, &r2, &p2); double r = r1 * r2; // 模相乘 double p = p1 + p2; // 辐角相加 double a = r * cos(p); // 实部 double b = r * sin(p); // 虚部 // 消除 -0.00 陷阱 if (fabs(a) < EPS) a = 0.0; if (fabs(b) < EPS) b = 0.0; // 虚部为负时直接输出负号,非负时手动补加号 if (b < 0) printf("%.2f%.2fi\n", a, b); else printf("%.2f+%.2fi\n", a, b); return 0; }逐段解释。
scanf那行,r1、p1、r2、p2 都是 double,必须写%lf。写成%f在 scanf 里是读 float 的,你拿 double 的地址去接 float 长度,数据会错乱。这是 C 语言新手翻车频率最高的点之一。
r = r1 * r2和p = p1 + p2,这两行是极坐标乘法的灵魂。没有任何多余操作,直接根据数学规则来。
fabs(a) < EPS判断,是对浮点脏数据的兜底。之前解释过,它只负责把“应该显示成 0.00 但带着负号那一丁点”的数据掰回正常。
最后的 if/else 是输出格式的关键。C 语言的%f只负责输出数字,不会自动帮你加正号。如果虚部 b 是正数,直接printf("%.2f%.2fi", a, b)会输出类似1.001.00i这种没有加号的字符串,显然不对。所以 b 非负时要手动打印一个+;b 为负时,%.2f会自动带上负号,不需要额外加符号。
编译的时候,如果你的环境是 gcc,记得链接数学库:
gcc 1051.c -o 1051 -lm-lm是让链接器找到 libm 数学库,很多新人在自己电脑上编译报“cos/sin 未定义引用”或者“对 cos 的未定义引用”,十有八九就是忘了这个参数。在 PAT 的在线评测环境里一般不用操心,但本地调试必须知道。
3.2 负零处理的两条技术路线
负零的解决办法,我在网上见过好几种写法,这里做个对比。
第一种是上面代码里的写法,用宏定义 EPS,然后判断绝对值。优点是阈值含义清楚,改起来方便;缺点是如果你不理解 0.005 的来历,容易照抄后还留隐患。
第二种是把判断写成这样:
if (a > -0.005 && a < 0.005) a = 0.0; if (b > -0.005 && b < 0.005) b = 0.0;效果和 fabs 版本几乎一样,但多打不少字,可读性也没提升。我推荐直接 fabs,因为意图一目了然:“绝对值小于阈值就归零”。
第三种是很多博客里出现的“补救派”:
if (a == 0) a = 0; // 无语,等于没写这种写法对负零完全无效,因为计算出来的 -0.0000000000001 在二进制里并不等于整数 0。你不能期望用== 0去捕获一个浮点噪声。真正可靠的只有阈值判断。
还有一种非常少见但值得知道的处理思路:输出前检查格式化结果。
char buf[32]; sprintf(buf, "%.2f", a); if (strcmp(buf, "-0.00") == 0) strcpy(buf, "0.00");这是把字符串抓出来硬改,也能过,但属于绕远路。在 PAT 这种追求简洁的题目里,用 EPS 判断就是最优解。
3.3 用样例和边界用例自测
写完代码先别急着提交,拿下面这组用例在本地跑一遍。这不是浪费时间,是替你省一次提交错误的内心暴击。
| 输入 | 期望输出 | 说明 |
|---|---|---|
| 2.3 3.5 5.2 0.4 | -8.68-8.23i | 题目原始样例,整体验证 |
| 1 0 1 0 | 1.00+0.00i | 最平凡的数,虚部为 0 |
| 1 3.14159265 1 0 | -1.00+0.00i | 辐角为 π,sin 约 0 |
| 0.0001 0 10000 0 | 1.00+0.00i | 检查虚部是否存在负零 |
| 1 1.570796327 1 0 | 0.00+1.00i | 辐角为 π/2,实部约 0 |
| 1 -1.570796327 1 0 | 0.00-1.00i | 辐角为 -π/2,检查负号 |
第一行是题目样例,验证主流程。第三行验证辐角为 π 时 sin 值接近 0 的情况。第四行是我最想强调的:0.0001 × 10000 等于 1,角度还是 0,理论上答案就是 1+0i;但如果你的程序没做负零处理,某些编译器下可能输出1.00-0.00i。第五、第六行分别验证实部为正零、虚部为正负号的情况。
把这些用例跑通之后,这道题基本就稳了。你甚至可以把这个测试表存成一个 shell 脚本或者批处理文件,以后每个格式题都套用一部分。
4. 常见错误与排查实录
4.1 高频错误清单与原因速查
把 PAT 讨论区里关于 1051 的提问翻一遍,错误基本集中在下面几张表里。
| 错误表现 | 根因 | 解决办法 |
|---|---|---|
| 输出 -0.00 被判错 | 浮点负零没处理 | 加 fabs(x) < 0.005 判断 |
| 结果完全不对 | scanf 使用 %f 读 double | 改成 %lf |
| 虚部为正时缺少加号 | 直接 printf("%.2f%.2fi") | 用 if/else 补加号 |
| 角度误以为是角度制 | 把 P 乘了 180/π | PAT 输入的一直是弧度 |
| 编译报错 cos/sin 未定义 | 没包含 math.h 或没链接 libm | 加头文件,gcc 加 -lm |
| 提交超时或运行错误 | 少数人用了字符串解析硬算 | 直接用数学公式最稳 |
这些错误看起来都很基础,但机器判题就是那么无情:错一个字符就是零分。我在群里见到的案例中,占用时间最长的不是算法不会,而是“样例明明过了但就是不 AC”,最后发现就是负零或加号的问题。
有个经验可以分享:如果在 PAT 页面上只看到“答案错误”,没有任何提示,先别花半小时去猜。直接把你的程序改成打印原始计算值,比如同时输出a和b的未格式化数值,跑一遍边界用例,基本一眼就能看出哪里出了格式问题。
4.2 本地排查手法:随机数据加交叉验证
我自己的排查套路比较机械,但特别有效。我会先写一个 Python 参考脚本,用相同的公式算出正确答案,再和 C 程序输出逐字符比对。
import math r1, p1, r2, p2 = map(float, input().split()) r = r1 * r2 p = p1 + p2 a = r * math.cos(p) b = r * math.sin(p) if abs(a) < 0.005: a = 0.0 if abs(b) < 0.005: b = 0.0 if b < 0: print(f"{a:.2f}{b:.2f}i") else: print(f"{a:.2f}+{b:.2f}i")然后用脚本生成大量随机测试数据,同时喂给两个程序,把输出写到两个文件里 diff 比对:
python3 gen_data.py > input.txt ./1051 < input.txt > c_out.txt python3 1051_ref.py < input.txt > py_out.txt diff c_out.txt py_out.txtdiff 无输出,说明两边的字符串一致,可以放心提交。不一致,就把具体输入拎出来单步调试。这套“参考实现 + 随机数据 + diff”的方法,我用在几乎所有有精度、有格式要求的题目上,节省的时间是惊人的。
4.3 一次翻车实录:我被 -0.00 支配的经历
我头一回提交这道题时,代码逻辑完全正确,样例输出也一模一样,结果页面显示一个刺眼的“答案错误”。
那时候我还不知道负零的存在。我把题目读了一遍又一遍,把 printf 看了又看,实在找不出问题,最后干脆把输入改成0.0001 0 10000 0跑了一遍。程序输出了1.00-0.00i。那一瞬间我整个人都清醒了:不是算法错,是格式错。标准答案里根本不该出现负号。
后来我养成了一个条件反射:凡是浮点数输出保留小数的题,提交之前先检查有没有写负零处理。这个习惯不只在 PAT 有用,在工作里写报表、导出数据时同样能救你。很多人眼里“差不多”的浮点输出,在严格对比的场景下就是大事故。
5. 从这道题带走的通用刷题经验
5.1 PAT乙级里的“格式化输出”套路题
1051 复数乘法不是孤例。PAT 乙级里有一大批题的核心考点就是“输出格式”,它们披着不同的外衣,内里都是同一套逻辑:
- 1037 在霍格沃茨找零钱:进制式货币换算之后,要求按固定格式输出零钱张数;
- 1041 考试座位号:输入准考证号和座位号,输出时要把多个信息拼接正确;
- 1046 划拳:每行一对儿输赢,最后输出两个数字时连空格位置都有讲究;
- 1056 组合数的和:考验循环求和后的输出规范性。
刷到这些题你会发现,乙级真正的主题不是“算法难度”,而是“能不能按照要求把结果忠实地呈现出来”。有算法竞赛背景的人写代码很快,但冷不丁被一道乙级的输出题打回原形,原因就是太不把字符串格式当回事。
我的建议是:见到“保留两位小数”“补前导零”“正负号”“行尾空格”这些关键词,先停下来,把输出规则写在草稿纸上,再动键盘。别高估自己的短期记忆,尤其是你在连续刷好几道题的时候。
5.2 如何配合翁恺老师的 C 语言课刷 PAT
很多同学是看翁恺老师的 C 语言 MOOC 入门的,看完之后跟着 PAT 题集刷题。这个路线我亲测走得通,但有几个细节要提醒。
翁恺老师的课把 scanf、printf、math.h 这些基础讲得非常细,但如果你只上课不写代码,遗忘速度很快。我自己习惯是“看一小节 → 做三道 PAT 题 → 再回来看下一节”,用题目逼自己复习。具体到 1051 这道题,它出现在题集靠后的位置,大概属于“数学模拟 + 格式化输出”的交界题。刷到这类题时,你心里应该形成一个判断:乙级常见的坑无非是输入越界、数组越界、格式化输出、浮点脏数据四大类。
另外,PAT 官网的乙级题集是按编号顺序排列的,从 1001 开始做最简单。1001 这种题属于“热身”,到 1051 这种就有一点真实工程的影子了:有数学背景,有浮点误差,有字符串格式。每一道题踩过的坑都值得记进你自己的“避坑清单”,不用多,二十条之后你刷乙级的速度会肉眼可见地提升。
5.3 遇到输出格式题时的固定动作
最后分享一套我每次做输出格式题都会执行的流程,三个固定动作:
第一步,圈关键约束。在读题时把“保留两位小数”“虚部为负时用减号”“行尾不能有多余空格”这类要求用笔圈出来,或者打个标记。这一步能避免 50% 的粗心错误。
第二步,先想边界,再写代码。保留两位小数,就想到 0.005 阈值;要输出复数,就想到正负号;要输出整数,就想到前导零。把这些边角料在动手前过一遍,比写完再 debug 省力得多。
第三步,提交前跑三个自测用例:题目给的样例、一个极值边界(比如 0.0001 和 10000 相乘)、一个符号边界(比如辐角为 -π/2 时虚部为负且实部约 0)。这三个用例覆盖了 80% 的隐藏格式坑。
这套流程看起来简单,但坚持做下来,你在 PAT 乙级上的“冤枉分”能少丢一大半。我曾经连着刷二十道题,输出格式一次都没错,就是靠这套肌肉记忆。
我个人刷 PAT 乙级到后来越来越明白,1051 这道题真正想教给你的,不是复数的数学知识,而是对“程序输出”这件小事的敬畏心。你写的每一行 printf,最终都会变成评测系统眼里的一串字符。少一个加号、多一个负号、错一位四舍五入,都是零分。把这些边边角角的讲究变成条件反射,比多背十个算法模板都值得。做题如此,工作里写报表、调接口、对账的时候,一样如此。