分块,优雅的暴力

Upd 2023.8.1: 补充了更一般的分块方法和查询大于等于 \(k\) 的数的个数的方法

来看一个例题

  • 现在给出一个长度为 \(N\) 序列 A,定义两个操作如下:

  • 1 l r v,表示从 \(A_l \sim A_r\) 每个数都加上 \(v\)

  • 2 l r,对 \(A_l \sim A_r\) 求和。

传统的线段树可以很优秀地实现这两个操作,但是需要打 \(lazytag\)

同时因为线段树(非动态开点)的空间复杂度为 \(O(4N)\),在空间限制或数据范围较大的题目中容易被卡,于是考虑用时间换空间,开发空间复杂度更优的算法。

分块就是这样的算法。

分块算法将整个序列分为若干个长度不超过 \(\sqrt n\) 的区间 \(^{(1)}\),并对于每个区间维护两个标记增量标记 \(add\) 和区间和 \(sum\)。其中增量标记 \(add\) 用来记录对于整个块的加法对答案产生的贡献,区间和 \(sum\) 用来记录整个区间内增量标记 \(add\) 之外的答案。

所以区间和查询就较为简单了。对于 \([l,r]\) 区间内的每个块,直接算 \(sum+add*(R_i-L_i+1)\) 计入答案即可,其中 \(L_i\)\(R_i\) 分别表示第 \(i\) 个区间的左右端点。而对于区间两端不被整个分块覆盖的位置直接暴力统计即可。记 \(l,r\) 所在区间编号分别为 \(p,q\),则两端的查询答案即为

\[\sum_{i=l}^{R_p}a_i+add_p\times(R_p-l+1)+\sum_{i=L_q}^{r}a_i+add_q\times(r-L_q+1) \]

于是将中间部分的答案和两端的答案直接合并就可以得到查询操作的答案了。

PS:如果 \(l,r\) 属于同一区间,则直接暴力统计即可。

inline int query(int l, int r) {
    int res = 0, p = pos[l], q = pos[r];
    if(p == q) {
        for(int i = l; i <= r; i++) {
            res += a[i] + add[i];
        }
        return res;
    }
    else {
        for(int i = p + 1; i < q; i++) {
            res += sum[i] + add[i] * (R[i] - L[i] + 1);
        }
        for(int i = l; i <= R[p]; i++) {
            res += a[i];
        }
        res += add[p] * (R[p] - l + 1);
        for(int i = L[q]; i <= r; i++) {
            res += a[i];
        }
        res += add[q] * (r - L[q] + 1);
        return res;
    }
}

如此暴力的做法复杂度是如何保证的呢?

观察到由于分块时每个区间长度均不超过 \(\sqrt N\),于是中间统计部分最多不超过 \(\lceil \sqrt N \rceil\) 个区间,而两端暴力统计的次数总的不超过 \(2\sqrt N\),所以整个查询的复杂度可以近似视作 \(O(\sqrt N)\)

对于修改操作可以用相同的思想,每个完整区间直接加在增量标记 \(add\) 上,不完整区间则暴力往原序列 \(A_i\) 和区间和 \(sum\) 上加。总复杂度用相同的方法分析可以得到也为 \(O(\sqrt N)\)

inline void change(int l, int r, int v) {
    int p = pos[l], q = pos[r];
    if(p == q) {
        for(int i = l; i <= r; i++) {
            a[i] += v;
        }
        sum[p] += (r - l + 1) * v;
    }
    else {
        for(int i = p + 1; i < q; i++) {
            add[i] += v;
        }
        for(int i = l; i <= R[p]; i++) {
            a[i] += v;
        }
        sum[p] += (R[p] - l + 1) * v;
        for(int i = L[q]; i <= r; i++) {
            a[i] += v;
        }
        sum[q] += (r - L[q] + 1) * v;
    }
}

算法的预处理部分要处理的为每个区间的左右端点 \(L_i,R_i\),每个位置 \(i\) 所对应的区间 \(pos_i\),和原序列的区间和 \(sum_j\),总复杂度为 \(O(N\sqrt N)\)

对于最后一个长度不满 \(\sqrt N\) 的区间直接处理即可。

    t = sqrt(n);
    for(int i = 1; i <= t; i++) {
        L[i] = (i - 1) * sqrt(n) + 1;
        R[i] = i * sqrt(n);
    }
    if(R[t] < n) {
        t++;
        L[t] = R[t - 1] + 1;
        R[t] = n;
    }
    for(int i = 1; i <= t; i++) {
        for(int j = L[i]; j <= R[i]; j++) {
            pos[j] = i;
            sum[i] += a[j];
        }
    }

若序列长度为 \(N\),总操作数为 \(Q\),则由上述分析可得分块算法的时间复杂度上界为 \(O((N+Q)\sqrt N)\),空间复杂度为 \(O(N)\)

在处理区间问题时,通常有树状数组、线段树和分块三种方法,三种方法各有优劣。

  • 树状数组效率最高,代码最短;但不易扩展,不够直观,调试难度大。

  • 线段树效率较高,扩展性好;但代码较长,且直观性一般,调试难度较大。

  • 分块算法最为通用且直观,且代码较短;但效率一般。在复杂度允许,且正确性有保证的情况下,写分块的考场收益一般最大。

\((1)\) 对于一般性分块方法的说明:

  • 不妨先设分出的每个子序列长度为 \(s\),则共分出 \(\lceil \dfrac{N}{s}\rceil\) 个区间,根据暴力单点修改不超过 \(2(s-1)\) 次,区间覆盖不超过 \(\lceil \dfrac{N}{s}\rceil\) 次,可得分块修改/查询的一般性时间复杂度为 \(O(\dfrac{N}{s}+s)\)。由均值不等式可得,当且仅当 \(\dfrac{N}{s}=s\),即 \(s=\sqrt{n}\) 时, \(\dfrac{N}{s}+s\) 取得最小值 \(N\sqrt N\)

  • 由此得出分块的最优块长为 \(\sqrt N\),时间复杂度为 \(\Omega((N+Q)\sqrt N)\)

分块还可以较快地求区间不超过 \(k\) 的数的个数

  • 1 l r v,表示从 \(A_l \sim A_r\) 每个数都加上 \(v\)

  • 2 l r v,表示求 \(A_l \sim A_r\)大于等于 \(v\) 的数的个数。

这一题用线段树就没有那么直观了,而分块的优点也显示了出来。

我们考虑仍考虑将原数列分为 \(\sqrt N\) 块,对每个分块内部进行排序,每次块内查询时如果块不完整那么暴力在修改后的原序列上查找(\(O(\sqrt N)\)),如果块是完整的,那么直接在排序后的块内对 \(v\) 做一次二分查找,则块内大于等于 \(v\) 的数的个数就可以在 \(O(\log \sqrt N)\) 的时间内找到。

而对于每次修改,如果修改了一个不完整的块,则直接将块重新排序保证单调性;如果修改了整个块,显然块内的单调性不会发生改变,因此正常处理即可。

这样总的查询复杂度为 \(O(Q \sqrt N \log N)\)\(Q\) 为操作总次数。

inline void Sort(int x) {
    for(int i = L[x]; i <= R[x]; i++) {
        t[i] = a[i];
    }
    sort(t + L[x], t + R[x] + 1);
}

inline void change(int l, int r, int v) {
    int p = pos[l], q = pos[r];
    if(p == q) {
        for(int i = l; i <= r; i++) {
            a[i] += v;
        }
        Sort(p);
        return;
    }
    else {
        for(int i = p + 1; i < q; i++) {
            add[i] += v;
        }
        for(int i = l; i <= R[p]; i++) {
            a[i] += v;
        }
        Sort(p);
        for(int i = L[q]; i <= r; i++) {
            a[i] += v;
        }
        Sort(q);
    }
}

inline int ask(int l, int r, int v) {
    int p = pos[l], q = pos[r], ans = 0;
    if(p == q) {
        for(int i = l; i <= r; i++) {
            if(a[i] + add[p] >= v) {
                ans++;
            }
        }
        return ans;
    }
    else {
        for(int i = p + 1; i < q; i++) {
            ans += R[i] - (lower_bound(t + L[i], t + R[i] + 1, v - add[i]) - t - 1);
        }
        for(int i = l; i <= R[p]; i++) {
            if(a[i] + add[p] >= v) {
                ans++;
            }
        }
        for(int i = L[q]; i <= r; i++) {
            if(a[i] + add[q] >= v) {
                ans++;
            }
        }
    }
    return ans;
}

需要注意的是,预处理时要先对块内的元素进行一次排序才能保证特殊情况下的正确性:如第一次操作就是查询。

for(int i = 1; i <= T; i++) {
    for(int j = L[i]; j <= R[i]; j++) {
         pos[j] = i;
    }
    Sort(i);
}

对于此题,注意到 \(Q \leq 3000\) 这个特殊条件,也可以考虑将序列分为 \(2Q+1\) 段,直接暴力模拟修改过程,复杂度 \(O(Q^2)\)(严谨而言,应该是 \(O(2Q(Q+1000))\))。(不过这好像才是出题人本意

具体来讲,就是用 \(3000\) 个询问的左右端点作为块的端点,一共分出 \(6001\) 段。对于每一段用 \(f_{i,j}\) 记录第 \(i\) 段中值大于等于 \(j\) 的数有多少个。对于每一个块都枚举一遍所有的询问,修改直接用add标记即可,查询时把询问减掉add查。

最后推荐一些分块练习题:

作诗

线段树模板2

[Violet] 蒲公英

[Ynoi2019 模拟赛] Yuno loves sqrt technology III

posted @ 2023-07-31 17:49  Happy-Pig-Orz  阅读(22)  评论(0编辑  收藏  举报