KMP 알고리즘: 문자열 검색의 표준

KMP(Knuth-Morris-Pratt) 알고리즘의 실패 함수(Failure Function) 구축 원리, O(n+m) 탐색 과정, 구현 세부 사항, 다중 패턴 검색과 주기성 탐지 응용까지 완전 분석합니다.

· 6 min read · PALDYN Team

지난 글에서 비트 조작을 다뤘다면, 이번에는 문자열 알고리즘의 핵심인 KMP를 살펴봅니다. 단순 브루트 포스 O(n·m)에서 O(n+m)으로 개선하는 핵심 아이디어는 이미 매칭된 정보를 버리지 않는 것입니다.

문제: 왜 브루트 포스가 느린가

텍스트 T = "AAAA...AB" (A 9999개 + B), 패턴 P = "AAAB"를 찾는다면:

  • 브루트 포스: 각 위치에서 최대 4번 비교, 약 40000번 비교
  • 불일치 시 패턴을 한 칸만 이동 → 앞서 비교한 정보 완전 폐기

KMP는 실패 함수(Failure Function)를 이용해, 불일치 시 패턴을 얼마나 건너뛰어도 안전한지 미리 계산합니다.

실패 함수 (Failure Function)

fail[i]: 패턴 P[0..i]에서 접두사(prefix) = 접미사(suffix)가 되는 최대 길이 (P[0..i] 자체는 제외)

패턴 "ABABCABAB":

인덱스문자fail[]이유
0A0길이 1, 자체 제외 → 0
1B0”AB” — 공통 없음
2A1”ABA” — “A” 일치
3B2”ABAB” — “AB” 일치
4C0”ABABC” — 없음
5A1”ABABCA” — “A”
6B2”ABABCAB” — “AB”
7A3”ABABCABA” — “ABA”
8B4”ABABCABAB” — “ABAB”

KMP 실패 함수

탐색 과정

불일치 발생 시 텍스트 포인터는 절대 뒤로 가지 않고, 패턴 포인터만 fail[j-1]로 이동합니다.

KMP 탐색

완전한 Python 구현

def kmp_search(text: str, pattern: str) -> list:
    n, m = len(text), len(pattern)
    if m == 0:
        return []

    # 1. 실패 함수 구축
    fail = [0] * m
    j = 0
    for i in range(1, m):
        while j > 0 and pattern[i] != pattern[j]:
            j = fail[j - 1]
        if pattern[i] == pattern[j]:
            j += 1
        fail[i] = j

    # 2. 탐색
    matches = []
    j = 0
    for i in range(n):
        while j > 0 and text[i] != pattern[j]:
            j = fail[j - 1]
        if text[i] == pattern[j]:
            j += 1
        if j == m:
            matches.append(i - m + 1)
            j = fail[j - 1]  # 다음 매칭을 위해 점프

    return matches

text = "ABABDABABCABABC"
pattern = "ABABC"
print(kmp_search(text, pattern))  # [5, 9]

실패 함수 구축 코드와 탐색 코드의 구조가 완전히 동일합니다. 실패 함수 구축은 패턴 자신을 텍스트로 삼아 탐색하는 것과 같기 때문입니다.

복잡도 증명: 왜 O(n)인가

텍스트 탐색 루프에서 i는 0에서 n-1까지 정확히 n번 증가합니다. ji가 증가할 때 최대 1 증가하고, fail[j-1]을 통해서만 감소합니다. 따라서 j의 총 증가량은 ≤ n, 총 감소량도 ≤ n이므로 while 루프 전체 실행 횟수는 O(n)입니다.

응용 1: 문자열 주기성 탐지

문자열 s가 어떤 문자열 t의 반복으로 구성되는지 확인:

def min_period(s: str) -> int:
    n = len(s)
    fail = build_failure(s)
    period = n - fail[n - 1]
    # n이 period의 배수이면 완전한 반복
    if n % period == 0:
        return period
    return n  # 비주기 문자열

def build_failure(s):
    m = len(s)
    fail = [0] * m
    j = 0
    for i in range(1, m):
        while j > 0 and s[i] != s[j]:
            j = fail[j-1]
        if s[i] == s[j]:
            j += 1
        fail[i] = j
    return fail

print(min_period("abababab"))  # 2
print(min_period("abcabcabc")) # 3
print(min_period("abcde"))     # 5 (비주기)

KMP 실패 함수만으로 주기를 O(m)에 구하는 우아한 방법입니다.

응용 2: 다중 패턴 검색

여러 패턴을 동시에 검색할 때 Aho-Corasick(추후 다룰 예정)이 이상적이지만, 패턴이 서로 연결된 경우 KMP를 그대로 활용할 수 있습니다. 패턴들을 구분자 #로 이어 붙여 실패 함수를 구축합니다.

def count_pattern_in_text(text, pattern):
    combined = pattern + "#" + text
    fail = build_failure(combined)
    m = len(pattern)
    return sum(1 for f in fail if f == m)

# "ABA"가 "ABABABA"에 몇 번 나타나는가 (겹침 허용)
print(count_pattern_in_text("ABABABA", "ABA"))  # 3

KMP vs 다른 문자열 검색

알고리즘전처리탐색특징
브루트 포스O(1)O(n·m)구현 단순
KMPO(m)O(n)균일한 성능
Boyer-MooreO(m+σ)O(n/m) ~ O(n·m)평균 빠름, 최악 주의
Rabin-KarpO(m)O(n) 평균다중 패턴에 유리
Z-AlgorithmO(m+n)O(m+n)구현 단순, KMP와 동등

KMP는 패턴이 길고 반복적인 구조를 가질수록 성능 이점이 크고, 최악의 경우에도 항상 O(n+m)을 보장합니다.


지난 글: 비트 조작 (Bit Manipulation): 비트 연산과 마스킹 완전 정복

다음 글: 라빈-카프 알고리즘: 해시 기반 문자열 검색


읽어주셔서 감사합니다. 😊