根号分治思想入门

· · 算法·理论

::::warning[注意]{open}

对于本文标有例题字样的题目,可以跟随笔者的思路一起思考,而对于本文标有练习字样的题目,建议先跟随笔者的提示自行思考后再查看详解。

::::

引入:

根号分治是一种优美的分治思想,更是 OI 中一个重要的 trick,能够融入进各种题目之中达成出其不意的效果,也能将一个极其复杂的题目简化为简单的暴力拼接,非常值得学习。

分治分治,顾名思义就是分而治之,一个问题往往有多种解决方法,它们的复杂度也不尽相同,那我们能不能将几个单独都无法通过的暴力做法通过一些组合,分治将其变为可以通过的做法呢?

这时就可以使用根号分治的思想,例如某一问题有两种做法,做法一复杂度 O(k) 而做法二是 O(\frac{n}{k}),两者若单独使用都能够造数据使其退化为 O(Qn),但是我们可以利用它们的性质,k 越大时,做法二越优秀,k 越小时,做法一越优秀。

所以我们就可以对 k 设置一个阈值 B,在 k>B 时使用算法二,否则就使用算法一,这时的单次操作复杂度就被我们优化为 O(B+\frac{n}{B}),这个时间复杂度取决于我们设置的阈值 B,利用基本不等式得 B+\frac{n}{B} \ge 2 \sqrt{n},取等条件为 B=\sqrt{n},所以设置阈值 B=\sqrt{n} 就可以得到理论最优复杂度 O(Q \sqrt{n}),这可比暴力的 O(Qn) 快了整整 \sqrt{n} 倍!

入门:

我们提到根号分治思想可以融合进许多题目之中,接下来就会展示一些较为简单的结合方式。

::::info[例题一:CF1207F Remainder Problem]{open}

CF1207F Remainder Problem,难度绿。

题意:

有一个长度为 5\times 10^5 的数列,下标从一开始,初始都为 0n 次操作,操作为单点加查询序列中所有下标模 x 等于 y 的元素之和。\ 数据范围 n,x,y \le 5 \times 10^5,时限四秒。

根据我们刚才所提的,根号分治思想是通过在不同情况下将数据分给不同复杂度形式的暴力来实现对单一暴力算法的优化的,那么请读者先思考几种暴力做法

:::info[暴力其一] 直接按题意模拟,单点加 O(1),查询 O(n) 跑一遍序列,总复杂度 O(n^2)。 :::

:::info[暴力其二] 维护所有模 xy 的元素之和,即预处理答案数组,查询时直接查表,由于每次修改受影响的答案数组元素数量是 O(x)=O(n) 量级的,故复杂度 O(x^2)=O(n^2)。 :::

看起来两个暴力毫无关联?仔细想想它们复杂度的性质。

这时我们发现:暴力一在 x 较大时比较快,即实际上的单次询问复杂度是 O(\frac{n}{x}) 的;暴力二则需要 x 小才快。

所以这就可以按 x 分治了,设置阈值 B

$x \le B$ 时就使用暴力二,这时查询的复杂度是 $O(1)$,修改的复杂度是 $O(x) \le O(B)$ 的。 根据基本不等式,设 $B=\sqrt{x}$ 即可取到理论最小复杂度,总复杂度为 $O(n \sqrt {x})=O(n \sqrt{n})$,四秒时限完全可以通过。 :::success[代码] ```cpp #include <bits/stdc++.h> using namespace std; //#define int ll //#define ll long long #define pb emplace_back #define pr pair<int,int> #define mp make_pair #define endl "\n" inline int read() { int x=0,f=1;char ch=getchar(); while (ch<'0'||ch>'9'){if (ch=='-') f=-1;ch=getchar();} while (ch>='0'&&ch<='9'){x=x*10+ch-48;ch=getchar();} return x*f; } void write(int x) { if(x<0)putchar('-'),x=-x; if(x<10)putchar(x+'0'); else write(x/10),putchar(x%10+'0'); } int n; int B=150; int a[500005]; int ans[155][155]; signed main(){ n=read(); for(int i=1;i<=n;i++){ int op=read(),x=read(),y=read(); if(op==1){ a[x]+=y; for(int j=1;j<=B;j++) ans[j][x%j]+=y; } else{ if(x<=B) cout<<ans[x][y]<<endl; else{ int s=0; for(int j=y;j<=500000;j+=x) s+=a[j]; cout<<s<<endl; } } } return 0; } ``` ::: :::: ::::info[练习一:P16522 自破碎的天空坠落]{open} [P16522 自破碎的天空坠落](https://www.luogu.com.cn/problem/P16522),难度绿。 ~~不是想宣传自己的题~~,但这题确实适合给根号分治新手进行练习。 ### 题意: 以 $y=kx+b$ 形式给 $n$ 条直线,$q$ 次询问,值域 $V$,每次查询有出多少条直线过**整点** $(x,y)$。\ 数据范围 $n,q \le 2 \times 10^5$,$|V| \le 2 \times 10^4$。 同样的,也请读者思考一些暴力做法和它们复杂度的性质。 :::info[暴力其一] 每次询问把 $n$ 条直线都遍历一遍,带入查询点看看是否满足解析式,时间复杂度 $O(qn)$。 ::: :::info[暴力其二] 记录出现过的斜率 $k$,将同种斜率的直线存在一个容器里面,每次查询遍历每种斜率并计算目标截距查询对应的数量,不同实现方法可以做到 $O(qV)$ 或 $O(qV \log n)$。 ::: :::info[暴力其三] 输入时直接预处理这条直线在 $V$ 范围内过了哪些整点,查询时直接查表 $O(1)$ 回答,复杂度 $O(nV+q)$。 ::: 那么接下来就要分析每种暴力的复杂度情况了,在根号分治中选择合适的暴力也是重要的一环。 :::info[复杂度分析] 暴力一是严格 $O(qn)$ 的。\ 暴力二在不同 $k$ 值少即 $k$ 范围较小的时候较快。\ 暴力三在 $k$ 值较大的时候较快,因为最多只会过 $\frac{V}{k}$ 个整点。 暴力二和暴力三都和 $k$ 有关,相信看到这大部分读者就能想到怎么进行根号分治了。 ::: :::success[正解] 暴力一是严格的,太劣,舍弃。 暴力二和暴力三的复杂度都和 $k$ 有关,所以考虑对 $k$ 分治,设置阈值 $B$。 暴力三在 $k$ 值大的时候快,所以当一条直线的 $k > B$ 时可以直接预处理过的整点数量,这部分复杂度 $O(\frac{nV}{B})$。 暴力二需要 $k$ 值的取值种类少,也就是 $k$ 的范围小,所以 $k \le B$ 时采用暴力二,按 $k$ 的值储存下所有斜率 $k \le B$ 的直线,查询时直接遍历所有不同 $k$ 值的容器按目标截距计算答案即可,这部分复杂度是 $O(qB)$ 或者 $O(qB \log n)$。 所以根据基本不等式,取 $B=\sqrt{V}$ 或 $B=\sqrt{V \log n}$ 就可以做到 $O(n \sqrt{V})$ 或 $O(n \sqrt{V \log n})$ 复杂度,可以通过本题。 ::: :::success[代码] 可以根据空间和时间的限制灵活调整阈值 $B$。 ```cpp #include<bits/stdc++.h> #include<bits/extc++.h> #define ll long long using namespace std; using namespace __gnu_pbds; inline ll read() { ll x=0,f=1;char ch=getchar(); while (ch<'0'||ch>'9'){if (ch=='-') f=-1;ch=getchar();} while (ch>='0'&&ch<='9'){x=x*10+ch-48;ch=getchar();} return x*f; } const ll N=2e5+5,V=2e4,B=600; struct line { int k,b; } li[N]; int cnt; gp_hash_table<short,gp_hash_table<short,int>> ans; int q[8005][40005]; inline ll gd(ll a,ll b) { if(b==0) return a; else return gd(b,a%b); } int n,qq; ll opt,x,y; int main() { n=read(); for(int i=1;i<=n;i++) { li[i].k=read(); li[i].b=read(); if(abs(li[i].k)>=B){ for(int j=-B;j<=B;j++){ int yy=j*li[i].k+li[i].b; if(yy<=V&&yy>=-V){ ans[j][yy]++; } } } else q[li[i].k+B][li[i].b+V]++; } qq=read(); for(int j=1;j<=qq;j++) { x=read(); y=read(); int anss=0; if(ans[x].find(y)!=ans[x].end())anss=ans[x][y]; for(int i=1;i<=2*B;i++){ ll zk=i-B; ll mb=y-(x*zk); if(mb>V||mb<-V) continue; anss+=(q[i][mb+V]); } printf("%d\n",anss); } } ``` ::: :::: ::::info[练习二:P16605 [SYSUCPC 2025] Sum]{open} [P16605 [SYSUCPC 2025] Sum](https://www.luogu.com.cn/problem/P16605),难度绿。 ### 题意: $T$ 组数据,每组给定 $n$ 和 $R$,求 $n$ 在 $2$ 进制到 $R$ 进制的表示小下的最小的数位和,$n,R \le 10^{12}$。 :::info[暴力] 对于每个进制都跑一遍进制转换,复杂度 $O(R \log n)$,不能通过。 ::: :::info[Trick] 一个经典的 Trick 就是一个数 $x$ 在大于 $\sqrt{x}$ 进制下只有 $2$ 位,分别是 $\frac{x}{Base}$ 和 $x \bmod {Base}$。 ::: 了解了上文的 Trick 后就可以思考如何根号分治了,显然是对当前的进制进行分治。 :::success[对于小于等于 $\sqrt{n}$ 进制] 直接进制转换然后算即可,这部分复杂度 $O(\sqrt{n} \log n)$。 ::: :::success[对于大于等于 $\sqrt{n}$ 进制] 首先这个数 $n$ 在二进制下的数位和一定小于等于 $\log_2 n$,所以我们只需要考虑 $\frac{x}{Base}+x \bmod {Base} \le \log_2 n$ 的了,于是直接算就行,这部分复杂度 $O(\sqrt{n})$。 ::: :::success[代码] 时间复杂度 $O(T \sqrt{n} \log n)$。 如果你被卡常了,可以考虑使用快读快写,用 ```unsigned long long``` 代替 ```long long``` 取模。 ```cpp #include <bits/stdc++.h> #define getchar_unlocked getchar #define putchar_unlocked putchar using namespace std; inline unsigned long long read() { unsigned long long x=0,f=1;char ch=getchar_unlocked(); while (ch<'0'||ch>'9'){if (ch=='-') f=-1;ch=getchar_unlocked();} while (ch>='0'&&ch<='9'){x=x*10+ch-48;ch=getchar_unlocked();} return x*f; } void write(unsigned long long x) { if(x<0)putchar_unlocked('-'),x=-x; if(x<10)putchar_unlocked(x+'0'); else write(x/10),putchar_unlocked(x%10+'0'); } unsigned long long m[1000005]; inline unsigned long long checker(register unsigned long long x,register unsigned long long y){ unsigned long long sum=0; while(x){sum+=x%y;x/=y;} return sum; } int t; unsigned long long q,r; int main(){ t=read(); for(unsigned long long i=1;i<=t;i++){ q=read();r=read(); if(q==2){write(1);putchar_unlocked('\n');} else if(q==3&&r==2){write(2);putchar_unlocked('\n');} else if(q==3&&r>=3){write(1);putchar_unlocked('\n');} else{ unsigned long long ans=q; unsigned long long g=sqrt(q); g=min(g,r); for(unsigned long long j=2;j<=g;j++)ans=min(ans,checker(q,j)); unsigned long long lim=min(q/r+5,g); for(unsigned long long j=1;j<=lim;j++){ unsigned long long k=q/j; if(k>=2&&k<=r)ans=min(ans,j+q%k); if(k-1>=2&&k-1<=r)ans=min(ans,j+(q%(k-1))); lim=min(lim,ans); } write(ans);putchar_unlocked('\n'); } } return 0; } ``` ::: 如果您已经熟练掌握根号做法,可以思考 $O(T n^{\frac{1}{3}} \log n)$ 的做法,这可以帮助您免去被卡常的烦恼的同时对根号分治思想有更深的了解。 :::: 至此我们已经了解了根号分治的一些简单应用,接下来更多的题目需要进行一定转化,不再像这一篇目一样容易被一眼秒。 ::::info[入门推荐练习题单] - [P16522 自破碎的天空坠落](https://www.luogu.com.cn/problem/P16522) - [P12751 [POI 2017 R2] 集装箱 Shipping containers](https://www.luogu.com.cn/problem/P12751) - [P16605 [SYSUCPC 2025] Sum](https://www.luogu.com.cn/problem/P16605) - [P8572 [JRKSJ R6] Eltaw](https://www.luogu.com.cn/problem/P8572) - [P3396 哈希冲突](https://www.luogu.com.cn/problem/P3396) - [CF1207F Remainder Problem](https://www.luogu.com.cn/problem/CF1207F) - [P1483 序列变换](https://www.luogu.com.cn/problem/P1483) - [P2425 小红帽的回文数](https://www.luogu.com.cn/problem/P2425) - [CF710D Two Arithmetic Progressions](https://www.luogu.com.cn/problem/CF710D) :::: ## 进阶 ::::info[例题一:P11287 [COTS 2017] 影响 Utjecaj]{open} [P11287 [COTS 2017] 影响 Utjecaj](https://www.luogu.com.cn/problem/P11287),难度蓝。 ### 题意: 给一张 $n$ 个点 $m$ 条边的无向图,每个点有点权,有一些点是关键点,$q$ 次操作,操作为单点修改点权或查询不经过除了 $x$ 以外的关键点就能到达 $x$ 的点的点权之和。\ $n,m,q\le 2 \times 10^5$。 首先是转化,一个点不对一个关键点有贡献,要不就是这两本来就不连通,或者是路径上有别的关键点,这看着很不好处理,**所以直接把关键点都删了先**,剩下的联通块显然可以缩成一个点,然后将关键点加回去即可。 接下来就是根号分治思想的运用了,先考虑两个暴力。 :::info[暴力其一] 修改点权时直接暴力更新和它相邻的关键点的答案,查询 $O(1)$ 修改最坏 $O(n)$。 ::: :::info[暴力其二] 修改点权时只考虑对自己联通块的影响,查询时把所有与这个关键点相邻的联通块都跑一遍累加求和,修改 $O(1)$ 查询最坏 $O(n)$。 ::: 我们发现这两个东西的复杂度都和某个点的度数 $d$ 有关啊,于是考虑对度数分治。 设置阈值 $B$。 对于 $d\le B$ 的点,修改时直接操作和它相邻的关键点即可,复杂度 $O(d) \le O(B)$。 对于 $d> B$ 的点,只修改自己的权值,查询时,枚举查询点周围所有 $d>B$ 的点累加答案,$d>B$ 的点最多只有 $\frac{m}{B}$ 个,于是复杂度 $O(\frac{m}{B})$。 取 $B=\sqrt{m}$ 即可得到理论最优复杂度 $O(q \sqrt{m})$。 :::success[代码] ```cpp #include <bits/stdc++.h> using namespace std; inline long long read() { long long x=0,f=1;char ch=getchar(); while (ch<'0'||ch>'9'){if (ch=='-') f=-1;ch=getchar();} while (ch>='0'&&ch<='9'){x=x*10+ch-48;ch=getchar();} return x*f; } long long n,m,qu; long long sn; long long tp[200005], a[200005], b[200005]; long long f[200005], dep[200005]; vector<long long> q[200005], g[200005]; unordered_set<long long> h[200005]; long long B; void bfs(long long p){ queue<long long>w; if(tp[p]==1) { b[p]+=a[p]; f[p]=p; return ; } w.push(p); while(!w.empty()){ long long mq=w.front(); w.pop(); if(f[mq]!=0) continue; b[p]+=a[mq]; f[mq]=p; for(long long i=0;i<q[mq].size();i++){ if(f[q[mq][i]]==0&&tp[q[mq][i]]==0)w.push(q[mq][i]); } } } int main(){ n=read(); m=read(); for(long long i=1;i<=n;i++) tp[i]=read(); for(long long i=1;i<=n;i++) a[i]=read(); for(long long i=1;i<=m;i++){ long long a=read(),b=read(); q[a].push_back(b); q[b].push_back(a); } for(long long i=1;i<=n;i++){ if(f[i]==0) bfs(i); } for(long long i=1;i<=n;i++){ for(long long j=0;j<q[i].size();j++){ if (f[i]!=f[q[i][j]]&&(tp[i]==0||tp[q[i][j]]==0)){ h[f[i]].insert(f[q[i][j]]); h[f[q[i][j]]].insert(f[i]); } } } for(long long i=1;i<=n;i++){ sn+=(dep[i]=h[i].size()); } B=max(1ll,(long long)sqrt(sn)); for (long long i=1;i<=n;i++){ if (tp[i]!=0){ for (long long j:h[i]){ if(dep[j]<=B) b[i]+=b[j]; else g[i].push_back(j); } } } qu=read(); for(long long i=1;i<=qu;i++){ long long op,w,e; op=read(); if(op==1){ w=read(); e=read(); if(dep[f[w]]<=B){ for (long long j:h[f[w]]){ if(tp[j]!=0) b[j]+=(e-a[w]); } } b[f[w]]+=(e-a[w]); a[w]=e; } else{ w=read(); long long ans=b[w]; for (long long j=0;j<g[w].size();j++){ ans+=b[g[w][j]]; } cout<<ans<<"\n"; } } return 0; } ``` ::: :::: ::::info[练习一:P5309 [Ynoi2011] 初始化]{open} [P5309 [Ynoi2011] 初始化](https://www.luogu.com.cn/problem/P5309),难度紫。 ### 题意: 长度为 $n$ 的序列,$m$ 次操作。\ 操作一从 $y$ 开始隔空加,步长 $x$,操作二查询区间和。\ $n,m \le 2 \times 10^5$。\ 保证 $y\le x$。 挺典的一个 Trick,因为要查询区间和,首先给序列分个块,查询为 $O(\sqrt{n})$。 步长较大时显然暴力,$O(\frac{n}{x})$,阈值设为 $\sqrt{n}$ 就是 $O(\sqrt{n})$ 复杂度。 思考步长较小时后的做法,注意这时的 $x$ 已经被我们控制在了较小的范围。 首先 $x$ 和 $y$ 都相同的操作直接累加贡献即可。 查询的时候我们需要遍历所有小 $x$,所以对每个 $x$ 的计算需要 $O(1)$,于是考虑前缀和。 :::success[正解] 感觉自己写的版本讲的不是很清楚,稍稍引用一下题解。 考虑记录 $f_{i,j}$ 表示 $x = i$,$y = j$ 的修改总和。 我们考虑如何统计答案。 $$ans=\sum_{i=l}^{r} a_i + \sum_{i=1}^{\sqrt{n}} \sum_{j=1}^{i} \left(f_{i,j} \times \left( \left\lfloor \frac{r-j}{i} \right\rfloor - \left\lfloor \frac{l-1-j}{i} \right\rfloor \right)\right)$$ 前缀和优化即可,具体的: 用 $s_{i,j}$ 表示 $f_{i,j}$ 对 $j$ 一维取前缀和,即 $s_{i,j} = \sum_{k=1}^{j} f_{i,k}$。 答案即可表示为: $$ans=\sum_{i=l}^{r} a_i + \sum_{i=1}^{\sqrt{n}} \left( \left(s_{i,i} \times \left( \left\lfloor \frac{r}{i} \right\rfloor - \left\lfloor \frac{l-1}{i} \right\rfloor \right)\right) + s_{i,r\bmod i} - s_{i,(l-1)\bmod i} \right)$$ 它本质上是计算了每个模数的完整周期贡献,再加上边界上的零头贡献。 修改时修改前缀和数组 $s$ 即可,因为 $y \le x$ 所以修改操作 $O(y) \le O(x) = O(\sqrt{n})$。 于是总复杂度 $O(n \sqrt{n})$,可以通过。 ::: :::success[代码] ```cpp #include <bits/stdc++.h> using namespace std; const long long MOD = 1000000007; inline long long read() { long long x=0,f=1;char ch=getchar_unlocked(); while (ch<'0'||ch>'9'){if (ch=='-') f=-1;ch=getchar_unlocked();} while (ch>='0'&&ch<='9'){x=x*10+ch-48;ch=getchar_unlocked();} return x*f; } int B=500; int G=120; int n,m; int wz[200005]; int mqk=0, mqs=B; struct fk{ int l,r; long long sum; long long a[505]; void csh(){ l=(mqk-1)*B+1; r=mqk*B; sum=0; } }k[505]; long long sumr[1005][1005]; long long suml[1005][1005]; signed main() { n=read(); m=read(); for(int i=1;i<=n;i++){ mqs++; if(mqs==B+1){ mqk++; mqs=1; k[mqk].csh(); } k[mqk].a[mqs]=read(); k[mqk].sum+=k[mqk].a[mqs]; k[mqk].sum%=MOD; wz[i]=mqk; } for(int u=1;u<=m;u++){ int op=read(); if(op==1){ long long x=read(),y=read(),z=read(); if(x>=G){ for(int j=y;j<=n;j+=x){ k[wz[j]].sum+=z; if(j%B==0) k[wz[j]].a[B]+=z; else k[wz[j]].a[j%B]+=z; if(j%B==0) k[wz[j]].a[B]%=MOD; else k[wz[j]].a[j%B]%=MOD; } } else{ for(int i=y;i<=x;i++){sumr[x][i]+=z;sumr[x][i]%=MOD;} for(int i=y;i>=1;i--){suml[x][i]+=z;suml[x][i]%=MOD;} } } else { int l=read(),r=read(); long long ans=0; int sl=wz[l],sr=wz[r]; if(sl==sr){ for(int i=1;i<=B;i++){ if(l<=k[sl].l+i-1&&r>=k[sl].l+i-1){ ans+=k[sl].a[i]; ans%=MOD; } } } else{ for(int i=1;i<=B;i++){ if(k[sl].l+i-1>=l){ ans+=k[sl].a[i]; ans%=MOD; } } for(int i=sl+1;i<=sr-1;i++){ ans+=k[i].sum; } ans%=MOD; for(int i=1;i<=B;i++){ if(k[sr].l+i-1<=r){ ans+=k[sr].a[i]; ans%=MOD; } } } for(int i=1;i<G;i++){ if(!sumr[i][i]) continue; int kl=(l-1)/i+1,kr=(r-1)/i+1; int jsl=(l-1)%i+1,jsr=(r-1)%i+1; if(kl==kr){ ans+=(sumr[i][jsr]-sumr[i][jsl-1]+MOD); ans%=MOD; } else{ long long sl=(sumr[i][i]-sumr[i][jsl-1]+MOD); long long sr=sumr[i][jsr]; ans+=(kr-kl-1)*sumr[i][i]; ans%=MOD; ans+=(sl+sr); ans%=MOD; } } cout<<ans%MOD<<"\n"; } } return 0; } ``` ::: :::: 至此我们已经了解了一些根号分治思想的进阶运用,当然这个巧妙的思想还有更多优美的性质等待我们去发现。 ::::info[进阶推荐练习题单] - [P11287 [COTS 2017] 影响 Utjecaj](https://www.luogu.com.cn/problem/P11287) - [P10761 [BalticOI 2024] Trains](https://www.luogu.com.cn/problem/P10761) - [P16781 ⌈Xzy OI R1 T3⌋ 穿穿表](https://www.luogu.com.cn/problem/P16781) - [P10998 【MX-J3-T3+】Tuple+](https://www.luogu.com.cn/problem/P10998) - [P8349 [SDOI/SXOI2022] 整数序列](https://www.luogu.com.cn/problem/P8349) - [P8211 [THUPC 2022 初赛] 搬砖](https://www.luogu.com.cn/problem/P8211) - [P5309 [Ynoi2011] 初始化](https://www.luogu.com.cn/problem/P5309) :::: ## 一些总结: 根号分治思想的运用通常会出现几种标志: 1. 有两种暴力且它们的复杂度一个随某一元素 $k$ 增大而增大,一个随 $k$ 增大而减小。 2. 题目提示你某一个关键的值为定值。 3. 一些比较奇怪的数据范围和时限,如值域较小或出现某某值大于或小于多少这种特殊性质。 如果这篇文章能对您有所帮助,可以留下一个赞吗? 如果您发现文章的错误或有疑问之处,请联系笔者或在评论区指出。 可能还会更新。