아호-코라식
여러 패턴을 한 번의 본문 순회로 찾기.
후보 접미사의 압축에서 매칭의 완전성까지.
0. 동기: 같은 본문을 반복해서 읽지 않기
K개의 패턴을 따로 탐색하면 본문을 K번 읽게 된다. KMP를 각각 적용해도 총 이다. 아호-코라식은 패턴들의 공통 접두사를 Trie로 합치고, 이미 읽은 본문의 유효한 접미사를 실패 링크로 재사용한다. 고정 크기 알파벳에서는 전처리와 전체 출력이 이다. M은 패턴 길이의 합, N은 본문 길이, Z는 실제 출력 개수이다.
패턴 목록 과 본문 T가 주어진다. 각 패턴이 본문의 어느 구간과 같은지 모두 찾는 것이 목표이다. 이 페이지는 빈 패턴을 제외하고, 겹치는 등장과 같은 문자열을 가진 서로 다른 패턴 ID를 모두 센다.
아래 예제에서 she와 he는 같은 위치 3에서 끝난다. 하나를 발견했다고 다른 하나를 버리면 안 된다. hers는 두 구간과 겹치지만 별개의 정답이다.
접두사, 접미사, 부분 문자열
접두사(prefix): 앞에서 연속으로 취한 문자열. hers의 접두사는 .
접미사(suffix): 뒤에서 연속으로 취한 문자열. hers의 접미사는 .
proper suffix: 자기 자신보다 짧은 접미사. 여기서는 빈 문자열 도 포함한다.
부분 문자열(substring): 임의의 연속 구간. er는 부분 문자열이지만 접두사도 접미사도 아니다. 문자를 건너뛰는 부분 수열과 구별한다.
필요한 배경은 문자열의 인덱스, 배열, 트리, 큐이다. BFS는 큐에서 먼저 들어온 노드를 먼저 꺼내 자식을 뒤에 넣는 순회이며, 트리에서 깊이가 작은 노드를 먼저 방문한다. KMP를 몰라도 아래 정의와 증명만으로 읽을 수 있다.
공유하는 것은 문자열 전체가 아니라 접두사
he를 넣으면 → h → he가 생긴다. hers를 넣을 때 → h → he는 재사용하고, her와 hers만 새로 만든다. 같은 노드에서 같은 문자로 나가는 간선은 하나뿐이다. 노드 이름은 설명용 문자열이며, 구현에서는 정수 번호만 저장한다.
terminal은 “패턴의 끝”이지 “자식이 없는 노드”가 아니다. he는 자식 her가 있지만 terminal이다. 반대로 her는 유효한 접두사이지만 등록된 패턴이 아니므로 terminal이 아니다.
현재 상태는 지금까지 읽은 본문 전체가 아니다
ushe를 읽은 뒤 그 접미사 중 Trie에 있는 것은 이다. 가장 긴 she만 현재 상태로 보관한다. 더 짧은 후보 he는 실패 링크로 복원할 수 있다. 따라서 후보 집합 전체를 매 문자마다 따로 저장할 필요가 없다.
1. Trie: 후보를 저장하는 공간
직관. 앞으로 패턴이 될 수 있는 문자열만 남긴다. 서로 같은 접두사는 하나의 상태를 공유하고, 패턴이 끝나는지 여부는 자식의 유무와 별도로 기록한다.
정의 1 · 입력과 표기
유한 알파벳 , 패턴 목록 , 본문 를 둔다. 이다. 중복 문자열도 ID가 다르면 다른 패턴으로 취급한다. P는 패턴 목록이 아니라 모든 패턴 접두사의 집합이며 이다.
Trie 노드 v와 문자열 를 대응시키고 로 둔다. 실제 간선 는 일 때만 존재한다. 는 인 ID j의 목록이다. 이다.
정리 1. Trie와 패턴 끝 노드의 대응
주장. 각 패턴 p를 삽입한 후, root에서 p의 문자를 순서대로 따라간 경로의 끝 노드는 정확히 p를 나타낸다. Trie의 노드 집합은 패턴들의 서로 다른 접두사 집합 P와 일대일 대응한다.
증명. 삽입 중 k글자를 처리한 노드가 p의 길이 k 접두사를 나타낸다고 가정한다. 다음 문자 c의 간선이 있으면 해당 노드로 이동하고, 없으면 c를 붙인 접두사의 노드를 생성한다. 같은 노드에서 같은 문자로 나가는 간선은 하나이므로 동일한 접두사가 중복 생성되지 않는다. 인 root부터의 귀납법으로 주장이 성립한다. 따라서 끝 노드의 terminal에 ID를 저장하면 패턴의 종료 위치를 정확히 식별한다.
∎
구현상의 결론. 노드마다 전체 문자열을 보관할 필요는 없다. 정수 인덱스와 간선, terminal ID 목록이면 충분하다. 그림의 문자열 이름은 설명용이다.
2. 실패 링크: 남겨야 할 접미사
직관. she 다음 r을 읽으려 할 때 she 전체로는 진행할 수 없지만, 끝의 he는 hers로 이어질 가능성이 있다. 버릴 것은 앞의 s이지, 이미 읽은 본문 전체가 아니다.
정의 2 · 수학적 실패 함수 F
에 대해 는 의 proper suffix 중 P에 속하는 가장 긴 문자열의 노드이다. proper는 자기 자신보다 짧다는 뜻이며 를 포함한다. 으로 약속한다. 각 길이의 접미사는 유일하고 가 항상 후보이므로 는 유일하게 존재한다.
보조정리 2. 실패 링크 사슬의 완전성
주장. 를 방문하되 root를 한 번 방문하면 멈춘다. 이 사슬에는 의 접미사이면서 P에 속하는 문자열들이 길이의 내림차순으로 정확히 한 번씩 모두 나타난다.
핵심 성질. 같은 문자열의 두 접미사 x, y에 대해 이면 x는 y의 접미사이다. 두 문자열 모두 원래 문자열의 마지막 문자를 공유하고, x가 차지하는 마지막 글자가 y 안에 포함되기 때문이다.
증명. v 자신은 가장 긴 후보이다. 일 때 는 정의상 그다음으로 긴 후보이다. q보다 짧은 후보 x는 위 성질에 의해 의 proper suffix이다. 역으로 의 접미사는 의 접미사이므로, 의 proper suffix 중 P에 속하는 후보 집합은 에서 v와 q를 제외한 후보 집합과 정확히 같다. depth에 대한 귀납법으로 누락이 없다. F를 따라갈 때 깊이가 엄격히 줄어 중복도 없으며, 유한 번 후 root에 도달한다. root의 자기 링크는 다시 따라가지 않는다.
∎
활용. 현재 상태 하나와 그 실패 사슬이 모든 유효한 접미사 후보를 표현한다. 사슬에 없는 후보를 별도로 찾을 필요가 없다.
3. 본문을 뒤로 돌리지 않는 이유
직관. 읽은 본문의 끝과 일치하는 가장 긴 접두사만 기억한다. 다음 문자 c가 붙을 수 없으면 실패 사슬을 짧아지는 순서로 시험한다. 실패 이동은 후보만 줄이고 c는 아직 소비하지 않는다.
정의 3 · 상태와 전이
는 X의 접미사 중 P에 속하는 최장 문자열의 노드이다. 로 정의한다. 구하고 싶은 성질은 단순히 “문자 하나를 따라간다”가 아니라 아래 등식이다.
여기서 와 F는 수학적으로 정의된 함수이다. 아직 구현 배열 go와 fail이 정확하다고 가정하지 않는다.
정리 3. 탐색 상태와 완성된 전이 함수 go의 정당성
불변식. 본문 접두사 X를 읽은 상태 v는 X의 접미사 중 P에 속하는 가장 긴 문자열을 나타낸다.
기초. 이면 상태는 root이므로 성립한다.
귀납 단계. 다음 문자 c를 읽은 뒤의 비어 있지 않은 후보는 yc 꼴이다. 이면 접두사 닫힘에 의해 이고, y는 X의 접미사이다. 불변식에 의해 이며, 같은 X의 접미사이므로 y는 의 접미사이다. 따라서 보조정리 2의 사슬에 y가 존재한다. 역으로 그 사슬의 y에 실제 c 간선이 있으면 이고 Xc의 접미사이다. 두 방향이 모두 성립하므로 후보 집합이 정확히 일치한다.
사슬은 길이 내림차순이므로 c 간선이 있는 첫 노드의 자식이 최장 후보이다. 끝까지 없으면 이다. 이 후보는 정의상 이므로 상태를 로 갱신하면 불변식이 보존된다. 특히 v에 실제 c 간선이 있으면 바로 그 자식이다.
의 계산 규칙은 실제 간선이 있으면 그 자식, 없고 이면 , root에서도 없으면 root이다. 깊이가 감소하므로 재귀 규칙은 종료한다. 뒤의 정리에서 go 배열이 이 와 같음을 보이면 구현의 탐색 정당성도 따라온다.
∎
구현상의 결론. 실패 단계를 풀어 보여 주는 화면에서도 출력 시점은 문자 소비 직후 한 번이다. 실패 중간 상태에서 출력하면 같은 종료 위치를 중복 보고할 수 있다.
4. BFS로 수학적 함수를 실제 배열로 계산
직관. 긴 접두사의 실패 후보는 짧은 접두사에서 이미 계산한 전이를 재사용한다. 그래서 깊이가 작은 노드부터 처리하는 BFS가 필요하다.
구성 규칙
root의 go는 실제 자식 또는 root이다. root 자식의 fail은 root로 직접 초기화한다. 의 실제 자식 에 대해 . 실제 간선이 없으면 이다. next는 원래 Trie, go는 문자 하나를 소비한 최종 전이표이다.
정리 4. BFS 실패 링크 점화식의 정당성
주장. root가 아닌 v의 실제 자식 에 대해 이다.
수학적 점화식. 의 비어 있지 않은 proper suffix 후보를 yc라 두자. 이고 y는 의 proper suffix이다. 길이가 보다 작아야 yc가 자체가 아니기 때문이다. 보조정리 2에 의해 가능한 y는 부터의 사슬에 모두 있다. 역으로 이 사슬의 y에 c 간선이 있으면 yc는 의 proper suffix 후보이다. 따라서 정리 3의 규칙을 적용하면 이다. 비어 있지 않은 후보가 없을 때도 양쪽 모두 root이다.
BFS 구현에 대한 귀납법. 처음에 root의 go는 실제 자식 또는 root이므로 와 같다. 깊이 1의 노드는 proper suffix가 뿐이므로 가 정확하다. 이제 BFS에서 v를 꺼내기 직전, v의 fail과 더 얕은 노드들의 go가 정확하다고 가정한다. 는 더 얕으므로 그 전이표가 완성되어 있다. 실제 간선이 없는 는 정리 3의 규칙으로, 실제 자식 u의 fail은 위 점화식으로 정확히 계산된다. 자식 u는 큐 뒤에 들어가므로 자신이 처리될 때 필요한 더 얕은 전이들이 먼저 완성된다. 모든 노드가 한 번 큐에 들어가므로 귀납법이 전체에 적용된다.
그러므로 최종 배열은 이다. root 자식을 일반 점화식에서 제외하는 것은 단순 구현 편의가 아니라 자기 자신을 proper suffix로 선택하지 않기 위한 조건이다.
∎
실패 후보를 직접 지워 보기
he · Trie에 있음 → 채택e · Trie에 없음
· 더 짧음
ers · Trie에 없음rs · Trie에 없음
s · Trie에 있음 → 채택
· 더 짧음
실패 링크는 Trie의 부모가 아니다. she의 부모 sh는 마지막 문자를 뺀 접두사이고, 실패 링크 he는 앞부분을 버린 접미사이다. 실패한 뒤에도 이미 읽은 본문의 끝과 일치해야 하므로 접미사가 필요하다.
BFS 계산표: 먼저 아는 값으로 다음 값을 구한다
아래 순서는 같은 깊이에서 h 가지를 s 가지보다 먼저 처리한 경우이다. 같은 깊이의 순서가 달라도 최종 링크는 같다. 예를 들어 sh의 부모는 s이고 이므로 . 다음으로 를 얻는다.
| 순서 | 상태 | 부모 | 마지막 문자 | fail | out |
|---|---|---|---|---|---|
| 0 | ε | — | — | ε | 없음 |
| 1 | h | ε | h | ε | 없음 |
| 2 | s | ε | s | ε | 없음 |
| 3 | he | h | e | ε | 없음 |
| 4 | hi | h | i | ε | 없음 |
| 5 | sh | s | h | h | 없음 |
| 6 | her | he | r | ε | 없음 |
| 7 | his | hi | s | s | 없음 |
| 8 | she | sh | e | he | he |
| 9 | hers | her | s | s | 없음 |
한 번의 go가 실제로 생략하는 과정
sher 간선 없음he실패 이동 · r은 아직 그대로herr 간선 이동 · 여기서 r 소비
. 두 번의 상태 이동을 전이표 조회 한 번으로 압축한 것이다.
에서도 간선이 없으면 에서 해당 문자를 소비한다. 예제의 첫 문자 u가 그렇다. 실패 이동 중에는 문자를 소비하거나 출력하지 않는다. 문자를 최종 처리한 상태에서만 그 종료 위치의 결과를 보고한다.
| 문자 | ||
|---|---|---|
| e | 없음 | ε |
| h | 없음 | h |
| i | 없음 | ε |
| r | 없음 | her |
| s | 없음 | s |
| u | 없음 | ε |
본문 한 글자마다 검산하기
| 위치 / 문자 | 종류 | 이동 | 새 매칭 |
|---|---|---|---|
| 0 / u | 소비 | ε → ε | 없음 |
| 1 / s | 소비 | ε → s | 없음 |
| 2 / h | 소비 | s → sh | 없음 |
| 3 / e | 소비 | sh → she | she [1,3], he [2,3] |
| 4 / r | 실패 · 미소비 | she → he | 출력 안 함 |
| 4 / r | 소비 | he → her | 없음 |
| 5 / s | 소비 | her → hers | hers [2,5] |
#include <array>
#include <stdexcept>
#include <string>
#include <vector>
using namespace std;
// 입력: 비어 있지 않은 a-z 패턴. 모든 위치는 0-index.
struct AhoCorasick {
struct Node {
array<int, 26> next, go;
int fail = 0, out = -1;
vector<int> terminal;
Node() { next.fill(-1); go.fill(0); }
};
vector<Node> t = vector<Node>(1);
vector<int> bfs = {0}, end, length;
explicit AhoCorasick(const vector<string>& patterns) {
for (int id = 0; id < (int)patterns.size(); ++id) {
if (patterns[id].empty())
throw invalid_argument("empty pattern");
int v = 0;
for (char ch : patterns[id]) {
if (ch < 'a' || ch > 'z')
throw invalid_argument("expected a-z");
int c = ch - 'a';
if (t[v].next[c] == -1) {
int u = (int)t.size();
t[v].next[c] = u;
t.emplace_back();
}
v = t[v].next[c];
}
t[v].terminal.push_back(id);
end.push_back(v);
length.push_back((int)patterns[id].size());
}
// root의 자식은 따로 초기화: fail[u] = 0.
for (int c = 0; c < 26; ++c) {
int u = t[0].next[c];
if (u != -1) {
t[0].go[c] = u;
bfs.push_back(u);
}
}
// bfs 벡터를 FIFO 큐로 사용.
for (int head = 1; head < (int)bfs.size(); ++head) {
int v = bfs[head], f = t[v].fail;
t[v].out = !t[f].terminal.empty() ? f : t[f].out;
for (int c = 0; c < 26; ++c) {
int u = t[v].next[c];
if (u == -1) {
t[v].go[c] = t[f].go[c];
} else {
t[v].go[c] = u;
t[u].fail = t[f].go[c];
bfs.push_back(u);
}
}
}
}
}; 위 생성자는 fail·go와 함께 out도 계산한다. out의 정의와 정당성은 바로 다음 절에서 다룬다. 구현은 비어 있지 않은 a–z 패턴, 26칸 배열을 사용한다.
5. 현재 상태 하나에서 모든 매칭 출력
직관. she가 끝나는 위치에서는 he도 끝난다. 다만 실패 조상 중 패턴이 아닌 노드를 매번 방문할 필요는 없다.
정의 4 · output link와 출력 집합
는 v의 엄격한 실패 조상 중 가장 가까운 terminal 노드이며, 없으면 −1이다. . 에 대해 f가 terminal이면 , 아니면 이다. 존재 여부만 저장하는 has의 기초는 이다.
정리 5. Terminal 전파와 output link의 정당성
주장. 본문 위치 i에서 끝나는 패턴 ID는 현재 상태 v와 v의 실패 조상에 저장된 terminal ID들의 합집합이다.
증명. 위치 i에서 끝나는 패턴 p는 읽은 본문의 접미사이며 P에 속한다. 정리 3에 의해 이고, 보조정리 2에 의해 p의 끝 노드는 v의 실패 사슬에 있다. 역으로 이 사슬의 terminal 노드 문자열은 읽은 본문의 접미사이므로 실제로 위치 i에서 끝난다.
존재 여부는 으로 계산한다. 깊이에 대한 귀납법으로 정확하다. output link는 terminal이 아닌 실패 조상만 건너뛰므로 출력 ID 집합을 바꾸지 않는다. 각 노드는 사슬에서 한 번만 방문하고 ID는 자신의 terminal에만 저장되므로 동일 ID·종료 위치를 중복 보고하지 않는다. 시작 위치는 이다.
out 점화식도 확인. 가 terminal이면 f가 가장 가까운 terminal 실패 조상이다. 아니면 첫 terminal은 f의 엄격한 실패 조상 중 가장 가까운 것이므로 이다. 을 기초로 깊이에 대해 귀납하면 계산이 정확하다. 출력 시 v 자체를 먼저 검사하고 그 뒤 out을 순회해야, v가 terminal인 경우도 빠지지 않는다.
∎
세 종류의 링크를 구별한다
| 관계 | 의미 | she 예제 | his 예제 |
|---|---|---|---|
| Trie 부모 | 마지막 문자 제거 | sh | hi |
| fail | 최장 proper suffix 상태 | he | s |
| out | 가장 가까운 terminal 실패 조상 | he | 없음 |
his의 fail은 s이지만 s는 terminal이 아니다. s의 실패 조상 도 terminal이 아니므로 out은 없다. out은 실제 상태 전이에 쓰는 링크가 아니라, 이미 결정된 종료 위치에서 추가 정답을 열거하는 링크이다.
위치 3의 상태 she에서는 자기 terminal의 she를 출력하고, out을 따라 he를 출력한다. ID별 구간은 각각 , 이다. 종료 위치 순서로 출력되며, 같은 종료 위치에서는 긴 패턴부터 짧은 패턴 순서이다.
6. 횟수만 필요할 때: Failure Tree
직관. aaa에 도착하면 aa와 a도 끝난다. 각 위치에서 여러 패턴을 전부 출력하는 대신, 도착한 상태에 1을 기록하고 나중에 실패 조상에게 전달한다.
정의 5 · 방문 횟수와 실패 트리
root 자기 간선을 제외하고 를 v의 부모로 둔다. 는 문자 소비 직후 상태가 v였던 본문 위치의 수이다. 는 u 자신을 포함한다. 초기 로 놓고 Trie BFS 역순에서 root를 제외한 v에 대해 를 수행한다.
정리 6. Failure Tree의 역순 누적과 패턴별 등장 횟수
주장. 탐색 중 상태 v에 도착한 횟수를 라 두면, 패턴 끝 노드 u의 등장 횟수는 failure tree에서 u의 서브트리에 속한 모든 v의 합이다.
왜 트리인가. root를 제외한 각 노드는 실패 부모를 정확히 하나 갖는다. 부모로 갈 때 깊이가 줄어 사이클이 없고 모든 노드가 root에 도달한다. 따라서 root 자기 링크를 제거한 간선들은 연결된 트리를 이룬다.
증명. 정리 5에 의해 u의 패턴이 종료 위치 i에서 등장할 필요충분조건은 u가 그 위치의 상태 v의 실패 조상인 것이다. 이는 v가 u의 failure-tree 서브트리에 있다는 조건과 같다. 각 본문 위치는 정확히 한 상태의 visit만 증가시키므로 서브트리 합은 각 등장을 정확히 한 번 센다. 겹치는 등장도 종료 위치가 다르므로 별도로 계산된다.
는 Trie 깊이가 더 작다. 따라서 Trie BFS 역순에서는 모든 failure-tree 자손의 누적이 조상보다 먼저 끝난다. 를 root를 제외한 모든 노드에 수행하면 서브트리 합이 완성된다.
∎
횟수만 필요하다면 출력 자체를 만들지 않는다
패턴 a, aa, aaa, 본문 aaaa를 보자. 네 문자를 읽은 상태는 차례로 a, aa, aaa, aaa. 직접 도착 횟수는 1, 1, 2이지만 정답 횟수는 아니다. aaa 상태에 도착한 순간 aa와 a도 동시에 끝나기 때문이다.
그림의 화살표는 failure link 방향이다. 트리의 root는 이며 a의 실패 부모 는 생략했다. 이 예제에서는 Trie와 failure tree가 모두 사슬이지만 일반적으로 둘의 모양은 다르다.
| 단계 | |||
|---|---|---|---|
| 직접 방문 횟수 | 1 | 1 | 2 |
| aaa → aa | 1 | 3 | 2 |
| aa → a | 4 | 3 | 2 |
| a → ε | 4 | 3 | 2 |
aa를 a에 먼저 더하고 나중에 aaa를 aa에 더하면, aaa에서 온 값이 a까지 전달되지 않는다. 따라서 자손 → 조상 순서가 필수이다. Trie 깊이의 역순이면 모든 실패 간선에서도 자식이 부모보다 먼저 처리된다.
Failure Tree를 질의 자료구조로 사용하는 이유
각 상태 v의 방문은 그 실패 조상 패턴들에 영향을 준다. 따라서 패턴 u의 횟수는 failure-tree 서브트리 방문 합이다. DFS 진입 번호를 붙이고 퇴장 시각을 배타적 끝으로 두면 u의 서브트리는 연속 구간 이 된다. v 방문을 의 점 갱신으로, u의 횟수를 그 구간 합으로 바꾸어 Fenwick tree 등으로 처리할 수 있다.
구간이 연속인 이유: DFS는 한 자식의 서브트리를 끝까지 방문한 후 다음 자식으로 넘어간다. u 진입부터 u 퇴장 전까지 정확히 u의 자손만 방문하므로 다른 노드가 그 구간에 끼지 않는다. 아래 구현은 더 단순한 전체 본문 일괄 누적 방식을 사용한다.
// 앞의 AhoCorasick 정의 이후에 작성.
// emit(id, start, finish): 양 끝을 포함한 매칭 구간.
template <class Emit>
void find_matches(const AhoCorasick& ac, const string& text,
Emit emit) {
int v = 0;
for (int i = 0; i < (int)text.size(); ++i) {
char ch = text[i];
// 패턴 알파벳 밖의 문자는 어떤 패턴에도 포함되지 않음.
v = ('a' <= ch && ch <= 'z') ? ac.t[v].go[ch - 'a'] : 0;
for (int u = v; u != -1; u = ac.t[u].out) {
for (int id : ac.t[u].terminal)
emit(id, i - ac.length[id] + 1, i);
}
}
}
// 위치 출력이 필요 없으면 별도 순회로 횟수만 계산.
vector<long long> count_matches(const AhoCorasick& ac,
const string& text) {
vector<long long> cnt(ac.t.size(), 0);
int v = 0;
for (char ch : text) {
v = ('a' <= ch && ch <= 'z') ? ac.t[v].go[ch - 'a'] : 0;
++cnt[v];
}
// root는 자기 자신에게 누적하지 않음.
for (int k = (int)ac.bfs.size() - 1; k > 0; --k) {
int u = ac.bfs[k];
cnt[ac.t[u].fail] += cnt[u];
}
vector<long long> result;
for (int u : ac.end) result.push_back(cnt[u]);
return result;
} 7. 구성 및 탐색 시각화
기본 예제는 he, she, his, hers와 ushers이다. she에 도착하면 he도 발견되고, 다음 r은 실패 링크를 거쳐 처리된다.
Pattern matching machine
소비한 문자 · 남은 후보 · 정리의 근거상태 선택 시 실패 링크와 출력 정보 표시. 탐색 진행 위치는 유지.
발견한 패턴 · [시작, 끝]
갈색 점선 문자는 아직 소비하지 않은 다음 문자이다. 파랑은 마지막으로 소비한 문자이다. 실패 이동에서는 갈색 문자의 위치가 변하지 않는다. 그래프 안의 ε는 root이다. 시각화는 학습용으로 상태와 결과의 사본을 저장한다. 화면 렌더링·스냅샷 비용은 아래 알고리즘 복잡도에 포함하지 않는다.
8. 복잡도: 어떤 연산을 세는가
문자·정수의 비교와 배열 접근은 , 출력 callback 한 건은 인 RAM 모델을 사용한다. Z는 중복 ID와 겹치는 매칭까지 포함한 실제 결과 수이다. 결과를 저장하면 공간이 별도로 필요하다. 빈 사전은 구현에서 허용하지만 아래 선형 표기의 전제는 비어 있지 않은 패턴 목록을 기준으로 한다.
정리 7. 시간·공간 복잡도의 유도
패턴 삽입은 총 M개 문자를 처리한다. V개 상태에서 개 전이를 각각 한 번 계산하므로 dense 배열 구성은 이다. next·go가 각각 개 원소를 저장하고, terminal ID의 총 개수는 패턴 수 K이므로 추가 공간은 이다. 원본 입력과 시각화용 사본의 저장 비용은 제외한 값이다.
완성된 go를 사용하면 N개 본문 문자의 상태 이동은 이다. 각 위치에서 현재 노드를 한 번 검사하고, output link로 방문한 노드는 반드시 한 개 이상의 ID를 출력한다. 출력 수가 Z일 때 추가 방문 수는 Z 이하이므로 전체 탐색·출력은 이다.
횟수 계산은 본문 순회 , 역순 누적 개 패턴 결과 추출 이므로 이다. 가 상수이고 이면 구성·출력까지 이다. 가 입력에 따라 증가하는 경우에는 항을 생략할 수 없다.
실패 이동을 명시적으로 수행하는 탐색도 전이 조회가 이라면 총 이다. 한 문자 소비로 Trie 깊이는 최대 1 증가하고, 실패 이동마다 최소 1 감소한다. 깊이는 음수가 될 수 없으므로 실패 이동 총 횟수는 증가 횟수 를 넘지 않는다. 이 분석은 탐색 과정에 대한 것이며, 임의의 맵 기반 구성 방식에 그대로 적용되는 것은 아니다.
∎
큰 알파벳에서 맵을 쓰면 lookup과 전처리 비용을 다시 분석해야 한다. 화면은 학습을 위해 문자열·방문값·출력을 복사하므로, 애니메이션 프로그램 전체가 이 시간·공간 상계로 동작한다고 주장하지 않는다.
9. 코드 연결과 실행 예제
자료구조의 필드와 의미
| 필드 | 보관하는 정보 |
|---|---|
| t[v].next[c] | Trie의 자식 번호. 없으면 −1. |
| t[v].go[c] | 문자 c를 소비한 뒤의 상태 번호. 항상 존재. |
| t[v].fail / out | 최장 proper suffix / 가장 가까운 terminal 실패 조상. |
| t[v].terminal | 이 노드에서 정확히 끝나는 패턴 ID들. 조상 ID는 복사하지 않음. |
| end[id], length[id] | 패턴 끝 노드와 패턴 길이. |
| bfs | Trie의 BFS 순서. root는 첫 원소. |
생성자는 Trie 삽입 → root 초기화 → BFS 순서로 동작한다. 노드 추가 시 vector가 재할당될 수 있으므로 노드의 참조를 오래 붙들지 않고 정수 인덱스로 다시 접근한다. bfs는 앞의 원소를 지우지 않고 head만 증가시키는 큐이다.
실행 가능한 예제
위의 두 코드를 각각 aho-build.cpp, aho-search.cpp로 저장하고 아래 파일을 같은 폴더에 둔다. c++ -std=c++17 -O2 aho-example.cpp -o aho로 컴파일한다. 예제 파일이 나머지 두 파일을 포함하므로 별도로 함께 컴파일하지 않는다.
#include <iostream>
#include "aho-build.cpp"
#include "aho-search.cpp"
int main() {
vector<string> patterns = {"he", "she", "his", "hers"};
string text = "ushers";
AhoCorasick ac(patterns);
find_matches(ac, text, [&](int id, int l, int r) {
cout << patterns[id] << " [" << l << ", " << r << "]\n";
});
auto counts = count_matches(ac, text);
for (int id = 0; id < (int)patterns.size(); ++id)
cout << patterns[id] << ": " << counts[id] << '\n';
} she [1, 3] he [2, 3] hers [2, 5] he: 1 she: 1 his: 0 hers: 1
예제는 두 API를 보여 주려고 본문을 두 번 순회한다. 실제 문제에서는 필요한 함수만 호출하면 된다. find_matches는 결과를 callback으로 즉시 전달하므로 결과 전체를 저장하지 않는다. callback이 결과를 모으면 별도로 공간이 필요하다. callback 자체가 비싼 작업을 하면 그 비용도 더해야 한다.