尾調用與尾遞歸

本講將對尾調用與尾遞歸進行介紹:函數的最後一條執行語句是調用一個函數的形式即爲尾調用;函數尾調用自身則爲尾遞歸,經過改寫循環便可輕鬆寫出尾遞歸函數。在語言支持尾調用優化的條件下,尾調用能節省很大一部份內存空間。html

什麼是尾調用

問:何爲尾調用?
說人話:函數的最後一條執行語句是調用一個函數,這種形式就稱爲尾調用。java

讓咱們看看如下幾個例子。python

// 正確的尾調用:函數/方法的最後一行是去調用function2()這個函數
public int function1(){
    return function2();
}

// 錯誤例子1:調用完函數/方法後,又多了賦值操做
public int function1(){
    int x = function2();
    return x;
}

// 錯誤例子2:調用完函數後,又多了運算操做
public int function1(){
    return function2() + 1;
}

// 錯誤例子3:f(x)的最後一個動做實際上是return null
public void function1(){
    function2();
}

尾調用優化

以Java爲例。JVM會爲每一個新建立的線程都建立一個棧(stack)。棧是用來存儲棧幀(stack frame)的容器;而棧幀是用來保存線程狀態的容器,其主要包括方法的局部變量表(local variable table),操做數棧(operand stack),動態鏈接(dynamic linking)和方法返回地址(return address)等信息。
(注:Java語言目前還不支持尾調用優化,但尾調用優化的原理是相通的。)函數

棧會對棧幀進行壓棧和出棧操做:每當一個Java方法被執行時都會新建立一個棧幀(壓棧,push),方法調用結束後即被銷燬(出棧,pop)。優化

在方法A的內部調用方法B,就會在A的棧幀上疊加一個B的棧幀。在一個活動的線程中,只有在棧頂的棧幀纔是有效的,它被稱爲當前棧幀(Current Stack Frame),這個棧幀所關聯的方法則被稱爲當前方法(Current Method)。只有當方法B運行結束,將結果返回到A後,B的棧幀纔會出棧。spa

舉個例子。線程

public int eat(){
    return 5;
}

public int action(){
    int x = eat();
    return x;
}

public int imitate(){
    int x = action();
    return x;
}

public static void main(String[] args){
    imitate();
}

這段代碼對應的棧的情況則爲以下:code

  1. 首先,在main線程調用了imitate()方法,便將它的棧幀壓入棧中。
  2. imitate()方法裏,調用了action()方法,因爲這不是個尾調用,在調用完action()方法後仍存在一個運算操做,所以將\(action\)的棧幀壓入棧中後,JVM認爲imitate()方法還沒執行完,便仍然保留着\(imitate\)的棧幀。
  3. 同理:action()方法裏對eat()方法的調用也不是尾調用,JVM認爲在調用完eat()方法後,action()方法仍未執行結束。所以保留\(action\)的棧幀,並繼續往棧中壓入\(eat\)的棧幀。
  4. eat()方法執行完後,其對應棧幀就會出棧;action()方法和imitate()方法在執行完後其對應的棧幀也依次出棧。

但假如咱們對上述示例代碼改寫成以下所示:htm

public int eat(){
    return 5;
}

public int action(){
    return eat();
}

public int imitate(){
    return action();
}

public static void main(String[] args){
    imitate();
}

那麼若是尾調用優化生效,棧對應的狀態就會爲以下:blog

  1. 首先,仍然是將imitate()方法的棧幀壓入棧中。
  2. imitate()方法中對action()方法進行了尾調用,所以在調用action()方法時就意味着imitate()方法執行結束:\(imitate\)棧幀出棧,\(action\)棧幀入棧。
  3. 同理:\(action\)棧幀出棧,\(eat\)棧幀入棧。
  4. 最後,eat()方法執行完畢,全流程結束。

咱們能夠看到,因爲尾調用是函數的最後一條執行語句,無需再保留外層函數的棧幀來存儲它的局部變量以及調用前地址等信息,因此棧從始至終就只保留着一個棧幀。這就是尾調用優化(tail call optimization),節省了很大一部分的內存空間。

但上面只是理論上的理想狀況,把代碼改寫成尾調用的形式只是一個前提條件,棧是否真的如咱們所願從始至終只保留着一個棧楨還得取決於語言是否支持。例如python就不支持,即便寫成了尾遞歸的形式,棧該爆仍是會爆。

尾遞歸

問:何爲尾遞歸?
說人話:函數尾調用自身,這個形式就稱爲尾遞歸。

手把手教你寫遞歸這篇文章中咱們提過,遞歸對空間的消耗大,例如計算factorial(1000),就須要保存1000個棧幀,很容易就致使棧溢出。

但假如咱們將其改成尾遞歸,那對於那些支持尾調用優化的語言來講,就能作到只保存1個棧幀,有效避免了棧溢出。

那尾遞歸函數要怎麼寫呢?

一個比較實用的方法就是先寫出用循環實現的版本,再把循環中用到的局部變量都改成函數的參數便可。這樣再進入下一層函數時就不須要再用到上一層函數的環境了,到最後一層時就包含了前面全部層的計算結果,就不要再返回了。

例如階乘函數。

public int fac(int n) {
    int result = 1;
    for (int index = 1; index <= n; index++)
        result = index * result;
    return result;
}

在這個用循環實現的版本中,能夠看到用到了\(result, index\)這兩個局部變量,那就將其改成函數的參數。而且經過循環能夠看出邊界條件是當index == n時;\(n\)從頭至尾不會變;\(index\)在每次進入下一層循環時會遞增,\(result\)在每次進入下一層循環時會有變更。咱們把這些改動直接照搬,改寫就很是容易了。

因此用尾遞歸實現的版本即爲以下:

public int fac(int n, int index, int result) {
    if (index == n)
        return index * result;
    else 
        return fac(n, index + 1, index * result);
}

再舉個例子,斐波那契數列(0, 1, 1, 2, 3, 5, 8, 13...)

其循環實現版本以下:

public int fibo(int n) {
    int a = 0;
    int b = 1;
    int x = 0;
    for (int index = 0; index < n; index++){
    	x = b;
        b = a + b;
        a = x;
    }
    return a;
}

局部變量有\(a, b, index\)\(x\)做爲\(a, b\)賦值的中間變量,在遞歸中能夠不須要用到),把這些局部變量放到參數列表。邊界條件爲當index == n時;而且,在進入下一層循環時,\(a\)的值會變爲\(b\)\(b\)的值會變爲\(a + b\)\(index\)的值加1,把這些改動照搬。

public int fibo(int n, int a, int b, int index) {
    if (index == n) 
        return a;
    else 
        return fibo(n, b, a + b, index + 1);
}

參考

  1. https://zhuanlan.zhihu.com/p/130885188
  2. https://www.cnblogs.com/minisculestep/articles/4934947.html
  3. https://zhuanlan.zhihu.com/p/24305359
  4. http://www.javashuo.com/article/p-fpvpbrbq-gu.html

創做不易,點個贊再走叭~

相關文章
相關標籤/搜索