栈的输出序列与卡特兰数
栈的输出序列与卡特兰数:从记忆化搜索到数学模型的深度解析
在算法竞赛中,经常会遇到关于合法操作序列计数的问题。以经典的洛谷 P1044 [NOIP 2003 普及组] 栈 为例,题目要求计算 1,2,…,n1,2,\ldots,n1,2,…,n 经过栈的 push 和 pop 操作后,可能得到的输出序列总数。
本文将从基础的搜索状态空间出发,探讨如何通过操作的唯一性进行计数,并逐步引出这类问题的核心数学模型——卡特兰数(Catalan Number),同时深入解析卡特兰数在不同场景下的两种核心“面孔”。
一、 基于过程的状态空间搜索与一一映射
在面对“求合法结果的总数”时,直接去枚举最终的排列并判断其合法性通常是困难且低效的。一个更底层的思维方式是:不关注最终结果,而是关注生成结果的操作过程。
因为输入序列 1,2,…,n1, 2, \ldots, n1,2,…,n 的顺序是固定的,任何一种合法的“进栈、出栈的操作序列”,必然唯一对应一种“输出序列”。这就形成了一种一一映射的关系:合法的操作序列数量,等于合法的输出排列数量。
因此,可以通过深度优先搜索(DFS)模拟这个过程。在任意时刻,状态可以由两个量唯一确定:
stknum: 当前栈内元素的个数。numbers: 还没入栈的元素个数。
在任意状态下,面临的合法决策最多只有两种:
- 进栈操作:只要还有未入栈的元素(
numbers > 0),就可以将一个元素压入栈中,此时stknum + 1,numbers - 1。 - 出栈操作:只要栈内有元素(
stknum > 0),就可以将栈顶元素弹出,此时stknum - 1,numbers不变。
为了避免重复计算,可以引入一个二维数组记录已经计算过的状态,这就是记忆化搜索。以下是这种思路的 C++ 代码实现:
#include <bits/stdc++.h>
using namespace std;
int n;
int mem[30][30];
int dfs(int stknum, int numbers){
// 边界条件:如果没有未进栈的数字了,剩余的元素只能依次全部出栈,这构成 1 种确定方案
if(numbers == 0){
return 1;
}
int cnt = 0;
// 决策1:将一个未入栈的数字压入栈中
if(mem[stknum + 1][numbers - 1]) {
cnt += mem[stknum + 1][numbers - 1];
} else {
int tmp = dfs(stknum + 1, numbers - 1);
cnt += tmp;
mem[stknum + 1][numbers - 1] = tmp;
}
// 决策2:将栈顶元素弹出(前提是栈不为空)
if(stknum > 0) {
if(mem[stknum - 1][numbers]) {
cnt += mem[stknum - 1][numbers];
} else {
int tmp = dfs(stknum - 1, numbers);
cnt += tmp;
mem[stknum - 1][numbers] = tmp;
}
}
return cnt;
}
int main(){
cin >> n;
int cnt = dfs(0, n);
cout << cnt << '\n';
return 0;
}
上述代码的时间复杂度为 O(n2)O(n^2)O(n2),对于 n≤18n \le 18n≤18 的数据范围绰绰有余。但如果 nnn 的范围扩大到 10510^5105,这种二维状态的递推将无法承受时间和空间的开销。
二、 抽象为数学模型:卡特兰数
如果仔细观察上述搜索过程的核心限制:整个过程需要执行 nnn 次进栈动作和 nnn 次出栈动作,且在任何时刻,已经执行的出栈次数,绝对不能超过已经执行的进栈次数(否则就会试图从空栈中弹出元素)。
在组合数学中,满足这种“两种动作数量相等,且在任意前缀中一种动作的数量不小于另一种动作的数量”的模型,其合法方案总数构成了一个著名的数列——卡特兰数(Catalan Number)。
卡特兰数的前几项为:1,1,2,5,14,42,132…1, 1, 2, 5, 14, 42, 132 \dots1,1,2,5,14,42,132…(对应 n=0,1,2,3,4,5,6…n=0, 1, 2, 3, 4, 5, 6 \dotsn=0,1,2,3,4,5,6…)。
三、 卡特兰数的两副“面孔”:经典同构问题解析
卡特兰数在数学和算法中有极其广泛的应用。表面上看毫不相干的问题,背后往往隐藏着相同的数学本质。卡特兰数主要有两副核心“面孔”。
面孔一:两种操作互不超越(线性匹配匹配)
除了栈的输出序列,网格路径问题是这一面孔的经典代表:
在 n×nn \times nn×n 的网格中,从左下角坐标 (0,0)(0,0)(0,0) 走到右上角 (n,n)(n,n)(n,n),每次只能向右或向上走,且不能越过对角线,有多少种走法?
乍一看,这与进出栈毫无关系。但细细剖析:
每次向右走一步,横坐标 xxx 加 1;每次向上走一步,纵坐标 yyy 加 1。
核心条件是“不能越过对角线”。对角线方程为 y=xy = xy=x,不能越过意味着在整个行走的任意时刻,必须满足 y≤xy \le xy≤x。也就是说:向上走的步数,绝对不能超过向右走的步数。
这与栈的操作完美对应:
- 向右走(横坐标 +1) 等价于 进栈操作(栈内元素 +1)
- 向上走(纵坐标 +1) 等价于 出栈操作(栈内元素 -1)
- 不能越过对角线 (y≤xy \le xy≤x) 等价于 出栈的次数不能超过进栈的次数
- 总共走到 (n,n)(n,n)(n,n) 等价于 总共进行了 nnn 次进栈和 nnn 次出栈
同理,经典的括号匹配问题(右括号数量不能超过左括号)也完全契合这一模型。
面孔二:分治与组合的二叉树结构(递归切分)
卡特兰数的另一副面孔,体现在“将一个大问题切分成两个独立的小问题”的结构中。凸多边形划分问题是最佳代表:
将一个凸 n+2n+2n+2 边形通过不相交的对角线划分为 nnn 个三角形,有多少种划分方法?
这里似乎只有“画对角线”一种操作,为什么也是卡特兰数?
假设有一个凸 n+2n+2n+2 边形,顶点按顺时针编号为 1,2,3,…,n+21, 2, 3, \ldots, n+21,2,3,…,n+2。我们可以盯住其中一条固定的边,比如连接顶点 111 和 n+2n+2n+2 的底边。
在任何一种合法的三角形划分中,这条底边必定属于某一个三角形。要构成这个三角形,还需要选择第三个顶点 kkk(kkk 可以是 2,3,…,n+12, 3, \ldots, n+12,3,…,n+1 中的任意一个)。
一旦选定了这个顶点 kkk,大三角形 (1,k,n+2)(1, k, n+2)(1,k,n+2) 就像一把刀,把原来的多边形切成了两半:
- 左边: 由顶点 1,2,…,k1, 2, \ldots, k1,2,…,k 构成的一个凸 kkk 边形。
- 右边: 由顶点 k,k+1,…,n+2k, k+1, \ldots, n+2k,k+1,…,n+2 构成的一个凸 n+3−kn+3-kn+3−k 边形。
这两个小多边形接下来还要各自继续划分三角形。如果定义 HnH_nHn 为划分 n+2n+2n+2 边形的方案数,选定顶点 kkk 时的总划法就是左边的方案数乘上右边的方案数。把所有可能的 kkk 累加起来,就得到了卡特兰数极其重要的非线性递推公式(卷积公式):
Hn=H0Hn−1+H1Hn−2+H2Hn−3+…+Hn−1H0H_n = H_0 H_{n-1} + H_1 H_{n-2} + H_2 H_{n-3} + \ldots + H_{n-1} H_0Hn=H0Hn−1+H1Hn−2+H2Hn−3+…+Hn−1H0
进出栈问题同样可以用这个公式解释:如果关注“最后一次出栈的元素是哪一个”,也能将操作序列劈成左右两个互相独立的合法子序列,从而推导出完全相同的乘法递推式。两种面孔在数学上殊途同归。
四、 卡特兰数的求解公式与代码
通过组合数学的反射容斥原理,可以推导出卡特兰数 HnH_nHn 的通项公式与线性递推公式。
1. 组合数通项公式:
Hn=1n+1(2nn)=(2n)!(n+1)!n!H_n = \frac{1}{n+1} \binom{2n}{n} = \frac{(2n)!}{(n+1)!n!}Hn=n+11(n2n)=(n+1)!n!(2n)!
2. 线性递推公式(最常用于编程求解):
Hn=Hn−1×4n−2n+1H_n = H_{n-1} \times \frac{4n - 2}{n + 1}Hn=Hn−1×n+14n−2
利用递推公式,可以在 O(n)O(n)O(n) 的时间复杂度与 O(1)O(1)O(1) 的空间复杂度下解决该问题,完美突破搜索算法的瓶颈。
#include <iostream>
using namespace std;
int main() {
int n;
cin >> n;
// 使用 long long 防止乘法过程溢出
long long h = 1;
for (int i = 1; i <= n; i++) {
h = h * (4 * i - 2) / (i + 1);
}
cout << h << '\n';
return 0;
}
五、 结语
解决合法操作计数问题时,从状态空间搜索入手是一种非常直观且不易遗漏的思路。通过定义清晰的状态和合法的决策分支,可以利用记忆化搜索或动态规划得到正确的答案。
而当问题可以抽象为两种互相制约的操作,或具备递归切分的二叉树特征时,将其识别为卡特兰数模型,则可以将算法的时间复杂度进一步降至线性级别,这充分体现了数学抽象与算法结合的优雅之处。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)