Ordenação Topológica

Grafos10 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.