一文带你彻底搞懂递归
前言:鄙人知道的并不多,但致力于把所知道的讲清楚。在学习的道路上最怕遇到忽悠文,还有一本正经扯着专业术语的深奥文,所以鄙人的愿景来了:希望你看完这篇文章后,不会觉得这是两类中的其一,并且会由衷的感慨这样的述说风格真是一种难能可贵的优秀品质!
“滴,前方 递归 收费站高速入口,请系好安全带 ”
目录:
1、递归基本概念
2、爬楼梯例题
3、全排列例题
4、N皇后例题
5、反转链表例题
一、递归基本概念
递归在我们生活中一直都有使用,比如一位教官想要知道自己的队伍中有多少人,那么他只需要问清队伍中最后一位同学,是第几个人就行,但是最后一位同学也不知道自己是第几个人怎么办,这个人可以问他旁边的人,在旁边的人数上加一就行,旁边人也不知道可以再问旁边人,一路 递 过去,第一个人总知道自己是第几个了吧,再一路的 归 回来,也就是有名的报数现象。
这就是一个非常标准的递归求解问题的分解过程,去的过程叫“递”,回来的过程叫“归”。基本上,所有的递归问题都可以用递推公式来表示。刚刚这个生活中的例子,我们用递推公式将它表示出来就是这样的: f(n) = f(n-1) + 1,其中 f(1) = 1。
递归需要满足的三个条件:
- 一个问题的解可以分解为几个子问题的解何为子问题?子问题就是数据规模更小的问题。
比如,前面讲的报数的例子,你要知道,“自己是第几个人”的问题,可以分解为“旁边的人是第几个人”这样一个子问题。
- 这个问题与分解之后的子问题,除了数据规模不同,求解思路完全一样。
比如,报数那个例子,你求解“自己是第几个人”的思路,和旁边的人求解“自己是第几个人”的思路,是一模一样的。
- 存在递归终止条件把问题分解为子问题,把子问题再分解为子子问题,一层一层分解下去,不能是无限循环。
还是报数的例子,第一个人不需要再继续询问任何人,就知道自己是第一个人,也就是 f(1)=1,这就是递归的终止条件。
如何编写递归代码:
1.定义递归函数。
2.找到终止条件。
写递归代码的几个注意事项:
1.不要试图用人脑去分解递归过程中的每个步骤。
2.递归代码要警惕堆栈溢出。
函数调用会使用栈来保存临时变量。每调用一个函数,都会将临时变量封装为栈帧压入内存栈,等函数执行完成返回时,才出栈。系统栈或者虚拟机栈空间一般都不大。如果递归求解的数据规模很大,调用层次很深,一直压入栈,就会有堆栈溢出的风险。
二、爬楼梯例题(力扣网上第70题)
1.定义递归函数:
假如我们要求爬到 第6阶 有多少种不同方法,那么我们知道 第6阶 可能从 第4阶 一次爬 2 个台阶上去的,也可能是从 第5阶 一次爬 1 个台阶上去的,那么我只需要知道爬到 第4阶 的方法 和 爬到 第5阶 的方法,两者相加即为爬到第6阶 的方法。
那么爬到 第4阶 的方法和 第5阶 的方法 为多少呢?我们不知道,所以我们定义一个函数 int climbStairs(int n);
返回一个 爬到 第n阶 有多少种不同的方法值。例如求爬到 第4阶 就是 climbStairs(4);同理爬到 第5阶。
2.找到终止条件:
我们可以很显然知爬到 第1阶 为一种方法,爬到 第2阶 为两种方法(一阶一阶爬,一次爬两阶)。所以终止条件就有了。
3.代码:
明确以上两点,我们可以直接写出代码了(当思路有了,可以尝试先自己写出代码实现),如下:
int climbStairs(int n) {
//终止条件
if(n == 1 ) return 1;
if(n == 2) return 2; //返回两种情况的和
reutrn climbStairs(n-1) + climbStairs(n-2);
`
4.注意事项:
1.不要试图用人脑去分解递归过程中的每个步骤。
再次强调不需要将递归过程中的每个步骤在脑袋里展开,你的脑袋能压几个栈呀?我们只需要扣住函数的定义,解决好当前层的 问题就行
2.警惕堆栈溢出(根据题目条件,选择性使用递归)。
3.尝试消除递归过程中的重叠子问题。
如图,我们求解爬到 第6阶 的方法数时,需求爬到 第4阶 和爬到 第5阶 ,求爬到 第5阶 时,也需求 爬到 第4阶, 这就进行了重复的计算,那么我们可以采用 散列表 记录第一次计算爬到 第4阶 的方法值,后续计算时,可以直接查找使用。
不赘言,直接上代码(这里采用 c++ 中的 unordered_map 实现):
unordered_map<int,int> hash;
int climbStairs(int n) {
if(n==1) return 1;
if(n==2) return 2;
if(hash.count(n)) return hash[n];
hash[n]=climbStairs(n-1)+climbStairs(n-2);
return hash[n];
}
三、全排列例题(力扣网上第46题)
1.定义递归函数:
先来看排列问题解决的思路,我们先从【1,2,3】 中选择【1】得【1】,再从【2,3】中选择【2】得【1,2】,最后选个【3】得【1,2,3】(用 track数组 记录得的结果)。如图:
最后一行即为排列结果。那么我们可以定义一个函数void backtrack(vector& nums,vector& track);
从 nums数组 中选择一个数,加入到 track数组 中。
2.找到终止条件:
可以很显然知道当 track数组 中的元素数量等于 nums数组 的元素 数量时,即 nums数组 中每一个数字都被选了时,即可结束。
3.代码:
有三点需要声明:
1.我们需要一个 res数组 记录最终结果。
2.我们需要一个 used数组 记录已经使用过的数字,来防止重复选择。
比如第一次我们从【1,2,3】中选择了【1】,第二次只能从【2,3】中选择,而不是再从【1,2,3】中选择。
3.撤销选择,即重新选。
比如第一次我们从【1,2,3】中选择了【1】,在第二次选择前,撤销对【1】的选择,于是我们从【1,2,3】中选择【2】。
有了思路后可以尝试先自己写代码实现!
vector<vector<int>> res; //从nums数组 中选择一个数加入到 track中。
void backtrack(vector<int>& nums, vector<int>& track)
{
//终止条件
if(track.size() == nums.size())
{
res.push_back(track);
return;
}
for(int i=0;i<nums.size();i++)
{
if(uesd[i]) continue;//若used[i]为true,表示以下标为 i 的数字已经用过了。
track.push_back(nums[i]);
uesd[i] = true;
//撤销选择
track.pop_back();
uesd[i] = false;//注意
}
}
这里有一个通过每次交换 两个元素 的技巧达到不用 used数组 记录已经使用过的数字的目的,有兴趣的可以参考力扣网上此题的官方题解,这里不再赘言。
四、N皇后例题
奇怪的游戏
佳哥在玩一种游戏,他需要在一个n*n的矩阵内填入n个数字,填入的数字很复杂,但幸运的是,你不需要知道需要填入什么数字。但是你要告诉佳哥应该在哪些格子中填数字。填数字只有一条规则,任何两个数字都不能处于同一行、同一列或同一斜线上,你能告诉佳哥有多少种填法吗?
比如对于样例输入4,输出2,代表两种符合要求的情况,分别是:
第一种:
0 1 0 0
0 0 0 1
1 0 0 0
0 0 1 0
第二种:
0 0 1 0
1 0 0 0
0 0 0 1
0 1 0 0
1.定义递归函数:
这题有不同种解法,这里以我的思路进行讲解。
对于这个棋盘我们一列一列的看,我们从第一列中选择任意一行填入数字,再从第二列中选择任意一行填入数字,这里要注意填入数字前要判断是否合法,即 满足两个数字都不能 处于同一行,同一列或同一斜线上。如图:
所以我们可以定义一个函数 void queen(vector<vector> board,int c); 在 board棋盘 的 c列 上选择任意一行填入数字。
2.找到终止条件:
我们可以很显然得知 当棋盘上每一列都填入了数字时,即可结束,并在 填法的总数 上加一,且记录下来。
3.代码:
有四点需要声明:
1.我们需要一个 res变量 记录 填法总数。
2.我们需要写一个 bool isValid(vector<vector> board,int r,int c) 函数,
判断在 board棋盘 的 r行,c列 上填入一个数字 是否合法。
3.撤销选择,即重新选择。
比如,第一次我们在 第一列 中选择 第一行,在第二次选择前,撤销对第一次的选择,于是我们在 第一列 中选择 第二行,
4.为了符合 OJ 平台的提交格式,这里的代码中 加入了 main函数,并初始化一个 n x n 的 board棋盘
以及导入头文件
有了思路后,尝试先自己写代码实现。
#include <bits/stdc++.h>
using namespace std;
int res=0;//全局变量,记录 填法总数
bool isValid(vector<vector<int>> board,int r,int c){
//判断在 board棋盘 的 r行,c列 上填入一个数字 是否合法。合法则返回 true;
int rt;
int ct;
for(ct=0;ct<(int)board[0].size();++ct){//判断同行。
if(board[r][ct]) return false;
}
rt=r;
ct=c;
while (--rt>=0&&--ct>=0){//判断左上斜线。
if(board[rt][ct]) return false;
}
rt=r;
ct=c;
while (++rt<(int)board.size() &&--ct>=0){//判断左下斜线。
if(board[rt][ct]) return false;
}
return true;
}
void queen(vector<vector<int>> board,int c){
//终止条件
if(c==(int)board[0].size()) ++res;//if 条件满足时,即棋盘上的每一列都填入了数字
//选择任意一行填入数字
for(int i=0;i<(int)board.size();++i){
if(isValid(board,i,c)){//判断是否合法
board[i][c]=1;//合法则填入 1;
queen(board,c+1);//对下一列进行选择 //撤销选择
board[i][c]=0;//注意
}
}
}
int main(){
int n;
cin>>n;
vector<vector<int>> board(n,vector<int>(n,0));//初始化一个棋盘
queen(board,0);
cout<<res;
return 0;
}
对于这题有一个 二维 压缩为 一维 的技巧,这里不再赘言,感兴趣的可以参考此题的 题解区 一些用户的题解。
五、反转链表例题(力扣网上第206题)
在解决题目前,我们先定义一个链表的节点(以供后文使用)。
/**
* 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) {}
* };
*/
1.定义递归函数:
这题我们如何定义递归函数呢?我们可以直接按照题目要求 定义一个函数
ListNode* reverseList(ListNode* head);反转一个以 head 为头节点的链表,并返回 反转后的链表 的头节点。
2.终止条件:
当 输入的 head 为总个链表 尾节点时即可结束。
3.代码:
有一点需要声明:
1.对于当前节点 head 我们需要将其 下一个节点 的 next指针 指向 head,并将 head的 next指针 指向 null;
有了思路后,尝试先自己写出代码实现。
ListNode* reverseList(ListNode* head) {
if(head==nullptr) return head;//为了符合 OJ 平台条件,这里多个判断
//终止条件
if(head->next==nullptr) return head;
ListNode* nhead = reverseList(head->next);
head->next->next =head;
head->next=nullptr;
return nhead;
}
我们用一组图片来描述这些代码的操作。
比如我们要反转以 head 为头节点 的这个链表。
ListNode* nhead = reverseList(head->next);
head->next->next =head;
head->next=nullptr;
后记:
事实上还有一些问题并没有搞懂,如果你要问,我们如何在一开始就定义好一个递归函数去解决问题呢?这就是如何运用好递归的问题了,如本文的标题一样,此文只负责带你彻底理解递归。由于本人的水平有限,这里只能给出一些概括:、递归 是一种 编程技巧,而解题需要 算法思想,而很多算法,比如 动态规划,回溯,分治等,都是可以很好的用递归实现的,对于这方面能力的提升,鄙人也在默默的努力,而鄙人能推荐的自认为最为有效的能提高这方面能力的方法便是:
多刷题!
在此文的题目中有很多技巧性的解法,文章都一笔带过,对于有兴趣的,学有余力的学者都可以详细的去了解。
庄子曰:吾生也有涯,而知也无涯;以有涯随无涯,殆矣。对于大部分的学者需先学好最通俗实用的解法,再去
有的放矢学习其他知识!
本文部分内容参考自 labuladong 微信公众号,极客时间王争的数据结构和算法之美专栏!
本文部分题目来自 力扣网,密码岛!
本文仅供参考学习,请客观性的看待,若有错误之处麻烦指出,以免误人子弟!