目录
递归与分治相关知识
递归
将问题逐步分解为更小的子问题。直到可以直接解决该子问题。然后将结果逐层返回。
分治
将问题拆解成独立的子问题。分别求解这些子问题,最后将解合并得到原始问题的解。
递归与分治的区别
递归是调用自身解决子问题,是逐层包含的关系。解决完了下层问题的解后逐层返回才可以求上层的问题的解。最终得到原始问题的解。
而分治是拆成独立的不同子问题,解决子问题时不一定要用同样的方法,各个子问题的解也不会互相影响。
递归与迭代的区别
递归是通过不断调用自身解决子问题。递归过程需要递归内容和递归终止条件。递归时需要的栈存储空间取决于递归深度。若递归次数过多,可能会造成栈溢出。
而迭代是通过循环解决问题的,不需要专门写递归函数。一般来说效率比较高。但代码复杂度会比递归高。
一般来说递归问题和迭代问题的求解可以相互转化。
相关问题
递归问题
1、汉诺塔问题
问题描述:
相传在古印度圣庙中,有一种被称为汉诺塔(Hanoi)的游戏。该游戏是在一块铜板装置上,有三根杆(编号A、B、C),在A杆自下而上、由大到小按顺序放置64个金盘(如图1)。游戏的目标:把A杆上的金盘全部移到C杆上,并仍保持原有顺序叠好。操作规则:每次只能移动一个盘子,并且在移动过程中三根杆上都始终保持大盘在下,小盘在上,操作过程中盘子可以置于A、B、C任一杆上。
问题解决:
设起始柱子为begin,终止柱子为end,中间暂用的柱子为temp。
#include <iostream>
#include <algorithm>
using namespace std;
int sum=0;
void hano(int n,char begin,char end,char temp)
{
if(n==1)
{
cout<<sum+1<<": ";
cout<<1<<" from "<<begin<<" to "<<end<<endl;
sum++;
}
else
{
hano(n-1,begin,temp,end);
cout<<sum+1<<": ";
cout<<n<<" from "<<begin<<" to "<<end<<endl;
sum++;
hano(n-1,temp,end,begin);
}
}
int main()
{
int n;
cin>>n;
getchar();
hano(n,'a','b','c');
cout<<"all steps: "<<sum<<endl;
return 0;
}
测试用例:
2、斐波那契数列问题
问题一:输入n输出斐波那契数列的第n项。
原始方法:
#include <iostream>
#include <algorithm>
using namespace std;
int feibo(int n)
{
if(n==1) return 1;
else if(n==2) return 1;
else return feibo(n-1)+feibo(n-2);
}
int main()
{
int n;
cin>>n;
cout<<feibo(n)<<endl;
return 0;
}
测试用例:
矩阵快速幂方法:
矩阵快速幂是一种高效求解斐波那契数列的方法,可以将时间复杂度降到O(log n)级别。
#include <iostream>
#include <vector>
using namespace std;
// 定义矩阵类型
typedef vector<vector<long long>> matrix;
// 矩阵乘法
matrix multiply(const matrix& A, const matrix& B)
{
int n = A.size(); //A矩阵行数
int m = A[0].size(); //A矩阵列数、B矩阵行数
int k = B[0].size(); //B矩阵列数
matrix C(n, vector<long long>(k)); //乘出来的矩阵行数为A的行数,列数为B的列数
for (int i = 0; i < n; i++) //矩阵乘法
{
for (int j = 0; j < k; j++)
{
for (int p = 0; p < m; p++)
{
C[i][j] += A[i][p] * B[p][j];
}
}
}
return C; //返回乘完后所得矩阵
}
// 矩阵快速幂
matrix matrix_pow(matrix A, int n)
{
matrix ans(A.size(), vector<long long>(A.size()));
// 初始化为单位矩阵
for (int i = 0; i < ans.size(); i++)
{
ans[i][i] = 1;
}
while (n > 0)
{
if (n & 1) //n的最后一位为1
{
ans = multiply(ans, A);
}
A = multiply(A, A); //更新A矩阵(累乘起来)
n >>= 1; //右移,相当于除以2
}
return ans; //返回乘完后的矩阵
}
// 计算Fibonacci数列的第n项
long long fibonacci(int n) {
if (n <= 0) {
return 0;
}
matrix A = {{0, 1}, {1, 1}}; // 初始矩阵
matrix ans = matrix_pow(A, n);
return ans[1][0];
}
int main()
{
int n;
while(cin>>n)
{
cout << fibonacci(n) << endl;
}
return 0;
}
测试用例:
关于矩阵快速幂的理论解释可以看一下这个视频,跟着推导一下会很清楚。
问题二:求第n项斐波那契数是奇数还是偶数。
奇+奇=偶,奇+偶=奇,偶+偶=偶
#include <iostream>
#include <algorithm>
using namespace std;
int feibo(int n)
{
if(n==1) return 1; //返回1代表奇数
else if(n==2) return 1;
else return (feibo(n-1)+feibo(n-2))%2; //返回模2计算结果
}
int main()
{
int n;
cin>>n;
if(feibo(n)==0) cout<<"even"<<endl;
else cout<<"odd"<<endl;
return 0;
}
测试用例:
3、求n的阶乘问题
输入整数n,输出n的阶乘
一、简单解法:
#include <iostream>
#include <algorithm>
using namespace std;
long long jiecheng(int n)
{
if(n==1||n==0) return 1;
else return n*jiecheng(n-1);
}
int main()
{
int n;
cin>>n;
long long ans;
ans=jiecheng(n);
cout<<ans<<endl;
return 0;
}
测试用例:
二、高精度的阶乘算法
算法思想来源于大数乘法。当c++无法处理long long以外的整数乘法时,需要借助高精度算法。
用数组倒序存储阶乘数。每乘一个新数时都要让这个新的数乘已算过的阶乘数的每一位,并进位(相当于先乘后加)
#include <iostream>
#include <algorithm>
#define N 100010
using namespace std;
int a[N];
int main()
{
long long n;
cin>>n;
a[0]=1; //设置哨兵位置存放数字有多少位
a[1]=1; //阶乘数至少为1,个位数先初始化为1
for(int i=2;i<=n;i++)
{
int carry=0; //进位
for(int j=1;j<=a[0];j++)
{
a[j]=a[j]*i+carry; //新的乘数要乘已有阶乘数的每一位。注意进位
carry=a[j]/10; //计算进位
a[j]%=10; //取个位
}
while(carry) //处理最高位的进位
{
a[0]++;
a[a[0]]=carry%10;
carry/=10;
}
}
for(int i=a[0];i>=1;i--) //逆序输出计算好的阶乘数
{
cout<<a[i];
}
cout<<endl;
return 0;
}
测试用例:
###关于高精度阶乘算法更详细的数学解释内容可以看这篇博文###
4、整数划分问题
问题描述:
整数划分问题是将一个正整数n分解成若干个正整数的和,其中每个正整数都可以重复使用,且分解出来的数的个数是不限定的。例如,将整数4分解成若干个正整数的和,可以有以下五种不同的分解方式:
4
3 + 1
2 + 2
2 + 1 + 1
1 + 1 + 1 + 1
经典的整数划分问题是要求对于给定的正整数n,计算将n分解成若干个正整数的和的所有不同的分解方式的数量,也就是求整数n的划分数。
#include <iostream>
#include <algorithm>
#define N 100010
using namespace std;
void part(int ans[],int l,int n,int nowmax)
{ //ans[]表示一个整数数组,用于存储在当前划分中选择的数字
//l记录当前ans数组的长度,即划分数
//n为需要划分的整数,nowmax为在当前递归层数中,可以选择的最大数字
if(n==0&&l>1) //找到一种划分方案,当至少有两个分解数时输出
{
for(int i=0;i<=l-2;i++)
{
cout<<ans[i]<<"+";
}
cout<<ans[l-1]<<endl;
}
else if(n>0 && nowmax>0) //说明还有可选的数,递归分解
{
for(int i=nowmax;i>=1;i--)
{
ans[l]=i;
part(ans,l+1,n-i,i);
}
}
}
int ans[N]; //全局变量自动初始化为0
int main()
{
int n;
cin>>n;
part(ans,0,n,n);
return 0;
}
测试用例:
5、全排列问题
问题描述:排列问题。设R={r1,r2,···,rn}是要进行排列的n个元素,R=R-{r1}。集合X中元素的全排列记为Perm(X)。(ri)Perm(X)表示在全排列 Pe的每个排列前加上前缀得到的排列。R的全排列可归纳定义如下:
当n=1时,Perm(R)=(r),其中r是集合R中唯一的元素;
n>1时,PerPerm(R)由(r1)Perm(R1),(r2)Perm(R2),···,(rn)Perm(Rn)构成。
思路分析:
递归求解。首先将第一个位置摘出来,这样问题就变成了第一个位置的排列和后面剩下数的全排列
对于第一个位置,可以让所有数都当一遍第一,在每个数当第一的时候,继续递归遍历后续数的全排列。
问题解决:
#include <iostream>
#include <algorithm>
using namespace std;
void swap(int &a,int &b) //定义交换函数
{
int t;
t=a;
a=b;
b=t;
}
void perm(int a[],int start,int end) //排列函数
{
if(start==end) //如果找到最后一轮(只剩一个数字)直接输出
{
for(int i=0;i<=end;i++)
{
cout<<a[i];
}
cout<<endl;
return;
}
else //否则继续排列
{
for(int i=start;i<=end;i++) //遍历每个数,让每个数都当一次第一个数
{
swap(a[start],a[i]); //将当前遍历到的数和第一个数交换
perm(a,start+1,end); //按此继续全排列除第一个数以外的后面的数
swap(a[start],a[i]); //这个数当第一的情况遍历完了,换回来,便于下一个数当第一
}
}
}
int main()
{
int n;
cin>>n;
getchar();
int a[n];
for(int i=0;i<n;i++)
{
cin>>a[i];
getchar();
}
sort(a,a+n);
perm(a,0,n-1); //注意范围是0到n-1,perm函数里的循环也要相应为<=end
return 0;
}
测试用例:
更详细的思路推导参考这篇文章
分治问题
分治解题步骤:
-
分解:将原问题划分成若干个规模更小的子问题。
-
解决:递归地求解每个子问题。
-
合并:将子问题的解合并成原问题的解。
需要注意的是:使用分治算法时,要保证子问题可以独立地求解,即子问题之间没有相关性。
效率分析:分治算法的时间复杂度为O(nlogn),优于朴素算法的O(n^2)
缺点:子问题规模可能划分不均衡导致效率降低。
1、二分搜索问题
给定已经排好的n个元素a[0:n-1],要在这n个元素中找出一特定元素x,返回x下标。
#include <iostream>
#include <algorithm>
#define N 10000
using namespace std;
int binarySearch(int a[],int x,int len)
{
int begin=0;
int end=len-1;
int mid=(begin+end)/2;
while(a[mid]!=x)
{
if(a[mid]>x)
{
end=mid-1;
mid=(begin+end)/2;
}
else
{
begin=mid+1;
mid=(begin+end)/2;
}
}
return mid;
}
int main()
{
int a[N];
int n,x;
cin>>n;
getchar();
for(int i=0;i<n;i++)
{
cin>>a[i];
getchar();
}
cin>>x;
getchar();
cout<<binarySearch(a,x,n)<<endl;
return 0;
}
测试用例:
2、棋盘覆盖
问题描述:
在一个2^k * 2^k*(k>=0)
个方格组成的棋盘中,恰有一个方格与其它方格不同,称该方格为特殊方格。棋盘覆盖问题要求用图2-5所示的4种不同形状的L型骨牌覆盖给定棋盘上除特殊方格以外的所有方格,且任何两个L型骨牌不得重复覆盖。
问题分析:
我们可以把棋盘划分为四个子棋盘,按照分治的想法,我们需要求出每个子棋盘中L骨牌覆盖的情况,再合并到一起即为问题的解。
下面我们思考如何求解四个子棋盘内的覆盖情况。特殊的那块格子必定落到某一个子棋盘内。这使得它不同于另外三个子棋盘。如果能采用同样的方法处理四块子棋盘,会使问题大大简化。
所以我们可以在棋盘的最中心,即每个子棋盘提供一个格子,用一块L骨牌覆盖没有特殊格子的子棋盘提供的那三个小格上。这样就相当于每个子棋盘都有了一块特殊格子。这样就可以用同一种方法处理四块子棋盘了。
效率分析:
按照教材上的算法代码可以解决此问题:
#include <iostream>
#include <algorithm>
using namespace std;
#define N 100
int tile=1; //特殊格子数
int board[N][N];
void ChessBoard(int tr,int tc,int dr,int dc,int size)
{
if(size==1)
{
return;
}
int t=tile++; //先增加特殊格子数
int s=size/2; //分割棋盘(s为当前棋盘的大小)
//覆盖左上角的子棋盘
if(dr<tr+s && dc<tc+s) //特殊格子在此子棋盘中,继续递归
{
ChessBoard(tr,tc,dr,dc,s);
}
else //若不在,就把右下角格子变成特殊格子(用t号骨牌覆盖)
{
board[tr+s-1][tc+s-1]=t;
ChessBoard(tr,tc,tr+s-1,tc+s-1,s); //继续递归覆盖其余方格
}
//覆盖右上角的子棋盘
if(dr<tr+s && dc>=tc+s) //特殊格子在此子棋盘中,继续递归
{
ChessBoard(tr,tc+s,dr,dc,s);
}
else //若不在,就把左下角格子变成特殊格子(用t号骨牌覆盖)
{
board[tr+s-1][tc+s]=t;
ChessBoard(tr,tc+s,tr+s-1,tc+s,s); //继续递归覆盖其余方格
}
//覆盖左下角的子棋盘
if(dr>=tr+s && dc<tc+s) //特殊格子在此子棋盘中,继续递归
{
ChessBoard(tr+s,tc,dr,dc,s);
}
else //若不在,就把右上角格子变成特殊格子(用t号骨牌覆盖)
{
board[tr+s][tc+s-1]=t;
ChessBoard(tr+s,tc,tr+s,tc+s-1,s); //继续递归覆盖其余方格
}
//覆盖右下角的子棋盘
if(dr>=tr+s && dc>=tc+s) //特殊格子在此子棋盘中,继续递归
{
ChessBoard(tr+s,tc+s,dr,dc,s);
}
else //若不在,就把左上角格子变成特殊格子(用t号骨牌覆盖)
{
board[tr+s][tc+s]=t;
ChessBoard(tr+s,tc+s,tr+s,tc+s,s); //继续递归覆盖其余方格
}
}
int main()
{
int size=1;
cin>>size;
int dr,dc;
cin>>dr>>dc;
ChessBoard(0,0,dr,dc,size);
for(int i=0;i<size;i++)
{
for(int j=0;j<size;j++)
{
printf("%6d",board[i][j]);
}
cout<<endl;
}
return 0;
}
测试用例:
3、合并排序
分:不断将待排序列分半
治:分别排序
合:再合并,并将排序结果copy到原数组
效率分析:
注意空间复杂度为O(n),因为设置了一个辅助数组用于合并排序结果。
#include <iostream>
#include <algorithm>
using namespace std;
#define N 1000
int num[N];
int tem[N];
void merge_sort(int a[],int left,int right)
{
if(left>=right) return; //递归出口
int mid=(left+right)/2;
merge_sort(a,left,mid); //分成两个子部分,分别排序
merge_sort(a,mid+1,right);
int k=0,i=left,j=mid+1;
while(i<=mid&&j<=right) //分别排完序后合并
{
if(a[i]<=a[j])
{
tem[k++]=a[i++];
}
else
{
tem[k++]=a[j++];
}
}
while(i<=mid) tem[k++]=a[i++]; //没排完的继续放入,连着放即可,先i后j
while(j<=right) tem[k++]=a[j++];
for(int i=left,j=0;i<=right;i++,j++) //合并完的结果copy到原数组
{
a[i]=tem[j]; //一定要注意tem[]要单独设一个j下标遍历,因为上面每次都是从k=0开始放的
}
}
int main()
{
int n;
cin>>n;
getchar();
for(int i=0;i<n;i++)
{
cin>>num[i];
getchar();
}
merge_sort(num,0,n-1);
for(int i=0;i<n;i++)
{
cout<<num[i]<<" ";
}
cout<<endl;
return 0;
}
测试用例:
4、快速排序
关于快速排序之前写过一版选基准记录的三者取中快排算法,可以直接看这个
由于划分的时候pivot选取、移动的代码可以有很多不同形式,在这里也再写一版较为易懂的。
#include <iostream>
#include <algorithm>
using namespace std;
#define N 1000
void swapp(int &x,int &y)
{
int t;
t=x;
x=y;
y=t;
}
int part(int a[],int left,int right)
{
int pivot = a[left]; //直接选最左边的元素当pivot
int i=left,j=right;
while(i<j)
{
while (a[j]>=pivot && i<j) j--;
while(a[i]<=pivot && i<j) i++;
if(i<j) swapp(a[i],a[j]);
}
a[left]=a[i]; //将基准元素换到中间
a[i]=pivot;
return i; //返回基准元素下标
}
void quicksort(int a[],int left,int right)
{
if(left>=right) return; //递归出口
int mid=part(a,left,right);
quicksort(a,left,mid-1); //左边再排序
quicksort(a,mid+1,right); //右边再排序
}
int main()
{
int n;
cin>>n;
getchar();
int num[N];
for(int i=0;i<n;i++)
{
cin>>num[i];
getchar();
}
quicksort(num,0,n-1);
for(int i=0;i<n;i++)
{
cout<<num[i]<<" ";
}
cout<<endl;
return 0;
}
测试用例:
5、最接近点对问题
基本思想:
以二维点(平面上的散点)举例。首先将所有点按照横坐标排序。点数较小(<=3)时可直接两两求间距取最小。若点数较多可分治,分成左右两部分,分别求左边最近的两点间距和右边最近的两点间距。在左右求最小距离时同理,点数少直接求,点数多继续分治。
需要注意的是,左右求出最小值,比较再取最小(记为min)之后还不是问题的解。我们还需要考虑中间被划分的地方是否有解优于左右的解。
所以,寻找距离划分中点<=min的一部分点,并按纵坐标将其排序,考察距离划分中点的纵坐标差,大于min的直接舍去,因为横纵坐标平方和不可能比min还小。纵坐标差小于min的可以算一下距离,若小于min就更新min。
例题:套圈问题
可以看之前写的这篇<算法学习>分治——求最近两点间距离
6、循环赛日程表
题目描述:
设有n=2^k个运动员要进行网球循环赛。现要设计一个满足以下要求的比赛日程表:
(1)每个选手必须与其他n-1个选手各赛一次;
(2)每个选手一天只能参赛一次;
(3)循环赛在n-1天内结束
解题方法:
此问题可以通过递归和循环两种方式解决。
这篇文章写得很清晰 循环赛日程表 (递归与分治)
可以看一下算法分析部分图文。
具体代码我再多解释一下
1、递归方法 (只看table函数即可,其他不过多解释)
#include<iostream>
#include<cmath>
using namespace std;
int a[100][100];
void table(int k, int d)
// 边长 步长
{
if (k == d) //边长等于步长时,说明分块copy已经完成,直接返回
return;
int i, j;
for (i = 0; i < d; i++) //copy的具体过程
{
for (j = 0; j < d; j++)
{
a[i + d][j + d] = a[i][j]; //先将左上角的小边长为d的正方形copy到右下角
a[i][j + d] = a[i][j] + d; //再将左上角小边长为d的正方形每个数+d copy到右上角
a[i + d][j] = a[i][j] + d; //和左下角
}
}
table(k, d * 2); //扩大步长,继续copy,直到达到规定人数大小的大正方块
}
int main()
{
//输入人数
int n;
cout << "学生人数k=2^n,请输入k:";
int k;
cin >> n;
k = pow(2, n);
//判断只有一个人时
if (k == 1)
a[0][0] = 0;
else
a[0][0] = 1;
//递归
table(k, 1);
//输出
for (int i = 0; i < k; i++)
{
for (int j = 0; j < k; j++)
{
cout << a[i][j]<<' ';
}
cout << endl;
}
}
2、 循环方法 (只看Table函数即可,其他不过多解释)
#include<iostream>
#include<cmath>
#define N 50
using namespace std;
int a[N][N];
void Table(int k);
void print(int k);
int main()
{
int k;
cout << "设参赛选手的人数为n(n=2^k),请输入k 的值:";
do
{
cin >> k;
if (k != 0)
{
Table(k); //注意传的是指数,不是直接传总人数,因为后面要用k做循环轮数
print(k);
}
else
cout << "您输入的数据有误,请重新输入!" << endl;
} while (k != 0);
}
void Table(int k)
{
int n = 1;//数组下标从1开始
for (int i = 1; i <= k; i++)
n *= 2;//求总人数(相当于pow函数)
for (int i = 1; i <= n; i++)
a[1][i] = i;//初始化,第一行等于1--n
int m = 1;//填充起始位置(用来控制每一次填表时i行j列的起始填充位置)
for (int s = 1; s <= k; s++)//总共循环k次(s指对称赋值的总循环次数,即分成几大步进行制作日程表)
{
n = n / 2; //表示一行分几组和上面行进行对称copy
for (int t = 1; t <= n; t++)//分的块数(t指明内部对称赋值的循环次数)
{
for (int i = m + 1; i <= 2 * m; i++) //copy具体过程
for (int j = m + 1; j <= 2 * m; j++)
{
a[i][j + (t - 1) * m * 2] = a[i - m][j + (t - 1) * m * 2 - m];//左上角赋给右下角
a[i][j + (t - 1) * m * 2 - m] = a[i - m][j + (t - 1) * m * 2];//右上角赋给左下角
}
}
m *= 2;//更新填充起始位置
}
}
void print(int k)
{
int i, j;
int n = pow(2, k);
for (i = 1; i <= n; i++)
{
for (j = 1; j <= n; j++)
{
cout << a[i][j] << ' ';
}
cout << "\n";
}
}
7、找第k小元素问题
跟快速排序有点像,不过不用完全排好,只需要找pivot恰好是在第k个元素位置上即可。
具体算法和例题看这篇比较好。
8、Strassen矩阵乘法和大整数乘法
可以直接看这两篇文章,写的都很好,我就不再赘述了。
《算法导论》第四章-矩阵乘法的Strassen算法(含C++代码)
the end
参考文章和书目:
计算机算法设计与分析(第五版)