mtpTema2
Transcript of mtpTema2
Capítulo 2: Esquemas Algorítmicos Fundamentales
Capítulo 2: Esquemas algorítmicos fundamentales
1ALGORITMOS DEVORADORES......................................................................................................................................3
1.1DESCRIPCIÓN........................................................................................................................................................................31.2DAR LA VUELTA (1)..............................................................................................................................................................51.3PROBLEMA DE LA MOCHILA (1)...............................................................................................................................................91.4ÁRBOL DE EXPANSIÓN MÍNIMO (ALGORITMO DE PRIM).............................................................................................................111.5CAMINOS MÍNIMOS.............................................................................................................................................................131.6PLANIFICACIÓN: MINIMIZACIÓN DEL TIEMPO EN EL SISTEMA......................................................................................................17
2DIVIDE Y VENCERÁS......................................................................................................................................................19
2.1ORDENACIÓN......................................................................................................................................................................192.2MULTIPLICACIÓN DE ENTEROS GRANDES................................................................................................................................202.3ALGORITMOS DE SELECCIÓN Y DE BÚSQUEDA DE LA MEDIANA...................................................................................................222.4TORRES DE HANOI..............................................................................................................................................................232.5CALENDARIO DE UN CAMPEONATO.........................................................................................................................................25
3ALGORITMOS DE VUELTA ATRÁS.............................................................................................................................29
3.1PROBLEMA DE DAR EL CAMBIO.............................................................................................................................................293.2PROBLEMA DE LAS 8 REINAS.................................................................................................................................................313.3PROBLEMA DE ATRAVESAR UN LABERINTO.............................................................................................................................33
4PROGRAMACIÓN DINÁMICA.......................................................................................................................................37
4.1CÁLCULO DE LOS N PRIMEROS NÚMEROS PRIMOS......................................................................................................................37
21
Capítulo 2: Esquemas Algorítmicos Fundamentales
22
Capítulo 2: Esquemas Algorítmicos Fundamentales
Capítulo 2
ESQUEMAS ALGORÍTMICOS FUNDAMENTALES
Cuando se estudian los problemas y algoritmos usualmente escogidos para mostrar los mecanismos de un lenguaje algorítmico (ya sea ejecutable o no), puede parecer que el desarrollo de algoritmos es un cajón de sastre en el que se encuentran algunas ideas de uso frecuente y cierta cantidad de soluciones ad hoc, basadas en trucos más o menos ingeniosos.
Sin embargo, la realidad no es así: muchos problemas se pueden resolver con algoritmos construidos en base a unos pocos modelos, con variantes de escasa importancia. En este capítulo se estudian algunos de esos esquemas algorítmicos fundamentales, su eficiencia y algunas de las técnicas más empleadas para mejorarla.
1 Algoritmos devoradoresEstos algoritmos toman decisiones basándose en la información que tienen disponible de modo inmediato, sin tener en cuenta los efectos que estas decisiones puedan tener en el futuro. Por tanto, resultan fáciles de inventar, fáciles de implementar y, cuando funcionan, son eficientes. Sin embargo, como el mundo no suele ser tan sencillo, hay muchos problemas que no se pueden resolver correctamente con dicho enfoque.
La estrategia de estos algoritmos es básicamente iterativa, y consiste en una serie de etapas, en cada una de las cuales se consume una parte de los datos y se construye una parte de la solución, parando cuando se hayan consumido totalmente los datos. El nombre de este esquema es muy descriptivo: en cada fase (bocado) se consume una parte de los datos. Se intentará que la parte consumida sea lo mayor posible, bajo ciertas condiciones.
1.1 DescripciónPor ejemplo, la descomposición de un número n en primos puede describirse mediante un esquema devorador. Para facilitar la descripción, consideremos la descomposición de 600 expresada así:
600 = 23 31 52
En cada fase, se elimina un divisor de n cuantas veces sea posible: en la primera se elimina el 2, y se considera el correspondiente cociente, en la segunda el 3, etc. y se finaliza cuando el número no tiene divisores (excepto el 1).
El esquema general puede expresarse así:
entero Resolver (D){
Generar la parte inicial de la solución S (y las condiciones iniciales)mientras (D sin procesar del todo) {
Extraer de D el máximo trozo posible TProcesar T (reduciéndose D)
23
Capítulo 2: Esquemas Algorítmicos Fundamentales
Incorporar el procesado de T a la solución S}
}
La descomposición de un número en factores primos se puede implementar sencillamente siguiendo este esquema:
entero Descomponer (n){
{PreC.: n>1}d←2;mientras n mayor que 1{ Dividir n por d cuantas veces (k) se pueda Escribir "dk"
d←d+1;}
}
Implementación en JAVA
public class FactPrimos {static public void descompFactPrimos(int n) {
int i = 2; // Factor primo inicialint k; // Número de veces factores
System.out.print(n + " = ");while (n > 1) {
k = 0;while ( ( n%i ) == 0 ){
++k;n /= i;
}
if ( k > 0 ) {System.out.print("(" + i + "^" + (k) +") ");
}
++i;}
System.out.println();}
public static void main(String[] args) {descompFactPrimos( 484 );
}}
24
Capítulo 2: Esquemas Algorítmicos Fundamentales
1.2 Dar la vuelta (1).Supongamos que vivimos en un país en el que están disponibles las siguientes monedas:100 pesetas, 25 pesetas, 10 pesetas, 5 pesetas y 1 peseta (con un número ilimitado de cada tipo de moneda). Nuestro problema consiste en diseñar un algoritmo para pagar una cierta cantidad a un cliente, utilizando el menor número posible de monedas. Por ejemplo, si tenemos que pagar 289 pesetas, la mejor solución consiste en dar al cliente 10 monedas: 2 de 100, 3 de 25, 1 de 10 y 4 de 1 peseta. Todos nosotros resolvemos este tipo de problemas todos los días sin pensarlo dos veces, empleando de forma inconsciente un algoritmo voraz: empezamos por nada, y en cada fase vamos añadiendo monedas que ya estén seleccionadas una moneda de la mayor denominación posible, pero que no debe llevarnos más allá de la cantidad que haya que pagar.
Con los valores dados para las monedas y disponiendo de un suministro adecuado de cada denominación, este algoritmo siempre produce una solución óptima para nuestro problema. Sin embargo, con una serie de valores diferente, o si el suministro de alguna de las monedas está limitado, el algoritmo voraz puede no funcionar. Por ejemplo, si el sistema de monedas fuera de 1, 7 y 9 pesetas el cambio de 15 pesetas que ofrece este algoritmo consta de 7 monedas: 1 de 9 y 6 de 1. Mientras que el cambio óptimo requiere tan sólo 3 monedas: 2 de 7 y 1 de 1. Por lo tanto, el algoritmo presentado para el cambio de moneda resultará correcto siempre que se considere un sistema monetario donde los valores de las monedas son cada uno múltiplo del anterior. Si no se da esta condición, es necesario recurrir a otros esquemas algorítmicos.
El algoritmo se puede formalizar de la siguiente forma:
Conjunto_de_monedas devolverCambio (entero n){
/* Da el cambio de n unidades utilizando el menor número posible de monedas. En C se especifican las monedas disponibles*/C ← {100,25,10,5,1}S ←∅ // S es un conjunto que contendrá la solucións ← 0 // Es la suma de los elementos de Smientras ( s != n ){
x ← el mayor elemento de C tal que s + x <= nsi no existe ese entonces devolver “No encuentro la solución”S ← S ∪ {una moneda de valor x}s ← s + x
}devolver S
}
Generalmente, los algoritmos voraces y los problemas que éstos resuelven se caracterizan por la mayoría de las siguientes propiedades:
• Tenemos que resolver algún problema de forma óptima. Para construir la solución disponemos de un conjunto de candidatos (las monedas disponibles).
• A medida que avanza el algoritmo, vamos acumulando dos conjuntos. Uno contiene los candidatos que ya han sido considerados y seleccionados, mientras que el otro contiene candidatos que han sido considerados y rechazados.
25
Capítulo 2: Esquemas Algorítmicos Fundamentales
• Existe una función que comprueba si un cierto conjunto de candidatos constituye una solución del problema, ignorando si es o no óptima por el momento. (Por ejemplo ¿suman las monedas seleccionadas la cantidad que hay que pagar?).
• Hay una segunda función que comprueba si un cierto conjunto de candidatos es factible, esto es, si es posible o no completar el conjunto añadiendo otros candidatos para obtener al menos una solución del problema. Una vez más, no nos preocupa aquí si esto es óptimo o no. Normalmente, se espera que el problema tenga al menos una solución que sea posible obtener empleando candidatos del conjunto que estaba disponible inicialmente.
• Hay otra función más, la función de selección, que indica en cualquier momento cuál es el más prometedor de los candidatos restantes, que no han sido seleccionados ni rechazados.
• Por último, existe una función objetivo que da el valor de la solución que hemos hallado (número de monedas utilizadas para dar la vuelta). A diferencia de las tres funciones mencionadas anteriormente, la función objetivo no aparece explícitamente en el algoritmo voraz.
Los algoritmos voraces avanzan paso a paso. Inicialmente, el conjunto de elementos seleccionados está vacío. Entonces, en cada paso se considera añadir a este conjunto el mejor candidato sin considerar los restantes, estando guiada nuestra elección por la función de selección. Si el conjunto ampliado de candidatos seleccionados ya no fuera factible, rechazamos el candidato que estamos considerando en ese momento. Sin embargo, si el conjunto aumentado sigue siendo factible, entonces añadimos el candidato actual al conjunto de candidatos seleccionados, en donde pasará a estar desde ahora en adelante. Cada vez que se amplía el conjunto de candidatos seleccionados, comprobamos si éste constituye ahora una solución para nuestro problema.
Conjunto voraz(C:conjunto){
{C es el conjunto de candidatos}S←∅ {Construimos la solución en el conjunto S}mientras ( C != ∅ y no solución( s ) )
x ← seleccionar ( C )C ← C \ {x}Si factible ( S ∪ {x} ) S ← S ∪ {x}
si solucion(S) devolver Ssino devolver "no hay soluciones"
}
Está clara la razón por la cual tales algoritmos se denominan “voraces”: en cada paso, el procedimiento selecciona el mejor bocado que pueda tragar, sin preocuparse por el futuro. Nunca cambia de opinión: una vez que un candidato se ha incluido en la solución queda allí para siempre; una vez que se excluye un candidato de la solución nunca vuelve a ser considerado.
Volviendo por un momento al ejemplo de dar cambio, lo que sigue es una forma de adecuar las características generales de los algoritmos voraces a las características particulares de este problema.
1. Los candidatos son un conjunto de monedas, que representan en nuestro ejemplo 100, 25, 10, 5, 1 pesetas, con tantas monedas de cada valor que nunca las agotamos. (Sin embargo, el conjunto de candidatos debe ser finito.)
2. La función de solución comprueba si el valor de las monedas seleccionadas hasta el momento es exactamente el valor que hay que pagar.
3. Un conjunto de monedas será factible si su valor total no sobrepasa la cantidad que haya que pagar.
26
Capítulo 2: Esquemas Algorítmicos Fundamentales
4. La función de selección toma la moneda de valor más alto que quede en el conjunto de candidatos.
5. La función objetivo cuenta el número de monedas utilizadas en la solución.
Está claro que es más eficiente rechazar todas las monedas restantes de 100 pesetas por ejemplo, cuando el valor restante que hay que pagar cae por debajo de ese valor. El uso de la división entera para calcular cuántas monedas de un cierto valor hay que tomar también es más eficiente que actuar por sustracciones sucesivas. Si se adopta cualquiera de estas tácticas, entonces podemos evitar la condición consistente en que el conjunto de monedas debe ser finito.
27
Capítulo 2: Esquemas Algorítmicos Fundamentales
Implementación en JAVA
public class Cambio {public static int[] darCambio(int obj, int[] C) {// C debe estar ordenado de mayor a menor
int [] S = new int[C.length];int parcial = 0;int i = 0;int k;
// Mientras no se haya cumplido el objetivo// con las monedas disponibleswhile ( parcial < obj
&& i < C.length ) {
// buscar una moneda que “quepa”k = obj – parcial;if ( i < C.length
&& C[i] <= k ){
// se encontró una monedak /= C[i]; // ¿cuantas de este tipo?S[i] += k;parcial += C[i]*k;
}
++i;}
if ( parcial < obj ) { S[0] = 1; // Marca “no hay solución” }
return S;}
public static void main(String[] args) {int[] C = {100, 50, 25, 5, 1};int[] resultado;
resultado = darCambio( 77, C );
if ( resultado[0] > 1 ) {for (int i = 0; i < resultado.length; ++i) if ( resultado[i] > 0 ) {
System.out.println( resultado[i] + " de " + C[i] ); }
}else System.out.println( "No hay solución" );
}}
28
Capítulo 2: Esquemas Algorítmicos Fundamentales
1.3 Problema de la mochila (1)Se desea llenar una mochila hasta un volumen máximo V, y para ello se dispone de n objetos,
en cantidades limitadas v1, ..., vn y cuyos valores por unidad de volumen son p1, ..., pn, respectivamente. Puede seleccionarse de cada objeto una cantidad cualquiera ci ∈ R con tal de que ci <= vi. El problema
consiste en determinar las cantidades c1, ..., cn que llenan la mochila maximizando el valor ∑i= 1
n
vipi
total.Este problema puede resolverse fácilmente seleccionando, sucesivamente, el objeto de mayor
valor por unidad de volumen que quede y en la máxima cantidad posible hasta agotar el mismo. Este paso se repetirá hasta completar la mochila o agotar todos los objetos. Por lo tanto, se trata claramente de un esquema voraz.
Si pensamos en el anterior problema, pero añadiendo la condición (más realista) de un número de monedas limitado para cada tipo, tendremos exactamente el mismo algoritmo presentado aquí. Dicho de otra forma, este algoritmo es el mismo que el anterior con el control de número de objetos de cada volumen añadido, además de un pequeño bucle auxiliar (bajo el comentario “buscar el primer objeto que cabe”) que se introduce por eficiencia y que es también aplicable al mencionado anterior algoritmo.
Implementación en JAVA (incluye entrada/salida)
public class Mochila { public static int[] llenarMochila(int V, int[] v, int[] n) /* IMPORTANTE: v debe estar ordenado de mayor a menor V es el volumen total a llenar v[] son los volúmenes de los objetos n[] es un vector paralelo con las cantidades */ { int [] S = new int[v.length]; int parcial = 0; int i = 0; int k; // Mientras no se haya cumplido el objetivo // ... o agotado los objetos ... while ( parcial < V && i < v.length ) { // buscar el primer objeto que cabe while( i < v.length && ( ( parcial + v[i] ) > V ) ) { ++i; } // ¿se encontró un objeto? if ( i < v.length ) { if ( n[i] > 0 ) {
29
Capítulo 2: Esquemas Algorítmicos Fundamentales
k = (Vparcial) / v[i]; // ¿cuántos de este tipo? if ( k > n[i] ) { k = n[i]; } S[i] += k; parcial += v[i] * k; } } else { S[0] = 1; // Marca “no hay solución” parcial = V; // Fin de bucle }
++i; }
return S; }
public static void main(String[] args) { int[] v = {100, 50, 25, 5, 1}; int[] n = { 0, 0, 2, 5, 5}; int[] resultado; resultado = llenarMochila( 77, v, n ); if ( resultado[0] > 1 ) { for (int i = 0; i < resultado.length; ++i) { if ( resultado[i] > 0 ) { System.out.println( resultado[i] + " de " + v[i] ); } } } else System.out.println( "No hay solución" ); }}
210
Capítulo 2: Esquemas Algorítmicos Fundamentales
1.4 Árbol de expansión mínimo (Algoritmo de Prim).Consideremos un mapa de carreteras, con dos tipos de componentes: las ciudades (nodos) y las carreteras que las unen. Cada tramo de carreteras (arco) está señalado con su longitud. Se desea implantar un tendido eléctrico siguiendo los trazos de las carreteras de manera que conecte todas las ciudades y que la longitud total sea mínima.
Otra forma de plantear el problema es la siguiente: “un viajante debe recorrer una serie de ciudades interconectadas entre sí, de manera que recorra todas ellas con el menor número de kilómetros posible”.
Una forma de lograrlo consiste en:
Empezar con el tramo de menor costerepetir
Seleccionar un nuevo tramohasta que esté completa una red que conecte todas las ciudades
donde cada nuevo tramo que se selecciona es el de menor longitud entre los no redundantes (es decir, que da acceso a una ciudad nueva).
Un razonamiento sencillo nos permite deducir que un árbol de expansión cualquiera (el mínimo en particular) para un mapa de n ciudades tiene n1 tramos. Por lo tanto, es posible simplificar la condición de terminación que controla el algoritmo anterior.
La representación del grafo se realizará mediante una matriz n x n. La casilla i, j guardará el valor del arco entre las ciudades i y j. En el caso de no existir arcos, o de tratarse de la diagonal de la matriz (casillas i, i), se elegirá un valor que denotará la falta de arco. Este valor especial puede ser cualquiera (por ejemplo, 1), pero si escogiendo un valor apropiado es posible simplificar mucho el algoritmo. Así, un valor de 999999 será muy útil, dado que se escogerá en cada paso el arco con menor valor.
Implementación en JAVA
public class Prim { private static final int NOHAYENLACE = 999999;
// Comprueba si el elemento "i" está en el vector "v" // Sirve para saber si se ha visitado ya un nodo static private boolean estaEn(int[] v, int i) {
int j; for (j = 0; j < v.length; ++j) { if ( v[j] == i ) { break; }
}
return ( j < v.length ); }
211
Capítulo 2: Esquemas Algorítmicos Fundamentales
public static int[] caminoMinimo(int[][] L, int salida) { int numnodos = L.length; int S[] = new int[numnodos]; int lcamino = 0; int menor = 0; int nodoact = salida; S[0] = salida;
while ( lcamino < ( numnodos – 1 ) ) {
// Encontrar el camino mínimo al siguiente nodo for (int i = 1; i < numnodos; ++i)
{ // El más pequeño, pero no es posible repetir nodos !!
if ( L[nodoact][i] < L[nodoact][menor] && !estaEn( S, i ) ) {
menor = i; }}
// El nodo siguiente es el destino del camino mínimo anterior ++lcamino; nodoact = menor; S[lcamino] = nodoact; } return S; }
public static void main(String args[]) { /* El grafo será n0 a n1 30 n0 a n2 40 n1 a n3 60 n1 a n2 10 n2 a n3 50 n0 a n3 80 */
// Vector de soluciones int sol[];
// Crear un grafo de 4 vértices representado por una matriz int g[][] = new int [4][4];
// Inicializar la matriz representando el grafo for(int i = 0; i < 4; ++i) { for(int j = 0; j < 4; ++j) { g[i][j] = NOHAYENLACE; } }
// Rellenar el grafo con las aristas g[0][1] = 30; g[0][2] = 15; g[0][3] = 80; g[1][3] = 60; g[1][2] = 10; g[2][3] = 50; g[2][1] = 10;
212
Capítulo 2: Esquemas Algorítmicos Fundamentales
// Encontrar el camino mínimo (se empieza desde el nodo 0) sol = caminoMinimo( g, 0 );
// Mostrar el camino System.out.print( "Solución: " ); for(int i = 0; i < 4; ++i) // n1 aristas entre n nodos System.out.print( sol[i] + " " );
System.out.println(); }}
1.5 Caminos Mínimos.Consideremos un grafo dirigido G = <N,A> en donde N es el conjunto de nodos de G, y A es el conjunto de aristas dirigidas. Cada arista posee una longitud no negativa. Se toma uno de los nodos como nodo origen. El problema consiste en determinar la longitud del camino mínimo que va desde el origen hasta cada uno de todos lo demás nodos del grafo. Podríamos considerar el coste de una arista, en lugar de mencionar su longitud, y se podría plantear el problema consistente en determinar la ruta más barata desde el origen hasta cada uno de todos los demás nodos.
Este problema se puede resolver mediante un algoritmo voraz que recibe frecuentemente el nombre de algoritmo de Dijkstra. El algoritmo utiliza dos conjuntos de nodos, S y C. En todo momento, el conjunto S contiene aquellos nodos que ya han sido seleccionados; como veremos, la distancia mínima desde el origen ya es conocida para todos los nodos de S. El conjunto C contiene todos los demás nodos, cuya distancia mínima desde el origen todavía no es conocida, y que son candidatos a ser seleccionados en alguna etapa posterior. Por tanto, tenemos la propiedad invariante N = S ∪ C. En primer momento, S contiene nada más el origen en sí; cuando se detiene el algoritmo, S contiene todos los nodos del grafo y nuestro problema está resuelto. En cada paso seleccionamos aquel nodo de C cuya distancia al origen sea mínima, y se lo añadimos a S.
Diremos que un camino desde el origen hasta algún otro nodo es especial si todos los nodos intermedios a lo largo del camino pertenecen a S. En cada fase del algoritmo, hay una matriz D que contiene la longitud del camino especial más corta que va hasta cada nodo del grafo. En el momento en que se desea añadir un nuevo nodo v a S, el camino especial más corto hasta v es también el más corto de los caminos posibles hasta v. Cuando se detiene el algoritmo, todos los nodos del grafo se encuentran en S, y por tanto todos los caminos desde el origen hasta algún otro nodo son especiales. Consiguientemente, los valores que hay en D dan la solución del problema de caminos mínimos.
Por sencillez suponemos que los nodos de G están numerados desde 0 hasta n, así que N = {0,1,...,n}. Podemos suponer sin pérdida de generalidad que el nodo cero es el nodo origen. Supongamos también que la matriz L da la longitud de todas las aristas dirigidas: L[i][j] >= 0 si la arista (i,j) ∈ A, y L[i][j] = ∞ en caso contrario. Véase a continuación el algoritmo:
213
Capítulo 2: Esquemas Algorítmicos Fundamentales
int [] Dijkstra (int [][] L){
int [] D // Posiciones desde 0 hasta n
{Iniciación}C ← {1,2,...,n} // S = N \ C sólo existe implícitamentepara (i ← 1, a n)
D[i] = L[0][i]
{bucle voraz}repetir n2 veces {
v ← algún elemento de C que minimiza D[v]C ← C \ {v} //e implícitamente S ← S ∪ {v}para cada w ∈ C hacer {
D[w] ← min(D[w], D[v] + L[v][w])}
}
devolver D }
Análisis del algoritmo: Supongamos que el algoritmo de Dijkstra se aplica a un grafo que posee n nodos y a aristas. Utilizando la representación sugerida hasta el momento, este caso se da en la forma de una matriz L[n][n]. La iniciación requiere un tiempo de O(n). En una implementación directa, la selección de v dentro del bucle (repetir) requiere examinar todos los elementos de C, así que examinaremos n1, n2,...,2 valores de D en las sucesivas iteraciones, dando un tiempo total de O(n2). El bucle para interno realiza n2, n3,...,1 iteraciones dando también un tiempo total de O(n2). El tiempo requerido para esta versión del algoritmo es por tanto de O(n2).
214
Capítulo 2: Esquemas Algorítmicos Fundamentales
Implementación en JAVA
class DikstraGrafo{ // Indica que no hay arista entre dos nodos public static final int NOHAYENLACE = 9999; /** * dijstra() El algoritmo que halla los caminos mínimos * desde el origen, dado un grafo * * @param L El grafo representado como una matriz * @return Un vector de enteros, cada posición "i" es * el coste del camino mínimo desde el nodo 0 * hasta el nodo "i". */ public static int[] dijkstra(int[][] L) { // Nodos del grafo procesados, se considera // que el nodo 0 es desde el que se parte (ya procesado) boolean [] C = {false, true, true, true, true}; // En D se almacenan los caminos mínimos desde el nodo 0 int [] D = new int[C.length]; // Inicializar la distancia mínima desde el nodo 0 // hasta el resto de nodos D[0]= 0; for (int i = 1; i < C.length; ++i) { D[i] = L [0][i]; } // Preparar la visualización paso a paso System.out.println( " Paso v C D" ); System.out.print( "Inicializacion " ); visualizar( D, C ); // Nodo que posee el menor camino en cada pasada int v = 0; int j; // Repetir n2 veces for (int i = 0; i < ( C.length – 2 ); ++i) { // Buscar el primer nodo del que todavía // no se ha calculado el camino mínimo j = 1; while ( !C[j] ) { j++; } v = j; // Continuar búsqueda del nodo con la distancia más pequeña for (++j; j < C.length; ++j) { //Nodo con la distancia más pequeña if ( C[j]
215
Capítulo 2: Esquemas Algorítmicos Fundamentales
&& D[v] > D[j] ) { v = j; } } // v nodo con el camino mínimo ya calculado C[v] = false; // Se calcula el mínimo camino para los nodos // que todavía no se han visitado for (j = 1; j < C.length; ++j) { if ( C[j] && ( D[j] > (D[v] + L[v][j] ) ) ) { D[j] = (D[v]+ L[v][j]); } } //Utilizando el nuevo nodo que se acaba de visitar System.out.print( " " + i + " " + v + " " )
; visualizar( D, C ); } return D; } // Muestra por pantalla el contenido de los nodos // que quedan por visitar // y sus caminos mínimos // Permite comprobar la ejecución del algoritmo paso a paso static void visualizar (int[] caminoMinimo, boolean[] visitados) { System.out.print( "{" ); for (int i = 0; i < visitados.length; ++i) { if ( visitados[i] ) { System.out.print( i + " " ); } }
System.out.print( "}" ); System.out.print( " " ); System.out.print( "[" ); for (int i = 1; i < caminoMinimo.length; ++i) { System.out.print( caminoMinimo[i]+" " ); }
216
Capítulo 2: Esquemas Algorítmicos Fundamentales
System.out.print( "]" ); System.out.println(); } // Asigna en L el valor de las aristas, cuando una arista no existe // le asigna el máximo valor NOHAYENLACE public static void main(String[] args) { // Solución con los caminos mínimos int[] D; // L es la representación del grafo int [][] L;
// Crear un grafo a resolver 5x5 L = new int [5][5]; // Inicializarlo con los valores adecuados for (int i = 0; i< L.length; ++i) { for (int j = 0; j < L[i].length; ++j) { L[i][j] = NOHAYENLACE;
} }
// Crear las aristas L[0][1]= 50; // "Coste" de ir de nodo "0" a nodo "1" L[0][2]= 30; L[0][3]= 100; L[0][4]= 10; L[2][1]= 5; L[3][1]= 20; L[3][2]= 50; L[4][3]= 10; // Resolver D = dijkstra( L ); // Mostrar System.out.print( "\n\nSolución: {" );
for (int i = 0; i < D.length; ++i) { System.out.print( D[i]+" " ); }
System.out.println( "}\n" ); }}
1.6 Planificación: Minimización del tiempo en el sistema.El problema consiste en minimizar el tiempo medio que invierte cada tarea en el sistema. Este problema se puede resolver empleando un algoritmo voraz.
Un único servidor, como por ejemplo un procesador, un surtidor de gasolina, o un cajero de un banco, tiene que dar servicio a n clientes. El tiempo requerido por cada cliente se conoce de antemano: el cliente i requerirá un tiempo ti para 1<= i <= n. Deseamos minimizar el tiempo invertido por cada
217
Capítulo 2: Esquemas Algorítmicos Fundamentales
cliente en el sistema. Dado que el número n de clientes está predeterminado, esto equivale a minimizar el tiempo total invertido en el sistema por todos los clientes. En otras palabras, deseamos minimizar T
= ∑i= 1
n
tiempo en el sistema para el cliente i)
Supongamos por ejemplo que tenemos tres clientes, con t1 = 5, t2 = 10 y t3 = 3. Existen seis órdenes de servicio posibles:
Orden T123: 5 + (5+10) + (5+10+3) = 38132: 5 + (5+3) + (5+3+10) = 31213: 10 + (10+5) + (10+5+3) = 43231: 10 + (10+3) + (10+3+5) = 41312: 3 + (3+5) + (3+5+10) = 29321: 3 + (3+10) + (3+10+5) = 34
En el primer caso, se sirve inmediatamente al cliente 1, el cliente 2 espera mientras se sirve al cliente 1 y entonces le llega el turno, y el cliente 3 espera mientras se sirve a los clientes 1 y 2, y se le sirve en último lugar; el tiempo total invertido en el sistema por los tres clientes es 38. Los cálculos para los demás casos son similares.
En este caso la planificación óptima se obtiene cuando se sirve a los tres clientes por orden creciente de tiempos de servicio: el cliente 3, que necesita el menor tiempo, es servido en primer lugar, mientras que el cliente 2, que necesita mayor tiempo, es servido en último lugar. Al servir a los clientes por orden decreciente de tiempos de servicio se obtiene la peor planificación.
Para dar plausibilidad a la idea de que puede ser óptimo planificar los clientes por orden creciente de tiempo de servicio, imaginemos un algoritmo voraz que construye la planificación óptima elemento a elemento. Supongamos que después de planificar el servicio para los clientes i1, i2, ..., im se añade un cliente j. El incremento del tiempo T en esta fase es igual a la suma de los tiempos de servicio para los clientes i1 hasta im (porque es lo que tiene que esperar el cliente j antes de recibir servicio) más tj, que es el tiempo necesario para servir al cliente j. Para minimizar esto, dado que un algoritmo voraz nunca reconsidera sus decisiones anteriores, lo único que podemos hacer es minimizar tj. Nuestro algoritmo voraz, por tanto, es bastante sencillo: en cada paso se añade al final de la planificación al cliente que requiera el menor servicio de entre los restantes.
Demostración: En la figura 3.1, se comparan dos planificaciones P y P’, se observa que los a1 primeros clientes salen del sistema exactamente al mismo tiempo en ambas planificaciones. Lo mismo sucede para los nb últimos clientes. Ahora el cliente a sale cuando antes salía el cliente b, mientras que el cliente b sale antes que salía el cliente a, porque tb < ta. Finalmente, los clientes que son servidos en las posiciones desde a+1 hasta b+1 también salen antes del sistema, por la misma razón. Por consiguiente, P’ es mejor que P en conjunto.
1 .. a1 a a+1 .. b1 b b+1 .. n
1 .. a1 b a+1 .. b1 a b+1 .. n
218
El algoritmo voraz es óptimo
Capítulo 2: Esquemas Algorítmicos Fundamentales
Figura 3.1 Intercambio de dos clientes.De esta manera, se puede optimizar toda planificación en la que se sirva a un cliente antes que a
otro que requiera menos servicio. Las únicas planificaciones que quedan son aquellas que se obtienen poniendo a los clientes por orden no decreciente de tiempo de servicio. Todas estas planificaciones son claramente equivalentes, y por tanto todas son óptimas.
La implementación de este algoritmo, en esencia, lo único que necesita es ordenar los clientes por orden de tiempo no decreciente de servicio, lo cual requiere un tiempo que está en O(n log n).
2 Divide y Vencerás.La idea básica de este esquema consiste dividir el problema en problemas más pequeños, hasta que estos son triviales. Entonces, se resuelven y se recombinan con las subdivisiones para presentar la solución. Puede esquematizarse en los siguientes pasos:
1. Dado un problema P, con datos D, si los datos permiten una solución directa, se ofrece ésta;2. En caso contrario, se siguen las siguientes fases:
a) Se dividen los datos D en varios conjuntos de datos más pequeños, Di.b) Se resuelven los problemas P(Di) parciales, sobre los conjuntos de datos Di,
recursivamente.c) Se combinan las soluciones parciales, resultando así la solución final.
Esquema genérico:
ts divide_y_venceras (tx x){
ts s1,s2,...,sk;tx x1,..,xk;si x es suficientemente simple devuelve solucion_simple (x)sino {
descomponer x en x1,..,xk;para (i ← 1, a k) {
s[i] ← divide_y_venceras(xi);}devuelve combinar (s1,s2,...,sk);
}}
2.1 OrdenaciónComo ejemplo, está que el problema de ordenar un vector admite dos soluciones siguiendo este esquema: los algoritmos Merge Sort y Quick Sort. algoritmos que se tratan en el tema de algoritmos de ordenación y búsqueda.
219
Capítulo 2: Esquemas Algorítmicos Fundamentales
El primer nivel de diseño de Merge Sort puede expresarse así:
si v es de tamaño 1 entonces v ya está ordenadosino
Dividir v en dos subvectores A y BOrdena A y B usando Merge SortMezclar las ordenaciones de A y B para generar el vector ordenado.
El siguiente algoritmo, Quick Sort, resuelve el mismo problema siguiendo también la estrategia de divide y vencerás:
si v es de tamaño 1 entonces v ya está ordenadosino
Dividir v en dos bloques A y BCon todos los elementos de A menores que los de BOrdenar A y B usando Quick sortDevolver v ya ordenado como concatenación de las ordenaciones de A y de B.
Para que el esquema algorítmico divide y vencerás sea eficiente es necesario que el tamaño de los subproblemas obtenidos sea similar.
2.2 Multiplicación de Enteros grandes.Como ya es conocido, el tamaño de cualquier tipo de dato numérico está sujeto a un rango. Así, normalmente los tipos de datos numéricos ofrecidos en los lenguajes de programación se basan en los que soporta directamente el hardware. Por ejemplo, los enteros sin signo en máquinas de 16 bits para el lenguaje C soportan 65536 valores distintos, mientras que en máquinas de 32 bits este valor se incrementa hasta más allá de los cuatro mil millones. Aunque este limite sea bastante grande, no deja de ser un límite, es decir, no es posible manejar directamente por hardware números cuyo valor sea de por ejemplo cien mil millones o incluso billones.
El coste de realizar las operaciones elementales de suma y multiplicación, es razonable considerarlo constante si los operandos son directamente manipulables por el hardware, es decir, no son muy grandes. Si se necesitan enteros muy grandes, y hay que implementar por software las operaciones, el coste dependerá de los algoritmos que se desarrollen.
Las operaciones de suma y resta pueden hacerse en tiempo lineal. Operaciones de multiplicación y división entera por potencias positivas de 10, pueden hacerse en tiempo lineal (desplazamiento de las cifras). Sin embargo, la operación de multiplicación con el algoritmo clásico supone un tiempo cuadrático.
A continuación se propone una técnica divide y vencerás para la multiplicación de enteros muy grandes:
• Sean u y v dos enteros de n cifras.• Se descomponen en mitades de igual tamaño:
• u = 10s w+x• v = 10s y+z con 0 <= x < 10s , 0 < = z < 10s y s = n/2
• Por tanto w e y tienen n/2 cifras• El producto es:
220
Capítulo 2: Esquemas Algorítmicos Fundamentales
• uv = 102s wy + 10s (wz+xy)+xz
Abundando en lo anterior, es posible presentar un pseudocódigo más detallado, que se presenta a continuación. Nótese que para poder implementarlo es imprescindible desarrollar el TDA GranEntero, el cuál además debe ofrecer las operaciones citadas anteriormente, multiplicación y división por potencias de diez, suma, y resta. Este TDA deberia implementarse como una clase, que guardara en una cadena el entero en cuestión como atributo. Cada operación se implementaría como un método que actúa sobre el entero.
En el siguiente pseudocódigo se asume que tal clase existe y funciona tal cuál ha sido descrita. Por simplicidad, no se incluye la citada clase, y por tanto, no se incluye la implementación en lenguaje Java.
GranEntero mult (GranEntero u, GranEntero v){
GranEntero w,x,y,z;Entero s;
n = max( tamaño( u ), tamaño( v ) );
si n es pequeño {devuelve multclasica ( u , v );
}sino {
s = n/2;w = u / 10s ;x = u % 10s;y = v / 10s;z = v % 10s; devuelve mult( w,y )* 102s + ( mult ( w,z ) + mult( x,y ) )* 10s + mult( x, z );
}}
El coste temporal supone para sumas, multiplicaciones por potencias de 10 y divisiones por potencias de 10 un tiempo lineal. Operación módulo de una potencia de 10 tiempo lineal (pueden hacerse con una división, una multiplicación y una resta). Si se supone que n es una potencia de 2, t(n) = 4 t(n/2) + O(n). Por lo tanto, el tiempo de ejecución es de O(n2).
La mejora se consigue si conseguimos reducir el cálculo a menos de 4 multiplicaciones. Teniendo en cuenta que: r = (w+x) (y+z) = wy + (wz + xy) + xz ... se puede reescribir el algoritmo de la siguiente forma:
Considerando p = wyq = xz r = (w+x) (y+z) 102s p + 10s (rpq) + q
221
Capítulo 2: Esquemas Algorítmicos Fundamentales
GranEntero mult2 (GranEntero u, GranEntero v){
GranEntero w,x,y,z,r,p,q;Entero s;
n = max( tamaño( u ), tamaño( v ) );
si n es pequeño {devuelve multclasica ( u,v );
}sino {
s = n/2;w = u / 10s ;x = u % 10s;y = v / 10s;z = v % 10s;r = mult2(w+x,y+z);p = mult2(w,y);q = mult2(x,z);
devuelve p* 102s + (r-p-q)* 10s + q;
}} El número de sumas (contando restas como si fueran sumas) es mayor que el algoritmo divide y
vencerás original. ¿Merece la pena efectuar cuatro sumas más para ahorrar una multiplicación? La respuesta es negativa cuando se multiplican números pequeños. Sin embargo, merece la pena cuando los números que hay que multiplicar son grandes, el tiempo requerido para la sumas es despreciable frente al tiempo que requiere una sola multiplicación.
El tiempo de t(n) = 3t(n/2) + g(n) cuando n es par y suficientemente grande tendría un tiempo de ejecución de O(nlg3). Lg 3 ≈ 1.585 es menor que 2, este algoritmo puede multiplicar dos enteros grandes mucho más deprisa que el algoritmo clásico de multiplicación, y cuanto mayor sea n, más merecerá la pena esta mejora.
2.3 Algoritmos de selección y de búsqueda de la mediana.Dado un vector T de n enteros, la mediana de T es un elemento m de T que tiene tantos elementos menores como mayores que él en T. Un problema más general es encontrar el késimo menor elemento. Obviamente, se puede realizar ordenando el vector, pero es de esperar que selección sea un proceso más rápido, al solicitarse menor información. Haciendo un pequeño cambio en el algoritmo quicksort, se puede resolver el problema de selección en tiempo lineal, en promedio. Llamamos a este algoritmo selección rápida. Los pasos a realizar son los siguientes:
1. Si el número de elementos en S es 1, entonces presumiblemente k también es 1, por lo que se puede devolver el único elemento en S.
2. En otro caso, elegir un elemento v de S como pivote:1. Hacer una partición de S – {v} en I y D, exactamente igual a como se hacía en el
quicksort.2. Si k es menor o igual que el número de elementos en I, entonces el elemento que
estamos buscando debe estar en I, por lo que se llama recursivamente a
222
Capítulo 2: Esquemas Algorítmicos Fundamentales
seleccionRapida( I, k ). Si k es exactamente igual a uno más que el número de elementos I, entonces el pivote es el késimo menor elemento y se puede devolver como respuesta. En otro caso, el késimo menor elemento estará en D. De nuevo podemos hacer una llamada recursiva y devolver el resultado obtenido.
2.4 Torres de Hanoi.El de las Torres de Hanoi es un juego matemático consistente en mover unos discos de una torre a otra. La leyenda cuenta que existe un templo (llamado Benares), bajo la bóveda que marca el centro del mundo, donde hay tres varillas de diamante creadas por Dios al crear el mundo, colocando 64 discos de oro en la primera de ellas. Unos monjes mueven los discos a razón de uno por día, y, el día en que tengan todos los discos en la tercera varilla, el mundo terminará. Como se comprobará a continuación, en realidad 64 discos son suficientes para muchos años.
En este juego, se trata de pasar un número de discos (típicamente, con tres existe una dificultad suficiente como para plantearlo como un pasatiempo), de un poste de origen (el primero, más a la izquierda) a un poste de destino (el tercero, a la derecha), utilizando como poste auxiliar el del medio. Sólo se puede mover un disco de cada vez, y nunca poner un disco sobre un segundo que sea de menor diámetro que el primero. Así, al comienzo del juego todos los discos apilados en el primero (el de la izquierda). Cada disco se asienta sobre otro de mayor diámetro, de manera que tomados desde la base hacia arriba, su tamaño es decreciente. El objetivo, como ya se ha dicho, es mover uno a uno los discos desde el poste A (origen) al poste C (destino) utilizando el poste B como auxiliar, para lo cuál se puede emplear una técnica divide y vencerás, como se explica a continuación.
Vamos a plantear la solución de tal forma que el problema vaya dividiendo en problemas más pequeños, y a cada uno de ellos aplicarles la misma solución. Se puede expresar así:
• El problema de mover n discos de A a C consiste en:
mover los n1 discos superiores de A a B
mover el disco n de A a C
mover los n1 discos de B a C
Un problema de tamaño n ha sido transformado en un problema de tamaño n1. A su vez cada problema de tamaño n1 se transforma en otro de tamaño n2 (empleando el poste libre como auxiliar).
• El problema de mover los n1 discos de A a B consiste en:
mover los n2 discos superiores de A a C
mover el disco n1 de A a B
mover los n2 discos de C a B
De este modo se va progresando, reduciendo cada vez un nivel de dificultad del problema hasta que sólo haya que mover un único disco. La técnica consiste en ir intercambiando la finalidad de los postes, origen, destino y auxiliar. La condición de terminación es que el número de discos sea 1. Cada acción de mover un disco realiza los mismos pasos, por lo que puede ser expresada de manera recursiva. Así, podemos establecer un pseudocódigo como el siguiente:
223
Capítulo 2: Esquemas Algorítmicos Fundamentales
hanoi(int numDiscos, origen, auxiliar, destino)
{
if ( numDiscos == 1 ) {
mover el disco de origen a destino;
}
hanoi( numDiscos – 1, origen, destino, auxiliar );
mover el disco número numDiscos de origen a destino;
hanoi( numDiscos – 1, auxiliar, origen, destino );
}
Si el algoritmo se invoca con hanoi( 3, A, B, C ), se puede observar cómo el mismo va cambiando las funcionalidades (origen, auxiliar y destino) para los postes (A, B, y C), resolviendo el problema correctamente (también para cualquier otro número de discos).
El número de movimientos aumenta según va aumentando el número de discos, de manera exponencial. De hecho, se puede decir que para n discos, el número de movimientos será 2n – 1.
Número de discos Movimientos
3 7
4 15
5 31
10 1023
64 5,05E+016
En realidad, la leyenda descrita es una pura y absoluta invención. Este juego fue inventado por el matemático francés Edouard Lucas en 1883. En aquella época, Francia estaba en plena campaña militar por el sudeste asiático, y es bastante posible que el sitio a la ciudad de Hanoi le inspirase en la creación del nombre para el juego.
Implementación en JAVA
class Torres_de_Hanoi { static long int cont = 0;
static void movimiento (int n, char A, char C) { System.out.println ("Mover disco " + n + " de " + A + " a " + C); ++cont; }
224
Capítulo 2: Esquemas Algorítmicos Fundamentales
static void hanoi (int n,char A, char B, char C) { if ( n == 1 ) {
movimiento (n,A,C); } else {
hanoi( n1, A, C, B ); movimiento( n, A, C ); hanoi( n1, B, A, C ); } }
public static void main (String [] args) { int n = 10; char A = 'A'; char B = 'B'; char C = 'C';
hanoi( n, A, B, C );
System.out.println( "Total movimientos " + cont ); } }
2.5 Calendario de un campeonato.Se desea organizar los enfrentamientos en forma de liga para n participantes (asumimos n = 2K). Los enfrentamientos son en días consecutivos, y cada día cada jugador sólo juega un partido.
Cada participante debe saber el orden en el que se enfrenta a los n1 restantes. Por tanto, la solución puede representarse por una matriz n x (n1). El elemento (i,j), 0 <= i <= n, 0 <= j <= n1, contiene el número del participante contra el que el participante iésimo compite el día jésimo.
Solución por fuerza bruta:
1. Se obtiene para cada participante i el conjunto P(i) de todas las permutaciones posibles del resto de los participantes {0..n}\{i}.
2. Se completan las filas de la matriz de todas las formas posibles, incluyen en cada fila i algún elemento de P(i).
3. Se elige cualquiera de las matrices resultantes en la que toda columna j, contiene números distintos (nadie puede competir el mismo día contra dos participantes).
Cada conjunto P(i) consta de (n1)! Elementos. La matriz tiene n filas. Por lo tanto, hay n! formas distintas de rellenar la matriz. ¡Es demasiado costoso!
Una solución mediante la técnica divide y vencerás.
1. Caso básico: n = 2, basta con una competición.
2. Caso a dividir n = 2K , con k > 1.
a) Se elaboran independientemente dos subcalendarios de 2K1 participantes, uno para los participantes 0 .. 2K1 , y otro para los participantes 2K1 + 1 .. 2K .
225
Capítulo 2: Esquemas Algorítmicos Fundamentales
b) Falta elaborar las competiciones cruzadas entre los participantes de numeración inferior y los de numeración superior.
I. Se completa primero la parte de los participantes de numeración inferior:
• 1er. Participante: compite días sucesivos con los participantes de numeración superior en orden creciente.
• 2º participante: toma la misma secuencia y realiza una permutación cíclica de un participante.
• Se repite el proceso par todos los participantes de numeración inferior.
II. Para la numeración superior se hace lo análogo con los de la inferior.
Implementación en JAVA
public class Campeonato{ private int participantes; private int[][] Calendario; // Método para visualizar el calendario, se muestra en pantalla // en forma de matriz de n x (n1), siendo las filas los jugadores // y las columnas los días de la competición
void Visualizar() { System.out.println( "Filas = jugadores, Columnas = días competición\n" ); System.out.print( " " );
for (int i = 0; i < participantes 1; ++i) { System.out.print(" "+i+" "); }
System.out.println(); System.out.println( " " ); for (int i = 0; i < participantes; ++i) { System.out.print( i + "|" );
for (int j = 0; j < ( participantes – 1 ); ++j) { System.out.print( " " + Calendario[i][j] + " " ); }
System.out.println(); } }
226
Capítulo 2: Esquemas Algorítmicos Fundamentales
void establecerCalendario(int[][] Calen, int inicio, int fin) { if ( inicio == fin – 1 ) {
Calen[inicio][0] = fin; Calen[fin][0] = inicio; } else { int medio = ( inicio + fin ) /2; establecerCalendario( Calen, inicio, medio ); establecerCalendario( Calen, medio+1, fin );
//Enfrentamiento entre los equipos inferiores y superiores, //se le pasa cual es el
//equipo inicial, el equipo final, día de comienzo, día de fin //y cual es el equipo
//que inicia el enfrentamiento
completarCalendario( Calen, inicio, medio, ( fininicio )/2, ( fininicio ) 1, medio + 1 ); completarCalendario( Calen, medio + 1, fin, ( fin inicio ) / 2, ( fininicio ) 1, inicio
); } } void completarCalendario(int[][] Calen,
int Equipoinf, int Equiposup, int diainicio, int diafin, int Equipoinicio)
{
// El primer participante de numeración inferior // participa en orden creciente // con los participantes // de numeración superior, y el primer participante de numeración // superior participa en orden creciente // con los participantes de numeración inferior.
for (int i = diainicio; i <= diafin; ++i) { Calen[Equipoinf][i] = Equipoinicio++; } // Para el resto de los participantes for (int i = Equipoinf + 1; i <= Equiposup; ++i) { // El primer día se enfrenta al participante // con el que se enfrentó el participante
227
Capítulo 2: Esquemas Algorítmicos Fundamentales
// anterior el último día Calen[i][diainicio] = Calen[i1][diafin]; // El resto de los días se enfrenta al participante // que se enfrentó el día anterior // contra el participante anterior
for (int j = diainicio + 1; j <= diafin; ++j) { Calen[i][j] = Calen [i1][j1]; } } }}
class Ppal { public static void main(String args[]) {
Campeonato Obj = new Campeonato();
Obj.participantes = 8;Obj.Calendario = new int[Obj.participantes][Obj.participantes1];Obj.establecerCalendario( Obj.Calendario, 0, Obj.participantes 1 );Obj.Visualizar();
}}
228
Capítulo 2: Esquemas Algorítmicos Fundamentales
3 Algoritmos de vuelta atrás.Los algoritmos de vuelta atrás (también conocidos como de backtracking) hacen una búsqueda sistemática de todas las posibilidades, sin dejar ninguna por considerar. Cuando intenta una solución que no lleva a ningún sitio, retrocede deshaciendo el último paso, e intentando una nueva variante desde esa posición (es normalmente de naturaleza recursiva).
El proceso general de los algoritmos de vuelta atrás se contempla como un método de prueba y búsqueda, que gradualmente construye tareas básicas y las inspecciona para determinar si conducen a la solución del problema. Si una tarea no conduce a la solución, prueba con otra tarea básica. Es una prueba sistemática hasta llegar a la solución, o bien determinar que no hay solución por haberse agotado todas las opciones que probar.
La característica principal de los algoritmos de vuelta atrás es intentar realizar pasos que se acercan cada vez más a la solución completa. Cada paso es anotado, borrándose tal anotación si se determina que no conduce a la solución, esta acción constituye la vuelta atrás. Cuando se produce una vuelta atrás se ensaya otro paso (otro movimiento). En definitiva, se prueba sistemáticamente con todas las opciones posibles hasta encontrar una solución, o bien agotar todas las posibilidades sin llegar a la solución.
Esquema general de este método:
EnsayarSolucion{
Inicializar cuenta de opciones de selecciónrepetir{
Seleccionar nuevo paso hacia la soluciónsi válido {
Anotar el pasosi (no completada solución)
EnsayarSolucion a partir del nuevo pasosi (no alcanza solución completa)
Borrar anotación}
} hasta (completada solución) o (no más opciones)}
Este esquema puede tener variaciones. En cualquier caso siempre habrá que adaptarlo a la casuística del problema a resolver.
3.1 Problema de dar el Cambio.Supongamos que el cajero sólo tiene billetes de 2000, 5000 y 10000 y nos debe dar 21000 pts.
Con una estrategia voraz nunca llegaría a la solución correcta (daría 2 de 10000 y no podría dar el resto), mientras que con vuelta atrás podemos ir dando billetes (empezando por el mayor valor posible) y si llegamos a una combinación sin solución, volvemos atrás intentándolo con el segundo billete más
229
Capítulo 2: Esquemas Algorítmicos Fundamentales
grande y así sucesivamente.
Implementación en JAVA
public class DarCambioVueltaAtras {private int[] C; // Conjunto de Monedasprivate int[] D; // Conjunto de cantidades de monedasprivate int[] S; // Conjunto de soluciones
public int[] getSolucion() { return S; }
public DarCambioVueltaAtras(int[] c, int[] d) { C = c; D = d; }
public boolean devCambio(int cambio) {int i = 0;int paso;boolean objetivo = false;
// Inicializar el conjunto resultado S. // Sus campos deben estar a cero (Java ya lo hace)
S = new int[C.length];
// Llegar al primer C[i] válido según “cambio”while ( ( i < C.length )
&& ( C[i] > cambio ) ){
++i;}
// Bucle principalwhile ( ( i < C.length ) && ( !objetivo ) ) {
// Hay suficientes monedas if ( D[i] > 0 )
{// El siguiente paso es esa monedapaso = cambio C[i];D[i]; // Queda una menos
// Hacer el pasoif ( paso == 0 ) {
// Es el fín !objetivo = true;++S[i];
}else {
objetivo = devCambio( paso );
230
Capítulo 2: Esquemas Algorítmicos Fundamentales
if ( objetivo ) {++S[i];
}else ++D[i]; // Al final no la usamos
}
}++i;
}
return objetivo;
}}
class Ppal {static public void main(String[] args) {
// Preparar los conjuntos int[] C = { 10000, 5000, 2000 }; // Monedas int[] D = { 0, 1, 10 }; // Cantidades de monedas int[] S; // Solución
// Preparar la ejecución DarCambioVueltaAtras calc = new DarCambioVueltaAtras( C, D );
// Llamar al algoritmoif ( !calc.devCambio( 21000 ))
System.out.println( "No hay solución" );else {
S = calc.getSolucion();
// Visualizar el resultadofor (int i = 0; i < S.length; ++i) { if ( S[i] > 0 ) {
System.out.println( S[i] + " de " + C[i] ); }
}}
}
3.2 Problema de las 8 reinas.El problema consiste en colocar ocho reinas en un tablero de ajedrez sin que se den jaque (dos
reinas se dan jaque si comparten fila, columna o diagonal). Puesto que no puede haber más de una reina por fila, podemos replantear el problema como "colocar una reina en cada fila del tablero de forma que no se den jaque". En este caso, para ver si dos reinas se dan jaque basta con ver si comparten columna o diagonal. Por lo tanto, toda solución del problema se puede representar como una 8tupla (X0,...,X7) en la que Xi es la columna en la que se coloca la reina que está en la fila i del tablero.
1. Representación de la información:a) Debe permitir interpretar fácilmente la solución:b) X vector [0..n1] de enteros. X[i] almacena la columna de una reina en la fila i
ésima.2. Evaluación:
231
Capítulo 2: Esquemas Algorítmicos Fundamentales
a) Se utilizará un método buenSitio que devuelva el valor verdad si la késima reina se puede colocar en el valor almacenado en X[k], es decir, si está en distinta columna y diagonal que las k1 reinas anteriores.
b) Dos reinas están en la misma diagonal sii tienen el mismo valor de “fila + columna”, mientras que están en la diagonal sii tienen el mismo valor de “filacolumna”.(f1 –c1 = f2 – c2) ∨ (f1 + c1 = f2 + c2)⇔ (c1 – c2 = f1 – f2) ∨ (c1 – c2 = f2 – f1)⇔ |c1 – c2|= |f1 – f2|
Implementación en JAVA
class Reinas{ // Devuelve verdad si y sólo si se puede colocar una reina en la fila j // y columna A[j], habiendo sido colocadas ya las j1 reinas anteriores static public boolean buenSitio (int j, int[] R) { // ¿Es amenaza colocar la reina j en A[j], con las anteriores ? for(int i = 0; i < j; ++i) { { if ( ( R[i] == R[j] ) || ( Math.abs( R[i]R[j] ) == Math.abs( ij ) ) ) { break; } } return ( i == j ); } static public boolean colocarReinas (int j, int[] R) { boolean toret = false;
// Comprobar con todas las columnas for (int i = 0; i < R.length; ++i) { // Colocar la reina j en la columna i R[j] = i; if ( buenSitio( j, R ) ) { // Si j es N1 he colocado todas las reinas if ( j == R.length 1 ) { toret = true;
break; } else { // Hacer el siguiente paso recursivamente // (colocar la siguiente reina) if ( colocarReinas( j + 1, R ) ) { toret = true;
break; }
232
Capítulo 2: Esquemas Algorítmicos Fundamentales
} } } return toret;} public static void main (String args[]){ // En Reinas[i] se almacena la columna donde está i int[] reinas = new int[8]; if ( colocarReinas( 0, reinas ) ) {
for (int i= 0; i < R.length; ++i) { System.out.println( " Reina " + i
+ “ en la fila “ + i + ", columna " + R[i]
);}
} else System.out.println( "No hay solución\n" ); }}
3.3 Problema de Atravesar un Laberinto.Nos encontramos en un entrada de un laberinto y debemos intentar atravesarlo. Dentro del algoritmo nos encontraremos con muros que no podremos atravesar, sino que habrá que rodear, lo que hará que nos encontremos a veces en un callejón sin salida. Es necesario tener en cuenta las siguientes condiciones:
1. Representación: Array N x N, de casillas marcadas como libre u ocupada por una pared.2. Es posible pasar de una casilla a otra moviéndose solamente en vertical u horizontal.3. El problema se soluciona cuando desde la casilla (0,0) se llega a las casilla (n1, n1).
Para resolver este problema se diseñará un algoritmo de búsqueda con retroceso de forma que se marcará en la misma matriz del laberinto un camino solución si existe.
Si por un camino recorrido se llega a una casilla desde la que es imposible encontrar una solución, hay que volver atrás y buscar otro camino.
Además hay que marcar las casillas por donde ya se ha pasado para evitar meterse varias veces por el mismo callejón sin salida, dar vueltas alrededor de columnas....
El pseudocódigo de dicho algoritmo podría ser el siguiente:
/*PosicionX e PosicionY, indican la casilla en la que estoy dentro de la matriz Laberinto.En una posición del laberinto pueden almacenarse los valores Libre,
que indica que una casilla está Libre, Pared quiere decir que esa casilla hay una pared, Camino quiere decir que esa casilla forma parte del camino que se está recorriendo,
e Imposible quiere decir que esa casilla no conduce a la solución*/
233
Capítulo 2: Esquemas Algorítmicos Fundamentales
boolean ensayar (posicionX, posicionY, Matriz Laberinto) { si ((posicionX < 0) o (posicionX > n-1) o (posicionY < 0) o (posicionY > n-1)) {
devuelve falso } sino {
si (Laberinto[posicionX][posicionY] es distinto de “Libre”)devuelve falso
sino { Laberinto[posicionX][posicionY] es igual a “Camino”;
si ((posicionX es igual a n-1) y (posicionY es igual a n-1))//Se ha encontrado la solucióndevuelve verdadero
sino {//desplazarse en vertical u horizontal// por las otras casillassi (no Ensayar(posicionX+1, posicionY, Laberinto) si (no Ensayar(posicionX, posicionY+1, Laberinto) si (no Ensayar(posicionX-1, posicionY, Laberinto) si (no Ensayar(posicionX,posicionY-1, Laberinto) {
Laberinto[posiconX][posicionY] <- “Imposible” devolver falso;
}devolver verdadero;
} } }}
Implementación en JAVA
public class Laberinto {
private char L[][];
public static final char camino = 'c'; public static final char obstaculo = 'X'; public static final char imposible = '*'; public static final char libre = ' '; public laberinto(char[][] matrLab) { L = matrLab; }
public char[][] getLaberinto() { return L; }
234
Capítulo 2: Esquemas Algorítmicos Fundamentales
public void visualizaLaberinto() { for (int i = 0; i<L.length; ++i) { for(int j = 0; j<L.length; ++j) { System.out.print(L[i][j] + " ");
}
System.out.println(); } }
public boolean ensayar(int posicionX, int posicionY) { int n = L.length; // Tamaño del laberinto n x n boolean toret;
// estamos dentro del laberinto ?if ( ( posicionX < 0 )
|| ( posicionX > n1 ) || ( posicionY < 0 ) || ( posicionY > n1 ) )
{toret = false;
}else{
// No se puede pasar por encima de los obstáculosif ( L[posicionX][posicionY] != libre ) {
toret = false;}else{
// Marca como parte del camino L[posicionX][posicionY] = camino;
// Quizás ya es la soluciónif ( ( posicionX == n1 )
&& ( posicionY == n1 ) ){
toret = true; //Se ha encontrado la solución}else {
// en principio, hay solución toret = true;
//hay que desplazarse en vertical //u horizontal por las otras casillas
if ( !ensayar( posicionX+1, posicionY ) ) if ( !ensayar( posicionX, posicionY+1 ) ) if ( !ensayar( posicionX1, posicionY ) ) if ( !ensayar( posicionX, posicionY1 ) ) {
L[posicionX][posicionY] = imposible; toret = false; }
}}
}
235
Capítulo 2: Esquemas Algorítmicos Fundamentales
return toret; }}
class Ppal { public static void main(String args[]) { boolean haysolucion;
// Crear un laberinto char lab[][] = {
// Fila 1{ laberinto.libre, laberinto.libre, laberinto.libre, laberinto.libre},// Fila 2{ laberinto.obstaculo, laberinto.obstaculo,
laberinto.libre, laberinto.obstaculo
},// Fila 3
{ laberinto.libre, laberinto.libre,
laberinto.libre, laberinto.obstaculo},// Fila 4
{ laberinto.libre, laberinto.obstaculo, laberinto.libre, laberinto.libre}
}; Laberinto calcLab = new Laberinto( lab );
// Mostrar problema inicial calcLab.visualizaLaberinto();
// Encontrar la solución if ( calcLab.ensayar( 0, 0 ) ) System.out.println( "Se encontró una solución\n\n" ); else System.out.println( "No se encontró una solución\n\n" );
// Visualizar solución (o lo que sea) calcLab.visualizaLaberinto(); }}
236
Capítulo 2: Esquemas Algorítmicos Fundamentales
4 Programación dinámica.La idea de la técnica "divide y vencerás" es llevada al extremo en la programación dinámica. Si en el proceso de resolución del problema resolvemos un subproblema, almacenamos la solución, por si esta puede ser necesaria de nuevo para la resolución del problema. Estas soluciones parciales de todos los subproblemas se almacenan en una tabla sin tener en cuenta si van a ser realmente necesarias posteriormente en la solución total.
Con el uso de esta tabla se evitan hacer cálculos idénticos reiteradamente, mejorando así la eficiencia en la obtención de la solución.
4.1 Cálculo de los n primeros números primos.Este problema se puede resolver empleando un esquema de programación dinámica. Se almacena cada número primo encontrado en una tabla y se divide cada nuevo número sólo por los que hay en la tabla (y no por todos los menores que él) para saber si es primo:
Implementación en JAVA
class PrimosDinamica{
public static void buscaPrimos(int n) { int[] Tabla = new int[n]; // Se almacenan los números primos ya calculados Tabla[0] = 2; // Primer número primo int nPrimos = 1; // Contador de números primos
// Mientras no encuentre 100 números primos for (int i = 3; nPrimos < n; ++i) { int j = 0; // Mientras que los números primos que están almacenados // proporcionen un resto distinto de 0 ...
while ( ( j < nPrimos ) && ( i % Tabla[j] != 0 ) ){ ++j;}
// Se han comprobado todos los números primos e i no es// divisible por ninguno de ellos.if ( j == nPrimos ){ Tabla[nPrimos] = i; ++nPrimos;}
}
// Se visualiza el resultado for (int i = 0; i < n; ++i) { System.out.print( "Numero primo :" + Tabla[i] + " " ); } }
237
Capítulo 2: Esquemas Algorítmicos Fundamentales
public static void main (String args[]) {
// Buscar los 100 primeros números primosbuscaPrimos( 100 );
}}
238