本文最早发自微信公众号【有趣理工男】,朋友们可以关注一波。
题目
Given an integer n
, return the number of structurally unique BST’s (binary search trees) which has exactly n
nodes of unique values from 1 to n.
示例1
输入:n = 3
输出:5
示例 2:
输入:n = 1
输出:1
思路
解决这道题我们先从基本的 Top-down (递归)写法入手,之后改写成 Bottom-up 写法。之所以这样,是因为 Top-down 的写法更加容易理解。
首先,从题目开始分析。给定一个数字 n
,求从节点值 1
到 n
互不相同的二叉搜索树有多少个。显然,我们可以将这个大问题拆分为小问题求解。即求根节点值为 k
的二叉搜索树有多少个,再把 k=1
,k=2
,… ,k=n
相加,即可求出答案。
如上面示例1,我们要求 n=3
,只需要分别求当根节点为 1
, 2
, 3
时的二叉搜索树有多少个,再将它们相加,就可以得到答案。
上面我们拆分了问题,得到了子问题。现在我们对子问题求解。
设给定整数 n
,拆分为 n
个子问题求解。
我们定义一个函数 G[n]
来表示长度为 n
的序列能构成的不同BST的个数。
每一个子问题根节点值为 k
,此时左子树的节点个数为 k-1
,而右子树节点个数为 n-k
。
再定义一个函数 numTrees(k, n)
来表示根节点为 k
、序列长度为 n
的BST的个数。(注意这个函数与前面的 G[n] 不一样,这个是子问题的求解函数)
对于上图,当 k=1
时(根节点为1),左子树可以组成的BST的个数为 G[0]
,右子树可以组成的BST的个数为 G[4]
。
1️⃣我们有:
G [ 5 ] = n u m T r e e s ( 1 , 5 ) + n u m T r e e s ( 2 , 5 ) + n u m T r e e s ( 3 , 5 ) + n u m T r e e s ( 4 , 5 ) + n u m T r e e s ( 5 , 5 ) G[5] = numTrees(1, 5) + numTrees(2, 5) + numTrees(3, 5) + numTrees(4, 5) + numTrees(5, 5) G[5]=numTrees(1,5)+numTrees(2,5)+numTrees(3,5)+numTrees(4,5)+numTrees(5,5)
2️⃣进一步我们有:
n
u
m
T
r
e
e
s
(
1
,
5
)
=
G
[
0
]
∗
G
[
4
]
n
u
m
T
r
e
e
s
(
2
,
5
)
=
G
[
1
]
∗
G
[
3
]
n
u
m
T
r
e
e
s
(
3
,
5
)
=
G
[
2
]
∗
G
[
2
]
n
u
m
T
r
e
e
s
(
4
,
5
)
=
G
[
3
]
∗
G
[
1
]
n
u
m
T
r
e
e
s
(
5
,
5
)
=
G
[
4
]
∗
G
[
0
]
numTrees(1, 5) = G[0] * G[4]\\ numTrees(2, 5) = G[1] * G[3]\\ numTrees(3, 5) = G[2] * G[2]\\ numTrees(4, 5) = G[3] * G[1]\\ numTrees(5, 5) = G[4] * G[0]
numTrees(1,5)=G[0]∗G[4]numTrees(2,5)=G[1]∗G[3]numTrees(3,5)=G[2]∗G[2]numTrees(4,5)=G[3]∗G[1]numTrees(5,5)=G[4]∗G[0]
对于1️⃣,整理为一般形式:
G
(
n
)
=
∑
k
=
1
n
n
u
m
T
r
e
e
s
(
k
,
n
)
G(n) = \sum_{k=1}^{n}numTrees(k, n)
G(n)=k=1∑nnumTrees(k,n)
对于2️⃣,有人可能会有疑惑:为什么是相乘?
这里我借LeetCode官方题解中的一副图:
相当于左子树有 G(i-1)
种组合,右子树有 G(n-i)
种组合。要求解整体能有多少种组合,则用熟悉来排列组合来做,直接把左子树的组合数量乘以右子树的组合数量,即可求解。
我们则可以求出 2️⃣ 的一般形式:
n
u
m
T
r
e
e
s
(
k
,
n
)
=
G
(
k
−
1
)
∗
G
(
n
−
k
)
numTrees(k, n) = G(k-1)*G(n-k)
numTrees(k,n)=G(k−1)∗G(n−k)
注意:k
是根节点的值。
把上述的两个公式写在一起:
G
(
n
)
=
∑
k
=
1
n
n
u
m
T
r
e
e
s
(
k
,
n
)
n
u
m
T
r
e
e
s
(
k
,
n
)
=
G
(
k
−
1
)
∗
G
(
n
−
k
)
G(n) = \sum_{k=1}^{n}numTrees(k, n)\\ numTrees(k, n) = G(k-1)*G(n-k)
G(n)=k=1∑nnumTrees(k,n)numTrees(k,n)=G(k−1)∗G(n−k)
结合成一个式子得:
G
(
n
)
=
∑
k
=
1
n
G
(
k
−
1
)
∗
G
(
n
−
k
)
G(n)=\sum_{k=1}^{n}G(k-1)*G(n-k)
G(n)=k=1∑nG(k−1)∗G(n−k)
最后,我们确定一下终止条件(Base case)。
即当 n=0
或 n=1
时,有: G(0) = 1 和 G(1) = 1 。其意义为:空树的BST个数为1。只有一个节点组成的BST个数为1 。
Top-Down Solution
根据前面的分析,从递归式很容易写出递归的代码。
def numTrees(n):
if n == 0 or n == 1:
return 1
res = 0
for k in range(1, n+1):
res += numTrees(k-1) * numTrees(n-k)
return res
但是,上面的代码放在 LeetCode 会超时。怎么办呢?首先,我们找出影响代码速度的地方,很明显是因为 numTrees()
多次的计算,那么有没有一种方法可以将之前计算出函数的结果存下来呢?有的。我们可以用下述两种方法来优化:
- 字典
- lru_cache
字典优化
def numTrees(n):
cache = dict()
def helper(n):
if n in cache:
return cache[n]
if n == 0 or n == 1:
return 1
res = 0
for k in range(1, n+1):
res += helper(k-1) * helper(n-k)
cache[n] = res
return res
return helper(n)
lru_cache 缓存优化
from functools import lru_cache
@lru_cache()
def numTrees(n):
if n == 0 or n == 1:
return 1
res = 0
for k in range(1, n+1):
res += numTrees(k-1) * numTrees(n-k)
return res
Bottom-Up Solution
def numTrees(n):
G = [0] * (n+1)
G[0] = 1
G[1] = 1
for i in range(2, n+1):
for k in range(1, n+1):
G[i] += G[k-1] * G[i-k]
return G[n]
最后这是 Bottom-Up的写法,其中 G
是一个列表。例如,G[2]
存了节点数量为 2 能构成不同BST的个数;G[n]
存了节点数量为 n 能构成不同BST的个数。由于 G[0]
和 G[1]
为 Base case,所以外层循环 i
取值从 2 开始,直到 n 。每层内层循环取值 k
相当于把 k
作为二叉树的根节点,显然 k
的取值是从 1 到 n 的。
注意,我们每次内层循环是用 +=
,理由前面有讲到,此处不再赘述。
G[k-1]
中 k-1
是左子树的个数,根据下图也很好理解。但是为什么 G[i-k]
中是 i-k
而不是 n-i
呢?
理解很简单,假设 n=3
,因为我们现在是动态规划,动态地求 G[2]
,G[3]
。使用 G[n-k]
的话,每次内层循环都是 G[3-k]
了,这样是错误的。正确使用 G[i-k]
每次内层循环乘号右边应该是 G[2-k]
,G[3-k]
。
在双层循环之后,切记要返回 G[n]
。
附上提交结果:
总结
正所谓,动态规划,层层相扣。将大问题分解为子问题对分析问题很有帮助,而从子问题找出解决方法是本题核心。想不到题目解的时候,不妨将几种情况写出,寻找它们规律。
本文最早发自微信公众号【有趣理工男】