题单 · 习题解析

洛谷 P1009 [NOIP 1998 普及组] 阶乘之和

★★ 题面**自己把暴力挂在哪儿写出来了**(【其他说明】点名《深入浅出基础篇》那份代码只到 `n ≤ 20`)—— 而实测第一个出错的 n **正是 21**,严丝合缝;★★★ 这一页最值钱的是一句方法论:**它的输入空间只有 50 个整数** ⇒ **一个生成器都不用写,把 n = 1..50 全跑一遍**,每个版本「从哪个 n 开始错」是一个**精确的整数**,不是抓获率(三条线分别在 n = **3 / 15 / 21**);★★ 「高精乘低精」有一个加法**没有**的坑:**它的进位可以大于 9**(k 到 50 时能爬到 33)⇒ 收尾必须是 `while (carry)` 而不是 `if (carry)`,⚠ 而这条线**不是「k ≥ 10 就出事」**:进位得攒够几轮才爬得上 10 —— 实测**收尾进位第一次 ≥ 10 和这个版本第一个出错的 n 精确到同一个整数(15)**;★★ **验算换了一把完全无关的尺子:`__int128`**(上限 1.7×10³⁸ ⇒ 覆盖到 n = 33),33 组逐字节核对、0 组对不上 ⚠ 而它当不了解法(题面要到 50);★★ 官方样例 `n = 3` 是[「一测就死的过滤器」](/sol/p1080/)最干净的一次现场 —— **它挡住的恰恰是那个线最早的错法,另外两个一个都挡不住**;★ 顺带:`50!` 和 `S(50)` 都是 **65 位**,而 50 个 n 里两者位数**一次都没不同过**;「每次从头重算 i!」也能 AC,只是多做 **15.2 倍**的活

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

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

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 ≤ 20」

【其他说明】那一段是本书见过最直白的一次:出题人不但告诉你第一版会挂, 还告诉你它挂在哪儿。而那条线是能精确算出来的:

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」严丝合缝。

p1009Ll.cpp✗ 第 ① 版:64 位整数累加 —— 就是题面点名的「书里那份」
// ✗ 第 ① 版: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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

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 + 上次进位) / 10k 到 50 时它能爬到 33(本页第 6 步数出来的最大值)。 ⇒ 所以收尾那句必须是

while (carry) { push(carry % 10); carry /= 10; }      // ★ 是 while

写成「if (carry) 补一位」就会丢位 —— 而这个坑只属于乘法

p1009.cpp★ 正解:高精加 + 高精乘低精,cur 滚着乘
// 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

3⚠ 两个错法,都长在「乘低精」那三行里

p1009Tail.cpp✗ 错法一:收尾写成 if (carry) 补一位 —— 进位 ≥ 10 就丢位
p1009Carry.cpp✗ 错法二:先把这一位写回去,再拿它算进位
★★ 「先写回、再拿它算进位」—— 一句话拆成两句时最常见的翻车
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 = 13! 被算成 16, 于是 S(3) 打出 19(正解 9)。

4★★★ 这道题的输入空间只有 50 个整数 —— 所以这一页没有生成器

★★★ 「1 ≤ n ≤ 50」⇒ 把 50 个 n 全跑一遍,报的就不是抓获率,是事实

本书前面那么多页都在写生成器、调档位、数抓获率 —— 而这道题一个生成器都不用写

它的全部合法输入只有 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 走一条完全无关的路

★★ 硬件算术能算到 n = 33 —— 那 33 组就是「正解到底对不对」的独立证据

高精度的正解只能自己和自己比 —— 除非找到一条不走高精度的路。 而这道题有现成的一条:__int128 的上限约 1.7×10³⁸, 而 S(33) ≈ 8.9×10³⁶n ≤ 33 全都装得下34! 就是 2.95×10³⁸,越界)。

⇒ 拿硬件的 128 位整数从 1 数到 33,和高精度正解逐字节比33 组,对不上 0 组。

★ 这条路和高精度一行代码都不共享:没有数组、没有进位、没有 trim。 ⇒ 「验算要走一条和算法完全无关的路」的又一次。 ⚠ 而它当不了解法(只覆盖到 n = 33,题面要到 50)—— 参照物和解法本来就是两回事

p1009Str.cpp参照物二:高位在前的字符串版(表示和正解互为镜像)—— 50 个 n 全同
p1009Refac.cpp★ 另一条正确的路:每次从头重算 i! —— 答案永远对,只是多做 15.2 倍的活
p1009Count.cpp本页所有数字的出处(50 个 n 全跑一遍 + __int128 核对)
// 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! 的首位,所以最后那句仍然是数出来的,不是推出来的。)