문자열 매칭 — KMP 알고리즘과 실패 함수의 원리
"ABABCABABD"에서 "ABABD"를 찾을 때, 불일치가 발생했다고 처음부터 다시 비교해야 할까요?
개념 정의
문자열 매칭은 텍스트(text)에서 패턴(pattern)이 등장하는 위치를 찾는 문제 입니다. 텍스트 길이를 N, 패턴 길이를 M이라 하면, 가장 단순한 방법은 O(N × M)이지만 KMP는 이를 O(N + M)으로 줄입니다.
왜 필요한가
- **텍스트 에디터 **: Ctrl+F 검색 기능
- ** 로그 분석 **: 특정 패턴의 에러 메시지 검색
- **DNA 서열 분석 **: 특정 유전자 패턴 탐색
- ** 네트워크 보안 **: 패킷에서 악성 패턴 탐지 (IDS/IPS)
브루트포스의 문제
// 브루트포스 — O(N × M)
public int bruteForce(String text, String pattern) {
int n = text.length(), m = pattern.length();
for (int i = 0; i <= n - m; i++) {
int j = 0;
while (j < m && text.charAt(i + j) == pattern.charAt(j)) {
j++;
}
if (j == m) return i; // 매칭 성공
}
return -1;
}
불일치가 발생하면 텍스트의 다음 위치로 이동하고 패턴의 처음부터 다시 비교합니다. 이전에 비교했던 정보를 전혀 활용하지 못하는 것이 비효율의 원인입니다.
동작 원리 — 실패 함수(Pi 배열)
KMP의 핵심은 ** 실패 함수(failure function)**입니다. pi[i]는 패턴의 0~i 부분 문자열에서 ** 접두사(prefix)와 접미사(suffix)가 일치하는 최대 길이 **를 저장합니다.
예시
패턴: A B A B D
인덱스: 0 1 2 3 4
pi[0] = 0 "A" → 접두사=접미사 없음
pi[1] = 0 "AB" → 없음
pi[2] = 1 "ABA" → "A" = "A" (길이 1)
pi[3] = 2 "ABAB" → "AB" = "AB" (길이 2)
pi[4] = 0 "ABABD" → 없음
pi = [0, 0, 1, 2, 0]
실패 함수의 의미
불일치가 발생했을 때, ** 패턴을 어디까지 되돌려야 하는지** 알려줍니다. pi[j-1]이 가리키는 위치부터 비교를 재개하면 됩니다.
텍스트: A B A B C A B A B D
패턴: A B A B D
↑ 불일치! (j=4)
pi[3] = 2이므로, 패턴의 인덱스 2부터 비교 재개
→ 이미 "AB"가 매칭되었다는 정보를 활용
텍스트: A B A B C A B A B D
패턴: A B A B D
↑ j=2부터 시작
코드 예제 — Java
실패 함수 구성
// 실패 함수(pi 배열) 구성 — O(M)
private int[] buildFailureFunction(String pattern) {
int m = pattern.length();
int[] pi = new int[m];
int len = 0; // 이전까지 일치한 접두사/접미사 길이
int i = 1;
while (i < m) {
if (pattern.charAt(i) == pattern.charAt(len)) {
len++;
pi[i] = len;
i++;
} else {
if (len > 0) {
len = pi[len - 1]; // 이전 실패 함수 값으로 점프
} else {
pi[i] = 0;
i++;
}
}
}
return pi;
}
len = pi[len - 1]이 핵심입니다. 불일치 시 처음으로 돌아가지 않고, 이전에 매칭된 정보를 활용해 적절한 위치로 점프합니다.
KMP 매칭
public List<Integer> kmpSearch(String text, String pattern) {
List<Integer> result = new ArrayList<>();
int n = text.length(), m = pattern.length();
int[] pi = buildFailureFunction(pattern);
int i = 0; // 텍스트 포인터
int j = 0; // 패턴 포인터
while (i < n) {
if (text.charAt(i) == pattern.charAt(j)) {
i++;
j++;
}
if (j == m) {
result.add(i - j); // 매칭 위치 기록
j = pi[j - 1]; // 다음 매칭을 위해 점프
} else if (i < n && text.charAt(i) != pattern.charAt(j)) {
if (j > 0) {
j = pi[j - 1]; // 실패 함수 활용
} else {
i++;
}
}
}
return result;
}
시간 복잡도 분석
- 실패 함수 구성: O(M)
- 매칭: O(N) — i는 항상 증가하고, j는 감소할 수 있지만 총 감소량이 증가량을 넘지 않습니다
- 전체: O(N + M)
Java String.indexOf()와의 비교
Java의 String.indexOf()는 기본적으로 브루트포스 방식입니다. 그런데도 실무에서 충분히 빠른 이유가 있습니다.
// Java 내부 — 실제로는 네이티브 메서드로 최적화
public int indexOf(String str) {
// OpenJDK: 기본적으로 단순 비교
// 하지만 JIT 컴파일러가 SIMD 명령어로 최적화할 수 있음
}
- 대부분의 실제 문자열은 최악 케이스가 아닙니다 (패턴이 "AAAA...AB" 같지 않음)
- JVM의 JIT 컴파일러가 하드웨어 수준에서 최적화합니다
- 짧은 패턴에서는 브루트포스의 상수 팩터가 작아 KMP보다 빠를 수 있습니다
KMP가 진가를 발휘하는 것은 ** 최악 케이스가 보장되어야 할 때 **입니다. 보안 관련 패턴 매칭이나 매우 긴 텍스트 처리에서 중요합니다.
다른 문자열 매칭 알고리즘
| 알고리즘 | 시간 복잡도 | 특징 |
|---|---|---|
| 브루트포스 | O(N × M) | 단순, 짧은 패턴에 효율적 |
| KMP | O(N + M) | 최악 보장, 스트리밍에 적합 |
| Boyer-Moore | O(N/M) 최선 | 긴 패턴에 효율적, 실무에서 많이 사용 |
| Rabin-Karp | O(N + M) 평균 | 다중 패턴 매칭에 적합 |
백엔드/실무 연결
로그 검색
대량의 로그 파일에서 특정 에러 패턴을 검색할 때, KMP 계열 알고리즘이 사용됩니다. grep은 내부적으로 Boyer-Moore와 비슷한 알고리즘을 사용합니다.
네트워크 침입 탐지(IDS)
네트워크 패킷을 실시간으로 스캔하면서 악성 패턴을 탐지하는 시스템에서, 최악 시간이 보장되는 KMP가 유리합니다. 공격자가 의도적으로 최악 케이스를 만들 수 있기 때문입니다.
스트리밍 매칭
KMP는 텍스트를 한 번만 순회하므로 ** 스트리밍 환경 **에 적합합니다. 네트워크 패킷이 순서대로 도착할 때, 도착하는 대로 매칭을 진행할 수 있습니다.
주의할 점
실패 함수(pi 배열) 구성에서 off-by-one 에러
pi 배열의 인덱스와 패턴 인덱스의 관계를 혼동하면 매칭 위치가 한 칸씩 어긋납니다. pi[i]는 패턴의 0~i 부분 문자열에서 접두사와 접미사가 일치하는 최대 길이입니다.
Java의 String.indexOf()가 KMP가 아닌 것을 모르는 실수
OpenJDK의 String.indexOf()는 기본적으로 브루트포스입니다. 대량 텍스트에서 빈번한 매칭이 필요하면 직접 KMP를 구현하거나 라이브러리를 사용해야 합니다.
정리
- KMP 는 실패 함수(pi 배열)를 이용해 불일치 시 되돌아갈 위치를 미리 계산합니다
pi[i]는 패턴의 0~i 부분 문자열에서 접두사=접미사인 최대 길이입니다- 시간 복잡도 O(N + M)으로 브루트포스의 O(N × M)을 크게 개선합니다
- Java의
String.indexOf()는 브루트포스지만 JVM 최적화로 대부분의 경우 충분히 빠릅니다 - 최악 케이스 보장이 필요한 보안/스트리밍 환경에서 KMP의 가치가 빛납니다