Algoritmos para Grafos | Índice
Este capítulo trata do célebre algoritmo de Prim para o problema da MST. (Veja os conceitos básicos sobre esse problema no capítulo Árvores geradoras de custo mínimo.) O algoritmo foi publicado por Robert C. Prim em 1957 e por E. W. Dijkstra pouco depois.
Problema: Encontrar uma MST (árvore geradora de custo mínimo) de um grafo não-dirigido com custos nas arestas.
Os custos das arestas são números inteiros arbitrários (positivos e negativos). O problema tem solução se e somente se o grafo é conexo. Assim, trataremos apenas de grafos conexos. (Mas veja exercício abaixo.)
O algoritmo de Prim é simples, mas sua implementação eficiente apresenta dificuldades inesperadas. A solução dessas dificuldade ensina interessantes lições de programação.
Sumário:
Dado um grafo não-dirigido conexo G com custos nas arestas, o algoritmo de Prim cultiva uma subárvore de G até que ela se torne geradora. No fim do processo, a árvore é uma MST.
Para discutir os detalhes, precisamos de um pouco de terminologia. Suponha que T é uma subárvore (não necessariamente geradora) de G. A franja (= fringe) de T é que o corte cuja margem é o conjunto de vértices de T. Em outras palavras, a franja de T é o conjunto de todas as arestas de G que têm uma ponta em T e outra fora de T.
Podemos agora descrever o algoritmo de maneira precisa. Cada iteração começa com uma subárvore T. No início da primeira iteração, T consiste em um único vértice. O processo iterativo consiste no seguinte: enquanto a franja de T não estiver vazia,
Como se vê, o algoritmo tem caráter guloso: em cada iteração, abocanha a aresta mais barata da franja sem se preocupar com o efeito global, a longo prazo, dessa escolha. A prova de que essa estratégia está correta decorre do critério de minimalidade baseado em cortes.
Exemplo A. Considere o grafo não-dirigido conexo com custos nas arestas definido a seguir. (O custo de cada aresta é proporcional ao comprimento geométrico do segmento de reta que representa a aresta na figura.)
5-4 7-4 7-5 0-7 1-5 0-4 2-3 7-1 0-2 1-2 1-3 7-2 2-6 3-6 0-6 6-4 35 37 28 16 32 38 17 19 26 36 29 34 40 52 58 93
A primeira iteração do algoritmo de Prim pode começar com qualquer vértice. Neste exemplo, escolhemos o vértice 0. A tabela abaixo registra, no início de cada iteração, o conjunto de vértices da subárvore T, o custo de T, e as arestas da franja de T.
T custo franja 0 0 0-2 0-4 0-6 0-7 0 7 0+16 0-2 0-4 0-6 7-1 7-2 7-4 7-5 0 7 1 16+19 0-2 0-4 0-6 7-2 7-4 7-5 1-2 1-3 1-5 0 7 1 2 35+26 0-4 0-6 7-4 7-5 1-3 1-5 2-3 2-6 0 7 1 2 3 61+17 0-4 0-6 7-4 7-5 1-5 2-6 3-6 0 7 1 2 3 5 78+28 0-4 0-6 7-4 2-6 3-6 5-4 0 7 1 2 3 5 4 106+35 0-6 2-6 3-6 4-6 0 7 1 2 3 5 4 6 141+40
A aresta da franja que será acrescentada a T na próxima iteração está pintada de vermelho. A MST resultante tem custo 181. Ela pode ser exibida apagando as colunas apropriadas da descrição do grafo:
5-4 7-5 0-7 2-3 7-1 0-2 2-6 35 28 16 17 19 26 40
0-6 0-1 0-2 4-3 5-3 7-4 5-4 0-5 6-4 7-0 7-6 7-1 51 32 29 34 18 46 40 60 51 31 25 21
0 1 2 3 4 5 (1,3) (2,1) (6,5) (3,4) (3,7) (5,3)
Para transformar o algoritmo de Prim num programa, precisamos tomar algumas decisões de projeto. Suporemos que nosso grafo é conexo. A árvore geradora T do grafo será representada por uma árvore radicada. Para isso, basta escolher um vértice de T para fazer o papel de raiz e eliminar um dos dois arcos de cada aresta de T. A árvore radicada será representada por um vetor de pais pa[] alocado pelo usuário.
Suporemos que nosso grafo é representado por listas de adjacência com custos. Para cada vértice v e cada a em G->adj[v], o custo do arco que liga v a a->w será a->c e esse número poderá ser positivo ou negativo. Suporemos também que temos uma constante
INFINITY
de valor maior que o custo de qualquer aresta. Finalmente, suporemos que os dois arcos que compõem cada aresta têm o mesmo custo.
Discutiremos a seguir uma implementação ingênua do algoritmo de Prim e duas implementações mais sofisticadas e eficientes: uma para grafos densos e uma para grafos esparsos.
Nossa primeira implementação transforma o algoritmo de Prim em código de maneira direta e literal. O resultado é simples mas ineficiente:
#define UGraph Graph
void UGRAPHmstP0( UGraph G, vertex *pa)
{
for (vertex w = 0; w < G->V; ++w) pa[w] = -1;
pa[0] = 0;
while (true) {
int min = INFINITY;
vertex x, y;
for (vertex v = 0; v < G->V; ++v) {
if (pa[v] == -1) continue;
for (link a = G->adj[v]; a != NULL; a = a->next)
if (pa[a->w] == -1 && a->c < min) {
min = a->c;
x = v, y = a->w;
}
}
if (min == INFINITY) break;
// A
pa[y] = x;
}
}
No ponto A , x-y é uma aresta de custo mínimo dentre as que estão na franja da árvore. O custo dessa aresta é min.
Desempenho. A função UGRAPHmstP0() é quadrática: quando aplicada a um grafo com V vértices e E arestas, a função consome tempo proporcional a VE no pior caso. Pode-se dizer que o consumo de tempo é proporcional a V vezes o tamanho do grafo.
0-1 1-6 6-5 5-3 3-2 2-4 99 99 99 99 99 99
enxuto, sem variáveis supérfluas.
A implementação ingênua do algoritmo de Prim é lenta e ineficiente porque cada iteração recalcula toda a franja da árvore, mesmo sabendo que a franja mudou pouco desde a iteração anterior. Para obter uma implementação mais eficiente, é preciso começar cada iteração com a franja pronta e atualizá-la no fim da iteração. Mas é difícil fazer isso se a franja for tratada como uma simples lista de arestas; é preciso inventar uma representação mais eficiente e tomar algumas decisões de projeto adicionais.
A fronteira de uma árvore T é o conjunto de todos os vértices do grafo que não pertencem a T mas são vizinhos de vértices de T. O preço de um vértice w da fronteira de T é o custo de uma aresta de custo mínimo dentre as que estão na franja de T e incidem em w. Se a aresta da franja que determina o preço de w é v-w, diremos que v é o gancho de w.
Podemos agora reescrever o algoritmo de Prim em termos de preços e ganchos. Cada iteração começa com uma árvore T e com os preços e ganchos dos vértices que estão na fronteira de T. O processo iterativo consiste no seguinte: enquanto a franja de T não estiver vazia,
Para armazenar os preços dos vértices
usaremos um vetor preco[] indexado pelos vértices.
Os ganchos poderiam ser armazenados num vetor alocado especialmente
para esse fim,
mas é melhor armazená-los na parte ociosa
do vetor de pais de T,
ou seja,
nas posições do vetor pa[]
indexadas pelos vértices da fronteira de T.
Com isso, os elementos de pa[]
terão a seguinte interpretação:
se v está em T
então pa[v] é o pai de v,
se v está na fronteira de T
então pa[v] é o gancho de v,
e nos demais casos
pa[v] está indefinido.
Poderíamos dizer que os elementos de pa[]
indexados pelos vértices da fronteira são pais provisórios
,
estando sujeitos a alterações nas próximas iterações.
As próximas seções usarão essas ideias para produzir duas implementações rápidas do algoritmo de Prim, uma para grafos densos e uma para grafos esparsos.
Exemplo B. Voltamos a examinar o grafo não-dirigido do exemplo A. Veja abaixo, mais uma vez, a lista de arestas e seus custos.
5-4 7-4 7-5 0-7 1-5 0-4 2-3 7-1 0-2 1-2 1-3 7-2 2-6 3-6 0-6 6-4 35 37 28 16 32 38 17 19 26 36 29 34 40 52 58 93
Aplique o algoritmo de Prim começando com o vértice 0.
Veja o conjunto de vértices de T e
o estado dos vetores pa[] e preco[]
no início de sucessivas iterações,
com .
indicando valores indefinidos
e *
indicando infinito.
pa[] preco[] T 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 0 . 0 . 0 . 0 0 0 * 26 * 38 * 58 16 0 7 0 7 0 . 7 7 0 0 0 19 26 * 37 28 58 16 0 7 1 0 7 0 1 7 7 0 0 0 19 26 29 37 28 58 16 0 7 1 2 0 7 0 2 7 7 2 0 0 19 26 17 37 28 40 16 0 7 1 2 3 0 7 0 2 7 7 2 0 0 19 26 17 37 28 40 16 0 7 1 2 3 5 0 7 0 2 5 7 2 0 0 19 26 17 35 28 40 16 0 7 1 2 3 5 4 0 7 0 2 5 7 2 0 0 19 26 17 35 28 40 16 0 7 1 2 3 5 4 6 0 7 0 2 5 7 2 0 0 19 26 17 35 28 40 16
Os ganchos e os preços dos vértices da fronteira estão pintados de cinza. Observe como os valores em cada coluna de pa[] mudam de uma iteração para a seguinte. A MST calculada pelo algoritmo tem arestas 0-7 7-1 0-2 2-3 7-5 5-4 2-6. O custo dessa MST é 181.
Exemplo C. O vídeo Prim's Algorithm Animation no YouTube aplica o algoritmo de Prim a um grafo cujos vértices são pontos aleatórios no plano. O grafo é completo e o custo de cada aresta é a distância geométrica entre suas pontas. O vídeo mostra muito bem os preços e os ganchos dos vértices que estão fora da árvore T. (Infelizmente, o vídeo usa a mesma cor para pintar as arestas da árvore e as arestas da franja.)
0-1 1-6 6-5 5-3 3-2 2-4 99 99 99 99 99 99
0-1 0-5 1-2 1-3 2-3 2-5 2-6 3-6 5-1 5-4 6-4 10 60 10 15 20 50 40 50 15 0 20
0-1 0-4 1-5 2-0 2-3 2-4 4-3 5-0 5-2 6-4 10 30 10 10 60 50 10 40 20 0
No início de cada iteração de nossa primeira implementação eficiente do algoritmo de Prim temos
#define UGraph Graph
/* Recebe um grafo não-dirigido conexo G com custos arbitrários nas arestas e calcula uma MST de G. A função trata a MST como uma árvore radicada com raiz 0 e armazena a árvore no vetor de pais pa[0..V-1] alocado pelo usuário. (A função é uma implementação do algoritmo de Prim. O código é uma versão melhorada do Programa 20.3 de Sedgewick.) */
void UGRAPHmstP1( UGraph G, vertex *pa) { bool tree[1000]; int preco[1000]; // inicialização: for (vertex v = 0; v < G->V; ++v) pa[v] = -1, tree[v] = false, preco[v] = INFINITY; pa[0] = 0, tree[0] = true; for (link a = G->adj[0]; a != NULL; a = a->next) pa[a->w] = 0, preco[a->w] = a->c; while (true) { int min = INFINITY; vertex y; for (vertex w = 0; w < G->V; ++w) { if (!tree[w] && preco[w] < min) min = preco[w], y = w; } if (min == INFINITY) break; // a aresta pa[y]-y é a mais barata da franja tree[y] = true; // atualização dos preços e ganchos: for (link a = G->adj[y]; a != NULL; a = a->next) { if (!tree[a->w] && a->c < preco[a->w]) { preco[a->w] = a->c; pa[a->w] = y; } } } }
Podemos usar uma versão simplificada da inicialização se estivermos dispostos a aceitar que a primeira iteração do while não comece com os preços e ganchos definidos na fronteira da árvore, como isso acontece nas demais iterações:
// inicialização simplificada:
for (vertex v = 0; v < G->V; ++v)
pa[v] = -1, tree[v] = false, preco[v] = INFINITY;
pa[0] = 0, preco[0] = INFINITY - 1;
Desempenho. Quando aplicada a um grafo não-dirigido com V vértices e E arestas, a função UGRAPHmstP1() consome tempo proporcional a V2 + E. Como E < V2, o consumo de tempo da função é proporcional a
V2.
Como o tamanho de grafos densos é proporcional a V2, podemos dizer que a função UGRAPHmstP1() é linear para grafos densos.
0-1 0-2 1-2 3-4 3-5 3-6 4-1 4-2 4-6 5-1 6-0 6-1 6-2 15 15 25 25 15 15 35 25 15 45 25 45 65
0-2 2-4 4-1 1-3 3-0 2-3 0-4 8 2 3 0 8 0 4
enxuto, sem variáveis supérfluas.
A primeira implementação do algoritmo de Prim começa cada iteração examinando os vértices da fronteira da árvore, um por um, à procura do mais barato. Para acelerar esse processo, a implementação seguinte mantém os vértices da fronteira em ordem crescente de preços (ou quase isso), para que não seja preciso procurar o vértice mais barato.
Tal como na primeira implementação do algoritmo de Prim, cada iteração da segunda implementação começa como
Mas, diferentemente da primeira implementação,
os vértices que não pertencem a T
são mantidos numa
fila priorizada de mínimo
(= min priority queue).
(Veja
capítulo 9 (Priority Queues and Heapsort)
do volume 1 do livro de Sedgewick.)
(Seria suficiente manter na fila priorizada
os vértices da fronteira,
mas é mais simples colocar na fila
todos os vértices que estão fora de T.)
#define UGraph Graph
/* Recebe um grafo não-dirigido conexo G com custos arbitrários nas arestas e calcula uma MST de G. A função trata a MST como uma árvore radicada com raiz 0 e armazena a árvore no vetor de pais pa[]. (A função implementa o algoritmo de Prim. O grafo G e os custos são representados por listas de adjacência. O código abaixo foi inspirado no Programa 21.1 de Sedgewick.) */
void UGRAPHmstP2( UGraph G, vertex *pa) { bool tree[1000]; int preco[1000]; // inicialização: for (vertex v = 1; v < G->V; ++v) pa[v] = -1, tree[v] = false, preco[v] = INFINITY; pa[0] = 0, tree[0] = true; for (link a = G->adj[0]; a != NULL; a = a->next) pa[a->w] = 0, preco[a->w] = a->c; PQinit( G->V); for (vertex v = 1; v < G->V; ++v) PQinsert( v, preco); while (!PQempty( )) { vertex y = PQdelmin( preco); if (preco[y] == INFINITY) break; tree[y] = true; // atualização dos preços e ganchos: for (link a = G->adj[y]; a != NULL; a = a->next) if (!tree[a->w] && a->c < preco[a->w]) { preco[a->w] = a->c; PQdec( a->w, preco); pa[a->w] = y; } } PQfree( ); }
Os vértices que não pertencem à árvore T
ficam armazenados numa
fila priorizada de mínimo
com prioridade ditada pelo preço de cada vértice.
A fila
é manipulada pelas seguintes funções:
Desempenho. A implementação clássica da fila priorizada usa uma estrutura de heap. Com essa implementação da fila, a função UGRAPHmstP2() consome tempo proporcional a (V+E) log V no pior caso, sendo V o número de vértices e E o número de arestas de G. Como G é conexo, temos E ≥ V−1 e portanto o consumo de tempo é proporcional a
E log V
no pior caso. Portanto, UGRAPHmstP2() é apenas um pouco pior que linear. Podemos dizer que UGRAPHmstP2() é linearítmica.
Como se compara o consumo de tempo de UGRAPHmstP2() com o de UGRAPHmstP1()? Se nos restringirmos a grafos esparsos, UGRAPHmstP2() é assintoticamente mais rápida que UGRAPHmstP1(). Se nos restringirmos a grafos densos, a relação se inverte: UGRAPHmstP1() é assintoticamente mais rápida que UGRAPHmstP2().
if (preco[y] == INFINITY) breakno código de UGRAPHmstP2()?
0-1 0-2 1-2 3-4 3-5 3-6 4-1 4-2 4-6 5-1 6-0 6-1 6-2 15 15 25 25 15 15 35 25 15 45 25 45 65
enxuto, sem variáveis supérfluas.