递归算法详解

递归算法


前言

想要真正了解递归,应该掌握如下图所示的四个箭头,即问题与算法之间的互相转换,以及算法和代码之间的互相转换,很遗憾的是,网上大部分关于递归的教程都只涉及到4个箭头的其中一部分。
在这里插入图片描述

一、如何理解递归代码(箭头①)

正常分析代码时,我们看到一个函数习惯于跳进去再跳出来,以这种分析方式去分析递归代码,就会被绕晕,比如二叉树的前序遍历代码,去用这种方式复现执行过程是一件很麻烦的事情;

// 二叉树的前序遍历
void preorderTraversal(TreeNode* root) {
    if (root == NULL) {
        return;
    }
    cout << root->val;  
    preorderTraversal(root->left);  
    preorderTraversal(root->right); 
}


在这里插入图片描述
所以,分析递归代码不能用分析正常代码的那种,看到函数就跳进去的思维去分析,而是应该将代码抽象成递归式(递归算法)的形式,然后在递归式的基础上去理解;
举个例子,求斐波那契数列1,1,2,3,5,8,13,21,34,55,89…的第n个数;
它的代码解法为:

int fibonacci(int n) {
    if (n == 1 || n == 2) 
        return 1;
    if (n > 2) 
        return fibonacci(n-1) + fibonacci(n-2);   
}

它的数学形式为:
在这里插入图片描述
数学形式相信任何人都看得懂,但是代码却不一定人人都看得懂,但实际上这两种形式只是解法的不懂表达方式而已,换句话说,如果每个递归代码都能转化成方程式的形式,那我们就都能看得懂它的解法了(就像英文转化为中文以后,我们都能看得懂了),那么代码转化为递归式(递归算法)的形式容易吗?很容易,等价替换就行了。

实际上我们会发现,代码里的if(n = ?)就相当于数学式里的"如果n等于多少多少",代码里的return相当于数学式里的"="符号,代码里的fibonacci(int n)函数相当于数学式里的函数f(n)(有没有觉得很巧合,数学里叫函数,代码里也叫函数)。

也就是说理解递归代码,重点是将递归代码抽象成递归式(recursion formula),而不是将递归代码拆开分析它的每一层。
当然,不是所有代码都能化为标准的数学方程式,但转化为这种形式也比纯代码要好理解的多,比如二叉树的前序遍历:
在这里插入图片描述
在这里插入图片描述
我们会发现,代码形式所要表达的意思就是二叉树前序遍历的定义,只不过一个用中文表达,一个用代码表达。

二、如何写出递归代码(箭头②)

给定一个问题的递归式(递归算法),将这个递归式转化为递归代码怎么才不会出错呢?那就是要严格按照递归三要素去写代码。
递归代码有三个基本要素:
(1)明确函数的定义:确定函数的功能,以及参数(自变量);
(2)边界条件:确定递归到何时终止,也称为递归出口;
(3)递归模式:大问题是如何分解为小问题的,也称为递归体。
递归三要素其实也是数学方程式的一些基本要素:
在这里插入图片描述
举个例子,在经典汉诺塔问题中,有 3 根柱子及 N 个不同大小的穿孔圆盘,盘子可以滑入任意一根柱子。一开始,所有盘子自上而下按升序依次套在第一根柱子上(即每一个盘子只能放在更大的盘子上面)。移动圆盘时受到以下限制:
(1) 每次只能移动一个盘子;
(2) 盘子只能从柱子顶端滑出移到下一根柱子;
(3) 盘子只能叠在比它大的盘子上;
要求用代码解决汉诺塔问题。
在这里插入图片描述
现已知汉诺塔递归式(递归算法)如下:
在这里插入图片描述
先别管这个递归式(递归解法)是怎么来的,我们先要具备的能力是拿到一个递归解法,要能够去翻译成代码,翻译的方法就是根据递归代码的三要素,翻译过程如下:
1.明确函数的定义:根据题意我们需要4个自变量,分别是disk数目数n,起始柱from,辅助柱aux和终点柱to,f(n,from,aux,to)函数指将n个disk从from移动到to上的步骤;

void hanoi(int n, char from, char to, char aux){}

2.边界条件:

void hanoi(int n, char from, char to, char aux) {
    if (n == 1) {
        cout << "Move disk 1 from " << from << " to " << to << endl;
        return;
    }
}

3.递归模式:

void hanoi(int n, char from, char to, char aux) {
    if (n == 1) {
        cout << "Move disk 1 from " << from << " to " << to << endl;
        return;
    }

    hanoi(n - 1, from, aux, to);
    cout << "Move disk " << n << " from " << from << " to " << to << endl;
    hanoi(n - 1, aux, to, from);
}

这个利用递归三要素去写代码,很多网上教程都说了,我就不多说了,但很多教程它只告诉了方法,却没有告诉这个方法是用在哪一步的,它其实仅仅用在已知算法,然后根据算法来写代码这一步,并不能单纯通过递归三要素能得出算法

三、如何理解递归算法(箭头③)

有时候我们即使拿到一个问题的递归算法,但却并不一样能够完全理解这个算法为什么能够解出这个问题,比如汉诺塔问题,我第一次拿到这个算法的时候,我实在有点不理解为何它能够这样解出来呢?为了更好的理解递归算法,我们应该把算法再往上抽象,抽象为思想递归算法一共可以抽象为三种思想:分治、动态规划和回溯;

3.1.分治

分治思想:将原问题划分为若干个规模较小且结构与原问题相似的子问题,然后再合并子问题的结果得到原问题的解。
涉及到链表和树结构的题常常会涉及到递归,原因是链表和树这种数据结构本身就符合分治的定义。
在这里插入图片描述

3.2.动态规划

动态规划:本质上是对分治思想的一种性能优化,当各子问题包含子子问题时,分治法会重复地求解公共的子子问题。动态规划算法对每个子子问题的结果保存在一张表中,避免每次遇到各个子问题时重新计算答案。
比如斐波那契数列就是个典型的分治问题,且它的子问题有相交的情况,f(n-1)和f(n-2)有相交,红色部分代表重复计算;
在这里插入图片描述
动态规划就是要把红色的重复部分剪掉,把么我们添加一张哈希表,每次递归之前先检索哈希表,有的话就略过这次递归,
在这里插入图片描述
所以动态规划本质上是对分治问题中子问题有相交的问题的性能优化。

3.3.回溯

回溯:尝试所有可能的选项,当发现当前选项不可行时,就回溯到上一个可行的状态,然后尝试其他选项,直到找到问题的解决方案为止。
最经典的回溯思想就是走迷宫,遇到一个岔路时,我们会选择一条路去走,如果是死路我们会返回上一个岔路重新选择路径;
从回溯的定义我们其实不能看出它和递归有什么联系,但如果观察回溯问题的状态空间,就会发现它是一个树状结构,
在这里插入图片描述
而树结构很容易与递归产生关联;
回溯思想本质上是一种穷举思想,它的性能较低,但有些问题能够解出来就不错了,就不用太在意性能了。

四、如何得到递归算法(箭头④)

在这里插入图片描述
一种常规方法是根据直觉和经验,从问题的特征入手,判断问题大概是属于哪类问题,从而用这类问题的模版去解题,但是这种常规解法的最大问题是,我怎么判断这个问题该用哪一种递归解法,这种方法往往需要进行大量的算法练习才能形成一种直觉;
常规思路的优点在于解题非常快,是应试的首选;缺点在于,由于省略了从问题到递归式的这一步,导致很多时候代码即使写出来也不能完全理解这道题的递归思路是啥,还有就是,如果问题不具有明显的特征,就不知道咋做了;
另一种方法是通过找规律发现递归规律,所有的递归题目,都可以通过找出从规模小的解到规模大的解之间的联系,找到递归规律,然后列出递归式;
比如说汉诺塔问题,从问题的本身是很难看出它是一个分治问题的,我初学递归的时候常在想,那个人是怎么想到这么妙的递归解法的,当然我们现在知道他是一个分治问题,但是这是马后炮的说法,他是先有递归解法,然后人们发现这个解法蕴含分治思想,然后把它归纳成了分治问题,而不是说,那个人未卜先知的在不知道解法的情况下,我先知道这是一个分治问题,再去找到他的递归解法;
个人认为第一个想出汉诺塔递归解法的人就是通过找规律的办法想出来的;
在这里插入图片描述
图解如下:
在这里插入图片描述
在这里插入图片描述它的递归式:
在这里插入图片描述
它的代码:

void hanoi(int n, char from, char to, char aux) {
    if (n == 1) {
        cout << "Move disk 1 from " << from << " to " << to << endl;
        return;
    }

    hanoi(n - 1, from, aux, to);
    cout << "Move disk " << n << " from " << from << " to " << to << endl;
    hanoi(n - 1, aux, to, from);
}

再举个比较经典也比较简单的例子,二叉树的遍历,三种遍历的模板如下:

//前序遍历模板
void preOrder(BiTree root)
{
    if (root == NULL) return;//边界条件
    访问根节点
    preOrder(root->lchild);//访问左节点
    preOrder(root->rchild);//访问右节点
}
//中序遍历模板
void inOrder(BiTree root)
{
    if (root == NULL) return;//边界条件
    preOrder(root->lchild);//访问左节点
    访问根节点
    preOrder(root->rchild);//访问右节点
}
//后序遍历模板
void postOrder(BiTree root)
{
    if (root == NULL) return;//边界条件
    preOrder(root->lchild);//访问左节点
    preOrder(root->rchild);//访问右节点
    访问根节点
}

用模板解题非常好用,但是这个模板怎么来的呢?
在这里插入图片描述
那么可以很容易的得到递归式:
在这里插入图片描述
翻译成代码:

//打印二叉树root的前序遍历
void preOrder(BiTree root)
{
    if (root == NULL) return;//边界条件
    cout<<root->val<<endl;//打印根结点
    preOrder(root->lchild);//访问左节点
    preOrder(root->rchild);//访问右节点
}

总结

①②③④这四个箭头应该分开练习,④是比较简单的,③的练习方式是拿到一个递归算法,先别管算法怎么来的,就单纯的去练习根据算法写出可执行代码,这一步的细节较多,应该要多多练习,②的话应该分模块去联系,三个模块都有各自的代码模版,练习达到的效果应该要记住三个模块各自的问题特征和各自的代码模版,④不需要刻意练习,只需要知道,一个递归算法它是怎么来的,它一般都是通过找规律得到的。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

一镜花水月一

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值