Академический Документы
Профессиональный Документы
Культура Документы
Índice
1. Introducción 1
3. Algoritmo de Dijkstra 4
3.1. Estructuras de datos y pseudocódigo del algoritmo de Dijkstra . . . . . . . . . . 5
1. Introducción
Imagı́nate ésta situación: tienes que llevar a cabo cierta tarea, y hay muchas formas de
conseguirlo. En principio todas esas formas de completar la tarea son válidas, son planes factibles.
Pero además de completar la tarea asignada, que es el objetivo irrenunciable, tienes otro objetivo
secundario: hacerlo lo más rápido posible, o lo más barato posible, o gastar la menor cantidad
posible de un cierto recurso, etc. Entonces, cuando tienes en cuenta este segundo objetivo, no
todos los planes factibles son igual de buenos, porque unos son más costosos que otros (en
tiempo, en dinero, en el recurso que queremos ahorrar, etc.) Surge entonces la necesidad de
escoger, entre los planes factibles, el que mejor satisface nuestro objetivo. Se trata entonces de
un problema de optimización: escoger la mejor solución entre las posibles.
Vamos a empezar presentando algunos algoritmos voraces sencillos, pero a la vez útiles
e interesantes. Algunos pueden incluso ser ya conocidos de cursos anteriores en matemática
discreta, etc. El objetivo es ir señalando algunas caracterı́sticas comunes a todos ellos.
1
que no haya dos vértices adyacentes del mismo color. Esa condición delimita las respuestas ad-
misibles del algoritmo. Pero además tenemos otro objetivo: colorear el grafo empleando la menor
cantidad posible de colores. ¿Qué esperamos en este caso como respuesta del algoritmo? Pues
una coloración del grafo, que no es sino una lista S en la que aparezca junto a cada vértice el
color asignado (de forma que se cumpla la condición sobre vértices adyacentes, por supuesto).
En principio, es fácil ver cómo ir avanzando hacia la respuesta desde respuestas parciales: si
ya hemos coloreado algunos vértices del grafo, buscamos un vértice no coloreado v, elegimos un
color y probamos a ver si se puede colorear con ese color; es decir, comprobamos si ese color ya
ha se ha empleado en alguno de los vértices adyacentes. Si no es ası́, coloreamos el vértice con
ese color (lo añadimos a la lista solución) y pasamos al siguiente vértice. Si ya hemos utilizado
el color en algún vecino de v entonces probamos con el siguiente color. ¿Cuántos colores hay que
considerar? Está claro que, como mucho, tantos como vértices del grafo. Ası́ pues, sean v1 , . . . , vn
son los vértices del grafo, y nombremos a los colores simplemente por un número entre 1 y n.
En principio podemos limitarnos a colorear los vértices del grafo empezando por v1 y siguiendo
el orden en que se han numerado los vértices. Aunque esta puede no ser la mejor opción, nos
servirá para ilustrar el hecho de que los algoritmos voraces no son especialmente astutos a la
hora de construir la solución.
Con estas ideas, podemos hacer un primer esquema del algoritmo de coloreado:
Función ColorearGrafo(G:tGrafo):Coloración:tColoración;
Var i:contador;
(* i recorre los vertices del grafo*)
Var color:contador;
(* color recorre los colores del 1 al n *)
para un vertice vi , desde i := 1 hasta n hacer
repetir
ColorEncontrado:=True;
Considerar el siguiente color;
si alguno de los vecinos de vi se ha coloreado con ese color entonces
Hacer ColorEncontrado:=False;
fin si
hasta ColorEncontrado:=True
Añadir la pareja (vi , color) a la solución;
fin para
Devolver la solución
Para convertir esto en un programa tenemos que discutir las estructuras de datos que vamos
a emplear. Supondremos que el grafo G tiene un número NumVertices de vértices y que viene
dado por una matriz de adyacencia arista[1..n, 1..n], de forma que arista[i, j] = 1 significa que
en G hay una arista entre vi y vj . Ası́ pues el tipo tGrafo que vamos a emplear se define ası́:
tipo tGrafo=record
NumVertices:Entero;
arista:array[1..n, 1..n]de Enteros
end;
Para comprobar si alguno de los vecinos de vi se ha coloreado con el mismo color basta con que
2
nos preocupemos de los vértices ya coloreados, que son v1 , . . . , vi−1 . Para saber si uno de ellos,
sea vj , es vecino de vi , miramos la fila i de la matriz de adyacencia. Si G[i, j] es 1, entonces
comprobamos el color asignado a vj .
Con este refinamiento de nuestras ideas el algoritmo queda ası́:
Función ColorearGrafo(G:tGrafo):tColoración;
Var i,j:contador;
(* i recorre los vertices del grafo, j los vértices del 1 al i *)
Var color:contador;
(* color recorre los colores del 1 al n *)
Var ColorEncontrado:Boolean;
(* sirve para señalar que ya hemos coloreado el vértice i *)
Var Coloracion:tColoracion;
(* almacena la solución al problema*)
para i := 1 hasta G.N umV ertices hacer
Color:=0;
repetir
ColorEncontrado:=True;
Color:=Color+1;
para j := 1 hasta i − 1 hacer
si G[i, j] = 1 y Coloracion[j]:=color entonces
ColorEncontrado:=False;
fin si
fin para
hasta ColorEncontrado:=True;
Coloracion[i]:=color;
fin para
Devolver Coloracion
(Si no imprimes esta hoja en colores no verás el resultado. Pero en ese caso puedes considerar
este ejemplo como un ejercicio para aplicar el algoritmo, y colorear tú los vértices siguiendo
el orden que se indica.) El número de colores necesario es dos. Sin embargo, como muestra la
3
siguiente figura, una ordenación diferente de los vértices conduce a un resultado muy distinto,
incluso en un grafo tan pequeño como éste:
En este caso hemos necesitado cuatro colores para colorear el grafo. Como ejercicio, hay una
ordenación de los vértices para la que se necesitan tres colores ¿puedes encontrarla?
Este ejemplo ilustra una caracterı́stica común a muchos algoritmos voraces: su estrategia miope.
Es decir, el algoritmo selecciona el mejor candidato según un criterio local, mirando a sus vecinos,
y no tiene una buena percepción de la situación a larga distancia. Por eso muchas veces los
algoritmos voraces producen soluciones no óptimas. Aunque en muchos casos son la única opción
disponible, y sus soluciones son, en general, moderadamente buenas.
3. Algoritmo de Dijkstra
Muchos problemas se pueden representar mediante un grafo con las aristas etiquetadas con
un número no negativo. Es frecuente decir entonces que ese número es la longitud (o a veces el
coste) de la arista en cuestión. Llamaremos d(v, w) a la longitud de la arista que conecta v con
w.
Estos grafos se denominan a menudo grafos ponderados. Cuando se usa un modelo semejante
para un problema, surge a menudo a necesidad de encontrar la distancia más corta (o el precio
más barato, si pensamos en precios en vez de en longitudes) entre dos vértices dados v y w. Existe
un algoritmo, creado por E. Dijkstra (ganador del premio Turing), que resuelve este problema.
De hecho el algoritmo de Dijkstra proporciona la longitud del camino más corto desde un vértice
v, llamado el vértice raı́z, a cualquier otro vértice del grafo. Además, el algoritmo calcula cuál
es, de hecho, el camino más corto desde v a cualquier vértice w.
El algoritmo procede iterativamente, de manera que a medida que avanza, aumenta el número
de nodos para los que conocemos el camino de longitud mı́nima que los une con v. El proceso
se lleva a cabo de la siguiente manera: inicialmente sólo sabemos cuál es el camino más corto de
v a v, que es obviamente de longitud cero. A partir de ese punto inicial, en cada momento del
proceso hay un conjunto de nodos, llamémosle S, que contiene los nodos que ya han sido tratados
y de los que no es necesario volver a ocuparse. Es decir, que ya sabemos cuál es el camino de
longitud mı́nima que conduce de v a cualquiera de los nodos de S. Y hay otro conjunto C, que
contiene los nodos que aún no hemos tratado, y para los que no sabemos cuál es el camino más
4
corto hasta v. En cada iteración un nodo de C se incorpora a S, de manera que a medida que
el algoritmo progresa el conjunto S crece, y C mengua, hasta que al llegar al final S contiene
todos los vértices del grafo y C está vacı́o. El punto de partida del algoritmo es S = {v} y C
contiene a todos los restantes vértices.
Para construir ese conjunto, los vértices reciben dos etiquetas, llamadas L(w) y P (w). La
etiqueta L(w) de un vértice w vale, en cada momento, la longitud del camino más corto que
hemos sido capaces de construir por el momento, desde v hasta w, usando sólo vértices de S.
Y la etiqueta P (w) nos dice cuál es el vértice (de C) que precede a w en el camino más corto
desde v a w encontrado por el momento. Esas etiquetas van cambiando durante la ejecución del
algoritmo, a medida que nuestro conocimiento aumenta. Inicialmente, sólo sabemos construir
un camino de longitud 0 de v a v, ası́ que L(v) := 0, P (v) := v, y, para cada vecino w de v,
sabemos la longitud de la arista que lo conecta con v. Ası́ que para cada uno de esos vecinos,
empezamos por hacer L(w) := a(v, w), P (w) := v. En cuanto al resto de los vértices, los que no
son vecinos directos de v, todavı́a no sabemos llegar hasta ellos desde v: no tenemos información
sobre ellos. Lo que se suele hacer para representar esta falta de información es adjudicar a los
restantes nodos una etiqueta especial, que se designa con el sı́mbolo ∞. Ası́ pues el comienzo
del algoritmo se completa asignado a esos vértices L(w) := ∞, P (w) := w. (Los valores de P
para estos vértices no son importantes, ası́ que se pueden dejar sin asignar).
Supongamos ahora que estamos en un paso intermedio, y que tenemos una cierta cantidad de
vértices en S, y el resto están en C. Para seguir avanzando, tenemos que seleccionar un vértice
de C e incorporarlo a S. Lo que hacemos es tomar el nodo w de C que tiene un valor mı́nimo
de L(w). Es fácil entender que ese nodo tiene que tener entre sus vecinos algún nodo de S, un
nodo ya tratado. Y que el valor de L(w) es la longitud de un camino desde v a w pero formado
sólo por nodos de S.
A continuación, para cada vecino z de w que aún esté en C, actualizamos la etiqueta L(z).
La forma de actualizarla es fácil de entender: puesto que ahora w está en S, se puede llegar
hasta z haciendo primero el camino de v a w, con longitud L(w), y después usando la arista que
une w y z, que mide a(w, z). Ası́ que comparamos la mejor información de a que disponı́amos
previamente, que es L(z), con L(w) + a(z, w). El menor de esos dos valores es el nuevo L(z). Y
además, todavı́a tenemos que actualizar P (z) para esos vecinos. El nuevo valor depende de si
L(z) ha cambiado o no: si ha cambiado, es porque el camino más corto hasta z pasa por w, y
entonces debemos hacer P (z) := w. Si no ha cambiado, debemos dejar P (w) como estaba.
Con ésto estamos listos para la siguiente iteración. Cuando el algoritmo llega al final, la
etiqueta L(w) de cada nodo es la longitud mı́nima entre todos los caminos que unen v con
w. Y si queremos saber cual es ese camino, tenemos que mirar la etiqueta P (w). El valor que
allı́ aparece nos indica cuál es el anterior vértice en ese camino, supongamos que es w̃. entonces
miramos P (w̃), y tenemos el anterior nodo. Continuando ası́, eventualmente tendremos que
llegar a v, y la lista de vértices por los que hemos pasado nos indica el camino más corto de v a
w.
5
Función Dijkstra(a[1..n,1..n]):L[1..n],P[1..n];
C := {2, . . . , n};
L(1) := 0; P (1) := 1
para i desde 2 hasta n hacer
L(i) := a(1, i)
fin para
k := 0
repetir
k:=k+1;
Encontrar el w de C con L(w) mı́nimo;
Eliminar w de C;
para cada z ∈ C hacer
si L[z] > L(w) + a[w, z] entonces
L(z) := mı́n(L(z), L(w) + a[w, z]);
P [z] := w
fin si
fin para
hasta k:=n-1;
6
con la solución parcial que hemos construido hasta el momento. En el algoritmo de coloreado
esta comprobación consiste en verificar que el color seleccionado no se ha usado en ninguno de
los vecinos. En el de Dijkstra en cambio la comprobación no es necesaria; los candidatos son
siempre compatibles.
Una vez encontrado un candidato compatible, se debe añadir ese candidato a la solución; es
decir, usarlo para fabricar una nueva solución parcial. En el algoritmo de coloreado esto significa
anotar el color que se ha elegido para ese vértice. En el de Dijkstra, la nueva solución se obtiene
actualizando las etiquetas de los restantes candidatos.
Finalmente, el último paso del bucle voraz consiste en eliminar el candidato ya tratado del
conjunto C de candidatos: un algoritmo voraz nunca vuelve atrás, y no plantea por segunda vez
un candidato que ya ha desechado.
Cuando el bucle voraz termina, puede ocurrir que haya encontrado la solución, o que se haya
quedado sin candidatos antes de encontrar una solución. Los dos ejemplos que hemos visto,
terminan siempre con una solución del problema.
Resumiendo toda la discusión anterior se puede dar el siguiente esquema genérico para un
algoritmo voraz, que luego se debe adaptar a cada caso concreto:
Función Voraz(Problema):Solución;
C:=Candidatos(Problema);
S:=InicializarSolución(Problema);
mientras QuedanCandidatos(C) y no EsSolución(S) hacer
x:=SeleccionarMejorCandidato(C);
si EsCompatible(x,S) entonces
S:=Añadir(x,S)
fin si
C:=Eliminar(x,C);
fin mientras
si EsSolución(S) entonces
Devolver S como solución
si no
No se encuentra solución.
fin si
Conviene comparar este esquema general con los esquemas concretos del algoritmo de coloreado
y el algoritmo de Dijkstra para identificar cada uno de los elementos que aparecen aquı́. La
primera observación en ese sentido es que el bucle voraz aparece aquı́ como un bucle Mientras,
pero en muchos casos se puede programar como un bucle repetir o un bucle para. Este último
caso se presenta muchas veces cuando tenemos una lista de candidatos almacenada en un vector
y vamos recorriéndolos por orden. En esa situación la condición de terminación del bucle voraz
se vuelve trivial, y coincide con el final del bucle para.
7
Definición 4.1. Una cola de prioridad es una estructura de datos que contiene un conjunto C
de elementos (los candidatos), cada uno de los cuales tiene asociado una clave o prioridad, de
manera que podemos comparar dos elementos por sus prioridades y decidir cuál es prioritario.
Una cola de prioridad dispone de las siguientes operaciones:
1. Insertar(C, x): para añadir un elemento a C.
2. Máximo(C): para obtener el elemento de C con mayor prioridad.
3. Extraer-máximo(C): obtener el elemento de mayor prioridad de C y eliminarlo de C.
4. Actualizar-Prioridad(C, x, k): cambiar la prioridad de un elemento x de C al nuevo
valor k.
Una forma especialmente interesante de hacer ésto en muchos casos consiste en utilizar un
montı́culo para almacenar los restantes candidatos, de manera que la raı́z del montı́culo contenga
siempre al candidato que se va a seleccionar. La estructura de montı́culo permite llevar a cabo
de forma eficiente las operaciones necesarias en una cola de prioridad.
8
Ahora seleccionamos la primera tarea por orden de beneficio, que es la 4, y comprobamos que
es factible, simplemente comprobando que su fecha de caducidad 2 es mayor o igual que 1. A
continuación tomamos la siguiente tarea que, por orden de beneficio, es la 1. Comprobamos
que la pareja de tareas 4,1 sea factible. Para eso las ordenamos por sus fechas de caducidad,
obteniendo: el orden 1, 4 de tareas con fechas de caducidad 1, 2. Esto es factible porque ninguna
tarea ocupa una posición mayor que su fecha de caducidad. Ası́ que aceptamos la lista de tareas
1, 4. A continuación tomamos la tarea 2, y ordenamos el conjunto de tareas 1, 4, 2 por caducidad.
Se obtiene 1, 2, 2 para las tareas realizadas en orden 1, 4, 2. Y como la última tarea ocupa la
posición 3 y tiene caducidad 2, el conjunto no es factible y rechazamos la tarea 2. A continuación
consideramos las tarea 1,4,3 ordenadas por caducidad. Serı́a 1, 1, 2 para las tareas realizadas en
el orden 1, 3, 4. Pero de nuevo la última tarea tiene caducidad 3, mayor que la posición dos que
ocupa, ası́ que rechazamos la tarea 3. El conjunto de tareas resultante por tanto es 1, 4, en ese
orden, con un beneficio de 17 unidades.
(Fin del ejemplo)
Como puedes ver en este ejemplo, los valores concretos de los beneficios no se utilizan durante
la construcción de la solución. Ası́ que, antes de empezar el bucle voraz el algoritmo debe ordenar
inicialmente las tareas por beneficio de mayor a menor. Para ello puede emplearse cualquier
algoritmo de ordenación.
Ası́ pues el algoritmo recibe como datos dos vectores, la lista de beneficios g y la de fechas
de caducidad d. Empezamos por invocar una función de ordenación OrdenBenef icio(g) que
devolverá como resultado un vector
T B = (i1 , i2 , . . . , in )
tal que la tarea Tik es la k-ésima por orden de beneficio (este vector es la primera fila de la
segunda tabla en el ejemplo anterior).
Una vez obtenido este vector, empieza el bucle voraz de selección o rechazo de tareas, cuyo
objetivo es devolver la solución en un vector con la lista de tareas. Este algoritmo se describe
inicialmente ası́:
Función planificación(g,d:vector):vector;
VAR j : vector
(*Este vector almacena las tareas ya seleccionadas,
aunque hasta el final no se obtiene el orden correcto*)
k:Integer;
(*Y este número nos dice cuantas tareas hemos seleccionado ya*)
TB:=OrdenBeneficio(g);
para i := 1 hasta n hacer
Leer la caducidad d[TB[i]];
Buscar el lugar r de d[T B[i]] entre las caducidades d[j[1]], . . . , d[j[k]]
si la lista de caducidades ası́ obtenida es factible entonces
Desplazar las tareas j[r + 1] hasta j[k] una posición a la derecha.
Colocar T B[i] en la posición j[r] que le corresponde en j
Aumentar k en 1
fin si
fin para
Devolver como solución el vector j
Para buscar el lugar r que le corresponde a la caducidad d[T B[i]] entre las caducidades
d[j[1]], . . . , d[j[k]] basta con recorrer esas posiciones usando una variable r en un bucle mientras
(decreciente) hasta que deje de cumplirse d[j[r]] > d[T B[i]]. Para garantizar el éxito de esta
búsqueda vamos a emplear la técnica del centinela, haciendo que la primera posición del vector
9
d almacene el valor cero (se supone que las caducidades son positivas). Empezamos haciendo
r := k y al terminar la búsqueda, el valor r + 1 nos dice la posición que ocupará la caducidad
de la nueva tarea.
Después de localizar esa posición debemos insertar la nueva tarea. Es decir que si hemos
comprobado que el lugar que le corresponde es detrás de la tarea j[r], entonces debemos desplazar
las tareas desde j[r +1] hasta j[k] una posición a la derecha y colocar T B[i] en la posición j[r +1]
que ha quedado libre.
Hay que verificar además si ası́ se obtiene una lista factible de tareas. El problema se entiende
bien con un ejemplo como éste:
Ejemplo 5.3. Si tenemos ya una lista parcial de tareas, cuyas caducidades son (1, 3, 3, 5) y la
siguiente tarea candidato tiene por caducidad el valor 2, entonces al insertar esa caducidad en
la posición que le corresponde en la lista se obtiene:
(1, 2, 3, 3, 5)
y esta lista no es factible porque en la cuarta posición tenemos una tarea de caducidad 3.
A la vista de este ejemplo podemos pensar que después de insertar la caducidad hay que
volver a recorrer la lista de las tareas empezando por la nueva y comprobando todas las que se
han desplazado, desde la r + 1 hasta la última, para comprobar mirando las caducidades que
seguimos teniendo una lista factible. ¿Y si no fuera ası́?¿Habrı́a que “desinsertar” la nueva tarea?
Afortunadamente hay una solución mucho más fácil. Podemos aprovechar el primer recorrido,
en el que buscamos la posición que corresponde a la tarea candidato para comprobar que la lista
resultante será factible. En efecto, cada vez que para un cierto r comparamos la caducidad del
candidato T B[i] con la de una tarea j[r − 1] ya aceptada, si se cumple d[j[r − 1]] > d[[T B[i]]]
entonces sabemos que la tarea j[r − 1] es una de las que se desplazarán. Y podemos comprobar
en este momento si al desplazar esa tarea va a quedar fuera de su plazo de caducidad. Para eso
podemos comprobar si es:
d[j[r − 1]] = r − 1
Si ocurre eso, entonces al desplazarla, la tarea j[r − 1] pasará a ocupar la posición r, mientras
que su fecha de caducidad es r − 1, y tendremos una lista no factible.
Tenemos que modificar por lo tanto el bucle de búsqueda para que localice la posición del
candidato, si es que es posible insertarlo en una posición que produzca una lista factible. A la
salida de este bucle de búsqueda puede ocurrir una de estas dos cosas:
1. Se puede insertar el candidato en la posición r sin que ninguna de las tareas ya aceptadas
ocupe una fecha mayor que su caducidad. Si el valor de r es k + 1 es que la posición que
hemos encontrado es la última de la lista. En cualquier caso, antes de la inserción y de los
posibles desplazamientos que sean necesarios (si r <= k), todavı́a tenemos que verificar
si la caducidad de la nueva tarea es compatible con la posición que ocupará. Es decir si
d[T B[i]] >= r. Si esto no ocurre la tarea debe ser rechazada.
10
(*Y este número nos dice cuantas tareas hemos seleccionado ya*)
r:Integer;
(*La posición de la tarea candidato entre las ya aceptadas*)
k := 0;
d[0] := 0; j[0] := 0; (*Técnica del centinela*)
TB:=OrdenarBeneficio(g);
para i := 1 hasta n hacer
r := k + 1;
mientras d[j[r − 1]] > d[T B[i]] y d[j[r − 1]] > r − 1 (*Comprobamos si hay que desplazar
tareas*) hacer
r:=r-1; (* y si la lista es factible *)
fin mientras
si (d[T B[i]] >= r) y d[j[r −1]] > r −1 (*Comprobamos si se inserta el candidato*) entonces
Notificar que la tarea T B[i] ha sido aceptada.
para l := k bajando hasta r + 1 (*Bucle de desplazamiento*) hacer
j[l + 1] := j[l];
fin para
j[r] := T B[i] (*Inserción*)
k := k + 1 (*Actualizamos el número de tareas aceptadas*)
si no
Notificar que la tarea T B[i] ha sido rechazada.
fin si
fin para
Devolver como solución el vector j
entonces si k > Cmax, el conjunto de tareas no es factible. Dicho de otra manera: si al cabo de
Cmax dı́as sabemos que todas las tareas han caducado, y tenemos más tareas por hacer que ese
número de dı́as, alguna tarea no se podrá realizar en el plazo correcto.
Imagı́nate ahora que quiero obtener una planificación factible del conjunto de tareas T1 , . . . , Tn
que incluya al mayor número posible de tareas para maximizar el beneficio . Tengo que tratar
de encajar las fechas de caducidad d1 , . . . , dn en una lista, inicialmente vacı́a, de n dı́as, con la
condición ya conocida de que sea di ≥ i. Y tendré que decidir las tareas que rechazo y las que
acepto, y las fechas en las que se realizan estas últimas. Empiezo con la tarea T1 . Puesto que
la lista esta vacı́a, la coloco tan tarde como sea posible. Es decir en la posición d1 , o bien en
la última posición si d1 > n. Luego tomo T2 . Y la coloco tan tarde como sea posible. Es decir,
en la primera posición de la lista a la izquierda de d2 (o de n si d2 > n) que encuentre libre. Y
sigo ası́ con el resto de tareas. La idea deberı́a estar clara: si colocamos cada tarea por orden
tan atrás en la lista como sea posible, y si aún ası́ no conseguimos una lista factible, entonces
no parece que pueda haber otra forma de conseguirla.
Ejemplo 5.4. ¿Cómo podemos planificar una lista de tareas cuyas fechas de caducidad son
éstas?
d1 = 3, d2 = 11, d3 = 5, d4 = 2, d5 = 1, d6 = 5, d7 = 2)
11
Empiezo con una lista vacı́a:
y coloco la tarea T1 en la posición 3, la más retrasada que es compatible con su fecha de caducidad
d1 = 3 (indico la caducidad de cada tarea como exponente):
T13
Ahora coloco T2 , que tiene d2 = 11 > 7 en la posición más retrasada posible, la última en éste
caso:
T13 T211
La tarea T3 con d3 = 5 ocupa la posición 5. La tarea T4 con d4 = 2 ocupa la posición 2. La T5
con d5 = 1 ocupa la 1, y tenemos:
2. En cada paso se coloca la tarea Ti en la posición j más grande posible tal que j ≤
máx(di , n), y que no haya sido ocupada previamente.
Se puede demostrar que el conjunto de tareas es factible si y sólo si este método tiene éxito en
construir una lista factible.
Ası́ que ahora imaginemos que hemos insertado ya varias de nuestras tareas en la lista.
Tendremos una lista con este aspecto:
12
Un conjunto, en este sentido, está formado por una celda libre y por todas las celdas ya
ocupadas que sean consecutivas a esa celda libre. Es decir, que en cualquier momento del proceso
la lista está formada por piezas como éstas:
Hay una dificultad en nuestra descripción si la primera celda de la lista está ocupada. Para
evitar ese problema incluimos una celda número cero que siempre se mantiene libre (un variante
de la técnica del centinela).
Vamos a llamar c1 , . . . , cn a las celdas de la lista. Cada conjunto de celdas comienza con una
celda libre, que vamos a llamar la cabeza de ese conjunto.
¿Cómo cambia la estructura de los conjuntos al introducir una nueva tarea, y por tanto
ocupar una celda que estaba libre? Supongamos que ci es esa celda libre que vamos a ocupar
insertando una nueva tarea. Puesto que ci estaba libre antes de insertar, era la cabeza de un
conjunto. Ese conjunto puede estar formado sólo por ci o por esa celda y unas cuantas celdas
ocupadas que la siguen. Al ocupar ci ya no puede seguir siendo la cabeza del conjunto. De hecho
la nueva cabeza tiene que pasar a ser la primera celda libre que quede a la izquierda de ci . Es
fácil darse cuenta de que esa celda es la cabeza del conjunto al que pertenece la celda precedente
ci−1 . Y una vez localizada, lo que tenemos que hacer es unir los dos conjuntos, para que ci y
todas las celdas ocupadas que estaban en su conjunto se incorporen al conjunto de ci−1 . Para
emplear la representación de la partición en subconjuntos mediante árboles tenemos que decidir
cuál va a ser el rótulo de cada conjunto. En principio podrı́amos pensar en utilizar la cabeza
de cada conjunto como rótulo de ese conjunto. Si hiciéramos ésto, puesto que la cabeza del
conjunto unión de dos conjuntos es la cabeza del conjunto situado más a la izquierda, deberı́a
ocurrir lo mismo con los rótulos. Pero al describir las estructuras de partición hemos visto que
era muy conveniente emplear la técnica de compresión de caminos y el control de rangos de los
árboles para mejorar la eficiencia general de las operaciones de búsqueda y unión. Y con esa
técnica, el rótulo de una unión depende de cuáles fueran los rangos de los árboles implicados.
No podemos usar esa técnica y al mismo tiempo imponer que el rótulo sea siempre el de la
izquierda, independientemente de los rangos. Ası́ que vamos a hacer lo siguiente: para cada
conjunto conservaremos por separado la información de cuál es su rótulo, y de cuál es su cabeza.
Ejemplo 5.5. Supongamos que queremos planificar este conjunto de tareas (ordenadas por
beneficio):
número de tarea 7 3 4 6 2 8 5 1
caducidad 2 4 3 4 1 3 3 4
beneficios 22 19 17 9 6 6 4 2
Empezamos con una lista vacı́a de nueve (ocho+centinela) posiciones:
T72
13
En la representación como partición en conjuntos, la celda 2 se ha unido con la celda 1, que es
el rótulo del conjunto; en términos de árboles tenemos:
()*+
/.-,
c0 , ()*+
/.-,
c1 , ()*+
/.-,
c3 , ()*+
/.-,
c4 , ()*+
/.-,
c5 , ()*+
/.-,
c6 , ()*+
/.-,
c7 , /.-,
()*+
c8
()*+
/.-,
c2
T72 T34
()*+
/.-,
c2 /.-,
()*+
c4
En el siguiente paso insertamos T4 , que tiene caducidad 3. La celda tres está libre, y la ocupamos:
La operación con conjuntos es más interesante en este paso. Al ocupar la celda 3, la posición
libre a la izquierda más cercana es la 1. Para localizar esa posición, tomamos la posición 2, la
anterior a la 3, y buscamos el rótulo del conjunto que la contiene, que es 1 (al llevar a cabo
esa búsqueda entra en juego la técnica de compresión de caminos en el árbol que tenı́a raı́z en
1, pero en este caso sin consecuencias porque el 2 ya es hijo directo de 1) Ası́ que el árbol que
tenı́a raı́z en c3 pasa a ser un subárbol del que tenı́a raı́z en c1 .
()*+
/.-,
c0 , ()*+
/.-,
c1 B , ()*+
/.-,
c5 , ()*+
/.-,
c6 , ()*+
/.-,
c7 , /.-,
()*+
c8
BB
BB
BB
()*+
/.-, ()*+
/.-,
B
c2 c3
()*+
/.-,
c4
A continuación insertamos T6 , con fecha de caducidad 4. ¿Dónde hay que insertarla? En una
celda libre a la izquierda de la celda 4. Buscamos el rótulo del conjunto que contiene a la celda
4. Al hacer esa búsqueda entra en juego la técnica de compresión de caminos, que reestructura
el árbol que contiene a la celda 4, dejándolo ası́:
()*+
/.-,
c
|| 1 BBB
|| BB
|| BB
/.-,
()*+ ()*+
/.-, ()*+
/.-,
|| B
c2 c3 c4
Al hacerlo tenemos que unir el árbol con raı́z en 0 y el árbol con raı́z en uno. Aquı́ puede
apreciarse por primera vez la técnica de control de rangos, que nos obliga a distinguir entre
14
rótulo y cabeza. Al unir estos dos árboles, utilizamos como raı́z el 1, que tiene mayor rango. Sin
embargo la cabeza del conjunto resultante es el 0.
()*+
/.-,
c1 ()*+
/.-,
c5 , /.-,
()*+
c6 , /.-,
()*+
c7 , /.-,
()*+
c8
nnnn|n| BBB
nnn | | BBB
nnn |||
()*+
/.-, ()*+
/.-, ()*+
/.-, ()*+
/.-,
nn BB
n |
c0 n c2 c3 c4
(T6 , T7 , T4 , T3 )
con caducidades:
(4, 2, 3, 4)
(Fin del ejemplo)
En este ejemplo puedes apreciar que, además de unir los conjuntos cuando se insertan nuevas
tareas, tenemos que mantener actualizada la información sobre la cabeza de cada conjunto. Pero
en relidad basta con que mantengamos la información asociada con el rótulo de un conjunto.
Es decir, que sólo hay que saber responder a la pregunta ”¿cuál es la cabeza del conjunto cuyo
rótulo es k?”. Y por tanto sólo hay que cambiar la información de cuál es la cabeza cuando
unamos dos conjuntos: la nueva cabeza es siempre la cabeza del conjunto de la izquierda.
T0 , . . . , T k
15
que recubren partes de G. La situación de partida es que cada vértice de G está en un árbol
distinto, ası́ que tenemos tantos árboles como vértices hay en G.
Una vez seleccionada la arista más corta de las restantes, debemos estudiar si es compatible.
Es fácil ver que una arista no es compatible si al añadirla se forma un lazo, porque entonces
al final del proceso no obtendrı́amos un árbol. Y la manera de evitar que se formen lazos es
comprobando si los extremos de esa arista están en árboles parciales distintos. Es decir, que
debemos almacenar la información de cuáles son los vértices que forman parte de cada uno de
los árboles parciales. Si los dos extremos están en el mismo subárbol, la arista se rechaza. Si
están en subárboles distintos, la arista se acepta y los dos árboles se fusionan.
El bucle voraz prosigue añadiendo aristas y fusionando árboles hasta que todos los vértices
de G pertenecen a un mismo árbol. En ese momento se produce el final del bucle, y las aristas
seleccionadas forman el árbol de recubrimiento mı́nimo.
Ejemplo 6.1. Vamos a aplicar el algoritmo de Kruskal para encontrar un árbol recubridor
mı́nimo de este grafo:
Al principio tenemos nueve árboles parciales, uno para cada vértice. La partición inicial del
conjunto de vértices es ésta:
Eso hace que los dos subárboles que forman el vértice e y el h se fusionen. La nueva partición
de los vértices es ésta:
{a} , {b} , {c} , {d} , {e, h} , {f } , {g} , {i}
A continuación tomamos una arista de peso mı́nimo de las restantes, por ejemplo la bc.
16
Eso produce esta nueva partición:
Obsérvese que la arista hf ahora conecta dos vértices del mismo subconjunto de la partición. Eso
significa que esa arista deja de formar parte del conjunto de candidatos. Por eso la hemos pintado
en gris. Lo cual no significa que ahora se elimine esa arista, lo cual serı́a demasiado trabajoso.
No, simplemente, cuando le llegue su turno comprobaremos que la arista no es compatible, la
eliminaremos, y pasaremos a la siguiente.
Acto seguido añadimos la arista ec.
17
obteniendo:
{a, b, c, e, f, h} , {d} , {g} , {i}
Se han acabado las aristas de peso 2 (ahora es cuando, de hecho, el algoritmo elimina la arista
be), ası́ que seleccionamos una arista de peso 3; por ejemplo, la dg
y la partición queda:
{a, b, c, e, f, h, i} , {d, g}
Finalmente añadimos db.
18
Y decimos finalmente, porque al hacerlo la partición se reduce a un conjunto:
{a, b, c, d, e, f, g, h, i}
19
El conjunto de candidatos son las aristas que unen vértices de A con vértices de F . De esa
forma, al añadir una de estas aristas estamos seguros de que no se va a formar un bucle, y por lo
tanto al añadirla seguimos teniendo un árbol. En cada iteración del bucle voraz seleccionamos
a la arista más corta entre los candidatos.
Ejemplo 6.2. Vamos a aplicar el algoritmo de Prim al mismo grafo que usamos como ejemplo
para el algoritmo de Kruskal (ver ejemplo (6.1)). Es decir:
A := {a} , B := {b, c, d, e, f, g, h, i}
A := {a, b} , B := {c, d, e, f, g, h, i}
A continuación se van añadiendo aristas. En cada caso indicamos al lado del grafo la situación
de los conjuntos A y B, y decoramos en gris aquellas aristas que (sin ser del árbol) no salen del
conjunto A.
A = {a, b, e} , B = {c, d, f, g, h, i}
20
A = {a, b, e, h} , B = {c, d, f, g, i}
A = {a, b, c, e, h} , B = {d, f, g, i}
A = {a, b, c, e, f, h} , B = {d, g, i}
A = {a, b, c, d, e, f, h} , B = {g, i}
A = {a, b, c, d, e, f, h, i} , B = {g}
21
A = {a, b, c, d, e, f, g, h, i} , B = ∅
Al cabo de ocho pasos el algoritmo necesariamente se detiene, porque se han añadido ocho aristas
y el resultado debe ser un árbol recubridor. El árbol que se obtiene es éste:
cuyo peso es 18 (que, naturalmente coincide con el obtenido usando el Algoritmo de Kruskal:
¡todos los recubridores mı́nimos pesan lo mismo!)
1. proximo[1..n]
2. distancia[1..n]
Para un nodos i de F , que está fuera del árbol, el valor proximo[i] indica cuál es el nodo de A
más cercano a ese vértice. Inicialmente proximo[i] debe valer 1 para todos los nodos, ya que el
nodo 1 es el único nodo de A. Y para cada nodo i de F , el valor distancia[i] indica cuál es la
distancia de ese nodo al nodo proximo[i], el más cercano de entre los de A. Si el nodo i es un
nodo de A, entonces hacemos distancia[i] = −1, y de esa manera es fácil identificar los nodos
ya incorporados al árbol. Inicialmente, distancia[i] = d[1, i] para todo i = 2, . . . , n, mientras que
distancia[1] := −1 (aunque este último valor no se va a usar).
Con estas estructuras de datos el pseudocódigo es éste:
22
Algoritmo de Prim(d[1..n,1..n]:tGrafo):Conjunto de aristas;
(* Inicialización*)
T es inicialmente un conjunto vacı́o de aristas;
para i desde 2 hasta n hacer
proximo[i] := 1
distancia[i] := d[1, i]
fin para
(* bucle voraz *)
repetir
min := −∞
para j desde 2 hasta n hacer
si 0 ≤ distancia[j] < min entonces
min := distancia[j]
k := j
fin si
fin para
Añadir a T la arista del vértice k al proximo[k]
distancia[k] := −1
para j desde 2 hasta n hacer
si d[j, k] < distancia[j] entonces
distancia[j] := d[j, k]
proximo[j] := k
fin si
fin para
hasta realizar n − 1 iteraciones
Devolver T como solución
23