0题目原文(本地存了一份)
原题在洛谷上(页头有链接)。别人的网站不归我们管 —— 打不开、改版、题号调整都可能发生, 到那时候连题目都没了。所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。
转录自洛谷 P1028,日期见页头。两边不一致时信原站。
题目描述
给出正整数 n,要求按如下方式构造数列:
- 只有一个数 n 的数列是一个合法的数列。
- 在一个合法的数列的末尾加入一个正整数,但是这个正整数不能超过该数列最后一项的一半, 可以得到一个新的合法数列。
请你求出,一共有多少个合法的数列。两个合法数列 a、b 不同当且仅当两数列长度不同,
或存在一个正整数 i ≤ |a|,使得 a[i] ≠ b[i]。
输入格式:输入只有一行一个整数,表示 n。
输出格式:输出一行一个整数,表示合法的数列个数。
数据范围:对于全部的测试点,保证 1 ≤ n ≤ 1000。
样例解释(n = 6):满足条件的数列为 6、6 1、6 2、6 3、6 2 1、6 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) = 1,f(2) = 1 + f(1) = 2,f(3) = 1 + f(1) = 2,
所以 f(6) = 1 + f(1) + f(2) + f(3) = 1 + 1 + 2 + 2 = 6 ✓
输入
6
输出
6
上面那段输出是仓库里的 p1028.cpp 真跑出来的,不是手敲的。
2第 ① 版:照着上面那行式子直接写
// 洛谷 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;}点「运行 ▶」看结果
这一版逻辑完全正确,而且它就是上面那行式子逐字翻译过来的。问题只有一个:慢。
一般说「递归慢」,说的是「重复算了很多遍」。这道题可以说得更精确:
每一个合法数列,恰好对应一次
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; // ★ 算完存下来
}
// 洛谷 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;}点「运行 ▶」看结果
n = 1000 时它只调用 63 001 次 —— 比第 ① 版少了三万多倍,一瞬间就完。
考场上写到这里就可以交了,然后去做下一题。 「不写到最优就不配交」是教科书给人的错觉 —— 评测机只看答案对不对、时间够不够, 能过的分和最优解的分一模一样。
★ 顺带认识一个名字:这叫记忆化搜索,是第 17 章的正题。 但请注意它长什么样:它不是一个新算法,是给已经写好的递归加一层缓存。 递归的形状一个字都没改,只多了两行。
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 数的计算 —— 第 ③ 版:把递归倒过来写成递推//// 输入: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;}点「运行 ▶」看结果
它们算的是同一批数、同样的量级,差别只在谁推着谁走:
记忆化 要用才算,从大往小问 写起来最省事:递归写完了,加两行就行
递推 先都算好,从小往大推 没有递归开销,但要自己想清楚「谁先算」这道题两种都轻松过。能想到哪种就写哪种 —— 别为了显得高级硬写递推, 更别因为「听说递推更快」就在没想清楚顺序的时候硬上。
⚠ 这一版是 O(n²)(n = 1000 时约 25 万次加法,眨眼就完)。
想要 O(n) 也行 —— f[1..i/2] 的和是个前缀和,边推边维护即可。
但这道题 n 只有 1000,为它多写十行前缀和是不划算的:先估工作量,再决定要不要优化。
5三个版本并排:换一把可复现的尺子
秒表在这儿量不出什么(后两版都在毫秒级,量到的大半是起进程的开销)。所以数次数。
// 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;}点「运行 ▶」看结果
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 万次加法看着比记忆化还多 —— 但那是加法,不是函数调用, 而且它没有递归的栈开销。⇒ 别拿不同的尺子量出来的数直接比大小。
它跑不完,秒表和计数器都等不到它。
但这里不需要另写公式:既然调用次数恒等于答案,直接拿答案填就行 ——
而答案是记忆化那一版真跑出来的。n ≤ 40 那几行是两种办法都做了一遍,用来验证这条性质。
6回头看:这道题在教什么
- 先给函数一个准确的说法(「以 n 开头的合法数列有多少个」), 递推关系会自己冒出来 —— 这是第 1 章的全部内容。
- 「工作量等于输出规模」是一种特殊的慢:它意味着优化循环毫无意义,只能换做法。 遇到这种情况,第一反应应该是「哪些东西被重复算了」。
- 记忆化不是新算法,是给写好的递归加两行缓存。而它能不能用, 看的是「一个子问题由哪几个量唯一决定,这几个量的组合有多少种」—— 这道题只有一个量 n,最多 1000 种,所以稳赢。
⇒ 下一道 P1044 栈 把第 3 条推到二维:状态是 (rest, in) 两个量。