Como ya mencionamos antes, podemos entender a un algoritmo como un método para resolver un problema dado en una computadora. Un algoritmo describe los pasos que se deben seguir para alcanzar el resultado buscado.
Para un problema cualquiera, puede existir más de un algoritmo que lo resuelva. Un algoritmo puede consumir más o menos recursos que otro que resuelve el mismo problema, por lo tanto una de las principales variables de análisis generalmente es la eficiencia en el uso de los recursos.
Otro aspecto a tener en cuenta al analizar algoritmos es la simplicidad. Un algoritmo simple es más fácil de entender, adaptar y mantener, por eso a veces prima la simplicidad a la hora de elegir un algoritmo.
La simplicidad y la eficiencia no son conceptos antagónicos. Es decir, un algoritmo puede ser simple y eficiente a la vez. Estas métricas sirven principalmente para comparar algoritmos entre sí y así poder elegir el más adecuado para una situación concreta.
1Complejidad¶
En esta definición de complejidad entra en juego una variable más, la cantidad o el tamaño de los datos, por ejemplo si tenemos que ordenar pocos datos, y rara vez se agregan nuevos datos o se modifican los existentes, quizás prime la simplicidad del algoritmo a la hora de elegir alguno de los algoritmos de ordenamiento, pero si tenemos que ordenar millones de datos, donde además se agregan cientos o miles de datos y se modifican otros muy frecuentemente, es probable que nos interese analizar cuánto tiempo va a demorar y cuánta memoria o espacio en disco vamos a necesitar y más aún, cuánto más necesitaremos a medida que aumente el volumen de datos, es decir, es probable que prime la eficiencia a la hora de elegir.
Informalmente hablando, cuanto mayor sea la complejidad de un algoritmo, será menos eficiente en el uso de los recursos.
La complejidad es independiente del hardware o la máquina donde se ejecuta el algoritmo. No debemos confundir complejidad con rendimiento.
El rendimiento sí depende del hardware. Por ejemplo en la medida que aumenta la velocidad del procesador, se necesitará menos tiempo para completar una tarea dada. En cambio la complejidad nos indica cuanto más tiempo o memoria va a requerir el algoritmo en función del tamaño de los datos.
En esta primera aproximación al estudio del análisis de algoritmos, nos vamos a enfocar en la complejidad temporal. Como la complejidad temporal es una métrica independiente del hardware donde se ejecuta el programa, normalmente se estima contando la cantidad de operaciones elementales que realiza el algoritmo bajo análisis para completar el cálculo, teniendo en cuenta que cada unidad elemental requiere una cantidad fija de tiempo, que por simplicidad se asume como una unidad de tiempo.
Como la cantidad de operaciones elementales que debe realizar depende de los datos, se toma siempre el peor caso, para poder obtener una cota confiable. Por ejemplo si hay que buscar un elemento en un arreglo desordenado, no queda otra que buscar el elemento en todas las posiciones del arreglo. En el peor caso se deberá recorrer todo el arreglo para encontrar el elemento en la última posición escrutada o poder concluir que el elemento no se encuentra, es probable que si ejecutamos nuestro algoritmo muchas veces, algunas veces lo encuentre antes de recorrer todo el arreglo, pero como buscamos una cota, se toma siempre el peor caso.
2Cota Superior Asintótica (O grande)¶
La cota superior asintótica, O grande o Big-O en inglés, es parte de la familia de notaciones asintóticas, también conocidas como notaciones de Bachmann-Landau. En ciencias de la computación, la O grande, permite clasificar a los algoritmos de acuerdo a como aumenta la cantidad de recursos que necesita en la medida que aumenta el tamaño de la entrada, es decir nos permite clasificar algoritmos en el límite, cuando el tamaño de la entrada tiende a infinito. Sea la función que indica cuánto tiempo va a tardar un algoritmo en función del tamaño de la entrada , donde es un número natural, entonces:
Esto quiere decir que la tasa de crecimiento de es menor o igual a la tasa de crecimiento de . Es decir la tasa de crecimiento de está acotada por arriba por .
Por ejemplo, si tenemos la función , se verifica que es de la familia de , es decir la tasa de crecimiento de está acotada por , ya que:
tal que:
Función Acotada:
Función Acotada:
A continuación se muestran algunas tasas de crecimiento de las funciones que comúnmente se encuentran al calcular la O grande.
Comparación de tasas de crecimiento
Comparación de tasas de crecimiento
En la siguiente tabla se muestran algunas de las funciones más comunes y sus nombres
| Orden | Nombre |
|---|---|
| constante | |
| logarítmica | |
| lineal | |
| casi lineal | |
| cuadrática | |
| exponencial |
2.1Propiedades de la O grande¶
3Cálculo de la O grande¶
Como ya se mencionó, para poder calcular el orden de un algoritmo se necesita contar cuantas operaciones elementales realiza.
3.1Operaciones elementales (OE)¶
Las operaciones elementales son lo que tienen un costo de 1 es decir cuyo tiempo de ejecución es 1 (una unidad genérica de tiempo).
Las siguientes son OE:
Asignación o consulta de una variable simple.
Operaciones aritméticas elementales de valores simples (suma, resta, multiplicación, división y resto).
Comparaciones (mayor, mayor igual, igual, distinto, menor y menor igual).
Operaciones lógicas (and, or y not).
Acceso indexado a un elemento simple de un arreglo.
Desreferenciar un puntero.
3.2Operaciones complejas¶
Por operaciones complejas entendemos a los condicionales, los ciclos y la ejecución de funciones. Estas operaciones en general pueden anidar otras operaciones.
3.3Ejemplos de cálculo¶
3.3.1Búsqueda Lineal¶
En este ejemplo vamos a implementar el algoritmo de búsqueda lineal para buscar un elemento dentro de un arreglo. El algoritmo se puede enunciar como:
A continuación una implementación en Go:
1 2 3 4 5 6 7 8func busquedaLineal(arreglo []int, objetivo int) int { for i := 0; i < len(arreglo); i++ { if arreglo[i] == objetivo { return i } } return -1 }
Para calcular el tiempo de ejecución de un algoritmo, conviene empezar por las operaciones elementales que se encuentran más anidadas. En este ejemplo la línea 4 dentro del condicional es .
Evaluar la condición arreglo[i] == objetivo (línea 3) también es ya que se trata de accesos a valores y una comparación, por lo tanto:
Entonces dentro del ciclo for tenemos un condicional de y la actualización del índice (i++) que también es una operación simple y por lo tanto es . Podemos concluir que todo el cuerpo del ciclo es .
ya que evaluar la condición del ciclo, i < len(arreglo), también es (donde es la longitud del arreglo).
La línea 7 también es .
3.3.2Búsqueda Binaria¶
Implementación en Go:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17func busquedaBinaria(lista []int, elemento int) int { L := 0 R := len(lista) - 1 for L <= R { M := (L + R) / 2 if lista[M] < elemento { L = M + 1 continue } if lista[M] > elemento { R = M - 1 continue } return M // Se encontró el elemento } return -1 // No se encontró }
Para analizar la búsqueda binaria, la primera observación que podemos realizar es que los condicionales son . Siguiendo el mismo razonamiento, todas las instrucciones que se realizan fuera del ciclo for también son OE. Por lo tanto podemos plantear la siguiente ecuación de recurrencia:
Donde representa que en cada vuelta del ciclo mientras se descarta la mitad del arreglo. La constante representa todas las operaciones que se realizan en cada vuelta del ciclo. Para resolverla podemos suponer que n es una potencia de 2. Es decir:
Reemplazando obtenemos:
Primera vuelta del ciclo
Segunda vuelta del ciclo
Vuelta del ciclo
entonces para
Vuelta del ciclo
es cuanto cuesta si el arreglo tiene tamaño 1, es decir L == R, y por lo tanto solo ejecuta una iteración del bucle para determinar si el elemento es el buscado o ajustar los índices para salir. Entonces
podemos despejar de la ecuación y nos queda que . Finalmente queda:
Es decir al tener el arreglo ordenado, la búsqueda binaria necesita realizar mucho menos operaciones que la búsqueda lineal para encontrar el valor buscado o determinar que no existe. La clave está en descartar la mitad del arreglo en cada vuelta del ciclo for.
4Ejercicios¶
Máximo entre dos números. Dado el siguiente pseudocódigo, calculá la cantidad de operaciones elementales que ejecuta y determiná su O grande:
1 2 3 4 5 6 7FUNCION maximo(x, y) SI x >= y ENTONCES RETORNAR x SINO RETORNAR y FIN SI FIN FUNCIONAlgoritmo máximo
Potencia. Dado el siguiente pseudocódigo, calculá la cantidad de operaciones elementales y determiná su O grande:
1 2 3 4 5 6 7FUNCION potencia(x, n) resultado ← 1.0 PARA i ← 0 HASTA n-1 HACER resultado ← resultado * x FIN PARA RETORNAR resultado FIN FUNCIONAlgoritmo potencia
Recorrer un arreglo. Dado el siguiente pseudocódigo, calculá la cantidad de operaciones elementales y determiná su O grande:
1 2 3 4 5 6 7 8FUNCION listarAlumnos(a) i ← 0 MIENTRAS i < LONGITUD(a) HACER ESCRIBIR a[i].nombre + " " + a[i].apellido i ← i + 1 FIN MIENTRAS RETORNAR FIN FUNCIONAlgoritmo listar alumnos
Fragmentos de código. Para cada uno de los siguientes fragmentos, determiná el tiempo de ejecución en notación O grande:
1 2 3PARA i ← 0 HASTA n-1 HACER sum ← sum + 1 FIN PARAFragmento 1
1 2 3PARA i ← 0 HASTA n-1 PASO 2 HACER sum ← sum + 1 FIN PARAFragmento 2
1 2 3 4 5PARA i ← 0 HASTA n-1 HACER PARA j ← 0 HASTA n-1 HACER sum ← sum + 1 FIN PARA FIN PARAFragmento 3
1 2 3 4 5 6PARA i ← 0 HASTA n-1 HACER sum ← sum + 1 FIN PARA PARA j ← 0 HASTA n-1 HACER sum ← sum + 1 FIN PARAFragmento 4
1 2 3 4 5PARA i ← 0 HASTA n-1 HACER PARA j ← 0 HASTA n*n-1 HACER sum ← sum + 1 FIN PARA FIN PARAFragmento 5
1 2 3 4 5PARA i ← 0 HASTA n-1 HACER PARA j ← 0 HASTA i-1 HACER sum ← sum + 1 FIN PARA FIN PARAFragmento 6
1 2 3 4 5 6 7PARA i ← 0 HASTA n-1 HACER PARA j ← 0 HASTA n*n-1 HACER PARA k ← 0 HASTA j-1 HACER sum ← sum + 1 FIN PARA FIN PARA FIN PARAFragmento 7