贪心
什么是贪心
在每次决策的时候都采取当前意义下的最优策略,一般贪心问题的难点在于最优策略的选择。
例题:有 \(n\) 项工作,每项工作从 \(s_i\) 时间开始,\(t_i\) 时间结束,对于每项工作你都可以选择是否要参加,若参加则必须全程参与。那么在不能同时参与多个工作的情况下,最多可以参加几个工作?(\(n \le 10^5; s_i,t_i \le 10^9\))
懒狗策略(错误)
时间越长的工作越耽误时间,那么考虑按照工作时长排序,先选择做工作时间短的。
反例:(1, 4), (3, 5), (4, 7) 三个工作,按照这个策略会优先选择 (3, 5) 这个工作,从而剩下的工作都做不了了;而实际上最好的方案是做 (1, 4) 和 (4, 7) 这两个工作。
卷王策略(错误)
开始工作得越早,就能做更多的工作,考虑按照 \(s_i\) 排序,先做开始时间造的工作。
反例:(1, 10), (3, 5), (7, 9) 三个工作,按照这个策略会优先选择 (1, 10) 这个工作,从而剩下的工作都做不了了;而实际上最好的方案是做 (3, 5) 和 (7, 9) 这两个工作。
正确策略
按照 \(t_i\) 排序,先做结束时间早的。
证明:优先考虑结束时间可以在选择工作数量相同的情况下,为后续的选择提供更多的时间。
例题:CF1768C Elemental Decompress
题意:给定一个序列 \(a\),请构造两个排列 \(p, q\),使得 \(a_i = \max (p_i, q_i)\),可能无解。
数据范围:\(n \le 200000\)
解题思路
可以按照 \(a[i]\) 的大小,按从大到小的顺序依次填数。
比如 \(a = [5, 3, 4, 2, 5]\)
首先填第一个数和第五个数(\(5\) 最大),因为要使得 \(a\) 中两个位置为 \(5\),所以不妨让 \(p[1] = 5, q[5] = 5\)
接着填第三个数,只需要一个 \(4\),不妨让 \(p[3] = q[3] = 4\)
同理,让 \(p[2] = q[2] = 3, p[4] = q[4] = 2\),此时 \(p\) 和 \(q\) 的一部分位置已经填过数字,还剩一些数字没有使用;
重复从大到小的填数顺序,将 \(p\) 和 \(q\) 中剩余的数也同样从大到小把一开始没填的位置填上,因此 \(p[5] = 1, q[1] = 1\)
注意按以上策略填完数字后还需要重新检查一遍是否满足要求,不满足说明无解。
参考代码
#include <cstdio>
#include <vector>
#include <algorithm>
using namespace std;
const int N = 200005;
int a[N], p[N], q[N];
bool u1[N], u2[N];
vector<int> idx[N];
int main()
{
int t;
scanf("%d", &t);
while (t--) {
int n;
scanf("%d", &n);
for (int i = 1; i <= n; i++) {
idx[i].clear(); p[i] = q[i] = 0;
u1[i] = u2[i] = false;
}
for (int i = 1; i <= n; i++) {
scanf("%d", &a[i]);
idx[a[i]].push_back(i);
}
bool flag = true;
for (int i = n; i >= 1; i--) {
if (idx[i].size() > 2) {
flag = false; break;
}
if (idx[i].size() == 2) {
p[idx[i][0]] = i; q[idx[i][1]] = i;
u1[i] = u2[i] = true;
} else if (idx[i].size() == 1) {
p[idx[i][0]] = q[idx[i][0]] = i;
u1[i] = u2[i] = true;
}
}
if (!flag) {
printf("NO\n"); continue;
}
int num1 = n, num2 = n;
for (int i = n; i >= 1; i--) {
if (idx[i].size() == 2) {
int i1 = idx[i][0], i2 = idx[i][1];
while (num1 >= 0 && u1[num1]) num1--;
p[i2] = num1; u1[num1] = true;
while (num2 >= 0 && u2[num2]) num2--;
q[i1] = num2; u2[num2] = true;
}
}
for (int i = 1; i <= n; i++)
if (a[i] != max(p[i], q[i])) {
flag = false; break;
}
if (flag) {
printf("YES\n");
for (int i = 1; i <= n; i++) printf("%d%c", p[i], i == n ? '\n' : ' ');
for (int i = 1; i <= n; i++) printf("%d%c", q[i], i == n ? '\n' : ' ');
} else printf("NO\n");
}
return 0;
}
例:CF1779C Least Prefix Sum
题意:给定 \(n\) 个数,每次修改可以让一个数乘以 \(-1\),求最少操作次数使得前 \(m\) 个数的和是所有前缀和中最小的。
数据范围:\(m \le n \le 200000\)
解题思路
首先转换题意:
若 \(i<m\),则前 \(i\) 个数的和等于前 \(m\) 个数的和减去第 \(i+1\) 到第 \(m\) 个数的和;
若 \(i>m\),则前 \(i\) 个数的和等于前 \(m\) 个数的和加上第 \(m+1\) 到第 \(i\) 个数的和。
所以题目等价于要求,在修改后,前 \(m\) 个数的所有后缀和都不大于 \(0\)(第 \(1 \sim m\) 个数的和可以大于 \(0\));第 \(m+1\) 到第 \(n\) 个数的所有前缀和都不小于 \(0\)
当前 \(m\) 个数的某个后缀和大于 \(0\) 时,显然这时我们应该去修改最大的正数;当第 \(m+1\) 到第 \(n\) 个数的某个前缀和小于 \(0\) 时,显然这时我们应该去修改最小的负数;以上操作可以通过大根堆与小根堆来维护当前的数。
参考代码
#include <cstdio>
#include <queue>
using namespace std;
typedef long long LL;
const int N = 200005;
int a[N];
int main()
{
int t;
scanf("%d", &t);
while (t--) {
int n, m;
scanf("%d%d", &n, &m);
for (int i = 1; i <= n; i++) {
scanf("%d", &a[i]);
}
int ans = 0;
LL sum = 0;
priority_queue<int> q1;
for (int i = m; i > 1; i--) {
q1.push(a[i]); sum += a[i];
if (sum > 0) {
ans++;
int x = q1.top();
sum -= x; q1.pop();
sum -= x; q1.push(-x);
}
}
sum = 0;
priority_queue<int, vector<int>, greater<int>> q2;
for (int i = m + 1; i <= n; i++) {
q2.push(a[i]); sum += a[i];
if (sum < 0) {
ans++;
int x = q2.top();
sum -= x; q2.pop();
sum -= x; q2.push(-x);
}
}
printf("%d\n", ans);
}
return 0;
}
例:P1248 加工生产调度
此题较难,其结论可以记一下。
如何贪心?
- 根据 \(A_i\) 升序?
- 根据 \(B_i\) 降序?
- 根据 \(A_i - B_i\) 升序?
- 前半段根据 \(A_i\) 升序,后半段根据 \(B_i\) 降序?
为了要使总的空闲时间最少,就要先加工 \(A_i\) 小的,最后加工 \(B_i\) 小的产品
贪心策略
先做 \(A_i < B_i\) 的产品,并将这些产品按 \(A_i\) 升序排序,之后再做 \(A_i \ge B_i\) 的产品,并将这些产品按 \(B_i\) 降序排序
参考代码
#include <cstdio>
#include <algorithm>
using namespace std;
const int N = 1005;
int sign(int x) {
return x > 0 ? 1 : (x == 0 ? 0 : -1);
}
struct Product {
int a, b, id;
bool operator<(const Product& other) const {
int s1 = sign(a - b), s2 = sign(other.a - other.b);
if (s1 != s2) return s1 < s2;
if (s1 < 0) return a < other.a;
else return b > other.b;
}
};
Product p[N];
int main()
{
int n;
scanf("%d", &n);
for (int i = 1; i <= n; i++) {
scanf("%d", &p[i].a); p[i].id = i;
}
for (int i = 1; i <= n; i++) scanf("%d", &p[i].b);
sort(p + 1, p + n + 1);
int time_a = 0, time_b = 0;
for (int i = 1; i <= n; i++) {
time_a += p[i].a;
time_b = max(time_a, time_b) + p[i].b;
}
printf("%d\n", time_b);
for (int i = 1; i <= n; i++) printf("%d%c", p[i].id, i == n ? '\n' : ' ');
return 0;
}
反悔贪心
普通贪心算法的特点是“目光短浅”,一旦做出决策就不可更改(一条路走到黑)。这种方法在很多问题上行之有效,但在某些存在约束冲突的复杂场景下,当前的局部最优决策可能导致后续无法得到全局最优解。
反悔贪心(又称“带撤销的贪心”)是对普通贪心的优化,它允许在后续决策中,如果发现更好的选择,可以“撤销”或“替换”之前的某个决策,从而修正解法,逼近全局最优。
这种机制通常借助一些数据结构来实现,用于快速找到“最不值得保留”的那个就决策。
例题:UVA1316 Supermarket
给定 \(n\) 个商品,每个商品有利润 \(p_i\) 和过期时间 \(d_i\),卖出一个商品需要 \(1\) 单位时间。求在商品不过期的前提下,能获得的最大总利润。
容易想到一个贪心策略:在最优解中,对于每个时间(天数)\(t\),应该在保证不卖出过期商品的前提下,尽量卖出利润前 \(t\) 大的商品。因此,可以依次考虑每个商品,动态维护一个满足性质的方案。
具体地说,把商品按照过期时间排序,建立一个初始为空的小根堆(节点权值为商品利润),然后扫描每个商品。
- 若当前商品的过期时间(天数)\(t\) 等于当前堆中的商品个数,则说明在目前方案下,前 \(t\) 天已经安排了 \(t\) 个商品卖出。此时,若当前商品的利润大于堆顶权值(即已经安排的 \(t\) 个商品中的最低利润),则替换掉堆顶(用当前商品替换掉原方案中利润最低的商品)。
- 若当前商品的过期时间(天数)大于当前堆中的商品个数,直接把该商品插入堆。
最终,堆里的所有商品就是需要卖出的商品,它们的利润之和就是答案,该算法的时间复杂度为 \(O(n \log n)\)。
参考代码
#include <iostream>
#include <algorithm>
#include <queue>
using namespace std;
const int N = 10005;
struct Item {
int p, d;
// 按过期时间从小到大排序
bool operator<(const Item& other) const {
return d < other.d;
}
};
Item a[N];
int main()
{
int n;
// 多组数据输入
while (cin >> n) {
for (int i = 0; i < n; i++) cin >> a[i].p >> a[i].d;
// 贪心策略第一步:将商品按过期时间从小到大排序
// 这样可以优先考虑过期时间早的商品,对于过期时间晚的商品,
// 后面还有机会处理(或者替换掉前面的低价值商品)
sort(a, a + n);
// 小根堆,维护当前已选商品的利润,堆顶为最小利润
// 用于后续“反悔”操作,替换掉利润最低的商品以获得更大收益
priority_queue<int, vector<int>, greater<int>> q;
int ans = 0, t = 0; // ans为总利润,t为当前已选商品的数量(也是占用的时间槽)
for (int i = 0; i < n; i++) {
// 如果当前已选商品数 + 1 小于等于当前商品的过期时间
// 说明还有时间槽可以卖出当前商品(只要在 d_i 之前卖出即可,
// 而既然按 d_i 排序,当前占用的 t 个时间槽都在 d_i 之前)
if (t + 1 <= a[i].d) {
t++;
ans += a[i].p;
q.push(a[i].p);
}
// 如果时间槽已满(即在前 a[i].d 天已经安排了 a[i].d 个商品),
// 但当前商品的利润比已选商品中利润最小的还要大,
// 那么进行“反悔”:放弃那个利润最小的,改卖当前这个利润更高的。
// 这样总数量不变,但总利润增加。
else if (!q.empty() && q.top() < a[i].p) {
ans += a[i].p - q.top(); // 减去最小利润,加上当前更大的利润
q.pop();
q.push(a[i].p);
}
}
cout << ans << "\n";
}
return 0;
}
例题:P3620 [APIO/CTSC2007] 数据备份
很容易发现,最优解中每两个配对的办公楼一定是相邻的。求出每两个相邻办公楼之间的距离,记为 \(D_1, D_2, D_3, \dots, D_{N-1}\)。于是问题可以转化为:从 \(D\) 中选出不超过 \(K\) 个数(对应办公楼的 \(K\) 个配对),使它们的和最小,并且相邻的两个数不能同时被选(任一办公楼都属于唯一的配对组)。

如果 \(K=1\),答案显然是 \(D\) 中的最小值。
如果 \(K=2\),答案一定是以下两种情况之一。
- 选择最小值 \(D_i\),以及除了 \(D_{i-1}, D_i, D_{i+1}\) 之外其他数中的最小值。
- 选择最小值 \(D_i\) 左右两侧的两个数,即 \(D_{i-1}\) 和 \(D_{i+1}\)。
这很容易证明:如果 \(D_{i-1}\) 和 \(D_{i+1}\) 都没有选,那么不选最小值 \(D_i\) 一定不优;如果 \(D_{i-1}\) 和 \(D_{i+1}\) 选了一个,那么把选了的那个换成 \(D_i\),答案也会变小,所以最优解必定是上面两种情况之一。
通过上述证明,也可以得到一个推论:在最优解中,最小值左右两侧的数要么同时选,要么都不选。
因此,可以先选上 \(D\) 中的最小值,然后把 \(D_{i-1},D_i,D_{i+1}\) 从 \(D\) 中删除,把 \(D_{i-1}+D_{i+1}-D_i\) 插入到 \(D\) 中刚才执行删除的位置。最后,求解“从新的 \(D\) 中选出不超过 \(K-1\) 个数,使它们的和最小,并且相邻两个数不能同时被选”这个子问题。
在这个子问题中,如果选了 \(D_{i-1}+D_{i+1}-D_i\) 这个数,相当于去掉 \(D_i\),换上 \(D_{i-1}\) 和 \(D_{i+1}\);如果没选,那么刚才选择最小值 \(D_i\) 显然是一步最优策略,这恰好涵盖了在推论中提到的最优解当中的两种情况。

综上所述,可以得到这样一个算法:
建立一个链表 \(L\),连接 \(N-1\) 个节点,节点上分别记录数值 \(D_1,D_2,D_3, \dots, D_{N-1}\),即每两个相邻办公楼之间的距离。再建立一个小根堆,与链表构成映射关系(即堆中也有 \(N-1\) 个节点,节点权值分别是 \(D_1, D_2, D_3, \dots, D_{N-1}\),并且同时记录对应的链表节点的指针)。
取出堆顶,把权值累加到答案中。设堆顶对应链表节点的指针为 \(p\),数值为 \(L(p)\)。在链表中删除 \(p\)、\(p\) 的前驱和 \(p\) 的后继,在同样的位置插入一个新节点 \(q\),记录数值 \(L(q) = L(前驱) + L(后继) - L(p)\)。在堆中也同时删除对应 \(p\) 的前驱和 \(p\) 的后继的节点,插入对应链表节点 \(q\),权值为 \(L(q)\) 的新节点。
重复上述操作 \(K\) 次,就得到了最终的答案。
参考代码
#include <cstdio>
#include <queue>
using namespace std;
// 常量定义
const int N = 1e5 + 5;
const int INF = 1e9 + 5;
// s: 办公楼位置
// d: 相邻办公楼的距离(差分数组)
// l, r: 双向链表数组,记录当前可选区间的左右邻居索引
int s[N], d[N], l[N], r[N];
// del: 标记节点是否已被逻辑删除(即已被合并处理)
bool del[N];
// 优先队列节点,存储距离值和对应的下标
struct Node {
int val, id;
bool operator>(const Node& other) const {
return val > other.val;
}
};
int main()
{
int n, k;
scanf("%d%d", &n, &k);
// 输入办公楼位置(有序)
for (int i = 1; i <= n; i++) scanf("%d", &s[i]);
// 计算相邻办公楼之间的距离
// 问题转化为:在 d[1] 到 d[n-1] 中选出 k 个互不相邻的数,使得和最小
for (int i = 1; i < n; i++) d[i] = s[i + 1] - s[i];
// 小根堆,维护当前所有可选区间的距离,用于贪心选择最小代价
priority_queue<Node, vector<Node>, greater<Node>> pq;
// 边界处理:将 d[0] 和 d[n] 设为无穷大,防止选到边界之外
d[0] = d[n] = INF;
// 初始化链表和优先队列
for (int i = 1; i < n; i++) {
l[i] = i - 1; r[i] = i + 1;
pq.push({d[i], i});
}
// 修正首尾的链表指向
r[0] = 1; l[n] = n - 1;
int ans = 0;
// 进行 k 次选择
for (int i = 0; i < k; i++) {
// 取出堆顶元素,如果该元素已被标记删除(说明作为邻居被合并过),则丢弃
while (!pq.empty() && del[pq.top().id]) pq.pop();
if (pq.empty()) break;
Node top = pq.top();
pq.pop();
int u = top.id, val = top.val;
// 累加当前选择的最小距离
ans += val;
// 【反悔贪心核心逻辑】
// 获取当前选定区间的左右邻居
int left = l[u], right = r[u];
// 计算反悔代价:
// 如果选了 d[u],但后来发现选 d[left] 和 d[right] 更优,
// 那么这三者的差额就是 d[left] + d[right] - d[u]。
// 将这个“反悔值”作为新节点加入堆中。如果以后选了这个新节点,
// 就相当于撤销了 d[u] 的选择,转而选择了 d[left] 和 d[right]。
d[u] = d[left] + d[right] - d[u];
pq.push({d[u], u});
// 标记左右邻居为已删除,因为它们已经被合并到 u 中了
// 如果之后选了反悔节点 u,实际上就是选了原来的 left 和 right
del[left] = del[right] = true;
// 更新链表,删除 left 和 right 节点,使 u 的新邻居指向 left 的左边和 right 的右边
// 这样维持了“不可选相邻区间”的性质(在新逻辑节点层面)
l[u] = l[left]; r[u] = r[right];
r[l[left]] = l[r[right]] = u;
}
printf("%d\n", ans);
return 0;
}
习题:P3545 [POI2012] HUR-Warehouse Store
解题思路
当一个人来的时候,如果当前的库存还够,直接满足;如果当前的库存不够,直接忽略这个人吗?如果前边有人买的比他多,可以放弃原来的人,把这个人换进去。
因此需要知道之前满足了的人里,买的最多的是谁?这可以用一个大根堆来维护。
过程中会不会出现:弹出了 \(1\) 个人,能补进去 \(2\) 个人?这是不可能的,因为如果要补 \(1\) 个人,新补的肯定也比弹出的小,最多踢 \(1\) 补 \(1\) 答案不变。
参考代码
#include <cstdio>
#include <queue>
#include <utility>
using ll = long long;
const int N = 250005;
int a[N], b[N];
bool ok[N];
int main()
{
int n; scanf("%d", &n);
for (int i = 1; i <= n; i++) scanf("%d", &a[i]);
for (int i = 1; i <= n; i++) scanf("%d", &b[i]);
ll cur = 0;
int ans = 0;
std::priority_queue<std::pair<int, int>> q; // 购买量,第几天
for (int i = 1; i <= n; i++) {
cur += a[i];
if (cur >= b[i]) {
cur -= b[i]; ans++; q.push({b[i], i});
} else if (!q.empty() && q.top().first > b[i]) {
cur += q.top().first - b[i]; q.pop(); q.push({b[i], i});
}
}
while (!q.empty()) {
ok[q.top().second] = true; q.pop();
}
printf("%d\n", ans);
for (int i = 1; i <= n; i++)
if (ok[i]) printf("%d ", i);
return 0;
}
习题:P2209 [USACO13OPEN] Fuel Economy S
难点:不知道每一站加油加多少,少了怕不够,多了怕浪费。
解题思路
按与起点的距离对所有加油站排序。
无解的判断:第一个站距离起点超过了 \(B\) 或者中间有两个点距离超过了 \(G\),或者最后一个加油站到终点距离超过了 \(G\)。
考虑“退油”这个操作,前面买的油可以在任何地方以原价退款(等价于当初没买油),在消耗了油的时候才把价格算进来,那么加油的时候可以统一加满,这样就解决了怕加多浪费这个顾虑。
如果油箱里有多种油,开一段距离,希望它消耗怎样的油?显然应该优先消耗价格最低的。
到达一个加油站之后,如果油箱里还有一堆油,这时候又能新加油,可以发现,油箱里比当前加油站贵的油都没意义了。所以可以把没用过的比当前加油站贵的油都淘汰了,然后用新油装满。
可以用单调队列模拟这个过程,维护当前油箱里边有多少单位什么价格的油。每一次从队首弹出开到当前加油站所消耗的油,算价钱;从队尾淘汰掉比这个加油站贵的油,用这个加油站的油装满油箱。
参考代码
#include <cstdio>
#include <utility>
#include <algorithm>
#include <deque>
using ll = long long;
const int N = 50005;
std::pair<int, int> sta[N]; // 距离,价格
int main()
{
int n, g, b, d; scanf("%d%d%d%d", &n, &g, &b, &d);
for (int i = 1; i <= n; i++) {
int x, y; scanf("%d%d", &x, &y);
sta[i] = {x, y};
}
std::sort(sta + 1, sta + n + 1);
sta[n + 1] = {d, 0};
ll ans = 0;
std::deque<std::pair<int, int>> dq; // 价格,油量
dq.push_back({0, b});
int cur = 0, vol = b;
for (int i = 1; i <= n + 1; i++) {
// 开到加油站
while (cur != sta[i].first) {
if (dq.empty()) {
printf("-1\n"); return 0;
}
if (dq.front().second > sta[i].first - cur) { // 最便宜的油足够开到加油站
ans += 1ll * (sta[i].first - cur) * dq.front().first;
vol -= (sta[i].first - cur);
dq.front().second -= (sta[i].first - cur);
cur = sta[i].first;
} else { // 最便宜的油全用完
ans += 1ll * dq.front().second * dq.front().first;
vol -= dq.front().second;
cur += dq.front().second;
dq.pop_front();
}
}
// 淘汰更贵的油
while (!dq.empty() && dq.back().first > sta[i].second) {
vol -= dq.back().second;
dq.pop_back();
}
// 加满油
if (vol < g) {
dq.push_back({sta[i].second, g - vol});
vol = g;
}
}
printf("%lld\n", ans);
return 0;
}
习题:P1016 [NOIP1999 提高组] 旅行家的预算
同 P2209 [USACO13OPEN] Fuel Economy S,本题的数据大多是小数,注意精度问题。
参考代码
#include <cstdio>
#include <deque>
#include <cmath>
#include <algorithm>
using namespace std;
const int N = 10;
const double EPS = 1e-6;
struct Station {
double d, p;
bool operator<(const Station& other) const {
return d != other.d ? d < other.d : p < other.p;
}
};
Station s[N];
struct Oil {
double p, v;
};
int main()
{
double d1, c, d2, p;
int n;
scanf("%lf%lf%lf%lf%d", &d1, &c, &d2, &p, &n);
for (int i = 1; i <= n; i++) scanf("%lf%lf", &s[i].d, &s[i].p);
sort(s + 1, s + n + 1);
double cur = 0, ans = 0;
deque<Oil> dq;
dq.push_back({p, c});
bool flag = true;
for (int i = 1; i <= n; i++) {
if (s[i].d > d1 - EPS) break;
double need = (s[i].d - cur) / d2;
while (!dq.empty()) {
if (dq.front().v > need - EPS) {
ans += dq.front().p * need;
dq.front().v -= need; need = 0;
break;
} else {
ans += dq.front().p * dq.front().v;
dq.pop_front(); need -= dq.front().v;
}
}
if (need > EPS) {
flag = false;
break;
}
double vol = (s[i].d - cur) / d2;
cur = s[i].d;
while (!dq.empty() && dq.back().p > s[i].p + EPS) {
vol += dq.back().v; dq.pop_back();
}
dq.push_back({s[i].p, vol});
}
double need = (d1 - cur) / d2;
while (!dq.empty()) {
if (dq.front().v > need - EPS) {
ans += dq.front().p * need; need = 0;
break;
} else {
ans += dq.front().p * dq.front().v; need -= dq.front().v;
dq.pop_front();
}
}
if (need > EPS) flag = false;
if (!flag) printf("No Solution\n");
else printf("%.2f\n", ans);
return 0;
}
习题:P10478 生日礼物
从长度为 \(N\) 的序列中,选择不超过 \(M\) 个连续的部分(即子段),使得这些部分的元素之和最大。
数据范围:\(N,M \le 10^5\)。
解题思路
将序列中相邻的正数合并为一个正数段,相邻的负数合并为一个负数段。因为在选择时,如果要选一个正数,为了和最大,一定会选其相邻的所有正数;负数同理。
序列首尾的负数段对增加总和没有贡献,直接舍弃。
假设合并后有 \(c\) 个正数段,如果 \(c \le M\),那么所有的正数段之和即为答案。
当 \(c \lt M\) 时,需要通过操作减少段数,直到段数等于 \(M\)。减少段数有两种方式:
- 放弃一个正数段:总和减去该正数段的绝对值,段数减 1。
- 合并两个正数段:即将它们中间的一个负数段也选上,总和加上该负数段(即减去其绝对值),段数减 1。
为了使总和减少得最少,每次从当前所有的段中,选择绝对值最小的一段进行操作。
用双向链表维护各个段及其左右相邻关系,方便合并操作。
用小根堆存储每个段的 ID 和权值的绝对值,每次取出绝对值最小的段进行处理。
当处理段 \(i\) 时,将其与左右相邻的段 \(L\) 和 \(R\) 合并为一个新段,新段的权值为 \(V_L + V_R + V_i\)。这样做不仅更新了总和,还保留了未来“反悔”的可能性(例如再次选择这个新段相当于反向操作)
参考代码
#include <cstdio>
#include <vector>
#include <queue>
#include <cmath>
using namespace std;
const int N = 1e5 + 5;
int a[N], val[N], l[N], r[N];
bool vis[N];
// 优先队列节点,存储段的 ID 和权值,按绝对值从小到大排序
struct Node {
int id, v;
bool operator>(const Node& other) const {
return abs(v) > abs(other.v);
}
};
int main()
{
int n, m;
scanf("%d%d", &n, &m);
for (int i = 0; i < n; i++) {
scanf("%d", &a[i]);
}
// 1. 预处理:合并相邻的同号数(正数合并,负数合并),0 直接跳过
vector<int> seg;
for (int i = 0; i < n; i++) {
if (a[i] == 0) continue;
if (seg.empty() || (seg.back() > 0) != (a[i] > 0)) {
seg.push_back(a[i]);
} else {
seg.back() += a[i];
}
}
// 2. 去掉首尾的负数,因为它们不会对增加总和或减少段数做出贡献
int start = 0, end = seg.size();
while (start < end && seg[start] <= 0) start++;
while (start < end && seg[end - 1] <= 0) end--;
// 如果没有正数段,最大和就是 0
if (start >= end) {
printf("0\n"); return 0;
}
priority_queue<Node, vector<Node>, greater<Node>> pq;
int ans = 0;
int cnt = 0, tot = 0;
// 3. 初始化双向链表和堆,计算初始正数段总和
for (int i = start; i < end; i++) {
val[++tot] = seg[i];
if (val[tot] > 0) {
ans += val[tot]; // 先选上所有正数段
cnt++; // 记录当前选取的段数
}
l[tot] = tot - 1;
r[tot] = tot + 1;
pq.push({tot, val[tot]});
}
r[tot] = 0; // 链表末尾
// 4. 贪心过程:当段数超过 m 时,通过合并或删除来减少段数
while (cnt > m) {
Node t = pq.top();
pq.pop();
int id = t.id;
// 如果该节点已在之前的操作中被合并,跳过
if (vis[id]) continue;
// 如果是两端的负数段,直接舍弃且不影响段数
if ((l[id] == 0 || r[id] == 0) && val[id] <= 0) {
vis[id] = true;
continue;
}
// 核心贪心:每次减少一段的代价最小是 abs(val[id])
// 若 val[id] > 0,代表舍弃这一段;若 val[id] < 0,代表合并左右两个正数段
ans -= abs(val[id]);
cnt--;
// 将当前段与其前驱、后继合并为一个新段,并更新双向链表
int left = l[id], right = r[id];
val[id] = val[left] + val[id] + val[right];
vis[left] = vis[right] = true; // 标记左右节点失效
l[id] = l[left]; r[id] = r[right];
if (l[id]) r[l[id]] = id;
if (r[id]) l[r[id]] = id;
// 将合并后的新段重新放入堆中
pq.push({id, val[id]});
}
printf("%d\n", ans);
return 0;
}

浙公网安备 33010602011771号