链表插入元素的三种递归实现 -- 简单递归,数据累加器,函数累加器

函数式编程的一个强大之处在于递归,使用递归可以简化算法设计的思路。

尾递归是递归的一种特殊形式,它的特点是可以不创建新的堆栈帧而是改变当前堆栈帧来实现,这样做的好处是不会浪费运行时空间,递归层次加深也不会发生栈溢出,如同执行迭代一样。

如何将非尾递归函数改写成尾递归函数是函数式编程的一项重要的基本功,而最简单的题目就是“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的元素类型,源代码中附有更通用的版本,有兴趣的读者可以作为参考。





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