根号分块(sqrt decomposition),类似于稀疏表,也是一种通过预处理来提高查询效率的数据结构(或者说“技巧”),它能够以 O ( n ) O(\sqrt{n}) O(n) 的时间复杂度完成诸如区间最大/小值查询,区间求和的操作。其核心思想是将数组分割为大小相同的“块”,在块中进行操作,便于提高速度。下面以区间求和为例来介绍根号分块的应用。
给定长度为 n n n 的数组,数组元素为 a [ 1 ] a[1] a[1], a [ 2 ] a[2] a[2],…, a [ n ] a[n] a[n],给定区间 [ L L L, R R R], L ≤ R L≤R L≤R,要求确定元素 a [ L ] a[L] a[L] 至 a [ R ] a[R] a[R] 的和。朴素的方法是逐个累加区间 [ L L L, R R R] 内的所有元素,很明显,当查询较多时效率很低。令 s = ⌈ n ⌉ s=\lceil{\sqrt{n}}\rceil s=⌈n⌉,将数组 a a a 按照每 s s s 个元素一组分成若干块,那么每个元素都会被划分到某个块内。由于 n n n 不一定是 s s s 的整数倍,因此划分得到的块,有可能最后一个块不足 s s s 个元素。在完成块的划分后,累加每个块内元素的和,得到一个块内元素和数组 s u m sum sum, s u m [ i ] sum[i] sum[i] 表示第 i i i 个块内所有元素的和。当对区间 [ L L L, R R R] 内的元素求和时,与普通的逐个累加不同,可以将区间表示为若干个完整块和至多两个不完整块的并。例如,令 n = 200 n=200 n=200,则 s = 15 s=15 s=15,则数组被划分为 14 14 14 个块,第 1 1 1 块至第 13 13 13 块的大小均为 15 15 15,第 14 14 14 块的大小为 5 5 5。当求区间 [ 24 24 24, 110 110 110] 的元素和时,可以将其分解为第 2 2 2 块的后 7 7 7 个元素,整个第 3 3 3 块、第 4 4 4 块、第 5 5 5 块、第 6 6 6 块、第 7 7 7 块,第 8 8 8 块的前 5 5 5 个元素,则区间 [ 24 24 24, 110 110 110] 的元素和 S S S 可以表示为
S = ( ∑ i = 24 30 a [ i ] ) + ( ∑ i = 3 7 s u m [ i ] ) + ( ∑ i = 106 110 a [ i ] ) S=(\sum_{i=24}^{30}a[i] )+(\sum_{i=3}^7sum[i] )+(\sum_{i=106}^{110}a[i]) S=(i=24∑30a[i])+(i=3∑7sum[i])+(i=106∑110a[i])
由于第 3 3 3 块至第 7 7 7 块的元素和已经在预处理阶段求得,因此只需 O ( 1 ) O(1) O(1) 的时间复杂度获取,而前后两个不完整块的元素和计算,至多需要 O ( n ) O(\sqrt{n}) O(n) 的时间,因此总的时间复杂度可以优化为 O ( n ) O(\sqrt{n}) O(n)。如果在后续的过程中对元素 a [ i ] a[i] a[i] 进行了更改,只需将第 i i i 个元素所属的块内和 s u m [ j ] sum[j] sum[j] 做相应更改即可用于后续的求和,而这个更改很容易做到。
const int MAXN = 100010;
int n, s, a[MAXN], link[MAXN], sum[MAXN];
// 查询区间[L,R]的元素和。
int query(int L, int R)
{
int p = link[L], q = link[R];
int r = 0;
for (int i = L; i <= min(R, p * s); i++) r += a[i];
for (int i = p + 1; i < q; i++) r += sum[i];
if (p != q) {
for (int i = (q - 1) * s + 1; i <= R; i++) r += a[i];
}
return r;
}
// 更新元素a[p]的值为v,需要更新对应的块内和。
void update(int p, int v)
{
sum[link[p]] -= a[p];
a[p] = v;
sum[link[p]] += a[p];
}
int main(int argc, char *argv[])
{
cin >> n;
// 确定分块的大小。
s = sqrt(n) + 1;
for (int i = 1; i <= s; i++) sum[i] = 0;
for (int i = 1; i <= n; i++) {
cin >> a[i];
// 确定第i个元素所属的分块。
link[i] = (i - 1) / s + 1;
// 将第i个元素累加到对应的块内和。
sum[link[i]] += a[i];
}
int m, L, R;
cin >> m;
for (int i = 0; i < m; i++) {
cin >> L >> R;
// 查询区间元素和。
cout << query(L, R) << '\n';
}
return 0;
}
从上述根号分块的实现不难看出,该数据结构实际上可以看做是在普通的暴力计算的基础上的一种优化,它尽可能地减少了进行单个元素累加的次数,从而在一定程度上提高了效率。由于分块过大或者过小都对效率有不利影响,为了保证效率,在一般情况下,取分块的大小 s = ⌈ n ⌉ s=\lceil{\sqrt{n}}\rceil s=⌈n⌉,可以使得操作的平摊时间复杂度保持在 O ( n ) O(\sqrt{n}) O(n)。
根号分块作为分块算法中的一种,不仅可以查询区间和、区间最大值/最小值,还可以进行适当拓展以处理一些线段树不方便处理的问题。由于根号分块的核心在于分块,因此在进行处理时需要将已经处理过的块加上适当的标记,以便后续处理时直接获得结果而不需要再次处理。例如,如果在每次查询后需要将给定区间内的元素开根号并向下取整,则由于每次开根号后,数值至少为原来的一半,易知在若干次操作后该区间的元素都将变为 1 1 1,继续开根号向下取整不会改变元素的值,因此只要某个块内的元素均为 1 1 1 之后,就可以给这个块加上标记,表示不需要再对此块进行处理,从而提高了效率。
编写程序,根据 m m m 条指令对数组 A [ 1 ] A[1] A[1], A [ 2 ] A[2] A[2],…, A [ n ] A[n] A[n] 进行变换。每条指令( L L L, R R R, v v v, p p p)的含义为:首先,确定从数组元素 A [ L ] A[L] A[L] 到 A [ R ] A[R] A[R](包括 A [ L ] A[L] A[L] 和 A [ R ] A[R] A[R])中严格小于 v v v 的数组元素个数 k k k,然后,将数组元素 A [ p ] A[p] A[p] 的值更改为: u × k / ( R − L + 1 ) u×k/(R-L+1) u×k/(R−L+1)。这里的除法使用整除,即忽略小数部分。
输入的第一行包含三个整数 n n n, m m m, u u u( 1 ≤ n ≤ 300000 1≤n≤300000 1≤n≤300000, 1 ≤ m ≤ 50000 1≤m≤50000 1≤m≤50000, 1 ≤ u ≤ 1000000000 1≤u≤1000000000 1≤u≤1000000000)。接下来的 n n n 行,每行包含一个整数 A [ i ] A[i] A[i]( 1 ≤ A [ i ] ≤ u 1≤A[i]≤u 1≤A[i]≤u)。再接下来的 m m m 行,每行包含一条指令,每条指令包含四个整数, L L L, R R R, v v v, p p p( 1 ≤ L ≤ R ≤ n 1≤L≤R≤n 1≤L≤R≤n, 1 ≤ v ≤ u 1≤v≤u 1≤v≤u, 1 ≤ p ≤ n 1≤p≤n 1≤p≤n)。
输出包含n行,每行一个整数,表示最终数组的值。
对于样例输入来说,总共只有一条指令: L = 2 L=2 L=2, R = 10 R=10 R=10, v = 6 v=6 v=6, p = 10 p=10 p=10,总共有 4 4 4 个数组元素( 2 2 2, 3 3 3, 4 4 4, 5 5 5)小于 6 6 6,故 k = 4 k=4 k=4,则数组元素 A [ 10 ] A[10] A[10] 的新值为 11 × 4 / ( 8 − 2 + 1 ) = 44 / 7 = 6 11×4/(8-2+1)=44/7=6 11×4/(8−2+1)=44/7=6。
10 1 11
1
2
3
4
5
6
7
8
9
10
2 8 6 10
1
2
3
4
5
6
7
8
9
6
由于查询数量较多,如果使用朴素的暴力统计,肯定会超时。考虑分块处理,每个块内对元素进行排序,以便使用库函数 lower_bound 查找小于 v v v 的元素个数。为了便于操作,将原数组做一个备份,以避免后续保持块内有序对原数组元素位置带来的影响。
const int MAXN = 300010, MAXM = 1010;
int n, m, u, s, link[MAXN], A[MAXN], B[MAXN];
int head[MAXM], tail[MAXM];
// 分块。确定各块边界,对块内元素进行排序以便查找。
void build()
{
s = sqrt(n);
int block = n / s;
if (block * s < n) block++;
for (int i = 1; i <= block; i++) head[i] = (i - 1) * s + 1, tail[i] = i * s;
tail[block] = n;
for (int i = 1; i <= block; i++) {
for (int j = head[i]; j <= tail[i]; j++) link[j] = i;
sort(A + head[i], A + tail[i] + 1);
}
}
// 查询。使用库函数lower_bound提高查找效率。
int query(int L, int R, int v)
{
int k = 0;
for (int i = L; i <= min(R, tail[link[L]]); i++) k += (B[i] < v);
for (int i = link[L] + 1; i < link[R]; i++)
k += lower_bound(A + head[i], A + tail[i] + 1, v) - A - head[i];
if (link[L] != link[R])
for (int i = head[link[R]]; i <= R; i++) k += (B[i] < v);
return k;
}
void update(int L, int R, int p, int k)
{
int belong = link[p];
// 查找已排序数组中具有对应元素值的元素位置。
int pp = lower_bound(A + head[belong], A + tail[belong] + 1, B[p]) - A;
B[p] = (long long)(u) * k / (R - L + 1);
// 使用插入排序,保持块内有序以便后续进行查找。
while (pp > head[belong] && B[p] < A[pp - 1]) { swap(A[pp - 1], A[pp]); pp--; }
while (pp < tail[belong] && B[p] > A[pp + 1]) { swap(A[pp + 1], A[pp]); pp++; }
A[pp] = B[p];
}
int main(int argc, char *argv[])
{
cin >> n >> m >> u;
// A为已排序数组,B为未排序数组。
for (int i = 1; i <= n; i++) {
cin >> A[i]; B[i] = A[i];
}
build();
for (int i = 1, L, R, v, p; i <= m; i++) {
cin >> L >> R >> v >> p;
update(L, R, p, query(L, R, v));
}
for (int i = 1; i <= n; i++) cout << B[i] << '\n';
return 0;
}
扩展练习:UVa 11990 Dynamic Inversion。
参考资料:
(1)https://github.com/e-maxx-eng/e-maxx-eng/blob/master/src/data_structures/sqrt_decomposition.md
(2)https://www.cnblogs.com/hlw1/p/12214748.html
(3)https://blog.csdn.net/DT2131/article/details/76864171