17. Conteo de operaciones

Contar operaciones permite estimar el costo de un algoritmo a partir de su estructura. Es el primer paso para comparar soluciones sin depender de la velocidad de una computadora particular.

17.1 Introducción

Un algoritmo ejecuta instrucciones: asignaciones, comparaciones, sumas, accesos a estructuras y llamadas. El conteo de operaciones construye una función T(n) que expresa cuántas acciones relevantes se realizan para una entrada de tamaño n.

No buscamos contar exactamente cada ciclo de procesador. Elegimos un modelo simple y coherente que permite descubrir cómo cambia el trabajo cuando la entrada crece. Luego usaremos notación asintótica para ignorar detalles que no dominan el crecimiento.

17.2 ¿Qué es una operación elemental?

En un modelo básico, tratamos como operaciones de costo constante las acciones que no dependen del tamaño de la entrada: asignar una variable, comparar dos números, sumar dos números acotados, acceder a un elemento por índice o devolver un resultado.

InstrucciónCosto simplificadoObservación
x = 0ConstanteUna asignación.
a < bConstanteUna comparación.
suma += valorConstanteSuma y asignación.
arreglo[i]Constante en un arregloAcceso directo por índice.
ordenar(arreglo)No necesariamente constanteDepende del algoritmo usado.

El modelo debe respetar la estructura del problema. Por ejemplo, comparar enteros de tamaño fijo se considera constante, pero comparar cadenas muy largas puede depender de su longitud.

17.3 Tamaño de entrada

Antes de contar debemos definir n. En un arreglo, n suele ser la cantidad de elementos. En una matriz puede haber filas y columnas; en un grafo, vértices y aristas; en un entero, la cantidad de bits necesarios para representarlo.

Arreglo: n = cantidad de elementos.
Matriz: n = filas y m = columnas.
Grafo: V = vértices, E = aristas.
Número entero: n = cantidad de dígitos o bits.

Sin una definición de tamaño, «rápido» o «lento» no tiene precisión matemática.

17.4 Secuencia de instrucciones

Cuando las instrucciones se ejecutan una después de otra, sus costos se suman. Si una parte realiza c1 operaciones y la siguiente c2, el total es c1 + c2.

x = 0;      costo constante
y = x + 5;    costo constante
return y;   costo constante

Total: c1 + c2 + c3 = Θ(1).

Agregar una cantidad fija de instrucciones no cambia la clase de complejidad. Un algoritmo que ejecuta 5 o 500 operaciones constantes sigue siendo Θ(1) respecto de n.

17.5 Un bucle simple

Un bucle que se repite n veces y ejecuta una operación constante en cada iteración tiene costo lineal. También debemos considerar inicialización, comparación de salida e incremento, aunque todos son proporcionales a n.

for (let i = 0; i < n; i++) {
  suma += i;
}

Inicialización: 1 vez.
Comparación: n + 1 veces.
Incremento: n veces.
Cuerpo: n veces.

T(n) = an + b = Θ(n).

17.6 Contar operaciones en una suma

function sumarYContar(n) {
  let suma = 0;
  let sumas = 0;

  for (let i = 1; i <= n; i++) {
    suma += i;
    sumas++;
  }

  return { suma, sumas };
}

console.log(sumarYContar(10)); // { suma: 55, sumas: 10 }

La función cuenta las operaciones principales de suma. Para n = 10 realiza diez sumas; para n = 1 000 000 realiza un millón. La relación principal es lineal.

17.7 Bucles anidados independientes

Si un bucle de n iteraciones contiene otro bucle de n iteraciones, el cuerpo interno se ejecuta n · n = n² veces. Los bucles anidados multiplican sus repeticiones cuando sus rangos son independientes.

for i = 0 hasta n - 1
  for j = 0 hasta n - 1
    operación constante

Cantidad de ejecuciones del cuerpo: n².
Complejidad: Θ(n²).

17.8 Contar pares de elementos

function contarParesOrdenados(n) {
  let operaciones = 0;

  for (let i = 0; i < n; i++) {
    for (let j = 0; j < n; j++) {
      operaciones++;
    }
  }

  return operaciones;
}

console.log(contarParesOrdenados(5)); // 25

El algoritmo realiza una operación por cada par ordenado (i, j). Para n = 5 hay 5² = 25 pares; para n = 100 hay 10 000.

17.9 Bucles anidados dependientes

Los bucles anidados no siempre realizan n² iteraciones. Si el límite interno depende del índice externo, debemos sumar la cantidad de repeticiones de cada fila.

for i = 0 hasta n - 1
  for j = 0 hasta i
    operación constante

Iteraciones: 1 + 2 + ... + n = n(n + 1)/2.
Complejidad: Θ(n²).

La constante 1/2 no cambia la clase asintótica, pero el conteo exacto revela que este algoritmo realiza aproximadamente la mitad de las iteraciones de uno con dos bucles independientes.

17.10 Contar combinaciones sin repetición

function contarParesSinRepeticion(n) {
  let pares = 0;

  for (let i = 0; i < n; i++) {
    for (let j = i + 1; j < n; j++) {
      pares++;
    }
  }

  return pares;
}

console.log(contarParesSinRepeticion(5)); // 10

La cantidad es (n - 1) + (n - 2) + ... + 1 = n(n - 1)/2. El algoritmo cuenta cada par no ordenado una sola vez y sigue teniendo crecimiento cuadrático.

17.11 Bucles que duplican o dividen

Un bucle no siempre incrementa de a uno. Si una variable se duplica en cada iteración, el número de repeticiones es logarítmico. Después de k duplicaciones, su valor es 2k.

let i = 1;
while (i < n) i *= 2;

Termina cuando 2k ≥ n.
Por lo tanto, k es aproximadamente log2(n).
Complejidad: Θ(log n).

17.12 Contar duplicaciones

function contarDuplicaciones(limite) {
  let valor = 1;
  let pasos = 0;

  while (valor < limite) {
    valor *= 2;
    pasos++;
  }

  return { valorFinal: valor, pasos };
}

console.log(contarDuplicaciones(100)); // { valorFinal: 128, pasos: 7 }

Solo se necesitan siete duplicaciones para superar 100. Para superar aproximadamente un millón se necesitan cerca de veinte, lo que ilustra la lentitud del crecimiento logarítmico.

17.13 Condicionales y casos de costo

En un condicional se cuenta el costo de evaluar la condición más el costo de la rama que se ejecuta. Si el algoritmo puede seguir rutas diferentes, debemos especificar si analizamos el mejor caso, el peor caso o el costo promedio.

MedidaDescripciónEjemplo de búsqueda lineal
Mejor casoMenor costo entre entradas de tamaño n.El valor está en la primera posición.
Peor casoMayor costo entre entradas de tamaño n.No está o aparece al final.
Caso promedioCosto esperado bajo una distribución definida.Depende de cómo se distribuyan los valores buscados.

17.14 Búsqueda lineal: mejor y peor caso

function buscarYContar(numeros, buscado) {
  let comparaciones = 0;

  for (let i = 0; i < numeros.length; i++) {
    comparaciones++;
    if (numeros[i] === buscado) {
      return { indice: i, comparaciones };
    }
  }

  return { indice: -1, comparaciones };
}

console.log(buscarYContar([4, 8, 15, 16, 23], 4)); // { indice: 0, comparaciones: 1 }
console.log(buscarYContar([4, 8, 15, 16, 23], 42)); // { indice: -1, comparaciones: 5 }

La búsqueda puede terminar en una comparación o necesitar n comparaciones. Su complejidad de peor caso es Θ(n), aunque su mejor caso sea Θ(1).

17.15 Operación dominante

En un algoritmo real hay muchas instrucciones, pero algunas se repiten con mayor frecuencia y dominan el costo. Contar una operación representativa simplifica el análisis sin perder la tendencia principal.

En búsqueda lineal: comparación numeros[i] === buscado.
En ordenamiento por comparación: comparación entre elementos.
En multiplicación de matrices: producto y suma de entradas.

Elegir la operación dominante no elimina las demás, sino que resume la parte que más crece con n.

Si una operación aparentemente constante oculta una tarea costosa, como copiar un arreglo entero, debemos contar esa tarea de forma explícita.

17.16 Conteo exacto y orden de crecimiento

El conteo exacto puede producir expresiones como 3n² + 7n + 12. Para entradas grandes, el término de mayor grado domina. Por eso 3n² + 7n + 12 tiene crecimiento cuadrático.

n = 10: 3n² + 7n + 12 = 382.
n = 1 000: 3n² + 7n + 12 = 3 007 012.

El término 3n² explica casi todo el crecimiento para n grande.
En notación asintótica: Θ(n²).

La notación Big-O, Omega y Theta formalizará esta comparación en los próximos temas.

17.17 Conteo en algoritmos con más de un parámetro

No todos los algoritmos dependen de un único tamaño n. Si recorremos una matriz de r filas y c columnas, el cuerpo se ejecuta r · c veces. Si recorremos un grafo, el costo puede depender de V y E.

for cada fila r
  for cada columna c
    operación

T(r, c) = Θ(rc).
Si r = c = n, entonces T(n) = Θ(n²).

Forzar todos los tamaños a una sola variable puede ocultar la estructura importante del problema. Conviene mantener los parámetros independientes mientras sea relevante.

17.18 Errores frecuentes

  • No definir qué representa el tamaño de entrada.
  • Suponer que los bucles anidados siempre producen n² operaciones.
  • Ignorar que el límite de un bucle interno depende del externo.
  • Confundir una operación repetida n veces con una operación de costo constante total.
  • Analizar solo el mejor caso sin indicar que se está haciendo.
  • Ignorar costos ocultos como copias, ordenamientos o recorridos dentro de una llamada.

17.19 Qué debes recordar y conclusión

  • El conteo de operaciones produce una función T(n) que describe el costo de un algoritmo.
  • Las instrucciones secuenciales suman sus costos y los bucles multiplican el costo de su cuerpo por las iteraciones.
  • Un bucle simple suele ser lineal; dos bucles independientes suelen ser cuadráticos.
  • Los bucles con límite dependiente se analizan mediante sumas.
  • Duplicar o dividir el tamaño produce cantidades logarítmicas de iteraciones.
  • Debemos distinguir mejor, peor y caso promedio.
  • El término dominante del conteo exacto determina el orden de crecimiento.

Contar operaciones permite transformar código en una expresión matemática y descubrir cómo escala un algoritmo. En el próximo tema estudiaremos el crecimiento de funciones para comparar esos costos con mayor precisión.