题单 · 习题解析

洛谷 P1601 A+B Problem(高精)

★★ 高精度加法的模板题,而三个坑**一个都不在算法里**:**倒着存**(第一次写多半会顺着存,于是「一位一位加」变成了把两个数**左对齐** —— 触发条件精确到「两个数位数不同」,等长时它完全正确)、**`|| carry`**(少了它算出来的恒等于 `(a+b) mod 10^max(位数)`)、以及 **`0` 是合法输入**(题面第一行就写着 `0 ≤ a, b`,而 trim 写成 `!empty()` 会把 `0 + 0` 打成一个空行);★★★ 这一页最值钱的是那张四档表:**同一个旋钮(两个数等不等长)把两个 bug 推向相反方向** —— 等长把「左对齐」打成**能证的精确的 0**,却把「少了 `|| carry`」从 23 顶到 **190**;⚠⚠ 而**顺手写的那一档同时是两个 bug 的弱区**:`|| carry` 只有 23/300,`trim` 那个是**结构性的精确的 0**(随机造「首位非零的正整数」**一辈子造不出 0**);★★ 十六格「触发 ≡ 抓获」一个不差,**而这次「≡」不是运气** —— 三个 bug 都能写成一句精确的等式,写得准之后 ≡ 是必然的;★ 顺带两笔算术:题面 20% 档 `a,b ≤ 10⁹` ⇒ **连 int 都恰好够、余量 7.37%**,40% 档 ⇒ 64 位整数**明码标价 40 分**;⚠ 而官方两组样例只挡住一个错法(靠样例二 `1001+9099` 那次进位)

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

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

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

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

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

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

题目背景

本题是高精度加法的模板题。

题目描述

给定两个非负整数 a, b,求它们的和。不用考虑负数

输入格式

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

输出格式

输出一行一个非负整数,表示 a + b 的值。

数据范围

对于 20% 的测试数据,a, b ≤ 10⁹

对于 40% 的测试数据,a, b ≤ 10¹⁸

对于 100% 的测试数据,0 ≤ a, b ≤ 10⁵⁰⁰

时限 1 秒,内存 512 MB(524288 KB)。

输入输出样例

输入

1
1

输出

2

样例一:1 + 1 = 2

输入

1001
9099

输出

10100

样例二:1001 + 9099 = 10100。⚠ 这一组不是上一组的重复 —— 见第 4 步那张表。

1第 ① 版:读进来直接加 —— 而它值 40 分,是明码标价的

★★ 题面那三行数据范围,第一行就是「暴力值多少分」的答案

这道题的数据范围写了三档,而它们不是背景,是三件工具

意味着什么
20% 的数据 a, b ≤ 10⁹ 和 ≤ 2×10⁹int 上限 2147483647 ⇒ ★ 连 int 都恰好够,余量 7.37%
40% 的数据 a, b ≤ 10¹⁸ 和 ≤ 2×10¹⁸long long 上限 9.22×10¹⁸余量 4.61 倍
100% 的数据 a, b ≤ 10⁵⁰⁰ 501 位,而 64 位整数只有 19~20 位

一个 64 位整数读进来直接加,稳拿 40 分。 ★ 这不是「碰巧能骗分」,是出题人把它写在题面上递过来的。

⚠ 顺带看一眼它在大数据上怎么错cin >> x 读一个 501 位的数会读失败 —— C++11 起 operator>>x 置成该类型的最大值并设 failbit,不崩溃、也不是随机值。 ⇒ 它会安静地打出一个巨大的常数。「它没崩」离「它对了」还差很远。

p1601Ll.cpp✗ 第 ① 版:一个 64 位整数读进来直接加 —— 40 分
// ✗ 第 ① 版:所有人真实的第一反应 —— 一个 64 位整数读进来直接加
//
// 它不是瞎写:题面的数据范围**自己把它的分数写出来了**。
//
// · 20% 的数据 a, b ≤ 10⁹ ⇒ 和 ≤ 2×10⁹,**连 int 都恰好装得下**(上限 2147483647,余量 7.4%)
// · 40% 的数据 a, b ≤ 10¹⁸ ⇒ 和 ≤ 2×10¹⁸ < 9.22×10¹⁸ ⇒ **long long 稳稳的**
// · 100% 的数据 a, b ≤ 10⁵⁰⁰ ⇒ 501 位,而 64 位整数只有 19~20 位 ⇒ **差 25 倍位数**
//
// ⇒ 这一版**稳拿 40 分**,而且是出题人明码标价递给你的 40 分。
//
// ⚠ 两处写法上的说明,都是为了让「它错成什么样」可复现:
// ① 用 unsigned long long 而不是 long long —— **有符号溢出是 UB**,
// 演示一个错误写法的时候不能让它的输出依赖编译器([第 45 章](/ch/45-estimate/)那条);
// ② `cin >> x` 读一个 501 位的数会**读失败**:C++11 起它把 x 置成该类型的最大值并设 failbit
// (不是随机值,也不崩溃)⇒ **「它没崩」离「它对了」还差很远。**
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
unsigned long long a = 0, b = 0;
cin >> a >> b;
cout << a + b << "\n"; // unsigned 的溢出是「模 2⁶⁴ 绕回」,有定义
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

2第 ② 版:改成一位一位地加 —— 而第一次写,多半会写成左对齐

★★★ 「数字读进来是字符串,下标 0 就是最高位」—— 这一句是本章一切错的病根

数读进来是 string,谁都会顺手写 a[i] = s[i] - '0'。 于是下标 0 成了最高位,而接着「一位一位加起来」的时候,人会按下标配对 —— 也就是把两个数左对齐了:

    12345          12345
  +   67    ==>  + 67          <- 67 被当成了 67000
  -------        -------

竖式是右对齐的(个位对个位),而这种存法下下标 0 离个位最远。

⇒ ★★ 第 44 章第 5 步那句「倒着存」,全部理由就是这个: 让下标和「这一位有多大」对上,右对齐就变成免费的, 乘法里那句 c[i+j] 也几乎是抄的。

★ 而这个错法的触发条件精确到一句话:两个数的位数不同。 位数相同时左对齐 ≡ 右对齐 ⇒ 它在等长输入上是完全正确的 (下面生成器档 1 量的就是这件事,那是一个能证的精确的 0)。

p1601Align.cpp✗ 错法一:顺着存 + 从下标 0 开始对齐 —— 位数不同就错

3★ 正解 —— 加上两处「不是算法」的细节

★ 倒着存之后,加法只剩三句话;而两处细节都不在算法里
for (i = 0; i < a.size() || i < b.size() || carry; i++) { ... }
  • || carry —— 「最高位还要再进一位」那一格(999 + 1 = 1000)。 少了它,算出来的恒等于 (a + b) mod 10^max(la, lb)
  • trim 的条件是 size() > 1,不是 !empty() —— 差一个字,0 会被删成空数组,输出变成一个空行。 ⚠ 而题面写的是 0 ≤ a, b0 0 是一组完全合法的输入

★ 而「结果最多多长」是能证也能数的:a + b 的位数只可能是 max(la, lb) 或者它 + 1(全枚举 a, b ≤ 999 共 100 万组,0 组例外)。

p1601.cpp★ 正解:倒着存的十进制数组,一次 O(n) 加法
// P1601 A+B Problem(高精)—— ★ 正解:倒着存的十进制数组,一次加法
//
// 这一份就是[第 44 章第 7 步](/ch/44-bignum/)那个 add() 原样搬过来,
// 只是把「同时输出加减乘」换成了这道题要的那一行。
//
// 表示约定(和本章正文一字不差):
// · vector<int>,一格一位十进制数字,**下标 0 是个位**;
// · 允许中途出现前导零(数组末尾的 0),最后统一由 trim() 去掉;
// · **0 就是 {0}**,长度 1 —— trim() 里 `size() > 1` 那个 1 的全部理由。
//
// ⚠ 这道题的两个坑都不在算法里:
// ① 题面写的是 `0 ≤ a, b` —— **0 是合法输入**,而 `0 + 0` 要打出一个 `0`,不是空行;
// ② 循环条件里那个 `|| carry` ——「最高位再进一位」(999 + 1 = 1000)少了它就少一位。
//
// 复杂度 O(n),n ≤ 501 ⇒ 什么都不用担心。
#include <bits/stdc++.h>
using namespace std;
using Big = vector<int>;
Big parse(const string& s) {
Big v;
for (int i = (int)s.size() - 1; i >= 0; i--) v.push_back(s[i] - '0');
return v;
}
string show(const Big& v) {
string s;
for (int i = (int)v.size() - 1; i >= 0; i--) s += char('0' + v[i]);
return s;
}
/** ★ 条件是 size() > 1,不是 !empty():差一个字,0 就被删成空数组 */
void trim(Big& v) {
while (v.size() > 1 && v.back() == 0) v.pop_back();
}
Big add(const Big& a, const Big& b) {
Big c;
int carry = 0;
// ★ `|| carry` 就是「最高位还要再进一位」那一格
for (size_t i = 0; i < a.size() || i < b.size() || carry; i++) {
int cur = carry;
if (i < a.size()) cur += a[i];
if (i < b.size()) cur += b[i];
c.push_back(cur % 10);
carry = cur / 10;
}
trim(c);
return c;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
string sa, sb;
if (!(cin >> sa >> sb)) return 0;
cout << show(add(parse(sa), parse(sb))) << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
p1601NoCarry.cpp✗ 错法二:循环条件少了 || carry —— 恒等于「答案去掉最高位的那次进位」
p1601TrimAll.cpp✗ 错法三:去前导零写成 !empty() —— 0 + 0 打出空行

4⚠ 官方给了两组样例 —— 而它们只挡住了三个错法里的一个

⚠ 两组样例 × 四个待测版本
版本 样例一 1 + 1 样例二 1001 + 9099 结论
★ 正解 2 10100
✗ 64 位整数 2 10100 全放过(这两组小得很)
✗ 左对齐 2 10100 全放过 —— ⚠ 两组样例里两个加数都是等长的
✗ 少了 || carry 2 100 样例二一测就死1001 + 9099 恰好进到第 5 位)
✗ trim 删过头 2 10100 全放过(这两组的答案都不是 0)

⇒ ★★ 「官方给了几组就跑几组,它们不是同一件事的重复」的又一次: 样例一什么都问不出来,样例二挡住了一个。 ⚠ 而「左对齐」被放过的原因很具体 —— 不是样例太小,是两组样例的两个加数都恰好一样长「这组样例在结构上问不出这个问题」)。

5★ 对拍:参照物要换一种表示,否则同一个笔误两边一起犯

★ 参照物用「压位」写 —— 顺带把「要不要 long long」的主语说清楚了

这一章的错几乎全在下标和边界上。两份代码要是连表示都一样, 同一个笔误会同时犯 —— 对拍等于没做(本章 brute.cpp 顶上那条规矩)。

所以参照物换成一格存 9 位十进制(base = 10⁹):分组方式、进位时机、 去前导零的位置、输出的写法(★ 除最高格外每格都要 %09llu全都不一样

⚠ 而它顺手称出一件本章没说的事:压位之后中间值是另一笔账。 一格一位时每格最大 9 + 9 + 1 = 19;压 9 位之后是 999999999 × 2 + 1 = 1999999999 —— ★ 而这和上面那张表里 20% 档那笔账是同一个算式int 恰好还够(余量 7.4%), 再压一位就当场不够

⇒ ★★ 「要不要 long long」不是数字大小的问题,是「你把几位塞进一格、又拿它做什么运算」的问题。 (这句话在下一道 P1303 上会再用一次,而那次的差距更狠: 换成乘法之后,压到一格四位就已经从 16 万跳到 500 亿。)

p1601Pack.cpp参照物:压位加法(base = 10⁹)—— 1200 轮 0 次不一致
p1601Gen.cpp(四档)生成器:顺手写的 / 等长 / 含 0 / 顶格 501 位
★★★ 四档 × 四个版本 —— 十六格「触发 ≡ 抓获」一个不差
档位(每档 300 轮) ✗ 64 位 ✗ 左对齐 ✗ 少了 || carry ✗ trim 删过头
0 ★ 顺手写的(位数各自随机 1~12) 0 275 23 精确的 0
1 ⚠ 两个数等长 0 精确的 0 190 精确的 0
2 ★ 含 0(各 1/2 概率取 0 0 78 3 68
3 ★ 顶格(495~501 位) 300 259 34 精确的 0

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

  1. ★★★ 同一个旋钮(两个数等不等长)把两个 bug 推向相反方向 —— 等长把「左对齐」打成能证的精确的 0(左对齐 ≡ 右对齐), 却把「少了 || carry」从 23 顶到 190(8.3 倍)。 ⚠ 道理一句话:位数不同的时候,短的那个几乎不可能把和顶出一位去。 ⇒ 同一个旋钮推两个 bug 反向这件事,本书至此第七次。
  2. ★★★ 顺手写的那一档,同时是两个 bug 的弱区 —— 「少了 || carry」只有 23 / 300,而「trim 删过头」是结构性的精确的 0: 随机造「首位非零的正整数」的生成器一辈子造不出 0, 而题面第一行就写着 0 ≤ a, b,题单注解还专门点了 0 + 0 那一组。 ⇒ 「生成器最自然的默认值往往正是某个 bug 的藏身处」
  3. ★★ 那十六格「触发 ≡ 抓获」全部一个不差,而这一次「≡」不是运气 —— 高精度这一章的三个 bug 都能写成一句精确的等式 (左对齐算的是「短的那个乘上 10 的若干次方」、少了 || carry 算的是 (a+b) mod 10^max、 trim 删过头只在答案为 0 时发作)。 ⇒ ★★★ 能把「它算了什么」写成式子,触发条件就写得准;写得准之后,「≡」是必然的。第 35 章 P4147 那次写不成 ≡,恰恰因为那个 bug 有两种效应叠在一起。)

6★ 哪一版就已经能过了

p1601Count.cpp本页所有数字的出处
// P1601 解析页上所有数字的出处。./p1601Count [csv]
//
// 四件事:
// ① 题面那三档数据范围各自意味着什么(20% 档连 int 都恰好够,余量算出来);
// ② 四个档位里每个 bug 的**触发条件**各满足了多少轮(用来和对拍的抓获数逐格对);
// ③ 「a + b 的位数只可能是 max(la, lb) 或者它 + 1」—— 小范围全枚举,0 组例外;
// ④ 顶格一次加法要做多少次一位数加法。
#include <bits/stdc++.h>
using namespace std;
/** 高位在前的字符串加法(只在这份统计程序里用,和被测的几版都不共享代码) */
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);
}
/** 和 p1601Gen.cpp 一模一样的一份,用来数「触发条件满足了多少轮」 */
static void gen(unsigned seed, int mode, string& a, string& b) {
mt19937 rng(seed * 1000003u + 20260904u);
int lo = mode == 3 ? 495 : 1, hi = mode == 3 ? 501 : 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) 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); b = one(lb);
if (mode == 2) {
unsigned z1 = rng() % 2u, z2 = rng() % 2u;
if (z1) a = "0";
if (z2) b = "0";
}
}
int main(int argc, char** argv) {
bool csv = argc > 1 && string(argv[1]) == "csv";
// ① 题面那三档
const long long INT_MAXV = 2147483647LL;
long long sum20 = 2000000000LL; // 20% 档:a, b ≤ 10⁹ ⇒ 和 ≤ 2×10⁹
double intMargin = 100.0 * (double)(INT_MAXV - sum20) / (double)sum20;
unsigned long long llMax = 9223372036854775807ULL;
double llRatio = (double)llMax / 2e18; // 40% 档:和 ≤ 2×10¹⁸
// ③ 位数上界:小范围全枚举
int bad = 0, hitPlus1 = 0, total = 0;
for (int a = 0; a <= 999; a++)
for (int b = 0; b <= 999; b++) {
string sa = to_string(a), sb = to_string(b), sc = addStr(sa, sb);
size_t mx = max(sa.size(), sb.size());
total++;
if (sc.size() != mx && sc.size() != mx + 1) bad++;
if (sc.size() == mx + 1) hitPlus1++;
}
// ② 四个档位的触发计数
int tAlign[4] = {0, 0, 0, 0}, tCarry[4] = {0, 0, 0, 0}, tTrim[4] = {0, 0, 0, 0}, tLl[4] = {0, 0, 0, 0};
for (int mode = 0; mode < 4; mode++)
for (unsigned s = 1; s <= 300; s++) {
string a, b;
gen(s, mode, a, b);
string c = addStr(a, b);
size_t mx = max(a.size(), b.size());
// ★ 左对齐会错的条件是**两层**:位数不同 **而且**短的那个不是 0
// (短的是 0 时,左对齐只是把 0 挪到了高位,加上去还是 0 ⇒ 它照样对)
if (a.size() != b.size() && a != "0" && b != "0") tAlign[mode]++;
if (c.size() > mx) tCarry[mode]++; // 最高位真的进了一位
if (a == "0" && b == "0") tTrim[mode]++; // 答案是 0
if (a.size() > 20 || b.size() > 20) tLl[mode]++; // 64 位整数读不进来
}
long long topOps = 501 + 1; // 顶格:最多 502 次一位数加法
if (csv) {
printf("intMargin,%.2f\n", intMargin);
printf("llRatio,%.2f\n", llRatio);
printf("lenCases,%d\n", total);
printf("lenBad,%d\n", bad);
printf("lenPlus1,%d\n", hitPlus1);
printf("topOps,%lld\n", topOps);
for (int m = 0; m < 4; m++) printf("trigAlign%d,%d\n", m, tAlign[m]);
for (int m = 0; m < 4; m++) printf("trigCarry%d,%d\n", m, tCarry[m]);
for (int m = 0; m < 4; m++) printf("trigTrim%d,%d\n", m, tTrim[m]);
for (int m = 0; m < 4; m++) printf("trigLl%d,%d\n", m, tLl[m]);
return 0;
}
printf("=== 题面那三档数据范围,各自是什么意思 ===\n");
printf("20%% 档 a, b <= 1e9 => 和 <= %lld,int 上限 %lld,**恰好够**,余量只有 %.2f%%\n",
sum20, INT_MAXV, intMargin);
printf("40%% 档 a, b <= 1e18 => 和 <= 2e18,long long 上限 %llu,余量 %.2f 倍\n", llMax, llRatio);
printf("100%% 档 a, b <= 1e500 => 501 位,64 位整数只有 19~20 位\n\n");
printf("=== 位数上界:len(a+b) 只可能是 max(la,lb) 或者它 + 1 ===\n");
printf("全枚举 a, b <= 999 共 %d 组:例外 %d 组;其中真的多出一位的 %d 组\n\n", total, bad, hitPlus1);
printf("=== 四个档位 × 每个 bug 的触发条件(各 300 轮)===\n");
printf("%-34s %6s %6s %6s %6s\n", "触发条件", "档0", "档1", "档2", "档3");
printf("%-34s %6d %6d %6d %6d\n", "位数不同且都不是 0(左对齐会错)", tAlign[0], tAlign[1], tAlign[2], tAlign[3]);
printf("%-34s %6d %6d %6d %6d\n", "最高位真的进了一位(|| carry)", tCarry[0], tCarry[1], tCarry[2], tCarry[3]);
printf("%-34s %6d %6d %6d %6d\n", "答案是 0(trim 删过头)", tTrim[0], tTrim[1], tTrim[2], tTrim[3]);
printf("%-34s %6d %6d %6d %6d\n", "有数超过 20 位(64 位读不进)", tLl[0], tLl[1], tLl[2], tLl[3]);
printf("\n顶格一次加法:最多 %lld 次一位数加法 —— 时限 1 秒,量级差得太远,秒表在这一章没用\n", topOps);
return 0;
}
点一下即可编辑
输出
点「运行 ▶」看结果
★ 第 ③ 版(正解)就能过,而这道题真正的关卡一个都不在算法上
写法 顶格代价 交上去
64 位整数 O(1) 40 分(题面写好的)
★ 倒着存的一位一位加 最多 502 次一位数加法 AC
压位(base 10⁹) 56 次 ★ AC,但一分钱不值

秒表在这一章是没用的第 44 章第 4 步):502 次加法和时限 1 秒之间 差了六七个数量级,量什么都是噪声。这一页从头到尾没有一个秒数,只有次数和位数。

⇒ 这道题真正要学的是三件和算法无关的事,而三件都只有一句话: 倒着存(否则右对齐要写成 n-1-i)、|| carry(最高位那一次进位)、 0 是合法输入trim 的条件是 size() > 1)。 ⚠ 而后两件,官方样例和顺手写的生成器加起来只挡得住一件