Laboratorio de Programación. _____________________________________________________________________________ 7 INTRODUCCIÓN A LA RECURSIVIDAD. Contenido _____________________________________________________________________________ 7.1.- Concepto de recursión. 7.2.- Ejemplos de programas recursivos. 7.3.- Búsqueda y ordenación usando recursión. 2.3.1.- Búsqueda. 2.3.2.- Ordenación. Ejercicios _____________________________________________________________________________ 7.1.- CONCEPTO DE RECURSION Se dice que un proceso es recursivo si se puede definir en términos de si mismo, y a dicha definición se le denomina definición recursiva. La recursividad es una nueva forma de ver las acciones repetitivas permitiendo que un subprograma se llame a sí mismo para resolver una versión más pequeña del problema original. La función factorial es una función que se puede definir recursivamente y cuyo dominio es el de los enteros positivos. La función factorial, que se representa con el símbolo de exclamación, se define como: n! = n X (n - 1) X (n - 2) X ... X 1 lo cual significa que n! es igual al producto de todos los enteros no negativos entre n y 1, inclusivos. Consideremos los factoriales de los enteros no negativos del 1 al 5: 1! = 1 2! = 2 X 1 3! = 3 X 2 X 1 4! = 4 X 3 X 2 X 1 5! = 5 X 4 X 3 X 2 X 1 ____________________________________________________________________________________ Introducción a la Recursión. Pág 1 Laboratorio de Programación. _____________________________________________________________________________ Si nos fijamos atentamente en las operaciones anteriores, podremos extraer una propiedad bastante interesante de la función factorial. Empecemos con el 5! , que es igual a: 5! = 5 X (4 X 3 X 2 X 1) pero lo que hay dentro del paréntesis es 4!, es decir (5 - 1)!, lo cual significa que : 5! = 5 X 4! Similarmente, podemos ver que: 4! = 4 X 3! y que 3! = 3 X 2!, y así sucesivamente. Si definimos 0! = 1 entonces n! se puede definir como: 1 n! = n x (n − 1)! î si n = 0 si n > 0 Esta es la definición recursiva de la función factorial, ya que se define en términos de si misma. La primera regla de la definición, o caso base, establece la condición de terminación. Las definiciones recursivas nos permiten definir un conjunto infinito de objetos mediante una sentencia finita. La función factorial recursiva, se puede escribir como: /************************************************** * Autor: * Fecha: Versión: ***************************************************/ #include <iostream.h> #include <stdlib.h> int factorial(int param) { if (param == 0) { return 1 ; } else { return param * factorial(param-1) ; } } int main() { int N; cout << "Introduzca numero:"; cin >> N << endl; ____________________________________________________________________________________ Introducción a la Recursión. Pág 2 Laboratorio de Programación. _____________________________________________________________________________ cout << factorial(N)<< endl; system("PAUSE"); return 0; } Como puedes ver, la función Factorial se llama a si misma para hallar el valor de (N-1)!. A este tipo de recursión, donde un procedimiento se llama a sí mismo, se le denomina recursión explícita o recursión directa. Si un procedimiento P llama a otro Q, Q llama a R, R llama a S, ... , y Z llama de nuevo a P, entonces también tenemos recursión, y a este tipo de recursión se le denomina recursión implícita o recursión indirecta. Consideremos ahora la ejecución de la función recursiva Factorial. En C++, cuando se llama a un procedimiento (o función), se guarda la dirección de la sentencia ‘llamante’ como dirección de retorno, se asigna memoria a las variables locales del procedimiento, y al finalizar la ejecución del procedimiento, se libera la memoria asignada a las variables locales y se devuelve la ejecución al punto en que se hizo la llamada haciendo uso de la dirección de retorno. Pero ¿qué es la dirección de retorno?. Cuando una sentencia escrita en un lenguaje de alto nivel se traduce a código máquina, dicha sentencia suele representar varias líneas de código máquina, es decir, la traducción no es una a una sino una a muchas. Cuando nos referimos a la dirección de retorno, nos estamos refiriendo a la dirección de la instrucción que sigue a la instrucción de llamada al procedimiento. Esta dirección podría estar en medio de la traducción de una sentencia de alto nivel (como en el caso de las llamadas a funciones) o podría ser la primera instrucción de la traducción de la sentencia de alto nivel, así como la propia llamada a la función. Podemos extraer entonces las siguientes conclusiones: 1. Para poder resolver un problema de forma recursiva se debe poder definir en términos de una versión más pequeña del mismo problema. 2. En cada llamada recursiva debe disminuir el tamaño del problema. 3. El diseño de la solución del problema ha de ser tal que asegure la ejecución del caso base y por tanto, el fin del proceso recursivo. Por otro lado, tenemos que estudiar dos conceptos más. Uno es la pila (stack) y el otro son los registros de activación de los procedimientos. Una pila es una forma especial de organizar la memoria en la que la información siempre se añade en la cima y la información que se necesita, también se coge de la cima de la pila, al igual que una pila de hojas de papel. Debido a este comportamiento a las pilas también se las conoce como estructuras ultimo-en-entrarprimero-en-salir (Last-In-First-Out=(LIFO)). El registro de activación de un procedimiento es un bloque de memoria que contiene información sobre las constantes y variables declaradas en el procedimiento, junto con una dirección de retorno. Ahora estamos en disposición de trazar la ejecución del programa para el caso de N = 3. Para llevar a cabo la recursión, las computadoras usan pilas. Al comienzo de la ejecución de un procedimiento, la pila está vacía (Figura 1.a). Cuando la ejecución alcanza la sentencia: cout << factorial(N); se produce una llamada a la función Factorial con N = 3. Esto hace que se cree un registro de activación, y este registro se inserta en la pila (Figura 1b). Cuando se ejecuta la parte ELSE de la función Factorial (cuando la sentencia IF no es TRUE), se produce una nueva llamada a Factorial, pero esta vez el argumento es 3 -1 =2. ____________________________________________________________________________________ Introducción a la Recursión. Pág 3 Laboratorio de Programación. _____________________________________________________________________________ RF RF RF RF N=1 A N=3 (a) RF N=2 N=2 A A N=3 N=3 B B B (b) (c) (d) N=0 A RF N=1 N=1 RF 1 1 A A N=2 N=2 N=2 A A A N=3 N=3 N=3 B (e) B RF (f) 6 (i) RF RF 2 N=3 B B (g) (h) 6 (j) Figura 1. Fases de la evolución de la pila durante la ejecución del programa Esta invocación se insertará en la cima de la pila (Figura 1.c). Esta vez, la dirección de retorno es la “A”. Debido a esta nueva invocación se tiene que volver a ejecutar el programa Factorial. La condición de la sentencia if aún es FALSE, y por tanto se volverá a invocar a la parte ELSE, pero esta vez con 2-1 =1 como argumento. Se inserta en la cima de la pila el registro de activación de esta nueva invocación (Figura 1.d). Una vez más se tiene que volver a ejecutar el procedimiento Factorial , en este caso el if también es FALSE y al ejecutarse la parte else se volverá a llamar a Factorial pero esta vez con argumento 1-1=0 (Figura 1.e). En esta invocación la condición del if se evalúa a TRUE y se devuelve un 1. Lo primero que se hace es almacenar este valor en el registro de la función (RF), y usando la dirección de retorno del registro de activación podremos volver a la sentencia que hizo la llamada. En este instante, como se ha completado la ejecución del procedimiento Factorial(cuando N = 0), el computador no necesitará este último registro de activación, así que se puede eliminar (Figura 1.f). La sentencia a la que retornamos necesita el valor de la función Factorial(cuando N = 0), que se puede obtener del registro de la función (RF). Este valor se multiplicará por el valor de N que es 1. El resultado se copiará en el registro de la función y así concluirá la ejecución de Factorial(cuando N = 1) , además su registro de activación será borrado (Figura 1.g). Los pasos anteriores se volverán a repetir dando lugar a un valor de 2 en el registro de la función y a un solo registro de activación en la pila (Figura 1.h). Esta vez, el valor obtenido del registro de la ____________________________________________________________________________________ Introducción a la Recursión. Pág 4 Laboratorio de Programación. _____________________________________________________________________________ función, que es 2, será multiplicado por N, cuyo valor actual es 3; el resultado, 6, se copiará en el registro de la función, y se usará la dirección “B”, con lo que retornaremos a la localización de la llamada original. Al final, la pila estará de nuevo vacía (Figura 1.j). Debido a la sobrecarga (overhead) que producen las operaciones sobre la pila, la creación y borrado de los registros de activación, los procedimientos recursivos consumen más tiempo y memoria que los programas no recursivos. Pero, algunas veces, debido a la estructura de datos usada en el problema o al planteamiento del mismo, surge de forma natural, y evitar la recursión es bastante más difícil que dar una solución recursiva al problema. 7.2.- EJEMPLOS DE PROGRAMAS RECURSIVOS La recursión, si se usa con cuidado, nos permitirá solucionar de forma elegante algunos problemas. En este apartado se van a resolver varios problemas usando procedimientos recursivos. En cada caso, sugerimos que antes de que veas el algoritmo no iterativo, lo intentes codificar tu mismo. Empezaremos con un problema simple. El término n-ésimo de la sucesión de Fibonacci se puede definir como: 1 F(N ) = F ( N − 1) + F ( N − 2) î si N = 1 ó N = 2 si N > 2 Puedes observar que en esta definición ,al igual que en la definición de la función factorial, tenemos un caso base que nos permite conocer el valor de la función. Esta regla es la condición de terminación. La otra regla es la relación de recurrencia. El ejemplo 1 ilustra un programa que lee varios valores de N y calcula el número correspondiente de la sucesión de Fibonacci. Ejemplo1. Programa que computa el N-ésimo término de la sucesión de haciendo uso de una función recursiva . Fibonacci /************************************************** * Autor: * Fecha: Versión: ***************************************************/ #include <iostream.h> #include <stdlib.h> double Fib(double N) { if (N<= 2) { return 1; } else { return Fib(N-1) + Fib(N - 2); } } ____________________________________________________________________________________ Introducción a la Recursión. Pág 5 Laboratorio de Programación. _____________________________________________________________________________ int main() { double i, N; cout << "Teclea un entero positivo: "; cin>> N << endl; for (i=1; i<=N; i++) { cout << i<< " ésimo término de Fibonacci es: "; cout << Fib(i)<< endl; } system("PAUSE"); return 0; } Analicemos ahora la ejecución de este programa; cuando N = 6 vamos a hallar el número de veces que se invoca la función Fib. Para hallar Fib(6), previamente tenemos que hallar Fib(5) y Fib(4); para hallar Fib(4), tenemos que hallar Fib(3) y Fib(2); y así sucesivamente. Las invocaciones de la función Fib se pueden ilustrar de la siguiente forma: Fib(6) Fib(5) Fib(4) Fib(3) Fib(2) Fib(4) Fib(3) Fib(3) Fib(2) Fib(1) Fib(2) Fib(2) Fib(1) Fib(1) Como se puede observar en el diagrama anterior, para hallar F(6) tenemos que llamar a la función Fib 15 veces. De esas 15 llamadas, tres tienen como argumento el 1, cinco tienen como argumento el 2, tres tienen como argumento el 3, dos tienen el 4 como argumento y con argumentos 5 y 6 sólo hay dos llamadas. Esto significa que el primer número de la serie de Fibonacci, se calcula tres veces, el segundo se calcula 5 veces, etc. Este análisis demuestra el por qué una función recursiva puede llegar a ser una herramienta muy costosa para solucionar un problema. El Ejemplo 2 muestra la versión iterativa, la cual es mucho más eficiente y fácil de escribir. ____________________________________________________________________________________ Introducción a la Recursión. Pág 6 Laboratorio de Programación. _____________________________________________________________________________ Ejemplo2. Programa que computa el N-ésimo término de la sucesión de Fibonacci sin usar recursión. /************************************************** * Autor: * Fecha: Versión: ***************************************************/ #include <iostream.h> #include <stdlib.h> double Fib(double N) { double i, Primero, Segundo, Siguiente; Primero = 1; Segundo = 1; Siguiente = 1; for (i=3; i<=N ; i++) { Siguiente = Primero + Segundo; Primero = Segundo; Segundo = Siguiente; } return Siguiente; } int main() { double i,N; cout << "Teclea un entero positivo: " ; cin >> N; for (i=1; i<=N; i++) { cout << i << " i-esimo termino de Fibonacci es: "; cout << Fib(i); cout << endl; } system("PAUSE"); return 0; } Aunque el programa del ejemplo anterior es más eficiente, en este programa es más difícil ver la relación entre las sentencias de la función Fib y la definición de la función Fibonacci. Una forma de hacer que el procedimiento recursivo sea más eficiente es la de usar algún tipo de memoria que nos permita ‘recordar’ los cálculos que ya hemos realizado y que por tanto no tengamos que repetirlos. Dicha memoria podría ser un array que inicialicemos a cero ya que todos los números que componen la sucesión de Fibonacci son mayores que cero. Podemos usar un array de tamaño 10. Asumimos que el i-ésimo elemento de este array se corresponde con el iésimo término de la sucesión de Fibonacci. Al principio, conocemos los dos primeros términos de la sucesión de Fibonacci. Durante el cálculo del N-ésimo término de la sucesión de Fibonacci, si N es menor o igual que 10, comprobaremos el N-ésimo elemento de dicho array. Si dicho elemento es mayor que cero, simplemente devolveremos ese valor; en otro caso tendremos que calcular el N-ésimo término de la sucesión de Fibonacci, almacenarlo en la posición correspondiente del array y devolverlo al programa principal. Si N es mayor que 10, tendremos que calcular su valor, pero eventualmente dicha computación hará uso de los valores ya precalculados y almacenados en el array. El Ejemplo 3 usa esta técnica para calcular el N-ésimo término de Fibonacci. Esta aproximación ____________________________________________________________________________________ Introducción a la Recursión. Pág 7 Laboratorio de Programación. _____________________________________________________________________________ reduce el número de llamadas al procedimiento; por tanto su ejecución ahorra tiempo a expensas de hacer uso de más espacio de memoria. Ejemplo3. Programa que computa el N-ésimo término de la sucesión de usando un algoritmo recursivo con memoria . Fibonacci /************************************************** * Autor: * Fecha: Versión: ***************************************************/ #include <iostream.h> #include <stdlib.h> const int Tamanyo = 10; typedef int TipoMemoria[Tamanyo]; TipoMemoria Memoria; void DefinirMemoria( TipoMemoria &M) { int i; M[1] = 1; M[2] = 1; for (i=3; i<=Tamanyo; i++) { M[i] = 0; } } int Fib(int N) { int K ; if (N < Tamanyo) { if (Memoria[N] != 0) { return Memoria[N]; } else { K = Fib(N-1) + Fib(N-2); Memoria[N] = K; return K; } } else { return (Fib(N-1) + Fib(N-2)); } } int main() { int N; DefinirMemoria(Memoria); Cout << "¿Termino Fibonacci?: "; Cin >> N ; Cout << endl; if (N > 0) { cout << N; cout << " ésimo término de Fibonacci es: "; cout << Fib(N); } else { cout<<"Error en la entrada. "; ____________________________________________________________________________________ Introducción a la Recursión. Pág 8 Laboratorio de Programación. _____________________________________________________________________________ cout << " N debería ser mayor que cero."; } system("PAUSE"); return 0; } Ahora resolveremos un problema donde la recursión simplifica nuestro programa. El programa consiste en encontrar el equivalente binario de los números decimales. Como su nombre indica, los números decimales se escriben usando diez dígitos (0 al 9) y los números binarios se escriben usando dos dígitos (0,1). La representación binaria de un número decimal se puede hallar fácilmente considerando el siguiente algoritmo: Sea N un número positivo decimal. Hallar N % 2 y N / 2, y almacenamos el primero. Reemplazamos N por N/2. Repetimos el proceso hasta que N sea igual a cero. Ahora, si reescribes los valores almacenados en sentido contrario a como los obtuviste, obtendrás el equivalente binario de N. Por ejemplo: supongamos que N es 23. N N%2 23 11 5 2 1 0 1 1 1 0 1 N/2 11 5 2 1 0 La representación binaria de 23 es 10111 Para resolver este problema sin usar recursión, necesitaremos un array para almacenar los dígitos binarios que vamos calculando, para después poder escribirlos al revés. El problema a la hora de usar arrays es que no conocemos el número de elementos que vamos a usar del array. Veamos, ahora, como nos puede ayudar la recursión. El equivalente binario de 11, que es igual a 23 / 2, es 1011 Así que, si podemos imprimir el equivalente binario de 11, será fácil escribir el equivalente binario del 23; lo único que tenemos que hacer es imprimir 23 % 2. Ahora observemos lo siguiente. El equivalente binario de 5, que es igual a 11 / 2, es 101. Si añadimos 11 % 2 = 1, nos da el equivalente binario del 11. Por tanto, si consideramos la representación binaria de un número como una cadena de dígitos binarios, podemos llegar a la siguiente definición: 0 si N = 0 Representación Binaria de N = 1 si N = 1 Representación binaria de( N / 2)||( N %2) î donde || representa la concatenación. Esta aproximación no requiere arrays y puede ser programada fácilmente. El programa se ilustra en el ejemplo 4. ____________________________________________________________________________________ Introducción a la Recursión. Pág 9 Laboratorio de Programación. _____________________________________________________________________________ Ejemplo4. Programa para hallar el equivalente binario de los números decimales. /************************************************** * Autor: * Fecha: Versión: ***************************************************/ #include <iostream.h> #include <stdlib.h> const int Base = 2; int N; void PrintBinario(int N) { if (N > 0) { PrintBinario(N/Base); Cout << N % Base; } } // Imprime equivalente binario de N/2 int main() { cout << "Introduzca un entero positivo: "; cin >> N ; cout << endl; cout << " El Número Decimal "<< N <<" es igual a "; PrintBinario(N); Cout << " en binario " << endl; system("PAUSE"); return 0; } Un ejemplo simple de la ejecución de este programa sería: Entra un entero positivo: 256 El Número Decimal 26 es igual a 100000000 en binario. Entra un entero positivo: 32767 El Número Decimal 32767 es igual a 111111111111111 en binario. Entra un entero positivo: 23 El Número Decimal 23 es igual a 10111 en binario. Entra un entero positivo: El programa del Ejemplo 4 se puede modificar para hallar la representación de un número decimal en cualquier base B. Debemos notar que, si la nueva base B es mayor que 10, necesitaremos más símbolos además de los dígitos del 1 al 9. Tradicionalmente, se han usado los caracteres A, B, C, D, ..., y así sucesivamente para representar el 10,11, 12, 13, ... . Por ejemplo, el número 30 en decimal es equivalente en hexadecimal (B=16) a 1E, donde E representa el 14. Para manipular estos caracteres en este programa modificado, introducimos un array de caracteres llamado Dígitos. En este nuevo programa, en vez de imprimir el valor de N % Base ____________________________________________________________________________________ Introducción a la Recursión. Pág 10 Laboratorio de Programación. _____________________________________________________________________________ usaremos el valor obtenido, para indexar el array Digitos e imprimir el carácter almacenado en dicho elemento (Ejemplo 5). Ejemplo5. Programa para convertir números decimales a cualquier base hasta la base 16 /************************************************** * Autor: * Fecha: Versión: ***************************************************/ #include <iostream.h> #include <stdlib.h> //Límite superior de la base const int BaseMax = 16; typedef char TDigitos[BaseMax]; int N, NuevaBase; TDigitos Digitos; void DefinirDigitos(TDigitos &Digitos) { char C; int i; C = '0'; for (i=0; i<=9; i++) { Digitos[i] = C; C++; } C = 'A'; for (i=10; i<=BaseMax-1; i++) { Digitos[i] = C; C++; } } void PrintNuevaBase(int N,int NuevaBase) { if (N > 0) { PrintNuevaBase(N / NuevaBase, NuevaBase); cout<<Digitos[N % NuevaBase]; } } int main() { DefinirDigitos(Digitos); Cout << "Introduzca un entero positivo: "; Cin >> N; Cout << endl; Cout << "Entra la nueva base (2-16): "; Cin >> NuevaBase; Cout << endl; Cout << " El Número Decimal "<<N<<" es igual a "; PrintNuevaBase(N, NuevaBase); Cout << " en base "<<NuevaBase<<endl; system("PAUSE"); return 0; } Un ejemplo simple de la ejecución de este programa sería: ____________________________________________________________________________________ Introducción a la Recursión. Pág 11 Laboratorio de Programación. _____________________________________________________________________________ Entra un entero positivo: 123 Entra la nueva base (2-16): 16 El Número Decimal 123 es igual a 7B en base 16. Entra un entero positivo: 32676 Entra la nueva base (2-16): 8 El Número Decimal 32676 es igual a 77644 en base 8. Entra un entero positivo: 32676 Entra la nueva base (2-16): 10 El Número Decimal 32676 es igual a 32676 en base 8. Entra un entero positivo: 32676 Entra la nueva base (2-16): 12 El Número Decimal 32676 es igual a 16AB0 en base 12. 7.3.- BUSQUEDA Y ORDENACION USANDO RECURSION 7.3.1.- Búsqueda Asumimos que tenemos un array llamado Ordenado de N elementos y que está ordenado en orden creciente. Consideremos el problema de determinar si un elemento dado, Item, está presente en dicho array. Si Item está presente en la lista, nos gustaría determinar su localización (Loc). Si Item no está presente en la lista, deberemos poner Loc a cero. Este problema se puede simplificar si reducimos el tamaño del array. Una forma de hacer esto es dividir el array en dos partes cogiendo el elemento X-ésimo del array, donde X es un índice seleccionado al azar entre 1 y N. Se nos presentan así tres posibilidades:. 1. Ordenado[X] = Item : En este caso, ya hemos encontrado el Item en el array, y ponemos Loc a X. 2. Ordenado[X] > Item : No podemos decir si el Item está o no en el array, pero ya que los elementos del array están en orden creciente, podemos ignorar la segunda parte del array;la siguiente vez, consideraremos sólo los elementos desde el primero al X-1. 3. Ordenado[X] < Item : No podemos decir si el Item está o no en el array, pero ya que los elementos del array están en orden creciente, podemos ignorar la primera parte del array; la siguiente vez, consideraremos sólo los elementos desde el X+1 al N. En los casos 2 y 3, el tamaño del array se reduce, y el mismo proceso, seleccionar un índice aleatorio X entre los valores del índice válidos (entre 1 y X-1 si se usa la primera parte; entre X +1 y N se usa la segunda parte) y comparar ese elemento con Item, se puede repetir sobre una porción más pequeña del array Ordenado. Eventualmente el tamaño del array bajo consideración será cero si el Item no está en el array; en otro caso se habrá localizado previamente. En vez de usar un índice X aleatorio , se suele coger la mitad del array. A este método se le denomina búsqueda binaria porque el elemento intermedio de un array divide al array en dos partes iguales o casi iguales (Si N es par). El programa principal del Ejemplo 6 tiene un programa principal que primero lee el número de elementos que va a contener el array Ordenado y que después lee los elementos. Dentro del bucle WHILE que sigue a la parte de entrada, el programa lee el Item a localizar y después invoca al procedimiento Buscar, que requiere cinco parámetros. Estos parámetros son, en orden: 2.Array Ordenado. ____________________________________________________________________________________ Introducción a la Recursión. Pág 12 Laboratorio de Programación. _____________________________________________________________________________ 3.Primero: Cota inferior de la parte del array Ordenado en el que estamos interesados. 4.Ultimo: Cota superior de la parte del array Ordenado en el que estamos interesados. 5.Item: El valor que estamos buscando. 6.Loc: Si se encuentra el Item, Loc contendrá un índice a la posición dentro del array donde está el Item, sino contendrá cero. El procedimiento Buscar , después de comparar con el elemento mitad de la porción actual del array que estamos tratando, decide qué parte de la porción actual hay que ignorar. Si el elemento intermedio es menor que el Item, se ignora la primera parte, y se llama otra vez a Buscar con las cotas inferiores y superiores configuradas con los valores Mitad+1 y Ultimo. En otro caso, se llaman con las cotas Primero y Mitad-1 como cotas inferior y superior. Ejemplo6. Búsqueda Binaria recursiva /************************************************** * Autor: * Fecha: Versión: ***************************************************/ #include <iostream.h> #include <stdlib.h> // Tamaño del array const int Tamanyo = 100; typedef int Lista[Tamanyo]; Lista Ordenado; int N, i, Loc,Item; void Buscar(Lista Ordenado, int Primero, int Ultimo, int Item, int &Loc) { int Mitad; if (Primero > Ultimo) { Loc = 0; } else { Mitad = (Ultimo + Primero) / 2; if (Ordenado[Mitad] == Item) { Loc = Mitad; } else { if (Ordenado[Mitad] < Item) { Buscar(Ordenado, Mitad+1, Ultimo, Item, Loc); } else { Buscar(Ordenado, Primero, Mitad-1, Item, Loc); } } } } int main() { // Lectura del array Ordenado cout << "Número de elementos en Ordenado: "; cin >> N; cout << endl; if (N > Tamanyo) { N = Tamanyo; // No se pueden leer más de Tamanyo elementos ____________________________________________________________________________________ Introducción a la Recursión. Pág 13 Laboratorio de Programación. _____________________________________________________________________________ } cout<< "Introduzca los elementos: "; for ( i = 0; i<=(N-1);i++) { cin >> Ordenado[i] ; } cout << endl; cout << "Item a Buscar: "; cin >> Item ; Buscar(Ordenado, 0, N-1, Item, Loc); cout << Item ; if (Loc == 0) { cout << " no está en la lista."; } else { cout << " es igual al "; cout << Loc; cout << "-esimo elemento de la lista."; } cout << endl; system("PAUSE"); return 0; } 7.3.2.- Ordenación La ordenación está muy relacionada con la mezcla. Mezclar es un término usado por un proceso que combina dos listas ordenadas preservando el orden. Examinemos la mezcla de dos listas ordenadas (asumiremos que nuestras listas contienen enteros y están ordenadas de forma creciente) antes de ver el papel de la mezcla en la ordenación. El proceso de mezcla se puede realizar seleccionando sucesivamente el valor más pequeño que aparece en cualquiera de las dos listas y moviendo dicho valor a una nueva lista, creando así una lista ordenada. Por ejemplo, mientras mezclamos las siguientes dos listas: Lista 1: 13 27 58 Lista 2: 8 36 podemos obtener las siguientes trazas: Lista 1: 13 27 58 Lista 2:36 Nueva Lista : 8 Lista 1: 27 58 Lista 2:36 Nueva Lista : 8 13 Lista 1: 58 Lista 2:36 Nueva Lista : 8 13 27 Lista 1: 58 Lista 2 : Nueva Lista : 8 13 17 36 ____________________________________________________________________________________ Introducción a la Recursión. Pág 14 Laboratorio de Programación. _____________________________________________________________________________ Lista 1: Lista 2: Nueva Lista : 8 13 17 36 58 Si estas dos listas se almacenan en el mismo array, dicho array tendrá el siguiente aspecto: Lista: 13 Primero 27 58 8 36 Mitad Ultimo donde los elementos Primero a Mitad-1 representan a la Lista1, mientras que los elementos Mitad a Ultimo representa a la Lista2. En el proceso de mezcla, se va a necesitar un área temporal (Temp) ,del mismo tamaño que la Lista, para almacenar los resultados. Al final, Temp se volverá a copiar en la Lista. El procedimiento para realizar la operación de mezcla se ilustra a continuación: void MezclaSimple(Lista &K, int Primero, int Mitad, int Ultimo) // Mezcla dos listas que están ordenadas en el mismo array { Lista Temp; int Indice1, Indice2, IndiceMezcla, i; Indice1 = Primero; Indice2 = Mitad; // Inicializa el número de elementos en Temp IndiceMezcla = 0; /* Mientras haya elementos en cualquier parte de las listas ordenadas, mezclalos */ while((Indice1 < Mitad) && (Indice2<= Ultimo)) { IndiceMezcla++; if (K[Indice1] <= K[Indice2]) { Temp[IndiceMezcla] = K[Indice1]; Indice1++; } else { Temp[IndiceMezcla] = K[Indice2]; Indice2++; } } /* En este punto o la primera parte o la segunda parte está agotada. Copiamos cualquier parte remanente de la segunda parte a Temp. */ while (Indice2 <= Ultimo) { IndiceMezcla++; Temp[IndiceMezcla] = K[Indice2]; Indice2++; } // Copiamos cualquier parte que quede de la primera parte a Temp ____________________________________________________________________________________ Introducción a la Recursión. Pág 15 Laboratorio de Programación. _____________________________________________________________________________ while (Indice1 < Mitad) { IndiceMezcla++; Temp[IndiceMezcla] = K[Indice1]; Indice1++; } /* Copiamos Temp en el array K*/ for (i=1 ;i<=IndiceMezcla; i++) { K[Primero+i-1] = Temp[i]; } } Este proceso de mezcla simple se puede generalizar para mezclar K listas ordenadas en una sola lista ordenada. A este proceso se le llama mezcla múltiple. La mezcla múltiple se puede llevar a cabo realizando una mezcla simple, repetidamente. Por ejemplo, si tenemos ocho listas para mezclar, podemos mezclarlas a pares para obtener así cuatro listas ordenadas. Estas listas, a su vez, se pueden mezclar a pares para obtener dos listas ordenas. Y una operación de mezcla final nos dará la lista ordenada. Veamos ahora como se puede usar la mezcla para ordenar un array de ocho elementos. Asumimos que la lista contiene: 81 8 13 7 79 54 1 5 Dividamos esta lista en dos partes iguales (una barra vertical indica el punto de división). Obtenemos: 81 8 13 7 | 79 54 1 5 Como no están ordenadas, no podemos mezclar estas dos listas. Dividamos la primera sublista en dos piezas: 81 | 8 13 7 | 79 8 | 13 7 | 54 1 5 54 1 Una vez más: 81 | 79 5 La primeras dos sublistas contienen sólo un elemento, y obviamente una lista que contiene un solo elemento está ordenada(!). Esto significa que se pueden mezclar estas dos obteniendo 8 81 | 13 7 | 79 54 1 5 Ahora, si la parte B de esta lista se divide en dos partes y las listas resultantes formadas por elementos simples se mezclan, obtenemos: 8 81 | 7 13 | 79 54 1 5 Como las dos primeras listas están ahora ordenadas, podemos mezclarlas : 7 8 13 81 | 79 54 1 5 Como puedes ver, la primera mitad del array ya está ordenada. Si aplicamos la misma técnica a la segunda sublista y después las mezclamos con la primera, estará ordenado todo el array. En el ____________________________________________________________________________________ Introducción a la Recursión. Pág 16 Laboratorio de Programación. _____________________________________________________________________________ Ejemplo 7, el procedimiento OrdenacionPorMezcla divide el array de entrada en sublistas más y más pequeñas hasta que alcancen el tamaño de un elemento. Entonces, usando el procedimiento MezclaSimple, se mezclan esas sublistas, obteníendose la lista ordenada. Ejemplo7. Programa recursivo para la ordenación por mezcla. /************************************************** * Autor: * Fecha: Versión: ***************************************************/ #include <iostream.h> #include <stdlib.h> // Tamaño del array const int Tamanyo = 10; typedef int Lista[Tamanyo]; void MezclaSimple(Lista &K, int Primero,int Mitad,int Ultimo) // Mezcla dos listas que están ordenadas en el mismo array // El cuerpo del procedimiento anterior void OrdenacionPorMezcla(Lista &K,int Primero, int Ultimo) { // Ordena el array K usando ordenación por mezcla recursiva int Tam, Mitad; // Determina el tamaño de la porción actual Tam = Ultimo - Primero + 1; if (Tam <= 1) // No más divisiones { return; } else { // Divide en dos y ordena Mitad = Primero + Tam / 2 -1; OrdenacionPorMezcla(K, Primero, Mitad); OrdenacionPorMezcla(K, Mitad+1, Ultimo); // Mezcla ambas mitades ordenadas MezclaSimple(K, Primero, Mitad+1,Ultimo); } } int main() { Lista Desordenado; int Elemento ,Primero, Segundo, Tercero, i, K; // Entrada de datos desordenados i = 0; cin >> K; while(i < Tamanyo-1) { i++; Desordenado[i] = K; Cin >> K; } Tercero = i; OrdenacionPorMezcla(Desordenado, 1, Tercero); // Imprime el array ordenado for( i = 1; i<=Tercero; i++) { cout<<Desordenado[i]<<endl; } system("PAUSE"); return 0; } ____________________________________________________________________________________ Introducción a la Recursión. Pág 17 Laboratorio de Programación. _____________________________________________________________________________ EJERCICIOS 1.- ¿ Cual será la salida del siguiente programa ?. Intenta resolverlo primero sobre papel. #include <iostream.h> #include <stdlib.h> void ImprimeResto() { const char Punto='.'; char X; cin >> X; if (X != Punto) { ImprimeResto(); } cout << X; } int main() { ImprimeResto(); cout << endl; system("PAUSE"); return 0; } si la entrada es : Si algo puede salir mal, saldrá mal. 2.- ¿ Cual será la salida del siguiente programa ?. Intenta resolverlo primero sobre papel. #include <iostream.h> #include <stdlib.h> int Uno(int A, int B) { if (B != 0) { A++; B--; return Uno(A,B); } else { return A ; } } int main() { cout << Uno(5,6) << endl; system("PAUSE"); return 0; } ____________________________________________________________________________________ Introducción a la Recursión. Pág 18 Laboratorio de Programación. _____________________________________________________________________________ 3.- La relación recurrente 1 x n = x * x n −1 n +1 î x /x si n = 0 si n > 0 si n < 0 define x como la potencia n-ésima de todos los enteros. Escribir un procedimiento recursivo llamado Potencia que calcule x a la n-ésima potencia para un entero x. ____________________________________________________________________________________ Introducción a la Recursión. Pág 19 Laboratorio de Programación. _____________________________________________________________________________ 4.- El máximo común divisor de dos enteros positivos M y N se puede definir como: N MCD( M , N ) = MCD( N , M ) MCD( N , M mod N ) î si N ≤ M y M mod N = 0 si M < N en otro caso Escribir una función recursiva que permita calcular el máximo común divisor de dos enteros positivos. 7.- En general, el N-ésimo término de la sucesión de Fibonacci, se define como: A F ( A, B, N ) = B F ( B, A + B, N − 1) î si N = 1 si N = 2 si N > 2 Donde A y B son los números que originan la secuencia. Escribir un programa que compute el Nésimo término de la sucesión de Fibonacci usando diferentes números para originar la secuencia. 8.- La función Q se define como: 1 Q( N ) = Q ( N − Q ( N − 1)) î si N ≤ 2 si N > 2 Escribir dos procedimientos, uno recursivo y otro no recursivo, para computar el valor de esta función para varios valores de N. ¿Puedes escribir una función que haga uso de más memoria para así aumentar la velocidad de los cálculos recursivos?. 9.- El valor del N-ésimo término del polinomio de Legendre se puede calcular usando las siguientes fórmulas: P0(x) = 1 P1(x) = x PN ( x ) = 2* N − 1 N −1 * x * PN −1 ( x ) − * PN − 2 ( x ) N N Escribir una furnción recursiva que compute el N-ésimo término del polinomio de Legendre. ____________________________________________________________________________________ Introducción a la Recursión. Pág 20 Laboratorio de Programación. _____________________________________________________________________________ 10.- Escribir una función recursiva para computar la función de Ackermann, la cual se define como: N + 1 si M = 0 A(M, N) = A(M − 1, N) si M ≠ 0 and N = 0 A(M -1, A(M, N -1)) si M ≠ 0 and N ≠ 0 î donde M y N son números cardinales. 11.- Para cualesquiera enteros no negativos N y K, donde 0<=K<=N, el coeficiente binomial C(N,K) se define como: C( N , K ) = N! K !* ( N − K )! Esta fórmula verifica que: 1. C(N,K) = C(N, N-K); es decir, la lista de coeficientes binomiales es simétrica con respecto a su valor central. 2. C(N,N) = 1 y por simetría C(N,0) = 1. 3. C(N,N-1)=N y por simetría C(N,1) = N. El problema para computar C(N,K) haciendo uso de la fórmula anterior estriba en que N! crece muy rápidamente.Por lo que necesitamos un método alternativo para calcular dichos coeficientes. Para ello podemos hacer uso del hecho de que para cualquier K que satisfaga 1<=K<=N-1: C( N , K ) = C ( N , K − 1) * ( N − K + 1) K Escribir un programa que calcule el coeficiente binomial C(N,K) para varios N y K. 12.- Un programa bastante popular es hacer que un ratón encuentre un queso de cabrales en un laberinto. Vamos a suponer que el laberinto es un recinto rectangular dividido en cuadrados, estando cada cuadrado ocupado por un obstáculo o libre. El perímetro del rectángulo está ocupado por obstáculos, excepto en una o más salidas. Comenzamos en algún lugar dentro del laberinto, y tenemos que encontrar el camino de salida. Podemos movernos de cuadrado en cuadrado en cualquier dirección (excepto diagonalmente), pero no podemos atravesar un obstáculo. Podemos representar el laberinto en un ordenador mediante un array de caracteres ( o booleano) bidimensional, por ejemplo: BBBBBBBBBBBB B..BBB....BB BB...B.BBB.B B..B.B.B...B BBB....BBBBB B...BBB....B B.B.....BB.B BBBBBBBBBB.B Construir un programa recursivo que tenga como entrada la posición del ratón dentro del laberinto y que determine una de sus posibles salidas. ____________________________________________________________________________________ Introducción a la Recursión. Pág 21 Laboratorio de Programación. _____________________________________________________________________________ (* El siguiente ejercicio no se debe incluir en la relación de problemas *) 12.- El método de ordenación rápida (QuickSort) es un método de ordenación rápido y relativamente moderno desarrollado por Hoare. Es esencialmente un esquema de inserción/intercambio que, en cada etapa, coloca al menos un elemento en su sitio adecuado. Saber que este elemento está bien posicionado se usa para reducir el número de comparaciones que se necesitan para posicionar el resto de elementos. La filosofía del QuickSort consiste en dividir la lista de elementos tomando como referencia un determinado elemento, llamado pivote, de forma que, en las listas resultantes de la división, todas los elementos que precedan al pivote sean menores o iguales al mismo y todos los elementos que estén después del pivote sean mayores o iguales que el mismo. Izqda IFin Dinicio Dcha . . . ≤pivote ≥pivote pivote Como resultado de la partición, el pivote está correctamente colocado. El mismo proceso de partición se aplica entonces a las dos sublistas a ambos lados del pivote. De esta forma la lista original es sistemáticamente reducida a una serie de sublistas, cada una de longitud uno, que están, por supuesto, ordenadas y, más importante, correctamente posicionadas en relación a las otras. La característica más importante de este algoritmo es la partición. La entrada seleccionada como el pivote suele ser la primera entrada. Habiendo elegido el pivote, el algoritmo busca en la lista ,desde dicho pivote hasta el final, el primer elemento que no pertenezca a ese lado del pivote. Estos dos elementos se intercambian, y se reanuda la búsqueda con los elementos adyacentes. Cuando coinciden dos búsquedas, el pivote está posicionado entre dos sublistas en orden para mantener la ordenación relativa. Dados los índices de los elementos más a la izquierda (Izquierda) y a la derecha (Derecha) de la sublista a ordenar, el algoritmo queda como: SI Izquierda < Derecha parte Lista[Izquierda] a Lista[Derecha] en dos secciones más pequeñas de forma que: Lista[Izquierda] ...Lista[Final] <= Lista[Pivote] <= Lista[DechaInicio] ... Lista[Derecha] ordena Lista[Izquierda]...Lista[Final] ordena Lista[DechaInicio]...Lista[Derecha] Escribir un programa que ordene una lista de enteros usando este método. ____________________________________________________________________________________ Introducción a la Recursión. Pág 22