ABOUT ME

Today
Yesterday
Total
  • imos-Hanbyeol Trick and its applications
    알고리즘 2023. 6. 11. 15:55

     

    (2023.06.11 online IHT with linear memory 문단 추가)

     

    이 글에서는 imos법을 확장한 imos-Hanbyeol Trick (IHT)의 개념과 응용에 대해 설명한다.

    귀여운 imos쨩의 그림이다.

    imos법

    imos법이란 구간 덧셈 업데이트를 빠르게 처리하는 테크닉이다. 다음과 같은 문제를 생각해보자.

     

    0으로 초기화된 N×N 크기의 2차원 배열 A가 주어진다.  다음 연산을 Q개 처리한 후 배열을 출력하여라.

    x1 y1 x2 y2 z: A[x1:x2][y1:y2]z를 더한다. (앞으로 모든 : notation은 닫힌 구간이다.)

     

    나이브하게 했을 때 시간복잡도는 최악의 경우 O(QN2)이지만, imos법을 사용하면 O(Q+N2)에 처리할 수 있다. 이에 대한 자료는 인터넷에 많이 있기 때문에 자세히 설명하지는 않겠다. 요약하면 A에 2차원 누적합을 적용했을 때 답이 나오도록 적절히 값을 더하고 빼면 O(Q)번의 덧셈으로 업데이트에 대한 정보를 모두 저장할 수 있다.

     

    imos법의 강력한 점은 단순히 1차원, 2차원 뿐만 아니라 임의의 차원에 적용할 수 있다는 점이다. imos-Hanbyeol Trick은 이 사실을 최대한 활용하는 트릭이다.

     

    더 귀여운 imos쨩 그림이다.

    imos-Hanbyeol Trick

    다음과 같은 문제를 생각해보자.

     

    0으로 초기화된 2×2××2 크기의 N차원 배열 A가 주어진다.  다음 연산을 Q개 처리한 후 배열을 출력하여라.

    x1 x2 xN z: A[x1:1][x2:1][xN:1]z를 더한다.

     

    이 문제 역시 나이브하게 하면 O(Q2N)이지만 imos법을 잘 활용하면 (Q+N2N)에 풀 수 있다. 이를 imos-Hanbyeol Trick이라고 정의한다.

     

    imos-Hanbyeol Trick을 이용한 풀이는 다음과 같다. 편의상 배열의 인덱스를 비트마스크로 관리하고 있다는 점에 유의하여라.

    vector<int> imos_hanbyeol_trick(int N, int Q, vector<pair<int, int>> query){
        vector<int> A(1<<N);
        for (int i=0;i<Q;i++){
            A[query[i].first] += query[i].second; // imos법 처럼 값을 더해준다.
        }
    
        for (int i=0;i<N;i++){ 
            for (int j=0;j<(1<<N);j++) if (j&(1<<i)){
                A[j] += A[j^(1<<i)] // imos-hanbyeol trick
            }
        }
        return A;
    }

    코드를 보면 뭔가 우리가 알던 imos법과 다르다는 느낌을 많이 받을 것이다. 코드가 정확히 어떻게 작동하는지 알아보자.

    놀란 한별이

    이중반복문을 돌기 전의 배열 AA라고 하자.

    i=0루프를 돌고 나면 A[s0]=A[s0], A[s1]=A[s0]+A[s1]이 된다. (s는 비트스트링이다.)

    i=1루프를 돌고 나면 A[s00]=A[s00], A[s01]=A[s00]+A[s01], A[s10]=A[s00]+A[s10], A[s11]=A[s00]+A[s01]+A[s10]+A[s11]이 된다.

     

    귀납적으로, i번째 루프까지 돌고 나면 A[msk]에는 msk의 하위 i개 비트가 submask고 상위비트는 모두 일치하는 모든 마스크에 대해, 해당되는 배열의 값을 더한 값이 저장됨을 알 수 있다. 식으로 적으면 다음과 같다.

    Ai[msk]=submskmsk,(msksubmsk)(2i1)A[submsk]

     

    식 정리를 해보면 식이 잘 맞아떨어진다는 것을 확인할 수 있다. 따라서, i=N1루프까지 돌고 나면, A에는 다음 값이 저장되어있다.

    A[msk]=submskmskA[submsk]

    msk z에 해당하는 업데이트는 msk을 submask로 가지는 모든 mskz를 더하는 업데이트이므로, 최종 상태의 A는 구하고자하는 A와 동일하다.

     

    놀라운 사실은, 우리가 지금까지 계산한 것이 imos법과 정확히 동일하다는 사실이다! 상상하기 힘들지만, N개의 축을 가진 N차원 공간의 초직육면체를 생각해보자. (N=3정도로 놓고 8개의 값이 어떻게 변하는지 생각해보면 좀 더 편하다.) i번째 루프를 도는 행위는 imos법에서 i번째 축으로 스위핑하면서 누적합을 해주는 것과 동일하다는 것을 알 수 있다. 즉, imos-Hanbyeol Trick은 N차원 imos법을 비트마스킹을 이용해서 간단하게 구현한 것이다.

     

    imos-Hanbyeol Trick을 다르게 표현하면 배열 A가 주어질 때 다음 배열 B를 빠르게 계산하는 것이다.

    B[msk]=submskmskA[submsk]

    havana723님이 그리신 귀여운 imos쨩과 한별이의 그림이다.

    Online imos-Hanbyeol Trick

    다음과 같은 점화식의 dp를 생각해보자.

    dp[msk]=f(submskmskdp[submsk])

    f는 상수시간에 계산할 수 있는 함수라고 가정하자. imos-Hanbyeol Trick은 쿼리/값을 모두 받고 한번에 처리하기 때문에 이처럼 online 꼴의 점화식을 계산할 수 없다는 한계점을 가지고 있다. 그러나, imos-Hanbyeol Trick을 돌릴 때 나오는 값들을 잘 활용하면 이를 계산하는 것이 가능하다.

     

    imos-Hanbyeol Trick을 증명할 때 사용한 식을 가져와보자. 앞에서 설명했듯이, 하위 i개 비트에 대한 submask들의 합이다.

    Si[msk]=submskmsk,(msksubmsk)(2i1)dp[submsk]

    이를 이용하면 dp[msk]를 다음과 같이 적을 수 있다.

    dp[msk]=f(2imsk0Si[msk2i])

    따라서, Si[msk]에 대한 정보가 있다면 dpO(N2N)에 구할 수 있다.

     

    한편, Si[msk]의 정의를 잘 생각해보면 Si[msk]에 대한 점화식도 쉽게 구할 수 있다.

    Si[msk]={Si1[msk](ifmsk2i=0)Si1[msk]+Si[msk2i](ifmsk2i0)

    편의상 S1[msk]=dp[msk]로 정의하면 편하다.

     

    이를 코드로 구현하면 다음과 같다. 시간복잡도는 O(N×2N)이다.

     

    vector<int> online_imos_hanbyeol_trick(int N){
        vector<int> dp(1<<N, 0);
        vector<vector<int>> S(1<<N, vector<int>(N, 0));
    
        for (int msk=0;msk<(1<<N);msk++){
            for (int i=0;i<N;i++) if (msk&(1<<i)){
                dp[msk] += S[msk^(1<<i)][i];
            }
            dp[msk] = f(dp[msk]);
    
            for (int i=0;i<N;i++){
                if (i) S[msk][i] = S[msk][i-1];
                else S[msk][i] = dp[msk];
    
                if (msk&(1<<i)) S[msk][i] += S[msk^(1<<i)][i];
            }
        }
        
        return dp;
    }

    Online imos-Hanbyeol Trick with linear memory

    이 파트는 sait2000님의 도움을 받아 작성되었습니다.

    웰노운 아니야

    Online imos-Hanbyeol Trick을 나이브하게 짜면 공간복잡도 O(N2N)이 되지만, 값 갱신을 적절하게 해주면 공간복잡도 O(2N)으로 짤 수 있다! 메모리를 대략 /20 해주기 때문에 메모리를 매우 아낄 수 있고, 시간도 꽤 줄어드는 꽤나 강력한 최적화라고 볼 수 있다. 일단 코드부터 보자.

    vector<int> online_imos_hanbyeol_trick_linear_memory(int N){
        vector<int> dp(1<<N, 0);
        vector<int> S(1<<N, 0);
    
        for (int msk=0;msk<(1<<N);msk++){
            for (int i=0;i<N;i++) if (msk&(1<<i)){
                dp[msk] += S[msk^(1<<i)];
            }
            dp[msk] = f(dp[msk]);
            S[msk] = dp[msk];
    
            for (int i=0;msk&(1<<i);i++){
                int r = msk + 1;
                int l = r - (1<<i);
                for (int msk2=l;msk2<r;msk2++){
                    S[msk2] += S[msk2 ^ (1<<i)];
                }
            }
        }
        
        return dp;
    }

    편의상 아까 정의했던 Si[msk]msk에 대한 크기 i의 subset sum이라고 부르자. 현재 for문에서 돌고 있는 msk에 대해, msk=2i1++2ik (단, i1>>ik)라고 하자. 즉, i1,,ikmsk의 비트 중 켜져있는 비트들이다. 이때, S에 저장된 값은 다음과 같다.

     

    S[0:2i11]: 크기 i1의 subset sum

    S[2i1:(2i1+2i21)]: 크기 i2의 subset sum

    ...

    S[(2i1++2ik1):(2i1++2ik1+2ik1)]: 크기 ik의 subset sum

     

    따라서, 현재 상태에서 S[msk2ij]=Sij[msk2ij]가 성립하게 되고, dp 점화식이 잘 계산된다.

     

    식만 보면 이해하기 힘드니 예시를 들어보자. 현재 msk=26=11010(2)라고 하면, S배열에는 다음과 같은 값들이 저장되어있다. (편의상 인덱스를 다섯자리 이진수로 적었다.)

    S[00000:01111]: 크기 4의 subset sum

    S[10000:10111]: 크기 3의 subset sum

    S[11000:11001]: 크기 1의 subset sum

     

    dp[11010]은 다음과 같이 계산된다.

    dp[11010]=f(S[01010]+S[10010]+S[11000])=f((dp[00000]+dp[00010]+dp[01000]+dp[01010])+(dp[10000]+dp[10010])+(dp[11000]))

     

    이제, 남은 부분은 S배열을 갱신하는 부분이다. for (int i=0;msk&(1<<i);i++)에 해당하는 반복문이 바로 그 파트이다. S배열은 인덱스 0부터 길이 2i1, 2i2, ... , 2ik의 "블록"을 이룬다는 점에 유의하자. msk를 1 늘릴 때 이 블록들이 어떻게 갱신되어야하는지 살펴볼 것이다.

     

    ik>0이면, 20짜리 블록을 추가하면 갱신이 끝나기 때문에 따로 해줄 것이 없다. (S[msk] = dp[msk])

    ik=0이면, 기존에 있던 20짜리 블록과 새로 추가된 20짜리 블록을 합쳐서 21짜리 블록으로 만들어줘야한다. 이때, 인덱스를 잘 생각해보면 앞에 있는 블록의 인덱스는 20에 해당하는 비트가 꺼져있고, 뒤에 있는 블록의 인덱스는 20에 해당하는 비트가 켜져있기 때문에, 뒤에 있는 블록의 값을 앞에 있는 블록의 값으로 갱신해주면 된다. 앞에 있는 블록은 20비트가 꺼져있기 때문에 크기를 늘려도 subset sum이 변하지 않는다. (S[msk2] += S[msk2 ^ (1<<i)])

     

    이렇게 블록을 1번 합쳐주고나서 끝날 수도 있지만, ik1=1이라면 21짜리 블록이 2개가 되기 때문에 이를 또 합쳐줘야한다. 이 과정은 여러 번 반복될 수 있다. 따라서, 블록을 합치는 과정을 i를 0부터 늘리면서 계속하다가, msk2i=0이 되는 순간 갱신을 하지 않고 중단해줘야 한다는 것을 알 수 있다.

    길이 2i짜리 블록 2개를 합치려고 한다면 앞에 있는 블록의 인덱스는 모두 2i에 해당하는 비트가 꺼져있고 뒤에 있는 블록은 모두 켜져있다는 사실을 이용해서 아까처럼 갱신해주면 된다. (S[msk2] += S[msk2 ^ (1<<i)])

     

    따라서, S를 갱신하는 부분만 코드로 적으면 다음과 같다.

            S[msk] = dp[msk];
    
            for (int i=0;msk&(1<<i);i++){
                int r = msk + 1;
                int l = r - (1<<i);
                for (int msk2=l;msk2<r;msk2++){
                    S[msk2] += S[msk2 ^ (1<<i)];
                }
            }

    각 원소에 대해 저장된 subset sum의 크기가 최대 N까지 갈 수 있고, 크기를 늘리는 것은 덧셈 0번 또는 1번에 되기 때문에 S배열을 갱신하는 과정은 시간복잡도 O(N2N)임을 알 수 있다. 따라서, Online IHT를 시간복잡도 O(N2N), 공간복잡도 O(2N)에 할 수 있다.

    OR Convolution using imos-Hanbyeol Trick (a.k.a. imos-Hanbyeol Gwabegigul)

    이 파트는 koosaga님의 글(https://codeforces.com/blog/entry/115438)을 참고하여 작성되었습니다.

    imos-한별 꽈배기굴의 개형도이다.

    배열 AB가 주어질 때, AB의 OR Convolution C는 다음과 같다.

    C[i]=jk=iA[j]B[k]

    나이브하게 계산하면 O(4N)의 시간이 걸리지만, imos-Hanbyeol Trick을 이용해서 O(N2N)에 계산할 수 있다.

     

    imos-Hanbyeol Trick을 이용하면 다음을 구할 수 있다.

    A[i]=ij=iA[j]

    B[i]=ij=iB[j]

    C[i]=A[i]B[i]

     

    이때, 다음이 성립함을 알 수 있다.

    C[i]=ijk=iA[j]B[k]=ip=iC[p]

    즉, CC에 imos-Hanbyeol Trick을 적용한 배열이다. 따라서, imos-Hanbyeol Trick을 거꾸로 해주면(Inverse imos-Hanbyeol Trick, IIHT) OR Convolution 결과를 얻을 수 있다.

     

    코드는 다음과 같다.

    vector<int> imos_hanbyeol_gwabegigul(int N, vector<int> A, vector<int> B){
        vector<int> C(A.size());
    
        // imos-hanbyeol trick
        for (int i = 0; i < N; i++) {
            for (int j = 0; j < (1 << N); j++) {
                if (j & (1<<i)) {
                    A[j] += A[j ^ (1 << i)];
                    B[j] += B[j ^ (1 << i)];
                }
            }
        }
    
        for (int i = 0; i < (1 << N); i++) {
            C[i] = A[i] * B[i];
        }
    
        // inverse imos-hanbyeol trick
        for (int i = N - 1; i >= 0; i--) {
            for (int j = (1 << N) - 1; j >= 0; j--) {
                if (j & (1<<i)) {
                    C[j] -= C[j ^ (1 << i)];
                }
            }
        }
        return C;
    }

     

    연습문제

    난이도 순서가 아닐 수 있다.

    https://www.acmicpc.net/problem/2803

     

    2803번: 내가 어렸을 때 가지고 놀던 장난감

    어젯밤에 나는 어렸을 때 가지고 놀던 장난감 상자 N개를 창고에서 발견했다. 상자를 좀 뒤적여보니 나는 M종류의 장난감을 가지고 있었다. 오랫동안 기억 속에서 잊혀졌던 장난감을 보니, 내가

    www.acmicpc.net

    https://www.acmicpc.net/problem/25390

     

    25390번: Traveling Junkman Problem

    고물상이 N개의 집을 순회하며 물건을 사고 판다. 각 집에는 1번부터 N번까지 번호가 붙어 있다. 고물상이 취급하는 물건은 총 M종류가 있으며, 마찬가지로 1번부터 M번까지 번호가 붙어

    www.acmicpc.net

    https://www.acmicpc.net/problem/27841(Online imos-Hanbyeol Trick으로 풀어보자.)

     

    27841번: Problem Setting

    Farmer John created N (1N105) problems. He then recruited M (1M20) test-solvers, each of which rated every problem as "easy" or "hard." His goal is now to create a problemset arranged in increasing order of difficulty, consisting o

    www.acmicpc.net

    Compatible Numbers

    Bits And Pieces

    Marek and Matching (easy version)

    AND Convolution

     

Designed by Tistory.