363n + recursividad.pdf) · (se ha reducido el tamaño del problema en una unidad). nuestro nuevo...

26
Capítulo 2: Recursividad 2.1 Introducción a la recursividad. La recursividad es una técnica ampliamente utilizada en matemáticas, y que básicamente consiste en realizar una definición de un concepto en términos del propio concepto que se está definiendo. Veamos algunos ejemplos: Los números naturales se pueden definir de la siguiente forma: 0 es un número natural y el sucesor de un número natural es también un número natural. El factorial de un número natural n, es 1 si dicho número es 0, o n multiplicado por el factorial del número n-1, en caso contrario. La n-ésima potencia de un número x, es 1 si n es igual a 0, o el producto de x por la potencia (n-1)-ésima de x, cuando n es mayor que 0. En todos estos ejemplos se utiliza el concepto definido en la propia definición, es decir, un número natural se define en términos de los naturales anteriores a él, o el factorial de un número se establece en función del factorial de otro número. La recursividad es una técnica de resolución de problemas muy potente ya que muchos problemas que a primera vista parecen poseer una solución difícil, son resueltos de manera sencilla y elegante, incluso inmediata de forma recursiva. Este método divide el problema original en varios más pequeños que son del mismo tipo que el inicial, procediendo seguidamente a encontrar la solución de estos subproblemas, soluciones en las que se basará la del problema inicialmente planteado. Para ello, realiza de nuevo las correspondientes divisiones en problemas más pequeños aún, hasta que éstos tengan un tamaño tan pequeño para que su solución se conozca de forma directa. Una vez conocidas estas soluciones, podremos ir resolviendo sucesivamente los problemas más grandes hasta llegar a la solución buscada del problema primeramente planteado. Es de vital importancia que exitan dichas soluciones a los problemas más sencillos, por que nos permitirán, a partir de ellas, ir resolviendo los problemas más complejos. Para ilustrar el funcionamiento de la resolución de problemas mediante la recursividad indicado en el párrafo anterior, supongamos que se pudiera resolver un problema P conociendo la solución de otro problema Q que es del mismo tipo que P, pero más pequeño. Igualmente, supongamos que pudiéramos resolver Q mediante la búsqueda de la solución de otro nuevo problema, R, que sigue siendo del mismo tipo que Q y P, pero de un tamaño menor que ambos. Si el problema R fuera tan simple que su solución es obvia o directa, entonces, dado que sabemos la solución de R, procederíamos a resolver Q y, una vez resuelto, finalmente se obtendría la solución definitiva al primer problema, P. El típico problema que clarifica lo descrito en el párrafo anterior es el cálculo del factorial de un número, por ejemplo 5. Calcular 5! se basa en multiplicar 5 por 4!, por tanto, el obtener el valor de 5! dependerá de cuánto valga 4! (se ha reducido el tamaño del problema en una unidad). Nuestro nuevo problema es ahora calcular 4! que, aplicando de nuevo la definición, es 4! = 4 * 3!. Así, para calcular 4! debemos hallar 3!. Si seguimos descomponiendo el problema en otro más pequeño de manera sucesiva tendremos que 3! es 3 * 2!, 2! = 2 * 1! y 1! = 1 * 0!. En la definición recursiva del factorial se dice que el factorial de 0 es 1, por tanto, hemos encontrado la solución al caso más sencillo. Con esta solución podemos ir hacia atrás construyendo las de los problemas más complejos: 0! = 1 1! = 1 * 0! = 1 2! = 2 * 1! = 2 3! = 3 * 2! = 6 4! = 4 * 3! = 24 5! = 5 * 4! = 120

Upload: others

Post on 26-Mar-2021

1 views

Category:

Documents


0 download

TRANSCRIPT

Page 1: 363n + recursividad.pdf) · (se ha reducido el tamaño del problema en una unidad). Nuestro nuevo problema es ahora calcular 4! que, aplicando de nuevo la definición, es 4! = 4 *

Capítulo 2: Recursividad

2.1 Introducción a la recursividad.La recursividad es una técnica ampliamente utilizada en matemáticas, y que básicamente

consiste en realizar una definición de un concepto en términos del propio concepto que se estádefiniendo. Veamos algunos ejemplos:

• Los números naturales se pueden definir de la siguiente forma: 0 es un número natural y elsucesor de un número natural es también un número natural.

• El factorial de un número natural n, es 1 si dicho número es 0, o n multiplicado por el factorialdel número n-1, en caso contrario.

• La n-ésima potencia de un número x, es 1 si n es igual a 0, o el producto de x por la potencia(n-1)-ésima de x, cuando n es mayor que 0.

En todos estos ejemplos se utiliza el concepto definido en la propia definición, es decir, unnúmero natural se define en términos de los naturales anteriores a él, o el factorial de un número seestablece en función del factorial de otro número.

La recursividad es una técnica de resolución de problemas muy potente ya que muchosproblemas que a primera vista parecen poseer una solución difícil, son resueltos de manera sencilla yelegante, incluso inmediata de forma recursiva. Este método divide el problema original en varios máspequeños que son del mismo tipo que el inicial, procediendo seguidamente a encontrar la solución deestos subproblemas, soluciones en las que se basará la del problema inicialmente planteado. Paraello, realiza de nuevo las correspondientes divisiones en problemas más pequeños aún, hasta queéstos tengan un tamaño tan pequeño para que su solución se conozca de forma directa. Una vezconocidas estas soluciones, podremos ir resolviendo sucesivamente los problemas más grandeshasta llegar a la solución buscada del problema primeramente planteado. Es de vital importancia queexitan dichas soluciones a los problemas más sencillos, por que nos permitirán, a partir de ellas, irresolviendo los problemas más complejos.

Para ilustrar el funcionamiento de la resolución de problemas mediante la recursividadindicado en el párrafo anterior, supongamos que se pudiera resolver un problema P conociendo lasolución de otro problema Q que es del mismo tipo que P, pero más pequeño. Igualmente,supongamos que pudiéramos resolver Q mediante la búsqueda de la solución de otro nuevoproblema, R, que sigue siendo del mismo tipo que Q y P, pero de un tamaño menor que ambos. Si elproblema R fuera tan simple que su solución es obvia o directa, entonces, dado que sabemos lasolución de R, procederíamos a resolver Q y, una vez resuelto, finalmente se obtendría la solucióndefinitiva al primer problema, P.

El típico problema que clarifica lo descrito en el párrafo anterior es el cálculo del factorial deun número, por ejemplo 5. Calcular 5! se basa en multiplicar 5 por 4!, por tanto, el obtener el valor de5! dependerá de cuánto valga 4! (se ha reducido el tamaño del problema en una unidad). Nuestronuevo problema es ahora calcular 4! que, aplicando de nuevo la definición, es 4! = 4 * 3!. Así, paracalcular 4! debemos hallar 3!. Si seguimos descomponiendo el problema en otro más pequeño demanera sucesiva tendremos que 3! es 3 * 2!, 2! = 2 * 1! y 1! = 1 * 0!. En la definición recursiva delfactorial se dice que el factorial de 0 es 1, por tanto, hemos encontrado la solución al caso mássencillo. Con esta solución podemos ir hacia atrás construyendo las de los problemas más complejos:

0! = 1

1! = 1 * 0! = 1

2! = 2 * 1! = 2

3! = 3 * 2! = 6

4! = 4 * 3! = 24

5! = 5 * 4! = 120

Page 2: 363n + recursividad.pdf) · (se ha reducido el tamaño del problema en una unidad). Nuestro nuevo problema es ahora calcular 4! que, aplicando de nuevo la definición, es 4! = 4 *

Programación II

14

Del ejemplo anterior, deducimos que la descomposición del problema deberá finalizar enalgún momento, ya que si no fuera así, estaríamos siempre aplicando la misma operación de divisiónde los problemas, sin llegar a ningún lado y con peligro de agotar los recursos del ordenador. Portanto, es de vital importancia para aplicar la recursividad determinar aquellos subproblemas cuyasoluciones vengan dadas por soluciones directas o conocidas, pero no recursivas.

Otro ejemplo de la resolución de un problema de forma recursiva, que sin duda algunaaclarará bastante las ideas y que se sale del ámbito de las matemáticas, es la búsqueda de unapalabra en un diccionario: abrimos el diccionario más o menos por la mitad, decidimos en qué mitadestá la palabra buscada. Si está en la primera, buscamos la palabra en dicha mitad utilizando estamisma técnica y si lo está en la segunda, hacemos lo propio con esa segunda parte del diccionario.De esta forma vamos reduciendo el tamaño del diccionario donde buscamos hasta que tenga eltamaño de una página, momento en el cual será fácil buscar la palabra.

En este ejemplo podemos observar cómo el problema se va reduciendo de tamaño: elproblema de buscar una palabra en un diccionario se reduce al de buscarla en una de sus mitades, elcual se convertirá en la búsqueda en su correspondiente mitad, es decir, en un cuarto del diccionario,y así sucesivamente hasta que el proceso finalice cuando el diccionario conste tan sólo de unapágina, situación en la que será fácil localizar la palabra, pues sólo habrá que buscarlasecuencialmente.

De manera general, aquellos problemas que puedan ser resueltos de forma recursiva tendránlas siguientes características:

1. Los problemas pueden ser redefinidos en términos de uno o más subproblemas, idénticosen naturaleza al problema original, pero de alguna forma menores en tamaño.

2. Uno o más subproblemas tienen solución directa o conocida, no recursiva.

3. Aplicando la redefinición del problema en términos de problemas más pequeños, dichoproblema se reduce sucesivamente a los subproblemas cuyas soluciones se conocendirectamente.

4. La solución a los problemas más simples se utiliza para construir la solución al problemainicial.

Cuando plasmamos la solución de un problema de manera recursiva en un algoritmo diremosentonces que ha sido resuelto mediante un algoritmo recursivo. Este algoritmo, o en general cualquiermódulo, tendrá que realizar una o varias llamadas a sí mismo, las cuales descompondrán el problemaen otros subproblemas con un tamaño más reducido, hasta que se lleguen a problemas cuyassoluciones se conozcan, en cuyo caso irán devolviendo el control a los módulos desde donde fueronllamados, solventando así los problemas mayores que los originaron.

Es muy importante que siempre se determine en qué momento se detendrán las llamadasrecursivas del módulo, ya que si no se hace, se corre el riesgo de no llegar nunca a la solución delproblema inicial por estar llamándose el módulo a sí mismo de forma infinita.

Un aspecto que hay que tener en cuenta cuando se está diseñando un algoritmo recursivo esla cuestión de la eficiencia. La recursividad es una alternativa a la resolución de problemas de formaiterativa y, como ya hemos dicho, ofrece soluciones simples y elegantes a algunos problemas,aunque en algunos casos estas soluciones no son mejores que las correspondientes iterativas,fundamentalmente por que son tan ineficientes que son totalmente impracticables. Por tanto, y comoveremos en las secciones siguientes, a la hora de diseñar un algoritmo, habrá que decidir que tipo desolución, recursiva o iterativa, es la más idónea.

Page 3: 363n + recursividad.pdf) · (se ha reducido el tamaño del problema en una unidad). Nuestro nuevo problema es ahora calcular 4! que, aplicando de nuevo la definición, es 4! = 4 *

Recursividad

15

Una vez que hemos diseñado el algoritmo recursivo, el siguiente paso dentro del proceso dedesarrollo del software es realizar su implementación en un lenguaje de programación, aunque notodos están preparados para ello. Lenguajes como BASIC, FORTRAN o COBOL no permiten laimplementación de algoritmos recursivos, por lo que se descartará este método de resolución deproblemas en caso de tener que utilizarlos, buscando vías alternativas. Otros como C, C++, Pascal oJava sí incluyen esta característica. En nuestro caso, utilizaremos el leguaje C para estudiar losconceptos básicos de la recursividad, creando para ello, y por analogía con los algoritmos, funcionesrecursivas1.

En el resto del tema nos centraremos en el estudio detallado de algunos aspectos necesariospara implementar funciones recursivas en C, como son las consideraciones básicas para crear dichossubprogramas (sección 2), el funcionamiento de la pila durante la ejecución de una función recursiva(sección 3), el paso de parámetros a funciones de ese tipo (sección 4), las ventajas e inconvenientesde las soluciones recursivas frente a las iterativas (sección 5), la conversión de una resoluciónrecursiva en iterativa (sección 6), y finalmente, el esbozo de algunas técnicas de resolución deproblemas basadas en recursividad (sección 7).

2.2 Diseño de módu los recursivos.Comencemos clasificando a las funciones recursivas. Si una función F contiene una llamada

explícita a sí misma, entonces se dice que es recursiva de forma directa, pero si F posee unareferencia a otra función Q, que a su vez contiene una llamada a F, entonces F es recursiva deforma indirecta.

La primera implementación de una función recursiva que vamos a tratar es el factorial de unnúmero, la cual se obtiene de manera directa a partir de la propia definición del factorial (en este casoes una función recursiva directa):

long factorial (long n) {

if (n == 0) return 1;else return n * factorial(n-1);

}

En esta función podemos claramente determinar dos partes principales:

1. la llamada recursiva, que expresa el problema original en términos de otro más pequeño,y

2. el valor para el cual se conoce una solución no recursiva. Esto es lo que se conoce comocaso base: una instancia del problema cuya solución no requiere de llamadas recursivas.

La existencia del caso base cubre fundamentalmente dos objetivos

1. Actuar como condición de finalización de la función recursiva. Sin el caso base la rutinarecursiva se llamaría indefinidamente y no finalizaría nunca.

1Como ya hemos dicho anteriormente, la línea lógica de desarrollo de un programa que implementeuna solución recursiva a un problema, y en general cualquier tipo de solución, sería diseñar elalgoritmo y posteriormente implementarlo, en nuestro caso en C, pero debido a la simplicidad de losproblemas que vamos a tratar inicialmente, éstos se van a implementar directamente sin necesidadde exponer explícitamente su algoritmo, aunque obviamente se comentará como se solucionarán losproblemas tratados.

Page 4: 363n + recursividad.pdf) · (se ha reducido el tamaño del problema en una unidad). Nuestro nuevo problema es ahora calcular 4! que, aplicando de nuevo la definición, es 4! = 4 *

Programación II

16

2. Es el cimiento sobre el cual se construirá la solución completa al problema.

Veamos seguidamente, en la figura 1, una traza de las llamadas recursivas a la funciónfactorial originadas para calcular el correspondiente valor de 5 desde una función main:

Figura 1: evolución de las llamadas recursivas del factorial de 5.

A la hora de resolver recursivamente un problema, son cuatro las preguntas que nosdebemos plantear:

[P1] ¿Cómo se puede definir el problema en términos de uno o más problemas máspequeños del mismo tipo que el original?

[P2] ¿Qué instancias del problema harán de caso base?

[P3] Conforme el problema se reduce de tamaño ¿se alcanzará el caso base?

[P4] ¿Cómo se usa la solución del caso base parar construir una solución correcta alproblema original?

Apliquemos esta técnica de preguntas y respuestas para diseñar una función recursiva queobtenga el valor del n-ésimo término de la secuencia de Fibonacci, que fue creada inicialmente paramodelar el crecimiento de una colonia de conejos. La secuencia es la siguiente: 1, 1, 2, 3, 5, 8, 13,21, 34, 55, 89, 144, ...

Se puede observar que el tercer término de la sucesión se obtiene sumando el segundo y elprimero. El cuarto, a partir de la suma del tercero y el segundo. El problema es calcular el valor del n-ésimo término de la solución, que se obtendrá sumando los términos n-1 y n-2.

Page 5: 363n + recursividad.pdf) · (se ha reducido el tamaño del problema en una unidad). Nuestro nuevo problema es ahora calcular 4! que, aplicando de nuevo la definición, es 4! = 4 *

Recursividad

17

Las respuestas a la preguntas anteriores serían:

[P1] fibonacci(n) = fibonacci(n-1) + fibonacci(n-2).

[P2] En este caso hay que seleccionar como casos bases fibonacci(1) = 1 y fibonacci(2)=1.

[P3] En cada llamada a la rutina fibonacci se reduce el tamaño del problema en uno o en dos,por lo que siempre se alcanzará uno de los dos casos bases.

[P4] fibonacci(3) = fibonacci(2) + fibonacci(1) = 1 + 1. Se construye la solución del probleman==2 a partir de los dos casos bases.

Teniendo en cuenta estas respuestas estamos preparados para implementar la funciónfibonacci en C:

int fibonacci(int n){if ((n == 1) || (n == 2)) return 1;else return (fibonacci(n-2) + fibonacci(n-1));

}

La evolución de las llamadas recursivas para calcular el valor del cuarto término de la seriesería la siguiente:

Figura 2: evolución de las llamadas recursivas para calcular fibonacci(3).

Page 6: 363n + recursividad.pdf) · (se ha reducido el tamaño del problema en una unidad). Nuestro nuevo problema es ahora calcular 4! que, aplicando de nuevo la definición, es 4! = 4 *

Programación II

18

Esta función posee dos peculiaridades: tiene más de un caso base y hay más de una llamadarecursiva, denominándose a este tipo recursión no lineal, en contraposición a la recursión linealproducida cuando sólo hay una única llamada recursiva. Generalmente, la recursión no lineal requierela identificación de más de un caso base.

Seguidamente vamos a estudiar tres ejemplos más: el cálculo del máximo común divisor dedos números, contar el número de veces que aparece un valor en un vector y calcular el número dedistintas formas de seleccionar k objetos de entre un total de n. Con ello podremos afianzar la técnicade las cuatro preguntas.

Máximo común divisor de dos números m y n.

En los dos problemas anteriores, la solución obtenida al encontrar el caso base sirve paraconstruir las soluciones a los problemas mayores que han derivado al caso base, pero hay ocasionesdonde esa solución del caso base es la propia solución del problema original. Este problema es unejemplo.

Se pretende implementar una función recursiva en C que calcule el máximo común divisor(MCD) de dos enteros m y n, no negativos, que es el mayor entero que divide a ambos.

El algoritmo de Euclides para encontrar el MCD de m y n es el siguiente: si n divide a m,entonces MCD(m, n) = n, y en otro caso, MCD(m, n) = MCD(n, m mod n), donde m mod n es el restode la división de m por n (en C se representa con el operador %).

Apliquemos la técnica de las cuatro preguntas para ver si esta definición está correctamentehecha:

[P1] Es evidente que MCD(m, n) ha sido definida en términos de un problema del mismo tipo,pero justifiquemos que MCD(m, n mod m) es de tamaño menor que MCD(m, n):

1. El rango de m mod n es 0, ..., n-1, por tanto el resto es siempre menor que n.

2. Si m > n, entonces MCD(n, m mod n) es menor que MCD(m, n).

3. Si n > m, el resto de dividir m entre n es m, por lo que la primera llamada recursiva esMCD(n, m mod n) es equivalente a MCD(n, m), lo que tiene el efecto de intercambiar losargumentos y producir el caso anterior.

[P2] Si n divide a m, si y sólo si, m mod n = 0, en cuyo caso MCD(m, n) = n, por tanto, seconseguirá el caso base cuando el resto sea cero.

[P3] Se alcanzará el caso base ya que n se hace más pequeño en cada llamada y además secumple que 0 ≤ m mod n ≤ n-1.

[P4] En este caso, cuando se llega al caso base es cuando se obtiene la solución final alproblema, por lo que no se construye la solución general en base a soluciones de problemas mássimples.

Teniendo en cuenta las respuestas anteriores, la función en C que implementa el cálculo delmáximo común divisor es la siguiente:

int MCD (int m, int n){if (m % n == 0) return n;else return (MCD(n, m % n));

}

Page 7: 363n + recursividad.pdf) · (se ha reducido el tamaño del problema en una unidad). Nuestro nuevo problema es ahora calcular 4! que, aplicando de nuevo la definición, es 4! = 4 *

Recursividad

19

Contar del número de veces que aparece un valor dado en un vector.

Dado un vector de n enteros, el problema a resolver recursivamente consiste en contar elnúmero de veces que un valor dado aparece en dicho vector.

Comencemos por las respuestas a las preguntas P1, P2, P3 y P4:

[P1] Si el primer elemento del vector es igual que el valor buscado, entonces la solución será1 más el número de veces que aparece dicho valor en el resto del vector. Si no estuviera en laprimera posición, la solución al problema original será sólo el número de veces que el valor seencuentra en las posiciones restantes, por lo que siempre se formula un nuevo problema con untamaño menor del vector en una unidad.

[P2] El caso base será cuando el vector a inspeccionar no tenga elementos, por lo que elnúmero de ocurrencias del valor buscado en dicho array será cero.

[P3] Cada llamada recursiva se reduce en uno el tamaño del vector, por lo que nosaseguramos que en N llamadas habremos alcanzado el caso base.

[P4] Cuando se encuentra una coincidencia se suma uno al valor devuelto por otra llamadarecursiva. El retorno del control de las sucesivas llamadas comenzará inicialmente sumando 0 cuandonos hayamos salido de las dimensiones del vector.

La función en C que implementa la solución recursiva al problema en curso tiene cuatroparámetros: el tamaño del vector (N), el propio vector (Vector), el valor a buscar (Objetivo) y el índicedel primer elemento del vector a procesar (Primero) y su código es el siguiente:

int ContarOcurrencias (int N, int *Vector, int Objetivo, int Primero){if (Primero > N-1) return 0;else {

if (Vector[Primero] == Objetivo)return(1 + ContarOcurrencias(N,Vector, Objetivo, Primero+1));

else return(ContarOcurrencias(N,Vector, Objetivo, Primero+1)); }}

Combinaciones sin repetición de n objetos tomados de k en k.

En este problema típico de combinatoria se desea calcular cuántas combinaciones de kelementos se pueden hacer de entre un total de n (combinaciones sin repetición de n elementostomados de k en k). La función recursiva que implementaremos en C se llamará "ncsr" y tendrá dosparámetros: el número total de elementos de que disponemos, n, y el tamaño del conjunto que sedesea generar, k.

Este enunciado nos sirve para ejemplificar cómo los casos bases pueden ser elaborados enfunción de varias condiciones. Las respuestas a las cuatro preguntas son las siguientes:

[P1] El cálculo del número de combinaciones de n elementos tomados de k en k se puededescomponer en dos problemas: calcular el número de combinaciones de n-1 elementos tomados dek en k y el número de combinaciones de n-1 elementos tomados de k-1 en k-1, siendo estos dosnuevos problemas instancias más pequeñas que el original de donde provienen y del mismo tipo. Elresultado del problema inicial se obtendrá, por tanto, sumando ambos resultados: ncsr(n, k) = ncsr(n-1, k-1) + ncsr(n-1, k).

Page 8: 363n + recursividad.pdf) · (se ha reducido el tamaño del problema en una unidad). Nuestro nuevo problema es ahora calcular 4! que, aplicando de nuevo la definición, es 4! = 4 *

Programación II

20

Para facilitar el entendimiento del problema, supongamos que tenemos un total de n personasy queremos determinar cuántos grupos de k personas se pueden formar de entre las n personas entotal. La forma de resolución recursiva que se ha elegido se puede interpretar como la suma de losgrupos que se pueden hacer de un tamaño k sin contar a la persona A (ncsr(n-1,k)), más todos losque se puedan realizar incluyendo a esa misma persona, ncsr(n-1, k-1) (contamos todos los que sepueden hacer con una persona menos y luego le añadimos esa persona).

Veamos el siguiente ejemplo: tenemos cuatro personas: A, B, C y D, y queremos determinarcuántos grupos de dos personas podemos formar, o lo que es lo mismo, calcular ncsr(4,2). Este valorse obtiene al aplicar la definición: ncsr(3,1)+ncsr(3,2). Fijada la persona A, calculamos cuántos gruposse pueden realizar con una persona menos, que en este caso es A (ncsr(3,1) será igual a 3 ya quepodemos hacer 3 conjuntos de tamaño 1:{B}, {C} y {D}) y se lo sumamos al número de comités quese pueden hacer, también de tamaño 2, pero sin A (ncsr(3,2) será también 3 ya que podemos generar{B,C}, {B,D} y {D,C}).

[P2] Los casos bases son los siguientes:

Si k > n entonces no hay suficientes elementos para formar combinaciones de tamaño k,en cuyo caso el total de combinaciones en este caso es 0.

Si k = 0, entonces este caso sólo ocurre una vez.

Si k = n, el número total de elementos a tomar coincide con el tamaño del grupo deelementos de donde seleccionarlo, siendo uno el número de combinaciones en estecaso.

Resumiendo: ncsr(n, n) = 1, ncsr(n,0)=0 y ncsr(n, k)=0 con k > n.

[P3] Considerando que en una de las llamadas recursivas restamos uno al número deelementos, y en la otra se resta la unidad tanto al número de elementos como al tamaño de lacombinación, nos aseguramos que en un número finito de pasos se habrá alcanzado uno de loscasos bases.

[P4] Apenas se haya encontrado un caso base, uno de los sumandos tendrá un valor.Cuando el otro sumando alcance a un caso base, se podrá hacer la suma y se devolverá a un valorque irá construyendo la solución final conforme se produzcan las devoluciones de control.

Para concluir, la implementación de la función que acabamos de diseñar es la siguiente:

int ncsr(int n, int k){

if (k > n) return 0;else if (n == k || k == 0) return 1;

else return ncsr(n-1, k) + ncsr(n-1, k-1);}

2.3 La pila del orden ador y la recursividad.En esta sección vamos a estudiar cómo gestiona un ordenador las llamadas recursivas

cuando éste ejecuta un módulo recursivo. Así, comprendiendo este funcionamiento, seremosconscientes de los problemas que se nos pueden plantear si el diseño del módulo recursivo no escorrecto.

La memoria de un ordenador a la hora de ejecutar un programa queda dividida en dos partes:la zona donde se almacena el código del programa y la zona donde se guardan los datos, utilizadaésta última fundamentalmente en las llamadas a rutinas y que se denomina pila (en inglés stack).

Page 9: 363n + recursividad.pdf) · (se ha reducido el tamaño del problema en una unidad). Nuestro nuevo problema es ahora calcular 4! que, aplicando de nuevo la definición, es 4! = 4 *

Recursividad

21

Cuando un programa principal llama a una rutina M, se crea en la pila lo que se denomina elregistro de activación o entorno E, asociado al módulo M. El registro de activación almacenainformación como constantes y variables locales del módulo, así como sus parámetros formales.

Figura 3: estado de la pila cuando se llaman tres módulos de manera anidada.

Conforme se van llamando de manera anidada a las rutinas, se van creando sucesivosregistros de activación, almacenándose de forma apilada: si M1 llama a M2 y a su vez, invoca a M3,la pila estará formada por un primer registro de activación correspondiente a M1, E1, sobre el que sereservará espacio para el de M2, E2, y posteriormente para el de M3, E3, conviviendo los tresentornos mientras se esté ejecutando M3. Esta situación queda representada gráficamente en elgráfico de la figura 3.

Cuando finaliza la ejecución del último módulo invocado, se devuelve el control a M2, y elespacio ocupado por su entorno se libera, quedando ahora la pila compuesta sólo por dos registrosde activación. Al acabar M2 se elimina de la pila el entorno E2, devolviéndose el control a M1 yquedando sólo el registro E1 correspondiente a ese último módulo, que desaparecerá, a su vez,cuando acabe su ejecución.

Como se puede observar, este trozo de memoria recibe el nombre de pila por la forma en quese gestiona, ya que en él se encuentran los entornos apilados en el orden en que han sido llamados,de tal manera que el registro activo en cada momento es el que está situado en la cabecera de la pila,ocupando así el lugar más alto de esta hipotética pila. Esta forma de gestionar ese espacio dememoria permite que el lenguaje de programación correspondiente pueda utilizar la recursividad.

En la siguiente figura se introduce una notación gráfica para representar el registro deactivación de un módulo. El módulo se representa mediante una rectángulo con cuatro divisiones: laprimera contendrá el nombre del módulo, la segunda el área de almacenamiento de los argumentosformales y sus valores, la tercera la zona donde aparecerán las variables locales e igualmente susvalores, y por último, una cuarta zona, creada sólo a efectos pedagógicos, donde aparecerán algunassentencias que pueden ser interesantes para entender el funcionamiento del módulo.

Page 10: 363n + recursividad.pdf) · (se ha reducido el tamaño del problema en una unidad). Nuestro nuevo problema es ahora calcular 4! que, aplicando de nuevo la definición, es 4! = 4 *

Programación II

22

Figura 4. Notación gráfica para representar el registro de activación.

En la pila existirán tantos entornos de una misma función como llamadas recursivas se hayanefectuado, siendo todos ellos independientes y distintos entre sí. Así, llamamos profundidad derecursión de un módulo recursivo al número de entornos que están presentes en la pila en unmomento dado.

El concepto de profundidad se suele usar como elemento de decisión para optar por unasolución recursiva de un problema, ya que problemas que requieran profundidades muy grandespueden originar que se llene la memoria del ordenador dedicada a la pila (desbordamiento de la pila).Obviamente, que la profundidad de un módulo recursivo sea pequeña no indica que el algoritmo queimplemente sea eficiente. Como regla general, se evitará utilizar una solución recursiva en aquelloscasos en los que la profundidad sea muy grande.

Seguidamente, y mediante un ejemplo, estudiaremos cómo evoluciona la pila durante lasdiferentes llamadas recursivas. El problema que vamos a resolver en este caso es la impresión de npalabras recibidas desde la entrada estándar en orden contrario al de entrada en la salida estándar:desde la última palabra introducida hasta la primera. Por ejemplo, si se introdujera la frase "En unlugar de la Mancha de cuyo nombre no quiero acordarme", la función recursiva imprimiría: "acordarmequiero no nombre cuyo de Mancha la de lugar un En".

Cabe destacar que en este problema, y al igual que ocurrió en el que contaba las ocurrenciasde un valor en un vector, y en general en todos aquellos que impliquen un procesamiento de listas deobjetos, se procesará inicialmente el primer elemento de la lista y posteriormente y de formarecursiva, el resto.

Comencemos a diseñar la función recursiva Imp_OrdenInverso, que recibirá como argumentoel número de palabras que quedan por leer (n). Aplicaremos la técnica de responder a las yaconocidas cuatro preguntas:

[P1] Se lee la primera palabra, y recursivamente se llama a la función para que lea e imprimael resto de palabras, para que finalmente se imprima la primera leída. El problema de leer n palabrase imprimirlas hacia atrás se convierte en realizar el mismo proceso pero sobre n-1 palabras.

[P2] El caso más simple en la impresión en orden inverso de una cadena será cuando sóloquede una palabra (n == 1), en cuyo caso se imprimirá directamente.

[P3] Como en cada llamada recursiva hay que leer una palabra menos del total, en n-1llamadas se habrá alcanzado el caso base.

Page 11: 363n + recursividad.pdf) · (se ha reducido el tamaño del problema en una unidad). Nuestro nuevo problema es ahora calcular 4! que, aplicando de nuevo la definición, es 4! = 4 *

Recursividad

23

[P4] Una vez se haya alcanzado el caso base (se haya leído la última palabra y se imprima)al devolver continuamente el control a las funciones invocantes se irán imprimiendo de atrás a delantelas palabras obtenidas desde la entrada estándar.

La implementación en C de la rutina Imp_OrdenInverso es la siguiente:

void Imp_OrdenInverso(int n){char Palabra[MAXTAMPAL];

if (n == 1) { scanf("%s", Palabra); printf("%s", Palabra); }else

{ scanf("%s", Palabra); Imp_OrdenInverso(n-1); printf("%s", Palabra); }}

MAXTAMPAL es una constante declarada en algún fichero que albergará el valor del tamañomáximo de una palabra.

Retomemos el objetivo principal de esta sección, para lo cual estudiaremos cómo evolucionagráficamente la pila si llamáramos a la función Imp_OrdenInverso con un valor del parámetro n igual a5, representado gráficamente por las dos siguientes gráficos (figuras 5 y 6).

Al llamar a Imp_OrdenInverso(5), se crea el registro de activación correspondiente, en el cualse reserva memoria para almacenar el valor del parámetro actual n == 5 y la variable palabra. Secomienza a ejecutar el código de dicha función y tras comprobar que no se da el caso base, elusuario introduce una primera cadena de caracteres, por ejemplo, "uno". A continuación se produce lallamada recursiva con una unidad menos en n (n==4). Encima del registro de la llamada inicial secrea un nuevo registro de activación, en este caso para la invocación Imp_OrdenInverso(4) y sereservan las zonas de memoria para los parámetros y las variables locales. Este proceso se repitetres veces más, encontrándose en el tope de la pila el registro correspondiente a la llamadaImp_OrdenInverso(1), momento en el cual se cumple el caso base, n == 1, y se lee la última palabra yseguidamente se imprime por la salida estándar. Este es el momento al que se corresponde la 5.

Page 12: 363n + recursividad.pdf) · (se ha reducido el tamaño del problema en una unidad). Nuestro nuevo problema es ahora calcular 4! que, aplicando de nuevo la definición, es 4! = 4 *

Programación II

24

.

Figura 5. Evolución de la pila con las llamadas recursivas.

Una vez finalizada la función, se elimina el registro de la pila, quedando en el tope la llamadaanterior, es decir, el caso de n==4. Para finalizar la ejecución de esa función sólo queda imprimir lapalabra leída (palabra == "cuatro") y se devolverá el control a la función que la invocó, tras eliminarsela zona de memoria reservada para alojar a esta función. El mismo proceso se repite para lasfunciones que fueron invocadas con n == 3, 2 y 1 respectivamente. Al llegar el tope de la pila alprimer registro de activación, se imprimirá la palabra "una" y finalizará su ejecución devolviendo elcontrol a la función main. En la salida estándar se habrá escrito "cinco cuatro tres dos uno". Elvaciado de la pila según van devolviendo el control las funciones recursivas corresponde a la figura 6.

Page 13: 363n + recursividad.pdf) · (se ha reducido el tamaño del problema en una unidad). Nuestro nuevo problema es ahora calcular 4! que, aplicando de nuevo la definición, es 4! = 4 *

Recursividad

25

Figura 6. Vaciado de la pila con el retorno de las llamadas recursivas.

Para finalizar, hacer hincapié en la corrección de los casos base como forma de evitar unarecursión infinita, y por tanto, que la pila se agote:

1. Cada rutina recursiva requiere como mínimo un caso base, sin el cual se generaránuna secuencia de llamadas infinitas originando el agotamiento de la pila.

2. Se debe identificar todos los casos base ya que en caso contrario se puede producirrecursión infinita.

3. Los casos bases deben ser correctos, es decir, las sentencias ejecutadas cuando sedé un caso base deben originar una solución correcta para una instancia particular delproblema. En caso contrario, se generará una solución incorrecta.

4. Hay que asegurarse que cada subproblema esté más cercano a algún caso base,por lo que en repetidas llamadas se alcanzará alguno de éstos.

Aconsejamos al lector para que llegue a comprender totalmente cómo funciona la pila delordenador, que implemente los ejemplos mostrados en este capítulo y los ejecute paso a pasoutilizando un depurador. De esta forma, podrá observar cómo va evolucionando la pila conforme seproducen las llamadas recursivas.

2.4 Paso de parámet ros a los módulos recursivos.Hay que hacer algunas consideraciones importantes a la hora de determinar los parámetros

formales que tendrá un módulo recursivo y si éstos serán de entrada, salida o de entrada/salida (si sepasarán por copia o por referencia) ya que puede influir en la correcta ejecución del módulo recursivo.Centrémonos en primer lugar en la forma de pasar los parámetros formales.

Debemos tener mucho cuidado a la hora de decidir el tipo de paso de parámetros.Supongamos que modificamos el código de la función factorial y el parámetro formal n quepasábamos por copia, ahora lo pasamos por referencia:

Page 14: 363n + recursividad.pdf) · (se ha reducido el tamaño del problema en una unidad). Nuestro nuevo problema es ahora calcular 4! que, aplicando de nuevo la definición, es 4! = 4 *

Programación II

26

long factorial (long* n) {

if (*n == 0) return 1;else

{ *n = *n -1;

return (*n+1) * factorial(n); }El desarrollo de las llamadas recursivas sería correcto, de tal forma que n se va reduciendo a

0, momento en el cual se comenzarán a devolver las llamadas recursivas al haberse alcanzado elcaso base. Pero, ¿qué ocurre en ese momento? Que *n siempre será igual a 0, y por tanto, sedevolverá siempre 1, por lo que finalmente el factorial de cualquier número será 1, resultado a todasluces incorrecto. Para aclarar lo anterior, estudiemos la traza correspondiente al siguiente trozo decódigo para calcular el factorial de 3;

long fact, tamanio= 3;

fact= factorial(&tamanio);

fact= factorial(*n == 3)

➥ (*n == 2) * factorial (*n == 2)

➥ (*n == 1) *factorial(*n == 1)

➥ (*n==0)*factorial(*n==0)

➥ return 1.

return (*n==0) * 1 = 0

return (*n==0) * 0

return (*n==0) * 0

fact= 0

En este caso, n no se puede pasar por referencia porque en la vuelta atrás de la recursividadse va a utilizar el valor antiguo que tenía antes de invocar a la función recursiva. La regla general paradecidir si un parámetro será pasado por copia o por referencia es la siguiente: si se necesita recordarel valor que tenía un parámetro una vez que se ha producido la vuelta atrás de la recursividad,entonces ha de pasarse por valor. En caso contrario, se podrá pasar por referencia.

Obviamente, si convertimos una función en un procedimiento habrá que introducir un nuevoparámetro pasado por referencia para ir guardando el valor solución en cada momento:

void factorial (long n, long *fact) {

if (n == 0) *fact=1;else

{ factorial(n-1, fact); *fact = *fact * n; } }

Page 15: 363n + recursividad.pdf) · (se ha reducido el tamaño del problema en una unidad). Nuestro nuevo problema es ahora calcular 4! que, aplicando de nuevo la definición, es 4! = 4 *

Recursividad

27

El otro aspecto a tener en cuenta en el diseño de la cabecera de un módulo es el número y eltipo de parámetros que se le pasarán. No es conveniente pasar ni muchos parámetros ni parámetrosque tengan un tamaño muy grande (por ejemplo, vectores o estructuras extensas), ya que en cadallamada recursiva la cantidad de memoria ocupada en la pila que se necesita para almacenar losparámetros actuales es muy grande, y en pocas llamadas recursivas se podría agotar.

Por ejemplo, si en vez de haber diseñado la cabecera de la función ContarOcurrencias comoint ContarOcurrencias (int N, int *Vector, int Objetivo, int Primero), hubiera hecho intContarOcurrencias ( int Vector[MAX], int Objetivo, int Primero), en cada llamada recursiva se hará unacopia de Vector en la pila y si el tamaño MAX es muy grande, se corre el riesgo de agotar la memoriaa las pocas llamadas.

Se podría solucionar el inconveniente del número y tamaño de los parámetros de tres formasdiferentes:

a) Declarar algunas variables como globales, y por tanto, no pasarlas como parámetrosa la función.

b) Hacer pasos por referencia. De esta manera se introduce a la función punteros enlugar de los propios objetos, reduciendo el tamaño requerido (esta es la soluciónrecogida en la función ContarOcurrencias).

c) Crear una función y dentro de ella implementar la función recursiva (la primerarecubrirá a la segunda). Los parámetros se pasarán únicamente a la primera y seránglobales a la segunda, la cual no tendrá parámetros formales o si los tiene, serán muypocos.

Aunque son soluciones posibles, para evitar efectos secundarios y escribir un código lo máslimpio posible, sólo vamos a tomar como única solución válida la b) olvidando completamente las dosrestantes en nuestras implementaciones.

Como resumen, habrá que tener en cuenta que:

1. Generalmente pasar por copia los parámetros necesarios para determinar si se haalcanzado el caso base.

2. Si la rutina acumula valores en alguna variable o vector, éste debe ser pasado porreferencia. Cualquier cambio en dicho vector se repercutirá en el resto de llamadasrecursivas posteriores.

3. Aunque depende del tipo de lenguaje, si existen en él como mínimo el paso porreferencia y el paso por copia, como ocurre en C, los vectores y las estructuras dedatos grandes no se pasarán por copia ya que se requiere una mayor demanda dememoria, si no que se pasarán por referencia y para evitar que se puedan modificarde manera errónea, se utilizará la palabra reservada const si trabajamos en C, ocualquier mecanismo de protección de los parámetros.

2.5 ¿ Recursividad o iteración ?La recursividad es una herramienta muy poderosa para resolver problemas, los cuales

resueltos iterativamente podrían tener una resolución muy compleja, sobre todos aquellos cuya propiadefinición es recursiva, aunque esto no asegura que dichos algoritmos recursivos posean unaeficiencia alta, ya que suelen consumir un mayor tiempo de cálculo.

En general, si un problema se puede describir en términos de versiones más pequeñas de élmismo, entonces la recursividad permite expresarlo, en la mayoría de los casos, y por tantoimplementarlo más fácilmente. De igual forma, cuando la recursividad nos permita solucionarproblemas cuyas soluciones iterativas sean difíciles de implementar, utilizaremos esta primeratécnica.

Page 16: 363n + recursividad.pdf) · (se ha reducido el tamaño del problema en una unidad). Nuestro nuevo problema es ahora calcular 4! que, aplicando de nuevo la definición, es 4! = 4 *

Programación II

28

A pesar de estas ideas expresadas en el párrafo anterior, hay dos aspectos que influyen en laineficiencia de algunas soluciones recursivas, y que tras evaluar convenientemente debemos decidirsi son inconvenientes sustanciales como para decidir buscar una solución iterativa al problema:

a) El tiempo asociado con la llamada a las rutinas es una cuestión a tener en cuenta,ya que en cada llamada se aloja en la pila un nuevo entorno, con el consiguiente tiempoadicional consumido en esta operación. En una rutina iterativa la operación se realiza sólouna vez y se puede considerar a este tiempo irrelevante con respecto a la ejecución total dela rutina, pero una simple llamada recursiva inicial puede generar un gran número dellamadas posteriores, penalizando sustancialmente el tiempo final de ejecución con el tiempototal de creación de los entornos.

En el momento de diseñar un algoritmo recursivo hay que decidir si la facilidad deelaboración del algoritmo merece la pena con respecto al costo en tiempo de ejecución queincrementará la gestión de la pila originado por el gran número de posibles llamadasrecursivas.

b) La ineficiencia inherente de algunos algoritmos recursivos. Por ejemplo, fijémonosen el módulo que resuelve la sucesión de Fibonacci. Si se trazan las llamadas recursivas defibonacci(6) podremos apreciar cómo fibonacci(4) se calcula dos veces como problemasseparados, fibonacci(3) se obtiene tres veces; fibonacci(2), cinco veces y fibonacci(1), tres. Esde suponer, según estas evidencias, que conforme aumente n, el número de cálculosrepetidos que se realizarán será mucho mayor, presentando una gran cantidad deprocesamiento duplicado que no es aprovechado para evitar llamadas recursivas posteriores.

Así, es de especial importancia que la rutina recursiva implemente algún tipo detécnica que evite realizar procesamiento duplicado en diferentes partes de la ejecución de larutina. Una solución podría ser la utilización de algún tipo de estructura de datos adicional,como por ejemplo un vector o una lista, que mantuviera toda la información calculada hasta elmomento, para evitar de esta forma, cálculos redundantes.

Adicionalmente, y relacionado con la gestión de la pila en la recursividad, cabe destacar queotro problema puede ser el planteado por la profundidad de la recursión, ya que un gran número dellamadas recursivas almacenadas en la pila y pendientes a ser resueltas puede ocasionar que estazona de memoria se agote, pudiendo bloquearse el ordenador o abortándose la ejecución delprograma. Por tanto, si el tamaño del problema a resolver va a ser considerable, debemosplantearnos el buscar una solución iterativa.

Habiendo decidido que una solución recursiva concreta tiene más inconvenientes queventajas, hay dos alternativas a la hora de diseñar una rutina que resuelva un problema dado: buscarun algoritmo puramente iterativo o, dada la rutina recursiva, intentar eliminar la recursividad,manteniendo la idea básica que sustenta dicha rutina recursiva. En la siguiente sección se hará unbreve estudio de esta segunda alternativa.

2.6 Eliminación de la recursividad.Existen varios lenguajes en los que no se puede utilizar la recursividad como técnica de

resolución de problemas debido a que no están preparados para ello, fundamentalmente por que noestablecen la zona de la pila en la memoria del ordenador. También, y como hemos comentado en lasección anterior, la solución recursiva debe ser evitada cuando implique más inconvenientes queventajas. En estos casos, se deberá estudiar la posibilidad de eliminar la recursión total oparcialmente, utilizando para ello dos técnicas principales:

Page 17: 363n + recursividad.pdf) · (se ha reducido el tamaño del problema en una unidad). Nuestro nuevo problema es ahora calcular 4! que, aplicando de nuevo la definición, es 4! = 4 *

Recursividad

29

a) Eliminación de la recursión de cola.

Cuando existe una llamada recursiva en un módulo que es la última instrucción de la rutina, aesta llamada se le denomina llamada de recursión de cola. En algunos casos puede ser suficienteeliminar esta llamada recursiva para obtener alguna ganancia en lo que se refiere a términos deeficiencia y de espacio de pila ocupado, que haga que sea una solución viable la recursiva.

Para eliminar la recursión de cola, se sustituye la llamada recursiva por las sentencias quecambian los valores de los parámetros que se utilizan en la llamada a suprimir por los valores con losque se iba a realizar dicha llamada.

En general, frente a una estructura con una única llamada recursiva de cola ajustándose alsiguiente patrón:

if (condición) { Ejecutar una tarea. Actualizar condiciones. Llamada recursiva. }

El paso a estructura iterativa es inmediato, ya que es equivalente a un ciclo while:while (condición) { Ejecutar una tarea. Actualizar condiciones. }

Sirva como ejemplo la función recursiva que implementa la solución al problema de las Torresde Hanoi, problema que se estudiará detalladamente en secciones posteriores, pero que nos serviráseguidamente para ejemplificar la eliminación de la recursión de cola:

La rutina recursiva es:

void TorresHanoi(int n; char de; char hacia; char usando){if (n==1) printf(“Moviendo disco 1 desde el poste %c al %c\n”,de, hacia);

elseif (n>1)

{ TorresHanoi(n-1, de, usando, hacia);

printf(“Moviendo disco %i desde el poste %c al %c\n”,n, de, hacia);TorresHanoi(n-1, usando, a, de);

}}Tras eliminar la recursión de cola, sustituyendo la última llamada TorresHanoi(n-1, usando, a,

de) por las asignaciones correspondientes para que los parámetros actuales tengan los valores conlos que se hacía esta llamada, la rutina quedaría como sigue:

void TorresHanoi(int n; char de; char hacia; char usando){char temp;while (n>1)

{

Page 18: 363n + recursividad.pdf) · (se ha reducido el tamaño del problema en una unidad). Nuestro nuevo problema es ahora calcular 4! que, aplicando de nuevo la definición, es 4! = 4 *

Programación II

30

TorresHanoi(n-1, de, usando, hacia); printf(“Moviendo disco %i desde el poste %c al %c\n”,n, de, hacia); n=n-1; temp= de; d= usando; usando= temp; }if (n==1) printf(“Moviendo disco 1 desde el poste %c al %c\n”,de, hacia);}

Consideremos otro ejemplo: la función recursiva Imp_OrdenInversoVector, la cual imprime deforma inversa un vector de enteros (es una variación de la ya estudiada Imp_OrdenInverso):

void Imp_OrdenInversoVector(int *Vector, int Tamanio){if (Tamanio > 0)

{ printf(“%c”, Vector[Tamanio-1]); Imp_OrdenInversoVector(Vector, Tamanio-1); }}

Aplicando el cambio propuesto anteriormente, la función iterativa obtenida al eliminar larecursión de cola quedaría como sigue:

void Imp_OrdenInversoVector(int *Vector, int Tamanio){while (Tamanio > 0) { printf(“%c”, Vector[Tamanio-1]); --Tamanio; }}b) Eliminación de la recursión mediante la utilización de pilas.

Cuando se produce una llamada a una rutina, se introducen en la pila los valores de lasvariables locales, la dirección de la siguiente instrucción a ejecutar una vez que finalice la llamada, seasignan los parámetros actuales a los formales y se comienza la ejecución por la primera instrucciónde la rutina llamada. Cuando acaba el procedimiento o función, se saca de la pila la dirección deretorno y los valores de las variables locales, y por último se ejecuta la siguiente instrucción.

En esta técnica de eliminación de recursividad, el programador implementa una estructura dedatos2 con la que simulará la pila del ordenador, introduciendo los valores de las variables locales yparámetros en la pila, en lugar de hacer las llamadas recursivas, y sacándolos para comprobar si secumplen los casos bases. Se añade un bucle, generalmente while, que iterará mientras la pila no estévacía, hecho que significa que se han finalizado las llamadas recursivas.

Al meter en la pila los valores de las variables locales y los parámetros formales, así como enalgunos casos la dirección de retorno (el lugar donde deberá volver el control cuando la invocaciónde la rutina termine), se simula la invocación recursiva. Cuando se saca de la pila, se está simulandoel comienzo de la rutina recursiva, ya que se recuperan los valores de las variables locales yparámetros formales con los que se haría la llamada recursiva.

2 Se recomienda, para la perfecta compresión de esta sección que el lector estudie detenidamente elconcepto de Tipo de Dato Abstracto Pila

Page 19: 363n + recursividad.pdf) · (se ha reducido el tamaño del problema en una unidad). Nuestro nuevo problema es ahora calcular 4! que, aplicando de nuevo la definición, es 4! = 4 *

Recursividad

31

Como hemos dicho anteriormente, se va a utilizar el T.D.A. Pila, del cual vamos a utilizar enesta sección las siguientes primitivas (se estudiarán con más detalle en el capítulo 4):

TPila crear() => Crea una pila y la deja lista para ser usada.

void destruir(TPila P) => Libera los recursos ocupados por una pila.

void push(TElemento x, TPila UnaPila) => Introduce en el tope de la pila UnaPila el valor de lavariable x.

TElemento tope(TPila UnaPila) => Devuelve el valor que ocupa el tope de la pila UnaPila.

void pop(Tpila UnaPila) => Elimina el elemento que está en el tope de la pila.

int vacia(TPila UnaPila) => Devuelve 1 si la pila UnaPila no tiene elementos y 0 en caso de queno esté vacía.

A continuación, eliminaremos la recursividad de la ya conocida función ncsr:int ncsr(int n, int k){

if (k > n) return 0;else if ((n == k) || (k == 0)) return 1;

else return ncsr(n-1, k) + ncsr(n-1, k-1);}Obteniendo la siguiente función no recursiva:int ncsr_nr(int n, int k){ tPila Pila; int Suma=0; Pila=crear();

push(n, Pila); push(k, Pila);while (!vacia(Pila))

{ k= tope(Pila); pop(Pila); n= tope(Pila); pop(Pila);

if (k >n ) Suma += 0;else if (k == n || k== 0) Suma +=1;

else { push(n-1, Pila); push(k-1, Pila); push(n-1, Pila); push(k, Pila); }

} destruir(Pila);return Suma;

}

Page 20: 363n + recursividad.pdf) · (se ha reducido el tamaño del problema en una unidad). Nuestro nuevo problema es ahora calcular 4! que, aplicando de nuevo la definición, es 4! = 4 *

Programación II

32

Después de crear la pila, las primeras sentencias con las que nos encontramos son las queintroducen en la pila los dos valores de los parámetros formales. Seguidamente hallamos un ciclowhile cuyo cuerpo se ejecutará mientras la pila no esté vacía. ¿Qué se hace dentro del ciclo? Serecuperan los valores de n y k de la pila y se comprueban los casos bases. Si se cumplen seactualiza la variable Suma con los valores que devolvería la rutina recursiva cuando se alcanzaran loscasos bases. En caso contrario, en la rutina recursiva se procedería a llamarse a sí misma condiferentes parámetros. Ahora, se introducen en la pila los valores de dichos parámetros (en nuestroejemplo, son cuatro los valores que se meten, correspondientes con los dos parámetros de las dosllamadas recursivas) para posteriormente, cuando comience de nuevo el bucle, sacarlos yprocesarlos.

De manera general, dada una estructura recursiva básica de la forma:RutinaRecursiva(parámetros)Si se han alcanzado los casos basesEntonces

Realizar las acciones que correspondan a dichos casos bases.Si no

Llamar recursivamente a la rutina actualizando convenientemente los parámetros dedicha llamada.

La estructura básica de la eliminación de la recursividad es la siguiente:

RutinaNoRecursiva(parámetros)Meter los parámetros y variables locales en la pila.Repetir mientras no esté vacía la pila

Sacar los elementos que están en el tope de la pilaSi éstos cumplen los casos basesEntonces

Realizar las acciones que correspondan a dichos casos bases.Si no

Meter en la pila los valores con los que se produciría la(s) llamada(s)recursiva(s).

Hay que tener en cuenta que, si los parámetros y las variables locales son de diferente tipo,habrá que tener tantas pilas como tipos haya para que alberguen los valores de las variables de esostipos.

2.7 Algunas técnicas de resolución de problemas basadasen la recursividad.Son muchas las técnicas de resolución de problemas que utilizan la recursividad como idea

básica. En esta sección serán dos las que presentaremos someramente y de las que ofreceremosalgún ejemplo para comprender sus fundamentos.

Page 21: 363n + recursividad.pdf) · (se ha reducido el tamaño del problema en una unidad). Nuestro nuevo problema es ahora calcular 4! que, aplicando de nuevo la definición, es 4! = 4 *

Recursividad

33

2.7.1 Divide y vencerás.

Esta técnica soluciona un problema mediante la solución de instancias más pequeñas dedicho problema, de tal manera que dichos subproblemas tienen una resolución más fácil,construyendo la solución al problema original a partir de dichas "subsoluciones". Así, teniendo encuenta este procedimiento de actuación, si se desea encontrar un problema de tamaño n, si éstetamaño es menor que un umbral dado, entonces se podrá aplicar, en caso de que exista, un algoritmosencillo para resolverlo, ya que el tamaño del problema permite que sea abordado por un algoritmoque encuentre la solución de forma simple y rápida. En caso contrario, se dividirá el problema detamaño n en m subproblemas distintos de menor tamaño y se encontrará la solución a cada uno deellos mediante una llamada recursiva. Finalmente, y una vez solucionados dichos subproblemas, dealguna forma se combinarán para calcular la solución al problema inicial.

Tanto el tamaño umbral en el que se aplicará un algoritmo sencillo, como el número desubproblemas en los que se dividirán el primero y la forma de combinar las soluciones dependeráclaramente del tipo de problema, no existiendo una regla que pueda determinar dichos valores, y serála persona que diseñe el algoritmo quien deberá decidir.

Como se puede apreciar la filosofía de esta técnica se corresponde totalmente con larecursividad, aunque hay que avisar de nuevo que no todos los problemas son aptos para serresueltos por el "divide y vencerás".

Búsqueda binaria.

En primer lugar estudiemos el algoritmo de búsqueda binaria recursiva de un valor en unvector ordenado, que ya utilizamos en la primera sección de este capítulo para ayudarnos a explicarel concepto de recursión.

Básicamente, el problema se resuelve determinando si el elemento buscado está en laposición que ocupa la mitad del vector. Si estuviera, concluiríamos la búsqueda. Si no es así,determinamos en qué mitad debería estar el elemento: si fuera menor o igual que el valor de laposición de la mitad, deberíamos buscar en la mitad izquierda, y si fuera mayor, en la parte derecha.Una vez decidida la mitad donde deberíamos buscar, el problema original (búsqueda de un elementoen el vector completo) lo sustituimos por la búsqueda sólo en una mitad (obviamente, el problemaoriginal se reduce de tamaño), en donde volveríamos a repetir todo el proceso descrito anteriormente.¿Cuándo acabaríamos? En el momento en que encontremos el valor buscado, o cuando el tamañodel vector sea cero, lo que indica que no está el valor buscado.

En este caso, y siguiendo las directrices de la técnica "divide y vencerás", el problema sedivide en dos subproblemas de igual tamaño e independientes entre sí, lo que implica queposteriormente no habrá que combinar las soluciones, pues la solución a un subproblema será lasolución general.

La función recursiva en C que implementará la búsqueda binaria recursiva (BusqBinaria)recibirá como parámetros un vector de números enteros (Vector), y tres parámetros formales enteros:los límites inferior (LimInf) y superior (LimSup) del vector y el valor a buscar (Valor), todos ellospasados por copia. Los límites nos indicarán en cada momento el tamaño y el subvector determinadodonde buscaremos.

El código en C de la función que resuelve el problema es el siguiente:int BusqBinaria(int *Vector, int LimInf, int LimSup, int Valor){int Mitad;

if (LimInf > LimSup) return -1;else

{

Page 22: 363n + recursividad.pdf) · (se ha reducido el tamaño del problema en una unidad). Nuestro nuevo problema es ahora calcular 4! que, aplicando de nuevo la definición, es 4! = 4 *

Programación II

34

Mitad= (LimInf + LimSup)/2;if (Valor == Vector[Mitad]) return Mitad;

elseif (Valor <= Vector[Mitad])

return BusqBinaria(Vector, LimInf, Mitad-1, Valor);else return BusqBinaria(Vector, Mitad+1, LimSup,Valor);

}}Tras comprobar que los límites pasados como parámetros actuales se han cruzado, en cuyo

caso el inferior es mayor que el superior, lo que implica que el valor buscado no está en el vector,devolviendo la función -1 para indicarlo, y una vez calculada la posición de la mitad, que esa posiciónno contenga dicho valor, se determina en qué mitad está: si lo está en la mitad izquierda (es menor oigual que el valor de la posición central), los nuevos límites del vector donde se buscarán serán LimInfy Mitad -1; si lo está en la mitad superior (el valor es mayor que el central), los límites se convertiránen Mitad + 1 y LimSup.

La invocación inicial para un vector de enteros llamado VectEnt compuesto de 10 elementos,en donde se desea buscar el valor 8 sería: Posicion= BusqBinaria(VectEnt, 0, 9, 8); Dejamos a cargodel lector la comprobación de las cuatro preguntas para validar esta solución recursiva.

Las torres de Hanoi.

Los autores de [CHV88] hacen una descripción bastante ilustrativa del problema que vamos aabordar a continuación. Expresada de manera algo abreviada, es la siguiente:

Cuenta la leyenda que el emperador de un país con capital en la actual ciudad vietnamita deHanoi, debía elegir a un nuevo sabio del reino tras la jubilación del actual. Para ello, el sabio todavíaen ejercicio pensó en un problema y aquella persona que lo solucionara ocuparía su puesto en lacorte.

Utilizando tres postes (A, B y C) y n discos de diferentes tamaños y con un agujero en susmitades para que pudieran meterse en los postes, y considerando que un disco sólo puede situarseen un poste encima de uno de tamaño mayor, el problema consistía en pasarlos, de uno en uno, delposte A, donde estaban inicialmente colocados, al poste B, utilizando como poste auxiliar el restante(C), cumpliendo siempre la restricción de que un disco mayor no se pueda situar encima de otromenor. El estado inicial de los postes y el final es el que queda representado gráficamente en la figura7 mediante las situaciones 1 y 2:

Page 23: 363n + recursividad.pdf) · (se ha reducido el tamaño del problema en una unidad). Nuestro nuevo problema es ahora calcular 4! que, aplicando de nuevo la definición, es 4! = 4 *

Recursividad

35

Figura 7. Estados inicial y final y pasos intermedios a la resolución.

El problema parecía fácil y fueron muchos los que lo intentaron, aunque nadie lo conseguía.El emperador estaba ya algo desesperado al no encontrar sucesor para el puesto de sabio, cuandoun monje budista se presentó ante él , diciéndole que el problema era tan fácil , que casi se resolvía así mismo. Esto sorprendió al emperador y éste le dijo que se lo explicara. El monje le dioprimeramente la solución para un sólo disco: mover el disco de A a B, para continuar seguidamentecon la solución para el caso en que hubiera más de un disco:

1. Solventar el problema para n-1 discos, ignorando el último de ellos, pero ahora teniendo encuenta que el poste destino será C y B será el auxiliar (estado 3 de la figura 7).

2. Una vez hecho esto anterior, los n - 1 discos estarán en C y el más grande permanecerá en A.Por tanto, tendríamos el problema cuando n = 1, en cuyo caso se movería ese disco de A a B(estado 4 de la figura 7).

3. Por último, nos queda mover los n-1 discos de C a B, utilizando A como poste auxiliar (estado 5de la figura 7).

Desde el punto de la filosofía de la técnica "divide y vencerás", el problema original se divideen tres subproblemas de tamaño menor, correspondiente a los tres puntos anteriores, dos de loscuales se solventarán utilizando llamadas recursivas, y el que queda será tan simple de resolver queno hace falta una nueva invocación recursiva.

Aplicando de manera directa los tres pasos que el monje indicaba, presentaremos la función

recursiva TorresHanoi que implementa la solución al problema en un total de 2N-1 movimientos y quetiene como parámetros el número de discos con los que se resolverá el problema (NumDiscos) y trescaracteres que representan a cada poste (Origen, Destino y Auxiliar).

void TorresHanoi(int NumDiscos, char Origen, char Destino, char Auxiliar){if (NumDiscos == 1)

printf("Mover el disco de arriba del disco %c al %c.\n", Origen, Destino);else

{ TorresHanoi(NumDiscos-1, Origen, Auxiliar, Destino); TorresHanoi(1, Origen, Destino, Auxiliar);

Page 24: 363n + recursividad.pdf) · (se ha reducido el tamaño del problema en una unidad). Nuestro nuevo problema es ahora calcular 4! que, aplicando de nuevo la definición, es 4! = 4 *

Programación II

36

TorresHanoi(NumDiscos-1, Auxiliar, Destino, Origen); }}Para un total de 5 discos, la llamada a la función sería:

TorresHanoi(5, 'A', 'B', 'C');

Por último, comentar a modo de curiosidad, que otra leyenda cuenta que hay un grupo demonjes que tienen la misión de resolver el problema con un total de 40 discos de oro sobre trespostes de diamante y que cuando finalicen, el mundo se acabará. Esperemos por nuestro bien queestos monjes no utilicen un ordenador para solucionarlo ;-)

2.7.2 Backtracking.

Para comprender el funcionamiento de esta técnica vamos a comenzar viendo cómo sesoluciona el "problema de las ocho reinas". Partimos de un tablero de ajedrez, el cual tiene un total de64 casillas (8 filas x 8 columnas). El problema consiste en situar ocho reinas en el tablero de talforma que no se den jaque entre ellas. Una reina puede dar jaque a aquellas reinas que se sitúen enla misma fila, columna o diagonal en la que se encuentra dicha reina. En la figura siguiente, podemosobservar una solución al problema:

R

R

R

R

R

R

R

R

Figura 8. Una solución para el problema de las 8 reinas.

La solución a este rompecabezas pasa, por tanto, en situar una reina en cada fila y en cadacolumna. ¿Cómo actuaríamos para situar las ocho reinas? Comenzaríamos por la primera columna ysituaríamos la primera reina. Al hacer esto, eliminamos como posibles casillas donde localizar reinasla fila, la columna y las dos diagonales. Teniendo en cuenta estas restricciones, se sitúa en lasegunda columna la segunda reina y se "tachan" las nuevas casillas prohibidas. Seguidamente seprocede a poner la tercera reina y así sucesivamente.

Supongamos que se han situado las seis primeras reinas. Puede llegar un momento en el queno se pueda situar la séptima sin que ninguna otra le dé jaque. En ese momento, se deshace elmovimiento que ha ubicado la sexta reina y busca otra posición válida para dicha reina. Si no sepudiera, se vuelve deshacer el movimiento de la sexta reina y se intenta buscar otra localización. Enel caso de que no sea posible, se sigue hacia atrás intentando colocar la reina quinta. Si se puedecolocar, entonces se procederá a dejar en el tablero la sexta de nuevo. Si se ha conseguido sin que leden jaque, se intentará emplazar la séptima y, por último, la octava. En general, si hay algúnproblema, se deshace el último movimiento y se prueba a localizar una casilla alternativa. Si no seconsigue, se vuelve hacia atrás y así sucesivamente.

Ésta técnica de prueba-error o avance-retroceso, se conoce como bactracking (vuelta atrás).En ella se van dando pasos hacia delante mientras sea posible y se deshacen cuando se ha llegado auna situación que no conduce a la resolución del problema original.

Page 25: 363n + recursividad.pdf) · (se ha reducido el tamaño del problema en una unidad). Nuestro nuevo problema es ahora calcular 4! que, aplicando de nuevo la definición, es 4! = 4 *

Recursividad

37

¿Cómo se puede aplicar la recursividad para solventar este problema? De forma muysencilla: dado que se ha situado una reina en una posición correcta en una columna, hay queconsiderar el problema de situar otra reina en la columna siguiente: resolver el mismo problema conuna columna menos. En este momento realizaríamos una llamada recursiva. ¿El caso base? Cuandoel tablero se haya reducido a uno de tamaño cero, en cuyo caso no se hará nada. Por tanto, se partiráde un problema de tamaño 8, y se considerarán problemas de tamaño menor quitando una columnaen cada llamada recursiva hasta que se llegue a un tablero de tamaño cero, momento en el cualhabremos alcanzado el caso base. Si se puede situar una reina en la columna correspondiente, sehace una llamada recursiva, si no, se vuelve hacia atrás y se prueba otras posiciones.

Para implementar la función recursiva en C que encuentre una solución para el problema delas ocho reinas necesitaremos una estructura de datos para representar el tablero, para lo cualutilizaremos una matriz de tamaño 8 x 8 de enteros, donde un 1 en una posición indicará que haysituada una reina en ella y un 0, que no está ocupada.

int Tablero[8][8];

Inicialmente todos los elementos de la matriz bidimensional serán 0, indicando que no hayninguna reina en el tablero y será global a la función recursiva, con objeto de ahorrar espacio en lapila.

Las funciones auxiliares que nos ayudarán a resolver el problema y que no implementaremosaquí por ser irrelevantes al tema que nos centra nuestra atención, son:

void AsignarReinaA(int Fila, int Columa) => Sitúa un 1 en la casilla (Fila, Columna)indicando que está ocupada por una reina.

void EliminarReinaDe(int Fila, int Columna) => Sitúa un 0 en la casilla (Fila, Columna)indicando que esa casilla está libre (antes estaba ocupada por una reina y ahora deja deestarlo).

int RecibeJaqueEn(int Fila, int Columna) => Devuelve 1 si la casilla (Fila, Columna)recibe jaque de alguna reina y 0 en caso contrario.

La función recursiva que implementaremos será void SituarReina(int Columna, int *Situada).El parámetro formal Col indica en qué columna se quiere situar la reina, y Situada, pasado porreferencia, es un parámetro que tomará el valor 1 cuando se haya logrado ubicar correctamente a lareina correspondiente, y 0 cuando el intento haya sido infructuoso. Columna se pasará por copia paraque, cuando se realice backtracking se pueda trabajar con el valor original de esta variable.

La llamada a esta función se hace con Col == 0 (primera columna de la matriz).void SituarReina(int Columna, int *Situada){int Fila;if (Columna > 7) *Situada=1;else

{ *Situada=0; Fila=1;

while (!(*Situada) && (Fila <= 7)) if (RecibeJaqueEn(Fila, Columna)) ++Fila; else

{ AsignarReinaA(Fila, Columna); SituarReina(Columna+1, *Situada);

if ( !(*Situada)) { EliminaReinaDe(Fila, Columna);

Page 26: 363n + recursividad.pdf) · (se ha reducido el tamaño del problema en una unidad). Nuestro nuevo problema es ahora calcular 4! que, aplicando de nuevo la definición, es 4! = 4 *

Programación II

38

++Fila; } }

}}

Básicamente, se comprueba si se está en el caso base, en cuyo caso se indica que se hasituado la reina en una casilla libre. En otro caso, se comenzará un bucle que iterará mientras no sehaya situado correctamente la reina dentro de la columna especificada en el parámetro, o noshayamos salido fuera del tablero, en cuanto a las filas se refiere. En el bucle se comprobará si en lacasilla (Fila, Columna) se puede situar la reina. Si no es así, se incrementa la fila para probar en otracasilla de la misma columna. Si no recibiera jaque una reina situada en esa casilla, se asigna a laposición del tablero y se llama recursivamente a la función SituarReina para situar en la siguientecolumna otra reina. Cuando han finalizado las llamadas recursivas, se comprueba el valor de Situadapara ver si ha habido éxito en el resto de intentos. En el caso de que no, se deshace la asignaciónanterior y se incrementa en una la fila para probar con otra casilla.

De forma sencilla se puede modificar esta función para que calcule, no sólo una soluciónposible, si no todas las existentes, tarea que dejamos a cargo del lector.

2.8 Bibliografía.• [AHU88] A. Aho, J.E. Hopcroft, J. Ullman. Estructuras de datos y algoritmos. Addison-Wesley

(1.988).

• [BB86] P. Berlioux, P. Bizard. Algorithms. The construction, proof and analysis of programs.John Wiley and Sons (1.986).

• [BB90] G. Brassad, P. Bratley. Algorítmica. Concepción y análisis. Masson (1.990).

• [CCP93] F.J. Cortijo, J.C. Cubero, O. Pons. Metodología de la programación. Programas yestructuras de datos en Pascal. ISBN. 84-604-7652-9 (1.993).

• [CHV88] F. Canorro, P. Helman, R. Veroff. Data abstraction and problem solving with C++.Walls and Mirrors. 2nd Edition. Addison-Wesley (1.988).

• [FT96] W. Ford, W. Topp. Data structures with C++. Prentice Hall International (1.996).

• [LAT97] Y. Langsom, M. Augenstein, A. Tenenbaum. Estructuras de datos con C y C++. 2ªEdición. Prentice Hall (1.997).

• [Riv99] M. L. Rivero Cejudo. Programación: parte teórica. Colección Apuntes. Universidad deJaén (1.999).

• [Sed88] R. Sedgewick. Algorithms in C. 2ndEdition. Addison-Wesley (1.988).

• [Sed98] R. Sedgewick. Algorithms in C. 3rd Edition. Addison-Wesley (1.998).

• [Wir86] N. Wirth. Algorithms and data structures. Prentice Hall (1986).