Buscar..


Observaciones

Definición

La notación Big-O es en su corazón una notación matemática, utilizada para comparar la tasa de convergencia de funciones. Sean n -> f(n) y n -> g(n) funciones definidas sobre los números naturales. Luego decimos que f = O(g) si y solo si f(n)/g(n) está limitada cuando n se acerca al infinito. En otras palabras, f = O(g) si y solo si existe una constante A, tal que para todos n, f(n)/g(n) <= A

En realidad, el alcance de la notación Big-O es un poco más amplio en matemáticas, pero por simplicidad lo he reducido a lo que se usa en el análisis de complejidad de algoritmos: funciones definidas en los naturales, que tienen valores distintos de cero, y el caso de n creciente hasta el infinito.

Qué significa eso ?

Tomemos el caso de f(n) = 100n^2 + 10n + 1 g(n) = n^2 . Es bastante claro que ambas funciones tienden a infinito ya que n tiende a infinito. Pero a veces, saber el límite no es suficiente, y también queremos saber la velocidad a la que las funciones se acercan a su límite. Nociones como Big-O ayudan a comparar y clasificar funciones por su velocidad de convergencia.

Averigüemos si f = O(g) aplicando la definición. Tenemos f(n)/g(n) = 100 + 10/n + 1/n^2 . Desde 10/n es 10 cuando n es 1 y está disminuyendo, y desde 1/n^2 es 1 cuando n es 1 y también está disminuyendo, hemos f f(n)/g(n) <= 100 + 10 + 1 = 111 . La definición se cumple porque hemos encontrado un límite de f(n)/g(n) (111) y por tanto f = O(g) (decimos que f es un Big-O de n^2 ).

Esto significa que f tiende a infinito aproximadamente a la misma velocidad que g. Ahora bien, esto puede parecer algo extraño de decir, porque lo que hemos encontrado es que f es a lo sumo 111 veces más grande que g, o en otras palabras, cuando g crece en 1, f crece como máximo en 111. Puede parecer que en crecimiento 111 veces más rápido no es "aproximadamente la misma velocidad". Y, de hecho, la notación Big-O no es una forma muy precisa de clasificar la velocidad de convergencia de la función, por lo que en matemáticas usamos la relación de equivalencia cuando queremos una estimación precisa de la velocidad. Pero a los efectos de separar algoritmos en clases de gran velocidad, Big-O es suficiente. No necesitamos separar las funciones que crecen un número fijo de veces más rápido que las otras, sino solo las funciones que crecen infinitamente más rápido que las otras. Por ejemplo, si tomamos h(n) = n^2*log(n) , vemos que h(n)/g(n) = log(n) que tiende a infinito con n, por lo que h no es O (n ^ 2), porque h crece infinitamente más rápido que n ^ 2.

Ahora necesito hacer una nota al margen: es posible que haya notado que si f = O(g) g = O(h) , entonces f = O(h) . Por ejemplo, en nuestro caso, tenemos f = O(n^3) , y f = O(n^4) ... En el análisis de complejidad de algoritmos, frecuentemente decimos que f = O(g) significa que f = O(g) y g = O(f) , que puede entenderse como "g es el Big-O más pequeño para f". En matemáticas decimos que tales funciones son Big-Thetas entre sí.

Cómo se usa ?

Al comparar el rendimiento del algoritmo, nos interesa la cantidad de operaciones que realiza un algoritmo. Esto se llama complejidad del tiempo . En este modelo, consideramos que cada operación básica (suma, multiplicación, comparación, asignación, etc.) toma una cantidad de tiempo fija, y contamos el número de dichas operaciones. Por lo general, podemos expresar este número en función del tamaño de la entrada, que llamamos n. Y lamentablemente, este número generalmente crece hasta el infinito con n (si no lo hace, decimos que el algoritmo es O (1)). Separamos nuestros algoritmos en clases de gran velocidad definidas por Big-O: cuando hablamos de un "algoritmo O (n ^ 2)", queremos decir que la cantidad de operaciones que realiza, expresada como una función de n, es una O ( n ^ 2). Esto dice que nuestro algoritmo es aproximadamente tan rápido como un algoritmo que haría una cantidad de operaciones igual al cuadrado del tamaño de su entrada, o más rápido . La parte "o más rápida" está ahí porque usé Big-O en lugar de Big-Theta, pero generalmente la gente dirá que Big-O quiere decir Big-Theta.

Cuando contamos las operaciones, generalmente consideramos el peor de los casos: por ejemplo, si tenemos un bucle que puede ejecutarse como máximo n veces y que contiene 5 operaciones, el número de operaciones que contamos es 5n. También es posible considerar la complejidad del caso promedio.

Nota rápida: un algoritmo rápido es uno que realiza pocas operaciones, por lo que si el número de operaciones crece infinitamente más rápido , entonces el algoritmo es más lento : O (n) es mejor que O (n ^ 2).

También a veces estamos interesados ​​en la complejidad espacial de nuestro algoritmo. Para esto, consideramos el número de bytes en la memoria ocupada por el algoritmo como una función del tamaño de la entrada, y usamos Big-O de la misma manera.

Un bucle simple

La siguiente función encuentra el elemento máximo en una matriz:

int find_max(const int *array, size_t len) {
    int max = INT_MIN;
    for (size_t i = 0; i < len; i++) {
        if (max < array[i]) {
            max = array[i];
        }
    }
    return max;
}

El tamaño de entrada es el tamaño de la matriz, a la que llamé len en el código.

Contemos las operaciones.

int max = INT_MIN;
size_t i = 0;

Estas dos tareas se realizan solo una vez, por lo que son 2 operaciones. Las operaciones que se realizan en bucle son:

if (max < array[i])
i++;
max = array[i]

Como hay 3 operaciones en el bucle, y el bucle se realiza n veces, agregamos 3n a nuestras 2 operaciones ya existentes para obtener 3n + 2 . Así que nuestra función toma 3n + 2 operaciones para encontrar el máximo (su complejidad es 3n + 2 ). Este es un polinomio donde el término de más rápido crecimiento es un factor de n, por lo que es O (n).

Probablemente habrás notado que la "operación" no está muy bien definida. Por ejemplo, dije que if (max < array[i]) era una operación, pero dependiendo de la arquitectura, esta declaración puede compilar, por ejemplo, tres instrucciones: una lectura de memoria, una comparación y una rama. También he considerado todas las operaciones como iguales, aunque, por ejemplo, las operaciones de memoria serán más lentas que las demás y su rendimiento variará enormemente debido, por ejemplo, a los efectos de caché. También he ignorado por completo la declaración de devolución, el hecho de que se creará un marco para la función, etc. Al final, no importa el análisis de complejidad, porque de cualquier forma que elija para contar las operaciones, solo cambiará el coeficiente del factor n y la constante, por lo que el resultado seguirá siendo O (n). La complejidad muestra cómo el algoritmo se escala con el tamaño de la entrada, ¡pero no es el único aspecto del rendimiento!

Un bucle anidado

La siguiente función comprueba si una matriz tiene algún duplicado tomando cada elemento, luego iterando sobre toda la matriz para ver si el elemento está ahí

_Bool contains_duplicates(const int *array, size_t len) {
    for (int i = 0; i < len - 1; i++) {
        for (int j = 0; j < len; j++) {
            if (i != j && array[i] == array[j]) {
                return 1;
            }
        }
    }
    return 0;
}

El bucle interno realiza en cada iteración una serie de operaciones que son constantes con n . El bucle externo también realiza algunas operaciones constantes y ejecuta el bucle interno n veces. El propio bucle externo se ejecuta n veces. Así que las operaciones dentro del bucle interno se ejecutan n^2 veces, las operaciones en el bucle externo se ejecutan n veces, y la asignación a i se realiza una vez. Por lo tanto, la complejidad será algo así como an^2 + bn + c , y como el término más alto es n^2 , la notación O(n^2) es O(n^2) .

Como habrá notado, podemos mejorar el algoritmo evitando hacer las mismas comparaciones varias veces. Podemos comenzar desde i + 1 en el bucle interno, porque todos los elementos antes de que ya se hayan verificado con todos los elementos de la matriz, incluido el del índice i + 1 . Esto nos permite soltar el cheque i == j .

_Bool faster_contains_duplicates(const int *array, size_t len) {
    for (int i = 0; i < len - 1; i++) {
        for (int j = i + 1; j < len; j++) {
            if (array[i] == array[j]) {
                return 1;
            }
        }
    }
    return 0;
}

Obviamente, esta segunda versión hace menos operaciones y por lo tanto es más eficiente. ¿Cómo se traduce eso a la notación Big-O? Bueno, ahora el cuerpo del bucle interno se ejecuta 1 + 2 + ... + n - 1 = n(n-1)/2 veces. Este es todavía un polinomio de segundo grado, y por lo tanto es solo O(n^2) . Claramente, hemos reducido la complejidad, ya que dividimos aproximadamente por 2 el número de operaciones que estamos realizando, pero aún estamos en la misma clase de complejidad definida por Big-O. Para reducir la complejidad a una clase más baja, tendríamos que dividir el número de operaciones por algo que tiende a infinito con n .

Un ejemplo de O (log n)

Introducción

Considere el siguiente problema:

L es una lista ordenada que contiene n enteros con signo (siendo n suficientemente grande), por ejemplo [-5, -2, -1, 0, 1, 2, 4] (aquí, n tiene un valor de 7). Si se sabe que L contiene el entero 0, ¿cómo puedes encontrar el índice de 0?

Enfoque ingenuo

Lo primero que viene a la mente es leer cada índice hasta que se encuentre 0. En el peor de los casos, el número de operaciones es n , por lo que la complejidad es O (n).

Esto funciona bien para valores pequeños de n , pero ¿hay una forma más eficiente?

Dicotomía

Considere el siguiente algoritmo (Python3):

a = 0
b = n-1
while True:
  h = (a+b)//2 ## // is the integer division, so h is an integer
  if L[h] == 0:
    return h
  elif L[h] > 0:
    b = h
  elif L[h] < 0:
    a = h

a y b son los índices entre los que 0 es que se encuentran. Cada vez que entramos en el bucle, utilizamos un índice entre a y b y lo utilizan para reducir el área de búsqueda.

En el peor de los casos, tenemos que esperar hasta que a y b sean iguales. ¿Pero cuántas operaciones lleva eso? No n, porque cada vez que entramos en el bucle, dividimos la distancia entre a y b en alrededor de dos. Más bien, la complejidad es O (log n).

Explicación

Nota: Cuando escribimos "log", nos referimos al logaritmo binario, o log base 2 (que escribiremos "log_2"). Como O (log_2 n) = O (log n) (puedes hacer los cálculos) usaremos "log" en lugar de "log_2".

Llamemos a x el número de operaciones: sabemos que 1 = n / (2 ^ x).

Entonces 2 ^ x = n, entonces x = log n

Conclusión

Cuando se enfrente a divisiones sucesivas (ya sea por dos o por cualquier número), recuerde que la complejidad es logarítmica.

O (log n) tipos de algoritmos

Digamos que tenemos un problema de talla n. Ahora, para cada paso de nuestro algoritmo (que necesitamos escribir), nuestro problema original se convierte en la mitad de su tamaño anterior (n / 2).

Así que en cada paso, nuestro problema se convierte en medio.

Paso Problema
1 n / 2
2 n / 4
3 n / 8
4 n / 16

Cuando el espacio del problema se reduce (es decir, se resuelve por completo), no se puede reducir más (n se vuelve igual a 1) después de salir de la condición de verificación.

  1. Digamos en el paso k o número de operaciones:

    problema-tamaño = 1

  2. Pero sabemos que en el paso k, nuestro tamaño del problema debe ser:

    tamaño del problema = n / 2 k

  3. De 1 y 2:

    n / 2 k = 1 o

    n = 2 k

  4. Tomar registro en ambos lados

    log e n = k log e 2

    o

    k = log e n / log e 2

  5. Usando fórmula log x m / log x n = log n m

    k = log 2 n

    o simplemente k = log n

Ahora sabemos que nuestro algoritmo puede ejecutarse al máximo hasta el registro n, por lo que la complejidad del tiempo se presenta como
O (log n)


Un ejemplo muy simple en código para soportar el texto anterior es:

for(int i=1; i<=n; i=i*2)
{
    // perform some operation
}

Entonces, si alguien le pregunta si n es 256 cuántos pasos se ejecutarán en bucle (o cualquier otro algoritmo que reduzca su tamaño del problema a la mitad), puede calcularlo muy fácilmente.

k = log 2 256

k = log 2 2 8 (=> log a a = 1)

k = 8

Otro muy buen ejemplo para un caso similar es el algoritmo de búsqueda binaria .

int bSearch(int arr[],int size,int item){
 int low=0;
 int high=size-1;

 while(low<=high){                 
     mid=low+(high-low)/2;                 
     if(arr[mid]==item)                        
         return mid;                 
     else if(arr[mid]<item)                         
         low=mid+1;                
     else  high=mid-1;      
     }  
  return –1;// Unsuccessful result
}


Modified text is an extract of the original Stack Overflow Documentation
Licenciado bajo CC BY-SA 3.0
No afiliado a Stack Overflow