先说结论n个互异元素依次入栈能产生的不同出栈序列数是第n个卡塔兰数而同样的n个元素依次入队能产生的出队序列数却是1。“n元栈和队列的两个计数问题的解析求解”这个标题浓缩的就是两个问题一个是栈的出栈序列计数一个是队列的出队序列计数最终都要给出闭式公式并解释清楚为什么答案差得这么远。适合谁看准备算法面试的人、学组合计数的人、背过卡特兰公式却搞不清它怎么来的人。如果只是记住C_n (1/(n1))·binom(2n, n)但被追问“为什么”“队列呢”就卡住这篇就是给你写的。1. 两个计数问题到底是什么1.1 栈数的是出栈序列先说“n元栈”的默认含义。有一组互异元素编号1到n按编号从小到大依次进入一个栈。过程中可以在任意时刻让栈顶元素出栈。问的是最终能产生多少种不同的出栈序列。拿n3举例。全排列一共有6个但通过栈过滤之后只剩下5个合法出栈序列分别是123、132、213、231、321。缺的是312因为要让3先出栈必须是1、2、3全部入栈后再依次弹出弹出顺序只能是3、2、1不可能在2之前弹出1。这个“被栈过滤掉”的机制正是计数的核心。这个问题在面试里很经典很多人第一反应是“这不就是排列组合嘛”但实际上栈的可达序列是一个严格子集。n3时6个排列只剩5个n4时24个排列只剩14个。答案序列1、1、2、5、14、42……看到这个数列熟悉的人已经知道是卡特兰数但“知道答案”和“会推导”是两码事。1.2 队列数出队序列还是操作序列结论完全不同队列那边是个经典陷阱。n个元素按编号1到n依次入队可以随时执行出队操作。如果问“出队序列有多少种”答案只有一个1到n顺序弹出。因为队列是先进先出先入队的永远先出队入队顺序固定之后出队顺序就被锁死了。但要是换一个问法“有多少种合法的入队/出队操作序列”比如n2可以“入、入、出、出”也可以“入、出、入、出”两个操作序列都合法数量是2。这样数出来的数字又变成卡特兰数了。所以队列问题必须先声明计数对象。同样是“入队出队”数“出队序列”和数“操作序列”是两套完全不同的题目答案分别是1和卡特兰数。标题里的“两个计数问题”我的理解就是把“栈数出栈序列”和“队列数出队序列”放在一起做解析求解。一个答案复杂、一个答案平凡正好构成一组绝佳的对照案例。2. 栈的计数问题解析求解之路这一节把卡特兰数的闭式公式完整推一遍。不是为了炫技而是只有亲手推过才能真正理解为什么答案长成那样后续遇到变体题才不会慌。2.1 用元素1的位置建立递推设f(n)表示n个互异元素入栈后能得到的出栈序列数。直接想递推时一个好用的分类标准是看第一个入栈的元素1它在出栈序列中是第几个出来的。假设元素1是第k1个出栈的k可以从0取到n-1。在元素1出栈之前栈里必须先处理k个元素让它们完成“入栈再出栈”。这k个元素的操作与元素1无关它们能产生的序列数是f(k)。元素1出栈之后剩下n-1-k个元素再按同样的规则进出栈序列数是f(n-1-k)。两部分互相独立乘法原理一乘就有f(k)·f(n-1-k)种。对k从0到n-1求和得到f(n) f(0)f(n-1) f(1)f(n-2) … f(n-1)f(0)这里f(0)1是个约定表示空栈只有一种空操作序列。没有这个约定递推没法起步。这个卷积形式的递推很整齐但直接用它算f(100)是O(n^2)的也没有闭式。所以继续往下走。2.2 生成函数给出闭式公式定义生成函数F(x)sum_{n≥0} f(n)x^n。把上面的递推卷进去能看出右边的卷积正好是F(x)^2的系数。所以F(x) 1 x·F(x)^2这个方程是二次的。移项后直接解x·F^2 - F 1 0F (1 ± sqrt(1-4x)) / (2x)两根必须二选一。判断方法很简单x→0时F应该趋近f(0)1。取减号那支sqrt(1-4x)在0附近约等于1-2x-2x^2-…分子约等于2x2x^2…除以2x后正好趋近1。取加号那支分子约等于2除以2x后发散必须舍掉。于是F(x) (1 - sqrt(1-4x)) / (2x)把sqrt(1-4x)做泰勒展开再和分母(2x)相除逐项提取x^n的系数。展开系数整理后就是f(n) (1/(n1))·binom(2n, n)这里binom(2n,n)表示从2n个位置里选n个的二项式系数。这个式子就是卡特兰数的标准闭式公式。验算一下n0得1n1得1n2得2n3得5n4得14和枚举完全一致。2.3 反射原理不用代数也能一眼看到答案生成函数推导严谨但对不熟悉代数操作的人不够直观。反射原理提供了另一条路也更有画面感。把“入栈”记作1“出栈”记作-1。n个元素最终都出栈所以总共2n个操作其中1有n个-1有n个。任意排列的种数是binom(2n,n)。一个排列要成为合法操作序列必须满足任意前缀里1的个数不少于-1的个数否则栈空时出栈非法。关键在数非法序列。某个非法序列第一次出现-1比1多1的位置把这个位置之前的1和-1全部反转1变-1-1变1。反转之后整个序列里-1的数量变成n1个1变成n-1个。而任意一个有n1个-1的序列也一定存在“第一次出现-1多于1”的位置反推回去能得到唯一的非法序列。所以非法序列数和binom(2n, n1)一一对应。合法序列数 总数 - 非法数binom(2n,n) - binom(2n,n1)代数化简一下就能得到同样的(1/(n1))·binom(2n,n)。两种推导路径一个偏代数一个偏组合建议都掌握。面试被问“为什么是卡特兰数”时反射原理讲起来最直观几句话说清楚。3. 队列的计数问题看似相同实则不同3.1 出队序列只有一个现在回到队列。n个元素按1到n的顺序依次入队无论你在什么时间点执行出队队头的元素永远是当前还没出队的元素里编号最小的那个。入队顺序固定先进先出的性质保证出队顺序只能是1、2、…、n。这里要特别体会一件事“操作序列”和“出队序列”是两个层面的东西。拿n2来看“入、入、出、出”和“入、出、入、出”都能执行完最终弹出的元素顺序都是(1,2)。区别只在于第一种情况里2是在1之后连续出队的第二种情况里1出队之后2才入队。也就是说操作序列决定了每个元素在队列里待多久但改变不了它们的相对顺序。所以出队序列这个集合里只有一个元素。它的闭式公式就是g(n)1解析求解到这里已经结束了。很多教材不把这个问题当回事因为它太简单。可恰恰是这份“简单”能照出不少人的思维惯性一看到入队出队就反射性地写卡特兰数。3.2 操作序列计数和栈的统一如果改问“合法的入队/出队操作序列有多少种”情况就变了。每个入队操作看成左括号出队操作看成右括号任何时刻出队次数不能超过入队次数否则队列为空会出错。这不就是合法括号序列的定义吗所以操作序列的数量同样是卡特兰数。有意思的地方在这栈和队列的合法操作序列数量相同都是卡特兰数但背后的物理含义完全不同。栈的每个合法操作序列都会对应一种出栈序列因为出栈顺序由栈顶决定队列的每个合法操作序列却只对应同一种出队序列1、2、…、n区别只在每个元素排队时间长或短。同样一个计数值底层一个靠LIFO一个靠FIFO。很多人在这一步会突然明白题目里“n元队列”如果没写清楚统计对象答案可以差出一个卡特兰数。这不能怪题目含糊只能怪自己没先问清楚“你数的到底是什么”。3.3 一张表看清差异计数对象栈后进先出队列先进先出出栈/出队序列数卡特兰数常数1合法操作序列数卡特兰数卡特兰数闭式公式(1/(n1))·binom(2n,n)1或卡特兰数取决于数什么核心机制栈顶决定出栈顺序队头固定出队顺序这张表建议保存。我见过不止一次模拟面试场景候选人流畅写完卡特兰公式被追问一句“那队列呢”当场愣住。原因就出在这里——题目问的是“序列”还是“操作序列”一字之差结论完全不同。能把这层窗户纸点破比多背十个公式更有用。4. 用代码验证解析结果推导完了必须用代码验证不然心里不踏实。下面这段Python同时用递推和闭式公式计算卡特兰数前10项并确认两者一致。from math import comb def catalan_by_recurrence(n_max): f [0] * (n_max 1) f[0] 1 for n in range(1, n_max 1): f[n] sum(f[k] * f[n - 1 - k] for k in range(n)) return f def catalan_by_formula(n): return comb(2 * n, n) // (n 1) N 10 print(递推:, catalan_by_recurrence(N)) print(公式:, [catalan_by_formula(i) for i in range(N 1)])输出都是同一行1, 1, 2, 5, 14, 42, 132, 429, 1430, 4862, 16796。递推版是O(n^2)公式版是O(n)n大了差距会很明显。更重要的是公式版把答案直接“算”出来而递推版只能一步一步往前推这正是“解析解”的价值。4.1 队列唯一性的枚举验证验证队列“出队序列唯一”可以枚举所有合法操作序列再把每个操作序列模拟一遍看最终出队序列是不是只有一种。from itertools import product def queue_dequeue_sequences(n): results set() for ops in product([0, 1], repeat2 * n): if sum(ops) ! n: continue queue [] output [] nxt 1 ok True for op in ops: if op 0: # 入队 queue.append(nxt) nxt 1 else: # 出队 if not queue: ok False break output.append(queue.pop(0)) if ok and not queue: results.add(tuple(output)) return results for n in range(1, 5): seqs queue_dequeue_sequences(n) print(fn{n}, 合法操作序列有无数可能, 但出队序列集合为: {seqs})运行结果是n1时只有(1,)n2时只有(1,2)n3时只有(1,2,3)n4时只有(1,2,3,4)。合法操作序列的数量是1、2、5、14正好是卡特兰数但每个操作序列对应的出队序列始终只有一个。这个实验把“操作序列数”和“出队序列数”的差异直观地甩在眼前比我讲多少话都管用。4.2 闭式公式的渐进增长分析闭式公式还有一个好处可以直接看增长趋势。用斯特林近似n!≈sqrt(2πn)·(n/e)^n代入卡特兰数的公式整理后得到C_n ≈ 4^n / (n^(3/2)·sqrt(π))4^n是主导项意味着n每增加1答案大约扩大4倍但分母里的n^(3/2)会稍微压低增速。这个渐进式有实际意义卡特兰数增长是指数级的n10时答案是16796看起来不大n20时已经是6564120420超过32位整数的范围了。所以实际写代码时要么用Python这种大整数原生支持的语言要么在C里及时取模。理解渐进增长还能帮你判断枚举法能做到多大。n30时总操作数是60直接枚举所有1/-1排列的规模是binom(60,30)大约是10^17量级完全不可行。这时候闭式公式几乎是唯一的快速路径。这也解释了为什么“解析求解”值得专门写一篇——递推能算枚举能验但真正要处理大规模问题时只有闭式公式顶得上。5. 常见问题与踩坑记录问题原因处理办法f(0)不会处理空栈的空操作序列也需要计数约定f(0)1递推从n1开始生成函数两根不会取舍有一支在x0处发散用F(0)1做极限检查反射原理非法数算成C(2n,n-1)反射后符号个数搞混反转后是n1个-1对应binom(2n,n1)大数组合算错浮点数除法损失精度用整数组合数先乘后整除以为队列出队序列也是卡特兰数混淆出队序列与操作序列动笔前先写下“数的是什么对象”不确定元素是否互异元素相同会改变计数“n元”默认指1到n的互异元素还有一个容易踩的坑有人会把“栈的计数”和“全排列的计数”混为一谈。卡特兰数不是n!而是被栈结构过滤之后仍然可达的那部分排列。n3时6个排列里只有5个可达缺的是312n4时24个排列里只有14个可达。这个对比能帮初学者快速建立“栈是过滤器”的直觉。实际推公式时有个小技巧先手算几个小规模答案n0到4分别是1、1、2、5、14一眼认出卡特兰数后再去想递推或闭式。认出数列之后推导路径会宽很多卡特兰数有十几种等价定义想到哪种用哪种。面试时如果卡住了先列小答案再猜递推往往比硬背公式靠谱得多。有人还会追问变体“如果元素不是按编号顺序入栈而是一个任意排列依次入栈出栈序列数还是卡特兰数吗”答案仍然是。你只需要把输入的排列重命名成1到n相对顺序没变计数模型就不变。类似的还有n对括号的合法排列、多边形的三角剖分数、二叉树形态数全都能落到同一套卡特兰推导上。我个人在实际操作中的体会是这种计数题最忌讳背公式。把生成函数和反射原理各推一遍再把栈和队列这对反例放在一起对比之后再遇到卡特兰相关的题会从容很多。最后再分享一个小习惯拿到任何计数问题先在纸上写下“我数的对象是____”明确到不能再明确再动笔。这个习惯看起来简单但帮我避开了绝大多数概念混淆。希望你也用得上。
阅读完成 · 觉得有帮助?