『学习笔记』反悔贪心

前言

模拟赛上考了一道反悔贪心,然而根本不会,于是蒟蒻就学习了一下。

概念

顾名思义,反悔贪心是反悔 + 贪心。

贪心一般来讲是没有撤销操作的,因为你贪心就是要贪最优解,哪里来的撤销呢?

但是有的时候你贪心出来的“最优解”可能只是局部的最优解,而不是全局最优解。

这时就要用到反悔操作。那么如何实现反悔呢?

一般来说,要开一个堆,记录一下当前贪心所选的元素集合,堆顶是最劣解。每次新加进来一个元素时,要进行判断

  • 符合(某种)条件,直接插入堆,并统计答案。
  • 不符合条件,与当前堆顶判断,取更优的那一个(由于堆中的元素都是贪心选到的元素,所以这个过程相当于把以前的某次贪心结果给撤销掉)。

反悔贪心由此得名。

下面讲一部分例题。

例题

P2949 Work Scheduling G

(初出茅庐)

Description

Luogu传送门

Solution

反悔贪心的板子。

错误的贪心:按照 \(D_i\) 排序,然后枚举每一个工作能选就选,错误性显然。

考虑反悔贪心。首先同样也要对 \(D_i\) 从小到大排序。

开一个小根堆,记录获利 \(P_i\),堆顶就是贪心选到的工作中获利最少的工作。

依次插入每一个工作,上面提到的插入元素的两种情况在这道题目中同样适用。

(某种)条件:判断当前工作能否直接做,由于每个工作都耗费 1 单位时间,所以就是要判断当前工作的截止时间 \(D_i\) 与堆的\(size\) 谁大谁小。

  • \(D_i < Q.size()\) :此时这项工作可以直接插入,同时 \(ans += P_i\)
  • \(D_i \geq Q.size()\):这时就要判断 \(P_i\)\(Q.top()\) 的大小了。
    • \(P_i > Q.top()\),显然不做堆顶的那项工作而做当前这项工作更优。
    • \(P_i \leq Q.top()\),此时不用做任何操作。

Code

#include <iostream>
#include <cstdio>
#include <algorithm>
#include <queue>
#define ll long long

using namespace std;

const ll N = 1e5 + 10;
ll n;
ll ans;
struct node{
    ll d, p;
    bool operator < (const node &b) const{
        return d < b.d;
    }
}a[N];
priority_queue <ll, vector<ll>, greater<ll> > q;

signed main(){
    scanf("%lld", &n);
    for(ll i = 1; i <= n; ++i)
        scanf("%lld%lld", &a[i].d, &a[i].p);
    sort(a + 1, a + 1 + n);
    for(ll i = 1; i <= n; ++i){
        if(a[i].d <= q.size()){
            if(a[i].p > q.top()){
                ans -= q.top() - a[i].p;
                q.pop(); q.push(a[i].p);
            }
        }else{
            q.push(a[i].p);
            ans += a[i].p;
        }
    }
    printf("%lld\n", ans);
    return 0;
}

P4053 [JSOI2007]建筑抢修

(略有小成)

Description

Luogu传送门

Solution

错误的贪心:先选截止时间靠前的,错误性显然。

我们还是要先按截止时间排序。

开一个大根堆,记录贪心选中的建筑所需的修理时间,显然堆顶是最劣解,即所需的修理时间最长。

考虑插入每一个建筑的过程(\(tim\) 是当前所用的时间):

  • \(tim + T1_i \leq T2_i\):直接放到堆中,且 \(tim += T1_i\)
  • \(tim + T1_i > T2_i\):判断 \(T2_i\) 与堆顶元素的大小,并决定是否替换。

证明:

显然修理相同数量的建筑的情况下,所用的修理时间越少越好。

我们判断当前建筑所需的修理时间和堆顶元素的大小并决定是否替换就是为了满足这个贪心性质。

Code

#include <iostream>
#include <cstdio>
#include <algorithm>
#include <queue>

using namespace std;

const int N = 2e5 + 10;
int n, tim, ans;
struct node{
    int v, t;
    bool operator < (const node &b) const{
        return t < b.t;
    }
}a[N];
priority_queue <int> q;

int main(){
    scanf("%d", &n);
    for(int i = 1; i <= n; ++i)
        scanf("%d%d", &a[i].v, &a[i].t);
    sort(a + 1, a + 1 + n);
    for(int i = 1; i <= n; ++i){
        if(tim + a[i].v > a[i].t){
            if(a[i].v < q.top()){
                tim -= q.top() - a[i].v;
                q.pop(), q.push(a[i].v);
            }
        }else{
            q.push(a[i].v);
            ans++, tim += a[i].v;
        }
    }
    printf("%d\n", ans);
    return 0;
}

P3620 [APIO/CTSC 2007] 数据备份

(功成名就)

Description

Luogu传送门

Solution

我们发现选取一些边后直接去判断其他点还能不能选择非常麻烦,所以要稍微转化一下。

考虑把边转化为点,把 \(n\) 个点形成的 \(n - 1\) 条边转化为 \(n - 1\) 个点,点权即为边权。

这样一来,假设我们选择了点 \(x\),那么 \(x - 1\)\(x + 1\) 都不能被选择(挺显然的吧)。

然后我们把这些点插入到小根堆里。

错误的贪心:每次选取堆顶,然后把相邻的两个点删掉,统计答案。

错误性非常明显,就看样例吧,转化之后的点权为:

\(2 \mid 1 \mid 2 \mid 6\)

插入到小根堆后,我们会先选择 1,然后 1 旁边的两个 2 我们都不能选了,所以我们只能选择 6.

这样计算的答案为 7,显然不如选择两个 2 优。

所以我们要通过反悔机制来撤销我们的选择。

事实上,如果我们选择了 \(x\),我们把 \(val_{x - 1}+ val_{x + 1} - val_x\) 再插入到堆里即可(\(x - 1\)\(x + 1\) 并不是严格的 -1 和 +1,而是指 \(x\) 前后的第一个没有被删除的点,这里只是为了方便理解)。

模拟一下上面的样例:

\(2 \mid 1 \mid 2 \mid 6\)

选 1:\(2 + 2 - 1 = 3\)

\(3 \mid 6\)

\(ans = 1\)

选 3:

\[堆为空 \]

\(ans = 1 + 3 = 4\)

是不是就是两个 2 相加呢?

考虑一下这样为什么是对的,我们先选择了 \(x\),然后把 \(res = val_{x - 1}+ val_{x + 1} - val_x\) 插入到堆里,\(ans = val_x\),如果此时 \(res\) 是最优值,我们要选择这个 \(res\),那么:

\[ans = ans + res \]

\[ans = val_x + (val_{x - 1} + val_{x + 1} - val_x) \]

\[ans = val_{x - 1} + val_{x + 1} \]

所以这样就可以做到反悔了。

这道题还需要用链表维护一下 \(x\) 前后第一个没有被删的点是哪个。

Code

#include <iostream>
#include <cstdio>
#include <algorithm>
#include <queue>

using namespace std;

const int N = 1e5 + 10;
int n, k, last, ans;
int pre[N], nxt[N], val[N];
struct Que{
    int id, val;
    bool operator < (const Que &b) const{
        return val > b.val;
    }
};
priority_queue <Que> q;
bool vis[N];

inline void del(int id){
    vis[pre[id]] = vis[nxt[id]] = 1;
    val[id] = val[pre[id]] + val[nxt[id]] - val[id];
    pre[id] = pre[pre[id]], nxt[id] = nxt[nxt[id]];
    pre[nxt[id]] = id, nxt[pre[id]] = id;
}

int main(){
    scanf("%d%d%d", &n, &k, &last);
    for(int i = 1, d; i < n; ++i){
        scanf("%d", &d);
        pre[i] = i - 1, nxt[i] = i + 1, val[i] = d - last;
        last = d;
        q.push((Que){i, val[i]});
    }
    val[0] = val[n] = 1e9;
    for(int i = 1; i <= k; ++i){
        while(vis[q.top().id]) q.pop();
        int pos = q.top().id, res = q.top().val;
        q.pop();
        ans += res, del(pos);
        q.push((Que){pos, val[pos]});
    }
    printf("%d\n", ans);
    return 0;
}

CF335F Buy One, Get One Free

(登峰造极)

Description

Luogu传送门

Solution

神仙贪心题。

首先进行一些预处理,把物品从大到小排序,并按价值分组,即相同价值的放一起(能否白嫖只与比当前物品价值更大的物品有关)。

错误的贪心:能白嫖就白嫖(观察样例就知道显然是错误的)。

还是考虑通过反悔来使它正确。

开一个小根堆,记录每一次贪心白嫖到的价值。

枚举每一组物品,设枚举到第 \(i\) 组,有 \(sum_i\) 个,先计算出能直接白嫖的物品数量。假设大于当前物品价值的有 \(num\) 个物品,白嫖的物品个数是 \(q.size()\),那么数量就是: $$p = min(num - 2 \times q.size(), sum_i)$$

(白嫖一个物品的条件是买一个价值比它大的物品,所以用掉的物品数是 \(2 \times q.size()\)

这时,我们要开一个数组来记录当前轮能白嫖哪些物品,而不是直接塞到小根堆里。因为直接塞到小根堆里的话,会影响当前轮后面物品的选择。

我们把 \(p\) 个物品直接压到数组里,然后考虑当前组剩下的物品,数量为 \(tot = min(num, sum_i) - p\)(应该比较好理解吧)

枚举这 \(tot\) 个物品,并与之前白嫖到的物品判断(即小根堆里的物品)。

注意:在此之前所有买或者白嫖的物品价值都大于当前组物品价值,但是小根堆里的值可能会小于当前组物品价值,因为里面还有一些为了达到反悔效果而塞进去的数。当然,不可否认的是,小根堆里的每一个元素都代表着一个物品。

取出当前堆顶,设为 \(k\),让 \(k\) 与当前组价值 \(val_i\) 做比较。

  • \(k < val_i\):此时白嫖 \(k\) 显然不如白嫖 \(val_i\) 优,那么我们把原本用来白嫖 \(k\) 的机会拿来白嫖 \(val_i\),且我们要购买 \(k\),因此又多了一个白嫖机会 ,所以再多白嫖一个 \(val_i\)

  • \(k \geq val_i\)\(k\)\(val_i\) 更优,我们先把 \(k\) 再放回去。考虑如何达到反悔效果,假设我们是选择价值为 \(x(x > k)\) 的物品来白嫖的 \(k\)。通过上面一种情况,我们知道,如果买 \(k\),可以多白嫖两个 \(val_i\),因此我们再来分类讨论一下。

    • \(x\)\(2 \times val_i\),嫖 \(k\):这时我们需要花费的代价是 \(x + 2 \times val_i\)
    • \(x\)\(k\),嫖 \(2 * val_i\):这时我们的代价是 \(x + k\)

    二者做一个差,为 \(res = 2 \times val_i - k\),我们把 \(res\) 当作一个物品压到数组里即可。

在上述过程中,如果直接放到堆里,\(res\) 可能会成为堆顶,就会出现自己嫖自己的情况。

反悔贪心的过程就结束啦。最后,我们对所有物品求个和,减去堆中的所有元素和就是最少需要花费的代价啦。

Code

#include <iostream>
#include <cstdio>
#include <cstring>
#include <queue>
#include <algorithm>
#define ll long long

using namespace std;

const ll N = 5e5 + 10;
ll n, ans;
ll a[N];
ll val[N], sum[N], cnt;
priority_queue <ll, vector<ll>, greater<ll> > q;
ll stk[N], top;

inline bool cmp(ll a, ll b){
    return a > b;
}

signed main(){
    scanf("%lld", &n);
    for(ll i = 1; i <= n; ++i)
        scanf("%lld", &a[i]), ans += a[i];
    sort(a + 1, a + 1 + n, cmp);
    for(ll i = 1; i <= n; ++i){
        if(i == 1 || a[i] != a[i - 1]) val[++cnt] = a[i];
        sum[cnt]++;
    }
    ll p, tot, num = 0;
    for(ll i = 1; i <= cnt; ++i){
        p = min(num - 2 * (ll)q.size(), sum[i]);
        tot = min(sum[i], num) - p;
        top = 0;
        for(ll j = 1; j <= p; ++j)
            stk[++top] = val[i];
        for(ll j = 1; j <= tot; j += 2){
            ll k = q.top();
            q.pop();
            if(k < val[i]){
                stk[++top] = val[i];
                if(j < tot) stk[++top] = val[i];
            }else{
                stk[++top] = k;
                if(j < tot) stk[++top] = (val[i] << 1) - k;
            }
        }
        for(ll j = 1; j <= top; ++j)
            if(stk[j] >= 0) q.push(stk[j]);
        num += sum[i];
    }
    while(!q.empty())
        ans -= q.top(), q.pop();
    printf("%lld\n", ans);
    return 0;
}

总结

  • 要找到一种方法使得之前的一次贪心选择被撤销掉。
  • 一般要用到一个堆来维护当前被选择的元素中的最劣值。
  • 可以先想一种错误的贪心,然后考虑通过反悔来使它变得正确。
posted @ 2021-11-03 21:09  xixike  阅读(1133)  评论(0编辑  收藏  举报