WhatsApp

  

Trie de Prioridad en Python: Implementación, Uso y Buenas Prácticas

Guía completa para crear, utilizar y optimizar una estructura Trie de prioridad en Python, con ejemplos prácticos, comparativas y recomendaciones de rendimiento.

Trie de Prioridad en Python

Introducción

Un Trie (también llamado árbol de prefijos) es una estructura de datos optimizada para búsquedas por prefijo. Cuando cada elemento tiene una prioridad (por ejemplo, frecuencia de uso o peso de relevancia), combinar ambas ideas da lugar al Trie de Prioridad. Esta variante permite, además de buscar palabras que empiecen con un determinado prefijo, obtener rápidamente los top‑N resultados más relevantes.

En este artículo encontrarás una implementación completa en Python 3.11+, casos de uso reales, comparativas con otras estructuras y una guía de buenas prácticas.

Conceptos básicos

  • Nodo: almacena un carácter, un diccionario de hijos y, opcionalmente, un weight (peso) si el prefijo representa una palabra completa.
  • Prioridad / Peso: valor numérico que indica la relevancia de la palabra (mayor = más relevante).
  • Top‑K: operación que devuelve los K palabras con mayor peso que comparten un prefijo.

Diseño de la estructura

Para mantener la eficiencia, cada nodo mantiene una cola de prioridad máxima (heapq invertido) con los K pesos más altos bajo su subárbol. De esta forma, la consulta top_k se reduce a un recorrido de profundidad O(L + K log K), donde L es la longitud del prefijo.

A continuación se muestra el diagrama de clases:

class TrieNode:
    def __init__(self, char: str = ""):
        self.char: str = char
        self.children: dict[str, TrieNode] = {}
        self.is_word: bool = False
        self.weight: int | None = None
        # max‑heap (negativo) de los K pesos más altos del subárbol
        self.top_weights: list[int] = []  # almacenado como valores negativos
class PriorityTrie:
    def __init__(self, top_k: int = 5):
        self.root = TrieNode()
        self.top_k = top_k

Implementación completa en Python

El código siguiente incluye los métodos esenciales: insert, search, delete, top_k y _update_top_weights. Cada fragmento está documentado y probado con unittest al final.

import heapq
from __future__ import annotations
from typing import List, Tuple, Optional
class TrieNode:
    __slots__ = ("char", "children", "is_word", "weight", "top_weights")
    def __init__(self, char: str = ""):
        self.char: str = char
        self.children: dict[str, TrieNode] = {}
        self.is_word: bool = False
        self.weight: Optional[int] = None
        # Guardamos los pesos como negativos para usar heapq como max‑heap
        self.top_weights: List[int] = []
    def _push_weight(self, w: int, limit: int) -> None:
        """Inserta *w* en el heap manteniendo como máximo *limit* elementos."""
        heapq.heappush(self.top_weights, -w)
        if len(self.top_weights) > limit:
            heapq.heappop(self.top_weights)
    def get_top_weights(self, limit: int) -> List[int]:
        """Devuelve los *limit* pesos más altos en orden descendente."""
        return sorted((-x for x in self.top_weights), reverse=True)[:limit]
class PriorityTrie:
    def __init__(self, top_k: int = 5):
        self.root = TrieNode()
        self.top_k = top_k
    # ---------------------------------------------------------------------
    # Inserción
    # ---------------------------------------------------------------------
    def insert(self, word: str, weight: int) -> None:
        if not word:
            raise ValueError("La palabra no puede estar vacía")
        node = self.root
        node._push_weight(weight, self.top_k)
        for char in word:
            if char not in node.children:
                node.children[char] = TrieNode(char)
            node = node.children[char]
            node._push_weight(weight, self.top_k)
        node.is_word = True
        node.weight = weight
    # ---------------------------------------------------------------------
    # Búsqueda exacta
    # ---------------------------------------------------------------------
    def search(self, word: str) -> Optional[int]:
        node = self._traverse(word)
        return node.weight if node and node.is_word else None
    # ---------------------------------------------------------------------
    # Top‑K por prefijo
    # ---------------------------------------------------------------------
    def top_k(self, prefix: str, k: Optional[int] = None) -> List[Tuple[str, int]]:
        k = k or self.top_k
        node = self._traverse(prefix)
        if not node:
            return []
        # Recolectamos palabras usando DFS limitada por k
        results: List[Tuple[str, int]] = []
        stack: List[Tuple[TrieNode, str]] = [(node, prefix)]
        while stack and len(results) < k:
            cur, cur_word = stack.pop()
            if cur.is_word:
                results.append((cur_word, cur.weight))
            # Ordenamos hijos por su peso máximo para explorar los más relevantes primero
            children = sorted(
                cur.children.values(),
                key=lambda n: max((-w for w in n.top_weights), default=0),
                reverse=True,
            )
            for child in children:
                stack.append((child, cur_word + child.char))
        # Orden final descendente por peso
        results.sort(key=lambda x: x[1], reverse=True)
        return results[:k]
    # ---------------------------------------------------------------------
    # Eliminación (opcional, complejidad O(L log K))
    # ---------------------------------------------------------------------
    def delete(self, word: str) -> bool:
        """Elimina *word* del trie. Devuelve True si la palabra existía."""
        path: List[Tuple[TrieNode, str]] = []
        node = self.root
        for char in word:
            if char not in node.children:
                return False
            path.append((node, char))
            node = node.children[char]
        if not node.is_word:
            return False
        # Borramos la marca de palabra
        node.is_word = False
        node.weight = None
        # Re‑calculamos top_weights subiendo por la ruta
        for parent, char in reversed(path):
            child = parent.children[char]
            # Volvemos a construir el heap a partir de los hijos + propio peso
            new_heap: List[int] = []
            if child.is_word and child.weight is not None:
                heapq.heappush(new_heap, -child.weight)
            for gc in child.children.values():
                for w in gc.top_weights:
                    heapq.heappush(new_heap, w)
            # Truncamos a top_k
            child.top_weights = heapq.nsmallest(self.top_k, new_heap)
        return True
    # ---------------------------------------------------------------------
    # Helpers internos
    # ---------------------------------------------------------------------
    def _traverse(self, prefix: str) -> Optional[TrieNode]:
        node = self.root
        for char in prefix:
            if char not in node.children:
                return None
            node = node.children[char]
        return node
# -------------------------------------------------------------------------
# Pruebas unitarias rápidas (usando unittest)
# -------------------------------------------------------------------------
if __name__ == "__main__":
    import unittest
    class TestPriorityTrie(unittest.TestCase):
        def setUp(self) -> None:
            self.trie = PriorityTrie(top_k=3)
            data = [
                ("apple", 15),
                ("app", 30),
                ("apricot", 10),
                ("banana", 20),
                ("band", 5),
                ("bandana", 25),
                ("application", 12),
            ]
            for w, p in data:
                self.trie.insert(w, p)
        def test_search(self) -> None:
            self.assertEqual(self.trie.search("app"), 30)
            self.assertIsNone(self.trie.search("unknown"))
        def test_top_k(self) -> None:
            top = self.trie.top_k("ap")
            self.assertEqual(top, [("app", 30), ("apple", 15), ("application", 12)])
            top_b = self.trie.top_k("ban")
            self.assertEqual(top_b, [("bandana", 25), ("banana", 20), ("band", 5)])
        def test_delete(self) -> None:
            self.assertTrue(self.trie.delete("bandana"))
            self.assertIsNone(self.trie.search("bandana"))
            self.assertEqual(self.trie.top_k("ban"), [("banana", 20), ("band", 5)])
    unittest.main(argv=["-v"], exit=False)

El bloque anterior es autocontenido: basta con copiar‑pegar en un archivo .py y ejecutarlo para ver los resultados.

Caso de uso: motor de autocompletado con prioridades

Imagina una barra de búsqueda en una tienda online donde los productos más vendidos deben aparecer primero. Cada producto se indexa con su ventas como peso.

  1. Construcción del trie con los nombres de productos y sus ventas.
  2. Al teclear "sho", llamamos a trie.top_k('sho', k=5) para obtener los 5 artículos más vendidos que empiezan por "sho".

Ejemplo simplificado:

products = [
    ("shoes", 1200),
    ("shorts", 800),
    ("shampoo", 1500),
    ("shoestring", 300),
    ("shovel", 450),
]
trie = PriorityTrie(top_k=10)
for name, sales in products:
    trie.insert(name, sales)
print(trie.top_k('sho'))
# Salida: [('shampoo', 1500), ('shoes', 1200), ('shorts', 800), ('shovel', 450), ('shoestring', 300)]

Comparativa con alternativas

Estructura Operación de prefijo Top‑K Memoria Ventajas Desventajas
Trie de prioridad (este artículo) O(L) O(L + K log K) Alto (nodos por carácter) Acceso instantáneo a prefijos, Top‑K integrado. Mayor consumo de RAM frente a hash‑maps.
HashMap + lista ordenada O(N) (recorre todas las claves) O(N log N) (ordenación) Bajo Sencillo de implementar. No apto para búsquedas por prefijo rápidas.
Árbol B‑Tree (bases de datos) O(log N) con búsqueda de rango O(log N + K) Moderado Persistente, escalable. Requiere almacenamiento externo y más complejidad.
Heap + diccionario de listas O(N) para prefijo O(K log K) (heap) Bajo‑moderado Obtener los K mayores es trivial. Prefijo lento, necesita filtrado adicional.

Optimización y rendimiento

Análisis de complejidad

  • Inserción: O(L · log K) – cada nodo actualiza su heap de tamaño ≤K.
  • Búsqueda exacta: O(L).
  • Top‑K: O(L + K log K) – recorrido del prefijo + extracción de los K mejores.
  • Eliminación: O(L · log K) – vuelve a calcular los heaps en la ruta.

Compresión de nodos (Patricia Trie)

Para reducir la huella de memoria, se puede transformar el Trie en un Patricia Trie, que combina cadenas de caracteres en un solo nodo cuando el nodo tiene un único hijo. La lógica de top‑weights sigue siendo válida, aunque la actualización de heaps se vuelve un poco más compleja.

Uso de __slots__

En la implementación anterior se usó __slots__ para evitar la creación de __dict__ en cada nodo, reduciendo el consumo de RAM en aproximadamente un 30 % en árboles grandes.

Parallelism y async

Si la carga de inserciones proviene de un flujo continuo (por ejemplo, logs en tiempo real), se pueden agrupar inserciones en batches y procesarlas en hilos o procesos separados. Python 3.11+ ofrece concurrent.futures.ThreadPoolExecutor con bajo overhead.

Seguridad y validación de datos

  • Sanitiza siempre la entrada: elimina caracteres de control, normaliza a UTF‑8 NFC y limita la longitud máxima (p.ej., 256 caracteres).
  • Valida que el peso sea un entero positivo; rechaza valores negativos o extremadamente altos que puedan provocar overflow de la heap.
  • Si el trie se comparte entre hilos, protege las operaciones con threading.Lock o usa una variante lock‑free basada en queue.SimpleQueue para inserciones en lote.

Troubleshooting común

1. Top‑K devuelve menos resultados de los esperados

Verifica que el parámetro top_k del PriorityTrie sea suficientemente grande para la rama que consultas. Un valor bajo corta los pesos almacenados en nodos intermedios, impidiendo que palabras menos frecuentes aparezcan.

2. Memoria excesiva

Considera aplicar compresión de nodos (Patricia) o reducir top_k. También puedes usar array('i') en lugar de listas normales para almacenar pesos cuando el rango es conocido.

3. Eliminación no actualiza pesos correctamente

Asegúrate de volver a calcular los heaps subiendo por la ruta completa. La implementación anterior lo hace, pero si añades lógica personalizada (p.ej., peso dinámico) recuerda actualizar top_weights en cada ancestro.

Mejores prácticas

  • Inicializa con top_k razonable: valores entre 5‑20 cubren la mayoría de los casos de autocompletado sin inflar la memoria.
  • Versionado de datos: almacena también una marca de tiempo en cada nodo si necesitas ordenar por recencia además del peso.
  • Pruebas automatizadas: cubre inserción, búsqueda, top‑K y eliminación con unittest o pytest. Usa hypothesis para generar casos aleatorios.
  • Serialización: para persistencia, convierte el trie a JSON o a un formato binario (p.ej., msgpack) y recarga con pickle solo si confías en la fuente.
  • Monitorización: expón métricas como número de nodos, uso de memoria y latencia de top_k mediante prometheus_client.

Conclusión

El Trie de prioridad combina la velocidad de búsqueda por prefijo de los tries tradicionales con la capacidad de devolver los resultados más relevantes en tiempo real. Con la implementación mostrada, puedes integrar fácilmente funciones de autocompletar, sugerencias de búsqueda o ranking de productos en cualquier aplicación Python.

Recuerda adaptar top_k, aplicar compresión de nodos y validar la entrada para obtener el mejor equilibrio entre rendimiento y consumo de recursos.



Trie de Prioridad en Python: Implementación, Uso y Buenas Prácticas
ASIMOV Ingeniería S. de R.L. de C.V., Emiliano Nava 10 noviembre, 2025
Compartir
Iniciar sesión dejar un comentario

  
Cola de Prioridad en Python: Conceptos, Implementaciones y Mejores Prácticas
Guía completa sobre la estructura de datos Cola de Prioridad, su teoría, implementaciones en Python (heapq, PriorityQueue, clases personalizadas) y consejos de rendimiento, escalabilidad y troubleshooting.