【算法专题】递归

递归

什么是递归?

        所谓的递归其实就是函数自己调用自己的情况。

为什么会用到递归?

        举几个我们之前学习过的例子:二叉树的遍历、快速排序、归并排序,都是用递归来解决的,比如二叉树的后序遍历实现方式:是先遍历左子树再遍历右子树,最后访问根节点,而遍历左子树和遍历右子树的过程也都可以分成:先遍历左子树再遍历右子树,最后访问根节点,直到不能再划分位置;而快速排序的实现方式:选择一个基准元素,将数组划分为大于基准元素和小于基准元素两个区间,然后对两个区间再次分别选择一个基准元素,将数组划分为大于基准元素和小于基准元素两个区间,直到不能再划分为止,这个时候我们就会发现数组已经有序了;归并排序的实现方式:将数组分为两个区间,对这两个区间分别进行排序后,将两区间合并,而对这两区间的排序则是通过再次将这两个区间分别分为两个子区间,分别排序后进行合并,直到不能划分为止,合并完成后我们会发现数组已经有序了。

        我们可以发现上述三个例子有一个共性:它们的问题都可以拆分成相同的子问题,子问题也可以继续拆分,直到不可拆分为止。

如何理解递归?

        在能够简单地画出递归的细节展开图和能解决一些简单的二叉树题目后,我们就可以开始以一个宏观的视角看待递归了。第一点:不要太在意递归的细节展开,第二点:把递归函数看作一个黑盒,第三点:相信这个黑盒能够完成任务。

如何写好一个递归?

        首先是函数头的书写,递归适用于解决能不断拆分为相同子问题的问题,则为了正确书写函数头,我们需要先找到相同的子问题。

        其次是函数体的书写,由于拆分出来的子问题实际上与父问题一样,我们书写函数体时,只需要关心某一个子问题是怎么解决的。

        最后是递归的出口,因为递归注定是不能无限递归下去的,我们需要找到问题无法再拆分的条件,这就是递归函数的出口。

递归算法题

汉诺塔问题

面试题 08.06. 汉诺塔问题 - 力扣(LeetCode)

        汉诺塔这道经典的递归题目相信大家都是不陌生的,但是大家可能会忘了我们为什么可以用递归来处理,这是因为递归的要求是一个问题能够被拆分为相同的子问题。而由于汉诺塔要求将所有盘子从a柱移动到c柱,由于移动圆盘存在三个限制,我们必须把最大的圆盘之外的所有圆盘先移动到b柱,才能把最大圆盘移动到c柱,最后将最大圆盘之外的整体移动到c柱。

        而将这个整体移动到c柱的过程,也能继续拆分,为了方便表示,我们假定x为起始位置,y为辅助转移的柱子,z为目的位置,则对于三个盘子的情况,我们可以这样处理:

        可以发现,这个过程中,第1步和第3步可能需要继续拆分成子问题的,而第2步可以直接移动进行处理。那么编写递归函数,首先就是函数头,根据我们例子中的子问题可以发现,递归函数肯定是需要传入这三个“柱子”才能对汉诺塔进行操作的,除此之外呢?为了让递归能够进行正确的次数,还需要传入起始位置的盘子数量n。

        接下来是函数体,我们知道,所谓递归就是函数自己调用自己,为了更轻松地理解递归,我们相信这个递归函数是能够顺利地完成任务的,则首先在函数体中调用这个函数,传入的盘子数量为n-1,把此时底部以上的整体从a借助c移动到b,然后把底部直接移动到c,最后再次调用自己,传入的盘子数量n-1,把这个整体从b借助a移动到c。

        最后是递归的出口,当n为0时,我们将结束递归,防止递归无止尽地调用造成栈溢出。

class Solution {
public:
    void dfs(vector<int>& A, vector<int>& B, vector<int>& C, int n)
    {
        if(n == 0) return;
        dfs(A, C, B, n-1);
        C.push_back(A.back());
        A.pop_back();
        dfs(B, A, C, n-1);
    }
    void hanota(vector<int>& A, vector<int>& B, vector<int>& C) 
    {
        dfs(A, B, C, A.size());
    }
};

合并两个有序链表

21. 合并两个有序链表 - 力扣(LeetCode)

        为了判断本题能否用递归解决,我们需要判断它是否能被拆分为相同的子问题。

        显然,对框中的两个链表进行合并和这个问题属于相同的问题,是这个问题的相同子问题,所以我们可以用递归来解决本题。那么要书写一个递归函数,有三个步骤,函数头、函数体、递归出口。

        首先是函数头,解决这个子问题除了两个链表指针 l1、l2 外,不需要其他变量,所以我们可以直接使用题目给的函数作为递归函数头。

        接下来是函数体,要想书写函数体,我们只需要解决子问题即可,选出当前两个指针中值较小的一个节点后,我们相信自己的这个函数是可以解决合并链表问题的,所以我们在函数体中调用自己,假设l1的值小于l2的值,则对l1->next和l2进行合并,由于我们相信函数是能顺利完成合并的,所以此时链表应该就是有序的了。

        最后是递归出口,我们需要找到递归结束的条件,当两个链表比较到最后,必然会有一个链表指针为空,此时无法进行比较,返回另一个链表指针即可。

/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     ListNode *next;
 *     ListNode() : val(0), next(nullptr) {}
 *     ListNode(int x) : val(x), next(nullptr) {}
 *     ListNode(int x, ListNode *next) : val(x), next(next) {}
 * };
 */
class Solution {
public:
    ListNode* mergeTwoLists(ListNode* l1, ListNode* l2) 
    {
        if(l1 == nullptr) return l2;
        if(l2 == nullptr) return l1;
       if(l1->val <= l2->val)
       {
            l1->next = mergeTwoLists(l1->next, l2);
            return l1;
       } 
       else
       {
            l2->next = mergeTwoLists(l1, l2->next);
            return l2;
       }

    }
};

两两交换链表中的节点

24. 两两交换链表中的节点 - 力扣(LeetCode)

        同样的,我们先分析为什么能用递归来解决,题目要求我们两两交换链表中的所有节点,这实际上可以被拆分为这样的子问题:交换两个链表的节点,并且交换后的在后面的节点指向这两个节点后续的处理完的链表。依然是三个步骤:函数头、函数体、递归出口:

        函数头:根据前面的分析,我们处理子问题只需要一个指向链表当前节点的指针,这和题目所给的方法的函数头一致,所以我们可以直接在这个方法中实现递归。

        接下来是函数体的书写,我们考虑具体的子问题,既然我们要写递归,那就相信它能完成它的任务,实现把链表的节点两两交换,所以我们先调用递归函数,把当前要处理的两个节点之后的链表处理了,然后交换这两个节点,让在后面的那个节点指向处理完的链表。

        最后是递归出口,我们考虑递归的结束条件,当传入函数的链表节点为空或链表只有一个节点时,我们无法再交换节点,所以直接返回传入的链表节点即可。

/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     ListNode *next;
 *     ListNode() : val(0), next(nullptr) {}
 *     ListNode(int x) : val(x), next(nullptr) {}
 *     ListNode(int x, ListNode *next) : val(x), next(next) {}
 * };
 */
class Solution {
public:
    ListNode* swapPairs(ListNode* head) {
        if(!head || !head->next) return head;
        auto res = head->next;
        // 为什么必须前序?---这是因为前序递归一次能处理两个节点,而后序递归一次只处理一个节点,会栈溢出
        auto tmp = swapPairs(res->next); 
        res->next = head;
        head->next = tmp;
        return res;
    }
};

pow(x, n)

50. Pow(x, n) - 力扣(LeetCode)

        根据题目提示,我们发现本题n的取值范围是非常巨大的,如果我们只是简单地将x乘n次是肯定会栈溢出的,所以必须对直接递归的方法进行优化,这个算法叫做快速幂,能够快速计算出x的整数n次幂,如下图所示,如果直接进行乘法运算的话,需要进行16次,而使用快速幂算法,我们只需要进行log16也就是4次,并且根据对数的性质,n越大,能提升的效率就越高。

        所以说快速幂实际上就是要计算x的n次方就先计算x的n/2次方,当n不能被2整除时,就需要再多乘一个x。这就是重复子问题,所以还是可以用递归函数。

        接下来就是细节问题,n可能为负数,并且可能到达int类型的最小值,因为int类型最小值的绝对值是比最大值的绝对值多一位的,所以我们计算时直接取反就会溢出,因此需要用long long类型来存n。

​
class Solution {
public:
    double myPow(double x, int n) 
    {
        if(n == 0) return 1.0;
        long long N = n;
        if(N < 0)
        {
            N = -N;
            x = 1.0 / x;
        }
        double tmp = myPow(x, N / 2);
        return n % 2 == 0 ? tmp * tmp : tmp * tmp * x;
    } 
};

​

求根节点到叶节点数字之和

129. 求根节点到叶节点数字之和 - 力扣(LeetCode)

        依然是找重复子问题,尽管本题并不能像前面几题那么容易找到子问题,但是我们可以举个树比较深的例子来自己模拟一下:

        我们单就值为2的这个节点进行模拟,要想完成题目要求的操作,在处理值为2的节点时,我们必须获取到它的根节点也就是值为1的节点,然后对值为2的这个节点的左右子树分别进行相同的操作,最后还得把计算结果返回给它的父节点。到这里我们就能够写出递归函数的函数头了,除了需要传入一个指向链表节点的指针外,还得传入前面已经计算过的值,然后这个递归函数的返回值就是对树剩下部分计算的结果。

        接下来就是函数体了,这其实就是把我们刚刚的分析过程用代码模拟实现。最后是递归的出口,当递归到树的叶节点时,直接返回当前统计的结果。

/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode() : val(0), left(nullptr), right(nullptr) {}
 *     TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
 *     TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
 * };
 */
class Solution {
public:
    int dfs(TreeNode* root, int val)
    {
        int ret = 0, sum = val * 10 + root->val;
        if(!root->left && !root->right) return sum;
        if(root->left) ret += dfs(root->left, sum);
        if(root->right) ret += dfs(root->right, sum);
        return ret;  
    }
    int sumNumbers(TreeNode* root) {
        return dfs(root, 0);
    }
};

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值