题单 · 习题解析

洛谷 P1028 数的计算

朴素递归的调用次数正好等于答案本身 —— 所以它不是「慢一点」,是优化循环救不了

原题:洛谷 P1028出自 第 1 章 递归入门:函数怎么调用自己 的题单题面本地存档:2026-08-25
⚠ 先自己写一遍,再往下看

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

0题目原文(本地存了一份)

为什么要在这儿抄一份题面

原题在洛谷上(页头有链接)。别人的网站不归我们管 —— 打不开、改版、题号调整都可能发生, 到那时候连题目都没了。所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。

转录自洛谷 P1028,日期见页头。两边不一致时信原站。

题目描述

给出正整数 n,要求按如下方式构造数列:

  1. 只有一个数 n 的数列是一个合法的数列。
  2. 在一个合法的数列的末尾加入一个正整数,但是这个正整数不能超过该数列最后一项的一半, 可以得到一个新的合法数列。

请你求出,一共有多少个合法的数列。两个合法数列 a、b 不同当且仅当两数列长度不同, 或存在一个正整数 i ≤ |a|,使得 a[i] ≠ b[i]

输入格式:输入只有一行一个整数,表示 n。

输出格式:输出一行一个整数,表示合法的数列个数。

数据范围:对于全部的测试点,保证 1 ≤ n ≤ 1000

样例解释n = 6):满足条件的数列为 66 16 26 36 2 16 3 1,共 6 个。

1先想清楚:这个函数负责什么

第 1 章反复在说的那件事,这道题是最干净的一次练习:先给函数一个准确的说法,剩下的交给它。

f(n) = 「以 n 开头的合法数列有多少个」

有了这句话,递推关系自己就冒出来了:

  • 只有 n 自己的那一个数列 —— 1 个
  • 后面接一个不超过 n / 2 的正整数 i,接完之后剩下的部分就是「以 i 开头的合法数列」—— f(i)
f(n) = 1 + f(1) + f(2) + ... + f(n/2)

拿样例核对一下:f(1) = 1f(2) = 1 + f(1) = 2f(3) = 1 + f(1) = 2, 所以 f(6) = 1 + f(1) + f(2) + f(3) = 1 + 1 + 2 + 2 = 6

输入

6

输出

6

上面那段输出是仓库里的 p1028.cpp 真跑出来的,不是手敲的。

2第 ① 版:照着上面那行式子直接写

p1028Naive.cpp第 ① 版(对,但跑不完)
输入 6 得 6。试试 40(很快)、再试 100 —— 开始有感觉了。别试 1000,它跑不完。
// 洛谷 P1028 数的计算 —— 第 ① 版:照着题意直接写递归(对,但跑不完)
//
// 输入:n(1 <= n <= 1000)
// 输出:合法数列的个数
//
// 这份为什么存在:它是照着题目一字一句翻译过来的,**逻辑完全正确**,
// 而且正是第 1 章教的那件事 —— 先想清楚「这个函数负责什么」,剩下的交给它自己。
//
// f(n) = 「以 n 开头的合法数列有多少个」
// = 1(只有 n 自己这一个)
// + f(1) + f(2) + ... + f(n/2)(后面接一个不超过 n/2 的数)
//
// ⚠ 它慢得非常特别,值得单独记住:**它的调用次数正好等于答案本身。**
// 每一个合法数列,恰好对应一次 f() 调用 —— 一一对应,不多不少。
// n = 1000 的答案是 1 981 471 878,所以它要调用十九亿八千万次。
// ⇒ 这不是「常数大」,是**它的工作量天生就等于输出的规模**,怎么优化循环都没用。
// p1028Count.cpp 把这个一一对应关系真的数出来。
#include <bits/stdc++.h>
using namespace std;
long long f(int n) {
long long total = 1; // 只有 n 自己的那个数列
for (int i = 1; i <= n / 2; i++) total += f(i);
return total;
}
int main() {
int n;
if (!(cin >> n)) return 0;
cout << f(n) << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

这一版逻辑完全正确,而且它就是上面那行式子逐字翻译过来的。问题只有一个:慢。

★ 它慢得非常特别:调用次数正好等于答案本身

一般说「递归慢」,说的是「重复算了很多遍」。这道题可以说得更精确:

每一个合法数列,恰好对应一次 f() 调用 —— 一一对应,不多不少。

理由一句话:f() 的递归树上,每一个节点都对应「已经写下的那段前缀」,而叶子就是完整的数列。

⇒ 于是 n = 1000 时它要调用 1 981 471 878 次(就是答案本身,接近二十亿)。

★★ 这个结论比「它很慢」有用得多: 它的工作量天生等于输出的规模。 循环写得再紧、常数抠得再狠都没用 —— 这种慢只能靠换做法。 第 ⑤ 步那张表会把这个一一对应逐行摆出来。

3第 ② 版:加一个数组,递归的形状一个字都不改

第 ① 版慢,不是因为算错,是因为 f(3) 这种小问题被反复算了无数遍。 那就算过就记下来

long long memo[1005];              // memo[n] = f(n),0 表示还没算过

long long f(int n) {
    if (memo[n]) return memo[n];   // ★ 算过了,直接拿
    long long total = 1;
    for (int i = 1; i <= n / 2; i++) total += f(i);
    return memo[n] = total;        // ★ 算完存下来
}
p1028Memo.cpp第 ② 版(能 AC)
直接输入 1000。上一版跑不完的东西,这一版一瞬间。
// 洛谷 P1028 数的计算 —— 第 ② 版:同一份递归,加一个数组(★ 这一版就能 AC)
//
// 输入:n
// 输出:合法数列的个数(和第 ① 版完全一样)
//
// 这份为什么存在:第 ① 版慢,不是因为它算错了,是因为**同一个 f(i) 被重复算了无数遍**。
// 算过就记下来,下次直接拿 —— 递归的形状**一个字都不用改**。
//
// 改动只有两行:
// 进函数先看有没有算过;算完存起来。
//
// ★ 这就是**记忆化搜索**(第 17 章的正题)。这里提前用一次,是想让你看见:
// 它不是一个新算法,是给已经写好的递归**加一层缓存**。
//
// 数字:n = 1000 时第 ① 版要 1 981 471 878 次调用,这一版只要 63 001 次 —— 少了三万多倍。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 1005;
long long memo[MAXN]; // memo[n] = f(n),0 表示还没算过(f 恒 >= 1,所以 0 可以当哨兵)
long long f(int n) {
if (memo[n]) return memo[n]; // ★ 算过了,直接拿
long long total = 1;
for (int i = 1; i <= n / 2; i++) total += f(i);
return memo[n] = total; // ★ 算完存下来
}
int main() {
int n;
if (!(cin >> n)) return 0;
cout << f(n) << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★★ 这一版就已经能 AC 了

n = 1000 时它只调用 63 001 次 —— 比第 ① 版少了三万多倍,一瞬间就完。

考场上写到这里就可以交了,然后去做下一题。 「不写到最优就不配交」是教科书给人的错觉 —— 评测机只看答案对不对、时间够不够, 能过的分和最优解的分一模一样。

★ 顺带认识一个名字:这叫记忆化搜索,是第 17 章的正题。 但请注意它长什么样:它不是一个新算法,是给已经写好的递归加一层缓存。 递归的形状一个字都没改,只多了两行。

⚠ 这里的哨兵能用 0,是因为答案恒大于 0

memo[n] 用 0 表示「还没算过」,前提是 0 不可能是一个真答案(这道题 f(n) ≥ 1)。

换一道题就不一定了 —— 比如答案可能是 0 的题,用 0 当哨兵会让「算出来是 0」和 「还没算过」永远分不开,于是那个格子每次都重算,缓存等于没加,而且答案还是对的, 你根本发现不了。

⇒ 稳妥的习惯是 memset(memo, -1, ...) 用 −1 当哨兵(下一道题 P1044 就是这么写的)。

4第 ③ 版:把递归倒过来,写成递推

记忆化是「要用才算,从大往小问」。既然 f(i) 只依赖比它小的那些, 干脆从小往大算一遍,连递归都不用了:

for (int i = 1; i <= n; i++) {
    f[i] = 1;
    for (int j = 1; j <= i / 2; j++) f[i] += f[j];
}
p1028.cpp第 ③ 版(递推)
和第 ② 版答案完全一样。没有函数调用、没有栈、没有哨兵判断。
// 洛谷 P1028 数的计算 —— 第 ③ 版:把递归倒过来写成递推
//
// 输入:n
// 输出:合法数列的个数
//
// 这份为什么存在:记忆化已经能过了,但它还是「从大往小问」。
// 既然 f(i) 只依赖比它小的那些,干脆**从小往大算一遍**,连递归都不用了。
//
// f[1] = 1
// f[i] = 1 + f[1] + f[2] + ... + f[i/2]
//
// ⇒ 没有函数调用、没有栈、没有缓存判断,就是两层循环。
//
// ★ 记忆化和递推算的是同一批数、同样的量级,差别只在「谁在推着谁走」:
// 记忆化是「要用才算」,递推是「先都算好」。
// 这道题两种都轻松过;**能想到哪种就写哪种**,别为了「显得高级」硬写递推。
//
// ⚠ 这里是 O(n²)(n = 1000 时约 25 万次加法,眨眼就完)。
// 想要 O(n) 也行:注意 f[1..i/2] 的和是个前缀和,边推边维护即可。
// 但这道题 n 只有 1000,**为它多写十行前缀和是不划算的** —— 先估工作量,再决定要不要优化。
#include <bits/stdc++.h>
using namespace std;
int main() {
int n;
if (!(cin >> n)) return 0;
vector<long long> f(n + 1, 0);
for (int i = 1; i <= n; i++) {
f[i] = 1; // 只有 i 自己的那个数列
for (int j = 1; j <= i / 2; j++) f[i] += f[j];
}
cout << f[n] << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
✓ 记忆化和递推是同一件事的两种走法

它们算的是同一批数、同样的量级,差别只在谁推着谁走

记忆化   要用才算,从大往小问     写起来最省事:递归写完了,加两行就行
递推     先都算好,从小往大推     没有递归开销,但要自己想清楚「谁先算」

这道题两种都轻松过。能想到哪种就写哪种 —— 别为了显得高级硬写递推, 更别因为「听说递推更快」就在没想清楚顺序的时候硬上。

⚠ 这一版是 O(n²)(n = 1000 时约 25 万次加法,眨眼就完)。 想要 O(n) 也行 —— f[1..i/2] 的和是个前缀和,边推边维护即可。 但这道题 n 只有 1000,为它多写十行前缀和是不划算的:先估工作量,再决定要不要优化。

5三个版本并排:换一把可复现的尺子

秒表在这儿量不出什么(后两版都在毫秒级,量到的大半是起进程的开销)。所以数次数

p1028Count.cpp数次数
不用输入,直接跑。重点看第 1 列和「答案」那一列 —— 它们逐行相同。
// P1028 的三个版本,各要调用多少次 —— 换一把可复现的尺子
//
// 输入:无(表是写死的几个 n,最后一行就是题目的上界 1000)
// 输出:一张表,对每个 n 列出
// 答案 f(n) 合法数列的个数
// ① 朴素递归 调用了多少次 f()
// ② 记忆化 调用了多少次 f()
// ③ 递推 做了多少次加法
//
// ★★ 这张表要说的第一件事,是那个漂亮的巧合:
// **朴素递归的调用次数,正好等于答案本身**(第 ① 列和「答案」那一列逐行相同)。
// 原因一句话:每一个合法数列恰好对应一次 f() 调用,一一对应。
// ⇒ 所以它不是「常数大」,是**工作量天生就等于输出的规模**。
// 循环怎么写都救不了 —— 这种慢只能靠换做法。
//
// ⚠ 第 ① 列在 n 大的时候是**算出来的,不是跑出来的**(它跑不完)——
// 但这里不用另写公式:既然它恒等于答案,直接拿答案填就行,
// 而答案是记忆化真跑出来的。这一条本身也写成了断言。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 1005;
long long memo[MAXN];
long long memoCalls = 0;
long long f(int n) {
memoCalls++;
if (memo[n]) return memo[n];
long long total = 1;
for (int i = 1; i <= n / 2; i++) total += f(i);
return memo[n] = total;
}
/** 朴素递归真跑一遍(只在 n 小的时候用,大了跑不完) */
long long naiveCalls = 0;
long long naive(int n) {
naiveCalls++;
long long total = 1;
for (int i = 1; i <= n / 2; i++) total += naive(i);
return total;
}
int main() {
cout << " n 答案 f(n) 1.朴素递归 2.记忆化 3.递推\n";
cout << "----- ----------------- --------------- ------------- ---------\n";
for (int n : {6, 10, 20, 40, 100, 500, 1000}) {
memset(memo, 0, sizeof memo);
memoCalls = 0;
long long ans = f(n);
// 第 ① 列:n 小就真跑一遍核对,n 大就用「调用次数 = 答案」这条性质
long long naiveN;
if (n <= 40) {
naiveCalls = 0;
naive(n);
naiveN = naiveCalls;
} else {
naiveN = ans;
}
// 第 ③ 列:递推做的加法次数
long long adds = 0;
for (int i = 1; i <= n; i++) adds += i / 2;
cout << setw(5) << n
<< setw(20) << ans
<< setw(18) << naiveN
<< setw(16) << memoCalls
<< setw(12) << adds << "\n";
}
cout << "\n第 1 列在 n <= 40 时是真跑出来的,正好等于答案;再大就直接用这条性质填。\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
    n           答案 f(n)        1.朴素递归        2.记忆化      3.递推
-----   -----------------   ---------------   -------------   ---------
    6                   6                 6               6           9
   10                  14                14              12          25
   20                  60                60              36         100
   40                 390               390             121         400
  100                9828              9828             676        2500
  500            26338428          26338428           15876       62500
 1000          1981471878        1981471878           63001      250000
  • 第 1 列和「答案」那一列逐行相同 —— 这就是上面那个一一对应,跑出来给你看的。
  • 第 2 列(记忆化)在 n = 1000 时是 63 001,比第 1 列少了 三万多倍
  • 第 3 列(递推)的 25 万次加法看着比记忆化还多 —— 但那是加法,不是函数调用, 而且它没有递归的栈开销。⇒ 别拿不同的尺子量出来的数直接比大小。
⚠ 第 1 列在 n 大的时候是「算出来的」,不是跑出来的

它跑不完,秒表和计数器都等不到它。

但这里不需要另写公式:既然调用次数恒等于答案,直接拿答案填就行 —— 而答案是记忆化那一版真跑出来的。n ≤ 40 那几行是两种办法都做了一遍,用来验证这条性质。

6回头看:这道题在教什么

✓ 三件带得走的东西
  1. 先给函数一个准确的说法(「以 n 开头的合法数列有多少个」), 递推关系会自己冒出来 —— 这是第 1 章的全部内容。
  2. 「工作量等于输出规模」是一种特殊的慢:它意味着优化循环毫无意义,只能换做法。 遇到这种情况,第一反应应该是「哪些东西被重复算了」。
  3. 记忆化不是新算法,是给写好的递归加两行缓存。而它能不能用, 看的是「一个子问题由哪几个量唯一决定,这几个量的组合有多少种」—— 这道题只有一个量 n,最多 1000 种,所以稳赢。

⇒ 下一道 P1044 栈 把第 3 条推到二维:状态是 (rest, in) 两个量。