
문자열 알고리즘은 텍스트 검색, 패턴 매칭, 압축 등 문자열 처리에 특화된 효율적인 알고리즘들입니다.
문자열 처리는 컴퓨터 과학에서 가장 기본적이면서도 중요한 분야입니다.
실생활 응용:
텍스트 검색:
- 구글, 네이버 검색
- Ctrl+F (문서 내 검색)
- 코드 에디터의 찾기
DNA 분석:
- 유전자 서열 매칭
- 유사한 패턴 찾기
텍스트 편집:
- 맞춤법 검사
- 자동 완성
- 표절 탐지
데이터 압축:
- ZIP, RAR
- 중복 패턴 제거
기본 문제: 패턴 매칭
텍스트: "ABCABCDABCDE"
패턴: "ABCD"
목표: 패턴이 텍스트의 어디에 있는가?
답: 인덱스 3 (0부터 시작)
0123456789...
텍스트: ABCABCDABCDE
패턴: ABCD
찾음! (인덱스 3)
1. 순진한 방법 (Naive)
- 기본 접근
- 비교 대상
2. KMP 알고리즘
- 실패 함수
- 불필요한 비교 건너뛰기
3. 라빈-카프 알고리즘
- 해싱 이용
- 다중 패턴 매칭
4. 트라이 (Trie)
- 문자열 집합 저장
- 빠른 검색
순진한 방법(Naive/Brute Force)은 가장 직관적인 패턴 매칭 방법입니다.
핵심 아이디어:
텍스트의 모든 위치에서 패턴과 비교
텍스트: ABCABCDABCDE
패턴: ABCD
위치 0: ABCA vs ABCD → 불일치 (마지막)
위치 1: BCAB vs ABCD → 불일치 (첫 글자)
위치 2: CABC vs ABCD → 불일치 (첫 글자)
위치 3: ABCD vs ABCD → 일치! ✓
...
특징:
장점:
- 구현 간단
- 이해 쉬움
단점:
- 비효율적
- 불필요한 비교 많음
def naive_search(text, pattern):
"""
순진한 문자열 매칭
text: 검색 대상 텍스트
pattern: 찾을 패턴
Returns: 패턴이 나타나는 모든 위치 리스트
시간복잡도: O(nm)
- n: 텍스트 길이
- m: 패턴 길이
- 최악의 경우 모든 위치에서 m번 비교
"""
n = len(text)
m = len(pattern)
positions = []
# 텍스트의 각 위치에서 시도
for i in range(n - m + 1):
# 패턴의 각 문자와 비교
j = 0
while j < m and text[i + j] == pattern[j]:
j += 1
# 모든 문자가 일치하면
if j == m:
positions.append(i)
return positions
# 사용 예시
text = "ABCABCDABCDE"
pattern = "ABCD"
result = naive_search(text, pattern)
print(f"텍스트: {text}")
print(f"패턴: {pattern}")
print(f"발견 위치: {result}")
# 비교 횟수 세기
def naive_search_count(text, pattern):
"""비교 횟수를 세는 버전"""
n = len(text)
m = len(pattern)
positions = []
comparisons = 0
for i in range(n - m + 1):
j = 0
while j < m and text[i + j] == pattern[j]:
comparisons += 1
j += 1
# 불일치한 경우도 비교 횟수 추가
if j < m:
comparisons += 1
if j == m:
positions.append(i)
return positions, comparisons
result, count = naive_search_count(text, pattern)
print(f"\n총 비교 횟수: {count}")
최악의 경우:
텍스트: AAAAAAAAAA (n개)
패턴: AAAAB (m개)
매 위치에서 m-1개는 일치, 마지막만 불일치
→ (n-m+1) × m 번 비교
→ O(nm)
예: n=10, m=5
AAAAAAAAAA
AAAAB (5번 비교)
AAAAB (5번 비교)
AAAAB (5번 비교)
...
6개 위치 × 5번 = 30번 비교
KMP 알고리즘은 불필요한 비교를 건너뛰는 효율적인 패턴 매칭 알고리즘입니다.
핵심 통찰:
순진한 방법의 문제:
텍스트: ABCABCDABCDE
패턴: ABCD
|||X
ABC는 일치, D만 불일치
다음 위치에서 다시 처음부터?
ABCD
X
낭비!
이미 "ABC"가 일치한다는 정보를 알고 있는데
처음부터 다시 비교할 필요 없음!
KMP의 아이디어:
패턴 자체에서 정보 추출
패턴: ABCDABC
"ABC"로 시작하고 "ABC"로 끝남!
→ 불일치 시 3칸 건너뛸 수 있음
이 정보를 "실패 함수"로 미리 계산
실패 함수는 패턴의 각 위치에서 접두사와 접미사의 최대 일치 길이를 저장합니다.
개념:
패턴: ABCDABC
각 위치에서:
A 접두사=접미사 = 없음 → 0
AB 접두사=접미사 = 없음 → 0
ABC 접두사=접미사 = 없음 → 0
ABCD 접두사=접미사 = 없음 → 0
ABCDA 접두사=접미사 = A → 1
ABCDAB 접두사=접미사 = AB → 2
ABCDABC 접두사=접미사 = ABC → 3
실패 함수: [0, 0, 0, 0, 1, 2, 3]
의미:
실패 함수[i] = k 의미:
"pattern[0:k] == pattern[i-k+1:i+1]"
즉, i 위치까지의 부분 문자열에서
길이 k인 접두사와 접미사가 같음
예: ABCDABC (i=6)
실패 함수[6] = 3
→ ABC(접두사) == ABC(접미사)
def compute_failure_function(pattern):
"""
KMP 실패 함수 계산
pattern: 패턴 문자열
Returns: 실패 함수 배열
시간복잡도: O(m)
- m: 패턴 길이
원리:
- 동적 계획법
- 이전 정보를 활용하여 다음 값 계산
"""
m = len(pattern)
failure = [0] * m
# j: 현재 접두사 길이
# i: 현재 확인 중인 위치
j = 0
# i=1부터 시작 (i=0은 항상 0)
for i in range(1, m):
# 불일치 시 j를 줄여가며 확인
while j > 0 and pattern[i] != pattern[j]:
j = failure[j - 1]
# 일치하면
if pattern[i] == pattern[j]:
j += 1
failure[i] = j
# 불일치면 failure[i] = 0 (이미 초기화됨)
return failure
# 사용 예시
pattern = "ABCDABC"
failure = compute_failure_function(pattern)
print(f"패턴: {pattern}")
print("위치: ", " ".join(str(i) for i in range(len(pattern))))
print("실패: ", " ".join(str(f) for f in failure))
# 여러 예시
patterns = ["AAAA", "ABAB", "ABABC", "ABCDABCA"]
print("\n다양한 패턴의 실패 함수:")
for p in patterns:
f = compute_failure_function(p)
print(f"{p:12s} → {f}")
실패 함수 계산 과정:
패턴: ABCDABC
i=0: failure[0] = 0 (초기값)
i=1: pattern[1]='B', pattern[0]='A'
불일치 → failure[1] = 0
i=2: pattern[2]='C', pattern[0]='A'
불일치 → failure[2] = 0
i=3: pattern[3]='D', pattern[0]='A'
불일치 → failure[3] = 0
i=4: pattern[4]='A', pattern[0]='A'
일치! j=1 → failure[4] = 1
i=5: pattern[5]='B', pattern[1]='B'
일치! j=2 → failure[5] = 2
i=6: pattern[6]='C', pattern[2]='C'
일치! j=3 → failure[6] = 3
결과: [0, 0, 0, 0, 1, 2, 3]
def kmp_search(text, pattern):
"""
KMP 문자열 매칭
text: 검색 대상 텍스트
pattern: 찾을 패턴
Returns: 패턴이 나타나는 모든 위치 리스트
시간복잡도: O(n + m)
- n: 텍스트 길이
- m: 패턴 길이
- 실패 함수 계산: O(m)
- 매칭: O(n)
"""
n = len(text)
m = len(pattern)
# 1. 실패 함수 계산
failure = compute_failure_function(pattern)
positions = []
j = 0 # 패턴에서 현재 비교 중인 위치
# 2. 텍스트 순회
for i in range(n):
# 불일치 시 실패 함수 활용
while j > 0 and text[i] != pattern[j]:
j = failure[j - 1] # 건너뛰기!
# 일치하면
if text[i] == pattern[j]:
j += 1
# 패턴 전체 일치
if j == m:
positions.append(i - m + 1)
j = failure[j - 1] # 다음 매칭을 위해
return positions
# 사용 예시
text = "ABCABCDABCDE"
pattern = "ABCD"
result = kmp_search(text, pattern)
print(f"텍스트: {text}")
print(f"패턴: {pattern}")
print(f"발견 위치: {result}")
# 비교 횟수 세기
def kmp_search_count(text, pattern):
"""비교 횟수를 세는 버전"""
n = len(text)
m = len(pattern)
failure = compute_failure_function(pattern)
positions = []
comparisons = 0
j = 0
for i in range(n):
while j > 0 and text[i] != pattern[j]:
j = failure[j - 1]
comparisons += 1
comparisons += 1 # 현재 비교
if text[i] == pattern[j]:
j += 1
if j == m:
positions.append(i - m + 1)
j = failure[j - 1]
return positions, comparisons
result, count = kmp_search_count(text, pattern)
print(f"\n총 비교 횟수: {count}")
print(f"순진한 방법과 비교: {count} vs 더 많음")
KMP 실행 과정:
텍스트: ABCABCDABCDE
패턴: ABCD
실패: [0, 0, 0, 0]
i=0: text[0]='A', pattern[0]='A'
일치, j=1
i=1: text[1]='B', pattern[1]='B'
일치, j=2
i=2: text[2]='C', pattern[2]='C'
일치, j=3
i=3: text[3]='A', pattern[3]='D'
불일치! j=failure[2]=0
text[3]='A', pattern[0]='A'
일치, j=1
i=4: text[4]='B', pattern[1]='B'
일치, j=2
i=5: text[5]='C', pattern[2]='C'
일치, j=3
i=6: text[6]='D', pattern[3]='D'
일치, j=4
매칭 발견! 위치=3
...
핵심: 불일치 시 실패 함수로 건너뛰기!
라빈-카프 알고리즘은 해싱을 이용한 패턴 매칭 알고리즘입니다.
핵심 아이디어:
문자열을 숫자로 변환하여 비교
패턴: "ABC"
해시: hash("ABC") = 123
텍스트를 순회하며:
- 각 부분 문자열의 해시 계산
- 해시가 같으면 실제 문자열 비교
장점:
- 여러 패턴을 동시에 검색 가능
- 평균적으로 빠름
해시 함수:
문자열을 숫자로:
"ABC" → 1×100 + 2×10 + 3×1 = 123
일반적으로:
hash = s[0]×d^(m-1) + s[1]×d^(m-2) + ... + s[m-1]×d^0
d: 진법 (보통 256, 문자 종류 수)
m: 패턴 길이
롤링 해시는 이전 해시값을 재사용하여 다음 해시를 빠르게 계산하는 기법입니다.
원리:
텍스트: "ABCDE"
패턴 길이: 3
hash("ABC") = 1×100 + 2×10 + 3×1 = 123
hash("BCD")를 처음부터 계산?
= 2×100 + 3×10 + 4×1 = 234
롤링 해시:
hash("BCD") = (hash("ABC") - 1×100) × 10 + 4
= (123 - 100) × 10 + 4
= 234
이전 값 재사용!
공식:
hash_new = (hash_old - text[old_pos]×d^(m-1)) × d + text[new_pos]
old_pos: 이전 시작 위치
new_pos: 새로운 끝 위치
def rabin_karp(text, pattern, d=256, q=101):
"""
라빈-카프 문자열 매칭
text: 검색 대상 텍스트
pattern: 찾을 패턴
d: 진법 (문자 종류 수)
q: 소수 (해시 충돌 감소용)
Returns: 패턴이 나타나는 모든 위치 리스트
시간복잡도:
- 평균: O(n + m)
- 최악: O(nm) (해시 충돌 많을 때)
특징:
- 해싱 이용
- 다중 패턴 매칭에 유리
- 롤링 해시로 최적화
"""
n = len(text)
m = len(pattern)
positions = []
# d^(m-1) % q 미리 계산
h = pow(d, m - 1, q)
# 패턴과 텍스트 첫 윈도우의 해시 계산
pattern_hash = 0
text_hash = 0
for i in range(m): # ord(): 문자를 컴퓨터가 이해하는 숫자(아스키 코드)로 바꿈 (예: 'A' -> 65)
pattern_hash = (d * pattern_hash + ord(pattern[i])) % q
text_hash = (d * text_hash + ord(text[i])) % q
# 텍스트 순회
for i in range(n - m + 1):
# 해시가 같으면
if pattern_hash == text_hash:
# 실제 문자열 비교 (해시 충돌 대비)
if text[i:i+m] == pattern:
positions.append(i)
# 다음 윈도우의 해시 계산 (롤링)
if i < n - m:
# 이전 문자 제거, 새 문자 추가
text_hash = (d * (text_hash - ord(text[i]) * h) +
ord(text[i + m])) % q
# 음수 방지
if text_hash < 0:
text_hash += q
return positions
# 사용 예시
text = "ABCABCDABCDE"
pattern = "ABCD"
result = rabin_karp(text, pattern)
print(f"텍스트: {text}")
print(f"패턴: {pattern}")
print(f"발견 위치: {result}")
# 다중 패턴 매칭
def rabin_karp_multiple(text, patterns, d=256, q=101):
"""
여러 패턴을 동시에 검색
라빈-카프의 장점 활용!
"""
# 각 패턴의 해시 계산
pattern_hashes = {}
for pattern in patterns:
h = 0
for char in pattern:
h = (d * h + ord(char)) % q
pattern_hashes[h] = pattern
results = {p: [] for p in patterns}
# 패턴 길이 (모두 같다고 가정)
m = len(patterns[0])
n = len(text)
# 텍스트 해시 계산 및 매칭
h_multiplier = pow(d, m - 1, q)
text_hash = 0
for i in range(m):
text_hash = (d * text_hash + ord(text[i])) % q
for i in range(n - m + 1):
# 해시가 어떤 패턴과 일치하는지 확인
if text_hash in pattern_hashes:
pattern = pattern_hashes[text_hash]
if text[i:i+m] == pattern:
results[pattern].append(i)
# 롤링 해시
if i < n - m:
text_hash = (d * (text_hash - ord(text[i]) * h_multiplier) +
ord(text[i + m])) % q
if text_hash < 0:
text_hash += q
return results
# 다중 패턴 예시
text = "ABCABCDABCDE"
patterns = ["ABC", "BCD", "CDE"]
results = rabin_karp_multiple(text, patterns)
print("\n\n다중 패턴 매칭:")
for pattern, positions in results.items():
print(f"{pattern}: {positions}")
트라이(Trie)는 문자열 집합을 효율적으로 저장하고 검색하는 트리 자료구조입니다.
핵심 아이디어:
공통 접두사를 공유
문자열 집합: ["cat", "car", "dog"]
트리 구조:
root
/ \
c d
| |
a o
/ \ |
t r g
"ca"를 공유 → 메모리 절약
특징:
장점:
- 검색: O(m) (m: 문자열 길이)
- 접두사 검색 빠름
- 자동 완성에 유용
단점:
- 메모리 많이 사용
- 각 노드마다 자식 배열
class TrieNode:
"""
트라이의 노드
Attributes:
children: 자식 노드 딕셔너리 {문자: TrieNode}
is_end: 이 노드가 단어의 끝인가?
"""
def __init__(self):
self.children = {}
self.is_end = False
class Trie:
"""
트라이 자료구조
문자열 집합을 효율적으로 저장
"""
def __init__(self):
"""루트 노드로 시작"""
self.root = TrieNode()
def insert(self, word):
"""
단어 삽입
word: 삽입할 단어
시간복잡도: O(m)
- m: 단어 길이
동작:
1. 루트부터 시작
2. 각 문자마다 자식 노드 확인
3. 없으면 생성, 있으면 이동
4. 마지막 노드에 is_end 표시
"""
node = self.root
for char in word:
# 자식 노드가 없으면 생성
if char not in node.children:
node.children[char] = TrieNode()
# 다음 노드로 이동
node = node.children[char]
# 단어의 끝 표시
node.is_end = True
def search(self, word):
"""
단어 검색
word: 검색할 단어
Returns: bool: 단어가 존재하면 True
시간복잡도: O(m)
"""
node = self.root
for char in word:
# 자식 노드가 없으면 단어 없음
if char not in node.children:
return False
node = node.children[char]
# 단어의 끝이어야 함
return node.is_end
def starts_with(self, prefix):
"""
접두사로 시작하는 단어가 있는가?
prefix: 접두사
Returns: bool: 접두사를 가진 단어가 있으면 True
시간복잡도: O(m)
자동 완성에 유용!
"""
node = self.root
for char in prefix:
if char not in node.children:
return False
node = node.children[char]
# 접두사까지 도달했으면 True (is_end 확인 불필요)
return True
def find_all_with_prefix(self, prefix):
"""
접두사로 시작하는 모든 단어 찾기
prefix: 접두사
Returns: 접두사를 가진 모든 단어 리스트
자동 완성 기능!
"""
node = self.root
# 접두사까지 이동
for char in prefix:
if char not in node.children:
return []
node = node.children[char]
# 이 노드부터 DFS로 모든 단어 수집
results = []
self._collect_words(node, prefix, results)
return results
def _collect_words(self, node, current_word, results):
"""
DFS로 모든 단어 수집 (헬퍼 함수)
"""
# 단어의 끝이면 추가
if node.is_end:
results.append(current_word)
# 모든 자식 탐색
for char, child in node.children.items():
self._collect_words(child, current_word + char, results)
# 사용 예시
trie = Trie()
# 단어 삽입
words = ["cat", "car", "card", "care", "dog", "dodge", "door"]
for word in words:
trie.insert(word)
print("삽입된 단어:", words)
# 검색
print("\n검색:")
print(f"'car' 존재? {trie.search('car')}")
print(f"'can' 존재? {trie.search('can')}")
# 접두사 확인
print("\n접두사 확인:")
print(f"'ca'로 시작? {trie.starts_with('ca')}")
print(f"'do'로 시작? {trie.starts_with('do')}")
print(f"'da'로 시작? {trie.starts_with('da')}")
# 자동 완성
print("\n자동 완성:")
prefix = "car"
suggestions = trie.find_all_with_prefix(prefix)
print(f"'{prefix}'로 시작하는 단어: {suggestions}")
prefix = "do"
suggestions = trie.find_all_with_prefix(prefix)
print(f"'{prefix}'로 시작하는 단어: {suggestions}")
트라이 시각화:
단어: cat, car, card, care, dog, dodge, door
root
/ \
c d
| |
a o
| / \
r g o
/|\ | |
d e t e r
| | |
(card)(dodge)(door)
각 노드의 is_end:
- t: True (cat)
- r: True (car)
- d: True (card)
- e: True (care)
- g: True (dog)
- e: True (dodge)
- r: True (door)
알고리즘 선택
# 단순 패턴 매칭 → KMP
kmp_search(text, pattern) # O(n+m)
# 여러 패턴 동시 검색 → 라빈-카프
rabin_karp_multiple(text, patterns)
# 사전, 자동 완성 → 트라이
trie = Trie()
trie.find_all_with_prefix("car")
성능 비교
알고리즘 시간 공간 특징
--------------------------------------------------
순진한 O(nm) O(1) 간단
KMP O(n+m) O(m) 단일 패턴 최적
라빈-카프 O(n+m)평균 O(1) 다중 패턴
트라이 O(m) O(총길이) 접두사 검색
실무 고려사항
# 짧은 텍스트 → 순진한 방법도 OK
if len(text) < 1000:
naive_search(text, pattern)
# 긴 텍스트 → KMP
else:
kmp_search(text, pattern)
# 여러 패턴 → 라빈-카프 또는 Aho-Corasick
rabin_karp_multiple(text, patterns)
# 자동 완성, 사전 → 트라이
trie = Trie()
패턴 매칭
순진한 방법:
- 모든 위치에서 비교
- O(nm)
- 간단하지만 느림
KMP:
- 실패 함수로 최적화
- O(n+m)
- 단일 패턴에 최적
라빈-카프:
- 해싱 이용
- O(n+m) 평균
- 다중 패턴에 유리
트라이
문자열 집합 저장:
- 공통 접두사 공유
- 검색: O(m)
- 자동 완성, 사전
- 메모리 많이 사용
시간복잡도 비교
연산 순진한 KMP 라빈-카프 트라이
--------------------------------------------------------
패턴 매칭 O(nm) O(n+m) O(n+m)평균 -
다중 패턴 O(nmk) O(nmk) O(n+mk) O(n+m)
접두사 검색 - - - O(m)
자동 완성 - - - O(m+결과)
[07-01] 시간 복잡도 (Time Complexity)
이전 글: [06-08] 그래프 알고리즘
다음 글: [07-01] 시간 복잡도
시리즈: P1. Computer Science