그리디 알고리즘(Greedy Algorithm)
당장 좋은 것만 선택하는 방법을 그리디(탐욕법) 알고리즘이라고 하며 단순하지만 강력한 문제 해결 방법이다.
그리디 알고리즘은 '현재 상황에서 지금 당장 좋은 것만을 고르는 방법'을 의미한다.
현재의 선택이 나중에 미칠 영향에 대해서는 고려하지 않는다.
이는 사전에 외우고 있지 않아도 풀 수 있을 가능성이 높은 문제 유형이라는 특징이 있다.(정렬, 최단 경로 등의 알고리즘 유형의 경우 미리 알고리즘 사용 방법을 숙지하고 있어야 해결 가능한 경우가 많다.)
그리디 알고리즘 자체가 문제 출제 폭이 매우 넓기 때문에 최단 경로 알고리즘에 속하는 다익스트라 알고리즘은 엄밀히 말하면 그리디 알고리즘이므로 그리디 알고리즘이면서도 '암기'가 필요한 알고리즘도 있을 수 있다.
따라서 그리디 알고리즘의 경우, 많은 유형을 접해보고 문제를 풀어보며 훈련을 해야한다.
그리디 알고리즘의 경우, 창의력 즉 문제를 풀기 위한 최소한의 아이디어를 떠올릴 수 있는 능력을 요구한다. 다시말해 이 문제가 그리디 알고리즘을 적용해서 풀 수 있는지를 파악할 수 있어야 한다.
그리디 알고리즘은 기준에 따라 좋은 것을 선택하는 알고리즘이므로 문제에서 '가장 큰 순서대로', '가장 작은 순서대로'와 같은 기준을 알게 모르게 제시해준다. 대체로 이 기준은 정렬 알고리즘을 사용했을 때 만족시킬 수 있으므로 그리디 알고리즘 문제는 자주 정렬 알고리즘과 묶여서 출제된다.
예제 1. 거스름돈
Q. 거스름돈으로 사용할 500원, 100원, 50원, 10원짜리 동전이 무한히 존재한다고 가정한다. 손님에게 거슬러 줘야할 돈이 N원일 때 거슬러줘야 할 동전의 최소 개수를 구하라. 단, 거슬러 줘야 할 돈 N은 항상 10의 배수이다.
A. 이 문제는 '가장 큰 화폐 단위부터' 돈을 거슬러 주면 된다. 간단하게 이정도 아이디어만 떠올리면 문제를 풀 수 있다. 화폐의 종류가 K개라고 할 때 해당 소스코드의 시간 복잡도는 O(K)이다.
그리디 알고리즘의 정당성
모든 알고리즘 문제에 그리디 알고리즘을 적용할 수 있는 것은 아니다. 대부분의 문제는 그리디 알고리즘을 이용했을 때 '최적의 해'를 찾을수 없을 가능성이 크다. 하지만 탐욕적으로 문제에 접근했을 때 정확한 답을 찾을 수 있는 보장이 있는 경우 이것만큼 효과적이고 직관적인 방법이 없다고 할 수 있다.
그래서 그리디 알고리즘으로 문제의 해법을 찾은 경우 그 해법이 정당한지 검토해야 한다. 위 거스름돈 문제를 그리디 알고리즘으로 풀 수 있는 이유는 가지고 있는 동전 중에서 큰 단위가 항상 작은 단위의 배수이므로 작은 단위의 동전들을 종합해 다른 해가 나올 수 없기 때문이다.
예를 들어, 100원, 60원, 10원 동전이 있고 120원을 거슬러줘야한다고 할 때, 그리디 알고리즘으로 할 경우 100 + 10 + 10이라 3이 나오지만, 실제 최적해는 60 + 60의 2가 되어야한다.(화폐가 무작위로 구성된 경우 다이나믹으로 풀 수 있다. 8장에 나온다.) 하지만 위 예제의 경우 이렇지 않기 때문에 ‘가장 큰 단위의 화폐부터 가장 작은 단위의 화폐까지 차례대로 확인하여 거슬러 주는 작업만을 수행해주면 된다’라는 아이디어는 정당하다고 볼 수 있다.
바로 문제 유형을 파악하기 어렵다면 그리디 알고리즘을 생각해보고 해결 가능한 탐욕적 해결법이 존재하는지 고민해보자. (이후에 다이나믹, 그래프 등을 고민)
실전문제 1. 큰 수의 법칙
<문제>
동빈이의 큰수의 법칙은 다양한 수로 이루어진 배열이 있을 때 주어진 수들을 M번 더하여 가장 큰수를 만드는 방법이다. 단, 배열의 특정한 인덱스에 해당하는 수가 연속해서 K번을 초과하여 더해질 수 없는 것이 이 법칙의 특징이다.
예를 들어 순서대로 2, 4, 5, 4, 6으로 이루어진 배열이 있을 때, M이 8이고 K가 3이라고 가정하자.
이 경우 특정한 인덱스의 수가 연속해서 세 번까지만 더해질 수 있으므로 큰 수의 법칙에 따른 결과는 6 + 6 + 6 + 5 + 6 + 6 + 6 + 5인 46이 된다. 단, 서로 다른 인덱스에 해당하는 수가 같은 경우에도 서로 다른 것으로 간주한다.
예를 들어 순서대로 3, 4, 3, 4, 3으로 이루어진 배열이 있을 때 M이 7이고 K가 2라고 가정하자. 이 경우 두 번째 원소에 해당하는 4와 네 번째 원소에 해당하는 4를 번갈아 두 번씩 더하는 것이 가능하다.
결과적으로 4 + 4 + 4 + 4 + 4 + 4 + 4 인 28이 도출된다.
배열의 크기 N, 숫자가 더해지는 횟수 M, 그리고 K가 주어질 때 동빈이의 큰 수의 법칙에 따른 결과를 출력하시오.
<입력 조건>
- 첫째 줄에 N(2 <= N <= 1000), M(1 <= M <= 10000), K(1 <= K <= 10000) 의 자연수가 주어지며 각자연수는 공백으로 구분한다.
- 둘째 줄에 N개의 자연수가 주어진다. 각 자연수는 공백으로 구분한다.
단, 각각의 자연수는 1 이상 10000 이하의 수로 주어진다.
입력으로 주어지는 K는 항상 M보다 작거나 같다.
<출력 조건>
- 첫째 줄에 동빈이의 큰수의 법칙에 따라 더해진 답을 출력한다.
<입력 예시>
- 5 8 3
2 4 5 4 6
<출력 예시>
46
해설 풀이(반복되는 수열 규칙 파악)
# N, M, K를 공백을 기준으로 구분하여 입력 받기
n, m, k = map(int, input().split())
# N개의 수를 공백을 기준으로 구분하여 입력 받기
data = list(map(int, input().split()))
data.sort() # 입력 받은 수들 정렬하기
first = data[n - 1] # 가장 큰 수
second = data[n - 2] # 두 번째로 큰 수
# 가장 큰 수가 더해지는 횟수 계산
count = int(m / (k + 1)) * k
count += m % (k + 1)
result = 0
result += (count) * first # 가장 큰 수 더하기
result += (m - count) * second # 두 번째로 큰 수 더하기
print(result) # 최종 답안 출력
필자는 반복되는 수열 규칙을 파악하는 것까진 생각하지 못하고 반복문을 통해 풀었다.
실전문제 2. 숫자 카드 게임
<문제>
숫자 카드 게임은 여러 개의 숫자 카드 중에서 가장 높은 숫자가 쓰인 카드 한장을 뽑는 게임이다. 단, 게임의 룰을 지키며 카드를 뽑아야 하고 룰은 다음과 같다.
1. 숫자가 쓰인 카드들이 N × M 형태로 놓여 있다. 이때 N은 행의 개수를 의미하며, M은 열의 개수를 의미한다.
2. 먼저 뽑고자 하는 카드가 포함되어 있는 행을 선택한다.
3. 그다음 선택된 행에 포함된 카드들 중 가장 숫자가 낮은 카드를 뽑아야 한다.
4. 따라서 처음에 카드를 골라낼 행을 선택할 때, 이후에 해당 행에서 가장 숫자가 낮은 카드를 뽑을 것을 고려하여 최종적으로 가장 높은 숫자의 카드를 뽑을 수 있도록 전략을 세워야 한다.
<입력 조건>
- 첫째 줄에 숫자 카드들이 놓인 행의 개수 N과 열의 개수 M이 공백을 기준으로 하여 각각 자연수로 주어진다. (1<=N,M<=100)
- 둘째 줄부터 N개의 줄에 걸쳐 각 카드에 적힌 숫자가 주어진다. 각 숫자는 1이상 10,000 이하의 자연수이다.
<출력 조건>
- 첫째 줄에 게임의 룰에 맞게 선택한 카드에 적힌 숫자를 출력하다.
<입력 예시 1>
- 3 3
3 1 2
4 1 4
2 2 2
<출력 예시 1>
2
<입력 예시 2>
- 2 4
7 3 1 8
3 3 3 4
<출력 예시 2>
3
사고 과정
1. 각 행에서 가장 작은 수들 중 가장 큰 수를 찾는 문제
2. 전체를 돌아보는 방법말고 찾을 수 있는 방법이 있을까?
3. 생각이 안나니까 전체를 반복문으로 돌린다.(이게 그리디 알고리즘은 아닌 것 같은데)
내 풀이
n, m = map(int, input().split())
mini=10000
l=[]
for i in range(n):
l.append(list(map(int, input().split())))
for i in l:
mini2=min(i)
if mini > mini2:
mini=mini2
print(mini2)
해설 풀이
# N, M을 공백을 기준으로 구분하여 입력 받기
n, m = map(int, input().split())
result = 0
# 한 줄씩 입력 받아 확인하기
for i in range(n):
data = list(map(int, input().split()))
# 현재 줄에서 '가장 작은 수' 찾기
min_value = min(data)
# '가장 작은 수'들 중에서 가장 큰 수 찾기
result = max(result, min_value)
print(result) # 최종 답안 출력
자체 피드백
해설에서는 위처럼 min() 함수를 사용한 방식과 2중 반복문 구조를 이용하는 답안을 제시했다.
min() 함수를 사용하는 것은 괜찮았지만 해설과 달리 불필요한 반복문이 한번 들어간 부분을 잘 생각해보자.
이 문제에서 그리디를 생각할 수 있는 부분은 '각 행마다 가장 작은 수를 찾은 뒤에 그 수 중에서 가장 큰 수'를 찾는다는 부분이다.
실전문제 3. 1이 될 때까지
<문제>
어떠한 수 N이 1이 될 때까지 다음의 두 과정 중 하나를 반복적으로 선택하여 수행하려고 한다. 단, 두번째 연산은 N이 K로 나누어떨어질 때만 선택할 수 있다.
1. N에서 1을 뺀다.
2. N을 K로 나눈다.
예를 들어 N이 17, K가 4라고 가정하자. 이때 1번의 과정을 한 번 수행하면 N은 16이 된다. 이후에 2번의 과정을 두 번 수행하면 N은 1이 된다. 결과적으로 이 경우 전체 과정을 실행한 횟수는 3이 된다. 이는 N을 1로 만드는 최소 횟수이다.
N과 K가 주어질 때 N이 1이 될 때까지 1번 혹은 2번의 과정을 수행해야 하는 최소 횟수를 구하는 프로그램을 작성하시오.
<입력 조건>
- 첫째 줄에 N(2≤ N ≤ 100,000)과 K(2≤ K ≤ 100,000)가 공백으로 구분되며 각각 자연수로 주어진다. 이때 입력으로 주어지는 N은 항상 K보다 크거나 같다.
<출력 조건>
- 첫째 줄에 N이 1이 될때까지 1번 혹은 2번의 과정을 수행해야 하는 횟수의 최솟값을 출력한다.
<입력 예시>
- 25 5
<출력 예시>
2
사고 과정
1. 입력 최대가 10만이면 최소 O(NlogN)인걸로 만들어야 하지 않을까?
2. 반복문 안에서 케이스를 나눠서 계산을 반복하면 되지 않을까?
내 풀이
n, k=map(int, input().split())
count=0
while n!=1:
if n%k==0:
n//=k
else:
n-=1
count+=1
print(count)
해설 풀이
# N, K공백을 기준으로 구분하여 입력 받기
n, k = map(int, input().split())
result = 0
while True:
# N이 K로 나누어 떨어지는 수가 될 때까지만 1씩 빼기
target = (n // k) * k
result += (n - target)
n = target
# N이 K보다 작을 때 (더 이상 나눌 수 없을 때) 반복문 탈출
if n < k:
break
# K로 나누기
result += 1
n //= k
# 마지막으로 남은 수에 대하여 1씩 빼기
result += (n - 1)
print(result)
자체 피드백
이 문제에서 그리디를 생각할 수 있는 부분은 주어진 N에 대해 '최대한 많이 나누기'를 수행하면 된다는 것이다.
2 이상의 수로 나누는 것이 1로 빼는 것보다 숫자를 훨씬 많이 줄일 수 있기 때문이다. 문제에서는 K가 2 이상의 자연수이므로 가능하면 나누는 것이 항상 더 숫자를 빠르게 줄이는 방법이 된다. 그러므로 K로 최대한 많이 나눌 수 있는 것이 최적의 해를 보장하는 것이다.