Herramientas de usuario

Herramientas del sitio


algoritmos-oia:analisis-amortizado

Diferencias

Muestra las diferencias entre dos versiones de la página.

Enlace a la vista de comparación

Próxima revisión
Revisión previa
algoritmos-oia:analisis-amortizado [2017/11/23 20:54]
santo creado
algoritmos-oia:analisis-amortizado [2017/11/23 22:02] (actual)
santo
Línea 1: Línea 1:
 ====== Análisis amortizado ====== ====== Análisis amortizado ======
  
-Se llama *análisis amortizado* de complejidad a una forma de medir el costo de ciertas operaciones,​ en la cual no contamos el tiempo real que toma **cada operación particular**,​ sino que medimos el tiempo total que toma realizar $n$ operaciones **cualesquiera**,​ y si ese tiempo es $T$, decimos finalmente que cada operación particular tiene una complejidad **amortizada** de $O(\frac{T}{n})$.+Se llama *análisis amortizado* de complejidad a una forma de medir el costo de ciertas operaciones,​ en la cual no contamos el tiempo real que toma **cada operación particular**,​ sino que medimos el tiempo total que toma realizar $n$ operaciones **cualesquiera**,​ y si ese tiempo es $T$, decimos finalmente que cada operación particular tiene una complejidad **amortizada** de $O\left(\frac{T}{n}\right)$.
  
 Lo interesante es que, como en la inmensa mayoría de los casos nos importa solamente el tiempo total, y no el tiempo que efectivamente tardó una operación en particular, **podemos perfectamente realizar todos los cálculos de complejidad suponiendo que las operación tardan siempre el tiempo promedio**, y el resultado final obtenido será siempre correcto para el tiempo total. Lo interesante es que, como en la inmensa mayoría de los casos nos importa solamente el tiempo total, y no el tiempo que efectivamente tardó una operación en particular, **podemos perfectamente realizar todos los cálculos de complejidad suponiendo que las operación tardan siempre el tiempo promedio**, y el resultado final obtenido será siempre correcto para el tiempo total.
  
-Varias operaciones de las bibliotecas estándar, como la [[http://wiki.oia.unsam.edu.ar/cpp-avanzado|STL de C++]]+===== Ejemplo: funciones de las bibliotecas estándar ===== 
 + 
 +Varias operaciones de las bibliotecas estándar, como la [[cpp-avanzado:stl|STL de C++]], garantizan sus tiempos de ejecución en forma amortizada, como por ejemplo ''​.push_back()''​ de ''​vector''​ que tiene un tiempo de ejecución $O(1)$ amortizado. 
 + 
 +===== Ejemplo: pila con eliminación múltiple ===== 
 + 
 +Este es quizás uno de los ejemplos más comunes e importantes de la idea de análisis amortizado. 
 + 
 +Supongamos que tenemos una pila, como podría ser por ejemplo [[cpp-avanzado:​queue-y-stack|stack]] de la STL de C++. Comenzando con una pila vacía, vamos a realizarle **dos tipos de operaciones**:​ 
 + 
 +   - Agregar un cierto elemento $x$ a la pila. 
 +   - Sacar una cierta cantidad $k$ de elementos de la pila. 
 + 
 +Tanto los $x$ como los $k$ de las operaciones anteriores son arbitrarios,​ y por ejemplo podemos pensar que los elige un usuario, o se leen de la entrada, sin que tengamos control sobre ellos.  
 + 
 +La pregunta que nos hacemos es, ¿Cuál es la complejidad de estas dos operaciones?​ La operación 1, de agregar, es la más simple, pues consistirá de un único ''​.push()''​ en el stack, que ya sabemos que tiene complejidad $O(1)$ ((Esta complejidad $O(1)$ es en realidad amortizada, pero eso no afecta nuestro análisis)). 
 + 
 +Si hacemos un análisis de cuánto tarda la operación de sacar, en el peor caso podrían tener que sacarse muchos números, pues la cantidad que se saca depende del $k$ ingresado. En particular, $k$ podría ser tan grande como todos los elementos que se han agregado en la pila hasta el momento. Por lo tanto en el peor de los casos la operación 2 tiene un costo $O(N)$, siendo $N$ la cantidad de elementos que se agregan en total (es decir, la cantidad de operaciones de tipo 1). 
 + 
 +Entonces por ejemplo, si se hacen $1000$ operaciones de tipo $1$, y $1000$ operaciones de tipo $2$, como las operaciones de tipo $2$ pueden costar hasta $1000$ según nuestro análisis previo, el costo total de estas operaciones será como mucho $1000 \cdot 1000$ en el peor de los casos. 
 + 
 +Si bien este análisis es correcto, es **pesimista**:​ En realidad el costo total será siempre **muchísimo menor** que $1000 \cdot 1000$. Observemos que, para que el costo total sea tan grande, **todas** las operaciones de tipo 2 deberían ser caras, es decir, en **todas** ellas se deberían eliminar **todos** los números. Pero esto es imposible: Una vez que un número fue eliminado, ya no está en la pila, y no puede "ser eliminado de nuevo"​. En otras palabras, **cada número que se agrega con una operación tipo 1, puede ser borrado a lo sumo una vez, en una sola operación tipo 2**.  
 + 
 +Con eso en mente, podemos pensar el costo **total** de otra manera: En lugar de pensar cuánto cuesta hacer los borrados en **una** operación particular, pensamos cuántas veces se borra un cierto **elemento** particular. La observación es entonces que, sin importar en qué operación se borre, cada elemento que se agrega se borra como máximo **una única vez**, y entonces el costo total de los borrados es proporcional a la cantidad de elementos agregados, es decir, a la cantidad de operaciones de tipo 1. 
 + 
 +Si llamamos $N$ al total de operaciones (tanto de tipo 1 como de tipo 2), tenemos entonces resumiendo:​ 
 + 
 +  - Las operaciones de tipo 1 son $O(1)$, y por lo tanto en total ejecutarlas todas tiene un costo $O(N)$ 
 +  - Las operaciones de tipo 2 son en total $O(N)$, y lo único "​caro"​ que hacen es borrar elementos, pero **en total**, el costo de los borrados es $O(N)$. Por lo tanto el costo total de hacer **todas** las operaciones de este tipo será también $O(N)$. 
 +  - El costo total de ejecutar todas las operaciones es, por los dos anteriores, $O(N)$. 
 +  - Entonces, como $N$ operaciones siempre tienen un costo proporcional a $N$, el **costo promedio por operación** resulta ser $\frac{N}{N} = 1$. Decimos entonces que ambas operaciones tienen un costo $O(1)$ **amortizado**:​ podemos pensar que ambas cuestan $O(1)$, y el costo final que obtendremos al calcular será el correcto. 
 + 
 +===== Ejemplo: contador binario ===== 
 + 
 +Supongamos que tenemos que implementar manualmente un **contador binario**: es decir, vamos a tener que mantener actualizada una lista de **dígitos** en [[algoritmos-oia:​enteros:​cambio-de-base|binario]], que representan un número, comenzando desde $0$, e **incrementando** el número en cada paso. 
 + 
 +Si almacenamos el contador binario en un ''​string'',​ podemos inicializarlo con $N$ dígitos cero: 
 + 
 +<code cpp> 
 +    string contador(N, '​0'​);​ 
 +</​code>​ 
 + 
 +Y a continuación,​ tendremos que implementar la operación de incrementar (sumar $1$) el valor, para completar nuestro contador. Para esto podemos sumar 1 con el algoritmo usual de sumar números: Le sumamos 1 al dígito de las unidades, y si este se pasa de lo permitido, dejamos un 0 y "nos llevamos uno" hacia la izquierda, continuando con la suma. 
 + 
 +Esta idea se podría implementar de la siguiente manera: 
 + 
 +<code cpp> 
 +void incrementar(string &​contador) 
 +
 +    int N = int(contador.size());​ 
 +    while (N > 0) 
 +    { 
 +        N--; 
 +        contador[N]++;​ 
 +        if (contador[N] < '​2'​) 
 +            break; 
 +        else 
 +            contador[N] = '​0';​ 
 +    } 
 +
 +</​code>​  
 + 
 +Se puede probar el funcionamiento de este contador con por ejemplo el siguiente fragmento:​ 
 + 
 +<code cpp> 
 +int main() 
 +
 +    int N; cin >> N; 
 +    string contador(N,'​0'​);​ 
 +    for (int i=0;​i<​(1<<​N)+5;​i++) 
 +    { 
 +        cout << contador << endl; 
 +        incrementar(contador);​ 
 +    } 
 +    return 0; 
 +
 +</​code>​ 
 + 
 +Si se ingresa ''​3'',​ el resultado por pantalla sería: 
 + 
 +<​code>​ 
 +000 
 +001 
 +010 
 +011 
 +100 
 +101 
 +110 
 +111 
 +000 
 +001 
 +010 
 +011 
 +100 
 +</​code>​ 
 + 
 +=== Complejidad === 
 + 
 +La pregunta entonces es, ¿Cuánto tiempo toma la ejecución de la función ''​incrementar''?​ En general, la mejor cota que podemos dar para una operación particular es $O(N)$, ya que por ejemplo en el paso que transforma ''​11111''​ en ''​00000'',​ para $N=5$, se deben ir alterando todos los $N$ dígitos. 
 + 
 +Sin embargo, veamos que **en promedio**, el costo de la función es mucho menor. 
 + 
 +Para esto, tenemos que pensar exactamente **cuántas veces** se ejecuta el código interior del ''​while''​ en una llamada particular de la función ''​incrementar''​. Lo que podemos observar es que esto **dependerá del número que se está incrementando**:​ 
 + 
 +  * Si el número termina en 0, se cambia por 1 y se termina en 1 solo paso. 
 +  * Si el número termina en 01, se cambia a 10, y se termina en 2 pasos. 
 +  * Si el número termina en 011, se cambia a 100, y se termina en 3 pasos. 
 +  * Si el número termina en 0111, se cambia a 1000, y se termina en 4 pasos. 
 +  * ... 
 +  * Si el número tiene todos 1, se cambia a todos 0, y se ejecutan todos los N pasos posibles. 
 + 
 +Entonces, el costo exacto de una llamada a incrementar depende de dónde esté el último cero del número. **La mitad** de las veces, habrá un 0 al final y se termina en 1 solo paso. La cuarta parte de las veces, el último 0 del número estará a 2 pasos de distancia. La octava parte de las veces, el número terminará con 011 y el cero estará a 3 pasos de distancia. En general, de los $2^N$ números posibles, en $\frac{2^N}{2^i}$ se tardan $i$ pasos en terminar. Por lo tanto, **el costo total** si procesamos los $2^N$ números con la función ''​incrementar''​ será: 
 + 
 +$$T = 2^{N-1} \cdot 1 + 2^{N-2} \cdot 2 + 2^{N-3} \cdot 3 + \cdots + 1 \cdot N$$ 
 + 
 +Como estamos realizando $n_{ops}=2^N$ operaciones en total, el costo **por operación** se obtiene dividiendo al anterior por $2^N$, y entonces el costo **promedio** es: 
 + 
 +$$\frac{T}{n_{ops}} = \frac{1}{2} + \frac{2}{4} + \frac{3}{8} + \frac{4}{16} + \frac{5}{32} + \cdots + \frac{N}{2^N} \leq 2$$ 
 + 
 +FIXME [Linkear a alguna explicación de por qué esa suma no se pasa de 2] 
 + 
 +Por lo tanto, concluimos que la función ''​incrementar''​ tiene un costo **amortizado** $O(1)$: si hacemos las $2^N$ llamadas a ''​incrementar''​ necesarias para pasar por todos los valores, el costo total será $2^N \cdot O(1) = O(2^N)$, y no de $2^N \cdot N$, que sería el resultado de hacer un cálculo "​pesimista",​ como si todas las operaciones fueran caras. 
 + 
 +=== Otros ejemplos similares === 
 + 
 +Hemos analizado un contador binario, pero un análisis completamente análogo mostraría que la misma técnica tiene complejidad amortizada $O(1)$ al incrementar un contador ternario, o en base 10, o en cualquier otra base $B \geq 2$. 
 + 
 +Algo muy similar a esto ocurre con la función [[cpp-avanzado:​algorithm:​next-permutation|next_permutation]],​ que si se utiliza muchas veces para generar todas las permutaciones de una lista dada, tiene una complejidad amortizada $O(1)$. 
 + 
algoritmos-oia/analisis-amortizado.1511470458.txt.gz · Última modificación: 2017/11/23 20:54 por santo