Como vimos en la clase anterior, un algoritmo es como una receta: un conjunto de
instrucciones para completar una tarea.
Lo que el algoritmo hace no depende del lenguaje de
programación (idioma).
¿Qué es un algoritmo?
Es un conjunto prescrito de instrucciones o pasos bien
definidos, cuyo fin es obtener una
solución o resultado.
La
palabra viene del nombre del matemático, Mohammed ibn-Musa al-Khwarizmi, que formaba parte de la
corte real en Baghdad, y vivió desde el año 780 hasta 850 (aprox).
Al-Khwarizmi
Algoritmos
Los algoritmos son independientes de los medios utilizados para completar la tarea.
El mismo algoritmo podría implementarse con lapiz y papel, un computador, un abaco, etc.
Por lo tanto, son independientes del lenguaje de programación que usamos.
Un programa de computador es simplemente una
implementación (o realización) de un algoritmo.
Precisión y rapidez de un algoritmo
La precisión del resultado y la rapidez en obtenerla dependen del algoritmo usado.
Si el algoritmo está diseñado mal, el problema será mucho más difícil de resolver.
En general, evaluamos un algoritmo en términos de:
Precisión del resultado obtenido.
Rapidez para obtener el resultado.
Diseño del algoritmo
El primer paso para resolver un problema computacionalmente es diseñar el algoritmo.
Del mismo modo que un edificio no puede construirse sin un plan de arquitecto, deberíamos
planificar nuestro método para resolver el problema antes de programar.
Diseño del algoritmo
Lista de instrucciones
Diagrama de flujo
Pseudo-código
Un ejemplo: ordenar números
Dado un conjunto de números, queremos ordenarlos en orden ascendiente.
¿Cómo podemos hacer eso?
Un ejemplo: ordenar números
No es necesario pensar en ningún lenguaje de programación específica cuando estamos
diseñando un algoritmo!
Primero, hay que pensar en los pasos lógicos que cualquier persona puede seguir para
convertir una lista de números sin orden en una lista de números ordenados...
Un ejemplo: ordenar números
La opción más simple es ir
tomando cada número de la lista y compararlo con
todo el resto.
Sin embargo, existen formas más rápidas
(con menos pasos) que nos llevaran al mismo resultado.
Un ejemplo de un algoritmo para ordenar números
Una posible solución a este problema es el siguiente algoritmo (que se llama "quicksort"):
Algoritmo Quicksort
Elegir un elemento de la lista, que se llama el elemento pivote.
Colocar todos los números menores que el elemento pivote a la izquierda, y todos los números
mayores a la derecha.
Para cada una de estas sublistas, realizar los pasos (1) y (2), e igualmente para cada sublista
dentro de las sublistas, etc.
Eventualmente las sublistas tendrán solamente $1$ elemento o ninguno, y estas sublistas estarán
ordenadas por definición.
$\Rightarrow$ se trata de un algoritmo recursivo.
Quicksort
Eligiendo el mejor algoritmo
¿Cómo sabemos que el algoritmo que hemos diseñado es la "mejor" solución?
¿Qué significaría "mejor" en este caso?
Típicamente significa el más preciso y el más rápido.
Precisión es normalmente el primer aspecto de un algoritmo que verificamos.
Calcular la rapidez de un algoritmo es un problema desafiante. ¿Cómo podemos comparar la rapidez
de dos algoritmos?
Determinando la rapidez de un algoritmo
Recordar que un algoritmo es independiente de los medios utilizados para implementarlo.
Este significa que el algoritmo es independiente del computador que usamos y el lenguaje de
programación.
Por lo tanto, podemos caracterizar la "rapidez" de un algoritmo en una manera que no refiere a
la velocidad del
procesador, el número de procesadores, si usamos Python o C, etc.
Complejidad algorítmica
En la computación, la rapidez de un algoritmo se define
en términos de su complejidad algorítmica.
Determinamos la complejidad de un algoritmo por la consideración del número de pasos que
el algoritmo tiene que ejecutar para completar su tarea.
Algoritmos que requieran más pasos serán más
lentos (mayor complejidad algorítmica).
Complejidad algorítmica
Pero, ¿qué es un "paso" para un algoritmo?
NO es algo al nivel del procesador y sus ciclos de operación.
En el análisis de algoritmos, estamos pensando al nivel de los cálculos necesarios en el
algoritmo.
Complejidad algorítmica
La complejidad algorítmica nos dirá como el algoritmo escala cuando lo aplicamos a más datos.
En el caso de "quicksort" queremos saber que tan rápido o lento es el algoritmo cuando lo
aplicamos a una lista de:
$10$ elementos
$100$ elementos
$1000$ elementos
...
Por ejemplo, nos gustaría saber: ¿Cuantos pasos se requieren en el caso de $100$ vs
$10$?
Complejidad algorítmica: N-cuerpos
¿Cómo describimos la complejidad de un algoritmo?
Vamos a usar un algoritmo más simple que "quicksort".
Analizaremos un tipo de simulación computacional que se usa mucho en la astrofísica: simulaciones de N-cuerpos.
Complejidad algorítmica: N-cuerpos
El problema de N-cuerpos consiste en un sistema de N
partículas sujetas a la acción de su propia gravedad
(como el Sistema Solar).
Se puede actualizar las posiciones de las partículas con sus velocidades: el sistema se
evoluciona en el tiempo.
Complejidad algorítmica: N-cuerpos
El "paso" básico en este algoritmo es el calculo de las fuerzas entre cada par de
partículas.
Con solo 2 partículas tenemos que calcular solamente la fuerza entre un par y listo! (1
paso).
Complejidad algorítmica: N-cuerpos
3 partículas: 3 pares posibles $(1,2)$, $(1,3)$, $(2,3)$. (3 "pasos" en el algorítmo)
¿$n$ partículas?
La pregunta es, ¿cuántos pares únicos podemos formar de $n$ partículas?
Complejidad algorítmica: N-cuerpos
Hay $n$ pares para cada partícula (incluyendo un "par" de la partícula con si misma).
Hay $n$ partículas $\Rightarrow$ número total de pares es $n \times n = n^2$.
Para eliminar los pares falsos (de una partícula con si misma), de los cuales hay $n$,
podemos restar $n$ de $n^2$.
Complejidad algorítmica: N-cuerpos
Pero, estamos contando los mismos pares dos veces: el par $(3,5)$ es el mismo par como
$(5,3)$.
Multiplicamos por $1/2$, para obtener el número final de pares únicos: $\frac{1}{2}(n^2 - n)$.
Complejidad algorítmica: notación O grande
La complejidad del algoritmo está dada simplemente por contar el número de pares únicos!
Expresamos la complejidad en notación "Big-O" (O grande): $\mathcal{O}$.
Complejidad algorítmica: notación O grande
El número de pares de $n$ partículas es $\frac{1}{2}(n^2 - n)$. Usamos el término del
grado
más alto del polinomio (sin preocuparnos por los coeficientes): $\mathcal{O}(n^2)$.
Este es porque este término crece más rápido con $n$ mayor que los otros, y el factor de
$1/2$ no es muy importante si $n$ es un número
grande.
Complejidad algorítmica: notación O grande
El algoritmo de N-cuerpos tiene una complejidad de $O(n^2)$.
Si la implementación del algorítmo (i.e. el programa) demora $2$ segundos para $10$ partículas
(i.e. $n=10$),
$t = cn^2$, $2 = c(10^2) \Rightarrow c = 0,02$.
El programa demorará aproximadamente $200$ segundos para $n = 100$ partículas.
$t = cn^2$, $t = (0,02) \times (100^2) = 200$.
Este algoritmo, por lo tanto, escala muy mal: demora mucho más tiempo cuando
aumentamos el número de partículas un poco.
Diferentes complejidades
¿Por qué es importante saber la complejidad de un algoritmo?
Ejemplo: si uno tiene un software para simulaciones de N-cuerpos, pero sin información de
su complejidad algoritmica.
Quizás uno lo usa para simular un cúmulo de estrellas muy pequeño con $20$ partículas, y tarda
$1$ hora para completarse.
Si el algoritmo tiene complejidad $\mathcal{O}(n^2)$ y uno intenta simular una galaxia con
200.000 partículas, se demorará...
11.000 años!
En qué momento se deja de esperar..?
Precisión
Precisión en la computación
Estamos todos acostumbrados a calcular cosas en una calculadora (o en el teléfono) y confiar en el
resultado. Pero, de hecho, los computadores nunca son perfectamente precisos.
$\Rightarrow$ Tienen una memoria finita
$\Rightarrow$ Utilizan números binarios.
Memoria finita
Los computadores no pueden almacenar numeros a una precisión arbitrariamente alta.
Por ejemplo: el número $\pi$ es un número irracional (de hecho, trascendente).
Memoria finita
No se puede escribir $\pi$ en ningún sistema de números (base-10, base-20, lo que sea) a una
precisión arbitraria porque requeriría un número infinito de dígitos.
Por lo tanto, cualquier cálculo en un computador que involucra $\pi$ tiene que ser (hasta cierto
punto) inexacto porque el número está truncado.
Límites en tamaños de variables
De hecho, la mayoría de los lenguajes de programación ponen límites en los tamaños de los
números que se puede guardar en una variable.
En Python un float normal no puede ser mayor que $10^{308}$. ¿Por qué? Tiene que ver con
como los computadores guardan números en binario.
Estándar para números de punto flotante
Existe un estandar en la informática, llamado IEEE754, que define como guardar un número de
punto flotante en la
memoria de un computador.
En Python hay un total de $64$ bits para guardar un float. De estos, $1$ bit es para el
signo (+/-), $11$
bits son para el exponente, y $52$ bits son para el significando (fracción, mantisa,
coeficiente).
Estándar para números de punto flotante
Con $11$ bits en el exponente, el rango de números decimales que se puede guardar es de $-1024$
a $1023$.
$2^{1023}$ es aproximadamente $10^{308}$.
Precisión de números de punto flotante
Entonces, todos los números de punto flotante en un computador están truncados.
En la mayoría de los lenguajes hay floats de precisión-simple (single-precision) y
precisión-doble (double-precision).
Es importante saber cual se ocupa, ya que determina la precisión numérica del algoritmo.
Ojo: no siempre es más conveniente utilizar
Precisión-doble, ya que requiere el doble de memoria que
Precisión-simple.
Precisión de la máquina
El grado de precisión que uno puede obtener en una variable se llama la precisión de la máquina (machine precision), $\epsilon$.
No importa el programa que escribamos, ni el algoritmo que usamos, nunca calcularemos
valores a una precisión mayor que la precisión de la máquina.
Las arquitecturas de computadores modernos son de
64-bits.
Notese que algunos lenguajes permiten control en la cantidad de memoria asignada a variables de
distintos tipos, y por eso podemos obtener valores muy pequeños para $\epsilon$ (es decir,
precisión extremadamente alta).
Ejemplo: errores al multiplicar un número muy pequeño por otro muy
grande:
La constante gravitacional en unidades SI: $6,67408 \times 10^{-11}$ m$^3$ kg$^{-1}$ $s^{-2}$.
La masa solar en unidades SI: $2 \times 10^{30}$ kg.
Al multiplicar ambas cantidades, el septimo dígito (último
digito válido en precisión simple) del resultado será un número de magnitud $10^{13}$!
Este número podría ser suficientemente grande para modificar otros números en el cálculo e
introducir errores.
Números que no se puede representar exactamente en binario
Otro problema: existen números que no podemos representar exactamente en el sistema binario.
En el sistema decimal, no se puede representar $1/3$ exactamente (es igual a $0.3333333\ldots$).
Números que no se puede representar exactamente en binario
Por las mismas razones, hay fracciones que no se puede representar exactamente en binario.
Un ejemplo común es el número $0.1$ (una representación exacta de $1/10$ en el sistema
decimal).
En binario, este tiene la representación $0.000110011001100110011\ldots$.
Por lo tanto, nunca tendremos el valor $0.1$
representado exactamente en el computador - siempre habrá un error del tamaño de la
precisión de la máquina.
Resumen
Algoritmos son procesos estructurados diseñados para resolver un problema especifico.
El primer paso, en cualquier tarea computacional, es pensar sobre el algoritmo, diseñarlo y
planificarlo.
La implementación de un algorítmo es un programa.
Un algoritmo bueno es rápido y preciso.
La complejidad algorítmica: como escala su tiempo de ejecución con la cantidad de datos que
usamos como entrada al
algoritmo.