NOIP2025 模拟赛2


【P11267 王国边缘】题解报告

题目核心分析

本题要求我们模拟一个在无限循环字符串上的移动过程。核心的挑战在于所有给定的数值(位置 s,步数 k,视野 m)都可能极大,达到 \(10^{18}\) 的量级。这意味着任何与这些数值大小成正比的模拟算法都是不可行的。

解决这类问题的关键通常在于利用题目中的周期性或固定模式,将复杂的、大量的操作转化为几次高效的数学计算或预处理查询。

幸运的是,题目中的字符串 \(T\) 是由 \(S\) 无限循环构成的,这给了我们强烈的周期性暗示。智能体的移动规则虽然看起来复杂,但由于字符串的周期性,从某个相对位置(模 \(n\) 的余数)出发的“跳跃模式”也应该是固定的。这引导我们向倍增(Binary Lifting)或矩阵快速幂这类对数时间复杂度的算法思考。


部分分算法解析

Subtask 1-2 (\(k=0\))

  • 约束条件:\(k=0\)。
  • 思路:移动次数为0,异客原地不动。
  • 算法:对于每个询问,直接输出起点 \(s\) 对 \(10^9+7\) 取模后的结果。
  • 代码实现:
    #include <iostream>
    
    int main() {
        long long n, m, s, k;
        int q;
        std::string str_s;
        std::cin >> n >> m >> q >> str_s;
        while (q--) {
            std::cin >> s >> k;
            std::cout << s % 1000000007 << std::endl;
        }
        return 0;
    }
    

Subtask 3-5 (\(S\) 为全 0 或全 1)

这是非常重要的特殊情况,能帮助我们理解跳跃的本质。

  • 情况A:\(S\) 为全 0

    • 思路:视野所及之处 T_{i+1...i+m} 将永远是全 0。根据规则,异客会移动到下一个字符 \(T_{i+1}\)。这意味着每一步都向前移动1个位置。
    • 算法:从起点 \(s\) 移动 \(k\) 次,最终位置就是 \(s+k\)。
    • 结果:\((s \pmod{MOD} + k \pmod{MOD}) \pmod{MOD}\)。
  • 情况B:\(S\) 为全 1

    • 思路:视野所及之处 T_{i+1...i+m} 永远有 1。根据规则,异客会移动到其中最远的一个 1 上,也就是位置 \(i+m\)。这意味着每一步都固定向前移动 \(m\) 个位置。
    • 算法:从起点 \(s\) 移动 \(k\) 次,最终位置就是 \(s + k \times m\)。
    • 结果:\((s \pmod{MOD} + (k \pmod{MOD}) \times (m \pmod{MOD}) \pmod{MOD}) \pmod{MOD}\)。
  • 启发:这两个特例告诉我们,在某些情况下,每一步的位移量是固定的。这启发我们思考:在一般情况下,位移量是否也存在某种规律?

Subtask 6-10 (暴力模拟, \(n, m, q, s, k\) 均较小)

  • 约束条件:所有数值都在 \(3 \times 10^3\) 以内。
  • 思路:数据范围很小,可以直接模拟。对于每次询问,循环 \(k\) 次,每次计算下一步的位置。
  • 算法:
    1. 为了方便处理环形字符串,可以将字符串 S 复制几倍(例如,复制到 \(n+m\) 的长度就足够一次查询)。
    2. 写一个函数 getNextPos(current_pos):
      • 从 current_pos + m 向 current_pos + 1 倒序扫描。
      • 遇到的第一个 '1' 就是目标位置。
      • 如果整个区间没有 '1',目标位置就是 current_pos + 1。
    3. 主循环中调用这个函数 k 次。
  • 代码实现:
    // 伪代码,演示核心逻辑
    long long getNextPos(long long current_pos, long long m, int n, const std::string& S) {
        for (long long i = current_pos + m; i > current_pos; --i) {
            // T_i 对应 S 的下标是 (i-1) % n
            if (S[(i - 1) % n] == '1') {
                return i;
            }
        }
        return current_pos + 1;
    }
    
    void solve_brute_force(long long s, long long k, long long m, int n, const std::string& S) {
        long long current_pos = s;
        for (int i = 0; i < k; ++i) {
            current_pos = getNextPos(current_pos, m, n, S);
        }
        std::cout << current_pos % 1000000007 << std::endl;
    }
    
  • 启发:暴力模拟帮助我们厘清了单步移动的具体逻辑,是实现正解中“单步跳转”计算部分的基础。

Subtask 11-14 (\(k=1\))

  • 约束条件:\(k=1\),但 \(s, m\) 很大。
  • 思路:只需计算一步。这强迫我们思考如何在 \(s\) 和 \(m\) 很大的情况下,高效地完成 getNextPos 函数的功能。
  • 算法:
    1. 设当前位置为 \(i\)。要找的目标区间是 \([i+1, i+m]\)。
    2. 要找这个区间里最远的 '1',等价于在整个无限字符串 \(T\) 中,寻找位置 \(\le i+m\) 的最远的 '1'。
    3. 我们可以预处理字符串 S,得到一个数组 last_one[j],表示 S 的前缀 S[0...j] 中最后一个 '1' 的位置。
    4. 设目标位置上界为 limit = i + m。它在 S 循环体中的位置是第 block = (limit - 1) / n 个块,和块内偏移 rem = (limit - 1) % n。
    5. 最远的 '1' 可能在 (block, rem) 这个位置,也可能在它前面。利用 last_one 数组可以快速定位。
      • 如果 S[0...rem] 中有 '1'(即 last_one[rem] 有效),那么最远的 '1' 就在第 block 个块的 last_one[rem] 位置。绝对位置是 block * n + last_one[rem] + 1。
      • 否则,最远的 '1' 在前一个块的末尾,也就是 (block - 1) * n + (S中最后一个'1'的位置) + 1。
    6. 如果算出来的位置 \(\le i\),说明视野内没有 '1',则移动到 \(i+1\)。
  • 启发:这个部分分解决了单步跳转的核心计算问题,是正解预处理步骤的关键。它告诉我们,无论 \(s,m\) 多大,单步跳转的计算量可以做到与 \(n\) 相关,而与 \(s,m\) 本身大小无关。

正解算法:周期性 + 倍增

结合以上所有启发,我们来设计正解。

1. 状态定义与转移

  • 状态:一个位置 \(i\) 的关键信息是它在循环节 S 中的相对位置,即 p = (i - 1) % n。我们称 p 为当前的状态。

  • 跳转分解:对于一个超大的视野 \(m\),我们可以将其分解为 m = q_m * n + r_m,其中 q_m = m / n,r_m = m % n。

  • 位移分解:从位置 \(i\)(状态为 p)跳一次,位移 Δ 可以分解为两部分:

    1. 基础位移:由 q_m 个完整的 S 循环贡献。
    2. 额外位移:由状态 p、视野零头 r_m 和 S 本身的 1 分布共同决定。

    设从状态 p 出发,通过一次跳转,位置从 \(i\) 变为 \(i_{next}\)。
    \(i_{next}\) 是 \([i+1, i+m]\) 中最远的 '1'。
    \(i_{next} - i = \Delta\) (总位移)。
    我们可以定义一个 extra_jump[p],使得 \(i_{next} = i + q_m \times n + \text{extra\_jump}[p]\)。
    这个 extra_jump[p] 可以通过 Subtask 11-14 的方法预处理出来。

  • 状态转移:从状态 p 跳一次后,新的状态 p_next 是:
    p_next = (i_next - 1) % n = (i - 1 + Δ) % n = (p + Δ) % n
    p_next = (p + q_m * n + extra_jump[p]) % n = (p + extra_jump[p]) % n
    关键结论:状态的转移只和 extra_jump[p] 有关,与巨大的 q_m 无关!

2. 倍增预处理

我们现在有一个包含 \(n\) 个状态的系统,每次转移的规则也已确定。为了快速计算 \(k\) 步的移动,我们使用倍增。

建立两个表:

  1. next_p[p][j]:从状态 p 开始,连续移动 \(2^j\) 次后,达到的新状态。
  2. extra_jump_sum[p][j]:从状态 p 开始,连续移动 \(2^j\) 次,所有 extra_jump 的总和(模 \(MOD\))。

递推关系:

  • Base Case (j=0):
    • next_p[p][0] = (p + extra_jump[p]) % n
    • extra_jump_sum[p][0] = extra_jump[p] % MOD
  • Recursive Step (j>0):移动 \(2^j\) 步 = 先移动 \(2^{j-1}\) 步,再从新位置移动 \(2^{j-1}\) 步。
    • mid_p = next_p[p][j-1]
    • next_p[p][j] = next_p[mid_p][j-1]
    • extra_jump_sum[p][j] = (extra_jump_sum[p][j-1] + extra_jump_sum[mid_p][j-1]) % MOD

预处理的时间复杂度为 \(O(n \log k)\)。

3. 查询处理

对于每个查询 \((s, k)\):

  1. 计算总的基础位移:总共移动了 \(k\) 步,每步的基础位移是 \(q_m \times n\)。总基础位移就是 \(k \times q_m \times n\)。
  2. 计算总的额外位移:
    • 初始状态 current_p = (s - 1) % n。
    • 总额外位移 total_extra = 0。
    • 将 \(k\) 进行二进制分解。从高位到低位遍历 j(从约60到0)。
    • 如果 \(k\) 的第 j 位是1(即 (k >> j) & 1),说明需要进行一次 \(2^j\) 步的“大跳”。
      • total_extra = (total_extra + extra_jump_sum[current_p][j]) % MOD
      • current_p = next_p[current_p][j]
  3. 合并结果:
    final_pos = (s + k * q_m * n + total_extra) % MOD
    注意所有计算都要在模 \(MOD\) 意义下进行。

查询的时间复杂度为 \(O(\log k)\)。总时间复杂度为 \(O(n \log(\text{max_k}) + q \log(\text{max_k}))\),可以通过本题。

最终代码实现

#include <iostream>
#include <vector>
#include <string>
#include <numeric>

using namespace std;
using ll = long long;

const int MOD = 1e9 + 7;
const int MAXN = 200005;
// k, m 可以很大,log2(10^18) 约 59.79,所以 LOGK 取 62 足够
const int LOGK = 62;

ll n_ll, m, q;
int n;
string s;

// 倍增表
int next_p[MAXN][LOGK];
ll extra_jump_sum_mod[MAXN][LOGK];

// 预处理S串的信息
vector<int> last_one_in_prefix; // last_one_in_prefix[i] = S[0..i]中最后一个'1'的下标
int last_one_in_s; // S中最后一个'1'的下标

// 辅助函数:给定T中的一个位置pos(1-based),返回<=pos的最远的'1'的位置
// 如果不存在,返回0
ll get_farthest_one_pos(ll pos) {
    if (last_one_in_s == -1 || pos <= 0) return 0;

    ll block = (pos - 1) / n_ll;
    int rem = (int)((pos - 1) % n_ll);

    if (last_one_in_prefix[rem] != -1) {
        // 在当前块的前缀部分找到了'1'
        return block * n_ll + last_one_in_prefix[rem] + 1;
    } else {
        // 当前块的前缀部分没有'1',需要到前一个块去找
        if (block == 0) return 0; // 第一个块的前面没有了
        return (block - 1) * n_ll + last_one_in_s + 1;
    }
}

// 处理S全为0的特殊情况
void solve_all_zeros() {
    for (int i = 0; i < q; ++i) {
        ll start, k;
        cin >> start >> k;
        ll ans = (start % MOD + k % MOD) % MOD;
        cout << ans << "\n";
    }
}

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    cin >> n_ll >> m >> q;
    n = (int)n_ll;
    cin >> s;

    // 特判:S全为0
    bool all_zeros = true;
    for (char c : s) {
        if (c == '1') {
            all_zeros = false;
            break;
        }
    }

    if (all_zeros) {
        solve_all_zeros();
        return 0;
    }

    // 预处理 S
    last_one_in_prefix.resize(n);
    int current_last = -1;
    for (int i = 0; i < n; ++i) {
        if (s[i] == '1') {
            current_last = i;
        }
        last_one_in_prefix[i] = current_last;
    }
    last_one_in_s = current_last;

    // --- 预处理倍增表 ---
    ll m_q = m / n_ll; // 基础位移中的整周期数
    ll m_r = m % n_ll; // 视野的零头

    vector<ll> extra_jump(n);
    for (int p = 0; p < n; ++p) { // p是状态, 对应位置 i = (任何数 k*n + p + 1)
        ll current_pos_example = p + 1; // 选一个代表位置
        ll vision_end = current_pos_example + m;
        
        ll farthest_one = get_farthest_one_pos(vision_end);

        if (farthest_one <= current_pos_example) { // 视野内没有 '1'
            // 移动到下一个位置
            ll jump_dist = 1;
            extra_jump[p] = jump_dist - m_q * n_ll;
        } else {
            ll jump_dist = farthest_one - current_pos_example;
            extra_jump[p] = jump_dist - m_q * n_ll;
        }
    }

    // 填充倍增表 j=0 的情况
    for (int p = 0; p < n; ++p) {
        extra_jump_sum_mod[p][0] = (extra_jump[p] % MOD + MOD) % MOD;
        ll next_p_ll = (ll)p + extra_jump[p];
        next_p[p][0] = (int)((next_p_ll % n_ll + n_ll) % n_ll);
    }
    
    // 递推填充整个倍增表
    for (int j = 1; j < LOGK; ++j) {
        for (int p = 0; p < n; ++p) {
            int mid_p = next_p[p][j - 1];
            next_p[p][j] = next_p[mid_p][j - 1];
            extra_jump_sum_mod[p][j] = (extra_jump_sum_mod[p][j - 1] + extra_jump_sum_mod[mid_p][j - 1]) % MOD;
        }
    }

    ll base_jump_per_step_mod = (m_q % MOD * (n_ll % MOD)) % MOD;

    // --- 处理查询 ---
    for (int i = 0; i < q; ++i) {
        ll start, k;
        cin >> start >> k;

        if (k == 0) {
            cout << start % MOD << "\n";
            continue;
        }

        ll final_pos_mod = start % MOD;
        ll k_mod = k % MOD;

        // 1. 加上所有基础位移
        ll total_base_jump_mod = (k_mod * base_jump_per_step_mod) % MOD;
        final_pos_mod = (final_pos_mod + total_base_jump_mod) % MOD;

        // 2. 加上所有额外位移
        int current_p = (int)((start - 1) % n_ll);
        ll total_extra_jump_mod = 0;

        for (int j = LOGK - 1; j >= 0; --j) {
            if ((k >> j) & 1) {
                total_extra_jump_mod = (total_extra_jump_mod + extra_jump_sum_mod[current_p][j]) % MOD;
                current_p = next_p[current_p][j];
            }
        }
        
        final_pos_mod = (final_pos_mod + total_extra_jump_mod) % MOD;
        cout << final_pos_mod << "\n";
    }

    return 0;
}

【P11268 买东西】题解

题目大意

有 \(n\) 个物品和 \(m\) 张优惠券。每个物品 \(i\) 有原价 \(a_i\) 和折扣价 \(b_i\)。每张优惠券 \(j\) 有一个使用门槛 \(w_j\) 和减免金额 \(v_j\)。

对于每个物品,有三种购买方式:

  1. 原价 \(a_i\) 购买。(由于 \(b_i \le a_i\),此选项永远不优于折扣价,可以忽略)。
  2. 折扣价 \(b_i\) 购买。
  3. 使用一张未使用过的优惠券 \(j\),条件是 \(a_i \ge w_j\),价格为 \(a_i - v_j\)。

目标是购买所有 \(n\) 个物品,求最小总花费。

思路分析与部分分

这是一个带有约束的资源分配问题:我们有 \(m\) 张优惠券(资源),需要分配给 \(n\) 个物品(任务),以实现总成本最小化。核心的困难在于,一张优惠券可能可以用于多个物品,而一个物品也可能适用多张优惠券。将哪张券给哪个物品,是一个需要精心决策的问题。

一个简单的贪心策略,比如“对于每个物品,找到能用的、面额最大的优惠券”,很容易被证明是错误的。可能一个物品为了一个很小的优惠,用掉了一张“神券”,而这张神券本可以给另一个物品带来巨大的优惠。

面对 \(N, M \le 10^6\) 的数据范围,我们需要一个高效的算法,通常是 \(O(N\log N)\) 或类似级别的复杂度。我们从分析部分分数据范围的特殊性质入手,逐步推导出正解。


Subtask 1: \(N, M \le 10\)

思路:暴力搜索 / 状态压缩DP

数据范围极小,允许指数级复杂度的算法。我们可以尝试所有可能的“物品-优惠券”匹配方案。

一个直接的思路是使用回溯法(backtracking)。我们定义一个函数 dfs(item_index, used_coupons_mask),表示正在为第 item_index 个物品决策,而已使用的优惠券集合由位掩码 used_coupons_mask 表示。

在函数中,我们 для第 item_index 个物品:

  1. 尝试以折扣价 \(b_i\) 购买,然后递归到 dfs(item_index + 1, used_coupons_mask)。
  2. 遍历所有未被使用的优惠券 \(j\)(通过检查 used_coupons_mask)。如果该券满足 \(a_i \ge w_j\),我们就尝试使用它,然后递归到 dfs(item_index + 1, used_coupons_mask | (1 << j))。

所有递归路径的尽头(item_index == n)的花费的最小值就是答案。

复杂度:最坏情况下,每个物品都可以和多个优惠券匹配,状态数巨大,复杂度大致为 \(O(N!)\) 或更高,但足以通过 \(N, M \le 10\) 的数据。

代码实现 (伪代码)

long long min_cost = infinity;
vector<bool> coupon_used(m, false);

void solve(int item_idx, long long current_cost) {
    if (item_idx == n) {
        min_cost = min(min_cost, current_cost);
        return;
    }

    // 选项1: 使用折扣价
    solve(item_idx + 1, current_cost + items[item_idx].b);

    // 选项2: 尝试使用每个可用的优惠券
    for (int j = 0; j < m; ++j) {
        if (!coupon_used[j] && items[item_idx].a >= coupons[j].w) {
            coupon_used[j] = true;
            solve(item_idx + 1, current_cost + items[item_idx].a - coupons[j].v);
            coupon_used[j] = false; // 回溯
        }
    }
}

Subtasks 2 & 3: \(a_i = b_i\)

思路:贪心

这个特殊性质是一个巨大的突破口。当 \(a_i = b_i\) 时,使用折扣价购买的花费是 \(a_i\)。而使用优惠券 \(j\) 的花费是 \(a_i - v_j\)。显然,只要能用优惠券,就一定比用原价(即折扣价)划算,因为 \(v_j > 0\)。

此时,问题转化为:为尽可能多的物品,匹配上尽可能好的优惠券。
我们的目标是最大化所有使用的优惠券的总面额 \(\sum v_j\)。

一个物品 \(i\) 可以使用优惠券 \(j\) 的条件是 \(a_i \ge w_j\)。
一个看似正确的贪心是:将物品按 \(a_i\) 升序排序,将优惠券按 \(w_j\) 升序排序。然后遍历每个物品,将所有满足 \(w_j \le a_i\) 的可用优惠券收集起来,并为当前物品选择其中面额 \(v_j\) 最大的一个。

这个策略是正确的。我们用一个优先队列(大根堆)来维护当前所有可用的优惠券的面额。

算法流程:

  1. 将物品按 \(a_i\) 升序排序。
  2. 将优惠券按 \(w_j\) 升序排序。
  3. 初始化总花费 total_cost = sum(all a_i)。我们先假设所有物品都用原价购买。
  4. 初始化一个大根堆 pq 和一个优惠券指针 coupon_ptr = 0。
  5. 遍历排序后的物品 \(i=0 \to n-1\):
    a. 将所有满足 coupons[coupon_ptr].w <= items[i].a 的优惠券的面额 v push进大根堆 pq。同时移动 coupon_ptr。
    b. 如果 pq 非空,说明有可用的优惠券。我们取出堆顶(面额最大的券),用它来减免费用。即 total_cost -= pq.top(),然后 pq.pop()。
  6. 最终的 total_cost 就是答案。

启发:这个子任务告诉我们,将物品和优惠券排序,并用一个数据结构(如优先队列)动态维护可用资源是一个非常有效的处理此类匹配问题的框架。

代码实现:

#include <iostream>
#include <vector>
#include <algorithm>
#include <queue>
#include <numeric>

struct Item { long long id, a, b; };
struct Coupon { long long id, w, v; };

// ... sort comparators ...

void solve_subtask_a_equals_b() {
    // ... read data ...
    // ... sort items by a, coupons by w ...
    
    long long total_cost = 0;
    for(const auto& item : items) {
        total_cost += item.a;
    }

    std::priority_queue<long long> pq;
    int coupon_idx = 0;
    for (int i = 0; i < n; ++i) {
        while (coupon_idx < m && coupons[coupon_idx].w <= items[i].a) {
            pq.push(coupons[coupon_idx].v);
            coupon_idx++;
        }
        if (!pq.empty()) {
            total_cost -= pq.top();
            pq.pop();
        }
    }
    std::cout << total_cost << std::endl;
}

Subtasks 5 & 6: \(\max_j w_j \le \min_i a_i\)

思路:贪心

这个性质意味着任何一张优惠券都可以用于任何一个物品。物品和优惠券之间的使用门槛限制消失了。

对于每个物品 \(i\),我们可以在 \(b_i\) 和 \(a_i - v_j\) 之间选择。使用优惠券 \(j\) 带来的节省(相比于折扣价)是 b_i - (a_i - v_j) = v_j - (a_i - b_i)。

我们希望总节省量最大。我们最多可以用掉 \(\min(n, m)\) 张优惠券。
我们可以把每张优惠券看作一个提供基础优惠 \(v_j\) 的物品,而每个物品 \(i\) 则会产生一个 a_i - b_i 的“附加成本”。为了最大化节省 v_j - (a_i - b_i),我们应该:

  1. 用面额大的 \(v_j\) 去匹配。
  2. 用在 a_i - b_i 小的物品上。

算法流程:

  1. 计算出每个物品的“附加成本” d_i = a_i - b_i。
  2. 将所有优惠券按面额 \(v_j\) 降序排序。
  3. 将所有物品按它们的“附加成本” \(d_i\) 升序排序。
  4. 初始总花费为所有物品的折扣价之和 sum(b_i)。
  5. 遍历前 \(\min(n, m)\) 个优惠券和物品。对于第 \(k\) 个配对(第 \(k\) 大的 \(v_j\) 和第 \(k\) 小的 \(d_i\)),计算节省量 saving = v_j - d_i。
  6. 如果 saving > 0,说明使用这张券是划算的。我们将这份节省从总花费中减去 total_cost -= saving。

启发:这个子任务让我们关注到了使用优惠券相对于折扣价的净收益,即 v_j - (a_i - b_i)。这个思想对正解至关重要。


正解:反悔贪心 (Regretful Greedy)

结合前面子任务的启发,我们来解决完整问题。

  • 从子任务2/3,我们学会了排序+优先队列的框架。
  • 从子任务5/6,我们学会了比较 v_j 和 a_i - b_i 的大小。

我们将这两者结合,得到一种称为反悔贪心的精妙解法。

核心思想:
我们依然采用“按 \(a_i\) 排序并遍历物品,用优先队列维护可用券”的框架。
对于每个物品 \(i\),我们做一次临时决策,然后提供一个反悔的机会。

算法流程:

  1. 将物品按 \(a_i\) 升序排序,优惠券按 \(w_j\) 升序排序。
  2. 初始化总花费 total_cost = 0。
  3. 初始化一个大根堆 pq。这个堆里存放的不再仅仅是优惠券面额,而是广义的“优惠机会”。
  4. 遍历每个物品 \(i\):
    a. 更新资源池:将所有新近可用的优惠券(满足 \(w_j \le a_i\))的面额 \(v_j\) 加入 pq。
    b. 决策:现在需要为物品 \(i\) 付款。我们有两种选择:
    - 花费 \(b_i\)。
    - 花费 \(a_i - V\),其中 \(V\) 是 pq 中最大的优惠机会。
    c. 贪心选择:我们比较这两种花费。
    - 如果 pq 为空,或者 b_i <= a_i - V(等价于 V <= a_i - b_i),说明用当前最好的优惠券也不如直接用折扣价划算。我们选择支付 \(b_i\)。
    - 否则(即 V > a_i - b_i),使用优惠券更优。我们选择支付 \(a_i - V\)。
    d. 反悔机制:这是算法的精髓。
    - 如果我们选择了支付 \(b_i\):那么最好的优惠机会 V 仍然在 pq 中,可以留给未来的物品,这本身就是一种“反悔”的体现——我们放弃了眼前的机会,以图未来的更大利益。
    - 如果我们选择了支付 \(a_i - V\):我们用掉了机会 V。但万一这个 V 对未来的某个物品 k 来说是“神券”呢?我们必须提供一个反悔的途径。
    这个途径就是:未来的物品 k 可以来“抢走”本属于物品 i 的机会 V,但它必须付出代价,即把物品 i 的购买方式变回折扣价 b_i。
    这个过程对总花费的影响是:物品 i 的花费从 a_i - V 变为 b_i,增加了 b_i - (a_i - V) = V - (a_i - b_i)。物品 k 的花费则减少了。
    为了在我们的模型中体现这一点,我们在从 pq 中拿出 V 之后,需要再将 a_i - b_i 这个值放回 pq。
    这相当于,物品 i 使用了优惠券 V 后,自身变成了一个新的“优惠机会”,其面额为 a_i - b_i。如果未来的物品 k 选择了这个面额为 a_i - b_i 的机会,就等价于它抢走了 V,并补偿了 i,使其最终花费为 b_i。

总结一下决策过程:
对于物品 i:

  1. 把能用的新券 v_j 加入大根堆 pq。
  2. 如果 pq 不为空,取出堆顶 V。
  3. 比较 V 和 a_i - b_i:
    • 若 V > a_i - b_i:说明用券划算。总花费增加 a_i - V。从 pq 中弹出 V,然后把 a_i - b_i 压入 pq(留下反悔机会)。
    • 若 V <= a_i - b_i:说明用折扣价划算。总花费增加 b_i。把 V 再放回 pq 中(因为没用它)。
  4. 如果 pq 为空,只能用折扣价,总花费增加 b_i。

这个过程可以简化为:

  1. 把能用的新券 v_j 加入大根堆 pq。
  2. 将 a_i - b_i 这个“使用折扣价”的选项也看作一个优惠机会,加入 pq。
  3. pq 中现在包含了所有可用的优惠券,以及“使用折扣价”这个选项(其优惠力度为 a_i-b_i)。
  4. 我们为当前物品选择 pq 中最好(最大)的一个优惠机会 V。
  5. 总花费增加 a_i - V。V 被消耗,从 pq 弹出。

这两种描述是等价的,第二种在实现上可能更简洁。但第一种更能体现“反悔”的逻辑。下面的代码采用第一种描述的逻辑,更清晰。

AC 代码

#include <iostream>
#include <vector>
#include <algorithm>
#include <queue>

// 为提高可读性,定义物品和优惠券的结构体
struct Item {
    long long a, b;
};

struct Coupon {
    long long w, v;
};

// 自定义比较函数,用于对物品按原价a升序排序
bool compareItems(const Item& x, const Item& y) {
    return x.a < y.a;
}

// 自定义比较函数,用于对优惠券按门槛w升序排序
bool compareCoupons(const Coupon& x, const Coupon& y) {
    return x.w < y.w;
}

int main() {
    // 使用快速I/O处理大规模数据
    std::ios_base::sync_with_stdio(false);
    std::cin.tie(NULL);

    int n, m;
    std::cin >> n >> m;

    std::vector<Item> items(n);
    for (int i = 0; i < n; ++i) {
        std::cin >> items[i].a >> items[i].b;
    }

    std::vector<Coupon> coupons(m);
    for (int i = 0; i < m; ++i) {
        std::cin >> coupons[i].w >> coupons[i].v;
    }

    // 排序是贪心算法的关键预处理步骤
    std::sort(items.begin(), items.end(), compareItems);
    std::sort(coupons.begin(), coupons.end(), compareCoupons);

    long long total_cost = 0;
    // 大根堆,用于存放所有当前可用的“优惠机会”
    std::priority_queue<long long> pq;
    int coupon_idx = 0; // 优惠券数组的指针

    // 遍历每一个物品
    for (int i = 0; i < n; ++i) {
        long long current_a = items[i].a;
        long long current_b = items[i].b;

        // 将所有对于当前物品可用的新优惠券加入优先队列
        while (coupon_idx < m && coupons[coupon_idx].w <= current_a) {
            pq.push(coupons[coupon_idx].v);
            coupon_idx++;
        }

        // 为当前物品做购买决策
        if (pq.empty()) {
            // 没有任何优惠券可用,只能用折扣价
            total_cost += current_b;
        } else {
            // 获取当前最好的优惠机会
            long long best_opportunity = pq.top();
            
            // 比较:使用最好机会的成本 vs. 使用折扣价的成本
            // a_i - v < b_i  <=>  v > a_i - b_i
            if (best_opportunity > current_a - current_b) {
                // 使用优惠机会更划算
                total_cost += current_a - best_opportunity;
                pq.pop(); // 消耗掉这个最好的机会

                // 【反悔机制】
                // 将“物品i反悔,改用折扣价”这个选项作为新的优惠机会放入堆中
                // 这个新机会的“面额”是 a_i - b_i
                pq.push(current_a - current_b);
            } else {
                // 使用折扣价更划算 (或一样)
                total_cost += current_b;
                // 最好的机会没有被使用,它仍然在堆里,留给后续更合适的物品
            }
        }
    }

    std::cout << total_cost << std::endl;

    return 0;
}
posted @ 2025-07-25 09:22  surprise_ying  阅读(116)  评论(0)    收藏  举报