贪心

什么是贪心

在每次决策的时候都采取当前意义下的最优策略,一般贪心问题的难点在于最优策略的选择。

例题:有 \(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 加工生产调度

此题较难,其结论可以记一下。

如何贪心?

  1. 根据 \(A_i\) 升序?
  2. 根据 \(B_i\) 降序?
  3. 根据 \(A_i - B_i\) 升序?
  4. 前半段根据 \(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;
}

反悔贪心

普通贪心算法的特点是“目光短浅”,一旦做出决策就不可更改(一条路走到黑)。这种方法在很多问题上行之有效,但在某些存在约束冲突的复杂场景下,当前的局部最优决策可能导致后续无法得到全局最优解。

反悔贪心(又称“带撤销的贪心”)是对普通贪心的优化,它允许在后续决策中,如果发现更好的选择,可以“撤销”或“替换”之前的某个决策,从而修正解法,逼近全局最优。

这种机制通常借助一些数据结构来实现,用于快速找到“最不值得保留”的那个就决策。

例题:P2949 [USACO09OPEN] Work Scheduling G

有 \(N \ (1 \le N \le 10^5)\) 项工作可以选择做,每项工作需要消耗 \(1\) 个单位时间。从时间 \(0\) 开始,第 \(i\) 项工作有一个截止时间 \(D_i \ (1 \le D_i \le 10^9)\) 和完成后的收益 \(P_i \ (1 \le P_i \le 10^9)\)。如果在时间 \(t \lt D_i\) 开始做第 \(i\) 项工作,将在时间 \(t+1\) 完成并获得收益 \(P_i\)。在任意时刻只能做一项工作,求出能够获得的最大总收益。

一种直觉是将所有工作按照截止时间 \(D_i\) 从小到大升序排序,然后依次遍历处理。假设在处理到第 \(i\) 项工作时,目前已选择的工作数量为 \(S\)。如果 \(S \lt D_i\),说明在此之前的空闲时间完全足够再加入当前工作,可以直接将其加入计划。如果 \(S=D_i\),说明时间槽已经满了,无法直接安排当前工作。但是,如果直接忽略当前工作,可能会错过“收益极大但截止时间较晚的工作”。

为了解决这个问题,需要允许反悔操作。维护一个小根堆,其中保存的是当前已经决定要做的所有工作的收益值 \(P_i\),小根堆的堆顶记录的是当前已被选择的工作中收益最小的那项。

当遇到第 \(i\) 项工作(截止时间为 \(D_i\),收益为 \(P_i\))时:如果目前已经选择的工作数量 \(S \lt D_i\),说明时间完全充裕,直接将收益 \(P_i\) 压入小根堆中;如果目前已经选择的工作数量 \(S=D_i\),时间槽已满,而如果当前工作的收益高于已选工作中的最低收益,可以放弃(弹出)之前那个收益最小的工作,换成做当前收益更高的工作,由于只是在原有的 \(S\) 个时间位置中用收益更高的工作替换掉了收益最低的工作,已选工作的总数量 \(S\) 保持不变(仍然为 \(D_i\)),因此所有已选工作在时间安排上依然是完全合法的。

参考代码
#include <iostream>
#include <algorithm>
#include <queue>
using namespace std;
using ll = long long;
const int N = (int)1e5 + 5;
// 工作结构体
struct Work {
    int d, p; // d 为截止时间,p 为收益
    // 按截止时间升序排序
    bool operator<(const Work &other) const {
        return d < other.d;
    }
};
Work w[N];
int main()
{
    int n; cin >> n;
    for (int i = 0; i < n; i++) {
        cin >> w[i].d >> w[i].p;
    }
    // 按照截止时间升序排序
    sort(w, w + n);
    // 小根堆,存储当前已被选择工作的收益值
    priority_queue<int, vector<int>, greater<int>> pq;
    // 遍历每一项工作并实施反悔贪心策略
    for (int i = 0; i < n; i++) {
        if (pq.size() < w[i].d) {
            // 时间充裕,直接做该工作
            pq.push(w[i].p);
        } else if (!pq.empty() && pq.top() < w[i].p) {
            // 时间不充裕,但当前工作收益高于已选工作的最小收益,进行替换(反悔)
            pq.pop();
            pq.push(w[i].p);
        }
    }
    // 统计小根堆中所有工作收益的总和
    ll ans = 0;
    while (!pq.empty()) {
        ans += pq.top();
        pq.pop();
    }
    cout << ans << "\n";
    return 0;
}

对 \(N\) 项工作进行升序排序,时间复杂度为 \(O(N \log N)\)。遍历 \(N\) 项工作,每次对大小不超过 \(N\) 的优先队列执行入堆与出堆操作,单次操作复杂度为 \(O(\log N)\),总耗时为 \(O(N \log N)\)。小根堆中最多有 \(N\) 个元素,依次弹出,耗时 \(O(N \log N)\)。综合总时间复杂度为 \(O(N \log N)\)。

2025 CSP-S1 T15

有 \(5\) 个独立的、不可抢占的任务 A1, A2, A3, A4, A5 需要在一台机器上执行(从时间 \(0\) 开始执行),每个任务都有对应的处理时长和截止时刻,按顺序分别为 \(3,4,2,5,1\) 和 \(5,10,3,15,11\)。如果某一个任务超时,相应的惩罚等于其处理时长。为了最小化惩罚,应该优先执行哪个任务?

答案

题目要求最小化超时惩罚,由于每个任务超时的惩罚都等于其处理时长(正数),最理想的全局最优解是让所有任务都不超时,从而使得总惩罚为 \(0\)。

可以列出各任务的参数,并尝试使用最早截止时间优先(EDD,Earliest Due Date)策略来进行调度。EDD 策略指出:如果存在一种调度方案使得所有任务都不超时,那么按截止时间从小到大执行就必定是可行方案。

按照截止时间从小到大,最优执行顺序应为 A3 \(\rightarrow\) A1 \(\rightarrow\) A2 \(\rightarrow\) A5 \(\rightarrow\) A4。在此方案下,没有任何任务超时,总惩罚达到了理论最低值 \(0\)。

在这道题的特定数据下,因为存在零超时的完美解,所以简单的 EDD 贪心策略就足够了。但如果数据变得苛刻,必然会有任务超时(例如所有任务的截止时间都很紧),单纯按 EDD 排序就不再是全局最优了。此时,就会进阶为:

  • 反悔贪心:结合优先队列(大根堆),先按截止时间排序执行,当发现某个任务必然超时,就从已安排的任务中“反悔”并剔除耗时最长的那一个。
  • 动态规划:当惩罚值与处理时长不成正比时,问题会退化成类似于“0-1 背包问题”的 NP-Hard 问题,需要用状态压缩或二维 DP 来求解。

例题:P4053 [JSOI2007] 建筑抢修

基地下有 \(N \ (1 \lt N \lt 150000)\) 个受损建筑,当前只有一个修理工人。每个建筑 \(i\) 有两个属性 \(T_1, T_2 \ (1 \le T_1 \lt T_2 \lt 2^{31})\):

  1. 修理耗时 \(T_1\):修复该建筑需要的时间(秒)。
  2. 报废截止时间 \(T_2\):建筑必须在第 \(T_2\) 秒(含)之前修复完毕,否则就会完全报废。

修理工人同一时间只能修复一个建筑,并且修理是连续不间断的。请设计修复顺序,使得成功抢修的建筑数量最多。

这个问题面临两个制约因素:

  1. 每个建筑的截止时间 \(T_2\) 不同,优先修理截止时间早的建筑显然能避免它们过早报废。
  2. 每个建筑消耗的修理时间 \(T_1\) 不同,消耗时间较短的建筑更容易挤入有限的时间预算中。

如果单纯按照截止时间 \(T_2\) 排序并依次修复,可能会因为遇到某个耗时 \(T_1\) 非常长的建筑,占用过多时间导致后续多个截止时间稍晚但耗时极短的建筑无法修复。因此,需要引入反悔机制:在时间超出当前建筑的 \(T_2\) 时,替换掉之前选择的建筑中耗时最长的那一个。

先按 $T_2$ 升序排序的正确性证明

命题:对于任意一个合法的抢修建筑集合 \(S = \{ b_1,b_2, \dots, b_k \}\),将该集合内的建筑按照截止时间 \(T_2\) 从小到大排序后依次修复,方案必然也是合法的。

证明:假设在某个合法的修复方案中,存在相邻修复的两个建筑 \(A\) 和 \(B\)(\(A\) 先于 \(B\) 修复),但不满足 \(T_2\) 的升序要求,即 \(T_{2,A} \gt T_{2,B}\)。

设开始修复 \(A\) 之前的累计已用时间为 \(t_0\),由于原方案合法,必须满足以下两个条件:

  1. \(A\) 修复完成的时间:\(t_0 + T_{1,A} \le T_{2,A}\)
  2. \(B\) 修复完成的时间:\(t_0 + T_{1,A} + T_{1,B} \le T_{2,B}\)

现在考虑交换 \(A\) 和 \(B\) 的修复顺序(即先修 \(B\),再修 \(A\)):

  • 交换后,\(B\) 修复完成的时间为 \(t_0 + T_{1,B}\)。因为 \(T_{1,A} \gt 0\),有 \(t_0 + T_{1,B} \lt t_0 + T_{1,A} + T_{1,B} \le T_{2,B}\),因此 \(B\) 必定能在截止时间 \(T_{2,B}\) 前成功修复。
  • 交换后,\(A\) 修复完成的时间为 \(t_0 + T_{1,B} + T_{1,A}\)。结合假设条件有 \(t_0 + T_{1,B} + T_{1,A} \le T_{2,B} \lt T_{2,A}\),因此 \(A\) 也必定能在截止时间 \(T_{2,A}\) 前成功修复。
  • 交换后,完成 \(A\) 和 \(B\) 的总用时仍为 \(t_0 + T_{1,A} + T_{1,B}\),对后续其他建筑的开始修复时间没有任何影响。

结论:对任意颠倒 \(T_2\) 顺序的相邻建筑进行交换,都不会破坏调度的合法性。经过有限次邻项交换后,可以将任意合法解集中的建筑全部按照 \(T_2\) 单调递增排序,且方案依然保持合法。因此,先按 \(T_2\) 从小到大排序不会丢失最优解。

反悔机制(淘汰耗时最大建筑)的正确性证明

在按照 \(T_2\) 升序处理建筑时,假设当前已经成功抢修了 \(k\) 个建筑,总耗时为 \(t\)。当遍历到下一个建筑 \(i\) 时:

  1. 如果 \(t + T_{1,i} \le T_{2,i}\):说明可以在不放弃先前任何建筑的前提下直接加入建筑 \(i\),此时抢修建筑数量增加到 \(k+1\)。
  2. 如果 \(t + T_{1,i} \gt T_{2,i}\):说明加入建筑 \(i\) 后总时间超限,无法同时完成当前的这 \(k+1\) 个建筑。此时在当前包含 \(i\) 的 \(k+1\) 个建筑中,找到耗时 \(T_1\) 最大的建筑 \(m\),并将它放弃。放弃 \(m\) 后,成功抢修的建筑数量保持为 \(k\) 不变(以一换一),新的总耗时变为 \(t' = t + T_{1,i} - T_{1,m}\)。由于 \(T_{1,m} \ge T_{1,i}\),必有 \(t' \lt t\)。新的总耗时 \(t'\) 不大于原总耗时 \(t\),且后续所有未处理建筑的 \(T_2\) 均大于等于当前建筑的 \(T_2\)。更小的已用时间 \(t'\) 为后续容纳更多建筑争取了更大的缓冲空间,因此该替换策略是全局最优的。
参考代码
#include <iostream>
#include <algorithm>
#include <queue>
using namespace std;
using ll = long long;
const int N = 150005;
struct Building {
    int t1, t2;
    // 按截止时间 t2 升序排序
    bool operator<(const Building &other) const {
        return t2 < other.t2;
    }
};
Building b[N];
int main()
{
    int n; cin >> n;
    for (int i = 0; i < n; i++) {
        cin >> b[i].t1 >> b[i].t2;
    }
    // 按截止时间升序排序
    sort(b, b + n);
    priority_queue<int> pq; // 大根堆,存储已选择建筑的 t1
    ll t = 0; // 当前累计消耗时间
    for (int i = 0; i < n; i++) {
        t += b[i].t1;
        pq.push(b[i].t1);
        // 超时反悔:淘汰掉之前耗时最长(t1 最大)
        if (t > b[i].t2) {
            t -= pq.top();
            pq.pop();
        }
    }
    // 堆的大小即为最多可抢修的建筑数
    cout << pq.size() << "\n";
    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;
}

习题:CF865D Buy Low Sell High

给定某只股票未来 \(N \ (2 \le N \le 3 \times 10^5)\) 天的价格 \(p_1, p_2, \dots, p_N \ (1 \le p_i \le 10^6)\),每天可以进行以下三种操作之一:

  1. 买入一股股票;
  2. 卖出一股股票(前提是手中持有的股票数大于 \(0\));
  3. 什么都不做。

初始时手中没有股票,求在第 \(N\) 天结束时(且手中不持有股票)能够获得的最大总盈利。

解题思路

如果采用直觉上的贪心算法:只要遇到今天价格 \(p\) 比之前某天的买入价格 \(a\) 高,就尝试把股票卖出(收益为 \(p-a\))。

但这样做可能会面临“短视”问题,假设第 \(1\) 天股价为 \(a=2\),第 \(2\) 天股价为 \(b=5\),第 \(3\) 天股价为 \(c=9\)。如果在第 \(2\) 天把第 \(1\) 天的股票卖掉,获得收益 \(5-2=3\)。但更优的方案显然是在第 \(1\) 天买入并在第 \(3\) 天卖出,获得收益 \(9-2=7\)。

如何解决这种“早卖了导致少赚”的问题?答案是引入反悔机制。

将总收益的拆分进行代数化描述,\(c-a = (b-a) + (c-b)\),这个式子给出了反悔贪心的核心原理。当在第 \(2\) 天以 \(b\) 卖出第 \(1\) 天买入的 \(a\) 时,先拿到了收益 \(b-a\)。此时,在可选买入候选集中额外插入一个虚拟的价格 \(b\)。如果后续遇到更高的价格 \(c \ (c \gt b)\),并匹配了买入价格 \(b\),获得的收益为 \(c-b\)。两次收益相加为 \((b-a)+(c-b)=c-a\),效果完全等价于取消了第 \(2\) 天以 \(b\) 卖出的决策,改在第 \(3\) 天以 \(c\) 卖出。

在代码实现中,维护一个小根堆,存储当前所有可用于匹配的买入价格(包含真实买入点与反悔买入点)。

每天处理股价 \(p\) 时,先将 \(p\) 压入小根堆(它是一个潜在的未来买入点)。检查堆顶元素,若小于 \(p\),说明在今天卖出可以产生收益,这个值就是两者的差。弹出堆顶(该买入价格已被当前的 \(p\) 匹配),再次将 \(p\) 压入小根堆,这第二次压入的 \(p\) 就是“反悔占位符”。若未来某天价格 \(p' \gt p\),选中这个占位符时,收益会增加 \(p'-p\),从而补全反悔链条。

参考代码
#include <iostream>
#include <queue>
using namespace std;
using ll = long long;
int main()
{
    int n; cin >> n;
    priority_queue<int, vector<int>, greater<int>> pq;
    ll ans = 0;
    while (n--) {
        int p; cin >> p;
        pq.push(p); 
        if (pq.top() < p) {
            ans += p - pq.top();
            pq.pop();
            pq.push(p);
        }
    }
    cout << ans << "\n";
    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。
  2. 合并两个正数段:即将它们中间的一个负数段也选上,总和加上该负数段(即减去其绝对值),段数减 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;
}

例题:P3620 [APIO/CTSC2007] 数据备份

很容易发现,最优解中每两个配对的办公楼一定是相邻的。求出每两个相邻办公楼之间的距离,记为 \(D_1, D_2, D_3, \dots, D_{N-1}\)。于是问题可以转化为:从 \(D\) 中选出不超过 \(K\) 个数(对应办公楼的 \(K\) 个配对),使它们的和最小,并且相邻的两个数不能同时被选(任一办公楼都属于唯一的配对组)。

image

如果 \(K=1\),答案显然是 \(D\) 中的最小值。

如果 \(K=2\),答案一定是以下两种情况之一。

  1. 选择最小值 \(D_i\),以及除了 \(D_{i-1}, D_i, D_{i+1}\) 之外其他数中的最小值。
  2. 选择最小值 \(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\) 显然是一步最优策略,这恰好涵盖了在推论中提到的最优解当中的两种情况。

image

综上所述,可以得到这样一个算法:
建立一个链表 \(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;
}

习题:P3045 [USACO12FEB] Cow Coupons G

农夫约翰准备买新奶牛,市场上有 \(N \ (1 \le N \le 5 \times 10^4)\) 头奶牛,第 \(i\) 头奶牛的原价为 \(P_i \ (1 \le P_i \le 10^9)\)。约翰手头有 \(K \ (1 \le K \le N)\) 张优惠券,使用优惠券购买第 \(i\) 头奶牛时价格会降为 \(C_i \ (1 \le C_i \le P_i)\)(每头奶牛只能使用一张优惠券)。在总预算不超过 \(M \ (1 \le M \le 10^{14})\) 的前提下,求约翰最多可以买到多少头奶牛。

解题思路

在每一步中,为了买下一头最便宜的牛,有两个选择:

  • 选择 1:不使用优惠券,以原价 \(P_i\) 购买当前还未买的牛中原价最便宜的一头(设为 \(i\))。
  • 选择 2:使用一张优惠券以 \(C_j\) 购买一头未买的牛(设为 \(j\))。
    • 如果目前已使用的优惠券张数小于 \(K\),则可以直接使用优惠券,成本为 \(C_j\)。
    • 如果目前已使用的优惠券张数等于 \(K\),这意味着没有多余的优惠券。如果仍要使用优惠券买 \(j\),必须收回(反悔)之前某头已经使用优惠券购买的牛 \(x\) 的优惠券,让牛 \(x\) 改用原价购买,腾出这张优惠券给 \(j\) 使用。这一步的净代价为:以优惠券购买 \(j\) 的成本加上补回 \(x\) 的原价差额,即 \(C_j + (P_x - C_x) = C_j - (C_x - P_x)\)。为了使代价最小,应该让 \(P_x - C_x\) 最小。

在每一步决策中比较“选择 1”和“选择 2”的价格,选择其中较小的一个。如果购买该牛后总花费超过预算 \(M\),则停止购买;否则买下它,并更新已使用的优惠券状态。

参考代码
#include <cstdio>
#include <queue>
#include <algorithm>
using namespace std;
typedef long long LL;
const int MAXN = 50005;
int p[MAXN], c[MAXN];
bool vis[MAXN];
struct CompareC {
    bool operator()(int i, int j) const { 
        return c[i] > c[j];
    }
};
struct CompareP {
    bool operator()(int i, int j) const {
        return p[i] > p[j];
    }
};
int main()
{
    int n, k;
    LL m;
    scanf("%d%d%lld", &n, &k, &m);
    priority_queue<int, vector<int>, CompareC> qc;
    priority_queue<int, vector<int>, CompareP> qp;
    priority_queue<int> qd;
    for (int i = 0; i < n; i++) {
        scanf("%d%d", &p[i], &c[i]);
        qc.push(i); qp.push(i);
    }
    LL sum = 0;
    int ans = 0;
    for (int i = 0; i < n; i++) {
        while (!qc.empty() && vis[qc.top()]) qc.pop();
        while (!qp.empty() && vis[qp.top()]) qp.pop();
        int p1 = p[qp.top()];
        int p2 = c[qc.top()] - (qd.size() < k ? 0 : qd.top());
        if (sum + min(p1, p2) > m) break;
        ans++;
        if (p1 <= p2) {
            sum += p1;
            vis[qp.top()] = true;
        } else {
            if (qd.size() == k) qd.pop();
            qd.push(c[qc.top()] - p[qc.top()]);
            sum += p2;
            vis[qc.top()] = true;
        }
    }
    printf("%d\n", ans);
    return 0;
}
posted @ 2023-11-07 16:03  RonChen  阅读(213)  评论(0)    收藏  举报