You are on page 1of 24

UNED CA Guadalajara

Programación III / Estructuras de datos y algoritmos Curso 2003-2004

Eficiencia
Índice
1. Introducción 2

2. La eficiencia de los algoritmos 2


2.1. Problemas y casos. Tamaño de los casos. . . . . . . . . . . . . . . . . . . . . . . . 2
2.2. Tiempo de ejecución t(n). Análisis de caso peor, caso mejor y caso medio. . . . . 3
2.3. Principio de invariancia. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4
2.3.1. Orden asintótico de una función . . . . . . . . . . . . . . . . . . . . . . . 5
2.4. Notaciones O, Θ y Ω . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6
2.4.1. Notación Θ . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7
2.4.2. Notación O . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8
2.4.3. Notación Ω . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8
2.5. Operaciones con órdenes de complejidad. Funciones anónimas . . . . . . . . . . . 8
2.5.1. Funciones polinómicas . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9
2.5.2. Usando lı́mites para calcular órdenes de complejidad . . . . . . . . . . . . 9
2.5.3. Suma y producto de órdenes de complejidad . . . . . . . . . . . . . . . . . 9
2.5.4. Funciones anónimas . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9

3. Análisis de algoritmos 10
3.1. Operaciones elementales. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10
3.1.1. Modelos de computación. . . . . . . . . . . . . . . . . . . . . . . . . . . . 11
3.2. Estructuras básicas de control . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 11
3.2.1. Secuencia de instrucciones . . . . . . . . . . . . . . . . . . . . . . . . . . . 12
3.2.2. Sentencias condicionales tipo IF-THEN . . . . . . . . . . . . . . . . . . . . 12
3.2.3. Bucles FOR . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 12
3.2.4. Instrucciones crı́ticas . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 13
3.3. El caso especial de los algoritmos recursivos. . . . . . . . . . . . . . . . . . . . . . 13
3.3.1. Disminución del tamaño del caso por división . . . . . . . . . . . . . . . . 13
3.3.2. Disminución del tamaño del caso por sustracción . . . . . . . . . . . . . . 14

4. Análisis de los algoritmos de ordenación 14


4.1. Inserción directa . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 15
4.1.1. Movimientos . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 15
4.1.2. Comparaciones . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 16
4.1.3. Conclusiones . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 17
4.2. Inserción binaria . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 17
4.2.1. Comparaciones . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 17
4.3. Selección directa . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 18
4.3.1. Comparaciones . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 18
4.3.2. Movimientos . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 18
4.3.3. Conclusiones . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 20
4.4. Burbuja . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 20

1
4.4.1. Comparaciones . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 21
4.4.2. Movimientos . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 21
4.4.3. Conclusión . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 21
4.5. Quicksort . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 21
4.5.1. Conclusión . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 23
4.6. Ordenación por montı́culo . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 23
4.6.1. Conclusión . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 24

1. Introducción
Hemos visto en el capı́tulo anterior varios algoritmos de ordenación. Ahora queremos refle-
xionar sobre la calidad de esos algoritmos. Hay varios sentidos en los que un algoritmo correcto
puede ser mejor que otro. Entre las caracterı́sticas más valiosas de los algoritmos cabe destacar la
claridad y la eficiencia. Pero la claridad es esencialmente cualitativa, y es difı́cil de medir. Eso no
significa que no sea importante desde el punto de vista práctico: un programa difı́cil de entender,
por más eficiente que sea, está abocado a causar problemas en el futuro. Y la verificación de un
algoritmo sencillo es más fácil, lo que reduce el riesgo de errores en su ejecución. La claridad,
por otra parte, depende de forma inseparable de la documentación del algoritmo. Volveremos
en otro momento sobre ese tema, y en este capı́tulo nos vamos a centrar en la eficiencia.
Las primeras secciones introducen los conceptos y la notación propios del análisis de la efi-
ciencia de los algoritmos. Para poner en práctica estos conceptos y notaciones vamos a aplicarlos
en la última sección al análisis de los algoritmos de ordenación.

2. La eficiencia de los algoritmos


2.1. Problemas y casos. Tamaño de los casos.
Los algoritmos que vamos a estudiar en este curso resuelven muchos casos distintos de un
mismo problema. Cada caso concreto, en la jerga de la algoritmia, es un ejemplar del problema.
Pero, por supuesto, según cual sea el caso que se considere, el algoritmo emplea más o menos
tiempo, o consume más o menos espacio de memoria, etc. Cuando tenemos varios algoritmos
disponibles para resolver un problema, debemos elegir cuál de ellos es el mejor. El criterio que
vamos a utilizar para seleccionar el mejor algoritmo se basa en el cálculo de los recursos que
el algoritmo emplea, según cuál sea el caso que tenga que resolver. Los recursos a los que nos
referimos son, primordialmente, los que hemos mencionado: el tiempo de proceso del algoritmo
y el espacio en memoria que consume.
Para poder hacer un análisis preciso de la calidad de los algoritmos necesitamos traducir
estas ideas que hemos expuesto en expresiones cuantitativas, en números, que podamos medir y
calcular. El primer paso consiste en asignar a cada caso del problema un tamaño. Eso significa que
tenemos que dar, para cada ejemplar o caso, un número, que sea representativo de la dificultad
que para el algoritmo presenta resolver ese caso en particular. Ese número es lo que llamamos
el tamaño de ese ejemplar.
En algunos casos la elección de la cantidad que hay que utilizar como tamaño del problema
será evidente. En otros, en cambio es necesario un análisis muy sutil para decidir cuál es la
medida correcta. En este curso, la mayorı́a de los problemas serán de la primera clase: fáciles.
Además, a medida que se gana en experiencia, las decisiones que al principio pueden parecer
difı́ciles se facilitan por el repertorio de ejemplos que vamos acumulando. Es esencial, por ejemplo,
que todos los ejemplares de un mismo tamaño supongan un nivel de dificultad similar para el
algoritmo, y que ese nivel de dificultad vaya aumentando con el tamaño, de manera que a mayor
tamaño, mayor dificultad. Suponemos por tanto que, dado un ejemplar del problema que nos

2
interesa, vamos a ser capaces siempre de identificar ese ejemplar como un ejemplar de un cierto
tamaño n.

2.2. Tiempo de ejecución t(n). Análisis de caso peor, caso mejor y caso medio.
Para simplificar, vamos a centrar nuestra discusión inicial en el análisis comparativo del
tiempo que tarda en ejecutarse un algoritmo. La discusión sobre la eficiencia en términos de
memoria es muy similar, y veremos ejemplos más adelante en el curso.
Para fijar ideas supongamos que tenemos un algoritmo para resolver un problema, y a partir
de él escribimos un programa de ordenador. Es decir, implementamos el algoritmo en un lenguaje
de programación concreto y en un ordenador concreto. A continuación empezamos a utilizar el
programa para resolver distintos ejemplares de ese problema. Es de suponer, si hemos usado
una buena medida del tamaño del problema, que cuanto mayor sea el tamaño n del problema,
más tardará el programa en ejecutarse. Llamaremos t(n) al tiempo que el programa tarda en
ejecutarse en un ejemplar de tamaño n.
Por supuesto hay una primera dificultad: puede que haya más de un ejemplar de tamaño
n, y el tiempo para todos esos ejemplares no tiene porque coincidir. Para seguir adelante con
la discusión, tenemos que tomar una decisión sobre qué es lo que necesitamos saber sobre el
programa.

1. Lo más habitual es que nos preocupe saber cual es el tiempo máximo que el programa
puede tardar en resolver el problema. Es decir, de entre todos los ejemplares de tamaño
n escogemos aquel para el que el programa tarda más, y decimos que es el peor caso de
tamaño n. Entonces tmax (n) significa el tiempo que se tarda para el peor caso.

2. Sin embargo a veces ese caso peor se presenta muy rara vez en la práctica, y en la mayorı́a
de los casos de tamaño n el programa tarda menos, incluso mucho menos. Serı́a útil saber
ésto a la hora de decidir si queremos usar el programa. Ası́ que lo que podemos hacer es
calcular el tiempo medio que el programa emplea en un ejemplar de tamaño n, y llamarlo
tmed (n). Aunque este tiempo no tiene porque coincidir con el tiempo que emplea ningún
ejemplar concreto, diremos que se tmed (n) es el tiempo del caso medio de tamaño n. Puesto
que puede haber muchı́simos (en algunos casos infinitos) ejemplares de tamaño n, hacer
esa media es complicado, y exige utilizar técnicas de la teorı́a de las probabilidades.

3. Finalmente, a veces también queremos saber cuál es el tiempo mı́nimo que el programa
va a tardar en resolver un ejemplar de tamaño n. En ese caso, el contrario del primero,
debemos buscar el mejor caso de tamaño n, aquel para el que el programa tarda menos.
Llamaremos tmin (n) al tiempo que se tarda en resolver el mejor caso de tamaño n.

Correspondiendo a cada una de esas posibilidades se puede hacer un estudio del comportamien-
to del programa para distintos valores de n. En general, siempre nos preocupa como será el
comportamiento del programa cuando n sea grande.
Como ya hemos dicho al describirlos, el análisis de caso medio es el más difı́cil de todos,
porque hay que considerar todos los casos posibles y promediar sobre ellos. El análisis de caso
mejor es más fácil, pero sólo nos da una estimación optimista sobre el programa. Por esa razón,
lo más frecuente es que al analizar un programa nuestro interés se centre en el tiempo tmax (n) del
peor caso, que es comparativamente fácil de obtener, y más útil si queremos saber cómo de bueno
es nuestro programa. Por esa razón, de ahora en adelante escribiremos a veces simplemente t(n)
para referirnos de forma abreviada a tmax (n). Si nos referimos a alguno de los otros valores lo
mencionaremos explı́citamente.

3
2.3. Principio de invariancia.
Nuestro objetivo en este capı́tulo es obtener herramientas para poder comparar dos algorit-
mos y decidir cuál de los dos es mejor. Pero antes de llegar a comparar dos algoritmos, incluso
cuando pensamos en un único algoritmo, todavı́a tenemos que sortear alguna dificultad. Porque
es evidente que para usar el algoritmo habrá que traducirlo en un programa –en algún lenguaje
de programación, tipo Pascal, C, Modula-2, etc.– y ejecutar ese programa en un ordenador con-
creto. Y claro, pensarás, el programa no va a ser igual de rápido en un ordenador de los años
ochenta que en una flamante máquina de última generación. Y por supuesto, el programa en
Pascal, y el programa en C, aunque correspondan exactamente al mismo algoritmo, no tienen
porque ser igual de rápidos. Ni siquiera un mismo programa en Pascal, pero compilado con dos
compiladores diferentes tiene que ser necesariamente igual de rápido. Ası́ que ¿qué importancia
puede tener esa función t(n) de la que hemos hablado, si depende del ordenador, del lenguaje
de programación y quién sabe de cuántas cosas más?
Hay que hilar más fino. Es innegable que t(n) depende de todas esas cosas. Pero ¿hasta
qué punto? Supongamos que usas un ordenador A que es 10 veces más rápido que el ordenador
B. Entonces tA (n) será 10 veces menor que tB (n). En general, el cambio de un modelo de
ordenador a otro más rápido, manteniendo todo lo demás igual, siempre significa que se tiene
una relación como ésta:
tA (n) = ctB (n)
donde c es una constante que compara las velocidades de ambas máquinas.
Cuando en lugar de cambiar de máquina lo que hacemos es cambiar de lenguaje de progra-
mación, o de compilador dentro de un mismo lenguaje, ocurre algo muy parecido. Piensa por
ejemplo en un compilador de C y uno de Pascal. Y escribe programas para un mismo algoritmo
en ambos lenguajes. El compilador de Pascal puede ser muy rápido haciendo unas cosas, y más
lento en otras. Pero si llamamos ahora tA (n) al tiempo que tarda el programa en Pascal y tB (n)
al tiempo que tarda el programa en C, siempre se puede encontrar un par de constantes c1 y c2
tales que:
c1 tA (n) ≤ tB (n) ≤ c2 tA (n)
y esas constantes no dependen del tamaño n del caso. ¿Qué significa ésto? Piensa por ejemplo
que c2 = 2. Eso significa que
tB (n) ≤ 2tA (n)
Es decir, el programa en C (que tarda tB (n)) nunca tarda más del doble de lo que tarda el
programa en Pascal. En definitiva, en este ejemplo, esto garantiza que usar C sólo puede provocar
que nuestro programa tarde el doble. Te estarás preguntado: “¿Sólo el doble?¿Y te parece poco?”.
Volveremos en seguida sobre eso.
Y si por ejemplo c1 = 31 , entonces
1
tA (n) ≤ tB (n) que es lo mismo que decir tA (n) ≤ 3tB (n)
3
Y eso significa que el programa en Pascal (que tarda tA (n)) nunca tarda más del triple de lo
que tarda el programa en C. Ası́ que lo peor que puede pasar si usamos Pascal es que nuestro
programa tarde tres veces más.
Lo importante de la anterior discusión es:
1. que cuando se compara el tiempo que se tarda empleando dos lenguajes distintos, o dos
compiladores de un mismo lenguaje, etc., en definitiva, cuando se comparan dos implemen-
taciones distintas A y B de un mismo algoritmo, siempre resulta que se llega a ecuaciones
de la forma:
c1 tA (n) ≤ tB (n) ≤ c2 tA (n)

4
2. una ecuación como esa significa que al cambiar una implementación por otra, el tiempo
que tarda el algoritmo puede verse multiplicado por una constante que no depende del
tamaño del caso. En definitiva, sabemos de antemano que al cambiar de implementación
lo peor que puede pasar es que tengamos que esperar 2,3, 10 veces más, o en general c
veces más.
La primera de estas dos afirmaciones se suele llamar principio de invariancia, y no es una
ley de la naturaleza, ni un teorema, ni nada parecido. Simplemente describe la situación que se
viene observando tras unas décadas de experiencia en la programación con diferentes máquinas
y lenguajes de programación.

2.3.1. Orden asintótico de una función


Vuelvo sobre la pregunta que hemos dejado pendiente en el párrafo anterior: ¿te parece poco
tardar el doble? Esta inocente pregunta es la clave para que podamos empezar a entender la idea
clave al comparar dos algoritmos. Imagı́nate que tienes dos algoritmos para resolver un mismo
problema. Y a partir de ellos produces dos programas, en el mismo lenguaje de programación
y los ejecutas en el mismo ordenador. Imagı́nate además que, en esas condiciones, el primer
programa tarde:
t1 (n) = 100n
segundos para un caso de tamaño n, mientras que el segundo programa tarde
t2 (n) = n2
segundos para un caso de tamaño n. ¿Qué algoritmo es mejor? Claro, para los primeros valores
de n tales como 1, 2, 3, . . . se obtiene
t2 (n) < t1 (n)
De hecho en esos valores t1 es muchas veces mayor que t2 . Y si tuviéramos la garantı́a de que
sólo vamos a necesitar resolver problemas de esos tamaños pequeños, sin duda preferirı́amos el
programa 2. Pero muy a menudo sucede que terminamos necesitando casos con valores de n
mucho mayores. Figúrate por ejemplo que alguna vez necesitamos resolver un caso de tamaño
n = 1000. Entonces:
t2 (1000) = 10002 = 1000000 mientras que t1 (n) = 100 · 1000 = 100000
Ahora resulta que el programa t1 es 10 veces más rápido que el t2 . Y si necesitas resolver un
problema de tamaño 10000, la diferencia es aún más escandalosa:
t2 (10000) = 100002 = 108 mientras que t1 (n) = 100 · 10000 = 106
Ahora t1 es 100 veces más rápido que t2 . Algunos experimentos más con estas fórmulas deberı́an
bastar para convencerte de que, para valores grandes de n, no sólo es que t1 sea menor que t2 . Es
que la magnitud de ambos números crece de distinta forma. No podemos decir, por ejemplo, que
t1 es siempre diez o cien veces más pequeño que t2 . No es “tantas veces más pequeño”, porque
no son cantidades proporcionales. Sus órdenes de magnitud son diferentes.
El orden de magnitud de una función es un concepto bien definido en matemáticas (en
particular en el análisis), al que aquı́ sólo necesitamos acercarnos superficialmente.
Hemos decidido por tanto elegir como mejor a aquel algoritmo que tarde menos en ejecutarse
para ejemplares de un cierto tamaño n. Si tenemos dos algoritmos que resuelven el mismo
problema y llamamos t1 (n), t2 (n) al tiempo que cada uno de los algoritmos emplea para resolver
un caso (el peor caso, recuérdalo) de tamaño n, está claro que si:
t1 (n) ≤ t2 (n) para todo n
entonces el algoritmo 1 es más rápido que el dos sea cual sea el tamaño del caso, y por tanto
debemos preferirlo siempre.

5
El punto de vista asintótico A veces las cosas no son tan sencillas. Puede ocurrir, por
ejemplo, que t1 (n) sea más pequeño que t2 (n) para algunos valores, y más grande para otros.
Si fuera muy difı́cil distinguir unos casos de otros, la comparación de algoritmos resultarı́a
extremadamente complicada. Afortunadamente lo más frecuente es que suceda ésto: existe un
cierto valor N , tal que, a partir de ese valor (es decir para todos los n con n > N ), se cumple

t1 (n) ≤ t2 (n)

(o viceversa), como en esta figura:

En ese caso, decimos que t1 es asintóticamente menor que t2 . Como ya hemos dicho, al aumen-
tar n, el tamaño del caso, aumenta su dificultad. Lo que significa que normalmente estamos
especialmente interesados en los casos de tamaño grande. Un algoritmo que es más rápido en
los casos grandes (y difı́ciles) es muchas veces preferible, incluso aunque no resulte demasiado
bueno en casos pequeños.
Un comentario: cuando empleamos el punto de vista asintótico, no nos preocupa encontrar
el menor N a partir del cual t1 (n) ≤ t2 (n). Basta con establecer que hay un N a partir del cual
se cumple esa desigualdad.

2.4. Notaciones O, Θ y Ω
Vamos a establecer una cierta escala de eficiencia de los algoritmos basada en las ideas
anteriores. Y lo primero que necesitamos es una forma de decir el lugar que un algoritmo ocupa en
esa escala. Para hacer esto vamos a utilizar la función t(n) que indica el tiempo (mı́nimo, máximo
o promedio, eso habrá que aclararlo en cada caso) que el algoritmo emplea en resolver un ejemplar
de tamaño n. Vamos a estudiar como se comporta t(n) asintóticamente, es decir, para valores
de n grandes. En principio, la fórmula concreta para t(n) puede ser muy complicada, depender
de muchos detalles de la implementación, etcétera. Pero desde el punto de vista asintótico, la
mayorı́a de esos detalles son irrelevantes. Cuando se aplica este punto de vista, la fórmula para
t(n) habitualmente se simplifica mucho.

Ejemplo 2.1. Si por ejemplo tenemos un algoritmo A1 que cumple t1 (n) = n3 y hemos inven-
tado un nuevo algoritmo A2 para el mismo problema que cumple:

n4 + 4n3 + 2n + 1
t2 (n) =
n2 + 3n − 2

6
puede parecer difı́cil saber cómo de bueno es A2 comparado con A1 . Lo que tenemos que hacer es
pensar asintóticamente. Cuando n es muy grande, el término más importante en el numerador
de la fórmula para t2 (n) es n4 . Y el término más importante del denominador es n2 . Eso significa
que para un n grande
n4
t2 (n) ≈ 2 = n2
n
Y por tanto el algoritmo A2 es mucho mejor que el A1 , en ese sentido asintótico, para valores
de n suficientemente grandes.
Lo que queremos hacer por tanto es evitar los detalles que oscurecen la comprensión de
la fórmula para t(n). De hecho, queremos ir un paso más allá de lo que hemos hecho en este
ejemplo. No vamos a tratar de encontrar la fórmula exacta para t(n) para luego simplificarla
asintóticamente, eliminando los detalles que la complican. No, de hecho lo que vamos a hacer es
tratar de obtener la fórmula ya simplificada, eliminando los detalles durante el propio análisis
del algoritmo.

2.4.1. Notación Θ
En cualquier caso, queremos desarrollar una notación para resumir el resultado final de esos
análisis simplificadores. Una notación con la que podamos decir algo como: “asintóticamente, la
fórmula (complicada) t(n) se comporta como la fórmula (mucho más sencilla) f (n)”. La notación
que vamos a emplear para esto se basa en la siguiente definición.
Definición 2.2 (Orden Θ). El conjunto T heta(f (n)) está formado por todas las funciones
g(n) que asintóticamente están acotadas, tanto superiormente como inferiormente, por múltiplos
constantes de f (n). Más precisamente, g(n) está en Θ(f (n)) si existen constantes c1 , c2 tales
que
c1 f (n) ≤ g(n) ≤ c2 f (n) para todo n suficientemente grande
La notación habitual en matemáticas para decir que g(n) está en el conjunto Θ(f (n)) serı́a
g(n) ∈ Θ(f (n)). Pero, por razones que luego veremos, en el análisis de algoritmos se hace una
excepción y en lugar de esto se escribe g(n) = Θ(f (n)).
En lo que sigue vamos a medir la calidad de un algoritmo, usando la notación anterior. En
ese caso la función g(n) será t(n), el tiempo (máximo, mı́nimo o promedio) que el algoritmo
emplea en un caso de tamaño n. Mientras que f (n) debe ser alguna fórmula sencilla que permita
rápidamente juzgar la calidad del algoritmo, comparándolo con sus competidores. Las funciones
f (n) más frecuentes son n, n2 y en general nk , junto con las funciones log n, n log n y en general
nk log n.
n2
Ejemplo 2.3. Veamos por ejemplo que g(n) = − 3n está en el conjunto Θ(f (n)) para
2
n2
f (n) = n2 o, como diremos más a menudo, que − 3n = Θ(n2 ). Para ello, tenemos que
2
demostrar que hay dos números c1 y c2 tales que:
n2
c1 n2 ≤ − 3n ≤ c2 n2
2
si n es suficientemente grande. Dividiendo por n2 esto es:
1 1
c1 ≤ − 3 ≤ c2
2 n
1 1
Y si tomamos por ejemplo c1 = , c2 = , entonces para n > 7 las desigualdades se cumplen.
14 2
Desde luego, existen otros valores de c1 y c2 que se podrı́an haber usado, pero eso no es impor-
tante. Lo que importa es que exista algún valor que sirva.

7
2.4.2. Notación O
La notación Θ que hemos visto nos obliga a acotar el comportamiento de las funciones tanto
superior como inferiormente. En muchas ocasiones esto es demasiado complicado de hacer en
un análisis asintótico. Por ejemplo, puede ser fácil estimar cuánto va a tardar como mucho
el algoritmo en resolver el peor ejemplar de tamaño n. Pero puede resultar muy difı́cil acotar
inferiormente esa misma cantidad. No obstante, la cota superior sigue siendo una información
muy valiosa sobre la calidad del algoritmo, y vamos a introducir una notación para referirnos a
ella.

Definición 2.4 (Orden O). El conjunto O(f (n)) está formado por todas las funciones g(n)
que asintóticamente están acotadas superiormente por un múltiplo constante de f (n). Es decir,
g(n) está en O(f (n)) si existe una constante c tal que

g(n) ≤ cf (n) para todo n suficientemente grande

Entonces, si tmáx (n) es el tiempo que el algoritmo emplea en resolver el peor ejemplar de
tamaño n, al decir que tmáx (n) = O(f (n)), estamos acotando superiormente (salvo una cons-
tante) el tiempo que el algoritmo emplea para ese ejemplar, y en consecuencia también acota-
mos el tiempo que tarda para todos los ejemplares de tamaño n. Eso hace que la información
tmáx(n) = O(f (n)) sea especialmente valiosa: nos da una garantı́a del rendimiento del algoritmo.
En cambio, si sabemos que tmáx (n) = Ω(f (n)), la estimación que estamos obteniendo nos dice
que el peor caso de tamaño n no tarda menos de una cierta cantidad. Pero puede haber otros
ejemplares del mismo tamaño que tarden mucho menos, porque éste es el peor. Ası́ que esa
información no es muy útil a la hora de predecir el comportamiento del algoritmo.

2.4.3. Notación Ω
Para completar el repertorio de notaciones asintóticas, introducimos la última de las defini-
ciones:

Definición 2.5 (Orden Ω). El conjunto Ω(f (n)) está formado por todas las funciones g(n)
que asintóticamente están acotadas inferiormente por un múltiplo constante de f (n). Es decir,
g(n) está en Ω(f (n)) si existe una constante c tal que

cf (n) ≤ g(n) para todo n suficientemente grande

La primera observación evidente es que las tres notaciones están relacionadas ası́:

Teorema 2.6. Sean cuales sean f (n) y g(n), se cumple que g(n) = Θ(f (n)) si y sólo si g(n) =
O(f (n)) y a la vez g(n) = Ω(f (n)).

Órdenes de complejidad Todas las funciones g(n) que cumplen g(n) = O(f (n)) se dice que
son del orden de complejidad O(f (n)). Cuando sea g(n) = Θ(f (n)) diremos que g(n) es del
orden de complejidad exacto Θ(f (n)).

2.5. Operaciones con órdenes de complejidad. Funciones anónimas


¿Cómo se demuestra, por ejemplo, que g(n) = Θ(f (n))? En principio siempre puede acudirse
a la definición, y buscar las constantes c1 , c2 , etcétera. Pero ésto es demasiado detallado, y nos
obligarı́a a conocer con precisión la fórmula de g(n). No es eso lo que queremos hacer. Serı́a
bueno contar con métodos más cómodos para aplicarlos durante el análisis de los algoritmos.
Vamos a ver ahora algunas de las reglas que nos facilitan el trabajo:

8
2.5.1. Funciones polinómicas
El orden de complejidad de cualquier polinomio viene determinado por su término de mayor
grado:

Teorema 2.7. Si g(n) = ak nk + an−1 k n−1 + · · · + a2 k 2 + a1 k + a0 entonces g(n) = Θ(nk )

2.5.2. Usando lı́mites para calcular órdenes de complejidad


Teorema 2.8 (Regla del lı́mite).

1. Si se cumple
f (n)
lı́m =0
n→∞ g(n)
entonces f (n) = O(g(n))

2. Si se cumple
f (n)
lı́m = c 6= 0
n→∞ g(n)
entonces f (n) = Θ(g(n))

2.5.3. Suma y producto de órdenes de complejidad


Muchas veces obtenemos una fórmula sumando o multiplicando otras fórmulas. La siguientes
herramientas son útiles para esos casos:

Teorema 2.9 (Regla de la suma). Si g1 (n) = Θ(f1 (n)) y g2 (n) = Θ(f2 (n)), entonces

g1 (n) + g2 (n) = Θ (máx(f1 (n), f2 (n)))

Esta regla es cierta si en lugar de Θ se usan órdenes O o Ω

Teorema 2.10 (Regla del producto). Si g1 (n) = Θ(f1 (n)) y g2 (n) = Θ(f2 (n)), entonces

g1 (n) · g2 (n) = Θ (f1 (n) · f2 (n))

Esta regla es cierta si en lugar de Θ se usan órdenes O o Ω

2.5.4. Funciones anónimas


Ya sabemos que una igualdad como g(n) = Θ(f (n)) debe interpretarse como un sı́mbolo de
pertenencia. Pero además de este uso, la notación asintótica también se emplea en ecuaciones
como en
n2 + 3n − 1 = n2 + Θ(n)
¿Qué significa la notación asintótica en este caso? Pues significa que existe una función h(n) =
3n − 1, que es de orden Θ(n) y tal que n2 + 3n − 1 = n2 + h(n). En general una ecuación como

g(n) = g̃(n) + Θ(f (n))

significa que existe una función h(n) que es del order Θ(f (n)) y que se cumple

g(n) = g̃(n) + h(n)

9
Este tipo de ecuaciones se emplean normalmente para destacar que g̃(n) es la parte importante
de la fórmula g(n), la parte que determina su orden asintótico. Y lo que queremos hacer es no
preocuparnos de la otra parte, h(n). Queremos olvidarnos de los detalles y decir “esta parte
h(n) está controlada en tamaño, y no es importante”. Por eso a la función h(n) se la denomina
función anónima: porque en realidad no nos interesa conocerla en detalle, sólo saber controlar
su orden asintótico.
La notación de funciones anónimas es especialmente útil para simplificar el análisis de los
algoritmos cuando se combina con las reglas del producto y de la suma. En efecto, si tenemos
dos igualdades:
g1 (n) = g̃1 (n) + O(f1 (n))
g2 (n) = g̃2 (n) + O(f2 (n))
Entonces se tiene:

g1 (n) + g2 (n) = (g̃1 (n) + g̃2 (n)) + O(máx(f1 (n), f2 (n)))

Lo cual nos permite obtener una descripción de g1 (n) + g2 (n) sin tener que preocuparnos de los
detalles de ambas fórmulas. Veamos un ejemplo, usando en este caso la regla del producto:

Ejemplo 2.11. Supongamos que es:

g1 (n) = n3 + O(n2 )
g2 (n) = n5 + O(n4 )

Entonces

g1 (n)g2 (n) = (n3 + O(n2 ))(n5 + O(n4 )) = n8 + n3 O(n4 ) + n5 O(n2 ) + O(n2 )O(n4 )

Esta igualdad debe interpretarse como que todos los sı́mbolos O(nk ) representan a funciones
anónimas del orden correspondiente. Pero entonces la regla del producto nos dice directamente
que O(n2 )O(n6 ) = O(n8 ). Y es evidente que n3 = O(n3 ), ası́ que n3 O(n4 ) = O(n7 ). Por la
misma razón n5 O(n2 ) = O(n7 ). Ası́ que tenemos:

g1 (n)g2 (n) = n8 + O(n7 ) + O(n7 ) + O(n6 )

Ahora basta aplicar la regla de la suma para concluir que O(n7 )+O(n7 )+O(n6 ) = 0(n7 ). Porque
al sumar se toma el máximo, y asintóticamente está claro que ese máximo es O(n7 ); el lector
puede pensar que es O(2n7 ), pero hay que recordar que en los órdenes asintóticos las constantes
multiplicativas son irrelevantes. La conclusión es que:

g1 (n)g2 (n) = n8 + O(n7 )

Y para obtener esta estimación no hemos necesitado preocuparnos de los detalles concretos de
las fórmulas g1 (n) y g2 (n). Con un poco de práctica todo este cálculo se hace a simple vista, y
de esa forma el análisis del orden de complejidad de un algoritmo gana mucho en agilidad.

3. Análisis de algoritmos
3.1. Operaciones elementales.
Una operación elemental de un algoritmo es una operación cuyo tiempo de ejecución se
puede acotar por una constante que sólo depende de la implementación (la máquina que se usa,
el lenguaje, el compilador, etcétera.) Por lo tanto esa constante no depende del caso concreto al
que se aplique el algoritmo, no depende de n.

10
Cuando una operación no es elemental, porque el tiempo que se tarda en realizarla depende
del caso concreto en el que estamos, la estrategia consiste en descomponer esa operación en
operaciones más sencillas, hasta llegar a operaciones elementales. Y después calcular el tiempo
que emplea el algoritmo, contando el número total de operaciones elementales que realiza. Si c
es la constante que acota el tiempo que se tarda en una operación elemental, y para resolver un
cierto caso el algoritmo tiene que hacer k operaciones elementales, entonces el tiempo que tarda
está acotado por kc. Si cambiamos la implementación el valor de c puede cambiar, pero no el
de k, ası́ que las cotas asintóticas de eficiencia no se ven alteradas. De esa manera habremos
conseguido nuestro objetivo de dar una medida de la calidad de un algoritmo que no dependa
de la implementación.
Como se ve el análisis de la eficiencia de un algoritmo sigue un curso similar al de su diseño,
con una estrategia de refinamiento que descompone acciones más complejas en acciones más
sencillas, hasta llegar al nivel de las operaciones elementales.

3.1.1. Modelos de computación.


Otra observación pertinente en este momento es que, para que el análisis de la eficiencia
de los algoritmos quede bien definido, es absolutamente necesario dejar claro cuáles son las
operaciones que pueden considerarse elementales. Hacer eso es lo que se conoce como definir un
modelo de computación. Y lo primero que hay que saber es que no hay un modelo de computación
único. El modelo de computación describe, sin entrar en demasiados detalles, la arquitectura
del hardware que se va a emplear en la implementación del algoritmo. Y por ejemplo, si se
emplean ordenadores con más de un procesador, en los que se pueden realizar varias operaciones
simultáneamente, entonces el repertorio de operaciones que se consideran elementales cambia.
De la misma forma, si alguna vez llegan a estar disponibles los ordenadores cuánticos, su juego
de operaciones elementales será radicalmente diferente en algunos aspectos del que ahora resulta
habitual. y ya hay algoritmos diseñados a la espera de esos ordenadores cuánticos, que calculan
su eficiencia teniendo en cuenta las operaciones que serı́an posibles con ellos.
El modelo que nosotros vamos a emplear en este curso se ajusta en lı́nea generales a la ar-
quitectura de las máquinas que casi todos conocemos y tenemos en casa: máquinas con un sólo
procesador, que siguen la arquitectura de Von Neumann. En este modelo vamos a considerar
como operaciones elementales (al menos) las siguientes: operaciones de asignación, de entrada/-
salida, o aritméticas mientras se lleven a cabo con tipos elementales de datos (enteros, reales,
booleanos en el sentido habitual en los lenguajes de programación). Estas mismas operaciones
no pueden considerarse elementales si involucran tipos estructurados (matrices, registros, listas,
árboles, grafos y el resto de tipos que veremos en temas posteriores) o si el tamaño de los datos
obliga a representarlos usando estructuras que exceden del tamaño previsto en los tipos básicos.
Por ejemplo, un entero de un millón de cifras no puede representarse con el tipo INTEGER de un
lenguaje como Pascal, o Modula-2.

3.2. Estructuras básicas de control


Nuestro programas utilizan siempre una serie de estructuras de control básicas: sentencias
condicionales tipo IF-THEN, bucle FOR o WHILE. Vamos a ver brevemente como se debe enfocar el
análisis de la eficiencia a partir de un estudio de estas estructuras. Debe quedar claro que no se
pueden dar reglas mecánicas para este análisis, y que será la experiencia, adquirida lo largo de
los ejemplos que vamos a ver en todo el curso, la que permita llevar a cabo un análisis correcto.
Aquı́ se trata sólo de presentar las ideas básicas, que iremos refinando poco a poco.

11
3.2.1. Secuencia de instrucciones
Cuando tenemos una lista de instrucciones en nuestro programa que se ejecutan una tras
otra, el tiempo de ejecución total es simplemente la suma de los tiempos de cada una de ellas.

3.2.2. Sentencias condicionales tipo IF-THEN


.
La ejecución de una de estas sentencias, tal como
IF A
THEN
B
ELSE
C
END
supone:

1. Evaluar la sentencia A. Supongamos que esto lleva un tiempo tA (n), que puede depender
del tamaño n del caso concreto del algoritmo.

2. Según A sea cierto o falso, evaluar B o C. Llamemos tB (n) y tc (n) a los tiepos que se tarda
en evaluar estas sentencias según el tamaño del caso de que se trate.

Entonces está claro que el tiempo total de evaluación de la sentencia condicional está acotado
por
tA (n) + máx(tB (n), tC (n))
o, más sencillamente, por:
máx(tA (n), tB (n), tC (n))

3.2.3. Bucles FOR


Queremos analizar el tiempo que emplea el algoritmo en bucles similares a éste:

FOR i : = 1 TO m DO
P( i )
END
donde P (i) representa el grupo de sentencias que forman el cuerpo del bucle. El caso más
fácil de todos es aquel en el que el tiempo que se tarda en ejecutar P (i) no depende en realidad
de i, incluso aunque dependa de n, el tamaño el caso de interés. En ese caso, y siempre que sea
m ≥ 1, si el tiempo que se tarda en ejecutar P (i) es t(n), el tiempo total de ejecución del bucle
será mt(n).
Es importante entender que en general este bucle aparecerá como parte dentro de un algo-
ritmo más complejo. En esos casos, el valor del parámetro m puede depender de cual sea el caso
que se esté tratando. Ası́ que en general tenemos que escribir m(n)t(n) incluso cuando el tiempo
de P (i) no depende de n. Por otra parte, puede ocurrir que el valor m = 0 (o algún otro valor
que 1)aparezca muchas veces a lo largo de la ejecución. Cuando m < 1 el cuerpo del bucle no
se ejecuta, pero eso no significa que el tiempo de ejecución del bucle sea 0, porque el algoritmo
tiene que comparar m con 1 y eso cuesta un cierto tiempo.
Por otra parte los casos en los que el tiempo que tarda P (i) en ejecutarse dependen de i
son más complicados, porque para obtener el tiempo que tarda en completarse el bucle debemos
calcular y sumar los tiempos que emplea cada una de las iteraciones. Estas sumas pueden ser
muy complicadas. Veremos más abajo algunos ejemplos.

12
Bucles WHILE El análisis de un bucle WHILE tal como éste:
WHILE A DO
B
END
repite las consideraciones que ya hemos tratado en el caso de los bucles FOR (y lo mismo
ocurre con otro tipo de bucles, tipo REPEAT-UNTIL, SWITCH, etc.) El análisis puede ser modera-
damente sencillo si el cuerpo B del bucle no depende de la condición A. Pero en general el análisis
de estos bucles es la parte más difı́cil de nuestro trabajo. Como hemos dicho antes, en lugar de
tratar de dar reglas generales precisas, preferimos trabajar a partir de ejemplos.
Otra estrategia para el análisis de la complejidad de un algoritmo iterativo es expresarlo de
forma recursiva, con técnicas como las que se han visto en la asignatura Programación II. Y
entonces llevar a cabo un análisis como el que veremos más abajo para los algoritmos recursivos.
No obstante ésto, debe tenerse en cuenta que el análisis de algoritmos recursivos que se aprende
en Programación II sólo cubre los casos más sencillos de la recursividad. La transformación de un
algoritmo iterativo en recursivo nos puede conducir a esquemas recursivos mucho más complejos
que los que allı́ se estudiaron, de manera que el esfuerzo de la transformación haya sido inútil.

3.2.4. Instrucciones crı́ticas


Una instrucción crı́tica de un algoritmo es una instrucción elemental que se ejecuta más veces
dentro del algoritmo que ninguna otra. Si existe una instrucción crı́tica y podemos localizarla,
entonces el análisis del algoritmo se simplifica, porque basta con contar el número de veces que se
ejecuta esa instrucción para tener el orden exacto de complejidad del algoritmo. Pero es posible
que no exista tal instrucción.

3.3. El caso especial de los algoritmos recursivos.


Los algoritmos recursivos se han visto ya en la asignatura Programación II. Nosotros tra-
taremos con ellos, ampliando lo que se aprendió entonces, cuando veamos nuevos ejemplos de
algoritmos recursivos. Aquı́ nos limitaremos a recordar lo que se aprendió en esa asignatura en
cuanto a la eficiencia de estos algoritmos.

3.3.1. Disminución del tamaño del caso por división


Teorema 3.1. Si el tiempo t(n) que el algoritmo emplea en resolver un ejemplar de tamaño n
sigue una ecuación recurrente de la forma:
(
cnk si 0 ≤ n < b
t(n) = k
at(n − b) + cn si n ≥ b
entonces se tiene: (
Θ(nk+1 ) si a = 1
t(n) =
Θ(an div b ) si a > 1
La relación de recurrencia en este caso significa que al tratar de resolver un problema de
tamaño n el algoritmo:
1. hace a llamadas recursivas
2. esas llamadas recursivas son problemas iguales, pero de tamaño n − b
3. Las operaciones auxiliares que se hacen en un problema de tamaño n ocupan un tiempo
cnk
Ese es el significado de los parámetros a, b, k que aparecen en este algoritmo.

13
3.3.2. Disminución del tamaño del caso por sustracción
Teorema 3.2. Si el tiempo t(n) que el algoritmo emplea en resolver un ejemplar de tamaño n
sigue una ecuación recurrente de la forma:
(
cnk si 1 ≤ n < b
t(n) = k
at(n/b) + cn si n ≥ b

entonces se tiene: 
k si a < bk
Θ(n )

t(n) = Θ(nk log n) si a = bk

Θ(nlogb a ) si a > bk

La relación de recurrencia en este caso significa que al tratar de resolver un problema de


tamaño n el algoritmo:

1. hace a llamadas recursivas

2. esas llamadas recursivas son problemas iguales, pero de tamaño n − b

3. Las operaciones auxiliares que se hacen en un problema de tamaño n ocupan un tiempo


cnk

Ese es el significado de los parámetros a, b, k que aparecen en este algoritmo.

4. Análisis de los algoritmos de ordenación


Vamos a utilizar los algoritmos de ordenación que hemos visto en el tema anterior como
ejemplos de la forma en que se realiza el análisis de la eficiencia de los algoritmos. Como hemos
visto, se trata esencialmente de contar el número de operaciones elementales que lleva a cabo el
algoritmo.
Estos algoritmos ordenan el vector mediate dos operaciones:

1. comparaciones entre los elementos, que suponen evaluar una desigualdad como ésta:

a[i] < a[j]

2. y movimientos, que se traducen en asignaciones:

x := a[i]

Si el vector es un vector de enteros (tipo INTEGER), ambas operaciones se pueden considerar


elementales. Pero esto no es cierto si los elementos del vector son de otro tipo, por ejemplo cade-
nas de caracteres que se desean ordenar alfabéticamente. En cualquier caso, las comparaciones
y movimientos son las operaciones básicas de estos algoritmos y aunque no sean elementales, se
puede suponer que un movimiento supone hacer un cierto número fijo c1 de operaciones elemen-
tales, y una comparación supone un número c2 de operaciones elementales. En general, ocurre
que el coste de los movimientos supera al de las comparaciones. Es decir, que c1 > c2 .
Resumiendo, si C es el número de comparaciones que realiza el algoritmo, M el de movi-
mientos, y A el de operaciones elementales auxiliares, el total de operaciones elementales del
algoritmo de ordenación es:
c1 M + c2 C + A

14
En general, el coste en tiempo que suponen las operaciones auxiliares es muy inferior al de los
movimientos y comparaciones. Ası́ que lo que vamos a hacer para cada uno de los algoritmos
es contar, aproximadamente (con un punto de vista asintótico), cuántos movimientos y cuántas
comparaciones realiza el algoritmo para un ejemplar de tamaño n. Además debemos hacer este
recuento para el peor caso de tamaño n, para el mejor y además hacer un promedio para todos
los casos de tamaño n.

4.1. Inserción directa


Recordemos el pseudocódigo:

Procedimiento Ordenación Por Inserción(A:vector[0..n] de enteros);


para i:=2 hasta n hacer
a[0]:=a[ i ];
j:=i−1;
mientras a[0]<a[j ] hacer
a[ j+1]:=a[j ];
j:=j−1;
fin mientras
a[ j+1]:=a[0];
fin para
Vamos a contar el número de movimientos y comparaciones:

4.1.1. Movimientos
Mejor caso: Es fácil ver que se produce cuando el vector inicialmente ya está ordenado. En
este caso, en cada iteración el algoritmo se limita a colocar el elemento a[i], que queremos
ordenar entre los i − 1 primeros, en la posición 0 del centinela. El bucle mientras no se
ejecuta nunca, porque los elementos que comparamos son siempre menores que el centinela.
Ası́ que al salir del bucle j + 1 sigue siendo i, y se lleva a cabo un segundo movimiento
para colocar el centinela en la posición i. En total, dos movimientos por iteración y n − 1
iteraciones, supone que el número de movimientos del mejor caso es:

Mmı́n = 2(n − 1)

Peor caso: Este caso ocurre cuando inicialmente el vector está ordenado en sentido con-
trario. Porque entonces, tras mover a[i] a la posición del centinela, se entra siempre en el
bucle mientras, porque el centinela es menor que todos los elementos a[1], . . . , a[i − 1].
Ası́ que hay que desplazarlos todos. En la primera iteración del bucle para eso supone
mover a la derecha el primer elemento del vector. En la segunda iteración hay que mover
dos elementos. En la tercera, tres, etcétera, hasta que en la última movemos n − 1 elemen-
tos. Y después de cada iteración hay que mover el centinela al hueco que hemos creado.
Ası́ que el total de movimientos se obtiene sumando los de cada iteración. La siguiente
tabla muestra el número de movimientos:

15
iteración i mover centinela mover elementos de 1 a i − 1 mover centinela
2 1 1 1
3 1 2 1
4 1 3 1
.. .. .. ..
. . . .
n 1 n−1 1
Total n−1 1 + 2 + · · · + (n − 1) n−1
Y finalmente el número total de movimientos es:
n−1
X n(n + 1) n2 + 3n − 4
Mmáx = (n − 1) + k + (n − 1) = 2(n − 1) + =
2 2
k=1

La suma 1 + 2 + · · · + (n − 1) se lleva a cabo observando que el primer y último término


suman n, que el segundo y penúltimo también suman n, y ası́ sucesivamente; ası́ que
tenemos (n − 1)/2 parejas que suman n. Este truco funciona en general para cualquier
suma de una progresión aritmética.
Aunque en este caso hemos hecho una cuenta completa del número de movimientos, no
siempre será posible tanto detalle. Eso en cualquier caso, no es relevante, en tanto seamos
capaces de obtener el término que asintóticamente es más importante.
Promedio: El análisis del caso promedio siempre es el más complicado. Tenemos que pensar
en todos los posibles vectores iniciales (hay infinitos) y suponer que todos son igualmente
probables (lo cual puede no ser cierto en una aplicación concreta del algoritmo, y habrı́a
que tenerlo en cuenta).
En el caso de este algoritmo de inserción, los dos movimientos del centinela se efectúan
siempre. Pero el movimiento del bucle mientras interior sólo se produce si el centinela es
menor que a[j]. La probabilidad de que un número entero sea menor que otro, cuando se
escogen al azar, es 1/2. Ası́ que, en promedio, en el bucle mientras se llevan a cabo la
mitad de los movimientos que se harı́an en el peor caso, cuando se hacen todos los posibles.
Es decir:
1 n(n + 1) n2 + 9n − 10
Mmed = 2(n − 1) + =
2 2 4

4.1.2. Comparaciones
El recuento de las comparaciones que se efectúan en este método es similar al que hemos
hecho para los movimientos.
Mejor caso: En el caso de un vector inicialmente ya ordenado, hacemos una sola com-
paración por cada iteración del bucle para, que nos impide entrar en el bucle mientras.
Ası́ que en este caso el número de comparaciones coincide con el de iteraciones
Cmı́n = n − 1

Peor caso: Si el vector inicial está ordenado en sentido contrario, entonces entramos en el
bucle mientras tantas veces como sea posible. Y cada vez que se entra en el bucle se evalúa
la comparación que lo controla. Ası́ que por cada movimiento se efectúa una comparación.
Pero además se efectúa una comparación extra, con el centinela, que sirve para salir del
bucle y que no conduce a ningún movimiento. La suma que hay que hacer es parecida a
la que hicimos en el caso de los movimientos:
(n − 1)(n + 2) n2 + n − 2
Cmáx = 2 + 3 + 4 + · · · + n = =
2 2

16
Promedio: Un razonamiento similar al que hicimos en el caso de los movimientos muestra
que el número de comparaciones en promedio es la mitad de las que hicimos en el peor
caso. Es decir:
1 n2 + n − 2
Cmed = Cmáx =
2 4

4.1.3. Conclusiones
Como ya hemos dicho, en general el peso de los movimientos supera al de las comparaciones
en el recuento de operaciones elementales. Ası́ que el término asintóticamente dominante a la
hora de calcular el tiempo empleado por el algoritmo de inserción es el de los movimientos. Ese
tiempo es un múltiplo constante del número de movimientos que realiza el algoritmo. A la vista
de los anteriores resultados se deduce que para el algoritmo de inserción directa:

tmı́n = Θ(n), tmáx = Θ(n2 ), tmed = Θ(n2 )

4.2. Inserción binaria


La inserción binaria sólo cambia la forma en la que se busca la posición de inserción, pero
no afecta al número de movimientos. Es fácil entender que no vamos a obtener una mejora en
la calidad asintótica del algoritmo, a pesar de que el tiempo de ejecución será sin duda mejor.
Nos limitamos por tanto al recuento de comparaciones.

4.2.1. Comparaciones
Para localizar la posición de inserción entre los i primeros tengo que hacer búsqueda binaria
en un vector de i elementos. La búsqueda binaria tiene asintóticamente un coste del orden dlog ie
(ver el texto base de Programación II, pág. 77; allı́ está búsqueda se llama dicotómica). Ası́ que
para hacer todas las búsquedas que necesito tengo que hacer:

dlog 2e + dlog 3e + · · · + dlog n − 1e

Esta suma se puede aproximar bastante bien (en el caso del logaritmo) por la integral
Z n
log xdx = n(log n − log e) + log e
1

En cualquier caso, el número de comparaciones es del orden n log n, y asintóticamente

n log n  n2

lo cual demuestra que para la inserción binaria, el número de movimientos es, con mucho, el que
determina la eficacia del algoritmo.

17
4.3. Selección directa
Recordemos el pseudocódigo

Procedimiento Ordenación por selección(VAR a:vector[1..n] de


enteros);
VAR
PosMin, Min:entero;
i,j,k:entero;
para i desde 1 hasta n − 1 hacer
{Buscamos el mı́nimo en las posiciones de la i a la n.}
P osM in := i
M in := a[P osM in]
para j desde i + 1 hasta n hacer
si a[j] < M in entonces
P osM in := j
M in := a[P osM in]
fin si
fin para
{y ahora colocamos ese mı́nimo en la posición i}
a[P osM in] := a[i];
a[i] := M in;
fin para

El algoritmo, para cada i de 1 a n busca el mı́nimo en las posiciones i a n y lo coloca en


la posición i del vector. Como en el caso anterior, la clave del análisis de este algoritmo es un
recuento del número de movimientos y comparaciones.

4.3.1. Comparaciones
Las comparaciones se usan en este algoritmo para buscar, en el bucle para interno, el mı́nimo
entre las posiciones de la i a la n. Y por eso es fácil entender que el número de comparaciones no
depende del vector inicial. En la primera iteración del bucle interno hacemos n−1 comparaciones,
en la siguiente n − 2, y ası́ sucesivamente, hasta que en la última hacemos 1 comparación

n2 − 2
C = (n − 1) + (n − 2) + · · · + 1 =
2
Como hemos dicho, el número de comparaciones es el mismo en todos los casos.

4.3.2. Movimientos
Los movimientos de este algoritmo que dependen del vector con el que trabajamos son los
que quedan dentro de la sentencia condicional:

si a[j] < M in entonces


P osM in := j
M in := a[P osM in]
fin si

Y el número de veces que se ejecuta el cuerpo de este condicional depende de la forma en


que estén ordenados los elementos de las posiciones finales del vector, de la i a la n.

18
Mejor caso: Es fácil ver que el mejor caso de este algoritmo también aparece si el vector
inicial ya está ordenado. Porque entonces al buscar el mı́nimo, lo localizamos en la primera
posición, la i. Se entra una vez en el condicional, pero ya no se vuelve a entrar. Es decir,
que en cada iteración del bucle para externo se realizan tres movimientos:
Mmı́n = 3(n − 1)

Peor caso: También ocurre cuando el vector inicial está ordenado en sentido contrario.
En cada vuelta del bucle interno un elemento intercambia su posición con el simétrico, y
ambos quedan ya ordenados. Pero hasta llegar al mı́nimo se guardan en la variable auxiliar
todos los que encontramos. Ası́ que en la primera iteración,entramos en el condicional n−1
veces. En la siguiente iteración, entramos no una, sino dos veces menos, es decir n − 3,
porque ahora el último elemento del vector es el máximo y no se guarda en la variable
auxiliar. En la siguiente iteración se entra en el condicional n − 5 veces, y ası́ la zona de
búsqueda va disminuyendo cada vez en dos, uno por cada extremo, hasta llegar al centro
del vector. En ese momento el vector ya está ordenado y en las siguientes iteraciones no
se entra en el condicional. La suma de movimientos dentro del condicional por lo tanto es
la siguiente, suponiendo un número par de elementos:
n2
(n − 1) + (n − 3) + · · · + 1=
| {z } 4
n/2 términos
Puede comprobarse que el resultado es el mismo si el vector tiene una cantidad impar de
elementos.
A estos movimientos hay que sumarles los 3(n − 1) que se efectúan incondicionalmente,
ası́ que
n2
Mmáx = 3(n − 1) +
4
Promedio:
Atención: Este análisis es de una complejidad mayor que los anteriores, porque exige
conocimientos de la teorı́a de probabilidades y sobre la suma de la serie armónica.

Tenemos que calcular el promedio de movimientos en el condicional. En la primera itera-


ción el condicional examina los elementos del 2 al n. Si el elemento a[2] nos hace entrar
en el condicional (y hacer un movimiento) es porque es menor que el primero. La proba-
1
bilidad de que eso ocurra es . A continuación, si el elemento a[3] nos hace entrar en el
2
condicional, es porque es menor que los dos que le preceden. Eso ocurre con probabilidad
1
. Y ası́ sucesivamente: la probabilidad de entrar en el condicional en el último elemento
3
1
es . En la siguiente iteración la situación es similar, pero empezamos a partir de a[3], y
n
ası́ sucesivamente, en cada iteración vamos reduciendo la zona de búsqueda del mı́nimo.
Ahora vamos a calcular el promedio del número de desplazamientos. Recordemos que en
estadı́stica el promedio es la esperanza de una variable aleatoria X. En nuestro caso la
variable aleatoria es Mi , el número de movimientos en la iteración número i del bucle
externo. En esa iteración buscamos en las posiciones de la i + 1 a la n, es decir en n − i
posiciones. Llamemos Mi,j a una variable aleatoria que vale 1 o 0 según que hagamos o no
un movimiento al llegar al elemento j en la iteración i (con j > i). Obsérvese que:
Mi = Mi,i+1 + Mi,i+2 + · · · + Mi,n

19
La probabilidad de que Mi,j valga 1 (se ha hecho un movimiento) es, como hemos visto,
1
la de que a[j] sea el mı́nimo entre a[i], . . . , a[j]. Y esa probabilidad es . Ası́ que
j−i+1
el valor medio de Mi,j es:
 
1 1 1
E(Mi,j ) = 1 · +0· 1− =
j−i+1 j−i+1 j−i+1
Recordemos que la esperanza de una variable aleatoria X que puede tomar los valores
X1 , . . . , Xk con probabilidades respectivas p1 , . . . , pk es
E(X) = p1 X1 + p2 X2 + · · · + pk Xk
Por tanto:
1 1 1
E(Mi ) = E(Mi,i+1 ) + E(Mi,i+2 ) + · · · + E(Mi,n ) = + + ··· + = Hn−i − 1
2 3 n−i+1
siendo Hk los números armónicos. Puesto que
Hk ≈ ln k + γ
siendo γ = 0,577216 . . . la constante de Euler, se puede aproximar
E(Mi ) ≈ 1 + ln(n − i) + γ
Ası́ pues, el promedio del número total de movimientos es:
n−1
X
Mmed = E(M ) = E(M1 )+E(M2 )+· · ·+E(Mn−1 ) = n(γ+1)+ log(n−k) ≈ n(log n+γ)
k=1

4.3.3. Conclusiones
La complejidad temporal del algoritmo de selección viene determinada, como en el caso de
la inserción, por el número de movimientos y comparaciones. El número de comparaciones, en
todos los casos, es cuadrático en n, porque no depende del orden inicial del vector. Ası́ que
tmı́n = Θ(n2 ), tmáx = Θ(n2 ), tmed = Θ(n2 )
para este algoritmo.
Sin embargo, la virtud de este algoritmo está en el bajo número de movimientos que se
efectúan en promedio, del orden n log n como hemos visto. Eso significa que, entre los algoritmos
elementales, este suele ser el preferido para ordenar un vector.

4.4. Burbuja
Este algoritmo procede iterativamente tratando de hundir sucesivamente cada elemento del
vector, de manera que en cada iteración se añade un elemento a la parte final, ya ordenada del
vector. El pseudocódigo es:

Algoritmo: Ordenación por el método de la burbuja:


para i desde n bajando hasta 2 hacer
para j desde 1 hasta i − 1 hacer
si a[j] > a[j + 1] entonces
Intercambiar a[j] y a[j + 1]
fin si
fin para
fin para
Téngase en cuenta que cada intercambio supone 3 movimientos. Analicemos el número de
movimientos y comparaciones.

20
4.4.1. Comparaciones
La única comparación del algoritmo es la que aparece en la sentencia si. Es evidente que
el número de comparaciones no depende del vector inicial, porque la condición de esa sentencia
se evalúa una vez en cada iteración. Ası́ que el número de comparaciones coincide con el de
iteraciones, y es:
n2 − n
C = 1 + 2 + · · · + (n − 1) =
2
Esto indica que, independientemente de lo que ocurra con los movimientos, el orden asintótico
de este algoritmo también va a estar en Θ(n2 ) en todos los casos: mejor, peor y promedio.

4.4.2. Movimientos
Mejor caso: Si el vector ya está ordenado, no hay movimientos.

Mmı́n = 0

Peor caso: Cuando el vector inicialmente está ordenado en sentido contrario, entonces cada
comparación produce un intercambio, con tres movimientos implicados. Ası́ pues:

n2 − n
Mmáx = 3C= 3
2

Promedio: Cada vez que hacemos una comparación la probabilidad de que haya que hacer
un intercambio es 1/2. Ası́ que la mitad de las veces habrá que hacerlo, y se deduce que el
número promedio de movimientos es la mitad del número máximo. Ası́ que

1 n2 − n
Mmed = Mmáx = 3
2 4

4.4.3. Conclusión
El método de la burbuja no ofrece mejoras significativas con respecto a los otros métodos
que hemos visto, especialmente cuando se le compara con la ordenación por selección. Sólo tiene
sentido emplearlo en el caso de vectores que inicialmente ya están prácticamente ordenados. En
esos casos el algoritmo utiliza un número pequeño de movimientos para la ordenación.

4.5. Quicksort
El algoritmo Quicksort es un algoritmo recursivo, como los que se han visto en la asignatura
Programación II. Su pseudocódigo es:

Quicksort(VARv[1..n], p, q)
si p < q entonces
r :=Partición(v, p, q)
Quicksort(v, p, r − 1)
Quicksort(v, r + 1, q)
fin si

Por tanto podemos tratar de aplicar el esquema que se vio allı́ para analizar la eficiencia de los
algoritmos recursivos (ver la sección (3.3)). La fórmula recursiva para t(n) es fácil de obtener:
el algoritmo parte un vector de longitud n en dos trozos, de longitudes que vamos a llamar i y

21
n − i − 1, y les aplica el algoritmo. El tiempo que lleva hacer la partición es un múltiplo de n.
Pongamos cn para una cierta constante. De esa forma:

T (n) = T (i) + T (n − i − 1) + cn

A partir de esta fórmula se puede llevar a cabo el estudio del tiempo en el peor y mejor caso, y
en promedio.

Mejor caso: Este caso ocurre cuando el pivote que se elige tiene la propiedad de dividir
siempre al vector en dos trozos de longitud n/2. Entonces la ecuación recursiva se convierte
en
T (n) = 2T (n/2) + cn
Y las fórmulas para recurrencias producen directamente T (n) = Θ(n log n)

Peor caso: Se presenta cuando el pivote es siempre el máximo o el mı́nimo en cada partición,
de manera que una de las dos partes del vector queda vacı́a. Entonces i = 0, n−i−1 = n−1,
y la ecuación recursiva queda:

T (n) = T (n − 1) + cn

La solución es T (n) = Θ(n2 ).

Promedio: En este caso todos los tamaños posibles de la partición (es decir, todos los
valores de i) son igualmente probables. La probabilidad de cada uno de ellos es 1/n, y el
valor medio de T (i) (obsérvese que T (n − i − 1) tiene que ser el mismo) es
Pn−1
j=0 T (j)
n
con lo que la ecuación de recurrencia se convierte en:
Pn−1
j=0 T (j)
T (n) = 2 + cn
n
La dificultad en este caso está en que esta fórmula no es ninguno de los dos casos que se han
estudiado en Programación II. Ası́ que nos planteamos un estudio directo del problema.
Multiplicando por n la relación anterior:
n−1
X
nT (n) = 2 T (j) + cn2
j=0

Si se escribe esto para n − 1 será:


n−2
X
(n − 1)T (n − 1) = 2 T (j) + c(n − 1)2
j=0

Y restando se tiene:

nT (n) − (n − 1)T (n − 1) = 2T (n − 1) + 2cn − c

De donde (despreciado el término constante):

nT (n) = (n + 1)T (n − 1) + 2cn

22
Ası́ que:
T (n) T (n − 1) 2c
= +
n+1 n n+1
Y usando esta relación para n, n − 1, . . . , 2 se llega a:
n+1
T (n) T (1) X1
= + 2c
n+1 2 i
i=1

Por tanto
T (n) = O(n log n)

4.5.1. Conclusión
El algoritmo Quicksort es uno de los algoritmos de ordenación de vectores conocidos con
mejor rendimiento en promedio. El orden n log n es muy ventajoso comparado con el n2 de los
algoritmos elementales que habı́amos revisado. El inconveniente fundamental del Quicksort es
que su peor caso es cuadrático. No obstante, el comportamiento promedio es el mejor entre todo
los algoritmos de ordenación de vectores in situ que vamos a ver.

4.6. Ordenación por montı́culo


El análisis en el caso promedio de este algoritmo es complejo, ası́ que aquı́ nos vamos a
limitar dar una estimación asintótica del tiempo del peor caso de este algoritmo. Recordemos
aquı́ que este algoritmo tiene una descripción muy sencilla:

procedimiento OrdenacionPorMonticulo(var T [1..n]);


CrearMonticulo(T );
para k desde n bajando hasta 2; hacer
Intercambiar T [1] y T [k];
Hundir(T [1..k − 1], 1);
fin para

Como puede verse, primero debemos contar el número de operaciones necesarias para crear
un montı́culo. Después hay un bucle para con n−1 iteraciones, en el que se realiza un intercambio
(3 movimientos) y se llama al procedimiento Hundir, para hundir la raı́z de un montı́culo de
k − 1 elementos.
Empecemos por analizar el procedimiento que crea un montı́culo a partir de un vector. Su
código es:

procedimiento CrearMontı́culo(var T [1..n], i);


para i := n ÷ 2 bajando hasta 1 hacer
Hundir(T,i);
fin para

donde el procedimiento Hundir es

23
Procedimiento Hundir(var T [1..n], i);
k:=i;
repetir
j := k; {buscamos el hijo mayor del nodo j}
si 2j ≤ n y T [2j] > T [k]; entonces
k := 2j;
fin si
si 2j < n y T [2j + 1] > T [k]; entonces
k := 2j + 1;
fin si
intercambiar T [j] y T [k];
hasta que j=k;{en ese momento el nodo se ha hundido hasta su posición
final}

En cada repetición de este procedimiento Hundir se elige el máximo entre los dos hijos de
un nodo y ese mı́nimo se compara con el nodo. Eso supone dos comparaciones en cada nodo. Y
después se produce un intercambio, que implica tres movimientos. Estas operaciones se repiten
en cada nivel, hasta que a, lo sumo, se alcanza una hoja (aunque el proceso puede detenerse
antes). Sabemos que el nodo a[k] está en el nivel blog kc y que las hojas están en el nivel blog nc
o tal vez blog nc − 1 (si el árbol no está completo). Por lo tanto el número de comparaciones y
movimientos que implica una llamada Hundir(T,k) es proporcional a

(log n − log k)

Y obsérvese que hundir la raı́z de un montı́culo de n nodos supone un número de operaciones


proporcional a log n.
Ahora es fácil analizar el algoritmo de ordenación por montı́culo. En la fase de creación del
montı́culo el procedimiento Hundir se ejecuta para cada nodo a[k] con k de 1 a n/2. Y en la
segunda fase, aparte de un intercambio, se hunden las raı́ces de montı́culos con un número de
nodos que va desde n hasta 2. Ası́ que el total de operaciones es proporcional a:

X n
X
k = 1n/2(log n − log k) + log j
j=2

Ambas sumas tienen menos de n términos, y cada sumando en cada una de ellas es a lo sumo
log n. Ası́ que el total es menor que n log n. Eso significa que para la ordenación por montı́culo
se cumple:
tmáx (n) = Θ(n log n)

4.6.1. Conclusión
El algoritmo de ordenación por montı́culo tiene un comportamiento promedio complicado
de analizar, pero que es también de orden Θ(n log n). Sin embargo, el promedio de Quicksort
es mejor. A cambio, la ordenación por montı́culo tiene un caso peor mucho mejor que el de
Quicksort. Esa homogeneidad de comportamiento, con un coste promedio similar al del caso
peor, es la principal virtud de la ordenación por montı́culo, cuando se precisa garantizar que la
ordenación no va a transcurrir de forma inesperadamente lenta en algunos casos.

24

You might also like