從示例逐漸理解Scala尾遞歸

1.遞歸與尾遞歸

1.1 遞歸

1.1.1 遞歸定義

遞歸大家都不陌生,一個函數直接或間接的調用它自己本身,就是遞歸。它通常把一個大型復雜的問題層層轉化為一個與原問題相似的規模較小的問題來求解,遞歸策略只需少量的代碼就可以執行多次重復的計算。

1.1.2 遞歸的條件

一般來說,遞歸需要有邊界條件、遞歸前進段和遞歸返回段。當邊界條件不滿足時,遞歸前進;當邊界條件滿足時,遞歸返回。

以遞歸方式實現階乘函數的實現:

代碼清單1-1

def factorial(n:Int): Long ={
    if(n <= 0) 1
    else n * factorial(n-1)
}

代碼清單中,if(n <= 0) 1是遞歸返回段,else后面部分是遞歸前進段。

1.1.3 遞歸的缺點:
  • 需要保持調用堆棧,如代碼清單1-1,每一次遞歸都要保存n*factorial(n-1)棧幀信息。如果調用次數太多,可能會導致棧溢出
  • 效率會比較低,遞歸就是不斷的調用自己本身,如果方法本身比較復雜,每次調用自己效率會較低。

1.2 尾遞歸

1.2.1 定義

尾遞歸的定義比較簡單,即函數在函數體最后調用它本身,就被稱為尾遞歸

我們可以這樣理解尾遞歸

  • 所有遞歸形式的調用都出現在函數的末尾
  • 遞歸調用是整個函數體中最后執行的語句且它的返回值不屬于表達式的一部分
1.2.2 例子程序

下面我們使用尾遞歸的模式實現上面的階乘

代碼清單1-2

def factorial(n:Int):Long = {
    @tailrec
    def factorial(main:Int,aggr:Int): Long ={
        if(main <= 0) aggr
        else factorial(main-1,main*aggr)
    }
    
   factorial(n,1)
}

我們可以比較代碼清單1-1和1-2
1-1中,每次的遞歸調用都要本身時依賴n這個變量,所以,它只能是個不同的遞歸。

1-2中,函數factorial每次返回的都是它自己本身,沒有依賴任何值。它做的是將main每次減1,將aggr每次乘main,然后將這兩個結果作為下一次遞歸調用的參數,進行調用。

尾遞歸的核心思想是通過參數來傳遞每一次的調用結果,達到不壓棧。它維護著一個迭代器和一個累加器。

1.3 循環

循環能夠解決大多數的累計問題,循環可以完成累加和迭代,處理問題比較簡單,思想也比較符合,容易理解

n的階乘循環的寫法

代碼清單1-3

def fibfor(n:Int): Int ={
    var m = 1
    for (i <- 1 to n) {
        m = m * i
    }
    m
}

循環版本,會有var的可變變量,我們知道,函數式編程就應該更函數范,我們盡可能的要用vals去替換可變的vars
所以我們可以使用遞歸的方式來消除掉vars


2.改寫 (循環,遞歸 TO 尾遞歸)

事實上,scala都是將尾遞歸直接編譯成循環模式的。所以我們可以大膽的說,所有的循環模式都能改寫為尾遞歸的寫法模式

尾遞歸會維護一個或多個累計值(aggregate)參數和一個迭代參數。我們具體分析

2.1迭代器和累計器

  • 累計值參數aggregate將每次循環產生的結果進行累計,然后傳到下一次的調用中。

  • 迭代器,和普通遞歸或循環一樣,每次遞歸或循環后,改變一次。(如for(i=0;i<1-;i++)里面的i)

2.2 普通遞歸轉換為尾遞歸

并不是所有的遞歸都能改寫為尾遞歸,那些比較復雜的遞歸調用時無法優化為尾遞歸的。但是大部分還是能夠進行優化的。

代碼清單1-1 和代碼清單 1-2 是求n階階乘的普通遞歸與尾遞歸的寫法,前者沒有進行優化,每次調用都會壓棧。
后者,通過定義一個aggregate(累計)參數,對每一次調用后的結果做一次累計,而另一個參數main稱為迭代器,每一次調用都會-1,當達到符合返回的條件時,將累計值返回。

2.3 循環(while loop)轉為尾遞歸(tail recursion)

正如上文循環例子所述,存在var,函數式編程就應該有函數范,我們盡量使用val來代替,所以接下來來看,怎么將循環轉換為尾遞歸

2.3.1 循環和尾遞歸

正如上文所說的迭代器和累計器,循環和尾遞歸都有這兩個概念

迭代器累計器

尾遞歸每一次的調用自身都會有一次累加(或者累積,累減等),然后會有一個迭代器進行迭代,一個累加器進行累加迭代的結果,然后作為參數,再去調用自身。

2.3.2 如上面求n階乘的尾遞歸例子:
  • 1.循環的例子中存在一個var,它在每次循環中充當一個累加器的角色,累加每一次的迭代結果,而每次迭代過程就是m*i的一個過程。

  • 2.尾遞歸也是一樣的思想,以main作為迭代器,每次遞減1,類似循環里的i,以aggr作為累加器,每次累計迭代的結果,類似循環的m

  • 3.相對于普通的遞歸,這里尾遞歸多的一個參數就是累加器aggr,用于累計每一次遞歸迭代的結果。這樣做的目的就是每一次調用的結果可以作為下一次函數調用的參數。

3.具體示例-加深理解

3.1 例子1 - 求斐波拉契數列

  • 普通遞歸寫法(性能較低)
def fibonacci(n:Int): Long ={
    n match {
      case 1 | 2 => 1
      case _ => fibonacci(n-1) + fibonacci(n-2)
    }
}
  • 循環的寫法(循環寫法)
def fibonacciFor(n:Int): Int = {
    var current = 1
    var next = 1
    if(n <=2) 1
    else {
        for(i <- 2 until n) {
            var aggr = current + next
            current = next
            next = aggr
        }   
        next
    }

}

可以看到,aggr是累加器,然后將累加的值賦值給下一個next,而current等于next,每一次循環,都有給current和next賦予新值,當累加完成后,返回next的值。

  • 尾遞歸寫法

如何對其進行優化?

仔細分析,上面的普通循環,每一輪兩個值都在改變,然后又一個累加器aggr,對這兩個值進行累加,并賦值給更大的next,然后進入下一次循環。

尾遞歸,我們也是同樣的做法,定義兩個接受值,當前的,和下一個,然后需要一個累加值。

這里普通方法的遞歸調用是兩個原函數相加,涉及到的變量有 n , n-1 , n-2

因此,我們在考慮使用尾遞歸時,可能也需要使用到三個參數,初略涉及,尾遞歸函數需要使用三個參數,于是改寫如下:

def fibonacci(n: Int): Long = {
    @tailrec
    def fibonacciTail(main: Int, current: Int, next: Int): Long = {
      main match {
        case 1 | 2 => next
        case _ => fibonacciByTail(main - 1, next, current+next)
      }
      fibonacciTail(n, 1, 1)
    }
    
    fibonacciTail(n,1,1)

}

使用尾遞歸和模式匹配。每一次調用自身,將next賦值給current,然后累加current和next的值賦值給新的next值,call下一輪。思想上和上面循環很像。但是更函數范,消除掉了var。


3.2 例子2 - loadBalance算法

需求,設計一個程序,傳入一個比例數組,比如Array(1,3,6,一直調用該函數,返回的3個節點的比例也應該如傳入的1:3:6的比例一樣。

  • 我最開始使用for循環return實現了這個需求,代碼如下:
def loadBalance(arr:Array[Int]): Int ={
      //根據傳入的數組使用scan高級函數進行變化,具體算法例子:
      //eg (1,3,6) ->  (1,4,10)
      //這樣的目的是,隨機出來的值為0-1時,選擇第一個節點,為1-4時選擇第二節點,依次類推
      val segment:Array[Int] = arr.scan(0)(_ + _).drop(1)
      //隨機數的范圍,根據傳入的數組的數據之和來,例如上的便是 10 ,產生的隨機數介于0 - 9 之間
      val weightSum:Int  = arr.sum
      val random = new Random().nextInt(weightSum)
      
      for(i <- 0 until segment.size ){
        if(random < segment(i)){

          return i
        }

      }
    0
}

我通過測試程序調用1萬次該方法,返回的隨機節點的比例是符合傳入的比例的。

思考

雖然這樣可以達到目的,但是代碼寫的既不優雅,在scala函數式編程中最好是不能使用return來強行打斷函數執行的,并且在最后,我還需要去寫一個0來作為默認返回。

尾遞歸優化

大部分或者幾乎所有的for循環都能使用尾遞歸進行優化,那上面這個代碼如何進行優化呢?

思路:上文的for循環,每次增加的是segment的下標,每循環一次 +1,因此,我們在設計尾遞歸時,可以使用一個參數來實現相同的功能,而另一個參數應該就是產生的隨機數。
ok,我們來進行實現

def loadBalance(arr:Array[Int]): Int ={
      //根據傳入的數組使用scan高級函數進行變化,具體算法例子:
      //eg (1,3,6) ->  (1,4,10)
      //這樣的目的是,隨機出來的值為0-1時,選擇第一個節點,為1-4時選擇第二節點,依次類推
      val segment:Array[Int] = arr.scan(0)(_ + _).drop(1)
      //隨機數的范圍,根據傳入的數組的數據之和來,例如上的便是 10 ,產生的隨機數介于0 - 9 之間
      val weightSum:Int  = arr.sum
      val random = new Random().nextInt(weightSum)
      //寫一個內部方法
      def loadUtil(rand:Int,index:Int) {
        //assert,保證程序健壯
        assert(index < arr.length && arr(index) >= 0)
          
        if(rand < segment(index)) index
        else loadUtil(rand,index+1)
      }
    loadUtil(random,0)
}

我們可以看到,使用尾遞歸的做法,代碼會非常的優雅,現在寫一個測試類進行測試!

def main(args: Array[String]): Unit = {
    val arr = Array(1,2,7)
    val countMap = collection.mutable.Map[Int,Int]()

    for(_ <- 1 until 100000) {
      val res = loadBalance(arr)

      countMap.get(res) match {
        case Some(x) => countMap += (res -> (x+1))
        case None => countMap +=(res -> 1)
      }
    }

    countMap.foreach(x => {
      println(s"${x._1}  調用次數 ${x._2}")
    })
    
  }

//測試10000次,返回結果如下:

2  調用次數 69966
1  調用次數 20028
0  調用次數 10005

如上,測試是通過的!是不是很優雅,感受到了尾遞歸的魅力?


4. scala編譯器對尾遞歸的優化

Scala 對形式上嚴格的尾遞歸進行了優化,對于嚴格的尾遞歸,不需要注解

@tailrec 可以讓編譯器去檢查該函數到底是不是尾遞歸,如果不是會報錯

具體以上面那個計算斐波拉契數列的例子進行性能分析
def time[T](t: =>T): T  = {
    val b = System.nanoTime()
    val x = t
    val e = System.nanoTime();
    println("time: " + (e-b)/1000 + "us");
    x
    
}

var count: Long = 0
  // @tailrec
  def fib2(n: Long): Long = {
    count += 1
    n match {
      case 1 | 2 => 1
      case _ =>
        fib2(n-1) + fib2(n-2)
    }
  }

通過上面時間和調用次數的測試,可以得出尾遞歸的性能消耗很低,速度很快。

4.1 編譯器對尾遞歸的優化

當編譯器檢測到一個函數調用是尾遞歸的時候,它就覆蓋當前的活動記錄而不是在棧中去創建一個新的。

scala編譯器會察覺到尾遞歸,并對其進行優化,將它編譯成循環的模式。

4.2 Scala尾遞歸的限制

  • 尾遞歸有嚴格的要求,就是最后一個語句是遞歸調用,因此寫法比較嚴格。

  • 尾遞歸最后調用的必須是它本身,間接的賦值它本身的函數也無法進行優化。

5. 總結

循環調用都是一個累計器和一個迭代器的作用,同理,尾遞歸也是如此,它也是通過累加和迭代將結果賦值給新一輪的調用,通過這個思路,我們可以輕松的將循環轉換為尾遞歸的形式。

[本文完,歡迎轉載,轉載請注明出處]

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,488評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,034評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,327評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,554評論 1 307
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,337評論 6 404
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,883評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 42,975評論 3 439
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,114評論 0 286
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,625評論 1 332
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,555評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,737評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,244評論 5 355
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 43,973評論 3 345
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,362評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,615評論 1 280
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,343評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,699評論 2 370

推薦閱讀更多精彩內容