Você está na página 1de 16

Grafos - Introdução e algoritmos iniciais https://ead5.ifmg.edu.br/bambui/mod/book/tool/print/index.php?

id=139311

Grafos - Introdução e algoritmos iniciais

Site: AVA - IFMG Campus Bambuí Impresso por: Letícia Moreira Leonel
Curso: Técnicas de Programação - BIBENGC.2021.1-A Data: quinta, 1 dez 2022, 09:56
Livro: Grafos - Introdução e algoritmos iniciais

1 of 16 01/12/2022 09:57
Grafos - Introdução e algoritmos iniciais https://ead5.ifmg.edu.br/bambui/mod/book/tool/print/index.php?id=139311

Índice

1. Introdução

2. Algoritmos Básicos de Busca


2.1. Busca em Largura
2.2. Busca em Profundidade
2.3. Ordenação Topológica

3. Árvore Geradora Mínima


3.1. Algoritmo de Kruskal
3.2. Algoritmo de Prim

4. Referências

2 of 16 01/12/2022 09:57
Grafos - Introdução e algoritmos iniciais https://ead5.ifmg.edu.br/bambui/mod/book/tool/print/index.php?id=139311

1. Introdução

Grafos são estruturas de dados muito utilizadas em algoritmos para solução de uma ampla gama de problemas. Vamos encontrá-los em um
GPS, para calcular a melhor rota entre dois pontos, em problemas de otimização e logística, etc.

Basicamente, um grafo é um conjunto de nós (vértices) conectados por um conjunto de arestas (também chamadas por alguns autores de
arcos). Por definição, um grafo G é um conjunto que contém esses dois subconjuntos: G = {V, E} (sendo V o conjunto de vértices e E, o de
arestas (edges)).

Em Matemática Discreta, na chamada Teoria dos Grafos, vemos muitas classificações de grafos (bipartidos, completos, conectados, árvore,
florestas, cíclicos e acíclicos, além de problemas básicos, como a coloração dos grafos, os ciclos hamiltoniano e euleriano, etc.). Vamos nos ater
aqui às definições de grafos enquanto estrutura de dados, sendo uma das mais versáteis e completas.

Um grafo pode ter valores associados aos vértices e/ou às arestas, de acordo com a natureza do problema a ser resolvido. Antes de
analisarmos a implementação desses algoritmos, vamos aproveitar alguns conceitos de revisão, apresentados pelo professor Rosinei Soares de
Figueiredo, do IFMG - Campus São João Evangelista.

Talvez a primeira pergunta que nos passe pela cabeça seja "como implementar uma estrutura que pode ter qualquer número arbitrário de
ponteiros (arestas) para os outros nós?". Para grafos, usamos duas representações: a lista de adjacência e a matriz de adjacência (também
conhecidas por lista/matriz de vizinhança). Essas representações facilitam a implementação e execução dos algoritmos e resolvem todos os
problemas sobre como representar as ligações. Vamos verificar mais um vídeo do professor Rosinei sobre essas representações:

Como podemos ver, vamos usar matrizes ou listas para armazenar as arestas que ligam os vértices, podendo ou não associar um peso a cada
uma delas. Esse peso pode ter um significado para cada algoritmo que for utilizado. Por exemplo, em um dispositivo de locomoção por GPS, em
um algoritmo de caminho mínimo (que veremos mais à frente), o peso pode ser a distância ou até mesmo o congestionamento ou grau de
conservação de uma via (considerando-as como arestas). Os vértices podem ser apenas os índices inteiros que indicam a posição do
vetor/matriz, ou podem ser ponteiros para objetos que guardam informações nos próprios vértices (veremos algoritmos que precisam de
informações em cada vértice para funcionarem corretamente). Um cuidado especial, ao usarmos matrizes de adjacências com grafos com pesos
nas arestas é selecionar, com cuidado, um valor que represente a ausência de aresta entre dois vértices, já que o valor 0 (zero) pode ser um
peso válido em alguns problemas. Dependendo do tipo de dados utilizado para representá-lo, podemos escolher valores arbitrários para
representar a ausência de ligação, mas devemos ter cuidado para não escolher um valor viável para a solução do problema.

3 of 16 01/12/2022 09:57
Grafos - Introdução e algoritmos iniciais https://ead5.ifmg.edu.br/bambui/mod/book/tool/print/index.php?id=139311

Neste tópico, vamos discutir os algoritmos presentes nos capítulos 22 e 23 de Cormen et al. (2009), que compreenderá os algoritmos básicos de
busca, ordenação topológica e de árvore geradora mínima. Muitos desses algoritmos são utilizados como base para a construção de algoritmos
mais complexos em outras disciplinas, por isso sua importância.

4 of 16 01/12/2022 09:57
Grafos - Introdução e algoritmos iniciais https://ead5.ifmg.edu.br/bambui/mod/book/tool/print/index.php?id=139311

2. Algoritmos Básicos de Busca

Agora que sabemos como fazer a representação de grafos, podemos introduzir alguns algoritmos básicos nessa estrutura de dados. Vamos
iniciar pelos algoritmos de busca, analisando a busca em largura (ou busca primeiro em largura - breadth-first search) e em profundidade (ou
busca primeiro em profundidade - depth-first search).

Aqui cabe uma ressalva importante: inicialmente, não estamos fazendo nenhuma busca por um valor, como tradicionalmente fizemos em todas
as estruturas de dados anteriores. As buscas em largura e em profundidade são algoritmos básicos, que estabelecem uma forma de percorrer o
grafo e montar uma árvore de busca. Esses algoritmos servem de base para outros, em que realmente fazemos a procura de um valor ou
padrão específico.

Em seguida, veremos o uso dessas buscas para a criação de um algoritmo de ordenação topológica. Ordenação topológica é um processo
muito importante, especialmente no meio industrial. Quando temos uma sequência de produção que pode ser mapeada em um grafo, no qual a
ordem dos processos pode ser representada pelas arestas (para se fazer a etapa C da produção, é necessário que A seja concluída primeiro,
como pré-requsito, por exemplo), usamos a ordenação topológica para produzir uma lista desses processos, numa sequência em que todas as
atividades que são pré-requisitos de outras serão feitas na ordem correta.

5 of 16 01/12/2022 09:57
Grafos - Introdução e algoritmos iniciais https://ead5.ifmg.edu.br/bambui/mod/book/tool/print/index.php?id=139311

2.1. Busca em Largura

A busca em largura, também chamada busca primeiro em largura (do inglês breadth-first search) é um dos algoritmos mais simples para
pesquisa em um grafo e serve como base para muitos algoritmos.

Dado um grafo G = {V, E} e um vértice de origem para a busca s, a busca em largura vai explorar as arestas para descobrir cada vértice que é
alcançável a partir de s. Para cada um desses vértices, ele vai armazenar a distância (número de arestas) necessárias para chegar a cada
vértice. No final, teremos uma árvore (i. e., um grafo sem ciclos), com raiz em s, que corresponde a todos os vértices alcançáveis a partir dessa
raiz e, de forma simplificada, também representa a menor distância (ou seja, o menor número de saltos de arestas) para chegar a esse destino.
Notem que aqui não temos pesos nas arestas e a distância é computada apenas pelo número de arestas.

O algoritmo tem o nome de busca em largura porque a partir da origem s, vamos expandindo a largura da busca, estabelecendo uma fronteira:
primeiro, todos os nós alcançáveis com uma única aresta (vizinhos imediatos de s), depois alargamos a busca para chegar aos nós que podem
ser alcançados com 2 arestas, e assim por diante.

Para o funcionamento do algoritmo, vamos armazenar nos vértices três atributos: a cor (branco - ainda não visitado, cinza - visitado, mas não
finalizado, e preto - finalizado); o predecessor (ou pai) do vértice (π), ou seja, o vértice a partir do qual alcançamos o vértice em análise e a
distância (d), que é o número de arestas necessárias para chegar àquele vértice, a partir da origem. Como podemos perceber, cada origem vai
estabelecer uma árvore de busca distinta.

Vamos ver como é o pseudocódigo do algoritmo, como proposto por Cormen et al. (2009):

BFS(G, s)
1 for each vertex u ϵ G.V - {s}
2 u.color = WHITE
3 u.d = ∞
4 u.π = NIL
5 s.color = GRAY
6 s.d = 0
7 s.π = NIL
8 Q = Ø
9 ENQUEUE(Q, s)
10 while Q ≠ Ø
11 u = DEQUEUE(Q)
12 for each v ϵ G.Adj[u]
13 if v.color == WHITE
14 v.color = GRAY
15 v.d = u.d + 1
16 v.π = u
17 ENQUEUE(Q, v)
18 u.color = BLACK

Nas linhas 1-4, faremos todos os vértices, exceto a origem s, ficarem brancos, dizemos que a distância é infinita e que o predecessor é nulo.
Em seguida, nas linhas 5-7 fazemos a inicialização dos dados da origem, a distância é zero e o predecessor é nulo (não há arestas para chegar
nesse vértice, já que vamos começar por ele), e a cor é cinza (vamos visitá-lo para percorrer seus vizinhos).

Na linha 8, inicializamos uma fila vazia, que é essencial para o algoritmo. Para que possamos percorrer corretamente as adjacências de cada
vértice, vamos colocá-los na fila e removê-los quando formos visitá-los. A fila recebe primeiro a origem (linha 9), e o algoritmo executará
enquanto a fila não estiver vazia (linha 10).

As linhas 11-17 cuidam do processamento de cada vértice do grafo. Primeiro, removemos o vértice que está na frente da fila (linha 11), que
chamamos de u. A linha 12 inicia um laço que vai analisar cada um dos vértices vizinhos de u, ou seja, cada vértice adjacente a ele na
representação do grafo (v). Se v for branco, significa que ainda não visitamos e devemos analisá-lo, mudando a cor para cinza, atualizando a
distância para a distância de u mais 1 unidade (para chegar a v a partir de s, percorremos todas as arestas de s até u, mais a aresta de u para
v). Dizemos que o pai de v na árvore é u e, finalmente, o colocamos na fila para ser processado depois.

Por fim, na linha 18, depois de processarmos todos os vizinhos de u, o marcamos como preto, para indicar que já foi finalizado, antes de
retornarmos ao laço da linha 10, retirando o próximo vértice da lista. Da forma como o algoritmo é elaborado, a cada passagem do laço das
linhas 10-18, vamos aumentando o raio da distância a partir de s em uma unidade, ou seja, primeiro os alcançáveis com 1 aresta, depois 2, 3, 4,
..., n, até que todo o grafo seja processado.

A complexidade do algoritmo depende do tempo de enfileirar e desenfileirar os vértices. Se as operações da fila forem O(1), o tempo total para
colocar e remover os vértices é O(V). Como cada vértice removido da fila terá suas arestas percorridas, percorremos cada adjacência no

6 of 16 01/12/2022 09:57
Grafos - Introdução e algoritmos iniciais https://ead5.ifmg.edu.br/bambui/mod/book/tool/print/index.php?id=139311

máximo 1 vez, nos dando uma complexidade O(E) para essa análise. A inicialização dos vértices tem um custo O(V), o que nos leva, no total, a
uma complexidade final O(V + E), que é bastante eficiente, por tratar-se de uma custo linear que depende do número de vértices e de arestas do
grafo.

Vamos verificar uma execução do algoritmo em um grafo de exemplo, feita pelo professor Rosinei Soares de Figueiredo, do IFMG - Campus São
João Evangelista. Podemos observar que, na simulação, o professor considera o valor -1 como equivalente ao NIL do algoritmo, pois está
armazenando o predecessor como um número inteiro e não como um ponteiro para o vértice. Ambas são implementações válidas e que
dependem da forma como representamos os vértices na implementação.

Uma outra forma de visualizar a simulação, para ver a árvore de busca com maior clareza, é fazer uma marcação do predecessor na forma de
uma linha pontilhada (ou em uma cor diferente) do vértice para o seu pai. Assim, podemos visualizar com mais clareza a árvore que foi gerada.
As cores também podem ser facilmente representadas riscando o vértice (para uma melhor visualização). Um traço (/) indica cinza, dois se
cortando em X é preto, nenhuma marcação é branco.

7 of 16 01/12/2022 09:57
Grafos - Introdução e algoritmos iniciais https://ead5.ifmg.edu.br/bambui/mod/book/tool/print/index.php?id=139311

2.2. Busca em Profundidade

A busca em profundidade, ou busca primeiro em profundidade (do inglês depth-first search) é um algoritmo que, como o próprio nome indica,
busca aprofundar do grafo o máximo possível, antes de abrir a busca para os vértices adjacentes. Quando o vértice mais "profundo" no grafo é
finalizado, o algoritmo usa a técnica de backtracking para explorar as demais arestas do vértice que o precedeu, e assim por diante. Difere,
portanto, da busca em largura, pois enquanto esta primeiro explora toda a vizinhança de um vértice antes de aprofundar no grafo, a busca em
profundidade só fará essa exploração após chegar ao último vértice alcançável naquele caminho.

Antes de prosseguirmos com o algoritmo, convém recordar que a técnica de programação backtracking é um refinamento da busca por força
bruta. Enquanto a força bruta busca enumerar, de forma exaustiva, todas as combinações possíveis da solução, o backtracking permite retornar
a um ponto dessa busca e eliminar parte das possíveis soluções sem precisar analisá-las explicitamente. Muito comum em algoritmos e
estruturas de dados recursivas, podemos retornar a um ponto da exploração e continuar por outra ramificação. No caso da busca em
profundidade, quando chegamos a um nó pela primeira vez, exploramos cada aresta que parte desse nó aos seus adjacentes e, no final, o
controle volta (backtracks) ao nó anterior ao que acabamos de explorar, permitindo analisar outras possibilidades. A busca em profundidade
utiliza o recurso de fazer um corte (poda) da árvore de possibilidades, explorando todos os outros vértices, mas não repete a busca para aqueles
que não estejam marcados como brancos, excluindo-os de uma nova busca.

Uma diferença marcante da busca em profundidade também está no seu resultado. Enquanto a busca em largura nos dá uma árvore de
predecessores enraizada na origem, a busca em profundidade pode nos oferecer uma floresta, com várias árvores de profundidade diferentes.
Ou seja, não enraizamos o resultado em um vértice, para definir todos os que sejam alcançáveis por ele.

Assim como na busca em largura, vamos utilizar as cores para representar o status de cada vértice durante a execução, assim como o
predecessor (π) daquele vértice. Em vez da distância, vamos utilizar duas marcações de tempo: tempo de descoberta (d), quando chegamos
àquele vértice pela primeira vez, e tempo de finalização (f), quando toda a vizinhança daquele vértice foi explorada, ele é finalizado e marcado
como preto. Cada tempo é incrementado em uma unidade e nos permite reconstruir o caminho passado pelo algoritmo. O tempo de finalização
de cada vértice sempre será maior que o tempo de descoberta.

Vamos analisar o pseudocódigo fornecido por Cormen et al. (2009) para a realização da busca em profundidade, lembrando que time é uma
variável compartilhada entre ambos os métodos (em POO, podemos dizer que é um atributo da classe que representa o grafo, na qual os
algoritmos serão implementados). A busca em profundidade é dividida em dois algoritmos, um para iniciá-la e percorrer todos os vértices
brancos, e um que se encarrega de fazer a visita, atualizar os atributos dos vértices e controlar o backtracking. Assim como a busca em largura,
não armazenamos dados nas arestas.

DFS(G)
1 for each vertex u ϵ G.V
2 u.color = WHITE
3 u.π = NIL
4 time = 0
5 for each vertex u ϵ G.V
6 if u.color == WHITE
7 DFS-VISIT(G, u)

No método DFS, fazemos a inicialização de todos os vértices para a cor branca e a indicação que o predecessor é nulo. Não há vértice de
origem e, consequentemente, raiz, pois o algoritmo pode capturar elementos desconexos do grafo. A linha 4 inicia o carimbo de tempo em 0,
para que o tempo de descoberta e finalização seja incrementado em cada passo do algoritmo. As linhas 5-7 percorrem todos os vértices do
grafo, visitando aqueles que ainda estejam brancos. O método DFS-VISIT se encarregará de aprofundar, o máximo possível, no grafo a partir
daquele vértice, antes de iniciar o backtracking.

DFS-VISIT(G, u)
1 time = time + 1 // white vertex u has just been discovered
2 u.d = time
3 u.color = GRAY
4 for each v ϵ G.Adj[u] // explore edge (u, v)
5 if v.color == WHITE
6 v.π = u
7 DFS-VISIT(G, v)
8 u.color = BLACK // blacken u; it is finished
9 time = time + 1
10 u.f = time

Em cada chamada de DFS-VISIT, o vértice u é, inicialmente, branco. Incrementamos o carimbo de tempo e atribuímos esse valor ao tempo de
descobrimento do vértice e o marcamos como cinza (linhas 1-3) . O laço das linhas 4-7 vai explorar toda a vizinhança de u, atribuindo o

8 of 16 01/12/2022 09:57
Grafos - Introdução e algoritmos iniciais https://ead5.ifmg.edu.br/bambui/mod/book/tool/print/index.php?id=139311

precedessor de cada uma desses vértices a u e fazendo a chamada recursiva a DFS-VISIT (portanto, aprofundando no grafo antes de analisar a
próxima aresta. Quando saímos do laço, marcamos esse vértice como finalizado (preto), atualizamos o marcador de tempo e atribuímos ao
tempo de finalização (linhas 8-10).

A complexidade da busca em profundidade pode ser calculada usando uma análise agregada. Os laços das linhas 1-3 e 5-7 do método DFS
possuem tempo Θ(V). Em cada uma das passagens, há uma chamada para DFS-VISIT, mas considerando que o algoritmo não visita vértices
não brancos, mesmo fazendo a recursão, temos que o método é chamada exatamente uma vez para cada vértice v ϵ G. No método DFS-VISIT,
o laço nas linhas 4-7 é executado |Adj[v]| vezes, o que nos dá Θ(E). O custo total da busca em largura é, portanto, Θ(V + E).

Vejamos uma simulação da busca em profundidade, feita pelo professor Rosinei Soares de Figueiredo, do IFMG - Campus São João
Evangelista:

A busca em profundidade tem propriedades interessantes, como por exemplo, o uso do tempo de busca e finalização para verificar
balanceamento de parênteses e a classificação de arestas. Essas propriedades podem ser verificadas (e a prova dos teoremas associados) em
Cormen et al. (2009).

9 of 16 01/12/2022 09:57
Grafos - Introdução e algoritmos iniciais https://ead5.ifmg.edu.br/bambui/mod/book/tool/print/index.php?id=139311

2.3. Ordenação Topológica

Ordenação topológica é uma ordenação linear de um grafo direcionado acíclico G = {V, E} de tal forma que se se temos uma aresta (u, v), ou
seja, uma aresta que sai de u em direção a v, então u deverá aparecer antes de v na lista ordenada. Não é possível fazer ordenação topológica
em grafos com ciclos.

Uma das aplicações mais comuns da ordenação topológica estabelecer a precedência entre eventos/processos/ações em um grafo direcionado
acíclico. No exemplo acima, a aresta (u, v) indica que antes de realizar a tarefa v, precisamos ter u completada, pois a seta sai de u para v. Na
modelagem de processos, em que precisamos serializar as fases para, por exemplo, colocar em uma linha de montagem, a ordenação
topológica é de grande utilidade.

Para realizar a ordenação topológica, vamos utilizar a busca em profundidade para ordenar os vértices. Quando um vértice é finalizado (ou seja,
marcado como preto), o colocamos em uma lista linear. Ao percorrermos a lista da esquerda para a direita, teremos todas as ações na
sequência correta. Vejamos o pseudocódigo de Cormen et al. (2009):

TOPOLOGICAL-SORT(G)
1 call DFS(G) to compute finishing times v.f for each vertex v
2 as each vertex is finished, insert it onto the front of a linked list
3 return the linked list of vertices

Considerando que a busca em profundidade tem tempo Θ(V + E) e gastamos O(1) para inserir cada um dos |V| vértices na frente da lista, a
ordenação topológica tem um custo total de Θ(V + E).

Vamos verificar um exemplo, dado por Cormen et al. (2009), para auxiliar o professor Bumstead a se vestir. Não podemos calçar os sapatos
antes das meias, ou colocar as calças por baixo da cueca, então temos arestas indicando quais operações devem ser realizadas em sequência
e representadas no grafo da Figura 1(a). A ordenação topológica nos dá uma lista com uma sequência adequada para se vestir, como mostrado
na Figura 1(b). Notem que, quando não há precedência, dependendo da ordem em que os vértices sejam visitados, podemos começar de
formas diferentes (colocar primeiro o relógio, ou as meias, ou a cueca, mas jamais teremos a inversão das precedências assinaladas pelas
arestas do grafo.

Figura 1 - Ordenação topológica da sequência para se vestir

Fonte: Cormen et al., 2009.

Podemos observar, junto a cada vértice, os valores de descobrimento e finalização (v.d/v.f). Como cada vértice é inserido na frente da lista após
a finalização, podemos perceber que estão inversamente ordenados a partir do valor de v.f.

10 of 16 01/12/2022 09:57
Grafos - Introdução e algoritmos iniciais https://ead5.ifmg.edu.br/bambui/mod/book/tool/print/index.php?id=139311

3. Árvore Geradora Mínima

Algumas vezes usamos grafos em problemas de otimização, em que queremos encontrar o menor valor. Uma das classes desses algoritmos é a
árvore geradora mínima (AGM), também conhecida como minimum spanning tree (MST). Dado um grafo não direcionado e conectado, uma
árvore geradora é formada por um conjunto de arestas que interligue todos os vértices, sem formar ciclos. Uma árvore geradora mínima é uma
extensão da árvore geradora, em que as arestas possuem pesos e a soma total dos pesos das arestas incluídas na árvore seja o menor
possível.

Quando usamos AGMs para resolver problemas? Existem várias situações. Por exemplo, uma companhia aérea recebe uma concessão para
interligar várias cidades com seus vôos. Ela não quer fazer um voo de cada cidade para as demais, pois os custos são elevados, então ela pode
montar um grafo em que todas as rotas são representadas e o seu custo associado. Executando um algoritmo para extrair árvore geradora
mínima, vamos remover ciclos e arestas de custo mais elevado, fazendo com que todas as cidades sejam conectadas por uma aresta, que
sabemos que terá o menor custo. Os passageiros poderão viajar por toda a rede de aeroportos, mesmo que não tenha voo direto para seu
destino, fazendo uma conexão. Assim a empresa aérea cumpre toda a região que deve atender e mantém seus custos baixos.

Se fôssemos interconectar todos os prédios do campus por fibra óptica, poderíamos usar uma estratégia semelhante. Por exemplo,
mapearíamos todos os prédios e colocaríamos os lances de fibra conectando todos eles. Obviamente, é uma estrutura redundante e não
precisamos gastar todo esse dinheiro, então podemos usar uma AGM para manter o menor custo possível de implantação dos cabos ópticos,
considerando o comprimento do cabo e até mesmo outros fatores financeiros (custo para lançamento aéreo ou subterrâneo, conforme o trecho
atendido) como custo de cada aresta. A AGM nos dará a menor quantidade de cabos ópticos a serem lançados (ou o menor custo de
lançamento, se considerarmos outros fatores junto ao comprimento).

Vamos analisar os algoritmos de Prim e de Kruskal para a produção de AGMs. Ambos são considerados algoritmos gulosos, ou seja, cada
passo do algoritmo deve tomar uma de várias escolhas possíveis. Um algoritmo guloso tenta escolher a melhor opção disponível naquele
momento, em vez de analisar todas as combinações possíveis. Essa estratégia não nos garante que sempre encontraremos soluções ótimas
globais, pois depende de como escolhemos "a melhor opção" daquele momento, podendo nos prender em mínimos e máximos locais e não a
solução global. No entanto, como os algoritmos de Prim e de Kruskal são implementados, temos uma situação em que podemos ter certeza que
as AGMs encontradas serão sempre o menor valor possível para todo o grafo, ou seja, um mínimo global.

Devemos nos lembrar aqui que esses algoritmos são desenvolvidos para grafos não direcionados, conectados e ponderados, condições
necessárias para seu correto funcionamento.

11 of 16 01/12/2022 09:57
Grafos - Introdução e algoritmos iniciais https://ead5.ifmg.edu.br/bambui/mod/book/tool/print/index.php?id=139311

3.1. Algoritmo de Kruskal

O algoritmo de Kruskal usa uma abordagem "gulosa" para fazer crescer a árvore geradora mínima, selecionando a aresta com menor peso
possível e verificando se ela é segura (não fecha um ciclo) na árvore existente. Para isso, o algoritmo divide os vértices, inicialmente, em vários
conjuntos disjuntos unitários (ou seja, cada conjunto contém um único vértice), ordena todas as arestas em ordem crescente de pesos e só
adiciona à solução (à árvore geradora mínima) uma aresta que, se adicionada, conecta dois vértices que estejam em dois conjuntos diferentes.
Portanto, o algoritmo parte de uma floresta, conectando as árvores até gerar uma árvore geradora com o menor peso possível.

Apesar de usar uma técnica gulosa, é um dos casos em que temos a solução ótima, ou seja, a menor possível. Dependendo da forma como as
arestas sejam ordenadas, podemos ter inclusão ou não de arestas de mesmo peso (dependendo da ordem que forem tomadas), podendo gerar
árvores com configurações diferentes, mas sempre com o mesmo peso total.

A operação MAKE-SET cria um conjunto disjunto contendo um único vértice, que será repetido para cada um dos vértices do grafo. A operação
FIND-SET(u) retorna um elemento representativo do conjunto que contém u. Os conjuntos disjuntos são abordados no capítulo 21 de Cormen et
al. (2009). Quando comparamos FIND-SET de dois vértices, estamos verificando se ambos pertencem ao mesmo conjunto (e portanto, incluídos
na mesma árvore e a inclusão da aresta não é segura, pois fechará um ciclo) ou não (conjuntos diferentes, que podem ser ligados pela aresta
sem formar ciclo). Se forem de conjuntos diferentes, fazemos a unificação com o procedimento UNION, também abordado no mesmo capítulo.
Na verdade, um ponto chave da implementação de Kruskal é a escolha de como representar essas operações e os conjuntos, uma vez que a
complexidade assintótica dessas operações podem mudar completamente o desempenho do algoritmo.

Vamos verificar o pseudocódigo proposto por Cormen et al. (2009) para o algoritmo de Kruskal:

MST-KRUSKAL(G, w)
1 A = Ø
2 for each vertex v ϵ G.V
3 MAKE-SET(v)
4 sort the edges of G.E into nondecreasing order by weight w
5 for each edge (u, v) ϵ G.E, taken in nondecreasing order by weight
6 if FIND-SET(u) ≠ FIND-SET(v)
7 A = A U {(u, v)}
8 UNION(u, v)
9 return A

O algoritmo recebe o grafo (G) e uma função w : E → ℝ, ou seja, uma função que mapeia cada aresta a um peso, podendo ser uma lista ou
matriz de adjacências com esses pesos. Vamos usar A, inicialmente vazio, para representar a árvore construída. Esta árvore A pode ser uma
nova matriz/lista de adjacências, à qual vamos acrescentar apenas as arestas que sejam façam parte da solução do problema (linha 1). As
linhas 2-3 criam |V| conjuntos unitários, cada um com um dos vértices do grafo, ou seja, uma floresta com |V| árvores contendo apenas um
vértice.

A linha 4 se encarrega de ordenar as arestas em ordem crescente, usando qualquer algoritmo de ordenação eficiente, ou seja, da ordem de O(n
log n), como o QuickSort ou MergeSort. Na linha 5, selecionamos a menor aresta, seguindo a lista ordenada, e verificamos se liga dois vértices
que já estejam no mesmo conjunto (linha 6). Se os vértices estiverem em conjuntos diferentes, vamos inserir essa aresta na árvore, pois ela é
segura e faz parte da solução e unir os conjuntos dos vértices em um conjunto, para indicar que fazem parte da mesma árvore na nossa floresta
(linhas 7-8). Faremos esse processo até que todas as arestas sejam percorridas e aquelas que não formem ciclos e tenham o menor peso
sejam incluídas na solução.

Antes de analisarmos um exemplo do algoritmo em funcionamento, vamos analisar sua complexidade assintótica. A inicialização de A na linha 1
tem custo O(1). Vamos assumir que a implementação de conjunto disjuntos seja a proposta no capítulo 21 dos autores (CORMEN et al., 2009),
com heurísticas de compressão de caminho, que são as implementações assintoticamente mais rápidas conhecidas. O tempo de ordenação das
arestas na linha 4 é O(E log E), usando um algoritmo de ordenação eficiente. O laço das linhas 5-8 executa O(E) operações FIND-SET e UNION
na floresta de conjuntos disjuntos. Junto com |V| operações MAKE-SET, essas operações possuem um custo total de O((V + E) α(V)), em que α
é uma função de crescimento lento definida no capítulo 21 e depende da implementação. Já que assumimos que G é um grafo conectado,
temos |E| ≥ |V| - 1, e as operações de conjuntos disjuntos consomem um tempo O(E α(V)). Assumindo que seja uma implementação eficiente de
conjuntos disjuntos, tal que α(|V|) = O (log V) = O(log E), pois o total de arestas será o número de vértices menos 1, o tempo total do algoritmo
de Kruskal é O(E log E). Observando que |E| < |V|², temos que log|E| = O(log V), então podemos reescrever o tempo de execução como O(E log
V). Portanto, um ponto crítico do algoritmo é a implementação dos conjuntos disjuntos. Se usarmos estruturas menos eficientes, como listas
lineares, o valor da função α será alterado e terá um impacto direto no custo total do algoritmo.

Vamos observar um teste de mesa de Kruskal feito pelo Prof. Paulo Henrique Ribeiro Gabriel, a UFU. Notem como ele também destaca que o
custo computacional depende da estrutura de dados utilizada para representar a floresta de conjuntos disjuntos.

12 of 16 01/12/2022 09:57
Grafos - Introdução e algoritmos iniciais https://ead5.ifmg.edu.br/bambui/mod/book/tool/print/index.php?id=139311

13 of 16 01/12/2022 09:57
Grafos - Introdução e algoritmos iniciais https://ead5.ifmg.edu.br/bambui/mod/book/tool/print/index.php?id=139311

3.2. Algoritmo de Prim

Assim como o algoritmo de Kruskal, o algoritmo de Prim é um algoritmo guloso para construção de uma AGM, embora adote uma abordagem
diferente na sua construção. No algoritmo de Prim, vamos especificar um vértice inicial, que servirá como raiz da AGM produzida, e a partir
desse vértice vamos adicionando todas as arestas seguras.

Durante a execução do algoritmo, todos os vértices que não estão incluídos na árvore ficam armazenados em uma fila de prioridade mínima
(uma fila em que o menor valor é removido). Essa prioridade será dada por um atributo denominado key, que é inicializado como ∞ para todos
os vértices, exceto a raiz, que terá o valor 0. Esse atributo será atualizado quando inserirmos uma aresta na árvore, conectando um novo vértice
ou atualizando um existente. Enquanto os vértices estiverem na fila, vamos atualizar o valor de key para representar o peso da aresta utilizada
para ligá-lo, atualizando esse valor sempre que uma aresta de menor peso for encontrada. Todo vértice também armazena seu predecessor (π).
O algoritmo de Prim finaliza quando a fila está vazia e, portanto, todos os vértices foram incluídos. Assim como Kruskal, usamos w para mapear
o peso das arestas do grafo.

MST-PRIM(G, w, r)
1 for each u ϵ G.V
2 u.key = ∞
3 u.π = NIL
4 r.key = 0
5 Q = G.V
6 while Q ≠ Ø
7 u = EXTRACT-MIN(Q)
8 for each v ϵ G.Adj[u]
9 if v ϵ Q and w(u, v) < v.key
10 v.π = u
11 v.key = w(u, v)

As linhas 1-5 inicializam os valores dos vértices e os insere na fila Q. Quando um vértice sai da fila, ele é definitivamente incluído na árvore e
não é mais atualizado, ou seja, a árvore atual é o conjunto de vértices, excluídos os presentes na fila. Enquanto a fila tiver algum vértice, vamos
executar os passos das linhas 7-11, ou seja, remover o vértice com a menor chave (ou seja, o que até o momento tem o menor custo de aresta
para ser alcançado). Inicialmente, é a raiz, que tem custo 0, nas iterações seguintes, os vizinhos da raiz serão avaliados e atualizados e assim
sucessivamente, de tal forma que a árvore crescerá a partir da raiz estabelecida. Nas linhas 9-11, sempre que encontrarmos uma aresta segura
(menor peso e que não feche ciclo) capaz de conectar um dos vizinhos à árvore, vamos atualizar essa estimativa de key e o predecessor
daquele vértice. Quanto terminarmos, removeremos o vértice com a menor key, ou seja, o que acrescentará o menor valor possível à AGM, e a
partir de então, ele não será mais alterado.

A complexidade assintótica do algoritmo de Prim depende da implementação da fila de prioridades. Se a implementarmos como um min-heap
binário, como proposto por Cormen et al. (2009) no capítulo 6, o custo total das linhas 1-5 de Prim é O(V). Como o laço da linha 6 executa |V|
vezes, e como cada operação EXTRACT-MIN tem tempo O(log V), temos que o custo completo para remover cada um dos vértices da fila é O(V
log V). O laço das linhas 8-11 executa O(E) vezes. Dentro desse laço, a linha 11 envolve uma operação de atualização do heap, feita em tempo
O(log V). Uma forma de manter esse custo baixo também envolve otimizar o teste se o vértice está na fila (linha 9), adicionando um atributo que
represente que ele está na fila e que seja alterado quando ele for removido, assim, o custo dessa verificação da presença do vértice na fila é
O(1). Portanto, o custo total do algoritmo de Prim é O(V log V + E log V) = O(E log V), que é assintoticamente igual ao de Kruskal.

Podemos melhorar o tempo de execução, segundo Cormen et al. (2009), utilizando heaps de Fibonacci, que os autores apresentam no capítulo
19. A operação EXTRACT-MIN teria um custo O(log V) e a atualização da chave na linha 11 teria um custo constante O(1), reduzindo o custo
total para O(E + V log V).

Podemos perceber, portanto, que a estrutura da fila é essencial para o desempenho do algoritmo. Se for usada uma lista linear, o custo para
extrair o menor elemento sobe para O(V), afetando consideravelmente o desempenho do algoritmo.

Vejamos uma execução do algoritmo de Prim, disponibilizada pelo professor Paulo Henrique Ribeiro GAbriel, da UFU:

14 of 16 01/12/2022 09:57
Grafos - Introdução e algoritmos iniciais https://ead5.ifmg.edu.br/bambui/mod/book/tool/print/index.php?id=139311

15 of 16 01/12/2022 09:57
Grafos - Introdução e algoritmos iniciais https://ead5.ifmg.edu.br/bambui/mod/book/tool/print/index.php?id=139311

4. Referências

CORMEN, Thomas H.; LEISERSON, Charles E.; RIVEST, Ronald L.; STEIN, Clifford. Introduction to Algorithms. 3rd ed. Cambridge,
Massachusetts: The MIT Press, 2009. 1292 p. ISBN 978-0-262-53305-8.

16 of 16 01/12/2022 09:57

Você também pode gostar