题单 · 习题解析

洛谷 P1044 栈

看不出公式就写过程:说清 (rest, in) 两个量就写完了一半,记忆化只多两行

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

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

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

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

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

转录自洛谷 P1044,日期见页头。两边不一致时信原站。 (原题带两张图,这里只能转文字;图在原站上。)

题目描述

一个操作数序列 1, 2, ..., n,栈 A 的深度大于 n。现在可以进行两种操作:

  1. 将一个数,从操作数序列的头端移到栈的头端(对应栈的 push 操作)
  2. 将一个数,从栈的头端移到输出序列的尾端(对应栈的 pop 操作)

使用这两种操作,由一个操作数序列就可以得到一系列的输出序列。 你的程序将对给定的 n,计算并输出由操作数序列 1, 2, ..., n 经过操作可能得到的输出序列的总数

输入格式:只含一个整数 n(1 ≤ n ≤ 18)。

输出格式:只有一行,即可能输出序列的总数目。

来源:NOIP 2003 普及组第三题。

1先看清楚:这道题一眼看不出公式,但过程是清楚的

这道题和前面几道不一样 —— 你多半看不出它是什么数列。但没关系:

看不出公式,就把过程写下来。 每一步只有两种动作,那就两种都试一遍。

关键是先说清楚「函数负责什么」。这里需要两个量才说得清:

f(rest, in) = 「还有 rest 个数没进栈、栈里现在有 in 个,往后还能凑出多少种输出序列」

于是三条规则自己就出来了:

rest == 0   剩下的只能一个一个弹出来,顺序是定死的      ->  1 种
可以进栈    f(rest-1, in+1)
栈非空还可以出栈   f(rest, in-1)

输入

3

输出

5

n = 3 的 5 种输出序列是:1 2 31 3 22 1 32 3 13 2 1。 (注意 3 1 2做不到的 —— 3 先出来说明 1、2 都还压在栈里,那 2 一定比 1 先出。) 上面那段输出是仓库里的 p1044.cpp 真跑出来的。

2第 ① 版:把两种动作都搜一遍(对,但太慢)

p1044Naive.cpp第 ① 版(对,但太慢)
输入 10 很快,13 慢一点,16 要等好几秒。题目的上界是 18。
// 洛谷 P1044 栈 —— 第 ① 版:把每一步「进栈还是出栈」都搜一遍(对,但太慢)
//
// 输入:n(1 <= n <= 18)
// 输出:可能的输出序列总数
//
// 这份为什么存在:这道题第一眼看不出公式,但**过程是清清楚楚的** ——
// 每一步只有两种动作,那就把两种都试一遍。这正是第 1 章讲的「信任函数」:
//
// f(rest, in) = 「还有 rest 个数没进栈、栈里现在有 in 个,往后能凑出多少种输出序列」
//
// · rest == 0:剩下的只能一个一个弹出来,顺序是定死的 ⇒ 1 种
// · 否则可以「进一个」:f(rest-1, in+1)
// · 栈非空还可以「弹一个」:f(rest, in-1)
//
// ⚠ 它是对的,但**同一个 (rest, in) 会被不同的路径反复问到**。
// n = 16 时要走 84 118 036 个节点,n = 18 已经到十亿量级 —— 一秒钟的时限扛不住。
// ⇒ 病根和 P1255 一模一样:**子问题重叠**。下一版加个数组就好。
#include <bits/stdc++.h>
using namespace std;
int n;
long long f(int rest, int in) {
if (rest == 0) return 1; // 剩下的只能依次弹出,只有一种
long long total = f(rest - 1, in + 1); // 进栈
if (in > 0) total += f(rest, in - 1); // 出栈
return total;
}
int main() {
if (!(cin >> n)) return 0;
cout << f(n, 0) << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

这一版是对的,而且它把题意翻译得非常忠实。问题还是老一个:

⚠ 同一个 (rest, in) 被不同的路径反复问到

「先进 1 再出 1」和「先进 1 再进 2 再出 2 再出 1」…… 很多条不同的走法, 最后都会走到同一个状态上。而这一版每碰到一次就从头重算一次。

n = 16   要走 84 118 036 个节点
n = 18   十亿量级 —— 一秒钟的时限扛不住

★ 病根和 P1028P1255 一模一样:子问题重叠。 不一样的是:那两道题的状态只有一个量,这道题有两个。

3第 ② 版:加一张二维表(★ 这一版就能 AC)

★★ 判断该不该上记忆化,就问这两句
  1. 一个子问题由哪几个量唯一决定? 这道题是 restin,两个。
  2. 这几个量的组合有多少种? 各自都不超过 n = 18 ⇒ 最多 19 × 19 = 361 个格子。

361 个格子,每个只算一次 —— 记忆化在这里稳赢。

⇒ 这两句话就是记忆化的全部判断依据,比记住「什么题能用记忆化」有用得多。

long long memo[25][25];            // ⚠ 初值 memset 成 -1,不是 0

long long f(int rest, int in) {
    if (rest == 0) return 1;
    if (memo[rest][in] >= 0) return memo[rest][in];   // ★ 算过了
    long long total = f(rest - 1, in + 1);
    if (in > 0) total += f(rest, in - 1);
    return memo[rest][in] = total;                    // ★ 存下来
}
p1044Memo.cpp第 ② 版(能 AC)
直接输入 18。上一版要等好几秒的东西,这一版一瞬间。
// 洛谷 P1044 栈 —— 第 ② 版:同一个递归,加一张二维表(★ 这一版就能 AC)
//
// 输入:n
// 输出:可能的输出序列总数(和第 ① 版完全一样)
//
// 这份为什么存在:第 ① 版慢在「同一个 (rest, in) 被问了很多遍」。
// **状态只有 (rest, in) 两个量,各自不超过 n** ⇒ 一共才 19 × 19 = 361 个格子,
// 每个只需要算一次。
//
// ★ 这就是记忆化的判断标准,值得背下来:
// **先看清楚「一个子问题由哪几个量唯一决定」,再看这几个量的组合有多少种。**
// 组合数不大(这里 361),记忆化就一定划算。
//
// 数字:n = 18 时第 ① 版要走十亿量级的节点,这一版只算 361 个格子。
//
// ⚠ memo 初值用 -1,不能用 0 —— 0 是**合法的答案**吗?这道题不是(答案恒 >= 1),
// 但养成用 -1 的习惯更安全:换一道题,0 很可能就是个真答案了。
#include <bits/stdc++.h>
using namespace std;
int n;
long long memo[25][25];
long long f(int rest, int in) {
if (rest == 0) return 1;
if (memo[rest][in] >= 0) return memo[rest][in]; // ★ 算过了
long long total = f(rest - 1, in + 1);
if (in > 0) total += f(rest, in - 1);
return memo[rest][in] = total; // ★ 存下来
}
int main() {
if (!(cin >> n)) return 0;
memset(memo, -1, sizeof memo);
cout << f(n, 0) << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

考场上写到这里就该去做下一题了。 后面那一版更漂亮,但一分都不多给。

⚠ 哨兵为什么用 −1,不用 0

P1028 那道题用 0 当「还没算过」是安全的,因为那道题的答案恒大于 0

但这是个碰运气的前提。换一道答案可能是 0 的题,用 0 当哨兵会让 「算出来是 0」和「还没算过」永远分不开 —— 那个格子每次都重算,缓存等于没加, 而答案还是对的,你根本发现不了,只会看到一个莫名其妙的 TLE。

⇒ 养成习惯:memset(memo, -1, sizeof memo),判断写 >= 0

4第 ③ 版:换个角度,它是卡特兰数

记忆化已经能过了。这一版不是为了更快(都是一瞬间),是为了看清这道题到底是什么

盯住一个具体的东西:1 号数字是第几个被输出的?

假设 1 是第 k+1 个被输出的。那么在它之前必须先输出 k 个数 —— 而这 k 个数只能来自 2..k+1(1 还压在栈底,只有比它晚进栈的才可能先出来)。

于是整个问题被切成两段互不干扰的小问题:

[ 前面那 k 个数自己排 ]  1  [ 剩下 n-1-k 个数自己排 ]
        f(k)                        f(n-1-k)

f(n) = f(0)·f(n-1) + f(1)·f(n-2) + ... + f(n-1)·f(0)        f(0) = 1

这就是卡特兰数f(3) = 5,正好是样例。

p1044.cpp第 ③ 版(卡特兰递推)
输入 18,和第 ② 版完全一样:477638700。
// 洛谷 P1044 栈 —— 第 ③ 版:换个角度想,它是卡特兰数
//
// 输入:n
// 输出:可能的输出序列总数
//
// 这份为什么存在:记忆化已经能过了。这一版不是为了更快(都是一瞬间),
// 是为了**看清这道题到底是什么**。
//
// 换个角度:盯住「**1 号数字是第几个被输出的**」。
// 假设 1 是第 k+1 个被输出的,那么在它之前必须先输出 k 个数 ——
// 而这 k 个数只能来自 2..k+1(因为 1 还压在栈底,比它晚进栈的才可能先出来)。
// 于是整个问题被切成两段互不干扰的小问题:
//
// 前面那段:k 个数自己排 ⇒ f(k)
// 后面那段:剩下 n-1-k 个数自己排 ⇒ f(n-1-k)
//
// f(n) = f(0)·f(n-1) + f(1)·f(n-2) + ... + f(n-1)·f(0) f(0) = 1
//
// 这就是**卡特兰数**(Catalan)。f(3) = 5,正好是样例。
//
// ★ 值得记住的不是「卡特兰数」这个名字,是那个动作:
// **盯住某一个元素,按它的位置把问题劈成两半。** 第 26 章区间 DP 用的是同一招。
//
// ⚠ 但也别倒过来想:**先会写第 ② 版的记忆化,再去认公式。**
// 认不出公式的题多得是,而「状态 + 记忆化」永远能用。
#include <bits/stdc++.h>
using namespace std;
int main() {
int n;
if (!(cin >> n)) return 0;
vector<long long> f(n + 1, 0);
f[0] = 1;
for (int i = 1; i <= n; i++)
for (int k = 0; k < i; k++) f[i] += f[k] * f[i - 1 - k];
cout << f[n] << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★ 值得记住的不是那个名字,是那个动作

「卡特兰数」这个词你可能一辈子只用到几次。但下面这个动作会一直用:

盯住某一个元素,按它的位置把问题劈成互不干扰的两半。

第 26 章区间 DP 用的是同一招(盯住「最后合并的是哪一堆」)。

⚠ 但顺序千万别反:先会写第 ② 版的记忆化,再去认公式。 认不出公式的题多得是,而「说清状态 + 记忆化」永远能用 —— 那才是保底的本事。

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

p1044Count.cpp数次数
不用输入,直接跑(第 1 列在 n = 16 时要走八千万个节点,会跑几秒)。
// P1044 的两个递归版本各走多少个节点 —— 换一把可复现的尺子
//
// 输入:无(表是写死的几个 n;第 ① 列超过 n = 16 就跑不动了,所以表到 16 为止)
// 输出:一张表,对每个 n 列出
// 答案 f(n) 可能的输出序列总数
// 1.没有记忆化 走过的节点数
// 2.有记忆化 调用次数(含直接命中缓存的那些)
// 真正算过的格子 记忆化下有多少个 (rest, in) 被真的算了一遍
//
// ★ 最后一列是这道题的题眼:**它正好是 n(n+1)/2**,因为能走到的状态就是
// rest 取 1..n、in 取 0..n-rest —— 数一数就这么多,和答案有多大毫无关系。
// ⇒ 「该不该上记忆化」这个判断,看的就是它:状态的组合数够小,就一定划算。
//
// ⚠ 第 ① 列的增长和答案本身一个量级(都是卡特兰数那条曲线),
// 所以它和 P1255、P1028 是同一种慢:**工作量跟着答案一起爆炸**。
#include <bits/stdc++.h>
using namespace std;
int n;
long long naiveNodes = 0;
long long naive(int rest, int in) {
naiveNodes++;
if (rest == 0) return 1;
long long total = naive(rest - 1, in + 1);
if (in > 0) total += naive(rest, in - 1);
return total;
}
long long memo[25][25];
long long memoCalls = 0, memoFilled = 0;
long long withMemo(int rest, int in) {
memoCalls++;
if (rest == 0) return 1;
if (memo[rest][in] >= 0) return memo[rest][in];
memoFilled++;
long long total = withMemo(rest - 1, in + 1);
if (in > 0) total += withMemo(rest, in - 1);
return memo[rest][in] = total;
}
int main() {
cout << " n 答案 f(n) 1.没记忆化 2.有记忆化 真正算的格子\n";
cout << "----- ------------- ------------- ----------- -----------\n";
for (int k : {3, 5, 10, 13, 15, 16}) {
n = k;
naiveNodes = 0;
long long ans = naive(k, 0);
memset(memo, -1, sizeof memo);
memoCalls = memoFilled = 0;
withMemo(k, 0);
cout << setw(5) << k
<< setw(16) << ans
<< setw(16) << naiveNodes
<< setw(14) << memoCalls
<< setw(14) << memoFilled << "\n";
}
cout << "\n最后一列正好是 n(n+1)/2(自己对一对:3->6, 5->15, 10->55, 16->136)——\n";
cout << "因为能走到的状态就是 rest 取 1..n、in 取 0..n-rest,数一数就是这么多。\n";
cout << "题目的上界 n = 18:第 1 列到那时是十亿量级,而最后一列只有 171 个格子。\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
    n       答案 f(n)      1.没记忆化    2.有记忆化  真正算的格子
-----   -------------   -------------   -----------   -----------
    3               5              13            10             6
    5              42             106            26            15
   10           16796           40509           101            55
   13          742900         1776311           170            91
   15         9694845        23097541           226           120
   16        35357670        84118036           257           136
  • 第 1 列跟着答案一起爆炸 —— 和 P1028、P1255 是同一种慢。
  • 最后一列正好是 n(n+1)/2(自己对一对:3→6、5→15、10→55、16→136)。 因为能走到的状态就是 rest1..nin0..n-rest,数一数就这么多。
  • 题目的上界 n = 18:第 1 列是十亿量级,而最后一列只有 171 个格子
★ 最后一列才是「该不该记忆化」的答案

它和「答案有多大」完全无关 —— 答案是 4.7 亿,格子只有 171 个。

⇒ 所以判断记忆化划不划算,永远看状态数,不看答案大小。 这两个数在这道题里差了六个数量级,正好把它们分得清清楚楚。

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

✓ 三件带得走的东西
  1. 看不出公式就写过程。 把「函数负责什么」说清楚,规则自己会冒出来 —— 这道题需要两个量 (rest, in) 才说得清,而说清了就写完了一半。
  2. 记忆化的判断标准是状态数,不是答案大小。 这道题答案 4.7 亿、状态 171 个。
  3. 哨兵用 −1 不用 0。 用 0 那次你可能碰巧对,下一次就会栽。

⇒ 这道题在第 1 章的题单里标着「想不出来就先跳过,学完第 17 章再回来」—— 现在你已经不用等到第 17 章了:第 ② 版就是记忆化,而它一共只多了两行。