Aho-Corasick#

설명#

Aho-Corasick, 아호-코라식
여러 개의 패턴 문자열을 한 번의 탐색으로 동시에 매칭할 수 있는
멀티 패턴 문자열 매칭 알고리즘이다.

KMP 같은 단일 패턴 매칭 알고리즘
하나의 패턴에 대해서는 선형 시간에 탐색이 가능하지만,
패턴이 여러 개일 경우 각 패턴마다 탐색을 수행해야 하므로
전체 시간 복잡도는 패턴 개수에 비례하여 증가한다.

반면, 아호-코라식 알고리즘은
모든 패턴을 하나의 Trie 구조로 통합하고,
실패 시 이동할 위치를 미리 계산한 실패 링크를 이용해
입력 문자열을 한 번만 순회하면서
모든 패턴의 출현 여부를 동시에 판별할 수 있다.


활용 예시#

  • Bad words filter
    욕설, 금칙어 목록이 다수 존재할 때
    채팅/댓글 문자열을 한 번만 스캔하여 필터링 가능
  • 보안
    IDS/IPS에서 시그니쳐 기반 패킷 검사
    악성 문자열 패턴 탐지
  • 문자열 분석
    로그 분석
    다수 키워드 동시 검색

장점#

  • 멀티 패턴 매칭을 선형 시간에 처리해, 패턴 개수가 많아질수록 압도적으로 효율적

단점#

  • Trie + 실패 링크 구성으로 인해 메모리 사용량 증가
  • 구현 난이도
  • 동적 패턴 추가/삭제에 취약 (실패링크 리빌드 비용 발생)

시간 복잡도#

|S|: S 문자열 길이, text: 비교 문자열, pattern: 패턴 문자열

  • Trie 생성: \( O(\Sigma |pattern|) \)
  • 실패 링크 구성: \( O(\Sigma |pattern|) \)
  • 문자열 탐색: \( O(|text|) \) + (매칭 횟수)

따라서, 전체 시간 복잡도는 \( O(|text| + \Sigma |pattern|) \)이고,
한 번의 빌드 이후 추가적인 탐색에서는 \( O(|text|) \) 로 수렴한다.


Go
import "container/list"

type Result struct {
	index int
	pattern string
}

type node struct {
	children map[rune]*node
	fail *node
	output []string
}

func newNode() *node {
	children := make(map[rune]*node)
	return &node{children: children, fail: nil, output: nil}
}

type AhoCorasick struct {
	root *node
}

func (a *AhoCorasick) Init(patterns []string) {
	a.root = newNode()
	for _, pattern := range patterns {
		a.insert(pattern)
	}
	a.connect()
}

func (a *AhoCorasick) insert(pattern string) {
	now := a.root
	for _, char := range []rune(pattern) {
		if _, exists := now.children[char]; !exists {
			now.children[char] = newNode()
		}
		now = now.children[char]
	}
	if now.output == nil {
		now.output = make([]string, 0)
	}
	now.output = append(now.output, pattern)
}

func (a *AhoCorasick) connect() {
	a.root.fail = a.root

	q := list.New()
	q.PushBack(a.root)

	for q.Len() > 0 {
		now := q.Remove(q.Front()).(*node)
		for key, next := range now.children {
			if now == a.root {
				next.fail = a.root
			} else {
				dst := now.fail
				for dst != a.root && dst.children[key] == nil {
					dst = dst.fail
				}
				if child, exists := dst.children[key]; exists {
					dst = child
				}
				next.fail = dst
			}
			next.output = append(next.output, next.fail.output...)
			q.PushBack(next)
		}
	}
}

func (a *AhoCorasick) search(word string) []Result {
	now := a.root
	res := make([]Result, 0)
	
	for i, char := range []rune(word) {
		for now != a.root && now.children[char] == nil {
			now = now.fail
		}
		
		if _, exists := now.children[char]; exists {
			now = now.children[char]
		}
		
		for _, p := range now.output {
			res = append(res, Result{i - len(p) + 1, p})
		}
	}
	return res
}
Python
class Node:
	def __init__(self):
		self.children = dict()
		self.fail = None
		self.output = []


class AhoCorasick:
	def __init__(self, patterns: List[str]):
		self.root = Node()
		for pattern in patterns:
			self.insert(pattern)
		self.connect()

	def insert(self, pattern: str):
		'''
		아호-코라식 자료구조에 패턴을 삽입
		:param pattern: 패턴 문자열
		:type pattern: str
		'''
		now = self.root
		for char in pattern:
			now = now.children.setdefault(char, Node())
		now.output.append(pattern)

	def connect(self):
		'''
		실패 링크 연결
		'''
		from collections import deque
		
		self.root.fail = self.root

		q = deque([self.root])
		while q:
			now = q.popleft()
			for char in now.children:
				nxt = now.children[char]
				if now is self.root:
					nxt.fail = self.root
				else:
					dst = now.fail
					while dst is not self.root and char not in dst.children:
						dst = dst.fail
					if char in dst.children:
						dst = dst.children[char]
					nxt.fail = dst
				nxt.output += nxt.fail.output
				q.append(nxt)

	def search(self, word: str) -> List[Tuple[int, str]]:
		'''
		word에서 매칭되는 모든 패턴과, 해당 패턴이 시작하는 위치를 출력
		:param word: 검색 문자열
		:type word: str
		:return: (index, pattern)형태의 패턴과 해당 패턴의 시작 인덱스
		:rtype: List[Tuple[int, str]]
		'''
		now = self.root
		res = []
		for i, char in enumerate(word):
			while now is not self.root and char not in now.children:
				now = now.fail

			if char in now.children:
				now = now.children[char]
	
			for p in now.output:
				res.append((i - len(p) + 1, p))
		return res

코드에 왜 BFS가 있지?#

connect()메서드는 기본적으로 BFS를 통해 실패 링크를 연결한다.
BFS는 문자열 탐색을 위한 것이 아니라, 실패 링크를 올바른 순서로 구성하기 위해 필요하다.

실패 링크#

“현재 노드에서 더 이상 문자를 이어갈 수 없을 때, 어디로 돌아가야 가장 긴 접미사를 유지할 수 있는가?”

실패 링크는 기본적으로 KMP의 실패 함수와 동일한 개념이지만,
아호-코라식에서는 이를 Trie 전체로 확장한 구조라고 할 수 있다.

실패 링크를 설정할 때, 부모 노드의 실패 링크가 이미 올바르게 설정되어 있어야 함을 전제로 한다.

그렇기에, BFS의 특성인 레벨 순회를 통해 위의 속성을 만족시키면서,
한 번의 순회로 모든 실패 링크를 구성할 수 있다.