반성적 그리디 알고리즘 완벽 정리

이 글은 반성적 그리디(Repentant Greedy) 알고리즘에 대한 학습 내용을 정리한 것입니다. 필자가 Div.3 대회에서 이 유형의 문제를 만난 후 깊이 공부하게 되었습니다.

반성적 그리디란?

일반적인 그리디 알고리즘은 선택을 되돌리지 않고 매 순간 최선이라고 판단되는 선택을 합니다. 하지만 이러한 방식은 지역 최적해(Local Optimum)에 빠져 전역 최적해(Global Optimum)를 놓칠 위험이 있습니다.

반성적 그리디는 이런 한계를 극복하기 위해, 현재 선택이 전역적으로 최적이 아님을 발견했을 때 이전 선택을 취소하고 더 나은 선택으로 대체하는 메커니즘입니다.

반성적 그리디는 크게 두 가지 방식으로 구현됩니다:

  1. 반성 힙(Repentance Heap): 힙 자료구조를 사용하여 현재까지 선택한 값들을 관리하고, 더 좋은 선택이 나타나면 힙에서 가장 나쁜 선택을 제거하고 새로운 선택을 추가합니다.
  2. 반성 오토마톤(Repentance Automaton): 차분값(Difference)을 활용하여 반성 과정을 자동화합니다. 선택의 여파를 새로운 노드나 값으로 변환하여 재사용합니다.

반성 힙 예제

시간 제약 모델: 작업 스케줄링

각 작업이 마감 기한 D_i와 이익 P_i를 가질 때, 최대 이익을 구하는 문제입니다. 모든 작업은 1단위 시간이 소요됩니다.

해결 방법:

  • 작업을 마감 기한 기준으로 오름차순 정렬합니다.
  • 현재 시각을 now라고 할 때, 마감 기한이 현재 시각보다 크면 작업을 수행하고 이익을 최소 힙에 추가합니다.
  • 마감 기한이 현재 시각 이하인 경우, 최소 힙의 최상단(가장 작은 이익)과 현재 작업의 이익을 비교합니다. 현재 작업의 이익이 더 크다면 힙에서 가장 작은 이익을 제거하고 현재 작업을 추가하여 전체 이익을 증가시킵니다.
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    
    int n;
    cin >> n;
    vector<pair<int,int>> tasks(n);
    for (int i = 0; i < n; i++) {
        cin >> tasks[i].first >> tasks[i].second;
    }
    
    sort(tasks.begin(), tasks.end());
    priority_queue<int, vector<int>, greater<int>> pq;
    ll total = 0;
    int time = 0;
    
    for (auto &[deadline, profit] : tasks) {
        if (deadline > time) {
            pq.push(profit);
            total += profit;
            time++;
        } else if (!pq.empty() && profit > pq.top()) {
            total += profit - pq.top();
            pq.pop();
            pq.push(profit);
        }
    }
    
    cout << total << '\n';
    return 0;
}

가치 일정 모델: 건물 복구

모든 작업의 가치가 동일할 때, 최대한 많은 작업을 완료하는 문제입니다. 각 작업은 소요 시간 T1과 마감 기한 T2를 가집니다.

해결 방법:

  • 마감 기한 기준으로 정렬한 후, 각 작업을 순회하며 현재 시간에 작업을 추가할 수 있으면 수행합니다.
  • 추가할 수 없는 경우, 현재까지 선택한 작업 중 가장 오래 걸리는 작업(최대 힙의 최상단)과 현재 작업의 소요 시간을 비교합니다. 현재 작업이 더 짧다면 대체하여 더 많은 여유 시간을 확보합니다.
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    
    int n;
    cin >> n;
    vector<pair<int,int>> tasks(n);
    for (int i = 0; i < n; i++) {
        cin >> tasks[i].first >> tasks[i].second;
    }
    
    sort(tasks.begin(), tasks.end(), 
         [](auto &a, auto &b) { return a.second < b.second; });
    
    priority_queue<int> pq;
    ll time = 0;
    int count = 0;
    
    for (auto &[duration, deadline] : tasks) {
        if (time + duration <= deadline) {
            time += duration;
            pq.push(duration);
            count++;
        } else if (!pq.empty() && duration < pq.top()) {
            time -= pq.top() - duration;
            pq.pop();
            pq.push(duration);
        }
    }
    
    cout << count << '\n';
    return 0;
}

반성 오토마톤 예제

주식 매매: CF865D

주식 가격 배열이 주어질 때, 최대 이익을 얻는 문제입니다. 하루에 한 번 매수 또는 매도만 가능합니다.

핵심 아이디어: 중간 가격 C_i를 이용하여 C_sell - C_buy = (C_sell - C_i) + (C_i - C_buy) 형태로 차분값을 누적합니다. 이는 반성 오토마톤의 전형적인 예시입니다.

#include <bits/stdc++.h>
using namespace std;
using ll = long long;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    
    int n;
    cin >> n;
    vector<int> prices(n);
    for (int i = 0; i < n; i++) cin >> prices[i];
    
    priority_queue<int, vector<int>, greater<int>> pq;
    ll profit = 0;
    
    for (int price : prices) {
        pq.push(price);
        if (!pq.empty() && pq.top() < price) {
            profit += price - pq.top();
            pq.pop();
            pq.push(price);
        }
    }
    
    cout << profit << '\n';
    return 0;
}

이중 연결 리스트 반성: 나무 심기

일렬로 늘어선 n개의 위치에 m그루의 나무를 심되, 인접한 위치에는 심을 수 없을 때 최대 가치를 구하는 문제입니다.

해결 방법:

  • 먼저 m > n/2이면 불가능하므로 에러를 출력합니다.
  • 이중 연결 리스트와 최대 힙을 사용합니다. 각 노드는 자신의 가치와 양옆 노드를 가리킵니다.
  • 가장 큰 가치를 선택하면 양옆 노드는 선택 불가능하므로 제거합니다.
  • 반성을 위해 새 노드를 생성합니다: 새 가치 = 왼쪽 가치 + 오른쪽 가치 - 현재 가치. 이 노드를 선택하면 원래 선택을 취소하고 양옆을 선택한 효과가 납니다.
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const int MAX = 200005;

struct Node {
    ll val;
    int idx;
    bool operator<(const Node& other) const {
        return val < other.val;
    }
};

ll value[MAX];
bool removed[MAX];
int left_list[MAX], right_list[MAX];

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    
    int n, m;
    cin >> n >> m;
    
    if (m > n / 2) {
        cout << "Error!" << '\n';
        return 0;
    }
    
    for (int i = 1; i <= n; i++) cin >> value[i];
    
    priority_queue<Node> pq;
    for (int i = 1; i <= n; i++) {
        pq.push({value[i], i});
    }
    
    for (int i = 1; i <= n; i++) {
        left_list[i] = i - 1;
        right_list[i] = i + 1;
    }
    left_list[1] = right_list[n] = 0;
    
    ll answer = 0;
    while (m--) {
        Node current = pq.top(); pq.pop();
        
        while (removed[current.idx]) {
            current = pq.top(); pq.pop();
        }
        
        if (current.val < 0) break;
        
        answer += current.val;
        int l = left_list[current.idx];
        int r = right_list[current.idx];
        
        if (l) removed[l] = true;
        if (r) removed[r] = true;
        
        if (l && r) {
            value[current.idx] = value[l] + value[r] - current.val;
            right_list[left_list[l]] = current.idx;
            left_list[current.idx] = left_list[l];
            left_list[right_list[r]] = current.idx;
            right_list[current.idx] = right_list[r];
            pq.push({value[current.idx], current.idx});
        } else if (l) {
            right_list[left_list[l]] = 0;
        } else {
            left_list[right_list[r]] = 0;
        }
    }
    
    cout << answer << '\n';
    return 0;
}

태그: 반성적그리디 그리디알고리즘 우선순위큐 스케줄링

7월 31일 06:08에 게시됨