Вы находитесь на странице: 1из 23

Algoritmos voraces

Índice
1. Introducción 1

2. El algoritmo voraz de coloreado de grafos 1

3. Algoritmo de Dijkstra 4
3.1. Estructuras de datos y pseudocódigo del algoritmo de Dijkstra . . . . . . . . . . 5

4. Esquema básico de un algoritmo voraz 6


4.1. Estructuras de datos para algoritmos voraces . . . . . . . . . . . . . . . . . . . . 7
4.1.1. Colas de prioridad . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7

5. Ejemplo: Planificación con plazo fijo 8


5.1. Mejorando el método . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 11

6. Ejemplo: Algoritmos de Kruskal y Prim 15


6.1. Árboles recubridores mı́nimos . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 15
6.2. Algoritmo de Kruskal . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 15
6.2.1. Estructuras de datos y pseudocódigo . . . . . . . . . . . . . . . . . . . . . 19
6.3. Algoritmo de Prim . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 19
6.3.1. Estructuras de datos y pseudocódigo . . . . . . . . . . . . . . . . . . . . . 22

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.

2. El algoritmo voraz de coloreado de grafos


El problema del coloreado de los vértices de un grafo, que seguramente conoces de la ma-
temática discreta, es éste: dado un grafo conexo G, tenemos que colorear sus vértices de forma

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;

La solución la vamos a construir en forma de un vector de colores, que tiene en posicion i el


número de color asignado al vértice número i. Ası́ que definimos un tipo tColoración ası́:

tipo tColoracion=ARRAY[1..n] of Enteros;

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

Ejemplo 2.1. En la siguiente figura se muestra el resultado de aplicar el algoritmo voraz de


coloreado a un grafo, siguiendo una cierta numeración de los vértices:

(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.

3.1. Estructuras de datos y pseudocódigo del algoritmo de Dijkstra


En el algoritmo de Dijkstra, el número n de vértices del grafo es un dato fijo del problema.
Para representar el grafo ponderado podemos, por tanto, emplear una matriz a[1..n, 1..n] en la
que a[i, j] es la longitud de la arista que une vi con vj . Si no existe esa arista, podemos suponer
que a[i, j] = ∞. También tenemos que representar las etiquetas de los nodos, lo cual se puede
hacer muy fácilmente con dos vectores L[1..n] y P [1..n].
Con estas estructuras es suficiente en principio para escribir un primer pseudocódigo del
algoritmo de Dijkstra. Suponemos que v es el vértice 1 del grafo.

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;

4. Esquema básico de un algoritmo voraz


¿Qué tienen en común los dos ejemplos previos de algoritmos voraces? Como hemos dicho en
la introducción, en ambos casos se trata de problemas de optimización, de máximos y mı́nimos:
en el algoritmo de coloreado se busca el número mı́nimo de colores, y en el de Dijkstra la distancia
mı́nima desde v al resto de los vértices.
Además, los dos algoritmos progresan iterativamente, y trabajan a partir de soluciones par-
ciales hacia la solución final, sin retroceder. En cada momento del proceso se dispone de una
solución parcial, que se hace crecer. Por ejemplo, en el algoritmo de coloreado, la solución parcial
es la lista de los vértices ya coloreados, cada uno con su color. Y en el algoritmo de Dijkstra la
solución parcial es el conjunto de nodos ya tratados. Llamaremos S a esa solución parcial. El
primer trabajo de un algoritmo voraz consiste, por tanto, en inicializar la solución parcial.
Para hacer crecer esa solución parcial disponemos de varios candidatos, de entre los que
escogemos el más adecuado. En el algoritmo de coloreado, el conjunto de candidatos está formado
por todas las parejas de la forma (v[i], c[j]) donde v[i] es un vértice aún sin colorear, y c[j] es un
color cualquiera, el número j de la lista de colores. En el de Dijkstra el conjunto de candidatos
lo forman los vértices aún no tratados. El conjunto de candidatos se llama C.
Un algoritmo voraz por tanto progresa iterativamente, mediante un bucle llamado bucle
voraz, de manera que en cada iteración del bucle se considera a un nuevo candidato, que o bien
se añade a la solución o bien se rechaza definitivamente. Las iteraciones se mantienen mientras
quedan candidatos y no se haya conseguido una solución al problema.
La selección del candidato más adecuado debe tener en cuenta el objetivo global que nos
hemos marcado. Este suele ser el punto clave en el diseño de un algoritmo voraz. El objetivo
es global, pero la selección de los candidatos se basa en criterios locales. El éxito del algoritmo
depende de la relación entre esos criterios locales y el objetivo global. En el algoritmo de coloreado
de grafos el criterio es “seleccionar el siguiente vértice y el color de orden más bajo dentro de la
lista”. En el algoritmo de Dijkstra el criterio de selección es “seleccionar el candidato z con un
valor mı́nimo de la etiqueta L(z) ”.
Una vez seleccionado el candidato, es necesario comprobar que al añadir ese candidato a la
solución parcial, se obtiene una nueva solución parcial. Es decir, que el candidato es compatible

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.

4.1. Estructuras de datos para algoritmos voraces


4.1.1. Colas de prioridad
Una de nuestras primeras tareas al diseñar un algoritmo voraz de acuerdo con este esquema
es la de pensar en qué es y cómo se representa la solución parcial y el espacio de candidatos. En
cada iteración del bucle voraz se debe seleccionar al mejor candidato de entre los miembros de
C. La estructura que empleamos para representar a los candidatos debe facilitar las siguientes
operaciones: hallar el mejor de entre ellos, eliminar a un candidato, añadir un nuevo candidato,
o modificar la información de un candidato. Estas operaciones configuran la estructura que se
conoce como cola de prioridad.

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.

5. Ejemplo: Planificación con plazo fijo


Tenemos que ejecutar un conjunto de n tareas T1 , T2 , . . . , Tn , cada una de las cuales consume
un tiempo unitario. Y no podemos realizar dos tareas a la vez, ası́ que en cada instante de tiempo
se realiza una y sólo una tarea Ti . La tarea Ti produce unos beneficios gi , pero sólo si se ejecuta
antes de di unidades de tiempo.
Un conjunto de tareas es factible si existe al menos una forma de ordenarlas que permite que
todas las tareas se ejecuten antes de sus respectivo plazos. La idea para el algoritmo voraz es
ésta: en cada paso añadimos la tarea con mayor valor gi entre las que aún no se han considerado,
siempre y cuando con ello se obtenga un conjunto factible de tareas.
El problema de este enfoque es que hay que saber cuándo se obtiene un conjunto factible de
tareas al añadir una nueva, porque el hecho de que un conjunto de tareas sea factible depende
del orden en que se ejecuten. Afortunadamente hay una manera muy sencilla de averiguar si son
factibles:
Lema 5.1. Sea (Tk1 , Tk2 , . . . , Tkm ) un conjunto de tareas ordenadas de forma que:
d k1 ≤ d k2 ≤ · · · ≤ d km
Es decir que las tareas se han ordenado según su fecha de caducidad. Entonces el conjunto de
tareas es factible si y sólo si las tareas son factibles precisamente en ese orden.
Sin ser una demostración, el siguiente argumento da fuerza a este lema: lo más sensato parece
ser sin duda atender en primer lugar a las tareas con menor fecha de caducidad (¿no es lo que
hacemos todos?).
Ejemplo 5.2. Supongamos que tenemos que atender a cuatro tareas con esta tabla de fechas de
caducidad di y beneficios gi :
tarea 1 2 3 4
caducidad 1 2 1 2
benef icio 7 6 6 10
Empezamos por ordenar las tareas por beneficio, de mayor a menor. Se obtiene este tabla:
tarea 4 1 2 3
caducidad 2 1 2 1
benef icio 10 7 6 6

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.

2. No se puede insertar el candidato. Entonces se cumple d[j[r − 1]] = r − 1. También en ese


caso la tarea debe ser rechazada.

Con estos refinamientos el algoritmo queda ası́:


Función planificación(VAR d,TB:vector):vector;
VAR j : vector
(*Este vector almacena las tareas ya seleccionadas,
aunque hasta el final no se obtiene el orden correcto*)
k:Integer;

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

5.1. Mejorando el método


Se puede obtener un método de planificación más rápido si se diseña una forma distinta de
averiguar que conjuntos de tareas son factibles. La primera observación es trivial: si tenemos
una serie de tareas Ti1 , . . . , Tik , y la caducidad máxima entre todas estas tareas es

Cmax = máx dij

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:

T51 T42 T13 T35 T211

A continuación es el turno de T6 , que a pesar de tener d6 = 5 no puede colocarse en la posición


5, porque ya está ocupada. Por tanto se coloca en la primera posición menor que 5 que quede
libre, la posición 4.
T51 T42 T13 T65 T35 T211
Para acabar deberı́amos colocar la tarea T7 , que tiene d7 = 2 en el único hueco disponible, que
es el sexto dı́a. Como puede verse, no es posible hacer ésto. Y de hecho la lista resultante nos
garantiza que no es posible: porque las tareas T5 , T4 y T7 tienen caducidad máxima 2 y, siendo
3, no se pueden hacer en 2 dı́as. Ası́ que la observación que precede a este ejemplo, combinada
con este método, nos lleva a rechazar la tarea T7 .
El criterio que vamos a emplear para planificar un conjunto de n tareas se basa en el esquema
de este ejemplo:
1. Se empieza con una planificación vacı́a.

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:

T51 T42 T13 T65 T35 T211


las celdas sombreadas están ocupadas, y las celdas en blanco están libres. Cuando tratas de
insertar una nueva tarea, si su fecha de caducidad coincide con una celda libre, en blanco, no
habrá ningún problema. Pero si esa celda ya está ocupada, si es una de las sombreadas, entonces
tendremos que empezar a desplazarnos hacia la izquierda hasta encontrar una celda libre. La
celda libre que nos interesa es la primera situada a la izquierda de un grupo de celdas sombreadas.
Es importante darse cuenta de que sea cual sea la celda sombreada de ese grupo que nos interese,
la posición en la que debemos colocar la tarea es la misma. Eso sugiere que agrupemos todas
las celdas de ese grupo en un conjunto, ya que la información relevante (la posición de la celda
libre a la izquierda) es la misma para todas ellas. La idea entonces es emplear la estructura de
partición que vimos en el capı́tulo sobre árboles para representar las etapas por las que pasa la
lista de tareas al completarla de esta forma.

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:

Ti1 Ti2 ··· Tik o también celdas libres sueltas

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:

En la representación como partición en conjuntos inicialmente tenemos nueve conjuntos c0 , c1 , . . . , c8


correspondientes a esas nueve celdas:
()*+
/.-,
c0 , ()*+
/.-,
c1 , /.-,
()*+
c2 , ()*+
/.-,
c3 , ()*+
/.-,
c4 , ()*+
/.-,
c5 , ()*+
/.-,
c6 , ()*+
/.-,
c7 , /.-,
()*+
c8

Introducimos la fecha de caducidad 2 de la primera tarea (por orden de beneficio), que es T7 ,


colocándola tan atrás como sea posible. En este caso, en la posición 2. Tenemos entonces:

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

Las siguientes etapas son éstas:


insertamos T3 con fecha de caducidad 4. La posición 4 está libre ası́ que ese es su lugar:

T72 T34

Los conjuntos de las celdas 3 y 4 se unen.


()*+
/.-, c1 , ()*+
c0 , ()*+
/.-, /.-,
c3 , ()*+
/.-,
c5 , ()*+
/.-,
c6 , ()*+
/.-,
c7 , /.-,
()*+
c8

()*+
/.-,
c2 /.-,
()*+
c4

En el siguiente paso insertamos T4 , que tiene caducidad 3. La celda tres está libre, y la ocupamos:

T72 T43 T34

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

La cabeza de ese conjunto es la celda 1, ası́ que ocupamos esa celda.

T64 T72 T43 T34

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

Intentamos a continuación insertar T2 , con fecha de caducidad 1. La posición en la que inser-


temos no deberı́a estar por detrás de la 1. Ası́ que miramos cuál es la cabeza del conjunto que
contiene a la celda 1, que es 0. Y eso indica que la tarea T2 debe ser rechazada. Lo mismo ocurre
al tratar de insertar T8 con fecha de caducidad 3, T5 con fecha de caducidad 3, y T1 con fecha
de caducidad 2. Por tanto la lista definitiva de tareas es:

(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.

6. Ejemplo: Algoritmos de Kruskal y Prim


6.1. Árboles recubridores mı́nimos
Sea G un grafo ponderado, como los que hemos visto al tratar del algoritmo de Dijkstra.
Entonces un subgrafo recubridor es un subconjunto T de aristas del grafo tal que, usando sólo
esas aristas se obtiene un subgrafo conexo; es decir, se puede ir de un vértice a cualquier otro
vértice usando sólo aristas de T .
Un mismo grafo, puede tener muchos recubridores distintos. Para cada uno de ellos podemos
sumar los pesos de las aristas que lo forman, y llamar a eso el peso del recubridor. Pues bien,
un árbol recubridor mı́nimo es un recubridor T que tiene un peso menor o igual que el de los
restantes recubridores. No es difı́cil ver que entonces T tiene que ser un árbol, y por esa razón
hablaremos de ahora en adelante de árboles recubridores.
Existen dos algoritmos voraces que obtienen un árbol recubridor mı́nimo de cualquier grafo
G dado. Ambos algoritmos utilizan como candidatos a las aristas de G. Pero el criterio para
seleccionar el siguiente candidato es distinto en cada uno de ellos.

6.2. Algoritmo de Kruskal


En cada iteración del bucle voraz, el algoritmo de Kruskal selecciona como mejor candidato
a la arista más corta del grafo que aún no se haya seleccionado. Eso significa que las aristas que
se van seleccionando pueden estar inicialmente desconectadas unas de otras, y que se van a ir
conectando para formar el árbol a lo largo del proceso. Por lo tanto, la situación al comienzo de
cada iteración es que tenemos una serie árboles parciales:

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:

{a} , {b} , {c} , {d} , {e} , {f } , {g} , {h} , {i}

Empezamos tomando la arista eh porque es la de menor peso.

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:

{a} , {b, c} , {d} , {e, h} , {f } , {g} , {i}

En el siguiente paso tomamos otra arista de peso 2, la ef .

que conduce a la partición:

{a} , {b, c} , {d} , {e, f, h} , {g} , {i}

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.

que produce la partición:


{a} , {b, c, e, f, h} , {d} , {g} , {i}
y convierte en incompatibles dos aristas más (en gris). Añadimos la arista ab

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

La nueva partición es:


{a, b, c, e, f, h} , {d, g} , {i}
Añadimos hi

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}

El árbol recubridor de peso mı́nimo (18) que se ha obtenido es éste:

6.2.1. Estructuras de datos y pseudocódigo


La implementación del algoritmo supone que somos capaces de conservar la información
sobre los nodos de cada uno de los subárboles, de manera que dados dos extremos i y j de
una arista podamos encontrar los subárboles a los que pertenecen, ver si coinciden y si no es
ası́, fusionarlos. Estos subárboles forman una partición del conjunto de nodos. Por el tipo de
operaciones que queremos realizar, lo más adecuado es utilizar una representación mediante
árboles de la partición, como la que hemos usado en el caso de la planificación con plazo fijo.
Vamos a representar el grafo G como un vector de aristas, con sus longitudes asociadas. El
pseudocódigo es éste:

Algoritmo de Kruskal(d[1..n,1..n]:tGrafo):Conjunto de aristas;


(* Inicialización*)
Ordenar el conjunto de aristas por longitudes crecientes.
n := número de vértices del grafo.
Crear una lista vacı́a T de aristas.
Crear una partición de n conjuntos, y poner cada vértice de G en un conjunto.
(* bucle voraz *)
repetir
e := la arista más corta aún no usada.
Sean u, v los vértices que une e.
Sean U y V los conjuntos de la partición que contienen a u y v.
si U 6= V entonces
Fusionar U y V
Añadir e a la lista T .
fin si
hasta que la partición tenga sólo un conjunto
Devolver T como solución

6.3. Algoritmo de Prim


El algoritmo de Prim hace crecer al árbol recubridor a partir de un vértice raı́z, manteniéndolo
siempre conexo. Ası́ que los vértices de G se dividen en dos clases: los que ya se han incorporado
al árbol, llamemos A a esta clase de vértices, y los que aún no lo han hecho, que forman la
clase F (los que están fuera). Inicialmente seleccionamos un vértice cualquiera del grafo, y A
está formado sólo por ese vértice, mientras que B contiene a todos los demás vertices.

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:

Empezamos desde el vértice a, es decir, hacemos

A := {a} , B := {b, c, d, e, f, g, h, i}

Y añadimos la arista de menos peso que sale de A, la ab.

Los nuevos conjuntos son:

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!)

6.3.1. Estructuras de datos y pseudocódigo


Vamos a numerar los nodos de G del 1 al n. El algoritmo empieza con un único nodo en el
conjunto A, ası́ que podemos suponer que ese nodo es el 1.
Además usamos una matriz cuadrada simétrica d[i, j] de orden n para representar las longitu-
des de las aristas del vértice i al j, al igual que en el algoritmo de Dijkstra (de nuevo, d[i, j] = ∞)
significa que no hay arista entre el vértice i y el j.) Además vamos a usar dos vectores:

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

Вам также может понравиться