0题目原文(本地存了一份)
原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。
转录自洛谷 P1009,日期见页头。两边不一致时信原站。
题目描述
用高精度计算出 S = 1! + 2! + 3! + … + n!(n ≤ 50)。
其中 ! 表示阶乘,定义为 n! = n × (n−1) × (n−2) × … × 1。例如,5! = 5 × 4 × 3 × 2 × 1 = 120。
输入格式
一个正整数 n。
输出格式
一个正整数 S,表示计算结果。
数据范围
对于 100% 的数据,1 ≤ n ≤ 50。
【其他说明】
注,《深入浅出基础篇》中使用本题作为例题,但是其数据范围只有 n ≤ 20,使用书中的代码无法通过本题。
如果希望通过本题,请继续学习第八章高精度的知识。
NOIP1998 普及组 第二题
时限 1 秒,内存 125 MB(128000 KB)。
输入输出样例
输入
3
输出
9
1! + 2! + 3! = 1 + 2 + 6 = 9。⚠ 第 5 步会看到,这一组挡住了三个错法里的一个。
1★★ 这道题的题面,自己把「暴力挂在哪儿」写出来了
【其他说明】那一段是本书见过最直白的一次:出题人不但告诉你第一版会挂, 还告诉你它挂在哪儿。而那条线是能精确算出来的:
| n | S(n) | 结论 |
|---|---|---|
| 20 | 2561327494111820313(19 位) | long long 上限 9.22×10¹⁸ ⇒ ★ 装得下 |
| 21 | 21! 本身就是 5.1×10¹⁹ |
★ 第一个装不下的 |
⇒ 这一版在 n ≤ 20 上逐字节正确,n ≥ 21 起全错 —— 而题面的 n 可以到 50。
★ 本页第 6 步把 50 个 n 全跑了一遍,实测第一个出错的 n 正是 21,
和书里那个「n ≤ 20」严丝合缝。
// ✗ 第 ① 版:64 位整数累加 —— 这就是题面点名的「书里那份过不了的代码」//// 题面的【其他说明】里写着:// 「《深入浅出基础篇》中使用本题作为例题,但是其数据范围只有 n ≤ 20,// 使用书中的代码无法通过本题。」//// ⇒ ★★ 这是本书见过的**最直白的一次**:出题人不但告诉你暴力会挂,// 还告诉你它挂在哪儿。而那条线是能精确算出来的://// n = 20:S = 2561327494111820313 —— 19 位,long long(上限 9.22×10¹⁸)**装得下**// n = 21:21! 就已经是 5.1×10¹⁹ —— **第一个装不下的**//// ⇒ 这一版在 n ≤ 20 上**逐字节正确**,n ≥ 21 起全错。而题面的 n 可以到 50。//// ⚠ 用 unsigned long long 是为了让它错得可复现(有符号溢出是 UB);// unsigned 的溢出是「模 2⁶⁴ 绕回」,有定义 —— 所以它会安静地打出一个错的数,不会崩。
#include <bits/stdc++.h>using namespace std;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n = 0; if (!(cin >> n)) return 0;
unsigned long long cur = 1, sum = 0; for (int i = 1; i <= n; i++) { cur *= (unsigned long long)i; sum += cur; } cout << sum << "\n"; return 0;}点「运行 ▶」看结果
2★ 正解:一个循环,高精度加法 + 高精度乘低精度
cur = 1 // cur 始终是 i!
for i = 1..n: cur *= i // 高精度 × 低精度(i ≤ 50,一个 int 就够)
sum += cur // 高精度 + 高精度★ 「高精乘低精」和第 44 章第 8 步那个「高精乘高精」不是一回事,
它便宜得多:一遍扫过去,每一位乘上 k 再进位 —— O(len),而不是 O(len × len)。
⚠⚠ 而它有一个加法没有的坑:它的进位可以大于 9。
加法的进位最大是 1(9 + 9 + 1 = 19);乘低精的进位是 (9k + 上次进位) / 10,
k 到 50 时它能爬到 33(本页第 6 步数出来的最大值)。
⇒ 所以收尾那句必须是
while (carry) { push(carry % 10); carry /= 10; } // ★ 是 while写成「if (carry) 补一位」就会丢位 —— 而这个坑只属于乘法。
// P1009 [NOIP 1998 普及组] 阶乘之和 —— ★ 正解:高精度加法 + 高精度乘低精度//// ★★ 这道题是「什么时候必须上高精度」最经典的一个例子,而且**题面自己把理由写出来了**:// 「注,《深入浅出基础篇》中使用本题作为例题,但是其数据范围只有 n ≤ 20,// 使用书中的代码无法通过本题。」// —— 书里那份代码就是 p1009Ll.cpp(64 位整数累加),而那条线**精确在 n = 21**。//// 算法只有三行,一个循环就够(**不用每次重算阶乘**)://// cur = 1 // cur 始终是 i!// for i = 1..n: cur *= i // 高精度 × 低精度(i ≤ 50,一个 int 就够)// sum += cur // 高精度 + 高精度//// ★ 「高精乘低精」和第 8 步那个「高精乘高精」不是一回事,它便宜得多:// 一遍扫过去,每一位乘上 k 再进位 —— O(len),而不是 O(len × len)。// ⚠ 而它的进位**可以大于 9**:一位最多 9×50 + 上一次的进位 ⇒ 进位能到 40 多。// 所以收尾那句必须是 `while (carry) { push(carry % 10); carry /= 10; }`,// 写成「if (carry) 补一位」就会丢位(p1009Tail.cpp 就是它)。//// 顶格 n = 50:50! 有 65 位,答案 S 也是 65 位(★ 50 个 n 里两者位数**从没不同过**)// —— 一共只有 **1355** 次一位数乘法。
#include <bits/stdc++.h>using namespace std;
using Big = vector<int>; // 下标 0 是个位
static void trim(Big& v) { while (v.size() > 1 && v.back() == 0) v.pop_back(); }
/** 高精度 × 低精度:一遍扫过去,进位带着走 */static Big mulSmall(const Big& a, int k) { Big c; int carry = 0; for (size_t i = 0; i < a.size(); i++) { int cur = a[i] * k + carry; c.push_back(cur % 10); carry = cur / 10; } while (carry) { c.push_back(carry % 10); carry /= 10; } // ★ 是 while,不是 if trim(c); return c;}
static 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;}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n = 0; if (!(cin >> n)) return 0;
Big cur{1}, sum{0}; for (int i = 1; i <= n; i++) { cur = mulSmall(cur, i); // cur = i! sum = add(sum, cur); }
string s; for (int i = (int)sum.size() - 1; i >= 0; i--) s += char('0' + sum[i]); cout << s << "\n"; return 0;}点「运行 ▶」看结果
3⚠ 两个错法,都长在「乘低精」那三行里
c[i] = (a[i] * k + carry) % 10;
carry = (c[i] * k + carry) / 10; // ⚠ 用的是**已经改过**的那一位,应该是 a[i]中间量只算一次是对的;拆成两句之后,第二句引用的已经不是原来那个值了。
★ 它错得非常早:n = 3 那一步算 2 × 3,正解的进位是 6 / 10 = 0,
而它拿改过的 c[0] = 6 去算,得到 (6×3 + 0)/10 = 1 ⇒ 3! 被算成 16,
于是 S(3) 打出 19(正解 9)。
4★★★ 这道题的输入空间只有 50 个整数 —— 所以这一页没有生成器
本书前面那么多页都在写生成器、调档位、数抓获率 —— 而这道题一个生成器都不用写:
它的全部合法输入只有 50 个整数。
⇒ 直接把 n = 1..50 全跑一遍,每个版本从哪个 n 开始错是一个精确的整数,
不是一个「300 轮抓到多少次」。
⇒ 「输入空间小的时候,算一遍比对拍又快又充分」的又一次 ——
★ 而这一次连「小」都不用打折:50 个。
| 版本 | 第一个出错的 n |
说明 |
|---|---|---|
| ✗ 64 位整数累加 | ★ 21 | 和题面点名的「书里那份只到 n ≤ 20」严丝合缝 |
| ✗ 收尾只补一位进位 | ★ 15 | ⇒ n ≤ 14 它逐字节正确 |
| ✗ 先写回再算进位 | ★ 3 | ⇒ 官方样例 n = 3 一测就死 |
| ★ 每次从头重算阶乘 | 从不出错 | 它是对的,只是多做了 15.2 倍的活 |
| ★ 高位在前的字符串版 | 从不出错 | 表示和正解互为镜像 —— 它是这一页的参照物 |
★★ 而「收尾只补一位」那条线能对上另一个独立数出来的量:
乘低精的收尾进位第一次 ≥ 10,正是在 n = 15(最大能到 33)。
⇒ 触发条件和出错的 n 精确到同一个整数 —— 因为这里报的不是概率,是算完的事实。
⚠ 而这条线不是「k ≥ 10 就出事」:进位得先攒够几轮才爬得上 10。
5⚠ 官方样例 n = 3:挡住一个,放过两个
| 版本 | 样例 n = 3 |
结论 |
|---|---|---|
| ★ 正解 | 9 | |
| ✗ 64 位整数 | 9 | 放过 —— 它的线在 n = 21 |
| ✗ 收尾只补一位 | 9 | 放过 —— 它的线在 n = 15 |
| ✗ 先写回再算进位 | 19 | ★ 一测就死 —— 它的线在 n = 3 |
⇒ ★★ 这是「官方样例是个一测就死的过滤器」那条规律最干净的一次现场:
三个错法的线分别在 n = 3 / 15 / 21,而样例给的正好是 n = 3 ——
它挡住的恰恰是那个「从最早开始就错」的,另外两个一个都挡不住。
⚠ 而真正让你 WA 的,恰恰是后面那两种。
6★ 换一把尺子:__int128 走一条完全无关的路
高精度的正解只能自己和自己比 —— 除非找到一条不走高精度的路。
而这道题有现成的一条:__int128 的上限约 1.7×10³⁸,
而 S(33) ≈ 8.9×10³⁶ ⇒ n ≤ 33 全都装得下(34! 就是 2.95×10³⁸,越界)。
⇒ 拿硬件的 128 位整数从 1 数到 33,和高精度正解逐字节比: 33 组,对不上 0 组。
★ 这条路和高精度一行代码都不共享:没有数组、没有进位、没有 trim。
⇒ 「验算要走一条和算法完全无关的路」的又一次。
⚠ 而它当不了解法(只覆盖到 n = 33,题面要到 50)——
参照物和解法本来就是两回事。
// P1009 解析页上所有数字的出处。./p1009Count [csv]//// ★★★ 这道题最值钱的一句话是**方法论**,不是算法:// **它的输入空间只有 50 个整数** —— `1 ≤ n ≤ 50`。// ⇒ 用不着写生成器、用不着对拍:**把 50 个 n 全跑一遍**,// 每个版本从哪个 n 开始错、错在哪一位,全是精确值,不是抓获率。// ⇒ [「输入空间小的时候,算一遍比对拍又快又充分」](/sol/p1002/)的又一次// —— 而这一次连「小」都不用打折:50 个。//// 这份程序里每个版本都独立重写了一遍,输出四件事:// ① 每个错法**第一个出错的 n**(精确到一个整数);// ② 用 __int128 走一条完全无关的路:n ≤ 33 上和硬件算术逐个核对;// ③ n! 和 S(n) 的位数表(顶格 50! 有多少位);// ④ 正解 vs「每次从头重算阶乘」的一位数乘法次数。#include <bits/stdc++.h>using namespace std;
using Big = vector<int>;static void trim(Big& v) { while (v.size() > 1 && v.back() == 0) v.pop_back(); }static string show(const Big& v) { string s; for (int i = (int)v.size() - 1; i >= 0; i--) s += char('0' + v[i]); return s;}static 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;}/** 正解的乘低精;ops 累加一位数乘法的次数 */static Big mulOk(const Big& a, int k, long long* ops) { Big c; int carry = 0; for (size_t i = 0; i < a.size(); i++) { if (ops) (*ops)++; int cur = a[i] * k + carry; c.push_back(cur % 10); carry = cur / 10; } while (carry) { c.push_back(carry % 10); carry /= 10; } trim(c); return c;}/** ✗ 只补一位 carry */static Big mulTail(const Big& a, int k) { Big c; int carry = 0; for (size_t i = 0; i < a.size(); i++) { int cur = a[i] * k + carry; c.push_back(cur % 10); carry = cur / 10; } if (carry) c.push_back(carry % 10); trim(c); return c;}/** ✗ 先写回这一位再拿它算进位 */static Big mulCarry(const Big& a, int k) { Big c(a.size(), 0); int carry = 0; for (size_t i = 0; i < a.size(); i++) { c[i] = (a[i] * k + carry) % 10; carry = (c[i] * k + carry) / 10; } while (carry) { c.push_back(carry % 10); carry /= 10; } trim(c); return c;}static string i128ToStr(__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) { bool csv = argc > 1 && string(argv[1]) == "csv";
int firstLl = 0, firstTail = 0, firstCarry = 0, refacBad = 0; int i128Cases = 0, i128Bad = 0, maxCarry = 0, firstBigCarry = 0; int lenFact50 = 0, lenSum50 = 0, lenDiff = 0; long long opsOk = 0, opsRefac = 0;
Big cur{1}, sum{0}; unsigned long long lcur = 1, lsum = 0; Big tcur{1}, tsum{0}; // Tail 版 Big ccur{1}, csum{0}; // Carry 版 __int128 icur = 1, isum = 0;
for (int n = 1; n <= 50; n++) { // 正解 cur = mulOk(cur, n, &opsOk); sum = add(sum, cur); string want = show(sum);
// ✗ 64 位 lcur *= (unsigned long long)n; lsum += lcur; if (!firstLl && to_string(lsum) != want) firstLl = n;
// ✗ 只补一位 tcur = mulTail(tcur, n); tsum = add(tsum, tcur); if (!firstTail && show(tsum) != want) firstTail = n;
// ✗ 先写回再算进位 ccur = mulCarry(ccur, n); csum = add(csum, ccur); if (!firstCarry && show(csum) != want) firstCarry = n;
// 「每次从头重算」—— 答案应该永远一样 { Big s2{0}; for (int i = 1; i <= n; i++) { Big c2{1}; for (int j = 1; j <= i; j++) c2 = mulOk(c2, j, n == 50 ? &opsRefac : nullptr); s2 = add(s2, c2); } if (show(s2) != want) refacBad++; }
// ② 完全无关的一条路:__int128(n ≤ 33 之内它装得下) if (n <= 33) { icur *= (__int128)n; isum += icur; i128Cases++; if (i128ToStr(isum) != want) i128Bad++; }
// 「乘低精的收尾进位」到底能有多大 —— Tail 那个 bug 的触发条件 { int carry = 0; Big f = cur; // 这一步之后 cur 就是 n! // 重算一次 (n-1)! * n 的收尾进位 Big prev{1}; for (int i = 1; i < n; i++) prev = mulOk(prev, i, nullptr); carry = 0; for (size_t i = 0; i < prev.size(); i++) { int c2 = prev[i] * n + carry; carry = c2 / 10; } if (carry > maxCarry) maxCarry = carry; if (!firstBigCarry && carry >= 10) firstBigCarry = n; (void)f; }
if (n == 50) { lenFact50 = (int)cur.size(); lenSum50 = (int)sum.size(); } if (cur.size() != sum.size()) lenDiff++; }
if (csv) { printf("firstLl,%d\n", firstLl); printf("firstTail,%d\n", firstTail); printf("firstCarry,%d\n", firstCarry); printf("refacBad,%d\n", refacBad); printf("i128Cases,%d\n", i128Cases); printf("i128Bad,%d\n", i128Bad); printf("maxCarry,%d\n", maxCarry); printf("firstBigCarry,%d\n", firstBigCarry); printf("lenFact50,%d\n", lenFact50); printf("lenSum50,%d\n", lenSum50); printf("lenDiff,%d\n", lenDiff); printf("opsOk,%lld\n", opsOk); printf("opsRefac,%lld\n", opsRefac); printf("opsRatio,%.1f\n", (double)opsRefac / (double)opsOk); printf("sum50,%s\n", show(sum).c_str()); return 0; }
printf("=== 输入空间只有 50 个整数 ⇒ 不写生成器,全跑一遍 ===\n"); printf("✗ 64 位整数累加 第一个出错的 n = %d (书里那份代码的数据范围正好是 n <= 20)\n", firstLl); printf("✗ 乘低精只补一位进位 第一个出错的 n = %d (收尾进位第一次 >= 10 是在 n = %d,最大 %d)\n", firstTail, firstBigCarry, maxCarry); printf("✗ 先写回再算进位 第一个出错的 n = %d\n", firstCarry); printf("★ 每次从头重算阶乘 50 个 n 里出错 %d 个(它是对的,只是多做了活)\n\n", refacBad);
printf("=== 换一把尺子:__int128 走一条完全无关的路 ===\n"); printf("n <= 33 上硬件算术能装下 ⇒ %d 组逐字节核对,对不上 %d 组\n\n", i128Cases, i128Bad);
printf("=== 位数 ===\n"); printf("50! 有 %d 位;S(50) 有 %d 位;50 个 n 里两者位数不同的有 %d 个\n", lenFact50, lenSum50, lenDiff); printf("S(50) = %s\n\n", show(sum).c_str());
printf("=== 一位数乘法次数(n = 50)===\n"); printf("正解(cur 滚着乘) %lld 次\n", opsOk); printf("每次从头重算阶乘 %lld 次 ⇒ 多 %.1f 倍\n", opsRefac, (double)opsRefac / (double)opsOk); printf("⚠ 两版都是零点几毫秒 —— 秒表在这一章量不动,只能数次数\n"); return 0;}点「运行 ▶」看结果
7★ 哪一版就已经能过了
| 写法 | n = 50 的一位数乘法次数 |
交上去 |
|---|---|---|
| ✗ 64 位整数 | O(1) | ✗ n ≥ 21 全错 |
★ 正解(cur 滚着乘) |
1355 | ★ AC |
★ 每次从头重算 i! |
20634(多 15.2 倍) | ★ 也 AC |
⚠ 秒表在这一章是没用的(第 44 章第 4 步): 两版都只有零点几毫秒,量到的全是噪声。这一页从头到尾没有一个秒数,只有次数。 ⇒ 所以「多 15.2 倍」这个数是数出来的,不是掐表掐出来的。
★ 而结论不是「重算那版不好」,是「这个技巧在这道题上成立」和「这道题该用它」是两句话:
n ≤ 50 时那 15.2 倍一分钱都不值,滚着乘赢的是「代码更短、更说得清」。
⇒ 顺带两个能自己验的数:50! 有 65 位,S(50) 也是 65 位 ——
★ 而 50 个 n 里,n! 和 S(n) 的位数一次都没有不同过。
(这不完全是巧合:1! + … + (n−1)! < n! ⇒ S(n) < 2 · n!,最多只可能多一位 ——
而「会不会真多那一位」还得看 n! 的首位,所以最后那句仍然是数出来的,不是推出来的。)