Caminhos Mais Curtos em um DAG: Ordem Topológica Supera Priority Queue
Objetivo
Entenda o atalho específico para DAG do problema de shortest paths que o concept irmão de Dijkstra deixa em aberto. Relaxamento (veja aquele concept) ainda é a única operação que faz trabalho de verdade aqui, mas a propriedade que define um directed acyclic graph — nunca ter ciclos — permite descartar totalmente a priority queue do Dijkstra. Processe cada vértice exatamente uma vez, em ordem topológica (veja o concept irmão de topological-sort para como essa ordem é produzida), relaxando as arestas de saída de cada vértice conforme avança, e pronto: sem escolha greedy de "sempre expandir o vértice não finalizado mais próximo", sem operação de heap O(log V) por aresta — e, como essa escolha greedy era a única razão de o Dijkstra precisar de pesos não negativos, também sem restrição alguma sobre o sinal dos pesos.
Casos de Uso
- Método do caminho crítico (CPM) / agendamento de projeto com gráfico PERT — o tempo mínimo possível de conclusão de um projeto é o comprimento do caminho mais longo de um marco inicial a um marco final em um DAG de dependências de tarefa. É o mesmo algoritmo coberto aqui com a desigualdade de relaxamento invertida para maximizar (veja Trade-offs).
- Sistemas de build e agendadores de tarefa (um DAG de dependências no estilo Makefile) computando o quanto antes cada target pode começar depois que todo pré-requisito terminou — um peso de aresta de "atraso" ou "adiantamento" pode ser negativo sem quebrar nada.
- Qualquer DAG ponderado que já tenha sido ordenado topologicamente por outro motivo (por exemplo, depois de rodar o concept irmão de topological-sort para ordenar dependências ou detectar um ciclo) — essa passada de shortest path é essencialmente grátis de adicionar em cima, já que a parte cara (a ordenação) já foi paga.
- Agendamento de instrução/fluxo de dados dentro do bloco básico de um compilador, onde dependências de operação formam um DAG e pesos de aresta podem representar folga de agendamento negativa sem precisar de um algoritmo diferente.
Aprofundamento
Por que processar em ordem topológica sozinho garante corretude
Para qualquer aresta u -> v em um DAG, a ordem topológica coloca u antes de v — essa é a garantia que define uma ordem topológica (veja o concept irmão de topological-sort para como ela é computada via DFS reverso por tempo de término). Percorra o que essa garantia compra para uma única passada linear que relaxa as arestas de saída de cada vértice conforme o alcança:
No momento em que a passada alcança o vértice v e está prestes a relaxar as próprias arestas de saída de v, toda aresta que aponta para dentro de v — toda aresta w -> v para algum w — já foi relaxada, porque todo w desse tipo foi necessariamente processado antes na passada (a ordem topológica garante que w precede v sempre que a aresta w -> v existe). Relaxamento é a única operação, em toda essa família de algoritmos, que pode reduzir um valor de distTo[], e toda aresta que poderia possivelmente reduzir distTo[v] já disparou quando você chega em v. Então distTo[v] não pode mudar de novo depois desse ponto: já é igual ao verdadeiro peso de caminho mais curto antes mesmo de você tocar em v para relaxar suas próprias arestas de saída.
O Teorema 22.5 do CLRS afirma isso como a propriedade de relaxamento de caminho aplicada ao longo de uma ordem topológica: tome qualquer caminho mais curto p = v0(=s), v1, ..., vk(=v). Como a ordem topológica processa v0 antes de v1 antes de v2 ... antes de vk (cada par consecutivo em p é, ele mesmo, uma aresta, logo ordenado pela ordenação topológica), o algoritmo relaxa a aresta (v0,v1) antes de (v1,v2) antes de ... antes de (vk-1,vk) — relaxar as arestas de um caminho exatamente na ordem do próprio caminho é precisamente o que a propriedade de relaxamento de caminho precisa para concluir vk.d = δ(s, vk) no término.
Contraste isso com a prova de corretude do Dijkstra (veja o concept irmão): Dijkstra precisa argumentar indutivamente que finalizar greedily o vértice de distância mínima da fronteira é seguro, e esse argumento só funciona porque os pesos são não negativos — um vértice ainda não processado nunca consegue superar uma distância já finalizada só porque arestas não podem ser negativas. A prova de corretude do algoritmo de DAG acima nunca menciona o sinal dos pesos em nenhum momento; ela se apoia unicamente na garantia da ordenação topológica. Esse é o motivo inteiro pelo qual ele tolera pesos negativos: nada no argumento de corretude jamais precisou que fossem não negativos.
O algoritmo: topological sort, depois uma passada linear de relaxamento
javapublic class AcyclicSP {
private DirectedEdge[] edgeTo;
private double[] distTo;
public AcyclicSP(EdgeWeightedDigraph G, int s) {
edgeTo = new DirectedEdge[G.V()];
distTo = new double[G.V()];
for (int v = 0; v < G.V(); v++) {
distTo[v] = Double.POSITIVE_INFINITY;
}
distTo[s] = 0.0;
// O concept irmão de topological-sort produz essa ordem via tempo de
// término reverso do DFS; este algoritmo só a consome.
Topological topological = new Topological(G);
for (int v : topological.order()) {
relax(G, v);
}
}
private void relax(EdgeWeightedDigraph G, int v) {
for (DirectedEdge e : G.adj(v)) {
int w = e.to();
if (distTo[w] > distTo[v] + e.weight()) {
distTo[w] = distTo[v] + e.weight();
edgeTo[w] = e;
}
}
}
public double distTo(int v) { return distTo[v]; }
}Compare isso com o loop condutor do Dijkstra no concept irmão: não há campo de priority queue em lugar nenhum nessa classe, nenhum delMin(), nenhum decreaseKey() — relax é chamado exatamente uma vez por vértice, na ordem fixa que topological.order() entrega, e toda aresta do grafo é relaxada exatamente uma vez, no total, em toda a execução.
Trace: um pequeno DAG ponderado com uma aresta negativa
Seis vértices, nove arestas direcionadas, uma delas negativa (A -> B, peso -4):
plaintextS -> A (5) A -> B (-4) B -> D (7) C -> T (2) S -> B (2) A -> C (3) C -> D (1) D -> T (4) B -> C (6)
S, A, B, C, D, T é a (única) ordem topológica válida para esse grafo — toda aresta acima aponta de um vértice mais cedo nessa lista para um mais tarde. Verificando à mão as distâncias mais curtas primeiro: o caminho mais barato até B não é a aresta direta S -> B (peso 2), mas S -> A -> B (5 + (-4) = 1), e o caminho mais barato até T é S -> A -> B -> C -> T (5 - 4 + 6 + 2 = 9), superando toda outra rota S-a-T (S -> A -> C -> T = 10, S -> B -> C -> T = 10, S -> A -> B -> D -> T = 12). Note que essa é exatamente a forma de problema que o contraexemplo de peso negativo do concept irmão de Dijkstra alerta — A -> B torna um caminho por A mais barato que a aresta direta até B — só que aqui não causa problema algum, porque nada é "finalizado" fora de ordem em primeiro lugar; a passada apenas relaxa arestas em uma sequência fixa independentemente de como qualquer distância parece naquele momento.
Distâncias finais a partir de S: A=5, B=1, C=7, D=8, T=9 — batendo com os valores verificados à mão acima, computados com zero comparações contra um mínimo de priority queue, apenas uma passada em linha reta pela ordem topológica.
Tempo de execução: O(V + E), estritamente melhor que o O(E log V) do Dijkstra
Três peças, cada uma linear: topological sort leva tempo Θ(V + E) (o próprio limite do concept irmão, via um DFS que visita todo vértice e varre toda lista de adjacência uma vez); inicializar distTo[]/edgeTo[] é Θ(V); e o loop principal faz uma iteração por vértice, e em todas as iterações o loop interno relaxa cada aresta exatamente uma vez, então — pelo mesmo argumento de análise agregada que o CLRS usa para a afirmação de tempo de execução do Teorema 22.5 — a passada inteira custa Θ(E). Total: Θ(V + E).
Compare isso com o O((V + E) log V) do Dijkstra com uma priority queue baseada em binary heap (veja os Trade-offs do concept irmão): Dijkstra paga O(log V) por todo delMin() e todo decreaseKey()/insert(), porque um heap é o preço de deixar o algoritmo escolher dinamicamente "qual vértice está mais perto agora" em um grafo arbitrário. Um DAG não precisa que essa pergunta seja respondida de jeito nenhum — a ordem topológica fixa toda a sequência de processamento de antemão, em tempo linear no tamanho do grafo, então não sobra overhead de heap por aresta para pagar. Sedgewick e Wayne fazem a comparação diretamente: o método baseado em topological sort é mais rápido que o algoritmo de Dijkstra "por um fator proporcional ao custo das operações de priority queue no algoritmo de Dijkstra" — ou seja, por aproximadamente o fator log V.
Essa mesma remoção da priority queue é exatamente o motivo pelo qual pesos negativos deixam de ser um problema. A prova de corretude do Dijkstra precisa de "nenhum vértice não processado jamais consegue superar uma distância já finalizada", e essa afirmação só é verdadeira quando os pesos não podem ser negativos (o contraexemplo do concept irmão mostra precisamente como isso quebra). A prova de corretude do algoritmo de DAG, acima, nunca invoca o sinal dos pesos em lugar nenhum — só usa "toda aresta para v foi relaxada antes de v ser alcançado", um fato que a ordem topológica garante independentemente de quais sejam os pesos. Essa rede de segurança existe só porque um DAG não tem ciclo algum: um ciclo de peso negativo tornaria "caminho mais curto" mal definido (você poderia dar voltas nele para sempre, reduzindo o total a cada vez), mas um DAG não pode conter um por definição, então a questão nunca aparece. Digrafos gerais com pesos negativos e possíveis ciclos precisam de um algoritmo diferente — Bellman-Ford — especificamente para detectar esse caso mal-posto.
Trade-offs
- Exige que o grafo seja genuinamente acíclico — não é uma restrição relaxável, é um pré-requisito rígido. Um único ciclo em qualquer lugar e todo o argumento do Aprofundamento desmorona, porque "toda aresta para
vjá foi relaxada antes devser alcançado" deixa de ser verdade. Grafos que podem conter ciclos (com ou sem pesos negativos) precisam do Bellman-Ford em vez disso, que é um algoritmo diferente, não uma variante deste. - Topological sort é pré-processamento obrigatório, não uma otimização opcional. Se já está sendo computado por outro motivo — detecção de ciclo, resolução de ordem de build, veja o concept irmão de topological-sort — essa passada de shortest path é quase grátis de adicionar depois. Se não está, a ordenação ainda custa apenas
Θ(V + E), então o pipeline combinado continua linear no total. - Caminhos mais longos (análise de caminho crítico / PERT) é um corolário direto, não um algoritmo separado. Copie
AcyclicSP, inicializedistTo[]comDouble.NEGATIVE_INFINITYem vez de infinito positivo, e inverta a desigualdade de relaxamento de>para<. O próprioAcyclicLPde Sedgewick e Wayne é exatamente essa mudança de duas linhas, e é o que o método do caminho crítico usa diretamente para análise de agendamento de projeto: o comprimento do caminho mais longo do início ao fim de um projeto é o tempo mínimo viável de conclusão. - O espaço é O(V) — o mesmo par
distTo[]/edgeTo[]que Dijkstra e Bellman-Ford usam, mas sem priority queue alguma para dimensionar, já que nada é inserido ou extraído de uma em nenhum momento.