Árvores binárias

Este é um resumo de parte das seções 5.4 (Trees), 5.6 (Tree traversal), 5.7 (Recursive binary-tree algorithms) e 12.5 (Binary Search Trees) do livro do Sedgewick.

Definição

Uma árvore binária (= binary tree) é um conjunto de nós; cada nó tem (a) um certo conteúdo (por exemplo, um número inteiro) e (b) os endereços das raízes de duas subárvores, uma esquerda e uma direita. Veja um exemplo típico de estrutura de nó:

typedef struct node *link;

struct node {
   int  item; // conteúdo do nó
   link l, r; // 'l' de "left" e 'r' de "right"
} ;

(Cuidado: não confunda a letra l com o dígito 1.)  Em alguns contextos, convém trocar a palavra item por chave.

Termos técnicos importantes:  raiz de uma árvore,  filho de um nó, pai de um nó, folha de uma árvore, nó interno de uma árvore, nível de um nó.

Em geral, quando dizemos  um nó x  devemos entender que  x  é o endereço de um nó. Nesses termos, o filho esquerdo de um nó  x  é  x->l  e o filho direito é  x->r. Um nó  x  é uma folha se não tem filhos, ou seja, se  x->l  e  x->r  valem  NULL.

Para ilustrar o conceito de árvore, eis uma pequena função que calcula o número de nós de uma árvore binária (veja programa 5.17 do Sedgewick).

// Esta função devolve o número de nós
// da árvore binária cuja raiz é h.

int count(link h) {
   if (h == NULL) return 0;
   return count(h->l) + count(h->r) + 1;
}

(O h é a inicial de here.)

Exercícios

  1. [Sedg 5.59]  Escreva uma função recursiva que receba uma árvore binária ab e um número x e remova da árvore todas as folhas que tenham item igual a x.

Altura de um nó e altura de uma árvore

A altura (= height) de um nó h em uma árvore binária é a distância entre h e o seu descendente mais afastado. Mas precisamente, a altura de h é o número de links no mais longo caminho que leva de h até uma folha. Os caminhos a que essa definição se refere são os obtidos pela iteração dos comandos  x = x->l  e  x = x->r,  em qualquer ordem.  Exemplo: a altura de uma folha é 0.

A função abaixo calcula a altura de um nó h (veja programa 5.17 do Sedgewick).  Ela dá a resposta correta até mesmo quando h é NULL.

// Devolve a altura de um nó h em uma
// árvore binária.

int height(link h) {
   int u, v;
   if (h == NULL) return -1;
   u = height(h->l);
   v = height(h->r);
   if (u > v) return u+1;
   else return v+1;
}

A altura de uma árvore é a altura de sua raiz.  A altura de uma árvore com N nós pode variar de  lg(N)  até  N-1.  (Como de hábito, lg é uma abreviatura de ⌊log2.)

Exercícios

  1. Escreva uma função não recursiva que calcule o número de nós de uma árvore binária.
  2. Mostre que toda árvore binária com N nós tem altura maior ou igual ao piso de  log2N.
  3. [Profundidade]  A profundidade (= depth) de um nó x em uma árvore binária com raiz h é a distância xh. Mais precisamente, a profundidade de x é o comprimento do (único) caminho que vai de h até x. Por exemplo, a profundidade de h é 0 e a profundidade de h->l é 1.  Escreva uma função que determine a profundidade de um nó dado em relação à raiz da árvore.
  4. Suponha que cada nó da árvore tem um campo depth do tipo int. Preencha o campo de cada nó com a altura do nó.
  5. [Códigos de nós]  Um caminho que vai da raiz de uma árvore até um nó pode ser representado por uma sequência de 0s e 1s:  toda vez que o caminho desce para a esquerda temos um 0; toda vez que desce para a direita temos um 1.  Diremos que essa sequência de 0s e 1s é o código do nó.  Suponha agora que todo nó de nossa árvore tem um campo adicional cod capaz de armazenar uma cadeia de caracteres. Escreva uma função que preencha o campo cod de cada nó com o código do nó.
  6. [Reconstrução]  Suponha dados os códigos de todas as folhas de uma árvore binária. Escreva uma função que reconstrua a árvore a partir desses códigos das folhas.

Como percorrer uma árvore

O seguinte exemplo é uma versão simplificada do programa 5.14 do Sedgewick.  Ele imprime o conteúdo de cada nó da árvore binária cuja raiz é h.

// Imprime o item de cada nó de uma árvore
// binária h que tem nós do tipo node.

void imprime (link h) { 
   if (h == NULL) return;
   printf("%d\n", h->item); 
   imprime(h->l);
   imprime(h->r);
}

Esta função percorre a árvore em ordem raiz-esquerda-direita (= preorder). Se as três últimas instruções forem trocadas por

   imprime(h->l);
   printf("%d\n", h->item); 
   imprime(h->r);

a árvore será percorrida na ordem esquerda-raiz-direita  (= inorder). Se as três últimas instruções forem trocadas por

   imprime(h->l);
   imprime(h->r);
   printf("%d\n", h->item); 

a árvore será percorrida na ordem esquerda-direita-raiz  (= postorder).

Exercícios

  1. Escreva uma função que encontre o nó de uma árvore binária cujo item tem um dado valor.
  2. [Sedg 5.86]  Escreva uma função que calcule o número de folhas de uma árvore binária. Faça três versões: uma que percorra a árvore em inorder, outra que percorra a árvore em preorder e outra que percorra a árvore em postorder.

Versão não recursiva dos algoritmos de percurso

Abaixo temos uma versão simplificada do programa 5.15 de Sedgewick.  Ela recebe uma árvore  h  e imprime o conteúdo de cada nó.  Nossa solução usa as funções de manipulação de pilha que discutimos em outro capítulo. Ela supõe que a árvore não é vazia (ou seja, que h != NULL) e tem 100 nós ou menos (na verdade, basta apenas que a altura da árvore não passe de 100).

// Imprime o item de cada nó de uma árvore 
// binária h. A função supõe que h != NULL
// e que a altura da árvore não passa de 100.

void imprimeB (link h) {
   STACKinit(100);
   STACKpush(h);
   while (!STACKempty()) {
      h = STACKpop();
      printf("%d\n", h->item);
      if (h->r != NULL) STACKpush(h->r);
      if (h->l != NULL) STACKpush(h->l);
   }
}

Esta função percorre a árvore em ordem raiz-esquerda-direita, ou seja, em preorder. Ela usa uma pilha de nós (todos diferentes de NULL) para gerenciar o andamento do algoritmo. Todo nó x na pilha representa a instrução imprima os nós da árvore cuja raiz é x.

No código abaixo, a pilha é implementada em um vetor pilha[0..t], sendo t o índice do topo da pilha:

// Imprime o item de cada nó de uma árvore
// binária h. A função supõe que h != NULL.

void imprimeB (link h) {
   link *pilha;
   int t;

   pilha = malloc((1+height(h)) * sizeof (link))
   pilha[t=0] = h;
   while (t >= 0) {
      h = pilha[t--];
      printf("%d\n", h->item);
      if (h->r != NULL) pilha[++t] = h->r;
      if (h->l != NULL) pilha[++t] = h->l;
   }
   free(pilha);
}

Note que pilha[i] != NULL para todo i entre 0 e t.

Exercícios

  1. [Inorder não recursivo. Sedg 5.82]  Escreva uma versão iterativa da função imprime que percorra a árvore na ordem esquerda-raiz-direita (= inorder).
  2. [Postorder não recursivo. Sedg 5.83]  Escreva uma versão iterativa da função imprime que percorra a árvore na ordem esquerda-direita-raiz (= postorder). (Cuidado!)
  3. Escreva uma função que calcule a soma dos conteúdos (campos item) dos nós de uma árvore binária. Percorra a árvore em ordem esquerda-raiz-direita (= inorder).

Percorrendo uma árvore por níveis

Os nós de uma árvore podem ser percorridos em uma quarta ordem, diferente da raiz-esquerda-direita, da esquerda-raiz-direita e da esquerda-direita-raiz. Para fazer isso, basta usar uma fila no lugar de uma pilha. (Veja programa 5.16 de Sedgewick.)

// Imprime o item de cada nó de uma árvore
// binária h. A função supõe que h != NULL.

void imprime (link h) {
   link *fila;
   int i, f;

   fila = malloc(count(h) * sizeof (link));
   fila[0] = h; 
   i = 0; f = 1;
   while (f > i) {
      h = fila[i++];
      printf("%d\n", h->item);
      if (h->l != NULL) fila[f++] = h->l;
      if (h->r != NULL) fila[f++] = h->r;
   }
   free(fila);
}

A função usa uma fila implementada em um vetor fila[i..f-1]:  o índice do primeiro da fila é i e o índice do último é f-1. Todos os elementos da fila são diferentes de NULL.

Desenho de uma árvore

O programa 5.18 de Sedgewick faz um desenho de uma árvore binária.  A função show supõe que o  item  de cada nó é do tipo  char  e não do tipo  int  como acima.

// A função show faz um desenho esquerda-
// direita-raiz da árvore x. O desenho
// terá uma margem esquerda de 3b espaços.

void show (link x, int b) {
   if (x == NULL) {
      printnode('*', b);
      return;
   }
   show(x->r, b+1);   
   printnode(x->item, b);
   show(x->l, b+1);   
}

// A função auxiliar printnode imprime o
// caracter c precedido de 3b espaços e
// seguido de uma mudança de linha.

void printnode(char c, int b) {
   int i;
   for (i = 0; i < b; i++) printf("   ");
   printf("%c\n", c);
}

Eis uma amostra do resultado de  show(x,0).  Troquei os espaços em branco por - para facilitar a leitura.

   ------*              
   ---H
   ------------*
   ---------G
   ------------*
   ------F
   ---------*
   E
   ------*
   ---D
   ------------*
   ---------C
   ------------*
   ------B
   ------------*
   ---------A
   ------------*

Eis o resultado da impressão da mesma árvore em ordem raiz-esquerda-direita. Troquei os espaços em branco por - para facilitar a leitura.

   E                      
   ---D
   ------B
   ---------A
   ------------*
   ------------*
   ---------C
   ------------*
   ------------*
   ------*
   ---H
   ------F
   ---------*
   ---------G
   ------------*
   ------------*
   ------*

Para obter isso, troquei os três últimos comandos de show por

   printnode(x->item, b);
   show(x->r, b+1);   
   show(x->l, b+1);   

Construção de um torneio

Diremos que uma árvore binária é um torneio se cada nó que não seja uma folha tem dois filhos e contém uma cópia do maior dos  items  de seus dois filhos. O programa 5.19 de Sedgewick ilustra a construção de um torneio:

// A função max recebe um vetor não vazio
// a[p..q] (portanto p <= q) e constrói
// um torneio cujas folhas são a[p],..,a[q].
// A função devolve a raiz do torneio.

link max (int a[], int p, int q) {
   int m, u, v;
   link x;

   m = (p + q) / 2;
   x = malloc(sizeof *x); 
   if (p == q) {
      x->l = x->r = NULL;
      x->item = a[m];
      return x;
   }
   x->l = max(a, p, m);
   x->r = max(a, m+1, q);
   u = x->l->item;
   v = x->r->item;
   if (u > v)  x->item = u;
   else  x->item = v;
   return x;
}

Compare com o programa 5.6 de Sedgewick.

Exercícios

  1. Aplique a função max acima ao vetor  1  2  3  4  5 .
  2. [Sedg 5.91]  Escreva uma função recursiva que remova de um torneio todas as folhas cujo item tenha um dado valor. (Veja acima o exercício Sedg 5.59.)
  3. Escreva uma função que construa uma árvore binária aleatória com n nós e valores aleatórios dos itens.

Árvore de expressão aritmética

Considere agora mais um exemplo de construção de árvore binária. Desta vez, a árvore representará uma expressão aritmética.

Suponha que temos uma expressão aritmética cujos operadores são todos binários. Mais concretamente, suponha que os operadores são soma (+) e multiplicação (*). Suponha também, para simplificar, que os operandos são nomes de variáveis e cada um consiste em uma única letra do alfabeto ASCII. Uma expressão aritmética pode ser muito bem representada por uma árvore binária: as folhas da árvore são os operandos e os nós internos são os operadores.

        *
      /   \
     +     f
   /   \
  a     *
      /   \
     *     +
    / \   / \
   b   c d   e

Se a árvore for lida em ordem esquerda-raiz-direita, teremos a expressão em notação infixa. Se for lida em ordem esquerda-direita-raiz, teremos a expressão em notação posfixa. Se for lida em ordem raiz-esquerda-direita-raiz, teremos a expressão em notação prefixa.

infixa   (a+(b*c)*(d+e))*f
posfixa   abc*de+*+f*
prefixa   *+a**bc+def

O programa 5.20 de Sedgewick, faz o serviço inverso: transforma a expressão prefixa (não vazia, é claro) em uma árvore binária. Se a expressão consiste em uma única letra, a árvore terá um único nó; se a expressão for algo como  *ab, a árvore terá uma raiz e duas folhas.

Suponha que a expressão prefixa está armazenada em um vetor global de caracteres  a[i..],  sendo i uma variável global.

typedef struct Tnode *link;
struct Tnode {
   char token;
   link l, r;
} ;

char *a; 
int i;
// A função parse atua sobre a expressão
// prefixa a[i..]. Os operadores são '+'
// e '*', cada variável tem um só caracter,
// e não há espaços entre os caracteres.
// A função transforma a expressão em uma
// árvore binária e devolve a raiz da árvore.

link parse () {
   char t;
   link x;

   t = a[i++];
   x = malloc(sizeof *x); 
   x->token = t;
   if (t == '+' || t == '*') {
      x->l = parse();
      x->r = parse();
   }
   else x->l = x->r = NULL;
   return x;
}

Compare com o programa 5.4 de Sedgewick.

Exercícios

  1. Escreva uma função que calcule o valor da expressão aritmética representada por uma árvore. Suponha que os valores das variáveis são dados em um vetor do tipo int indexado por letras do alfabeto ASCII.
  2. Escreva uma função que receba uma expressão aritmética em notação infixa e construa a correspondente árvore. Suponha que a expressão só envolve os operadores '+' e '*' e operandos que consistem em uma só letra do alfabeto ASCII. A página sobre pilhas pode ser útil.

Valor de uma expressão aritmética

O capítulo 14 do livro de Roberts discute a implementação de um processador de expressões aritméticas. Vamos extrair daquele capítulo apenas

Nossas expressões aritméticas admitem os operadores  =+-*/  e admitem operandos que podem ser números inteiros, nomes de variáveis e, é claro, sub-expressões. Exemplo:

     =
   /   \
  y     *
      /   \
     3     +
         /   \
        -    abc
       / \
      6  de

Eis a declaração do tipo de uma expressão:

typedef char *string;

// Type: expression
// ----------------
// This type is used to represent the abstract notion
// of an expression, such as one you might encounter
// in a C program. An expression is defined recursively
// to be one of the following:
//    1. A constant
//    2. A string representing the name of a variable
//       in the ASCII alphabet.
//    3. Two expressions combined by an operator
//
typedef struct node *expression

// Type: exptype
// -------------
// This enumeration type is used to differentiate the
// three expression types: constants, variables, and
// subexpressions.
//
typedef enum {Constant, Variable, Subexpression} exptype;

Para representar os nós da árvore vamos usar uma estrutura que envolve um union (da linguagem C):

// Type: node
// ----------
// An expression is represented as tree. The contents
// of each node consists of a tagged union that allows
// the node to have multiple interpretations.
//
struct node {
   exptype type;
   union {
      int constRep;      // a constant
      string varRep;     // name of a variable
      struct {
         char       op;  // '=' or '+' or '-' or '*' or '/'
         expression lhs; // left subexpression
         expression rhs; // right subexpression
      } subexpRep;
   } contents;
} ;

Finalmente, eis as funções que calculam o valor de uma expressão:

// Function: EvalExp
// Usage: value = EvalExp(exp);
// ---------------------------
// Returns the value of the expression exp. (The
// function assumes that the values of all variables
// have been already loaded into the appropriate table.)
//
int EvalExp (expression exp) {
   switch (exp->type) {
      case Constant:
         return exp->contents.constRep;
      case Variable:
         return GetVariableValue(exp->contents.varRep);
      case Subexpression:
         return EvalSubExp(exp);
   }
}

// Returns the value of the subexpression exp. (The
// values of all variables must have been already
// loaded into the appropriate table.)
//
static int EvalSubExp (expression exp) {
   char op;
   expression leftexp, rightexp;
   int leftval, rightval;
   op = exp->contents.subexpRep.op;
   leftexp = exp->contents.subexpRep.lhs;
   rightexp = exp->contents.subexpRep.rhs;
   if (op == '=') {
      rightval = EvalExp(rightexp);
      SetVariableValue(leftexp->contents.varRep, rightval);
      return rightval;
   }
   leftval = EvalExp(leftexp);
   rightval = EvalExp(rightexp);
   switch (op) {
      case '+': return leftval + rightval;
      case '-': return leftval - rightval;
      case '*': return leftval * rightval;
      case '/': return leftval / rightval;
   }
}

// Prototypes of auxiliary functions:

// Returns the value of variable var.
int GetVariableValue(string var) ;

// Sets the value of variable var to val.
int SetVariableValue(string var, int val) ;

(O par de funções (EvalExp, EvalSubExp) é mutuamente recursivo.)

 


Mais exercícios

  1. Escreva uma função que receba um vetor a[1..n], interprete esse vetor como um heap, e construa a correspondente árvore binária.
  2. [Sedg 12.54]  O comprimento interno de uma árvore binária é a soma dos comprimentos dos caminhos que levam da raiz a cada uma das folhas. Escreva um programa recursivo que calcule o comprimento interno de uma árvore binária dada.
  3. [Sedg 12.63]  Árvores binárias podem ser implementadas com índices no lugar de ponteiros, da seguinte maneira:  use três vetores paralelos, item[1..N], l[1..N] e r[1..N];  para cada índice i, armazene em l[i] o índice do filho esquerdo de i e em r[i] o índice do filho direito.

    Reescreva todas as funções deste capítulo para a implementação que acabamos de sugerir.  (Essa implementação tem certas vantagens porque reduz o tempo consumido pelas sucessivas chamadas de malloc durante a construção da árvore. Mas exige que o número total de nós seja conhecido antes que a árvore comece a ser construída.)

 


Compressão de arquivos

Um arquivo de texto nada mais é que uma cadeia de caracteres (= string). Para simplificar, vamos nos restringir ao conjunto ASCII de caracteres. Suponha dada uma cadeia de caracteres, digamos

bafeabacaadefa

Cada caracter dessa cadeia é representado por 8 bits na tabela ASCII:

 
caracter
  
código
ASCII
  
 
bits
a 97 01100001
b 98 01100010
c 99 01100011
d 100 01100100
e 101 01100101
f 102 01100110

Portanto, a cadeia como um todo é representada pela seguinte sequência de 112 bits (a sequência foi dividida em três linhas para que coubesse na tela):

0110001001100001011001100110010101100001011
0001001100001011000110110000101100001011001
00011001010110011001100001

Agora adote uma codificação com número variável de bits — poucos bits para as letras mais frequentes e muitos bits para as letras raras:

caracter    bits
a 0
b 101
c 100
d 111
e 1101
f 1100

Com isso, podemos representar nossa cadeia de caracteres por uma sequência de apenas 34 bits:

1010110011010101010000111110111000

Note que não temos separadores entre as subsequências que representam os caracteres. Apesar disso, a sequência de bits pode ser decodificada sem ambiguidade. Essa é uma propriedade valiosa de nossa tabela de códigos. A propriedade decorre do seguinte fato: o código pode ser representado por uma árvore cujas folhas são os caracteres da cadeia:

      .         
    /   \       
   a     .      
       /   \    
     .       .  
    / \     / \ 
   c   b   .   d
          / \   
         f   e  

Para determinar o código de um caracter x, comece na raiz e caminhe até x; toda vez que descer para a esquerda, acrescente um 0 ao código de x; toda vez que descer para a direita, acrescente um 1.  (Veja exercício sobre códigos de nós.)

Problema: Dada uma cadeia de caracteres, construir uma tabela de codificação que represente a cadeia usando o menor número possível de bits.

Eis um algoritmo que resolve o problema. Seja X o conjunto dos caracteres e digamos que cada caracter x de X ocorre n(x) vezes na cadeia de caracteres. Construa, como especificarei a seguir, uma árvore binária em que os items dos nós são números inteiros. Comece com um nó para cada caracter x e atribua n(x) ao item do nó. Coloque os nós em ordem crescente de item. Sejam x e y os dois primeiros nós da lista. Faça com que x e y sejam os filhos de um novo nó z. O item do novo nó será a soma dos items de xy. Retire x e y de X e acrescente o nó zX. Repita o processo até que tenhamos uma árvore.  Essa é a árvore de Huffman da cadeia de caracteres dada.

Exemplo: Suponha que nossa cadeia só contém os caracteres a, b, c, d, e, f e que o número de ocorrências de cada caracter é dado pela tabela a seguir. Se aplicarmos o algoritmo a essa tabela, teremos a árvore da figura acima.

x    a  b  c  d  e  f
n(x)    45  13  12  16   9   5

Exercício: Escreva uma função que receba uma cadeia de caracteres e construa uma árvore de Huffman para essa cadeia.

Veja também o exercício sobre reconstrução da árvore de códigos.

(O material sobre codificação e compressão de arquivos pode ser encontrado no capítulo 22 da 2-a edição do livro do Sedgewick. Também pode ser encontrado no livro Introduction to Algorithms de Cormen, Leiserson, Rivest e Stein.)

 


Veja minhas notas de aula sobre árvores binárias.  Veja também os excelentes capítulos 13 e 14 do livro de Roberts.
URL: www.ime.usp.br/~pf/mac0122-2002/
Atualizado em 2017-11-01
© Paulo Feofiloff
IME-USP