欸?根号?——根号算法学习笔记
0. 写在前面
打模拟赛时发现有根号算法的题,但只想到了一半,仔细一想又觉得自己一点不会,所以想着作一个小小的总结,毕竟这一块完全是知识盲区。
由于是用来梳理知识的,所以概念不会特别详细还请见谅。然后因为我水平太菜,所以不会有太难的题目,兑。想学习具体算法的建议找题目自行练习,本文侧重于讲解思想上的一些东西。
但说实话这东西考简单题的概率极低,至少我在正赛没碰到过,都是相对有难度的,所以这篇文章看看只图一乐就好,毕竟还是要自己多做一点相关的题目啊……
1. 根号算法定义
根号算法,又称分块算法、平方根算法,是一类基于分治思想的算法统称。顾名思义,根号算法的时间复杂度中含有
2. 根号算法用途
对于一些和区间等有关的操作,在数据量特别大时,如果没有单次操作
但是对于一些
3. 根号算法基本思路
最核心的思想就是:将程序要处理的东西进行划分。这个东西可能是要求维护的数组,也可能是单次操作的规模,还有可能是题目给出的询问。总之,根号算法就是通过划分这一操作进行处理的。这些不同的划分方式会带来不同的应用,而这正是接下来我们要讨论的东西。
4. 根号算法实际应用
4.1 分块
大家最耳熟能详喜闻乐见(并非)的根号算法就是分块了。分块的
下面用两道题目,从不同的角度去看待分块。
4.1.1 P3374 【模板】树状数组 1
什么?不是有
选用这道题是为了体现分块这一算法的普适性,就是说带
对于这道题的分块,显然我们统计块内所有数的和,单点修改 没错由于实在是太基础了所以并没有什么进阶的题目。
:::info[代码]
#include <bits/stdc++.h>
using namespace std;
#define int long long
#define rep(i, a, b) for(int i = a;i <= b;i++)
const int Max = 5e5+5;
int n, m, a[Max], l[Max], r[Max], belong[Max];
int block, num, sum[Max];
void build()//分块
{
block = sqrt(n);
num = n/block;
if(n%block)num++;
rep(i, 1, num)//块的左右端点
{
l[i] = (i-1)*block+1;
r[i] = i*block;
}
r[num] = n;
rep(i, 1, n)//每个点属于哪个块
belong[i] = (i-1)/block+1;
rep(i, 1, num)//块内数字和
rep(j, l[i], r[i])sum[i] += a[j];
}
void add(int x, int val)//单点修改
{
a[x] += val;
sum[belong[x]] += val;
}
int query(int x, int y)//区间查询
{
if(belong[x] == belong[y])
{
int res = 0;
rep(i, x, y)res += a[i];
return res;
}
int ans = 0;
rep(i, x, r[belong[x]])ans += a[i];//暴力查找
rep(i, l[belong[y]], y)ans += a[i];//同上
rep(i, belong[x]+1, belong[y]-1)ans += sum[i];//直接调用预处理好的数据
return ans;
}
signed main()
{
ios::sync_with_stdio(0);
cin.tie(0);cout.tie(0);
cin >> n >> m;
rep(i, 1, n)cin >> a[i];
build();
while(m--)
{
int op, x, y;
cin >> op >> x >> y;
if(op == 1)add(x, y);
else cout << query(x, y) << "\n";
}
return 0;
}
:::
4.1.2 P4168 [Violet] 蒲公英
对于这道题目,我们似乎很难想到一个可行的
这里顺带提一嘴,分块的优势就是对于所有的整块可以显著的减小计算量,使得非整块进行暴力计算时仍然可以有相当优秀的时间复杂度。当然太大还是会被卡的,本人模拟赛就信仰写下分块然后超时了。
:::info[代码] 没有注释且码风较丑,建议看一下题解。
#include <bits/stdc++.h>
using namespace std;
#define rep(i, a, b) for(int i = a; i <= b; i++)
#define int long long
const int Max = 4e4+5, N = 5e2+5;
int n, m, x, tot;
int a[Max], b[Max], s[Max];
int sum[N][Max];
int f[N][N];
int block, num, L[N], R[N], belong[Max];
inline int read()
{
int f = 1, x = 0;
char c = getchar();
while(!isdigit(c))
{
if(c == '-') f = -1;
c = getchar();
}
while(isdigit(c))
{
x = 10*x + c - '0';
c = getchar();
}
return f*x;
}
signed main()
{
n = read();m = read();
rep(i, 1, n)b[i] = a[i] = read();
// 离散化
sort(b+1, b+n+1);
tot = unique(b+1, b+n+1)-b-1;
rep(i, 1, n)a[i] = lower_bound(b+1, b+tot+1, a[i])-b;
block = sqrt(n);
num = (n-1)/block+1;
rep(i, 1, num)
{
L[i] = block*(i-1)+1;
R[i] = min(block*i, n);
}
rep(i, 1, n)belong[i] = (i-1)/block+1;
rep(i, 1, num)
{
rep(j, L[i], R[i])sum[i][a[j]]++;
rep(j, 1, tot)sum[i][j] += sum[i-1][j];
}
rep(i, 1, num)
{
rep(j, i, num)
{
int maxn = f[i][j-1];
rep(k, L[j], R[j])
{
if(sum[j][a[k]]-sum[i-1][a[k]] > sum[j][maxn]-sum[i-1][maxn]||
(sum[j][a[k]]-sum[i-1][a[k]] == sum[j][maxn]-sum[i-1][maxn]&&a[k] < maxn))
maxn = a[k];
}
f[i][j] = maxn;
}
}
while(m--)
{
int l = (read()+x-1)%n+1;
int r = (read()+x-1)%n+1;
if(l > r) swap(l, r);
int maxn = 0;
int bl = belong[l], br = belong[r];
if(br-bl <= 1)
{
rep(i, l, r)s[a[i]]++;
rep(i, l, r)
{
if(s[a[i]] > s[maxn]||(s[a[i]] == s[maxn]&&a[i]<maxn))
maxn = a[i];
}
rep(i, l, r) s[a[i]] = 0;
}
else
{
rep(i, l, R[bl])s[a[i]]++;
rep(i, L[br], r)s[a[i]]++;
maxn = f[bl+1][br-1];
rep(i, l, R[bl])
{
int cntmax = s[maxn]+sum[br-1][maxn]-sum[bl][maxn];
int cntcur = s[a[i]]+sum[br-1][a[i]]-sum[bl][a[i]];
if(cntcur > cntmax||(cntcur == cntmax&&a[i] < maxn))
maxn = a[i];
}
rep(i, L[br], r)
{
int cntmax = s[maxn]+sum[br-1][maxn]-sum[bl][maxn];
int cntcur = s[a[i]]+sum[br-1][a[i]]-sum[bl][a[i]];
if(cntcur > cntmax||(cntcur == cntmax&&a[i] < maxn))
maxn = a[i];
}
rep(i, l, R[bl]) s[a[i]] = 0;
rep(i, L[br], r) s[a[i]] = 0;
}
x = b[maxn];
printf("%lld\n", x);
}
return 0;
}
:::
4.2 莫队
好吧莫队算是现学现用了。
莫队算法的主要思路是:对于序列上的区间询问问题,离线排序,按一定顺序处理每个询问,暴力从上一个区间的答案转移到下一个区间答案。那么这个
我们这里只讨论基础的莫队板子,主要是理解其将询问分组排序对复杂度的显著优化。
4.2.1 P1494 [国家集训队] 小 Z 的袜子
莫队板子题。计算发现需要所有每种袜子的个数的平方之和,这显然可以
:::info[代码]
#include <bits/stdc++.h>
using namespace std;
#define int long long
#define rep(i, a, b) for(int i = a;i <= b;i++)
const int Max = 5e4+5;
int block;
int n, m, c[Max];
struct node{
int l, r, id;
bool operator <(const node &x) const
{
if(l/block != x.l/block)return l/block < x.l/block;
if((l/block)&1)return r < x.r;
return r > x.r;
}
};
struct frac{
int p, q, id;
static int gcd(int x, int y){return (!y?x:gcd(y, x%y));}
frac(int pp = 0, int qq = 1, int dd = 0)
{
int d = gcd(pp, qq);
p = pp/d, q = qq/d, id = dd;
}
};
node ask[Max];
frac ans[Max];
bool cmp_ask(node x, node y){return x < y;}
bool cmp_ans(frac x, frac y){return x.id < y.id;}
int sum[Max], tot = 0;
void add(int x)
{
tot -= sum[c[x]]*sum[c[x]];
sum[c[x]]++;
tot += sum[c[x]]*sum[c[x]];
}
void del(int x)
{
tot -= sum[c[x]]*sum[c[x]];
sum[c[x]]--;
tot += sum[c[x]]*sum[c[x]];
}
signed main()
{
ios::sync_with_stdio(0);
cin.tie(0);cout.tie(0);
cin >> n >> m;
rep(i, 1, n)cin >> c[i];
block = sqrt(n);
rep(i, 1, m)cin >> ask[i].l >> ask[i].r, ask[i].id = i;
sort(ask+1, ask+m+1, cmp_ask);
int l = 1, r = 0;
rep(i, 1, m)
{
while(r < ask[i].r)add(++r);
while(r > ask[i].r)del(r--);
while(l < ask[i].l)del(l++);
while(l > ask[i].l)add(--l);
int res = ask[i].r-ask[i].l+1;
if(ask[i].l == ask[i].r)ans[i] = frac(0, 1, ask[i].id);
else ans[i] = frac(tot-res, res*(res-1), ask[i].id);
}
sort(ans+1, ans+m+1, cmp_ans);
rep(i, 1, m)cout << ans[i].p << "/" << ans[i].q << "\n";
return 0;
}
:::
4.2.2 P4462 [CQOI2018] 异或序列
看到区间异或先前缀处理一下。不妨记
有两个细节需要注意,一个是区间操作的
:::info[代码]
#include <bits/stdc++.h>
using namespace std;
#define rep(i, a, b) for(int i = a;i <= b;i++)
typedef long long ll;
const int Max = 1e5+5, N = 1e3+5;
int n, m, k, block, a[Max];
ll ans[Max];
struct query{
int l, r, id;
bool operator <(const query &x)const
{
if(l/block != x.l/block)return l < x.l;
if((l/block)&1)return r < x.r;
else return r > x.r;
}
}ask[Max];
int cnt[Max];
ll sum = 0;
void add(int x)
{
sum += cnt[a[x]^k];
cnt[a[x]]++;
}
void del(int x)
{
cnt[a[x]]--;
sum -= cnt[a[x]^k];
}
int main()
{
ios::sync_with_stdio(0);
cin.tie(0);cout.tie(0);
cin >> n >> m >> k;block = sqrt(n);
rep(i, 1, n)cin >> a[i], a[i] ^= a[i-1];
rep(i, 1, m)cin >> ask[i].l >> ask[i].r, ask[i].id = i;
sort(ask+1, ask+m+1);
int l = 1, r = 0;
rep(i, 1, m)
{
while(l < ask[i].l-1)del(l++);
while(l > ask[i].l-1)add(--l);
while(r < ask[i].r)add(++r);
while(r > ask[i].r)del(r--);
ans[ask[i].id] = sum;
}
rep(i, 1, m)cout << ans[i] << "\n";
return 0;
}
:::
4.2.3 P3604 美好的每一天
编号重排这个限制非常宽松,我们重点考虑这个性质,思考如何转移到莫队相关的问题上。我们发现:判断重排后是否能构成一个回文串等价于判断所有出现字母个数中至多有一个奇数,即维护每个字母出现次数,这恰恰是莫队可以维护的内容。
考虑进一步优化,我们只想知道出现次数的奇偶性,考虑异或处理,于是这道题便转化为了前面这道题,我们让每一个字母与一个二进制位一一对应就行了,计算贡献时要考虑每一个字母出现次数为奇数的情况。
:::info[代码]
#include <bits/stdc++.h>
using namespace std;
#define rep(i, a, b) for(int i = a;i <= b;i++)
const int Max = 6e4+5, N = (1<<26)+5;
int n, m, a[Max], cnt[N], ans[Max], block;
struct node{
int l, r, id;
bool operator <(const node &x) const
{
if(l/block != x.l/block)return l/block < x.l/block;
if((l/block)&1)return r < x.r;
else return r > x.r;
}
}ask[Max];
string s;
int res = 0;
void add(int x)
{
res += cnt[a[x]]++;
rep(i, 0, 25)
res += cnt[a[x]^(1<<i)];
}
void del(int x)
{
res -= --cnt[a[x]];
rep(i, 0, 25)
res -= cnt[a[x]^(1<<i)];
}
int main()
{
ios::sync_with_stdio(0);
cin.tie(0);cout.tie(0);
cin >> n >> m;block = sqrt(n);
cin >> s;
rep(i, 1, n)
a[i] = a[i-1]^(1<<(int)(s[i-1]-'a'));
rep(i, 1, m)
cin >> ask[i].l >> ask[i].r, ask[i].id = i;
sort(ask+1, ask+m+1);
int l = 1, r = 0;
rep(i, 1, m)
{
while(l < ask[i].l-1)del(l++);
while(l > ask[i].l-1)add(--l);
while(r < ask[i].r)add(++r);
while(r > ask[i].r)del(r--);
ans[ask[i].id] = res;
}
rep(i, 1, m)
cout << ans[i] << "\n";
return 0;
}
:::
4.3 根号分治
这一算法的主要思想就是将操作的数据规模分治处理。对于小规模的操作(通常取一次性操作数量
为什么不对小规模的也进行预处理或特殊处理呢?因为没有必要。而且如果你这样做的话,反而会提升你的时间复杂度,因为许多处理对于小规模操作来说是无用的,处理除了耗时之外没有任何用处。比如说用数据结构维护带单
但是对于大规模操作而言,预处理或特殊处理则能显著减少暴力枚举的数目,从而排除无效状态。根号分治便是将这两种方法融合到一起从而提高效率的。
当然,只是讲的话看起来有点抽象。下面一样用两个具体例子来解释一下上面的思想。
4.3.1 P12751 [POI 2017 R2] 集装箱 Shipping containers
先想暴力做法:每次操作暴力修改所有的点,最劣复杂度为
记
这里复杂度原本是
根号分治还有一个比较玄学的点就是,提前计算最大的
:::info[代码]
#include <bits/stdc++.h>
using namespace std;
#define rep(i, a, b) for(int i = a;i <= b;i++)
const int Max = 1e5+5;
//友情提示:本题卡空间
int n, k, b, d[Max+20000][151], num[Max];
int main()
{
ios::sync_with_stdio(0);
cin.tie(0);cout.tie(0);
cin >> n >> k;b = 150;
rep(i, 1, k)
{
int ai, li, di;
cin >> ai >> li >> di;
if(di > b)
{
rep(j, 0, li-1)
num[ai+di*j]++;
}
else
{
d[ai][di]++;
d[ai+di*li][di]--;
}
}
rep(i, 1, b)
{
rep(j, i, n)d[j][i] += d[j-i][i];
rep(j, 1, n)num[j] += d[j][i];
}
rep(i, 1, n)cout << num[i] << " ";
return 0;
}
:::
4.3.2 P8427 [COI 2020] Paint
没错我就是为了这盘醋包的饺子。
看到这道题,你的第一反应肯定是并查集。对!但是我们怎么合并呢?
思考一下,我们发现无非两种方法:
-
对于一个连通块,我们暴力查找与这个连通块相邻的所有连通块,如果颜色相等就直接更新。
-
我们提前记录下这个连通块周围的所有颜色包含的连通块,如果修改就直接调用存好的对应颜色的连通块信息并进行合并,无需查找过程。
看起来第二种方法确实聪明一点(其实是因为我赛时就这么想的),但是我们用这种方法要用数据结构维护,这样就带有
那么我们不妨仿照前面一题,部分暴力部分预处理,和前面一题一样,我们对于小的操作直接暴力找连通块,对于大的操作预处理相邻颜色信息。我们还是记阈值为
你发现了吗?这里如果我们将
当然,这道题分析起来的确不难,但是想要实现还是难度较大的,你可以选择不看题解自己做来锻炼码力。代码有详细解析,不懂的可以看一下。
:::info[代码]
#include <bits/stdc++.h>
using namespace std;
#define int long long
#define rep(i, a, b) for(int i = a;i <= b;i++)
const int Max = 2e5+5, B = 1e3;
const int dx[] = {0, 1, 0, -1}, dy[] = {1, 0, -1, 0};
int R, S, q;
inline int id(int i, int j){return (i-1)*S+j;}
int col[Max];
class DSU//disjoint set union 并查集
{
int f[Max];
public:
DSU(){rep(i, 1, Max-3)f[i] = i;}
int find(int x){return (f[x] == x?x:f[x] = find(f[x]));}
int join(int x, int y)
{
x = find(x);y = find(y);
if(x != y)f[x] = y;
return y;
}
bool root(int x){return f[x] == x;}
};
DSU d;
set<int> g[Max];//用于小块dfs合并
bool large[Max];//用于大块找相邻合并
set<int> sl;
map<int, map<int, set<int> > > mpl;
void add_large(int i, int c, int x)//向与第i个块相邻的颜色为c的块中加入x
{
mpl[i][c].insert(x);
}
void erase_large(int i, int c, int x)//向与第i个块相邻的颜色为c的块中删去x
{
auto it = mpl.find(i);
if(it == mpl.end())return ;
auto itc = it->second.find(c);
if(itc == it->second.end())return ;
itc->second.erase(x);
if(itc->second.empty())it->second.erase(itc);
}
void new_large(int i)//新增一个大块
{
large[i] = 1;sl.insert(i);
auto &add = mpl[i];
add.clear();
for(auto x:g[i])
add[col[x]].insert(x);
}
int merge(int i, int j)//将大块与另外一个块进行合并
{
i = d.find(i), j = d.find(j);
if(i == j)return j;
if(g[i].size() > g[j].size())swap(i, j);
//i合并到j,将所有i内信息转移到j
for(int x:g[i])
{
g[x].erase(i);
if(large[x])erase_large(x, col[i], i);
}
sl.erase(i);
mpl.erase(i);
for(int x:g[i])
{
if(x != j)
{
g[j].insert(x);
g[x].insert(j);
if(large[x])add_large(x, col[j], j);
}
}
if(large[j])
{
for(auto x:g[i])
if(x != j)add_large(j, col[x], x);
}
else if(large[i]||g[j].size() >= B)new_large(j);
d.join(i, j);
g[i].clear();
return j;
}
signed main()
{
ios::sync_with_stdio(0);
cin.tie(0);cout.tie(0);
cin >> R >> S;
//并查集合并
rep(i, 1, R)rep(j, 1, S)
{
cin >> col[id(i, j)];
if(i > 1&&col[id(i, j)] == col[id(i-1, j)])d.join(id(i-1, j), id(i, j));
if(j > 1&&col[id(i, j)] == col[id(i, j-1)])d.join(id(i, j-1), id(i, j));
}
//确定块与块之间的连接关系
rep(i, 1, R)rep(j, 1, S)
{
int fx = d.find(id(i, j));
if(i > 1)
{
int fy = d.find(id(i-1, j));
if(fx != fy)
{
g[fx].insert(fy);
g[fy].insert(fx);
}
}
if(j > 1)
{
int fy = d.find(id(i, j-1));
if(fx != fy)
{
g[fx].insert(fy);
g[fy].insert(fx);
}
}
}
//建立大块
rep(i, 1, R*S)
if(d.root(i)&&g[i].size() >= B)new_large(i);;
cin >> q;
while(q--)
{
int r, s, c;
cin >> r >> s >> c;
int i = d.find(id(r, s));
if(col[i] == c)continue;
for(auto j:sl)
{
if(j != i&&g[i].count(j))
{
erase_large(j, col[i], i);
add_large(j, c, i);
}
}
col[i] = c;
vector<int> pre;//保存将要合并的块
if(large[i])//如果是大块,直接调用保存好的相邻颜色信息
{
auto it = mpl[i].find(c);
if(it != mpl[i].end())
{
for(auto j:it->second)
pre.push_back(j);
}
}
else//如果是小块,暴力查找
{
for(auto j:g[i])
if(col[j] == c)pre.push_back(j);
}
for(int j:pre)i = merge(i, j);
}
rep(i, 1, R)
{
rep(j, 1, S)
cout << col[d.find(id(i, j))] << " ";
cout << "\n";
}
return 0;
}
:::
5 总结 & 闲话
第一次写笔记,所以有讲的不好的地方还请包涵,也欢迎在评论区指出,看到了我会改的。有好的题目也请推荐一下,说不定我哪一天就把它加上(咕咕咕)。
还有就是这类方法不仅可以当正解,实在不行你也能骗部分分不是吗……