Algoritmos para Grafos  |  Índice

Algoritmo de Kosaraju para componentes fortes

Em muitas aplicações é possível (e muito proveitoso) processar cada componente forte de um grafo em separado. Por isso, a habilidade de encontrar as componentes fortes rapidamente é muito útil.

S. Rao Kosaraju e M. Sharir descobriram (em 1978 e 1981 respectivamente) um algoritmo muito eficiente para calcular as componentes fortes de um grafo.  Seria justo usar a expressão algoritmo de Kosaraju-Sharir para designar o algoritmo, mas vamos simplificar e dizer apenas algoritmo de Kosaraju.

Sumário:

O algoritmo

É tentador imaginar que cada etapa de uma busca em profundidade revela uma componente forte do grafo.  Infelizmente, isso só é verdade se o vértice inicial de cada nova etapa for escolhido de uma maneira especial. Para fazer essa escolha especial, o algoritmo de Kosaraju começa por colocar os vértices numa certa ordem, determinada por uma busca em profundidade preliminar. O algoritmo pode ser descrito assim:

  1. Ao receber um grafo G, inverta a direção de cada arco, obtendo assim o grafo reverso GR.  Em seguida, faça uma busca DFS em GR para calcular uma permutação em pós-ordem, digamos  v0 v1 v2vn−1, dos vértices de GR.
  2. Faça uma busca DFS em G examinando os vértices na ordem  vn−1 v2 v1 v0  para iniciar cada nova etapa da busca.  Em cada etapa da busca, o conjunto dos vértices descobertos induz uma componente forte de G.

O passo 1 é conhecido como primeira fase do algoritmo; o passo 2 constitui a segunda fase.  A segunda fase é semelhante ao algoritmo de eliminação iterada de sorvedouros, que decide se um grafo tem ciclos.

Exemplo A.  Considere o grafo G definido pelas listas de adjacência abaixo. Note que G é um dag.

0:  3 4  
1:  3  
2:  4 7  
3:  5 6 7   
4:  6    
5:      
6:    
7:  

Veja as listas de adjacência do grafo reverso GR:

0:  
1:  
2:  
3:  0 1
4:  0 2
5:  3
6:  3 4
7:  2 3

Faça uma busca DFS em GR. A floresta DFS consiste em oito vértices isolados. Veja a numeração dos vértices de GR em pós-ordem:

     v   0 1 2 3 4 5 6 7
post[v]  0 1 2 3 4 5 6 7

(Essa numeração dos vértices é anti-topológica pois GR é um dag.)  Agora faça uma busca DFS no grafo original G, tomando os vértices na ordem  7 6 5 4 3 2 1 0  ao escolher o vértice inicial de cada nova etapa.  Como G é um dag, cada etapa descobre apenas um vértice.

Exemplo B.  Seja G o grafo definido pelas listas de adjacência abaixo. (Trata-se de um dag isomorfo ao do exemplo A.)

0:  
1:  
2:  
3:  1
4:  0 1 2    
5:  0 3    
6:  3 4  
7:  4

A seguir, veja as listas de adjacência do grafo reverso GR e uma floresta DFS em GR (você deve imaginar que todos os arcos da figura estão dirigidos de cima para baixo).

    0      1    2 
   / \     |      
  4   5    3      
 / \              
6   7             
0:  4 5
1:  3 4
2:  4
3:  5 6 
4:  6 7
5:  
6:  
7:  

Veja a numeração dos vértices de GR em pós-ordem:

     v   6 7 4 5 0 3 1 2
post[v]  0 1 2 3 4 5 6 7

Agora faça uma busca DFS no grafo original G, examinando os vértices na ordem  2 1 3 0 5 4 7 6  ao escolher o vértice inicial de cada nova etapa.  (Essa permutação dos vértices de G é topológica pois G é um dag.)  Cada etapa descobre apenas um vértice, como era de se esperar uma vez que G é um dag.

Exemplo C.  Seja G o grafo definido pelas listas de adjacência abaixo (o mesmo grafo do exemplo A do capítulo Algoritmo de Tarjan, mas com nomes alfabéticos para os vértices).

a:  b g  
b:  c f  
c:  b d  
d:  e    
e:  d    
f:  c    
g:  h i  
h:  d g j
i:  h    
j:       

A seguir, veja as listas de adjacência do grafo reverso GR e uma floresta DFS de GR (você deve imaginar que todos os arcos da figura estão dirigidos de cima para baixo).

a     b     d     j 
     /     / \      
    c     e   h     
   /         / \    
  f         g   i   
a:  
b:  a c
c:  b f
d:  c e h
e:  d
f:  b
g:  a h
h:  g i
i:  g
j:  h

Veja a numeração dos vértices de GR em pós-ordem:

     v   a f c b e g i h d j
post[v]  0 1 2 3 4 5 6 7 8 9
 j    d     h     b    a 
     /     /     / \     
    e     g     c   f    
         /               
        i                

Agora veja uma busca DFS no grafo original G, com os vértices considerados na ordem  j d h i g e b c f a  ao escolher o início de cada nova etapa. Observe que cada árvore da floresta DFS corresponde a uma componente forte de G.

Exercícios 1

  1. Para que serve o algoritmo de Kosaraju?  Que problema resolve?
  2. Grafo reverso.  O reverso de um grafo G é o resultado da troca de cada arco v-w de G por um arco w-v. Escreva uma função GRAPHreverse() que construa o reverso de um grafo G
  3. Componentes fortes do grafo reverso.  Seja GR o reverso de um grafo G, isto é, o grafo que se obtém quando cada arco v-w é substituído por um arco w-v. Mostre que as relações de ligação forte em G e GR têm exatamente as mesmas classes de equivalência.
  4. ★ Seja G um dag. Seja v0 v1 v2vn−1 uma permutação em pós-ordem dos vértices do reverso de G.  Mostre que essa permutação é anti-topológica no reverso de G e topológica em G.
  5. Mais simples?  O algoritmo de Kosaraju calcula uma permutação em pós-ordem, digamos L1, dos vértices do grafo reverso e depois processa o grafo original na ordem inversa de L1.  Não seria mais simples evitar essas duas inversões e fazer o seguinte:  calcular a permutação em pós-ordem, digamos L0, dos vértices do grafo original e depois processar o grafo original na ordem L0?  (Sugestão: Considere o grafo definido pelos arcos  0-1 1-2 2-3 3-0 4-0 5-3 1-6 2-7 , que consiste em um ciclo com quatro pernas.)
  6. [Sedgewick 19.124] Ciclos e caminhos.  Aplique o algoritmo de Kosaraju ao grafo  0-1 1-2 2-3 3-4 4-5 5-6 6-7 7-8 8-9 9-0.  Aplique o algoritmo de Kosaraju ao grafo  0-1 1-2 2-3 3-4 4-5 5-6 6-7 7-8 8-9.
  7. ★ Que acontece se trocarmos a segunda busca DFS do algoritmo de Kosaraju por uma busca em largura, ou por qualquer outro tipo de busca que numere, em alguma ordem, todos os vértices ao alcance de um vértice inicial?
  8. Variante.  A seguinte variante do algoritmo de Kosaraju está correta?   (a) Faça uma busca DFS em G para determinar uma permutação v0 v1vn−1 dos vértices de G em pós-ordem.   (b) Inverta a direção de cada arco de G, obtendo assim o grafo reverso GR.   (c) Faça uma busca DFS em GR, examinando os vértices na ordem  vn−1v1 v0  ao escolher o vértice inicial de cada nova etapa da busca.

Implementação

Nossa implementação do algoritmo de Kosaraju calculará uma numeração dos vértices que identifique a componente forte a que cada vértice pertence. Essa numeração será armazenada num vetor sc[0..V-1]. (O nome do vetor é uma abreviatura de strong component.)  Portanto, sc[v] valerá k se v estiver na k-ésima componente forte.

static int cnt, pre[1000];
static int cntt, post[1000];
static vertex vv[1000];
/* Esta função atribui um rótulo sc[v]
   (os rótulos são 0,1,2,...)
   a cada vértice v do grafo G de modo que dois vértices 
   tenham o mesmo rótulo
   se e somente se os dois pertencem à mesma componente forte.
   A função devolve o número (quantidade) de
   componentes fortes de G.
   (A função implementa o algoritmo de Kosaraju.
   O código é adaptado do Programa 19.10 de Sedgewick.) */
int GRAPHstrongCompsK( Graph G, int *sc) 
{
   // fase 1:
   Graph GR = GRAPHreverse( G);
   cnt = cntt = 0;
   vertex v; 
   for (v = 0; v < GR->V; ++v) pre[v] = -1;
   for (v = 0; v < GR->V; ++v) 
      if (pre[v] == -1)
         dfsR( GR, v); 
   for (v = 0; v < GR->V; ++v) 
      vv[post[v]] = v;
   // vv[0..V-1] é permutação de GR em pós-ordem

   // fase 2:
   for (v = 0; v < G->V; ++v) sc[v] = -1;
   int k = 0;
   for (int i = G->V-1; i >= 0; --i) {
      v = vv[i];
      if (sc[v] == -1) { // nova etapa
         dfsRstrongCompsK( G, v, sc, k);
         k++;
      }
   }
   GRAPHdestroy( GR);
   return k;
}
/* Constrói o reverso do grafo G. */
Graph GRAPHreverse( Graph G) 
{
   Graph GR = GRAPHinit( G->V);
   for (vertex v = 0; v < G->V; ++v) 
      for (link a = G->adj[v]; a != NULL; a = a->next)
         GRAPHinsertArc( GR, a->w, v);
   return GR;
}

Na fase 1, o algoritmo faz uma busca DFS no grafo GR a partir do vértice v:

/* Armazena no vetor post[]
   uma numeração em pós-ordem do grafo GR.
   O vetor pre[] marca os vértices já descobertos. */
static void dfsR( Graph GR, vertex v) 
{ 
   pre[v] = cnt++; 
   for (link a = GR->adj[v]; a != NULL; a = a->next)
      if (pre[a->w] == -1) 
         dfsR( GR, a->w); 
   post[v] = cntt++;
}

No fim da fase 1, a numeração post[] é transformada na correspondente permutação vv[] dos vértices.  A fase 2 faz uma busca DFS no grafo G. Ao passar de uma etapa da busca para outra, os vértices são considerados na ordem inversa de vv[].

/* Atribui o rótulo k
   a todo vértice w
   ao alcance de v
   que ainda não foi rotulado.
   Os rótulos são armazenados no vetor sc[]. */
static void dfsRstrongCompsK( Graph G, vertex v, int *sc, int k) 
{ 
   sc[v] = k;
   for (link a = G->adj[v]; a != NULL; a = a->next)
      if (sc[a->w] == -1) 
         dfsRstrongCompsK( G, a->w, sc, k);
}

Exemplo D.  Considere a aplicação da função GRAPHstrongCompsK() ao grafo G definido pelas listas de adjacência abaixo (o grafo é o mesmo do exemplo B do capítulo Algoritmo de Tarjan).

0:  1
1:  2
2:  3 5
3:  4
4:  3
5:  1 3

A seguir, veja as listas de adjacência do grafo reverso GR e uma floresta DFS de GR.  A correspondente permutação dos vértices de GR em pós-ordem é  0 2 5 1 4 3.

0     1     3 
     /     /  
    5     4   
   /          
  2           
0:  
1:  0 5
2:  1
3:  2 4 5
4:  3
5:  2
   3     1    0 
  /     /       
 4     2        
      /         
     5          

Agora veja o resultado de uma busca DFS no grafo original G, com os vértices examinados na ordem  3 4 1 5 2 0  ao escolher o vértice inicial de cada nova etapa. Cada árvore dessa floresta DFS corresponde a uma componente forte de G.

Sedgewick-Wayne/strong-comps-G.png

Exemplo E.  Considere a aplicação da função GRAPHstrongCompsK() ao grafo G da figura à direita.  A figura seguinte mostra o grafo reverso GR. Faça uma busca DFS em GR tomando os vértices em ordem em crescente de nomes (poderia ter tomado qualquer outra ordem).  Isso produz o seguinte vetor post[]:

     v    0  1  2  3  4  5  6  7  8  9 10 11 12
post[v]  11 12 10  9  8  0  3  2  1  6  4  7  5
Sedgewick-Wayne/strong-comps-reverse-G.png

e portanto a seguinte permutação dos vértices em pós-ordem:

    5 8 7 6 10 12 9 11 4 3 2 0 1

A fase 1 do algoritmo está concluída. Agora faça uma busca DFS no grafo original G tomando os vértices na ordem inversa à da permutação acima.  A primeira etapa da busca começa no vértice 1 e descobre apenas o vértice

    1

Esse vértice induz a primeira componente forte.   A segunda etapa da busca começa com o vértice 0 e descobre o conjunto de vértices

    0 5 4 2 3

que induz a segunda componente forte.   A terceira etapa começa com o vértice 11 e descobre o conjunto de vértices

   11 12 9 10

que induz a terceira componente forte.   A quarta etapa começa com o vértice 6 e descobre o vértice

    6
figs/Sedgewick-Wayne/SCCexample-x.png

e nada mais. Esse vértice induz a quarta componente forte.   A quinta etapa começa com o vértice 7 e descobre os vértices

    7 8

que induzem a quinta componente forte.

Exercícios 2

  1. Repita o exemplo E tomando os vértices em ordem decrescente de nomes e supondo que as listas de adjacência estão em ordem decrescente de nomes.
  2. Instâncias extremas.  A função GRAPHstrongCompsK() está correta se G for fortemente conexo? E se for um dag? E se tiver um só vértice? E se não tiver aresta alguma?  Justique suas respostas a partir do código da função.
  3. [Sedgewick 19.127] Aplique a função GRAPHstrongCompsK() ao grafo definido pelo conjunto de arcos  3-7 1-4 7-8 0-5 5-2 3-8 2-9 0-6 4-9 2-6 6-4. Faça o rastreamennto da execução da função.
  4. Suponha que uma etapa de GRAPHstrongCompsK() começa com um certo vértice vv[i]. É verdade que o conjunto de vértices descoberto por dfsR(G, vv[i]) é  vv[i] vv[i+1] vv[i+2] ... vv[j]  para algum j < G->V?
  5. [Sedgewick 19.128]  Versão para matriz de adjacências.  Escreva uma versão do algoritmo de Kosaraju para grafos representados por matriz de adjacências. Note que não é preciso construir o grafo reverso GR, pois a transposta da matriz de adjacências de G representa GR.
  6. Numeração topológica das componentes fortes.  A fase 2 da função GRAPHstrongCompsK() atribui números (0,1,2, etc.) às componentes fortes do grafo G e grava os números no vetor sc[]. Mostre que essa é uma numeração anti-topológica do grafo das componentes fortes de G.
  7. Considere o vetor sc[] das componentes fortes calculado pela função GRAPHstrongCompsK().  Seja x-y um arco do grafo tal que sc[x] ≠ sc[y].  Mostre que sc[x] > sc[y].
  8. Testes em dags.  Faça um programa para testar GRAPHstrongCompsK().  Use grafos topológicos aleatórios para fazer testes. O seu programa deve verificar se cada componente do dag tem um só vértice.  Invente outros testes interessantes cujo resultado você possa verificar.
  9. Testes.  Faça um programa para testar GRAPHstrongCompsK().  Use grafos aleatórios de vários tamanhos para fazer os testes. Compare os resultados de GRAPHstrongCompsK() com os resultados de um algoritmo ingênuo, como o sugerido no fim do capítulo Componentes fortes.  (Um algoritmo ingênuo é fácil de programar corretamente e por isso é um ótimo padrão de referência durante a implementação e testes de algoritmos mais sofisticados.)  Aproveite para cronometrar GRAPHstrongCompsK() e o algoritmo ingênuo.

Desempenho

Quanto tempo a função GRAPHstrongCompsK() consome?  Como o algoritmo consiste essencialmente em uma inversão do grafo e duas buscas em profundidade, o consumo de tempo é da ordem de V + A, sendo V o número de vértices e A o número de arcos do grafo G.  (Se AV, como acontece em muitas aplicações, podemos dizer que o consumo de tempo é proporcional a A.)

O algoritmo de Kosaraju é, portanto, linear no tamanho do grafo.

Exercícios 3

  1. Alocação dinâmica.  No código de GRAPHstrongCompsK() acima, os vetores sc[], pre[], post[], vv[] são alocados estaticamente. Reescreva o código usando alocação dinâmica.  O código também pode ser ligeiramente melhorado se dispensarmos post[] e calcularmos vv[] diretamente.
  2. Atualização da biblioteca.  Depois de resolver o exercício Alocação dinâmica, acrescente GRAPHstrongCompsK(), dfsR() e dfsRstrongCompsK() à biblioteca GRAPHlists que mencionamos no capítulo Estruturas de dados para grafos.  Também acrescente as versões apropriadas à biblioteca GRAPHmatrix.

Por que o algoritmo está correto?

Não é nada óbvio que o algoritmo de Kosaraju produz o resultado que promete.  A prova da correção do algoritmo tem por base a seguinte relação entre as componentes fortes de um grafo e a permutação dos vértices do grafo em pós-ordem:

Propriedade da Pós-Ordem: Seja S o conjunto de vértices de uma componente forte de um grafo H e seja v0 v1vn−1 uma permutação dos vértices de H em pós-ordem. Para todo vértice r fora de S, se existe um arco de rS então r aparece em v0 v1vn−1 depois de todos os vértices de S.

Eis um esboço da prova da propriedade: Considere a busca DFS em H que produziu a permutação v0 v1vn−1. Suponha primeiramente que r é descoberto antes de qualquer vértice de S. Então todos os vértices de S são descobertos e morrem durante o intervalo de vida de r. Logo, r é ancestral de todos os vértices de S.  Suponha agora que r é descoberto depois de algum vértice de S. Como r não está ao alcance de nenhum dos vértices de S, todos os vértices de S são descobertos e morrem antes de r. Portanto, r é primo direito de todo vértice de S.

Prova da correção do algoritmo.  Para provar que o algoritmo de Kosaraju está correto, basta mostrar que cada etapa da fase 2 do algoritmo produz uma nova componente forte do grafo G.  É preciso lembrar que na fase 2 do algoritmo temos uma permutação em pós-ordem v0 v1vn−1 dos vértices do grafo reverso GR e que a fase 2 processa os vértices de G na ordem inversa dessa permutação.  Cada etapa da fase 2 consiste em uma busca DFS em G a partir do vértice não descoberto de maior índice. Digamos que esse vértice é  vi  e seja S o conjunto de vértices da componente forte que contém vi. Precisamos provar que a busca a partir de vi descobre todos os vértices de S e somente esses. Eis as duas partes da prova:

  1. É claro que cada vértice de S está ao alcance de vi.  Além disso, podemos supor, a título de hipótese de indução, que nenhum vértice de S foi descoberto antes do início dessa etapa. Portanto, a busca a partir de vi descobrirá todos os vértices de S.
  2. Suponha agora, para obter uma contradição, que a busca a partir de vi descobre algum vértice que não pertence a S. Seja vk o primeiro tal vértice. Então algum arco do grafo vai de Svk.  No grafo reverso GR, o conjunto S induz uma componente forte e existe um arco de vkS. Assim, se tomarmos H = GR na Propriedade da Pós-Ordem, concluiremos que  k > i.  Mas isso é impossível, pois todos os vértices do conjunto {vi+1 vi+2vn−1} foram descobertos antes do início da etapa corrente. Essa contradição mostra que todos os vértices descobertos a partir de vi pertencem a S.

Provamos assim que o algoritmo de Kosaraju está correto.

Exercícios 4

  1. Discuta os detalhes da prova da Propriedade da Pós-Ordem.
  2. Mostre que a Propriedade da Pós-Ordem pode ser formulada assim: Seja v-w um arco de um grafo H e suponha que não existe caminho de wv. Mostre que post[v] > post[w], sendo post[] uma numeração dos vértices de H em pós-ordem.  Prove essa propriedade diretamente, sem deduzí-la da Propriedade da Pós-Ordem.
  3. Que aparência tem a matriz da adjacência do transposto de um grafo?  (Esse exercício revela a origem da termo transposto). Escreva uma rotina Inverta que receba as listas de adjacência Adj de um grafo e produza as listas de adjacência do grafo transposto.
  4. Seja H o transposto de um grafo G. Mostre que G e H têm o mesmo conjunto de componentes fortes.