每日算法 — 使用java实现可持久化线段树:静态区间第K大与版本回溯查询

算法背景与问题引入

在日常开发中,我们经常遇到这样的需求:给定一个长度为N的数组,需要多次查询某个子区间内第K小的元素是多少。例如,在日志分析系统中查询某个时间段内第K频繁的操作类型,或在成绩管理系统中查询某班级第K名的分数。

如果数组是静态不变的,我们可以使用可持久化线段树(Persistent Segment Tree,又称主席树)在O(log N)时间内回答每次查询,预处理时间为O(N log N)。这一数据结构的核心思想是:每次单点修改时,不是直接修改原树,而是创建一条从根到叶子的新路径,共享未修改的子树,从而形成一棵”版本树”。

本文将完整讲解主席树的构建原理,并用Java实现静态区间第K大查询和版本回溯功能。

核心原理:函数式持久化

为什么需要可持久化

普通线段树支持单点修改和区间查询,时间复杂度均为O(log N)。但如果我们需要在修改后保留历史版本(例如实现”撤销”操作或查询历史状态的区间第K大),直接复制整棵树的代价是O(N),无法接受。

主席树的关键洞察在于:一次单点修改只会影响从根节点到目标叶子节点路径上的O(log N)个节点。因此,我们只需新建这O(log N)个节点,其余节点直接复用旧版本的指针,即可在O(log N)时间和空间内创建一个新版本。

版本链的结构

假设我们有一个数组[4, 1, 3, 2],主席树的构建过程如下:

  1. 首先对所有数值进行离散化(排序去重),将原始值映射到[1, M]的整数范围
  2. 依次将每个元素插入到线段树中,每次插入产生一个新版本
  3. 版本i的根节点代表前i个元素构成的多重集合
  4. 查询区间[L, R]的第K大时,只需用版本R的根减去版本(L-1)的根,即可得到该区间的统计信息

算法步骤详解

第一步:离散化处理

由于数组元素的值域可能很大(如10^9),但元素个数N有限,我们先对所有值进行排序去重,将每个原始值映射到一个紧凑的排名。这使得线段树的范围变为[1, M],其中M是不重复元素的个数。

第二步:动态开点建线段树

主席树采用动态开点策略:初始时不创建任何节点,每次插入一个值时,从当前版本的根出发,沿途创建需要的新节点。每个节点维护两个信息:

  • left / right:左右子节点的引用
  • count:当前区间内插入的元素个数

第三步:区间第K大查询

设root[R]为处理完前R个元素后的版本根节点,root[L-1]为前L-1个元素的版本根节点。查询区间[L, R]第K大的过程如下:

  1. 从两个版本的根节点同时出发
  2. 计算左子树中元素个数之差:cnt = left[R].count - left[L-1].count
  3. 如果K <= cnt,说明第K大的元素在左子树,递归进入左子树
  4. 否则,第K大的元素在右子树,令K -= cnt,递归进入右子树
  5. 当到达叶子节点时,对应的离散化值即为答案

Java完整实现

import java.util.*;

/**
 * 可持久化线段树(主席树)实现
 * 支持:静态区间第K大查询、版本回溯
 */
public class PersistentSegmentTree {

    /**
     * 线段树节点类
     * 采用动态开点,每个节点记录左右子节点索引和区间内元素个数
     */
    static class Node {
        int left;   // 左子节点在nodes数组中的索引,0表示空
        int right;  // 右子节点在nodes数组中的索引,0表示空
        int count;  // 当前区间内的元素个数

        Node() {
            this.left = 0;
            this.right = 0;
            this.count = 0;
        }
    }

    // 节点池,动态开点复用数组
    private final Node[] nodes;
    private int nodeCnt; // 当前已分配的节点数量
    private final int maxNodes;

    // 每个版本的根节点索引
    private final int[] roots;

    // 离散化映射:原始值 -> 排名
    private final Map<Integer, Integer> compress;
    // 反离散化:排名 -> 原始值
    private final int[] values;
    private final int m; // 离散化后的值域大小

    /**
     * 构造函数:初始化主席树
     * @param arr 原始数组
     * @param maxVersions 最大版本数(即数组长度)
     */
    public PersistentSegmentTree(int[] arr, int maxVersions) {
        int n = arr.length;
        // 离散化处理
        int[] sorted = arr.clone();
        Arrays.sort(sorted);
        // 去重得到唯一值列表
        int uniqueCount = 0;
        for (int i = 0; i < sorted.length; i++) {
            if (i == 0 || sorted[i] != sorted[i - 1]) {
                uniqueCount++;
            }
        }
        this.values = new int[uniqueCount + 1]; // 1-based
        this.compress = new HashMap<>();
        int idx = 1;
        for (int i = 0; i < sorted.length; i++) {
            if (i == 0 || sorted[i] != sorted[i - 1]) {
                values[idx] = sorted[i];
                compress.put(sorted[i], idx);
                idx++;
            }
        }
        this.m = uniqueCount;

        // 预分配节点池:每次插入最多新增 log(m) 个节点
        // 总节点数上限约为 n * (log(m) + 2)
        this.maxNodes = maxVersions * 25;
        this.nodes = new Node[maxNodes + 10];
        for (int i = 0; i < nodes.length; i++) {
            nodes[i] = new Node();
        }
        this.nodeCnt = 0;

        // 初始化版本根数组
        this.roots = new int[maxVersions + 2];
        roots[0] = 0; // 版本0表示空树

        // 依次构建每个版本
        for (int i = 1; i <= n; i++) {
            int rank = compress.get(arr[i - 1]);
            roots[i] = update(roots[i - 1], 1, m, rank);
        }
    }

    /**
     * 单点更新:在旧版本基础上插入一个排名为rank的值,返回新版本根节点
     * @param prevRoot 旧版本根节点索引
     * @param l 当前节点代表的区间左端点
     * @param r 当前节点代表的区间右端点
     * @param rank 插入值的离散化排名
     * @return 新版本根节点索引
     */
    private int update(int prevRoot, int l, int r, int rank) {
        // 创建新节点,复制旧节点信息
        int cur = ++nodeCnt;
        nodes[cur].left = nodes[prevRoot].left;
        nodes[cur].right = nodes[prevRoot].right;
        nodes[cur].count = nodes[prevRoot].count + 1;

        if (l < r) {
            int mid = (l + r) >>> 1;
            if (rank <= mid) {
                // 进入左子树,递归更新左子节点
                nodes[cur].left = update(nodes[prevRoot].left, l, mid, rank);
            } else {
                // 进入右子树,递归更新右子节点
                nodes[cur].right = update(nodes[prevRoot].right, mid + 1, r, rank);
            }
        }
        return cur;
    }

    /**
     * 查询区间 [L, R] 中第 K 小的元素(1-based,K=1表示最小)
     * @param L 区间左端点(1-based,对应原数组下标)
     * @param R 区间右端点(1-based)
     * @param K 查询第K小
     * @return 第K小的原始值
     */
    public int queryKth(int L, int R, int K) {
        if (K < 1 || K > R - L + 1) {
            throw new IllegalArgumentException("K超出范围");
        }
        return values[query(roots[L - 1], roots[R], 1, m, K)];
    }

    /**
     * 递归查询第K小的离散化排名
     * @param leftRoot 前L-1个元素版本的根
     * @param rightRoot 前R个元素版本的根
     * @param l 当前区间左端点
     * @param r 当前区间右端点
     * @param K 查询第K小
     * @return 离散化排名
     */
    private int query(int leftRoot, int rightRoot, int l, int r, int K) {
        if (l == r) {
            return l;
        }
        int mid = (l + r) >>> 1;
        // 计算左子树中区间[L,R]的元素个数
        int leftCount = nodes[nodes[rightRoot].left].count - nodes[nodes[leftRoot].left].count;

        if (K <= leftCount) {
            // 第K小在左子树
            return query(nodes[leftRoot].left, nodes[rightRoot].left, l, mid, K);
        } else {
            // 第K小在右子树
            return query(nodes[leftRoot].right, nodes[rightRoot].right, mid + 1, r, K - leftCount);
        }
    }

    /**
     * 获取指定版本的根节点(用于版本回溯)
     * @param version 版本号(0表示空集,i表示前i个元素)
     * @return 该版本根节点索引
     */
    public int getVersionRoot(int version) {
        if (version < 0 || version >= roots.length) {
            throw new IllegalArgumentException("版本号超出范围");
        }
        return roots[version];
    }

    /**
     * 基于已有版本创建新版本(支持版本回溯后的继续操作)
     * @param baseVersion 基础版本号
     * @param value 要插入的原始值
     * @return 新版本号
     */
    public int forkVersion(int baseVersion, int value) {
        if (!compress.containsKey(value)) {
            throw new IllegalArgumentException("值不在原始数组中,无法插入");
        }
        // 注:实际应用中需要动态扩展roots数组
        int rank = compress.get(value);
        int newRoot = update(roots[baseVersion], 1, m, rank);
        // 此处简化处理,实际应维护一个可扩展的版本列表
        return newRoot;
    }

    /**
     * 获取某个版本中元素的总个数
     * @param version 版本号
     * @return 元素个数
     */
    public int getVersionSize(int version) {
        return nodes[roots[version]].count;
    }

    // ==================== 主程序与测试 ====================

    public static void main(String[] args) {
        // 测试数据
        int[] arr = {4, 1, 3, 2, 5, 3, 1, 4, 2, 5};
        int n = arr.length;

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

        // 构建主席树
        PersistentSegmentTree pst = new PersistentSegmentTree(arr, n);

        // 测试1:查询整个数组的第K小
        System.out.println("=== 测试1:查询整个数组 [1, 10] 的第K小 ===");
        for (int k = 1; k <= n; k++) {
            int result = pst.queryKth(1, n, k);
            System.out.printf("第 %d 小的元素是: %d%n", k, result);
        }

        System.out.println();

        // 测试2:查询子区间的第K小
        System.out.println("=== 测试2:查询子区间 [3, 7] 的第K小 ===");
        // 子数组为 [3, 2, 5, 3, 1],排序后为 [1, 2, 3, 3, 5]
        int[] subArray = Arrays.copyOfRange(arr, 2, 7);
        Arrays.sort(subArray);
        System.out.println("子数组 [3, 7] 排序后: " + Arrays.toString(subArray));
        for (int k = 1; k <= 5; k++) {
            int result = pst.queryKth(3, 7, k);
            System.out.printf("区间 [3, 7] 第 %d 小的元素是: %d%n", k, result);
        }

        System.out.println();

        // 测试3:查询单元素区间
        System.out.println("=== 测试3:查询单元素区间 [5, 5] ===");
        int result = pst.queryKth(5, 5, 1);
        System.out.printf("区间 [5, 5] 第 1 小的元素是: %d (期望: %d)%n", result, arr[4]);

        System.out.println();

        // 测试4:版本信息
        System.out.println("=== 测试4:各版本的元素个数 ===");
        for (int i = 0; i <= n; i++) {
            System.out.printf("版本 %d (前 %d 个元素): 元素个数 = %d%n",
                i, i, pst.getVersionSize(i));
        }

        System.out.println();

        // 测试5:区间第1小和第K大
        System.out.println("=== 测试5:区间 [2, 8] 的最小值、最大值、中位数 ===");
        int len = 8 - 2 + 1; // 7个元素
        int minVal = pst.queryKth(2, 8, 1);
        int maxVal = pst.queryKth(2, 8, len);
        int median = pst.queryKth(2, 8, (len + 1) / 2);
        System.out.printf("区间 [2, 8] 最小值: %d%n", minVal);
        System.out.printf("区间 [2, 8] 最大值: %d%n", maxVal);
        System.out.printf("区间 [2, 8] 中位数: %d%n", median);
    }
}

复杂度分析

操作 时间复杂度 空间复杂度
初始化(构建所有版本) O(N log M) O(N log M)
区间第K大查询 O(log M) O(1)
单点更新(创建新版本) O(log M) O(log M)

其中N为数组长度,M为离散化后的值域大小(M ≤ N)。

应用场景拓展

  1. 历史数据查询:在数据库或日志系统中,每个版本对应一个时间点,可以查询任意历史时刻的数据分布情况。

  2. 区间中位数查询:通过查询第(len+1)/2小的元素,可在O(log N)时间内获取区间中位数。

  3. 带修改的区间第K大:结合树状数组或线段树套主席树,可以支持单点修改后的区间第K大查询(动态主席树)。

  4. 版本控制系统:在文本编辑器或代码版本管理中,利用主席树的版本链特性,可以快速回溯到任意历史状态。

总结

主席树通过函数式持久化的思想,巧妙地利用动态开点和节点复用,在仅增加O(log N)空间开销的前提下保存了完整的历史版本信息。对于静态区间第K大这类经典问题,它提供了最优的在线查询方案。理解主席树的关键在于把握”路径共享”和”版本相减”这两个核心思想,这也是许多高级数据结构的设计精髓所在。