En la lección anterior definimos qué es un algoritmo y escribimos el primero para RutaBus. Ahora daremos un paso atrás para contemplar el panorama completo: los algoritmos se pueden clasificar según varias dimensiones —cómo se estructuran, para qué sirven, si su comportamiento es predecible y si garantizan la solución exacta—. Conocer estas clasificaciones es importante porque te da un mapa mental: cuando te enfrentes a un problema nuevo en RutaBus (o en tu trabajo), sabrás en qué "familia" encaja y qué tipo de herramienta buscar. Además, en esta lección aprenderás en profundidad la recursividad, un concepto transversal que reaparecerá en casi todos los módulos del curso.

Contenido

  1. Las dimensiones de clasificación
  2. Por enfoque: iterativos vs recursivos
  3. La recursividad en detalle: caso base y caso recursivo
  4. Por propósito: búsqueda, ordenación, grafos y más
  5. Deterministas vs no deterministas
  6. Exactos vs heurísticos/aproximados
  7. Avance: estrategias de diseño del Módulo 3

Las dimensiones de clasificación

Un mismo algoritmo puede clasificarse a la vez según varios criterios independientes, igual que una parada de RutaBus puede clasificarse a la vez por zona, por accesibilidad y por líneas que la sirven. Las cuatro dimensiones que veremos son:

Dimensión Pregunta que responde Valores típicos
Enfoque ¿Cómo estructura la repetición? Iterativo, recursivo
Propósito ¿Qué problema resuelve? Búsqueda, ordenación, grafos, compresión...
Determinismo ¿Se comporta siempre igual con la misma entrada? Determinista, no determinista
Exactitud ¿Garantiza la solución óptima/correcta? Exacto, heurístico/aproximado

Por ejemplo, el algoritmo parada_mas_cercana de la lección anterior es iterativo (usa un bucle), de búsqueda (busca un mínimo), determinista (con la misma entrada siempre da la misma salida) y exacto (garantiza encontrar la parada realmente más cercana).

Por enfoque: iterativos vs recursivos

La primera dimensión distingue cómo repite trabajo un algoritmo.

  • Un algoritmo iterativo repite pasos mediante bucles (for, while), manteniendo el estado en variables que se van actualizando.
  • Un algoritmo recursivo resuelve el problema haciendo que una función se llame a sí misma con una versión más pequeña del problema, hasta llegar a un caso tan simple que se resuelve directamente.

Veamos el mismo problema de RutaBus resuelto con ambos enfoques: contar cuántas paradas tiene una línea, representada como lista de nombres.

linea_l1 = ["Plaza Mayor", "Gran Vía", "Hospital Central", "Estación Norte"]

# Enfoque ITERATIVO: un bucle acumula el resultado en una variable
def contar_paradas_iterativo(paradas):
    contador = 0
    for _ in paradas:      # por cada parada...
        contador += 1      # ...sumamos 1 al acumulador
    return contador

# Enfoque RECURSIVO: la función se apoya en sí misma
def contar_paradas_recursivo(paradas):
    if not paradas:                                  # caso base: lista vacía
        return 0
    return 1 + contar_paradas_recursivo(paradas[1:]) # caso recursivo

print(contar_paradas_iterativo(linea_l1))  # 4
print(contar_paradas_recursivo(linea_l1))  # 4

La versión recursiva se lee así: "el número de paradas de una lista es 1 (la primera) más el número de paradas del resto de la lista; y una lista vacía tiene 0 paradas". Es una definición del problema en términos de sí mismo, pero con un problema cada vez más pequeño.

Criterio Iterativo Recursivo
Mecanismo de repetición Bucles (for, while) Llamadas a la propia función
Estado Variables actualizadas en cada vuelta Parámetros de cada llamada
Legibilidad Mejor en problemas lineales simples Mejor en problemas con estructura anidada (árboles, divisiones)
Consumo de memoria Constante en general Una entrada en la pila por cada llamada pendiente
Riesgo típico Bucle infinito por condición mal escrita RecursionError por olvidar o no alcanzar el caso base

Ningún enfoque es "mejor" en abstracto: todo algoritmo recursivo puede reescribirse de forma iterativa y viceversa. La recursión brilla cuando el problema es naturalmente autosimilar (explorar todas las combinaciones de transbordos, recorrer estructuras jerárquicas); la iteración suele ser preferible para recorridos lineales sencillos.

La recursividad en detalle: caso base y caso recursivo

Toda función recursiva bien construida tiene exactamente dos ingredientes:

  1. Caso base: la situación tan simple que se resuelve sin recursión. Es la condición de parada. Sin él (o si nunca se alcanza), la función se llamaría a sí misma indefinidamente.
  2. Caso recursivo: la función se llama a sí misma con una entrada estrictamente más pequeña o más cercana al caso base, y combina ese resultado parcial con algo de trabajo propio.
flowchart TD
    A[Llamada con el problema P] --> B{¿P es el<br/>caso base?}
    B -- Sí --> C[/Devolver solución directa/]
    B -- No --> D[Reducir P a un problema menor P']
    D --> E[Llamada recursiva con P']
    E --> F[Combinar el resultado de P'<br/>con el trabajo propio]
    F --> G[/Devolver resultado/]

Apliquémoslo a un problema real de RutaBus: la línea L2 tiene sus paradas y queremos saber cuántas paradas quedan hasta el final del trayecto desde la parada donde está el usuario.

linea_l2 = ["Estación Norte", "Avenida del Puerto", "Plaza Mayor",
            "Universidad", "Terminal Sur"]

def paradas_restantes(linea, parada_actual):
    """Cuenta las paradas que quedan DESPUÉS de parada_actual.

    Caso base:      la parada actual es la primera de la lista restante
                    -> quedan len - 1... resuelto contando recursivamente.
    """
    # Caso base: la parada actual es la primera de la lista
    if linea[0] == parada_actual:
        return len(linea) - 1
    # Caso recursivo: descartamos la primera parada y buscamos en el resto
    return paradas_restantes(linea[1:], parada_actual)

print(paradas_restantes(linea_l2, "Plaza Mayor"))  # 2 (Universidad y Terminal Sur)

Sigamos la traza de la ejecución para entender qué ocurre por dentro:

paradas_restantes(["Estación Norte", "Avenida del Puerto", "Plaza Mayor", "Universidad", "Terminal Sur"], "Plaza Mayor")
  → "Estación Norte" ≠ "Plaza Mayor" → llamada recursiva con la lista sin la primera
  paradas_restantes(["Avenida del Puerto", "Plaza Mayor", "Universidad", "Terminal Sur"], "Plaza Mayor")
    → "Avenida del Puerto" ≠ "Plaza Mayor" → llamada recursiva
    paradas_restantes(["Plaza Mayor", "Universidad", "Terminal Sur"], "Plaza Mayor")
      → CASO BASE: devuelve len - 1 = 2
    ← 2
  ← 2
← 2

Cada llamada pendiente queda "en espera" en la pila de llamadas hasta que la llamada interior devuelve su resultado. Esto explica el coste en memoria de la recursión: con una línea de 1.000 paradas podría haber hasta 1.000 llamadas apiladas (Python, por defecto, corta en torno a las 1.000 con RecursionError).

Observa también un defecto deliberado del ejemplo: si parada_actual no está en la línea, la lista acabará vacía y linea[0] lanzará IndexError. Un caso base adicional lo arregla:

def paradas_restantes_robusto(linea, parada_actual):
    if not linea:                       # caso base 2: parada no encontrada
        return None
    if linea[0] == parada_actual:       # caso base 1: parada encontrada
        return len(linea) - 1
    return paradas_restantes_robusto(linea[1:], parada_actual)

Regla de oro: enumera primero los casos base (todos), después escribe el caso recursivo, y comprueba que cada llamada recursiva acerca la entrada a algún caso base.

Por propósito: búsqueda, ordenación, grafos y más

La segunda dimensión clasifica los algoritmos por el tipo de problema que resuelven. Es la clasificación más práctica en el día a día, porque los problemas reales "se parecen" a alguna de estas familias:

Familia Qué resuelve Ejemplo en RutaBus Dónde se profundiza
Búsqueda Localizar un elemento (o el mejor) en una colección Encontrar la parada "Plaza Mayor" en el listado; búsqueda binaria Módulo 4 (04-01)
Ordenación Disponer elementos según un criterio Ordenar los próximos autobuses por hora de llegada Módulo 4 (04-02 a 04-04)
Grafos Trabajar con redes de nodos y conexiones Calcular la ruta más rápida entre dos paradas de la red Módulo 4 (04-05, 04-06)
Compresión Reducir el tamaño de los datos preservando la información Comprimir el histórico de posiciones GPS de los autobuses (fuera del alcance del curso)
Criptografía Proteger información Cifrar las credenciales de los usuarios de la app (fuera del alcance del curso)
Optimización numérica Minimizar/maximizar una función Ajustar frecuencias de paso para minimizar esperas Se toca en Módulos 3 y 5

Un pequeño ejemplo de cada una de las dos familias más frecuentes, en versión sencilla (las versiones eficientes llegan en el Módulo 4):

# BÚSQUEDA (lineal): ¿en qué posición de la línea está una parada?
def buscar_parada(linea, nombre):
    for i, parada in enumerate(linea):
        if parada == nombre:
            return i          # encontrada: devolvemos su índice
    return -1                 # no encontrada

# ORDENACIÓN (delegada en Python): próximos buses por hora de llegada
llegadas = [("L3", "10:42"), ("L1", "10:35"), ("L2", "10:39")]
por_hora = sorted(llegadas, key=lambda bus: bus[1])
print(por_hora)  # [('L1', '10:35'), ('L2', '10:39'), ('L3', '10:42')]

En buscar_parada, enumerate nos da a la vez el índice i y el valor parada; devolvemos el índice en cuanto hay coincidencia (no hace falta seguir mirando). En la ordenación usamos el sorted de Python con una función key que indica el criterio (la hora, posición 1 de cada tupla) — en el Módulo 4 abriremos esa "caja negra" y construiremos nuestros propios algoritmos de ordenación.

Deterministas vs no deterministas

Un algoritmo es determinista si, ante la misma entrada, ejecuta siempre exactamente los mismos pasos y produce siempre la misma salida. Todos los que hemos escrito hasta ahora lo son, y es la propiedad deseable por defecto: hace el software predecible y fácil de probar.

Un algoritmo no determinista (en la práctica, aleatorizado) incorpora decisiones al azar, por lo que dos ejecuciones con la misma entrada pueden diferir en los pasos intermedios o incluso en la salida. Lejos de ser un defecto, la aleatoriedad es a veces una herramienta valiosa:

import random

def parada_para_encuesta(paradas):
    """RutaBus quiere encuestar usuarios en una parada elegida al azar
    cada día, para que la muestra no esté sesgada hacia una zona."""
    return random.choice(paradas)

# Dos ejecuciones con la MISMA entrada pueden dar resultados distintos:
print(parada_para_encuesta(["Plaza Mayor", "Estación Norte", "Terminal Sur"]))
Aspecto Determinista No determinista (aleatorizado)
Misma entrada → Misma salida siempre Salida (o camino) potencialmente distinto
Pruebas / depuración Sencillas y reproducibles Requieren fijar la semilla (random.seed)
Usos típicos La inmensa mayoría del software Muestreo, simulación, evitar peores casos patológicos

Un apunte que retomaremos: algunos algoritmos famosos usan aleatoriedad para mejorar su comportamiento típico — por ejemplo, Quick Sort (Módulo 4) suele elegir su "pivote" al azar. La salida sigue siendo correcta y la misma; lo que varía es el camino.

Exactos vs heurísticos/aproximados

La última dimensión responde a: ¿el algoritmo garantiza la mejor solución posible?

  • Un algoritmo exacto garantiza la solución correcta u óptima. parada_mas_cercana es exacto: la parada devuelta es realmente la más cercana.
  • Un algoritmo heurístico o aproximado renuncia a esa garantía a cambio de rapidez o simplicidad: da una solución razonablemente buena casi siempre, pero puede equivocarse o quedarse lejos del óptimo.

¿Por qué renunciar a la exactitud? Porque hay problemas donde encontrar el óptimo exacto es inasumiblemente costoso. Ejemplo clásico adaptado a RutaBus: un supervisor debe visitar 20 paradas en un solo recorrido, minimizando la distancia total (el famoso "problema del viajante"). El número de recorridos posibles es astronómico, así que una heurística razonable es: "desde cada parada, ve siempre a la más cercana no visitada".

def recorrido_supervisor(paradas, inicio):
    """Heurística del 'vecino más cercano': recorrido corto, no necesariamente óptimo."""
    pendientes = [p for p in paradas if p["nombre"] != inicio["nombre"]]
    recorrido = [inicio]
    actual = inicio
    while pendientes:
        # Elegimos la parada pendiente más cercana a la actual
        siguiente = min(
            pendientes,
            key=lambda p: distancia(actual["x"], actual["y"], p["x"], p["y"])
        )
        recorrido.append(siguiente)
        pendientes.remove(siguiente)
        actual = siguiente
    return recorrido

Esta heurística produce recorridos buenos en la práctica, pero se puede demostrar que a veces devuelve recorridos claramente peores que el óptimo. Ese es el trato: velocidad a cambio de garantías.

Aspecto Exacto Heurístico/aproximado
Garantía sobre la solución Óptima/correcta siempre "Buena", sin garantía (o con cota de error)
Coste computacional Puede ser prohibitivo en problemas duros Habitualmente bajo
Cuándo elegirlo Siempre que el coste sea asumible Problemas intratables o con límite de tiempo estricto

Matiz de vocabulario: se suele llamar aproximado al que ofrece una garantía matemática de cercanía al óptimo (p. ej., "como mucho el doble del óptimo") y heurístico al que no ofrece ninguna, solo buen comportamiento empírico.

Avance: estrategias de diseño del Módulo 3

Además de las dimensiones anteriores, los algoritmos suelen agruparse por la estrategia de diseño con la que se construyen. Solo las nombramos aquí — cada una tiene su propia lección en el Módulo 3:

  • Divide y vencerás (03-01): partir el problema en subproblemas más pequeños, resolverlos y combinar sus soluciones.
  • Greedy (voraces) (03-02): construir la solución tomando en cada paso la decisión localmente mejor — la heurística del supervisor que acabamos de ver es de espíritu greedy.
  • Programación dinámica (03-03): resolver subproblemas que se repiten guardando sus resultados para no recalcularlos.
  • Backtracking (03-04): explorar sistemáticamente todas las opciones, retrocediendo cuando un camino no lleva a solución.

Errores Comunes y Consejos

  • Olvidar el caso base (o alguno de ellos) en una función recursiva. El síntoma en Python es RecursionError: maximum recursion depth exceeded. Consejo: escribe los casos base antes que el caso recursivo, y pregúntate "¿qué entradas NO deberían provocar otra llamada?".
  • Llamada recursiva que no reduce el problema. paradas_restantes(linea, parada_actual) llamándose con la misma lista jamás termina. Verifica que cada llamada recursiva pasa una entrada estrictamente más cercana al caso base.
  • Usar recursión para recorridos lineales largos en Python. Con listas de miles de elementos agotarás la pila. Para recorridos simples, prefiere la iteración; reserva la recursión para problemas con estructura ramificada.
  • Creer que "no determinista" significa "incorrecto". Un algoritmo aleatorizado bien diseñado es tan legítimo como uno determinista; solo exige disciplina extra en las pruebas (fijar random.seed(42) para reproducirlas).
  • Usar una heurística cuando el problema admite solución exacta barata. Antes de aceptar soluciones "aproximadas", comprueba si existe un algoritmo exacto eficiente para tu problema (muchas veces existe y está en el Módulo 4).
  • Clasificar el problema demasiado tarde. Consejo profesional: antes de programar, pregunta "¿esto es búsqueda, ordenación, grafos...?". Identificar la familia te lleva directo a soluciones conocidas en lugar de reinventarlas.

Ejercicios

Ejercicio 1

Clasifica la función recorrido_supervisor de esta lección según las cuatro dimensiones (enfoque, propósito, determinismo, exactitud) y justifica cada respuesta en una frase.

Ejercicio 2

Escribe una función recursiva hay_parada_recursiva(linea, nombre) que devuelva True si la parada nombre está en la lista linea y False en caso contrario. Identifica explícitamente en comentarios el caso base (o casos base) y el caso recursivo. Después, escribe la versión iterativa y compara: ¿cuál te parece más clara para este problema?

Ejercicio 3

La siguiente función recursiva pretende sumar los minutos de espera de una lista, pero tiene dos errores. Encuéntralos y corrígelos:

def suma_esperas(esperas):
    if len(esperas) == 1:
        return esperas[0]
    return esperas[0] + suma_esperas(esperas)

Soluciones

Solución 1:

  • Enfoque: iterativo — repite mediante un bucle while, sin llamarse a sí misma.
  • Propósito: optimización (busca un recorrido corto), apoyándose en operaciones de búsqueda del mínimo.
  • Determinismo: determinista — con las mismas paradas y el mismo inicio produce siempre el mismo recorrido (el min de Python resuelve los empates siempre igual: gana el primero).
  • Exactitud: heurístico — no garantiza el recorrido de distancia mínima, solo uno razonablemente corto.

Solución 2:

# Versión recursiva
def hay_parada_recursiva(linea, nombre):
    if not linea:                 # CASO BASE 1: lista vacía -> no está
        return False
    if linea[0] == nombre:        # CASO BASE 2: la primera coincide -> está
        return True
    # CASO RECURSIVO: buscar en el resto de la lista (problema más pequeño)
    return hay_parada_recursiva(linea[1:], nombre)

# Versión iterativa
def hay_parada_iterativa(linea, nombre):
    for parada in linea:
        if parada == nombre:
            return True
    return False

Para un recorrido lineal como este, la versión iterativa suele considerarse más clara y además no consume pila. La recursiva es un buen ejercicio, pero en producción (y en Python) la iterativa es la elección natural. Fíjate en que hacen falta dos casos base: uno de éxito y otro de fracaso.

Solución 3:

Errores:

  1. La llamada recursiva no reduce el problema: suma_esperas(esperas) se llama con la misma lista, provocando recursión infinita. Debe ser suma_esperas(esperas[1:]).
  2. Falta el caso base de lista vacía: con esperas = [], la condición len(esperas) == 1 es falsa y esperas[0] lanza IndexError. Lo más limpio es que el caso base sea la lista vacía (y así además el caso len == 1 queda cubierto por el caso recursivo).
def suma_esperas(esperas):
    if not esperas:                          # caso base: lista vacía suma 0
        return 0
    return esperas[0] + suma_esperas(esperas[1:])  # caso recursivo: reduce la lista

print(suma_esperas([5, 3, 8]))  # 16
print(suma_esperas([]))         # 0

Conclusión

En esta lección hemos construido un mapa de los tipos de algoritmos según cuatro dimensiones independientes: por enfoque (iterativos frente a recursivos, con la recursividad explicada a fondo mediante caso base y caso recursivo), por propósito (búsqueda, ordenación, grafos, compresión...), por determinismo (predecibles frente a aleatorizados) y por exactitud (exactos frente a heurísticos/aproximados). También hemos avistado las cuatro grandes estrategias de diseño que desarrollaremos en el Módulo 3. Con este mapa ya sabemos qué familias de algoritmos existen; la pregunta natural siguiente es cómo comparar dos algoritmos de la misma familia que resuelven el mismo problema. Para eso necesitamos un lenguaje común e independiente de la máquina: la notación asintótica, protagonista de la próxima lección.

© Copyright 2026. Todos los derechos reservados