Introduction to Sparse Tables
A Sparse Table is a powerful data structure designed to answer range queries on static arrays โ arrays that do not change between queries. It excels at answering queries for idempotent operations such as range minimum, range maximum, range greatest common divisor (GCD), and range bitwise AND/OR. The key advantage of a Sparse Table is its ability to answer each query in O(1) time after an O(n log n) preprocessing step.
The name "sparse" comes from the fact that the precomputed table stores answers for intervals whose lengths are powers of two, rather than every possible interval. This dramatically reduces the space required compared to precomputing answers for every possible query range, which would take O(nยฒ) space.
Why Sparse Tables Matter
Consider a scenario where you have an array of one million integers and you need to answer one million queries asking for the minimum value in a given range. A naive approach would take O(n) per query, resulting in O(nยฒ) total time โ far too slow for large inputs. Segment trees can solve this in O(log n) per query, but Sparse Tables go one step further by answering each query in constant time.
Sparse Tables are particularly valuable in competitive programming and systems where:
- The data is static and does not require updates between queries.
- The query operation is idempotent, meaning
f(x, x) = x. This allows overlapping intervals to be combined without affecting the result. - You need to answer a very large number of queries efficiently.
- Memory is available for the
O(n log n)precomputed table.
The Core Idea
The fundamental insight behind Sparse Tables is that any interval [l, r] can be covered by two overlapping intervals whose lengths are powers of two. Specifically, if k = floor(log2(r - l + 1)), then the range [l, r] can be covered by the intervals [l, l + 2^k - 1] and [r - 2^k + 1, r]. Both of these intervals have length 2^k, and together they completely cover [l, r] (with some overlap in the middle).
For idempotent operations, the overlap does not matter because applying the operation to the same element twice produces the same result. This is why Sparse Tables work for min, max, GCD, and bitwise operations, but not for sum or product queries (where overlapping would double-count elements).
Precomputation
We define st[k][i] as the answer for the range [i, i + 2^k - 1] โ that is, the range of length 2^k starting at index i. The table is built using dynamic programming:
st[0][i] = arr[i]โ a range of length 1 is just the element itself.st[k][i] = f(st[k-1][i], st[k-1][i + 2^(k-1)])โ a range of length2^kis the combination of two ranges of length2^(k-1).
The number of levels K is floor(log2(n)) + 1, and each level has at most n entries, giving the O(n log n) space and time complexity for preprocessing.
Implementation
Below is a complete C++ implementation of a Sparse Table for range minimum queries. The same structure can be adapted for other idempotent operations by changing the combine function.
#include <bits/stdc++.h>
using namespace std;
class SparseTable {
private:
vector<vector<int>> st;
vector<int> logTable;
int n;
int K;
public:
SparseTable(const vector<int>& arr) {
n = arr.size();
// Precompute log values for O(1) query lookup
logTable.resize(n + 1);
logTable[1] = 0;
for (int i = 2; i <= n; i++) {
logTable[i] = logTable[i / 2] + 1;
}
K = logTable[n] + 1;
st.assign(K, vector<int>(n));
// Base case: ranges of length 1
for (int i = 0; i < n; i++) {
st[0][i] = arr[i];
}
// Build the table for increasing power-of-two lengths
for (int k = 1; k < K; k++) {
for (int i = 0; i + (1 << k) <= n; i++) {
st[k][i] = min(st[k - 1][i],
st[k - 1][i + (1 << (k - 1))]);
}
}
}
// Query the minimum in range [l, r] (0-indexed, inclusive)
int query(int l, int r) {
int k = logTable[r - l + 1];
return min(st[k][l], st[k][r - (1 << k) + 1]);
}
};
int main() {
vector<int> arr = {7, 2, 3, 0, 5, 10, 3, 12, 18};
SparseTable st(arr);
cout << "Min in [0, 4]: " << st.query(0, 4) << endl; // Output: 0
cout << "Min in [4, 7]: " << st.query(4, 7) << endl; // Output: 3
cout << "Min in [7, 8]: " << st.query(7, 8) << endl; // Output: 12
return 0;
}
Adapting for Other Operations
To adapt the Sparse Table for a different idempotent operation, simply replace the min function in both the build and query methods. Here is an example for range GCD queries:
class SparseTableGCD {
private:
vector<vector<int>> st;
vector<int> logTable;
int n, K;
public:
SparseTableGCD(const vector<int>& arr) {
n = arr.size();
logTable.resize(n + 1);
logTable[1] = 0;
for (int i = 2; i <= n; i++) {
logTable[i] = logTable[i / 2] + 1;
}
K = logTable[n] + 1;
st.assign(K, vector<int>(n));
for (int i = 0; i < n; i++) {
st[0][i] = arr[i];
}
for (int k = 1; k < K; k++) {
for (int i = 0; i + (1 << k) <= n; i++) {
st[k][i] = __gcd(st[k - 1][i],
st[k - 1][i + (1 << (k - 1))]);
}
}
}
int query(int l, int r) {
int k = logTable[r - l + 1];
return __gcd(st[k][l], st[k][r - (1 << k) + 1]);
}
};
Time Complexity Analysis
Preprocessing
The preprocessing step fills a table of size K ร n, where K = floor(log2(n)) + 1. Each cell is computed in O(1) time using two previously computed cells. Therefore, the total preprocessing time is:
O(n * K) = O(n * log n)
The precomputation of the log table also takes O(n) time, which is dominated by the O(n log n) table construction.
Query
Each query involves computing k = logTable[r - l + 1] (an O(1) lookup thanks to the precomputed log table) and then combining two values from the table. The total query time is therefore:
O(1)
This constant-time query is the primary reason Sparse Tables are preferred over segment trees for static idempotent range queries.
Space Complexity
The Sparse Table stores K arrays of size n, plus the log table of size n + 1. The total space complexity is:
O(n * log n)
For an array of one million elements, this translates to approximately 20 million integers (since log2(10^6) โ 20), which is about 80 MB of memory โ generally acceptable but worth keeping in mind for memory-constrained environments.
Handling Non-Idempotent Operations
For non-idempotent operations like range sum, the standard Sparse Table approach fails because the two overlapping intervals would double-count the elements in the overlap. However, there is a variant that handles sum queries by using O(log n) per query: you decompose the query range into disjoint power-of-two segments and sum them up. This is essentially the same as the binary lifting technique.
long long querySum(int l, int r) {
long long sum = 0;
int len = r - l + 1;
int k = logTable[len];
for (int i = k; i >= 0; i--) {
if ((1 << i) <= len) {
sum += st[i][l];
l += (1 << i);
len -= (1 << i);
}
}
return sum;
}
However, for sum queries, a prefix sum array is almost always a better choice since it provides O(n) preprocessing, O(n) space, and O(1) queries. The Sparse Table variant is only useful when you need both idempotent and non-idempotent queries on the same static array.
Best Practices
- Precompute logarithms: Always precompute the log values in an array rather than calling
log2()during queries. Floating-point log functions can introduce subtle precision errors and are significantly slower than an array lookup. - Use 0-indexed or 1-indexed consistently: Mixing indexing conventions is a common source of off-by-one errors. Pick one convention and stick with it throughout your implementation.
- Choose the right data structure: If the array requires updates, use a segment tree or Fenwick tree instead. Sparse Tables cannot be efficiently updated โ any change requires rebuilding the entire table.
- Watch memory usage: For very large arrays, the
O(n log n)space can be significant. Consider whether a segment tree (which usesO(n)space) might be a better fit if memory is tight. - Verify idempotency: Before using a Sparse Table, confirm that your operation is idempotent. Using it for sum or product queries with the standard
O(1)query method will produce incorrect results. - Use
__builtin_clzfor log computation: On GCC, you can computefloor(log2(x))as31 - __builtin_clz(x)for 32-bit integers, which can be faster than a precomputed table for single lookups (though the table approach is still preferred for bulk queries).
Comparison with Other Data Structures
To understand when to use a Sparse Table, it helps to compare it with alternatives:
- Prefix Sum Array:
O(n)preprocessing,O(n)space,O(1)queries โ but only works for invertible operations like sum. Cannot handle min/max. - Segment Tree:
O(n)preprocessing,O(n)space,O(log n)queries โ supports both updates and any associative operation. More flexible but slower for queries. - Sparse Table:
O(n log n)preprocessing,O(n log n)space,O(1)queries โ fastest queries but no updates and requires idempotent operations. - Disjoint Sparse Table: A variant that achieves
O(1)queries for any associative (not necessarily idempotent) operation, at the cost of slightly more complex implementation.
Conclusion
Sparse Tables are an elegant and highly efficient data structure for answering static range queries with idempotent operations. By precomputing answers for all power-of-two-length intervals, they achieve O(1) query time after O(n log n) preprocessing, making them ideal for scenarios involving massive numbers of queries on immutable data. While they are limited to idempotent operations and cannot handle updates, their simplicity, speed, and predictable performance make them a valuable tool in any developer's or competitive programmer's toolkit. Understanding when to reach for a Sparse Table versus a segment tree or prefix sum array is a key skill for writing efficient range-query code.