聊聊递归与尾递归——仍然以C语言为例

前言

大约半个月前,我在《深入理解栈内存与函数调用栈——以C语言为例》这篇文章的结尾给自己挖了个坑。鉴于我挖了没管的坑已经两只手都数不过来了,所以是时候填一填了。

看官可以先食用之前那篇文章,以获得关于函数调用栈的背景知识。

递归(recursion)

递归并不是一个程序设计领域专属的概念,有很多其他丰富的例子:

  • 德罗斯特效应(Droste effect),即与原图相同的图重复嵌套出现,得名于1904年出产的Droste牌可可粉包装。
  • 俄罗斯套娃(Matryoshka),下图是一个已经打开到最内层的套娃。
  • 西尔平斯基三角形(Sierpinski triangle),等边三角形的不断细分。
聊聊递归与尾递归——仍然以C语言为例_第1张图片
  • GNU = "GNU's not Unix",GNU = "GNU's not Unix",……

  • 从前有座山,山上有座庙,庙里有个老和尚正在讲故事,讲的什么呢?从前有座山,山上有座庙,庙里有个老和尚正在讲故事,讲的什么呢?从前有座山,山上有座庙………(泥垢

当然,递归也是我们入门程序设计时很早就会接触的概念,一句话:函数直接或间接地调用自身。在大学计算机课程初期,递归也贯穿在整个学习过程中,从比较简单的阶乘、斐波那契数列、辗转相除法,到回溯法、树的遍历、深度优先搜索等等。

以最简单的斐波那契数列为例,它的递归描述如下:

int fibonacci(int n) {
  assert(n >= 0);
  if (n == 1 || n == 2) {
    return 1;
  }
  return fibonacci(n - 1) + fibonacci(n - 2);
}

当然,我们也有对应的迭代描述:

int fibonacci(int n) {
  assert(n >= 0);
  if (n == 1 || n == 2) {
    return 1;
  }
  int f1 = 1, f2 = 1, result = 0;
  for (int j = 3; j <= n; j++) {
    result = f1 + f2;
    f2 = f1;
    f1 = result;
  }
  return result;
}

说白了,递归的思想就是“大事化小,小事化了”,将原问题逐层拆解为规模较小的子问题,直至遇到递归终止条件再逐层返回。设定正确的递归终止条件很重要,否则永远得不出结果,妥妥造成栈溢出。

既然问题的递归解法都有等价的迭代解法,那么递归还有什么存在的意义呢?别忘了,迭代法要求我们必须自己记录下每个子问题的解(递归的话就是函数调用栈的栈帧帮我们记录了),对于复杂的递归问题还要引入辅助数据结构来转化为非递归的,思路比较绕,并且代码会膨胀,递归解法则简明又方便。

斐波那契数列的例子太naive,只是多用了两个变量而已。下面换一道已经被问烂了的面试题,就能看出更明显的差别。

输入一棵二叉树,求该树的深度。深度指从根节点开始到叶子节点的最长路径的长度。

(二叉)树就是一种经典的自然递归的数据结构,所以该题的递归解法非常straightforward,代码如下。

int treeDepth(BiTreeNode *root) {
  if (root == null) {
    return 0;
  }
  int dLeft = treeDepth(root->left);
  int dRight = treeDepth(root->right);
  return (dLeft > dRight) ? (dLeft + 1) : (dRight + 1);
}

如果不用递归就麻烦一些,下面给出一种基于层序遍历的思路,需要引入队列。

int treeDepth(BiTreeNode *root) {
  if (root == null) {
    return 0;
  }
  int depth = 0;
  queue q;
  while (!q.empty()) {
    depth++;
    int size = q.size();
    while (size--) {
      BiTreeNode *node = q.front();
      q.pop();
      if (node->left != null) {
        q.push(node->left);
      }
      if (node->right != null) {
        q.push(node->right);
      }
    }
  }
  return depth;
}

虽然仍然不难理解,但代码确实多了不少哈。

例子举完了。由于递归涉及到非常密集的函数调用和返回,故维护栈帧也间接造成了大量的入栈和出栈操作,这就是一般情况下同一问题的递归解法比迭代解法执行效率低的原因。但凡事不是绝对的,如果我们能把普通递归优化成尾递归,效率就会大大提升,下面具体看看。

尾递归(tail recursion)

所谓尾递归,就是指函数在最末尾的一条语句调用自身,并且不能出现调用自身之外的其他表达式逻辑。以递归计算阶乘的函数为例:

int fact1(int n) {
  assert(n >= 0);
  if (n == 0 || n == 1) {
    return 1;
  }
  return n * fact1(n - 1);
}

这种写法就不是尾递归,因为递归调用虽然出现在末尾,但本次调用的结果需要依赖n乘以下次调用的结果,不是单纯的调用。下面这种写法就是尾递归:

// m initialized as 1
int fact2(int n, int m) {
  assert(n >= 0);
  if (n == 0) {
    return 1;
  } else if (n == 1) {
    return m;
  }
  return fact2(n - 1, n * m);
}

通过多引入一个参数m来记录当前调用的结果,就可以将函数调用外部的乘n操作消灭掉。

为什么尾递归比普通递归的效率要高呢?我们以计算5的阶乘为例,写出fact1()函数执行时栈空间的变化如下。

// 递归阶段,栈帧逐渐增多
f(5)
5 * f(4)
5 * [4 * f(3)]
5 * [4 * [3 * f(2)]]
// 递归终止,此时有f(5)~f(1)共5个栈帧
5 * [4 * [3 * [2 * f(1)]]]  
// 递归返回
5 * [4 * [3 * [2 * 1]]]
5 * [4 * [3 * 2]]
5 * [4 * 6]
5 * 24
// 得出结果
120

在普通递归的情况下,这段程序创建并销毁了5个栈帧。而fact2()函数执行时栈空间的变化如下。

// 始终只有1个栈帧
f(5, 1)
f(4, 5)
f(3, 20)
f(2, 60)
f(1, 120)
// 得出结果
120

也就是说,因为尾递归不再依赖除了函数自身之外的其他逻辑,所以也就没有必要维护每一层的EBP指针地址和返回地址了。编译器会自动优化尾递归的情况:不再每递归调用一次产生一个栈帧,而是在原有栈帧的基础上直接修改,这比普通递归的效率无疑要高很多了。

The End

今晚初雪,部门出来聚餐,喝酒吃肉,好不舒适。

本文的最后几段是在回家的出租车上用手机写完的,希望没错吧。

民那晚安。

你可能感兴趣的