遞迴問題的時間和空間複雜度分析
遞迴的時間/空間複雜度
在解決問題的過程中,遞迴的正確使用總是能產生 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,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,r):
pivot = patition(a,r) # Time: O(r - l)
quick_sort(a,p)
quick_sort(a,p + 1,r)
複製程式碼
由於快速排序的效能依賴於樞紐元pivot
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,r):
m = (l + r) / 2
merge_sort(a,m)
merge_sort(a,r)
merge(a,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,可以嘗試用主方法或者遞迴樹的方式來進行推導。