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
- Las dimensiones de clasificación
- Por enfoque: iterativos vs recursivos
- La recursividad en detalle: caso base y caso recursivo
- Por propósito: búsqueda, ordenación, grafos y más
- Deterministas vs no deterministas
- Exactos vs heurísticos/aproximados
- 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)) # 4La 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:
- 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.
- 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
← 2Cada 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_cercanaes 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 recorridoEsta 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
minde 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 FalsePara 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:
- La llamada recursiva no reduce el problema:
suma_esperas(esperas)se llama con la misma lista, provocando recursión infinita. Debe sersuma_esperas(esperas[1:]). - Falta el caso base de lista vacía: con
esperas = [], la condiciónlen(esperas) == 1es falsa yesperas[0]lanzaIndexError. Lo más limpio es que el caso base sea la lista vacía (y así además el casolen == 1queda 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([])) # 0Conclusió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.
Curso de Análisis y Diseño de Algoritmos
Módulo 1: Introducción a los Algoritmos
Módulo 2: Análisis de Algoritmos
- Análisis de Complejidad Temporal
- Análisis de Complejidad Espacial
- Casos de Complejidad: Mejor, Peor y Promedio
Módulo 3: Estrategias de Diseño de Algoritmos
Módulo 4: Algoritmos Clásicos
- Búsqueda Binaria
- Ordenamiento por Inserción
- Ordenamiento por Mezcla (Merge Sort)
- Ordenamiento Rápido (Quick Sort)
- Algoritmo de Dijkstra
- Algoritmo de Floyd-Warshall
