Tema 1: Análisis de algoritmos

Qué se analiza

Para un mismo problema suele haber varios programas, y se pueden comparar por muchos criterios (legibilidad, interfaz, memoria, consumo…). En este tema el criterio es la eficiencia: el mejor aprovechamiento de los recursos.

Se estudian los algoritmos (los métodos), no los programas (sus implementaciones en un lenguaje concreto).

Hay dos recursos:

  • Complejidad espacial: memoria que consume (memory-bound).
  • Complejidad temporal: tiempo que tarda (compute-bound).

Suelen ser objetivos contrapuestos: a menudo se gana tiempo gastando más memoria, y hay que buscar un compromiso. El tema se centra en la temporal, pero todo vale igual para la espacial.

Análisis teórico y experimental

Análisis teórico (a priori)Análisis experimental (a posteriori)
Qué haceObtiene una expresión matemática del coste en función de los parámetrosEjecuta casos de prueba y mide tiempos
Depende deSolo del algoritmoMáquina, lenguaje, compilador y datos concretos
Sirve paraGeneralizarCaracterizar el programa en sus condiciones de uso

El análisis teórico interesa porque permite predecir el coste sin implementar el algoritmo y se puede usar ya en la fase de diseño.


Talla, paso y coste temporal

Talla del problema: valor o valores de la entrada que miden su tamaño.

EntradaTalla
VectorNúmero de elementos
MatrizDimensiones
NúmeroEl propio número

Paso: fragmento de código cuyo tiempo no depende de la talla y está acotado por una constante. Es lo que se suele llamar operación elemental: operaciones aritméticas y lógicas, comparaciones, accesos a variables o a elementos de vectores, asignaciones, lecturas, retornos…

Como cada paso tarda un tiempo acotado en cualquier máquina, se supone que todos tardan lo mismo, y el coste se mide en pasos, no en segundos.

Coste temporal de un algoritmo: función que da el número de pasos que necesita el algoritmo para cada talla posible.

Principio de invarianza: dos implementaciones del mismo algoritmo solo se diferencian en una constante multiplicativa. Si t1(n)t_1(n) y t2(n)t_2(n) son sus tiempos:

∃ c∈R+, ∃ n0∈N:t1(n)≤c t2(n)∀n≥n0\exists\, c \in \mathbb{R}^+,\ \exists\, n_0 \in \mathbb{N} : \quad t_1(n) \le c\, t_2(n) \quad \forall n \ge n_0

Un factor 10 o 100 importa poco frente a una diferencia en cómo crece el coste con la talla: para tallas grandes, eso es lo que decide. Por eso el algoritmo es más importante que el programa.

Ejemplo: tres formas de calcular n2n^2

Funcion Solucion1 (n: entero) retorna (m: entero)
  m = n*n;                      // 2 pasos
  retorna m                     // 1 paso
ffuncion

Funcion Solucion2 (n: entero) retorna (m: entero)
  m = 0;                        // 1 paso
  para i = 1 hasta n hacer      // 2n + 2 pasos
    m = m + n;                  // 2 pasos, n veces
  fpara
  retorna m                     // 1 paso
ffuncion

Funcion Solucion3 (n: entero) retorna (m: entero)
  m = 0;                        // 1 paso
  para i = 1 hasta n hacer      // 2n + 2 pasos
    para j = 1 hasta n hacer    // 2n + 2 pasos, n veces
      m++;                      // 1 paso, n² veces
    fpara
  fpara
  retorna m                     // 1 paso
ffuncion

Un bucle para i = 1 hasta n cuesta 2n+22n + 2 pasos: 1 asignación, n+1n+1 comparaciones y nn incrementos.

SoluciónPasos
133 (constante)
21+(2n+2)+2n+1=4n+41 + (2n+2) + 2n + 1 = 4n + 4
31+(2n+2)+(2n+2)n+n2+1=3n2+4n+41 + (2n+2) + (2n+2)n + n^2 + 1 = 3n^2 + 4n + 4

Aunque se dé un coste distinto a cada operación (en las diapositivas, producto 30 µs, suma 20 µs y el resto 1 µs), las gráficas tienen la misma forma: la solución 1 acaba siendo siempre la mejor. Un método de tiempo constante acaba ganando a uno lineal, y uno lineal a uno cuadrático. Se dice que la solución 1 es asintóticamente más eficiente que las otras dos, y la 2 que la 3.


Instancias: mejor, peor y caso promedio

El coste no siempre depende solo de la talla.

Instancia: factor con el que varía el coste para una talla fija. Es un caso particular del problema que hace que el algoritmo se comporte de una forma u otra.

  • Mejor caso: instancias que, para cada talla, se resuelven más rápido. Aquí, xx en la primera posición: coste constante.
  • Peor caso: instancias que, para cada talla, necesitan más pasos. Aquí, xx no está: coste lineal.
  • Caso promedio: necesita conocer la probabilidad de cada instancia.

¿Cuál da más información? Depende. El promedio parece el más interesante, pero en algunas aplicaciones importa más el peor caso (un algoritmo razonable de media puede ser prohibitivo en el peor).

En la asignatura se estudian el mejor y el peor caso porque:

  • Son fáciles de analizar; el promedio exige conocer la distribución de probabilidades y matemáticas más avanzadas.
  • El promedio siempre está entre los dos, así que ya dan información sobre él.

Notación asintótica

Estudia el comportamiento para tallas suficientemente grandes, sin fijarse en lo que pasa con tallas pequeñas y olvidando las constantes. Sean f,g:N→R+f, g : \mathbb{N} \to \mathbb{R}^+, con f(n)f(n) el coste del algoritmo.

OO (o grande): cota superior

f(n)∈O(g(n))  ⟺  ∃ c∈R+, ∃ n0∈N:f(n)≤c g(n)∀n≥n0f(n) \in O(g(n)) \iff \exists\, c \in \mathbb{R}^+,\ \exists\, n_0 \in \mathbb{N} : \quad f(n) \le c\, g(n) \quad \forall n \ge n_0

gg domina asintóticamente a ff: a partir de n0n_0, ff nunca supera a un múltiplo de gg. O(g(n))O(g(n)) es el conjunto de funciones acotadas superiormente por gg.

Ω\Omega (omega): cota inferior

f(n)∈Ω(g(n))  ⟺  ∃ c∈R+, ∃ n0∈N:f(n)≥c g(n)∀n≥n0f(n) \in \Omega(g(n)) \iff \exists\, c \in \mathbb{R}^+,\ \exists\, n_0 \in \mathbb{N} : \quad f(n) \ge c\, g(n) \quad \forall n \ge n_0

gg es dominada por ff: hay un múltiplo de gg que ff nunca baja.

θ\theta (theta): orden exacto

f(n)∈θ(g(n))  ⟺  ∃ c1,c2∈R+, ∃ n0∈N:c1 g(n)≤f(n)≤c2 g(n)∀n≥n0f(n) \in \theta(g(n)) \iff \exists\, c_1, c_2 \in \mathbb{R}^+,\ \exists\, n_0 \in \mathbb{N} : \quad c_1\, g(n) \le f(n) \le c_2\, g(n) \quad \forall n \ge n_0

Es decir, f(n)∈θ(g(n))  ⟺  f(n)∈O(g(n))∧f(n)∈Ω(g(n))f(n) \in \theta(g(n)) \iff f(n) \in O(g(n)) \land f(n) \in \Omega(g(n)): ff domina y es dominada por gg.

Jerarquía de órdenes

O(1)⊂O(log⁡n)⊂O(n)⊂O(n)⊂O(nlog⁡n)⊂O(n2)⊂O(n3)⊂O(2n)⊂O(nn)O(1) \subset O(\log n) \subset O(\sqrt{n}) \subset O(n) \subset O(n \log n) \subset O(n^2) \subset O(n^3) \subset O(2^n) \subset O(n^n)

Con Ω\Omega las inclusiones van al revés: Ω(nn)⊂Ω(2n)⊂⋯⊂Ω(log⁡n)⊂Ω(1)\Omega(n^n) \subset \Omega(2^n) \subset \dots \subset \Omega(\log n) \subset \Omega(1).

Así, 3n+23n + 2 está en O(n)O(n), pero también en O(n2)O(n^2) y en O(2n)O(2^n). Siempre se da la cota más ajustada.

TipoOrden
ConstanteO(1)O(1)
Logarítmica (sublineal)O(log⁡n)O(\log n), O(n)O(\sqrt{n})
LinealO(n)O(n)
SuperlinealO(nlog⁡n)O(n \log n)
Polinómica: cuadrática, cúbicaO(n2)O(n^2), O(n3)O(n^3)
ExponencialO(2n)O(2^n), O(nn)O(n^n)

Propiedades de las cotas

Todas valen igual cambiando θ\theta por OO o por Ω\Omega.

  • Polinomios: un polinomio de grado kk es θ(nk)\theta(n^k).
  • Regla de la suma: θ(f(n))+θ(g(n))=θ(max⁡(f(n),g(n)))\theta(f(n)) + \theta(g(n)) = \theta(\max(f(n), g(n))). Ejemplo: θ(n2)+θ(n)=θ(n2)\theta(n^2) + \theta(n) = \theta(n^2).
  • Regla del producto: θ(f(n))⋅θ(g(n))=θ(f(n)⋅g(n))\theta(f(n)) \cdot \theta(g(n)) = \theta(f(n) \cdot g(n)). Ejemplo: θ(n)⋅θ(n2)=θ(n3)\theta(n) \cdot \theta(n^2) = \theta(n^3).
  • Cerrado para la suma: si f,g∈θ(h(n))f, g \in \theta(h(n)), entonces f+g∈θ(h(n))f + g \in \theta(h(n)). Ejemplo: (3n+3)+(25n+8)=28n+11∈θ(n)(3n+3) + (25n+8) = 28n + 11 \in \theta(n).
  • No cerrado para el producto: si f,g∈θ(h(n))f, g \in \theta(h(n)), f⋅gf \cdot g no tiene por qué estar en θ(h(n))\theta(h(n)). Ejemplo: (3n+3)(25n+8)∈θ(n2)(3n+3)(25n+8) \in \theta(n^2), no θ(n)\theta(n).
  • Constantes: si f∈θ(h(n))f \in \theta(h(n)), entonces af(n)+b∈θ(h(n))a f(n) + b \in \theta(h(n)) para a>0a > 0. Ejemplo: 100(3n+3)+7∈θ(n)100(3n+3) + 7 \in \theta(n).

Comparar órdenes con límites

lim⁡n→∞f(n)g(n)\displaystyle\lim_{n\to\infty} \frac{f(n)}{g(n)}Conclusión
k≠0k \ne 0 (constante)f∈θ(g)f \in \theta(g) y g∈θ(f)g \in \theta(f)
∞\inftyff crece más rápido que gg
00gg crece más rápido que ff

Sumas habituales

∑i=1n1=n∑i=1ni=n(n+1)2∑i=1ni2=n(n+1)(2n+1)6∑i=1ni3=n2(n+1)24\sum_{i=1}^{n} 1 = n \qquad \sum_{i=1}^{n} i = \frac{n(n+1)}{2} \qquad \sum_{i=1}^{n} i^2 = \frac{n(n+1)(2n+1)}{6} \qquad \sum_{i=1}^{n} i^3 = \frac{n^2(n+1)^2}{4}
  • Progresión aritmética (an=a1+(n−1)da_n = a_1 + (n-1)d): ∑i=1nai=n(a1+an)2\displaystyle\sum_{i=1}^{n} a_i = \frac{n(a_1 + a_n)}{2}
  • Progresión geométrica (an=a1rn−1a_n = a_1 r^{n-1}): ∑i=1nai=a1rn−1r−1\displaystyle\sum_{i=1}^{n} a_i = a_1\frac{r^n - 1}{r - 1}
  • Linealidad: ∑c f(i)=c∑f(i)\sum c\,f(i) = c \sum f(i) y ∑(f(i)+g(i))=∑f(i)+∑g(i)\sum (f(i) + g(i)) = \sum f(i) + \sum g(i)
SumaOrden (también vale con OO y Ω\Omega)
∑i=1nik\sum_{i=1}^{n} i^k, k∈N+k \in \mathbb{N}^+θ(nk+1)\theta(n^{k+1})
∑i=1n(n−i)k\sum_{i=1}^{n} (n-i)^k, k∈N+k \in \mathbb{N}^+θ(nk+1)\theta(n^{k+1})
∑i=1nri\sum_{i=1}^{n} r^i, r>1r > 1θ(rn)\theta(r^n)
∑i=1n1i\sum_{i=1}^{n} \frac{1}{i}θ(log⁡n)\theta(\log n)
∑i=1n1ri\sum_{i=1}^{n} \frac{1}{r^i}, r>1r > 1θ(1)\theta(1)

Cómo calcular la complejidad de un algoritmo

  1. Talla: decidir de qué depende el tamaño del problema.
  2. Instancias: ver si hay mejor y peor caso.
    • Si el coste no depende de la instancia: se da con θ\theta.
    • Si depende: se da con Ω\Omega (mejor caso) y OO (peor caso).
  3. Cuantificar:
    • Algoritmos iterativos: contar los pasos significativos.
    • Algoritmos recursivos: plantear y resolver una relación de recurrencia.

Algoritmos recursivos

Su coste se expresa de forma natural con una relación de recurrencia:

T(n)={c1en el caso baseT(s(n))+p(n)+c2en otro casoT(n) = \begin{cases} c_1 & \text{en el caso base} \\ T(s(n)) + p(n) + c_2 & \text{en otro caso} \end{cases}

donde s(n)<ns(n) < n; lo habitual es s(n)=n−cs(n) = n - c o s(n)=n/cs(n) = n / c, y p(n)p(n) puede ser 00.

Hay varios métodos para resolverlas (sustitución, inducción, función generadora…). En la asignatura se usa el de sustitución o expansión: ir sustituyendo cada T(⋅)T(\cdot) por su definición hasta ver el término general, y llegar al caso base.

Recurrencias inmanejables: acotar

A veces la expansión se complica tanto que no se puede resolver. Entonces se busca la menor cota superior y la mayor cota inferior posibles, sustituyendo la recurrencia por otras dos más sencillas que la acoten.

Modelos generales de recurrencia

Sirven para comprobar que los cálculos hechos a mano están bien.

Sustracción (el problema se reduce restando bb):

T(n)={c nk0≤n<ba T(n−b)+c nkn≥b⟹T(n)∈{θ(nk)a<1θ(nk+1)a=1θ ⁣(a ndiv⁡b)a>1T(n) = \begin{cases} c\,n^k & 0 \le n < b \\ a\,T(n-b) + c\,n^k & n \ge b \end{cases} \qquad\Longrightarrow\qquad T(n) \in \begin{cases} \theta(n^k) & a < 1 \\ \theta(n^{k+1}) & a = 1 \\ \theta\!\left(a^{\,n \operatorname{div} b}\right) & a > 1 \end{cases}

División (el problema se reduce dividiendo entre bb):

T(n)={c nk1≤n<ba T(n/b)+c nkn≥b⟹T(n)∈{θ(nk)a<bkθ(nklog⁡n)a=bkθ ⁣(nlog⁡ba)a>bkT(n) = \begin{cases} c\,n^k & 1 \le n < b \\ a\,T(n/b) + c\,n^k & n \ge b \end{cases} \qquad\Longrightarrow\qquad T(n) \in \begin{cases} \theta(n^k) & a < b^k \\ \theta(n^k \log n) & a = b^k \\ \theta\!\left(n^{\log_b a}\right) & a > b^k \end{cases}

Aquí aa es el número de llamadas recursivas, bb cuánto se reduce el problema y nkn^k el coste del trabajo no recursivo. El caso a<1a < 1 en sustracción no tiene utilidad práctica; está por completitud.


Límites del criterio asintótico

¿Es mejor un algoritmo de coste 3n33n^3 o uno de 600n2600n^2? Asintóticamente, el segundo, porque O(n2)⊂O(n3)O(n^2) \subset O(n^3). Pero eso es para nn suficientemente grande: 3n3≥600n23n^3 \ge 600n^2 solo cuando n≥200n \ge 200.

  • Si casi todos los problemas reales tienen n<200n < 200, conviene el de 3n33n^3.
  • Para ir bien con cualquier talla, se pueden combinar: un programa que llame a uno u otro según nn.

Otras cosas a tener en cuenta:

  • Un algoritmo teóricamente mejor puede esconder una constante enorme que lo haga peor en la práctica.
  • Si se va a usar pocas veces, puede compensar uno menos eficiente pero más rápido de desarrollar: el coste incluye desarrollo y mantenimiento, no solo ejecución.
  • Ganar tiempo puede costar memoria.

Como cualquier ingeniero, el diseñador tiene que buscar un compromiso entre todos estos factores (talla, frecuencia de uso…).