写在前面
- 算法的实现(代码)以及算法的正确性(和所需的理论知识)已经在括号生成(理论及实现篇)写到,如果你没有看过请先大略浏览一遍,以防直接阅读本篇造成的无上下文的体验。
- LeetCode官方题解对这两种算法的分析不仅有些简略而且不太严谨。
现在以上一篇为基础,对闭合数
和回溯法
做算法复杂度分析。
C
n
=
C
0
C
n
−
1
+
C
1
C
n
−
2
+
⋯
+
C
n
−
1
C
0
,
n
≥
1
(**)
C_n=C_0C_{n-1}+C_1C_{n-2}+\cdots+C_{n-1}C_0,\quad n\ge 1\tag{**}
Cn=C0Cn−1+C1Cn−2+⋯+Cn−1C0,n≥1(**)
算法复杂度
- 闭合数(纯递归版)
//部分代码
for (int c = 0; c < n; ++c)
for (String left: generateParenthesis(c))
for (String right: generateParenthesis(n-1-c))
ans.add("(" + left + ")" + right);
这一段循环实际上是翻译了一遍Catalan数的递推式
(
∗
∗
)
(**)
(∗∗),我们自然可以得到
T
g
p
R
e
c
(
n
)
=
T
g
p
R
e
c
(
0
)
T
g
p
R
e
c
(
n
−
1
)
+
T
g
p
R
e
c
(
1
)
T
g
p
R
e
c
(
n
−
2
)
+
⋅
⋅
=
⋅
⋅
+
T
g
p
R
e
c
(
n
−
1
)
T
g
p
R
e
c
(
0
)
=
∑
k
=
0
n
−
1
T
g
p
R
e
c
(
k
)
T
g
p
R
e
c
(
n
−
k
−
1
)
\begin{aligned}T_{gpRec}(n) &=T_{gpRec}(0)T_{gpRec}(n-1)+T_{gpRec}(1)T_{gpRec}(n-2)+\cdot\cdot\\ &\phantom{=}\cdot\cdot+T_{gpRec}(n-1)T_{gpRec}(0) \\ &=\sum_{k=0}^{n-1}T_{gpRec}(k)T_{gpRec}(n-k-1)\end{aligned}
TgpRec(n)=TgpRec(0)TgpRec(n−1)+TgpRec(1)TgpRec(n−2)+⋅⋅=⋅⋅+TgpRec(n−1)TgpRec(0)=k=0∑n−1TgpRec(k)TgpRec(n−k−1)
理论篇中说明了使用母函数法可以算出
T
g
p
R
e
c
(
n
)
T_{gpRec}(n)
TgpRec(n)的表达式(具体方法已经超出本文讨论范畴,不在此说明,可以自行查阅资料如《组合数学》),利用结论我们有
T
g
p
R
e
c
(
n
)
=
O
(
1
n
+
1
(
2
n
n
)
)
T_{gpRec}(n)=O(\frac 1 {n+1}\binom{2n}{n})
TgpRec(n)=O(n+11(n2n))
使用Striling公式
n
!
∼
2
π
n
(
n
e
)
n
n! \sim\sqrt{2\pi n}(\frac n e)^n
n!∼2πn(en)n,对
(
2
n
n
)
\binom{2n}{n}
(n2n)作无穷量近似
(
2
n
n
)
=
(
2
n
)
!
n
!
n
!
∼
4
π
n
⋅
4
n
⋅
(
n
e
)
2
n
2
π
n
⋅
(
n
e
)
2
n
=
4
n
π
n
→
4
n
n
\begin{aligned}\binom{2n}{n} =\frac{(2n)!}{n!n!}&\sim\frac{\sqrt{4\pi n}\cdot4^n\cdot(\frac n e)^{2n}}{2\pi n\cdot(\frac n e)^{2n}}\\ &=\frac{4^n}{\sqrt{\pi n}}\rightarrow \frac{4^n}{\sqrt{ n}}\end{aligned}
(n2n)=n!n!(2n)!∼2πn⋅(en)2n4πn⋅4n⋅(en)2n=πn4n→n4n
所以可得
T
g
p
R
e
c
(
n
)
=
O
(
1
n
+
1
⋅
4
n
n
)
=
O
(
4
n
n
n
)
T_{gpRec}(n)=O(\frac 1 {n+1}\cdot\frac{4^n}{\sqrt{ n}})=O(\frac{4^n}{n\sqrt{ n}})
TgpRec(n)=O(n+11⋅n4n)=O(nn4n)
-
闭合数(动态规划版)
递推式和1
中的递归版完全一样
T g p D P ( n ) = ∑ k = 0 n − 1 T g p D P ( k ) T g p D P ( n − k − 1 ) T_{gpDP}(n) =\sum_{k=0}^{n-1}T_{gpDP}(k)T_{gpDP}(n-k-1) TgpDP(n)=k=0∑n−1TgpDP(k)TgpDP(n−k−1)由于我们对子问题的结果进行了保存处理,所以时间复杂度
不仅
由递推式决定还要考虑己保存的子问题结果,所以我们最终需要分析的是 g p D P gpDP gpDP算法对应的DAG(Directed Acyclic Graph, 有向无环图)
中节点的个数。以 n = 3 n=3 n=3为例
对于 g p R e c gpRec gpRec,函数调用关系图如下(部分省略)
我们省去一些相同的调用,将上图变为DAG
这也就是 g p D P gpDP gpDP所做的,节点个数为4,只需计算4次就能得到答案,于是
T g p D P ( n ) = O ( n + 1 ) = O ( n ) T_{gpDP}(n)=O(n+1)=O(n) TgpDP(n)=O(n+1)=O(n) -
回溯法
这一算法用递推关系难以表示(确切的说这就是一个串行算法, 并不是任意一层递归都能进入添加)
和(
这两个入口,大多时候只有一个入口),因为问题的规模不仅仅与 n n n 有关,还与当前开闭括号数有关,所以不能精确地表示 T ( n ) T(n) T(n)。
但如果简单的认为复杂度与结果的个数相近既不严谨也不正确,所以我们换一个角度,对函数调用次数进行分析(这样分析的理由使考虑到该算法过于“串行”)
基于实际消耗的时间,让我们觉得更加需要重新分析- 回溯法
- 闭合数
I . I. I. 纯递归
I I . II. II. 动态规划
- 回溯法
基于函数调用次数的复杂度分析(这种观点存在争议)
规定:
- 适用于复杂度受递归层数影响较大的算法
- 函数调用次数用 N ( n ) N(n) N(n)表示, n n n 表示输入的规模,意思是要得到参数为n时的结果,还需进行 N ( n ) N(n) N(n)次函数调用
- 根据定义,我们不考虑第一次调用,即 N ( 0 ) = 0 N(0)=0 N(0)=0
- 可以通过分析递归深度,与每层调用的大致情况进行定性分析
-
闭合数(纯递归)
观察递推式 ( ∗ ∗ ) (**) (∗∗)
C n = C 0 C n − 1 + C 1 C n − 2 + ⋯ + C n − 1 C 0 C_n=C_0C_{n-1}+C_1C_{n-2}+\cdots+C_{n-1}C_0 Cn=C0Cn−1+C1Cn−2+⋯+Cn−1C0我们发现, C 0 , C 1 , ⋯ , C n − 1 C_0,C_1,\cdots,C_{n-1} C0,C1,⋯,Cn−1每个出现两次,反映在函数调用上,可表述为——参数为 n n n的调用,会进行两次参数为 i ( ∈ [ 0 , n − 1 ] ) i(\in[0,n-1]) i(∈[0,n−1])的调用,每个调用要得到相应结果还需进行 N ( i ) N(i) N(i)次调用,依次类推…
于是可以写出递推式
N ( n ) = 2 ( N ( n − 1 ) + ⋯ + N ( 1 ) + N ( 0 ) ) + 2 n N(n)=2(N(n-1)+\cdots+N(1)+N(0))+2n N(n)=2(N(n−1)+⋯+N(1)+N(0))+2n简单的变形后可以计算出
N ( n ) = 3 n − 1 = O ( 3 n ) N(n)=3^{n}-1=O(3^n) N(n)=3n−1=O(3n) -
回溯法
可以先观察一些 n n n较小的 N ( n ) N(n) N(n)的值
i | 1 | 2 | 3 | 4 | |
N(i) | 回溯法 | 2 | 7 | 21 | 63 |
闭合数 | 2 | 8 | 26 | 80 |
发现回溯法的 N ( n ) N(n) N(n)值的确比闭合数的小,且趋势逐渐增大