그리디 알고리즘은 단순하지만 강력한 문제 해결 방법이다.
이 알고리즘 유형은 국내 알고리즘 교재에서 단어 그대로 번역하여 탐욕법으로 소개된다.
이름에서 알 수 있듯이 어떠한 문제가 있을 때 단순 무식하게 탐욕적으로 문제를 푸는 알고리즘이다.
여기서 탐욕적이라는 것은 '현재 상황에서 지금 당장 좋은 것만 고르는 방법'을 의미한다.
그리디 알고리즘을 이용하면 매 순간 가장 좋아보이는 것을 선택하며, 현재의 선택이 나중에 미칠 영향에 대해서는 고려하지 않는다.
코딩테스트에서 만나게 될 그리디 알고리즘의 문제 유형은 앞으로 다루게 될 알고리즘과 비교했을 때 사전에 외우고 있지 않아도 풀 수 있을 가능성이 높은 문제 유형이라는 특징이 있다.
반면 이후에 공부할 정렬, 최단 경로 등의 알고리즘 유형은 이미 그 알고리즘의 사용 방법을 정확히 알고 있어야만 해결 가능한 경우가 많다.
예를 들어 여러 개의 데이터를 빠르게 정렬해야 하는 문제는 정렬 라이브러리의 사용 방법을 알고 있어야 한다.
또 다른 예시로 최단 경로를 빠르게 찾아야 하는 문제는 플로이드 워셜 혹은 다익스트라 알고리즘과 같은 특정 알고리즘을 미리 알고 있거나 팀 노트를 통해 준비해야 풀 수 있다.
참고로 다익스트라 알고리즘은 엄밀히 말하면 그리디 알고리즘으로 분류되므로, 그리디 알고리즘이면서도 '암기'가 필요한 알고리즘이다.
다만, 그리디 알고리즘 자체가 문제 출제의 폭이 매우 넓기 때문에, 다익스트라 알고리즘과 같은 특히 케이스를 제외하고는 단순 암기를 통해 모든 문제를 대처하기 어렵다는 점을 이해하자.
이외에도 그리디 알고리즘 유형의 문제는 매우 다양하기 때문에 암기한다고 해서 항상 잘 풀 수 있는 알고리즘 유형이 아니다.
사전 지식 없이도 풀 수 있는 문제도 있겠지만, 많은 유형을 접해보고 문제를 풀어보며 훈련을 해야한다.
보통 코딩 테스트에서 출제되는 그리디 알고리즘 유형의 문제는 매우 다양하기 때문에 암기한다고 해서 항상 잘 풀수 있는 알고리즘 유형이 아니다.
사전 지식 없이도 풀 수 있는 문제도 있겠지만, 많은 유형을 접해보고 문제를 풀어보며 훈련을 해야 한다.
보통 코딩 테스트에서 출제되는 그리디 알고리즘 유형의 문제는 창의력, 즉 문제를 풀기 위한 최소한의 아이디어를 떠올릴 수 있는 능력을 요구한다.
다시 말해 특정한 문제를 만났을 때 단순히 현재 상황에서 가장 좋아보이는 것만을 선택해도 문제를 풀 수 있는지를 파악할 수 있어야 한다.
그리디 알고리즘은 기준에 따라 좋은 것을 선택하는 알고리즘이므로 문제에서 '가장 큰 순서대로', '가장 작은 순서대로'와 같은 기준을 알게 모르게 제시해준다.
대체로 이 기준은 정렬 알고리즘을 사용했을 때 만족시킬 수 있으므로 그리디 알고리즘 문제는 자주 정렬 알고리즘과 짝을 이뤄 출제된다.
거스름돈
문제
당신은 음식점의 계산을 도와주는 점원이다. 카운터에는 거스름돈으로 사용할 500원, 100원, 50원, 10원짜리 동전이 무한히 존재한다고 가정한다.
손님에게 거슬러 줘야 할 돈이 N원일 때 거슬러 줘야 할 동전의 최소 개수를 구하라.
단, 거슬러 줘야 할 돈 N은 항상 10의 배수이다.
문제 해설
이 문제는 그리디 알고리즘을 이용해 풀 수 있는 대표적인 문제로 간단한 아이디어만 떠올릴 수 있으면 문제를 해결할 수 있다.
그것은 바로 '가장 큰 화폐 단위부터' 돈을 거슬러 주는 것이다.
N원을 거슬러줘야 할 때, 가장 먼저 500원으로 거슬러 줄 수 있을 만큼 거슬러 준다.
그다음 100원, 50원, 10원 짜리 동전을 차례로 거슬러 줄 수 있을 만큼 거슬러 주면 최소한의 동전 개수로 모두 거슬러 줄 수 있다.
package CodeTest;
public class Greedy {
public static void main(String[] args) {
int N = 1260;
int cnt = 0;
// 큰 단위의 화폐부터 차례대로 확인
int coinType[] = {500, 100, 50, 10};
for(int i=0; i<coinType.length; i++){
int coin = coinType[i];
cnt += N / coin;
N %= coin;
}
System.out.println(cnt);
}
}
코드를 보면 화폐의 종류만큼 반복을 수행해야 한다.
따라서 화폐의 종류가 K개라고 할 때 위 소스 코드의 시간 복잡도는 O(K)이다.
참고로 시간 복잡도에서 거슬러 주어야 할 돈 N은 찾아볼 수 없는 것을 알 수 있다.
이 알고리즘의 시간 복잡도는 동전의 총 종류에만 영향을 받고, 거슬러 줘야 하는 금액의 크기와는 무관하다는 것을 알 수 있다.
그리디 알고리즘의 정당성
그리디 알고리즘을 모든 알고리즘 문제에 적용할 수 있는 거승ㄴ 아니다.
대부분의 문제는 그리디 알고리즘을 이용했을 때 '최적의 해'를 찾을 수 없는 가능성이 다분하다.
하스름 거스름돈 문제에서 '가장 큰 화폐단위부터' 돈을 거슬러 주는 것과 같이, 탐욕적으로 문제를 접근했을 때 정확한 답을 찾을 수 있다는 보장이 있을 때는 매우 효과적이고 직관적이다.
그리디 알고리즘으로 문제의 해법을 찾았을 때는 그 해법이 정당한지 검토해야 한ㄷ가.
거스름돈 문제를 그리디 알고리즘으로 해결할 수 있는 이유는 가지고 있는 동전 중에서 큰 단위가 항상 작은 단위의 배수이므로 작은 단위의 동전들을 종합해 다른 해가 나올 수 없기 때문이다.
예를 들어 800원을 거슬러 줘야 하는데, 화폐 단위가 500원, 400원, 100인 경우를 생각해보자.
이 경우에 그리디 알고리즘으로는 4개의 동전 (500원 + 100원 + 100원 + 100원)을 거슬러줘야 한다고 나오는데, 최적의 해는 2개의 동전(400원 + 400원)을 거슬러 주는 것이다.
다시 말해 이 문제에서는 큰 단위가 작은 단위의 배수 형태이므로, '가장 큰 단위의 화폐부터 가장 작은 단위의 화폐까지 차례대로 확인하여 거슬러 주는 작업만을 수행하면 된다'는 아이디어는 정당하다.
대부분의 그리디 알고리즘 문제는 이처럼 문제 풀이를 위한 최소한의 아이디어를 떠올리고, 이것이 정당한지 검토할 수 있어야 답을 도출할 수 있다.
어떤 코딩테스트 문제를 만났을 때, 바로 문제 유형을 파악하기 어렵다면 그리디 알고리즘을 의심하고, 문제를 해결할 수 있는 탐욕적인 해결법이 존재하는지 고민해야 한다.
만약 오랜 시간을 고민해도 그리디 알고리즘으로 해결 방법을 찾을 수 없다면, 다이나믹 프로그래밍이나 그래프 알고리즘 등으로 문제를 해결할 수 있는지를 재차 고민해보는 것도 한 방법이다.
처음 문제를 만났을 때는 이것저것 다양한 아이디어를 고려해야 한다.
가장 먼저 10원짜리로만 모두 거슬러 주도록 코드를 작성하면 어떻게 되지 라고 생각할 수 있다.
가능한 또 다른 문제 풀이 방법을 하나씩 곰곰히 생각해보는 것이다.
실제로 거스름돈 문제에서 동전(화폐)의 단위가 서로 배수 형태가 아니라, 무작위로 주어진 경우에는 그리디 알고리즘으로 해결할 수 없다.
화폐의 단위가 무작위로 주어진 문제는 다이나믹 프로그래밍으로 해결할 수 있다.
큰 수의 법칙
package CodeTest;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.Arrays;
import java.util.StringTokenizer;
public class Greedy0202 {
public static void main(String[] args) throws IOException {
BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
StringTokenizer st = new StringTokenizer(br.readLine());
int N = Integer.parseInt(st.nextToken()); // 배열의 크기 N
int M = Integer.parseInt(st.nextToken()); // 더하기 횟수 M
int K = Integer.parseInt(st.nextToken()); // 연속더하기 불가한 횟수 K
// K <= M
int num[] = new int[N];
st = new StringTokenizer(br.readLine());
for(int i=0; i<N; i++){
num[i] = Integer.parseInt(st.nextToken());
}
Arrays.sort(num); //정렬
int first = num[N-1]; //첫번째로 큰 수
int second = num[N-2]; // 두번째로 큰수
//가장 큰 수가 더해지는 횟수 계산
int count = (M/ (K+1)) * K;
count += M % (K+1); // M / (K+1)이 나머지가 발생할때 추가로 더해줘야 한다.
int ans = 0;
ans += count * first; // 큰수 더하 기
ans += (M-count) * second; //두번 째로 큰 수 더하기
System.out.println(ans);
}
}
숫자 카드 게임
package CodeTest;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.StringTokenizer;
public class Greedy0304 {
public static void main(String[] args) throws IOException{
BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
StringTokenizer st = new StringTokenizer(br.readLine());
int N = Integer.parseInt(st.nextToken()); // 행의 개수 N
int M = Integer.parseInt(st.nextToken()); // 열의 개수 M
int card[][] = new int[N][M];
int result = 0;
for(int i=0; i<N; i++){
st = new StringTokenizer(br.readLine());
int minNum = 10_001;
for(int j=0; j<M; j++){
card[i][j] = Integer.parseInt(st.nextToken());
minNum = Math.min(minNum, card[i][j]);//행에서 가장 낮은 수
}
result = Math.max(minNum, result); //행들 중에서 가장 큰 수
}
System.out.println(result);
}
}
1이 될 때 까지
package CodeTest;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.StringTokenizer;
public class Greedy0304 {
public static void main(String[] args) throws IOException{
BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
StringTokenizer st = new StringTokenizer(br.readLine());
int N = Integer.parseInt(st.nextToken()); // 자연수 N
int K = Integer.parseInt(st.nextToken()); // 나눌 수 K
// N >= K
int cnt = 0;
while(true){
//(N == K로 나누어떨어지는 수)가 될 때까지 1씩 빼기
int target = (N / K) * K;
cnt += (N - target);
N = target;
// N이 K보다 작을 때 (더이상 나눌 수 없을 때) 종료
if(N < K){
break;
}
// K로 나누기
cnt += 1;
N /= K;
}
cnt += (N-1);
System.out.println(cnt);
}
}