ACM 一个月学习计划 · DAY 30

Day 30 · 高精度运算 + 30 天总结

当数字大到 long long 装不下 —— 自己用数组造一个「无限长整数」出来

今日目标:掌握高精度加减乘的原理,写出属于自己的模板;完成整月复盘
预计用时:约 4 小时(含做题与总结)  难度:普及-
题目来源:洛谷 P1601 / P1303 / P2142 / P1009
← Day 29 · 模拟赛

⏱今日时间安排

0 - 20 min搞清楚「为什么要高精度」(第 1 节)
20 - 70 min吃透统一模板的三件事:倒序存 / 逐位算 / 去前导零(第 2 节)
70 - 110 min逐个拆解加法、减法、乘法的手推过程(第 3、4 节)
110 - 180 min做 4 道题,把模板默写一遍(不看正文)
180 - 215 min30 天总结:读错题本、整理个人模板文件、写避坑清单
215 - 240 min做最后的自测(2 小时 AC 四题挑战,可另找时间)
💡 最后一天了

今天有两件事:上午学高精度(这是最后一个新知识点),下午做整月复盘。 高精度本身不难,但它是「必须会」的东西 —— 因为几乎每场比赛都会有一道大数题, 而且学会之后可以直接封装成模板,以后一辈子都能用。

1为什么需要高精度

先看 long long 到底能装多大

类型字节能表示的最大值十进制位数
short232,7675 位
int42,147,483,647(约 2.1×10⁹)10 位
unsigned int44,294,967,295(约 4.3×10⁹)10 位
long long89,223,372,036,854,775,807(约 9.2×10¹⁸)19 位
🎯 记住这个数:19

不管你的电脑多快、编译器多新,C++ 里最大的内置整数类型也就 19 位十进制数。 这是由「用固定字节存数」这件事本身决定的,跟语言无关。

但题目要你算的东西,动辄几百上千位

题目要求算的东西大概多少位
P1601a + b,a、b ≤ 10⁵⁰⁰501 位
P1303a × b,每个 ≤ 10²⁰⁰⁰4000 位
P2142a − b,每个 ≤ 10¹⁰⁰⁸⁶10086 位
P10091! + 2! + … + 50!65 位

看 P1009:50! 大约是 3×10⁶⁴,早就飞出 long long 的射程了。 更别说 P2142 的 10086 位数 —— 用 long long 连零头都存不下。

⚠️ 溢出的表现是「静默出错」

C++ 里整数溢出不会报错、不会崩溃、不会警告(开 -Wall 也不会), 它只会悄悄地把高位丢掉,给你一个看着挺正常但其实是错的数。

所以在竞赛里看到「大数」「阶乘」「乘积」「不超过 10^k」这类字眼, 第一反应应该是判断会不会爆 long long,而不是直接上手写。

高精度的本质:把一个大数拆成很多个「个位数」

既然一个变量装不下 1000 位,那就每一位存一个数组元素。 1000 位的数就用长度 1000 的数组存 —— 数组想开多长开多长,位数就没有上限了。

✅ 一句话概括

高精度 = 自己实现「小学竖式」。
你在小学学的加法进位、减法借位、乘法逐位相乘,就是全部的算法。 计算机只是把它一位一位地做一遍而已 —— 不需要任何新数学。

2统一模板:所有高精度题都靠这三件事

四道题看起来不一样(加法、减法、乘法、阶乘之和),但它们的骨架完全一样。 把骨架固化下来,就得到一套能反复用的模板。

第一件事:倒序存放(低位在数组前面)

小学竖式是从个位开始算的,因为进位是从低位往高位传的。 数组也应该这样:下标 0 存个位,下标 1 存十位,下标 2 存百位……

数正常写法倒序存进数组
12345"12345"a[0]=5, a[1]=4, a[2]=3, a[3]=2, a[4]=1
1000"1000"a[0]=0, a[1]=0, a[2]=0, a[3]=1
💡 为什么必须倒过来

如果不倒序,a[0] 是最高位,那么「进位」就要从数组末尾往前传, 而且两个位数不同的数相加时,还得先把它们右对齐——非常麻烦。

倒序之后:a[i] 天然对应「第 i 位」,两个数的同一位就是同一个下标, 进位也天然从下标小往下标大走。整个算法变成了一次线性扫描。

第二件事:逐位运算 + 进位 / 借位

三种运算的逐位公式:

运算第 i 位的计算进位 / 借位怎么传
加法s = a[i] + b[i] + carry本位 = s % 10,进位 = s / 10
减法t = a[i] - b[i] - borrow若 t < 0 则 t += 10、借位 = 1;否则借位 = 0
乘法c[i+j] += a[i] * b[j]先全部堆着,最后统一从低位到高位处理进位
🎯 加法和减法是一对,乘法是另一路

加法和减法边算边进位,一遍扫完就出结果。
乘法不能这样:99 × 99 里,c[1] 会被加两次(9×9 和 9×9), 所以必须先把所有乘积堆到对应位置上,最后再统一处理进位。这一点是初学者最容易写错的地方。

第三件事:去掉前导零

倒序存的时候,结果的高位可能会多出一堆 0。比如 1000 − 999 = 1, 算出来数组是 [1, 0, 0, 0],正着输出就是 0001 —— 错。

while(a.size() > 1 && a.back() == 0) a.pop_back();   // ★ 从高位往下砍 0
⚠️ > 1 这个条件不能省

如果写成 while(a.back() == 0) a.pop_back();,当结果是数字 0 时, 整个数组会被砍空,然后 a.back() 访问空数组 → 运行错误(RE)。

加 a.size() > 1 的意思是:至少给我留一位。这样 0 就变成「一位的 0」, 输出正好是 0。

完整的「六件套」模板

背下这套模板,四道题全部够用。强烈建议今天就把它抄进你自己的模板文件里。

#include <bits/stdc++.h>
using namespace std;

typedef vector<int> Big;            // 倒序存放:big[0] 是个位

// ① 字符串 → 倒序数组
Big toBig(const string &s){
    Big a;
    for(int i = (int)s.size() - 1; i >= 0; i--) a.push_back(s[i] - '0');
    return a;
}

// ② 倒序数组 → 字符串(顺便去掉前导零)
string toStr(Big a){
    while(a.size() > 1 && a.back() == 0) a.pop_back();   // ★ 去前导零
    string s;
    for(int i = (int)a.size() - 1; i >= 0; i--) s += char('0' + a[i]);
    return s;
}

// ③ 比较大小:a > b 返回 1,a < b 返回 -1,相等返回 0
int cmpBig(Big a, Big b){
    while(a.size() > 1 && a.back() == 0) a.pop_back();
    while(b.size() > 1 && b.back() == 0) b.pop_back();
    if(a.size() != b.size()) return a.size() < b.size() ? -1 : 1;
    for(int i = (int)a.size() - 1; i >= 0; i--)
        if(a[i] != b[i]) return a[i] < b[i] ? -1 : 1;
    return 0;
}

// ④ 高精度加法
Big addBig(const Big &a, const Big &b){
    Big c;
    int len = (int)max(a.size(), b.size());
    int carry = 0;                              // 进位
    for(int i = 0; i < len || carry; i++){       // ★ carry 也当循环条件
        int t = carry;
        if(i < (int)a.size()) t += a[i];
        if(i < (int)b.size()) t += b[i];
        c.push_back(t % 10);
        carry = t / 10;
    }
    return c;
}

// ⑤ 高精度减法(要求调用前保证 a >= b,结果非负)
Big subBig(const Big &a, const Big &b){
    Big c;
    int borrow = 0;                             // 借位
    for(int i = 0; i < (int)a.size(); i++){
        int t = a[i] - borrow - (i < (int)b.size() ? b[i] : 0);
        if(t < 0){ t += 10; borrow = 1; }       // ★ 不够减就借一位
        else borrow = 0;
        c.push_back(t);
    }
    return c;                                   // 前导零交给 toStr 处理
}

// ⑥ 高精度乘法
Big mulBig(const Big &a, const Big &b){
    if((a.size() == 1 && a[0] == 0) || (b.size() == 1 && b[0] == 0))
        return Big(1, 0);                       // 任何数乘 0 都是 0

    Big c(a.size() + b.size(), 0);              // 结果最多这么长
    for(int i = 0; i < (int)a.size(); i++)
        for(int j = 0; j < (int)b.size(); j++)
            c[i + j] += a[i] * b[j];            // ★ 先统统堆到 i+j 位上

    int carry = 0;                              // ★ 最后统一处理进位
    for(int i = 0; i < (int)c.size(); i++){
        int t = c[i] + carry;
        c[i]  = t % 10;
        carry = t / 10;
    }

    while(c.size() > 1 && c.back() == 0) c.pop_back();
    return c;
}
💡 为什么用 vector<int> 而不是定长数组

定长数组要自己维护「当前有多长」这个变量(比如 int len), 传参时还得把长度一起传过去,很容易忘记更新。

vector 自带 size() 和 push_back(), 长度就是数据的长度,不可能对不上。代价只是稍微慢一点点,完全可以接受。

3手推一遍加法和减法

加法:1001 + 9099

两个数倒序存:1001 → [1,0,0,1],9099 → [9,9,0,9]。

ia[i]b[i]carry(进)t = 和c[i] = t%10carry(出)
01901001
10911001
2001110
31901001
4——1110

结果数组 [0,0,1,0,1],倒过来读是 10100 ✓(1001 + 9099 = 10100)

💡 最后多出来的那个 1 是哪来的

两个 4 位数相加,结果可能是 5 位数(比如 9999 + 1 = 10000)。 所以循环条件必须写 i < len || carry: 即使两个数的位都取完了,只要还有进位,就要再补一位。

如果只写 i < len,9999 + 1 就会算成 0000 —— 这就是最经典的「加法进位丢失」bug。

减法:1000 − 999(用来看前导零)

1000 → [0,0,0,1],999 → [9,9,9]。

ia[i]b[i]borrow(借)t = 差c[i]borrow(出)
0090−9 → +10 = 111
1091−10 → +10 = 001
2091−10 → +10 = 001
31没有这一位1000

结果数组 [1,0,0,0],正着读是 0001 —— 这里就必须去前导零了, 砍掉高位的 3 个 0,剩下 [1],输出 1 ✓

🎯 减法为什么要先比大小

模板里的 subBig(a, b) 只处理 a ≥ b 的情况,因为「借位」的定义就是「上一位借给下一位」, 如果 a < b,最高位会一直借下去,借出一个负数出来,这套逻辑就崩了。

所以标准写法是:

if(cmpBig(a, b) >= 0) cout << toStr(subBig(a, b)) << endl;
else                 cout << '-' << toStr(subBig(b, a)) << endl;  // 负数:输出负号 + 绝对值的差

先判断符号、再算绝对值 —— 这就是 P2142 的全部难点。

4高精度乘法:位置对齐是关键

核心洞察:a[i] × b[j] 的结果落在第 i + j 位

为什么是 i + j?用位数来想:a[i] 代表的是 a[i] × 10ⁱ, b[j] 代表 b[j] × 10ʲ,两者相乘就是 (a[i]×b[j]) × 10^(i+j) —— 10 的幂次是 i+j,所以它属于第 i+j 位。

手推:99 × 99

两个数都是 [9, 9]。先开一个长度为 2+2 = 4 的结果数组,全部填 0。

ij乘积累加到c 数组现在的样子
009×9 = 81c[0][81, 0, 0, 0]
019×9 = 81c[1][81, 81, 0, 0]
109×9 = 81c[1][81, 162, 0, 0] ← 同一个位置被加了两次!
119×9 = 81c[2][81, 162, 81, 0]

然后从低位到高位统一处理进位:

it = c[i] + carryc[i] 最终carry
081 + 0 = 8118
1162 + 8 = 170017
281 + 17 = 9889
30 + 9 = 990

结果数组 [1,0,8,9],倒过来读是 9801 ✓(99 × 99 = 9801)

🚫 乘法最容易错的两件事

1. 边乘边进位。如果你在 c[i+j] += a[i]*b[j] 的同时就处理进位, 后面还会再往 c[i+j] 里加东西,进位就乱了。 必须先全部堆好,再统一进位(因为 c[i+j] 会被访问多次)。

2. 结果是 0 的情况。0 × 12345,两层循环算出来 c 全是 0, 去前导零之后只剩一个 0,输出 0,其实是对的 —— 但如果结果数组长度为 0 就会崩。 模板里我直接特判了「任何数乘 0 都是 0」,最省心。

💡 结果数组开多长才够

m 位数 × n 位数,结果最多 m + n 位(比如 99 × 99 是 4 位)。 所以 Big c(a.size() + b.size(), 0); 一定够,多一个都不用。

比如 2000 位数相乘,结果数组开 4000 —— 和实际输出 4000 位正好吻合(我在实测里验证过)。

顺带一个更简单的兄弟:高精度 × 低精度

如果乘数是一个普通的 int(比如 P1009 里的 i! = (i−1)! × i, 这里的 i 只是个不超过 50 的小整数),那就根本不用两层循环:

Big mulInt(const Big &a, int k){
    if(k == 0) return Big(1, 0);
    Big c;
    long long carry = 0;
    for(int i = 0; i < (int)a.size() || carry; i++){
        long long t = carry;
        if(i < (int)a.size()) t += (long long)a[i] * k;   // ★ 一位对上整个 k
        c.push_back((int)(t % 10));
        carry = t / 10;
    }
    while(c.size() > 1 && c.back() == 0) c.pop_back();
    return c;
}

它其实就是「把乘法退化成加法」:每一位乘 k 再传进位,一遍线性扫描结束。 P1009 用它比自己写高精度乘法快得多也好写得多。

⚠️ 这里的 carry 必须用 long long

a[i] * k 最大是 9 × 50 = 450,加上进位也远没超 int。 但如果 k 是个更大的数(比如 10⁹),9 × 10⁹ 就爆 int 了。 养成习惯:乘法里所有中间变量一律用 long long。

5四道例题详解

下面每道题的代码都用 g++ -std=c++14 -O2 -Wall 实际编译并运行验证过。为了每份代码都能独立编译,我只贴出本题用得到的那几个函数 (完整的「六件套」在第 2 节)。

P1601 高精度加法 普及- · 加法模板

题意:给定两个非负整数 a、b,求 a + b。题目明确说了不用考虑负数。

⚠️ 数据范围 / 关键约束

20% 的数据 a, b ≤ 10⁹;40% 的数据 a, b ≤ 10¹⁸;100% 的数据 0 ≤ a, b ≤ 10⁵⁰⁰。

看清楚:10⁵⁰⁰ 是 1 后面 500 个 0,也就是最多 501 位。
long long 只有 19 位,所以 40% 那两档用 long long 能过, 但满分必须用高精度。这就是"子任务分"的典型结构 —— 比赛时不会写高精度也先把 40 分拿了。

另外注意 a、b 都可以是 0,别漏了 0 + 0 这种情况。

思路

就是第 3 节手推的那套:读成字符串 → 倒序存 → 逐位相加并传进位 → 去前导零 → 倒着输出拼回字符串。

关键的一句是循环条件 i < len || carry,它保证了「最高位进位」不会丢。

样例输入 1
1
1
样例输出 1
2
样例输入 2
1001
9099
样例输出 2
10100

完整代码

#include <iostream>
#include <string>
#include <vector>
#include <algorithm>
using namespace std;

typedef vector<int> Big;            // 倒序存放:big[0] 是个位

Big toBig(const string &s){
    Big a;
    for(int i = (int)s.size() - 1; i >= 0; i--) a.push_back(s[i] - '0');
    return a;
}

string toStr(Big a){
    while(a.size() > 1 && a.back() == 0) a.pop_back();   // ★ 去前导零
    string s;
    for(int i = (int)a.size() - 1; i >= 0; i--) s += char('0' + a[i]);
    return s;
}

Big addBig(const Big &a, const Big &b){
    Big c;
    int len = (int)max(a.size(), b.size());
    int carry = 0;                              // 进位
    for(int i = 0; i < len || carry; i++){       // ★ carry 也当循环条件
        int t = carry;
        if(i < (int)a.size()) t += a[i];
        if(i < (int)b.size()) t += b[i];
        c.push_back(t % 10);
        carry = t / 10;
    }
    return c;
}

int main(){
    string sa, sb;
    cin >> sa >> sb;
    cout << toStr(addBig(toBig(sa), toBig(sb))) << endl;
    return 0;
}
✅ 实测结果

官方样例 1 1 / 1 → 2 完全一致

官方样例 2 1001 / 9099 → 10100 完全一致

边界 0 / 0 → 0 正确(去前导零的 > 1 条件生效,没把数组砍空)

边界 10⁵⁰⁰ / 1(1 后面 500 个 0 加上 1)→ 输出 501 位,开头是 1000…、结尾是 …0001 正确

边界 500 个 9 加 1 → 输出 1 后面 500 个 0,共 501 位 正确(这是纯进位测试,验证了 || carry 那一句)

随机对拍:用 40 组随机大数(位数 1 ~ 60)和 PowerShell 的 bigint 精确值逐一比对, 加法 40 组全部一致(同一次对拍里还顺带验证了乘法和减法,见下面两题)。

易错点

  • 循环条件漏了 || carry:999 + 1 会输出 000。
  • 用 cin >> long long 读:501 位的数读进来直接失败(这是 40 分和 100 分的分界线)。
  • 忘了去前导零:1 + 1 会输出 02。
  • 字符串转数组时忘了减 '0':存进去的是 ASCII 码 48 ~ 57,算出来全是错的。
  • a、b 有前导零:题目说「非负整数」,理论上输入可能写成 00123。 我的模板对这种情况天然兼容(去前导零在 toStr 里做了,输入有前导零也不影响求和)。
P2142 高精度减法 普及- · 减法 + 前导零

题意:给定两个正整数 a、b,求 a − b。如果结果是负数,要输出负号。

⚠️ 数据范围 / 关键约束

20% 的数据 a、b 在 long long 存储范围内; 100% 的数据 0 < a, b ≤ 10¹⁰⁰⁸⁶。

注意两点:① 10¹⁰⁰⁸⁶ 是 10087 位数,相当大(这就是为什么必须用 vector 而不是固定数组 —— 也有人直接开 int[10100]); ② 题目没说 a ≥ b,而且输出格式里专门写了「如果 a − b < 0,请输出负号」, 所以结果可能是负的,必须处理。

思路

减法模板只能算「大减小」,所以分两步:

  1. 先用 cmpBig 比较 a 和 b 的大小。
  2. 如果 a ≥ b,直接输出 subBig(a, b);否则输出一个负号,再输出 subBig(b, a)。
🎯 cmpBig 怎么写才不出错

不能直接比字符串("100" < "99" 是真的,但 100 > 99)。正确的比较顺序是:

① 先去掉两边的前导零(否则 "099" 会被当成 3 位数)
② 位数不同 → 位数多的更大
③ 位数相同 → 从最高位往下逐位比,第一个不同的位决定大小
④ 全都相同 → 相等

样例输入
2
1
样例输出
1

完整代码

#include <iostream>
#include <string>
#include <vector>
using namespace std;

typedef vector<int> Big;

Big toBig(const string &s){
    Big a;
    for(int i = (int)s.size() - 1; i >= 0; i--) a.push_back(s[i] - '0');
    return a;
}

string toStr(Big a){
    while(a.size() > 1 && a.back() == 0) a.pop_back();
    string s;
    for(int i = (int)a.size() - 1; i >= 0; i--) s += char('0' + a[i]);
    return s;
}

// 比较大小(先去掉前导零再比):a > b 返回 1,a < b 返回 -1,相等返回 0
int cmpBig(Big a, Big b){
    while(a.size() > 1 && a.back() == 0) a.pop_back();   // ★ 必须先规范化
    while(b.size() > 1 && b.back() == 0) b.pop_back();
    if(a.size() != b.size()) return a.size() < b.size() ? -1 : 1;   // 位数多的更大
    for(int i = (int)a.size() - 1; i >= 0; i--)                      // 从高位往下逐位比
        if(a[i] != b[i]) return a[i] < b[i] ? -1 : 1;
    return 0;
}

// 高精度减法(要求已经保证 a >= b,结果非负)
Big subBig(const Big &a, const Big &b){
    Big c;
    int borrow = 0;                             // 借位
    for(int i = 0; i < (int)a.size(); i++){
        int t = a[i] - borrow - (i < (int)b.size() ? b[i] : 0);
        if(t < 0){ t += 10; borrow = 1; }       // ★ 不够减,向前借一位
        else borrow = 0;
        c.push_back(t);
    }
    return c;                                   // 前导零交给 toStr 处理
}

int main(){
    string sa, sb;
    cin >> sa >> sb;

    Big a = toBig(sa), b = toBig(sb);

    if(cmpBig(a, b) >= 0){
        cout << toStr(subBig(a, b)) << endl;
    }else{
        cout << '-' << toStr(subBig(b, a)) << endl;   // ★ 负号 + 绝对值的差
    }
    return 0;
}
✅ 实测结果

官方样例 2 / 1 → 1 完全一致

前导零测试 1000 / 999 → 1 正确(不是 0001)

负数测试 1 / 2 → -1 正确(负号输出正确)

相等测试 1000000 / 1000000 → 0 正确(不是空输出、也不崩)

最大规模 10¹⁰⁰⁸⁶ / 1(a 是 1 后面 10086 个 0,即 10087 位数,正好是题面上限) → 输出 10086 位,每一位都是 9,与 10¹⁰⁰⁸⁶ − 1 的理论值逐字符精确一致 正确

随机对拍:40 组随机大数(位数 1 ~ 60,故意让 a 可能小于 b)和 bigint 精确值比对, 减法 40 组全部一致,含负数结果也正确。

易错点

  • 直接用字符串比大小:"100" < "99",位数不同的比较一定错。必须比长度。
  • 比大小前忘了去前导零:"099" 长度是 3,会被误判成比 "100" 还大吗? 其实 "099" vs "100" 长度相同,逐位比第一位 0 < 1 就对了; 但 "099" vs "9" 长度 3 > 1 就会误判成 099 > 9。所以 先规范化。
  • 忘了 a < b 的情况:只写 subBig 不判符号,会输出一堆乱码(借位借穿了)。
  • 负数结果是 0 的时候:1 − 1 = 0,cmpBig 返回 0,走的是 >= 0 分支, 输出 0,不会输出 -0。如果你写成 if(cmpBig(a,b) > 0), 那么 1 − 1 就会走 else 分支输出 -0 —— 这是个很隐蔽的坑。
  • 借位忘了减:t = a[i] - b[i] 少了 - borrow,结果会偏大。
P1303 A*B Problem 普及- · 乘法模板

题意:给出两个非负整数,求它们的乘积。

⚠️ 数据范围 / 关键约束

每个非负整数不超过 10²⁰⁰⁰。

10²⁰⁰⁰ 是 2001 位数,乘起来结果是 4000 位左右。 题目说「非负」,所以 0 也要能正确处理。

另外这题没有子任务分,long long 一分都拿不到,必须老老实实写高精度。

思路

套第 4 节的乘法模板。三个关键点再强调一次:

  1. 结果数组开 a.size() + b.size() 位 —— m 位数乘 n 位数最多 m + n 位。
  2. c[i + j] += a[i] * b[j],先全部堆着。
  3. 最后从低位到高位统一处理进位。
样例输入
1 
2
样例输出
2

完整代码

#include <iostream>
#include <string>
#include <vector>
using namespace std;

typedef vector<int> Big;

Big toBig(const string &s){
    Big a;
    for(int i = (int)s.size() - 1; i >= 0; i--) a.push_back(s[i] - '0');
    return a;
}

string toStr(Big a){
    while(a.size() > 1 && a.back() == 0) a.pop_back();
    string s;
    for(int i = (int)a.size() - 1; i >= 0; i--) s += char('0' + a[i]);
    return s;
}

// 高精度乘法
Big mulBig(const Big &a, const Big &b){
    if((a.size() == 1 && a[0] == 0) || (b.size() == 1 && b[0] == 0))
        return Big(1, 0);                       // ★ 任何数乘 0 都是 0

    Big c(a.size() + b.size(), 0);              // 结果最多这么长
    for(int i = 0; i < (int)a.size(); i++)
        for(int j = 0; j < (int)b.size(); j++)
            c[i + j] += a[i] * b[j];            // ★ 先统统堆到 i+j 位上

    int carry = 0;                              // ★ 最后统一处理进位
    for(int i = 0; i < (int)c.size(); i++){
        int t = c[i] + carry;
        c[i]  = t % 10;
        carry = t / 10;
    }

    while(c.size() > 1 && c.back() == 0) c.pop_back();
    return c;
}

int main(){
    string sa, sb;
    cin >> sa >> sb;
    cout << toStr(mulBig(toBig(sa), toBig(sb))) << endl;
    return 0;
}
✅ 实测结果

官方样例 1 / 2 → 2 完全一致

边界 0 / 999 → 0 正确(乘 0 特判生效)

999 / 999 → 998001 正确

20 个 9 × 20 个 9 → 9999999999999999999800000000000000000001(40 位) 正确 (验算:(10²⁰−1)² = 10⁴⁰ − 2×10²⁰ + 1)

最大规模 2000 个 9 × 2000 个 9 → 输出 4000 位,和理论值 (10²⁰⁰⁰−1)² = 10⁴⁰⁰⁰ − 2×10²⁰⁰⁰ + 1 的字符串逐字符精确比对通过(1999 个 9、1 个 8、1999 个 0、1 个 1)。

2000 个 7 × 1 → 输出 2000 位,和输入完全一样 正确(边界:乘数是 1 位数)

随机对拍:40 组随机大数(位数 1 ~ 60)与 bigint 精确值比对,乘法 40 组全部一致。

易错点

  • 边乘边进位:c[i+j] 会被加多次,先堆后进位是硬性要求。
  • 结果数组开小了:开成 a.size() + b.size() - 1 在最高位有进位时会越界。 直接开 a.size() + b.size(),多一位不用白不用。
  • 最多只有 4000 位,别开 10⁶ 的数组:按输入长度动态开就好,用 vector 最省心。
  • 乘数是 0 时输出了空串:如果不用 toStr 里的 > 1 保护,数组会被砍空。
  • 这题没有负数和前导零的坑(题面说非负整数),但下一题 P1009 会用到乘法模板的变体。
P1009 [NOIP 1998 普及组] 阶乘之和 普及- · 高精度综合应用

题意:用高精度算出 S = 1! + 2! + 3! + … + n!,n ≤ 50。

⚠️ 数据范围 / 关键约束

1 ≤ n ≤ 50。

看起来很小,但 50! 大约是 3.04×10⁶⁴,有 65 位 —— long long 差得远。

题目里还有一句提醒很值得注意:《深入浅出基础篇》拿这题当例题,但书上数据只有 n ≤ 20, 用书里的代码过不了本题。 20! 约 2.4×10¹⁸ 刚好卡在 long long 边缘 —— 这正是「数据范围决定算法」的活教材。

思路

不要想着「先算 50! 再回头算 49!」—— 那样每次都要重算一遍乘法,很浪费。递推着来:

步骤做什么用哪个模板函数
初始fact = 1(代表 0!),sum = 0—
循环 i = 1…nfact = fact × i → 得到 i!mulInt(高精度 × 低精度)
同上sum = sum + factaddBig
结束输出 sumtoStr
🎯 为什么这里用「高精度 × 低精度」而不是完整乘法

因为乘数 i 最大就是 50,是个普通 int。 mulInt 只要一遍线性扫描(O(位数)),而 mulBig 要两层循环(O(位数²))。
n = 50 时位数才 65,两者都很快,但 「能用低精度就别上高精度」是个好习惯 —— 如果哪天要算 100000!,差距就是几万倍。

先手算一遍小数据(n = 3)

ifact = fact × isum = sum + fact
开始10
11 × 1 = 10 + 1 = 1
21 × 2 = 21 + 2 = 3
32 × 3 = 63 + 6 = 9

1! + 2! + 3! = 1 + 2 + 6 = 9 ✓ 正好是样例输出。

样例输入
3
样例输出
9

完整代码

#include <iostream>
#include <string>
#include <vector>
#include <algorithm>
using namespace std;

typedef vector<int> Big;

Big toBig(const string &s){
    Big a;
    for(int i = (int)s.size() - 1; i >= 0; i--) a.push_back(s[i] - '0');
    return a;
}

string toStr(Big a){
    while(a.size() > 1 && a.back() == 0) a.pop_back();
    string s;
    for(int i = (int)a.size() - 1; i >= 0; i--) s += char('0' + a[i]);
    return s;
}

// 高精度加法
Big addBig(const Big &a, const Big &b){
    Big c;
    int len = (int)max(a.size(), b.size());
    int carry = 0;
    for(int i = 0; i < len || carry; i++){
        int t = carry;
        if(i < (int)a.size()) t += a[i];
        if(i < (int)b.size()) t += b[i];
        c.push_back(t % 10);
        carry = t / 10;
    }
    return c;
}

// 高精度 × 低精度(k 是一个普通的 int)
Big mulInt(const Big &a, int k){
    if(k == 0) return Big(1, 0);
    Big c;
    long long carry = 0;
    for(int i = 0; i < (int)a.size() || carry; i++){
        long long t = carry;
        if(i < (int)a.size()) t += (long long)a[i] * k;   // ★ 一位对上整个 k
        c.push_back((int)(t % 10));
        carry = t / 10;
    }
    while(c.size() > 1 && c.back() == 0) c.pop_back();
    return c;
}

int main(){
    int n;
    cin >> n;

    Big fact = toBig("1");      // 当前的阶乘 i!
    Big sum  = toBig("0");      // 累加和

    for(int i = 1; i <= n; i++){
        fact = mulInt(fact, i);     // i! = (i-1)! * i
        sum  = addBig(sum, fact);   // 累加
    }

    cout << toStr(sum) << endl;
    return 0;
}
✅ 实测结果

官方样例 3 → 9 完全一致

边界 n = 1 → 1 正确

n = 50(最大数据) → 输出 65 位:
31035053229546199656252032972759319953190362094566672920420940313

全程对拍:把 n = 1, 2, 3, …, 50 全部 50 个答案都算出来, 和用任意精度整数(bigint)独立算出的精确值逐一比对,50 个全部一致,0 个失败。

易错点

  • 把 fact 初始化成 0:那样整个循环乘出来一直是 0,答案也是 0。 必须初始成 1(0! = 1 是数学定义)。
  • 每次重新从头算阶乘:for i: for j = 1..i: fact *= j, 虽然 n ≤ 50 也能过,但完全没必要 —— 用递推 i! = (i−1)! × i 一遍就够。
  • 乘低精度时进位用 int:k 一大就溢出了。统一用 long long。
  • 忘了 sum 也要用高精度:50 个 65 位的数相加,肯定也是高精度。
  • 以为 n ≤ 20 就够了:题目提示专门说了书上代码只支持 n ≤ 20, 必须真的用高精度才能过 n = 50。

✗今日常见错误汇总

#错误写法后果正确做法
1 正序存(a[0] 是最高位) 进位方向反了,两个不同位数的数还得右对齐,代码立刻复杂一倍 低位在数组前面:a[0] = 个位
2 转数组时忘了 - '0' 存的是 ASCII 码 48~57,算出来全是垃圾 a.push_back(s[i] - '0')
3 加法循环只写 i < len 999 + 1 = 000,最高位进位丢掉了 i < len || carry
4 去前导零写 while(a.back()==0) pop_back() 结果是 0 时数组被砍空 → RE 加保护:while(a.size() > 1 && a.back()==0)
5 忘了去前导零 1000−999 输出 0001 统一在 toStr 里做,所有输出都过一遍
6 减法不比较大小 a < b 时借位借穿,输出乱码 先 cmpBig,负数就输出负号 + subBig(b,a)
7 用字符串直接比大小 "100" < "99" 恒为真,判断全反 先比位数,位数相同再从高位逐位比
8 乘法边乘边进位 c[i+j] 后面还会被累加,进位全乱 先全部 c[i+j] += a[i]*b[j],最后统一进位
9 乘法结果数组开 m+n-1 最高位有进位时越界 开 a.size() + b.size()
10 P1009 的 fact 初始化成 0 答案恒为 0 初始化成 1(0! = 1)
11 中间变量用 int 乘法/进位悄悄溢出 高精度里的乘法、进位一律 long long
12 看到大数才想起 long long 不够 WA 半天找不到原因(溢出无提示) 读到「≤10^k」先估位数,超过 18 位就要高精度

✓自测:你真的会了吗

第 1 题:为什么高精度要「倒序存放」?

答:因为进位和借位都是从低位往高位传的。

倒序存放(a[0] = 个位)之后,第 i 位就是下标 i,进位只需要「往后一个下标传」, 整个算法变成一次从下标 0 到末尾的线性扫描。

顺带还解决了位数对齐问题:1234 + 56,倒序后是 [4,3,2,1] 和 [6,5], 第二位直接取 a[1]=3、b[1]=5,天然的个位对齐个位。如果正序存,还得先把两个数右对齐。

第 2 题:9999 + 1 用模板算,结果数组会是什么?

答:[0, 0, 0, 0, 1],倒过来读是 10000。

9999 → [9,9,9,9],1 → [1]。

i=0: 9+1 = 10 → c[0]=0, carry=1
i=1: 9+0+1 = 10 → c[1]=0, carry=1
i=2: 9+0+1 = 10 → c[2]=0, carry=1
i=3: 9+0+1 = 10 → c[3]=0, carry=1
i=4: i < len(4) 不成立,但 carry = 1 成立 → 再补一位 c[4]=1

如果循环条件少了 || carry,第 5 位就不会产生,输出变成 0000。

第 3 题:手推 12 × 34,把乘法的两个阶段都写出来。

答:12 → [2,1],34 → [4,3]。结果数组长度 2+2 = 4,初始 [0,0,0,0]。

阶段一 · 堆乘积:

  • i=0,j=0: 2×4 = 8 → c[0] = 8
  • i=0,j=1: 2×3 = 6 → c[1] = 6
  • i=1,j=0: 1×4 = 4 → c[1] = 6+4 = 10
  • i=1,j=1: 1×3 = 3 → c[2] = 3

得到 [8, 10, 3, 0]。注意 c[1] = 10 已经超过 9 了,这是正常的,进位留到第二阶段。

阶段二 · 统一进位:

  • i=0: t = 8+0 = 8 → c[0]=8, carry=0
  • i=1: t = 10+0 = 10 → c[1]=0, carry=1
  • i=2: t = 3+1 = 4 → c[2]=4, carry=0
  • i=3: t = 0+0 = 0 → c[3]=0, carry=0

[8,0,4,0] → 去前导零 → [8,0,4] → 倒读 408 ✓(12 × 34 = 408)

第 4 题:为什么 P1009 用 mulInt 而不直接用 mulBig?

答:因为乘数 i 是个小整数(≤ 50),不是高精度数。

mulInt 把「每一位乘 k」一次扫完,复杂度 O(位数); mulBig 要两层嵌套循环,复杂度 O(位数²)。 把 i 硬转成高精度再乘,白白多算一位 × 一位的乘法。

n = 50 时两者都很快,但习惯要养对:能用低精度的场合就别上高精度。

第 5 题:0 × 0 用乘法模板算,会发生什么?

答:模板里第一句就特判了:a[0]==0 || b[0]==0 时直接返回 Big(1, 0), 也就是「长度为 1、内容为 0」的数组。经过 toStr 输出就是 0。

如果不特判:c 长度是 1+1 = 2,全是 0;去前导零后剩 [0],输出也是 0 —— 其实也对。 特判主要是为了省掉无谓的循环,同时避免「长度为 0 的数组」这种危险状态。

▤速查卡(截图存手机)

【高精度 · 三件事】
  ① 倒序存      a[0] 是个位,a[1] 是十位 …
  ② 逐位算      + 进位 / - 借位 / × 先堆后排
  ③ 去前导零    while(size > 1 && back() == 0) pop_back();

【为什么倒序】
  进位、借位都是从低位往高位传
  倒序后第 i 位 = 下标 i,天然对齐,线性扫一遍

【六件套】
  Big  toBig(const string &s);     // 字符串 → 倒序数组
  string toStr(Big a);             // 倒序数组 → 字符串(去前导零)
  int  cmpBig(Big a, Big b);       // 先比位数,再逐位比
  Big  addBig(const Big &a, const Big &b);
  Big  subBig(const Big &a, const Big &b);   // 要求 a >= b
  Big  mulBig(const Big &a, const Big &b);
  Big  mulInt(const Big &a, int k);          // 高精度 × 低精度

【加法】
  carry = 0;
  for(i = 0; i < len || carry; i++){
      t = carry + a[i] + b[i];
      c.push_back(t % 10);
      carry = t / 10;
  }
  // ★ 少了 "|| carry",999+1 会变成 000

【减法】
  borrow = 0;
  for(i = 0; i < a.size(); i++){
      t = a[i] - borrow - b[i];
      if(t < 0){ t += 10; borrow = 1; } else borrow = 0;
      c.push_back(t);
  }
  // 先用 cmpBig 判符号:a<b 就输出 '-' 再算 subBig(b,a)
  // ★ 相等时走 >= 分支,不会输出 -0

【乘法】
  // 阶段一:先堆,不进位
  c.assign(a.size() + b.size(), 0);
  for(i) for(j) c[i + j] += a[i] * b[j];
  // 阶段二:统一进位
  carry = 0;
  for(i = 0; i < c.size(); i++){ t = c[i] + carry; c[i] = t % 10; carry = t / 10; }
  // ★ a[i]*b[j] 落在第 i+j 位
  // ★ 结果最多 m+n 位

【什么时候该上高精度】
  ≤ 9 位   → int
  ≤ 18 位  → long long
  > 18 位  → 高精度
  看到「阶乘」「大数乘积」「≤10^k 且 k ≥ 19」→ 立刻警觉

【P1009 阶乘之和】
  fact = 1;  sum = 0;
  for(i = 1..n){ fact = mulInt(fact, i); sum = addBig(sum, fact); }
  // ★ fact 初始是 1 不是 0(0! = 1)

🎓30 天总结 · 你走过的路

恭喜你走到这里。回头看,30 天前你还在为「输入两个数输出它们的和」调格式, 今天你已经能手写高精度乘法、能分析二分答案的单调性、能设计出背包的状态转移方程了。 这不是一点点进步,这是换了一个人。

知识脉络:这 30 天到底学了什么

阶段主题核心收获
Day 1–6 语法与数组基本功
评测机制与输入输出 / 循环与累加(整数浮点陷阱)/ while 与终止条件 / 多重循环与图形输出 / 一维数组 / 桶计数
把「想法翻译成代码」这件事练到不用想;理解数据范围决定变量类型;学会「用值当下标」这个影响深远的思路
Day 7–14 二维数组、字符串、函数与贪心
二维数组 / 字符与字符串 / 数字进制格式 / 函数 / 结构体 / 排序 / 贪心入门 / 贪心进阶
从「写一段代码」升级到「写一个模块」;掌握 sort 和自定义比较函数;第一次接触「证明贪心正确性」这件事
Day 15–21 前缀和、递归、搜索
前缀和与差分 / 递归 / 递推与记忆化 / DFS 入门 / DFS 回溯 / BFS 入门 / BFS 进阶
学会「用预处理换查询速度」;掌握递归的思考方式;能独立写出 DFS 回溯和 BFS 最短路 —— 这是第一次真正意义上的算法
Day 22–28 二分、动态规划、栈与队列
二分查找 / 二分答案 / 二分综合 / 线性 DP / 0-1 背包 / 完全背包 / 栈与队列
掌握「二分答案」这个把求解变判定的强套路;跨过 DP 这道大坎(状态设计 + 转移方程 + 边界);熟悉 STL 容器
Day 29–30 模拟赛 + 高精度
限时模拟赛 / 高精度加减乘、阶乘之和
把前面所有基本功在压力下用一遍;补上最后一块拼图 —— 没有内置类型够用时的兜底手段

回头看:这 30 天你反复在学的其实是同一件事

🎯 三条贯穿始终的主线

主线一:用空间换时间。桶计数是(用数组换 O(1) 查询)、前缀和是(用数组换 O(1) 区间和)、 记忆化搜索是(用数组换重复子问题)、DP 也是(用数组换指数级枚举)。这是算法里最核心的交易。

主线二:把大问题切成小问题。递归、分治、二分、DP —— 全都是这一个思想的不同外衣。

主线三:先把边界想清楚再写代码。数组开多大、从 0 还是 1 开始、 空集怎么处理、n = 0 / n = 1 怎么办 —— 你这一个月踩的坑,十有八九都在这里。

整月总结任务(今天就做完)

  1. 统计你的 AC 数量。按难度分三类:入门 / 普及- / 普及提高-。 数字本身不重要,重要的是看哪一类最少 —— 那就是你的短板。
  2. 把整月的错题本从头读一遍。不要重做,只读。然后把反复犯的错误写成一张 「个人避坑清单」,贴在电脑旁边。常见的几条:
    • 数组开小了 / 下标从 1 开始但只开了 n
    • 该开 long long 却开了 int
    • 循环边界写成 <= 或 < 搞反
    • 输出格式:多余空格、少空格、大小写、换行
    • 多测数据没有清空全局数组
    • 去前导零 / 空集 / n = 0 这些边界没处理
  3. 建立你自己的代码模板文件。建议包含: 万能头与快读、素数筛、二分模板(整数 + 浮点)、DFS / BFS 模板、背包模板、 以及今天这套高精度六件套。写一次,用一辈子。
  4. 完成最终自测(见下)。

最终自测:2 小时内独立 AC 这 4 题

题号题目考什么难度
P1047校门外的树数组标记(Day 5–6 的基本功)入门
P1094纪念品分组排序 + 贪心双指针(Day 12–13)普及-
P1605迷宫DFS 回溯 + 网格(Day 19)普及-
P1048采药0/1 背包(Day 26)普及-
✅ 四题全部 AC,说明什么

说明你已经具备参加新生赛的实力了。

这四道题分别考察数组、贪心、搜索、DP —— 正好覆盖了新生赛最常考的四个方向, 而且没有一道是「偏题」。能两小时内全过,你的基本功是扎实的。

如果卡住了,别灰心,去翻对应的那一天: 哪一题卡住,就说明那一块需要二刷。这本身就是最有价值的诊断结果。

下一步建议

方向具体建议
短期
(1–2 周)
· 把 30 天里所有没做出来或看了题解才会的题重写一遍(不看题解)
· 在洛谷「官方题单 → 新手村 / 普及练习场」里按专题刷,每个专题刷 5–10 题
· 重点补:DP 和 搜索。这两块是分水岭,也是 30 天里练得最少的
中期
(1–2 月)
· 学新知识点:并查集、图论基础(最短路、最小生成树)、树与二叉树、STL 深水区(map / set / priority_queue)
· 开始打 洛谷月赛 / Div.3 难度的比赛,每场都写赛后复盘
· 建立「一题三刷」习惯:第一遍看思路、第二遍不看题解、第三遍限时重写
长期 · 目标定在 CSP-J / 蓝桥杯省赛 这类入门级赛事
· 从「会做」升级到「做得快、写得稳」—— 这需要模板化 + 限时训练
· 保持一周至少 5 题的节奏,比一次爆刷 50 题有用得多
习惯 · 每道 AC 的题都写 2–3 行题解笔记(记录关键洞察,不用长)
· 每场比赛后统计:哪题超时、哪题 WA 几次、下次怎么改
· 永远先看数据范围,再想算法
💌 最后几句话

算法竞赛最有趣的地方在于:你不会的东西,一定是能被学会的。 今天让你抓耳挠腮的 DP,三个月后就是肌肉记忆;

这 30 天你最大的收获其实不是某个具体算法,而是「遇到不会的题不慌,能自己拆解、查资料、试错、最终解决」的能力。 这个能力跟竞赛无关,但会跟着你很久。

计划到这里就结束了,但你的路才刚开始。去做那道你一直不敢点开的题吧。 🎉