鏈表插入元素的三種遞歸實現 -- 簡單遞歸,數據累加器,函數累加器

函數式編程的一個強大之處在於遞歸,使用遞歸可以簡化算法設計的思路。

尾遞歸是遞歸的一種特殊形式,它的特點是可以不創建新的堆棧幀而是改變當前堆棧幀來實現,這樣做的好處是不會浪費運行時空間,遞歸層次加深也不會發生棧溢出,如同執行迭代一樣。

如何將非尾遞歸函數改寫成尾遞歸函數是函數式編程的一項重要的基本功,而最簡單的題目就是“n的階乘”。有許多blog講解如何將“n的階乘”改寫成尾遞歸的形式,而這裏假設讀者已經具備寫出尾遞歸版本的“n的階乘”的能力。

今天我們來看另外一個經典的題目: 鏈表插入元素。

題目的描述是這樣的:給定一個有序鏈表L和一個元素v,將v插入到L中並保持新鏈表仍然有序。例如 L = List(1, 2, 4, 5), v = 3,函數應該返回List(1, 2, 3, 4, 5)。

遞歸的思路:如果當前鏈表爲空,直接返回只有一個元素v的鏈表。否則需要比較鏈表頭部x和v的大小,如果x < v,那麼將鏈表尾部作爲L繼續調用當前函數,然後將x和結果連接起來;如果 x >= v,那麼將v和L連接起來返回。

這樣我們就很容易可以寫出簡單遞歸第一個版本:

def insert1(l: List[Int], v: Int): List[Int] = l match {
  case List() => List(v)
  case x :: xs => 
    if (x < v) x :: insert1(xs, v)
    else v :: l
}

這個版本很簡單也很直觀,但是很可惜它不是尾遞歸的。當x < v的時候,遞歸調用之後又執行了一次cons(連接鏈表頭部和尾部)。

那麼如何將函數改成尾遞歸版本呢?瞭解“n的階乘”的實現的話很容易可以猜到使用helper function和accumulator(幫助函數和累加器)。但是對於鏈表插入這個問題還稍微有點複雜:因爲accumulator一定是一個鏈表,而鏈表只能在頭部進行操作,所以如果accumulator的排序和原順序一樣的話,每次處理一個元素就需要將這個元素插入到鏈表尾部,這個是不現實的。所以結論是accumulator需要逆序排列,一旦找到插入位置再將其逆序,這樣我們就得出了使用數據累加器的第二個版本的實現:

def insert2(l: List[Int], v: Int): List[Int] = {
  def concatReversePrefix(l: List[Int], prefix: List[Int]): List[Int] = prefix match {
    case List() => l
    case x :: xs => concatReversePrefix(x :: l, xs)
  }
  def insertRec(l: List[Int], prefix: List[Int]): List[Int] = l match {
    case List() => concatReversePrefix(List(v), prefix)
    case x :: xs => 
      if (x < v) insertRec(xs, x :: prefix)
      else concatReversePrefix(v :: l, prefix)
  }
  insertRec(l, List())
}

第二個版本使用了尾遞歸,但是在找到插入位置之後還需要將prefix進行逆序,算法不是很直觀,那到底能不能不使用鏈表逆序完成題目呢?結論是能,但是需要用到另外一種技術,函數累加器。思路是這樣的,我們在處理當前位置時需要將之前迭代過的節點記錄到accumulator中,第二個版本中使用了鏈表這樣的表現形式,但是由於鏈表處理的特殊性我們只能逆序排列,接下來這個版本也將迭代過的節點記錄到accumulator中,但是是以函數的方式,所以叫做函數累加器。實現如下:

def insert3(l: List[Int], v: Int): List[Int] = {
  def insertRec(l: List[Int], f: (List[Int]) => List[Int]): List[Int] = l match {
    case List() => f(List(v))
    case x :: xs =>
      if (x < v) insertRec(xs, {l => f(x :: l)})
      else f(v :: l)
  }
  insertRec(l, {l => l})
}

將{l => l}作爲初始函數累加器傳入,一旦發現插入位置就將v插入並執行函數累加器,累加器的作用就是持續插入之前的元素;否則就構造新的累加器{l => f(x :: l)}遞歸調用。如果不太瞭解函數累加器的思路,第三個版本需要一些時間來消化。但是一旦理解並掌握了這種技術,實現尾遞歸的能力就大大增強了。

總結一下,本文對一個經典的問題“鏈表插入元素”進行剖析,解釋了尾遞歸的含義以及作用。並介紹了三種遞歸函數的實現:簡單遞歸,數據累加器,函數累加器以及分析問題的思路。理解並掌握尾遞歸的實現方法和設計思路是函數式編程中必不可少的學習環節,希望這篇文章可以幫助更多的人瞭解尾遞歸的技術。

PS:文章中使用Int作爲List的元素類型,源代碼中附有更通用的版本,有興趣的讀者可以作爲參考。





發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章