Vous êtes sur la page 1sur 19

Capítulo 8: Análisis de algoritmos y crecimiento de

funciones

Introducción al análisis de algoritmos

Un algoritmo es una lista ordenada de instrucciones claras y precisas para resolver cierto
problema. Para resolver un determinado problema, generalmente podemos hacerlo de
distintas maneras. Nos interesa desarrollar la capacidad de analizar algoritmos para
determinar qué algoritmo resuelve el problema de la manera más “eficiente”.

Ejemplo 8.1:
Escribir un algoritmo que lea n números enteros y que imprima el menor.

Solución:

Algoritmo 1

Para resolver este problema, podemos leer los n datos y almacenarlos en un vector.
Luego, para hallar el menor, usamos una variable que siempre tenga el menor hasta el
momento. Esta variable se puede inicializar con el primer elemento del vector, y luego
comenzamos a comparar desde el segundo en adelante, actualizando la variable cada vez
que hallemos uno menor que el que teníamos. El siguiente algoritmo resuelve el
problema:

for i=1 to n
read(A[i]);
menor-hasta-el-momento = A[1];
for i= 2 to n
if A[i] < menor-hasta-el-momento
menor-hasta-el-momento = x;
menor = menor-hasta-el-momento;

Algoritmo 2

Quizás el lector haya notado que para resolver este problema no es necesario tener todos
los datos almacenados en memoria a la vez. Podemos ir guardando el menor hasta el
momento, y leer cada dato en la misma variable. Una vez haya actualizado el menor hasta
el momento, puedo leer el próximo dato en la misma variable anterior, pues ya no
necesitamos aquel valor. El menor hasta el momento se puede inicializar con el entero
mayor posible, de modo que al comparar con el primer dato que leamos, será seguro que
este dato será menor o igual al valor inicial escogido, y así actualizaremos el menor hasta
el momento con este primer dato. Entonces el algoritmo sería:
menor-hasta-el-momento = +∞;
for i = 1 to n
{
read(x);
if x < menor-hasta-el-momento
menor-hasta-el-momento = x;
}
menor = menor-hasta-el-momento;

¿Qué algoritmo es mejor? ¿Cuál será más eficiente? Hay varios elementos importantes
que es preciso considerar para determinar que un algoritmo es mejor o peor que otro. Por
ejemplo, dos características importantes de un buen algoritmo son su claridad y sencillez.
Estas son características importantes, sobre todo cuando se conoce que la gran mayoría
de los programas que se escriben en algún momento habrá que modificarlos, ya sea para
corregir errores, o para mejorarlo por alguna otra razón. Es imprescindible que la persona
que tendrá que modificar el algoritmo entienda perfectamente qué hace el algoritmo y
cómo lo hace.

Además de la claridad y sencillez de un algoritmo, nos interesa medir la eficiencia o


complejidad de un algoritmo en dos aspectos principales: uso de memoria y tiempo de
ejecución.

En términos del uso de memoria, diremos que un algoritmo es más eficiente en la medida
en que requiera menor uso de memoria de la computadora. En ese sentido, no hay duda
que el algoritmo 2 es mejor que el del 1, pues mientras que el segundo necesita tan sólo 4
espacios para variables (x, i, menor-hasta-el-momento y menor), el primero almacena
todos los datos a la vez, usando n variables y tres variables adicionales (i, menor-hasta-
el-momento y menor). Por lo tanto, el primer algoritmo requiere n + 3 espacios en
memoria para almacenar esa cantidad de números enteros, mientras que el segundo sólo
necesita espacio para cuatro enteros.

Sin embargo, el aspecto que generalmente nos interesa más (suponiendo que disponemos
de suficiente espacio en memoria), es el tiempo de ejecución. Nos interesa medir la
eficiencia o complejidad de un algoritmo como una propiedad del algoritmo propiamente,
independientemente de la máquina en que se ejecute. Evidentemente, el tiempo de
ejecución no será el mismo si ejecutamos el algoritmo en una 286 que en una Penthium.
Por esta razón, no usaremos el tiempo de ejecución directamente para medir la eficiencia
de un algoritmo, sino una medida que de alguna manera (pues no lo hace totalmente) no
tome en cuenta la velocidad o las características particulares de la computadora en que se
ejecuta el algoritmo. Esta medida es el número de instrucciones necesarias para ejecutar
el algoritmo. Este número debe ser, más o menos, el mismo independientemente de la
máquina.1

1
Aún en esta medida también puede haber diferencias de una máquina a otra, pues lo que es una
instrucción en una computadora quizás en otra se descompone en varias instrucciones, pero sin duda con
esta medida las diferencias se hacen menos significativas que si consideramos el tiempo de ejecución.
Definimos la función T(n) como el número de instrucciones necesarias para ejecutar un
algoritmo en el cual la data es de tamaño n. En ocasiones la cantidad de instrucciones que
se ejecutan depende de los valores específicos de la data. En este caso, podemos calcular
el número promedio de instrucciones que ejecuta un algoritmo (tomando en
consideración todos los posibles tipos de datos), o podemos analizar el peor caso
(partiendo de la combinación de datos que haría que el algoritmo ejecute el mayor
número de instrucciones), o el mejor caso.

Ejemplo 8.2:
Determine el número de instrucciones que se ejecutan en el siguiente ciclo:

for i = 1 to n
read(x);

Solución:
En el “for” la i empieza en 1 y compara con n. Si 1 ≤ n, entonces entra al ciclo. Luego la
i se incrementa a 2, y vuelve a comparar. Cuando la i llegue a n + 1, compara con n y
entonces es que no entra al ciclo. Por tanto, entrará al ciclo n veces, pero compara con la
n una vez adicional, es decir, n + 1 veces. n veces entra al ciclo, y una vez no. El “for”,
por lo tanto, tiene tres partes: la inicialización se ejecuta una sola vez, se hacen n+1
comparaciones, y la i se incrementa n veces. Por lo tanto, esta instrucción se descompone
en 1 + n + 1 +n = 2n + 2 instrucciones, y el “read” se ejecuta n veces. Por tanto, en total
se ejecutan
T(n) = (2n + 2) + n = 3n + 2

Ejemplo 8.3:
Halle T(n) para el Algoritmo 1 del ejemplo 8.1:

Solución:
En la tabla a continuación, aparece el número de veces que se ejecuta cada instrucción (o
en el caso del “for” el número de instrucciones en que se descompone).

# instrucción Instrucción # de veces que se


ejecuta
1 for i = 1 to n 2n + 2
2 read(A[i]); n
3 menor-hasta-el-momento = A[1]; 1
4 for i= 2 to n 2n
5 if A[i] < menor-hasta-el-momento n–1
6 menor-hasta-el-momento = A[i]; n–1
7 menor = menor-hasta-el-momento; 1
Total de instrucciones T(n) = 7n + 2

Como en el ejemplo anterior, la instrucción 1 se descompone en 2n + 2 instrucciones. La


2 se ejecuta n veces. La 3 se ejecuta una sola vez. En la instrucción 4, cuando la j tenga
valores de 2 a n, el algoritmo entrará al ciclo, es decir, entra n - 2 + 1 = n - 1 veces.
Cuando la j es n + 1 entonces no entra al ciclo. La inicialización se ejecuta 1 vez y la i se
incrementa n-1 veces. Por tanto, la instrucción 4 se descompone en 1 + n + n-1 = 2n
instrucciones, y la instrucción 5 se ejecuta n - 1 veces. La instrucción 6 se ejecuta cada
vez que hallemos un número menor del que teníamos hasta el momento, lo cual
dependerá de como esté organizada la data inicialmente. En este caso, analizaremos el
peor caso. Esto es, supondremos que esta instrucción se ejecutará cada vez que entremos
al ciclo, es decir, como si siempre la condición del “if” fuera cierta. Entonces esta
instrucción se ejecutaría n - 1 veces. La instrucción 7 se ejecuta una sola vez. De ahí
llegamos al resultado de que

T(n) = 7n + 2

Ejemplo 8.4:
Halle T(n) para el algoritmo 2 del ejemplo 8.1:

Solución:
La instrucción 2 se descompone en 1 + n + 1 + n = 2n + 2 instrucciones. El algoritmo
entra al ciclo para ejecutar las instrucciones 4, 5 y 6 n veces. Las instrucciones 4 y 5 se
ejecutan estas n veces, y la 6, en el peor caso, también se ejecutará n veces.

# instrucción Instrucción # de veces que se


ejecuta
1 menor-hasta-el-momento := ∞; 1
2 for i = 1 to n 2n + 2
3 { -
4 read(x); n
5 if x < menor-hasta-el-momento n
6 menor-hasta-el-momento = x; n
7 } -
8 menor = menor-hasta-el-momento; 1
Total de instrucciones T(n) = 5n + 4

Este algoritmo tiene T(n) = 5n + 4, mientras que el anterior tenía T(n) = 7n + 2. De


modo que el algoritmo 2 ejecutará menos instrucciones para n ≥ 2.

Ejemplo 8.5:
Determine el valor de T(n) para el siguiente algoritmo

for i = 1 to n
for j = 1 to n
write(i,j);

Solución:
La instrucción 1 se descompone en 1 + n + n +1 = 2n + 2 instrucciones. La instrucción 2
se descompone en 1 + n + 1 + n = 2n + 2 instrucciones para cada valor de i, desde 1 hasta
n. Por tanto, ahí se ejecutan n(2n + 2) instrucciones. La instrucción 3 se ejecuta para cada
i y cada j desde 1 hasta n. Por la regla del producto, se ejecuta n2 veces. Por tanto, este
algoritmo tiene T(n)

T(n) = (2n + 2) + n(2n + 2) + n2 = 3n2 + 4n + 2

Ejemplo 8.6:
Determine T(n) para el siguiente algoritmo de ordenar (“bubblesort”):

for i = 1 to n-1
for j = n downto2 i + 1
if A[j] < A[j - 1]
{
A[j] = temp;
temp = A[j - 1];
A[j - 1] = temp;
}

Solución:
La instrucción 1 se descompone en 1 + n + n -1 = 2n instrucciones. El análisis de la
instrucción 2 lo hacemos de la siguiente manera:

cuando la i es 1, la j baja de n a 2 è se ejecutan 1 + n + n -1 = 2n instrucciones


cuando la i es 2, la j baja de n a 3 è se ejecutan 1 + n - 1 n – 2 = 2n – 2 instrucciones
cuando la i es 3, la j baja de n a 4 è se ejecutan 1 + n – 2 + n – 3 = 2n – 4 instrucciones
cuando la i es 4, la j baja de n a 5 è 1 + n – 3 + n - 4 = 2n – 6 instrucciones
.
.
.
cuando la i es n - 1, la j baja de n a n è 1 + 2 + 1 = 4 instrucciones

Por tanto, la instrucción 2 se descompone en: 2n + 2n-2 + 2n-4 + 2n-6 + ... + 6 + 4


= 2n(n-1) – (0 +2 + 4 + 6 + … 2n-4) = 2n2-2n – (2n – 4)(n-1)/2 = n2 + n -2

Aquí aplicamos la fórmula para progresiones aritméticas.

La instrucción 3, al igual que en el peor caso las instrucciones 5, 6 y 7, se ejecutarán igual


número de veces. Para cada valor de i, será la misma cantidad de veces que se disminuye
la j. Esto es, n-1 + n-2 + n-3 + ... + 2 + 1.

Nuevamente, esto es una progresión aritmética, cuyo valor es: n(n - 1)/2. Entonces, el
valor de T(n) es:

2
“downto” tiene el efecto de hacer que la variable del ciclo vaya disminuyendo su valor en 1 cada vez que
se sale del ciclo.
T(n) = 2n + n2 + n -2 + 4n(n - 1)/2 = 3n2 + n - 2

# instrucción instrucción # de veces que se


ejecuta
1 for i = 1 to n - 1 2n
2
2 for j = n downto i + 1 n + n -2
3 if A[j] < A[j - 1] n(n - 1)/2
4 { -
5 A[j] = temp; n(n - 1)/2
6 temp = A[j - 1]; n(n - 1)/2
7 A[j - 1] = temp; n(n - 1)/2
8 } -
Total de instrucciones T(n) = 3n2 + n - 2

Crecimiento de funciones

El número de instrucciones que ejecuta un algoritmo es una función del tamaño de la


data. Nos interesa analizar cómo crece T(n) cuando la n aumenta.

Podemos dar ejemplos de instrucciones que se ejecutan una cantidad fija de veces, sin
importar el tamaño de la data. Por ejemplo, considere las siguientes instrucciones:

suma = 0;
for i = 1 to 10
{
read(x);
suma = suma + x;
}
promedio = suma/x;

La primera instrucción se ejecuta 1 vez. El “for” se descompone en 11 instrucciones, y


las dos instrucciones dentro del ciclo se ejecutan 10 veces cada una, para un total de 20
instrucciones. La última se ejecuta 1 sola vez. Por tanto, este algoritmo ejecuta 33
instrucciones, independientemente del tamaño de la data (que podría ser más de 10),
aunque el algoritmo sólo procese los primeros 10 datos. Este algoritmo tiene T(n) = 33.
Aún cuando la data aumente, el algoritmo ejecuta una cantidad fija de instrucciones.

Consideremos ahora un algoritmo para el cual T(n) sea una función lineal en n. Digamos
que T(n) = 5n + 1. La siguiente tabla muestra como aumenta T(n) con el aumento de n.

n T(n)
1 6
2 11
4 21
8 41
16 81
32 161
64 321

Podemos observar que si la n se duplica, T(n), aproximadamente, se duplica también. La


diferencia para que no se duplique exactamente la hace el 1 que se le suma a 5n, el cual
no es significativo cuando la n es grande. Por tanto, podemos decir que T(n) aumenta
proporcional a n. Esto quiere decir que cambios que ocurran en el tamaño de la data n se
reflejarán en la misma proporción en T(n). Si n se hace 4 veces mayor, entonces T(n) se
hará, aproximadamente, 4 veces mayor.

Definiremos la función orden O como la función que nos dice cómo aumenta T(n) a
medida que n aumenta. En este caso, como T(n) crece proporcional a n, decimos que
T(n) = O(n).

Ejemplo 8.7:
Determine cómo aumenta T(n) con aumentos de n para un algoritmo con T(n) = 2n2.
Solución:
Construyamos la tabla que indica los aumentos en T(n) a medida que n aumenta:

n T(n)
1 2
2 8
4 32
8 128
16 512
32 2048
64 8192

Vemos que si n se duplica, entonces T(n) se cuatriplica, es decir, se hace 4 veces más
grande. Si n se hace 4 veces mayor (por ejemplo, de 8 a 32), entonces T(n) se hace
2048/128 = 16 veces mayor. Esto implica que T(n) aumenta proporcionalmente con el
cuadrado de n. Un aumento del doble en n, es decir n aumenta a 2n, entonces T(n)
aumenta a (2T(n))2, es decir, a 4T(n). Esto es así porque T(n) es una función cuadrática
de n. En este caso decimos que T(n) es O(n2).
En este ejemplo, los cambios en n se reflejaban en proporción idéntica en T(n), pues la
expresión cuadrática de T(n) no tenía término lineal ni constante. Consideremos el
siguiente ejemplo, en el cual sí aparecen estos términos.

Ejemplo 8.8:
Determine cómo aumenta T(n) con aumentos de n para un algoritmo con
T(n) = 2n2 + 2n + 3

Solución:
Construyamos la tabla que indica los aumentos en T(n) a medida que n aumenta:

n T(n) T(n)/T(n/2)
1 7
2 15 15/7 = 2.14
4 43 43/15 = 2.87
8 147 3.42
16 547 3.72
32 2115 3.87
64 8323 3.94
128 33027 3.97

En este caso, cuando la n se duplica, por ejemplo, T(n) no se hará exactamente 4 veces
mayor. Sin embargo, la razón de T(n)/T(n/2) esta muy cerca de 4. Y lo importante aquí
es que a medida que n se hace cada vez más grande, esta razón se acerca cada vez más a
4. De manera que, a medida que n es mayor y mayor, T(n) crece más y más cerca con el
cuadrado de n. Por tanto, decimos que T(n) es O(n2).

Función O

Veamos una definición precisa de la función O.

Definición

Sean f(n), g(n) dos funciones. Decimos que f(n) es O(g(n)) si existe una constante c tal
que: f(n) ≤ c.g(n) para valores suficientemente grandes de n.

En otras palabras, decimos que f(n) es O(g(n)), si a partir de cierto crecimiento de n, a


parte de una constante c, la función g domina a la función f.

Por ejemplo, decimos que T(n) es O(n) si la función g(n) = n domina a T(n) al
multiplicar g(n) por una constante, sin importar cuánto más crezca n.

Decimos que un algoritmo con T(n) = O(g(n)) tiene complejidad g(n).


Ejemplo 8.9:
Según la definición de la función orden O, demuestre que T(n) = 5n +1 es O(n).

Solución:
Tenemos que demostrar que la función g(n) = n, multiplicada por una constante c,
domina a T(n) = 5n + 1 a partir de un cierto valor de n. La constante c la podemos
escoger nosotros. Lo importante es que esta constante no cambia aún cuando la n
aumente.
¿Cómo podemos hacer que se cumpla la siguiente desigualdad?

5n + 1 ≤ cn a partir de cierto valor de n

Si escogemos c = 6, entonces esa desigualdad será cierta para todos los valores de n. Es
decir, para n ≥ 1, es cierto siempre que 5n + 1 ≤ 6n. 3Esto demuestra que T(n) es O(n).

Ejemplo 8.10:
Demuestre que T(n) = 5/2n2 -1/2n -1 es O(n2).

Solución:
Tenemos que demostrar que

5/2n2 -1/2n -1 ≤ cn2

Si escogemos c = T(1) = 1, tenemos el resultado deseado.

Verifique que

5/2n2 -1/2n -1 ≤ n2

para n ≥ 1.

Teorema 1:
Sea p(x) un polinomio de grado k. Entonces p(x) es O(xk).

Demostración:
Sea

p(x) = akxk + ak-1xk-1 + ... + a1x + a0.

Esta es la forma general de un polinomio de grado k. Para demostrar que p(x) es O(xk),
tenemos que hallar una constante c tal que

akxk + ak-1xk-1 + ... + a1x + a0 ≤ cxk

Considere c = p(1) = ak + ak-1 + ... + a1 + a0

Claramente,

3
Note que siempre podemos escoger una constante mayor, y la desigualdad claramente se
cumplirá también.
akxk + ak-1xk-1 + ... + a1x + a0 ≤ (ak + ak-1 + ... + a1 + a0)xk

para toda x ≥ 1, pues aixi ≤ aixk, siempre que i ≤ k. Por tanto, queda demostrado que p(x)
es O(xk).

Teorema 2:
Sean p(x) y q(x) dos polinomios, donde el grado de q(x) es mayor o igual que el de p(x).
Entonces si la función f(x) es O(p(x)), también es O(q(x)).

Demostración:
Si f(x) es O(p(x)) entonces, por definición, existe una constante c1 tal que

f(x) ≤ c1p(x) para toda n ≥ n1

(n1 es el valor “suficientemente grande que indica la definición)

Como el grado de q(x) ≥ grado de p(x), no es difícil ver que para valores suficientemente
grandes de n, q(x) dominará a p(x). De hecho, si tomamos c2 = p(1), entonces tendremos
que

p(x) ≤ c2q(x) para n ≥ 1

Tomando c = c1c2, podemos ver que entonces

f(x) ≤ c1p(x) ≤ c1c2q(x) = cq(x) para n ≥ n1

Esto demuestra que f(x) es O(q(x)).

Suponga que T(n) = 5n3 + 2n2 + 1. Según el teorema 1, tenemos que T(n) es O(n3).
Según el teorema 2, T(n) también es O(n4), O(n5), O(n6), ...

Al decir que una función es O(g(n)) lo que podemos saber de la función es que a partir de
ciertos valores de n la función no crece más rápido que g(n). Puede ser, sin embargo, que
crezca mucho más lento que g(n). Generalmente, nos interesa hallar la función de menor
orden O tal que domine a la función en cuestión, pues es la que nos da más información
sobre el comportamiento de la función. Así, por ejemplo, preferimos decir que T(n) = 5n2
es O(n2), aunque también sea O(n3).

Ejemplo 8.11:
Halle la complejidad (función orden O) para el siguiente algoritmo que hace una
búsqueda lineal en un vector de n elementos:

encontré = false;
i = 1;
while not encontré and (i ≤ n)
if x = A[i]
encontré = true;
else
i = i + 1;

Solución:
La cantidad de instrucciones que ejecutará este algoritmo dependerá cuán rápido
encontremos en el vector el elemento x que se busca. Hagamos el análisis del peor caso,
esto es, o que el elemento esté en la última posición en el vector, o que no esté en
ninguna. En ambos casos, la instrucción del “while” se ejecutará n + 1 veces (la i tiene
que ir desde 1 hasta n + 1). La comparación del “if” se hará n veces. Si x no está en el
vector, entonces la instrucción del “else” se hará n veces y la del “if” ninguna. En el peor
caso, la suma de ambas será n.

# instrucción instrucción # veces que se ejecuta


1 encontré = false; 1
2 i = 1; 1
3 while not encontré and (i ≤ n) n+1
4 if x = A[i] n
5 encontré = true; 0
6 else -
7 i = i + 1; n
T(n) = 3n + 3

En la tabla, suponemos que el número que buscamos no está en la lista. Como T(n) = 3n
+ 3, sabemos que el algoritmo tiene complejidad O(n). El análisis estuvo basado en el
peor caso. El caso promedio se puede analizar de la siguiente manera. Si partimos de que
el número está en la lista y de que la probabilidad de que el número esté en una posición
determinada es la misma para todas ellas, entonces tendremos que buscar, en promedio,
en la mitad del vector. Es decir, tendremos que hacer en promedio n/2 comparaciones.
Note que el número de comparaciones será menor que en el peor caso, pero sigue siendo
un polinomio lineal en n, y por lo tanto T(n) seguirá siendo O(n).

A menudo no es necesario hacer todo el cálculo de T(n) para determinar la complejidad


de un algoritmo. Sobre todo si el algoritmo es relativamente sencillo, como es el caso de
la búsqueda lineal, puede que no sea muy difícil determinar dicha complejidad si
entendemos el funcionamiento del algoritmo. Este algoritmo, por ejemplo, va
comparando el número a buscar uno por uno con los elementos del vector a lo largo del
mismo. Cada elemento de la data (que en este caso es el tamaño del vector) se examina
una sola vez. Si no encontramos el número llegaremos hasta el final del vector, en cuyo
caso habremos ejecutado un número de instrucciones en proporción al tamaño del vector.
La complejidad por lo tanto es O(n).
Cuando un algoritmo ejecuta una cantidad fija de instrucciones, sin importar el tamaño de
la data, decimos que el algoritmo es O(1). El algoritmo que habíamos visto arriba, que
calcula el promedio de tan sólo 10 elementos de la data, es un ejemplo de un algoritmo
que es O(1).

suma = 0;
for i = 1 to 10
{
read(x);
suma = suma + x;
}
promedio = suma/x;

Este algoritmo tiene T(n) = 33. Como 33 ≤ c⋅1 para c ≥ 33, esto demuestra que T(n) es
O(1). También, claro está, es O(2), O(100), etc., pero generalmente decimos que es O(1).

Ejemplo 8.12:
Determine la complejidad del siguiente algoritmo:

i = 1;
while i ≤ n
{
i = 2 * i;
write(i);
}

Solución:
La cantidad de veces que se ejecute el ciclo dependerá del valor de n. Sin embargo, la
variable i no va tomando todos los valores hasta llegar a n, sino que asume los valores 1,
2, 4, 8, etc. ¿Cuántos valores distintos asumirá antes de que sea mayor que n, y entonces
el algoritmo se vaya del ciclo? Consideremos la siguiente tabla, que nos indica el número
de valores que asumirá la variable i antes de ser mayor que n para algunos valores de n:

n valores de i
4 1,2,4,8 = 4
8 1,2,4,8,16 = 5
16 6
32 7
64 8
128 9
256 10
512 11
1000 11

Note que cada vez que se duplica la n, el ciclo del “while” se ejecutará solamente una vez
más. La función que tiene esta propiedad es el logaritmo. Verifique que los pares
ordenados dados en la tabla coinciden con la función:

valores que asume la variable i = ⎣log2n⎦

Esto implica que la instrucción del “while” se ejecuta ⎣log2n⎦ veces, y por tanto las
instrucciones dentro del ciclos e ejecutan cada una, ⎣log2n⎦ - 1 veces. Entonces

T(n) = ⎣log2n⎦ + 2(⎣log2n⎦ - 1) + 1 = 3 ⎣log2n⎦ - 1

Por tanto,

T(n) = O(log2n)

Generalmente, cuando usemos logaritmos estaremos trabajando en base 2. En tales casos,


si no se indica lo contrario, escribiremos simplemente log n.

Ejemplo 8.13:
Halle la complejidad (función orden O) para el siguiente algoritmo que hace una
búsqueda binaria de un entero x en un vector A de n elementos ordenados
ascendentemente:

encontré = false;
índice-izq = 1;
índice-der = n;
while not encontré and (índice-izq ≤ índice-der)
{
medio = (índice-izq + índice-der) div4 2;
if A[medio] = x
encontre = true;
else
if A[medio] < x
índice-izq = medio + 1;
else
índice-der = medio - 1;
}
if encontré
posición = medio;

Este algoritmo hace una búsqueda de x de la siguiente manera: se localiza el elemento del
medio del vector, y se compara con x. Si son iguales, acabamos y la posición es el índice
dada por la variable medio. Si x es mayor que el elemento del vector en el medio,
entonces, como suponemos que el vector esta ordenado de menor a mayor, seguimos la
búsqueda en la parte derecha del vector (desde el ‘medio’ hasta el valor dado por el

4
“div” es la operación módulo (equivalente al operador ‘%’ en C.
índice derecho). Si x es menor, entonces buscamos en la parte de la izquierda (desde el
índice de la izquierda hasta el ‘medio’).

La clave del algoritmo es que luego de cada comparación, siempre logramos restringir la
búsqueda en un segmento del vector que es la mitad del tamaño anterior (más o menos
uno de la mitad, pues no siempre podemos dividir el vector exactamente por la mitad).
Así, si el vector original era de tamaño 128, al comparar con la posición del medio
(⎣(1+128)/2⎦ = 64), podremos descartar la mitad del vector y seguir la búsqueda en la
otra mitad. Si el vector fuera de 256 elementos, con la primera comparación ya lo
reduciríamos a la mitad. Con la segunda comparación a la cuarta parte. Con la tercera a la
octava parte, etc. S

En el peor caso, seguiremos buscando hasta que nos quede un segmento de un sólo
elemento. En este caso, en cuyo caso habremos comparado un número proporcional a log
n (vea la tabla). Si el elemento no está en el vector, entonces eventualmente el índice
izquierdo será mayor que el derecho, y saldremos del ciclo. También en este caso
habremos hecho un número proporcional a log n.

# comparaciones elementos en el nuevo segmento

1 ⎡n/2⎤

2 ⎡n/4⎤

3 ⎡n/8⎤

k ⎡n/2k⎤ = 1

Cuando hemos hecho k comparaciones, nos queda un segmento del vector de tamaño
n/2k. En el peor caso, este valor será 1.

⎡n/2k⎤ = 1 è k = ⎡log n⎤

Por tanto, el número de comparaciones será proporcional a log n, lo que implica que el
algoritmo es O(log n).

Ejemplo 8.14:
Halle la complejidad del algoritmo siguiente:

1. k = 2;
2. for i = 1 to n
{
3. for j = 1 to k - 1
4. write(j);
5. k = 2 * k;
}

Solución:
La instrucción 1 se ejecuta 1 vez. La instrucción 2 se descompone en 2n + 2
instrucciones. La instrucción 5 se ejecuta una vez cada vez que entramos al ciclo de la i,
es decir, n veces. Para determinar cuántas veces se ejecutan las instrucciones 3 y 4,
consideremos la siguiente tabla, que nos dice cuántas veces se ejecutan las instrucciones
3 y 4 para cada valor de i y k:

valor de i valor de k # instr. en que se # veces se ejecuta instr. 4


descompone instr. 3
1 2 4 1
2 4 8 3
3 8 16 7
4 16 32 15

n 2n 2n+1 2n - 1

Por tanto, el número de instrucciones en que se descompone la instrucción 3 es:

∑2 i +1
= 4 + 8 + 16 + ... + 2n+1 = 2n+2 – 4 = 4.2n - 4
i =1

(esta es una progresión geométrica)

La instrucción 4 se ejecutará:

= - = - n = 2. 2n - 2 - n

Por tanto, el valor de T(n) para el algoritmo será:

T(n) = 1 + (n + 1) + 4.2n - 4 + (2⋅ 2n - 2 - n) + n = 6 ⋅ 2n + - 4

Para determinar la complejidad de este algoritmo, note que el término 6⋅ 2n dominará a


los términos n - 2. Por tanto, el algoritmo es O(2n).

Cómo crece una función con el crecimiento de la data que procesa es un aspecto
fundamental para analizar la eficiencia de un algoritmo. Podemos pensar que un
algoritmo con complejidad lineal O(n) es más eficiente que uno con complejidad
cuadrática O(n2). Sin embargo, no necesariamente este es el caso si la data no es lo
suficientemente grande. Un algoritmo con T(n) = 200n correrá más rápido que uno con
T(n) = n2 para valores pequeños de n (hasta n = 200, en este caso). Lo importante es que
a la larga, si la data es más y más grande, entonces el algoritmo con menor complejidad
será más eficiente.

Es importante entonces analizar cuidadosamente cómo se comportan algunas funciones,


en términos de su crecimiento, para saber cuándo son insoportablemente ineficientes
cuando la n es “suficientemente” grande. Algunas de las funciones que normalmente nos
encontramos al analizar la complejidad de algoritmos importantes son:

tipo de forma general de


función la función
constante 1
logarítmica log n
lineal n
n log n n log n
cuadrática n2
cúbica n3
polinomial nx
exponencial 2x
factorial n!

Tomemos un momento para ver el impacto que tienen cada una de estas funciones en
términos del tiempo de ejecución de un algoritmo para diversos valores del tamaño de la
data, n. Podemos considerar que tenemos algoritmos que ejecutan una cantidad de
instrucciones equivalentes a las formas generales de las funciones descritas, tal como se
indica en la tabla a continuación, y que cada algoritmo se ejecuta en la misma máquina, la
cual puede ejecutar una instrucción promedio en 1 microsegundo (10-6 seg.). La primera
tabla indica el número de instrucciones que se ejecutarían por los algoritmos:

n log n n n log n n2 2n n!
10 3.32 10 33.2 100 1024 3.62x106
100 6.64 100 664 10,000 1.26x1030 9.33x10157
1,000 9.97 1,000 9,966 1,000,000 1.07x10301 -
10,000 13.29 10,000 132,877 100,000,000 - -
100,000 16.61 100,000 1,660,964 10,000,000,000 - -
1,000,000 19.93 1,000,000 19,931,569 1,000,000,000,000 - -

La segunda tabla nos indica el tiempo de ejecución de los algoritmos:

n log n n n log n n2 2n n!
10 3.32x10-6s 10-5s 3.32x10-5s 10-4s 1.024x10-3s 3.62s
100 6.64x10-6s 10-4s 6.64x10-4s .01s 4x1016 años 2.96x10150 años
1,000 9.97x10-6s 10-3s 9.97x10-3s 1s 3.4x10293 años -
10,000 1.33x10-5s .01s 1.33x10-1s 1 min. 40s - -
100,000 1.66x10-5s .1s 1.66s 2.78 horas - -
1,000,000 1.99x10-5s 1s 19.93s 11.58 días - -

El tiempo de ejecución de un algoritmo exponencial en procesar una data de tamaño 100


es 4 x 1016 años, es decir: 40,000,000,000,000,000 años, ¡unos cuantos más de los que ha
vivido la humanidad sobre el planeta! Un algoritmo con complejidad exponencial, como
2n, sólo será práctico para valores pequeños de n.

Un problema se dice que es no tratable si no existe un algoritmo eficiente que lo


resuelva. Por ejemplo, un algoritmo que requiere una complejidad exponencial es para
todos los efectos no tratable, pues sólo instancias de tamaño muy reducido será factible
resolver con ellos. Decimos que un problema es tratable si existe un algoritmo de
complejidad polinomial que lo resuelve. Se ha demostrado que existen problemas que son
no tratables. Además, hay un conjunto de problemas para los cuales no se conocen
algoritmos eficientes que los resuelvan, pero para los cuales no se ha logrado demostrar
que estos algoritmos eficientes o polinomiales no existen. A este clase de problemas se
les conoce como NP Completos. Decimos que estos problemas forman una clase en el
sentido de que se ha demostrado que si existe un algoritmo polinomial para alguno de
ellos, entonces existiría uno para todos, y si no existe para uno, no existe para ninguno.
Uno de los problemas más importantes de las Ciencias de Cómputos hoy día que aún no
se ha resuelto es precisamente determinar si existen o no algoritmos de tiempo polinomial
para esta clase de problemas. Terminamos este capítulo presentando un ejemplo de uno
de los problemas famosos de esta clase.

Ejemplo 8.15:
Suponga que un viajero quiere visitar n ciudades. Entre algunas ciudades y otras hay
conexión directa y entre otras no. El problema consiste en determinar si existe una ruta
para el viajero de tal manera que termine en la misma ciudad en que empezó y visite cada
ciudad exactamente una vez.

Se ha demostrado que este problema, tan sencillo de plantear y con una aplicación tan
común, pertenece a la clase de problemas NP Completos. Es decir, no se conoce un
algoritmo polinomial que lo resuelva, pero tampoco se ha demostrado que dicho
algoritmo no exista.

Es posible que para una instancia específica de este problema, es decir, para un
determinado conjunto de ciudades con sus interconexiones directas, exista una solución
polinomial. Pero de lo que se trata el problema es de resolver el caso general. Dado
cualquier conjunto de ciudades y sus conexiones, debemos poder determinar su existe o
no la ruta con las propiedades que nos interesan.
Ejercicios:

1. Halle T(n) para los siguientes conjuntos de instrucciones:

a. for i = 1 to n
{
j = 1;
while j ≤ n {
write(j);
j = j + 1; }
}

b. i = n;
while i ≥ 1
{
write(i);
i = i div 2;
}

c. i = n;
for j = 1 to n
while i ≥ 1
{
write(i);
i = i div 2;
}

d. for j = n downto 2
read(x);

e. for i = 2 to n - 1
for j = i to n
write(‘función orden O’);

f. for i = 1 to n
for j = 1 to n
for k = 1 to n
read(A[i,j,k]);

g. k = 1;
for i = 1 to n
{
for j = 1 to k
write(j);
k = k + 1; }
2. Para cada uno de los ejercicios del problema 1, determine la complejidad del
algoritmo. En cada caso halle la constante y a partir de qué valor de n se cumple la
condición de la función orden O.

3. Demuestre que:

a. T(n) = 4n + 1 es O(n)
b. T(n) = 5n2 - 7n + 4 es O(n2)
c. T(n) = 5logn -7 es O(logn)

4. Determine la complejidad para los siguientes valores de T(n):

a. T(n) = 7n5 - 12n3 - 48


b. T(n) = 4n - 2logn
c. T(n) = 7n + 3nlogn
d. T(n) = n6 + 4logn + 2n

5. Si tiene algoritmos con los siguientes valores para la función orden O, ordénelos en
orden de menor a mayor complejidad o eficiencia.

nlogn, 1000, 25n2, 7logn, n!, nn, 3n, 12n + 75, n300

6. Halle la complejidad (función orden O) para el siguiente algoritmo que suma dos
matrices A y B de tamaño n x n:

for i = 1 to n
for j = 1 to n
C[i,j] = A[i,j] + B[i,j];

7. Diseñe un algoritmo que calcule el máximo de un vector de n elementos, calcule T(n)


y determine su complejidad.

8. Diseñe un algoritmo que inserte un elemento en un vector ordenado de n elementos.


Calcule T(n) y determine su complejidad. Si utiliza este algoritmo para ir ordenando
un vector originalmente vacío, ¿cuál será la complejidad del algoritmo al haber
insertado n elementos en el vector?

Vous aimerez peut-être aussi