Tired

Tired

单调队列/优先队列

1. 问题转化

给定序列 \(a_1, a_2, \dots, a_n\),每个位置修改代价为 \(c_i\)。可以支付 \(c_i\) 将 \(a_i\) 改为任意值。要求修改后序列的每个前缀和 \(s_i = \sum_{j=1}^i a_j\) 都满足 \(s_i \bmod r \neq 0\)。

设原始前缀和模 \(r\) 为:

\[pre[i] = \left( \sum_{j=1}^i a_j \right) \bmod r \]

如果我们在位置 \(l\) 修改 \(a_l\),相当于给从 \(l\) 开始的所有前缀和加上同一个偏移量 \(\Delta \pmod r\)。因为 \(a_l\) 可以任意改,所以 \(\Delta\) 可以任意选择。

选择一些位置修改后,整个序列被这些修改点切分成若干段:

  • 第一段(第一个修改点之前):没有偏移,所以原始前缀和模 \(r\) 不能为 \(0\)。
  • 后续每一段(从某个修改点之后到下一个修改点之前):可以为该段选择一个统一的偏移量,使得段内所有位置的前缀和模 \(r\) 都不为 \(0\)。

2. 关键结论

对于一段区间,如果其中原始前缀和模 \(r\) 的取值包含了全部 \(r\) 种余数,那么无论加上什么偏移量,总有一个位置会变成 \(0\)。反之,如果该区间内原始前缀和模 \(r\) 的不同取值数量小于 \(r\),那么一定存在至少一个偏移量,使得加上偏移后所有值都不为 \(0\)。

因此,每一段合法的条件是:

  • 第一段:不包含余数 \(0\)。
  • 后续段:不包含全部 \(r\) 种余数。

3. 预处理

3.1 计算前缀和模 \(r\)

3.2 找到第一个前缀和为 \(0\) 的位置

如果 f > i,说明前 \(i\) 个位置的原始前缀和模 \(r\) 都不为 \(0\),可以完全不修改。

3.3 双指针求 limit

对于每个右端点 \(i\),找到最大的左端点 \(L\),使得区间 \([L, i]\) 包含全部 \(r\) 种余数。记这个 \(L\) 为 limit[i]。

那么对于任何区间 \([l, i]\),如果 \(l > limit[i]\),则该区间不包含全部 \(r\) 种余数,即合法。

双指针实现:

int s = 1, t = 1;
vector<int> limit(n + 1, 0);
if(r <= n) {
    vector<int> cnt(r + 1, 0);
    int kind = 0;
    for(; t <= n; t ++) {
        int x = pre[t];
        if(cnt[x] == 0) kind ++;
        cnt[x] ++;
        while(kind == r) {
            int p = pre[s];
            cnt[p] --; s ++;
            if(cnt[p] == 0) kind --;
        }
        limit[t] = s - 1;
    }
}

4. DP 定义与转移

设 \(dp[i]\) 表示:处理完前 \(i\) 个位置,使得前 \(i\) 个位置的前缀和模 \(r\) 都不为 \(0\) 的最小总代价。\(dp[0] = 0\)。

对于第 \(i\) 个位置,有两种情况:

情况 A:不修改任何位置,前 \(i\) 个位置天然合法

这要求原始序列的前 \(i\) 个前缀和模 \(r\) 都不为 \(0\),即:

\[first\_zero > i \]

此时代价为 \(0\)。

情况 B:在某个位置 \(l\) 进行修改,\(1 \le l \le i\)

在位置 \(l\) 支付代价 \(c[l]\),前 \(l-1\) 个位置必须已经合法,最小代价为 \(dp[l-1]\)。修改位置 \(l\) 后,从 \(l\) 到 \(i\) 这一段可以自由选择偏移量,只要该段不包含全部 \(r\) 种余数即可。根据双指针结果,这要求:

\[l > limit[i] \quad \text{即} \quad l \ge limit[i] + 1 \]

因此,总代价为:

\[dp[l-1] + c[l] \]

转移方程为:

\[dp[i] = \min\left( \begin{cases} 0, & \text{若 } first\_zero > i \\ \min\limits_{l = limit[i]+1}^{i} \big(dp[l-1] + c[l]\big), & \text{否则} \end{cases} \right) \]

如果 first_zero > i 不成立,则第一项视为无穷大。

5. 单调队列优化

转移中需要求:

\[\min_{l \ge limit[i]+1} \big(dp[l-1] + c[l]\big) \]

定义:

\[val[l] = dp[l-1] + c[l] \]

则转移变为:

\[dp[i] = \min\left( best\_no\_modify,\ \min_{l \ge limit[i]+1} val[l] \right) \]

随着 \(i\) 增大,\(limit[i]\) 非递减,所以下界 \(limit[i]+1\) 也非递减。因此可以用单调队列维护 \(val[l]\) 的最小值。

单调队列操作:

  • 队列中存储索引 \(l\),对应的 \(val[l]\) 单调递增(队头最小)。
  • 对于当前的 \(i\),计算 \(L = limit[i] + 1\)。
  • 弹出队头所有 \(l < L\) 的元素。
  • 此时队头就是满足 \(l \ge L\) 的最小 \(val[l]\)。
  • 计算:
ll best = (q.empty() ? INF : val[q.front()]);
ll isfirst = (f > i ? 0 : INF);
dp[i] = min(best, isfirst);
  • 然后,为了后续转移,需要将 \(l = i+1\) 的 \(val[i+1]\) 加入队列:
if(i < n) {
    val[i + 1] = dp[i] + c[i + 1];
    while(!q.empty() && val[q.back()] >= val[i + 1]) q.pop_back();
    q.push_back(i + 1);
}

6. 两个版本实现比较

版本一:优先队列 + 直接 DP

  • DP 定义:\(dp[i]\) 表示前 \(i\) 个位置的最小总代价,直接输出 \(dp[n]\)。
  • 转移维护:使用小根堆(priority_queue)维护 \(val[l] = dp[l-1] + c[l]\),通过懒惰删除弹出 \(l \le limit[i]\) 的非法元素。
  • 初始化:对于 \(i < first\_zero\),直接令 \(dp[i] = 0\),并将 \(\{c[i], i\}\) 入队。
  • 主循环:从 \(i = first\_zero\) 到 \(n\),弹出非法队头,加入新候选,取堆顶作为 \(dp[i]\)。
  • 复杂度:\(O(n \log n)\)。
  • 优点:DP 定义直观,代码简洁,容易理解。
  • 缺点:常数稍大,但 \(n \le 5 \times 10^5\) 完全够用。

关键代码片段:

vector<ll> dp(n + 1, INF); dp[0] = 0;
int f = 0;
priority_queue<pair<ll, int>, vector<pair<ll, int>>, greater<pair<ll, int>>> q;
for(int i = 1; i <= n; i ++) {
    if(pre[i] != 0) {
        dp[i] = 0; q.push({c[i], i});
        continue;
    }
    f = i; break;
}
if(f == 0) {cout << "0\n"; return;}
for(int i = f; i <= n; i ++) {
    int left = limit[i];
    while(!q.empty() && q.top().second <= left) q.pop();
    q.push({dp[i - 1] + c[i], i});
    dp[i] = q.top().first;
}

版本二:单调队列 + 修改点 DP(需修复边界)

  • DP 定义:\(dp[i]\) 表示在位置 \(i\) 进行修改,且前 \(i-1\) 个位置已经合法的最小代价。最后还需要从所有可能的最后一个修改点中取最小值,并考虑最后一段是否合法。
  • 转移维护:使用双端队列(deque)维护 \(val[l] = dp[l-1] + c[l]\),保证队列中 \(val\) 单调递增,队头为最小值。
  • 初始化:\(dp[0] = 0\),\(val[1] = c[1]\),将 \(1\) 入队。
  • 主循环:对于每个 \(i\),计算 \(L = limit[i] + 1\),弹出队头 \(l < L\) 的元素,取队头 \(val\) 作为候选,同时考虑“前 \(i\) 个位置天然合法”的情况(即 \(first\_zero > i\)),得到 \(dp[i]\)。然后将 \(val[i+1] = dp[i] + c[i+1]\) 加入队列。
  • 边界问题:若不存在 \(pre[i] == 0\),first_zero 应初始化为 \(n+1\),否则 isfirst 条件会失效,导致输出非零值。修复后正确。
  • 复杂度:\(O(n)\)。
  • 优点:理论复杂度更优,常数小。
  • 缺点:DP 定义稍绕,最后需要额外处理最后一个修改点和最后一段的合法性,边界条件较多,容易出错。

关键代码片段:

int f = n + 1;
for(int i = 1; i <= n; i ++) {
    if(pre[i] == 0) {
        f = i; break;
    }
}

vector<ll> dp(n + 1, INF), val(n + 2, INF); dp[0] = 0; val[1] = c[1];
deque<int> q; q.push_back(1);

for(int i = 1; i <= n; i ++) {
    int l = limit[i] + 1;
    while(!q.empty() && q.front() < l) q.pop_front();

    ll best = (q.empty() ? INF : val[q.front()]);
    ll isfirst = (f > i ? 0 : INF);
    dp[i] = min(best, isfirst);

    if(i < n) {
        val[i + 1] = dp[i] + c[i + 1];
        while(!q.empty() && val[q.back()] >= val[i + 1]) q.pop_back();
        q.push_back(i + 1);
    }
}

对比总结

特性 版本一(优先队列) 版本二(单调队列)
DP 定义 \(dp[i]\) 前 \(i\) 个位置最小代价 \(dp[i]\) 在 \(i\) 修改的最小代价
转移维护 小根堆 + 懒惰删除 单调队列
时间复杂度 \(O(n \log n)\) \(O(n)\)
空间复杂度 \(O(n)\) \(O(n)\)
代码难度 较简单,边界少 稍复杂,需处理最后一段和边界
推荐度 足够通过,推荐 追求极致效率时使用

对于本题数据范围 \(\sum n \le 5 \times 10^5\),两种方法均可通过。优先队列版本代码更直观,不容易出错;单调队列版本效率更高,但需要注意 first_zero 的初始化和最后答案的取值。

#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const ll INF = 1e18;

void solve() {
    int n, r; cin >> n >> r;
    vector<ll> a(n + 1), c(n + 1), pre(n + 1, 0);
    for(int i = 1; i <= n; i ++) cin >> a[i];
    for(int i = 1; i <= n; i ++) cin >> c[i];
    for(int i = 1; i <= n; i ++) pre[i] = (pre[i - 1] + a[i]) % r;
    
    int s = 1, t = 1;
    vector<int> limit(n + 1, 0);
    if(r <= n) {
        vector<int> cnt(r + 1, 0);
        int kind = 0;
        for(; t <= n; t ++) {
            int x = pre[t];
            if(cnt[x] == 0) kind ++;
            cnt[x] ++;
            while(kind == r) {
                int p = pre[s];
                cnt[p] --; s ++;
                if(cnt[p] == 0) kind --;
            }
            limit[t] = s - 1;
        }
    }

    //
    vector<ll> dp(n + 1, INF); dp[0] = 0;
    int f = 0;
    priority_queue<pair<ll, int>, vector<pair<ll, int>>, greater<pair<ll, int>>> q;
    for(int i = 1; i <= n; i ++) {
        if(pre[i] != 0) {
            dp[i] = 0; q.push({c[i], i});
            continue;
        }
        f = i; break;
    }
    if(f == 0) {cout << "0\n"; return;}
    for(int i = f; i <= n; i ++) {
        int left = limit[i];
        while(!q.empty() && q.top().second <= left) q.pop();
        q.push({dp[i - 1] + c[i], i});
        dp[i] = q.top().first;
    }
    //单调队列版本
    // int f = n + 1;
    // for(int i = 1; i <= n; i ++) {
    //     if(pre[i] == 0) {
    //         f = i; break;
    //     }
    // }
    
    // vector<ll> dp(n + 1, INF), val(n + 2, INF); dp[0] = 0; val[1] = c[1];
    // deque<int> q; q.push_back(1);

    // for(int i = 1; i <= n; i ++) {
    //     int l = limit[i] + 1;
    //     while(!q.empty() && q.front() < l) q.pop_front();

    //     ll best = (q.empty() ? INF : val[q.front()]);
    //     ll isfirst = (f > i ? 0 : INF);
    //     dp[i] = min(best, isfirst);

    //     if(i < n) {
    //         val[i + 1] = dp[i] + c[i + 1];
    //         while(!q.empty() && val[q.back()] >= val[i + 1]) q.pop_back();
    //         q.push_back(i + 1);
    //     }
    // }
    
    cout << dp[n] << "\n";
}

int main() {
    ios::sync_with_stdio(false); cin.tie(0); cout.tie(0);
    int _ = 1; cin >> _;
    while(_--) solve();
    return 0;
}
posted @ 2026-10-13 14:39  thenrify  阅读(6)  评论(0)    收藏  举报