Curso de DSA EN

Capítulo 43 de 56 · avanzado

Aho-Corasick: búsqueda multi-patrón

Lo que cubre este capítulo

Los algoritmos de un solo patrón encuentran una aguja en un pajar. Aho-Corasick encuentra miles de agujas en una sola pasada. Dado un diccionario completo de patrones — firmas de virus, palabras prohibidas, reglas de detección de intrusos, un conjunto de keywords — localiza cada aparición de cada patrón en un único recorrido lineal del texto, sin importar cuántos patrones haya ni qué tan largos sean. La alternativa obvia, buscar cada patrón por separado, vuelve a leer el texto una vez por patrón; Aho-Corasick lo lee una vez y ya. Lo logra generalizando la función de fallo de KMP: de un solo patrón a un trie de todos los patrones. Acomodas los patrones en un árbol de prefijos, agregas failure links que indican a dónde retroceder cuando un carácter no extiende el match actual, y pasas el texto por el autómata resultante. Este capítulo lo construye, ve el ejemplo clásico de ushers encontrar she, he y hers de un jalón, y mide la ganancia contra la búsqueda patrón por patrón: 260 veces menos caracteres examinados.

Un poco de historia

Alfred Aho y Margaret Corasick publicaron el algoritmo en 1975 en Bell Labs, y su origen es deliciosamente concreto: se construyó para una librería de búsqueda bibliográfica, para escanear documentos buscando muchos términos de índice a la vez. Aho después co-escribiría el "Dragon Book" sobre compiladores y co-crearía AWK (la "A" es suya), pero Aho-Corasick sigue siendo uno de sus resultados más desplegados — corre dentro de fgrep, la clásica herramienta de Unix para búsqueda de cadenas fijas, que es exactamente el problema de "encuentra cualquiera de estas cadenas". El algoritmo es una generalización directa de Knuth-Morris-Pratt, publicado apenas dos años antes: la función de fallo de KMP le dice a un patrón dónde retomar después de un mismatch, y los failure links de Aho-Corasick hacen lo mismo para todo un árbol de patrones a la vez. El linaje es explícito — Aho-Corasick es lo que obtienes cuando preguntas "¿y si KMP tuviera un trie en lugar de una sola cadena?" — y volvió la búsqueda multi-patrón tan barata como la de un solo patrón, razón por la que se convirtió en la herramienta estándar para escaneo de firmas.

La intuición

Construye el algoritmo en tres capas. Primero, un trie: inserta cada patrón en un árbol de prefijos, de modo que he, she, his y hers compartan sus prefijos comunes como caminos compartidos, y el último carácter de cada patrón marque un nodo como "aquí termina un patrón". Seguir caracteres hacia abajo desde la raíz es la forma de hacer match: mientras el texto siga extendiendo un camino del trie, estás dentro de un match potencial.

Segundo, los failure links, la idea de KMP llevada al árbol. Cuando el siguiente carácter del texto no continúa el camino actual del trie, no quieres rendirte y reiniciar desde la raíz: quieres caer al sufijo propio más largo de lo que ya llevas emparejado que también sea prefijo de algún patrón, y retomar ahí. El failure link de cada nodo apunta exactamente a ese nodo. Se calculan con un recorrido por anchura, primero los nodos superficiales, porque el destino de fallo de un nodo siempre está más arriba y debe estar terminado antes de poder calcular el del nodo mismo — la misma dependencia de orden que la tabla de KMP, ahora sobre un árbol.

Tercero, los output links. Una sola posición del texto puede terminar varios patrones a la vez: llegar al nodo de hers también debería reportar he, porque he es un sufijo... no — hers contiene a he como prefijo, pero el caso sutil es she, que termina en un nodo cuyo failure link lleva a he, así que llegar a she debe reportar también he. Aho-Corasick resuelve esto fusionando el conjunto de salidas de cada nodo a lo largo de su failure link, de modo que un nodo reporta su propio patrón más todo patrón que sea sufijo de la cadena que representa. Ahora pasa el texto en una sola pasada: en cada carácter, sigue una arista goto si existe, o sigue failure links hasta que alguna exista, y emite las salidas del nodo actual. El puntero del texto nunca retrocede — un solo barrido lineal encuentra todo.

Complejidad: cómo escala

Construir el autómata es O(pattern lengths)O(\sum \text{pattern lengths}) — un nodo del trie por cada carácter distinto de patrón, más una pasada por anchura para los failure links. Buscar es O(n+z)O(n + z) donde nn es la longitud del texto y zz el número de matches reportados: cada carácter del texto se procesa una vez (los retrocesos por failure links son O(1)O(1) amortizado por carácter, el mismo argumento que en KMP), más trabajo constante por cada match emitido. Y lo crucial: el costo de búsqueda es independiente del número de patrones — mil patrones cuestan la misma pasada única que uno. Ese es todo el punto, y el enfrentamiento lo mide directo: caracteres examinados para encontrar kk patrones, la pasada única de Aho-Corasick contra escanear el texto una vez por patrón:

La línea de una-pasada-por-patrón sube sin parar: cada patrón nuevo significa otro recorrido completo del texto, así que su costo crece linealmente con el número de patrones. La línea de Aho-Corasick es prácticamente plana — procesa el texto una vez sin importar cuántos patrones tenga el autómata, así que agregar patrones solo hace crecer la construcción (que es barata), no el escaneo. Con 500 patrones, Aho-Corasick examinó alrededor de 10,000 caracteres contra los 2.6 millones del enfoque patrón por patrón — 260 veces menos, y la brecha se abre más con cada patrón que agregas. Es la misma historia de "una pasada le gana a muchas pasadas" que el truco multi-patrón de Rabin-Karp, pero Aho-Corasick lo hace con patrones de cualquier longitud y con una garantía lineal limpia, y por eso es él, y no Rabin-Karp, la herramienta de producción para diccionarios grandes.

A fondo A fondo

A fondo: los failure links como autómata KMP sobre un árbol, y la sutileza de los output links

La función de fallo de KMP, lps, está definida sobre las posiciones de una sola cadena: lps[j] es el prefijo propio más largo que también es sufijo de los primeros j caracteres. El failure link de Aho-Corasick es el mismo concepto sobre el trie: para el nodo que representa la cadena w, su failure link apunta al nodo que representa el sufijo propio más largo de w que a su vez es un nodo del trie (es decir, prefijo de algún patrón). La construcción por BFS refleja exactamente la construcción de la tabla de KMP: para hallar el failure link del nodo v (donde v extiende a su padre u con el carácter c), arrancas en el failure link de u y sigues failure links hasta encontrar uno cuyos hijos incluyan c — entonces v falla hacia ese hijo. Es el ciclo while k > 0 and ... : k = lps[k-1] de KMP, corriendo sobre el árbol. Y así como el escaneo de KMP es lineal amortizado porque el puntero de retroceso baja tanto como sube, el escaneo de Aho-Corasick es lineal por la misma razón: la profundidad del nodo actual baja (vía failure links) a lo mucho tanto como sube (vía aristas goto).

Los output links arreglan un bug sutil de completitud. Piensa en he y she dentro del autómata escaneando ushers. Cuando el escaneo llega al nodo de she, ya emparejó she — pero también acaba de emparejar he, porque he es sufijo de she y he es un patrón. La salida propia del nodo she es únicamente {she}; el match de he solo se alcanza siguiendo el failure link (que apunta de she a he). En vez de recorrer la cadena de fallos en cada posición (que podría ser lento), Aho-Corasick lo precalcula durante el BFS: cada nodo hereda el conjunto de salidas de su destino de fallo, así que la salida de she se vuelve {she, he} y reportar cuesta O(1) por nodo más O(1) por match. Esa fusión es la razón de que, en la animación, llegar a she dispare she y he en el mismo paso — la completitud que un recorrido ingenuo del trie dejaría escapar.

En qué es bueno y en qué no

Aho-Corasick es la herramienta definitiva para emparejar muchos patrones fijos a la vez. Sus usos son exactamente los problemas de "encuentra cualquiera de estas cadenas": motores antivirus escaneando archivos contra miles de firmas de malware, sistemas de detección de intrusos en red (Snort, Suricata) comparando flujos de paquetes contra conjuntos de reglas, filtros de spam y groserías revisando listas de palabras, la búsqueda multi-cadena de fgrep, y herramientas de bioinformática encontrando un conjunto de motifs en ADN. Como su costo de búsqueda es independiente de la cantidad de patrones y hace una sola pasada en streaming sin retroceder, es ideal para escaneo en tiempo real de alto throughput donde el diccionario de patrones es grande y fijo. Además compone bien: una vez construido, el autómata puede escanear texto ilimitado, así que construyes una vez y haces streaming para siempre.

Donde es la herramienta equivocada es con un solo patrón (KMP o Boyer-Moore son más simples, y Boyer-Moore es más rápido), o con un conjunto de patrones que cambia seguido — el autómata hay que reconstruirlo cuando los patrones cambian, aunque la construcción es barata comparada con escanear mucho texto. Su memoria crece con el tamaño total del diccionario de patrones (un nodo por carácter distinto de prefijo de patrón), lo cual para diccionarios enormes puede ser considerable, y la implementación clásica guarda un mapa goto por nodo; las versiones de producción usan representaciones compactas (tries de doble arreglo, autómatas bit-sliced) para reducirlo. Y para matching aproximado o estilo regex no aplica directamente — es matching exacto de cadenas fijas. Pero para ese nicho — muchos patrones exactos, un texto, tiempo lineal — nada le gana.

Los datos, o las entradas

El enfrentamiento fija un texto aleatorio de 5000 caracteres y busca un número creciente de patrones, contando caracteres examinados para la pasada única de Aho-Corasick contra escanear el texto una vez por patrón — así aísla la independencia respecto al número de patrones. La corrección se verifica con cientos de diccionarios y textos aleatorios: los matches de Aho-Corasick deben ser exactamente iguales al resultado de buscar cada patrón por separado con el str.find de Python. La animación corre el ejemplo de libro de texto — el diccionario {he, she, his, hers} escaneando el texto ushers — dibujando el trie e iluminando el estado actual del autómata conforme se alimenta cada carácter, disparando matches (incluido el he reportado vía failure link dentro de she) conforme se completan.

Constrúyelo, una función a la vez

El trie — inserta cada patrón, compartiendo prefijos comunes:

class AhoCorasick:
    """A trie of patterns augmented with failure and output links — the Aho-Corasick automaton."""

    def __init__(self):
        self.goto = [{}]                          # goto[node][char] -> child node; node 0 is the root
        self.fail = [0]                           # failure link per node (filled in by build)
        self.out = [set()]                        # patterns ending at this node
        self.parent = [(-1, "")]                  # (parent node, edge char) — for drawing the trie

    def add(self, word):
        """Insert one pattern, creating trie nodes for any new characters along its path."""
        node = 0
        for ch in word:
            if ch not in self.goto[node]:
                self.goto.append({})
                self.fail.append(0)
                self.out.append(set())
                self.parent.append((node, ch))
                self.goto[node][ch] = len(self.goto) - 1
            node = self.goto[node][ch]
        self.out[node].add(word)                  # this node marks the end of `word`

Los failure y output links — la idea de KMP, construida por anchura sobre el árbol:

def build(self):
    """Compute failure links by breadth-first traversal (shallower nodes first, so a node's
    failure target is already finished when we need it — the same order dependency as KMP's
    table). A node's failure link is the deepest OTHER node whose string is a proper suffix of
    this node's string. Output sets are merged along failure links, so reaching a node reports
    not just its own pattern but every pattern that is a suffix of it (that's how 'hers' also
    reports 'he')."""
    q = deque()
    for ch, nxt in self.goto[0].items():
        self.fail[nxt] = 0                     # depth-1 nodes fail to the root
        q.append(nxt)
    while q:
        u = q.popleft()
        for ch, v in self.goto[u].items():
            q.append(v)
            f = self.fail[u]                   # follow u's failure chain to find where ch continues
            while f and ch not in self.goto[f]:
                f = self.fail[f]
            self.fail[v] = self.goto[f].get(ch, 0) if self.goto[f].get(ch, 0) != v else 0
            self.out[v] |= self.out[self.fail[v]]   # inherit suffix patterns

Y la búsqueda de una sola pasada sobre el texto:

def search(self, text):
    """Scan the text once. Follow goto edges when the next character extends the current match;
    on a dead end, follow failure links (never rewinding the text) until the character fits or
    we're back at the root. At each position, the current node's output set names every pattern
    ending here. Returns (start_index, pattern) for every occurrence. O(n + matches)."""
    node = 0
    results = []
    for i, ch in enumerate(text):
        while node and ch not in self.goto[node]:
            node = self.fail[node]             # fall back, like KMP, without moving in the text
        node = self.goto[node].get(ch, 0)
        for w in self.out[node]:
            results.append((i - len(w) + 1, w))
    return sorted(results)

Míralo funcionar

Aquí está el autómata para {he, she, his, hers} escaneando el texto ushers. Los nodos marcados con rombo (color teal) son donde termina un patrón; el naranja es el estado actual del autómata. Alimenta los caracteres uno por uno: u no tiene arista desde la raíz, así que nos quedamos donde estamos; s avanza al nodo s, h a sh, e a she — y llegar a she dispara dos matches a la vez, she y he, porque el output link fusionó he en las salidas de she. Luego r no tiene arista desde she, así que el autómata sigue un failure link — cae al nodo he, que sí tiene arista r — y continúa a her; la s final llega a hers y dispara ese match. Una pasada de izquierda a derecha, el puntero del texto sin rebobinar nunca, y las tres apariciones encontradas:

El código completo

La pestaña "desde cero" es el autómata completo de Aho-Corasick — trie, failure links, fusión de salidas y búsqueda; la pestaña de librería es el enfoque de una pasada por patrón contra el que se verifica y se compite, con la llamada a pyahocorasick que usarías en producción anotada. Cambia entre ellas.

"""Aho-Corasick — find ALL occurrences of MANY patterns in one pass over the text. Rabin-Karp
could search many equal-length patterns; Aho-Corasick searches a whole dictionary of patterns of
any lengths, in a single linear scan, and it's the right tool when the pattern set is large: virus
signatures, network-intrusion rules, spam word lists, keyword filters.

It's KMP generalized from one pattern to many. First, arrange all the patterns into a TRIE (a
prefix tree) so shared prefixes are shared paths. Then add FAILURE LINKS exactly like KMP's
failure function, but on the trie: from each node, a failure link points to the node representing
the longest proper suffix of the current matched string that is also a prefix of some pattern —
where to fall back to when the next character doesn't extend the current match. Feed the text
through the resulting automaton one character at a time, following goto edges and failure links,
and OUTPUT links report every pattern that ends at the current position. One pass, O(n + total
pattern length + number of matches).
"""
from collections import deque


# region: trie
class AhoCorasick:
    """A trie of patterns augmented with failure and output links — the Aho-Corasick automaton."""

    def __init__(self):
        self.goto = [{}]                          # goto[node][char] -> child node; node 0 is the root
        self.fail = [0]                           # failure link per node (filled in by build)
        self.out = [set()]                        # patterns ending at this node
        self.parent = [(-1, "")]                  # (parent node, edge char) — for drawing the trie

    def add(self, word):
        """Insert one pattern, creating trie nodes for any new characters along its path."""
        node = 0
        for ch in word:
            if ch not in self.goto[node]:
                self.goto.append({})
                self.fail.append(0)
                self.out.append(set())
                self.parent.append((node, ch))
                self.goto[node][ch] = len(self.goto) - 1
            node = self.goto[node][ch]
        self.out[node].add(word)                  # this node marks the end of `word`
    # endregion

    # region: build
    def build(self):
        """Compute failure links by breadth-first traversal (shallower nodes first, so a node's
        failure target is already finished when we need it — the same order dependency as KMP's
        table). A node's failure link is the deepest OTHER node whose string is a proper suffix of
        this node's string. Output sets are merged along failure links, so reaching a node reports
        not just its own pattern but every pattern that is a suffix of it (that's how 'hers' also
        reports 'he')."""
        q = deque()
        for ch, nxt in self.goto[0].items():
            self.fail[nxt] = 0                     # depth-1 nodes fail to the root
            q.append(nxt)
        while q:
            u = q.popleft()
            for ch, v in self.goto[u].items():
                q.append(v)
                f = self.fail[u]                   # follow u's failure chain to find where ch continues
                while f and ch not in self.goto[f]:
                    f = self.fail[f]
                self.fail[v] = self.goto[f].get(ch, 0) if self.goto[f].get(ch, 0) != v else 0
                self.out[v] |= self.out[self.fail[v]]   # inherit suffix patterns
    # endregion

    # region: search
    def search(self, text):
        """Scan the text once. Follow goto edges when the next character extends the current match;
        on a dead end, follow failure links (never rewinding the text) until the character fits or
        we're back at the root. At each position, the current node's output set names every pattern
        ending here. Returns (start_index, pattern) for every occurrence. O(n + matches)."""
        node = 0
        results = []
        for i, ch in enumerate(text):
            while node and ch not in self.goto[node]:
                node = self.fail[node]             # fall back, like KMP, without moving in the text
            node = self.goto[node].get(ch, 0)
            for w in self.out[node]:
                results.append((i - len(w) + 1, w))
        return sorted(results)
    # endregion
"""The library counterpart and the contrast. For production multi-pattern matching you'd use a
maintained Aho-Corasick library (`pyahocorasick`, a C extension) or a regex engine's alternation:

    import ahocorasick
    A = ahocorasick.Automaton()
    for i, w in enumerate(patterns):
        A.add_word(w, (i, w))
    A.make_automaton()
    for end, (i, w) in A.iter(text):
        ...

The instructive contrast is the obvious alternative: search for each pattern SEPARATELY, one pass
over the text per pattern. `search_each` below does exactly that with str.find — the correctness
reference and the k-passes cost Aho-Corasick's single pass is timed against.
"""


# region: per_pattern
def search_each(text, patterns):
    """Find every pattern by searching for each one independently — one full scan of the text per
    pattern (k patterns → k scans). Returns the same (start, pattern) list Aho-Corasick produces,
    so it's both the correctness reference and the 'naive multi-pattern' contrast."""
    results = []
    for w in patterns:
        if not w:
            continue
        i = text.find(w)
        while i != -1:
            results.append((i, w))
            i = text.find(w, i + 1)               # +1 → overlapping occurrences
    return sorted(results)
# endregion

Desde cero vs librería

Aho-Corasick es el cierre de este bloque porque une todos sus hilos. Es la función de fallo de KMP (el primer capítulo del bloque) generalizada a un trie (el árbol de prefijos del bloque de árboles), resolviendo la versión multi-patrón del problema que insinuaba la multi-búsqueda de Rabin-Karp, con la garantía lineal limpia que la disciplina se pasó los años setenta estableciendo. La lección recurrente del bloque de cadenas queda a la vista: el matching saca su velocidad de precalcular estructura antes del escaneo — la tabla de KMP, el rolling hash de Rabin-Karp, la tabla de saltos de Boyer-Moore, el índice del suffix array y ahora el autómata de Aho-Corasick son todos la misma jugada: paga por adelantado para que el escaneo salga barato. Y el resultado de "una pasada le gana a muchas" refleja el truco multi-patrón de Rabin-Karp y las consultas amortizadas del suffix array: cuando vas a hacer mucho matching, compila el trabajo en una estructura una sola vez. En producción usarías pyahocorasick (una extensión en C) por el throughput; construir el autómata tú mismo es lo que convierte la maquinaria de failure links y fusión de salidas — la parte que es fácil de equivocar de forma sutil — en algo que entiendes en lugar de algo en lo que confías.

Dónde te lo vas a encontrar

Aho-Corasick corre dentro de la infraestructura de seguridad y de texto de la que dependes a diario. Los motores antivirus y anti-malware escanean archivos contra diccionarios de miles de firmas de bytes con él. Los sistemas de detección y prevención de intrusos en red — Snort, Suricata y los motores de inspección profunda de paquetes en los firewalls — comparan payloads de paquetes contra grandes conjuntos de reglas en tiempo real usando Aho-Corasick (y sus variantes compactas). Filtros de contenido, detectores de groserías y spam, y sistemas de prevención de fuga de datos comparan contra listas de keywords. Las herramientas de búsqueda (fgrep, grep -F con varios patrones, la ruta multi-literal de ripgrep) lo usan a él o a parientes cercanos. La bioinformática lo usa para encontrar conjuntos de motifs de ADN/proteínas, y la lingüística computacional para tokenización con diccionario. Donde sea que un flujo tenga que compararse contra muchas cadenas fijas a la vez y rápido, Aho-Corasick es casi con certeza el motor.

Puntos clave

Aho-Corasick compila un diccionario completo de patrones en un solo autómata — un trie de los patrones más failure links al estilo KMP y output links fusionados — y encuentra cada aparición de cada patrón en una pasada lineal sobre el texto, con un costo de búsqueda independiente del número de patrones (260× menos caracteres examinados que la búsqueda patrón por patrón con 500 patrones). Es la generalización multi-patrón de KMP: los failure links son la función de fallo de KMP sobre un árbol, la fusión de salidas garantiza que se reporte cada patrón que termina en una posición, y el escaneo es lineal amortizado por la misma razón que el de KMP.

Con eso cierra el bloque de cadenas — búsqueda de un solo patrón por tres vías (KMP, Rabin-Karp, Boyer-Moore), indexado de texto (suffix arrays) y matching multi-patrón (Aho-Corasick) — todos unidos por el tema de precalcular estructura para abaratar el escaneo. El libro ahora deja atrás las estructuras de datos y problemas específicos para pasar a los paradigmas que generan algoritmos: el siguiente bloque trata de las estrategias reutilizables — divide y vencerás, greedy, programación dinámica, backtracking, dos punteros — que sostienen buena parte de lo que ya construiste, haciendo explícitos los patrones que venían repitiéndose de forma implícita todo este tiempo.