[include(틀:컴퓨터공학)] [include(틀:이론 컴퓨터 과학)] [목차] [clearfix] == 개요 == {{{+1 [[動]][[的]] [[計]][[劃]][[法]] / dynamic programming, DP}}} [[최적화 이론]]의 한 기술이며, 특정 범위까지의 값을 구하기 위해서 그것과 다른 범위까지의 값을 이용하여 효율적으로 값을 구하는 [[알고리즘]] 설계 기법이다. 조금 장난스럽게 말해서 답을 재활용하는 거다. 앞에서 구했던 답을 뒤에서도 이용하고, 옆에서도 이용하고...엄밀히 말해 동적 계획법은 구체적인 알고리즘이라기보다는 문제해결 패러다임에 가깝다. 동적 계획법은 "어떤 문제를 풀기 위해 그 문제를 더 작은 문제의 연장선으로 생각하고, 과거에 구한 해를 활용하는" 방식의 알고리즘을 총칭한다. [* 초보자라서 이게 무슨 소리인지 잘 이해가 안간다면 "N번째 답을 구하기 위해 N-1번째 답을 활용하는 방식의 접근법" 이라고 이해해도 된다. N번째 답이 N-1번째 답에서 도출될 수 있다면, N-1번째 답은 N-2번째 답에서 도출되고, N-2번째 답은 N-3번째에서.. 하는 방식으로 나아가게 될 것이다. 결국 2번째 답은 1번째 답을 활용해서 구할 수 있으므로, 1번째 답만 구해놓고 dp를 활용하면 원하는 N이 무엇이든 답을 얻을 수 있게 된다. 수준이 올라갈수록 N번째 답을 구하기 위해 N-k번째 답을 활용한다거나 하는 식으로 여러모로 복잡하게 활용되기 시작하지만, 초보자라면 지금 당장은 저렇게 이해해도 무방하다.] 답을 구하기 위해서 했던 계산을 또 하고 또 하고 계속해야 하는 종류의 문제의 구조를 [[그리디 알고리즘#s-2.1|최적 부분 구조(Optimal Substructure)]]라고 부른다. 동적 계획법은 이런 문제에서 효과를 발휘한다. 동적 계획법을 영문으로는 다이나믹 프로그래밍(dynamic programming)이라 표기하는데, 이름과는 달리 딱히 다이나믹하지도 않고 [[프로그래밍]]이라는 단어와도 큰 연관이 없다.[* 동적 계획법의 고안자인 벨만(Richard E. Bellman, 벨만-포드 알고리즘의 그 벨만 맞다.) 은 dynamic 이라는 단어가 멋있어서 이름으로 선택했다고 한다. Programming 이란 단어는 최적화 연구 분야에서 최적의 프로그램을 찾아낸다는 의미로 사용된다. - 프로그래밍 대회에서 배우는 알고리즘 문제해결전략(구종만 저) 1권 p.207][* 벨만이 다이나믹 프로그래밍에 대해 쓴 저서의 서문을 보면 연구소에서 펀딩을 받기에 좋은(...) 단어라서 선택했다고 나온다. 그는 당시 [[벨 연구소]]에 재직중이었다.] 이에 대해 이광근 교수의 저서 "컴퓨터 과학이 여는 세계"에서는 다이나믹 프로그래밍을 본질적인 의미를 더 살려서 '''기억하며 풀기'''로 더욱 [[적절]]하게 번역하였다. == 구현 == 동적 계획법의 접근 방식은 기본적으로 [[분할 정복 알고리즘]]과 비슷하다. 위에서도 설명했지만 동적 계획법을 사용하는 알고리즘 역시 주어진 문제를 부분 문제로 나누어 각 부분 문제의 답을 계산하고, 이 계산한 결과값을 이용해 원래 문제의 답을 산출하기 때문이다. 여기서 분할정복과 차이가 생기는 부분은 원래의 문제를 부분 문제로 나누는 방식에 있다. 동적 계획법의 경우 주어진 문제를 나눌 때 부분 문제를 최대한 많이 이용하도록 나눈 다음, 주어진 부분 문제의 정답을 한 번만 계산하고 저장해둔 뒤 다시 한 번 이 부분 문제를 이용할 때에는 저장해둔 정답을 바로 산출하여 이용함으로써 속도를 향상시킨다. [[파일:5-23-2.png|width=500]] 즉, 동적 계획법은 그림과 같이 [[그리디 알고리즘#s-2.1|최적 부분 구조(Optimal Substructure)]]를 지닌 중복된 하위 문제들(Overlapping Subproblems)을 분할 정복으로 풀이하는 문제해결 패러다임이다. === 접근 === 동적 계획법의 개념과 구현에 대해 정확하게 짚고 넘어가기 위해 동적 계획법을 적용시킬 수 있는 예에 대해 알아보자. * f(a,b) = f(a-1,b) + f(a,b-1) (a,b >= 1 ) * f(0,0) = 1, 임의의 자연수 n에 대해 f(n,0) = f(0,n) = 1 위와 같이 정의된 함수에서 주어진 임의의 a,b에 대해 f(a,b)의 값을 효율적으로 구하고자 할 때 동적 계획법을 적용시킬 수 있다. 예를 들어 f(2,2)을 계산한다고 해보자. 이 경우 아래 그림과 같은 참조를 거치게 된다. [[파일:external/s21.postimg.org/tree.png]] 순서대로 계산횟수를 구해보면 f(2,2)를 구하기 위해 총 5번의 연산을 거쳐야한다. 이때 중복되는 부분 문제[* overlapping subproblems - 두 번 이상 계산되는 부분 문제]에 대한 답을 미리 계산해놓고 연산한다고 가정했을 때 계산횟수를 구해보면 f(2,2)를 구하기 위해 4번의 연산을 거쳐야한다. 이 경우 간단한 예라 중복되는 부분 문제가 f(1,1) 하나뿐이기 때문에 큰 속도의 이득을 볼 수 없지만 a, b의 값이 커질수록 속도 면에서 엄청난 이득을 볼 수 있다. 몇 가지 a, b 값에 대해 f(a, b)를 구하기 위한 연산 횟수를 나타낸 아래 표를 참고해보자. || {{{#fff (a,b)}}} || {{{#fff '''그냥 계산 시 연산 횟수'''}}} || {{{#fff '''동적 계획법 이용 시 연산 횟수'''}}} || || (2,2) || 5 || 4 || || (4,4) || 69 || 16 || || (6,8) || 3002 || 48 || || (10,10) || 184755 || 100 || 이 정도면 아마 대충 동적 계획법의 효율이 느껴질 것이다. 실제로 a, b가 1 증가할 때마다 연산 횟수는 기하급수적으로 증가하며, 이때 중복되는 부분 문제를 적절히 처리해줌으로써 높은 계산효율을 얻을 수 있는 것이다. === 예시 === 동적 계획법을 이해하는 가장 쉬운 예시로 [[피보나치 수열]] 구하기가 있다. 물론 굳이 동적 계획법을 쓰지 않고도 훨씬 깔끔하게 구할 수 있지만,[* 굳이 알고리즘을 쓸 필요없이 피보나치 수열의 점화식을 표현하는 행렬의 거듭제곱을 하는 것이 가장 깔끔하긴 하다. 행렬의 거듭제곱 계산을 O(log N)에 한다면 시간복잡도 역시 O(log N)이다. 피보나치 수열의 일반항을 이용하는 경우도 시간복잡도는 같지만 일반항에 무리수가 포함된다는 단점이 있다.] 이해를 돕기 위해 여기서는 정통적인 동적 계획법으로 풀어본다. ==== 재귀함수 형태의 피보나치 수열 ==== 피보나치 수열은 다음과 같이 [[재귀함수]]의 형태(Recursive Form)로 표현된다. {{{f(0) = 1 f(1) = 1 f(n) = f(n-1) + f(n-2) when n > 1}}} 이 수학적인 정의는 매우 깔끔하지만, 실제로 컴퓨터가 계산하기엔 매우 부적합한 형태이다. 위 정의를 그대로 C언어로 구현하면 아래와 같다. {{{#!syntax cpp int f(unsigned int n) { if(n <= 1) return 1; else return f(n-1)+f(n-2); } }}} C언어를 배우면 왜 문제인지 알 수 있다. 함수가 호출되면 프로그램 메모리의 [[스택(자료구조)|스택]](Stack)이라는 곳에 데이터가 쌓이게 된다. 그 함수의 실행이 끝났을 때 다시 메모리가 해제되는 방식인데, 이 말인 즉슨 함수가 계속 호출되면 메모리에 쌓이는 것들이 계속 증가한다는 소리다! 여기서 재귀적 피보나치 함수의 문제가 드러난다. 5번째 피보나치 수열을 구하기 위해선 다음 그림과 같이 총 15번의 함수 호출이 발생한다. [[파일:5-23-3.png|width=600]] 숫자가 작을 때는 그나마 괜찮지만, 숫자가 조금만 커져도 시간 복잡도와 공간 복잡도가 지수 스케일로 폭발한다! (이런 걸 Exponential Explosion이라고 한다) 시간 복잡도야 시간을 많이 들이면 견딜 수 있다 해도, 공간 복잡도의 경우 스택 오버플로우가 발생해 프로그램이 튕겨버린다. ==== 동적 계획법 형태의 피보나치 수열 ==== 동적 계획법에서는 이런 '삽질(=반복계산)'을 막기 위해 이전에 계산했던 값들을 배열에 저장한다. 대표적인 방식은 Top-down과 Bottom-up이다. Top-down은 위에서 내려오는 것, 즉, 큰 문제부터 시작해서 계속 작은 문제로 분할해 가면서 푸는 것이다. fibonacci(4)를 구하는 큰 문제는 fibonacci(3)과 fibonacci(2)를 구하는 작은 문제로 나눌 수 있다. fibonacci(3)을 구하는 작은 문제는 fibonacci(2)와 fibonacci(1)을 구하는 더 작은 문제로 나눌 수 있다...따라서 fibonacci(n)을 구하는 큰 문제를 풀 수 있다. 구현은 다음과 같다. {{{#!syntax cpp int memo[100]{}; //메모이제이션 공간. 전역 변수이므로 0으로 초기화 int fibonacci(unsigned int n) { if (n<=1) //0번째, 1번째 피보나치 수 return n; if (memo[n]!=0) //메모가 있는지 확인(0으로 초기화되었으므로 0이 아니라면 메모가 쓰인 것임) return memo[n]; //메모 리턴 memo[n]=fibonacci(n-1) + fibonacci(n-2); //작은 문제로 분할 return memo[n]; } }}} Bottom-up은 바닥에서 올라오는 것, 즉, 작은 문제부터 시작해서 작은 문제를 점점 쌓아 큰 문제를 푸는 것이다. 첫 번째 피보나치 수를 구하는 문제와 두 번째 피보나치 수를 구하는 문제를 풀면 세 번째 피보나치 수를 구하는 문제를 풀 수 있다. 두 번째 피보나치 수를 구하는 문제와 세 번째 피보나치 수를 구하는 문제를 풀면 네 번째 피보나치 수를 구하는 문제를 풀 수 있다....이걸 반복하면 n번째 피보나치 수를 구할 수 있다. 구현은 다음과 같다. {{{#!syntax cpp int f_data[N] = {1, 1}; // N은 정의하기 나름 int last_pos = 1; // 마지막으로 계산한 지점. 이 코드에선 이미 f_data[1]까지 정의되어있기 때문에 1로 초기화한다. int f(int n) //피보나치 수열의 제 n항을 구한다. 배열의 관점에서는 n-1번째 요소를 구하는 것. { int i; if(f_data[n-1] == 0) // 아직 구한 적이 없으면 구한다 { for(i=last_pos+1; i