Programación 2 Curso 2011/2012 Recursividad Los términos recurrencia, recursión o recursividad hacen referencia a una técnica de definición de conceptos (o de diseño de procesos) en la que el concepto definido (o el proceso diseñado) es usado en la propia definición (o diseño). Un ejemplo paradigmático sería el del triángulo de Sierpinski en el que cada triángulo está compuesto de otro más pequeños, compuestos s su vez de la misma estructura recursiva (de hecho en este caso se trata de una estructura fractal) Otro caso de estructura recursiva son las denominadas Matryoshkas (o muñecas rusas): donde cada muñeca esconde en su interior otra muñeca, que esconde en su interior otra muñeca que …, hasta que se llega a una muñeca que ya no escode nada. En nuestro caso nos preocuparemos de los métodos (funciones o acciones) recursivos: aquéllos en los que, dentro de las instrucciones que los forman, contienen una llamada a sí mismos. Como siempre, la parte más compleja no será a nivel de programación, sino a nivel de diseño: dado un problema, ser capaz de encontrar una solución recursiva del mismo. Por tanto, deberemos ser capaces de pensar recursivamente. Algunos de los problemas que veremos ya los sabéis resolver iterativamente y es bueno comparar las soluciones recursivas que veremos con las iterativas que podéis realizar por vuestra cuenta. J.M. Gimeno y J.L. González 1 Programación 2 Curso 2011/2012 1. Llamadas a funciones Antes de empezar con las llamadas recursivas, recordaremos brevemente cómo funcionan las llamadas entre funciones y cómo éstas modifican el flujo de ejecución. Consideremos el siguiente ejemplo, que ya vimos en el tema anterior: 1 /* 2 * File: SquareRoot.java 3 * -­‐-­‐-­‐-­‐-­‐-­‐-­‐-­‐-­‐-­‐-­‐-­‐-­‐-­‐-­‐-­‐-­‐-­‐-­‐-­‐-­‐ 4 * This program calculates the square root of a 5 * given positive integer 6 */ 7 8 import acm.program.ConsoleProgram; 9 10 public class SquareRoot extends ConsoleProgram { 11 12 public int squareRoot(int n) { 13 int lower = 0; 14 while ((lower + 1) * (lower + 1) <= n) { 15 lower = lower + 1; 16 } 17 return lower; 18 } 19 20 public void run() { 21 int n = readInt("Enter a natural number: "); 22 int root = squareRoot(n); 23 println("The root is " + root); 24 } 25 } Lo que vamos a considerar ahora es cómo se ejecutan las líneas, en función de las llamadas entre funciones: • La ejecución comienza la línea 21, que contiene la llamada a la función readInt. Se congela la ejecución del método run y se ejecuta el código de readInt • Poco podemos decir de la ejecución de readInt ya que no disponemos de su código, pero a grandes rasgos, después de escribir el mensaje y esperar la entrada del usuario, una vez éste ha entrado un número entero, se devuelve la ejecución a la línea J.M. Gimeno y J.L. González 2 Programación 2 Curso 2011/2012 21 (en la que habíamos congelado la ejecución), asignando el valor devuelto por readInt a n • La ejecución pasa entonces a la línea 22, dónde se llama al método squareRoot. Se vuelve a congelar la ejecución de run y se pasa a ejecutar la línea 13 • Después de unas cuantas vueltas (dependiendo del valor de n) , se sale del bucle y se ejecuta la línea 17, volviendo al punto dónde nos habíamos congelado la ejecución de run. • … ¿Qué pasaría si, desde una función, llamáramos a la propia función? Pues que el punto de ejecución pasaría a la primera instrucción de la función y que, cuando dicha llamada retornase, continuaríamos la ejecución en el punto en el que nos hubiéramos quedado. El diagrama de llamadas que se producen es: run n: 15 root: 3 "Enter a …" 15 readInt 21 3 squareRoot "The root is …" 22 println 23 n: 15 lower: 0 1 2 3 En este diagrama, cada llamada viene representada por: • en la parte superior izquierda tenemos el nombre de la función llamada • en la parte superior derecha tenemos la línea a la que regresaremos cuando salgamos de la función (dicha línea pertenece a la función llamante) • una flecha de entrada en la que se muestran los valores de los parámetros • una flecha de salida en la que se muestra el resultado (o nada si la función es void) • dentro de la caja los valores de las variables locales de la función (que incluyen los parámetros). En el caso de squareRoot hemos marcado los sucesivos valores que tiene la variable lower J.M. Gimeno y J.L. González 3 Programación 2 Curso 2011/2012 2. Pensar recursivamente: Los textos palíndromos Una palabra (o texto) es palíndroma si se lee igual de izquierda a derecha que de derecha a izquierda (en el caso de un texto no tomaremos en cuenta los posibles espacios que separen las palabras). Por ejemplo: “Dábale arroz a la zorra el abad” es, tal y como podéis comprobar, un texto palíndromo1 Lo que queremos será un programa tal que, dado un texto, nos diga si es palíndromo o no. El programa principal básicamente consistirá en: • Pedir los datos al usuario. Como se tratará de un texto, la forma natural de hacerlo será con el método readLine • Eliminar los espacios de la cadena de entrada. Para ello crearíamos un método removeSpaces tal que, dado un String, devuelva otro, con los espacios borrados2. • Llamar a la función que comprueba si el texto entrado es palíndromo. Llamaremos a esta función isPalindrome, y será una función que recibirá como parámetro un String y devolverá un boolean. • Finalmente, dependiendo del valor devuelto por la función anterior, se indicará si el texto es palíndromo o no. Si escribimos esto en Java, tendremos: 1 Para simplificar, al introducir el texto obviaremos los posibles acentos ortográficos que pudieran tener las palabras. 2 Para programarlo os podéis inspirar en el método removeVocals del tema anterior. J.M. Gimeno y J.L. González 4 Programación 2 Curso 2011/2012 1 /* CheckPalindrome.java 2 * -­‐-­‐-­‐-­‐-­‐-­‐-­‐-­‐-­‐-­‐-­‐-­‐-­‐-­‐-­‐-­‐-­‐-­‐-­‐-­‐ 3 * Checks whether the entered text is palindrome. 4 */ 5 6 import acm.program.ConsoleProgram; 7 8 public class CheckPalindrome extends ConsoleProgram { 9 10 public String removeSpaces(String text) { 11 // Ejercicio 12 } 13 14 public boolean isPalindrome(String text) { 15 // Se detallará más adelante 16 } 17 18 public void run() { 19 String text = readLine(“Enter text to check: “); 20 text = removeSpaces(text); 21 if ( isPalindrome(text) ) { 22 println(“Text is palindrome.”); 23 } else { 24 println(“Text is not palindrome.”); 25 } 26 } 27 } Una solución iterativa Antes de intentar solucionar el problema de forma recursiva, vamos a ver cómo procederíamos a hacerlo con los conocimientos que tenemos, es decir, mediante una solución iterativa. ¿Qué es lo que hemos de hacer? Básicamente comprobar que: • el primer carácter de la cadena (posición 0) coincide con el último (posición longitud-­‐1), y • el segundo (posición 1), coincide con el penúltimo (posición longitud-­‐2), y … • … hasta llegar a la mitad de la cadena3 Para ello haremos un bucle que vaya generando las parejas a comparar. En el momento de encontrar dos caracteres diferentes, ya podemos dar por acabada la comprobación (ya que sabemos que no lo 3 ¿Por qué solamente hasta la mitad? Considerad los casos de textos de longitud par e impar al justificarlo. J.M. Gimeno y J.L. González 5 Programación 2 Curso 2011/2012 es). Si al final no hemos encontrado ninguna diferente, sabemos que se trata de un palíndromo. Es decir, se trata de un esquema de búsqueda. 1 public boolean isPalindrome(String text) { 2 int i = 0; 3 int length = text.length(); 4 int maxPair = length / 2; 5 while ( i < maxPair && 6 text.charAt(i) == text.charAt(length–1-­‐i) ) { 7 i = i + 1; 8 } 9 // If not found, isPalindrome is true 10 return ( i == maxPair ); 11 } Si comparamos la descomposición del problema en subproblemas tenemos: • problema inicial: ver si un texto es palíndromo • una serie de problemas más sencillos: comparar parejas de caracteres. Pensando una solución recursiva Una solución recursiva del problema consistiría en una descomposición en la que, para comprobar si una texto es palíndromo, nos surja como subproblema la necesidad de saber si una parte de él lo es. Recordad las muñecas rusas: tenemos una muñeca (saber si un texto es palíndromo) que al abrirla (descomponer el problema) contiene dentro otra muñeca más pequeña (saber si una parte del texto es un palíndromo). En este caso, la descomposición que podemos hacer es la siguiente:para comprobar si un texto es palíndromo o no: • miramos si los caracteres de los extremos (primero y último) son iguales • comprobamos si el texto formado por los caracteres no extremos es palíndromo Como puede observarse, en la nueva descomposición, uno de los subproblemas que nos aparece es el mismo que el original (pero sobre un texto más pequeño). La necesidad de los casos simples Si la única posibilidad de solucionar un problema fuera aplicar la regla recursiva, ¡nunca acabaríamos de resolverlo!, ya que siempre nos J.M. Gimeno y J.L. González 6 Programación 2 Curso 2011/2012 quedaría un subproblema que resolver (al que aplicaríamos la regla recursiva, que nos daría otro subproblema, que nos daría …, hasta el infinito). Es por ello es necesario que existan casos que no necesiten de aplicar la regla recursiva, sino que se pueden resolver directamente. Son como la muñequita rusa más pequeña, que ya no contiene más muñequitas dentro. A estos casos que pueden resolverse directamente se les conoce como casos simples, en contraposición de los otros que se denominan casos recursivos. En este caso, ¿cuándo podemos considerar que un texto es palíndromo sin necesidad de comprobar nada más? • si un texto es vacío, podemos considerar que es palíndromo • si un texto consiste en solamente una letra, también Juntando todo, ya tenemos todos los ingredientes que necesitamos para programar nuestra versión recursiva de la función: 1 public boolean isPalindrome(String text) { 2 if ( text.length() <= 1) { 3 return true; 4 } else { 5 char first = text.charAt(0); 6 char last = text.charAt(text.length()-­‐1); 7 String inner = removeExtrems(text); 8 return (first == last) && isPalindrome(inner); 9 } 10 11 public String removeExtrems(String text) { 12 // Ejercicio para el lector 13 } Es cierto que en esta versión recursiva, en las sucesivas llamadas recursivas dentro del método removeExtrems, creamos muchas copias de trozos de la cadena. Cuando más adelante veamos más detalles de la clase String, veremos que hay formas de evitar dichas copias4. Tened presente que todavía nos queda mucho por avanzar y que no podemos pretender tener en cuenta todos los detalles. La ejecución del programa usando esta versión recursiva de isPalindrome genera el siguiente diagrama de llamadas: 4 En la siguiente sección veremos el uso de índices que permitiría delimitar la parte de String a considerar sin necesidad de ir generando un String diferente para cada llamada recursiva. J.M. Gimeno y J.L. González 7 Programación 2 Curso 2011/2012 run "Enter text …" text: "abcba" "abcba" readLine "Text is pal…" 19 "abcba" "abcba" removeExtrems "abcba" true isPalindrome 21 println 22 text: "abcba" first: 'a' last: 'a' inner: "bcb" 20 true "bcb" isPalindrome 8 text: "bcb" first: 'b' last: 'b' inner: "c" "c" true isPalindrome 8 text: "c" Resumiendo Intentemos resumir en una tabla las intuiciones que obtenemos pensando en la imagen de las muñecas rusas y su equivalente en cuanto a la solución recursiva de un problema5: Muñecas rusas Solución recursiva Una muñeca puede abrirse para Un problema de descompone en ver qué es lo que hay en su varios subproblemas . interior. Al abrir una muñeca grande Al descomponer un problema encontramos muñecas más grande (casos recursivos) pequeñas en su interior encontramos subproblemas que tienen la misma estructura que el problema inicial y trabajan sobre datos más pequeños 5 Como toda metáfora la coincidencia no es exacta, pero puede ayudarnos a tener intuiciones. J.M. Gimeno y J.L. González 8 Programación 2 La muñeca más pequeña ya no contiene otras muñecas Existen casos simples cuya solución no requiere descomponerlos más. Entre los casos simples y los recursivos tengo todas las posibilidades cubiertas Sólo hay dos tipos de muñecas (las que contienen otras en su interior y las más pequeñas que no las contienen). J.M. Gimeno y J.L. González Curso 2011/2012 9 Programación 2 Curso 2011/2012 3. Recursividad usando índices Una forma de no tener que ir generando múltiples copias de partes del String en el caso del texto capicúa, consiste en plantear la recursividad no sobre el String, sino sobre unos índices que nos indiquen el subconjunto de elementos sobre los que estamos trabajando. Como tanto los vectores como los Strings permiten acceder directamente a una posición, plantaeremos este ejemplo sobre vectores y quedará como ejercicio hacer los cambios necesarios para que funcione sobre Strings. ¿Cómo referirse a un subvector? Supongamos que el String text, que ya no tiene espacios, almacena el texto del que queremos ver si es palíndromo, y textChars el char[] correspondiente, es decir: char[] textChars = text.toCharArray(); Además denominaremos L al número de elementos del vector, es decir: int L = textChars.length; Por tanto, el vector está formado por las posiciones 0 a L-­‐1. Para referirnos a un subvector de elementos consecutivos, uno podría usar dos índices: uno para el primer elemento del segmento y otro para el último, es decir6: 0 first last L Pero la experiencia ha demostrado que elegir los índices de esta manera complica algunos algoritmos. Por ejemplo, para representar un subvector vacío hemos de hacer que último sea más pequeño que primero (last <first), el número de elementos del segmento es last-­‐first+1, lo que es algo incómodo. Existen dos formas típicas para referirnos a un subvector: • Dos índices, uno que se refiera al primer elemento de subvector (inf) y otro que se refiera al primer elemento fuera del subvector (sup). 6 Fijaos en que la posición L cae fuera del vector. Por cierto, L lo usaremos en los diagramas como alias de la longitud del vector. J.M. Gimeno y J.L. González 10 Programación 2 Curso 2011/2012 • Un índice que indique el primer elemento del subvector (begin) y el número de elementos a considerar (len)7. Es decir, len 0 begin end L En este caso se cumple: • len == begin – end • el subvector vacío: len == 0 y begin == end • el subvector total: begin == 0 y end == len -­‐ L • 0 <= begin <= end <= L Vectores palíndromos La función recursiva que realizaremos tendrá la siguiente forma: 1 public boolean isPalindromeArray(char[] textChars, 2 int begin, 3 int end) { 4 5 // Checks whether the subarray from begin to 6 // end-­‐1 of textChars is palindrome 7 } ¿Cuál será la descomposición en este caso? Para ver si los caracteres desde begin a end-­‐1 de textChars forman un palíndromo hemos de: • comprobar si los caracteres extremos son iguales, es decir hemos de comparar textChars[begin] y textChars[end-­‐1] • comprobar que el subvector sin los extremos, es palíndromo, es decir, hemos de hacer la llamada recursiva con los parámetros textChars, begin+1 y end-­‐1 Como ya vimos anteriormente dicha descomposición solamente es posible si el subvector tiene longitud al menos dos. En los casos de longitudes cero (vacío) o uno (un solo carácter) sabemos que la función ha de retornar cierto. Si lo juntamos todo, nos queda: 7 Si recordáis, esta es la forma que se utiliza para indicar los elementos de un char[] a tener en cuenta cuando construimos un vector. J.M. Gimeno y J.L. González 11 Programación 2 Curso 2011/2012 1 public boolean isPalindromeArray(char[] textChars, 2 int begin, 3 int end) { 4 5 // Checks whether the subarray from begin to 6 // end-­‐1 of textChars is palindrome 7 8 if ( begin == end || begin == end-­‐1) { 9 return true; 10 else { 11 return textChars[begin] == textChars[end-­‐1] && 12 isPalindromeArray(textChars, begin+1, end-­‐1); 13 } 14 } Teniendo en cuenta que end-­‐begin da el número de elementos en el segmento, la condición del caso simple puede expresarse también como ( end-­‐begin <= 1). Fijaos que en la llamada recursiva el tamaño del intervalo es más pequeño. Con esta nueva versión, el método run quedará ahora como: 1 public void run() { 2 String text = readLine(“Enter text to check: “); 3 text = removeSpaces(text); 4 boolean isPal = isPalindromeArray(text.toCharArray(), 5 0, 6 text.length()); 7 if (isPal) { 8 println(“Text is palindrome.”); 9 } else { 10 println(“Text is not palindrome.”); 11 } 12 } Se deja como ejercicio la implementación usando como parámetros begin y len, es decir, el índice del primer elemento del subvector y su tamaño. Observad que la llamada que hacemos, en la que begin es 0 y end es text.length(), comprueba si los caracteres de todo el vector forman un palíndromo, es decir: J.M. Gimeno y J.L. González 12 Programación 2 Curso 2011/2012 L end 0 begin Descomposición cuando uno de los extremos está fijo Cuando uno de los extremos, ya sea el de la izquierda como el de la derecha, esté fijo, solamente deberemos usar un índice. En estos casos, la descomposición del vector será: • Si el extremo izquierdo queda fijo: 0 pos L pos L • Si es el extremo derecho: 0 Fijaos que en ambos casos hemos seguido la convención de que el extremo izquierdo forma parte del intervalo y, en cambio, el extremo derecho es el primer elemento que ya no forma parte de ese intervalo. J.M. Gimeno y J.L. González 13 Programación 2 Curso 2011/2012 4. Un ejemplo sobre números: la exponenciación Vamos a aplicar nuestra idea de buscar recurrencias a aun problema, esta vez sobre números: dados dos números a≥0 y b≥0, diseñar una función que calcule la exponenciación ab. Solución iterativa Dado que ab no es más que realizar b multiplicaciones de a, hacemos un bucle tal que, a cada vuelta acumule (multiplicando) sobre una variable (que inicialmente valdrá 0) el producto de esa variable por a. Al final de b iteraciones habremos multiplicado b veces por a y, por tanto, tendremos la exponenciación buscada. Vamos a buscar una solución recursiva Recordad, la estrategia consiste en encontrar una descomposición de la exponenciación que, a su vez, incluya una exponenciación. Una vez encontrada, deberemos buscar también como mínimo un caso en que se pueda dar el resultado sin necesidad de aplicar la recurrencia (si no, el programa nunca acabaría). Si con esos casos tenemos cubiertas todas las posibilidades, hemos acabado. En nuestro caso podemos ver que: • 𝑎! = 𝑎 ∗ 𝑎!!! , si 𝑏 ≥ 1 • cuando 𝑏 = 0, 𝑎! = 1 Es decir, 1 public long exp(long a, long b) { 2 if ( b == 0 ) { 3 return 1; 4 } else { 5 return a * exp(a, b-­‐1); 6 } 7 } Hemos usado long en vez de int pues la exponenciación genera números potencialmente grandes y con int podríamos tener problemas de precisión. Esta solución funciona pero, cuando b es grande, es bastante ineficiente: a cada llamada se reduce b en una unidad, por lo que para J.M. Gimeno y J.L. González 14 Programación 2 Curso 2011/2012 calcular la exponenciación hemos de hacer tantas llamadas como valor tiene el exponente8. ¿Podemos hacerlo mejor? Restar es lento, dividir es más rápido (para llegar a cero) Si en vez de restar uno al exponente, lo dividimos por dos (usando división entera), tendremos que hacer menos llamadas hasta legar al caso simple. Por ejemplo, si empezamos con b=10, tendremos: • Restando: 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0 • Dividiendo: 10, 5, 2, 1, 0 Incluso en este caso (con un valor bajo de b) la diferencia es significativa. Pensad que si duplico el valor de b, en el primer caso se duplica el número de vueltas, mientras que en el segundo solamente se añade una vuelta más9. ¿Podemos encontrar una recursión dividiendo? Como queremos dividir10, tendremos que encontrar una relación entre el 𝑎! original y 𝐸 ! div ! de la llamada recursiva (dónde el 𝐸 es una expresión que debemos determinar). Una posible forma de proceder es buscar una relación entre 𝑏 y (𝑏 div 2), y luego aplicar algo de aritmética. Al dividir por dos, tenemos dos posibilidades, dependiendo de si b es par o impar11: • Si 𝑏 es par, la división entera es exacta, por lo que tenemos que 𝑏 es exactamente 2 ∗ (𝑏 div 2) • Si 𝑏 es impar, la división entera pierde el valor del resto de la división, por tanto b es 2 ∗ (𝑏 div 2)+1 Si aplicamos el análisis anterior a la fórmula 𝑎! , tenemos: • Si 𝑏 es par, 𝑎! = 𝑎!∗(! div 2) = 𝑎! ! div 2 • Si 𝑏 es impar, 𝑎! = 𝑎!∗(! div 2)+1 = 𝑎 ∗ 𝑎! ! div 2 Es decir, en ambos casos, para calcular 𝑎! hemos de calcular 𝑎! ! div 2 , tan sólo que en caso de que 𝑏 sea impar, deberíamos multiplicar el resultado de la llamada recursiva por 𝑎 antes de devolverlo. Es decir: 8 Técnicamente se dice que el coste temporal del algoritmo es lineal respecto del valor del exponente. Más en la asignatura de Algorítmica y Complejidad. 9 En este caso diremos que el coste temporal del algoritmo es logarítmico respecto del valor del exponente. 10 Cuando trabajamos con enteros usamos la división entera a div b. En Java no existe un operador especial por lo que cuando a y b son enteros, a/b es la división entera entre a y b. 11 Recordad que estamos tratando con aritmética de enteros por lo que la división no siempre es exacta. J.M. Gimeno y J.L. González 15 Programación 2 1 public long exp2(long a, long b) { 2 if ( b == 0 ) { 3 return 1; 4 } else { 5 long result = exp2(a*a, b/2); 6 if ( b%2 == 1 ) { 7 result = a * result; 8 } 9 return result; 10 } 11 } En este caso, es b quién decrece a cada llamada recursiva, ya que cuando 𝑏 > 0, se tiene que 𝑏/2 < 𝑏 (usando división entera). Curso 2011/2012 7 13 7 * 13841287201 = 96889010407 exp2(7, 13) Trazando las llamadas 7*7 = 49 Aunque hayamos realizado un fino 13841287201 13/2 = 6 razonamiento sobre la corrección de la función anterior, a veces necesitamos un pequeño ejemplo exp2(49, 6) de ejecución para ver que, efectivamente, funciona. Vamos a ejecutar paso a paso el cálculo de 49*49 = 2401 2401 * 576801 713. = 13841287201 6/2=3 Observando el diagrama podemos observar que la ejecución de un algoritmo recursivo consiste en exp2(2401, 3) dos bucles: • uno que va desde la llamada 2401 * 2401 5764801 * 1 inicial hasta el caso simple y = 5764801 = 5764801 que va modificando los 3/2 = 1 parámetros • uno que va desde la solución exp2(5764801, 1) del caso simple hasta regresar de la llamada inicial y que va propagando 5764801 * 5764801 = 33232930569601 1 la solución 1/2 = 0 Fijaos que conseguimos estos dos bucles sin explicitarlos de manera alguna (con un while, o un for). exp2(33232930569601, 0) J.M. Gimeno y J.L. González 16 Programación 2 Curso 2011/2012 Espacio usado por un algoritmo recursivo Si miramos la traza de las llamadas, veremos que, para que la ejecución de la función pueda recorrer el camino de vuelta, en algún lugar se deberán guardar los valores de los parámetros y de las variables locales de las llamadas anteriores. Este lugar es la pila de ejecución. Su nombre responde a que, de la misma manera que en una pila de platos, solamente podemos colocar cosas encima de la pila, y acceder al elemento de la cima (que es el último que hemos introducido). Debido a que en la ejecución de un método recursivo podemos tener que realizar muchas llamadas hasta alcanzar los casos simples, puede darse la situación de que se llena la pila de ejecución y se produce el error de desbordamiento de pila (Stack Overflow). J.M. Gimeno y J.L. González 17 Programación 2 Curso 2011/2012 5. Raíz cuadrada (esta vez “exacta”) Vamos a realizar el diseño de una función para calcular la raíz cuadrada de un número 𝑥 ≥ 0, pero usando números en coma flotante12. Una primera cuestión a tener en cuenta es que, debido a que hay errores de precisión, ya que el número de decimales que se toman en cuenta es finito, no podemos comprobar si el resultado es igual a la raíz cuadrada sino que es una aproximación suficientemente buena. Relacionado con lo anterior, un algoritmo del tipo: comenzamos en 0.0 y vamos incrementado poco a poco (por ejemplo en incrementos de 0.00001) hasta encontrar la raíz cuadrada no son aplicables13. Además, estamos buscando una solución recursiva. ¿Qué hacemos? Reformular el problema A veces, cuando un problema se nos resiste, la solución consiste en replantear el problema en función de otro que sí sabremos resolver. Desgraciadamente no existe una regla que podamos seguir para encontrar la reformulación que nos garantice encontrar el ¡ajá! adecuado para cada problema. Lo único que podemos hacer es estudiar soluciones, meditar sobre ellas, intentar generalizarlas y aplicarlas a otras situaciones. En este caso el ¡ajá! consiste en considerar lo siguiente14: • tal y como se ha comentado antes, no podemos buscar exactitud, sino una buena aproximación • una buena aproximación quiere decir que lo que encontraremos está suficientemente cerca • que esté suficientemente cerca quiere decir que está dentro de un intervalo que incluye la solución • intervalo que incluye la solución, … • ummm, intervalo, ¡intervalo! , ¡claro!, ¡aja! 12 En el primer tema vimos el cálculo de la raíz cuadrada entera, es decir, del número más alto que, elevado al cuadrado, es menor que el número dado. Es decir, la raíz cuadrada entera 𝑟 de un número n cumple 𝑟 ! ≤ 𝑛 < (𝑟 + 1)! 13 No porque no logren la solución, sino porque el tiempo que emplearían en ello sería astronómico. 14 También podríamos habernos acordado teorema de Bolzano sobre funciones continuas. J.M. Gimeno y J.L. González 18 Programación 2 Curso 2011/2012 Vamos a intentar formalizar un poco estos conceptos para ver si podemos esbozar el diseño que existe detrás de la solución. Comenzaremos con la idea de que el valor que retornamos está suficientemente cerca de la solución. Formalizando “suficientemente bueno” Formalmente, decir que la aproximación 𝑟 es suficientemente buena quiere decir que 𝑟 está suficientemente cerca de 𝑥, es decir 𝑥 − 𝑟 < 𝜀 para un valor de 𝜀 > 0 suficientemente pequeño (y que, por tanto, define la precisión del resultado). Aplicando la definición de valor absoluto 𝑥 − 𝑟 < 𝜀 si 𝑥 ≥ 𝑟 𝑥 − 𝑟 < 𝜀 ≡ 𝑟 − 𝑥 < 𝜀 si 𝑥 < 𝑟 Por la primera rama: 𝑥 − 𝑟 < 𝜀 ≡ 𝑥 − 𝜀 < 𝑟 que, junto con la condición de estar en este caso ( 𝑥 ≥ 𝑟), hace que se deba cumplir 𝑥 − 𝜀 < 𝑟 ≤ 𝑥 ≡ 𝑟 ∈ ( 𝑥 − 𝜀, 𝑥] Análogamente por la segunda rama 𝑟 − 𝑥 < 𝜀 ≡ 𝑟 < 𝑥 + 𝜀 que, junto con la condición de estar en este caso ( 𝑥 < 𝑟), hace en este caso se deba cumplir: 𝑥 < 𝑟 < 𝑥 + 𝜀 ≡ 𝑟 ∈ ( 𝑥, 𝑥 + 𝜀) Juntando las condiciones de ambas ramas, se tiene: 𝑟 ∈ ( 𝑥 − 𝜀, 𝑥 + 𝜖) Gráficamente, para que 𝑟 se considere un resultado suficientemente bueno ha de estar en el siguiente intervalo: ! − !! ! + !! !! Es decir, el objetivo es: dados 𝑥 y 𝜀 encontrar un valor r que esté ahí dentro. Por tanto la función que queremos diseñar tendrá la forma: J.M. Gimeno y J.L. González 19 Programación 2 Curso 2011/2012 1 public double squareRoot(double x, double epsilon) { 2 ¿? 3 } Si solamente podemos variar estos dos parámetros no es fácil encontrar una solución recursiva. Por ello intentaremos reformular el problema en otro que sea más simple, y que nos permita encontrar un valor suficientemente bueno para la raíz. En concreto, para simplificar el problema, utilizaremos una estrategia que también funciona en la vida real: si el problema es muy complicado, pediremos alguna pista adicional para simplificarlo. Reformulemos el problema Supongamos que nos dan como pista un intervalo, delimitado por 𝒂 y 𝒃, en el que nos dicen que ahí dentro está 𝒙. Es decir, 𝑥 ∈ 𝑎, 𝑏 Dicha condición es equivalente a: 𝑥 ∈ [𝑎! , 𝑏 ! ] Por tanto, ahora, la función a diseñar será15: 1 public double squareRoot(double x, 2 double epsilon, 3 double inf, 4 double sup) { 5 ¿? 6 } Dentro de la función al límite inferior (𝑎) le llamamos inf y al superior (𝑏) sup. ¿Podemos usar esta información adicional en nuestro favor para así encontrar una solución recursiva al problema? El intervalo es pequeño Si el tamaño del intervalo fuera más pequeño que 𝜀 , cualquier valor del intervalo sería una aproximación adecuada de 𝑥. Es decir: !! !! a b 15 Quedará por ver si podemos usar esta función para calcular la que realmente nos interesa, en otras palabras, la llamada inicial. J.M. Gimeno y J.L. González 20 Programación 2 Curso 2011/2012 Como 𝑥 está dentro de ese intervalo, cualquier valor del intervalo está a una distancia menor que 𝜀 y, por tanto, cualquiera nos sirve como aproximación de la raíz cuadrada. ¡¡ Ya hemos encontrado el caso simple !! El intervalo es grande Si el intervalo es grande, la idea es descomponerlo en intervalos más pequeños y preguntarnos en cual de ellos estará 𝑥 para que sea la llamada recursiva quién nos resuelva el problema (recordad que solamente podemos llamar a la función usando un intervalo que contenga el valor de raíz de x). !! ? ? ¿Cómo sabemos en qué lado está 𝑥? Pues comparando en valor de 𝑚! con el de 𝑥 y, si es menor, hemos de seguir buscando por la derecha y, en caso contrario, por la izquierda. La función en Java quedaría, por tanto: a m b 1 public double squareRoot(double x, 2 double epsilon, 3 double inf, 4 double sup) { 5 6 double mid = (inf + sup) / 2; 7 8 if ( sup -­‐ inf < epsilon ) { 9 return mid; 10 } else if ( mid * mid < x ) { 11 return squareRoot(x, epsilon, mid, sup); 12 } else { 13 return squareRoot(x, epsilon, inf, mid); 14 } 15 } Dos cosas a mencionar en el código anterior: • El uso de la función Math.abs para calcular el valor absoluto. La clase Math, del paquete java.lang, contiene algunas funciones matemáticas de uso común, como la que permite calcular el valor absoluto. Como todas las demás clases del paquete java.lang no hace falta hacer un import para usarlas (lo mismo sucede con la clase String). J.M. Gimeno y J.L. González 21 Programación 2 Curso 2011/2012 • Fijaos en la forma de alinear los if, aunque el else de la línea 12 se corresponde con el if de la 10 se alinea con el de la línea 8. De esta manera visualmente vemos que la estructura de control se corresponde en el fondo con tres posibilidades (dos con la condición explicitada y una que es la que se toma cuando ambas fallan). La llamada inicial Hasta el momento no ha habido demasiados problemas para encontrar los parámetros a pasar en la primera llamada. Para esta función, tendremos que pensar un pelín más. De las cuatro parámetros de la función, dos están claros: x y épsilon. El problema viene para 𝑖𝑛𝑓 y para 𝑠𝑢𝑝, ya que se ha de cumplir 𝑖𝑛𝑓 ! ≤ 𝑥 ≤ 𝑠𝑢𝑝! . • para 𝑖𝑛𝑓 no hay demasiado problema dado que 𝑥 ≥ 0, lo que garantiza que podemos coger siempre 𝑖𝑛𝑓 = 0 • para 𝑠𝑢𝑝 está un pelín más complicado. Una posible idea sería coger 𝑥 , ya que normalmente 𝑥 ! ≥ 𝑥, pero ello solamente es cierto si 𝑥 ≥ 1. En caso de no serlo, podemos coger el mismo 1.0 como un valor mayor que 𝑥 En resumen: 1 public double squareRoot(double x, double epsilon) { 2 return squareRoot(x, epsilon, 0.0, Math.max(1.0, x)); 3 } Dos comentarios: • el uso de la función Math.max, para decidir si usamos un valor u otro para 𝑠𝑢𝑝 (también se podría usar un condicional pero usar max o min en estos casos es habitual). • el método tiene el mismo nombre que el que tiene 4 parámetros. En Java es posible tener dos métodos con el mismo nombre, siempre que se puedan distinguir a partir del número de parámetros o del tipo de los mismos. A esto se le llama sobrecarga16. 16 Fijaos que esto mismo pasa con los métodos print y println, que se llaman igual independientemente de que lo que escribamos sea un entero, un carácter, un String, etc. J.M. Gimeno y J.L. González 22 Programación 2 Curso 2011/2012 6. Seno de un ángulo Otro ejemplo de programa recursivo sobre números en coma flotante se puede plantear para el cálculo del seno de un ángulo a partir de: 1. Fórmula del seno del ángulo triple 𝛼 𝛼 ! sin 𝛼 = 3 sin − 4 sin 3 3 2. Aproximación del seno para ángulos pequeños sin 𝛼 lim = 1 !→! 𝛼 La segunda propiedad podemos reformularla de la siguiente manera para que quede más claro que nos permite resolver el caso simple: sin 𝛼 ≈ 𝛼 para 𝛼 ≈ 0 es decir, para ángulos suficientemente pequeños podemos usar el propio ángulo como valor de su seno. Observad que cuando hacemos la llamada recursiva con 𝛼 3, lo hacemos sobre un valor menor que nos acerca al caso simple. 1 public class SineProgram extends ConsoleProgram { 2 3 public double sine(double alpha) { 4 double epsilon = 0.00001; 5 if (Math.abs(alpha) <= epsilon) { 6 return alpha; 7 } else { 8 double sin3rd = sinus(alpha/3); 9 return sin3rd * (3 -­‐ 4 * sin3rd * sin3rd); 10 } 11 } 12 13 public void run() { 14 double[] angles = { 0.5, -­‐2.0, 4.0, -­‐5.0, 10000.0 }; 15 for (int i = 0; i < angles.length; i++) { 16 double mySin = sine(angles[i]); 17 double javaSin = Math.sin(angles[i]); 18 println(" MySin: " + mySin); 19 println("JavaSin: " + javaSin); 20 println(); 21 } 22 } 23 } Dicho programa muestra: J.M. Gimeno y J.L. González 23 Programación 2 Curso 2011/2012 MySin: 0.47942553860944637 JavaSin: 0.479425538604203 MySin: 0.9092974268237167 JavaSin: 0.9092974268256817 MySin: -­‐0.7568024953326157 JavaSin: -­‐0.7568024953079282 MySin: -­‐0.9589242746422137 JavaSin: -­‐0.9589242746631385 MySin: -­‐0.30561450635509907 JavaSin: -­‐0.30561438888825215 Fijaos en que la aproximación, conforme aumenta el valor del ángulo, es cada vez peor. Ello se debe a que esta forma de calcular el seno del ángulo no es la mejor, desde el punto de vista de la precisión. J.M. Gimeno y J.L. González 24 Programación 2 Curso 2011/2012 7. Búsqueda binaria17 en un vector ordenado Intentemos aplicar esta idea de los intervalos que se van dividiendo por la mitad a un problema parecido: la búsqueda en un vector de enteros ordenado. Similitudes: • al igual que los números reales en el intervalo [inf, sup], los elementos del vector están ordenados. • buscamos un elemento dentro del intervalo (inicialmente el intervalo es el vector desde las posiciones 0 hasta la L-­‐1). Diferencias: • en el caso de la raíz cuadrada sabemos con seguridad que la solución existe, es decir, existe un elemento que es la raíz cuadrada. En el caso de la búsqueda, el elemento podría no existir dentro del vector. Reformulación del problema De cara a tratar con el problema de qué devolver si el elemento a buscar no se encuentra en el vector, reformularemos el problema de la siguiente manera: dado un vector de enteros ordenado crecientemente y un elemento 𝑥 , que puede o no pertenecer al vector, encontrar la posición pos del vector tal que marque la separación entre los que son ≤ 𝑥 y los que son > 𝑥. Es decir: ≤x >x 0 pos L Casos particulares: • si 𝑥 pertenece al vector, ocupará la posición pos • si 𝑥 aparece varias veces en el vector, se dará la posición de más a la derecha • si todos los elementos del vector son > 𝑥, el valor devuelto es -­‐1 • si todos los elementos del vector son < 𝑥, el valor devuelto es L-­‐1 • si 𝑥 no pertenece al vector, pero hay elementos ≤ 𝑥 y > 𝑥, se devuelve una posición del vector entre 0 y L-­‐1 17 También denominada búsqueda dicotómica. J.M. Gimeno y J.L. González 25 Programación 2 Curso 2011/2012 Planteamiento de la solución recursiva La estrategia consistirá en considerar que el vector está dividido en tres zonas: • la izquierda, que contiene los elementos que sabemos que son ≥ 𝑥 • la central, que es la que desconocemos, y que queremos hacer cada vez más pequeña • la derecha, que contiene los elementos que sabemos que son > 𝑥 Gráficamente: ≤x 0 >x ¿? left right L Dos cosas a tener en cuenta: • fijaos en que los tres intervalos están definidos de la manera recomendada, es decir, la posición del primer elemento del intervalo y la posición del primero que no pertenece a él • cualquier zona puede estar vacía (por ejemplo inicialmente las zonas de la derecha y de la izquierda lo están, ya que no sabemos nada)18. • se cumple: 0 ≤ 𝑙𝑒𝑓𝑡 ≤ 𝑟𝑖𝑔ℎ𝑡 ≤ 𝐿 A nivel de Java, la función recursiva tendrá la forma: 1 public int binarySearch(int[] v, 2 int x, 3 int left, 4 int right) { 5 ¿? 6 } Caso simple ¿Cuándo podemos dar la respuesta sin tener que hacer nada más? Cuando la zona intermedia de los interrogantes está vacía, es decir, cuando left=right, ya sabemos la solución left-­‐1. 18 Como ejercicio podéis considerar los valores de left y right para los casos particulares mencionados en el apartado anterior. J.M. Gimeno y J.L. González 26 Programación 2 Curso 2011/2012 ≤x >x 0 pos right left L Caso recursivo Ahora es cuando aplicaremos la estrategia que hemos utilizado antes, es decir, dividiremos el intervalo por la mirad y estudiaremos qué podemos hacer. Para calcular la posición media, haremos la misma división que antes, tan solo que ahora se tratará de una división entera, es decir: 𝑚𝑖𝑑 = (𝑙𝑒𝑓𝑡 + 𝑟𝑖𝑔ℎ𝑡) div 2; Gráficamente: ≤x >x ¿? 0 left mid L right Lo que hemos de hacer depende de si el valor que hay en la posición mid es ≤ 𝑥 o > 𝑥. • Si 𝑣 [𝑚𝑖𝑑] ≤ 𝑥, podemos extender la zona izquierda hasta incluir la posición 𝑚𝑖𝑑, es decir: ≤x >x ¿? 0 mid left L right • Si 𝑣 𝑚𝑖𝑑 > 𝑥, ahora lo que podemos extender es la zona de la derecha, es decir: ≤x 0 >x ¿? mid right left L Dónde el índice marcado en negrita es el que se pasa en la llamada recursiva. Una vez diseñada la solución, simplemente queda programarla en Java e indicar los valores de la llamada inicial. Al principio la zona J.M. Gimeno y J.L. González 27 Programación 2 Curso 2011/2012 desconocida ocupa todo el vector, por lo que la llamada inicial se hace con left = 0 y right = L 1 public int binarySearch(int[] v, 2 int x, 3 int left, 4 int right) { 5 int pos; 6 if ( left == right ) { 7 pos = left-­‐1; 8 } else { 9 int mid = (left + right) / 2; 10 if ( v[mid] <= x) { 11 pos = binarySearch(v, x, mid+1, right); 12 } else { 13 pos = binarySearch(v, x, left, mid); 14 } 15 } 16 return pos; 17 } 18 19 public int binarySearch(int[] v, int x) { 20 return binarySearch(v, x, 0, v.length); 21 } 22 23 public boolean contains(int[] v, int x) { 24 int pos = binarySearch(v, x); 25 return pos != -­‐1 && v[pos] == x; 26 } Las llamadas se hacen sobre intervalos más pequeños Lo único que nos queda es comprobar que cuando hacemos la llamada recursiva, lo hacemos sobre un intervalo más pequeño. Lo primero a tener en cuenta es que en los casos recursivos 𝑙𝑒𝑓𝑡 < 𝑟𝑖𝑔ℎ𝑡 (ya que siempre se cumple que 𝑙𝑒𝑓𝑡 ≤ 𝑟𝑖𝑔ℎ𝑡 y además sabemos que 𝑙𝑒𝑓𝑡 ≠ 𝑟𝑖𝑔ℎ𝑡. • Caso v[mid]<=x: Queremos demostrar que 𝑟𝑖𝑔ℎ𝑡 − 𝑙𝑒𝑓𝑡 > 𝑟𝑖𝑔ℎ𝑡 − 𝑚𝑖𝑑 + 1 −𝑙𝑒𝑓𝑡 > − 𝑚𝑖𝑑 + 1 𝑙𝑒𝑓𝑡 < 𝑚𝑖𝑑 + 1 𝑙𝑒𝑓𝑡 ≤ 𝑚𝑖𝑑 𝑙𝑒𝑓𝑡 ≤ 𝑙𝑒𝑓𝑡 + 𝑟𝑖𝑔ℎ𝑡 div 2 Lo cual es cierto para 𝑟𝑖𝑔ℎ𝑡 > 𝑙𝑒𝑓𝑡 ya que J.M. Gimeno y J.L. González 28 Programación 2 Curso 2011/2012 𝑙𝑒𝑓𝑡 + 𝑟𝑖𝑔ℎ𝑡 div 2 ≥ 𝑙𝑒𝑓𝑡 + 𝑙𝑒𝑓𝑡 div 2 = 𝑙𝑒𝑓𝑡 • Caso v[mid]>x: Ahora se trata de probar que 𝑟𝑖𝑔ℎ𝑡 − 𝑙𝑒𝑓𝑡 > 𝑚𝑖𝑑 − 𝑙𝑒𝑓𝑡 𝑟𝑖𝑔ℎ𝑡 > 𝑚𝑖𝑑 𝑟𝑖𝑔ℎ𝑡 > 𝑙𝑒𝑓𝑡 + 𝑟𝑖𝑔ℎ𝑡 div 2 Lo que es cierto para 𝑟𝑖𝑔ℎ𝑡 > 𝑙𝑒𝑓𝑡 ya que 𝑟𝑖𝑔ℎ𝑡 = 𝑟𝑖𝑔ℎ𝑡 + 𝑟𝑖𝑔ℎ𝑡 𝑑𝑖𝑣 2 > 𝑙𝑒𝑓𝑡 + 𝑟𝑖𝑔ℎ𝑡 𝑑𝑖𝑣 2 Es decir, en ambos casos el intervalo es menor. J.M. Gimeno y J.L. González 29 Programación 2 Curso 2011/2012 8. La ordenación rápida (algoritmo quicksort) El algoritmo quicksort, desarrollado en 1960 por Sir Charles Antony Richard Hoare es, en la práctica, el más eficiente algoritmo de ordenación. La idea detrás del algoritmo es la siguiente: si se descompone el vector en dos partes, tales que todos los elementos del subvector izquierdo sean menores que los del subvector derecho, se pueden ordenar separadamente (in situ), y el vector resultante ya quedará ordenado. Gráficamente: "pequeños" "grandes" L 0 dónde con “pequeños” queremos decir que todos los elementos del subvector izquierdo son menores19 que los del subvector “grandes”. En este caso, para ordenar todo el vector, puedo hacer dos llamadas recursivas: una sobre el subvector izquierdo y otra sobre el derecho. El caso simple también será fácil ya que un vector de 0 ó 1 elemento siempre estará ordenado. Como hemos de poder delimitar subvectores que tanto varíen su límite por la izquierda como por la derecha, añadiremos dos índices left y right para delimitar los límites del subvector que estamos ordenando, es decir: 0 left right L Ya podemos hacer un esbozo del algoritmo: 19 Durante la presentación del algoritmo supondremos que estamos ordenando los elementos del vector de forma creciente. J.M. Gimeno y J.L. González 30 Programación 2 Curso 2011/2012 1 public void quickSort(int[] v) { 2 quickSort(v, 0, v.length); 3 } 4 5 public void quickSort(int[] v, int left, int right) { 6 // 0 <= left <= right <= v.length 7 if (right – left > 1) { 8 9 // Particionar el vector y devolver la posición 10 // de corte. 11 12 // Llamadas recursivas: 13 quickSort(v, left, ¿?); 14 quickSort(v, ¿?, right); 15 } 16 } Por tanto, si logramos diseñar una función para hacer la partición del vector, ya tendremos solucionado el problema de la ordenación. El problema de la partición Una posible idea para particionar el vector de la manera deseada, es escoger un elemento del vector p (al que se le denomina pivote) y restructurar el subvector dado hasta que todos los elementos menores o iguales que p estén a la izquierda, y los mayores que p a la derecha. Es decir: >p ≤p left right pos Para diseñar esta función, dividiremos el subvector dado en tres partes: • la de los que sabemos que son ≤p • la de los que sabemos que son >p • la de los que aún no sabemos nada. Es decir: left >p ¿? ≤p inf J.M. Gimeno y J.L. González sup right 31 Programación 2 Curso 2011/2012 En la que • el caso simple será cuando la zona intermedia esté vacía (inf==sup) y el valor a retornar será inf • la llamada inicial se realizará con inf==left y sup==right. En el caso recursivo, la estrategia es reorganizar todo el subvector de en medio en base a reorganizar un subvector menor. Además, para que la llamada recursiva se realice sobre un subvector de menor tamaño, o bien ha de crecer la zona de la izquierda, o la de la derecha, o ambas. • Para que crezca la zona de la izquierda, ha de pasar que v[inf] <=p. En este caso, puedo reorganizar el vector con la llamada recursiva sobre inf+1 y sup. • Para que crezca la zona de la derecha, análogamente, se ha de cumplir que v[sup-­‐1]>p. En este caso, puedo reorganizar el vector con la llamada inf, sup-­‐1. • ¿Qué pasa si no se cumple ningún caso de los anteriores? Fijaos en que esto se produce cuando v[inf]>p && v[sup-­‐1]<=p. En este caso puedo intercambiar los elementos de las posiciones inf y sup-­‐1 del vector v y reorganizar recursivamente el subvector delimitado por inf+1 y sup-­‐1. En Java, quedaría: J.M. Gimeno y J.L. González 32 Programación 2 Curso 2011/2012 1 public int partition(int[] v, 2 int pivot, 3 int inf, 4 int sup) { 5 if ( inf == sup ) { 6 return sup; 7 } else if (v[inf] <= pivot) { 8 return partition(v, pivot, inf+1, sup); 9 } else if (v[sup-­‐1] > pivot) { 10 return partition(v, pivot, inf, sup-­‐1); 11 } else { 12 swap(v, inf, sup-­‐1); 13 return partition(v, pivot, inf+1, sup-­‐1); 14 } 15 } 16 17 public void swap(int[] v, int i, int j) { 18 int tmp = v[i]; 19 v[i] = v[j]; 20 v[j] = tmp; 21 } Uniéndolo todo Usando la función de partición dentro de quickSort y suponiendo que existe una función que, dado un subvector, nos selecciona un elemento de él para usar como pivote, nos queda: J.M. Gimeno y J.L. González 33 Programación 2 Curso 2011/2012 1 public void quickSort(int[] v, int left, int right) { 2 // 0 <= left <= right <= v.length 3 if (right – left > 1) { 4 int pivotValue = choosePivot(v, left, right); 5 int pos = partition(v, pivotValue, left, right); 6 quickSort(v, left, pos); 7 quickSort(v, pos, right); 8 } 9 } 10 11 public int choosePivot(int[] v, int left, int right) { 12 // Returns any element of v whose position is 13 // >= left and < right 14 } Pero tenemos un “pequeño” problema Recordad que una de las cosas que hemos de garantizar es que las llamadas recursivas se hacen sobre subvectores más pequeños. En nuestro caso necesitamos garantizar que • pos – left < right – left pos < right • right – pos < right – left pos > left Y vemos que la primera de ellas no es cierta (ya que podría ser que no existieran elementos mayores que el pivote). ¿Cómo lo arreglamos? El “truco” consiste en, una vez escogido el pivote, “sacarlo del vector”, partir el vector sin el pivote y luego reintegrar el pivote que se había eliminado, para poder hacer la llamada recursiva sobre la parte izquierda del vector, con un elemento menos. Veámoslo gráficamente: • Intercambiamos el primero con el pivote (p) p right left • Partimos el subvector (sin el primer elemento, que es el pivote) partition(v, p, left+1, right) p right left Después de partir, se cumple: J.M. Gimeno y J.L. González 34 Programación 2 Curso 2011/2012 >p ≤p p pos left right Si ahora intercambiamos left y pos-­‐1, queda: >p ≤p p pos left right Y podemos asegurar que el subvector de la izquierda, en el peor de los casos, tendrá un elemento menos. En java: 1 public void quickSort(int[] v, int left, int right) { 2 // 0 <= left <= right <= v.length 3 if (right – left > 1) { 4 int pivotPos = choosePivotPosition(v, left, right); 5 int pivotValue = v[pivotPos]; 6 swap(v, left, pivotPos); 7 int pos = partition(v, pivotValue, left+1, right); 8 swap(v, left, pos-­‐1); 9 quickSort(v, left, pos-­‐1); 10 quickSort(v, pos, right); 11 } 12 } 13 14 public int choosePivotPosition(int[] v, 15 int left, 16 int right) { 17 // One possible pivit possition can is the 18 // middle of the subvector. 19 20 return left + (right -­‐ left) / 2; 21 } Consideraciones finales • En nuestro caso hemos diseñado la función partition como una función recursiva. Por cuestiones de eficiencia casi siempre la veréis implementada iterativamente. J.M. Gimeno y J.L. González 35 Programación 2 Curso 2011/2012 • La elección del pivote que hemos hecho, el punto medio del subvector, es una de muchas posibles. Otras posibilidades: o Escoger una posición al azar entre left y right-­‐1 (más adelante durante el curso veréis cómo usar un generador de números aleatorios). o Una mala elección sería escoger siempre left como pivote, ya que en vectores casi-­‐ordenados el rendimiento sería pésimo (todo esto lo veréis en la asignatura de Algorítmica y Complejidad) • Hay muchísimas variaciones posibles en la manera de partir el vector, en el lugar dónde “apartamos” el pivote, etc, etc. por lo que hay que entender el porqué de los pasos dados y ser capaces de rehacer el algoritmo o En otras palabras, no tiene sentido que intentéis aprenderos éste (o cualquier otro) algoritmo de memoria. J.M. Gimeno y J.L. González 36 Programación 2 Curso 2011/2012 9. Las torres de Hanói El puzle de las Torres de Hanói consiste en hacer pasar los discos, que inicialmente están en la primera torre, a la segunda, usando la tercera como auxiliar. Eso sí, cumpliendo dos reglas: • solamente puede moverse el disco superior de una torre (el que está encima de todo) • no podemos poner un disco sobre otro de menor tamaño Lo que se pide es una función que escriba los movimientos a realizar para resolver un puzle con un número de discos dado. Para referirnos a un movimiento solo tendremos que indicar las torres origen y destino, ya que solamente podrá moverse el disco superior de la torre origen y quedará colocado sobre todos los que ya existen en la torre destino. Planteamiento recursivo Una forma de encontrar la solución consiste en estudiar las condiciones bajo las cuales puedo mover el disco más grande: • ha de ser el único que haya en la torre origen • la torre destino ésta ha de estar vacía • todos los discos han de estar en la torre auxiliar J.M. Gimeno y J.L. González 37 Programación 2 Curso 2011/2012 ¿Lo veis? Para poder mover el disco más grande de los n desde la torre origen a la destino, he de haber movido previamente los n-­‐1 discos desde la torre origen a la auxiliar. Una vez movidos los n-­‐1 discos que lo obstruyen y movido el más grande a la torre destino, hemos de mover los n-­‐1 discos que están en la torre auxiliar a la torre destino, usando como torre auxiliar la torre origen inicial. Casos simples ¿Cuándo puedo solucionar el problema directamente? Cuando solamente he de mover un disco, no he de apartar nada no usar la torre auxiliar: lo muevo directamente. Escribiendo la solución en Java La función tendrá tres parámetros: • numDisks, que será el número de discos a mover • from, nombre de la torre de partida • to, nombre de la torre de destino • using, nombre de la torre auxilar 1 public void solve(int numDisks, 2 String from, 3 String to; 4 String using) { 5 6 if ( numDisks == 1 ) { 7 println(“Move disk from “ + from + “ to “ + to); 8 } else { 9 solve(n-­‐1, from, using, to); 10 println(“Move disk from “ + from + “ to “ + to); 11 solve(n-­‐1, using, to, from); 12 } 13 } Visualización de las llamadas Por ejemplo, para 3 discos que se quieren mover de la torre “A” a la “B” usando “C” como auxiliar haríamos la llamada solve(3, “A”, “B”, “C”) que escribiría: Move disk from A to B Move disk from A to C Move disk from B to C Move disk from A to B J.M. Gimeno y J.L. González 38 Programación 2 Curso 2011/2012 Move disk from C to A Move disk from C to B Move disk from A to B Si hacemos un gráfico de las llamadas que se producen nos queda el siguiente árbol20: 3,"A","B","C" A->B 2,"A","C","B" 2,"C","B","A" A->C C->B 1,"A","B","C" 1,"B","C","A" 1,"C","A","B" 1,"A","B","C" A->B B->C C->A A->B Dónde además de indicar dentro de los cuadrados los parámetros que se pasan en la llamada, he indicado de la forma Origen-­‐>Destino, fuera de las llamadas, lo que se escribe en la pantalla. Simplificando la solución De cara a pensar la solución ha sido conveniente considerar que el caso base es cuando numDisks es 1. Pero si miramos la solución obtenida vemos que podríamos simplificar las cosas considerando como caso más simple cuando numDisks es 0. ¡En tal caso, no hay que hacer nada para mover los 0 discos! El código de la función quedaría ahora como: 1 public void solve2(int numDisks, 2 String from, 3 String to; 4 String using) { 5 6 if ( numDisks >= 1 ) { 7 solve2(n-­‐1, from, using, to); 8 println(“Move disk from “ + from + “ to “ + to); 9 solve2(n-­‐1, using, to, from); 10 } 11 } 20 En este caso no mostraremos los resultados ya que las llamadas a la función no devuelven nada, simplemente se escribe en la salida. Para que ocupe menos espacio, solamente mostramos el valor de los tres parámetros. J.M. Gimeno y J.L. González 39 Programación 2 Curso 2011/2012 Recursividad múltiple Fijaos que en este caso la descomposición del caso no simple ha dado lugar a dos llamadas recursivas. No hay ningún problema. Mientras las llamadas se hagan sobre datos más pequeños, no hay limitación alguna en su cantidad. Una cuestión importante en este caso es que, a diferencia de los anteriores ejemplos, no existe una aproximación iterativa evidente al problema (y, una vez vista, la solución recursiva es muy clara). Otro aspecto interesante de este problema es que muestra la potencia de la recursividad: el hecho de que podamos usar llamadas recursivas, hace que en la solución podamos utilizar operaciones mucho más potentes que las disponibles inicialmente. En este caso, no tan solo disponemos de la posibilidad de mover un disco (operación básica), sino que podemos usar una operación que permite mover varios discos a la vez (propia función que estamos diseñando). Como curiosidad final Un aspecto curioso, que de alguna manera conecta de forma palindrómica con el inicio del tema es que si dibujamos las configuraciones posibles de los discos como vértices de un grafo y los unimos cuando es posible pasar de una a otra a través de un movimiento válido, obtenemos el triángulo de Sierpinski con el que comenzábamos el tema. Para un disco (caso simple): ) C" " , B" " ", A " A 1, ( e A%>B lv so B 1 ABC Para dos discos (caso recursivo): J.M. Gimeno y J.L. González 1 ABC C ''1 ABC 40 so lv e( 1, so "C lv ", e( "B 2, ", "A "A ", ") "B ", so "C lv ") e( 1, "A ", "C ", "B ") Programación 2 Curso 2011/2012 AA CA BB $1 $2 ABC BA 21 ABC 2$1 ABC A->B CB 1 2 ABC $21 ABC 12 ABC AB 1$2 ABC AC BC $12 ABC CC $$1 $$2 ABC J.M. Gimeno y J.L. González 41 Programación 2 Curso 2011/2012 10. Número de particiones de un número natural Un conjunto de números naturales >0 es una partición de un número dado n, si la suma de dichos números es n. Es decir: 𝑎! , 𝑎! , … , 𝑎! ∈ 𝑃𝑎𝑟𝑡𝑖𝑐𝑖𝑜𝑛𝑒𝑠 𝑛 ⟺ ∀! 𝑎! > 0 ∧ 𝑎! = 𝑛 ! Lo que se desea es diseñar e implementar una función tal que, dado un número n, calcule el número de particiones distintas de dicho número. Es decir, 1 public int numPartitions(int nat) { 2 ¿? 3 } El problema será encontrar una solución recursiva del mismo. Intentado buscar una recursión Una estrategia que a veces funciona es la de intentar calcular manualmente los valores que ha de buscar la función y ver si podemos basar nuestro diseño en ese método de cálculo. Otra estrategia que podemos aplicar cuando nos pidan calcular el número de veces que algo se puede hacer, es generar ese algo a contar de forma organizada. Intentemos buscar las particiones de varios números, de la manera más organizada posible, ya que querremos poder luego programarla (y teniendo en cuenta que querremos usar una estrategia recursiva, es decir, una en la que el número der particiones de un número se calcule en base al número de particiones de otros números). Empecemos21: • #(1) = #{{1}}=1 • #(2) = #{{2},{1+1}} = 2 ¿Podemos intentar encontrar aquí una posible regla? La respuesta es que sí, las formas de sumar 2 se pueden dividir en dos categorías: • Una en la que solamente usamos un número, es decir, {2} • Otra en la que usamos varios números, en este caso, {1,1} Fijaos en que en éste último caso, los números que aparecen, son menores que 2. ¿Podemos intentar encontrar una regla para generarlos? 21 Para simplificar la notación usaremos #(n) como el número de particiones del número n y #{c} como la cardinalidad del conjunto c. J.M. Gimeno y J.L. González 42 Programación 2 Curso 2011/2012 Primera aproximación Una posible idea consiste en pensar en que si tenemos que n = p+q, cualquier combinación que sume p, sumada a una combinación que sume q, es una combinación que suma n. Por ejemplo, como 10 = 4 + 6, una combinación que sume 4, por ejemplo {2,2} junto con una combinación que sume 6, por ejemplo {4,1,1}, forman una combinación {2,2,4,1,1} que suma 10. Así que, podemos conjeturar que la recursión será: !!! #(𝑛) = 1 + #(𝑘) ∗ #(𝑛 − 𝑘) 𝑛 > 1 !!! ¿Probamos? • #(2) = 1 + #(1)*#(1) = 1+1*1 = 2 • #(3) = 1 + #(1)*#(2) + #(2)*#(1) = 1 + 1*2 + 2*1 = 5 Pero 3 se descompone como {3}, {2,1}, {1,1,1}, es decir, de 3 formas diferentes. Segunda aproximación Para evitar repeticiones, si ya hemos considerado la descomposición de 3 como 2+1 ya no consideraremos la 1+2, es decir: ! !"# ! #(𝑛) = 1 + #(𝑘) ∗ #(𝑛 − 𝑘) 𝑛 > 1 !!! ¿Probamos? • #(2) = 1 + #(1)*#(1) = 1 + 1*1 = 2 • #(3) = 1 + #(1)*#(2) = 1 + 1*2 = 3 • #(4) = 1 + #(1)*#(3) + #(2)*#(2) = 1*3 + 2*2 = 7 Pero 4 se descompone en, {4}, {3,1},{2,2}, {2,1,1},{1,1,1,1}. El problema es que la {1,1,1,1} y {2,2} se cuentan dos veces. En resumen, nuestro problema consisten en encontrar formas de contar que eviten considerar varias veces la misma descomposición (en otras palabras, descomposiciones del problema que, por construcción, sean independientes entre sí). Cuando esto sucede, una estrategia común es añadir algún parámetro que permita distinguir independizar unos subproblemas de otros. Posibilidad 1: distinguiendo particiones por tamaño Una forma de sistematizar el conteo de particiones es por su tamaño (ya que una misma partición no puede tener dos tamaños diferentes). Si llamamos #(n,k) al número de particiones de n de tamaño k, tenemos que: J.M. Gimeno y J.L. González 43 Programación 2 Curso 2011/2012 ! # 𝑛 = #(𝑛, 𝑘) !!! Por lo que ahora el problema consistirá en buscar una recurrencia que nos permita calcular #(n,k) que es el número de particiones del número n usando k números positivos. Como siempre debemos buscar casos simples y casos recursivos. • #(n,k)=0 cuando k>n • #(n,k)=1 cuando k=n Por lo que nos quedan los casos en los que k<n. Propiedad 1: Particiones de menor tamaño ¿Cómo se obtienen particiones de tamaño k a partir de particiones de tamaño k-­‐1? Sumando un número. Supongamos que tengo una partición de tamaño k-­‐1 del número n, si le sumo el número dk obtengo una partición de tamaño k del número n+dk. Es decir, 𝑛 = 𝑑! , … , 𝑑!!! ⟺ 𝑛 + 𝑑! = 𝑛 = 𝑑! , … , 𝑑! ⟺ 𝑛 − 𝑑! = 𝑑! , … , 𝑑! o también 𝑑! , … , 𝑑!!! Propiedad 2: Particiones con sumandos menores Otra manera de manipular las particiones es cambiar alguno de los sumandos que aparecen en la descomposición. Es decir: 𝑛 = 𝑑! , … , (𝑑! + 𝑑) ⟺ 𝑛 − 𝑑 = 𝑑! , … , 𝑑! o también 𝑛 = 𝑑! , … , 𝑑! ⟺ 𝑛 − 𝑑 = 𝑑! , … , (𝑑! − 𝑑) siempre y cuando 𝑑! > 𝑑. Si todos los sumandos son mayores que d podemos aplicar varias veces la propiedad anterior y obtenemos 𝑛 = 𝑑! , … , 𝑑! ⟺ 𝑛 − 𝑘 ∗ 𝑑 = (𝑑! − 𝑑), … , (𝑑! − 𝑑) Aplicación al caso recursivo ¿Podemos aprovecharnos de esta propiedad para encontrar una recurrencia? (En este punto conviene recordar que queremos obtener subproblemas que no contengan particiones en común, ya que no J.M. Gimeno y J.L. González 44 Programación 2 Curso 2011/2012 queremos volver a caer en el error de sumar una misma partición varias veces). • ¿Cuántas descomposiciones contienen al menos un 1? Por la propiedad 1 tantas como formas de obtener n-­‐1 usando k-­‐ 1 números, es decir, #(n-­‐1, k-­‐1). • ¿Y cuantas no lo contienen? Si no contienen un 1, quiere decir que todos los sumandos son >1, por lo que por la propiedad 1 podemos restar un 1 a cada uno de ellos. Eso quiere decir que de éstas hay tantas como #(n-­‐k, k). • ¿Hay alguna otra posibilidad? No. O bien una partición contiene al menos un 1, o no lo contiene. ¡¡Ya hemos acabado!! Recapitulando Si agrupamos todo lo que hemos desarrollado tenemos que: 0 𝑘 > 𝑛 # 𝑛, 𝑘 = 1 𝑘 = 𝑛 #(𝑛 − 1, 𝑘 − 1) + # 𝑛 − 𝑘, 𝑘 𝑘 < 𝑛 Y expresando todo en Java quedaría: 1 public int numPartitions(int sum) { 2 // Entrada: sum > 0 3 int count = 0; 4 for(int numParts=1; i<=total; ++numParts) { 5 count += numPartitions(sum, numParts); 6 } 7 return count; 8 } 9 10 public int numPartitions(int sum, int numParts) { 11 // Entrada: sum > 0, numParts > 0 12 if ( numParts > sum ) { 13 return 0; 14 } else if ( numParts == sum ) { 15 return 1; 16 } else { 17 return numPartitions(sum-­‐1, numParts-­‐1) + 18 numPartitions(sum-­‐numParts, numParts); 19 } 20 } Fijaos en que, en esta solución, el “tamaño” del problema puede definirse como sum+numParts, por lo que en ambas llamadas recursivas los tamaños de los subproblemas son menores. J.M. Gimeno y J.L. González 45 Programación 2 Curso 2011/2012 Posibilidad 2: limitando el mínimo sumando de una partición Otra forma de sistematizar el conteo, y evitar repeticiones, es fijar el valor mínimo que puede tener un sumando en una partición. Es decir, $(n, k) = número de formas de sumar n con sumandos que son >=k. En este caso el problema original #(n) es exactamente $(n,1), ya que por definición, todos los sumandos serán >=1. Queda como ejercicio buscar la recursión en este caso. J.M. Gimeno y J.L. González 46 Programación 2 Curso 2011/2012 11. Bibliografía • Para la parte de recursividad, “Programación Metódica”, de J.L.Balcázar, Ed. McGraw-­‐Hill (2001). • Para el funcionamiento de las llamadas a función, capítulo 5 del libro “The Art and Science of Java (Preliminary Draft)” de Eric S. Roberts. Lo tenéis disponible en sakai. J.M. Gimeno y J.L. González 47