[算法2]第一集 递归

递归啊递归,说简单简单,说难难。

首先我们要知道

一、什么是递归?

我们再C语言和数据结构里都用了不少递归,这里就不多详细介绍。

递归简单来说就是函数自己调用自己的情况

二、为什么要用递归呢?

本质来说其实就是我们在解决一个问题后出现相同的问题,解决这个问题后会再出现相同的问题。我们解决这些问题的方式一样,所以就出现了函数自己调用自己。

三、如何加深理解递归?

以下的步骤由浅入深

  1. 首先,你需要会画递归展开图
  2. 当你会画递归展开图时,下面要刷刷题,先从简单的二叉树中的题目来刷,这种一般比较容易
  3. 当上面都完成后,下面我们要做到的就是能够宏观的去看递归的过程,宏观的去看递归的过程需要:
    1. 不要一味的在意递归的过程
    2.  尽量把递归函数当作一个黑盒
    3. 相信这个黑盒的实力

四、如何写好一个递归

  1. 找到相同的问题(把主问题分解成若干个子问题)--->函数头的设计
  2. 只需要关心某一个子问题是如何解决得---->函数体的书写
  3. 注意递归函数的出口,避免死循环

五、题目实战

1,经典汉诺塔问题

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

这道题属于是我们接触递归后解决的第一道问题,很多人做完汉诺塔后,知道了汉诺塔要用递归解决,那么你知道为什么汉诺塔要用递归吗?

1)如何解决汉诺塔问题

我们可以先假设,三个柱子为A、B、C,盘子数量问n,盘子开始在A上,我们需要把它放到C上。

当n=1时 我们只需要一次操作,把A上的盘子转移到C上。

当n=2时

我们需要把盘子全部转移到C上,所以我们需要先把大的盘子转移到C上,所以我们需要先把上面的盘子转移到B上,把A上最大的盘子转移到C上

当n=3时,此时A上右三个盘子,

我们要把A上的盘子全部移到C上,所以我们需要把A上最大的盘子先移动到C,所以我们需要把A上较小的两个盘子移到B上,这时我们可以把它转化成n=2来解,此时的C是B,B是C。

当我们想出这个思路时,我们的递归思路是不是来了呢?

2)为什么呢用递归

所以我们为什么要用递归呢?

因为我们解决这个汉诺塔问题时,我们可以把它分解成若干的子问题。

3)如何编写递归代码?

1.重复子问题->函数头

将x柱上的n个盘子,借助y柱,转移到z柱上——>void dfs(x,y,z,n)

2.只关心某一个子问题在做什么->函数体

我们只需要先移动上n-1个,即调用dfs(x,z,y,n-1),然后x——>z,然后dfs(y,x,z,n-1)

3.递归的出口

n=1时,x—— >z

4) 代码解决
class Solution {
public:
    void hanota(vector<int>& A, vector<int>& B, vector<int>& C) 
    {
        int n = A.size();
        dfs(n, A, B, C);
    }

    void dfs(int n, vector<int>& A, vector<int>& B, vector<int>& C)
    {
        if (n == 1)
        {
            C.push_back(A.back());
            A.pop_back();
            return;
        }

        dfs(n-1, A, C, B);    // 将A上面n-1个通过C移到B
        C.push_back(A.back());  // 将A最后一个移到C
        A.pop_back();          // 这时,A空了
        dfs(n-1, B, A, C);     // 将B上面n-1个通过空的A移到C
    }
};

如果对于代码有疑问的可以自己画画递归展开图

2.合并两个有序链表

1)题意解析

2)算法原理

解法:递归——>重复子问题

1)重复子问题——>函数头的设计

这个很明显,就是合并两个有序链表——>Node*dfs(l1,l2)

2)只关心某一个子问题在做什么->函数体

1.比大小

2.(假设l1较小)l1->next=dfs(l1->next,l2)

3.return l1

3)递归的出口

3)代码解决
class Solution {
public:
    ListNode* mergeTwoLists(ListNode* l1, ListNode* l2) {
        if (l1 == nullptr) {
            return l2;
        } else if (l2 == nullptr) {
            return l1;
        } else if (l1->val < l2->val) {
            l1->next = mergeTwoLists(l1->next, l2);
            return l1;
        } else {
            l2->next = mergeTwoLists(l1, l2->next);
            return l2;
        }
    }
};

本题结束。

4)小总结

1)迭代(循环)和递归

它们都能解决重复的子问题,所以我们一般的代码都能递归改循环,循环改递归。

但是为什么有些代码递归改循环麻烦,有些代码循环改递归麻烦呢?

这个问题我们可以像看一下这个例子,比如汉诺塔问题吧,我们不是画过递归展开图吗?不少人肯定发现了,我们没的递归展开图和树的深度优先搜索极为相似。

可以这么说,递归展开图,其实就是对一棵树做一次深度优先遍历(dfs)

所以,我们可以得出当递归展开图,如上图,比较复杂时,这个时候递归改循环就比较困难!

那么什么时候递归改循环简单呢?我们根据上面的结论可以反推出来,当递归展开图简单时,递归改循环简单!如下图:

我们也可以举一个实例,比如遍历数组

假设我们有一个num数组

我们用循环写一个遍历并打印

for(int i=0;i<num.size();++i)
{
cout<<num[i[<<" ";
}

我们用递归写个函数呢?
 

void dfs(vector<int>& num,int i)
{
    if(i==num.size())
    return;
    cout<<num[i]<<" ";
    dfs(num,i+1)
}

3.反转链表

1)题意解析

2)算法原理

解法:递归——>重复子问题

算法思路:
  1. 递归函数的含义:交给你⼀个链表的头指针,你帮我逆序之后,返回逆序后的头结点;
  2. 函数体:先把当前结点之后的链表逆序,逆序完之后,把当前结点添加到逆序后的链表后面即可;
  3.  递归出口:当前结点为空或者当前只有⼀个结点的时候,不用逆序,直接返回。

本题我们可以从两个视角进行解决:

视角一:从宏观看待

我们通过dfs把除头节点逆序

最后会变成这样

然后我们只需要让2的next指针指向head,head的next指针指向null就行了

所以在这种视角下我们只需要注意时序即可,dfs分两步即可。 

所以我们在这个视角下的操作时:

1.让当前节点后面的链表逆置,并且把头节点返回;

2.让当前节点添加到逆置后的链表的后面;

视角二:把链表看成一棵树

我们知道树是n(n>=0)个结点的有限集

这些都叫树。

那链表不就是每个结点只能指向另一个节点的特殊的树?

所以我们可以把链表转化成这样的一棵树:

这个思路下,我们先通过dfs遍历到叶子节点,此时我们将指针返回,把next指针指向其父节点,然后把其父节点的next置空过程如下。

在这种思路下我们只需要进行一次dfs,整个过程其实就是一个后续遍历。

3)代码解决
class Solution {
public:
    ListNode* reverseList(ListNode* head) 
    {
        if(head==nullptr||head->next==nullptr)
        return head;
        ListNode* newhead=reverseList(head->next);
        head->next->next=head;
        head->next=nullptr;
        return newhead;

    }
};

没看懂的同学一定一定要,看看我们画的图,实在不行再画画递归展开图

4.两两交换链表中的节点

1)题意解析

2)算法思路
1. 递归函数的含义:交给你⼀个链表,将这个链表两两交换⼀下,然后返回交换后的头结点;
2. 函数体:先去处理⼀下第⼆个结点往后的链表,然后再把当前的两个结点交换⼀下,连接上后面处理后的链表;
3. 递归出口:当前结点为空或者当前只有⼀个结点的时候,不⽤交换,直接返回。
这题还是很简单还是两个视角,但是这里我就不多介绍了我们直接宏观去看。

用dfs完成每个子问题

3)代码解决
class Solution
{
public:
 ListNode* swapPairs(ListNode* head) 
 {
 if(head == nullptr || head->next == nullptr) return head;
 auto tmp = swapPairs(head->next->next);
 auto ret = head->next;
 head->next->next = head;
 head->next = tmp;
 return ret;
 }
};

5.Pow(x, n)- 快速幂(medium)

1)题意解析

2)算法思路

本题解法很多,我们只讲递归

1. 递归函数的含义:求出 x 的 n 次方是多少,然后返回;
2. 函数体:先求出 x 的 n / 2 次方是多少,然后根据 n 的奇偶,得出 x 的 n 次方是多少;
3. 递归出口:当 n 为 0 的时候,返回 1 即可。
我们这里举个例子吧,防止有人没看懂思路,假设我们求:
 
但是这题需要思考一些细节问题:
1.这题的数据范围,n是负数的情况
此时我们可以吧负数先转化成正数
例:
2.n是2^-31时
上面的方法固然很好但是,但n=2^-31时,如果我们把它转换成分数时,会出现:2^31,但是int类型的范围是:[2^-31,2^31-1],这超出了int的范围,需要改成long long
3)代码解决
class Solution {
public:
 double myPow(double x, int n) 
 {
    return n < 0 ? 1.0 / pow(x, -(long long)n) : pow(x, n);
 }
 double pow(double x, long long n)
 {
    if(n == 0) return 1.0;
    double tmp = pow(x, n / 2);
    return n % 2 == 0 ? tmp * tmp : tmp * tmp * x;
 }
};

我们递归的讲解到此结束,希望大家多多练习,还有递归结束不代表我们后面就不会出现递归了,我们会再在一些其他算法中仍然能看到递归的,大家一定要重视!!!

感谢大家的观看,如有错误欢迎指正!

  • 33
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值