Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

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 T(n)T(n) la función que indica cuánto tiempo va a tardar un algoritmo en función del tamaño de la entrada nn, donde nn es un número natural, entonces:

Esto quiere decir que la tasa de crecimiento de T(n)T(n) es menor o igual a la tasa de crecimiento de f(n)f(n). Es decir la tasa de crecimiento de T(n)T(n) está acotada por arriba por f(n),  n>n0f(n), \; \forall n > n_0.

Por ejemplo, si tenemos la función T(n)=4n+1T(n) = 4 n + 1, se verifica que es de la familia de O(n)O(n), es decir la tasa de crecimiento de T(n)T(n) está acotada por f(n)=nf(n) = n, ya que:

  c=5,  n0=1\exists \; c = 5, \; n_0 = 1

tal que:

5n4n+1,  n15 n \ge 4 n + 1, \; \forall n \ge 1
Función Acotada: T(n) \subset O(n)

Función Acotada: T(n)O(n)T(n) \subset O(n)

Función Acotada: T(n) \subset O(n)

Función Acotada: T(n)O(n)T(n) \subset O(n)

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

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

OrdenNombre
O(1)O(1)constante
O(log2(n))O(log_2(n))logarítmica
O(n)O(n)lineal
O(n  log2(n))O(n \; log_2(n))casi lineal
O(n2)O(n^2)cuadrática
O(kn)O(k^n)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:

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.

Condicionales
if  C  then  S1  else  S2\texttt{if} \; \langle C \rangle \; \texttt{then} \; \langle S_1 \rangle \; \texttt{else} \; \langle S_2 \rangle

entonces:

T(n)=T(C)+max(T(S1),T(S2))T(n) = T(C) + \max(T(S_1), T(S_2))

El tiempo de ejecución de un condicional se calcula como el tiempo para evaluar la condición CC más el máximo entre el cuerpo del then\texttt{then} y el else\texttt{else}, considerando el peor caso de ejecución.

Ciclos
for  C  {  S  }\texttt{for} \; \langle C \rangle \; \{ \; \langle S \rangle \; \}

entonces:

T(n)=T(C)+iteraciones(T(C)+T(S))T(n) = T(C) + \langle iteraciones \rangle \cdot (T(C) + T(S))

El tiempo de ejecución de un ciclo se calcula como el tiempo de evaluar la condición por primera vez, para ver si se ejecuta el ciclo, más la cantidad de iteraciones multiplicado por la suma de evaluar nuevamente la condición en cada iteración más el tiempo de todas las sentencias del cuerpo del ciclo.

Ejecución de Funciones
F(P1,P2,P3,,Pn)  {  S  }F(P_1, P_2, P_3, \dots, P_n) \; \{ \; \langle S \rangle \; \}

entonces:

T(n)=1+T(P1)+T(P2)+T(P3)++T(Pn)+T(S)T(n) = 1 + T(P_1) + T(P_2) + T(P_3) + \dots +T(P_n) + T(S)

El tiempo de ejecución de una función se calcula como 1 (operación elemental de llamar a la función) más el tiempo de evaluar cada uno de los parámetros, más el tiempo de ejecutar todas las instrucciones en el cuerpo de la función.

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
8
func 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 O(1)O(1).

Evaluar la condición arreglo[i] == objetivo (línea 3) también es O(1)O(1) ya que se trata de accesos a valores y una comparación, por lo tanto:

T(condicional)=O(1)T(\langle condicional \rangle) = O(1)

Entonces dentro del ciclo for tenemos un condicional de O(1)O(1) y la actualización del índice (i++) que también es una operación simple y por lo tanto es O(1)O(1). Podemos concluir que todo el cuerpo del ciclo es O(1)O(1).

T(ciclo)=O(1)+nO(1)=O(n)T(\langle ciclo \rangle) = O(1) + n \, O(1) = O(n)

ya que evaluar la condición del ciclo, i < len(arreglo), también es O(1)O(1) (donde nn es la longitud del arreglo).

La línea 7 también es O(1)O(1).

T(n)=O(1)+O(n)+O(1)=O(n)T(n) = O(1) + O(n) + O(1) = O(n)

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
17
func 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 O(1)O(1). 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:

T(n)=T(n2)+cT(n) = T \left( \frac{n}{2} \right) + c

Donde T(n2)T(\frac{n}{2}) representa que en cada vuelta del ciclo mientras se descarta la mitad del arreglo. La constante cc representa todas las operaciones O(1)O(1) que se realizan en cada vuelta del ciclo. Para resolverla podemos suponer que n es una potencia de 2. Es decir:

n=2kn = 2^k

Reemplazando obtenemos:

Primera vuelta del ciclo

T(n)=T(2k2)+c=T(2k1)+cT(n) = T \left( \frac{2^k}{2} \right) + c = T(2^{k-1}) + c

Segunda vuelta del ciclo

T(n)=T(2k2)+2cT(n) = T(2^{k-2}) + 2 \, c
\dots

Vuelta ii del ciclo

T(n)=T(2ki)+icT(n) = T(2^{k-i}) + i \, c

entonces para i=ki = k

Vuelta kk del ciclo

T(n)=T(1)+kcT(n) = T(1) + k \, c

T(1)T(1) 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 T(1)=O(1)T(1) = O(1)

podemos despejar kk de la ecuación n=2kn = 2^k y nos queda que k=log2(n)k = log_2(n). Finalmente queda:

T(n)=O(1)+c  log2(n)T(n) = O(1) + c \; log_2(n)
T(n)=O(log2(n))T(n) = O(log_2(n))

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

  1. 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
    7
    FUNCION maximo(x, y)
        SI x >= y ENTONCES
            RETORNAR x
        SINO
            RETORNAR y
        FIN SI
    FIN FUNCION

    Algoritmo máximo

  2. Potencia. Dado el siguiente pseudocódigo, calculá la cantidad de operaciones elementales y determiná su O grande:

    1
    2
    3
    4
    5
    6
    7
    FUNCION potencia(x, n)
        resultado ← 1.0
        PARA i ← 0 HASTA n-1 HACER
            resultado ← resultado * x
        FIN PARA
        RETORNAR resultado
    FIN FUNCION

    Algoritmo potencia

  3. 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
    8
    FUNCION listarAlumnos(a)
        i ← 0
        MIENTRAS i < LONGITUD(a) HACER
            ESCRIBIR a[i].nombre + " " + a[i].apellido
            i ← i + 1
        FIN MIENTRAS
        RETORNAR
    FIN FUNCION

    Algoritmo listar alumnos

  4. Fragmentos de código. Para cada uno de los siguientes fragmentos, determiná el tiempo de ejecución en notación O grande:

    1
    2
    3
    PARA i ← 0 HASTA n-1 HACER
        sum ← sum + 1
    FIN PARA

    Fragmento 1

    1
    2
    3
    PARA i ← 0 HASTA n-1 PASO 2 HACER
        sum ← sum + 1
    FIN PARA

    Fragmento 2

    1
    2
    3
    4
    5
    PARA i ← 0 HASTA n-1 HACER
        PARA j ← 0 HASTA n-1 HACER
            sum ← sum + 1
        FIN PARA
    FIN PARA

    Fragmento 3

    1
    2
    3
    4
    5
    6
    PARA i ← 0 HASTA n-1 HACER
        sum ← sum + 1
    FIN PARA
    PARA j ← 0 HASTA n-1 HACER
        sum ← sum + 1
    FIN PARA

    Fragmento 4

    1
    2
    3
    4
    5
    PARA i ← 0 HASTA n-1 HACER
        PARA j ← 0 HASTA n*n-1 HACER
            sum ← sum + 1
        FIN PARA
    FIN PARA

    Fragmento 5

    1
    2
    3
    4
    5
    PARA i ← 0 HASTA n-1 HACER
        PARA j ← 0 HASTA i-1 HACER
            sum ← sum + 1
        FIN PARA
    FIN PARA

    Fragmento 6

    1
    2
    3
    4
    5
    6
    7
    PARA 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 PARA

    Fragmento 7