如何理解递归

基本思想

写好递归要掌握几个技巧:

1 明确递归函数的作用,将递归函数看作一个黑盒

我自己把该技巧称为黑盒思想,我认为黑盒思想对于理解递归有很大的作用,递归函数就是隐藏了很多细节,我们没必要去一步一步地模拟递归函数的运行,那样大脑也受不了。

比如最简单的阶乘函数,我们定义一个函数int Factorial(int n),规定此函数的作用就是输入一个整数,返回该数的阶乘,我们解题的过程中需要时刻保持这种思想。

2 明确递归结束的条件

递归另外一个重要的因素就是要明确递归的结束条件,我们知道,如果递归无法正常终止,最终会导致栈溢出,所以明确递归结束的条件是很重要的,一般我们在找这个条件的时候都要寻找这么一个情况:在该参数下该函数可以返回确切的值,一般我们需要寻找某些简单情况,这些情况下可以直接获取返回值。

例如,还是阶乘的例子,我们知道1的阶乘是1,2的阶乘是2,那么我们就可以得出如果n等于1或者2,就可以直接返回n的值,那么该函数就变成了:

int Factorial(int n) {
    if (n <= 2) return n;
    ...
}

3 缩小递归范围,获得等价条件

我觉着这是递归中最难的部分了,说是要确定等价条件,其实有的时候等价条件很好找,但是有些情况下并不是很明显的等价条件,一般我会分为两种问题,一种是可以很容易推导出公式的,这种问题推出公式来问题也就基本解决了,另外一种是寻找缩小递归范围的办法,使递归不断趋近递归结束条件(其实第一种也是为了达到该目的)。这一部分需要不断做题积累总结,同时黑盒思想在此处充分体现出来了。

完善阶乘的例子:

我们知道,阶乘的公式是 f ( n ) = n ! f(n)=n! f(n)=n!,容易得出 f ( n − 1 ) = ( n − 1 ) ! f(n-1)=(n-1)! f(n1)=(n1)! f ( n ) = n ( n − 1 ) ! f(n)=n(n-1)! f(n)=n(n1)!,那么最终得到的公式就是 f ( n ) = n f ( n − 1 ) f(n)=nf(n-1) f(n)=nf(n1),这里的f(n)就是等价于第一步我们确定的函数Factorial(int n),那么该函数就变成了:

int Factorial(int n) {
    if (n <= 2) return n;
    return n * Factorial(n - 1);
}

现在的函数就和我们一开始推出的公式是一个意思了,向一个作用为输入一个正整数得到该数的阶乘的函数中输入参数n,根据刚刚得到的公式返回获得的结果,每次计算都会将递归范围减小,直到达到结束条件。

题目整理

下面的题目都是在刷剑指offer和LeetCode中遇到的可以用递归求解的几个题目,挑了几个典型的题目,难度在我看来是按照顺序递增的。

  1. 大家都知道斐波那契数列,现在要求输入一个整数n,请你输出斐波那契数列的第n项(从0开始,第0项为0)。

斐波那契数列的公式是 f ( n ) = f ( n − 1 ) + f ( n − 2 ) f(n) = f(n-1) + f(n-2) f(n)=f(n1)+f(n2),那么我们只需要确定递归的结束条件。f(0)=0,f(1)=1,f(2)=f(1)+f(0)…所以当n<=1时,输入n返回n,那么我们就可以解出改题目了。

实现:

int Fibonacci(int n) {
    if (n <= 2) return 1;
    return Fibonacci(n - 1) + Fibonacci(n - 2);
}
  1. 一只青蛙一次可以跳上1级台阶,也可以跳上2级。求该青蛙跳上一个n级的台阶总共有多少种跳法(先后次序不同算不同的结果)。

这道题目最重要的就是推出公式了,我们可以先试着模拟几次,如果n=1,只有一个台阶,f(1)=1,同样地,f(2)=1。当有三个台阶,即n=3时候,要分两次跳,第一次跳有两种选择1或2,如果第一次选1,那么还剩两节台阶,问题就回到了f(2),第二次就只有一种跳法;如果第一次选2,那么还剩一节台阶,问题回到f(1),第二次只有一种跳法,故f(3)=f(2)+f(1)。所以,当跳n级台阶时,第一次有两种跳法,如果第一次选择了1,那么有f(n-1)中跳法;如果第一次选择了2,那么有f(n-2)种跳法,总共有f(n-1)+f(n-2)种跳法,公式为f(n)=f(n-1)+f(n-2)。

结束条件也很明显了,n<=2时,返回n。

实现:

int JumpFloor(int target) {
    if (target <= 2) return target;
    return JumpFloor(target - 1) + JumpFloor(target - 2);
}
  1. 一只青蛙一次可以跳上1级台阶,也可以跳上2级……它也可以跳上n级。求该青蛙跳上一个n级的台阶总共有多少种跳法。

根据上题容易推出 f ( n ) = f ( n − 1 ) + f ( n − 2 ) + . . . + f ( 0 ) f(n)=f(n-1)+f(n-2)+...+f(0) f(n)=f(n1)+f(n2)+...+f(0)

f ( n − 1 ) = f ( n − 2 ) + f ( n − 3 ) + . . . + f ( 0 ) f(n-1)=f(n-2)+f(n-3)+...+f(0) f(n1)=f(n2)+f(n3)+...+f(0)

两式相减,得 f ( n ) = 2 f ( n − 1 ) f(n)=2f(n-1) f(n)=2f(n1)

实现:

int JumpFloor(int target) {
    if (target <= 2) return target;
    return 2 * JumpFloor(target - 1);
}

  1. 输入一个链表,反转链表后,输出新链表的表头。

对于此题来说,黑盒思想就很重要,首先明确此函数的作用,此函数的作用是输入一个链表的头节点,之后对链表进行反转,返回反转后新链表的头节点。

首先,明确结束条件,同样我们可以将某些简单情况作为结束条件,当head==null或者head.next==null(只有一个节点)时,反转链表的结果就是直接返回head,那么结束递归的条件就是if (head == null || head.next == null) return head;

最后一步就是寻找等价关系,缩小递归范围,我们知道该函数的返回值是反转之后的新节点,这个新节点就是旧链表的尾节点,要想一步步逼近答案,显然需要一步步向后搜索,递归部分就是reverse(head.next),先将这一句加进代码中去:

Node reverse(Node head) {
	if (head == null || head.next == null) return head;
    Node newNode = reverse(head.next); // 暂时不return,先用变量储存
}

reverse(head.next)的作用即为输入头节点的下一个节点,并且将头节点后面的节点都反转,如下图所示,第二个链表就是执行reverse(head.next)之后的变化。

(可能到这里会有疑问了,我们并没有写任何将链表反转的逻辑,链表不可能自己就反转了,确实,到目前为止没有任何实际代码可以将链表反转,但是递归的一个特点就是省略了细节,而且一定要记住黑盒思想,将递归函数看作黑盒,即使函数看起什么都没有做,也要从心底里面让自己相信它现在就起了这个功能,否则你的大脑就爆栈了)

如何理解递归_第1张图片

下一步观察上图中第二个链表,此时该链表并没有反转完毕,旧链表的头节点还没有变化,下面我们需要做的就是就要将链表完全反转。让图中2节点的next指针指向1节点,1节点的next指针指向空就可以了,最后我们再将刚刚储存的新链表的头节点返回就可以了。最终的链表就是这样子的:

如何理解递归_第2张图片

最后,完整代码如下:

Node reverse(Node head) {
    if (head == null || head.next == null) return head;
    Node newNode = reverse(head.next);
    head.next.next = head;
    head.next = null;
    return newNode;
}
  1. 输入两棵二叉树A,B,判断B是不是A的子结构。(ps:我们约定空树不是任意一个树的子结构)

关于什么是数的子结构,可以自行百度。

在一开始做这个题的时候,我的想法既然前序序列和中序序列就可以唯一确定一棵二叉树,那么通过分别前序遍历和中序遍历去得到两个数的遍历序列,然而这种方法是行不通的,很容易找出反例,不要用这种方法去判断。

整个算法过程应该是这样的:首先判断两个树的根节点是否相等,如果相等,就继续判断两个树的左孩子、右孩子…否则,直接结束判断对A树的根节点的左孩子执行上面的判断。

此题需要分为两个过程:一个是对A树不断遍历,一个是对AB树的节点进行逐一判断。

首先规定函数boolean HasSubtree(TreeNode root1,TreeNode root2)的作用为输入树A、B的根节点,如果B是A的子结构,返回true,否则返回false。

第二步,确定递归结束条件,题目中提到,约定空树不是任意一个树的子结构,那么可以从这里入手,如果A、B有任何一个为空,直接返回false。实际上,此处的节点为空还包含着另一个语义,就是递归遍历结束,下面也会用到这个条件。现在函数就变成了:

boolean HasSubtree(TreeNode root1,TreeNode root2) {
    if (root1 == null || root2 == null) return false;
}

最后确定该函数的主体部分,我们先不写出另一个递归判断过程的函数,先将第一个递归函数的架子搭好,在本题中,缩小递归范围的方法就是遍历,放在本题中对二叉树的遍历就是判断完根节点判断左孩子、右孩子,如果根节点通过了判断,就返回true,没有通过,就递归判断左孩子…代码如下(利用了逻辑运算符的短路运算):

boolean HasSubtree(TreeNode root1,TreeNode root2) {
    if (root1 == null || root2 == null) return false;
    return judge(root1, root2) || HasSubtree(root1.left, root2.left) || HasSubtree(root1.right, root2.right);
}

下面就是补全boolean judge(TreeNode root1,TreeNode root2)方法了,该方法的作用实际上是输入两个二叉树AB,只比较根节点,判断B是否是A的为子结构。

首先,确定递归结束条件,因为此处AB树的都不可能为空了,所以第一个递归函数的判断条件就不适用了,由于仍然需要对树进行递归判断,我们可以通过遍历结束时候的情况作为结束条件,很容易得出:如果A树先遍历完毕即root1==null,那么此时应该返回false;如果B树先遍历完毕即root2==null,那么此时应该返回true(实在理解不了画图分析一下)。

下面,补全剩下的部分,如果两个树当前的节点不相等,直接返回false;如果相等,递归判断左子树、右子树,如果都相等,返回true。

完整代码如下:

boolean HasSubtree(TreeNode root1,TreeNode root2) {
    if (root1 == null || root2 == null) return false;
    return judge(root1, root2) || HasSubtree(root1.left, root2.left) || HasSubtree(root1.right, root2.right);
}

boolean judge(TreeNode root1,TreeNode root2) {
    if (root2 == null) return true;
    if (root1 == null) return false;
    if (root1.val == root2.val) 
        return judge(root1.left, root2.left) && judge(root1.right, root2.right);
    else return false;
}

理解上面几个题目后,递归差不多就可以入门了,我总结的办法不一定适用于所有的递归题目,不可生搬硬套,多做题,多总结经验和方法。

参考资料:

https://www.zhihu.com/question/31412436/answer/683820765

https://zhuanlan.zhihu.com/p/86745433

你可能感兴趣的:(如何理解递归)