1. 程式人生 > >Scala中尾遞迴

Scala中尾遞迴

    作為一個程式設計師,大家對遞迴應該都很熟悉,在《 資料結構與演算法分析:C描述》書中,已列印連結串列為例,提到了尾遞迴,並指出了尾遞迴是對遞迴及其不當的使用,它指出雖然編譯器會對遞迴進行自動優化,但是一般情況下還是不要使用尾遞迴。此外在Java中,遞迴的使用率也是很低,這可能是因為比起遞迴,迴圈在java中更容易實現,並且遞迴對於編寫遞迴函式的人來說比較容易理解,但是對閱讀的人來說可能不太容易理解。但是這種情況在Scala中就完全不一樣了,Scala語言支援函數語言程式設計(從某種意義上來說鼓勵我們使用遞迴代替迴圈)。我們都知道遞迴的效率不如迴圈,那麼函數語言程式設計語言中使用瞭如此的多的遞迴是如何保證效率的呢?答案就是這些語言會對尾遞迴進行優化。
    這裡有幾個問題,首先什麼是尾遞迴?在scala中,如何合理的使用尾遞迴?以及在scala中使用尾遞迴需要注意哪些問題?
  1.   什麼是尾遞迴?
    我們常說的遞迴函式指的是在函式中呼叫自身的函式。根據遞迴的呼叫方式,可以將遞迴分為首遞迴和尾遞迴。在首遞迴中,函式呼叫自身之後,再進行其他運算,因此在執行的時候需要不斷的使用新的棧幀來儲存函式中的臨時變數,這種情況下,當遞迴的層次不是很多的時候,還不會出現問題,但是一旦敵對的層次很深的時候就很容易出現stack overflow的情況,這對程式而言往往是致命的。因此使用首遞歸併不安全。下面是一個首遞迴函式:
public class Node{
    private int value;
    private Node next;
    public Node(int value,Node next){
       this.value=value;
       this.next=next;
   }
   public int getValue(){
       return value;
   }
   public Node getNext(){
       return next;
   }
}
// 首遞迴函式
public static int getLength(Node head){
   if(head==null) return 0;
//  可以看到在遞迴呼叫getLength之後還進行一次求和計算,因此不是尾遞迴,在遞迴的過程中需要保持臨時變數
   return getLength(head.getNext())+1

}
       而尾遞迴則完全不同,在尾遞迴函式中,所有的計算都在呼叫之前完成(這一點非常重要),  因此函式可以在呼叫完成之後釋放棧幀,因此不管遞迴的層次有多深都不會發生stack overflow的情況。將上述首遞迴函式改成尾遞迴函式的形式如下所示:
public static getLength(Node head){
   if(head==null)  return acc;
// 可以看到遞迴呼叫getLength之後整個函式的計算都已經完成,因此棧幀可以立即釋放
   return getLength(head.getNext(),acc+1)

}
     注意上面的acc,我們可以認為是一個累加器(其實不一定是做加法,代表任何形式的一致積聚),用於積累之前呼叫的結果,這樣呼叫的資料就不會被丟棄,這也是尾遞迴非常重要的一個特點。 2 、Scala中如何是使用尾遞迴?     我們以典型的斐波那契數列為例,來看看scala中如何使用尾遞迴。     首遞迴形式的斐波那契數列
def fibonacci(n:Int):Int={
    if(n<=2) 1
    else fibonacci(n-1)+fibonacci(n-2)
}
   當n=5時,其計算過程如下所示:
fibonacci(5)
fibonacci(4) + fibonacci(3)
(fibonacci(3) + fibonacci(2)) + (fibonacci(2) + fibonacci(1))
((fibonacci(2) + fibonacci(1)) + 1) + (1 + 1)
((1 + 1) + 1) + 2
5
    尾遞迴的形式如下所示:
def fibonacciTail(n:Int,acc1:Int,acc2:Int):Int={
    if(n<2)  acc2
    else  fibonacciTail(n-1,acc2,acc1+acc2)
}
    其呼叫過程如下所示:
fibonacciTailrec(5,0,1)
fibonacciTailrec(4,1,1)
fibonacciTailrec(3,1,2)
fibonacciTailrec(2,2,3)
fibonacciTailrec(1,3,5)
5
    其實在scala中使用尾遞迴最關鍵的地方還是需要理解遞迴函式需要返回的是什麼,返回的結果應該如何積聚(本質上就是如何引入合適的累加器來積聚遞迴程式返回的結果)     Scala對形式上嚴格的尾遞迴進行了優化,對於嚴格的尾遞迴,可以放心使用,不必擔心效能問題。對於是否是嚴格尾遞迴,若不能自行判斷, 可使用Scala提供的尾遞迴標註@scala.annotation.tailrec,這個符號除了可以標識尾遞迴外,更重要的是編譯器會檢查該函式是否真的尾遞迴,若不是,會導致如下編譯錯誤。
could not optimize @tailrec annotated method fibonacci: it contains a recursive call not in tail position
3、在scala中使用尾遞迴需要注意哪些問題?      由於scala本質上一種jvm語言,最後還是要編譯為class檔案,有jvm執行,因此其執行機制肯定會送到jvm的限制(這是其與其他純函數語言程式設計語言不通的地方),Scala對尾遞迴的優化很有限,它只能優化形式上非常嚴格的尾遞迴。下列的情況scala不會優化。
  • 遞迴不是直接呼叫,而是通過函式值。例如:
//call function value will not be optimized     
val func = factorialTailrec _
def factorialTailrec(n: BigInt, acc: BigInt): BigInt = {
  if(n <= 1) acc
// 呼叫函式,不會優化
  else func(n-1, acc*n)
}
  • 間接遞迴不會被優化 間接遞迴,指不是直接呼叫自身,而是通過其他的函式最終呼叫自身的遞迴。
//indirect recursion will not be optimized
def foo(n: Int) : Int = {
  if(n == 0) 0;
//通過間接呼叫,不會被優化
  bar(n)
}
def bar(n: Int) : Int = {
  foo(n-1)
}
本文參考的資料如下: