Secme 19288
Secme 19288
Secme 19288
PROGRAMACIÓN AVANZADA
APUNTES
PERIODO 2015B.
1
Prof. Joel Ayala de la Vega.
PROGRAMACIO N AVANZADA.
Tabla de contenido
PRESENTACIÓN ...................................................................................................................... 4
I PROGRAMACIÓN MODULAR ............................................................................................ 5
1.1 Programas y Sentencias ................................................................................................ 5
1.2 La Lingüística de la Modularidad ................................................................................. 5
1.3 Conexiones Normales y Patológicas ............................................................................ 6
1.4 Como alcanzar sistemas de mínimo costo .................................................................... 7
1.5 Como se logra el costo mínimo con Diseño Estructurado............................................ 8
1.6 El concepto de Cajas Negras ........................................................................................ 8
1.7 Comparación entre las estructuras administrativas y el diseño estructurado ............... 9
1.8 Manejo de la complejidad........................................................................................... 10
1.9 Complejidad en términos humanos ............................................................................ 12
1.10 Acoplamiento ........................................................................................................... 16
1.11 Cohesión ................................................................................................................... 24
Ejercicios. ......................................................................................................................... 32
II PROGRAMACIÓN RECURSIVA ...................................................................................... 33
2.1 Clasificación de funciones recursivas........................................................................ 33
2.2 Diseño de funciones recursivas .................................................................................. 34
III INTRODUCCIÓN A LA ALGORITMICA........................................................................ 37
IV COMPLEJIDAD Y ORDEN............................................................................................... 42
Ejercicios .................................................................................................................................. 45
V ORDENACIÓN Y BUSQUEDA ......................................................................................... 46
5.1 Método de la burbuja ................................................................................................. 46
5.2 Ordenación por selección directa ............................................................................... 47
5.3 Método de inserción binaria ....................................................................................... 49
5.4 Método de ordenación rápida (quicksort) ................................................................... 49
5.5 Método de mezcla (merge sort) .................................................................................. 52
5.6 Búsqueda Secuencial .................................................................................................. 55
5.7 Búsqueda binaria (Binary Search) .............................................................................. 56
PARADIGMAS ........................................................................................................................ 58
VI DIVIDE Y CONQUISTARAS ........................................................................................... 58
6.1 Buscando el Máximo y Mínimo (Finding The Maximun and Minimun). .................. 59
VII MÉTODO CODICIOSO. (Greedy) ................................................................................... 62
7.1. Almacenamiento Óptimo en Cintas. (Optimal Storage On Tapes) ........................... 62
2
Prof. Joel Ayala de la Vega.
7.2 El Problema de la Mochila. (Knapsack Problem) ..................................................... 63
7.3 Patrón óptimo de concatenación (Optimal Merge Pattern) ........................................ 64
7.4 Árbol de expansión mínima (Minimun Spanning Tree) ............................................. 66
Ejercicios. ......................................................................................................................... 74
VIII PROGRAMACIÓN DINÁMICA. (Dynamic Programming). ........................................ 76
Principio de optimalidad................................................................................................... 76
8.1 Gráficas de Múltiples Etapas. (MultiStage Graphs) ................................................... 76
8.2 El Problema del Agente Viajero (The Traveling Sales Person Problem, TSP) .......... 79
Ejercicios. ......................................................................................................................... 83
IX Retorno Sobre la Misma Ruta. (Backtracking) ................................................................... 84
9.1 El problema de las ocho reinas (8-Queens). ............................................................. 84
9.2 Hamiltonian Cycles (Camino Hamiltoniano) ............................................................ 89
Ejercicios .......................................................................................................................... 91
X RAMIFICACIÓN Y ACOTAMIENTO. (Branch and Bound) ............................................ 92
10.1 Descripción General ................................................................................................. 92
10.2 Estrategias de Poda ................................................................................................... 94
10.3 Estrategias de Ramificación ..................................................................................... 94
10.4 TRAVELING SALESPERSON (El problema del agente viajero) ......................... 97
Ejercicios ........................................................................................................................ 108
XI PROBLEMAS NP ............................................................................................................. 109
11.1 Antecedentes de complejidad. ........................................................................................ 109
11.2 Tesis CHURCH – TURING. .................................................................................. 113
11.3 Complejidad. .......................................................................................................... 113
11.4 Tesis de computabilidad secuencial. ...................................................................... 114
11.5 Problemas NP ......................................................................................................... 114
11.6 Un poco de especulación. ...................................................................................... 119
Trabajos citados ...................................................................................................................... 121
ANEXO A Programa de Estudios. ................................................................................ 122
3
Prof. Joel Ayala de la Vega.
PRESENTACIÓN.
Este escrito fue hecho con la intención de apoyar a los estudiantes que cursan la Unidad de
Aprendizaje (U. de A.) “Programación Avanzada” en el estudio de la modularidad, en la
comprensión de funciones recursivas, de los algoritmos y sus diferentes paradigmas. Dentro
del estudio de algoritmos, en cada capítulo del escrito muestra el pseudocódigo o el código en
el lenguaje C y un ejemplo de la ejecución del algoritmo, permitiendo la comprensión del
mismo.
El escrito se basa por completo en el temario de “Programación Avanzada” revisado en Mayo
del 2011.
Por el otro lado se tiene la complejidad algorítmica, esta complejidad no trata sobre el número
de línea, más bien, trata sobre el tiempo que pueda un algoritmo en específico en ser
ejecutado, en este sentido se puede tratar de una función de unas cuantas líneas, pero para
poder llegar a un resultado se requieran de horas, días, meses, años e incluso siglos. (Puede
ser un algoritmo de nueve líneas como el caso de las famosas torres de Hanoi o una
representación matemática de una sola línea como lo muestra el algoritmo del agente viajero,
ambos problemas intratables). Los algoritmos intratables tienen la característica de ser
recursivos, por lo que se debe tener la idea de lo que es recursión tanto en la parte matemática
como en el ambiente de la programación.
De esta forma, la tercera y cuarta parte del escrito permite explicar la “Introducción a la
Algorítmica y Complejidad”. En este punto se da una reseña histórica del nacimiento de los
algoritmos y clasifica la forma de medir la complejidad del algoritmo.
La quinta parte da los primeros ejemplos del análisis mediante algoritmos de búsqueda y
ordenamiento.
De la sexta a la décima parte del escrito responde a “Estrategias de diseño de algoritmos” bajo
los paradigmas “Divide y vencerás”, “insaciables”, “Programación Dinámica”, “Retorno
sobre la misma ruta” y “Ramificación y acotamiento”.
En el paradigma de programación dinámica se analiza un algoritmo recursivo “El agente
Viajero”, permitiendo hacer un “Análisis de algoritmo recursivo”.
La onceava parte del escrito trata, en forma histórica, los conceptos de algoritmos NP. Cómo
Hilbert planteó la matemática a inicios del siglo XX, los resultados obtenidos con Gödel, la
definición de algoritmo por Turing y otros matemáticos llegando a comentar los algoritmos
no determinísticos e intratabilidad (el Entscheidungsproblem).
Uno espera, con este escrito, dar un criterio al alumno de una clasificación de la complejidad
de software y de los algoritmos. Además, observar la diferencia de lo que es un algoritmo
tratable, un algoritmo intratable y los problemas no computables. Para estos últimos se
explica “the halting problem”, siendo éste un problema clásico de “Teoría de la
Computación”.
4
Prof. Joel Ayala de la Vega.
I PROGRAMACIÓN MODULAR
(Booch, 1991)
(Carlo Ghezzi, 1991)
(Deheza, 2005)
Un programa puede ser definido como "una secuencia precisa y ordenada de instrucciones y
grupos de instrucciones, las cuales, en su total, definen, describen, o caracterizan la
realización de alguna tarea".
Para los propósitos de nuestro estudio, consideraremos que una sentencia es una línea de
código que el programador escribe.
Fig. 1.1
5
Prof. Joel Ayala de la Vega.
Diremos que A1 y A2 son los límites del conjunto o agregado de sentencias llamado A. La
sentencia B se encuentra dentro de A, y C se encuentra fuera de A.
Diremos que entre dos módulos existe una conexión normal cuando la conexión se produce al
nivel del identificador del módulo invocado.
6
Prof. Joel Ayala de la Vega.
1.4 Como alcanzar sistemas de mínimo costo
Cuando se trata con un problema de diseño, por ejemplo, un sistema que pueda ser
desarrollado en un par de semanas, no se tienen mayores problemas, y el desarrollador puede
tener todos los elementos del problema "en mente" a la vez. Sin embargo cuando se trabaja en
proyectos de gran escala, es difícil que una sola persona sea capaz de llevar todas las tareas y
tener en mente todos los elementos a la vez.
El diseño exitoso se basa en un viejo principio conocido desde los días de Julio Cesar: Divide
y conquistarás.
Manejablemente pequeñas
Solucionables separadamente.
De manera similar, podemos decir que el costo de mantenimiento puede ser minimizado
cuando las partes de un sistema son:
Por otra parte, para minimizar los costos de mantenimiento debemos lograr que cada pieza sea
independiente de otra. En otras palabras debemos ser capaces de realizar modificaciones al
módulo A sin introducir efectos indeseados en el módulo B.
7
Prof. Joel Ayala de la Vega.
En resumen, podemos afirmar lo siguiente: los costos de implementación, mantenimiento, y
modificación, generalmente serán minimizados cuando:
La cuestión es: ¿Dónde y cómo debe dividirse el problema? ¿Qué aspectos del problema
deben pertenecer a la misma pieza del sistema, y cuales a distintas piezas? El diseño
estructurado responde estas preguntas con dos principios básicos:
Partes del problema altamente interrelacionadas deberán pertenecer a la misma pieza del
sistema.
Partes sin relación entre ellas, deben pertenecer a diferentes piezas del sistema sin relación
directa.
Otro aspecto importante del diseño estructurado es la organización del sistema. Debemos
decidir cómo se interrelacionan las partes, y que parte está en relación con cual. El objetivo es
organizar el sistema de tal forma que no existan piezas más grandes de lo estrictamente
necesario para resolver los aspectos del problema que ella abarca. Igualmente impórtate, es el
evitar la introducción de relaciones en el sistema, que no existe en el dominio del problema.
Una caja negra es un sistema (o un componente) con entradas conocidas, salidas conocidas, y
generalmente transformaciones conocidas, pero del cual no se conoce el contenido en su
interior.
En la vida diaria existe innumerable cantidad de ejemplos de uso cotidiano: una radio, un
televisor, un automóvil, son cajas negras que usamos a diario sin conocer (en general) como
funciona en su interior. Solo conocemos como controlarlos (entradas) y las respuestas que
podemos obtener de los artefactos (salidas).
8
Prof. Joel Ayala de la Vega.
documentación en tales casos, es de utilidad pero no transforma al módulo en una verdadera
caja negra. Podríamos hablar en todo caso de "cajas blancas".
Uno de los aspectos más interesantes del diseño de programas es la relación que puede
establecerse con las estructuras de organización humana, particularmente la jerarquía de
administración encontrada en la mayoría de las grandes organizaciones.
Podemos establecer una analogía con la estructura de programas y es razonable pensar que un
módulo que tenga demasiados módulos subordinados a quienes controlar, sea sumamente
complejo, y susceptible a fallas.
9
Prof. Joel Ayala de la Vega.
Podemos apreciar a simple vista que la tarea de los jefes A, X, Y, es relativamente trivial y
podría ser comprimida en una sola jefatura. Estableciendo una comparación con la estructura
de programas, si tenemos un módulo cuya única función es llamar a otro, y este a su vez a
otro, el cual llama a uno que finalmente realizará la tarea, los módulos intermedios podrán
comprimirse un único módulo de control.
Podemos decir que en una organización perfecta, los administradores no realizan ninguna
tarea operativa. Su labor consiste en coordinar información entre los subordinados y tomar
decisiones. Los niveles inferiores son los que realizan el trabajo operativo. Análogamente,
podemos argumentar que los módulos de nivel alto en un programa o sistema solamente
coordinan y controlan la ejecución de los módulos de menor nivel, quienes son los que
realizan los cómputos y procesos que el sistema requiere.
En principio diremos que escribir un programa "grande" generalmente lleva más tiempo que
escribir uno "pequeño". Esto es valedero si medimos "grande" y "pequeño" en unidades
apropiadas. Claramente el número de instrucciones de un programa no es una medida de
complejidad ya que existen instrucciones más complejas que otras, y algoritmos más
complejos que otros. En realidad lo que diremos es que es más difícil resolver un problema
difícil! , e intentaremos realizar un análisis sobre como disminuir la complejidad de un
determinado problema.
Si asumimos que podemos medir por algún método la complejidad de un problema P (no
importa aquí que método), diremos que la complejidad del mismo será M(P), y que el costo
de realizar un programa que resuelva el problema P será C(P). Los enunciados previos
responderán a la siguiente regla:
Intentaremos tomar dos problemas separados y en lugar de escribir dos programas, crear un
programa combinado. Si juntamos los dos problemas, obtendremos uno mayor que si
tomamos los dos por separado. La razón fundamental para no combinar los problemas, es que
los humanos tenemos inconvenientes para tratar adecuadamente grandes complejidades, y en
la medida que esta se incrementa somos susceptibles a cometer un mayor número de errores.
En virtud de esto podemos afirmar que
10
Prof. Joel Ayala de la Vega.
y consecuentemente
Siempre será preferible crear dos piezas pequeñas que una sola más grande, si ambas
solucionan el mismo problema.
Ahora bien, cuando se descompone una tarea en dos, si las subtareas no son realmente
independientes, al solucionar una de las partes debe simultáneamente tratarse aspectos de la
otra.
donde I1 es una fracción que representa la interacción de P’ con P", e I2 es una fracción que
representa la interacción de P" con P’. Siempre que I1 e I2 sean mayores a cero, es obvio que
será
11
Prof. Joel Ayala de la Vega.
C(P’ + I1 x P’) + C(P" + I2 x P") > C(½ P) + C(½ P)
A medida que los módulos sean más pequeños, podemos esperar que su complejidad (y costo)
disminuyan, pero además, a mayor cantidad de módulos, tendremos mayor posibilidad
problemas debido a errores en las conexiones intermódulos. Estas son dos curvas en
contraposición
12
Prof. Joel Ayala de la Vega.
En el punto anterior realizamos un análisis sobre la incidencia de la complejidad en los costos,
y cómo manejarla a través de la subdivisión de un problema en problemas menores. Vimos
que muchos de nuestros problemas en diseño y programación se deben a la limitada capacidad
de la mente humana para lidiar con la complejidad.
En otras palabras:
¿Qué aspectos del diseño de sistemas y programas son considerados complejos por el
diseñador?
Y por extensión
En primer término podemos decir que el tamaño de un módulo es una medida simple de la
complejidad. Generalmente un módulo de 1000 sentencias, será más complejo que uno de 10.
Obviamente el problema es mayor ya que existen sentencias más complejas que otras.
Por ejemplo las sentencias de decisión son uno de los primeros factores que contribuyen a la
complejidad de un módulo. Otro factor de importancia es el "espacio" de vida o alcance de
los elementos de dato, es decir el número de sentencias durante las cuales el estado y valor de
un elemento de datos debe ser recordado por el programador en orden de comprender que es
lo que hace el módulo.
Otro aspecto relacionado con la complejidad es el alcance o amplitud del flujo de control,
esto es el número de sentencias lexicográficamente contiguas que deben examinarse antes de
encontrar una sección de código caja-negra con un punto de entrada y un punto de salida. Es
interesante notar que la teoría detrás de la programación estructurada provee el medio de
reducir este alcance a una mínima longitud organizando la lógica en combinaciones de
operaciones de "secuencia", "decisión", e "iteración".
Todas estas medidas reconocen que la complejidad de los programas percibida por humanos,
se ve altamente influenciada por el tamaño del módulo.
Tres factores, implícitos en el enfoque previo, han sido identificados como afectando la
complejidad de las sentencias:
Mientras que la complejidad de todo tipo de sentencias de programas puede evaluarse según
estos criterios, enfocaremos la atención en aquellas que establecen relaciones intermodulares.
13
Prof. Joel Ayala de la Vega.
Cantidad
En términos simples, esto se relaciona con el número de argumentos o parámetros que son
pasados en la llamada al módulo. Por ejemplo, una llamada a una subrutina que involucra 100
parámetros, será más compleja que una que involucra solo 3.
El programador inferirá que X funciona como parámetro de entrada y como valor de retorno
del procedimiento. Y es muy probable que esto sea así.
el programador podrá inferir que X funciona como parámetro de entrada, que el resultado es
retornado del procedimiento, y que en Z se retorna algún código de error. Esto podría ser
cierto, pero es alta la probabilidad de que el orden de los parámetros sea diferente.
Vemos que a medida que crece la cantidad de parámetros, la posibilidad de error es mayor.
Puede argumentarse que esto se soluciona con una adecuada documentación, pero la realidad
demuestra que en la mayoría de los casos los programas no están bien documentados.
Notaremos además que un módulo con demasiados parámetros, posiblemente esté realizando
más de una función específica, y por lo tanto podría descomponerse en dos módulos más
sencillos y funcionales con una menor cantidad de argumentos.
Accesibilidad
14
Prof. Joel Ayala de la Vega.
La interface es menos compleja si la información es presentada localmente dentro de la
misma sentencia de llamada. La interface es más compleja si la información necesaria
es remota a la sentencia.
La interface es menos compleja si la información es presentada en forma estándar que si se
presenta de forma imprevista.
La interface es menos compleja si su naturaleza es obvia es menos compleja que si su
naturaleza es obscura.
Observaremos el siguiente ejemplo: supongamos que tenemos la función DIST que calcula la
distancia existente entre dos puntos. La fórmula matemática para realizar dicho cálculo es:
A primera vista podemos pensar que la opción 1 es la más compleja ya que involucra el
mayor número de parámetros. Sin embargo la opción 1 presenta los parámetros en
forma directa.
La opción 4 presenta la misma desventaja que 2 y 3, presentando los valores en forma remota.
15
Prof. Joel Ayala de la Vega.
Parametrizar una interface requiere más trabajo
El proceso de parametrización mismo puede introducir errores
En general la velocidad del programa es menor que cuando se usan variables globales
Estructura
Similarmente, las expresiones lógicas que involucran operadores de negación (NOT) son más
difíciles de comprender que aquellas que no lo presentan.
Estas filosofías de pensamiento linear y positivo también son importantes en las referencias
intermódulares. Supongamos la siguiente instrucción:
DISTANCIA = SQRT ( sum( square ( dif (Y1,Y0), square ( dif (X1, X0) ) ) )
A2 = square (A)
B2 = square (B)
1.10 Acoplamiento
16
Prof. Joel Ayala de la Vega.
presencia del otro. Esto implica que no existen interconexiones entre los módulos, y que se
tiene un valor cero en la escala de "dependencia".
En general veremos que a mayor número de interconexiones entre dos módulos, se tiene una
menor independencia.
La cuestión aquí es: ¿cuánto debe conocerse acerca de un módulo para poder comprender otro
módulo? Cuanto más debamos conocer acerca del módulo B para poder comprender el
módulo A, menos independientes serán A de B.
Claramente, el costo total del sistema se verá fuertemente influenciado por el grado de
acoplamiento entre los módulos.
Los cuatro factores principales que influyen en el acoplamiento entre módulos son:
Tipo de conexión entre módulos: los sistemas normalmente conectados, tienen menor
acoplamiento que aquellos que tienen conexiones patológicas.
Complejidad de la interface: Esto es aproximadamente igual al número de ítems diferentes
pasados (no cantidad de datos). Más ítems, mayor acoplamiento.
Tipo de flujo de información en la conexión: los sistemas con acoplamiento de datos tienen
menor acoplamiento que los sistemas con acoplamiento de control, y estos a su vez menos
que los que tienen acoplamiento híbrido.
Momento en que se produce el ligado de la Conexión: Conexiones ligadas a referentes fijos
en tiempo de ejecución, resultan con menor acoplamiento que cuando el ligado tiene lugar
en tiempo de carga, el cual tiene a su ver menor acoplamiento que cuando el ligado se
realiza en tiempo de linkage-edición, el cual tiene menos acoplamiento que el que se realiza
en tiempo de compilación, todos los que a su vez tiene menos acoplamiento que cuando el
ligado se realiza en tiempo de codificación.
17
Prof. Joel Ayala de la Vega.
1.10.2 Tipos de conexiones entre módulos
El elemento referenciado define una interface, un límite del módulo, a través del cual fluyen
datos y control.
Toda interface en un módulo representa cosas que deben ser conocidas, comprendidas, y
apropiadamente conectadas por los otros módulos del sistema.
Todo módulo además debe tener al menos una interface para ser definido y vinculado al resto
del sistema.
Pero, ¿es una interface de identidad simple suficiente para implementar sistemas que
funcionen adecuadamente? La cuestión aquí es: ¿A qué propósito sirven las interfaces?
Solo flujos de control y datos pueden pasarse entre módulos en un sistema de programación.
Una interface puede cumplir las siguientes cuatro únicas funciones:
Un módulo puede ser identificado y activado por medio de una interfaz de identidad simple.
También podemos pasar datos a un módulo sin agregar otras interfaces, haciendo a la interfaz
de entrada capaz de aceptar datos como control. Esto requiere que los elementos de datos sean
pasados dinámicamente como argumentos (parámetros) como parte de la secuencia de
activación, que da el control a un módulo; cualquier referencia estática a datos puede
introducir nuevas interfaces.
Se necesita también que la interface de identidad de un módulo sirva para transferir el retorno
del control al módulo llamador. Esto puede realizarse haciendo que la transferencia de control
desde el llamador sea una transferencia condicional. Debe implementarse además un
mecanismo para transmitir datos de retorno desde el módulo llamado hacia el llamador. Puede
asociarse un valor a una activación particular del módulo llamado, la cual pueda ser usada
contextualmente en el llamador. Tal es el caso de las funciones lógicas. Alternativamente
pueden transmitirse parámetros para definir ubicaciones donde el módulo llamado retorna
valores al llamador.
18
Prof. Joel Ayala de la Vega.
Si todas las conexiones de un sistema se restringen a ser completamente parametrizadas (con
respecto a sus entradas y salidas), y la transferencia condicional de control a cada módulo se
realiza a través de una identidad simple y única, diremos que el sistema está mínimamente
conectado.
Diremos que un sistema está normalmente conectado cuando cumple con las condiciones de
mínimamente conectado, excepto por alguna de las siguientes consideraciones:
El uso de múltiples puntos de entrada garantiza que existirán más que el número mínimo de
interconexiones para el sistema. Por otra parte si cada punto de entrada determina funciones
con mínima conexión a otros módulos, el comportamiento del sistema será similar a uno
mínimamente interconectado.
De manera similar, los puntos de retorno alternativo son frecuentemente útiles dentro del
espíritu de los sistemas normalmente conectados. Esto se da cuando un módulo continuará su
ejecución en un punto que depende del valor resultante de una decisión realizada por un
módulo subordinado invocado previamente. En un caso de mínima conexión, el módulo
subordinado retornará el valor como un parámetro, el cual deberá ser testeado nuevamente en
el módulo superior. Sin embargo, el módulo superior puede indicar por algún medio
directamente el punto donde debe continuarse la ejecución del programa, (un valor relativo +
o - direcciones a partir de la instrucción llamadora, o un parámetro con una dirección
explícita).
19
Prof. Joel Ayala de la Vega.
1.10.4 Flujo de Información
Otro aspecto importante del acoplamiento tiene que ver con el tipo de información que se
transmite entre el módulo superior y subordinado. Distinguiremos tres tipos de flujo de
información:
datos
control
híbrido
Los datos son información sobre la cual una pieza de programa opera, manipula, o modifica.
La información de control (aun cuando está representada por variables de dato) es aquella que
gobierna como se realizarán las operaciones o manipulaciones sobre los datos.
Diremos que una conexión presenta acoplamiento por datos si la salida de datos del módulo
superior es usada como entrada de datos del subordinado. Este tipo de acoplamiento también
es conocido como de entrada-salida.
Diremos que una conexión presenta acoplamiento de control si el módulo superior comunica
al subordinado información que controlará la ejecución del mismo. Esta información puede
pasarse como datos utilizados como señales o "banderas" (flags) o bien como direcciones de
memoria para instrucciones de salto condicional (branch-adress). Estos son elementos de
control "disfrazados" como datos.
Se puede minimizar el acoplamiento si solo se transmiten datos a través de las interfaces del
sistema.
El acoplamiento de control abarca todas las formas de conexión que comuniquen elementos
de control. Esto no solo involucra transferencia de control (direcciones o banderas), si no que
puede involucrar el pasaje de datos que cambia, regula, o sincroniza la ejecución de otro
módulo.
20
Prof. Joel Ayala de la Vega.
Cuando un módulo modifica el contenido procedural de otro módulo, decimos que
existe acoplamiento híbrido. El acoplamiento híbrido es una modificación de sentencias
intermódular. En este caso, para el módulo destino o modificado, el acoplamiento es visto
como de control en tanto que para el módulo llamador o modificador es considerado como de
datos.
El grado de interdependencia entre dos módulos vinculados con acoplamiento híbrido es muy
fuerte. Afortunadamente es una práctica en decadencia y reservada casi con exclusividad a los
programadores en ensamblador.
De esta forma, el ligado puede tener lugar cuando el programador escribe una sentencia en el
editor de código fuente, cuando un módulo es compilado o ensamblado, cuando el código
objeto (compilado o ensamblado) es procesado por el "link-editor" o el "link-loader"
(generalmente este proceso es el conocido como ligado en la mayoría de los sistemas), cuando
el código "imagen-de-memoria" es cargado en la memoria principal, y finalmente cuando el
sistema es ejecutado.
La importancia del tiempo de ligado radica en que cuando el valor de variables dentro de una
pieza de código es fijado más tarde, el sistema es más fácilmente modificable y adaptable al
cambio de requerimientos.
Alternativas:
1. Escribimos el literal "72" en todas las rutinas de impresión de todos los programas.
(ligado en tiempo de escritura)
2. Reemplazamos el literal por la constante manifiesta LONG_PAG a la que asignamos
el valor "72" en todos los programas (ligado en tiempo de compilación)
3. Ponemos la constante LONG_PAG en un archivo de inclusión externo a los
programas (ligado en tiempo de compilación)
4. Nuestro lenguaje no permite la declaración de constantes por lo cual definimos una
variable global LONG_PAG a la que le asignamos el valor de inicialización "72"
(ligado en tiempo de link-edición)
21
Prof. Joel Ayala de la Vega.
5. Definimos un archivo de parámetros del sistema con un campo LONG_PAG al cual se
le asigna el valor "72". Este valor es leído junto con otros parámetros cuando el
sistema se inicia. (ligado en tiempo de ejecución)
6. Definimos en el archivo de parámetros un registro para cada terminal del sistema y
personalizamos el valor del campo LONG_PAG según la impresora que tenga
vinculada cada terminal. De esta forma las terminales que tienen impresoras de 12"
imprimen 72 líneas por página, y las que tienen una impresora de inyección de tinta
que usan papel oficio, imprimen 80. (ligado en tiempo de ejecución)
22
Prof. Joel Ayala de la Vega.
que los módulos no puedan funcionar uno sin el otro. No ocurre lo mismo en el acoplamiento
de control, en el cual un módulo, aunque reciba información de control, puede ser invocado
desde diferentes puntos del sistema.
Siempre que dos o más módulos interactúan con un entorno de datos común, se dice que
dichos módulos están en acoplamiento por entorno común.
Ejemplos de entorno común pueden ser áreas de datos globales como la DATA división del
COBOL o un archivo en disco.
El punto es que el acoplamiento por entorno común no es necesariamente malo y deba ser
evitado a toda costa. Por el contrario existen ciertas circunstancias en que es una opción
válida.
1.10.7 Desacoplamiento
El desacoplamiento, desde el punto de vista funcional, rara vez puede realizarse, excepto en
los comienzos de la fase del diseño.
Como regla general, una disciplina de diseño que favorezca el acoplamiento de entrada-salida
y el acoplamiento de control por sobre el acoplamiento por contenido y el acoplamiento
híbrido, y que busque limitar el alcance del acoplamiento por entorno común es el enfoque
más efectivo.
Convertir las referencias implícitas en explícitas. Lo que puede verse con mayor facilidad
es más fácil de comprender.
Estandarización de las conexiones.
Uso de "buffers" para los elementos comunicados en una conexión. Si un módulo puede ser
diseñado desde el comienzo asumiendo que un buffer mediará cada corriente de
comunicación, las cuestiones temporización, velocidad, frecuencia, etc., dentro de un
módulo no afectarán el diseño de otros.
Localización. Utilizado para reducir el acoplamiento por entorno común. Consiste en
23
Prof. Joel Ayala de la Vega.
dividir el área común en regiones para que los módulos solo tengan acceso a aquellos datos
que les son de su estricta incumbencia.
Acoplamiento normal por datos. Toda conexión se realiza explícitamente por el mínimo
número de parámetros, siendo los datos del tipo primitivo o elemental.
Acoplamiento normal por estampado. Se presenta cuando las piezas de datos que se
intercambian so compuestas como estructuras o arreglos. Cuando se desea establecer el nivel
de acoplamiento de un módulo, cada uno de los datos compuestos es contabilizado como uno,
y no el número de elementos que componen la estructura.
Acoplamiento normal de control. Un módulo intercambia con otro modulo información que
desea alterar la lógica interna del otro modulo. La información indicará expresamente la
acción que debe realizar el otro módulo.
Acoplamiento patológico. Dos módulos tienen la posibilidad de afectar datos de otro a través
de errores en la programación. Esta situación puede darse, por ejemplo, cuando un módulo
escribe los datos de otro modulo, por ejemplo en el mal uso de apuntadores o el uso explícito
de memoria para modificar variables.
1.11 Cohesión
24
Prof. Joel Ayala de la Vega.
Hemos visto que la determinación de módulos en un sistema no es arbitraria. La manera en la
cual dividimos físicamente un sistema en piezas (particularmente en relación con la estructura
del problema) puede afectar significativamente la complejidad estructural del sistema
resultante, así como el número total de referencias intermódulares.
Imaginemos que tengamos una magnitud para medir el grado de relación funcional existente
entre pares de módulos. En términos de tal medida, diremos que el sistema más modularmente
efectivo será aquel cuya suma de relación funcional entre pares de elementos que pertenezcan
a diferentes módulos sea mínima. Entre otras cosas, esto tiende a minimizar el número de
conexiones intermódulares requeridas y el acoplamiento intermódular.
La cohesión modular puede verse como el cemento que amalgama juntos a los elementos de
procesamiento dentro de un mismo módulo. Es el factor más crucial en el diseño estructurado,
y el de mayor importancia en un diseño modular efectivo.
Este concepto representa la técnica principal que posee un diseñador para mantener su diseño
lo más semánticamente próximo al problema real, o dominio de problema.
Ambas medidas son excelentes herramientas para el diseño modular efectivo, pero de las dos
la más importante y extensiva es la cohesión.
25
Prof. Joel Ayala de la Vega.
Una cuestión importante a determinar es como reconocer la relación funcional.
Debe tenerse en mente que la cohesión se aplica sobre todo el módulo, es decir sobre todos
los pares de elementos. Así, si Z está relacionado a X e Y, pero no a A, B, y C, los cuales
pertenecen al mismo módulo, la inclusión de Z en el módulo, redundará en baja cohesión del
mismo.
Primero, un elemento de procesamiento puede ser algo que debe ser realizado en un módulo
pero que aún no ha sido reducido a código. En orden de diseñar sistemas altamente
modulares, debemos poder determinar la cohesión de módulos que todavía no existen.
Diferentes principios asociativos fueron desenvolviéndose a través de los años por medio de
la experimentación, argumentos teóricos, y la experiencia práctica de muchos diseñadores.
Existen siete niveles de cohesión distinguibles por siete principios asociativos. Estos se listan
a continuación en orden creciente del grado de cohesión, de menor a mayor relación
funcional:
26
Prof. Joel Ayala de la Vega.
Cohesión Funcional (la mejor)
La cohesión casual ocurre cuando existe poca o ninguna relación entre los elementos de un
módulo.
Es muy difícil encontrar módulos puramente casuales. Puede aparecer como resultado de la
modularización de un programa ya escrito, en el cual el programador encuentra un
determinada secuencia de instrucciones que se repiten de forma aleatoria, y decide por lo
tanto agruparlas en una rutina.
Otro factor que influenció muchas veces la confección de módulos casualmente cohesivos,
fue la mala práctica de la programación estructurada, cuando los programadores mal
entendían que modularizar consistía en cambiar las sentencias GOTO por llamadas a
subrutinas
Debemos notar que si bien la cohesión casual no es necesariamente perjudicial (de hecho es
preferible un programa casualmente cohesivo a uno lineal), dificulta las modificaciones y
mantenimiento del código.
Los elementos de un módulo están lógicamente asociados si puede pensarse en ellos como
pertenecientes a la misma clase lógica de funciones, es decir aquellas que pueden pensarse
como juntas lógicamente.
Por ejemplo, se puede combinar en un módulo simple todos los elementos de procesamiento
que caen en la clase de "entradas", que abarca todas las operaciones de entrada.
Podemos tener un módulo que lea desde consola una tarjeta con parámetros de control,
registros con transacciones erróneas de un archivo en cinta, registros con transacciones
válidas de otro archivo en cinta, y los registros maestros anteriores de un archivo en disco.
Este módulo que podría llamarse "Lecturas", y que agrupa todas las operaciones de entrada,
es lógicamente cohesivo.
La cohesión lógica es más fuerte que la casual, debido a que representa un mínimo de
asociación entre el problema y los elementos del módulo. Sin embargo podemos ver que un
módulo lógicamente cohesivo no realiza una función específica, sino que abarca una serie de
funciones.
27
Prof. Joel Ayala de la Vega.
1.11.2.3 Cohesión Temporal (de moderada a pobre)
Cohesión temporal significa que todos los elementos de procesamiento de una colección
ocurren en el mismo período de tiempo durante la ejecución del sistema. Debido a que dicho
procesamiento debe o puede realizarse en el mismo período de tiempo, los elementos
asociados temporalmente pueden combinarse en un único módulo que los ejecute a la misma
vez.
Existe una relación entre cohesión lógica y la temporal, sin embargo, la primera no implica
una relación de tiempo entre los elementos de procesamiento. La cohesión temporal es más
fuerte que la cohesión lógica, ya que implica un nivel de relación más: el factor tiempo. Sin
embargo la cohesión temporal aún es pobre en nivel de cohesión y acarrea inconvenientes en
el mantenimiento y modificación del sistema.
Al igual que en los casos anteriores, para decir que un módulo tiene solo cohesión procedural,
los elementos de procesamiento deben ser elementos de alguna iteración, decisión, o
secuencia, pero no deben estar vinculados con ningún principio asociativo de orden superior.
Este nivel de cohesión comúnmente se tiene como resultado de derivar una estructura modular
a partir de modelos de procedimiento como ser diagramas de flujo, o diagramas Nassi-
Shneiderman.
Ninguno de los niveles de cohesión discutidos previamente está fuertemente vinculado a una
estructura de problema en particular. Cohesión de Comunicación es el menor nivel en el cual
encontramos una relación entre los elementos de procesamiento que es
intrínsecamente dependiente del problema.
28
Prof. Joel Ayala de la Vega.
En el diagrama de la figura podemos observar que los elementos de procesamiento 1, 2, y 3,
están asociados por comunicación sobre la corriente de datos de entrada, en tanto que 2, 3, y 4
se vinculan por los datos de salida.
Los diagramas de flujo de datos (DFD) son un medio objetivo para determinar si los
elementos en un módulo están asociados por comunicación.
En términos prácticos podemos decir que cohesión funcional es aquella que no es secuencial,
por comunicación, por procedimiento, temporal, lógica, o casual.
Los ejemplos más claros y comprensibles provienen del campo de las matemáticas. Un
módulo para realizar el cálculo de raíz cuadrada ciertamente será altamente cohesivo, y
probablemente, completamente funcional. Es improbable que haya elementos superfluos más
29
Prof. Joel Ayala de la Vega.
allá de los absolutamente esenciales para realizar la función matemática, y es improbable que
elementos de procesamiento puedan ser agregados sin alterar el cálculo de alguna forma.
En contraste un módulo que calcule raíz cuadrada y coseno, es improbable que sea
enteramente funcional (deben realizarse dos funciones ambiguas).
1. Si la frase resulta ser una sentencia compuesta, contiene una coma, o contiene más de
un verbo, probablemente el módulo realiza más de una función; por tanto,
probablemente tiene vinculación secuencial o de comunicación.
2. Si la frase contiene palabras relativas al tiempo, tales como "primero", "a
continuación", "entonces", "después", "cuando", "al comienzo", etc., entonces
probablemente el módulo tiene una vinculación secuencial o temporal.
3. Si el predicado de la frase no contiene un objeto específico sencillo a continuación del
verbo, probablemente el módulo esté acotado lógicamente. Por ejemplo editar todos
los datos tiene una vinculación lógica; editar sentencia fuente puede tener vinculación
funcional.
4. Palabras tales como "inicializar", "limpiar", etc., implican vinculación temporal.
Donde existe más de una relación entre un par de elementos de procesamiento, se aplica el
máximo nivel que alcanzan. Por esto, si un módulo presenta cohesión lógica entre todos sus
pares de elementos de procesamiento, y a su vez presenta cohesión de comunicación también
entre todos dichos pares, entonces dicho módulo es considerado como de cohesión por
comunicación.
30
Prof. Joel Ayala de la Vega.
Ahora, ¿cuál sería la cohesión de dicho módulo si también contiene algún par de elementos
completamente no relacionados? En teoría, debería tener algún tipo de promedio entre la
cohesión de comunicación y la casual. Para propósitos de depuración, mantenimiento, y
modificación, un módulo se comporta como si fuera "solo tan fuerte como sus vínculos más
débiles".
El efecto sobre los costos de programación es próximo al menor nivel de cohesión aplicable
dentro del módulo en vez del mayor nivel de cohesión.
La decisión de que nivel de cohesión es aplicable a un módulo dado requiere de cierto juicio
humano. Algunos criterios establecidos son:
- Similarmente existe un salto mayor entre la cohesión lógica y la temporal que entre casual y
lógica.
0: casual
1: lógica
3: temporal
5: procedural
7: de comunicación
9: secuencial
10: funcional
La obligación del diseñador es conocer los efectos producidos por la variación en la cohesión,
especialmente en términos de modularidad, en orden de realizar soluciones
de compromiso beneficiando un aspecto en contra de otro.
31
Prof. Joel Ayala de la Vega.
Ejercicios.
32
Prof. Joel Ayala de la Vega.
II PROGRAMACIÓN RECURSIVA
(Cairó/Gardati, 2000)
(Loomis, 2013)
(Levin, 2004) (Serie
de Fibonacci)
http://www.ugr.es/~eaznar/fibo.htm
Según el punto donde se realice la llamada recursiva, la función recursiva puede ser:
Las funciones recursivas finales suelen ser más eficientes (en la constante multiplicativa en
cuanto al tiempo, y sobre todo en cuanto al espacio de memoria) que las no finales. (Algunos
compiladores pueden optimizar automáticamente estas funciones pasándolas a iterativas).
33
Prof. Joel Ayala de la Vega.
2.2 Diseño de funciones recursivas
34
Prof. Joel Ayala de la Vega.
Un requisito importante para que sea correcto un algoritmo recursivo es que no genere una
secuencia infinita de llamadas así mismo.
Otro elemento a tomar en cuenta es la facilidad para comprobar y verificar que la solución es
correcta (inducción matemática).
En general, las soluciones recursivas son más ineficientes en tiempo y espacio que las
versiones iterativas, debido a las llamadas a subprogramas, la creación de variables dinámicas
en la pila recursiva y la duplicación de variables. Otra desventaja es que en algunas soluciones
recursivas repiten cálculos en forma innecesaria. Por ejemplo, el cálculo del n-ésimo término
de la sucesión de Fibonacci.
Ejercicios.
1. Realice un árbol recursivo con Fibonacci(5)
2. Realice un árbol recursivo con la función de Hanoi(4,o,d,a)
Hanoi(N,Origen, Destino, Auxiliar)
Si N=1
Imprime “Mover disco de” Origen “a” Destino
Sino
Hanoi(N-1, Origen, Auxiliar, Destino)
35
Prof. Joel Ayala de la Vega.
Imprime “Mover disco de” Origen “a” Destino
Hanoi(N-1, Auxiliar, Destino, Origen)
N+1 Si M=0
4. El algoritmo de Euclides para el cálculo del máximo común divisor se define como:
M Si N=0
MCD(M,N)
5. Realizar un programa que imprima una palabra al revés (no importando su dimensión)
sin el uso de arreglos o memoria dinámica.
36
Prof. Joel Ayala de la Vega.
III INTRODUCCIÓN A LA ALGORITMICA
(Ellis horowitz, 1978)
(Dasgupta, Papadimitriou, & Vazirani, 2008)
(Dewdney, 1989)
Dos ideas cambiaron al mundo. En 1448 en la ciudad de Mainz, un orfebre llamado Johann
Gutenberg descubrió el camino para imprimir libros colocando juntas dos piezas metálicas
móviles. En este momento se inició la disipación de la edad obscura y el intelecto humano se
liberaba, la ciencia y la tecnología habían triunfado, con ello inició la fermentación de la
semilla que culminó con la revolución industrial. Varios historiadores indican que nosotros se
lo debemos a la tipografía. Imagine a un mundo en el cual sólo una elite podría leer estas
líneas. Pero otros insisten que la llave del desarrollo no fue la tipografía, sino que fue la
algorítmica.
37
Prof. Joel Ayala de la Vega.
Figura 3.2 Al Khwarizmi
(Vivió entre el año 780 y 850 D. C)
Formalmente hablando, los números de Fibonacci Fn son generados por una regla simple:
No existe otra secuencia de números que haya sido estudiada en forma tan extensa, o aplicada
a más campos del conocimiento: biología, demografía, arte, arquitectura, música, para
nombrar algunos de los campos. Y junto con la potencia de dos, es en ciencias de la
computación una de las secuencias favoritas.
38
Prof. Joel Ayala de la Vega.
En realidad, la secuencia de Fibonacci crece tan rápido como la potencia de dos: por ejemplo,
F30 rebasa el millón, y la secuencia F100 tiene aproximadamente 21 dígitos de largo. En
general, Fn ≈ 20.694n .
Peor, ¿Cuál es el valor exacto de F100 o de F200 ? Fibonacci nunca supo la respuesta. Para
saberlo, necesitamos un algoritmo para computar el n-ésimo número de Fibonacci.
Una idea es utilizar la definición recursiva de Fn . El pseudocódigo se muestra enseguida:
Función fib(n){
Si n=0 retorna 0
Si n=1 retorna 1
Retorna fib(n-1) + fib(n-2)
}
Todas las veces que se tiene un algoritmo, existen preguntas que se deben de responder:
1. ¿Es correcto?
2. ¿Cuánto tiempo tarda en dar el resultado, cómo una función de n?
3. ¿Se puede mejorar?
El tiempo de ejecución del algoritmo crece tan rápido como los números de Fibonacci: T(n) es
exponencial en n, el cual implica que el algoritmo es impráctico exceptuando para valores
muy pequeños de n.
39
Prof. Joel Ayala de la Vega.
Y en general por inducción, el número de sumas para calcular F(n) es igual a
S(n)=S(n-1)+S(n-2)+1
Por ejemplo, para calcular F200 , la función fib( ) se ejecuta T(200) ≥ F200 ≥ 2138 pasos
elementares de cómputo. ¿Cuánto tiempo se requiere? Bueno, eso depende de la computadora
usada. En este momento, la computadora más veloz en el mundo es la NEC Earth Simulator,
con 400 trillones de pasos por segundo. Aún para ésta máquina, fib(200) tardará al menos 292
segundos. Esto significa que, si nosotros iniciamos el cómputo hoy, estaría trabajando después
de que el sol se torne una estrella roja gigante.
Pero la tecnología se ha mejorado de tal forma que los pasos de computo se han estado en
forma aproximada duplicando cada 18 meses, a tal fenómeno se le conoce la ley de Moore.
Con éste extraordinario crecimiento, posiblemente la función fib se ejecutará en forma mucho
más rápida para el próximo año. Chequemos éste dato, El tiempo de ejecución de fib(n) ≈
20.694n ≈ (1.6)n, por lo que toma 1.6 veces más tiempo para computar Fn+1 que Fn . Y bajo la
ley de Moore, el poder de computo crece aproximadamente 1.6 veces cada año. Si nosotros
podemos computar en forma razonable F100 con el crecimiento de la tecnología, el siguiente
año se podrá calcular F101 . y el siguiente año, F102 y así sucesivamente: Solo un número de
Fibonacci más cada año. Así es el comportamiento de un tiempo exponencial.
En corto, nuestro algoritmo recursivo es correcto pero sumamente ineficiente. ¿Podemos
hacerlo mejor?
El algoritmo se vuelve ineficiente por la razón de que una llamada a fib(n) se tiene una
cascada de llamadas recursivas en las cuales la mayoría de ellas son repetitivas.
40
Prof. Joel Ayala de la Vega.
Fig. 1.4. Árbol recursivo con la función de Fibonacci.
#include <stdio.h>
main(){
int i, n=5, fibn_2,fibn_1, fibn;
fibn_2=fibn_1=1;
for ( i =2;i<=n; i++){
fibn=fibn_2+fibn_1;
printf ("fibo (%d) =%d\n", i , fibn);
fibn_2=fibn_1;
fibn_1=fibn;
}
getchar ();
}
¿Cuánto tiempo toma el algoritmo? El loop tiene sólo un paso y se ejecuta n-1 veces. Por lo
que el algoritmo se considera lineal en n. De un tiempo exponencial, hemos pasado a un
tiempo polinomial, un gran progreso en tiempo de ejecución. Es ahora razonable calcular F200
o aun F200000.
41
Prof. Joel Ayala de la Vega.
IV COMPLEJIDAD Y ORDEN
(Ellis horowitz, 1978)
(Dasgupta, Papadimitriou, & Vazirani, 2008)
(Diccionario de la Real Academia Española)
(Complejidad Algorítmica)
¿Qué es un algoritmo?
El Diccionario de la Real Academia Española lo define como:
Un algoritmo está compuesto de un conjunto finito de pasos, cada paso puede requerir una o
más operaciones. Cada operación debe estar definida. Cada paso debe ser tal que deba, al
menos, ser hecha por una persona usando lápiz y papel en un tiempo finito.
Existe una gran diferencia entre receta y algoritmo:
Receta: Colocar sal al gusto.
Algoritmo: Debe de indicarse cada paso con exactitud.
Otra palabra que obedece a casi todo lo indicado es “proceso computacional”. Un ejemplo es
el Sistema Operativo. Este proceso está diseñado para controlar la ejecución de trabajos, en
teoría, el proceso nunca termina ya que se queda en estado de espera hasta que la solicitud de
otro trabajo llegue.
42
Prof. Joel Ayala de la Vega.
Estas operaciones se pueden acotar en un tiempo constante. Ejemplo, la comparación entre
caracteres se puede hacer en un tiempo fijo.
La comparación de las cadenas depende del tamaño de las cadenas. (No se puede acotar el
tiempo).
Para ver los tiempos de un algoritmo se requiere un análisis a priori y no a posteriori. En un
análisis a priori se obtiene una función que acota el tiempo del algoritmo. En un análisis a
posteriori se colecta una estadística sobre el desarrollo del algoritmo en tiempo y espacio al
momento de la ejecución de tal algoritmo.
El análisis a priori ignora qué tipo de máquina es, el lenguaje, y sólo se concentra en
determinar el orden de magnitud de la frecuencia de ejecución.
Notación O
F(n) = O(g(n)) iff existen 2 constantes “c” y “ no” tal que
| f(n)| ≤c|g(n)| para toda n > no.
Cuando se dice que un algoritmo tiene un tiempo computacional O(g(n)), indica que el
algoritmo se corre en una computadora x con el mismo tipo de datos pero con n mayor, el
tiempo será menor que algún tiempo constante |g(n)|.
Como ejemplo para comprender el orden de magnitud suponga que se tienen dos algoritmos
para la misma tarea en el cual requieren uno del O(n2) y el otro del
O(n log n). Si n = 1024 se requiere para el primer algoritmo 1048576 operaciones y para el
segundo algoritmo son 10241 operaciones. Si la computadora toma un μseg para realizar
cada operación, el algoritmo uno requiere aproximadamente 1.05 segundos y el segundo
algoritmo requiere aproximadamente 0.0102 segundos para la misma entrada.
Los tiempos más comunes son:
O(1) < O(log n) < O(n) < O(n log n) < O(n2) < O(n3) < O(2n).
43
Prof. Joel Ayala de la Vega.
Nota: La base del logaritmo es dos. (Logb N = Loga N/ Logb b)
O(1) El número de operaciones básicas es fijo por lo que el tiempo se acota por una
constante.
O(n), O(n2) y O(n3) son del tipo polinomial.
O(2n) es de tipo exponencial.
Los algoritmos con complejidad mayor a O(n log n) a veces son imprácticos.
Un algoritmo exponencial es práctico sólo con un valor pequeño de n.
Ejemplo:
Otra tabla comparativa es la siguiente en la que se supone que la computadora puede hacer un
millón de operaciones por segundo:
44
Prof. Joel Ayala de la Vega.
Figura 4.1
La notación O (o gran O) sirve para indicar una cota superior. También se puede definir una
cota inferior.
F(n) = Ώ(g(n)) iff existe una constante “c” y “ no” tal que
| f(n)| ≥c|g(n)| para toda n> no
Si F(n) = Ώ(g(n)) y f(n)= O(g(n)) entonces:
F(n) = Ө(g(n)) iff existen constantes positivas c y no tal que para toda n > no ,
C1 |g(n)| ≤ |f(n)| ≤ C2|g(n)|
Esto indica que el peor y el mejor caso marcan la misma cantidad de tiempo.
Ejemplo: un algoritmo que busca el máximo en n elementos desordenados siempre realizará
n-1 iteraciones, por lo tanto
Ө(n).
Ejercicios
1. Dado el algoritmo de las Torres de Hanoi mostrado en el capítulo de recursividad,
determinar su orden. Suponiendo que se tienen 63 anillos.
2. Investigue la complejidad del algoritmo de Ackermann y compárelo con el algoritmo
de las Torres de Hanoi.
45
Prof. Joel Ayala de la Vega.
V ORDENACIÓN Y BUSQUEDA
(Cairó/Gardati, 2000)
(Loomis, 2013)
(Ellis horowitz, 1978)
(Método de la Burbuja)
Es el algoritmo más sencillo. Ideal para empezar. Consiste en ciclar repetidamente a través de
una lista, comparando elementos adyacentes de dos en dos. Si un elemento es mayor que el
que está en la siguiente posición se intercambian.
El algoritmo en pseudocódigo en C es:
Dónde:
lista: Es cualquier lista a ordenar
TAM: Es una constante que determina el tamaño de la lista.
i, j: Contadores
temp: Permite realizar los intercambios de la lista
46
Prof. Joel Ayala de la Vega.
Estabilidad: Este algoritmo nunca intercambia registros con claves iguales. Por lo
tanto es estable.
Requerimientos de Memoria: Este algoritmo sólo requiere de una variable
adicional para realizar los intercambios.
Tiempo de Ejecución: El ciclo interno se ejecuta n veces para una lista de n
elementos. El ciclo externo también se ejecuta n veces. Es decir, la complejidad
es O(n2). El comportamiento del caso promedio depende del orden de entrada de
los datos, pero es sólo un poco mejor que el del peor caso, y sigue siendo O(n2).
Ventajas:
Fácil implementación.
No requiere memoria adicional.
Desventajas:
Muy lento.
Realiza numerosas comparaciones.
Realiza numerosos intercambios.
Cálculo de la complejidad:
Por lo que:
T(n)=N + 5*N2 por lo que la complejidad es O(n2).
La idea básica de éste algoritmo consiste en buscar el menor elemento del arreglo y colocarlo
a la primera posición. Luego se busca el segundo elemento más pequeño del arreglo y se
coloca en la segunda posición. El proceso continúa hasta que todos los elementos del arreglo
hayan sido ordenados. El método se basa en los siguientes principios:
Seleccionar el menor elemento del arreglo
Intercambiar dicho elemento con el primero
Repetir los pasos anteriores con los (n-1), (n-2) elementos, y así sucesivamente hasta
que sólo quede el elemento mayor.
47
Prof. Joel Ayala de la Vega.
El algoritmo en código C es:
48
Prof. Joel Ayala de la Vega.
#include <stdio.h>
main(){
int MENOR, i, a[7]={10,7,6,4,9,8,1},j , k , m;
for (i=0;i<6;i++){
MENOR = a[i];
for (j=i+1; j<7;j++){
if (a[j] <MENOR){
MENOR=a[j];
k=j;
}
}
if (MENOR<a[i]){
a[k] =a[i];
a[i] =MENOR;
}
}
for (i=0; i<7;i++)
printf ("%d...”, a[i]);
getchar ();
}
C=(n-1)+(n-2)+. . . +2+1
Ahora bien, utilizando el truco de Gauss para la suma de números naturales se tiene:
Sn = 1+ 2+ 3+ 4 +…+(n-3)+(n-2)+(n-1)+ n
Sn = n+ (n-1)+(n-2)+(N-3)+…+ 4+ 3+ 2+ 1
2Sn=(n+1)+(n+1)+(n+1)+(n+1)+…+(n+1)+(n+1)+(n+1)+(n+1)
2Sn=n*(n+1), por lo tanto, Sn=n*(n+1)/2
Sn = 1+ 2+ 3 + …+(n-3)+(n-2)+(n-1)
Sn = (n-1)+(n-2)+(N-3)+(n-4)+ … + 2+ 1
2Sn= (n-1)+(n-1)+(n-1)+(n+1)+… + (n+1)+(n+1)+(n-1)
2Sn=n*(n-1), por lo tanto, Sn=n*(n-1)/2
49
Prof. Joel Ayala de la Vega.
5.3 Método de inserción binaria
El método de ordenación por inserción binaria realiza una búsqueda binara el lugar de una
búsqueda secuencial, para insertar un elemento en la parte izquierda del arreglo, que ya se
encuentra ordenado. El proceso se repite desde el segundo hasta el n-ésimo elemento.
#include <stdio.h>
main(){
int a[]={10,8,7,2,1,3,5,4,6,9},i,aux,der,izq,m,j;
for (i=1;i<10;i++){
aux=a[i];
izq=0;
der=i-1;
while(izq<=der){
m=(izq+der)/2;
if (aux<=a[m])
der=m-1;
else
izq=m+1;
}
j=i-1;
while(j>=izq){
a[j+1]=a[j];
j--;
}
a[izq]=aux;
}
for (i=0;i<10;i++)
printf("%d..",a[i]);
putchar('\n');
getchar();
}
C=1/2+2/2+3/2+…+(n-1)/2=(n*(n-1))/4=(n2-n)/4
Por lo tanto, el tiempo de ejecución del algoritmo sigue siendo proporcional a O(n2).
50
Prof. Joel Ayala de la Vega.
elementos del arreglo. Su autor, C. A. Hoare, lo llamó así. La idea central de este algoritmo
consiste en lo siguiente:
1. Se toma un elemento X de una posición cualquiera del arreglo.
2. Se trata de ubicar a X en la posición correcta del arreglo, de tal forma que todos
los elementos que se encuentren a su izquierda sean menores o iguales a X y todos
los que se encuentran a su derecha sean mayores o iguales a X.
3. Se repiten los pasos anteriores, pero ahora para los conjuntos de datos que se
encuentran a la izquierda y a la derecha de la posición X en el arreglo.
4. El proceso termina cuando todos los elementos se encuentran en su posición
correcta en el arreglo.
En este caso, para la programación del algoritmo, el elemento X será el primer elemento de la
lista. Se empieza a recorrer el arreglo de derecha a izquierda comparando si los elementos son
mayores o iguales a X. Si un elemento no cumple con esta condición, se intercambian
aquellos y se almacena en una variable la posición la posición del elemento intercambiado –se
acota el arreglo por la derecha-. Se inicia nuevamente el recorrido, pero ahora de izquierda a
derecha, comparando si los elementos son menores o iguales a X. Si un elemento no cumple
con esta condición, entonces se intercambian aquellos y se almacena en otra variable la
posición del elemento intercambiado –se acota el arreglo por la izquierda-. Se repiten los
pasos anteriores hasta que el elemento X encuentra su posición correcta en el arreglo. En este
momento, dependiendo del lugar que ocupe el valor de X, se hará una recursividad izquierda
tomando sólo los primeros elementos del subarreglo hasta el vecino a la izquierda de X o una
recursividad a la derecha del vecino más cercano a la derecha de X hasta el final del
subarreglo.
El algoritmo se muestra en C.
#include <stdio.h>
#define N 10
void quicksort(int [], int, int);
main(){
int a[]={10,8,7,2,1,3,5,4,6,9},i;
quicksort(a,0,N-1);
for (i=0;i<10;i++)
printf("%d..",a[i]);
putchar('\n');
getchar();
}
51
Prof. Joel Ayala de la Vega.
izq++;
if (pos!=izq){
band=1;
aux=a[pos];
a[pos]=a[izq];
a[izq]=aux;
pos=izq;
}
}
}
if (pos-1>ini)
quicksort(a, ini, pos-1);
if (fin>pos+1)
quicksort(a,pos+1,fin);
}
El método quicksort es el más rápido de ordenación interna que existe en la actualidad. Esto
es sorprendente, porque el método tiene su origen en el método de intercambio directo, el peor
de todos los métodos directos. Diversos estudios realizados sobre su comportamiento
demuestran que si se escoge en cada pasada el elemento que ocupa la posición central del
conjunto de datos a analizar, el número de comparaciones, si el tamaño del arreglo es una
potencia de 2, en la primera pasada realiza (n-1) comparaciones, en la segunda (n-1)/2
comparaciones, pero en dos conjuntos diferentes, en la tercera realizará (n-1)/4
comparaciones, pero en cuatro conjuntos diferentes y así sucesivamente, esto produce un
árbol binario recursivo. Por lo tanto:
C=(n-1)+2*(n-1)/2+4*(n-1)/4+. . . (n-1)*(n-1)/(n-1)
C=(n-1)*k
Considerando que el número de términos de la sumatoria (k) es el número de niveles del árbol
binario, el número de elementos del arreglo se puede definir como 2k=n, por lo que log 2
2k=log 2 n, k log 2 2 = log 2 n (recordando que log m m=1), k =log 2 n, por lo que la expresión
anterior queda como:
52
Prof. Joel Ayala de la Vega.
C=(n-*1)* log n
Sin embargo, encontrar el elemento que ocupe la posición central del conjunto de datos que se
van a analizar es una tarea difícil, ya que existen 1/n posibilades de lograrlo. Además, el
rendimiento medio del método es aproximadamente (2*ln 2) inferior al caso óptimo, por lo
que Hoare, el autor del método, propone como solución que el elemento X se seleccione
arbitrariamente o bien entre una muestra relativamente pequeña de elemento del arreglo.
El peor caso ocurre cuando los elementos del arreglo ya se encuentran ordenados, o bien
cuando se encuentran ordenados en forma inversa. Supongamos que se debe ordenar el
siguiente arreglo unidimensional que ya se encuentra ordenado:
A: 08 12 15 16 27 35 44 67
Si se escoge arbitrariamente el primer elemento (08), entonces se particionará el arreglo en
dos mitades, una de 0 y la otra de (n-1) elementos.
Si se continua con el proceso de ordenamiento y se escoge de nuevo el primer elemento (12)
del conjunto de datos que se analizará, entonces de dividirá el arreglo en dos nuevos
subconjuntos, nuevamente uno de 0 y otro de (n-2) elementos. Por lo tanto, el número de
comparaciones que se realizará será:
Cmáx=n+(n-1)+(n-2)+. . .+2=n*(n-1)/2-1
Que es igual a:
Cmáx=(n2+n)/2-1
Éste algoritmo ordena elementos y tiene la propiedad de que el peor caso en complejidad será:
O(n log n). Los elementos van a ser ordenados en forma creciente. Dados n elementos, éstos
se dividirán en 2 subconjuntos. Cada subconjunto será ordenado y el resultado será unido para
producir una secuencia de elementos ordenados. El código en C es el siguiente:
#include <stdio.h>
#define N 10
void mergesort(int [],int,int);
void merge(int [],int,int,int);
main(){
int i,a[N]={9,7,10,8,2,4,6,5,1,3};
mergesort(a,0,9);
for (i=0;i<10;i++)
printf("%d..",a[i]);
getchar();
}
52
Prof. Joel Ayala de la Vega.
mid=(low+high)/2;
mergesort(a,low,mid);
mergesort(a,mid+1,high);
merge(a,low,mid,high);
}
}
Considere el arreglo de diez elementos A(310, 285, 179, 652, 351, 423, 861, 254, 450, 520).
MERGESORT inicia por dividir A en dos subarreglos de tamaño cinco. Los elementos A(1:5)
son a su vez divididos en arreglos de tamaño tres y dos. Entonces l los elementos A(1:3) son
divididos en dos sus arreglos de tamaño dos y uno. Los dos valores en A(1:2) son divididos
en un subarreglo de un solo elemento y la fusión inicia.. Hasta éste momento ningún
movimiento ha sido realizado. Pictóricamente el arreglo puede ser visto de la siguiente
forma:
(310|285|179|652, 351|423, 861, 254, 450, 520)
Las barras verticales indican el acotamiento de los subarreglos. A(1) and A(2) son fusionados
produciendo:
(285, 310| 179|652, 351| 423, 861, 254, 450, 520)
53
Prof. Joel Ayala de la Vega.
(179, 285, 310|652, 351|423, 861,254, 450, 520)
Los elementos A(4) y A(5) son fusionados:
(179, 285, 310|351, 652| 423, 861, 254, 450, 520)
Siguiendo la fusión de A(1:3) y A(4:5) se tiene
(179, 285, 310, 351, 652| 423, 861, 254, 450, 520)
La figura 5.1 muestra la secuencia de recursividades producidas por MERGESORT con los
diez elementos. Note que la división continúa hasta contener un simple elemento.
El tiempo de cómputo para mergesort se describe a continuación:
54
Prof. Joel Ayala de la Vega.
T(n) = O(n log n)
Este algoritmo es un ejemplo clásico del paradigna “divide y vencerás” que se explica
posteriormente.
La búsqueda secuencial consiste en revisar elemento tras elemento hasta encontrar el dato
buscado, o llegar al final del conjunto de datos disponible.
Normalmente cuando una función de búsqueda concluye con éxito, interesa conocer en qué
posición fue hallado el elemento que se estaba buscando. Esta idea se puede generalizar para
todos los métodos de búsqueda.
A continuación se presenta el algoritmo de búsqueda secuencial en arreglos desordenados en
código C.
#include <stdio.h>
#define N 10
main(){
int x,i=0,a[N]={9,7,10,8,2,4,6,5,1,3};
scanf("%d",&x);
while (i<N && a[i]!=x)
i++;
if (i>N-1)
printf("dato no encontrado");
else
printf("El dato se encuentra en la posici[on %d\n",i);
getchar();
getchar();
}
Si hubiera dos o más ocurrencias del mismo valor, se encuentra la primera de ellas. Sin
embargo, es posible modificar el algoritmo para obtener todas las ocurrencias de datos
buscados.
A continuación se presenta una variante de este algoritmo, pero utilizando recursividad, en
lugar de interactividad.
#include <stdio.h>
#define N 10
void secuencial(int [], int,int,int);
main(){
int x,i=0,a[N]={9,7,10,8,2,4,6,5,1,3};
scanf("%d",&x);
secuencial(a,N,x,0);
getchar();
getchar();
}
void secuencial(int a[],int n, int x, int i){
if (i>n-1)
printf("Dato no localizado\n");
55
Prof. Joel Ayala de la Vega.
else if (a[i]==x)
printf("dato localizado en la posicion %d\n",i);
else
secuencial(a,n,x,i+1);
}
56
Prof. Joel Ayala de la Vega.
printf("Elemento localizado en el lugar %d.\n",j);
getchar();
getchar();
}
Teorema. Si n está en el rango [2k-1, 2k] el algoritmo de búsqueda binaria hace máximo k
comparaciones. Por lo que requiere máximo O(log n) para un éxito y para un fracaso Ө(log
n)
57
Prof. Joel Ayala de la Vega.
PARADIGMAS.
VI DIVIDE Y CONQUISTARAS.
(Ellis horowitz, 1978)
(Dasgupta, Papadimitriou, & Vazirani, 2008)
(Abellanas & Lodares, 1990)
(Goldshlager & Lister, 1986)
Dada una función para computar n entradas, la estrategia divide y conquistaras sugiere dividir
la entrada en k subconjuntos, 1< k ≤ n produciendo k subproblemas. Estos subproblemas
deben ser resueltos y entonces un método debe ser encontrado para combinar las
subsoluciones dentro de una solución como un todo. Si el subproblema es aún demasiado
grande, entonces puede ser reaplicada la estrategia. Frecuentemente los subproblemas
resultantes de un diseño divide y conquistaras son del mismo tipo del problema original. El
principio divide y conquistaras se expresa en forma natural por un procedimiento recursivo.
Por lo que se obtienen subproblemas de menor dimensión de la misma clase, eventualmente
se producen subproblemas que son lo suficientemente pequeños que son resueltos sin la
necesidad de dividirlos.
Una forma general de ver el modelo es:
En este caso, la función small determina si se cumple una condición especifica para que el
algoritmo se detenga, si no es así se crean dos subespacios en los cuales se puede subdivide el
espacio en dos más pequeños.
58
Prof. Joel Ayala de la Vega.
f(n) es el tiempo que se dedica para calcular las funciones DIVIDE y COMBINE.
Ahora el mejor caso es cuando los elementos están en forma creciente ya que en el mejor de
los casos se requier n-1 comparaciones en el mejor de los casos y en el peor de los casos se
requieren 2(n-1) comparaciones. El promedio será.
[2(n-1)+n-1]/2 = 3n/2 – 1
A continuación se muestra un algoritmo recursivo que encuentra el máximo y el mínimo de
un conjunto de elementos y maneja la estrategia de divide y conquistaras. Este algoritmo
envía cuatro parámetros, los dos primeros se manejan como paso de parámetros por valor y
los dos últimos se manejan como pase de parámetros por referencia. En este caso, el segundo
y tercer parámetro indican el subconjunto a analizar y los dos últimos parámetros sirven para
retornar el mínimo y máximo de un subconjunto determinado. Al término de la recursión se
obtienen el mínimo y máximo del conjunto dado. Se muestra el algoritmo en C:
59
Prof. Joel Ayala de la Vega.
#include <stdio.h>
void MaxMin(int [],int, int, int *, int *);
int max(int, int);
int min(int, int);
main(){
int fmax,fmin,a[]={4,2,10,5,-7,9,80,6,3,1};
MaxMin(a,0,9,&fmax,&fmin);
printf("El maximo valor es %d y el minimo valor es %d\n",fmax,fmin);
getchar();
}
Ejemplo:
A(n) 22 13 -5 -8 15 60 17 31 47
60
Prof. Joel Ayala de la Vega.
Fig. 6.1 Árbol recursivo con el algoritmo MaxMin.
Por lo tanto
Si n es igual a 16 se tiene:
T(16)=2T(8) + 2 = 2(2T(4)+2) + 2 = 4T(4) + 4 + 2 = 4(2T(2)+2) +4 +2
T(16) = 8T(2) + 8 + 4 + 2
Donde n=24, K = 4 y T(2)=1; por lo tanto:
T(16)= 23 + 24 – 2 = 3*16/2 – 2 = 22
Observe que 3n/2 – 2 es el mejor promedio y peor caso si n es poder de 2. Comparado con 2n-
2 comparaciones existe un ahorro del 25%. Por lo que es mejor que el secuencial. Pero:
¿Esto indica que sea mejor en la práctica? No necesariamente. En términos de
almacenamiento es peor ya que requiere una pila para guardar a i , j, fmax, fmin. Dados n
elementos se requieren log n +1 niveles de recursión. Se requieren guardar 5 valores y el
direccionamiento de retorno. Por lo MaxMin es más ineficiente ya que se maneja una pila y
recursión.
61
Prof. Joel Ayala de la Vega.
VII MÉTODO CODICIOSO. (Greedy)
(Ellis horowitz, 1978)
(Dasgupta, Papadimitriou, & Vazirani, 2008)
La mayoría de estos problemas tiene n entradas y requiere tener un subconjunto que satisfaga
ciertas restricciones. Cualquier subconjunto que satisfaga estas restricciones se conoce como
solución factible. El problema requiere encontrar una solución factible que maximice o
minimice una función objetivo dada. Existe una forma obvia de encontrar un punto factible,
pero no necesariamente óptima.
El método greedy sugiere que uno puede hacer un algoritmo que trabaje por pasos,
considerando una entrada a la vez. En cada paso se realiza una decisión. Si la inclusión de la
siguiente entrada da una solución no factible, la entrada no se adiciona a la solución parcial.
Enseguida se muestra en pseudocódigo la estrategia greedy:
Procedure Greedy.
//A(1:n) contiene n entradas.
solución ← φ
for i ← to n do
x ← select (A)
if feasible(solución, x) then
Solución ← UNION(Solución, x)
endif
repeat
return (solución)
end Greedy.
Existen n archivos que son almacenados en una cinta de tamaño L. Asociado con cada archivo
i hay un tamaño Li, 1 ≤ i ≤ n. Todos los archivos pueden ser guardados en cinta iff la suma del
tamaño de los archivos es máximo L. Si los archivos son guardados en orden I=i1, i1, i2, i3, . .
., in y el tiempo requerido para guardar o recuperar el archivo ij es Tj= ∑1 ≤ k ≤ j Li,k. Si todo
archivo es leído con la misma frecuencia, entonces el Tiempo de Referencia Medio (TRM) es
(1/n)∑1 ≤ j ≤ n tj . En el problema del almacenamiento óptimo, se requiere encontrar una
permutación para n de tal forma que se minimice el TRM. Minimizar TRM es equivalente a
minimizar D(I)= ∑1 ≤ k ≤ j ∑1 ≤ k ≤ j Ii, k .
Ejemplo:
N=3 (I1, I2, I3) =(5, 10, 3). Existen
N!=6 posibles ordenaciones.
62
Prof. Joel Ayala de la Vega.
El orden óptimo es (3,1,2)
En este algoritmo, el método greedy requiere que los archivos sean almacenados en forma
creciente Esta ordenación puede realizarse por medio de un algoritmo de ordenación como
Merge Sort, por lo que requiere O(n log n).
Se tienen n objetos y una mochila. El objeto i tiene un peso Wi y la mochila tiene una
capacidad M. Si una fracción Xi, 0 ≤ Xi ≤ 1 del objeto i se introduce, se tendrá una ganancia
Pi Xi. El objetivo es llenar la mochila de tal forma que maximice la ganancia.
El problema es:
Una posible solución es cualquier conjunto (X1, X2 , . . .,Xn ) que satisfaga (2) y (3). Una
solución óptima es una solución factible en el cual maximice la ganancia (1).
Ejemplo: n=3, M=20, (P1, P2, P3) = (25, 24, 15)
(W1, W2, W3 )= (18, 15, 10)
#include <stdio.h>
#define N 3
main(){
float Cu,M=20, P[]={24,15,25},W[]={15,10,18},X[]={0,0,0},ganancia=0;
float R[N];
63
Prof. Joel Ayala de la Vega.
int i;
Cu=M;
for (i=0;i<N;i++){
if(W[i]>Cu)
break;
X[i]=1;
Cu=Cu-W[i];
}
if (i<N)
X[i]=Cu/W[i];
for (i=0;i<N;i++){
printf("X[%d]=%f..",i,X[i]);
ganancia=ganancia+X[i]*P[i];
}
printf("\nGanancia=%f\n",ganancia);
getchar();
}
Mientras que las dos primeras estrategias no garantizan la solución óptima para el problema
de la mochila, el siguiente teorema muestra que la tercera estrategia siempre obtiene una
solución óptima.
Teorema: Si P1/W1 ≥ P2/W2 ≥ P3/W3 ≥. . . ≥ Pn/Wn entonces el algoritmo de la mochila
genera un óptimo a la instancia dada por el problema.
64
Prof. Joel Ayala de la Vega.
Fig. 7.1 Patrón óptimo de concatenación.
Si di es la distancia entre la raíz y el nodo extremo del archivo Fi y qi, el tamaño de Fi,
entonces el número total de movimientos de registros se define como ∑i=1 hasta n di qi, esta suma
se conoce como el peso específico de la trayectoria para la parte externa del árbol binario.
El algoritmo ÁRBOL contiene una lista de entrada L con n árboles. Cada árbol tiene tres
campos, LCHILD, RCHILD y peso. Al inicio, cada árbol sólo tiene un nodo y todos los nodos
son externos. El peso representa el número de registros del archivo. WEIGHT(T) es el tamaño
de los archivos a unirse. El procedimiento TREE emplea tres subalgoritmos:
GETNODE(T) provee el nuevo nodo a usarse en el árbol a construirse. LEAST(L) encuentra
un árbol en L cuya raíz tiene el menor peso. Este árbol es removido de L. INSERT(L, T)
inserta el árbol con raíz T en la lista L.
Ejemplo:
Sea L=(2, 3, 5, 7, 9, 13) el tamaño de seis archivos. En la figura 3.2 se mostrará el final de
cada iteración del “for” (un árbol de unión binaria.)
65
Prof. Joel Ayala de la Vega.
Fig. 7.2 Un ejemplo de árbol de unión binaria.
Definición: Sea G(V, E) una gráfica no direccionada. Una subgráfica T(V, E`) es un árbol de
extensión de G iff T es un árbol.
66
Prof. Joel Ayala de la Vega.
Si los nodos representan ciudades y el segmento que las une representa una posible unión
entre ambas ciudades, entonces el mínimo número de segmentos para unir las n ciudades es n-
1. El árbol de extensión representa todas las posibles combinaciones.
En una situación práctica, los segmentos tendrán pesos asignados. Estos pesos pueden
representar costos de construcción, distancias, etc. Ahora, dado un peso, lo que se desea es
conectarse a todos los nodos con el mínimo costo. En cualquier caso, los segmentos
seleccionados formarán un árbol (suponiendo todos los costos positivos). El interés será
localizar un árbol de extensión en G con el costo mínimo.
Un método Greedy para obtener un mínimo costo será ir edificando el árbol segmento por
segmento. El siguiente segmento a escoger será aquel en que se minimice el incremento en
costos.
Si A es el conjunto de segmentos seleccionados hasta el momento, entonces A forma un árbol.
El siguiente segmento (u, v) a ser incluido en A es un segmento con costo mínimo no en A
con la propiedad de que A U {u, v} también es un árbol. Este algoritmo se conoce como el
algoritmo de Prim.
67
Prof. Joel Ayala de la Vega.
Fig. 7.5 Solución paso a paso del árbol de expansión mínima.
El algoritmo inicia con un árbol que incluye sólo un borde con costo mínimo de G. Entonces,
las aristas serán adicionadas al árbol una por una. La siguiente arista (i, j) a ser adicionada es
tal que el vértice i se encuentra incluido en el árbol y j es el vértice aún no incluido y el
COSTO(i, j) es mínimo sobre todas la aristas (k, l) donde el vértice k es parte del árbol y el
vértice l no se encuentra en el árbol. En orden de determinar este vértice (i, j) en forma
eficiente, nosotros asociamos por cada vértice j aún no incluido en el árbol un valor NEAR(j).
NEAR(j) es un vértice en el árbol tal que COSTO(j, NEAR(j)) es mínimo sobre todas las
elecciones para NEAR(j). Se define NEAR(j)=0 para todos los vértices j que se encuentran en
el árbol. La siguiente arista a incluir es definida por el vértice j tal que NEAR(j) ≠0 (j no se
encuentra aún en el árbol) y COSTO(j, NEAR(j)) es mínimo.
#include <stdio.h>
#define TAM 6
main(){
//costo(n,n) es la matriz de costos del trayecto del grafo
//El costo(i,i) es infinito, el costo(i,j), donde i!=j, es positivo.
//El trayecto se guarda en el arrego T(n,2)
//El costo final se asigna a minicost.
//Se requiere un vector que indique el nodo más cercano al nodo j, ese vector
//NEAR se guarda en la variable ne.
int
cost[TAM][TAM]={{999,10,999,30,45,999},{10,999,50,999,40,25},{999,50,999,999,35,15}
,{30,999,999,999,999,20},{45,40,35,999,999,55},{999,25,15,20,55,999}};
int ne[6], n, i, j, k, l, t[6][2], min=999,mincost;
for (i=0;i<TAM;i++)
for (j=0;j<TAM;j++)
68
Prof. Joel Ayala de la Vega.
if (min>cost[i][j]){
min=cost[i][j];
k=i;
l=j;
}
mincost=cost[k][l];
t[0][0]=k;
t[0][1]=l;
for (i=0;i<TAM;i++)
if (cost[i][l]<cost[i][k])
ne[i]=l;
else
ne[i]=k;
ne[k]=ne[l]=-1;
for (i=1;i<TAM-1;i++){
min=999;
for (k=0;k<TAM;k++){
if (ne[k]!=-1 && cost[k][ne[k]]<min){
min=cost[k][ne[k]];
j=k;
}
}
t[i][0]=j;
t[i][1]=ne[j];
mincost=mincost+cost[j][ne[j]];
ne[j]=-1;
for (k=0;k<TAM;k++)
if(ne[k]!=-1 && cost[k][ne[k]]>cost[k][j])
ne[k]=j;
}
printf("Costo del árbol de expansión mínima=%d\n",mincost);
for (i=0;i<TAM-1;i++)
printf("Trayecto de %d a %d\n",t[i][0]+1,t[i][1]+1);
getchar();
getchar();
}
El tiempo para ejecutar el algoritmo PRIM es del Ө(n2) donde n es el número de nodos.
La matriz COSTO del ejemplo anterior junto con una simulación de los valores históricos del
vector NEAR queda de la siguiente forma:
Tabla 7.1
69
Prof. Joel Ayala de la Vega.
7.5 Single Source Shortest Paths (La ruta más corta a partir de un origen)
Los grafos pueden ser utilizados para representar carreteras, estructuras de un estado o país
con vértices representando ciudades y segmentos que unen los vértices como la carretera. Los
segmentos pueden tener asignados pesos que marcan una distancia entre dos ciudades
conectadas.
La distancia de un trayecto es definido por la suma del peso de los segmentos. El vértice de
inicio se definirá como el origen y el último vértice se definirá como el destino. El problema a
considerar será en base a una gráfica dirigida G= (V, E), una función de peso c(e) para los
segmentos de G y un vértice origen v0. El problema es determinar el trayecto más corto de v0
a todos los demás vértices de G. Se asume que todos los pesos son positivos.
Ejemplo:
Considere la gráfica dirigida de la siguiente figura. El número de trayectos también es el
número de pesos. Si v0 es el vértice de origen, entonces el trayecto más corto desde v0 a v1 es
v0 v2 v3 v1. La distancia del trayecto es 10 + 15 + 20 = 45. En este caso, recorrer tres caminos
es más económico que recorrer en forma directa v0 v1, el cual tiene un costo de 50. No existe
trayecto alguno de v0 a v1.
Para formular un algoritmo greedy para generar el trayecto más corto, debemos concebir una
solución multi etapa. Una posibilidad es construir el trayecto más corto uno por uno. Como
una medida de optimización se puede usar la suma de todos los trayectos hasta el momento
generados. En orden de que la medida sea mínima, cada trayecto individual debe ser de
tamaño mínimo. Si se han construido i trayectos mínimos, entonces el siguiente trayecto a ser
construido debería ser el siguiente trayecto con mínima distancia. El camino greedy para
generar los trayectos cortos desde V0 a los vértices remanentes es generando los trayectos en
orden creciente. Primero, el trayecto más corto al vértice más cercano es generado. Entonces
el trayecto más corto al segundo vértice más cercano se genera y así sucesivamente. Para la
gráfica del ejemplo, el trayecto más cercano para V0 es V2 (c(V0, V2 )=10). Por lo que el
trayecto V0 V2 será el primer trayecto generado. El segundo trayecto más cercano es V0 V3
con una distancia de 25. El trayecto V0 V2 V3 será el siguiente trayecto generado. Para generar
los siguientes trayectos se debe determinar i) el siguiente vértice que con el cual deba generar
un camino más corto y ii) un camino más corto para éste vértice. Sea S el conjunto de vértices
70
Prof. Joel Ayala de la Vega.
(incluyendo V0) en el cual el trayecto más corto ha sido generado. Para w no en S, sea
DIST(w) la distancia del trayecto más corto desde V0 yendo sólo a través de esto
s vértices que están en S y terminando en w. Se observa que:
I. Si el siguiente trayecto más corto es al vértice u, entonces el trayecto inicia en V0,
termina en u y va a través de los vértices localizados en S.
II. El destino del siguiente trayecto generado debe de ser aquel vértice u tal que la
mínima distancia, DIST(u), sobre todos los vértices no en S.
III. Habiendo seleccionado un vértice u en II y generado el trayecto más corto de V0 a u,
el vértice u viene a ser miembro de S. En este punto la dimensión del trayecto más
corto iniciando en V0. irá en los vértices localizados en S y terminando en w no en S
puede decrecer. Esto es, el valor de la distancia DIST(w) puede cambiar. Si cambia,
entonces se debe a que existe un trayecto más corto iniciando en V0 posteriormente
va a u y entonces a w. Los vértices intermedios de V0, a u y de w a w deben estar
todos en S. Además, el trayecto V0 a u debe ser el más corto, de otra forma DIST(w)
no está definido en forma apropiada. También, el trayecto u a w puede no contener
vértices intermedios.
Las observaciones arriba indicadas forman un algoritmo simple. (El algoritmo fue
desarrollado por Dijkstra). De hecho solo determina la magnitud de la trayectoria del vértice
V0 a todos los vértices en G.
Se asume que los n vértices en G se numeran de 1 a n. El conjunto se mantiene S con un
arreglo con S(i)=0 si el vértice i no se encuentra en S y S(i)=1 si pertenece a S. Se asume que
la gráfica se representa por una matriz de costos.
#include<stdio.h>
#include<stdlib.h>
71
Prof. Joel Ayala de la Vega.
//La ruta más corta a partir de un origen
void generar(int **,int);
void path(int **,int *,int,int);
main(){
int **cost,*dist,tam,i,j,v;
printf(" ********** Ruta mas corta a partir de un origen **********\n");
printf("\nIntroduce el numero de vertices: ");
scanf("%d",&tam);
cost=(int **)malloc(sizeof(int *)*tam);
for(i=0;i<tam;i++)
cost[i]=(int *)malloc(sizeof(int)*tam);
dist=(int *)malloc(sizeof(int)*tam);
for(i=0;i<tam;i++)
for(j=0;j<tam;j++)
cost[i][j]=9999;
generar(cost,tam);
printf("Introduce el vertice de origen: ");
scanf("%d",&v);
printf("La matriz generada es: \n");
for(i=0;i<tam;i++){
for(j=0;j<tam;j++){
printf("%6d",cost[i][j]); } printf("\n"); }
path(cost,dist,tam,v-1);
printf("\n\nCosto de los caminos\n");
for(i=0;i<tam;i++)
printf("Camino %d a %d: %d\n",v,i+1,dist[i]);
system("PAUSE");
}
72
Prof. Joel Ayala de la Vega.
int u,w,i,num,s[tam],min;
for(i=0;i<tam;i++){
s[i]=0;
dist[i]=cost[v][i];}
s[v]=1;
dist[v]=0;
for(num=1;num<tam-1;num++){
min=9999;
for(w=0;w<tam;w++)
if(s[w]==0 && dist[w]<min){
min=dist[w];
u=w;
}
s[u]=1;
for(w=0;w<tam;w++)
if(s[w]==0){
if(dist[w]<dist[u]+cost[u][w])
dist[w]=dist[w];
else
dist[w]=dist[u]+cost[u][w];
}
}
}
El tiempo que tarda el algoritmo con n vértices es O(n2). Esto se ve fácilmente ya que el
algoritmo contiene dos for anidados.
Ejemplo.
73
Prof. Joel Ayala de la Vega.
Tabla 7.2
Si v=5, nos indica que se busca el trayecto de mínimo costo a todos los nodos desde el nodo
5. Por lo tanto, la corrida es:
Tabla 7.3
Se observará que este algoritmo tiene una complejidad de O(n2) donde n es el número de
nodos. Se tiene un for anidado de la siguiente forma:
Ejercicios.
74
Prof. Joel Ayala de la Vega.
3. Dada la siguiente matriz de costos:
1 2 3 4 5 6 7 8
1 0
2 400 0
3 800 600 0
4 1200 0
5 1400 0 250
6 1200 0 1000 1600
7 0 1000
8 0
75
Prof. Joel Ayala de la Vega.
VIII PROGRAMACIÓN DINÁMICA. (Dynamic Programming).
(Ellis horowitz, 1978)
(Dasgupta, Papadimitriou, & Vazirani, 2008)
Principio de optimalidad
Cuando hablamos de optimizar nos referimos a buscar alguna de las mejores soluciones de
entre muchas alternativas posibles. Dicho proceso de optimización puede ser visto como una
secuencia de decisiones que nos proporcionan la solución correcta. Si, dada una subsecuencia
de decisiones, siempre se conoce cuál es la decisión que debe tomarse a continuación para
obtener la secuencia óptima, el problema es elemental y se resuelve trivialmente tomando una
decisión detrás de otra, lo que se conoce como estrategia voraz. En otros casos, aunque no sea
posible aplicar la estrategia voraz, se cumple el principio de optimalidad de Bellman
enunciado en 1957 y que dicta que «dada una secuencia óptima de decisiones, toda
subsecuencia de ella es, a su vez, óptima». En este caso sigue siendo posible el ir tomando
decisiones elementales, en la confianza de que la combinación de ellas seguirá siendo óptima,
pero será entonces necesario explorar muchas secuencias de decisiones para dar con la
correcta, siendo aquí donde interviene la programación dinámica. Aunque este principio
parece evidente no siempre es aplicable y por tanto es necesario verificar que se cumple para
el problema en cuestión.
Para que un problema pueda ser abordado por esta técnica ha de cumplir dos condiciones:
La solución al problema ha de ser alcanzada a través de una secuencia de decisiones,
una en cada etapa.
Dicha secuencia de decisiones ha de cumplir el principio de óptimalidad
76
Prof. Joel Ayala de la Vega.
Es una gráfica dirigida en el cual los vértices son particionados en K ≥2 conjuntos disjuntos
Vi, 1 ≤ i ≤ k. En adición, si <u, v> son una arista en E entonces u Є Vi y vЄVi+1 para algun i,
1 ≤ i < k. El conjuto V1 y Vk son tales que | V1 | = | Vk | =1. Sea s y t respectivamente el
vértice en V1 y Vk. s es la fuente y t es la meta a llegar. Sea c(i, j) el costo de la arista <i, j>. El
costo del trayecto de s a t iniciando en el estado 1, va la etapa 2, posteriormente al la etapa 3,
a la etapa 4 etc. Y eventualmente termina en la etapa k. En la siguiente figura muestra una
gráfica de 5 etapas. El trayecto de mínimo costo de s a t se muestra en negrita.
Varios problemas pueden ser formulados como problemas de múltiple etapa. Se dará sólo un
ejemplo. Considere un problema de asignación de recursos en el cual n unidades de recursos
van a ser asignados a r proyectos. Si j, 0<=j<=n unidades de recursos son asignados al
proyecto i entonces el beneficio neto resultante es N(i, j). El problema es asignar el recurso al
proyecto r de tal forma que maximice el beneficio neto. Este problema puede ser formulado
como una grafica de r+1 etapas como sigue. Etapa i, 1<=i<=r representa el proyecto i. Hay n
+ 1 vertices V(i, j), 0<=j<=n asociados con la etapa i, 2<=i<=r. Etapas 1 y r + 1 cada uno
tiene un vértice V(1,0)=s y V(r+1, n) = t respectivamente. Vértice V(i, j), 2<=i<=r representa
el estado en el cual un total de j unidades de recursos han sido asignados a los proyectos 1,2, .
. .i-1. Las aristas en G son de la forma <V(i, j, V(i+1, L)>, para toda j<=L y 1<=r. La arista
<(v(i,j, Vi+1, L)>, j<=L se asigna con un peso o costo de N(i, L-j) y corresponde a la
asignación de L-j unidades de recursos al proyecto i, 1<=i<r. En adición, G tiene aristas del
tipo <V(r,j, V(r+1,n)>. A cada de estas aristas es asignado un peso de max 0<=p<=n-j{N(r, p)}.
La grafica resultante con un proyecto de tres problemas con n=4 se muestra en la siguiente
figura. Debería ser fácil ver que una asignación óptima de recursos se define por un máximo
costo en la trayectoria a t. Esto es fácilmente convertido a un problema de consto mínimo
sólo cambiando el singo de toda arista.
77
Prof. Joel Ayala de la Vega.
Fig. 8.2 Gráfica de etapas correspondiente a un proyecto de 3 problemas.
78
Prof. Joel Ayala de la Vega.
Por lo que el mínimo el trayecto del mínimo costo de s a t es 16. Este trayecto puede ser
determinado fácilmente si registramos la decisión realizada en cada etapa. Sea D(i,j) el valor
de L que minimiza c(j,L) + COSTO(i + 1,L), para la gráfica de 5 etapas se tiene:
D(3,6)=10; D(3,7)=10; D(3,8)=10;
D(2,2)=7; D(2,3)=6; D(2,4)=8; D(2,5)=8;
D(1,1)=2
Por lo que el trayecto del mínimo costo puede ser s=1, v2, v3, v4,. . . vk-1, t. Es fácil ver que
v2= D(1,1)=2; v3= D(2, D(1,1))=7 y v4=D3, D(2,D(1,1)))=D(3,7)=10.
Antes de escribir un algoritmo para resolver una gráfica de k etapas, se impondrá un orden en
los vértices en V. Éste orden será fácil de escribir el algoritmo. Se requerirá que los n vértices
en V estén indexados de 1 hasta n. Los índices serán asignados en orden a los a las etapas.
Primero s será asignado al índice 1, los vértices en V2 son asignados en al índice, así
sucesivamente. t tiene el índice n. Por lo que los índices en Vi+1 son mayores que los
asignados a los vértices en Vi. Como un resultado de este esquema de índices, COSTO y D
pueden ser calculados en el orden n-1, n-2,. . ., 1. El primer subíndice en COSTO, P y D sólo
identifica el número de etapa y es omitido en el algoritmo. El algoritmo es:
Procedure FGRAPH(E, k, n, P)
//k Número de etapas en la gráfica.
//E un conjunto de aristas
//c(i,j) es el costo de <i, j>.
//P(1:K) es la trayectoria de mínimo costo.
Real COSTO(n), integer (D(n-1), P(k), r, j, k, n
COSTO(n)=0;
For j=n-1 a 1 by -1 do //calcular COSTO(j)
Sea r un vértice tal que <j, r> € E y c(j,r) + COSTO( r ) sea mínimo.
COSTO(j)=c(j, r) + COSTO( r)
D(j)=r
repeat
//busca el trayecto del mínimo costo.
P(1)=1; P(k)=n
For j= 2 to k - 1 do
P(j ) = D(P(j-1));
repeat
end FGRAPH
Se observará que existen dos operadores de control for que no son anidados, por lo que el
tiempo necesario será Ө(n).
8.2 El Problema del Agente Viajero (The Traveling Sales Person Problem,
TSP)
79
Prof. Joel Ayala de la Vega.
Tabla 8.1
Sea G=(V, E) una gráfica dirigida con costo de cada arista cij, cij se define tal que cij >0 para
todo i y j y ci j = ∞ si <i, j> ¢ E. Sea |V| = n y asuma que n > 1. Una gira de G es un ciclo
dirigido que incluye todos los vértices en V. El costo de la gira es la suma de los costos de las
aristas en la gira. El problema del agente viajero es encontrar una gira que minimice los
costos.
Una aplicación del problema es la siguiente: Suponga que se tiene una ruta de una camioneta
postal que recoge correo de cajas de correo localizados en n diferentes sitios. Una gráfica de
n+1 vértices puede ser usada para representar tal situación. Un vértice representa la oficina
postal desde donde la camioneta postal inicia su recorrido y en el cual debe de retornar. La
arista <i, j> tiene asignado un costo igual a la distancia desde el sitio i al sitio j. La ruta
tomada por la camioneta postal es una gira y lo que se espera es minimizar el trayecto de la
camioneta.
En la siguiente discusión se comentará sobre un recorrido que inicia en el vértice 1 y termina
en el mismo vértice, siendo el recorrido el del mínimo costo. Toda gira consiste de una arista
<1, k> para algún k є V – {1} y un trayecto desde el vértice k al vértice 1. El trayecto desde el
vértice k al vértice 1 va a través de cada vértice en V – {1, k}. De aquí que el principio de
optimización se mantiene. Sea g(i, S) la longitud del trayecto más corto iniciando en el vértice
i, yendo a través de todos los vértices en S y terminando en el vértice 1. g(1, V – {1}) es la
longitud de una gira optima de un agente viajero. Desde el principio de optimalidad se
deduce que:
80
Prof. Joel Ayala de la Vega.
main(){
int **cost,*m,d,o,v,i,j,r;
printf(" *************** TSP ***************\n");
printf("\nIntroduce el numero de vertices: ");
scanf("%d",&v);
cost=(int **)malloc(sizeof(int *)*v);
for(i=0;i<v;i++)
cost[i]=(int *)malloc(sizeof(int)*v);
m=(int *)malloc(sizeof(int)*v);
for(i=0;i<v;i++)
m[i]=0;
for(i=0;i<v;i++)
for(j=0;j<v;j++){
if(i==j)
cost[i][j]=0;
else
cost[i][j]=9999;
}
generar(cost,v);
printf("\nIntroduce el origen:");
scanf("%d",&r);
printf("La matriz generada es: \n");
for(i=0;i<v;i++){
for(j=0;j<v;j++){
printf("%6d",cost[i][j]); } printf("\n"); }
printf("\n\nCosto minimo a partir de %d: %d\n",r,tsp(cost,m,v,r-1,v,r-1));
system("PAUSE");
}
81
Prof. Joel Ayala de la Vega.
return cost[o][r];
int dist,dmin=999;
m[o]=1;
for(int i=0;i<v;i++)
if(m[i]==0){
dist=cost[o][i]+tsp(cost,m,d-1,i,v,r);
m[i]=0;
if(dist<dmin){
dmin=dist;
}
}
return dmin;
}
Ejemplo. Considere la siguiente gráfica donde el tamaño de las aristas se dan en la matriz c:
Fig. 8.3 Gráfica dirigida cuya longitud de cada arista se localiza en la matriz C.
82
Prof. Joel Ayala de la Vega.
Figura 8.4 Árbol recursivo del Agente Viajero
Una gira óptima de la gráfica de la figura tiene una longitud de 35. Una gira de esta longitud
puede ser construida si se retiene de cada g(i, S) el valor de j que minimiza el lado derecho de
(2). Sea J(i, S) este valor. Entonces J(1,{2, 3, 4}) =2. De esta forma la gira inicia de 1 a 2. El
siguiente punto a visitar se obtiene de g(2,{3,4}), J(2,{3, 4}=4, por lo que la siguiente arista
es <2. 4>. Lo que falta de la gira es g(4,{3}), J(4,{3})=3. El recorrido óptimo es 1, 2, 4, 3, 1.
Sea N el número de g(i, S) que tiene que ser calculado antes que de que g(1, V – {1}) sea
calculado. Para cada valor de |S| hay n – 1 opciones para i. El número de conjuntos
n-2
distintos S de tamaño k que no incluyen a 1 y a i es k
De esta forma:
n-2 n-2
N = Σ (n – 1) k = (n – 1)2n – 2
K=0
Un algoritmo que procede a encontrar un recorrido óptimo haciendo uso de (1) y (2) requerirá
θ (n2 2n) veces para el cálculo de g(i, S) con |S| = k requiere k – 1 comparaciones para resolver
(2). Esto es mejor que la enumeración de todos los n! diferentes recorridos para encontrar el
mejor recorrido. El inconveniente más grave de ésta solución con programación dinámica es
el espacio requerido. El espacio necesario es O(n2n). Esto es demasiado grande incluso para
valores modestos de n.
Ejercicios.
83
Prof. Joel Ayala de la Vega.
IX Retorno Sobre la Misma Ruta. (Backtracking)
(Ellis horowitz, 1978)
(Dasgupta, Papadimitriou, & Vazirani, 2008)
Las restricciones explicitas pueden o no depender de una particular instancia I del problema a
ser resuelto. Todas las tuplas que satisfacen a restricciones explicitas definen a un posible
espacio de solución para I. Las restricciones implícitas determinan cuál de las tuplas en un
espacio de solución de I en realidad satisfacen la función del criterio. Así, restricciones
implícitas describen el camino en el cual las xi deben de relacionarse una a otra.
84
Prof. Joel Ayala de la Vega.
Fig. 9.1 Posiciones que puede atacar la reina.
Un problema combinatorio clásico es colocar las ocho reinas en un tablero de ajedrez de tal
forma que no se puedan atacar entre ellas, esto es que no existan dos reinas en la misma
hilera, columna o diagonal. Enumerando las hileras y columnas del tablero del 1 al 8. Las
reinas pueden enumerarse también del 1 al 8. Ya que cada reina debe estar sobre una hilera
diferente, se puede asumir que la reina i se colocará en la hilera i. Toda solución puede ser
representad como 8 tuplas (x1, x2, x3, . . . , x8) donde xi es la columna donde la reina i será
colocada La restricción explicita usando esta formulación será S= {1, 2, 3, . . .,8}, 1≤ i ≤n. Por
lo que espacio de soluciones consiste de 88 tuplas de 8. Una de las restricciones implícitas del
problema es que dos x´s no deben de ser las mismas (esto es, toda reina debe de estar en
diferente columna) y tampoco en la misma diagonal. La primer restricción indica que todas
las soluciones son permutaciones de las 8 tuplas (1, 2, . . .,8). Esto reduce el espacio de la
solución de 88 tuplas a 8! Tuplas.
El problema de las filas y columnas lo tenemos cubierto, pero ¿qué ocurre con las diagonales?
Para las posiciones sobre una misma diagonal descendente se cumple que tienen el mismo
valor fila − columna, mientras que para las posiciones en la misma diagonal ascendente se
85
Prof. Joel Ayala de la Vega.
cumple que tienen el mismo valor fila + columna. Así, si tenemos dos reinas colocadas en
posiciones (i,j) y (k,l) entonces están en la misma diagonal si y solo si cumple:
i−j=k−loi+j=k+l
j−l=i−koj−l=k−i
Teniendo todas las consideraciones en cuenta podemos aplicar el esquema backtracking para
implementar las ocho reinas de una manera realmente eficiente. Para ello, reformulamos el
problema como un problema de búsqueda en un árbol. Decimos que en un vector V1…k de
enteros entre 1 y 8 es k-prometedor, para 0≤ k ≤ 8 si ninguna de las k reinas colocadas en las
posiciones (1, V1), (2, V2), . . ., (3, V3) amenaza a ninguna de las otras. Las soluciones a
nuestro problema se corresponden con aquellos vectores que son 8-prometedores.
iєA
Sea N el conjunto de vectores de k-prometedores, 0≤ k ≤8, sea G = (N,A) el grafo dirigido tal
que (U, V) є A si y solo si existe un entero k, con 0≤ k ≤8 tal que
U es k-prometedor
V es (k + 1)-prometedor
Ui = Vi para todo i є{1, . . ., k}
Este grafo es un árbol. Su raíz es el vector vacío correspondiente a k = 0. sus hojas son o bien
soluciones (k = 8), o posiciones sin salida (k < 8). Las soluciones del problema de las ocho
reinas se pueden obtener explorando este árbol. Sin embargo no generamos explícitamente el
árbol para explorarlo después. Los nodos se van generando y abandonando en el transcurso de
la exploración mediante un recorrido en profundidad.
Hay que decidir si un vector es k-prometedor, sabiendo que es una extensión de un vector (k −
1)-prometedor, únicamente necesitamos comprobar la última reina que haya que añadir. Este
se puede acelerar si asociamos a cada nodo prometedor el conjunto de columnas, el de
diagonales positivas (a 45 grados) y el de diagonales negativas (a 135 grados) controlados por
las reinas que ya están puestas.
86
Prof. Joel Ayala de la Vega.
Procedimiento reinas(k, col, diag45, diag135)
//sol1 . . . k es el k prometedor
//col = {soli | 1 ≤ i ≤ k}
//diag45 ={soli - i + 1| 1 ≤ i ≤ k}
// diag135 ={soli + i - 1| 1 ≤ i ≤ k}
si k = 8 entonces //un vector 8-prometedor es una solución
Escribir sol
Si no //explorar las extensiones (k+1) prometedoras de sol
Para j←1 hasta 8 hacer
Si j¢ col y j – k ¢ diag45 y j + k ¢ diag135 entonces
Solk + 1 ← j//sol1, . . .,k+1 es (k + 1) prometedor
Reinas(k+1, col U {j}, diag45 U {j – k}, diag135 U {j + k})
El algoritmo comprueba primero si k = 8, si esto es cierto resulta que tenemos ante nosotros
un vector 8-prometedor, lo cual indica que cumple todas las restricciones originando una
solución. Si k es distinto de 8, el algoritmo explora las extensiones (k + 1)-prometedoras, para
ello realiza un bucle, el cual va de 1 a 8, debido al número de reinas. En este bucle se
comprueba si entran en jaque las reinas colocadas en el tablero, si no entran en jaque, se
realiza una recurrencia en la cual incrementamos k (buscamos (k + 1)-prometedor) y añadimos
la nueva fila, columna y diagonales al conjunto de restricciones. Al realizar la recurrencia
hemos añadido al vector sol una nueva reina la cual no entra en jaque con ninguna de las
anteriores, además hemos incrementado el conjunto de restricciones añadiendo una nueva fila,
columna y diagonales (una positiva y otra negativa) prohibidas.
Un ejemplo del árbol en profundidad puede verse fácilmente en la figura 9.4 con un ejemplo
de las 4 reinas:
#include<stdio.h>
#include<stdlib.h>
void marcar(int **,int,int,int);
void vaciar(int **,int);
void solucion(int **,int);
void dam(int **,int **,int,int,int,int);
void regresar(int **,int **,int,int,int);
int cont=0;
87
Prof. Joel Ayala de la Vega.
main(){
system("color 2f");
int **matriz,**tablero,reinas,fila=0,columna=0;
printf("Introduce el numero de reinas: ");
scanf("%d",&reinas);
matriz=(int **)malloc(sizeof(int *)*reinas);
tablero=(int **)malloc(sizeof(int *)*reinas);
for(int i=0;i<reinas;i++){
matriz[i]=(int *)malloc(sizeof(int)*reinas);
tablero[i]=(int *)malloc(sizeof(int)*reinas);}
vaciar(matriz,reinas);
vaciar(tablero,reinas);
for(int i=0;i<reinas;i++)
dam(matriz,tablero,i,0,1,reinas);
if(cont==0)
printf("No hay soluciones para el problema con %d reinas\n",reinas);
system("PAUSE");
}
88
Prof. Joel Ayala de la Vega.
for(int i=0;i<reinas;i++){
for(int j=0;j<reinas;j++){
printf("%d ",vect[i][j]); }printf("\n");}
printf("\n\n");
}
Los caminos y ciclos hamiltonianos fueron nombrados después que William Rowan
Hamilton, inventor del juego de Hamilton, lanzara un juguete que involucraba encontrar un
ciclo hamiltoniano en las aristas de un grafo de un dodecaedro. Hamilton resolvió este
problema usando cuaterniones, pero esta solución no se generaliza a todos los grafos.
Definición
Un camino hamiltoniano es un camino que pasa por cada vértice exactamente una vez. Un
grafo que contiene un camino hamiltoniano se denomina un ciclo hamiltoniano o circuito
hamiltoniano si es un ciclo que pasa por cada vértice exactamente una vez (excepto el vértice
del que parte y al cual llega). Un grafo que contiene un ciclo hamiltoniano se dice grafo
hamiltoniano.
89
Prof. Joel Ayala de la Vega.
El vector solución por backtraking (x1, x2, x3, . . . , xn) se define de tal forma que xi representa
el i-ésimo vértice visitado del ciclo propuesto. Ahora, todo lo se tiene que hacer es determinar
como calcular el conjunto posible de vértices para xk si x1, . . . xk-1 han sido ya escogidos. Si
k=1 entonces X(1) puede ser cualquiera de los n vértices. Para evitar la impresión del mismo
ciclo n veces se requiere que X(1) =1. Si 1 < k < n entonces X(k) puede ser cualquier vértice v
el cual es distinto de X(1), X(2), . . . , X(k-1) y v es conectado por una arista a X(k-1). X(n)
puede ser sólo un vértice restante y debe ser conectado a ambos X(n-1) y X(1).
Procedure NEXTVALUE(K)
//X(1),… X(k-1) es un trayecto de k-1 vertices. Si (X(k)=0 entonces no se ha asignado
//un vértice a X(K). Despues de la ejecución de X(k). Después de la ejecución de X(k) //es
asignado al siguiente vértice numerado mayor el cual (i) aun no aparece en X(1), . . // , X(k-
1). De otra manera X(k)=0. Si k=n entonces en adición X(k) se conecta a X(1).
Global integer n, X(1:n), Boolean GRAPH(1:n, 1:n)
Integer k, j
Loop
X(k)← (X(k)+1) mod (n+1) //el siguiente vértice.
if X(k) =0 then return endif
if GRAPH (X(k-1), X(k)) then //existe una arista?
For j←1 to k-1 do //verificación de distinción
If (X(j)=X(k) then
Exit
Endif
Repeat
If j=k then //si es verdadero entonces el vértice es distinto.
If k<n or (k=n and GRAPH(X(n),1)) then return
Endif
Endif
Endif
Repeat
End NEXTVALUE
90
Prof. Joel Ayala de la Vega.
.
Este procedimiento primero inicializa la matriz adyacente GRAPH(1:n, 1:n), a continuación
establece X(2:n)←0, X(1)←1 y ejecuta la llamada a HAMILTONIAN(2).
El problema del agente viajero es un ciclo Hamiltoniano con la diferencia de que cada arista
tiene un costo diferente.
Ejercicios
91
Prof. Joel Ayala de la Vega.
X RAMIFICACIÓN Y ACOTAMIENTO. (Branch and Bound)
(Ellis horowitz, 1978)
(Dasgupta, Papadimitriou, & Vazirani, 2008)
Nuestra meta será encontrar el valor mínimo de una función f(x) (un ejemplo puede ser el
coste de manufacturación de un determinado producto) donde fijamos x rangos sobre un
determinado conjunto S de posibles soluciones. Un procedimiento de ramificación y poda
requiere dos herramientas.
La idea clave del algoritmo de ramificación y poda es: si la menor rama para algún árbol nodo
(conjunto de candidatos) A es mayor que la rama padre para otro nodo B, entonces A debe ser
descartada con seguridad de la búsqueda. Este paso es llamado poda, y usualmente es
implementado manteniendo una variable global m que graba el mínimo nodo padre visto entre
todas las subregiones examinadas hasta entonces. Cualquier nodo cuyo nodo hijo es mayor
que m puede ser descartado. La recursión se detiene cuando el conjunto candidato S es
reducido a un solo elemento, o también cuando el nodo padre para el conjunto S coincide con
el nodo hijo. De cualquier forma, cualquier elemento de S va a ser el mínimo de una función
sin S.
Funcion RyP {
P = Hijos(x,k)
mientras ( no vacio(P) )
x(k) = extraer(P)
si esFactible(x,k) y G(x,k) < optimo
si esSolucion(x)
Almacenar(x)
sino
92
Prof. Joel Ayala de la Vega.
RyP(x,k+1)
}
Donde:
Subdivisión Efectiva
Idealmente, el procedimiento se detiene cuando todos los nodos del árbol de búsqueda están
podados o resueltos. En ese punto, todas las subregiones no podadas, tendrán un nodo padre e
hijo iguales a una función global mínima. En la práctica el procedimiento a menudo termina,
cuando finaliza un tiempo dado, hasta el punto que el mínimo de nodos hijos y el máximo de
nodos padres sobe todas las secciones no podadas, definen un rango de valores que contienen
el mínimo global. Alternativamente, sin superar un tiempo restringido, el algoritmo debe
terminar cuando un criterio de error, tal que (max-min)/(max+min), cae bajo un valor
específico.
Los métodos de ramificación y poda deben ser clasificados acorde a los métodos de poda, y a
las maneras de creación/clasificación de los árboles de búsqueda.
93
Prof. Joel Ayala de la Vega.
10.2 Estrategias de Poda
Nuestro objetivo principal será eliminar aquellos nodos que no lleven a soluciones buenas.
Podemos utilizar dos estrategias básicas. Supongamos un problema de maximización donde
se han recorrido varios nodos i=1,…,n. estimando para cada uno la cota superior CS(xi) e
inferior CI(xi).
Vamos a trabajar sobre un problema donde se quiere maximizar el valor (si fuese un problema
de minimización entonces se aplicaría la estrategia equivalente).
Estrategia 1
Si a partir de un nodo xi se puede obtener una solución válida, entonces se podrá podar dicho
nodo si la cota superior CS(xi) es menor o igual que la cota inferior CI(xj) para algún nodo j
generado en el árbol.
Estrategia 2
Si se obtiene una posible solución válida para el problema con un beneficio Bj, entonces se
podrán podar aquellos nodos xi cuya cota superior CS(xi) sea menor o igual que el beneficio
que se puede obtener Bj (este proceso sería similar para la cota inferior).
Como se comentó en la introducción de éste apartado, la expansión del árbol con las distintas
estrategias está condicionada por la búsqueda de la solución óptima. Debido a esto, todos los
nodos de un nivel deben ser expandidos antes de alcanzar un nuevo nivel, cosa que es lógica
ya que para poder elegir la rama del árbol que va a ser explorada, se deben conocer todas las
ramas posibles.
Todos estos nodos que se van generando y que no han sido explorados se almacenan en lo que
se denomina Lista de Nodos Vivos (a partir de ahora LNV), nodos pendientes de expandir por
el algoritmo.
La LNV contiene todos los nodos que han sido generados pero que no han sido explorados
todavía. Según como estén almacenados los nodos en la lista, el recorrido del árbol será de
uno u otro tipo, dando lugar a las tres estrategias que se detallan a continuación.
Estrategia FIFO
En la estrategia FIFO (First In First Out), la LNV será una cola, dando lugar a un recorrido en
anchura del árbol.
94
Prof. Joel Ayala de la Vega.
Fig. 10.1 Estrategias de ramificación FIFO.
Estrategia LIFO
En la estrategia LIFO (Last In First Out), la LNV será una pila, produciendo un recorrido en
profundidad del árbol.
Fig.
10.2 Estrategias de ramificación LIFO
En la figura se muestra el orden de generación de los nodos con una estrategia LIFO. El
proceso que se sigue en la LNV es similar al de la estrategia FIFO, pero en lugar de utilizar
una cola, se utiliza una pila.
Al utilizar las estrategias FIFO y LIFO se realiza lo que se denomina una búsqueda “a
ciegas”, ya que expanden sin tener en cuenta los beneficios que se pueden alcanzar desde cada
95
Prof. Joel Ayala de la Vega.
nodo. Si la expansión se realizase en función de los beneficios que cada nodo reporta (con una
“visión de futuro”), se podría conseguir en la mayoría de los casos una mejora sustancial.
Es así como nace la estrategia de Menor Coste o LC (Least cost), selecciona para expandir
entre todos los nodos de la LNV aquel que tenga mayor beneficio (o menor coste). Por tanto,
ya no estamos hablando de un avance “a ciegas”.
Esto nos puede llevar a la situación de que varios nodos puedan ser expandidos al mismo
tiempo. De darse el caso, es necesario disponer de un mecanismo que solucione este conflicto:
-Estrategia LC-FIFO: Elige de la LNV el nodo que tenga mayor beneficio y en caso de
empate se escoge el primero que se introdujo.
-Estrategia LC-LIFO: Elige de la LNV el nodo que tenga mayor beneficio y en caso de
empate se escoge el último que se introdujo.
Un variante del método de ramificación y poda más eficiente se puede obtener “relajando” el
problema, es decir, eliminando algunas de las restricciones para hacerlo más permisivo.
Cualquier solución válida del problema original será solución válida para el problema
“relajado”, pero no tiene por qué ocurrir al contrario. Si conseguimos resolver esta versión del
problema de forma óptima, entonces si la solución obtenida es válida para el problema
original, esto querrá decir que es óptima también para dicho problema.
La verdadera utilidad de este proceso reside en la utilización de un método eficiente que nos
resuelva el problema relajado. Uno de los métodos más conocidos es el de Ramificación y
Corte (Branch and Cut (versión inglesa)).
Ramificación y Corte
Este método resuelve problemas lineales con restricciones enteras usando algoritmos
regulares simplificados. Cuando se obtiene una solución óptima que tiene un valor no entero
para una variable que ha de ser entera, el algoritmo de planos de corte se usa para encontrar
una restricción lineal más adelante que sea satisfecha por todos los puntos factibles enteros. Si
se encuentra esa desigualdad, se añade al programa lineal, de tal forma que resolverla nos
llevará a una solución diferente que esperamos que sea “menos fraccional”. Este proceso se
repite hasta que ó bien, se encuentra una solución entera (que podemos demostrar que es
óptima), ó bien no se encuentran más planos de corte.
En este punto comienza la parte del algoritmo de ramificación y poda. Este problema se
divide en dos versiones: una con restricción adicional en que la variable es más grande o igual
que el siguiente entero mayor que el resultado intermedio, y uno donde la variable es menor o
igual que el siguiente entero menor. De esta forma se introducen nuevas variables en las bases
96
Prof. Joel Ayala de la Vega.
de acuerdo al número de variables básicas que no son enteros en la solución intermedia pero
son enteros de acuerdo a las restricciones originales. Los nuevos programas lineales se
resuelven usando un método simplificado y después el proceso es repetido hasta que una
solución satisfaga todas las restricciones enteras.
Durante el proceso de ramificación y poda, los planos de corte se pueden separar más adelante
y pueden ser o cortes globales válidos para todas las soluciones enteras factibles, o cortes
locales que son satisfechos por todas las soluciones llenando todas las ramas de la restricción
del subárbol de ramificación y poda actual.
Para programación dinámica, el algoritmo del agente viajero tiene una complejidad de
O(n22n). Ahora, lo que se va a tratar de explicar es el algoritmo visto bajo la óptica de
ramificación y acotamiento. El uso de una buena función de acotamiento permitirá que el
algoritmo, bajo el paradigma de ramificación y acotamiento, en algunos casos pueda ser
resuelto en mucho menor tiempo que el requerido en programación dinámica.
Sea G=(V,E) una gráfica definida como una instancia del problema del agente viajero y sea
cij el costo de la arista <i, j>, cij = ∞ si <i, j> ¢ E y sea | V | = n. Sin perdida de generalidad, se
puede asumir que cualquier recorrido inicia y termina en el vértice 1. De esta forma la
solución del espacio S está dado por S= {1, ∏, 1| ∏ es la permutación de (2, 3, . . ., n)}. |S|=(n
– 1)! . El tamaño de S puede ser reducido restringiendo S de modo que (1, i1, i2, . . . , in-1, 1) €
S si y solo si < ij, . . ., ij+1> € E, 0≤ j ≤ n – 1, i0 = in = 1. S puede estar organizado dentro de un
árbol de estados. La siguiente figura muestra la organización del árbol para el caso de una
gráfica completa con |V| = 4. Cada nodo hoja L es una solución y representa el recorrido
definido por el trayecto desde la raíz hasta L. El nodo 14 representa el recorrido i0 = 1, i1 = 3,
i2 = 4, i3 = 2 y i4 = 1.
Fig. 10.3 Árbol de estados para un problema del agente viajero con n=4 y i0 = i4 = 1.
En orden a usar ramificación y acotamiento LC para buscar el árbol de estados del agente
viajero, requiere definir una función de costo c(.) y otras dos funciones ĉ(.) y u(.) de tal forma
que ĉ(R) ≤ c(R) ≤ u(R) para todo nodo R. c(.) es el nodo solución si c(.) es el nodo de menor
costo correspondiente al tour más corto en G. Una forma de escoger c(.) es:
97
Prof. Joel Ayala de la Vega.
Una simple ĉ(.) tal que ĉ(A) ≤ c(A) para todo A es obtenido definiendo ĉ(A) a ser el tamaño
de la trayectoria definida en el nodo A. Por ejemplo, la trayectoria definida en el árbol
anterior es i0, i1, i2, = 1, 2, 4. Este consiste de las aristas <1,2> y <2, 4>. Un ĉ(.) mejor puede
ser obtenido usando la matriz de costo reducida correspondiente a G. Una hilera (columna) se
reduce si y solo si contiene al menos un cero y todos los demás valores son no negativos. Una
matriz es reducida si y solo si toda hilera y columna es reducida. Como un ejemplo de la
reducción del costo de una matriz de una gráfica dada G, consiste en la matriz de la siguiente
figura:
La matriz corresponde a una gráfica con 5 vértices. Todo recorrido incluye exactamente una
arista <i, j> con i=k, 1 ≤k ≤ 5 y exactamente una arista <i, j> con j=k, 1 ≤ k ≤ 5, substrayendo
una constante t de todos los elementos en una hilera o una columna de la matriz de costos se
reduce el tamaño de cada recorrido exactamente t unidades. Un recorrido de costo mínimo se
mantiene después de esta operación de sustracción. Si t se escoge para hacer mínimo la
entrada en la hilera i (columna j, restando i de todas las entradas en la fila i (columna j)
presentará un cero en la fila i (columna j). Repitiendo este procedimiento tanto como sea
necesario, la matriz de costos puede ser reducida. El monto total substraído de todas las
columnas e hileras es el límite inferior y puede ser utilizado como el valor ĉ de la raíz del
árbol de espacio de estado. Substrayendo 10, 2, 2, 3, 4, 1 y 3 de las hileras 1, 2, 3, 4, 5 y
columnas 1 y 3 respectivamente de la matriz del inciso a) de la figura anterior se tiene la
matriz reducida del inciso b) de la misma figura. El monto total substraído es 25. Por lo tanto,
todo recorrido del origen a origen tiene una longitud al menos de 25 unidades.
Con todos los nodos del agente viajero del árbol de estados se puede asociar a una matriz de
costos. Sea A la matriz de costos del nodo R. Sea S el hijo de R tal que la arista del árbol (R,
S) corresponde a la arista <i, j> en el recorrido. Si S no es una hoja entonces la matriz de
costo para S puede ser obtenida de la siguiente forma:
Al escoger el trayecto <i, j>, cambiar todo valor en la hilera i y columna j de A por ∞.
Esto previene el uso de algunas aristas salientes del vértice i o vértices entrantes de j.
Colocar A(j,1) en ∞. Esto previene el uso de aristas <j, 1>.
98
Prof. Joel Ayala de la Vega.
Reducir todas las hileras y columnas en la matriz resultante excepto para las hileras y
columnas que contienen sólo ∞. Cada diferencia a cero se suma en la variable “r”. La
matriz resultante será B.
ĉ(S) = ĉ(R) + A<i, j> + r
Siendo “S” el número de nodo actual.
Siendo “R” el número de nodo padre.
Los dos primeros pasos son validos y no existirá un recorrido en el sub árbol S que contenga
las aristas del tipo <j, k> o <k, j> o <j, 1> (excepto para la arista <i, j>). En este momento “r”
es el monto total substraído del paso 3, entonces ĉ(S) = ĉ(R) + A<i, j> + r. Para los nodos hoja
ĉ(.) = c() es fácil calcular ya que cada rama hasta la hoja define un único recorrido. Para la
función de la cota superior u, se requiere usar u(R) = ∞ para todo nodo R.
99
Prof. Joel Ayala de la Vega.
Restando por hileras:
A la h1 se resta 10. Por lo tanto, r=10
A la h2 se resta 2. Por lo tanto, r= 12
A la h3 se resta 2. Por lo tanto r= 14
A la h4 se resta 3. Por lo tanto r= 17
A la h5 se resta 4. Por lo tanto r= 21
La matriz resultante es:
Para S = 2 (1,2):
Ya sabiendo el costo menor, se escoge la primera parte del trayecto, donde A<2,1>=∞.
En este caso, en toda hilera y en toda columna existe un cero, por lo que r=0.
100
Prof. Joel Ayala de la Vega.
En este caso, toda hilera tiene al menos un cero, pero la primer columna es diferente de cero,
por lo que r=11.
101
Prof. Joel Ayala de la Vega.
102
Prof. Joel Ayala de la Vega.
Siendo S=6 la ruta mínima se tiene:
103
Prof. Joel Ayala de la Vega.
Se observará que el nodo hoja con el menor costo es S=10.
104
Prof. Joel Ayala de la Vega.
Del nodo 5 al nodo 3 7 unidades
Del nodo 3 al nodo 1 3 unidades.
TOTAL 28 unidades.
#include<stdio.h>
#include<stdlib.h>
struct Lista{
int **matriz,*marcas,costo,contador,ciudad,mc;
Lista *sig;
};
void bloquear(Lista *p,int tam,int x,int y){ //bloquea la hilera y fila, ademas A<j,i>
for(int i=0;i<tam;i++){
p->matriz[x][i]=999;
p->matriz[i][y]=999;
}
p->matriz[y][x]=999;
}
void restar_fila(Lista *q,int tam,int min,int i){ //Resta el minimo de cada fila
for(int k=0;k<tam;k++)
if(q->matriz[i][k]!=999 && q->matriz[i][k]!=0)
q->matriz[i][k]-=min;
}
void restar_columna(Lista *q,int tam,int min,int i){ //Resta el minimo de cada columna
for(int k=0;k<tam;k++)
if(q->matriz[k][i]!=999 && q->matriz[k][i]!=0)
q->matriz[k][i]-=min;
}
105
Prof. Joel Ayala de la Vega.
min=q->matriz[j][i];
}
if(min!=999)
q->costo+=min;
if(min!=0 && min!=999)
restar_columna(q,tam,min,i);
}
}
106
Prof. Joel Ayala de la Vega.
q->sig=NULL;
q->ciudad=j;
q->costo=0;
q->mc=1;
generar(q,tam);
costo(q,tam);
p=q;
}
else{
q->matriz=(int **)malloc(sizeof(int *)*tam);
q->marcas=(int *)malloc(sizeof(int)*tam);
for(int i=0;i<tam;i++){
q->matriz[i]=(int *)malloc(sizeof(int)*tam);
q->marcas[i]=mmin->marcas[i];
}
for(int i=0;i<tam;i++)
for(int j=0;j<tam;j++)
q->matriz[i][j]=mmin->matriz[i][j];
q->contador=num;
q->marcas[ciudad]=1;
q->sig=NULL;
q->ciudad=j;
q->costo=0;
q->mc=0;
min=q->matriz[ciudad][j];
if(min!=999)
q->costo=mmin->costo+min;
else
q->costo=mmin->costo;
bloquear(q,tam,ciudad,j);
costo(q,tam);
aux=(Lista *)p;
while(aux->sig!=NULL)
aux=aux->sig;
aux->sig=q;
}
return p;
}
int main(){
int tam,num=1,ciudad=0,min;
Lista *mmin,*aux;
void *p=NULL;
printf("Introduce el numero de ciudades: ");
scanf("%d",&tam);
p=guardar(p,mmin,tam,num,0,0);
mmin=(Lista *)p;
do{
//Mientras el num no sea igual al numero de ciudades el arbol no ha terminado, sigue
//recorriendo los caminos que hacen falta y detectando los menores
107
Prof. Joel Ayala de la Vega.
aux=(Lista *)p;
num=num+1;
for(int i=0;i<tam;i++)
if(mmin->marcas[i]!=1)
p=guardar(p,mmin,tam,num,ciudad,i);
min=999;
while(aux!=NULL){
if(aux->costo<min && aux->mc!=1){
min=aux->costo;
mmin=aux;
}
aux=aux->sig;
}
ciudad=mmin->ciudad;
mmin->marcas[ciudad]=1;
mmin->mc=1;
num=mmin->contador;
}while(num!=tam);
printf("\n\n Recorrido de costo minimo \n\nCosto minimo: %d\n",mmin->costo);
system("pause");
Ejercicios
108
Prof. Joel Ayala de la Vega.
XI PROBLEMAS NP.
(Ellis horowitz, 1978)
(Dasgupta, Papadimitriou, & Vazirani, 2008)
(Garey & Johnson, 1975)
(Dewdney, 1989)
(Kewis & Papadimitriou, 1989)
(Penrose, 1989)
(Singh, 1995)
(Alfonseca Cubero, Alfonseca Moreno, & Moriyon, 2007)
(Deutsch)
Se pensaba que las matemáticas son un sistema consistente, es decir, que no es posible llegar
a contradicciones a partir de los axiomas iniciales. Posteriormente se amplió el problema (que
se convirtió en el Entscheidungsproblem, o problema de la decisión), para incluir también la
demostración que la aritmética también es completa (existe una prueba para toda proposición
matemática correcta) y decidible (existe un método efectivo que decide, para cada
proposición posible, si es verdadera o falsa)
Correcto y completo
Uno de los principales promotores de esta creencia fue el famoso matemático David Hilbert
(1826-1943). Hilbert creía que en matemáticas todo podía y debía probarse a partir de los
axiomas básicos. El resultado de ello sería demostrar de manera concluyente los dos
elementos básicos del sistema matemático. En primer lugar, las matemáticas debían ser
capaces, al menos en teoría, de responder a cualquier interrogante concreto. En segundo
lugar, las matemáticas deberían estar libres de incongruencias, o lo que es lo mismo, una vez
demostrada la veracidad de una premisa a través de un método no sería posible que mediante
otro método se concluyera que esa misma premisa sea falsa. Hilbert estaba convencido de
que, asumiendo tan sólo unos pocos axiomas, sería posible responder a cualquier pregunta
matemática concebible sin temor a una contradicción.
El 8 de agosto de 1900 Hilbert pronunció una conferencia histórica en el Congreso
Internacional de Matemáticas de París. Hilbert planteó veintitrés problemas matemáticos sin
resolver que él consideraba de una perentoria importancia. Hilbert pretendía sacudir a la
comunidad para que lo ayudaran a realizar su sueño de crear un sistema matemático libre de
toda duda e incoherencia. Una ambición que inscribió en su lápida:
Wir műssen wissen,
Wir werden wissen.
Tenemos que saber,
Llegaremos a saber.
109
Prof. Joel Ayala de la Vega.
Al mismo tiempo, el lógico inglés Bertrand Russell, que también estaba contribuyendo al gran
proyecto de Hilbert, había tropezado con una incoherencia. Russell evocó su propia reacción
ante la temida posibilidad de que las matemáticas fueran intrínsecamente contradictorias.
No había escapatoria a la contradicción. El trabajo de Russell causó un perjuicio considerable
al sueño de crear un sistema matemático libre de duda, incoherencia y paradoja.
La paradoja de Russell se explica a menudo con el cuento del bibliotecario minucioso.
Un día, deambulando entre las estanterías, el bibliotecario descubre una
colección de catálogos. Hay diferentes catálogos para novelas, obras de
consulta, poesía, y demás. Se da cuenta de que algunos de los catálogos se
incluyen a sí mismos y otros en cambio, no.
Con el objeto de simplificar el sistema, el bibliotecario elabora dos catálogos
más: en uno de ellos hace constar todos los catálogos que se incluyen a sí
mismos y en el otro, más interesante aún, todos aquellos que no se catalogan
a sí mismos. ¿Debe catalogarse a sí mismo? Si se incluye, por definición no
debería estar incluido; en cambio, si no se incluye, debería incluirse por
definición. El bibliotecario se encuentra en una situación imposible.
110
Prof. Joel Ayala de la Vega.
El primer enunciado de Gödel dice básicamente que, con independencia de la serie de
axiomas que se vaya a utilizar, habrá cuestiones que las matemáticas no puedan resolver; la
completitud no podrá alcanzarse jamás. Peor aún, el segundo enunciado dice que los
matemáticos jamás podrán estar seguros de que los axiomas elegidos no los conducirán a
ninguna contradicción; la coherencia no podrá demostrarse jamás. Gödel probó que el
programa de Hilbert era una tarea imposible.
A pesar de que el segundo enunciado de Gödel decía que era imposible demostrar que los
axiomas fueran coherentes, eso no implicaba que fueran incoherentes. Muchos años después,
el gran teórico de números André Weil dijo:
Puesto que Gödel consiguió traducir la proposición de más arriba a una notación matemática,
fue capaz de demostrar que hay enunciados matemáticos ciertos que jamás podrán probarse
como tales; son los denominados enunciados indecidibles. Éste fue el golpe mortal para el
programa de Hilbert.
111
Prof. Joel Ayala de la Vega.
posterior se dirigió hacia ese objetivo. En 1928, David Hilbert y Wilhelm Ackermann
propusieron la pregunta en su formulación anteriormente mencionada.
Una fórmula lógica de primer orden es llamada universalmente válida o lógicamente válida si
se deduce de los axiomas del cálculo de primer orden. El teorema de completitud de Gödel
establece que una fórmula lógica es universalmente válida en este sentido si y sólo si es cierta
en toda interpretación de la fórmula en un modelo.
Antes de poder responder a esta pregunta, hubo que definir formalmente la noción de
algoritmo. Esto fue realizado por Alonzo Church en 1936 con el concepto de “calculabilidad
efectiva” basada en su cálculo lambda y por Alan Turing basándose en la máquina de Turing.
Los dos enfoques son equivalentes, en el sentido en que se pueden resolver exactamente los
mismos problemas con ambos enfoques.
Es importante notar que si se restringe el problema a una teoría de primer orden específica
con constantes, predicados constantes y axiomas, es posible que exista un algoritmo de
decisión para la teoría. Algunos ejemplos de teorías decidibles son: la aritmética de
Presburger y los sistemas estáticos de tipos de los Lenguajes de programación.
Sin embargo, la teoría general de primer orden para los números naturales conocida como la
aritmética de Peano no puede ser decidida con ese tipo de algoritmo. Esto se deduce del
argumento de Turing resumido más arriba.
Además, el teorema de Gödel mostró que no existe algoritmo cuya entrada pueda ser
cualquier proposición acerca de los enteros y cuya salida es o no verdadera. Siguiendo de
cerca a Gödel, otros matemáticos como Alonso Church, Sephen Kleene, Emil Post, Alan
Turing y muchos otros, encontraron más problemas que carecían de solución algorítmica. Tal
vez la característica más notable de estos primeros resultados sobre problemas que no se
pueden resolver por medio de computadoras es que se obtuvieron en la década de 1930 ¡antes
de que se hubiera construido la primera computadora!
112
Prof. Joel Ayala de la Vega.
11.2 Tesis CHURCH – TURING.
Existe un obstáculo importante al probar que no existe un algoritmo para una tarea específica.
Primero es necesario saber con exactitud qué significa algoritmo. Cada uno de los
matemáticos mencionados en la sección anterior había superado este obstáculo y lo hizo
definiendo algoritmo en forma diferente.
Gödel definió un algoritmo como una secuencia de reglas para formar funciones matemáticas
complicadas a partir de funciones matemáticas más simples.
Church utilizó un formalismo denominado cálculo lambda.
Turing empleó una máquina hipotética conocida como la máquina de Turing. Turing definió
un algoritmo como cualquier conjunto de instrucciones para su máquina simple.
Estas definiciones, en apariencia diferentes, y creadas de manera independiente, resultan ser
equivalentes. Conforme los investigadores se dieron cada vez más cuenta de esta equivalencia
en la década de los 30’s, se creyó en forma amplia en las dos proposiciones siguientes:
1. Todas las definiciones razonables de “algoritmo” conocidas hasta el momento son
equivalentes.
2. Cualquier definición razonable de “algoritmo” que se llegue a dar, a su vez será
equivalente a las definiciones ya conocidas.
Estas creencias han llegado a denominarse tesis de Church – Turing en honor a dos de los
primeros trabajadores que se dieron cuenta de la naturaleza fundamental del concepto que
habían definido. Hasta el momento no ha existido evidencia en contra y se acepta
ampliamente la tesis de Church – Turing.
En un planteamiento moderno, es posible definir “algoritmo” como cualquier cosa que pueda
ejecutarse en una computadora. Dadas dos computadoras modernas, es posible escribir un
programa para una de ellas que pueda comprender y ejecutarse en otra.
La equivalencia entre toda computadora moderna, y la máquina de Turing y con otros
numerosos medios de definir “algoritmo”, es una evidencia más de la tesis de Church –
Turing. Esta propiedad de los algoritmos se conoce como Universalidad.
En términos informales, universalidad significa que cualquier computadora es equivalente a
todas las otras en el sentido de que todas pueden efectuar las mismas tareas.
11.3 Complejidad.
El estudio de la computabilidad lleva a comprender cuáles son los problemas que admiten
solución algorítmica y cuáles no. De aquellos problemas para los que existen algoritmos,
también resulta de interés saber cuántos recursos de cómputo se necesitan para su ejecución.
Sólo los algoritmos que utilizan una cantidad factible de recursos resultan útiles en la
práctica. El campo de la ciencia de la computación denominado teoría de la complejidad es el
que pregunta e intenta resolver cuestiones acerca del empleo de recursos de cómputo.
En la siguiente figura se muestra una representación pictórica del universo de problemas.
Aquellos que pueden computarse en forma algorítmica forman un subconjunto
infinitesimalmente pequeño. Los que son factiblemente computables tomando en cuenta sus
necesidades de recursos, comprenden una diminuta porción del ya infinitesimalmente
pequeño subconjunto. Sin embargo, la clase de problemas factibles computables es tan grande
que la ciencia de la computación se ha vuelto una ciencia interesante, practica y floreciente.
113
Prof. Joel Ayala de la Vega.
Problemas
Computables Todos los problemas
Problemas
factiblemente
computables.
La creencia de que todas las computadoras secuenciales razonables que se llegan a crear
tienen tiempos de ejecución relacionados polinomialmente recibe el nombre de tesis de
computación secuencial. Esta tesis puede compararse con la tesis de Curch-Turing. Es una
versión más fuerte de esta tesis, pues afirma no sólo que todos los problemas computables son
los mismos para todas las computadoras, sino también que todos los problemas computables
factibles son los mismos para todas las computadoras.
Esta sección contiene lo que tal vez el desarrollo más importante en investigación en
algoritmos en la década de los 70’s, no sólo en ciencias de la computación, sino también en
ingeniería eléctrica, en investigación de operaciones y otras áreas relacionadas.
Una idea importante es la distinción entre un grupo de problemas cuya solución se obtiene en
tiempo polinomial y un segundo grupo de problemas cuya solución no se obtiene en tiempo
polinomial.
114
Prof. Joel Ayala de la Vega.
La teoría de NP-completo no provee algoritmos para resolver los problemas del segundo
grupo en tiempo polinomial, tampoco dice que no exista algún algoritmo en tiempo
polinomial. En lugar de eso, se explicará que todo aquel problema que no tiene en este
momento un algoritmo en tiempo polinomial está computacionalmente relacionado. En
realidad, se pueden establecer dos clases de problemas. Estos serán los problemas NP-duros y
los NP-completos. Un problema que es NP-completo tendrá la propiedad de que se puede
resolver en tiempo polinomial si y sólo si todos los demás NP-completo también se puede
resolver en tiempo polinomial. Si un problema NP-duro se puede resolver en tiempo
polinomial entonces todos los problemas NP-completos se pueden resolver en tiempo
polinomial.
Mientras que se definen varios problemas con la propiedad de ser clasificados como NP-
duros o NP-completos (problemas que no se resuelven en forma secuencial en tiempo
polinomial), estos mismos problemas se pueden resolver en máquinas no determinísticas en
tiempo polinomial.
115
Prof. Joel Ayala de la Vega.
Note que, como A no está ordenado, cualquier algoritmo determinístico de búsqueda tiene una
complejidad Ω(n).
Una interpretación de un algoritmo no determinístico puede ser permitido utilizando una
computadora paralela sin límites. Se puede hacer en cada instante cada choice(S), el algoritmo
realiza varias copias de él mismo. Una copia para cada choice(S). Por lo que todas las copias
son ejecutadas al mismo tiempo. La primera copia que termine con un sucessful obliga que
terminen las demás copias. Si una copia termina en failure, sólo esa copia se detiene. Es
importante indicar que una máquina no determinística no produce ninguna copia de algún
algoritmo cada vez que un choice(S) sea ejecutado. Ya que la máquina es ficticia, no es
necesario explicar como tal máquina determina si existe un success o un failure.
Stephen Cook demostró dicha pertenencia en 1971 utilizando una máquina de Turing no
determinista (MTND) a través de la siguiente demostración:
116
Prof. Joel Ayala de la Vega.
Veamos a esto con un ejemplo:
Se evalúa la expresión: .
Como no se ha encontrado una solución válida se hace una nueva asignación:
y
Se evalúa la expresión: .
Estas son sólo dos de las ocho (2n = 3) posibles asignaciones. Se puede apreciar que el número
de soluciones crece rápidamente al añadir nuevas variables, de ahí que su complejidad
computacional sea elevada.
Algoritmo DPLL: utiliza una búsqueda hacia atrás sistemática (back tracking) para
explorar las posibles asignaciones de valores a las variables que hagan al problema
satisfacible.
En este momento se puede definir mejor los tipos de problemas NP-duros y NP –completo.
Primero se definirá la noción de reducibilidad.
Definición: Sea L1 y L2 dos problemas. L1 reduce a L2 (L1 α L2) si y sólo si existe un camino
para resolver L1 con un algoritmo polinomial determinístico también se usará un algoritmo
determinístico de tiempo polinomial que resuelva a L2.
Esta definición implica que si existe un algoritmo determinístico en tiempo polinomial para
L2 entonces podemos resolver L1 en tiempo polinomial. Este operador es transitivo, esto es,
si L1 α L2 y L2 α L3 entonces L1 α L3.
Un problema NP-duro puede no ser NP-completo. Sólo un problema de decisión puede ser
NP-completo. Sin embargo, un problema de optimización puede ser NP-duro. Además, si L1
es un problema de decisión y L2 es un problema de optimización, es bastante posible que L1 α
L2. Se puede observar que el problema de decisión de la mochila se puede reducir al problema
de optimización de la mochila. También se puede comentar que el problema de optimización
se puede reducir a su correspondiente problema de decisión. Por lo tanto, problemas de
optimización no pueden ser NP-completos, mientras que algunos problemas de decisión
pueden ser del tipo NP-duro y no son NP-completos.
117
Prof. Joel Ayala de la Vega.
Considere el problema del paro para un algoritmo determinístico. El problema del paro (the
halting problem) es determinar para un arbitrario algoritmo determinístico A y una entrada I si
el algoritmo A con la entrada I termina (o entra en un ciclo infinito). Este problema es
indecidible. Por lo que no existe algoritmo (de ninguna complejidad) para resolver este
problema. Por lo que el problema no es del tipo NP. Para poder mostrar satisfacibilidad α
halting problem, simplemente se construye un algoritmo A cuya entrada es una formula
proposicional X. Si X tiene n variables entonces A realiza los 2n posibles asignaciones y
verifica si X es satisfacible. Si lo es, entonces A se detiene. Si X no es satisfacible entonces A
entra en un ciclo infinito. Si tenemos un algoritmo en tiempo polinomial para el halting
problem entonces podemos resolver el problema de la satisfacibilidad en tiempo polinomial
usando A y X como entrada del algoritmo para “the halting problem”. De aquí que, el
problema del paro es un problema NP-duro pero no está en NP.
118
Prof. Joel Ayala de la Vega.
representativa) se puede llegar a un problema que se pueda resolver en un tiempo polinomial,
pero la solución tiene un relajamiento y no es el problema como tal.
Ya que es casi imposible que los problemas NP-duros se puedan resolver en tiempo
polinomial, es importante determinar cuáles son las restricciones a relajar dentro de las cuales
nosotros podamos resolver el problema en tiempo polinomial.
Según ideas recientes de David Deutscth, es posible en principio construir una computadora
cuántica para la que existen (clases de) problemas que no están en P, pero que podrían ser
resueltos por dicho dispositivo en tiempo polinomial. No está claro todavía cómo podría
construirse un dispositivo físico confiable que se comporte (confiablemente) como una
119
Prof. Joel Ayala de la Vega.
computadora cuántica – y además, la clase particular de problemas considerada hasta ahora es
decididamente artificial-, pero subsiste la posibilidad teórica de que un dispositivo físico
cuántico mejoraría una máquina de Turing.
¿Sería posible que un cerebro humano –que para nuestro estudio estoy considerando como un
“dispositivo físico” sorprendentemente sutil, delicado en su diseño, así como complicado-
estuviera sacando provecho de la teoría cuántica? ¿Comprendemos el modo en el que podrían
ser aprovechados los efectos cuánticos para la solución de problemas y la formación de
juicios? ¿Es concebible que tengamos que ir aún más allá de la teoría cuántica de hoy para
hacer uso de esas ventajas? ¿En verdad los dispositivos físicos pueden mejorar la teoría de la
complejidad para máquinas de Turing? ¿Qué sucede con la teoría de la computabilidad para
dispositivos físicos reales?
Penrose deja una serie de interrogantes que permiten, en cierta forma, unir el procesamiento
cerebral con el procesamiento de una computadora cuántica.
120
Prof. Joel Ayala de la Vega.
Trabajos citados
Abellanas, M., & Lodares, D. (1990). Análisis de Algoritmos y Teoría de Grafos. México:
Macrobit-Ra-Ma.
Alfonseca Cubero, E., Alfonseca Moreno, M., & Moriyon, R. (2007). Teoría de Autómatas y
Lenguajes Formales. México: Mac Graw Hill.
Booch, G. (1991). Object Oriented Design with Applications. California: The
Benjamin/Cummings Publishing Company, Inc.
Cairó/Gardati. (2000). Estructura de Datos. México: Mc Graw hill.
Carlo Ghezzi, M. J. (1991). Funamentals of Software Engineering. New Jersey: Prentice Hall
International.
Dasgupta, S., Papadimitriou, C., & Vazirani, U. (2008). Algorithms. New York: Mc Graw
Hill.
Deheza, M. E. (2005). Importancia de la cohesión y acoplaimento en la Ingenierìa de
Software. Texcoco, México: Universidad Francisco Ferreira y Arriola (Tesis de
Licenciatura en Informática).
Deutsch, D. (s.f.). Lectures on Cuantum Computation. Recuperado el 20 de 09 de 2011, de
http://www.quiprocone.org/Protected/DD_lectures.htm
Dewdney, A. K. (1989). The Turing Omnibus. 61 Excursions in Computer Science. New
York: Computer Science Press.
Diccionario de la Real Academia Española. (s.f.). Recuperado el 30 de 09 de 2015, de
http://lema.rae.es/drae/?val=algoritmo
Ellis horowitz, S. S. (1978). Fundamentals of Computer Algorithms. United States of
America: Computer Science Press.
Garey, M. R., & Johnson, D. S. (1975). Computer and intratability. A guide to the theory of
NP Completeness. U. S. A.: A series of Books in the Mathematical Science.
Goldshlager, L., & Lister, A. (1986). Introducción Moderna a las Ciencias de la
Computación con un enfoque algorítmico. México: Prentice Hall.
Kewis, H. R., & Papadimitriou, C. H. (1989). Elements of The Theory of Computation. U. S.
A.: Prentice Hall.
Levin, G. (2004). Computaciónn y Programación Moderna, perspectiva integral de la
Informática. México: Addison Wesley.
Loomis, M. E. (2013). Estructura de Datos y Organización de Archivos. México: Prentice
Hall.
Penrose, R. (1989). La mente nueva del emperador. En torno a la cibernética, la mente y las
leyes de la Física. México: Fondo de Cultura Económica.
Singh, S. (1995). El Enigma de Fermat. México: Planeta.
121
Prof. Joel Ayala de la Vega.
ANEXO A Programa de Estudios.
122
Prof. Joel Ayala de la Vega.
123
Prof. Joel Ayala de la Vega.
124
Prof. Joel Ayala de la Vega.
125
Prof. Joel Ayala de la Vega.
126
Prof. Joel Ayala de la Vega.