본문 바로가기
알고리즘/NP-hard

Subset sum problem

by oculis 2023. 3. 31.
728x90

개요

NP-complete
집합 내 원소의 합으로 S를 구성하는 방법의 수 구하기

P, NP문제는 너무 어려워서 이해하는 데에도 시간이 꽤 오래걸렸다. 막 작성해보는 중인데 NP-hard, NP-complete나 "deterministic"에 대한 이해가 어려워서 일단 SSP부터 정리하고 가려고 한다.


접근

SSP는 NP-complete 문제이다. 다항 시간 내에 검증이 가능하고, 다항시간 내에 해결이 불가능하다. SSP의 해결방법은 크게 세 가지를 소개하려 하는데, Bruteforce, Meet in the middle, Knapsack이다.

Bruteforce Meet in the middle Knapsack
$O(2^n)$ $O(2\times 2^{n\over 2})$ $O(nS)$

1. Bruteforce

  1. 모든 경우를 다 따지는 방법. 어떤 원소를 선택/미선택 하는 스위치를 N개 가지고, 켠 경우와 끈 경우를 모든 스위치에 대해 따져준다. 아래 코드를 보자.
     void dp(int i,int k) {
         if (i==n) {
             if (k==S) ans++;
             return;
         }
         dp(i+1,k);
         dp(i+1,k+a[i]);
     }
    만약 끝까지 왔을 때 합이 목표하는 값이라면 count를 1 추가해주고, 끝이 아니라면 합에 ai를 포함하는 경우와 포함하지 않는 경우를 다음 단계로 넘겨준다.
  2. 따로 배열을 사용하지는 않으므로 공간은 많이 안먹지만, $O(2^n)$만큼의 시간이 필요하니 n이 20이 넘어가면서부터는 거의 사용할 수 없다. 그래서 Knapsack을 사용하게 되는데, knapsack이 문제가 되는 상황이 생기므로 다음 방법인 Meet in the middle을 사용하게 된다.

S2 1182 부분수열의 합 : 풀이


2. Meet in the middle

  1. 중간에서 만나기, 개인적으로는 이분-브루트포스가 더 적합한 이름인 것 같다. 조금 특이한 알고리즘인데, 핵심은 배열을 반으로 나누고 브루트포스를 하는 것이다. 아래 코드를 보자.

     // left
     void dp1(int i,int k) {
         if (i==n/2) {
             m1[k]++;
             return;
         }
         dp1(i+1,k);
         dp1(i+1,k+a[i]);
     }
    
     // right
     void dp2(int i,int k) {
         if (i==n) {
             ans+=m1[S-k];
             return;
         }
         dp2(i+1,k);
         dp2(i+1,k+a[i]);
     }

    배열을 반으로 나누고 map을 이용해 왼쪽 배열의 "합이 k인 선택의 개수"를 모두 저장한다. 그리고 오른쪽 배열을 돌면서 왼쪽의 "전체 합 - 오른쪽 절반의 합" 의 개수를 정답에 더한다. 이게 뭔말인고 하니,

  2. 우리가 구하고자 하는 값 S는 배열을 절반으로 나누었을 때 왼쪽 선택의 합 L + 오른쪽 선택의 합 R과 같다. 위에서 N개의 스위치를 사용하는 것이라 했는데, 어차피 전체 N개의 스위치는 왼쪽의 스위치 N/2개와 오른쪽 스위치 N/2개의 상태를 하나로 표현한 것이므로, 왼쪽/오른쪽의 스위치를 순서대로 다루어보자는 것이다.

  3. 즉 S = L+R 이므로, S를 바로 구하기보다 L과 S-R을 찾아보는 방법이다. L을 저장하는 건 $O(2^{n\over 2})$, S-R을 찾는 것도 동일하니까 전체 시간복잡도가 $O(2\times 2^{n\over 2})$가 된다. 두 함수를 아래와 같이 하나로 줄여줄 수도 있다.

     void dp(int i,int k,int e) {
         if (i==e) {
             if (e==n) ans+=m[S-k];
             else m[k]++;
             return;
         }
         dp(i+1,k,e);
         dp(i+1,k+a[i],e);
     }

G4 2295 세 수의 합 : 풀이
G2 7453 합이 0인 네 정수 : 풀이
G1 1208 부분수열의 합 2 : 풀이
P3 1044 팀 선발 : 풀이


3. Knapsack

  1. 마지막으로 배낭문제를 이용하는 방법이다. 배낭문제는 dp의 꽃이라 생각하는데, N개의 원소의 합으로 S를 구성하는 개수를 구하는 알고리즘이다. 이때는 앞선 생각대로 선택/미선택 으로 스위치 돌리면 되는것 아님? 하고 생각할 수 있는데, 선택 여부가 아닌 합을 중심으로 생각하면 아래와 같은 접근이 가능하다.

     int dp[n+1][S+1];
     int get(int n,int S) {
         dp[0][0]=1;
         for (int i=1;i<=n;i++)
             for (int k=0;k<=S;k++) {
                 dp[i][k]=dp[i-1][k];
                 if (k>=a[i])
                     dp[i][k]+=dp[i-1][k-a[i]];
             }
         return dp[n][S];
     }

    Knapsack을 알고 있다면 익숙할텐데, 2차원 배열에서 끝이 i이고, 합이 k인 경우를 저장하는 dp배열을 만든다. 그럼 끝이 i이고 합이 k인 경우는 앞의 것 중 합이 k인 것 + 앞의 것 중 합이 k-a[i]인 것이 된다. k-a[i]에 a[i]를 더해버리면 k가 되기 때문이고, 그게 끝이 i이면서 k를 구성할 전체 경우이기 때문이다.

    이 코드는 메모리를 아끼기 위해 아래와 같이 바꿔줄 수도 있다.

     int dp[S+1],h[S+1];
     int get(int n,int S) {
         h[0]=1;
         for (int i=1;i<=n;i++) {
             for (int k=0;k<=S;k++) {
                 dp[k]=h[k];
                 if (k>=a[i])
                     dp[k]+=h[k-a[i]];
             }
             for (int k=0;k<=S;k++)
                 h[k]=dp[k];
         }
         return dp[S];
     }

    어차피 함수 내에서 dp 배열의 두 행만 사용하므로 배열을 N×S가 아닌 2×S로 바꿔준 것이다.

  2. 시간복잡도를 따져보면 $O(nS)$가 된다. 엥? 다항시간 아니냐? P인데? 하는 생각이 드는데, 모양이 다항함수 같아도 사실 S의 범위가 무궁무진하기 때문에 다항/지수 함수 모두 가능하다. S는 모든 원소의 총합인데, 이건 앞의 Bruteforce와 달리 n과 관계없는 값이다.

  3. 예를 들어 n=10이고, a의 원소가 모두 1e9라고 해보자. 그럼 O(nS)는 1e11이 된다. 이때 Meet in the middle을 사용했다면? 시간복잡도는 2^10=1024가 된다. S를 일반화해서 2^n쯤으로 잡아버리면? 즉, 모든 원소가 대략 (2^n)/n 쯤 된다면? 똑같이 knapsack도 non-polynomial하다.

  4. 또 애초에 원소가 음수인 경우는 배열을 생성할 수도 없다. 1182 같은 문제가 그렇다.

G4 16132 그룹 나누기 (Subset) : 풀이


참조

Wikipedia-Subset sum problem

728x90

댓글