07.05.2013 Views

MANUAL DE ANÁLISIS Y DISEÑO DE ALGORITMOS

MANUAL DE ANÁLISIS Y DISEÑO DE ALGORITMOS

MANUAL DE ANÁLISIS Y DISEÑO DE ALGORITMOS

SHOW MORE
SHOW LESS

You also want an ePaper? Increase the reach of your titles

YUMPU automatically turns print PDFs into web optimized ePapers that Google loves.

<strong>MANUAL</strong> <strong>DE</strong> <strong>ANÁLISIS</strong> Y <strong>DISEÑO</strong><br />

<strong>DE</strong> <strong>ALGORITMOS</strong><br />

Versión 1.0<br />

Dirección de Área Informática<br />

www.informatica.inacap.cl


Colaboraron en el presente manual:<br />

Víctor Valenzuela Ruz<br />

v_valenzuela@inacap.cl<br />

Docente<br />

Ingeniería en Gestión Informática<br />

INACAP Copiapó<br />

Página 1


Copyright<br />

© 2003 de Instituto Nacional de Capacitación. Todos los derechos reservados. Copiapó, Chile.<br />

Este documento puede ser distribuido libre y gratuitamente bajo cualquier soporte siempre y<br />

cuando se respete su integridad.<br />

Queda prohibida su venta y reproducción sin permiso expreso del autor.<br />

Página 2


Prefacio<br />

"Lo que debemos aprender a hacer lo aprendemos haciéndolo".<br />

Aristóteles, Ethica Nicomachea II (325 A.C.)<br />

El presente documento ha sido elaborado originalmente como apoyo a la<br />

asignatura de “Análisis y Diseño de Algoritmos” del séptimo semestre de la<br />

carrera de Ingeniería en Gestión Informática, del Instituto Nacional de<br />

Capacitación (INACAP). Este documento engloba la mayor parte de la materia<br />

de este curso troncal e incluye ejemplos resueltos y algunos ejercicios que serán<br />

desarrollados en clases.<br />

El manual ha sido concebido para ser leído en forma secuencial, pero también<br />

para ser de fácil consulta para verificar algún tema específico.<br />

No se pretende que estos apuntes sustituyan a la bibliografía de la asignatura ni<br />

a las clases teóricas, sino que sirvan más bien como complemento a las notas<br />

que el alumno debe tomar en clases. Asimismo, no debe considerarse un<br />

documento definitivo y exento de errores, si bien ha sido elaborado con<br />

detenimiento y revisado exhaustivamente.<br />

El autor pretende que sea mejorado, actualizado y ampliado con cierta<br />

frecuencia, lo que probablemente desembocará en sucesivas versiones, y para<br />

ello nadie mejor que los propios lectores para plantear dudas, buscar errores y<br />

sugerir mejoras.<br />

El Autor.<br />

Copiapó, Enero 2003<br />

Página 3


Índice General<br />

Presentación 7<br />

Página<br />

1. Introducción<br />

1.1. Motivación y Objetivos 8<br />

1.2. Algunas Notas sobre la Historia de los Algoritmos 10<br />

1.3. Fundamentos Matemáticos 11<br />

2. Algoritmos y Problemas<br />

2.1. Definición de Algoritmo 18<br />

2.2. Formulación y Resolución de Problemas 19<br />

2.3. Razones para Estudiar los Algoritmos 22<br />

2.4. Formas de Representación de Algoritmos 23<br />

2.5. La Máquina de Turing 24<br />

3. Eficiencia de Algoritmos<br />

3.1. Introducción 25<br />

3.2. Concepto de Eficiencia 25<br />

3.3. Medidas de Eficiencia 26<br />

3.4. Análisis A Priori y Prueba A Posteriori 27<br />

3.5. Concepto de Instancia 27<br />

3.6. Tamaño de los Datos 28<br />

3.7. Cálculo de Costos de Algoritmos<br />

3.7.1. Cálculo de eficiencia en análisis iterativo 29<br />

3.7.2. Cálculo de eficiencia en análisis recursivo 29<br />

3.8. Principio de Invarianza 31<br />

3.9. Análisis Peor Caso, Mejor Caso y Caso Promedio 31<br />

4. Análisis de Algoritmos<br />

4.1. Introducción 34<br />

4.2. Tiempos de Ejecución 34<br />

4.3. Concepto de Complejidad 36<br />

4.4. Órdenes de Complejidad 37<br />

4.5. Notación Asintótica<br />

4.5.1. La O Mayúscula 39<br />

4.5.2. La o Minúscula 39<br />

4.5.3. Diferencias entre O y o 42<br />

4.5.4. Las Notaciones Ω y Θ 42<br />

4.5.5. Propiedades y Cotas más Usuales 42<br />

4.6. Ecuaciones de Recurrencias<br />

4.6.1. Introducción 45<br />

4.6.2. Resolución de Recurrecias 45<br />

Página 4


4.6.3. Método del Teorema Maestro 45<br />

4.6.4. Método de la Ecuación Característica 46<br />

4.6.5. Cambio de Variable 48<br />

4.7. Ejemplos y Ejercicios 49<br />

5. Estrategias de Diseño de Algoritmos<br />

5.1. Introducción 51<br />

5.2. Recursión 51<br />

5.3. Dividir para Conquistar 55<br />

5.4. Programación Dinámica 57<br />

5.5. Algoritmos Ávidos 58<br />

5.6. Método de Retroceso (backtracking) 60<br />

5.7. Método Branch and Bound 61<br />

6. Algoritmos de Ordenamiento<br />

6.1. Concepto de Ordenamiento 63<br />

6.2. Ordenamiento por Inserción 63<br />

6.3. Ordenamiento por Selección 64<br />

6.4. Ordenamiento de la Burbuja (Bublesort ) 65<br />

6.5. Ordenamiento Rápido (Quicksort) 65<br />

6.6. Ordenamiento por Montículo (Heapsort) 68<br />

6.7. Otros Métodos de Ordenamiento<br />

6.7.1. Ordenamiento por Incrementos Decrecientes 74<br />

6.7.2. Ordenamiento por Mezclas Sucesivas 75<br />

7. Algoritmos de Búsqueda<br />

7.1. Introducción 78<br />

7.2. Búsqueda Lineal 78<br />

7.3. Búsqueda Binaria 80<br />

7.4. Árboles de Búsqueda 81<br />

7.5. Búsqueda por Transformación de Claves (Hashing) 81<br />

7.6. Búsqueda en Textos<br />

7.6.1. Algoritmo de Fuerza Bruta 88<br />

7.6.2. Algoritmo de Knuth-Morris-Pratt 88<br />

7.6.3. Algoritmo de Boyer-Moore 92<br />

8. Teoría de Grafos<br />

8.1. Definiciones Básicas 97<br />

8.2. Representaciones de Grafos<br />

8.2.1. Matriz y Lista de Adyacencia 101<br />

8.2.2. Matriz y Lista de Incidencia 103<br />

8.3. Recorridos de Grafos<br />

8.3.1. Recorridos en Amplitud 104<br />

8.3.2. Recorridos en Profundidad 106<br />

8.4. Grafos con Pesos 108<br />

8.5. Árboles 108<br />

Página 5


8.6. Árbol Cobertor Mínimo<br />

8.6.1. Algoritmo de Kruskal 109<br />

8.6.2. Algoritmo de Prim 111<br />

8.7. Distancias Mínimas en un Grafo Dirigido<br />

8.7.1. Algoritmo de Dijkstra 113<br />

8.7.2. Algoritmo de Ford 114<br />

8.7.3. Algoritmo de Floyd-Warshall 115<br />

9. Complejidad Computacional<br />

9.1. Introducción 118<br />

9.2. Algoritmos y Complejidad 118<br />

9.3. Problemas NP Completos 118<br />

9.4. Problemas Intratables 121<br />

9.5. Problemas de Decisión 123<br />

9.6. Algoritmos No Determinísticos 124<br />

Bibliografía 126<br />

Página 6


Presentación<br />

El curso de Análisis y Diseño de Algoritmos (ADA) tiene como propósito<br />

fundamental proporcio nar al estudiante las estructuras y técnicas de manejo de<br />

datos más usuales y los criterios que le permitan decidir, ante un problema<br />

determinado, cuál es la estructura y los algoritmos óptimos para manipular los<br />

datos.<br />

El curso está diseñado para propor cionar al alumno la madurez y los<br />

conocimientos necesarios para enfrentar, tanto una gran variedad de los<br />

problemas que se le presentarán en su vida profesional futura, como aquellos que<br />

se le presentarán en los cursos más avanzados.<br />

El temario gira en torno a dos temas principales: estructuras de datos y análisis de<br />

algoritmos. Haciendo énfasis en la abstracción, se presentan las estructuras de<br />

datos más usuales (tanto en el sentido de útiles como en el de comunes), sus<br />

definiciones, sus especificaciones como tipos de datos abstractos (TDA's), su<br />

implantación, análisis de su complejidad en tiempo y espacio y finalmente algunas<br />

de sus aplicaciones. Se presentan también algunos algoritmos de ordenación, de<br />

búsqueda, de recorridos en gráficas y para resolver problemas mediante recursión<br />

y retroceso mínimo analizando también su complejidad, lo que constituye una<br />

primera experiencia del alumno con el análisis de algoritmos y le proporcionará<br />

herramientas y madurez que le serán útiles el resto de su carrera.<br />

Hay que enfatizar que el curso no es un curso de programación avanzada, su<br />

objetivo es preparar al estudiante brindándole una visión amplia de las<br />

herramientas y métodos más usuales para la solución de problemas y el análisis de<br />

la eficiencia de dichas soluciones. Al terminar el curso el alumno poseerá un<br />

nutrido "arsenal" de conocimientos de los que puede echar mano cuando lo<br />

requiera en su futura vida académica y profesional. Sin embargo, dadas estas<br />

características del curso, marca generalmente la frontera entre un programador<br />

principiante y uno maduro, capaz de analizar, entender y programar sistemas de<br />

software más o menos complejos.<br />

Para realizar las implantaciones de las distintas estructuras de datos y de los<br />

algoritmos que se presentan en el curso se hará uso del lenguaje de programación<br />

MODULA-2, C++ y Java.<br />

Página 7


Capítulo 1<br />

Introducción<br />

1.1 Motivación y Objetivos<br />

La representación de información es fundamental para las Ciencias de la<br />

Computación.<br />

La Ciencia de la Computación (Computer Science), es mucho más que el<br />

estudio de cómo usar o programar las computadoras. Se ocupa de<br />

algoritmos, métodos de calcular resultados y máquinas autómatas.<br />

Antes de las computadoras, existía la computación, que se refiere al uso de<br />

métodos sistemáticos para encontrar soluciones a problemas algebraicos o<br />

simbólicos.<br />

Los babilonios, egipcios y griegos, desarrollaron una gran variedad de<br />

métodos para calcular cosas, por ejemplo el área de un círculo o cómo<br />

calcular el máximo común divisor de dos números enteros (teorema de<br />

Euclides).<br />

En el siglo XIX, Charles Babbage describió una máquina que podía liberar a<br />

los hombres del tedio de los cálculos y al mismo tiempo realizar cálculos<br />

confiables.<br />

La motivación principal de la computación por muchos años fue la de<br />

desarrollar cómputo numérico más preciso. La Ciencia de la Computación<br />

creció del interés en sistemas formales para razonar y la mecanización de la<br />

lógica, así cómo también del procesamiento de datos de negocios. Sin<br />

embargo, el verdadero impacto de la computación vino de la habilidad de las<br />

computadoras de representar, almacenar y transformar la información.<br />

La computación ha creado muchas nuevas áreas como las de correo<br />

electrónico, publicación electrónica y multimedia.<br />

La solución de problemas del mundo real, ha requerido estudiar más de<br />

cerca cómo se realiza la computación. Este estudio ha ampliado la gama de<br />

problemas que pueden ser resueltos.<br />

Por otro lado, la construcción de algoritmos es una habilidad elegante de un<br />

gran significado práctico. Comput adoras más poderosas no disminuyen el<br />

significado de algoritmos veloces. En la mayoría de las aplicaciones no es el<br />

hardware el cuello de botella sino más bien el software inefectivo.<br />

Página 8


Este curso trata de tres preguntas centrales que aparecen cuando uno quiere<br />

que un computador haga algo: ¿Es posible hacerlo? ¿Cómo se hace? y ¿Cuán<br />

rápido puede hacerse? El curso da conocimientos y métodos para responder<br />

estas preguntas, al mismo tiempo intenta aumentar la capacidad de<br />

encontrar algoritmos efectivos. Los problemas para resolver, son un<br />

entrenamiento en la solución algorítmica de problemas, y para estimular el<br />

aprendizaje de la materia.<br />

Objetivos Generales:<br />

1. Introducir al alumno en el análisis de complejidad de los algoritmos,<br />

así como en el diseño e implementación de éstos con las técnicas y<br />

métodos más usados.<br />

2. Desarrollar habilidades en el uso de las técnicas de análisis y diseño<br />

de algoritmos computacionales.<br />

3. Analizar la eficiencia de diversos algoritmos para resolver una<br />

variedad de problemas, principalmente no numéricos.<br />

4. Enseñar al alumno a diseñar y analizar nuevos algoritmos.<br />

5. Reconocer y clasificar los problemas de complejidad polinómica y no<br />

polinómica.<br />

Metas del curso:<br />

Al finalizar este curso, el estudiante debe estar capacitado para:<br />

• analizar, diseñar e implementar algoritmos iterativos y recursivos<br />

correctos.<br />

• medir la eficiencia de algoritmos iterativos y recursivos, y de tipos o<br />

clases de datos orientados por objetos.<br />

• utilizar la técnica de diseño de algoritmos más adecuada para una<br />

aplicación en particular.<br />

• definir la aplicabilidad de las diferentes técnicas de búsqueda y<br />

ordenamiento, del procesamiento de cadenas de caracteres, de los<br />

métodos geométricos, del uso de los algoritmos de grafos, de los<br />

algoritmos paralelos y de los alg oritmos de complejidad no<br />

polinómica.<br />

• diferenciar entre los algoritmos de complejidad polinómica y no<br />

polinómica.<br />

Página 9


1.2 Algunas Notas sobre la Historia de los Algoritmos<br />

El término proviene del matemático árabe Al'Khwarizmi, que escribió un<br />

tratado sobre los números. Este texto se perdió, pero su versión latina,<br />

Algoritmi de Numero Indorum, sí se conoce.<br />

El trabajo de Al'Khwarizmi permitió preservar y difundir el conocimiento de<br />

los griegos (con la notable excepción del trabajo de Diofanto) e indios,<br />

pilares de nuestra civilización. Rescató de los griegos la rigurosidad y de los<br />

indios la simplicidad (en vez de una larga demostración, usar un diagrama<br />

junto a la palabra Mira). Sus libros son intuitivos y prácticos y su principal<br />

contribución fue simplificar las matemáticas a un nivel entendible por no<br />

expertos. En particular muestran las ventajas de usar el sistema decimal<br />

indio, un atrevimiento para su época, dado lo tradicional de la cultura árabe.<br />

La exposición clara de cómo calcular de una manera sistemática a través de<br />

algoritmos diseñados para ser usados con algún tipo de dispositivo mecánico<br />

similar a un ábaco, más que con lápiz y papel, muestra la intuición y el poder<br />

de abstracción de Al'Khwarizmi. Hasta se preocupaba de reducir el número<br />

de operaciones necesarias en cada cálculo. Por esta razón, aunque no haya<br />

sido él el inventor del primer algoritmo, merece que este concepto esté<br />

asociado a su nombre.<br />

Los babilonios que habitaron en la antigua Mesopotania, empleaban unas<br />

pequeñas bolas hechas de semillas o pequeñas piedras, a manera de<br />

"cuentas" y que eran agrupadas en carriles de caña. Más aún, en 1.800 A.C.<br />

un matemático babilónico inventó los algoritmos que le permitieron resolver<br />

problemas de cálculo numérico.<br />

En 1850 A.C., un algoritmo de multiplicación similar al de expansión binaria<br />

es usado por los egipcios.<br />

La teoría de las ciencias de la computación trata cualquier objeto<br />

computacional para el cual se puede crear un buen modelo. La investigación<br />

en modelos formales de computación se inició en los 30's y 40's por Turing,<br />

Post, Kleene, Church y otros. En los 50's y 60's los lenguajes de<br />

programación, compiladores y sistemas operativos estaban en desarrollo,<br />

por lo tanto, se convirtieron tanto en el sujeto como la base para la mayoría<br />

del trabajo teórico.<br />

El poder de las computadoras en este período estaba limitado por<br />

procesadores lentos y por pequeñas cantidades de memoria. Así, se<br />

desarrollaron teorías (modelos, algoritmos y análisis) para hacer un uso<br />

eficiente de ellas. Esto dio origen al desarrollo del área que ahora se conoce<br />

como "Algoritmos y Estructuras de Datos". Al mismo tiempo se hicieron<br />

estudios para comprender la complejidad inherente en la solución de<br />

algunos problemas. Esto dió origen a lo que se conoce como la jerarquía de<br />

problemas computacionales y al área de "Complejidad Computacional".<br />

Página 10


1.3 Fundamentos Matemáticos<br />

Monotonicidad<br />

Una función f es monótona si es creciente o decreciente. Es creciente si<br />

rige la implicación siguiente:<br />

∀ n0, n1 : (n1 = n2) f(n1) = f(n2 )<br />

Es decreciente si rige la implicación siguiente:<br />

∀ n0, n1 : (n1 = n2) f(n1) = f(n2 )<br />

Una función f(n) es monotónicamente creciente si m ≤ n implica que<br />

f(m) ≤ f(n). Similarmente, es monotónicamente decreciente si m ≤ n<br />

implica que f(m) ≥ f(n). Una función f(n) es estrictamente creciente si<br />

m < n implica que f(m) < f(n) y estrictamente decreciente si m < n<br />

implica que f(m) > f(n).<br />

Conjuntos<br />

Un conjunto es una colección de miembros o elementos distinguibles. Los<br />

miembros se toman típicamente de alguna población más grande conocida<br />

como tipo base. Cada miembro del conjunto es un elemento primitivo<br />

del tipo base o es un conjunto. No hay concepto de duplicación en un<br />

conjunto.<br />

Un orden lineal tiene las siguientes propiedades:<br />

• Para cualesquier elemento a y b en el conjunto S, exactamente uno de<br />

a < b, a = b, o a > b es verdadero.<br />

• Para todos los elementos a, b y c en el conjunto S, si a < b, y b < c,<br />

entonces a < c. Esto es conocido como la propiedad de<br />

transitividad.<br />

Permutación<br />

Una permutación de una secuencia es simplemente los elementos de la<br />

secuencia arreglados en cualquier orden. Si la secuencia contiene n<br />

elementos, existe n! permutaciones.<br />

Página 11


Funciones Piso y Techo<br />

Piso (floor) y techo (ceiling) de un número real x. Son respectivamente<br />

el mayor entero menor o igual que x, y el menor entero mayor o igual a x.<br />

Para cualquier número real x, denotamos al mayor entero, menor que o igual<br />

a x como floor(x), y al menor entero, mayor que o igual a x como<br />

ceiling(x); (floor=piso, ceiling=techo). Para toda x real,<br />

x - 1 < floor(x) ≤ x ≤ ceiling(x) < x + 1<br />

Para cualquier entero n,<br />

ceiling(x/2) + floor(x/2) = n,<br />

y para cualquier entero n y enteros a ≠ 0 y b ≠ 0,<br />

ceiling(ceiling(n/a)/b) = ceiling(n/ab) y<br />

floor(floor(n/a)/b) = floor(n/ab).<br />

Las funciones floor y ceiling son monótonas crecientes.<br />

Operador módulo<br />

Esta función regresa el residuo de una división entera. Generalmente se<br />

escribe n mod m, y el resultado es el entero r, tal que n= qm+r para q un<br />

entero y/0 menor o igual que r y r < m.<br />

Por ejemplo: 5 mod 3 = 2 y 25 mod 3 = 1<br />

Polinomios<br />

Dado un entero positivo d, un polinomio en n de grado d es una función<br />

p(n) de la forma:<br />

p(n) = ∑ d*ai n i<br />

i=0<br />

donde las constantes a0, a1,..., ad son los coeficientes del polinimio y ad ≠<br />

0. Un polinomio es asintóticamente positivo si y sólo si ad > 0. Para un<br />

polinomio asintóticamente positivo p(n) de grado d, tenemos que p(n) =<br />

O(n d). Para cualquier constante real a ≥ 0, la función n a es monótona<br />

creciente, y para cualquier constante real a ≤ 0, la función n a es monótona<br />

decreciente. Decimos que una función f(n) es polinomialmente acotada<br />

si f(n) = n O(1) , que es equivalente en decir que f(n) = O(n k ) para alguna<br />

constante k.<br />

Página 12


Exponenciales<br />

Para toda a ≠ 0, m y n, tenemos las siguientes identidades:<br />

aº = 1<br />

a¹ = a<br />

a (-1) = 1/a<br />

(a m ) n = a<br />

m n<br />

(a m ) n = (a n ) m<br />

a ma n = a m + n<br />

Para todo n y a ≥ ,1<br />

lim n ∞ n b /a n = 0<br />

de lo que concluimos que n b =o(a n ).<br />

Entonces, cualquier función exponencial positiva crece más rápido que<br />

cualquier polinomio.<br />

i=0<br />

exp(x) = ∑ x i /(i!)<br />

∞<br />

Logaritmos<br />

Un logaritmo de base b para el valor y, es la potencia al cual debe elevarse<br />

b para obtener y. Logb y = x b x =y.<br />

Usos para los logaritmos:<br />

• ¿Cuál es el número mínimo de bits necesarios para codificar una<br />

colección de objetos? La respuesta es techo(log2 n).<br />

• Por ejemplo, si se necesitan 1000 códigos que almacenar, se<br />

requerirán al menos techo(log2 1000) = 10 bits para tener 1000<br />

códigos distintos. De hecho con 10 bits hay 1024 códigos distintos<br />

disponibles.<br />

• Análisis de algoritmos que trabajan rompiendo un problema en<br />

subproblemas más pequeños. Por ejemplo, la búsqueda binaria de un<br />

valor dado en una lista ordenada por valor. ¿Cuántas veces puede<br />

una lista de tamaño n ser divida a la mitad hasta que un sólo<br />

elemento quede en la lista final? La respuesta es log2 n.<br />

Página 13


Los logaritmos tienen las siguientes propiedades:<br />

• log nm = log n + log m<br />

• log n/m = log n – log m<br />

• log n r = r log n<br />

• loga n = logb n/ logb a (para a y b enteros)<br />

Esta última propiedad dice que el logaritmo de n en distintas bases, está<br />

relacionado por una constante (que es logaritmo de una base en la otra). Así<br />

que análisis de complejidad que se hacen en una base de logaritmos, pueden<br />

fácilmente traducirse en otra, simplemente con un factor de<br />

proporcionalidad.<br />

Factoriales<br />

La notación n! se define para los enteros n ≥ 0 como:<br />

n! =<br />

1 si n=0<br />

n·(n-1)! si n>0<br />

Entonces, n!=1·2·3·4 ··· n.<br />

Una cota superior débil de la función factorial es n! ≤ n n , pues cada uno de<br />

los n términos en el producto factorial es a lo más n. La aproximación de<br />

Stirling, proporciona una cota superior ajustada, y también una cota<br />

inferior:<br />

n! = sqrt{2pn} (n/e) n (1 + Q(1/n))<br />

donde e es la base del logaritmo natural. Usando la aproximación de<br />

Stirling, podemos demostrar que:<br />

n! = o(n n )<br />

n! = (2 n)<br />

lg(n!) = Q(n lg n)<br />

Números de Fibonacci<br />

Los números de Fibonacci se definen por la siguiente recurrencia:<br />

F0 = 0<br />

F1 = 1<br />

Fi = Fi-1 + Fi-2 para i ≥ 2<br />

Página 14


Entonces cada número de Fibonacci es la suma de los números previos,<br />

produciendo la sucesión:<br />

Recursión<br />

0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, ...<br />

Un algoritmo es recursivo si se llama a sí mismo para hacer parte del<br />

trabajo. Para que este enfoque sea exitoso, la llamada debe ser en un<br />

problema menor que al originalmente intentado.<br />

En general, un algoritmo recursivo tiene dos partes:<br />

• El caso base, que maneja una entrada simple que puede ser resuelta<br />

sin una llamada recursiva<br />

• La parte recursiva, que contiene una o más llamadas recursivas al<br />

algoritmo, donde los parámetros están en un sentido más cercano al<br />

caso base, que la llamada original.<br />

Ejemplos de aplicación: cálculo del factorial, torres de Hanoi y función de<br />

Ackermann.<br />

Sumatorias y recurrencias<br />

Las sumatorias se usan mucho para el análisis de la complejidad de<br />

programas, en especial de ciclos, ya que realizan conteos de operaciones<br />

cada vez que se entra al ciclo.<br />

Cuando se conoce una ecuación que calcula el resultado de una sumatoria,<br />

se dice que esta ecuación representa una solución en forma cerrada .<br />

Ejemplos de soluciones en forma cerrada:<br />

• Σ i = n(n+1)/2<br />

• Σ I 2 = (2n 3 +3n 2 + n)/6<br />

• Σ 1 log n n = n log n<br />

• Σ ∞ a i = 1/(1-a)<br />

El tiempo de ejecución para un algoritmo recursivo está más fácilmente<br />

expresado por una expresión recursiva.<br />

Una relación de recurrencia define una función mediante una expresión<br />

que incluye una o más instancias (más pequeñas) de si misma. Por ejemplo:<br />

Página 15


n! = (n-1)! n , 1! = 0! = 1<br />

La secuencia de Fibonacci:<br />

• Fib(n)=Fib(n-1)+Fib(n-2); Fib(1)=Fib(2)=1<br />

• 1,1,2,3,5,8,13,...<br />

Técnicas de Demostración Matemática<br />

Prueba por contradicción<br />

Es la forma más fácil de refutar un teorema o enunciado, es mediante un<br />

contra-ejemplo. Desafortunadamente, no importa el número de ejemplos<br />

que soporte un teorema, esto no es suficiente para probar que es correcto.<br />

Para probar un teorema por contradicción, primero suponemos que el<br />

teorema es falso. Luego encontramos una contradicción lógica que surja de<br />

esta suposición. Si la lógica usada para encontrar la contradicción es<br />

correcta, entonces la única forma de resolver la contradicción es suponer que<br />

la suposición hecha fue incorrecta, esto es, concluir que el teorema debe ser<br />

verdad.<br />

Prueba por inducción matemática<br />

La inducción matemática es como recursión y es aplicable a una variedad de<br />

problemas.<br />

La inducción proporciona una forma útil de pensar en diseño de algoritmos,<br />

ya que lo estimula a pensar en resolver un problema, construyendo a partir<br />

de pequeños subproblemas.<br />

Sea T un teorema a probar, y expresemos T en términos de un parámetro<br />

entero positivo n. La inducción matemática expresa que T es verdad para<br />

cualquier valor de n, si las siguientes condiciones se cumplen:<br />

• Caso base. T es verdad para n = 1<br />

• Paso inductivo. Si T es verdad para n-1, => T es verdad para n.<br />

Ejemplo de demostración del teorema:<br />

La suma de los primeros n números enteros es n(n+1)/2.<br />

Página 16


Estimación<br />

Consiste en hacer estimaciones rápidas para resolver un problema. Puede<br />

formalizarse en tres pasos:<br />

• Determine los parámetros principales que afectan el problema.<br />

• Derive una ecuación que relacione los parámetros al problema.<br />

• Seleccione valores para los parámetros, y aplique la ecuación para<br />

obtener una solución estimada.<br />

Ejemplo: ¿Cuántos libreros se necesitan para guardar libros que contienen<br />

en total un millón de páginas?<br />

Se estiman que 5 00 páginas de un libro requieren cerca de una pulgada en la<br />

repisa del librero, con lo cual da 2000 pulgadas de repisa. Lo cual da 167<br />

pies de repisa (aprox. 200 pies). Si una repisa es alrededor de 4 pies de<br />

ancho, entonces se necesitan 50 repisas. Si un librero contiene 5 repisas,<br />

entonces se necesitan 10 libreros.<br />

Página 17


Capítulo 2<br />

Algoritmos y Problemas<br />

2.1 Definición de Algoritmo<br />

El concepto intuitivo de algoritmo, lo tenemos prácticamente todos: Un<br />

algoritmo es una serie finita de pasos para resolver un problema.<br />

Hay que hacer énfasis en dos aspectos para que un algoritmo exista:<br />

1. El número de pasos debe ser finito. De esta manera el algoritmo debe<br />

terminar en un tiempo finito con la solución del problema,<br />

2. El algoritmo debe ser capaz de determinar la solución del problema.<br />

De este modo, podemos definir algoritmo como un "conjunto de reglas<br />

operacionales inherentes a un cómputo". Se trata de un método sistemático,<br />

susceptible de ser realizado mecánicamente, para resolver un problema<br />

dado.<br />

Sería un error creer que los algoritmos son exclusivos de la informática.<br />

También son algoritmos los que aprendemos en la escuela para multiplicar y<br />

dividir números de varias cifras. De hecho, el algoritmo más famoso de la<br />

historia se remonta a la antigüedad: se trata del algoritmo de Euclides para<br />

calcular el máximo común divisor.<br />

Siempre que se desee resolver un problema hay que plantearse qué<br />

algoritmo utilizar. La respuesta a esta cuestión puede depender de<br />

numerosos factores, a saber, el tamaño del problema, el modo en que está<br />

planteado y el tipo y la potencia del equipo disponible para su resolución.<br />

Características de un algoritmo<br />

1. Entrada: definir lo que necesita el algoritmo<br />

2. Salida: definir lo que produce.<br />

3. No ambiguo: explícito, siempre sabe qué comando ejecutar.<br />

4. Finito: El algoritmo termina en un número finito de pasos.<br />

5. Correcto: Hace lo que se supone que debe hacer. La solución es<br />

correcta<br />

6. Efectividad: Cada instrucción se completa en tiempo finito. Cada<br />

instrucción debe ser lo suficientemente básica como para que en<br />

principio pueda ser ejecutada por cualquier persona usando papel y<br />

lápiz.<br />

Página 18


7. General: Debe ser lo suficientemente general como para contemplar<br />

todos los casos de entrada.<br />

Así podemos, decir que un Algoritmo es un conjunto finito de<br />

instrucciones precisas para resolver un problema.<br />

Un algoritmo es un método o proceso seguido para resolver un problema.<br />

Si el problema es visto como una función, entonces el algoritmo toma una<br />

entrada y la transforma en la salida.<br />

Un problema es una función o asociación de entradas con salidas. Un<br />

problema puede tener muchos algoritmos.<br />

Por tanto, un algoritmo es un procedimiento para resolver un problema<br />

cuyos pasos son concretos y no ambiguos. El algoritmo debe ser correcto, de<br />

longitud finita y debe terminar para todas las entradas. Un programa es<br />

una instanciación de un algoritmo en un lenguaje de programación.<br />

2.2 Formulación y Resolución de Problemas<br />

Los algoritmos son los procedimientos que se construyen para la resolución<br />

de cualquier problema. De este modo, cuando se refiere a la construcción de<br />

un programa, nos estamos refiriéndo a la construcción de un algoritmo. El<br />

algoritmo no es un concepto proveniente del campo de la computación, sino<br />

que es un término matemático.<br />

Los algoritmos los encontramos, o mejor, los ejecutamos a lo largo de<br />

nuestras actividades diarias; por ejemplo, cuando hacemos una llamada<br />

telefónica, tenemos en cuenta un conjunto de instrucciones mínimas y el<br />

orden en el cual debemos ejecutarlas para conseguir comunicarnos con<br />

alguien en particular; o cuando consultamos un diccionario, cuando se<br />

prepara un menú, etc.<br />

Podemos conceptuar que un algoritmo es un procedimiento que contiene un<br />

conjunto finito de pasos que se deben ejecutar en un orden específico y<br />

lógico, para solucionar los problemas.<br />

Un algoritmo puede ser caracterizado por una función lo cual asocia una<br />

salida: s= f (E) a cada entrada E.<br />

Se dice entonces que un algoritmo calcula una función f. Entonces la entrada<br />

es una variable independiente básica en relación a la que se producen las<br />

salidas del algoritmo, y también los análisis de tiempo y espacio.<br />

Página 19


Cuando se tiene un problema para el cual debemos especificar un algoritmo<br />

solución, tendremos en cuenta varios puntos:<br />

o Si no se conoce un método para solucionar el problema en cuestión,<br />

debemos hacer un análisis del mismo para llegar a una solución,<br />

después de evaluar alternativas, exigencias y excepciones.<br />

o Si se conoce un "buen" método de solución al problema, se debe<br />

especificar exacta y completamente el método o procedimiento de<br />

solución en un lenguaje que se pueda interpretar fácilmente.<br />

o Los problemas pueden agruparse en conjunto de problemas que son<br />

semejantes.<br />

o En algún sentido, en general, un método para solucionar todos los<br />

problemas de un conjunto dado, se considera superior a un método<br />

para solucionar solamente un problema del conjunto.<br />

o Hay criterios para determinar que tan "buena" es una solución,<br />

distintos de ver si trabaja o no, o hasta qué punto es general la<br />

aplicabilidad del método. Estos criterios involucran aspectos tales<br />

como: eficiencia, elegancia, velocidad, etc.<br />

Al definir con exactitud un método de solución para un problema, este debe<br />

estar en capacidad de encontrarla si existe; en caso de que no exista, el<br />

método debe reconocer esta situación y suspender cualquier acción.<br />

Formular y resolver problemas constituye una actividad esencial de la vida.<br />

Los modelos educativos se han dedicado a plantear problemas y enseñar<br />

como solucionarlos. Sin embargo, no se enseña nunca técnicas de cómo<br />

resolver problemas en general.<br />

La formulación y resolución de problemas ha sido preocupación de los<br />

psicólogos cuando hablan de la inteligencia del hombre. Ellos han concebido<br />

esta capacidad como el concurso de ciertas destrezas mentales relacionadas<br />

con lo que ya se ha aprendido y que incluyen:<br />

o Capacidad de analizar los problemas.<br />

o La rapidez y precisión con que acude al pensamiento.<br />

o Una organización de ideas para garantizar que se posee la<br />

información esencial que asegura el aprendizaje.<br />

o Una retención clara del incremento de conocimiento.<br />

No se trata de almacenar problemas y sus correspondientes soluciones, sino<br />

de desarrollar una capacidad para hacer frente con éxito a situaciones<br />

nuevas o desconocidas en la vida del hombre. Ante situaciones nuevas, el<br />

que no sabe buscar soluciones se sentirá confuso y angustiado y entonces no<br />

busca una estrategia y dará una primera solución para poner punto final a su<br />

agonía.<br />

Página 20


El que sabe buscar soluciones, selecciona la estrategia que le parece más<br />

cercana a la requerida y hace una hábil adaptación que se ajusta a la nueva<br />

demanda.<br />

Al movernos dentro de la informática no siempre encontramos los<br />

problemas a punto de resolverlos, es necesario empezar por hacer su<br />

formulación. Se exige hacerlo en términos concisos y claros partiendo de las<br />

bases o supuestos que se dispone para especificar sus requerimientos.<br />

Sólo a partir de una buena formulación será posible diseñar una estrategia<br />

de solución. Es necesario aprender a desligar estos dos procesos. No se<br />

deben hacer formulaciones pensando paralelamente en posibles soluciones.<br />

Es necesario darle consistencia e independencia para determinar con<br />

precisión a qué se quiere dar solución y luego canalizar una gama de<br />

alternativas posibles.<br />

Si ya se ha realizado la formulación del problema podemos cuestionarla con<br />

el fin de entender bien la naturaleza del problema.<br />

En nuestra área, el análisis de un problema tiene dos etapas claramente<br />

definidas y relacionadas:<br />

o Formulación o planteamiento del problema.<br />

o Resolución del problema.<br />

A su vez, la formulación la podemos descomponer en tres etapas:<br />

o Definición del problema.<br />

o Supuestos: aserciones y limitaciones suministradas.<br />

o Resultados esperados.<br />

La fase de planteamiento del problema lo que pretende un algoritmo es<br />

sintetizar de alguna forma una tarea, cálculo o mecanismo antes de ser<br />

transcrito al computador. Los pasos que hay que seguir son los siguientes:<br />

o Análisis previo del problema.<br />

o Primera visión del método de resolución.<br />

o Descomposición en módulos.<br />

o Programación estructurada.<br />

o Búsqueda de soluciones parciales.<br />

o Ensamblaje de soluciones finales.<br />

La fase de resolución del problema se puede descomponer en tres<br />

etapas:<br />

o Análisis de alternativas y selección de la solución.<br />

o Especificación detallada del procedimiento solución.<br />

Página 21


o Adopción o utilización de una herramienta para su<br />

implementación, si es necesaria.<br />

Hay que notar que el computador es un medio y no es el fin en la solución de<br />

problemas.<br />

En otras palabras, no es el computador el que soluciona los problemas,<br />

somos nosotros quienes lo hacemos y de alguna manera le contamos como<br />

es la cosa para que él con su velocidad y exactitud trabaje con grandes<br />

volúmenes de datos.<br />

En el campo de las ciencias de la computación la solución de problemas se<br />

describe mediante el diseño de procedimientos llamados algoritmos, los<br />

cuales posteriormente se implementan como programas. Los<br />

programas son procedimientos que solucionan problemas y que se<br />

expresan en un lenguaje conocido por el computador.<br />

Se pueden dar problemas que por su tamaño es necesario subdividirlos en<br />

problemas más simples para solucionarlos, utilizando la filosofía de "Dividir<br />

para conquistar". Se parte del principio de que es más fácil solucionar varios<br />

problemas simples como partes de un todo que seguir una implantación de<br />

"Todo o Nada". Desarrollar la capacidad de formular y resolver problemas<br />

nos prepara para enfrentar situaciones desconocidas.<br />

2.3 Razones para Estudiar los Algoritmos<br />

Con el logro de computadores cada vez más rápidos se podría caer en la<br />

tentación de preguntarse si vale la pena preocuparse por aumentar la<br />

eficiencia de los algoritmos. ¿No sería más sencillo aguardar a la siguiente<br />

generación de computadores?. Vamos a demostrar que esto no es así.<br />

Supongamos que se dispone, para resolver un problema dado, de un<br />

algoritmo que necesita un tiempo exponencial y que, en un cierto<br />

computador, una implementación del mismo emplea 10 -4 x 2 n segundos.<br />

Este programa podrá resolver un ejemplar de tamaño n=10 en una décima<br />

de segundo. Necesitará casi 10 minutos para resolver un ejemplar de tamaño<br />

20. Un día entero no bastará para resolver uno de tamaño 30. En un año de<br />

cálculo ininterrumpido, a duras penas se resolverá uno de tamaño 38.<br />

Imaginemos que necesitamos resolver ejemplares más grandes y<br />

compramos para ello un computador cien veces más rápido. El mismo<br />

algoritmo conseguirá resolver ahora un ejemplar de tamaño n en sólo 10 -6 2 n<br />

segundos. ¡Qué decepción al constatar que, en un año, apenas se consigue<br />

resolver un ejemplar de tamaño 45!<br />

En general, si en un tiempo dado se podía resolver un ejemplar de tamaño n,<br />

con el nuevo computador se resolverá uno de tamaño n+7 en ese mismo<br />

tiempo.<br />

Página 22


Imaginemos, en cambio, que investigamos en algorítmica y encontramos un<br />

algoritmo capaz de resolver el mismo problema en un tiempo cúbico. La<br />

implementación de este algoritmo en el computador inicial podría necesitar,<br />

por ejemplo, 10 -2 n 3 segundos. Ahora se podría resolver en un día un<br />

ejemplar de un tamaño superior a 200. Un año permitiría alcanzar casi el<br />

tamaño 1500.<br />

Por tanto, el nuevo algoritmo no sólo permite una aceleración más<br />

espectacular que la compra de un equipo más rápido, sino que hace dicha<br />

compra más rentable.<br />

Dado estos argumentos, podemos resumir las siguientes razones para<br />

justificar el estudiar los algoritmos:<br />

1. Evitar reinventar la rueda.<br />

Para algunos problemas de programación ya existen buenos algoritmos<br />

para solucionarlos. Para esos algoritmos ya fueron analizadas sus<br />

propiedades. Por ejemplo, confianza en su correctitud y eficiencia.<br />

2. Para ayudar cuando desarrollen sus propios algoritmos.<br />

No siempre existe un algoritmo desarrollado para resolver un problema.<br />

No existe regla general de creación de algoritmos. Muchos de los<br />

principios de proyecto de algoritmos ilustrados por algunos de los<br />

algoritmos que estudiaremos son importantes en todos los problemas de<br />

programación. El conocimiento de los algoritmos bien definidos provee<br />

una fuente de ideas que pueden ser aplicadas a nuevos algoritmos.<br />

3. Ayudar a entender herramientas que usan algoritmos particulares. Por<br />

ejemplo, herramientas de compresión de datos:<br />

• Pack usa Códigos Huffman.<br />

• Compress usa LZW.<br />

• Gzip usa Lempel-Ziv.<br />

4. Útil conocer técnicas empleadas para resolver problemas de<br />

determinados tipos.<br />

2.4 Formas de Representación de Algoritmos<br />

Existen diversas formas de representación de algoritmos, pero no hay un<br />

consenso con relación a cuál de ellas es mejor.<br />

Algunas formas de representación de algoritmos tratan los problemas a un<br />

nivel lógico, abstrayéndose de detalles de implementación, muchas veces<br />

relacionados con un lenguaje de programación específico. Por otro lado,<br />

existen formas de representación de algoritmos que poseen una mayor<br />

Página 23


iqueza de detalles y muchas veces acaban por oscurecer la idea principal, el<br />

algoritmo, dificultando su entendimiento.<br />

Dentro de las formas de representación de algoritmos más conocidas,<br />

sobresalen:<br />

• La descripción narrativa<br />

• El Flujograma convencional<br />

• El diagrama Chapin<br />

• El pseudocódigo, o también conocido como lenguaje estructurado.<br />

2.5 La Máquina de Turing<br />

Alan Turing, en 1936 desarrolló su Máquina de Turing (la cual se cubre<br />

en los cursos denominados Teoría de la Computación o Teoría de<br />

Automátas), estableciendo que cualquier algoritmo puede ser representado<br />

por ella.<br />

Turing mostró también que existen problemas matemáticos bien definidos<br />

para los cuales no hay un algoritmo. Hay muchos ejemplos de problemas<br />

para los cuales no existe un algoritmo. Un problema de este tipo es el<br />

llamado problema de paro (halting problem):<br />

Dado un programa de computadora con sus entradas, ¿parará este<br />

alguna vez?<br />

Turing probó que no hay un algoritmo que pueda resolver correctamente<br />

todas las instancias de este problema.<br />

Alguien podría pensar en encontrar algunos métodos para detectar patrones<br />

que permitan examinar el programa para cualquier entrada. Sin embargo,<br />

siempre habrá sutilezas que escapen al análisis correspondiente.<br />

Alguna persona más suspicaz, podría proponer simplemente correr el<br />

programa y reportar éxito si se alcanza una declaración de fin.<br />

Desgraciadamente, este esquema no garantiza por sí mismo un paro y en<br />

consecuencia el problema no puede ser resuelto con los pasos propuestos.<br />

Como consecuencia de acuerdo con la definición anterior, ese último<br />

procedimiento no es un algoritmo, pues no se llega a una solución.<br />

Muchas veces aunque exista un algoritmo que pueda solucionar<br />

correctamente cualquier instancia de un problema dado, no siempre dicho<br />

algoritmo es satisfactorio porque puede requerir de tiempos exageradamente<br />

excesivos para llegar a la solución.<br />

Página 24


Capítulo 3<br />

Eficiencia de Algoritmos<br />

3.1 Introducción<br />

Un objetivo natural en el desarrollo de un programa computacional es<br />

mantener tan bajo como sea posible el consumo de los diversos recursos,<br />

aprovechándolos de la mejor manera que se encuentre. Se desea un buen<br />

uso, eficiente, de los recursos disponibles, sin desperdiciarlos.<br />

Para que un programa sea práctico, en términos de requerimientos de<br />

almacenamiento y tiempo de ejecución, debe organizar sus datos en una<br />

forma que apoye el procesamiento eficiente.<br />

Siempre que se trata de resolver un problema, puede interesar considerar<br />

distintos algoritmos, con el fin de utilizar el más eficiente. Pero, ¿cómo<br />

determinar cuál es "el mejor"?. La estrategia empírica consiste en<br />

programar los algoritmos y ejecutarlos en un computador sobre algunos<br />

ejemplares de prueba. La estrategia teórica consiste en determinar<br />

matemáticamente la cantidad de recursos (tiempo, espacio, etc.) que<br />

necesitará el algoritmo en función del tamaño del ejemplar considerado.<br />

El tamaño de un ejemplar x corresponde formalmente al número de dígitos<br />

binarios necesarios para representarlo en el computador. Pero a nivel<br />

algorítmico consideraremos el tamaño como el número de elementos lógicos<br />

contenidos en el ejemplar.<br />

3.2 Concepto de Eficiencia<br />

Un algoritmo es eficiente cuando logra llegar a sus objetivos planteados<br />

utilizando la menor cantidad de recursos posibles, es decir, minimizando el<br />

uso memoria, de pasos y de esfuerzo humano.<br />

Un algoritmo es eficaz cuando alcanza el objetivo primordial, el análisis de<br />

resolución del problema se lo realiza prioritariamente.<br />

Puede darse el caso de que exista un algoritmo eficaz pero no eficiente, en lo<br />

posible debemos de manejar estos dos conceptos conjuntamente.<br />

La eficiencia de un programa tiene dos ingredientes fundamentales:<br />

espacio y tiempo.<br />

• La eficiencia en espacio es una medida de la cantidad de memoria<br />

requerida por un programa.<br />

Página 25


• La eficiencia en tiempo se mide en términos de la cantidad de<br />

tiempo de ejecución del programa.<br />

Ambas dependen del tipo de computador y compilador, por lo que no se<br />

estudiará aquí la eficiencia de los programas, sino la eficiencia de los<br />

algoritmos. Asimismo, este análisis dependerá de si trabajamos con<br />

máquinas de un solo procesador o de varios de ellos. Centraremos nuestra<br />

atención en los algoritmos para máquinas de un solo procesador que<br />

ejecutan una instrucción y luego otra.<br />

3.3 Medidas de Eficiencia<br />

Inventar algoritmos es relativamente fácil. En la práctica, sin embargo, no<br />

se pretende sólo diseñar algoritmos, si no más bien que buenos algoritmos.<br />

Así, el objetivo es inventar algoritmos y probar que ellos mismos son<br />

buenos.<br />

La calidad de un algoritmo puede ser avalada utilizando varios criterios.<br />

Uno de los criterios más importantes es el tiempo utilizado en la ejecución<br />

del algoritmos. Existen varios aspectos a considerar en cada criterio de<br />

tiempo. Uno de ellos está relacionado con el tiempo de ejecución<br />

requerido por los diferentes algoritmos, para encontrar la solución final de<br />

un problema o cálculo particular.<br />

Normalmente, un problema se puede resolver por métodos distintos, con<br />

diferentes grados de eficiencia. Por ejemplo: búsqueda de un número en<br />

una guía telefónica.<br />

Cuando se usa un computador es importante limitar el consumo de<br />

recursos.<br />

Recurso Tiempo:<br />

• Aplicaciones informáticas que trabajan “en tiempo real” requieren<br />

que los cálculos se realicen en el menor tiempo posible.<br />

• Aplicaciones que manejan un gran volumen de información si<br />

no se tratan adecuadamente pueden necesitar tiempos<br />

impracticables.<br />

Recurso Memoria:<br />

• Las máquinas tienen una memoria limitada .<br />

Página 26


3.4 Análisis A Priori y Prueba A Posteriori<br />

El análisis de la eficiencia de los algoritmos (memoria y tiempo de ejecución)<br />

consta de dos fases: Análisis A Priori y Prueba A Posteriori.<br />

El Análisis A Priori (o teórico) entrega una función que limita el tiempo<br />

de cálculo de un algoritmo. Consiste en obtener una expresión que indique el<br />

comportamiento del algoritmo en función de los parámetros que influyan.<br />

Esto es interesante porque:<br />

• La predicción del costo del algoritmo puede evitar una<br />

implementación posiblemente laboriosa.<br />

• Es aplicable en la etapa de diseño de los algoritmos, constituyendo<br />

uno de los factores fundamentales a tener en cuenta.<br />

En la Prueba A Posteriori (experimental o empírica) se recogen<br />

estadísticas de tiempo y espacio consumidas por el algoritmo mientras se<br />

ejecuta. La estrategia empírica consiste en programar los algoritmos y<br />

ejecutarlos en un computador sobre algunos ejemplares de prueba, haciendo<br />

medidas para:<br />

• una máquina concreta,<br />

• un lenguaje concreto,<br />

• un compilador concreto y<br />

• datos concretos<br />

La estrategia teórica tiene como ventajas que no depende del computador ni<br />

del lenguaje de programación, ni siquiera de la habilidad del programador.<br />

Permite evitar el esfuerzo inútil de programar algoritmos ineficientes y de<br />

despediciar tiempo de máquina para ejecutarlos. También permite conocer<br />

la eficiencia de un algoritmo cualquiera que sea el tamaño del ejemplar al<br />

que se aplique.<br />

3.5 Concepto de Instancia<br />

Un problema computacional consiste en una caracterización de un<br />

conjunto de datos de entrada, junto con la especificación de la salida<br />

deseada en base a cada entrada.<br />

Un problema computacional tiene una o más instancias, que son valores<br />

particulares para los datos de entrada, sobre los cuales se puede ejecutar el<br />

algoritmo para resolver el problema.<br />

Ejemplo: el problema computacional de multiplicar dos números enteros<br />

tiene por ejemplo, las siguientes instancias: multiplicar 345 por 4653,<br />

multiplicar 2637 por 10000, multiplicar -32341 por 12, etc.<br />

Página 27


Un problema computacional abarca a otro problema computacional si las<br />

instancias del segundo pueden ser resueltas como instancias del primero en<br />

forma directa.<br />

Ejemplo: Problema del Vendedor Viajero.<br />

C2<br />

10<br />

3.6 Tamaño de los Datos<br />

6<br />

C1<br />

5<br />

C3<br />

9<br />

9<br />

3<br />

Variable o expresión en función de la cual intentaremos medir la<br />

complejidad del algoritmo.<br />

Es claro que para cada algoritmo la cantidad de recurso (tiempo, memoria)<br />

utilizados depende fuertemente de los datos de entrada. En general, la<br />

cantidad de recursos crece a medida que crece el tamaño de la entrada.<br />

El análisis de esta cantidad de recursos no es viable de ser realizado<br />

instancia por instancia.<br />

Se definen entonces las funciones de cantidad de recursos en base al<br />

tamaño (o talla) de la entrada . Suele depender del número de datos del<br />

problema. Este tamaño puede ser la cantidad de dígitos para un número, la<br />

cantidad de elementos para un arreglo, la cantidad de caracteres de una<br />

cadena, en problemas de ordenación es el número de elementos a ordenar,<br />

en matrices puede ser el número de filas, columnas o elementos totales, en<br />

algoritmos recursivos es el número de recursiones o llamadas propias que<br />

hace la función.<br />

En ocasiones es útil definir el tamaño de la entrada en base a dos o más<br />

magnitudes. Por ejemplo, para un grafo es frecuente utilizar la cantidad de<br />

nodos y de arcos.<br />

En cualquier caso, se debe elegir la misma variable para comparar<br />

algoritmos distintos aplicados a los mismos datos.<br />

C4<br />

Una instancia de solución<br />

sería:<br />

<br />

con una longitud de 27.<br />

Página 28


3.7 Cálculo de Costos de Algoritmos<br />

Queremos saber la eficiencia de los algoritmos, no del computador. Por ello,<br />

en lugar de medir el tiempo de ejecución en microsegundos o algo por el<br />

estilo, nos preocuparemos del número de veces que se ejecuta una operación<br />

primitiva (de tiempo fijo).<br />

Para estimar la eficiencia de este algoritmo, podemos preguntarnos, "si el<br />

argumento es una frase de N números, ¿cuántas multiplicaciones<br />

realizaremos?" La respuesta es que hacemos una multiplicación por cada<br />

número en el argumento, por lo que hacemos N multiplicaciones. La<br />

cantidad de tiempo que se necesitaría para el doble de números sería el<br />

doble.<br />

3.7.1 Cálculo de eficiencia en análisis iterativo<br />

Cuando se analiza la eficiencia, en tiempo de ejecución, de un algoritmo son<br />

posibles distintas aproximaciones: desde el cálculo detallado (que puede<br />

complicarse en muchos algoritmos si se realiza un análisis para distintos<br />

contenidos de los datos de entrada, casos más favorables, caso peor,<br />

hipótesis de distribución probabilística para los contenidos de los datos de<br />

entrada, etc.) hasta el análisis asintótico simplificado aplicando reglas<br />

prácticas.<br />

En estos apuntes se seguirá el criterio de análisis asintótico simplificado, si<br />

bien nunca se ha de dejar de aplicar el sentido común. Como en todos los<br />

modelos simplificados se ha mantener la alerta para no establecer hipótesis<br />

simplificadoras que no se correspondan con la realidad.<br />

En lo que sigue se realizará una exposición basada en el criterio de exponer<br />

en un apartado inicial una serie de reglas y planteamientos básicos<br />

aplicables al análisis de eficiencia en algoritmos iterativos, en un segundo<br />

apartado se presenta una lista de ejemplos que permitan comprender<br />

algunas de las aproximaciones y técnicas expuestas.<br />

3.7.2 Cálculo de eficiencia en análisis recursivo<br />

Retomando aquí el socorrido ejemplo del factorial, tratemos de analizar el<br />

coste de dicho algoritmo, en su versión iterativa , se tiene:<br />

PROCEDURE Factorial(n : CARDINAL) : CARDINAL<br />

BEGIN<br />

VAR Resultado,i : CARDINAL ;<br />

Resultado :=1 ;<br />

FOR i :=1 TO n DO<br />

Resultado :=Resultado*i ;<br />

Página 29


END ;<br />

RETURN Resultado<br />

END Factorial ;<br />

Aplicando las técnicas de análisis de coste en algoritmos iterativos de forma<br />

rápida y mentalmente (es como se han de llegar a analizar algoritmos tan<br />

simples como éste), se tiene: hay una inicialización antes de bucle, de coste<br />

constante. El bucle se repite un número de veces n y en su interior se realiza<br />

una operación de coste constante. Por tanto el algoritmo es de coste lineal o<br />

expresado con algo más de detalle y rigor, si la función de coste del<br />

algoritmo se expresa por T(n), se tiene que T(n) ? T.<br />

Una versión recursiva del mismo algoritmo, es:<br />

PROCEDURE Factorial(n: CARDINAL): CARDINAL;<br />

BEGIN<br />

IF n=0 THEN<br />

RETURN 1<br />

ELSE<br />

RETURN ( n* Factorial(n-1) )<br />

END<br />

END Factorial;<br />

Al aplicar el análisis de coste aprendido para análisis de algoritmos iterativos<br />

se tiene: hay una instrucción de alternativa, en una de las alternativas<br />

simplemente se devuelve un valor (operación de coste constante). En la otra<br />

alternativa se realiza una operación de coste constante (multiplicación) con<br />

dos operandos. El primer operando se obtiene por una operación de coste<br />

constante (acceso a la variable n), el coste de la operación que permite<br />

obtener el segundo operando es el coste que estamos calculando!, es decir es<br />

el coste de la propia función factorial (solo que para parámetro n-1).<br />

Por tanto, para conocer el orden de la función de coste de este algoritmo<br />

¿debemos conocer previamente el orden de la función de coste de este<br />

algoritmo?, entramos en una recurrencia.<br />

Y efectivamente, el asunto está en saber resolver recurrencias. Si T(n) es la<br />

función de coste de este algoritmo se puede decir que T(n) es igual a una<br />

operación de coste constante c cuando n vale 0 y a una operación de coste<br />

T(n-1) más una operación de coste constante (el acceso a n y la<br />

multiplicación) cuando n es mayor que 0, es decir:<br />

T(n) =<br />

c si n = 0<br />

T(n-1) + c si n > 0<br />

Se trata entonces de encontrar soluciones a recurrencias como ésta.<br />

Entendiendo por solución una función simple f(n) tal que se pueda asegurar<br />

que T(n) es del orden de f(n).<br />

Página 30


En este ejemplo puede comprobarse que T(n) es de orden lineal, es decir del<br />

orden de la función f(n)=n, ya que cualquier función lineal T(n)= an+b<br />

siendo a y b constantes, es solución de la recurrencia:<br />

T(0)= b , es decir una constante<br />

T(n)= a.n+b= an-a+ b+a= a(n-1)+b+a= T(n-1)+a, es decir el<br />

coste de T(n) es igual al de T(n-1) más una constante.<br />

3.8 Principio de Invarianza<br />

Dado un algoritmo y dos implementaciones I1 y I2 (máquinas distintas o<br />

códigos distintos) que tardan T1(n) y T2(n) respectivamente, el principio de<br />

invarianza afirma que existe una constante real c>0 y un número natural n0<br />

tales que para todo n>=n0 se verifica que T1(n)0 y una implementación I del algoritmo que tarda<br />

menos que c·T (n), para todo n tamaño de entrada.<br />

El comportamiento de un algoritmo puede variar notablemente para<br />

diferentes secuencias de entrada. Suelen estudiarse tres casos para un<br />

mismo algoritmo: caso mejor, caso peor, caso medio.<br />

3.9 Análisis Peor Caso, Mejor Caso y Caso Promedio<br />

Puede analizarse un algoritmo particular o una clase de ellos. Una clase de<br />

algoritmo para un problema son aquellos algoritmos que se pueden<br />

clasificar por el tipo de operación fundamental que realizan.<br />

Ejemplo:<br />

Problema: Ordenamiento<br />

Clase: Ordenamiento por comparación<br />

Para algunos algoritmos, diferentes entradas (inputs) para un tamaño dado<br />

pueden requerir diferentes cantidades de tiempo.<br />

Por ejemplo, consideremos el problema de encontrar la posición particular<br />

de un valor K, dentro de un arreglo de n elementos. Suponiendo que sólo<br />

ocurre una vez. Comentar sobre el mejor, peor y caso promedio.<br />

¿Cuál es la ventaja de analizar cada caso? Si examinamos el peor de los<br />

casos, sabemos que al menos el algoritmo se desempeñará de esa forma.<br />

Página 31


En cambio, cuando un algoritmo se ejecuta muchas veces en muchos tipos<br />

de entrada, estamos interesados en el comportamiento promedio o típico.<br />

Desafortunadamente, esto supone que sabemos cómo están distribuidos los<br />

datos.<br />

Si conocemos la distribución de los datos, podemos sacar provecho de esto,<br />

para un mejor análisis y diseño del algoritmo. Por otra parte, sino<br />

conocemos la distribución, entonces lo mejor es considerar el peor de los<br />

casos.<br />

Tipos de análisis:<br />

o Peor caso: indica el mayor tiempo obtenido, teniendo en<br />

consideración todas las entradas pos ibles.<br />

o Mejor caso: indica el menor tiempo obtenido, teniendo en<br />

consideración todas las entradas posibles.<br />

o Media: indica el tiempo medio obtenido, considerando todas las<br />

entradas posibles.<br />

Como no se puede analizar el comportamiento sobre todas las entradas posibles,<br />

va a existir para cada prob lema particular un análisis en él:<br />

- peor caso<br />

- mejor caso<br />

- caso promedio (o medio)<br />

El caso promedio es la medida más realista de la performance, pero es más<br />

dif ícil de calcular pues establece que todas las entradas son igualmente<br />

probables, lo cual puede ser cierto o no. Trabajaremos específicamente con<br />

el peor caso.<br />

Página 32


Ejemplo<br />

Sea A una lista de n elementos A1, A2 , A3, ... , An. Ordenar significa permutar<br />

estos elementos de tal forma que los mismos queden de acuerdo con un<br />

orden preestablecido.<br />

Ascendente A1=An<br />

Caso peor: Que el vector esté ordenado en sentido inverso.<br />

Caso mejor: Que el vector esté ordenado.<br />

Caso medio: Cuando el vector esté desordenado aleatoriamente.<br />

Página 33


Capítulo 4<br />

Análisis de Algoritmos<br />

4.1 Introducción<br />

Como hemos visto, existen muchos enfoques para resolver un problema.<br />

¿Cómo escogemos entre ellos? Generalmente hay dos metas en el diseño de<br />

programas de cómputo:<br />

• El diseño de un algoritmo que sea fácil de entender, codificar y<br />

depurar (Ingeniería de Software ).<br />

• El diseño de un algoritmo que haga uso eficiente de los recursos de la<br />

computadora (Análisis y Diseño de algoritmos).<br />

El análisis de algoritmos nos permite medir la dificultad inherente de<br />

un problema y evaluar la eficiencia de un algoritmo.<br />

4.2 Tiempos de Ejecución<br />

Una medida que suele ser útil conocer es el tiempo de ejecución de un<br />

algoritmo en función de N, lo que denominaremos T(N). Esta función se<br />

puede medir físicamente (ejecutando el programa, reloj en mano), o<br />

calcularse sobre el código contando instrucciones a ejecutar y multiplicando<br />

por el tiempo requerido por cada instrucción.<br />

Así, un trozo sencillo de programa como:<br />

requiere:<br />

S1; FOR i:= 1 TO N DO S2 END;<br />

T(N):= t1 + t2*N<br />

siendo t1 el tiempo que lleve ejecutar la serie "S1" de sentencias, y t2 el que<br />

lleve la serie "S2".<br />

Prácticamente todos los programas reales incluyen alguna sentencia<br />

condicional, haciendo que las sentencias efectivamente ejecutadas<br />

dependan de los datos concretos que se le presenten. Esto hace que más que<br />

un valor T(N) debamos hablar de un rango de valores:<br />

Tmin(N)


los extremos son habitualmente conocidos como "caso peor" y "caso mejor".<br />

Entre ambos se hallará algún "caso promedio" o más frecuente. Cualquier<br />

fórmula T(N) incluye referencias al parámetro N y a una serie de constantes<br />

"Ti" que dependen de factores externos al algoritmo como pueden ser la<br />

calidad del código generado por el compilador y la velocidad de ejecución de<br />

instrucciones del computador que lo ejecuta.<br />

Dado que es fácil cambiar de compilador y que la potencia de los<br />

computadores crece a un ritmo vertiginoso (en la actualidad, se duplica<br />

anualmente), intentaremos analizar los algoritmos con algún nivel de<br />

independencia de estos factores; es decir, buscaremos estimaciones<br />

generales ampliamente válidas.<br />

No se puede medir el tiempo en segundos porque no existe un computador<br />

estándar de referencia, en su lugar medimos el número de operaciones<br />

básicas o elementales.<br />

Las operaciones básicas son las que realiza el computador en tiempo acotado<br />

por una constante, por ejemplo:<br />

• Operaciones aritméticas básicas<br />

• Asignaciones de tipos predefinidos<br />

• Saltos (llamadas a funciones, procedimientos y retorno)<br />

• Comparaciones lógicas<br />

• Acceso a estructuras indexadas básicas (vectores y matrices)<br />

Es posible realizar el estudio de la complejidad de un algoritmo sólo en base<br />

a un conjunto reducido de sentenc ias, por ejemplo, las que más influyen en<br />

el tiempo de ejecución.<br />

Para medir el tiempo de ejecución de un algoritmo existen varios métodos.<br />

Veamos algunos de ellos:<br />

a) Benchmarking<br />

La técnica de benchmark considera una colección de entradas típicas<br />

representativas de una carga de trabajo para un programa.<br />

b) Profiling<br />

Consiste en asociar a cada instrucción de un programa un número que<br />

representa la fracción del tiempo total tomada para ejecutar esa<br />

instrucción particular. Una de las técnicas más conocidas (e informal) es<br />

la Regla 90-10, que afirma que el 90% del tiempo de ejecución se<br />

invierte en el 10% del código.<br />

Página 35


c) Análisis<br />

Consiste en agrupar las entradas de acuerdo a su tamaño, y estimar el<br />

tiempo de ejecución del programa en entradas de ese tamaño, T(n). Esta<br />

es la técnica que se estudiará en el curso.<br />

De este modo, el tiempo de ejecución puede ser definido como una<br />

función de la entrada. Denotaremos T(n) como el tiempo de ejecución de un<br />

algoritmo para una entrada de tamaño n.<br />

4.3 Concepto de Complejidad<br />

La complejidad (o costo) de un algoritmo es una medida de la cantidad de<br />

recursos (tiempo, memoria) que el algoritmo necesita. La complejidad de un<br />

algoritmo se expresa en función del tamaño (o talla) del problema.<br />

La función de complejidad tiene como variable independiente el tamaño del<br />

problema y sirve para medir la complejidad (espacial o temporal). Mide el<br />

tiempo/espacio relativo en función del tamaño del problema.<br />

El comportamiento de la función determina la eficiencia. No es única para<br />

un algoritmo: depende de los datos. Para un mismo tamaño del problema,<br />

las distintas presentaciones iniciales de los datos dan lugar a distintas<br />

funciones de complejidad. Es el caso de una ordenación si los datos están<br />

todos inicialmente desordenados, parcialmente ordenados o en orden<br />

inverso.<br />

Ejemplo: f(n)= 3n 2 +2n+1 , en donde n es el tamaño del problema y expresa<br />

el tiempo en unidades de tiempo.<br />

¿Una computadora más rápida o un algoritmo más rápido?<br />

Si compramos una computadora diez veces más rápida, ¿en qué tiempo<br />

podremos ahora ejecutar un algoritmo?<br />

La respuesta depende del tamaño de la entrada de datos, así como de la<br />

razón de crecimiento del algoritmo.<br />

Si la razón de crecimiento es lineal (es decir, T(n)=cn) entonces por<br />

ejemplo, 100.000 números serán procesados en la nueva máquina en el<br />

mismo tiempo que 10.000 números en la antigua computadora.<br />

¿De qué tamaño (valor de n) es el problema que podemos resolver con una<br />

computadora X veces más rápida (en un intervalo de tiempo fijo)?<br />

Por ejemplo, supongamos que una computadora resuelve un problema de<br />

tamaño n en una hora. Ahora supongamos que tenemos una computadora<br />

Página 36


10 veces más rápida, ¿de qué tamaño es el problema que podemos<br />

resolver?<br />

f(n) n n´ cambio n´/n<br />

10n 1000 10000 n´=10n 10<br />

20n 500 5000 n´=10n 10<br />

5n log n 250 1842 √(10)n 7.37<br />

2)<br />

O(n!) orden factorial<br />

Es más, se puede identificar una jerarquía de órdenes de complejidad que<br />

coincide con el orden de la tabla anterior; jerarquía en el sentido de que<br />

cada orden de complejidad superior tiene a los inferiores como<br />

Página 37


subconjuntos. Si un algoritmo A se puede demostrar de un cierto orden O1 ,<br />

es cierto que también pertenece a todos los órdenes superiores (la relación<br />

de orden cota superior es transitiva); pero en la práctica lo útil es encontrar<br />

la "menor cota superior", es decir el menor orden de complejidad que lo<br />

cubra.<br />

Antes de realizar un programa conviene elegir un buen algoritmo, donde<br />

por bueno entendemos que utilice pocos recursos, siendo usualmente los<br />

más importantes el tiempo que lleve ejecutarse y la cantidad de espacio en<br />

memoria que requiera. Es engañoso pensar que todos los algoritmos son<br />

"más o menos iguales" y confiar en nuestra habilidad como programadores<br />

para convertir un mal algoritmo en un producto eficaz. Es asimismo<br />

engañoso confiar en la creciente potencia de las máquinas y el<br />

abaratamiento de las mismas como remedio de todos los problemas que<br />

puedan aparecer.<br />

Un ejemplo de algunas de las funciones más comunes en análisis de<br />

algoritmos son:<br />

La mejor técnica para diferenciar la eficiencia de los algoritmos es el estudio<br />

de los órdenes de complejidad. El orden de complejidad se expresa<br />

generalmente en términos de la cantidad de datos procesados por el<br />

programa, denominada n, que puede ser el tamaño dado o estimado.<br />

En el análisis de algoritmos se considera usualmente el caso peor, si bien a<br />

veces conviene analizar igualmente el caso mejor y hacer alguna estimación<br />

sobre un caso promedio. Para independizarse de factores coyunturales, tales<br />

como el lenguaje de programación, la habilidad del codificador, la máquina<br />

de soporte, etc. se suele trabajar con un cálculo asintótico que indica<br />

cómo se comporta el algoritmo para datos muy grandes y salvo algún<br />

coeficiente multiplicativo. Para problemas pequeños es cierto que casi todos<br />

los algoritmos son "más o menos iguales", primando otros aspectos como<br />

esfuerzo de codificación, legibilidad, etc. Los órdenes de complejidad sólo<br />

son importantes para grandes problemas.<br />

Página 38


4.5 Notación Asintótica<br />

El interés principal del análisis de algoritmos radica en saber cómo crece el<br />

tiempo de ejecución, cuando el tamaño de la entrada crece. Esto es la<br />

eficiencia asintótica del algoritmo. Se denomina “asintótica” porque analiza<br />

el comportamiento de las funciones en el límite, es decir, su tasa de<br />

crecimiento.<br />

La notación asintótica se describe por medio de una función cuyo dominio<br />

es los números naturales (Ν ) estimado a partir de tiempo de ejecución o de<br />

espacio de memoria de algoritmos en base a la longitud de la entrada. Se<br />

consideran las funciones asintóticamente no negativas.<br />

La notación asintótica captura el comportamiento de la función para<br />

valores grandes de N.<br />

Las notaciones no son dependientes de los tres casos anteriormente vistos,<br />

es por eso que una notación que determine el peor caso puede estar<br />

presente en una o en todas las situaciones.<br />

4.5.1 La O Mayúscula<br />

La notación O se utiliza para comparar funciones. Resulta particularmente<br />

útil cuando se quiere analizar la complejidad de un algoritmo, en otras<br />

palabras, la cantidad de tiempo que le toma a un computador ejecutar un<br />

programa.<br />

Definición: Sean f y g funciones con dominio en R ≤ 0 o N es imagen en<br />

R. Si existen constantes C y k tales que:<br />

∀ x > k, | f (x) | ≤ C | g (x) |<br />

es decir, que para x > k, f es menor o igual a un multiplo de g, decimos que:<br />

f (x) = O ( g (x) )<br />

La definición formal es:<br />

f (x) = O ( g (x) ) ∃ k , N | ∀ x > N, | f (x) | ≤ k| g (x) |<br />

¿Qué quiere decir todo esto? Básicamente, que una función es siempre<br />

menor que otra función (por ejemplo, el tiempo en ejecutar tu programa es<br />

menor que x 2 ) si no tenemos en cuenta los factores constantes (eso es lo que<br />

significa la k) y si no tenemos en cuenta los valores pequeños (eso es lo que<br />

significa la N).<br />

Página 39


¿Por qué no tenemos en cuenta los valores pequeños de N? Porqué para<br />

entradas pequeñas, el tiempo que tarda el programa no es significativo y<br />

casi siempre el algoritmo será sufic ientemente rápido para lo que queremos.<br />

Así 3N 3 + 5N 2 – 9 = O (N 3 ) no significa que existe una función O(N 3 ) que<br />

es igual a 3N 3 + 5N 2 – 9.<br />

Debe leerse como:<br />

que significa:<br />

“3N 3 +5N 2 –9 es O-Grande de N 3 ”<br />

“3N 3 +5N 2 –9 está asintóticamente dominada por N 3 ”<br />

La notación puede resultar un poco confusa. Veamos algunos ejemplos.<br />

Ejemplo 1: Muestre que 3N 3 + 5N 2 – 9 = O (N 3 ).<br />

De nuestra experiencia anterior pareciera tener sentido que C = 5.<br />

Encontremos k tal que:<br />

3N 3 + 5N 2 – 9 ≤ 5N 3 for N > k :<br />

1. Agrupamos: 5N 2 = 2N 3 + 9<br />

2. Cuál k asegura que 5N 2 = N 3 para N > k?<br />

3. k = 5<br />

4. Así que para N > 5, 5N 2 = N 3 = 2N 3 + 9.<br />

5. C = 5, k = 5 (no es único!)<br />

Ejemplo 2: Muestre que N 4 „ O (3N 3 + 5N 2 – 9) .<br />

Hay que mostrar que no existen C y k tales que para N > k, C* (3N 3 + 5N 2 –<br />

9) ‡ N 4 es siempre cierto (usamos límites, recordando el curso de Cálculo).<br />

lim x 4 / C(3x 3 + 5 x 2 – 9) = lim x / C(3 + 5/x – 9/x 3 )<br />

x ∞ x ∞<br />

= lim x / C(3 + 0 – 0) = (1/3C) . lim x = ∞<br />

x ∞ x ∞<br />

Página 40


Así que sin importar cual sea el C que se elija, N 4 siempre alcanzará y<br />

rebasará C* (3N 3 + 5N 2 – 9).<br />

Podemos considerarnos afortunados porque sabemos calcular límites!<br />

Límites serán útiles para probar relaciones (como veremos en el próximo<br />

teorema).<br />

Lema: Si el límite cuando x ∞ del cociente |f (x)/g (x)| existe (es finito)<br />

entonces f (x) = O ( g (x) ).<br />

Ejemplo 3: 3N 3 + 5N 2 – 9 = O (N 3 ). Calculamos:<br />

lim x 3 / (3x 3 + 5 x 2 – 9) = lim 1 /(3 + 5/x – 9/x 3 ) = 1 /3<br />

x ∞ x ∞<br />

Listo!<br />

4.5.2 La o Minúscula<br />

La cota superior asintótica dada por la notación O puede o no ser ajustada<br />

asintóticamente. La cota 2n² = O(n²) es ajustada asintóticamente, pero la<br />

cota 2n = O(n²) no lo es. Utilizaremos la notación o para denotar una cota<br />

superior que no es ajustada asintóticamente.<br />

Definimos formalmente o(g(n)) ("o pequeña") como el conjunto:<br />

o(g(n)) = {f(n): para cualquier constante positiva c > 0, existe una constante<br />

n0 > 0 tal que: 0 ≤ f(n) ≤ cg(n) para toda n ≥ n0 }.<br />

Por ejemplo, 2n = o(n²), pero 2n² no pertenece a o(n²).<br />

Las notaciones de O y o son similares. La diferencia principal es, que en f(n)<br />

= O(g(n)), la cota 0 ≤ f(n) ≤ cg(n) se cumple para alguna constante c > 0,<br />

pero en f(n) = o(g(n)), la cota 0 ≤ f(n) ≤ cg(n) se cumple para todas las<br />

constantes c> 0. Intuitivamente en la notación o, la función f(n) se vuelve<br />

insignificante con respecto a g(n) a medida que n se acerca a infinito, es<br />

decir:<br />

lim (f(n)/g(n)) = 0.<br />

x ∞<br />

Página 41


4.5.3 Diferencia entre O y o<br />

Para o la desigualdad se mantiene para todas las constantes positivas,<br />

mientras que para O la desigualdad se mantiene sólo para algunas<br />

constantes positivas.<br />

4.5.4 Las Notaciones W y Q<br />

Ω Es el reverso de O.<br />

f (x ) = Ω(g (x )) g (x ) = O (f (x ))<br />

Ω Grande dice que asintóticamente f (x ) domina a g (x ).<br />

Θ Grande dice que ambas funciones se dominan mutuamente, en otras<br />

palabras, son asintóticamente equivalentes.<br />

f = Θ(g): “f es de orden g ”<br />

f (x ) = Θ(g (x ))<br />

<br />

f (x ) = O (g (x )) f (x ) = Ω(g (x ))<br />

4.5.5 Propiedades y Cotas más Usuales<br />

Propiedades de las notaciones asintóticas<br />

Relaciones de orden<br />

La notación asintótica nos permite definir relaciones de orden entre el<br />

conjunto de las funciones sobre los enteros positivos:<br />

• f ∈ O (g (x )) « f ≤ g (se dice que f es asintóticamente menor o igual que g)<br />

• f ∈ o (g (x )) « f < g<br />

• f ∈ Θ (g (x )) « f = g<br />

• f ∈ Ω (g (x )) « f ≥ g<br />

• f ∈ ω (g (x )) « f > g<br />

Página 42


Relaciones entre cotas<br />

1. f(n) ∈ O(f (n))<br />

2. f(n) ∈ O(f (n)) ∧ g(n) ∈ O(f (n )) ⇒ f(n) ∈ O(h (n ))<br />

3. f(n) ∈ O(g (n)) ⇒ g(n) ∈ Ω(h(n ))<br />

4. lim f(n)/g(n) = 0 ⇒ O(f (n)) ⊂ O(g (n))<br />

n ∞<br />

5. O(f (n)) = O(g (n)) ⇔ f(n) ∈ O(g (n)) ∧ g(n) ∈ O(f (n ))<br />

6. O(f (n)) ⊂ O(g (n)) ⇔ f(n) ∈ O(g (n)) ∧ g(n) ∉ O(f (n ))<br />

7. Ω(f(n )) ⊂ Ω(g (n)) ⇔ f(n) ∈ Ω(g (n)) ∧ g(n) ∉ Ω(f (n ))<br />

Relaciones de inclusión<br />

Son muy útiles a la hora de comparar funciones de coste en el caso<br />

asintótico.<br />

∀ x, y, a, ε ∈ R >0<br />

1. loga n ∈ O(logbn)<br />

2. O(loga x n) ⊂ O(loga x+δ n)<br />

3. O(loga x n) ⊂ O(n)<br />

4. O(n x ) ⊂ O(n x+δ )<br />

5. O(n x loga y n) ⊂ O(n x+δ )<br />

6. O(n x ) ⊂ O(2 n )<br />

Página 43


Propiedades de clausura<br />

El conjunto de funciones del orden de f(n) es cerrado respecto de la suma y<br />

la multiplicación de funciones:<br />

1. f1(n) ∈ O(g1(n)) ∧ f2(n) ∈ O(g2(n)) ⇒ f1(n) + f2(n) ∈ O(g1(n) + g2(n))<br />

2. f1(n) ∈ O(g1(n)) ∧ f2(n) ∈ O(g2(n)) ⇒ f1(n) * f2(n) ∈ O(g1(n) * g2(n))<br />

3. f1(n) ∈ O(g1(n)) ∧ f2(n) ∈ O(g2(n)) ⇒ f1(n)+f2(n) ∈ O(max(g1(n), g2(n))<br />

como consecuencia es que los polinomios de grado k en n son exactamente<br />

del orden de nk<br />

akn k + … + a1n + a0 ∈ Θ(n k)<br />

Cotas Asintóticas Más Usuales<br />

1. c ∈ Θ(1) ∀ c ∈ R ≥ 0<br />

n<br />

2. ∑ i = (n/2)(n+1) ∈ Θ(n 2 )<br />

i=1<br />

n<br />

3. ∑ i 2 = (n/3)(n+1) (n+1/2) ∈ Θ(n 3 )<br />

i=1<br />

n<br />

4. ∑ i k ∈ Θ(n k+1 ) ∀ k ∈ N<br />

i=1<br />

n<br />

5. ∑ log i ∈ Θ(n log n)<br />

i=1<br />

n<br />

6. ∑ (n- i) k ∈ Θ(n k+1 ) ) ∀ k ∈ N<br />

i=1<br />

Página 44


4.6 Ecuaciones de Recurrencias<br />

4.6.1 Introducción<br />

Como ya hemos indicado, el análisis del tiempo de ejecución de un<br />

programa recursivo vendrá en función del tiempo requerido por la(s)<br />

llamada(s) recursiva(s) que aparezcan en él. De la misma manera que la<br />

verificación de programas recursivos requiere de razonar por inducción,<br />

para tratar apropiadamente estas llamadas, el cálculo de su eficiencia<br />

requiere un concepto análogo: el de ecuación de recurrencia.<br />

Demostraciones por inducción y ecuaciones de recurrencia son por tanto los<br />

conceptos básicos para tratar programas recursivos.<br />

Supongamos que se está analizando el coste de un algoritmo recursivo,<br />

intentando calcular una función que indique el uso de recursos, por ejemplo<br />

el tiempo, en términos del tamaño de los datos; denominémosla T(n) para<br />

datos de tamaño n. Nos encontramos con que este coste se define a su vez en<br />

función del coste de las llamadas recursivas, es decir, de T(m) para otros<br />

tamaños m (usualmente menores que n). Esta manera de expresar el coste T<br />

en función de sí misma es lo que denominaremos una ecuación recurrente y<br />

su resolución, es decir, la obtención para T de una fórmula cerrada<br />

(independiente de T) puede ser dificultosa.<br />

4.6.2 Resolución de Recurrencias<br />

Las ecuaciones de recurrencia son utilizadas para determinar cotas<br />

asintóticas en algoritmos que presentan recursividad.<br />

Veremos dos técnicas básicas y una auxiliar que se aplican a diferentes<br />

clases de recurrencias:<br />

• Método del teorema maestro<br />

• Método de la ecuación característica<br />

• Cambio de variable<br />

No analizaremos su demostración formal, sólo consideraremos su<br />

aplicación para las recurrencias generadas a partir del análisis de<br />

algoritmos.<br />

4.6.3 Método del Teorema Maestro<br />

Se aplica en casos como:<br />

T(n) =<br />

5 si n=0<br />

9T(n/3) + n si n ≠ 0<br />

Página 45


Teorema: Sean a = 1, b > 1 constantes, f(n) una función y T(n) una<br />

recurrencia definida sobre los enteros no negativos de la forma T(n) =<br />

aT(n/b) + f(n), donde n/b puede interpretarse como ⎡n/b⎤ o ⎣n/b⎦.<br />

Entonces valen:<br />

1. Si f(n) ∈ O(m log b a - ε ) para algún ε > 0 entonces T(n) ∈ Θ(n log b a ).<br />

2. Si f(n) ∈ Θ(n log b a ) entonces T(n) ∈ Θ(n log b a lgn).<br />

3. Si f(n) ∈ Ω(n log b a + ε ) para algún ε > 0, y satisface af(n/b) ≤ cf(n) para<br />

alguna constante c < 1, entonces T(n) ∈ Θ(f(n)).<br />

Ejemplos:<br />

1. Si T(n)=9T(n/3)+n entonces a=9, b=3, se aplica el caso 1 con ε=1 y<br />

T(n) ∈ Θ(n 2 ).<br />

2. Si T(n)=T(2n/3)+1 entonces a=1, b=3/2, se aplica el caso 2 y T(n) =<br />

Θ(lgn).<br />

3. Si T(n)=3T(n/4)+nlgn entonces a=3, b=4, f(n) ∈ Ω(n log 4 3 + 0,2 ) y<br />

3(n/4) lg(n/4) ≤ 3/4n lgn, por lo que se aplica el caso 3 y T(n) ∈ Θ(n<br />

lgn)<br />

4.6.4 Método de la Ecuación Característica<br />

Se aplica a ciertas recurrencias lineales con coeficientes constantes como:<br />

5 si n=0<br />

T(n) = 10 si n=1<br />

5T(n-1) + 8T(n-2)+2n si n > 0<br />

En general, para recurrencias de la forma:<br />

T(n) = a1T(n-1) + a2T(n-2) + … + akT(n-k) + b n p(n)<br />

donde ai, 1 ≤ i ≤ k, b son constantes y p(n) es un polinomio en n de grado s.<br />

Ejemplos:<br />

En general, para:<br />

1. En T(n)=2t(n-1)+3 n , a1 =2, b=3, p(n)=1, s=0.<br />

2. En T(n)=t(n-1)+ t(n-2)+n, a1=1, a2 =1, b=1, p(n)=n, s=1.<br />

T(n) = a1T(n-1) + a2T(n-2) + … + akT(n-k) + b n p(n)<br />

Página 46


♦ Paso 1: Encontrar las raíces no nulas de la ecuación característica:<br />

(x k - a1x k-1 - a2x k-2 - … - ak)(x-b) s+1 = 0<br />

Raíces: ri, 1 ≤ i ≤ l ≤ k, cada una con multiplicidad mi.<br />

♦ Paso 2: Las soluciones son de la forma de combinaciones lineales de<br />

estas raíces de acuerdo a su multiplicidad.<br />

T(n) = -----<br />

♦ Paso 3: Se encuentran valores para las constantes cij, tal que:<br />

1 ≤ i ≤ l, 0 ≤ j ≤ mi - 1<br />

y di, 0 ≤ i ≤ s – 1 según la recurrencia original y las condiciones<br />

iniciales (valores de la recurrencia para n=0,1, …).<br />

Ejemplo: Resolver la ecuación de recurrencia siguiente:<br />

T(n) =<br />

donde b=1 y p(n)=1 de grado 0.<br />

0 si n=0<br />

2T(n-1) + 1 si n > 0<br />

La ecuación característica (x-2)(x-1) 0 +1 = 0, con raíces 2 y 1 de<br />

multiplicidad 1.<br />

La solución general es entonces de la forma: T(n)=c112 n + c211 n .<br />

A partir de las condiciones iniciales se encuentra el siguiente sistema de<br />

ecuaciones que sirve para hallar c11 y c21:<br />

c11 + c21 = 0 de n = 0<br />

2c11 + c21 = 1 de n = 1<br />

de donde c11 = 1 y c21 = -1.<br />

La solución es entonces: T(n) = 2 n – 1.<br />

Página 47


4.6.5 Cambio de Variable<br />

Dada la siguiente ecuación de recurrencia:<br />

T(n) =<br />

a si n=1<br />

2T(n/2) + nlog2 n si n = 2 k<br />

No se puede aplicar el teorema maestro ni la ecuación característica.<br />

Se define una nueva recurrencia S(i) = T(2 i), con el objetivo de llevarla a una<br />

forma en la que se pueda resolver siguiendo algún método anterior.<br />

Entonces el caso general queda:<br />

S(i) = T(2 i ) = 2T(2 i /2) + 2 i i = 2T(2 i-1 ) + i2 i = 2S(i-1) + i2 i<br />

Con b=2 y p(i) = i de grado 1.<br />

La ecuación característica de esta recurrencia es:<br />

con raíz 2 de grado 3.<br />

(x - 2)(x - 2) i+1 = 0,<br />

La solución es entonces S(i) = c112 i + c12 i2 i + c13i 2 2 i , con lo que volviendo a la<br />

variable original queda:<br />

T(n) - 2T(n/2) = nlog2 n = (c12 - c13)n + 2c13n(log2n).<br />

Se pueden obtener los valores de las constantes sustituyendo esta solución<br />

en la recurrencia original:<br />

T(n) = c11n + c12(log2n)n + c13(log2n) 2n.<br />

de donde c12 = c13 y 2c12 = 1.<br />

Por tanto T(n) ∈ Θ(nlog 2 n | n es potencia de 2).<br />

Si se puede probar que T(n) es eventualmente no decreciente, por la regla<br />

de las funciones de crecimiento suave se puede extender el resultado<br />

a todos los n (dado que nlog 2 n es de crecimiento suave).<br />

En este caso T(n) ∈ Θ(nlog 2 n).<br />

Página 48


4.7 Ejemplos y Ejercicios<br />

Ejemplos de cálculo del tiempo de ejecución de un programa<br />

Veamos el análisis de un simple enunciado de asignación a una variable<br />

entera:<br />

a = b;<br />

Como el enunciado de asignación toma tiempo constante, está en Θ(1).<br />

Consideremos un simple ciclo “for”:<br />

sum=0;<br />

for (i=1; i


Comparemos el análisis asintótico de los siguientes fragmentos de código:<br />

sum1=0;<br />

for(i=1; i


Capítulo 5<br />

Estrategias de Diseño de<br />

Algoritmos<br />

5.1 Introducción<br />

A través de los años, los científicos de la computación han identificado<br />

diversas técnicas generales que a menudo producen algoritmos eficientes<br />

para la resolución de muchas clases de problemas. Este capítulo presenta<br />

algunas de las técnicas más importantes como son: recursión, dividir para<br />

conquistar, técnicas ávidas, el método de retroceso y programación<br />

dinámica.<br />

Se debe, sin embargo, destacar que hay algunos problemas, como los NP<br />

completos, para los cuales ni éstas ni otras técnicas conocidas producirán<br />

soluciones eficientes. Cuando se encuentra algún problema de este tipo,<br />

suele ser útil determinar si las entradas al problema tienen características<br />

especiales que se puedan explotar en la búsqueda de una solución, o si puede<br />

usarse alguna solución aproximada sencilla, en vez de la solución exacta,<br />

difícil de calcular.<br />

5.2 Recursión<br />

La recursividad es una técnica fundamental en el diseño de algoritmos<br />

eficientes, que está basada en la solución de versiones más pequeñas del<br />

problema, para obtener la solución general del mismo. Una instancia del<br />

problema se soluciona según la solución de una o más instancias diferentes<br />

y más pequeñas que ella.<br />

Es una herramienta poderosa que sirve para resolver cierto tipo de<br />

problemas reduciendo la complejidad y ocultando los detalles del problema.<br />

Esta herramienta consiste en que una función o procedimiento se llama a sí<br />

mismo.<br />

Una gran cantidad de algoritmos pueden ser descritos con mayor claridad en<br />

términos de recursividad, típicamente el resultado será que sus programas<br />

serán más pequeños.<br />

La recursividad es una alternativa a la iteración o repetición, y aunque en<br />

tiempo de computadora y en ocupación en memoria es la solución recursiva<br />

menos eficiente que la solución iterativa, existen numerosas situaciones en<br />

las que la recursividad es una solución simple y natural a un problema que<br />

en caso contrario ser difícil de resolver. Por esta razón se puede decir que la<br />

Página 51


ecursividad es una herramienta potente y útil en la resolución de problemas<br />

que tengan naturaleza recursiva y, por ende, en la programación.<br />

Existen numerosas definiciones de recursividad, algunas de las más<br />

importantes o sencillas son éstas:<br />

• Un objeto es recursivo si figura en su propia definición.<br />

• Una definición recursiva es aquella en la que el objeto que se define<br />

forma parte de la definición (recuerde la regla gramatical: lo definido<br />

nunca debe formar parte de la definición)<br />

La característica importante de la recursividad es que siempre existe un<br />

medio de salir de la definición, mediante la cual se termina el proceso<br />

recursivo.<br />

Ventajas:<br />

• Puede resolver problemas complejos.<br />

• Solución más natural.<br />

Desventajas:<br />

• Se puede llegar a un ciclo infinito.<br />

• Versión no recursiva más difícil de desarrollar.<br />

• Para la gente sin experiencia es difícil de programar.<br />

Tipos de Recursividad<br />

• Directa o simple: un subprograma se llama a si mismo una o más<br />

veces directamente.<br />

Página 52


• Indirecta o mutua: un subprograma A llama a otro subprograma B<br />

y éste a su vez llama al subprograma A.<br />

a) Propiedades para un Procedimiento Recursivo<br />

• Debe de existir cierto criterio (criterio base) en donde el<br />

procedimiento no se llama a sí mismo. Debe existir al menos una<br />

solución no recursiva.<br />

• En cada llamada se debe de acercar al criterio base (reducir rango). El<br />

problema debe reducirse en tamaño, expresando el nuevo problema<br />

en términos del propio problema, pero más pequeño.<br />

Los algoritmos de divide y vencerás pueden ser procedimientos<br />

recursivos.<br />

b) Propiedades para una Función Recursiva<br />

• Debe de haber ciertos argumentos, llamados valores base para los que<br />

la función no se refiera a sí misma.<br />

• Cada vez que la función se refiera así misma el argumento de la<br />

función debe de acercarse más al valor base.<br />

Ejemplos:<br />

Factorial<br />

n! = 1 * 2 * 3 ... * (n-2) * (n-1) * n<br />

0! = 1<br />

1! = 1<br />

2! = 1 * 2<br />

3! = 1 * 2 * 3<br />

...<br />

n! = 1 * 2 * 3 ... * (n-2) * (n-1) * n<br />

⇒ n! = n* (n-1)!<br />

Página 53


si n = 0 entonces n! = 1<br />

si n > 0 entonces n! = n * (n-1)!<br />

Function factorial( n:integer):longint:<br />

begin<br />

if ( n = 0 ) then<br />

factorial:=1<br />

else<br />

factorial:= n * factorial( n-1);<br />

end;<br />

Rastreo de ejecución:<br />

Si n = 6<br />

Nivel<br />

1) Factorial(6)= 6 * factorial(5) = 720<br />

2) Factorial(5)=5 * factorial(4) = 120<br />

3) Factorial(4)=4 * factorial(3) = 24<br />

4) Factorial(3)=3 * factorial(2) = 6<br />

5) Factorial(2)=2 * factorial(1) = 2<br />

6) Factorial(1)=1 * factorial(0) = 1<br />

7) Factorial(0)=1<br />

La profundidad de un procedimiento recursivo es el número de veces que se<br />

llama a sí mismo.<br />

Serie de Fibonacci<br />

0, 1, 1, 2, 3, 5, 8, ...<br />

F0 = 0<br />

F1 = 1<br />

F2 = F1 + F0 = 1<br />

F3 = F2 + F1 = 2<br />

F4 = F3 + F2 = 3<br />

...<br />

Fn = Fn-1 + Fn-2<br />

si n= 0, 1 Fn = n<br />

si n >1 Fn = Fn-1 + Fn-2<br />

Página 54


Procedure fibo(var fib:longint; n:integer);<br />

var<br />

fibA, fibB : integer;<br />

begin<br />

if ( n = 0) or ( n = 1) then fib:=n<br />

else begin<br />

fibo(fibA,n-1);<br />

fibo(fibB,n-2);<br />

fib:=fibA + FibB;<br />

end;<br />

end;<br />

function fibo ( n:integer) : integer;<br />

begin<br />

if ( n = 0) or ( n = 1) then<br />

fibo:=n<br />

else<br />

fibo:=fibo( n-1) + fibo(n-2);<br />

end;<br />

end;<br />

No todos los ambientes de programación proveen las facilidades necesarias<br />

para la recursión y adicionalmente, a veces su uso es una fuente de<br />

ineficiencia inaceptable. Por lo cual se prefiere eliminar la recursión. Para<br />

ello, se sustituye la llamada recursiva por un lazo, donde se incluyen algunas<br />

asignaciones para recolocar los parámetros dirigidos a la función.<br />

Remover la recursión es complicado cuando existe más de una llamada<br />

recursiva, pero una vez hecha ésta, la versión del algoritmo es siempre más<br />

eficiente.<br />

5.3 Dividir para Conquistar<br />

Muchos algoritmos útiles tienen una estructura recursiva, de modo que para<br />

resolver un problema se llaman recursivamente a sí mismos una o más veces<br />

para solucionar subproblemas muy similares.<br />

Esta estructura obedece a una estrategia dividir-y-conquistar, en que se<br />

ejecuta tres pasos en cada nivel de la recursión:<br />

• Dividir. Dividen el problema en varios subproblemas similares al<br />

problema original pero de menor tamaño;<br />

• Conquistar. Resuelven recursivamente los subproblemas si los<br />

tamaños de los subproblemas son suficientemente pequeños, entonces<br />

resuelven los subproblemas de manera directa; y luego,<br />

Página 55


• Combinar. Combinan estas soluciones para crear una solución al<br />

problema original.<br />

La técnica Dividir para Conquistar (o Divide y Vencerás) consiste en<br />

descomponer el caso que hay que resolver en subcasos más pequeños,<br />

resolver independientemente los subcasos y por último combinar las<br />

soluciones de los subcasos para obtener la solución del caso original.<br />

Caso General<br />

Consideremos un problema arbitrario, y sea A un algoritmo sencillo capaz de<br />

resolver el problema. A debe ser eficiente para casos pequeños y lo<br />

denominamos subalgoritmo básico.<br />

El caso general de los algoritmos de Divide y Vencerás (DV) es como sigue:<br />

función DV(x)<br />

{<br />

si x es suficientemente pequeño o sencillo entonces<br />

devolver A(x)<br />

descomponer x en casos más pequeños x1, x2, x3 , ... , xl<br />

para i ← 1 hasta l hacer<br />

yi ← DV(xi)<br />

recombinar los yi para obtener una solución y de x<br />

devolver y<br />

}<br />

El número de subejemplares l suele ser pequeño e independiente del caso<br />

particular que haya que resolverse.<br />

Para aplicar la estrategia Divide y Vencerás es necesario que se cumplan tres<br />

condiciones:<br />

• La decisión de utilizar el subalgoritmo básico en lugar de hacer<br />

llamadas recursivas debe tomarse cuidadosamente.<br />

• Tiene que ser posible descomponer el caso en subcasos y recomponer<br />

las soluciones parciales de forma eficiente.<br />

• Los subcasos deben ser en lo posible aproximadamente del mismo<br />

tamaño.<br />

En la mayoría de los algoritmos de Divide y Vencerás el tamaño de los l<br />

subcasos es aproximadamente m/b, para alguna constante b, en donde m es<br />

el tamaño del caso (o subcaso) original (cada subproblema es<br />

aproximadamente del tamaño 1/b del problema original).<br />

Página 56


El análisis de tiempos de ejecución para estos algoritmos es el siguiente:<br />

Sea g(n) el tiempo requerido por DV en casos de tamaño n, sin contar el<br />

tiempo necesario para llamadas recursivas. El tiempo total t(n) requerido<br />

por este algoritmo de Divide y Vencerás es parecido a:<br />

t(n) = l t(n/ b) + g(n) para l = 1 y b = 2<br />

siempre que n sea suficientemente grande. Si existe un entero k = 0 tal que:<br />

g(n) ∈ Θ (n k ),<br />

se puede concluir que:<br />

T(n k ) si l < b k<br />

t(n) ∈ T(n k log n) si l = b k<br />

T (n log b l ) si l > b k<br />

Se deja propuesta la demostración de esta ecuación de recurrencia usando<br />

análisis asintótico.<br />

5.4 Programación Dinámica<br />

Frecuentemente para resolver un problema complejo se tiende a dividir este<br />

en subproblemas más pequeños, resolver estos últimos (recurriendo<br />

posiblemente a nuevas subdivisiones) y combinar las soluciones obtenidas<br />

para calcular la solución del problema inicial.<br />

Puede ocurrir que la división natural del problema conduzca a un gran<br />

número de subejemplares idénticos. Si se resuelve cada uno de ellos sin<br />

tener en cuenta las posibles repeticiones, resulta un algoritmo ineficiente;<br />

en cambio si se resuelve cada ejemplar distinto una sola vez y se conserva el<br />

resultado, el algoritmo obtenido es mucho mejor.<br />

Esta es la idea de la programación dinámica: no calcular dos veces lo mismo<br />

y utilizar normalmente una tabla de resultados que se va rellenando a<br />

medida que se resuelven los subejemplares.<br />

La programación dinámica es un método ascendente. Se resuelven primero<br />

los subejemplares más pequeños y por tanto más simples. Combinando las<br />

soluciones se obtienen las soluciones de ejemplares sucesivamente más<br />

grandes hasta llegar al ejemplar original.<br />

Página 57


Ejemplo:<br />

Consideremos el cálculo de números combinatorios. El algoritmo sería:<br />

función C(n, k)<br />

si k=0 o k=n entonces devolver 1<br />

si no devolver C(n-1, k-1) + C(n-1, k)<br />

Ocurre que muchos valores C(i, j), con i < n y j < k se calculan y recalculan<br />

varias veces.<br />

Un fenómeno similar ocurre con el algoritmo de Fibonacci.<br />

La programación dinámica se emplea a menudo para resolver problemas de<br />

optimización que satisfacen el principio de optimalidad: en una secuencia<br />

óptima de decisiones toda subsecuencia ha de ser también óptima.<br />

Ejemplo:<br />

Si el camino más corto de Santiago a Copiapó pasa por La Serena, la parte<br />

del camino de Santiago a La Serena ha de ser necesariamente el camino<br />

mínimo entre estas dos ciudades.<br />

Podemos aplicar esta técnica en:<br />

• Camino mínimo en un grafo orientado<br />

• Árboles de búsqueda óptimos<br />

5.5 Algoritmos Ávidos<br />

Los algoritmos ávidos o voraces (Greedy Algorithms) son algoritmos que<br />

toman decisiones de corto alcance, basadas en información inmediatamente<br />

disponible, sin importar consecuencias futuras.<br />

Suelen ser bastante simples y se emplean sobre todo para resolver problemas<br />

de optimización, como por ejemplo, encontrar la secuencia óptima para<br />

procesar un conjunto de tareas por un computador, hallar el camino mínimo<br />

de un grafo, etc.<br />

Habitualmente, los elementos que intervienen son:<br />

• un conjunto o lista de candidatos (tareas a procesar, vértices del<br />

grafo, etc.);<br />

• un conjunto de decisiones ya tomadas (candidatos ya escogidos);<br />

• una función que determina si un conjunto de candidatos es una<br />

solución al problema (aunque no tiene por qué ser la óptima);<br />

Página 58


• una función que determina si un conjunto es completable, es<br />

decir, si añadiendo a este conjunto nuevos candidatos es posible<br />

alcanzar una solución al problema, suponiendo que esta exista;<br />

• una función de selección que escoge el candidato aún no<br />

seleccionado que es más prometedor;<br />

• una función objetivo que da el valor/coste de una solución (tiempo<br />

total del proceso, la longitud del camino, etc.) y que es la que se<br />

pretende maximizar o minimizar;<br />

Para resolver el problema de optimización hay que encontrar un conjunto de<br />

candidatos que optimiza la función objetivo. Los algoritmos voraces<br />

proceden por pasos.<br />

Inicialmente el conjunto de candidatos es vacío. A continuación, en cada<br />

paso, se intenta añadir al conjunto el mejor candidato de los aún no<br />

escogidos, utilizando la función de selección. Si el conjunto resultante no es<br />

completable, se rechaza el candidato y no se le vuelve a considerar en el<br />

futuro. En caso contrario, se incorpora al conjunto de candidatos escogidos y<br />

permanece siempre en él. Tras cada incorporación se comprueba si el<br />

conjunto resultante es una solución del problema. Un algoritmo voraz es<br />

correcto si la solución así encontrada es siempre óptima.<br />

El esquema genérico del algoritmo voraz es:<br />

funcion voraz(C:conjunto):conjunto<br />

{ C es el conjunto de todos los candidatos }<br />

S


Ejemplo: Mínimo número de monedas<br />

Se desea pagar una cantidad de dinero a un cliente empleando el menor<br />

número posible de monedas. Los elementos del esquema anterior se<br />

convierten en:<br />

• candidato: conjunto finito de monedas de, por ejemplo, 1, 5, 10 y 25<br />

unidades, con una moneda de cada tipo por lo menos;<br />

• solución: conjunto de monedas cuya suma es la cantidad a pagar;<br />

• completable: la suma de las monedas escogidas en un momento dado<br />

no supera la cantidad a pagar;<br />

• función de selección: la moneda de mayor valor en el conjunto de<br />

candidatos aún no considerados;<br />

• función objetivo: número de monedas utilizadas en la solución.<br />

5.6 Método de Retroceso (backtracking)<br />

El Backtracking o vuelta atrás es una técnica de resolución general de<br />

problemas mediante una búsqueda sistemática de soluciones. El<br />

procedimiento general se basa en la descomposición del proceso de<br />

búsqueda en tareas parciales de tanteo de soluciones (trial and error).<br />

Las tareas parciales se plantean de forma recursiva al construir<br />

gradualmente las soluciones. Para resolver cada tarea, se descompone en<br />

varias subtareas y se comprueba si alguna de ellas conduce a la solución del<br />

problema. Las subtareas se prueban una a una, y si una subtarea no conduce<br />

a la solución se prueba con la siguiente.<br />

La descripción natural del proceso se representa por un árbol de búsqueda<br />

en el que se muestra como cada tarea se ramifica en subtareas. El árbol de<br />

búsqueda suele crecer rápidamente por lo que se buscan herramientas<br />

eficientes para descartar algunas ramas de búsqueda produciéndose la poda<br />

del árbol.<br />

Diversas estrategias heurísticas, frecuentemente dependientes del<br />

problema, establecen las reglas para decidir en que orden se tratan las tareas<br />

mediante las que se realiza la ramificación del árbol de búsqueda, y qué<br />

funciones se utilizan para provocar la poda del árbol.<br />

El método de backtracking se ajusta a muchos famosos problemas de la<br />

inteligencia artif icial que admiten extensiones muy sencillas. Algunos de<br />

estos problemas son el de las Torres de Hanoi, el del salto del caballo o el de<br />

la colocación de las ocho reinas en el tablero de ajedrez, el problema del<br />

laberinto.<br />

Página 60


Un caso especialmente importante en optimización combinatoria es el<br />

problema de la selección óptima. Se trata del problema de seleccionar una<br />

solución por sus componentes, de forma que respetando una serie de<br />

restricciones, de lugar a la solución que optimiza una función objetivo que se<br />

evalúa al construirla. El árbol de búsqueda permite recorrer todas las<br />

soluciones para quedarse con la mejor de entre las que sean factibles. Las<br />

estrategias heurísticas orientan la exploración por las ramas más<br />

prometedoras y la poda en aquellas que no permiten alcanzar una solución<br />

factible o mejorar la mejor solución factible alcanzada hasta el momento.<br />

El problema típico es el conocido como problema de la mochila. Se<br />

dispone de una mochila en la que se soporta un peso máximo P en la que se<br />

desean introducir objetos que le den el máximo valor. Se dispone de una<br />

colección de n objetos de los que se conoce su peso y su valor;<br />

(pi,vi); i = 1, 2, ..., n.<br />

5.7 Método de Branch and Bound<br />

Branch and bound (o también conocido como Ramifica y Poda) es una<br />

técnica muy similar a la de Backtracking, pues basa su diseño en el análisis<br />

del árbol de búsqueda de soluciones a un problema. Sin embargo, no utiliza<br />

la búsqueda en profundidad (depth first), y solamente se aplica en<br />

problemas de Optimización.<br />

Los algoritmos generados por está técnica son normalmente de orden<br />

exponencial o peor en su peor caso, pero su aplicación ante instancias muy<br />

grandes, ha demostrado ser eficiente (incluso más que bactracking).<br />

Se debe decidir de qué manera se conforma el árbol de búsqueda de<br />

soluciones. Sobre el árbol, se aplicará una búsqueda en anchura (breadthfirst),<br />

pero considerando prioridades en los nodos que se visitan (bestfirst).<br />

El criterio de selección de nodos, se basa en un valor óptimo posible<br />

(bound), con el que se toman decisiones para hacer las podas en el árbol.<br />

Modo de proceder de los algoritmos Branch and Bound:<br />

• Nodo vivo: nodo con posibilidad de ser ramificado, es decir, no se<br />

ha realizado una poda.<br />

• No se pueden comenzar las podas hasta que no se haya<br />

descendido a una hoja del árbol<br />

• Se aplicará la poda a los nodos con una cota peor al valor de una<br />

solución dada a los nodos podados a los que se les ha aplicado<br />

una poda se les conoce como nodos muertos<br />

• Se ramificará hasta que no queden nodos vivos por expandir<br />

• Los nodos vivos han de estar almacenados en algún sitio lista<br />

de nodos, de modo que sepamos qué nodo expandir<br />

Página 61


Algoritmo General:<br />

Función ramipoda(P:problema): solucion<br />

L={P} (lista de nodos vivos)<br />

S= ∞± (según se aplique máximo o mínimo)<br />

Mientras L ≠ 0<br />

n=nodo de mejor cota en L<br />

si solucion(n)<br />

si (valor(n) mejor que s)<br />

s=valor(n)<br />

para todo m ∈ L y Cota(m) peor que S<br />

borrar nodo m de L<br />

sino<br />

añadir a L los hijos de n con sus cotas<br />

borrar n de L (tanto si es solución como si ha ramificado)<br />

Fin_mientras<br />

Devolver S<br />

Página 62


Capítulo 6<br />

Algoritmos de Ordenamiento<br />

6.1 Concepto de Ordenamiento<br />

<strong>DE</strong>FINICIÓN.<br />

Entrada: Una secuencia de n números , usualmente en la<br />

forma de un arreglo de n elementos.<br />

Salida: Una permutación de la secuencia de entrada tal<br />

que a’1 = a’2 = … = a’n.<br />

CONSI<strong>DE</strong>RACIONES. Cada número es normalmente una clave que forma<br />

parte de un registro; cada registro contiene además datos satélites que se<br />

mueven junto con la clave; si cada registro incluye muchos datos satélites,<br />

entonces movemos punteros a los registros (y no los registros).<br />

Un método de ordenación se denomina estable si el orden relativo de los<br />

elementos no se altera por el proceso de ordenamiento. (Importante cuanto<br />

se ordena por varias claves).<br />

Hay dos categorías importantes y disjuntas de algoritmos de ordenación:<br />

• Ordenación interna: La cantidad de registros es suficientemente<br />

pequeña y el proceso puede llevarse a cabo en memoria.<br />

• Ordenación externa: Hay demasiados registros para permitir<br />

ordenación interna; deben almacenarse en disco.<br />

Por ahora nos ocuparemos sólo de ordenación interna.<br />

Los algoritmos de ordenación interna se clasifican de acuerdo con la<br />

cantidad de trabajo necesaria para ordenar una secuencia de n elementos:<br />

¿Cuántas comparaciones de elementos y cuántos movimientos de<br />

elementos de un lugar a otro son necesarios?<br />

Empezaremos por los métodos tradicionales (inserción y selección). Son<br />

fáciles de entender, pero ineficientes, especialmente para juegos de datos<br />

grandes. Luego prestaremos más atención a los más eficientes: heapsort y<br />

quicksort.<br />

Página 63


6.2 Ordenamiento por Inserción<br />

Este es uno de los métodos más sencillos. Consta de tomar uno por uno los<br />

elementos de un arreglo y recorrerlo hacia su posición con respecto a los<br />

anteriormente ordenados. Así empieza con el segundo elemento y lo ordena<br />

con respecto al primero. Luego sigue con el tercero y lo coloca en su posición<br />

ordenada con respecto a los dos anteriores, así sucesivamente hasta recorrer<br />

todas las posiciones del arreglo.<br />

Este es el algoritmo:<br />

Sea n el número de elementos en el arreglo A.<br />

INSERTION-SORT(A) :<br />

{Para cada valor de j, inserta A[j] en la posición que le corresponde en la<br />

secuencia ordenada A[1 .. j – 1]}<br />

for j ← 2 to n do<br />

k ← A[j]<br />

i ← j – 1<br />

while i > 0 ∧ A[i] > k do<br />

A[i + 1] ← A[i]<br />

i ← i – 1<br />

A[i + 1] ← k<br />

Análisis del algoritmo:<br />

Número de comparaciones: n(n-1)/2 lo que implica un T(n) = O(n 2 ). La<br />

ordenación por inserción utiliza aproximadamente n 2 /4 comparaciones y<br />

n 2 /8 intercambios en el caso medio y dos veces más en el peor de los casos.<br />

La ordenación por inserción es lineal para los archivos casi ordenados.<br />

6.3 Ordenamiento por Selección<br />

El método de ordenamiento por selección consiste en encontrar el menor de<br />

todos los elementos del arreglo e intercambiarlo con el que está en la primera<br />

posición. Luego el segundo más pequeño, y así sucesivamente hasta ordenar<br />

todo el arreglo.<br />

SELECTION-SORT(A) :<br />

{Para cada valor de j, selecciona el menor elemento de la secuencia no<br />

ordenada A[j .. n] y lo intercambia con A[j]}<br />

for j ← 1 to n – 1 do<br />

k ← j<br />

for i ← j + 1 to n do<br />

if A[i] < A[k] then k ← i<br />

intercambie A[j] ↔ A[k]<br />

Página 64


Análisis del algoritmo:<br />

La ordenación por selección utiliza aproximadamente n 2 /2 comparaciones y<br />

n intercambios, por lo cual T(n) = O(n 2 ).<br />

6.4 Ordenamiento de la Burbuja (Bubblesort)<br />

El algoritmo bubblesort, también conocido como ordenamiento burbuja,<br />

funciona de la siguiente manera: Se recorre el arreglo intercambiando los<br />

elementos adyacentes que estén desordenados. Se recorre el arreglo tantas<br />

veces hasta que ya no haya cambios que realizar. Prácticamente lo que hace es<br />

tomar el elemento mayor y lo va recorriendo de posición en posición hasta<br />

ponerlo en su lugar.<br />

Procedimiento BubbleSort<br />

for (i = n; i >= 1; i--)<br />

for (j = 2; j A[j]) then intercambie(A, j-1, j);<br />

}<br />

Análisis del algoritmo:<br />

La ordenación de burbuja tanto en el caso medio como en el peor de los casos<br />

utiliza aproximadamente n 2 /2 comparaciones y n 2 /2 intercambios, por lo cual<br />

T(n) = O(n 2 ).<br />

6.5 Ordenamiento Rápido ( Quicksort)<br />

Vimos que en un algoritmo de ordenamiento por intercambio, son<br />

necesarios intercambios de elementos en sublistas hasta que no son posibles<br />

más. En el burbujeo, son comparados ítems correlativos en cada paso de la<br />

lista. Por lo tanto para ubicar un ítem en su correcta posición, pueden ser<br />

necesarios varios intercambios.<br />

Veremos el sort de intercambio desarrollado por C.A.R. Hoare conocido<br />

como Quicksort. Es más eficiente que el burbujeo (bubblesort) porque los<br />

intercambios involucran elementos que están más apartados, entonces<br />

menos intercambios son requeridos para poner un elemento en su posición<br />

correcta.<br />

La idea básica del algoritmo es elegir un elemento llamado pivote, y<br />

ejecutar una secuencia de intercambios tal que todos los elementos<br />

menores que el pivote queden a la izquierda y todos los mayores a la<br />

derecha.<br />

Página 65


Lo único que requiere este proceso es que todos los elementos a la izquierda<br />

sean menores que el pivote y que todos los de la derecha sean mayores luego<br />

de cada paso, no importando el orden entre ellos, siendo precisamente esta<br />

flexibilidad la que hace eficiente al proceso.<br />

Hacemos dos búsquedas, una desde la izquierda y otra desde la derecha,<br />

comparando el pivote con los elementos que vamos recorriendo, buscando<br />

los menores o iguales y los mayores respectivamente.<br />

<strong>DE</strong>SCRIPCIÓN. Basado en el paradigma dividir-y-conquistar, estos son los<br />

tres pasos para ordenar un subarreglo A[p … r]:<br />

Dividir. El arreglo A[p … r] es particionado en dos subarreglos no vacíos<br />

A[p … q] y A[q+1 … r]—cada dato de A[p … q] es menor que cada dato<br />

de A[q+1 … r]; el índice q se calcula como parte de este proceso de<br />

partición.<br />

Conquistar. Los subarreglos A[p … q] y A[q+1 … r] son ordenados<br />

mediante sendas llamadas recursivas a QUICKSORT.<br />

Combinar. Ya que los subarreglos son ordenados in situ, no es necesario<br />

hacer ningún trabajo extra para combinarlos; todo el arreglo A[p … r]<br />

está ahora ordenado.<br />

QUICKSORT (A, p, r) :<br />

if p < r then<br />

q ← PARTITION(A, p, r)<br />

QUICKSORT(A, p, q)<br />

QUICKSORT(A, q+1, r)<br />

PARTITION (A, p, r) :<br />

x ← A[p]; i ← p–1; j ← r+1<br />

while τ<br />

repeat j ← j–1 until A[j] = x<br />

repeat i ← i+1 until A[i] = x<br />

if i < j then<br />

intercambie A[i] ↔ A[j]<br />

else return j<br />

Para ordenar un arreglo completo A, la llamada inicial es QUICKSORT(A, 1, n).<br />

PARTITION reordena el subarreglo A[p … r] in situ, poniendo datos menores<br />

que el pivote x = A[p] en la parte baja del arreglo y datos mayores que x en<br />

la parte alta.<br />

Análisis del algoritmo:<br />

Depende de si la partición es o no balanceada, lo que a su vez depende de<br />

cómo se elige los pivotes:<br />

• Si la partición es balanceada, QUICKSORT corre asintóticamente tan<br />

rápido como ordenación por mezcla.<br />

Página 66


• Si la partición es desbalanceada, QUICKSORT corre asintóticamente tan<br />

lento como la ordenación por inserción.<br />

El peor caso ocurre cuando PARTITION produce una región con n–1<br />

elementos y otra con sólo un elemento.<br />

Si este desbalance se presenta en cada paso del algoritmo, entonces, como la<br />

partición toma tiempo Θ (n) y σ(1) = Θ (1), tenemos la recurrencia:<br />

n<br />

σ(n) = σ(n – 1) + Θ (n) = Θ(k)<br />

k =1<br />

= Θ (n 2 ).<br />

Por ejemplo, este tiempo Θ(n 2 ) de ejecución ocurre cuando el arreglo está<br />

completamente ordenado, situación en la cual INSERTIONSORT corre en<br />

tiempo Ο(n).<br />

Si la partición produce dos regiones de tamaño n/2, entonces la recurrencia<br />

es:<br />

σ(n) = 2σ(n/2) + Θ(n) = Θ(n log n).<br />

Tenemos un algoritmo mucho más rápido cuando se da el mejor caso de<br />

PARTITION.<br />

El caso promedio de QUICKSORT es mucho más parecido al mejor caso que al<br />

peor caso; por ejemplo, si PARTITION siempre produce una división de<br />

proporcionalidad 9 a 1:<br />

o Tenemos la recurrencia σ(n) = σ(9n/10) + σ(n/10) + n.<br />

o En el árbol de recursión, cada nivel tiene costo n (o a lo más n más<br />

allá de la profundidad log 10 n), y la recursión termina en la<br />

profundidad log 10/9 n = Θ(log n) … ⇒<br />

o … el costo total para divisiones 9 a 1 es Θ(nlog n), asintóticamente lo<br />

mismo que para divisiones justo en la mitad.<br />

o Cualquier división de proporcionalidad constante, por ejemplo, 99 a<br />

1, produce un árbol de recursión de profundidad Θ(log n), en que el<br />

costo de cada nivel es Θ(n), lo que se traduce en que el tiempo de<br />

ejecución es Θ(nlog n).<br />

En la práctica, en promedio, PARTITION produce una mezcla de divisiones<br />

buenas y malas, que en el árbol de recursión están distribuidas<br />

aleatoriamente; si suponemos que aparecen alternadamente por niveles:<br />

• En la raíz, el costo de la partición es n y los arreglos producidos tienen<br />

tamaños n–1 y 1.<br />

Página 67


• En el siguiente nivel, el arreglo de tamaño n–1 es particionado en dos<br />

arreglos de tamaño (n–1)/2, y el costo de procesar un arreglo de<br />

tamaño 1 es 1.<br />

• Esta combinación produce tres arreglos de tamaños 1, (n–1)/2 y (n–<br />

1)/2 a un costo combinado de 2n–1 = Θ(n) …<br />

• … no peor que el caso de una sola partición que produce dos arreglos de<br />

tamaños (n–1)/2 + 1 y (n–1)/2 a un costo de n = Θ(n), y que es muy<br />

aproximadamente balanceada.<br />

Intuitivamente, el costo Θ(n) de la división mala es absorbido por el costo<br />

Θ(n) de la división buena:<br />

• ⇒ la división resultante es buena.<br />

⇒ El tiempo de ejecución de QUICKSORT, cuando los niveles producen<br />

alternadamente divisiones buenas y malas, es como el tiempo de ejecución<br />

para divisiones buenas únicamente:<br />

O(n log n), pero con una constante más grande.<br />

6.6 Ordenamiento por Montículo ( Heapsort)<br />

<strong>DE</strong>FINICIONES. Un heap (binario) es un arreglo que puede verse como un<br />

árbol binario completo:<br />

• Cada nodo del árbol corresponde a un elemento del arreglo;<br />

• El árbol está lleno en todos los niveles excepto posiblemente el de más<br />

abajo, el cual está lleno desde la izquierda hasta un cierto punto.<br />

Un arreglo A que representa a un heap tiene dos atributos:<br />

• λ[A] es el número de elementos en el arreglo;<br />

• σ[A] es el número de elementos en el heap almacenado en el arreglo—<br />

σ[A] = λ[A];<br />

• A[1 .. λ[A]] puede contener datos válidos, pero ningún dato más allá de<br />

A[σ [A]] es un elemento del heap.<br />

La raíz del árbol es A[1]; y dado el índice i de un nodo, los índices de su padre,<br />

hijo izquierdo, e hijo derecho se calculan como:<br />

P(i) : return ⎣i/2⎦ L(i) : return 2i R(i) : return 2i + 1<br />

Un heap satisface la propiedad de heap:<br />

• Para cualquier nodo i distinto de la raíz, A[P(i)] = A[i];<br />

• El elemento más grande en un heap está en la raíz;<br />

Página 68


• Los subárboles que tienen raíz en un cierto nodo contienen valores más<br />

pequeños que el valor contenido en tal nodo.<br />

La altura de un heap de n elementos es Θ(log n).<br />

RESTAURACIÓN <strong>DE</strong> LA PROPIEDAD <strong>DE</strong> HEAP. Consideremos un arreglo A y un<br />

índice i en el arreglo, tal que:<br />

• Los árboles binarios con raíces en L(i) y R(i) son heaps;<br />

• A[i] puede ser más pequeño que sus hijos, violando la propiedad de<br />

heap.<br />

HEAPIFY hace “descender” por el heap el valor en A[i], de modo que el<br />

subárbol con raíz en i se vuelva un heap; en cada paso:<br />

• determinamos el más grande entre A[i], A[L(i)] y A[R(i)], y<br />

almacenamos su índice en k;<br />

• si el más grande no es A[i], entonces intercambiamos A[i] con A[k], de<br />

modo que ahora el nodo i y sus hijos satisfacen la propiedad de heap;<br />

• pero ahora, el nodo k tiene el valor originalmente en A[i]—el subárbol<br />

con raíz en k podría estar violando la propiedad de heap …<br />

• … siendo necesario llamar a HEAPIFY recursivamente.<br />

HEAPIFY (A, i) :<br />

l ← L(i); r ← R(i)<br />

if l = σ[A] ∧ A[l] > A[i] then k ← l else k ← i<br />

if r = σ[A] ∧ A[r] > A[k] then k ← r<br />

if k ? i then<br />

intercambie A[i] ↔ A[k]<br />

HEAPIFY(A, k)<br />

El tiempo de ejecución de HEAPIFY a partir de un nodo de altura h es O(h) =<br />

O(log n), en que n es el número de nodos en el subárbol con raíz en el nodo de<br />

partida.<br />

CONSTRUCCIÓN <strong>DE</strong> UN HEAP. Usamos HEAPIFY de abajo hacia arriba para<br />

convertir un arreglo A[1 .. n]— n = λ[A] —en un heap:<br />

• Como los elementos en A[(⎣n/2⎦ + 1) .. n] son todos hojas del árbol, cada<br />

uno es un heap de un elemento;<br />

• BUILD-HEAP pasa por los demás nodos del árbol y ejecuta HEAPIFY en<br />

cada uno.<br />

Página 69


• El orden en el cual los nodos son procesados garantiza que los<br />

subárboles con raíz en los hijos del nodo i ya son heaps cuando HEAPIFY<br />

es ejecutado en el nodo i.<br />

BUILD-HEAP(A) :<br />

σ[A] ← λ[A]<br />

for i ← ⎣λ[A]/2⎦ downto 1 do HEAPIFY (A, i)<br />

El tiempo que toma HEAPIFY al correr en un nodo varía con la altura del nodo,<br />

y las alturas de la mayoría de los nodos son pequeñas.<br />

En un heap de n elementos hay a lo más n/2 h+1 nodos de altura h, de modo<br />

que el costo total de BUILD-HEAP es:<br />

ya que<br />

∞<br />

⎣logn ⎦<br />

∑<br />

h=0<br />

⎡ n<br />

⎢<br />

2<br />

h ∑ = 2<br />

h<br />

2<br />

.<br />

h=0<br />

h+1<br />

⎛<br />

⎤<br />

⎥ O(h) = O⎜ n<br />

⎜<br />

⎝<br />

log n<br />

⎣ ⎦<br />

∑<br />

h=0<br />

h<br />

2h ⎞<br />

⎟<br />

⎟<br />

⎠<br />

= O( n)<br />

Un heap es un árbol binario con las siguientes características:<br />

o Es completo. Cada nivel es completo excepto posiblemente el último,<br />

y en ese nivel los nodos están en las posiciones más a la izquierda.<br />

o Los ítems de cada nodo son mayores e iguales que los ítems<br />

almacenados en los hijos.<br />

Para implementarlo podemos usar estructuras enlazadas o vectores<br />

numerando los nodos por nivel y de derecha a izquierda.<br />

Página 70


Es decir:<br />

1<br />

2 3<br />

4 5 6 7<br />

De este modo, es fácil buscar la dirección del padre o de los hijos de un nodo.<br />

El padre de i es: i div 2. Los hijos de i son: 2 * i y 2 * i + 1.<br />

Un algoritmo para convertir un árbol binario completo en un heap es básico<br />

para otras operaciones de heap. Por ejemplo un árbol en el que ambos<br />

subárboles son heap pero el árbol en si no lo es.<br />

2<br />

9 4<br />

1 5 3<br />

La única razón por la que no es un heap es porque la raíz es menor que uno<br />

(o dos como en este caso subárboles)<br />

El primer paso es intercambiar la raíz con el mayor de los dos hijos:<br />

9<br />

2 4<br />

1 5 3<br />

Esto garantiza que la nueva raíz sea mayor que ambos hijos, y que uno de<br />

estos siga siendo un heap (en este caso el derecho) y que el otro pueda ser o<br />

no. Si es, el árbol entero lo es, sino simplemente repetimos el swapdown en<br />

este subárbol.<br />

Swapdown<br />

1. Done := false<br />

2. c:= 2 ** r<br />

3. While not Done and c < = n Do<br />

a. if c < n and Heap[c] < Heap[c+1] then c := c+1;<br />

b. if Heap[r] < Heap[c] then<br />

i. Intercambiar Heap[r] y Heap[c]<br />

ii. r := c<br />

iii. c := 2 ** c<br />

else Done := true<br />

Página 71


Heapify<br />

Recibe un Árbol Binario Completo (ABC) almacenado en un arreglo<br />

desde posición 1 a posición n. Convierte el árbol en un heap.<br />

Veamos un ejemplo:<br />

SwapDown (árbol cuya raíz esta en r)<br />

Sean los siguientes elementos: 35, 15, 77, 60, 22, 41<br />

El arreglo contiene los ítems como un ABC.<br />

35<br />

15 77<br />

60 22 41<br />

Usamos Heapify para convertirlo en Heap.<br />

77<br />

60 41<br />

15 22 35<br />

Esto pone al elemento más grande en la raíz, es decir la posición 1 del vector.<br />

Ahora usamos la estrategia de sort de selección y posicionamos<br />

correctamente al elemento más grande y pasamos a ordenar la sublista<br />

compuesta por los otros 5 elementos<br />

35<br />

60 41<br />

15 22 77 -------------------35,60,41,15,22,77<br />

En términos del árbol estamos cambiando, la raíz por la hoja más a la<br />

derecha. Sacamos ese elemento del árbol. El árbol que queda no es un heap.<br />

Como solo cambiamos la raíz, los subárboles son heaps, entonces podemos<br />

usar swapdown en lugar del Heapify que consume más tiempo.<br />

Página 72


60<br />

35 41<br />

15 22<br />

Ahora usamos la misma técnica de intercambiar la raíz con la hoja más a la<br />

derecha que podamos.<br />

22<br />

35 41<br />

15 60 --------------------------------------- 35,22,41,15,60,77<br />

Volvemos a usar SwapDown para convertir en un Heap.<br />

15<br />

41<br />

35 22<br />

Y volvemos a hacer el intercambio hoja-raíz.<br />

15<br />

35 22<br />

41 ------------------------------------------- 35,15,22,41,60,77<br />

15<br />

35 22<br />

Volvemos a usar SwapDown<br />

35<br />

15 22 Intercambiamos hoja-raíz y podamos<br />

22 ------------------------------------------ 22,15,35,41,60,77<br />

Página 73


Finalmente, el árbol de dos elementos es convertido en un heap e<br />

intercambiado la raíz con la hoja y podado<br />

15<br />

22 ------------------------------------------ 15,22,35,41,60,77<br />

El siguiente algoritmo resume el procedimiento descrito.<br />

Consideramos a X como un árbol binario completo (ABC) y usamos heapify<br />

para convertirlo en un heap.<br />

For i = n down to 2 do<br />

• Intercambiar X[1] y X[i], poniendo el mayor elemento de la sublista<br />

X[1]...X[i] al final de esta.<br />

• Aplicar SwapDown para convert ir en heap el árbol binario<br />

correspondiente a la sublista almacenada en las posiciones 1 a i - 1 de<br />

X.<br />

6.7 Otros Métodos de Ordenamiento<br />

Existen varios otros algoritmos de ordenamiento. A continuación se<br />

mencionarán brevemente algunos otros más.<br />

6.7.1 Ordenamiento por Incrementos Decrecientes<br />

Denominado así por su desarrollador Donald Shell (1959). El ordenamiento<br />

por incrementos decrecientes (o método shellsort ) es una generalización<br />

del ordenamiento por inserción donde se gana rápidez al permitir<br />

intercambios entre elementos que se encuentran muy alejados.<br />

La idea es reorganizar la secuencia de datos para que cumpla con la<br />

propiedad siguiente: si se toman todos los elementos separados a una<br />

distancia h, se obtiene una secuencia ordenada.<br />

Se dice que la secuencia h ordenada está constituida por h secuencias<br />

ordenadas independendientes, pero entrelazadas entre sí. Se utiliza una<br />

serie decreciente h que termine en 1.<br />

for ( h = 1; h 0; h/= 3 )<br />

for ( i = h + 1; i


while ( j > h && A[j-h] > v ) then<br />

{ A[j] = A[j-1];<br />

j -= h;<br />

}<br />

A[j] = v;<br />

}<br />

Análisis del algoritmo:<br />

Esta sucesión de incrementos es fácil de utilizar y conduce a una ordenación<br />

eficaz. Hay otras muchas sucesiones que conducen a ordenaciones mejores.<br />

Es difícil mejorar el programa anterior en más de un 20%, incluso para n<br />

grande. Existen sucesiones desfavorables como por ejemplo: 64, 32, 16, 8, 4,<br />

2, 1, donde sólo se comparan elementos en posiciones impares cuando h=1.<br />

Nadie ha sido capaz de analizar el algoritmo, por lo que es difícil evaluar<br />

analíticamente los diferentes incrementos y su comparación con otros<br />

métodos, en consecuencia no se conoce su forma funcional del tiempo de<br />

ejecución.<br />

Para la sucesión de incrementos anterior se tiene un T(n) = n( log n) 2 y n 1,25 .<br />

La ordenación de Shell nunca hace más de n 3/2 comparaciones (para los<br />

incrementos 1,2,13, 40.....) .<br />

6.7.2 Ordenamiento por Mezclas Sucesivas (merge sort)<br />

Se aplica la técnica divide-y-vencerás, dividiendo la secuencia de datos en dos<br />

subsecuencias hasta que las subsecuencias tengan un único elemento, luego<br />

se ordenan mezclando dos subsecuencias ordenadas en una secuencia<br />

ordenada, en forma sucesiva hasta obtener una secuencia única ya ordenada.<br />

Si n = 1 solo hay un elemento por ordenar, sino se hace una ordenación de<br />

mezcla de la primera mitad del arreglo con la segunda mitad. Las dos mitades<br />

se ordenan de igual forma.<br />

Ejemplo: Se tiene un arreglo de 8 elementos, se ordenan los 4 elementos de<br />

cada arreglo y luego se mezclan. El arreglo de 4 elementos, se ordenan los 2<br />

elementos de cada arreglo y luego se mezclan. El arreglo de 2 elementos,<br />

como cada arreglo sólo tiene n = 1 elemento, solo se mezclan.<br />

Página 75


void ordenarMezcla(TipoEle A[], int izq, int der)<br />

{ if ( izq < der )<br />

{ centro = ( izq + der ) % 2;<br />

ordenarMezcla( A, izq, centro );<br />

ordenarMezcla( A, centro+1, der);<br />

intercalar( A, izq, centro, der );<br />

}<br />

}<br />

void intercalar(TipoEle A[], int a, int c, int b )<br />

{ k = 0;<br />

i = a;<br />

j = c + 1;<br />

n = b - a;<br />

while ( i < c + 1 ) && ( j < b + 1 )<br />

{ if ( A[i] < A[j] )<br />

{ B[k] = A[i];<br />

i = i + 1;<br />

}<br />

else<br />

{ B[k] = A[j];<br />

j = j + 1;<br />

}<br />

k = k + 1;<br />

};<br />

while ( i < c + 1 )<br />

{ B[k] = A[i];<br />

i++;<br />

k++;<br />

};<br />

while ( j < b + 1 )<br />

Página 76


{ B[k] = A[j];<br />

j++;<br />

k++;<br />

};<br />

i = a;<br />

for ( k = 0; k < n; i++ )<br />

{ A[i] = B[k];<br />

i++;<br />

};<br />

};<br />

Análisis del algoritmo:<br />

La relación de recurrencia del algoritmo es T(1) = 1, T(n) = 2 T(n/2) + n, cuya<br />

solución es T(n) = n log n.<br />

Página 77


Capítulo 7<br />

Algoritmos de Búsqueda<br />

7.1 Introducción<br />

En cualquier estructura de datos puede plantearse la necesidad de saber si<br />

un cierto dato está almacenado en ella, y en su caso en que posición se<br />

encuentra. Es el problema de la búsqueda.<br />

Existen diferentes métodos de búsqueda, todos parten de un esquema<br />

iterativo, en el cual se trata una parte o la totalidad de la estructura de datos<br />

sobre la que se busca.<br />

El tipo de estructura condiciona el proceso de búsqueda debido a los<br />

diferentes modos de acceso a los datos.<br />

7.2 Búsqueda Lineal<br />

La búsqueda es el proceso de localizar un registro (elemento) con un valor de<br />

llave particular. La búsqueda termina exitosamente cuando se localiza el<br />

registro que contenga la llave buscada, o termina sin éxito, cuando se<br />

determina que no aparece ningún registro con esa llave.<br />

Búsqueda lineal, también se le conoce como búsqueda secuencial.<br />

Supongamos una colección de registros organizados como una lista lineal. El<br />

algoritmo básico de búsqueda lineal consiste en empezar al inicio de la lista e<br />

ir a través de cada registro hasta encontrar la llave indicada (k), o hasta al<br />

final de la lista. Posteriormente hay que comprobar si ha habido éxito en la<br />

búsqueda.<br />

Es el método más sencillo en estructuras lineales. En estructuras ordenadas<br />

el proceso se optimiza, ya que al llegar a un dato con número de orden<br />

mayor que el buscado, no hace falta seguir.<br />

La situación óptima es que el registro buscado sea el primero en ser<br />

examinado. El peor caso es cuando las llaves de todos los n registros son<br />

comparados con k (lo que se busca). El caso promedio es n/2<br />

comparaciones.<br />

Este método de búsqueda es muy lento, pero si los datos no están en orden<br />

es el único método que puede emplearse para hacer las búsquedas. Si los<br />

valores de la llave no son únicos, para encontrar todos los registros con una<br />

llave particular, se requiere buscar en toda la lista.<br />

Página 78


Mejoras en la eficiencia de la búsqueda lineal<br />

1) Muestreo de acceso<br />

Este método consiste en observar que tan frecuentemente se solicita<br />

cada registro y ordenarlos de acuerdo a las probabilidades de acceso<br />

detectadas.<br />

2) Movimiento hacia el frente<br />

Este esquema consiste en que la lista de registros se reorganicen<br />

dinámicamente. Con este método, cada vez que búsqueda de una llave<br />

sea exitosa, el registro correspondiente se mueve a la primera<br />

posición de la lista y se recorren una posición hacia abajo los que<br />

estaban antes que él.<br />

3) Transposición<br />

Este es otro esquema de reorganización dinámica que consiste en que,<br />

cada vez que se lleve a cabo una búsqueda exitosa, el registro<br />

correspondiente se intercambia con el anterior. Con este<br />

procedimiento, entre más accesos tenga el registro, más rápidamente<br />

avanzará hacia la primera posición. Comparado con el método de<br />

movimiento al frente, el método requiere más tiempo de actividad<br />

para reorganizar al conjunto de registros. Una ventaja de método de<br />

transposición es que no permite que el requerimiento aislado de un<br />

registro, cambie de posición todo el conjunto de registros. De hecho,<br />

un registro debe ganar poco a poco su derecho a alcanzar el inicio de<br />

la lista.<br />

4) Ordenamiento<br />

Una forma de reducir el número de comparaciones esperadas cuando<br />

hay una significativa frecuencia de búsqueda sin éxito es la de<br />

ordenar los registros en base al valor de la llave. Esta técnica es útil<br />

cuando la lista es una lista de excepciones, tales como una lista de<br />

decisiones, en cuyo caso la mayoría de las búsquedas no tendrán<br />

éxito. Con este método una búsqueda sin éxito termina cuando se<br />

encuentra el primer valor de la llave mayor que el buscado, en lugar<br />

de la final de la lista.<br />

En ciertos casos se desea localizar un elemento concreto de un arreglo. En<br />

una búsqueda lineal de un arreglo, se empieza con la primera casilla del<br />

arreglo y se observa una casilla tras otra hasta que se encuentra el elemento<br />

buscado o se ha visto todas las casillas. Como el resultado de la búsqueda es<br />

un solo valor, ya sea la posición del elemento buscado o cero, puede usarse<br />

una función para efectuar la búsqueda.<br />

Página 79


En la función BUSQ_LINEAL, nótese el uso de la variable booleana<br />

encontrada.<br />

Function BUSQ_LINEAL (nombres: tiponom; Tam: integer;<br />

{Regresa el índice de la celda que contiene el elemento buscado o<br />

regresa 0 si no está en el arreglo}<br />

Var encontrado: boolean;<br />

indice; integer:<br />

Begin<br />

encontrado := false;<br />

indice := 1:<br />

Reapeat<br />

If nombres {índice} = sebusca then<br />

begin<br />

encontrado : = true;<br />

BUSQ_LINEAL := indice<br />

end<br />

else<br />

indice := indice + 1<br />

Until (encontrado) or {indice > tam);<br />

End;<br />

7.3 Búsqueda Binaria<br />

If not encontrado then BUSQ_LINEAL: = 0<br />

Una búsqueda lineal funciona bastante bien para una lista pequeña. Pero si<br />

se desea buscar el número de teléfono de Jorge Perez en el directorio<br />

telefónico de Santiago, no sería sensato empezar con el primer nombre y<br />

continuar la búsqueda nombre por nombre. En un método más eficiente se<br />

aprovechará el orden alfabético de los nombres para ir inmediatamente a un<br />

lugar en la segunda mitad del directorio.<br />

La búsqueda binaria es semejante al método usado automáticamente al<br />

buscar un número telefónico. En la búsqueda binaria se reduce<br />

sucesivamente la operación eliminando repetidas veces la mitad de la lista<br />

restante. Es claro que para poder realizar una búsqueda binaria debe tenerse<br />

una lista ordenada.<br />

Se puede aplicar tanto a datos en listas lineales como en árboles binarios de<br />

búsqueda. Los prerrequisitos principales para la búsqueda binaria son:<br />

o La lista debe estar ordenada en un orden especifíco de acuerdo<br />

al valor de la llave.<br />

o Debe conocerse el número de registros.<br />

Página 80


Algoritmo<br />

1. Se compara la llave buscada con la llave localizada al centro del<br />

arreglo.<br />

2. Si la llave analizada corresponde a la buscada fin de búsqueda si no.<br />

3. Si la llave buscada es menor que la analizada repetir proceso en mitad<br />

superior, sino en la mitad inferior.<br />

4. El proceso de partir por la mitad el arreglo se repite hasta encontrar el<br />

registro o hasta que el tamaño de la lista restante sea cero , lo cual<br />

implica que el valor de la llave buscada no está en la lista.<br />

El esfuerzo máximo para este algoritmo es de log2 n. El mínimo de 1 y en<br />

promedio ½ log2 n.<br />

7.4 Árboles de Búsqueda<br />

Son tipos especiales de árboles que los hacen adecuados para almacenar y<br />

recuperar información. Como en un árbol binario de búsqueda buscar<br />

información requiere recorrer un solo camino desde la raíz a una hoja. Como<br />

en un árbol balanceado el más largo camino de la raíz a una hoja es O(log<br />

n). A diferencia de un árbol binario cada nodo puede contener varios<br />

elementos y tener varios hijos. Debido a esto y a que es balanceado, permite<br />

acceder a grandes conjuntos de datos por caminos cortos.<br />

7.5 Búsqueda por Transformación de Claves (Hashing)<br />

Hasta ahora las técnicas de localización de registros vistas, emplean un<br />

proceso de búsqueda que implica cierto tiempo y esfuerzo. El siguiente<br />

método nos permite encontrar directamente el registro buscado.<br />

La idea básica de este método consiste en aplicar una función que traduce un<br />

conjunto de posibles valores llave en un rango de direcciones relativas. Un<br />

problema potencial encontrado en este proceso, es que tal función no puede<br />

ser uno a uno; las direcciones calculadas pueden no ser todas únicas, cuando<br />

R(k1)= R(k2 ) . Pero: K1 diferente de K2 decimos q ue hay una colisión. A dos<br />

llaves diferentes que les corresponda la misma dirección relativa se les llama<br />

sinónimos.<br />

A las técnicas de cálculo de direcciones también se les conoce como:<br />

o Técnicas de almacenamiento disperso<br />

o Técnicas aleatorias<br />

o Métodos de transformación de llave-a-dirección<br />

o Técnicas de direccionamiento directo<br />

o Métodos de tabla Hash<br />

o Métodos de Hashing<br />

Página 81


Pero el término más usado es el de hashing. Al cálculo que se realiza para<br />

obtener una dirección a partir de una llave se le conoce como función<br />

hash.<br />

Ventajas:<br />

1. Se pueden usar los valores naturales de la llave, puesto que se<br />

traducen internamente a direcciones fáciles de localizar.<br />

2. Se logra independencia lógica y física, debido a que los valores de las<br />

llaves son independientes del espacio de direcciones.<br />

3. No se requiere almacenamiento adicional para los índices.<br />

Desventajas:<br />

1. No pueden usarse registros de longitud variable.<br />

2. El archivo no está ordenado.<br />

3. No permite llaves repetidas.<br />

4. Solo permite acceso por una sola llave.<br />

Costos:<br />

• Tiempo de procesamiento requerido para la aplicación de la función<br />

hash.<br />

• Tiempo de procesamiento y los accesos E/S requeridos para<br />

solucionar las colisiones.<br />

La eficiencia de una función hash depende de:<br />

1. La distribución de los valores de llave que realmente se usan.<br />

2. El número de valores de llave que realmente están en uso con<br />

respecto al tamaño del espacio de direcciones.<br />

3. El número de registros que pueden almacenarse en una dirección<br />

dada sin causar una colisión.<br />

4. La técnica usada para resolver el problema de las colisiones.<br />

Las funciones hash más comunes son:<br />

o Residuo de la división<br />

o Método de la Multiplicación<br />

o Medio del cuadrado<br />

o Pliegue<br />

Hashing por Residuo de la División<br />

En este caso, h(k) = k mod m , en que m = número primo no muy cercano<br />

a una potencia de 2—si m = 2 p , entonces h(k) es los p bits menos<br />

significativos de k.<br />

Página 82


La idea de este método es la de dividir el valor de la llave entre un número<br />

apropiado, y después utilizar el residuo de la división como dirección<br />

relativa para el registro (dirección = llave mod divisor).<br />

Mientras que el valor calculado real de una dirección relativa, dados tanto<br />

un valor de llave como el divisor, es directo; la elección del divisor apropiado<br />

puede no ser tan simple. Existen varios factores que deben considerarse para<br />

seleccionar el divisor:<br />

1. El rango de valores que resultan de la operación "llave mod divisor", va<br />

desde cero hasta el divisor 1. Luego, el divisor determina el tamaño del<br />

espacio de direcciones relativas. Si se sabe que el archivo va a contener<br />

por lo menos n registros, entonces tendremos que hacer que divisor > n,<br />

suponiendo que solamente un registro puede ser almacenado en una<br />

dirección relativa dada.<br />

2. El divisor deberá seleccionarse de tal forma que la probabilidad de<br />

colisión sea minimizada. ¿Cómo escoger este número? Mediante<br />

investigaciones se ha demostrado que los divisores que son números<br />

pares tienden a comportase pobremente, especialmente con los<br />

conjuntos de valores de llave que son predominantemente impares.<br />

Algunas investigaciones sugieren que el divisor deberá ser un número<br />

primo. Sin embargo, otras sugieren que los divisores no primos trabajan<br />

tan bién como los divisores primos, siempre y cuando los divisores no<br />

primos no contengan ningún factor primo menor de 20. Lo más común<br />

es elegir el número primo más próximo al total de direcciones.<br />

Independientemente de que tan bueno sea el divisor, cuando el espacio de<br />

direcciones de un archivo está completamente lleno, la probabilidad de<br />

colisión crece dramáticamente. La saturación de archivo de mide mediante<br />

su factor de carga , el cual se define como la relación del número de<br />

registros en el archivo contra el número de registros que el archivo podría<br />

contener si estuviese completamente lleno.<br />

Todas las funciones hash comienzan a trabajar probablemente cuando el<br />

archivo está casi lleno. Por lo general, el máximo factor de carga que puede<br />

tolerarse en un archivo para un rendimiento razonable es de entre el 70% y<br />

80%.<br />

Por ejemplo, si:<br />

• n = 2,000 strings de caracteres de 8 bits, en que cada string es<br />

interpretado como un número entero k en base 256, y<br />

• estamos dispuestos a examinar un promedio de 3 elementos en una<br />

búsqueda fallida,<br />

Página 83


Entonces podemos:<br />

• asignar una tabla de mezcla de tamaño m = 701 y<br />

• usar la función de mezcla k(k) = k mod 701 .<br />

Hashing por Método de la Multiplicación<br />

En este caso, h(k) = ⎣m (k A mod 1) ⎦ ,<br />

en que 0 < A < 1; el valor de m no es crítico y típicamente se usa una<br />

potencia de 2 para facilitar el cálculo computacional de la función.<br />

Por ejemplo, si k = 123456, m = 10,000, y A = ( 5 – 1)/2 ≈ 0.6180339887,<br />

entonces h(k) = 41.<br />

Hashing por Medio del Cuadrado<br />

En esta técnica, la llave es elevada al cuadrado, después algunos dígitos<br />

específicos se extraen de la mitad del resultado para constituir la dirección<br />

relativa. Si se desea una dirección de n dígitos, entonces los dígitos se<br />

truncan en ambos extremos de la llave elevada al cuadrado, tomando n<br />

dígitos intermedios. Las mismas posiciones de n dígitos deben extraerse<br />

para cada llave.<br />

Ejemplo:<br />

Utilizando esta función hashing el tamaño del archivo resultante es de 10 n<br />

donde n es el numero de dígitos extraídos de los valores de la llave elevada al<br />

cuadrado.<br />

Hashing por Pliegue<br />

En esta técnica el valor de la llave es particionada en varias partes, cada una<br />

de las cuales (excepto la última) tiene el mismo número de dígitos que tiene<br />

la dirección relativa objetivo. Estas particiones son después plegadas una<br />

sobre otra y sumadas. El resultado, es la dirección relativa. Igual que para el<br />

método del medio del cuadrado, el tamaño del espacio de direcciones<br />

relativas es una potencia de 10.<br />

Comparación entre las Funciones Hash<br />

Aunque alguna otra técnica pueda desempeñarse mejor en situaciones<br />

particulares, la técnica del residuo de la división proporciona el mejor<br />

desempeño. Ninguna función hash se desempeña siempre mejor que las<br />

Página 84


otras. El método del medio del cuadrado puede aplicarse en archivos con<br />

factores de cargas bastantes bajas para dar generalmente un buen<br />

desempeño. El método de pliegues puede ser la técnica más fácil de calcular,<br />

pero produce resultados bastante erráticos, a menos que la longitud de la<br />

llave sea aproximadamente igual a la longitud de la dirección.<br />

Si la distribución de los valores de llaves no es conocida, entonces el método<br />

del residuo de la división es preferible. Note que el hashing puede ser<br />

aplicado a llaves no numéricas. Las posiciones de ordenamiento de<br />

secuencia de los caracteres en un valor de llave pueden ser utilizadas como<br />

sus equivalentes "numéricos". Alternativamente, el algoritmo hash actúa<br />

sobre las represe ntaciones binarias de los caracteres.<br />

Todas las funciones hash presentadas tienen destinado un espacio de<br />

tamaño fijo. Aumentar el tamaño del archivo relativo creado al usar una de<br />

estas funciones, implica cambiar la función hash, para que se refiera a un<br />

espacio mayor y volver a cargar el nuevo archivo.<br />

Métodos para Resolver el Problema de las Colisiones<br />

Considere las llaves K1 y K2 que son sinónimas para la función hash R. Si K1<br />

es almacenada primero en el archivo y su dirección es R(K1), entonces se<br />

dice que K1 esta almacenado en su dirección de origen.<br />

Existen dos métodos básicos para determinar dónde debe ser alojado K2:<br />

• Direccionamiento abierto. Se encuentra entre dirección de origen<br />

para K2 dentro del archivo.<br />

• Separación de desborde (Área de desborde). Se encuentra una<br />

dirección para K2 fuera del área principal del archivo, en un área<br />

especial de desborde, que es utilizada exclusivamente para almacenar<br />

registro que no pueden ser asignados en su dirección de origen<br />

Los métodos más conocidos para resolver colisiones son:<br />

Sondeo lineal<br />

Es una técnica de direccionamiento abierto. Este es un proceso de búsqueda<br />

secuencial desde la dirección de origen para encontrar la siguiente localidad<br />

vacía. Esta técnica es también conocida como método de desbordamiento<br />

consecutivo.<br />

Para almacenar un registro por hashing con sondeo lineal, la dirección no<br />

debe caer fuera del límite del archivo. En lugar de terminar cuando el límite<br />

del espacio de dirección se alcanza, se regresa al inicio del espacio y<br />

sondeamos desde ahí. Por lo que debe ser posible detectar si la dirección<br />

Página 85


ase ha sido encontrada de nuevo, lo cual indica que el archivo está lleno y<br />

no hay espacio para la llave.<br />

Para la búsqueda de un registro por hashing con sondeo lineal, los valores<br />

de llave de los registros encontrados en la dirección de origen, y en las<br />

direcciones alcanzadas con el sondeo lineal, deberá compararse con el valor<br />

de la llave buscada, para determinar si el registro objetivo ha sido localizado<br />

o no.<br />

El sondeo lineal puede usarse para cualquier técnica de hashing. Si se<br />

emplea sondeo lineal para almacenar registros, también deberá emplearse<br />

para recuperarlos.<br />

Doble hashing<br />

En esta técnica se aplica una segunda función hash para combinar la llave<br />

original con el resultado del primer hash. El resultado del segundo hash<br />

puede situarse dentro del mismo archivo o en un archivo de sobreflujo<br />

independiente; de cualquier modo, será necesario algún método de solución<br />

si ocurren colisiones durante el segundo hash.<br />

La ventaja del método de separación de desborde es que reduce la situación<br />

de una doble colisión, la cual puede ocurrir con el método de<br />

direccionamiento abierto, en el cual un registro que no está almacenado en<br />

su dirección de origen desplazará a otro registro, el que después buscará su<br />

dirección de origen. Esto puede evitarse con direccionamiento abierto,<br />

simplemente moviendo el registro extraño a otra localidad y almacenando al<br />

nuevo registro en la dirección de origen ahora vacía.<br />

Puede ser aplicado como cualquier direccionamiento abierto o técnica de<br />

separación de desborde.<br />

Para ambas métodos para la solución de colisiones existen técnicas para<br />

mejorar su desempeño como:<br />

1. Encadenamiento de sinónimos<br />

Una buena manera de mejorar la eficiencia de un archivo que utiliza el<br />

cálculo de direcciones, sin directorio auxiliar para guiar la<br />

recuperación de registros, es el encadenamiento de sinónimos.<br />

Mantener una lista ligada de registros, con la misma dirección de<br />

origen, no reduce el número de colisiones, pero reduce los tiempos de<br />

acceso para recuperar los registros que no se encuentran en su<br />

localidad de origen. El encadenamiento de sinónimos puede emplearse<br />

con cualquier técnica de solución de colisiones.<br />

Cuando un registro debe ser recuperado del archivo, solo los sinónimos<br />

de la llave objetivo son accesados.<br />

Página 86


2. Direccionamiento por cubetas<br />

Otro enfoque para resolver el problema de las colisiones es asignar<br />

bloques de espacio (cubetas), que pueden acomodar ocurrencias<br />

múltiples de registros, en lugar de asignar celdas individuales a<br />

registros. Cuando una cubeta es desbordada, alguna nueva localización<br />

deberá ser encontrada para el registro. Los métodos para el problema<br />

de sobrecupo son básicamente los mismos que los métodos para<br />

resolver colisiones.<br />

Comparación entre Sondeo Lineal y Doble Hashing<br />

De ambos métodos resultan distribuciones diferentes de sinónimos en un<br />

archivo relativo. Para aquellos casos en que el factor de carga es bajo (< 0.5),<br />

el sondeo lineal tiende a agrupar los sinónimos, mientras que el doble<br />

hashing tiende a dispersar los sinónimos más ampliamente a través del<br />

espacio de direcciones.<br />

El doble hashing tiende a comportarse casi también como el sondeo lineal<br />

con factores de carga pequeños (< 0.5), pero actúa un poco mejor para<br />

factores de carga mayores. Con un factor de carga > 80 %, el sondeo lineal,<br />

por lo general, resulta tener un comportamiento terrible, mientras que el<br />

doble hashing es bastante tolerable para búsquedas exitosas, pero no así en<br />

búsquedas no exitosas.<br />

7.6 Búsqueda en Textos<br />

La búsqueda de patrones en un texto es un problema muy importante en la<br />

práctica. Sus aplicaciones en computación son variadas, como por ejemplo<br />

la búsqueda de una palabra en un archivo de texto o problemas relacionados<br />

con biología computacional, en donde se requiere buscar patrones dentro de<br />

una secuencia de ADN, la cual puede ser modelada como una secuencia de<br />

caracteres (el problema es más complejo que lo descrito, puesto que se<br />

requiere buscar patrones en donde ocurren alteraciones con cierta<br />

probabilidad, esto es, la búsqueda no es exacta).<br />

En este capítulo se considerará el problema de buscar la ocurrencia de un<br />

patrón dentro de un texto. Se utilizarán las siguientes convenciones:<br />

n denotará el largo del texto en donde se buscará el patrón, es decir, texto =<br />

a1 a2 ... an. Donde m denotará el largo del patrón a buscar, es decir, patrón =<br />

b1 b2 ... bm.<br />

Página 87


Por ejemplo:<br />

Texto = "analisis de algoritmos" n=22 y m=4<br />

Patrón = "algo"<br />

7.6.1 Algoritmo de Fuerza Bruta<br />

Se alinea la primera posición del patrón con la primera posición del<br />

texto, y se comparan los caracteres uno a uno hasta que se acabe el<br />

patrón, esto es, se encontró una ocurrencia del patrón en el texto, o<br />

hasta que se encuentre una discrepancia.<br />

Si se detiene la búsqueda por una discrepancia, se desliza el patrón en<br />

una posición hacia la derecha y se intenta calzar el patrón<br />

nuevamente.<br />

En el peor caso este algoritmo realiza O(m . n)comparaciones de<br />

caracteres.<br />

7.6.2 Algorimo Knuth-Morris-Pratt<br />

Suponga que se está comparando el patrón y el texto en una posición<br />

dada, cuando se encuentra una discrepancia.<br />

Página 88


Sea X la parte del patrón que calza con el texto, e Y la correspondiente<br />

parte del texto, y suponga que el largo de X es j. El algoritmo de fuerza<br />

bruta mueve el patrón una posición hacia la derecha, sin embargo,<br />

esto puede o no puede ser lo correcto en el sentido que los primeros j-<br />

1 caracteres de X pueden o no pueden calzar los últimos j-1 caracteres<br />

de Y.<br />

La observación clave que realiza el algoritmo Knuth-Morris-Pratt (en<br />

adelante KMP) es que X es igual a Y, por lo que la pregunta planteada<br />

en el párrafo anterior puede ser respondida mirando solamente el<br />

patrón de búsqueda, lo cual permite precalcular la respuesta y<br />

almacenarla en una tabla.<br />

Por lo tanto, si deslizar el patrón en una posición no funciona, se<br />

puede intentar deslizarlo en 2, 3, ..., hasta j posiciones.<br />

Se define la función de fracaso (failure function) del patrón como:<br />

f(j) = max(i < j | b1 .. bi = bj=i+1 …bj)<br />

Intuitivamente, f(j) es el largo del mayor prefijo de X que además es<br />

sufijo de X. Note que j = 1 es un caso especial, puesto que si hay una<br />

discrepancia en b1 el patrón se desliza en una posición.<br />

Si se detecta una discrepancia entre el patrón y el texto cuando se<br />

trata de calzar bj+1, se desliza el patrón de manera que bf(j) se<br />

encuentre donde bj se encontraba, y se intenta calzar nuevamente.<br />

Página 89


Suponiendo que se tiene f(j) precalculado, la implementación del<br />

algoritmo KMP es la siguiente:<br />

// n = largo del texto<br />

// m = largo del patrón<br />

// Los índices comienzan desde 1<br />

int k=0;<br />

int j=0;<br />

while (k calce, j el patrón no estaba en el texto<br />

Página 90


Ejemplo:<br />

El tiempo de ejecución de este algoritmo no es difícil de analizar, pero<br />

es necesario ser cuidadoso al hacerlo. Dado que se tienen dos ciclos<br />

anidados, se puede acotar el tiempo de ejecución por el número de<br />

veces que se ejecuta el ciclo externo (menor o igual a n) por el número<br />

de veces que se ejecuta el ciclo interno (menor o igual a m), por lo que<br />

la cota es igual a O(mn), ¡que es igual a lo que demora el algoritmo de<br />

fuerza bruta!.<br />

El análisis descrito es pesimista. Note que el número total de veces<br />

que el ciclo interior es ejecutado es menor o igual al número de veces<br />

que se puede decrementar j, dado que f(j)


El algoritmo resultante es el siguiente (note que es similar al<br />

algoritmo KMP):<br />

// m es largo del patrón<br />

// los índices comienzan desde 1<br />

int[] f=new int[m];<br />

f[1]=0;<br />

int j=1;<br />

int i;<br />

while (j0 && patron[i+1]!=patron[j+1])<br />

{<br />

i=f[i];<br />

}<br />

if (patron[i+1]==patron[j+1])<br />

{<br />

f[j+1]=i+1;<br />

}<br />

else<br />

{<br />

f[j+1]=0;<br />

}<br />

j++;<br />

}<br />

El tiempo de ejecución para calcular la función de fracaso puede ser<br />

acotado por los incrementos y decrementos de la variable i, que es<br />

O(m).<br />

Por lo tanto, el tiempo total de ejecución del algoritmo, incluyendo el<br />

preprocesamiento del patrón, es O(n+m).<br />

7.6.3 Algoritmo de Boyer-Moore<br />

Hasta el momento, los algoritmos de búsqueda en texto siempre<br />

comparan el patrón con el texto de izquierda a derecha. Sin embargo,<br />

suponga que la comparación ahora se realiza de derecha a izquierda:<br />

si hay una discrepancia en el último carácter del patrón y el carácter<br />

del texto no aparece en todo el patrón, entonces éste se puede deslizar<br />

m posiciones sin realizar niguna comparación extra. En particular, no<br />

fue necesario comparar los primeros m-1 caracteres del texto, lo cual<br />

indica que podría realizarse una búsqueda en el texto con menos de n<br />

comparaciones; sin embargo, si el carácter discrepante del texto se<br />

encuentra dentro del patrón, éste podría desplazarse en un número<br />

menor de espacios.<br />

Página 92


El método descrito es la base del algoritmo Boyer-Moore, del cual se<br />

estudiarán dos variantes: Horspool y Sunday.<br />

Boyer-Moore-Horspool (BMH)<br />

El algoritmo BMH compara el patrón con el texto de derecha a<br />

izquierda, y se detiene cuando se encuentra una discrepancia con el<br />

texto. Cuando esto sucede, se desliza el patrón de manera que la letra<br />

del texto que estaba alineada con bm, denominada c, ahora se alinie<br />

con algún bj, con j=0 => no hubo calce.<br />

Página 93


Ejemplo de uso del algoritmo BMH:<br />

Se puede demostrar que el tiempo promedio que toma el algoritmo<br />

BMH es:<br />

donde c es el tamaño del alfabeto (c


¿Es posible generalizar el argumento, es decir, se pueden utilizar<br />

caracteres más adelante en el texto como entrada de la función<br />

siguiente? La respuesta es no, dado que en ese caso puede ocurrir que<br />

se salte un calce en el texto.<br />

Página 95


Capítulo 8<br />

Teoría de Grafos<br />

Un grafo (o multigrafo) , es una estructura muy importante utilizada en<br />

Informática y también en ciertos ramos de Matemáticas, como por ejemplo, en<br />

Investigación de Operaciones. Muchos problemas de difícil resolución, pueden ser<br />

expresados en forma de grafo y consecuentemente resuelto usando algoritmos de<br />

búsqueda y manipulación estándar.<br />

Entre las múltiples aplicaciones que tienen estas estructuras podemos mencionar:<br />

• Modelar diversas situaciones reales, tales como: sistemas de aeropuertos,<br />

flujo de tráfico, etc.<br />

• Realizar planificaciones de actividades, tareas del computador, planificar<br />

operaciones en lenguaje de máquinas para minimizar tiempo de ejecución.<br />

Simplemente, un grafo es una estructura de datos no lineal, que puede ser<br />

considerado como un conjunto de vértices (o nodos, dependiendo de la<br />

bibliografía) y arcos que conectan esos vértices.<br />

Matemáticamente tenemos G=(V, E). Si u y v son elementos de V, entonces un<br />

arco se puede representar por (u,v).<br />

Los grafos también se pueden clasificar como:<br />

• Grafo No Dirigido<br />

• Grafo Dirigido (o Digrafo)<br />

En el primer caso, los arcos no tienen una dirección y, por lo tanto, (u,v) y (v,u)<br />

representan el mismo arco. En un Grafo No Dirigido, dos vértices se dicen<br />

adyacentes si existe un arco que une a esos dos vértices.<br />

En el segundo caso, los arcos tienen una dirección definida, y así (u,v) y (v,u)<br />

entonces representan arcos diferentes. Un Grafo Dirigido (o digrafo) puede<br />

también ser fuertemente conectado si existe un camino desde cualquier vértice<br />

hacia otro cualquier vértice.<br />

Página 96


Ejemplos de grafos (dirigidos y no dirigidos):<br />

G1 = (V1, E1)<br />

V1 = {1, 2, 3, 4} E1 = {(1, 2), (1, 3), (1, 4), (2, 3), (2, 4), (3, 4)}<br />

G2 = (V2, E2)<br />

V2 = {1, 2, 3, 4, 5, 6} E2 = {(1, 2), (1, 3), (2, 4), (2, 5), (3, 6)}<br />

G3 = (V3, E3)<br />

V3 = {1, 2, 3} E3 = {, , }<br />

Gráficamente estas tres estructuras de vértices y arcos se pueden representar<br />

de la siguiente manera:<br />

8.1 Definiciones Básicas<br />

Un grafo puede ser direccional o no direccional.<br />

Un grafo direccional G es un par (V, E):<br />

• V es un conjunto finito de vértices y<br />

• E es un conjunto de aristas que representa una relación binaria sobre<br />

V — E ⊆ V × V.<br />

En un grafo no direccional G = (V, E), el conjunto de aristas E consiste<br />

en pares no ordenados de vértices.<br />

Página 97


Una arista es un conjunto {u, v}, en que u, v ∈ V y u ≠ v, y que<br />

representamos como (u, v).<br />

Si (u, v) es una arista en un grafo:<br />

• dirigido, entonces (u, v) es incidente desde o sale del vértice u y es<br />

incidente a o entra al vértice v;<br />

• no dirigido, entonces (u, v) es incidente sobre u y v.<br />

v es adyacente a u; si el grafo es no dirigido, entonces la relación de<br />

adyacencia es simétrica.<br />

Un camino (o ruta) es una secuencia de vértices v1,...,vn tal que (vi,vi+1 )<br />

pertenece a E. La longitud de un camino es la cantidad de arcos que éste<br />

contiene.<br />

Un cam ino de longitud k desde un vértice u a un vértice u’ en un grafo G =<br />

(V, E) es una secuencia v0, v1, …, vk de vértices tal que:<br />

• u = v0,<br />

• u’ = vk y<br />

• (vi-1, vi) ∈ E para i = 1, 2, …, k.<br />

Si hay un camino p desde u a u’, entonces u’ es alcanzable desde u vía p.<br />

Un camino simple es aquel donde todos sus vértices son distintos. Sólo el<br />

primero y el último pueden coincidir. Un ciclo en un grafo dirigido es el<br />

camino de longitud mayor o igual que 1 donde w1 = wn. Se llamará ciclo<br />

simple si el camino es simple.<br />

Si a={u,v} es una arista de G escribiremos sólo a=uv, y diremos que a une<br />

los vértices u y v o que u y v son extremos de a. Una arista a=uu se llama<br />

bucle. Una arista que aparece repetida en E se llama arista múltiple.<br />

Página 98


Un ciclo es un camino simple y cerrado.<br />

Un grafo es conexo si desde cualquier vértice existe un camino hasta<br />

cualquier otro vértice del grafo.<br />

Un grafo es conexo si para cada par de vértices u y v existe un camino de<br />

u a v. Si G es un grafo no conexo (o disconexo), cada uno de sus subgrafos<br />

conexos maximales se llama componente conexa de G.<br />

Un digrafo D=(V,E) es fuertemente conexo si para todo par de vértices u<br />

y v existe un camino dirigido que va de u a v.<br />

Dado un digrafo D, podemos considerar el grafo G no dirigido que se obtiene<br />

al sustituir cada arco (u,v) por la arista (u,v). Si este grafo es conexo, diremos<br />

que el digrafo D es débilmente conexo.<br />

Se dice que un grafo no dirigido es un árbol si es conexo y acíclico.<br />

En algunos textos llaman grafo al que aquí se denomina grafo simple,<br />

permitiendo la presencia de aristas múltiples en los multigrafos y de bucles<br />

en los seudografos.<br />

Página 99


Dos vértices son adyacentes si son extremos de una misma arista. Dos<br />

aristas son adyacentes si tienen un extremo común. Un vértice y una<br />

arista son incidentes si el vértice es extremo de la arista. Un vértice es<br />

aislado si no tiene otros vértices adyacentes.<br />

Un grafo completo es un grafo simple en el que todo par de vértices está<br />

unido por una arista. Se representa con Kn al grafo completo de n vértices.<br />

Un grafo G=(V,E) se llama bipartito (o bipartido) si existe una partición<br />

de V, V=X U Y, tal que cada arista de G une un vértice de X con otro de Y.<br />

(Se designa por Kr,s al grafo bipartito completo en que |X|= r e |Y|= s,<br />

y hay una arista que conecta cada vértice de X con cada vértice de Y).<br />

El número de vértices de un grafo G es su orden y el número de aristas su<br />

tamaño. Designaremos el orden con n y el tamaño con q y utilizaremos la<br />

notación de grafo (n,q).<br />

Dos grafos G=(V,E) y G’=(V’,E’) son isomorfos si existe una biyección<br />

f:V V’ que conserva la adyacencia. Es decir, ∀ u,v ∈ V, u y v son<br />

adyacentes en G ⇔ f(u) y f(v) son adyacentes en G’.<br />

Un subgrafo de G=(V,E) es otro grafo H=(V’,E’) tal que V’ ⊆ V y E’ ⊆ E. Si<br />

V’ = V se dice que H es un subgrafo generador.<br />

Se llama grado de un vértice v al número de aristas que lo tienen como<br />

extremo, (cada bucle se cuenta, por tanto, dos veces). Se designa por d(v).<br />

Un grafo regular es un grafo simple cuyos vértices tienen todos los<br />

mismos grados.<br />

A la sucesión de los grados de los vértices de G se le denomina sucesión<br />

de grados del grafo G. Una sucesión de enteros no negativos se dice<br />

sucesión gráfica si es la sucesión de grados de un grafo simple.<br />

8.2 Representaciones de Grafos<br />

Existen dos formas de mantener un grafo G en la memoria de una<br />

computadora, una se llama Representación secuencial de G, la cual se<br />

basa en la matriz de adyacencia.<br />

La otra forma, es la llamada Representación enlazada de G y se basa en<br />

listas enlazadas de vecinos. Independientemente de la forma en que se<br />

mantenga un grafo G en la memoria de una computadora, el grafo G<br />

normalmente se introduce en la computadora por su definición formal: Un<br />

conjunto de nodos y un conjunto de aristas.<br />

Página 100


8.2.1 Matriz y Lista de Adyacencia<br />

Dado un grafo G = (V, E), podemos representar un grafo a través de una<br />

matriz de dimensión V x V, donde el contenido podrá ser de números<br />

binarios (0;1) o de número enteros (-1;0;1).<br />

Para ejemplificar, analicemos el grafo G:<br />

La cardinalidad de vértices del grafo G es 6, por tanto, para su<br />

representación, deberemos tener una matriz de adyacencias 6x6. Utilizando<br />

valores binarios, podemos representarla de esta forma:<br />

ma[i,j] =<br />

Conforme observamos, la matriz de adyacencias está formada siguiendo la<br />

siguiente regla: ma[i,j] = 1, si i es adyacente a j, y 0 en caso<br />

contrario. Como estamos trabajando con un dígrafo, debemos establecer<br />

cual es el origen y cual es el destino. En el ejemplo presentado, el origen está<br />

definido por la letra indicadora de línea. Por ejemplo, A está conectado con<br />

B, más no en sentido contrario, por eso ma[A,B] es 1 y ma[B,A] es 0.<br />

Para resolver esto, podemos hacer una representación alternativa: ma[i,j]<br />

= -1 si i es origen de adyacencia con j, 1 si i es el destino de<br />

adyacencia con j, y es 0 para los demás vértices no envueltos en la<br />

adyacencia . Esto es sintetizado en la siguiente matriz:<br />

Página 101


ma[i,j] =<br />

Como ejemplo, observemos el elemento ma[A,B] , ma[A,C] e ma[A,D] que<br />

poseen valor -1. Esto indica que A es origem de arcos para C , D y E. También<br />

observemos ma[F,D] y ma[F,E] , con valor 1, indicando que F recibe los<br />

arcos de D y E.<br />

A pesar de la metodología empleada, se observa que para una aplicación<br />

dada que necesite de alteración del grafo, sería inadecuada la representación<br />

a través de estructuras fijas, exigiendo, entonces, estructuras dinámicas.<br />

Lista de Adyacencias<br />

Para que sea pos ible remodelar un grafo en tiempo de ejecución , se torna<br />

necesaria la utilización dinámica de su representación. Por eso, la<br />

representación de adyacencias entre vértices puede ser hecha a través de<br />

listas lineales.<br />

Su construcción es realizada por un vector dinámico con listas encadenadas<br />

formando un índice de vértices. De cada elemento de índice parte una lista<br />

encadenada descr ibiendo los vértices adyacentes conectados.<br />

Como ejemplo, para el grafo G presentado anteriormente, visualizaremos la<br />

siguiente representación:<br />

Página 102


La lista encadenada es formada por nodos que contienen el dato del vértice<br />

(letra) y el puntero para el vértice adyacente a lo indicado en el índice (vector<br />

descriptor de vértices). Eventualmente los nodos pueden exigir otros<br />

campos, tales como marcas de visita al vértice, datos adicionales para<br />

procesamiento de la secuencia del grafo, etc.<br />

Esta es la forma de representación más flexible para la representación de<br />

grafos. Obviamente que aplicaciones específicas permitirán el uso de otras<br />

formas de representación. Cabe aquí destacar que es posible la descripción<br />

de grafos a través de sus aristas, lo que puede demandar una matriz para su<br />

descripción.<br />

La suma de las longitudes de todas las listas es ⏐E⏐, si G es direccional, y<br />

2⏐E⏐ si G es no direccional — la cantidad de memoria necesaria es siempre<br />

O(max(V, E)) = O(V + E).<br />

La matriz de adyacencias de G = (V, E) supone que los vértices están<br />

numerados 1, 2, …, ⏐V⏐ arbitrariamente, y consiste en una matriz A = (a ij )<br />

de ⏐V⏐×⏐V⏐, tal que:<br />

⎧ 1 si (i, j) ∈E,<br />

aij = ⎨<br />

⎩ 0 en otro caso.<br />

Esta representación requiere Θ(V 2 ) memoria, independientemente del<br />

número de aristas en G.<br />

La matriz de adyacencia siempre es simétrica porque aij = aji . La lista<br />

de Adyacencia es una lista compuesta por vértices y una sublista<br />

conteniendo las aristas que salen de él.<br />

En el caso de las listas de adyacencia el espacio ocupado es O(V + E), muy<br />

distinto del necesario en la matriz de adyacencia, que es de O(V 2 ). La<br />

representación por listas de adyacencia, por tanto, será más adecuada para<br />

grafos dispersos.<br />

8.2.2 Matriz y Lista de Incidencia<br />

Este tipo de matriz representa un grafo a partir de sus aristas. Como exige<br />

muchas veces la utilización de una matriz mayor dado que el método de la<br />

matriz de adyacencias, no esta tan utilizada en cuanto a aquella. La matriz<br />

alojada deberá tener dimensiones V x E.<br />

El princ ipio de esta representación está en la regla: mi[i,j] = 1 si el vértice<br />

i incide con la arista j, y 0 en caso contrario . Ejemplificando a partir<br />

del grafo G anteriormente presentado, tendremos una matriz:<br />

Página 103


mi[i,j] =<br />

8.3 Recorridos de Grafos<br />

En muchas aplicaciones es necesario visitar todos los vértices del grafo a<br />

partir de un nodo dado. Algunas aplicaciones son:<br />

• Encontrar ciclos<br />

• Encontrar componentes conexas<br />

• Encontrar árboles cobertores<br />

Hay dos enfoques básicos:<br />

• Recorrido (o búsqueda) en amplitud (breadth-first search):<br />

Se visita a todos los vecinos directos del nodo inicial, luego a los<br />

vecinos de los vecinos, etc.<br />

• Recorrido (o búsqueda) en profundidad (depth-first search):<br />

La idea es alejarse lo más posible del nodo inicial (sin repetir nodos),<br />

luego devolverse un paso e intentar lo mismo por otro camino.<br />

8.3.1 Recorridos en Amplitud<br />

La Búsqueda en Amplitud (BFS) es uno de los algoritmos más simples para<br />

recorrer un grafo y es el modelo de muchos algoritmos sobre grafos.<br />

Dado un grafo G = (V, E) y un vértice de partida s, BFS explora<br />

sistemáticamente las aristas de G para descubrir todos los vértices<br />

alcanzables desde s:<br />

• Calcula la distancia — mínimo número de aristas — de s a cada<br />

vértice alcanzable.<br />

• Produce un árbol con raíz s que contiene todos los vértices<br />

alcanzables.<br />

• Para cualquier vértice v alcanzable desde s, la ruta de s a v en el<br />

árbol contiene el mínimo número de aristas — es una ruta más<br />

corta.<br />

• Funciona en grafos direccionales y no direccionales.<br />

Página 104


BFS expande la frontera entre vértices descubiertos y no descubiertos<br />

uniformemente en toda la amplitud de la frontera.<br />

Descubre todos los vértices a distancia k antes de descubrir cualquier vértice<br />

a distancia k + 1.<br />

BFS pinta cada vértice BLANCO, PLOMO o NEGRO, según su estado:<br />

Todos los vértices empiezan BLANCOs.<br />

Un vértice es descubierto la primera vez que es encontrado, y se pinta<br />

PLOMO.<br />

Cuando se ha descubierto todos los vértices adyacentes a un vértice<br />

PLOMO, éste se pinta NEGRO.<br />

Los vértices PLOMOs pueden tener vértices adyacentes BLANCOs —<br />

representan la frontera entre vértices descubiertos y no descubiertos.<br />

BFS construye un árbol de amplitud:<br />

• Inicialmente, el árbol sólo contiene su raíz — el vértice s.<br />

• Cuando se descubre un vértice BLANCO v mientras se explora la lista<br />

de adyacencias de un vértice u, v y la arista (u, v) se agregan al árbol,<br />

y u se convierte en el predecesor o padre de v en el árbol.<br />

En la siguiente versión de BFS:<br />

• El grafo se representa mediante listas de adyacencias.<br />

• Para cada vértice u, se almacena: el color en color[u], el predecesor<br />

en π [u], y la distancia desde s en d[u].<br />

• El conjunto de vértices PLOMOs se maneja en la cola Q.<br />

El árbol de amplitud o grafo predecesor de G se define como<br />

G π = (V π, E π), en que:<br />

V π = {v ∈ V : π [v] ≠ NIL} ∪ {s}, y<br />

E π = {( π [v], v) ∈ E : v ∈ V π – {s}};<br />

Las aristas en E π se llaman aristas de árbol.<br />

Página 105


BFS(G, s) :<br />

for each u ∈ V – {s} do<br />

color[u] ← BLANCO<br />

d[u] ← ¥<br />

π [u] ← NIL<br />

color[s] ← PLOMO;<br />

d[s] ← 0;<br />

π [s] ← NIL<br />

Q ← {s}<br />

while Q ≠ Ø do<br />

u ← head[Q]<br />

for each v ˛ α [u] do<br />

if color[v] = BLANCO then<br />

color[v] ← PLOMO<br />

d[v] ← d[u] + 1<br />

π [v] ← u<br />

ENQUEUE(Q, v)<br />

<strong>DE</strong>QUEUE(Q)<br />

color[u] ← NEGRO<br />

El tiempo total de ejecución de BFS es O(V + E).<br />

8.3.2 Recorridos en Profundidad<br />

A medida que recorremos el grafo, iremos numerando correlativamente los<br />

nodos encontrados (1,2,...). Suponemos que todos estos números son cero<br />

inicialmente, y utilizamos un contador global n, también inicializado en<br />

cero.<br />

La Búsqueda en Profundidad (DFS) busca más adentro en el grafo mientras<br />

sea posible:<br />

• Las aristas se exploran a partir del vértice v más recientemente<br />

descubierto.<br />

• Cuando todas las aristas de v han sido exploradas, la búsqueda<br />

retrocede para explorar aristas que salen del vértice a partir del cual<br />

se descubrió v.<br />

• Continuamos hasta que se ha descubierto todos los vértices<br />

alcanzables desde el vértice de partida.<br />

Página 106


• Si quedan vértices no descubiertos, se elige uno como nuevo vértice<br />

de partida y se repite la búsqueda.<br />

DFS construye un subgrafo predecesor:<br />

• Cuando descubre un vértice v durante la exploración de la lista de<br />

adyacencias de un vértice u, asigna u al predecesor π [v] de v.<br />

• Este subgrafo o bosque de profundidad puede constar de varios<br />

árboles de profundidad , porque la búsqueda puede repetirse desde<br />

varios vértices de partida.<br />

El subgrafo predecesor se define como como G π = (V, E π ):<br />

Eπ = {( π [v], v) : v ∈ V ∧ π [v] ≠ NIL}<br />

las aristas en Eπ se llaman aristas de árbol.<br />

DFS pinta cada vértice BLANCO, PLOMO o NEGRO:<br />

• Todos los vértices empiezan BLANCOs.<br />

• Cuando un vértice es descubierto, se pinta PLOMO.<br />

• Cuando un vértice es terminado — se ha examinado toda la lista de<br />

adyacencias del vértice — se pinta NEGRO.<br />

• Se garantiza que cada vértice forma parte de sólo un árbol de<br />

profundidad — éstos son disjuntos.<br />

En la siguiente versión recursiva de DFS:<br />

• G puede ser direccional o no direccional.<br />

• A cada vértice u, se le asocia los tiempos<br />

• d[u] al descubrirlo — cuando u se pinta PLOMO — y<br />

• f[u] al terminar la exploración de α[u] — cuando u se pinta NEGRO.<br />

DFS(G) :<br />

for each u ∈ V do<br />

t ← 0<br />

color[u] ← BLANCO<br />

π [u] ← NIL<br />

for each u ∈ V do<br />

if color[u] = BLANCO then<br />

VISITAR(u)<br />

VISITAR(u) :<br />

color[u] ← PLOMO<br />

Página 107


t ← t + 1<br />

d[u] ← t<br />

8.4 Grafos con Pesos<br />

for each v ∈ α [u] do<br />

if color[v] = BLANCO then<br />

π [v] ← u<br />

VISITAR(v)<br />

color[u] ← NEGRO<br />

t ← t + 1<br />

f[u] ← t<br />

El tiempo total de ejecución de DFS es Θ(V + E).<br />

Un grafo con peso (o grafo con costo) es un grafo G más una función de<br />

peso o costo:<br />

f: E(G) → R<br />

En general, salvo mención expresa en contrario, consideramos sólo grafos<br />

con pesos no negativos en las aristas.<br />

Definición: Sea G un grafo con peso, y sea H un subgrafo de G. El costo<br />

total o peso total de H es:<br />

8.5 Árboles<br />

ω(H) = ∑ ω(e)<br />

e ∈ H<br />

En esta parte del curso vamos estudiar un caso particular de la Teoría de<br />

Grafos que son los árboles. Primero, vamos definir el concepto de árbol.<br />

Simplemente, un árbol es un grafo conexo sin ciclos. Notar que como un<br />

grafo requiere contener un mínimo de un vértice, un árbol contiene un<br />

mínimo de un vértice. También tenemos que un árbol es un grafo simple,<br />

puesto arcos y aristas paralelas forman ciclos. Un grafo no conexo que no<br />

contiene ciclos será denominado bosque. En ese caso tenemos un grafo<br />

donde cada componente es un árbol.<br />

Página 108


Árboles cobertores<br />

Dado un grafo G no dirigido, conexo, se dice que un subgrafo T de G es un<br />

árbol cobertor si es un árbol y contiene el mismo conjunto de nodos que G.<br />

Todo recorrido de un grafo conexo genera un árbol cobertor, consistente del<br />

conjunto de los arcos utilizados para llegar por primera vez a cada nodo.<br />

Para un grafo dado pueden existir muchos árboles cobertores. Si<br />

introducimos un concepto de "peso" (o "costo") sobre los arcos, es<br />

interesante tratar de encontrar un árbol cobertor que tenga costo mínimo.<br />

8.6 Árbol Cobertor Mínimo<br />

En general estamos interesados en el caso en que H = T es un árbol de<br />

cobertura de G. Ya que G es finito, la función w(t) alcanza su mínimo en<br />

algún árbol T. Nos interesa hallar algún árbol T que minimice el costo total;<br />

dicho grafo no tiene por qué se único.<br />

En esta sección veremos dos algoritmos para encontrar un árbol cobertor<br />

mínimo para un grafo no dirigido dado, conexo y con costos asociados a los<br />

arcos. El costo de un árbol es la suma de los costos de sus arcos.<br />

8.6.1 Algoritmo de Kruskal<br />

Este es un algoritmo del tipo "avaro" ("greedy"). Comienza inicialmente con<br />

un grafo que contiene sólo los nodos del grafo original, sin arcos. Luego, en<br />

cada iteración, se agrega al grafo el arco más barato que no introduzca un<br />

ciclo. El proceso termina cuando el graf o está completamente conectado.<br />

En general, la estrategia "avara" no garantiza que se encuentre un óptimo<br />

global, porque es un método "miope", que sólo optimiza las decisiones de<br />

corto plazo. Por otra parte, a menudo este tipo de métodos proveen buenas<br />

heurísticas, que se acercan al óptimo global.<br />

Página 109


En este caso, afortunadamente, se puede demostrar que el método "avaro"<br />

logra siempre encontrar el óptimo global, por lo cual un árbol cobertor<br />

encontrado por esta vía está garantizado que es un árbol cobertor mínimo.<br />

Una forma de ver este algoritmo es diciendo que al principio cada nodo<br />

constituye su propia componente conexa, aislado de todos los demás nodos.<br />

Durante el proceso de construcción del árbol, se agrega un arco sólo si sus<br />

dos extremos se encuentran en componentes conexas distintas, y luego de<br />

agregarlo esas dos componentes conexas se fusionan en una sola.<br />

Descripción del Algoritmo<br />

Entrada: Un grafo G con costos, conexo.<br />

Idea: Mantener un subgrafo acíclico cobertor H de modo que en cada paso<br />

exista al menos un árbol de cobertura de costo mínimo T, tal que H sea<br />

subgrafo de T. Considérense las aristas de G ordenadas por costo en forma<br />

no decreciente, los empates se rompen arbitrariamente.<br />

Inicialización: E(H) = Ø<br />

Iteración: Si la siguiente arista e de G (en el orden dado) une dos<br />

componentes de H, entonces agregamos e a E(H). Si no, la ignoramos.<br />

Terminación: Termínese el algoritmo cuando H sea conexo o cuando se<br />

acaben las aristas de G.<br />

función Kruskal (G = : grafo; longitud: A → R + ): conjunto de<br />

aristas<br />

{Iniciación}<br />

Ordenar A por longitudes crecientes<br />

n ← el número de nodos que hay en N<br />

T ← ∅ {contendrá las aristas del árbol de recubrimiento mínimo}<br />

Iniciar n conjuntos, cada uno de los cuales contiene un elemento<br />

distinto de N<br />

{bucle greedy }<br />

repetir<br />

e ← {u, v} ← arista más corta aun no considerada<br />

compu ← buscar(u)<br />

compv ← buscar(v)<br />

si compu ≠ compv entonces<br />

fusionar(compu, compv)<br />

T ← T ∪ {e}<br />

hasta que T contenga n - 1 aristas<br />

devolver T<br />

Página 110


Complejidad del Algoritmo<br />

El algoritmo requiere que las |E| aristas estén ordenadas previamente. Esto<br />

toma O(|E| log(|E|)) operaciones.<br />

El proceso de examinar las |E| aristas toma a lo más O(|E|) iteraciones; de<br />

ellas, n-1 requieren actualizar la lista de componentes a las que pertenecen<br />

los n vértices. Esto puede ser hecho en un total de O(nlog2n) operaciones, o<br />

incluso en forma más astuta en O(na (n)), donde:<br />

Ejemplo:<br />

8.6.2 Algoritmo de Prim<br />

La característica principal del algoritmo de Kruskal es que éste selecciona<br />

las mejores aristas sin preocuparse de las conexiones con las aristas<br />

selecionadas antes. El resultado es una proliferación de árboles que<br />

eventualmente se juntan para formar un árbol.<br />

Ya que sabemos que al final tenemos que producir un solo árbol, por qué no<br />

intentar hacer como que el árbol crezca naturalmente hasta la obtención de<br />

un árbol generador mínimo? Así, la próxima arista seleccionada sería<br />

siempre una que se conecta al árbol que ya existe.<br />

Esa es la idea del algoritmo de Prim. Se caracteriza por hacer la selección<br />

en forma local, partiendo de un nodo seleccionado y construyendo el árbol<br />

en forma ordenada.<br />

Página 111


Dado que el arco es seleccionado de aquellos que parten del árbol ya<br />

construído, la viabilidad está asegurada. También se puede demostrar que el<br />

algoritmo de Prim es correcto, es decir que devuelve un árbol de<br />

recubrimiento minimal en todos los casos.<br />

Al inicio el conjunto B cont iene un vértice arbitrario. En cada paso, el<br />

algoritmo considera todas las aristas que tocan a B y selecciona a la de<br />

menor peso. Después, el algoritmo aumenta en B el vértice ligado por esa<br />

arista que no estaba en B. El proceso continúa hasta que B contenga a todos<br />

los vértices de G.<br />

Una descripción del algoritmo se describe a continuación:<br />

función Prim(G = (N, A): grafo): conjunto de aristas<br />

T := {}<br />

B := Un vértice de G<br />

Mientras B no contenga todos los vértices<br />

(u,v) := arista de menor peso tal que u V - B e v B<br />

T := T U {(u,v)}<br />

B := B U {u}<br />

Retornar T<br />

Complejidad del Algoritmo<br />

Para implementar este algoritmo eficientemente, podemos mantener una<br />

tabla donde, para cada nodo de V-A, almacenamos el costo del arco más<br />

barato que lo conecta al conjunto A. Estos costos pueden cambiar en cada<br />

iteración.<br />

Si se organiza la tabla como una cola de prioridad, el tiempo total es O(m<br />

log n). Si se deja la tabla desordenada y se busca linealmente en cada<br />

iteración, el costo es O(n 2 ). Esto último es mejor que lo anterior si el grafo es<br />

denso, pero no si está cerca de ser un grafo completo.<br />

8.7 Distancias Mínimas en un Grafo Dirigido<br />

Dado un grafo (o digrafo) ponderado y dos vértices s y t se quiere hallar<br />

d(s,t) y el camino con dicha longitud. Los primeros algoritmos que<br />

presentamos obtienen todos los caminos de longitud mínima desde un<br />

vértice dado s al resto de vértices del grafo. El último algoritmo resuelve el<br />

problema para un par cualquiera de vértices de G.<br />

Si el vértice u se encuentra en un camino C de longitud mínima entre los<br />

vértices s y z entonces, la parte de C comprendida entre los vértices s y u<br />

es un camino de longitud mínima entre s y u. Por tanto, el conjunto de<br />

caminos mínimos desde s a los restantes vértices del grafo G es un árbol,<br />

llamado el árbol de caminos mínimos desde s.<br />

Página 112


Definición: En un grafo con pesos, la distancia entre dos vértices es:<br />

d(u,v) = min{ w(P) : P es un camino que une u y v }.<br />

Note que en particular, esto coincide con la definición usual de distancia, si<br />

consideramos la función de peso w donde w(e)= 1 para todo e ∈ E(G).<br />

Nos interesa hallar la distancia más corta entre dos vértices dados u y v, o<br />

bien hallar todas las distancias más cortas entre un vértice dado u y todos<br />

los otros vértices de G.<br />

En general, no es mucho más eficiente hallar la ruta más corta entre u y v<br />

que hallar todas las rutas más cortas entre u y los otros vértices. Para esto<br />

usamos el algoritmo de Dijkstra.<br />

8.7.1 Algoritmo de Dijkstra<br />

La idea básica del algoritmo es la siguiente: Si P es un camino de longitud<br />

mínima sfiz y P contiene al vértice v, entonces la parte sfi v de P es<br />

también camino de longitud mínima de s a v. Esto sugiere que si deseamos<br />

determinar el camino óptimo de s a cada vértice z de G, podremos hacerlo<br />

en orden creciente de la distancia d(s,z).<br />

Descripción del algoritmo<br />

Entrada: Un grafo (o digrafo) G con pesos no negativos, y un vértice de<br />

partida u. El peso de una arista xy es w(xy); si xy no es una arista diremos<br />

que w(xy) = ∞.<br />

Idea: Mantener un conjunto S de vértices a los que conocemos la distancia<br />

más corta desde u, junto con el predecesor de cada vértice de S en dicha<br />

ruta. Para lograr esto, mantenemos una distancia tentativa t(z) desde u a<br />

cada z ∈ V(G). Si z ∉ S, t(z) es la distancia más corta encontrada hasta<br />

ahora entre u y z. Si z ∈ S, t(z) es la distancia más corta entre u y z. En<br />

cualquier caso, pred[z] es el predecesor de z la ruta u → z en cuestión.<br />

Inicialización: S = {u}, t(u)=0, t(z) = w(uz) y pred[z] = u para z ≠ u.<br />

Iteración: Sea v ∈ V(G) – S, tal que:<br />

t(z) = min{t(v) : v∈ V(G) – S }<br />

Agrégase v a S.<br />

Para z ∈ V(G) – S actualícese t(z) = min{t(z),t(v)+w(vz) }.<br />

Página 113


Si cambia t(z), cámbiese pred[z] a v.<br />

Terminación: Termínese el algoritmo cuando S = V(G) o cuando t(z) = ∞<br />

para todo z ∈ V(G) – S .<br />

Al terminar, la distancia más corta entre u y v está dada por: d(u,v) = t(v).<br />

Análisis de la complejidad<br />

En cada iteración se añade un vértice a T, luego el número de iteraciones<br />

es n. En cada una se elige una etiqueta mínima, la primera vez entre n-1, la<br />

segunda entre n-2, ..., luego la complejidad total de estas elecciones es<br />

O(n 2 ). Por otra parte cada arista da lugar a una actualización de etiqueta,<br />

que se puede hacer en tiempo constante O(1), en total pues O(q). Por tanto<br />

la complejidad total del algoritmo es O(n 2 ).<br />

8.7.2 Algoritmo de Ford<br />

Es una variante del algoritmo de Dijkstra que admite la asignación de<br />

pesos negativos en los arcos, aunque no permite la existencia en el digrafo<br />

de ciclos de peso negativo.<br />

Descripción del algoritmo<br />

Entrada: Un digrafo ponderado con pesos no negativos en los arcos, un<br />

vértice s∈V. El peso del arco uv se indica por w(uv), poniendo w(uv)= ∞<br />

si uv no es arco.<br />

Salida: La distancia desde s a cada vértice del grafo.<br />

Clave: Mejorar en cada paso las etiquetas de los vértices, t(u).<br />

Inicialización: Sea T={s}, t(s)=d(s,s)=0, t(z)= ∞ para z ≠ s.<br />

Iteración: Mientras existan arcos e=xz para los que t(z) > t(x) + w(e)<br />

actualizar la etiqueta de z a min{t(z), t(x)+w(xz)}.<br />

Análisis de la complejidad<br />

En primer lugar debemos observar que cada arco puede considerarse<br />

varias veces. Empecemos ordenando los arcos del digrafo D siendo este el<br />

orden en que se considerarán los arcos en el algoritmo. Después de la<br />

primera pasada se repite el proceso hasta que en una pasada completa no<br />

se produzca ningún cambio de etiquetas. Si D no contiene ciclos negativos<br />

puede demostrarse que, si el camino mínimo s u contiene k arcos<br />

entonces, después de k pasadas se alcanza la etiqueta definitiva para u.<br />

Como k ≤ n y el número de arcos es q, resulta que la complejidad del<br />

Página 114


algoritmo de Ford es O(qn). Además podemos detectar un ciclo negativo si<br />

se produce una mejora en las etiquetas en la pasada número n.<br />

8.7.3 Algoritmo de Floyd-Warshall<br />

A veces no es suficiente calcular las distancias con respecto a un vértice s,<br />

si no que necesitamos conocer la distancia entre cada par de vértices.<br />

Para ello se puede aplicar reiteradamente alguno de los algoritmos<br />

anteriores, variando el vértice s de partida. Así tendríamos algoritmos de<br />

complejidad O(n 3 ) (si usamos el algoritmo de Dijkstra) u O(n 2 q) (si<br />

usamos el algoritmo de Ford).<br />

A continuación se describe un algoritmo, debido a Floyd y Warshall, con<br />

una estructura sencilla, que permite la presencia de arcos de peso negativo<br />

y que resuelve el mismo problema. (Naturalmente los ciclos de peso<br />

negativo siguen estando prohibidos).<br />

La idea básica del algoritmo es la construcción de una sucesión de matrices<br />

W 0 , W 1 , ..., W n , donde el elemento ij de la matriz W k nos indique la<br />

longitud del camino mínimo ij utilizando como vértices interiores del<br />

camino los del conjunto {v1, v2, ..., vk}. La matriz W 0 es la matriz de pesos<br />

del digrafo, con w 0 ij=w(ij) si existe el arco i j, w 0 ii=0 y w 0 ij=∞ si no existe<br />

el arco i j.<br />

Para aplicar este algoritmo, los nodos se numeran arbitrariamente 1,2,...n. Al<br />

comenzar la iteración k-ésima se supone que una matriz D[i,j] contiene la<br />

distancia mínima entre i y j medida a través de caminos que pasen sólo por<br />

nodos intermedios de numeración


Para inicializar la matriz de distancias, se utilizan las distancias obtenidas a<br />

través de un arco directo entre los pares de nodos (o infinito si no existe tal<br />

arco). La distancia inicial entre un nodo y sí mismo es cero.<br />

Descripción del algoritmo<br />

Entrada: Un digrafo ponderado sin ciclos de peso negativos. El peso del<br />

arco uv se indica por w(uv), poniendo w(uv)= ∞ si uv no es arco.<br />

Salida: La distancia entre dos vértices cualesquiera del grafo.<br />

Clave: Construimos la matriz W k a partir de la matriz W k-1 observando<br />

que<br />

Iteración: Para cada k=1,...,n, hacer ∀i,j=1,...,n<br />

El elemento ij de la matriz W n nos da la longitud de un camino mínimo<br />

entre los vértices i y j.<br />

Análisis de la complejidad<br />

Se deben construir n matrices de tamaño nxn y cada elemento se halla en<br />

tiempo constante. Por tanto, la complejidad del algoritmo es O(n 3 )<br />

El algoritmo de Floyd-Warshall es mucho más eficiente desde el punto de<br />

vista de almacenamiento dado que puede ser implementado una vez<br />

atualizado la distancia de la matriz con cada elección en k; no hay ninguna<br />

necesidad de almacenar matrices diferentes. En muchas aplicaciones<br />

específicas, es más rápido que cualquier versión de algoritmo de Dijkstra.<br />

Ejemplo:<br />

Página 116


V1<br />

Valores Iniciales de d(i,j)<br />

V1 V2 V3 V4 V5 V6<br />

0 1 3 X 1 4<br />

V2 1 0 1 X 2 X<br />

V3 3 1 0 3 X X<br />

V4 X X 3 0 1 2<br />

V5 1 2 X 1 0 2<br />

V6 4 X X 2 2 0<br />

Obs: X significa que un vértice cualquiera no tiene ligazón directa con otro.<br />

V1<br />

Menor Camino<br />

V1 V2 V3 V4 V5 V6<br />

0 1 2 2 1 3<br />

V2 1 0 1 3 2 4<br />

V3 2 1 0 3 3 5<br />

V4 2 3 3 0 1 2<br />

V5 1 2 3 1 0 2<br />

V6 3 4 5 2 2 0<br />

Página 117


Capítulo 9<br />

Complejidad Computacional<br />

9.1 Introducción<br />

La complejidad computacional considera globalmente todos los posibles<br />

algoritmos para resolver un problema dado.<br />

Estamos interesados en la distinción que existe entre los problemas que<br />

pueden ser resueltos por un algoritmo en tiempo polinómico y los<br />

problemas para los cuales no conocemos ningún algoritmo polinómico, es<br />

decir, el mejor es no-polinómico.<br />

La teoría de la NP-Completitud no proporciona un método para obtener<br />

algoritmos de tiempo polinómico, ni dice que que estos algoritmos no<br />

existan. Lo que muestra es que muchos de los problemas para los cuales no<br />

conocemos algoritmos polinómicos están computacionalmente<br />

relacionados.<br />

9.2 Algoritmos y Complejidad<br />

En los capítulos anteriores, hemos visto que los algoritmos cuya complejidad<br />

es descrita por una función polinomial pueden ser ejecutados para entradas<br />

grandes en una cantidad de tiempo razonable, mientras que los algoritmos<br />

exponenciales son de poca utilidad excepto para entradas pequeñas.<br />

En esta sección se tratarán los problemas cuya complejidad es descrita por<br />

funciones exponenciales, problemas para los cuales el mejor algoritmo<br />

conocido requeriría de muchos años o centurias de tiempo de cálculo para<br />

entradas moderadamente grandes.<br />

De esta forma se presentarán definiciones que pretenden distinguir entre los<br />

problemas tratables (aquellos que no son tan duros) y los problemas<br />

intratables (duros o que consumen mucho tiempo). La mayoría de estos<br />

problemas ocurren como problemas de optimización combinatoria.<br />

9.3 Problemas NP Completos<br />

Definición Formal de la Clase NP<br />

La definición formal de NP-completo usa reducciones, o transformaciones,<br />

de un problema a otro.<br />

Página 118


Una definición formal de NP-completo es:<br />

Un problema P es NP-completo si éste está en NP y para cada otro<br />

problema P ’ en NP, P ’ µ P<br />

Del teorema anterior y la definición de NP completo se deduce el siguiente<br />

teorema:<br />

Si cualquier problema NP-completo esta en P, entonces P=NP<br />

Este teorema indica, por un lado, que tan valioso sería encontrar un<br />

algoritmo polinomialmente acotado para cualquier problema NP-completo<br />

y, por otro, que tan improbable es que tal algoritmo exista pues hay muchos<br />

problemas en NP para los cuales han sido buscados algoritmos<br />

polinomialmente acotados sin ningún éxito.<br />

Como se señaló antes, el primer problema de decisión que se propuso como<br />

un problema NP-completo es el satisfactibilidad de la lógica<br />

proposicional. Todos los problemas NP para los cuales se puede comprobar<br />

que pueden ser reducibles al problema de satisfctibilidad, son NPcompletos.<br />

Así tenemos que los siguientes problemas son NP-completos: rutas y<br />

circuitos de Hamilton, asignación de trabajos con penalizaciones, el<br />

agente viajero, el problema de la mochila.<br />

Actualmente para probar que un problema es NP-completo, es suficiente con<br />

probar que algún otro problema NP-completo es polinomialmente reducible<br />

a éste debido a que la relación de reducibilidad es transitiva. La lógica de lo<br />

dicho es la siguiente:<br />

• Si P’ es NP-completo, entonces todos los problemas en NP µ P’<br />

• Si se demuestra que P’ µ P<br />

• Entonces todos los problemas en NP µ P<br />

• Por lo tanto, P es NP-completo<br />

Supóngase un problema P, P* es el complemento del problema P si el<br />

conjunto de símbolos en la cadena s de la fase de adivinación del algoritmo<br />

no determinístico que codifican las instancias si de P* son exactamente<br />

aquellas que no están codificando las instancias si de P.<br />

Por ejemplo, el problema del circuito Hamiltoniano es: dado un grafo<br />

G=(V, E), es G Hamiltoniano. Y el complemento del problema del circuito<br />

Hamiltoniano es: dado un grafo G=(V, E), es G no Hamiltoniano.<br />

Supóngase un problema P en P, entonces hay un algoritmo acotado<br />

polinomialmente que resuelve P. Un algoritmo acotado polinomialmente<br />

que solucione el complemento de P es exactamente el mismo algoritmo,<br />

Página 119


únicamente con la sustitución de no por si cuando se obtiene una solución<br />

afirmativa y viceversa. El siguiente teorema expresa lo mencionado<br />

Si P es un problema en P, entonces el complemento P * de P está también en<br />

P.<br />

Los mismos argumentos no pueden ser aplicados para demostrar que un<br />

problema en NP está también en NP. Esto motiva la siguiente definición:<br />

La clase co. NP es la clase de todos los problemas que son complemento de<br />

problemas en NP.<br />

Decir que el complemento de todos los problemas en NP está también en NP<br />

es equivalente a decir que NP = co-NP. Sin embargo, hay razones para<br />

creer que NP¹ co-NP. La evidencia es tan circunstancial como la que se<br />

estableció para la conjetura de que P¹ NP: Muchos investigadores han<br />

tratado por largo tiempo, sin éxito, de construir pruebas sucintas para el<br />

complemento del problema del circuito Hamiltoniano, así como para<br />

muchos otros problemas. Sin embargo, puede ser mostrado (como con P¹<br />

NP) que si la conjetura es verdadera, está en los problemas NP-completo<br />

testificar su validez.<br />

Si el complemento de un problema NP-completo está en NP, entonces NP =<br />

co-NP.<br />

Así de todos los problemas en NP, Los problemas NP-completo son<br />

aquellos con complementos de estar en NP. Contrariamente, si el<br />

complemento de un problema en NP está también en NP, esto es evidencia<br />

de que el problema no está en NP.<br />

Cuando todos los problemas en NP se transforman polinomialmente al<br />

problema P, pero no es posible demostrar P ⊆ NP, se dice que P es tan difícil<br />

como cualquier problema en NP y por lo tanto NP-hard.<br />

NP-hard es usado también en la literatura para referirse a los problemas de<br />

optimización de los cuales su problema de decisión asociado es NP-completo<br />

Problemas que pueden ser solucionados por algoritmos cuyas operaciones<br />

pueden ser confinadas dentro de una cantidad de espacio que está acotado<br />

por un polinomio del tamaño de la entrada, pertenecen a la clase PSPACE.<br />

P, NP y co-NP son subconjuntos de PSPACE.<br />

Se dice que un problema es PSPACE-completo si éste está en PSPACE y<br />

todos los otros problemas en PSPACE son polinomialmente reducibles a él.<br />

Página 120


9.4 Problemas Intratables<br />

Garey & Johnson, plantean una situación de intratabilidad interesante que<br />

describimos a través del siguiente caso:<br />

Si trabajas para una compañía que está pensando entrar a una nueva era<br />

globalizadora de gobierno y tu jefe te propone que obtengas un método para<br />

determinar si o no cualquier conjunto dado de especificaciones para un<br />

nuevo componente pueden ser satisfechas de alguna manera y, si es así, se<br />

deberá construir el diseño que satisfaga dichas expecificaciones. La realidad<br />

es que después de un tiempo comprobarás que no hay manera de obtener tal<br />

método. De modo que tendrás dos alternativas:<br />

1. Renunciar para evitar la pena de que te corran por inepto.<br />

2. Esperar a que te corran por inepto. Sería muy penoso, pero tendrías<br />

que confesar que no pudiste resolver el problema por falta de<br />

capacidad.<br />

Para evitar la situación anterior tú podrías tratar de tomar una actitud de<br />

una persona más valiente y demostrar que en realidad dicho método no se<br />

puede obtener. Si eso fuera así entonces tú podrías ir con tu jefe y aclararle<br />

que el problema no lo resolviste porque no se puede resolver: Es intratable.<br />

Sin embargo, es muy probable que a tu jefe esta característica del problema<br />

le tenga sin cuidado y, de todas maneras, te corran o tengas que renunciar.<br />

Es decir regresarás a las alternativas: 1 y 2 antes citadas.<br />

Una situación más prometedora, es tratar de probar que el problema no solo<br />

es tan difícil que no se puede resolver, sino que además pertenece a una<br />

clase de problemas tales que cientos y miles de personas famosas e<br />

inteligentes, tampoco pudieron resolver. Aún así, no estamos muy seguros<br />

aún de tu permanencia en la compañía!.<br />

En realidad el conocimiento de que un problema es intratable es de gran<br />

utilidad en problemas de tipo computacional. Dado que el problema<br />

completo es intratable, tu podrías resolver el problema para ciertas<br />

instancias del mismo, o bien, resolver un problema menos ambicioso. Por<br />

ejemplo, podrá conformarte con obtener un método para las especificaciones<br />

de solo cierto tipo de componentes para determinados conjuntos de entrada<br />

y no para "cualquier conjunto dado de especificaciones". Esto seguramente<br />

te llevará a tener mejor relaciones con tu compañía.<br />

En general, nos interesa encontrar el algoritmo más eficiente para resolver<br />

un problema determinado. Desde ese punto de vista tendríamos que<br />

considerar todos los recursos utilizados de manera que el algoritmo más<br />

eficiente sería aquél que consumiera menos recursos. ¿Cuáles recursos? Si<br />

pensamos nuevamente que tu jefe te encarga que obtengas el algoritmo más<br />

eficiente, es decir el que consume menos recursos so pena de que si no lo<br />

Página 121


obtienes seas eliminado de la nomina de la compañía, estoy seguro, en que<br />

pensarías en todos los recursos posibles (horas-hombre para construir el<br />

algoritmo, tiempo de sintonización, tiempo requerido para obtener una<br />

solución, número de procesadores empleados, cantidad de memoria<br />

requerida, etc.). Para simplificar un poco la tarea, consideraremos que tu<br />

jefe te permite que la eficiencia la midas sobre un solo recurso: El tiempo de<br />

ejecución. Pensemos por un momento que solo se tiene una sola máquina de<br />

modo que el número de procesadores no requiere de un análisis. ¿Qué le<br />

pasó a tu jefe? ¿De pronto se volvió comprensivo contigo? En realidad los<br />

otros parámetros los ha tomado en cuenta como costo directo del programa<br />

en cuestión. Esto es en realidad lo que se hace en teoría de complejidad. Para<br />

el análisis de complejidad, se considera por lo general la eficiencia sobre el<br />

tiempo, para un solo procesador en cómputo secuencial; para cómputo<br />

paralelo se considera también el número de procesadores.<br />

Los algorimos han sido divididos como buenos o malos algoritmos. La<br />

comunidad computacional acepta que un buen algoritmo es aquél para el<br />

cual existe un algoritmo polinomial determinístico que lo resuelva. También<br />

se acepta que un mal algoritmo es aquel para el cual dicho algoritmo<br />

simplemente no existe. Un problema se dice intratable, si es muy difícil que<br />

un algoritmo de tiempo no polinomial lo resuelva. No obstante, esta<br />

clasificación de algoritmos en buenos y malos puede resultar a veces<br />

engañosa, ya que se podría pensar que los algoritmos exponenciales no son<br />

de utilidad práctica y que habrá que utilizar solamente algoritmos<br />

polinomiales. Sin embargo se tiene el caso de los métodos simplex y Branch<br />

and Bound, los cuales son muy eficientes para muchos problemas prácticos.<br />

Desafortunadamente ejemplos como estos dos son raros, de modo que es<br />

preferible seguir empleando como regla de clasificación en buenos y malos<br />

algoritmos, la que lo hace dependiendo de si son o no polinomiales; todo<br />

esto con la prudencia necesaria.<br />

La teoría de la NP-Completés fué presentada inicialmente por Cook desde<br />

1971. Cook probó que un problema particular que se estudia en Lógica (no<br />

necesitamos ahora estudiarlo) y que se llama "El Problema de<br />

Satisfactibilidad", tenía la propiedad de que cualquier problema que<br />

perteneciera a la clase NP, podía ser reducido a él a través de una<br />

transformación de tipo polinomial. Esto significaba que si el problema de<br />

satisfactibilidad podía ser resuelto en tiempo polinomial, todos los<br />

problemas No Polinomiales también podrían resolverse en tiempo<br />

polinomial, lo que significaría que la clase NP dejaría de existir!!.<br />

De esta forma, si cualquier problema en NP es intratable, entonces<br />

satisfactibilidad es un problema intratable. Cook también sugirió que el<br />

problema de satisfactibilidad y otros problemas NP tenían la característica<br />

de ser los problemas más duros. Estos problemas tienen dos versiones: de<br />

decisión y de optimización. El conjunto de estos problemas de optimización<br />

se denominan NP Completos, mientras que a sus correspondientes<br />

problemas de optimización, reciben el nombre de NP Hard.<br />

Página 122


9.5 Problemas de Decisión<br />

Aquí hemos hablado de problemas de optimización como aquél donde<br />

buscamos el máximo o el mínimo de una func ión donde existen o no un<br />

conjunto de restricciones. Sin embargo, un problema de optimización<br />

combinatoria puede también ser formulado de manera más relajada como<br />

sigue:<br />

Dado un problema de optimización, podemos encontrar el costo de la<br />

solución óptima.<br />

Se sabe que dado un problema de optimización, se puede definir un<br />

problema de decisión asociado a él, esto es, una pregunta que puede ser<br />

contestada por si o no. Por otro lado, varios problemas computacionales<br />

bien conocidos son problemas de decisión. Entre los problemas de decisión<br />

se pueden mencionar por ejemplo:<br />

• El problema de paro (Halting Problem ): dado un algoritmo y su<br />

entrada, ¿Parará éste alguna vez?<br />

• El problema de satisfactibilidad: dada una fórmula booleana, ¿Es ésta<br />

satisfactible?<br />

• El problema del circuito Hamiltoniano: dado un grafo G, ¿Hay un<br />

circuito en G que visite todos los nodos exactamente una vez?.<br />

La definición de problema de decisión a partir del problema de optimización<br />

permite estudiar ambos tipos de problemas de una manera uniforme.<br />

Además, como se ha puntualizado que un problema de decisión no es más<br />

difícil que el problema de optimización original, cualquier resultado negativo<br />

probado sobre la complejidad del problema de decisión será aplicable al<br />

problema de optimización también.<br />

Se está interesado en clasificar los problemas de decisión de acuerdo a su<br />

complejidad. Se denota por P a la clase de problemas de decisión que son<br />

polinomialmente acotados, esto es, la clase de problemas de decisión que<br />

pueden ser solucionados en tiempo polinomial. La clase P puede ser<br />

definida muy precisamente en términos de cualquier formalismo<br />

matemático para algoritmos, como por ejemplo la Máquina de Turing.<br />

Se puede decir que P es la clase de problemas de decisión relativamente<br />

fáciles, para los cuales existe un algoritmo que los soluciona eficientemente.<br />

Para una entrada dada, una "solución" es un objeto (por ejemplo, un grafo<br />

coloreado) que satisface el criterio en el problema y justifica una respuesta<br />

afirmativa de si. Una "solución propuesta" es simplemente un objeto del tipo<br />

apropiado, este puede o no satisfacer el criterio. Informalmente se puede<br />

decir que NP es la clase de problemas de decisión para los cuales una<br />

solución propuesta dada para una entrada dada, puede ser chequeada<br />

Página 123


ápidamente (en tiempo polinomial) para ver si ésta es realmente una<br />

solución, es decir, si ésta satisface todos los requerimientos del problema.<br />

Una solución propuesta puede ser descrita por una cadena de símbolos a<br />

partir de algún conjunto finito. Simplemente se necesita alguna convención<br />

para describir grafos, conjuntos, funciones, etc. usando estos símbolos. El<br />

tamaño de la cadena es el número de símbolos en ella. Chequear una<br />

solución propuesta incluye chequear que la cadena tenga sentido (esto es,<br />

que tenga una sintáxis correcta) como una descripción del tipo de objeto<br />

requerido, también como chequear que ésta satisface el criterio del<br />

problema.<br />

9.6 Algoritmos No Determinísticos<br />

Un algoritmo no determinístico tiene dos fases:<br />

Fase no Determinística: alguna cadena de caracteres, s, completamente<br />

arbitraria es escrita a partir de algún lugar de memoria designado. Cada vez<br />

que el algoritmo corre, la cadena escrita puede diferir (Esta cadena puede<br />

ser vista como una adivinación de la solución para el problema, por lo que a<br />

esta fase se le da el nombre de fase de adivinación, pero s también podría ser<br />

ininteligible o sin sentido).<br />

Fase Determinística: Un algoritmo determinístico (es decir ordinario)<br />

siendo ejecutado. Además de la entrada del problema de decisión, el<br />

algoritmo puede leer s, o puede ignorarla. Eventualmente éste para con una<br />

salida de si o no, o puede entrar en un ciclo infinito y nunca parar (véase ésta<br />

como la fase de chequear, el algoritmo determinístico verifica s para ver si<br />

ésta es una solución para la entrada del problema de decisión).<br />

El número de pasos llevados a cabo durante la ejecución de un algoritmo no<br />

determinístico es definido como la suma de los pasos en ambas fases; esto<br />

es, el número de pasos tomados para escribir s (simplemente el número de<br />

caracteres en s) más el número de pasos ejecutados por la segunda fase<br />

determinística.<br />

Normalmente cada vez que se corre un algoritmo con la misma entrada se<br />

obtiene la misma salida. Esto no ocurre con algoritmos no determinísticos;<br />

para una entrada particular x, la salida a partir de una corrida puede diferir<br />

de la salida de otra debido a que ésta depende de s. aunque los algoritmos no<br />

determinísticos no son realistas (algoritmos útiles en la práctica), ellos son<br />

útiles para clasificar problemas.<br />

Se dice que un algoritmo no determinístico es polinomialmente acotado si<br />

hay un polinomio p tal que para cada entrada de tamaño n para la cual la<br />

respuesta es si, hay alguna ejecución del algoritmo que produce una salida si<br />

en cuando mucho p(n) pasos.<br />

Página 124


De esta forma se puede decir que: NP es la clase de problemas de decisión<br />

para los cuales hay un algoritmo no determinístico acotado<br />

polinomialmente (el nombre de NP viene de no determinístico<br />

polinomialmente acotado).<br />

Un algoritmo ordinario (determinístico) para un problema de decisión es un<br />

caso especial de un algoritmo no determinístico. En otras palabras, si A es<br />

un algoritmo determinístico para un problema de decisión, entonces A es la<br />

segunda fase de un algor itmo no determinístico. A simplemente ignora lo<br />

que fue escrito por la primera fase y procede con su cálculo usual. Un<br />

algoritmo no determinístico puede hacer cero pasos en la primera fase<br />

(escribiendo la cadena nula). Así, si A corre en tiempo polinomial, el<br />

algoritmo no determinístico con A como su segunda fase corre también en<br />

tiempo polinomial. De lo mencionado se deduce que P es un subconjunto<br />

propio de NP.<br />

La gran pregunta es ¿ P = NP o es P un subconjunto propio de NP? Se cree<br />

que NP es un conjunto mucho mayor que P, pero no hay un solo problema<br />

en NP para el cual éste haya sido probado que el problema no está en P. No<br />

hay un algoritmo polinomialmente acotado conocido para muchos<br />

problemas en NP, pero ningún límite inferior mayor que un polinomio ha<br />

sido probado para estos problemas.<br />

NP-completo es el término usado para describir los problemas de decisión<br />

que son los más difíciles en NP en el sentido que, si hubiera un algoritmo<br />

polinomialmente acotado para un problema NP-completo, entonces habría<br />

un algoritmo polinomialmente acotado para cada problema en NP.<br />

Página 125


Bibliografía<br />

1. AHO, Alfred H., Hopcroft, John E. y Ullman, Jeffrey D. Estructuras de<br />

Datos y Algoritmos, Addison-Wesley Iberoamericana, 1988.<br />

2. BAEZA-YATES, Ricardo. Algoritmia, Depto. Ciencias de la Computación,<br />

Universidad de Chile, 1991.<br />

3. BAASE, S.; Van Gelder, A. Computer Algorithms. Introduction to Design<br />

and Analysis. Addison-Wesley, 2000.<br />

4. BALCAZAR, José L. Apuntes sobre el Cálculo de la Eficiencia de los<br />

Algoritmos, Universidad Politécnica de Cataluña, España.<br />

5. BRASSARD, G., Bratley, P., Fundamentals of Algorithmics. Prentice Hall,<br />

1995.<br />

6. CORMEN Thomas, Leiserson Charles and Rivest Ronald. Introduction to<br />

Algorithms. The MIT Press, 1990.<br />

7. GIMÉNEZ Cánovas, Domingo. Apuntes y Problemas de Algorítmica.<br />

Facultad de Informática. Universidad de Murcia, 2001.<br />

8. GONNET, G. H., Baeza-Yates, R. Handbook of Algorithms and Data<br />

Structures, Addison-Wesley, 1991.<br />

9. HOROWITZ, E.; Sahni, S. Fundamentals of Data Structures. Computer<br />

Science Press, 1976.<br />

10. KNUTH, Donald E. "Fundamental algorithms", volume 1 de "The Art of<br />

Computer Programming", segunda edición, Addison-Wesley Publishing<br />

Company, Reading, Massachusetts, 1973.<br />

11. KNUTH, Donald E. "Sorting and Searching", volume 3 de "The Art of<br />

Computer Programming", segunda edición, Addison-Wesley Publishing<br />

Company, Reading, Massachusetts, 1973.<br />

12. PARBERRY Ian. Problems on Algorithms. Prentice Hall, 1995.<br />

13. PEÑA M., Ricardo. Diseño de programas. Formalismo y abstracción.<br />

Prentice-Hall, 1998.<br />

14. WEISS, M. A., Estructuras de datos y algoritmos. Addison-Wesley<br />

Iberoamericana, 1995.<br />

15. WIRTH, Niklaus. Algoritmos y Estructuras de Datos. Pentice Hall, 1987.<br />

Página 126

Hooray! Your file is uploaded and ready to be published.

Saved successfully!

Ooh no, something went wrong!