검색 알고리즘 완벽 가이드
결론 요약
- Fuzzy Search: 오타와 철자 변형을 허용하는 "검색 기법"으로, Levenshtein 거리로 유사도를 측정합니다
- Full Text Index: 대량의 텍스트를 빠르게 검색하기 위한 "색인 시스템"으로, Fuzzy Search를 포함할 수 있습니다
- Trie: 문자열 검색에 특화된 트리 구조로, O(m) 시간에 검색이 가능합니다 (m = 문자열 길이)
- Aho-Corasick: 여러 패턴을 동시에 찾는 알고리즘으로, O(n + m + z) 시간복잡도를 가집니다 (n = 텍스트 길이, m = 패턴 총 길이, z = 매칭 개수)
- 벡터 DB 알고리즘: 고차원 벡터 검색을 위한 ANN 알고리즘으로, HNSW는 빠르지만 메모리 많이 사용, IVF는 메모리 효율적이지만 정확도 낮음
1. Fuzzy Search vs Full Text Index
핵심 차이점
Fuzzy Search와 Full Text Index는 서로 다른 계층의 개념입니다:
- Fuzzy Search: 근사 매칭(Approximate Matching) "기법"
- Full Text Index: 텍스트 검색을 위한 "색인 시스템"
Unraveling Search Puzzle: Keyword vs. Fuzzy vs. Full-Text vs. Semantic (opens in a new tab)에 따르면:
"The first 3 search types (keyword, fuzzy, fulltext) are 'lexical search' as they rely on matching words — keyword search means finding exact match, while fuzzy search can allow some typos and different word forms, while fulltext can further enable scenarios like proximity searches."
Fuzzy Search 상세
정의: 패턴과 "대략적으로" 일치하는 문자열을 찾는 기법
핵심 원리: Levenshtein Distance (편집 거리)
두 문자열 간 최소 편집 연산 횟수를 측정합니다:
- 삽입 (Insertion): "aple" → "apple" (p 삽입)
- 삭제 (Deletion): "appple" → "apple" (p 삭제)
- 대체 (Substitution): "apqle" → "apple" (q를 p로 대체)
예시:
- "apple"과 "aple"의 거리 = 1 (p 삽입)
- "apple"과 "orange"의 거리 = 5 (완전히 다름)
시간복잡도: O(m × n) - m, n은 두 문자열의 길이
Elastic의 Fuzzy Search 가이드 (opens in a new tab)에서:
"Lucene's Levenshtein distance implementation is state of the art and quite fast, but it is still much slower than a plain match query, with runtime growing with the number of unique terms in the index."
Full Text Index 상세
정의: 대량의 텍스트에서 빠른 검색을 위한 역색인(Inverted Index) 구조
핵심 구조:
문서 1: "The quick brown fox"
문서 2: "The lazy dog"
역색인:
"the" → [문서1, 문서2]
"quick" → [문서1]
"brown" → [문서1]
"fox" → [문서1]
"lazy" → [문서2]
"dog" → [문서2]시간복잡도: O(1) ~ O(log n) - 해시 테이블 또는 B-Tree 사용
Meilisearch의 Fuzzy Search 가이드 (opens in a new tab)에서:
"Full-text search with a separate search index could be the answer if you have more extensive searchable databases. Full-text search may be lightning fast, even on massive volumes of data, and may allow many advanced techniques, including fuzzy search."
비교표
| 특성 | Fuzzy Search | Full Text Index |
|---|---|---|
| 개념 | 검색 기법 | 데이터 구조/시스템 |
| 목적 | 오타 허용 검색 | 빠른 텍스트 검색 |
| 시간복잡도 | O(m × n) | O(1) ~ O(log n) |
| 메모리 | 적음 | 많음 (색인 저장) |
| 정확도 | 근사 매칭 | 정확 매칭 (+ 옵션으로 Fuzzy) |
| 사용 예 | 검색창 자동완성 | 검색 엔진, DB 검색 |
2. Trie (트라이) 자료구조
결론: 왜 사용하는가?
Trie는 문자열 집합에서 O(m) 시간에 검색할 수 있는 최적의 자료구조입니다 (m = 검색 문자열 길이). 이진탐색트리의 O(m × log n)보다 훨씬 빠릅니다.
한국어 위키백과 - 트라이 (opens in a new tab)에서:
"트라이(trie)는 컴퓨터 과학에서 탐색 트리의 일종으로, 동적 집합이나 연관 배열을 저장하는 데 사용되는 트리 자료 구조입니다."
구조 설명
Trie는 **접두사 트리(Prefix Tree)**로, 각 노드가 문자를 나타냅니다.
예시: "apple", "app", "application" 삽입
시간복잡도 증명 (중학생 수준)
삽입 연산 증명
명제: 길이 m인 문자열을 Trie에 삽입하는 시간복잡도는 O(m)이다.
증명:
-
문자열 "apple" (길이 5)를 삽입한다고 가정
-
각 문자마다 다음 연산을 수행:
- 현재 노드에 해당 문자의 자식이 있는지 확인: O(1) - 배열 또는 해시맵 사용
- 없으면 새 노드 생성: O(1)
- 해당 자식 노드로 이동: O(1)
-
문자 개수 = m = 5
- 'a': 1번 연산
- 'p': 1번 연산
- 'p': 1번 연산
- 'l': 1번 연산
- 'e': 1번 연산
- 총 5번 = m번 연산
-
각 연산이 O(1)이고 m번 반복하므로: O(m × 1) = O(m) ∎
검색 연산 증명
명제: 길이 m인 문자열을 Trie에서 검색하는 시간복잡도는 O(m)이다.
증명:
-
문자열 "apple"을 검색한다고 가정
-
각 문자마다:
- 현재 노드의 자식 중 해당 문자가 있는지 확인: O(1)
- 있으면 이동, 없으면 검색 실패로 종료
-
최악의 경우 모든 문자를 확인: m번
-
O(m × 1) = O(m) ∎
이진탐색트리(BST)와 비교
BST에서 문자열 검색:
- n개의 문자열 중 하나를 찾기 위해: O(log n)번의 비교
- 각 비교마다 문자열 전체를 비교: O(m)
- 총 시간복잡도: O(m × log n)
Trie에서 문자열 검색:
- 문자열 길이만큼만 탐색: O(m)
- n(문자열 개수)과 무관!
구체적 예시:
- n = 1,000,000 (백만 개 문자열)
- m = 10 (평균 문자열 길이)
| 자료구조 | 시간복잡도 | 실제 연산 횟수 |
|---|---|---|
| BST | O(m × log n) | 10 × log₂(1,000,000) ≈ 10 × 20 = 200 |
| Trie | O(m) | 10 |
Trie가 20배 빠릅니다!
구현 예시 (Java)
import java.util.HashMap;
import java.util.Map;
/**
* Trie 노드 클래스
* 각 노드는 하나의 문자를 나타내며, 자식 노드들과 단어 종료 여부를 저장합니다.
*/
class TrieNode {
// 자식 노드들을 저장하는 HashMap
// Key: 문자, Value: 해당 문자로 이어지는 TrieNode
Map<Character, TrieNode> children;
// 이 노드가 단어의 끝인지 표시하는 플래그
// true면 루트부터 이 노드까지의 경로가 완전한 단어를 나타냄
boolean isEnd;
/**
* TrieNode 생성자
* 초기화: 빈 HashMap과 isEnd = false
*/
public TrieNode() {
this.children = new HashMap<>();
this.isEnd = false;
}
}
/**
* Trie 자료구조 클래스
* 문자열 집합을 효율적으로 저장하고 검색하는 트리 구조
*
* 시간복잡도:
* - 삽입: O(m), m = 문자열 길이
* - 검색: O(m)
* - 접두사 검색: O(m)
*/
class Trie {
// Trie의 루트 노드 (빈 문자열을 나타냄)
private TrieNode root;
/**
* Trie 생성자
* 빈 루트 노드로 초기화
*/
public Trie() {
this.root = new TrieNode();
}
/**
* 단어를 Trie에 삽입합니다.
* 시간복잡도: O(m), m은 단어의 길이
*
* 동작 과정:
* 1. 루트 노드에서 시작
* 2. 단어의 각 문자를 순회하며:
* - 현재 노드의 자식 중 해당 문자가 없으면 새 노드 생성
* - 해당 문자의 자식 노드로 이동
* 3. 마지막 노드의 isEnd를 true로 설정
*
* @param word 삽입할 단어
*/
public void insert(String word) {
// 1단계: 루트 노드에서 시작
TrieNode node = root;
// 2단계: 단어의 각 문자를 순회
for (int i = 0; i < word.length(); i++) {
char c = word.charAt(i);
// 2-1단계: 현재 노드의 자식 중 해당 문자가 있는지 확인
// 없으면 새로운 TrieNode를 생성하여 추가 (O(1) 연산)
if (!node.children.containsKey(c)) {
node.children.put(c, new TrieNode());
}
// 2-2단계: 해당 문자의 자식 노드로 이동
node = node.children.get(c);
}
// 3단계: 마지막 노드를 단어의 끝으로 표시
// 이렇게 하면 "app"와 "apple"을 구별할 수 있음
node.isEnd = true;
}
/**
* 단어가 Trie에 존재하는지 검색합니다.
* 시간복잡도: O(m), m은 단어의 길이
*
* 동작 과정:
* 1. 루트 노드에서 시작
* 2. 단어의 각 문자를 순회하며:
* - 현재 노드의 자식 중 해당 문자가 없으면 false 반환
* - 해당 문자의 자식 노드로 이동
* 3. 마지막 노드의 isEnd 값을 반환
*
* @param word 검색할 단어
* @return 단어가 존재하면 true, 아니면 false
*/
public boolean search(String word) {
// 1단계: 루트 노드에서 시작
TrieNode node = root;
// 2단계: 단어의 각 문자를 순회
for (int i = 0; i < word.length(); i++) {
char c = word.charAt(i);
// 2-1단계: 해당 문자가 자식 노드에 없으면 검색 실패
if (!node.children.containsKey(c)) {
return false;
}
// 2-2단계: 해당 문자의 자식 노드로 이동
node = node.children.get(c);
}
// 3단계: 마지막 노드가 단어의 끝인지 확인
// "app"를 검색할 때 "apple"이 있어도 "app"의 isEnd가 false면 false 반환
return node.isEnd;
}
/**
* 주어진 접두사로 시작하는 단어가 Trie에 존재하는지 확인합니다.
* 시간복잡도: O(m), m은 접두사의 길이
*
* 동작 과정:
* 1. 루트 노드에서 시작
* 2. 접두사의 각 문자를 순회하며:
* - 현재 노드의 자식 중 해당 문자가 없으면 false 반환
* - 해당 문자의 자식 노드로 이동
* 3. 모든 문자를 찾으면 true 반환 (isEnd 확인 불필요)
*
* @param prefix 검색할 접두사
* @return 접두사로 시작하는 단어가 존재하면 true, 아니면 false
*/
public boolean startsWith(String prefix) {
// 1단계: 루트 노드에서 시작
TrieNode node = root;
// 2단계: 접두사의 각 문자를 순회
for (int i = 0; i < prefix.length(); i++) {
char c = prefix.charAt(i);
// 2-1단계: 해당 문자가 자식 노드에 없으면 검색 실패
if (!node.children.containsKey(c)) {
return false;
}
// 2-2단계: 해당 문자의 자식 노드로 이동
node = node.children.get(c);
}
// 3단계: 모든 문자를 찾았으면 true 반환
// search()와 달리 isEnd 확인하지 않음 (접두사만 확인)
return true;
}
/**
* 사용 예시
*/
public static void main(String[] args) {
Trie trie = new Trie();
// 단어 삽입
trie.insert("apple");
trie.insert("app");
trie.insert("application");
// 검색 테스트
System.out.println(trie.search("app")); // true - "app"는 완전한 단어로 존재
System.out.println(trie.search("appl")); // false - "appl"은 완전한 단어가 아님
System.out.println(trie.startsWith("appl")); // true - "appl"로 시작하는 단어 존재 (apple, application)
}
}TwPower의 Trie 개념 설명 (opens in a new tab)에서:
"m이 탐색할 문자열의 길이일 때 시간복잡도는 O(m)입니다. 생성시 시간복잡도: O(M*L) - 모든 문자열들을 넣어야하니 M개에 대해서 트라이 자료구조에 넣는건 가장 긴 문자열 길이만큼 걸리니 L만큼 걸립니다."
장단점
장점:
- ✅ 검색 속도가 매우 빠름: O(m)
- ✅ 접두사 검색에 최적화
- ✅ 자동완성 기능 구현에 이상적
- ✅ 사전순 정렬이 자동으로 됨
단점:
- ❌ 메모리 사용량이 큼: 각 노드마다 포인터 배열 저장
- ❌ 문자 집합이 크면(예: 유니코드) 공간 낭비 심함
- ❌ 캐시 지역성이 낮아 실제 성능이 이론보다 낮을 수 있음
Brunch의 Trie 설명 (opens in a new tab)에서:
"빠르게 탐색이 가능하다는 장점이 있지만 각 노드에서 자식들에 대한 포인터들을 배열로 모두 저장하고 있다는 점에서 저장 공간의 크기가 크다는 단점도 있습니다."
3. 아호코라식 (Aho-Corasick) 알고리즘
결론: 언제 사용하는가?
아호코라식은 여러 패턴을 동시에 검색할 때 사용합니다. 텍스트를 단 한 번만 순회하면서 모든 패턴을 O(n + m + z) 시간에 찾을 수 있습니다.
Wikipedia - Aho-Corasick algorithm (opens in a new tab)에서:
"The Aho-Corasick algorithm is a string-searching algorithm invented by Alfred V. Aho and Margaret J. Corasick in 1975. It is a dictionary-matching algorithm that locates elements of a finite set of strings within an input text."
문제 상황
시나리오: 텍스트 "she sells seashells by the seashore"에서 ["she", "he", "sell", "sea"] 찾기
단순한 방법의 문제:
- 각 패턴마다 텍스트 전체를 검색: O(패턴 개수 × 텍스트 길이)
- 패턴이 1000개면 1000배 느림!
아호코라식의 해결책:
- 텍스트를 단 한 번만 순회
- 모든 패턴을 동시에 검색
Trie + Failure Link 구조
1단계: Trie 구성
패턴 ["he", "she", "his", "hers"]로 Trie 생성
2단계: Failure Link 추가
Failure Link = "매칭 실패 시 이동할 노드"
Failure Link 규칙:
노드 v의 Failure Link는 "v까지의 경로가 나타내는 문자열의 가장 긴 proper suffix"를 나타내는 노드입니다.
예시:
- "she"의 'e' 노드:
- 경로 문자열: "she"
- Proper suffixes: "he", "e"
- "he"로 시작하는 패턴 존재 → "he"의 'e' 노드로 링크!
시간복잡도 증명
명제: 아호코라식의 시간복잡도는 O(n + m + z)이다.
- n = 텍스트 길이
- m = 모든 패턴의 총 길이
- z = 매칭된 패턴 개수
증명:
1. 전처리 단계 (Trie + Failure Link 구성)
Trie 구성: O(m)
- 각 패턴의 문자를 한 번씩 삽입
- 패턴 ["he", "she", "his", "hers"]의 총 길이 = 2+3+3+4 = 12
- 12번 연산 = O(m)
Failure Link 구성: O(m)
- BFS로 모든 노드 방문: 노드 개수 ≤ m
- 각 노드마다 상수 시간 연산
- O(m)
전처리 총 시간: O(m + m) = O(m)
2. 검색 단계
텍스트를 한 글자씩 순회: O(n)
- 각 문자마다:
- 다음 상태로 전이: O(1)
- 실패 시 Failure Link 따라감: 최악의 경우에도 amortized O(1)
왜 Amortized O(1)인가?
각 문자를 처리할 때:
- 성공: 깊이 +1
- 실패: 깊이 감소 (Failure Link 따라감)
깊이는 최대 n까지만 증가 가능하므로, 총 깊이 감소도 최대 n번입니다.
- 전체 n개 문자 처리: n번 증가 가능
- 전체 Failure Link 이동: n번 감소 가능
- 총 2n번 연산 = O(n)
매칭 출력: O(z)
- 찾은 패턴마다 출력
검색 총 시간: O(n) + O(z) = O(n + z)
전체 시간복잡도: O(m) + O(n + z) = O(n + m + z) ∎
동작 예시
텍스트 "she"를 패턴 ["he", "she"]로 검색:
| 단계 | 문자 | 현재 상태 | 동작 | 결과 |
|---|---|---|---|---|
| 1 | s | Root | s 전이 | s 노드 |
| 2 | h | s | h 전이 | sh 노드 |
| 3 | e | sh | e 전이 | she 노드 (매칭!) |
| 3-1 | - | she | Failure Link → he | he 노드 (매칭!) |
한 번의 순회로 "she"와 "he"를 모두 찾았습니다!
CP-Algorithms의 Aho-Corasick 설명 (opens in a new tab)에서:
"The complexity of the algorithm is linear in the length of the strings plus the length of the searched text plus the number of output matches."
구현 예시 (Java)
import java.util.*;
/**
* 매칭 결과를 저장하는 클래스
* 찾은 패턴과 시작 위치를 포함
*/
class Match {
String pattern; // 매칭된 패턴 문자열
int position; // 텍스트에서의 시작 위치
public Match(String pattern, int position) {
this.pattern = pattern;
this.position = position;
}
@Override
public String toString() {
return String.format("패턴 '%s' found at position %d", pattern, position);
}
}
/**
* 아호코라식(Aho-Corasick) 알고리즘 구현 클래스
* 여러 패턴을 동시에 검색하는 문자열 매칭 알고리즘
*
* 시간복잡도:
* - 전처리 (패턴 추가 + Failure Link 구성): O(m), m = 모든 패턴의 총 길이
* - 검색: O(n + z), n = 텍스트 길이, z = 매칭 개수
*
* 핵심 구조:
* 1. goto: 상태 전이 함수 (Trie 구조)
* 2. fail: 실패 링크 (매칭 실패 시 이동할 상태)
* 3. output: 각 상태에서 매칭되는 패턴들
*/
class AhoCorasick {
// Trie 구조: 상태 -> {문자: 다음_상태}
// 예: goto.get(0).get('a') = 1 (상태 0에서 'a'를 읽으면 상태 1로 이동)
private Map<Integer, Map<Character, Integer>> gotoFunction;
// Failure Link: 상태 -> 실패_시_이동할_상태
// 예: fail.get(5) = 2 (상태 5에서 매칭 실패 시 상태 2로 이동)
private Map<Integer, Integer> failFunction;
// Output 함수: 상태 -> [매칭된_패턴_인덱스들]
// 예: output.get(3) = [0, 2] (상태 3에서 패턴 0번과 2번이 매칭됨)
private Map<Integer, List<Integer>> outputFunction;
// 현재 사용 중인 상태 번호 (0부터 시작, 루트는 0)
private int stateCount;
/**
* AhoCorasick 생성자
* 초기 상태: 루트 노드(상태 0)만 존재
*/
public AhoCorasick() {
this.gotoFunction = new HashMap<>();
this.failFunction = new HashMap<>();
this.outputFunction = new HashMap<>();
this.stateCount = 0;
// 루트 상태(0) 초기화
gotoFunction.put(0, new HashMap<>());
outputFunction.put(0, new ArrayList<>());
}
/**
* 패턴을 Trie에 추가합니다.
* 시간복잡도: O(k), k는 패턴의 길이
*
* 동작 과정:
* 1. 루트 상태(0)에서 시작
* 2. 패턴의 각 문자를 순회하며:
* - 현재 상태에서 해당 문자로 가는 전이가 없으면 새 상태 생성
* - 해당 문자의 다음 상태로 이동
* 3. 마지막 상태에 패턴 인덱스를 output에 추가
*
* @param pattern 추가할 패턴 문자열
* @param index 패턴의 인덱스 (검색 결과에서 어떤 패턴인지 식별용)
*/
public void addPattern(String pattern, int index) {
// 1단계: 루트 상태에서 시작
int state = 0;
// 2단계: 패턴의 각 문자를 순회
for (int i = 0; i < pattern.length(); i++) {
char c = pattern.charAt(i);
// 2-1단계: 현재 상태에서 해당 문자로 가는 전이가 있는지 확인
Map<Character, Integer> transitions = gotoFunction.get(state);
if (!transitions.containsKey(c)) {
// 2-2단계: 없으면 새로운 상태 생성
stateCount++;
int newState = stateCount;
// 새 상태의 전이 함수와 output 초기화
transitions.put(c, newState);
gotoFunction.put(newState, new HashMap<>());
outputFunction.put(newState, new ArrayList<>());
}
// 2-3단계: 다음 상태로 이동
state = transitions.get(c);
}
// 3단계: 마지막 상태에 패턴 인덱스 추가
// 이 상태에 도달하면 해당 패턴이 매칭되었다는 의미
outputFunction.get(state).add(index);
}
/**
* Failure Link를 BFS로 구성합니다.
* 시간복잡도: O(m), m은 모든 패턴의 총 길이
*
* 동작 과정:
* 1. Level 1 (루트의 직접 자식들): 모두 루트(0)로 실패 링크 설정
* 2. Level 2 이상: BFS로 각 노드의 실패 링크 계산
* - 부모의 실패 링크를 따라가며 매칭되는 상태 찾기
* - 찾으면 그곳으로 링크, 못 찾으면 루트로 링크
* 3. Output 상속: 실패 링크가 가리키는 상태의 output도 현재 상태에 추가
*
* Failure Link의 의미:
* "현재까지 읽은 문자열의 가장 긴 proper suffix가 나타내는 상태"
*/
public void buildFailureLinks() {
Queue<Integer> queue = new LinkedList<>();
// 1단계: Level 1 초기화 (루트의 직접 자식들)
// 루트의 모든 자식은 실패 시 루트로 돌아감
Map<Character, Integer> rootTransitions = gotoFunction.get(0);
for (int state : rootTransitions.values()) {
failFunction.put(state, 0); // 루트로 실패 링크 설정
queue.offer(state); // BFS 큐에 추가
}
// 2단계: Level 2 이상 처리 (BFS)
while (!queue.isEmpty()) {
int currentState = queue.poll();
// 현재 상태의 모든 전이를 확인
Map<Character, Integer> transitions = gotoFunction.get(currentState);
for (Map.Entry<Character, Integer> entry : transitions.entrySet()) {
char c = entry.getKey();
int nextState = entry.getValue();
// 다음 레벨 탐색을 위해 큐에 추가
queue.offer(nextState);
// 2-1단계: 실패 링크 계산
// 현재 상태의 실패 링크에서 시작하여
// 해당 문자로 갈 수 있는 상태를 찾을 때까지 실패 링크를 따라감
int failState = failFunction.get(currentState);
while (failState != 0 && !gotoFunction.get(failState).containsKey(c)) {
failState = failFunction.get(failState);
}
// 2-2단계: 실패 링크 설정
if (gotoFunction.get(failState).containsKey(c)) {
// 해당 문자로 갈 수 있는 상태를 찾음
failFunction.put(nextState, gotoFunction.get(failState).get(c));
} else {
// 찾지 못하면 루트로
failFunction.put(nextState, 0);
}
// 2-3단계: Output 상속
// 실패 링크가 가리키는 상태의 output도 현재 상태에 추가
// 이렇게 하면 "she"를 찾을 때 "he"도 동시에 찾을 수 있음
List<Integer> inherited = outputFunction.get(failFunction.get(nextState));
outputFunction.get(nextState).addAll(inherited);
}
}
}
/**
* 텍스트에서 모든 패턴을 검색합니다.
* 시간복잡도: O(n + z), n은 텍스트 길이, z는 매칭 개수
*
* 동작 과정:
* 1. 루트 상태에서 시작
* 2. 텍스트의 각 문자를 순회하며:
* - 현재 상태에서 해당 문자로 가는 전이가 있으면 이동
* - 없으면 실패 링크를 따라가며 전이 가능한 상태 찾기
* 3. 각 상태에서 output에 있는 모든 패턴을 결과에 추가
*
* Amortized O(1) 분석:
* - 각 문자마다 상태 깊이가 최대 1 증가 (총 n번)
* - 실패 링크는 깊이를 감소시킴 (총 최대 n번)
* - 따라서 전체 O(2n) = O(n)
*
* @param text 검색할 텍스트
* @param patterns 패턴 리스트 (인덱스로 패턴 문자열 찾기 위함)
* @return 매칭 결과 리스트 (패턴과 위치)
*/
public List<Match> search(String text, List<String> patterns) {
List<Match> results = new ArrayList<>();
// 1단계: 루트 상태에서 시작
int state = 0;
// 2단계: 텍스트의 각 문자를 순회
for (int i = 0; i < text.length(); i++) {
char c = text.charAt(i);
// 2-1단계: 다음 상태로 전이 시도
// 현재 상태에서 해당 문자로 가는 전이가 없으면
// 실패 링크를 따라가며 전이 가능한 상태를 찾음
while (state != 0 && !gotoFunction.get(state).containsKey(c)) {
state = failFunction.get(state); // 실패 링크 따라가기
}
// 2-2단계: 전이 가능한 상태로 이동
if (gotoFunction.get(state).containsKey(c)) {
state = gotoFunction.get(state).get(c);
}
// else: state = 0 (루트에 머무름)
// 3단계: 현재 상태에서 매칭되는 모든 패턴 출력
List<Integer> matchedPatterns = outputFunction.get(state);
for (int patternIndex : matchedPatterns) {
String pattern = patterns.get(patternIndex);
// 패턴의 시작 위치 계산: 현재 위치 - 패턴 길이 + 1
int startPos = i - pattern.length() + 1;
results.add(new Match(pattern, startPos));
}
}
return results;
}
/**
* 사용 예시
*/
public static void main(String[] args) {
AhoCorasick ac = new AhoCorasick();
// 1단계: 패턴 추가
List<String> patterns = Arrays.asList("he", "she", "his", "hers");
for (int i = 0; i < patterns.size(); i++) {
ac.addPattern(patterns.get(i), i);
}
// 2단계: Failure Link 구성
ac.buildFailureLinks();
// 3단계: 검색 수행
String text = "she sells seashells by the seashore";
List<Match> matches = ac.search(text, patterns);
// 4단계: 결과 출력
for (Match match : matches) {
System.out.println(match);
}
// 출력:
// 패턴 'she' found at position 0
// 패턴 'he' found at position 1
// 패턴 'she' found at position 10
// 패턴 'he' found at position 11
// 패턴 'she' found at position 27
// 패턴 'he' found at position 28
}
}실제 응용 사례
- 안티바이러스 소프트웨어: 파일에서 수천 개의 바이러스 시그니처를 동시에 검색
- 네트워크 침입 탐지 시스템(IDS): 패킷에서 악성 패턴 탐지
- 텍스트 에디터: 다중 단어 검색 및 하이라이트
- DNA 서열 분석: 여러 유전자 서열을 동시에 찾기
GeeksforGeeks - Aho-Corasick Algorithm (opens in a new tab)에서:
"The preprocessing consists of three stages: Go-to Stage defines transitions between states based on pattern characters, Failure Stage defines transitions when a mismatch occurs, and Output Stage stores the indexes of all patterns that end at a given state."
4. 벡터 데이터베이스 알고리즘
결론: ANN의 필요성
벡터 데이터베이스는 고차원 벡터 간 유사도 검색을 위해 Approximate Nearest Neighbor (ANN) 알고리즘을 사용합니다. 정확도를 약간 포기하고 속도를 극적으로 향상시킵니다.
Milvus Blog - IVF vs HNSW (opens in a new tab)에서:
"ANN algorithms trade slight accuracy for much better speed compared to brute-force k-Nearest Neighbors (KNN) searches, which become impractical with high-dimensional data."
벡터 거리 측정 방법
1. 유클리드 거리 (Euclidean Distance)
중학교 피타고라스 정리의 확장!
2차원: 두 점 A(x₁, y₁), B(x₂, y₂) 사이의 거리
예시: A(0, 0), B(3, 4)
n차원으로 확장:
Java 구현:
/**
* 벡터 유틸리티 클래스
* 벡터 간 거리와 유사도를 계산하는 정적 메서드 제공
*/
class VectorUtils {
/**
* 두 벡터 간의 유클리드 거리를 계산합니다.
* 시간복잡도: O(n), n은 벡터의 차원
*
* 수학 공식:
* d = √((x₁-y₁)² + (x₂-y₂)² + ... + (xₙ-yₙ)²)
*
* 동작 과정:
* 1. 각 차원에서 두 벡터의 차이를 계산
* 2. 차이의 제곱을 모두 합산
* 3. 합산 결과의 제곱근을 계산하여 반환
*
* @param a 첫 번째 벡터
* @param b 두 번째 벡터
* @return 유클리드 거리 값
* @throws IllegalArgumentException 벡터 길이가 다를 경우
*/
public static double euclideanDistance(double[] a, double[] b) {
// 예외 처리: 벡터 길이가 같은지 확인
if (a.length != b.length) {
throw new IllegalArgumentException(
"벡터의 차원이 다릅니다: " + a.length + " vs " + b.length
);
}
// 1단계: 각 차원에서의 차이의 제곱을 합산
double sumOfSquares = 0.0;
for (int i = 0; i < a.length; i++) {
// 각 차원에서 차이 계산
double diff = a[i] - b[i];
// 차이의 제곱을 누적 합산
// 예: a[0]=0, b[0]=3이면 diff=3, diff²=9
sumOfSquares += diff * diff;
}
// 2단계: 제곱합의 제곱근 계산
// √(sumOfSquares)
return Math.sqrt(sumOfSquares);
}
/**
* 사용 예시: 유클리드 거리 계산
*/
public static void exampleEuclideanDistance() {
// 2차원 벡터 예시
double[] a = {0, 0};
double[] b = {3, 4};
double distance = euclideanDistance(a, b);
System.out.println("유클리드 거리: " + distance); // 5.0
// 수식 검증:
// √((3-0)² + (4-0)²) = √(9 + 16) = √25 = 5
}
}2. 코사인 유사도 (Cosine Similarity)
벡터의 방향이 얼마나 비슷한지 측정
공식:
- = 내적 =
- = 벡터의 크기 =
예시: A = [3, 4], B = [6, 8]
A·B = 3×6 + 4×8 = 18 + 32 = 50
|A| = √(9 + 16) = 5
|B| = √(36 + 64) = 10
cos(θ) = 50 / (5 × 10) = 1cos(θ) = 1 → 완전히 같은 방향!
시각화:
Java 구현:
/**
* 벡터 유틸리티 클래스 (계속)
*/
class VectorUtils {
/**
* 두 벡터 간의 코사인 유사도를 계산합니다.
* 시간복잡도: O(n), n은 벡터의 차원
*
* 수학 공식:
* cos(θ) = (A·B) / (|A| × |B|)
* - A·B: 내적 (dot product)
* - |A|, |B|: 벡터의 크기 (magnitude)
*
* 반환값 범위:
* - 1: 완전히 같은 방향
* - 0: 직교 (90도)
* - -1: 완전히 반대 방향
*
* 동작 과정:
* 1. 두 벡터의 내적 계산
* 2. 각 벡터의 크기(길이) 계산
* 3. 내적을 두 크기의 곱으로 나눔
*
* @param a 첫 번째 벡터
* @param b 두 번째 벡터
* @return 코사인 유사도 (-1 ~ 1)
* @throws IllegalArgumentException 벡터 길이가 다르거나 영벡터일 경우
*/
public static double cosineSimilarity(double[] a, double[] b) {
// 예외 처리: 벡터 길이가 같은지 확인
if (a.length != b.length) {
throw new IllegalArgumentException(
"벡터의 차원이 다릅니다: " + a.length + " vs " + b.length
);
}
// 1단계: 내적(dot product) 계산
// A·B = a₁b₁ + a₂b₂ + ... + aₙbₙ
double dotProduct = 0.0;
for (int i = 0; i < a.length; i++) {
dotProduct += a[i] * b[i];
}
// 2단계: 각 벡터의 크기(magnitude) 계산
// |A| = √(a₁² + a₂² + ... + aₙ²)
double magnitudeA = 0.0;
double magnitudeB = 0.0;
for (int i = 0; i < a.length; i++) {
magnitudeA += a[i] * a[i]; // a[i]의 제곱을 누적
magnitudeB += b[i] * b[i]; // b[i]의 제곱을 누적
}
// 제곱합의 제곱근을 계산하여 크기를 얻음
magnitudeA = Math.sqrt(magnitudeA);
magnitudeB = Math.sqrt(magnitudeB);
// 예외 처리: 영벡터 확인 (0으로 나누기 방지)
if (magnitudeA == 0.0 || magnitudeB == 0.0) {
throw new IllegalArgumentException(
"영벡터는 코사인 유사도를 계산할 수 없습니다"
);
}
// 3단계: 코사인 유사도 계산
// cos(θ) = (A·B) / (|A| × |B|)
return dotProduct / (magnitudeA * magnitudeB);
}
/**
* 사용 예시: 코사인 유사도 계산
*/
public static void exampleCosineSimilarity() {
// 같은 방향의 벡터들 (크기만 다름)
double[] a = {3, 4};
double[] b = {6, 8};
double similarity = cosineSimilarity(a, b);
System.out.println("코사인 유사도: " + similarity); // 1.0 (완전히 같은 방향)
// 수식 검증:
// A·B = 3×6 + 4×8 = 18 + 32 = 50
// |A| = √(3² + 4²) = √(9 + 16) = √25 = 5
// |B| = √(6² + 8²) = √(36 + 64) = √100 = 10
// cos(θ) = 50 / (5 × 10) = 50 / 50 = 1.0
// 직교하는 벡터들
double[] c = {1, 0};
double[] d = {0, 1};
double orthogonal = cosineSimilarity(c, d);
System.out.println("직교 벡터 유사도: " + orthogonal); // 0.0 (90도)
}
/**
* 전체 사용 예시
*/
public static void main(String[] args) {
System.out.println("=== 유클리드 거리 예시 ===");
exampleEuclideanDistance();
System.out.println("\n=== 코사인 유사도 예시 ===");
exampleCosineSimilarity();
}
}차원의 저주 (Curse of Dimensionality)
문제: 차원이 높아질수록 모든 점들이 비슷한 거리에 있게 됩니다.
증명 (직관적 설명):
2차원 예시:
- 가까운 점: 거리 1
- 먼 점: 거리 10
- 비율: 10:1 → 명확한 차이
1000차원 예시:
- 랜덤 벡터들의 평균 거리: √1000 ≈ 31.6
- 가까운 점: 31.5
- 먼 점: 31.7
- 비율: 1.006:1 → 거의 차이 없음!
수학적 설명:
고차원 공간에서 랜덤 벡터 간 거리의 분산이 0에 수렴합니다.
시각적 비유:
이것이 ANN 알고리즘이 필요한 이유입니다!
HNSW (Hierarchical Navigable Small World)
핵심 아이디어
고속도로 → 국도 → 골목길 개념의 계층적 그래프
Pinecone - HNSW 설명 (opens in a new tab)에서:
"HNSW graphs are among the top-performing indexes for vector similarity search, producing state-of-the-art performance with super fast search speeds and fantastic recall."
계층 구조
검색 과정
예시: 쿼리 Q와 가장 가까운 이웃 찾기
-
Layer 3 (최상위):
- 진입점 A에서 시작
- 이웃 중 Q에 가장 가까운 F로 이동
- 더 이상 가까워질 수 없으면 Layer 2로 하강
-
Layer 2:
- F를 시작점으로
- 그리디하게 Q에 가까운 이웃으로 이동
- 지역 최소값 I에 도달 → Layer 1로 하강
-
Layer 1:
- I를 시작점으로
- 계속 탐색...
-
Layer 0:
- 최종 k개 이웃 찾기
시간복잡도 분석
검색 시간복잡도: O(log n)
증명 (직관적):
각 층에서:
- 평균 M개의 연결 (M ≈ 16~32)
- 각 층마다 검색 공간이 절반으로 줄어듦
층의 개수: log n
총 연산: O(M × log n) = O(log n) (M은 상수)
구체적 예시:
| 데이터 크기 | Brute-force | HNSW |
|---|---|---|
| 1,000 | 1,000 | log₂(1000) ≈ 10 |
| 1,000,000 | 1,000,000 | log₂(1,000,000) ≈ 20 |
| 1,000,000,000 | 1,000,000,000 | log₂(1,000,000,000) ≈ 30 |
10억 개 벡터에서 30번 비교로 검색!
TigerData - HNSW 설명 (opens in a new tab)에서:
"HNSW combines a hierarchy of layers with networks of navigable small worlds, allowing for scalable, high-performant search."
장단점
장점:
- ✅ 매우 빠른 검색 속도: O(log n)
- ✅ 높은 정확도 (recall > 95%)
- ✅ 동적 삽입/삭제 가능
단점:
- ❌ 메모리 많이 사용: O(n × M)
- ❌ 인덱스 구축 시간이 김
- ❌ 필터링 성능이 IVF보다 낮음
Neon Blog - HNSW with pgvector (opens in a new tab)에서:
"HNSW is significantly faster than traditional methods like IVF and provides a high recall rate. However, it requires more memory and longer build times."
IVF (Inverted File Index)
핵심 아이디어
K-means 클러스터링으로 검색 공간 축소
PingCAP - IVF vs HNSW vs PQ (opens in a new tab)에서:
"IVF divides the data space into clusters using algorithms like k-means, and during a query, only the relevant clusters are searched, significantly speeding up the process."
K-means 클러스터링
알고리즘 동작:
-
초기화: k개의 중심점 랜덤 선택
-
할당: 각 벡터를 가장 가까운 중심점에 배정
-
갱신: 각 클러스터의 평균을 새 중심점으로
-
반복: 중심점이 변하지 않을 때까지
시각화:
IVF 검색 과정
전처리:
- 전체 n개 벡터를 k개 클러스터로 분할 (K-means)
- 각 클러스터의 중심점(centroid) 저장
검색:
- 쿼리 벡터 Q와 가장 가까운 nprobe개 중심점 선택
- 그 클러스터들 내부에서만 검색
- 나머지 (k - nprobe)개 클러스터는 무시!
예시:
- n = 1,000,000 (백만 개 벡터)
- k = 1,000 (천 개 클러스터)
- nprobe = 10 (10개 클러스터 검색)
1. 1000개 중심점 중 10개 선택: 1,000번 거리 계산
2. 10개 클러스터 내부 검색: 10 × (1,000,000 / 1,000) = 10,000번
총: 11,000번 (Brute-force는 1,000,000번!)약 90배 빠릅니다!
시간복잡도 분석
검색 시간복잡도: O(k + (n/k) × nprobe)
증명:
-
중심점 검색: 쿼리와 k개 중심점 간 거리 계산 = O(k)
-
클러스터 내 검색:
- 각 클러스터 평균 크기: n/k
- nprobe개 클러스터 검색: (n/k) × nprobe
- O((n/k) × nprobe)
총: O(k + (n/k) × nprobe)
최적화: k = √n으로 설정
예시 (n = 1,000,000, k = 1,000, nprobe = 10):
Brute-force O(1,000,000)보다 90배 빠름!
장단점
장점:
- ✅ 메모리 효율적: O(n)
- ✅ 빠른 인덱스 구축
- ✅ 필터링 검색에 유리
단점:
- ❌ HNSW보다 정확도 낮음
- ❌ 동적 업데이트 어려움 (재구축 필요)
- ❌ 클러스터 분포가 불균형하면 성능 저하
Milvus Blog - IVF 상세 (opens in a new tab)에서:
"Compared with graph-based indexes like HNSW, IVF delivers faster index builds, lower memory usage. However, IVF indexes often need to be entirely reconstructed to accommodate new data or remove old."
HNSW vs IVF 비교
| 특성 | HNSW | IVF |
|---|---|---|
| 시간복잡도 | O(log n) | O(√n) with k=√n |
| 공간복잡도 | O(n × M) | O(n) |
| 검색 속도 | 매우 빠름 | 빠름 |
| 정확도 (Recall) | 95%+ | 85-95% |
| 인덱스 구축 | 느림 | 빠름 |
| 메모리 사용 | 많음 (수GB) | 적음 |
| 동적 업데이트 | 쉬움 | 어려움 (재구축) |
| 필터링 검색 | 느림 | 빠름 |
| 추천 용도 | 실시간 검색, 높은 정확도 | 대용량, 배치 검색 |
Medium - Vector Search Comparison (opens in a new tab)에서:
"IVF is best for speed and clustered data but may lack precision; HNSW provides high accuracy and recall but consumes more memory and compute."
5. 알고리즘 선택 가이드
의사결정 흐름도
상황별 추천
텍스트 검색
1. 검색창 자동완성 (예: 구글 검색)
- 추천: Trie
- 이유: 접두사 검색에 최적화, O(m) 검색 속도
2. 오타 교정 (예: "Did you mean...")
- 추천: Fuzzy Search (Levenshtein Distance)
- 이유: 유사한 단어 찾기, 편집 거리 계산
3. 문서 검색 엔진 (예: Elasticsearch)
- 추천: Full Text Index + Fuzzy Search
- 이유: 대량 텍스트 빠른 검색 + 오타 허용
4. 바이러스 탐지, IDS (예: Snort)
- 추천: 아호코라식
- 이유: 수천 개 패턴을 동시에 검색, O(n) 단일 순회
벡터 검색
5. 이미지 유사도 검색 (예: Pinterest)
- 추천: HNSW
- 이유: 실시간 응답 필요, 높은 정확도
6. 추천 시스템 (예: Netflix)
- 데이터 크기에 따라:
- 소규모(~10만): HNSW
- 대규모(100만+): IVF (배치 업데이트 가능)
7. 의미 검색 (예: ChatGPT의 RAG)
- 추천: HNSW
- 이유: 실시간 응답, 높은 정확도 필요
8. 대용량 로그 분석
- 추천: IVF
- 이유: 메모리 제약, 배치 처리 가능
출처
Fuzzy Search & Full Text Index
-
Unraveling Search Puzzle: Keyword vs. Fuzzy vs. Full-Text vs. Semantic (opens in a new tab)
"The first 3 search types (keyword, fuzzy, fulltext) are 'lexical search' as they rely on matching words"
-
Elastic - How to Use Fuzzy Searches (opens in a new tab)
"Lucene's Levenshtein distance implementation is state of the art and quite fast"
-
Meilisearch - Fuzzy Search Guide (opens in a new tab)
"Full-text search may be lightning fast, even on massive volumes of data"
-
Wikipedia - Approximate String Matching (opens in a new tab)
Trie
-
한국어 위키백과 - 트라이 (opens in a new tab)
"트라이는 탐색 트리의 일종으로, 동적 집합이나 연관 배열을 저장하는 데 사용되는 트리 자료 구조"
-
TwPower - 트라이 개념과 기본 문제 (opens in a new tab)
"생성시 시간복잡도: O(M*L), 탐색시 시간복잡도: O(L)"
-
Brunch - 트라이 자료구조 (opens in a new tab)
"빠르게 탐색이 가능하다는 장점이 있지만 저장 공간의 크기가 크다는 단점"
Aho-Corasick
-
Wikipedia - Aho-Corasick algorithm (opens in a new tab)
"A dictionary-matching algorithm that locates elements of a finite set of strings within an input text"
-
CP-Algorithms - Aho-Corasick (opens in a new tab)
"The complexity is linear in the length of the strings plus the length of the searched text plus the number of output matches"
-
GeeksforGeeks - Aho-Corasick Algorithm (opens in a new tab)
"Three stages: Go-to Stage, Failure Stage, and Output Stage"
-
Medium - The Essence of Aho-Corasick Algorithm (opens in a new tab)
Vector Database Algorithms
-
Pinecone - HNSW 설명 (opens in a new tab)
"HNSW graphs are among the top-performing indexes for vector similarity search"
-
Milvus Blog - IVF vs HNSW (opens in a new tab)
"ANN algorithms trade slight accuracy for much better speed compared to brute-force KNN"
-
PingCAP - IVF vs HNSW vs PQ (opens in a new tab)
"IVF is best for speed and clustered data; HNSW provides high accuracy and recall"
-
TigerData - Vector Database Basics: HNSW (opens in a new tab)
"HNSW combines a hierarchy of layers with networks of navigable small worlds"
-
Neon Blog - Understanding Vector Search with pgvector (opens in a new tab)
"HNSW is significantly faster than traditional methods like IVF and provides a high recall rate"
-
Redis Blog - How HNSW Algorithms Improve Search (opens in a new tab)
-
The Data Quarry - Vector Databases: Not All Indexes Are Created Equal (opens in a new tab)
마치며
이 가이드에서는 다양한 검색 알고리즘의 원리, 시간복잡도 증명, 구현 방법을 중학생도 이해할 수 있는 수준으로 설명했습니다. 각 알고리즘은 특정 상황에 최적화되어 있으므로, 문제의 특성에 맞는 알고리즘을 선택하는 것이 중요합니다.
핵심 요약:
- 텍스트 접두사 검색 → Trie
- 여러 패턴 동시 검색 → 아호코라식
- 벡터 검색 (속도 우선) → HNSW
- 벡터 검색 (메모리 우선) → IVF