每日算法 — 使用java实现莫队算法:区间查询的分块排序与双指针离线优化

引言

在处理大规模区间查询问题时,我们经常会遇到一个困境:单个查询可以用简单的数据结构在 O(n) 或 O(log n) 内完成,但当查询数量达到 10^5 级别时,总时间复杂度将变得不可接受。莫队算法(Mo’s Algorithm)正是为解决这类离线区间查询问题而生的一种 elegant 的分块优化技巧。它由中国信息学竞赛选手莫涛在 2010 年提出,核心思想是通过对查询进行特殊的分块排序,使得相邻查询之间的区间移动代价最小化,从而将总复杂度从 O(nm) 优化到 O((n+m)√n)。本文将用 Java 实现莫队算法的完整框架,并以「区间最大频率」问题为例,展示其工程实践细节。

问题定义:区间最大频率

给定一个长度为 n 的整数序列 a[1..n],以及 m 个查询。每个查询给出一个闭区间 [l, r],要求返回该区间内出现次数最多的数值的出现次数。

例如序列 [1, 2, 1, 3, 2, 1],查询区间 [1, 6] 时,数字 1 出现了 3 次,是频率最高的,因此答案为 3。

暴力解法的瓶颈

最直观的解法是:对于每个查询,遍历区间 [l, r] 统计每个数字的出现次数,然后取最大值。单次查询复杂度为 O(n),m 次查询的总复杂度为 O(nm)。当 n 和 m 均为 10^5 时,运算量将达到 10^10,在 Java 中难以在合理时间内完成。

我们需要一种能够「批量处理」查询、并利用相邻查询之间关联性的方法。

分块思想:化整为零

莫队算法的第一个关键步骤是「分块」。我们将数组按照下标划分为大小为 B 的若干块(通常取 B = ⌈√n⌉)。第 i 个块包含下标范围 [(i-1)×B+1, i×B] 的元素。

分块的目的不是直接处理查询,而是为后续的「查询排序」提供依据。我们可以将每个查询 [l, r] 按照其左端点 l 所在的块编号进行分组,同一块内的查询再按右端点 r 排序。

莫队排序策略:让区间”少走冤枉路”

假设我们已经按上述规则对查询进行了排序。现在我们用两个指针 curL 和 curR 维护当前处理的区间,初始时 curL = 1, curR = 0(空区间)。

对于排序后的每一个查询 [L, R],我们需要将 curL 移动到 L,将 curR 移动到 R。移动过程中,每向左/右扩展或收缩一格,就对「进入区间的元素」执行 add 操作,对「离开区间的元素」执行 remove 操作。

莫队排序的精妙之处在于:同一块内的查询,左端点 l 在同一区间内波动,而右端点 r 是单调递增的。这保证了指针的总移动距离被控制在 O(n√n) 级别。

更具体地说,对于左端点在同一块中的查询,curR 只会向右移动(或重置到块的起始位置后向右),因此右指针的总移动次数为 O(n × 块数) = O(n√n)。左指针在同一块内的移动距离不超过块大小 B = O(√n),共有 m 个查询,因此左指针总移动次数为 O(m√n)。综合起来,总复杂度为 O((n+m)√n)。

双指针维护:O(1) 增删与最大频率追踪

莫队算法的第二个关键步骤是设计 O(1) 的 add 和 remove 操作。对于「区间最大频率」问题,我们维护以下数据结构:

  • cnt[v]:数值 v 在当前区间中的出现次数。
  • freq[c]:当前区间中「出现次数恰好为 c」的不同数值有多少个。
  • maxFreq:当前区间中的最大出现次数。

添加元素 x:
1. 获取 x 原来的出现次数 old = cnt[x]
2. cnt[x]++
3. freq[old]--freq[old+1]++
4. 如果 old + 1 > maxFreq,更新 maxFreq = old + 1

删除元素 x:
1. 获取 x 原来的出现次数 old = cnt[x]
2. cnt[x]--
3. freq[old]--freq[old-1]++
4. 如果 freq[maxFreq] == 0,说明当前已经没有数值的出现次数达到 maxFreq,因此 maxFreq--

通过 freq 数组的辅助,我们可以在 O(1) 时间内完成最大频率的更新与维护。

完整 Java 实现

以下是一个完整的、可直接运行的 Java 程序。程序包含快读优化、离散化、莫队排序、双指针维护以及结果输出。

import java.io.*;
import java.util.*;

/**
 * 莫队算法实现:区间最大频率查询
 * 
 * 核心思想:
 * 1. 将所有查询按左端点分块排序,使得相邻查询的区间移动距离最小化。
 * 2. 使用双指针维护当前区间,配合 O(1) 的增删操作统计最大频率。
 */
public class MoAlgorithm {

    // 查询结构体,存储查询的原始编号、左右端点
    static class Query {
        int id;      // 原始查询编号,用于按原顺序输出答案
        int l, r;    // 查询区间 [l, r],采用 1-based 索引

        Query(int id, int l, int r) {
            this.id = id;
            this.l = l;
            this.r = r;
        }
    }

    public static void main(String[] args) throws Exception {
        // 使用 BufferedReader 加速输入读取
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        StringTokenizer st;

        // ========== 输入读取 ==========
        st = new StringTokenizer(br.readLine());
        int n = Integer.parseInt(st.nextToken());  // 序列长度
        int m = Integer.parseInt(st.nextToken());  // 查询数量

        int[] a = new int[n + 1];  // 原始数组,1-based
        st = new StringTokenizer(br.readLine());
        for (int i = 1; i <= n; i++) {
            a[i] = Integer.parseInt(st.nextToken());
        }

        // ========== 离散化 ==========
        // 将可能很大的原始数值映射到紧凑的 [1, K] 范围,降低数组开销
        int[] sorted = Arrays.copyOfRange(a, 1, n + 1);
        Arrays.sort(sorted);
        int uniqueCount = 1;
        for (int i = 1; i < sorted.length; i++) {
            if (sorted[i] != sorted[i - 1]) {
                sorted[uniqueCount++] = sorted[i];
            }
        }
        int[] uniqueValues = Arrays.copyOf(sorted, uniqueCount);

        for (int i = 1; i <= n; i++) {
            a[i] = Arrays.binarySearch(uniqueValues, a[i]) + 1;  // 映射为 1-based 编号
        }

        // ========== 读取查询 ==========
        Query[] queries = new Query[m];
        for (int i = 0; i < m; i++) {
            st = new StringTokenizer(br.readLine());
            int l = Integer.parseInt(st.nextToken());
            int r = Integer.parseInt(st.nextToken());
            queries[i] = new Query(i, l, r);
        }

        // ========== 莫队排序 ==========
        int blockSize = (int) Math.sqrt(n);  // 分块大小,通常取 sqrt(n)
        if (blockSize == 0) blockSize = 1;

        final int bs = blockSize;
        Arrays.sort(queries, (q1, q2) -> {
            int block1 = (q1.l - 1) / bs;
            int block2 = (q2.l - 1) / bs;
            if (block1 != block2) {
                return block1 - block2;  // 左端点所在块编号小的排前面
            }
            // 同一块内,右端点按升序排列(奇偶优化可进一步减少指针跳跃)
            return q1.r - q2.r;
        });

        // ========== 初始化频率统计结构 ==========
        // cnt[v]:离散化后数值 v 在当前区间中的出现次数
        // freq[c]:当前区间中出现次数恰好为 c 的数值有多少个
        int[] cnt = new int[uniqueCount + 2];
        int[] freq = new int[n + 2];  // 最大频率不会超过区间长度 n
        int maxFreq = 0;  // 当前区间的最大出现次数

        int curL = 1, curR = 0;  // 当前维护的空区间 [1, 0]
        int[] answers = new int[m];  // 存储每个查询的答案

        // ========== 逐个处理排序后的查询 ==========
        for (Query q : queries) {
            int targetL = q.l;
            int targetR = q.r;

            // 扩展右边界:curR -> targetR
            while (curR < targetR) {
                curR++;
                int val = a[curR];
                int oldCount = cnt[val];
                cnt[val] = oldCount + 1;
                freq[oldCount]--;
                freq[oldCount + 1]++;
                if (oldCount + 1 > maxFreq) {
                    maxFreq = oldCount + 1;
                }
            }

            // 收缩右边界:curR -> targetR
            while (curR > targetR) {
                int val = a[curR];
                int oldCount = cnt[val];
                cnt[val] = oldCount - 1;
                freq[oldCount]--;
                freq[oldCount - 1]++;
                if (freq[maxFreq] == 0) {
                    maxFreq--;
                }
                curR--;
            }

            // 扩展左边界:curL -> targetL(向左移动,即减小 curL)
            while (curL > targetL) {
                curL--;
                int val = a[curL];
                int oldCount = cnt[val];
                cnt[val] = oldCount + 1;
                freq[oldCount]--;
                freq[oldCount + 1]++;
                if (oldCount + 1 > maxFreq) {
                    maxFreq = oldCount + 1;
                }
            }

            // 收缩左边界:curL -> targetL(向右移动,即增大 curL)
            while (curL < targetL) {
                int val = a[curL];
                int oldCount = cnt[val];
                cnt[val] = oldCount - 1;
                freq[oldCount]--;
                freq[oldCount - 1]++;
                if (freq[maxFreq] == 0) {
                    maxFreq--;
                }
                curL++;
            }

            // 记录当前查询的答案
            answers[q.id] = maxFreq;
        }

        // ========== 按原始查询顺序输出结果 ==========
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < m; i++) {
            sb.append(answers[i]).append('\n');
        }
        System.out.print(sb.toString());
    }
}

输入输出示例

输入:

6 3
1 2 1 3 2 1
1 6
2 4
3 5

输出:

3
1
1

解释:
– 查询 [1, 6]:数字 1 出现 3 次,答案为 3。
– 查询 [2, 4]:数字 2、1、3 各出现 1 次,答案为 1。
– 查询 [3, 5]:数字 1、3、2 各出现 1 次,答案为 1。

复杂度分析

时间复杂度:
– 排序阶段:对 m 个查询进行排序,复杂度为 O(m log m)。
– 指针移动阶段:左指针在每个块内的移动距离不超过块大小 B = O(√n),共 m 个查询,总移动次数为 O(m√n)。右指针在每个块内单调移动,最多移动 O(n) 次,共有 O(√n) 个块,因此右指针总移动次数为 O(n√n)。
– 综合:总时间复杂度为 O(n√n + m√n + m log m),在 n = m = 10^5 时约为 3×10^7 次操作,在 Java 中可以轻松通过。

空间复杂度:
– 离散化数组、cnt 数组、freq 数组以及查询数组,总空间复杂度为 O(n + m)

进阶扩展

1. 希尔伯特序优化(Hilbert Order)

普通的分块排序在某些数据分布下可能导致右指针频繁”回跳”。希尔伯特序是一种空间填充曲线排序方式,它可以使得相邻查询在二维平面 (l, r) 上的曼哈顿距离更小,从而进一步减少指针移动次数。在实际竞赛中,使用希尔伯特序的莫队算法通常比普通分块排序快 30% 到 50%。

2. 带修改的莫队算法

当序列不仅存在区间查询,还存在「单点修改」操作时,可以将莫队算法扩展为三维版本。每个查询或修改操作被打上时间戳,排序规则变为 (l_block, r_block, time)。指针除了 curL 和 curR 之外,还需要维护一个「时间指针」,在向前或向后追溯时执行或撤销修改操作。时间复杂度为 O(n^(5/3))。

3. 树上莫队

莫队算法不仅适用于线性数组,也可以通过欧拉序(Euler Tour)将树结构转化为线性序列,从而在树上处理路径查询问题。将树节点的入序和出序展开后,一条树路径对应欧拉序中的一个或两个区间,配合莫队排序即可高效处理「树上路径颜色统计」等问题。

总结

莫队算法的魅力在于它「不修改数据结构本身,而是修改查询的处理顺序」。这种离线优化的思想在很多场景下都有借鉴意义。本文实现的完整 Java 程序展示了莫队算法的工程细节,包括离散化、分块排序、双指针增删以及最大频率的 O(1) 维护。读者可以在此基础上,将核心框架迁移到其他区间统计问题中,如区间不同元素个数、区间众数、区间频率前缀和等,体会这一算法的优雅与实用。