🗓 Publicado el 1 de agosto de 2019
¿Qué es la complejidad algorÃtmica?, ¿cuál es la diferencia entre la notación O, Theta y Omega?, ¿cuáles son los tiempos de ejecución más comunes?
La complejidad del tiempo de ejecución de un algoritmo, entre otras cosas, describe el desempeño del mismo. En concreto, nos puede indicar qué tanto mas poder de procesamiento o tiempo es requerido para ejecutar determinado algoritmo si se incrementan los datos de entrada.
La notación Big O (u O mayúscula) es el lenguaje y la métrica utilizada para describir la eficiencia de un algoritmo.
No existe una relación directa entre el mejor, peor y el caso esperado de un algoritmo y las notaciones O, Ω y Θ.
El mejor, peor y caso esperado describen el tiempo de ejecición O (ó Θ) para determinados casos o valores de entrada.
O, Ω y Θ describen los lÃmites superior, inferior y estrechos (ambos superior e inferior) para determinado tiempo de ejecución.
Es posible que un algoritmo O(N) se ejecute más rápido que uno O(1) para determinados valores de entrada. Por esta razón se ignoran las constantes al momento de describir el tiempo de ejecución. Asà pues, un algoritmo descrito como O(2N) es un algoritmo O(N).
Se suman los tiempos de ejecución: O(A + B)
for (int a: arr A) {
print(a);
}
for (int b: arr B) {
print(b);
}
Multiplicar los tiempos de ejecución: O(A * B)
for (int a: arr A) {
for (int b: arr B) {
print(a + ", " + b);
}
}
Tomaremos como ejemplo la búsqueda binaria. En la búsqueda binaria se busca un elemento x en un arreglo ordenado de N elementos. Primero se compara x al punto medio del arreglo.
x == medio, entonces retornamos.x < medio, buscamos en el lado izquierdo del arreglox > medio, buscamos en el lado derecho del arreglobuscar 9 dentro de {1, 5, 8, 9, 11, 13, 15, 19, 21}
comparar 9 con 11 -> menor
buscar 9 dentro de {1, 5, 8, 9, 11}
comparar 9 con 8 -> mayor
buscar 9 dentro de {9, 11}
comparar 9 con 9
retornar
El tiempo de ejecución total radica en encontrar cuántos pasos nos toma el que N se convierta en 1 (dividiendo N entre 2 cada paso).
N = 16
N = 8 /* divide entre 2 */
N = 4 /* divide entre 2 */
N = 2 /* divide entre 2 */
N = 1 /* divide entre 2 */
PodrÃamos analizarlo en sentido inverso (yendo de 1 a 16 en vez de 16 a 1). ¿Cuántas veces podemos multiplicar 1 por 2 hasta obtener N?
N = 1
N = 2 /* multiplica por 2 */
N = 4 /* multiplica por 2 */
N = 8 /* multiplica por 2 */
N = 16 /* multiplica por 2 */
¿Qué valor tiene k en la expresión 2k = N? Esto es exactamente lo que expresa la función logaritmo.
24 = 16 -> log2 16 = 4
log2N = k -> 2k = N
Cuando tengamos un problema en el que el número de elementos en el espacio del problema se reduce a la mitad cada vez, muy probablemente sea un tiempo de ejecución logarÃtmico O(log N).
Por esta misma razon la complejidad de encontrar un elemento en un árbol de búsqueda binaria es O(log N). Por cada comparación buscamos del lado izquierdo o derecho del arreglo. La mitad de los nodos están en cada lado, por lo que cortamos el espacio del problema a la mitad en cada iteración.
Tomando como ejemplo esta función:
int f(int n) {
if (n <= 1) {
return 1;
}
return f(n - 1) + f(n - 1)
}
Analizando el código para f(4), llamaremos a f(3) dos veces y cada una de esas llamadas a f(3) llamará a f(2) hasta llegar a f(1). ¿Cuántas llamadas a f se realizaron?
Si lo imaginamos como un árbol, éste tendrá una profundidad N. Cada nodo (o llamada a f) tendrá dos hijos. Por lo que cada nivel tendrá el doble de llamadas que el anterior. El número de nodos en cada nivel es:
| Nivel | Nodos | Tambièn expresado como... | O... |
|---|---|---|---|
| 0 | 1 | 20 | |
| 1 | 2 | 2 * nivel previo = 2 | 21 |
| 2 | 4 | 2 * nivel previo = 2 * 21 = 22 | 22 |
| 3 | 8 | 2 * nivel previo = 2 * 22 = 23 | 23 |
| 4 | 16 | 2 * nivel previo = 2 * 23 = 24 | 24 |
Por consiguiente, habrá 20 + 21 + 22 + 23 + ... + 2N (lo que es 2N+1 - 1) nodos.
Este patrón indica que cuando tenemos una función recursiva que realiza múltiples llamadas, el tiempo de ejecución frecuentemente (mas no siempre) se podrá representar como O(ramasprofundidad), donde ramas es el número de veces cada llamada recursiva se ramifica. En este caso nos da un valor de O(2N).
Para el problema de invertir una cadena, una posible solución iterativa es:
function invertir(cadena) {
let invertida = ''
for (let caracter of cadena) {
invertida = caracter + invertida
}
return invertida
}
En esta solución iteramos cada caracter de la cadena que queremos invertir una sola vez, es decir, si la cadena es abc iteramos dicha cadena 3 veces. Por cada caracter que le agreguemos a la cadena se generará una iteración mas en el bucle for. Esto significa que el tiempo de ejecución serÃa O(n) o lineal.
Para el problema de steps (o media pirámide) y su solución con dos bucles for (uno anidado):
const steps = n => {
let row = 0
let col = 0
for (row; row < n; row++) {
let result = ''
for (col; col < n; col++) {
if (col <= row) result += '#'
else result += ' '
}
console.log(result)
}
}
En este caso, para cada valor de entrada n tenemos dos for que iteran n veces; esto significa que cada vez que se incremente el valor de n se va a incrementar de manera cuadrática el número de instrucciones a ejecutar: si n = 2 habrá que realizar 4 instrucciones, si n = 3 habrá que realizar 9 instrucciones y asà sucesivamente, lo que nos indica que el tiempo de ejecución es O(n2).
En resumen:
| Trabajo | Complejidad |
|---|---|
| Iterar una sola colección de datos mediante un bucle for | Probablemente O(N) |
| Iterar media colección de datos | O(N) |
| Iterar dos colecciones de datos diferentes con bucles for separados | O(N + M) |
| Dos bucles for anidados iterando la misma colección | O(N2) |
| Ordenamiento | Probablemente O(N*log(N)) |
| Búsqueda sobre un arreglo ordenado | O(log(N)) |
A continuación se muestra una tabla con algunas de las complejidades algorÃtmicas más comunes y qué tan eficientes son:
