题单 · 习题解析

洛谷 P1888 三角函数

★★★ 这一页最值钱的是它订正了**本书自己的题单**:第 40 章原来写着「最小两边之比约分」——**那是 tan 不是 sin**(官方样例 3 5 4 ⇒ 正解 3/5,那句话给 3/4),2026-09-01 已改(本书第三次订正自己的题单注解);★ 正解五行:较小锐角对着**最短边**,sin = 对边/**斜边** ⇒ 最短边/最长边,再除以 gcd;⚠ 第二个坑是「忘了约分」—— 勾股数常常不是本原的(6 8 10),而**官方样例正好是本原的、挡不住**,抓获率完全由生成器决定(本原档精确的 0、放大档 300、混合档 **284 ≡ 300 − 16**);★★★ 而这道题**最难写的是生成器**:照题面在 [1,10⁹] 里随机三个数,**一百万组里 0 组是勾股数** ⇒ 只能用欧几里得公式 (m²−n², 2mn, m²+n²) 反着造,而且生成器自己也验过(m,n ≤ 60 的全部 737 组本原勾股数,0 组违反)

原题:洛谷 P1888出自 第 40 章 GCD、LCM 与欧几里得算法 的题单题面本地存档:2026-09-01
⚠ 先自己写一遍,再往下看

这一页不是标准答案,是一条阶梯:把大多数人真实会写出的第一版摆出来, 看它错在哪、慢在哪,再一步一步改。所以它对「已经自己动手撞过一次墙」的人最有用。
还没写过的话,先去写 —— 写出来的哪怕是错的,也比直接看这一页收获大。

0题目原文(本地存了一份)

为什么要在这儿抄一份题面

原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。

转录自洛谷 P1888,日期见页头。两边不一致时信原站。

题目描述

输入一组勾股数 abca ≠ b ≠ c),用分数格式输出其较小锐角的正弦值(要求约分)。

输入格式

一行,包含三个正整数,即勾股数 abc无大小顺序)。

输出格式

一行,包含一个分数,即较小锐角的正弦值。

数据范围

对于 100% 的数据,abc 为正整数且 ∈ [1, 10⁹]

输入输出样例

输入

3 5 4

输出

3/5

3 5 4 是一组勾股数(3² + 4² = 5²)。斜边是 5,较小锐角对着最短边 3 ⇒ sin = 3/5,已经是最简分数。

1⚠⚠ 这道题只有五行,而第一件事是别把 sin 读成 tan

★★★ 本书自己在这句话上栽过一次 —— 这一页顺手订正了它

第 40 章的题单里,这道题的注解原来写的是

五行题:最小两边之比约分,约的就是 gcd。

这是错的。 拿官方样例一测就知道:3 5 4 的最小两边是 3 和 4, 「最小两边之比」= 3/4,而正确答案是 3/5

★ 把定义捋一遍就清楚了:

  • 直角三角形里,斜边是最长的那条(勾股数里最大的那个数);
  • 较小的锐角对着最短的那条边(大边对大角);
  • sin(角) = 对边 / 斜边

答案 = 最短边 / 最长边。而「最短边 / 次短边」是 tan,不是 sin。

⇒ ★ 题单那句注解 2026-09-01 已经照这个订正。 这已经是本书第三次订正自己的题单注解了(前两次是第 29 章 P1330第 31 章 P2712)—— ⇒ ★★ 「顺手写下的那句提示」和「实测」是两回事,本书自己也不例外。

p1888Tan.cpp✗ 错法一:拿最小两边之比(也就是题单原来那句话)—— 样例一测就死
// P1888 ✗ 错法之一:拿「最小的两条边之比」当答案
//
// ⚠⚠ 这一版存在的理由很具体:**本书第 40 章的题单里,那道题的注解原来就是这么写的**
// (「最小两边之比约分,约的就是 gcd」)—— 而它是错的,2026-09-01 已订正。
//
// ★ 最小两边之比 = 最短边 / 次短边 = **tan(较小锐角)**,不是 sin。
// sin 要的是「对边 / **斜边**」,而斜边是**最长**的那条。
//
// ★ 官方样例当场就能分辨:`3 5 4` ⇒ 正解 `3/5`,这一版给 `3/4`。
// ⇒ ⚠ 这是本书里少见的「**样例一测就死**」的错法(连着好几轮拿到的都是反例)。
#include <bits/stdc++.h>
using namespace std;
static long long gcdll(long long a, long long b) { while (b) { long long t = a % b; a = b; b = t; } return a; }
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
long long v[3];
if (!(cin >> v[0] >> v[1] >> v[2])) return 0;
sort(v, v + 3);
long long lo = v[0], mid = v[1]; // ⚠ 拿的是最小的两条边(那是 tan)
long long g = gcdll(lo, mid);
cout << lo / g << '/' << mid / g << '\n';
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

2★ 正解:五行

p1888.cpp正解:最短边 / 最长边,再除以 gcd
// P1888 正解 —— 最短边 / 斜边,再用 gcd 约分
//
// ★ 这道题只有五行,但它有一处**极容易读错**的地方:
// 「较小锐角的正弦值」——
// · 直角三角形里,斜边是**最长**的那条(勾股数里最大的那个数);
// · 较小的那个锐角,对着**最短**的那条边;
// · 而 sin(角) = **对边 / 斜边**。
// ⇒ 答案 = **最短边 / 最长边**。
//
// ⚠⚠ 注意它**不是**「最小的两条边之比」—— 那是 **tan**,不是 sin(见 p1888Tan.cpp)。
// ★ 官方样例 `3 5 4` 就能分辨:答案是 `3/5`(最短 3、斜边 5),而不是 `3/4`。
//
// ⚠ 输入「无大小顺序」⇒ 不能假定第三个数是斜边,要自己找 min 和 max。
// ⚠ 输出要求约分 ⇒ 除以 gcd。
#include <bits/stdc++.h>
using namespace std;
static long long gcdll(long long a, long long b) { while (b) { long long t = a % b; a = b; b = t; } return a; }
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
long long a, b, c;
if (!(cin >> a >> b >> c)) return 0;
long long lo = min(a, min(b, c)); // 最短边:较小锐角的对边
long long hi = max(a, max(b, c)); // 最长边:斜边
long long g = gcdll(lo, hi);
cout << lo / g << '/' << hi / g << '\n';
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

⚠ 两个细节:输入无大小顺序 ⇒ 得自己找 minmax(不能假定第三个数是斜边); 输出要求约分 ⇒ 除以 gcd(这就是第 40 章第 7 步那个手法)。

3✗ 错法二:忘了约分 —— 而官方样例挡不住它

p1888NoGcd.cpp✗ 错法二:不约分
★★ 它的触发条件写得出来:这组勾股数不是本原的

勾股数经常是放大过的6 8 10 就是 3 4 5 的两倍)—— 这时候不约分就会打出 6/10 而不是 3/5

⚠ 而官方样例 3 5 4 正好是本原的 ⇒ 样例一个字都不会变。 ⇒ 抓获率完全由生成器决定(每档 300 轮):

档位 本原的组数 「忘了约分」被抓 「最小两边之比」被抓
0 本原勾股数 300 精确的 0 300
1 放大 k ∈ [2,20] 0 300 300
★ 2 混着(k ∈ [1,20] 16 284 300
3 顶格(本原,逼近 10⁹ 300 0 300

★★ 第 ③ 行是「触发条件 ≡ 抓获数」的又一次一个不差: 284 ≡ 300 − 16(不是本原的那 284 组)。

★ 而最后一列四个 300 是另一件事:「最小两边之比」那个错法每一组都会错 —— ⚠ 这是本书里少见的「样例一测就死」的错法(前面十几轮拿到的几乎全是反例)。

4⚠⚠ 这道题最难写的不是正解,是生成器

p1888Gen.cpp★ 用欧几里得公式反着造:(m² − n², 2mn, m² + n²) 一定是勾股数
// P1888 的生成器:./p1888Gen 种子 [档位]
//
// ⚠⚠ **这道题最难写的不是正解,是生成器** —— 题面要的是**勾股数**,
// 而「照题面在 [1, 10⁹] 里随机三个数」造出勾股数的概率约等于 0
// (p1888Count.cpp 数过:一百万组里 0 组)⇒ 那样测的是一组题目不会给的输入。
// ⇒ 只能**反着造**:用欧几里得公式 m > n ⇒ (m² − n², 2mn, m² + n²) 一定是勾股数。
// ★ 这是[第 13 章 P1162](/sol/p1162/) 那条的又一次现场。
//
// ★ 每个版本靠什么现形:
// · p1888Tan(拿最小两边之比)→ 任何一组勾股数都会错(**连官方样例都挡得住它**)。
// · p1888NoGcd(忘了约分)→ 要**这组勾股数不是本原的** ⇒ 档 0(本原)是精确的 0,
// 档 1(放大 k 倍)才抓得到。
//
// 档位:
// 0 ★ **本原**勾股数(gcd(m,n) = 1 且一奇一偶)⇒ 忘约分那版是精确的 0
// 1 ★★ 本原勾股数再乘 k(k ∈ [2, 20])⇒ 忘约分那版必被抓
// 2 两者混着(k ∈ [1, 20])—— 顺手写的样子
// 3 顶格:m、n 取到让 m² + n² 逼近 10⁹
//
// ⚠ 三个数的**顺序每轮都打乱**(题面明写「无大小顺序」)。
// ⚠ rng() 一律先落到具名变量再传参。
#include <bits/stdc++.h>
using namespace std;
static long long gcdll(long long a, long long b) { while (b) { long long t = a % b; a = b; b = t; } return a; }
int main(int argc, char** argv) {
unsigned seed = argc > 1 ? (unsigned)atoi(argv[1]) : 1;
int mode = argc > 2 ? atoi(argv[2]) : 0;
mt19937_64 rng(seed * 1000003ull + 20260901ull);
long long m, n;
long long hiM = (mode == 3) ? 22000 : 60; // m² + n² ≤ 10⁹ ⇒ m 最大约 31600
do {
m = 2 + (long long)(rng() % (unsigned long long)hiM);
n = 1 + (long long)(rng() % (unsigned long long)m);
} while (n >= m || gcdll(m, n) != 1 || ((m - n) % 2 == 0)); // 本原的条件
long long k = 1;
if (mode == 1) k = 2 + (long long)(rng() % 19u);
else if (mode == 2) k = 1 + (long long)(rng() % 20u);
if (mode == 3) k = 1;
long long t[3] = { k * (m * m - n * n), k * (2 * m * n), k * (m * m + n * n) };
for (int i = 2; i > 0; i--) { int j = (int)(rng() % (unsigned long long)(i + 1)); swap(t[i], t[j]); }
printf("%lld %lld %lld\n", t[0], t[1], t[2]);
return 0;
}
点一下即可编辑
输出
点「运行 ▶」看结果
★★★ 「照题面随机」在这道题上一组合法输入都造不出来

题面说 a, b, c ∈ [1, 10⁹],而且是一组勾股数。 如果照着前半句随便随机三个数会怎样?p1888Count.cpp 数了一百万组:

量的是 实测
[1, 10⁹] 里随机三个数,共试 1 000 000 组
其中真的满足 a² + b² = c² 0 组

一组都没有。 那样跑出来的对拍,测的是一组题目永远不会给的输入。 ⇒ 只能反着造:欧几里得公式 m > n(m² − n², 2mn, m² + n²) 一定是勾股数, 再乘一个 k 就得到所有勾股数。 ★ 这是第 13 章 P1162 那条的又一次现场: 看到「保证……」先问一句,随手造的数据有多大概率满足它。

★ 而生成器自己也要验一遍和 P1162 那次一样): 把 m, n ≤ 60全部 737 组本原勾股数枚举出来, 逐组核对「满足勾股定理」且「最短边与斜边互质」—— 0 组违反

p1888Count.cpp本页所有数字的出处
// P1888 的度量程序:./p1888Count csv (本页的数字都出自它)
//
// rand : ⚠⚠ 「照题面在 [1, 10⁹] 里随机三个数」——**一百万组里有几组真的是勾股数**。
// 这一个数就是「这道题最难写的是生成器」的全部理由。
// prim : ★ 各档里**本原**勾股数(gcd(最短边, 斜边) = 1)的组数
// —— 「忘了约分」那版的触发条件,逐档对上。
// tan : ★ 「拿最小两边之比」那版和正解不同的组数 —— 它应该是**满分 300**(连样例都挡得住)。
// exh : ★★ 把 m, n ≤ 60 的**全部**本原勾股数枚举一遍,
// 逐组核对「答案 = 最短边/斜边,且已约分」这两件事。
//
// ⚠ 这里**复刻**了 p1888Gen.cpp 的档位逻辑(同一个 mt19937_64、同一个种子公式)。
#include <bits/stdc++.h>
#include <chrono>
using namespace std;
static long long gcdll(long long a, long long b) { while (b) { long long t = a % b; a = b; b = t; } return a; }
struct Tri { long long x, y, z; };
static Tri gen(unsigned seed, int mode) {
mt19937_64 rng(seed * 1000003ull + 20260901ull);
long long m, n;
long long hiM = (mode == 3) ? 22000 : 60;
do {
m = 2 + (long long)(rng() % (unsigned long long)hiM);
n = 1 + (long long)(rng() % (unsigned long long)m);
} while (n >= m || gcdll(m, n) != 1 || ((m - n) % 2 == 0));
long long k = 1;
if (mode == 1) k = 2 + (long long)(rng() % 19u);
else if (mode == 2) k = 1 + (long long)(rng() % 20u);
if (mode == 3) k = 1;
long long t[3] = { k * (m * m - n * n), k * (2 * m * n), k * (m * m + n * n) };
for (int i = 2; i > 0; i--) { int j = (int)(rng() % (unsigned long long)(i + 1)); swap(t[i], t[j]); }
return { t[0], t[1], t[2] };
}
int main(int argc, char** argv) {
bool csv = (argc > 1 && string(argv[1]) == "csv");
/* ① 照题面随机三个数,有几组真的是勾股数 */
mt19937_64 rng(20260901ull);
const long long TRIES = 1000000;
long long hit = 0;
for (long long i = 0; i < TRIES; i++) {
long long a = 1 + (long long)(rng() % 1000000000ull);
long long b = 1 + (long long)(rng() % 1000000000ull);
long long c = 1 + (long long)(rng() % 1000000000ull);
long long lo = min(a, min(b, c)), hi = max(a, max(b, c)), mid = a + b + c - lo - hi;
if (lo * lo + mid * mid == hi * hi) hit++;
}
/* ② 各档:本原的组数 + 「拿最小两边」那版的不同组数 */
int prim[4] = {0}, tanBad[4] = {0};
for (int m = 0; m < 4; m++)
for (int s = 1; s <= 300; s++) {
Tri t = gen(s, m);
long long lo = min(t.x, min(t.y, t.z)), hi = max(t.x, max(t.y, t.z));
long long mid = t.x + t.y + t.z - lo - hi;
if (gcdll(lo, hi) == 1) prim[m]++;
long long g1 = gcdll(lo, hi), g2 = gcdll(lo, mid);
if (lo / g1 != lo / g2 || hi / g1 != mid / g2) tanBad[m]++;
}
/* ③ 全范围穷举本原勾股数 */
long long exh = 0, bad = 0;
for (long long m = 2; m <= 60; m++)
for (long long n = 1; n < m; n++) {
if (gcdll(m, n) != 1 || ((m - n) % 2 == 0)) continue;
long long a = m * m - n * n, b = 2 * m * n, c = m * m + n * n;
exh++;
long long lo = min(a, b), g = gcdll(lo, c);
if (a * a + b * b != c * c) bad++; // 勾股数本身对不对
if (g != 1) bad++; // 本原 ⇒ 最短边和斜边必互质
}
if (csv) {
printf("randTries,%lld\nrandHit,%lld\n", TRIES, hit);
for (int m = 0; m < 4; m++) printf("prim%d,%d\ntan%d,%d\n", m, prim[m], m, tanBad[m]);
printf("exhCount,%lld\nexhBad,%lld\n", exh, bad);
return 0;
}
printf("① 照题面在 [1, 10⁹] 里随机三个数,%lld 组里真的是勾股数的有 **%lld 组**\n", TRIES, hit);
printf(" ⇒ 「照题面随机」在这道题上造不出一组合法输入,只能用欧几里得公式反着造。\n\n");
printf("② 各档 300 轮:\n");
const char* NM[4] = {"0 本原勾股数", "1 放大 k ∈ [2,20] 倍", "2 混着(k ∈ [1,20])", "3 顶格(本原,逼近 10⁹)"};
for (int m = 0; m < 4; m++)
printf(" %-28s 本原的 %3d / 300 | 「最小两边之比」那版不同 %3d / 300\n", NM[m], prim[m], tanBad[m]);
printf("\n③ 枚举 m, n ≤ 60 的全部本原勾股数:共 %lld 组,其中「不满足勾股定理或最短边与斜边不互质」的有 %lld 组\n",
exh, bad);
return 0;
}
点一下即可编辑
输出
点「运行 ▶」看结果
★ 一页纸
关键一步 最短边 / 最长边,再除以 gcd —— 全部五行
⚠ 最容易读错的 「较小锐角的正弦」不是「最小两边之比」(那是 tan)—— 本书题单自己写错过
⚠ 第二个坑 勾股数常常不是本原的 ⇒ 必须约分,而官方样例是本原的、挡不住
★★ 真正的难点 生成器 —— 照题面随机一百万组,0 组是勾股数
要不要 long long m² + n² ≤ 10⁹ 装得进 int,但生成器里 m*mk 相乘会超 ⇒ 一律 long long(代价是零)