1. 简介

稀疏表(Sparse Table)是一种用于高效处理范围查询问题的数据结构,最常见的是范围最小值查询(RMQ),可以在 O(1) 时间内回答每次查询。

在本文中,我们将通过构建稀疏表的思路,逐步理解其原理、实现方式以及适用场景,并探讨其优缺点。

2. 问题描述

我们以经典的范围最小值查询(Range Minimum Query, RMQ)为例:
给定一个数组 A = {A₁, A₂, ..., Aₙ},以及若干形如 (L, R) 的查询请求,要求返回数组 A 中从索引 LR(含)之间的最小值。

即:

RMQ(L, R) = \min(A_L, A_{L+1}, ..., A_R)

3. 暴力解法

3.1. 每次查询遍历区间

最直接的方式是,每次查询都从 L 遍历到 R,找出最小值:

List<Integer> answer = new ArrayList<>();
for (int[] query : queries) {
    int L = query[0], R = query[1];
    int min = A[L];
    for (int i = L + 1; i <= R; i++) {
        min = Math.min(min, A[i]);
    }
    answer.add(min);
}

优点:空间复杂度为 O(1)
缺点:每次查询时间 O(n),总时间复杂度 O(mn),效率低

3.2. 预处理所有区间最小值(O(1) 查询)

我们可以预处理出所有区间 [L, R] 的最小值,存储在一个二维数组 minTable 中:

int[][] minTable = new int[n][n];
for (int i = 0; i < n; i++) {
    minTable[i][i] = A[i];
}
for (int i = 0; i < n; i++) {
    for (int j = i + 1; j < n; j++) {
        minTable[i][j] = Math.min(minTable[i][j - 1], A[j]);
    }
}

然后每次查询只需 O(1)

List<Integer> answer = new ArrayList<>();
for (int[] query : queries) {
    int L = query[0], R = query[1];
    answer.add(minTable[L][R]);
}

优点:每次查询 O(1)
缺点:预处理时间 O(n²),空间复杂度 O(n²),不适用于大规模数据

4. 稀疏表解法

稀疏表的核心思想是:只预处理长度为 2^k 的区间最小值,从而在查询时通过组合这些区间快速得到答案

4.1. 核心思想

  • 任意长度的区间 [L, R] 都可以拆解为若干个长度为 2^k 的子区间。
  • 例如 [2, 12] 可以拆成 [2, 9](长度 8)、[10, 11](长度 2)、[12, 12](长度 1)
  • 我们只需预处理所有起点 i 和长度 2^j 的区间最小值即可。

4.2. 稀疏表结构定义

我们定义一个二维数组 sparse[i][j],表示从索引 i 开始,长度为 2^j 的区间的最小值:

sparse[i][j] = \min(A[i], A[i+1], ..., A[i + 2^j - 1])

递推公式如下:

sparse[i][j] = \min(sparse[i][j-1], sparse[i + 2^{j-1}][j-1])

4.3. 构建稀疏表

int[][] sparse = new int[n][log2(n) + 1];
// 初始化长度为1的区间
for (int i = 0; i < n; i++) {
    sparse[i][0] = A[i];
}
// 构建其他长度的区间
for (int j = 1; (1 << j) <= n; j++) {
    for (int i = 0; i + (1 << j) <= n; i++) {
        sparse[i][j] = Math.min(
            sparse[i][j - 1],
            sparse[i + (1 << (j - 1))][j - 1]
        );
    }
}

✅ 时间复杂度:O(n log n)
✅ 空间复杂度:O(n log n)

4.4. 使用稀疏表进行 RMQ 查询

对于任意区间 [L, R],我们找到其长度 len = R - L + 1,并计算最大 k 满足 2^k ≤ len,即 k = log2(len)

然后我们查询两个长度为 2^k 的区间:

int k = log2(R - L + 1);
int min1 = sparse[L][k];
int min2 = sparse[R - (1 << k) + 1][k];
int result = Math.min(min1, min2);

✅ 查询时间复杂度:O(1)

4.5. 预处理 log2 值(可选优化)

为了加速查询中 log2 的计算,我们可以预先计算所有可能的 log2(i) 值:

int[] logTable = new int[n + 1];
logTable[1] = 0;
for (int i = 2; i <= n; i++) {
    logTable[i] = logTable[i / 2] + 1;
}

这样在查询时只需查表即可。

5. 稀疏表的通用性

稀疏表不仅适用于 RMQ,也可以用于其他满足以下两个条件的函数 f

  1. 结合性(Associative)f(a, b, c) = f(f(a, b), c) = f(a, f(b, c))
  2. 重叠友好性(Overlap-friendly)f(a, b, c) = f(f(a, b), f(b, c))

✅ 常见支持函数:

  • 最小值(min)
  • 最大值(max)
  • 最大公约数(GCD)

❌ 不支持函数:

  • 求和(sum)——虽然满足结合性,但不满足重叠友好性,不能用 O(1) 查询

6. 稀疏表的局限性

⚠️ 稀疏表适用于静态数组,一旦数组内容发生改变,就需要重新构建整个稀疏表,代价为 O(n log n)。

因此,稀疏表不适合以下场景:

  • 数组频繁更新
  • 要求支持动态修改的范围查询(如线段树更适合)

7. 总结

特性 暴力 预处理 稀疏表
查询时间 O(n) O(1) O(1)
预处理时间 - O(n²) O(n log n)
空间复杂度 O(1) O(n²) O(n log n)
支持更新

✅ 稀疏表非常适合用于静态数组的范围最小/最大值、GCD 等查询,查询效率高,适合大规模数据处理。

✅ 若需支持更新或求和操作,可考虑使用线段树前缀和等结构。


原始标题:Understanding Sparse Tables