개요

모든 알고리즘 문제들은 완전탐색으로 해결할 수 있다.
그러나 비효율적인 연산과 시간을 줄이기 위해 여러 알고리즘이 있는데 동적 계획법이란 그 중 가장 많은 문제를 효율적으로 풀 수 있는 알고리즘이다.
동적 계획법이란 복잡한 문제를 여러개의 간단한 문제로 분리하여 부분의 문제들을 해결함으로써 최종적으로 복잡한 문제의 답을 구하는 방법이다.

동적 계획법으로 풀 수 있는 문제 중 대표적인 것이 바로 피보나치 수열이다.
피보나치 수열은 이전의 두 항의 합을 현재의 항으로 설정하는 수열로 예를 들면
1, 1, 2, 3, 5, 8, 13, 21….와 같다.

이를 일반화 해보면 a(n+2) = a(n+1) + a(n)로 코드 상으로는 배열과 리스트를 이용해서 처리할 수 있다.

코드로 나타내보면…

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

print(fibo(5))

그러나 x가 작다면 문제가 없겠지만 x가 커지면 연산횟수가 급격하게 늘어난다.
시간복잡도론 O(2^N)으로 나타낼 수 있다.

예를 들어 수가 구하려는 수가 10만 되더라도 약 1천 번의 연산을 수행해야한다.

image 여기서 좀 더 효율적으로 목표한 수를 구할 수 있을까?

가만히 보니 동일한 함수가 여러 번 호출되는 것이 보인다.
이 문제는 메모리 공간을 사용하는 것으로 해결할 수 있으며 이러한 접근의 문제 해결 방식을 동적 계획법, 다이내믹 프로그래밍이라고 부른다.

핵심 이론

동적 계획법의 원리와 구현 방식은 이러하다.

  • 큰 문제를 작은 문제로 쪼갤 수 있어야한다.
  • 작은 문제들이 반복되어 나타난다
  • 이 작은 문제들의 결과값은 항상 같아야한다.
  • 작은 문제들은 한 번만 계산해 DP테이블에 저장하며 추후 재사용할 때는 이 테이블의 값을 이용한다.
  • 동적 계획법은 톱다운과 바텀업 방식으로 구현한다.

예시 문제

파보나치 수열

5번 째 피보나치 수열은 4번째 피보나치 수열과 3번째 피보나치 수열의 합이다. 즉 5번 째 피보나치 수열의 값은 4번째, 3번째 수열을 구하는 작은 문제로 쪼갤 수 있고 수열의 값은 항상 같기에 동적 계획법으로 풀 수 있다.

점화식을 세워보면 D[i] = D[i-1] + D[i-2]가 된다. 그 다음 이 값을 DP테이블에 저장해놓는다. 만약 해당 값이 필요하면 다시 연산하지 않고 저장되어있는 값을 사용한다.

d = [0] * 100

def fibo(x):
  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))

이처럼 이미 수행된 적 있는 결과를 기록해서 재사용하는 방식을 캐싱이라고 부른다. 피보나치 수열에 동적계획법을 적용했을 때의 시간 복잡도는 O(n)이다.
기존 O(2^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)

현재 항을 구한 뒤 그 다음 항을 구하는데 사용되기 때문이다.
우리가 구하고자하는 6번째 항을 구하기 위해 작은 문제를 호출하는 방식으로 구했고 탑다운 방식에 해당한다.

바텀업 방식으로 코드를 작성해보면 다음과 같다.

d = [0] * 100

d[1] = 1
d[2] = 1
n = 99

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

print(d[n])

정수를 1로 만들기

1로 만들기는 대표적인 동적 계획법 문제로
정수 n개가 주어졌을 때 다음의 연산을 사용하여 주어진 숫자를 1로 만드는 최소 연산 횟수를 구하는 문제다.

  • 3으로 나누어 떨어지면 3으로 나눈다.
  • 2로 나누어 떨어지면 2로 나눈다.
  • 1을 뺀다.

문제 플이 과정

D[i]는 i에서 1로 만드는데 걸리는 최소 연산 횟수
D[i] = D[i-1] + 1
if(i % 2 == 0) min(D[i], D[i]/2 + 1)
if(i % 3 == 0) min(D[i], D[i]/3 + 1)

의사코드

N은 구하고자 하는 
for(i -> 2 ~ N) {
  D[i] = D[i-1] + 1
  if(i % 2 == 0) min(D[i], D[i]/2 + 1) // 결과가 D[i]보다 작으면 변경하기
  if(i % 3 == 0) min(D[i], D[i]/3 + 1) // 결과가 D[i]보다 작으면 변경하기 
}

가장 큰 정사각형 찾기

프로그래머스의 문제로 2차원 배열이 주어진다. 원소는 0또는 1이 담겨있으며 1이 적혀있는 칸은 1 * 1의 정사각형을 의미한다.
만약 1이 두 개 연이어서 있다면 직사각형으로 정사각형이 되려면 1개의 정사각형이 가로 2개 세로 2개 있어야한다.

예시를 보면 다음과 같다.

1	2	3	4
0	1	1	1
1	1	1	1
1	1	1	1
0	0	1	0

(0,1)부터 (3, 3)까지의 정사각형이 가장 크기 때문에 답은 9가 된다.
동적 계획법을 사용하여 작은 문제로 나누어보자.

우선 정사각형의 넓이는 가로 * 세로이고 실수할 가능성이 있으니 미리 리턴 값에 answer * 2와 같이 타이핑 해놓는 것이 좋다.

연속된 1이 있더라도 가로 세로를 더했을 때 정사각형이 나오지 않을 수 있다.

1111
1111

가로 길이 4, 세로 길이 2로 정사각형이 아니다. 생각해보면 최대 정사각형의 너비는 가로와 세로 길이중에 짧은 길이에 의해 결정된다. -> min 함수 위의 경우 2 * 2 = 4가 된다.
이미 구한 답을 다시 구하지 않도록 이전의 계산 결과를 원본 배열 board에 저장한다.

문제에서 주어진 board 배열을 순회하며 자신을 기준으로 왼쪽, 위, (정사각형이 되기 위한)대각선 위를 참고하여 자신을 정사각형의 오른쪽 아래 꼭짓점으로 잡았을 때 만들 수 있는 가장 큰 정사각형 값을 계산한다.

만약 왼쪽 대각선 위의 값이 0이라면 가로와 세로 값이 있다고 하더라도 정사각형을 만들 수 없게 된다.

인덱스 범위 초과 오류를 피하기 위해 (1,1) 지점부터 배열을 순회한다.

def solution(board):
    max_side = board[0][0]
    rows, cols = len(board), len(board[0])

    for i in range(1, rows):
        for j in range(1, cols):
            if board[i][j] == 1:
                board[i][j] = min(board[i-1][j], board[i][j-1], board[i-1][j-1]) + 1
                max_side = max(max_side, board[i][j])

    return max_side ** 2