递归,怎样读或写一个递归函数

1,683 阅读11分钟

我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第6篇文章,点击查看活动详情

大家应该都听过这么一个故事:

从前有座山,山上有座庙,庙里有个老和尚和一个小和尚,老和尚在给小和尚讲一个故事,故事是什么呢?故事就是:从前有座山,山上有座庙,庙里有个老和尚和一个小和尚,老和尚在给小和尚讲一个故事,故事是什么呢?故事就是:从前有座山,山上有座庙,庙里有个老和尚和一个小和尚,老和尚在给小和尚讲一个故事,故事是什么呢?故事就是:.....

也都应该经历过这么一个场景:

有一些理发店里面呢,会有两个镜子相对的情况,镜子A里面可以看到镜子B,镜子B里面又可以看到镜子A,镜子A跟镜子B之间互相反射的循环反反复复的无穷无尽。

我认为这可以算是现实世界中的递归现象。当然了,现实世界中类似于这样的现象并不少见。我觉得发明了递归算法的人一定也是在这其中的某一个场景或者是某一个故事下获得的灵感。

毕竟,计算机科学就是一种对于现实世界的抽象。


学习计算机的同学所接触到的第一个递归算法应该都是递归求阶乘或者是数组求和这样的算法。虽然求阶乘或者是求和这只是很简单的递归代码,但是递归算法该有的,它也一样都没有落下。其实这样的功能只需要一层简单的循环就可以搞定,递归算法在这里反而是降低了代码的可读性,并没有为我们带来实际上的好处。

这里并不是不鼓励同学们使用递归来编写逻辑代码,而是希望不要过度设计自己的代码,还是那句话,刚刚好的才是最美:)

递归

百度百科:

程序调用自身的编程技巧称为递归( recursion)。递归做为一种算法在程序设计语言中广泛应用。 一个过程或函数在其定义或说明中有直接或间接调用自身的一种方法,它通常把一个大型复杂的问题层层转化为一个与原问题相似的规模较小的问题来求解,递归策略只需少量的程序就可描述出解题过程所需要的多次重复计算,大大地减少了程序的代码量。递归的能力在于用有限的语句来定义对象的无限集合。

一般来说,递归需要有边界条件、递归前进段和递归返回段。当边界条件不满足时,递归前进;当边界条件满足时,递归返回。

结合一下递归求阶乘的代码理解一下上面百度百科的这段话,特别是最后一句话。

/**
 * 递归求阶乘
 */
public static int factorial(int n) {
    if (n == 1)
        return 1;
    return n * factorial(n - 1);
}

这是一个简单的递归代码,但是麻雀虽小,却也五脏俱全。在这个例子中,边界条件、递归前进段和递归返回段都是很明确的。

  • 边界条件: n == 1
  • 递归前进段: 每次调用 factorial(n - 1) 时递归算法就前进一个函数单位
  • 递归返回段:n-1减到n==1的时候,也就是if条件成立返回1的时候,factorial(n - 1)开始返回,每次返回,递归就结束一次factorial(n - 1)函数的调用,也就是返回一个函数单位。

简单一些来理解的话:递归无非就是一个满足了 直接或者是间接的调用自身、以及在某个时候会结束对自身调用 这两个条件的函数,仅此而已。

如果要我一句话来描述递归算法的话,我会说:比起循环,这是一种更能够提高代码可读性的循环。

我认为递归所能完成的逻辑,换成循环来做这样的事情甚至效率上还要更高一些,因为循环并不需要额外的方法调用的成本。但是在某些复杂的场景下,使用递归会让我们的代码变得非常简洁易于理解,比如:二叉树的前、中、后序的遍历;求一个二叉树的节点个数等等。

但是递归也有一个非常明显的缺点,那就是几乎所有的编程语言的函数调用栈都有一个明确的大小,这就导致了递归函数的调用层数不能超出这个大小,否则会产生一个栈内存的溢出,在Java中这是一个叫做StackOverflowError的异常。


递归函数的宏观语义

递归的本质实际上就是将一个问题不断的拆解成一个一个的小的问题,直到最后的一个问题小到不能再小了,这个时候我们可以很容易的解决这个问题,将这个问题的解决结果返回给上一个问题,解决掉上一个问题之后再解决上上个问题以此类推,最终解决我们的问题。拆解问题的过程也就是递归前进段,解决掉最基本问题开始返回的阶段也就是递归返回段。

更多的时候我喜欢把递归函数理解成一个单元,也就是递归单元。因为递归函数调用自身后还是走一遍自身的逻辑,每次递归调用在逻辑上并没有产生任何的变化,相应的我们只是改变了每次调用的参数而已。将递归函数拆解成的一个一个的小问题的这个步骤,就体现在每次调用递归函数的参数列表都要比上一次的参数列表要更简单一点点。

很多时候编写递归逻辑的时候都是因为递归调用自身的这个动作会令我们感到很迷惑,没有办法理解到这一步操作是怎么执行的。我第一次接触二叉树前序遍历的代码的时候就是倒在了这一步。

/**
 * 二叉树前序遍历
 */
public void prevOrder(Node node) {
    if (node == null) return;

    System.out.print(node); // 7
    prevOrder(node.left);   // 8
    prevOrder(node.right);  // 9
}

我刚刚开始看这代码的时候我完全无法理解为什么上面这段代码为什么会以中、左、右的方式输出一个二叉树,我完全不能够明白这段代码的逻辑,甚至不知道什么时候第9行代码什么时候会执行。

原因是我自己的思维跟着递归函数一起被递归进去了,一直试图去理解每次递归函数调用之后发生了什么事情,但实际上这完全没必要,因为递归函数的逻辑其实一直没变,就像是开头的哪个老和尚讲故事一样。

但是,如果能从宏观的角度来看这段代码,就舒服多了:

  1. 明白我们写的递归函数是要完成一个什么样的功能。
  2. 不要去研究递归进去的逻辑是什么样的,仅仅只看第一层逻辑。因为递归进去的逻辑跟第一层的逻辑是完全一样的。

如果要把 prevOrder 这个递归函数所做的事情理解成一个递归单元的话,我们只需要整理出这个函数所要实现的功能就可以理解这个递归函数的逻辑。只需要整理下面的几个问题即可:

  • Q:prevOrder这个函数的功能是什么?
  • A:按照中、左、右的顺序输出一棵二叉树。
  • Q:第7行的逻辑是什么?
  • A:输出当前节点。
  • Q:第8行的逻辑是什么?
  • A:按照中、左、右的顺序输出当前节点的左子树。
  • Q:第9行的逻辑是什么?
  • A:按照中、左、右的顺序输出当前节点的右子树。

看到这里如果还不能够明白这个递归逻辑的话,我建议在纸上画一棵高度为3,7个节点的满二叉树然后按照上面 Q&A 再结合代码依次访问一下这棵二叉树上的各个节点,你也许能更加直观的感受什么是递归的宏观语义。

我所理解的递归函数的宏观语义是:

  • 将一个大的问题一点一点的拆分成许多个小的问题,或者说是抽象成多个小问题,每一个问题就是一个递归单元,每一个递归单元在逻辑上都是相同的,唯一不同的只是参数列表。

  • 在这个例子中,前序遍历当前节点、前序遍历当前节点的左子树、前序遍历当前节点的右子树,这是完完全全相同的逻辑。

  • 当我们能够按照这种思维来解读递归代码的话,我们可以完全不关心递归调用里面的逻辑是怎样的,递归调用也就可以理解成为一种普通的函数调用。


如何编写一个递归程序

借助力扣第206号问题(反转链表)来讲述如何梳理出递归函数的宏观语义以及怎么编写递归函数。

给你单链表的头节点 head 请你翻转链表,并返回反转后的链表
示例一:
输入:head = (1) -> (2) -> (3) -> (4) -> (5) -> NULL
输出:head = (5) -> (4) -> (3) -> (2) -> (1) -> NULL
示例二:
输入:head = (1) -> (2) -> NULL
输出:head = (2) -> (1) -> NULL
示例三:
输入:head = (1) -> NULL
输出:head = (1) -> NULL
示例四:
输入:head = NULL
输出:head = NULL

如果一个问题已经明确了要使用递归来求解的话,我们第一步要做的就是找出这个拆解后最基本的那个问题,对于反转链表的这个问题来说,显而易见最基本的问题就是当链表为空或者是链表只有一个节点的情况。一个空链表反转过后还是一个空链表,一个只有一个节点的链表反转过后还是只有一个节点。找出最基本的问题只是递归函数中最基础的部分,如果连最基本的问题都找不出来的话,那么几乎可以断定这个问题无法使用递归求解。

完成了最基本的部分接下来才是一个如何编写一个递归函数的重头戏,将递归函数单元化,也就是找出这个递归函数的宏观语义,清楚这个递归函数要表达什么。将递归函数看作是一个单元之后其实就不需要再考虑我们要求解的这个问题具体是什么了,解决问题的重点就放在了怎么解决一个一个拆解出来的小问题了。

这里我再啰嗦一下解释一下示例一这个例子:

  • 反转前:head 的值为1,指向了值为2的节点,值为2的节点指向了值为3的节点...最终指向NULL
  • 反转后:head 的值为5,指向了值为4的节点,值为4的节点指向了值为3的节点...最终指向NULL

假如我们现在处于返回段的 (3) 这个节点,结合递归函数的宏观语义来看一下此时链表的状态。前面的 (1)(2) 节点是没有变化的,因为我们现在处于 (3) 这个节点,还没有反转到 (1)(2) 。但是对于已经反转过的 (4)(5) 来说,应该是 NULL <- (4) <- (5) 这个样子的。而此时的 (3) 这个节点仍然是指向了 (4) 这个节点的。

好了,分析到此为止,答案已经是显而易见的了。当我们处于 (3) 这个节点的时候,我们只需要将 (3) 这个节点当作是已经反转完成的链表的下一个待反转节点再将 (3) 这个节点反转过来就可以了。

/**
 * Definition for singly-linked list.
 * public class ListNode {
 *     int val;
 *     ListNode next;
 *     ListNode() {}
 *     ListNode(int val) { this.val = val; }
 *     ListNode(int val, ListNode next) { this.val = val; this.next = next; }
 * }
 */
class Solution {
    public ListNode reverseList(ListNode head) {
        if (head == null || head.next == null)  // 最基本的问题
            return head;

        ListNode rev = reverseList(head.next);  // 得到已经反转完成的链表
        head.next.next = head;                  // 将当前节点挂接在已经反转完成的链表的下一个节点,注意:head.next此时指向的是已经反转完成的链表的尾节点。
        head.next = null;                       // 执行到这一步的时候,head已经挂载到反转完成链表的尾节点的位置了,但是链表尾节点必须指向null,所以令 head.next = null
        return rev;                             // 返回已经反转完成的链表
    }
}

上面的这段代码也就是力扣上第206号问题的答案,有兴趣的同学可以将代码复制到力扣上第206号问题的解答区中去验证结果。