배낭 문제¶
선행 지식: 동적 계획법 입문
소개¶
다음 예제를 살펴봅시다.
[USACO07 Dec] Charm Bracelet¶
서로 다른 물건 $n$개와 용량이 $W$인 배낭이 있습니다. 각 물건에는 무게($w_{i}$)와 가치($v_{i}$)라는 2가지 속성이 있습니다. 총 무게가 용량 $W$를 넘지 않으면서 총 가치가 최대가 되도록 배낭에 넣을 물건의 부분집합을 골라야 합니다.
위 예제에서 각 물건이 가질 수 있는 상태는 두 가지(넣음 또는 넣지 않음)뿐이며, 이는 이진수 0과 1에 대응합니다. 따라서 이러한 유형의 문제를 "0-1 배낭 문제"라고 부릅니다.
0-1 배낭¶
설명¶
위 예제에서 문제의 입력은 다음과 같습니다. $i^{th}$ 물건의 무게 $w_{i}$, $i^{th}$ 물건의 가치 $v_{i}$, 그리고 배낭의 총 용량 $W$입니다.
앞의 $i$개 물건만 고려할 때 용량이 $j$인 배낭에 담을 수 있는 최대 총 가치를 저장하는 동적 계획법 상태를 $f_{i, j}$라고 합시다.
앞의 $i-1$개 물건에 대한 모든 상태가 이미 처리되었다고 가정하면, $i^{th}$ 물건에 대해서는 어떤 선택지가 있습니까?
- 배낭에 넣지 않는 경우, 남은 용량은 그대로이고 총 가치도 변하지 않습니다. 따라서 이 경우의 최댓값은 $f_{i-1, j}$입니다.
- 배낭에 넣는 경우, 남은 용량은 $w_{i}$만큼 줄어들고 총 가치는 $v_{i}$만큼 늘어나므로, 이 경우의 최댓값은 $f_{i-1, j-w_i} + v_i$입니다.
이로부터 dp 전이 방정식을 유도할 수 있습니다:
또한 $f_{i}$는 $f_{i-1}$에만 의존하므로 첫 번째 차원을 제거할 수 있습니다. 그러면 다음 전이 규칙을 얻습니다.
이 규칙은 $j$의 내림차순으로 실행해야 합니다(그래야 $f_{j-w_i}$가 $f_{i,j-w_i}$가 아니라 $f_{i-1,j-w_i}$에 암묵적으로 대응합니다).
배낭 문제의 전이 대부분이 이와 비슷한 방식으로 유도되므로, 이 전이 규칙을 이해하는 것이 중요합니다.
구현¶
앞서 설명한 알고리즘은 다음과 같이 $O(nW)$에 구현할 수 있습니다.
for (int i = 1; i <= n; i++)
for (int j = W; j >= w[i]; j--)
f[j] = max(f[j], f[j - w[i]] + v[i]);
다시 한번 실행 순서에 유의하십시오. 다음 불변식을 보장하려면 이 순서를 엄격히 지켜야 합니다. 쌍 $(i, j)$를 처리하기 직전에, $k > j$이면 $f_k$는 $f_{i,k}$에 대응하고, $k < j$이면 $f_{i-1,k}$에 대응합니다. 이로써 $f_{j-w_i}$가 $i$번째 단계가 아니라 $(i-1)$번째 단계에서 가져온 값임이 보장됩니다.
완전 배낭¶
완전 배낭 모델은 0-1 배낭과 비슷하며, 0-1 배낭과 유일하게 다른 점은 물건을 한 번만이 아니라 횟수 제한 없이 선택할 수 있다는 것입니다.
0-1 배낭의 아이디어를 참고하여 상태를 정의할 수 있습니다. $f_{i, j}$는 최대 용량이 $j$일 때 앞의 $i$개 물건을 사용하여 배낭이 얻을 수 있는 최대 가치입니다.
상태 정의는 0-1 배낭과 비슷하지만, 전이 규칙은 0-1 배낭과 다르다는 점에 유의해야 합니다.
설명¶
가장 단순한 접근은 처음 $i$개의 물건에 대해 각 물건을 몇 번 담을지를 모두 열거하는 것입니다. 이로부터 다음과 같은 전이 방정식을 얻습니다:
앞에서 설명한 알고리즘은 다음과 같이 $O(nW^2)$에 구현할 수 있습니다.
for (int i = 1; i <= n; i++)
for (int j = 0; j <= W; j++)
for (int k = 0; k * w[i] <= j; k++)
f[i][j] = max(f[i][j], f[i-1][j - k * w[i]] + k * v[i]);
동시에, 이는 "평평한" 방정식으로 단순화됩니다:
이것이 성립하는 이유는 $f_{i, j-w_i}$가 이미 $f_{i, j-2\cdot w_i}$ 등에 의해 차례로 갱신되어 있기 때문입니다.
0-1 배낭 문제와 마찬가지로 첫 번째 차원을 제거하여 공간 복잡도를 최적화할 수 있습니다. 그러면 0-1 배낭 문제와 같은 전이 규칙을 얻습니다.
규칙은 동일하지만 이제는 $j$의 증가하는 순서로 실행해야 하며, 이 형태에서 두 문제의 차이는 이것뿐입니다. 위로 올라가며 진행하면 $f_{j-w_i}$는 이미 물건 $i$에 대해 갱신되어 있으므로 $f_{i,j-w_i}$에 해당하고, 따라서 그 물건을 다시 담을 수 있습니다. 반면 아래로 내려가며 진행하면 여전히 $f_{i-1,j-w_i}$에 해당하므로 각 물건은 최대 한 번만 담을 수 있습니다.
구현¶
앞에서 설명한 알고리즘은 다음과 같이 $O(nW)$에 구현할 수 있습니다.
for (int i = 1; i <= n; i++)
for (int j = w[i]; j <= W; j++)
f[j] = max(f[j], f[j - w[i]] + v[i]);
전이 규칙이 같음에도 불구하고 위의 코드는 0-1 배낭 문제에 대해서는 올바르지 않습니다.
코드를 주의 깊게 살펴보면, 현재 처리 중인 물건 $i$와 현재 상태 $f_{i,j}$에 대해 $j\geqslant w_{i}$일 때 $f_{i,j}$가 $f_{i,j-w_{i}}$의 영향을 받는다는 것을 알 수 있습니다. 이는 물건 $i$를 배낭에 여러 번 넣을 수 있는 것과 같으며, 0-1 배낭 문제가 아니라 완전 배낭 문제와 일치합니다.
다중 배낭 문제¶
다중 배낭 문제 역시 0-1 배낭 문제의 변형입니다. 주요한 차이점은 각 물건이 $1$개가 아니라 $k_i$개씩 있다는 것입니다.
설명¶
매우 간단한 아이디어는 "각 물건을 $k_i$번 선택하는 것"이 "같은 물건 $k_i$개를 하나씩 선택하는 것"과 같다는 것입니다. 따라서 이를 0-1 배낭 모델로 변환할 수 있으며, 이는 다음 전이 함수로 기술할 수 있습니다.
이 과정의 시간 복잡도는 $O(W\sum\limits_{i=1}^{n}k_i)$입니다.
이진 그룹화 최적화¶
여전히 최적화를 위해 다중 배낭 모델을 0-1 배낭 모델로 변환하는 방법을 고려합니다. 시간 복잡도 $O(Wn)$은 위의 접근으로는 더 이상 최적화할 수 없으므로, $O(\sum k_i)$ 부분에 집중합니다.
$i^{th}$ 물건에서 분리된 $j^{th}$ 물건을 $A_{i, j}$라고 합시다. 앞에서 논의한 단순한 접근에서는 모든 $j \leq k_i$에 대해 $A_{i, j}$가 같은 물건을 나타냅니다. 효율이 낮은 주된 이유는 반복적인 작업을 많이 하고 있기 때문입니다. 예를 들어 $\{A_{i, 1},A_{i, 2}\}$를 선택하는 경우와 $\{A_{i, 2}, A_{i, 3}\}$를 선택하는 경우를 생각해 봅시다. 이 두 경우는 완전히 동등합니다. 따라서 분리 방법을 최적화하면 시간 복잡도를 크게 줄일 수 있습니다.
이진 그룹화를 사용하면 그룹화를 더 효율적으로 할 수 있습니다.
구체적으로, $A_{i, j}$는 개별 물건 $2^j$개를 담습니다($j\in[0,\lfloor \log_2(k_i+1)\rfloor-1]$). $k_i + 1$이 $2$의 정수 거듭제곱이 아니라면, 크기가 $k_i-(2^{\lfloor \log_2(k_i+1)\rfloor}-1)$인 묶음을 하나 더 사용하여 나머지를 채웁니다.
위의 분할 방법을 사용하면 몇 개의 $A_{i, j}$를 선택하여 $\leq k_i$인 어떤 개수의 물건이든 만들 수 있습니다. 각 물건을 설명한 방식대로 분할한 후에는 0-1 배낭 방법으로 새롭게 정식화된 문제를 풀기만 하면 됩니다.
이 최적화를 통해 $O(W\sum\limits_{i=1}^{n}\log k_i)$의 시간 복잡도를 얻습니다.
구현¶
index = 0;
for (int i = 1; i <= n; i++) {
int c = 1, p, h, k;
cin >> p >> h >> k;
while (k > c) {
k -= c;
list[++index].w = c * p;
list[index].v = c * h;
c *= 2;
}
list[++index].w = p * k;
list[index].v = h * k;
}
단조 큐 최적화¶
이 최적화에서는 배낭 문제를 최대 큐 문제로 변환하는 것을 목표로 합니다.
설명의 편의를 위해 $g_{x, y} = f_{i, x \cdot w_i + y} ,\space g'_{x, y} = f_{i-1, x \cdot w_i + y}$라고 둡시다. 그러면 전이 규칙은 다음과 같이 쓸 수 있습니다:
나아가 $G_{x, y} = g'_{x, y} - v_i \cdot x$라고 둡시다. 그러면 전이 규칙은 다음과 같이 표현할 수 있습니다:
이는 전형적인 단조 큐 최적화 형태가 됩니다. $G_{x, y}$는 $O(1)$에 계산할 수 있으므로, 고정된 $y$에 대해 $g_{x, y}$를 $O(\lfloor \frac{W}{w_i} \rfloor)$ 시간에 계산할 수 있습니다. 따라서 모든 $g_{x, y}$를 구하는 복잡도는 $O(\lfloor \frac{W}{w_i} \rfloor) \times O(w_i) = O(W)$입니다. 이렇게 하여 알고리즘의 전체 복잡도는 $O(nW)$로 줄어듭니다.
혼합 배낭¶
혼합 배낭 문제는 위에서 설명한 세 가지 문제가 결합된 것입니다. 즉, 어떤 물건은 한 번만 가져갈 수 있고, 어떤 물건은 무한히 가져갈 수 있으며, 어떤 물건은 최대 $k$번까지 가져갈 수 있습니다.
이 문제는 어려워 보일 수 있지만, 앞선 배낭 문제들의 핵심 아이디어를 이해하고 이를 결합하기만 하면 풀 수 있습니다. 풀이의 의사 코드는 다음과 같습니다:
for (each item) {
if (0-1 knapsack)
Apply 0-1 knapsack code;
else if (complete knapsack)
Apply the complete knapsack code;
else if (multiple knapsack)
Apply multiple knapsack code;
}