KuonjiCat的演算法學習筆記:反悔貪心

B1anc4t發表於2024-11-24

反悔貪心

本蒟蒻在做題時被卡,看題解發現用反悔貪心,遂蒐羅資料,得有此篇

part.1

什麼是反悔貪心?

簡單的例子,我有一個只能裝3個物品的揹包,我要從n個價值由小到大的物品中選出3個最大的裝進包裡,但只能從頭往後選,假如我此刻的包內物品價值為1 2 3,而我要面對的下一個物品的價值為4,那麼我就要丟掉我包裡價值最小的物品,也就是反悔,然後將這個價值更大的4放入揹包內,這便是反悔貪心

也就是說,在區域性範圍內做出的最優決策對於全域性來說並不適用,於是我們就需要反悔的操作,儘可能達成最優解

part.2

此處我們需要用到堆的資料結構,我們可以手寫一個堆出來,也可以使用stl中的優先佇列

在此處提一下優先佇列

優先佇列(priority_queue) 是一種stl容器,他與正常的佇列不同的是,我們可以自定義佇列內元素的優先順序

優先佇列只維護隊頭的元素

priority_queue<data type> pq;//建堆操作
//第一種寫法
//預設為大根堆
struct cmp{
    bool operator ()(const int & u, const int & v) const
    {
        return u > v;
    }
};
//若要使用小根堆將大於號換成小於即可
//當使用自己定義的結構體時過載小於號
struct Node{
    int x, y;
    bool operator < (const Node &u)const
    {
        return x == u.x ? y < u : u < x;
    }
};
//另外一種寫法,比第一種方便很多
struct node {
    int x, y;
    bool operator < (const node &v) const
    {
        if(y > v.y)return(true);
        else return(false);
    }//過載<號的定義,規定堆為關於y的小根堆 
};

part.3

反悔貪心的寫法

一般情況下有兩種:反悔堆與反悔自動機

兩種模型:用時一定模型,價值一定模型

我們先來介紹一下反悔堆

1.反悔堆

用時一定模型

P2949 [USACO09OPEN] Work Scheduling G

簡要題意,n項工作,每個工作都有一個截止日期已經完成後的回報,完成這個工作要花費一個單位的時間

簡單的貪心思想在這並不成立,可以舉的例子很多,此處就不提了

我們只考慮最優的解法 :這道題的一個優質解法就是使用反悔堆

因為題目中說,我們可以在任意時間完成一份任意的工作(前提是工作沒有逾期

那麼我們在同一時間內,既可以選擇完成a也可以選擇完成b工作

題目的思路大致如下:先排序,考慮維護一個小根堆

如果說我們能透過捨棄價值低的工作來完成報酬更高的工作

換句話說,就是堆頂的元素要比接下來要面對的元素價值小,就捨棄這個堆頂,把新的工作放進來

堆內所有元素之和就是答案

#include<bits/stdc++.h>
using namespace std;
using ll = long long;
struct node {
    int deadline, value;
    bool operator < (const node &v) const
    {
        if(value > v.value)return(true);
        else return(false);
    }
}a[110000];
priority_queue<node> pq;

bool cmp(node x, node y){
    if(x.deadline < y.deadline) return(true);
    else return(false);
}
ll ans;
signed main(){
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    int n;cin >> n;
    for(int i = 1;i <= n;i ++) cin >> a[i].deadline >> a[i].value;
    sort(a + 1, a + n + 1, cmp);
    
    for(int i = 1;i <= n;i ++) {
        if(pq.size() < a[i].deadline){
            ans += a[i].value;
            pq.push(a[i]);
        }//截止日期沒到,就還能做任務
        else {
            if(a[i].value > pq.top().value){//如果接下來的工作價值更大,就反悔
                ans -= pq.top().value;pq.pop();
                ans += a[i].value;
                pq.push(a[i]);
            }
        }
    }
    cout << ans;
    return 0;
}

價值一定模型

P4053 [JSOI2007] 建築搶修

簡要題意:n項工作,每個工作有一個完成花費的時間以及截止時間,工人可以選擇做任意一個任務,但只有修理完才能去做下一個任務,問最多能修多少個建築

與上一道題不同,這次每份工作所花費的時間不同,但是價值是相同的

那麼類似的思路,這次我們考慮使用一個大根堆來維護堆頂,堆頂的元素就是堆內目前耗時最長的工作

如果有比它耗時短的,就pop掉,然後將新的push進來

#include<bits/stdc++.h>
using namespace std;
using ll = long long;
struct node {
    int deadline, Time;
    bool operator < (const node &v) const
    {
        if(Time < v.Time)return(true);//大根堆用小於號
        else return(false);
    }
}a[160000];
priority_queue<node> pq;

bool cmp(node x, node y){
    if(x.deadline < y.deadline) return(true);
    else return(false);
}//截止日期從小到大排序
ll last;
signed main(){
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    int n;cin >> n;
    for(int i = 1;i <= n;i ++) cin >> a[i].Time >> a[i].deadline;
    sort(a + 1, a + n + 1, cmp);
    
    for(int i = 1;i <= n;i ++) {
        if(a[i].deadline >= last + a[i].Time){
            last += a[i].Time;
            pq.push(a[i]);
        }//如果說,這次工作完成的耗時與之前相加沒超時,那麼就push進來
        else {
            if(a[i].Time < pq.top().Time){//如果說接下來完成時間更小,就push入堆中
                last -= pq.top().Time;pq.pop();//先pop在push
                last += a[i].Time;
                pq.push(a[i]);
            }
        }
    }
    cout << pq.size();//最後輸出堆內元素的數量即可
    return 0;
}

2.反悔自動機

問題:你現在有A, B, C , D四個數,每次只能從中AB中選擇一個CD中選擇一個,問怎麼選價值最大

思路:我先選A 和 C,然後將B, D的值分別更新為B - A和D - C

那麼我們接下來繼續選,如果說選擇了B,那麼實際上選擇的是B - A,再加上之前的A,就相當於我們只選擇了B

也就是相當於反悔了

反悔自動機的基本思想就是,透過修改關聯點的值讓我們避免去選擇之前決定過的點

堆反悔自動機

https://codeforces.com/contest/865/problem/D

買股票問題:已知後面n天每天的股票價格,在每一天裡,你都可以買入一份股票並賣出一份股票,問n天之後你的最大收益為多少(n天之後你持有的股票數量應當為0)

設計反悔自動機的反悔策略,使所有的貪心情況都可以得到最優解

定義在Pbuy為全域性最優解中買入的當天的價格,Psell為全域性最優解中賣出的當天的價格

那麼公式如下

\[Psell - Pbuy = (Psell - p_i) + (p_i - Pbuy) \]

那麼就相當於,我們在pi天把Pbuy時的股票賣掉,再以pi的價格買回來,再在Psell時賣掉,也就是說我們買價格最小的股票再以最大的價格賣掉,以達成最優解

#include<bits/stdc++.h>
using namespace std;
using ll = long long;
struct node {
    int value;
    bool operator < (const node &v) const
    {
        if(value > v.value)return(true);
        else return(false);
    }
}a[330000];
priority_queue<node>q;

ll ans;
int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    int n;cin >> n;
    for (int i = 1; i <= n; i++) cin >> a[i].value;
    
    for(int i = 1; i <= n; i++) {
        q.push(a[i]);//用於貪心買價格最小的股票去買價格最大的股票
        if(!q.empty() && q.top().value < a[i].value) {//假如當前的股票價格不是最優解
            ans += a[i].value - q.top().value;//將差值計入全域性最優解 
            q.pop();//將已經統計的最小的股票價格丟出去
            q.push(a[i]);//反悔策略:將當前的股票價格再放入堆中,即記錄中間變數(等式中間的Vi)
        }
    }

    cout << ans;
    return 0;
} 

雙向連結串列反悔自動機

這道題的模板題是種樹

P1484 種樹(真的是種樹)

簡要題意:一條直線上有n個坑,你有k個樹種用來種樹,沒種一顆樹你都會獲得對應的利益,但相鄰的兩個坑不能種樹

考慮反悔自動機,那麼要構建差值公式

先舉個簡單的例子:假如我們有4個坑A, B, C, D,對應的利益為a, b, c, d

假如我們選擇了B點,我們能獲得的最大利益為b + d(種子一定夠用)

但能肯定此時為最優解嗎?

不一定,可能選擇A和C是最優的

觀察發現

\[a + c < b + d\\ a + c - b < d\\ \]

我們新加入一個P點,他對應的價值為a + c - b,並刪點A和C點

如果我們下一次選擇了P,就證明我們後悔了

假如說我們要操作的點在兩端呢?

那麼一定不會進行反悔操作,直接刪掉即可

如果我決定刪除A,就代表A的值是大於B的值的,那我們就沒有可能反悔了。這是因為假如我們反悔了選了B不選A,那我們得到的值b小於a,並且影響了C讓C不能選了(選A時C是可以選的),可以說是賠了夫人又折兵,所以是不可能反悔的直接把A和B都刪了就可以了。

​ ——————摘自蒟蒻大佬

感覺自己說的不太明白,就引用了一下,感謝jvruo大佬!

#include<bits/stdc++.h>
using namespace std;
struct node {
    long long site, val;
    bool operator < (const node &v) const {
        if(v.val > val) return(true);
        else return(false);
    }
}now, x;
long long val[2200000];//價值 
long long vis[2200000], l[2200000], r[2200000];
//vis用於記錄是否刪除(1為刪除),l,r為雙向連結串列的左右鄰居 
long long t, n, m;
long long ans;
priority_queue<node>q;
int main()  {
    scanf("%lld%lld", &n, &m);

    for(long long i = 1; i <= n; i++) scanf("%lld", &val[i]);
    while(!q.empty()) q.pop();

    for(long long i = 1; i <= n; i++) {
        now.site = i;
        now.val = val[i];
        vis[i] = 0;
        q.push(now);
    } 

    for(long long i = 2; i <= n; i++) l[i] = i - 1;
    for(long long i = 1; i <= n - 1; i++) r[i] = i + 1;

    l[1] = r[n] = 0;
    //預處理,製作堆和雙向連結串列 

    for(long long i = 1; i <= m; i++) {
        x = q.top(); q.pop();
        while(vis[x.site] == 1) {
            x = q.top();
            q.pop();
        }//找到一個沒有被刪除的值最大的點 
        if(x.val < 0) break;
        ans +=  x.val;

        if(l[x.site] != 0) vis[l[x.site]] = 1;//刪除左邊的點 
        if(r[x.site] != 0) vis[r[x.site]] = 1;//刪除右邊的點 

        if(l[x.site] != 0 && r[x.site] != 0) {
            now.site = x.site;
            now.val = val[l[x.site]] + val[r[x.site]] - val[x.site];
            val[x.site] = val[l[x.site]] + val[r[x.site]] - val[x.site];
            r[l[l[x.site]]] = x.site;
            l[x.site] = l[l[x.site]];
            l[r[r[x.site]]] = x.site;
            r[x.site] = r[r[x.site]];
            q.push(now);
        }//如果不在連結串列兩端 
        else if(l[x.site]) r[l[l[x.site]]] = 0;
        else l[r[r[x.site]]] = 0;   
        //如果在連結串列兩端 
    }

    printf("%lld\n", ans);
    return 0;
}

part.4

總結

在網上看了不少資料,感覺還是jvruo大佬講的比較詳細,他的部落格對我的幫助很大,此處對jvruo大佬表示真摯的感謝

感覺對應反悔貪心的知識點仍需要時間去理解

相關文章