LEETCODE 096Medium

不同的二叉搜索树

以 i 为根时左右子树各自独立,形态数按乘法原理相乘,再对所有根求和,G(n) 正是卡特兰数。

问题拆解

1n 这些节点能组成多少棵结构互不相同的二叉搜索树?如果真去枚举每一棵树,形态数是指数级的,n 稍大就没法穷举。

突破口是 BST 的性质:一旦选定 i 当根,左子树必然由 1..i-1 组成,右子树必然由 i+1..n 组成,划分是唯一确定的。更关键的一步观察是:子树的形态数只和节点个数有关,与具体是哪些数无关——{1,2,3} 能摆出的 BST 形态和 {5,6,7} 一模一样。于是状态可以压缩成一维:设 G(k) 表示 k 个节点的 BST 形态数。

左子树怎么长、右子树怎么长,彼此互不干涉:左边每一种形态都能和右边每一种形态自由搭配。这就是乘法原理,也是 G(i-1) * G(n-i) 这个乘积的全部含义。

对根的位置分类,各类互不重叠又覆盖所有情况,所以求和:

G(n) = Σ G(i-1) * G(n-i),i 从 1 取到 n

这个递推式在数学上就是卡特兰数。

枚举根节点的递推

G 的定义从小规模往大规模填表即可。唯一要想清楚的边界是 G(0) = 1:空树也算一种形态,否则只要某一侧子树为空,乘积就被清零了。

public int numTrees(int n) {
    int[] g = new int[n + 1];
    g[0] = 1; // 空树也是一种形态
    for (int i = 1; i <= n; i++) {
        for (int root = 1; root <= i; root++) {
            g[i] += g[root - 1] * g[i - root]; // 左子树形态数 × 右子树形态数
        }
    }
    return g[n];
}
def numTrees(n: int) -> int:
    g = [0] * (n + 1)
    g[0] = 1  # 空树也是一种形态
    for i in range(1, n + 1):
        for root in range(1, i + 1):
            g[i] += g[root - 1] * g[i - root]  # 左子树形态数 × 右子树形态数
    return g[n]
func numTrees(n int) int {
    g := make([]int, n+1)
    g[0] = 1 // 空树也是一种形态
    for i := 1; i <= n; i++ {
        for root := 1; root <= i; root++ {
            g[i] += g[root-1] * g[i-root] // 左子树形态数 × 右子树形态数
        }
    }
    return g[n]
}
pub fn num_trees(n: i32) -> i32 {
    let n = n as usize;
    let mut g = vec![0i64; n + 1];
    g[0] = 1; // 空树也是一种形态
    for i in 1..=n {
        for root in 1..=i {
            g[i] += g[root - 1] * g[i - root]; // 左子树形态数 × 右子树形态数
        }
    }
    g[n] as i32
}

内层循环枚举“谁当根”:根左边有 root - 1 个节点,右边有 i - root 个,两侧形态数相乘后累加。注意这里加的是乘积而不是和——初学者容易写成 g[root-1] + g[i-root],那算的是“左右子树形态数之和”,丢掉了搭配关系。另外题目虽然保证 n <= 19、结果不溢出 int,Rust 版仍用 i64 中转一下,省去对边界的心算。

复杂度

指标 复杂度 原因
时间 O(n²) 每个规模 i 要枚举 i 种根的位置
空间 O(n) 一张一维的 G

可以迁移的模式

  • 计数 DP 先找一个“互不重叠、覆盖全部”的分类维度,这里是“谁当根”;
  • 两个互相独立的子结构,方案数用乘法原理相乘,再对分类求和;
  • 当子问题的答案只依赖规模、不依赖具体元素时,状态就能从“集合”压缩成“个数”。

“枚举根、左右相乘、按根求和”这套动作还会出现在第 95 题(要求真正构造出所有树)以及一切卡特兰数结构里——括号匹配、出栈序列,本质都是同一条递推。