4/27 동적 프로그래밍(Dynamic Programming)

JK·2023년 4월 27일
0

동적 프로그래밍(Dynamic Programming)

다이나믹 프로그래밍은 메모리를 적절히 수용하여 수행 시간 효율성을 비약적으로
향상시키는 방법입니다
이미 계산된 결과는 별도의 메모리 영역에 저장하여 다시 계산하지 않습니다
다이나믹 프로그래밍의 구현은 일반적으로 두 가지 방식(탑다운과 보텀업)으로 구성합니다

다이나믹 프로그래밍은 동적 계획법이라고도 부릅니다
일반적인 프로그래밍 분야에서의 동적이란 자료구조에서 동적 할당(Dynamic Allocation)
은 '프로그램이 실행되는 도중에 실행이 필요한 메모리를 할당하는 기법'
반면 다이나믹 프로그래밍에서 '다이나믹'은 별다른 의미 없이 사용된 단어

다이나믹 프로그래밍은 문제가 다음의 조건을 만족할 때 사용할 수 있습니다.
1. 최적 부분 구조(Optimal Substructure)
큰 문제를 작은 문제로 나눌 수 있으며 작은 문제의 답을 모아서 큰 문제를 해결합니다.
할 수 있습니다.(분할 정복?)
2. 중복되는 부분의 문제(Overrlapping Subproblem)
동일한 작은 문제를 반복적으로 해결해야 합니다.

다이나믹 프로그래밍을 이용한 대표적인 문제 피보나치 수열
피보나치 수열은 첫번 째 피보나치 수와 두번 째 피보나치 수가 1일 때
특정 번째의 피보나치 수를 구할 때 앞에 두 피보나치 수를 더한 값
예) 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, ...
점화식이란 인접한 항들 사이의 관계식을 의미합니다
피보나치 수열을 점화식으로 표현하면 다음과 같습니다.
an = an-1 + an-2, a1 = 1, a2 = 1

피보나치 함수(Fibonacci Function)을 재귀함수로 구현
def fibo(x): #피보나치 재귀함수 호출
if x == 1 or x == 2: #첫번 째, 두번 째 수가 각각 1 이면 종료
return 1
return fibo(x - 1) + fibo(x - 2)

print(fibo(4))

피보나치 수열의 시간 복잡도 분석
단순 재귀 함수로 피보나치 수열을 해결하면 지수 시간 복잡도를 가지게 됩니다
f(6)을 호출 하는 것만 해도 f(2)가 5번 호출됩니다
세타 표기법 : 0(1.618...^N)
빅오 표기법 : O(2^N)
빅오 표기법을 기준으로 f(30)을 계산하기 위해 약 10억가량의 연산을 수행합니다

피보나치 수열의 효율적인 해법: 다이나믹 프로그래밍다이나믹 프로그래밍의 사용 조건을 만족하는지 확인합니다

  1. 최적 부분 구조: 큰 문제를 작은 문제로 나눌 수 있습니다(O)
    f(4) = f(3) + f(2) 처럼 나누어집니다

  2. 중복되는 부분 문제: 동일한 작은 문제를 반복적으로 해결합니다(O)
    f(4)를 구할 때 f(2)가 2번 사용됩니다(중복되는 부분 문제)

    메모이제이션(Memoization, 하향식)
    메모이제이션은 다이나믹 프로그래밍을 구현하는 방법 중 하나입니다
    한 번 계산한 결과를 메모리 공간에 메모하는 기법입니다
    같은 문제를 다시 호출하면 메모했던 결과를 그대로 가져옵니다
    값을 기록해 놓는다는 점에서 캐싱(Caching)이라고도 합니다.
    문제를 해결할 때 사용하는 배열 이름을 캐시, 메모, 테이블, dp, d라고 하기도 합니다

    탑다운 vs 보텀업
    탑다운(메모이제이션) 방식은 하향식이라고도 하며 보텀없 방식은 상향식이라고 합니다.
    다이나믹 프로그래밍의 전형적인 형태는 보텀업 방식입니다
    결과 저장용 리스트는 DP 테이블이라고 부릅니다.
    엄밀히 말하면 메모이제이션은 이전에 계산된 결과를 일시적으로 기록해 놓는개념을 의미합니다.
    따라서 메모이제이션은 다이나믹 프로그래밍에 국한된 개념은 아닙니다.
    한 번 계산된 결과를 담아 놓기만 하고 다이나믹 프로그래밍을 위해 활용하지 않을 수도 있습니다.

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

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

# 피보나치 함수(Fibonacci Function)를 재귀함수로 구현(탑다운 다이나믹 프로그래밍)
def fibo(x):

    if 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(99))

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

# 앞서 계산된 결과를 저장하기 위한 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])

피보나치 수열: 메모이제이션 동작 분석
이미 계산된 결과를 메모리에 저장하면 색칠된 노드만 처리할 것을 기대할 수 있습니다.

메모이제이션을 이용하는 경우 피보나치 수열 함수의 시간 복잡도는 O(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)

실행 결과

 f(6) f(5) f(4) f(3) f(2) f(1) f(2) f(3) f(4)

다이나믹 프로그래밍 vs 분할 정복
다이나믹 프로그래밍과 분할 정복은 모두 최적 부분 구조를 가질 때 사용할 수 있습니다.
큰 문제를 작은 문제로 나눌 수 있으며 작은 문제의 답을 모아서 큰 문제를 해결할 수 있는 상황
다이나믹 프로그래밍과 분할 정복의 차이점은 부분 문제의 중복입니다.
다이나믹 프로그래밍 문제에서는 각 부분 문제들이 서로 영향을 미치며 부분 문제가 중복됩니다.
분할 정복 문제에서는 동일한 부분 문제가 반복적으로 계산되지 않습니다.

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

다이나믹 프로그래밍 문제에 접근하는 방법
주어진 문제가 다이나믹 프로그래밍 유형임을 파악하는 것이 중요합니다.
가장 먼저 그리디, 구현, 완전 탐색 등의 아이디어로 문제를 해결할 수 있는지 검토할 수 있습니다.
다른 알고리즘으로 풀이 방법이 떠오르지 않는다면 다이나믹 프로그래밍을 고려해 봅시다.
일단 재귀 함수로 비효율적인 완전 탐색 프로그램을 작성한 뒤에 (탑다운) 작은 문제에서 구한 답이 큰
문제에서 그대로 사용될 수 있으면, 코드를 개선하는 방법을 사용할 수 있습니다.
일반적인 코딩 테스트 수준에서는 기본 유형의 다이나믹 프로그래밍 문제가 출제되는 경우가 많습니다.

동적 프로그래밍을 이용한 경우의 수 구하기

2차원 배열

def combination(n, r):
    if r == 0 or n == r:
        return 1
    if dp[n][r] != 0:
        return dp[n][r]
    dp[n][r] = combination(n-1, r-1) + combination(n-1, r)
    return dp[n][r]

n = 5
r = 3
dp = [[0] * (r+1) for _ in range(n+1)]
print(combination(n, r))

3차원 배열

def permutation(n, r):
    if r == 0:
        return 1
    if dp[n][r] != 0:
        return dp[n][r]
    dp[n][r] = 0
    for i in range(n-1, -1, -1):
        dp[n][r] += permutation(i, r-1)
    return dp[n][r]

n = 5
r = 3
dp = [[[0] * (r+1) for _ in range(n+1)] for _ in range(n+1)]
print(permutation(n, r))

동적 프로그래밍을 이용한 문제 풀기

문제 링크

01타일

시간 제한 메모리 제한 제출 정답 맞힌 사람 정답 비율
0.75 초 (추가 시간 없음) 256 MB 83923 27282 21645 31.685%

문제
지원이에게 2진 수열을 가르쳐 주기 위해, 지원이 아버지는 그에게 타일들을 선물해주셨다. 그리고 이 각각의 타일들은 0 또는 1이 쓰여 있는 낱장의 타일들이다.

어느 날 짓궂은 동주가 지원이의 공부를 방해하기 위해 0이 쓰여진 낱장의 타일들을 붙여서 한 쌍으로 이루어진 00 타일들을 만들었다. 결국 현재 1 하나만으로 이루어진 타일 또는 0타일을 두 개 붙인 한 쌍의 00타일들만이 남게 되었다.
그러므로 지원이는 타일로 더 이상 크기가 N인 모든 2진 수열을 만들 수 없게 되었다. 예를 들어, N=1일 때 1만 만들 수 있고, N=2일 때는 00, 11을 만들 수 있다. (01, 10은 만들 수 없게 되었다.) 또한 N=4일 때는 0011, 0000, 1001, 1100, 1111 등 총 5개의 2진 수열을 만들 수 있다.
우리의 목표는 N이 주어졌을 때 지원이가 만들 수 있는 모든 가짓수를 세는 것이다. 단 타일들은 무한히 많은 것으로 가정하자.

입력
첫 번째 줄에 자연수 N이 주어진다. (1 ≤ N ≤ 1,000,000)

출력
첫 번째 줄에 지원이가 만들 수 있는 길이가 N인 모든 2진 수열의 개수를 15746으로 나눈 나머지를 출력한다.

예제 입력 1
4

예제 출력 1
5

import sys
input = sys.stdin.readline

n = int(input())
a = 2
b = 3
if n <= 3:
    print(n)
else:
    for i in range(4, n + 1):
        num = b + a
        a = b
        b = num % 15746

    print(b)

동적 프로그래밍을 활용한 문제들은 점화식을 찾기가 어려운 거 같아서 문제를 많이 풀어봐야 할 거 같습니다
이번 주에 공부하기로 예정된 주제가 크게 동적 프로그래밍, 그리디 두 가지라 좀 더 시간을 가지고 활용 문제들을 풀어봐야 할 거 같습니다. :)

profile
^^

0개의 댓글