Skip to content

L - Ladder Challenge - 题解

标签与难度

标签: 平方根分解, 预计算, 二分查找, 递推, 算法分析 难度: 2100

题目大意喵~

各位挑战者,你好呀~!这道题是关于一个叫做小明的人参加“天梯”游戏的故事,喵~

游戏里有 nn 个选手,他们的分数 a1,a2,,ana_1, a_2, \dots, a_n 是严格递增的。小明一开始不在他们之中。

小明可以通过“挑战”来提升自己的分数和排名。规则是:

  1. 小明总是选择当前所有积分比他高的选手中,积分最低的那一个进行挑战。
  2. 每挑战一次,小明的积分 +1,被挑战的选手积分 -1

现在有 qq 次独立的询问。每次询问会给出小明的初始积分 xx 和他想要达到的目标排名 yy。我们需要计算,小明至少要挑战多少次,才能达到或超过这个排名,呐。

这里的排名是所有 n+1 个人(包括小明)一起排的,分数越高排名越靠前,同分算并列。

解题思路分析

这道题看起来有点复杂,但别怕,让我带你一步步解开它的奥秘,喵~

关键操作的数学表达

首先,我们来分析一下小明的挑战过程。他总是找积分比他高的人里面最菜的那个(也就是积分最低的那个)。因为初始时选手积分 aia_i 是严格递增的,所以小明会按顺序挑战 ai,ai+1,ai+2,a_i, a_{i+1}, a_{i+2}, \dots

假设小明当前分数为 SS,他正在挑战分数为 AA 的选手。他需要挑战多少次才能“战胜”这位选手,然后去挑战下一位呢?“战胜”意味着他的分数要不低于对方。

设小明挑战了 kk 次:

  • 小明的分数变为 S+kS+k
  • 对手的分数变为 AkA-k

他可以挑战下一位选手的条件是 S+kAkS+k \ge A-k。我们来解这个不等式:

2kASkAS22k \ge A - S \\ k \ge \frac{A - S}{2}

因为挑战次数 kk 必须是整数,所以最小的 kk 就是 AS2\lceil \frac{A - S}{2} \rceil

挑战了 kk 次后,小明的新分数 SnewS_{new} 是多少呢? Snew=S+k=S+AS2S_{new} = S + k = S + \lceil \frac{A - S}{2} \rceil

对于整数运算,m/2\lceil m/2 \rceil 可以漂亮地写成 (m + 1) / 2(当 m 是正数时)。所以: k=(AS+1)/2k = (A - S + 1) / 2Snew=S+(AS+1)/2=(2S+AS+1)/2=(S+A+1)/2S_{new} = S + (A - S + 1) / 2 = (2S + A - S + 1) / 2 = (S + A + 1) / 2 (这里是整除哦)

哇!我们得到了一个非常简洁的递推公式!如果小明当前分数为 SoldS_{old},挑战完分数为 aia_i 的选手后,他的新分数将是:

Snew=Sold+ai+12S_{new} = \lfloor \frac{S_{old} + a_i + 1}{2} \rfloor

总挑战次数就是最终分数减去初始分数。

如何处理询问?

对于每个询问 (x,y)(x, y)

  1. 确定挑战目标:小明想达到排名 yy,意味着最多有 y1y-1 个人分数比他高。在最初的 nn 个选手中,他需要超过 a1,,any+1a_1, \dots, a_{n-y+1}。所以他的最终挑战对象是第 ny+1n-y+1 位选手。
  2. 确定挑战序列:小明从初始分数 xx 开始,首先用二分查找(比如 upper_bound)找到第一个分数比 xx 高的选手 ala_l。然后他会依次挑战 al,al+1,,ara_l, a_{l+1}, \dots, a_r,其中 r=ny+1r = n-y+1
  3. 计算最终分数:我们可以写一个循环,从 i=li=lrr,不断用上面的递推公式更新小明的分数。

如果直接这么做,对于每次查询,最坏情况下需要遍历近乎整个数组,复杂度是 O(N)O(N)。总复杂度就是 O(NQ)O(N \cdot Q),对于 N,QN, Q 都是 21052 \cdot 10^5 的数据量来说,是绝对会超时的,喵!

平方根分解来加速!

当我们需要对一个静态数组进行大量范围查询和操作时,平方根分解(分块)是一个非常棒的优化技巧,呐!

我们可以把 nn 个选手分成大约 N\sqrt{N} 个块,每块大小也是 N\sqrt{N}。对于一个从 llrr 的查询,我们可以把它拆成三部分:

  1. ll 所在的那个块的零散部分。
  2. 中间若干个完整的块。
  3. rr 所在的那个块的零散部分。

零散部分可以直接暴力计算,因为每部分长度不超过块大小 N\sqrt{N}。关键在于如何快速处理中间那些完整的块。

分块后的神奇性质——收敛性

让我们再仔细看看那个递推公式:Snew=(Sold+ai+1)/2S_{new} = (S_{old} + a_i + 1) / 2。 这个 / 2 操作非常关键!它有“压缩”数值范围的魔力。

假设有两个不同的初始分数 S1S_1S2S_2,经过一次操作后,它们的新分数的差大约是原来差的一半: S1S2S1+ai+12S2+ai+12=S1S22S'_{1} - S'_{2} \approx \frac{S_1 + a_i + 1}{2} - \frac{S_2 + a_i + 1}{2} = \frac{S_1 - S_2}{2}

这意味着,不管两个初始分数相差多远,每经过一次挑战,它们的差距就缩小一半。经过一个大小为 BB 的块(即 BB 次挑战)后,它们分数的差距会缩小到原来的 1/2B1/2^B

如果我们的块大小 B=N450B = \sqrt{N} \approx 450,那么 24502^{450} 是一个天文数字!任何合理的初始分数差(比如 10910^9)经过一个块的操作后,差值都会被抹平成 0。

这揭示了一个惊人的事实:对于一个足够大的块,无论初始输入的分数是多少,最终从这个块里出来的分数都是一个几乎固定的值!

这个性质让我们的问题变得简单多了。我们可以为每个块预计算一些信息,来帮助我们快速“跳过”整个块。

最终的解法

  1. 预处理 (O(N)O(N)):

    • 设定块大小 BNB \approx \sqrt{N}
    • 我们定义一个“基准路径”。假设小明初始分数为 0,我们计算他依次挑战 a1,a2,,ana_1, a_2, \dots, a_n 后的分数序列,记为 base_path[i]base_path[i] 表示从0分开始,挑战完前 ii 个选手后的分数。
    • 我们还需要一个“扰动路径”来处理那些不等于基准路径输入的情况。对于每个块 kk,我们计算当输入比基准路径的输入(即 base_path[L[k]-1])大 1 时,通过这个块后的输出分数。记为 perturbed_path_out[k]
  2. 查询 (O(N)O(\sqrt{N})):

    • 对于查询 (x,y)(x, y),找到挑战区间 [l,r][l, r]
    • 如果 l>rl > r,说明小明已经达标,输出 0。
    • 将小明的当前分数 current_score 初始化为 xx
    • 处理左边散块:从 ll 到其所在块的末尾,暴力更新 current_score
    • 处理中间整块:对于每个完整的块 kk,我们检查 current_score
      • 如果 current_score 正好等于这个块的基准输入 base_path[L[k]-1],那么输出就是基准输出 base_path[R[k]]
      • 否则,由于强大的收敛性,任何偏离基准路径的输入,在经过一整个块后,其路径都会收敛到我们预计算的“扰动路径”上。所以输出就是 perturbed_path_out[k]
    • 处理右边散块:用经过中间块的 current_score,从 rr 所在块的开头暴力计算到 rr
    • 最终的答案就是 current_score - x

这样,每次查询的复杂度就是处理两个散块的 O(N)O(\sqrt{N}) 和处理若干整块的 O(N)O(\sqrt{N}),总共是 O(N)O(\sqrt{N})。总时间复杂度为 O(N+QN)O(N + Q\sqrt{N}),可以轻松通过啦,喵~

代码实现

cpp
#include <iostream>
#include <vector>
#include <cmath>
#include <algorithm>

using namespace std;

// 使用 long long 防止分数溢出
using ll = long long;

const int MAXN = 200005;
const int BLOCK_SIZE = 450; // 一般取 sqrt(N)

int n, q;
ll a[MAXN];

// 预处理数组
ll base_path[MAXN]; // base_path[i]: 初始分数为0,挑战完前i个选手后的分数
ll perturbed_path_out[MAXN / BLOCK_SIZE + 5]; // 扰动路径在一个块的输出
int block_id[MAXN]; // 每个元素属于的块编号
int block_L[MAXN / BLOCK_SIZE + 5], block_R[MAXN / BLOCK_SIZE + 5]; // 每个块的左右边界

// 核心递推函数
ll transform(ll current_score, ll opponent_score) {
    return (current_score + opponent_score + 1) / 2;
}

// 计算通过一个完整块后的分数
ll calculate_block_output(int block_idx, ll start_score) {
    if (start_score == base_path[block_L[block_idx] - 1]) {
        return base_path[block_R[block_idx]];
    } else {
        return perturbed_path_out[block_idx];
    }
}

int main() {
    // 优化输入输出,喵~
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

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

    // --- 预处理阶段 ---
    // 1. 分块
    int num_blocks = (n + BLOCK_SIZE - 1) / BLOCK_SIZE;
    for (int i = 1; i <= n; ++i) {
        block_id[i] = (i - 1) / BLOCK_SIZE + 1;
    }
    for (int i = 1; i <= num_blocks; ++i) {
        block_L[i] = (i - 1) * BLOCK_SIZE + 1;
        block_R[i] = min(i * BLOCK_SIZE, n);
    }

    // 2. 计算基准路径
    base_path[0] = 0;
    for (int i = 1; i <= n; ++i) {
        base_path[i] = transform(base_path[i - 1], a[i]);
    }

    // 3. 计算每个块的扰动路径输出
    for (int i = 1; i <= num_blocks; ++i) {
        ll perturbed_start_score = base_path[block_L[i] - 1] + 1;
        for (int j = block_L[i]; j <= block_R[i]; ++j) {
            perturbed_start_score = transform(perturbed_start_score, a[j]);
        }
        perturbed_path_out[i] = perturbed_start_score;
    }

    // --- 查询阶段 ---
    while (q--) {
        ll x, y;
        cin >> x >> y;

        // 找到挑战的起始选手和结束选手
        // upper_bound 找到第一个 > x 的元素
        int l = upper_bound(a + 1, a + n + 1, x) - a;
        int r = n - y + 1;

        if (l > r) {
            cout << 0 << "\n";
            continue;
        }

        ll current_score = x;
        int start_block = block_id[l];
        int end_block = block_id[r];

        if (start_block == end_block) {
            // 情况1: l和r在同一个块内,直接暴力计算
            for (int i = l; i <= r; ++i) {
                current_score = transform(current_score, a[i]);
            }
        } else {
            // 情况2: l和r在不同块
            // a. 处理左边的散块
            for (int i = l; i <= block_R[start_block]; ++i) {
                current_score = transform(current_score, a[i]);
            }
            // b. 处理中间的整块
            for (int i = start_block + 1; i < end_block; ++i) {
                current_score = calculate_block_output(i, current_score);
            }
            // c. 处理右边的散块
            for (int i = block_L[end_block]; i <= r; ++i) {
                current_score = transform(current_score, a[i]);
            }
        }
        cout << current_score - x << "\n";
    }

    return 0;
}

复杂度分析

  • 时间复杂度: O(N+QN)O(N + Q \cdot \sqrt{N})

    • 预处理阶段:分块是 O(N)O(N),计算基准路径是 O(N)O(N),计算所有块的扰动路径输出是 O(N)×O(N)=O(N)O(\sqrt{N}) \times O(\sqrt{N}) = O(N)。所以总预处理是 O(N)O(N)
    • 查询阶段:每次查询最多处理两个散块和 N\sqrt{N} 个整块。散块处理是 O(N)O(\sqrt{N}),整块处理是 O(1)O(1),总共是 O(N)O(\sqrt{N})QQ 次查询就是 O(QN)O(Q \cdot \sqrt{N})
  • 空间复杂度: O(N)O(N)

    • 我们需要存储选手分数数组 a,基准路径数组 base_path,以及一些分块信息的辅助数组,它们的大小都和 NN 线性相关。

知识点总结

这道题是考察对问题建模和算法优化能力的好例子,喵~

  1. 数学建模: 将复杂的挑战规则转化为简洁的递推公式是解题的第一步。
  2. 平方根分解: 这是一个非常通用的优化技巧,适用于对静态数组的区间查询。核心思想是“大段维护,小段朴素”。
  3. 收敛性分析: 本题解法的精髓在于发现了递推公式 (S+A+1)/2 的收敛性质。这个性质保证了分块后,对整块的处理可以被极大地简化和预计算。
  4. 二分查找: 用于快速定位小明开始挑战的第一个选手。

希望这篇题解能帮助你理解这道有趣的题目!只要我们一步步分析,再复杂的问题也能被我们猫爪子挠开的,加油哦,喵~!