阶段 9 · 补课 · 第 44 章普及组 J

高精度:加减乘,以及「为什么不能用 long long」

★ 关键一步是「进位和借位是一条链,不是一次判断」。⚠ 而这一章真正难的地方不在算法 —— 六个错法里有三个,随机数据一辈子都撞不到:结果正好是 0、结果位数变短、位数大到 64 位装不下。

需要先学:第 5 章 枚举与模拟例题:500 位以内的 a+b、a−b、a×b建议用时:110 分钟
这一章补的是一个洞,不是加一块进阶内容

高精度是 CSP-J 的必考点,可它在前 43 章里一次都没有独立出现过 —— 只在几条练习的备注里被顺带提到。 这一章把它补上。

★ 所以它的位置有点特别:它不依赖后面任何一章,学完第 5 章(枚举与模拟)之后随时可以来看。 排在最后只是因为它补得晚。

⚠ 而它值得单独一章,不是因为算法难 —— 竖式你小学就会了。是因为:

这一章真正难的 难在哪
进位 / 借位是一条链 不是「不够减就跟上一位借一次」,是要一路传下去
前导零 删少了 1000−999 变成 0001;删多了 5−5 变成一个空行
★★ 这三个错,随机数据撞不到 结果正好是 0、结果位数变短、位数超过 64 位 —— 生成器不专门去造,对拍能一直全绿

最后那一行才是这一章的主课。前面 43 章反复在说「造数据比算法本身更容易骗过你」, 这一章是那句话最干净的一次现场。

1一句话问题

给两个非负整数 ab(各不超过 500 位,没有前导零,保证 a ≥ b),输出四行:

  • 第 1 行:a + b
  • 第 2 行:a − b
  • 第 3 行:a × b
  • ★ 第 4 行:上面三个答案各有多少位(空格隔开;规定 0 是 1 位)

输入

987
123

输出

1110
864
121401
4 3 6

987 + 123 = 1110(4 位)、987 − 123 = 864(3 位)、987 × 123 = 121401(6 位)。

★ 第 4 行那个「位数」不是凑数的:它是专门用来照前导零的。 答案写成 0001 还是 1,值一样、位数不一样 —— 后面会看到,有两个 bug 就活在这个差别里。

⚠ 500 位是什么概念

long long 能表示的最大值大约是 9.2 × 10¹⁸,也就是 19 位。 500 位比它大 480 个数量级 —— 这不是「换个更大的类型」能解决的问题, C++ 里根本没有能装下它的内置类型(__int128 也只有 38 位)。

⇒ 所以只能自己造一个:用数组存每一位数字,自己实现加减乘。这就是「高精度」。

2先用纸笔算一遍,然后做一个决定

竖式你小学就会了:

      9 8 7
    + 1 2 3
    ---------
    1 1 1 0

从个位开始:7+3=10,写 0 进 1;8+2+1=11,写 1 进 1;9+1+1=11,写 1 进 1;最后那个进位单独占一位。

把它翻译成代码,只有一个决定是关键的

★ 数组下标 0 存个位(也就是把数字倒过来存)
   写在纸上:      9   8   7
   数组下标:      2   1   0
   数组里存的:    9   8   7      ← v = {7, 8, 9},v[0] 是个位

为什么不顺着存(下标 0 存最高位)?因为竖式的三件事全是从个位开始的

  • 加法从个位往上进位;
  • 减法从个位往上借位;
  • 乘法里「a 的第 i 位 × b 的第 j 位」要落到第 i+j 位上。

顺着存的话,这三句话里的下标全都要写成 n-1-i 那种形状 —— 一处写错就全错, 而且错得很难看出来。倒过来存之后,下标就是「这一位有多大」i + j 那一句几乎是照着数学抄下来的。

⚠ 代价是:读进来要反着放,打印时要反着输出。这两处各写一次,比后面每一行都写 n-1-i 划算得多。

3「那就用 long long 啊」—— 这个想法快,而且是错的

这一章的「暴力」和全书别的章不一样。别的章的暴力是对但慢,这一份是快但错

ull.cpp⚠ 64 位整数版:快,但会算错
// 「那就用 long long 啊」—— 这份代码就是那个想法,它跑得飞快,而且**是错的**
//
// 这一章的「暴力」和全书别的章不一样:别的章的暴力是**对但慢**,
// 这一份是**快但错**。所以第 4 步的实测不是量时间,是量它**从第几位开始给出错误答案**。
//
// ⚠ 为什么用 unsigned long long 而不是 long long:
// 有符号整数溢出在 C++ 里是**未定义行为**(第 10 章那条)——
// 未定义行为的结果随编译器、优化等级、机器而变,**没法写进断言,也没法印在教材里**。
// 而无符号整数溢出是**有定义的**:结果对 2⁶⁴ 取模。
// ⇒ 换成 unsigned 之后,这份「错」是确定的、可复现的、可以钉成断言的错。
// ★ 这不是在护着它。恰恰相反:**连最讲道理的那个版本都错得这么早**,才说明问题。
//
// 它错在哪一步,limits.cpp 一档一档量给你看。
#include <bits/stdc++.h>
using namespace std;
/** 逐位读进 unsigned long long。超过 2⁶⁴ 之后自动对 2⁶⁴ 取模(这是标准规定的行为) */
unsigned long long parseUll(const string& s) {
unsigned long long v = 0;
for (char c : s) v = v * 10ULL + (unsigned long long)(c - '0');
return v;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
string sa, sb;
if (!(cin >> sa >> sb)) return 0;
unsigned long long a = parseUll(sa), b = parseUll(sb);
unsigned long long s = a + b, d = a - b, p = a * b;
string ss = to_string(s), sd = to_string(d), sp = to_string(p);
cout << ss << "\n" << sd << "\n" << sp << "\n";
cout << ss.size() << " " << sd.size() << " " << sp.size() << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

小数据上它完全正确 —— 这正是它危险的地方。自己拧一下位数看看

同题对比:⚠ 64 位整数版 vs ✓ 高精度版
先跑 8(两边一模一样),再改成 10 —— 第三行(乘积)会当场分家。再改成 20,连第一行都不一样了。
⚠ 64 位整数版
✓ 高精度版

★ 8 位的时候两边逐字节相同,10 位就开始不一样 —— 而这中间没有任何警告、没有崩溃, 它只是安静地给出一个错的答案。所以第一件事不是量它多快, 而是量它从第几位开始给出错误答案

limits.cpp一档一档试:a = b = k 个 9
让 a = b = 一串 k 个 9(同位数里最大的数),k 从 1 数上去,看 64 位整数从哪一档开始和高精度对不上。
// 量一件事:unsigned long long 到底从第几位开始给出错误答案
//
// 做法:让 a = b = 一串 k 个 9(k 从 1 数上去),拿两条路各算一遍 a+b 和 a×b:
// · 一条是 ull.cpp 那条(64 位整数,溢出后对 2⁶⁴ 取模);
// · 一条是 fast.cpp 那条(高精度,永远对)。
// 第一次对不上的那个 k,就是「64 位整数在这道题上的极限」。
//
// ★ 为什么选「全是 9」:它是同样位数里最大的数,最早顶到上界 —— 量上限就该拿它量。
// 换成随机的 k 位数,崩掉的位置会晚一两位,而且每次不一样,那样的数字没法写进教材。
//
// 用法:./limits [最大位数=25]
// 输出:每行 `k 和是否一致 积是否一致`,最后两行是两个「第一次出错的 k」。
#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;
}
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;
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;
}
Big mul(const Big& a, const Big& b) {
Big c(a.size() + b.size(), 0);
for (size_t i = 0; i < a.size(); i++)
for (size_t j = 0; j < b.size(); j++)
c[i + j] += a[i] * b[j];
int carry = 0;
for (size_t i = 0; i < c.size(); i++) { int cur = c[i] + carry; c[i] = cur % 10; carry = cur / 10; }
trim(c);
return c;
}
unsigned long long parseUll(const string& s) {
unsigned long long v = 0;
for (char c : s) v = v * 10ULL + (unsigned long long)(c - '0');
return v;
}
int main(int argc, char** argv) {
int maxK = (argc > 1) ? atoi(argv[1]) : 25;
if (maxK < 1) maxK = 1;
if (maxK > 40) maxK = 40;
int badAdd = 0, badMul = 0;
printf("位数k a=b=k个9 和一致 积一致\n");
for (int k = 1; k <= maxK; k++) {
string s(k, '9');
Big a = parse(s);
string trueAdd = show(add(a, a)), trueMul = show(mul(a, a));
unsigned long long u = parseUll(s);
string ullAdd = to_string(u + u), ullMul = to_string(u * u);
bool okAdd = (ullAdd == trueAdd), okMul = (ullMul == trueMul);
if (!okAdd && !badAdd) badAdd = k;
if (!okMul && !badMul) badMul = k;
printf("%5d %s %s\n", k, okAdd ? "✓" : "✗", okMul ? "✓" : "✗");
}
printf("\n和:第一次算错是 %d 位\n", badAdd);
printf("积:第一次算错是 %d 位\n", badMul);
return 0;
}
点一下即可编辑
输出
点「运行 ▶」看结果
★★ 实测结果:加法撑到 19 位,乘法只撑到 10 位
运算 64 位整数第一次算错
a + b 19 位
a × b 10 位

第二行才是要命的那一行。十位数相乘就崩 —— 而十位数在题目里太常见了 (一个 10⁹ 级别的数就是 10 位)。

道理很简单:unsigned long long 的上界是 1.8 × 10¹⁹, 而两个 10 位数相乘是 10²⁰ 量级 —— 一乘就出去了。 ⇒ 判断该不该用高精度,看的不是输入多大,是「中间结果」多大。

⚠ 这里用的是 unsigned long long 而不是 long long,是有意的: 有符号整数溢出是未定义行为(第 10 章那条),它的结果随编译器、优化等级、机器而变, 没法写进断言,也没法印在教材里;而无符号溢出是有定义的(对 2⁶⁴ 取模)。 ⇒ 换成 unsigned 之后,上面这两个数字是确定的、可复现的、能钉成断言的。 ★ 这不是在护着它 —— 恰恰相反:连最讲道理的那个版本都错得这么早,才说明问题。

4⚠ 秒表在这一章是没用的

按全书的惯例,这一步该实测「暴力有多慢」。但这一章量不出来:

500 位的加减乘,在本机跑完是 0.00 秒 —— 秒表的读数几乎全是进程启动 (第 23 章那条「秒表彻底失效」的第二次现场)。

⇒ 那就换一把尺子:不数秒,数「一位数运算做了多少次」。

count.cpp换尺子:数一位数运算的次数
两个数都取全 9(同位数里最大)。次数是在算法里放计数器真数出来的,不是套公式印的。
// 换一把尺子:数「一位数运算」做了多少次
//
// 秒表在这一章基本没用 —— 500 位的加减乘在本机都是零点零几秒,读数几乎全是进程启动
// (第 23 章那条「秒表彻底失效」的第二次现场)。而这一章的三种运算,工作量是**能数出来的**:
//
// 加法 / 减法:O(n) —— 每一位做一次一位数加/减
// 乘法 :O(nm) —— a 的每一位都要和 b 的每一位相乘
//
// ⚠ 这里的次数是**真数出来的**(在算法里放计数器),不是套公式印出来的。
// 两者对不上就说明我对自己的代码理解错了 —— 这正是这一节要防的事。
//
// 用法:./count <n> <m> [csv] n、m 是两个数的位数
// 默认打一张人看的表;带 csv 就只打 `键,值`,给 check:viz 用。
#include <bits/stdc++.h>
using namespace std;
using Big = vector<int>;
static long long cntAdd = 0, cntSub = 0, cntMul = 0, cntCarry = 0;
Big mkNines(int k) { return Big(k, 9); } // k 个 9:同位数里最大的数
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;
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];
cntAdd++; // ← 一次一位数加法
c.push_back(cur % 10);
carry = cur / 10;
}
trim(c);
return c;
}
Big sub(const Big& a, const Big& b) {
Big c;
int borrow = 0;
for (size_t i = 0; i < a.size(); i++) {
int cur = a[i] - borrow - (i < b.size() ? b[i] : 0);
cntSub++; // ← 一次一位数减法
borrow = 0;
if (cur < 0) { cur += 10; borrow = 1; }
c.push_back(cur);
}
trim(c);
return c;
}
Big mul(const Big& a, const Big& b) {
Big c(a.size() + b.size(), 0);
for (size_t i = 0; i < a.size(); i++)
for (size_t j = 0; j < b.size(); j++) {
c[i + j] += a[i] * b[j];
cntMul++; // ← 一次一位数乘法
}
int carry = 0;
for (size_t i = 0; i < c.size(); i++) {
int cur = c[i] + carry;
cntCarry++; // ← 统一进位那一遍
c[i] = cur % 10;
carry = cur / 10;
}
trim(c);
return c;
}
int main(int argc, char** argv) {
int n = (argc > 1) ? atoi(argv[1]) : 500;
int m = (argc > 2) ? atoi(argv[2]) : 500;
bool csv = (argc > 3) && string(argv[3]) == "csv";
if (n < 1) n = 1;
if (m < 1) m = 1;
if (n > 5000) n = 5000;
if (m > 5000) m = 5000;
if (m > n) swap(n, m); // 保证 a >= b(都是 9 的话位数多的更大)
Big a = mkNines(n), b = mkNines(m);
add(a, b);
sub(a, b);
mul(a, b);
if (csv) {
printf("n,%d\n", n);
printf("m,%d\n", m);
printf("add,%lld\n", cntAdd);
printf("sub,%lld\n", cntSub);
printf("mul,%lld\n", cntMul);
printf("carry,%lld\n", cntCarry);
return 0;
}
printf("两个数:%d 位 和 %d 位(都取全 9,同位数里最大)\n\n", n, m);
printf(" 加法:一位数加法 %lld 次 (≈ n+1,最后那一次是最高位的进位)\n", cntAdd);
printf(" 减法:一位数减法 %lld 次 (= n)\n", cntSub);
printf(" 乘法:一位数乘法 %lld 次 (= n×m),之后统一进位再扫 %lld 次(= n+m)\n", cntMul, cntCarry);
printf("\n★ 加减是 O(n),乘是 O(nm) —— %d×%d 的乘法是加法的 %lld 倍。\n",
n, m, cntMul / max(1LL, cntAdd));
return 0;
}
点一下即可编辑
输出
点「运行 ▶」看结果
★ 500 位 × 500 位 = 25 万次一位数乘法
运算 次数(n = m = 500) 形状
加法 501 O(n)(多出来的 1 次是最高位的进位)
减法 500 O(n)
乘法 250 000 O(nm)
乘法后的统一进位 1 000 O(n+m)

加减是线性的,乘法是平方的 —— 500 位时乘法是加法的 499 倍。 25 万次对现在的机器来说仍然是一瞬间,所以 500 位这道题怎么写都能过。 但把位数放大,平方就开始咬人了。

想看平方长什么样,就把位数拧大

本机实测(i5-13500H / WSL2,2026-08-25,单进程独占,每档跑三次取中位数; 数据来自 ./genBig <位数>,种子固定,你可以原样复现):

g++ -O2 -std=c++17 -o genBig genBig.cpp && g++ -O2 -std=c++17 -o fast fast.cpp
./genBig 8000 > big.txt
time ./fast < big.txt > /dev/null
位数 一位数乘法次数 耗时 比上一行
500 250 000 量不出来(0.00 秒) ——
8 000 6400 万 0.02 秒 ——
16 000 2.56 亿 0.08 秒 ×4
32 000 10.2 亿 0.38 秒 ×4.75
64 000 41 亿 1.45 秒 ×3.8

位数翻倍,时间乘四 —— 这就是 O(n²) 在秒表上的样子。 ⚠ 秒数换台机器一定会变,但「比上一行」那一列不会。要记的是那一列。

5★ 关键的一步:进位和借位是一条链

★ 一句话:上一位欠的账,这一位必须先还

写加法时,几乎每个人第一遍都会写对;写减法时,很多人会写成这样:

int cur = a[i] - b[i];
if (cur < 0) cur += 10;      // ⚠ 借了 10,可上一位那边没人记这笔账
c.push_back(cur);

它在 95 − 8 上是对的,在 1000 − 1 上会算出 1009

因为「借」是有代价的:这一位多拿了 10,上一位就要少 1。 而上一位可能是 0,于是它又得向再上一位借 —— 借位是一条链1000 − 1 要从个位一路借到最高位。

⇒ 正确的写法是让 borrow 变成循环里带着走的变量:

int cur = a[i] - borrow - (i < b.size() ? b[i] : 0);
borrow = 0;
if (cur < 0) { cur += 10; borrow = 1; }

★ 加法那边一模一样:carry 也要一路传,而且循环条件里要带上 || carry —— 999 + 1 的进位会传到最高位之外,答案要多出一位。

下面这份把每一位的账都打出来了,动画就是照着它画的:

trace.cpp把竖式每一位的账打出来
默认跑 987 + 123。想看借位链就在终端里跑 ./trace 1000 999 sub —— 借位 3 次、去掉 3 个前导零、答案只剩 1 位。
// 把竖式一步一步打出来 —— 动画照着这份代码画,check:viz 拿它和动画**逐步**对
//
// 用法:./trace <a> <b> <add|sub>
//
// 输出格式(每一位一行,位号从个位开始数,和数组下标一致):
// 步 0 位=0 a=7 b=3 借入=0 → 4 写下 4 借出=0
// 最后一行是收尾:答案、以及「一共进位/借位了多少次」。
//
// ★ 为什么要专门有这一份:动画和正解**必须讲同一件事**。
// 只对最终答案的话,「答案对了但中间画的是另一回事」这种事查不出来
// (第 27、28 章那句「一份走法能自证清白,一个数字不能」)。
// 所以这里把每一位的 (a位, b位, 进位入, 结果, 进位出) 全打出来,动画那边逐帧对。
#include <bits/stdc++.h>
using namespace std;
int main(int argc, char** argv) {
string sa = (argc > 1) ? argv[1] : "987";
string sb = (argc > 2) ? argv[2] : "123";
string op = (argc > 3) ? argv[3] : "add";
vector<int> a, b;
for (int i = (int)sa.size() - 1; i >= 0; i--) a.push_back(sa[i] - '0');
for (int i = (int)sb.size() - 1; i >= 0; i--) b.push_back(sb[i] - '0');
printf("op=%s a=%s b=%s\n", op.c_str(), sa.c_str(), sb.c_str());
vector<int> c;
int cnt = 0; // 进位 / 借位发生的次数
if (op == "add") {
int carry = 0;
for (size_t i = 0; i < a.size() || i < b.size() || carry; i++) {
int ai = (i < a.size()) ? a[i] : 0;
int bi = (i < b.size()) ? b[i] : 0;
int cur = ai + bi + carry;
int in = carry;
carry = cur / 10;
if (carry) cnt++;
printf("步 %zu 位=%zu a=%d b=%d 进位入=%d → %d 写下 %d 进位出=%d\n",
i, i, ai, bi, in, cur, cur % 10, carry);
c.push_back(cur % 10);
}
} else {
int borrow = 0;
for (size_t i = 0; i < a.size(); i++) {
int ai = a[i];
int bi = (i < b.size()) ? b[i] : 0;
int in = borrow;
int cur = ai - borrow - bi;
borrow = 0;
if (cur < 0) { cur += 10; borrow = 1; cnt++; }
printf("步 %zu 位=%zu a=%d b=%d 借入=%d → %d 写下 %d 借出=%d\n",
i, i, ai, bi, in, cur, cur, borrow);
c.push_back(cur);
}
}
// 去前导零(0 要留一个)
size_t before = c.size();
while (c.size() > 1 && c.back() == 0) c.pop_back();
string res;
for (size_t i = c.size(); i-- > 0;) res += char('0' + c[i]);
printf("收尾 答案 %s %s %d 次 去掉前导零 %zu 个\n",
res.c_str(), op == "add" ? "进位" : "借位", cnt, before - c.size());
return 0;
}
点一下即可编辑
输出
点「运行 ▶」看结果

6动画一:一位一位地算,看着进位往上爬

竖式:从个位开始,一位一位地算(高亮的格子就是当前这一位)
加法 共 5 帧
第 1 / 5 步
进位
1
a
9
8
7
+ b
1
2
3
=
0
进位已经发生 1
第 0 位:7 + 3 = 10,写下 0,向上进 1。

看的时候盯住三样:

  1. 高亮的格子从右往左走 —— 竖式是从个位开始的,这就是「下标 0 存个位」的画面版;
  2. 上面那一行小小的进位 / 借位 —— 它是这一章的主角,1000 − 1 那一档能看到它一路爬到头;
  3. 最后一帧的「去掉前导零」 —— 1000 − 999 会先算出 0001,划掉三个 0 才是答案。 ⚠ 而 5 − 5 那一档要盯紧:算出来是 0,这时候一个都不能再划

7正解

fast.cpp加减乘三个函数,一共 40 行
// 高精度加减乘 —— 正解:低位在前的十进制数组
//
// 这一章的全部内容,就是把小学的竖式翻译成代码。翻译时只有一个决定是关键的:
//
// ★ **数组下标 0 存个位**(低位在前,也就是把数字倒过来存)。
//
// 为什么不能顺着存(下标 0 存最高位)?因为竖式的三件事全是从个位开始的:
// 加法从个位往上进位、减法从个位往上借位、乘法里 a 的第 i 位乘 b 的第 j 位要落到「第 i+j 位」。
// 顺着存的话,这三句话里的下标全都要写成 `n-1-i` 那种形状 —— 一处写错就全错,
// 而且错得很难看出来。倒过来存之后,下标就是「这一位有多大」,i+j 那一句几乎是抄的。
//
// 表示约定(三个函数共用):
// · vector<int>,每个元素是一位十进制数字(0~9),下标 0 是个位;
// · 允许出现前导零(也就是数组末尾的 0),最后统一由 trim() 去掉;
// · **0 就是 {0}**,长度 1 —— 这是 trim() 里 `size() > 1` 那个 1 的全部理由。
//
// 复杂度:加、减都是 O(n);乘是 O(nm) —— 500 位 × 500 位是 25 万次一位数乘法,
// 眨眼就完(count.cpp 把这个数数出来了)。
#include <bits/stdc++.h>
using namespace std;
using Big = vector<int>;
/** "123" → {3,2,1}(下标 0 是个位) */
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;
}
/** {3,2,1} → "123" */
string show(const Big& v) {
string s;
for (int i = (int)v.size() - 1; i >= 0; i--) s += char('0' + v[i]);
return s;
}
/**
* 去掉高位多余的 0。
* ★ 条件是 `v.size() > 1`,不是 `!v.empty()` —— 差一个字,0 就会被删成空数组,
* 输出变成一个空行。这是这一章最常见的两个错法之一(wrongTrimAll.cpp 就是它)。
*/
void trim(Big& v) {
while (v.size() > 1 && v.back() == 0) v.pop_back();
}
/**
* 加法:逐位加,进位往上传。
* ★ 循环条件里那个 `|| carry` 是「最高位还要再进一位」那种情况(999 + 1 = 1000)——
* 漏掉它的后果是答案少一位,而且**只在进位一直传到头的时候才现形**。
*/
Big add(const Big& a, const Big& b) {
Big c;
int carry = 0;
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;
}
/**
* 减法:调用方保证 a >= b。
* ★ 借位是一条**链**:1000 - 1 要从个位一路借到最高位。所以 borrow 必须是循环里
* 带着走的变量,而不是「发现不够就跟上一位借一次」那种一次性判断。
*/
Big sub(const Big& a, const Big& b) {
Big c;
int borrow = 0;
for (size_t i = 0; i < a.size(); i++) {
int cur = a[i] - borrow - (i < b.size() ? b[i] : 0);
borrow = 0;
if (cur < 0) { cur += 10; borrow = 1; }
c.push_back(cur);
}
trim(c); // ★ 1000 - 999 = 0001 → 1,这一步不能省
return c;
}
/**
* 乘法:竖式,但**先把所有部分积按 i+j 累加进去,最后统一进位**。
*
* ★ 两句话是这一段的全部:
* ① `c[i + j] += a[i] * b[j]` —— 第 i 位乘第 j 位,结果落在第 i+j 位(倒着存的红利);
* ② 结果数组开 `a.size() + b.size()` 位 —— 上界是「位数之和」,少开一位就会丢最高位。
*
* ⚠ 中间的 c[i] 会长到多大?每个 c[i] 最多累加 min(n,m) 个 9×9=81 —— 500 位时是 40500,
* int 装得下,所以这里**不需要** long long。(换成压位写法(一格存 8 位十进制)就需要了。)
*/
Big mul(const Big& a, const Big& b) {
Big c(a.size() + b.size(), 0);
for (size_t i = 0; i < a.size(); i++)
for (size_t j = 0; j < b.size(); j++)
c[i + j] += a[i] * b[j];
int carry = 0;
for (size_t i = 0; i < c.size(); i++) {
int cur = c[i] + carry;
c[i] = 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;
Big a = parse(sa), b = parse(sb);
Big s = add(a, b), d = sub(a, b), p = mul(a, b);
string ss = show(s), sd = show(d), sp = show(p);
cout << ss << "\n" << sd << "\n" << sp << "\n";
// ★ 多问一行位数:0 的位数算 1。它专门用来照出「前导零」那一类错
cout << ss.size() << " " << sd.size() << " " << sp.size() << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

输入

10000000000000000000
9999999999999999999

输出

19999999999999999999
1
99999999999999999990000000000000000000
20 1 38

换一组更能说明问题的:10¹⁹10¹⁹ − 1。 它们的和有 20 位、积有 38 位 —— 两个都超出了 64 位整数, 而高精度这边一个字都不用改。

8乘法:i + j 那一句

乘法在代码里只有两句话,但这两句话值得画出来:

Big c(a.size() + b.size(), 0);                 // ① 结果最多 n+m 位
for (i) for (j) c[i + j] += a[i] * b[j];       // ② 第 i 位 × 第 j 位 → 落在第 i+j 位
// 最后统一进位一遍
先把每个 a[i]×b[j] 加到第 i+j 位上,最后统一进位
2 × 2 = 4 次一位数乘法
第 1 / 9 步
a[0]=9
81→0
81→1
a[1]=9
81→1
81→2
b[0]=9
b[1]=9
第几位
0
3
0
2
0
1
81
0
已经算了 1 / 4 格 (红色的格子是 > 9 的:统一进位之前,每一位都可以很大)
a 的第 0 位 (9) × b 的第 0 位 (9) = 81 ⇒ 加到结果的第 0 位上(0 + 0 = 0)。
★ 为什么可以「先随便加,最后统一进位」

中间过程里,c[i+j] 可以远大于 9(动画里那些红色的格子)—— 这完全没关系, 因为最后有一遍统一进位把它们摊平。这样写比「一边乘一边进位」短得多,也不容易错。

⚠ 那 c[i+j] 会不会溢出?算一下:每一格最多累加 min(n,m)9×9=81, 500 位时是 81 × 500 = 40500 —— int 装得下,所以这里不需要 long long。 (换成「压位」写法(一格存 8 位十进制)就需要了,那是后话。)

★ 而 n+m 这个长度也不是拍脑袋的:n 位数 < 10ⁿ,m 位数 < 10ᵐ, 乘积 < 10^(n+m),所以最多 n+m 位。少开一位,最高位那个进位就无处可去 —— 下一步的第六个错误版本就是它。

9★ 对拍:六个写错的版本,外加那个 64 位的

标准答案是 brute.cpp:同一道题,但从头到尾只用 string、高位在前

brute.cpp标准答案:字符串版,高位在前
⚠ 先说清楚这次对拍抓不到什么

两份代码的算法是同一套(都是小学那套竖式),换的只是表示

fast.cpp brute.cpp
存法 vector<int>,下标 0 是个位 string,下标 0 是最高位
同一位的下标 i n-1-i

★ 这么安排是有理由的:这一章的错几乎全是下标 / 边界上的错, 而两边的下标互为镜像 —— 一边写错,一定和另一边对不上。

⚠ 但也得老实说:它抓不到「我对竖式的理解本身就是错的」 —— 那样两份会一起错。 第 11 步会换一把尺子补上这一半。 (第 20 章那句「标准答案最好用完全不同的思路写出来」,这一章只做到了一半,就得说是一半。)

对拍器
★ 把右边换成你自己默写的那份,再点开始。生成器默认跑最终档(第 10 步那张表里的档位 9):会造出「结果为 0」「结果位数变短」「借位链」「位数大到 64 位装不下」这几类数据。
// 高精度加减乘 —— 正解:低位在前的十进制数组
//
// 这一章的全部内容,就是把小学的竖式翻译成代码。翻译时只有一个决定是关键的:
//
// ★ **数组下标 0 存个位**(低位在前,也就是把数字倒过来存)。
//
// 为什么不能顺着存(下标 0 存最高位)?因为竖式的三件事全是从个位开始的:
// 加法从个位往上进位、减法从个位往上借位、乘法里 a 的第 i 位乘 b 的第 j 位要落到「第 i+j 位」。
// 顺着存的话,这三句话里的下标全都要写成 `n-1-i` 那种形状 —— 一处写错就全错,
// 而且错得很难看出来。倒过来存之后,下标就是「这一位有多大」,i+j 那一句几乎是抄的。
//
// 表示约定(三个函数共用):
// · vector<int>,每个元素是一位十进制数字(0~9),下标 0 是个位;
// · 允许出现前导零(也就是数组末尾的 0),最后统一由 trim() 去掉;
// · **0 就是 {0}**,长度 1 —— 这是 trim() 里 `size() > 1` 那个 1 的全部理由。
//
// 复杂度:加、减都是 O(n);乘是 O(nm) —— 500 位 × 500 位是 25 万次一位数乘法,
// 眨眼就完(count.cpp 把这个数数出来了)。
#include <bits/stdc++.h>
using namespace std;
using Big = vector<int>;
/** "123" → {3,2,1}(下标 0 是个位) */
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;
}
/** {3,2,1} → "123" */
string show(const Big& v) {
string s;
for (int i = (int)v.size() - 1; i >= 0; i--) s += char('0' + v[i]);
return s;
}
/**
* 去掉高位多余的 0。
* ★ 条件是 `v.size() > 1`,不是 `!v.empty()` —— 差一个字,0 就会被删成空数组,
* 输出变成一个空行。这是这一章最常见的两个错法之一(wrongTrimAll.cpp 就是它)。
*/
void trim(Big& v) {
while (v.size() > 1 && v.back() == 0) v.pop_back();
}
/**
* 加法:逐位加,进位往上传。
* ★ 循环条件里那个 `|| carry` 是「最高位还要再进一位」那种情况(999 + 1 = 1000)——
* 漏掉它的后果是答案少一位,而且**只在进位一直传到头的时候才现形**。
*/
Big add(const Big& a, const Big& b) {
Big c;
int carry = 0;
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;
}
/**
* 减法:调用方保证 a >= b。
* ★ 借位是一条**链**:1000 - 1 要从个位一路借到最高位。所以 borrow 必须是循环里
* 带着走的变量,而不是「发现不够就跟上一位借一次」那种一次性判断。
*/
Big sub(const Big& a, const Big& b) {
Big c;
int borrow = 0;
for (size_t i = 0; i < a.size(); i++) {
int cur = a[i] - borrow - (i < b.size() ? b[i] : 0);
borrow = 0;
if (cur < 0) { cur += 10; borrow = 1; }
c.push_back(cur);
}
trim(c); // ★ 1000 - 999 = 0001 → 1,这一步不能省
return c;
}
/**
* 乘法:竖式,但**先把所有部分积按 i+j 累加进去,最后统一进位**。
*
* ★ 两句话是这一段的全部:
* ① `c[i + j] += a[i] * b[j]` —— 第 i 位乘第 j 位,结果落在第 i+j 位(倒着存的红利);
* ② 结果数组开 `a.size() + b.size()` 位 —— 上界是「位数之和」,少开一位就会丢最高位。
*
* ⚠ 中间的 c[i] 会长到多大?每个 c[i] 最多累加 min(n,m) 个 9×9=81 —— 500 位时是 40500,
* int 装得下,所以这里**不需要** long long。(换成压位写法(一格存 8 位十进制)就需要了。)
*/
Big mul(const Big& a, const Big& b) {
Big c(a.size() + b.size(), 0);
for (size_t i = 0; i < a.size(); i++)
for (size_t j = 0; j < b.size(); j++)
c[i + j] += a[i] * b[j];
int carry = 0;
for (size_t i = 0; i < c.size(); i++) {
int cur = c[i] + carry;
c[i] = 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;
Big a = parse(sa), b = parse(sb);
Big s = add(a, b), d = sub(a, b), p = mul(a, b);
string ss = show(s), sd = show(d), sp = show(p);
cout << ss << "\n" << sd << "\n" << sp << "\n";
// ★ 多问一行位数:0 的位数算 1。它专门用来照出「前导零」那一类错
cout << ss.size() << " " << sd.size() << " " << sp.size() << "\n";
return 0;
}
点一下即可编辑

300 轮实测(种子 1..300,最终档 9),七个版本:

故意写错的地方 被抓 靠什么现形
wrongMulIdxc[i+j+1] 下标错一位 300 / 300 任何多位乘法,不挑数据
ull.cpp:64 位整数版 279 / 300 位数够大(和 19 位、积 10 位)
wrongBorrow:借了不还 218 / 300 发生借位;借位链更稳
wrongMulLen:结果数组少开一位 211 / 300 乘积正好顶满 n+m 位
wrongTrimNone:减法结果不去前导零 ★ 154 / 300 结果位数比 a 短(两数高位相同)
wrongCarry:加法丢最高位进位 100 / 300 进位一路传到头(999+1
wrongTrimAll:0 被删成空 ★★ 43 / 300 结果正好是 0(a = b)

⚠ 这张表的下三行才是重点:它们的被抓率不是「算法有多难」决定的,是「生成器造不造得出那种数据」决定的。 下一步把每一个旋钮的账单独算给你看。

六个错误版本长什么样(点开看)
wrongCarry.cpp① 加法丢了最高位的进位
wrongBorrow.cpp② 借了 10 却没还给上一位
wrongTrimAll.cpp③ 0 被删成了空
wrongTrimNone.cpp④ 减法结果没去前导零
wrongMulIdx.cpp⑤ 乘法下标错一位
wrongMulLen.cpp⑥ 结果数组少开一位

★ 每一份的文件头都写着「它靠什么现形」—— 那不是注释,那是下一步调生成器的依据。

10★★ 生成器:三个 bug,随机数据一辈子都撞不到

这一章的生成器有六个旋钮,逐档打开。下面这张表是每一档 300 轮、每个错误版本被抓多少轮

档位 相对上一档拧了什么 ① Carry ② Borrow ③ TrimAll ④ TrimNone ⑤ MulIdx ⑥ MulLen ⑦ ull
0(顺手写法) 位数 1~8、数字均匀随机、a < b 就交换 62 204 7 25 300 240 0
1 ★ 有时让 b = a 84 160 76 84 300 234 0
2 ★ 有时让 b 和 a 高位相同 92 182 12 103 300 232 0
3 ★ 有时把 a 造成 10^k 44 209 5 85 300 167 0
4 ★ 位数悬殊(a 长 b 短) 61 180 2 16 300 243 0
5 ★ 数字偏向 0 和 9 135 205 15 36 300 276 0
6 ★ 位数放大到 12~30 55 300 0 30 300 242 300
7 = 1 + 2 103 144 67 137 300 228 0
8 = 7 + 3 + 4 82 133 68 145 300 175 0
9 ★ 最终档 = 8 + 5 + 6 100 218 43 154 300 211 279
10 对照 = 9 − 「b = a」 85 252 0 132 300 211 273
11 对照 = 9 − 「高位相同」 81 214 66 124 300 212 271
12 对照 = 9 − 「位数放大」 117 131 69 151 300 200 0
★★★ 三笔账,一笔比一笔干净

① 「位数放大」这个旋钮,单独决定 64 位版的死活。

档位 12(最终档去掉它):ull 0 / 300;加回去(档位 9):279 / 300。 ⇒ 生成器不造大数,long long 版能一直全绿。 这句话值得在心里放大:它意味着「我对拍过了」和「我的代码对」之间,隔着一个你自己造的数据。

② 「让 b = a」这个旋钮,单独决定 ③ 的死活。

档位 10(最终档去掉它):wrongTrimAll 0 / 300;加回去:43 / 300。 ⚠ 更有意思的是顺手档(0)居然抓到了 7 轮 —— 那是因为顺手档造的是 18 位的小数, 两个数碰巧相等的概率不是零。而一旦位数放大到 1230 位(档位 6),碰巧相等就再也不会发生了: 数越大,靠运气撞上边界的机会越小 —— 所以数据一大,专门造边界就从「锦上添花」变成了唯一来源。

③ 而 ⑤ 从头到尾都是 300 / 300。

它错在「每一位」,不是错在某个边界上。⇒ 随机数据抓得到的,是那些到处都错的 bug; 抓不到的,全是只在边界上错的。 这一章三个难抓的(③④⑦)无一例外都是边界。

gen.cpp(十三个档位)六个旋钮,每一个都写清了它是为哪个 bug 拧的
⚠ 顺手写的生成器有多温柔,算一下就知道

顺手档(0)造的是「1~8 位、数字均匀随机」的两个数。在这种数据上:

  • long long永远是对的(8 位数相乘才 16 位,64 位装得下);
  • 两个数几乎不会相等(除非都是一位数);
  • 相减几乎不会掉位数(高位相同的概率极低)。

⇒ 拿它跑一万轮,你会得到一句非常危险的结论:「我的代码没问题」。 ★ 这就是本书从第 7 章开始反复说的那句话,在这一章的样子: 先写下「每个 bug 靠什么现形」,再照着那张清单拧旋钮 —— 而不是把 n 调大了事。

11★ 对拍够不着的那一半:换一把尺子

上面那次对拍有个说不出口的短板:两份代码用的是同一套竖式。 所以它抓得到「实现写歪了」,抓不到「我对竖式的理解本身就错了」。

⇒ 补上另一半:不用任何自己写的竖式,直接拿 CPU 的 128 位整数当裁判。

verify.cpp第三把尺子:__int128 vs 高精度
随机两个 ≤ 18 位的数,__int128 算一遍(硬件算术,绝对可信),高精度算一遍,逐字节比。默认 20000 轮。
// 第三把尺子:拿硬件算术(__int128)验高精度
//
// ⚠ 对拍(brute.cpp ↔ fast.cpp)有一个说不出口的短板:**两份代码用的是同一套竖式**,
// 只是表示不同(字符串高位在前 vs 数组低位在前)。所以它抓得到「实现写歪了」,
// 抓不到「我对竖式的理解本身就错了」—— 两份会一起错,而且错得一模一样。
// (第 20 章那句「标准答案最好用完全不同的思路写出来」,在这一章只做到了一半。)
//
// ⇒ 这份程序补上另一半:**不用任何自己写的竖式**,直接用 CPU 的 128 位整数算 a+b、a−b、a×b,
// 再和高精度的结果比。它只能验小数据(两个数各 ≤ 18 位,乘积 ≤ 36 位,__int128 装得下),
// 但它验的是「算得对不对」这件事本身。
//
// ★ 这就是本书反复在做的事:**一把尺子够不着的地方,换一把,而不是把结论说小一点。**
// (第 36 章的「四个盲区」、第 41 章的「换成数划的次数」都是这个动作。)
//
// 用法:./verify [轮数=20000] [种子=20260825]
#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;
}
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;
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;
}
Big sub(const Big& a, const Big& b) {
Big c;
int borrow = 0;
for (size_t i = 0; i < a.size(); i++) {
int cur = a[i] - borrow - (i < b.size() ? b[i] : 0);
borrow = 0;
if (cur < 0) { cur += 10; borrow = 1; }
c.push_back(cur);
}
trim(c);
return c;
}
Big mul(const Big& a, const Big& b) {
Big c(a.size() + b.size(), 0);
for (size_t i = 0; i < a.size(); i++)
for (size_t j = 0; j < b.size(); j++)
c[i + j] += a[i] * b[j];
int carry = 0;
for (size_t i = 0; i < c.size(); i++) { int cur = c[i] + carry; c[i] = cur % 10; carry = cur / 10; }
trim(c);
return c;
}
/** __int128 没有现成的打印,自己转 —— 只处理非负数 */
string i128(__int128 v) {
if (v == 0) return "0";
string s;
while (v > 0) { s += char('0' + (int)(v % 10)); v /= 10; }
reverse(s.begin(), s.end());
return s;
}
int main(int argc, char** argv) {
long long rounds = (argc > 1) ? atoll(argv[1]) : 20000;
unsigned seed = (argc > 2) ? (unsigned)strtoul(argv[2], nullptr, 10) : 20260825u;
if (rounds < 1) rounds = 1;
mt19937_64 rng(seed);
int maxDigits = 0;
long long bad = 0;
for (long long r = 0; r < rounds && bad == 0; r++) {
// 位数 1~18:__int128 要装得下乘积(36 位),绰绰有余
int la = 1 + (int)(rng() % 18), lb = 1 + (int)(rng() % 18);
auto mk = [&](int len) {
string s;
for (int i = 0; i < len; i++) {
int d = (int)(rng() % 10);
if (i == 0 && d == 0) d = 1 + (int)(rng() % 9);
s += char('0' + d);
}
return s;
};
string sa = mk(la), sb = mk(lb);
// 偶尔造两个相等的数(结果为 0),以及 b = 0
if (rng() % 20 == 0) sb = sa;
if (rng() % 50 == 0) sb = "0";
__int128 A = 0, B = 0;
for (char c : sa) A = A * 10 + (c - '0');
for (char c : sb) B = B * 10 + (c - '0');
if (A < B) { swap(A, B); swap(sa, sb); }
maxDigits = max(maxDigits, (int)max(sa.size(), sb.size()));
Big a = parse(sa), b = parse(sb);
string gotAdd = show(add(a, b)), gotSub = show(sub(a, b)), gotMul = show(mul(a, b));
string wantAdd = i128(A + B), wantSub = i128(A - B), wantMul = i128(A * B);
if (gotAdd != wantAdd || gotSub != wantSub || gotMul != wantMul) {
bad++;
printf("✗ 第 %lld 轮对不上:a=%s b=%s\n", r + 1, sa.c_str(), sb.c_str());
printf(" 加:高精度 %s / __int128 %s\n", gotAdd.c_str(), wantAdd.c_str());
printf(" 减:高精度 %s / __int128 %s\n", gotSub.c_str(), wantSub.c_str());
printf(" 乘:高精度 %s / __int128 %s\n", gotMul.c_str(), wantMul.c_str());
}
}
if (!bad) printf("✓ 全部通过 %lld 轮(两个数最长 %d 位,乘积最长 %d 位)\n",
rounds, maxDigits, maxDigits * 2);
return bad ? 1 : 0;
}
点一下即可编辑
输出
点「运行 ▶」看结果
★ 它验的东西和对拍不一样
对拍(brute ↔ fast) 这一份(__int128 ↔ fast)
覆盖的规模 500 位以上都行 只能到 18 位(乘积 36 位)
抓得到 下标 / 边界写歪 竖式本身理解错了
抓不到 两份一起错 大数据上才现形的问题

⇒ 两把尺子的覆盖面互补,所以两把都要有。 本机实测 20000 轮全部通过(两个数最长 18 位、乘积最长 36 位)。

★ 这就是本书反复在做的动作:一把尺子够不着的地方,换一把,而不是把结论说小一点。 (第 36 章「四个盲区」、第 41 章「改成数划的次数」、第 42 章「比性质不比答案」都是它。)

12⚠ 这一章没讲的:除法、压位、更快的乘法

⚠ 说清楚边界,比假装讲全了有用
没讲的 什么时候会需要它 一句话提示
高精度 ÷ 低精度 除数是个普通 int(比如求阶乘再除) 最高位开始,余数带着往下走 —— 唯一一个从高位开始的运算
高精度 ÷ 高精度 少见,CSP-J 基本不考 试商,或者二分答案 + 高精度乘法
压位 位数上万、常数吃紧时 一格存 8 位十进制(而不是 1 位),次数除以 64 ⚠ 这时候中间结果就要 long long
FFT / NTT 乘法 位数十万级 O(n log n)。CSP-S 偶尔用得上,CSP-J 完全用不到

★ 按第 4 步那张表:O(n²) 的乘法在 64000 位时是 1.45 秒 —— 也就是说到万位级之前,这一章这套朴素写法都够用。 ⇒ 不要一上来就去背 FFT。先把进位链和前导零写对,那才是考场上真正会丢分的地方。

13自测

自测清单0 / 11
配套练习
  • 洛谷 P1601 A+B Problem(高精) —— 高精度加法模板。⚠ 注意 0 + 0 那组数据 —— 前导零的坑第一次就会撞上
  • 洛谷 P2142 高精度减法 —— ★ 比加法难:要自己判断 a 和 b 谁大(本章的题面替你保证了 a ≥ b,真题不会),结果为 0 和结果为负都要处理
  • 洛谷 P1303 A*B Problem —— 高精度乘法模板。i+j 那一句和结果数组开多长,交之前先自己说一遍
  • 洛谷 P1009 [NOIP 1998 普及组] 阶乘之和 —— ★ 高精度加法 + 高精度乘低精度。50! 有 65 位 —— 这道题是「什么时候必须上高精度」最经典的例子
  • 洛谷 P1255 数楼梯 —— ★★ 斐波那契 + 高精度:第 2 章那道题的完整版。5000 级楼梯的答案有 1046 位,long long 在第 90 级就崩了
  • 洛谷 P1080 [NOIP 2012 提高组] 国王游戏 —— ⚠ 提高组:贪心(第 19 章的交换论证)+ 高精度乘除。放在这儿是想说明一件事 —— 高精度很少单独考,它总是别的算法的一部分
这一章我卡在哪(过一个月回来看,这几行比整章正文都值钱)
这一章记住三句话
  1. 进位和借位是一条链,不是一次判断 —— 1000 − 1 要一路借到最高位。
  2. 前导零有两头:删少了 1000−999 变成 0001,删多了 5−5 变成空行。 trim 里那个 size() > 1 就是这两头之间唯一的那道墙。
  3. ★★ 随机数据抓得到的是「到处都错」的 bug;只在边界上错的,得你自己去造。 这一章三个难抓的(结果为 0、位数变短、超出 64 位)无一例外都是边界 —— 而它们恰恰是考场上最容易丢分的三个。