Introdução a Grafos

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
A→BF
B→ACE
C→BD
D→CE
E→BDF
F→AE
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 V² 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 faz parte destes roadmaps

Ver todos →

Introdução a Grafos aparece em percursos com objetivo próprio. O conteúdo é o mesmo; o que muda é a pergunta que ele responde ali, e o que vem antes e depois.