Algorithm

[알고리즘] 다이나믹 프로그래밍

아윤_ 2023. 8. 11. 13:40
 
 

 

이 글은 유튜버 '동빈나'의  "이것이 취업을 위한 코딩 테스트다 with 파이썬"

강의를 보고 작성한 글이며, 강의 링크는 아래를 참고하면 된다.

 

https://www.youtube.com/watch?v=m-9pAwq1o3w&list=PLRx0vPvlEmdAghTr5mXQxGpHjWqSz0dgC 

 


 

다이나믹 프로그래밍

 

다이나믹 프로그래밍은 메모리를 적절히 사용하여 수행 시간 효율성을 비약적으로 향상시키는 방법이다.

 

이미 계산된 결과(작은 문제)는 별도의 메모리 영역에 저장하여 다시 계산하지 않도록 한다.

 

다이나믹 프로그래밍의 구현은 일반적으로 두 가지 방식(탑다운과 보텀업)으로 구성된다.

 

  • 탑다운
    • 위에서부터 아래로 내려간다고 하여 하향식 방식이라 부른다.

 

  • 보텀업
    • 아래에서부터 위로 올라간다 하여 상향식 방식이라고 부른다.

 

 

다이나믹 프로그래밍은 이름 그대로 동적 계획법이라고도 부른다. 하지만, 일반적인 프로그래밍 분야에서의 동적(Dynamic)의 의미와는 다른 뜻을 지닌다는 것에 유의하자.

 

자료구조에서 동적 할당(Dynamic Allocation) '프로그램이 실행되는 도중에 실행에 필요한 메모리를 할당하는 기법'을 의미하지만, 다이나믹 프로그래밍에서 '다이나믹'은 별다른 의미 없이 사용된 단어이다.

 

 

다이나믹 프로그래밍의 조건

 

다이나믹 프로그래밍은 문제가 다음의 조건을 만족할 때 사용할 수 있다.

 

  1. 최적 부분 구조 (Optimal Substructure)
    • 큰 문제를 작은 문제로 나눌 수 있으며 작은 문제의 답을 모아서 큰 문제를 해결할 수 있다.
  2. 중복되는 부분 문제 (Overlapping Subproblem)
    • 동일한 작은 문제를 반복적으로 해결해야 한다.

 

 

피보나치 수열

 

피보나치 수열이란, 다음과 같은 형태의 수열로, 다이나믹 프로그래밍으로 효과적으로 계산할 수 있다.

 

1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, ...

 

점화식이란 인접한 항들 사이의 관계식을 의미하며, 피보나치 수열을 점화식으로 표현하면 다음과 같다.

 

 

피보나치 수열에서 특정 번째의 피보나치 수열을 구하려 할 때 앞에 있는 두 수를 더한 값이 해당 피보나치 수가 된다.

 

피보나치 수열이 계산되는 과정은 다음과 같이 표현할 수 있다. 프로그래밍에서는 이러한 수열을 배열이나 리스트를 이용해 표현한다.

 

 

 

 n번째 피보나치 수를 f(n)라고 할 때 4번째 피보나치 수 f(4)를 구하는 과정은 다음과 같다. 

 

 

4번째 피보나치 수를 구하기 위해서는 3번째 피보나치 수와 2번째 피보나치 수를 알고 있어야 하며, 3번째 피보나치 수 또한 2번째 피보나치 수와 1번째 피보나치 수를 알고 있어야 하므로 다음과 같이 트리 형태로 표현이 가능하다.

 

 

피보나치 수열: 단순 재귀 소스코드 (Python)

# 피보나치 함수 (Fibonacci function)을 재귀함수로 구현

def fibo(x):
    if x == 1 or x == 2:
        return 1
    return fibo(x-1) + fibo(x-2)

print(fibo(4))

 

재귀 함수에서는 반드시 종료 조건을 설정해주어야 한다. 피보나치 수열에서는 x가 1 또는 2라면 1을 반환하고 종료하도록 한다. 

 

 

피보나치 수열의 시간 복잡도 분석

 

하지만, 이와 같이 단순 재귀 함수로 피보나치 수열을 해결하면, 지수 시간 복잡도를 가지게 된다. 또한, 다음과 같이 f(2)가 여러 번 호출되어 중복되는 부분 문제 발생한다.

 

 

 

 

피보나치 수열의 시간 복잡도는 다음과 같다.

  • 빅오 표기법: O(2^n)
  • 빅오 표기법을 기준으로 f(30)을 계산하기 위해 약 10억 가량의 연산을 수행해야 한다.
  • 이러한 문제를 해결하기 위해 다이나믹 프로그래밍을 사용할 수 있다.

 

 

피보나치 수열의 효율적인 해법: 다이나믹 프로그래밍

 

다이나믹 프로그래밍을 사용하기 전, 먼저 피보나치 수열이 다이나믹 프로그래밍의 사용 조건을 만족하는지 확인해 보자,

 

  1. 최적 부분 구조: 큰 문제를 작은 문제로 나눌 수 있다.
    • 4번째 피보나치 수를 구하고자 할 때 3번째 수와 2번째 수를 더한 값이 된다. 즉 큰 문제를 작은 문제로 나눌 수 있다.
  2. 중복되는 부분 문제: 동일한 작은 문제를 반복적으로 해결한다.
    • 아까 f(2)가 중복되어 호출되는 모습을 볼 수 있었다.

 

즉, 피보나치 수열은 다이나믹 프로그래밍의 사용 조건을 만족한다.

 

 

메모이제이션 (Memoization)

 

메모이제이션은 다이나믹 프로그래밍을 구현하는 방법 중 하나로, 한 번 계산한 결과를 메모리 공간에 메모하는 기법이다.

 

같은 문제를 다시 호출하면 메모했던 결과를 그대로 가져온다. 값을 기록해 놓는다는 점에서 캐싱(Cashing)이라고도 한다. 

 

 

탑타운 VS 보텀업

 

탑다운(메모이제이션) 방식을 하향식이라고도 하며 보텀업 방식은 상향식이라고도 한다.

 

다이나믹 프로그래밍의 전형적인 형태는 보텀업 방식이며, 결과 저장용 리스트를 DP 테이블이라고 부른다.

 

엄밀히 말하면 메모이제이션은 이전에 계산된 결과를 일시적으로 기록해 놓는 넓은 개념을 의미한다. 따라서, 매모이제이션은 다이나믹 프로그래밍에 국한된 개념은 아니다. 한 번 계산된 결과를 담아 놓기만 하고 다이나믹 프로그래밍을 위해 활용하지 않을 수도 있다.

 

 

피보나치 수열: 탑다운 다이나믹 프로그래밍 소스 코드 (Python)

# 한 번 계산된 결과를 메모이제이션(Memoization) 하기 위한 리스트 초기화
d = [0] * 100

# 피보나치 함수(Fibonacci function)을 재귀함수로 구현(탑다운 다이나믹 프로그래밍)
def fibo(x):
    # 종료 조건(1 혹은 2일 때 1을 반환)
    if x == 1 or x == 2:
        return 1
    # 이미 계산한 적 있는 문제라면 그대로 반환
    if d[x] != 0:
        return d[x]
    # 아직 계산하지 않은 문제라면 점화식에 따라서 피보나치 결과 반환
    d[x] = fibo(x-1) + fibo(x-2)
    return d[x]

print(fibo(90))

 

종료 조건의 경우, 재귀 함수를 사용하기 때문에 이전과 동일하며, 이미 해당 문제가 계산된 적이 있는 문제라면 dp 테이블의 값을 확인해서 그 값이 0이 아닌 경우에 그 값을 바로 리턴하도록 하고, 그렇지 않다면 기존의 점화식을 이용해 dp 테이블에 그 값을 기록한 다음, 기록된 값을 리턴하도록 한다.  

 

 

실행 결과

 

 

 

피보나치 수열: 보텀업 다이나믹 프로그래밍 소스코드 (Python)

# 앞서 계산된 결과를 저장하기 위한 DP 테이블 초기화
d = [0] * 100

# 첫 번째 피보나치 수와 두 번째 피보나치 수는 1
d[1] = 1
d[2] = 1
n = 99

# 피보나치 함수(Fibonacci function) 반복문으로 구현(보텀업 다이나믹 프로그래밍)
for i in range(3, n+1):
    d[i] = d[i-1] + d[i-2]

print(d[n])

 

보텀업 방식에서는 재귀함수가 아닌, 반복문이 사용되므로, 종료 조건 대신 점화식에서의 시작 항에 대한 값들을 먼저 초기화할 수 있도록 한다.

 

반복문을 이용해 점화식을 그대로 기입해 차례대로 각각의 항에 대한 값을 구해나가는 것을 확인할 수 있다. 즉, 작은 문제부터 해결한 다음, 이를 조합해 앞으로의 큰 문제들을 해결하는 것을 확인할 수 있다.

 

 

실행 결과

 

 

실행 결과는 탑다운 방식과 동일한 것을 확인할 수 있다.

 

 

피보나치 수열: 메모이제이션 동작 분석

 

이미 계산된 결과를 메모리에 저장하면 다음과 같이 색칠된 노드만 처리할 것을 기대할 수 있다.

 

 

 

더 나아가, 실제로 호출되는 함수에 대해서만 확인해 보면, 다음과 같이 방문하게 된다.

 

 

 

실제 코드를 통해 호출되는 함수를 출력해 보면, 동일한 결과가 나온다.

d = [0] * 100

def fibo(x):
    print('f(' + str(x) + ')', end =' ')
    if x == 1 or x == 2:
        return 1
    if d[x] != 0:
        return d[x]
    d[x] = fibo(x-1) + fibo(x-2)
    return d[x]

fibo(6)

 

 

 

 

이처럼, 메모이제이션을 이용하는 경우, 피보나치 수열 함수의 시간 복잡도는 O(N)이 된다.

 

 

다이나믹 프로그래밍 VS 분할 정복

 

다이나믹 프로그래밍과 분할 정복은 모두 최적 부분 구조를 가질 때 사용할 수 있다.

최적 부분 구조란, 큰 문제를 작은 문제로 나눌 수 있고, 작은 문제의 답을 모아서 큰 문제를 해결할 수 있는 상황을 말한다.

 

다이나믹 프로그래밍과 분할 정복의 차이점은 부분 문제의 중복이다. 

다이나믹 프로그래밍 문제에서는 각 부분 문제들이 서로 영향을 미치며 부분 문제가 중복되지만, 분할 정복 문제에서는 동일한 부분 문제가 반복적으로 계산되지 않는다. 

 

 

분할 정복의 대표적인 예: 퀵 정렬

 

분할 정복의 대표적인 예시인 퀵 정렬의 경우, 한 번 기준 원소(Pivot)가 자리를 변경해서 자리를 잡으면 그 기준 원소의 위치는 바뀌지 않는다. 또한, 분할 이후에 해당 피벗을 다시 처리하는 부분 문제는 호출하지 않는다.

 

 

 

다이나믹 프로그래밍 문제에 접근하는 방법

 

주어진 문제가 다이나믹 프로그래밍 유형임을 파악하는 것이 중요하다.

 

가장 먼저 그리디, 구현, 탐색 등의 아이디어로 문제를 해결할 수 있는지 검토한 다음, 다른 알고리즘으로 풀이 방법이 떠오르지 않는다면 다이나믹 프로그래밍을 고려해 보자.

 

일단 재귀 함수로 비효율적인 완전 탐색 프로그램을 작성한 뒤에(탑다운) 작은 문제에서 구한 답이 큰 문제에서 그대로 사용될 수 있으면, 코드를 개선하는 방법을 사용할 수 있다.

 

일반적인 코딩 테스트 수준에서는 기본 유형의 다이나믹 프로그래밍 문제가 출제되는 경우가 많다.