ii442 análisis y diseño de algoritmos

75
ANÁLISIS Y DISEÑO DE ALGORITMOS BASADO EN LIBRO DE TECNICAS Y DISEÑO DE ALGORITMOS DE ROSA GUEREQUETA Y ANTONIO VALLECILLO RECOPILACIÓN: MG. DANIEL GAMARRA MORENO HUANCAYO – PERU 2005

Upload: daniel-gamarra-moreno

Post on 24-Jul-2015

208 views

Category:

Documents


2 download

TRANSCRIPT

ANÁLISIS Y DISEÑO DE ALGORITMOS

BASADO EN LIBRO DE

TECNICAS Y DISEÑO DE ALGORITMOS

DE ROSA GUEREQUETA Y ANTONIO VALLECILLO

RECOPILACIÓN: MG. DANIEL GAMARRA MORENO

HUANCAYO – PERU

2005

2

¿QUÉ ES UN ALGORITMO? Un algoritmo, nombre que proviene del matemático persa del siglo IX alKhowárizmi, es sencillamente un conjunto de reglas para efectuar algún cálculo, bien sea a mano o, más frecuentemente, en una máquina.

El algoritmo más famoso de la historia procede de un tiempo anterior al de los antiguos griegos: se trata del algoritmo de Euclides para calcular el máximo común divisor de dos enteros.

La ejecución de un algoritmo no debe de implicar, normalmente, ninguna decisión subjetiva, ni tampoco debe de hacer preciso el uso de la intuición ni de la creatividad. Por tanto se puede considerar que una receta de cocina es un algoritmo si describe precisamente la forma de preparar un cierto plato, proporcionándonos las cantidades exactas que deben de utilizarse y también instrucciones detalladas acerca del tiempo que debe de guisarse. Por otra parte, si se incluyen nociones vagas tales como «salpimentar a su gusto» o «guísese hasta que esté medio hecho» entonces no se podría llamar algoritmo.

Algunas circunstancias los algoritmos aproximados pueden resultar útiles. Si deseamos calcular la raíz cuadrada de 2, por ejemplo, ningún algoritmo nos podrá dar una respuesta exacta en notación decimal, por cuanto la representación de 2 es infinitamente larga y no se repite. En este caso, nos conformaremos con que el algoritmo nos pueda dar una respuesta que sea tan precisa como nosotros decidamos: 4 dígitos de precisión, o 10 dígitos o los que queramos.

Hay problemas para los cuales no se conocen algoritmos prácticos. Para tales problemas, la utilización de uno de los algoritmos disponibles para encontrar la respuesta exacta requerirá en la mayoría de los casos un tiempo excesivo: por ejemplo, algunos siglos. Cuando esto sucede, nos vemos obligados, si es que necesitamos disponer de alguna clase de solución al problema, a buscar un conjunto de reglas que creamos que nos van a dar una buena aproximación de la respuesta correcta, y que podremos ejecutar en un tiempo razonable. Si podemos demostrar que la respuesta computada mediante este conjunto de reglas no es excesivamente errónea, tanto mejor. A ello se denomina algoritmo heurístico o simplemente heurística. Obsérvese una diferencia crucial entre los algoritmos aproximados y la heurística: con los primeros podemos especificar el error que estamos dispuestos a aceptar; con la segunda no podemos controlar el error, pero quizá seamos capaces de estimar su magnitud.

Cuando nos disponemos a resolver un problema, es posible que haya toda una gama de algoritmos disponibles. En este caso, es importante decidir cuál de ellos hay que utilizar. Dependiendo de nuestras prioridades y de los límites del equipo que esté disponible para nosotros, quizá necesitemos seleccionar el algoritmo que requiera menos tiempo, o el que utilice menos espacio, o el que sea más fácil de programar y así sucesivamente. La respuesta puede depender de muchos factores, tales como los números implicados, la forma en que se presenta el problema, o la velocidad y capacidad de almacenamiento del equipo de computación disponible. Quizás suceda que ninguno de los algoritmos disponibles sea totalmente adecuado, así que tendremos que diseñar un algoritmo nuevo por nuestros propios medios.

Por ejemplo, en la multiplicación:

Si nos han educado en Norteamérica, lo más probable es que se multiplique sucesivamente el multiplicando por cada una de las cifras del multiplicador, tomadas de derecha a izquierda, y que se escriban estos resultados intermedios uno tras otro, desplazando cada línea un lugar a la izquierda, y que finalmente se sumen todas estas filas para obtener la respuesta. Por tanto para multiplicar 981 por 1.234 se construirá una disposición de números como la de la figura 1. Si, por otra parte, le han educado a uno en Inglaterra, es más probable que trabajemos de izquierda a derecha, dando lugar a la distribución que se muestra en la figura 2.

Figura 1 . Multiplicación estilo norteamericano.

3

Figura 2 . Multiplicación estilo inglés.

MULPLICACIÓN A LA RUSA Se escriben el multiplicando y el multiplicador uno junto a otro. Se hacen dos columnas, una debajo de cada operando, repitiendo la regla siguiente hasta que el número de la columna izquierda sea un 1: se divide el número de la columna de la izquierda por 2, ignorando los restos y se duplica el número de la columna de la derecha sumándolo consigo mismo. A continuación se tachan todas las filas en las cuales el número de la columna izquierda sea par, y finalmente se su man los números que quedan en la columna de la derecha. La figura 3 ilustra la forma de multiplicar 981 por 1.234. La respuesta obtenida es:

1234 + 4936 + 19744 + 78976 + 157952+ 315904 + 631808 = 1210554.

Figura 3 . Multiplicación a la rusa.

MULTIPLICACIÓN DIVIDE Y VENCERAS Es necesario que el multiplicando y el multiplicador tengan el mismo número de cifras y además se necesita que este número sea una potencia de dos, tal como 1, 2, 4, 8, 16, etc. Esto se arregla fácilmente añadiendo ceros por la izquierda si es necesario: en nuestro ejemplo, añadimos nada más un cero a la izquierda del multiplicando, transformándolo en 0981, de tal manera que ambos operandos tengan cuatro cifras.

Figura 4 . Divide y vencerás.

Ahora para multiplicar 0981 por 1234 multiplicamos primero la mitad izquierda del multiplicando por la mitad izquierda del multiplicador (12), y escribimos el resultado (108) desplazado hacia la izquierda tantas veces como cifras haya en el multiplicador: cuatro, en nuestro ejemplo. A continuación multiplicamos la mitad izquierda del multiplicando (09) por la mitad derecha del multiplicador (34), y escribimos el resultado (306) desplazado hacia la izquierda tantas veces como la mitad de las cifras que haya en el multiplicador: dos, en este caso. En tercer lugar multiplicamos la mitad derecha del multiplicando (81) por la mitad izquierda del multiplicador (12), y escribimos el resultado (972) desplazado también hacia la izquierda tantas veces como la mitad de las cifras que haya en el multiplicador, y en cuarto lugar multiplicamos la mitad derecha del multiplicando (81) por la mitad derecha del multiplicador (34) y escribimos el resultado (2.754), sin desplazarlo en absoluto. Por último sumamos los cuatro resultados intermedios según se muestra en la figura 4 para obtener la respuesta 1210554.

4

Figura 5 . Multiplicación de 09 por 12 mediante divide y vencerás.

Si se ha seguido el funcionamiento del algoritmo hasta el momento, se verá que hemos reducido la multiplicación de dos números de cuatro cifras a cuatro multiplicaciones de números de dos cifras (09 x 12, 09 x 34, 81 x 12 y 81 x 34), junto con un cierto número de desplazamientos y una suma final. El truco consiste en observar que cada una de estas multiplicaciones de números de dos cifras se puede efectuar exactamente de la misma manera salvo que cada multiplicación de números de dos cifras requiere cuatro multiplicaciones de números de una cifra, algunos desplazamientos y una suma. Por ejemplo la figura 5 muestra cómo multiplicar 09 x 12. Calculamos O x 1 = 0, desplazado hacia la izquierda dos veces; O x 2 = 0, desplazado hacia la izquierda una vez; 9 x 1 = 9, desplazado a la izquierda una vez, y 9 x 2 = 18, sin desplazar. Finalmente sumamos estos resultados intermedios para obtener la respuesta: 108. Utilizando estas ideas se podría operar de tal manera que las multiplicaciones solamente implicasen a operandos de una cifra.

NOTACIÓN PARA LOS PROGRAMAS Es importante decidir la forma en que vamos a describir nuestros algoritmos. Si intentamos explicarlos en español, descubriremos rápidamente que los lenguajes naturales no están en absoluto adaptados para este tipo de cosas. Hay algunos aspectos de nuestra notación para los programas que merecen especial atención. Utilizaremos frases en español en nuestros programas siempre que esto produzca sencillez y claridad. De manera similar, utilizaremos el lenguaje matemático, tal como el del álgebra y de la teoría de conjuntos, siempre que sea necesario. Como consecuencia, una sola “instrucción” de nuestros programas puede tener que traducirse a varias instrucciones, quizá a un bucle mientras, si es que el algoritmo tiene que implementarse en un lenguaje de programación convencional. Por tanto no se debe esperar ser capaces de ejecutar directamente los algoritmos que presentemos: siempre será preciso hacer el esfuerzo necesario para transcribirlos a un lenguaje de programación «real». Sin embargo, este enfoque es el que más se ajusta a nuestro objetivo primordial, que es presentar de la forma más clara posible los conceptos básicos que subyacen a nuestros algoritmos.

Para simplificar todavía más nuestros programas, omitiremos casi siempre las declaraciones de magnitudes escalares (enteras, reales o booleanas). En aquellos casos en que sea importante, tal como en las funciones y procedimientos recursivos, todas las variables que se utilizan se toman implícitamente como variables locales, a no ser que el contexto indique claramente lo contrario. En este mismo espíritu de simplificación se evita la proliferación de instituciones begin y end que invaden a los programas escritos en Pascal: el rango de instrucciones tales como si, mientras o for, así como otras declaraciones como procedimiento, función o registro se muestra sangrando las instrucciones en cuestión. La instrucción devolver marca la finalización dinámica de un procedimiento o de una función, y en este último caso proporciona además el valor de la función.

En los procedimientos y funciones no se declara el tipo de los parámetros, ni tampoco el tipo de los resultados proporcionados por una función, a no ser que tales declaraciones hagan que el algoritmo sea más fácil de entender. Los parámetros escalares se pasan por valor, lo que significa que son tratados como variables locales dentro del procedimiento o la función, a no ser que se declaren como parámetros var, en cuyo caso se pueden utilizar para proporcionar un valor al programa llamante. En contraste, los parámetros tipo matriz se pasan por referencia, lo cual significa que toda modificación efectuada dentro del procedimiento o función se verán reflejados en la matriz que realmente se pase en la instrucción que haga la llamada.

Programa para multiplicar á la russe. Aquí el símbolo ÷ denota la división entera: cualquier posible resto se descarta.

función rusa(m, n) resultado=0 repetir si m es impar entonces resultado=resultado + n m=m ÷ 2 n=n + n hasta que m = 1 devolver resultado+n

5

NOTACIÓN MATEMÁTICA CÁLCULO PROPOSICIONAL

Existen dos “valores de verdad”, verdadero y falso. Una variable booleana (o proposicional) solamente puede tomar uno de estos dos valores. Si p es una variable booleana, escribimos p es verdadero, o bien simplemente p, para indicar p=verdadero. Esto es generalizable a todas las expresiones arbitrarias cuyo valor sea booleano. Sean p y q dos variables booleanas. Su conjunción qp ∧ , o p y q es verdadero si y sólo si p y q son verdaderos. Su disyunción

qp ∨ , o p o q es verdadero si y sólo si al menos uno de entre p o q es verdadero. (En particular, la disyunción de p y q es verdadera cuando tanto p como q son verdaderos.) La negación de p que se denota como p¬ o «no p», es verdadero si y sólo si p es falso. Si la verdad de p implica la de q escribiremos qp ⇒ , que se lee si p entonces q. Si la verdad de p es equivalente a la de q, lo cual significa que son ambos o bien verdadero o bien falso, entonces escribimos qp ⇔ . Podemos construir fórmulas booleanas a partir de variables booleanas, constantes (verdadero y falso), conectivas ( ∧ , ∨ , ¬ , ⇒ , ⇔ ) y paréntesis de la forma evidente.

TEORÍA DE CONJUNTOS Para todos los efectos prácticos, resulta suficiente pensar que un conjunto es una colección no ordenada de elementos distintos. Un conjunto se dice finito si contiene un número finito de elementos; en caso contrario el conjunto es infinito. Si X es un conjunto finito, |X|, la cardinalidad de X denota el número de elementos que hay en X. Si X es un conjunto infinito podemos escribir que la cardinalidad de X es infinita. El conjunto vacío, que se denota como φ , es el conjunto único cuya cardinalidad es 0.

La forma más sencilla de denotar un conjunto es rodear la enumeración de esos elementos entre llaves. Por ejemplo, 2,3,5,7, denota el conjunto de números primos de una sola cifra. Cuando no puede surgir ninguna ambigüedad, se permite el uso de puntos suspensivos, tal como en “N= 0,1,2,3, ... es el conjunto de números naturales”.

Si X es un conjunto, x∈X significa que x pertenece a X. Escribiremos que x∉X cuando x no pertenezca a X. La barra vertical “|” se lee en la forma “tal que” y se utiliza para definir un conjunto describiendo la propiedad que cumplen todos sus miembros. Por ejemplo, n | n ∈ N y n es impar denota el conjunto de todos los números naturales impares. Hay otras notaciones alternativas más sencillas para el mismo conjunto que son n∈N | n es impar o incluso 2n + 1 I n∈N.

Si X e Y son dos conjuntos, X ⊆ Y significa que todos los elemento de X pertenecen también a Y; y se lee “X es un subconjunto de Y”. La notación X ⊂ Y significa que X ⊆ Y y además que hay por lo menos un elemento de Y que no pertenece a X; se lee “X es un subconjunto propio de Y”. Tenga en cuenta que algunos autores utilizan ⊂ para denotar lo que nosotros denotamos mediante ⊆ . Los conjuntos X e Y son iguales, lo cual se escribe X=Y, si y sólo si contienen exactamente los mismos elementos. Esto es equivalente a decir que X ⊆ Y y que Y ⊆ X.

Si X e Y son dos conjuntos, denotamos su unión mediante X ∪ Y=z|z∈X o z∈Y, su intersección como X ∩ Y=z|z∈X y z∈Y), y su diferencia como X\Y=z|z∈X pero z∉Y.

Obsérvese en particular que z∈X ∩ Y cuando z pertenece tanto a X como a Y.

Representamos por (x, y) el par ordenado que consta de los elementos x e y en este orden. El producto cartesiano de X e Y es el conjunto de pares ordenados cuyo primer componente es elemento de X y cuyo segundo componente es elemento de Y; esto es X x Y= (x,y)|x∈X e y∈Y. Las n-tuplas ordenadas para n > 2 y el producto cartesiano de más de dos conjuntos se definen de forma similar. Denotaremos X x X por X2 y similarmente para Xi, i≥3.

ENTEROS, REALES E INTERVALOS Denotaremos el conjunto de los números enteros por Z =..., –2, –1, 0, 1, 2, ..., y el conjunto de números naturales como ¥ =0, 1, 2, ..., y el conjunto de los enteros positivos como +¥ =1, 2, 3,.... A veces ponemos de manifiesto que el 0 no está incluido en +¥ haciendo alusión explícita al conjunto de los números enteros estrictamente positivos. En algunas ocasiones aludiremos a los números naturales con el nombre de enteros no negativos.

Indicamos el conjunto de números reales como ¡ , y el conjunto de los números reales positivos como

| 0x x+ = ∈ >¡ ¡

En algunas ocasiones hacemos hincapié en que 0 no está incluido en +¡ aludiendo explícitamente al conjunto de números reales estrictamente positivos. El conjunto de números reales no negativos se denota mediante

6

0 | 0x x≥ = ∈ ≥¡ ¡ . Un intervalo es un conjunto de números reales que yacen entre dos límites. Sean a y b dos números reales tales que a≤b. El intervalo abierto (a, b) se representa por:

|x a x b∈ < <¡ .

El intervalo es vacío si a = b. El intervalo cerrado [a,b] se representa por

|x a x b∈ ≤ ≤¡

También existen intervalos semiabiertos:

( ] , |a b x a x b= ∈ < ≤¡

[ ) , |a b x a x b= ∈ ≤ <¡ Lo que es más, se admiten a = −∞ y b = +∞ . con el significado evidente siempre y cuando queden en el lado abierto de un intervalo.

Un intervalo entero es un conjunto de enteros que yacen entre dos límites. Sean i y j dos enteros tales que i ≤ j + 1. El intervalo [i…j] se indica por

|n i n j∈ ≤ ≤¢

Este intervalo está vacío si i=j+1. Obsérvese que |[i…j]|=j-i+1.

FUNCIONES Y RELACIONES Sean X e Y dos conjuntos. Todo subconjunto ρ de su producto cartesiano X x Y es una relación. Cuando x∈X e y∈Y, decimos que x está relacionado con y según ρ, lo cual se denota x ρ y, si y sólo si (x, y) ∈ ρ. Por ejemplo, uno puede pensar en la relación ≤ con respecto a los números enteros como el conjunto de los pares de enteros tales que el primer componente del par es menor o igual que el segundo.

Considérese cualquier relación f entre X e Y. La relación se llama función si, para cada x ∈ X, existe sólo un y perteneciente a Y tal que el par (x,y) ∈ f. Esto se representa con la expresión f: X à Y, lo que se lee “f es una función de X en Y”. Dado x ∈ X, el único y ∈ Y tal que (x,y) ∈ f se representa como f(x). El conjunto X se llama dominio de la función, Y es su imagen, y el conjunto f[X]=f(x) | x ∈ X es su rango. En general, f[Z] denota f(x) | x e Z siempre que Z ⊆ X.

Una función f:XàY se dice inyectiva (o bien uno a uno) si no existen dos x1, x2 ∈ X tales que f(x1)=f(x2). Es suprayectiva (o sobreyectiva) si para todos los y ∈ Y existe al menos una x ∈ X tal que f(x)=y. En otras palabras, es suprayectiva si su rango coincide con su imagen. Se dice que es biyectiva si es a la vez inyectiva y suprayectiva. Si f es biyectiva, denotaremos por f-1, que se pronuncia “la inversa de f” a la función de Y en X que está definida por f(f-

1(y))=y para todos los y ∈ Y.

Ejemplo inyectiva: X=a, e, i

Y=1, 3, 5, 7

7

f=(a, 7), (e, 1), (i, 5)

Ejemplo sobreyectiva: X=a, e, i, o, u

Y=1, 3, 5, 7

f=(a, 1), (e, 7), (i, 3), (o, 5), (u, 7)

Ejemplo biyectiva X=a, e, i, o, u

Y=1, 3, 5, 7, 9

f=(a, 5), (e, 1), (i, 9), (o, 3), (u, 7)

Dado cualquier conjunto X, una función P:Xà(verdadero, falso) se llama un predicado sobre X. Existe una equivalencia natural entre predicados de X y subconjuntos de X: el subconjunto correspondiente a P es x∈X|P(x). Cuando P es un predicado sobre X, diremos en algunas ocasiones que P es una propiedad de X. Por ejemplo, la imparidad es una propiedad de los enteros, que es verdadera para los enteros impares y falsa para los enteros pares. También existe una interpretación natural de las fórmulas booleanas en términos de predicado. Por ejemplo, uno puede definir un predicado P: verdadero, falso3àverdadero, falso mediante

( , , ) ( ) ( )P p q r p q q r= ∧ ∨ ¬ ∧ en cuyo caso P (verdadero, falso, verdadero) = verdadero.

CUANTIFICADORES Los símbolos ∀ y ∃ se leen “para todo” y “existe”, respectivamente. Para ilustrar esto, considérese un conjunto

arbitrario X y una propiedad P sobre X. Escribimos ( )[ ( )]x X P x∀ ∈ para indicar que “todos los x de X tienen la

misma propiedad P”. De manera similar, ( )[ ( )]x X P x∃ ∈ significa que «existe al menos un elemento de x en X que

tiene la propiedad P». Finalmente, escribiremos ( ! )[ ( )]x X P x∃ ∈ para significar «existe exactamente un x en X que

tiene la propiedad P». Si X es el conjunto vacío, ( )[ ( )]x X P x∀ ∈ siempre es vacíamente verdadero, intente

encontrar un contraejemplo si no está de acuerdo, mientras que ( )[ ( )]x X P x∃ ∈ siempre es trivialmente falso. Considérense los tres ejemplos concretos siguientes:

1

( 1)( )2

n

i

n nn i=

+ ∀ ∈ =

∑¥

2

1

( ! )n

in i n+

=

∃ ∈ =

∑¥

[ ]( , ) 1, 1 12573m n m n y mn∃ ∈ > > =¥ Estos ejemplos afirman que la bien conocida fórmula para la suma de los n primeros enteros es siempre válida, que esta suma es también igual a un n2 sólo para un valor entero positivo de n, y que 12.573 es un entero compuesto, respectivamente.

Se puede utilizar una alternancia de los cuantificadores en una sola expresión. Por ejemplo:

[ ]( )( )n m m n∀ ∈ ∃ ∈ >¥ ¥ dice que para todo número natural existe otro número natural mayor todavía. Cuando se utiliza la alternancia de cuantificadores, el orden en el cual se presentan los cuantificadores es importante. Por ejemplo, la afirmación

[ ]( )( )m n m n∃ ∈ ∀ ∈ >¥ ¥ es evidentemente falsa: significaría que existe un entero m que es mayor que todos los números naturales (incluyendo el propio m).

Siempre y cuando el conjunto X sea infinito, resulta útil decir que no solamente existe un x∈X tal que la propiedad de

P(x) es cierta, sino que además existen infinitos de ellos. El cuantificador apropiado en este caso es ∞

∃ Por ejemplo,

8

[ ]( )n si n es primo∞

∃ ∈¥. Obsérvese que

∃ es más fuerte que ∃ pero más débil que ∀ . Otro cuantificador útil,

más fuerte que ∞

∃ pero todavía más débil que ∀ , es ∞

∀ , que se usa cuando una propiedad es válida en todos los casos salvo posiblemente para un número finito de excepciones.

Por ejemplo, [ ]( ) ,n si n es primo entonces n es impar∞

∀ ∈¥ significa que los números primos siempre son impares,

salvo posiblemente por un número finito de excepciones, en este caso hay solamente una excepción: 2 es a la vez primo y par.

Cuando estamos interesados en las propiedades de los números naturales, existe una definición equivalente para estos cuantificadores, y suele ser mejor pensar en ellos en consecuencia. Una propiedad P de los números naturales es cierta con infinita frecuencia, si, independientemente de lo grande que sea m, existe un n≥m tal que P(n) es válido. De manera similar, la propiedad P es válida para todos los números naturales salvo posiblemente por un número finito de excepciones si existe un natural m tal que P(n) es válido para todos los números naturales n≥m. En este último caso, diremos que “la propiedad P es cierta para todos los enteros suficientemente grandes”. Formalmente:

[ ] ( )( ) ( ) ( )[ ( )]n P n es equivalente a m n m P n∞

∃ ∈ ∀ ∈ ∃ ≥¥ ¥ mientras que

[ ] ( )( ) ( ) ( )[ ( )]n P n es equivalente a m n m P n∞

∀ ∈ ∃ ∈ ∀ ≥¥ ¥

El principio de dualidad para los cuantificadores dice que “no es cierto que la propiedad P sea válida para todo x∈X si y sólo si existe al menos un x∈X para el cual la propiedad P no es válida”. En otras palabras:

[ ] ( )( ) ( ) [ ( )]x X P x es equivalente a x X P x¬ ∀ ∈ ∃ ∈ ¬ De manera similar:

[ ] ( )( ) ( ) [ ( )]x X P x es equivalente a x X P x¬ ∃ ∈ ∀ ∈ ¬

El principio de dualidad también es válido para ∞

∀ y ∞

∃ .

SUMAS Y PRODUCTOS

Considérese una función :f →¥ ¡ y un entero n≥0. (Esto incluye :f →¥ ¥ como caso especial.) La suma de los valores tomados por f sobre los n primeros números positivos se denota mediante

1( ) (1) (2) ( )

n

if i f f f n

=

= + + +∑ K

que se lee “la suma de los f(i) cuando i va desde 1 hasta n”.

En el caso en que n=0, la suma se define como O. Esto se generaliza en la forma evidente para denotar una suma cuando i va desde m hasta n siempre y cuando m≤n+1. En algunas ocasiones resulta útil considerar sumas condicionales. Si P es una propiedad de los enteros,

( )

( )P i

f i∑

denota la suma de f(i) para todos los enteros i tal que sea válido P(i). Esta suma puede no estar bien definida si involucra a un número infinito de enteros, podemos incluso utilizar una notación mixta tal como

1( )

( )n

iP i

f i=∑

que denota la suma de los valores tomados por f para aquellos enteros que se encuentran entre 1 y n para los cuales es válida la propiedad P. Si no hay tales enteros, la suma es 0. Por ejemplo,

9

10

1

1 3 5 7 9 25ii impar

i=

= + + + + =∑

El producto de los valores tomados por f sobre los n primeros enteros positivos se denota mediante

1

( ) (1) (2) (3) ( )n

i

f i f f f f n=

=∏ K ,

lo cual se lee “el producto de los f(i) cuando i va desde 1 hasta n”. En el caso n=0, se define el producto como 1. Esta notación se generaliza en la misma forma que en la notación del sumatorio.

MISCELÁNEA Si b≠1 y x son números reales estrictamente positivos, entonces logb x , que se lee “el logaritmo en base b de x”, se

define como el número real y tal que yb x= . Por ejemplo, 10log 1000 3= . Obsérvese que aun cuando b y x deben

ser positivos, no existe tal restricción para y. Por ejemplo, 10log 0,001 3= − . Cuando la base b no está especificada, interpretamos que se trata de e=2,7182818..., la base de los llamados logaritmos naturales (algunos autores toman la base 10 cuando no se especifica y denotan el logaritmo natural como «ln».) En Algoritmia, la base que se utiliza más a menudo para los logaritmos es 2, y merece una notación propia: “lg x” es la abreviatura de 2lg x . Las identidades logarítmicas más importantes:

log ( ) log logb b bxy x y= + ,

log logyb bx y x= ,

logloglog

ba

b

xxa

=

y por último log logb by xx y= Recuérdese también que el “log log n” es el logaritmo del logaritmo de n, pero “log2 n” es el cuadrado del logaritmo de n.

Si x es un número real, x representa el mayor entero que no es mayor que x, y se denomina el suelo de x. Por

ejemplo, 13 32

= . Cuando x es positivo, x es el entero que se obtiene descartando la parte fraccionaria de x si

es que existe. Sin embargo, cuando x es negativo y no es un entero en sí x es más pequeño que este valor por una

unidad. Por ejemplo, 13 42

− = − . De manera similar, definimos el techo de x, que se denota como x , como el

menor entero que no es menor que x. Obsérvese que 1 1x x x x x− < ≤ ≤ < + para todo x.

Si m≥0 y n>0 son enteros, m/n denota como siempre el resultado de dividir m por n, lo cual no es necesariamente un entero. Por ejemplo, 7/2 = 3½. Denotamos el cociente entero mediante el símbolo “÷”, por tanto 7÷2=3. Formalmente,

/m n m n÷ = . También utilizamos mod para denotar el operador “módulo” que se define como:

mod ( )m n m n m n= − ÷

En otras palabras, m mod n es el resto cuando m es dividido por n.

Si m es un entero positivo, denotamos el producto de los m primeros enteros positivos como m!, lo cual se lee factorial de m. Es natural definir 0!=1. Ahora bien n!=n(n – 1)! para todos los enteros positivos n. Una aproximación

útil del factorial es la que da la fórmula de Stirling: ! 2nnn n

eπ ≈

, en donde e es la base de los logaritmos

naturales.

Si n y r son enteros tales que 0 r n≤ ≤ , se representa mediante nr

el número de formas de seleccionar r

elementos de un conjunto de cardinalidad n, sin tener en cuenta el orden en el cual hagamos nuestras selecciones.

10

TÉCNICA DE DEMOSTRACIÓN 1: CONTRADICCIÓN Ya hemos visto que puede existir toda una selección de algoritmos disponibles cuando nos preparamos para resolver un problema. Para decidir cuál es el más adecuado para nuestra aplicación concreta, resulta crucial establecer las propiedades matemáticas de los diferentes algoritmos, tal como puede ser el tiempo de ejecución como función del tamaño del ejemplar que haya que resolver. Esto puede implicar demostrar estas propiedades mediante una demostración matemática. Dos técnicas de demostración que suelen ser útiles en Algoritmia: la demostración por contradicción y la demostración por inducción matemática.

La demostración por contradicción, que también se conoce como prueba indirecta, consiste en demostrar la veracidad de una sentencia demostrando que su negación da lugar a una contradicción. En otras palabras, supongamos que se desea demostrar la sentencia S. Por ejemplo, S podría ser «existe un número infinito de números primos». Para dar una demostración indirecta de S, se empieza por suponer que S es falso (o, equivalentemente, suponiendo que «no S» es verdadero). ¿Qué se puede deducir si, a partir de esa suposición, el razonamiento matemático establece la veracidad de una afirmación que es evidentemente falsa? Naturalmente, podría ser que el razonamiento en cuestión estuviera equivocado. Sin embargo, si el razonamiento es correcto, la única explicación posible es que la suposición original es falsa. De hecho, solamente es posible demostrar la veracidad de una falsedad a partir de una hipótesis falsa.

Ilustraremos este principio con dos ejemplos, el segundo de los cuales es una sorprendente ilustración de que las pruebas indirectas nos dejan algunas veces un sabor algo amargo. Nuestro primer ejemplo es el que ya se ha mencionado, y que era conocido por los antiguos griegos (Proposición Vigésima del Libro IX de los Elementos de Euclides).

Teorema 1. (Euclides) Existen infinitos números primos

DEMOSTRACIÓN Sea P el conjunto de los números primos. Supongamos para buscar una contradicción que P es un conjunto finito. El conjunto P no es vacío, porque contiene al menos el entero 2. Dado que P es finito y no está vacío, tiene sentido multiplicar todos sus elementos. Sea x ese producto, y sea “y” el valor x + 1. Consideremos el menor entero d que es mayor que 1 y que es el divisor de “y”. Este entero existe ciertamente por cuanto “y” es mayor que 1 y no exigimos que d sea distinto de y. Obsérvese en primer lugar que d en sí es primo, porque en caso contrario todo divisor propio de d dividiría también a “y”, y sería más pequeño que d, que estaría en contradicción con la definición de d. Por tanto, de acuerdo con nuestra suposición de que P contiene absolutamente todos los números primos, d pertenece a P. Esto demuestra que d es también divisor de x, puesto que x es el producto de una colección de enteros que contiene a d. Hemos llegado a la conclusión de que d divide exactamente tanto a x como a y. Pero se recordará que y = x + 1. Por tanto, hemos obtenido un entero d más grande que 1 y que divide a dos enteros consecutivos x e y. Esto es claramente imposible: si realmente d divide a x, entonces la división de ”y” por d dejará necesariamente 1 como resto. La conclusión ineludible es que la suposición original era igualmente imposible. Pero la suposición original era que el conjunto P de todos los primos es finito, y por tanto su imposibilidad indica que el conjunto P es, de hecho, infinito.

La demostración del Teorema de Euclides se puede transformar en un algoritmo, aunque no sea muy eficiente, capaz de hallar un nuevo número primo dado cualquier conjunto finito de primos.

función Nuevoprimo (P : conjunto de enteros) El argumento P debería ser un conjunto de primos no vacío finito x= producto de los elementos de P y= x + 1 d=1 repetir d=d+1 hasta que d divide a y devolver d La demostración de Euclides establece que el valor proporcionado por Nuevoprimo (P) es un número primo que no pertenece a P. ¿Pero quién necesita a Euclides cuando está escribiendo un algoritmo para esta tarea? ¿Por qué no utilizar el siguiente algoritmo, mucho más sencillo?

función EvitarEuclides (P : conjunto de enteros) El argumento P ha de ser un conjunto de primos no vacío finito x el mayor elemento de P repetir x = x + 1 hasta que x es primo devolver x

Es evidente que este segundo algoritmo proporciona como resultado un número primo que no pertenece a P, ¿verdad? La respuesta es sí, siempre y cuando el algoritmo termine. El problema es que EvitarEuclides quedaría en un bucle infinito si P contuviera el mayor de los números primos.

11

Naturalmente, esta situación no se puede dar porque no existe tal cosa como «el mayor número primo», pero se necesita la demostración de Euclides para establecer esto. En resumen, EvitarEuclides funciona, pero la demostración de su terminación no es inmediata. Por contraposición, el hecho de que Nuevoprimo siempre termine es evidente (en el caso peor terminará cuando d alcance el valor de y), pero el hecho de que proporcione un nuevo primo requiere demostración.

Acabamos de ver que a veces es posible transformar una demostración matemática en un algoritmo. Desafortunadamente, esto no siempre sucede cuando la demostración es por contradicción. Ilustraremos esto con un ejemplo elegante.

Teorema 2. Existen dos números irracionales x e y tales que xy es racional.

DEMOSTRACIÓN Para realizar la demostración por contradicción supongamos que xy es necesariamente irracional siempre que tanto x como y son irracionales. Es bien conocido que 2 es irracional (esto se sabía ya en tiempos de Pitágoras, que vivió

incluso antes de Euclides). Hagamos que z tome el valor de 2

2 . Por nuestra suposición z es irracional por cuanto es el

resultado de elevar un número irracional ( 2 ) a una potencia irracional (una vez más 2 ). Hagamos ahora que w tenga el

valor de 2z . Una vez más, tenemos que w es irracional por nuestra suposición, en cuanto que tanto z como 2 son irracionales. Pero

( ) ( )2 2 2 222 2 2 2 2w z

⋅ = = = = =

.

Hemos llegado a la conclusión de que 2 es irracional, lo cual claramente es falso. Por tanto hay que concluir que nuestra suposición era falsa: debe ser posible obtener un número racional cuando se eleva un número irracional a una potencia irracional.

Ahora bien, ¿cómo se podría transformar esta demostración en un algoritmo? Claramente el propósito del algoritmo sería mostrar dos números irracionales x e y tales que yx es racional. A primera vista, puede uno sentirse tentado a

decir que el algoritmo podría limitarse a indicar x z= (tal como se define z en la demostración) e 2y = , puesto

que se ha demostrado que z es irracional y que 2 2z = . ¡Cuidado! la «demostración» de que z es irracional, depende de la suposición falsa con la que hemos comenzado y por tanto ésta demostración no es válida (sólo la demostración no es válida; a decir verdad, es cierto que z es irracional, pero esto es difícil de establecer). Siempre hay que tener cuidado de no utilizar posteriormente un resultado intermedio «demostrado» en medio de una demostración por contradicción.

No hay una forma directa de extraer la pareja requerida (x, y) a partir de la demostración del teorema. Lo que se puede hacer es extraer dos parejas y afirmar con confianza que una de ellas hace lo que deseamos (pero no será posible saber cuál). Esta demostración se denomina no constructiva y no es muy frecuente entre las pruebas indirectas. Aun cuando algunos matemáticos no aceptan las demostraciones no constructivas, la mayoría de ellos las ven como perfectamente válidas. En todo caso, nos abstendremos en lo posible de utilizarlas en el contexto de la Algoritmia.

12

TÉCNICA DE DEMOSTRACIÓN 2: INDUCCIÓN MATEMÁTICA De las herramientas matemáticas básicas útiles en la Algoritmia, quizá no haya ninguna más importante que la inducción matemática. No sólo nos permite demostrar propiedades interesantes acerca de la corrección y eficiencia de los algoritmos, sino que además puede utilizarse para determinar qué propiedades es preciso probar.

Antes de discutir la técnica, se ha de indicar una digresión acerca de la naturaleza del descubrimiento científico. En la ciencia hay dos enfoques opuestos fundamentales: inducción y deducción. De acuerdo con el Concise Oxford Dictionary, la inducción consiste en «inferir una ley general a partir de casos particulares», mientras que una deducción es una «inferencia de lo general a lo particular». Veremos que, aun cuando la inducción puede dar lugar a conclusiones falsas, no se puede despreciar. La deducción, por otra parte, siempre es válida con tal de que sea aplicada correctamente.

En general no se puede confiar en el resultado del razonamiento inductivo. Mientras que haya casos que no hayan sido considerados, sigue siendo posible que la regla general inducida sea incorrecta. Por ejemplo, la experiencia de todos los días nos puede haber convencido inductivamente que «siempre es posible meter una persona más en un tranvía». Pero unos instantes de pensamiento nos muestran que esta regla es absurda. Como ejemplo más matemático considérese el polinomio 2( ) 41p n n n= + + . Si computamos p(0), p(1), p(2),..., p(10), se va encontrando 41, 43, 47, 53, 61, 71, 83, 97, 113, 131 y 151. Es fácil verificar que todos estos enteros son números primos. Por tanto es natural inferir por inducción que p(n) es primo para todos los valores enteros de n. Pero de hecho p(40)=1681=412 es compuesto.

Un ejemplo más sorprendente de inducción incorrecta es el dado por una conjetura de Euler, que formuló en 1769. ¿Es posible que la suma de tres cuartas potencias sea una cuarta potencia? Formalmente, es posible encontrar cuatro enteros positivos A, B, C y D tales que

4 4 4 4A B C D+ + = ?

Al no poder encontrar ni siquiera un ejemplo de este comportamiento, Euler conjeturó que esta ecuación nunca se podría satisfacer. (Esta conjetura está relacionada con el último Teorema de Fermat.) Transcurrieron más de dos siglos antes de que Elkies en 1987 descubriese el primer contraejemplo, que implicaba números de siete y ocho cifras. Posteriormente ha sido demostrado por parte de Frye, utilizando cientos de horas de tiempo de computación en varias Connection Machines, que el único contraejemplo en el que D es menor que un millón es

4 4 4 495 800 217 519 414 560 422 481+ + =

(sin contar con la solución obtenida multiplicando cada uno de estos números por 2). Obsérvese que 422 4814 es un número de 23 dígitos.

La ecuación de Pell proporciona un caso todavía más extremo de razonamiento inductivo tentador pero incorrecto. Considérese el polinomio 2( ) 91 1p n n= + . La pregunta es si existe un entero positivo n tal que p(n) es un cuadrado perfecto. Si uno va probando varios valores para n, resulta cada vez más tentador suponer inductivamente que la respuesta es negativa. Pero de hecho se puede obtener un cuadrado perfecto con este polinomio: la solución más pequeña se obtiene cuando

n = 12 055 735 790 331 359 447 442 538 767

Por contraste, el razonamiento deductivo no está sometido a errores de este tipo. Siempre y cuando regla invocada sea correcta, y sea aplicable a la situación que se estudie, la conclusión que se alcanza es necesariamente correcta. Matemáticamente, si es cierto que alguna afirmación P(x) es válida para todos los x de algún conjunto X, y si de hecho y pertenece a X, entonces se puede afirmar sin duda que P(y) es válido. No quiere decir esto que no se pueda inferir algo falso utilizando un razonamiento deductivo. A partir de una premisa falsa, se puede derivar deductivamente una conclusión falsa; éste es el principio que subyace a las demostraciones indirectas. Por ejemplo, si es correcto que P(x) es cierto para todos los x de X, pero si somos descuidados al aplicar esta regla a algún y que no pertenezca a X, entonces podemos creer equivocadamente que P(y) es cierto. De manera similar, si nuestra creencia en que P(x) es cierto para todos los x de X está basada en un razonamiento inductivo descuidado, entonces P(y) puede resultar falso aun cuando de hecho y pertenezca a X. En conclusión, el razonamiento deductivo puede producir un resultado incorrecto, pero sólo si las reglas que se siguen son incorrectas o si no se siguen correctamente.

Como ejemplo perteneciente a la ciencia de la computación consideremos la multiplicación á la russe. Si se prueba este algoritmo con varios pares de números positivos se observará que siempre da la respuesta correcta. Por inducción, puede uno formular la conjetura consistente en que el algoritmo siempre es correcto. En este caso, la

13

conjetura alcanzada inductivamente resulta ser cierta: mostraremos rigurosamente (mediante razonamiento deductivo) la corrección de este algoritmo. Una vez que se ha establecido la corrección, si se utiliza el algoritmo para multiplicar 981 por 1234 y se obtiene 1 210 554, se puede concluir que

981 x 1 234 = 1 210 554

En esta ocasión, la corrección de este caso específico de multiplicación de enteros es un caso especial de la corrección del algoritmo en general. Por tanto, la conclusión de que 981 x 1 234 = 1 210 554 está basada en un razonamiento deductivo. Sin embargo, la prueba de la corrección del algoritmo no dice nada acerca de su comportamiento para números negativos y fraccionarios y por tanto no se puede deducir nada acerca del resultado obtenido por el algoritmo si se ejecuta con -12 y 83,7.

Es muy probable que se pregunte ¿por qué utilizar la inducción, que es proclive a errores, en lugar de una deducción que es a prueba de bomba? Hay dos razones básicas para el uso de la inducción en el proceso del descubrimiento científico. Si uno es un físico cuyo objetivo es determinar las leyes fundamentales que gobiernan el Universo, es preciso utilizar un enfoque inductivo: las reglas que se infieren deberían de reflejar datos reales obtenidos de experimentos. Aun cuando sea uno un físico teórico, tal como Einstein, siguen siendo necesarios experimentos reales que otras personas hayan efectuado. Por ejemplo, Halley predijo el retorno de su cometa homónimo por razonamiento inductivo, y también por razonamiento inductivo Mendeleev predijo no sólo la existencia de elementos químicos todavía no descubiertos, sino también sus propiedades químicas.

Con seguridad, ¿sólo será legítima en matemáticas y en las rigurosas ciencias de la computación la deducción? Después de todo las propiedades matemáticas como el hecho consistente en que existen infinitos números primos y que la multiplicación á la russe es un algoritmo correcto se pueden demostrar de una forma deductiva rigurosa, sin datos experimentales. Los razonamientos inductivos deberían eliminarse de las matemáticas, ¿verdad? ¡Pues no! En realidad la matemática puede ser también una ciencia experimental. No es infrecuente que un matemático descubra una verdad matemática considerando varios casos especiales e infiriendo a partir de ellos por inducción una regla general que parece plausible. Por ejemplo, si se observa que

13 = 1 = 12

13+23 = 9 = 32

13+23+33 = 36 = 62

13+23+33+43 = 100 = 102

13+23+33+43+53 = 225 = 152

Se puede empezar a sospechar que la suma de los cubos de los números enteros positivos es siempre un cuadrado perfecto. Resulta en este caso que el razonamiento inductivo proporciona una ley correcta. Si soy todavía más perceptivo quizá me dé cuenta de que esta suma de los cubos es precisamente el cuadrado de la suma de los primeros números enteros positivos.

Sin embargo, independientemente de lo tentadora que sea la evidencia cuantos más valores de n se ensayan, una regla de este tipo no se puede basar en la evidencia inductiva solamente. La diferencia entre las matemáticas y las ciencias inherentemente experimentales es que, una vez se ha descubierto por inducción una ley matemática general, debemos demostrarla rigurosamente aplicando el proceso deductivo. Sin embargo, la inducción tiene su lugar en el proceso matemático. En caso contrario, ¿como podríamos esperar comprobar rigurosamente un teorema cuyo enunciado ni siquiera ha sido formulado? Para resumir, la inducción es necesaria para formular conjeturas, y la deducción es igualmente necesaria para demostrarlas, o a veces para demostrar su falsedad. Ninguna de estas técnicas puede ocupar el lugar de la otra. La deducción sólo es suficiente para las matemáticas «muertas» o congeladas, tal como en los Elementos de Euclides (que quizá sean el mayor monumento de la historia a las matemáticas deductivas, aun cuando no cabe duda de que gran parte de su material fue descubierto por razonamiento inductivo). Pero se necesita la inducción para mantener vivas las matemáticas. Tal como Pólya dijo una vez «las matemáticas presentadas con rigor son una ciencia deductiva sistemática, pero las matemáticas que se están haciendo son una ciencia inductiva experimental».

Por último, la moraleja de esta digresión: una de las técnicas deductivas más útiles que están disponibles en matemáticas tiene la mala fortuna de llamarse inducción matemática. Esta terminología resulta confusa, pero tenemos que vivir con ella.

EL PRINCIPIO DE INDUCCIÓN MATEMÁTICA Considérese el algoritmo siguiente:

función cuadrado (n) Si n = O entonces devolver O

14

Sino devolver 2n + cuadrado (n – 1) – 1

Si se prueba con unas cuantas entradas pequeñas, se observa que:

cuadrado(0)=0, cuadrado(1)=1, cuadrado(2)=4, cuadrado(3)=9, cuadrado(4)=16

Por inducción, parece evidente que cuadrado(n)=n2 para todos los n≥0, ¿pero cómo podría demostrarse esto rigurosamente? ¿Será verdad? Diremos que el algoritmo tiene éxito sobre el entero n siempre que cuadrado(n)=n2y que fracasa en caso contrario.

Considérese cualquier entero n≥1 y supóngase por el momento que el algoritmo tiene éxito en n –1. Por definición del algoritmo cuadrado(n)=2n+cuadrado(n –1)–1. Por nuestra suposición cuadrado(n –1)=(n –1)2. Por tanto:

cuadrado(n)=2n+(n – 1)2–1=2n+(n2–2n+1)–1=n2

¿Qué es lo que hemos conseguido? Hemos demostrado que el algoritmo debe tener éxito en n siempre que tenga éxito en n – 1, siempre y cuando n≥1. Además está claro que tiene éxito en n=0.

El principio de inducción matemática, nos permite inferir a partir de lo anterior que el algoritmo tiene éxito en todos los n≥0. Hay dos formas de comprender porque se sigue esta conclusión: de forma constructiva y por contradicción. Considérese cualquier entero positivo m sobre el cual se desea probar que el algoritmo tiene éxito. A efectos de nuestra discusión, supongamos que m≥9 (los valores menores se pueden demostrar fácilmente). Ya sabemos que el algoritmo tiene éxito en 4. A partir de la regla general consistente en que debe tener éxito en n siempre que tenga éxito en n–1 para n≥1, inferimos que también tendrá éxito en 5. Aplicando una vez más esta regla se muestra que el algoritmo tiene éxito en 6 también. Puesto que tiene éxito en 6, también tiene que tener éxito en 7, y así sucesivamente. Este razonamiento continúa tantas veces como sea necesario para llegar a la conclusión de que el algoritmo tiene éxito en m–1. Finalmente, dado que tiene éxito en m –1, tiene que tener éxito en m también. Está claro que podríamos efectuar este razonamiento de forma explícita, sin necesidad de «y así sucesivamente», para cualquier valor positivo fijo de m.

Si preferimos una única demostración que funcione para todos los n≥0 y que no contenga «y así sucesivamente», debemos aceptar el axioma del mínimo entero, que dice que todo conjunto no vacío de enteros positivos contiene un elemento mínimo. Este axioma nos permite utilizar este número mínimo como el fundamento a partir del cual demostraremos teoremas.

Ahora, para demostrar la corrección (exactitud) del algoritmo por contradicción, supongamos que existe al menos un entero positivo en el cual falla el algoritmo. Sea n el menor de estos enteros, que existe por el axioma del mínimo entero. En primer lugar, n tiene que ser mayor o igual a 5, puesto que ya hemos verificado que cuadrado(i)=i2 cuando i=1, 2, 3 ó 4. En segundo lugar el algoritmo tiene que tener éxito en n – 1, porque en caso contrario n no sería el menor entero positivo en el cual falla. Pero esto implica por regla general que el algoritmo también tiene éxito en n, lo cual contradice a nuestra suposición acerca de la selección de n. Por tanto esa n no puede existir, lo cual significa que el algoritmo tiene éxito para todos los enteros positivos. Puesto que también sabemos que el algoritmo tiene éxito en 0, concluiremos que cuadrado(n)=n2 para todos los enteros de n≥0.

Una versión sencilla del principio de inducción matemática, que es suficiente en muchos casos. Considérese cualquier propiedad P de los enteros. Por ejemplo, P(n) podría ser «cuadrado(n)=n2 o «la suma de los cubos de los n primeros números enteros es igual al cuadrado de la suma de esos enteros», o «n3<2n». Las dos primeras propiedades son ciertas para todos los n≥0, mientras que la tercera es válida siempre y cuando n≥10. Considérese también un entero a, que se conoce con el nombre de base. Si

1. P(a) es cierto

2. P(n) debe de ser cierto siempre que P(n – 1) sea válido para todos los enteros n>a

entonces la propiedad P(n) es válida para todos los enteros n≥a. Usando este principio, podríamos afirmar que cuadrado(n)=n2 para todos los n≥0, e inmediatamente, demostrar que cuadrado(0)=0=02 y que cuadrado (n)=n2 siempre que cuadrado(n – 1)2 y n≥1.

Nuestro primer ejemplo de inducción matemática mostraba cómo se puede utilizar de forma rigurosa para demostrar la corrección de un algoritmo. Como segundo ejemplo, vamos a ver cómo las demostraciones mediante inducción matemática se pueden transformar a veces en algoritmos. Este ejemplo también es instructivo por cuanto pone de manifiesto la forma correcta de escribir una demostración por inducción matemática. La discusión que sigue hace hincapié en los puntos importantes que son comunes a este tipo de pruebas.

Considérese el siguiente problema de embaldosado. Se nos da un tablero dividido en cuadrados iguales. Hay m cuadrados por fila y m cuadrados por columna, en donde m es una potencia de 2. Un cuadrado arbitrario del tablero se distingue como especial; véase la Figura 6 (a).

15

Figura 6 . Problema del embaldosado.

También se nos da un montón de baldosas, cada una de las cuales tiene el aspecto de un tablero 2 x 2 del cual se ha eliminado un cuadrado, según se ilustra en la figura 6 (b). El acertijo consiste en recubrir el tablero con estas baldosas, para que cada cuadrado quede cubierto exactamente una vez con excepción del cuadrado especial, que quedará en blanco. Este recubrimiento se denomina embaldosado, figura 6 (d) da una solución del caso dado en la figura 6 (a).

TEOREMA: EL PROBLEMA DE TESELADO SIEMPRE SE PUEDE RESOLVER Demostración. La demostración se hace por inducción matemática sobre el entero n tal que m = 2n.

• Base: el caso n=0 se satisface de forma trivial. Aquí m=1, y el «tablero» 1x1 es un único cuadrado, que es necesariamente especial. ¡Este tablero se recubre sin hacer nada! (Si no le gusta este argumento, compruebe el caso siguiente por orden de sencillez: si n = 1, entonces m = 2 y todo tablero 2 x 2 del cual se elimine un cuadrado tiene exactamente el aspecto de una baldosa, por definición.)

• Paso de inducción: considérese cualquier n≥1. Sea m=2n. Asúmase la hipótesis de inducción, consistente en que el teorema es válido para tableros 2n-1x2n-1. Considérese un tablero mxm, que contiene un cuadrado especial colocado arbitrariamente. Divídase el tablero por cuatro subtableros iguales partiéndolo en dos horizontal y verticalmente. El cuadrado especial original pertenece ahora exactamente a uno de los subtableros. Colóquese una baldosa en medio del tablero original de tal manera que cubra exactamente un cuadrado de cada uno de los demás subtableros; véase la figura 6 (c). Consideremos cada uno de los tres cuadrados así cubiertos «especial» para el subtablero correspondiente. Nos quedan cuatro subtableros del tipo 2n-1x2n-1, cada uno de los cuales contiene un cuadrado especial. Por nuestra hipótesis de inducción, cada uno de estos cuatro subtableros se puede recubrir. La solución final se obtiene combinando los recubrimientos de los subtableros junto con la baldosa colocada en la posición media del tablero original.

Puesto que el teorema es verdadero cuando m=20, y dado que su validez para m=2n se sigue de la suposición de su validez para m=2n-1 para todos los n≥1, se sigue del principio de inducción matemática que el teorema es verdadero para todo m, siempre y cuando m sea una potencia de 2.

No es difícil para transformar esta prueba de un teorema matemático a un algoritmo para efectuar el embaldosamiento real (quizá no sea un algoritmo de computadora, pero es al menos un algoritmo adecuado para el «procesamiento a mano»). Este algoritmo de teselado sigue el esquema general conocido con el nombre de divide y vencerás. Esta situación no es infrecuente cuando se demuestra constructivamente un teorema por inducción matemática.

Examinemos ahora con detalle todos los aspectos de una demostración formalmente correcta por inducción matemática, tal como la anterior. Considérese una vez más una propiedad abstracta P de los enteros, un entero a, y supóngase que se desea demostrar que P(n) es válido para todos los n≥a. Es preciso comenzar la demostración

16

mediante el caso base, que consiste en demostrar que P(a) es válido. Este caso base suele ser sencillo, y algunas veces incluso trivial, pero resulta crucial efectuarlo correctamente; en caso contrario, toda la «demostración» carece literalmente de fundamento.

El caso base va seguido por el paso de inducción, que suele ser más complicado. Éste debería empezar por «considérese cualquier n>a» (o de manera equivalente «consideremos cualquier n>a+1»). Debería continuar con una indicación explícita de la hipótesis de inducción, que establece esencialmente que P(n–1) es válido. En este momento queda por demostrar que es posible inferir que P(n) es válido suponiendo válida la hipótesis de inducción.

Con respecto a la hipótesis de inducción, es importante comprender que suponemos que P(n–1) es válido de forma provisional; no sabemos realmente que sea válido mientras no se haya demostrado el teorema. En otras palabras, el objetivo del paso de inducción es demostrar que la veracidad de P(n) se deduce lógicamente de la veracidad de P(n-1), independientemente de si P(n–1) es válido. De hecho si P(n-1) no es válido, el paso de inducción no nos permite llegar a ninguna conclusión acerca de la veracidad de P(n).

Por ejemplo, considérese la afirmación «n3<2n», que representaremos mediante P(n). Para un positivo entero n, resulta sencillo demostrar que n3<2(n–1)3 precisamente si n≥5. Considérese cualquier n≥5 y asumamos provisionalmente que P(n –1) es verdadero. Ahora

n3<2(n-1)3 porque n≥5

< 2x2n-1 por la suposición consistente en que P(n – 1) es válido

=2n.

De esta manera se ve que P(n) se sigue lógicamente de P(n–1) siempre que n≥5. Sin embargo, P(4) no es válido (se diría que 43<24, lo cual sería decir que 64<16) y por tanto no se puede inferir nada con respecto a la veracidad de P(5). Por prueba y error averiguaremos sin embargo que P(10) sí es válido (103=1000<210=1024). Por consiguiente es legítimo inferir que P(11) también es válido, y de la veracidad de P(11) se sigue que P(12) también es válido, y así sucesivamente. Por el principio de inducción matemática, dado que P(10) es válido y dado que P(n) se sigue de P(n–1) siempre que n≥5, concluimos que n3<2n es verdadero para todo n≥10. Resulta instructivo observar que P(n) también es válido para n=0 y n = 1, pero no podemos utilizar estos puntos como base de la inducción matemática porque el paso de inducción no es aplicable para valores tan pequeños de n.

Quizá suceda que la propiedad que deseamos demostrar no afecta a todos los enteros mayores o iguales a uno dado. Nuestro acertijo del embaldosado concierne solamente al conjunto de enteros que son potencias de 2. En algunas ocasiones, la propiedad no concierne en absoluto a los números enteros. Por ejemplo, no es infrecuente en Algoritmia tener la necesidad de demostrar una propiedad de los grafos. (Se podría decir, incluso, que nuestro problema del embaldosado no concierne realmente a los números enteros, sino más bien a los tableros y a las baldosas, pero esto sería partir un pelo en el aire.) En tales casos, si se ha de utilizar la inducción matemática sencilla, la propiedad que hay que demostrar deberá transformarse primeramente en una propiedad del conjunto de todos los números enteros que no sean menores que un cierto caso base. En nuestro ejemplo de embaldosado, demostrábamos que P(n) es válido para todas las potencias de 2, demostrando que Q(n) es válido para n≥0, en donde Q(n) es equivalente a P(2n). Cuando esta transformación es necesaria, se acostumbra comenzar la demostración (tal como hicimos) con las palabras «la demostración es por inducción matemática sobre tal y cual parámetro». De esta manera se realizan demostraciones acerca del número de nodos de un grafo, acerca de la longitud de una cadena de caracteres, acerca de la profundidad de un árbol, y así sucesivamente.

Existe un aspecto de las demostraciones por inducción matemática que la mayoría de los principiantes encuentra sorprendente, si es que no les parece paradójico: ¡a veces es más sencillo demostrar una afirmación más fuerte que una más débil! Ilustraremos esto con un ejemplo que ya hemos encontrado. Vimos que resulta sencillo conjeturar por inducción (no por inducción matemática) que la suma de los cubos de los n primeros números enteros siempre es un cuadrado perfecto. Demostrar esto por inducción matemática no es sencillo. La dificultad estriba en que una hipótesis de inducción tal como «la suma de los cubos de los n–1 primeros números enteros es un cuadrado perfecto» no sirve de gran ayuda para demostrar que esto también sucede para los n primeros números enteros porque no dice cuál de los cuadrados es perfecto: en general, no hay ninguna razón para creer que se obtenga un cuadrado cuando n3 se suma con otro cuadrado. Por contraste, resulta más sencillo demostrar el teorema más fuerte consistente en que nuestra suma de cubos es precisamente el cuadrado de la suma de los n primeros enteros: la hipótesis de inducción es ahora mucho más significativa.

17

TEOREMA. LA MULTIPLICACIÓN Á LA RUSSE MULTIPLICA CORRECTAMENTE CUALQUIER PAREJA DE NÚMEROS ENTEROS POSITIVOS

DEMOSTRACIÓN. Supongamos que se desea multiplicar m por n. La demostración se hace por inducción matemática sobre el valor de m.

v Base: el caso m = 1 es fácil; sólo se tiene una fila que consta de un 1 en la columna de la izquierda y de n en la columna de la derecha. Esta fila no se tacha porque 1 no es par. Cuando se «suma» el único número que «queda» en la columna de la derecha, obtenemos evidentemente n, que es el resultado correcto de multiplicar 1 por n.

v Paso de inducción: considérese cualquier m≥2 y cualquier entero positivo n. Supongamos como hipótesis de inducción que la multiplicación á la russe multiplica correctamente s por t para cualquier entero positivo s menor que m y para cualquier entero positivo t. (Obsérvese que no se exige que t sea menor que n.) Hay que considerar dos casos:

Ø Si m es par, la segunda fila de la tabla obtenida cuando se multiplica m por n contiene m/2 en la columna de la izquierda, y 2n en la columna de la derecha. Esto es idéntico a la primera fila que se obtiene cuando se multiplica m / 2 por 2n. Dado que toda fila no inicial de estas tablas depende solamente de la fila anterior, la tabla obtenida cuando se multiplica m por n es por tanto idéntica a la que se obtiene al multiplicar m / 2 por 2n, salvo por la primera fila adicional, que contiene m en la columna de la izquierda y n en la columna de la derecha. Como m es par, esta fila adicional se tachará antes de la suma final. Por tanto, el resultado final obtenido cuando se multiplica m por n á la russe es el mismo que cuando se multiplica m / 2 por 2n. Pero m/2 es positivo y menor que m. Por tanto, la hipótesis de inducción es aplicable: el resultado obtenido cuando se multiplica m/2 por 2n á la russe es (m/2) x (2n), tal como debe de ser. Consiguientemente, el resultado que se obtiene cuando se multiplica m por n á la russe es igual a (m/2) x (2n) = mn, que es lo que tiene que ser.

Ø El caso en que m es impar resulta similar, salvo que hay que sustituir m/2 por (m-1)/2 en todas partes, y que no se borra la primera fila cuando se multiplica m por n. Por tanto, el resultado final al multiplicar m por n á la russe es igual a n más el resultado de multiplicar (m – 1)/2 por 2n á la russe. Por hipótesis de inducción, esto último se calcula correctamente en la forma ((m – 1)/2) x 2n, y por tanto lo anterior se calcula en la forma n + ((m – 1)/2) x 2n, que es mn tal como debía de ser.

Esto completa la demostración del paso por inducción y por tanto del teorema.

EFICIENCIA Y COMPLEJIDAD Una vez dispongamos de un algoritmo que funciona correctamente, es necesario definir criterios para medir su rendimiento o comportamiento. Estos criterios se centran principalmente en su simplicidad y en el uso eficiente de los recursos.

A menudo se piensa que un algoritmo sencillo no es muy eficiente. Sin embargo, la sencillez es una característica muy interesante a la hora de diseñar un algoritmo, pues facilita su verificación, el estudio de su eficiencia y su mantenimiento. De ahí que muchas

veces prime la simplicidad y legibilidad del código frente a alternativas más crípticas y eficientes del algoritmo.

Respecto al uso eficiente de los recursos, éste suele medirse en función de dos parámetros: el espacio, es decir, memoria que utiliza, y el tiempo, lo que tarda en ejecutarse. Ambos representan los costes que supone encontrar la solución al problema planteado mediante un algoritmo. Dichos parámetros van a servir además para comparar algoritmos entre sí, permitiendo

18

determinar el más adecuado de entre varios que solucionan un mismo problema.

El tiempo de ejecución de un algoritmo va a depender de diversos factores como son: los datos de entrada que le suministremos, la calidad del código generado por el compilador para crear el programa objeto, la naturaleza y rapidez de las instrucciones máquina del procesador concreto que ejecute el programa, y la complejidad intrínseca del algoritmo. Hay dos estudios posibles sobre el tiempo:

1. Uno que proporciona una medida teórica (a priori), que consiste en obtener una función que acote (por arriba o por abajo) el tiempo de ejecución del algoritmo para unos valores de entrada dados.

2. Y otro que ofrece una medida real (a posteriori), consistente en medir el tiempo de ejecución del algoritmo para unos valores de entrada dados y en un ordenador concreto.

Ambas medidas son importantes puesto que, si bien la primera nos ofrece estimaciones del comportamiento de los algoritmos de forma independiente del ordenador en donde serán implementados y sin necesidad de ejecutarlos, la segunda representa las medidas reales del comportamiento del algoritmo. Estas medidas son funciones temporales de los datos de entrada.

Entendemos por tamaño de la entrada el número de componentes sobre los que se va a ejecutar el algoritmo. Por ejemplo, la dimensión del vector a ordenar o el tamaño de las matrices a multiplicar.

La unidad de tiempo a la que deben hacer referencia estas medidas de eficiencia no puede ser expresada en segundos o en otra unidad de tiempo concreta, pues no existe un ordenador estándar al que puedan hacer referencia todas las medidas.

Denotaremos por T(n) el tiempo de ejecución de un algoritmo para una entrada de tamaño n.

Teóricamente T(n) debe indicar el número de instrucciones ejecutadas por un ordenador idealizado. Debemos buscar por tanto medidas simples y abstractas, independientes del ordenador a utilizar. Para ello es necesario acotar de alguna forma la diferencia que se puede producir entre distintas implementaciones de un mismo algoritmo, ya sea del mismo código ejecutado por dos máquinas de distinta velocidad, como de dos códigos que implementen el mismo método. Esta diferencia es la que acota el siguiente principio:

PRINCIPIO DE INVARIANZA Dado un algoritmo y dos implementaciones suyas I1 e I2, que tardan T1(n) y T2(n) segundos respectivamente, el Principio de Invarianza afirma que existe una constante real c > 0 y un número natural n0 tales que para todo n ≥ n0 se verifica que T1(n) ≤ cT2(n).

Es decir, el tiempo de ejecución de dos implementaciones distintas de un algoritmo dado no va a diferir más que en una constante multiplicativa.

Con esto podemos definir sin problemas que un algoritmo tarda un tiempo del orden de T(n) si existen una constante real c > 0 y una implementación I del algoritmo que tarda menos que cT(n), para todo n tamaño de la entrada.

Dos factores a tener muy en cuenta son la constante multiplicativa y el n0 para los que se verifican las condiciones, pues si bien a priori un algoritmo de orden cuadrático es mejor que uno de orden cúbico, en el caso de tener dos algoritmos cuyos tiempos de ejecución son 2610 n y 35n el primero sólo será mejor que el segundo para tamaños de la entrada superiores a 200.000.

También es importante hacer notar que el comportamiento de un algoritmo puede cambiar notablemente para diferentes entradas (por ejemplo, lo ordenados que se encuentren ya los datos a ordenar). De hecho, para muchos programas el tiempo de ejecución es en realidad una función de la entrada específica, y no sólo del tamaño de ésta. Así suelen estudiarse tres casos para un mismo algoritmo: caso peor, caso mejor y caso medio.

El caso mejor corresponde a la traza (secuencia de sentencias) del algoritmo que realiza menos instrucciones. Análogamente, el caso peor corresponde a la traza del algoritmo que realiza más instrucciones. Respecto al caso medio, corresponde a la traza del algoritmo que realiza un número de instrucciones igual a la esperanza matemática de la variable aleatoria definida por todas las posibles trazas del algoritmo para un tamaño de la entrada dado, con las probabilidades de que éstas ocurran para esa entrada.

Es muy importante destacar que esos casos corresponden a un tamaño de la entrada dado, puesto que es un error común confundir el caso mejor con el que menos instrucciones realiza en cualquier caso, y por lo tanto contabilizar las instrucciones que hace para n = 1.

A la hora de medir el tiempo, siempre lo haremos en función del número de operaciones elementales que realiza dicho algoritmo, entendiendo por operaciones elementales (en adelante OE) aquellas que el ordenador realiza en tiempo acotado por una constante. Así, consideraremos OE las operaciones aritméticas básicas, asignaciones a variables de tipo predefinido por el compilador, los saltos (llamadas a funciones y procedimientos, retorno desde ellos, etc.), las comparaciones lógicas y el acceso a estructuras indexadas básicas, como son los vectores y matrices. Cada una de ellas contabilizará como 1 OE.

Resumiendo, el tiempo de ejecución de un algoritmo va a ser una función que mide el número de

19

operaciones elementales que realiza el algoritmo para un tamaño de entrada dado.

En general, es posible realizar el estudio de la complejidad de un algoritmo sólo en base a un conjunto reducido de sentencias, aquellas que caracterizan que el algoritmo sea lento o rápido en el sentido que nos interesa. También es posible distinguir entre los tiempos de ejecución de las diferentes operaciones elementales, lo cual es necesario a veces por las características específicas del ordenador (por ejemplo, se podría considerar que las operaciones + y ÷ presentan complejidades diferentes debido a su implementación). Sin embargo, a menos que se indique lo contrario, todas las operaciones elementales del lenguaje, y supondremos que sus tiempos de ejecución son todos iguales.

Para hacer un estudio del tiempo de ejecución de un algoritmo para los tres casos citados comenzaremos con un ejemplo concreto. Supongamos entonces que disponemos de la definición de los siguientes tipos y constantes:

// número máximo de elementos de un vector #define N ….…… // Tamaño del vector, no se usa la posición cero #define NV N+1 y de un algoritmo cuya implementación en C++ es:

int buscar(int vector[NV],int c) int j; /* 1 */ j=1; /* 2 */ while (vector[j]<c) && (j<N) /* 3 */ j=j+1; /* 4 */ /* 5 */ if (vector[j]==c) /* 6 */ return j; /* 7 */ else return 0; Para determinar el tiempo de ejecución, calcularemos primero el número de operaciones elementales (OE) que se realizan:

– En la línea (1) se ejecuta 1 OE (una asignación).

– En la línea (2) se efectúa la condición del bucle, con un total de 4 OE (dos comparaciones, un acceso al vector, y un AND).

– La línea (3) está compuesta por un incremento y una asignación (2 OE).

– La línea (5) está formada por una condición y un acceso al vector (2 OE).

– La línea (6) contiene un RETURN (1 OE) si la condición se cumple.

– La línea (7) contiene un RETURN (1 OE), cuando la condición del IF anterior es falsa.

Obsérvese cómo no se contabiliza la copia del vector a la pila de ejecución del programa, pues se pasa por referencia y no por valor. En caso de pasarlo por valor, necesitaríamos tener en cuenta el coste que esto supone (un incremento de n OE). Con esto:

– En el caso mejor para el algoritmo, se efectuará la línea (1) y de la línea (2) sólo la primera mitad de la condición, que supone 2 OE (suponemos que las expresiones se evalúan de izquierda a derecha, y con “cortocircuito”, es decir, una expresión lógica deja de ser evaluada en el momento que se conoce su valor, aunque no hayan sido evaluados todos sus términos). Tras ellas la función acaba ejecutando las líneas (5) a (7). En consecuencia, T(n)=1+2+3=6.

– En el caso peor, se efectúa la línea (1), el bucle se repite n–1 veces hasta que se cumple la segunda condición, después se efectúa la condición de la línea (5) y la función acaba al ejecutarse la línea (7). Cada iteración del bucle está compuesta por las líneas (2) y (3), junto con una ejecución adicional de la línea (2) que es la que ocasiona la salida del bucle. Por tanto

– En el caso medio, el bucle se ejecutará un

número de veces entre 0 y n–1, y vamos a suponer que cada una de ellas tiene la misma probabilidad de suceder. Como existen n posibilidades (puede que el número buscado no esté) suponemos a priori que son equiprobables y por tanto cada una tendrá una probabilidad asociada de 1/n. Con esto, el número medio de veces que se efectuará el bucle es de

Tenemos pues que

Es importante observar que no es necesario conocer el propósito del algoritmo para analizar su tiempo de ejecución y determinar sus casos mejor, peor y medio, sino que basta con estudiar su código. Suele ser un error muy frecuente el determinar tales casos basándose sólo en la funcionalidad para la que el algoritmo fue concebido, olvidando que es el código implementado el que los determina.

En este caso, un examen más detallado de la función (¡y no de su nombre!) nos muestra que tras su

20

ejecución, la función devuelve la posición de un entero dado c dentro de un vector ordenado de enteros, devolviendo 0 si el elemento no está en el vector. Lo que acabamos de probar es que su caso mejor se da cuando el elemento está en la primera posición del vector. El caso peor se produce cuando el elemento no está en el vector, y el caso medio ocurre cuando consideramos equiprobables cada una de las posiciones en las que puede encontrarse el elemento dentro del vector (incluyendo la posición especial 0, que indica que el elemento a buscar no se encuentra en el vector).

REGLAS GENERALES PARA EL CÁLCULO DEL NÚMERO DE OE

La siguiente lista presenta un conjunto de reglas generales para el cálculo del número de OE, siempre considerando el peor caso. Estas reglas definen el número de OE de cada estructura básica del lenguaje, por lo que el número de OE de un algoritmo puede hacerse por inducción sobre ellas.

– Vamos a considerar que el tiempo de una OE es, por definición, de orden 1. La constante c que menciona el Principio de Invarianza dependerá de la implementación particular, pero nosotros supondremos que vale 1.

– El tiempo de ejecución de una secuencia consecutiva de instrucciones se calcula sumando los tiempos de ejecución de cada una de las instrucciones.

– El tiempo de ejecución de la sentencia “switch(C) case v1:S1| case v2:S2 | ... | case vn:Sn;” es T = T(C) + maxT(S1),T(S2),...,T(Sn). Obsérvese que T(C) incluye el tiempo de comparación con v1, v2 ,..., vn.

– El tiempo de ejecución de la sentencia “if(C) S1 else S2;” es T = T(C) + maxT(S1),T(S2).

– El tiempo de ejecución de un bucle de sentencias “WHILE(C) S;” es T = T(C) + (nº iteraciones)*(T(S) + T(C)). Obsérvese que

tanto T(C) como T(S) pueden variar en cada iteración, y por tanto habrá que tenerlo en cuenta para su cálculo.

– Para calcular el tiempo de ejecución del resto de sentencias iterativas (for, do while) basta expresarlas como un bucle WHILE. A modo de ejemplo, el tiempo de ejecución del bucle:

For(i=1;i<=n;i++) S; puede ser calculado a partir del bucle equivalente: i:=1; WHILE(i<=n) S; I=i+1;

– El tiempo de ejecución de una llamada a un procedimiento o función F(P1, P2,..., Pn) es 1 (por la llamada), más el tiempo de evaluación de los parámetros P1, P2,..., Pn, más el tiempo que tarde en ejecutarse F, esto es, T = 1 + T(P1) + T(P2) + ... + T(Pn) + T(F). No contabilizamos la copia de los argumentos a la pila de ejecución, salvo que se trate de estructuras complejas (registros o vectores) que se pasan por valor. En este caso contabilizaremos tantas OE como valores simples contenga la estructura. El paso de parámetros por referencia, por tratarse simplemente de punteros, no contabiliza tampoco.

– El tiempo de ejecución de las llamadas a procedimientos recursivos va a dar lugar a ecuaciones en recurrencia.

También es necesario tener en cuenta, cuando el compilador las incorpore, las optimizaciones del código y la forma de evaluación de las expresiones, que pueden ocasionar “cortocircuitos” o realizarse de forma “perezosa” (lazy). En el presente trabajo supondremos que no se realizan optimizaciones, que existe el cortocircuito y que no existe evaluación perezosa.

ESPERANZA Y SUMATORIAS ESPERANZA O VALOR ESPERDADO

Para una variable aleatoria discreta con valores posibles nxxx ,,, 21 K y sus proabilidades representadas por )( ixp la esperanza se calcula con:

[ ] ∑=

=n

kkk xpxXE

1)(

PROPIEDADES DE LAS SUMATORIAS Propiedad #1: teconsunaesccnc

n

ktan

1=∑

=

21

Propiedad #2: ( ) ∑∑∑===

±=±n

kk

n

kk

n

kkk yxyx

111

Propiedad #3: ∑∑==

=n

kk

n

kk teconsunaescxccx

11tan

Propiedad #4: ∑∑=

+

+

=

+=n

kkkn

n

kk aaa

0

1

0

Propiedad #5: ∑∑=

+

+

=

+=n

kkkn

n

kk aaa

0

1

0

Propiedad #6: ∑∑∑+===

+=n

jkk

j

kk

n

kk aaa

111

Propiedad #7: jmjmm

jk≥−=∑

=

,1

Propiedad #8: ( )

21

1

+=∑

=

nnkn

k

Propiedad #9: ( )( )

21+−+

=∑=

pqpqkq

pk

Propiedad #10: ( )121

+=∑=

nnkn

k

Propiedad #11: ( ) 2

112 nk

n

k∑

=

=−

Propiedad #12: ( ) ( )12141

+=−∑=

nnkn

k

Propiedad #13: ( )1241

+=∑=

nnkn

k

Propiedad #14: ( ) ( )

6121

1

2 ++=∑

=

nnnkn

k

Propiedad #15: ( ) ( )( )21311

1++=+∑

=

nnnkkn

k

Propiedad #16: ( ) 111

1 +=

+∑= n

nkk

n

k

Propiedad #17: ( ) 2

1

3

21

+

=∑=

nnkn

k

Propiedad #18: ( ) ( ) ( )

30133121 2

1

4 −+++=∑

=

nnnnnkn

k

Propiedad #19: ∑∑=

−=

−=t

ktkkk

1

1

Propiedad #20: 0=∑−=

t

tkk

Propiedad #21: ℵ∈∀= ∑∑=

−=

hkkt

k

h

tk

h

1

21

2

22

Propiedad #22: ℵ∈∀= ∑∑=−=

hkkt

k

ht

tk

h

1

22 2

Propiedad #23: ℵ∈∀=∑−=

+ hkt

tk

h 012

Propiedad #24: ( ) 01

1 aaaa n

n

kkk −=−∑

=−

Propiedad #25: ( ) nmaaaa nm

m

nkkk ≥−=− −

=−∑ 11

Propiedad #26: ( ) nmaaaa jnjm

m

nkjkjk ≥−=− −−−

=−−−∑ 11

COTAS DE COMPLEJIDAD. MEDIDAS ASINTÓTICAS T(n) para clasificar, vamos a definir clases de equivalencia, correspondientes a las funciones que “crecen de la misma forma”.

COTA SUPERIOR. NOTACIÓN O (OMICRON) Dada una función f, las funciones g que a lo sumo crecen tan deprisa como f. Al conjunto de tales funciones se le llama cota superior de f y lo denominamos O(f). Conociendo la cota superior de un algoritmo podemos asegurar que, en ningún caso, el tiempo empleado será de un orden superior al de la cota.

Definición Sea f: N→[0,∞):

[ ) 0 0( ) : 0, | , 0, ( ) ( )O f g c c n g n cf n n n= → ∞ ∃ ∈ > ∃ ∈ • ≤ ∀ ≥¥ ¡ ¥

Diremos que una función [ ): 0,t → ∞¥ es de orden O de f si ( )t O f∈

Propiedades de O

23

COTA INFERIOR. NOTACIÓN Ω Dada una función f, queremos estudiar aquellas funciones g que a lo sumo crecen tan lentamente como f. Al conjunto de tales funciones se le llama cota inferior de f y lo denominamos Ω (f). Conociendo la cota inferior de un algoritmo podemos asegurar que, en ningún caso, el tiempo empleado será de un orden inferior al de la cota.

Definición Sea f: N→[0,∞):

[ ) 0 0( ) : 0, | , 0, ( ) ( )f g c c n g n cf n n nΩ = → ∞ ∃ ∈ > ∃ ∈ • ≥ ∀ ≥¥ ¡ ¥

Diremos que una función [ ): 0,t → ∞¥ es de orden Ω de f si ( )t f∈Ω

Propiedades de Ω

ORDEN EXACTO. NOTACIÓN Θ

Definición Sea f: N→[0,∞):

( ) ( ) ( )f O F fΩ = ∩ Ω

[ ) 0 0( ) : 0, | , , , 0, ( ) ( ) ( )f g c d c d n cf n g n df n n nΘ = → ∞ ∃ ∈ > ∃ ∈ • ≤ ≤ ∀ ≥¥ ¡ ¥

Diremos que una función [ ): 0,t → ∞¥ es de orden Ω de f si ( )t f∈Θ

24

Propiedades de Θ

EJERCICIOS Se tiene las siguientes definiciones:

#define N 10 typedef int VECTOR[N+1]; VECTOR vector=0,10,2,6,4,5,6,7,8,9,1;

Determine T(n) para el mejor caso, peor caso y el caso medio para las siguientes funciones

int busquedaLineal(VECTOR vector,int c) int i; i=1; while(vector[i]!=c && i<N) i++; if (vector[i]==c) return i; else return 0;

void burbujaDerIzq(VECTOR vector,int n) int i,j; for (i=2;i<=n;i++) for (j=n;j>=i;j--) if (vector[j-1]>vector[j]) cambio(vector[j-1],vector[j]);

void cambio(int &n1,int &n2) int t;

25

t=n1; n1=n2; n2=t;

ALGORITMOS RECURSIVOS La recursión es un concepto fundamental en Matemáticas y en la Ciencia de la Computación, en este último caso particularmente cuando se manipulan ciertas estructuras de datos de naturaleza recursiva o como paso previo para obtener una solución no recursiva.

Un objeto es llamado recursivo si parcialmente consiste o está definido en términos de sí mismo.

Las características esenciales en la recursión son:

• La condición de terminación. Sin ella, se tendría un número infinito de llamadas recursivas.

• Un parámetro que varía en cada llamada recursiva y que después de un número finito de valores debe conducir a la condición de terminación.

PROGRAMA EJEMPLO UNO

Factorial de un número natural El factorial de un número natural n es otro número natural denotado por n! y definido como sigue:

(a) n! = n (n-1)! , si n > 0

(b) 0! = 1

De acuerdo con está definición, el cálculo de factorial de 4! es como sigue:

4! = 4 * (3!) por (b) 4! = 4 * 3* (2!) por (b) 4! = 4 * 3* 2 * (1!) por (b) 4! = 4 * 3* 2 * 1* (0!) por (b) 4! = 4 * 3* 2 * 1* 1 por (b) 4! = 120

La implementación de una función factorial recursiva en el Lenguaje C++ es:

public class factorial public static long factorial(long n) if (n==0) return(1); else return(n*factorial(n-1)); public static void main(String []args) long n; System.out.print("Ingrese un número: "); n=Leer.datoLong(); System.out.println("El factorial es "+factorial(n));

PROGRAMA EJEMPLO DOS

Multiplicación de números naturales. El producto a*b en donde a y b son enteros positivos, se define como a sumado a sí mismo b veces (definición iterativa).

Una definición recursiva equivalente es:

a*b = a, si b=1

a*b = a * (b-1) + a si b>1

26

De acuerdo con esta definición, si se desea evaluar 6 * 2 se tiene:

6 *3 = 6 * 2 + 6

= 6 * 1+ 6 + 6

= 6 + 6 + 6

public class producto public static int producto(int a, int b) if (b==1) return(a); else return(producto(a,b-1)+a); public static void main(String []args) int a,b,p; System.out.print("Ingrese 1er número: "); a=Leer.datoInt(); System.out.print("Ingrese 2do número: "); b=Leer.datoInt(); p=producto(a,b); System.out.println("El producto es "+p);

PROGRAMA EJEMPLO TRES Suma de los n números consecutivos: 1+2+3+…+n

public class sumaConsecutivos public static int suma(int n) if (n==0) return(0); else return(suma(n-1)+n); public static void main(String []args) int n,s; System.out.print("Ingrese un número: "); n=Leer.datoInt(); s=suma(n); System.out.println("La suma es "+s);

PROGRAMA EJEMPLO CUATRO Programa que cambia del sistema de numeración de base 10 a otra base utilizando una función recursiva.

#include <iostream> #include <cstdlib> using namespace std; void base(int n,int b); void invertir(int n); int main(int argc,char *argv[]) int n,b; long nb; cout<<"Numero en base 10 : "; cin>>n; cout<<"Base (0..9): "; cin>>b; invertir(n);

27

cout<<endl; cout<<"En la nueba base es: "; base(n,b); cout<<endl; system("PAUSE"); void base(int n,int b) int resto; if (n==0) return; else resto=n%b; n=n/b; base(n,b); cout<<resto; void invertir(int n) int resto; if (n==0) return; else resto=n%10; n=n/10; cout<<resto; invertir(n);

PROGRAMA EJEMPLO CINCO Determina el mayor valor alamacenado en un arreglo unidimensional.

#include <cstdlib> #include <iostream> using namespace std; int suma(int a[],int n); int mayor(int a[],int n,int i); void imprimirNor(int a[],int n); void imprimirInv(int a[],int n); void imprimir(int a[],int n,int i); void imprimirI(int a[],int n,int i); int main(int argc, char *argv[]) int n=4; int v[]=3,4,5,2; imprimirNor(v,n); cout<<endl; imprimir(v,n,0); cout<<endl; imprimirInv(v,n); cout<<endl; imprimirI(v,n,0); cout<<endl; cout<<"suma = "<<suma(v,n)<<endl; cout<<"mayor= "<<mayor(v,n,0)<<endl;

28

system("PAUSE"); return EXIT_SUCCESS; int suma(int a[],int n) if(n==1) return a[0]; else return a[n-1]+suma(a,n-1); int mayor(int a[],int n, int i) if (i==n-1) return a[n-1]; else if (a[i]>mayor(a,n,i+1)) return a[i]; else return mayor(a,n,i+1); void imprimirInv(int a[],int n) if(n==0) return; else cout<<a[n-1]<<"\t"; imprimirInv(a,n-1); void imprimirNor(int a[],int n) if(n==0) return; else imprimirNor(a,n-1); cout<<a[n-1]<<"\t"; void imprimir(int a[],int n,int i) if(i==n) return; else cout<<a[i]<<"\t"; imprimir(a,n,i+1); void imprimirI(int a[],int n,int i) if(i==n) return; else imprimirI(a,n,i+1); cout<<a[i]<<"\t";

PROGRAMA EJEMPLO SEIS Realiza la búsqueda binaria en un arreglo unidimensional. La búsqueda binaria funciona en arreglos donde sus elmentos estan ordenados.

public class busquedaBinaria extends ArregloUniOrd busquedaBinaria() super(); public int BinariaBusqueda(int busca,int ini,int fin) int mid; if(ini>fin) return -1; else

29

mid=(ini+fin)/2; if(a[mid]==busca) return mid; else if(busca>a[mid]) return BinariaBusqueda(busca,mid+1,fin); else return BinariaBusqueda(busca,ini,mid-1); public static void main(String []args) busquedaBinaria b; int busca,pos; char resp; b=new busquedaBinaria(); b.ingreso(); b.reporte(); do System.out.print("Elemento a buscar: "); busca=Leer.datoInt(); pos=b.BinariaBusqueda(busca,1,b.cantidad()); if(pos>-1) System.out.println("Se encuentra en el índice "+pos); else System.out.println("No se encunetra ese valor"); System.out.print("Continuar (S/N)?"); resp=Leer.datoChar(); while(resp !='n' && resp !='N');

PROGRAMA EJEMPLO SIETE Calcula el máximo común divisor

Solución #include <cstdlib> #include <iostream> using namespace std; int mcd(int a,int b); int main(int argc, char *argv[]) int n1,n2; cout<<"Ingrese los numeros: "; cin>>n1>>n2; cout<<"MCD="<<mcd(n1,n2)<<endl; system("PAUSE"); return EXIT_SUCCESS; int mcd(int n1,int n2) if (n2==0) return n1; else return mcd(n2,n1%n2);

30

PROGRAMA EJEMPLO OCHO

Problema de las Torres de Hanoi El problema de las "Torres de Hanoi", cuya posición inicial se muestra en la Ilustración 1. Existen tres estacas, A, B y C. Se colocan cinco discos de diferentes diámetros en la estaca 1, de manera que un disco más grande siempre esté abajo de un disco más pequeño. El objetivo es pasar los cinco discos a la estaca C usando la B como auxiliar. Sólo puede moverse el disco superior de cualquier estaca a otra y un disco más grande nunca puede colocarse sobre uno más pequeño. Para solucionar este problema, en lugar de concentrar nuestra atención en una solución para cinco discos, consideremos el caso general de n discos. Suponga que teníamos una solución para n-1 discos y podíamos plantear una solución para n discos en términos de la solución para n-1 discos. En este caso se resolvería el problema. Esto es cierto debido a que en el caso trivial de un disco (restando una y otra vez 1 de n terminará por producir 1), la solución es simple: únicamente mover un solo disco de la estaca A a la C. Así, habremos desarrollado una solución recursiva si planteamos una solución para n discos en términos de n-1. Vea si puede encontrar esta relación. En particular, para el caso de cinco discos, suponga que sabernos cómo mover los cuatro discos superiores de la estaca A a otra de acuerdo con las reglas. ¿Cómo podríamos entonces terminar el trabajo de mover las cinco? Recuerde que hay tres estacas disponibles.

Ilustración 1. Posición inicial de las torres de Hanoi.

Suponga que podemos mover cuatro discos de la estaca A a la C. Entonces podríamos pasarlos con facilidad a B usando C como auxiliar. Esto produciría la situación mostrada en la Ilustración 2. Después podríamos mover el disco más grande de A a C (Ilustración 3) y por último aplicar una vez más la solución para cuatro discos con el fin de pasar cuatro discos de B a C, usando la ahora vacía estaca A como auxiliar (Ilustración 4).

Ilustración 2. Posición luego de mover cuatro discos de la estaca A a B, usando C como auxiliar

Ilustración 3. Posición después de mover el disco más grande de A a C.

A

B

C

A

B

C

A

B

C

31

Ilustración 4. Posición luego de mover cuatro discos de la estaca B a C, usando A como auxiliar

Por tanto, podemos plantear una solución recursiva para el problema de las Torres de Hanoi del modo siguiente: Para mover n discos de A a C usando B como auxiliar:

1. Si n == 1, mover el disco único de A a C y detenerse.

2. Mover los n-1 discos superiores de A a B usando C como auxiliar.

3. Mover el disco restante de A a C.

4. Mover los n-1 discos de B a C usando A como auxiliar.

public class hanoi public static void hanoi(int num,char ini,char fin,char auxiliar) if(num==1) System.out.println("Mueva disco "+num +" desde la clavija "+ini +" hasta la clavija "+fin); return; else hanoi(num-1,ini,auxiliar,fin); System.out.println("Mueva disco "+num +" desde la clavija "+ini +" hasta la clavija "+fin); hanoi(num-1,auxiliar,fin,ini); public static void main(String []args) int n; char resp; do System.out.println("Los clavijas son A B C"); System.out.print("Numero de discos: "); n=Leer.datoInt(); System.out.println(); hanoi(n,'A','C','B'); System.out.println(); System.out.print("Continuar (s/n)? "); resp=Leer.datoChar(); while(resp!='n' && resp!='N');

PROBLEMAS PROPUESTOS

PROBLEMA UNO Desarrollar un procedimiento o función recursiva para hallar las combinaciones de n números combinados en grupos de r elementos. Recomendación: Utilice las siguientes propiedades:

10 =nC

A

B

C

32

nr

nr C

rrnC 1*1

−+−

=

PROBLEMA DOS Escribir un programa que utilizando una función recursiva imprima los “n” numeros consecutivos.

PROBLEMA TRES Diseñar un método recursivo para que reciba como parámetro un dato a buscar. Como respuesta debe devolver la dirección del nodo que contiene el dato o una dirección nula si el dato no es encontrado.

PROBLEMA CUATRO Diseñar un método recursivo para que recorra e imprima los elementos de una lista simplemente enlazada.

PROBLEMA CINCO Considere un arreglo (matriz) unidimensional de enteros. Escriba algoritmos recursivos para calcular:

a) El mayor elemento del arreglo.

b) El menor elemento del arreglo.

c) La suma de los elementos del arreglo.

d) El producto de los elementos del arreglo.

e) El promedio de los elementos del arreglo.

PROBLEMA SEIS La función de Ackerman se define en forma recursiva para enteros no negativos de la siguiente manera:

a(m,n)=n+1 à si m=0

a(m,n)=a(m-1,n) à si m≠0, n=0

a(m,n)=a(m-1,a(m,n-1) à si m≠0,n ≠0

Escriba un algoritmo recursivo para la función de Ackerman.

PROBLEMA SIETE Diseñar una función recursiva para que reciba como parámetros un dato a buscar. Como respuesta debe devolver la dirección del nodo que contiene el dato o una dirección nula si el dato no es encontrado.

PROBLEMA EIGHT Diseñar una función recursiva para calcular los elementos de la serie de fibbonacci. Se comienza incicalizando los dos primeros terminos, y a partir del tercero, el elemento siguiente se halla sumando los dos elementos anteriores; por ejemplo, si inicializamos en 0 y 1, la serie es: 0, 1, 2, 3, 5, 8, 13, 21, …

#include <cstdlib> #include <iostream> using namespace std; int fib(int n); int main(int argc, char *argv[]) int n; cout<<"Termino: "; cin>>n; cout<<"Termino "<<n<<": "<<fib(n)<<endl; system("PAUSE"); return EXIT_SUCCESS;

33

int fib(int n) if(n==0) return 0; else if(n==1) return 1; else return fib(n-1)+fib(n-2);

ECUACIONES DE RECURRENCIA RECURRENCIAS HOMOGENEAS

0)()2()1()( 210 =−++−+−+ knTanTanTanTa kK Donde los coeficientes ai son números reales, y k es un número natural entre 1 y n.

Para resolverlas vamos a buscar soluciones que sean combinaciones de funciones exponenciales de la forma:

∑=

=+++=k

i

niii

nkkk

nn rnpcrnpcrnpcrnpcnT1

222111 )()()()()( K

Donde los valores c1, c2,...,cn y r1, r2, ...,rn son números reales, y p1(n),...,pk(n) son polinomios en n con coeficientes reales.

Para resolverlas haremos el cambio xn = T(n), con lo cual obtenemos la ecuación característica asociada:

022

11 =++++ −−

kkkk

o axaxaxa K

Llamemos r1, r2,...,rk a sus raíces, ya sean reales o complejas.

CASO 1: RAÍCES DISTINTAS Llamemos r1, r2,...,rk a sus raíces, ya sean reales o complejas. Dependiendo del orden de multiplicidad de tales raíces, pueden darse los dos siguientes casos.

∑=

=+++=k

i

nii

nkk

nn rcrcrcrcnT1

2211)( K

Donde los coeficientes ci se determinan a partir de las condiciones iniciales.

CASO 2: RAÍCES CON MULTIPLICIDAD MAYOR QUE 1 Supongamos que alguna de las raíces (por ejemplo r1) tiene multiplicidad m>1. Entonces la ecuación característica puede ser escrita en la forma:

( ) ( ) ( )121 +−−−− mkm rxrxrx K

en cuyo caso la solución de la ecuación en recurrencia viene dada por la expresión:

∑∑+=

+−=

− +=k

mi

nmii

m

i

nii rcrncnT

11

11

1)(

donde los coeficientes c i se determinan a partir de las condiciones iniciales.

34

Este caso puede ser generalizado de la siguiente forma. Si r1,r2,...,rk son las raíces de la ecuación característica de una ecuación en recurrencia homogénea, cada una de multiplicidad mi, esto es, si la ecuación característica puede expresarse como:

( ) ( ) ( ) kmk

mm rxrxrx −−− K2121

entonces la solución a la ecuación en recurrencia viene dada por la expresión:

∑∑∑=

=

=

− +++=km

i

nk

iki

m

i

nii

m

i

nii rncrncrncnT

1

1

12

12

11

11

21

)( K

RESUMEN ECUACIONES RECURRENTES HOMOGENEAS

FORMA DE LA ECUACION DE RECURRENCIA

0)()1()( 10 =−++−+ knTanTanTa kK

ECUACIÓN CARACTERISTICA

( ) ( ) ( ) 02121 =−−− km

kmm rxrxrx K

ECUACIÓN DE RECURRENCIA

∑∑∑=

=

=

− +++=km

i

nk

iki

m

i

nii

m

i

nii rncrncrncnT

1

1

12

12

11

11

21

)( K

ECUACIONES RECURRENTES NO HOMOGENEAS

FORMA DE LA ECUACIÓN DE RECURRENCIA

)()()()()1()( 221110 npbnpbnpbknTanTanTa sns

nnk +++=−++−+ KK

ECUACIÓN CARÁCTERISTICA

( )( ) ( ) ( ) 0112

11

22

11

21 =−−−++++ +++−− sds

ddk

kkko bxbxbxaxaxaxa KK

CAMBIO DE VARIABLE Esta técnica se aplica cuando n es potencia de un número real a, esto es, n = a k.

RECURRENCIAS NO LINEALES En este caso, la ecuación que relaciona T(n) con el resto de los términos no es lineal. Para resolverla intentaremos convertirla en una ecuación lineal como las que hemos estudiado hasta el momento.

EJEMPLO PARA LA BÚSQUEDA BINARIA int busquedaBinR(int prim, int ult,int x) int mitad;

/* 1 */ if(prim>=ult) /* 2 */ return a[ult]==x; /* 3 */ else

35

/* 4 */ mitad=(prim+ult)/2; /* 5 */ if(x==a[mitad]) /* 6 */ return 1; /* 7 */ else if(x<a[mitad]) /* 8 */ return busquedaBinR(prim,mitad-1,x); /* 9 */ else /* 10 */ return busquedaBinR(mitad+1,ult,x);

/* 1 */ 1 /* 2 */ 3 /* 3 */ 0 /* 4 */ 3 /* 5 */ 2 /* 6 */ 1 /* 7 */ 2 /* 8 */ 3+T(n/2) /* 9 */ 0 /* 10 */ 3+T(n/2

La ecuación de recurrencia para el peor caso es:

T(n)=11+T(n/2) y T(1)=4

Haciendo n=2k:

T(2k)=11+T(2k/2)

T(2k)=11+T(2k-1)

Haciendo tk=T(2k):

tk=11+tk-1

tk+ tk-1=11

tk+ tk-1=1n11

Ecuación característica:

(x-1)(x-1)=0

(x-1)2=0

La ecuación de recurrencia:

tk=c1k+c2

Haciendo T(2k)=tk:

T(2k)=c1K+c2

Haciendo 2k=n y k= log(n):

T(n)=c1 log(n)+c2

Cuando n=1, T(1)=4:

c1 log(1)+c2=4

c2=4

Cuando n=2, T(2)=4:

T(2)=11+T(2/2)=11+T(1)=11+4=15

T(2)=c1 log(2)+c2=15

c1+4=15

c1=11

Por lo tanto:

T(n)=11 log(n)+4

int busquedaBin(int prim, int ult,int x) int mitad; /* 1 */ while(prim<ult) /* 2 */ mitad=(prim+ult)/2; /* 3 */ if(x==a[mitad]) /* 4 */ return 1; /* 5 */ else if(x<a[mitad]) /* 6 */ ult=mitad-1; /* 7 */ else /* 8 */ prim=mitad+1; /* 9 */ /* 10 */ return x==a[ult]; /* 1 */ 1 /* 2 */ 3 /* 3 */ 2 /* 4 */ 1 /* 5 */ 2 /* 6 */ 2 /* 7 */ 0 /* 8 */ 2 /* 9 */ 0 /* 10 */ 3

Para el peor caso el bucle se tomo como una ecuación recursiva igual a:

T(n)=c+T(n/2)

Donde T(1)=0 y T(2)=1

Haciendo n=2k:

T(2k)=c+T(2k/2)

T(2k)=c+T(2k-1)

Haciendo tk=T(2k):

tk=c+tk-1

tk+ tk-1=c

tk+ tk-1=1nc

Ecuación característica:

(x-1)(x-1)=0

(x-1)2=0

La ecuación de recurrencia:

tk=c1k+c2

Haciendo T(2k)=tk:

T(2k)=c1K+c2

Haciendo 2k=n y k= log(n):

T(n)=c1 log(n)+c2

Cuando n=1, T(1)=0:

36

c1 log(1)+c2=0

c2=0

Cuando n=2, T(2)=1:

T(2)=c1 log(2)+c2=1

c1+0=1

c1=1

Por lo tanto:

T(n)=log(n)

Ecuación de recurrencia para el algoritmo:

DIVIDE Y VENCERÁS El término Divide y Vencerás en su acepción más amplia es algo más que una técnica de diseño de algoritmos. De hecho, suele ser considerada una filosofía general para resolver problemas y de aquí que su nombre no sólo forme parte del vocabulario informático, sino que también se utiliza en muchos otros ámbitos.

En nuestro contexto, Divide y Vencerás es una técnica de diseño de algoritmos que consiste en resolver un problema a partir de la solución de subproblemas del mismo tipo, pero de menor tamaño. Si los subproblemas son todavía relativamente grandes se aplicará de nuevo esta técnica hasta alcanzar subproblemas lo suficientemente pequeños para ser solucionados directamente. Ello naturalmente sugiere el uso de la recursión en las implementaciones de estos algoritmos.

La resolución de un problema mediante esta técnica consta fundamentalmente de los siguientes pasos:

1. En primer lugar ha de plantearse el problema de forma que pueda ser descompuesto en k subproblemas del mismo tipo, pero de menor tamaño. Es decir, si el tamaño de la entrada es n, hemos de conseguir dividir el problema en k subproblemas (donde 1 ≤ k ≤n), cada uno con una entrada de tamaño nk y donde 0 ≤ nk < n. A esta tarea se le conoce como división.

2. En segundo lugar han de resolverse independientemente todos los subproblemas, bien directamente si son elementales o bien de forma recursiva. El hecho de que el tamaño de los subproblemas sea estrictamente menor que el tamaño original del problema nos garantiza la convergencia hacia los casos elementales, también denominados casos base.

3. Por último, combinar las soluciones obtenidas en el paso anterior para construir la solución del problema original. El funcionamiento de los algoritmos que siguen la técnica de Divide y Vencerás descrita anteriormente se refleja en el esquema general que presentamos a continuación:

tiposolucion DyV(x:TipoProblema) int i,k; TipoSolucion s; TipoProblema subproblemas[xx]; TipoSolucion subsoluciones[yy]; if EsCasobase(x) s=ResuelveCasoBase(x) else k=Divide(x,subproblemas); for(i=1;i<=k;i++) subsoluciones[i]=DyV(subproblemas[i]) s=Combina(subsoluciones) return s; Hemos de hacer unas apreciaciones en este esquema sobre el procedimiento Divide, sobre el número k que representa el número de subproblemas, y sobre el tamaño de los subproblemas, ya que de todo ello va a depender la eficiencia del algoritmo resultante.

En primer lugar, el número k debe ser pequeño e independiente de una entrada determinada. En el caso particular de los algoritmos Divide y Vencerás que contienen sólo una llamada recursiva, es decir k = 1, hablaremos de algoritmos de simplificación. Tal es el caso del algoritmo recursivo que resuelve el cálculo del factorial de un número, que sencillamente reduce el problema a otro subproblema del mismo tipo de tamaño más pequeño. También son algoritmos de simplificación el de búsqueda binaria en un vector o el que resuelve el problema del k-ésimo elemento.

La ventaja de los algoritmos de simplificación es que consiguen reducir el tamaño del problema en cada paso, por lo que sus tiempos de ejecución suelen ser muy buenos (normalmente de orden logarítmico o lineal). Además pueden admitir una mejora adicional, puesto que en ellos suele poder eliminarse fácilmente la recursión mediante el uso de

37

un bucle iterativo, lo que conlleva menores tiempos de ejecución y menor complejidad espacial al no utilizar la pila de recursión, aunque por contra, también en detrimento de la legibilidad del código resultante.

Por el hecho de usar un diseño recursivo, los algoritmos diseñados mediante la técnica de Divide y Vencerás van a heredar las ventajas e inconvenientes que la recursión plantea:

a) Por un lado el diseño que se obtiene suele ser simple, claro, robusto y elegante, lo que da lugar a una mayor legibilidad y facilidad de depuración y mantenimiento del código obtenido.

b) Sin embargo, los diseños recursivos conllevan normalmente un mayor tiempo de ejecución que los iterativos, además de la complejidad espacial que puede representar el uso de la pila de recursión.

Desde un punto de vista de la eficiencia de los algoritmos Divide y Vencerás, es muy importante conseguir que los subproblemas sean independientes, es decir, que no exista solapamiento entre ellos. De lo contrario el tiempo de ejecución de estos algoritmos será exponencial. Como ejemplo pensemos en el cálculo de la sucesión de Fibonacci, el cual, a pesar de ajustarse al esquema general y de tener sólo dos llamadas recursivas, tan sólo se puede considerar un algoritmo recursivo pero no clasificarlo como diseño Divide y Vencerás. Esta técnica está concebida para resolver problemas de manera eficiente y evidentemente este algoritmo, con tiempo de ejecución exponencial, no lo es.

En cuanto a la eficiencia hay que tener en también en consideración un factor importante durante el diseño del algoritmo: el número de subproblemas y su tamaño, pues esto influye de forma notable en la complejidad del algoritmo.

BÚSQUEDA BINARIA El algoritmo de búsqueda binaria es un ejemplo claro de la técnica Divide y Vencerás. El problema de partida es decidir si existe un elemento dado x en un vector de enteros ordenado. El hecho de que esté ordenado va a permitir utilizar esta técnica, pues podemos plantear un algoritmo con la siguiente estrategia: compárese el elemento dado x con el que ocupa la posición central del vector. En caso de que coincida con él, hemos solucionado el problema. Pero si son distintos, pueden darse dos situaciones: que x sea mayor que el elemento en posición central, o que sea menor. En cualquiera de los dos casos podemos descartar una de las dos mitades del vector, puesto que si x es mayor que el elemento en posición central, también será mayor que todos los elementos en posiciones anteriores, y al revés. Ahora se procede de forma recursiva sobre la mitad que no hemos descartado.

En este ejemplo la división del problema es fácil, puesto que en cada paso se divide el vector en dos mitades tomando como referencia su posición central. El problema queda reducido a uno de menor tamaño y por ello hablamos de “simplificación”. Por supuesto, aquí no es necesario un proceso de combinación de resultados.

Su caso base se produce cuando el vector tiene sólo un elemento. En esta situación la solución del problema se basa en comparar dicho elemento con x. Como el tamaño de la entrada (en este caso el número de elementos del vector a tratar) se va dividiendo en cada paso por dos, tenemos asegurada la convergencia al caso base.

El esquema de la clase ArregloUni es:

#define MAX 20 class ArregloUni private: int n; int a[MAX]; public: //Se definen los métdos ; Algoritmo recursivo para la búsqueda binaria:

//El método pertenece a la clase ArregloUni int ArregloUni::busquedaBinR(int prim, int ult,int x) int mitad; if(prim>=ult) return a[ult]==x; else mitad=(prim+ult)/2; if(x==a[mitad]) return 1; else if(x<a[mitad])

38

return busquedaBinR(prim,mitad-1,x); else return busquedaBinR(mitad+1,ult,x);

El tiempo de ejecución del algoritmo resultante en el peor caso es:

( ) 11log 4T n n= + ∈ (log )nΘ

BÚSQUEDA BINARIA NO CENTRADA Una de las cuestiones a considerar cuando se diseña un algoritmo mediante la técnica de Divide y Vencerás es la partición y el reparto equilibrado de los subproblemas. Más concretamente, en el problema de la búsqueda binaria nos podemos plantear la siguiente cuestión: supongamos que en vez de dividir el vector de elementos en dos mitades del mismo tamaño, las dividimos en dos partes de tamaños 1/3 y 2/3. ¿Conseguiremos de esta forma un algoritmo mejor que el original?

Algoritmo recursivo para la búsqueda binaria no balanceada:

int ArregloUni::busquedaBinRNB(int prim, int ult,int x) int tercio; if(prim>=ult) return a[ult]==x; else tercio=prim+((ult-prim+1)/3); if(x==a[tercio]) return 1; else if(x<a[tercio]) return busquedaBinRNB(prim,tercio,x); else return busquedaBinRNB(tercio+1,ult,x);

Dividiendo el vector en dos partes de tamaños k y n–k, el tiempo de ejecución del algoritmo resultante en el peor caso es:

/ max , ( ) 11log 4n k n kT n n−= + ∈ (log )nΘ

Ahora bien, para 1 ≤ k < n sabemos que la función n/maxk,n–k se mantiene por debajo de 2, y sólo alcanza este valor para k = n/2.

BÚSQUEDA TERNARIA Podemos plantearnos también diseñar un algoritmo de búsqueda “ternaria”, que primero compara con el elemento en posición n/3 del vector, si éste es menor que el elemento x a buscar entonces compara con el elemento en posición 2n/3, y si no coincide con x busca recursivamente en el correspondiente subvector de tamaño 1/3 del original. ¿Conseguimos así un algoritmo mejor que el de búsqueda binaria?

int ArregloUni::busquedaBin3(int prim, int ult,int x) int nterc; if(prim>=ult) return a[ult]==x; nterc=(ult-prim-1)/3; if(x==a[prim+nterc]) return 1; else if(x<a[prim+nterc]) return busquedaBin3(prim,prim+nterc-1,x); else if(x==a[ult-nterc]) return 1; else if(x<a[ult-nterc]) return busquedaBin3(prim+nterc+1,ult-nterc-1,x); else return busquedaBin3(ult-nterc+1,ult,x);

39

El tiempo de ejecución del algoritmo resultante en el peor caso es:

3( ) 23log 4T n n= + ∈ (log )nΘ

MULTIPLICACIÓN DE ENTEROS Sean u y v dos números naturales de n bits donde, por simplicidad, n es una potencia de 2. El algoritmo tradicional para multiplicarlos es de complejidad O(n2).

El algoritmo basado en la técnica de Divide y Vencerás divide los números en dos partes:

/ 22nu a b= +

/ 22nv c d= + siendo a, b, c y d números naturales de n/2 bits, y calcula su producto como sigue:

( )( ) ( )/ 2 / 2 / 22 2n n n nuv an b cn d ac ad bc bd= + + = + + +

Sustituyendo ( ) ( )ad bc a b d c ac bd+ = − − + +

( )( )( ) / 22 2n nuv ac a b d c ac bd bd= + − − + + +

Las multiplicaciones ac y bd se realizan usando este algoritmo recursivamente.

El algoritmo tradicional esta en el orden n2, pero con este método se obtiene un tiempo de ejecución para el algoritmo recursivo igual a:

log log log31 2 1 2( ) 3 2n nT n c c c n c n= + = + ∈ 1.59( )nΘ

Este método es menor que el tradicional cuando el número es mayor a 500 bits.

ALGORITMOS ÁVIDOS El método que produce algoritmos ávidos es un método muy sencillo y que puede ser aplicado a numerosos problemas, especialmente los de optimización.

Dado un problema con n entradas el método consiste en obtener un subconjunto de éstas que satisfaga una determinada restricción definida para el problema. Cada uno de los subconjuntos que cumplan las restricciones diremos que son soluciones prometedoras. Una solución prometedora que maximice o minimice una función objetivo la denominaremos solución óptima.

Como ayuda para identificar si un problema es susceptible de ser resuelto por un algoritmo ávido vamos a definir una serie de elementos que han de estar presentes en el problema:

• Un conjunto de candidatos, que corresponden a las n entradas del problema.

• Una función de selección que en cada momento determine el candidato idóneo para formar la solución de entre los que aún no han sido seleccionados ni rechazados.

• Una función que compruebe si un cierto subconjunto de candidatos es prometedor. Entendemos por prometedor que sea posible seguir añadiendo candidatos y encontrar una solución.

• Una función objetivo que determine el valor de la solución hallada. Es la función que queremos maximizar o minimizar.

• Una función que compruebe si un subconjunto de estas entradas es solución al problema, sea óptima o no.

Con estos elementos, podemos resumir el funcionamiento de los algoritmos ávidos en los siguientes puntos:

1. Para resolver el problema, un algoritmo ávido tratará de encontrar un subconjunto de candidatos tales que, cumpliendo las restricciones del problema, constituya la solución óptima.

2. Para ello trabajará por etapas, tomando en cada una de ellas la decisión que le parece la mejor, sin considerar las consecuencias futuras, y por tanto escogerá de entre todos los candidatos el que

40

produce un óptimo local para esa etapa, suponiendo que será a su vez óptimo global para el problema.

3. Antes de añadir un candidato a la solución que está construyendo comprobará si es prometedora al añadirlo. En caso afirmativo lo incluirá en ella y en caso contrario descartará este candidato para siempre y no volverá a considerarlo.

4. Cada vez que se incluye un candidato comprobará si el conjunto obtenido es solución.

Resumiendo, los algoritmos ávidos construyen la solución en etapas sucesivas, tratando siempre de tomar la decisión óptima para cada etapa. A la vista de todo esto no resulta difícil plantear un esquema general para este tipo de algoritmos:

Conjunto AlgoritmoAvido(entrada:Conjunto) Elemento x Conjunto solucion Bolean Encontrada encontrada=FALSE crear(solucion) WHILE NOT EsVacio(entrada) AND (NOT encontrada) HACER x=SeleccionarCandidato(entrada) IF EsPrometedor(x,solucion) Incluir(x,solucion); ENDIF IF EsSolucion(solucion) encontrada=TRUE ENDIF ENDWHILE RETURN solucion De este esquema se desprende que los algoritmos ávidos son muy fáciles de implementar y producen soluciones muy eficientes. Entonces cabe preguntarse ¿por qué no utilizarlos siempre? En primer lugar, porque no todos los problemas admiten esta estrategia de solución. De hecho, la búsqueda de óptimos locales no tiene por qué conducir siempre a un óptimo global, como mostraremos en varios ejemplos. La estrategia de los algoritmos ávidos consiste en tratar de ganar todas las batallas sin pensar que, como bien saben los estrategas militares y los jugadores de ajedrez, para ganar la guerra muchas veces es necesario perder alguna batalla.

Desgraciadamente, y como en la vida misma, pocos hechos hay para los que podamos afirmar sin miedo a equivocarnos que lo que parece bueno para hoy siempre es bueno para el futuro. Y aquí radica la dificultad de estos algoritmos. Encontrar la función de selección que nos garantice que el candidato escogido o rechazado en un momento determinado es el que ha de formar parte o no de la solución óptima sin posibilidad de reconsiderar dicha decisión. Por ello, una parte muy importante de este tipo de algoritmos es la demostración formal de que la función de selección escogida consigue encontrar óptimos globales para cualquier entrada del algoritmo. No basta con diseñar un procedimiento ávido, que seguro de entre todos los candidatos el que produce un óptimo local para esa etapa, suponiendo que será a su vez óptimo global para el problema.

Debido a su eficiencia, este tipo de algoritmos es muchas veces utilizado aun en los casos donde se sabe que no necesariamente encuentran la solución óptima. En algunas ocasiones la situación nos obliga a encontrar pronto una solución razonablemente buena, aunque no sea la óptima, puesto que si la solución óptima se consigue demasiado tarde, ya no vale para nada (piénsese en el localizador de un avión de combate, o en los procesos de toma de decisiones de una central nuclear).

También hay otras circunstancias, como en los algoritmos que siguen la técnica de Ramificación y Poda, en donde lo que interesa es conseguir cuanto antes una solución del problema y, a partir de la información suministrada por ella, conseguir la óptima más rápidamente. Es decir, la eficiencia de este tipo de algoritmos hace que se utilicen aunque no consigan resolver el problema de optimización planteado, sino que sólo den una solución “aproximada”.

El nombre de algoritmos ávidos, también conocidos como voraces (su nombre original proviene del término inglés greedy) se debe a su comportamiento: en cada etapa “toman lo que pueden” sin analizar consecuencias, es decir, son glotones por naturaleza.

En este tipo de algoritmos el proceso no acaba cuando disponemos de la implementación del procedimiento que lo lleva a cabo. Lo importante es la demostración de que el algoritmo encuentra la solución óptima en todos los casos, o bien la presentación de un contraejemplo que muestra los casos en donde falla.

41

EL PROBLEMA DEL CAMBIO Suponiendo que el sistema monetario de un país está formado por monedas de valores v1, v2, ..., vn, el problema del cambio de dinero consiste en descomponer cualquier cantidad dada M en monedas de ese país utilizando el menor número posible de monedas.

En primer lugar, es fácil implementar un algoritmo ávido para resolver este problema, que es el que sigue el proceso que usualmente utilizamos en nuestra vida diaria. Sin embargo, tal algoritmo va a depender del sistema monetario utilizado y por ello vamos a plantearnos dos situaciones para las cuales deseamos conocer si el algoritmo ávido encuentra siempre la solución óptima:

a) Suponiendo que cada moneda del sistema monetario del país vale al menos el doble que la moneda de valor inferior, que existe una moneda de valor unitario, y que disponemos de un número ilimitado de monedas de cada valor.

b) Suponiendo que el sistema monetario está compuesto por monedas de valores 1, p, p2, p3,..., pn, donde p>1 y n>0, y que también disponemos de un número ilimitado de monedas de cada valor.

SOLUCIÓN Comenzaremos con la implementación de un algoritmo ávido que resuelve el problema del cambio de dinero:

#include <cstdlib> #include <iostream> using namespace std; #define N 3 int valor[]=11,5,1; void cambio(int n,int solucion[]); int main(int argc,char *argv[]) int solucion[N],i; cambio(15,solucion); for(i=0;i<N;i++) cout<<solucion[i]<<" monedas de "<<valor[i]<<endl; system("PAUSE"); return EXIT_SUCCESS; void cambio(int n,int solucion[]) int i=0,moneda; for(moneda=0;moneda<N;moneda++) solucion[moneda]=0; for(moneda=0;moneda<N;moneda++) while(valor[moneda]<=n) solucion[moneda]=solucion[moneda]+1; n=n-valor[moneda]; Este algoritmo es de complejidad lineal respecto al número de monedas del país, y por tanto muy eficiente. Respecto a las dos cuestiones planteadas, comenzaremos por la primera. Supongamos que nuestro sistema monetario esta compuesto por las siguientes monedas:

Valor[]=11,5,1;

Tal sistema verifica las condiciones del enunciado pues disponemos de moneda de valor unitario, y cada una de ellas vale más del doble de la moneda inmediatamente inferior.

Consideremos la cantidad n = 15. El algoritmo ávido del cambio de monedas descompone tal cantidad en: 15 = 11 + 1 + 1 + 1 + 1, es decir, mediante el uso de cinco monedas. Sin embargo, existe una descomposición que utiliza menos monedas (exactamente tres): 15 = 5 + 5 + 5.

EL VIAJANTE DE COMERCIO Se conocen las distancias entre un cierto número de ciudades. Un viajante debe, a partir de una de ellas, visitar cada ciudad exactamente una vez y regresar al punto de partida habiendo recorrido en total la menor distancia posible.

42

Este problema también puede ser enunciado más formalmente como sigue: dado un grafo g conexo y ponderado y

dado uno de sus vértices 0v , encontrar el ciclo Hamiltoniano de coste mínimo que comienza y termina en 0v .

Cara a intentar solucionarlo mediante un algoritmo ávido, nos planteamos las siguientes estrategias:

a) Sea ( ),C v el camino construido hasta el momento que comienza en 0v y termina en v . Inicialmente C es

vacío y 0v v= . Si C contiene todos los vértices de g , el algoritmo incluye el arco 0( , )v v y termina. Sino,

incluye el arco ( , )v w de longitud mínima entre todos los arcos desde v a los vértices w que no están en el camino C .

b) Otro posible algoritmo ávido escogería en cada iteración el arco más corto aún no considerado que cumpliera las dos condiciones siguientes: (i) no formar un ciclo con los arcos ya seleccionados, excepto en la última iteración, que es donde completa el viaje; y (ii) no es el tercer arco que incide en un mismo vértice de entre los ya escogidos.

SOLUCIÓN PRIMER ALGORITMO #include <cstdlib> #include <iostream> using namespace std; #define N 4 #define TRUE 1 #define FALSE 0 int busca(int g[N][N],int vertice,int yaesta[N]); void viajante(int g[N][N],int sol[N][N]); int main(int argc,char *argv[]) int grafo[N][N]=0,1,5,2,0,0,4,6,0,0,0,3,0,0,0,0; int solucion[N][N],i,j; viajante(grafo,solucion); cout<<"Grafo:"<<endl; for(i=0;i<N;i++) for(j=0;j<N;j++) cout<<grafo[i][j]<<"\t"; cout<<endl; cout<<"Solucion:"<<endl; for(i=0;i<N;i++) for(j=0;j<N;j++) cout<<solucion[i][j]<<"\t"; cout<<endl; system("PAUSE"); return EXIT_SUCCESS;

void viajante(int g[N][N],int sol[N][N]) int yaEsta[N]; int i,j,verticeEnCurso,verticeAnterior; for(i=0;i<N;i++) yaEsta[i]=FALSE; for(i=0;i<N;i++) for(j=0;j<N;j++) sol[i][j]=FALSE;

43

verticeEnCurso=0; for(i=0;i<N;i++) verticeAnterior=verticeEnCurso; yaEsta[verticeAnterior]=TRUE; verticeEnCurso=busca(g,verticeEnCurso,yaEsta); sol[verticeAnterior][verticeEnCurso]=TRUE;

int busca(int g[N][N],int vertice,int yaEsta[N]) int mejorvertice,i,min; mejorvertice=0; min=999999; for(i=0;i<N;i++) if((i!=vertice) && (!yaEsta[i]) && (g[vertice][i]<min)) min=g[vertice][i]; mejorvertice=i; return mejorvertice; La clave de este algoritmo está en la función Busca, que es la que realiza el proceso de selección, decidiendo en cada paso el siguiente vértice de entre los posibles candidatos.

Respecto a los ejemplos de grafos en donde el algoritmo encuentra o no la solución óptima, comenzaremos por un grafo en donde la encuentra. Sea entonces

2 3 4

1 1 5 2

2 4 6

3 3

una tabla que representa la matriz de adyacencia de un grafo ponderado 1g con cuatro vértices. Partiendo del vértice 1, el algoritmo encuentra la solución óptima, que está formada por los arcos

(1,2),(2,3),(3,4),(4,1)

lo que da lugar al ciclo (1,2,3,4,1), cuyo coste es 1 + 4 + 3 + 2 = 10, óptimo pues el resto de soluciones poseen costes superiores o iguales a él: 15, 17, 14, 17 y 10.

Para ver un ejemplo en donde el algoritmo falla, consideraremos un grafo ponderado 2g con seis vértices definido por la siguiente matriz de adyacencia:

2 3 4 5 6

1 3 10 11 7 25

2 6 12 8 26

3 9 4 20

4 5 15

5 18

Partiendo del vértice 1 el algoritmo va a ir escogiendo la secuencia de arcos (1,2),(2,3),(3,5),(5,4),(4,6),(6,1) lo que da lugar al ciclo (1,2,3,5,4,6,1), cuyo coste es 3 + 6 + 4 + 5 + 15 + 25 = 58. Sin embargo, éste no es el ciclo con menor coste, pues el camino definido por los arcos: (1,2),(2,3),(3,6),(6,4),(4,5),(5,1) tiene un coste de 3 + 6 + 20 + 15 + 5 + 7 = 56.

SOLUCIÓN SEGUNDO ALGORITMO #include <cstdlib> #include <iostream> using namespace std;

44

#define CIUDADES 6 #define N CIUDADES+1 #define NO CIUDADES*(CIUDADES-1)/2+1 #define TRUE 1 #define FALSE 0 struct item int origen; int destino; int peso; ; void inicParticion(int p[N]); void fusionar(int p[N],int a,int b); int finParticion(int p[N]); int obtenerComponente(int p[N],int i); void viajante(int g[N][N],int sol[N][N]); int ordenar(int g[N][N],item g2[NO]); int main(int argc,char *argv[]) int grafo[N][N]= 0, 0, 0, 0, 0, 0, 0, 0, 0, 3,10,11, 7,25, 0, 0, 0, 6,12, 8,26, 0, 0, 0, 0, 9, 4,20, 0, 0, 0, 0, 0, 5,15, 0, 0, 0, 0, 0, 0,18, 0, 0, 0, 0, 0, 0, 0; int solucion[N][N],i,j; cout<<"Grafo:"<<endl; for(i=1;i<N;i++) for(j=1;j<N;j++) cout<<grafo[i][j]<<"\t"; cout<<endl; viajante(grafo,solucion); cout<<"Solucion:"<<endl; for(i=1;i<N;i++) for(j=1;j<N;j++) cout<<solucion[i][j]<<"\t"; cout<<endl; system("PAUSE"); return EXIT_SUCCESS;

void viajante(int g[N][N],int sol[N][N]) int p[N]; int c1,c2; // Componentes de la partición item gOrdenado[NO]; int i,j,narcos; //Numero de arcos del grafo int u,v; //vertices tratados en cada paso int ndest[N]; //num. veces que cada vertice es destino en // la solucion inicParticion(p); for(i=1;i<N;i++) for(j=1;j<N;j++) sol[i][j]=FALSE; for(i=1;i<N;i++)

45

ndest[i]=0; narcos=ordenar(g,gOrdenado); // devuelve el num. de arcos i=0; while(not finParticion(p) && i<narcos) i=i+1; u=gOrdenado[i].origen; v=gOrdenado[i].destino; c1=obtenerComponente(p,u); c2=obtenerComponente(p,v); if(c1!=c2 && ndest[u]<2 && ndest[v]<2) fusionar(p,c1,c2); sol[u][v]=TRUE; ndest[u]=ndest[u]+1; ndest[v]=ndest[v]+1; // ahora solo nos queda el ultimo vertice, // que cierra el ciclo while(i<narcos) i=i+1; u=gOrdenado[i].origen; v=gOrdenado[i].destino; if(ndest[u]<2 && ndest[v]<2) // lo encontramos! sol[u][v]=TRUE; ndest[u]=ndest[u]+1; ndest[v]=ndest[v]+1; i=narcos; // para salirnos del bucle

void inicParticion(int p[N]) int i; for(i=1;i<N;i++) p[i]=i;

void fusionar(int p[N],int a,int b) int i,temp; if(a>b) temp=a; a=b; b=temp; for(i=1;i<N;i++) if(p[i]==b) p[i]=a;

int finParticion(int p[N]) int i; for(i=1;i<N;i++) if(p[i]!=1) return FALSE; return TRUE;

46

int obtenerComponente(int p[N],int i) return p[i];

int ordenar(int g[N][N],item g2[NO]) int i,j,k; item t; k=0; for(i=1;i<N;i++) for(j=1;j<N;j++) if(g[i][j]!=0) k=k+1; g2[k].origen=i; g2[k].destino=j; g2[k].peso=g[i][j]; for(i=1;i<=k-1;i++) for(j=k;j>=i+1;j--) if(g2[j-1].peso>g2[j].peso) t=g2[j-1]; g2[j-1]=g2[j]; g2[j]=t; return k; El grafo 1g es un ejemplo para el cual el algoritmo encuentra la solución óptima, al igual que ocurría con el anterior. Sin embargo, este algoritmo no encuentra la solución óptima en todos los casos, como ocurre por ejemplo con el grafo 2g del apartado anterior. Para él, y partiendo del vértice 1, el algoritmo va a ir escogiendo la secuencia de arcos (1,2),(3,5),(4,5),(2,3),(4,6),(1,6) que da lugar al mismo ciclo que obteníamos antes, (1,2,3,5,4,6,1), de coste 58 y por tanto no óptimo.

LA MOCHILA Dados n elementos e1, e2, ..., en con pesos p1, p2, ..., pn y beneficios b1, b2, ..., bn, y dada una mochila capaz de albergar hasta un máximo de peso M (capacidad de la mochila), queremos encontrar las proporciones de los n elementos x1, x2, ..., xn (0 ≤ xi ≤ 1) que tenemos que introducir en la mochila de forma que la suma de los beneficios de los elementos escogidos sea máxima.

Esto es, hay que encontrar valores (x1, x2, ..., xn) de forma que se maximice la cantidad 1

n

i ii

b x=∑ , sujeta a la restricción

1

n

i ii

p x M=

≤∑ .

SOLUCIÓN

Un algoritmo ávido que resuelve este problema ordena los elementos de forma decreciente respecto a su ratio /i ib p y va añadiendo objetos mientras éstos vayan cabiendo.

Con ellos, el algoritmo ávido para resolver el problema pedido con n elementos y para una capacidad de la mochila M es:

#include <cstdlib> #include <iostream> using namespace std; #define N 10 #define TAMANO N+1 #define M 30 struct elemento

47

float peso; float beneficio; float ratio; ; void ordenar(elemento a[TAMANO]); void imprimir(elemento a[TAMANO]); void mochila(elemento e[TAMANO],int n,float m,float sol[TAMANO]); int main(int argc,char *argv[]) float solucion[TAMANO]; int i; elemento elementos[]=0,0,0, 5,10,0, 10,2,0, 6,20,0, 3,4,0, 12,10,0, 8,120,0, 5,6,0, 4,8,0, 7,4,0, 9,12,0; ordenar(elementos); imprimir(elementos); mochila(elementos,N,M,solucion); cout<<"\nSolucion"<<endl; cout<<"Peso\tBeneficio\tProporcion"<<endl; for(i=1;i<=N;i++) cout<<elementos[i].peso<<"\t"<<elementos[i].beneficio <<"\t\t"<<solucion[i]<<endl; system("PAUSE"); return EXIT_SUCCESS;

void mochila(elemento e[TAMANO],int n,float m, float sol[TAMANO]) // supone que los elementos de "e" estan en // orden decreciente de su ratio bi/pi float pesoEnCurso; int i; for(i=1;i<=n;i++) sol[i]=0.0; pesoEnCurso=0.0; i=1; while(pesoEnCurso<M && i<=n) if(e[i].peso+pesoEnCurso<=M) sol[i]=1.0; else sol[i]=(M-pesoEnCurso)/e[i].peso; pesoEnCurso=pesoEnCurso+(sol[i]*e[i].peso); i=i+1;

void ordenar(elemento a[TAMANO]) int i,j; elemento t; for(i=1;i<=N;i++)

48

a[i].ratio=a[i].beneficio/a[i].peso; for(i=1;i<=N;i++) for(j=N;j>=i+1;j--) if(a[j-1].ratio<a[j].ratio) t=a[j-1]; a[j-1]=a[j]; a[j]=t;

void imprimir(elemento a[TAMANO]) int i,j; elemento t; cout<<"Elementos:"<<endl; cout<<"Peso\tBeneficio\tRatio"<<endl; for(i=1;i<=N;i++) cout<<a[i].peso<<"\t"<<a[i].beneficio<<"\t\t" <<a[i].ratio<<endl;

Figura 7 . Salida del programa.

Respecto al tiempo de ejecución del algoritmo, éste consta de la ordenación previa, de complejidad O(nlogn), y de un bucle que como máximo recorre todos los elementos, de complejidad O(n), por lo que el tiempo total resulta ser de orden O(nlogn).

Para demostrar que siguiendo la ordenación dada el algoritmo encuentra la solución óptima, vamos a suponer sin pérdida de generalidad que los elementos ya están ordenados de esta forma, es decir, que / /i i j jb p b p> si i j> . Por simplicidad en la notación utilizaremos los símbolos de sumatorios sin los índices.

Sea X = (x1, x2, ...,xn) la solución encontrada por el algoritmo. Si xi = 1 para todo i, la solución es óptima. Si no, sea j el menor índice tal que xj < 1. Por la forma en que trabaja el algoritmo, 1ix = para todo i < j, 0ix = para todo i > j, y

además i ix p M=∑ . Sea ( ) i iB x x b= ∑ el beneficio que se obtiene para esa solución.

Consideremos entonces Y = (y1, y2, ...,yn) otra solución, y sea ( ) i iB y y b= ∑ su beneficio. Por ser solución cumple

que i iy p M≤∑ . Entonces, restando ambas capacidades, podemos afirmar que ( ) 0i i i ix p y p− ≥∑ .

Calculemos entonces la diferencia de beneficios:

49

( ) ( ) ( ) ( ) ( )ii i i i i i

i

bB X B Y x y b x y pp

− = − = −∑ ∑

La segunda igualdad se obtiene multiplicando y dividiendo por ip . Con esto, para el índice j escogido anteriormente sabemos que ocurre:

• Si i < j entonces 1ix = , y por tanto ( ) 0i ix y− ≥ . Además, / /i i j jb p b p≥ por la ordenación escogida (decreciente).

• Si i > j entonces 0ix = , y por tanto ( ) 0i ix y− ≤ . Además, / /i i j jb p b p≤ por la ordenación escogida (decreciente).

• Por último, si i = j entonces / /i i j jb p b p= .

En consecuencia, podemos afirmar que ( )( / ) ( )( / )i i i i i i j jx y b p x y b p− ≥ − para todo i, y por tanto:

( ) ( ) ( ) ( / ) ( / ) ( ) 0i i i i i j j i i iB X B Y x y p b p b p x y p− = − ≥ − ≥∑ ∑ , esto es, ( ) ( )B X B Y≥ , como queríamos demostrar.

TAREA ACADEMICA Realice la implementación de la mochila (0,1), para lo cual se da la siguiente descripción.

LA MOCHILA (0,1) Consideremos una modificación al problema de la Mochila en donde añadimos el requerimiento de que no se pueden escoger fracciones de los elementos, es decir, 0ix = ó 1ix = , 1 i n≤ ≤ . Como en el problema original, deseamos

maximizar la cantidad 1

n

i ii

b x=∑ , sujeta a la restricción

1

n

i ii

p x M=

≤∑ . ¿Seguirá funcionando el algoritmo anterior en

este caso?

En la solución considere Que lamentablemente no funciona, como pone de manifiesto el siguiente ejemplo.

Supongamos una mochila de capacidad M = 6, y que disponemos de los siguientes elementos (ya ordenados respecto a su ratio beneficio/peso):

El algoritmo sólo introduciría el primer elemento, con un beneficio de 11, aunque sin embargo es posible obtener una mejor elección: podemos introducir los dos últimos elementos en la mochila puesto que no superan su capacidad, con un beneficio total de 12.

LA ASIGNACIÓN DE TAREAS Supongamos que disponemos de n trabajadores y n tareas. Sea 0ijb > el coste de asignarle el trabajo j al trabajador i. Una asignación de tareas puede ser expresada como una asignación de los valores 0 ó 1 a las variables

ijx , donde 0ijx = significa que al trabajador i no le han asignado la tarea j, y 1ijx = indica que sí. Una asignación válida es aquella en la que a cada trabajador sólo le corresponde una tarea y cada tarea está asignada a un trabajador. Dada una asignación válida, definimos el coste de dicha asignación como:

1 1

n n

ij iji j

x b= =∑∑ .

Diremos que una asignación es óptima si es de mínimo coste. Cara a diseñar un algoritmo ávido para resolver este problema podemos pensar en dos estrategias distintas: asignar cada trabajador la mejor tarea posible, o bien asignar

50

cada tarea al mejor trabajador disponible. Sin embargo, ninguna de las dos estrategias tiene por qué encontrar siempre soluciones óptimas. ¿Es alguna mejor que la otra?

SOLUCIÓN Este es un problema que aparece con mucha frecuencia, en donde los costes son o bien tarifas (que los trabajadores cobran por cada tarea) o bien tiempos (que tardan en realizarlas). Para implementar ambos algoritmos vamos a definir la matriz de costes ( ijb ):

int costes[n][n] que forma parte de los datos de entrada del problema, y la matriz de asignaciones ( ijx ), que es la que buscamos:

int asignacion[n][n] El programa principal es:

#include <cstdlib> #include <iostream> using namespace std; #define N 4 #define TAMANO N+1 #define TRUE 1 #define FALSE 0 void asignacionOptima(int b[TAMANO][TAMANO], int x[TAMANO][TAMANO]); int mejorTarea(int b[TAMANO][TAMANO],int x[TAMANO][TAMANO], int i); int yaEscogida(int x[TAMANO][TAMANO],int trabajador, int tarea); void imprimir(int x[TAMANO][TAMANO]); int costoTotal(int b[TAMANO][TAMANO],int x[TAMANO][TAMANO]); int main(int argc,char *argv[]) int costos[TAMANO][TAMANO]=0,0,0,0,0, 0,3,4,5,7, 0,5,4,6,9, 0,2,3,8,7, 0,4,5,3,4; int asignacion[TAMANO][TAMANO]; asignacionOptima(costos,asignacion); cout<<"Matriz de costos"<<endl; imprimir(costos); cout<<"\n\nMatriz de asignacion"<<endl; imprimir(asignacion); cout<<"\n\nCosto total: "<<costoTotal(costos,asignacion) <<endl; system("PAUSE"); return EXIT_SUCCESS; Con esto, el primer algoritmo puede ser implementado como sigue:

void asignacionOptima(int b[TAMANO][TAMANO], int x[TAMANO][TAMANO]) int trabajador,tarea; for(trabajador=1;trabajador<=N;trabajador++) for(tarea=1;tarea<=N;tarea++) x[trabajador][tarea]=FALSE;

51

for(trabajador=1;trabajador<=N;trabajador++) x[trabajador][mejorTarea(b,x,trabajador)]=TRUE; La función MejorTarea es la que busca la mejor tarea aún no asignada para ese trabajador:

int mejorTarea(int b[TAMANO][TAMANO],int x[TAMANO][TAMANO], int i) int tarea,min,mejorTarea; min=65535; for(tarea=1;tarea<=N;tarea++) if(!yaEscogida(x,i,tarea) && b[i][tarea]<min) min=b[i][tarea]; mejorTarea=tarea; return mejorTarea; Por último, la función YaEscogida decide si una tarea ya ha sido asignada previamente:

int yaEscogida(int x[TAMANO][TAMANO],int trabajador, int tarea) int i; for(i=1;i<=trabajador-1;i++) if(x[i][tarea]) return TRUE; return FALSE;

void imprimir(int x[TAMANO][TAMANO]) int i,j; for(i=1;i<=N;i++) for(j=1;j<=N;j++) cout<<x[i][j]<<"\t"; cout<<endl;

int costoTotal(int b[TAMANO][TAMANO],int x[TAMANO][TAMANO]) int costo,trabajador,tarea; costo=0; for(trabajador=1;trabajador<=N;trabajador++) for(tarea=1;tarea<=N;tarea++) costo=costo+b[trabajador][tarea]*x[trabajador][tarea]; return costo; Salida del programa:

52

Lamentablemente, este algoritmo ávido no funciona para todos los casos como pone de manifiesto la siguiente matriz de valores:

Tarea 1 2 3 1 16 20 18 2 11 15 17 Trabajador 3 17 1 20

Para ella, el algoritmo produce una matriz de asignaciones en donde los “unos” están en las posiciones (1,1), (2,2) y (3,3), esto es, asigna la tarea i al trabajador i (i = 1, 2 ,3), con un valor de la asignación de 51 (= 16 + 15 + 20). Sin embargo la asignación óptima se consigue con los “unos” en posiciones (1,3), (2,1) y (3,2), esto es, asigna la tarea 3 al trabajador 1, la 1 al trabajador 2 y la tarea 2 al trabajador 3, con un valor de la asignación de 30 (= 18 + 11 + 1).

Si utilizamos la segunda estrategia nos encontramos en una situación análoga. En primer lugar, su implementación es:

#include <cstdlib> #include <iostream> using namespace std; #define N 4 #define TAMANO N+1 #define TRUE 1 #define FALSE 0 void asignacionOptima(int b[TAMANO][TAMANO], int x[TAMANO][TAMANO]); int mejorTrabajador(int b[TAMANO][TAMANO], int x[TAMANO][TAMANO],int i); int yaEscogida(int x[TAMANO][TAMANO],int trabajador, int tarea); void imprimir(int x[TAMANO][TAMANO]); int costoTotal(int b[TAMANO][TAMANO],int x[TAMANO][TAMANO]); int main(int argc,char *argv[]) int costos[TAMANO][TAMANO]=0,0,0,0,0, 0,3,4,5,7, 0,5,4,6,9, 0,2,3,8,7, 0,4,5,3,4; int asignacion[TAMANO][TAMANO]; asignacionOptima(costos,asignacion); cout<<"Matriz de costos"<<endl; imprimir(costos); cout<<"\n\nMatriz de asignacion"<<endl; imprimir(asignacion); cout<<"\n\nCosto total: "<<costoTotal(costos,asignacion) <<endl; system("PAUSE");

53

return EXIT_SUCCESS;

void asignacionOptima(int b[TAMANO][TAMANO], int x[TAMANO][TAMANO]) int trabajador,tarea; for(trabajador=1;trabajador<=N;trabajador++) for(tarea=1;tarea<=N;tarea++) x[trabajador][tarea]=FALSE; for(tarea=1;tarea<=N;tarea++) x[mejorTrabajador(b,x,tarea)][tarea]=TRUE; La función MejorTrabajador es la que busca el mejor trabajador aún no asignado para esa tarea:

int mejorTrabajador(int b[TAMANO][TAMANO],int x[TAMANO][TAMANO],int i) int trabajador,min,mejorTrabajador; min=65535; for(trabajador=1;trabajador<=N;trabajador++) if(!yaEscogida(x,trabajador,i) && b[trabajador][i]<min) min=b[trabajador][i]; mejorTrabajador=trabajador; return mejorTrabajador; Por último, la función YaEscogido decide si un trabajador ya ha sido asignado previamente:

int yaEscogida(int x[TAMANO][TAMANO],int trabajador, int tarea) int i; for(i=1;i<=tarea-1;i++) if(x[trabajador][i]) return TRUE; return FALSE;

void imprimir(int x[TAMANO][TAMANO]) int i,j; for(i=1;i<=N;i++) for(j=1;j<=N;j++) cout<<x[i][j]<<"\t"; cout<<endl;

int costoTotal(int b[TAMANO][TAMANO],int x[TAMANO][TAMANO]) int costo,trabajador,tarea; costo=0; for(trabajador=1;trabajador<=N;trabajador++) for(tarea=1;tarea<=N;tarea++) costo=costo+b[trabajador][tarea]*x[trabajador][tarea]; return costo;

54

Salida del programa:

Lamentablemente, este algoritmo ávido tampoco funciona para todos los casos como pone de manifiesto la siguiente matriz de valores:

Tarea 1 2 3 1 16 11 17 2 20 15 1 Trabajador 3 18 17 20

Para ella, el algoritmo produce una matriz de asignaciones en donde los “unos” vuelven a estar en las posiciones (1,1), (2,2) y (3,3), con un valor de la asignación de 51 (=16+15+20). Sin embargo la asignación óptima se consigue con los “unos” en posiciones (3,1), (1,2) y (2,3), con un valor de la asignación de 30 (=18+11+1).

Respecto a la pregunta de si una estrategia es mejor que la otra, la respuesta es que no. La razón es que las soluciones son simétricas. Aún más, una es la imagen especular de la otra. Por tanto, si suponemos equiprobables los valores de las matrices, ambos algoritmos van a tener el mismo número de casos favorables y desfavorables.

PROGRAMACIÓN DINÁMICA Existe una serie de problemas cuyas soluciones pueden ser expresadas recursivamente en términos matemáticos, y posiblemente la manera más natural de resolverlos es mediante un algoritmo recursivo. Sin embargo, el tiempo de ejecución de la solución recursiva, normalmente de orden exponencial y por tanto impracticable, puede mejorarse substancialmente mediante la Programación Dinámica.

En el diseño Divide y Vencerás veíamos cómo para resolver un problema lo dividíamos en subproblemas independientes, los cuales se resolvían de manera recursiva para combinar finalmente las soluciones y así resolver el problema original. El inconveniente se presenta cuando los subproblemas obtenidos no son independientes sino que existe solapamiento entre ellos; entonces es cuando una solución recursiva no resulta eficiente por la repetición de cálculos que conlleva. En estos casos es cuando la Programación Dinámica nos puede ofrecer una solución aceptable. La eficiencia de esta técnica consiste en resolver los subproblemas una sola vez, guardando sus soluciones en una tabla para su futura utilización.

La Programación Dinámica no sólo tiene sentido aplicarla por razones de eficiencia, sino porque además presenta un método capaz de resolver de manera eficiente problemas cuya solución ha sido abordada por otras técnicas y ha fracasado.

Donde tiene mayor aplicación la Programación Dinámica es en la resolución de problemas de optimización. En este tipo de problemas se pueden presentar distintas soluciones, cada una con un valor, y lo que se desea es encontrar la solución de valor óptimo (máximo o mínimo).

La solución de problemas mediante esta técnica se basa en el llamado principio de óptimo enunciado por Bellman en 1957 y que dice: “En una secuencia de decisiones óptima toda subsecuencia ha de ser también óptima”.

Hemos de observar que aunque este principio parece evidente no siempre es aplicable y por tanto es necesario verificar que se cumple para el problema en cuestión. Un ejemplo claro para el que no se verifica este principio aparece al tratar de encontrar el camino de coste máximo entre dos vértices de un grafo ponderado.

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 óptimo.

En grandes líneas, el diseño de un algoritmo de Programación Dinámica consta de los siguientes pasos:

55

1. Planteamiento de la solución como una sucesión de decisiones y verificación de que ésta cumple el principio de óptimo.

2. Definición recursiva de la solución.

3. Cálculo del valor de la solución óptima mediante una tabla en donde se almacenan soluciones a problemas parciales para reutilizar los cálculos.

4. Construcción de la solución óptima haciendo uso de la información contenida en la tabla anterior.

CÁLCULO DE LOS NÚMEROS DE FIBONACCI Antes de abordar problemas más complejos veamos un primer ejemplo en el cual va a quedar reflejada toda esta problemática. Se trata del cálculo de los términos de la sucesión de números de Fibonacci. Dicha sucesión podemos expresarla recursivamente en términos matemáticos de la siguiente manera:

Por tanto, la forma más natural de calcular los términos de esa sucesión es mediante un programa recursivo:

int fib(int n) if(n<=1) return 1; else return fib(n-1)+fib(n-2); El inconveniente es que el algoritmo resultante es poco eficiente ya que su tiempo de ejecución es de orden exponencial. Como podemos observar, la falta de eficiencia del algoritmo se debe a que se producen llamadas recursivas repetidas para calcular valores de la sucesión, que habiéndose calculado previamente, no se conserva el resultado y por tanto es necesario volver a calcular cada vez.

Para este problema es posible diseñar un algoritmo que en tiempo lineal lo resuelva mediante la construcción de una tabla que permita ir almacenando los cálculos realizados hasta el momento para poder reutilizarlos:

Fib(0) Fib(1) Fib(2) ... Fib(n) El algoritmo iterativo que calcula la sucesión de Fibonacci utilizando tal tabla es:

#include <cstdlib> #include <iostream> using namespace std; int fib(int n); int main(int argc, char *argv[]) int n; cout<<"Termino: "; cin>>n; cout<<"Termino "<<n<<": "<<fib(n)<<endl; system("PAUSE"); return EXIT_SUCCESS;

int fib(int n) int t[n+1],i; if(n<=1) return 1; else t[0]=1; t[1]=1; for(i=2;i<=n;i++) t[i]=t[i-1]+t[i-2];

56

return t[n]; Existe aún otra mejora a este algoritmo, que aparece al fijarnos que únicamente son necesarios los dos últimos valores calculados para determinar cada término, lo que permite eliminar la tabla entera y quedarnos solamente con dos variables para almacenar los dos últimos términos:

int fib(int n) int i,suma,x,y; // x e y son los 2 últimos términos if(n<=1) return 1; else x=1; y=1; for(i=2;i<=n;i++) suma=x+y; y=x; x=suma; return suma; Aunque esta función sea de la misma complejidad temporal que la anterior (lineal), consigue una complejidad espacial menor, pues de ser de orden O(n) pasa a ser O(1) ya que hemos eliminado la tabla.

El uso de estructuras (vectores o tablas) para eliminar la repetición de los cálculos, pieza clave de los algoritmos de Programación Dinámica, hace que no sólo en la complejidad temporal de los algoritmos estudiados, sino también en su complejidad espacial.

En general, los algoritmos obtenidos mediante la aplicación de esta técnica consiguen tener complejidades (espacio y tiempo) bastante razonables, pero debemos evitar que el tratar de obtener una complejidad temporal de orden polinómico conduzca a una complejidad espacial demasiado elevada.

CÁLCULO DE LOS COEFICIENTES BINOMIALES En la resolución de un problema, una vez encontrada la expresión recursiva que define su solución, muchas veces la dificultad estriba en la creación del vector o la tabla que ha de conservar los resultados parciales. Así en este segundo ejemplo, aunque también sencillo, observamos que vamos a necesitar una tabla bidimensional algo más compleja. Se trata del cálculo de los coeficientes binomiales, definidos como:

El algoritmo recursivo que los calcula resulta ser de complejidad exponencial por la repetición de los cálculos que realiza. No obstante, es posible diseñar un algoritmo con un tiempo de ejecución de orden O(nk) basado en la idea del Triángulo de Pascal. Para ello es necesaria la creación de una tabla bidimensional en la que ir almacenando los valores intermedios que se utilizan posteriormente:

57

Iremos construyendo esta tabla por filas de arriba hacia abajo y de izquierda a derecha mediante el siguiente algoritmo de complejidad polinómica:

int coefBinomial(int n,int k); int main(int argc, char *argv[]) int n,k; cout<<"Valor de n: "; cin>>n; cout<<"Valor de k: "; cin>>k; cout<<"Coeficiente binomial : "<<coefBinomial(n,k)<<endl; system("PAUSE"); return EXIT_SUCCESS;

int coefBinomial(int n,int k) int i,j; int c[n+1][n+1]; for(i=0;i<=n;i++) c[i][0]=1; for(i=1;i<=n;i++) c[i][1]=i; for(i=2;i<=k;i++) c[i][i]=1; for(i=3;i<=n;i++) for(j=2;j<=i-1;j++) if(j<=k) c[i][j]=c[i-1][j-1]+c[i-1][j]; return c[n][k]; Usando la recursividad:

int coefBinomial(int n,int k) if(k==0) return 1; else if(k==n) return 1; else return coefBinomial(n-1,k-1)+coefBinomial(n-1,k);

EL CAMPEONATO MUNDIAL Considere una competición en la cual hay dos equipos A y B que juegan un máximo de 2n —1 partidas, y en donde el ganador es el primer equipo que consiga n victorias. Suponemos que no hay empates, que los resultados de todos los partidos son independientes, y que para cualquier partido dado hay una probabilidad constante p de que el equipo A sea el ganador, y por tanto una probabilidad constante q=1-p de que gane el equipo B.

Sea P(i,j) la probabilidad de que el equipo A gane el campeonato, cuando todavía necesita i victorias más para conseguirlo, mientras que el equipo B necesita j victorias más para ganar. Por ejemplo, antes del primer partido del campeonato la probabilidad de que gane el equipo A es P(n,n): ambos equipos necesitan todavía n victorias para

58

ganar el campeonato. Si el equipo A necesita cero victorias más, entonces lo cierto es que ya ha ganado el campeonato, y por tanto P(0,i)=1, con 1<=i<=n. De manera similar, si el equipo B necesita 0 victorias mis, entonces ya ha ganado el campeonato, y por tanto P(i,0)=0, con 1<=i<=n. Como no puede producirse una situación en la que ambos equipos hayan ganado todos los partidos que necesitaban, P(0, 0) carece de significado. Por último dado que el equipo A gana cualquier partido con una probabilidad p y pierde con una probabilidad q:

P(i, j) = pP(i-1,j)+qP(i,j-1), i >=1, j>=1

Entonces podemos calcular P(i,j) en la forma siguiente:

función P(i,j) si i=0 entonces devolver 1 sino si j=0 entonces devolver 0 sino devolver pP(i-1,j)+qP(i,j-1)

Figura 8 . Llamadas recursivas efectuadas por una llamada a P(i,j).

Implementación en C++:

#include <cstdlib> #include <iostream> using namespace std; float prob(int i,int j,float p); int main(int argc, char *argv[]) int i,j; float p; cout<<"probabilidad (p) de que gane: "; cin>>p; cout<<"Partidos que le faltan ganar a A : "; cin>>i; cout<<"Partidos que le faltan ganar a B: "; cin>>j; cout<<"P("<<i<<","<<j<<")="<<prob(i,j,p)<<endl; system("PAUSE"); return EXIT_SUCCESS;

float prob(int i,int j,float p) if(i==0) return 1; else if(j==0) return 0; else return p*prob(i-1,j,p)+(1-p)*prob(i,j-1,p);

59

Para acelerar el algoritmo, procederemos más o menos igual que con el triángulo de Pascal: declaramos un vector del tamaño adecuado y después vamos rellenando las entradas. Esta vez, sin embargo, en lugar de ir llenando el vector línea por línea, vamos a trabajar diagonal por diagonal. Éste es el algoritmo para calcular P(n,n). El algoritmo implementado en C++ es:

#include <cstdlib> #include <iostream> using namespace std; float serie(int n,float p); int main(int argc, char *argv[]) float p; int n; cout<<"Valor de n: "; cin>>n; cout<<"Probabilidad (p) de que gane: "; cin>>p; cout<<"P(n,n)="<<serie(n,p)<<endl; system("PAUSE"); return EXIT_SUCCESS;

float serie(int n,float p) float P[n+1][n+1]; float q; int s,k; q=1-p; // Llenamos desde la esquina izquierda hasta la diagonal principal for(s=1;s<=n;s++) P[0][s]=1; P[s][0]=0; for(k=1;k<=s-1;k++) P[k][s-k]=p*P[k-1][s-k]+q*P[k][s-k-1]; // Llenamos desde debajo de la diagonal principal hasta la esuina derecha for(s=1;s<=n;s++) for(k=0;k<=n-s;k++) P[s+k][n-k]=p*P[s+k-1][n-k]+q*P[s+k][n-k-1]; return P[n][n]; Salidas del programa:

60

Dado que el algoritmo tiene que llenar una matriz n x n, y dado que se necesita una constante temporal para calcular cada entrada, su tiempo de ejecución se encuentra en O(n2). Al igual que en el triángulo de Pascal, resulta sencillo implementar este algoritmo de tal manera que baste con un espacio de almacenamiento en O(n).

DEVOLVER CAMBIO Recuerde que el problema consiste en desarrollar un algoritmo para pagar una cierta cantidad a un cliente, empleando el menor número posible de monedas. Describíamos un algoritmo voraz para este problema. Desafortunadamente, aunque el algoritmo voraz es muy eficiente, funciona solamente en un número limitado de casos. Con ciertos sistemas monetarios, o cuando faltan monedas de una cierta denominación (o su número es limitado), el algoritmo puede encontrar una respuesta que no sea óptima, o incluso puede no hallar respuesta ninguna.

Por ejemplo, supongamos que vivimos en un lugar en el cual hay monedas de 1, 4 y 6 unidades. Si tenemos que cambiar 8 unidades, el algoritmo voraz propondrá hacerlo con una moneda de 6 unidades y dos de una unidad, con un total de tres monedas. Sin embargo, está claro que podemos hacerlo mejor: basta con dar al cliente su cambio empleando tan sólo dos monedas de cuatro unidades. Aunque el algoritmo voraz no halla esta solución, resulta sencillo obtenerla empleando programación dinámica.

Como en la sección anterior, el quid del método consiste en preparar una tabla que contenga resultados intermedios útiles, que serán combinados en la solución del caso que estamos considerando. Supongamos que el sistema monetario que estamos considerando tiene monedas de n denominaciones diferentes, y que una moneda de denominación i, con 1<=i<=n tiene un valor de di unidades. Supondremos, como es habitual, que todos los d i > 0. Por el momento, supondremos también que se dispone de un suministro ilimitado de monedas de cada denominación. Por último, supongamos que tenemos que dar al cliente monedas por valor de N unidades, empleando el menor número posible de monedas.

Para resolver este problema mediante programación dinámica, preparamos una tabla c[1..n, 0..N] con una fila para cada denominación posible y una columna para las cantidades que van desde 0 unidades hasta N unidades. En esta tabla, c[i,j] será el número mínimo de monedas necesarias para pagar una cantidad de j unidades, con 0<=j<=N, empleando solamente monedas de las denominaciones desde 1 hasta i, con 1<=i<=n. La solución del ejemplar, por tanto, está dada por c[n,N] si lo único que necesitamos saber es el número de monedas que se necesitan. Para rellenar la tabla, obsérvese primero que c[i,0] es cero para todos los valores de i. Después de esta inicialización, la tabla se puede rellenar o bien fila por fila de izquierda a derecha, o bien columna por columna avanzando hacia abajo. Para abonar una cantidad j utilizando monedas de las denominaciones entre 1 e i, tenemos dos opciones en general. En primer lugar, podemos decidir que no utilizaremos monedas de la denominación i, aun cuando esto está permitido ahora, en cuyo caso c[i,j] = c[i–1,j]. Como alternativa, podemos decidir que emplearemos al menos una moneda de la denominación i. En este caso, una vez que hayamos entregado la primera moneda de esta denominación, quedan por pagar j – di unidades. Para pagar esto se necesitan c[i,j–d i] unidades, así que c[i,j]=1+c[i,j-di]. Dado que deseamos minimizar el número de monedas utilizadas, seleccionaremos aquella alternativa que sea mejor. En general, por tanto:

c[i,j] = mín(c[i–1,j],1+c[i,j-di])

Cuando i = 1, uno de los elementos que hay que comparar cae fuera de la tabla. Lo mismo sucede cuando j<di. Resulta cómodo pensar que tales elementos poseen el valor +∞. Si i = 1 y j<d1, entonces los dos elementos que hay que comparar caen fuera de la tabla. En este caso, hacemos c[i,j] igual a +∞ para indicar que es imposible pagar una cantidad j empleando solamente monedas del tipo 1.

La figura 9 ilustra el caso en el que teníamos que pagar 8 unidades con monedas que valían 1, 4 y 6 unidades. Por ejemplo, c[3,8] se obtiene en este caso como el menor de c[2,8]=2 y 1+c[3,8-d3] =1+c[3,2]=3. Las entradas en el resto de la tabla se obtienen de forma similar. La respuesta para este caso concreto es que podemos pagar ocho unidades empleando únicamente dos monedas. De hecho, la tabla nos da la solución de nuestro problema para todos los casos que supongan un pago de 8 unidades o menos.

Cantidad: 0 1 2 3 4 5 6 7 8 d1=1 0 1 2 3 4 5 6 7 8 d2=4 0 1 2 3 1 2 3 4 2 d3=6 0 1 2 3 1 2 1 2 2

Figura 9 . Devolver cambio empleando programación dinámica.

Véase a continuación la implementación del algoritmo usando la programación dinámica:

#include <cstdlib> #include <iostream> using namespace std;

61

#define n 4 int monedas(int N); int min(int a,int b); int main(int argc,char *argv[]) int monto; cout<<"Monto a cambiar: "; cin>>monto; cout<<"Numero de monedas a entregar: "<<monedas(monto)<<endl; system("PAUSE"); return EXIT_SUCCESS;

int monedas(int N) // Devuelve el mínimo número de monedas necesarias // para cambiar N unidades. // El vector d[1..n] especifica las denominaciones; // en el ejemplo hay monedas de 1, 4 y 6 unidades int d[n]=0,1,4,6; int c[n][N+1]; int i,j; for(i=1;i<=n;i++) c[i][0]=0; for(i=1;i<=n;i++) for(j=1;j<=N;j++) if(i==1 && j<d[i]) c[i][j]=-1; else if(i==1) c[i][j]=1+c[1][j-d[1]]; else if(j<d[i]) c[i][j]=c[i-1][j]; else c[i][j]=min(c[i-1][j],1+c[i][j-d[i]]); return c[n][N];

int min(int a,int b) if(a<b) return a; else return b; Implementación recursiva del problema cambio a monedas:

#include <cstdlib> #include <iostream> using namespace std; int monedas(int i, int j); int min(int a,int b); int main(int argc,char *argv[]) int monto; cout<<"Monto a cambiar: "; cin>>monto;

62

cout<<"Numero de monedas a entregar: "<<monedas(3,monto)<<endl; system("PAUSE"); return EXIT_SUCCESS;

int monedas(int i, int j) int d[]=0,1,4,6; if(j==0) return 0; else if(i==1 && j<d[i]) return -1; else if(i==1) return 1+monedas(1,j-d[1]); else if(j<d[i]) return monedas(i-1,j); else return min(monedas(i-1,j),1+monedas(i,j-d[i]));

int min(int a,int b) if(a<b) return a; else return b;

Si está disponible un suministro inagotable de monedas con un valor de una unidad, entonces siempre podemos hallar una solución para nuestro problema. De no ser así, puede haber valores de N para los cuales no exista una solución. Esto sucede, por ejemplo, si todas las monedas representan un número par de unidades, y se nos pide que paguemos un número impar de unidades. En tales casos, el algoritmo devuelve el resultado artificial +∞. El problema invita a modificar el algoritmo para abordar una situación en que esté limitado el suministro de monedas de una cierta denominación.

Aun cuando el algoritmo sólo parece decir cuántas monedas se necesitan para dar el cambio correspondiente a una determinada cantidad, es fácil descubrir las monedas que se necesitan una vez que se ha construido la tabla c. Supongamos que es preciso abonar una cantidad j empleando monedas de las denominaciones 1, 2, ..., i. Entonces el valor de c[i,j] dice cuántas monedas se van a necesitar. Si c[i,j] = c[i-1, j] entonces no se necesitan monedas de la denominación i, y pasamos a c[i -1, j], para ver lo que hay que hacer a continuación; si c[i,j]=1+c[i,j-di] entonces entregamos una moneda de denominación i, que vale di y avanzamos hacia la izquierda hasta c[i,j-di] para ver lo que hay que hacer a continuación. Si c[i-1,j] y 1+c[i,j-di] son ambos iguales a c[i,j] entonces podemos seleccionar cualquiera de las dos posibilidades. Siguiendo de esta manera, volveremos eventualmente a c[0,0] y ya no quedará nada por abonar. Esta fase del algoritmo es esencialmente un algoritmo voraz que basa sus decisiones en la información de la tabla, y no tiene que retroceder nunca.

El análisis del algoritmo es directo. Para ver cuántas monedas se necesitan para dar un cambio de N unidades cuando están disponibles monedas de n denominaciones diferentes, el algoritmo tiene que rellenar una matriz nx(N+1), así que el tiempo de ejecución está en O(nN). Para ver las monedas que habría que utilizar, la búsqueda que retrocede desde c[n,N] hasta c[0, 0] da n-1 pasos hasta la fila de encima (que corresponde a no utilizar una moneda de la denominación actual) y c[n,N] pasos hacia la izquierda (que corresponde a entregar una moneda). Dado que cada uno de estos pasos se puede dar en un tiempo constante, el tiempo total que se requiere está en O(n+c[n,N]).

INTERESES BANCARIO Dadas n funciones f1, f2, ..., fn y un entero positivo M, deseamos maximizar la función f1(x1) + f2(x2) + ... + fn(xn) sujeta a la restricción x1 +x2 + ... + xn = M, donde fi(0)=0 (i=1,..,n), xi son números naturales, y todas las funciones son monótonas crecientes, es decir, x ≥ y implica que fi(x) > fi(y). Supóngase que los valores de cada función se almacenan en un vector.

63

Este problema tiene una aplicación real muy interesante, en donde fi representa la función de interés que proporciona el banco i, y lo que deseamos es maximizar el interés total al invertir una cantidad determinada de dinero M. Los valores xi van a representar la cantidad a invertir en cada uno de los n bancos.

SOLUCIÓN Sea fi un vector que almacena el interés del banco i (1 ≤ i ≤ n) para una inversión de 1, 2, 3, ..., M pesetas. Esto es, fi(j) indicará el interés que ofrece el banco i para j pesetas, con 0 < i ≤ n , 0 < j ≤ M.

Para poder plantear el problema como una sucesión de decisiones, llamaremos In(M) al interés máximo al invertir M pesetas en n bancos,

In(M) = f1(x1) + f2(x2) + ... + fn(xn)

que es la función a maximizar, sujeta a la restricción x1 +x2 + ... + xn = M.

Veamos cómo aplicar el principio de óptimo. Si In(M) es el resultado de una secuencia de decisiones y resulta ser óptima para el problema de invertir una cantidad M en n bancos, cualquiera de sus subsecuencias de decisiones ha de ser también óptima y así la cantidad

In–1(M – xn) = f1(x1) + f2(x2) + ... + fn–1(xn–1)

será también óptima para el subproblema de invertir (M – xn) pesetas en n – 1 bancos. Y por tanto el principio de óptimo nos lleva a plantear la siguiente relación en recurrencia:

Para resolverla y calcular In(M), vamos a utilizar una matriz I de dimensión nxM en donde iremos almacenando los resultados parciales y así eliminar la repetición de los cálculos. El valor de I[i,j] va a representar el interés de j pesetas cuando se dispone de i bancos, por tanto la solución buscada se encontrará en I[n,M]. Para guardar los datos iniciales del problema vamos a utilizar otra matriz F, de la misma dimensión, y donde F[i,j] representa el interés del banco i para j pesetas.

En consecuencia, para calcular el valor pedido de I[n,M] rellenaremos la tabla por filas, empezando por los valores iniciales de la ecuación en recurrencia, y según el siguiente algoritmo:

int max(int F[FILAS][COLUMNAS],int I[FILAS][COLUMNAS],int i,int j); int max2(int a,int b); void imprimir(int a[FILAS][COLUMNAS]); int main(int argc,char *argv[]) int f[FILAS][COLUMNAS]= 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 3, 4, 5, 8,10,11,12,12,12, 0, 4, 4, 4, 5, 5, 5, 5, 6, 6, 7, 0, 2, 3, 4, 5, 6, 7, 8, 9,10,11; int i[FILAS][COLUMNAS]; cout<<"Intereses: "<<intereses(f,i)<<endl; cout<<"\nTabla de intereses"<<endl; imprimir(f); cout<<"\nTabla de resultados"<<endl; imprimir(i); system("PAUSE"); return EXIT_SUCCESS;

int intereses(int F[FILAS][COLUMNAS],int I[FILAS][COLUMNAS]) int i,j; for(i=1;i<=n;i++)

64

for(j=0;j<=M;j++) I[i][j]=0; for(i=1;i<=n;i++) I[i][0]=0; for(j=1;j<=M;j++) I[1][j]=F[1][j]; for(i=2;i<=n;i++) for(j=1;j<=M;j++) I[i][j]=max(F,I,i,j); return I[n][M];

int max(int F[FILAS][COLUMNAS],int I[FILAS][COLUMNAS],int i,int j) int maximo,t; maximo=I[i-1][j]+F[i][0]; for(t=1;t<=j;t++) maximo=max2(maximo,I[i-1][j-t]+F[i][t]); return maximo;

int max2(int a,int b) if(a>=b) return a; else return b;

void imprimir(int a[FILAS][COLUMNAS]) int i,j; cout.setf(ios::fixed); for(i=1;i<=n;i++) for(j=0;j<=M;j++) cout.width(5); cout.precision(3); cout<<a[i][j]; cout<<endl;

La función Max es la que calcula el máximo que aparece en la expresión recursiva. La función Max2 es la que calcula el máximo de dos números naturales.

La complejidad del algoritmo completo es de orden O(nM2). En este ejemplo queda de manifiesto la efectividad del uso de estructuras en los algoritmos de Programación Dinámica para conseguir obtener tiempos de ejecución de orden polinómico, frente a los tiempos exponenciales de los algoritmos recursivos iniciales.

EL VIAJE MÁS BARATO POR RÍO Sobre el río Guadalhorce hay n embarcaderos. En cada uno de ellos se puede alquilar un bote que permite ir a cualquier otro embarcadero río abajo (es imposible ir río arriba). Existe una tabla de tarifas que indica el coste del viaje del embarcadero i al j para cualquier embarcadero de partida i y cualquier embarcadero de llegada j más abajo en el río (i < j). Puede suceder que un viaje de i a j sea más caro que una sucesión de viajes más cortos, en cuyo caso se tomaría un primer bote hasta un embarcadero k y un segundo bote para continuar a partir de k. No hay coste adicional por cambiar de bote.

65

Nuestro problema consiste en diseñar un algoritmo eficiente que determine el coste mínimo para cada par de puntos i,j (i < j) y determinar, en función de n, el tiempo empleado por el algoritmo.

SOLUCIÓN Llamaremos T[i,j] a la tarifa para ir del embarcadero i al j (directo). Estos valores se almacenarán en una matriz triangular superior de orden n, siendo n el número de embarcaderos.

El problema puede resolverse mediante Programación Dinámica ya que para calcular el coste óptimo para ir del embarcadero i al j podemos hacerlo de forma recurrente, suponiendo que la primera parada la realizamos en un embarcadero intermedio k (i < k ≤ j):

C(i,j) = T(i,k) + C(k,j).

En esta ecuación se contempla el viaje directo, que corresponde al caso en el que k coincide con j. Esta ecuación verifica también que la solución buscada C(i,j) satisface el principio del óptimo, pues el coste C(k,j), que forma parte de la solución, ha de ser, a su vez, óptimo. Podemos plantear entonces la siguiente expresión de la solución:

La idea de esta segunda expresión surge al observar que en cualquiera de los trayectos siempre existe un primer salto inicial óptimo.

Para resolverla según la técnica de Programación Dinámica, hace falta utilizar una estructura para almacenar resultados intermedios y evitar la repetición de los cálculos. La estructura que usaremos es una matriz triangular de costes C[i,j], que iremos rellenando por diagonales mediante el procedimiento que hemos denominado Costes. La solución al problema es la propia tabla, y sus valores C[i,j] indican el coste óptimo para ir del embarcadero i al j.

#include <cstdlib> #include <iostream> using namespace std; #define MAXEMBARCADEROS 10 #define FILAS MAXEMBARCADEROS+1 #define COLUMNAS MAXEMBARCADEROS+1 void costes(int C[FILAS][COLUMNAS],int n); int min(int C[FILAS][COLUMNAS],int i,int j); int min2(int a,int b); void imprimir(int a[FILAS][COLUMNAS]); int T[FILAS][COLUMNAS]= 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3,14,15, 6, 7, 8, 9,10, 0, 0, 4, 4, 5, 5, 5, 5, 6, 6, 7, 0, 0, 0, 4, 5, 5, 5, 5, 6, 6, 7, 0, 0, 0, 0, 5,15, 5, 5, 6, 6, 7, 0, 0, 0, 0, 0, 5, 5, 5, 6, 6,17, 0, 0, 0, 0, 0, 0, 5,12, 6, 6, 7, 0, 0, 0, 0, 0, 0, 0, 5, 6, 6,17, 0, 0, 0, 0, 0, 0, 0, 0, 6, 6,17, 0, 0, 0, 0, 0, 0, 0, 0, 0, 6, 7, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,11; int main(int argc,char *argv[]) int C[FILAS][COLUMNAS]; costes(C,MAXEMBARCADEROS); cout<<"\nTabla de tarifa"<<endl; imprimir(T); cout<<"\nTabla de costos optimos"<<endl;

66

imprimir(C); system("PAUSE"); return EXIT_SUCCESS;

void costes(int C[FILAS][COLUMNAS],int n) int i,j,diagonal; // condiciones iniciales for(i=1;i<=MAXEMBARCADEROS;i++) for(j=1;j<=MAXEMBARCADEROS;j++) C[i][j]=0; for(i=1;i<=n;i++) C[i][i]=0; for(diagonal=1;diagonal<=n-1;diagonal++) for(i=1;i<=n-diagonal;i++) C[i][i+diagonal]=min(C,i,i+diagonal);

int min(int C[FILAS][COLUMNAS],int i,int j) int k,minimo; minimo=65535; for(k=i+1;k<=j;k++) minimo=min2(minimo,T[i][k]+C[k][j]); return minimo;

int min2(int a,int b) if(a<b) return a; else return b;

void imprimir(int a[FILAS][COLUMNAS]) int i,j; cout.setf(ios::fixed); for(i=1;i<=MAXEMBARCADEROS;i++) for(j=1;j<=MAXEMBARCADEROS;j++) cout.width(5); cout.precision(3); cout<<a[i][j]; cout<<endl; Dicho algoritmo utiliza la siguiente función min, que permite calcular la expresión del mínimo que aparece en la ecuación en recurrencia. La función Min2 es la que calcula el mínimo de dos números naturales. Es importante observar que esta función, por la forma en que se va rellenando la matriz C, sólo hace uso de los elementos calculados hasta el momento.

La complejidad del algoritmo es de orden O(n3).

67

VUELTA ATRAS Dentro de las técnicas de diseño de algoritmos, el método de Vuelta Atrás (del inglés Backtracking) es uno de los de más amplia utilización, en el sentido de que puede aplicarse en la resolución de un gran número de problemas, muy especialmente en aquellos de optimización.

Los métodos estudiados en los capítulos anteriores construyen la solución basándose en ciertas propiedades de la misma; así en los algoritmos Ávidos se va construyendo la solución por etapas, siempre avanzando sobre la solución parcial previamente calculada; o bien podremos utilizar la Programación Dinámica para dar una expresión recursiva de la solución si se verifica el principio de óptimo, y luego calcularla eficientemente. Sin embargo ciertos problemas no son susceptibles de solucionarse con ninguna de estas técnicas, de manera que la única forma de resolverlos es a través de un estudio exhaustivo de un conjunto conocido a priori de posibles soluciones, en las que tratamos de encontrar una o todas las soluciones y por tanto también la óptima.

Para llevar a cabo este estudio exhaustivo, el diseño Vuelta Atrás proporciona una manera sistemática de generar todas las posibles soluciones siempre que dichas soluciones sean susceptibles de resolverse en etapas.

En su forma básica la Vuelta Atrás se asemeja a un recorrido en profundidad dentro de un árbol cuya existencia sólo es implícita, y que denominaremos árbol de expansión. Este árbol es conceptual y sólo haremos uso de su organización como tal, en donde cada nodo de nivel k representa una parte de la solución y está formado por k etapas que se suponen ya realizadas.

Sus hijos son las prolongaciones posibles al añadir una nueva etapa. Para examinar el conjunto de posibles soluciones es suficiente recorrer este árbol construyendo soluciones parciales a medida que se avanza en el recorrido.

En este recorrido pueden suceder dos cosas. La primera es que tenga éxito si, procediendo de esta manera, se llega a una solución (una hoja del árbol). Si lo único que buscábamos era una solución al problema, el algoritmo finaliza aquí; ahora bien, si lo que buscábamos eran todas las soluciones o la mejor de entre todas ellas, el algoritmo seguirá explorando el árbol en búsqueda de soluciones alternativas.

Por otra parte, el recorrido no tiene éxito si en alguna etapa la solución parcial construida hasta el momento no se puede completar; nos encontramos en lo que llamamos nodos fracaso. En tal caso, el algoritmo vuelve atrás (y de ahí su nombre) en su recorrido eliminando los elementos que se hubieran añadido en cada etapa a partir de ese nodo. En este retroceso, si existe uno o más caminos aún no explorados que puedan conducir a solución, el recorrido del árbol continúa por ellos. La filosofía de estos algoritmos no sigue unas reglas fijas en la búsqueda de las soluciones.

Podríamos hablar de un proceso de prueba y error en el cual se va trabajando por etapas construyendo gradualmente una solución. Para muchos problemas esta prueba en cada etapa crece de una manera exponencial, lo cual es necesario evitar.

Gran parte de la eficiencia (siempre relativa) de un algoritmo de Vuelta Atrás proviene de considerar el menor conjunto de nodos que puedan llegar a ser soluciones, aunque siempre asegurándonos de que el árbol “podado” siga conteniendo todas las soluciones. Por otra parte debemos tener cuidado a la hora de decidir el tipo de condiciones (restricciones) que comprobamos en cada nodo a fin de detectar nodos fracaso. Evidentemente el análisis de estas restricciones permite ahorrar tiempo, al delimitar el tamaño del árbol a explorar. Sin embargo esta evaluación requiere a su vez tiempo extra, de manera que aquellas restricciones que vayan a detectar pocos nodos fracaso no serán normalmente interesantes. No obstante, y como norma de actuación general, podríamos decir que las restricciones sencillas son siempre apropiadas, mientras que las más sofisticadas que requieren más tiempo en su cálculo deberían reservarse para situaciones en las que el árbol que se genera sea muy grande.

Vamos a ver como se lleva a cabo la búsqueda de soluciones trabajando sobre este árbol y su recorrido. En líneas generales, un problema puede resolverse con un algoritmo Vuelta Atrás cuando la solución puede expresarse como una n-tupla [x1, x2, ..., xn] donde cada una de las componentes xi de este vector es elegida en cada etapa de entre un conjunto finito de valores. Cada etapa representará un nivel en el árbol de expansión.

En primer lugar debemos fijar la descomposición en etapas que vamos a realizar y definir, dependiendo del problema, la n-tupla que representa la solución del problema y el significado de sus componentes xi. Una vez que veamos las posibles opciones de cada etapa quedará definida la estructura del árbol a recorrer. Vamos a ver a través de un ejemplo cómo es posible definir la estructura del árbol de expansión.

68

LAS N REINAS Numeramos las reinas del 1 al 8. Cualquier solución a este problema estará representada por una 8-tupla [x1,x2,x3,x4,x5,x6,x7,x8] en la que cada xi representa la columna donde la reina de la fila i-ésima es colocada. Una posible solución al problema es la tupla [4,6,8,2,7,1,3,5].

Para decidir en cada etapa cuáles son los valores que puede tomar cada uno de los elementos xi hemos de tener en cuenta lo que hemos denominado restricciones a fin de que el número de opciones en cada etapa sea el menor posible. En los algoritmos Vuelta Atrás podemos diferenciar dos tipos de restricciones:

Restricciones explícitas. Formadas por reglas que restringen los valores que pueden tomar los elementos xi a un conjunto determinado. En nuestro problema este conjunto es S = 1,2,3,4,5,6,7,8.

Restricciones implícitas. Indican la relación existente entre los posibles valores de los xi para que éstos puedan formar parte de una n-tupla solución. En el problema que nos ocupa podemos definir dos restricciones implícitas. En primer lugar sabemos que dos reinas no pueden situarse en la misma columna y por tanto no puede haber dos xi iguales (obsérvese además que la propia definición de la tupla impide situar a dos reinas en la misma fila, con lo cual tenemos cubiertos los dos casos, el de las filas y el de las columnas). Por otro lado sabemos que dos reinas no pueden estar en la misma diagonal, lo cual reduce el número de opciones. Esta condición se refleja en la segunda restricción implicita que, en forma de ecuación, puede ser expresada como |x – x’| ≠ |y –y’|, siendo (x,y) y (x’,y’) las coordenadas de dos reinas en el tablero.

De esta manera, y aplicando las restricciones, en cada etapa k iremos generando sólo las k-tuplas con posibilidad de solución. A los prefijos de longitud k de la ntupla solución que vamos construyendo y que verifiquen las restricciones expuestas los denominaremos k-prometedores, pues a priori pueden llevarnos a la solución buscada. Obsérvese que todo nodo generado es o bien fracaso o bien k-prometedor.

Con estas condiciones queda definida la estructura del árbol de expansión, que representamos a continuación para un tablero 4x4:

Como podemos observar se construyen 15 nodos hasta dar con una solución al problema. El orden de generación de los nodos se indica con el subíndice que acompaña a cada tupla.

Conforme vamos construyendo el árbol debemos identificar los nodos que corresponden a posibles soluciones y cuáles por el contrario son sólo prefijos suyos. Ello será necesario para que, una vez alcanzados los nodos que sean posibles soluciones, comprobemos si de hecho lo son.

Por otra parte es posible que al alcanzar un cierto nodo del árbol sepamos que ninguna prolongación del prefijo de posible solución que representa va a ser solución a la postre (debido a las restricciones). En tal caso es absurdo que prosigamos buscando por ese camino, por lo que retrocederemos en el árbol (vuelta atrás) para seguir buscando por otra opción. Tales nodos son los que habíamos denominado nodos fracaso.

También es posible que aunque un nodo no se haya detectado a priori como fracaso (es decir, que sea k-prometedor) más adelante se vea que todos sus descendientes son nodos fracaso; en tal caso el proceso es el mismo que si lo

69

hubiésemos detectado directamente. Tal es el caso para los nodos 2 y 3 de nuestro árbol. Efectivamente el nodo 2 es nodo fracaso porque al comprobar una de las restricciones (están en la misma diagonal) no se cumple. El nodo 3 sin embargo es nodo fracaso debido a que sus descendientes, los nodos 4 y 5, lo son.

Por otra parte hemos de identificar aquellos nodos que pudieran ser solución porque por ellos no se puede continuar (hemos completado la n-tupla), y aquellos que corresponden a soluciones parciales. No por conseguir construir un nodo hoja de nivel n quiere decir que hayamos encontrado una solución, puesto que para los nodos hojas también es preciso comprobar las restricciones. En nuestro árbol que representa el problema de las 4 reinas vemos cómo el nodo 8 podría ser solución ya que hemos conseguido colocar las 4 reinas en el tablero, pero sin embargo la tupla [1,4,2,3] encontrada no cumple el objetivo del problema, pues existen dos reinas x3 = 2 y x4 = 3 situadas en la misma diagonal. Un nodo con posibilidad de solución en el que detectamos que de hecho no lo es se comporta como nodo fracaso.

En resumen, podemos decir que Vuelta Atrás es un método exhaustivo de tanteo (prueba y error) que se caracteriza por un avance progresivo en la búsqueda de una solución mediante una serie de etapas. En dichas etapas se presentan unas opciones cuya validez ha de examinarse con objeto de seleccionar una de ellas para proseguir con el siguiente paso. Este comportamiento supone la generación de un árbol y su examen y eventual poda hasta llegar a una solución o a determinar su imposibilidad. Este avance se puede detener cuando se alcanza una solución, o bien si se llega a una situación en que ninguna de las soluciones es válida; en este caso se vuelve al paso anterior, lo que supone que deben recordarse las elecciones hechas en cada paso para poder probar otra opción aún no examinada. Este retroceso (vuelta atrás) puede continuar si no quedan opciones que examinar hasta llegar a la primera etapa. El agotamiento de todas las opciones de la primera etapa supondrá que no hay solución posible pues se habrán examinado todas las posibilidades.

El hecho de que la solución sea encontrada a través de ir añadiendo elementos a la solución parcial, y que el diseño Vuelta Atrás consista básicamente en recorrer un árbol hace que el uso de recursión sea muy apropiado. Los árboles son estructuras intrínsecamente recursivas, cuyo manejo requiere casi siempre de recursión, en especial en lo que se refiere a sus recorridos. Por tanto la implementacion más sencilla se logra sin lugar a dudas con procedimientos recursivos.

De esta forma llegamos al esquema general que poseen los algoritmos que siguen la técnica de Vuelta Atrás:

PROCEDURE VueltaAtras(etapa); BEGIN IniciarOpciones; REPEAT SeleccionarNuevaOpcion; IF Aceptable THEN AnotarOpcion; IF SolucionIncompleta THEN VueltaAtras(etapa_siguiente); IF NOT exito THEN CancelarAnotacion END ELSE (* solucion completa *) exito:=TRUE END END UNTIL (exito) OR (UltimaOpcion) END VueltaAtras; En este esquema podemos observar que están presentes tres elementos principales. En primer lugar hay una generación de descendientes, en donde para cada nodo generamos sus descendientes con posibilidad de solución. A este paso se le denomina expansión, ramificación o bifurcación. A continuación, y para cada uno de estos descendientes, hemos de aplicar lo que denominamos prueba de fracaso (segundo elemento). Finalmente, caso de que sea aceptable este nodo, aplicaremos la prueba de solución (tercer elemento) que comprueba si el nodo que es posible solución efectivamente lo es.

Tal vez lo más difícil de ver en este esquema es donde se realiza la vuelta atrás, y para ello hemos de pensar en la propia recursión y su mecanismo de funcionamiento, que es la que permite ir recorriendo el árbol en profundidad.

Para el ejemplo que nos ocupa, el de las n reinas, el algoritmo que lo soluciona quedaría como sigue:

#include <cstdlib> #include <iostream>

70

using namespace std; #define N 8 int X[N+1]=0,0,0,0,0; bool exito=false; void reinas(int k); bool valido(int k); int valAbs(int x,int y); int main(int argc,char *argv[]) int i; reinas(1); if(exito) cout<<"Solucion: "<<endl; cout<<"["; for(i=1;i<=N;i++) cout<<X[i]; if(i!=N) cout<<","; cout<<"]"<<endl; system("PAUSE"); return EXIT_SUCCESS; void reinas(int k) if(k>N) return; X[k]=0; do X[k]=X[k]+1; // seleccion de nueva opcion if(valido(k))// prueba de fracaso if(k!=N) reinas(k+1); // llamada recursiva else exito=true; while(X[k]!=N && !exito); bool valido(int k) // comprueba si el vector solucion X construido hasta el paso k es // k-prometedor, es decir, si la reina puede situarse en la columna k) int i; for(i=1;i<=k-1;i++) if(X[i]==X[k] || valAbs(X[i],X[k])==valAbs(i,k)) return false; return true; int valAbs(int x,int y) if(x>y) return x-y; else return y-x; La función Valido es la que comprueba las restricciones implícitas, realizando la prueba de fracaso y la función ValAbs(x,y), que es la que devuelve |x – y|.

71

Cuando se desea encontrar todas las soluciones habrá que alterar ligeramente el esquema dado, de forma que una vez conseguida una solución se continúe buscando hasta agotar todas las posibilidades. Queda por tanto el siguiente esquema general para este caso:

PROCEDURE VueltaAtrasTodasSoluciones(etapa); BEGIN IniciarOpciones; REPEAT SeleccionarNuevaOpcion; IF Aceptable THEN AnotarOpcion; IF SolucionIncompleta THEN VueltaAtrasTodasSoluciones(etapa_siguiente); ELSE ComunicarSolucion END; CancelarAnotacion END UNTIL (UltimaOpcion); END VueltaAtrasTodasSoluciones; que en nuestro ejemplo de las reinas queda reflejado la siguiente implementación:

#include <cstdlib> #include <iostream> using namespace std; #define N 8 int X[N+1]=0,0,0,0,0; void reinas(int k); bool valido(int k); int valAbs(int x,int y); void comunicarSolucion(int X[N+1]); int main(int argc,char *argv[]) int i; reinas(1); system("PAUSE"); return EXIT_SUCCESS; void reinas(int k) // Encuentra todas las maneras de disponer n reinas if(k>N) return; X[k]=0; do X[k]=X[k]+1; // seleccion de nueva opcion if(valido(k))// prueba de fracaso if(k!=N) reinas(k+1); // llamada recursiva else comunicarSolucion(X); while(X[k]!=N); bool valido(int k) // comprueba si el vector solucion X construido hasta el paso k es // k-prometedor, es decir, si la reina puede situarse en la columna k) int i;

72

for(i=1;i<=k-1;i++) if(X[i]==X[k] || valAbs(X[i],X[k])==valAbs(i,k)) return false; return true; int valAbs(int x,int y) if(x>y) return x-y; else return y-x; void comunicarSolucion(int X[N+1]) int i; cout<<"Solucion: "<<endl; cout<<"["; for(i=1;i<=N;i++) cout<<X[i]; if(i!=N) cout<<","; cout<<"]"<<endl; Aunque la solución más utilizada es la recursión, ya que cada paso es una repetición del anterior en condiciones distintas (más simples), la resolución de este método puede hacerse también utilizando la organización del árbol que determina el espacio de soluciones. Así, podemos desarrollar también un esquema general que represente el comportamiento del algoritmo de Vuelta Atrás en su versión iterativa:

PROCEDURE VueltaAtrasIterativo; BEGIN k:=1; WHILE k>1 DO IF solucion THEN ComunicarSolucion ELSIF Fracaso(solucion) OR (k<n) THEN DEC(k); CalcularSucesor(k) ELSE INC(k); CalcularSucesor(k) END END END VueltaAtrasIterativo; En este esquema también vemos presentes los tres elementos anteriores: prueba de solución, prueba de fracaso y generación de descendientes.

Para cada nodo se realiza la prueba de solución en cuyo caso se terminará el proceso y la prueba de fracaso que en caso positivo da lugar a la vuelta atrás.

Observamos también que si la búsqueda de descendientes no consigue ningún hijo, el nodo se convierte en nodo fracaso y se trata como en el caso anterior; en caso contrario la etapa se incrementa en uno y se continúa.

Por otra parte la vuelta atrás busca siempre un hermano del nodo que estemos analizando descendiente de su mismo padre - para pasar a su análisis; si no existe tal hermano se decrementa la etapa k en curso y si k sigue siendo mayor que cero (aun no hemos recorrido el árbol) se repite el proceso anterior.

El algoritmo iterativo para el problema de las n reinas puede implementarse por tanto utilizando este esquema, lo que da lugar a la siguiente implementación:

#include <cstdlib> #include <iostream> using namespace std;

73

#define N 8 int X[N+1]; void reinas(); bool valido(int k); int valAbs(int x,int y); void comunicarSolucion(int X[N+1]); int main(int argc,char *argv[]) int i; reinas(); system("PAUSE"); return EXIT_SUCCESS; void reinas() int k; X[1]=0; k=1; while(k>0) X[k]=X[k] + 1; // selecciona nueva opcion while(X[k]<=N && !valido(k)) // fracaso? X[k]=X[k]+1; if(X[k]<=N) if(k==N) comunicarSolucion(X); else k++; X[k]=0; else k--; // vuelta atras bool valido(int k) // comprueba si el vector solucion X construido hasta el paso k es // k-prometedor, es decir, si la reina puede situarse en la columna k) int i; for(i=1;i<=k-1;i++) if(X[i]==X[k] || valAbs(X[i],X[k])==valAbs(i,k)) return false; return true; int valAbs(int x,int y) if(x>y) return x-y; else return y-x; void comunicarSolucion(int X[N+1]) int i; cout<<"Solucion: "<<endl; cout<<"["; for(i=1;i<=N;i++) cout<<X[i]; if(i!=N)

74

cout<<","; cout<<"]"<<endl; Hemos visto en este apartado cómo generar el árbol de expansión, pero sin prestar demasiada atención al orden en que lo hacemos. Usualmente los algoritmos Vuelta Atrás son de complejidad exponencial por la forma en la que se busca la solución mediante el recorrido en profundidad del árbol. De esta forma estos algoritmos van a ser de un orden de complejidad al menos del número de nodos del árbol que se generen y este número, si no se utilizan restricciones, es de orden de zn donde z son las posibles opciones que existen en cada etapa, y n el número de etapas que es necesario recorrer hasta construir la solución (esto es, la profundidad del árbol o la longitud de la n-tupla solución).

El uso de restricciones, tanto implícitas como explícitas, trata de reducir este número tanto como sea posible (en el ejemplo de las reinas se pasa de 88 nodos si no se usa ninguna restricción a poco más de 2000), pero sin embargo en muchos casos no son suficientes para conseguir algoritmos “tratables”, es decir, que sus tiempos de ejecución sean de orden de complejidad razonable.

Para aquellos problemas en donde se busca una solución y no todas, es donde entra en juego la posibilidad de considerar distintas formas de ir generando los nodos del árbol. Y como la búsqueda que realiza la Vuelta Atrás es siempre en profundidad, para lograr esto sólo hemos de ir variando el orden en el que se generan los descendientes de un nodo, de manera que trate de ser lo más apropiado a nuestra estrategia.

Como ejemplo, pensemos en el problema del laberinto que veremos más adelante. En cada etapa vamos a ir generando los posibles movimientos desde la casilla en la que nos encontramos. Pero en vez de hacerlo de cualquier forma, sería interesante explorar primero aquellos que nos puedan llevar más cerca de la casilla de salida, es decir, tratar de ir siempre hacia ella.

Desde un punto de vista intuitivo, lo que intentamos hacer así es llevar lo más hacia arriba posible del árbol de expansión el nodo hoja con la solución (dibujando el árbol con la raiz a la izquierda, igual que lo hemos hecho en el problema de las reinas), para que la búsqueda en profundidad que realizan este tipo de algoritmos la encuentre antes. En algunos ejemplos, como puede ser en el del juego del Continental, que también veremos más adelante, el orden en el que se generan los movimientos hace que el tiempo de ejecución del algoritmo pase de varias horas a sólo unos segundos, lo cual no es despreciable.

RECORRIDOS DEL REY DE AJEDREZ Dado un tablero de ajedrez de tamaño nxn, un rey es colocado en una casilla arbitraria de coordenadas (x,y). El problema consiste en determinar los n2–1 movimientos de la figura de forma que todas las casillas del tablero sean visitadas una sola vez, si tal secuencia de movimientos existe.

SOLUCIÓN La solución al problema puede expresarse como una matriz de dimensión nxn que representa el tablero de ajedrez. Cada elemento (x,y) de esta matriz solución contendrá un número natural k que indica el número de orden en que ha sido visitada la casilla de coordenadas (x,y).

El algoritmo trabaja por etapas decidiendo en cada etapa k hacia donde se mueve. Como existen ocho posibles movimientos en cada etapa, éste será el número máximos de hijos que se generarán por cada nodo.

Respecto a las restricciones explícitas, por la forma en la que hemos definido la estructura que representa la solución (en este caso una matriz bidimensional de números naturales), sabemos que sus componentes pueden ser números comprendidos entre cero (que indica que una casilla no ha sido visitada aún) y n2, que es el orden del último movimiento posible. Inicialmente el tablero se encuentra relleno con ceros y sólo existe un 1 en la casilla inicial (x0,y0).

Las restricciones implícitas en este caso van a limitar el número de hijos que se generan desde una casilla mediante la comprobación de que el movimiento no lleve al rey fuera del tablero o sobre una casilla previamente visitada.

Una vez definida la estructura que representa la solución y las restricciones que usaremos, para implementar el algoritmo que resuelve el problema basta utilizar el esquema general, obteniendo:

#include <cstdlib> #include <iostream> #include <iomanip.h> using namespace std; #define N 4 #define DIMENSION N+1

75

int tablero[DIMENSION][DIMENSION]; int movX[9],movY[9]; void movimientosPosibles(); void rey(int k,int x,int y,bool &exito); int main(int argc,char *argv[]) int x0,y0,i,j; bool exito; x0=1; y0=1; movimientosPosibles(); for(i=1;i<=N;i++) for(j=1;j<=N;j++) tablero[i][j]=0; // x0,y0 es la casilla inicial tablero[x0][y0]=1; rey(2,x0,y0,exito); if(exito) for(i=1;i<=N;i++) for(j=1;j<=N;j++) cout<<setw(3)<<tablero[i][j]; cout<<endl; system("PAUSE"); return EXIT_SUCCESS; void rey(int k,int x,int y,bool &exito) // busca una solucion, si la hay. k indica la etapa, (x,y) las // coordenadas de la casilla en donde se encuentra el rey int orden; // recorre cada uno de los 8 movimientos int u,v; // u,v indican la casilla destino desde x,y orden=0; exito=false; do orden++; u=x+movX[orden]; v=y+movY[orden]; if(1<=u && u<=N && 1<=v && v<=N && tablero[u][v]==0) tablero[u][v]=k; if(k<N*N) rey(k+1,u,v,exito); if(!exito) tablero[u][v]=0; else exito=true; while(!exito && orden!=8); void movimientosPosibles() movX[1]=0; movY[1]=1; movX[2]=-1; movY[2]=1; movX[3]=-1; movY[3]=0; movX[4]=-1; movY[4]=-1; movX[5]=0; movY[5]=-1; movX[6]=1; movY[6]=-1; movX[7]=1; movY[7]=0; movX[8]=1; movY[8]=1;