來源:3.2 Functions and the Processes They Generatehtml
譯者:飛龍git
協議:CC BY-NC-SA 4.0github
函數是計算過程的局部演化模式。它規定了過程的每一個階段如何構建在以前的階段之上。咱們但願可以建立有關過程總體行爲的語句,而過程的局部演化由一個或多個函數指定。這種分析一般很是困難,可是咱們至少能夠試圖描述一些典型的過程演化模式。算法
在這一章中,咱們會檢測一些用於簡單函數所生成過程的通用「模型」。咱們也會研究這些過程消耗重要的計算資源,例如時間和空間的比例。編程
若是函數的函數體直接或者間接本身調用本身,那麼這個函數是遞歸的。也就是說,遞歸函數的執行過程可能須要再次調用這個函數。Python 中的遞歸函數不須要任何特殊的語法,可是它們的確須要一些注意來正肯定義。數組
做爲遞歸函數的介紹,咱們以將英文單詞轉換爲它的 Pig Latin 等價形式開始。Pig Latin 是一種隱語:對英文單詞使用一種簡單、肯定的轉換來掩蓋單詞的含義。Thomas Jefferson 據推測是先行者。英文單詞的 Pig Latin 等價形式將輔音前綴(可能爲空)從開頭移動到末尾,而且添加-ay
元音。因此,pun
會變成unpay
,stout
會變成outstay
,all
會變成allay
。緩存
>>> def pig_latin(w): """Return the Pig Latin equivalent of English word w.""" if starts_with_a_vowel(w): return w + 'ay' return pig_latin(w[1:] + w[0]) >>> def starts_with_a_vowel(w): """Return whether w begins with a vowel.""" return w[0].lower() in 'aeiou'
這個定義背後的想法是,一個以輔音開頭的字符串的 Pig Latin 變體和另外一個字符串的 Pig Latin 變體相同:它經過將第一個字母移到末尾來建立。因而,sending
的 Pig Latin 變體就和endings
的變體(endingsay
)相同。smother
的 Pig Latin 變體和mothers
的變體(othersmay
)相同。並且,將輔音從開頭移動到末尾會產生帶有更少輔音前綴的更簡單的問題。在sending
的例子中,將s
移動到末尾會產生以元音開頭的單詞,咱們的任務就完成了。數據結構
即便pig_latin
函數在它的函數體中調用,pig_latin
的定義是完整且正確的。函數
>>> pig_latin('pun') 'unpay'
可以基於函數自身來定義函數的想法可能十分使人混亂:「循環」定義如何有意義,這看起來不是很清楚,更不用說讓計算機來執行定義好的過程。可是,咱們可以準確理解遞歸函數如何使用咱們的計算環境模型來成功調用。環境的圖示和描述pig_latin('pun')
求值的表達式樹展現在下面:工具
Python 求值過程的步驟產生以下結果:
pig_latin
的def
語句 被執行,其中:
使用函數體建立新的pig_latin
函數對象,而且
將名稱pig_latin
在當前(全局)幀中綁定到這個函數上。
starts_with_a_vowel
的def
語句相似地執行。
求出pig_latin('pun')
的調用表達式,經過
求出運算符和操做數子表達式,經過
查找綁定到pig_latin
函數的pig_latin
名稱
對字符串對象'pun'
求出操做數字符串字面值
在參數'pun'
上調用pig_latin
函數,經過
添加擴展自全局幀的局部幀
將形參w
綁定到當前幀的實參'pun'
上。
在以當前幀起始的環境中執行pig_latin
的函數體
最開始的條件語句沒有效果,由於頭部表達式求值爲False
求出最後的返回表達式pig_latin(w[1:] + w[0])
,經過
查找綁定到pig_latin
函數的pig_latin
名稱
對字符串對象'pun'
求出操做數表達式
在參數'unp'
上調用pig_latin
,它會從pig_latin
函數體中的條件語句組返回預期結果。
就像這個例子所展現的那樣,雖然遞歸函數具備循環特徵,他仍舊正確調用。pig_latin
函數調用了兩次,可是每次都帶有不一樣的參數。雖然第二個調用來自pig_latin
本身的函數體,但由名稱查找函數會成功,由於名稱pig_latin
在它的函數體執行前的環境中綁定。
這個例子也展現了 Python 的遞歸函數的求值過程如何與遞歸函數交互,來產生帶有許多嵌套步驟的複雜計算過程,即便函數定義自己可能包含很是少的代碼行數。
許多遞歸函數的函數體中都存在通用模式。函數體以基本條件開始,它是一個條件語句,爲須要處理的最簡單的輸入定義函數行爲。在pig_latin
的例子中,基本條件對任何以元音開頭的單詞成立。這個時候,只須要返回末尾附加ay
的參數。一些遞歸函數會有多重基本條件。
基本條件以後是一個或多個遞歸調用。遞歸調用有特定的特徵:它們必須簡化原始問題。在pig_latin
的例子中,w
中最開始輔音越多,就須要越多的處理工做。在遞歸調用pig_latin(w[1:] + w[0])
中,咱們在一個具備更少初始輔音的單詞上調用pig_latin
-- 這就是更簡化的問題。每一個成功的pig_latin
調用都會更加簡化,直到知足了基本條件:一個沒有初始輔音的單詞。
遞歸調用經過逐步簡化問題來表達計算。與咱們在過去使用過的迭代方式相比,它們一般以不一樣方式來解決問題。考慮用於計算n
的階乘的函數fact
,其中fact(4)
計算了4! = 4·3·2·1 = 24
。
使用while
語句的天然實現會經過將每一個截至n
的正數相乘來求出結果。
>>> def fact_iter(n): total, k = 1, 1 while k <= n: total, k = total * k, k + 1 return total >>> fact_iter(4) 24
另外一方面,階乘的遞歸實現能夠以fact(n-1)
(一個更簡單的問題)來表示fact(n)
。遞歸的基本條件是問題的最簡形式:fact(1)
是1
。
>>> def fact(n): if n == 1: return 1 return n * fact(n-1) >>> fact(4) 24
函數的正確性能夠輕易經過階乘函數的標準數學定義來驗證。
(n − 1)! = (n − 1)·(n − 2)· ... · 1 n! = n·(n − 1)·(n − 2)· ... · 1 n! = n·(n − 1)!
這兩個階乘函數在概念上不一樣。迭代的函數經過將每一個式子,從基本條件1
到最終的總數逐步相乘來構造結果。另外一方面,遞歸函數直接從最終的式子n
和簡化的問題fact(n-1)
構造結果。
將fact
函數應用於更簡單的問題實例,來展開遞歸的同時,結果最終由基本條件構建。下面的圖示展現了遞歸如何向fact
傳入1
而終止,以及每一個調用的結果如何依賴於下一個調用,直到知足了基本條件。
雖然咱們可使用咱們的計算模型展開遞歸,一般把遞歸調用看作函數抽象更清晰一些。也就是說,咱們不該該關心fact(n-1)
如何在fact
的函數體中實現;咱們只須要相信它計算了n-1
的階乘。將遞歸調用看作函數抽象叫作遞歸的「信仰飛躍」(leap of faith)。咱們以函數自身來定義函數,可是僅僅相信更簡單的狀況在驗證函數正確性時會正常工做。這個例子中咱們相信,fact(n-1)
會正確計算(n-1)!
;咱們只須要檢查,若是知足假設n!
是否正確計算。這樣,遞歸函數正確性的驗證就變成了一種概括證實。
函數fact_iter
和fact
也不同,由於前者必須引入兩個額外的名稱,total
和k
,它們在遞歸實現中並不須要。一般,迭代函數必須維護一些局部狀態,它們會在計算過程當中改變。在任何迭代的時間點上,狀態刻畫了已完成的結果,以及未完成的工做總量。例如,當k
爲3
且total
爲2
時,就還剩下兩個式子沒有處理,3
和4
。另外一方面,fact
由單一參數n
來刻畫。計算的狀態徹底包含在表達式樹的結果中,它的返回值起到total
的做用,而且在不一樣的幀中將n
綁定到不一樣的值上,而不是顯式跟蹤k
。
遞歸函數能夠更加依賴於解釋器自己,經過將計算狀態儲存爲表達式樹和環境的一部分,而不是顯式使用局部幀中的名稱。出於這個緣由,遞歸函數一般易於定義,由於咱們不須要試着弄清必須在迭代中維護的局部狀態。另外一方面,學會弄清由遞歸函數實現的計算過程,須要一些練習。
另外一個遞歸的廣泛模式叫作樹形遞歸。例如,考慮斐波那契序列的計算,其中每一個數值都是前兩個的和。
>>> def fib(n): if n == 1: return 0 if n == 2: return 1 return fib(n-2) + fib(n-1) >>> fib(6) 5
這個遞歸定義和咱們以前的嘗試有很大關係:它準確反映了斐波那契數的類似定義。考慮求出fib(6)
所產生的計算模式,它展現在下面。爲了計算fib(6)
,咱們須要計算fib(5)
和fib(4)
。爲了計算fib(5)
,咱們須要計算fib(4)
和fib(3)
。一般,這個演化過程看起來像一棵樹(下面的圖並非完整的表達式樹,而是簡化的過程描述;一個完整的表達式樹也擁有一樣的結構)。在遍歷這棵樹的過程當中,每一個藍點都表示斐波那契數的已完成計算。
調用自身屢次的函數叫作樹形遞歸。以樹形遞歸爲原型編寫的函數十分有用,可是用於計算斐波那契數則很是糟糕,由於它作了不少重複的計算。要注意整個fib(4)
的計算是重複的,它幾乎是一半的工做量。實際上,不可貴出函數用於計算fib(1)
和fib(2)
(一般是樹中的葉子數量)的時間是fib(n+1)
。爲了弄清楚這有多糟糕,咱們能夠證實fib(n)
的值隨着n
以指數方式增加。因此,這個過程的步驟數量隨輸入以指數方式增加。
咱們已經見過斐波那契數的迭代實現,出於便利在這裏貼出來:
>>> def fib_iter(n): prev, curr = 1, 0 # curr is the first Fibonacci number. for _ in range(n-1): prev, curr = curr, prev + curr return curr
這裏咱們必須維護的狀態由當前值和上一個斐波那契數組成。for
語句也顯式跟蹤了迭代數量。這個定義並無像遞歸方式那樣清晰反映斐波那契數的數學定義。可是,迭代實現中所需的計算總數只是線性,而不是指數於n
的。甚至對於n
的較小值,這個差別都很是大。
然而咱們不該該從這個差別總結出,樹形遞歸的過程是沒有用的。當咱們考慮層次數據結構,而不是數值上的操做時,咱們發現樹形遞歸是天然而強大的工具。並且,樹形過程能夠變得更高效。
記憶。用於提高重複計算的遞歸函數效率的機制叫作記憶。記憶函數會爲任何以前接受的參數儲存返回值。fib(4)
的第二次調用不會執行與第一次一樣的複雜過程,而是直接返回第一次調用的已儲存結果。
記憶函數能夠天然表達爲高階函數,也能夠用做裝飾器。下面的定義爲以前的已計算結果建立緩存,由被計算的參數索引。在這個實現中,這個字典的使用須要記憶函數的參數是不可變的。
>>> def memo(f): """Return a memoized version of single-argument function f.""" cache = {} def memoized(n): if n not in cache: cache[n] = f(n) return cache[n] return memoized >>> fib = memo(fib) >>> fib(40) 63245986
由記憶函數節省的所需的計算時間總數在這個例子中是巨大的。被記憶的遞歸函數fib
和迭代函數fib_iter
都只須要線性於輸入n
的時間總數。爲了計算fib(40)
,fib
的函數體只執行 40 次,而不是無記憶遞歸中的 102,334,155 次。
空間。爲了理解函數所需的空間,咱們必須在咱們的計算模型中規定內存如何使用,保留和回收。在求解表達式過程當中,咱們必須保留全部活動環境和全部這些環境引用的值和幀。若是環境爲表達式樹當前分支中的一些表達式提供求值上下文,那麼它就是活動環境。
例如,當求值fib
時,解釋器按序計算以前的每一個值,遍歷樹形結構。爲了這樣作,它只須要在計算的任什麼時候間點,跟蹤樹中在當前節點以前的那些節點。用於求出剩餘節點的內存能夠被回收,由於它不會影響將來的計算。一般,樹形遞歸所需空間與樹的深度成正比。
下面的圖示描述了由求解fib(3)
生成的表達式樹。在求解fib
最初調用的返回表達式的過程當中,fib(n-2)
被求值,產生值0
。一旦這個值計算出來,對應的環境幀(標爲灰色)就再也不須要了:它並非活動環境的一部分。因此,一個設計良好的解釋器會回收用於儲存這個幀的內存。另外一方面,若是解釋器當前正在求解fib(n-1)
,那麼由此次fib
調用(其中n
爲2
)建立的環境是活動的。與之對應,最開始在3
上調用fib
所建立的環境也是活動的,由於這個值尚未成功計算出來。
在memo
的例子中,只要一些名稱綁定到了活動環境中的某個函數上,關聯到所返回函數(它包含cache
)的環境必須保留。cache
字典中的條目數量隨傳遞給fib
的惟一參數數量線性增加,它的規模線性於輸入。另外一方面,迭代實現只須要兩個數值來在計算過程當中跟蹤:prev
和curr
,因此是常數大小。
咱們使用記憶函數的例子展現了編程中的通用模式,即一般能夠經過增長所用空間來減小計算時間,反之亦然。
考慮下面這個問題:若是給你半美圓、四分之一美圓、十美分、五美分和一美分,一美圓有多少種找零的方式?更一般來講,咱們能不能編寫一個函數,使用一系列貨幣的面額,計算有多少種方式爲給定的金額總數找零?
這個問題能夠用遞歸函數簡單解決。假設咱們認爲可用的硬幣類型以某種順序排列,假設從大到小排列。
使用n
種硬幣找零的方式爲:
使用全部除了第一種以外的硬幣爲a
找零的方式,以及
使用n
種硬幣爲更小的金額a - d
找零的方式,其中d
是第一種硬幣的面額。
爲了弄清楚爲何這是正確的,能夠看出,找零方式能夠分爲兩組,不使用第一種硬幣的方式,和使用它們的方式。因此,找零方式的總數等於不使用第一種硬幣爲該金額找零的方式數量,加上使用第一種硬幣至少一次的方式數量。然後者的數量等於在使用第一種硬幣以後,爲剩餘的金額找零的方式數量。
所以,咱們能夠遞歸將給定金額的找零問題,歸約爲使用更少種類的硬幣爲更小的金額找零的問題。仔細考慮這個歸約原則,而且說服本身,若是咱們規定了下列基本條件,咱們就可使用它來描述算法:
若是a
正好是零,那麼有一種找零方式。
若是a
小於零,那麼有零種找零方式。
若是n
小於零,那麼有零種找零方式。
咱們能夠輕易將這個描述翻譯成遞歸函數:
>>> def count_change(a, kinds=(50, 25, 10, 5, 1)): """Return the number of ways to change amount a using coin kinds.""" if a == 0: return 1 if a < 0 or len(kinds) == 0: return 0 d = kinds[0] return count_change(a, kinds[1:]) + count_change(a - d, kinds) >>> count_change(100) 292
count_change
函數生成樹形遞歸過程,和fib
的首個實現同樣,它是重複的。它會花費很長時間來計算出292
,除非咱們記憶這個函數。另外一方面,設計迭代算法來計算出結果的方式並非那麼明顯,咱們將它留作一個挑戰。
前面的例子代表,不一樣過程在花費的時間和空間計算資源上有顯著差別。咱們用於描述這個差別的便捷方式,就是使用增加度的概念,來得到當輸入變得更大時,過程所需資源的大體度量。
令n
爲度量問題規模的參數,R(n)
爲處理規模爲n
的問題的過程所需的資源總數。在咱們前面的例子中,咱們將n
看作給定函數所要計算出的數值。可是還有其餘可能。例如,若是咱們的目標是計算某個數值的平方根近似值,咱們會將n
看作所需的有效位數的數量。一般,有一些問題相關的特性可用於分析給定的過程。與之類似,R(n)
可用於度量所用的內存總數,所執行的基本的機器操做數量,以及其它。在一次只執行固定數量操做的計算中,用於求解表達式的所需時間,與求值過程當中執行的基本機器操做數量成正比。
咱們說,R(n)
具備Θ(f(n))
的增加度,寫做R(n)=Θ(f(n))
(讀做「theta f(n)
」),若是存在獨立於n
的常數k1
和k2
,那麼對於任何足夠大的n
值:
k1·f(n) <= R(n) <= k2·f(n)
也就是說,對於較大的n
,R(n)
的值夾在兩個具備f(n)
規模的值之間:
下界k1·f(n)
,以及
上界k2·f(n)
。
例如,計算n!
所需的步驟數量與n
成正比,因此這個過程的所需步驟以Θ(n)
增加。咱們也看到了,遞歸實現fact
的所需空間以Θ(n)
增加。與之相反,迭代實現fact_iter
花費類似的步驟數量,可是所需的空間保持不變。這裏,咱們說這個空間以Θ(1)
增加。
咱們的樹形遞歸的斐波那契數計算函數fib
的步驟數量,隨輸入n
指數增加。尤爲是,咱們能夠發現,第 n 個斐波那契數是距離φ^(n-2)/√5
的最近整數,其中φ
是黃金比例:
φ = (1 + √5)/2 ≈ 1.6180
咱們也表示,步驟數量隨返回值增加而增加,因此樹形遞歸過程須要Θ(φ^n)
的步驟,它的一個隨n
指數增加的函數。
增加度只提供了過程行爲的大體描述。例如,須要n^2
個步驟的過程和須要1000·n^2
個步驟的過程,以及須要3·n^2+10·n+17
個步驟的過程都擁有Θ(n^2)
的增加度。在特定的狀況下,增加度的分析過於粗略,不能在函數的兩個可能實現中作出判斷。
可是,增加度提供了實用的方法,來表示在改變問題規模的時候,咱們應如何預期過程行爲的改變。對於Θ(n)
(線性)的過程,使規模加倍只會使所需的資源總數加倍。對於指數的過程,每一點問題規模的增加都會使所用資源以固定因數翻倍。接下來的例子展現了一個增加度爲對數的算法,因此使問題規模加倍,只會使所需資源以固定總數增長。
考慮對給定數值求冪的問題。咱們但願有一個函數,它接受底數b
和正整數指數n
做爲參數,並計算出b^n
。一種方式就是經過遞歸定義:
b^n = b·b^(n-1) b^0 = 1
這能夠翻譯成遞歸函數:
>>> def exp(b, n): if n == 0: return 1 return b * exp(b, n-1)
這是個線性的遞歸過程,須要Θ(n)
的步驟和空間。就像階乘那樣,咱們能夠編寫等價的線性迭代形式,它須要類似的步驟數量,但只須要固定的空間。
>>> def exp_iter(b, n): result = 1 for _ in range(n): result = result * b return result
咱們能夠以更少的步驟求冪,經過逐次平方。例如,咱們這樣計算b^8
:
b·(b·(b·(b·(b·(b·(b·b))))))
咱們可使用三次乘法來計算它:
b^2 = b·b b^4 = b^2·b^2 b^8 = b^4·b^4
這個方法對於 2 的冪的指數工做良好。咱們也可使用這個遞歸規則,在求冪中利用逐步平方的優勢:
咱們一樣能夠將這個方式表達爲遞歸函數:
>>> def square(x): return x*x >>> def fast_exp(b, n): if n == 0: return 1 if n % 2 == 0: return square(fast_exp(b, n//2)) else: return b * fast_exp(b, n-1) >>> fast_exp(2, 100) 1267650600228229401496703205376
fast_exp
所生成的過程的空間和步驟數量隨n
以對數方式增加。爲了弄清楚它,能夠看出,使用fast_exp
計算b^2n
比計算b^n
只須要一步額外的乘法操做。因而,咱們可以計算的指數大小,在每次新的乘法操做時都會(近似)加倍。因此,計算n
的指數所需乘法操做的數量,增加得像以2
爲底n
的對數那樣慢。這個過程擁有Θ(log n)
的增加度。Θ(log n)
和Θ(n)
之間的差別在n
很是大時變得顯著。例如,n
爲1000
時,fast_exp
僅僅須要14
個乘法操做,而不是1000
。