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\) 为:
如果我们在位置 \(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\),即:
此时代价为 \(0\)。
情况 B:在某个位置 \(l\) 进行修改,\(1 \le l \le i\)
在位置 \(l\) 支付代价 \(c[l]\),前 \(l-1\) 个位置必须已经合法,最小代价为 \(dp[l-1]\)。修改位置 \(l\) 后,从 \(l\) 到 \(i\) 这一段可以自由选择偏移量,只要该段不包含全部 \(r\) 种余数即可。根据双指针结果,这要求:
因此,总代价为:
转移方程为:
如果 first_zero > i 不成立,则第一项视为无穷大。
5. 单调队列优化
转移中需要求:
定义:
则转移变为:
随着 \(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;
}

浙公网安备 33010602011771号