CF题解——Minimum Array

C. Minimum Array 解题思路

核心问题分析

题意:给定长度为 nn 的数组 aa,依次对它执行 qq 次区间加操作——第 kk 次操作给定 (lk,rk,xk)(l_k, r_k, x_k),把 a[lk..rk]a[l_k..r_k] 整体加上 xkx_k。记 bjb_j 为“只做了前 jj 次操作”之后的数组(b0b_0 就是原始数组),0jq0 \le j \le q。在这 q+1q+1 个数组 b0,b1,,bqb_0, b_1, \dots, b_q 里,找出字典序最小的那一个并输出。

多组数据,1n,q5×1051 \le n, q \le 5 \times 10^5n,qn, q 之和都有上界),33 秒时限。数据范围友好地暗示我们要找一个 O((n+q)log(n+q))O((n+q)\log(n+q)) 左右的做法。

1. 拆开来看:每个位置其实是关于 jj 的一个阶梯函数

直接比较 q+1q+1 个完整数组显然不可能。先盯着单个位置 ii:它在 bjb_j 里的值是

bj[i]=ai+kj, lkirkxkfi(j)b_j[i] = a_i + \underbrace{\sum_{k \le j,\ l_k \le i \le r_k} x_k}_{f_i(j)}

fi(j)f_i(j) 只由“前 jj 次操作里,覆盖了位置 ii 的那些操作”决定,jj 每增大一次,fi(j)f_i(j) 要么不变、要么跳一下。也就是说,对每个固定的位置 iifi()f_i(\cdot) 是一个关于 jj 的阶梯函数——这是后面所有优化的基础。

2. 字典序最小 = 贪心地逐位淘汰候选 jj

quot;">​

字典序比较的规则很简单:先比第一位,谁小谁赢;平局才看第二位……以此类推。所以我们可以把这 q+1q+1jj 值看成一群“候选人”,从位置 11 开始逐位淘汰:

  • 在位置 ii,看所有还活着的候选 jj,取 minjfi(j)\min_j f_i(j),这就是最终答案数组在位置 ii 上的取值(因为字典序最优的那个 jj,在这一位上必然取到这个最小值,否则就会被取到更小值的候选打败);
  • 把所有在这一位上没能达到这个最小值的候选 jj 直接判死刑,它们已经不可能是字典序最小的那个了;
  • 打平的候选留到下一位继续比。

一路比下去,比到最后活下来的(可能不止一个,随便选一个)就是答案对应的 jj。这个过程本身就是 O(n)O(n) 轮“求最小值 + 批量淘汰”,难点全部转移到怎么维护 fi(j)f_i(j) 以及怎么支持批量淘汰上。

3. 用扫描线把 fi(j)f_i(j) 挂到线段树上

fi(j)f_i(j) 是关于两个变量 (i,j)(i, j) 的东西,直接存下来是 O(nq)O(nq),肯定不行。但注意到我们是按 i=1,2,,ni = 1, 2, \dots, n 顺序扫过去的,而每次操作 kk 覆盖的是一段连续的位置 [lk,rk][l_k, r_k]——这就是经典的“扫描线 + 差分”套路:

  • 扫到 i=lki = l_k 时,操作 kk 从这一位开始生效,于是对所有 jkj \ge kfi(j)f_i(j) 都要加上 xkx_k
  • 扫到 i=rk+1i = r_k + 1 时,操作 kk 已经管不到这一位了,把刚才加的 xkx_k 原样减回去。

也就是说,维护一棵下标为 j[0,q]j \in [0, q] 的线段树,扫到位置 lkl_k 时对区间 [k,q][k, q] 做一次 +xk+x_k 的区间加,扫到位置 rk+1r_k+1 时对区间 [k,q][k, q] 做一次 xk-x_k 的区间加抵消掉。这样任意时刻线段树里存的,就恰好是当前位置 ii 对应的 fi(j)f_i(j),对所有 jj 同时维护,全程摊下来只有 O(q)O(q) 次区间加。

cpp
for (int i = 1; i <= n; i++) {
    for (auto [idx, val] : events[i]) {
        sgt.modify(idx, q, val);
    }
    // 此刻线段树里存的就是 f_i(0..q)
}

4. 批量淘汰:线段树也能"判死刑"

有了 fi(j)f_i(j),每一步要做两件事:查全局最小值,把所有值 >min> \min 的叶子永久剔除出局(赋成 ++\infty,以后既不会被选中也不会再被更新影响结果)。这正是 segment tree beats 里最经典的一招:维护区间的 min\minmax\max,剔除时如果某棵子树的 maxlimit\max \le limit 就直接跳过(这些人已经全部合格,不用管),否则递归下去,一路递归到叶子把不合格的枪毙掉。

cpp
void discard(int v, int l, int r, int limit) {
    if (tr[v].mx <= limit) return; // 整棵子树都合格,剪掉这一支
    if (l == r) { tr[v].mn = tr[v].mx = INF; return; } // 判死刑
    pushdown(v);
    int mid = (l + r) >> 1;
    discard(v * 2, l, mid, limit);
    discard(v * 2 + 1, mid + 1, r, limit);
    pushup(v);
}

因为每个叶子(每个候选 jj)一辈子只会被判死一次,所以尽管单次 discard 看起来可能递归到很多叶子,但均摊下来整个扫描过程里 discard 的总代价是 O(qlogq)O(q \log q)——这跟经典的势能分析(区间开方、区间取模那一套)是同一个道理。

5. 组装起来

对每个位置 ii:先把这一位新生效/失效的操作差分更新打到线段树上,查一次全局最小值 bestbest(就是答案在这一位的取值),令 ai+=besta_i \mathrel{+}= best,再把所有当前值 >best> best 的候选剔除。nn 个位置各做一遍,总复杂度

O((n+q)logq)O\big((n + q)\log q\big)

轻松跑进 33 秒时限。

回头看这道 2400*2400 的题,真正巧妙的地方在于把“字典序最小”翻译成了逐位淘汰候选 —— 一旦想通这一点,剩下的“怎么维护 fi(j)f_i(j)”交给差分扫描线,“怎么批量淘汰”交给 segment tree beats 式的势能剪枝,两个经典套路一拼就通关了。

CPP 代码实现

cpp
// C. Minimum Array

#include <bits/stdc++.h>
#define lg(x) (63 - __builtin_clzll(x))
#define all(x) (x).begin(), (x).end()
#define low_bit(x) ((x) & (-x))
#define pb push_back
#define db long double
#define int long long
#define sz(x) (int)x.size()
#define endl "\n"

using namespace std;

const int INF = 4e18;

struct SGT {
    struct Node { int mn, mx, tag; };

    int n;
    vector<Node> tr;

    SGT(int n) : n(n), tr(4 * n + 4) {
        build(1, 0, n - 1);
    }

    void build(int v, int l, int r) {
        tr[v] = {0, 0, 0};
        if (l == r) return;
        int mid = (l + r) >> 1;
        build(v * 2, l, mid);
        build(v * 2 + 1, mid + 1, r);
    }

    void apply(int v, int val) {
        tr[v].mn += val;
        tr[v].mx += val;
        tr[v].tag += val;
    }

    void pushdown(int v) {
        if (tr[v].tag != 0) {
            apply(v * 2, tr[v].tag);
            apply(v * 2 + 1, tr[v].tag);
            tr[v].tag = 0;
        }
    }

    void pushup(int v) {
        tr[v].mn = min(tr[v * 2].mn, tr[v * 2 + 1].mn);
        tr[v].mx = max(tr[v * 2].mx, tr[v * 2 + 1].mx);
    }

    void update(int v, int l, int r, int L, int R, int val) {
        if (R < l || r < L) return;
        if (L <= l && r <= R) { apply(v, val); return; }
        pushdown(v);
        int mid = (l + r) >> 1;
        update(v * 2, l, mid, L, R, val);
        update(v * 2 + 1, mid + 1, r, L, R, val);
        pushup(v);
    }

    void discard(int v, int l, int r, int limit) {
        if (tr[v].mx <= limit) return;
        if (l == r) { tr[v].mn = tr[v].mx = INF; return; }
        pushdown(v);
        int mid = (l + r) >> 1;
        discard(v * 2, l, mid, limit);
        discard(v * 2 + 1, mid + 1, r, limit);
        pushup(v);
    }

    void modify(int L, int R, int val) { update(1, 0, n - 1, L, R, val); }
    int query_min() { return tr[1].mn; }
    void kill_worse_than(int limit) { discard(1, 0, n - 1, limit); }
};

void solve() {

    int n;
    cin >> n;
    vector<int> a(n + 1);
    for (int i = 1; i <= n; i++) cin >> a[i];

    int q;
    cin >> q;
    vector<vector<pair<int, int>>> events(n + 2);
    for (int i = 1; i <= q; i++) {
        int l, r, x;
        cin >> l >> r >> x;
        events[l].pb({i, x});
        events[r + 1].pb({i, -x});
    }

    SGT sgt(q + 1);
    for (int i = 1; i <= n; i++) {
        for (auto [idx, val] : events[i]) {
            sgt.modify(idx, q, val);
        }
        int best = sgt.query_min();
        a[i] += best;
        sgt.kill_worse_than(best);
    }

    for (int i = 1; i <= n; i++) cout << a[i] << " ";
    cout << endl;

}

signed main() {

    ios_base::sync_with_stdio(false);
    cin.tie(nullptr);

    int t;
    cin >> t;

    while (t--) {
        solve();
    }

}
CF题解——Remove the Grail Tree
CF题解——Covered Points