중복된 계산 연산을 제거하여
Algorithm
을 개선시키는 방법
다이나믹 프로그래밍(Dynamic Programming)
은 이미 계산된 결과(작은 문제)를 별도의 메모리 영역에 저장하여 수행 시간 효율성을 비약적으로 향상시키는 방법이다.
구현은 일반적으로 두 가지 방식(TopDown
, BottomUp
)을 사용하며, 동적 계획법이라고도 부른다.
자료구조의
동적 할당(Dynamic Allocation)
의동적(Dynamic)
은 '프로그램이 실행되는 도중에 실행에 필요한 메모리를 할당하는 기법'을 의미한다.
반면Dynamic Programming
에서Dynamic
은 별다른 의미 없이 사용된 단어이다.
다이나믹 프로그래밍은 문제가 다음의 조건을 만족할 때 사용할 수 있다.
Optimal Substructure
)큰 문제를 작은 문제로 나눌 수 있으며 작은 문제의 답을 모아서 큰 문제를 해결할 수 있다.
Overlapping Subproblem
)동일한 작은 문제를 반복적으로 해결해야 한다.
메모이제이션(Memoization)
다이나믹 프로그래밍을 구현하는 방법 중 하나로, 한 번 계산한 결과를 메모리 공간에 메모하는 기법이다.
값을 기록해 놓는다는 점에서 캐싱(Caching)
이라고도 한다.
엄밀히 말하면 메모이제이션은 이전에 계산된 결과를 일시적으로 기록해 놓는 넓은 개념을 의미하기 때문에 다이나믹 프로그래밍에 국한된 개념이 아니다.
즉, 한 번 계산된 결과를 담아 놓기만 하고 다이나믹 프로그래밍을 위해 활용하지 않을 수도 있다.
피보나치 수열(Fibonacci Sequence)
다음과 같은 형태의 수열이며, 다이나믹 프로그래밍으로 효과적으로 계산할 수 있다.
또한 점화식이란 인접한 항들 사이의 관계식을 의미하는데, 피보나치 수열을 점화식으로 표현하면 다음과 같다.
피보나치 수열이 계산되는 과정은 다음과 같이 표현할 수 있다.
n번째 피보나치 수를 라고 할 때, 를 구하는 과정은 다음과 같다.
위의 과정을 재귀함수로 구현하면 다음과 같다.
// 피보나치 함수(Fibonacci Function)를 재귀함수로 구현
function fibonacci(n) {
if (n === 0 || n === 1) return 1;
return fibonacci(n - 2) + fibonacci(n - 1);
};
console.log(fibonacci(4));
// 실행 결과
3
단순 재귀 함수로 피보나치 수열을 구현하면 지수 시간 복잡도를 가지게 된다.
이는 다음과 같이 가 여러 번 호출 되어 중복되는 부분 문제를 발생시킨다.
이처럼 피보나치 수열의 점화식을 재귀 함수를 사용해 만들 수는 있지만, 단순히 매번 계산하도록 하면 문제를 효율적으로 해결할 수 없다.
피보나치 수열은 위에서 설명한 다이나믹 프로그래밍의 사용 조건을 만족하기 때문에 이를 통해 위와 같은 문제를 해결할 수 있다.
- 최적 부분 구조: 큰 문제를 작은 문제로 나눌 수 있다.
- 중복 부분 문제: 동일한 작은 문제를 반복적으로 해결한다.
다이나믹 프로그래밍은 크게 TopDown(Memoization)
, BottomUp
2가지 방식으로 구현할 수 있다.
TopDown(Memoization)
방식은 하향식이라고 하며, 재귀 함수를 이용한다.
즉, 큰 문제를 해결하기 위해서 작은 문제들을 재귀적으로 호출하여 작은 문제가 모두 해결되었을 때 실제로 큰 문제에 대한 답까지 얻을 수 있도록 코드를 작성한다.
TopDown
다이나믹 프로그래밍 방식으로 피보나치 수열을 구현하면 다음과 같다.
// 한 번 계산된 결과를 메모이제이션하기 위한 리스트 초기화
const dp = new Array(100).fill(0);
function fibonacci(n) {
// 종료 조건(0 혹은 1일때 1을 반환)
if (n === 0 || n === 1) return 1;
// 계산한 적 있는 문제라면 그대로 반환
if (dp[n] !== 0) return dp[n];
// 아직 계산하지 않은 문제라면 점화식에 따라서 피보나치 결과 반환
dp[n] = fibonacci(n - 2) + fibonacci(n - 1);
return dp[n];
}
console.log(fibonacci(99));
// 실행 결과
218922995834555169026
BottomUp
방식은 상향식 이라고 하며, 결과 저장용 리스트 DP 테이블을 이용한다.
주로 다이나믹 프로그래밍에서는 BottomUp
방식을 사용한다.
BottomUp
다이나믹 프로그래밍 방식으로 피보나치 수열을 구현하면 다음과 같다.
const dp = new Array(100).fill(0);
dp[0] = 1;
dp[1] = 1;
const n = 99;
for (let i = 2; i < n + 1; i++) {
dp[i] = dp[i - 2] + dp[i - 1];
}
console.log(dp[n]);
// 실행 결과
218922995834555169026
다이나믹 프로그래밍과 분할 정복은 모두 최적 부분 구조를 가질때 사용할 수 있다.
단, 두 방식의 차이점은 분할 문제의 중복이다.
다이나믹 프로그래밍 문제에서는 부분 문제들이 서로 영향을 미치며 부분 문제가 중복된다.
하지만 분할 정복 문제에서는 동일한 부분 문제가 반복적으로 계산되지 않는다.
분할 정복의 대표적인 예시인 퀵 정렬을 확인해보면 한 번 기준 원소(pivot)가 자리를 변경해서 자리를 잡으면 그 기준 원소의 위치는 바뀌지 않는다.
그래서 분할 이후 해당 피벗을 다시 처리하는 부분 문제를 호출하지 않는다.
다이나믹 프로그래밍 문제는 주어진 문제가 다이나믹 프로그래밍 유형임을 파악한 뒤 접근하는 것이 중요하다.
가장 먼저 그리디, 구현, 완전 탐색...등의 아이디어로 문제를 해결할 수 있는지 검토할 수 있다.
이 때 다른 알고리즘으로 풀이 방법이 떠오르지 않으면 다이나믹 프로그래밍을 고려해볼 수 있다.
일단 재귀함수로 비효율적인 완전 탐색 프로그램(TopDown
)을 작성한 뒤 작은 문제에서 구한 답이 큰 문제에서 그대로 사용될 수 있으면, 코드를 개선하는 방법을 사용할 수 있다.