Skip to content

稀疏表(Sparse Table)

想象一下这个场景:你是某个大数据平台的工程师,数据库里存了一张表,有 100 万行数据。用户频繁发来这样的查询请求:"第 5 万行到第 10 万行之间的最小值是多少?"每天可能要处理几十万次。

如果每次查询都去扫描一遍区间,O(n) 的复杂度在 100 万的数据量下就是一场灾难。但如果数据是静态的(不会频繁更新),有一种数据结构可以在 O(1) 时间内回答任意区间最值查询,而且代码出奇地简单。这就是今天要讲的 稀疏表(Sparse Table)

为什么需要稀疏表?

先来看看常见方案的痛点:

方案查询复杂度更新复杂度适用场景
暴力扫描O(n)O(1)小数据、单次查询
线段树O(log n)O(log n)动态数据
稀疏表O(1)O(n log n)静态数据

线段树我们之前聊过(docs/algo/segment-tree.md),支持动态更新,但查询还是要 O(log n)。稀疏表的优势在于:数据不变时,查询就是 O(1)——这是目前静态 RMQ 问题能做到的最优复杂度。

原理拆解

什么是 RMQ?

RMQ 全称是 Range Minimum/Maximum Query,即"区间最小/最大查询"。给定一个数组,回答"区间 [L, R] 内的最小/最大值是多少?"

稀疏表的核心思想

稀疏表本质上是倍增DP的应用。核心思路就一句话:预处理每个元素开始的 2^k 长度的最值,查询时把区间分成两段重叠的 2^k 块

Step 1: 预处理

table[k][i] 表示从数组第 i 个位置开始、长度为 2^k 的区间内的最值(即 arr[i]arr[i + 2^k - 1] 的最小值)。

状态转移方程:

table[0][i] = arr[i]                          // 长度 2^0 = 1,自己就是最小值
table[k][i] = min(table[k-1][i], table[k-1][i + 2^(k-1)])
               // 长度为 2^k 的区间 = 两段长度为 2^(k-1) 的区间拼接

这就是典型的倍增DP——从长度为 1 开始,逐级合并出更长的区间。

数组: [3, 2, 5, 7, 1, 4, 6, 3]

table[0] (长度1):  [3, 2, 5, 7, 1, 4, 6, 3]
table[1] (长度2):  [2, 2, 5, 1, 1, 4, 3]     // min(3,2)=2, min(2,5)=2, ...
table[2] (长度4):  [2, 1, 1, 1, 1, 3]        // min(3,2,5,7)=2, min(2,5,7,1)=1, ...
table[3] (长度8):  [1]                        // min(整个数组)=1

构建复杂度分析:

  • 一共有 O(n log n) 个状态
  • 每个状态 O(1) 计算
  • 总时间:O(n log n),总空间:O(n log n)

Step 2: O(1) 查询

假设要查询区间 [L, R] 的最小值:

设 k = floor(log2(R - L + 1))   // 区间长度的二进制下最高位
查询区间长度 = 2^k(这个 k 确保了两段都完全落在 [L, R] 内)

图示:

查询区间 [L, R],长度 = R - L + 1

位置:   L        L+2^k-1    R-2^k+1    R
        |----------|----------|----------|
        ← 2^k →   ← 重叠 →   ← 2^k →

区间1: 从 L 开始,长度 2^k   → table[k][L]
区间2: 从 R-2^k+1 开始,长度 2^k → table[k][R-2^k+1]

两个区间覆盖了整个 [L,R],取 min 就是答案

为什么要取两段?

  • 区间1: [L, L+2^k-1]
  • 区间2: [R-2^k+1, R]
  • 因为 2^k ≤ 区间长度,所以这两个区间都完全落在 [L,R]
  • 两段可能有重叠,但重叠不影响最值结果(两个区间各自包含正确答案)

代码实现:

typescript
function rmq(arr: number[], L: number, R: number): number {
  const len = R - L + 1;
  const k = Math.floor(Math.log2(len)); // 区间长度的最高位
  return Math.min(table[k][L], table[k][R - (1 << k) + 1]);
}

关键细节:为什么 O(1) 是可行的?

这是稀疏表最妙的地方。传统数据结构(如线段树)查询需要沿着树往下走 O(log n) 步,是因为信息没有冗余存储。而稀疏表在预处理时把 O(n log n) 的空间换来了 O(1) 的查询时间

查询区间 [L, R] 为什么要用两段而不是一段?

假设 len = 13 (十进制) = 1101 (二进制)

我们取 k = floor(log2(13)) = 3(因为 2^3 = 8 ≤ 13 < 16 = 2^4)

如果只取一段: [L, L+8),只覆盖了部分区间 ❌
用两段: [L, L+8) + [R-8+1, R+1),正好覆盖整个区间 ✅

两段之间有重叠(重叠 8+8-13=3 个元素),但 min 操作不怕重叠

局限性:为什么稀疏表只适合静态数据?

因为预处理是基于固定数组的。一旦数组某个位置的值变了,所有包含这个位置的 table[k][i] 都需要更新,而影响范围是 O(n log n)——这还不如重新建表。

所以:

  • ✅ 静态数组 + 大量查询 → 稀疏表
  • ❌ 需要频繁单点更新 → 线段树 / 树状数组
  • ❌ 需要频繁区间更新 → 线段树(Lazy Propagation)

代码实现

TypeScript

typescript
/**
 * 稀疏表(Sparse Table)—— 静态区间最值查询
 *
 * 核心思想:预处理所有 2^k 长度的最值,查询时 O(1) 合并两段
 * 适用场景:数组静态、查询频繁
 */

class SparseTable {
  private table: number[][]; // table[k][i] = min/max of arr[i..i+2^k-1]
  private n: number;
  private isRMQ = true; // true=最小值查询,false=最大值查询

  constructor(arr: number[], type: 'min' | 'max' = 'min') {
    this.n = arr.length;
    this.isRMQ = type === 'min';
    this.build(arr);
  }

  private build(arr: number[]): void {
    // 预处理 log2(n) 向上取整的层数
    const maxK = Math.floor(Math.log2(this.n)) + 1;
    this.table = Array.from({ length: maxK }, () => new Array(this.n).fill(0));

    // 第 0 层:长度为 1 的区间,就是元素本身
    for (let i = 0; i < this.n; i++) {
      this.table[0][i] = arr[i];
    }

    // 第 k 层:合并两个长度为 2^(k-1) 的区间
    for (let k = 1; k < maxK; k++) {
      for (let i = 0; i + (1 << k) <= this.n; i++) {
        const left = this.table[k - 1][i];
        const right = this.table[k - 1][i + (1 << (k - 1))];
        this.table[k][i] = this.isRMQ
          ? Math.min(left, right)
          : Math.max(left, right);
      }
    }
  }

  /**
   * 查询区间 [L, R] 的最值
   * 时间复杂度: O(1)
   */
  query(L: number, R: number): number {
    if (L > R || L < 0 || R >= this.n) {
      throw new Error('Invalid range');
    }

    const len = R - L + 1;
    const k = Math.floor(Math.log2(len)); // 区间长度对应的最大 2^k

    const left = this.table[k][L];
    const right = this.table[k][R - (1 << k) + 1];

    return this.isRMQ ? Math.min(left, right) : Math.max(left, right);
  }
}

// 使用示例
const arr = [3, 2, 5, 7, 1, 4, 6, 3];
const st = new SparseTable(arr, 'min');

// 单次查询
console.log(st.query(1, 4)); // 区间 [2,5,7,1] → 最小值 1
console.log(st.query(0, 7)); // 整个数组 → 最小值 1

// 批量查询模拟
const queries = [
  [0, 3], [2, 5], [1, 6], [4, 7], [0, 0]
];
queries.forEach(([L, R]) => {
  console.log(`RMQ(${L}, ${R}) = ${st.query(L, R)}`);
});

Go

go
package sparsetable

import (
	"math"
)

// SparseTable 稀疏表 —— 静态区间最值查询
type SparseTable struct {
	table  [][]int // table[k][i] = 从 arr[i] 开始,长度为 2^k 的区间最值
	isMin  bool    // true=查最小值,false=查最大值
	length int     // 数组长度
}

// NewMin 创建查最小值的稀疏表
func NewMin(arr []int) *SparseTable {
	return newSparseTable(arr, true)
}

// NewMax 创建查最大值的稀疏表
func NewMax(arr []int) *SparseTable {
	return newSparseTable(arr, false)
}

func newSparseTable(arr []int, isMin bool) *SparseTable {
	n := len(arr)
	maxK := int(math.Log2(float64(n))) + 1

	// table[k] 存储长度为 2^k 的区间最值
	// table[0] 长度为 1,table[1] 长度为 2...
	table := make([][]int, maxK)
	for k := 0; k < maxK; k++ {
		table[k] = make([]int, n)
	}

	// 第 0 层:长度 1 = 元素本身
	copy(table[0], arr)

	// 动态规划:从已知的小区间合并出更大的区间
	for k := 1; (1 << k) <= n; k++ {
		for i := 0; i+(1<<k) <= n; i++ {
			left := table[k-1][i]
			right := table[k-1][i+(1<<(k-1))]
			if isMin {
				if left < right {
					table[k][i] = left
				} else {
					table[k][i] = right
				}
			} else {
				if left > right {
					table[k][i] = left
				} else {
					table[k][i] = right
				}
			}
		}
	}

	return &SparseTable{
		table:  table,
		isMin:  isMin,
		length: n,
	}
}

// Query 查询区间 [L, R] 的最值,O(1) 时间
func (st *SparseTable) Query(L, R int) int {
	if L < 0 || R >= st.length || L > R {
		panic("invalid range")
	}

	// 取 k = floor(log2(R-L+1)),即满足 2^k <= len < 2^(k+1)
	len := R - L + 1
	k := int(math.Log2(float64(len)))

	left := st.table[k][L]
	right := st.table[k][R-(1<<k)+1]

	if st.isMin {
		if left < right {
			return left
		}
		return right
	}
	if left > right {
		return left
	}
	return right
}

// RMQ 测试用例
func ExampleRMQ() {
	arr := []int{3, 2, 5, 7, 1, 4, 6, 3}

	stMin := NewMin(arr)
	stMax := NewMax(arr)

	queries := [][2]int{
		{0, 3}, // [3,2,5,7] → min=2, max=7
		{2, 5}, // [5,7,1,4] → min=1, max=7
		{1, 6}, // [2,5,7,1,4,6] → min=1, max=7
	}

	for _, q := range queries {
		L, R := q[0], q[1]
		min := stMin.Query(L, R)
		max := stMax.Query(L, R)
		println("Range", L, "-", R, ": min =", min, ", max =", max)
	}
}

Java

java
import java.util.Arrays;

/**
 * 稀疏表(Sparse Table)—— 静态区间最值查询
 *
 * 预处理时间 O(n log n),查询时间 O(1)
 * 适用于:数组元素固定不变,查询极其频繁的场景
 */
public class SparseTable {
    private final int[][] table; // table[k][i] = 从 arr[i] 开始,长度 2^k 的区间最值
    private final boolean isMin; // true = 查最小值
    private final int n;         // 数组长度

    /**
     * @param arr   原始数组
     * @param isMin true=查最小值,false=查最大值
     */
    public SparseTable(int[] arr, boolean isMin) {
        this.isMin = isMin;
        this.n = arr.length;

        // 最大需要 log2(n) + 1 层
        int maxK = (int) (Math.log(n) / Math.log(2)) + 1;
        this.table = new int[maxK][n];

        // 第 0 层:长度为 1
        table[0] = Arrays.copyOf(arr, n);

        // DP 构建:合并两个长度为 2^(k-1) 的子区间
        for (int k = 1; (1 << k) <= n; k++) {
            for (int i = 0; i + (1 << k) <= n; i++) {
                int left = table[k - 1][i];
                int right = table[k - 1][i + (1 << (k - 1))];
                table[k][i] = isMin ? Math.min(left, right) : Math.max(left, right);
            }
        }
    }

    /**
     * O(1) 查询区间 [L, R] 的最值
     */
    public int query(int L, int R) {
        if (L < 0 || R >= n || L > R) {
            throw new IllegalArgumentException("Invalid range");
        }

        int len = R - L + 1;
        // 取 k = floor(log2(len)),确保 2^k <= len < 2^(k+1)
        int k = (int) (Math.log(len) / Math.log(2));

        int left = table[k][L];
        int right = table[k][R - (1 << k) + 1];

        return isMin ? Math.min(left, right) : Math.max(left, right);
    }

    // 使用示例
    public static void main(String[] args) {
        int[] arr = {3, 2, 5, 7, 1, 4, 6, 3};

        SparseTable minTable = new SparseTable(arr, true);
        SparseTable maxTable = new SparseTable(arr, false);

        // 批量查询
        int[][] queries = {
            {0, 3},
            {2, 5},
            {1, 6},
            {4, 7}
        };

        for (int[] q : queries) {
            int L = q[0], R = q[1];
            System.out.printf("RMQ(%d, %d) → min=%d, max=%d%n",
                L, R, minTable.query(L, R), maxTable.query(L, R));
        }
    }
}

Python

python
import math
from typing import List


class SparseTable:
    """稀疏表(Sparse Table)—— 静态区间最值查询

    预处理: O(n log n) 时间 + O(n log n) 空间
    查询:   O(1) 时间

    核心思想:用 DP 预处理所有 2^k 长度的最值,
    查询时把区间分成两段 2^k 块(可能有重叠),合并结果。

    为什么重叠也没关系:因为取最值的操作具有幂等性,
    重叠区间里相同的元素不会影响最终结果。
    """

    def __init__(self, arr: List[int], type_: str = 'min'):
        """
        Args:
            arr: 原始数组
            type_: 'min' 查最小值,'max' 查最大值
        """
        self.arr = arr
        self.n = len(arr)
        self.is_min = (type_ == 'min')

        # maxK = floor(log2(n)) + 1,最多 log2(n) + 1 层
        max_k = int(math.log2(self.n)) + 1
        # table[k][i] = arr[i..i+2^k-1] 的最值
        self.table = [[0] * self.n for _ in range(max_k)]

        # 第 0 层:长度 1 = 元素本身
        self.table[0] = arr.copy()

        # DP 构建:从小区间合并出大区间
        for k in range(1, max_k):
            for i in range(self.n - (1 << k) + 1):
                left = self.table[k - 1][i]
                right = self.table[k - 1][i + (1 << (k - 1))]
                self.table[k][i] = min(left, right) if self.is_min else max(left, right)

    def query(self, L: int, R: int) -> int:
        """O(1) 查询区间 [L, R] 的最值

        为什么用两段 2^k:只要 2^k <= 区间长度,两段就完全落在区间内,
        它们各自包含正确答案,min/max 操作不怕重叠。
        """
        if L < 0 or R >= self.n or L > R:
            raise ValueError(f"Invalid range [{L}, {R}]")

        # 区间长度对应的最大 2^k
        length = R - L + 1
        k = int(math.log2(length))

        left = self.table[k][L]
        right = self.table[k][R - (1 << k) + 1]

        return min(left, right) if self.is_min else max(left, right)

    def __repr__(self) -> str:
        return f"SparseTable(n={self.n}, type={'min' if self.is_min else 'max'})"


if __name__ == "__main__":
    arr = [3, 2, 5, 7, 1, 4, 6, 3]

    min_table = SparseTable(arr, 'min')
    max_table = SparseTable(arr, 'max')

    # 模拟多次查询
    queries = [(0, 3), (2, 5), (1, 6), (4, 7), (0, 7)]

    print(f"数组: {arr}")
    print("-" * 40)
    for L, R in queries:
        min_val = min_table.query(L, R)
        max_val = max_table.query(L, R)
        print(f"RMQ[{L}, {R}] = [最小值: {min_val}, 最大值: {max_val}]")

    # 验证:对比暴力法
    print("\n验证(对比暴力法):")
    for L, R in queries:
        expected_min = min(arr[L:R+1])
        expected_max = max(arr[L:R+1])
        assert min_table.query(L, R) == expected_min
        assert max_table.query(L, R) == expected_max
    print("✅ 所有查询验证通过!")

复杂度分析

操作时间复杂度空间复杂度
预处理O(n log n)O(n log n)
单次查询O(1)O(1)
单点更新O(n log n)(不建议用)

为什么空间是 O(n log n)?

  • 共有 log(n) 层
  • 第 k 层有 n - 2^k + 1 个元素
  • 总和约为 n * (log(n) + 1) = O(n log n)

不过实际实现时,可以用笛卡尔树把空间优化到 O(n)——这是另一个有趣的算法,但面试中 O(n log n) 的空间完全够用。

实际应用场景

1. LCP(最长公共前缀)查询

字符串的后缀数组排序后,相邻后缀的 LCP 可以用 RMQ 来回答:

typescript
// 给定后缀数组 SA 和 rank 表,查第 i 和第 j 个后缀的 LCP
function lcpQuery(SA: number[], rank: number[], i: number, j: number): number {
  if (i === j) return arr.length - SA[i]; // 同一个后缀
  const L = rank[i], R = rank[j];
  return sparseTable.query(Math.min(L, R) + 1, Math.max(L, R));
}

这是后缀数组的经典应用,搜索引擎的字符串处理经常用到。

2. 实时监控系统

传感器每秒采集一次温度/延迟数据,数据量很大且通常不会修改历史数据。查询"过去 1 小时内最低/最高温度"时,O(1) 的稀疏表查询比线段树的 O(log n) 快了 log n 倍,在高频查询场景下优势明显。

3. ST 表与 LCA 的转换

树上两个节点的最近公共祖先(LCA)问题,可以通过欧拉 tour + RMQ 在 O(1) 时间内解决。把树遍历成 Euler 序列, LCA 就是 Euler 序列中两个节点首次出现位置之间的深度最小值。Sparse Table 完美契合这个需求。

稀疏表 vs 线段树 vs 树状数组

特性稀疏表线段树树状数组
预处理O(n log n)O(n)O(n log n)
查询O(1)O(log n)O(log n)
点更新O(n log n)O(log n)O(log n)
区间更新O(n log n)O(log n)O(log n)
空间O(n log n)O(n)O(n)
适用场景静态数据动态数据动态数据(单点为主)

面试时如果被问到"静态 RMQ",直接上稀疏表。如果是动态 RMQ(需要更新),就讲线段树。

小结

稀疏表的核心就是倍增DP预处理 + O(1) 区间合并

  • ✅ 查询 O(1),静态数据下这是理论最优
  • ✅ 代码极简,状态转移就一行 min(table[k-1][i], table[k-1][i+2^(k-1)])
  • ✅ 是 RMQ 问题的经典解法,跟后缀数组、LCA 都有紧密联系
  • ❌ 不支持动态更新,更新一个元素需要 O(n log n)
  • ❌ 空间 O(n log n),大数据量时注意内存

掌握 Sparse Table,你就在 RMQ 问题上达到了理论最优——面试时能跟面试官聊到倍增DP、预处理的时空交换思维,说明你对算法思想的理解已经到了一个不错的层次 🎯

最近更新