본문 바로가기
개발/알고리즘

동적계획법, Dynamic programming

by blondie 2024. 4. 5.

 

1. 동적계획법이란

 

큰 문제를 더 작은 문제로 분해해 각각의 결과를 저장하여 본래 문제를 해결하는 알고리즘 설계법이다.

  • 문제의 입력 instance를 더 작은 부분 instance로 나눈다.
  • 작은 instance를 먼저 해결하고 그 결과 저장한다
  • 저장된 결과들로 큰 instance를 해결한다.

이를 주로 Bottom-up, 상향식 방식으로 진행하게 된다.

 

코딩테스트에서 주로 숫자 제한범위가 크고, 경우의 수가 방대한 문제가 주로 DP로 설계하면 해결된다(고 함)

 

 

2. Memoization 

 

 

n번째 피보나치 수열의 수를 구하는 문제를 재귀식으로 풀 때를 생각해보자.

 

func fibonacci(n)
  if n < 2
    n
  else
    fibonacci(n - 1) + fibonacci(n - 2)

 

보다시피 f(n) = f(n-1) + f(n-2)이므로, f(n)을 해결하기 위해 f(n-1)과 f(n-2)를 호출하는 하향식 방법이다.

 

 

 

https://medium.com/launch-school/recursive-fibonnaci-method-explained-d82215c5498e

 

또한 f(5) = f(4) + f(3), f(6) = f(5) + f(4) 에서 f(4)가 중복 사용되는 것 처럼,

동일한 문제가 중복 계산되어 효율이 떨어질 수 있다.

 

DP에서는 이러한 중복계산을 방지하도록 배열과 같은 메모리 공간에 하위 문제의 계산결과를 저장하여

필요할 때 이를 다시 호출하여 사용한다.

 

이를 Memoization 이라고 부른다.

 

 

3. DP의 조건

 

그럼 모든 재귀를 DP로 풀면 더 효율적인거 아니야? 라고 할 수 있으나 

 

DP로 풀 수 있는 조건이 있다.

 

 

A. 중복되는 부분 문제 (Overlapping Subproblems)

 

위의 피보나치 수열의 경우 처럼 부분 문제를 계산하는데 중복계산이 발생하는 경우에

DP를 적용하여 효율적인 알고리즘을 설계할 수 있다.

DP는 나누어진 문제의 결과값을 저장하여 재사용하는 것이 중요하기 때문이다.

 

 

B. 최적 부분 구조(Optimal Substructure)

 

부분 문제를 최적 결과 값으로 상위 문제의 최적 결과를 낼 수 있어야 한다.

 

무슨 소리냐 하면,

 

https://medium.com/@sogunledolapo/dynamic-programming-in-javascript-acd15ff5b59a

 

위의 그림에서 A->D로 가는 최단 경로를 찾아보자.

 

A에서 B를 가는 최단경로, A에서 C로 가는 최단경로를 결정하고

그 경로들을 이용해 A->D까지의 최단경로를 찾을 수 있다.

 

이처럼 하위문제의 최적해가 상위문제의 최적해에 이용되는 것이다.

 

 

 

 

4. DP로 문제를 해결하기

 

DP로 문제를 해결하는데 중요한 두가지가 있다.

 

 

A. 변수간 관계식

 

고등학교 때 자주 접한 점화식이다.

 

피보나치 수열은 f(n) = f(n-1)+f(n-2) 으로 관계식을 세울 수 있다.

 

이항계수 문제의 경우

 

로 나타낼 수 있다.

 

이처럼 큰 문제를 작은 문제로 분해하여 이들간에 관계식을 세워야 한다.

 

 

B. Memoization

 

하위 문제들의 결과를 저장할 배열과 같은 메모리 공간을 생성하고,

이 공간에 값을 저장하여 이후 호출해 재활용해야한다.

 

 

 

이를 마쳤으면, 최하위 문제부터 차례대로 계산하여 가장 큰 문제를 해결하도록 구현하면 된다.

 

가장 하위의 부분 문제의 결과값은 할 수 있다면 미리 지정한다.

 

 

 

5. 구현

 

구현에는 두 가지 방법이 있다. 피보나치 수열을 예로 살펴보자.

 

 

A. Bottom-up, 상향식

 

이는 반복문을 사용하여 구현한다.

 

가장 작은 문제부터 해결하여 이들의 최적해를 배열에 저장하고, 차차 상위문제를 해결해나간다.

 

의사코드로 확인하자.

int dp[n]

// 최하위 부분문제의 초기값 지정
dp[0] = 0 
dp[1] = 1

func fibo(n) {
	for i in 3...n {
    	dp[i] = dp[i-1] + dp[i-1] // 배열에서 재활용
    }
    
    return dp[n]
}

 

 

 

B. Top-down, 하향식

 

재귀식으로 구현한다.

 

int dp[n]
dp[0] = 0
dp[1] = 1

func fibo(n) {
    if(dp[n] != 0) // 배열에 저장된 값이 있다면 재활용
    	return dp[n] 
    
    dp[n] = fibo(n-1) + fibo(n-2)
    
    return dp[n]
}

 

 

 

 

 

 

6. 예제: 이항계수

 

 

고등학교 수학에서 배운 다항식의 정리에 대한 식, 이항정리 문제이다. 

 

 

이 식에서 계수만 따오면 이항계수이다.

 

수학시간은 아니므로 적당히 넘어가고

 

우리는 이 계수, 주어진 n과 k에 대한 계수 (n,k) 가 몇인가를 알고싶다.

https://blog.naver.com/lty2242/221091097459

 

 

 

 

 

 

A. 재귀

 

func bin(int n, int k){
	if(k == 0 or n == k)
    	return 1
    else
    	return bin(n-1,k-1) + bin(n-1,k)
}

 

간단하다. 위에서 세운 관계식을 그대로 구현하면 된다.

 

시간복잡도는 T(n,k) = T(n-1,k-1) + T(n-1,k) ≈ 2(n! / k!(n-k)!) 로 대강 엄청나다.

 

이를 단순재귀로 풀면 바보라 보면 되겠다.

 

 

 

 

 

 

B. DP

 

DP는 일단 배열이 필요하다. 2차원 배열 B[i][j] 은

 

 

이라고 하자.

 

그리고 계수를 나타내는 파스칼 삼각형을 

 

이처럼 보았을 때 1이 아닌 값들은

좌표 (i-1,j-1)의 값과 (i-1,j)의 합과 같다.

 

2차원 배열 B[i][j]를 위의 그림같이 사용한다면 우리는 DP로 구현할 수 있을 것 같다.

 

 

 

n과 k가 주어졌을때 n번째 행의 k번째 수를 찾는 DP로 구현한 의사코드이다.

 

pascal[100][100] = {0, } // 계수를 저장할 이차원배열, 모두 0 초기화

func bin(int n, int k){
	for (i in 0...n) 
    	pascal[i][0] = 1 // 삼각형의 좌측 변은 모두 1로 고정
    
    for (i in 1...n) {
    	for j in 1...i {
        	if (j >= k) continue // k 값 이상으로는 구할 필요 X
            pascal[i][j] = pascal[i-1][j-1] + pascal[i-1][j]
        }
    }
    
    return pascal[n-1][k-1]
}

 

 

시간복잡도는 다음과 같다.

 

n과 k가 주어졌을때 i값에 따라 for j의 루프가 몇 번 실행되는지 따져봐야한다.

 

i = 0 일때 1번,

i = 1 일때 2번,

i = 2 일때 3번 ...

i = k 일때 k + 1번

i = k + 1일때 k + 1번 ...

i = n 일때 k + 1번 

 

따라서 루프는 1 + 2 + 3 + ... + k + (k+1) * (n-k+1) 번

 = k(k+1)/2 + (k+1)(n-k+1) = (2n-k+2)(k+1)/2 

 ≈  Θ(nk)