Cerramos el módulo de grafos diciendo que el siguiente músculo a entrenar era otro: buscar y ordenar dentro de volúmenes de datos. Rutalia acumula millones de filas — el histórico de entregas con su timestamp, el catálogo de productos, las tarifas por tramos de peso — y sobre datos ordenados existe una herramienta que convierte búsquedas de millones de pasos en unas pocas decenas: la búsqueda binaria. Ya la conoces de refilón: en 01-02 analizamos su recurrencia T(n) = T(n/2) + c ("búsqueda en direcciones ordenadas") y concluimos que era O(log n). Esta lección la trabaja a fondo, porque la búsqueda binaria tiene fama merecida de ser la más afilada y la más traicionera de las técnicas básicas: es trivial de enunciar, pero un estudio clásico encontró errores en la mayoría de implementaciones escritas por programadores profesionales. Aprenderás a escribirla con un invariante que la hace a prueba de balas, sus variantes lower_bound/upper_bound, el módulo bisect de Python, y el patrón más potente de todos: la búsqueda binaria sobre la respuesta.
Contenido
- La búsqueda binaria clásica y por qué es traicionera
- El invariante del intervalo: la forma correcta de razonar
lower_boundyupper_bound: primera y última ocurrencia- El módulo
bisectde Python en la práctica - Búsqueda binaria sobre la respuesta: el patrón "¿es factible(x)?"
- Funciones monótonas y una mención a la búsqueda ternaria
La búsqueda binaria clásica y por qué es traicionera
La idea es la de siempre: si el array está ordenado, comparo con el elemento central y descarto la mitad que no puede contener lo que busco. Cada comparación divide el problema por dos, de ahí la recurrencia T(n) = T(n/2) + c que resolvimos en 01-02: O(log n). Sobre un histórico de 10 millones de entregas de Rutalia, eso son unas 24 comparaciones frente a las 10.000.000 de un recorrido lineal.
def busqueda_binaria(datos, objetivo):
"""Devuelve un índice i tal que datos[i] == objetivo, o -1 si no está.
Requiere que `datos` esté ordenado ascendentemente.
"""
lo, hi = 0, len(datos) - 1 # intervalo CERRADO [lo, hi]
while lo <= hi:
medio = (lo + hi) // 2
if datos[medio] == objetivo:
return medio
elif datos[medio] < objetivo:
lo = medio + 1 # el objetivo, si está, está a la derecha
else:
hi = medio - 1 # el objetivo, si está, está a la izquierda
return -1Desglosemos por qué cada línea es como es, porque aquí es donde la gente se corta:
lo, hi = 0, len(datos) - 1: elegimos representar el intervalo cerrado[lo, hi]— ambos extremos son candidatos válidos. Esta decisión condiciona todo lo demás.while lo <= hi: con intervalo cerrado, el intervalo está vacío cuandolo > hi. Escribirlo < hiaquí sería un error: dejaría sin examinar el caso de un único candidato.lo = medio + 1yhi = medio - 1: siempre excluimosmediodel nuevo intervalo, porque ya lo hemos examinado. Escribirlo = medioohi = medioen esta versión provoca el clásico bucle infinito cuando el intervalo se reduce a uno o dos elementos ymediono avanza.
Los tres errores clásicos
| Error | Síntoma | Causa |
|---|---|---|
Límites mal inicializados (hi = len(datos) con intervalo cerrado) |
IndexError o resultado incorrecto |
Mezclar la convención cerrada [lo, hi] con la semiabierta [lo, hi) |
lo = medio o hi = medio sin ajustar la condición |
Bucle infinito con 1–2 elementos | medio puede coincidir con lo por el redondeo hacia abajo de // |
Condición lo < hi con intervalo cerrado |
No encuentra elementos que sí están | El último candidato nunca se examina |
El overflow histórico de (lo + hi) // 2
En Python los enteros tienen precisión arbitraria y esta línea es segura. Pero conviene saberlo: en Java, C o C++, (lo + hi) / 2 con enteros de 32 bits desborda cuando lo + hi supera 2³¹ − 1, aunque ambos valores sean índices válidos por separado. Este bug estuvo casi diez años en la búsqueda binaria de la biblioteca estándar de Java (java.util.Arrays.binarySearch) hasta que se detectó en 2006. La forma robusta en esos lenguajes es:
Si algún día portas tu código de Rutalia de Python a un servicio en Java o Go, recuerda esta línea. Es el ejemplo perfecto de por qué la búsqueda binaria es traicionera: el algoritmo es correcto en la pizarra y falla en la máquina.
El invariante del intervalo: la forma correcta de razonar
En 03-03 razonamos Dijkstra con un invariante ("las distancias extraídas del heap son definitivas"). La búsqueda binaria se domina igual: en lugar de memorizar dónde va cada +1, se declara un invariante y se mantiene.
Usemos la convención semiabierta [lo, hi), que es la nativa de Python (slicing, range, bisect). El invariante que elegimos, pensando ya en la variante más útil:
Invariante: todo elemento con índice
< loes estrictamente menor que el objetivo; todo elemento con índice>= hies mayor o igual que el objetivo.
Gráficamente, el array queda dividido en tres zonas que el bucle va estrechando:
def lower_bound(datos, objetivo):
"""Primer índice i tal que datos[i] >= objetivo (n si no existe)."""
lo, hi = 0, len(datos) # intervalo semiabierto [lo, hi)
while lo < hi:
medio = (lo + hi) // 2
if datos[medio] < objetivo:
lo = medio + 1 # datos[medio] < objetivo: pasa a la zona izquierda
else:
hi = medio # datos[medio] >= objetivo: pasa a la zona derecha
return lo # lo == hi: la frontera exactaFíjate en los detalles y compáralos con la versión cerrada:
- Aquí
hi = mediosin-1es correcto, porquehies exclusivo: asignarhi = mediosignifica "sé quedatos[medio] >= objetivo", exactamente lo que exige el invariante. - No hay bucle infinito: como
lo < hidentro del bucle, siempremedio < hi, así quehi = medioreduce estrictamente el intervalo, ylo = medio + 1también. - Al salir,
lo == hiy las dos zonas se tocan:loes la frontera entre "menores que el objetivo" y "mayores o iguales". Esa frontera es un resultado mucho más rico que un simple "está / no está".
Esta manera de pensar — elige el invariante, escribe cada rama para mantenerlo, y el resultado sale solo — es la que debes interiorizar. Todas las variantes que siguen son el mismo esqueleto con otro invariante.
lower_bound y upper_bound: primera y última ocurrencia
En datos reales hay duplicados. En el histórico de Rutalia, miles de entregas comparten el mismo día; en las tarifas, varios productos comparten tramo de peso. La pregunta útil casi nunca es "¿está el valor X?" sino "¿dónde empieza y dónde acaba el bloque de X?". Para eso existen dos fronteras:
| Función | Devuelve | Invariante de la frontera |
|---|---|---|
lower_bound(a, x) |
Primer índice i con a[i] >= x |
izquierda: < x — derecha: >= x |
upper_bound(a, x) |
Primer índice i con a[i] > x |
izquierda: <= x — derecha: > x |
Con ambas fronteras se responde todo:
- Primera ocurrencia de x:
i = lower_bound(a, x); existe sii < len(a)ya[i] == x. - Última ocurrencia de x:
upper_bound(a, x) - 1(si x existe). - Número de ocurrencias:
upper_bound(a, x) - lower_bound(a, x)— cero si x no está. - Rango de un intervalo de valores [x, y]:
a[lower_bound(a, x) : upper_bound(a, y)].
upper_bound es lower_bound cambiando una comparación (<= en lugar de < al decidir la rama). En Python no hace falta escribirlas: ya vienen hechas.
El módulo bisect de Python en la práctica
La biblioteca estándar trae las dos fronteras con nombres propios: bisect_left es lower_bound y bisect_right (alias bisect) es upper_bound. Además, insort_left/insort_right insertan manteniendo el orden (ojo: la inserción en lista es O(n) por el desplazamiento de elementos; lo logarítmico es solo localizar la posición).
Veámoslo sobre el histórico de entregas de Rutalia, ordenado por timestamp. Como siempre, datos ficticios genéricos:
import bisect
# Histórico ordenado por timestamp: (timestamp_iso, id_entrega, zona)
historico = [
("2026-07-01T08:12:00", "E-10231", "ALM"),
("2026-07-01T08:47:00", "E-10232", "MER"),
("2026-07-01T09:03:00", "E-10233", "CEN"),
("2026-07-01T09:03:00", "E-10234", "CEN"), # mismo minuto: duplicado real
("2026-07-01T10:30:00", "E-10235", "UNI"),
("2026-07-01T11:15:00", "E-10236", "RIO"),
("2026-07-01T13:40:00", "E-10237", "HOS"),
]
# ¿Qué entregas se hicieron entre las 09:00 y las 11:00 del día 1?
# Las tuplas se comparan lexicográficamente: basta buscar por el primer campo.
inicio = bisect.bisect_left(historico, ("2026-07-01T09:00:00",))
fin = bisect.bisect_right(historico, ("2026-07-01T11:00:00", ""))
for entrega in historico[inicio:fin]:
print(entrega)
# ('2026-07-01T09:03:00', 'E-10233', 'CEN')
# ('2026-07-01T09:03:00', 'E-10234', 'CEN')
# ('2026-07-01T10:30:00', 'E-10235', 'UNI')Dos trucos importantes del ejemplo:
- Buscar con tuplas parciales:
("2026-07-01T09:00:00",)es una tupla de un solo elemento; al compararse con tuplas de tres, la comparación lexicográfica decide por el primer campo, que es justo lo que queremos. Para el límite superior añadimos un centinela "muy grande" ("") como segundo campo, para incluir todas las entregas de ese instante exacto. bisect_leftpara el inicio,bisect_rightpara el fin: el patrón universal para extraer rangos con duplicados.
Desde Python 3.10, bisect acepta key=, lo que evita los centinelas:
# Buscar directamente por el campo timestamp con key= (Python >= 3.10)
inicio = bisect.bisect_left(historico, "2026-07-01T09:00:00", key=lambda e: e[0])
fin = bisect.bisect_right(historico, "2026-07-01T11:00:00", key=lambda e: e[0])Tarifas por tramos: el uso estrella de bisect
Rutalia cobra el envío según el peso del paquete, por tramos. Este problema — "dado un valor, ¿en qué tramo cae?" — es exactamente una llamada a bisect:
import bisect
# Límites superiores de cada tramo de peso (kg) y su tarifa (EUR)
limites = [1, 2, 5, 10, 20] # hasta 1 kg, hasta 2 kg, ...
tarifas = [2.90, 3.80, 5.50, 8.20, 12.00, 19.90] # la última: más de 20 kg
def tarifa(peso_kg):
# bisect_left: un paquete de exactamente 2.0 kg cae en el tramo "hasta 2 kg"
tramo = bisect.bisect_left(limites, peso_kg)
return tarifas[tramo]
for peso in [0.4, 2.0, 2.1, 25.0]:
print(f"{peso:>5} kg -> {tarifa(peso):.2f} EUR")
# 0.4 kg -> 2.90 EUR
# 2.0 kg -> 3.80 EUR
# 2.1 kg -> 5.50 EUR
# 25.0 kg -> 19.90 EURLa elección entre bisect_left y bisect_right aquí no es estética: decide si el límite exacto (2,0 kg) cae en el tramo barato o en el caro. Con bisect_right, 2,0 kg pagaría 5,50 EUR. Es el tipo de detalle de frontera que en producción se traduce en facturación incorrecta — otra muestra del carácter traicionero de esta familia de algoritmos.
Búsqueda binaria sobre la respuesta: el patrón "¿es factible(x)?"
Hasta aquí buscábamos en un array. El salto conceptual grande de esta lección es que se puede buscar en el espacio de las respuestas posibles, aunque no exista ningún array. Solo se necesita una propiedad:
Si existe una función
factible(x)que responde sí/no y es monótona — si x funciona, todo x' > x también funciona (o al revés) — entonces la respuesta óptima se encuentra por búsqueda binaria sobre x.
El espacio de respuestas tiene esta pinta: NO NO NO NO SÍ SÍ SÍ SÍ. Buscar la frontera entre el último NO y el primer SÍ es exactamente un lower_bound sobre un "array virtual" que nunca materializamos.
Ejemplo Rutalia: capacidad mínima de furgoneta para k viajes
La furgoneta de la zona CEN debe repartir una secuencia de paquetes en el orden dado (así vienen paletizados del almacén), en como mucho k viajes. ¿Qué capacidad mínima (kg) necesita la furgoneta?
- ¿Es monótono? Sí: si con capacidad C se puede en k viajes, con C+1 también (la furgoneta grande puede imitar a la pequeña).
factible(C): se comprueba con un voraz trivial — ir llenando el viaje actual y abrir uno nuevo cuando no cabe. ¿Te suena? Es primo del first-fit del bin packing de 02-02, pero aquí el orden es fijo y el voraz sí es exacto.
def viajes_necesarios(pesos, capacidad):
"""Nº de viajes si cargamos en orden con la capacidad dada (voraz exacto)."""
viajes, carga = 1, 0
for p in pesos:
if carga + p <= capacidad:
carga += p
else:
viajes += 1
carga = p
return viajes
def capacidad_minima(pesos, k):
"""Mínima capacidad para repartir `pesos` (en orden) en <= k viajes."""
lo = max(pesos) # cota inferior: debe caber el paquete más pesado
hi = sum(pesos) # cota superior: con todo en un viaje seguro que basta
while lo < hi: # mismo esqueleto que lower_bound
medio = (lo + hi) // 2
if viajes_necesarios(pesos, medio) <= k: # ¿factible(medio)?
hi = medio # medio funciona: la respuesta es <= medio
else:
lo = medio + 1 # medio no funciona: la respuesta es > medio
return lo
pesos = [8, 3, 12, 5, 7, 9, 4, 6, 10, 2] # kg, en orden de paletizado
print(capacidad_minima(pesos, k=3)) # 23
print(viajes_necesarios(pesos, 23)) # 3 -> viajes: 8+3+12 | 5+7+9 | 4+6+10+2
print(viajes_necesarios(pesos, 22)) # 4 (23 es realmente el mínimo)Análisis: cada factible cuesta O(n) y hacemos O(log R) llamadas, con R = sum − max; total O(n · log R). La alternativa ingenua de probar capacidades una a una es O(n · R): con pesos en gramos y rangos de toneladas, la diferencia es abismal.
Segundo ejemplo: mínimo tiempo t para completar las entregas
Mismo patrón con otro disfraz: Rutalia tiene m repartidores y el repartidor i tarda t_i minutos por entrega (moto, bici, furgoneta...). ¿Cuál es el mínimo tiempo T para completar n entregas trabajando todos en paralelo?
factible(T): en T minutos, el repartidor i completaT // t_ientregas. ¿Suman al menos n? Cálculo O(m).- Monotonía: más tiempo, más entregas. Frontera NO→SÍ.
def tiempo_minimo(tiempos_por_entrega, n):
lo, hi = 1, min(tiempos_por_entrega) * n # cotas seguras
while lo < hi:
medio = (lo + hi) // 2
if sum(medio // t for t in tiempos_por_entrega) >= n:
hi = medio
else:
lo = medio + 1
return lo
print(tiempo_minimo([4, 7, 10], n=12)) # 28
# Comprobación: con 27 min -> 6+3+2 = 11 < 12; con 28 min -> 7+4+2 = 13 >= 12. Correcto.El patrón mental que debes llevarte: cuando el enunciado pide "el mínimo X tal que..." o "el máximo X tal que...", pregúntate si puedes escribir un factible(x) monótono y barato. Si la respuesta es sí, el problema de optimización se convierte en O(log R) problemas de decisión. En 02-03 buscábamos óptimos podando árboles con cotas; aquí los buscamos estrechando un intervalo. Dos filosofías distintas para el mismo verbo: optimizar.
Funciones monótonas y búsqueda ternaria (mención breve)
La búsqueda binaria no necesita un array: necesita monotonía. Sirve igual para resolver f(x) = objetivo con f creciente y continua (bisección numérica, con while hi - lo > 1e-9 en lugar de índices), por ejemplo para calibrar la velocidad media a la que debe circular la flota para cumplir una ventana de entrega.
Si la función no es monótona pero es unimodal (baja y luego sube, como el coste total en función del número de furgonetas: pocas = horas extra, muchas = flota infrautilizada), la herramienta hermana es la búsqueda ternaria: se evalúan dos puntos interiores m1 < m2 y se descarta el tercio que no puede contener el mínimo. También es O(log n). No la desarrollamos más: quédate con que existe y con la palabra clave unimodal para reconocer cuándo aplicarla.
Errores Comunes y Consejos
- Mezclar convenciones de intervalo. El 90 % de los bugs vienen de usar
hi = len(a)conwhile lo <= hi, ohi = len(a) - 1conhi = medio. Elige una convención (recomendamos la semiabierta[lo, hi), la nativa de Python) y anótala en un comentario en la primera línea. - No verificar el resultado de
bisect_left.bisect_left(a, x)devuelve una posición de inserción, no garantiza quexesté: compruebai < len(a) and a[i] == xantes de dar el elemento por encontrado. - Buscar en datos no ordenados. La búsqueda binaria sobre un array desordenado no falla ruidosamente: devuelve basura con total confianza. En desarrollo, un
assert all(a[i] <= a[i+1] for i in range(len(a)-1))te salva (quítalo en producción: es O(n) y anula la gracia). - Bucle infinito en la variante "maximizar". Si buscas el máximo x factible con enteros y mueves
lo = medio, el redondeo hacia abajo de(lo + hi) // 2no avanza cuandohi == lo + 1. Solución: redondear hacia arriba conmedio = (lo + hi + 1) // 2en esa variante. - Cotas iniciales incorrectas en la búsqueda sobre la respuesta. Si
lono es una cota inferior válida (p. ej. olvidarmax(pesos)en el ejemplo de la furgoneta), el verificador puede ejecutarse con capacidades en las que ni siquiera cabe un paquete y devolver resultados sin sentido. Dedica un minuto a justificar ambas cotas. - Consejo: cuando dudes de una implementación, testéala contra fuerza bruta con arrays aleatorios pequeños, incluidos el vacío y el de un elemento. La búsqueda binaria falla casi siempre en n ∈ {0, 1, 2}.
Ejercicios
Ejercicio 1 — Rango de un día en el histórico. Con el historico ordenado por timestamp de la sección de bisect, escribe entregas_del_dia(historico, fecha) que devuelva la lista de entregas cuyo timestamp empieza por fecha (formato "2026-07-01"), usando bisect_left/bisect_right (no recorras la lista entera). El coste debe ser O(log n) + O(k), con k el tamaño del resultado.
Ejercicio 2 — Última entrega antes de un instante. Escribe ultima_entrega_antes(historico, ts) que devuelva la última entrega con timestamp estrictamente menor que ts, o None si no hay ninguna. Pista: ¿qué frontera te da directamente esa posición, bisect_left o bisect_right?
Ejercicio 3 — Máximo peso de paquete admisible. Invirtamos el patrón de la búsqueda sobre la respuesta: Rutalia quiere anunciar el máximo peso por paquete que puede prometer, sabiendo que su furgoneta de capacidad C fija debe seguir completando el reparto de pesos (en orden) en k viajes, y suponiendo que todo paquete que supere el peso anunciado se recorta a ese peso. Escribe peso_maximo_admisible(pesos, k, C) con búsqueda binaria sobre la respuesta. Cuidado con la variante "maximizar" y su bucle infinito.
Soluciones
Solución 1:
import bisect
def entregas_del_dia(historico, fecha):
inicio = bisect.bisect_left(historico, (fecha,)) # "2026-07-01" < "2026-07-01T..."
fin = bisect.bisect_right(historico, (fecha + "",)) # centinela por encima del día
return historico[inicio:fin]
dia = entregas_del_dia(historico, "2026-07-01")
print(len(dia), "entregas") # 7 entregas (todas, en este histórico de ejemplo)El truco es el de la lección: el prefijo "2026-07-01" es menor que cualquier timestamp del día (porque cualquier carácter extra hace mayor a la cadena más larga cuando el prefijo coincide), y el centinela "" supera a todos ellos. Coste: dos búsquedas O(log n) más el slice O(k).
Solución 2:
def ultima_entrega_antes(historico, ts):
i = bisect.bisect_left(historico, (ts,)) # primer índice con timestamp >= ts
return historico[i - 1] if i > 0 else None
print(ultima_entrega_antes(historico, "2026-07-01T09:03:00"))
# ('2026-07-01T08:47:00', 'E-10232', 'MER') — las de las 09:03 NO cuentan (estrictamente menor)bisect_left da el primer elemento >= ts; el anterior es, por definición del invariante, el último < ts. Con bisect_right habríamos obtenido "la última con timestamp <= ts", la otra semántica habitual — elegir la frontera correcta es el ejercicio.
Solución 3:
def peso_maximo_admisible(pesos, k, C):
lo, hi = 1, C # anunciar más de C no cambia nada: ningún viaje admite más de C
# Buscamos el MÁXIMO x factible: la frontera es SÍ...SÍ NO...NO
while lo < hi:
medio = (lo + hi + 1) // 2 # redondeo hacia ARRIBA: evita el bucle infinito
recortados = [min(p, medio) for p in pesos]
if viajes_necesarios(recortados, C) <= k:
lo = medio # medio es factible: la respuesta es >= medio
else:
hi = medio - 1 # medio no es factible: la respuesta es < medio
return lo
pesos = [8, 3, 12, 5, 7, 9, 4, 6, 10, 2]
print(peso_maximo_admisible(pesos, k=3, C=22))
# 11: recortando el paquete de 12 kg a 11, los viajes quedan 8+3+11 | 5+7+9 | 4+6+10+2
# (todos <= 22). Sin recorte (x=12) harían falta 4 viajes: 12 no es factible.La monotonía va al revés que antes: cuanto menor es el peso anunciado, menor la carga total y menos viajes — si x es factible, x−1 también. Por eso la frontera es SÍ→NO, movemos lo = medio al acertar, y eso obliga al redondeo hacia arriba (lo + hi + 1) // 2 para garantizar progreso.
Conclusión
La búsqueda binaria es O(log n) puro: la recurrencia T(n) = T(n/2) + c del módulo 1 convertida en herramienta. Lo esencial de esta lección no es el algoritmo — cabe en diez líneas — sino la disciplina: (1) elegir una convención de intervalo y un invariante, y dejar que ellos escriban las ramas; (2) pensar en fronteras (lower_bound/upper_bound) y no en "está o no está", porque las fronteras responden rangos, conteos y tramos de tarifa; (3) reconocer el patrón de la búsqueda sobre la respuesta: cualquier "mínimo/máximo x tal que factible(x)" monótono se resuelve con O(log R) llamadas a un verificador barato — así calculamos la capacidad mínima de la flota de Rutalia sin probar capacidades una a una.
Todo esto ha dependido de una premisa silenciosa: que los datos ya estaban ordenados. El histórico por timestamp, las tarifas por tramos, el catálogo... alguien tuvo que ordenarlos, y sobre millones de filas eso no se hace de cualquier manera. En la próxima lección (04-02) abrimos la caja de la ordenación: por qué los algoritmos O(n²) mueren a escala, cómo mergesort y quicksort logran O(n log n), por qué esa cota no puede batirse comparando... y cómo, con las cartas adecuadas (los códigos postales de Rutalia, por ejemplo), sí puede batirse.
Algoritmos Avanzados
Módulo 1: Introducción a los Algoritmos Avanzados
- Conceptos Básicos y Notación
- Análisis de Complejidad
- Recursión y Programación Dinámica
- Estructuras de Datos Avanzadas
Módulo 2: Algoritmos de Optimización
- Programación Lineal
- Algoritmos de Optimización Combinatoria
- Backtracking y Branch and Bound
- Algoritmos Genéticos
- Optimización de Colonia de Hormigas
Módulo 3: Algoritmos en Grafos
- Representación de Grafos
- Búsqueda en Grafos: BFS y DFS
- Algoritmos de Caminos Mínimos
- Árboles de Expansión Mínima
- Algoritmos de Flujo Máximo
- Algoritmos de Emparejamiento en Grafos
Módulo 4: Algoritmos de Búsqueda y Ordenación
Módulo 5: Algoritmos de Aprendizaje Automático
- Introducción al Aprendizaje Automático
- Algoritmos de Clasificación
- Algoritmos de Regresión
- Redes Neuronales y Deep Learning
- Algoritmos de Clustering
Módulo 6: Casos de Estudio y Aplicaciones
- Optimización en la Industria
- Aplicaciones de Grafos en Redes Sociales
- Búsqueda y Ordenación en Grandes Volúmenes de Datos
- Aplicaciones de Aprendizaje Automático en la Vida Real
