浅尝递归思路求解

什么是递归:

最近在迫于求职的压力,刷刷剑指,遇到一些关于递归的问题将其整理一下。
递归在算法中是一种非常重要的思想,就是在函数中调用函数本身的情况,以简单的阶乘函数为例,在factorial(n)函数中存在着factorial(n-1)

public int factorial(int n) {
    if (n < =1) {
        return 1;
    }
    return n * factorial(n - 1)
}

进一步剖析「递归」,先有「递」再有「归」,「递」的意思是将问题拆解成子问题来解决, 子问题再拆解成子子问题,…,直到被拆解的子问题无需再拆分成更细的子问题(即可以求解),「归」是说最小的子问题解决了,那么它的上一层子问题也就解决了,上一层的子问题解决了,上上层子问题自然也就解决了,…,直到最开始的问题解决,文字说可能有点抽象,那我们就以阶层 f(6) 为例来看下它的「递」和「归」。

在这里插入图片描述
求解问题 f(6), 由于 f(6) = n * f(5), 所以 f(6) 需要拆解成 f(5) 子问题进行求解,同理 f(5) = n * f(4) ,也需要进一步拆分,… ,直到 f(1), 这是「递」,f(1) 解决了,由于 f(2) = 2 f(1) = 2 也解决了,… f(n)到最后也解决了,这是「归」,所以递归的本质是能把问题拆分成具有相同解决思路的子问题,。。。直到最后被拆解的子问题再也不能拆分,解决了最小粒度可求解的子问题后,在「归」的过程中自然顺其自然地解决了最开始的问题。

递归算法的通用解决思路:

由上面可知递归的2个特点:
1、一个问题拆成具有相同思路的子问题,子子问题,这些问题都可以使用同一个函数解决
2、经过层层分解的子问题最后一定有一个不能再分解的固定值
所以根据以上2个特征来判断一个问题是否可以用递归解决
递归4个基本步骤(真的很有用):
1、先定义一个函数,明确这个函数的功能
2、寻找问题与子问题之间的关系(递推关系式),这样由于问题与子问题具有相同解决思路,只要子问题调用步骤 1 定义好的函数,问题即可解决。所谓的关系最好能用一个公式表示出来,比如 f(n) = n * f(n-1) 这样,如果暂时无法得出明确的公式,用伪代码表示也是可以的, 发现递推关系后,要寻找最终不可再分解的子问题的解,即(临界条件),确保子问题不会无限分解下去。由于第一步我们已经定义了这个函数的功能,所以当问题拆分成子问题时,子问题可以调用步骤 1 定义的函数,符合递归的条件(函数里调用自身)
3、将第二步的递推关系公式用代码表示出来补充到步骤1定义的函数中
4、最后也是很关键的一步,根据问题与子问题的关系,推导出时间复杂度,如果发现递归时间复杂度不可接受,则需转换思路对其进行改造,看下是否有更靠谱的解法

实战演练(来自剑指):

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

f ( n ) = { 0 n = 0 1 n = 1 f ( n 1 ) + f ( n 2 ) n > 1 f(n) = \begin{cases} 0& n= 0 \\ 1& n= 1 \\ f(n-1)+f(n-2) \qquad & n> 1 \end{cases}

第一种解法:
利用递归直接求解

class Solution {
public:
    int Fibonacci(int n) {
        if(n<=1) return n;
        return Fibonacci(n-1)+Fibonacci(n-2);
    }
};

因为在递归过程程中有大量的中间过程重复计算,所以可以使用两个变量来暂时存放f(n-1)+f(n-2);

第二种解法:

class Solution {
public:
    int Fibonacci(int n) {
        int a[2] = {0,1};
        if(n<2) return a[n];
        int F1 = 0;
        int F2 = 1;
        int FN = 0;
        //自下向上,保存中间过程变量,避免重复计算
        for(int i =2;i<=n;i++){
            FN = F1 +F2 ;
            F1 = F2 ;
            F2 = FN ;
        }
        return FN;
    }
};

2.一只青蛙一次可以跳上1级台阶,也可以跳上2级。求该青蛙跳上一个n级的台阶总共有多少种跳法(先后次序不同算不同的结果)

和斐波那契函数一样;把n级台阶时的跳法看成n的函数,记为f(n)。
当n>=2时,第一次跳的时候有两种不同的选择:第一次跳1级,此时跳法的数目等于后面剩下的n-1级台阶的跳法数目,即为f(n-1);第一次跳2级,此时跳法数目等于后面剩下的n-2级台阶的跳法数目,即为f(n-2)。
因此,n级台阶的不同跳法总数为f(n) = f(n-1) + f(n-2)
f ( n ) = { 1 n = 1 2 n = 2 f ( n 1 ) + f ( n 2 ) n > 2 f(n) = \begin{cases} 1& n= 1 \\ 2& n= 2 \\ f(n-1)+f(n-2) \qquad & n> 2 \end{cases}

class Solution {
public:
    int jumpFloor(int number) {
        if(number<=0) return 0;
        if(number<=2) return number;
        int F1 = 1;
        int F2 = 2;
        int FN = 0;
        for(int i = 3;i<=number;i++){
            FN = F1+F2;
            F1 = F2;
            F2 = FN;
        }
        return FN;
    }
};

3.变态版跳台阶,一只青蛙一次可以跳上1级台阶,也可以跳上2级……它也可以跳上n级。求该青蛙跳上一个n级的台阶总共有多少种跳法。

f(n)=f(n-1)+f(n-2)+…+f(1)f(n-1)=f(n-2)+…f(1) 得:f(n)=2*f(n-1)

class Solution {
public:
    int jumpFloorII(int number) {
        if(number<=2) return number;
        int FN = 2;
        for(int i = 3;i<=number;i++){
            FN = 2*FN;
        }
        return FN;
    }
};

4.接下来我们来看下一道经典的题目: 反转二叉树 将左边的二叉树反转成右边的二叉树

在这里插入图片描述
采用自上而下的思考方式,那我们取前面的1, 2,3 结点来看,对于根节点 1 来说,假设 2, 3 结点下的节点都已经翻转,那么只要翻转 2, 3 节点即满足需求
在这里插入图片描述
对于2, 3 结点来说,也是翻转其左右节点即可,依此类推,对每一个根节点,依次翻转其左右节点,所以我们可知问题与子问题的关系是 翻转(根节点) = 翻转(根节点的左节点) + 翻转(根节点的右节点)

class Solution {
public:
    void Mirror(TreeNode *pRoot) {
        if(!pRoot || (!pRoot->left&&!pRoot->right)) return ;
        //指针必须先定义,定义一个中间节点用于交换指针
        TreeNode* temp = NULL;
        //当左右指针存在时,交换
        if(pRoot->left || pRoot->right)
        {
            temp = pRoot->left;
            pRoot->left = pRoot->right;
            pRoot->right = temp;
        }
        //递归遍历左右子树
        Mirror(pRoot->left);
        Mirror(pRoot->right);
    }
};
  1. 汉诺塔问题:如下图所示,从左到右有A、B、C三根柱子,其中A柱子上面有从小叠到大的n个圆盘,现要求将A柱子上的圆盘移到C柱子上去,期间只有一个原则:一次只能移到一个盘子且大盘子不能在小盘子上面,求移动的步骤和移动的次数

在这里插入图片描述
我们套用递归四步法看下这题怎么解

  1. 定义问题的递归函数,明确函数的功能,我们定义这个函数的功能为:把 A 上面的 n 个圆盘经由 B 移到 C
// 将 n 个圆盘从 a 经由 b 移动到 c 上
public void hanoid(int n, char a, char b, char c) {
}
  1. 查找问题与子问题的关系 首先我们看如果 A 柱子上只有两块圆盘该怎么移
    在这里插入图片描述
    前面我们多次提到,分析问题与子问题的关系要采用自上而下的分析方式,要将 n 个圆盘经由 B 移到 C 柱上去,可以按以下三步来分析 * 将 上面的 n-1 个圆盘看成是一个圆盘,这样分析思路就与上面提到的只有两块圆盘的思路一致了 * 将上面的 n-1 个圆盘经由 C 移到 B * 此时将 A 底下的那块最大的圆盘移到 C * 再将 B 上的 n-1 个圆盘经由A移到 C上
    有人问第一步的 n - 1 怎么从 C 移到 B,重复上面的过程,只要把 上面的 n-2个盘子经由 A 移到 B, 再把A最下面的盘子移到 C,最后再把上面的 n - 2 的盘子经由A 移到 B 下…, 怎么样,是不是找到规律了,不过在找问题的过程中 切忌把子问题层层展开,到汉诺塔这个问题上切忌再分析 n-3,n-4 怎么移,这样会把你绕晕,只要找到一层问题与子问题的关系得出可以用递归表示即可。
    由以上分析可得
    move(n from A to C) = move(n-1 from A to B) + move(A to C) + move(n-1 from B to C`)
    一定要先得出递归公式,哪怕是伪代码也好!这样第三步推导函数编写就容易很多,终止条件我们很容易看出,当 A 上面的圆盘没有了就不移了
  2. 根据以上的递归伪代码补充函数的功能
// 将 n 个圆盘从 a 经由 b 移动到 c 上
public void hanoid(int n, char a, char b, char c) {
    if (n <= 0) {
        return;
    }
    // 将上面的  n-1 个圆盘经由 C 移到 B
    hanoid(n-1, a, c, b);
    // 此时将 A 底下的那块最大的圆盘移到 C
    move(a, c);
    // 再将 B 上的 n-1 个圆盘经由A移到 C上
    hanoid(n-1, b, a, c);
}

从函数的功能上看其实比较容易理解,整个函数定义的功能就是把 A 上的 n 个圆盘 经由 B 移到 C,由于定义好了这个函数的功能,那么接下来的把 n-1 个圆盘 经由 C 移到 B 就可以很自然的调用这个函数,所以明确函数的功能非常重要,按着函数的功能来解释,递归问题其实很好解析,切忌在每一个子问题上层层展开死抠,这样这就陷入了递归的陷阱,计算机都会栈溢出,何况人脑
4. 时间复杂度分析 从第三步补充好的函数中我们可以推断出
f(n) = f(n-1) + 1 + f(n-1) = 2f(n-1) + 1 = 2(2f(n-2) + 1) + 1 = 2 * 2 * f(n-2) + 2 + 1 = 22 * f(n-3) + 2 + 1 = 22 * f(n-3) + 2 + 1 = 22 * (2f(n-4) + 1) = 23 * f(n-4) + 22 + 1 = … // 不断地展开 = 2n-1 + 2n-2 + …+ 1
显然时间复杂度为 O(2n),很明显指数级别的时间复杂度是不能接受的,汉诺塔非递归的解法比较复杂,大家可以去网上搜一下

6.细胞分裂 有一个细胞 每一个小时分裂一次,一次分裂一个子细胞,第三个小时后会死亡。那么n个小时候有多少细胞?

1.照样我们用前面的递归四步曲来解

public int allCells(int n) {
}

2.接下来寻找问题与子问题间的关系(即递推公式) 首先我们看一下一个细胞出生到死亡后经历的所有细胞分裂过程
在这里插入图片描述
图中的 A 代表细胞的初始态, B代表幼年态(细胞分裂一次), C 代表成熟态(细胞分裂两次),C 再经历一小时后细胞死亡 以 f(n) 代表第 n 小时的细胞分解数 fa(n) 代表第 n 小时处于初始态的细胞数, fb(n) 代表第 n 小时处于幼年态的细胞数 fc(n) 代表第 n 小时处于成熟态的细胞数 则显然 f(n) = fa(n) + fb(n) + fc(n) 那么 fa(n) 等于多少呢,以n = 4 (即一个细胞经历完整的生命周期)为例
仔细看上面的图
可以看出 fa(n) = fa(n-1) + fb(n-1) + fc(n-1), 当 n = 1 时,显然 fa(1) = 1
fb(n) 呢,看下图可知 fb(n) = fa(n-1)。当 n = 1 时 fb(n) = 0
在这里插入图片描述
fc(n) 呢,看下图可知 fc(n) = fb(n-1)。当 n = 1,2 时 fc(n) = 0
在这里插入图片描述
综上, 我们得出的递归公式如下
f(n) = fa(n) + fb(n) + fc(n);
fa(n) = fa(n-1) + fb(n-1) + fc(n-1)
fb(n) = fa(n-1)。当 n = 1 时 fb(n) = 0;
fc(n) = fb(n-1)。当 n = 1,2 时 fc(n) = 0;

3.根据以上递归公式我们补充一下函数的功能

public int allCells(int n) {
    return aCell(n) + bCell(n) + cCell(n);
}

/**
 * 第 n 小时 a 状态的细胞数
 */
public int aCell(int n) {
    if(n==1){
        return 1;
    }else{
        return aCell(n-1)+bCell(n-1)+cCell(n-1);
    }
}

/**
 * 第 n 小时 b 状态的细胞数
 */
public int bCell(int n) {
    if(n==1){
        return 0;
    }else{
        return aCell(n-1);
    }
}

/**
 * 第 n 小时 c 状态的细胞数
 */
public int cCell(int n) {
    if(n==1 || n==2){
        return 0;
    }else{
        return bCell(n-1);
    }
}

只要思路对了,将递推公式转成代码就简单多了,另一方面也告诉我们,可能一时的递归关系我们看不出来,此时可以借助于画图来观察规律
4. 求时间复杂度 由第二步的递推公式我们知道 f(n) = 2aCell(n-1) + 2aCell(n-2) + aCell(n-3)
之前青蛙跳台阶时间复杂度是指数级别的,而这个方程式显然比之前的递推公式(f(n) = f(n-1) + f(n-2)) 更复杂的,所以显然也是指数级别的

总结:

大部分递归题其实还是有迹可寻的, 按照之前总结的解递归的四个步骤可以比较顺利的解开递归题,一些比较复杂的递归题我们需要勤动手,画画图,观察规律,这样能帮助我们快速发现规律,得出递归公式,一旦知道了递归公式,将其转成递归代码就容易多了,很多大厂的递归考题并不能简单地看出递归规律,往往会在递归的基础上多加一些变形,不过万遍不离其宗,我们多采用自顶向下的分析思维,多练习,相信递归不是什么难事

参考:CodeSheep

发布了2 篇原创文章 · 获赞 1 · 访问量 72

猜你喜欢

转载自blog.csdn.net/why18767183086/article/details/104046148