每日算法 — 使用java实现ST表:倍增预处理与O(1)区间最值查询

区间最值查询(Range Minimum/Maximum Query, RMQ)是算法竞赛和工程实践中的高频问题。ST表(Sparse Table,稀疏表)通过倍增预处理,可以在 O(n log n) 的预处理后,以 O(1) 的时间回答任意区间的最值查询。本文用Java完整实现ST表,并对比其与线段树的适用场景。

一、问题定义

给定一个长度为 n 的数组 a[0..n-1],回答 q 次查询:区间 [l, r] 内的最大值(或最小值)是多少?

约束:n ≤ 10⁵,q ≤ 10⁶,要求单次查询尽可能快。

二、核心思想:倍增预处理

ST表的核心洞察:任何区间 [l, r] 都可以被两个重叠的、长度为2的幂的区间完全覆盖。

例如区间 [3, 10](长度8)可以被 [3, 6](长度4)和 [7, 10](长度4)覆盖,也可以被 [3, 10] 本身(长度8)覆盖。

预处理 dp[k][i] 表示从位置 i 开始、长度为 2ᵏ 的区间的最值:

dp[0][i] = a[i]                        // 长度为1
dp[k][i] = max(dp[k-1][i], dp[k-1][i + 2^(k-1)])  // 长度为2^k

查询时,设 len = r - l + 1,k = floor(log₂(len)),则:

answer = max(dp[k][l], dp[k][r - 2^k + 1])

两个长度为 2ᵏ 的区间覆盖 [l, r],允许重叠,因为最值具有重叠不变性。

三、完整Java实现

import java.util.Arrays;

/**
 * ST表 (Sparse Table) - 静态区间最值查询
 * 预处理: O(n log n)
 * 查询: O(1)
 * 不支持修改操作
 */
public class SparseTable {

    private int n;
    private int[][] st;      // st[k][i]: 从i开始长度为2^k的区间的最值
    private int[] logTable;  // logTable[i]: floor(log2(i))
    private boolean isMax;   // true=最大值, false=最小值

    /**
     * 构建ST表
     * @param arr 原始数组
     * @param isMax true表示查询最大值, false表示最小值
     */
    public SparseTable(int[] arr, boolean isMax) {
        this.n = arr.length;
        this.isMax = isMax;
        buildLogTable();
        buildST(arr);
    }

    /**
     * 预处理log2表,避免查询时重复计算
     */
    private void buildLogTable() {
        logTable = new int[n + 1];
        logTable[1] = 0;
        for (int i = 2; i <= n; i++) {
            logTable[i] = logTable[i / 2] + 1;
        }
    }

    /**
     * 构建ST表的核心逻辑
     */
    private void buildST(int[] arr) {
        int maxK = logTable[n] + 1;
        st = new int[maxK][n];

        // 第0层: 长度为1的区间
        for (int i = 0; i < n; i++) {
            st[0][i] = arr[i];
        }

        // 第k层: 长度为2^k的区间
        for (int k = 1; k < maxK; k++) {
            int len = 1 << k; // 2^k
            for (int i = 0; i + len <= n; i++) {
                int half = 1 << (k - 1); // 2^(k-1)
                if (isMax) {
                    st[k][i] = Math.max(st[k - 1][i], st[k - 1][i + half]);
                } else {
                    st[k][i] = Math.min(st[k - 1][i], st[k - 1][i + half]);
                }
            }
        }
    }

    /**
     * 查询区间 [l, r] 的最值 (0-based, 闭区间)
     * @param l 左边界
     * @param r 右边界
     * @return 区间最值
     */
    public int query(int l, int r) {
        if (l < 0 || r >= n || l > r) {
            throw new IllegalArgumentException("Invalid range: [" + l + ", " + r + "]");
        }
        int len = r - l + 1;
        int k = logTable[len];
        int p = 1 << k; // 2^k

        if (isMax) {
            return Math.max(st[k][l], st[k][r - p + 1]);
        } else {
            return Math.min(st[k][l], st[k][r - p + 1]);
        }
    }

    /**
     * 同时维护最大值和最小值的高效方案
     */
    public static class STDouble {
        private int n;
        private int[][] stMax;
        private int[][] stMin;
        private int[] logTable;

        public STDouble(int[] arr) {
            this.n = arr.length;
            buildLogTable();
            int maxK = logTable[n] + 1;
            stMax = new int[maxK][n];
            stMin = new int[maxK][n];

            for (int i = 0; i < n; i++) {
                stMax[0][i] = arr[i];
                stMin[0][i] = arr[i];
            }

            for (int k = 1; k < maxK; k++) {
                int half = 1 << (k - 1);
                for (int i = 0; i + (1 << k) <= n; i++) {
                    stMax[k][i] = Math.max(stMax[k - 1][i], stMax[k - 1][i + half]);
                    stMin[k][i] = Math.min(stMin[k - 1][i], stMin[k - 1][i + half]);
                }
            }
        }

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

        public int queryMax(int l, int r) {
            int k = logTable[r - l + 1];
            int p = 1 << k;
            return Math.max(stMax[k][l], stMax[k][r - p + 1]);
        }

        public int queryMin(int l, int r) {
            int k = logTable[r - l + 1];
            int p = 1 << k;
            return Math.min(stMin[k][l], stMin[k][r - p + 1]);
        }
    }

    public static void main(String[] args) {
        int[] arr = {7, 2, 9, 1, 5, 8, 3, 6, 4, 10};

        System.out.println("原始数组: " + Arrays.toString(arr));
        System.out.println();

        // 最大值ST表
        SparseTable stMax = new SparseTable(arr, true);
        System.out.println("区间最大值查询:");
        int[][] maxQueries = {{0, 9}, {2, 5}, {1, 3}, {6, 8}, {0, 0}};
        for (int[] q : maxQueries) {
            System.out.printf("  [%d, %d] max = %d%n", q[0], q[1], stMax.query(q[0], q[1]));
        }

        System.out.println();

        // 最小值ST表
        SparseTable stMin = new SparseTable(arr, false);
        System.out.println("区间最小值查询:");
        for (int[] q : maxQueries) {
            System.out.printf("  [%d, %d] min = %d%n", q[0], q[1], stMin.query(q[0], q[1]));
        }

        System.out.println();

        // 同时维护最大最小值
        STDouble stDouble = new STDouble(arr);
        System.out.println("同时查询最大最小值:");
        for (int[] q : maxQueries) {
            System.out.printf("  [%d, %d] max=%d, min=%d%n", 
                q[0], q[1], stDouble.queryMax(q[0], q[1]), stDouble.queryMin(q[0], q[1]));
        }

        // 性能测试
        System.out.println("\n性能测试: n=100000, q=1000000");
        performanceTest();
    }

    private static void performanceTest() {
        int n = 100000;
        int q = 1000000;
        int[] largeArr = new int[n];
        for (int i = 0; i < n; i++) {
            largeArr[i] = (int)(Math.random() * 1000000);
        }

        long start = System.currentTimeMillis();
        SparseTable st = new SparseTable(largeArr, true);
        long buildTime = System.currentTimeMillis() - start;

        start = System.currentTimeMillis();
        for (int i = 0; i < q; i++) {
            int l = (int)(Math.random() * n);
            int r = l + (int)(Math.random() * (n - l));
            st.query(l, r);
        }
        long queryTime = System.currentTimeMillis() - start;

        System.out.printf("  预处理时间: %d ms%n", buildTime);
        System.out.printf("  %d次查询时间: %d ms%n", q, queryTime);
        System.out.printf("  单次查询约: %.3f μs%n", queryTime * 1000.0 / q);
    }
}

四、ST表的局限性

ST表最大的优点是 O(1) 查询,但它有一个致命限制:无法处理修改操作。因为修改一个元素可能影响 O(log n) 层预处理结果,更新代价过高。

适用场景对比:

数据结构 预处理 查询 修改 适用场景
ST表 O(n log n) O(1) 不支持 静态数组,大量查询
线段树 O(n) O(log n) O(log n) 需要动态修改
树状数组 O(n log n) O(log n) O(log n) 前缀查询,单点修改

五、扩展:支持GCD查询

ST表不仅适用于最值,还适用于任何满足幂等性的运算(即 f(a, a) = a),如GCD。

/**
 * ST表扩展 - 区间GCD查询
 * GCD满足幂等性: gcd(x, x) = x
 */
public class STGCD {

    private int n;
    private int[][] st;
    private int[] logTable;

    public STGCD(int[] arr) {
        this.n = arr.length;
        buildLogTable();
        int maxK = logTable[n] + 1;
        st = new int[maxK][n];

        for (int i = 0; i < n; i++) {
            st[0][i] = Math.abs(arr[i]);
        }

        for (int k = 1; k < maxK; k++) {
            int half = 1 << (k - 1);
            for (int i = 0; i + (1 << k) <= n; i++) {
                st[k][i] = gcd(st[k - 1][i], st[k - 1][i + half]);
            }
        }
    }

    private int gcd(int a, int b) {
        while (b != 0) {
            int tmp = a % b;
            a = b;
            b = tmp;
        }
        return a;
    }

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

    public int query(int l, int r) {
        int k = logTable[r - l + 1];
        int p = 1 << k;
        return gcd(st[k][l], st[k][r - p + 1]);
    }

    public static void main(String[] args) {
        int[] arr = {12, 18, 24, 9, 15, 30, 6, 21};
        STGCD st = new STGCD(arr);

        System.out.println("数组: [12, 18, 24, 9, 15, 30, 6, 21]");
        System.out.println("区间GCD查询:");
        int[][] queries = {{0, 2}, {1, 4}, {3, 6}, {0, 7}};
        for (int[] q : queries) {
            System.out.printf("  [%d, %d] GCD = %d%n", q[0], q[1], st.query(q[0], q[1]));
        }
    }
}

六、经典应用:滑动窗口最值

虽然单调队列更适合滑动窗口,但ST表也能处理固定窗口大小的离线查询。

/**
 * 使用ST表解决滑动窗口最值问题
 * 给定数组和窗口大小k,求每个窗口的最大值
 */
public class SlidingWindowWithST {

    public int[] maxSlidingWindow(int[] nums, int k) {
        if (nums == null || nums.length == 0 || k <= 0) return new int[0];

        SparseTable st = new SparseTable(nums, true);
        int n = nums.length;
        int[] result = new int[n - k + 1];

        for (int i = 0; i <= n - k; i++) {
            result[i] = st.query(i, i + k - 1);
        }
        return result;
    }

    public static void main(String[] args) {
        SlidingWindowWithST solver = new SlidingWindowWithST();
        int[] nums = {1, 3, -1, -3, 5, 3, 6, 7};
        int k = 3;

        int[] result = solver.maxSlidingWindow(nums, k);
        System.out.println("数组: " + java.util.Arrays.toString(nums));
        System.out.println("窗口大小: " + k);
        System.out.println("滑动窗口最大值: " + java.util.Arrays.toString(result));
        // 期望: [3, 3, 5, 5, 6, 7]
    }
}

七、总结

ST表是静态区间查询问题中查询速度最快的数据结构。其设计精髓在于倍增思想:通过预处理所有长度为2的幂的区间答案,使得任意区间的查询都能通过两个预计算结果合并得到。理解ST表的关键是掌握其”允许重叠”的特性——这正是幂等运算赋予它的独特优势。在无需修改的静态场景下,ST表无疑是RMQ问题的最优解。