递归的时间/空间复杂度
在解决问题的过程中,递归的正确使用总是能产生 subtle code, 但追踪实际的递归调用序列通常是非常困难的,但当我们了解递归的设计法则后,我们知道,我们一般没有必要知道这些细节,这正体现了使用递归的好处,因为计算机能计算出复杂的细节。
递归的基本法则
- 基准情形。 无须递归就能解决的case。
- 不断推进。 确保每一次递归调用都将问题规模缩小,向基准情形推进。
- 设计法则。 假设所有的递归调用都能运行。
- 合成效益法则。 切勿在不同的递归调用中做重复性的工作。该条法则可以引出记忆化递归。
几个常见的递归算法
树的遍历
void printInorder(TreeNode root){
if(root == null) return;
printInorder(root.left);
System.out.println(root.val);
printInorder(root.right);
}
该算法是一个很简单的递归算法,也是解决树的相关问题的一个常见pattern。
很显然,它处理了基本情形,并且不断向基本情形,空结点,推进。每个节点只访问一次,递归深度为树的高度, 因此:
Time: T(n) = 2 * T(n / 2) + O(1) --> T(n) = O(n)
Space: O(logn) --> O(h) h--> the height of the tree
二分查找
def binary_search(a, l, r):
m = (l + r) / 2
if(f(m)):
binary_search(a, l, m)
else:
binary_search(a, m + 1, r)
Time: T(n) = T(n / 2) + O(1) --> T(n) = O(logn)
Space: O(logn)
快速排序
def qucik_sort(a, l, r):
pivot = patition(a, l, r) # Time: O(r - l)
quick_sort(a, l, p)
quick_sort(a, p + 1, r)
由于快速排序的性能依赖于枢纽元pivot的选取,因此就存在最坏的情形最好的情形。
Best case:
T(n) = 2 * T(n / 2) + O(n)
根据主方法(master method),T(n) = O(nlogn)
Worst case:
T(n) = T(n - 1) + T(1) + O(n) --> T(n) = O(n ^ 2)
Space: O(logn) --> O(n)
归并排序
def merge_sort(a, l, r):
m = (l + r) / 2
merge_sort(a, l, m)
merge_sort(a, m + 1, r)
merge(a, l, m, r) # O(r - l)
和快速排序类似, 但它没有所谓的最好和最坏情形,因为它总是将问题的规模缩小一半。
但因为归并需要对数组进行拷贝操作,快排对系统的利用更高,并且worst case 很少出现,快排的使用更加的广泛。
Time: T(n) = 2 \* T(n / 2) + O(n) --> T(n) = O(nlogn)
Space: O(logn + n) --> 递归深度O(logn), 拷贝数组 O(n)
Combination
def conbination(d, s):
if(d == n):
return func() #O(1)
for i in range(d + 1, n):
combination(d + 1, i + 1)
Time: T(n) = T(n - 1) + T(n - 2) + ... + T(1) --> O(2^n)
Space: O(n)
Permutation
def permutation(d, used):
if(d == n):
return func() #O(1)
for i in range(0, n):
if i in used: continue
used.add(i)
permutation(d + 1, used)
used.remove(i)
Time: T(n) = n * T(n - 1) --> O(n!)
Space: O(n)
总结表格
| Equation | Time | Space | Examples |
|---|---|---|---|
T(n) = 2 * T(n / 2) + O(n) |
O(nlogn) | O(logn) | qucik_sort |
T(n) = 2 * T(n / 2) + O(n) |
O(nlogn) | O(logn + n) | merge_sort |
T(n) = T(n / 2) + O(1) |
O(logn) | O(logn) | binary_search |
T(n) = 2 * T(n / 2) + O(1) |
O(nlogn) | O(logn) | binary tree |
T(n) = T(n - 1) + O(1) |
O(n^2) | O(n) | quick_sort (worst case) |
T(n) = n * T(n - 1) |
O(n!) | O(n) | permutation |
T(n) = T(n - 1) + T(n - 2) + ... + T(1) |
O(2^n) | O(n) | combination |
记忆化递归/Memorization Recursion
根据上述的递归基本法则第四条,合成效益法则,我们再来看看这个斐波那契数列的问题。
def fib(n):
if n < 3 : return 1
return fib(n - 1) + fib(n - 2)
Time: T(n) = T(n - 1) + T(n - 2) + ... + T(1) = O(2^n) = O(1.618^n)
它实际上重复求解了许多的子问题,那么其实可以设置一个记忆体来保存已经求结果的子问题的解。
def fib(n):
if(n < 3): return 1
if memo[n] > 0: return memo[n]
memo[n] = fib(n - 1) + fib(n - 2)
return memo[n]
其中记忆体memo可以存储在全局变量, 也可以当作函数的参数传递。对记忆化递归的时间空间复杂度分析,通常只需要看它包含有多少个子问题。空间也和记忆体的大小成正比。
Time: O(n)
Space: O(n)
对于更加复杂的case,可以尝试用主方法或者递归树的方式来进行推导。