동적 계획법 편집하기


편집하면 당신의 IP 주소가 공개적으로 기록됩니다. 계정을 만들고 로그인하면 편집 시 사용자 이름만 보이며, 위키 이용에 여러 가지 편의가 주어집니다.

편집을 취소할 수 있습니다. 이 편집을 되돌리려면 아래의 바뀐 내용을 확인한 후 게시해주세요.

최신판 당신의 편집
1번째 줄: 1번째 줄:
Dynamic Programming, 動的計劃法.
Dynamic Programming, DP.


== 개요 ==
== 개요 ==
수학과 컴퓨터 공학, 경제학에서 쓰이는 동적 계획법은 어떤 복잡한 최적화 문제를, 간단한 작은 부분 문제로 나누어 해결하는 방법을 말한다.
동적 계획법은 어떤 최적화 문제를, 작은 부분 문제로 나누어 차례대로 해결하는 알고리즘을 말한다.


이름이 Dynamic Programming이지만 그닥 역동적인 알고리즘은 아닌데, [[예산]]을 따오기 위해 멋진 이름을 지었다고 카더라(...). 또한 Programming은 컴퓨터 프로그래밍을 의미하는 것이 아니고, [[선형계획법]]처럼 최적화 문제의 해결 방법이라는 의미이다.
이름이 Dynamic Programming이지만 그닥 역동적인 알고리즘은 아닌데, [[예산]]을 따오기 위해 멋진 이름을 지었다고 카더라(...). 또한 Programming은 컴퓨터 프로그래밍을 의미하는 것이 아니고, [[선형계획법]]처럼 최적화 문제의 해결 방법이라는 의미이다.
컴퓨터 공학에서 Dynamic Programming은 중요한 위치를 차지한다. 시간 복잡도를 낮춤으로써 더 빠르게 문제를 해결하는 해를 얻을 수 있다. 예를 들어, <math> O(n^3) </math>을 사용해 해결한 문제에 동적 프로그래밍으로 접근이 가능하다면, <math> O(n) </math>만에 푸는 것도 가능하다. ACM-ICPC 따위의 대회에 많이 출제되는 유형이기도 하며, Baekjoon Online Judge의 알고리즘 분류 기준에서 가장 많은 문제들이 출제되어 있는 알고리즘이다.
== 설명 ==
주어진 문제를 풀기 위하여 그 문제를 작은 문제들로 나누어 푼 다음, 그 결과들을 조합하여 궁극적 결론에 도달하는 것이다. 이 과정에서 같은 문제를 풀어야 할 경우 메모이제이션 기법을 사용하여 시간을 절약할 수 있다.


== 예시 ==
== 예시 ==
35번째 줄: 29번째 줄:
저 과정에서 <math> \mathrm{fib}(2) </math>는 3번, <math> \mathrm{fib}(3) </math>는 2번 계산됨을 알 수 있다. 이걸 일일히 계산하지 말고, 한번 구한 값을 그냥 어디 적어(memo)놨다가 다시 쓰면 어떨까?
저 과정에서 <math> \mathrm{fib}(2) </math>는 3번, <math> \mathrm{fib}(3) </math>는 2번 계산됨을 알 수 있다. 이걸 일일히 계산하지 말고, 한번 구한 값을 그냥 어디 적어(memo)놨다가 다시 쓰면 어떨까?


이 기법이 바로 메모이제이션(memoization)이다.
이 기법이 바로 메모화(memoization)이다.


저 그림의 맨 왼쪽 아래부터 계산하기 시작해보자. <math> \mathrm{fib}(2) = 1 + 0 = 1 </math>로 채워진다. 그 위의 <math> \mathrm{fib}(3) = 1 + 1 = 2 </math>가 될 것이다. 이제 <math> \mathrm{fib}(4) </math>를 계산해야 한다. 원래는 <math> \mathrm{fib}(4) = 2 + \mathrm{fib}(2) </math>로 오른쪽의 <math> \mathrm{fib}(2) </math>를 다시 계산해야겠지만, 앞서 <math> \mathrm{fib}(2) </math>를 계산할 때 어디 적어뒀다면 이를 바로 읽어와 <math> \mathrm{fib}(4) = 2 + 1 = 3 </math>을 간단히 계산할 수 있다. 마찬가지로, <math> \mathrm{fib}(5) </math>의 계산 역시 <math> \mathrm{fib}(3) </math>의 계산 결과가 저장되어 있다면, 곧바로 <math> \mathrm{fib}(5) = 3 + 2 = 5 </math>라는 값을 구할 수 있다. 메모하지 않았다면 <math> \mathrm{fib}(3), \mathrm{fib}(2) </math> 따위를 여러번 다시 구해야 할 판인데, 계산이 확 줄어들었다.
저 그림의 맨 왼쪽 아래부터 계산하기 시작해보자. <math> \mathrm{fib}(2) = 1 + 0 = 1 </math>로 채워진다. 그 위의 <math> \mathrm{fib}(3) = 1 + 1 = 2 </math>가 될 것이다. 이제 <math> \mathrm{fib}(4) </math>를 계산해야 한다. 원래는 <math> \mathrm{fib}(4) = 2 + \mathrm{fib}(2) </math>로 오른쪽의 <math> \mathrm{fib}(2) </math>를 다시 계산해야겠지만, 앞서 <math> \mathrm{fib}(2) </math>를 계산할 때 어디 적어뒀다면 이를 바로 읽어와 <math> \mathrm{fib}(4) = 2 + 1 = 3 </math>을 간단히 계산할 수 있다. 마찬가지로, <math> \mathrm{fib}(5) </math>의 계산 역시 <math> \mathrm{fib}(3) </math>의 계산 결과가 저장되어 있다면, 곧바로 <math> \mathrm{fib}(5) = 3 + 2 = 5 </math>라는 값을 구할 수 있다. 메모하지 않았다면 <math> \mathrm{fib}(3), \mathrm{fib}(2) </math> 따위를 여러번 다시 구해야 할 판인데, 계산이 확 줄어들었다.
52번째 줄: 46번째 줄:
</pre>
</pre>
==== 시간 복잡도 분석 ====
==== 시간 복잡도 분석 ====
우선 메모이제이션을 적용하지 않고 계산했을 때의 시간 복잡도를 구해보자. <math> \mathrm{fib}(n) </math>을 계산하기 위해서는, <math> \mathrm{fib}(n - 1) </math>과 <math> \mathrm{fib}(n - 2) </math>를 계산해야 한다. 계산 시간을 <math> T(n) </math> 이라고 하면, <math> T(n) = T(n - 1) + T(n - 2) </math> 이다. 이는 피보나치 수열의 점화식과 같다. [[big-O 표기법]]에 따라 쓰면 <math> O(\mathrm{fib}(n)) </math>이고, 좀 큰 <math> n </math> 에 대해 <math> \mathrm{fib}(n) \approx 1.6^n </math> 이므로, <math> O(1.6^n) </math>로 쓸 수 있다.
우선 메모화를 적용하지 않고 계산했을 때의 시간 복잡도를 구해보자. <math> \mathrm{fib}(n) </math>을 계산하기 위해서는, <math> \mathrm{fib}(n - 1) </math>과 <math> \mathrm{fib}(n - 2) </math>를 계산해야 한다. 계산 시간을 <math> T(n) </math> 이라고 하면, <math> T(n) = T(n - 1) + T(n - 2) </math> 이다. 이는 피보나치 수열의 점화식과 같다. [[big-O 표기법]]에 따라 쓰면 <math> O(\mathrm{fib}(n)) </math>이고, 좀 큰 <math> n </math> 에 대해 <math> \mathrm{fib}(n) \approx 1.6^n </math> 이므로, <math> O(1.6^n) </math>로 쓸 수 있다.


지수함수라는 놈이 얼마나 흉악한 놈인지는 다들 알겠지만 한번 짚어보자. <math> n = 100 </math>정도만 생각해봐도 대충 <math> 1.6^{100} = 2.6 \times 10^{20} </math>쯤 되는데, 이건 컴퓨터가 뚝딱 계산할 수 있는 양이 아니다. 요즘 CPU 클럭수가 대략 4GHz, 초당 <math> 4 \times 10^9 </math>회니까, 65,000,000,000초, 752314일쯤 걸리지 않을까? 물론 시간복잡도에 숫자 때려넣은 걸 클럭수로 나눈 것과 실제 수행 시간이 일치하지는 않지만, 대충 감은 잡을 수 있다.
지수함수라는 놈이 얼마나 흉악한 놈인지는 다들 알겠지만 한번 짚어보자. <math> n = 100 </math>정도만 생각해봐도 대충 <math> 1.6^{100} = 2.6 \times 10^{20} </math>쯤 되는데, 이건 컴퓨터가 뚝딱 계산할 수 있는 양이 아니다. 요즘 CPU 클럭수가 대략 4GHz, 초당 <math> 4 \times 10^9 </math>회니까, 65,000,000,000초, 752314일쯤 걸리지 않을까? 물론 시간복잡도에 숫자 때려넣은 걸 클럭수로 나눈 것과 실제 수행 시간이 일치하지는 않지만, 대충 감은 잡을 수 있다.


그렇다면 메모이제이션을 적용했을 때는 시간이 얼마나 걸릴까? 이미 계산한 값은 다시 계산하지 않기 때문에, n이 1 증가했을 때 추가되는 시간은 앞서 계산한 <math> \mathrm{fib}(n - 1) </math>과 <math> \mathrm{fib}(n - 2) </math>의 합을 구하는 시간만 필요하다. 이는 상수 시간에 해결될 수 있으므로, <math> T(n) = T(n - 1) + c </math> 이고, 결국 <math> T(n) = O(n) </math>이다. 지수 시간이 걸리는 문제를, 동적 계획법을 이용해 다항시간에 해결할 수 있음을 알 수 있다.
그렇다면 메모화를 적용했을 때는 시간이 얼마나 걸릴까? 이미 계산한 값은 다시 계산하지 않기 때문에, n이 1 증가했을 때 추가되는 시간은 앞서 계산한 <math> \mathrm{fib}(n - 1) </math>과 <math> \mathrm{fib}(n - 2) </math>의 합을 구하는 시간만 필요하다. 이는 상수 시간에 해결될 수 있으므로, <math> T(n) = T(n - 1) + c </math> 이고, 결국 <math> T(n) = O(n) </math>이다. 지수 시간이 걸리는 문제를, 동적 계획법을 이용해 다항시간에 해결할 수 있음을 알 수 있다.


==== Bottom-up Approach ====
==== Bottom-up Approach ====
메모이제이션을 통해 빠르게 피보나치 수열의 값을 구할 수 있게 되었다. 그런데 문제가 하나 남았다. 저 구현이 아직 [[재귀함수]]의 형태를 띠고 있다는 것이다. 재귀함수는 디버깅이 어렵고, 무엇보다 호출 스택의 한계 때문에 일정 이상으로 재귀하게 되면 프로그램이 터져 버리는 문제가 발생할 가능성이 있다. 모든 재귀함수는 반복문으로 고칠 수 있다는 사실이 증명되어 있으므로, 저걸 반복문으로 고치는게 좋겠다.
메모화를 통해 빠르게 피보나치 수열의 값을 구할 수 있게 되었다. 그런데 문제가 하나 남았다. 저 구현이 아직 [[재귀함수]]의 형태를 띠고 있다는 것이다. 재귀함수는 디버깅이 힘들고, 무엇보다 호출 스택의 한계 때문에 일정 이상으로 재귀하게 되면 프로그램이 터져 버리는 문제가 발생할 가능성이 있다. 모든 재귀함수는 반복문으로 고칠 수 있다는 사실이 증명되어 있으므로, 저걸 반복문으로 고치는게 좋겠다.


메모이제이션 재귀에서는 <math> \mathrm{fib}(5) </math>를 먼저 호출하고, 그 곳에서 <math> \mathrm{fib}(4), \mathrm{fib}(3), \cdots </math>등을 위에서 아래로 (Top-down) 호출하게 된다. 하지만 실제로 피보나치 수를 저장한 테이블이 차오르는 순서는 반대로, 아래에서부터 올라오게 된다. 위에서 아래로 내려가는 과정을 생략하고, 그냥 아래서부터 채워 올라오는 것이 바로 Bottom-up Approach이다.
메모화 재귀에서는 <math> \mathrm{fib}(5) </math>를 먼저 호출하고, 그 곳에서 <math> \mathrm{fib}(4), \mathrm{fib}(3), \cdots </math>등을 위에서 아래로 (Top-down) 호출하게 된다. 하지만 실제로 피보나치 수를 저장한 테이블이 차오르는 순서는 반대로, 아래에서부터 올라오게 된다. 위에서 아래로 내려가는 과정을 생략하고, 그냥 아래서부터 채워 올라오는 것이 바로 Bottom-up Approach이다.


<pre>
<pre>
84번째 줄: 78번째 줄:
</pre>
</pre>


처럼 하면 메모리 공간을 절약할 수 있다. 다만 이 풀이는 동적 계획법 풀이라고 보기는 힘들고, 피보나치 수열을 구하는 수많은 방법 중 하나라고 생각하자. 동적 계획법은 메모리를 희생해서 수행 시간을 개선하는 방법이라고 생각해도 좋다.
처럼 하면 메모리 공간을 절약할 수 있다. 다만 이 풀이는 동적계획법적인 풀이라고 보기는 힘들고, 피보나치 수열을 구하는 수많은 방법 중 하나라고 생각하자. 동적 계획법은 메모리를 희생해서 수행 시간을 개선하는 방법이라고 생각해도 좋다.


=== 거스름돈 문제 ===
=== 거스름돈 문제 ===
리브레 위키에서의 모든 기여는 크리에이티브 커먼즈 저작자표시-동일조건변경허락 3.0 라이선스로 배포됩니다(자세한 내용에 대해서는 리브레 위키:저작권 문서를 읽어주세요). 만약 여기에 동의하지 않는다면 문서를 저장하지 말아 주세요.
글이 직접 작성되었거나 호환되는 라이선스인지 확인해주세요. 리그베다 위키, 나무위키, 오리위키, 구스위키, 디시위키 및 CCL 미적용 사이트 등에서 글을 가져오실 때는 본인이 문서의 유일한 기여자여야 하고, 만약 본인이 문서의 유일한 기여자라는 증거가 없다면 그 문서는 불시에 삭제될 수 있습니다.
취소 편집 도움말 (새 창에서 열림)

| () [] [[]] {{}} {{{}}} · <!-- --> · [[분류:]] · [[파일:]] · [[미디어:]] · #넘겨주기 [[]] · {{ㅊ|}} · <onlyinclude></onlyinclude> · <includeonly></includeonly> · <noinclude></noinclude> · <br /> · <ref></ref> · {{각주}} · {|class="wikitable" · |- · rowspan=""| · colspan=""| · |}