Files
hello-algo/ko/docs/chapter_dynamic_programming/intro_to_dynamic_programming.md
T
Yudong Jin 2f561d3d51 feat(ko): add the Korean online edition (#2006)
* feat(ko): add the complete Korean online edition

* Use UTF-8 for Python CI output on Windows

* Remove print-book promotion from the Korean site

* fix(ko): preserve emphasis before Korean particles
2026-10-29 03:46:43 +08:00

7.6 KiB

동적 계획법 첫걸음

동적 계획법(dynamic programming)은 중요한 알고리즘 패러다임이다. 문제를 더 작은 부분 문제들로 나누고 그 해를 저장하여 중복 계산을 피함으로써 시간 효율을 크게 높인다.

이 절에서는 대표적인 예제에서 출발하여 먼저 완전 탐색을 수행하는 백트래킹 풀이를 제시한다. 그 안의 중복 부분 문제를 살펴보고, 더 효율적인 동적 계획법 풀이를 단계적으로 도출한다.

!!! question "계단 오르기"

총 $n$ 칸인 계단이 있고, 한 번에 $1$ 칸 또는 $2$ 칸을 오를 수 있다. 맨 위까지 오르는 방법은 몇 가지인가?

아래 그림과 같이 3 칸짜리 계단의 맨 위에 오르는 방법은 총 3 가지다.

3번째 칸까지 오르는 방법 수

이 문제는 방법의 수를 구하는 것이므로 백트래킹으로 모든 가능성을 열거할 수 있다. 계단 오르기를 여러 차례의 선택 과정으로 생각해 보자. 바닥에서 출발하여 매번 1 칸 또는 2 칸을 고른다. 맨 위에 도착할 때마다 방법 수를 1 늘리고, 맨 위를 넘어가면 가지치기한다. 코드는 다음과 같다.

[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 가지다.

아래 코드는 일반적인 백트래킹 코드와 마찬가지로 깊이 우선 탐색이지만 더 간결하다.

[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]에서 결과를 바로 가져와 중복 계산을 피한다.

코드는 다음과 같다.

[file]{climbing_stairs_dfs_mem}-[class]{}-[func]{climbing_stairs_dfs_mem}

아래 그림과 같이 메모이제이션을 적용하면 모든 중복 부분 문제를 한 번만 계산하므로 시간 복잡도가 $O(n)$으로 개선된다. 이는 매우 큰 발전이다.

메모이제이션을 이용한 탐색의 재귀 트리

방법 3: 동적 계획법

메모이제이션을 이용한 탐색은 '위에서 아래로' 진행하는 방법이다. 원래 문제(루트 노드)에서 시작하여 큰 부분 문제를 작은 부분 문제로 재귀적으로 나누고, 해를 이미 아는 가장 작은 부분 문제(리프 노드)에 도달한다. 이후 되돌아오면서 각 단계의 해를 모아 원래 문제의 해를 구성한다.

반대로 동적 계획법은 '아래에서 위로' 진행하는 방법이다. 가장 작은 부분 문제의 해에서 시작하여 반복적으로 더 큰 부분 문제의 해를 구성하고, 마침내 원래 문제의 해를 얻는다.

동적 계획법에는 되돌아오는 과정이 없으므로 재귀 없이 반복문만으로 구현할 수 있다. 아래 코드에서는 부분 문제의 해를 저장하는 배열 dp를 초기화한다. 이는 메모이제이션을 이용한 탐색의 배열 mem과 같은 기록 역할을 한다.

[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에 모든 부분 문제의 해를 저장할 필요가 없음을 알아차렸을 것이다. 변수 두 개를 차례로 갱신하며 진행하면 된다. 코드는 다음과 같다.

[file]{climbing_stairs_dp}-[class]{}-[func]{climbing_stairs_dp_comp}

위 코드에서는 배열 dp의 공간을 없앴으므로 공간 복잡도가 $O(n)$에서 $O(1)$로 줄어든다.

동적 계획법의 현재 상태는 앞선 몇 개의 상태에만 의존하는 경우가 많다. 이때 필요한 상태만 남겨 '차원을 줄이면' 메모리를 절약할 수 있다. 이 공간 최적화 기법을 '롤링 변수' 또는 '롤링 배열'이라고 한다.