Menú de navegaciónMenú
Categorías

La mejor forma de Aprender Programación online y en español www.campusmvp.es

Rendimiento de algoritmos y notación Big-O

Big-O-AlgoritmosEn programación el rendimiento o la complejidad de un algoritmo se suele medir utilizando una notación denominada Big-O, y también conocida como Notación Asintótica o Notación Landau (en honor a uno de sus inventores, a  principios del siglo pasado, Edmund Landau).

Ya os hemos contado aquí la importancia que tiene aprender a crear ciertos algoritmos aunque no los vayas a usar en el día a día. Pero además, en cualquier documentación o en cualquier libro o página que describa un algoritmo nos vamos a encontrar con la notación Big-O, por lo que es muy importante conocerla. Por ejemplo, si consultamos la documentación de MSDN para la función RemoveAt de una SortedList nos dice qué es una operación O(n):

SortedList.RemoveAt

Nota: en general soy de los que opino que cualquier documentación técnica oficial es mucho mejor leerla en inglés, y éste es un buen ejemplo del porqué: en la versión en español de MSDN la notación Big-O aparece como "E/s(n)", que no tiene nada que ver, fruto de la traducción automática, que no va nada fina.

Y si consultamos el método Item para localizar o establecer un elemento en una colección de tipo ArrayList, nos dice que su complejidad es O(1).

¿Qué significa esto exactamente y por qué nos importa?

Vamos a verlo...

Entendiendo la notación Big-O

La notación Big-O nos proporciona una manera de saber cómo se va a comportar un algoritmo en función de los argumentos que le pasemos y la escala de los mismos.

Por ejemplo, imagínate una función que se utiliza para localizar un elemento dentro de una lista de elementos previamente guardados. Si la documentación de la misma nos dice que es una operación de tipo O(1), quiere decir que da igual cuántos elementos haya en la lista, la operación siempre tarda lo mismo. Para ser más exactos deberíamos decir que el esfuerzo de cómputo necesario es el mismo.

Por eso, por ejemplo, que en la documentación el indizador Item de un ArrayList que vimos antes, tenga complejidad O(1) quiere decir que da exactamente igual que la colección tenga uno o un millón de elementos: insertar o recuperar un elemento siempre tarda más o menos lo mismo.

Ahora supongamos que tenemos una función que dibuja en una gráfica los puntos que le pasemos en una matriz. Su documentación nos dice que su complejidad es O(n). Esto quiere decir, para entendernos, que si pintar 1 punto implica, por ejemplo, 10ms, pintar 2 implicaría 20ms, 3 puntos serían 30ms, etc... O sea, el tiempo necesario para ejecutar la función es función directa y lineal del número de elementos que le pasemos.

Es muy importante conocer el tipo de función/curva que se asocia con la ejecución de una función ya que nos permitirá saber de antemano si el rendimiento de nuestra aplicación se puede resentir en caso de que el tamaño de los datos a manejar aumente mucho. Así, entre dos algoritmos/funciones que hagan lo mismo, deberíamos elegir el que tenga una complejidad asintótica menor, para lo cual consultaremos la notación Big-O asociada al mismo.

Atendiendo a su complejidad, las notaciones Big-O más comunes para todo tipo de algoritmos y funciones son las que se muestran en esta lista:

  • O(1): constante. La operación no depende del tamaño de los datos. Es el caso ideal, pero a la vez probablemente el menos frecuente. No se ve en la gráfica de más abajo porque la logarítmica le pasa justo por encima y la tapa.
  • O(n): lineal. El tiempo de ejecución es directamente proporcional al tamaño de los datos. Crece en una línea recta.
  • O(log n): logarítmica. por regla general se asocia con algoritmos que "trocean" el problema para abordarlo, como por ejemplo una búsqueda binaria.
  • O(nlogn): en este caso se trata de funciones similares a las anteriores, pero que rompen el problema en varios trozos por cada elemento, volviendo a recomponer información tras la ejecución de cada "trozo". Por ejemplo, el algoritmo de búsqueda Quicksort.
  • O(n2): cuadrática. Es típico de algoritmos que necesitan realizar una iteración por todos los elementos en cada uno de los elementos a procesar. Por ejemplo el algoritmo de ordenación de burbuja. Si tuviese que hacer la iteración más de una vez serían de complejidad O(n3), O(n4), etc... pero se trata de casos muy raros y poco optimizados.
  • O(2n): exponencial. Se trata de funciones que duplican su complejidad con cada elemento añadido al procesamiento. Son algoritmos muy raros pues en condiciones normales no debería ser necesario hacer algo así. Un ejemplo sería, por ejemplo, el cálculo recursivo de la serie de Fibonacci, que es muy poco eficiente (se calcula llamándose a sí misma la función con los dos números anteriores: F(n)=F(n-1)+F(n-2)).
  • O(n!); explosión combinatoria. Un algoritmo que siga esta complejidad es un algoritmo totalmente fallido. Una explosión combinatoria se dispara de tal manera que cuando el conjunto crece un poco, lo normal es que se considere computacionalmente inviable. Solo se suele dar en algoritmos que tratan de resolver algo por la mera fuerza bruta. No deberías verlo nunca en un software "real".

Podemos verlo gráficamente a continuación (he usado la herramienta gratuita Desmos para graficarlo):

Las distintas gráficas de complejidad algorítmica para compararlas con más facilidad

En el eje horizontal tenemos el número de elementos, y en el vertical el tiempo.

Como podemos observar, a medida que aumenta la complejidad, el tiempo necesario para completar la tarea crece mucho, pudiendo llegar a aumentar enormemente en algunos casos en cuanto hay más datos a manejar. Creo que verlo gráficamente es la mejor manera de darse cuenta de su importancia.

Si te encuentras con una función con complejidad cuadrática (O(n2)) o exponencial (O(2n)) por regla general será señal de que el algoritmo necesita una revisión urgente, y mejor no utilizarlo.

Lo interesante de esta notación es que nos permite comparar varios algoritmos equivalentes sin preocuparnos de hacer pruebas de rendimiento que dependen del hardware utilizado. Es decir, ante resultados equivalentes podemos elegir el algoritmo de mayor rendimiento, que lo será siempre independientemente del hardware si el conjunto de datos es lo suficientemente grande (en conjuntos pequeños un hardware más rápido puede dar resultados más rápidos para un algoritmo menos eficiente, pero a medida que el conjunto crece ya no será así).

Por eso, a partir de ahora, cuando veas que una función documenta explícitamente su complejidad usando la notación Big-O, presta mucha atención y no te olvides de las implicaciones que puede tener en tu aplicación el hecho de utilizarla. Del mismo modo, cuando crees un algoritmo para resolver un problema en tu aplicación, es interesante anotar en la documentación del mismo (aunque sea en los comentarios de la cabecera) cuál es la complejidad algorítmica del mismo en notación Big-O. Eso ayudará a cualquier programador que venga detrás, tanto a usarlo como a tener que optimizarlo, y sabrá cuál es la lógica a batir.

Aquí tienes una tabla muy completa con la complejidad de los principales algoritmos para estructuras de datos, ordenación de matrices, operaciones en grafos y operaciones de montón (heap).

Espero que esta explicación te haya aclarado los conceptos y que no vuelvas a ver la notación Big-O de la misma manera :-)

José M. Alarcón Aguín Fundador de campusMVP, es ingeniero industrial y especialista en consultoría de empresa. Ha escrito diversos libros, habiendo publicado hasta la fecha cientos de artículos sobre informática e ingeniería en publicaciones especializadas. Microsoft lo ha reconocido como MVP (Most Valuable Professional) en desarrollo web desde el año 2004 hasta la actualidad. Puedes seguirlo en Twitter en @jm_alarcon o leer sus blog técnico o personal. Ver todos los posts de José M. Alarcón Aguín
Archivado en: Lenguajes y plataformas

Boletín campusMVP.es

Solo cosas útiles. Una vez al mes.

🚀 Únete a miles de desarrolladores

DATE DE ALTA

x No me interesa | x Ya soy suscriptor

La mejor formación online para desarrolladores como tú

Comentarios (7) -

Muy interesante y didáctica tu publicación.

Responder

Muchas gracias!

Responder

Carlos Ortega
Carlos Ortega

Cuando se habla de logaritmos en tiempos de ejecución (expected time complexity) se habla de un logaritmo base 2, no base 10, por lo tanto, O(n * log(n)) solo podría ser mejor que O(n) en el intervalo 0 a 2, no 0 a 10 como aparece en la gráfica.

Dicho lo anterior, considerando:
- Que n pertenece a los números enteros positivos,
- Que en la práctica, un algoritmo que corra O(n * log(n)) solo podría ser mejor que uno corra en O(n) si n = 1, y
- Que si n = 1, cualquiera de los algoritmos corre de "forma instántanea".

Se concluye fácilmente que, O(n) es mejor o (en el peor de los casos) igual que O(n * log(n)) para cualquier caso real (práctico).

Responder

José Manuel Alarcón
José Manuel Alarcón

Hola Carlos:

Tienes razón, es logaritmo en base 2, no en base 10. No obstante O(n) y O(nLogn) se cruzan en el 5, no en el 2. Así que O(nLogn) sería mejor que O(n) hasta 5 (y no de 10) y sería peor a partir de ahí. Teniendo en cuenta que cualquier tarea con cualquier número de elemento en esos rangos (hasta 10) la diferencia de tiempos sería imperceptible... creo que tampoco es tan importante

En cualquier caso, creo que el objetivo del artículo, que es explicar el concepto de la notación Big-O y saber distinguir cuándo es mejor usar un algoritmo u otro en función de su complejidad, está conseguido. Lo demás, como dicen los británicos, es "Splitting Hairs".

No obstante te agradezco el comentario y trataré de actualizar la gráfica con ese detalle cuando tenga un rato.

Saludos.

Responder

Carlos Ortega
Carlos Ortega

Gracias por tu amable respuesta, José Manuel. Por curiosidad, ¿por qué indicas que se cruzarían en el 5 y no en el 2? Corrígeme si me equivoco: el número para el cual n = n * log2(n) es 2, ¿no es así?

Por lo demás la información es valiosa, mi intención no ha sido criticar el contenido sino agregar que con datasets más grandes (que no se ven en la gráfica) O(n) y O(n * log(n)) se empiezan a separar de forma importante y sostenida. Y el pequeño error de cruzar ambas curvas en 10 en realidad está relativamente extendido, es decir, muchos lugares lo tienen igual porque al parecer se les escapa que la fundación del 'divide and conquer' es siempre dividir entre 2.

Responder

José Manuel Alarcón
José Manuel Alarcón

Hola:

Error mío otra vez: en Desmos asumí que escribir log2 era base 2, pero en realidad por lo que veo debería haber escrito log_2 para que lo pille bien, así que se me interceptaba en 5 y no en 2.

De todos modos, como digo, eso es hilar fino pues no tiene mayor importancia. Lo importante aquí es que, independientemente de la base del logaritmo, la complejidad relativa del algoritmo es la misma y en valores tan bajos como esos no es importante. Es por esto que en los análisis de complejidad se omite indicar las constantes, como en este caso la base del logaritmo concreto, si bien es cierto que en general se suele usar base 2, entre otras cosas porque va perfecto para analizar la búsqueda binaria que se reduce a la mitad en cada paso (o sea, log_2(n)).

Intentaré mejorar la gráfica. Gracias.

Saludos.

Quizá el verdadero error por mi parte haya sido usar una escala horizontal tan corta ya que en una escala mayor la diferencia se ve igual de clara por esa complejidad relativa. Intentaré cambiar esa gráfica para dejarla perfecta y que se vea más claro, a ver si lo consigo.

Responder

José Manuel Alarcón
José Manuel Alarcón

Hola de nuevo:

Acabo de actualizar la gráfica. Ahora creo que se aprecia mucho mejor ampliando un poco la escala por la derecha. Esta vez sí he utilizado logaritmos en base 2 para hacerla por lo que ya no hay fallo 😉

Gracias de nuevo.

Saludos.

Responder

Agregar comentario

Los datos anteriores se utilizarán exclusivamente para permitirte hacer el comentario y, si lo seleccionas, notificarte de nuevos comentarios en este artículo, pero no se procesarán ni se utilizarán para ningún otro propósito. Lee nuestra política de privacidad.