목차7개 항목
출처와 참고
「KMP : 문자열 검색 알고리즘」을 바탕으로 재구성한 노트다. 라빈-카프 절은 fru1tworld/cs-basic 모의 면접 질문 목록의 「자료구조」 질문이 묻는 개념으로 보강했다. 오토마타 매칭과 보이어-무어의 이동 규칙은 고려사이버대학교 「컴퓨터 알고리즘」(2024-2학기) 강의 내용으로 보강했다.
KMP란
KMP(Knuth-Morris-Pratt)는 길이 N인 텍스트에서 길이 M인 패턴이 나오는 모든 위치를 O(N + M)에 찾는 알고리즘이다.
단순 비교는 텍스트의 시작 위치마다 패턴을 처음부터 맞춰 보므로 최악 O(NM)이다. AAAA…AB에서 AAAB를 찾는 경우처럼 거의 다 맞다가 끝에서 틀리는 일이 반복되면 이 최악이 나온다.
핵심 아이디어
불일치가 났다는 것은 그 직전까지는 일치했다는 정보이기도 하다. 일치한 구간의 접두사와 접미사가 같다면, 패턴을 그 겹치는 길이만큼 당겨 놓고 이어서 비교하면 된다. 그래서 텍스트 포인터는 절대 뒤로 가지 않는다.
텍스트 A B A B A B A C
패턴 A B A B A C ← 6번째에서 불일치. 일치한 "ABABA"의 접두사=접미사 "ABA"(길이 3)
패턴 A B A B A C ← 패턴을 당겨 4번째 글자부터 비교를 이어 간다실패 함수 (pi 배열)
pi[i]는 패턴의 0..i 부분에서 접두사이면서 접미사인 가장 긴 진부분 문자열의 길이다. 자기 자신 전체는 제외한다.
| i | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|---|
| 패턴 | A | B | A | B | A | B | A | C |
| pi | 0 | 0 | 1 | 2 | 3 | 4 | 5 | 0 |
pi 배열은 패턴을 자기 자신에게 KMP로 맞춰 보는 과정으로 O(M)에 만든다.
def build_pi(p):
pi = [0] * len(p)
j = 0 # 지금까지 일치한 접두사 길이
for i in range(1, len(p)):
while j > 0 and p[i] != p[j]:
j = pi[j - 1] # 더 짧은 접두사로 물러난다
if p[i] == p[j]:
j += 1
pi[i] = j
return pi검색
def kmp_search(text, p):
pi, j, found = build_pi(p), 0, []
for i, ch in enumerate(text):
while j > 0 and ch != p[j]:
j = pi[j - 1] # 텍스트 i는 그대로, 패턴만 점프
if ch == p[j]:
if j == len(p) - 1:
found.append(i - len(p) + 1)
j = pi[j] # 겹치는 다음 일치도 찾는다
else:
j += 1
return foundwhile이 들어 있어도 전체가 선형인 이유는, j가 한 번에 최대 1씩만 늘고 while은 j를 줄이기만 하므로 줄어드는 총량이 늘어난 총량을 넘을 수 없기 때문이다(분할 상환 분석).
라빈-카프: 롤링 해시로 비교하기
라빈-카프(Rabin-Karp)는 글자를 하나씩 비교하는 대신 길이 M인 창의 해시값을 패턴의 해시값과 비교한다. 창을 한 칸 옮길 때 해시를 처음부터 다시 계산하지 않고, 빠지는 글자와 들어오는 글자만 반영해 O(1)에 갱신하는 것이 핵심이다(롤링 해시).
H(s[i..i+M-1]) = s[i]·b^(M-1) + s[i+1]·b^(M-2) + … + s[i+M-1] (mod q)
한 칸 이동: H' = (H − s[i]·b^(M-1))·b + s[i+M] (mod q)def rabin_karp(t, p, b=256, q=1_000_000_007):
n, m = len(t), len(p)
if m > n:
return []
high = pow(b, m - 1, q) # 빠지는 글자에 곱할 b^(M-1)
hp = ht = 0
for i in range(m):
hp = (hp * b + ord(p[i])) % q
ht = (ht * b + ord(t[i])) % q
found = []
for i in range(n - m + 1):
if hp == ht and t[i:i + m] == p: # 해시가 같으면 글자로 한 번 더 확인
found.append(i)
if i + m < n:
ht = ((ht - ord(t[i]) * high) * b + ord(t[i + m])) % q
return found- 해시가 같아도 다른 문자열일 수 있으므로(충돌) 일치 후보만 글자로 확인한다. 충돌이 드물면 평균 O(N + M), 충돌이 몰리면 최악 O(NM)이다. 모듈러 q를 큰 소수로 두고, 두 개의 서로 다른 (b, q)로 해시를 이중으로 쓰면 충돌이 사실상 사라진다.
- 패턴이 여러 개라도 길이가 같으면 패턴 해시들을 집합에 넣고 창 해시를 집합에서 찾아 한 번의 훑기로 모두 찾는다. 표절 검사, 중복 문서 탐지가 이 방식이다.
- 문자열 비교를 해시 비교로 바꾸는 발상이라 「길이 L인 부분 문자열 중 두 번 나오는 것이 있나」 같은 문제에서 이분 탐색과 함께 쓴다.
문자열 집합을 저장하고 접두사로 찾는 구조는 트라이(기초 자료구조), 여러 패턴을 길이와 무관하게 동시에 찾는 것은 트라이에 실패 함수를 붙인 아호-코라식이다.
다른 문자열 검색과 비교
| 알고리즘 | 시간 | 특징 |
|---|---|---|
| 단순 비교 | 최악 O(NM) | 구현이 쉽다. 짧은 패턴·무작위 텍스트에서는 충분히 빠르다 |
| 유한 오토마타 | 전처리 O(M·|Σ|) + 검색 O(N) | 「지금까지 패턴 앞부분을 몇 글자 맞췄나」를 상태로, 글자마다 다음 상태를 표로 미리 만든다. 텍스트 한 글자에 표 조회 한 번이지만 알파벳 Σ가 크면 표가 커진다 |
| KMP | O(N + M) | 최악도 선형. pi 배열 자체가 문자열의 반복 구조(주기)를 구하는 문제에도 쓰인다 |
| 라빈-카프 | 평균 O(N + M) | 롤링 해시로 비교. 여러 패턴을 한꺼번에 찾을 때 유리 |
| 보이어-무어 | 최선 O(N/M), 평균 준선형 | 패턴 끝부터 비교해 크게 건너뛴다. 실무 텍스트 검색 도구가 변형을 많이 쓴다 |
| 아호-코라식 | O(N + M 합 + 매치 수) | KMP의 실패 함수를 트라이로 확장해 여러 패턴을 동시에 찾는다 |
KMP의 실패 함수는 오토마타의 상태 전이표를 「틀리면 어디로 돌아가나」 하나로 압축한 것이다. 그래서 알파벳 크기와 무관하게 O(M) 전처리로 같은 선형 검색을 얻는다.
보이어-무어는 패턴을 텍스트에 대 놓고 오른쪽 끝 글자부터 비교한다. 불일치가 나면 두 규칙 중 더 멀리 가는 쪽으로 패턴을 민다.
| 규칙 | 미는 거리 |
|---|---|
| 나쁜 문자(bad character) | 불일치한 텍스트 글자가 패턴에서 마지막으로 나오는 자리를 그 글자에 맞춘다. 패턴에 없는 글자면 패턴 전체를 그 글자 너머로 넘긴다 |
| 좋은 접미사(good suffix) | 이미 맞은 뒷부분이 패턴 안에 또 나오면 그 자리를 맞춘다 |
패턴에 없는 글자를 자주 만나는 자연어 텍스트에서는 한 번에 M칸씩 뛰어 텍스트의 일부 글자는 아예 보지 않는다. 패턴이 길수록 빨라지는 이유다. 나쁜 문자 규칙만 쓴 단순화판(호스풀, Horspool)도 많이 쓴다.
정리
- 불일치 정보로 패턴을 당기고, 텍스트 포인터는 되돌리지 않는다.
- pi[i] = 패턴 앞부분
0..i의 「접두사 = 접미사」 최대 길이. - pi 계산 O(M) + 검색 O(N). 실전 언어의
find·in은 대개 충분히 빠르므로, 코딩 테스트에서 최악 입력이 걸릴 때 KMP를 꺼낸다.
관련 노트: 코딩 테스트 접근법, 빅오 표기법과 시간 복잡도, 완전 탐색과 백트래킹, 해시 테이블, 기초 자료구조(트라이)