MAC0122  Princípios de Desenvolvimento de Algoritmos
Home  |   Administração  |   Fórum  |   Livros  |   WWW  |   Diário  |   Tarefas  |   Alunos

 

Tabelas de símbolos e hashing

Esta página é uma versão simplificada do capítulo 11 (Symbol Tables) do livro de Eric Roberts. O capítulo discute tabelas de símbolos (=symbol tables) e o conceito de tabela de dispersão (=hash table).

 

Motivação

Problema:  Determinar o número de ocorrência de cada palavra em um arquivo texto. Em seguida, dizer quantas vezes cada palavra de uma lista de palavras ocorreu no texto.

Por exemplo, considere o seguinte trecho de "The Secret Adversary" de Agatha Christie (o Projeto Gutenberg tem o texto completo do livro):

IT was 2 p.m. on the afternoon of May 7, 1915.  The Lusitania had
been struck by two torpedoes in succession and was sinking
rapidly, while the boats were being launched with all possible
speed. The women and children were being lined up awaiting their
turn. Some still clung desperately to husbands and fathers;
others clutched their children closely to their breasts. One girl
stood alone, slightly apart from the rest. She was quite young,
not more than eighteen.  She did not seem afraid, and her grave,
steadfast eyes looked straight ahead.

Quantas vezes cada palavra da lista abaixo aparece no texto?

children lusitania ship she

Resposta:

children          2
lusitania         1
ship              0
she               2

Um conceito importante para a solução de nosso problema é o de tabela de símbolos.

 

Tabelas de símbolos

Imagine que temos uma grande coleção de objetos, cada um dotado de uma chave (= key) e um valor (= value).  [Acho que seria mais sugestivo usar a palavra "rótulo" no lugar de "chave".]  Em geral,

Nesta página, vamos supor que os valores são do tipo int *.  No problema proposto acima, as chaves são palavras e o valor associado com cada chave é um ponteiro para o número de ocorrências da palavra no texto.

Uma coleção de objetos desse tipo merece o nome de tabela de símbolos (= symbol tabledictionary) se

  1. não tem dois objetos diferentes com a mesma chave e
  2. está sujeta às operações de inserção (= enter) e consulta (= lookup).

A operação de consulta recebe uma chave k e devolve o valor associado a k na tabela (ou devolve a informação de que não há objeto com chave k na tabela).   A operação de inserção recebe uma chave k e um valor v e acrescenta o par (k,v) à tabela (se a tabela já tiver um objeto com chave k, ele será substituído pelo novo par).

 

Primeira implementação da tabela de símbolos

Eis uma implementação simplória de uma tabela de símbolos para resolver nosso problema de contagem de palavras. A tabela residirá em uma lista encadeada com células do tipo cellT:

typedef char *string;

typedef struct cellT {
   string        key;
   int          *value;
   struct cellT *link;
} cellT;

O campo key é uma palavra do texto que estamos analisando. O campo value é o endereço de um inteiro cujo valor é o número de ocorrências de key no texto. Assim, se c é uma variável do tipo cellT* então temos  *(c->value)  ocorrências de  c->key.

///////////////////////////////////////////////////////////
// Implementação simplória de uma tabela de símbolos
// A tabela é representada por um registro do tipo symbtabT.
// O registro contém o endereço de uma lista encadeada de 
// células do tipo cellT.
///////////////////////////////////////////////////////////

typedef struct {
   cellT *list;
} symbtabT;


// A função abaixo coloca na tabela de símbolos t a chave k
// associada com o valor v. Se a tabela já tinha a chave k
// (associada com alguma valor), o valor antigo é substituído
// por v.

void Enter( symtabT *t, string k, int *v) {
   cellT *cp;
   cp = t->list;
   while (cp != NULL && strcmp( cp->key, k) != 0)
      cp = cp->link;
   if (cp == NULL) {
      cp = malloc( sizeof (cellT));
      cp->key = copyString( k);
      cp->link = t->list;
      t->list = cp;
   }
   cp->value = v;
}

// Esta função auxiliar faz uma cópia da string s. O espaço
// para a cópia é alocado por malloc. Esta função foi inspirada
// na CopyString da biblioteca strlib de Eric Roberts.

string copyString( string s) {
   string newstr;
   newstr = malloc( strlen( s) + 1);
   if (newstr == NULL) exit( EXIT_FAILURE);
   strcpy( newstr, s);
   return newstr;
}

// A função abaixo devolve o valor que a tabela t associa com
// a chave k. Se k não estiver associado com valor algum,
// a função devolve NULL.

int *Lookup( symbtabT *t, string k) {
   cellT *cp;
   cp = t->list;
   while (cp != NULL && strcmp( cp->key, k) != 0)
      cp = cp->link;
   if (cp == NULL) return NULL;
   return cp->value;
}

Além das funções "principais" Enter e Lookup, é importante que nossa implementação de tabela de símbolos tenha algumas funções "auxiliares":

// Esta função cria uma tabela de símbolos vazia.

symbtabT *NewSymbolTable( void) {
   symbtabT *t;
   t = malloc( sizeof (symbtabT));
   t->list = NULL;
   return t;
}

// Esta função destrói a tabela de símbolos t.

void FreeSymbolTable( symbtabT *t) {
   cellT *cp, *next;
   cp = t->list;
   while (cp != NULL) {
      next = cp->link;
      free( cp->key);
      free( cp);
      cp = next;
   }
   free( t);
}

Infelizmente, essa implementação da tabela de símbolos é muito ineficiente, pois cada invocação de Enter e Lookup percorre a tabela toda.

 

Contagem de palavras de um texto

Vamos à solução do problema proposto no começo da página. Além da tabela de símbolos discutida acima, nosso programa usa o token scanner que discutimos em outra página.

Observe que o programa de contagem de palavras (que é o "cliente" da tabela se símbolos) faz questão de manipular a tabela de símbolos apenas através das funções

EnterLookupNewSymbolTable  e  FreeSymbolTable.

Em particualr, o programa-cliente faz questão de não se referir diretamente as campos key, value e link da estrutura da tabela se símbolos.

// O programa recebe, via linha de comando, o nome de um
// arquivo que contém um texto e o nome de um arquivo que
// contém uma lista de palavras. O programa faz uma tabela
// do número de ocorrências de cada palavra do segundo
// arquivo no primeiro.

int main( int argc, char *argv[]) {
   FILE *infile, queriesfile;
   string line, token;
   scannerT *scanner;
   symtabT *table;

   scanner = NewScanner();
   table = NewSymbolTable();
   infile = fopen( argv[1], "r");
   if (infile == NULL) exit( EXIT_FAILURE);
   while ((line = readLine( infile)) != NULL) {
      int i;
      for (i = 0; line[i] != '\0'; i++) 
         line[i] = tolower( line[i]);
      SetScannerString( scanner, line);
      while (MoreTokensExist( scanner)) {
         token = GetNextToken( scanner);
         if (isalpha( token[0])) RecordWord( table, token);
      }
   }
   fclose( infile);
   FreeScanner( scanner);
   queriesfile = fopen( argv[2], "r");
   if (queriesfile == NULL) exit( EXIT_FAILURE);
   printf( "Número de ocorrências de palavras no texto:\n");
   AnswerQueries( queriesfile, table);
   fclose( queriesfile);
   return 0;
}


// Esta função registra a palavra word na tabela 
// de símbolos table.

void RecordWord( symbtabT *table, string word) {
   int *v;
   v = Lookup( table, word);
   if (v == NULL) {
      v = malloc( sizeof (int));
      if (v == NULL) exit( EXIT_FAILURE);
      *v = 0;
      Enter( table, word, v);
   }
   (*v)++;
}


// Esta função imprime uma tabela com o número de
// ocorrências de cada palavra do arquivo q na tabela
// de símbolos t.

void AnswerQueries( FILE *q, symbtabT *t) {
   string line, word;
   scannerT *scanner;
   scanner = NewScanner();
   while ((line = readLine( q)) != NULL) {
      int i;
      for (i = 0; line[i] != '\0'; i++) 
         line[i] = tolower( line[i]);
      SetScannerString( scanner, line);
      while (MoreTokensExist( scanner)) {
         word = GetNextToken( scanner);
         if (isalpha( word[0])) {
            int *v, ocurr;
            v = Lookup( table, word);
            ocurr = (v == NULL) ? 0 : *(v);
            printf( "%-15s%5d\n", word, ocurr);
         }
      }
   }
   FreeScanner( scanner);
}


// Essa função auxiliar lê uma linha do arquivo infile e
// devolve a linha na forma de uma string. A linha deve ter
// no máximo 100 caracteres. A função devolve NULL se o
// arquivo infile não tiver mais linhas. Esta função foi
// inspirada na ReadLine da biblioteca simpio de Eric
// Roberts, mas é bem mais simples que aquela.

string readLine( FILE *infile) {
    string line;
    int n, ch;
    n = 0;
    line = malloc( 100+1);
    while ((ch = getc( infile)) != '\n' && ch != EOF) {
       if (n >= 100) {
          free( line);
          exit( EXIT_FAILURE);
       }
       line[n++] = ch;
    }
    if (n == 0 && ch == EOF) {
       free( line);
       return NULL;
    }
    line[n] = '\0';
    return line;
}

 

Exercícios

  1. Acrescente ao programa acima uma função que imprima todas as palavras na tabela de símbolos em ordem lexicográfica juntamente com o valor associado a cada palavra. Sugestão: copie as palavras para um vetor e ordene o vetor.

 

Uma implementação melhor: tabela de dispersão

A implementação da tabela de símbolos que fizemos acima é muito ineficiente pois cada invocação de Enter e Lookup percorre a tabela toda.  Uma implementação bem mais eficiente usa o conceito de tabela de dispersão (= hash table).

Ao invés de uma única lista  t->list  teremos várias listas. Os endereços dessas listas serão armazenadas em um vetor  buckets.  Portanto, o tipo-de-dados  symbtabT  será redefinido assim:

#define NBuckets 101

typedef struct {
   cellT *buckets[NBuckets];
} symbtabT;

Para determinar a particular a lista em que uma dada chave será armazenada, ou seja, para determinar o índice  h  tal que a chave  key  será armazenada na lista  buckets[h],  usaremos a função  Hash.  Essa função

recebe uma chave k e
devolve um número no intervalo fechado [0..NBuckets-1].

É fundamental que Hash seja uma função no sentido matemático do termo, isto é, que para cada chave k ela devolva sempre o mesmo número.  A função será tanto mais útil quando melhor espalhar as chaves pelo intervalo [0..NBuckets-1].

///////////////////////////////////////////////////////////
// Função de dispersão (= hash function)
///////////////////////////////////////////////////////////

#define Multiplier -1664117991L

// A função abaixo calcula um número hashcode a partir de 
// uma string k. O número hashcode calculado pertence ao
// intervalo [0 .. n-1].

int Hash( string k, int n) {
   int i;
   unsigned long hashcode;

   hashcode = 0;
   for (i = 0; k[i] != '\0'; i++) 
      hashcode = hashcode * Multiplier + k[i];
   return hashcode % n;
}

O valor de Multiplier não é crítico para o funcionamento do algoritmo. Mas a experiência mostra que o valor adotado acima contribui para fazer com que o índice calculado por Hash pareça aleatório.  (Algumas pessoas preferem usar um número primo grande como valor de Multiplier.)

Por razões que não vou tentar explicar aqui, o resultado do processo de hashing é melhor (ou seja, produz um índice com aparência aleatória) se a constante

NBuckets  é um número primo.

 

Exercícios

  1. Adapte a implementação da tabela de símbolos dada acima de modo a incorporar a função Hash.  Em seguida, faça vários testes do programa que calcula os números de ocorrências de palavras. Para fazer os testes, você pode usar Faça os testes variando o valor das constantes NBuckets e Multiplier. Para cada combinação de valores dessas constantes, o seu programa deve informar o comprimento mínimo e o máximo das listas t->buckets[i].

  2. Escreva uma função que faça o seguinte: ao receber um número x, devolve o primeiro número primo superior a x.

  3. Suponha que estou planejando um programa que uma tabela de símbolos cujas chaves são números inteiros não-negativos.  Como é possível usar uma implementação da tabela de símbolos que usa strings como chaves?

  4. Suponha que estou planejando um programa que uma tabela de símbolos cujas chaves são números inteiros não-negativos.  Adapte para essa situação a implementação da tabela de símbolos com hashing discutida acima.

  5. Critique a seguinte candidata a função de dispersão:
    int Hash( string k, int n) {
       static int i = 0;
       return (i + 1) % n;
    }
    

  6. O que é uma função de dispersão para uma tabela de símbolos?  Quais os dois aspectos importantes para o funcionamento eficiente da tabela?  Quais os fatores (ou parâmetros) que controlam os dois espectos?

 


Veja minhas anotações sobre tabelas de símbolos e hashing.
URL of this site: www.ime.usp.br/~pf/mac0122-2002/
Last modified: Mon Oct 9 07:14:53 BRT 2017
Paulo Feofiloff
IME-USP