En el Módulo 2 aprendimos a medir algoritmos: sabemos calcular su coste temporal, su consumo de memoria y distinguir el mejor caso del peor. Pero medir no es crear. Cuando el equipo de RutaBus se enfrenta a un problema nuevo — encontrar la hora punta de una línea, detectar las dos paradas más próximas entre sí —, necesita algo más que un cronómetro: necesita estrategias de diseño, patrones probados para construir algoritmos desde cero. Esta lección inaugura el Módulo 3 con la primera y quizá más elegante de las cuatro grandes estrategias que anticipamos en 01-02: divide y vencerás (divide and conquer). La idea es engañosamente simple — partir el problema en trozos, resolver cada trozo y recomponer la solución — pero de ella nacen algunos de los algoritmos más eficientes jamás escritos.
Contenido
- El esquema general: dividir, conquistar, combinar
- Cuándo aplica (y cuándo no)
- Primer ejemplo RutaBus: la hora punta de una línea
- Segundo ejemplo RutaBus: el par de paradas más cercanas en 1D
- Razonar el coste: contar niveles de división
- Algoritmos célebres de la estrategia
El esquema general: dividir, conquistar, combinar
Divide y vencerás resuelve un problema de tamaño n mediante tres fases:
- Dividir: partir la entrada en dos o más subproblemas del mismo tipo pero de menor tamaño (habitualmente dos mitades).
- Conquistar: resolver cada subproblema recursivamente. Cuando el subproblema es tan pequeño que la respuesta es inmediata (una parada, una franja horaria), estamos en el caso base — el mismo concepto que estudiamos al ver recursividad en 01-02.
- Combinar: fusionar las soluciones parciales en la solución del problema original.
flowchart TD
A["Problema de tamaño n"] --> B["Dividir"]
B --> C["Subproblema n/2"]
B --> D["Subproblema n/2"]
C --> E["Conquistar (recursión)"]
D --> F["Conquistar (recursión)"]
E --> G["Combinar"]
F --> G
G --> H["Solución del problema original"]
Fíjate en que la recursión hace casi todo el trabajo por nosotros: nuestro esfuerzo de diseño se concentra en decidir cómo dividir y, sobre todo, cómo combinar. La fase de combinación es donde suelen esconderse tanto la genialidad como los errores.
Cuándo aplica (y cuándo no)
No todo problema se deja trocear. Divide y vencerás funciona bien cuando se cumplen tres condiciones:
- Los subproblemas son del mismo tipo que el original. Buscar el máximo en media lista sigue siendo "buscar el máximo en una lista". Si al dividir el problema cambia de naturaleza, la recursión no encaja.
- Los subproblemas son independientes. Resolver la mitad izquierda no necesita nada de la mitad derecha. Esta condición es crucial: si los subproblemas se solapan (comparten trabajo), divide y vencerás repite cálculos y se vuelve ineficiente — ese escenario es justo el que resolverá la programación dinámica en 03-03.
- Combinar es más barato que resolver desde cero. Si fusionar las soluciones parciales cuesta tanto como el problema entero, no hemos ganado nada.
| Condición | Si se cumple | Si no se cumple |
|---|---|---|
| Subproblemas del mismo tipo | La recursión es natural | Buscar otra estrategia |
| Subproblemas independientes | Ningún trabajo repetido | Programación dinámica (03-03) |
| Combinación barata | El coste total baja | El troceo no aporta nada |
Primer ejemplo RutaBus: la hora punta de una línea
Empecemos con un ejemplo deliberadamente sencillo para fijar el esquema. RutaBus registra cuántos pasajeros suben en la línea L1 en cada franja de 15 minutos. El servicio de planificación quiere conocer la hora punta: el máximo de esa lista de conteos.
Ya sabríamos resolverlo con un bucle O(n), como hicimos con parada_mas_cercana en 01-01. Vamos a resolverlo ahora con divide y vencerás para ver el esquema en su forma más pura:
def maximo_pasajeros(conteos, inicio, fin):
"""Máximo de conteos[inicio..fin] (ambos inclusive) por divide y vencerás."""
# CASO BASE: una sola franja -> el máximo es ella misma
if inicio == fin:
return conteos[inicio]
# DIVIDIR: partimos el rango por la mitad
medio = (inicio + fin) // 2
# CONQUISTAR: máximo de cada mitad, recursivamente
max_izq = maximo_pasajeros(conteos, inicio, medio)
max_der = maximo_pasajeros(conteos, medio + 1, fin)
# COMBINAR: el máximo global es el mayor de los dos parciales
return max_izq if max_izq >= max_der else max_der
hora_punta = maximo_pasajeros(conteos_l1, 0, len(conteos_l1) - 1)
print(hora_punta) # 91Desglosemos cada pieza:
- Caso base (
inicio == fin): un rango de una sola franja no se puede dividir más; su máximo es trivial. Sin este caso la recursión no terminaría — recuerda la propiedad de finitud de Knuth (01-01). - Dividir: calculamos
medioy obtenemos dos rangos,[inicio..medio]y[medio+1..fin]. Observa que pasamos índices en lugar de hacer slices (conteos[:medio]): en 02-01 vimos que cada slice copia datos y cuesta O(k); con índices, la división es O(1). - Conquistar: dos llamadas recursivas, cada una sobre la mitad de las franjas.
- Combinar: una única comparación, O(1).
La ejecución sobre las 8 franjas forma un árbol de llamadas:
flowchart TD
A["[0..7] → 91"] --> B["[0..3] → 78"]
A --> C["[4..7] → 91"]
B --> D["[0..1] → 45"]
B --> E["[2..3] → 78"]
C --> F["[4..5] → 91"]
C --> G["[6..7] → 64"]
D --> H["12"] & I["45"]
E --> J["78"] & K["33"]
F --> L["91"] & M["27"]
G --> N["64"] & O["50"]
Una confesión importante: este algoritmo hace exactamente n − 1 comparaciones, las mismas que el bucle lineal. Aquí divide y vencerás no gana nada en tiempo (y además consume pila de recursión, como analizamos en 02-02). Lo hemos usado porque muestra el esquema con total claridad. La ganancia real aparece cuando dividir nos permite evitar trabajo, como en el siguiente ejemplo.
Segundo ejemplo RutaBus: el par de paradas más cercanas en 1D
En 02-01 escribimos pares_conectables, que comparaba todas las parejas de paradas: O(n²). Un problema hermano: el equipo de infraestructura quiere detectar las dos paradas más próximas entre sí de una avenida (si están demasiado juntas, probablemente una sobre). Simplificamos a 1D: cada parada queda representada por su kilómetro sobre la avenida.
La fuerza bruta compararía las n(n−1)/2 parejas — la suma aritmética que dedujimos en 02-01 —: O(n²). Divide y vencerás lo baja a O(n log n):
def distancia_minima(kms_ordenados, inicio, fin):
"""Distancia mínima entre dos paradas de kms_ordenados[inicio..fin].
Precondición: la lista está ordenada por kilómetro."""
# CASO BASE: con menos de dos paradas no hay pareja posible
if fin - inicio < 1:
return float("inf")
# DIVIDIR
medio = (inicio + fin) // 2
# CONQUISTAR: mejor pareja dentro de cada mitad
mejor_izq = distancia_minima(kms_ordenados, inicio, medio)
mejor_der = distancia_minima(kms_ordenados, medio + 1, fin)
# COMBINAR: la pareja ganadora puede CRUZAR la frontera.
# Al estar ordenada la lista, la única pareja cruzada candidata es
# (última de la izquierda, primera de la derecha).
cruce = kms_ordenados[medio + 1] - kms_ordenados[medio]
return min(mejor_izq, mejor_der, cruce)
kms_ordenados = sorted(kms) # [0.3, 0.9, 1.2, 2.8, 4.0, 5.1]
print(distancia_minima(kms_ordenados, 0, len(kms_ordenados) - 1))
# 0.3 -> entre las paradas de los km 0.9 y 1.2Puntos clave:
- La fase de combinar ya no es trivial: la mejor pareja podría tener una parada en cada mitad. Gracias a la ordenación previa, basta examinar una pareja fronteriza, no todas las cruzadas.
- El caso base devuelve
float("inf")("infinito"): un valor neutro que nunca gana a unmin. Es un patrón muy habitual para "aquí no hay solución". - Siendo honestos: con la lista ya ordenada, un simple recorrido de parejas adyacentes también resolvería esto en O(n). Usamos la versión recursiva porque (a) ilustra una combinación no trivial y (b) es la antesala directa del problema real en 2D (paradas sobre un mapa), donde el recorrido lineal ya no existe y divide y vencerás mantiene exactamente esta estructura.
Razonar el coste: contar niveles de división
¿Cómo se analiza un algoritmo que se llama a sí mismo dos veces? En 02-01 contamos llamadas recursivas una a una; con divide y vencerás hay un método más cómodo: contar niveles.
Cada nivel de recursión divide el tamaño por 2: n → n/2 → n/4 → … → 1. La pregunta "¿cuántas veces puedo dividir n entre 2 hasta llegar a 1?" tiene por respuesta, precisamente, log₂ n — la misma intuición que asociamos a O(log n) en la jerarquía de 01-03.
El coste total se estima así:
coste total ≈ (número de niveles) × (trabajo por nivel)
| Trabajo de dividir + combinar por llamada | Trabajo por nivel | Niveles | Coste total |
|---|---|---|---|
O(1) (ej.: maximo_pasajeros) |
dominan las ~n hojas del árbol | log₂ n | O(n) |
| O(n) repartido en el nivel (ej.: fusionar mitades ordenadas) | O(n) en cada nivel | log₂ n | O(n log n) |
Dos lecturas de esta tabla:
- En
maximo_pasajeros, cada llamada combina en O(1); el árbol tiene unos2n − 1nodos en total, así que el coste es O(n). Por eso no mejoraba al bucle: el trabajo lo mandan las hojas. - Cuando combinar cuesta O(n) por nivel, tenemos O(n) de trabajo en cada uno de los log₂ n niveles: O(n log n), la complejidad estrella de los buenos algoritmos de ordenación.
Este segundo patrón se escribe formalmente como la recurrencia T(n) = 2·T(n/2) + O(n): "el coste para tamaño n es el de dos subproblemas de tamaño n/2 más un trabajo lineal de combinación". Resolveremos esta recurrencia con todo rigor cuando analicemos merge sort en 04-03; por ahora, quédate con el método de los niveles, que da la respuesta correcta en los casos habituales.
Algoritmos célebres de la estrategia
Divide y vencerás es la partida de nacimiento de varios clásicos que estudiaremos, ya con implementación completa, en el Módulo 4:
| Algoritmo | Cómo divide | Coste | Lo veremos en |
|---|---|---|---|
| Búsqueda binaria | Descarta una mitad en cada paso (¡solo un subproblema!) | O(log n) | 04-01 |
| Merge sort | Ordena cada mitad y las fusiona | O(n log n) | 04-03 |
| Quick sort | Particiona alrededor de un pivote | O(n log n) promedio | 04-04 |
| Multiplicación de Karatsuba | Parte los números en mitades de dígitos | ≈ O(n^1.585) | (solo mención) |
No los implementaremos aquí: en este módulo nos interesa la estrategia; en el Módulo 4, los clásicos que engendró.
Errores Comunes y Consejos
- Olvidar el caso base o definirlo mal. Un rango vacío o de un elemento debe cortarse en seco. Prueba siempre tu función con
n = 0,n = 1yn = 2antes que con listas grandes. - Dividir con slices en lugar de índices.
lista[:medio]copia O(k) elementos en cada nivel (coste oculto de 02-01) y dispara la memoria (02-02). Pasainicioyfin. - Errores de ±1 en la frontera. Si las mitades fueran
[inicio..medio]y[medio..fin](conmediorepetido), la recursión podría no reducir el tamaño y no terminar nunca. Verifica que ambas mitades son estrictamente más pequeñas que el rango original. - Ignorar las soluciones que cruzan la frontera. En
distancia_minima, olvidar la pareja fronteriza produce resultados incorrectos silenciosos. Al diseñar la combinación pregúntate siempre: "¿puede la solución tener un pie en cada mitad?". - Aplicar la estrategia por inercia. Si combinar no abarata nada (caso del máximo), un bucle simple es mejor: igual de rápido, más legible y sin gastar pila de recursión.
Ejercicios
Ejercicio 1
Escribe total_pasajeros(conteos, inicio, fin) que sume los pasajeros de un rango de franjas usando divide y vencerás. Identifica su caso base y sus tres fases, y razona su coste total contando niveles. ¿Gana algo respecto a sum(conteos)?
Ejercicio 2
RutaBus quiere la franja valle (mínimo de pasajeros) y la hora punta (máximo) a la vez. Escribe min_y_max(conteos, inicio, fin) que devuelva la tupla (minimo, maximo) con una sola pasada de divide y vencerás.
Ejercicio 3
En distancia_minima, ¿por qué es imprescindible que la lista esté ordenada para que la combinación pueda mirar una sola pareja? Construye una lista desordenada concreta con la que el algoritmo, aplicado sin ordenar antes, devuelva un resultado incorrecto.
Soluciones
Solución 1
def total_pasajeros(conteos, inicio, fin):
if inicio > fin: # caso base: rango vacío
return 0
if inicio == fin: # caso base: una franja
return conteos[inicio]
medio = (inicio + fin) // 2
izq = total_pasajeros(conteos, inicio, medio) # conquistar
der = total_pasajeros(conteos, medio + 1, fin)
return izq + der # combinar: O(1)Combinar cuesta O(1); como en maximo_pasajeros, el árbol tiene ~2n nodos y el coste total es O(n). No gana nada frente a sum (también O(n)) y encima consume O(log n) de pila (02-02). Buen ejercicio de esquema; mala idea en producción.
Solución 2
def min_y_max(conteos, inicio, fin):
if inicio == fin:
return (conteos[inicio], conteos[inicio])
medio = (inicio + fin) // 2
min_i, max_i = min_y_max(conteos, inicio, medio)
min_d, max_d = min_y_max(conteos, medio + 1, fin)
return (min(min_i, min_d), max(max_i, max_d))La gracia está en que cada llamada devuelve las dos respuestas a la vez y la combinación sigue siendo O(1); el coste total es O(n). (Dato curioso: refinando el caso base para que procese parejas — comparar los dos elementos una vez y decidir quién compite por el mínimo y quién por el máximo —, esta estructura baja a ~1,5n comparaciones frente a las ~2n de calcular mínimo y máximo por separado.)
Solución 3
La combinación solo mira la pareja (kms[medio], kms[medio+1]) porque, con la lista ordenada, cualquier otra pareja cruzada está más separada: los elementos de la izquierda son todos ≤ kms[medio] y los de la derecha todos ≥ kms[medio+1]. Sin orden, esa garantía desaparece. Contraejemplo: [0.3, 5.1, 0.4, 9.0]. Las mitades dan 4.8 (izquierda: |5.1−0.3|) y 8.6 (derecha: |9.0−0.4|), y la pareja fronteriza es |0.4−5.1| = 4.7. El algoritmo devolvería 4.7, pero la respuesta real es |0.4−0.3| = 0.1 — una pareja cruzada que la frontera no ve.
Conclusión
Divide y vencerás nos ha dado nuestro primer molde de diseño: dividir el problema en subproblemas independientes del mismo tipo, conquistarlos recursivamente desde un caso base sólido y combinar sus respuestas — la fase donde vive la verdadera dificultad. Hemos aprendido a estimar su coste contando niveles (log₂ n divisiones) y hemos comprobado, con la hora punta de la L1, que la estrategia solo compensa cuando dividir ahorra trabajo, como en el par de paradas más cercanas (de O(n²) a O(n log n)). Los frutos más famosos del molde — búsqueda binaria, merge sort, quick sort — nos esperan en el Módulo 4. Pero antes hay que conocer al resto de la familia. Divide y vencerás resuelve todos los subproblemas y luego decide; la siguiente estrategia es mucho más impaciente: toma en cada paso la decisión que ahora mismo parece mejor y jamás vuelve atrás. A veces esa audacia produce algoritmos óptimos y velocísimos; otras veces, desastres silenciosos. Son los algoritmos greedy, y aprender a distinguir cuándo funcionan es el objetivo 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
