题单 · 习题解析

洛谷 P1618 三连击(升级版)

★★★ 顺手写的生成器 300 轮一个都抓不到(万分之六才有解),改成「先造答案」也只抓到 15 轮

原题:洛谷 P1618出自 第 5 章 枚举与模拟 的题单题面本地存档:2026-08-26
⚠ 先自己写一遍,再往下看

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

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

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

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

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

题目描述

1, 2, …, 99 个数分成三组,分别组成三个三位数,且使这三个三位数的比例是 A : B : C,试求出所有满足条件的三个三位数,若无解,输出 No!!!

输入格式:三个数,ABC

输出格式:若干行,每行 3 个数字。按照每行第一个数字升序排列。

数据范围:保证 0 ≤ A < B < C ≤ 999

upd 2022.8.3:新增加二组 Hack 数据。

输入输出样例

输入

1 2 3

输出

192 384 576
219 438 657
273 546 819
327 654 981

1 : 2 : 3 有四组解。上面那段输出是仓库里的 p1618.cpp 真跑出来的。

1第 ① 版:题目怎么说就怎么写(对,但跑不完)

「分成三组、组成三个三位数」—— 那就三个三位数各枚举一遍,每组验两件事: 九个数字恰好用一次、比例正好是 A : B : C

p1618Brute.cpp第 ① 版:三重循环(对,但 8.5 秒)
答案是对的。⚠ 它要跑 8 秒多才出来 —— 页面上耐心等一下。
// P1618 的第 ① 版:题目怎么说,代码就怎么写 —— 三个三位数全枚举一遍
//
// 「将 1~9 共 9 个数分成三组,组成三个三位数」⇒ 三个三位数各枚举一遍,
// 每一组都验两件事:九个数字恰好用一次、比例正好是 A:B:C。
//
// ⚠ 比例**不要写成除法**(`x / A == y / B` 整除会截断,一堆假答案)。
// 写成交叉相乘:x : y = A : B ⇔ x * B == y * A。
// ★ 顺带把 A = 0 也接住了:B > A = 0,于是 x * B == y * 0 = 0 永不成立,
// 自然输出 No!!! —— 而下一版栽的就是这里。
//
// 它是对的。它也是**跑不完的**:900³ ≈ 7.29 亿组,正文第 ③ 步有秒表。
#include <bits/stdc++.h>
using namespace std;
// 九个数字恰好用一次(出现 0 就作废)
bool okDigits(int x, int y, int z) {
int used = 0;
for (int v : {x, y, z})
for (int i = 0; i < 3; i++, v /= 10) {
int d = v % 10;
if (d == 0) return false;
if (used >> d & 1) return false;
used |= 1 << d;
}
return true;
}
int main() {
int A, B, C;
cin >> A >> B >> C;
bool any = false;
for (int x = 100; x <= 999; x++)
for (int y = 100; y <= 999; y++)
for (int z = 100; z <= 999; z++)
if (okDigits(x, y, z) && x * B == y * A && x * C == z * A) {
printf("%d %d %d\n", x, y, z);
any = true;
}
if (!any) printf("No!!!\n");
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

本机实测(B 机:原生 Ubuntu 7.0.0-30 / i5-10210U 8 线程 / 18 GB,2026-08-26,独占): 900³ ≈ 7.29 亿组,8.52 秒。题目限时 1 秒

⚠ 比例不要写成除法

x / A == y / B 是错的:整数除法会截断,199 / 2 == 200 / 2 都成立。 判比例一律交叉相乘x : y = A : Bx * B == y * A

★ 顺带一个便宜:A 可以是 0(题目只保证 0 ≤ A), 而交叉相乘把它当乘数x * B == y * 0 永不成立,自然就输出 No!!! 了。 下一版栽的正是这一处 —— 记住这个便宜是从哪儿来的。

2第 ② 版:能算出来的量不要枚举(快了,样例也过了)

第 5 章的枚举第一原则:能算出来的量,不要枚举。 定了第一个数 x,比例就把后两个钉死了 —— 于是三重循环塌成一重:

for (int x = 100; x <= 999; x++) {
    if (x % A != 0) continue;      // 倍数 k = x / A 必须是整数
    int k = x / A, y = k * B, z = k * C;
    ...
}
p1618Div.cpp第 ② 版:只枚举第一个数(快 90 万倍,但它是错的)
样例四行一字不差。⚠ 把输入改成 2 4 6 再跑一次 —— 同一个比例,它只剩一行。
// P1618 的第 ② 版:只枚举第一个数,另外两个**算**出来
//
// 第 5 章的第一原则:**能算出来的量,不要枚举。**
// 定了 x,比例就把 y、z 钉死了:y = x / A * B、z = x / A * C。
// 900 万分之一的工作量,样例一秒不到就过。
//
// ⚠⚠ 可它是**错的**,而且样例挡不住 —— 两处:
// ① `x % A != 0` 就跳过 ⇒ 默认「倍数 k = x / A 一定是整数」。
// 可题目给的是**比例**:A:B:C = 2:4:6 和 1:2:3 是同一个比例,
// 而 123 : 246 : 369 正好是 1:2:3 —— 它对 A = 2 不整除,于是被漏掉。
// (洛谷 2022-08-03 加的两组 Hack 数据打的就是这里。)
// ② A 可以是 0(题目只保证 0 ≤ A < B < C)⇒ `x % A` 当场除以 0,
// x86 上直接 SIGFPE,一分不剩。
#include <bits/stdc++.h>
using namespace std;
bool okDigits(int x, int y, int z) {
int used = 0;
for (int v : {x, y, z})
for (int i = 0; i < 3; i++, v /= 10) {
int d = v % 10;
if (d == 0) return false;
if (used >> d & 1) return false;
used |= 1 << d;
}
return true;
}
int main() {
int A, B, C;
cin >> A >> B >> C;
bool any = false;
for (int x = 100; x <= 999; x++) {
if (x % A != 0) continue; // ⚠ A = 0 时这一行就是除以 0
int k = x / A;
int y = k * B, z = k * C;
if (y > 999 || z > 999) break;
if (okDigits(x, y, z)) { printf("%d %d %d\n", x, y, z); any = true; }
}
if (!any) printf("No!!!\n");
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
⚠⚠ 样例挡不住:它把「比例」当成了「倍数」

A : B : C = 2 : 4 : 61 : 2 : 3 是同一个比例。 可这一版要求 x % A == 0

输入 1 2 3  ->  192 384 576 / 219 438 657 / 273 546 819 / 327 654 981   ✓ 四行
输入 2 4 6  ->  192 384 576                                             ✗ 只剩一行

219 : 438 : 657 确实等于 2 : 4 : 6,但 219 不是 2 的倍数 —— 它被 continue 掉了。 洛谷 2022 年 8 月加的那两组 Hack 数据,打的就是这里。

3★★★ 把 (A, B, C) 的整个空间扫一遍:它到底错多少

「漏一点」还是「错一片」,是两件事。这道题的输入空间小得可以整个扫完,那就别猜:

p1618Sweep.cpp扫全空间:整除版漏了多少组
0.03 秒扫完 1.66 亿组。诀窍在源码开头那段注释:反着数 —— 先枚举 9! 个排列。
// 把 (A, B, C) 的**整个空间**扫一遍:整除版到底漏了多少组
//
// 用法:./p1618Sweep 人话版
// ./p1618Sweep csv 只打 `键,值`,给 check:viz 用
//
// ★ 为什么能扫得动:A < B < C ≤ 999 有 C(1000,3) = 166 167 000 组,
// 一组组去解是不可能的。反过来做就很便宜:
// ① 1~9 的全排列只有 9! = 362 880 种,切成三个三位数就是**全部可能的解**;
// ② 一组解 (x, y, z) 除掉它们的 gcd,得到**这组解唯一的最简比例** (a, b, c);
// ③ 于是「有解的 (A,B,C)」= 某个最简比例的整数倍,t 从 1 数到 999 / c 就完了。
// ⇒ 有解的组数其实少得可怜,正文那张表里的数就是这么来的。
//
// 整除版(p1618Div.cpp)的判据是 `x % A == 0`,即「倍数 k = x / A 必须是整数」。
// 而 (A,B,C) = t·(a,b,c)、解 (x,y,z) = s·(a,b,c) 时,这个判据等价于 **t | s** ——
// t 不整除 s 的那些解,它一个都看不见。
#include <bits/stdc++.h>
using namespace std;
int main(int argc, char** argv) {
bool csv = (argc > 1 && string(argv[1]) == "csv");
// ① 全排列 -> 所有可能的解,按最简比例分组
map<array<int, 3>, vector<array<int, 3>>> byRatio;
int digits[9] = {1, 2, 3, 4, 5, 6, 7, 8, 9};
long long perms = 0, sols = 0;
do {
perms++;
int x = digits[0] * 100 + digits[1] * 10 + digits[2];
int y = digits[3] * 100 + digits[4] * 10 + digits[5];
int z = digits[6] * 100 + digits[7] * 10 + digits[8];
if (!(x < y && y < z)) continue; // A < B < C ⇒ 必然 x < y < z
sols++;
int g = __gcd(__gcd(x, y), z);
byRatio[{x / g, y / g, z / g}].push_back({x, y, z});
} while (next_permutation(digits, digits + 9));
// ② 每个最简比例的整数倍,就是所有「有解」的 (A, B, C)
long long solvable = 0, missedTriples = 0, missedLines = 0, missGcd = 0;
array<int, 3> firstMissed = {0, 0, 0};
for (auto& [r, list] : byRatio) {
for (int t = 1; (long long)t * r[2] <= 999; t++) {
solvable++;
int A = t * r[0];
long long miss = 0;
for (auto& s : list) if (s[0] % A != 0) miss++;
/* 约分版(p1618Gcd.cpp):t 被除掉了,判据变成「x 是 a 的倍数」,恒成立 */
for (auto& s : list) if (s[0] % r[0] != 0) missGcd++;
if (miss) {
missedTriples++;
missedLines += miss;
if (!firstMissed[2] || array<int, 3>{A, t * r[1], t * r[2]} < firstMissed)
firstMissed = {A, t * r[1], t * r[2]};
}
}
}
long long total = 1000LL * 999 * 998 / 6; // C(1000,3):A < B < C ≤ 999
long long zeroA = 999LL * 998 / 2; // A = 0 的那些:整除版当场除以 0
if (csv) {
printf("perms,%lld\nsolutions,%lld\nratios,%zu\ntotal,%lld\nsolvable,%lld\n"
"missedTriples,%lld\nmissedLines,%lld\nmissedGcd,%lld\nfirstMissed,%d %d %d\nzeroA,%lld\n",
perms, sols, byRatio.size(), total, solvable,
missedTriples, missedLines, missGcd,
firstMissed[0], firstMissed[1], firstMissed[2], zeroA);
} else {
printf("1~9 的全排列 %lld 种\n", perms);
printf("其中 x < y < z 的(= 全部可能的解)%lld 组\n", sols);
printf("它们一共只有 %zu 个最简比例\n", byRatio.size());
printf("A < B < C <= 999 一共 %lld 组\n", total);
printf(" 其中有解的只有 %lld 组\n", solvable);
printf(" 整除版会漏掉解的 %lld 组(共漏 %lld 行答案)\n",
missedTriples, missedLines);
printf(" 而约分版(p1618Gcd)漏的是 %lld 行\n", missGcd);
printf(" 最小的那一组是 %d %d %d\n",
firstMissed[0], firstMissed[1], firstMissed[2]);
printf("另外 A = 0 的 %lld 组,整除版直接除以 0\n", zeroA);
}
return 0;
}
点一下即可编辑
输出
点「运行 ▶」看结果
组数
A < B < C ≤ 999 一共 166 167 000
其中有解 98 199 组(万分之六)
其中第 ② 版会漏解 26 081 组(有解的组里 26.6%
最小的那一组 2 4 6
另外 A = 0 498 501 组,第 ② 版当场除以 0
⚠ `A = 0` 那 49 万组:不是错,是 RE

x % AA = 0 时是除以零。x86 上直接 SIGFPE(退出码 136), 一个字都没输出。⇒ 页面上把输入改成 0 1 2 试一次,第 ② 版会当场炸给你看。

★ 这一类特判有个共性:题目把它写在数据范围里,而不是写在描述里 (「保证 0 ≤ A」)—— 数据范围是题面的一部分,要一个字一个字读。

4第 ③ 版:一行修好 —— 先约分(能 AC,但只修好了一半)

第 ② 版漏解的唯一原因是它默认「x 一定是 A 的整数倍」。 写成式子就清楚了:(A,B,C) = t·(a,b,c)、解 (x,y,z) = s·(a,b,c), 它的判据等价于 t | s —— 而 t = gcd(A,B,C) 就是那个多余的 t。除掉它:

int g = __gcd(__gcd(A, B), C);
A /= g; B /= g; C /= g;          // ★ 就这一行
p1618Gcd.cpp第 ③ 版:约分(能 AC)

它已经能 AC 了check:viz 扫过全空间:漏解 0 行)。 ⚠ 可 A = 0 那 49 万组还在:gcd(0, B, C) = gcd(B, C)A 除完还是 0修好了一个坑,另一个坑一动没动。

5★ 第 ④ 版:换一种写法,两个坑一起消失

回到第 ① 版那个便宜:判比例用交叉相乘,A 是乘数不是除数。 把它和「只枚举 x」合起来 —— y = x·B / A,除得尽才算数:

long long y = 1LL * x * B, z = 1LL * x * C;
if (y % A || z % A) continue;    // 除不尽 ⇒ 这个 x 配不出整数
y /= A; z /= A;
p1618.cpp第 ④ 版:交叉相乘(推荐写法)
// P1618 三连击(升级版)—— 能 AC 的那一版
//
// 只枚举第一个数 x(900 个),另外两个用**交叉相乘**反解,一次除法都不做:
// x : y : z = A : B : C
// ⇔ y * A == x * B 且 z * A == x * C
//
// 所以 y 必须满足 A | x*B,取 y = x * B / A,再回代验一次(验的是「除得尽」)。
// ★ 这么写,三件事同时被解决:
// ① 比例不用约分(2:4:6 和 1:2:3 自动等价);
// ② 倍数 k 不必是整数(123 : 246 : 369 照样能被找到);
// ③ A = 0 也不崩 —— A 是**乘数**不是除数,x*B == y*0 永不成立,自然输出 No!!!。
//
// 枚举 x 是递增的 ⇒ 输出天然「按每行第一个数字升序」,不用再排序。
#include <bits/stdc++.h>
using namespace std;
bool okDigits(int x, int y, int z) {
int used = 0;
for (int v : {x, y, z})
for (int i = 0; i < 3; i++, v /= 10) {
int d = v % 10;
if (d == 0) return false; // 含 0 的不行,只能用 1~9
if (used >> d & 1) return false; // 重复的不行
used |= 1 << d;
}
return true;
}
int main() {
int A, B, C;
cin >> A >> B >> C;
bool any = false;
// A = 0 ⇒ 第一个数只能是 0,不可能是三位数。★ 这一行也是「别把 A 当除数」的收益:
// 它只是一句提前收工,而不是一处「忘了就 RE」的特判。
for (int x = 123; A > 0 && x <= 987; x++) {
long long y = 1LL * x * B, z = 1LL * x * C;
if (y % A || z % A) continue; // 除不尽 ⇒ 这个 x 配不出整数
y /= A; z /= A;
if (y > 999 || z > 999) break; // z 只会越来越大,可以直接收工
if (okDigits(x, (int)y, (int)z)) { printf("%d %lld %lld\n", x, y, z); any = true; }
}
if (!any) printf("No!!!\n");
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★★ 「关键的一步」不一定是算法,也可以是「让一整类坑消失」

这一版和第 ③ 版一样快、一样能过,但它不需要你记住任何一条特判

第 ② 版 第 ③ 版(约分) 第 ④ 版(交叉相乘)
2 : 4 : 6 这种没约分的比例 ✗ 漏解 ✓ 记得约分才行 天然对
A = 0 ✗ 除以 0 ✗ 除以 0 天然对

⇒ 第 ③ 版是「我记得要约分」,第 ④ 版是「不需要记」。 第 47 章那一课在这儿又出现了一次: 一个坑要靠你记住才不踩,它就迟早会被踩;能改成「压根不存在」的,就别留着。

6★★★ 对拍:顺手写的生成器一轮都抓不到

三个版本摆在这儿,对拍应该能把第 ② 版打假吧?顺手写一个生成器试试—— 随机 1 ≤ A < B < C ≤ 999,300 轮:

p1618Gen.cpp生成器:三个档位
参数是「种子 档位」。改成 1 0 / 1 1 / 1 2 各跑一次,看它们造出来的 (A,B,C) 长什么样。
// 数据生成器(P1618 对拍用):`./p1618Gen <seed> [level]`
//
// level 0(默认)顺手写法:随机 1 <= A < B < C <= 999
// level 1 ★ 反着造:先随机一个 1~9 的排列当**答案**,
// 切成三个三位数、排好序、除掉 gcd 得到最简比例 (a,b,c),
// 再随机乘一个 t,输出 (ta, tb, tc) —— 这样造出来的 (A,B,C) **保证有解**。
// level 2 ★★ 反着造,而且**逼着 t >= 2**:只留下 c <= 499 的比例(否则 t 只能是 1)。
//
// ★★★ 为什么非要有 level 1(这道题最值钱的一条,正文第 ⑥ 步):
// A < B < C <= 999 一共 166 167 000 组,**有解的只有 98 199 组** —— 万分之六。
// 顺手随机 300 轮,几乎每一轮三个版本都齐刷刷输出 No!!!,**对拍全绿而错版还是错的**。
// 要抓漏解,必须**先把「有答案」造出来**。
#include <bits/stdc++.h>
using namespace std;
static mt19937 rng;
static int ri(int lo, int hi) { return lo + (int)(rng() % (unsigned)(hi - lo + 1)); }
int main(int argc, char** argv) {
unsigned seed = (argc > 1) ? (unsigned)atoi(argv[1]) : 1;
int level = (argc > 2) ? atoi(argv[2]) : 0;
rng.seed(seed);
if (level == 0) {
int a = ri(1, 997), b = ri(a + 1, 998), c = ri(b + 1, 999);
printf("%d %d %d\n", a, b, c);
return 0;
}
int d[9] = {1, 2, 3, 4, 5, 6, 7, 8, 9};
shuffle(d, d + 9, rng);
int v[3] = {d[0] * 100 + d[1] * 10 + d[2],
d[3] * 100 + d[4] * 10 + d[5],
d[6] * 100 + d[7] * 10 + d[8]};
sort(v, v + 3);
int g = __gcd(__gcd(v[0], v[1]), v[2]);
int a = v[0] / g, b = v[1] / g, c = v[2] / g;
if (level >= 2) {
// ★★ level 1 还不够:整除版只在 t >= 2 时才漏,而最简比例的 c 多半有七八百,
// 999 / c 就是 1 —— t 被**逼成** 1,于是「保证有解」的数据照样打不假。
// 这里重摇到 c <= 499 为止,t 才有得选。
while (c > 499) {
shuffle(d, d + 9, rng);
v[0] = d[0] * 100 + d[1] * 10 + d[2];
v[1] = d[3] * 100 + d[4] * 10 + d[5];
v[2] = d[6] * 100 + d[7] * 10 + d[8];
sort(v, v + 3);
g = __gcd(__gcd(v[0], v[1]), v[2]);
a = v[0] / g; b = v[1] / g; c = v[2] / g;
}
}
int t = (level >= 2) ? ri(2, 999 / c) : ri(1, 999 / c); // ⚠ t = 1 时整除版是对的
printf("%d %d %d\n", a * t, b * t, c * t);
return 0;
}
点一下即可编辑
输出
点「运行 ▶」看结果

本机实测(check:viz 每次都真跑一遍):

生成器 造出来的是什么 300 轮里第 ② 版被抓
档位 0:随机 A < B < C 万分之六才有解,其余全是 No!!! 0 轮
档位 1:反着造(先造答案,再乘 t 保证有解 15 轮
档位 2:反着造,并且逼着 t ≥ 2 保证有解、且没约分 171 轮
★★★ 这道题最值钱的一条:先把「有答案」造出来,还不够
  • 档位 0 全绿。 因为 1.66 亿组里只有 9.8 万组有解 —— 随机 300 轮, 三个版本齐刷刷输出 No!!!错的那版和对的那版一模一样地对。 ⇒ 第 49 章那条:调生成器的第一步,是把「有答案」造出来。
  • 档位 1 只抓到 15 轮,而它已经「保证有解」了。 为什么?第 ② 版只在 t ≥ 2(比例没约分)时才漏,而最简比例的 c 多半有七八百, t 最大只能取 999 / c = 1 —— t 被逼成了 1。 「保证有解」的数据,照样打不假。
  • 档位 2 抓 171 轮。 只多做一件事:重摇到 c ≤ 499,让 t 有得选。

⇒ 一句话:造对「有答案」只是第一步,还要造得到「能踩到那个 bug 的答案」。第 51 章是「要又大又深」,第 52 章是「顺手的生成器 会亲手删掉一个边界」,这一页是它自己把触发条件挤没了。)

7四个版本并排

版本 做法 枚举量 1 2 3 2 4 6 0 1 2 能过吗
p1618Brute 三个三位数全枚举 7.29 亿 8.52 秒
p1618Div 只枚举 xx % A == 0 900 ✗ 漏 3 行 RE
p1618Gcd ② + 先约分 900 RE
p1618 只枚举 x,交叉相乘 900
和算法无关、但会挂人的两条
  • 无解要输出 No!!! —— 三个感叹号,一个不多一个不少。
  • 「按每行第一个数字升序」:枚举 x 是从小到大的,天然就是升序,不用再排序。 ⚠ 但如果你是枚举倍数 k 而不是 x,顺序就得自己想清楚了。
这一页记住三句话
  1. ★★ 判比例一律交叉相乘。 除法会截断(漏解),也会除以 0(RE)—— 而这两件事样例都挡不住,第 ② 版在样例上是满分表现。
  2. 「能算出来的量不要枚举」是对的,但「算」要算对。 同样是一重循环,第 ② 版和第 ④ 版差着 26 081 组数据。
  3. ★★★ 顺手写的生成器一轮都抓不到(0/300);改成「先造答案」也只抓到 15 轮 —— 因为它把触发条件 t ≥ 2 自己挤没了。 逼出 t ≥ 2 之后是 171 轮。