学习卡特兰数:从原理到应用,解决所有递推计数问题
在组合数学和算法面试中,有一类问题始终占据重要地位------它们的答案都指向同一个数列:卡特兰数。无论是"合法括号组合""出栈序列计数",还是"二叉搜索树形态数",背后都藏着卡特兰数的逻辑。
本文将从卡特兰数的由来出发,用通俗的语言拆解核心原理(递推公式+通项公式推导),再结合3类经典应用场景,给出可直接套用的解题模板,帮你彻底掌握这一高频考点。
一、卡特兰数的由来:一个赌徒的问题
卡特兰数(Catalan Number)最早由比利时数学家欧仁·查理·卡特兰(Eugène Charles Catalan)在1838年提出,但在此之前,欧拉、兰伯特等数学家已在研究相关问题。
其最初的起源是"凸多边形三角剖分问题":一个凸n边形,通过不相交的对角线,能将其分成多少个三角形?
比如:
-
凸3边形(三角形):只有1种分法(本身就是三角形)
-
凸4边形:有2种分法(沿两条不同的对角线拆分)
-
凸5边形:有5种分法
这些分法数,正是卡特兰数的前几项。后来人们发现,这个数列还能解决大量"约束条件下的计数问题",成为组合数学中的核心数列之一。
二、卡特兰数的核心原理:两个公式的通俗推导
卡特兰数的核心是两个公式:递推公式(适合小范围计算、理解逻辑)和通项公式(适合大范围计算、提高效率)。我们从最直观的"合法括号组合"问题入手,一步步推导这两个公式。
2.1 先明确:什么是"合法括号组合"?
给定n对括号,合法组合需满足两个条件:
-
任意前缀中,左括号数量 ≥ 右括号数量(过程约束,避免"右括号超前");
-
最终左括号总数 = 右括号总数(结果约束,用完所有括号)。
比如n=2时,合法组合有2种:()()、(());非法组合有2种:)()、())(。
2.2 递推公式推导:拆分问题,化繁为简
递推公式的核心思想是:把大问题拆成两个独立的小问题,用"乘法原理+加法原理"合并结果。
第一步:找拆分的"锚点"
对于任意一个n对括号的合法组合,第一个左括号(必然有一个唯一对应的右括号)。这个对应关系就是我们的"拆分锚点",它将整个组合分成3部分:
( 内部i对括号 ) 右边n-1-i对括号
解释:
-
外层的
()是锚点,用掉了1对括号; -
"内部i对括号":锚点中间的合法组合,方案数记为C(i)(第i个卡特兰数);
-
"右边n-1-i对括号":锚点右边的合法组合,方案数记为C(n-1-i);
-
i的取值范围是0≤i≤n-1(内部可以是空的,也可以塞下n-1对括号)。
第二步:用乘法原理算单种i的方案数
乘法原理:做一件事分两步,第一步有A种方法,第二步有B种方法,总方法数=A×B。
对应拆分逻辑:
-
第一步:选内部i对括号的组合,有C(i)种方法;
-
第二步:选右边n-1-i对括号的组合,有C(n-1-i)种方法;
-
单种i对应的总方案数 = C(i) × C(n-1-i)。
第三步:用加法原理算所有i的总方案数
加法原理:做一件事有多种互斥路径,每种路径有A、B、C种方法,总方法数=A+B+C。
不同i对应的组合是"互斥"的(一个组合只能属于某一个i),因此总方案数是所有i的方案数之和。
最终递推公式
边界条件:C(0) = 1(0对括号只有1种空方案)
递推公式:C(n) = C(0)×C(n-1) + C(1)×C(n-2) + ... + C(n-1)×C(0) (n≥1),即从i=0到i=n-1,将C(i)与C(n-1-i)的乘积依次相加
验证:用递推公式算前几项
-
C(0) = 1
-
C(1) = C(0)×C(0) = 1×1 = 1(1对括号:
()) -
C(2) = C(0)×C(1) + C(1)×C(0) = 1×1 + 1×1 = 2(2对括号:
()()、(())) -
C(3) = C(0)×C(2) + C(1)×C(1) + C(2)×C(0) = 1×2 + 1×1 + 2×1 = 5(3对括号:5种合法组合)
完全符合实际情况!
2.3 通项公式推导:补集思想,简化计算
递推公式的时间复杂度是O(n²),n较大时效率低。我们需要更简洁的通项公式,这里用"补集思想"推导:合法组合数 = 总排列数 - 非法组合数。
第一步:计算总排列数
n对括号 = n个( + n个),共2n个字符。从2n个位置中选n个放(,剩下的放),总排列数是组合数:从2n个位置里选n个位置的选法数,记为C(2n, n),其计算方式是(2n的阶乘)除以(n的阶乘乘以n的阶乘)。
第二步:计算非法组合数
非法组合的定义:存在某前缀,右括号数 > 左括号数。这里有个关键技巧------非法组合与"n+1个 ( ** + n-1个** ) 的排列"一一对应。
转换方法:找到第一个右括号数超过左括号数的位置,将这个位置及之前的所有括号反转((变),)变()。
例子:n=2,非法组合)()
-
第一个非法位置是第1位(右括号数=1,左括号数=0);
-
反转第1位:
)变(,得到新排列(()(; -
新排列有3个
(、1个)(即n+1个(、n-1个))。
因此,非法组合数 = 从2n个位置选n+1个放(的组合数:
因此,非法组合数 = 从2n个位置选n+1个放(的组合数,记为C(2n, n+1),其计算方式是(2n的阶乘)除以((n+1)的阶乘乘以(n-1)的阶乘)。
第三步:化简得到通项公式
合法组合数 = 总排列数 - 非法组合数:
合法组合数 = 总排列数 - 非法组合数,对应关系为:C(n) = C(2n, n) - C(2n, n+1)(C(2n, n)是总排列数,C(2n, n+1)是非法组合数)。
代入组合数公式化简(过程略,核心是通分、约分):
代入组合数公式化简(过程略,核心是通分、约分)后,得到通项公式:C(n) = 1/(n+1) × C(2n, n),其计算方式是(2n的阶乘)除以((n+1)的阶乘乘以n的阶乘)。
验证:用通项公式算C(3)
用通项公式验证C(3):C(3) = 1/(3+1) × C(6, 3),其中C(6, 3)是从6个位置选3个的组合数,结果为20,因此C(3) = 1/4 × 20 = 5。
和递推公式结果一致!
2.4 卡特兰数的前10项(快速对照)
根据公式计算,前10项卡特兰数为:
| n | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
|---|---|---|---|---|---|---|---|---|---|---|
| C(n) | 1 | 1 | 2 | 5 | 14 | 42 | 132 | 429 | 1430 | 4862 |
三、卡特兰数的3类经典应用场景(附解题模板)
所有卡特兰数的应用场景,都具备一个核心特征:存在"前缀约束",且问题可拆分为两个独立子问题。下面介绍3类面试高频场景,均提供可直接套用的代码模板。
3.1 场景1:合法括号生成(最经典)
问题描述
给定n对括号,生成所有合法的括号组合。
解题思路
用回溯法,严格遵循"前缀约束":
-
左括号数≤n时,可添加左括号;
-
右括号数<左括号数时,可添加右括号。
代码模板(JavaScript)
javascript
/**
* 生成n对合法括号
* @param {number} n - 括号对数
* @returns {string[]} 所有合法组合
*/
function generateParenthesis(n) {
const result = [];
// 回溯函数:cur-当前组合,left-已用左括号数,right-已用右括号数
const backtrack = (cur, left, right) => {
// 终止条件:组合长度=2n
if (cur.length === 2 * n) {
result.push(cur);
return;
}
// 可添加左括号(左括号未用完)
if (left < n) {
backtrack(cur + '(', left + 1, right);
}
// 可添加右括号(右括号数<左括号数)
if (right < left) {
backtrack(cur + ')', left, right + 1);
}
};
backtrack('', 0, 0);
return result;
}
// 测试:n=3 → 输出5种组合
console.log(generateParenthesis(3));
// ["((()))","(()())","(())()","()(())","()()()"]
}
3.2 场景2:合法出栈序列
问题描述
给定进栈序列1~n,生成所有合法的出栈序列(不能从空栈出栈)。
解题思路(与括号问题的等价性)
| 出栈场景 | 括号场景 | 约束条件 |
|---|---|---|
| 元素进栈 | 添加左括号 | 进栈数≤n |
| 元素出栈 | 添加右括号 | 出栈数<进栈数 |
代码模板(JavaScript)
javascript
/**
* 生成1~n的所有合法出栈序列
* @param {number} n - 元素个数
* @returns {number[][]} 所有合法出栈序列
*/
function generatePopSequence(n) {
const result = [];
// 回溯函数:inStack-栈内元素,outStack-已出栈元素,next-下一个要进栈的元素
const backtrack = (inStack, outStack, next) => {
// 终止条件:栈空且所有元素已进栈
if (inStack.length === 0 && next > n) {
result.push([...outStack]);
return;
}
// 栈非空时,可出栈
if (inStack.length > 0) {
const top = inStack.pop();
outStack.push(top);
backtrack(inStack, outStack, next);
// 回溯:恢复状态
outStack.pop();
inStack.push(top);
}
// 还有元素未进栈时,可进栈
if (next <= n) {
inStack.push(next);
backtrack(inStack, outStack, next + 1);
// 回溯:恢复状态
inStack.pop();
}
};
backtrack([], [], 1);
return result;
}
// 测试:n=3 → 输出5种序列
console.log(generatePopSequence(3));
// [[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,2,1]]
}
3.3 场景3:不同的二叉搜索树(BST)形态
问题描述
给定n个不同的节点(如1~n),统计所有可能的二叉搜索树形态数,或生成所有形态。
解题思路
BST的核心是"左子树值<根值<右子树值",因此:
-
选第i个节点作为根,左子树有i-1个节点,右子树有n-i个节点;
-
左子树形态数×右子树形态数 = 以i为根的形态数;
-
遍历所有i(1≤i≤n),求和得到总形态数(即C(n))。
代码模板1:统计形态数(递推公式)
javascript
/**
* 统计n个节点的BST形态数
* @param {number} n - 节点数
* @returns {number} 形态总数(第n个卡特兰数)
*/
function countBST(n) {
const dp = new Array(n + 1).fill(0);
dp[0] = 1; // 边界:0个节点=空树,1种形态
// 递推公式:C(n) = sum(C(i) * C(n-1-i))
for (let i = 1; i <= n; i++) {
for (let j = 0; j < i; j++) {
// j=左子树节点数,i-1-j=右子树节点数(根占1个)
dp[i] += dp[j] * dp[i - 1 - j];
}
}
return dp[n];
}
// 测试:n=3 → 输出5
console.log(countBST(3));
}
代码模板2:生成所有BST形态
javascript
// 定义二叉树节点
class TreeNode {
constructor(val) {
this.val = val;
this.left = null;
this.right = null;
}
}
/**
* 生成n个节点的所有BST形态
* @param {number} n - 节点数(值为1~n)
* @returns {TreeNode[]} 所有BST的根节点数组
*/
function generateBST(n) {
// 生成[start, end]范围内的所有BST
const build = (start, end) => {
const res = [];
if (start > end) {
res.push(null); // 空树
return res;
}
// 遍历所有可能的根节点i
for (let i = start; i <= end; i++) {
const leftTrees = build(start, i - 1); // 左子树所有形态
const rightTrees = build(i + 1, end); // 右子树所有形态
// 乘法原理:组合左右子树
for (const left of leftTrees) {
for (const right of rightTrees) {
const root = new TreeNode(i);
root.left = left;
root.right = right;
res.push(root);
}
}
}
return res;
};
return n === 0 ? [] : build(1, n);
}
// 测试:统计n=3的形态数
console.log(generateBST(3).length); // 输出5
}
四、如何快速判断题目是否用卡特兰数?
遇到以下特征的计数/生成题,直接联想到卡特兰数:
-
存在"前缀约束"(如括号左≥右、栈非空才能出栈、BST的左<根<右);
-
问题可拆分为"左右两个独立子问题";
-
小例子的结果符合卡特兰数(n=1→1,n=2→2,n=3→5,n=4→14)。
五、总结
卡特兰数的核心不是"死记公式",而是"理解约束下的拆分逻辑":
-
由来:源于凸多边形三角剖分问题,后来扩展到各类约束计数场景;
-
原理:递推公式(拆分问题+乘法/加法原理)、通项公式(补集思想+组合数化简);
-
应用:括号生成、出栈序列、BST形态等,核心是"前缀约束+子问题拆分";
-
解题:通用模板基于回溯或递推,只需根据场景调整"约束条件"和"子问题组合方式"。
掌握卡特兰数,就能解决一类高频面试题------记住:所有符合"前缀约束+子问题拆分"的计数问题,答案都是卡特兰数。
拓展练习:尝试用卡特兰数解决"凸n边形三角剖分"问题,验证是否符合C(n-2)(提示:凸n边形的三角剖分数=第n-2个卡特兰数)。
六、进阶优化技巧与面试真题解析
6.1 进阶优化:大数情况下的卡特兰数计算
卡特兰数增长极快,当n≥20时,数值会远超JavaScript原生Number类型的最大值(2⁵³-1),导致精度丢失。此时需要通过"大数运算"或借助Python的原生大整数类型解决。
优化方案1:JavaScript大数运算(模拟乘法)
Plain
/**
* 大数运算计算第n个卡特兰数(避免精度丢失)
* @param {number} n - 卡特兰数序号
* @returns {string} 第n个卡特兰数(字符串形式,避免大数溢出)
*/
function catalanBigNumber(n) {
// 用数组存储大数,低位在前
let result = [1];
// 计算 (2n)! / (n! * (n+1)!) = 乘积从i=n+2到2n,除以i-n
for (let i = n + 2; i <= 2 * n; i++) {
// 第一步:乘以i
let carry = 0;
for (let j = 0; j < result.length; j++) {
const product = result[j] * i + carry;
result[j] = product % 10;
carry = Math.floor(product / 10);
}
while (carry > 0) {
result.push(carry % 10);
carry = Math.floor(carry / 10);
}
// 第二步:除以 (i - n)
let remainder = 0;
for (let j = result.length - 1; j >= 0; j--) {
const dividend = remainder * 10 + result[j];
result[j] = Math.floor(dividend / (i - n));
remainder = dividend % (i - n);
}
// 移除前面的0
while (result.length > 1 && result[result.length - 1] === 0) {
result.pop();
}
}
// 数组反转,转为字符串
return result.reverse().join('');
}
// 测试:n=20 → 输出16796(正确的第20个卡特兰数)
console.log(catalanBigNumber(20)); // 输出"6564120420"
}
优化方案2:Python原生大整数(简洁高效)
Python的int类型支持任意大小整数,无需额外处理大数,直接用通项公式计算即可:
Plain
def catalan(n):
from math import comb
# 通项公式:C(n) = 1/(n+1) * C(2n, n)
return comb(2 * n, n) // (n + 1)
# 测试:n=100 → 直接输出超大卡特兰数
print(catalan(100))
# 输出:896519947090131496687170070074100632420837521538745909320
6.2 面试真题解析
真题1:LeetCode 22. 括号生成(中等)
题目描述:数字 n 代表生成括号的对数,请你设计一个函数,用于能够生成所有可能的并且 有效的 括号组合。
解题思路:直接套用"合法括号生成"模板,核心是回溯时遵循"左括号数≤n"和"右括号数<左括号数"的约束。
代码实现:与本文3.1节的代码模板完全一致,提交即可通过。
真题考点:卡特兰数的核心约束、回溯算法的应用。
真题2:LeetCode 96. 不同的二叉搜索树(中等)
题目描述:给你一个整数 n ,求恰由 n 个节点组成且节点值从 1 到 n 互不相同的 二叉搜索树 有多少种?返回满足题意的二叉搜索树的种数。
解题思路:本题是卡特兰数的直接应用,n个节点的BST形态数就是第n个卡特兰数,直接套用3.3节的"统计形态数"模板。
Plain
/**
* @param {number} n
* @returns {number}
*/
var numTrees = function(n) {
const dp = new Array(n + 1).fill(0);
dp[0] = 1;
for (let i = 1; i <= n; i++) {
for (let j = 0; j < i; j++) {
dp[i] += dp[j] * dp[i - 1 - j];
}
}
return dp[n];
};
真题考点:卡特兰数递推公式、动态规划思想。
真题3:LeetCode 95. 不同的二叉搜索树 II(中等)
题目描述:给你一个整数 n ,请你生成所有由 1 到 n 为节点所组成的 二叉搜索树 ,并返回它们的根节点列表。
解题思路:套用3.3节的"生成所有BST形态"模板,通过递归构建左右子树,再组合得到所有合法BST。
代码实现:与本文3.3节的代码模板一致,注意TreeNode的定义符合题目要求即可。
真题考点:卡特兰数的拆分逻辑、递归组合子问题。