题单 · 习题解析

洛谷 P1255 数楼梯

一道题两个门槛:先撞「跑不完」,再撞「long long 装不下」—— 后者样例还是过的

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

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

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

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

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

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

题目描述

楼梯有 N 阶,上楼可以一步上一阶,也可以一步上二阶。编一个程序,计算共有多少种不同的走法。

输入格式:一个数字,楼梯数。

输出格式:输出走的方式总数。

数据范围

  • 对于 60% 的数据,N ≤ 50
  • 对于 100% 的数据,1 ≤ N ≤ 5000

1先看清楚:这道题有两关,不是一关

这道题在题单里出现了两次(第 1 章和第 2 章各一次),因为它正好卡在两个不同的地方:

第一关:跑得动吗   ->  朴素递归是指数级的,n = 45 就已经等不起
第二关:装得下吗   ->  n = 5000 的答案有 1045 位,long long 差得远

绝大多数人只会撞上第一关,然后以为自己做完了。 第二关是这道题真正的门槛, 而且它不报错、不超时,只是悄悄给出一个错的数

递推关系本身一眼就能看出来 —— 盯住最后一步

最后一步要么迈一阶(前面走了 n-1 阶),要么迈两阶(前面走了 n-2 阶)
f(n) = f(n-1) + f(n-2)        f(1) = 1, f(2) = 2

输入

4

输出

5

N = 4:走法是 1+1+1+11+1+21+2+12+1+12+2,共 5 种。 上面那段输出是仓库里的 p1255.cpp 真跑出来的。

2第 ① 版:照着式子直接递归(对,但跑不完)

p1255Naive.cpp第 ① 版(跑不完)
输入 30 还行,试试 40(要等一下),45 就基本别想了。题目要的是 5000。
// 洛谷 P1255 数楼梯 —— 第 ① 版:照题意直接写递归(对,但跑不完)
//
// 输入:N(1 <= N <= 5000)
// 输出:走法总数
//
// 这份为什么存在:题意翻译成递归只有一行 ——
// 最后一步要么迈一阶、要么迈两阶,所以 f(n) = f(n-1) + f(n-2)。
// 这就是斐波那契,也是第 1 章正文里那个「同一个子问题被算了无数遍」的活标本。
//
// ⚠ 慢到什么程度:**调用次数正好是 2 * f(n-1) - 1**(p1255Count.cpp 逐行比给你看)。
// ⚠ 这个式子是**跑出来才对上的**:一开始猜的是 2 * f(n) - 1,一比就差了一整阶。
// 「看着显然」的式子也要跑一遍 —— 这本书踩过好几次。
// 总之调用次数和答案是同一个量级:f(45) 已经十亿,而题目要的是 f(5000) —— 那个数有 1045 位。
// ⇒ 它不是「慢一点」,是**这辈子都跑不完**。
//
// ★ 第 1 章那句「先别急着优化,记住这个感觉」说的就是它。
// 下一版加一个数组就好了;再下一版才轮到这道题真正的难点:数太大,long long 装不下。
#include <bits/stdc++.h>
using namespace std;
long long f(int n) {
if (n == 1) return 1; // 一阶:只有一种走法
if (n == 2) return 2; // 两阶:1+1 或 2
return f(n - 1) + f(n - 2);
}
int main() {
int n;
if (!(cin >> n)) return 0;
cout << f(n) << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
⚠ 慢的形状:调用次数和答案是同一个量级

调用次数正好是 2 × f(n-1) − 1(第 ⑤ 步那张表逐行比给你看)。

⚠ 这个式子是跑出来才对上的:一开始我猜的是 2 × f(n) − 1,一比就发现差了一整阶。 「看着显然」的式子也要跑一遍。

⇒ 结论不变:工作量跟着答案一起爆炸。而这道题的答案本身就是天文数字, 所以这一版不是「慢一点」,是这辈子都跑不完

★ 第 1 章那句「先别急着优化,记住这个感觉」说的就是它。

3第 ② 版:改成递推,快的问题当场解决(但答案错了)

从小往大推一遍,一个循环就完事:

f[1] = 1; f[2] = 2;
for (int i = 3; i <= n; i++) f[i] = f[i - 1] + f[i - 2];
p1255LL.cpp第 ② 版(快,但答案错)
先输入 4 —— 对的。再输入 40 —— 也对。然后输入 5000,看看它给你什么。
// 洛谷 P1255 数楼梯 —— 第 ② 版:改成递推,快得飞起(**但答案是错的**)
//
// 输入:N
// 输出:走法总数
//
// 这份为什么存在:把递归倒过来从小往大推,一个循环就完事,
// **快的问题当场解决**(5000 阶眨眼就跑完)。很多人写到这儿就交了。
//
// ⚠ 一处刻意的写法:这里用的是 **unsigned long long**,不是 long long。
// 真实的错误当然是用 long long 写的,但**有符号溢出在 C++ 里是未定义行为** ——
// 换个编译器、换个优化档,它吐出来的数就可能不一样,那样这一页就没法拿数字说话了。
// unsigned 的溢出**有明确定义**(对 2^64 取模),于是「错的那个数」也是可复现的。
// ⇒ 演示「这样写是错的」时,要让那个错本身是确定的。(第 44 章立的规矩。)
//
// ⚠⚠ 然后就 WA 了,而且**样例是过的** —— 这是这道题最坑人的地方。
// N = 4 输出 5,N = 40 也对,到第 92 阶(无符号)/ 第 91 阶(有符号)为止都还对,
// 再往后它会给出一个**看起来很正常、其实完全错误的数**(溢出之后无声无息)。
// 题目要的是 N = 5000,那个答案有 **1045 位**。
//
// ★ 记住这条:**「跑得动」和「答案对」是两回事,而溢出属于后者。**
// 对拍也救不了你 —— 两份都用 long long 的话,它们会一起错,而且错得一模一样。
// ⇒ 唯一的办法是**动手前先估一估答案能有多大**(第 44 章整章在讲这件事)。
//
// p1255Count.cpp 会把「从第几阶开始溢出」精确地找出来。
#include <bits/stdc++.h>
using namespace std;
int main() {
int n;
if (!(cin >> n)) return 0;
vector<unsigned long long> f(max(n, 2) + 1);
f[1] = 1;
f[2] = 2;
for (int i = 3; i <= n; i++) f[i] = f[i - 1] + f[i - 2];
cout << f[n] << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
⚠⚠ 它会给出一个完全不像话的数,而样例是过的

N = 4 对、N = 40 对、样例全过,然后从某一阶开始,它给出的每一个数都是错的:

64 位有符号 (long long)     最多撑到第 91 阶
64 位无符号 (unsigned)      最多撑到第 92 阶
题目要的                    第 5000 阶,答案有 1045 位

这两条界是用 __int128 当裁判算出来的(第 ⑤ 步那张表)—— 注意是「算」不是「让它溢出去看看」:有符号溢出是未定义行为, 让它自己撞一次得到的数,换台机器可能就不一样,不能当证据。

⚠ 也因此这份演示代码用的是 unsigned long long 而不是 long long: 无符号溢出有明确定义(对 2^64 取模),这样「它错成什么样」才是可复现的。 真实的错误当然是用 long long 写的,两者的结论完全一样 —— 装不下

★★ 这一条要背下来:「跑得动」和「答案对」是两回事,而溢出属于后者。

⚠ 而且对拍救不了你:两份都用 long long 的话,它们会一起错,而且错得一模一样。 ⇒ 唯一的办法是动手前先估一估答案能有多大。这一步只要三秒钟。

4第 ③ 版:高精度加法(正解)

和第 ② 版的差别只有一处:把 long long 换成「一个数组存一个大数」。 递推那两行的形状一模一样,还是 f[i] = f[i-1] + f[i-2]

高精度加法就是竖式加法,小学怎么算的就怎么写:

   低位在前存法(下标 0 是个位)

   f[i-1] :  [3][2][1]        表示 123
   f[i-2] :  [9][8]           表示  89
   逐位加 :   3+9=12 -> 写 2 进 1
              2+8+1=11 -> 写 1 进 1
              1+0+1=2  -> 写 2
   结果   :  [2][1][2]        表示 212  ✓
p1255.cpp第 ③ 版(正解)
输入 5000,看那个 1045 位的数。再输入 4 核对样例。
// 洛谷 P1255 数楼梯 —— 第 ③ 版:递推 + 高精度加法(正解)
//
// 输入:N(1 <= N <= 5000)
// 输出:走法总数(N = 5000 时是一个 1045 位的数)
//
// 和第 ② 版的差别只有一处:**把 long long 换成「一个数组存一个大数」**。
// 递推那两行的形状一模一样,还是 f[i] = f[i-1] + f[i-2]。
//
// 高精度加法就是竖式加法,小学怎么算的就怎么写:
// · 每一位存一个十进制数字,**低位在前**(下标 0 是个位)—— 这样进位是「往后加」,不用挪数组
// · 逐位相加,超过 10 就往上一位进 1
//
// ⚠ 低位在前是约定,不是随便挑的:加法、乘法都是从低位往高位算,
// 存成「低位在前」之后下标和数位就一一对应了,最后输出时倒着打一遍即可。
// (第 44 章整章讲这件事,这里只用得上最简单的加法。)
#include <bits/stdc++.h>
using namespace std;
/** 大数:低位在前,每个元素是一个十进制数字 */
using Big = vector<int>;
Big fromInt(int x) {
Big a;
if (x == 0) a.push_back(0);
while (x > 0) { a.push_back(x % 10); x /= 10; }
return a;
}
/** 竖式加法:逐位相加,满十进一 */
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 s = carry;
if (i < a.size()) s += a[i];
if (i < b.size()) s += b[i];
c.push_back(s % 10);
carry = s / 10;
}
return c;
}
int main() {
int n;
if (!(cin >> n)) return 0;
vector<Big> f(max(n, 2) + 1);
f[1] = fromInt(1);
f[2] = fromInt(2);
for (int i = 3; i <= n; i++) f[i] = add(f[i - 1], f[i - 2]);
const Big& ans = f[n];
for (size_t i = ans.size(); i-- > 0; ) cout << ans[i]; // 低位在前,倒着输出
cout << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★ 为什么要「低位在前」

这不是随手挑的:加法和乘法都是从低位往高位算的

存成低位在前之后,「个位」永远是下标 0、「十位」永远是下标 1 —— 进位就是「往后一个下标加 1」,数组不用挪动。 存成高位在前的话,每进一次位都可能要把整个数组往后搬,写起来痛苦得多。

⇒ 代价只有一个:输出时要倒着打一遍。这笔交易非常划算。

(第 44 章整章讲高精度,这里只用得上最简单的加法。)

5两把尺子:一把量时间,一把量数值

p1255Count.cpp数次数
不用输入,直接跑。两张表分别对应上面两关。
// P1255 的三个版本各卡在哪 —— 两把可复现的尺子
//
// 输入:无
// 输出:两张表
// 表一:朴素递归的调用次数(并验证它恒等于 2 × f(n) − 1)
// 表二:long long 从第几阶开始装不下,以及答案有多少位
//
// ★ 这份代码要说明的是两件**性质不同**的失败:
// · 第 ① 版失败在**时间**上 —— 调用次数是答案的两倍,而答案本身就大得没边;
// · 第 ② 版失败在**数值**上 —— 它跑得飞快,只是从某一阶开始悄悄给出错的数。
// 前者你会看到 TLE,后者你只会看到 WA,而且样例还是过的。
//
// ⚠ 表二用 __int128 当裁判:它能装到 1.7 × 10^38,足够看清 long long 在哪一步开始跑偏。
// (第 44 章 verify.cpp 用的是同一招:换一把更长的尺子去量原来那把。)
#include <bits/stdc++.h>
using namespace std;
long long calls = 0;
long long fib(int n) {
calls++;
if (n == 1) return 1;
if (n == 2) return 2;
return fib(n - 1) + fib(n - 2);
}
/** 大数位数:只算位数,不算值 */
int digitsOf(int n) {
vector<int> a{1}, b{2}, c;
if (n == 1) return 1;
if (n == 2) return 1;
for (int i = 3; i <= n; i++) {
c.clear();
int carry = 0;
for (size_t k = 0; k < a.size() || k < b.size() || carry; k++) {
int s = carry;
if (k < a.size()) s += a[k];
if (k < b.size()) s += b[k];
c.push_back(s % 10);
carry = s / 10;
}
a = b;
b = c;
}
return (int)b.size();
}
int main() {
// 先把 f 递推出来(第 ③ 列要用 f[n-1])
vector<long long> f(45);
f[1] = 1; f[2] = 2;
for (int i = 3; i < 45; i++) f[i] = f[i - 1] + f[i - 2];
cout << "表一:朴素递归要调用多少次\n\n";
cout << " n 答案 f(n) 调用次数 2*f(n-1)-1\n";
cout << "----- ------------- ----------- -----------\n";
for (int n : {4, 10, 20, 30, 40}) {
calls = 0;
long long v = fib(n);
cout << setw(5) << n
<< setw(16) << v
<< setw(14) << calls
<< setw(14) << 2 * f[n - 1] - 1 << "\n";
}
cout << "\n后两列永远相等:调用次数 = 2 * f(n-1) - 1。\n";
cout << "也就是说它和答案是同一个量级 —— 所以「答案大」就等于「跑不完」,优化循环救不了。\n";
cout << "⚠ 这个式子是跑出来才对上的:先猜的是 2*f(n)-1,一比就发现差了一整阶。\n";
cout << "\n表二:64 位整数最多能撑到第几阶\n\n";
// ⚠ 用 __int128 递推真值,再和两条上限比 —— 全程没有溢出,结论是确定的。
// (直接让 long long 溢出去看它变成什么,是**未定义行为**,不能拿来当证据。)
const __int128 LL_MAX = (__int128)9223372036854775807LL;
const __int128 ULL_MAX = ((__int128)1 << 64) - 1;
__int128 A = 1, B = 2; // f(1), f(2)
int lastLL = 2, lastULL = 2;
// ⚠ 只推到第 100 阶就够了 —— 再往下推,连 __int128 自己都会溢出,
// 那样这把「裁判尺」本身就不可信了。(量东西之前先确认尺子够长。)
for (int i = 3; i <= 100; i++) {
__int128 C = A + B;
if (C <= LL_MAX) lastLL = i;
if (C <= ULL_MAX) lastULL = i;
A = B; B = C;
}
cout << "64 位有符号 (long long) 最多到第 " << lastLL << " 阶\n";
cout << "64 位无符号 (unsigned) 最多到第 " << lastULL << " 阶\n";
cout << "题目要的 第 5000 阶,答案有 " << digitsOf(5000) << " 位\n";
cout << "\n⇒ 差的不是「大一点」,是差着 " << digitsOf(5000) / 19 << " 个 long long 那么长。\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
表一:朴素递归要调用多少次

    n       答案 f(n)      调用次数    2*f(n-1)-1
-----   -------------   -----------   -----------
    4               5             5             5
   10              89           109           109
   20           10946         13529         13529
   30         1346269       1664079       1664079
   40       165580141     204668309     204668309
表二:64 位整数最多能撑到第几阶

64 位有符号 (long long)      最多到第 91 阶
64 位无符号 (unsigned)       最多到第 92 阶
题目要的                     第 5000 阶,答案有 1045 位
★ 两种失败,长得完全不一样
第 ① 版   失败在时间上   ->  你会看到 TLE,一眼就知道出事了
第 ② 版   失败在数值上   ->  你只会看到 WA,而且样例还是过的

第二种危险得多,因为它不给你任何提示。

⇒ 对付它只有一招:动手前先估一估答案的位数。 斐波那契每加一阶大约多 0.209 位,5000 阶就是一千多位 —— 这个估算三秒钟, 比事后调一小时值钱得多。

★ 用 __int128 当裁判去量 long long 够不够长,是这本书反复用的一招 (第 44 章的 verify.cpp 同款):换一把更长的尺子,去量原来那把。

⚠ 而且量之前要先确认这把裁判尺自己够长__int128 最多约 38 位, 所以那段代码只推到第 100 阶就停了 —— 再往下推,裁判自己也会溢出, 那时候比出来的结论是假的。尺子不够长的时候,读数是没有意义的。

6回头看:这道题为什么在题单里出现两次

✓ 它是一道题,两个门槛
  • 第 1 章列它,是为了让你亲手撞一次「同一个子问题被算了无数遍」—— 那种「明明式子这么简单,怎么就跑不完」的感觉,只有自己撞过才记得住。
  • 第 2 章列它,是因为分解思维走完之后,剩下的就是数值本身太大这个新问题。

⇒ 于是这道题正好把两件事分开摆给你看:递归的形状对不对,和数值装不装得下 —— 它们互相独立,各自会用完全不同的方式咬你。

★ 顺带一提:第 ① 版那个「指数级递归」的正规解法是记忆化 (见 P1028 第 ③ 步),但这道题连记忆化都不用 —— 直接递推更省事。 选最省事的那条路,别为了用上刚学的技巧硬套。