高精度是 CSP-J 的必考点,可它在前 43 章里一次都没有独立出现过 —— 只在几条练习的备注里被顺带提到。 这一章把它补上。
★ 所以它的位置有点特别:它不依赖后面任何一章,学完第 5 章(枚举与模拟)之后随时可以来看。 排在最后只是因为它补得晚。
⚠ 而它值得单独一章,不是因为算法难 —— 竖式你小学就会了。是因为:
| 这一章真正难的 | 难在哪 |
|---|---|
| 进位 / 借位是一条链 | 不是「不够减就跟上一位借一次」,是要一路传下去 |
| 前导零 | 删少了 1000−999 变成 0001;删多了 5−5 变成一个空行 |
| ★★ 这三个错,随机数据撞不到 | 结果正好是 0、结果位数变短、位数超过 64 位 —— 生成器不专门去造,对拍能一直全绿 |
最后那一行才是这一章的主课。前面 43 章反复在说「造数据比算法本身更容易骗过你」, 这一章是那句话最干净的一次现场。
1一句话问题
给两个非负整数
a和b(各不超过 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 就活在这个差别里。
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;最后那个进位单独占一位。
把它翻译成代码,只有一个决定是关键的:
写在纸上: 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 啊」—— 这个想法快,而且是错的
这一章的「暴力」和全书别的章不一样。别的章的暴力是对但慢,这一份是快但错:
// 「那就用 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;}点「运行 ▶」看结果
小数据上它完全正确 —— 这正是它危险的地方。自己拧一下位数看看:
★ 8 位的时候两边逐字节相同,10 位就开始不一样 —— 而这中间没有任何警告、没有崩溃, 它只是安静地给出一个错的答案。所以第一件事不是量它多快, 而是量它从第几位开始给出错误答案:
// 量一件事: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;}点「运行 ▶」看结果
| 运算 | 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 章那条「秒表彻底失效」的第二次现场)。
⇒ 那就换一把尺子:不数秒,数「一位数运算做了多少次」。
// 换一把尺子:数「一位数运算」做了多少次//// 秒表在这一章基本没用 —— 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;}点「运行 ▶」看结果
| 运算 | 次数(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 的进位会传到最高位之外,答案要多出一位。
下面这份把每一位的账都打出来了,动画就是照着它画的:
// 把竖式一步一步打出来 —— 动画照着这份代码画,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动画一:一位一位地算,看着进位往上爬
看的时候盯住三样:
- 高亮的格子从右往左走 —— 竖式是从个位开始的,这就是「下标 0 存个位」的画面版;
- 上面那一行小小的进位 / 借位 —— 它是这一章的主角,
1000 − 1那一档能看到它一路爬到头; - 最后一帧的「去掉前导零」 ——
1000 − 999会先算出0001,划掉三个 0 才是答案。 ⚠ 而5 − 5那一档要盯紧:算出来是0,这时候一个都不能再划。
7正解
// 高精度加减乘 —— 正解:低位在前的十进制数组//// 这一章的全部内容,就是把小学的竖式翻译成代码。翻译时只有一个决定是关键的://// ★ **数组下标 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;}点「运行 ▶」看结果
输入
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 位
// 最后统一进位一遍
中间过程里,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、高位在前。
两份代码的算法是同一套(都是小学那套竖式),换的只是表示:
| fast.cpp | brute.cpp | |
|---|---|---|
| 存法 | vector<int>,下标 0 是个位 |
string,下标 0 是最高位 |
| 同一位的下标 | i |
n-1-i |
★ 这么安排是有理由的:这一章的错几乎全是下标 / 边界上的错, 而两边的下标互为镜像 —— 一边写错,一定和另一边对不上。
⚠ 但也得老实说:它抓不到「我对竖式的理解本身就是错的」 —— 那样两份会一起错。 第 11 步会换一把尺子补上这一半。 (第 20 章那句「标准答案最好用完全不同的思路写出来」,这一章只做到了一半,就得说是一半。)
// 高精度加减乘 —— 正解:低位在前的十进制数组//// 这一章的全部内容,就是把小学的竖式翻译成代码。翻译时只有一个决定是关键的://// ★ **数组下标 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),七个版本:
| 故意写错的地方 | 被抓 | 靠什么现形 |
|---|---|---|
⑤ wrongMulIdx:c[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) |
⚠ 这张表的下三行才是重点:它们的被抓率不是「算法有多难」决定的,是「生成器造不造得出那种数据」决定的。 下一步把每一个旋钮的账单独算给你看。
★ 每一份的文件头都写着「它靠什么现形」—— 那不是注释,那是下一步调生成器的依据。
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(最终档去掉它):
ull0 / 300;加回去(档位 9):279 / 300。 ⇒ 生成器不造大数,long long版能一直全绿。 这句话值得在心里放大:它意味着「我对拍过了」和「我的代码对」之间,隔着一个你自己造的数据。
② 「让 b = a」这个旋钮,单独决定 ③ 的死活。
档位 10(最终档去掉它):
wrongTrimAll0 / 300;加回去:43 / 300。 ⚠ 更有意思的是顺手档(0)居然抓到了 7 轮 —— 那是因为顺手档造的是 18 位的小数, 两个数碰巧相等的概率不是零。而一旦位数放大到 1230 位(档位 6),碰巧相等就再也不会发生了: 数越大,靠运气撞上边界的机会越小 —— 所以数据一大,专门造边界就从「锦上添花」变成了唯一来源。
③ 而 ⑤ 从头到尾都是 300 / 300。
它错在「每一位」,不是错在某个边界上。⇒ 随机数据抓得到的,是那些到处都错的 bug; 抓不到的,全是只在边界上错的。 这一章三个难抓的(③④⑦)无一例外都是边界。
顺手档(0)造的是「1~8 位、数字均匀随机」的两个数。在这种数据上:
long long版永远是对的(8 位数相乘才 16 位,64 位装得下);- 两个数几乎不会相等(除非都是一位数);
- 相减几乎不会掉位数(高位相同的概率极低)。
⇒ 拿它跑一万轮,你会得到一句非常危险的结论:「我的代码没问题」。 ★ 这就是本书从第 7 章开始反复说的那句话,在这一章的样子: 先写下「每个 bug 靠什么现形」,再照着那张清单拧旋钮 —— 而不是把 n 调大了事。
11★ 对拍够不着的那一半:换一把尺子
上面那次对拍有个说不出口的短板:两份代码用的是同一套竖式。 所以它抓得到「实现写歪了」,抓不到「我对竖式的理解本身就错了」。
⇒ 补上另一半:不用任何自己写的竖式,直接拿 CPU 的 128 位整数当裁判。
// 第三把尺子:拿硬件算术(__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自测
- 洛谷 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 章的交换论证)+ 高精度乘除。放在这儿是想说明一件事 —— 高精度很少单独考,它总是别的算法的一部分
- 进位和借位是一条链,不是一次判断 ——
1000 − 1要一路借到最高位。 - 前导零有两头:删少了
1000−999变成0001,删多了5−5变成空行。trim里那个size() > 1就是这两头之间唯一的那道墙。 - ★★ 随机数据抓得到的是「到处都错」的 bug;只在边界上错的,得你自己去造。 这一章三个难抓的(结果为 0、位数变短、超出 64 位)无一例外都是边界 —— 而它们恰恰是考场上最容易丢分的三个。