* 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
10 KiB
동적 계획법 문제 풀이 방법
앞의 두 절에서는 동적 계획법 문제의 주요 특성을 살펴보았다. 이제 더 실용적인 두 질문을 알아보자.
- 어떤 문제가 동적 계획법 문제인지 어떻게 판단할까?
- 동적 계획법 문제는 어디서 시작하며, 전체 풀이 단계는 어떻게 될까?
문제 판단
대체로 중복 부분 문제와 최적 부분 구조가 있고 무후효성을 만족하면 동적 계획법에 적합하다. 하지만 문제 설명에서 이러한 특성을 바로 끌어내기는 어렵다. 따라서 보통 조건을 완화하여 먼저 백트래킹(모든 경우 열거)으로 풀기에 적합한지 살펴본다.
백트래킹에 적합한 문제는 보통 '의사 결정 트리 모형'을 따른다. 각 노드가 결정 하나를, 각 경로가 결정의 순서를 나타내는 트리 구조로 표현할 수 있는 문제다.
즉, 명확한 결정 개념이 있고 연속된 결정을 통해 해가 만들어진다면 의사 결정 트리 모형을 만족하며, 보통 백트래킹으로 해결할 수 있다.
여기에 더해 동적 계획법 문제로 판단할 때 '가산점'이 되는 요소도 있다.
- 최댓값, 최솟값, 가장 많은 수, 가장 적은 수 등의 최적화 표현이 포함되어 있다.
- 상태를 리스트, 다차원 행렬, 트리로 표현할 수 있고, 한 상태와 주변 상태들 사이에 점화 관계가 있다.
반대로 '감점' 요소도 있다.
- 최적해 하나가 아니라 가능한 모든 해결 방법을 찾는 것이 목표다.
- 문제 설명에 순열이나 조합의 특성이 뚜렷하며, 구체적인 여러 방법을 반환해야 한다.
의사 결정 트리 모형을 만족하면서 '가산점' 요소가 뚜렷하다면, 우선 동적 계획법 문제라고 가정하고 풀이 과정에서 확인해 볼 수 있다.
문제 해결 단계
동적 계획법의 풀이 흐름은 문제의 성격과 난이도에 따라 다르지만, 보통 결정 설명, 상태 정의, dp 테이블 구성, 상태 전이 식 도출, 경계 조건 결정 등의 단계를 따른다.
단계를 더 구체적으로 보여 주기 위해 대표적인 '최소 경로 합' 문제를 예로 든다.
!!! question "문제"
$n \times m$의 2차원 격자 `grid`가 주어진다. 각 칸에는 그 칸의 비용인 음이 아닌 정수가 들어 있다. 로봇은 왼쪽 위에서 출발하여 매번 아래 또는 오른쪽으로 한 칸씩만 이동하고, 오른쪽 아래에 도착한다. 왼쪽 위에서 오른쪽 아래까지의 최소 경로 합을 반환하라.
아래 그림의 격자에서는 최소 경로 합이 $13$이다.
1단계: 매 차례의 결정을 생각하고 상태를 정의하여 dp 테이블을 얻는다
이 문제에서 매 차례의 결정은 현재 칸에서 아래 또는 오른쪽으로 한 칸 이동하는 것이다. 현재 칸의 행과 열 인덱스가 $[i, j]$라면 이동 후에는 [i+1, j] 또는 $[i, j+1]$이 된다. 따라서 상태는 행 인덱스와 열 인덱스라는 두 변수를 포함해야 하며 $[i, j]$로 표시한다.
상태 $[i, j]$의 부분 문제는 출발점 $[0, 0]$에서 $[i, j]$까지의 최소 경로 합이고, 해를 $dp[i, j]$라고 한다.
이로써 아래 그림처럼 입력 격자 $grid$와 크기가 같은 2차원 dp 행렬을 얻는다.
!!! note "참고"
동적 계획법과 백트래킹 과정은 결정의 순서로 표현할 수 있으며, 상태는 모든 결정 변수로 이루어진다. 상태에는 풀이의 진행 정도를 나타내는 모든 변수가 포함되어야 한다. 즉, 다음 상태를 도출하기에 충분한 정보가 있어야 한다.
각 상태는 부분 문제 하나에 대응하며, 모든 부분 문제의 해를 저장하기 위해 $dp$ 테이블을 정의한다. 상태의 독립 변수 각각이 $dp$ 테이블의 한 차원이 된다. 본질적으로 $dp$ 테이블은 상태와 부분 문제의 해 사이의 매핑이다.
2단계: 최적 부분 구조를 찾아 상태 전이 식을 도출한다
상태 $[i, j]$는 위쪽 칸 [i-1, j] 또는 왼쪽 칸 $[i, j-1]$에서만 올 수 있다. 따라서 최적 부분 구조는 다음과 같다. $[i, j]$까지의 최소 경로 합은 $[i, j-1]$까지의 최소 경로 합과 $[i-1, j]$까지의 최소 경로 합 중 작은 쪽에 의해 결정된다.
위 분석으로 아래 그림의 상태 전이 식을 얻는다.
dp[i, j] = \min(dp[i-1, j], dp[i, j-1]) + grid[i, j]
!!! note "참고"
정의한 $dp$ 테이블을 바탕으로 원래 문제와 부분 문제의 관계를 생각하고, 부분 문제의 최적해로 원래 문제의 최적해를 만드는 방법, 즉 최적 부분 구조를 찾는다.
최적 부분 구조를 찾으면 이를 이용해 상태 전이 식을 세울 수 있다.
3단계: 경계 조건과 상태 전이 순서를 정한다
이 문제에서 첫 행의 상태는 왼쪽 상태에서만, 첫 열의 상태는 위쪽 상태에서만 올 수 있다. 따라서 첫 행 $i = 0$과 첫 열 $j = 0$이 경계 조건이다.
아래 그림과 같이 각 칸은 왼쪽 칸과 위쪽 칸에서 전이되므로, 바깥 반복문은 행을, 안쪽 반복문은 열을 순회한다.
!!! note "참고"
경계 조건은 동적 계획법에서 $dp$ 테이블을 초기화하는 데, 탐색에서는 가지치기에 사용한다.
상태 전이 순서의 핵심은 현재 문제의 해를 계산하기 전에 그 해가 의존하는 모든 작은 부분 문제의 해가 올바르게 계산되어 있도록 하는 것이다.
이제 동적 계획법 코드를 바로 작성할 수 있다. 하지만 부분 문제로 나누는 사고는 위에서 아래로 진행하므로, '완전 탐색 \rightarrow 메모이제이션을 이용한 탐색 \rightarrow 동적 계획법' 순서로 구현하는 편이 사고 흐름에 더 자연스럽다.
방법 1: 완전 탐색
상태 $[i, j]$에서 탐색을 시작하여 더 작은 상태 $[i-1, j]$와 $[i, j-1]$로 계속 나눈다. 재귀 함수에는 다음 요소가 포함된다.
- 재귀 매개변수: 상태
[i, j]. - 반환값: $[0, 0]$에서 $[i, j]$까지의 최소 경로 합
dp[i, j]. - 종료 조건: $i = 0$이고 $j = 0$이면 비용 $grid[0, 0]$을 반환한다.
- 가지치기:
i < 0또는 $j < 0$이면 인덱스가 범위를 벗어나므로 불가능함을 나타내는 비용 $+\infty$를 반환한다.
구현 코드는 다음과 같다.
[file]{min_path_sum}-[class]{}-[func]{min_path_sum_dfs}
아래 그림은 $dp[2, 1]$을 루트로 하는 재귀 트리다. 중복 부분 문제가 포함되어 있으며, 그 수는 격자 grid의 크기가 커질수록 급격히 늘어난다.
중복 부분 문제가 생기는 근본적인 이유는 왼쪽 위에서 어떤 칸에 도달하는 경로가 여러 개 있기 때문이다.
각 상태에는 아래와 오른쪽이라는 두 선택이 있고, 왼쪽 위에서 오른쪽 아래까지는 총 m + n - 2 번 이동해야 한다. 따라서 최악의 시간 복잡도는 $O(2^{m + n})$이며, $n$과 $m$은 각각 격자의 행 수와 열 수다. 이 계산은 경계 근처의 상황을 고려하지 않았다. 격자 경계에서는 선택이 하나뿐이므로 실제 경로 수는 이보다 적다.
방법 2: 메모이제이션을 이용한 탐색
격자 grid와 같은 크기의 메모이제이션 리스트 mem을 도입하여 각 부분 문제의 해를 기록하고 중복 부분 문제를 가지치기한다.
[file]{min_path_sum}-[class]{}-[func]{min_path_sum_dfs_mem}
아래 그림과 같이 메모이제이션을 도입하면 모든 부분 문제의 해를 한 번만 계산한다. 따라서 시간 복잡도는 전체 상태 수, 즉 격자 크기인 $O(nm)$에 의해 결정된다.
방법 3: 동적 계획법
반복을 이용한 동적 계획법 구현은 다음과 같다.
[file]{min_path_sum}-[class]{}-[func]{min_path_sum_dp}
아래 그림의 최소 경로 합 상태 전이 과정에서는 전체 격자를 순회하므로 시간 복잡도는 $O(nm)$이다.
배열 dp의 크기는 $n \times m$이므로 공간 복잡도는 $O(nm)$이다.
공간 최적화
각 칸은 왼쪽 칸과 위쪽 칸에만 의존하므로 한 행짜리 배열만으로 dp 테이블을 구현할 수 있다.
배열 dp가 한 행의 상태만 나타내므로 첫 열의 상태를 미리 초기화할 수는 없다. 대신 각 행을 순회할 때 갱신해야 한다.
[file]{min_path_sum}-[class]{}-[func]{min_path_sum_dp_comp}

















