Vous êtes sur la page 1sur 20

ESTRUCTURAS DE DATOS

UNIDAD UNO - SEMANA DOS


RECURSIÓN *

TABLA DE CONTENIDO

1. FUNCIONES RECURSIVAS 2
1.1. FUNCIÓN DE FIBONACCI 3
2. MEMOIZATION 9
2.1. IMPLEMENTACIÓN DE LA FUNCIÓN DE FIBONACCI USANDO MEMOIZATION 9
3. DIVIDIR Y CONQUISTAR (DIVIDIR Y VENCER) 10
3.1. TORRES DE HANOI 10
3.2. BÚSQUEDA BINARIA (BINARY SEARCH) 15
3.3. ALGORITMO DE ORDENAMIENTO POR MEZCLA (MERGE SORT) 18
4. BACKTRACKING 19
EN RESUMEN 20
PARA TENER EN CUENTA 20

*
Resumen del libro Estructuras de Datos en Java de Alejandro Sotelo Arévalo, cuya publicación está pendiente.

ESTRUCTURAS DE DATOS 1
1. FUNCIONES RECURSIVAS

Una función es recursiva si está definida en términos de sí misma. Claramente debe haber
casos especiales en los que una función recursiva esté definida mediante constantes y
llamados a otras funciones, porque de lo contrario existirían llamados recursivos infinitos a la
función. Por ejemplo, la función

no está bien definida porque la evaluación en cualquier punto implicaría un número infinito
de llamados, lo que se evidencia al intentar calcular la función en algún , por decir algo, en
:
 aplicando la definición con .
 aplicando la definición con .
 aplicando la definición con .
 aplicando la definición con .
 aplicando la definición con .
...

Observe que el proceso seguiría indefinidamente, involucrando un número infinito de


llamados recursivos. Así pues, la definición de una función recursiva debe estar formada por:
Casos base: son casos triviales en los que la definición de la función no depende de la
función misma.
Casos recursivos: son casos complejos en los que la definición de la función referencia a la
función misma.

El diseño de algoritmos mediante la recursión como técnica de programación tiene ventajas y


desventajas.

Ventajas:
• Frecuentemente, al trabajar sobre estructuras de datos definidas recursivamente es más
natural diseñar algoritmos recursivos que iterativos. La forma de las soluciones recursivas
sobre estructuras de datos definidas recursivamente reflejan el carácter recursivo de la
definición.
• A veces es más fácil pensar una solución recursiva que una iterativa.
• Para algunos problemas, se puede diseñar algoritmos recursivos eficientes más sencillos de
escribir que sus contrapartes iterativas.

Desventajas:
• La máquina debe manejar estructuras adicionales para controlar los llamados recursivos, lo
que resulta en mayor tiempo de ejecución y espacio extra adicional.
• Hay un límite para el nivel de anidamiento de los llamados recursivos que, si se sobrepasa,
se lanza una excepción en la máquina (StackOverflow).

ESTRUCTURAS DE DATOS 2
• Si los casos inductivos tienen más de una referencia a la función es posible que se efectúen
reiteradamente llamados a la función con los mismos parámetros, lo que implica gastar
tiempo de ejecución en repetir cálculos innecesarios. Para resolver este problema se puede
aplicar la técnica conocida como Memoization, que consiste en declarar una estructura de
datos adicional que guarde los resultados de los llamados recursivos de tal forma que no se
repitan cálculos innecesarios.

1.1. FUNCIÓN DE FIBONACCI


Recurso como proyecto en Eclipse: Fibonacci.zip.

La función de Fibonacci se define por la fórmula

que puede ser evaluada sobre cualquier número natural , simulando los llamados
recursivos:






Código 1: Implementación básica en Java de la función de Fibonacci.


// VERSIÓN A : ALGORITMO RECURSIVO TÍPICO
public static int fibA(int n) { // n>=0
if (n==0) { // Si n es cero
return 0; // Retornar 0
}
else if (n==1) { // Si n es uno
return 1; // Retornar 1
}
else { // Si n es mayor o igual que dos
return fibA(n-1)+fibA(n-2); // Retornar fibA(n-1)+fibA(n-2)
}
}

Código 2: Programa que prueba la función de Fibonacci para diversos valores de .


public class Fibonacci { // Declaración de la clase
public static void main(String[] args) { // Método main
for (int i=0; i<=2000; i++) {
System.out.println("fib("+i+")="+Fibonacci.fibA(i));
}
}
// VERSIÓN A : ALGORITMO RECURSIVO TÍPICO
public static int fibA(int n) { // n>=0
if (n==0) { // Si n es cero
return 0; // Retornar 0
}
else if (n==1) { // Si n es uno

ESTRUCTURAS DE DATOS 3
return 1; // Retornar 1
}
else { // Si n es mayor o igual que dos
return fibA(n-1)+fibA(n-2); // Retornar fibA(n-1)+fibA(n-2)
}
}
}

Tabla 3: Impresión en consola del programa anterior.


fib(0)=0 fib(16)=987 fib(32)=2178309
fib(1)=1 fib(17)=1597 fib(33)=3524578
fib(2)=1 fib(18)=2584 fib(34)=5702887
fib(3)=2 fib(19)=4181 fib(35)=9227465
fib(4)=3 fib(20)=6765 fib(36)=14930352
fib(5)=5 fib(21)=10946 fib(37)=24157817
fib(6)=8 fib(22)=17711 fib(38)=39088169
fib(7)=13 fib(23)=28657 fib(39)=63245986
fib(8)=21 fib(24)=46368 fib(40)=102334155
fib(9)=34 fib(25)=75025 fib(41)=165580141
fib(10)=55 fib(26)=121393 fib(42)=267914296
fib(11)=89 fib(27)=196418 fib(43)=433494437
fib(12)=144 fib(28)=317811 fib(44)=701408733
fib(13)=233 fib(29)=514229 fib(45)=1134903170
fib(14)=377 fib(30)=832040 fib(46)=1836311903
fib(15)=610 fib(31)=1346269 fib(47)=-1323752223

Todo parece ir bien, pero … la implementación fib1 tiene los siguientes problemas:
1. El tipo de datos int sólo permite representar números enteros hasta (es decir,
hasta 2147483647), porque utiliza sólo 32 bits para su representación. Por lo tanto, todas las
operaciones que superen este umbral arrojarían resultados erróneos, hecho que se conoce
como desbordamiento del tipo de datos. Por esta razón es que el Fibonacci de 47 da un valor
extraño (-1323752223), que no corresponde con el resultado que debería tener:
1134903170+1836311903=2971215073.
2. Se demora muchísimo para valores de cercanos a .

Abordemos el primer problema: el de desbordamiento del tipo de datos. Una solución


temporal es cambiar int por long, que usa 64 bits para su representación, permitiendo la
manipulación de números hasta (es decir, hasta 9223372036854775807).
Claramente esta modificación sólo pospondría el problema y no lo solucionaría
definitivamente. Para corregir el desbordamiento de una vez por todas, es necesario el uso
de una librería especializada en el manejo de números grandes, como BigInteger en Java.

BigInteger es una clase de Java que nos permite operar con números de precisión arbitraria

.


La documentación de la clase BigInteger está disponible en el API de Java en el sitio
http://java.sun.com/javase/6/docs/api/

ESTRUCTURAS DE DATOS 4
Tabla 4: Algunos servicios provistos por la clase BigInteger.
Operación Descripción
BigInteger.valueOf(x) Convierte un número x de tipo long a un número de tipo BigInteger.
new BigInteger(s)
Convierte un número s almacenado como cadena de texto a un número
de tipo BigInteger.
a.add(b)
Retorna un nuevo BigInteger con el resultado de a+b (la suma), donde
a y b son dos números de tipo BigInteger.

a.subtract(b)
Retorna un nuevo BigInteger con el resultado de a-b (la resta), donde
a y b son dos números de tipo BigInteger.
Retorna un nuevo BigInteger con el resultado de a*b (la
a.multiply(b)
multiplicación), donde a y b son dos números de tipo BigInteger.
Retorna un nuevo BigInteger con el resultado de a/b (la división
a.divide(b)
entera), donde a y b son dos números de tipo BigInteger.
Retorna un nuevo BigInteger con el resultado de a%b (el residuo
a.mod(b)
entero), donde a y b son dos números de tipo BigInteger.
Retorna un nuevo BigInteger con el máximo común divisor de a y b,
a.gcd(b)
donde a y b son dos números de tipo BigInteger.
Retorna un nuevo BigInteger con el mínimo valor entre a y b, donde a
a.min(b)
y b son dos números de tipo BigInteger.
Retorna un nuevo BigInteger con el máximo valor entre a y b, donde a
a.max(b)
y b son dos números de tipo BigInteger.
Retorna verdadero si los números a y b son iguales, y retorna falso si
a.equals(b)
son distintos, donde a y b son dos números de tipo BigInteger.

Contando con BigInteger ahora sí somos capaces de hacer operaciones con números
grandes, como
761809243486409043837*2046696616531860150-4525695587262334931605*324298040889334
escribiendo un programa muy sencillo

Código 5: Programa que ilustra el uso de la clase BigInteger.


import java.math.*; // Paquete necesario para usar BigInteger
public class EjemploBigInteger { // Declaración de la clase
// Declaración del método main
public static void main(String[] args) {
BigInteger a=new BigInteger("761809243486409043837");
BigInteger b=new BigInteger("2046696616531860150");
BigInteger c=new BigInteger("4525695587262334931605");
BigInteger d=new BigInteger("324298040889334");
BigInteger r=(a.multiply(b)).subtract(c.multiply(d));
System.out.println(r);
}
}

que nos informa por consola


que el resultado es el valor
1557724726873718731381506195680239394480, que claramente no cabe ni en una variable de
tipo int ni en una variable de tipo long. Usar el tipo de datos double no es una opción,
porque nos da como resultado 1.557724726873719E39 (en notación exponencial, sería
), que no es exacto porque no tiene todos los dígitos de la
respuesta.

ESTRUCTURAS DE DATOS 5
Corrigiendo el defecto de desbordamiento obtenemos una nueva versión de la
implementación de Fibonacci, que da solución a nuestro primer inconveniente.

Código 6: Implementación recursiva en Java de la función de Fibonacci, usando BigInteger.


import java.math.*; // Paquete necesario para usar BigInteger
...
// VERSIÓN B : ALGORITMO RECURSIVO TÍPICO, USANDO BIGINTEGER
public static BigInteger fibB(int n) { // n>=0
if (n==0) { // Si n es cero
return new BigInteger("0"); // Retornar 0
}
else if (n==1) { // Si n es uno
return new BigInteger("1"); // Retornar 1
}
else { // Si n es mayor o igual que dos
return fibB(n-1).add(fibB(n-2)); // Retornar fibB(n-1)+fibB(n-2)
}
}

Ahora, ataquemos el problema de eficiencia. En el computador en el que se probó la función,


el cálculo del Fibonacci de se demoró 655906 milisegundos (10.9 minutos) y el
cálculo del Fibonacci de se demoró 1097766 milisegundos (18.3 minutos). Sin duda
alguna, ¡esto es demasiado tiempo!.

Para saber qué tan demorada es la versión fibB codificaremos un programa capaz de
exportar una tabla csv que muestre cuántos milisegundos tarda la función fibB calculando
cada uno de los Fibonacci’s desde hasta .

Código 7: Programa que mide cuánto se demora la implementación recursiva en calcular


la función de Fibonacci sobre algunos valores.
import java.math.*; // Importar todas las clases del paquete java.math
import java.io.*; // Importar todas las clases del paquete java.io
public class FibonacciTiempos { // Declaración de la clase
public static void main(String[] args) throws Exception { // Método main
// Abrir el archivo tablaTiempos_fibB.csv para escritura
PrintWriter pw=new PrintWriter("tablaTiempos_fibB.csv");
// Imprimir el encabezado de la tabla csv
pw.println("n;fib(n);demora real calculando fib(n)");
for (int i=0; i<=45; i++) { // Para cada i desde 0 hasta 45
// Tomar el tiempo inicial
long tmIni=System.currentTimeMillis();
// Calcular el fibonacci de i con el método fibB
BigInteger r=FibonacciTiempos.fibB(i);
// Tomar el tiempo final
long tmFin=System.currentTimeMillis();
// Calcular cuánto tiempo se demoró la función fibB en ejecutar
long tmDif=tmFin-tmIni;
// Imprimir tanto en consola como en el archivo la información
System.out.println(i+";"+r+";"+tmDif);
pw.println(i+";"+r+";"+tmDif);
// Obligar al PrintWriter pw a que baje a disco duro la información escrita
pw.flush();
}
pw.close(); // Cerrar el archivo tablaTiempos_fibB.csv
}
// VERSIÓN B : ALGORITMO RECURSIVO TÍPICO, USANDO BIGINTEGER
public static BigInteger fibB(int n) { // n>=0

ESTRUCTURAS DE DATOS 6
if (n==0) { // Si n es cero
return new BigInteger("0"); // Retornar 0
}
else if (n==1) { // Si n es uno
return new BigInteger("1"); // Retornar 1
}
else { // Si n es mayor o igual que dos
return fibB(n-1).add(fibB(n-2)); // Retornar fibB(n-1)+fibB(n-2)
}
}
}

El archivo exportado (tablaTiempos_fibB.csv) se puede visualizar con Microsoft Excel o con


Open Office Calc. Graficando el tiempo en milisegundos que se demoró la función fibB
calculando el Fibonacci de , versus , obtenemos:

Gráfica 8: Demora real (en milisegundos) calculando el Fibonacci de , versus .

Observe que la función que describe el consumo de tiempo de la función fibB versus es
una función exponencial. Más precisamente, se puede demostrar que la forma de esta
función es una constante multiplicada por , donde es una constante conocida en
el mundo matemático como phi o número de oro, y que es aproximadamente igual a 1.61.

Se dice entonces que la complejidad temporal de la función fibB es ). Informalmente,


la complejidad temporal es una medida de qué tanto tiempo consume un algoritmo en su
ejecución y, en general, sirve para medir la eficiencia de un programa.

¿Qué significa esa rara que se colocó? En términos informales, se dice que un
algoritmo es (lo que se lee textualmente de ) si el tiempo que se demora el
algoritmo para resolver un problema de tamaño está por debajo de un múltiplo constante
de la función . Esta notación se conoce en español como la notación de la gran , y en
inglés como Big-Oh notation.

Código 9: Algoritmo iterativo eficiente para calcular la función de Fibonacci.


// VERSIÓN C : ALGORITMO ITERATIVO TÍPICO
public static BigInteger fibC(int n) { // n>=0
// Inicialice la variable 'a' en 0 y la variable 'b' en 1:
BigInteger a=new BigInteger("0"),b=new BigInteger("1");
for (int i=0; i<n; i++) { // Ejecutar exactamente n veces el siguiente proceso:
BigInteger c=a.add(b); // La variable auxiliar c guarda el valor de a+b.
a=b; // A la variable 'a' asígnele el valor de la variable 'b'.
b=c; // A la variable 'b' asígnele el valor de la variable 'c'.

ESTRUCTURAS DE DATOS 7
}
return a; // Retorne el valor de la variable 'a'.
}

¡Convénzase usted mismo de que el algoritmo funciona! Teóricamente nos vamos a


concientizar de que esta versión (fibC) es mucho más eficiente que la anterior (fibB).

El fragmento de código
BigInteger c=a.add(b); // La variable auxiliar c guarda el valor de a+b.
a=b; // A la variable 'a' asígnele el valor de la variable 'b'.
b=c; // A la variable 'b' asígnele el valor de la variable 'c'.
hace una suma y tres asignaciones. Por lo tanto su complejidad temporal es , porque
ejecuta un número constante de operaciones. En general, todo programa que ejecute un
número constante de operaciones tiene complejidad temporal .

La inicialización
BigInteger a=new BigInteger("0"),b=new BigInteger("1");
crea dos números y hace dos asignaciones. Entonces, también tiene complejidad .

El retorno
return a; // Retorne el valor de la variable 'a'.
simplemente entrega el valor de la variable a como resultado. Su complejidad también es
.

Y finalmente, el ciclo
for (int i=0; i<n; i++) { // Ejecutar exactamente n veces el siguiente proceso:
BigInteger c=a.add(b); // La variable auxiliar c guarda el valor de a+b.
a=b; // A la variable 'a' asígnele el valor de la variable 'b'.
b=c; // A la variable 'b' asígnele el valor de la variable 'c'.
}
hace exactamente iteraciones, donde en cada una de éstas se efectúa un número
constante de operaciones. Se concluye pues que la complejidad temporal del ciclo es ,
porque ejecuta un número de operaciones que siempre está por debajo de una constante
multiplicada por .

Por lo tanto, la complejidad temporal del método fibC es .

Formúlese la siguiente pregunta: ¿Qué es mejor: la función fibB con complejidad temporal
) donde , o la función fibC con complejidad temporal ?

Obviamente es mejor la función fibC porque su consumo de tiempo es menor, dado que la
función lineal es más pequeña que la función exponencial cuando
es grande.

ESTRUCTURAS DE DATOS 8
¿Recuerda que el método fibB se demoró calculando 18.3 minutos el Fibonacci de ?
Para que note la diferencia, ¡la versión fibC se demoró menos de un milisegundo entregando
el mismo resultado!

2. MEMOIZATION

En una función recursiva es posible que se efectúen reiteradamente llamados a la función


con los mismos parámetros, lo que implica gastar tiempo de ejecución en repetir cálculos
innecesarios. Por ejemplo, la razón de que la implementación fibB sea muy demorada es que
repite de manera innecesaria muchas veces los mismos cálculos.

Gráfica 10: Evidencia que muestra que el método fibB repite cálculos.
fib(4) Aquí se repitió el
cálculo de fib(2)

fib(3) fib(2)

fib(2) fib(1) fib(1) fib(0)

fib(1) fib(0)

Para resolver este problema se puede aplicar una técnica conocida como Memoization, que
consiste en crear una estructura de datos adicional cuyo propósito sea guardar los resultados
de los llamados recursivos de tal forma que no se repitan cálculos innecesarios.

2.1. IMPLEMENTACIÓN DE LA FUNCIÓN DE FIBONACCI USANDO


MEMOIZATION
Recurso como proyecto en Eclipse: Fibonacci.zip.

Con la ayuda de una tabla es posible memorizar los valores retornados por la función de
Fibonacci.

Código 11: Implementación recursiva de la función de Fibonacci, usando la técnica de Memoization.


// VERSIÓN D : ALGORITMO RECURSIVO MEMORIZANDO LOS RESULTADOS
// Tabla para memorizar los resultados de la función.
private static BigInteger tabla[]=new BigInteger[100001];
// Declaración de la función.
public static BigInteger fibD(int n) { // n>=0
// Si tabla[n]!=null es porque el fibonacci de n ya fue calculado y guardado
// en tabla[n].
if (tabla[n]!=null) {
// Retornar el fibonacci de n, que está guardado precisamente en tabla[n].
return tabla[n];

ESTRUCTURAS DE DATOS 9
}
// De lo contrario, si tabla[n]==null es porque el fibonacci de n aún no ha
// sido calculado.
else {
if (n==0) { // Si n es cero.
BigInteger res=BigInteger.valueOf(0); // Calcular el fibonacci de 0, que es 0.
tabla[n]=res; // Guardar en la tabla el fibonacci de 0, para memorizarlo.
return res; // Retornar el resultado.
}
else if (n==1) { // Si n es uno.
BigInteger res=BigInteger.valueOf(1); // Calcular el fibonacci de 1, que es 1.
tabla[n]=res; // Guardar en la tabla el fibonacci de 1, para memorizarlo.
return res; // Retornar el resultado.
}
else { // Si n es mayor o igual a dos.
BigInteger res=fibD(n-1).add(fibD(n-2)); // Calcular el fibonacci de n.
tabla[n]=res; // Guardar en la tabla el fibonacci de n, para memorizarlo.
return res; // Retornar el resultado.
}
}
}

La complejidad temporal de esta última versión es porque, para calcular el Fibonacci de


, en el peor de los casos se va a tener que llenar todas las posiciones de la tabla desde
hasta . Observe que bajo ninguna circunstancia una posición de la tabla es calculada dos o
más veces.

3. DIVIDIR Y CONQUISTAR (DIVIDIR Y VENCER)

Generalmente una función recursiva refleja el uso de la técnica de Dividir y Conquistar,


también conocida como Dividir y Vencer. La técnica de Dividir y Vencer consiste en dividir un
problema en subproblemas similares más pequeños, solucionar tales subproblemas y unir
estas soluciones para resolver el problema original.

3.1. TORRES DE HANOI


Recurso como proyecto en Eclipse: Hanoi.zip.

El juego de las torres de Hanoi está conformado por tres columnas verticales y un conjunto
de discos de diámetros distintos que tienen un orificio en el centro que coincide con el
grosor de las columnas.

ESTRUCTURAS DE DATOS 10
Gráfica 12: Insumos para el juego con : cinco discos de diferente diámetro y tres columnas.

Por simplicidad, las columnas se etiquetan con las letras A, B y C, donde la columna A es la
columna inicial, la columna B es la columna intermedia, y la columna C es la columna final. Al
principio, todos los discos se encuentran apilados en la primera columna (la columna A),
ordenados por diámetro, comenzando con el de mayor diámetro y terminando con el de
menor diámetro.

Gráfica 13: Estado inicial y estado final del juego.

El objetivo del juego consiste en trasladar todos los discos de la columna inicial (la A) hacia la
columna final (la C) mediante una serie de movimientos que deben seguir tres reglas:
Regla 1: sólo se puede mover un disco a la vez.
Regla 2: no se puede colocar un disco encima de un disco de diámetro menor.
Regla 3: no se puede trasladar un disco que tenga otros discos encima suyo.

Puede practicar el juego en el sitio http://www.mazeworks.com/hanoi/index.htm. ¿Se


imagina un algoritmo para resolverlo? Piense durante algunos minutos … ¿Será que un
diseño recursivo le facilita pensar?

Vamos a proceder recursivamente:

Caso 1 ( ): si es , no tengo discos! Y como no hay discos, entonces no debo hacer


nada.

Caso 2 ( ): si es mayor que , entonces si tengo discos. Reflexionemos …

ESTRUCTURAS DE DATOS 11
Quiero mover discos de la columna A a la columna C usando la columna B como auxiliar.

Columna A Columna B Columna C


Concentrémonos en el disco más grande de la columna A, que está resaltado de azul. En
algún momento tendremos que mover ese disco azul de la columna A a la columna C, pero
para poderlo mover, no debe haber ningún disco encima suyo (por la regla 3) y no debe
haber ningún disco en la columna C porque de lo contrario quedaría encima de un disco más
pequeño, hecho que no puede suceder (por la regla 2).
La única opción posible es que todos los discos verdes se hayan movido de la columna
A a la columna B. ¿Y cómo los muevo? ¡Pues recursivamente! Supongamos que
recursivamente cumplimos con el objetivo de pasar los discos verdes de la columna A a
la columna B:

Columna A Columna B Columna C


Ahora, pasamos el disco azul de la columna A a la columna C.

Columna A Columna B Columna C


Y finalmente trasladamos recursivamente los discos verdes de la columna B a la
columna C.

Columna A Columna B Columna C

ESTRUCTURAS DE DATOS 12
Pseudocódigo de la solución:
Algoritmo solucionarHanoi(A,B,C,n)
// A es la columna inicial, B es la columna intermedia, C es la columna final
// n es el número de discos a mover
si n>0 entonces:
// Trasladar recursivamente n-1 discos de A a B usando como intermedia la C
solucionarHanoi(A,C,B,n-1)
// Trasladar un disco de A a C
trasladarDisco(A,C)
// Trasladar recursivamente n-1 discos de B a C usando como intermedia la A
solucionarHanoi(B,A,C,n-1)
fin si

Código 14: Traducción a Java del pseudocódigo, imprimiendo los movimientos a desarrollar en la consola del sistema.
public class HanoiConsola {
public static void main(String[] args) {
solucionar('A','B','C',5); // Llamado inicial con 5 discos
}
public static void solucionar(char A, char B, char C, int n) {
if (n>0) {
solucionar(A,C,B,n-1);
System.out.println("Movimiento "+A+"->"+C);
solucionar(B,A,C,n-1);
}
}
}

¿Cuál es la complejidad temporal del algoritmo? Responderemos esta pregunta contando


exactamente cuántos movimientos se realizan para solucionar un juego de torres de Hanoi
con discos. Obviamente, entre más movimientos haga el programa, más tiempo se va a
demorar.

Sea el número exacto de movimientos que hace el algoritmo para solucionar un juego
de torres de Hanoi con discos. Por ejemplo, sería el número de movimientos para
trasladar discos, sería el número de movimientos para trasladar discos,
sería el número de movimientos para trasladar discos y sería el número de
movimientos para trasladar discos.

Evidentemente, porque cuando no se tienen discos, entonces no hay que realizar


ningún movimiento. Para se tiene que
porque para mover una torre de discos, primero hay que trasladar
recursivamente discos de la columna inicial a la columna intermedia (lo que usaría
movimientos), luego hay que trasladar un disco de la columna inicial a la final (lo
que usaría movimiento), y finalmente hay que trasladar discos de la columna
intermedia a la columna final (lo que usaría movimientos). ¿Por qué trasladar
recursivamente discos de una columna a otra requiere de movimientos? ¡La
respuesta está al final del párrafo anterior!

ESTRUCTURAS DE DATOS 13
A una ecuación como la anterior se le llama relación de recurrencia. ¿Crecerá más que la
función ? ¿Crecerá menos que la función ?. Para poder comparar
cómodamente contra otras funciones, es necesario encontrar una fórmula cerrada que
la describa, es decir, una fórmula que no tenga llamados recursivos. Para tal efecto,
seguiremos una receta útil que consta de dos pasos:

Paso 1: suponga que es grande y aplique la función varias veces hasta que encuentre
un patrón.
porque
porque
distribuyendo y simplificando
porque
distribuyendo y simplificando
porque
distribuyendo y simplificando
porque
distribuyendo y simplificando
...
generalizando la fórmula donde es
el número de paso

Paso 2: tome el patrón, trate de eliminar los llamados recursivos a la función y


simplifique.
patrón encontrado
Sea . Por tanto . hipótesis para eliminar los llamados recursivos.
usando que y que
puesto que se sabe que
usando aritmética
usando aritmética

Hemos demostrado que el número exacto de movimientos que realiza el algoritmo para
solucionar un juego de torres de Hanoi con discos es exactamente . Por tanto, para
discos, el programa debe hacer movimientos ( jugadas). Se concluye pues que la
complejidad temporal del algoritmo es .

El proyecto en Eclipse Hanoi.zip provee una animación gráfica del algoritmo que soluciona el
juego de Hanoi.

ESTRUCTURAS DE DATOS 14
3.2. BÚSQUEDA BINARIA (BINARY SEARCH)
Recurso como proyecto en Eclipse: BusquedaBinaria.zip.

El proceso de búsqueda tradicional, llamado Búsqueda Lineal, consiste en pasar posición por
posición de un arreglo para buscar el valor requerido

Código 15: Algoritmo de Búsqueda Lineal.


public static int busquedaLineal(long[] pArreglo, long pValor) {
// Llamar al método de abajo
return busquedaLineal(pArreglo,0,pArreglo.length-1,pValor);
}
public static int busquedaLineal(long[] pArreglo, int pInf, int pSup, long pValor) {
// Por cada posición k desde el límite inferior pInf hasta el límite superior pSup:
for (int k=pInf; k<=pSup; k++) {
// Si la posición k del arreglo tiene el valor buscado, se retorna la posición:
if (pArreglo[k]==pValor) return k;
}
// Si la porción del arreglo que va de pInf a pSup no tiene el valor buscado,
// hay que retornar -1:
return -1;
}

El método
public static int busquedaLineal(long[] pArreglo, int pInf, int pSup, long pValor)
entrega como respuesta la posición donde se encuentra el valor pValor dentro del arreglo
pArreglo, considerando posiciones del arreglo desde pInf (inclusive) hasta pSup (inclusive).
En caso de que el valor aparezca varias veces en el arreglo, se entrega la menor posición
donde se encuentre; y en caso de que el valor no aparezca, se retorna -1. La complejidad del
algoritmo es , donde es la cantidad de elementos en la porción de arreglo a
inspeccionar, porque en el peor de los casos se deberán procesar todas las posiciones del
arreglo en cuestión.

¿Se podría implementar un algoritmo más eficiente si sabemos que el arreglo está ordenado?
Imagine un diccionario de trescientas páginas que tiene definiciones de diez mil palabras del
idioma español. Si el diccionario estuviera desordenado, no nos queda otro camino que
leerlo todo para determinar si una palabra está o no está. Pero como todos sabemos que los
términos del diccionario están ordenados alfabéticamente, podemos buscar una palabra más
rápidamente mediante una destreza que aprendimos desde la infancia: buscar en un
diccionario o en un directorio telefónico. Suponga que estamos buscando la palabra Faro y
que abrimos el diccionario justo en la mitad, encontrando la palabra Jarro. Sólo con esto
sabemos que Faro está en la primera mitad del diccionario y no en la segunda, porque Faro
está antes que Jarro. Luego, abrimos el diccionario en medio de la primera mitad y
encontramos la palabra Doncella. Sabemos entonces que Faro está después de Doncella y
antes que Jarro, lo que nos deja con un cuarto del total del diccionario. Siguiendo este
proceso de manera sucesiva llegamos a la página donde debe estar Faro, y dentro de la
página hacemos lo mismo para encontrar el lugar preciso donde aparece el término. El
proceso llevado a cabo se conoce en computación como Búsqueda Binaria, y como pudo
notarlo, es mucho más eficiente que la Búsqueda Lineal, ¿pero qué tanto?

ESTRUCTURAS DE DATOS 15
Gráfica 16: Diagrama de flujo para el algoritmo de Búsqueda Binaria.

El argumento recursivo es el siguiente: si el arreglo no tiene elementos se informa que el


elemento no aparece; de lo contrario, si el arreglo tiene elementos: 1. si el valor buscado
está en la mitad del arreglo se informa que se encontró en la mitad, 2. si el valor buscado es
menor que el valor que está en la mitad del arreglo se llama recursivamente al algoritmo
sobre la mitad izquierda del arreglo, y 3. si el valor buscado es mayor que el valor que está en
la mitad del arreglo se llama recursivamente al algoritmo sobre la mitad derecha del arreglo.
Estamos aprovechándonos de que el arreglo está ordenado para decidir sobre qué mitad
operar: si el valor es menor que lo que está en medio debería estar en la mitad izquierda y, si
es mayor, debería estar en la mitad derecha.

Código 17: Algoritmo de Búsqueda Binaria.


public static int busquedaBinaria(long[] pArreglo, long pValor) {
// Llamar al método de abajo
return busquedaBinaria(pArreglo,0,pArreglo.length-1,pValor);
}
public static int busquedaBinaria(long[] pArreglo, int pInf, int pSup, long pValor) {
// Hallar el número de elementos que tiene la porción de arreglo a inspeccionar:
int n=pSup-pInf+1;
// Si no hay elementos en la porción de arreglo, retornar -1 como código de error:
if (n<=0) {
return -1;
}
// Si hay elementos en la porción de arreglo:
else {
// Hallar la posición correspondiente a la mitad del arreglo:
int mitad=(pInf+pSup)/2;
// Si el valor buscado es igual a lo que está en la mitad:
if (pValor==pArreglo[mitad]) {
return mitad; // Informar que el valor buscado se encontró en la mitad.
}
// Si el valor buscado es menor que lo que está en la mitad:
else if (pValor<pArreglo[mitad]) {

ESTRUCTURAS DE DATOS 16
// Buscar el valor en la mitad de la izquierda:
return busquedaBinaria(pArreglo,pInf,mitad-1,pValor);
}
// Si el valor buscado es mayor que lo que está en la mitad:
else {
// Buscar el valor en la mitad de la derecha:
return busquedaBinaria(pArreglo,mitad+1,pSup,pValor);
}
}
}

Sea el tiempo que se demora la función busquedaBinaria en buscar un valor en un


arreglo de tamaño . para todo porque para buscar un valor en un arreglo
de tamaño 1 o menos hay que hacer un número constante de operaciones. Para se
cumple que porque, en el peor de los casos, hay que efectuar un
número constante de operaciones y terminar buscando el valor en la mitad izquierda o en la
mitad derecha (nunca se busca en ambas mitades). Buscar el valor en cualquiera de las
mitades se demora un tiempo de porque cada mitad tiene elementos y, según la
definición, sería el tiempo que se demora la función busquedaBinaria en buscar un
valor en un arreglo de tamaño .

Paso 1: suponga que es grande y aplique la función varias veces hasta que encuentre
un patrón.
porque
porque
simplificando
porque
simplificando
porque
simplificando
porque
simplificando
...
generalizando la fórmula donde
es el número de paso

Paso 2: tome el patrón, trate de eliminar los llamados recursivos a la función y


simplifique.
patrón encontrado
Sea . Entonces, . hipótesis para eliminar los llamados recursivos.
usando que
puesto que se sabe que
por que

ESTRUCTURAS DE DATOS 17
Entonces, la complejidad temporal del algoritmo de Búsqueda Binaria es , lo que
demuestra que es más eficiente que el algoritmo de Búsqueda Lineal, cuya complejidad
temporal es .

3.3. ALGORITMO DE ORDENAMIENTO POR MEZCLA (MERGE SORT)

Para ordenar arreglos se cuenta con un proceso muy eficiente llamado algoritmo de
Ordenamiento por Mezcla (Merge Sort en inglés).

Gráfica 18: Diagrama de flujo para el algoritmo de Ordenamiento por Mezcla.

Sea el tiempo que se demora el proceso descrito en ordenar un arreglo de tamaño .


para todo porque para ordenar un arreglo de tamaño 1 o menos hay que
hacer un número constante de operaciones. Para se cumple que
porque ordenar la mitad de la izquierda se demora , ordenar la mitad de
la derecha se demora y mezclar ambas mitades se puede hacer con un algoritmo
eficiente con complejidad . Ordenar cada una de las dos mitades se demora un tiempo
de porque cada mitad tiene elementos y, según la definición, sería el
tiempo que se demora el proceso en ordenar un arreglo de tamaño .

ESTRUCTURAS DE DATOS 18
Paso 1: suponga que es grande y aplique la función varias veces hasta que encuentre
un patrón.
porque
porque
distribuyendo y simplificando
porque
distribuyendo y simplificando
porque
distribuyendo y simplificando
porque
distribuyendo y simplificando
...
generalizando la fórmula donde
es el número de paso

Paso 2: tome el patrón, trate de eliminar los llamados recursivos a la función y


simplifique.
patrón encontrado
Sea . Entonces, . hipótesis para eliminar los
llamados recursivos.
usando que
puesto que se sabe que
usando que
porque
usando aritmética
porque es más pequeño que
para grande
usando aritmética

Entonces, la complejidad temporal del algoritmo de Ordenamiento por Mezcla es


.

4. BACKTRACKING

Otra técnica de programación que puede implementarse con recursión es el Backtracking. El


Backtracking es una técnica de búsqueda por fuerza bruta que consiste en iterar sobre todas
las posibilidades hasta que se encuentre una solución adecuada al problema, descartando en
masa conjuntos de posibilidades sin haberlas construido explícitamente, utilizando las
restricciones que ofrece el problema. La idea fundamental se basa en que no es necesario

ESTRUCTURAS DE DATOS 19
construir todas las posibilidades que resultan de una combinación parcialmente construida si
se sabe que generará posibilidades que violan las restricciones del problema.

EN RESUMEN
Una función es recursiva si está definida en términos de sí misma.
La definición de una función recursiva debe estar formada por:
 Casos base: son casos triviales en los que la definición de la función no depende de la función
misma.
 Casos recursivos: son casos complejos en los que la definición de la función referencia a la función
misma.
Una función es cerrada si no está definida en términos de sí misma.
BigInteger es una clase de Java que nos permite operar con números de precisión arbitraria.
La complejidad temporal es una medida de qué tanto tiempo consume un algoritmo en su ejecución.
La complejidad temporal sirve para medir la eficiencia de un programa.
Todo programa que ejecute un número constante de operaciones tiene complejidad temporal .
Todo programa que ejecute un número de operaciones que siempre esté por debajo de una
constante multiplicada por , tiene complejidad temporal .
La técnica Memoization consiste en crear una estructura de datos adicional cuyo propósito sea
guardar los resultados de los llamados recursivos de tal forma que no se repitan cálculos innecesarios.
Dividir y Conquistar, también conocida como Dividir y Vencer, es una técnica que consiste en dividir
un problema en subproblemas similares más pequeños, solucionar tales subproblemas y unir estas
soluciones para resolver el problema original.
El algoritmo de Búsqueda Binaria es un proceso eficiente para buscar valores en arreglos ordenados.
El Backtracking es una técnica de búsqueda por fuerza bruta que consiste en iterar sobre todas las
posibilidades hasta que se encuentre una solución adecuada al problema, descartando en masa
conjuntos de posibilidades sin haberlas construido explícitamente, que se sabe que no van a llegar a
la solución.

PARA TENER EN CUENTA


 Si no recuerda las propiedades de los logaritmos repáselas de su libro preferido de cálculo. Son muy
útiles al momento de calcular complejidades.
 Después de que lea el desarrollo de cada uno de los ejemplos de esta lectura intente fomentar la
práctica volviéndolos a hacer usted mismo en una hoja blanca y sin ayuda de nadie. Es muy
importante que se ejercite de esta forma para que adquiera destreza en la solución de ejercicios.
 Ponga especial atención a los procedimientos que involucran la aplicación de leyes del álgebra y de
la aritmética. Analice con cuidado, paso a paso, la forma en que se calculó la complejidad temporal de
los distintos programas implementados en la lectura.
 Descargue los proyectos, ábralos en Eclipse y juegue haciendo programas que usen las funciones
tratadas en la lectura. ¡La única manera de aprender a programar es practicando!

ESTRUCTURAS DE DATOS 20

Vous aimerez peut-être aussi