Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Análisis de Complejidad Algorítmica

Fundamentos matemáticos del análisis asintótico

Universidad Nacional de Río Negro

Prerrequisitos: lazos, funciones, arreglos y noción de TAD. Para medir ejemplos en Linux/WSL2 se usa clock_gettime de <time.h>.

Objetivo: separar una medición concreta de la tasa de crecimiento y comparar alternativas mediante notación asintótica.

Comprobación de salida: duplicá el tamaño de entrada, registrá dos mediciones y explicá por qué no constituyen por sí solas una prueba de Big-O.

Medición mínima reproducible

La complejidad predice tendencias; una medición controla qué ocurre en una máquina concreta. Este ejemplo mide una suma lineal y debe compilarse con gcc -Wall -Wextra -std=c11 -pedantic medicion.c -o medicion:

#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <time.h>

int main(void)
{
    const long limite = 1000000L;
    volatile long suma = 0;
    struct timespec inicio, fin;
    clock_gettime(CLOCK_MONOTONIC, &inicio);
    for (long i = 0; i < limite; i++) suma += i;
    clock_gettime(CLOCK_MONOTONIC, &fin);
    long ns = (fin.tv_sec - inicio.tv_sec) * 1000000000L +
              (fin.tv_nsec - inicio.tv_nsec);
    printf("suma=%ld, tiempo=%ld ns\\n", suma, ns);
    return 0;
}

Duplicá limite varias veces y compará la tendencia; una única medición no demuestra Big-O, pero conecta el modelo con evidencia observable.

Desarrollo

Introducción: El Estudio de la Eficiencia

El análisis de algoritmos es una disciplina fundamental en la ciencia de la computación que se enfoca en cuantificar los recursos que un algoritmo consume. Su propósito es predecir, de manera formal y rigurosa, cómo se comportará un algoritmo en términos de tiempo de ejecución y uso de memoria a medida que el tamaño de la entrada de datos crece.

El objetivo principal no es obtener tiempos exactos en segundos —una métrica volátil que depende del hardware, el compilador y el sistema operativo— sino establecer una base teórica para:

  1. Comparar algoritmos: Determinar objetivamente cuál de dos algoritmos es más eficiente para resolver un mismo problema.

  2. Predecir la escalabilidad: Entender si un algoritmo seguirá siendo viable cuando el volumen de datos aumente de miles a millones o miles de millones de registros.

  3. Optimizar el código: Identificar los cuellos de botella y las partes críticas de un programa que más impactan en su rendimiento.

Para lograr esto, la herramienta central es el análisis asintótico.

Análisis Asintótico: Enfocándose en lo que Importa

El análisis asintótico es una metodología matemática que describe el comportamiento de una función en su límite, es decir, cuando el tamaño de la entrada (nn) se vuelve arbitrariamente grande (tiende al infinito). Este enfoque nos permite abstraernos de los detalles de la implementación y del hardware, y concentrarnos en la tasa de crecimiento intrínseca del algoritmo.

Al analizar una función de costo como T(n)=3n2+100n+500T(n) = 3n^2 + 100n + 500, observamos que para valores grandes de nn, el término 3n23n^2 domina a los demás. El análisis asintótico nos permite simplificar esta expresión a su orden de crecimiento, que es n2n^2, ignorando constantes multiplicativas (3) y términos de menor orden (100n+500100n + 500).

Las Notaciones Asintóticas: O, Ω, y Θ

Para formalizar este análisis, utilizamos un conjunto de notaciones que describen los límites del crecimiento de la función de costo de un algoritmo.

1. Notación Big O (O) - Cota Superior (Peor Caso)

La notación Big O es la más utilizada en la práctica, ya que describe una cota superior asintótica. Nos ofrece una garantía sobre el rendimiento del algoritmo: nunca será peor que esta cota.

Representación gráfica de la cota superior asintótica f(n) \in O(g(n)). A
partir de n_0, la función c \cdot g(n) es siempre mayor o igual a f(n).

Figure 1:Representación gráfica de la cota superior asintótica f(n)∈O(g(n))f(n) \in O(g(n)). A partir de n0n_0, la función c⋅g(n)c \cdot g(n) es siempre mayor o igual a f(n)f(n).

2. Notación Omega (Ω) - Cota Inferior (Mejor Caso)

La notación Omega describe una cota inferior asintótica. Nos garantiza que el rendimiento del algoritmo nunca será mejor que esta cota.

3. Notación Theta (Θ) - Cota Ajustada (Caso Exacto)

La notación Theta proporciona la descripción más precisa del comportamiento de un algoritmo, acotándolo tanto por arriba como por abajo.

Representación gráfica de la cota ajustada asintótica f(n) \in \Theta(g(n)).
La función f(n) queda atrapada entre las cotas c_1 \cdot g(n) y c_2 \cdot
g(n) para todo n \ge n_0.

Figure 2:Representación gráfica de la cota ajustada asintótica f(n)∈Θ(g(n))f(n) \in \Theta(g(n)). La función f(n)f(n) queda atrapada entre las cotas c1⋅g(n)c_1 \cdot g(n) y c2⋅g(n)c_2 \cdot g(n) para todo n≥n0n \ge n_0.

Notaciones Menos Comunes

Little-o (Límite Asintótico Estricto)

f(n)∈o(g(n))f(n) \in o(g(n)) si para toda constante c>0c > 0, existe n0n_0 tal que:

0≤f(n)<c⋅g(n)∀n≥n00 \leq f(n) < c \cdot g(n) \quad \forall n \geq n_0

Equivalentemente: lim⁡n→∞f(n)g(n)=0\lim_{n \to \infty} \frac{f(n)}{g(n)} = 0

Ejemplo: n∈o(n2)n \in o(n^2) pero n∉o(n)n \notin o(n)

Little-omega (Límite Inferior Estricto)

f(n)∈ω(g(n))f(n) \in \omega(g(n)) si para toda constante c>0c > 0, existe n0n_0 tal que:

0≤c⋅g(n)<f(n)∀n≥n00 \leq c \cdot g(n) < f(n) \quad \forall n \geq n_0

Propiedades Algebraicas

Las notaciones asintóticas tienen propiedades útiles:

  1. Transitividad: Si f∈O(g)f \in O(g) y g∈O(h)g \in O(h), entonces f∈O(h)f \in O(h)

  2. Reflexividad: f∈Θ(f)f \in \Theta(f)

  3. Simetría: Si f∈Θ(g)f \in \Theta(g), entonces g∈Θ(f)g \in \Theta(f)

  4. Suma: O(f)+O(g)=O(max⁡(f,g))O(f) + O(g) = O(\max(f, g))

  5. Producto: O(f)⋅O(g)=O(f⋅g)O(f) \cdot O(g) = O(f \cdot g)

Ejercicios de Notaciones Asintóticas

Jerarquía de Complejidades

Jerarquía de las clases de complejidad más comunes, ordenadas de más eficiente a
menos eficiente.

Figure 3:Jerarquía de las clases de complejidad más comunes, ordenadas de más eficiente a menos eficiente.

Clasificación Detallada

Constante: O(1)O(1)

Características:

Código ejemplo:

1
2
3
4
int obtener_primero(int arr[], int n)
{
    return arr[0]; // O(1): una operación, independiente de n
}
Logarítmica: O(log⁡n)O(\log n)

Características:

Ejemplos: búsqueda binaria, operaciones en árboles balanceados

Código ejemplo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Búsqueda binaria: O(log n)
// Precondición: el arreglo 'arr' debe estar ordenado de menor a mayor.
int busqueda_binaria(int arr[], int n, int clave)
{
    int izq = 0, der = n - 1;
    while (izq <= der)
    { // Se reduce a la mitad en cada lazo
        int medio = izq + (der - izq) / 2;
        if (arr[medio] == clave)
        {
            return medio;
        }
        if (arr[medio] < clave)
        {
            izq = medio + 1;
        }
        else
        {
            der = medio - 1;
        }
    }
    return -1;
}

Análisis:

En cada iteración del lazo, el espacio de búsqueda se reduce a la mitad. Si inicialmente hay nn elementos, después de kk lazos quedan n2k\frac{n}{2^k}. El algoritmo termina cuando n2k=1\frac{n}{2^k} = 1, es decir, k=log⁡2nk = \log_2 n.

Lineal: O(n)O(n)

Características:

Ejemplos: búsqueda secuencial, recorrer un arreglo, suma de elementos

Código ejemplo:

1
2
3
4
5
6
7
8
9
10
// Suma de elementos: O(n)
int sumar_elementos(int arr[], int n)
{
    int suma = 0;
    for (int i = 0; i < n; i++)
    {                   // n iteraciones
        suma += arr[i]; // O(1) por iteración
    }
    return suma;
}
Log-Lineal: O(nlog⁡n)O(n \log n)

Características:

Ejemplos: Merge Sort, Heap Sort, Quick Sort (promedio)

Código ejemplo (Merge Sort):

1
2
3
4
5
6
7
8
9
10
11
// Merge Sort: O(n log n)
void merge_sort(int arr[], int izq, int der)
{
    if (izq < der)
    {
        int medio = izq + (der - izq) / 2;
        merge_sort(arr, izq, medio);     // T(n/2)
        merge_sort(arr, medio + 1, der); // T(n/2)
        merge(arr, izq, medio, der);     // O(n)
    }
}

Análisis: La recurrencia es T(n)=2T(n/2)+O(n)T(n) = 2T(n/2) + O(n), que resuelve a T(n)=O(nlog⁡n)T(n) = O(n \log n) por el Teorema Maestro.

Cuadrática: O(n2)O(n^2)

Características:

Ejemplos: Bubble Sort, Selection Sort, Insertion Sort

Código ejemplo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Bubble Sort: O(n²)
void bubble_sort(int arr[], int n)
{
    for (int i = 0; i < n - 1; i++)
    { // n iteraciones
        for (int j = 0; j < n - i - 1; j++)
        { // n-i iteraciones
            if (arr[j] > arr[j + 1])
            {
                intercambiar(&arr[j], &arr[j + 1]); // O(1)
            }
        }
    }
}

Análisis: Total de comparaciones = ∑i=0n−1(n−i)=n(n−1)2∈Θ(n2)\sum_{i=0}^{n-1} (n-i) = \frac{n(n-1)}{2} \in \Theta(n^2)

Cúbica: O(n3)O(n^3)

Características:

Ejemplos: multiplicación ingenua de matrices, algunos algoritmos de grafos

Código ejemplo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Multiplicación de matrices: O(n³)
void multiplicar_matrices(int A[][N], int B[][N], int C[][N], int n)
{
    for (int i = 0; i < n; i++)
    { // n iteraciones
        for (int j = 0; j < n; j++)
        { // n iteraciones
            C[i][j] = 0;
            for (int k = 0; k < n; k++)
            { // n iteraciones
                C[i][j] += A[i][k] * B[k][j];
            }
        }
    }
}
Exponencial: O(2n)O(2^n)

Características:

Ejemplos: subconjuntos de un conjunto, Torre de Hanoi, algunos problemas NP-completos

Código ejemplo:

1
2
3
4
5
6
7
8
9
// Fibonacci recursivo ingenuo: O(2^n)
int fibonacci(int n)
{
    if (n <= 1)
    {
        return n;
    }
    return fibonacci(n - 1) + fibonacci(n - 2); // Dos llamadas recursivas
}

Análisis: La relación de recurrencia para el tiempo de ejecución es T(n)=T(n−1)+T(n−2)+O(1)T(n) = T(n-1) + T(n-2) + O(1). Para resolver la parte homogénea de esta ecuación de diferencias, T(n)−T(n−1)−T(n−2)=0T(n) - T(n-1) - T(n-2) = 0, proponemos una solución de la forma T(n)=rnT(n) = r^n. Al sustituir, obtenemos la ecuación característica:

r2−r−1=0r^2 - r - 1 = 0

cuyas raíces son r1=1+52=ϕ≈1.618r_1 = \frac{1+\sqrt{5}}{2} = \phi \approx 1.618 (la razón áurea) y r2=1−52≈−0.618r_2 = \frac{1-\sqrt{5}}{2} \approx -0.618. La solución general de la recurrencia es una combinación lineal de ambas potencias, dominada asintóticamente por la raíz de mayor magnitud, por lo que T(n)∈Θ(ϕn)T(n) \in \Theta(\phi^n).

Factorial: O(n!)O(n!)

Características:

Ejemplos: generar todas las permutaciones, problema del viajante (fuerza bruta)

Código ejemplo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Generar permutaciones: O(n!)
void generar_permutaciones(int arr[], int inicio, int fin)
{
    if (inicio == fin)
    {
        imprimir(arr, fin + 1);
        return;
    }
    for (int i = inicio; i <= fin; i++)
    {
        intercambiar(&arr[inicio], &arr[i]);
        generar_permutaciones(arr, inicio + 1, fin); // (n-1)! llamadas
        intercambiar(&arr[inicio], &arr[i]);
    }
}
Comparación del crecimiento de diferentes funciones de complejidad para valores
de n hasta 100.

Figure 4:Comparación del crecimiento de diferentes funciones de complejidad para valores de nn hasta 100.

Tabla Comparativa de Crecimiento

nnlog⁡n\log nnnnlog⁡nn \log nn2n^2n3n^32n2^nn!n!
10310331001K1K3.6M
20420864008K1M2.4×10182.4 \times 10^{18}
3053014790027K1B2.7×10322.7 \times 10^{32}
100710066410K1M1.3×10301.3 \times 10^{30}9.3×101579.3 \times 10^{157}
1000101K9.9K1M1B——

Ejercicios de Jerarquía de Complejidades

Técnicas de Análisis

Análisis de Lazos

Lazo Simple
1
2
3
4
for (int i = 0; i < n; i++)
{
    // Operación O(1)
}

Análisis: ∑i=0n−1O(1)=O(n)\sum_{i=0}^{n-1} O(1) = O(n)

Lazos Anidados
1
2
3
4
5
6
7
for (int i = 0; i < n; i++)
{ // n iteraciones
    for (int j = 0; j < n; j++)
    { // n iteraciones
        // Operación O(1)
    }
}

Análisis: ∑i=0n−1∑j=0n−1O(1)=n⋅n⋅O(1)=O(n2)\sum_{i=0}^{n-1} \sum_{j=0}^{n-1} O(1) = n \cdot n \cdot O(1) = O(n^2)

Lazos con Dependencia
1
2
3
4
5
6
7
for (int i = 0; i < n; i++)
{
    for (int j = i; j < n; j++)
    { // Depende de i
        // Operación O(1)
    }
}

Análisis:

∑i=0n−1∑j=in−1O(1)=∑i=0n−1(n−i)=∑k=1nk=n(n+1)2∈O(n2)\sum_{i=0}^{n-1} \sum_{j=i}^{n-1} O(1) = \sum_{i=0}^{n-1} (n-i) = \sum_{k=1}^{n} k = \frac{n(n+1)}{2} \in O(n^2)
Lazo Logarítmico
1
2
3
4
for (int i = 1; i < n; i *= 2)
{
    // Operación O(1)
}

Análisis: Si ii comienza en 1 y se duplica cada iteración, el lazo ejecuta kk veces donde 2k=n2^k = n, es decir, k=log⁡2nk = \log_2 n. Por tanto, O(log⁡n)O(\log n).

Análisis de Recursión

Método de Sustitución

El método de sustitución (o método de inducción matemática) se utiliza para resolver recurrencias mediante dos etapas:

  1. Expandir (desarrollar) la relación de recurrencia para adivinar el patrón de la solución.

  2. Probar la solución por inducción matemática para verificar su exactitud formal.

Ejemplo de desarrollo paso a paso: Consideremos la recurrencia T(n)=T(n−1)+cT(n) = T(n-1) + c, donde cc es el costo constante de la operación básica (O(1)O(1)), con el caso base T(1)=dT(1) = d (donde dd es otra constante).

  1. Expansión por sustitución sucesiva: Comenzamos sustituyendo recursivamente la fórmula:

    • Paso 1: T(n)=T(n−1)+cT(n) = T(n-1) + c

    • Paso 2: Sustituimos T(n−1)T(n-1) usando la misma definición: T(n−1)=T(n−2)+cT(n-1) = T(n-2) + c.

      T(n)=(T(n−2)+c)+c=T(n−2)+2cT(n) = (T(n-2) + c) + c = T(n-2) + 2c
    • Paso 3: Sustituimos T(n−2)=T(n−3)+cT(n-2) = T(n-3) + c:

      T(n)=(T(n−3)+c)+2c=T(n−3)+3cT(n) = (T(n-3) + c) + 2c = T(n-3) + 3c
  2. Generalización del patrón: Podemos generalizar la expresión para el paso kk:

    T(n)=T(n−k)+k⋅cT(n) = T(n-k) + k \cdot c
  3. Aplicación del caso base: Deseamos alcanzar el caso base T(1)T(1). Para ello, definimos n−k=1n - k = 1, lo que implica k=n−1k = n - 1. Sustituyendo kk en nuestra ecuación generalizada:

    T(n)=T(1)+(n−1)⋅cT(n) = T(1) + (n-1) \cdot c

    T(n)=d+c⋅n−cT(n) = d + c \cdot n - c

    T(n)=c⋅n+(d−c)T(n) = c \cdot n + (d - c)

Dado que cc y dd son constantes, la función de costo se reduce a una ecuación lineal:

T(n)∈Θ(n)T(n) \in \Theta(n)
Método del Árbol de Recursión

Antes de enunciar el Teorema Maestro, es fundamental visualizar cómo se distribuye el trabajo en un algoritmo recursivo de tipo divide y vencerás. El árbol de recursión es una herramienta gráfica donde:

Árbol de recursión ilustrando el Teorema Maestro y cómo se distribuye el trabajo
en cada nivel del árbol.

Figure 5:Árbol de recursión ilustrando el Teorema Maestro y cómo se distribuye el trabajo en cada nivel del árbol.

Ejemplo de análisis con árbol: Consideremos la recurrencia T(n)=2T(n/2)+nT(n) = 2T(n/2) + n (con caso base T(1)=O(1)T(1) = O(1)):

Nivel 0:                n              → costo: n
                       / \
Nivel 1:            n/2   n/2          → costo: n
                     / \   / \
Nivel 2:         n/4 n/4 n/4 n/4       → costo: n
                  ...
Teorema Maestro

El Teorema Maestro es una receta matemática que sistematiza este análisis para recurrencias de la forma general:

T(n)=aT(nb)+f(n)T(n) = aT\left(\frac{n}{b}\right) + f(n)

donde:

Al comparar el trabajo en las hojas del árbol (que es Θ(nlog⁡ba)\Theta(n^{\log_b a})) con el trabajo no recursivo en la raíz (f(n)f(n)), el Teorema Maestro determina cuál de los dos domina la complejidad asintótica:

Caso 1 (Dominan las hojas): Si f(n)∈O(nlog⁡ba−ϵ)f(n) \in O(n^{\log_b a - \epsilon}) para algún ϵ>0\epsilon > 0, entonces:

T(n)∈Θ(nlog⁡ba)T(n) \in \Theta(n^{\log_b a})

Caso 2 (Trabajo balanceado): Si f(n)∈Θ(nlog⁡balog⁡kn)f(n) \in \Theta(n^{\log_b a} \log^k n) para algún k≥0k \geq 0, entonces:

T(n)∈Θ(nlog⁡balog⁡k+1n)T(n) \in \Theta(n^{\log_b a} \log^{k+1} n)

Caso 3 (Domina la raíz): Si f(n)∈Ω(nlog⁡ba+ϵ)f(n) \in \Omega(n^{\log_b a + \epsilon}) para algún ϵ>0\epsilon > 0, y se cumple la condición de regularidad (af(n/b)≤cf(n)a f(n/b) \leq c f(n) para alguna constante c<1c < 1 y nn suficientemente grande), entonces:

T(n)∈Θ(f(n))T(n) \in \Theta(f(n))

La condición de regularidad garantiza que la tasa de trabajo no recursivo decrezca geométricamente a medida que se desciende en el árbol de recursión. Si no se satisface esta condición, no se puede aplicar el Caso 3 del Teorema Maestro.

Ejemplos de aplicación:

  1. Merge Sort: T(n)=2T(n/2)+O(n)T(n) = 2T(n/2) + O(n)

    • a=2,b=2,f(n)=na=2, b=2, f(n)=n

    • log⁡ba=log⁡22=1\log_b a = \log_2 2 = 1

    • f(n)=n∈Θ(n1)f(n) = n \in \Theta(n^1) → Caso 2 con k=0k=0

    • Solución: T(n)∈Θ(nlog⁡n)T(n) \in \Theta(n \log n)

  2. Búsqueda Binaria: T(n)=T(n/2)+O(1)T(n) = T(n/2) + O(1)

    • a=1,b=2,f(n)=1a=1, b=2, f(n)=1

    • log⁡ba=0\log_b a = 0

    • f(n)=1∈Θ(n0)f(n) = 1 \in \Theta(n^0) → Caso 2 con k=0k=0

    • Solución: T(n)∈Θ(log⁡n)T(n) \in \Theta(\log n)

  3. Multiplicación de Karatsuba: T(n)=3T(n/2)+O(n)T(n) = 3T(n/2) + O(n)

    • a=3,b=2,f(n)=na=3, b=2, f(n)=n

    • log⁡ba=log⁡23≈1.585\log_b a = \log_2 3 \approx 1.585

    • f(n)=n∈O(n1.585−ϵ)f(n) = n \in O(n^{1.585-\epsilon}) → Caso 1

    • Solución: T(n)∈Θ(nlog⁡23)≈Θ(n1.585)T(n) \in \Theta(n^{\log_2 3}) \approx \Theta(n^{1.585})

Análisis Amortizado

El análisis amortizado considera el costo promedio de una secuencia de operaciones, permitiendo que algunas operaciones sean costosas si la mayoría son baratas.

Método del Agregado

Ejemplo: Arreglo dinámico redimensionable en C.

Supongamos que implementamos un arreglo dinámico en C mediante una estructura que almacena un puntero, el tamaño actual y la capacidad máxima de almacenamiento. Cuando el arreglo alcanza su capacidad límite, duplicamos su tamaño utilizando realloc:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
typedef struct
{
    int *datos;
    size_t tamaño;
    size_t capacidad;
} arreglo_dinamico_t;
bool insertar_arreglo(arreglo_dinamico_t *arr, int valor)
{
    if (arr->tamaño >= arr->capacidad)
    {
        size_t nueva_capacidad = arr->capacidad * 2;
        int *nuevo_espacio =
            realloc(arr->datos, nueva_capacidad * sizeof(int));
        if (nuevo_espacio == NULL)
        {
            return false;
        }
        arr->datos = nuevo_espacio;
        arr->capacidad = nueva_capacidad;
    }
    arr->datos[arr->tamaño] = valor;
    arr->tamaño++;
    return true;
}

Analicemos el costo de una secuencia de nn inserciones consecutivas en el lazo de carga, comenzando con una capacidad inicial de 1:

Para nn inserciones (donde nn es una potencia de 2), el costo total acumulado es la suma de los accesos normales y los costos de copia por redimensionamiento:

Costo Total=n+∑j=0log⁡2n2j=n+(2log⁡2n+1−1)=n+2n−1<3n\text{Costo Total} = n + \sum_{j=0}^{\log_2 n} 2^j = n + (2^{\log_2 n + 1} - 1) = n + 2n - 1 < 3n

Costo amortizado: Al dividir el costo total por la cantidad de operaciones, obtenemos 3nn=O(1)\frac{3n}{n} = O(1) por cada inserción individual.

Método del Potencial

El método del potencial analiza la complejidad amortizada definiendo una función potencial Φ\Phi sobre los estados de la estructura de datos. Esta función asocia un número real no negativo Φ(Di)\Phi(D_i) a la estructura tras la operación ii.

El costo amortizado c^i\hat{c}_i de la ii-ésima operación se define como:

c^i=ci+Φ(Di)−Φ(Di−1)\hat{c}_i = c_i + \Phi(D_i) - \Phi(D_{i-1})

donde cic_i es el costo real de la operación y ΔΦi=Φ(Di)−Φ(Di−1)\Delta\Phi_i = \Phi(D_i) - \Phi(D_{i-1}) es el cambio en el potencial.

Análisis del Arreglo Dinámico Para un arreglo dinámico, definimos la función potencial después de la operación ii como:

Φi=2⋅ti−ci\Phi_i = 2 \cdot t_i - c_i

donde tit_i es el tamaño actual (número de elementos) y cic_i es la capacidad actual.

Precondición de validez: Como la capacidad es a lo sumo el doble del tamaño y al menos igual, tenemos que Φi≥0\Phi_i \ge 0. Inicialmente, con un arreglo vacío, t0=0t_0 = 0 y c0=0c_0 = 0, por lo que Φ0=0\Phi_0 = 0.

Analicemos los dos escenarios posibles para la ii-ésima inserción:

Escenario 1: Inserción sin Redimensionamiento

El arreglo tiene espacio libre (ti−1<ci−1t_{i-1} < c_{i-1}).

  1. El costo real es constante: ci=1c_i = 1 (copiar el elemento en el arreglo).

  2. El tamaño aumenta en uno (ti=ti−1+1t_i = t_{i-1} + 1), y la capacidad permanece constante (ci=ci−1c_i = c_{i-1}).

  3. El cambio en el potencial es:

    ΔΦi=Φi−Φi−1=(2⋅ti−ci)−(2⋅ti−1−ci−1)\Delta\Phi_i = \Phi_i - \Phi_{i-1} = (2 \cdot t_i - c_i) - (2 \cdot t_{i-1} - c_{i-1})

    ΔΦi=(2(ti−1+1)−ci−1)−(2⋅ti−1−ci−1)=2\Delta\Phi_i = (2(t_{i-1} + 1) - c_{i-1}) - (2 \cdot t_{i-1} - c_{i-1}) = 2
  4. El costo amortizado calculado es:

    c^i=ci+ΔΦi=1+2=3\hat{c}_i = c_i + \Delta\Phi_i = 1 + 2 = 3

Escenario 2: Inserción con Redimensionamiento

El arreglo está lleno (ti−1=ci−1t_{i-1} = c_{i-1}). Para insertar, se debe duplicar la capacidad: ci=2⋅ci−1c_i = 2 \cdot c_{i-1}.

  1. El costo real de esta inserción implica alocar nueva memoria y copiar todos los elementos existentes más el nuevo: ci=ti−1+1c_i = t_{i-1} + 1.

  2. El tamaño aumenta en uno (ti=ti−1+1t_i = t_{i-1} + 1), y la capacidad se duplica (ci=2⋅ti−1c_i = 2 \cdot t_{i-1}).

  3. Calculamos la variación del potencial ΔΦi\Delta\Phi_i:

    Φi−1=2⋅ti−1−ci−1=2⋅ti−1−ti−1=ti−1\Phi_{i-1} = 2 \cdot t_{i-1} - c_{i-1} = 2 \cdot t_{i-1} - t_{i-1} = t_{i-1}

    Φi=2⋅ti−ci=2(ti−1+1)−2⋅ti−1=2\Phi_i = 2 \cdot t_i - c_i = 2(t_{i-1} + 1) - 2 \cdot t_{i-1} = 2

    ΔΦi=Φi−Φi−1=2−ti−1\Delta\Phi_i = \Phi_i - \Phi_{i-1} = 2 - t_{i-1}
  4. El costo amortizado calculado es:

    c^i=ci+ΔΦi=(ti−1+1)+(2−ti−1)=3\hat{c}_i = c_i + \Delta\Phi_i = (t_{i-1} + 1) + (2 - t_{i-1}) = 3

Conclusión En ambos escenarios (con o sin redimensionamiento), el costo amortizado de una inserción en el arreglo dinámico es exactamente 3, lo que demuestra formalmente que la operación de inserción tiene una complejidad de amortización constante:

c^i∈Θ(1)\hat{c}_i \in \Theta(1)

Ejercicios de Técnicas de Análisis

Complejidad Espacial

La complejidad espacial mide la cantidad de memoria adicional que un algoritmo requiere.

Clasificación

Recursión y Pila de Llamadas

Cada llamada recursiva ocupa espacio en la pila. La profundidad máxima de recursión determina la complejidad espacial.

Ejemplo: Fibonacci recursivo

1
2
3
4
5
6
7
8
int fibonacci(int n)
{
    if (n <= 1)
    {
        return n;
    }
    return fibonacci(n - 1) + fibonacci(n - 2);
}

Trade-off Tiempo-Espacio

A menudo es posible reducir tiempo usando más espacio (memoización) o viceversa.

Ejemplo: Fibonacci con memoización

1
2
3
4
5
6
7
8
9
10
11
12
13
int fibonacci_memo(int n, int memo[])
{
    if (n <= 1)
    {
        return n;
    }
    if (memo[n] != -1)
    {
        return memo[n];
    }
    memo[n] = fibonacci_memo(n - 1, memo) + fibonacci_memo(n - 2, memo);
    return memo[n];
}
Ilustración del trade-off entre tiempo y espacio en el problema de Fibonacci.

Figure 6:Ilustración del trade-off entre tiempo y espacio en el problema de Fibonacci.

Ejercicios de Complejidad Espacial

Límites Inferiores y Óptimalidad

Límites Inferiores Basados en Información

Un límite inferior establece que ningún algoritmo puede resolver un problema más rápido que cierta complejidad.

Teorema: Ordenamiento por Comparación

Enunciado: Cualquier algoritmo que ordene nn elementos mediante comparaciones requiere al menos Ω(nlog⁡n)\Omega(n \log n) comparaciones en el peor caso.

Demostración (árbol de decisión):

  1. Un algoritmo de ordenamiento por comparación puede modelarse como un árbol binario de decisión.

  2. Cada hoja representa una permutación posible de los nn elementos de entrada.

  3. Hay n!n! permutaciones posibles, por tanto, el árbol debe tener al menos n!n! hojas.

  4. Un árbol binario de altura hh tiene como máximo 2h2^h hojas.

  5. Para que el árbol pueda representar todas las salidas válidas, se requiere que 2h≥n!2^h \geq n!, lo que implica h≥log⁡2(n!)h \geq \log_2(n!).

  6. Demostramos la cota inferior de log⁡2(n!)\log_2(n!) expandiendo la sumatoria y acotándola inferiormente desde su término medio: $$ \log_2(n!) = \sum_{i=1}^n \log_2 i \geq \sum_{i=n/2}^n \log_2 i \geq \sum_{i=n/2}^n \log_2(n/2) = \frac{n}{2} \log_2(n/2) = \frac{n}{2} (\log_2 n

      1. \in \Omega(n \log n) $$

Conclusión: Cualquier algoritmo basado en comparaciones requiere al menos Ω(nlog⁡n)\Omega(n \log n) comparaciones en el peor caso. Algoritmos como Merge Sort y Heap Sort son, por lo tanto, óptimos.

Algoritmos Óptimos

Un algoritmo es asintóticamente óptimo si su complejidad coincide con el límite inferior teórico del problema.

Ejemplos:

Más Allá: Una Introducción a la Teoría de la Complejidad (P vs. NP)

Mientras que el análisis de algoritmos se enfoca en determinar la eficiencia de una solución específica, la teoría de la complejidad aborda una pregunta más fundamental: ¿cuál es la dificultad inherente de un problema? No se pregunta “¿cuán rápido es mi algoritmo para ordenar?”, sino “¿cuán rápido puede ser cualquier algoritmo que ordene?”.

Esta disciplina clasifica los problemas computacionales en clases de complejidad basadas en los recursos (tiempo y memoria) que se requieren para resolverlos en el peor de los casos, independientemente del algoritmo específico utilizado.

Conceptos de Complejidad: Intratabilidad y las Clases P y NP

En el análisis de algoritmos, no solo nos interesa determinar la complejidad asintótica exacta, sino también clasificar los problemas según si son resolubles de forma eficiente en la práctica. Esta distinción introduce la noción de intratabilidad.

Problemas Tratables vs. Intratables

Las Clases P y NP

Para formalizar esta clasificación, la teoría de la complejidad define conjuntos de problemas llamados clases de complejidad:

La Cuestión P≠NPP \neq NP y los Problemas NP-Completos

La relación entre estas clases plantea uno de los interrogantes abiertos más importantes de la ciencia de la computación: ¿Es P=NPP = NP? Es decir: si la solución a un problema se puede verificar eficientemente, ¿se puede también encontrar de forma eficiente?

El consenso científico generalizado es que P≠NPP \neq NP, lo que significa que verificar soluciones es fundamentalmente más sencillo que crearlas.

Dentro de la clase NP, existen problemas denominados NP-Completos. Estos problemas representan los elementos más difíciles de NP. Tienen la propiedad de que si se encontrara un algoritmo eficiente (polinomial) para resolver cualquiera de ellos, ese algoritmo podría adaptarse inmediatamente para resolver todos los problemas de la clase NP en tiempo polinomial, demostrando que P=NPP = NP.

Ejemplos clásicos:

Visualización de las Clases de Complejidad

Asumiendo que P ≠ NP, la relación entre estas clases se puede visualizar de la siguiente manera:

Frente a la intratabilidad de los problemas NP-Completos, en el desarrollo práctico de software se emplean algoritmos de aproximación, heurísticas o restricciones del dominio para hallar soluciones aceptables en tiempos razonables, sabiendo que una solución óptima general y rápida no es viable.

Ejemplos Detallados de Análisis

Ejemplo 1: Búsqueda del Máximo

1
2
3
4
5
6
7
8
9
10
11
12
int buscar_maximo(int arr[], int n)
{
    int max = arr[0]; // O(1)
    for (int i = 1; i < n; i++)
    { // n-1 iteraciones
        if (arr[i] > max)
        {                 // O(1)
            max = arr[i]; // O(1)
        }
    }
    return max; // O(1)
}

Análisis:

Ejemplo 2: Búsqueda de Duplicados

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Versión ingenua: O(n²)
bool tiene_duplicados_ingenuo(int arr[], int n)
{
    for (int i = 0; i < n; i++)
    { // n iteraciones
        for (int j = i + 1; j < n; j++)
        { // (n-i-1) iteraciones
            if (arr[i] == arr[j])
            {
                return true; // O(1)
            }
        }
    }
    return false;
}

Análisis:

T(n)=∑i=0n−1∑j=i+1n−1O(1)=∑i=0n−1(n−i−1)=n(n−1)2∈O(n2)T(n) = \sum_{i=0}^{n-1} \sum_{j=i+1}^{n-1} O(1) = \sum_{i=0}^{n-1} (n-i-1) = \frac{n(n-1)}{2} \in O(n^2)
1
2
3
4
5
6
7
8
9
10
11
12
13
// Versión optimizada: O(n log n) con ordenamiento previo
bool tiene_duplicados_ordenado(int arr[], int n)
{
    qsort(arr, n, sizeof(int), comparar); // O(n log n)
    for (int i = 0; i < n - 1; i++)
    { // O(n)
        if (arr[i] == arr[i + 1])
        {
            return true;
        }
    }
    return false;
}

Análisis: T(n)=O(nlog⁡n)+O(n)=O(nlog⁡n)T(n) = O(n \log n) + O(n) = O(n \log n)

Ejemplo 3: Torres de Hanoi

1
2
3
4
5
6
7
8
9
10
11
void hanoi(int n, char origen, char destino, char auxiliar)
{
    if (n == 1)
    {
        printf("Mover disco 1 de %c a %c\n", origen, destino);
        return;
    }
    hanoi(n - 1, origen, auxiliar, destino);
    printf("Mover disco %d de %c a %c\n", n, origen, destino);
    hanoi(n - 1, auxiliar, destino, origen);
}

Análisis mediante recurrencia: $$

T(n)=2T(n−1)+1T(1)=1\begin{align} T(n) &= 2T(n-1) + 1 \\ T(1) &= 1 \end{align}

$$

Solución por sustitución: $$

T(n)=2T(n−1)+1=2(2T(n−2)+1)+1=4T(n−2)+2+1=8T(n−3)+4+2+1=2kT(n−k)+(2k−1+2k−2+⋯+2+1)=2kT(n−k)+(2k−1)\begin{align} T(n) &= 2T(n-1) + 1 \\ &= 2(2T(n-2) + 1) + 1 = 4T(n-2) + 2 + 1 \\ &= 8T(n-3) + 4 + 2 + 1 \\ &= 2^k T(n-k) + (2^{k-1} + 2^{k-2} + \cdots + 2 + 1) \\ &= 2^k T(n-k) + (2^k - 1) \end{align}

$$

Cuando k=n−1k = n-1:

T(n)=2n−1T(1)+2n−1−1=2n−1+2n−1−1=2n−1∈Θ(2n)T(n) = 2^{n-1}T(1) + 2^{n-1} - 1 = 2^{n-1} + 2^{n-1} - 1 = 2^n - 1 \in \Theta(2^n)

Conclusión: Torres de Hanoi es inherentemente exponencial. No existe solución más eficiente.

Ejercicios de Autoevaluación

Glosario

Complejidad Algorítmica
Medida del crecimiento de recursos (tiempo/espacio) respecto al tamaño de entrada.
Notación Big-O
Notación matemática que describe el límite superior del crecimiento de una función.
Análisis Asintótico
Método para describir el comportamiento de algoritmos cuando la entrada tiende a infinito.

Síntesis y Resumen

Resumen

El análisis de complejidad es fundamental para:

  1. Predecir rendimiento: Saber si un algoritmo será viable para el tamaño de entrada esperado

  2. Comparar algoritmos: Elegir el más eficiente para cada situación

  3. Identificar cuellos de botella: Localizar partes del código que necesitan optimización

  4. Establecer límites teóricos: Determinar si un algoritmo es óptimo o puede mejorarse

Puntos Clave

Guía Práctica de Decisión

Para elegir un algoritmo:

  1. ¿Cuál es el tamaño típico de entrada?

    • Pequeño (n<100n < 100): Casi cualquier complejidad funciona

    • Mediano (n∼104n \sim 10^4): Evitar O(n3)O(n^3) o peor

    • Grande (n>106n > 10^6): Necesario O(n)O(n) o O(nlog⁡n)O(n \log n)

  2. ¿Importa más tiempo o espacio?

    • Tiempo crítico: Considera usar más memoria (memoización, tablas hash)

    • Espacio limitado: Acepta algoritmos más lentos si usan menos memoria

  3. ¿Es un problema conocido?

    • Usa algoritmos estándar óptimos cuando existan

    • Para problemas NP-completos, considera aproximaciones o heurísticas

El análisis de complejidad no reemplaza la medición empírica, pero proporciona garantías teóricas esenciales para el diseño de software robusto y escalable.

Referencias y Lecturas de Complejidad Algorítmica

Referencias y Lecturas de Complejidad Algorítmica

Textos Fundamentales

Recursos Complementarios

Recursos en Línea

References
  1. Cormen, T. H., Leiserson, C. E., Rivest, R. L., & Stein, C. (2009). Introduction to Algorithms (3rd ed.). MIT Press.
  2. Sedgewick, R., & Wayne, K. (2011). Algorithms (4th ed.). Addison-Wesley. https://algs4.cs.princeton.edu/
  3. Knuth, D. E. (1974). Structured Programming with go to Statements. ACM Computing Surveys, 6(4), 261–301. 10.1145/356635.356640
  4. Bentley, J. (1999). Programming Pearls (2nd ed.). Addison-Wesley.
  5. Bryant, R. E., & O’Hallaron, D. R. (2015). Computer Systems: A Programmer’s Perspective (3rd ed.). Pearson.