關於遞歸函數轉換非遞歸函數的一些方式
前言
最近在重拾算法和數據結構的一些知識,打算從基本的樹的遍歷算法入手。網上翻看了很多的二叉樹的遍歷算法相關文章,二叉樹的遍歷有前、中、後三種遍歷方法。最簡單的用遞歸方法遍歷,三種方法的邏輯一目瞭然很好理解,看到非遞歸遍歷方法時,前序遍歷還能理解,中序和後序遍歷看的理解起來感覺不那麼順了,所以想先研究一下遞歸方法改非遞歸方法的一些方法,翻看了一些文章結合自己的理解記錄下對遞歸方法改成非遞歸方法的一些方法。
目的
既然是要將遞歸方法轉換成非遞歸方法,那首先就要明白爲什麼要將遞歸方法轉換成非遞歸方法,也就是遞歸轉非遞歸的目的和意義何在?如果這樣的轉換沒有任何實際意義那也就不存在轉換的必要了。下面收集了一些遞歸和非遞歸方法的一些優缺點,自己權衡:
- 遞歸函數邏輯清楚,以數學化的方式來寫函數,便於理解。非遞歸函數一般相對邏輯性和可理解性要差些。
- 大部分語言的編譯器對遞歸的層數有限制。非遞歸函數沒有這個限制。當然有時間和性能上的要求。
- 遞歸方式使用了系統的棧來存儲函數的參數和變量等,造成額外的更多的開銷。 非遞歸方式要分情況考慮系統開銷,後面例子測試會有比較。
可行性
既然清楚了遞歸函數轉換成非遞歸函數的目的,下面就要提出一個問題,那就是是否所有的遞歸函數都能轉換成非遞歸函數即轉換的可行性。這個答案是肯定的。一個顯然的原因是:我們計算機是如何運行遞歸函數的?學習過彙編語言的童鞋可以很自然的理解這個原因。彙編語言中對函數的調用通過call指令來執行, call指令通過將調用程序段執行的寄存器及代碼執行計數器入棧的方式來執行被調用函數,被調用函數執行完畢後通過return指令來出棧。所以原則上我們可以藉助棧這個結構用程序來模擬函數調用過程,也就是可以實現非遞歸轉換成遞歸的方法。
轉換的幾種途徑
遞歸轉換成非遞歸一般有以下幾種途徑:
- 可行性裏面介紹了藉助棧來實現轉換。
- 使用循環和數組方式來轉換。
這兩種方法效率不同,且循環數組方式不一定能解決所有的轉換問題,查閱網上的一些資料可以參考:
- 公衆號:Linux雲計算網絡的 : 漫談遞歸轉非遞歸.
- 奔跑de五花肉的: 遞歸算法轉換爲非遞歸算法的技巧.
轉換示例
第一個例子:階乘n!
由易入難首先選擇階乘,n的階乘計算方法: n! = n * (n-1) * (n-2) * … * 3 * 2 * 1 (n>0且n屬於自然數),它也可以是一個最簡單的遞歸函數,假設f(n)=n!,則f(n)=n * f(n-1),f(1) = 1,那麼寫成遞歸代碼如下(本文代碼均用Python描述):
# 遞歸求階乘 n>0
def recu_fact(n):
if n==1:
return 1
else:
return n * recu_fact(n-1)
這個遞歸函數就是尾遞歸函數,我們可以很自然的使用循環來改寫成非遞歸結構,代碼如下:
# 非遞歸方式求階乘,循環數組方法改寫
def cycle_fact(n):
result = 1
for i in range(1, n+1):
result = result * i
return result
然後,我們用棧模擬方式來改寫成非遞歸結構,python中的list有append()和pop()方法實際上可以看成棧,但是爲了便於理解,我們先定義一個文件stack.py來模擬一個棧類,代碼如下:
#!/usr/bin/python
# coding:utf-8
# stack.py
# 使用列表封裝的一個簡易Stack類,便於演示算法
class Stack(object):
def __init__(self):
self._list = []
# 壓棧
def push(self, node):
self._list.append(node)
# 出棧
def pop(self):
return self._list.pop()
# 棧是否爲空
def empty(self):
return len(self._list) == 0
# 棧頂元素
def top(self):
return self._list[-1]
def __len__(self):
return len(self._list)
push(), pop(),empty(),top()是棧常用的幾個方法,不多解釋。
然後嘗試用棧模擬來變更成非遞歸方法,代碼如下:
# 非遞歸階乘計算
# n>0; n=1:f(1)=1, n>1:f(n)=n*f(n-1)
# 理解last和top指針的作用,cmp(last,top)決定是壓棧還是出棧
def nonrecu_fact(n):
# 定義一個類來存儲函數的參數和返回值
class ret(object):
n = 0 # 函數參數
result = None # 函數返回值
def __init__(self, n):
self.n = n
#pdb.set_trace()
stack = Stack()
r = ret(n)
stack.push(r)
last = r # 每一次push的時候要設置last爲棧頂元素
while not stack.empty():
top = stack.top()
if top.n == 1:
top.result = 1
last = stack.pop() # 每一次pop要設置last,pop意味着棧頂函數已經解出
else:
if last == top: # 兩者一致說明上一層的函數未解出,所以需要壓棧
r = ret(top.n-1)
stack.push(r)
last = r
else:
m = last.result
top.result = m * top.n
last = stack.pop()
return last.result
這裏解釋下:
- 建立類ret主要用來保存函數的參數和返回值,可以想象一下函數調用過程,函數參數如何傳給調用函數,返回值又如何提供給調用函數。
- 循環的結束靠棧是否爲空判斷。
- 循環外棧中壓入第一個對象。
- 通過last和top指針的比較來判斷是壓棧還是出棧。
第二個例子:菲波那契數列
斐波那契數列又叫兔子數列,它的由來和那個經典的數學題有關:每對大兔每個月能生產1對小兔,而每對大兔生長2個月就成爲大兔,假設初始只有1對小兔,求n個月後兔子的對數。數學表示:n個月兔子的對數爲F(n),則F(n)=F(n-1)+F(n-2),顯然F(1)=F(2)=1,這就是Fibonacci數列的公式。遞歸函數求解邏輯很自然,代碼如下:
# 遞歸fibonacci數 n>0
def recu_fib(n):
if n<=2:
return 1
else:
return recu_fib(n-1) + recu_fib(n-2)
下面尋找轉換爲非遞歸的方法,首先考慮循環數組方式,代碼如下:
# 非遞歸計算fibonacci數列,循環數組方式
def cycle_fib(n):
x = [1] * n
for i,value in enumerate(x):
if i>=2:
x[i] = x[i-1] + x[i-2]
return x[-1]
使用棧模擬方式轉換,代碼如下:
# 非遞歸計算fibnacci數列,棧模擬方式
def nonrecu_fib(n):
# 定義類存儲函數的參數和返回值
class ret(object):
n = 0, # 存儲形參
n1 = None # 內部變量,存儲f(n-1)
n2 = None # 內部變量,存儲f(n-2)
result = None # 存儲返回值
def __init__(self, n):
self.n = n
stack = Stack()
r = ret(n)
stack.push(r)
last = r
while not stack.empty():
top = stack.top()
if top.n in (1,2):
top.result = 1
last = stack.pop()
else:
if last == top:
if top.n1 == None: # top.f(n-1)還未算出,繼續push下一層
r = ret(top.n-1)
stack.push(r)
last = r
else: # top.f(n-1)已解出,開始計算top.f(n-2), push f(n-2)
r = ret(top.n-2)
stack.push(r)
last = r
else:
m = last.result
if top.n1 == None:
top.n1 = m
last = top # 此處一定要設置last和top一致
else:
top.n2 = m
top.result = top.n1 + top.n2
last = stack.pop()
return last.result
效率的比較
1、階乘三種方式函數的執行效率比較
首先比較階乘的三種方式所用的時間,代碼如下:
# 導入時間模塊用於計算程序運行時間
import time
def main():
t0 = time.clock()
f1 = [recu_fact(i) for i in range(1, 15)]
t1 = time.clock()
f2 = [cycle_fact(i) for i in range(1, 15)]
t2 = time.clock()
f3 = [nonrecu_fact(i) for i in range(1, 15)]
t3 = time.clock()
print(f1)
print(f2)
print(f3)
print('recu method: %fms'%((t1-t0)*1000))
print('cycle method: %fms'%((t2-t1)*1000))
print('nonrecu method: %fms'%((t3-t2)*1000))
在開始測試前,我預估了一下結果應該是:數組循環方式最快,遞歸和棧模擬方式最慢,但是兩則時間應該差不多。但是 結果打臉了,😓 調用main函數,實際運行結果如下:
運行時間:循環數組方式<遞歸<棧模擬。 最讓我覺得不可思議的地方是棧模擬方式慢了那麼多。會不會是巧合?多次運行後結果差不多,改代碼全部運行F(i)(i取值100,200,300等)也是如此,單次運行大概棧模擬的時間大概是遞歸時間的4-2倍左右,運行時間次數越多,越趨向於2倍,遞歸是循環數組方式的6倍左右。修改代碼並使用matplolib畫圖,代碼如下:
# 導入時間模塊用於計算程序運行時間
import time
# 導入matplotlib和numpy用於畫圖
import matplotlib.pyplot as plt
import numpy as np
def main():
x = np.linspace(100,900,9)
y1 = []
y2 = []
for i in x:
i = int(i)
t0 = time.clock()
f1 = recu_fact(i)
t1 = time.clock()
f2 = cycle_fact(i)
t2 = time.clock()
f3 = nonrecu_fact(i)
t3 = time.clock()
e1 = (t1-t0)*1000
e2 = (t2-t1)*1000
e3 = (t3-t2)*1000
print(i, e1, e2, e3)
y1.append(e1/e2) # 以循環數組方式運行時間爲基準,遞歸相對於循環數組的花費時間
y2.append(e3/e2) # 以循環數組方式運行時間爲基準,棧模擬相對於循環數組的花費時間
plt.plot(x,y1)
plt.plot(x,y2)
plt.show()
運行結果如下:
X軸是F(x)中的x取值,取了100,900之間每隔100的9個離散值,黃線是以循環數組方式運行耗費時間爲基準,棧模擬方式運行時間耗費,藍色的線是以循環數組方式運行耗費時間爲基準,遞歸方式運行時間耗費。由於python對遞歸深度的默認限制大概是998次,所以沒有計算F(1000)以後的情況了。從圖中看,黃色線是逼近藍色線的,我猜測之所以造成用棧模擬方式花費時間比遞歸時間高這麼多的原因在於python編譯器對遞歸的優化比我手工使用列表棧的方式更優化。此測試也可以看出,系統對遞歸函數的調用深度有限制。但是使用棧模擬的方式確沒有這個限制,只要機器的運行速度和內存可以跟的上的話,例如調用recu_fact(1000)會報遞歸深度超過最大值錯誤,我的電腦調用nonrecu_fact(2000)確沒有任何問題。當然,還有另外一種方式修改python的默認遞歸深度的最大值的方式,這不細說了。
2、Fibonacci三種方式函數的執行效率比較
其實結果應該和第一點的運行效率比較結果大致一致,這裏不多說了,直接放測試代碼如下:
# 導入時間模塊用於計算程序運行時間
import time
def main():
t0 = time.clock()
g1 = [cycle_fib(i) for i in range(1, 30)]
t1 = time.clock()
g2 = [recu_fib(i) for i in range(1, 30)]
t2 = time.clock()
g3 = [nonrecu_fib(i) for i in range(1, 30)]
t3 = time.clock()
print(g1)
print(g2)
print(g3)
print('cycle method: %fms'%((t1-t0)*1000))
print('recu method: %fms'%((t2-t1)*1000))
print('nonrecu method: %fms'%((t3-t2)*1000))
運行結果如下:
以上比較結果更顯著,不多說。
總結
從上面的測試及分析可以看出:
- 循環數組方式轉換遞歸方法效率最高,這其實是一種空間換時間的方法,但是,並不適合所有的遞歸轉換。
- 遞歸方式簡單明瞭,邏輯清楚,但是一般語言的編譯器對遞歸深度有限制。
- 棧模擬的方式轉換遞歸,可以轉換所有的遞歸,這其實是一種使用棧結構來模擬底層函數調用的一種實現。但是也存在邏輯上不夠清楚的缺陷,但是能繞過一般語言編譯器上對遞歸深度的限制。效率上沒有編譯器的優化,效率最差。
所以用不用遞歸,應該視具體問題而言,各種選擇都有優劣。在循環數組方式邏輯清楚的情況下用循環數組方式其實更好的照顧了執行效率和代碼的可讀性。 如果在必須用遞歸的情況下,儘可能的使用一種叫尾遞歸的方法改造遞歸函數,尾遞歸其實是在遞歸函數的一種優化的表示,原則上可以使用更少的棧空間,但是可讀性上也要稍差,可以參考下面對尾遞歸的介紹:
懷念小兔: 遞歸和尾遞歸.
說明
調試的方法
python程序測試過程中都會用到調試,使用最多的可能是打印日誌的方法,但是使用pdb調試效率會更高,例如本篇,我爲了測試遞歸函數和我寫的棧模擬的區別使用了pdb調試,pdb調試一般有兩種方法:
- cmd下加載代碼,命令:python -m pdb recursive.py 進入pdb後設置斷點等。
- 在程序中import pdb,在需要設置斷點的地方使用:pdb.set_trace()
單步調試指令n,進入函數s. 百度搜索"python pdb"等可以得到更多指令,不細說。
完整的代碼
完整的測試代碼見本文附件資源。