Algoritmos I - análisis asintótico

# Introducción

Esta es la primera parte de una serie de posts en los que hablaré sobre algoritmos y cómo analizarlos. Para poder analizar algoritmos necesitamos desbloquear un par de cosas:

# Por qué medir la eficiencia de un algoritmo

Venga, te presento los siguientes algoritmos de ordenación, ¿cuál dirías que es más eficiente?

quizás sabes la respuesta pero, ¿sabrías justificar por qué?

Insertion sort

void insertionsort(int[] arr) {
    for (int j = 1; j < arr.length; j++) {
        int i = j - 1;
        while (i >= 0 && arr[i] > arr[i+1]) { 
            swap(arr, i, i+1);
            i--;
        }
    }
}

Merge sort

void mergesort(int[] arr, int from, int to) {
    if (arr.length == 1) return;
    
    int mid = (from + to) / 2;
    mergesort(arr, from, mid);
    mergesort(arr, mid + 1, to);

    merge(arr, from, mid, to);
}

Así a simple vista tampoco parece que haya mucha diferencia. El número de líneas de código es similar, no parece que en ninguno haya cálculos muy complejos… Entonces, ¿cómo lo hacemos? Quizá se te pase por la cabeza decir: “bueno pues voy haciendo pruebas manualmente con conjuntos de datos de diferente tamaño, más pequeños y más grandes, y a ver cuál termina primero”. Aunque poco práctico, podría ser una primera aproximación, pero tiene un problema considerable; estarías probado cómo se comporta el algoritmo en tu hardware. Lo cual no es extrapolable a cómo se comportaría el mismo algoritmo en una máquina distinta.

Para solucionar esto se estableció un marco teórico, matemático, a través del cual puedes abstraerte del hardware y medir de forma analítica el rendimiento de tu algoritmo. Este marco matemático se llama análisis asintótico y básicamente se encarga de categorizar el tiempo de ejecución de un algoritmo dado cuando el conjunto de datos con el que trabaja crece sin límite.

Cuando decimos asintótico nos referimos al comportamiento de una función cuando los datos de entrada tienden a infinito.

# La “big picture”

Fuente: https://www.bigocheatsheet.com

O(log n), O(1) O(n) O(n log n) O(n^2) O(2^n) O(n!) Operations Elements

A poco que busques información sobre notación asintótica te cruzarás con esta imagen o con una similar. Básicamente representa las categorizaciones que comúnmente usamos para clasificar algoritmos (existen muchas más categorías), donde los algoritmos con mejor rendimiento se encontrarían paralelos (más bien apoyados) al eje xx e iría empeorando el rendimiento a medida que nos alejamos.

Para empezar, vamos a desglosar un poco esas categorías en orden decreciente al rendimiento, es decir, los más eficientes primero, categorizándolos usando la notación O(x)\Omicron(x).

Por ejemplo, declarar una variable tiene un tiempo de ejecución constante. De esta forma nos abstraemos del hardware, ya que declarar una variable en un ordenador de los 90 o en un servidor de alta capacidad muy moderno tiene el mismo coste (asintóticamente hablando).

Por ejemplo, supongamos que diseñamos un algoritmo que es capaz de ordenar un conjunto de elementos en tiempo logarítmico. Si el conjunto de datos tiene, por ejemplo, 10 elementos, mi algoritmo tardaría 1 milisegundo en resolver el problema. Para 100 elementos, tardaría 2 milisegundo, para 1000 tardaría 3 milisegundo, y así sucesivamente. Encontrar un elemento dado en una lista de elementos previamente ordenada tiene complejidad logarítmica.

Actualmente no existe ningún algoritmo de ordenación capaz de ordenar con esta complejidad asintótica.

Todos los algoritmos de ordenación basados en comparaciones binarias^* (la mayoría, vamos) tienen una complejidad asíntótica de Ω(nlog(n)) \Omega(n \cdot log(n)).

^* Comparaciones binarias se refiere a cualquier comparación lógica en la que intervengan dos elementos: >,<,,,=...>, <, \leq, \ge, = ...

# Notación O\Omicron

Primero veámos la idea intuitiva de qué se quiere representar con esta notación y después vemos la definición formal.

La notación O(n)\Omicron(n) (conocida como big-O y usada como O grande de nn o O de nn) representa una cota superior asintótica para una función a partir de un valor dado. Es decir, para valores suficientemente grandes de nn, la función f(n)f(n) crece como mucho igual que g(n)g(n) dentro de un factor constante. Este hecho se representa como f(n)=O(g(n))f(n) = \Omicron(g(n)).

Cuando decimos que un algoritmo es del orden de crecimiento de O(g(n))\Omicron(g(n)), indicamos que la función f(n)f(n) crecerá como mucho igual que g(n)g(n), de ahí lo de “cota superior”. Puede ser mejor dadas ciertas circunstancias, pero con esta notación indicamos el tope por arriba. Os dejo un pequeño esbozo de cómo se representaría una función f(n)f(n) dentro de O(g(n))\Omicron(g(n)):

c*g(n)f(n)kf(n) = O(g(n))

Normalmente, cuando hacemos este tipo de análisis, la complejidad asíntótica se da para el peor de los casos, es decir, el rendimiento que tendría nuestro algoritmo si todas las condiciones que lo harían más eficiente no son favorables.

De forma intuitiva se ve a que no aporta mucha información decir cómo de eficiente es un algoritmo cuando las condiciones le son totalmente favorables. Por ejemplo, decir que nuestro algoritmo tiene complejidad constante O(1)\Omicron(1) a la hora de ordenar datos porque dichos datos ya está previamente ordenados no aporta ningún valor puesto que no podemos sacar información a partir de eso.

Una vez vista la idea general, pasemos a la definición formal y la repasamos:

O(g(n))={f(n):c>0,k0(f(n)cg(n)) nk}\Omicron(g(n)) = \set{f(n) : \exists c > 0, \exists k \geq 0 ( f(n) \leq c \cdot g(n) ) \space \forall n \geq k}

La expresión anterior dice que O(g(n))\Omicron(g(n)) es el conjunto de funciones f(n)f(n) para las cuales existen contantes cc y kk tal que f(n)f(n) es menor o igual a una constante cc multiplicada por ese g(n)g(n) para todo nn mayor que un kk dado. Normalmente “abusamos” de la notación y, cuando decimos que f(n)=O(g(n))f(n) = \Omicron(g(n)), en realidad estamos diciendo que nuestro f(n)f(n) pertecene a esa familia de funciones g(n)g(n) porque existen constanten cc y un kk que satisfacen dicha desigualdad.

Decir que f(n)=O(g(n))f(n) = \Omicron(g(n)) es un uso poco ortodoxo matemáticamente hablando, ya que si O(g(n))\Omicron(g(n)) representa un conjunto de funciones lo normal sería decir que f(n)O(g(n))f(n) \in \Omicron(g(n)). Aún así hacer este uso de la notación tiene sus ventajas, ya que así podemos usar las notaciones asintóticas dentro de ecuaciones “tradicionales”, por ejemplo, diciendo T(n)=T(n2)+O(n)T(n) = T(\dfrac{n}{2}) + \Omicron(n)

Pongamos un par de ejemplos prácticos para que se vea claramente: ¿ x2+2x+1=O(x2)x^2 + 2x + 1 = \Omicron(x^2) ?

Según la definición, debería haber una constante cc y un valor mínimo de kk tal que, multiplicar esa cc por x2x^2, (nuestra g(x)g(x)) hace que x2+2x+1cx2x^2 + 2x + 1 \leq c \cdot x^2 sea cierto para todo x>kx > k. Un poco de álgebra y desarrollamos la desigualdad:

x2+2x+1cx2x2+2x2+x2=4x2\begin{align*} x^2 + 2x + 1 \leq c \cdot x^2 \\ \leq x^2 + 2x^2 + x^2 \\ = 4x^2 \end{align*}

Pues ahí tenemos lo que buscábamos. Hemos encontrado valores para c=4c=4 y k=1k=1 en este caso. Dado que esta notación representa un conjunto de funciones hay otros muchos valores para cc y kk que cumplen la desigualdad, pero lo importante es que hay valores que cumplen para todo xx. Simplemente encontrando valores que satisfagan cc y kk (a veces llamados testigos) es suficiente.

De la misma forma podríamos demostrar lo contrario, que una función no pertenece a un conjunto de funciones. Por ejemplo, ¿ será cierto que n2=O(n)n^2 = \Omicron(n) ?

n2cn=nnn=n1\begin{align*} n^2 \leq c \cdot n \\ = n \cdot n \leq n \\ = n \leq 1 \end{align*}

Dado que nn crece sin límite, no se cumple que para todo nn esa condición se cumpla, ya que solo se cumple para n=1n=1. Por tanto n2O(n)n^2 \neq \Omicron(n).

# Notación Ω\Omega

Todo lo que he contado para O(g(n))\Omicron(g(n)) aplica para esta notación.

La notación Ω(n)\Omega(n) (omega de n o big-Omega) representa a una cota inferior asintótica. Es decir, para valores suficientemente grandes de nn, la función f(n)f(n) crece, al menos, como g(n)g(n) dentro de un factor constante. Este hecho se representa como f(n)=Ω(g(n))f(n) = \Omega(g(n)).

c*g(n)f(n)kf(n) = Ω(g(n))

Dejo por aquí la definición formal:

Ω(g(n))={f(n):c>0,k0(f(n)cg(n)) nk}\Omega(g(n)) = \set{f(n) : \exists c > 0, \exists k \geq 0 ( f(n) \geq c \cdot g(n) ) \space \forall n \geq k}

Para practicar, vamos a usar el mismo ejemplo de arriba: ¿ será x2+2x+1=Ω(x2)x^2 + 2x + 1 = \Omega(x^2) ?

Como antes, debemos encontrar valores para cc y kk que cumplan, así que usemos la definición y veamos si algebráicamente podemos adaptar el resultado:

x2+2x+1cx2\begin{align*} x^2 + 2x + 1 \geq c \cdot x^2 \\ \end{align*}

Si tomamos c=1c=1 y restamos x2x^2 a ambos miembros:

2x+10 2x + 1 \geq 0 \medspace \medspace \medspace

Resolvemos para xx

x12 x \geq -\dfrac{1}{2}

Y ya lo tendríamos. Para x12x \geq -\dfrac{1}{2}, eligiendo valores c=1c=1 y k1k \geq 1, tendríamos que la desigualdad se cumple para todo xkx \geq k. Concluimos que x2+2x+1=Ω(x2)x^2 + 2x + 1 = \Omega(x^2).

# Notación Θ\Theta

Por último presentamos la notación Θ\Theta, que es una mezcla de las dos anteriores:

Θ(n)\Theta(n) (theta de n o big-Theta) es una notación que representa una cota ajustada, tanto “por arriba” como “por abajo”. Es decir, si decimos que una función f(n)f(n) = Θ(n2)\Theta(n^2) implica que la función f(n)f(n) se comporta igual que g(n)g(n) (asintóticamente hablando) a medida que el número de datos de entrada aumenta.

Si has visto o recuerdas algo de cálculo, quizá las dos funciones anteriores te recuerden a los límites de una función cuando se aproximan por izquierda y por derecha (salvando las distancias). Pues esta notación sería como el teorema del sándwich.

c2g(n)f(n)kf(n) = Θ(g(n))c1g(n)

De hecho, una vez entendidas las notaciones anteriores, la definición formal de esta no tiene mucha diferencia:

Θ(g(n))={f(n):c1,c2>0,k0(c1g(n)f(n)c2g(n)) nk}\Theta(g(n)) = \set{f(n) : \exists c_1,c_2 > 0, \exists k \geq 0 (c_1 \cdot g(n) \leq f(n) \leq c_2 \cdot g(n) ) \space \forall n \geq k}

Teniendo en cuenta las definiciones anteriores y esta, se puede derivar el siguiente teorema:

f(n)=Θ(g(n))    f(n)=O(g(n))f(n)=Ω(g(n)) f(n) = \Theta(g(n)) \iff f(n) = \Omicron(g(n)) \land f(n) = \Omega(g(n))

Con esto concluimos el apartado de notaciones asintóticas. Existen dos notaciones más que no voy a cubrir aquí porque no suelen aparecer tanto, aunque las dejaré como referencia: little-o y little-omega.

# Pero y todo esto, ¿cómo se aplica?

Como comentaba en la introducción, el análisis asintótico es solo “una pata” de todas las que sustenta el análisis de algoritmos. Para cerrar el post voy a dejar el análisis de uno de los algoritmos que mencioné arriba, insertion sort, aunque para ello utilizaré técnicas que no están descritas en esta entrada pero que explicaré en un futuro.

# Análisis de insertion sort

void insertionsort(int[] arr) {
    for (int j = 1; j < arr.length; j++) {
        int i = j - 1;
        while (i >= 0 && arr[i] > arr[i+1]) {
            swap(arr, i, i+1);               
            i--;                             
        }                                    
    }
}

Insertion-sort es un algoritmo de ordenación muy fácil de entender. Imaginemos una baraja de cartas, sin ordenar, puesta boca abajo en una mesa. Vamos levantando cartas con la mano derecha y colocándolas en la izquierda, asegurando que siempre que añadamos una carta a la mano izquierda la coloquemos ordenada en la posición que corresponda (orden ascendente, por ejemplo). De esta forma todo lo que hay en el montón boca abajo está desordenado y lo que tenemos en la mano izquierda ordenado.

# Bucle while

void insertionsort(int[] arr) {
    for (int j = 1; j < arr.length; j++) {
        int i = j - 1;
        while (i >= 0 && arr[i] > arr[i+1]) {
            swap(arr, i, i+1);
            i--;
        }
    }
}

Primero analicemos el bucle while. Si hacemos una tabla de iteración tenemos que:

Iteracióni
1j1j-1
2j2j-2
3j3j-3
kjkj-k

Donde kk es la k-ésima iteración (un número de iteración random, la que sea, no importa). La condición de parada del bucle es i0i \geq 0, así que resolviendo para kk tenemos que

jk=0    k=j j - k = 0 \implies k = j

Por tanto, cuando la iteración kk es la última tenemos que el bucle se ha ejecutado jj veces. Asumiendo Θ(1)\Theta(1) para las líneas 4 y 5 tenemos que el coste del bucle while es de Θ(j)\Theta(j).

# Bucle for

void insertionsort(int[] arr) {
    for (int j = 1; j < arr.length; j++) {
        int i = j - 1;
        while (i >= 0 && arr[i] > arr[i+1]) {
            swap(arr, i, i+1);               
            i--;                             
        }                                    
    }
}

Ahora centrémonos en el bucle for desde las líneas 2 a 5. Ya sabemos que el bucle while tiene una complejidad de Θ(j)\Theta(j), lo obviamos y nos centramos en el resto de líneas de código que, de hecho, solo hay una. Esta línea es la asignación de una variable, y sabemos que eso tiene tiempo constante, por tanto la línea 3 tiene complejidad Θ(1)\Theta(1).

Pues ya solo nos queda analizar conjuntamente el bucle for y el while. Para ello usamos una sumatoria desde j=1j=1 hasta nn (que es el rango del bucle for):

j=1nΘ(j)=j=1ncj=cj=1nj=n(n+1)2=Θ(n2) \sum\limits_{j=1}^{n} \Theta(j) = \sum\limits_{j=1}^{n} c \cdot j = c \cdot \sum\limits_{j=1}^{n} j = \dfrac{n(n+1)}{2} = \Theta(n^2)

Lo que nos queda al manipular la sumatoria es una progresión aritmética (la suma de los primeros nn términos o suma de Gauss), que tiene forma cerrada conocida: n(n+1)2\dfrac{n(n+1)}{2}.

Concluir entonces que el algoritmo insertion sort tiene complejidad cuadrática o Θ(n2)\Theta(n^2) y, como hemos visto en esta entrada, es un algoritmo muy poco eficiente.

Cualquier duda, comentario, mejora o error en el footer está mi correo personal. ¡Hasta la próxima!