2 분 소요

[KMP 알고리즘 (커누스-모리스-프랫 Knuth-Morris-Pratt)]

<알고리즘 문제해결 전략 2> 권 - 20장 문자열에서 KMP 알고리즘을 자바로 구현해 보았다. (p643~653)

주어진 문자열 H가 다른 문자열 N을 포함하는 여부를 확인하고, 포함한다면 일치하는 부분 문자열의 시작위치를 반환하는 문제이다.

1. H와 N을 하나하나 다 비교해본다.

public class SimpleStringFind {
    public static void main(String[] args) {
        SimpleStringFind sSF = new SimpleStringFind();
        String full = "elephaele";
        String part = "ele";
        System.out.println(Arrays.toString(new List[]{sSF.naiveSearch(full, part)}));
    }

        public List<Integer> naiveSearch(String full, String part) {
        List<Integer> index = new ArrayList<>();
        for (int begin=0; begin <= full.length()-part.length(); begin++) {
            boolean matched = true;
            for (int i=0; i<part.length(); i++) {
                if(full.charAt(begin+i)!=part.charAt(i)) {
                    matched = false;
                    break;
                }
            }
            if(matched) index.add(begin);
        }
        return index;
    }
}

naiveSearch() 메서드의 반환타입은 List로 하였지만, 배열로 하든 void이든 인덱스 0번 위치부터 주어진 2개의 문자열을 비교해 나가는 것은 동일하다.

그런데 이렇게 풀 경우, 어떤 문자열이 주어졌냐에 따라 시간 복잡도가 무지막지하게 증가할 수 있다. 예를 들어, ‘a’문자 2만개만으로 이루어진 문자열 H에서 ‘a’만 1천개로 이루어진 문자열 N의 시작위치를 반환하는 문제가 있다면…

H의 모든 위치의 수, 즉 H의 길이가 정답이라는 것을 직관적으로 쉽게 알 수 있다. 하지만 위 코드는 그런 건 모른다. 우직하게 하나하나 다 따져가면서 시간복잡도를 구한다. 따라서 O(2만 * 1천) = O(2천만)이라는 엄청난 시간이 걸리게 된다.

2. [KMP 알고리즘]으로 풀어보자.

<알고리즘 문제해결 전략 2>에 나온 문제를 살펴보자. 주어진 문자열 H안에 “aabaabac“라는 문자열 N이 포함되어 있는지 예시를 삺펴보자.
image

문자열 N의 길이는 8인데, 위 그림에서 보듯 마지막 글자가 맞지 않았다. 위 1번 코드에서처럼 N을 1칸씩 옮겨가면서 주어진 문자열 H와 비교해보자. 그러면 i+3, i+6번째에서는 N과 H의 일부분이 일치할 가능성이 있다는 것을 알 수 있다. 여기에 규칙성이 있을까?

image

설명을 길게 잘 할 자신은 없어, 한 가지만 예를 들어보겠음. 문자열 N에서 맨 마지막 1글자만 빼고 앞의 7글자(aabaaba)가 H의 일부와 맞을 때, 7글자 내부에는 일치하는 접두사, 접미사가 존재한다. 그게 A, AABA이다.

image

이 2개 중 기억해야 할 것은 AABA의 길이 = 4이다. N과 H의 일부분이 7개까지 동일해서 “드디어 일치하는건가?”하고 기대감이 차 있다가 마지막 글자가 같지 않을 때,

실망만 하지 말고 N과 비교를 다시 시작할 H의 새로운 위치는 4칸 이동해서 시작하면 된다는 뜻이다. 이 부분이 바로 책에서 설명하는 “부분 일치 테이블”이다.

public class KMPsearch {
    public static void main(String[] args) {
        String target = "weeleephae";
        String pattern = "eelee";
        kmpSearch(target, pattern);
    }

    public static void kmpSearch(String target, String pattern) {
        int pL = pattern.length(), tL = target.length();
        int[] pi = getPi(pattern);

        int begin = 0, matched = 0;
        while (begin + pL <= tL) {
            if (matched<pL && pattern.charAt(matched)==target.charAt(begin+matched)) {
                matched++;
                if (matched == pL) {
                    System.out.println(begin);
                }
            }
            else {
                if (matched==0) {
                    begin++;
                }
                else {
                    begin = begin + matched - pi[matched-1];
                    matched = pi[matched - 1];
                }
            }
        }
    }

    public static int[] getPi(String pattern) {
        int pL = pattern.length();
        int[] pi = new int[pL];

        int begin = 1, matched = 0;
        while (begin + matched < pL) {
            if (pattern.charAt(matched)==pattern.charAt(begin + matched)) {
                matched++;
                pi[begin + matched - 1] = matched;
            }
            else {
                if (matched==0) {
                    begin++;
                }
                else {
                    begin = begin + matched - pi[matched - 1];
                    matched = pi[matched - 1];
                }
            }
        }
        return pi;
    }

}

getPi() 메소드는 주어진 문자열 H의 일부가 다른 문자열 N과 부분적으로만 일치할 때 - 즉, 결국은 완전히 일치하지 않아서 다시 비교를 시작할 위치를 계산해서 반환해준다.

kmpSearch()메소드는 H의 일부와 N이 일치할 때, 그 시작위치를 반환한다.

이 두 메소드는 사실 동일한 계산방식이 적용된다. 즉, 비교 대상인 두 문자열이 주어졌을 때, 서로 겹치는(동일한) 문자의 수에 따라 -> 다음 번 비교를 시작할 위치를 찾는다는 것이다.

이렇게 KMP알고리즘을 구현하면 문자열의 글자 하나하나를 다 확인할 필요가 없어져서 시간복잡도가 줄어든다.

댓글남기기