Ordenação Topológica

Grafos⏱ 10 min de leituraMédioPython

"Em que ordem eu faço isso?" é uma das perguntas mais comuns de engenharia, e ela aparece disfarçada: em que ordem compilar os módulos, instalar as dependências, rodar as migrations, cursar as matérias, executar as tarefas do pipeline. Todas viram o mesmo problema quando você desenha as dependências como um grafo dirigido, e todas têm a mesma resposta: ordenação topológica.

O problema, e a condição para ele ter solução

Você tem um conjunto de coisas e um conjunto de restrições da forma "A precisa vir antes de B". Uma ordenação topológica é uma sequência de todos os elementos em que toda aresta aponta para frente.

Modele como grafo dirigido: cada tarefa é um vértice, e A → B quer dizer "A precisa acontecer antes de B".

Aí aparece a condição, e ela é o coração do tópico: existe ordenação topológica se, e somente se, o grafo não tem ciclo. Um grafo dirigido sem ciclo tem um nome próprio, DAG (directed acyclic graph), e é o que você vai ver em todo enunciado.

A razão é intuitiva. Se A depende de B e B depende de A, quem vai primeiro? Nenhum dos dois pode, e não existe ordem. É o deadlock de dependências que todo mundo já viu num package.json ou num build quebrado.

Note que a ordenação topológica em geral não é única. Se duas tarefas não dependem uma da outra, tanto faz qual vem primeiro. O número de ordens válidas pode ser enorme, e qualquer uma serve. O visualizador mostra isso: sempre que a fila tem mais de um vértice, existe uma escolha ali.

Kahn: remover quem não depende de ninguém

O algoritmo de Kahn é o mais fácil de explicar para um humano, porque é o que você faria à mão.

Para cada vértice, conte quantas arestas chegam nele. Esse número é o grau de entrada, e ele quer dizer "quantos pré-requisitos ainda faltam".

  1. Todo vértice com grau de entrada 0 pode ser feito agora. Ponha todos numa fila.
  2. Tire um da fila e coloque na resposta.
  3. Para cada vizinho dele, diminua o grau de entrada em 1. Se algum chegar a zero, ele foi liberado: entra na fila.
  4. Repita.
Python
from collections import deque

def kahn(vertices, adj):
    grau = {v: 0 for v in vertices}
    for u in vertices:
        for v in adj[u]:
            grau[v] += 1

    fila = deque(v for v in vertices if grau[v] == 0)
    ordem = []
    while fila:
        u = fila.popleft()
        ordem.append(u)
        for v in adj[u]:
            grau[v] -= 1
            if grau[v] == 0:
                fila.append(v)

    if len(ordem) < len(vertices):
        raise ValueError("ciclo: não existe ordenação")
    return ordem
Visualizador · Kahn, com o grau de entrada à vista
passo 1 de 18

Uma aresta A → B quer dizer 'A é pré-requisito de B'. A ordenação topológica é uma ordem válida de fazer as matérias.

Alg0C11C21Fis2Est2ML2Prog0
Grau de entrada pré-requisitos que faltam
Alg0C11C21Fis2Est2ML2Prog0
Fila grau zero, prontos para sair
vazia
Ordem final
nada ainda

Conto quantas arestas CHEGAM em cada vértice: é o grau de entrada, ou seja, quantos pré-requisitos ainda faltam para ele poder acontecer.

kahn.py
1def kahn(vertices, arestas):
2 adj = {v: [] for v in vertices}
3 grau = {v: 0 for v in vertices}
4 for u, v in arestas:
5 adj[u].append(v)
6 grau[v] += 1 # quantos pré-requisitos faltam
7
8 fila = deque(v for v in vertices if grau[v] == 0)
9 ordem = []
10 while fila:
11 u = fila.popleft()
12 ordem.append(u)
13 for v in adj[u]:
14 grau[v] -= 1 # um pré-requisito a menos
15 if grau[v] == 0:
16 fila.append(v) # liberado
17 if len(ordem) < len(vertices):
18 raise CicloDetectado()
Variáveis
na ordem0 de 7
na fila0
arestas8

Quando a fila tem mais de um vértice ao mesmo tempo, existe mais de uma ordem válida: qualquer um deles pode sair primeiro. E quando a fila esvazia cedo, o que sobrou na tela é o ciclo. Rode o terceiro preset até o fim.

←→ passo · espaço roda

Acompanhe o grau de entrada dentro de cada vértice no desenho. Ele é o estado que faz o algoritmo funcionar, e vê-lo cair até zero é ver a dependência sendo cumprida. Repare também no preset "muita coisa independente": a fila fica com vários vértices ao mesmo tempo, e cada um deles poderia sair primeiro.

A detecção de ciclo vem de graça

Rode o terceiro preset. A fila esvazia com só 4 dos 7 vértices na resposta, e o algoritmo para.

O que sobrou não é aleatório: os vértices que ficaram têm grau de entrada maior que zero e ninguém mais para zerá-lo, porque todos os que poderiam já saíram. A única forma disso acontecer é eles dependerem uns dos outros. O que sobra é o ciclo.

Isso dá o teste mais limpo de ciclo em grafo dirigido:

Python
if len(ordem) < len(vertices):
    # existe ciclo, e ele está entre os vértices que faltaram

É por isso que o problema Course Schedule ("dá para terminar todas as matérias?") é resolvido com ordenação topológica: a resposta é sim exatamente quando a ordem sai completa.

A outra saída: DFS com pós-ordem

Existe uma segunda implementação, e ela é mais curta:

Python
def topo_dfs(vertices, adj):
    visitado = set()
    ordem = []

    def dfs(u):
        visitado.add(u)
        for v in adj[u]:
            if v not in visitado:
                dfs(v)
        ordem.append(u)          # PÓS-ordem: depois de todos os descendentes

    for u in vertices:
        if u not in visitado:
            dfs(u)
    return ordem[::-1]           # inverte no fim

A ideia: em pós-ordem, um vértice só é anotado depois de todos os vértices alcançáveis a partir dele. Ou seja, ele é anotado depois de todos que dependem dele. Invertendo a lista, cada vértice aparece antes de todos os seus dependentes, que é a definição de ordenação topológica.

Detalhe fácil de errar: inverter é obrigatório. A pós-ordem devolve a ordem exatamente ao contrário do que você quer, e esquecer o [::-1] produz uma resposta que parece plausível e está de ponta-cabeça. Outro detalhe: nesta versão, detectar ciclo exige as três cores (branco, cinza, preto) descritas em DFS e BFS, porque um vértice "já visitado" não é necessariamente um ciclo.

Kahn (fila)DFS (pós-ordem)
Estruturafila e vetor de grausrecursão
Detectar ciclode graça, conta quantos saíramprecisa das três cores
Ordem produzidapor "camadas" de liberaçãopor profundidade
Bom paraníveis, paralelismo, semestrescódigo curto, componentes

Os dois custam O(V + E): você toca em cada vértice e em cada aresta uma vez.

Um bônus do Kahn: quantos passos em paralelo

Como o Kahn libera vértices em camadas, ele responde de graça uma pergunta que o DFS não responde: quantas rodadas seriam necessárias se você pudesse executar em paralelo tudo que está liberado ao mesmo tempo?

Basta processar a fila em blocos, como no BFS por níveis:

Python
rodadas = 0
while fila:
    for _ in range(len(fila)):        # trava o nível atual
        u = fila.popleft()
        ...
    rodadas += 1

Esse número é o caminho crítico do seu grafo de dependências, e é exatamente a conta que um sistema de build faz para saber o mínimo de tempo possível, mesmo com paralelismo infinito. É também o que responde problemas do tipo "quantos semestres no mínimo para terminar o curso".

Onde isso já está rodando

  • Sistemas de build: make, Bazel, Gradle e o pipeline do seu CI ordenam alvos topologicamente. O "circular dependency detected" que você já viu é exatamente o len(ordem) < len(vertices).
  • Gerenciadores de pacote: npm, pip e apt resolvem a ordem de instalação assim, e o "dependency cycle" é o mesmo erro.
  • Migrations de banco: rodar na ordem em que as tabelas dependem umas das outras.
  • Planilhas: o Excel recalcula células em ordem topológica das fórmulas, e o aviso de "referência circular" é a detecção de ciclo.
  • Compiladores: ordem de inicialização de módulos e de avaliação de expressões.

Se você quiser uma ordem específica entre as várias válidas (a lexicograficamente menor, por exemplo), troque a fila do Kahn por uma fila de prioridade. O algoritmo continua correto, e passa a desempatar sempre pelo menor. É uma variação que cai em problema de competição com frequência.

Como praticar

Course Schedule é o "existe ordem?" e é o melhor primeiro problema, porque ele reduz a ordenação topológica a um sim ou não. Logo depois, Course Schedule II pede a ordem em si, e é o mesmo código devolvendo ordem em vez de um booleano.

Minimum Height Trees é uma variação bonita: você remove folhas em camadas, exatamente como o Kahn remove vértices de grau zero, até sobrar o centro do grafo. Ver que é o mesmo padrão com outra roupa vale mais que o problema em si.

Alien Dictionary é o passo mais difícil: você recebe palavras numa ordem desconhecida e precisa descobrir as arestas antes de ordenar. É o exercício definitivo de "enxergar o grafo", que a introdução a grafos levanta.

Uma dica de leitura de enunciado: as palavras "pré-requisito", "depende de", "antes de", "ordem de execução" e "circular" são o gatilho. Quando aparecerem, desenhe o grafo dirigido e pergunte se ele tem ciclo. Daí em diante o código é o desta página.

Depois daqui, a Árvore Geradora Mínima muda a pergunta de "em que ordem" para "quais arestas manter", e é o último algoritmo clássico deste grupo.

Vídeo da aula

Direto do canal da comunidade Craft & Code Club · 1:41:07.

Problemas para praticar

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

MédioCourse ScheduleLeetCode 207↗
MédioCourse Schedule IILeetCode 210↗
MédioMinimum Height TreesLeetCode 310↗
DifícilAlien DictionaryLeetCode 269↗
GuiaTopological SortingGeeksforGeeks↗

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 de

Ver todos →

Ordenação Topológica aparece num percurso 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.