前言:
最近刷完leetcode递归的专题了,无奈本人很菜,关于递归每次都是看大佬的题解,自己也设计不出来递归,今日打算从递归本质出发,彻底剖析递归。本文中的大部分递归思想来自:递归的内涵与经典应用。
在数学与计算机科学中,递归(Recursion)是指在函数的定义中使用函数自身的方法。实际上,递归,顾名思义,其包含了两个意思:递
和归
,这正是递归思想的精华所在。
递归就是分为递去
和归来
,递去是指:递归的问题必须可以分解为若干规模较小,与原问题相同的子问题,这些子问题可以用相同的解题思路解决;归来是指:这些问题的演化过程是一个从小到大、由远及近的过程,并且会有一个明确的终点,一旦到了这个明确的终点后,就需要从原路返回到原点了(类比迷宫的分叉点),原问题就能解决了。
更直接地说,递归的基本思想就是把规模大的问题转化为规模小的相似的子问题来解决。特别地,在函数实现时,因为解决大问题的方法和解决小问题的方法往往是同一个方法,所以就产生了函数调用它自身的情况,这也正是递归的定义所在。格外重要的是,这个解决问题的函数必须有明确的结束条件,否则就会导致无限递归的情况。
数学归纳法用于将解决的原问题转化为解决它的子问题,而它的子问题的子问题,和原问题其实都是一个模型,也就是说存在相同的逻辑归纳处理项
。当然递归结束的最后一个子问题不是我们的逻辑归纳项,否则我们就要进行无穷递归了。
数学归纳法三个关键要素:
- 1)步进表达式:问题蜕变成子问题的表达式
- 2)结束条件:什么时候可以不再使用步进表达式
- 3)直接求解表达式:在结束条件下能够直接计算返回值的表达式
- 1)明确递归的终止条件
在递归的过程中,我们需要一个临界点来终止递归,这样当我们到达这个临界点时,就不用继续往下递去了,而是实实在在的归来。- 2) 给出递归终止时的处理办法
当到达递归临界点时,我们需要给出问题的解决方案,也就是给定一个具体的值而不是一个递归函数。一般地,在这种情景下,问题的解决方案是最直观的,最容易的。- 3) 提取重复的逻辑,缩小问题规模
我们在阐述递归思想内涵时谈到,递归问题必须可以分解为若干个规模较小、与原问题形式相同的子问题,这些子问题可以用相同的解题思路来解决。从程序实现的角度而言,我们需要抽象出一个干净利落的重复的逻辑,以便使用相同的方式解决子问题。
模板一:在递去中解决问题(回溯法模板)
function recursion(大规模){
if(end_condition){ //找到一个可行解,返回
end; //给出到达递归边界需要进行的处理
}
else{ //在将问题转换为子问题的每一步,解决该步中剩余部分的问题
solve; //解决该步中的剩余问题,递去
recursion(小规模); //转换为下一个子问题,递到最深处不断归来
}
}
模板二:在归来的过程中解决问题(分治法模板)
function recursion(大规模){
if(end_condition){ //到达最小子问题,然后就可以解决总问题了
end; //给出到达递归边界需要进行的处理
}
else{ //先将问题全部展开,然后再从尽头"返回"依次解决每步中剩余部分的问题
recursion(); //递去
solve; //递到最深处,不断归来
}
}
通常情况下,递归是一种直观而有效的实现算法的方法。 但是,如果我们不明智地使用它,可能会给性能带来一些不希望的损失,例如重复计算。 这时我们就需要使用一种称之为记忆术(memoization)
的方法,来避免这个问题。
比如:计算裴波那契数,F(n) = F(n - 1) + F(n - 2),F(0) = 0, F(1) = 1
下面这个数显示了在计算F(4)时发生的所有重复计算(按颜色分组)
为了消除上述情况中的重复计算,正如许多人已经指出的那样,其中一个想法是将中间结果存储在缓存中,以便我们以后可以重用它们,而不需要重新计算,这就是所谓的记忆术。
记忆化:
是一种优化技术,主要用于加快计算机程序的速度,方法是存储昂贵的函数调用的结果,并在相同的输入再次出现时返回缓存的结果。
对于上述计算斐波那契数列中的重复项计算,我们使用一个hashmap来存放
的键值对就好了,再遇到重复项时,我们直接返回这个重复项就好了,不需要重复计算。
尾递归:
尾递归函数是递归函数的一种,其中递归调用是递归函数中的最后一条指令。并且在函数中应该只有一次递归调用。区别尾递归与非尾递归最重要到一点就是最后一次递归调用中是否有额外的计算。
尾递归的好处:
它可以避免递归调用期间栈空间开销的累积,因为系统可以为每个递归调用重用栈中的固定空间。具体可参考:这里。
在学习和生活中,递归算法一般用于解决三类问题:
递归与循环是两种不同的解决问题的典型思路。递归通常很直白地描述了一个问题的求解过程,因此也是最容易被想到解决方式。循环其实和递归具有相同的特性,即做重复任务,但有时使用循环的算法并不会那么清晰地描述解决问题步骤。单从算法设计上看,递归和循环并无优劣之别。然而,在实际开发中,因为函数调用的开销,递归常常会带来性能问题,特别是在求解规模不确定的情况下;而循环因为没有函数调用开销,所以效率会比递归高。递归求解方式和循环求解方式往往可以互换,也就是说,如果用到递归的地方可以很方便使用循环替换,而不影响程序的阅读,那么替换成循环往往是好的。
问题的递归实现转换成非递归实现一般需要两步工作:
【类型3:数据结构是递归的】:344. 反转字符串
void reverseString(vector<char>& s){
reverseString(s,0,s.size()-1);
}
void reverseString(vector<char>& s,int i,int j){
if(i>j)return;//1、递归边界,函数就直接返回
swap(s[i],s[j]);//2、解决该步中的需要处理问题,递去
reverseString(i+1,j-1);//3、转换为下一个子问题,归来
}
【类型3:数据结构是递归的】:206.反转链表:
ListNode* reverseList(ListNode *head){
if(nullptr==head||nullptr==head->next)return head;//1、到达递归边界,函数直接返回头节点
ListNode *p=reverseList(head->next);//2、递归调用,缩小问题的规模
//3、归来的时候,进行solve
head->next->next=head;
head->next=nullptr;
return p;
}
【类型1:问题的定义就是按递归定义的】70.爬楼梯:
int climbStairs(int n){
int memo[n+1]={0};
return helper(0,n,memo);
}
int helper(int i,int n,int* memo){
if(i>n)return 0;//1、递归边界,i>n,函数直接返回0
if(i==n)return 1;//1、递归边界,i=n,函数直接返回1
//用来消除重复项,避免重复计算
if(memo[i]>0)return memo[i];
memo[i]=helper(i+1,n,memo)+helper(i+2,n,memo);//2、递归调用,缩小问题规模
return memo[i];//3、归来的时候,进行解决子问题中的剩余问题
}
【类型3:数据结构是递归的】:leetcode104:二叉树的最大深度
int maxDepth(TreeNode* root) {
if(root==nullptr) return 0;//1、递归边界
else return 1+max(maxDepth(root->left),maxDepth(root->right));//非尾递归,在递归的时候解决该步中的部分问题,然后转换为下一个子问题
}
【类型3:数据结构是递归的】:leetcode21:合并两个有序链表
ListNode* mergeTwoLists(ListNode* l1, ListNode* l2)
{
if(l1==NULL)return l2;//1、递归边界
if(l2==NULL)return l1;
ListNode *head;
if(l1->val<=l2->val)
{
head=l1;//2、解决的该步中的部分问题,递去
head->next=mergeTwoLists(l1->next,l2);//3、转换为下一个子问题,归来
}
else
{
head=l2;//2、解决的该步中的部分问题,递去
head->next=mergeTwoLists(l1,l2->next);//3、转换为下一个子问题,归来
}
return head;
}
【类型3:数据结构是递归的】:leetcode95:不同的二叉搜索树Ⅱ
vector<TreeNode*> generateTrees(int n) {
if(n==0)return {};
return helper(1,n);
}
vector<TreeNode*> helper(int begin,int end){
vector<TreeNode*> result;
if(begin>end)//此时没有数字
{
result.push_back(nullptr);
return result;
}
for(int i=begin;i<=end;++i)
{
vector<TreeNode*> left_trees=helper(begin,i-1);//得到左子树的集合
vector<TreeNode*> right_trees=helper(i+1,end);//得到右子树的集合
for(auto l:left_trees){
for(auto r:right_trees){
TreeNode* root=new TreeNode(i);//数字i作为根节点
root->left=l;
root->right=r;
result.push_back(root);
}
}
}
return result;
}
注:递归题持续更新,未完待续~