Algoritmos de Búsqueda y Complejidad Algorítmica

Semana 5 — Búsqueda lineal, búsqueda binaria, notación O grande y jerarquía de memoria

1. El Problema de la Búsqueda

La búsqueda es una de las operaciones más fundamentales y frecuentes en computación. Desde localizar un contacto en el teléfono hasta verificar si un DNI existe en una base de datos de millones de registros, buscar un elemento dentro de una colección es un problema que aparece en prácticamente todo programa.

Formalmente, el problema de búsqueda se define así: dada una secuencia de n elementos y un valor objetivo (la clave), determinar si la clave se encuentra en la secuencia y, de ser así, reportar su posición. El resultado es un índice (si se encontró) o una señal de ausencia (si no se encontró).

Contrato de la función de búsqueda

Entrada: una secuencia datos de n elementos y un valor clave del mismo tipo que los elementos.

Salida: el índice i tal que datos[i] == clave, o bien -1 si la clave no está en la secuencia.

Precondición general: la secuencia tiene al menos cero elementos (puede estar vacía).

En las semanas anteriores hemos utilizado búsquedas implícitas dentro de nuestros patrones de procesamiento de secuencias —por ejemplo, al recorrer una secuencia buscando un divisor o verificando una condición—. Esta semana formalizamos la búsqueda como algoritmo explícito, lo encapsulamos en funciones (aplicando la modularización de la Semana 4) y analizamos rigurosamente su eficiencia.

🔗 Conexión con semanas anteriores

El patrón de búsqueda con condición de corte de la Semana 3 (búsqueda de divisor para determinar primalidad, verificación de propiedad) es una instancia del algoritmo de búsqueda lineal que aquí generalizamos. La modularización de la Semana 4 nos permite encapsular cada algoritmo de búsqueda en una función con un contrato claro: precondiciones, parámetros y valor de retorno.

2. Búsqueda Lineal (Secuencial)

2.1. Idea y diseño

La búsqueda lineal es el algoritmo más directo: recorrer la secuencia elemento por elemento, desde el primero hasta el último, comparando cada uno con la clave buscada. Si se encuentra una coincidencia, se reporta la posición; si se llega al final sin encontrarla, se reporta ausencia.

Búsqueda lineal: buscar clave = 42 15 i=0 ✗ 27 i=1 ✗ 8 i=2 ✗ 63 i=3 ✗ 42 i=4 ✓ 91 5 36 Recorrido secuencial: 5 comparaciones

2.2. Pseudocódigo y código Python

Algoritmo busqueda_lineal(datos, n, clave)
    i ← 0
    mientras i < n y datos[i] ≠ clave:
        i ← i + 1
    si i < n:
        retornar i     // encontrado en posición i
    sino:
        retornar −1    // no encontrado

Diseño del bucle: condición compuesta con cortocircuito

La condición del while tiene dos partes unidas por and: primero se verifica que el índice está dentro de los límites (i < n) y luego se compara el elemento (datos[i] != clave). Gracias a la evaluación en cortocircuito (Semana 3), si i < n es falso, Python no evalúa datos[i], evitando un acceso fuera de rango. Al salir del bucle, la inspección post-bucle determina cuál de las dos condiciones causó la salida.

def busqueda_lineal(datos, clave):
    '''
    SECCIÓN DECLARATIVA
    Descripción: Buscar la posición de 'clave' en la secuencia 'datos'.
        Recorre la secuencia elemento a elemento hasta encontrar la
        clave o agotar todos los elementos.
    Precondición: datos es una secuencia indexable; clave es comparable
        con los elementos de datos mediante ==.
    Postcondición: retorna el índice i tal que datos[i] == clave,
        o -1 si clave no pertenece a datos.
    '''

    # --- SECCIÓN ALGORÍTMICA ---

    # Prólogo: obtener el tamaño de la secuencia
    n = len(datos)

    # Resolución: recorrer hasta encontrar o agotar
    i = 0
    while i < n and datos[i] != clave:
        i += 1

    # Epílogo: inspección post-bucle
    if i < n:
        return i        # encontrado
    else:
        return -1      # no encontrado

2.3. Traza manual

Tracemos la ejecución para datos = [15, 27, 8, 63, 42, 91, 5, 36] y clave = 42:

Iteraciónii < ndatos[i]datos[i] != 42Acción
10True15Truei ← 1
21True27Truei ← 2
32True8Truei ← 3
43True63Truei ← 4
54True42FalseSale del while

Al salir: i = 4, i < 8 es verdadero ⇒ retorna 4. Se realizaron 5 comparaciones con elementos de la secuencia.

2.4. Diseño de casos de prueba

TipoEntradaResultado esperadoJustificación
Normaldatos=[3,7,1,9,4], clave=93Elemento interior de la secuencia
Normaldatos=[3,7,1,9,4], clave=5-1Elemento ausente
Límitedatos=[3,7,1,9,4], clave=30Primer elemento (mejor caso)
Límitedatos=[3,7,1,9,4], clave=44Último elemento (peor caso si existe)
Extremodatos=[], clave=5-1Secuencia vacía
Extremodatos=[42], clave=420Secuencia de un elemento, encontrado
Extremodatos=[42], clave=7-1Secuencia de un elemento, no encontrado

2.5. Análisis de eficiencia

¿Cuántas comparaciones realiza la búsqueda lineal en una secuencia de n elementos?

En los tres casos, la cantidad de trabajo crece proporcionalmente a n. Decimos que la búsqueda lineal tiene complejidad O(n) —una notación que formalizaremos en la Sección 4.

3. Optimizaciones de la Búsqueda Lineal

La búsqueda lineal es simple y correcta, pero si vamos a buscar muchas veces en la misma secuencia —un escenario muy frecuente en la práctica—, existen estrategias que reducen el tiempo promedio de búsquedas sucesivas sin cambiar el peor caso. Estas optimizaciones se basan en una observación empírica: no todas las claves se buscan con la misma frecuencia. Si podemos reorganizar la secuencia para que los elementos más buscados estén más cerca del inicio, las búsquedas futuras serán más rápidas en promedio.

Principio de localidad temporal

En muchos sistemas reales, un elemento accedido recientemente tiene alta probabilidad de ser accedido de nuevo en el futuro cercano. Este principio, conocido como localidad temporal, es el mismo que aprovecha la memoria caché del procesador (tema holístico de esta semana). Aplicado a la búsqueda, sugiere que los elementos recién encontrados deberían estar más cerca del inicio de la secuencia.

3.1. Inserción al comienzo

Si los elementos recién incorporados a la secuencia tienen mayor probabilidad de ser buscados en el futuro inmediato, la primera optimización es trivial: insertar siempre al comienzo de la lista en lugar de al final. Así, los elementos más nuevos —presumiblemente los más buscados— se encuentran primero.

def insertar_al_comienzo(datos, nuevo_elemento):
    # Inserta al inicio: los elementos nuevos se encuentran primero
    datos.insert(0, nuevo_elemento)

Costo de la inserción al comienzo

En una lista de Python (implementada como arreglo dinámico), insert(0, x) tiene costo O(n) porque debe desplazar todos los elementos existentes una posición hacia la derecha. La inserción al final (append) es O(1) amortizado. Por lo tanto, la inserción al comienzo solo conviene si el beneficio en las búsquedas posteriores compensa su costo. En una lista enlazada (estructura que se estudia en cursos posteriores), la inserción al comienzo es O(1).

3.2. Mover al frente (Move-to-Front)

La heurística mover al frente funciona así: cada vez que una búsqueda encuentra el elemento buscado, ese elemento se mueve a la primera posición de la secuencia (desplazando los demás). Esto garantiza que los elementos buscados recientemente siempre están al inicio.

Move-to-Front: buscar clave = 63 ANTES: 15 27 8 63 42 91 encontrado en i=3 mover al frente DESPUÉS: 63 15 27 8 42 91 Si se vuelve a buscar 63, ahora se encuentra en i=0 → 1 comparación
def busqueda_lineal_mtf(datos, clave):
    '''Búsqueda lineal con heurística Move-to-Front.
       Si encuentra la clave, la mueve a la posición 0.'''
    n = len(datos)
    i = 0
    while i < n and datos[i] != clave:
        i += 1

    if i < n:
        # Encontrado: mover al frente
        elemento = datos.pop(i)   # extraer de posición i
        datos.insert(0, elemento) # insertar al inicio
        return 0               # ahora está en posición 0
    else:
        return -1

Ventaja y desventaja de Move-to-Front

Ventaja: se adapta rápidamente a patrones de acceso cambiantes. Un elemento buscado una vez queda inmediatamente al inicio, por lo que una segunda búsqueda consecutiva es O(1).

Desventaja: una sola búsqueda esporádica de un elemento poco frecuente lo coloca al frente, desplazando a todos los demás. Si la distribución de búsquedas es estable, esto puede ser contraproducente.

3.3. Transposición (Transpose)

La heurística de transposición es más conservadora: cada vez que se encuentra la clave, se la intercambia con el elemento inmediatamente anterior. Así, los elementos frecuentemente buscados «ascienden» gradualmente hacia el inicio, sin saltos bruscos.

Transposición: buscar clave = 63 ANTES: 15 27 8 63 42 91 intercambio vecinos DESPUÉS: 15 27 63 8 42 91 El 63 subió de i=3 a i=2. Con búsquedas repetidas, seguirá ascendiendo.
def busqueda_lineal_transpose(datos, clave):
    '''Búsqueda lineal con heurística de Transposición.
       Si encuentra la clave, la intercambia con el anterior.'''
    n = len(datos)
    i = 0
    while i < n and datos[i] != clave:
        i += 1

    if i < n:
        if i > 0:  # no intercambiar si ya está en posición 0
            # Intercambio con el anterior (swap)
            datos[i], datos[i - 1] = datos[i - 1], datos[i]
            return i - 1  # nueva posición
        else:
            return 0      # ya estaba al inicio
    else:
        return -1

3.4. Comparación de las heurísticas

AspectoSin optimizaciónMove-to-FrontTransposición
Tras encontrar No modifica la secuencia Mueve el elemento a posición 0 Intercambia con el vecino anterior
Adaptación Ninguna Inmediata (un acceso basta) Gradual (requiere múltiples accesos)
Estabilidad Orden fijo Baja: un acceso aislado altera todo Alta: sube solo si se busca repetidamente
Costo de reorganización 0 O(n) en lista con arreglo; O(1) en lista enlazada O(1) siempre (un solo swap)
Mejor escenario — Distribución que cambia frecuentemente Distribución estable de frecuencias

Ejemplo: secuencia de búsquedas repetidas

Supongamos datos = [A, B, C, D, E] y la secuencia de búsquedas: D, D, D, D.

Move-to-Front: tras la primera búsqueda de D (4 comparaciones), la lista queda [D, A, B, C, E]. Las tres búsquedas siguientes de D cuestan 1 comparación cada una. Total: 4 + 1 + 1 + 1 = 7 comparaciones.

Transposición: búsqueda 1 (4 comp.) → [A, B, D, C, E]; búsqueda 2 (3 comp.) → [A, D, B, C, E]; búsqueda 3 (2 comp.) → [D, A, B, C, E]; búsqueda 4 (1 comp.) → [D, A, B, C, E]. Total: 4 + 3 + 2 + 1 = 10 comparaciones.

En este caso, Move-to-Front es más eficiente. Pero si la búsqueda de D fuera esporádica entre muchas búsquedas de A, Transposición evitaría que D desplace injustamente a A del frente.

4. Complejidad Algorítmica: Notación O Grande

En la Semana 3, al comparar las series de Leibniz y Nilakantha para aproximar π, observamos que dos algoritmos pueden resolver el mismo problema con eficiencias radicalmente diferentes. También al optimizar la verificación de primalidad, reducimos los divisores a probar de n − 2 a ⌊√n⌋. ¿Cómo expresar esta diferencia de manera precisa? La respuesta es la notación O grande (Big-O notation).

4.1. La idea central

La notación O grande describe cómo crece el tiempo de ejecución (o el uso de memoria) de un algoritmo a medida que crece el tamaño de la entrada n. No nos interesa el tiempo exacto en milisegundos (que depende del hardware, el sistema operativo y la carga del procesador), sino el orden de crecimiento: ¿se duplica el tiempo cuando se duplica n? ¿Se cuadruplica? ¿Apenas cambia?

Definición intuitiva de O(f(n))

Decimos que un algoritmo tiene complejidad O(f(n)) si, para entradas suficientemente grandes, su tiempo de ejecución crece a lo sumo como la función f(n), salvo una constante multiplicativa. Es una cota superior del crecimiento.

Formalmente: T(n) ∈ O(f(n)) si existen constantes c > 0 y n0 ≥ 1 tales que T(n) ≤ c · f(n) para todo n ≥ n0.

4.2. Reglas prácticas

  1. Constantes no importan: O(3n) = O(n). O(5n²) = O(n²). Las constantes multiplicativas se absorben.
  2. Términos menores no importan: O(n² + 3n + 7) = O(n²). Para n grande, el término de mayor grado domina.
  3. El caso se especifica: O grande se suele aplicar al peor caso, salvo que se indique explícitamente el caso promedio.

4.3. Complejidades comunes

La siguiente tabla muestra las complejidades más frecuentes, ordenadas de menor a mayor crecimiento, con ejemplos de operaciones y el impacto concreto sobre la cantidad de operaciones para distintos valores de n:

NotaciónNombreEjemplon=10n=100n=1.000n=1.000.000
O(1) Constante Acceder a datos[i] 1 1 1 1
O(log n) Logarítmica Búsqueda binaria ~3 ~7 ~10 ~20
O(n) Lineal Búsqueda lineal 10 100 1.000 1.000.000
O(n log n) Lineal-logarítmica Merge sort ~33 ~664 ~10.000 ~20.000.000
O(n²) Cuadrática Burbuja, selección 100 10.000 1.000.000 1012
O(2n) Exponencial Subconjuntos por fuerza bruta 1.024 ~1030 — —
Crecimiento comparativo de funciones Tamaño de entrada (n) Operaciones 10 30 50 70 O(1) O(log n) O(n) O(n log n) O(n²) O(2ⁿ)

4.4. Análisis de la búsqueda lineal con O grande

Apliquemos la notación O grande al algoritmo de búsqueda lineal:

Operación dominante: la comparación datos[i] != clave. Es la operación que se repite en cada iteración del bucle y la que determina el crecimiento del tiempo de ejecución.

Cuando decimos que la búsqueda lineal es «O(n)» sin más especificación, nos referimos al peor caso.

5. Búsqueda Binaria

5.1. La precondición clave: secuencia ordenada

La búsqueda binaria requiere una precondición que la búsqueda lineal no necesita: la secuencia debe estar ordenada. Esta precondición es un contrato —si no se cumple, el algoritmo no garantiza un resultado correcto—.

Precondición ineludible

La búsqueda binaria sobre una secuencia desordenada puede dar resultados incorrectos: puede reportar que un elemento no está cuando sí está, o buscar en la mitad equivocada. Antes de usar búsqueda binaria, el programador debe garantizar que la secuencia está ordenada (o bien ordenarla previamente, lo cual tiene su propio costo).

5.2. Idea: dividir para conquistar

La búsqueda binaria aprovecha el orden para descartar la mitad de los elementos en cada paso. La idea es análoga a buscar una palabra en un diccionario de papel: no se recorre página por página, sino que se abre por la mitad, se compara y se descarta la mitad que no puede contener la palabra.

  1. Examinar el elemento central de la secuencia.
  2. Si coincide con la clave, la búsqueda termina.
  3. Si la clave es menor que el central, buscar solo en la mitad izquierda.
  4. Si la clave es mayor que el central, buscar solo en la mitad derecha.
  5. Repetir hasta encontrar la clave o hasta que la región de búsqueda quede vacía.
Búsqueda binaria: buscar clave = 42 en [5, 8, 15, 27, 36, 42, 63, 91] Paso 1: 5 8 15 27 36 42 63 91 medio=3, datos[3]=27 42 > 27 → descartar izquierda Paso 2: 5 8 15 27 36 42 63 91 medio=5, datos[5]=42 42 == 42 → ¡Encontrado! Solo 2 comparaciones. La búsqueda lineal habría necesitado 6. Con n = 1.000.000, búsqueda binaria necesita ~20 comparaciones vs. hasta 1.000.000 de la lineal.

5.3. Código Python: versión iterativa

def busqueda_binaria(datos, clave):
    '''
    SECCIÓN DECLARATIVA
    Descripción: Buscar la posición de 'clave' en la secuencia ordenada 'datos'
        usando búsqueda binaria (versión iterativa).
    Precondición: datos está ordenado de menor a mayor.
    Postcondición: retorna el índice i tal que datos[i] == clave,
        o -1 si clave no pertenece a datos.
    '''

    # --- SECCIÓN ALGORÍTMICA ---

    # Prólogo: definir la región de búsqueda
    izq = 0
    der = len(datos) - 1

    # Resolución: dividir la región a la mitad en cada paso
    while izq <= der:
        medio = (izq + der) // 2

        if datos[medio] == clave:
            return medio             # encontrado
        elif clave < datos[medio]:
            der = medio - 1        # descartar mitad derecha
        else:
            izq = medio + 1        # descartar mitad izquierda

    # Epílogo: la región quedó vacía (izq > der)
    return -1                   # no encontrado

5.4. Traza manual

Para datos = [5, 8, 15, 27, 36, 42, 63, 91] y clave = 42:

Pasoizqdermediodatos[medio]ComparaciónAcción
10732742 > 27izq ← 4
24754242 == 42Retorna 5 ✓

Solo 2 comparaciones. La búsqueda lineal habría necesitado 6 (recorrer las posiciones 0 a 5).

Errores de indexación en las trazas

Un error frecuente al trazar la búsqueda binaria es confundir la posición visual de un elemento con su índice real. Por ejemplo, en la secuencia de 8 elementos anterior, el valor 42 está en la posición 5 (contando desde 0), no en la 6. Si al calcular medio = (4+7)//2 = 5 hubiéramos anotado erróneamente que datos[5] = 63, la traza habría continuado descartando la mitad equivocada y habría «concluido» que el 42 no está —un resultado incorrecto provocado no por un fallo del algoritmo, sino por un error humano en la traza—. La lección: siempre verificar cada valor consultando la secuencia concreta.

5.5. Caso de búsqueda fallida

Tracemos la búsqueda de clave = 40 (ausente) en la misma secuencia:

Pasoizqdermediodatos[medio]ComparaciónAcción
10732740 > 27izq ← 4
24754240 < 42der ← 4
34443640 > 36izq ← 5
454——izq > derRetorna −1

3 comparaciones con elementos de la secuencia para determinar que 40 no está presente. La búsqueda lineal habría necesitado 8 (recorrer toda la secuencia).

5.6. Análisis de eficiencia

En cada paso, la búsqueda binaria descarta la mitad de los elementos restantes. ¿Cuántas veces podemos dividir n por 2 antes de llegar a 1?

Derivación de O(log n)

Partimos de n elementos. Tras 1 paso: n/2. Tras 2 pasos: n/4. Tras k pasos: n/2k. La búsqueda termina cuando n/2k ≤ 1, es decir, cuando 2k ≥ n, o sea, k ≥ log2(n).

Por lo tanto, la búsqueda binaria realiza a lo sumo ⌈log2(n)⌉ + 1 comparaciones. Esto es O(log n).

5.7. Diseño de casos de prueba

TipoEntradaResultadoJustificación
Normaldatos=[2,5,8,12,16,23,38,56,72,91], clave=235Elemento interior
Normaldatos=[2,5,8,12,16,23,38,56,72,91], clave=50-1Ausente, entre elementos
Límitedatos=[2,5,8,12,16,23,38,56,72,91], clave=20Primer elemento
Límitedatos=[2,5,8,12,16,23,38,56,72,91], clave=919Último elemento
Límitedatos=[2,5,8,12,16,23,38,56,72,91], clave=1-1Menor que todos
Límitedatos=[2,5,8,12,16,23,38,56,72,91], clave=100-1Mayor que todos
Extremodatos=[], clave=5-1Secuencia vacía
Extremodatos=[42], clave=420Un elemento, encontrado

6. Búsqueda por Interpolación

La búsqueda binaria siempre examina el elemento central de la región, pero ¿es siempre la mejor elección? Cuando buscamos en una guía telefónica un apellido que empieza con «Z», no abrimos por la mitad: abrimos cerca del final. Estamos estimando la posición del elemento a partir de su valor y de los valores en los extremos de la región. Esta idea da origen a la búsqueda por interpolación.

6.1. La idea: estimar la posición

En lugar de calcular el punto medio como (izq + der) // 2, la búsqueda por interpolación estima la posición más probable de la clave usando una interpolación lineal entre los valores de los extremos:

Fórmula de interpolación

pos = izq + ⌊(clave − datos[izq]) × (der − izq) / (datos[der] − datos[izq])⌋

La intuición: si datos[izq] = 10, datos[der] = 100 y clave = 25, la clave está a ~17% del rango de valores, así que estimamos su posición a ~17% del rango de índices.

6.2. Código Python

def busqueda_interpolacion(datos, clave):
    '''
    SECCIÓN DECLARATIVA
    Descripción: Buscar 'clave' en la secuencia ordenada 'datos' usando
        interpolación lineal para estimar la posición.
    Precondición: datos está ordenado de menor a mayor y contiene valores
        numéricos (la interpolación requiere aritmética sobre los valores).
    Postcondición: retorna el índice i tal que datos[i] == clave,
        o -1 si clave no pertenece a datos.
    '''

    # --- SECCIÓN ALGORÍTMICA ---

    izq = 0
    der = len(datos) - 1

    while izq <= der and datos[izq] <= clave <= datos[der]:
        # Evitar división por cero cuando todos los valores son iguales
        if datos[izq] == datos[der]:
            if datos[izq] == clave:
                return izq
            else:
                return -1

        # Estimar posición por interpolación lineal
        pos = izq + (clave - datos[izq]) * (der - izq) // (datos[der] - datos[izq])

        if datos[pos] == clave:
            return pos
        elif datos[pos] < clave:
            izq = pos + 1
        else:
            der = pos - 1

    return -1

6.3. Traza manual

Para datos = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100] (distribución uniforme) y clave = 70:

PasoizqderCálculo de posposdatos[pos]Acción
1090 + (70−10)×9/(100−10) = 6670Retorna 6 ✓

Una sola comparación. La interpolación estimó exactamente la posición porque los datos están uniformemente distribuidos. La búsqueda binaria habría necesitado 2–3 comparaciones.

6.4. Análisis de conveniencia

La eficiencia de la búsqueda por interpolación depende críticamente de la distribución de los valores:

DistribuciónInterpolaciónBinariaRecomendación
Uniforme
Valores equiespaciados o casi
O(log log n) O(log n) Interpolación
Aproximadamente uniforme
DNIs, timestamps, IPs
~O(log log n) O(log n) Interpolación
Sesgada / exponencial
Valores concentrados en un extremo
O(n) O(log n) Binaria
Desconocida Impredecible O(log n) garantizado Binaria (más segura)

¿Por qué O(log log n)?

Cuando la distribución es uniforme, la estimación por interpolación acota la región a √(der − izq) elementos en cada paso, en lugar de la mitad. Resolver la recurrencia resultante produce O(log log n). Para n = 1.000.000: log2(n) ≈ 20, pero log2(log2(n)) ≈ 4. La diferencia es significativa para grandes volúmenes de datos con distribución conocida.

El peor caso puede ser devastador

Si los datos tienen distribución muy sesgada (por ejemplo, [1, 2, 3, 4, 5, 1000000]), la fórmula de interpolación puede estimar posiciones muy alejadas del valor real, generando una degradación a O(n) —peor que la búsqueda binaria—. La regla práctica es: usar interpolación solo cuando se tiene certeza razonable de que la distribución es aproximadamente uniforme. En caso de duda, la búsqueda binaria es la opción segura porque su peor caso O(log n) está garantizado sin importar la distribución.

🔗 Conexión con la jerarquía de memoria

La búsqueda por interpolación tiene otra ventaja práctica cuando los datos residen en disco: cada acceso a disco es costosísimo (~5–20 ms), así que reducir la cantidad de accesos de ~20 (binaria) a ~4 (interpolación) produce una aceleración medible. Esto explica su uso histórico en sistemas de bases de datos que operan sobre archivos indexados con claves numéricas distribuidas uniformemente.

7. Búsqueda Binaria Recursiva: Anticipo

En la Semana 4, al estudiar la pila de ejecución, observamos que cada invocación de función apila un nuevo frame con sus variables locales y su dirección de retorno. La búsqueda binaria tiene una estructura que se presta naturalmente a una formulación recursiva: el caso en que la clave está en el medio es el caso base (se retorna inmediatamente), y los casos en que se descarta una mitad son llamadas recursivas sobre una región más pequeña.

def busqueda_binaria_rec(datos, clave, izq, der):
    # Caso base: región vacía
    if izq > der:
        return -1

    medio = (izq + der) // 2

    # Caso base: encontrado
    if datos[medio] == clave:
        return medio
    # Caso recursivo: buscar en la mitad correspondiente
    elif clave < datos[medio]:
        return busqueda_binaria_rec(datos, clave, izq, medio - 1)
    else:
        return busqueda_binaria_rec(datos, clave, medio + 1, der)

Recursividad: anticipo conceptual

Cada llamada recursiva reduce el tamaño de la región (der − izq) a la mitad. Eventualmente, la región queda vacía (izq > der) o se encuentra la clave: estos son los casos base que detienen la recursión. Observe que en la pila de ejecución se apilarán a lo sumo ⌈log2(n)⌉ frames —uno por cada paso de la búsqueda—, un uso de memoria muy modesto.

La recursividad como técnica general de resolución de problemas se estudiará en detalle en la Semana 8, donde analizaremos su estructura (caso base, caso recursivo), su correctitud (convergencia al caso base), su relación con la pila de ejecución y su comparación sistemática con la iteración.

¿Cuándo preferir la versión recursiva?

Las versiones iterativa y recursiva de la búsqueda binaria tienen la misma complejidad temporal O(log n). La iterativa usa O(1) de memoria adicional; la recursiva usa O(log n) por los frames apilados. En Python, la versión iterativa es generalmente preferible porque Python tiene un límite de profundidad de recursión (por defecto 1000, consultable con sys.getrecursionlimit()). Para n = 21000 la recursiva fallaría —pero eso son más elementos que partículas en el universo observable, así que en la práctica no es una limitación real para la búsqueda binaria.

8. Comparación: Lineal vs. Binaria vs. Interpolación

AspectoBúsqueda LinealBúsqueda BinariaBúsqueda por Interpolación
Precondición Ninguna Secuencia ordenada Secuencia ordenada + valores numéricos
Estrategia Recorrido secuencial Dividir por la mitad Estimar posición por interpolación lineal
Peor caso O(n) O(log n) O(n) si distribución sesgada
Caso promedio O(n) O(log n) O(log log n) si distribución uniforme
Mejor caso O(1) O(1) O(1)
n = 1.000.000 Hasta 1.000.000 Hasta 20 ~4 (uniforme) o hasta 1.000.000 (sesgada)
¿Cuándo usar? Secuencias pequeñas o desordenadas Secuencias ordenadas; elección segura universal Secuencias ordenadas con distribución conocida y aproximadamente uniforme

¿Cuándo conviene ordenar y luego buscar?

Ordenar una secuencia de n elementos cuesta O(n log n) (con un buen algoritmo, como merge sort, que estudiaremos en la Semana 6). Si solo vamos a buscar una vez, la búsqueda lineal O(n) es más barata que ordenar O(n log n) + buscar O(log n). Pero si vamos a buscar muchas veces (m búsquedas), el costo total es:

La segunda opción conviene cuando m es suficientemente grande. Por ejemplo, para n = 10.000 y m = 100 búsquedas: lineal ≈ 100 × 10.000 = 1.000.000 operaciones; binaria ≈ 133.000 + 100 × 14 = 134.400 operaciones. Con solo 100 búsquedas, la opción binaria ya es 7 veces más rápida.

9. Correctitud de la Búsqueda Binaria: Invariante del Bucle

La búsqueda binaria es un algoritmo notoriamente difícil de implementar correctamente. Donald Knuth observó que, a pesar de que la idea es simple, la primera implementación publicada (en 1946) contenía errores, y que pasaron 16 años hasta que se publicó una versión totalmente correcta. ¿Cómo verificar que nuestra implementación es correcta?

Invariante del bucle

Una propiedad que se mantiene verdadera antes y después de cada iteración

La clave para demostrar la correctitud es el invariante del bucle: una propiedad que es verdadera al comenzar cada iteración y que, al terminar el bucle, garantiza que el resultado es correcto.

Para la búsqueda binaria, el invariante es:

Invariante: si la clave está en datos, entonces está en datos[izq..der] (la sub-secuencia entre los índices izq y der, ambos incluidos).

Inicialización: antes de la primera iteración, izq = 0 y der = n-1, que abarca toda la secuencia ⇒ el invariante es trivialmente verdadero.

Mantenimiento: si datos[medio] < clave, todos los elementos en posiciones izq..medio son menores que clave (por el orden). Ajustar izq = medio + 1 preserva el invariante. El caso simétrico con der = medio - 1 es análogo.

Terminación: cuando izq > der, la región está vacía. Si la clave existiera, estaría en una región vacía —contradicción—. Retornar −1 es correcto.

Errores clásicos en la búsqueda binaria

1. Desbordamiento del cálculo del medio: la fórmula (izq + der) // 2 puede desbordar en lenguajes con enteros de tamaño fijo (C, Java) si izq + der excede el máximo. La alternativa segura es izq + (der - izq) // 2. En Python los enteros no tienen límite de tamaño, pero es un buen hábito.

2. Condición del bucle: usar izq < der en lugar de izq <= der provoca que no se examine el caso en que la región tiene exactamente un elemento (izq == der).

3. Actualización de límites: hacer izq = medio en lugar de izq = medio + 1 puede causar un bucle infinito cuando izq == medio (región de 2 elementos).

10. Jerarquía de Memoria: Del Registro al Disco

Los algoritmos de búsqueda no operan en abstracto: se ejecutan sobre datos almacenados en la memoria de la computadora. Pero «la memoria» no es un componente homogéneo —es una jerarquía de dispositivos con capacidades y velocidades radicalmente diferentes. Comprender esta jerarquía es esencial para entender por qué ciertos algoritmos son rápidos en la práctica (no solo en teoría).

Jerarquía de memoria Registros CPU ~1 ns · ~1 KB · SRAM Caché L1 / L2 / L3 ~1–10 ns · 32 KB–64 MB · SRAM Memoria RAM ~50–100 ns · 4–128 GB · DRAM Almacenamiento SSD ~25–100 μs · 256 GB–4 TB · Flash NAND Disco HDD / Red ~5–20 ms · TB–PB · Magnético / Remoto Velocidad ↑ Capacidad ↑ Cada nivel es ~10–1000× más lento que el anterior y ~10–1000× más grande

10.1. ¿Por qué una jerarquía?

Existe un compromiso fundamental en la tecnología de memorias: velocidad y capacidad son inversamente proporcionales al costo. La memoria más rápida (registros, SRAM) es extremadamente cara y solo se puede fabricar en cantidades pequeñas. La memoria más lenta (disco HDD) es barata y puede almacenar terabytes. La jerarquía es la solución ingenieril a este compromiso: usar poca memoria rápida para los datos que se usan ahora, y mucha memoria lenta para el resto.

10.2. El principio de localidad

La jerarquía funciona gracias a un fenómeno empírico de los programas reales: la localidad de referencia.

🔗 Conexión con los algoritmos de búsqueda

La búsqueda lineal tiene excelente localidad espacial: accede a posiciones consecutivas del arreglo, lo que aprovecha perfectamente las líneas de caché. La búsqueda binaria, en cambio, salta a posiciones distantes del arreglo en cada paso, lo que puede causar más cache misses. Para arreglos pequeños (decenas de elementos), la búsqueda lineal puede ser más rápida que la binaria en la práctica, a pesar de su peor complejidad asintótica, precisamente por la localidad de caché.

Las heurísticas de Move-to-Front y Transposición explotan la localidad temporal: asumen que un elemento buscado recientemente volverá a buscarse pronto, y lo colocan donde se encontrará más rápido.

10.3. ¿Cómo funciona la caché?

La caché es una memoria pequeña y rápida (SRAM) ubicada dentro del procesador. Cuando la CPU necesita un dato, sigue este proceso:

  1. Busca el dato en la caché L1 (la más rápida y pequeña).
  2. Si está (cache hit), lo usa inmediatamente (~1 ns).
  3. Si no está (cache miss), busca en L2, luego L3, luego RAM.
  4. Al traer el dato desde un nivel inferior, se copia también a la caché, junto con los datos vecinos (una línea de caché, típicamente 64 bytes), aprovechando la localidad espacial.

Impacto práctico: tiempos de acceso relativos

Si un acceso a caché L1 fuera equivalente a buscar un libro en tu escritorio (2 segundos), entonces: caché L2 sería buscar en la estantería de tu cuarto (7 segundos), RAM sería ir a la biblioteca del edificio (3 minutos), SSD sería ir a una biblioteca pública (1 hora), y HDD sería pedir un libro por correo postal (2 semanas). Esta analogía ilustra por qué un cache miss es tan costoso en términos relativos.

11. Administración de Archivos en el Sistema Operativo

En el extremo inferior de la jerarquía de memoria se encuentran los dispositivos de almacenamiento persistente (SSD, HDD). El sistema operativo gestiona el acceso a estos dispositivos a través del sistema de archivos, una abstracción que ya introdujimos en el documento «Funciones del Sistema Operativo». Aquí profundizamos en los conceptos más relevantes para un programador.

11.1. Rutas y estructura de directorios

El sistema de archivos organiza la información en una estructura jerárquica de árbol. Cada archivo se identifica por su ruta (path), que puede ser absoluta (desde la raíz) o relativa (desde el directorio actual).

ConceptoLinux / macOSWindows
Raíz / C:\ (una raíz por unidad)
Separador / (barra) \ (contrabarra)
Ruta absoluta /home/alumno/tp1.py C:\Users\alumno\tp1.py
Ruta relativa ./datos/entrada.txt .\datos\entrada.txt
Directorio actual . .
Directorio padre .. ..

11.2. Permisos de archivos

En sistemas Unix/Linux, cada archivo tiene tres conjuntos de permisos: para el propietario, el grupo y otros. Cada conjunto define tres permisos: lectura (r), escritura (w) y ejecución (x).

$ ls -l tp1.py
-rwxr-xr-- 1 alumno cursada 2048 may 15 10:30 tp1.py

La cadena de 10 caracteres al inicio se descompone en cuatro secciones, cada una con un significado específico:

Descomposición de los permisos - r w x r - x r - - Tipo - archivo d directorio l enlace Propietario rwx = lectura + escritura + ejecución 7 (4+2+1) Grupo r-x = lectura + ejecución (sin escritura) 5 (4+0+1) Otros r-- = solo lectura (sin escritura ni ejecución) 4 (4+0+0)

El conjunto de permisos rwxr-xr-- se representa numéricamente como 754 en octal. Cada grupo de tres permisos es un número octal: r=4, w=2, x=1; los valores se suman dentro de cada grupo.

Representación octal y conexión con la Semana 2

En Python, os.chmod('tp1.py', 0o754) establece estos permisos. El prefijo 0o indica notación octal. La representación octal conecta directamente con la representación binaria estudiada en la Semana 2: cada dígito octal corresponde exactamente a 3 bits. Así, el permiso rwx (7 en octal) equivale a 111 en binario, y r-- (4 en octal) equivale a 100 en binario. Esta correspondencia no es casual: el sistema de permisos Unix fue diseñado para aprovechar la relación entre octal y binario.

11.3. Operaciones fundamentales sobre archivos

Todo acceso a archivos se realiza mediante llamadas al sistema (syscalls), que son solicitudes que el programa hace al kernel del SO. El documento «Sistema Operativo: Funciones y Administración de Archivos» detalla estas operaciones; aquí destacamos las más relevantes para entender cómo un programa interactúa con datos persistentes.

OperaciónSyscall¿Qué hace el SO?Equivalente en Python
Abrir open(path, flags) Resuelve la ruta recorriendo los directorios, verifica permisos y crea una entrada en la tabla de archivos abiertos del proceso. Retorna un descriptor de archivo (fd). f = open('datos.txt', 'r')
Leer read(fd, buf, n) Busca los bloques en el buffer cache; si no están, los trae del disco. Actualiza la posición actual de lectura. contenido = f.read()
Escribir write(fd, buf, n) Escribe en el buffer cache (puede ser asíncrono con el disco). Actualiza el inodo con el nuevo tamaño. f.write('datos')
Cerrar close(fd) Libera la entrada en la tabla de archivos. Si era la última referencia, hace flush de buffers pendientes. f.close()

El descriptor de archivo (fd)

Cuando un programa abre un archivo, el SO le asigna un descriptor de archivo: un entero pequeño que identifica esa conexión abierta. Por convención, todo proceso comienza con tres descriptores ya abiertos: 0 (entrada estándar — teclado), 1 (salida estándar — pantalla) y 2 (salida de error). Los archivos que el programa abre explícitamente reciben los números 3, 4, etc. En Python, el with gestiona automáticamente la apertura y el cierre, pero detrás de escena opera sobre estos descriptores.

11.4. Caché de buffer: la memoria intermedia del disco

El SO mantiene en RAM una caché de bloques de disco (buffer cache o page cache) que actúa como memoria intermedia entre los programas y el almacenamiento físico. Esta caché es fundamental para el rendimiento:

🔗 Caché de buffer y algoritmos de búsqueda

La caché de buffer aplica el mismo principio de localidad que la caché del procesador, pero a escala de disco. Si un programa realiza búsquedas repetidas sobre un archivo de datos, la primera lectura trae los bloques del disco a la caché en RAM; las búsquedas siguientes los encuentran en RAM, evitando accesos a disco. Esto es especialmente relevante para la búsqueda binaria sobre archivos: aunque el algoritmo accede a posiciones dispersas, la cantidad total de bloques que necesita es O(log n) —un conjunto pequeño que la caché puede retener completamente—.

11.5. Archivos en Python: anticipación

La lectura y escritura de archivos en Python se desarrollará en profundidad en la Semana 6 (junto con los algoritmos de ordenamiento). Aquí anticipamos la idea central: Python abstrae las operaciones de E/S del sistema operativo mediante el context manager with, que garantiza el cierre correcto del archivo incluso si ocurre un error.

# Lectura de un archivo de texto (anticipación Semana 6)
with open('datos.txt', 'r') as archivo:
    for linea in archivo:
        print(linea)

¿Qué ocurre detrás de with open(...)?

La sentencia with invoca open(), que a su vez ejecuta la syscall del SO para abrir el archivo. El SO resuelve la ruta (recorriendo el árbol de directorios), consulta el inodo del archivo (una estructura de datos del sistema de archivos que contiene los metadatos: tamaño, permisos, punteros a los bloques de datos), verifica los permisos del usuario y, si todo es correcto, crea el descriptor de archivo. Al salir del bloque with —incluso si ocurre una excepción— se ejecuta automáticamente close(), liberando el descriptor y sincronizando los buffers. Para más detalles sobre inodos, descriptores y el ciclo completo de vida de un archivo, consultar la sección 6 del documento «Sistema Operativo: Funciones y Administración de Archivos».

🔗 Conexión holística: del algoritmo al hardware

Cuando nuestro programa de búsqueda binaria accede a datos[medio], ocurre una cadena de eventos invisible:

  1. Python solicita el valor al intérprete CPython.
  2. CPython accede a la dirección de memoria del elemento en el arreglo.
  3. La CPU verifica si esa dirección está en la caché L1.
  4. Si no (cache miss), busca en L2, L3 y finalmente en RAM.
  5. Si los datos estuvieran en un archivo en disco, el SO usaría su caché de bloques para minimizar accesos al dispositivo físico.

Cada salto a un nivel inferior de la jerarquía multiplica el tiempo de acceso. Por esto, los algoritmos que acceden a posiciones contiguas de memoria (localidad espacial) son favorecidos por el hardware.

12. Modelo de Programa: Búsqueda como Función

Aplicando el modelo de programa de las semanas anteriores, un programa completo que utiliza búsqueda tiene la siguiente estructura:

Modelo de programa con función de búsqueda SECCIÓN DECLARATIVA Descripción, precondiciones, postcondiciones, análisis de casos de prueba DEFINICIONES DE FUNCIONES def busqueda_lineal(datos, clave): ...   def busqueda_binaria(datos, clave): ... Cada función con su propia docstring (contrato) SECCIÓN ALGORÍTMICA PRÓLOGO Lectura de datos y clave RESOLUCIÓN pos = busqueda_binaria(datos, clave) EPÍLOGO Mostrar resultado o ausencia La función de búsqueda es reutilizable: se define una vez, se invoca las veces que se necesite. El programa principal no sabe cómo funciona internamente la búsqueda (abstracción). Se puede reemplazar busqueda_lineal por busqueda_binaria sin cambiar el programa principal.

Beneficio de la modularización

Nótese que el programa principal invoca busqueda_binaria(datos, clave) sin preocuparse por los detalles internos del algoritmo. Si más adelante descubrimos un algoritmo aún mejor, basta con reemplazar la función —el resto del programa no cambia—. Esto es la abstracción funcional en acción (Semana 4).

13. Visión de Conjunto

Esta semana hemos formalizado los algoritmos de búsqueda y la herramienta teórica para medir su eficiencia. La búsqueda lineal —simple y sin precondiciones— es la opción universal, pero su crecimiento lineal O(n) la hace inviable para grandes volúmenes de datos con búsquedas frecuentes. La búsqueda binaria —dramáticamente más eficiente con O(log n)— exige el precio de mantener los datos ordenados. La búsqueda por interpolación lleva la eficiencia aún más lejos —O(log log n) cuando la distribución es favorable—, pero a cambio requiere presunciones sobre los datos que, si no se cumplen, la degradan hasta O(n). La elección entre estas opciones es un ejemplo paradigmático de decisión de diseño algorítmico: no hay una respuesta universalmente correcta, sino un análisis de compromisos que depende del problema concreto.

Las heurísticas de optimización de la búsqueda lineal (Move-to-Front, Transposición) muestran que incluso un algoritmo simple puede mejorarse cuando se conocen patrones de uso. El anticipo de la versión recursiva de la búsqueda binaria conecta con la pila de ejecución estudiada en la Semana 4 y prepara el terreno para el estudio sistemático de la recursividad. La idea de adaptar el algoritmo al contexto reaparecerá a lo largo de la carrera.

🗺️ Conexiones del tema

⬅️
Semanas 1–4: Los patrones de procesamiento de secuencias (Semana 3), especialmente la búsqueda con condición de corte, son la base de la búsqueda lineal. Las funciones, la abstracción y la pila de ejecución (Semana 4) nos permiten encapsular cada algoritmo de búsqueda con un contrato claro y entender cómo opera la versión recursiva.
📌
Semana 5 (este tema): Búsqueda lineal y sus optimizaciones (Move-to-Front, Transposición), búsqueda binaria (iterativa y anticipo recursivo), búsqueda por interpolación, notación O grande, correctitud por invariante de bucle. Jerarquía de memoria (registros, caché, RAM, disco) y sistema de archivos (operaciones, descriptores, caché de buffer) como contexto holístico.
➡️
Semana 6: Algoritmos de ordenamiento (selección, inserción, burbuja, merge sort) y archivos de texto. El ordenamiento es el complemento natural de la búsqueda binaria: ordenar los datos es el costo que se paga para poder buscar eficientemente. Se ampliará la notación O grande con las complejidades O(n²) y O(n log n).
🔮
Semanas posteriores: La búsqueda binaria recursiva anticipada aquí se enmarcará en el estudio sistemático de la recursividad (Semana 8): caso base, caso recursivo, árbol de llamadas, correctitud. Las tablas hash (Semana 9) ofrecerán búsqueda en O(1) promedio, superando incluso a la binaria, a cambio de más memoria. El proyecto final (Semanas 13–16) exigirá elegir y justificar la estrategia de búsqueda según el volumen y la dinámica de los datos.

🔗 Conexión holística: búsqueda, hardware y sistema operativo

La eficiencia de un algoritmo de búsqueda no se mide solo por la cantidad de comparaciones (modelo teórico), sino también por cómo interactúa con la jerarquía de memoria. La búsqueda lineal recorre posiciones contiguas, aprovechando la localidad espacial y la caché. La búsqueda binaria salta posiciones distantes, generando más cache misses. En el nivel del sistema operativo, cuando los datos residen en disco, cada acceso implica una operación de E/S que es órdenes de magnitud más lenta que un acceso a RAM —y aquí la búsqueda binaria, con sus O(log n) accesos, supera ampliamente a la lineal con O(n) accesos a disco.

El principio de localidad de referencia —que aprovechan la caché, el sistema operativo y las heurísticas de búsqueda— es un ejemplo de cómo una misma idea se manifiesta en todos los niveles de abstracción de un sistema de computación.


Algoritmos de Búsqueda y Complejidad Algorítmica — Semana 5
Desarrollo de Contenidos — Algoritmia y Programación — Carrera de Informática