题单 · 习题解析

洛谷 P2142 高精度减法

★★★ 这道题拆掉的正是[第 44 章第 7 步](/ch/44-bignum/)那个 `sub()` 顶上写着的前提 ——「**调用方保证 a ≥ b**」,而题面不但不保证,还专门写了「a−b<0 请输出负号」⇒ 照抄模板算出来的恒等于 **`(a−b) mod 10^len(a)`**(`2−5` 打 `7`),四档 133/149/111/149,**「记模板要把前提一起记下来」换成高精度一个字没变**([第 43 章那三道](/sol/p3807/)刚演过);★★ 正解多出来的一层全是「和算法无关」的活:比大小要**先比位数**(直接用 string 的 `<` 是字典序,`"100" < "99"`)、去前导零、以及 **`a == b` 时不能打 `-0`**(那句判断写成 `<=` 就会);★★★ 而最值钱的一条是**那两个 bug 的旋钮不在任何一条数据范围里** —— 题面把位数、正负、上界全写了,唯独没说「两个数会不会挨得很近」:顺手写的那一档「不去前导零」只有 **9/300**、「`-0`」是**精确的 0**,造一档「两个数离得近(1/3 完全相等)」当场 **178 / 99**;★★★ **验算走了一条和减法完全无关的路:把答案加回去**(`|c| + min == max`,1200 组 0 组对不上),⚠⚠ 而这个 0 **配了自检**:同一段验证器去验「照抄 sub()」那版,542 组抓出 **542** 组;★★ 二十格「触发 ≡ 抓获」一个不差;⚠ 而官方**唯一那组样例 `2 / 1`,五个待测版本全放过**

⚠ 先自己写一遍,再往下看

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

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

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

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

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

题目描述

给定两个正整数 a, b,求 a − b 的值。

输入格式

输入共两行,每行一个正整数,分别为 a, b

输出格式

输出一行一个整数,表示 a − b 的值。

如果 a − b < 0请输出负号

数据范围

对于 20% 的数据,a, blong long 类型的存储范围内;

对于 100% 的数据,0 < a, b ≤ 10¹⁰⁰⁸⁶

时限 1 秒,内存 125 MB(128000 KB)。

输入输出样例

输入

2
1

输出

1

官方只给了这一组2 − 1 = 1。⚠ 第 4 步会看到,它把五个待测版本全放过了

1★★★ 这道题拆掉的,是本章那个 sub() 顶上写着的那句前提

★★★ 「调用方保证 a >= b」—— 而这道题的题面没有这句保证

第 44 章第 7 步那份 sub() 的注释第一行就写着:

调用方保证 a >= b

而这道题的题面没有这句保证 —— 它反而专门写了一行 「如果 a − b < 0请输出负号」。

⇒ ★★ 照抄那个模板,就是把一个前提已经不成立的模板搬进考场。 说清楚它算了什么:a < b 时最后一位的借位没人接, 于是它算出来的恒等于 (a − b) mod 10^len(a)(10 的补数)——

   2 − 5  =>  它打 7   (不是 −3)
  12 − 34 =>  它打 78  (不是 −22)

★ 所有表现都是白送的推论:a >= b 的每一轮它一个字都不会错 ⇒ 它是「偶尔才错」型,抓获率大约就是「a < b 的概率」,约一半(实测四档 133 / 149 / 111 / 149)。

⇒ ★★★ 第 43 章那三道题刚把这句话演了一遍: 记一个模板的时候,要把它的前提一起记下来 —— 前提失效的那天,模板不会报错。 换成高精度,这句话一个字都没变。

p2142Ll.cpp✗ 第 ① 版:64 位整数直接减 —— 题面明码标价的 20 分
// ✗ 第 ① 版:64 位整数直接减 —— 题面明码标价的 20 分
//
// · 20% 的数据:a, b 在 long long 的范围内 ⇒ **这一版全对**;
// · 100% 的数据:a, b ≤ 10^10086 ⇒ **10087 位**,而 long long 只有 19 位 —— 差 530 倍位数。
//
// ⇒ 和[上一道 P1601](/sol/p1601/) 是同一件事的第二次:
// **出题人写的那几行分档,就是「暴力值多少分」的答案。**
// ⚠ 但这道题的第一档只值 20 分(P1601 那道值 40 分)——
// **同一个偷懒的写法,在两道相邻的题上值的钱不一样。**
//
// ⚠ 用 long long(不是 unsigned)是因为这道题的答案**可以是负数**,
// 而这一版在小数据上是真的对:负号由 printf 自己打出来。
// 顶格时 `cin >>` 读失败 ⇒ 两个变量都被置成 LLONG_MAX ⇒ 打出 0(不是崩溃)。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
long long a = 0, b = 0;
cin >> a >> b;
cout << a - b << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
p2142NoSwap.cpp✗ 错法一:照抄本章 sub(),不判大小 —— 恒等于「a − b 的 10 的补数」
⚠ 顺带一条:同一个偷懒的写法,在相邻两道题上值的钱不一样

上一道 P1601 的第一档写着「20% + 40%」⇒ 64 位整数值 40 分; 这道题只有「20% 的数据 a, blong long 范围内」⇒ 它值 20 分

⇒ ★ 「先交一发 64 位的」值多少分,是「那个写法 × 那道题的分档」的属性, 不是那个写法的属性。(顶格 10¹⁰⁰⁸⁶10087 位long long 只有 19 位 —— 差 530 倍。)

2★ 正解:多出来的那一层全是「和算法无关」的活

★ 减法比加法难的地方,一处都不在借位上
要做的事 一句话
① 比大小 先比位数,位数相同再逐位比 —— ⚠ 直接用 string<字典序,不是数值序
② 交换 + 记负号 a < b 时算 b − a,前面补一个 -
③ 去前导零 1000 − 999 = 0001 → 1
④ ⚠ a == b 答案是 0不能打成 -0 —— 第 ② 步那个判断写成 <= 就会

★ 而 ④ 这一格值得单独说一句:题面写的是 0 < a, b —— 那句话管的是输入,没管输出a == b 时答案就是 0,而且完全合法。 ⇒ ⚠ 和上一道 P1601 正好错开: 那道题 0 是合法的输入0 ≤ a, b),这道题 0 是合法的输出两道相邻的题,两个 0 长在不同的地方。

p2142.cpp★ 正解:先判谁大,再减,最后补符号
// P2142 高精度减法 —— ★ 正解:先判谁大,再减,最后补符号
//
// ★★ 这道题和上一道([P1601](/sol/p1601/))差的不是难度,是**前提**:
// [第 44 章第 7 步](/ch/44-bignum/)那个 sub() 的注释里写着一句
// 「**调用方保证 a >= b**」—— 而这道题的题面**没有这个保证**,
// 还专门写了一行「如果 a − b < 0,请输出负号」。
// ⇒ 照抄本章的 sub() 就是把一个**前提已经不成立的模板**搬过来(p2142NoSwap.cpp 就是它)。
//
// 所以正解比模板多了整整一层,而这一层全是「和算法无关」的活:
// ① 比大小:**先比位数,位数相同再逐位比**(⚠ 直接用 string 的 `<` 是错的,见 p2142Lex.cpp);
// ② 小的减大的时候交换两个数,并且记下要打一个负号;
// ③ 减完去前导零(1000 − 999 = 0001 → 1);
// ④ ⚠ `a == b` 时答案是 `0`,**不能打成 `-0`**(第 ② 步那个判断写成 `<=` 就会,见 p2142NegZero.cpp)。
//
// 复杂度 O(n),n ≤ 10087(题面 a, b ≤ 10^10086)⇒ 一万位的减法,眨眼。
#include <bits/stdc++.h>
using namespace std;
/** −1 / 0 / 1。★ 位数不同时长的那个大,位数相同时才轮到逐位比 */
static int cmpNum(const string& a, const string& b) {
if (a.size() != b.size()) return a.size() < b.size() ? -1 : 1;
if (a == b) return 0;
return a < b ? -1 : 1;
}
/** 高位在前的减法,调用方保证 a >= b;结果已去掉前导零 */
static string subStr(const string& a, const string& b) {
string y = string(a.size() - b.size(), '0') + b, c(a.size(), '0');
int borrow = 0;
for (size_t k = a.size(); k-- > 0;) {
int cur = (a[k] - '0') - (y[k] - '0') - borrow;
borrow = 0;
if (cur < 0) { cur += 10; borrow = 1; } // ★ 借位是一条链,borrow 要带着走
c[k] = char('0' + cur);
}
size_t i = 0;
while (i + 1 < c.size() && c[i] == '0') i++; // ★ 1000 − 999 = 0001 → 1
return c.substr(i);
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
string a, b;
if (!(cin >> a >> b)) return 0;
int r = cmpNum(a, b);
if (r == 0) cout << "0\n"; // ⚠ 这一支必须单独走,否则会打出 -0
else if (r > 0) cout << subStr(a, b) << "\n";
else cout << "-" << subStr(b, a) << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
p2142Lex.cpp✗ 错法二:用 string 的 < 判谁大 —— 字典序不看长度
p2142Trim.cpp✗ 错法三:减完忘了去前导零 —— 1000 − 999 打成 0001
p2142NegZero.cpp✗ 错法四:比大小那一句写成 <= —— a == b 时打出 -0

3★★★ 验算走一条和减法完全无关的路:把答案加回去

★★★ 减法的答案,可以用加法验 —— 而两者一行代码都不共享

正解算出 c = a − b 之后,下面这句应该逐字节成立

|c| + min(a, b) == max(a, b)

★ 这条路和减法一行代码都不共享:加法只有进位、没有借位, 没有比大小、没有符号、没有交换。 ⇒ 「验算要走一条和算法完全无关的路」的又一次。

实测:四个档位共 1200 组,对不上 0 组

⚠⚠ 而这个「0 组」配了自检才敢写 —— 把同一段验证器拿去验那个 「照抄本章 sub()」的版本:在 a < b542 组里,它抓出 542 组,一组不漏。 ⇒ ★★ 「报『0 次 / 找不到』之前,先拿一个已知错的东西验证这段代码是活的」

4⚠ 官方那唯一一组样例:五个待测版本全放过

⚠ 样例 `2 / 1` —— 而五个原因全都能说清
版本 样例输出 为什么放过
★ 正解 1
✗ 64 位整数 1 两个数只有一位
✗ 照抄 sub() 1 ★ 这一组恰好 a > b ⇒ 它的前提在样例上是成立的
✗ 字典序比大小 1 两个数等长 ⇒ 字典序和数值序是同一个序
✗ 不去前导零 1 答案 1 位,一个前导零都没有
<=-0 1 a ≠ b

⇒ ★★ 这是「官方样例是个一测就死的过滤器」那条规律的极端一头: 这道题只给了一组样例,而且五个坑它一个都问不出来。 ⚠ 和上一道 P1601 正好对照 —— 那道题给了两组,其中一组挡住了一个。

5★★★ 生成器:最后两个 bug 的旋钮,不在任何一条数据范围里

p2142Pack.cpp参照物:压位减法(base = 10⁹)—— 1200 轮 0 次不一致
p2142Gen.cpp(四档)生成器:顺手写的 / 等长 / 两个数离得近 / 顶格 10086 位
★★★ 四档 × 五个版本 —— 二十格「触发 ≡ 抓获」一个不差
档位(每档 300 轮) ✗ 64 位 ✗ 照抄 sub() ✗ 字典序 ✗ 不去前导零 <=-0
0 ★ 顺手写的(位数各自随机 1~12) 0 133 123 9 精确的 0
1 ⚠ 两个数等长 0 149 精确的 0 70 3
2 ★ 两个数离得近(1/3 完全相等) 0 111 精确的 0 178 99
3 ★ 顶格(10080~10086 位) 300 149 124 15 精确的 0

★★★ 三条读得出来的结论:

  1. ★★★ 最后两个 bug 的旋钮是同一个 —— 「两个数有多近」,而它不在任何一条数据范围里。 题面只写了 0 < a, b ≤ 10¹⁰⁰⁸⁶:位数、正负、上界全写了, 唯独「两个数会不会挨得很近」一个字都没有。 ⇒ 顺手写的那一档里,「不去前导零」只有 9 / 300、「-0」是精确的 0; 而把两个数造得很近之后是 17899。 ⇒ ★★ 「抓不到时别加轮数,去想那条线在哪儿、然后照着它造」 —— ⚠ 而这一次那条线得自己想出来,题面不会提醒你。
  2. ★★ 「等长」这个旋钮同时给字典序那个 bug 做了自检 —— 等长时字典序和数值序是同一个序(一行能证)⇒ 档 1、档 2 都是精确的 0; 而把位数放开,它当场 123 / 124。 ⇒ 同一个动作干两件事:证明那段对拍是活的 + 称出「位数不同」才是它的命门。
  3. ★★ 那二十格「触发 ≡ 抓获」一个不差 —— 和上一道 P1601 的十六格连起来, 这一轮一共 36 格全中。而这不是运气:高精度的每个 bug 都能写成一句精确的等式, 写得准之后 ≡ 是必然的。(第 35 章 P4147 那次写不成 ≡, 是因为那个 bug 有两种效应叠在一起。)

6★ 哪一版就已经能过了

p2142Count.cpp本页所有数字的出处(含「加回去」那条验算和它的自检)
// P2142 解析页上所有数字的出处。./p2142Count [csv]
//
// 三件事:
// ① 四个档位里每个 bug 的**触发条件**各满足了多少轮(拿去和对拍的抓获数逐格对);
// ② ★★★ **验算走一条和减法完全无关的路:把答案加回去** ——
// `|a − b| + min(a,b)` 应该逐字节等于 `max(a,b)`。
// 加法和减法在代码上一行都不共享(一个只有进位、一个只有借位),
// 所以这不是「拿减法验减法」。
// ⚠⚠ 而这个「0 组对不上」**配了自检才敢写**:同一段验证器拿去验那个
// 「照抄本章 sub()」的版本,当场抓出一大片([报 0 之前先证明这段代码是活的](/sol/p1094/))。
// ③ 顶格一次减法要做多少次一位数减法。
#include <bits/stdc++.h>
using namespace std;
static int cmpNum(const string& a, const string& b) {
if (a.size() != b.size()) return a.size() < b.size() ? -1 : 1;
if (a == b) return 0;
return a < b ? -1 : 1;
}
static string subStr(const string& a, const string& b) {
string y = string(a.size() - b.size(), '0') + b, c(a.size(), '0');
int borrow = 0;
for (size_t k = a.size(); k-- > 0;) {
int cur = (a[k] - '0') - (y[k] - '0') - borrow;
borrow = 0;
if (cur < 0) { cur += 10; borrow = 1; }
c[k] = char('0' + cur);
}
size_t i = 0;
while (i + 1 < c.size() && c[i] == '0') i++;
return c.substr(i);
}
/** 完全无关的那条路:只有进位,没有借位 */
static string addStr(const string& x, const string& y) {
size_t n = max(x.size(), y.size());
string a = string(n - x.size(), '0') + x, b = string(n - y.size(), '0') + y, c(n, '0');
int carry = 0;
for (size_t k = n; k-- > 0;) {
int cur = (a[k] - '0') + (b[k] - '0') + carry;
c[k] = char('0' + cur % 10);
carry = cur / 10;
}
if (carry) c = char('0' + carry) + c;
size_t i = 0;
while (i + 1 < c.size() && c[i] == '0') i++;
return c.substr(i);
}
/** 「照抄本章 sub()、不判大小」那一版算出来的东西 —— 只用来给验证器做自检 */
static string noSwap(const string& a, const string& b) {
string y = b;
if (y.size() > a.size()) y = y.substr(y.size() - a.size());
y = string(a.size() - y.size(), '0') + y;
string c(a.size(), '0');
int borrow = 0;
for (size_t k = a.size(); k-- > 0;) {
int cur = (a[k] - '0') - (y[k] - '0') - borrow;
borrow = 0;
if (cur < 0) { cur += 10; borrow = 1; }
c[k] = char('0' + cur);
}
size_t i = 0;
while (i + 1 < c.size() && c[i] == '0') i++;
return c.substr(i);
}
static void gen(unsigned seed, int mode, string& a, string& b) {
mt19937 rng(seed * 1000003u + 20260904u);
int lo = mode == 3 ? 10080 : 1, hi = mode == 3 ? 10086 : 12;
unsigned r1 = rng() % (unsigned)(hi - lo + 1);
unsigned r2 = rng() % (unsigned)(hi - lo + 1);
int la = lo + (int)r1, lb = lo + (int)r2;
if (mode == 1 || mode == 2) lb = la;
auto one = [&](int len) {
string s;
unsigned h = rng() % 9u;
s += char('1' + h);
for (int i = 1; i < len; i++) { unsigned d = rng() % 10u; s += char('0' + d); }
return s;
};
a = one(la);
if (mode == 2) {
unsigned same = rng() % 3u;
b = a;
if (same != 0) {
unsigned k = 1u + rng() % 3u;
for (unsigned t = 0; t < k && t < a.size(); t++) { unsigned d = rng() % 10u; b[b.size() - 1 - t] = char('0' + d); }
if (b[0] == '0') b[0] = a[0];
}
} else {
b = one(lb);
}
}
int main(int argc, char** argv) {
bool csv = argc > 1 && string(argv[1]) == "csv";
int tSwap[4] = {0}, tLex[4] = {0}, tTrim[4] = {0}, tEq[4] = {0}, tLl[4] = {0};
int backBad = 0, backCases = 0, selfCaught = 0, selfCases = 0;
for (int mode = 0; mode < 4; mode++)
for (unsigned s = 1; s <= 300; s++) {
string a, b;
gen(s, mode, a, b);
int r = cmpNum(a, b);
if (r < 0) tSwap[mode]++; // NoSwap 会错
bool lexSaysLess = (a < b); // 字典序
if (lexSaysLess != (r < 0)) tLex[mode]++; // 字典序判反了
if (r == 0) tEq[mode]++; // NegZero 打 -0
if (a.size() > 19 || b.size() > 19) tLl[mode]++;
string big = r >= 0 ? a : b, small = r >= 0 ? b : a;
string c = r == 0 ? string("0") : subStr(big, small);
if (r != 0 && c.size() < big.size()) tTrim[mode]++; // 答案位数变少 ⇒ 有前导零
// ★★★ 反着验:把答案加回去
backCases++;
if (addStr(c, small) != big) backBad++;
// ★ 自检:同一段验证器去验那个已知错的版本
if (r < 0) { // a < b 时它才错
selfCases++;
string w = noSwap(a, b);
if (addStr(w, b) != a) selfCaught++;
}
}
long long topLen = 10087;
if (csv) {
for (int m = 0; m < 4; m++) printf("trigSwap%d,%d\n", m, tSwap[m]);
for (int m = 0; m < 4; m++) printf("trigLex%d,%d\n", m, tLex[m]);
for (int m = 0; m < 4; m++) printf("trigTrim%d,%d\n", m, tTrim[m]);
for (int m = 0; m < 4; m++) printf("trigEq%d,%d\n", m, tEq[m]);
for (int m = 0; m < 4; m++) printf("trigLl%d,%d\n", m, tLl[m]);
printf("backCases,%d\n", backCases);
printf("backBad,%d\n", backBad);
printf("selfCases,%d\n", selfCases);
printf("selfCaught,%d\n", selfCaught);
printf("topLen,%lld\n", topLen);
return 0;
}
printf("=== 四个档位 × 每个 bug 的触发条件(各 300 轮)===\n");
printf("%-40s %6s %6s %6s %6s\n", "触发条件", "档0", "档1", "档2", "档3");
printf("%-40s %6d %6d %6d %6d\n", "a < b(照抄本章 sub 会错)", tSwap[0], tSwap[1], tSwap[2], tSwap[3]);
printf("%-40s %6d %6d %6d %6d\n", "字典序把大小判反了", tLex[0], tLex[1], tLex[2], tLex[3]);
printf("%-40s %6d %6d %6d %6d\n", "答案位数变少(前导零)", tTrim[0], tTrim[1], tTrim[2], tTrim[3]);
printf("%-40s %6d %6d %6d %6d\n", "a == b(会打出 -0)", tEq[0], tEq[1], tEq[2], tEq[3]);
printf("%-40s %6d %6d %6d %6d\n", "有数超过 19 位(64 位装不下)", tLl[0], tLl[1], tLl[2], tLl[3]);
printf("\n=== 验算:把答案加回去(|a−b| + min == max)===\n");
printf("%d 组里对不上的:%d 组\n", backCases, backBad);
printf("★ 自检:同一段验证器去验「照抄本章 sub()」那一版 —— %d 组里抓出 %d 组\n", selfCases, selfCaught);
printf("\n顶格:%lld 位的减法,%lld 次一位数减法 —— 时限 1 秒,秒表在这一章没用\n", topLen, topLen);
return 0;
}
点一下即可编辑
输出
点「运行 ▶」看结果
★ 正解那一版就能过 —— 而这道题的四个关卡里,三个跟借位无关
写法 顶格代价 交上去
64 位整数 O(1) 20 分
★ 比大小 + 一次借位扫描 10087 次一位数减法 AC

秒表在这一章是没用的:一万位的减法和时限 1 秒之间差着好几个数量级。 这一页从头到尾没有一个秒数

⇒ 这道题真正的四个关卡:比大小要比位数结果为负要打负号结果要去前导零结果为 0 不能打 -0。 四个里只有第二个跟「减法怎么算」有关,另外三个是格式和边界。 ⚠ 而官方样例一个都挡不住,顺手写的生成器只挡得住两个。