递归是算法中的基础,但很多人似乎没有熟练的掌握它。
有些固有的算法与数据结构,本身特别适用递归求解。如:二叉树(一系列树的算法),线段树,深度优先搜索,二分(三分多分等)搜索,快速排序(第k大数,Randomized select),归并排序(逆序对数量),并查集等。
有些题目有着深刻的分治思想。如:棋盘覆盖问题,汉诺塔问题,最接近点对问题,循环赛日程表问题,Strassen矩阵,线性时间选择问题等。
以上就是本篇博客要讲诉的内容,作者并非圣贤如有问题请及时指正!
另:为了方便归纳,本文还将涉及:
递归思考组合数学中的组合数公式(基础公式,strling数,第一类第二类catlan数)
递归与迭代的相互转换,尾递归
深度优先搜索的优化问题(启发式搜索,迭代加深搜索),如何辨别BFS和DFS的使用场景(相信我,这绝对不是BFS求最短路所能完全分辨的)
递归的定义和简单例题不必赘述,自行百度。
事实上,有很多同学无法拥有递归的思路,为什么我们要用递归?
因为每一层递归做的事都是一样的!
比如,你小学想考好初中,初中想考好高中,高中想考好大学。递归函数做的就是学学学考考考,是同样的事情。
但是每一层递归想考的学校不同,年龄不同,班主任不同,分数线不同,这时候我们就需要把这些参数记录为变量,甚至可以用返回值记录这些数值。
有了递归我们就不必考虑具体哪一层做什么了。
其实,动态规划中的最优子结构也是这么一个性质,迭代也是可以重复做同样运行的方式。因此,递归大多可以转换为迭代,动态规划也可以转化为有备忘录的深搜,即记忆化搜索。(递归的效率通常比迭代差,但递归胜在思路清晰)
我们从一道经典的汉诺塔开始:
从两个盘子分析:
小盘子A->B,挪位子让大盘子出来,大盘子A->C,小盘子B->C。
三个盘子:
一个双层盘子和一个大盘子。
其中双层盘子的目的地是B。
整体的目的地是C。
四个盘子:
一个三层豪华盘子和一个大盘子。
…
于是可以写出如下函数:
#include
using namespace std;
void Hanoi(char a,char b,char c,int n)
{
if(n==1)
{
cout<<a<<"->"<<c<<endl;
}
else
{
Hanoi(a,c,b,n-1);
cout<<a<<"->"<<c<<endl;
Hanoi(b,a,c,n-1);
}
}
int main()
{
Hanoi('A','B','C',3);
return 0;
}
在汉诺塔问题中,我们把盘子的每个阶段分成了三个小阶段:小盘子A->B,挪位子让大盘子出来,大盘子A->C,小盘子B->C。
这就是分治。分治和递归密不可分,分治就像我们中学学习的分类讨论,通过对递归的不断分治,问题规模不断减小,直到我们知道答案的规模,也就是上面的一个盘子如何移动。
在解空间树中,分治会形成一颗解集树。
递归是一种手段,分治是一种思想。因此分治也可以选用迭代来解决。
下面我们就抛开递归,谈一谈分治:
棋盘覆盖问题比汉诺塔问题更具有分治的特点。
请跟我一起看如下问题:
void ChessBoard(int tr,int tc,int dr,int dc,int size)
{//tr左上角方格行号,tc左上角方格列号,dr,dc特殊方格位置
if(size==1)
return;
int t=tile++;//骨牌号
s=size/2;//规格
if(dr<tr+s&&dc<tc+S)
{
ChessBoard(tr,tc,dr,dc,s);//特殊方格在左上角
}
else
{
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
{
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
{
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
{
Board[tr+s][tc+s-1]=t;
ChessBoard(tr+s,tc+s,tr+s,tc+s,s);//特殊方格不在右下角
}
}
通过分治,size->size/2,减小了问题的规模。
个人见解:棋盘覆盖问题最巧妙的地方是将每一个楼梯形的小块在每一次递归实时编号,这是每一块编号必须经历的,也是本题的突破口。
分治可以是分成若干对等的类比,如二分搜索问题。
二分搜索不仅用于搜索一个数在排好序的数组中的位置,还应用于任何可以掰成两半的性质。在分界点左边不满足右边满足这个性质(对应模板1——最大值最小化问题),在分界点右边不满足左边满足这个性质(对应模板2——最小值最大化问题)。
模板1:mid包含在了左区里
while(l<r)
{
int mid=(l+r)>>1;
if(check(mid))
{
r=mid;
}
else
{
l=mid+1;
}
}
模板2:mid包含在了右区里
while(l<r)
{
int mid=(l+r+1)>>1;
if(check(mid))
{
l=mid;
}
else
{
r=mid-1;
}
}
模板2为什么要加一再右移呢?
仔细观察,在L=R-1时,M=(2L+1)/2
,【L,R】->【M,R】,就会产生死循环,这时候采取上取整就能避免了。
掌握好这两个模板,能够解决百分之八十的二分题目。
剩下百分之二十这里先作为一个坑以后再说。
二分法\多分法详解
1v1比赛嘛都是对称的。
观察循环赛日程表的对称性,我们将日程表不断二分,得到最后两个选手,让他们比赛就行了。
void table(int k,int **a)//a是二维数组
{
int n=1;
for(int i=1;i<=k;i++)
n*=2;//表格规模
for(int i=1;i<=n;i++)
a[i][i]=i;
int m=1;
for(int s=1;s<=k;s++)
{
n/=2;
for(int t=1;t<=n;t++)
{
for(int i=m+1;i<=2*m;i++)
{
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];//m是对称的间隔
a[i][j+(t-1)*m*2-m]=a[i-m][j+(t-1)*m*2];
}
m*=2;
}
}
}
}
分治也可以分成相等的类,并在最后对他们进行处理,例如快速排序与归并排序。
对于快速排序我们并不陌生,但是更多人选择用sort解决它。其实快速排序的思想还是十分精湛的。
快速排序的思想:揪一个数出来(一般为了方便我们就揪第一个数了,但是随机揪和揪中位数这两种方法效率更高),把数组分成三部分:这个数,比这个数大的数,比这个数小的数。再继续对比这个数大的数,比这个数小的数递归求解。
最后规模只有1返回,得到一个排好序的序列。
#include
#include
using namespace std;
const int N=1000010;
int a[N];
void qsort(int l,int r)
{
if(l==r)
return;
int t=a[l];
int i=l,j=r;
while(i!=j)
{
while(a[i]<=a[l])//前指针
i++;
while(a[j]>a[l])//后指针
j--;
int temp=a[i];
a[i]=a[j];
a[j]=temp;
}
int temp=a[l];
a[l]=a[i];
a[i]=temp;
qsort(l,i-1);
qsort(i+1,r);
}
int main()
{
int n;
cin>>n;
for(int i=0;i<n;i++)
{
cin>>a[i];
}
qsort(0,n);
for(int i=0;i<n;i++)
{
cout<<a[i]<<" ";
}
return 0;
}
快速排序可以应用于求第k大(小)数
#include
#include
using namespace std;
const int N=1000010;
typedef long long LL;
LL a[N];
LL qsort(LL l,LL r,LL k)
{
if(l>=r)
return a[l];
//cout<
LL i=l-1,j=r+1,x=a[l+r>>1];
while(i<j)
{
do{
i++;
}
while(a[i]<x);
do{
j--;
}
while(a[j]>x);
if(i<j)
{
LL temp=a[i];
a[i]=a[j];
a[j]=temp;
}
//cout<
}
//cout<
if(k<=j-l+1)
return qsort(l,j,k);
else
return qsort(j+1,r,k-(j-l+1));//如果是在右边
//求第k-(j-l+1)大数
}
int main()
{
LL n,k;
cin>>n>>k;
for(LL i=0;i<n;i++)
{
cin>>a[i];
}
LL res=qsort(0,n-1,k);
cout<<res<<endl;
return 0;
}
归并排序的基本思想:先对左半边排序,后对右半边排序,最后合并成一个有序序列。
归并排序可以在左右合并中通过互换的次数,计算出一个序列里逆序对的数量。下面是计算逆序对的代码:
#include
using namespace std;
long long cnt;
long long a[300010];
long long temp[300010];
void mergesort(long long a[],long long l,long long r)
{
if(l>=r)
return ;
long long mid=l+r>>1;
mergesort(a,l,mid);
mergesort(a,mid+1,r);
long long int i=l,j=mid+1,t=0;
while(i<=mid&&j<=r)
{
if(a[i]<=a[j])
temp[t++]=a[i++];
else
{
temp[t++]=a[j++];
cnt+=mid-i+1;
}
}
while(i<=mid)
{
temp[t++]=a[i];
i++;
}
while(j<=r)
{
temp[t++]=a[j];
j++;
}
for(long long i=l,j=0;i<=r;i++,j++)
a[i]=temp[j];
}
int main()
{
long long n;
cin>>n;
for(int i=0;i<n;i++)
{
cin>>a[i];
}
mergesort(a,0,n-1);
cout<<cnt;
return 0;
}
求逆序对还有另一种方法,树状数组求逆序对。
思路:我们知道,一个数做大数的逆序对数,取决于他之前有多少个比他大的数。
树状数组可以实现单点修改和区间查询,我们只需要对数组total进行操作即可。
【代码插入处】
还可以分成并不完全相等的类,比如最接近点对问题。
最接近点对问题思路:把平面分成两半,则线分为三类:1区的最接近点对,2区的最接近点对,跨区的最接近点对。
【代码插入处】
在计算复杂度时,我们总是秉承着计算次数最少的原则,来优化递归算法。Strassen矩阵问题,大整数乘法问题就是两个很好的例子。(这个放在这讲不太好,但是期末复习要用。。)
相信各位都学过线性代数,在一个二维矩阵中C=AB
则C11=A11B11+A12B21;
C12=A11B12+A12B22;
C21=A21B11+A22B21;
C22=A21B12+A22B22;
计算这些,假设原矩阵规模是n,我们需要8个n/2规模的乘法和4个n/2规模的加法,这时有聪明人就思考了(反正我也不知道怎么想出来的0-0):
X=A^(n/2)+B;
Y=C^(n/2)+D;
XY=(A(n/2)+B)* (C(n/2)+D)=AC2n/2+(AD+BC)2n/2+BD
我们变形一下就可以减少乘法次数了:
XY=(A(n/2)+B) (C(n/2)+D)=AC2n/2+((A-B)(D-C)+AC+BD)*2n/2+BD
在讲这些例子以前,我们讨论过很多递归能用迭代进行,但看完这些例子后,你可能怀疑这样复杂的形式能否真的转换为迭代,因此我们来讨论递归和迭代的相互转换(栈模拟递归)问题。
讲完这些基本的例子,相信你对递归和分治会有更深的理解。
本部分参考了洛谷夏令营的课程。
线段树是一种递归定义的数据结构。
其本质是动态的维护一个前缀和。
什么是前缀和呢?
我们在一个数组中,让s[i]=a[1]+…+a[i]。
做法就是:
for(int i=1;i<=n;i++)
{
s[i]+=a[i];
}
前缀和的用处:当我们要求a[l~r]的和时,我们可以用
s[r]-s[l-1]
除了一维前缀和,还有二维前缀和,二维前缀和中
与前缀和相应的一个技巧是差分,会在我的小技巧整理里面涉及。
我们回归线段树。
我们刚说到其本质是动态的维护一个前缀和,是什么意思呢?
当我们前缀和处理的数组中,如果要修改一个值,整个前缀和数组都会变化,显然是时间复杂度很高的事情,而线段树能实现单点修改(区间修改)后还能维护一个前缀和。
线段树采用数组存储,当根节点编号是x,左子节点是x<<1(即x2),右子节点是x<<1|1(即x2+1)。
根据树的基本性质不难看出,有N个结点的数组,变成线段是会有2N个结点。
当我们查询一段区间或者修改一个区间,这个区间最多被分成log个结点值之和。
在最基础的前缀和线段树中,有4个操作:
让我们看一下源代码:
#include
using namespace std;
const int N=1e6+10;
int sum[N<<2];
int n,m,a[N];
void pushup(int o)
{
sum[o]=sum[o<<1]+sum[o<<1|1];
}
void build(int o,int l,int r)
{
if(l==r)
{
sum[o]=a[l];
return;
}
int mid=l+r>>1;
build(o<<1,l,mid);
build(o<<1|1,mid+1,r);
pushup(o);
}
void change(int o,int l,int r,int q,int v)
{
if(l==r)
{
sum[o]+=v;
return;
}
int mid=l+r>>1;
if(q<=mid) change(o<<1,l,mid,q,v);
else change(o<<1|1,mid+1,r,q,v);
pushup(o);
}
int querysum(int o,int l,int r,int ql,int qr)
{
if(ql<=l&&r<=qr) return sum[o];
int ans=0;
int mid=l+r>>1;
if(ql<=mid) ans+=querysum(o<<1,l,mid,ql,qr);
if(qr>mid) ans+=querysum(o<<1|1,mid+1,r,ql,qr);
return ans;
}
int main()
{
cin>>n>>m;
for(int i=1;i<=n;i++)
{
cin>>a[i];
}
build(1,1,n);
while(m--)
{
int opt;
cin>>opt;
if(opt==1)
{
int x,y;
cin>>x>>y;
change(1,1,n,x,y);
}
if(opt==2)
{
int l,r;
cin>>l>>r;
cout<<querysum(1,1,n,l,r);
}
}
return 0;
}
实际上,只要是一个运算满足结合律,都可以用线段树维护,常用的有:异或和,区间最值。
下面放一个维护区间最小值的代码:
#include
using namespace std;
const int N=1e6+10;
int minv[N<<2];
int n,m,a[N];
void pushup(int o)
{
minv[o]=min(minv[o<<1],minv[o<<1|1]);
}
void build(int o,int l,int r)
{
if(l==r)
{
minv[o]=a[l];
return;
}
int mid=l+r>>1;
build(o<<1,l,mid);
build(o<<1|1,mid+1,r);
pushup(o);
}
void change(int o,int l,int r,int q,int v)
{
if(l==r)
{
minv[o]+=v;
return;
}
int mid=l+r>>1;
if(q<=mid) change(o<<1,l,mid,q,v);
else change(o<<1|1,mid+1,r,q,v);
pushup(o);
}
int querymin(int o,int l,int r,int ql,int qr)
{
if(ql<=l&&r<=qr) return minv[o];
int ans=0xcfcfcf;
int mid=l+r>>1;
if(ql<=mid) ans=min(ans,querymin(o<<1,l,mid,ql,qr));
if(qr>mid) ans=min(ans,querymin(o<<1|1,mid+1,r,ql,qr));
return ans;
}
int main()
{
cin>>n>>m;
for(int i=1;i<=n;i++)
{
cin>>a[i];
}
build(1,1,n);
while(m--)
{
int opt;
cin>>opt;
if(opt==1)
{
int x,y;
cin>>x>>y;
change(1,1,n,x,y);
}
if(opt==2)
{
int l,r;
cin>>l>>r;
cout<<querymin(1,1,n,l,r);
}
}
return 0;
}
与之相对的是树状数组,它与线段树比起来,有些功能没有线段树好用,但是人家模板短啊!
以上我们说的单点修改,区间查询,都是可以用树状数组的。
树状数组运用的是二进制压缩的方法。
树状数组使用lowbit操作,找到一个数最右侧的1,运用change函数实现对单点的更新:即包含了底层一个点的所有点的更新,实现方法:i=底层点x,i+=lowbit(i)。
query函数实现查询1~x的前缀和,只要:i=底层点x,i-=lowbit(i)。
代码如下:(建议背诵,做题时候也没什么时间给你想二进制不二进制的,不是吗?)
int lowbit(int x)//取二进制最低位1,跳过图中的空白结点
{
return x&(-x);
}
void change(int x,int d)
{
for(int i=x;i<=n;i+=lowbit(i))
c[i]+=d;
}
ll query(int x)
{
if(x==0) return 0;
ll res=0;
for(int i=x;i;i-=lowbit(i))
{
res+=c[i];
}
return res;
}
运用树状数组实现cdq分治和整体二分
下面要讲的功能是只能线段树实现的:区间修改。
我们不再满足于修改单个点,而是对[l,r]的值都进行修改,这时候如果每次都修改所有点,一定会产生相当高的时间复杂度。
我们可以记录下来一个点被修改的值,并打上懒标记,多次重复修改后,查询时再把所有点统一处理。
需要的操作:
#include
using namespace std;
const int N=1e6+10;
int sum[N<<2],add[N<<2];
int n,m,a[N];
void pushup(int o)
{
sum[o]=sum[o<<1]+sum[o<<1|1];
}
void build(int o,int l,int r)
{
add[o]=0;
if(l==r)
{
sum[o]=a[l];
return;
}
int mid=l+r>>1;
build(o<<1,l,mid);
build(o<<1|1,mid+1,r);
pushup(o);
}
inline void puttag(int o,int l,int r,int v)
{
add[o]+=v;
sum[o]+=(r-l+1)*v;
}
void pushdown(int o,int l,int r)
{
if(add[o]==0)
return;
add[o<<1]+=add[o];
add[o<<1|1]+=add[o];
int mid=l+r>>1;
sum[o<<1]+=add[o]*(mid-l+1);
sum[o<<1|1]+=add[o]*(r-mid);//r-(mid+1)+1
add[o]=0;//标记回收
}
void optadd(int o,int l,int r,int ql,int qr,int v)
{
if(ql<=l&&r<=qr)
{
puttag(o,l,r,v);
return;
}
int ans=0;
int mid=(l+r)>>1;
pushdown(o,l,r);
if(ql<=mid) optadd(o<<1,1l,mid,ql,qr,v);
if(qr>mid) optadd(o<<1|1,mid+1,r,ql,qr,v);
pushup(o);//add的时候既要pushup也要pushdown 修改的时候不下放标记就不能正确累加
}
int querysum(int o,int l,int r,int ql,int qr)
{
if(ql<=l&&r<=qr) return sum[o];
int ans=0;
int mid=l+r>>1;
pushdown(o,l,r);
if(ql<=mid) ans+=querysum(o<<1,l,mid,ql,qr);
if(qr>mid) ans+=querysum(o<<1|1,mid+1,r,ql,qr);
return ans;//query只要pushdown
}
int main()
{
cin>>n>>m;
for(int i=1;i<=n;i++)
{
cin>>a[i];
}
build(1,1,n);
while(m--)
{
int opt;
cin>>opt;
if(opt==1)
{
int x,y,z;
cin>>x>>y>>z;
optadd(1,1,n,x,y,z);
}
if(opt==2)
{
int l,r;
cin>>l>>r;
cout<<querysum(1,1,n,l,r)<<endl;
}
}
return 0;
}
洛谷夏令营中,整理了带标记的线段树处理套路,作者觉得还蛮有用的:
线段树还有个重要的应用就是扫描线。(hdu1542裸题)
从下向上扫描,min记录最小权值,sum记录宽度。
并查集是另一种递归定义的数据结构,他本质上是个在图上连边的活计。并查集可以运用于最小生成树,割点割边等问题。
并查集有两个操作
其中有两种优化方式:
启发式合并:每次将小的合并到大的,能减少常数复杂度。
这个技能很常用:平衡树,堆,线段树的合并都可以用。
实现代码(find):
int find(int a)
{
return f[a]==a?a:f[a]=find(f[a]);
}
实现代码(union):
void union(int x,int y)
{
int xx=find(x),yy=find(y);
if(size[xx]<size[yy])
swap(xx,yy);
f[yy]=xx;
size[xx]+=size[yy];
}
一道简单的哈希和并查集的题:
#include
using namespace std;
typedef long long ll;
int p[514748];
int mod=514747;
int a[100001];
int b[100001];
int find(int a)
{
return p[a]==a?a:(p[a]=find(p[a]));
}
void merge(int x,int y)
{
p[find(x)]=find(y);
}
int main()
{
ios::sync_with_stdio(false);
int n,t;
cin>>t;
for(int q=1;q<=t;q++)
{
int flag=1;
int cnt=0;
for(int i=0;i<=514747;i++)
p[i]=i;
cin>>n;
for(int k=1;k<=n;k++)
{
bool e;
ll i,j;
cin>>i>>j>>e;
if(e)
{
merge(i%mod,j%mod);
}
else
{
a[++cnt]=i%mod;
b[cnt]=j%mod;
}
}
for(int j=1;j<=cnt;j++)
{
if(find(a[j])==find(b[j]))
{
flag=0;
cout<<"NO"<<endl;
break;
}
}
if(flag)
cout<<"YES"<<endl;
}
return 0;
}
并查集的另一种写法:f[i]为负数表示到达了根节点,其余表示这个集合的size。【记得填坑】
并查集的应用场景
kruskal算法
联通块
左偏树等可合并堆
维护区间关系
带权并查集合并
数学中也有递归定义的数,你们没有发现,递归就是活脱脱的数学归纳法吗?
组合数中有很多奇妙的规律都是递归定义的。
组合数学与博弈论
还有用递归定义的数据结构,那就是树,树是一种没有环的图,这也决定了它可以一直往深处递归。
一些树的基础题和树形数据结构概述
树状DP本质上也是种分治算法
深度优先搜索其实是图论中的一个概念,DFS和BFS都是按照一颗解集树搜索下去的,理解解集树后,相信你会更好的理解搜索状态,顺序,和剪枝。
搜索详解