Back to Blog
배낭 문제0-1 KnapsackFractional KnapsackDP그리디

0x06. 배낭 문제 (Knapsack Problem)

0-1 배낭 문제(DP)와 분할 가능 배낭 문제(Greedy)를 비교 분석한다.

배낭 문제란?

배낭 문제(Knapsack Problem)는 제한된 용량의 배낭에 가치가 서로 다른 물건들을 어떻게 담아야 최대 가치를 얻을 수 있는가를 묻는 최적화 문제다. 이사할 때 트렁크 용량이 한정되어 있는데, 짐마다 중요도가 다르다면 무엇부터 넣어야 할지 고민하는 상황과 같다.

배낭 문제는 크게 두 가지 버전으로 나뉜다.

1. 0-1 Knapsack Problem

  • 물건을 통째로 넣거나 빼거나 둘 중 하나다. 쪼갤 수 없다.
  • Dynamic Programming(DP) 으로 풀어야 하며, Greedy로는 최적해를 보장할 수 없다.

2. Fractional Knapsack Problem

  • 물건을 원하는 만큼 쪼개서 넣을 수 있다.
  • 각 물건의 단위 무게당 가치(value per weight, vi/wiv_i / w_i) 를 계산한 뒤, 가치가 높은 것부터 최대한 많이 담는 Greedy 전략으로 최적해를 구할 수 있다.

두 문제 모두 최적 부분 구조(Optimal Substructure) 를 가진다. 하지만 탐욕 선택 속성(Greedy Choice Property) 은 Fractional Knapsack에만 성립하기 때문에, 0-1 Knapsack에는 Greedy를 적용할 수 없다.


0-1 Knapsack Problem

0-1 Knapsack은 물건을 쪼갤 수 없으므로 각 물건에 대해 "넣는다 / 넣지 않는다"의 이진 선택만 가능하다. 이 문제를 해결하는 네 가지 접근법을 살펴본다.

Brute Force

가장 직관적인 방법은 모든 조합을 시도하는 것이다. n개의 물건이 있으면 각각을 넣거나 빼는 2n2^n가지 조합이 존재한다. 이 중에서 총 무게가 W 이하이면서 총 가치가 최대인 조합을 찾으면 된다.

당연히 시간 복잡도는 Θ(2n)\Theta(2^n) 으로, 물건의 수가 조금만 늘어나도 현실적으로 사용할 수 없다.

Greedy

Greedy 전략은 0-1 Knapsack에서는 최적해를 보장하지 못한다. 단위 가치가 높은 물건부터 담는다고 해서 반드시 전체 최적해가 되지는 않기 때문이다.

Dynamic Programming

0-1 Knapsack의 실질적인 해법은 DP(Dynamic Programming) 다. DP에서 가장 중요한 것은 부분 문제(Subproblem)를 올바르게 정의하는 것이다.

부분 문제 정의: 첫 번째 시도

물건에 1부터 n까지 번호를 매기고, SkS_k = {물건 1, 2, ..., k}로 정의해 보자. 이는 가장 단순한 부분 문제 정의이긴 하지만, 최종 해 SnS_n을 부분 문제 SkS_k로 표현하려고 하면 문제가 생긴다.

위 그림에서 볼 수 있듯이, 물건 집합만으로는 부분 문제 간의 관계를 제대로 기술할 수 없다. 이 정의에는 결함(flaw) 이 있으므로 새로운 정의가 필요하다.

부분 문제 정의: 매개변수 추가

해결책은 무게(w) 라는 매개변수를 추가하는 것이다. 부분 문제를 B[k, w] 로 정의하면, "물건 1~k 중에서 총 무게가 w 이하가 되도록 골랐을 때의 최대 가치"를 나타낸다.

SkS_k의 최적 부분 집합이 총 무게 W를 가질 때, 물건 k를 포함하거나 포함하지 않거나 둘 중 하나다.

  • Case 1: wkw_k > W 이면, 물건 k는 배낭에 들어갈 수 없다. 따라서 B[k, w] = B[k-1, w]가 된다.
  • Case 2: wkw_k ≤ W 이면, 물건 k를 넣을 수도 있다. 넣는 경우와 넣지 않는 경우 중 더 큰 가치를 선택한다.

이를 의사 코드로 표현하면 다음과 같다.

for w = 0 to W
    B[0, w] = 0         // 물건이 0개면 가치도 0

for i = 1 to n
    B[i, 0] = 0         // 무게 한도가 0이면 아무것도 못 넣음
    for w = 1 to W
        if w[i] <= w     // 물건 i를 넣을 수 있는 경우
            if b[i] + B[i-1, w - w[i]] > B[i-1, w]
                B[i, w] = b[i] + B[i-1, w - w[i]]
            else
                B[i, w] = B[i-1, w]
        else
            B[i, w] = B[i-1, w]   // w[i] > w, 넣을 수 없음

시간 복잡도는 O(nW) 다. n은 물건의 수, W는 배낭의 용량이다. 주의할 점은 이것이 의사 다항(Pseudo-polynomial) 시간이라는 것이다. W의 값 자체에 비례하므로 W가 매우 크면 비효율적일 수 있다.

Branch and Bound는 탐색 트리에서 모든 노드를 방문하지 않고, 유망하지 않은 가지를 가지치기(pruning)하여 효율적으로 최적해를 찾는 방법이다.

전제 조건

물건들을 단위 무게당 가치(bi/wib_i / w_i) 기준 내림차순으로 정렬한다.

노드의 지역 변수

각 노드에서 세 가지 지역(local) 값을 계산한다.

  • benefit: 현재 노드까지 담은 물건들의 가치 합
  • weight: 현재 노드까지 담은 물건들의 무게 합
  • bound: 이 노드에서 더 확장했을 때 얻을 수 있는 가치의 상한(upper bound). 미래를 예측하는 값이다.

Bound 계산

bound는 현재까지의 benefit에, 남은 용량을 단위 가치가 높은 물건들로 (분할해서) 채웠을 때의 가치를 더한 값이다. tot_weight = weight(이미 담은 총 무게) + 앞으로 담을 무게이며, 이때 tot_weight ≤ W를 만족해야 한다.

전역 변수: max_benefit

지역 변수 외에 하나의 전역(global) 변수인 max_benefit이 필요하다. 이는 지금까지 찾은 최적 해의 가치를 나타낸다.

  • 초기값: max_benefit = 0
  • weight < W이면, max_benefit = max(max_benefit, benefit)으로 갱신한다.

유망성 판단 (Promising vs. Non-promising)

지역 변수 bound와 전역 변수 max_benefit의 관계로 노드의 유망성을 판단한다.

  • Non-promising (가지치기 대상):
    • bound ≤ max_benefit 또는 weight > W
  • Promising (확장 대상):
    • bound > max_benefit 그리고 weight ≤ W

Non-promising 노드가 발생하는 두 가지 경우를 좀 더 자세히 보면 다음과 같다.

Case 1: bound ≤ max_benefit

이 노드에서 계산된 benefit 자체는 유효하다. 다만 이 노드 이후를 더 탐색해도 현재 최적해보다 나은 결과를 얻을 수 없으므로, 더 이상 확장할 필요가 없다.

Case 2: weight > W

배낭 용량을 초과했으므로 이 노드의 benefit은 유효하지 않다. max_benefit도 갱신되지 않으며, 당연히 확장도 하지 않는다.

탐색 전략

기본적으로 BFS(너비 우선 탐색) 를 적용하되, 유망한(promising) 노드 중에서 bound가 가장 큰 노드를 우선적으로 확장한다. 이것이 Best-First Search 전략이다.

예제

다음은 Branch and Bound를 단계별로 적용한 예제다.


연습 문제

1. Knapsack Greedy의 수행 시간

Fractional Knapsack에서 Greedy 알고리즘의 수행 시간은 Θ(nlgn)\Theta(n \lg n) 이다.

  • 단위 가치 기준으로 정렬: Θ(nlgn)\Theta(n \lg n)
  • 정렬된 물건을 1부터 n-1까지 순회하며 담기: Θ(n)\Theta(n)

정렬이 지배적이므로 전체 시간 복잡도는 Θ(nlgn)\Theta(n \lg n)이다. 참고로, Θ(nlgn)\Theta(n \lg n)을 보장하는 정렬 알고리즘은 Merge Sort와 Heap Sort뿐이다.

2. Knapsack DP 예제

조건: n = 4, W = 4, 물건들의 (무게, 가치) = (2, 3), (3, 4), (4, 5), (5, 6)

참고로, DP는 정렬이 필요 없지만, Greedy는 단위 가치 기준 정렬이 반드시 필요하다.

3. Knapsack Branch and Bound 예제

배낭 용량(Total weight) = 10

ibib_iwiw_ibi/wib_i / w_i
130215
260512
370710
42045

위 표는 bi/wib_i / w_i 기준 내림차순으로 정렬된 상태다.


HGU 전산전자공학부 용환기 교수님의 23-1 알고리듬 분석 수업을 듣고 작성한 포스트이며, 첨부한 모든 사진은 교수님 수업 PPT의 사진 원본에 필기를 한 수정본입니다.