이 글은 구종만님의 ‘알고리즘 문제해결전략’을 바탕으로 작성하였습니다.

문자열 검색

주어진 긴 문자열 H가 패턴 N을 부분 문자열로 포함하는지 확인하고, 포함한다면 N과 일치하는 부분 문자열의 시작 위치를 찾는 문제를 문자열 검색 문제라고 합니다. H=”starbuckstar”, N=”star” 이라고 하면, H는 N을 두 번 포함하며 각각의 시작 위치는 0, 8 입니다.

이 문제를 푸는 간단한 방법은 N의 가능한 모든 시작 위치를 다 시도해 보는 것입니다. 처음에는 0번 글자에서 시작하는 부분 문자열이 N과 같은지 확인하기 위해 H와 N의 글자를 하나하나 맞춰 나갑니다. 만약 모든 글자가 서로 일치한다면 이 위치를 답에 추가하고, 중간에 실패하거나 답을 하나 찾으면 시작 위치를 오른쪽으로 한 칸 옮겨 계속합니다.

vector<int> naiveSearch(const string& H, const string& N) {
    vector<int> ret;
    for (int begin = 0; begin + N.size() <= H.size(); ++begin) {
        bool matched = true;
        for (int i = 0; i < N.size(); ++i) {
            if (H[begin + i] != N[i]) {
                matched = false;
                break;
            }
        }
        if (matched) ret.push_back(begin);
    }
    return ret;
}

이 알고리즘은 |H|-|N|+1개의 모든 시작 위치에 대해서 비교를 수행합니다. 대개 |H|»|N|이므로 시작 위치의 수는 O(|H|)라고 할 수 있습니다. 각각의 비교에는 O(|N|)의 시간이 걸리기 때문에, 이와 같은 단순한 알고리즘의 전체 시간복잡도는 O(|N|*|H|)이 됩니다.

입력이 큰 경우 이 알고리즘은 꽤 비효율적이지만, 이런 경우는 사실 흔치 않으며 구현이 단순하다는 장점이 있기 때문에 단순한 알고리즘은 표준 라이브러리 구현에 널리 사용됩니다. C의 strstr(), C++ 문자열의 string::find(), 자바 문자열의 indexOf() 등이 모두 이와 같은 알고리즘을 사용합니다.

KMP 알고리즘

일치한 글자의 수는 항상 0에서 |N| 사이의 정수이기 때문에, 전처리 과정에서 이 정보들을 미리 계산해 저장해 둘 수 있습니다. 이와 같은 최적화를 적용한 문자열 검색 알고리즘이 커누스-모리스-프랫(Knuth-Moriss-Paratt) 알고리즘으로, 흔히 KMP 알고리즘이라고 합니다.

KMP 알고리즘을 이해하기 위해 접두사(prefix), 접미사(suffix) 그리고 pi 배열에 대해 알고 있어야 합니다.

  • coffee의 접두사(prefix): c / co / cof / coff / coffe / coffee

  • coffee의 접미사(suffix): e / ee / fee / ffee / offee / coffee

  • pi 배열은 주어진 문자열의 0~i 까지의 부분 문자열 중에서 접미사도 되고 접두사도 되는 문자열의 최대 길이입니다.

i 부분 문자열 prefix=sufix인 문자열 pi[i]
0 A (없음) 0
1 AA A 1
2 AAB (없음) 0
3 AABA A 1
4 AABAA AA 2
5 AABAAB AAB 3
6 AABAABA AABA 4
7 AABAABAC (없음) 0
vector<int> kmpSearch(const string& H, const string& N) {
    int n = H.size(), m = N.size();
    vector<int> ret;
    vector<int> pi = getPartialMatch(N);    // pi[i]=N[..i]의 접미사도 되고 접두사도 되는 문자열의 최대 길이
    int begin = 0, matched = 0;
    while (begin <= n - m) {
        // 만약 짚더미의 해당 글자가 패턴의 해당 글자와 같다면
        if (matched < m && H[begin + matched] == N[matched]) {
            ++matched;
            if (matched == m) ret.push_back(begin);
        }
        else {
            // 예외: matched가 0인 경우에는 다음 칸에서부터 계속
            if (matched == 0) ++begin;
            else {
                begin += matched - pi[matched - 1];
                // begin을 옮겼다고 처음부터 다시 비교할 필요가 없다.
                // 옮긴 후에도 pi[matched - 1]만큼은 항상 일치하기 때문이다.
                matched = pi[matched - 1];
            }
        }
    }
    return ret;
}
  • begin을 옮길 때 이전에 계산한 pi[] 값을 이용하고 있습니다. 현재 matched 글자가 일치했다면 pi[matched - 1]는 항상 계산된 뒤임을 증명할 수 있기 때문입니다.

  • pi[]의 각 원소는 최대 한 번만 변경되기 때문에 max() 연산을 따로 해 줄 필요가 없습니다.

vector<int> getPartialMatch(const string& N) {
    int m = N.size();
    vector<int> pi(m, 0);
    // KMP로 자기 자신을 찾는다.
    // N을 N에서 찾는다. begin=0이면 자기 자신을 찾아버리니까 안됨!
    int begin = 1, matched = 0;
    while (begin + matched < m) {
        if (N[begin + matched] == N[matched]) {
            ++matched;
            pi[begin + matched - 1] = matched;
        }
        else {
            if (matched == 0) ++begin;
            else {
                begin += matched - pi[matched - 1];
                matched = pi[matched - 1];
            }
        }
    }
    return pi;
}

참고자료

KMP : 문자열 검색 알고리즘