递归(Recursion)就是子程序(或函数)直接调用自己或通过一系列调用语句间接调用自己,是一种描述问题和解决问题的基本方法。(如二叉树的定义)
直接或间接地调用自身的算法称为递归算法。用函数自身给出定义的函数称为递归函数。
递归有两个基本要素:
⑴ 边界条件:确定递归到何时终止;
⑵ 递归模式:大问题是如何分解为小问题的。
用一个通俗的故事来解释什么是递归就是:从前有座山,山中有座庙,庙里有个老和尚,老和尚在给小和尚讲故事:“ 从前有座山,山中有座庙,庙里有个老和尚,老和尚在给小和尚讲故事:“ 从前有座山,山中有座庙,庙里有个老和尚,老和尚在给小和尚讲故事:“ 从前有座山,山中有座庙,庙里有个老和尚,老和尚在给小和尚讲故事:“太困了不讲了”,于是都回去睡觉了。
由分治法产生的子问题往往是原问题的较小模式,这就为使用递归技术提供了方便。在这种情况下,反复应用分治手段,可以使子问题与原问题类型一致而其规模却不断缩小,最终使子问题缩小到很容易直接求出其解。这自然导致递归过程的产生。
分治与递归像一对孪生兄弟,经常同时应用在算法设计之中,并由此产生许多高效算法。
递归问题解题思路
- 大问题转小问题,定义小问题函数
- 找到递归出口
- 递推函数关系式
一个递归函数的调用过程类似于多个函数的嵌套调用,只不过调用函数和被调用函数是同一个函数。为了保证递归函数的正确执行,系统需设立一个工作栈。具体地说,递归调用的内部执行过程如下:
(1)运行开始时,首先为递归调用建立一个工作栈,其结构包括值参、局部变量和返回地址;
(2)每次执行递归调用之前,把递归函数的值参和局部变量的当前值以及调用后的返回地址压栈;
(3)每次递归调用结束后,将栈顶元素出栈,使相应的值参和局部变量恢复为调用前的值,然后转向返回地址指定的位置继续执行。
优缺点
递归算法结构清晰,可读性强,而且容易用数学归纳法来证明算法的正确性,因此,它为设计算法和调试程序带来很大方便,是算法设计中的一种强有力的工具。
但是,因为递归算法是一种自身调用自身的算法,随着递归深度的增加,工作栈所需要的空间增大,递归调用时的辅助操作增多,因此,递归算法的运行效率较低。
面试官 | 什么是递归算法?它有什么用? - Java中文社群_老王的个人空间 - OSCHINA - 中文开源技术交流社区
回溯
很喜欢一个博主的一句话:
理解回溯比较困难的是理解「回到过去」,现实世界里我们无法回到过去,但是在算法的世界里可以。
回溯是递归的一种形式,回溯算法实际是一个类似枚举的搜索尝试方法,其采用了一种“走不通就掉头”的思想,作为其控制结构(迷途知返的策略)。它的主题思想是在搜索尝试中找问题的解,当不满足求解条件就”回溯”返回,尝试别的路径。
回溯法是在包含问题的所有解的解空间树中。按照深度优先的策略,从根结点出发搜索解空间树,算法搜索至解空间树的任一结点时,总是先判断该结点是否满足问题的约束条件。如果满足进入该子树,继续按深度优先的策略进行搜索。否则,不去搜索以该结点为根的子树,而是逐层向其祖先结点回溯。
回溯法就是对隐式图的深度优先搜索算法。
(1) 适用问题:求解搜索问题和优化问题
(2) 搜索空间:树结点对应部分解向量,树叶对应可行解
(3) 搜索过程:采用系统的方法隐含遍历搜索树
(4) 搜索策略:深度优先,宽度优先,函数优先,宽深结合等
(5) 结点分支判定条件:
满足约束条件---分支扩张解向量
不满足约束条件,回溯到该结点的父结点
(6) 结点状态:动态生成
白结点(尚未访问);
灰结点(正在访问该结点为根的子树);
黑结点(该结点为根的子树遍历完成)
(7) 存储:当前路径
void backtracing(参数列表){
if(终止条件){
存放结果;
return;
}for(循环条件){
pass
backtracking(路径, 选择路径);
回溯,撤销选择;
}
}
如何正确的做一道题
- 从简入手: 先从简单暴力(时间复杂度高)的方法入手。
- 优化: 思考如何在第一步的基础上,如何优化算法,降低时间复杂度。
- 构思代码: 有了以上两步,我们此时应该已经有了一个正确的想法,此时我们应该构思代码,有那几部分,每部分实现什么功能,代码怎么写。而不是直接闷头去写代码,因为很多时候没想清楚,直接去写代码,会导致写了一半发现思路不对,写的代码都是错误的。
- 写代码: 实现第三步代码。
- (Debug): 如果我们的题目没有通过测试,应该检查代码是不是有bug、思路对不对等。
- 总结与反思: 题目通过了,我们应该总结一下这道题考察的知识点、切入的角度、同类型的题目等,同时思考有没有更优的办法。
经典习题(持续更新)
1
一只青蛙可以一次跳 1 级台阶或一次跳 2 级台阶,例如:跳上第 1 级台阶只有一种跳法:直接跳 1 级即可。跳上第 2 级台阶有两种跳法:每次跳 1 级,跳两次;或者一次跳 2 级。问要跳上第 n 级台阶有多少种跳法?
思路按三步走:
第一,大问题转化为小问题,若要跳到第n阶台阶,则只能从n-1阶或者n-2阶跳,而n-1只能由n-2或n-3跳……
第二,找到递归出口,就是n为跳第一阶或者第二阶的时候,此时,f(1)=1,f(2)=2;
第三,找到递推函数关系式,由上易得f(n) = f(n - 1) + f(n-2);
/*一只青蛙可以一次跳 1 级台阶或一次跳 2 级台阶,例如:
跳上第 1 级台阶只有一种跳法:直接跳 1 级即可。跳上第 2 级台阶
有两种跳法:每次跳 1 级,跳两次;或者一次跳 2 级。
问要跳上第 n 级台阶有多少种跳法? */
#include<stdio.h>
int f(int n)
{
if(n==1) //递归出口
return 1;
if (n==2) //递归出口
return 2;
return f(n-1)+f(n-2); //递归表达式
}
int main()
{
int n;scanf("%d",&n);
int num = f(n);
printf("%d",num);
}
此时间复杂度同斐波那契数列为指数级,显然是不能接受的,递归过程中有大量重复计算,且f(n)中n值越低的计算的次数越多, 随着 n 的增大,f (n) 的时间复杂度自然呈指数上升了,但博主还是入门阶段,暂时给不了优化方案,不过我会好好学习,以后写上的!!!
2.
全排列
给定一串无重复的字符或数字,输出其有所有排列,并统计其共有排列个数。
力扣https://leetcode.cn/problems/permutations/
第一,大问题转化为小问题,若要找到n个不重复数的全排列,则先找第一个个位置上能放的数,然后再找n-1的全排列……以此类推,则最后回归到找第一个数的全排列
第二,找到递归出口,就是找到第n个位置。
第三,找到递推函数关系式,
#include<stdio.h>
const int N = 10;
int arr[N],a[N];
bool book[N];
void sort(int a[],int n)
{
for(int i = 0;i<n;i++){ //一个基础的冒泡排序
for(int j = 0;j<n;j++){
if(i!=j){
if(a[i]<a[j]){
int temp =a[i];
a[i] = a[j];
a[j] = temp;
}
}
}
}
}
int dfs(int a[] , int n,int step)
{
if(step == n) //递归出口
{
for(int i=0;i<n;i++)
printf("%d ",arr[i]);
printf("\n");
}
for(int i=0;i<n;i++)
{
if(book[i] == false )
{
arr[step] = a[i];
book[i] = true;
dfs(a,n,step+1); //递归到下一层
book[i] = false; //递归结束,i+1下一层继续开始
}
}
}
int main()
{
int n;
scanf("%d",&n);
for(int i=0;i<n;i++) scanf("%d",&a[i]);
sort(a,n);//冒泡排序
dfs(a,n,0);//深搜
}
3.
将左边的树反转成右边的二叉树
还在学树,欸嘿嘿,晚点!
4.
递归函数的经典问题——汉诺塔问题
在世界刚被创建的时候有一座钻石宝塔(塔A),其上有64个金碟。所有碟子按从大到小的次序从塔底堆放至塔顶。紧挨着这座塔有另外两个钻石宝塔(塔B和塔C)。从世界创始之日起,婆罗门的牧师们就一直在试图把塔A上的碟子移动到塔C上去,其间借助于塔B的帮助。每次只能移动一个碟子,任何时候都不能把一个碟子放在比它小的碟子上面。当牧师们完成任务时,世界末日也就到了。
汉诺塔问题可以通过以下三个步骤实现:
(1)将塔A上的n-1个碟子借助塔C先移到塔B上。
(2)把塔A上剩下的一个碟子移到塔C上。
(3)将n-1个碟子从塔B借助塔A移到塔C上。显然,这是一个递归求解的过程
#include<stdio.h>
void move(char x,char y)
{
printf("把 %c 移到 %c\n",x,y);
}
void hanoi(int n,char A,char B,char C)
{
if(n==1)
move(A,C);
else
{
hanoi(n-1,A,B,C);
move(A,B);
hanoi(n-1,B,A,C);
}
}
int main()
{
int n;
scanf("%d",&n);
hanoi(n,'A','B','C');
}
5.
例3 Ackerman函数
当一个函数及它的一个变量是由函数自身定义时,称这个函数是双递归函数。
Ackerman函数A(n,m)定义如下:
A(n , m) 的自变量 m 的每一个值都定义了一个单变量函数:M=0 时, A(n,0)=n+2M=1 时, A(n,1)=A(A(n-1,1),0)=A(n-1,1)+2 ,和 A(1,1)=2 故 A(n,1)=2*nM=2 时, A(n,2)=A(A(n-1,2),1)=2A(n-1,2) ,和 A(1,2)=A(A(0,2),1)=A(1,1)=2 ,故 A(n,2)= 2^nM=3 时,类似的可以推出M=4 时, A(n,4) 的增长速度非常快,以至于没有适当的数学式子来表示这一函数。定义单变量的 Ackerman 函数 A(n) 为, A(n)=A(n , n) 。定义其拟逆函数 α(n) 为: α(n)=min{k | A(k)≥n} 。即 α(n) 是使 n≤A (k) 成立的最小的 k 值。α(n) 在复杂度分析中常遇到。对于通常所见到的正整数 n ,有 α(n)≤4 。但在理论上 α(n) 没有上界,随着 n 的增加,它以难以想象的 快 速度趋向正无穷大。
7.
整数划分问题:
将正整数n表示成一系列正整数之和:n=n1+n2+…+nk,其中n1≥n2≥…≥nk≥1,k≥1。正整数n的这种表示称为正整数n的划分。求正整数n的不同划分个数。 例如正整数6有如下11种不同的划分:
6;
5+1;
4+2,4+1+1;
3+3,3+2+1,3+1+1+1;
2+2+2,2+2+1+1,2+1+1+1+1;
1+1+1+1+1+1。
在本例中,如果设p(n)为正整数n的划分数,则难以找到递归关系,
因此考虑增加一个自变量:将最大加数n1不大于m的划分个数记作q(n,m)。可以建立q(n,m)的如下递归关系。
#include<stdio.h>
int divide(int n,int m) //m为n能划分的最大的数
{
if(n<1||m<1)
return 0;
if(n==1||m==1)
return 1;
if(n<m)
return divide(n,n);
if(n == m)
return divide(n,m-1)+1;
if(n>m&&m>1)
return divide(n,m-1)+divide(n-m,m);
}
int main()
{
int n,p;scanf("%d",&n);
p = divide(n,n);
printf("%d",p);
return 0;
}
8.
细胞分裂 有一个细胞 每一个小时分裂一次,一次分裂一个子细胞,第三个小时后会死亡。那么 n 个小时候有多少细胞?
暂无
9.
给你一个 无重复元素 的整数数组
candidates
和一个目标整数target
,找出candidates
中可以使数字和为目标数target
的 所有 不同组合 ,并以列表形式返回。你可以按 任意顺序 返回这些组合。
candidates
中的 同一个 数字可以 无限制重复被选取 。如果至少一个数字的被选数量不同,则两种组合是不同的。对于给定的输入,保证和为
target
的不同组合数少于150
个。输入:candidates =[2,3,6,7],target =7 输出:[[2,2,3],[7]] 解释: 2 和 3 可以形成一组候选,2 + 2 + 3 = 7 。注意 2 可以使用多次。 7 也是一个候选, 7 = 7 。 仅有这两种组合。
力扣
10.究极无敌令人头秃的n皇后问题
先放这腾位置
11.电话号码的数字排列
12.子集
给你一个整数数组
nums
,数组中的元素 互不相同 。返回该数组所有可能的子集(幂集)。解集 不能 包含重复的子集。你可以按 任意顺序 返回解集。
13.括号生成
数字 n
代表生成括号的对数,请你设计一个函数,用于能够生成所有可能的并且 有效的 括号组合。
14.单词搜索
给定一个 m x n
二维字符网格 board
和一个字符串单词 word
。如果 word
存在于网格中,返回 true
;否则,返回 false
。
单词必须按照字母顺序,通过相邻的单元格内的字母构成,其中“相邻”单元格是那些水平相邻或垂直相邻的单元格。同一个单元格内的字母不允许被重复使用。