October 21, 2020

Big O - ejemplos básicos

Ver explicación sobre "Big O" antes de profundizar en estos ejemplos básicos de algoritmos.

O(1) - constante

Es el mejor resultado, y quiere decir que el tiempo de ejecución no varía conforme aumenta el tamaño de los datos de entrada, y la respuesta siempre tarda lo mismo sin importar la magnitud de entrada.

Ejemplo: Acceso directo en un array de datos

const fruits = ["apple", "orange", "pear", "kiwi", "melon"]

const consoleFirstItem = items => {
  console.log(items[0])
}

consoleFirstItem(fruits)

O(n) - lineal

El crecimiento es lineal en tanto el tiempo de ejecución es cada vez mayor de modo proporcional a cómo se incrementa el tamaño de la entrada. Por lo que si tenemos el doble de elementos de entrada, tardará el doble, aunque despreciamos realmente la pendiente de la misma y sólo nos quedamos con que aumenta de forma lineal.

Ejemplo: recorrer un array en un ciclo for

Tanto en el caso de un valor de entrada de tamaño N

function consoleNumbersTo(n) {
  for (let i = 1; i <= n; i++) {
    console.log(i)
  }
}

consoleNumbersTo(10)

O igualmente un array de N elementos

const fruits = ["apple", "orange", "pear", "kiwi", "melon"]

const consoleAlltItems = items => {
  items.forEach(item => console.log(item))
}

consoleAlltItems(fruits)

No importa si en cada ciclo realizamos más o menos trabajo ya que aunque pudiera parecer que tenemos un logaritmo que tarda el doble o el triple, algo como O(3n) lo simplificamos a O(n)

function consoleNumbersTo(n) {
  for (let i = 1; i <= n; i++) {
    console.log(i)
  }
  for (let i = 1; i <= n; i++) {
    console.log(i)
  }
  for (let i = 1; i <= n; i++) {
    console.log(i)
  }
}

consoleNumbersTo(10)

O(n^2) - cuadrática

El crecimiento es de forma exponencial por lo que será un algoritmo a evitar ya que para valores pequeños de entrada el tiempo será asumible, pero conforme aumente el tamaño de los datos de entrada el tiempo tenderá a ser muy elevado y es probable que el procesador se quede inoperativo.

Ejemplo: recorrer un ciclo for anidado a otro

function consoleMultiplicationOf(n) {
  for (let i = 1; i <= n; i++) {
    for (let j = 1; j <= n; j++) {
      console.log(`${i} * ${j} = ${i * j}`)
    }
  }
}

consoleMultiplicationOf(10)
const fruits = ["apple", "orange", "pear", "kiwi", "melon"]
const isVowel = char => ["a", "e", "i", "o", "u"].includes(char.toLowerCase())

const consoleAllVowels = items => {
  items.forEach(item => {
    item.split("").forEach(char => {
      if (isVowel(char)) {
        console.log(char)
      }
    })
  })
}

consoleAllVowels(fruits)

Del mismo modo si tenemos un algorítmo que primero hace una operación O(n) y seguidamente otro paso de O(n^2), al final lo simplificamos al más desfavorable: O(n+n^2) -> O(n^2)

function consoleMultiplicationOf(n) {
  for (let i = 1; i <= n; i++) {
    console.log(i)
  }

  for (let i = 1; i <= n; i++) {
    for (let j = 1; j <= n; j++) {
      console.log(`${i} * ${j} = ${i * j}`)
    }
  }
}

consoleMultiplicationOf(10)

Aunque a efectos de análisis de Big O las constantes de tiempo se desprecien, en la realidad esta constante sí que puede ser importante si por ejemplo supone un tiempo significativo que haga un algorítmo reducir o ampliar el tiempo que tarda en procesarse en base a por ejemplo a esos pasos extras que amplian el tiempo.

Peor caso

En general hablamos siempre del peor de los casos, porque podemos tener algoritmos de busqueda que en ocasiones encuentren el elemento buscado a la primera lo que sería en un O(1) pero en otros casos que tiene que ir hasta el último elemento, y será este el que normalmente se usa para especificar el valor Big O de un algoritmo, atendiendo al peor de los casos.

Ejemplo: buscando un elemento recorriendo todos los datos

const fruits = ["apple", "orange", "pear", "kiwi", "melon"]

const bruteSearch = (arr, item) => {
  for (let i = 0; i < arr.length; i++) {
    if (arr[i] === item) {
      return `Found ${item} at ${i + 1}`
    }
  }
}

bruteSearch(fruits, "kiwi")

O(log n) - logarítmica

Una forma de crecimiento que crece al inicio pero tiende a estabilizarse conforme aumentan el tamaño de entrada, por lo que es una buena nota para un algoritmo ya que no tiende a resentirse.

El ejemplo anterior mejorado sustancialmente usando: Binary Search

Se llama busqueda binaria o busqueda logarítmica, ya que no aumenta apenas conforme aumenta el tamaño de los datos a evaluar, con motivo de que los datos de partida están ordenados, y que en cada paso descarta la mitad de los datos: compara el valor con el elemento en el medio del array, si no son iguales, la mitad en la cual el valor no puede estar es eliminada y la búsqueda continúa en la mitad restante hasta que el valor se encuentre.

Dada la complejidad del mismo si no se ha visto antes, lo mejor es tratarlo en un artículo aparte. :)

function binarySearch(sortedArray, key) {
  let start = 0
  let end = sortedArray.length - 1

  while (start <= end) {
    let middle = Math.floor((start + end) / 2)

    if (sortedArray[middle] === key) {
      // found the key
      return middle
    } else if (sortedArray[middle] < key) {
      // continue searching to the right
      start = middle + 1
    } else {
      // search searching to the left
      end = middle - 1
    }
  }
  // key wasn't found
  return -1
}


Si te ha gustado... ¡Compártelo!