ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 편집거리(boj 7620)
    알고리즘 2020. 4. 11. 02:45
    728x90

    최근 며칠동안 편집거리 문제 푸느라 엄청 고생했었어요 

     

    사실 쉽게 푸는 방법이 있었는데 편집거리(Hard) 로 풀어보고 싶어서 고집 피우다가 결국 실패했네요.. ㅠㅠ

    학교 과제로 나온거라 기한이 있어서, 나중에 시간나면 하드로 다시 풀어보려구요

     

    간단히 요약하자면

    최대 17000 글자인 두 문자열 A, B 를 입력받게되면 문자열 A를 B로 만드는데 필요한 비용을 편집거리라고 해요
    우리는 이 편집거리를 최소한으로 하는 방법을 찾으면 됩니다.

    연산과 그 비용의 정의는 다음과 같아요

    a 'n' : add 출력 문자열에 'n' 을 출력 ( 비용 : 1 )

    d 'n' : delete 입력 문자열의 'n'을 삭제 ( 비용 : 1 )

    m 'n' : modify 입력 문자를 'n'으로 수정 ( 비용 : 1 )

    c 'n' : copy 입력 문자 'n'을 복사하여 출력 문자열에 출력 (비용 : 0 )

     

    잘 이해가 안되신다면 위 링크를 통해 문제를 읽고오셔야해요!

     

    문제를 보는 순간 일단 뭔진 몰라도 m이 무조건 많이 쓰일 것 같고, c 가 많을 수록 비용이 감소하니까 최대한 c를 많이 활용해야겠다는 생각이 들었어요

     

    문제를 이해했으니 이제 솔루션에 대해 생각해볼게요

     

    Edit Distance 알고리즘은 그 설명을 LCS(Longest Common Subsequence) 로 하는게 편해요

    그럼 LCS 란 무엇이냐?

     

    LCS : 두 문자열 A, B 가 있다고 할 때 두 문자열이 공통으로 갖고 있는 가장 긴 subsequence 를 말해요

     

    잠깐 용어 정의를 하자면 subsequence 는 연속하지않아도 되는 부분 문자열이고, substring 은 연속하는 부분 문자열이에요

    예를 들어 apple 에서 대표적인 subsequence 는 apl 이고, 대표적인 substring은 ppl 이에요

     

    A = abc

    B = qascd

    LCS는 qascd 가 되겠죠? = 즉 "ac" 가 LCS 입니다.

     

    Edit distance 는 LCS에서부터 시작된다는걸 눈치 채셨나요?

    LCS를 찾게 되면 해당 문자들은 copy 연산을 통해 cost 0으로 출력해낼 수 있어요

    이제 남은건 LCS에 속하지 않은 문자들을 어떻게 잘 출력해낼까? 만 남았어요

     

    여기서부터는 DP 개념이 필요해요 (사실 LCS 도 DP 로 구해야해요)

     

    LCS

    먼저 LCS를 구하는걸 설명해볼게요. DP 정의는 다음과 같아요

    table[i][j] = A의 i 번째 문자와 B의 j 번째 문자까지만 고려했을 때 LCS 의 길이

        q a s c d
      0 0 0 0 0 0
    a 0          
    b 0          
    c 0          

    초기 테이블의 상태는 다음과 같아요. 이 dp 는 특이하게 빈 문자열도 고려해요 그래서 테이블의 0번 row, col 이 빈 문자열을 의미하죠

     

    table[0][0] = 빈 문자열과 빈 문자열의 LCS = 0

    table[1][0] = "a" 와 빈 문자열의 LCS = 0

    table[2][0] = "ab" 와 빈 문자열의 LCS = 0

     

    table[0][1] = 빈문자열과 "q"의 LCS = 0

    점화식은 다음과 같아요

    table[i][j] = if ( A[i] == B[j] ) : table[i - 1][j - 1] + 1

            else : max( table[i - 1][j], table[i][j - 1] )

    말로 풀어볼게요

    table[i][j] 는 두 가지 경우로 생각할 수 있는데 A의 i 번째 글자와 B의 j 번째 글자가 같은 경우와 아닌 경우이다.

    같은 경우에는 A[i - 1]과 B[j - 1] 까지만 고려하여 만들어진 LCS (table[i - 1][j - 1]) 에 한 글자를 덧붙인다. ( + 1 )

     

    A의 i 번째 글자와 B의 j 번째 글자가 다른 경우에는 지금까지 만들어진 최대 LCS를 가져온다.

    A[i - 1] 와 B[j] 까지만 고려한 LCS와 A[i] 와 B[j - 1] 까지만 고려한 LCS 둘 중 큰 놈을 가져온다는 것이다.

     

    잘 이해가 안된다면 아래 예시를 보며 이해해봅시다.

     

    table[1][1] 은 우선 A[1]( = 'a')와 B[1]( = 'q') 가 다르므로 table[0][1], table[1][0] 즉 윗놈과 왼쪽 놈 중 큰 놈을 가져오게 됩니다.

    둘 다 0이므로 table[1][1] 은 0입니다.

        q a s c d
      0 0 0 0 0 0
    a 0 0        
    b 0          
    c 0          

    LCS를 추적해서 출력해야하므로 출력 테이블을 만들어 두겠습니다.

    이 테이블은 각 원소가 어느 방향으로부터 왔는지 기록하게 됩니다. NORTH_WEST 는 왼쪽 위, NORTH 는 위, WEST는 왼쪽에서부터 왔다는 의미입니다.

        q a s c d
      NORTH_WEST WEST WEST WEST WEST WEST
    a NORTH NORTH        
    b NORTH          
    c NORTH          

     

     

     

    table[1][2]는 어떤가요?

    A[1] 과 B[2] 가 'a'로 같으므로 table[0][1] + 1을 넣어줍니다.

     

    table[0][1] 을 가져오는 이유는 A[1]과 B[1]이 고려되지 않은 경우 중에서 가장 큰 LCS 값을 갖는 경우이기 때문입니다.

    만약 table[0][2] 를 가져오면 어떻게 될까요? 지금 당장은 1로 동일한 값을 갖겠지만, 이는 우연일 뿐 실제로는 이상한 값을 갖게 될 것입니다.

    table[0][2] 는 정의상 A[ ~ 0]과 B[ ~ 2] 를 고려한 경우입니다. 이 값을 가져와 + 1을 한다는 것은 B[2]를 두 번이나 고려하게 된다는 의미입니다. (B[ ~ 2] 는 0번째 글자부터 2번째 글자를 모두 일컫는 표현입니다 "qa" 가 되겠네요)

    이미 "qa"를 고려하여 최대 LCS를 구하여 table[0][2]에 저장해뒀는데, 'a' 를 또 고려한다? 이는 논리적으로 맞지않죠

        q a s c d
      0 0 0 0 0 0
    a 0 0 1      
    b 0          
    c 0          
        q a s c d
      NORTH_WEST WEST WEST WEST WEST WEST
    a NORTH NORTH NORTH_WEST      
    b NORTH          
    c NORTH          

     

    table[1][3] 은 A[1] 과 B[3] 이 다르므로 윗놈과 왼쪽놈 중 큰 놈을 가져와야합니다.

    따라서 1이 되겠죠 

        q a s c d
      0 0 0 0 0 0
    a 0 0 1 1    
    b 0          
    c 0          
        q a s c d
      NORTH_WEST WEST WEST WEST WEST WEST
    a NORTH NORTH NORTH_WEST WEST    
    b NORTH          
    c NORTH          

    table[0][3] ( 윗놈 ) 이 의미하는건 빈 문자열과 "qas" 의 LCS 길이

    table[1][2] ( 왼쪽놈 ) 이 의미하는건 "a"와 "qa" 의 LCS 길이

    이 둘 중 최대 값을 가져오는 이유는 table[0][3] 은 A[1] 이 고려되지 않은 경우의 수 중 가장 큰 경우이고,

    table[1][2] 는 B[3]이 고려되지 않은 경우의 수 중 가장 큰 경우이기 때문입니다.

     

    같은 방법으로 쭉 이어가게되면

        q a s c d
      0 0 0 0 0 0
    a 0 0 1 1 1 1
    b 0          
    c 0          
        q a s c d
      NORTH_WEST WEST WEST WEST WEST WEST
    a NORTH NORTH NORTH_WEST WEST WEST WEST
    b NORTH          
    c NORTH          

     

     

        q a s c d
      0 0 0 0 0 0
    a 0 0 1 1 1 1
    b 0 0 1 1 1 1
    c 0 0 1 1 2 2
        q a s c d
      NORTH_WEST WEST WEST WEST WEST WEST
    a NORTH NORTH NORTH_WEST WEST WEST WEST
    b NORTH NORTH NORTH NORTH NORTH NORTH
    c NORTH NORTH NORTH NORTH NORTH_WEST WEST

    따라서 A[3][5] 는 2 이므로 최대 LCS의 길이는 2입니다.

    출력은 A[3][5] 부터 DFS 로 타고 들어가면서 [0][0] 까지 가보면 됩니다. 그리고 NORTH_WEST 를 만날때마다 print 해주면 되겠지요

    왜냐하면 NORTH_WEST를 만난 경우는 두 문자가 같아서 LCS의 값이 추가된 경우이기 때문에 해당 문자는 LCS에 포함되기 때문이에요

     

    쭉 따라가볼게요

        q a s c d
      NORTH_WEST WEST WEST WEST WEST WEST
    a NORTH NORTH NORTH_WEST WEST WEST WEST
    b NORTH NORTH NORTH NORTH NORTH NORTH
    c NORTH NORTH NORTH NORTH NORTH_WEST WEST

    맨 좌측 상단은 빈 문자열이라 출력해주지 않습니다!

    굵은 글씨 처리한 부분들을 출력하게 되면 "ac" 가 나오네요 정답이 맞죠? 헤헤

    Edit Distance

    그럼 LCS를 기반으로 한 Edit distance 를 DP로 구해볼게요. 테이블의 정의는 다음과 같아요

    table[i][j] = i 번째 문자까지의 A로 j 번째 문자까지의 B를 만드는 최소 cost 의 Edit distacne

      = A[ ~ i] 로 B[ ~ j] 를 만드는 최소 cost의 Edit distance

        q a s c d
      0 1 2 3 4 5
    a 1          
    b 2          
    c 3          

     

    table[0][0] = 빈 문자열을 빈 문자열로 바꾸는 비용 = 0

    table[1][0] = "a" 를 빈 문자열로 바꾸는 비용 = 1          -> delete 1번

    table[2][0] = "ab" 를 빈 문자열로 바꾸는 비용 = 2         -> delete 2번

    table[3][0] = "abc" 를 빈 문자열로 바꾸는 비용 = 3          -> delete 3번

     

    table[0][1] = 빈 문자열을 "q"로 바꾸는 비용 = 1             -> add 1번

    table[0][2] = 빈 문자열을 "qa"로 바꾸는 비용 = 2             -> add 2번

    ...

     

    그럼 dp 답게 1번 row 부터 채워나갈거에요

    점화식은 다음과 같습니다.

    table[i][j] = if (A[i] == B[j]) : table[i - 1][j - 1]

            else : min(table[i - 1][j - 1], table[i - 1][j], table[i][j - 1]) + 1

     

    말로 풀어볼게요

    table[i][j] 는 두 가지 경우로 나누어서 생각합니다.

    A의 i 번째 글자와 B의 j 번째 글자가 같다면 table[i - 1][j - 1]을 그대로 가져옵니다.

    그대로 가져오는 이유는 LCS는 +1 을 해주면서 최대 길이의 공통 subsequence 를 찾아가는 것이였지만 Edit distance 는 최소 코스트를 찾는 것이므로 A[ ~ i] 와 B[ ~ j] 에서 공통 문자 A[i]( = B[i]) 를 copy 해왔기에 cost 는 증가하지 않습니다.

     

    A의 i 번째 글자와 B의 j 번째 글자가 다르다면 table[i - 1][j - 1],  table[i - 1][j],  table[i][j - 1] 이 셋 중에 값이 가장 적은 것을 가져와 +1 해준 값을 저장합니다.

    table[i - 1][j - 1] 을 가져오는 경우는 A[i] 를 B[j] 로 modify(수정) 하는 경우

    table[i - 1][j] 를 가져오는 경우는 A[ ~ i - 1] 만으로 B[j]를 만들어버렸으니 사용되지 못한 A[i] 를 버리는 delete(삭제) 하는 경우

    table[i][j - 1] 를 가져오는 경우는 A[ ~ i] 로 B[ ~ j - 1]을 만들었으니 끝에 B[j]를 붙여 add(더하기) 하는 경우

    이 세 경우를 각각 고려하여 셋 중에서 가장 적은 cost를 갖는 방법으로 A[ ~ i] 를 B[ ~ j] 로 바꾸어냅니다.

     

     

    table[1][1] 을 생각해보죠

    'a' 와 'q'는 같은 문자가 아니니까 왼쪽위, 위, 왼쪽 셋 중에 가장 작은놈 + 1을 합니다.

        q a s c d
      0 1 2 3 4 5
    a 1 1        
    b 2          
    c 3          
        q a s c d
      NORTH_WEST WEST WEST WEST WEST WEST
    a NORTH NORTH_WEST        
    b NORTH          
    c NORTH          

     

    table[1][2] 을 생각해보죠

    'a' 와 'a'는 같은 문자이므로 왼쪽윗놈을 그대로 가져옵니다.

        q a s c d
      0 1 2 3 4 5
    a 1 1 1      
    b 2          
    c 3          
        q a s c d
      NORTH_WEST WEST WEST WEST WEST WEST
    a NORTH NORTH_WEST NORTH_WEST      
    b NORTH          
    c NORTH          

     

    계속 채워나가면 다음과 같이 됩니다.

        q a s c d
      0 1 2 3 4 5
    a 1 1 1 2 3 4
    b 2 2 2 2 3 4
    c 3 3 3 3 2 3
        q a s c d
      NORTH_WEST WEST WEST WEST WEST WEST
    a NORTH NORTH_WEST NORTH_WEST WEST WEST WEST
    b NORTH NORTH_WEST NORTH_WEST NORTH_WEST NORTH_WEST NORTH_WEST
    c NORTH NORTH_WEST NORTH_WEST NORTH_WEST NORTH_WEST WEST

     

    따라서 최소 코스트는 3

    그 과정을 DFS로 출력해보면

    a q

    c a

    m s

    c c

    a d

    가 됩니다.

    주의할 점은 NORTH_WEST 라고 그냥 막 출력하는게 아니라 두 문자가 같으면 copy, 다르면 modify 인것만 구분지어서 출력해주면 어렵지 않게 출력할 수 있습니다.

     

    N = 문자열 A의 길이

    W = 문자열 B의 길이라고 할 때

    두 DP 는 시간복잡도 O(NW), 공간복잡도는 O(NW)가 됩니다.

     

    맨 위 링크타고 보시면 시간은 8초, N, W는 각각 최대 17000이므로 17000 * 17000 = 289000000 대략 10^8 이라서 충분히 돌아갑니다만 메모리 제한이 128MB 에요 그래서 dp 테이블이랑 출력 테이블을 char 형 배열로 선언하더라도 289 MB 짜리 두 개라서 가볍게 터집니다. (하나도 못만들어요...ㅎ)

     

    그래서 공간 복잡도를 최적화해야하는데 DP 테이블은 O(W) 로 최적화하여 토글 방식으로 생성이 가능합니다.

    쉽게말해서 어느 한 요소를 만들 때 필요한건 그 윗놈, 왼쪽놈, 그 왼쪽 윗놈만 필요하니까 한 row를 채우는데는 그 바로 위 row만 있으면 된다고 생각하는겁니다.

     

        q a s c d
      0 1 2 3 4 5
    a 1 1 1 2 3 4
    b 2 2 2 2 3 4
    c 3 3 3 3 2 3
        q a s c d
      0 1 2 3 4 5
    a 1 1 1 2 3 4
    b 2 2 2 2 3 4
    c 3 3 3 3 2 3
        q a s c d
      0 1 2 3 4 5
    a 1 1 1 2 3 4
    b 2 2 2 2 3 4
    c 3 3 3 3 2 3
        q a s c d
      0 1 2 3 4 5
    a 1 1 1 2 3 4
    b 2 2 2 2 3 4
    c 3 3 3 3 2 3

    이렇게 색깔 칠한 순서대로 배열을 구성해서

    table[0]에 구한 값을 table[1] 을 만들때 사용하고, 여기서 다시 table[0] 을 table[1] 을 통해 만들어가는 방식입니다.

    근데 이게 문제가 dp 테이블은 토글방식으로 최적화가 가능한데 출력 테이블은 이게 안되거든요

     

    하는 방법은 Hirschberg 알고리즘이라고 있긴한데 이거 코딩을 제대로 해내지 못해서 일단 단순하게 메모리 최적화를 시켰어요

    제가 한 방법은 C언어의 비트필드를 이용해서 NORTH_WEST, WEST, NORTH, NORTH_WEST_BY_COPY 이렇게 4가지 경우를 표현하려면 2비트로 표현이 가능하므로 char 하나(8 비트)를 통해 4개의 출력 테이블 element 를 표현할 수 있어요

    원래 char 1개로 하나의 element를 표현하다가 4개를 표현할 수 있게 되니 사이즈는 당연히 4분의 1로 줄어들것이고

    그러면 280MB / 4 = 70MB 남짓 되므로 128MB 제한에 들 수 있게 되는거죠

     

    제 소스 첨부합니다~

    #include <cstdio>
    #include <cstdlib>
    #include <algorithm>
    #include <set>
    #include <vector>
    #include <deque>
    #include <list>
    #include <queue>
    #include <memory.h>
    #define INF 98765432
    #define MAX 17000
    #define NORTH_WEST 0
    #define NORTH 1
    #define WEST 2
    #define COPY 3
    
    typedef struct {
        unsigned char leftTop : 2;
        unsigned char rightTop : 2;
        unsigned char leftBottom : 2;
        unsigned char rightBottom : 2;
    } Path;
    
    int N, K;
    int dp[2][MAX + 10];
    Path path_log[MAX / 2 + 10][MAX / 2 + 10];
    char str1[MAX + 10];
    char str2[MAX + 10];
    int length1, length2;
    int direction[4][2] = {
            {-1, -1},
            {-1, 0},
            {0, -1},
            {-1, -1}
    };
    
    int get(int y, int x) {
        Path p = path_log[y / 2][x / 2];
        if (y % 2 == 1 && x % 2 == 1)
            return p.rightBottom;
    
        if (y % 2 == 1)
            return p.leftBottom;
    
        if (x % 2 == 1)
            return p.rightTop;
    
        return p.leftTop;
    }
    
    void set(int y, int x, int val) {
        if (y % 2 == 1 && x % 2 == 1)
            path_log[y / 2][x / 2].rightBottom = val;
        else if (y % 2 == 1)
            path_log[y / 2][x / 2].leftBottom = val;
        else if (x % 2 == 1)
            path_log[y / 2][x / 2].rightTop = val;
        else
            path_log[y / 2][x / 2].leftTop = val;
    }
    
    
    void print(int y, int x) {
        if (y == 0 && x == 0)
            return;
    
        int dIndex = get(y, x);
        int newY = y + direction[dIndex][0];
        int newX = x + direction[dIndex][1];
        print(newY, newX);
    
        char op, c;
        if (dIndex == NORTH_WEST) {
            op = 'm';
            c = str2[x - 1];
        } else if (dIndex == NORTH) {
            op = 'd';
            c = str1[y - 1];
        } else if (dIndex == WEST) {
            op = 'a';
            c = str2[x - 1];
        } else {
            op = 'c';
            c = str1[y - 1];
        }
        printf("%c %c\n", op, c);
    }
    
    int main() {
        scanf("%s %s", str1, str2);
    
        length1 = strlen(str1);
        length2 = strlen(str2);
    
        for (int i = 0; i <= MAX; i++) {
            dp[0][i] = i;
            set(0, i, WEST);
            set(i, 0, NORTH);
        }
    
        for (int i = 1; i <= MAX; i++) {
            int index = i % 2;
            dp[index][0] = i;
            for (int j = 1; j <= MAX; j++) {
                if (str1[i - 1] == str2[j - 1]) {
                    dp[index][j] = dp[!index][j - 1];
                    set(i, j, COPY);
                } else {
                    dp[index][j] = std::min(
                            dp[!index][j - 1],
                            std::min(
                                    dp[!index][j],
                                    dp[index][j - 1]
                            )
                    ) + 1;
    
                    if (dp[index][j] == dp[!index][j - 1] + 1) {
                        set(i, j, NORTH_WEST);
                    } else if (dp[index][j] == dp[!index][j] + 1) {
                        set(i, j, NORTH);
                    } else {
                        set(i, j, WEST);
                    }
                }
            }
        }
    
        print(length1, length2);
        return 0;
    }

     

    '알고리즘' 카테고리의 다른 글

    MT 문제 (boj 10265)  (0) 2020.05.16
    위상정렬  (0) 2020.05.16
    사과와 바나나 (boj 3114)  (0) 2020.04.17
    구간합 구하기  (0) 2020.04.17
    DFS 메모이제이션  (0) 2020.04.15

    댓글

Designed by Tistory.