LeetCode 96:不同的二叉搜索树——动态规划与卡特兰数全解析
1. 一眼看穿题目本质是计数问题不是构造问题1.1 题目到底在问什么很多朋友第一次看到96. 不同的二叉搜索树这个标题时第一反应是去枚举各种树形结构结果越枚举越乱。其实这道题问的并不是怎么把二叉搜索树构造出来而是给定 1 到 n 这 n 个节点能构成多少种结构互不相同的二叉搜索树。关键在于结构不同这四个字。二叉搜索树的性质是左子树的所有节点值都小于根右子树的所有节点值都大于根。当节点值固定为 1 到 n并且要求中序遍历结果是升序时一棵二叉搜索树的形态其实完全由它的左右子树的形态组合决定。也就是说我们数的不是节点的排列方案而是树的拓扑结构方案。这个问题如果靠暴力枚举复杂度是指数级别的。n 3 时还能手画n 4 时画出 14 种已经有点吃力n 5 时是 42 种等你画到 n 10 就要面对 16796 种结构了。所以这道题真正的考察点是你能不能从枚举树形的惯性思维里跳出来看到它背后的递推结构。1.2 关键突破口枚举根节点的位置我把这个问题的突破口概括成一句话不要想整棵树只想根节点。假设现在有 1 到 n 共 n 个节点我们任选其中一个值 k 作为根节点。根据二叉搜索树的定义左子树里只能放比 k 小的节点也就是 1 到 k-1共 k-1 个节点右子树里只能放比 k 大的节点也就是 k1 到 n共 n-k 个节点。这一步看起来平平无奇但它带来一个非常强的结论左子树能构成的形态数量只取决于左子树的节点个数也就是 k-1而不取决于这些节点的具体值。同理右子树能构成的形态数量只取决于 n-k。你可能会有疑问左子树的值是 1 到 k-1右子树的值是 k1 到 n两组值不一样为什么形态数量只和个数有关答案是二叉搜索树的形态只依赖于节点的相对大小关系不依赖于绝对数值。1、2、3 三个节点能构成的 5 种 BST 形态和 5、6、7 三个节点能构成的形态是一模一样的。理解这一点是整个动态规划推导的基础。1.3 为什么不能直接套排列组合公式有人说既然左子树、右子树都是子问题那我是不是可以直接用组合数 C(n, 2) 之类的方法来算不行。这里面的误区在于树的高度和节点分布并不是均匀的不存在简单的从 n 个位置里选两个子树边界这种均匀结构。二叉搜索树的每一种形态本质上都是从根节点开始递归地把节点划分到左右子树的结果。这个递归划分的次数、每次划分的子树规模都不一样所以它天生适合用递推或者递归来描述而不是直接的组合公式。卡特兰数公式确实存在但它也是从递推关系推导出来的而不是凭空套上去的。想通这一点整个问题的难度就下降了一大半。2. 动态规划推导自底向上把树数出来2.1 dp[i] 的定义与初始化我们把问题抽象成dp[i] 表示由 i 个节点能构成的不同二叉搜索树的形态数量。这里有一个非常容易忽略但极其重要的初始化dp[0] 1。为什么 0 个节点也算一种形态因为空树是一棵合法的树。更关键的是当根节点的左子树为空即 k 1或者右子树为空即 k n时空子树必须被视作一种形态否则乘法原理无法成立。我们可以用一个生活化的类比来理解你给一个书架分层左边放几本、右边放几本。如果左边一本都不放这本身也是一种合理的摆放方案不能因为书架上没有书就认为这层不存在。dp[0] 1 就是这个意思。dp[1] 呢只有一个节点的树左子树为空、右子树为空形态只有一种所以 dp[1] 1。这两个初始值一个代表空一个代表单节点是后面所有递推的基础。2.2 状态转移方程的几何意义当我们要计算 dp[n] 时可以从 1 到 n 逐个枚举根节点的值 k。对于每一个 k左子树有 k-1 个节点能构成 dp[k-1] 种形态右子树有 n-k 个节点能构成 dp[n-k] 种形态左右子树各自的形态互相独立所以组合起来是 dp[k-1] * dp[n-k] 种。把所有 k 的情况加起来就得到dp[n] sum(dp[k-1] * dp[n-k])其中 k 从 1 到 n如果令 j k-1那么 n-k n-j-1上式可以等价写成dp[n] sum(dp[j] * dp[n-j-1])其中 j 从 0 到 n-1这两个写法本质一样后面那个在代码里更常用。我建议你记住后面这种因为 j 直接代表左子树节点数边界特别好处理。这里为什么可以用乘法因为左子树的每一种形态都可以和右子树的任意一种形态配合形成一棵新的完整的树而且这些配合不会产生重复。为什么不会重复因为根节点的值 k 被明确枚举了不同的 k 会产生不同的根节点所以整棵树一定是不同的。这个按根节点分类的思路保证了不重不漏。2.3 手算 dp[0] 到 dp[4] 的完整过程光看公式容易觉得抽象我把前几项展开算一遍你感受一下递推的节奏。已知dp[0] 1 dp[1] 1计算 dp[2]dp[2] dp[0]*dp[1] dp[1]*dp[0] 1*1 1*1 2这两项分别对应根节点为 1 和 2 的情况结果是 2。手动验证一下两个节点的 BST 只有根 1、右孩子 2和根 2、左孩子 1两种形态正确。计算 dp[3]dp[3] dp[0]*dp[2] dp[1]*dp[1] dp[2]*dp[0] 1*2 1*1 2*1 5当根为 1右子树 2 个节点有 2 种形态根为 2左右各 1 个节点各只有 1 种形态组合出来 1 种根为 3左子树 2 个节点有 2 种形态。合计 5 种。计算 dp[4]dp[4] dp[0]*dp[3] dp[1]*dp[2] dp[2]*dp[1] dp[3]*dp[0] 1*5 1*2 2*1 5*1 14到这里你应该能清楚看到趋势1, 1, 2, 5, 14——这就是卡特兰数的前几项。到这一步你已经不是在画树了而是在用递推直接数树。2.4 参考实现先看最直觉的二维版本虽然本题用不到二维 DP但理解这个版本有助于把握思路# 二维 DP 版本先枚举区间长度再枚举左子树大小 def num_trees(n): dp [0] * (n 1) dp[0] 1 dp[1] 1 for i in range(2, n 1): for j in range(i): dp[i] dp[j] * dp[i - j - 1] return dp[n]内层循环里的j就是左子树节点数i - j - 1是右子树节点数。这个实现的时间复杂度是 O(n²)空间复杂度是 O(n)。如果你用 C需要注意dp数组的类型n 到 19 左右答案就会超过 int 范围后面详细说long long numTrees(int n) { vectorlong long dp(n 1, 0); dp[0] 1; dp[1] 1; for (int i 2; i n; i) { for (int j 0; j i; j) { dp[i] dp[j] * dp[i - j - 1]; } } return dp[n]; }Java 里同样可以用long[]这个解法已经足够通过本题。3. 从 DP 到卡特兰数一个数学公式的跃迁3.1 卡特兰数到底是什么如果只是解这一道题上面的动态规划已经足够了。但很多刷题的朋友会在题解区看到卡特兰数四个字然后陷入疑惑这个数列到底是怎么从二叉搜索树里冒出来的卡特兰数是一个非常经典的组合数学数列它描述了一大类递归拆分结构的总数。除了二叉搜索树以下这些问题的答案也都是卡特兰数n 对括号的合法排列数1 到 n 的入栈顺序对应的合法出栈序列数n 个节点的二叉树不要求二叉搜索树总形态数在 n x n 的网格中从 (0,0) 走到 (n,n)不越过对角线的路径数。为什么这么多不同的问题会共享同一个答案序列因为它们骨子里的结构是等价的。以括号序列为例一对括号把序列分成内部和外部两部分这种嵌套-并列的递归结构和二叉搜索树里根节点把序列分成左右子树的结构完全一致。你在二叉搜索树里数的是形态在括号问题里数的是序列本质上是同一个递归拆分的计数。理解这一层你就能做到会一道题通一类题。3.2 从递推公式推导出通项公式卡特兰数最常见的递推形式是C(0) 1 C(n) sum(C(i) * C(n-1-i))i 从 0 到 n-1这和我们前面写的dp[n] sum(dp[j] * dp[n-j-1])一字不差。所以二叉搜索树问题的答案就是第 n 个卡特兰数。卡特兰数的通项公式是C(n) C(2n, n) / (n 1)这个公式也叫组合数封闭形式。怎么理解它的来历一个经典的证明思路是从括号序列或走网格路径的角度利用反射原理算出非法情况的数量然后用总数减去非法数。结合二叉搜索树来证明通项公式比较绕一个是 dp 递推一个是组合计数两者殊途同归但考场上更实际的用法是直接用通项公式来 O(n) 计算答案。为什么通项公式能成立我给你一个直觉上的解释在 2n 个位置里选 n 个作为某种标记总共有 C(2n, n) 种情况但其中有一部分是不合法的因为过程中某一刻会打破平衡。反射原理把这部分非法情况一一对应到 C(2n, n-1) 种情况所以合法的数量是 C(2n, n) - C(2n, n-1)化简后正好等于 C(2n, n) / (n1)。这个证明在括号序列模型里最容易理解建议你从那里入手比直接从二叉搜索树硬证要轻松得多。3.3 O(n) 计算组合数与连乘化简如果直接在代码里算组合数 C(2n, n)可以先用公式展开C(2n, n) (2n)! / (n! * n!)但阶乘在 n 稍大时会爆炸必须边乘边除。更稳妥的写法是用循环累乘def num_trees(n): result 1 for i in range(n): result result * (2 * n - i) // (i 1) # 现在 result C(2n, n) return result // (n 1)这里有一个特别容易踩的坑result result * (2*n - i) // (i 1)不能把除法提前到乘法之前否则会丢失精度。因为每一步的乘法结果一定能够被i 1整除这是组合数 C(2n, i1) 的递推性质保证的。只要保证先乘后除整数除法就不会产生小数误差。还有另一种写法直接从卡特兰数相邻两项的关系出发C(n) C(n-1) * (4*n - 2) / (n 1)这个递推式也很常用计算时同样注意先乘后除def num_trees(n): c 1 for i in range(1, n 1): c c * (4 * i - 2) // (i 1) return c我个人在刷题时会优先用组合数循环版本因为它在很多其他组合问题里也能复用写起来不容易错。3.4 DP 方案与通项方案的复杂度对比一张表看明白两种方案的取舍方案时间复杂度空间复杂度代码量适用场景动态规划O(n²)O(n)约 10 行任何 n思路直观不易错卡特兰通项O(n)O(1)约 8 行大 n追求效率时使用从 LeetCode 这道题的约束看n 最大是 19O(n²) 的 DP 完全够用。但如果你在其他地方遇到 n 高达几千的变体题就必须用 O(n) 的通项公式了。另外通项公式里涉及long long的乘法需要注意溢出后面我会专门讲。4. 刷题过程中的常见坑与实用经验4.1 类型溢出C(34,17) 就已经超过 int 范围这道题 n 虽然只有 19但中间结果很容易超 int。算一下第 19 个卡特兰数是多少C(19) 1767263190这个数刚好在 int 正数范围内但你再算下一个C(20) 6564120420已经超过 2^31 - 1 2147483647 了。如果你用int类型存 dp 数组在 n 19 时答案本身不会爆但在中间计算dp[j] * dp[i-j-1]时两个 int 相乘可能先溢出再赋值给 int结果就会错。所以稳妥起见dp 数组统一用long long即 64 位整数。Python 的用户没有这个烦恼因为它的整数是无限精度的但 C/Java 的用户必须注意。如果题目把 n 范围改大到 30 以上连long long都可能不够这时需要考虑取模输出或者用 Python 这类大整数语言。刷题时先看一眼 n 的范围再决定类型是基本素养。4.2 dp[0] 1 的地位比想象中更重要我自己第一次做这道题时把 dp[0] 初始化为 0结果整个 dp[2] 都算成了 0想了半天没搞明白。dp[0] 1 不是凭空多出一种树而是在表达空子树是一种合法的子树形态。你可以从乘法原理的角度再去体会当根节点是 1 时左子树为空。此时右子树有 n-1 个节点形态数是 dp[n-1]。如果 dp[0] 0那么根节点为 1 的所有情况都会被清零显然不对。所以这个初始值不是可选的它是递推成立的前提。4.3 记忆化 DFS另一种不过脑子的写法很多人在面试时紧张动态规划的表推着推着就乱了。我建议你掌握一种保底方案——记忆化搜索。它的核心是递归定义和递推公式完全一致只是用一个数组把已经算过的子问题缓存起来。def num_trees(n): memo {0: 1, 1: 1} def dfs(k): if k in memo: return memo[k] total 0 for i in range(k): total dfs(i) * dfs(k - i - 1) memo[k] total return total return dfs(n)这个写法的好处是不用明确推导出从 2 到 n 挨个填表的循环顺序只要递归关系正确、有记忆化复杂度同样是 O(n²)。缺点是递归深度可能达到 n如果 n 很大比如上万会爆栈但在本题范围内完全没问题。面试时如果一下子推不出迭代写法先写记忆化递归拿到分再优化成迭代也是实战中合理的策略。4.4 从这一题出发还能延伸出什么我做完这道题之后习惯性地看了一下相关变体如果要求输出所有 BST 的具体结构而不是只数数量就是95. 不同的二叉搜索树 II那时才真正需要递归构造树且不能用卡特兰数偷懒如果给定一个具体的 BST要求验证它是不是合法的二叉搜索树那就是中序遍历或递归区间约束的问题如果把节点总数固定改成树的深度不超过某个值或左右子树高度差受限计数递推就需要多一维状态。我特别建议你把95. 不同的二叉搜索树 II连着做掉。一道题考数有几个另一道题考具体长什么样两道题一起做你对递归拆分的理解会深很多。我自己当年是先做了 II 再回来做 I反而觉得 I 简单得不可思议因为递归构造的过程已经把枚举根节点、合并左右子树的思维焊死在脑子里了。还有一个实战小经验在面试中如果被问到这道题不要上来就背卡特兰数公式。面试官更想看到的是你从枚举根节点出发自然推导出 DP 递推式的过程。卡特兰数可以作为最后的一句总结但绝不是开场白。把递推关系讲清楚再提一句这个序列其实就是卡特兰数所以也可以用通项公式 O(n) 计算会让你的回答完整且有深度。

相关新闻

摩尔投票法:O(1)空间寻找多数元素的原理与实战

摩尔投票法:O(1)空间寻找多数元素的原理与实战

摩尔投票法(Boyer-Moore majority vote algorithm)是我在刷数组类题目时见过的最精巧的 O(1) 空间解法之一。它的任务很具体:在线性时间内,只用常数级额外空间,找出数组中出现次数超过 n/2 的多数元素。整段核心代码不…

2026/10/13 0:32:24 阅读更多 →
K8s集群监控:Metrics Server+Dashboard组合搭建可视化方案

K8s集群监控:Metrics Server+Dashboard组合搭建可视化方案

集群跑起来之后,第一件事不是欢天喜地部署业务,而是把Kubernetes监控补齐。在我这几年实操过的环境里,最顺手、最省心的一套基础监控组合,就是Metrics Server搭配Dashboard:一个负责把节点和Pod的CPU、内存数据及时采上…

2026/10/13 0:32:24 阅读更多 →
Ubuntu 18.04下MATLAB 2018b与CUDA 10.1兼容配置实战

Ubuntu 18.04下MATLAB 2018b与CUDA 10.1兼容配置实战

1. 项目背景与整体设计思路1.1 为什么这个组合值得单独拿出来讲Ubuntu 18.04、MATLAB 2018b、CUDA 10.1 这三个版本号摆在一起,做过深度学习或GPU加速计算的人应该会心一笑——这是一个典型的"版本锁死"场景。MATLAB 2018b 官方支持的CUDA版本上限是9.2&a…

2026/10/13 0:32:22 阅读更多 →

最新新闻

模型上下文协议MCP实战:从架构原理到工具集成落地

模型上下文协议MCP实战:从架构原理到工具集成落地

1. 从一个真实困境说起:为什么我们需要模型上下文协议如果你最近半年在关注大模型应用开发,大概率会反复听到一个词——MCP,也就是模型上下文协议(Model Context Protocol)。我第一次接触这个概念的时候,心…

2026/10/14 8:11:50 阅读更多 →
大智慧通达信交易插件TradeX-TPS:量化下单中间件原理与实战配置

大智慧通达信交易插件TradeX-TPS:量化下单中间件原理与实战配置

简介:这是一套面向股票量化交易与自动化下单场景的插件工具,适配大智慧、通达信两大行情软件,通过TradeX.dll或Trade.dll连接券商服务器,实现买入、卖出、撤单、按预设比例清仓,以及账户信息、持仓和可撤单信息的查询&…

2026/10/14 8:11:50 阅读更多 →
模型上下文协议MCP实战:从零搭建本地文件检索服务

模型上下文协议MCP实战:从零搭建本地文件检索服务

1. 模型上下文协议到底在解决什么问题第一次听到“模型上下文协议”这个词,很多人会以为它又是一个新的大模型训练框架,或者某种提示词模板规范。其实都不是。它要解决的是一个更底层、更工程化的问题:当大语言模型需要和外部世界打交道时&am…

2026/10/14 8:11:50 阅读更多 →
BepInEx 6.0 IL2CPP 插件框架:加载机制、配置排查与稳定性实践

BepInEx 6.0 IL2CPP 插件框架:加载机制、配置排查与稳定性实践

BepInEx 6.0 IL2CPP 插件框架:加载机制、配置排查与稳定性实践 【免费下载链接】BepInEx Unity / XNA game patcher and plugin framework 项目地址: https://gitcode.com/GitHub_Trending/be/BepInEx BepInEx 是一个面向 Unity 与 .NET 游戏的插件&#xff…

2026/10/14 8:11:50 阅读更多 →
context-mode:基于SQLite+FTS5+BM25的上下文调度工程实践

context-mode:基于SQLite+FTS5+BM25的上下文调度工程实践

1. “context-mode”到底是什么?别被术语唬住,它本质是让AI真正“听懂上下文”的工程化方案最近在多个技术社区和开源项目讨论里,“context-mode”这个词出现频率陡增,尤其和MCP、SQLite、FTS5、BM25这些词高频绑定。很多人第一反…

2026/10/14 8:11:50 阅读更多 →
AI辅助量化策略开发:从零搭建ETF轮动回测框架

AI辅助量化策略开发:从零搭建ETF轮动回测框架

1. 为什么说现在是个人做量化的最好时机我接触量化交易差不多有七八年了,最早那会儿,个人想做一套自己的策略,门槛高得离谱。你得自己搭数据库、自己写回测框架、自己处理除权除息、自己搞行情接口,光是数据清洗就能耗掉大半个月。…

2026/10/14 8:10:50 阅读更多 →

日新闻

AnyPS5通用适配层设计:兼容性与性能的工程取舍

AnyPS5通用适配层设计:兼容性与性能的工程取舍

1. 从"AnyPS5"这个名字说起:它到底想解决什么问题第一次看到"AnyPS5"这个标题,我脑子里冒出来的第一个念头是:这大概率是一个围绕"跨平台运行"或者"通用适配"做文章的项目。名字里的"Any"…

2026/10/14 0:00:41 阅读更多 →
Flutter跨平台开发实战:二手交易应用与HarmonyOS适配

Flutter跨平台开发实战:二手交易应用与HarmonyOS适配

1. 项目概述:Flutter跨平台二手交易应用开发作为一名长期从事移动应用开发的工程师,我最近完成了一个基于Flutter框架的二手物品交易平台项目。这个项目最吸引我的地方在于它完美结合了Flutter的跨平台优势与HarmonyOS的生态特性,实现了"…

2026/10/14 0:00:41 阅读更多 →
DFS算法解决飞机降落调度问题实战

DFS算法解决飞机降落调度问题实战

1. 项目背景与核心思路飞机降落问题是一个经典的调度优化场景,也是算法竞赛和面试中的高频题目。这个题目要求我们合理安排多架飞机的降落顺序和时间,确保所有飞机都能安全着陆而不发生冲突。在实际航空管制中,这类问题直接关系到飞行安全和机…

2026/10/14 0:00:41 阅读更多 →

周新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/14 3:30:28 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/13 16:22:04 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/14 3:29:58 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/13 22:01:27 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/13 14:57:39 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/14 7:53:34 阅读更多 →