Dynamic Programming (동적 계획법)
복잡한 문제를 간단한 문제(sub-problems)로 쪼개어 해결하는 알고리즘
❓ 피보나치 수열 문제 예시
DP table를 사용하기 위해서는 다음 두 가지 조건을 만족해야 한다.
1. Optimal Structure (최적 부분 구조)
1-a. 해당 문제를 작은 문제로 분할할 수 있어야 한다.
F(5)를 구하기 위해서는 F(4)와 F(3)을 계산해야 한다. 따라서 F(5) 문제를 F(4)와 F(3)이라는 작은 문제로 나누어 해결할 수 있다.
1-b. 작은 문제의 해결 방법을 통해 큰 문제의 해결 방법을 구할 수 있어야 한다.
피보나치 수열에서 n번째 항을 계산하기 위해서는 (n-1)번째 항과 (n-2)번째 항의 합을 계산해야 한다. 예를 들어, n=6인 경우를 생각해보면 다음과 같다.
F(6) = F(5) + F(4)
F(5) = F(4) + F(3)
F(4) = F(3) + F(2)
F(3) = F(2) + F(1)
위와 같이 작은 문제의 해결 방법을 이용하여 F(6)을 구할 수 있다.
2. Overlapping Sub-problems (중복되는 문제)
작은 문제들이 반복해서 해결되는 방식을 거친다.
F(4)를 구하기 위해서는 F(3)과 F(2)를 계산해야 한다.
이 때, F(3)은 F(2)와 F(1)을 계산하기 위해 이미 사용되었다. 따라서, F(3)을 한 번 계산해두면, 이를 재사용하여 F(4)를 계산할 수 있다.
이와 같은 방식으로, 이전에 계산한 결과를 저장해두고 재사용함으로써, 계산 속도를 빠르게 할 수 있다.
작은 문제들이 반복해서 해결되는 방식을 거친다.
Dynamic Programming의 구체적인 방식
6번째 피보나치 수를 구하는 코드를 비교해보자
0. 피보나치 코드의 재귀 함수를 이용한 구현
#include <iostream>
using namespace std;
int fibonacci(int n) {
if (n <= 1) {
return n; // n이 1 이하인 경우, n을 반환
}
// n이 2보다 크면, 재귀적으로 호출하여 피보나치 수열을 계산
return fibonacci(n-1) + fibonacci(n-2);
}
int main() {
int n = 6;
cout << fibonacci(n) << endl;
return 0;
}
1. Top-down 방식 (재귀적 구현)
작은 부분 문제로 분할한 후, 재귀적으로 부분 문제를 해결하는 방법
결과를 메모이제이션하여 중복 계산을 피한다.
#include <iostream>
#include <unordered_map>
using namespace std;
// n 값에 6을 넣으면, 6까지의 값이 계산됨.
// 계산된 값이 memo에 저장된 후 값을 return
int fibonacci(int n, unordered_map<int, int>&memo) {
// memo 딕셔너리 안에 n이라는 key가 존재하는 경우,
// 해당 값을 반환
if (memo.find(n) != memo.end()) {
return memo[n];
}
// n이 1 이하인 경우
// n을 반환
if (n <= 1) {
return n;
}
// n-1과 n-2의 합을 memo[n]에 저장 후 memo[n]을 return
memo[n] = fibonacci(n-1, memo) + fibonacci(n-2, memo);
return memo[n];
}
int main() {
int n = 6;
unordered_map<int, int> memo;
cout << fibonacci(n, memo) << endl;
return 0;
}
2. Bottom-up 방식 (반복적 구현)
작은 부분 문제부터 시작하여, 큰 문제를 해결해 나가는 방법
이러한 방법을 Tabulation이라고 함
#include <iostream>
using namespace std;
int memo[10]; // DP Table인 memo 배열 생성.
int fibonacci(int n) {
memo[0] = 0; // 초기값 설정
memo[1] = 1; // 초기값 설정
for (int i = 2; i <= n; i++) {
// 작은 문제부터 시작하여 이전에 계산한 결과를 이용하여 큰 문제 해결
memo[i] = memo[i - 1] + memo[i - 2];
}
return memo[n]; // n번째 피보나치 수열 값 반환
}
int main() {
int n = 6; // n을 6으로 지정
cout << fibonacci(n) << endl; // fibonacci 함수를 호출하고, 결과를 출력
return 0;
}
기본적으로 DP Table이라는 배열을 사용함.
- 인덱스 : 문제의 크기를 의미
- 값 : 해당 문제의 해답을 의미
Top-down(Memoization) vs Bottom-up(Tabulation)
- 구현 방식
- Memoization : 재귀 함수와 메모이제이션 배열을 사용하여 구현
- Tabulation : 반복문과 배열을 사용하여 구현
- 계산 방식
- Memoization : 필요한 부분 문제만 계산
- Tabulation : 모든 부분 문제를 계산
- 메모리 사용
- Memoization : 배열의 크기를 미리 지정하지 않아도 되므로 메모리 사용량을 줄일 수 있음
- Tabulation : 배열의 크기를 미리 지정해야 하므로 더 많은 메모리를 사용할 수 있음
- 오버헤드 (프로그램 실행 과정에서 추가적으로 발생하는 비용)
- Memoization : 재귀 함수를 사용하기 때문에 함수 호출 오버헤드가 있을 수 있음
- Tabulation : 반복문을 사용하기 때문에 오버헤드가 없음
동적 계획법(Dynamic Programming) vs 분할 정복 (Divide and Conquer)
공통점
- 모두 최적화 문제를 해결하는 데 사용되는 방법론
- 원래 문제를 부분 문제로 나누어 해결
차이점
- 동적 계획법
- 중복 계산을 피하기 위해 이미 계산한 결과를 저장하여 재활용
- 메모리를 추가로 만들어 사용 → 중복 계산을 피하는 대신 시간 복잡도가 개선
- 하향식(Top-down)과 상향식(Bottom-up) 두 가지 방식으로 구현
- 분할 정복
- 중복되지 않도록 각각의 부분 문제를 서로 겹치지 않게 나누어 해결
- 재귀 호출을 사용 → 메모리를 상대적으로 적게 사용하지만, 시간 복잡도가 높은 문제를 해결하는 데 적합
- 부분 문제들을 동시에 해결
'IT_Study > C++' 카테고리의 다른 글
[C++] 프로그램 컴파일 과정 - 선행처리기(preprocessor), 컴파일러(compiler), 링커(linker) (0) | 2023.03.01 |
---|---|
[C++] Bit operation, 구조체를 활용한 Map 사용법 정리 (0) | 2023.02.23 |
[C++] Greedy Algorithm (욕심쟁이 알고리즘) 정리 (0) | 2023.02.20 |
[C++] Binary Search, Parametric Search, Two pointer 알고리즘을 활용한 문제 풀이 방법 (0) | 2023.02.19 |
[그래프 #6] MST (Minimum Spanning Tree)를 활용한 문제 풀이 방법 (0) | 2023.02.16 |