Apuntes teóricos – Sintaxis y Semántica del Lenguaje Año 2013 Prof. Lic. María Gabriela Cerra RECURSION Veremos un nuevo mecanismo, una nueva técnica de diseño, para resolver problemas: LA RECURSIÓN. La recursión es una alternativa a la iteración o repetición, y aunque en tiempo de ejecución y espacio de memoria ocupada la solución recursiva es menos eficiente que la iterativa, existen numerosas situaciones en las que la recursividad provee una solución más simple y natural a un problema. La recursividad es una herramienta potente y útil que la aplicaremos: - en la resolución de problemas que tengan naturaleza recursiva - en reemplazo de la iteración cuando el lenguaje de programación elegido NO posea ninguna estructura de control repetitiva - cuando la solución iterativa sea de gran complejidad respecto de la solución recursiva ¿Qué es recursión? Es una técnica que realiza una tarea T, haciendo otra tarea T ‘ de la misma naturaleza que T, pero en algún sentido más pequeña que la original . De esta forma, un algoritmo recursivo expresa la solución de un problema de tamaño N, en términos de una llamada o invocación a sí mismo. Cada invocación se plantea sobre problemas de igual naturaleza que el original, pero de un tamaño menor que N. Al ir reduciendo progresivamente la complejidad del problema a resolver, llegará un momento en que la resolución será trivial y directa. Esta última situación se denomina caso base. La forma en que se va reduciendo el tamaño del problema original asegura que el caso base se alcance y por consiguiente, se llegue a la solución esperada. Un algoritmo recursivo (procedimiento o función) presenta las siguientes 3 características: - se autoinvoca dentro de su propia definición, es decir se llama a sí mismo dentro de su cuerpo (al menos una vez) - presenta al menos un caso base o especial, donde se llevan a cabo acciones distintas que aseguran la finalización del proceso y la obtención de la solución - en cada autoinvocación se resuelve un problema de igual naturaleza que el original pero de menor tamaño. La reducción del tamaño del problema asegura que se alcance el caso base. Se deben hacer cuatro preguntas para construir una solución recursiva: 1.- Cómo representar el problema T en términos de un problema T ’ del mismo tipo, pero más pequeño. 1 Apuntes teóricos – Sintaxis y Semántica del Lenguaje Año 2013 Prof. Lic. María Gabriela Cerra 2.- Cómo reducir, en cada llamada recursiva, el tamaño del problema. 3.- Qué instancia del problema sirve como caso base. 4.- Qué manera de reducir el problema nos asegura que siempre será alcanzado el caso base. Para analizar esta técnica de diseño desde el punto de vista del uso de memoria veamos el siguiente ejemplo. Cálculo del Factorial, se elige porque es fácil de entender y se ajusta perfectamente al modelo dado. Definición iterativa del factorial (con n entero positivo): FACT (n) = n * (n-1) * (n-2) * ....* 1 FACT (0) = 1 y el factorial de un número negativo es indefinido. Todos sabemos construir una solución iterativa para este problema basándonos en esta definición. También podemos construir un solución recursiva del factorial: FACT (n) = n * FACT (n - 1) Esta definición carece de un elemento importante, el caso base. Como en el diccionario, un caso debe definirse diferente de todos los demás, de lo contrario la recursión nunca se detiene. El caso base en la recursión es el Factorial (0) el que se define simplemente como 1. Dado que n se asume positivo, decrementando en 1 cada vez que se llama al factorial se sabe que siempre será alcanzado el caso base. Definición recursiva del factorial: 1 Factorial (n) si n = 0 n * Factorial (n -1) si n > 0 Estudiaremos los mecanismos de ejecución de esta función recursiva. Si calculamos el Factorial (3), usando esta definición: Factorial (3) = 3 * Factorial (2) 2 Apuntes teóricos – Sintaxis y Semántica del Lenguaje Año 2013 Prof. Lic. María Gabriela Cerra Factorial (2) = 2 * Factorial (1) Factorial (1) = 1 * Factorial (0) Factorial (0) = 1 Se alcanzó el caso base. La aplicación de la definición recursiva se detiene y la información obtenida se puede usar para responder la pregunta original ¿factorial (3)? Dado que: Factorial (0) = 1 entonces, reemplazando en cada llamada Factorial (1) = 1 * 1 = 1, entonces Factorial (2) = 2 * 1 = 2, entonces Factorial (3) = 3 * 2 = 6 Es fácil construir una función a partir de la definición recursiva: Function Factorial (n: entero) : entero hacer si (n=0) entonces Factorial :=1 sino Factorial := n * Factorial (n-1) finsi finhacer finfunción Esta función responde al modelo de solución recursiva. 1) La función Factorial se llama a sí misma 2) En cada llamada recursiva el número cuyo factorial se calcula se disminuye en 1. 3) El Factorial (0) se maneja en forma distinta. Este caso base no produce una llamada recursiva. Tipos de recursión. La recursión puede ser: Directa: si el algoritmo recursivo presenta una o más llamadas recursivas en su propio cuerpo. o Simple: si presenta una sola llamada recursiva El factorial de un nº entero positivo: Fact(0):=1 Fact(N):= N* fact(N-1), si N>0 o Múltiple: si presenta dos o más llamados recursivos La serie de Fibonacci: F(0):=0 F(1):=1 F(N):= F(N-1) + F(N-2), si N>1 3 Apuntes teóricos – Sintaxis y Semántica del Lenguaje Año 2013 Prof. Lic. María Gabriela Cerra o Anidada: si presenta un llamado recursivo como argumento de una llamada recursiva La función de Ackerman: A(m,n):= n +1, si m=0 A(m,n):=A(m-1,1), si n=0 A(m,n):=A(m-1, A(m,n-1)), si m>0 y n>0 Indirecta o cruzada: si el llamado recursivo no aparece en su cuerpo sino que se da a través de la invocación de un algoritmo auxiliar. Es decir, si un algoritmo A invoca a otro algoritmo B y recíprocamente, el algoritmo B invoca al algoritmo A. Procedimiento A Hacer …. B …. finhacer finProcedimiento Procedimiento B Hacer … A …. … finhacer finProcedimiento ¿Cómo funciona la recursión en memoria?. Traza de ejecución: método de la caja En principio la evaluación de un algoritmo recursivo no es más difícil que la evaluación de cualquier otro algoritmo. En la práctica, sin embargo, el seguimiento puede irse de las manos, para ello introducimos un método sistemático, llamado método de la caja, para seguir la ejecución de funciones o procedimientos recursivos. Cada caja muestra la representación en memoria de la activación de una unidad de programa y se denomina Registro de Activación de la unidad. Este contiene toda la información necesaria para que dicha unidad pueda llevar a cabo su ejecución: datos + información de control * Cada llamada recursiva hecha al subprograma en el transcurso de la ejecución va a generar una caja o registro de activación, que contendrá el 4 Apuntes teóricos – Sintaxis y Semántica del Lenguaje Año 2013 Prof. Lic. María Gabriela Cerra ambiente local del subprograma. Esto es, las variables y parámetros que se crean en el llamado y se destruyen cuando se termina la ejecución. Cada caja contendrá entonces: -El valor de los parámetros formales. -Las variables declaradas localmente (no existen en este ejemplo). -Un lugar para el valor a ser retornado por cada llamada recursiva generada a partir de la caja corriente (marcada con el rótulo). -El valor de la función misma. * Cuando se crea una nueva caja se dibuja una fecha desde la caja donde se hizo la llamada hacia la nueva. Sobre la flecha se pone el nombre de la llamada (rótulo) para indicar a donde se debe retornar. * Comenzar la ejecución del cuerpo del subprograma con los valores correspondientes al ámbito local de la caja corriente. Cuando termina la ejecución de la caja corriente y se vuelve hacia atrás en las cajas, la anterior es ahora la corriente y el nombre en la flecha indica el lugar a donde se debe retornar y continuar la ejecución del subprograma. El valor calculado se coloca en el ítem apropiado en la caja corriente. Trabajando sobre este concepto vamos a seguir la Traza de Ejecución que resulta de calcular el Factorial (3): Llamada original: Factorial (3) comienza la ejecución n =3 A: Factorial (n-1) = ? Factorial =? rótulo En el punto A, se hace una llamada recursiva, y la nueva invocación de la función Factorial comienza su ejecución. n =3 A: Factorial (n-1) = ? Factorial =? n =3 A: Factorial (n-1) = ? Factorial (n-1) =? A A n =2 A: Factorial (n-1) = ? Factorial =? n =2 A: Factorial (n-1) = ? Factorial =? En el punto A, nuevamente se hace una llamada recursiva, y la nueva invocación de la función Factorial comienza su ejecución. A n = 13 A: Factorial (n-1) = ? Factorial (n-1) =? Nuevamente, en el punto A, se hace una llamada recursiva, y la nueva invocación de la función Factorial comienza su ejecución. 5 Apuntes teóricos – Sintaxis y Semántica del Lenguaje Año 2013 n =3 A: Factorial (n-1) = ? Factorial (n-1) =? A n =2 A: Factorial (n-1) = ? Factorial =? Prof. Lic. María Gabriela Cerra A n = 13 A: Factorial (n-1) = ? Factorial (n-1) =? A n = 03 A: Factorial (n-1) = ?1 Factorial Factorial (n-1) =? Se alcanza el caso base, por lo tanto la invocación de Factorial se completó y pueden comenzar a resolverse las cajas. Se vuelve a la caja anterior y se retorna el valor pendiente al punto del llamado (marcado con el rótulo A) n =3 A: Factorial (n-1) = ? Factorial (n-1) =? A n =2 A: Factorial (n-1) = ? Factorial =? n =3 A: Factorial (n-1) = ? Factorial (n-1) =? A n =2 A: Factorial (n-1) = 1 Factorial =2 n =3 A: Factorial (n-1) = 2? Factorial (n-1) = 6? A n =2 A: Factorial (n-1) = 1 Factorial =2 A A A n = 13 A: Factorial (n-1) = 1? Factorial (n-1) = 1? n = 13 A: Factorial (n-1) = 1? Factorial (n-1) = ?1 n = 13 A: Factorial (n-1) = 1? Factorial (n-1) = 1? A A A n = 03 A: Factorial (n-1) = ?1 Factorial Factorial (n-1) =? n = 03 A: Factorial (n-1) = ?1 Factorial Factorial (n-1) =? n = 03 A: Factorial (n-1) = ?1 Factorial Factorial (n-1) =? Valor final retornado al programa principal: 6 Otra forma de representación gráfica del método de las caja (pila de ejecución): Conclusión: Como se puede apreciar en los gráficos del ejemplo anterior, el espacio de memoria necesario para ejecutar un algoritmo recursivo es mucho mayor que si fuese un algoritmo iterativo. En un algoritmo recursivo se genera una caja o 6 Apuntes teóricos – Sintaxis y Semántica del Lenguaje Año 2013 Prof. Lic. María Gabriela Cerra registro de activación por cada llamada al mismo subprograma, representando en memoria los datos necesarios para su ejecución tantas veces como sea invocado. En un algoritmo iterativo solo existe una caja o registro de activación correspondiente al algoritmo que contiene dicha iteración. Ejemplos de aplicación: 1) Vamos a resolver de manera recursiva el problema de imprimir una lista de caracteres hacia atrás. Para ello debemos responder las tres preguntas, es decir vamos a construir la solución al problema de imprimir una lista de longitud N hacia atrás en términos de una cadena de longitud N -1. Esto indicaría que en cada paso recursivo la longitud de la lista se hace más chica. Entonces el problema de imprimir una “lista muy pequeña” hacia atrás puede servir como caso degenerado. Una lista muy pequeña es la lista vacía, es decir de longitud cero. Nuestro caso base es entonces imprimir una lista nula y la solución a este problema es no hacer nada (no hay nada que imprimir). Ahora hay que determinar de qué manera se puede usar la solución de imprimir hacia atrás un lista de longitud N–1 para resolver el problema de imprimir hacia atrás una lista de longitud N. La lista de longitud N –1 resulta de quitar un nodo de la lista original avanzando en cada llamada, al nodo siguiente. Veamos la siguiente solución aproximada: Procedimiento Imprimir_ hacia_ atrás (PC) Si (PC No es nulo) entonces Imprimir_hacia_atrás ( nodo siguiente de PC) Imprimir el carácter del nodo corriente apuntado por PC finsi Las llamadas recursivas a Imprimir _hacia _atrás pasan sucesivamente listas de longitud más chicas sacando siempre un nodo al pasar como argumento la dirección del nodo siguiente al corriente, entonces seguro se alcanzará el caso base (es decir la lista vacía). Procedure Imprimir_ hacia_ atrás (PC: lista) Begin If (PC <> nil) then begin Imprimir_hacia_atrás ( PC^.psig) Writeln(PC^.let) End End; Invocación desde el principal: Imprimir_hacia_atrás(Pin) 7 Apuntes teóricos – Sintaxis y Semántica del Lenguaje Año 2013 Prof. Lic. María Gabriela Cerra Traza de la ejecución usando el método de la caja: Supongamos sea esta la lista L: ‘C’ ‘A’ ‘S’ ‘A’ pc pc pc pc Write ‘C’ Write ‘A’ Write ‘S’ Write ‘A’ NIL L Imprime ‘a’ imprime ‘s’ Resultado ‘ASAC’ imprime ‘a’ imprime ‘c’ pc no hace nada La construcción de la solución en este ejemplo y en el del factorial se logra por backtracking, es decir, en la vuelta atrás del proceso recursivo. La retroalimentación es una de las técnicas esenciales de resolución de la recursión, eficaz a la hora de obtener todos los posibles resultados o combinaciones posibles a un problema dado. Por ejemplo para encontrar todas las combinaciones de números que cumplen una determinada condición, o todas las ubicaciones posibles de fichas en un tablero o la mejor solución a un problema, podemos “sacar” después de la llamada recursiva el último elemento colocado en la combinación y al volver hacia atrás el mecanismo probará con otro elemento aún no colocado. Este proceso se repetirá recursivamente y se lograrán combinaciones automáticas. 2) Diseñe una solución recursiva que encuentre todas las combinaciones posibles de 3 letras mayúsculas de la A a la Z que admita letras repetidas. Procedure Comb_Letras (cad:string[3]; N:integer); {recibe una cadena nula en la que irá armando las posibles combinaciones y N=3, la longitud de la cadena a armar} Var I: char; Begin If (N=0) then Writeln(cad) Else For I:=’A’ to ‘Z’ do Comb_Letras (cad + I, N-1) {concatena una letra a la cadena y decrementa su longitud) End; Invocación desde el principal: Comb_Letras ( ‘ ‘, 3) 8 Apuntes teóricos – Sintaxis y Semántica del Lenguaje Año 2013 Prof. Lic. María Gabriela Cerra Este algoritmo usa la iteración para lograr generar todas las letras que se colocan en una posición dada de la cadena que se está armando. Observar que la lógica de resolución propone ubicar una letra inicial en la cadena (concatenando) comenzando con la A y llamar recursivamente para ubicar una letra en la 2º posición y lo mismo con la siguiente. Cuando se hayan puesto las primeras 3 letras, llega al caso base e imprime (AAA). Por backtracking deshace una caja al volver atrás y ahora va reemplazando la última letra por B e imprime, luego por C e imprime, y así siguiendo hasta llegar a la Z. AAA AAB AAC ……AAZ Al finalizar la ejecución del For para la 3º posición, retorna una paso más atrás y vuelve a la 2º posición y cambia la letra por B y vuelve a completar la 3º posición…… ABA ABB ABC …..ABZ ACA ACB ACC ….ACZ ……… AZA AZB ………. AZZ BAA BAB BAC BAZ BBA ………………………………………………... ….. ZAA ZAB …..ZAZ … ZZA ZZB …………………………… ZZZ Es decir, por cada letra que ubica en una posición dada, la combina con todas las 28 posibles del alfabeto en la posición siguiente. Esto se logra combinando backtracking y el uso del For. 3) Diseñe una solución recursiva que permita eliminar un elemento de una lista. Procedure Elim( var L:lista; pa:lista; pc:lista; el:elem); Begin If (pc <> nil) then If (pc^.dato < > el) then Elim (L, pa^.ps, pc^.ps, el) Else Begin If (pa=nil) then L:=L^.ps; Else Pa^.ps:=pc^.ps; Dispose(pc) End End; Invocación desde el principal: Elim(Pin, nil, Pin, eldato) 9 {lista simple} Apuntes teóricos – Sintaxis y Semántica del Lenguaje Año 2013 Prof. Lic. María Gabriela Cerra 4) Diseñe una solución recursiva que permita insertar al final un elemento en una lista circular. Procedure InsCir( var L:listaC; pc:listaC; el:elem); Var Nuevo:listaC; Begin If (L=nil) then {lista vacia} Begin New(nuevo); nuevo^.dato:=el; L:= nuevo; nuevo^.ps:=L; end else If (pc^.ps <> L) then InsCirc(L, pc^.ps, el) Else Begin New(nuevo); Nuevo^.dato:=el; Pc^.ps:=nuevo; Nuevo^.ps:=L End {lista circular} end Invocación desde el principal: InsCir( Pin, Pin, eldato) Ventajas y desventajas del uso de recursión. Las soluciones recursivas son elegantes y simples para problemas de complejidad grande, presentan un diseño muy bien estructurado y modular. Comparadas con la solución iterativa contienen menos líneas de código y son más fáciles de analizar y leer. Desde el punto de vista de la eficiencia, demandan más tiempo de ejecución que las soluciones iterativas y mayor espacio de memoria debido a que cada llamado recursivo genera una copia independiente de las variables declaradas en dicho algoritmo, almacenadas en una zona de la memoria Ram denominadas Registro de Activación. Aplicación y uso. Esta técnica de diseño se aplica fundamentalmente en lenguajes de programación que carecen de estructuras de control repetitivas como ocurre en aquellos pertenecientes a la programación Funcional (LISP, HASKELL) y Lógica 10 Apuntes teóricos – Sintaxis y Semántica del Lenguaje Año 2013 Prof. Lic. María Gabriela Cerra (APL, PROLOG). Es de amplio uso en Inteligencia Artificial como demostrador de teoremas, y en el área matemática para los cálculos combinatorios. Bibliografía de referencia: - Luis Joyanes Aguilar, 2003. Fundamentos de Programación. Algoritmos y Estructuras de Datos. Ed. Mc Graw Hill - Luis Joyanes Aguilar, 2008. Fundamentos de Programación. Algoritmos, Estructuras de Datos y Objetos. Ed. Mc Graw Hill - Luis Joyanes Aguilar, 2006. Programación en Turbo/Borland Pascal 7.0. Ed. Mc Graw Hill - De Giusti, Madoz y otros,1998. Algoritmos, datos y programas. Conceptos básicos. Ed. Exacta. 11