目录
一、递推
什么是递推?
核心:寻找规律,寻找关系式
我们都知道斐波那契数列:令f(0)=1、f(1)=1,当前的数是前两个数之和;
将其转化为数学关系,如下:
f(0)=1,f(1)=1;
f(n)=f(n-1)+f(n-2);
f(n)=f(n-1)+f(n-2)就是我们找到的关系式
于此,我们就可使用这个关系式来写出代码了。
例:打印斐波那契数列的第20个数;
AC代码:
#include<bits/stdc++.h>
using namespace std;
int fib[25];
int main(){
fib[1]=fib[2]=1;
for(int i=3;i<=20;i++) fib[i]= fib[i-1]+fib[i-2];
cout <<fib[20];
}
由此,我们可以知道,递推是寻找规律、寻找一个关系式,找到后,就可以很简单地写出代码了。
二、递归
1、什么是递归?
程序调用自身的编程技巧称为递归。 递归作为一种算法在程序设计语言中广泛应用。 一个过程或函数在其定义或说明中有直接或间接调用自身的一种方法,它通常把一个大型复杂的问题层层转化为一个与原问题相似的规模较小的问题来求解,递归策略只需少量的程序就可描述出解题过程所需要的多次重复计算,大大地减少了程序的代码量。
简单来说,可以把递归看作多重嵌套的循环语句。当最里层的循环语句执行结束后,将会往上一层进行回溯。
2、什么是回溯?
为了求得问题的解,先选择某一种可能情况向前探索。在探索过程中,一旦发现原来的选择是错误的,就退回上一步重新选择条件,继续向前探索,如此反复进行,直至得到解或证明无解。
我们还是用斐波那契数列数列来举例。
函数fib(20)计算斐波那契数。
递归过程:
递归前进:fib(20) = fib(19) + fib(18)
递归前进:fib(19) = fib(18) + fib(17)
递归前进:fib(18) = fib(17) + fib(16)
…
递归前进:fib(3) = fib(2) + fib(1)
到达终止条件:fib(2) = 1,fib(1) = 1
递归返回(回溯):
fib(3) = fib(2) + fib(1) =1+1=2
递归返回(回溯):
fib(4) = fib(3) + fib(2) =2+1=3
…
递归返回(回溯):
fib(20)=fib(19)+fib(18)=4181+2584=6765
#include<bits/stdc++.h>
using namespace std;
int cnt=0; //统计执行了多少次递归
int fib (int n){ //递归函数
cnt ++;
if (n==1 || n==2) return 1; //到达终止条件,即最小问题
return fib (n-1) + fib (n-2); //递归调用自己2次,复杂度O(2n)
}
int main(){
cout << fib(20); //计算第20个斐波那契数
cout <<" cnt="<<cnt; //递归了cnt=13529次
}
在代码中我们可以看到,递归执行的次数有13529次,打印第20个斐波那契数列就已经执行了上万次。为什么会这样呢?
在这个图中,我们可以看到fib(3)在计算fib(5)时已经计算了一次,在计算fib(4)又计算了一次。每计算一次就递归一次,则一共递归了两次。而除了一些部分顶层的数不会计算两次外(如fib(19)、fib(20)等),其他的数几乎将会计算两次,时间复杂度接近O(2^n)
3、对递归的改进--记忆化
递归的过程中做了重复工作。例如:fib(3)计算了2次,其实只算1次就够了。 为避免递归时重复计算,在子问题得到解决时,就保存结果,再次需要这个结果时,直接返回保存的结果,不继续递归下去。
这种存储已经解决的子问题结果的技术称为“记忆化”。 记忆化是递归的常用优化技术。
使用记忆化对上例的斐波那契数列进行优化:
#include<bits/stdc++.h>
using namespace std;
int cnt=0; //统计执行了多少次递归
int data[25]; //存储斐波那契数
int fib (int n){
cnt++; //执行一次递归就自增1
if (n==1 || n==2){data[n]=1; return data[n];}
if(data[n]!=0) return data[n];
//记忆化搜索:已经算过,不用再算,直接返回结果
data[n] = fib (n-1) + fib (n-2); //继续递归
return data[n];
}
int main(){
cout << fib(20); //计算第20个斐波那契数
cout <<" cnt="<<cnt; //递归了cnt=37次。
}
在代码中我们可以看到,此时递归只执行了37次,大大的减少了递归次数。
4、为什么可以把递归看成多重循环?
我们先来看一道题:
给你一个长度为3的环形数组,请你往里面填数字1--20,要求不能重复,而且相邻两个数的和为质数。 请输出所有的可能方案。 这个太简单啦,我写三个for循环就没啦!
如果是要求长度为5的素数环呢? 这个……我写5个for循环也行! 如果是100个,难道你写100个for循环……? (当然,咱先不考虑100个跑不跑的出来的问题了) 如果是素数环长度不固定,怎么办呢?
我们观察一下之前的for循环,我们发现每一层的for循环都非常的像。 那么我们有没有一个生成这些类似的for循环的办法,让程序自己去写这些很像的for循环呢? 回顾一下之前的基础数据结构,你能想到一个可以用得上的数据结构吗?
这题for循环的特点: 每一层结构类似。 在某一层中,我们要先计算下一层的答案,再返回回来枚举别的可能性。 我们考虑这些生成的for循环,先生成的后结束,后生成的先结束 是不是满足先进后出的数据结构?
我们发现这个很像一个栈。那么我们怎么去写呢? 我们可以用递归的办法,让系统帮我们生成一个系统栈(系统执行函数调用时,采用的是栈的方式。先将主函数放入栈,再将其他调用的函数依次放入栈),在我们使用递归时,就是在不断的生成函数、调用函数,当函数执行完毕后就会一个个出栈了。 这里可以回顾一下之前的求Fibonacci数列的过程理解一下。 相当于之前的函数里面只有一句话,而这里我写的是for循环。
5、递归的要点和代码框架
- 初始状态
- 目标状态
- 穷举范围
- 约束条件
- 状态恢复
搜索模板的套路:(后面写搜索算法时能更加深入理解)
先判断是否达到目标状态
如果达到,判断当前状态是否合法是否计入答案。
未达到,枚举可能的状态,记录本轮选择,进入下一层。
返回后,消除影响。
6、递归算法题
1、手写全排列
问题:给出n个数,求全排列。(可以直接用C++ next_permutation())
手写全排列的思路:
(1)让第一个数不同,得到n个数列
把第1个和后面每个数交换。
1 2 3 4 5......n
2 1 3 4 5......n
.....
n 2 3 4 5......1
这n个数,只要第一个数不同,不管后面n-1个数怎么排,这n个数的排列都不同。
(2)在上面的每个数列中,去掉第一个数,对后面的n-1个数进行类似的排列。
例如从第2行的{2 1 3 4 5......n}进入第二层。先去掉首位2,然后对n-1个做排列
1 3 4 5......n
3 1 4 5......n
......
n 3 4 5......1
这n-1个数列,只要第一个数不同,不管后面n-2个数怎么排,这n-1个数列都不同。 这是递归的第二层。
AC代码:
#include <bits/stdc++.h>
using namespace std;
const int N = 10010;
int a[N];
int n;
void dfs(int a[],int s,int t)
{
if(s==t) //s指向当前数组的位置,t为最终的数组位置 ;s=t说明此时已经找完了
{
for(int i=0;i<n;i++)//打印全排列
cout<<a[i]<<" ";
printf("\n");
}
else
for(int i=s;i<=t;i++) //从当前位置开始找直到找完
{
int temp=a[s]; //交换位置
a[s]=a[i];
a[i]=temp;
dfs(a,s+1,t);//深入下一层,去掉第一位的元素
temp=a[s]; //回溯
a[s]=a[i];
a[i]=temp;
}
}
int main(int argc, char** argv) {
cin>>n;
for(int i=0;i<n;i++)
cin>>a[i];
dfs(a,0,n-1);
return 0;
}
结果截图:
2、手写全排列变式
问题:输出前n个数任意m个的全排列
AC代码:
#include <iostream>
#include <bits/stdc++.h>
using namespace std;
/* run this program using the console pauser or add your own getch, system("pause") or input loop */
//输出前n个数m个的全排列
const int N = 1001;
int a[N];//输入的数
bool vis[N];//记录第i个数是否用过
int b[N];//生成的一个全排列
void dfs(int s, int t)
{
if(s==t)
{
for(int i=0;i<t;i++) //输出一个排列
cout<<b[i]<<" ";
cout<<"\n";
return;
}
for(int i=0;i<t;i++)
{
if(vis[i]==false) //如果第i个数没有用过
{
vis[i]=true;//将它标记为用过
b[s]=a[i];//把它放到数组b中
dfs(s+1,t);//下一层
vis[i]=false;//回溯
}
}
}
int main(int argc, char** argv) {
int n,m;
cin>>n>>m;//n为输入的数组长度,m为要查询的数组全排列的长度
for(int i=0;i<n;i++)
cin>>a[i];
dfs(0,m);
return 0;
}
结果截图:
递归(算法)求全排列(暴力法):
(1)n个元素的全排列=(一个元素作为前缀)+(剩下n-1个元素的全排列);
(2)结束:如果只有一个元素的全排列,说明已经排完,输出数组;
(3)不断将每个元素放作第一个元素,然后将这个元素作为前缀,并将其余元素继续全排列,等到结束。出去后还需要还原数组。
3、自写组合数算法
例:给出n个数,打印出其中的组合;
样例:以3个数{1, 2, 3}为例,打印其中的结合
AC代码:
#include <iostream>
#include <bits/stdc++.h>
using namespace std;
const int N = 1010;
int a[N]; //存储输入的数字
int vis[N];//标记第i个数是否被使用
int t;//t表示需要打印的数组长度;
void dfs(int k) //k表示当前指向第几个数
{
if(k==t) //k指向了t,表明全部搜完,打印
{
for(int i=0;i<t;i++)
if(vis[i]!=0) cout<<a[i]; //0为表示没有选这个数,1表示选了这个数
cout<<"\n";
}
else
{
vis[k]=0;//不选第k个数
dfs(k+1);//继续搜下一个数
vis[k]=1;//选这个数
dfs(k+1);//继续搜下一个数
}
}
int main(int argc, char** argv) {
cin>>t;//输入的数组长度
for(int i=0;i<t;i++)
cin>>a[i];
dfs(0); //从第一个数开始搜
return 0;
}
结果贴图:
4、自写组合数算法应用:打印二进制数的组合
样例:打印二进制数000 ~ 111
AC代码:
#include <iostream>
using namespace std;
const int N = 1010;
int vis[N];
int t;//表示要打印多少位的二进制的组合数
void dfs(int k) //k表示当前指向的位数
{
if(k==t) //表示搜完了,打印出来
{
for(int i=0;i<t;i++)
printf("%d",vis[i]);
cout<<endl;
}
else
{
vis[k]=0; //不选第k个,表明此处为0
dfs(k+1);//继续往下搜
vis[k]=1;//选第k个,表明此处为1
dfs(k+1);//继续往下搜
}
}
int main(int argc, char** argv) {
cin>>t;
dfs (0);//从第一个数开始搜
return 0;
}
结果截图:
总结递归求组合数:
(1)用一个数组表明第i个数选还是不选,选置为1,不选置为0
(2)用一个变量指明当前为第几个数
(3)当递归指向需要求t个数的组合,t的位置时,递归遍历数组当前位置的数,将选了的数打印出来。
5、五星填数 (全排列的应用)
题目描述
五星填数
本题为填空题,只需要算出结果后,在代码中使用输出语句将所填结果输出即可
如下图的五星图案节点填上数字:1 ~ 12,除去 7 和 11。 要求每条直线上数字和相等。
如图就是恰当的填法。
请你利用计算机搜索所有可能的填法有多少种。 注意:旋转或镜像后相同的算同一种填法。
思路分析:
- 步骤: 定义五星。(数据结构)
- 第一步:写出10个数的全排列。(算法)
- 第二步:判断每个直线上的数字和是不是相等。(逻辑)
- 第三步:剔除旋转、镜像相同的解。(思维)
定义五星数组: int s[]={0,1,2,3,4,5,6,8,9,10,12};//0不用
5个排列:
v[1]+v[3]+v[6]+v[9]
v[1]+v[4]+v[8]+v[10]
v[2]+v[3]+v[4]+v[5]
v[2]+v[6]+v[7]+v[10]
v[5]+v[7]+v[8]+v[9]
第一步:写出10个数的全排列。 一共有多少个排列:10!=3,628,800
1、笨办法:写一个10级的for循环:
for(i=1;i<=10;i++)
for(j=1;j<=10;j++) //并且让j不等于i
for(k=1;k<=10;k++) //并且让k不等于i,j
......
2、用库函数next_permutation()
3、自写递归求全排列
第二步:判断每个直线上的数字和是不是相等(逻辑)
v[1]+v[3]+v[6]+v[9]
v[1]+v[4]+v[8]+v[10]
v[2]+v[3]+v[4]+v[5]
v[2]+v[6]+v[7]+v[10]
v[5]+v[7]+v[8]+v[9]
第三步:剔除旋转、镜像 相同的解(思维)
旋转:一个解旋转5次,都相同;(把五角星旋转)
镜像:再乘以2。(镜像的旋转次数与原像的旋转次数一致
所以任意一种组合经过镜像和旋转都有10种不同的组合满足判断。最后结果除以10即可。
(1)next_permutation函数实现:
#include <iostream>
#include <bits/stdc++.h>
using namespace std;
//使用next_permutation函数实现全排列
int main(int argc, char** argv) {
int v[12]={0,1,2,3,4,5,6,8,9,10,12};//0不算
int sum=0;
do{
int t=v[1]+v[3]+v[6]+v[9];
if(t==v[1]+v[4]+v[8]+v[10]&&t==v[2]+v[3]+v[4]+v[5]&&t==v[2]+v[6]+v[7]+v[10]&&t==v[5]+v[7]+v[8]+v[9])
sum++;
}while(next_permutation(v+1,v+11));//将数组中的v[1]~v[11]进行全排列
cout<<sum/10;
return 0;
}
(2)手写全排列实现
#include <iostream>
#include <bits/stdc++.h>
using namespace std;
int v[12]={0,1,2,3,4,5,6,8,9,10,12};//0不算
int sum=0;
void dfs(int s,int k) //递归实现全排列
{
if(s==k)
{
int t = v[1]+v[3]+v[6]+v[9];
if(t==v[1]+v[4]+v[8]+v[10] && t==v[2]+v[3]+v[4]+v[5] &&
t==v[2]+v[6]+v[7]+v[10] && t==v[5]+v[7]+v[8]+v[9])
sum++;
}
else
for(int i=s;i<=k;i++)
{
swap(v[s],v[i]);
dfs(s+1,k);
swap(v[s],v[i]);
}
}
int main(int argc, char** argv) {
dfs(1,10);
cout<<sum/10;
return 0;
}
6、寒假作业 (手写全排列的剪枝)
题目描述
本题为填空题,只需要算出结果后,在代码中使用输出语句将所填结果输出即可。
现在小学的数学题目也不是那么好玩的。 看看这个寒假作业:
□ + □ = □
□ - □ = □
□ × □ = □
□ ÷ □ = □
每个方块代表 1~13 中的某一个数字,但不能重复。
比如:
6 + 7 = 13
9 - 8 = 1
3 * 4 = 12
10 / 2 = 5
以及:
7 + 6 = 13
9 - 8 = 1
3 * 4 = 12
10 / 2 = 5
就算两种解法。(加法,乘法交换律后算不同的方案)
你一共找到了多少种方案?
思路分析:
1、将1~13的数进行全排列,将会得到1~13的数出现在1~13的所有位置
(1)用permutations()函数,生成所有的排列,检查是否合法
运行时间极长:13个数的排列:13! = 6,227,020,800
(2)自写全排列:
不需要生成一个完整排列。 例如一个排列的前3个数,如果不满足“□ + □ = □”,那么后面的9个数不管怎么排列都不对。 这种提前终止搜索的技术叫“剪枝”。
2、将1~3的三个位置用于加法运算算,4~6的位置用于减法运算,7~9的位置用于乘法运算,10~12的位置用于乘法运算(但是由于除法运算容易出现小数和错误,再次改成乘法)
3、因为第一步已经将1~13进行了全排列,保证了所有可能出现的情况,所以第二步划分的位置可以保证当前所有可能出现的的情况都出现。
AC代码:
#include <iostream>
#include<bits/stdc++.h>
using namespace std;
int a[20]={1,2,3,4,5,6,7,8,9,10,11,12,13};//存储1~13个数字
bool vis[20];//表示当前第i个位置是否使用
int b[20];//存储1~13个数字的全排列,且将划分加减乘除的位置
int cnt=0;//计数
int n;//n表示要排列的前n个数
void dfs(int s) //s表示当前指向第i个数
{
if(s==12)//当s指向了12,说明前12个数都进行了全排列
{
if(b[9]*b[10]==b[11]) cnt++;//由于除法运算容易产生小数和错误,改成乘法;
return;
}
//剪枝
if(s==3&&b[0]+b[1]!=b[2]) return;//当s指向了3,说明前3个数全排列了
if(s==6&&b[3]-b[4]!=b[5]) return;//当s指向了6,说明前6个数全排列了
if(s==9&b[6]*b[7]!=b[8]) return;//当s指向了9,说明前9个数全排列了
for(int i=0;i<n;i++)//遍历0~n
{
if(vis[i]==false) //当前第i个数没有被使用
{
//这里为什么不用swap交换的原因:数组b存储了a数组的全排列,a的所有可能情况都在b中存储。
//只需将a数组的第i个数放在数组b的不同位置中,即构成了a数组的全排列
vis[i]=true;//表示数组a中的第i个数被使用了
b[s]=a[i];//将它放到数组b中的第s个位置
dfs(s+1);//递归到数组b的下一个位置,再在数组a中找一个没有被使用过的数放入
vis[i]=false;//已经找到了最底层,解锁回溯
}
}
}
int main(int argc, char** argv) {
cin>>n;
dfs(0);
cout<<cnt;
return 0;
}
以上就是递归和递推了。递归常常用在搜索、暴力解题的思维中;递推则是后面的动态规划的思维;所以递归和递推的最为基础的基础算法,牢牢掌握有助于后面学习更高阶的算法。