cv.cm

코딩 · 알고리즘

KMP 문자열 검색 쉽게 이해하기: 동작 원리, 코드, 시간 복잡도

7분 분량

핵심 답변

크누스-모리스-프랫(KMP) 알고리즘은 길이 n인 텍스트에서 길이 m인 패턴의 모든 등장 위치를 O(n + m) 시간에 찾습니다. 먼저 패턴의 접두사 함수(실패 함수 표라고도 함)를 만듭니다. 각 위치에서 접두사이면서 동시에 접미사이기도 한 가장 긴 진접두사의 길이를 저장하는 표입니다. 불일치가 일어나면 KMP는 이 표를 사용해 텍스트에서 뒤로 가지 않고 패턴을 이동시킵니다.

정규식 테스트 열기

KMP가 해결하는 문제

문자열 검색은 짧은 패턴이 긴 텍스트의 어디에 나타나는지 묻습니다. 단순한 방법은 모든 시작 위치를 시도하며 문자를 하나씩 비교합니다. 일반적인 텍스트에서는 빠르지만 반복적인 입력에서는 느립니다. aaaaaaaaaa에서 aaaab를 찾으면 각 위치에서 패턴 거의 전체를 비교하게 되어 O(n × m) 시간이 걸립니다.

KMP는 불일치가 일어난 뒤 텍스트 문자를 다시 읽지 않습니다. 도널드 크누스(Donald Knuth), 제임스 H. 모리스(James H. Morris), 본 프랫(Vaughan Pratt)이 1977년 "Fast Pattern Matching in Strings"에서 발표했습니다.

핵심 아이디어

패턴 ABABC를 찾고 있고 ABAB까지 일치했는데 다음 텍스트 문자가 C가 아니라고 합시다. 방금 일치한 ABAB는 AB로 끝나는데, 이는 패턴의 시작과도 같습니다. 따라서 다음 시도는 이미 2글자가 일치한 상태로 시작한다는 것을 알고, 처음부터 다시 하는 대신 패턴의 2번 위치부터 비교를 이어 갈 수 있습니다.

접두사 함수는 이 숫자들을 저장합니다. pi[i]는 pattern[0..i]의 진접두사 중 접미사이기도 한 가장 긴 것의 길이입니다(진(proper)이란 문자열 전체가 아니라는 뜻).

i0123456
pattern[i]ABABACA
pi[i]0012301

예를 들어 pi[4] = 3인 이유는 ABABA가 ABA로 시작하고 끝나기 때문입니다. pi[5] = 0인 이유는 ABABAC의 어떤 접두사도 C로 끝나지 않기 때문입니다.

단계별 예제

텍스트 ABABABC에서 패턴 ABABC(접두사 함수 0, 0, 1, 2, 0)를 검색합니다. 변수 k는 현재 일치한 패턴 문자의 수입니다.

텍스트 위치텍스트 문자이전 k동작이후 k
0–3A B A B0연속 네 번 일치4
4A4pattern[4] = C와 불일치: k = pi[3] = 2로 설정한 뒤 A가 pattern[2]와 일치3
5B3pattern[3]과 일치4
6C4pattern[4]와 일치: 6에서 끝나는 완전 일치이므로 시작 위치는 6 − 5 + 1 = 25 → pi[4] = 0

텍스트 포인터는 7번 앞으로만 움직이고 한 번도 뒤로 가지 않았습니다. 단순한 방법이라면 1번 위치에서 다시 시작해 일부 문자를 다시 비교했을 것입니다.

파이썬 구현

def prefix_function(p):
    pi = [0] * len(p)
    k = 0
    for i in range(1, len(p)):
        while k > 0 and p[i] != p[k]:
            k = pi[k - 1]          # fall back to a shorter border
        if p[i] == p[k]:
            k += 1
        pi[i] = k
    return pi

def kmp_search(text, pattern):
    """Return the start index of every occurrence (overlaps included)."""
    if not pattern:
        return []
    pi, k, hits = prefix_function(pattern), 0, []
    for i, ch in enumerate(text):
        while k > 0 and ch != pattern[k]:
            k = pi[k - 1]
        if ch == pattern[k]:
            k += 1
        if k == len(pattern):
            hits.append(i - k + 1)
            k = pi[k - 1]          # keep going to find overlapping matches
    return hits

print(prefix_function("ABABACA"))      # [0, 0, 1, 2, 3, 0, 1]
print(kmp_search("ABABABC", "ABABC"))  # [2]
print(kmp_search("AAAAA", "AA"))       # [0, 1, 2, 3]

시간 복잡도와 공간 복잡도

단계시간공간이유
접두사 함수 만들기O(m)O(m)k는 단계마다 최대 한 번 증가하고 폴백할 때마다 감소하므로, 폴백 횟수는 총 m번 이하입니다.
검색O(n)O(1) 추가텍스트에 대해서도 같은 논리가 적용됩니다. 증가는 최대 n번이므로 폴백도 최대 n번입니다.
합계O(n + m)O(m)최악의 경우가 최선의 경우와 상수배 차이 이내로 같으며, 나쁜 입력이 없습니다.

단순한 알고리즘은 최악의 경우 O(n × m)이지만 자연어 텍스트에서는 O(n)에 가까운 경우가 많으므로, KMP의 장점은 주로 반복적인 데이터에서 드러납니다.

변형과 흔한 실수

  • 폴백에서의 off-by-one: 불일치 이후에는 k = pi[k]가 아니라 k = pi[k - 1]로 설정합니다. pi는 마지막으로 일치한 위치로 인덱싱됩니다.
  • 폴백에 while 대신 if를 사용함: 문자가 일치하기 전에 폴백이 연속으로 여러 번 필요할 수 있습니다.
  • 완전 일치 후 k를 재설정하는 것을 잊음: 겹치는 일치를 찾으려면 k = pi[k - 1]로, 겹치지 않는 일치만 찾으려면 k = 0으로 설정합니다.
  • 이어 붙이기 기법: 둘 어디에도 나오지 않는 구분자를 사용해 pattern + separator + text의 접두사 함수를 계산합니다. 값이 m인 모든 위치가 일치의 끝입니다.
  • Z 알고리즘은 매우 비슷한 배열을 계산하며 마찬가지로 O(n + m)에 검색합니다.

언제 쓰는지와 실제 활용

알고리즘가장 적합한 용도
KMP패턴 하나에 대해 선형 시간 보장. 뒤를 돌아보지 않으므로 스트리밍 텍스트에 적합.
보이어-무어와 그 변형큰 알파벳에서의 긴 패턴. 많은 문자를 건너뛰는 경우가 많으며 grep 같은 도구에서 사용.
라빈-카프같은 길이의 많은 패턴, 또는 롤링 해시를 이용한 표절 탐지.
아호-코라식여러 패턴을 한꺼번에 검색. KMP의 실패 링크를 트라이로 일반화.
접미사 배열 또는 접미사 오토마톤고정된 같은 텍스트에 대한 서로 다른 많은 질의.
  • 코딩 인터뷰 문제: strStr() 구현, 가장 짧은 팰린드롬(s + "#" + reversed(s)의 접두사 함수), 반복되는 부분 문자열 패턴, 문자열의 최소 주기(m - pi[m - 1]).
  • 실제 사례: 최악의 경우에도 선형 시간이 중요한 DNA 서열이나 네트워크 패킷 스트림 검색.

자주 묻는 질문

KMP의 접두사 함수 또는 실패 함수 표란 무엇인가요?

패턴의 각 위치 i에 대해 pattern[0..i]의 진접두사 중 접미사이기도 한 가장 긴 것의 길이를 저장합니다. 불일치가 일어나면 KMP는 이를 사용해 아직 몇 글자가 일치한 상태인지 알아냅니다.

KMP의 시간 복잡도는 얼마인가요?

O(n + m)입니다. 접두사 함수를 만드는 데 O(m), 텍스트를 훑는 데 O(n)이 듭니다. 최악의 경우에도 선형입니다.

KMP가 파이썬의 str.find보다 빠른가요?

아니요. str.find는 최적화된 검색 알고리즘으로 C로 구현되어 있어 실제로는 훨씬 빠릅니다. KMP는 선형 시간이 보장된다는 점, 학습용, 문자열이 아닌 시퀀스에 쓸 수 있다는 점에서 가치가 있습니다.

KMP는 겹치는 일치도 찾나요?

네, 완전 일치 후 k를 0이 아니라 pi[k - 1]로 설정하면 됩니다. 예를 들어 AAAAA에서 AA를 0, 1, 2, 3번 위치에서 찾습니다.

코딩 튜토리얼 전체