























Prepara tus exámenes y mejora tus resultados gracias a la gran cantidad de recursos disponibles en Docsity
Gana puntos ayudando a otros estudiantes o consíguelos activando un Plan Premium
Prepara tus exámenes
Prepara tus exámenes y mejora tus resultados gracias a la gran cantidad de recursos disponibles en Docsity
Prepara tus exámenes con los documentos que comparten otros estudiantes como tú en Docsity
Los mejores documentos en venta realizados por estudiantes que han terminado sus estudios
Estudia con lecciones y exámenes resueltos basados en los programas académicos de las mejores universidades
Responde a preguntas de exámenes reales y pon a prueba tu preparación
Consigue puntos base para descargar
Gana puntos ayudando a otros estudiantes o consíguelos activando un Plan Premium
Comunidad
Pide ayuda a la comunidad y resuelve tus dudas de estudio
Descubre las mejores universidades de tu país según los usuarios de Docsity
Ebooks gratuitos
Descarga nuestras guías gratuitas sobre técnicas de estudio, métodos para controlar la ansiedad y consejos para la tesis preparadas por los tutores de Docsity
Describe la complejidad que tienenlos algoritmos
Tipo: Transcripciones
1 / 31
Esta página no es visible en la vista previa
¡No te pierdas las partes importantes!
José A. Mañas
(ejecutando el programa, reloj en mano), o calcularse sobre el código contando instrucciones a ejecutar y multiplicando por el tiempo requerido por cada instrucción. Así, un trozo sencillo de programa como S1; for (i= 0; i < N; i++) S2; requiere T(N) = t1 + t2*N siendo t1 el tiempo que lleve ejecutar la serie "S1" de sentencias, y t2 el que lleve la serie "S2". Prácticamente todos los programas reales incluyen alguna sentencia condicional, haciendo que las sentencias efectivamente ejecutadas dependan de los datos concretos que se le presenten. Esto hace que más que un valor T(N) debamos hablar de un rango de valores Tmin(N) ≤ T(N) ≤ Tmax(N) los extremos son habitualmente conocidos como "caso peor" y "caso mejor". Entre ambos se hallara algún "caso promedio" o más frecuente. Cualquier fórmula T(N) incluye referencias al parámetro N y a una serie de constantes "Ki" que dependen de factores externos al algoritmo como pueden ser la calidad del código generado por el compilador y la velocidad de ejecución de instrucciones del ordenador que lo ejecuta. Dado que es fácil cambiar de compilador y que la potencia de los ordenadores crece a un ritmo vertiginoso^1 , intentaremos analizar los algoritmos con algún nivel de independencia de estos factores; es decir, buscaremos estimaciones generales ampliamente válidas.
Por una parte, necesitamos analizar la potencia de los algoritmos independientemente de la potencia de la máquina que los ejecute e incluso de la habilidad del programador que los codifique. Por otra, este análisis nos interesa especialmente cuando el algoritmo se aplica a problemas grandes. Casi siempre los problemas pequeños se pueden resolver de cualquier forma, apareciendo las limitaciones al atacar problemas grandes. No debe olvidarse que cualquier técnica de ingeniería, si funciona, acaba aplicándose al problema más grande que sea posible: las tecnologías de éxito, antes o después, acaban llevándose al límite de sus posibilidades. Las consideraciones anteriores nos llevan a estudiar el comportamiento de un algoritmo cuando se fuerza el tamaño del problema al que se aplica. Matemáticamente hablando, cuando N tiende a infinito. Es decir, su comportamiento asintótico. (^1) Se suele citar la Ley de Moore que predice que la potencia se duplique cada 2 años.
Para enfocar la comparación de algoritmos seguiremos los siguientes pasos:
Decidimos cómo medir N. Decidimos el recurso que nos interesa tiempo de ejecución = f(N) memoria necesaria = f(N) A partir de aquí analizaremos f(n). Como veremos más adelante, calcular la fórmula analítica exacta que caracteriza un algoritmo puede ser bastante laborioso. Con frecuencia nos encontraremos con que no es necesario conocer el comportamiento exacto, sino que basta conocer una cota superior, es decir, alguna función que se comporte "aún peor". De esta forma podremos decir que el programa práctico nunca superará una cierta cota.
Dadas dos funciones f(n) y g(n) diremos que f(n) es más compleja que g(n) cuando lim 𝑛→∞
Diremos que f(n) es menos compleja que g(n) cuando lim 𝑛→∞
Y diremos que f(n) es equivalente a g(n) cuando lim 𝑛→∞
siendo K ≠0 y K ≠∞ Nótese que esta definición nos permite un análisis algorítmico conociendo la formulación de la función, y también un análisis experimental observando los recursos consumidos para valores crecientes de N.
Se suelen manejar las siguientes
tales que para todo N > M, g(N) K * f(N) } o, dicho de otra forma, O(f(n)) = { g(n), lim 𝑛→∞
𝑔(𝑛) 𝑓(𝑛)
en palabras, O(f(n)) está formado por aquellas funciones g(n) que crecen a un ritmo menor o igual que el de f(n). De las funciones "g" que forman este conjunto O(f(n)) se dice que "están dominadas asintóticamente" por "f", en el sentido de que, para N suficientemente grande, y salvo una constante multiplicativa "K", f(n) es una cota superior de g(n). Si un algoritmo A se puede demostrar de un cierto orden O(…), es cierto que también pertenece a todos los órdenes superiores (la relación de orden “cota superior de” es transitiva); pero en la práctica lo útil es encontrar la "menor cota superior", es decir el menor orden de complejidad que lo cubra.
La siguiente tabla muestra el comportamiento de diferentes funciones para valores crecientes de n. Observe cómo las funciones de mayor orden de complejidad crecen de forma más desmesurada: n O(1) O(log n) O(n) O(n log n) O(n^2) O(n^5) O(5^n) O(n!)
10 1 2 10 23 100 1e+05 1e+07 4e+ 20 1 3 20 60 400 3e+06 1e+14 2e+ 30 1 3 30 102 900 2e+07 9e+20 3e+ 40 1 4 40 148 1,600 1e+08 9e+27 8e+ 50 1 4 50 196 2,500 3e+08 9e+34 3e+ 60 1 4 60 246 3,600 8e+08 9e+41 8e+ 70 1 4 70 297 4,900 2e+09 8e+48 1e+ 80 1 4 80 351 6,400 3e+09 8e+55 7e+ 90 1 4 90 405 8,100 6e+09 8e+62 1e+ 100 1 5 100 461 10,000 1e+10 8e+69 9e+
La misma tabla puede resultar más intuitiva si hablamos de tiempos medidos en microsegundos: n O(1) O(lg n) O(n) O(n lg n) O(n^2 ) O(n^5 ) O(5n) O(n!) 10 1μs 2μs 10μs 23 μs 100μs 100ms 10s 4s 20 1μs 3 μs 2 0μs 60 μs 400μs 3s 3a 63 mil años 30 1μs 3 μs 3 0μs 102 μs 900μs 20s 28 millones de años 40 1μs 4 μs 4 0μs 148 μs 1,6ms 100s 50 1μs 4 μs 5 0μs 196 μs 2,5ms 300s 60 1μs 4 μs 6 0μs 246 μs 3,6ms 800s 70 1μs 4 μs 7 0μs 297 μs 4,9ms 33m 80 1μs 4 μs 8 0μs 351 μs 6,4ms 50m 90 1μs 4 μs 9 0μs 405 μs 8,1ms 1h40m 100 1μs 5 μs 100 μs 461 μs 10ms 2h46m Disculpe el lector que no hayamos calculado los números más grandes; pero es que no tenemos palabras para tiempos tan fuera de nuestra capacidad de imaginación. Claramente esos programas son inviables a todos los efectos prácticos.
G. Sea k una constante, f (n) O (g) k * f (n) O (g) H. Si f O (h1) y g O (h2) f + g O (h1+h2) I. Si f O (h1) y g O (h2) f * g O (h1*h2) J. Sean los reales 0 < a < b O (na) es subconjunto de O (nb) K. Sea P (n) un polinomio de grado k P (n) O (nk) L. Sean los reales a, b > 1 O (log_a) = O (log_b) La regla [L] nos permite olvidar la base en la que se calculan los logaritmos en expresiones de complejidad. La combinación de las reglas [K, G] es probablemente la más usada, permitiendo de un plumazo olvidar todos los componentes de un polinomio, menos su grado. Por último, la regla [H] es la básica para analizar el concepto de secuencia en un programa: la composición secuencial de dos trozos de programa es de orden de complejidad el de la suma de sus partes.
Aunque no existe una receta que siempre funcione para calcular la complejidad de un algoritmo, si es posible tratar sistemáticamente una gran cantidad de ellos, basándonos en que suelen estar bien estructurados y siguen pautas uniformes. Los algoritmos bien estructurados combinan las sentencias de alguna de las formas siguientes
Nos referimos a las sentencias de asignación, entrada/salida, etc. siempre y cuando no trabajen sobre variables estructuradas cuyo tamaño esté relacionado con el tamaño N del problema. La inmensa mayoría de las sentencias de un algoritmo requieren un tiempo constante de ejecución, siendo su complejidad O(1).
La complejidad de una serie de elementos de un programa es del orden de la suma de las complejidades individuales, aplicándose las operaciones arriba expuestas.
La condición suele ser de O(1), complejidad a sumar con la peor posible, bien en la rama THEN, o bien en la rama ELSE. En decisiones múltiples (ELSIF, CASE), se tomará la peor de las ramas.
En los bucles con contador explícito, podemos distinguir dos casos: que el tamaño N forme parte de los límites o que no. Si el bucle se realiza un número fijo de veces, independiente de N, entonces la repetición sólo introduce una constante multiplicativa que puede absorberse. for (int i= 0; i < K; i++) algo_de_O(1); K * O(1) = O (1) Si el tamaño N aparece como límite de iteraciones, tenemos varios casos: caso 1: for (int i= 0; i < N; i++) algo_de_O(1); N * O(1) = O (n) caso 2: for (int i= 0; i < N; i++) for (int j= 0; j < N; j++) algo_de_O(1); N * N * O(1) = O (n^2 ) caso 3: for (int i= 0; i < N; i++) for (int j= 0; j < i; j++) algo_de_O(1); el bucle exterior se realiza N veces, mientras que el interior se realiza 1, 2, 3, ... N veces respectivamente. En total, tenemos la suma de una serie aritmética: 1 + 2 + 3 + ... + N = N * (1+N) / 2 O (n^2 ) A veces aparecen bucles multiplicativos, donde la evolución de la variable de control no es lineal (como en los casos anteriores)
Por ejemplo, podemos encontrarnos con un problema que para resolver un problema de tamaño N resuelve 2 problemas de tamaño N/2 y luego combina las soluciones parciales con una operación de complejidad O(n). La relación de recurrencia es T(n) = 2 T(n/2) + O(n) T(1) = O(1) Y el problema es cómo deducir a partir de esa definición la complejidad del algoritmo completo. Para resolverlo, vamos a ir aplicando pasos sucesivos de recursión T(n) = 2 T(n/2) + n T(n) = 2 (2 T(n/4) + n/2) + n = 4 T(n/4) + 2n T(n) = 8 T(n/8) + 3n … hasta que veamos un patrón … T(n) = 2k^ T(n/2k) + k n Esta fórmula es válida siempre, en particular cuando n/2k^ = 1 y podemos escribir T(n) = 2k^ T(1) + k n y como sabemos la condición de parada de la recursión T(n) = 2k^ + k n El valor de k que termina la recursión es n/2k^ = 1 k = log 2 (n) sustituyendo T(n) = 2 log 2 (n)^ + log 2 (n) n T(n) = n + n log(n) O(n log(n))
El método descrito permite resolver numerosas relaciones de recurrencia. Algunas son muy habituales: relación complejidad ejemplos T(n) = T(n/2) + O(1) O(log n) búsqueda binaria T(n) = T(n-1) + O(1) O(n) búsqueda lineal factorial bucles for, while T(n) = 2 T(n/2) + O(1) O(n) recorrido de árboles binarios: preorden, en orden, postorden T(n) = 2 T(n/2) + O(n) O(n log n) ordenación rápida ( quick sort ) T(n) = T(n-1) + O(n) O(n^2 ) ordenación por selección ordenación por burbuja
T(n) = 2 T(n-1) + O(1) O(2n) torres de hanoi
Vamos a aplicar lo explicado hasta ahora a un problema de fácil especificación: diseñar un programa para evaluar un polinomio P(x) de grado N. Sea coef el vector de coeficientes: double evaluaPolinomio1(double[] coef, double x) { double total = 0; for (int i = 0; i < coef.length; i++) { // bucle 1 double xn = 1.0; for (int j = 0; j < i; j++) // bucle 2 xn *= x; total += coef[i] * xn; } return total; } Como medida del tamaño tomaremos para N el grado del polinomio, que es el número de coeficientes. Así pues, el bucle más exterior (1) se ejecuta N veces. El bucle interior (2) se ejecuta, respectivamente 1 + 2 + 3 + ... + N veces = N * (1+N) / 2 O(n^2 ) Intuitivamente, sin embargo, este problema debería ser menos complejo, pues repugna al sentido común que sea de una complejidad tan elevada. Se puede ser más inteligente a la hora de evaluar la potencia xn: double potencia(double x, int y) { double t; if (y == 0) return 1.0; if (y % 2 == 1) return x * potencia(x, y - 1); else { t = potencia(x, y / 2); return t * t; } } double evaluaPolinomio2(double[] coef, double x) { double total = 0; for (int i = 0; i < coef.length; i++) total += coef[i] * potencia(x, i);
double xn = 1; double total = coef[0]; for (int i = 1; i < coef.length; i++) { xn *= x; total += coef[i] * xn; } return total; } que queda en un algoritmo de O(n). Habiendo N coeficientes distintos, es imposible encontrar ningún algoritmo de un orden inferior de complejidad. En cambio, si es posible encontrar otros algoritmos de idéntica complejidad: double evaluaPolinomio4(double[] coef, double x) { double total = 0; for (int i = coef.length - 1; i >= 0; i--) total = total * x + coef[i]; return total; } No obstante ser ambos algoritmos de idéntico orden de complejidad, cabe resaltar que sus tiempos de ejecución pudieran ser notablemente distintos. En efecto, mientras el último algoritmo ejecuta N multiplicaciones y N sumas, el penúltimo requiere 2N multiplicaciones y N sumas. Si el tiempo de ejecución es notablemente superior para realizar una multiplicación, cabe razonar que el último algoritmo ejecutará en la mitad de tiempo que el anterior, aunque eso no cambie el orden de complejidad. La tabla siguiente muestra algunos tiempos de ejecución. Para calcularlo, se han utilizado unos coeficientes aleatorios, y se ha ejecutado cada método 10.000 veces: N = 10 100 500 1.00 0 evaluaPolinomio1 4ms 138ms 3.306ms 12.221ms evaluaPolinomio2 8ms 57ms 474ms 1.057ms evaluaPolinomio3 2ms 6ms 15ms 26ms evaluaPolinomio4 2ms 9ms 21ms 38ms Nótese que para polinomios pequeños todos los algoritmos son equivalente, mientras que al crecer el número de coeficientes, van empeorando según lo previsto. También puede observarse que entre los algoritmos 3 y 4 la diferencia es contraria a lo previsto, pudiendo pensarse que el ordenador tiene un co-procesador matemático que multiplica a la misma velocidad que suma y que el compilador genera mejor código para el algoritmo 3 que para el 4. Esto son sólo conjeturas.
La serie de Fibonacci es una serie de números naturales muy conocida y estudiada. Su definición más habitual es fib(1) = 1 fib(2) = 1 fib(n) = fib(n-1) + fin(n-2), para n > 2
Si aplicamos ciegamente la definición matemática: int fibo1(int n) { if (n < 2) return 1; else return fibo1(n - 1) + fibo1(n-2); } Calcular su complejidad no es fácil, pues la función del tiempo de ejecución en función del valor de n es T(n) = T(n-1) + T(n-2) Podemos hacer una hipótesis e intentar corroborarla. Supongamos que T(N) responde a una función del tipo T(n) = k an entonces debe satisfacerse que k an^ = k an-^1 + k an-^2 simplificando a^2 = a + 1 de donde se despeja 𝑎 = 1 +√ 5 2
o sea T(n) ∼1,6n o, en otras palabras, T(n) O(1,6n) En cuanto al espacio requerido en RAM, baste observar que el algoritmo profundiza por dos ramas, n-1 y n-2, siendo n-1 la mayor, lo que lleva a una profundidad de recursión de n pasos (puede ver un diagrama en la siguiente sección). En consecuencia E(n) O(n) (^2) Es precisamente la proporción áurea.
Así visto, reducimos radicalmente el grafo de llamadas. En la primera bajada vamos calculando y luego reutilizamos De forma que el programa se convierte en una simple secuencia de llamadas T(n) O(n) No obstante, cabe destacar que hemos introducido un coste en espacio en memoria RAM: los valores memorizados. En términos de memoria RAM necesaria, este programa también requiere O(n) datos memorizados. Comparado con fibo1, esta forma de programarlo en vez de usar la RAM para guardar llamadas recursivas la utiliza para memorizar datos. Cabe destacar que esta técnica de memorizar datos se puede aplicar en muchísimos casos pues es muy general.
Una variante del algoritmo con memoria es memorizar sólo los datos que más se repiten de forma que el espacio requerido en RAM es fijo e independiente de N: private static int[] tabla = new int[20]; public static int fibo3(int n) { if (n < 2) return 1; if (n < tabla.length) { int resultado = tabla[n]; if (resultado > 0) return resultado; resultado = fibo3(n - 1) + fibo3(n - 2); tabla[n] = resultado; return resultado; } return fibo3(n - 1) + fibo3(n - 2); }
El efecto en la complejidad del tiempo de ejecución es difícil de calcular pues depende del tamaño de la memoria respecto de los valores N que necesitemos.
Si pensamos en fin(n) como el n-ésimo término de la serie de Fibonacci 1 1 2 3 5 8 13 21 34 … podemos programarlo como int fibo 4 (int n) { int n0= 1; int n1= 1; for (int i= 2; i <= n; i++) { int ni= n0 + n1; n0=n1; n1= ni; } return n1; } En este caso tenemos un simple bucle T(n) O(n) En cuanto a memoria RAM, el espacio ocupado es constante; o sea E(n) O(1) 7.5 Algoritmo directo Se conoce la fórmula de Binet (1786 – 1856) 𝑓𝑖𝑏(𝑛) =
que podemos programar como static final double SQRT_5 = Math.sqrt(5); int fibo5(int n) { if (n < 2) return 1; n += 1; double t1 = Math.pow((1 + SQRT_5) / 2, n); double t2 = Math.pow((1 - SQRT_5) / 2, n); double t = (t1 - t2) / SQRT_5; return (int) Math.round(t); } lo que nos deja un algoritmo de tiempo constante T(n) O(1) En cuanto a memoria RAM, el espacio ocupado es constante; o sea E(n) O(1)