Introdução a Grafos

11 min de leituraMédioPython

Árvore é um grafo com regras: um pai por nó, sem ciclo, um ponto de entrada. Tire as regras e sobra a estrutura mais geral da computação, capaz de modelar mapa, rede social, dependência de build, rota de pacote, malha de estradas e o grafo de chamadas do seu programa. O preço da generalidade aparece logo na primeira linha de código: sem hierarquia, você precisa decidir como guardar as conexões, e essa decisão define o custo de tudo que vem depois.

O que é, em duas palavras

Um grafo é um par: um conjunto de vértices (os pontos) e um conjunto de arestas (as ligações entre eles). Só isso. A riqueza vem das variações:

Dirigido ou não

Não dirigido: a aresta vale nos dois sentidos. Amizade no Facebook, estrada de mão dupla. Dirigido: a aresta tem sentido. Seguir alguém no Twitter, dependência entre módulos, rua de mão única.

Ponderado ou não

Sem peso: só interessa se existe ligação. Contagem de saltos. Com peso: cada aresta carrega um custo, distância ou tempo. É o que faz Dijkstra existir.

E o vocabulário mínimo, que aparece em todo enunciado:

  • Grau de um vértice: quantas arestas saem dele. Em grafo dirigido, separa-se em grau de entrada e de saída.
  • Caminho: sequência de vértices ligados por arestas.
  • Ciclo: caminho que volta ao ponto de partida. Árvore é justamente o grafo sem ciclo.
  • Conexo: existe caminho entre qualquer par de vértices. Se não, o grafo tem componentes separados.
  • Denso e esparso: muitas ou poucas arestas em relação ao máximo possível. Esta palavra decide a próxima seção.

Um grafo com V vértices tem no máximo V(V-1)/2 arestas se for não dirigido, e o dobro disso se for dirigido. Com 6 vértices são 15 arestas no máximo; com 1.000, são quase 500 mil. A densidade é quantas arestas você realmente tem em relação a esse teto, e quase todo grafo do mundo real é esparso: você não é amigo de metade do Facebook.

Guardar o grafo: as duas formas

Existem dois jeitos de representar as arestas, e a escolha entre eles é a primeira decisão de projeto.

A matriz de adjacência é uma tabela V por V em que a célula [i][j] diz se existe aresta de i para j. A lista de adjacência é um vetor de listas: para cada vértice, quem são os vizinhos dele.

Python
# matriz: V x V, sempre
matriz = [[0, 1, 0, 0],
          [1, 0, 1, 0],
          [0, 1, 0, 1],
          [0, 0, 1, 0]]

# lista: só o que existe
lista = {0: [1], 1: [0, 2], 2: [1, 3], 3: [2]}
Visualizador · o mesmo grafo em matriz e em lista de adjacência
V = 6 · E = 7 · densidade 47%
Tipo

O caso mais comum do mundo real: cada vértice tem poucos vizinhos. São 7 das 15 arestas possíveis, e 22 das 36 células da matriz ficam em zero.

ABCDEF
Matriz de adjacência clique para ligar/desligar
ABCDEF
A
B
C
D
E
F
Lista de adjacência só o que existe
ABF
BACE
CBD
DCE
EBDF
FAE
vértices (V)6
arestas (E)7 de 15
memória da matriz36 células
memória da lista20 entradas
células em zero22

A matriz custa V² sempre, ligada ou não a aresta. A lista custa V + 2E (não dirigido: cada aresta aparece nos dois vizinhos), e no grafo completo isso dá exatamente V²: os dois empatam, e é o mais caro que a lista chega a ficar. Com 6 vértices a diferença é pequena; com 1 milhão de vértices, a matriz pediria 10¹² células e simplesmente não cabe.

Comece no preset esparso e olhe a matriz: quase tudo é zero. Cada zero é memória reservada para uma aresta que não existe. Agora clique nas células para ligar arestas e veja o desenho e a lista acompanharem, e o contador de "células em zero" cair.

Depois troque para completo e compare os dois custos: com o grafo cheio, a matriz para de desperdiçar.

Por fim, ligue o modo dirigido e clique numa célula: agora só ela acende, enquanto no modo não dirigido a célula espelhada acompanhava. É a prova de uma coisa boa de saber: num grafo não dirigido, metade da matriz é cópia da outra metade, e a lista guarda cada aresta duas vezes justamente por isso.

Qual escolher, e por quê

A conta é direta. A matriz custa células, existam arestas ou não. A lista custa V + 2E (ou V + E se for dirigido), porque só guarda o que existe.

OperaçãoMatrizLista
Existe aresta entre u e v?O(1)O(grau de u)
Percorrer todos os vizinhos de uO(V)O(grau de u)
MemóriaO(V²)O(V + E)
Adicionar arestaO(1)O(1)

Cada uma ganha em uma coisa. A matriz responde "existe aresta?" em tempo constante, e a lista percorre vizinhos sem olhar quem não é vizinho.

Na prática, a lista vence quase sempre, por um motivo de escala: com um milhão de vértices, a matriz pediria 10¹² células, algo em torno de um terabyte. Não é lento, é impossível. Já a lista guarda só as arestas reais.

A regra prática: use lista de adjacência por padrão, e só troque para matriz quando o grafo for pequeno (algumas centenas de vértices) ou realmente denso, ou quando a operação dominante for testar a existência de uma aresta específica. Praticamente todo algoritmo de grafo do roadmap, de BFS a Dijkstra, é escrito supondo lista, e a complexidade deles é anunciada em V e E justamente por isso.

Em Python, a lista de adjacência costuma ser um dicionário de listas, e o defaultdict evita metade dos casos de borda:

Python
from collections import defaultdict

g = defaultdict(list)
for u, v in arestas:
    g[u].append(v)
    g[v].append(u)      # tire esta linha e o grafo vira dirigido

Essa única linha comentada é a diferença entre os dois mundos, e vale ler o código de qualquer solução de grafo procurando por ela.

O grafo que você não desenhou

Aqui está a ideia que mais rende em problema de entrevista: você quase nunca recebe um grafo. Você recebe outra coisa e precisa enxergar o grafo dentro dela.

O caso mais comum é a grade. Uma matriz de células é um grafo em que cada célula é um vértice e as vizinhas (acima, abaixo, esquerda, direita) são as arestas. Você não constrói lista de adjacência nenhuma: a vizinhança é calculada na hora.

Python
DIRECOES = [(-1, 0), (1, 0), (0, -1), (0, 1)]

def vizinhos(grade, i, j):
    for di, dj in DIRECOES:
        ni, nj = i + di, j + dj
        if 0 <= ni < len(grade) and 0 <= nj < len(grade[0]):
            yield ni, nj

Com isso, "quantas ilhas existem neste mapa" vira "quantos componentes conexos tem este grafo", e "menor número de passos até a saída do labirinto" vira BFS. Metade dos problemas de grade do LeetCode é grafo disfarçado.

A mesma leitura serve para outras coisas:

  • Palavras: cada palavra é um vértice, e existe aresta se elas diferem por uma letra (é o Word Ladder).
  • Estados de um jogo: cada configuração do tabuleiro é um vértice, cada jogada é uma aresta.
  • Dependências: cada tarefa é um vértice, cada "A precisa de B" é uma aresta dirigida. É o ordenamento topológico.
  • Rotas de um servidor web: cada segmento de URL é um vértice, e resolver a rota é caminhar no grafo.

Quando o enunciado falar em "conectado", "caminho", "alcançar", "vizinho", "dependência" ou "menor número de passos", pare e pergunte quem são os vértices e quem são as arestas. Se você conseguir responder isso, o resto é escolher entre os algoritmos dos próximos tópicos.

Onde ficam os ciclos, e por que isso importa

Uma diferença prática entre grafo e árvore muda o código de todo percurso: no grafo existe ciclo, então você pode voltar a um vértice já visitado e girar para sempre.

A solução é uma linha, e ela é obrigatória:

Python
visitados = set()

def dfs(u):
    if u in visitados:
        return
    visitados.add(u)
    for v in g[u]:
        dfs(v)

Numa árvore, esse set não existe, porque a estrutura garante que você nunca volta. Num grafo, esquecer dele é recursão infinita. É a diferença mais importante entre os percursos que você já conhece e os de DFS e BFS em grafos, que é exatamente o próximo tópico.

Como praticar

Comece pelos problemas que pedem para você enxergar o grafo antes de percorrer. Number of Islands é a grade como grafo e componentes conexos; Flood Fill é a mesma ideia com pintura. Find if Path Exists in Graph é a versão mais direta possível, e serve para você escrever sua primeira lista de adjacência do zero.

Depois, Clone Graph é ótimo por um motivo diferente: ele obriga a lidar com ciclo de verdade, porque copiar ingenuamente entra em laço infinito, e a saída é um mapa de "original para cópia" que faz o papel do conjunto de visitados.

Se quiser treinar a representação em si, pegue qualquer problema resolvido com lista de adjacência e reescreva com matriz. A solução vai ficar mais curta em um ponto e absurdamente cara em outro, e sentir isso na mão vale mais que a tabela desta página.

A partir daqui, o caminho é DFS e BFS em Grafos, que traz os dois percursos com o detalhe dos visitados, e depois os algoritmos de caminho mínimo: Dijkstra para pesos não negativos e Bellman-Ford para quando existe peso negativo.

Vídeo da aula

Direto do canal da comunidade Craft & Code Club · 1:46:55.

Problemas para praticar

Na ordem em que recomendamos resolver. Marque os que você já fez, fica salvo aqui.

FácilFlood FillLeetCode 733
MédioNumber of IslandsLeetCode 200
MédioClone GraphLeetCode 133
MédioNumber of ProvincesLeetCode 547

Referências

Artigos e materiais externos para se aprofundar.

Travou em algum passo? Traga sua questão para o Discord da comunidade ou para os encontros semanais.

Entrar
Concluiu este tópico?
Marque para acompanhar seu progresso.

Este tópico também tem página própria, fora deste roadmap: Introdução a Grafos.