꺼내먹는지식 준

다이나믹 프로그래밍 본문

코딩테스트총정리

다이나믹 프로그래밍

알 수 없는 사용자 2022. 8. 10. 00:33

다이나믹 프로그래밍

컴퓨터로 해결하기 어려운 문제는 최적의 해를 구하기에 시간이 매우 많이 필요하거나 메모리 공간이 매우 많이 필요한 문제들이 해당된다. 

 

특정 문제들을 메모리 공간을 약간 더 사용하면 연산 속도를 비약적으로 증가시킬 수 있는 방법이 있다. 

 

대표적 방법이 바로 다이다믹 프로그래밍이다.  

 

(동적 할당이란 '프로그램이 실행되는 도중'에 필요한 메모리를 할당하는 기법이다. 하지만 다이나믹 프로그래밍의 다이나믹은 이 정의에 해당되지 않는다.)

 

다이나믹 프로그램의 대표적 예시

 

피보나치 수열 

https://news.samsungdisplay.com/23402

점화식은 수열의 항이 이어지는 형태를 간결하게 표현한다. 

피보나치 수열의 점화식은 다음과 같다. 

$$a_{n+2} = f(a_{a+1}, a_n) = a_{n+1} + a_n$$

$$a_1 = 1, \, a_2 = 1$$

 

기존 우리가 배운 방법으로 피보나치 수열을 해결한다면 재귀함수를 사용할 것이다. 

해당 경우 $fib(7)$ 은 아래의 그림과 같이 해결이 된다. 

https://shoark7.github.io/programming/algorithm/%ED%94%BC%EB%B3%B4%EB%82%98%EC%B9%98-%EC%95%8C%EA%B3%A0%EB%A6%AC%EC%A6%98%EC%9D%84-%ED%95%B4%EA%B2%B0%ED%95%98%EB%8A%94-5%EA%B0%80%EC%A7%80-%EB%B0%A9%EB%B2%95

동일한 연산이 계속해서 반복되는 것을 알 수 있다. 

피보나치 수열의 시간 복잡도는 $/theta( 1.618...^N )$ 이라고 한다. 하지만 빅오 표현식으로 단순화 하여 $O(2^N)$ 과 같이 표현한다. 만약 N = 30 이면, 약 10억 가량의 연산이 수행된다. 

 

즉, 반복적인 연산을 통해 10억번의 연산을 하는 것은 너무나 큰 자원낭비다. N = 100 이라면 현대 컴퓨터로는 도저히 개산이 불가능하다. 

그러나 피보나치 수열을 살펴봐서 알겠지만, 사실상 N = 100 이어도 다른 숫자가 등장하는 경우는 100번 외에는 없다. 즉 동일한 수를 다시계산하는 과정이 반복된다. 

이 때 다이나믹 프로그래밍을 사용한다. 

다이나믹 프로그래밍은 다음의 조건을 만족할 때 사용가능하다. 

  • 큰 문제를 작은 문제로 나눌 수 있다.
  • 작은 문제에서 구한 정답은 그것을 포함하는 큰 문제에서도 동일하다. 

메모이제이션

다이나믹 프로그래밍을 구현하는 방법 중 한 종류

한번 구한 결과를 메모리 공간에 메모해두고 같은 식을 다시 호출하면 메모한 결과를 그대로 가져오는 기법 

메모이제이션은 값을 저장하는 방식이므로 캐싱이라고도 한다.

 

예제 

import sys 
input = sys.stdin.readline

N = int(input())

d = [0] * 100 

def fib(n):
    if n == 1 or n == 2:
        return 1 
    if d[n] != 0:
        return d[n]
    d[n] = fib(n-1) + fib(n-2)
    return d[n]

print(fib(N))

fib(1 .. 9) 가 먼저 채워지고 나면, 그 이후로는 반복할 일이 없어서 필요 연산 수가 기하급수적으로 줄어든다. 

아래를 참고하자. 

https://mlleo.github.io/cs/algorithm_Dynamic_Programming/

 

분할정복(divide and conquer) 즉, 큰 문제를 작은 문제로 작게 나누는 방법은 퀵 정렬에서도 소개된 적 있다. 그러나 분할 정복과 다이나믹 프로그래밍의 차이는 다이나믹 프로그래밍은 문제들이 서로 영향을 미치고 있다는 점이다. 

 

이 처럼 다이나믹 프로그래밍은 반복되는 연산 횟수를 기하급수적으로 줄여주는데, 다만 재귀 함수를 사용하면 컴퓨터 시스템에서 함수를 다시 호출했을 때 메모리 상에 적재되는 일련의 과정을 따라야 하므로 오버헤드가 발생하곤 한다. 이에 따라 반복문을 사용하면 오버헤드를 줄일 수 있고, 반복문 자체가 성능이 주로 더 좋다. 

다이나믹 프로그래밍으로 해결한 피보나치 수열 알고리즘의 시간 복잡도는 $O(N)$ 이다. 

 

탑다운 보텀업 방식 

탑다운: 큰문제해결하기 위해 작은 문제를 호출

보텀업: 단순히 반복문을 이용하여 소스코드를 작성하는 경우 작은 문제부터 차근 차근 답을 도출

 

보텀업 방식으로 피보나치 수열을 해결하면 다음과 같다. 

d[1] = 1
d[2] = 1
ans = 0

for i in range(1, N-1):
    d[i+2] = d[i] + d[i+1]

print(d[N])

 

보텀업 방식에서 사용되는 결과 저장용 리스트는 'DP 테이블' 이라고 부르며, 메모이제이션탑다운 방식에 국한되어 사용되는 표현이다. 

 

메모이제이션 팁

  • 메모이제이션은 엄밀히 말하면 한번 계산된 결과를 어딘가에 담아놓는 행위 자체를 의미하므로 꼭 다이나믹 프로그래밍과 관련이 있지는 않다. 
  • 수열처럼 연속적이지 않은 경우 사전(dict) 자료형을 사용하는 것이 효과적일 수 있다. 

다이나믹 프로그래밍 유형 팁 

  • 주어진 문제가 다이나믹 프로그래밍 조건에 해당하는지 확인한다.
  • 단순히 재귀 함수로 비효율적으로 작성한 후, 작은 문제에서 구한 답이 큰 문제에서 그대로 사용될 수 있으면, (즉 메모이제이션이 가능하면) 소스코드를 수정한다. 
  • 더나아가 가능하면 재귀 함수보다는 보텀업 방식으로 구현한다. (재귀함수는 스택 크기가 한정되어있다.) sys.setrecursionlimit() 함수 호출로 재귀 제한을 완화할 수 있다. 

 


실전 예제

1) 1로 만들기

정수 X가 주어질 때 정수 X에 사용할 수 있는 연산은 다음과 같이 4가지 이다. 

  • X가 5로 나누어떨어지면, 5로 나눈다. 
  • X가 3로 나누어떨어지면, 3로 나눈다. 
  • X가 2로 나누어떨어지면, 2로 나눈다. 
  • X에서 1을 뺀다. 

정수 X가 주어졌을 때, 연산 4개를 적절히 사용해서 1을 만들려 한다. 연산 사용의 최솟값은? 

 

재귀 

import sys
input = sys.stdin.readline

N = int(input())

lst = []

def oper(n, cnt, lst):
    if n == 1:
        lst.append(cnt)
        return lst
    if n % 5 == 0: 
        oper(n/5, cnt+1,lst)
    if n % 3 == 0:
        oper(n/3, cnt+1,lst)
    if n % 2 == 0:
        oper(n/2, cnt+1,lst)
    return oper(n-1, cnt+1,lst)

oper(N, 0, lst)
print(len(lst))
print(min(lst))

 

해당 문제는 X 의 범위가 (0 < X < 30,002 ) 이다. 

재귀 함수로만 풀려고 하면 다음과 같은 상황이 벌어진다. 

X = 50 일 때, 총 2352번의 연산이 일어나고, 

X = 200 일 때, 총 791862번의 연산이 일어난다. 

 

이미 200 번만 해도 연산이 약 100만번에 이르므로 해당 방법으로는 문제를 풀 수 없다. 

 

해당 문제는 다이나믹 프로그래밍으로 해결을 해야 한다. 

규칙은 다음과 같다. 

마치 피보나치와 같이 특정 값에 여러 연산이 적용될 수 있어도 결국 같은 수에 대한 결과는 같다. 

 

탑다운

d = [0] * 30001

def oper(n):
    if n == 1:
        return 0
    if d[n] != 0:
        return d[n]
    d[n] = oper(n-1) + 1 
    if n % 5 == 0:
        d[n] = min(oper(n//5) + 1, d[n])
    if n % 3 == 0:
        d[n] = min(oper(n//3) + 1, d[n])
    if n % 2 == 0:
        d[n] = min(oper(n//2) + 1, d[n])
    return d[n]

oper(N)
print(d[N])

메모이제이션 기법으로 반복되는 내용을 저장하고 푼 방식이다. 

재귀만 사용한 방법과 동작 시간을 비교해보면 아래와 같다. 

X = 50, 재귀 기법: 0.00233   메모이제이션: 0.000193

X = 200, 재귀 기법: 0.27059 메모이제이션: 0.00100

X = 300, 재귀 기법: 2.008     메모이제이션: 0.00095

X = 350, 재귀 기법: 4.589     메모이제이션: 0.001416

X = 400, 재귀 기법: 9.776     메모이제이션: 0.001255

 

시간이 지날 수록 더 큰 차이가 난다. 

재귀만 사용하면 X = 500 을 구하기도 힘들지만, 메모이제이션 기법으로는 금방 구할 수 있다. 

다만 문제는 

X = 1000 일때를 구해보고자 하면 recursion depth 문제가 발생한다. 

이 때, sys.setrecursionlimit() 함수를 사용하면 depth 를 완화하여 해결 가능하다. 

sys.setrecursionlimit(10 ** 6)

X = 1000 메모이제이션 0.0023200

X = 29000 메모이제이션 0.03386

X = 30000 Segmentation fault: 11 recursion depth 를 상당히 깊게 설정했음에도 불구하고 깊이가 걸렸다. 

감당할 수 없는 깊이일 때 Segmentation fault가 나온다고 한다. 

 

즉, 해당 문제는 recursion 으로 해도 tough한 케이스에서 걸리고 만다. 

위에서 배웠듯이 다음의 탑다운 방식은 보텀업 방식으로도 구현 가능하므로 살펴보자. 

 

※ 재귀로 구현할 때 유의사항 

 

재귀함수를 구현할 때 함수를 어떻게 어디부터 짜야하나 햇갈리곤 한다. 그때 다음의 항목을 기억하면 구해야 하는 초점에서 벗어나지 않고 중점을 잘 지킬 수 있을 것 같다. 



  • 내가 구하고자 하는 것이 무엇인지 파악 

현재 내가 구하고자 하는 것은 최소 횟수이다. 즉, 횟수를 재귀함수를 통하여 찾고자 한다. 

 

  • 종료 조건 

종료 조건때 무엇을 return 할 것인가도 중요하다. 

 

  • 각 조건에 따른 return  

위 문제의 경우 recursion 을 call 할 때마다 count 를 증가시켜 파라미터로 넘겨줌으로써 종료조건 때 해당하는 count 를 return 해줄 수도 있다. list 에 저장해서 min 을 취해줘도 충분히 값을 찾을 수는 있다. 

하지만, 이 경우 각 조건에 따라 count 는 올려주되 최종적으로 무엇을 return 할지 명확하지 않다. 

또한, 재귀함수의 철학에 맞지 않아 보인다. 물론 코딩을 통해 효율적이지 않은 방법으로도 구현을 할 수 있지만, 각 기법은 최대의 효율을 위해 고안된 방법론이다. 즉 고안될 때 특정 사용방법을 위해 고안되었는데 다른 방법으로 사용하면 비효율적일 확률이 높다. 재귀를 통해 종료조건의 return 에서 부터 stack에 있는 함수들을 차근 차근 빼내며 우리가 원하는 답에 가까워진다고 할 때, 매번 함수콜에서 count 를 키워주는 것은 재귀 함수에 대한 올바른 이해랑 거리가 멀다. 즉 위 구현과 같이 종료조건의 return 으로 부터 매번 함수 콜에서 더해나가는 것이 올바른 방법이다. 

 

다음을 기억하고 있으면 재귀함수를 구현하는 것이 쉽다. 

위 내용이 정리되지 않아 매번 재귀함수가 어려웠는데 머리속에 정리가 되고 나니 재귀함수가 오히려 다른 함수보다 쉽게 다가온다. 

 

보텀업

#when n = 1 
d[1] = 0

def oper(N):
    for i in range(2, N+1):

        if d[i] != 0: 
            continue

        d[i] = d[i-1] + 1
        if i % 5 == 0:
            d[i] = min(d[i//5] + 1, d[i])

        if i % 3 == 0:
            d[i] = min(d[i//3] + 1, d[i])

        if i % 2 == 0:
            d[i] = min(d[i//2] + 1, d[i])
    
    return d[N]      


print(oper(N))

 

코드 개선 

d[1] = 0

해당 블록은 생략 가능 (초기화시 0으로 생성하였다.)

 

if d[i] != 0:
	continue

해당 블록은 사용하는 의미가 없다. 

어차피 바톰업 방식으로 차근 차근 채워나갔기 때문에 미리 채워서 반복 사용하는 경우가 없다. 

 

 

X = 29000 메모이제이션 0.03386 보텀업 방식: 0.0001070

실제 동작 속도를 비교해보면 바톰업이 훨씬 빠른 걸 알 수 있다.

코딩 테스트에서 시간이 남으면 탑다운을 바톰업으로 구현하는건 언제나 좋은 선택이 될 수 있을 것 같다.

지금까지 경험으로 미뤄봤을 때는 머리속 논리를 정리하며 탑다운으로 구현하고 그 후 바톰업으로 접근하는 것이 맞는 것 같다. 


2) 바닥 공사 

가로 길이 N, 세로 길이 2인 직사각형 형태의 얇은 바닥이 있다. 

이 바닥을 1 X 2 덮개, 2 X 1 덮개, 2 X 2 덮개를 이용해 채우고자 한다. 

이때 바닥을 채우는 모든 경우의 수를 구하는 프로그램을 작성하라. 

 

해당 문제를 읽으면 그리디로 해결이 불가능한것이 느껴진다. brute force 로 접근해야 하지만, 모든 경우의 수를 반복하면 시간이 너무 오래걸리므로 어딘가에 내용을 저장하며 풀어야 한다는 것을 느낄 수 있다. 

 

일단 다음의 두가지 발견을 했다. 

1 X 2 는 한번 쓰이면 무조건 아래에 같이 채워 쓰여야 한다. 

2 X 1 는 N 이 홀수이냐 짝수이냐에 따라 쓰일 수 있는 개수 정도가 다르다. 만약 N 이 1개가 남으면 2 X 1 로 채워야 한다. 

 

탑다운 실패사례

import sys 
input = sys.stdin.readline
import time 

N = int(input())
d = [0] * 1001 
start = time.time()
#저장해야 하는 것이 최소 횟수가 아니라 
#가능한 경우의 수를 저장해야 한다 .

def oper(n):
    if n == 0:
        return 0
    if d[n] != 0:
        return d[n]

    d[n] = oper(n-1) + 1  
    if n > 1: 
        d[n] += oper(n-2) + 2 
    return d[n]

end = time.time()

oper(N)

첫 풀이 시도 

해당 문제는 오랜 기간 고민이 필요했다. 우선적으로 해당 문제에서 구하고자 하는 바를 찾았다. 

가능한 모든 경우의 수에 대해 파악이 필요하다. 

별다른 점화식 없이, 2 X 1 는 ( N - 1 ) 을 재귀로 다시 호출하고 시행 시도에 + 1 하면 된다고 생각했다. 

 

하지만 이는 치명적인 생각 오류가 있었다. 

다음은 타일을 채우는 방법 중 1가지이지만, 내 알고리즘으로는 4가지 방법으로 판단한다. 

이로 인해 기껏 떠올린 알고리즘은 (2 X 1) 타일이 반복될 때는 count 를 올리지 않는 것이다. 

이를 구현하기 위해서는 매번 재귀함수에서 parameter를 던져 전 단계가 (2 X 1) 타일이었는지 아닌지 판별해줘야 한다. 

또한 (2 X 1) 다음의 두가지 경우의 수는 기존의 경우의 수를 2배 만큼 해주는 것이지 +1 하는 것이 아니다. 

 

전반적으로 정리가 잘 안된다. 

이럴 때는 점화식을 세워야 한다고 한다. 

사진 출처: https://cau-meng2.tistory.com/95

그림을 그려 살펴보면 규칙이 보인다. 바로 전 단계의 양쪽에 한번씩 전전 단계의 타일을 붙인 것과 동일하다. 

점화식을 세우면 $a_i = a_{i-1} + a_{i-2} + a_{i-2}$ 이다. 

 

다이나믹 프로그래밍이 점화식을 세우는 것이 우선순위인 경우가 있다는 것을 배웠다. 

 

탑다운

import sys 
input = sys.stdin.readline

N = int(input())
# 2 X N 
d = [0] * 1001
d[1] = 1
d[2] = 3

def oper(n):

    if n == 0:
        return 0
    
    if d[n] != 0:
        return d[n]

    d[n] = oper(n-2) * 2 + oper(n-1)
    return d[n]

oper(N)
print(d[N])

재귀로 풀 때 유의점을 다시한번 정리해보자 

  • 종료 조건 
  • 메모이제이션 
  • d[n] 에 대한 정의 

 

보텀업

for i in range(3, N+1):
    d[i] = d[i-2] * 2 + d[i-1]

print(d[N])

3) 개미전사

N개의 식량창고가 일직선으로 이어져있다. 각 식량창고는 각 정해진 수의 식량을 저장한다. 개미전사는 식량 창고를 선택적으로 약탈한다. 일직선상 식량창고 중 서로 인접한 창고를 공격할 수는 없다. 최대로 약탈할 수 있는 식량의 최댓값은?

 

입력: 일직선상의 식량창고 

N (4) 

3 <= N <= 100 

1 3 1 5 

 

출력: 최댓값 

8

 

탑다운 첫 풀이

import sys 
input = sys.stdin.readline

N = int(input())

food = list(map(int,input().split()))
food.insert(0,0)
d = [0] * 101 


def oper(n):
    if n == 0:
        return 0 
    if n == 1:
        return food[1]
    if n == 2:
        return max(food[1], food[2]) 
    if d[n] != 0:
        return d[n]
    # 4개 이상일 때 가능 
    d[n] = oper(n-2) + food[n]
    if n > 3:
        d[n] = max(d[n], oper(n-3) + food[n])
    return d[n]


oper(N)
tmp = d[N]

oper(N-1)
tmp = max(d[N-1], tmp)
print(tmp)

처음한 구현이다. 해당 구현도 무리없이 답은 맞추는 듯 하나 다시 살펴보는 과정에서 몇가지 오류를 발견했다. 

 

1) 시작점을 두개로 잡아야 한다는 판단 

각 원을 $a_1, a_2, a_3, a_4, a_5 ... $ 라 할 때, 

만약 $a_1$ > $a_2$ 라면 문제가 없다. 

하지만 $a_2 > a_1$ 이라면 대소 비교가 어렵다. 

$a_1 + a_3 + a_5$ 와 $a_2+a_5$ 의 대소비교를 첫번째로 해야 하고, 

두번째로 $a_1 + a_3 + a_5$ 와 $a_2 + a_4$의 대소비교가 필요하다. 

두번째 대소비교는 그 이후로도 $a_6$ .. 이런식으로 계속해서 구분이 필요하다. 

 

내가 생각한 알고리즘은 시작점은 두개로 잡을 수 밖에 없다는 것을 확인 

 

 

2) n == 2일 때 1번과 2번 element 대소비교 착각 

n == 2 일때 1번과 2번 element 를 굳이 대소 비교하여 더 큰 element 를 반환하도록 했었다. 물론 연산 숫자에 별 차이가 없기는 하나 실제로 필요 없는 구현이다. 

그림으로 살펴 보면 (n-2), (n-3) 두 재귀함수 중 한 곳에 걸린 경우이다. 

일단 4번째 줄의 경우에는 (5, 3, return 1) 에 걸리므로 더 큰 경우가 발생해서 고려하지 않아도 된다. 

그러면 1,2,3 번째 줄만 고민하면 된다. 

1번째 줄의 경우에는 어차피 5 에서 (n-3) 재귀함수의 return 2 이므로  재외한다. 

2, 3번째 줄만 고려하면 된다. 

2번째 줄은 사실 4에서 (n-3) 재귀 함수에 걸린다. 즉 4 + 1 을 따로 해주지 않아도 자동으로 수행된다. 

3번째 줄은 (n-2)에 걸려 n == 2 를 만든 경우인데 2번째 줄이 해결되었으므로 따로 고려해주지 않아도 된다. 

즉, 별다른 고민 안해도 (n-2), (n-3) 의 재귀함수에서 다 잘 처리된다. 재귀함수에 아직도 덜 익숙해서 하는 실수같다. 

 

결론 떠올린 알고리즘이 작동은 하는 듯 하나 비효율적인 것 같다. 

결국 다른 블로그 글들을 참고해본다. 

 

참고 이미지: https://turume.tistory.com/entry/%EB%8B%A4%EC%9D%B4%EB%82%98%EB%AF%B9%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%B0%8D-%EA%B0%9C%EB%AF%B8%EC%A0%84%EC%82%AC-Swift

 

해당 문제는 결국

(n-2) 까지의 최대값과 + n 번째 원소 값 vs (n-1) 원소까지의 최대값

를 비교하면 된다. 

 

이를 기반으로 turu 블로거 님이 작성하신 점화식은 위와 같다. 

$f(n) = max(f(n-2) + a_n, f(n-1))$

즉 위와 같이 $a_1 + a_3$ 과 $a_2$ 를 비교하면 되는 문제이다. 

내가 세웠던 점화식은 $max(f(n-2) + a_n, oper(n-3) + a_n)$ , $max(f((n-1)-2) + a_n, oper((n-1)-3) + a_n)$ 로 차선이긴 하지만 가장 적절한 점화식을 바로 발견하지 못했던 것이 문제였다. 

이를 기반으로 다시 작성해보면 

탑다운 정식 풀이 

import sys 
input = sys.stdin.readline

N = int(input())

food = list(map(int,input().split()))
food.insert(0,0)
d = [0] * 101 


def oper(n):
    if n == 0:
        return 0 

    if n == 1:
        return food[1]

    if n == 2: 
        return food[2]

    if d[n] != 0:
        return d[n]

    d[n] = max(oper(n-1), oper(n-2) + food[n])
    return d[n]

oper(N)

print(d[N])

훨씬 간단하고 명료하게 작성된다. 

 

이번에 내가 바로 핵심을 파악하지 못했던 이유를 분석해보면 

  • d[n] 정의를 재대로 하지 못했다. 
  • d[n] 정의한 후, n 에 (1,2,3,4) 정도만 눈으로 넣어봐도 잘 작동하는지 확인 가능하도록 짜여야 한다. 

바텀업 

d[1] = food[0]
d[2] = max(food[0], food[1])

for i in range(3, N+1):
    d[i] = max(d[i-1], d[i-2]+food[i])

print(d[N])

확실히 바텀업이 짧고 명료하다. 

 

실험 결과 무지성으로 풀이했던 첫 탑다운 시도도 제한 시간 내에 답을 찾는데는 지장이 없다는 것을 확인했다. DP 는 언제나 명확한 규칙을 확인하고, n = (1,2,3,4) 정도는 시도해보고 덤벼야 할 것 같다. 

예시) 

N = 10 

8 3 9 1 2 7 3 9 2 7
출력: 40

 

N = 100
40 4 32 12 78 78 22 30 16 94 42 52 77 12 87 90 29 70 34 19 63 77 84 55 39 23 24 79 53 51 72 22 65 56 15 20 27 71 97 35 5 1 46 53 29 34 19 62 69 16 62 70 37 13 6 5 84 2 30 90 72 98 90 61 46 1 39 7 69 62 21 8 18 50 41 40 88 56 51 91 30 6 95 18 84 20 65 40 50 1 73 58 15 22 32 67 62 52 8 83
출력: 2616


4) 효율적인 화폐 구성

N 가지 종류의 화폐 

화폐 개수를 최소한 활용해서 가치의 합이 M 원이 되도록 한다. 

화폐는 몇개라도 사용 가능, 구성은 같지만 순서만 다른 것은 같은 것으로 구분한다. 

$1 \leq N \leq 100$

$1 \leq M \leq 100,00$ 

 

입력:

2 15 (N M)

2

3

출력: 5 (3원 5장) 

 

탑다운

import sys 
input = sys.stdin.readline

N, M = map(int,input().split())
lst = []
for i in range(N):
    lst.append(int(input()))

d = [10001] * (M+1)

def oper(n):
    if n == 0:
        return 0
    if n < 0 :
        return 10001

    if d[n] != 10001:
        return d[n]
    d[n] = oper(n-lst[0]) + 1 
    for i in lst[1:]:
        d[n] = min(oper(n-i)+1, d[n])
    return d[n]

oper(M)
print(d)
print(d[M])
[10001, 10002, 1, 1, 2, 2, 2, 3, 3, 3, 4, 4, 4, 5, 10001, 5]
5

 

어려웠지만 처음부터 코드는 잘 짜졌다. 

다만,

if n<0:
	return 100001

이 부분을 떠올리기 어려웠다. 

 

즉, 해당 문제는 min 을 구하는 문제이므로 d list 초기화를 0 이 아닌 최대 값으로 해줘야 한다. 

그 후, 다이나믹 프로그래밍으로 풀 때 여러 조합으로 맞춰보던 도중 실패하는 경우가 당연히 발생하고 그 경우에는 최종 목표값인 0에 도달하지 못한다. 

그때는 0에 도달하지 못하고 -1 값에 도달하게 된다. 이 때는 return 값을 10001 로 처리해줘서 어떻게 해도 해당 가격에 대하여 오버된 값이 들어가지 못하도록 한다. 

 

바텀업 첫 구현 

lst.sort()
for i in lst:
    d[i] = 1

print(lst[-1])

for i in range(lst[-1]+1,M+1):
    d[i] = d[i - lst[0]] +1 
    for j in lst:
        d[i] = min(d[i], d[i - j] + 1)

print(d)
print(d[i])
[10001, 10001, 1, 1, 2, 2, 2, 3, 3, 3, 4, 4, 4, 5, 5, 5]
5

생각보다 탑다운에서 바텀업으로 아이디어를 전이하기 어려웠던 문제다. 

아이디어를 잘 못 잡아서 완전 잘 못 구현했다. 

easy case 에서는 문제가 없었지만 

예를 들어 M = 1007, 화폐가 2,3,4000 이면? 

for i in range(4000+1, M+1):
    d[i] = d[i - 2] +1 
    
    d[i] = min(d[i], d[i - 3] + 1)
    d[i] = min(d[i], d[i - 4000] + 1)

 

 

4000 부터 시작하여 앞부분의 array 가 채워져있지 않아 아예 풀 수가 없다. 

즉, array를 앞부분 부터 차근 차근 채우는 것이 문제의 골자이다. 

 

탑다운을 보텀업으로 변경할 때 유의사항은 실제 동작 횟수는 동일한가이다. 

지금은 동작 횟수가 확 줄어들었으므로 문제가 많다. 

 

d[0] = 0

#아예 0 부터 시작되도록 한 식 
for i in lst:
    for j in range(i, M+1):
    	if i > M:
	       continue
        if(d[j-i] != 10001):
            d[j] = min(d[j] ,d[j-i] + 1)

print(d[M])

 

다음과 같이 수정해야 한다. i 가 M 보다 큰 경우는 생략한다. 

 

이로서 dynamic programming 기본기를 학습했다. 

이제 쉬울 것 같으면서도 매번 꽤 어렵다는 걸 깨닫는다. 

 

'코딩테스트총정리' 카테고리의 다른 글

최단 경로 - 플로이드 워셜 알고리즘  (0) 2022.08.14
최단 경로 - 다익스트라  (0) 2022.08.11
탐색 (순차, 이진)  (0) 2022.08.06
정렬  (0) 2022.08.03
DFS, BFS  (0) 2022.08.03
Comments