Files
hello-algo/ko/docs/chapter_dynamic_programming/knapsack_problem.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

169 lines
8.2 KiB
Markdown

# 0-1 배낭 문제
배낭 문제는 동적 계획법 입문에 매우 좋은 예제이며 가장 흔한 문제 유형이다. 0-1 배낭, 무한 배낭, 개수 제한 배낭 등 여러 변형이 있다.
이 절에서는 가장 흔한 0-1 배낭 문제부터 해결한다.
!!! question "문제"
물건 $n$ 개와 용량이 $cap$인 배낭이 주어진다. $i$ 번째 물건의 무게는 $wgt[i-1]$, 가치는 $val[i-1]$이다. 각 물건을 한 번만 선택할 수 있을 때, 배낭 용량 제한 안에 담을 수 있는 물건의 최대 가치를 구하라.
아래 그림에서 물건 번호 $i$는 $1$부터, 배열 인덱스는 $0$부터 시작하므로 물건 $i$의 무게와 가치는 각각 $wgt[i-1]$, $val[i-1]$이다.
![0-1 배낭 문제 예시 데이터](knapsack_problem.assets/knapsack_example.png)
0-1 배낭 문제는 $n$ 차례의 결정으로 이루어진 과정으로 볼 수 있다. 각 물건에는 넣지 않기와 넣기라는 두 결정이 있으므로 의사 결정 트리 모형을 만족한다.
목표가 '배낭 용량 제한 안에 담을 수 있는 최대 가치'이므로 동적 계획법 문제일 가능성이 높다.
**1단계: 매 차례의 결정을 생각하고 상태를 정의하여 $dp$ 테이블을 얻는다**
물건을 넣지 않으면 배낭 용량은 그대로이고, 넣으면 용량이 줄어든다. 따라서 현재 물건 번호 $i$와 배낭 용량 $c$로 상태를 정의하고 $[i, c]$라고 한다.
상태 $[i, c]$의 부분 문제는 **앞의 $i$ 개 물건을 용량 $c$인 배낭에 담았을 때의 최대 가치**이며, 이를 $dp[i, c]$라고 한다.
구할 값은 $dp[n, cap]$이므로 $(n+1) \times (cap+1)$ 크기의 2차원 $dp$ 테이블이 필요하다.
**2단계: 최적 부분 구조를 찾아 상태 전이 식을 도출한다**
물건 $i$에 대한 결정을 내리면 앞의 $i-1$ 개 물건을 결정하는 부분 문제가 남는다. 다음 두 경우로 나뉜다.
- **물건 $i$를 넣지 않음**: 용량은 그대로이며 상태는 $[i-1, c]$로 바뀐다.
- **물건 $i$를 넣음**: 용량이 $wgt[i-1]$ 줄고 가치가 $val[i-1]$ 늘며, 상태는 $[i-1, c-wgt[i-1]]$로 바뀐다.
이 분석에서 최적 부분 구조가 드러난다. **최대 가치 $dp[i, c]$는 물건 $i$를 넣지 않는 경우와 물건 $i$를 넣는 경우 중 가치가 더 큰 쪽이다**. 상태 전이 식은 다음과 같다.
$$
dp[i, c] = \max(dp[i-1, c], dp[i-1, c - wgt[i-1]] + val[i-1])
$$
현재 물건의 무게 $wgt[i - 1]$가 남은 용량 $c$를 넘으면 넣지 않는 선택만 가능하다.
**3단계: 경계 조건과 상태 전이 순서를 정한다**
물건이 없거나 배낭 용량이 $0$이면 최대 가치는 $0$이다. 즉, 첫 열 $dp[i, 0]$과 첫 행 $dp[0, c]$는 모두 $0$이다.
현재 상태 $[i, c]$는 바로 위의 $[i-1, c]$와 왼쪽 위의 $[i-1, c-wgt[i-1]]$에서 전이되므로, 이중 반복문으로 전체 $dp$ 테이블을 정방향으로 순회하면 된다.
이 분석을 바탕으로 완전 탐색, 메모이제이션을 이용한 탐색, 동적 계획법 순서로 구현한다.
### 방법 1: 완전 탐색
탐색 코드에는 다음 요소가 포함된다.
- **재귀 매개변수**: 상태 $[i, c]$.
- **반환값**: 부분 문제의 해 $dp[i, c]$.
- **종료 조건**: 물건 번호가 범위를 벗어나 $i = 0$이 되거나 남은 용량이 $0$이면 재귀를 종료하고 가치 $0$을 반환한다.
- **가지치기**: 현재 물건의 무게가 남은 배낭 용량보다 크면 넣지 않는 선택만 할 수 있다.
```src
[file]{knapsack}-[class]{}-[func]{knapsack_dfs}
```
아래 그림과 같이 물건마다 선택하지 않기와 선택하기라는 두 탐색 가지가 생기므로 시간 복잡도는 $O(2^n)$이다.
재귀 트리에는 $dp[1, 10]$ 같은 중복 부분 문제가 있음을 쉽게 알 수 있다. 물건이 많고 용량이 크며, 특히 무게가 같은 물건이 많을수록 중복 부분 문제의 수가 크게 늘어난다.
![0-1 배낭 문제의 완전 탐색 재귀 트리](knapsack_problem.assets/knapsack_dfs.png)
### 방법 2: 메모이제이션을 이용한 탐색
중복 부분 문제를 한 번만 계산하도록 메모이제이션 리스트 `mem`에 부분 문제의 해를 저장한다. `mem[i][c]`는 $dp[i, c]$에 대응한다.
메모이제이션을 도입하면 **시간 복잡도는 부분 문제의 수에 의해 결정되므로** $O(n \times cap)$이다. 구현 코드는 다음과 같다.
```src
[file]{knapsack}-[class]{}-[func]{knapsack_dfs_mem}
```
아래 그림은 메모이제이션을 이용한 탐색에서 잘라 낸 가지들을 보여 준다.
![0-1 배낭 문제의 메모이제이션 탐색 재귀 트리](knapsack_problem.assets/knapsack_dfs_mem.png)
### 방법 3: 동적 계획법
동적 계획법은 본질적으로 상태 전이를 통해 $dp$ 테이블을 채우는 과정이다. 코드는 다음과 같다.
```src
[file]{knapsack}-[class]{}-[func]{knapsack_dp}
```
아래 그림과 같이 시간 복잡도와 공간 복잡도는 모두 배열 `dp`의 크기인 $O(n \times cap)$이다.
=== "<1>"
![0-1 배낭 문제의 동적 계획법 과정](knapsack_problem.assets/knapsack_dp_step1.png)
=== "<2>"
![0-1 배낭 동적 계획법 2단계](knapsack_problem.assets/knapsack_dp_step2.png)
=== "<3>"
![0-1 배낭 동적 계획법 3단계](knapsack_problem.assets/knapsack_dp_step3.png)
=== "<4>"
![0-1 배낭 동적 계획법 4단계](knapsack_problem.assets/knapsack_dp_step4.png)
=== "<5>"
![0-1 배낭 동적 계획법 5단계](knapsack_problem.assets/knapsack_dp_step5.png)
=== "<6>"
![0-1 배낭 동적 계획법 6단계](knapsack_problem.assets/knapsack_dp_step6.png)
=== "<7>"
![0-1 배낭 동적 계획법 7단계](knapsack_problem.assets/knapsack_dp_step7.png)
=== "<8>"
![0-1 배낭 동적 계획법 8단계](knapsack_problem.assets/knapsack_dp_step8.png)
=== "<9>"
![0-1 배낭 동적 계획법 9단계](knapsack_problem.assets/knapsack_dp_step9.png)
=== "<10>"
![0-1 배낭 동적 계획법 10단계](knapsack_problem.assets/knapsack_dp_step10.png)
=== "<11>"
![0-1 배낭 동적 계획법 11단계](knapsack_problem.assets/knapsack_dp_step11.png)
=== "<12>"
![0-1 배낭 동적 계획법 12단계](knapsack_problem.assets/knapsack_dp_step12.png)
=== "<13>"
![0-1 배낭 동적 계획법 13단계](knapsack_problem.assets/knapsack_dp_step13.png)
=== "<14>"
![0-1 배낭 동적 계획법 14단계](knapsack_problem.assets/knapsack_dp_step14.png)
### 공간 최적화
각 상태는 바로 윗행의 상태에만 의존하므로 두 배열을 번갈아 갱신하면 공간 복잡도를 $O(n \times cap)$에서 $O(cap)$으로 줄일 수 있다.
배열 하나만으로도 가능할까? 각 상태는 바로 위 또는 왼쪽 위 칸에서 전이된다. 배열 하나를 쓴다면 $i$ 번째 행의 순회를 시작할 때 그 배열에는 아직 $i-1$ 번째 행의 상태가 저장되어 있다.
- 정방향으로 순회하면 $dp[i, j]$에 도달했을 때 왼쪽 위 $dp[i-1, 1]$ ~ $dp[i-1, j-1]$의 값이 이미 덮어써졌을 수 있어 올바른 상태 전이를 할 수 없다.
- 역방향으로 순회하면 값이 덮어써지는 문제가 없어 상태 전이를 올바르게 수행할 수 있다.
아래 그림은 배열 하나로 $i = 1$ 행에서 $i = 2$ 행으로 바꾸는 과정이다. 정방향과 역방향 순회의 차이를 생각해 보자.
=== "<1>"
![공간 최적화 후 0-1 배낭의 동적 계획법 과정](knapsack_problem.assets/knapsack_dp_comp_step1.png)
=== "<2>"
![공간 최적화된 0-1 배낭 2단계](knapsack_problem.assets/knapsack_dp_comp_step2.png)
=== "<3>"
![공간 최적화된 0-1 배낭 3단계](knapsack_problem.assets/knapsack_dp_comp_step3.png)
=== "<4>"
![공간 최적화된 0-1 배낭 4단계](knapsack_problem.assets/knapsack_dp_comp_step4.png)
=== "<5>"
![공간 최적화된 0-1 배낭 5단계](knapsack_problem.assets/knapsack_dp_comp_step5.png)
=== "<6>"
![공간 최적화된 0-1 배낭 6단계](knapsack_problem.assets/knapsack_dp_comp_step6.png)
구현에서는 배열 `dp`의 첫 번째 차원 $i$를 없애고 안쪽 반복문을 역방향 순회로 바꾸기만 하면 된다.
```src
[file]{knapsack}-[class]{}-[func]{knapsack_dp_comp}
```