1. 재귀를 통한 문제 해결
1.1 재귀의 개념과 구조
- 재귀는 문제를 동일한 형태의 작은 문제로 나누어 해결하는 방식입니다
- 재귀 함수는 자기 자신을 호출하여 문제를 해결합니다
1.2 재귀의 핵심 요소
- 재귀의 핵심 요소는 종료 조건(Base Case)과 재귀 호출(Recursive Case)입니다
1.2.1 종료 조건 예제
def factorial(n):
# 종료 조건
if n <= 1:
return 1
# 재귀 호출
return n * factorial(n-1)
- 재귀 호출이 끝나는 시점을 명확히 정의해야 합니다
- 종료 조건이 없으면 무한 재귀에 빠질 수 있습니다
1.2.2 재귀 호출 특징
- 문제를 더 작은 단위로 나누어 자신을 다시 호출합니다
- 각 호출마다 문제의 크기가 감소해야 합니다
1.3 재귀로 피보나치 수열 구현하고 문제점 분석
1.3.1 기본 재귀 구현
def fibonacci(n):
# 종료 조건
if n <= 1:
return n
# 재귀 호출
return fibonacci(n-1) + fibonacci(n-2)
- 시간 복잡도: O(2ⁿ)
- 중복된 계산이 많아 성능이 떨어집니다
- 예) fibonacci(3) = fibonacci(2) + fibonacci(1) = fibonacci(1) + fibonacci(0) + fibonacci(1)
- fibonacci(1)이 중복 호출됩니다
- 메모리 문제
- 재귀 호출마다 스택 프레임이 쌓임
- 깊은 재귀의 경우 스택 오버플로우 발생 가능
- 각 호출마다 지역 변수와 반환 주소가 스택에 저장됨
2. 다이나믹 프로그래밍의 등장
2.1 DP가 필요한 이유
- 재귀의 치명적인 문제: 중복 계산
- DP의 핵심 아이디어: 계산 결과 재사용
- 한 번 계산한 결과를 저장하고 재활용하여 중복 계산을 방지합니다
2.2 DP가 적용 가능한 문제의 특징
- 최적 부분 구조(Optimal Substructure)
- 큰 문제의 최적해가 작은 문제의 최적해로 구성됨
- 간단히 말하면 점화식을 세울 수 있다면 Optimal Substructure를 가진다고 할 수 있습니다
- 피보나치 예시: F(n) = F(n-1) + F(n-2)
- 중복되는 부분 문제(Overlapping Subproblem)
- 동일한 작은 문제들이 반복해서 나타남
- 피보나치 예시: F(3)이 F(5), F(4) 계산시 중복 등장
- 이항 계수 계산: nCr = n-1Cr + n-1Cr-1에서 여러 항의 계산이 중복됨
3. DP의 구현 방식
- DP는 크게 하향식(Top-down)과 상향식(Bottom-up) 방식으로 구현할 수 있습니다
- 메모이제이션(Memoization): 하향식 방식으로 구현하는 기법
- 이미 계산한 값을 저장해두고 재활용하는 기법
- 타뷸레이션(Tabulation): 상향식 방식으로 구현하는 기법
- 작은 문제부터 차례대로 해결해 나가는 기법
3.1 하향식(Top-down) - 메모이제이션
- 메모이제이션(Memoization): 계산 결과를 저장해두고 재활용하는 기법
- 재귀 호출을 사용하며 중복 계산을 피합니다
- 이미 계산한 값은 저장해두고 필요할 때 꺼내 사용합니다
3.1.1 메모이제이션 구현
def fibonacci_memoization(n, memo=None):
if memo is None:
memo = {}
# 이미 계산된 값이면 반환
if n in memo:
return memo[n]
# 종료 조건
if n <= 1:
return n
# 계산 결과를 메모에 저장
memo[n] = fibonacci_memoization(n-1, memo) + fibonacci_memoization(n-2, memo)
return memo[n]
3.1.2 메모이제이션 장단점
-
장점
- 필요한 부분만 계산합니다
- 직관적이고 재귀적 구조를 그대로 유지합니다
- 모든 상태를 계산할 필요가 없습니다
-
단점
- 재귀 호출 스택을 사용하므로 깊이가 깊어지면 스택 오버플로우가 발생할 수 있습니다
- 함수 호출 오버헤드가 있습니다
3.2 상향식(Bottom-up) - 타뷸레이션
- 타뷸레이션(Tabulation): 작은 문제부터 차례대로 해결해 나갑니다
- 반복문을 사용하며 중복 계산을 피합니다
- 작은 문제부터 순차적으로 해결하므로 최종 답을 구할 수 있습니다
- 메모이제이션과 달리 재귀 호출이 없어 스택 오버플로우 위험이 없습니다
3.2.1 타뷸레이션 구현
def fibonacci_tabulation(n):
# 테이블 초기화
dp = [0] * (n + 1)
dp[1] = 1
# 작은 문제부터 순차적으로 해결
for i in range(2, n + 1):
dp[i] = dp[i-1] + dp[i-2]
return dp[n]
3.2.2 타뷸레이션 장단점
-
장점
- 재귀 호출이 없어 스택 오버플로우 위험이 없습니다
- 캐시 지역성이 좋아 실행 속도가 더 빠릅니다
- 반복문을 사용하므로 예측 가능한 성능을 보입니다
- 메모리 사용량을 쉽게 예측할 수 있습니다
-
단점
- 모든 부분 문제를 계산해야 합니다
- 때로는 불필요한 상태까지 계산할 수 있습니다
- 점화식을 직관적으로 세우기 어려울 수 있습니다
3.3 공간 최적화 버전
3.3.1 최적화된 구현
def fibonacci_space_optimized(n):
if n <= 1:
return n
prev2, prev1 = 0, 1
for i in range(2, n + 1):
current = prev1 + prev2
prev2, prev1 = prev1, current
return prev1
- 시간 복잡도: O(n)
- 공간 복잡도: O(1)
4. DP 문제 해결 전략
4.1 문제 해결 단계
- DP 적용 가능성 확인
- 최적 부분 구조 존재 여부
- 중복되는 부분 문제 존재 여부
- 상태 정의
- 문제를 작은 부분으로 나누었을 때 어떤 상태가 필요한지
- 예: fibonacci(n)에서 n이 상태
- 점화식 수립
- 현재 상태와 이전 상태의 관계 정의
- 예: dp[i] = dp[i-1] + dp[i-2]
- 구현 방식 선택
- 메모이제이션(Top-down)
- 타뷸레이션(Bottom-up)
5. 다른 알고리즘과 차이점
5.1 Greedy Algorithm과 차이점
- Greedy Algorithm은 그 순간에 최적이라고 생각되는 것을 선택하면서 풀이합니다
- Dynamic Programming은 중복된 하위 문제들(Overlapping Subproblem)의 결과를 저장해뒀다가 풀이해 나갑니다
5.2 분할 정복과 차이점
- 분할 정복은 DP와 마찬가지로 최적 부분 구조를 가지는 공통점이 있으나 하위 문제가 반복되지 않습니다
5.3 알고리즘 비교 표
| 알고리즘 | 풀이 가능한 문제의 특징 | 풀이 가능한 문제 및 알고리즘 |
|---|---|---|
| 다이나믹 프로그래밍 | 최적 부분 구조 중복된 하위 문제 | 0-1 배낭 문제 피보나치 수열 다익스트라 알고리즘 |
| 그리디 알고리즘 | 최적 부분 구조 탐욕 선택 속성 | 분할 가능 배낭 문제 다익스트라 알고리즘 |
| 분할 정복 | 최적 부분 구조 | 병합 정렬 퀵 정렬 |