【Scala】尾遞迴優化

JasonDing1354發表於2016-01-15

以遞迴方式思考

遞迴通過靈巧的函式定義,告訴計算機做什麼。在函數語言程式設計中,隨處可見遞迴思想的運用。
下面給出幾個遞迴函式的例子:

object RecursiveExample extends App{
  // 數列求和例子
  def sum(xs: List[Int]): Int =
    if (xs.isEmpty)
      1
    else
      xs.head + sum(xs.tail)


  // 求最大值例子
  def max(xs: List[Int]): Int =
    if (xs.isEmpty)
      throw new NoSuchElementException
    else if (xs.size == 1)// 遞迴的邊界條件
      xs.head
    else
      if (xs.head > max(xs.tail)) xs.head else max(xs.tail)


  // 翻轉字串
  def str_reverse(xs: String): String =
    if (xs.length == 1)
      xs
    else
      str_reverse(xs.tail) + xs.head

  // 快速排序例子
  def quicksort(ls: List[Int]): List[Int] = {
    if (ls.isEmpty)
      ls
    else
      quicksort(ls.filter(_ < ls.head)) ::: ls.head :: quicksort(ls.filter(_ > ls.head))
      //quicksort(ls.filter(x =>  x < ls.head)) ::: ls.head :: quicksort(ls.filter(x => x > ls.head))
  }
}

我們以上面程式碼最後一個快速排序函式為例,使用遞迴的方式,其程式碼實現非常的簡潔和通俗易懂。遞迴函式的核心是設計好遞迴表示式,並且確定演算法的邊界條件。上面的快速排序中,認為空列表就是排好序的列表,這就是遞迴的邊界條件,這個條件是遞迴終止的標誌。

尾遞迴

遞迴演算法需要保持呼叫堆疊,效率較低,如果呼叫次數較多,會耗盡記憶體或棧溢位。然而,尾遞迴可以克服這一缺點。
尾遞迴是指遞迴呼叫是函式的最後一個語句,而且其結果被直接返回,這是一類特殊的遞迴呼叫。由於遞迴結果總是直接返回,尾遞迴比較方便轉換為迴圈,因此編譯器容易對它進行優化。

遞迴求階乘的經典例子

普通遞迴求解的程式碼如下:

def factorial(n: BigInt): BigInt = {
  if (n <= 1)
    1
  else
    n * factorial(n-1)
}

上面的程式碼,由於每次遞迴呼叫n-1的階乘時,都有一次額外的乘法計算,這使得堆疊中的資料都需要保留。在新的遞迴中要分配新的函式棧。
執行過程就像這樣:

factorial(4)
--------------
4 * factorial(3)
4 * (3 * factorial(2))
4 * (3 * (2 * factorial(1)))
4 * (3 * (2 * 1))

而下面是一個尾遞迴版本,在效率上,和迴圈是等價的:

import scala.annotation.tailrec

def factorialTailRecursive(n: BigInt): BigInt = {
  @tailrec
  def _loop(acc: BigInt, n: BigInt): BigInt =
    if(n <= 1) acc else _loop(acc*n, n-1)

  _loop(1, n)
}

這裡的執行過程如下:

factorialTailRecursive(4)
--------------------------
_loop(1, 4)
_loop(4, 3)
_loop(12, 2)
_loop(24, 1)

該函式中的_loop在最後一步,要麼返回遞迴邊界條件的值,要麼呼叫遞迴函式本身。
改寫成尾遞迴版本的關鍵:
尾遞迴版本最重要的就是找到合適的累加器,該累加器可以保留最後一次遞迴呼叫留在堆疊中的資料,積累之前呼叫的結果,這樣堆疊資料就可以被丟棄,當前的函式棧可以被重複利用。
在這個例子中,變數acc就是累加器,每次遞迴呼叫都會更新該變數,直到遞迴邊界條件滿足時返回該值。
對於尾遞迴,Scala語言特別增加了一個註釋@tailrec,該註釋可以確保程式設計師寫出的程式是正確的尾遞迴程式,如果由於疏忽大意,寫出的不是一個尾遞迴程式,則編譯器會報告一個編譯錯誤,提醒程式設計師修改自己的程式碼。

菲波那切數列的例子

原始的程式碼很簡單:

def fibonacci(n: Int): Int =
  if (n <= 2)
    1
  else
    fibonacci(n-1) + fibonacci(n-2)

尾遞迴版本用了兩個累加器,一個儲存較小的項acc1,另一個儲存較大項acc2:

def fibonacciTailRecursive(n: Int): Int = {
  @tailrec
  def _loop(n: Int, acc1: Int, acc2: Int): Int =
    if(n <= 2)
      acc2
    else
      _loop(n-1, acc2, acc1+acc2)

  _loop(n, 1, 1)
}

幾個列表操作中使用尾遞迴的例子

求列表的長度

def lengthTailRecursive[A](ls: List[A]): Int = {
  @tailrec
  def lengthR(result: Int, curList: List[A]): Int = curList match {
    case Nil => result
    case _ :: tail => lengthR(result+1, tail)
  }
  lengthR(0, ls)
}

翻轉列表

def reverseTailRecursive[A](ls: List[A]): List[A] = {
  @tailrec
  def reverseR(result: List[A], curList: List[A]): List[A] = curList match {
    case Nil        => result
    case h :: tail  => reverseR(h :: result, tail)
  }
  reverseR(Nil, ls)
}

去除列表中多個重複的元素

這裡要求去除列表中多個連續的字元,只保留其中的一個。

// If a list contains repeated elements they should be replaced with
// a single copy of the element.
// The order of the elements should not be changed.
// Example:
// >> compress(List('a, 'a, 'a, 'a, 'b, 'c, 'c, 'a, 'a, 'd, 'e, 'e, 'e, 'e))
// >> List('a, 'b, 'c, 'a, 'd, 'e)

def compressTailRecursive[A](ls: List[A]): List[A] = {
  @tailrec
  def compressR(result: List[A], curList: List[A]): List[A] = curList match {
    case h :: tail  => compressR(h :: result, tail.dropWhile(_ == h))
    case Nil        => result.reverse
  }
  compressR(Nil, ls)
}

轉載請註明作者Jason Ding及其出處
Github部落格主頁(http://jasonding1354.github.io/)
GitCafe部落格主頁(http://jasonding1354.gitcafe.io/)
CSDN部落格(http://blog.csdn.net/jasonding1354)
簡書主頁(http://www.jianshu.com/users/2bd9b48f6ea8/latest_articles)
Google搜尋jasonding1354進入我的部落格主頁

相關文章