NeFut Logo NeFut
EN 管理员登录

高效静态区间最值查询:动态规划与倍增算法的深入解析

发布于:2026-05-27 09:17 最后更新:2026-06-10 09:06
#Dynamic Programming #Sparse Table #RMQ

核心逻辑与数学原理

动态规划与倍增空间幂集

ST 表(Sparse Table)的本质是基于动态规划思想的倍增算法,核心用于解决静态区间最值查询(RMQ)问题。其数学基石在于覆盖可重复贡献性质(Idempotent Operations),即对于任意满足 $f(x, x) = x$ 的幂等运算符(如 $\text{max}, \text{min}, \text{gcd}$),多个重叠子区间的运算结果等于其并集的运算结果。

设 $f[i][j]$ 表示区间 $[i, i + 2^j - 1]$ 长度为 $2^j$ 的子区间最值。利用倍增思想,将长度为 $2^j$ 的区间等分为两段长度为 $2^{j-1}$ 的子区间:

状态转移方程为:

$$f[i][j] = \text{max}(f[i][j-1], f[i + 2^{j-1}][j-1])$$

$O(1)$ 复杂度的空间覆盖

对于任意查询区间 $[L, R]$,其长度为 $len = R - L + 1$。令 $k = \lfloor \text{log}_2(len) \rfloor$。由于 $2^k \le len < 2^{k+1}$,区间 $[L, R]$ 必然能被两个长度为 $2^k$ 且分别以 $L$ 为起点、 $R$ 为终点的子区间完全覆盖,即:

$$[L, R] = [L, L + 2^k - 1] \bigcup [R - 2^k + 1, R]$$

由此,RMQ 查询退化为常数级的代数映射:

$$\text{RMQ}(L, R) = \text{max}(f[L][k], f[R - 2^k + 1][k])$$


状态设计与算法推导

空间与时间权衡

状态定义中,第一维 $i$ 映射序列下标,边界为 $N$;第二维 $j$ 映射 2 的幂次,边界为 $\lfloor \text{log}_2 N \rfloor$。预处理阶段在外层枚举幂次 $j$,内层枚举下标 $i$。若错误调换循环顺序,会导致未计算的状态被提前引用,破坏动态规划的拓扑序。

预处理时间复杂度:

$$\text{O}(N \text{ log } N)$$

查询阶段通过位运算或内联芯片指令(如 __builtin_clz)计算 $k$,消除循环迭代,达成 $O(1)$ 查询。


C++ 标准源码

#include <iostream>
#include <vector>
#include <algorithm>

using namespace std;

// 封装 ST 表结构体,规避全局变量命名污染
struct SparseTable {
    int n;
    vector<int> log_table;
    vector<vector<int>> st;

    void init(const vector<int>& arr) {
        n = arr.size();
        int max_log = 0;
        while ((1 << max_log) <= n) max_log++;

        st.assign(n, vector<int>(max_log));
        log_table.assign(n + 1, 0);

        // 预处理 log 表,严禁在查询时调用 std::log2() 引入浮点数常数开销
        log_table[1] = 0;
        for (int i = 2; i <= n; i++) {
            log_table[i] = log_table[i >> 1] + 1;
        }

        // 基础状态填装
        for (int i = 0; i < n; i++) {
            st[i][0] = arr[i];
        }

        // 致命踩坑点:外层必须枚举幂次 j,内层枚举下标 i,严格保证 DP 状态转移的拓扑序
        for (int j = 1; j < max_log; j++) {
            for (int i = 0; i + (1 << j) - 1 < n; i++) {
                st[i][j] = max(st[i][j - 1], st[i + (1 << (j - 1))][j - 1]);
            }
        }
    }

    int query(int l, int r) {
        // 传入参数转换为 0-indexed 边界控制
        int len = r - l + 1;
        int k = log_table[len];
        // 致命踩坑点:右半部分区间的起点是 r - (1 << k) + 1,位运算优先级低,必须加括号
        return max(st[l][k], st[r - (1 << k) + 1][k]);
    }
};

int main() {
    // 强制解除输入输出流同步,加速 IO
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n, m;
    if (!(cin >> n >> m)) return 0;

    vector<int> arr(n);
    for (int i = 0; i < n; i++) {
        cin >> arr[i];
    }

    SparseTable table;
    table.init(arr);

    for (int i = 0; i < m; i++) {
        int l, r;
        cin >> l >> r;
        // 转换为 0-indexed 传入
        cout << table.query(l - 1, r - 1) << "\n";
    }

    return 0;
}

NOIP 实战避坑指南

  1. 循环顺序颠倒与边界溢出
    预处理 ST 表时,部分选手习惯性将 $i$ 放在外层, $j$ 放在内层。由于 $st[i][j]$ 依赖于 $st[i + 2^{j-1}][j-1]$,在外层为 $i$ 的情况下,计算当前状态时所需要的 $i + 2^{j-1}$ 处的 $j-1$ 状态尚未被外层循环扫到,导致读入未初始化的垃圾值。此外,内层控制边界 i + (1 << j) - 1 < n 缺失会导致数组越界踩中段错误(SIGSEGV)。
  2. 位运算优先级与常数灾难
    查询语句中 r - (1 << k) + 1,如果写成 r - 1 << k + 1,由于加减法运算符优先级高于移位运算符,实际执行逻辑变为 (r - 1) << (k + 1),导致区间逻辑彻底崩溃。另一致命低级错误是在查询阶段或预处理阶段高频调用库函数 std::log2(),该函数涉及浮点数计算与 FPU 指令转换,会带来高额常数开销,在 $10^6$ 级别查询下直接导致 TLE。必须使用递推数组或芯片内置汇编指令计算 $O(1)$ 的对数。

经典 NOIP/洛谷 真题

洛谷 P3865 【模板】ST 表

洛谷 P2880 [USACO07JAN] Balanced Lineup G

原文链接: local://3.3

[h] 返回首页