浅谈根号分治
本文章同步发表在博客园。
什么是根号分治?
根号分治。听起来好像是一个很高级的什么递归做法,但实质上。它,只是一个暴力缝合怪而已。仅此而已。
根号分治很简单的。其实,就是解决一个题目有两种暴力方法,第一种方法的时间复杂度是
显然,当
分割开来。如果给定的
这,就是根号分治。
解答根号分治题目的方案
解决根号分治的题目很简单。联想到根号分治更是简单。
为什么这么说呢?因为根号分治的本质就是把两种暴力算法拼凑在一起而已,因此只要想题的时候想到了两种不同的暴力算法并且可以拼起来就行啦。注意时间复杂度的一个取向不能是完全相同的,不然就没有意义了。
例题一:CF797E
上来就可以想到两种最暴力的方案:
- 直接上 DP,算出每种
p 和k 的答案。- 具体地,定义
dp_{i,j} 表示p=i 且k=j 的情况下,需要执行多少次操作。 - 转移的时候倒序枚举
i 从n 到1 ,正序枚举j 从1 到n 。 - 然后判断一下,如果
i+a_i+j>n 那么直接让dp_{i,j} = 1 ,否则转移dp_{i,j} = dp_{i+a_i+j,j} + 1 。
- 具体地,定义
- 询问给定
p 和k 之后直接暴力模拟。- 这个显然不用怎么多说,一直
while循环按照题目所述来模拟一遍。 - 每次让
p \gets p+a_p+k ,然后cnt \gets cnt+1 ,直到p > n 就跳出。 - 输出
cnt 所得数值即可。
- 这个显然不用怎么多说,一直
显然这两种方案直接做都是会超时的,但是想想看吧,当
考虑取分界线为 毕竟它叫根号分治,怎么也要在根号的位置分一下界吧,哈哈。
具体地,DP 只做到
然后就结束了。
#include<bits/stdc++.h>
#define LL long long
#define UInt unsigned int
#define ULL unsigned long long
#define pii pair<int,int>
#define fr first
#define se second
#define pb push_back
using namespace std;
const int N = 1e5+5 , M = 405;
int n,m,Q,a[N],dp[N][M];
int read(){
int su=0,pp=1;char ch=getchar();
while(ch<'0'||ch>'9'){if(ch=='-')pp=-1;ch=getchar();}
while(ch>='0'&&ch<='9'){su=su*10+ch-'0';ch=getchar();}
return su*pp;
}
int main(){
n=read();m=sqrt(n);
for(int i=1;i<=n;i++)cin>>a[i];
for(int i=n;i>=1;i--)for(int j=1;j<=m;j++)
dp[i][j]=(i+a[i]+j>n?1:dp[i+a[i]+j][j]+1);
Q=read();while(Q--){
int p=read(),k=read();if(k<=m)cout<<dp[p][k]<<"\n";
else{int cnt=0;while(p<=n)p+=a[p]+k,cnt++;cout<<cnt<<"\n";}
}
return 0;
}
见证到根号分治的力量了吗?它可以轻松将两种暴力结合起来,成为正解!
例题二:CF786C
仍然考虑两种暴力方案:
- 直接暴力算出每个
k 所对应的划分的方案。- 具体地,可以使用类似双指针的方法,把整个数列扫一遍。
- 由于
k 已经固定,因此限制是已经知道了的。 - 可以用桶标记目前出现过的数字,每次新加一个数就将桶的那个位置置为
1 ,并且根据这次是否是第一次出现来加减cnt 。 - 如果
cnt 超过k 了,那么就要ans \gets ans + 1 ,并且重置桶和cnt ,然后不断计算就行了。 - 注意出了循环之后如果还有遗留的
cnt ,也要ans \gets ans + 1 哦。 - 当然,不要忘记最后还得清空桶哦!
- 用二分的方式,每次求出某个
k 的答案后,就二分到这种答案能延续到哪个k^\prime 的位置,然后把所有k \sim k^\prime 的ans 值全部重置。- 显然是二分答案,但是
check函数是非常简单的。 - 因为其逻辑就和第一种暴力方案是一样的,只不过最后返回的并不是
ans ,而是ans 是否在要求的区间划分个数内。 - 但是显然不能一个个枚举
k 啊,所以记录上一次到达的位置(即之前的k^\prime ),然后每次新开一轮就是k = k^\prime_{last} + 1 了。 - 实质上应该枚举的不是
k ,而是填进答案里的数值,即要求的区间划分个数m 。 - 每次算出新的
k 之后都得先check一下这个m 是否正确,如果不对的话说明最后的ans 中没有一个答案为m 的,那么直接跳过这一轮循环就好了。
- 显然是二分答案,但是
但是显然上面两种方案单独拎出来做都是不现实的。但是可以考虑根号分治结合它们呀!
分界线为
首先用第一种暴力方案算出
看看时间。第一种暴力方案的时间复杂度是
所以就 A 啦!
#include<bits/stdc++.h>
#define LL long long
#define UInt unsigned int
#define ULL unsigned long long
#define pii pair<int,int>
#define fr first
#define se second
#define pb push_back
using namespace std;
const int N = 1e5+5;
int n,Sq,a[N],Ans[N],Last;bool vis[N];
int min(int x,int y){return (x<y?x:y);}
int max(int x,int y){return (x>y?x:y);}
int read(){
int su=0,pp=1;char ch=getchar();
while(ch<'0'||ch>'9'){if(ch=='-')pp=-1;ch=getchar();}
while(ch>='0'&&ch<='9'){su=su*10+ch-'0';ch=getchar();}
return su*pp;
}
bool check(int m,int k){
int cnt=0,lst=1,sum=0;
for(int i=1;i<=n;i++){
if(!vis[a[i]])vis[a[i]]=1,cnt++;
if(cnt<=k)continue;
for(int j=lst;j<=i;j++)vis[a[j]]=0;
cnt=1,vis[a[i]]=1,sum++,lst=i;
}
if(cnt){for(int j=lst;j<=n;j++)vis[a[j]]=0;sum++;}
return (sum<m);
}
int main(){
n=read(),Sq=sqrt(n)+1,Last=Sq+1;
for(int i=1;i<=n;i++)a[i]=read();
for(int k=1;k<=Sq;k++){
int cnt=0,lst=1;
for(int i=1;i<=n;i++){
if(!vis[a[i]])vis[a[i]]=1,cnt++;
if(cnt<=k)continue;
for(int j=lst;j<=i;j++)vis[a[j]]=0;
cnt=1,vis[a[i]]=1,Ans[k]++,lst=i;
}
if(cnt){for(int j=lst;j<=n;j++)vis[a[j]]=0;Ans[k]++;}
}
for(int m=Sq;m>=1;m--){
int l=Last,r=n+1,Ed=Last;
if(check(m,l))continue;
while(l<=r){
int mid=(l+r)>>1;
if(check(m,mid))Ed=mid,r=mid-1;
else l=mid+1;
}
for(int i=Last;i<Ed;i++)Ans[i]=m;
Last=Ed;
}
for(int i=Last;i<=n;i++)Ans[i]=1;
for(int i=1;i<=n;i++)cout<<Ans[i]<<" ";cout<<"\n";
return 0;
}
总结
啊,好累啊,放过我吧,只讲两个例题应该够了吧,根号分治很好理解的。
根号分治。根号分治。根号分治。
碰到正解是根号分治的题目,应该是很难一眼看出“哦,这是一个根号分治的题目!”的吧。
我觉得啊,第一步应该是能够找到两个暴力做法。并且这两个暴力做法相互矛盾,或者说相反——具体点吧,大概就是说,你的优点是我的缺点,你的缺点到了我这儿却又变成了优点。
这种情况下根号分治就会上场来结合这两种做法以 AC 此题了。
根号分治也从另一方面启示了,做题时不要一上来就想正解,先想暴力!先想暴力!先想暴力!重要的事情说三遍
码这么多字也不容易,还麻烦你点个赞支持一下,真是太感谢啦!