块状数组的基本用法:把数组变成灵活的积木

生活中处处可见分块思想的影子。走进图书馆,书籍按照学科分类,读者只需先定位大类别,再在小范围内查找,就能快速找到目标书籍;小区的快递柜更是将大量包裹按照格口大小和编号分块存放,快递员按区域投放,收件人按编号取件,极大提升了物流效率。这种 "先整体划分,再局部处理" 的思路,在算法世界中演变成了一种高效的数据结构 ------ 块状数组。

在处理数组问题时,我们也可以使用分块思想构建块状数组。

将一个长度为 \(n\) 的线性数组,按照一定规则分割成若干个连续的子数组(我们称之为 "块")。每个块的大小通常设定在 \(\sqrt n\) 左右(取得块大小和块数量的平衡)。例如,对于长度为 100 的数组,我们可以将其划分为 10 个块,每个块包含 10 个元素;若数组长度为 1000,则可分成 32 个块(因为 √1000≈31.62),前 31 个块各含 32 个元素,最后一个不完整块包含剩余的 28 个元素。

复制代码
vector<vector<int>> init_block_array(const vector<int>& arr) {
    int n = arr.size();
    if (n == 0) return {};
    int block_size = ceil(sqrt(n)); // 向上取尽可能避免最后的大小极小的块
    int block_num = (n + block_size - 1) / block_size; // 计算总块数
    vector<vector<int>> blocks(block_num);

    for (int i = 0; i < n; ++i) {
        int block_idx = i / block_size; // 计算元素所属块编号
        blocks[block_idx].push_back(arr[i]);
    }
    return blocks;
}

这样一来,原本线性排列的数组就被 "拆解" 成了若干个可独立操作的 "积木块",为后续的区间操作奠定了基础。

从内存结构上看,块状数组相当于在原数组的基础上增加了一层 "块级索引",这层索引就像图书馆的区域导视图,让我们能批量快速的编辑这些 "区域"。

块状数组的基本用法

块状数组的真正价值,在于它能像搭积木一样灵活处理数组的区间操作。当我们需要对一个连续区间进行更新或查询时,不必逐个遍历元素,而是可以利用 "块" 的特性批量处理,大幅提升效率。仅仅在区间边界处理单个数。

区间加 - 区间和问题

这个经典问题可以用各种数据结构话花式解决,分块也可以。核心是为每个块维护两个关键信息:加法标记块内元素和。加法标记记录了需要对整个块执行的累加操作,类似于给一整箱积木贴上 "每个积木加 3" 的标签;块内元素和则预先存储了当前块所有元素的总和,避免每次查询时重新计算。

以区间加操作为例子,我们将目标区间分为三部分处理:

  • 左侧边缘块:区间起始位置所在的不完整块,需要逐个遍历元素执行加法操作,并同步更新块内元素和。

  • 中间完整块:完全包含在目标区间内的块,直接更新其加法标记(如将标记值 + 5),同时通过 "块大小 × 增量" 快速更新块内元素和,而不处理中间块之中的元素。

  • 右侧边缘块:区间结束位置所在的不完整块,处理方式同左侧边缘块。

最佳块大小的数学证明

这种处理方式的巧妙之处在于,完整块的操作只需 \(O (1)\) 时间,而边缘块最多涉及两个块。

为什么块大小选择 \(\sqrt n\) 能达到最优效率?假设块大小为 \(s\),总块数则为 \(n/s\)。对于任意区间操作,最多需要处理 2 个边缘块(共 \(O (s)\) 时间)和 \(O (n/s)\) 个完整块(共 \(O (n/s)\) 时间)。总时间复杂度为 \(O (s + n/s)\),根据均值不等式, \(s = \sqrt n\) 时,复杂度达到最优的 \(O (\sqrt n)\)。

一般来讲,取根号都是最优的,达到了块大小和块数量的平衡。某些特定情况可能需要取其他的块大小。

复制代码
struct BlockArray {
    vector<int> arr;         // 原始数组
    vector<long long> sum;   // 每个块的元素和
    vector<int> add;         // 每个块的加法标记
    int block_size;          // 块大小
    int block_num;           // 块数量

    BlockArray(const vector<int>& data) {
        // ...

        // 初始化块内和
        for (int i = 0; i < n; ++i) {
            int bid = i / block_size;
            sum[bid] += arr[i];
        }
    }

    // 区间[l, r]加val
    void range_add(int l, int r, int val) {
        int left_bid = l / block_size;
        int right_bid = r / block_size;

        // 只有一块
        if (left_bid == right_bid) {
            for (int i = l; i <= r; ++i) {
                arr[i] += val;
            }
            sum[left_bid] += val * (r - l + 1); // 记得更新总和
            return;
        }

        // 左侧完整部分
        for (int i = l; i < (left_bid + 1) * block_size; ++i) {
            arr[i] += val;
        }
        sum[left_bid] += val * (block_size - l % block_size);

        // 中间完整块
        for (int bid = left_bid + 1; bid < right_bid; ++bid) {
            add[bid] += val;
            sum[bid] += val * block_size;
        }

        // 右侧边缘块
        for (int i = right_bid * block_size; i <= r; ++i) {
            arr[i] += val;
        }
        sum[right_bid] += val * (r % block_size + 1);
    }

    // 查询区间[l, r]的和
    long long range_sum(int l, int r) {
        int left_bid = l / block_size;
        int right_bid = r / block_size;
        long long res = 0;

        if (left_bid == right_bid) {
            for (int i = l; i <= r; ++i) {
                res += arr[i] + add[left_bid];
            }
            return res;
        }

        // 左侧边缘
        for (int i = l; i < (left_bid + 1) * block_size; ++i) {
            res += arr[i] + add[left_bid];
        }

        // 中间完整块
        for (int bid = left_bid + 1; bid < right_bid; ++bid) {
            res += sum[bid];
        }

        // 右侧边缘
        for (int i = right_bid * block_size; i <= r; ++i) {
            res += arr[i] + add[right_bid];
        }

        return res;
    }
};

区间加乘 - 区间和问题

当问题升级为同时包含加法和乘法的区间操作时,我们需要再引入乘法标记 mul(初始值为 1) ,并严格处理两种标记的优先级。由于乘法对加法有分配律(a*val1 + val2),正确的处理顺序应为先乘后加

  • 当对块执行乘法操作(乘 m)时:

    • mul = mul * m
    • add = add * m
    • sum = sum * m
  • 当对块执行加法操作(加 a)时:

    • add = add + a
    • sum = sum + a * 块大小

在处理边缘块的单个元素时,需要先应用乘法标记,再应用加法标记:arr[i] = arr[i] * mul[bid] + add[bid]

仍然是根号复杂度,代码就不再演示了。

数据结构 支持操作 构建复杂度 单次查询 单次更新 实现难度 适用场景 & 优缺点
块状数组(√分块) 区间求和、区间加、区间众数等 \(O(n)\) 或 \(O(n\sqrt n)\)(众数) \(O(\sqrt n)\) 或 \(O(\sqrt n\log n)\)(众数) \(O(1)\)--\(O(\sqrt n)\)(区间加需延迟) ★★☆☆☆ + 实现简单、常数低 -- 查询/更新均摊 \(O(\sqrt n)\),规模更大时不够快
线段树 区间求和/最值/GCD/加标记等 \(O(n)\) \(O(\log n)\) \(O(\log n)\) ★★★☆☆ + 支持几乎所有区间操作、可加懒标记 -- 实现稍复杂,常数较大
树状数组(BIT) 前缀和、区间加 & 单点查、区间和 \(O(n)\) \(O(\log n)\) \(O(\log n)\) ★☆☆☆☆ + 结构紧凑、易实现 -- 只支持前缀/区间和,难以做区间最值、区间众数等