深入理解递归

  • 1. 递归是什么?
  • 查词典
  • 头递归和尾递归
  • 案例1:求N的介乘
  • 2. 递归与分治法
  • 3. 解递归题的关键
  • 4. 解决递归题的套路
  • 5. 诀窍
  • 案例2:反转链表
  • 案例3:跳台阶
  • 案例4:反转二叉树
  • 参考链接


1. 递归是什么?

在数学和计算机科学中,递归是指在函数的定义中调用自身函数的方法。

查词典

为了更加理解什么是递归。 我们可以把日常生活中使用词典的过程看做递归。 即为了解释一个词,需要使用更多的词。

比如:当你查一个词时,发现这个词的解释中某个词依然不懂,于是你开始查这第二词。可惜,第二词的解释中仍然有不懂的词,于是查第三个词,(递): 这样查下去,直到有个词的解释你是完全能看懂的,那么递归走到了尽头,然后你开始后退 (归),逐个明白之前查过的每一个词。最终,你明白了最开始那个词的意思。

头递归和尾递归

按照递归的使用方法,递归分成两类:1. head recursion; 2. tail recursion.

  • 头递归(Head Recursion)
    默认情况下,我们都用头递归。 在调用函数自身后,才做其他计算操作。

这需要把函数调用和计算结果缓存到栈中,要使用更多内存空间。

所以头递归比循环要慢两倍左右,性能更慢。它需要把原问题先挨个展开成子问题,直到遇到底部。然后回溯,合并子问题的结果。

  • 尾递归(Tail Recursion)
    在递归函数内,先做计算操作,在函数末尾调用函数自身。类似于循环遍历,所以性能更高。下面看看一个案例,初步认识什么是递归函数。

案例1:求N的介乘

求N的介乘
输入: n = 3
输出:n = 3 * 2 * 1 = 6

输入: n = 5
输出: n = 5 * 4 * 3 * 2 * 1 = 120
子问题:大小相邻数相乘。
用头递归和尾递归分别解答这个题目。代码如下:

package Recursion;

public class HeadAndTailRecursion {
    public static void main(String[] args) {
        System.out.println(recursionHead(3));

        System.out.println(recursionTail(3, 1));
    }

    /**
     * Head recursion 递归过程
     * 递:
     * //1
     * //   2* recursionHead(1)
     * //      3 * recursionHead(2)
     * <p>
     * 归:
     * //    2 * 1
     * //  3 * 2
     * //6
     *
     * @param N
     * @return
     */
    private static int recursionHead(int N) {
        if (N <= 1) {
            return 1;
        }
        int nextValue = recursionHead((N - 1)); // 1. calling the method recursively.
        return N * nextValue; //2. Do some operations.
    }

    private static int recursionTail(int N, int result) {
        if (N == 1) {
            return result;
        }
        int updateResult = N * result; // 1. Do some operations.
        return recursionTail(N - 1, updateResult); // 2. calling the method recursively.
    }
}

2. 递归与分治法

递归算法可以实现分而治之的思想。这意味着递归函数是基于下面两个步骤设计出来

1.自顶向下拆分问题

2.自底向上逐层解决问题

因此,递归解决问题时,先有拆分问题的过程, 其次,需要在拆分到底之后,一层一层向上返回,才能真正解决问题。

3. 解递归题的关键

递归有两个特点:

  1. 可分解。一个问题可以分解为具有相同解决思路的子问题,子子问题,换句话说这些问题都能调用同一个函数。
  2. 有终点。经过层层分解的子问题,最后一定有一个不能分解的固定值,即终止条件。如果没有,则无穷无尽的分解子问题了,问题显然是无解的。

所以解递归题的关键,是根据以上递归的两个特点:可递归,有终点,来判断题目是否可以用递归来解。

4. 解决递归题的套路

递归问题虽然复杂,但是一旦确认题目可以用递归来求解,那它也有一套专门规律可循。 我们继续看看解决递归问题的套路。

分为四部曲:

  1. 定义函数,明确这个函数的功能。由于递归的特点是问题和子问题都会调用函数自身,所以这个函数的功能一旦确定了,之后只需找寻问题与子问题的递归关系即可。
  2. 定义递推公式,找到问题与子问题的关系。这样由于问题与子问题具有相同解决思路,因此只要子问题调用步骤1定义好的函数,问题即可解决。所谓的关系最好能用一个公式表示出来,如fn=n*f(n-1),如果暂时无法得出明确的公式,则用伪代码表示也是可以。
  3. 发现递推关系后,要寻找最终不可再分解的子问题的解,即临界条件,确保子问题不会无限分解下去。由于已经定义好了这个函数的功能,所以当问题分解成子问题时,子问题可以调用步骤1定义的函数,即符合递归的条件:函数调用自身。
    在步骤1中的定义函数中用代码实现递推公式。
  4. 计算时间复杂度。根据问题与子问题的关系,推导出时间复杂度。如果发现递归的时间复杂度不可接受,则需转换思路对其进行改造,看是否有更靠谱的解法。

5. 诀窍

要掌握递归,没有捷径可走,最好的办法是多练习。

案例2:反转链表

输入链表:1 -> 2 -> 3 -> 4 -> 5 -> null

输出结果: 5 -> 4 -> 3 -> 2 -> 1 -> null

发现重复的子问题:当前节点和前一个节点进行反转。

暴力递归遍历链表,当遇到节点的next为空,则返回尾节点。
在递归的回溯过程中(把当前节点与前一个节点进行反转),回归到原链表的首部节点,指针指向null。具体演示过程如下:

递:
recursion(1,null)
    recursion(2, 1)
      recursion(3, 2)
        recursion(4, 3)
          recursion(5, 4)
            recursion(null, 5)
归:
         5->4
        4->3
      3->2
   2->1
1->null

返回:Node(5)

从上图推导过程,可得递归函数recursion(cur, pre)

private ListNode recursion(ListNode cur, ListNode pre) {
        if(cur == null) {//终止条件
                return pre;
        }

        ListNode res = recursion(cur.next, cur);//分解子问题,推导递推公式。
        cur.next = pre;
        return res;
}

案例3:跳台阶

案例4:反转二叉树