Árboles

"Los árboles convierten lookup en búsqueda binaria. Eso es magia."

Qué vas a aprender en este capítulo

Los árboles son una generalización de listas enlazadas: en vez de un puntero al siguiente, varios punteros a hijos. Modelan jerarquías (organigramas, archivos), permiten búsqueda en O(logn)O(\log n) (BST balanceados), implementan colas de prioridad (heaps), y son la base de muchos algoritmos. Vas a implementar BST, heaps, recorrer árboles de tres formas distintas, y entender por qué los árboles balanceados son tan importantes en la práctica.

3.1 La idea: jerarquías

💡 Intuición

Un árbol es una estructura de datos donde cada elemento (nodo) tiene cero o más hijos. Existe una raíz, y cada nodo (excepto la raíz) tiene exactamente un padre.

        Pupusería
       /    |    \
   Sucursal  Sucursal  Sucursal
   "Centro"  "Mall"    "Periférico"
   /    \      |        |
  Caja  Cocina Caja    Caja

Un árbol modela cualquier jerarquía: archivos en carpetas, organigrama, taxonomía, DOM de una página web, AST de un programa, decisiones de un AI.

Cuándo usar árbol vs lista:

  • Lista: orden lineal, recorrer secuencial.
  • Árbol: jerarquía o búsqueda eficiente.

Una propiedad mágica de los árboles binarios balanceados: tienen altura O(logn)O(\log n). Eso significa que si los buscás bien, podés alcanzar cualquier elemento en pocos pasos aunque haya millones.

3.2 Vocabulario

📐 Fundamento

Término Significado
Raíz Nodo sin padre
Hoja Nodo sin hijos
Padre / hijo Relación directa
Descendiente / ancestro Cualquier abajo / arriba
Nivel Distancia desde la raíz (raíz = 0)
Altura Nivel máximo del árbol
Subárbol Un nodo + todos sus descendientes
Grado Cantidad de hijos del nodo
Árbol binario Cada nodo tiene 0, 1 o 2 hijos

Propiedades:

  • Un árbol con nn nodos tiene n1n - 1 aristas.
  • Hay un único camino entre dos nodos cualquiera.
  • En un árbol binario perfecto de altura hh, hay 2h+112^{h+1} - 1 nodos. Inversa: hlog2nh \approx \log_2 n.

3.3 Árbol binario

📐 Fundamento

Cada nodo tiene a lo sumo dos hijos: izquierdo y derecho.

class Nodo:
    def __init__(self, dato, izq=None, der=None):
        self.dato = dato
        self.izq = izq
        self.der = der

# Construir un árbol manual
arbol = Nodo("A",
    Nodo("B", Nodo("D"), Nodo("E")),
    Nodo("C", None, Nodo("F"))
)
#         A
#        / \
#       B   C
#      / \   \
#     D   E   F

Recorridos

Tres recorridos clásicos, todos O(n)O(n):

Preorden: raíz → izquierdo → derecho.

def preorden(nodo):
    if nodo is None: return
    print(nodo.dato)
    preorden(nodo.izq)
    preorden(nodo.der)

# Para el árbol arriba: A, B, D, E, C, F

Inorden: izquierdo → raíz → derecho.

def inorden(nodo):
    if nodo is None: return
    inorden(nodo.izq)
    print(nodo.dato)
    inorden(nodo.der)

# Para el árbol arriba: D, B, E, A, C, F

Postorden: izquierdo → derecho → raíz.

def postorden(nodo):
    if nodo is None: return
    postorden(nodo.izq)
    postorden(nodo.der)
    print(nodo.dato)

# Para el árbol arriba: D, E, B, F, C, A

Cuándo cada uno:

  • Preorden: copiar / serializar el árbol. JSON-ish.
  • Inorden: en BST, devuelve los elementos ordenados.
  • Postorden: liberar memoria del árbol (los hijos primero), evaluar expresiones.

BFS / por niveles: con cola.

from collections import deque

def por_niveles(raiz):
    if raiz is None: return
    cola = deque([raiz])
    while cola:
        nodo = cola.popleft()
        print(nodo.dato)
        if nodo.izq: cola.append(nodo.izq)
        if nodo.der: cola.append(nodo.der)

# Para el árbol arriba: A, B, C, D, E, F

3.4 Árbol binario de búsqueda (BST)

📐 Fundamento

Un BST es un árbol binario con la invariante:

Para cada nodo: todos los valores en el subárbol izquierdo son menores; todos en el derecho son mayores.

        50
       /  \
      30   70
     / \   / \
    20 40 60 80

Búsqueda eficiente: comparás con la raíz, vas a izq o der según corresponda. Cada paso descarta la mitad del árbol.

class BST:
    def __init__(self):
        self.raiz = None

    def insertar(self, x):
        self.raiz = self._insertar(self.raiz, x)

    def _insertar(self, nodo, x):
        if nodo is None:
            return Nodo(x)
        if x < nodo.dato:
            nodo.izq = self._insertar(nodo.izq, x)
        elif x > nodo.dato:
            nodo.der = self._insertar(nodo.der, x)
        # x == nodo.dato: ignorar duplicados
        return nodo

    def buscar(self, x):
        return self._buscar(self.raiz, x)

    def _buscar(self, nodo, x):
        if nodo is None: return False
        if x == nodo.dato: return True
        if x < nodo.dato: return self._buscar(nodo.izq, x)
        return self._buscar(nodo.der, x)

    def __contains__(self, x):
        return self.buscar(x)

Eliminar es más feo. Tres casos:

  1. Hoja: eliminar directo.
  2. Un solo hijo: reemplazar el nodo con su hijo.
  3. Dos hijos: reemplazar con su sucesor inorden (el más chico del subárbol derecho), luego eliminar el sucesor.
def _eliminar(self, nodo, x):
    if nodo is None: return None
    if x < nodo.dato:
        nodo.izq = self._eliminar(nodo.izq, x)
    elif x > nodo.dato:
        nodo.der = self._eliminar(nodo.der, x)
    else:
        # Encontrado
        if nodo.izq is None: return nodo.der
        if nodo.der is None: return nodo.izq
        # Dos hijos: sucesor inorden
        sucesor = nodo.der
        while sucesor.izq:
            sucesor = sucesor.izq
        nodo.dato = sucesor.dato
        nodo.der = self._eliminar(nodo.der, sucesor.dato)
    return nodo

Análisis

  • Mejor caso (árbol balanceado): todas las operaciones O(logn)O(\log n).
  • Peor caso (árbol "lineal"): O(n)O(n) — degenera en lista enlazada.

Ejemplo del peor caso: insertar 1, 2, 3, 4, 5, 6 en orden:

1
 \
  2
   \
    3
     \
      4
       ...

Búsqueda de "6": O(n)O(n). El BST sin balanceo es frágil.

3.5 Árboles balanceados (AVL, Red-Black)

📐 Fundamento

Para evitar la degeneración, los árboles balanceados se reorganizan automáticamente para mantener altura O(logn)O(\log n).

AVL (Adelson-Velsky & Landis, 1962). El primero. Mantiene la diferencia de altura entre subárboles izquierdo y derecho ≤ 1. Tras cada inserción/eliminación, hace rotaciones si se desequilibra.

Red-Black trees. Cada nodo es rojo o negro, con reglas que garantizan altura 2log2(n+1)\leq 2 \log_2(n+1). Más relajado que AVL pero más rápido en inserciones. Lo usan std::map (C++), TreeMap (Java), kernel de Linux.

Rotaciones — la operación clave:

       y                          x
      / \                        / \
     x   C    rotación der      A   y
    / \      ←-------------         / \
   A   B                            B   C

Una rotación reorganiza tres nodos preservando el orden BST y reduciendo la altura.

No los implementamos a fondo aquí. El código de un AVL o Red-Black es largo (200+ líneas). El concepto que sí debés sacar:

En la práctica, siempre usá una estructura balanceada (de la biblioteca estándar) en lugar de implementar tu propio BST. Las bibliotecas tienen décadas de optimizaciones y casos esquina cubiertos.

En Python:

  • No hay BST en stdlib.
  • sortedcontainers.SortedList (paquete externo) usa skip lists, comportamiento similar.
  • bintrees (paquete externo) tiene AVL y Red-Black.
  • Para casos donde un BST sería natural, muchas veces un dict + lista alcanza.

En otros lenguajes:

  • C++: std::map, std::set (red-black).
  • Java: TreeMap, TreeSet.
  • Rust: BTreeMap, BTreeSet (B-tree, similar pero distinto).

3.6 Heaps (montones)

📐 Fundamento

Un heap es un árbol binario completo (lleno por niveles) con la propiedad:

  • Min-heap: cada nodo es ≤ que sus hijos. La raíz es el mínimo.
  • Max-heap: cada nodo es ≥ que sus hijos. La raíz es el máximo.

No es un BST — los hijos pueden estar en cualquier orden entre sí.

Implementación: array. Un heap se representa como array donde:

  • Padre de i: (i - 1) // 2.
  • Hijo izquierdo: 2*i + 1.
  • Hijo derecho: 2*i + 2.
índice:   0  1  2  3  4  5
valor:   [1, 3, 2, 5, 4, 9]

         1                 (i=0)
        / \
       3   2               (i=1, i=2)
      / \ /
     5  4 9                (i=3, i=4, i=5)

Es min-heap: 1 < 3, 2; 3 < 5, 4; 2 < 9.

Operaciones:

  • peek (mín): raíz, O(1)O(1).
  • push: insertar al final, "burbujear" hacia arriba. O(logn)O(\log n).
  • pop: sacar la raíz, mover el último a la raíz, "hundir" hacia abajo. O(logn)O(\log n).

Python ya tiene heapq (min-heap):

import heapq

h = []
heapq.heappush(h, 5)
heapq.heappush(h, 1)
heapq.heappush(h, 3)
heapq.heappush(h, 8)

print(h)               # [1, 5, 3, 8]  (orden de heap, no ordenado)
print(heapq.heappop(h))  # 1
print(heapq.heappop(h))  # 3

heapq opera sobre listas. Es eficiente, parte de stdlib.

Aplicaciones

Cola de prioridad. El uso clásico. Tareas con prioridad → la más prioritaria sale primero.

Heapsort. Ordenamiento O(nlogn)O(n \log n):

def heapsort(lista):
    h = lista[:]
    heapq.heapify(h)         # convierte a heap en O(n)
    return [heapq.heappop(h) for _ in range(len(h))]

Top K elementos.

def top_k(lista, k):
    return heapq.nlargest(k, lista)

# heapq.nsmallest, nlargest existen y son eficientes

Algoritmo de Dijkstra (rutas más cortas) usa min-heap.

3.7 Tries (vista rápida)

📐 Fundamento

Un trie (pronunciado "tray") es un árbol especializado en strings. Cada arista es una letra, cada camino desde la raíz es un prefijo.

       (raíz)
       /  |  \
      c   p   ...
      |   |
      a   u
      |   |
      r   p
      |   |
      o   u
          |
          s
          |
          a

"caro" y "pupusa" están en el trie. Comparten la raíz pero divergen.

Operaciones O(L)O(L) donde LL es la longitud de la palabra. Eso es independiente de cuántas palabras haya guardadas.

Aplicaciones:

  • Autocompletado (Google search bar).
  • Diccionarios y correctores ortográficos.
  • Routing IP (longest prefix match).
  • Frecuencia de palabras en textos.
class Trie:
    def __init__(self):
        self.hijos = {}
        self.fin = False

    def insertar(self, palabra):
        nodo = self
        for c in palabra:
            if c not in nodo.hijos:
                nodo.hijos[c] = Trie()
            nodo = nodo.hijos[c]
        nodo.fin = True

    def buscar(self, palabra):
        nodo = self
        for c in palabra:
            if c not in nodo.hijos:
                return False
            nodo = nodo.hijos[c]
        return nodo.fin

    def empieza_con(self, prefijo):
        nodo = self
        for c in prefijo:
            if c not in nodo.hijos:
                return False
            nodo = nodo.hijos[c]
        return True


t = Trie()
t.insertar("pupusa")
t.insertar("pupuseria")
print(t.buscar("pupusa"))         # True
print(t.empieza_con("pup"))       # True
print(t.buscar("pup"))             # False

3.8 Grafos (vista rápida)

Un grafo es la generalización del árbol: un conjunto de nodos y aristas sin restricciones de jerarquía.

Representaciones:

# Lista de adyacencia (dict)
grafo = {
    "A": ["B", "C"],
    "B": ["A", "D"],
    "C": ["A"],
    "D": ["B"],
}

# Matriz de adyacencia
matriz = [
    [0, 1, 1, 0],
    [1, 0, 0, 1],
    [1, 0, 0, 0],
    [0, 1, 0, 0],
]

Lista de adyacencia es mejor para grafos dispersos (pocos vecinos). Matriz para densos o cuando hay que chequear "¿están conectados?" O(1)O(1).

Algoritmos básicos:

Materia entera de Algoritmos Avanzados. Por ahora, sabé que existen y que se construyen sobre estructuras del cap 2 + 3.

3.9 Resumen visual

Estructura Ideal para Ops principales
Árbol binario general Jerarquía cualquiera Recorridos O(n)O(n)
BST balanceado Lookup + ordenamiento Insert/buscar/borrar O(logn)O(\log n)
Heap Cola de prioridad Mín en O(1)O(1), push/pop O(logn)O(\log n)
Trie Strings y prefijos O(L)O(L) por palabra
Grafo Relaciones arbitrarias BFS, DFS, Dijkstra
Recorrido Orden Aplicación
Preorden Raíz → izq → der Serializar
Inorden Izq → raíz → der Ordenado en BST
Postorden Izq → der → raíz Liberar memoria
BFS / niveles Por capas Distancia mínima

3.10 Ejercicios

✏️ Ejercicio 3.1 — Altura de un árbol

Implementá altura(nodo) que devuelve la altura del árbol (raíz = 0 si nodo único, -1 si es None).

✏️ Ejercicio 3.2 — Verificar BST

Dado un nodo raíz de árbol binario, decir si es BST válido.

✏️ Ejercicio 3.3 — Top K

Dada una lista de un millón de números, encontrá los 10 más grandes sin ordenar todo.

✏️ Ejercicio 3.4 — Trie autocompletado

Extendé Trie con método autocompletar(prefijo) que devuelve todas las palabras del trie que comienzan con ese prefijo.

3.11 Para profundizar


Definiciones nuevas: árbol, raíz, hoja, nivel, altura, árbol binario, BST, AVL, Red-Black, rotación, heap (min/max), heapsort, cola de prioridad, trie, grafo, lista de adyacencia.