완전 탐색
이나 동적 계획법
과는 달리, 그리디 알고리즘은 각 단계마다 지금 당장 가장 좋은 방법만을 선택한다.그래서 그리디 알고리즘은 많은 경우 최적해를 찾지 못한다.
그리디 알고리즘이 사용되는 경우는 크게 두 가지로 나뉜다.
그리디 알고리즘을 사용해도 항상 최적해를 구할 수 있는 문제의 경우, 동적 계획법보다 수행 시간이 훨씬 빠르기 때문에 유용하다.
시간이나 공간적 제약으로 인해 다른 방법으로 최적해를 찾기 너무 어렵다면 최적해 대신 적당히 괜찮은 답(근사 해)을 찾는 것으로 타협할 수 있다. 이럴 때 그리디 알고리즘은 최적은 아니지만 임의의 답보다는 좋은 답을 구하는 용도로 유용하다.
주로 1.에 해당하는 문제가 많이 출제되고, 2.가 나왔다 하더라도 그리디 알고리즘보다는 조합 탐색이나 메타 휴리스틱 알고리즘들이 더 좋은 답을 주는 경우가 많다고 한다.
그리디 알고리즘을 이용해서 해결할 문제는 상당히 제한되어 있다는 의미로 받아들이면 좋을 것 같다.
그리디 알고리즘을 사용하면 최적해가 나올 수 있는 문제에만 적용하는 것이 좋다.
어떤 문제에 그리디 알고리즘을 적용해서 해결해야겠다고 생각했으면 두 가지 속성을 고려해야 한다. 이 책에서는 이를 그리디 알고리즘의 정당성 증명이라 한다. 증명이라 해서 너무 복잡하게 생각할 필요는 없는 듯하다. 직관적으로 떠올릴 수도 있고, 수식으로써 표현할 수도 있는데 그건 자유다. 대회도 아니고 코테할 때 증명까지 하고 있진 않을 듯?
그리디 알고리즘의 정당성 증명은 많은 경우 일정한 패턴을 가진다. 이 증명 패턴은 탐욕적인 알고리즘이 항상 최적해를 찾아낼 수 있다는 것을 두 가지의 속성을 증명함으로써 보인다.
정리하자면, 그리디 알고리즘으로 문제를 풀기 위해서는 그리디 알고리즘을 구상하고, 탐욕적 선택 속성과 최적 부분 구조를 증명한 뒤, 구현하면 된다.
그리디 알고리즘으로 최적해를 찾을 수 있는 많은 문제들은 동적 계획법으로도 풀 수가 있다. 그리디 알고리즘으로 최적해를 찾을 수 있다는 말은 부분 문제 한 단계만을 고려해도 답을 찾을 수 있다는 의미이다. 모든 단계를 고려하는 동적 계획법의 풀이가 틀릴 이유가 없다. 그럼에도 불구하고 그리디 알고리즘을 사용하는 이유는 동적 계획법에 필요한 메모리나 시간이 과도하게 크기 때문이다.
애매하다... 여기까지는 이론적인 부분이고 실제 문제에 적용하면서 감을 잡아보자.
출전 순서 정하기 (문제 ID: MATCHORDER, 난이도: 하)
그리디 알고리즘 구상
탐욕적 선택 속성 증명
최적 부분 구조 증명
완전탐색으로 접근 가능 -> 시간복잡도: n!
DP로 접근 가능 -> 시간복잡도: O(n * 2^n)
- Python
C = int(input()) for _ in range(C): win = 0 member = int(input()) ru = list(map(int, input().split())) kr = list(map(int, input().split())) ru.sort(reverse=True) # 내림차순, 레이팅이 높은 순서대로 정렬 kr.sort() # 오름차순, 레이팅이 낮은 순서대로 정렬 for i in range(member): if ru[i] > kr[-1]: continue else: # 한국 선수의 레이팅이 같거나 클 경우 승리 kr.pop() win += 1 print(win)
- C++
#include <iostream> #include <algorithm> using namespace std; #define endl "\n"; int N; int Russia[100]; int Korea[100]; bool check[100] = { false, }; int main(void) { ios::sync_with_stdio(false); cin.tie(NULL); cout.tie(NULL); int C; cin >> C; while (C--) { cin >> N; for (int i = 0; i < N; i++) { cin >> Russia[i]; } for (int i = 0; i < N; i++) { cin >> Korea[i]; } for (int i = 0; i < N; i++) { check[i] = 0; } sort(Russia, Russia + N); sort(Korea, Korea + N); int score = 0; for (int i = 0; i < N; i++) { for (int j = 0; j < N; j++) { if (check[j] == false && Korea[j] >= Russia[i]) { score++; check[j] = true; break; } } } cout << score << endl; } return 0; }
- 시간 및 메모리 제한
프로그램은 10초 안에 실행되어야 하며, 64MB 이하의 메모리를 사용해야 합니다.
- 입력
입력의 첫 줄에는 테스트 케이스의 수 C(C<=300)가 주어집니다. 각 테스트 케이스는 세 줄로 구성됩니다. 첫 줄에는 도시락의 수 n(1<=n<=10000)이 주어집니다. 두 번째 줄에는 n개의 정수로 각 도시락을 데우는 데 걸리는 시간이 주어지며, 세 번째 줄에는 역시 n개의 정수로 각 도시락을 먹는 데 걸리는 시간이 주어집니다.
- 출력
각 테스트 케이스마다 한 줄에 점심을 먹는 데 걸리는 최소 시간을 출력합니다. 이 값은 항상 2^31보다 작습니다.
- 예제 입력
2 3 2 2 2 2 2 2 3 1 2 3 1 2 1
- 예제 출력
8 7
DP
와 분할 정복
의 경우, 모든 상황을 고려해 최적해를 도출하지만, 그리디 알고리즘
의 경우 모든 상황을 고려하지 않고 최적해를 도출해낸다.풀이
예제 분석을 통한 그리디 직관 얻기
문자열: 3 1 3 4 1
문자열의 길이 1과 1을 먼저 연결
1 + 1 = 2
문자열의 길이 3과 3을 연결
3 + 3 = 6
문자열의 길이 2와 6을 연결
2 + 6 = 8
문자열의 길이 8과 4를 연결
8 + 4 = 12
따라서 총 비용은 26문자열: 1 1 1 1 1 1 1 2
문자열의 길이 1과 1을 연결
1 + 1 = 2
문자열의 길이 1과 1을 연결
1 + 1 = 2
문자열의 길이 1과 1을 연결
1 + 1 = 2
문자열의 길이 1과 2를 연결
1 + 2 = 3
문자열의 길이 2와 2를 연결
2 + 2 = 4
문자열의 길이 2와 3을 연결
2 + 3 = 5
문자열의 길이 4와 5를 연결
4 + 5 = 9
따라서 총 비용은 27문자열을 합칠때마다 합쳐지는 문자열들의 총 길이가 전체 비용에 더해진다.
그러므로 가장 짧은 두 문자열부터 합쳐나가야 가장 적은 비용으로 전체 문자열들을 합칠 수 있을 것이다.즉, 가장 짧은 문자열은 우선순위가 가장 높다고 볼 수 있다. 따라서 BST 기반의 heap을 사용하면 효율적으로 문제를 해결할 수 있을 것이라 생각했다. heap의 삽입과 삭제의 시간복잡도는 O(logN)이기 때문이다.
정답 코드
# STRJOIN
import heapq as hq # priority queue
def greedy(heap_queue):
total = 0
while len(heap_queue) > 1: # heap_queue의 길이가 1이면 문자열을 다 합친 것
a = hq.heappop(heap_queue)
b = hq.heappop(heap_queue)
hq.heappush(heap_queue, a + b) # 가장 짧은 두 문자열을 합친다
total += a + b
return total
c = int(input())
for _ in range(c):
n = int(input())
str_list = list(map(int, input().split()))
hq.heapify(str_list)
cost = greedy(str_list)
print(cost)