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

Daisy 🌼·2022년 7월 31일
0

알고리즘

목록 보기
6/8
post-thumbnail

강의 출처 : 나동빈님 유튜브 강의

1. 다이나믹 프로그래밍 (DP)

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

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

  • DP는 문제가 다음의 조건을 만족할 때 사용 가능
    1. 최적 부분 구조 (Optimal Substructure)
    • 큰 문제를 작은 문제로 나눌 수 있으며, 작은 문제의 답을 모아 큰 문제를 해결 가능
    1. 중복되는 부분 문제 (Overlapping SUbproblem)
    • 동일한 작은 문제를 반복적으로 해결해야 함

3. 피보나치 수열

  • 피보나치 수열은 다음과 같은 형태의 수열이며, DP로 효과적으로 계산 가능

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

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

    an=an1+n2,a+1=1,a2=1a_n = a_{n-1} + _{n-2}, a+1 = 1, a_2 = 1

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

  • 피보나치 수열이 계산되는 과정은 다음과 같이 표현 가능하며, n번째 피보나치 수를 f(n)f(n)라고 할 때, 4번째 피보나치 수 f(4)f(4)를 구하는 과정은 다음과 같음

  • 피보나치 수열 : 단순 재귀 소스코드

    def fibo(x):
    	if x == 1 or x == 2:
        	return 1
        return fibo(x-1) + fibo(x-2)
    print (fibo(4))
    >>> 3
  • 피보나치 수열의 시간복잡도 분석
    단순 재귀 함수로 피보나치 수열을 해결하면 지수 시간 복잡도를 가지게 됨
    다음과 같이 f(2)f(2)가 여러 번 호출 되는 것 확인 가능 (중복되는 부분문제)

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

    세타 표기법 : θ(1.618...N)\theta (1.618...^N)
    빅오 표기법 : O(2N)O(2^N)

빅오 표기법을 기준으로 f(30)f(30)을 계산하기 위해 약 10억가량의 연산을 수행해야 한다. 그렇다면 f(100)f(100)을 계산하기 위해 얼마나 많은 연산이 필요할까?


  • 피보나치 수열의 효율적인 해법 : DP
    • DP의 사용 조건을 만족하는 지 확인한다
  1. 최적 부분 구조 : 큰 문제를 작은 문제로 나눌 수 있음
  2. 중복되는 부분 문제 : 동일한 작은 문제를 반복적으로 해결
    피보나치 수열은 DP의 사용조건을 만족함

4. 메모이제이션(Memoization)

  • 메모이제이션은 DP 구현하는 방법 중 하나
  • 한 번 계산한 결과를 메모리 공간에 메모하는 기법
    • 같은 문제를 다시 호출하면 메모했던 결과를 그대로 가져옴
    • 값을 기록해 놓는다는 점에서 캐싱 (Cashing)이라고도 함

5. 탑다운 vs 보텀업

  • 탑다운(메모이제이션) 방식은 하향식이라고도 하며, 보텀업 방식은 상향식이라고 함
  • DP의 전형적인 형태는 보텀업 방식
    • 결과 저장용 리스트는 DP 테이블이라고 부름
  • 엄밀히 말하면, 메모이제이션은 이전에 계산된 결과를 일시적으로 기록해 놓는 넓은 개념
    • 따라서 메모이제이션은 DP에 국한된 개념은 아님
    • 한 번 계산된 결과를 담아 놓기만 하고, DP를 위해 활용하지 않을 수도 있음

5-1. 피보나치 수열 : 탑 다운 DP 예제

# 한 번 계산된 결과를 Memoization 하기 위한 리스트 초기화 
d = [0] * 100 # 0~99까지의 인덱스를 갖게 됨

def fibo(x):
	# 종료조건 (1 or 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))
>>> 218922995834555169026

5-2. 피보나치 수열 : 보텀 업 DP 예제

# 앞서 계산한 결과를 저장하기 위한 DP 테이블 초기화
d = [0] *100
>>> # [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
# 첫 번째 피보나치 수와 두 번째 피보나치 수는 1
d[1] = 1
d[2] = 2
n = 99

# 피보나치 함수 반복문으로 구현(보텀업 DP)
for i in range(3, n+1):
	d[i] =d[i-1] + d[i-2]
   
print(d[n])
>>> 218922995834555169026

5-3. 피보나치 수열 : Memoization 동작 분석 (시간복잡도 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)

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

  • 실제로 호출되는 함수에 대해서만 확인해보면 다음과 같이 방문함


6. DP vs 분할정복

  • DP와 분할 정복은 모두 최적 부분 구조를 가질 때 사용할 수 있음
    • 큰 문제를 작은 문제로 나눌 수 있으며, 작은 문제의 답을 모아 큰 문제를 해결 할 수 있는 상황
  • DP와 분할 정복의 차이점 : 부분 문제의 중복
    • DP 문제에서는 각 부분 문제들이 서로 영향을 미치며 부분 문제가 중복됨
    • 분할정복 문제에서는 동일한 부분 문제가 반복적으로 계산되지 않음

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

7. DP 문제 접근 방법

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

8. 문제 : 개미전사

8-1. 문제풀이

n= int(input()) # 4
array = list(map(int, input().split())) # [1, 3, 1, 5]

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

# DP 진행 (보텀업)
d[0] = array[0] # 1
d[1] = max(array[0], array[1]) # max(1, 3) = 3
for i in range(2, n): # 2~3
	# 특정한 i 창고를 안털면 [i-1], 특정한 i 창고를 털면 [i-2]
	d[i] = max(d[i-1], d[i-2] + array[i]) 
 # d[2] = max(d[2-1]), d[2-2] + array[2] -> max(3, 1+1) = 3
 # d[3] = max(d[3-1]), d[3-2] + array[3] -> max(3, 3+5) = 8

print(d[n-1]) # d[4-1] = d[3] = 8

9. 문제 : 1로 만들기


9-1. 문제풀이

x = int(input()) # input = 4

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

for i in range(2, x+1): #2 ~ 4
 
  d[i] = d[i - 1] + 1 # 현재 수 - 1
  # i = 2, d[2] = d[2-1] + 1 = 1
  # i = 3, d[3] = d[3-1] + 1 = 1+1= 2
  # i = 4, d[4] = d[4-1] + 1 = 2+1= 3  
 
  if i % 2 == 0: 
    d[i] = min(d[i], d[i//2] + 1) 
    # i = 1, min(d[2], d[2//2] + 1) =min(1, 1+1) = 1
    # i = 4, min(d[4], d[4//2] + 1) =min(3, 2) = 2
 
  if i % 3 == 0:
    d[i] = min(d[i], d[i//3] + 1) 
    # i = 3, min(d[3], d[3//3]+1) = min(2, 2) = 2
 
  if i % 5 == 0:
    d[i] = min(d[i], d[i//5] + 1)

print(d[x])
>>> 2

10. 문제 : 효율적인 화폐 구성


10-1. 문제풀이

n, m = map(int, input().split())
arr = []

for i in range(n):
  arr.append(int(input()))
# 한 번 계산된 결과를 저장하기 위한 DP 테이블 초기화
d = [10001] * (m + 1)
# DP 진행 (보텀업)
d[0] = 0
for i in range(n):
  for j in range(arr[i], m + 1):
      if d[j - arr[i]] != 10001: # (i-k)원을 만드는 방법이 존재하면
        d[j] = min(d[j], d[j - arr[i]] + 1)
# 계산된 결과 출력
if d[m] == 10001: # 최종적으로 M원을 만드는 방법이 없는 경우
  print(-1)
else:
  print(d[m])

11. 문제 : 금광

11-1. 문제풀이

for tc in range(int(input())):
  n, m = map(int, input().split())
  arr = list(map(int, input().split()))
 
 # DP를 위한 2차원 DP 테이블 초기화
  dp = []
  index = 0
  for i in range(n):
    dp.append(arr[index:index + m])
    index += m  
   
   # DP 진행
  for j in range(1, m):
    for i in range(n): 
      # 왼쪽 위에서 오는 경우
      if i == 0: left_up = 0
      else: left_up = dp[i - 1][j - 1]         
      # 왼쪽 아래에서 오는 경우
      if i == n - 1: left_down = 0
      else: left_down = dp[i + 1][j - 1]
      # 왼쪽에서 오는 경우
      left = dp[i][j - 1]
      dp[i][j] = dp[i][j] + max(left_up, left_down, left)
          
  result =0
  for i in range(n):
    result = max(result, dp[i][m - 1])
  print(result)

12. 문제 : 병사 배치하기


12-1. 문제풀이

n = int(input())
arr = list(map(int, input().split()))
arr.reverse() # 순서를 뒤집어, '최장 증가 부분 수열' 문제로 변환

# DP를 위한 1차원 DP 테이블 초기화
d = [1] * n

# 가장 긴 증가하는 부분 수열(LIS) 알고리즘 수행
for i in range(1, n):
  for j in range(0, i):
    if arr[j] < arr[i]:
      d[i] = max(d[i], d[j] + 1)
     
# 열외해야 하는 병사의 최소 수를 출력
print(n-max(d))

profile
세상을 이롭게하는 AI Engineer

0개의 댓글