Documentos de Académico
Documentos de Profesional
Documentos de Cultura
Apunte complementario.
1. Introducción
A la hora de escribir un algoritmo no sólo nos interesa garantizar que éste realiza lo esperado (correctitud)
sino que también resulta importante poder estimar la complejidad del mismo. El análisis de complejidad se
ocupa de analizar y estudiar dos aspectos fundamentales de los algoritmos: el tiempo que demora en realizar lo
que pretende y cuánta memoria necesita a tal efecto. En general nos ocuparemos principalmente de la cuestión
temporal relegando la espacial, no obstante las técnicas que veremos pueden ser aplicadas en ambos tipos de
análisis. De la misma manera, los análisis que en general realizaremos serán de carácter asintótico. La realización
de un análisis con esta caracterı́stica tiene implı́cita dos preferencias: no interesa el detalle preciso de la cantidad
de operaciones que un algoritmo realiza, ası́ como tampoco cómo se comporta para algunas entradas particulares
de cierto tamaño. En el primer caso, la experiencia indica que como primer aproximación resulta prescindible el
detalle fino de la cantidad de operaciones; además su cálculo es considerablemente más dificultoso y la expresión
resultante puede llegar a ser complicada. Con la notación ası́ntotica se obtiene una caracterización precisa y
concisa de un algoritmo, que se basa en el estudio de los rasgos fundamentales de un algoritmo sin entrar en
detalles que pueden hacer perder el foco de esos puntos principales.
Este tipo de estudio sobre un algoritmo resulta fundamental para poder comprenderlo cabalmente; si a un
algoritmo no le podemos calcular su complejidad difı́cilmente podamos entender qué es lo que realiza y cómo
lo hace. También resulta fundamental porque nos da herramientas con las cuales poder comparar distintos
algoritmos que realizan una misma tarea y de acuerdo a algún criterio elegir entre ellos.
Como primer paso para ello hemos de precisar cuales son los parámetros en función de los cuales nos interesa
expresar dicha complejidad: el largo de una lista, la cantidad de posiciones de un arreglo, etc.. Si bien para
cada algoritmo se pueden establecer párametros mejores que otros, en principio cualquier elección es buena en
la medida en que establezcamos precisamente la correspondencia existente entre los parámetros establecidos y
la entrada del algoritmo en particular.
También es importante que la expresión que demos para la complejidad de un algoritmo refleje todos los
parámetros que pueden hacer que el tiempo de ejecución se vea alterado por cambios en la entrada. Por otro
lado, preferimos que nuestras expresiones de complejidad sólo estén escritas en función de estos parámetros y
que no incluyan valores que no dependan de la entrada. Más aún, también preferimos dejar en la expresión sólo
los términos más significativos que en ella aparecen; haciendo uso de las propiedades conocidas de O() que luego
repasaremos.
Por último, debemos tener claro bajo que circunstancias el orden establecido es válido y, luego, indicarlo. A
lo largo de la materia los órdenes a calcular serán mayoritariamente del peor caso. Ante una omisión se suele dar
por sentado de que se habla del peor caso; ası́ lo haremos en el presente apunte. El análisis del caso promedio suele
ser más dificultoso, de hecho surge una dificultad inmediata respecto a cuál es el caso promedio. Eso depende del
contexto en el cual se vaya a ejecutar el algoritmo en cuestión. A los efectos de realizar un análisis general lo más
razonable posible, lo que suele hacerse es considerar que todos los posibles inputs tienen igual probabilidad de
ser recibidos como entrada. Esto implica tomar una distribución uniforme para la entrada, a pesar de lo sencilla
que es esta distribución el análisis que suele ser necesario realizar resulta dificultoso.
1
2. Primeros cálculos
2.1. La matriz
Comencemos recordando una propiedad que hemos visto en Álgebra 1; para ello escribamos un procediento
bastante sencillo que dada una matriz de n×n nos dice cuántos elementos por debajo de la diagonal son distintos
de cero:
cantCerosPorEncimaDeLaDiagonal(M )
1 cantidadDeCeros ← 0
2 for f il ← 1 to n
3 do for col ← 1 to f il − 1
4 do if M(f il,col) = 0
5 then cantidadDeCeros ← cantidadDeCeros + 1
6 return cantidadDeCeros
Al recorrer la fila f il se visitan las primeras f il − 1 columnas; luego en la primer fila no se visitan columas,
en la segunda se visita una columna y ası́ sucesivamente.
Entonces, si recordamos de las clases de Álgebra que:
X 1
(a + bj) = a(m + 1) + bm(m + 1) (2)
2
0≤j≤m
Vemos que en nuestro caso a = −1, b = 1 y ademas, tenemos que omitir el primer término de la sumatoria.
Por lo tanto, la expresı́ón a partir de (2) es
1
(n − 1)n (3)
2
Que, como dijimos, nos interesa escribir solamenente como O(n2 ). Ahora bien, ¿cuál serı́a la complejidad de un
algoritmo que quisiera contar los ceros sobre toda la matriz?
2.2. La secuencia
Ahora tomemos un segundo ejemplo, donde dado un número n se devuelve una secuencia con los n primeros
números naturales
generarSecuenciaNaturales(n)
1 res ←<>
2 i←0
3 while long(res) < n
4 do res ← res ◦ i
5 i←i+1
6 return res
1 En ciertos casos, puede resultar importante distinguir, por ejemplo, el costo de la suma del de la multiplicación.
2
¿Qué podemos decir, entonces, de la complejidad de este ejemplo? Como se ve, el ciclo se realizará n veces ya
que en cada paso se agrega un elemento y la secuencia comienza estando vacı́a. ¿Quiere decir que el algoritmo
es O(n)? No, no podemos afirmarlo (sólo podemos dar por cierto es que el algoritmo es Ω(n).) ya que no hemos
analizado las operaciones que en el algoritmo se realizan. Entonces, lo que necesitamos es conocer los costos
de las operaciones de secuencia involucradas. Para precisarlos supongamos una implementación de la secuencia
consistente en nodos simplemente encadenados y donde se tiene un puntero al primero de los nodos de la lista
encadenada2 . Entonces es razonable asumir:
<> es O(1)3 .
◦ y long son O(l)4 .
El =, asignación por referencia, es O(1).
Entonces la cuenta que podemos plantear para el algoritmo teniendo en cuenta el costo de las operaciones
que no son constantes es
n−1
X
T◦ (l) + Tlong (l) + Tlong (n) (4)
l=0
Ya que se realizan n iteraciones y n + 1 evaluaciones de la guarda del ciclo; teniendo en cuenta los costos
asumidos (y las propiedades de O() que permiten reemplazar T (n) = O(f (n)) por f (n) directamente) puede
transformarse en
n−1
X
n+ l (5)
l=0
generarSecuenciaNaturalesQuick(n)
1 aux ←<>
2 i←0
3 while i < n
4 do aux ← i • aux
5 i←i+1
6 res ←<>
7 while ¬vacia?(aux)
8 do res ← prim(aux) • res
9 aux ← f in(aux)
10 return res
Donde las funciones nuevas que usamos de secuencia tienen O(1) de complejidad, lo cual es factible para
la implementación propuesta. Entonces, pasamos a tener dos ciclos de O(n), los cuales al combiarlos dan un
algoritmo de la misma complejidad. Resulta más eficiente, entonces, el caso donde tenemos dos ciclos que el
primero donde habı́a uno solo.
2 Otra alternativa hubiera sido no asumir implementación de la secuencia y expresar la complejidad del algoritmo en función de
3
3. Algunas notaciones
Antes de seguir con más ejemplos, hagamos un alto y realicemos algunos comentarios sobre la notación de
O(). En general decimos , por ejemplo, “el orden del algoritmo A es O(n). . . ”. Esa frase no es del todo correcta
ya que O(), Ω() y Θ() están definidas para funciones matemáticas, tenemos que tener presente que una frase
más correcta serı́a “el orden de la complejidad del algoritmo A es O(n). . . ”, donde la complejidad es la función
matemática que para su algoritmo asociado da una medida de la cantidad de operaciones que éste efectúa.
Al realizar las manipulaciones de términos habituales a la hora de calcular complejidades, suelen surgir
expresiones que podrı́an prestarse a la confusión:
n = O(n) (6)
2n = O(n) (7)
También podemos apuntar que si P (n) es un polinomio de grado menor o igual a m entonces P (n) = O(nm ).
En virtud de las consideraciones anteriores, al trabajar con las expresiones usualmente también ignoraremos
constantes que no nos interesan dejando las expresiones solamente en función de aquellos parámetros relevantes.
4. Más ejemplos
Es recomendable en primer instancia realizar un análisis previo de un algoritmo sin tratar de usar directamente
el aparatejo matemático con que disponemos.
4
imprimirSimbolos(simb)
1 for i ← 1 to cantF ormas
2 do repet[i] = 0
3 for i ← 1 to n
4 do repet[nat(simb[i])] = repet[nat(simb[i])] + 1
5 for i ← 1 to cantF ormas
6 do for j ← 1 to repet[i]
7 do imprimirSimboloDeF orma(i)
8
Entonces, la pregunta de rigor: ¿Cuál es la complejidad del algortimo? Veamos, que es lo que se está hacien-
do. . .
Como dijimos al plantear el problema, se tienen n sı́mbolos cada uno de los cuales es de alguno de los
cantF ormas tipos distintos. Los dos primeros ciclos se realizan cantF ormas y n veces respectivamente. Entonces
nos falta ver el tercer ciclo, con su bucle interno.
Ahora bien, la llamada a imprimirSimbolo se realiza por cada sı́mbolo que se imprime, luego se llama
solamente n veces por más que se encuentre dentro de un par de ciclos anidados. De todas maneras, ¿Podemos
decir, entonces, que el último grupo de ciclos es O(n)? No, no tan rápido; pensemos que sucederı́a si n = 0 (por
lo que ∀ i repet[i] = 0), podemos ver es que, de todas maneras, se iterará cantF ormas veces.
Plateando con cuidado la sumatoria para estos dos últimos ciclos vemos que en cada iteración del ciclo
exterior se evalúa si hay que entrar o no en el ciclo iterno y, eventualmente, se itera
cantF
X ormas
(repet[i] + 1) (14)
i=1
altura(a)
1 if nil?(a)
2 then return 0
3 else d ← altura(der(a))
4 i ← altura(izq(a))
5 return max(d, i) + 1
Para estimar la complejidad de este algoritmo veremos que tenemos herramientas más rigurosas pero en el
espı́ritu del análisis anterior pensemos que es lo que está sucediendo. En cada paso descendemos por cada hijo
una única vez hasta encontrarnos que llegamos a una hoja. En ningún caso volvemos en el camino andado, por
lo cual visitamos cada nodo una única vez; entonces el algoritmo es O(n) siendo n la cantidad de nodos del árbol
recibido como parámetro.
Por otro lado, digamos que no podrı́amos esperar resolverlo con una complejidad menor:¿Si no visitamos
algún nodo, como podemos garantizar que el subárbol que lo tiene como raı́z no es aquel con la rama más larga?
5
un árbol completo con estas caracterı́sticas, parándose en cada nodo descienda por la derecha hasta llegar a una
hoja. Un pseudocódigo serı́a
recorrerArbolCompleto(a)
1 if ¬nil?(a)
2 then
3 h←a
4 while ¬nil?(der(h))
5 do h ← der(h)
6 sentido ← IZQ
7 visiteRaiz ← f alse
8 while ¬visiteRaiz
9 do b ← h
10 while ¬nil?(der(b))
11 do imprimir(b)
12 b ← der(b)
13 if tieneP adre?(h)
14 then if sentido = IZQ
15 then if tieneHmnoIzq?(h)
16 then h ← hermanoIzquierda(h)
17 else h ← padre(h)
18 sentido ← DER
19 else if tieneHmnoDer?(h)
20 then h ← hermanoDerecha(h)
21 else h ← padre(h)
22 sentido = IZQ
23 else visiteRaiz ← true
Entonces, ¿cúantas veces se realiza la impresión? Veamos, intentemos expresar dicha cantidad en función de
la cantidad de nodos del árbol (n). Lo que hace el algoritmo es pararse en cada nodo y descender hasta una
hoja, claramente en cada descenso se recorre a lo sumo la altura de nuestro árbol (que, dado que es completo,
es lg(n + 1)). Entonces podemos decir que se imprime O(n · lg(n + 1)) veces, lo que es O(n · lg n).
Lo que hemos encontrado es una cota superior a la cantidad de impresiones; tratemos de afinar la cuenta
para hallar una cota más justa, ¿Se podrá? Como dijimos, parándonos en cada nodo bajamos hasta una hoja,
pero no siempre recorremos la altura; de hecho sólo lo hacemos cuándo empezamos en la raı́z. Por otro lado,
cuando empezamos en las hojas de hecho nada imprimimos. Notemos, por lo tanto, que a medida que vamos
subiendo en cada nivel cada vez recorremos más al bajar pero también se reduce la cantidad de nodos a partir
de los cuáles se comienza el descenso.
Para precisar lo enunciado en el párrafo precedente, recordemos que en el nivel i (la raı́z está en el nivel
0) de un árbol completo hay 2i nodos. Por otro lado, nuestro algoritmo para cada nodo del nivel i imprime
altura(a) − i veces. Ahora estamos en condiciones de plantear la siguiente sumatoria
altura(a)
X
2i (altura(a) − i) (15)
i=0
6
Ahora bien altura(a) = lg(n + 1), entonces tenemos
altura(a)
X j
(n + 1) (18)
j=0
2j
¿Pero la sumatoria, cuánto vale? Notemos que tenemos una del tipo aritmético-geométrica, cuya razón es
menor a uno (tenemos 1/2) entonces la serie infinita converge a un cierto número c constante. Entonces, la nueva
cota para la cantidad de impresiones es O(n)6 . De la misma manera en que nos planteamos antes: ¿Podrı́amos
encontrar una mejor cota asintótica?
¿Es esta la complejidad del algoritmo? No, no podemos asegurar esto ya que no conocemos la complejidad de
las operaciones utilizadas. De todas formas, la cuenta realizada consituye la base del análisis de este algoritmo,
lo único que falta, como dijimos, es tener en cuenta cuánto cuestan las operaciones de árboles que utilizamos
además de indicar que el ciclo previo que se sitúa en la hoja de la extrema derecha cuesta lg n (lo cual no afecta
a la cota hallada).
manoDeCartas(mazo, jugadores, j, c)
1 for jj ← 1 to j
2 do cartas[jj] =<>
3 for cc ← 1 to c
4 do for jj ← 1 to j
5 do cartas[jj] = sacarCarta(mazo) • cartas[jj]
6 hayLimite ← hayLimite(jugadores[0], cartas[0])
7 if hayLimite
8 then
9 limite ← limite(jugadores[0], cartas[0])
10 for jj ← 1 to j
11 do if hayLimite
12 then cartas[jj] ← sacarCartas(jugardores[jj], cartas[jj], limite)
13 else cartas[jj] ← sacarCartas(jugardores[jj], cartas[jj])
14 despues ← long(cartas[jj])
15 for n ← despues to c
16 do cartas[jj] = sacarCarta(mazo) • cartas[jj]
17 puntosGanadores ← calcularP untaje(cartas[jj])
18 ganadores ← 0• <>
19 for jj ← 2 to j
20 do ptje ← calcularP untaje(cartas[jj])
21 if ptje > puntosGanadores
22 then ganadores ← jj• <>
23 if ptje = puntosGanadores
24 then ganadores ← jj • ganadores
25 return ganadores
Supongamos la misma implementación de las secuencias, arreglos con operaciones de tiempo constante y los
siguientes órdenes
sacarCarta es O(1).
6 Notar que esta cota no es en algún caso promedio sino que es válida en el peor caso; la encontrada anteriormente no era tan
buena.
7
hayLimite, limite y sacarCartas (sin lı́mite) son O(car).
calcularPuntaje es O(car2 ).
sacarCartas (con limite) son O(car · lim).
donde car es la cantidad de cartas recibidas y lim el lı́mite impuesto. Queda como ejericio (para quien quiera,
obviamente) calcular la complejidad de este algoritmo.
Haciendo el tipo de análisis que hasta ahora realizábamos el algoritmo es O(n) temporalmente hablando;
como siempre se recorre todo el arreglo el algoritmo también es Ω(n) (por lo tanto Θ(n)).
Ahora bien, si nos ponemos más detallistas podrı́a interesarnos la cantidad de veces que se ejecuta cada paso
del algoritmo. Una vez se ejecuta el primer paso, n veces la evaluación de la guarda del ciclo y n − 1 veces tanto
la obtención de un nuevo elemento dentro del ciclo como la comparación del nuevo valor con max. Pero:¿cuántas
veces se ejecuta el reemplazo de max por el nuevo valor encontrado? Para completar el análisis estudiemos esa
cantidad, llamemósla A.
Veamos algunos casos, en el peor de los casos A = n − 1, eso sucede cuando el arreglo está ordenado creciente-
mente. En cada paso encontramos un valor que es mayor a todos los que habı́amos visto antes. Recı́procamente,
en el mejor de los casos A = 0, cuando el arreglo está ordenada decrecientemente, el primer elemento es el
mayor de todos. Lo que no queda tan claro es: ¿cuál es el valor promedio de A? Claramente cae entre 0 y n − 1.
¿Es 21 n? ¿Es 13 n? Resulta crucial para responder está pregunta definir precisamente qué queremos significar por
promedio. Para ello asumiremos ciertas caracterı́sticas del arreglo:
Todos sus elementos son distintos.
Cada una de las n! permutaciones de los n valores de la secuencia es igualmente probable.
Notemos que la performance del algoritmo no depende de los valores en sı́ sino más bien del orden entre ellos.
A los efectos del análisis, entonces, podemos decir que el arreglo está formado por los números 1 . . . n en algún
orden.
La probabilidad de que A tenga el valor k es
8
√
Por último, la desviación estándar (σn ) es Vn .
El significado de σn puede enterderse notando que, para todo r ≥ 1, la probabilidad que A no caiga dentro
de rσn de su valor medio es menos que 1/r2 . Por ejemplo, |A − An | > 2σn con probabilidad < 1/4.
Podemos determinar el comportamiento de A determinando las probalidades pnk . Por la ecuación (19),
queremos contar el número de permutaciones que tienen A = k.
Consideremos las permutaciones x1 x2 . . . xn en {1, 2, . . . , n}. Si xn = n, el valor de A es uno más que el valor
obtenido en x1 . . . xn−1 ; si xn 6= n, el valor de A es exactamente el mismo que el de x1 . . . xn−1 . Entonces:
1
pnk = (]perm de n − 1 con A = k − 1 + (]perm de n − 1 con A = k)n − 1) (22)
n!
Donde el factor n-1 da cuenta de las n-1 posiciones en la permutación original donde se puede encontrar n (todas
las posiciones excepto la última). Luego dividiendo cada término por n! y escribiéndolo como n(n − 1)! en virtud
de (19)
1 n−1
pnk = p(n−1)(k−1) + p(n−1)k (23)
n n
Esta ecuación determinará pnk si proveemos los valores iniciales:
pnk = 0 (25)
Ahora introducimos la siguiente función (este tipo de funciones se denominan generadoras o generatrices):
X
Gn z = pn0 + pn1 z + . . . = pnk z k (26)
k
Como sabemos que A ≤ n − 1 resulta que pnk = 0 para valores grandes de k. Entonces Gn (z) resulta ser un
polinomio.
Por (24) resulta que G1 (z) = 1 y por (23)
z n−1 z+n−1
Gn (z) = Gn−1 (z) + Gn−1 (z) = Gn−1 (z) (27)
n n n
Luego, utilizando esta misma expresión pero sobre Gn−1 (z) y ası́ sucesivamente
1
Gn (z) = (z + n − 1)(z + n − 2) . . . (z + 1) (28)
n!
Entonces,
1 z+n
Gn (z) = (29)
z+n n
Tomemos directamente el siguiente resultado
1 X h n i k−1
Gn (z) = z (30)
n! k
k
9
El hecho importante es que se pueden determinar el promedio y la varianza fácilmente a partir de la función
misma.
Para lograr esto tomemos una función generadora cuyos coeficientes representen probabilidades
G(z) = p0 + p1 z + p2 z 2 + . . . (33)
Entonces queremos calcular
X
mean(G) = kpk (34)
k
y también
X
var(G) = k 2 pk − (mean(G))2 (35)
k
Ahora notemos que G(1) = 1 ya que es la suma de todas las probabilidades.
Si derivamos
X
G0 (z) = kpk z k−1 (36)
entonces
Referencias
[Knu73] Donald E. Knuth. The art of computer programming, volume Fundamental Algorithms of Computer
Science and information processing. Addison-Wesley, second edition, 1973.
10