bigywater.tech
算法与数据结构 · LeetCode 96

LeetCode 96. 不同的二叉搜索树

给定整数 n,问用 1 到 n 这 n 个互不相同的数,能组成多少种结构不同的二叉搜索树。上一题(第 95 题)要把每一棵树都造出来,这一题只要数出个数,所以不需要真的建树。二叉搜索树的结构由"选谁当根"决定:如果选第 root 小的数当根,那么比它小的 root - 1 个数必须在左子树,比它大的 n - root 个数必须在右子树,左右两边互相独立,方案数相乘。用 dp[k] 表示 k 个结点能组成的树的个数,dp[k] 等于对 root 从 1 到 k 求 dp[root-1] × dp[k-root] 的和。这个数列就是卡特兰数。本文用真实运行验证它与卡特兰数公式一致,展示不带记忆化递归的调用次数,用 Java 展示 n = 20 时 32 位整数溢出,给出五种语言的实现,并把填表做成分步演示。

19
组随机对拍,与暴力解的结果全部一致
7 步
分步演示,每一帧都来自解法的真实执行,另附 5 种语言实现

题意与示例

完整题目见官方页面(链接在文末)。n 不超过 19(这样答案在 32 位有符号整数范围内)。基准解是卡特兰数的通项公式 C(2n, n) / (n + 1),与用递推填表是完全不同的两条路。一共 12 组用例,n 取 1 到 8、10、15、18、19:

真实实测 · 本机执行输出
输入 [1]
输出 1
输入 [2]
输出 2
输入 [3]
输出 5
输入 [4]
输出 14
输入 [5]
输出 42
输入 [6]
输出 132

陷阱:不带记忆化的递归

按定义写的递归:f(n) = Σ f(i-1) × f(n-i),同一个 f(k) 会被反复计算很多次。下面是真实统计的递归调用次数:

真实实测 · 本机执行输出
不带记忆化的递归:调用次数与耗时
n= 5: 结果      42,递归调用       135 次,    0.02 ms;动态规划只有 15 次乘加
n= 8: 结果    1430,递归调用      3645 次,    0.56 ms;动态规划只有 36 次乘加
n=10: 结果   16796,递归调用     32805 次,    3.95 ms;动态规划只有 55 次乘加
n=12: 结果  208012,递归调用    295245 次,   39.97 ms;动态规划只有 78 次乘加

n = 5、8、10、12 时,调用次数是 135、3645、32805、295245,n 每增加 1 大约乘 3,是指数增长;动态规划只需要约 n²/2 次乘加(n = 12 时 78 次)。

陷阱:32 位整数在 n = 20 时溢出

题目把 n 限制在 19,原因是 f(19) = 1767263190 刚好在 32 位有符号整数的范围内(最大 2147483647),f(20) 就超出了。下面是真实运行的 Java 程序,对比 int 和 long:

真实实测 · 本机执行输出
Java:用 int 和 long 分别计算(题目限制 n ≤ 19):
n=19  int: 1767263190   long: 1767263190
n=20  int: -2025814172   long: 6564120420

n = 19 时两者一致;n = 20 时 int 得到 -2025814172(溢出回绕成了负数),long 得到正确的 6564120420。所以如果要把这道题推广到更大的 n,必须换更宽的整数类型。

动态规划:五种语言的实现

dp[0] = 1(空树也算一种)。对 k 从 1 到 n,对 root 从 1 到 k:dp[k] += dp[root - 1] * dp[k - root]。返回 dp[n]。时间 O(n²),空间 O(n)。Python、Java、Go、JavaScript、C++ 都是真实编译运行的。

Python · 解法代码def solve(n): dp = [0] * (n + 1) # dp[k]:k 个不同的数能组成多少种二叉搜索树 dp[0] = 1 # 0 个结点:只有一种(空树) for k in range(1, n + 1): for root in range(1, k + 1): # 选第 root 小的数当根:左子树有 root-1 个结点,右子树有 k-root 个 dp[k] += dp[root - 1] * dp[k - root] return dp[n]
真实实测 · Python · 前几组用例的输出
$ python3 solution.py   # 统一入口:逐行读取 cases.jsonl,调用上面的 solve,打印 JSON
[1]  →  1
[2]  →  2
[3]  →  5
[4]  →  14
[5]  →  42
[6]  →  132
Java · 解法代码class Solution { public int numTrees(int n) { int[] dp = new int[n + 1]; // dp[k]:k 个不同的数能组成多少种二叉搜索树 dp[0] = 1; // 0 个结点:只有一种(空树) for (int k = 1; k <= n; k++) for (int root = 1; root <= k; root++) // 选第 root 小的数当根:左子树 root-1 个结点,右子树 k-root 个 dp[k] += dp[root - 1] * dp[k - root]; return dp[n]; } }
真实实测 · Java · 前几组用例的输出
$ javac -d build Main.java Solution.java && java -cp build Main < cases.jsonl
[1]  →  1
[2]  →  2
[3]  →  5
[4]  →  14
[5]  →  42
[6]  →  132
Go · 解法代码func numTrees(n int) int { dp := make([]int, n+1) // dp[k]:k 个不同的数能组成多少种二叉搜索树 dp[0] = 1 // 0 个结点:只有一种(空树) for k := 1; k <= n; k++ { for root := 1; root <= k; root++ { // 选第 root 小的数当根:左子树 root-1 个结点,右子树 k-root 个 dp[k] += dp[root-1] * dp[k-root] } } return dp[n] }
真实实测 · Go · 前几组用例的输出
$ go build -o bin . && ./bin < cases.jsonl
[1]  →  1
[2]  →  2
[3]  →  5
[4]  →  14
[5]  →  42
[6]  →  132
JavaScript · 解法代码function numTrees(n) { const dp = new Array(n + 1).fill(0); // dp[k]:k 个不同的数能组成多少种二叉搜索树 dp[0] = 1; // 0 个结点:只有一种(空树) for (let k = 1; k <= n; k++) for (let root = 1; root <= k; root++) // 选第 root 小的数当根:左子树 root-1 个结点,右子树 k-root 个 dp[k] += dp[root - 1] * dp[k - root]; return dp[n]; }
真实实测 · JavaScript · 前几组用例的输出
$ node run.js < cases.jsonl
[1]  →  1
[2]  →  2
[3]  →  5
[4]  →  14
[5]  →  42
[6]  →  132
C++ · 解法代码#include <vector> using namespace std; class Solution { public: int numTrees(int n) { vector<int> dp(n + 1, 0); // dp[k]:k 个不同的数能组成多少种二叉搜索树 dp[0] = 1; // 0 个结点:只有一种(空树) for (int k = 1; k <= n; k++) for (int root = 1; root <= k; root++) // 选第 root 小的数当根:左子树 root-1 个结点,右子树 k-root 个 dp[k] += dp[root - 1] * dp[k - root]; return dp[n]; } };
真实实测 · C++ · 前几组用例的输出
$ clang++ -std=c++17 -O2 -o bin sol.cpp && ./bin < cases.jsonl
[1]  →  1
[2]  →  2
[3]  →  5
[4]  →  14
[5]  →  42
[6]  →  132
真实实测 · 本机执行输出
共 12 组用例(含边界),以暴力解的结果为基准逐组比对:
  Python      通过 12/12
  Java        通过 12/12
  Go          通过 12/12
  JavaScript  通过 12/12
  C++         通过 12/12

耗时实测

n 分别为 100、300、1000、2000,每个规模取三次中最快的一次(Python 的整数不会溢出,所以答案会变成非常大的整数):

真实实测 · 本机执行输出
n | 暴力解 | 本文解法
       100 |     0.005 ms | 0.362 ms
       300 |     0.019 ms | 5.032 ms
      1000 |     0.115 ms | 275.123 ms
      2000 |     0.432 ms | 4049.855 ms

在 Python 里,直接用公式(基准)比动态规划快得多:n = 2000 时 0.432ms 对 4049.9ms。原因有两个:公式是 C 实现的一次大整数运算;动态规划的 O(n²) 次乘加,在 n 大时每次都是对上千位大整数的运算,所以耗时比 n² 增长得更快(n 从 300 到 1000,耗时增长约 55 倍,n² 只有 11 倍)。这不影响本题的正确性,题目限制 n ≤ 19,动态规划远远够用;这里只是说明大整数的代价。

随机对拍

Python 版本另外和卡特兰数公式对 n 从 1 到 19 做了对拍:

真实实测 · 本机执行输出
随机对拍 19 组(n 取 1 到 19(超过 19 时答案会超出 32 位整数)),solve 与 brute 的结果全部一致。

n 只有 19 种取值,随机取值几乎覆盖全部,固定用例已经包含 n = 19 这个最大值。另外四种语言只在上面 12 组固定用例上验证过。

分步演示:n = 5

下面的演示用 n = 5,答案 42。每一步展示 dp[k] 是怎么由更小的 dp 相乘再相加得到的:dp[3] = dp[0]×dp[2] + dp[1]×dp[1] + dp[2]×dp[0] = 2 + 1 + 2 = 5(根依次取第 1、2、3 小的数)。每一帧都是把上面的解法在这组输入上真实执行记录下来的,运行器还检查了演示的最终结果与 solve 的返回值一致。

不同的二叉搜索树:n = 5(只数个数)
正在计算的 dp[k]已算好

    参考与说明

    • 官方题目页:LeetCode 96. 不同的二叉搜索树。本文只用自己的话概括了题意,完整约束以官方页面为准。
    • 数据来自本机真实运行,五种语言的版本写在页脚;耗时对比只用 Python 测量;溢出实验用的是本机的 Java。
    • 没有覆盖:卡特兰数的组合数学证明;在其他语言里用公式计算时如何避免溢出。
    京ICP备2026067502号-1京公网安备11010502063487号