피보나치 수열이란 앞의 두 수의 합이 바로 뒤의 수가 되는 수열을 의미한다. 피보나치 수 은 다음과 같은 초기값 및 점화식으로 정의되는 수열이다.
이 피보나치 수열을 간단하게 재귀함수로 구현하면 다음과 같다.
# 재귀함수로 구현
def fibo(n):
if n == 1 or n == 2:
return 1
return fibo(n-1) + fibo(n-2)
# 한 줄로도 가능
# return fibo(n-1) + fibo(n-2) if n >= 2 else n
print(fibo(10))
알고리즘 설계 기법과 문제 해결 접근 전략을 이야기하는데 갑자기 피보나치가 등장해서 의아할 수 있다.
피보나치 수열의 경우 재귀함수를 설명하는데 사용되는 대표적인 문제이다. 하지만 이 코드는 치명적인 단점이 존재하는데, 바로 항이 커질수록 계산속도도 매우 느려진다는 점이다.
동일한 계산이 반복적으로 수행되며 시간 복잡도는 이 된다. 이런 문제를 해결하기 위해서 재귀함수로 구성하는 것이 아닌 Dynamic Programming방법을 이용한다면 단점을 보완하며 더 빠른 속도로 문제를 해결할 수 있다.
하나의 문제를 여러 작은 문제로 나누고, 작은 문제의 답을 재사용하여 문제를 효율적으로 푸는 것
💡Dynamic Programming의 조건
1. 작은 문제들이 반복된다.
2. 같은 문제는 구할때 마다 정답이 동일하다.
위와 같은 조건을 충족할때만 동적 프로그래밍을 사용할 수 있다. 작은 문제의 결과 값이 항상 같다는 점을 이용해서 큰 문제를 해결하는 방법이니 당연한 것이다.
이제 위에서 나온 피보나치 수열문제를 메모이제이션으로 구성하면 다음과 같은 코드가 된다.
# 메모이제이션
def fibo_memo(n):
# n이 2보다 작거나 같은 경우
if n <= 2:
return 1
else:
# 계산된 이력이 있는 경우
if memo_[n] != 0:
# 해당 함수값 반환하고 호출 종료
return memo_[n]
else:
# f(n) = f(n-1) + f(n-2)
memo_[n] = fibo_memo(n-1) + fibo_memo(n-2)
# 결과값 반환 (f(n))
return memo_[n]
num = 10
# 값 저장용 리스트(계산된 숫자는 해당 숫자의 인덱스에 값이 저장됨)
memo_ = [0]*(num+1)
print(fibo_memo(num))
코드를 보게 되면 계산된 이력이 있는지 살펴보고 없다면 아래의 재귀함수 부분으로 들어가게 된다. 즉 위에서부터(큰 수부터) 계산된 이력이 있는지 파악하면서 작은 수로 내려가기 때문에 하향식 방식이라고 부른다.
위 코드의 시간 복잡도는 의 시간복잡도를 가지게 된다.
# 태뷸레이션
def fibo_tabul(n):
# 태뷸레이션 : Botton-Top 방식
# 0~2번째 값을 먼저 설정
tab = [0, 1, 1]
# 3번째 인덱스부터 진행. i번째 자리에 i-1, i-2의 값을 합쳐서 append -> for문
for i in range(3, n + 1):
tab.append(tab[i-1] + tab[i-2])
return tab[n]
print(fibo_tabul(10))
이 코드 역시 시간복잡도는 에 해당한다. 하지만 이미 계산된 값을 저장한 리스트에서 값을 가져오는 작업 자체는 의 시간 복잡도를 가진다.
두 방법 중 어느 것이 시간적으로 더 효율적인지 묻는다면, 그에 대한 답은 "알 수 없다"이다.
Top-Down 방식을 사용하는 Memoization은 재귀를 통해 답을 찾아 내려간다. 그렇다보니 재귀함수 호출로인한 Stack이 쌓여서 stackOverFlow
같은 에러가 발생할 수 있다. 특히 Python에서는 해당 케이스가 잦다보니 이럴 경우 Bottom-up방식의 Tabulation으로 풀면된다.
두 방법 중 두 가지를 모두 사용하지 못하고 하나만 사용할 수 있는 경우가 있는지에 대해서는 있다고 할 수 있지만 그것은 동적 프로그래밍에 익숙해지고 경험적으로 알아낼 수 있는 부분이라고 한다.
우리가 프로그래밍을 배울 때 항상 기억해야하는 것은 모든 알고리즘은 기존에 있던 문제를 해결하기 위해서 고안되었다는 것이다.
완전 탐색, DFS,BFS와 같은 알고리즘은 수많은 경우의 수를 전부 따져봐야 하는데 그 경우의 수가 너무 많아서 속도가 느려지는 문제를 개선하고자, 수행 시간을 단축하고자 만들어진 알고리즘이 Dynamic programming이다.
💡 Dynamic Programming의 유형
- DFS/BFS로 풀 수 있지만 경우의 수가 너무 많을 때
- 패턴을 파악하여 경우의 수가 얼마나 증가할지 고려
- DFS나 완전 탐색으로 진행하는 maginot line은 500만 개의 경우의 수로 볼 수 있다.
- 을 넘어가는 경우의 수라면 동적 프로그래밍방법 사용을 고려해 볼 것.
- 경우의 수들에 중복적인 연산이 많은 경우
- DP를 사용하게 되는 상황: 이진 검색, 최단경로 알고리즘, 최적화 문제, 외판원 문제
👉 정리: DP는 최적 부분 구조로 구성된 중복된 서브문제를 분할정복으로 해결한다.
뒤는 생각하지 않고 오로지 매 순간 현재 최선인 답을 선택하여 최종적으로 최적값, 또는 근사값을 구하는 방법
Greedy Algorithm은 크게 2가지 경우에서 사용한다.
- Greedy Algorithm으로 최적의 해를 찾을 수 있는 경우
Greedy Algorithm은 다른 알고리즘에 비해 코드를 쉽게 작성할 수 있고 처리 속도 또한 뛰어나다.- 최적의 해를 계산하는데 시간이 오래걸리는 문제에 대해 Greedy Algorithm을 이용하면 적당히 빠르면서 괜찮은 근사해를 구할 수 있는 경우
💡 풀이 방식
기본적으로 그때그때 가장 좋은 해결책을 찾아가는 기법이다. 해를 구하는 일련의 선택과정 마다 가장 좋아보이는 최선을 선택하면, 전체적으로 최적 해를 구할 수 있다는 방법론이다. 각 단계마다 최상으로 보이는 해결핵으로 구한 해들을 모아서 제시하게 된다.
💡 적용문제
- 동전 거스름돈을 가장 적은 수의 동전으로 주는 문제
- 최단경로 알고리즘(다익스트라 알고리즘)
- 최소비용 신장트리(Spanning Tree)를 구하는 알고리즘(크루스칼 알고리즘, 프림 알고리즘 등)(참고)
# 탐욕 알고리즘 예제: 잔돈
# 잔돈갯수를 구하자.(갖고 있는 돈 : 100원)
price = int(input('물건값을 입력하세요.'))
# 거스름돈
change = 100 - price
print(f'잔돈은 {change}원입니다.')
coin_list = [50, 40, 20, 10, 5] # 받을 수 있는 잔돈의 종류. 크기순으로 적는다.(중요)
change_count = [] # 잔돈갯수
while change != 0:
for coin in coin_list:
change_bool = 0 # (동전 종류마다)동전 갯수에 대한 변수 생성
# Greedy: 우선 금액이 큰 동전부터 거슬러준다.
change_bool = change_bool + (change // coin) # 몫이 동전의 갯수.
print(change_bool)
change_count.append(change_bool) # 잔돈 갯수 리스트에 추가
change = change - (change_bool * coin) # 잔돈 갱신
print(coin, change_count)
print('잔돈갯수 :',sum(change_count)) # 잔돈의 갯수를 합한다.(sum 내장함수 활용)
❗️ 참고자료
1. 코드스테이츠 교육자료