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
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