Projeto de Algoritmos

"É como procurar agulha num palheiro."

Quantas vezes a palavra algoritmo aparece nesta página?

Busca de palavras em um texto
(string searching)

(Veja os verbetes String searching algorithm e Boyer-Moore string search algorithm na Wikipedia.)

Dizemos que um vetor  a[1..m]  casa com um vetor  b[j..k]  se  a[1] = b[j]a[2] = b[j+1],  … ,  a[m] = b[k];  é claro que devemos ter m-1 = k-j.    Um sufixo de um vetor b[1..k] é qualquer segmento  b[j..k]  do vetor;  é claro que 1 ≤ j ≤ k.    Dizemos que um vetor  a[1..m] ocorre em b[1..n]  se existe k no intervalo m..n tal que

a[1..m] casa com um sufixo de b[1..k].

Exemplos: A figura especifica alguns pares ab.  O sinal ^ marca posições k tais que a casa com um sufixo de b[1..k].

 
  e s p e c i f i c a
A   f i g u r a   e s p e c i f i c a   a l g u n s
^
                  3 1 4 1 5 9                      
3 1 3 1 4 3 1 4 1 3 1 4 1 5 9 3 1 4 1 5 9 2 6 3 1 4
^ ^
                            T A C T A              
G T A G T A T A T A T A T A T A C T A C T A G T A G
^ ^

O problema

Esta página estuda alguns algoritmos para o seguinte problema, conhecido como string matching:

Dados vetores a[1..m] e b[1..n], encontrar o número de ocorrências de a em b.

É conveniente dizer que o vetor a[1..m] é uma palavra e o vetor b[1..n] é um texto.  Nosso problema consiste, então, em contar o número de ocorrências de uma palavra em um texto.

Se m > n então o número de ocorrências é zero. Para garantir que o número de ocorrências seja finito, suporemos sempre que m ≥ 1.

Há duas questões triviais que convém esclarecer desde já. Para procurar a palavra a, podemos varrer o texto b da esquerda para a direita ou da direita para a esquerda. As duas alternativas são equivalentes; mas vamos adotar sempre a primeira: comparar a com b[1..m], depois com b[2..m+1], e assim por diante.

A comparação de a com um sufixo b[1..k] também pode ser feita da esquerda para a direita ou da direita para a esquerda. Em geral, as duas alternativas são equivalentes, mas um dos algoritmos que veremos adiante exige que a comparação seja feita na direção contrária à da varredura do texto. Por isso, adotaremos sempre comparação da direita para a esquerda: primeiro a[m] com b[k], depois a[m-1] com b[k-1], e assim por diante.

O algoritmo trivial

A seguinte função resolve o problema da maneira mais óbvia. Pacientemente, ela tenta casar a com b[1..m], depois com b[2..m+1], e assim por diante. O código supõe que os elementos de a e b são caracteres:

// Recebe vetores a[1..m] e b[1..n] com m >= 1 e n >= 0
// e devolve o número de ocorrências de a em b.

int 
trivial (unsigned char a[], int m,
         unsigned char b[], int n) 
{
   int i, j, k, occors = 0;
   for (k = m; k <= n; ++k) {
      i = m, j = k;
      while (i >= 1 && a[i] == b[j]) 
         --i, --j;   
      if (i < 1) ++occors;
   }
   return occors;
}

A função faz no máximo  m·n  comparações entre elementos de ab e portanto consome tempo proporcional a mn no pior caso.  Desafio: resolver o problema fazendo menos comparações entre a palavra e o texto.

Exercícios

  1. Dê um exemplo em que o algoritmo trivial faz o maior número possível de comparações entre elementos de a e b. Qual é esse número exatamente?

Primeiro algoritmo de Boyer-Moore

Suponha agora que o conjunto a que pertencem todos os elementos de a e de b é conhecido de antemão. Este conjunto é o "alfabeto" do problema. Se o alfabeto é conhecido de antemão, é possível fazer um algoritmo mais rápido que o trivial.

A ideia do algoritmo inventado por R.S. Boyer e J.S. Moore ("A fast string searching algorithm", Communications of the ACM, 20, p.762-772, 1977) é simples. Suponha que acabamos de comparar a com o sufixo apropriado de b[1..k] (o resultado da comparação não importa).  Feito isso, não é necessário comparar a com o sufixo de b[1..k+1]:  podemos tratar imediatamente de

b[1..k+d],

com d escolhido de tal modo que b[k+1] fique emparelhado com a última ocorrência da letra b[k+1] em a.

No exemplo abaixo, marcamos com | as posições que fazem o papel de k+d. No caso em que há casamento, a marca | foi trocada por ^.
X C B A B X C B A A X B C B A B X
| | | | ^ |
B C B A
B C B A
B C B A
B C B A
B C B A
B C B A

Para implementar a ideia, basta fazer um pré-processamento da palavra a que determine, para cada letra l do alfabeto, a posição da última ocorrência de l em a. Esta última posição será denotada por ult[l].  Supondo que o alfabeto é o conjunto de todos os 256 caracteres, teremos

 
  ... > ? @ A B C D E F ...
ult[l]   ... 0 0 0 4 3 2 0 0 0 ...
1 2 3 4
B C B A

 

Segue o código do primeiro algoritmo de Boyer e Moore. O primeiro processo iterativo trata do pré-processamento da palavra e o segundo cuida de contar as ocorrêcias da palavra no texto.

// Recebe vetores a[1..m] e b[1..n] de caracteres,
// com m >= 1 e n >= 0, e devolve
// o número de ocorrências de a em b.

int 
boyermoore1 (unsigned char a[], int m,
             unsigned char b[], int n) 
{
   int ult[256];
   int i, j, k, occors;

   // pré-processamento da palavra a
   for (i = 0; i < 256; ++i)  ult[i] = 0;
   for (i = 1; i <=  m; ++i)  ult[a[i]] = i;

   // busca da palavra a no texto b
   occors = 0;
   k = m;
   while (k <= n) {
      i = m, j = k;
      while (i >= 1 && a[i] == b[j]) 
         --i, --j;   
      if (i < 1) ++occors;
      if (k == n) break;
      k += m - ult[b[k+1]] + 1;
   }
   return occors;
}

Exercícios

  1. Responda rápido: o seguinte fragmento de código funciona corretamente?
       unsigned char i;
       for (i = 0; i < 256; ++i) ult[i] = 0;
    
  2. Prove que a fase de pré-processamento na função boyermoore1 preenche corretamente a tabela ult.
  3. A seguinte variante do código da busca de a em b está correta?
       occors = 0; k = m;
       while (k <= n) {
          i = m, j = k;
          while (i >= 1 && a[i] == b[j]) --i, --j;   
          if (i < 1) ++occors;
          kk = k+1;                                 
          while (kk <= n && ult[b[kk]] == 0) ++kk;  
          if (kk > n) break;                       
          k += m - ult[b[kk]] + kk - k;
       }
       return occors;
    
  4. Analise a seguinte variante da boyermoore1. Mostre que ela é tão boa quanto a versão acima.
       int ult[256];
       int i, j, k, occors;
       for (i = 0; i < 256; ++i)  ult[i] = 0;
       for (i = 1; i <   m; ++i)  ult[a[i]] = i;
       occors = 0;
       k = m;
       while (k <= n) {
          i = m, j = k;
          while (i >= 1 && a[i] == b[j]) 
             --i, --j;   
          if (i < 1) ++occors;
          k += m - ult[b[k]];
       }
       return occors;
    }
    

Segundo algoritmo de Boyer-Moore

O segundo algoritmo de Boyer-Moore, ao contrário do primeiro, não precisa conhecer o alfabeto de antemão. Em compensação, precisa comparar a palavra com o texto da direita para a esquerda: primeiro a[m] com b[k], depois a[m-1] com b[k-1], e assim por diante.

Suponha que já descobrimos que a[h..m] casa com um sufixo de b[1..k].  Suponha também, a título de exemplo, que a[h..m] não casa

com  a[h-1..m-1],  nem com  a[h-2..m-2]  e nem com  a[h-3..m-3].

É fácil deduzir então, sem fazer quaisquer comparações adicionais entre ab, que a[1..m] não casa com o sufixo apropriado

de b[1..k+1],  nem de b[1..k+2],  nem de b[1..k+3].

Portanto, só precisamos verificar o sufixo de b[1..k+4].  Em particular, será preciso verificar se a[h-4..m-4] casa com b[k-m+h..k], e isto é o mesmo que verificar se a[h-4..m-4] casa com a[h..m].

Suponha, para completar o exemplo, que a[1..m] de fato casa com um sufixo de b[1..k+4].  Se h-4 ≥ 1 então a[h..m] casa com um sufixo de a[1..m-4].
k
- - - - - - C B A * C B A - - - - -
 
1 h m
& C B A * C B A
& C B A * C B A

Por outro lado, se h-4 < 1 então a[1..m-4] casa com um sufixo de a[h..m].
k
- - - - C B A * C B A - - - - - - -
 
1 h m
B A * C B A
B A * C B A

 

Este exemplo sugere que a implementação da ideia deve começar com um pré-processamento da palavra a:  para cada h em 1..m, calcule o maior k em 1..m-1 tal que

Na falta de um termo melhor, diremos que esse valor máximo de k é o alcance de h.   Eis alguns exemplos da função alcance:
1 2 3 4 5 6  6 5 4 3 2 1
C A A B A A alcance[h]  5 3 0 0 0 0
1 2 3 4 5 6 7 8  8 7 6 5 4 3 2 1
B A - B A * B A alcance[h]  5 5 2 2 2 2 2 2
1 2 3 4 5 6 7 8 9 10 11 11 10 9 8 7 6 5 4 3 2 1
B A - B A * B A * B A alcance[h]  8  8 8 8 8 2 2 2 2 2 2

 

Podemos examinar agora o código do segundo algoritmo de Boyer-Moore:

// Recebe uma palavra a[1..m] com 1 <= m <= MAXm e
// um texto b[1..n] e devolve o número de ocorrências
// de a em b.

int 
boyermoore2 (unsigned char a[], int m,
             unsigned char b[], int n) 
{
   int alcance[MAXm];
   int occors, h, k, i, j, k;

   // pré-processamento da palavra a
   k = m-1;
   i = m, j = k;
   while (i >= h && j >= 1)
      if (a[i] == a[j]) --i, --j;
      else i = m, j = --k; 
   alcance[h] = k;

   // busca da palavra a no texto b
   occors = 0;
   k = m;
   while (k <= n) {
      i = m, j = k;
      while (i >= 1 && a[i] == b[j]) 
         --i, --j;   
      if (i < 1) ++occors;
      if (i == m) k += 1;
      else k += m - alcance[i+1];
   }
   return occors;
}  

Exercícios

  1. Mostre que a fase de pré-processamento na função boyermoore2 preenche corretamente a tabela alcance.   Mostre que essa fase consome m2 unidades de tempo no pior caso.

Terceiro algoritmo de Boyer-Moore

O terceiro algoritmo de Boyer-Moore é uma fusão dos dois anteriores: a cada passo, o algoritmo escolhe o maior dos deslocamentos ditados pelas tabelas ultalcance.   [Este é o algoritmo de Boyer-Moore propriamente dito. A distinção que fizemos acima entre primeiro e segundo algoritmos é apenas didática.]

O algoritmo consome mn unidades de tempo no pior caso. Mas o pior caso é bem mais raro que o do algoritmo trivial. No caso médio, o terceiro algoritmo de Boyer-Moore consome tempo proporcional a n.  (Ou seja, em média, cada elemento de b é comparado com um número de elementos de a que não depende do valor de m.)

A definição da tabela alcance pode ser aperfeiçoada de tal maneira que o terceiro algoritmo consuma apenas m2+6n unidades de tempo, mesmo no pior caso.

Exercícios

  1. Escreva o código do terceiro algoritmo de Boyer-Moore.
  2. [Projeto de programação]  Implemente e teste o terceiro algoritmo de Boyer-Moore.  Compare, na prática, o desempenho de sua implementação com o do algoritmo trivial.  Invente pares palavra/texto interessantes para fazer os testes.
  3. Investigue as alterações que devem ser feitas na definição da tabela alcance para que o terceiro algoritmo de Boyer-Moore faça apenas 6n comparações entre elementos da palavra e do texto.
  4. Escreva uma função que receba vetores b[1..n], a[1..m], x[1..p] e substitua por x cada ocorrência de a em b.
  5. Suponha que nossa palavra e nosso texto são vetores de caracteres. Suponha que o caractere '#' tem um significado especial dentro da palavra: ele representa 0 ou mais ocorrências de qualquer outro caractere (ou seja, # é um curinga ou wildcard). Exemplos:

    • a palavra "A#B#C" casa com qualquer trecho do texto que comece com 'A', termine com 'C' e tenha um 'B' em algum lugar entre 'A' e 'C';
    • a palavra  "x#[#]#=#;"  casa com  "x[i] = x[i-1] + 1;"  e também com  "x2[i]=x[i-1]+1; y= z;" .

    Escreva uma função que busque uma palavra em um texto interpretando o wildcard # como sugerido acima.

 


Veja o sítio Exact String Matching Algoritms, com animação java de algoritmos de busca de palavras em texto.
Veja também Pattern Matching Pointers.
URL of this site: www.ime.usp.br/~pf/algoritmos/
1998 | Last modified: Tue Dec 6 07:45:36 BRST 2011
Paulo Feofiloff
IME-USP

Valid HTML 4.01 Transitional     Valid CSS!