Llevamos tres lecciones prometiéndola: la anticipamos en 01-01 como motivación, en 01-03 la usamos para ilustrar el crecimiento logarítmico, y el Módulo 3 cerró llamándola "la joya de la eficiencia logarítmica". Ha llegado el momento de pagar la deuda. La búsqueda binaria localiza un elemento en una colección ordenada descartando la mitad de los candidatos en cada paso: donde buscar_parada (02-03) necesitaba hasta 40 comparaciones para encontrar una parada en una línea de 40, la búsqueda binaria necesita 6. En esta lección la implementaremos con cuidado quirúrgico — es célebre por concentrar errores sutiles en cuatro líneas —, la trazaremos sobre el catálogo de paradas de RutaBus, la analizaremos con las herramientas del Módulo 2 y la conectaremos con su estrategia madre: divide y vencerás (03-01).

Contenido

  1. El requisito innegociable: una colección ordenada
  2. Implementación iterativa con invariantes
  3. Traza paso a paso: buscar una parada por código
  4. Versión recursiva
  5. Análisis: por qué O(log n)
  6. Búsqueda binaria contra búsqueda lineal
  7. Variantes útiles: primera ocurrencia y el módulo bisect

El requisito innegociable: una colección ordenada

La búsqueda binaria hace una apuesta: mira el elemento central y, comparándolo con el objetivo, decide en qué mitad no puede estar. Esa deducción solo es válida si la colección está ordenada. Si el elemento central es menor que el objetivo, todo lo que hay a su izquierda también lo es — y podemos descartarlo en bloque sin mirarlo. Sin orden, no hay deducción posible: descartar una mitad sería tirar una moneda.

El catálogo central de paradas de RutaBus asigna a cada parada un código alfabético. Mantenido en orden, es el escenario perfecto:

codigos = ["AVP", "ESN", "HCE", "MVJ", "PDR", "PMA", "POL", "TSU", "UNI"]
#           Avenida  Estación Hospital Mercado Parque  Plaza   Poli-   Terminal Univer-
#           d.Puerto Norte    Central  Viejo   del Río Mayor   deportivo Sur    sidad

¿Y si la lista no está ordenada? Ordenarla tiene su propio coste y sus propios algoritmos — exactamente el tema de las tres próximas lecciones (04-02 a 04-04). Aquí asumimos el orden como dado; la regla práctica de cuándo compensa ordenar para luego buscar la veremos al comparar costes en el apartado 6.

Implementación iterativa con invariantes

La versión iterativa es la canónica. Cada línea tiene una justificación precisa:

def busqueda_binaria(lista, objetivo):
    izq = 0                    # primer índice candidato
    der = len(lista) - 1       # último índice candidato

    while izq <= der:                      # queda al menos un candidato
        medio = (izq + der) // 2           # punto central (división entera)
        if lista[medio] == objetivo:
            return medio                   # encontrado: devolvemos el índice
        elif lista[medio] < objetivo:
            izq = medio + 1                # el objetivo solo puede estar a la DERECHA
        else:
            der = medio - 1                # el objetivo solo puede estar a la IZQUIERDA
    return -1                              # rango vacío: no está

La herramienta para razonar sobre este bucle es su invariante: una afirmación que es cierta antes de cada vuelta. Aquí es:

Si objetivo está en la lista, su índice está en el rango [izq, der].

Cada pieza del código existe para preservar ese invariante:

  • medio = (izq + der) // 2: el índice central del rango vigente (la división entera // redondea hacia abajo cuando el rango tiene tamaño par). Elegir el centro es lo que garantiza que, decidamos lo que decidamos, descartamos la mitad de los candidatos — ni más ni menos.
  • izq = medio + 1 (y no izq = medio): ya hemos comprobado que lista[medio] != objetivo, así que medio deja de ser candidato. Saltárselo no rompe el invariante (el objetivo no puede estar ahí) y es lo que garantiza que el rango siempre se encoge — la clave para que el bucle termine.
  • der = medio - 1: el argumento simétrico.
  • while izq <= der: el rango [izq, der] contiene der - izq + 1 elementos; cuando izq > der contiene cero. En ese momento el invariante dice: "si estuviera, estaría en un rango vacío" — es decir, no está. Por eso el return -1 de después del bucle es correcto, no un parche.

Fíjate en que el razonamiento por invariante convierte "me suena que funciona" en una demostración: invariante cierto al inicio (todo el rango es candidato), preservado en cada vuelta, y rango estrictamente menguante → el algoritmo termina y su respuesta es correcta.

Traza paso a paso: buscar una parada por código

Busquemos el código "PMA" (Plaza Mayor) en la lista de 9 códigos. Los índices van de 0 a 8:

Vuelta izq der medio lista[medio] Comparación con "PMA" Acción
1 0 8 4 "PDR" "PDR" < "PMA" izq = 5 (descarta índices 0–4)
2 5 8 6 "POL" "POL" > "PMA" der = 5 (descarta índices 6–8)
3 5 5 5 "PMA" igual return 5

Tres comparaciones para 9 elementos. La búsqueda lineal habría necesitado 6 (recorre AVP, ESN, HCE, MVJ, PDR, PMA). Observa la mecánica de descarte: tras la primera comparación quedan 4 candidatos; tras la segunda, 1. Cada vuelta reduce el rango a la mitad (a veces algo menos de la mitad, nunca más).

Y una búsqueda fallida, "HOS" (un código que no existe):

Vuelta izq der medio lista[medio] Comparación Acción
1 0 8 4 "PDR" > "HOS" der = 3
2 0 3 1 "ESN" < "HOS" izq = 2
3 2 3 2 "HCE" < "HOS" izq = 3
4 3 3 3 "MVJ" > "HOS" der = 2
3 2 izq > der return -1

Cuatro comparaciones para certificar la ausencia entre 9 elementos. Recuerda de 02-03 que en la búsqueda lineal verificar la ausencia obliga a mirarlo todo (n comparaciones); aquí basta log n — la ausencia se certifica al mismo precio que la presencia.

Versión recursiva

La estructura "descarta una mitad y repite" es naturalmente recursiva, y de hecho es un caso de divide y vencerás degenerado: se divide en dos mitades pero solo se conquista una (la otra se descarta), y no hay nada que combinar:

def busqueda_binaria_rec(lista, objetivo, izq, der):
    if izq > der:                          # caso base: rango vacío
        return -1
    medio = (izq + der) // 2
    if lista[medio] == objetivo:
        return medio
    elif lista[medio] < objetivo:
        return busqueda_binaria_rec(lista, objetivo, medio + 1, der)
    else:
        return busqueda_binaria_rec(lista, objetivo, izq, medio - 1)

busqueda_binaria_rec(codigos, "PMA", 0, len(codigos) - 1)   # → 5

Misma lógica, mismas comparaciones. La diferencia está en el espacio: cada llamada pendiente ocupa un marco en la pila (02-02), así que la recursiva usa O(log n) de espacio auxiliar frente al O(1) de la iterativa. Como además Python no optimiza la recursión de cola, en la práctica se prefiere la iterativa; la recursiva vale como puente conceptual hacia los algoritmos de 04-03 y 04-04, que sí necesitan la recursión de verdad.

Análisis: por qué O(log n)

Apliquemos el método de contar mitades, el mismo conteo de niveles de 03-01. Cada comparación fallida reduce el número de candidatos, como mínimo, a la mitad:

Comparación Candidatos restantes (n = 1.000.000)
inicio 1.000.000
1 500.000
2 250.000
3 125.000
... ...
19 1
20 0 → no está

La pregunta "¿cuántas veces puedo dividir n entre 2 hasta llegar a 1?" tiene nombre desde 01-03: log₂ n. El peor caso hace pues ⌊log₂ n⌋ + 1 comparaciones → O(log n). Es la recurrencia T(n) = T(n/2) + O(1): un solo subproblema de tamaño mitad más trabajo constante — compárala con la T(n) = 2·T(n/2) + O(n) que dejamos pendiente en 03-01 y que resolveremos en 04-03.

Los tres casos, con el criterio de 02-03:

  • Mejor caso — Θ(1): el objetivo está justo en el primer medio. Una comparación.
  • Peor caso — Θ(log n): el objetivo está en una "hoja" del proceso de división, o no está.
  • Caso promedio — Θ(log n): la mayoría de los elementos necesitan cerca de log n pasos (la mitad de los elementos solo se alcanzan en el último nivel), así que el promedio queda a una o dos comparaciones del peor caso.

Espacio: O(1) auxiliar en la iterativa (tres variables), O(log n) en la recursiva por la pila.

Búsqueda binaria contra búsqueda lineal

Enfrentemos a la recién llegada con buscar_parada de 02-03:

buscar_parada (lineal) busqueda_binaria
Requisito previo ninguno lista ordenada
Mejor caso Θ(1) (primera posición) Θ(1) (justo en el centro)
Caso promedio ≈ n/2 → Θ(n) Θ(log n)
Peor caso Θ(n) (última o ausente) Θ(log n)
Ausencia del elemento n comparaciones ⌊log₂ n⌋ + 1 comparaciones
Espacio auxiliar O(1) O(1)

Para hacerse una idea física: con el catálogo nacional de 1.000.000 de paradas, la lineal promedia 500.000 comparaciones; la binaria, 20. Es la diferencia entre jerarquías de 01-03 hecha carne.

La letra pequeña es el requisito previo. Si la lista cambia constantemente y solo vas a buscar una vez, ordenar primero (que costará O(n log n), como veremos) para ahorrar en una búsqueda es un mal negocio: la lineal gana. La búsqueda binaria brilla cuando el coste de ordenar se amortiza entre muchas búsquedas — un catálogo que se ordena una vez al desplegarse y se consulta millones de veces al día, como el de RutaBus.

Variantes útiles: primera ocurrencia y el módulo bisect

Primera ocurrencia

Nuestra busqueda_binaria devuelve algún índice donde está el objetivo. Si hay duplicados — varias llegadas con la misma hora en un panel — a menudo queremos el primero. El truco: al encontrar una coincidencia, no pares; anótala y sigue buscando a la izquierda:

def primera_ocurrencia(lista, objetivo):
    izq, der = 0, len(lista) - 1
    resultado = -1
    while izq <= der:
        medio = (izq + der) // 2
        if lista[medio] == objetivo:
            resultado = medio         # candidata... pero puede haber otra antes
            der = medio - 1           # seguimos buscando a la izquierda
        elif lista[medio] < objetivo:
            izq = medio + 1
        else:
            der = medio - 1
    return resultado

Sigue siendo O(log n): no hemos añadido vueltas, solo hemos cambiado qué hacemos al acertar. El invariante ahora es "resultado es la ocurrencia más a la izquierda vista hasta ahora, y si hay una anterior está en [izq, der]".

bisect: la búsqueda binaria de serie en Python

Python trae la búsqueda binaria de fábrica en el módulo bisect — de hecho ya lo usamos sin explicarlo en registrar_llegada (02-03, ejercicio 3):

import bisect

panel = ["18:05", "18:12", "18:30", "18:47"]
bisect.bisect_left(panel, "18:30")    # → 2: índice de la PRIMERA "18:30" (o dónde iría)
bisect.bisect_right(panel, "18:30")   # → 3: índice TRAS la última "18:30"
bisect.insort(panel, "18:20")         # busca el hueco en O(log n)... e inserta en O(n)

Dos matices que confunden al principio:

  • bisect_left/bisect_right no dicen si el elemento está: devuelven el punto de inserción que mantiene el orden. Para saber si está: i = bisect_left(lista, x) y comprobar i < len(lista) and lista[i] == x — que es exactamente primera_ocurrencia gratis.
  • insort busca en O(log n) pero inserta en O(n) por el desplazamiento de list.insert — la conclusión del ejercicio de 02-03 sigue vigente: la búsqueda binaria acelera el encontrar, no el mover.

Errores Comunes y Consejos

  • Aplicarla a una lista sin ordenar. El error número uno, y el más traicionero: no lanza excepción, simplemente devuelve resultados erróneos a veces. Si tienes dudas, assert lista == sorted(lista) en desarrollo.
  • Off-by-one en los límites. der = len(lista) en vez de len(lista) - 1, o while izq < der en vez de <=: ambos hacen que el último candidato nunca se examine (búsquedas que "no encuentran" elementos presentes en los bordes). Decide un convenio — aquí, rango cerrado [izq, der] — y sé coherente con él en las tres líneas que dependen de él.
  • Bucle infinito por no encoger el rango. Escribir izq = medio (sin + 1) parece inocente, pero con un rango de 2 elementos medio == izq y el rango ya no cambia: bucle eterno. La regla de oro: tras cada vuelta, medio debe quedar fuera del nuevo rango.
  • La anécdota del overflow. Durante décadas, la implementación de referencia en Java calculaba medio = (izq + der) / 2; en 2006 se descubrió que con arrays de más de 2³⁰ elementos la suma izq + der desbordaba el entero de 32 bits y producía un índice negativo. La corrección clásica es medio = izq + (der - izq) // 2. En Python no puede pasar — los enteros son de precisión arbitraria —, pero la conocerás en cuanto toques Java, C o C++, y es un recordatorio de que hasta el algoritmo más analizado de la historia escondía un bug durante 60 años.
  • Consejo: cuando dudes de tu implementación, prueba sistemáticamente los cuatro casos frontera: lista vacía, un elemento (presente y ausente), objetivo menor que todos, objetivo mayor que todos. Esas cinco pruebas cazan casi todos los off-by-one.

Ejercicios

Ejercicio 1

Traza a mano (tabla con izq, der, medio, comparación y acción) la búsqueda del código "TSU" en codigos = ["AVP", "ESN", "HCE", "MVJ", "PDR", "PMA", "POL", "TSU", "UNI"]. ¿Cuántas comparaciones necesita? ¿Y la búsqueda lineal?

Ejercicio 2

RutaBus guarda los kilómetros acumulados de cada expedición de la L1 en una lista ordenada. Escribe cuantas_antes(kms, limite) que devuelva cuántas expediciones llevan estrictamente menos de limite km, en O(log n). Pista: no busques un elemento; busca un punto de corte — puedes hacerlo con tu propia búsqueda binaria o con una llamada a bisect.

Ejercicio 3

Esta versión llega de una revisión de código de RutaBus con dos errores. Encuéntralos y explica qué síntoma produce cada uno (¿resultado incorrecto? ¿bucle infinito?):

def buscar(lista, objetivo):
    izq, der = 0, len(lista)
    while izq <= der:
        medio = (izq + der) // 2
        if lista[medio] == objetivo:
            return medio
        elif lista[medio] < objetivo:
            izq = medio
        else:
            der = medio - 1
    return -1

Soluciones

Solución 1

Vuelta izq der medio lista[medio] Comparación Acción
1 0 8 4 "PDR" < "TSU" izq = 5
2 5 8 6 "POL" < "TSU" izq = 7
3 7 8 7 "TSU" igual return 7

3 comparaciones. La lineal habría hecho 8 (es el penúltimo elemento). Con solo 9 elementos la ventaja parece modesta — recuerda de 01-03 que las jerarquías se separan al crecer n.

Solución 2

En una lista ordenada, "cuántos son menores que limite" es exactamente el índice donde limite se insertaría por la izquierda:

import bisect

def cuantas_antes(kms, limite):
    return bisect.bisect_left(kms, limite)

cuantas_antes([120, 340, 560, 560, 780], 560)   # → 2 (120 y 340)

A mano sería la búsqueda binaria del "primer índice con kms[i] >= limite". Este patrón — usar la búsqueda binaria para contar en vez de para encontrar — es de los más rentables en la práctica. Error común: usar bisect_right, que contaría también las expediciones con exactamente limite km (el enunciado pedía estrictamente menos).

Solución 3

  • Error 1: der = len(lista) con la condición izq <= der. En cuanto el rango se pega al borde derecho, medio puede valer len(lista) y lista[medio] lanza IndexError (por ejemplo, buscando un objetivo mayor que todos los elementos). Corrección: der = len(lista) - 1.
  • Error 2: izq = medio sin + 1. Con izq = 4, der = 5, medio = 4; si lista[4] < objetivo, se asigna izq = 4... y el estado no cambia: bucle infinito. Corrección: izq = medio + 1, que además es correcto porque medio ya está descartado.

El síntoma delata al culpable: una excepción de índice apunta a los límites iniciales; un cuelgue apunta a un rango que no encoge.

Conclusión

La deuda está pagada: la búsqueda binaria descarta la mitad de los candidatos en cada comparación gracias al invariante "si está, está en [izq, der]", y eso la hace O(log n) en peor caso y promedio — frente al Θ(n) de buscar_parada —, con O(1) de espacio en su forma iterativa. Hemos visto por qué cada detalle importa (medio + 1 para garantizar la terminación, izq <= der para no perder al último candidato), la variante de primera ocurrencia y el bisect de serie de Python, y hemos catalogado sus trampas históricas, del off-by-one al overflow que vivió 60 años en las bibliotecas estándar. Todo su poder descansa sobre una condición que hemos dado por regalada: que la lista esté ordenada. ¿Y quién la ordena? Ese es exactamente el problema de las tres próximas lecciones. Empezaremos en 04-02 por el algoritmo más humano de todos — el que usas sin saberlo al ordenar cartas o al cuadrar un panel de llegadas a mano —: el ordenamiento por inserción.

© Copyright 2026. Todos los derechos reservados