CF题解——Remainder Problem

F. Remainder Problem 解题思路

核心问题分析

题意非常干净利落:我们有一个长度 500000500000 的数组 aa,下标从 11500000500000,初始全是 00。然后要在线处理两种操作:

  • 1 x y:把 axa_x 加上 yy(单点修改);
  • 2 x y:求 iR(x,y)ai\sum_{i \in R(x,y)} a_i,其中 R(x,y)R(x,y)15000001 \sim 500000xxyy 的那些下标。

换句话说,第二种询问要的是 ay+ay+x+ay+2x+a_y + a_{y+x} + a_{y+2x} + \dots 这样一条等差数列上所有元素的和(首项是 yy,公差是 xx)。

数据范围 q500000q \le 500000,值域 N=500000N = 500000。先掂量一下两种朴素做法:

  • 如果对每个询问都老老实实从 yy 开始、步长 xx 一路加过去,单次复杂度是 O(N/x)O(N/x)。当 xx 很小(比如 x=1x = 1)时,一次就是 5×1055 \times 10^5 次加法,qq 个这种询问直接 2.5×10112.5 \times 10^{11},原地爆炸。
  • 反过来,如果我们想"预存好每个 (x,y)(x, y) 的答案",那修改操作 1 x y 一来,得更新所有模数 xxxx 的归属——而模数有 5×1055 \times 10^5 种,单次修改又爆炸了。

两条路各走极端都死。一个走"询问慢",一个走"修改慢"。那能不能让它俩各管一段、井水不犯河水? 这就是根号分治的味道了。

1. 一道分水岭:以 N\sqrt{N} 为界把模数劈成两半

核心 trick 就一句话:设一个阈值 BB,把模数 xx 分成"小模数 xBx \le B"和"大模数 x>Bx > B"两类,用完全不同的策略各自伺候

为什么 N\sqrt{N} 这个分水岭是黄金分割点?我们逐项算一下两类的开销:

  • 大模数 x>Bx > B:直接暴力跳。等差数列 y,y+x,y+2x,y, y+x, y+2x, \dots[1,N][1, N] 内最多有 N/x\lceil N/x \rceil 项,因为 x>Bx > B,所以一次询问最多跳 O(N/B)O(N/B) 步。
  • 小模数 xBx \le B:我们提前把答案都存好。开一个二维数组 ans[x][r],表示"模数为 xx、余数为 rr 的那一坨下标,当前的元素之和"。每次修改 1 x y,只需要对所有 xBx \le B 的模数,把这个下标归到对应余数桶里更新一下,单次修改是 O(B)O(B);而小模数的询问就退化成 O(1)O(1) 直接查表。

于是修改的代价是 O(B)O(B),大模数询问的代价是 O(N/B)O(N/B)。两者相加,当 BN707B \approx \sqrt{N} \approx 707 时取到最优。这位 AC 老哥取的阈值是 B=450B = 450(略微偏向让"小模数预处理桶"更紧凑、ans 数组开 1000×10001000 \times 1000 刚好兜住),实测在 4s 时限内丝毫不影响通过。

2. 修改操作:一次更新喂饱所有小模数桶

来看修改那段,它干了两件事:

cpp
if (ob == 1) {
    nums[x] += y;
    for (int i = 1; i <= 450; i++) {
        ans[i][x % i] += y;
    }
}

第一行 nums[x] += y 维护的是真实数组——这是给大模数询问暴力跳用的原始数据。第二行那个循环才是根号分治的精髓:对每一个小模数 i[1,450]i \in [1, 450],这个下标 xx 在模 ii 意义下落在余数 xmodix \bmod i 这个桶里,所以把对应的 ans[i][x % i] 加上 yy 即可。

注意这里 ans 数组开成了 1000×10001000 \times 1000,第一维装模数(用到 450450),第二维装余数(余数严格小于模数,所以也不超过 450450),完全够用。单次修改就是这个长度 450450 的循环,O(B)O(B) 妥妥的。

3. 询问操作:看模数大小走两条岔路

询问就是上面那盘棋的"收割时刻",根据 xx 的大小走两条完全不同的路:

cpp
} else {
    if (x <= 450) {
        cout << ans[x][y] << endl;
    } else {
        int res = 0;
        for (int i = y; i <= 500000; i += x) {
            res += nums[i];
        }
        cout << res << endl;
    }
}
  • 小模数 x450x \le 450:答案我们早在修改时就一笔一笔攒进 ans[x][y] 里了,直接 O(1)O(1) 报出来,无脑得很。
  • 大模数 x>450x > 450:从余数 yy 出发,步长 xxnumsnums 上一路跳到 500000500000,把沿途的值累加。因为 x>450x > 450,最多跳 500000/4501111500000 / 450 \approx 1111 步,单次询问 O(N/B)O(N/B)

两条路恰好互补:小模数怕暴力跳得慢(步多),我们就预存;大模数怕预存占空间又拖修改,我们就暴力跳(步少)。各取所长,皆大欢喜。

4. 复杂度结算

把账算清楚:每次修改 O(B)O(B),每次询问最坏 O(N/B)O(N/B)qq 个操作总复杂度是

O ⁣(q(B+NB))O\!\left(q \cdot \left(B + \frac{N}{B}\right)\right)

B=Θ(N)B = \Theta(\sqrt{N}) 时括号里取到最小值 Θ(N)\Theta(\sqrt{N}),于是整体是

O ⁣(qN)5×105×7073.5×108O\!\left(q\sqrt{N}\right) \approx 5\times 10^5 \times 707 \approx 3.5\times 10^8

看着吓人,但这是纯加法、访存又连续(cache 友好),配上 4s 的宽松时限,跑起来轻轻松松。根号分治这套"用空间换平衡、把极端拉回中庸"的思想,在这道题上展现得淋漓尽致——记住这个 B+N/BB + N/B 的对勾函数,以后碰到"修改快 vs 询问快二选一"的窘境,它能包打天下。

CPP 代码实现

cpp
// F. Remainder Problem

#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;

void solve() {

    vector<int> nums(500010);
    vector<vector<int>> ans(1000, vector<int>(1000));
    int n;
    cin >> n;
    while (n--) {
        int ob, x, y;
        cin >> ob >> x >> y;
        if (ob == 1) {
            nums[x] += y;
            for (int i = 1; i <= 450; i++) {
                ans[i][x % i] += y;
            }
        } else {
            if (x <= 450) {
                cout << ans[x][y] << endl;
            } else {
                int res = 0;
                for (int i = y; i <= 500000; i += x) {
                    res += nums[i];
                }
                cout << res << endl;
            }
        }
    }
    
}

signed main() {

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

    int t = 1;
    // cin >> t;
    
    while (t--) {
        solve();
    }
    
}
CF题解——Coloring Edges
CF题解——OpenStreetMap