Documente Academic
Documente Profesional
Documente Cultură
Capítulo 2: Esquemas algorítmicos fundamentales
1ALGORITMOS DEVORADORES......................................................................................................................................3
1.1DESCRIPCIÓN........................................................................................................................................................................3
1.2DAR LA VUELTA (1)..............................................................................................................................................................5
1.3PROBLEMA DE LA MOCHILA (1)...............................................................................................................................................9
1.4ÁRBOL DE EXPANSIÓN MÍNIMO (ALGORITMO DE PRIM).............................................................................................................11
1.5CAMINOS MÍNIMOS.............................................................................................................................................................13
1.6PLANIFICACIÓN: MINIMIZACIÓN DEL TIEMPO EN EL SISTEMA......................................................................................................17
2DIVIDE Y VENCERÁS......................................................................................................................................................19
2.1ORDENACIÓN......................................................................................................................................................................19
2.2MULTIPLICACIÓN DE ENTEROS GRANDES................................................................................................................................20
2.3ALGORITMOS DE SELECCIÓN Y DE BÚSQUEDA DE LA MEDIANA...................................................................................................22
2.4TORRES DE HANOI..............................................................................................................................................................23
2.5CALENDARIO DE UN CAMPEONATO.........................................................................................................................................25
3ALGORITMOS DE VUELTA ATRÁS.............................................................................................................................29
3.1PROBLEMA DE DAR EL CAMBIO.............................................................................................................................................29
3.2PROBLEMA DE LAS 8 REINAS.................................................................................................................................................31
3.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 devoradores
Estos 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ón
Por 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 T
Procesar T (reduciéndose D)
23
Capítulo 2: Esquemas Algorítmicos Fundamentales
Incorporar el procesado de T a la solución S
}
}
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 inicial
int 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ón
s ← 0 // Es la suma de los elementos de S
mientras ( s != n )
{
x ← el mayor elemento de C tal que s + x <= n
si 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.
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 S
sino 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 disponibles
while ( parcial < obj
&& i < C.length )
{
// buscar una moneda que “quepa”
k = obj – parcial;
if ( i < C.length
&& C[i] <= k )
{
// se encontró una moneda
k /= 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
n
consiste en determinar las cantidades c1, ..., cn que llenan la mochila maximizando el valor ∑ vipi
i= 1
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 coste
repetir
Seleccionar un nuevo tramo
hasta 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ícitamente
para (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(n 2).
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
n
= ∑ tiempo en el sistema para el cliente i)
i= 1
Supongamos por ejemplo que tenemos tres clientes, con t1 = 5, t2 = 10 y t3 = 3. Existen seis órdenes
de servicio posibles:
Orden T
123: 5 + (5+10) + (5+10+3) = 38
132: 5 + (5+3) + (5+3+10) = 31
213: 10 + (10+5) + (10+5+3) = 43
231: 10 + (10+3) + (10+3+5) = 41
312: 3 + (3+5) + (3+5+10) = 29
321: 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.
El algoritmo voraz es óptimo
218
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ón
Como 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á ordenado
sino
Dividir v en dos subvectores A y B
Ordena A y B usando Merge Sort
Mezclar 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á ordenado
sino
Dividir v en dos bloques A y B
Con todos los elementos de A menores que los de B
Ordenar A y B usando Quick sort
Devolver 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:
s
• u = 10 w+x
s s s
• v = 10 y+z con 0 <= x < 10 , 0 < = z < 10 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.
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 = wy
q = xz
r = (w+x) (y+z)
102s p + 10s (rpq) + q
221
Capítulo 2: Esquemas Algorítmicos Fundamentales
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);
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).
225
Capítulo 2: Esquemas Algorítmicos Fundamentales
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ón
repetir
{
Seleccionar nuevo paso hacia la solución
si válido
{
Anotar el paso
si (no completada solución)
EnsayarSolucion a partir del nuevo paso
si (no alcanza solución completa)
Borrar anotación
}
} hasta (completada solución) o (no más opciones)
}
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 Monedas
private int[] D; // Conjunto de cantidades de monedas
private 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 principal
while ( ( i < C.length )
&& ( !objetivo ) )
{
// Hay suficientes monedas
if ( D[i] > 0 )
{
// El siguiente paso es esa moneda
paso = cambio C[i];
D[i]; // Queda una menos
// Hacer el paso
if ( 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 algoritmo
if ( !calc.devCambio( 21000 ))
System.out.println( "No hay solución" );
else {
S = calc.getSolucion();
// Visualizar el resultado
for (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
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áculos
if ( L[posicionX][posicionY] != libre ) {
toret = false;
}
else
{
// Marca como parte del camino
L[posicionX][posicionY] = camino;
// Quizás ya es la solución
if ( ( 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 primos
buscaPrimos( 100 );
}
}
238