# 동적 계획법 첫걸음
동적 계획법(dynamic programming)은 중요한 알고리즘 패러다임이다. 문제를 더 작은 부분 문제들로 나누고 그 해를 저장하여 중복 계산을 피함으로써 시간 효율을 크게 높인다.
이 절에서는 대표적인 예제에서 출발하여 먼저 완전 탐색을 수행하는 백트래킹 풀이를 제시한다. 그 안의 중복 부분 문제를 살펴보고, 더 효율적인 동적 계획법 풀이를 단계적으로 도출한다.
!!! question "계단 오르기"
총 $n$ 칸인 계단이 있고, 한 번에 $1$ 칸 또는 $2$ 칸을 오를 수 있다. 맨 위까지 오르는 방법은 몇 가지인가?
아래 그림과 같이 $3$ 칸짜리 계단의 맨 위에 오르는 방법은 총 $3$ 가지다.

이 문제는 방법의 수를 구하는 것이므로 **백트래킹으로 모든 가능성을 열거할 수 있다**. 계단 오르기를 여러 차례의 선택 과정으로 생각해 보자. 바닥에서 출발하여 매번 $1$ 칸 또는 $2$ 칸을 고른다. 맨 위에 도착할 때마다 방법 수를 $1$ 늘리고, 맨 위를 넘어가면 가지치기한다. 코드는 다음과 같다.
```src
[file]{climbing_stairs_backtrack}-[class]{}-[func]{climbing_stairs_backtrack}
```
## 방법 1: 완전 탐색
백트래킹은 보통 문제를 명시적으로 나누지 않고 일련의 의사 결정 단계로 바라보며, 시도와 가지치기를 통해 가능한 모든 해를 찾는다.
이 문제를 부분 문제로 나누는 관점에서 분석해 보자. $i$ 번째 칸에 오르는 방법 수를 $dp[i]$라고 하면 $dp[i]$가 원래 문제이고, 다음은 그 부분 문제들이다.
$$
dp[i-1], dp[i-2], \dots, dp[2], dp[1]
$$
한 번에 $1$ 칸 또는 $2$ 칸만 오를 수 있으므로, 현재 $i$ 번째 칸에 있다면 직전에는 $i - 1$ 번째 또는 $i - 2$ 번째 칸에 있었어야 한다. 즉, $i -1$ 번째 또는 $i - 2$ 번째 칸에서만 $i$ 번째 칸으로 올라올 수 있다.
여기서 중요한 결론을 얻는다. **$i - 1$ 번째 칸에 오르는 방법 수와 $i - 2$ 번째 칸에 오르는 방법 수를 더하면 $i$ 번째 칸에 오르는 방법 수가 된다**. 식으로 나타내면 다음과 같다.
$$
dp[i] = dp[i-1] + dp[i-2]
$$
이는 계단 오르기 문제의 부분 문제들 사이에 점화 관계가 있으며, **부분 문제의 해로 원래 문제의 해를 구성할 수 있다는 뜻**이다. 아래 그림이 이 점화 관계를 보여 준다.

점화식으로 완전 탐색 풀이를 얻을 수 있다. $dp[n]$에서 출발해 **큰 문제 하나를 더 작은 두 문제의 합으로 재귀적으로 나누고**, 가장 작은 부분 문제 $dp[1]$과 $dp[2]$에 도달하면 반환한다. 이들의 해는 $dp[1] = 1$, $dp[2] = 2$로 이미 알려져 있다. 즉, $1$, $2$ 번째 칸에 오르는 방법은 각각 $1$, $2$ 가지다.
아래 코드는 일반적인 백트래킹 코드와 마찬가지로 깊이 우선 탐색이지만 더 간결하다.
```src
[file]{climbing_stairs_dfs}-[class]{}-[func]{climbing_stairs_dfs}
```
아래 그림은 완전 탐색으로 만들어지는 재귀 트리다. 문제 $dp[n]$의 재귀 트리 깊이는 $n$이고 시간 복잡도는 $O(2^n)$이다. 지수적 증가는 폭발적이므로 비교적 큰 $n$을 입력하면 매우 오래 기다려야 한다.

위 그림에서 **지수 시간 복잡도는 '중복 부분 문제' 때문에 생긴다**. 예를 들어 $dp[9]$는 $dp[8]$과 $dp[7]$로, $dp[8]$은 $dp[7]$과 $dp[6]$으로 나뉘므로 두 경우 모두 부분 문제 $dp[7]$을 포함한다.
그 아래의 부분 문제에도 더 작은 중복 부분 문제가 계속 생겨난다. 계산 자원의 대부분은 이러한 중복 부분 문제에 낭비된다.
## 방법 2: 메모이제이션을 이용한 탐색
효율을 높이려면 **모든 중복 부분 문제를 한 번씩만 계산해야 한다**. 이를 위해 각 부분 문제의 해를 기록하는 배열 `mem`을 만들고, 탐색 중 중복 부분 문제를 가지치기한다.
1. $dp[i]$를 처음 계산하면 나중에 사용할 수 있도록 `mem[i]`에 기록한다.
2. $dp[i]$가 다시 필요하면 `mem[i]`에서 결과를 바로 가져와 중복 계산을 피한다.
코드는 다음과 같다.
```src
[file]{climbing_stairs_dfs_mem}-[class]{}-[func]{climbing_stairs_dfs_mem}
```
아래 그림과 같이 **메모이제이션을 적용하면 모든 중복 부분 문제를 한 번만 계산하므로 시간 복잡도가 $O(n)$으로 개선된다**. 이는 매우 큰 발전이다.

## 방법 3: 동적 계획법
**메모이제이션을 이용한 탐색은 '위에서 아래로' 진행하는 방법**이다. 원래 문제(루트 노드)에서 시작하여 큰 부분 문제를 작은 부분 문제로 재귀적으로 나누고, 해를 이미 아는 가장 작은 부분 문제(리프 노드)에 도달한다. 이후 되돌아오면서 각 단계의 해를 모아 원래 문제의 해를 구성한다.
반대로 **동적 계획법은 '아래에서 위로' 진행하는 방법**이다. 가장 작은 부분 문제의 해에서 시작하여 반복적으로 더 큰 부분 문제의 해를 구성하고, 마침내 원래 문제의 해를 얻는다.
동적 계획법에는 되돌아오는 과정이 없으므로 재귀 없이 반복문만으로 구현할 수 있다. 아래 코드에서는 부분 문제의 해를 저장하는 배열 `dp`를 초기화한다. 이는 메모이제이션을 이용한 탐색의 배열 `mem`과 같은 기록 역할을 한다.
```src
[file]{climbing_stairs_dp}-[class]{}-[func]{climbing_stairs_dp}
```
아래 그림은 위 코드의 실행 과정을 보여 준다.

백트래킹과 마찬가지로 동적 계획법도 '상태'로 문제 해결의 특정 단계를 나타낸다. 각 상태는 부분 문제 하나와 그에 해당하는 국소 최적해에 대응한다. 예를 들어 계단 오르기 문제의 상태는 현재 위치한 계단의 칸 번호 $i$로 정의한다.
위 내용을 바탕으로 동적 계획법의 주요 용어를 정리할 수 있다.
- 배열 `dp`를 dp 테이블이라고 하며, $dp[i]$는 상태 $i$에 대응하는 부분 문제의 해다.
- 가장 작은 부분 문제에 해당하는 상태(계단의 $1$, $2$ 번째 칸)를 초기 상태라고 한다.
- 점화식 $dp[i] = dp[i-1] + dp[i-2]$를 상태 전이 식이라고 한다.
## 공간 최적화
주의 깊게 읽었다면 **$dp[i]$는 $dp[i-1]$과 $dp[i-2]$에만 의존하므로 배열 `dp`에 모든 부분 문제의 해를 저장할 필요가 없음**을 알아차렸을 것이다. 변수 두 개를 차례로 갱신하며 진행하면 된다. 코드는 다음과 같다.
```src
[file]{climbing_stairs_dp}-[class]{}-[func]{climbing_stairs_dp_comp}
```
위 코드에서는 배열 `dp`의 공간을 없앴으므로 공간 복잡도가 $O(n)$에서 $O(1)$로 줄어든다.
동적 계획법의 현재 상태는 앞선 몇 개의 상태에만 의존하는 경우가 많다. 이때 필요한 상태만 남겨 '차원을 줄이면' 메모리를 절약할 수 있다. **이 공간 최적화 기법을 '롤링 변수' 또는 '롤링 배열'이라고 한다**.