离散化Prefix

MrHe··8 min read

Part 1: 二维前缀和 (2D Prefix Sum)

1. 问题场景

想象一下,你有一个二维的矩阵(比如一张像素图),现在频繁地问你:“请告诉我,从左上角 (r1, c1) 到右下角 (r2, c2) 这个子矩阵里,所有元素的和是多少?”

暴力解法: 太直接了。来一个查询,我就写一个二重循环,从 r1 遍历到 r2,从 c1 遍历到 c2,把所有数加起来。

// 暴力解法
int query_brute_force(int[][] matrix, int r1, int c1, int r2, int c2) {
    int sum = 0;
    for (int i = r1; i <= r2; i++) {
        for (int j = c1; j <= c2; j++) {
            sum += matrix[i][j];
        }
    }
    return sum;
}

复杂度分析:如果矩阵是 N x M,一个查询的复杂度最坏是 O(N M)。Q 次查询就是 O(Q N * M),这在数据量大的时候是不可接受的。

思路演进: 一维的问题,我们用前缀和把 O(N) 的区间求和优化到了 O(1)。二维的能不能也这么干?当然可以!

2. 二维前缀和的原理

我们定义一个二维前缀和数组 sum[][]sum[i][j] 存的是什么呢?它存的是从左上角 (0, 0) 到当前位置 (i, j) 这个矩形区域内所有元素的和。

怎么计算这个 sum[i][j] 呢?这里要用到一个非常经典的思想——容斥原理

看图说话: 为了计算 sum[i][j](整个蓝色+绿色的区域),我们可以:

  1. 拿它上面那个矩形的和 sum[i-1][j] (区域1+区域2)。

  2. 加上它左边那个矩形的和 sum[i][j-1] (区域1+区域3)。

  3. 这时你会发现,左上角的那个小矩形 sum[i-1][j-1] (区域1) 被加了两遍!所以要减掉一次。

  4. 最后,别忘了加上当前位置 matrix[i][j] (区域4) 本身的值。

于是,我们得到了二维前缀和的递推公式sum[i][j] = sum[i-1][j] + sum[i][j-1] - sum[i-1][j-1] + matrix[i][j]

如何查询? 有了这个 sum 数组,怎么求 (r1, c1)(r2, c2) 的和呢?还是用容斥原理!

  1. 先取右下角为 (r2, c2) 的大矩形 sum[r2][c2]

  2. 减掉它上面多余的部分,也就是 sum[r1-1][c2]

  3. 减掉它左边多余的部分,也就是 sum[r2][c1-1]

  4. 你会发现,左上角 sum[r1-1][c1-1] 这块区域,被减了两次,所以要加回来一次。

于是,我们得到了查询公式query(r1, c1, r2, c2) = sum[r2][c2] - sum[r1-1][c2] - sum[r2][c1-1] + sum[r1-1][c1-1]

这样,预处理的复杂度是 O(N * M),但每次查询都变成了 O(1)!

3. OI/ACM 风格代码实现 (LeetCode 304)

代码技巧:为了避免处理 i-1j-1 时烦人的边界判断,我们通常把前缀和数组 sum 的尺寸定义为 (N+1) x (M+1),让它的下标从 1 开始。这样 sum[0][j]sum[i][0] 天然就是 0,公式就统一了。

class NumMatrix {
​
    private int[][] sum; // 1-based indexing prefix sum array
​
    public NumMatrix(int[][] matrix) {
        if (matrix == null || matrix.length == 0 || matrix[0].length == 0) {
            return;
        }
        int rows = matrix.length;
        int cols = matrix[0].length;

        // 使用 (rows+1) x (cols+1) 的尺寸,避免边界判断
        sum = new int[rows + 1][cols + 1];
​
        // 构建二维前缀和数组
        for (int i = 1; i <= rows; i++) {
            for (int j = 1; j <= cols; j++) {
                // sum[i][j] 对应 matrix[i-1][j-1]
                sum[i][j] = sum[i - 1][j] + sum[i][j - 1] - sum[i - 1][j - 1] + matrix[i - 1][j - 1];
            }
        }
    }
​
    public int sumRegion(int row1, int col1, int row2, int col2) {
        // 传入的坐标是 0-based,我们的 sum 数组是 1-based
        // 所以查询时坐标要 +1
        int r1 = row1 + 1;
        int c1 = col1 + 1;
        int r2 = row2 + 1;
        int c2 = col2 + 1;

        // 应用查询公式
        return sum[r2][c2] - sum[r1 - 1][c2] - sum[r2][c1 - 1] + sum[r1 - 1][c1 - 1];
    }
}

Part 2: 二维差分 (2D Difference Array)

1. 问题场景

与前缀和相反,现在我们要做的是区域更新。 指令:“请把矩阵中,从 (r1, c1)(r2, c2) 这个子矩阵里,所有元素都加上一个值 val”。 做完所有 M 次更新后,请告诉我最终的矩阵是什么样子。

暴力解法:来一次更新,我就二重循环遍历 (r1, c1)(r2, c2),挨个加上 val。复杂度 O(M * N²)。

思路演进: 一维差分能把区间更新变成 O(1) 的边界点修改。二维差分也一样! 我们定义一个二维差分数组 diff[][]。它和原矩阵 matrix 的关系是:matrixdiff 的二维前缀和。

那么,对 matrix 的一个子矩阵 (r1, c1) -> (r2, c2) 全体 +val,会对 diff 产生什么影响? 我们逆向思考前缀和的查询公式。我们希望在 (r1, c1) 这个点开始,之后的所有点(通过前缀和累加)都获得一个 +val 的效果。

  1. 我们在 diff[r1][c1] 加上 val。这会导致 (r1, c1) 右下方的所有点在前缀和计算后都增加了 val

  2. 但是,这个影响超出了我们想要的 c2 列。为了消除 c2 列之后的影响,我们需要在 diff[r1][c2+1] 减去 val

  3. 同理,这个影响也超出了我们想要的 r2 行。为了消除 r2 行之后的影响,我们需要在 diff[r2+1][c1] 减去 val

  4. 这样一来,对于 (r2+1, c2+1) 右下方的区域,我们既减了一次(来自2),又减了一次(来自3),多减了一次!所以要把它加回来,在 diff[r2+1][c2+1] 加上 val

结论(核心): 对原矩阵 (r1, c1) -> (r2, c2) 的区间执行 +val 操作,等价于对它的差分数组 diff 执行四个单点操作

  1. diff[r1][c1] += val

  2. diff[r1][c2+1] -= val

  3. diff[r2+1][c1] -= val

  4. diff[r2+1][c2+1] += val

O(N²) 的区域更新,又变成了 O(1) 的四次定点修改!

2. 代码实现与相关题目

场景题:比如 "地毯铺设" 问题 (洛谷 P3397)。在一个 n x n 的场地上,铺了 m 块矩形地毯,求最后每个点被多少层地毯覆盖。 这不就是每铺一块地毯,就对一个矩形区域 +1 吗?典型的二维差分。

public class TwoDimDifference {
​
    private int[][] diff;
    private int rows, cols;
​
    public TwoDimDifference(int rows, int cols) {
        this.rows = rows;
        this.cols = cols;
        // 差分数组要开大一点,防止 c2+1 或 r2+1 越界
        this.diff = new int[rows + 2][cols + 2];
    }
​
    /**
     * 对 (r1, c1) -> (r2, c2) 区域增加 val
     * @param r1, c1, r2, c2  (0-based index)
     */
    public void update(int r1, int c1, int r2, int c2, int val) {
        // 转换为 1-based indexing for diff array
        int x1 = r1 + 1, y1 = c1 + 1;
        int x2 = r2 + 1, y2 = c2 + 1;

        diff[x1][y1] += val;
        diff[x1][y2 + 1] -= val;
        diff[x2 + 1][y1] -= val;
        diff[x2 + 1][y2 + 1] += val;
    }
​
    /**
     * 从差分数组还原出最终的矩阵
     * @return 最终结果矩阵
     */
    public int[][] recover() {
        int[][] result = new int[rows][cols];
        // 临时前缀和数组
        int[][] prefixSum = new int[rows + 2][cols + 2];
        for (int i = 1; i <= rows; i++) {
            for (int j = 1; j <= cols; j++) {
                prefixSum[i][j] = prefixSum[i - 1][j] + prefixSum[i][j - 1] - prefixSum[i - 1][j - 1] + diff[i][j];
                result[i - 1][j - 1] = prefixSum[i][j];
            }
        }
        return result;
    }
}

Part 3: 离散化 (Discretization)

1. 问题场景

想象一个更变态的问题:一条无限长的公路(坐标范围 110^9)。现在有 N (N <= 10^5) 个施工队,第 i 个队伍要对 [L_i, R_i] 这段路进行施工(比如,把路的高度都增加1)。问所有施工队都完工后,公路上哪个点的施工层数最多?

分析: 这不就是我们前面讲的一维差分吗?diff[L_i]++, diff[R_i+1]--问题是:坐标范围是 10^9!你敢开一个大小为 10^9 的差分数组吗?内存直接爆炸。

思路演进: 我们真的关心 110^9 之间的每一个点吗?不关心! 我们只关心那些施工区间的端点,比如 L_iR_i+1。因为在两个相邻的端点之间,施工的层数是不变的。

比如说,有施工 [100, 200][150, 800]。 有意义的坐标点就是 100, 150, 200+1=201, 800+1=801。 我们真正关心的区间是:[100, 149], [150, 200], [201, 800], [801, ...]。 这些稀疏、巨大的坐标,我们可以把它们“压缩”到连续的、小的整数索引上,比如 0, 1, 2, 3 ...。这个过程,就叫离散化

2. 离散化的步骤与实现

用一个具体题目来说明:酒店预订问题 AcWing 802. 区间和 (这是一个经典的离散化+前缀和/差分模板题) 问题:有 n 次操作,每次操作是给 x 位置加上 c。有 m 次查询,每次查询 [l, r] 区间的和。x, l, r 范围很大。

离散化步骤

  1. 收集所有坐标:把所有操作和查询中涉及到的坐标 x, l, r 全部收集到一个列表里。

  2. 排序并去重:对这个列表进行排序和去重,得到一个有序的、无重复的坐标轴。

  3. 建立映射:创建一个 HashMap,建立从“原始大坐标”到“离散化后的小索引”的映射关系。map.put(原始坐标, 0), map.put(下一个原始坐标, 1) ...

  4. 在小索引上操作:现在,我们可以在一个大小等于去重后坐标数量的小数组上,执行我们的算法(比如前缀和)。

    • add(x, c) 操作,变成在 a[map.get(x)] += c

    • query(l, r) 操作,需要找到 lr 在离散化坐标轴上的位置,然后求和。

代码实现 (以离散化+前缀和为例)

import java.util.*;
​
public class DiscretizationExample {
​
    // 假设这是输入
    static class AddOp { int x, c; AddOp(int x, int c){this.x=x;this.c=c;} }
    static class QueryOp { int l, r; QueryOp(int l, int r){this.l=l;this.r=r;} }
​
    public static void solve(List<AddOp> adds, List<QueryOp> queries) {
        // 1. 收集所有坐标
        List<Integer> allCoords = new ArrayList<>();
        for (AddOp op : adds) {
            allCoords.add(op.x);
        }
        for (QueryOp op : queries) {
            allCoords.add(op.l);
            allCoords.add(op.r);
        }
​
        // 2. 排序并去重
        Collections.sort(allCoords);
        List<Integer> uniqueCoords = new ArrayList<>();
        if (!allCoords.isEmpty()) {
            uniqueCoords.add(allCoords.get(0));
            for (int i = 1; i < allCoords.size(); i++) {
                if (!allCoords.get(i).equals(allCoords.get(i - 1))) {
                    uniqueCoords.add(allCoords.get(i));
                }
            }
        }
​
        // 3. 建立映射 (也可以用二分查找代替map,在竞赛中更快)
        Map<Integer, Integer> map = new HashMap<>();
        for (int i = 0; i < uniqueCoords.size(); i++) {
            map.put(uniqueCoords.get(i), i);
        }
​
        // 4. 在小索引上操作
        // 创建值数组和前缀和数组
        int size = uniqueCoords.size();
        long[] a = new long[size];
        for (AddOp op : adds) {
            int index = map.get(op.x);
            a[index] += op.c;
        }
​
        // 构建前缀和数组
        long[] prefixSum = new long[size + 1];
        for (int i = 0; i < size; i++) {
            prefixSum[i + 1] = prefixSum[i] + a[i];
        }
​
        // 处理查询
        for (QueryOp op : queries) {
            // 注意:要找到离散化坐标轴上 >= l 的第一个位置,和 <= r 的最后一个位置
            // 这里为了简化,我们假设查询的 l, r 都在 uniqueCoords 中
            // 严谨的实现需要二分查找 lower_bound 和 upper_bound
            Integer l_idx = map.get(op.l);
            Integer r_idx = map.get(op.r);
​
            if (l_idx == null || r_idx == null) {
                // 如果查询的端点不存在于操作点中,需要更复杂的处理(通常是二分查找)
                // 这里简化处理
                System.out.println("Querying range " + op.l + " to " + op.r + " -> Result depends on mapping logic");
                continue;
            }
​
            long result = prefixSum[r_idx + 1] - prefixSum[l_idx];
            System.out.println("Sum of [" + op.l + ", " + op.r + "] is " + result);
        }
    }
}

重要提醒:离线 vs. 在线

  • 前缀和、差分、离散化 都是 离线(Offline) 算法。这意味着,你必须在开始处理之前,拿到所有的操作和查询。你不能处理一个操作,马上回答一个查询,再处理下一个操作... 因为你需要预处理整个数据集(比如构建前缀和数组,或者收集所有坐标进行离散化)。

  • 树状数组(BIT)、线段树在线(Online) 算法。它们构建的结构支持实时的“单点修改”和“区间查询”。你可以在任意时刻进行一次修改,然后立刻进行一次查询并得到正确的结果,无需知道未来的操作。二维的问题,就对应二维树状数组二维线段树