Está en la página 1de 28

Sistemas de Información

Análisis y Diseño de Algoritmos


Quinto Semestre
Periodo noviembre 2020 / marzo 2021

Luis Oyarzun
Análisis y Diseño de Algoritmos
Resultado de aprendizaje de la asignatura
Diseñar algoritmos eficientes y confiables por medio de la investigación de métodos para analizar
cuidadosamente la estructura de un problema dentro de un marco matemático, buscando a menudo la
posibilidad de disminuir drásticamente los recursos.

Contenido
Resultado de aprendizaje de la asignatura .................................................................................................. 3
Unidad 1: Algoritmos Básicos ...................................................................................................................... 1
Algoritmos ............................................................................................................................................... 1
Ejemplos .............................................................................................................................................. 1
¿Qué tipos de problemas son resueltos por los algoritmos? ............................................................... 2
Diferencia entre algoritmos: ................................................................................................................... 2
¿Cómo funciona el método de inserción? ........................................................................................... 3
Elaboración de un pseudocódigo: ....................................................................................................... 3
Bucle invariante: .................................................................................................................................. 4
Analizando algoritmos ......................................................................................................................... 5
Diferencias entre el mejor, el esperado y el peor caso de un algoritmo. ............................................ 7
Futuros análisis .................................................................................................................................... 8
Orden de crecimiento.......................................................................................................................... 8
Clase de complejidad: Crecimiento de las funciones ............................................................................. 13
Notación asintótica ........................................................................................................................... 14
Unidad 2: Técnicas de diseño de algoritmos. ............................................................................................ 21
Unidad 3: ................................................................................................................................................... 21
Unidad 1: Algoritmos Básicos
Algoritmos
Un algoritmo es una secuencia de pasos bien definidos dentro de un
orden lógico, el cual permite resolver un problema, este debe ser Algoritmo
analizado de tal forma, que tanto sus entradas como salidas deben
Es una secuencia
ser bien definidas, porque este algoritmo necesita tomar un valor o de pasos
conjunto de valores como entrada y producir o devolver algún valor
computacionales
o conjunto de valores como salida. Es decir que el algoritmo es una que transforman
secuencia de pasos computacionales que transforman la entrada en
la entrada en la
la salida. Es importante entender que todo algoritmo siempre debe salida.
concluir su proceso, este no debe ser interrumpido porque todo dato
que ingresa y es procesado hasta convertirse en una salida, es
almacenado en memoria, y solo al terminar o finalizar el algoritmo
este dato almacenado será eliminado de la memoria.

Desde otro punto de vista, un algoritmo

• Es como una herramienta para resolver un problema computacional bien


especificado.
• El enunciado del problema especifica en términos generales la relación
entrada/salida deseada.
• El algoritmo describe un procedimiento computacional específico para lograr esa
relación entrada/salida.

Ejemplos
Como un ejemplo básico para entender cómo
funciona un algoritmo, vamos a preparar un
jugo de naranja:

1. Inicio
2. Verificar si hay naranjas
3. Si no hay naranjas, entonces comprar
naranjas
4. Cortar las naranjas en partes iguales
5. Exprimir las naranjas
6. Verter el jugo de naranja en un vaso
7. ¿Desea agregar agua?
a. Si desea agregar agua
i. Debe agregar agua y azúcar
ii. Luego revolver y servir
b. Caso contrario, servir
8. Finalizar

En este algoritmo, se puede ver que en la


entrada se esperan naranjas y en la salida el
jugo de naranja. Adicional a esto hay una
segunda instancia donde se puede ver que en la entrada también existen el agua y el
azúcar.

Otro ejemplo comúnmente usado en los libros de análisis y diseño de algoritmos es el


de ordenamiento, donde el problema estable que se desea ordenar un conjunto de
Luis Oyarzun

números en la entrada de forma ascendente, de ahí que la entrada sería una secuencia
2 de “n” elementos no ordenados, tal que 〈𝑎1 , 𝑎2 , … , 𝑎𝑛 〉 y en la salida debe existir una
permutación de los “n” elementos 〈𝑎′1 , 𝑎′ 2 , … , 𝑎′ 𝑛 〉, tal que 𝑎′1 ≤ 𝑎′ 2 ≤ ⋯ ≤ 𝑎′𝑛 .

Entrada Salida

• ⟨𝑎1 , 𝑎2 , … , 𝑎𝑛 ⟩ • Permutación
⟨𝑎′1 , 𝑎′2 , … , 𝑎′𝑛 ⟩ ,
tal que 𝑎′1 ≤
𝑎′ 2 ≤ ⋯ ≤ 𝑎′𝑛

Aterricemos este ejemplo con algo real, suponga que, en la secuencia de ENTRADA, se
ingresan los siguientes seis números:

31 41 59 26 41 50 26 31 41 41 58 59

Entrada Salida

La secuencia de entrada es denomina “instancia”, después lo definiremos este


concepto.

¿Qué tipos de problemas son resueltos por los algoritmos?


• Proyectos de genoma humano
• Búsquedas en Internet
• Comercio electrónico
• Las empresas comerciales y fabricas
• Etc.

Diferencia entre algoritmos:


Para determinar si un algoritmo es bueno o malo, primero se debe analizar el
comportamiento de este, de ahí que la diferencia entre algoritmos es el resultado de
comparar el comportamiento de varios algoritmos que hacen lo mismo. Entonces el
comportamiento de un algoritmo puede ser analizado según su tiempo de ejecución,
llegando a concluir tres posibles resultados de análisis y diferenciar entre: el mejor, el
esperado y el peor caso de un algoritmo.

Con esto claro, vamos primero a analizar que significa estar frente a un mejor, a un
esperado o un peor caso de comportamiento, recuerde, esto se refiere a la eficiencia
de un algoritmo en relación con la cantidad de recursos computacionales que necesita
este para poder ejecutarse dentro de un entorno informático y llegar al objetivo para
el cual fue diseñado (mientras haya una menor cantidad de recursos consumidos,
mayor será la eficiencia del algoritmo), entonces:

• El mejor caso de comportamiento en un algoritmo se da cuando un algoritmo se


ejecuta en óptimas condiciones, es decir consumiendo lo mínimo de los recursos
del sistema.
• El peor caso de comportamiento se da cuando al ejecutar un algoritmo, este se
ejecuta en las peores condiciones, es decir abusando de los recursos del sistema.
• El caso esperado, en la mayoría de los análisis puede llegar a ser tan malo como el
peor de caso.
Análisis y Diseño de Algoritmos

Para un ejemplo práctico, debemos analizar el algoritmo de ordenamiento por


inserción (insert sort), el cual busca ordenar una secuencia (usualmente números) 3
ingresada de forma aleatoria, donde la entrada es una secuencia de n números
〈𝑎1 , 𝑎2 , … , 𝑎𝑛 〉 y su salida es la permutación (reordenada) de la secuencia de entrada
de los n elementos 〈𝑎′1 , 𝑎′ 2 , … , 𝑎′ 𝑛 〉, tal que 𝑎′1 ≤ 𝑎′ 2 ≤ ⋯ ≤ 𝑎′𝑛 . Es decir, si
tenemos una instancia del problema de ordenamiento, cuya entrada es una secuencia
de 6 números ⟨31, 41, 59, 26, 41, 58⟩, la salida esperada para la instancia dada sería
la permutación de la entrada ⟨26, 31, 41, 41, 58 , 59⟩.

¿Cómo funciona el método de inserción? La idea principal


Si utilizamos el ejemplo de ordenar los naipes en una
mano, esta puede comenzar vacía y con los naipes
barajado boca abajo, de tal forma que todos los
naipes en el mazo estén desordenados. Ahora la
forma de ordenar los naipes puede variar de persona
a persona. Entonces:

1. Tomamos un naipe desde la parte superior del


mazo de naipes que está en la meza y lo
colocamos en la mano en su posición correcta,
esto lo hacemos un naipe a la vez.
2. Para encontrar la correcta ubicación del naipe, cuando tomamos uno nuevo de la
mesa, este es comparado con cada uno de los naipes que está en nuestra mano,
al ser números usualmente lo hacemos de menor a mayor y de izquierda a
derecha. Este proceso será repetido cada vez que robemos un naipe del mazo que
está en la mesa.
3. Todas las cartas del naipe que ahora en mi mano están ordenadas, fueron tomadas
una a una desde la parte superior del mazo que está en la mesa.

Elaboración de un pseudocódigo:
Para elaborar un código, se debe comenzar elaborando un pseudocódigo y para
Pseudocódigo
elaborar el seudocódigo, se debe definir el algoritmo del proceso a realizar. Para
elaborar un algoritmo se debe conocer como se realiza el proceso de forma manual. Es la escritura del
código en un
Primero vamos a definir el nombre de nuestro proceso y lo llamaremos
lenguaje natural
𝑂𝑟𝑑𝑒𝑛𝑎_𝐼𝑛𝑠𝑒𝑟𝑐𝑖𝑜𝑛(), el cual va a recibir como parámetro un arreglo que llamaremos
o de alto nivel de
“𝐴”, este arreglo contiene una secuencia de “𝑛” números los cuales serán ordenados.
forma compacta
La idea principal es que el ordenamiento sea realizado en el mismo arreglo “𝐴” y el
e informal de
apoyo de una variable temporal llamada “𝑡𝑒𝑚𝑝” con el espacio suficiente como para
cómo debe
almacenar un solo dato a la vez. El tamaño del arreglo será almacenado en
funcionar el
𝐴. 𝑙𝑜𝑛𝑔𝑖𝑡𝑢𝑑.
algoritmo
Imaginemos que el arreglo 𝐴[1 … 𝑛], contiene los elementos 𝐴[5, 2, 4, 6, 1, 3], donde implementado.
cada elemento del arreglo esta en un casillero, cada uno contado del 1 al 6.
Como una
recomendación,
5 2 4 6 1 3 2 5 4 6 1 3 2 4 5 6 1 3 se debe respetar
la estructura del
lenguaje que se
está utilizando.
1 2 3 4 5 6 1 2 4 5 6 3 2 4 5 6 1 3
Luis Oyarzun

Ordena_Insercion(A)
4 1 desde j = 2 hasta A.longitud
2 temp = A[j]
3 //Inserta A[j] en la secuencia ordenada A[1 … j – 1]
4 i = j – 1
5 mientras i > 0 y A[i] > temp
6 A[i+1] = A[i]
7 i = i – 1
8 A[i + 1] = temp
Bucle invariante:
Propiedad de 𝐴[1 … 𝑗 − 1], el valor de “𝑗” indica la posición del número que se está
insertando. Al comienzo de cada iteración para el bucle desde (for) de las líneas 1 a la
7, el sub arreglo de 𝐴[1 … 𝑗 − 1] consiste de los elementos originales en 𝐴[1 … 𝑗 − 1]
pero ordenados, mientras que los elementos restantes que no están ordenados se
almacenan en el sub arreglo 𝐴[𝑗 + 1 … 𝑛].

Entonces, el bucle invariante ayuda a comprender el por qué un algoritmo es correcto.


De ahí que en bucle invariante existen tres cosas:

• Inicialización: es cierto antes de la primera iteración del ciclo.


• Mantenimiento: si es verdadero antes de una iteración del bucle, permanece
verdadero después de la iteración.
• Terminación: cuando el ciclo termina, el invariante nos da una propiedad útil que
ayuda a mostrar que el algoritmo es correcto.

Cuando las primeras dos propiedades se mantienen, el lazo invariante es verdadero


antes de cada iteración del lazo.

Tenga en cuenta la similitud con la inducción matemática, donde para demostrar que
una propiedad es válida, demuestra un caso base y un paso inductivo. Aquí, mostrar
que el invariante se cumple antes de la primera iteración corresponde al caso base, y
mostrar que el invariante se mantiene de una iteración a otra iteración
correspondiente al paso inductivo.

La tercera propiedad es quizás la más importante, ya que estamos usando el lazo


invariante para mostrar la corrección. Por lo general, usamos el lazo invariante junto
con la condición que provocó la terminación del ciclo. La propiedad de terminación
difiere de cómo usamos habitualmente la inducción matemática, en la que aplicamos
el paso inductivo infinitamente; aquí, detenemos la "inducción" cuando termina el
bucle.

Veamos cómo se mantienen estas propiedades para el ordenamiento por inserción.

• Inicialización: Comenzamos mostrando que el invariante del ciclo se mantiene


antes de la primera iteración del ciclo, cuando 𝑗 = 2. El subarreglo 𝐴[1 … 𝑗 − 1],
por lo tanto, consiste solo en el elemento 𝐴[1], que de hecho es el elemento
original en 𝐴[1]. Además, este subarreglo está ordenado (trivialmente, por
supuesto), lo que muestra que el lazo invariante se mantiene antes de la primera
iteración del ciclo.
• Mantenimiento: A continuación, abordamos la segunda propiedad: mostrar que
cada iteración mantiene el bucle invariante. De manera informal, el cuerpo del
bucle desde (for) funciona moviendo 𝐴[𝑗 − 1], 𝐴[𝑗 − 2], 𝐴[𝑗 − 3], y así
sucesivamente una posición hacia la derecha hasta encontrar la posición adecuada
para 𝐴[𝑗](líneas 4-7), en cuyo punto inserta el valor de 𝐴[𝑗](línea 8). El subarreglo
Análisis y Diseño de Algoritmos

𝐴[1 … 𝑗] entonces consta de los elementos originalmente en 𝐴[1 … 𝑗], pero en un


orden determinado u ordenado. Incrementar j para la siguiente iteración del ciclo 5
desde (for) entonces preserva el bucle invariante.
Un tratamiento más formal de la segunda propiedad nos requeriría enunciar y
mostrar un ciclo invariante para el ciclo mientras (while) de las líneas 5-7. En este
punto, sin embargo, preferimos no empantanarnos en tal formalismo, por lo que
confiamos en nuestro análisis informal para mostrar que la segunda propiedad es
válida para el ciclo externo.
• Terminación: Finalmente, examinamos qué sucede cuando termina el ciclo. La
condición que causa la terminación del bucle desde (for) es que 𝑗 >
𝐴. 𝑙𝑜𝑛𝑔𝑖𝑡𝑢𝑑 = 𝑛. Debido a que cada iteración del ciclo aumenta j en 1, debemos
tener 𝑗 = 𝑛 + 1 en ese momento. Sustituyendo 𝑛 + 1 por j en la redacción del
ciclo invariante, tenemos que el subarreglo 𝐴[1 … 𝑛] n consta de los elementos
originalmente en 𝐴[1 … 𝑛], pero en orden determinado u ordenado. Observando
que el subarreglo 𝐴[1 … 𝑛] es el arreglo completo, concluimos que el arreglo
completo está ordenado. Por tanto, el algoritmo es correcto.

Analizando algoritmos
Analizar un algoritmo ha llegado a significar predecir los recursos que requiere el
algoritmo. Ocasionalmente, los recursos como la memoria, el ancho de banda de
comunicación o el hardware de la computadora son la principal preocupación, pero la
mayoría de las veces es el tiempo computacional lo que queremos medir.
Generalmente, al analizar varios algoritmos candidatos para un problema, podemos
identificar el más eficiente. Dicho análisis puede indicar más de un candidato viable,
pero a menudo podemos descartar varios algoritmos inferiores en el proceso.

Antes de que podamos analizar un algoritmo, debemos tener un modelo de la


tecnología de implementación que usaremos, incluido un modelo para los recursos de
esa tecnología y sus costos. Para la mayor parte de este libro, asumiremos un modelo
genérico de computación de un procesador y máquina de acceso aleatorio (RAM)
como nuestra tecnología de implementación y entenderemos que nuestros algoritmos
se implementarán como programas de computadora. En el modelo RAM, las
instrucciones se ejecutan una tras otra, sin operaciones concurrentes.

Estrictamente hablando, debemos definir con precisión las instrucciones del modelo
RAM y sus costos. Sin embargo, hacerlo sería tedioso y proporcionaría poca
información sobre el diseño y análisis de algoritmos. Entonces para hacerlo, debemos
tener cuidado de no abusar del modelo RAM. Por ejemplo, ¿qué pasa si una RAM tiene
una instrucción que ordena? Entonces podríamos clasificar en una sola instrucción. Tal
RAM no sería realista, ya que las computadoras reales no tienen tales instrucciones.
Por tanto, a través de este curso veremos cómo se diseñan las computadoras reales.
El modelo de RAM contiene instrucciones que se encuentran comúnmente en
computadoras reales: aritmética (como sumar, restar, multiplicar, dividir, resto, piso,
techo), movimiento de datos (cargar, almacenar, copiar) y control (bifurcación
condicional e incondicional, llamada y retorno de subrutina). Cada una de estas
instrucciones requiere una cantidad constante de tiempo.

Los tipos de datos en el modelo RAM son enteros y de coma flotante (para almacenar
números reales). Aunque normalmente no nos preocupamos por la precisión en este
curso, en algunas aplicaciones la precisión es crucial. También asumimos un límite en
el tamaño de cada palabra de datos. Por ejemplo, cuando trabajamos con entradas de
Luis Oyarzun

tamaño “n”, normalmente asumimos que los enteros están representados por 𝑐 𝑙𝑔 𝑛
6 bits para alguna constante 𝑐 ≥ 1.

Requerimos 𝑐 ≥ 1 para que cada palabra pueda contener el valor de “n”, lo que nos
permite indexar los elementos de entrada individuales, y restringimos “c” para que sea
una constante para que el tamaño de la palabra no crezca arbitrariamente. (Si el
tamaño de la palabra pudiera crecer arbitrariamente, podríamos almacenar grandes
cantidades de datos en una palabra y operar en todo ello en un tiempo constante,
claramente un escenario poco realista).

En el modelo RAM, no intentamos modelar la jerarquía de memoria que es común en


las computadoras contemporáneas. Es decir, no modelamos cachés ni memoria
virtual. Varios modelos computacionales intentan dar cuenta de los efectos de la
jerarquía de memoria, que a veces son importantes en programas reales en máquinas
reales. Los modelos que incluyen la jerarquía de memoria son un poco más complejos
que el modelo de RAM, por lo que puede ser difícil trabajar con ellos. Además, los
análisis de modelos de RAM suelen ser excelentes predictores del rendimiento en
máquinas reales.

Analizar incluso un algoritmo simple en el modelo RAM puede ser un desafío. Las
herramientas matemáticas necesarias pueden incluir combinatoria, teoría de la
probabilidad, destreza algebraica y la capacidad de identificar los términos más
significativos en una fórmula.

Debido a que el comportamiento de un algoritmo puede ser diferente para cada


entrada posible, necesitamos un medio para resumir ese comportamiento en fórmulas
simples y fáciles de entender.

Aunque normalmente seleccionamos solo un modelo de máquina para analizar un


algoritmo dado, todavía enfrentamos muchas opciones para decidir cómo expresar
nuestro análisis. Nos gustaría una forma que sea simple de escribir y manipular,
muestre las características importantes de los requisitos de recursos de un algoritmo
y suprima los detalles tediosos.

Las computadoras reales contienen instrucciones que no se enumeran anteriormente,


y tales instrucciones representan un área gris en el modelo de RAM. Otras propiedades
discutidas según sea necesario. Se debe tener cuidado ya que el modelo de cálculo
tiene grandes implicaciones en el análisis resultante.

Ahora, al aplicar lo estudiado para realizar un análisis al algoritmo de ordenamiento


por inserción, vemos que:

• El requisito de recursos de tiempo depende del tamaño de entrada


• El tamaño de entrada depende del problema que se está estudiando; con
frecuencia, este es el número de elementos en la entrada
• Tiempo de ejecución: número de operaciones primitivas o "pasos" ejecutados para
una entrada
• Suponga una cantidad constante de tiempo “𝐶𝑖 ” para cada línea de pseudocódigo
• Si este es un ciclo de repetición o bucle se repetirá las “𝑛” veces que el bucle
necesite ejecutarse, y su contenido se ejecutará “𝑛 − 1” veces
• El resultado final será la suma algebraica de cada uno de estos valores.
• Si dentro de un bucle se encuentra otro bucle se aplica la misma lógica que el caso
anterior, para este caso será: en el bucle la ∑𝑛𝑗 𝑡𝑗 , donde “𝑗” es el valor inicial y “𝑛”
Análisis y Diseño de Algoritmos

el número de veces que se repitió el bucle padre. Y “𝑡𝑗 ” es el número de veces que
se va a repetir este bucle hijo.
7

Ordena_Insercion(A) Costo Veces


1 desde j = 2 hasta A.longitud C 1 n*
2 temp = A[j] C2 n – 1
3 //Inserta A[j] en la secuencia ordenada A[1 … j – 1] 0 n–1
4 i = j – 1 C4 n – 1
5 mientras i > 0 y A[i] > temp C 5 ∑𝑛𝑗=2 𝑡𝑗

6 A[i+1] = A[i] C6 ∑𝑛 (𝑡𝑗 − 1)


𝑗=2
7 i = i – 1 C 7 ∑𝑛𝑗=2(𝑡𝑗 − 1)
8 A[i + 1] = temp C8 n – 1
Diferencias entre el mejor, el esperado y el peor caso de un algoritmo.
Como se ha dicho, el tiempo de ejecución de un algoritmo es la suma de las veces que
cada instrucción o línea de instrucción ha sido ejecutada. Una línea de comandos que
toma 𝐶𝑖 pasos para ejecutarse y se ejecuta 𝑛 veces aportará con 𝐶𝑖 𝑛 al total del tiempo
de ejecución, por lo tanto, para calcular el tiempo de ejecución 𝑇(𝑛) del ordenamiento
por inserción se debe sumar el producto de las columnas costo y veces.
𝑛 𝑛

𝑇(𝑛) = 𝑐1 𝑛 + 𝑐2 (𝑛 − 1) + 0 + 𝑐4 (𝑛 − 1) + 𝑐5 ∑ 𝑡𝑗 + 𝑐6 ∑(𝑡𝑗 − 1)
𝑗=2 𝑗=2
𝑛

+ 𝑐7 ∑(𝑡𝑗 − 1) + 𝑐8 (𝑛 − 1)
𝑗=2

Incluso si las entradas tienen un tamaño fijo, el tiempo de ejecución de un algoritmo


puede depender de cuál es el tamaño dado en la entrada, bajo este criterio, según su
tiempo de ejecución, podemos determinar:

⚫ El mejor caso de comportamiento:


Para encontrar el caso de comportamiento, debemos asegurar que el algoritmo
ejecutado ocupe la menor cantidad de recursos (tiempo) necesarios para su
ejecución, y esto se logra cuando la lista entrante en
Se logra cuando la lista entrante en Ordena_Insercion, ya se encuentra ordenada
de forma ascendente, por lo tanto, el bucle interno nunca itera, porque para cada
𝑗 = 2, 3, … , 𝑛, cuando llega a la línea 5, se encuentra que 𝐴[𝑖] ≤ 𝑡𝑒𝑚𝑝 cuando 𝑖
tiene su valor inicial de 𝑗 − 1. Así 𝑡𝑗 = 1 para 𝑗= 2, 3, … , 𝑛, y el mejor tiempo de
ejecución es
𝑇(𝑛) = 𝑐1 𝑛 + 𝑐2 (𝑛 − 1) + 0 + 𝑐4 (𝑛 − 1) + 𝑐8 (𝑛 − 1)
+ (𝑐1 + 𝑐2 + 𝑐4 + 𝑐5 + 𝑐8 )𝑛 − (𝑐2 + 𝑐4 + 𝑐5 + 𝑐8 )

Porque en esta ejecución el tiempo puede ser expresado como 𝑎𝑛 + 𝑏, donde 𝑎 y


𝑏 son constantes que dependen del costo 𝑐𝑖 ; pudiendo concluir que, en este caso
particular, tenemos una función lineal de 𝑛.

⚫ El peor caso de comportamiento:


Para llegar a este análisis debemos pensar cual sería el caso que más nos demoraría
en ordenar, es decir, el caso que mayor cantidad de recursos (tiempo) vaya a
necesitar el algoritmo. Ahora esto se lograría si la lista entrante en

*
𝑛, es la longitud del arreglo

𝑡𝑗, determina el número de veces que se ejecuta el bucle mientras y depende del valor de 𝑗
Luis Oyarzun

Ordena_Insercion, es ingresada ordenada de forma descendente, es decir en un


8 orden opuesto al mejor de los casos de estudio. Si se llega a dar este caso, el bucle
interno itera el número máximo de veces posibles porque debe reubicar a un
determinado elemento tantas veces como elementos haya en la lista de entrada.
Por lo tanto, cada valor en 𝐴[𝑗], deberá ser comparado con todos los elementos
ordenados en el sub-arreglo 𝐴[1 … 𝑗 − 1], y entonces 𝑡𝑗 = 𝑗 para 𝑗 = 2,3, … , 𝑛.
Señalando que las soluciones para ∑𝑛𝑗=2(𝑗) y ∑𝑛𝑗=2(𝑗 − 1) son:
𝑛
𝑛(𝑛 + 1)
∑(𝑗) = −1
2
𝑗=2
y
𝑛
𝑛(𝑛 − 1)
∑(𝑗 − 1) =
2
𝑗=2
Entonces, el peor tiempo de ejecución es dado por:
𝑛(𝑛 + 1) 𝑛(𝑛 − 1)
𝑇(𝑛) = 𝑐1 𝑛 + 𝑐2 (𝑛 − 1) + 0 + 𝑐4 (𝑛 − 1) + 𝑐5 ( − 1) + 𝑐6 ( )
2 2
𝑛(𝑛 − 1)
+ 𝑐7 ( ) + 𝑐8 (𝑛 − 1)
2
Donde una vez aplicada las reglas de álgebra básica se llega a:
𝑐5 𝑐6 𝑐7 𝑐5 𝑐6 𝑐7
𝑇(𝑛) = ( + + ) 𝑛2 + (𝑐1 + 𝑐2 + 𝑐4 + − − + 𝑐8 ) 𝑛
2 2 2 2 2 2
− (𝑐2 + 𝑐4 + 𝑐5 + 𝑐8 )
Como un análisis básico a esta ecuación, esta puede ser expresada como 𝑎𝑛2 +
𝑏𝑛 + 𝑘, donde 𝑎, 𝑏 y 𝑘, son constantes que dependen del costo 𝑐𝑖 ; pudiendo decir
que es una función cuadrática de 𝑛.

⚫ El caso promedio de comportamiento:


Para el caso promedio, es este algoritmo particularmente, podemos concluir por el
número de iteraciones que pueden existir en 𝑡𝑗 , se puede obtener un tiempo de
ejecución igual que el peor caso.

Futuros análisis
En su mayor parte, los análisis posteriores se centrarán en:

⚫ Tiempo de ejecución en el peor de los casos, por ser el límite superior del tiempo
de ejecución para cualquier entrada.
⚫ El análisis de casos promedios, donde el tiempo de ejecución esperado en todas las
entradas, a menudo se encuentra que, el peor de los casos y el caso promedio
tienen el mismo "orden de crecimiento"

Orden de crecimiento
Este busca simplificar la abstracción del tiempo de ejecución de un algoritmo por: su
tasa (rate) de crecimiento o su orden de crecimiento, el cual permite comparar
algoritmos sin tener la preocupación del rendimiento de la implementación. Por lo
general, solo se toma el término de orden más alto sin coeficiente constante,
utilizando notación “theta” 𝛩.

Para el caso del ordenamiento por inserción, el mejor caso de ordenación por inserción
es 𝛩(𝑛) y el peor caso de ordenación por inserción es 𝛩(𝑛2 )
Análisis y Diseño de Algoritmos

Existen varias técnicas / patrones para diseñar algoritmos:


9
Enfoque incremental:
El cual construye la solución un componente a la vez

Enfoque de dividir y conquistar:


El cual divide el problema original en varias instancias más pequeñas del mismo
problema, resultando en algoritmos recursivos en los cuales son fáciles de analizar su
complejidad utilizando técnicas probadas.

Esta técnica (o paradigma) implica tres etapas que son: división, conquista y combina.

⚫ La etapa de “división” expresa el problema en términos de varios subproblemas


más pequeños, y esta división se la realiza hasta que el problema más pequeño sea
fácil de resolver.
⚫ Etapa de “conquista” resuelve los subproblemas más pequeños aplicando la
solución de forma recursiva; los subproblemas más pequeños se pueden resolver
directamente.
⚫ Etapa de “combinar” construye la solución al problema original a partir de
soluciones de subproblemas más pequeños.

Como un ejemplo utilizaremos otro método de ordenamiento, el “ordenamiento por


mezcla” (merge sort). En este caso analizaremos las tres etapas citadas anteriormente:

⚫ Etapa de división: divide la 𝑛


secuencia de 𝑛 elementos en dos
subsecuencias de
𝑛
elementos (no ordenados)
2
cada una.
⚫ Etapa de conquista: ordena de
forma recursiva las dos
subsecuencias usando el 𝑛/2 𝑛/2
ordenamiento por mezcla. (no ordenados) (no ordenados)
⚫ Etapa de combinación: mezcla las
dos subsecuencias ordenadas en
una secuencia ordenada (la
solución).
𝑛/2 𝑛/2
La recursividad en este tipo de
ordenamiento tiene un caso base (ordenados) (ordenados)
cuando la secuencia de elementos a
ordenar tiene una longitud de 1, lo
cual quiere decir que ya no se puede
dividir más la lista y no hay más que 𝑛
hacer, pues la lista ya está ordenada.
(ordenados)
El elemento clave en el algoritmo de
ordenamiento por mezcla, es justamente la mezcla de dos secuencias ya ordenadas en
la etapa de combinación, la cual se hace a través del llamado de procedimiento auxiliar
al que llamaremos 𝑀𝑒𝑧𝑐𝑙𝑎(𝐴, 𝑝, 𝑞, 𝑟), donde 𝐴 es la secuencia de números ingresada,
𝑝, 𝑞 y 𝑟 son los índices dentro de la secuencia tal que 𝑝 ≤ 𝑞 < 𝑟. El procedimiento
asume que las subsecuencias 𝐴[𝑝 … 𝑞] y 𝐴[𝑞 + 1 … 𝑟] están ya ordenadas. La mezcla
de estos forma una simple subsecuencia ya ordenada que reemplaza la subsecuencia
actual 𝐴[𝑝 … 𝑟].
Luis Oyarzun

El procedimiento 𝑀𝑒𝑧𝑐𝑙𝑎 toma un tiempo 𝛩(𝑛), donde 𝑛 = 𝑟 − 𝑝 + 1 es el número


10 total de elementos a ser mezclados.

Volviendo al ejemplo del juego de cartas, supongamos ahora, que tenemos dos pilas
de cartas boca arriba en una mesa. Cada pila está ordenada, con las cartas más
pequeñas en la parte superior. Se desea fusionar las dos pilas en una sola pila
ordenada, que debe estar boca abajo sobre la mesa. El paso básico consiste en elegir
la más pequeña de las dos cartas en la parte superior de las pilas boca arriba, sacarla
de su pila (que expone una nueva carta superior) y colocar esta carta boca abajo en la
pila de salida. Repetimos este paso hasta que una pila de entrada esté vacía, En este
momento simplemente se toma la pila de entrada restante y se coloca boca abajo en
la pila de salida. Computacionalmente, cada paso básico toma un tiempo constante,
ya que estamos comparando solo las dos cartas superiores. Dado que se realiza como
máximo 𝑛 pasos básicos, la mezcla lleva 𝛩(𝑛) de tiempo.

El siguiente pseudocódigo implementa la idea anterior, pero con un giro adicional que
evita tener que verificar si alguna pila está vacía en cada paso básico. Se ubica en la
parte inferior de cada pila una tarjeta centinela, que contiene un valor especial que se
usará para simplificar el código. Aquí, se utilizará ∞ como valor centinela, de modo
que siempre que una carta con ∞ esté expuesta, no puede ser la carta más pequeña a
menos que ambas pilas tengan expuestas sus cartas centinela. Pero una vez que eso
sucede, todas las cartas que no son centinelas ya se han colocado en la pila de salida.
Como se sabe de antemano que exactamente 𝑟 − 𝑝 + 1 cartas se colocarán en la pila
de salida, se puede detener una vez que se hayan realizado los pasos básicos.

Mezcla(A,p,q,r)
1 n1 = q – p + 1
2 n2 = r – q
3 Permitir que L[1 … n1 + 1] y R[1 … n2 + 1] sean nuevos arreglos
4 desde i = 1 hasta n1
5 L[i] = A[p + i -1]
6 desde j = 1 hasta n2
7 R[j] = A[q + j]
8 L[n1 + 1] = ∞
9 R[n2 + 1] = ∞
10 i = 1
11 j = 1
12 desde k = p hasta r
13 si L[i] ≤ R[j]
14 A[k] = L[i]
15 i = i + 1
16 caso contrario A[k] = R[j]
17 j = j + 1

En detalle, el procedimiento 𝑀𝑒𝑧𝑐𝑙𝑎 funciona de la siguiente manera:

1. La línea 1 calcula la longitud 𝑛1 de la subsecuencia 𝐴[𝑝 … 𝑞],


2. La línea 2 calcula la longitud 𝑛2 de la subsecuencia 𝐴[𝑞 + 1 … 𝑟]. Se crean los
arreglos L y R (“izquierda” y “derecha”), de longitudes 𝑛1 + 1 y 𝑛2 + 1,
respectivamente,
3. En la línea 3; la posición adicional en cada arreglo guardará al centinela.
4. El bucle desde de las líneas 4 y 5 copia la subsecuencia 𝐴[𝑝 … 𝑞] en 𝐿[1 … 𝑛1],
5. El bucle desde de las líneas 6 y 7 copia la subsecuencia 𝐴[𝑞 + 1 … 𝑟] en 𝑅[1 … 𝑛2].
6. Las líneas 8 a 9 colocan a los centinelas en los extremos de las matrices 𝐿 y 𝑅.
Análisis y Diseño de Algoritmos

7. Líneas 10 a 17, ejecutan los pasos básicos de 𝑟 − 𝑝 + 1 manteniendo el siguiente


buble invariante: 11
a. Al comienzo de cada iteración del bucle desde de las líneas 12 a 17, la
subsecuencia 𝐴[𝑝 … 𝑘 − 1] contiene los 𝑘 − 𝑝 elementos más pequeños de
𝐿[1 … 𝑛1 + 1] y 𝑅[1 … 𝑛2 + 1], ya ordenados. Además, 𝐿[𝑖] y 𝑅[𝑗] son los
elementos más pequeños de sus secuencias que no se han vuelto a copiar en
𝐴.

Figura 1
La operación de
las líneas 10 a 17
en el llamado al
procedimiento
Merge(A,9,12,16)

Figura 1: Operación de las líneas 10 a 17

Se debe demostrar que este bucle invariante se mantiene antes de la primera iteración
del ciclo desde de las líneas 12 a 17, que cada iteración del ciclo mantiene la invarianza
y que el invariante proporciona una propiedad útil para mostrar la corrección cuando
el ciclo termina.

Inicialización: Antes de la primera iteración del ciclo, se tiene que 𝑘 = 𝑝, de modo que
la subsecuencia 𝐴[𝑝 … 𝑘 − 1] está vacía. Esta subsecuencia vacía contiene los 𝑘 − 𝑝 =
0 elementos más pequeños de 𝐿 y 𝑅, y dado que 𝑖 = 𝑗 = 1, tanto 𝐿[𝑖] como 𝑅[𝑗] son
Luis Oyarzun

los elementos más pequeños de sus secuencias que no se han copiado nuevamente en
12 𝐴.

Mantenimiento: para ver que cada iteración mantiene invariante el ciclo, primero se
supone que 𝐿[𝑖] ≤ 𝑅[𝑗]. Entonces 𝐿[𝑖] es el elemento más pequeño que aún no se ha
copiado de nuevo en 𝐴. Dado que 𝐴[𝑝 … 𝑘 − 1] contiene los 𝑘 − 𝑝 elementos más
pequeños, después de que la línea 14 copia 𝐿[𝑖] en 𝐴[𝑘], la subsecuencia 𝐴[𝑝 … 𝑘]
contendrá el 𝑘 − 𝑝 + 1 elementos más pequeños. Incrementando 𝑘 (en la
actualización del ciclo desde) e 𝑖 (en la línea 15) restablece el bucle invariante para la
siguiente iteración. Si en cambio 𝐿[𝑖] > 𝑅[𝑗], entonces las líneas 16 y 17 realizan la
acción apropiada para mantener el bucle invariante.

Terminación: En la terminación, 𝑘 = 𝑟 + 1. Por el bucle invariante, la subsecuencia


𝐴[𝑝 … 𝑘 − 1], que es 𝐴[𝑝 … 𝑟], contiene los 𝑘 − 𝑝 = 𝑟 − 𝑝 + 1 elementos más
pequeños de 𝐿[1 … 𝑛1 + 1] y 𝑅[1 … 𝑛2 + 1], ya ordenados. Los arreglos 𝐿 y 𝑅 juntos
contienen 𝑛1 + 𝑛2 + 2 = 𝑟 − 𝑝 + 3 elementos. Todos menos los dos más grandes se
han copiado de nuevo en 𝐴, y estos dos elementos más grandes son los centinelas.

Para ver que el procedimiento 𝑀𝑒𝑟𝑔𝑒 se ejecuta en un tiempo de 𝛩(𝑛), donde 𝑛 =


𝑟 − 𝑝 + 1, observe que cada una de las líneas 1 a 3 y 8 a 11 toma un tiempo constante,
los bucles desde de las líneas 4 a 7 toman un tiempo de 𝛩(𝑛1 + 𝑛2) = 𝛩(𝑛), y hay 𝑛
iteraciones del ciclo desde de las líneas 12 a 17, cada una de las cuales toma un tiempo
constante.

Ahora se puede usar el procedimiento 𝑀𝑒𝑟𝑔𝑒 como una subrutina en el algoritmo de


ordenamiento por mezcla. El procedimiento 𝑂𝑟𝑑𝑒𝑛𝑎_𝑀𝑒𝑧𝑐𝑙𝑎(𝐴, 𝑝, 𝑟) ordena los
elementos de la subsecuencia 𝐴[𝑝 … 𝑟]. Si 𝑝 ≥ 𝑟, la subsecuencia tiene como máximo
un elemento y, por lo tanto, ya está ordenado. De lo contrario, el paso de división
simplemente calcula un índice 𝑞 que divide 𝐴[𝑝 … 𝑟] en dos subsecuencias: 𝐴[𝑝 … 𝑞],
que contiene 𝑛/2 elementos, y 𝐴[𝑞 + 1 … 𝑟], que contiene 𝑛/2 elementos.

Ordena_Mezcla(A,p,r) Ordena_Mezcla(A,1,A.longitud)
1 si p < r // revisa por un caso base
2 𝑞 = ⌊(𝑝+𝑞)/2⌋ // divide
3 Ordena_Mezcla(𝐴, 𝑝, 𝑞) // conquista
4 Ordena_Mezcla(𝐴, q+1, r) // conquista
5 Mezcla(𝐴, 𝑝, 𝑞,𝑟) // combina

Para ordenar una secuencia entera de 𝐴 = 〈𝐴[1], 𝐴[2], … 𝐴[𝑛]〉, se hace un llamado
inicial al procedimiento 𝑂𝑟𝑑𝑒𝑛𝑎_𝑀𝑒𝑧𝑐𝑙𝑎(𝐴, 1, 𝐴. 𝑙𝑜𝑛𝑔𝑖𝑡𝑢𝑑), donde nuevamente
𝐴. 𝑙𝑜𝑛𝑔𝑖𝑡𝑢𝑑 = 𝑛. La siguiente figura (Figura 2) muestra el funcionamiento del
procedimiento de abajo hacia arriba cuando 𝑛 es una potencia de 2. El algoritmo
consiste en fusionar pares de secuencias de 1 elemento para formar secuencias
ordenadas de longitud 2, fusionando pares de secuencias de longitud 2 para formar
secuencias ordenadas de longitud 4, y así sucesivamente, hasta que dos secuencias de
longitud n = 2 se fusionen para formar la secuencia final ordenada de longitud n.

Analizar algoritmos de divide y vencerás

Cuando un algoritmo contiene una llamada recursiva a sí mismo, a menudo se puede


describir su tiempo de ejecución mediante una ecuación de recurrencia o recurrencia,
que describe el tiempo de ejecución general en un problema de tamaño 𝑛 en términos
del tiempo de ejecución en entradas más pequeñas.
Análisis y Diseño de Algoritmos

Luego, se puede usar herramientas matemáticas para resolver la recurrencia y


proporcionar límites sobre el rendimiento del algoritmo. 13

Una repetición o recurrencia del tiempo de ejecución de un algoritmo de divide y


vencerás se sale de los tres pasos del paradigma básico. Como antes, se dejó en claro
que 𝑇(𝑛) sea el tiempo de ejecución de un problema de tamaño 𝑛. Si el tamaño del
problema es lo suficientemente pequeño, digamos 𝑛 ≤ 𝑐 para alguna constante 𝑐, la
solución sencilla toma un tiempo constante, que se describe como 𝛩(1). Suponga que
nuestra división del problema produce un subproblema, cada uno de los cuales es 1/𝑏
del tamaño del original. (Para la ordenación por mezcla, tanto 𝑎 como 𝑏 son 2, pero
también nos encontraremos muchos algoritmos de divide y vencerás en los que 𝑎 ≠
𝑏.) Se necesita un tiempo 𝑇(𝑛/𝑏) para resolver un subproblema de tamaño 𝑛/𝑏, y
entonces toma un tiempo de 𝑎𝑇(𝑛/𝑏) para resolver uno de ellos. Si se toma un tiempo
𝐷(𝑛) para dividir el problema en subproblemas y un tiempo 𝐶(𝑛) para combinar las
soluciones de los subproblemas en la solución del problema original, se obtiene la
recurrencia.

𝛩(1), si 𝑛 ≤ 𝑐
𝑇(𝑛) = {
𝑎𝑇(𝑛/𝑏) + 𝐷(𝑛) + 𝐶(𝑛), otros casos

Para el caso del ordenamiento por mezcla:

Figura 2: Operación del ordenamiento por mezcla

𝑇(𝑛) = 𝛩(𝑛 log 𝑛)

Clase de complejidad: Crecimiento de las funciones


Capítulo 3 de “Introduction to algorithms” Cormen

El orden de crecimiento en el tiempo de ejecución de un algoritmo proporciona una


caracterización simple de la eficiencia del algoritmo y también nos permite comparar
el rendimiento relativo de algoritmos alternativos. Una vez que el tamaño de entrada
𝑛 se vuelve lo suficientemente grande, la ordenación por mezcla (merge sort), con su
tiempo de ejecución en el peor de los casos de 𝛩(𝑛 log 𝑛) , supera al ordenamiento
por inserción, cuyo tiempo de ejecución en el peor de los casos es 𝛩(𝑛2 ). Aunque a
veces podemos determinar el tiempo de ejecución exacto de un algoritmo, como
hicimos para el ordenamiento por inserción, la precisión adicional no suele merecer el
esfuerzo de calcularla. Para entradas lo suficientemente grandes, las constantes
Luis Oyarzun

multiplicativas y los términos de orden inferior de un tiempo de ejecución exacto están


14 dominados por los efectos del tamaño de entrada en sí.

Cuando se miran los tamaños de entrada lo suficientemente grandes como para hacer
relevante solo el orden de crecimiento del tiempo de ejecución, se está estudiando la
eficiencia asintótica de los algoritmos. Es decir, nos preocupa cómo aumenta el tiempo
de ejecución de un algoritmo con el tamaño de la entrada en el límite, a medida que
el tamaño de la entrada aumenta sin límite. Por lo general, un algoritmo que sea
asintóticamente más eficiente será la mejor opción para todas las entradas excepto las
muy pequeñas.

Existen varios métodos estándar para simplificar el análisis asintótico de algoritmos.


La siguiente sección comienza definiendo varios tipos de “notación asintótica”, de los
cuales ya se ha visto un ejemplo en la notación.

Notación asintótica
Las notaciones que se usan para describir el tiempo de ejecución asintótico de un
algoritmo se definen en términos de funciones cuyos dominios son el conjunto de
números naturales ℕ = {0, 1, 2, … }. Tales notaciones son convenientes para describir
la función de tiempo de ejecución del peor caso 𝑇(𝑛), que generalmente se define solo
en tamaños de entrada enteros. Sin embargo, a veces resulta conveniente abusar de
la notación asintótica de diversas formas. Por ejemplo, se podría extender la notación
al dominio de los números reales o, alternativamente, restringirla a un subconjunto de
los números naturales. Sin embargo, se debe asegurar de comprender el significado
preciso de la notación para que cuando se abuse, no hacerlo mal. Esta sección define
las notaciones asintóticas básicas y también presenta algunos abusos comunes.

Notación asintótica, funciones y tiempos de ejecución


Se usa la notación asintótica principalmente para describir los tiempos de ejecución de
los algoritmos, como cuando se describe que el peor tiempo de ejecución en el
algoritmo de ordenamiento por inserción es 𝛩(𝑛2 ). Sin embargo, la notación
asintótica se aplica realmente a las funciones. Recuerde que se caracterizó al peor
tiempo de ejecución de ordenamiento por inserción como 𝑎𝑛2 + 𝑏𝑛 + 𝑐, para algunas
constantes 𝑎, 𝑏 y 𝑐. Al escribir que el tiempo de ejecución del ordenamiento por
inserción es 𝛩(𝑛2 ), resumiendo algunos detalles de esta función. Debido a que la
notación asintótica se aplica a las funciones, lo que se describe como 𝛩(𝑛2 ), era la
función 𝑎𝑛2 + 𝑏𝑛 + 𝑐, que en ese caso caracterizó el peor tiempo de ejecución del
ordenamiento por inserción.

Aquí, las funciones a las que se aplique la notación asintótica normalmente


caracterizarán los tiempos de ejecución de los algoritmos. Pero la notación asintótica
puede aplicarse a funciones que caracterizan algún otro aspecto de los algoritmos (la
cantidad de espacio que usan, por ejemplo), o incluso a funciones que no tienen nada
que ver con los algoritmos.

Incluso cuando se utiliza la notación asintótica para aplicarla al tiempo de ejecución de


un algoritmo, se debe comprender a qué tiempo de ejecución se refiere. A veces nos
interesa el peor tiempo de ejecución. A menudo, sin embargo, se desea caracterizar el
tiempo de ejecución sin importar cuál sea la entrada. En otras palabras, a menudo se
desea hacer una declaración general que cubra todas las entradas, no solo en el peor
de los casos. A continuación, se verán notaciones asintóticas que son adecuadas para
caracterizar los tiempos de ejecución sin importar cuál sea la entrada.
Análisis y Diseño de Algoritmos

Notación “𝛩” (Theta)


Anteriormente fue utilizada para definir el peor caso de ejecución del ordenamiento 15
por inserción (𝑇(𝑛) = 𝛩(𝑛2 )), ahora, definiendo a la notación, para una función dada
𝑔(𝑛), se determina que 𝛩(𝑔(𝑛)) es definida como el conjunto de funciones

𝛩(𝑔(𝑛)) = {𝑓(𝑛): existen constantes positivas 𝑐1 , 𝑐2 y 𝑛0 tales que 0 ≤ 𝑐1 𝑔(𝑛)


≤ 𝑓(𝑛) ≤ 𝑐2 𝑔(𝑛) para todo 𝑛 ≥ 𝑛0 }‡

Una función 𝑓(𝑛) pertenece al conjunto 𝛩(𝑔(𝑛)) si existen constantes positivas 𝑐1 y


𝑐2 tales que se puede ubicar entre 𝑐1 𝑔(𝑛) y 𝑐2 𝑔(𝑛), para un 𝑛 suficientemente grande.
Como 𝛩(𝑔(𝑛)) es un conjunto, se puede escribir “𝑓(𝑛) ∈ 𝛩(𝑔(𝑛))” para indicar que
𝑓(𝑛) es un miembro de 𝛩(𝑔(𝑛)). En su lugar, usualmente se escribe “𝑓(𝑛) = Figura 3: Notación 𝛩
𝛩(𝑔(𝑛))”para expresar la misma noción. Puede estar confundido porque se abusó de
la igualdad de esta manera.

En la Figura 3, se ofrece una imagen intuitiva de las funciones 𝑓(𝑛) y 𝑔(𝑛), donde
𝑓(𝑛) ∈ 𝛩(𝑔(𝑛)). Para todos los valores de 𝑛 en y a la derecha de 𝑛0 , el valor de 𝑓(𝑛)
se encuentra por encima de𝑐1 𝑔(𝑛) y por debajo de 𝑐2 𝑔(𝑛). En otras palabras, para
todo 𝑛 ≥ 𝑛0 , la función 𝑓(𝑛) es igual a 𝑔(𝑛) dentro de un factor constante. Y se
concluye que 𝑔(𝑛) es una cota asintóticamente ajustada para 𝑓(𝑛).

La definición de 𝛩(𝑔(𝑛)) requiere que cada miembro 𝑓(𝑛) ∈ 𝛩(𝑔(𝑛)) sea


asintóticamente no negativo, es decir, que 𝑓(𝑛) sea no negativo siempre que 𝑛 sea
suficientemente grande. (Una función asintóticamente positiva es aquella que es
positiva para todo 𝑛 suficientemente grande.) En consecuencia, la función 𝑔(𝑛) en sí
misma debe ser asintóticamente no negativa, o de lo contrario el conjunto 𝛩(𝑔(𝑛))
está vacío. Por lo tanto, asumiremos que cada función utilizada dentro de la notación
𝛩 debe ser asintóticamente no negativa. Esta suposición se aplica también a las otras
notaciones asintóticas.

Hasta ahora se ha introducido una noción informal de notación 𝛩‚ que equivalía a


desechar los términos de orden inferior e ignorar el coeficiente principal del término
de orden superior. Justificando brevemente esta intuición utilizando la definición
formal para demostrar que 1/2 𝑛^2 − 3𝑛 = 𝛩(𝑛^2). Para hacerlo, se deben
determinar constantes positivas para 𝑐1 , 𝑐2 y 𝑛0 tales que
1
𝑐1 𝑛2 ≤ 𝑛2 − 3𝑛 ≤ 𝑐2 𝑛2
2
para todo 𝑛 ≥ 𝑛0 . Dividiendo para 𝑛2 rendimientos
1 3
𝑐1 ≤ − ≤ 𝑐2
2 𝑛
Se puede hacer que la desigualdad de la derecha se mantenga para cualquier valor de
𝑛 ≥ 1 eligiendo cualquier constante 𝑐_2 ≥ 1/2. Asimismo, se puede hacer que la
desigualdad de la izquierda se mantenga para cualquier valor de 𝑛 ≥ 7 eligiendo
cualquier constante 𝑐_1 ≥ 1/14. Por lo tanto, al elegir 𝑐_1 = 1/14, 𝑐_1 = 1/2 y 𝑛0 =
7, se puede verificar que 1/2 𝑛^2 − 3𝑛 = 𝛩(𝑛^2). Ciertamente, existen otras
opciones para las constantes, pero lo importante es que existe alguna elección. Tenga


Dentro de la notación una coma significa “tales que”
Luis Oyarzun

en cuenta que estas constantes dependen de la función 1/2 𝑛^2 − 3𝑛; una función
16 diferente perteneciente a 𝛩(𝑛2 ), normalmente requeriría diferentes constantes.

Intuitivamente, los términos de orden inferior de una función asintóticamente positiva


se pueden ignorar al determinar límites asintóticamente estrechos porque son
insignificantes para 𝑛 grandes. Cuando 𝑛 es grande, incluso una pequeña fracción del
término de orden superior es suficiente para dominar los términos de orden inferior.
Por lo tanto, establecer 𝑐1 en un valor ligeramente menor que el coeficiente del
término de orden más alto y establecer 𝑐2 en un valor ligeramente mayor permite
satisfacer las desigualdades en la definición de la notación 𝛩. Del mismo modo, el
coeficiente del término de orden superior puede ignorarse, ya que solo cambia 𝑐1 y 𝑐2
por un factor constante igual al coeficiente.

Dado que cualquier constante es un polinomio de grado 0, se puede expresar cualquier


función constante como 𝛩(𝑛0 ), o 𝛩(1) Esta última notación es un abuso menor, sin
embargo, porque la expresión no indica qué variable tiende al infinito. A menudo se
usa la notación 𝛩(1) para tener una constante o una función constante con respecto
a alguna variable.

Notación “O” (Gran O)


La notación 𝛩 delimita asintóticamente una función desde arriba y desde abajo. Ahora,
cuando solo se tiene un límite superior asintótico, se utiliza la notación O. Para una
función dada 𝑔(𝑛), dada por 𝑂(𝑔(𝑛)) (pronunciado “Gran O de g de n" o a veces
simplemente "O de g de n") es definida como el conjunto de funciones

𝑂(𝑔(𝑛)) = {𝑓(𝑛): existen constantes positivas 𝑐 y 𝑛0 tales que 0 ≤ 𝑓(𝑛)


≤ 𝑐𝑔(𝑛) para todo 𝑛 ≥ 𝑛0 }
Entonces, se utiliza la notación O para dar un límite superior en una función, dentro de
Figura 4: Notación O un factor constante. La figura muestra la intuición detrás de la notación O. Para todos
los valores 𝑛 en y a la derecha de 𝑛0 , el valor de la función 𝑓(𝑛) es igual o inferior a
𝑐𝑔(𝑛).

Se escribe 𝑓(𝑛) = 𝑂(𝑔(𝑛)) para indicar que una función 𝑓(𝑛) es miembro del
conjunto 𝑂(𝑔(𝑛)). Tenga en cuenta que 𝑓(𝑛) = 𝛩(𝑔(𝑛)) implica 𝑓(𝑛) = 𝑂(𝑔(𝑛)),
ya que la notación 𝛩‚ es una noción más fuerte que la notación O. Escrito en notación
para teoría de conjuntos, se tiene que Θ(𝑔(𝑛)) ⊆ O(𝑔(𝑛)). Por tanto, la prueba de
que cualquier función cuadrática 𝑎𝑛2 + 𝑏𝑛 + 𝑐, donde 𝑎 > 0, está en 𝛩(𝑛2 ) también
muestra que dicha función cuadrática está en 𝑂(𝑛2 ). Lo que puede ser más
sorprendente es que cuando 𝑎 > 0, cualquier función lineal 𝑎𝑛 + 𝑏 está en 𝑂(𝑛2 ), lo
𝑏
que se verifica fácilmente tomando 𝑐 = 𝑎 + |𝑏| y 𝑛0 = 𝑚á𝑥 (1, − ).
𝑎

Si ha visto la notación O antes, puede resultarle extraño que se deba escribir, por
ejemplo, 𝑛 = 𝑂(𝑛2 ). En la literatura, a veces se encuentra la notación O que describe
informalmente límites asintóticamente estrechos, es decir, lo que hemos definido
usando la notación 𝛩.

Usando la notación O, a menudo se puede describir el tiempo de ejecución de un


algoritmo simplemente inspeccionando la estructura general del algoritmo. Por
ejemplo, la estructura de bucle doblemente anidado del algoritmo de ordenación por
inserción visto anteriormente produce inmediatamente un límite superior 𝑂(𝑛2 ) en el
peor tiempo de ejecución: el costo de cada iteración del bucle interno está acotado
desde arriba por 𝑂(1) (constante), los índices 𝑖 y 𝑗 son ambos como máximo 𝑛, y el
Análisis y Diseño de Algoritmos

ciclo interno se ejecuta como máximo una vez para cada uno de los 𝑛2 pares de valores
para 𝑖 y 𝑗.
17

Dado que la notación O describe un límite superior, cuando se la usa para limitar el
peor tiempo de ejecución de un algoritmo, se tiene un límite en el tiempo de ejecución
del algoritmo en cada entrada, la declaración general que se revisó anteriormente. Por
lo tanto, el límite de 𝑂(𝑛2 ) en el peor de los casos de tiempo de ejecución del
ordenamiento por inserción también se aplica a su tiempo de ejecución en cada
entrada. Sin embargo, el límite 𝛩(𝑛2 ) en el peor tiempo de ejecución del
ordenamiento por inserción no implica un límite‚ 𝛩(𝑛2 ) en el tiempo de ejecución del
ordenamiento por inserción en cada entrada. Por ejemplo, se revisó que cuando la
entrada ya está ordenada, el ordenamiento por inserción se ejecuta en un tiempo
𝛩(𝑛).

Técnicamente, es un abuso decir que el tiempo de ejecución del ordenamiento por


inserción es 𝑂(𝑛2 ), ya que, para un 𝑛 dado, el tiempo de ejecución real varía,
dependiendo de la entrada particular de tamaño 𝑛. Cuando se dice que "el tiempo de
ejecución es 𝑂(𝑛2 )", se quiere decir que hay una función 𝑓(𝑛) que es 𝑂(𝑛2 ) tal que
para cualquier valor de 𝑛, no importa qué entrada particular de tamaño 𝑛 se elija, el
tiempo de ejecución en esa entrada está limitado desde arriba por el valor 𝑓(𝑛). De
manera equivalente, quiere decir que el peor tiempo de ejecución es 𝑂(𝑛2 ).

Notación “𝛺” (Gran Omega)


Así como la notación O proporciona un límite superior asintótico en una función, la
notación 𝛺 proporciona un límite inferior asintótico. Para una función dada 𝑔(𝑛), que
esta denotada por 𝛺(𝑔(𝑛)) (pronunciado "omega grande de g de n" o a veces
simplemente "omega de g de n") se define como el conjunto de funciones

Ω(𝑔(𝑛)) = {𝑓(𝑛): existen constantes positivas 𝑐 y 𝑛0 tales que 0 ≤ 𝑐𝑔(𝑛)


≤ 𝑓(𝑛) para todo 𝑛 ≥ 𝑛0 }§
La figura muestra la intuición detrás de la notación. Para todos los valores 𝑛 en o a la
derecha de 𝑛0 , el valor de 𝑓(𝑛) es igual o superior a 𝑐𝑔(𝑛). Figura 5: Notación 𝛺

A partir de las definiciones de las notaciones asintóticas que se han estudiado hasta
ahora, es fácil probar el siguiente teorema.

Teorema
Para dos funciones cualesquiera 𝑓(𝑛) y 𝑔(𝑛), tenemos 𝑓(𝑛) = 𝛩(𝑔(𝑛)) si y solo si
𝑓(𝑛) = 𝑂(𝑔(𝑛)) y 𝑓(𝑛) = 𝛺(𝑔(𝑛)).

Notación “o” (pequeña o)


El límite superior asintótico proporcionado por la notación “O” (Gran O) puede ser o
no asintóticamente apretado. El límite 2𝑛2 = 𝑂(𝑛2 ) es asintóticamente apretado,
pero el límite 2𝑛 = 𝑂(𝑛2 ) no lo es. Se usa la notación “o” (pequeña o) para denotar
un límite superior que no es asintóticamente apretado. 𝑜(𝑔(𝑛)) (“pequeño o de g de
n”) es definida formalmente como el conjunto:

𝑜(𝑔(𝑛)) = {𝑓(𝑛): para cualquier constante positiva 𝑐


> 0, existe una constante 𝑛0 >0 tal que 0 ≤ 𝑓(𝑛)
≤ 𝑐𝑔(𝑛) para todo 𝑛 ≥ 𝑛0 }

§
Dentro de la notación una coma significa “tales que”
Luis Oyarzun

Por ejemplo, 2𝑛 = 𝑂(𝑛2 ), pero 2𝑛2 ≠ 𝑂(𝑛2 ).


18
Las definiciones de notación “O” y notación “o” son similares. La principal diferencia
es que en 𝑓(𝑛) = 𝑂(𝑔(𝑛)), el límite 0 ≤ 𝑓(𝑛) ≤ 𝑐𝑔(𝑛) se cumple para alguna
constante 𝑐 > 0, pero en 𝑓(𝑛) = 𝑜(𝑔(𝑛)), el límite 0 ≤ 𝑓(𝑛) ≤ 𝑐𝑔(𝑛) se cumple
para todas las constantes 𝑐 > 0. Intuitivamente, en notación “o”, la función 𝑓(𝑛) se
vuelve insignificante en relación con 𝑔(𝑛) cuando 𝑛 se acerca al infinito; es decir,
𝑓(𝑛)
lim =0
𝑛→∞ 𝑔(𝑛)

Algunos autores utilizan este límite como definición de la notación “o”; la definición
en este documento también restringe las funciones anónimas para que sean
asintóticamente no negativas.

Notación 𝜔 (pequeña omega)


Por analogía, la notación 𝜔 es para la notación 𝛺 como la notación “o” es para la
notación “O”. Se utiliza la notación 𝜔 Para denotar un límite inferior que no es
asintóticamente apretado. Una forma de definirlo es por

𝑓(𝑛) ∈ 𝜔(𝑔(𝑛)) sí y solo si 𝑔(𝑛) ∈ 𝑜(𝑓(𝑛)).

Formalmente, sin embargo, se define 𝜔(𝑔(𝑛)) ("pequeño omega de g de n") como el


conjunto

𝜔(𝑔(𝑛)) = {𝑓(𝑛): para cualquier constante positiva 𝑐


> 0, existe una constante 𝑛0 >0 tal que 0 ≤ 𝑐𝑔(𝑛)
≤ 𝑓(𝑛) para todo 𝑛 ≥ 𝑛0 }
𝑛2 𝑛2
Por ejemplo, = 𝜔(𝑛), pero ≠ 𝜔(𝑛2 ). La relación 𝑓(𝑛) = 𝜔(𝑔(𝑛)) implica que
2 2

𝑓(𝑛)
lim =∞
𝑛→∞ 𝑔(𝑛)

si existe el límite. Es decir, 𝑓(𝑛) se vuelve arbitrariamente grande en relación con 𝑔(𝑛)
cuando n se aproxima al infinito.

Comparando funciones
Muchas de las propiedades relacionales de los números reales se aplican también a las
comparaciones asintóticas. Para lo siguiente, suponga que 𝑓(𝑛) y 𝑔(𝑛) son
asintóticamente positivos.

Transitividad:
𝑓(𝑛) = 𝛩(𝑔(𝑛)) y 𝑔(𝑛) = 𝛩(ℎ(𝑛)) implica 𝑓(𝑛) = 𝛩(ℎ(𝑛))

𝑓(𝑛) = 𝑂(𝑔(𝑛)) y 𝑔(𝑛) = 𝑂(ℎ(𝑛)) implica 𝑓(𝑛) = 𝑂(ℎ(𝑛))

𝑓(𝑛) = 𝛺(𝑔(𝑛)) y 𝑔(𝑛) = 𝛺(ℎ(𝑛)) implica 𝑓(𝑛) = 𝛺(ℎ(𝑛))

𝑓(𝑛) = 𝑜(𝑔(𝑛)) y 𝑔(𝑛) = 𝑜(ℎ(𝑛)) implica 𝑓(𝑛) = 𝑜(ℎ(𝑛))

𝑓(𝑛) = 𝜔(𝑔(𝑛)) y 𝑔(𝑛) = 𝜔(ℎ(𝑛)) implica 𝑓(𝑛) = 𝜔(ℎ(𝑛))

Reflexividad
𝑓(𝑛) = 𝛩(𝑓(𝑛))
Análisis y Diseño de Algoritmos

𝑓(𝑛) = 𝑂(𝑓(𝑛))
19
𝑓(𝑛) = 𝛺(𝑓(𝑛))

𝑓(𝑛) = 𝑜(𝑓(𝑛))

𝑓(𝑛) = 𝜔(𝑓(𝑛))

Simetría
𝑓(𝑛) = 𝛩(𝑔(𝑛)) si y solo si 𝑔(𝑛) = 𝛩(𝑓(𝑛))

Simetría transpuesta
𝑓(𝑛) = 𝑂(𝑔(𝑛)) si y solo si 𝑔(𝑛) = 𝛺(𝑓(𝑛))

𝑓(𝑛) = 𝑜(𝑔(𝑛)) si y solo si 𝑔(𝑛) = 𝜔(𝑓(𝑛))

Debido a que estas propiedades son válidas para las notaciones asintóticas, se puede
establecer una analogía entre la comparación asintótica de dos funciones 𝑓 y 𝑔 y la
comparación de dos números reales 𝑎 y 𝑏:

𝑓(𝑛) = 𝛩(𝑔(𝑛)) es como 𝑎 ≤ 𝑏

𝑓(𝑛) = 𝑂(𝑔(𝑛)) es como 𝑎 ≥ 𝑏

𝑓(𝑛) = 𝛺(𝑔(𝑛)) es como 𝑎 = 𝑏

𝑓(𝑛) = 𝑜(𝑔(𝑛)) es como 𝑎 < 𝑏

𝑓(𝑛) = 𝜔(𝑔(𝑛)) es como 𝑎 > 𝑏

Se define que 𝑓(𝑛) es asintóticamente más pequeño que 𝑔(𝑛) si 𝑓(𝑛) = 𝑜(𝑔(𝑛)), y
𝑓(𝑛) es asintóticamente más grande que 𝑓(𝑛) = 𝜔(𝑔(𝑛)).

Sin embargo, una propiedad de los números reales no se traslada a la notación


asintótica:

Tricotomía: Para dos números reales cualesquiera 𝑎 y 𝑏, exactamente uno de los


siguientes debe mantener: 𝑎 < 𝑏, 𝑎 = 𝑏, o 𝑎 > 𝑏.

Aunque se pueden comparar dos números reales cualesquiera, no todas las funciones
son asintóticamente comparables. Es decir, para dos funciones 𝑓(𝑛) y 𝑔(𝑛), puede
darse el caso de que ni 𝑓(𝑛) = 𝑂(𝑔(𝑛)) ni 𝑓(𝑛) = 𝛺(𝑔(𝑛)) se cumplan. Por ejemplo,
no se pueden comparar las funciones 𝑛 y 𝑛1+sin 𝑛 usando notación asintótica, ya que
el valor del exponente en 𝑛1+sin 𝑛 oscila entre 0 y 2, tomando todos los valores
intermedios.
Unidad 2: Técnicas de diseño de algoritmos.
Tipos de algoritmos
Los algoritmos se pueden clasificar básicamente en 6 tipos:

• Recursivo
• Divide y Conquista
• Programación dinámica
• Voraz (Greedy)
• Fuerza bruta
• Backtracking recursivo

Algoritmos Recursivos
Son los algoritmos, donde una función se vuelve a llamar hasta que se cumple una
condición base.

Ejemplo:
𝑖=1

𝑚𝑢𝑙𝑡𝑖𝑝𝑙𝑖𝑐𝑎𝑐𝑖𝑜𝑛(𝑥, 𝑦) = ∑ 𝑥𝑖
𝑖=𝑦

1 multiplicacion :: Entero -> Entero -> Entero


2 multiplicacion x 1 = x
3 multiplicacion x y = x + multiplicacion x (y-1)
Algoritmo: Divide y Conquista
Este tipo de algoritmo busca dividir un problema en soluciones más pequeñas, para
alcanzar la solución final

Algoritmo: Programación dinámica


Al igual que el método de divide y conquista, resuelve problemas combinando las
soluciones a los subproblemas

Al desarrollar un algoritmo de programación dinámica, se sigue una secuencia de


cuatro pasos:

1. Caracterizar la estructura de una solución óptima.


2. Definir de forma recursiva el valor de una solución óptima.
3. Calcular el valor de una solución óptima, normalmente de forma ascendente.
4. Construir una solución óptima a partir de información calculada.

Ejemplo:

Serie de Fibonacci: 0, 1, 1, 2, 3, 5, 8, …, n, donde:

𝑓𝑖𝑏𝑜(𝑛) = 𝑓𝑖𝑏𝑜(𝑛 − 1) + 𝑓𝑖𝑏𝑜(𝑛 − 2)

1 fibo :: entero -> entero


2 fibo 0 = 0
3 fibo 1 = 1
4 fibo n = fibo(n-1) + fibo(n-2)
Luis Oyarzun

Algoritmos Voraces (Greedy algorithm)


22 Los algoritmos para problemas de optimización generalmente pasan por una
secuencia de pasos, con un conjunto de opciones en cada paso.

Para muchos problemas de optimización, usar programación dinámica para


determinar las mejores opciones es excesivo; algoritmos más simples y eficientes
serán suficientes.

Un algoritmo voraz o codicioso siempre toma la decisión que se ve mejor en ese


momento.

Estos algoritmos se utilizan para resolver problemas de optimización.

En este algoritmo, encontramos una solución óptima localmente (sin tener en cuenta
ninguna consecuencia en el futuro) y esperamos encontrar la solución óptima a nivel
global.

El método no garantiza que podamos encontrar una solución óptima.

El algoritmo tiene 5 componentes:

1. El primero es un conjunto de candidatos a partir del cual intentamos encontrar


una solución.
2. Una función de selección que ayuda a elegir al mejor candidato posible.
3. Una función de viabilidad que ayuda a decidir si el candidato se puede utilizar para
encontrar una solución.
4. Una función objetivo que asigna valor a una posible solución o a una solución
parcial.
5. Función de solución que indica cuándo hemos encontrado una solución al
problema.

Ejemplo:

La codificación Huffman y el algoritmo de Dijkstra son dos ejemplos principales en los


que se utiliza el algoritmo Greedy.

El algoritmo de Dijkstra, busca el camino más corto entre los nodos de un grafo,
analizando cada posible caso que tenga.

La solución optima no es encontrada muchas veces por un algoritmo voraz

Algoritmo: Fuerza Bruta


Este es uno de los conceptos de algoritmos más simples.
Análisis y Diseño de Algoritmos

Un algoritmo de fuerza bruta itera ciegamente todas las soluciones posibles para
buscar una o más de una solución que pueda resolver una función. 23
Piense en la fuerza bruta como si utilizara todas las combinaciones posibles de
números para abrir una caja fuerte.

En este caso, creo que ¿Podemos hacerlo mejor?

Ejemplo:
1 Algoritmo B_Busqueda(A[0..n], X)
2 A[n] ← X
3 i ← 0
4 mientras que A[i] ≠ X haga
5 i ← i + 1
6 si i < n
7 devuelve i
8 sino
9 devuelve -1
Algoritmo de Retroceso (Backtracking)
El bactracking, retroceso o vuelta atrás, es una técnica para encontrar una solución a
un problema en un enfoque incremental.

Resuelve problemas de forma recursiva e intenta llegar a la solución de un problema,


resolviendo una parte del problema a la vez. Si una de las soluciones falla, la
eliminamos y retrocedemos para encontrar otra solución.

En otras palabras, un algoritmo de backtracking resuelve un subproblema y si no


resuelve el problema, deshace el último paso y comienza de nuevo para encontrar la
solución al problema.

Ejemplo:

El problema de “N Reinas”, es un buen


ejemplo para ver el algoritmo Backtracking
en acción.

El problema de las “N Reinas” establece que


hay N piezas de reinas en un tablero de
ajedrez y tenemos que organizarlas de modo
que ninguna reina pueda atacar a ninguna
otra reina en el tablero una vez organizado.
Luis Oyarzun

Ahora echemos un vistazo al algoritmo SolveNQ:


24
⚫ Problema de las N reinas
⚫ Donde N = 4

Algoritmo de ordenamiento
Ordenamiento Rápido (Quicksort)

Ordenamiento por montículos (Heapsort9

Arboles de búsqueda

También podría gustarte