Introduction
Block decomposition is an idea rather than a data structure.
Problems using block decomposition appear at every difficulty level, from NOIP through NOI to IOI.
The basic idea is to partition the original data appropriately and preprocess partial information for each block, thereby obtaining a better time complexity than a general brute-force algorithm.
The time complexity of block decomposition mainly depends on the block length. In general, the optimal block length and the corresponding complexity can be derived using the arithmetic-geometric mean inequality.
Block decomposition is very flexible. Compared with a Fenwick tree or a segment tree, its advantage is greater generality: it can maintain information that many Fenwick trees and segment trees cannot.
Its drawback, of course, is that its asymptotic complexity is not as good as that of a segment tree or a Fenwick tree.
Nevertheless, for most problems block decomposition remains a useful choice.
Here are several examples.
Range Sum
Example: LibreOJ 6280, Introduction to Sequence Block Decomposition 4
Given a sequence of length , , perform operations. There are two kinds of operations:
-
Add to every number between .
-
Compute .
Partition the sequence into blocks of elements each, and record the sum of every block.
The last block may be incomplete because need not be a multiple of , but this has little effect on the discussion.
First consider a query:
- If and are in the same block, sum the elements by brute force. Since the block length is , the worst-case complexity is .
- If and are not in the same block, the answer consists of three parts: the incomplete block beginning at , several complete blocks in the middle, and the incomplete block ending at . Compute the incomplete blocks by brute force as above, and use the precomputed for the complete blocks. The worst-case complexity is .
Now consider an update:
- If and are in the same block, update directly. Since the block length is , the worst-case complexity is .
- If and are not in the same block, update three parts: the incomplete block beginning at , several complete blocks in the middle, and the incomplete block ending at . For incomplete blocks, update each element by brute force (and remember to update the block sum ); for complete blocks, update directly. The worst-case complexity is again .
By the arithmetic-geometric mean inequality, a single operation is fastest when , namely , giving complexity .
Reference implementation
#include <cmath>
#include <iostream>
using namespace std;
int id[50005], len;
// id denotes the block number; len=sqrt(n), i.e. s in the solution above; using sqrt gives the optimal time complexity.
long long a[50005], b[50005], s[50005];
// The a array stores the data, while the b array records the aggregate assignment for each block, similar to lazy_tag; s
// stores the sum of the elements in the block.
void add(int l, int r, long long x) { // Range addition.
int sid = id[l], eid = id[r];
if (sid == eid) { // Within a block.
for (int i = l; i <= r; i++) a[i] += x, s[sid] += x;
return;
}
for (int i = l; id[i] == sid; i++) a[i] += x, s[sid] += x;
for (int i = sid + 1; i < eid; i++)
b[i] += x, s[i] += len * x; // Update the range-sum array (a complete block).
for (int i = r; id[i] == eid; i--) a[i] += x, s[eid] += x;
// For the two incomplete blocks above, simply sum directly; that's enough.
}
long long query(int l, int r, long long p) { // Range query.
int sid = id[l], eid = id[r];
long long ans = 0;
if (sid == eid) { // Brute-force sum directly within a block.
for (int i = l; i <= r; i++) ans = (ans + a[i] + b[sid]) % p;
return ans;
}
for (int i = l; id[i] == sid; i++) ans = (ans + a[i] + b[sid]) % p;
for (int i = sid + 1; i < eid; i++) ans = (ans + s[i]) % p;
for (int i = r; id[i] == eid; i--) ans = (ans + a[i] + b[eid]) % p;
// The same idea as the range update above.
return ans;
}
int main() {
int n;
cin >> n;
len = sqrt(n); // By the arithmetic-mean inequality, the optimal complexity is sqrt(n).
for (int i = 1; i <= n; i++) { // Required by the problem statement.
cin >> a[i];
id[i] = (i - 1) / len + 1;
s[id[i]] += a[i];
}
for (int i = 1; i <= n; i++) {
int op, l, r, c;
cin >> op >> l >> r >> c;
if (op == 0)
add(l, r, c);
else
cout << query(l, r, c + 1) << endl;
}
return 0;
}
/*
https://loj.ac/s/1151495
*/
Range Sum 2
The preceding method has complexity .
Here we introduce an algorithm.
To answer queries in time, we can maintain various prefix sums.
With updates, however, maintaining all of them is inconvenient, so we maintain prefix sums within each individual block.
We also maintain prefix sums whose unit is a complete block.
Each update takes time.
A query involves three parts, each of which can be obtained directly from prefix sums, so its complexity is .
Block Decomposition of Queries
Consider the same problem with a sequence of length and operations.
If there are relatively few operations, we can record them and add their effects when answering a query.
Suppose we record at most operations. An update costs and a query costs .
After operations, recompute the prefix sums in time.
The total complexity is .
When , the total complexity is .
Other Problems
The block-decomposition idea also applies to other integer-related problems, such as counting zero elements, finding the first nonzero element, and counting elements satisfying a given property.
Other problems can also be solved this way. For example, maintain a set of numbers that supports insertion and deletion, test whether a number belongs to the set, and find the -th largest number. Store the numbers in increasing order and divide them into blocks containing numbers each. Whenever a number is inserted or deleted, rebalance the blocks by moving numbers across adjacent block boundaries.
The well-known offline Mo's algorithm is also based on block decomposition.
Exercises
-
This page is mainly translated from the blog post Sqrt-decomposition and the English edition Sqrt Decomposition. The Russian version is licensed under Public Domain + Leave a Link; the English version is licensed under CC-BY-SA 4.0.