Você está na página 1de 16

Ponteiros

A informação que acessamos em uma variável está armazenada em algum lugar da memória do computador. O nome
da variável é apenas um rótulo conveniente para acessarmos este lugar.

Quando fazemos

int x = 10;
double y = 12.34;

estamos pedindo ao compilador que aloque, por toda a duração da execução da função em que são declaradas, 2
bytes fixos para guardar um inteiro, que será acessado pelo nome de “x” e 4 bytes fixos para um double, que será
acessado pelo nome de “y”. Internamente, o compilador saberá a localização exata do início de cada um desses blocos
de bytes (o início se chama base) e o tipo a que o espaço se refere (incluindo aí a informação do tamanho).

Ponteiros são variáveis cujo conteúdo são endereços de memória que usamos para registrar a base de alguma área de
memória – em geral já reservada para outras variáveis declaradas ou por alocação dinâmica (adiante).

Declaração

Quando declaramos um ponteiro, devemos informar o tipo de dado que estará presente no bloco a ser acessado, de
maneira que o compilador saiba quantos bytes ler e com que formato, quando solicitarmos o conteúdo registrado no
bloco. Além disso, devemos diferenciá-los de variáveis comuns – o que é feito com o sinal asterisco.

int* x_ptr, k_ptr; // ponteiros para inteiro 


double* y_ptr;     // ponteiro para double

Quando trabalhamos com ponteiros, em geral, queremos fazemos duas coisas básicas, e temos um operador
específico para cada uma:

 para conhecer o endereço de uma variável, colocamos um & na frente de seu nome; 
 conhecer o conteúdo armazenado em um endereço colocamos * na frente do ponteiro.

Assim, &x é o endereço onde será guardado todo conteúdo atribuido à variável x; e *x_ptr traz o conteúdo
armazenado no endereço apontado pelo ponteiro x_ptr. O exemplo a seguir mostra estas operações elementares.
Ponteiros podem receber valores do operador & ou de outros ponteiros de mesmo tipo.

x = 123; 
x_ptr = &x; 
y_ptr = x_ptr; 
printf(“%d %d %d”, x, *x_ptr, *y_ptr);

Um ponteiro pode, ainda, apontar para lugar nenhum, recebendo o valor NULL. E ponteiros podem ser comparados
entre si – a igualdade se dá quando são do mesmo tipo e apontam para o mesmo endereço – ou com NULL. Podemos
também registrar um valor num bloco de memória apontado por um ponteiro.

*y_ptr = 456; 
printf(“%d %d %d”, x, *x_ptr, *y_ptr);

Coloque os pequenos exemplos acima no contexto correto (#includes e função main()) e observe os resultados dos
printfs. Experimente, para ficar à vontade com o conceito.

Utilização de ponteiros

Funções: passagem de parâmetros por referência

O parâmetro passado por valor é, dentro da função, uma variável local para a qual se copiou o valor da variável usada
na chama (poderia até ter sido um valor constante) e, com isto, o valor da variável no escopo que chama a função não
é alterado. Já o parâmetro passado por referência é um ponteiro para a posição de memória onde está registrada a
variável usada na chamada, com isto, podemos afetar, dentro da função, o valor que a variável tem no escopo da
chamada.

Veja o exemplo
void func(int a, int* b) {
    a = a + *b;  // a recebe 23, local
    *b = a + *b; // b recebe 35, afeta a y do main
}

int main () {
    int x = 11, y = 12;
    func(x, &y);
    printf(“%d %d”, a, b); // quais serão os valores impressos? (11 e 35, entenda por
que razão)
}

Vejamos também a já conhecida função troca(), que recebe duas variáveis e permuta seus valores. O que
aconteceria (no printf) se ela fosse como a seguir?

void troca(int a, int b) {


    int temp = a;
    a = b;
    b = temp;
}

void main() {
    int x = 10, y = 20;
    troca(x, y);
    printf("x e y valem %d %d\n", x, y);
}

Como exercício, corrija a função troca (e a chamada dela, na função main) para que funcione como esperado.

Vetores

O nome de um vetor, sem índice, é um ponteiro para a base do primeiro elemento do vetor. É por isto que um vetor
sempre é passado para uma função por referência.

  int vetor[100] = {23, 436, 12, 723, 1, 78, 12, 63, 59, 90, 72, 94}; 
  int *v_ptr;
  if (vetor == &vetor[0]) printf(“São iguais\n”);

Quando declaramos um vetor, a memória alocada é contígua: N blocos apropriados para o tipo base do vetor. Quando
somamos um número inteiro a um ponteiro, fazemos com que aponte para o bloco seguinte, considerando o tamanho
do tipo do ponteiro.

  v_ptr = vetor + 10; 


  if (v_ptr == &vetor[10]) printf(“São iguais!\n”);
  printf("vetor[0, 4, 10] são %d, %d, %d, %d", *vetor, *(vetor+4), *v_ptr);

Strings

Como vimos antes, uma string é um vetor de caracteres (terminado por '\0').

Quando declaramos uma string como

char nome[100] = “Dom Pedro de Alcântara”; 


printf(“%c %c %c”, *nome, nome[5], *(nome+4)); // D e e

estamos alocando 100 bytes (pois o tamanho do tipo base, char, é 1 byte) e, implicitamente, declarando o ponteiro
nome para o primeiro destes bytes. Tudo o que dissemos para vetores, vale para strings.

Veremos, na próxima página, outra forma de alocar espaço na memória e, então, poderemos declarar apenas o
ponteiro.

Alocação dinâmica de memória

Um vetor (e também uma string, claro) deve ser declarado com um tamanho fixo. Não é permitido usar uma variável
para declarar o tamanho do vetor, pois a memória para o vetor é alocada no início da execução do programa. Isto é
uma tremenda falta de flexibilidade: precisamos alocar um tanto grande para dar conta de todas as possibilidades para
as quais o programa seja especificado e, se precisarmos de um espacinho a mais, não tem como.
Com a alocação dinâmica, pedimos ao sistema operacional, durante a execução do programa, que aloque um tanto de
memória e nos devolva o endereço do início deste tanto. Desta forma, podemos alocar apenas o que for necessário (e
se preciso, poderemos alocar mais depois, não necessariamente contígua).

Para isto, usamos a função malloc(), que recebe um número correspondente à quantidade de bytes que desejamos
e devolve um ponteiro genérico (que deve ser forçado para o tipo que nos interessa). E, para nos ajudar a calcular o
número de bytes que precisamos, temos a função sizeof(), que opera sobre um tipo de dado. Assim,

  int* v_ptr; 
  int* c_ptr; 
  v_ptr = (int *)malloc(100 * sizeof(int));    // aloca espaço para 100 inteiros (200
bytes)
  c_ptr = (char *)malloc(100 * sizeof(char));  // aloca espaço para 100 chars

Caso não seja possível alocar o espaço desejado, a função malloc() devolve NULL – por isto devemos sempre testar
se ela devolveu NULL ou um valor válido.

  v_ptr = (int *)malloc(100 * sizeof(int));


  if (v_ptr == NULL) {
      printf("Erro de alocação de memória.\n");
      exit(1);
  }

Ainda, podemos desalocar a memória de que não precisamos mais, para que ela possa ser usada em outras partes do
programa (ou por outros programas, no caso de sistemas operacionais multitarefa). E para isto, temos a função
free(), que recebe um ponteiro e libera toda a memória que foi alocada naquele bloco (o sistema operacional cuida
de saber até onde vai o bloco, mesmo que tenham sido alocados, em operações distintas, dois blocos contíguos). O
ponteiro continua apontando, mas se a memória foi liberada, aponta para uma memória “inválida”.

  v_ptr = (int *)malloc(100 * sizeof(int));


  if (v_ptr != NULL) free(v_ptr);
  v_ptr = NULL;

Como exercício, abra o monitor de recursos de sua máquina (algo que mostra, entre outras coisas, a quantidade da
memória ocupada pelos programas em execução) e rode um pequeno programinha que a cada volta de um loop,
aloque 100 megabytes (100 vezes 1024x1024) de memória e peça que o usuário digite algo. Se digitar zero, sai do
loop e libera toda a memória anteriormente alocada. Acompanhe o aumento de memória em uso mostrado pelo
monitor de recursos.

Observe que você terá de guardar os ponteiros para todas as alocações. Por enquanto, use um vetor de ponteiros -
cada posição do vetor é um ponteiro (para qualquer tipo). Como a memória de um PC comum não passa de 3
gigabytes, você precisará de no máximo 30 ponteiros - além disso não será mais possível alocar memória.

Listas ligadas

Listas ligadas são estruturas de dados dinâmicas, que contornam a dificuldade imposta pelos vetores de alocar uma
quantidade fixa de memória – o que implica duas limitações: se a entrada do problema em questão for maior do que o
tamanho definido para o vetor, o programa não funciona; e se alocar muita memória, pode faltar para outras
necessidades. Outra limitação dos vetores que as listas ligadas superam é a rigidez da estrutura – vetores são
lineares, um elemento vem depois do outro, enquanto as listas ligadas permitem vários tipos de relacionamento.

A essência de seu funcionamento é a alocação de memória feita sob demanda: conforme o programa precisa de mais
memória, ele solicita ao S.O., ocupando apenas o necessário, e pode devolvê-la após o uso.

Não haveria vantagem fosse necessário declarar uma variável (ou ocupar uma posição de um vetor) para cada pedaço
de memória alocado, então deve-se prever, na própria estrutura de dados, uma forma de ligar os elementos de
memória um ao outro, e daí vem o nome deste tipo de estrutura.

A melhor forma de compreender é a representação gráfica. Considere a necessidade de se representar pessoas em


relação genealógica. Uma pessoa pode ser representada como uma struct contendo componentes para nome, data de
nascimento, local de nascimento e uma referência para o pai e outra para a mãe.

(esquema)

Isto é declarado, em C, da seguinte maneira


struct pessoa { 
  char nome[100]; 
  struct dt_nascimento {int dia, mes, ano;}; 
  char naturalidade[100]; 
  struct pessoa *pai, *mae; 
}

E usado assim:

int main () { 
  struct pessoa *p, *a; 

  p = malloc(sizeof(struct pessoa));  // p é o ponteiro temporário, de trabalho


  strcpy(p->nome, "Fulano");          // observe que a componente da struct é acessada
  strcpy(p->naturalidade, "Aqui");    //   com uma "seta", quando é através de um
ponteiro
  p->dt_nascimento.dia = 29;          // já aqui, a componente dt_nascimento não é um
ponteiro
  p->dt_nascimento.mes = 2;           //   e portanto suas componentes são acessadas com
"ponto"
  p->dt_nascimento.ano = 1998; 
  p->pai = NULL;                      // por segurança, mantemos nulos os ponteiros
"para lugar nenhum"
  p->mae = NULL;                      // isto nos dá condições de testar se é válido ou
não

  a = p;                              // o ponteiro a é a entrada da árvore genealógica 

  p = malloc(sizeof(struct pessoa));

  strcpy(p->nome,"Ciclano"); 
  strcpy(p->naturalidade,"Ali"); 
  p->dt_nascimento.dia = 29; 
  p->dt_nascimento.mes = 2; 
  p->dt_nascimento.ano = 1972; 
  p->pai = NULL; 
  p->mae = NULL; 

  a->pai = p; 

   p = malloc(sizeof(struct pessoa)); 


  strcpy(p->nome,"Beltrana"); 
  strcpy(p->naturalidade,"Ali"); 
  p->dt_nascimento.dia = 29; 
  p->dt_nascimento.mes = 2; 
  p->dt_nascimento.ano = 1976; 
  p->pai = NULL; 
  p->mae = NULL; 

  a->mae = p; 

  return 0; 
}

Observe que numa estrutura acessada por um ponteiro, o acesso aos elementos é feito utilizando a notação “seta” (->)
ao invés da notação ponto.

Este foi um exemplo simples, apenas para mostrar a alocação de elementos de memória e sua ligação numa lista.
Ligamos apenas os pais do elemento raiz, indicado pelo ponteiro a. Num caso real, faríamos um loop solicitando a
entrada de dados do teclado, ou lendo de um arquivo, e iríamos pendurando os elementos na árvore conforme fossem
informados, ou lidos. Neste caso, precisaríamos de um terceiro ponteiro auxiliar, para localizar o elemento cujos pais
acabaram de ser informados, já que o a deve continuar apontando a raiz da árvore. Veremos este funcionamento mais
tarde, quando falarmos de criação de listas ordenadas, em que os elementos deverão ser incluídos em posições
centrais de uma lista linear, e mais, quando falarmos de árvores.

Aqui criamos uma estrutura de árvore binária (cada nó tem dois nós filhos, podendo ser nulos). Como vimos antes
(heap), podemos representar este tipo de árvore em vetor, mas ainda temos a limitação do tamanho fixo. Outras
estruturas mais complexas, como grafos, não podem ser representadas facilmente em vetor.

Utilizações básicas, fundamentais, das listas ligadas são pilhas e filas. Elas também podem ser implementadas em
vetores (com a limitação do tamanho), o que pode ser um bom exercício de revisão e treinamento de manipulação de
vetores.
Pilhas

Pilhas são conjuntos de elementos cuja ordem é dada pelo momento de entrada na estrutura e esta entrada só pode
ser feita por um ponto, chamado topo, assim como a saída. Por vezes, é chamada de último a entrar, primeiro a sair ou
last-in, first-out (LIFO) em inglês. São largamente utilizadas em programação de sistemas operacionais (pilha de
chamada de função) e linguagens (compiladores – análise sintática, avaliação de expressões).

(esquema)

Estas estruturas têm duas operações essenciais e, normalmente, mais duas básicas (além de outras, eventuais,
necessárias em cada contexto): empilha, desempilha, topo e tamanho.

Pode-se implementar pilhas em vetores. Normalmente, o elemento zero é o fundo da pilha e o topo é indicado por um
índice móvel. Como exercício de revisão de vetores, implemente com vetores os exemplos que veremos com listas
ligadas.

Para implementar pilhas com listas ligadas, precisamos apenas de um ponteiro para o topo e um inteiro para o
tamanho. Vejamos como exemplo, a utilização de uma pilha para verificar a coerência de parênteses, chaves e
colchetes (elementos de escopo) numa expressão matemática. Para que haja coerência, nenhum elemento deve ser
fechado sem ser o mais recente a ter sido aberto e nenhum elemento pode ficar aberto, ao término da expressão. O
que precisamos é uma pilha de caracteres, onde acumularemos os elementos de escopo (desprezando os números e
operadores). Percorreremos a expressão, empilhando e desempilhando os elementos, conforme são abertos ou
fechados.

#include <stdlib.h> 
#include <stdio.h> 
#include <string.h> 

struct elemento { 
  char abriu; 
  struct elemento *prox; // os elementos de listas ligadas sempre têm um ponteiro para
elementos do mesmo tipo
}; 

struct pilha { 
  struct elemento *topo; 
  int tamanho; 
}; 

int empilha(pilha *p, char x) {    // coloca o elemento x no topo da pilha p


  struct elemento *f; 

  if ((f = malloc(sizeof(struct elemento))) == NULL) { 


    printf("Sem memória.\n"); 
    exit(1); 
  }                        // criou elemento, apontado por f 

  f->abriu = x;      // preenche elemento 


  f->prox = p->topo; // novo elemento aponta topo atual como próximo (vale também para o
primeiro!)
  p->topo = f;       // novo elemento passa a ser o topo 
  p->tamanho++;      // aumenta o tamanho da pilha 

char desempilha(pilha *p) {        // tira o elemento do topo da pilha e devolve seu
conteúdo
  char x; 
  struct elemento *e; 

  e = p->topo;       // guarda ponteiro temporário para o topo atual 


  x = e->aberto;     // reserva o caracter do topo atual (para devolver, no fim)

  p->topo = e->prox; // aponta o topo para o próximo 


  p->tamanho--;      // diminui o tamanho da pilha 
  free(e);           // libera memória do antigo topo 

  return x; 

char notopo(pilha p) { 
  return p.topo->aberto; 

void analisa(char* s) {     // recebe a expressão numa string, monta uma pilha e
verifica a coerência
  int n = strlen(s), i; 
  pilha p; 
  char x; 

  p.tamanho = 0; 

  for (i = 0; i < n; i++) { 

    if (s[i] == '(' ||      // se for um dos caracteres de abertura de escopo, empilha
        s[i] == '[' ||
        s[i] == '{' ||
        s[i] == '<'   ) {
      empilha(&p, s[i]); 
      printf("%c aberto, ok (empilhado).\n", s[i]);
    }

    if (s[i] == ')' ||      // se for um dos caracteres de fechamento de escopo...


        s[i] == ']' ||      // vai verificar se há algum escopo aberto e
        s[i] == '}' ||      // se este é correspondente ao escopo atualmente aberto;
        s[i] == '>'   )     // se for, simplesmente desempilha
      if (p.tamanho == 0) 
        printf("*** não há escopo algum aberto - %c incorreto ***\n", s[i]); 
      else { 
        x = notopo(p); 
        if ( (x == '(' && s[i] == ')') || 
             (x == '[' && s[i] == ']') || 
             (x == '{' && s[i] == '}') || 
             (x == '<' && s[i] == '>') ) printf("%c fechado, ok.\n", desempilha(&p)); 
        else 
             printf("*** o escopo aberto é %c, não serve para %c ***\n", x, s[i]); 
      } 
  } 

  if (p.tamanho) { 
    printf("*** restaram os seguintes escopos abertos:"); 
    while (p.tamanho) printf(" %c", desempilha(&p));      // observe que
desempilha decrementa o tamanho
    printf("\n"); 
  } 

int main() { 
  char expr[100]; 

  while(1) { 
    printf("Informe expressão (. termina): "); 
    scanf("%s", expr); 
    if (strcmp(expr, ".")==0) break; 
    analisa(expr); 
  } 

  return 0; 
}

Como exercício de fixação (importante!), escreva uma expressão com escopos coerentes e uma ou duas com
escopos errados e, para cada uma, simule no papel o funcionamento do programa acima. Isto não é para aprender C,
mas para entender o funcionamento das listas ligadas e, em particular, no caso de hoje, da pilha.

Este exercício o ajudará a implementar o mesmo programa usando vetor para representar a pilha. Procure entender e
manter a estruturação do programa, pois as funções elementares são utilizadas muitas vezes e ficaria confuso e
poluído escrevê-las passo a passo a cada vez que fossem utilizadas.

Não deixe para depois, pois o material irá se acumular e não haverá tempo. Vocês já sabem que programar coisas
novas toma muito tempo - agora precisam aprender que programar coisas já feitas é bem mais rápido.

Na próxima aula (quarta-feira, 03/11, primeiro horário, no laboratório), veremos a estrutura básica chamada fila, que é
como uma irmã da pilha. Será muito mais fácil entender se tiverem feito estes exercícios.

Lembrem-se de que teremos reposição de aula no dia seguinte (quinta-feira, 04/11, segundo horário, na sala de aula).
Filas

Filas são estruturas semelhantes a pilhas, isto é conjuntos de elementos cuja ordem é dada pela entrada na estrutura,
e esta entrada é feita apenas por um ponto (o fim da fila), mas a saída é feita por outro ponto (o início da fila), por isto,
são estruturas primeiro a entrar, primeiro a sair ou first-in, first-out (FIFO) em inglês. São também utilizadas em
sistemas operacionais (filas de processos, de spool etc) e outras áreas de computação básica, mas também são
comuns na representação de realidades (filas de atendimento de pessoas, de ordens de serviço etc).

(esquema)

Suas operações fundamentais são enfileira e atende, além das auxiliares básicas início, fim e tamanho. Também
podem ser implementadas em vetores, mas neste caso não há um ponto fixo – dois índices independentes apontam o
início e o fim da fila, com o fim mais à direita (inicialmente). Quando fim == começo, a fila está vazia e o tamanho é
zero. Quando um elemento sai da fila, abre um espaço no começo do vetor, que só vai ser ocupado quando a fila
atingir o fim do vetor – neste caso, o fim “dá a volta” e utiliza o espaço deixado (deve-se ter cuidado com a operação de
tamanho, que agora, não é mais a diferença simples entre os índices). Pode-se também optar por gastar um tempo
“chegando a fila para a frente”, a cada saída da fila ou quando a fila bate no fim do vetor. Experimente.Para
implementar filas como listas ligadas, será melhor utilizarmos dois apontadores, um para o fim e outro para o começo;
se usarmos apenas um para o começo, teremos que percorrer a fila inteira para encontrar o fim e enfileirar um novo
elemento, ou se usarmos apenas um para o fim, teremos que percorrer a fila inteira para entrar o começo e atender o
primeiro.

Vejamos um exemplo simples de atendimento de pessoas.

#include <stdlib.h>
#include <stdio.h>
#include <string.h>

struct elemento {
  int senha;
  struct elemento *prox;
};

struct fila {
  struct elemento *comeco, *fim;
  int tamanho;
};

int enfileira(fila *f, int senha) {   // recebe uma fila e uma informação nova para
enfileirar
  struct elemento *e;

  if ((e = malloc(sizeof(struct elemento))) == NULL) {


    printf("Sem memória.\n");
    exit(1);
  }                            // criou elemento, apontado por p.
  e->senha = senha;            // preenche elemento.
  e->prox = NULL;              // novo elemento aponta nulo como próximo.
  if (f->tamanho == 0)         // se a fila estava vazia,
    f->comeco = e;             //   novo elemento passa a ser o começo.
  else                         // mas se já tinha alguém,
    f->fim->prox = e;          //   último atual aponta novo como próximo.
  f->fim = e;                  // novo elemento passa a ser o fim.
  f->tamanho++;                // aumenta o tamanho da fila.
}

int atende(fila *f) {        // recebe uma fila e extrai o primeiro elemento, devolve o
conteúdo dele
  int senha;
  struct elemento *e;

  e = f->comeco;                  // guarda ponteiro para o começo atual


  senha = e->senha;               // reserva a senha do começo atual

  f->comeco = e->prox;            // aponta o começo para o próximo


  free(e);                        // libera memória do antigo começo
  f->tamanho--;                   // diminui o tamanho da fila

  return senha;
}

int proximo(fila f) {       // inspeciona o início da fila, sem extrair


  return f.comeco->senha;
}
int ultimo(fila f) {        // inspeciona o fim da fila, sem fazer coisa alguma
  return f.fim->senha;
}

int main() {
  fila f;
  char comando[10];

  f.tamanho = 0;

  while(1) {
    printf("<senha>|atende|termina ?");          // solicita senha para enfileirar ou
comando
    scanf("%s", comando);
    if (strcmp(comando, "termina")==0) {         // se comando para terminar, avisa
eventual conteúdo e termina while
      if (f.tamanho) printf("Ainda há %d senhas aguardando.\n", f.tamanho);
      break;
    }
    if (strcmp(comando, "atende")==0){           // se comando para atender, extrai e
apresenta primeiro
      if (f.tamanho == 0) printf("Ninguém na fila.\n");
      else printf("Atendimento concedido para senha %d.\n", atende(&f));
    }
    else enfileira(&f, atoi(comando));           // do contrário, converte para int e
enfileira
  }

  return 0;
}

Simule o funcionamento manualmente, no papel, para entender o funcionamento. Implemente a mesma funcionalidade
usando um vetor (para simplificar, use um vetor grande e limite o funcionamento até este tamanho; num segundo
momento, pense em como fazer com que a fila "dê a volta" no vetor).

Reveja os lembretes no fim do resumo da aula anterior.

Listas simples e operações elementares

Nesta aula, falamos de funções elementares com listas ligadas bem simples. Novamente, os esquemas ficaram no
quadro de giz, pois a produção deles em imagens toma muito tempo, mas é importante, para o pleno entendimento,
que você desenhe e simule o funcionamento das funções a seguir.

Para estes estudos, definimos a estrutura básica do elemento de lista (um nó) e usamos o comando typedef para
abreviar um pouco a notação, criando um novo tipo com base nesta estrutura.

struct um_elemento {
    int chave;
    struct um_elemento *prox;
};

typedef struct um_elemento elemento;

A primeira operação elementar examinada foi a busca linear, semelhante à que vimos sobre vetores nas primeiras
aulas do semestre. Ela recebe como parâmetros um ponteiro para o começo de uma lista e um valor a procurar, e
devolve um ponteiro para o primeiro elemento que contém este valor.

elemento *busca (int x, elemento *ini) {


    elemento *p;
    p = ini;
    while (p != NULL && p->chave != x) p = p->prox; // enquanto não for o procurado e
for possível, avance na lista
    return p;
}

Em seguida, examinamos a inserção de um novo elemento numa posição arbitrária. A função recebe um ponteiro para
um elemento qualquer de uma lista e um novo valor a ser inserido, na posição imediatamente seguinte. Omitimos aqui
o teste sobre o resultado de malloc(), para maior clareza, mas ele deve ser feito sempre. A função não tem que
devolver coisa alguma, apenas inserir o elemento.
void insere(int x, elemento *pos) {
    elemento *novo;
    novo = malloc(sizeof(estrutura));
    novo->chave = x;                    // preenche o elemento
    novo->prox = p->prox;               // faz com que aponte o mesmo próximo do
elemento base
    p->prox = novo;                     // faz com que o elemento base aponte o novo
}

Para encerrar esta primeira fase, fizemos também uma função de remoção de um elemento. Ela recebe o ponteiro para
o elemento imediatamente anterior àquele que deve ser removido e não precisa devolve coisa alguma. Poderíamos,
simplesmente, fazê-la devolver o conteúdo do elemento extraído.

void retira(elemento *pos) {


    elemento *morta;
    morta = p->prox;
    p->prox = morta->prox;
    free(morta);
}

Ficam como exercícios, duas combinações das funções acima:

 buscaEinsere() recebe dois valores de chave e um ponteiro para o início da lista; ela deve buscar o primeiro
elemento que contém o valor da primeira chave, criar um novo elemento com o segundo valor de chave, e
inserí-lo na posição imediatamente posterior
 buscaEremove() recebe um valor de chave e um ponteiro para o início da lista; a função deve localizar o
elemento cuja chave é igual à dada e retirá-la da lista (mantendo a lista íntegra!)

Algumas operações mais complexas sobre listas simples.

Na segunda parte da aula, examinados duas funções: a que reverte os elementos de uma lsita; e a que ordena uma
lista pronta. Mais uma vez, é importante simular o funcionamento delas (e das anteriores, elementares, também) para
entender o funcionamento e pegar o jeito.

A reversão consiste na extração dos elementos da lista original, na ordem em que são dados originalmente, e sua
colocação no início de uma nova lista, cujo início é apontado por r, como se fosse uma pilha sendo esvaziada (pelo
topo, normalmente) e outra pilha sendo enchida. O ponteiro y aponta sempre o elemento que está sendo trabalhado
(exceto no último comando do while, quando é alterado para apontar o próximo, caso exista - é exatamente isto que
nos permite testar se ainda há elementos a trabalhar e, caso negativo, encerrar o loop). O ponteiro t preserva o
restante da lista, quando o elemento em y é retirado dela, para a próxima volta do loop. Simule no papel!!

void reverte (struct elemento *ini)



    struct elemento *t, *y, *r;
    y = ini;
    r = NULL;
    while (y != NULL) { 
       t = y->next; 
       y->next = r; 
       r = y; 
       y = t; 
    }    
    ini = r;
}

A ordenação é uma versão da ordenação por inserção direta, primeiro algoritmo de ordenação que examinamos sobre
vetores. Observe que os algoritmos sofisticados de vetores não podem ser aplicados sobre listas, pois eles dependem
de acesso imediato a qualquer elemento, o que não é possível com listas. Fica como estudo implementar uma
ordenação de lista ligada com base no método de seleção direta e outra com base no método bolha (bubbleSort).

Ainda é de se observar que, nesta função, criamos um elemento adicional, a cabeça, para homogeneizar o tratamento
do percurso da lista. A chave deste elemento nunca é considerada. Sempre examinamos o conteúdo do elemento
seguinte ao apontado por x, de modo que este ponteiro sempre aponta para o elemento imediatamente anterior à
posição, na lista destino, de inserção do elemento de trabalho, apontado por t na lista original. O ponteiro u preserva o
restante da lista original, quando desligamos dela o elemento de trabalho.

void insertSort(struct elemento *ini) {


  struct elemento cabeca;
  struct elemento *t, *u, *x, *b;
  b = &cabeca; 
  b->next = NULL;

  t = ini;
  while (t != NULL) {
       x = b; 
       while (x->next != NULL && x->next->item <= t->item)
          x = x->next;
       u = t->next;
       t->next = x->next; 
       x->next = t; 
       t = u;
  }
  ini = b->next;
}

Árvores

Além das listas ligadas lineares, as estruturas dinâmicas de memória permitem construir qualquer tipo de
relacionamento entre elementos de dados, inclusive entre elementos diferentes. Por exemplo, um sistema gerenciador
de janelas pode ter um número variável de desktops, cada um com uma lista de janelas de programas abertos e cada
janela de programa com diversos elementos gráficos de tipos diferentes. Mas não vamos tão longe, ficaremos apenas
em grafos. Grafos são conjuntos de nós ligados por arestas - ora, os nós são os elementos de dados e as arestas são
as ligações indicadas pelos ponteiros! Ainda assim, não iremos viajar tanto nesta e nas próximas aulas, restringindo o
escopo a um tipo particular de grafos: as árvores.

As árvores são definidas recursivamente em duas partes, da seguinte forma:

1. Uma estrutura vazia (nenhum elemento de dados) é uma árvore;


2. Um nó do tipo base T (elemento de dado estruturado), associado diretamente a um número finito de árvores
(vazias ou não) é uma árvore.

(esquema)

O tipo base T é qualquer tipo estruturado, e é necessariamente estruturado porque deve conter espaço para a
informação útil, o conteúdo propriamente dito e pelo menos dois ponteiros para elementos do mesmo tipo. Algo como,
por exemplo,

typedef struct elemento {             // precisa ter um nome antes do typedef entrar em


vigor...
    int conteudo;
    struct elemento *s1, *s2, *s3;    // ... por causa desta auto-referência.
} no;                                 // Nó

Observe que uma lista linear, como as estudadas anteriormente, é uma árvore... apenas é de se notar que cada nó
desta árvore tem apenas uma sub-árvore (exceto o último nó, que não tem sub-árvore). Podemos chamar as listas
lineares de árvores degeneradas.

Alguma nomenclatura

O nó raiz de uma árvore é aquele para o qual nenhum outro nó aponta - apenas o ponteiro de base da árvore
(equivalente ao ponteiro para o topo da pilha). Um nó folha é um nó cujos ponteiros para outros nós estão todos nulos.
E um nó que aponta para outros nós e que é alvo do ponteiro de algum outro nó é chamado de nó interno.

Os nós para os quais um dado nó aponta são os descendentes diretos (ou filhos) deste último. O nó que aponta para
um dado nó é o ancestral direto (ou pai) deste último. Por extensão, os descendentes de um descendente direto são
também descendentes de um dado nó (como se fossem netos, bisnetos etc - mas não usamos estes termos,
formalmente); e os ascendentes de um ascendente direto são também ascendentes de um nó.

Desta forma, o nó raiz é um nó que não tem ascendentes, e os nós folha são nós que não têm descendentes. Os nśo
internos têm, pelo menos, um ascendente e um descendente.

A altura (ou profundidade) de um nó é o número de nós entre ele e a raiz (inclusive os dois). A altura (ou
profundidade) da árvore é a altura (ou profundidade) do nó de maior altura (ou profundidade).

O grau de um nó é o número de descendentes diretos e o grau da árvore é o grau do nó de maior grau. Com uma
estrutura de nó como a exemplificada antes, o número máximo de descendentes diretos é fixo (3, neste exemplo), mas
pode-se facilmente imaginar uma estrutura de lista ligada (linear ou não) cujos elementos (em número limitado apenas
pela disponibilidade de memória) são os ponteiros para os descendentes.

Um ramo é determinado pelo trajeto de um determinado nó até uma dada folha (que pode ser o próprio nó - um ramo
unitário).

Recursão e trajetos

Uma das operações básicas com árvores é o passeio ou trajeto. Para examiná-la, vamos considerar a árvore abaixo,
de grau 2.

Existem três tipos de passeio mais comuns em árvores de grau 2 (e 2 dois deles são facilmente generalizáveis para
árvores de grau maior que este):

 pré-ordem: processa o nó e depois as suas sub-árvores;


 in-ordem: processa a sub-árvore esquerda, depois o nó e depois a sub-árvore direita;
 pós-ordem: processa as duas sub-árvores e depois o nó.

Como exemplo, vamos escrever três funções para imprimir a árvore acima segundo os três roteiros. Considere que a
função que vai chamar cada uma destas abaixo tem um ponteiro para a raiz da árvore. Observe que a recursão é a
maneira natural de fazer estes passeios, e para tornar isto mais claro, vamos manter um parâmetro de chamada
indicando a profundidade atual. Simule o funcionamento destas funções no papel (como fizemos na sala de aula) para
melhorar seu entendimento da recursão.

typedef struct elemento {


    char nome[30];
    struct elemento *esq, *dir;    // sub-árvores esquerda e direita
} no;                               // Nó

void preOrdem (no *p, int n) {


    int i;

    if (p == NULL) return;                   // caso seja uma árvore vazia, termina a


execução da função
                                             // e volta para o ponto onde foi
chamada

    for (i = 0; i < n; i++) printf("\t");    // imprime tantas tabulações quanto for a
profundidade
    printf("%s\n", p->nome);                 // imprime o nome

    preOrdem(p->esq, n+1);                   // visita a sub-árvore esquerda


    preOrdem(p->dir, n+1);                   // visita a sub-árvore direita
}

void inOrdem (no *p, int n) {


    int i;

    if (p == NULL) return;                   // caso seja uma árvore vazia, termina a
execução da função
                                             // e volta para o ponto onde foi
chamada
 
   inOrdem(p->esq, n+1);                    // visita a sub-árvore esquerda
 
    for (i = 0; i < n; i++) printf("\t");    // imprime tantas tabulações quanto for a
profundidade
    printf("%s\n", p->nome);                 // imprime o nome

 
   inOrdem(p->dir, n+1);                   // visita a sub-árvore direita
}

void posOrdem (no *p, int n) {


    int i;

    if (p == NULL) return;                   // caso seja uma árvore vazia, termina a


execução da função
                                             // e volta para o ponto onde foi chamada

    posOrdem(p->esq, n+1);                   // visita a sub-árvore esquerda


    posOrdem(p->dir, n+1);                   // visita a sub-árvore direita

    for (i = 0; i < n; i++) printf("\t");    // imprime tantas tabulações quanto for a
profundidade
    printf("%s\n", p->nome);                 // imprime o nome
}

int main() {
    no *p;

    p = montaArvore();           // não será examinada, encara como exercício?

    printf("\nA árvore em pré-ordem:\n");


    preOrdem(p, 0);

    printf("\n\nA árvore em in-ordem:\n");


    inOrdem(p, 0);

    printf("\n\nA árvore em pós-ordem:\n");


    posOrdem(p, 0);

    printf("\n\n");
    return 0;
}

A montagem da árvore foi deixada para exercício (mas primeiro faça a simulação do programa acima no papel!) pois é
uma extensão da montagem de qualquer lista ligada - procura-se o nó do qual o novo elemento será filho, cria-se o nó
(preenchendo-se o conteúdo) e faz-se a ligação dos ponteiros. A procura pelo elemento deverá ser exaustiva, pois não
foi definida ainda uma ordenação para a árvore, e pode ser feita em qualquer das três ordens examinadas acima.

Uma sugestão das funções a serem construídas é a que segue. É uma implementação simples em que sempre seráo
inseridas folhas, nunca nós internos.

no *insere (no *pai, char *novo_nome, char esq_dir) {


    // aloca memória e preenche o nome com o novo_nome
    // se pai for NULL (significa que é a raiz) devolve o endereço do novo nó
    //    (pai não pode ser alterado diretamente; para isso teria de ser ponteiro para
ponteiro)
    // do contrário, continua...
    // se esq_dir for E, liga o novo nó como sub-árvore esquerda de pai
    // senão, liga o novo nó como sub-árvore direita de pai
    // devolve NULL (a raiz não foi alterada)
}

no *busca (no *p, char *nome_pai) {


    // se p é NULL, devolve NULL (quer dizer, não encontrou o nome no ramo percorrido
até aqui)
    // do contrário, continua...
    // se o nome do nó p é o nome_pai (use strcmp, não igualdade de ponteiros!!) devolve
p;
    // do contrário, continua...
    // se o retorno da chamada recursiva busca(p->esq, nome_pai) não for NULL, devolve
este valor
    // senão devolve o valor da chamada recursiva busca(p->dir, nome_pai)     ... mesmo
que seja NULL
}

no *montaArvore() {
  no *raiz, *q;

  raiz = insere(NULL, "Ana", 'X');       // esq_dir não terá efeito nesta chamada
  q = busca(raiz, "Ana");
  insere(q, "Clareana", 'E');    // a partir daqui, ignoramos o valor devolvido por
insere pois não mais a raiz
  insere(q, "Doriana", 'D');
  q = busca(raiz, "Clareana");
  insere(q, "Eliana", 'E');
  insere(q, "Floriana", 'D');

  // inclua aqui as demais buscas e inserções

  return raiz;
}

Consegue imaginar uma forma mais eficiente de fazer isto? Não basta imaginar, teste!

Lembre-se de que na prova é permitido consultar os exercícios realizados. E tenha em mente que pouco adiantará
levar os exercícios resolvidos pelo colega, pois o que importa é o entendimento - a consulta apenas ajudará a
relembrar.

Na próxima aula, veremos como construir uma árvore ordenada (dada uma definição de ordem, que será baseada num
dos passeios acima). Esta árvore ordenada resolverá o problema da busca em estruturas dinâmicas ligadas, já que
não é possível implementar uma busca binária numa lista linear.

Árvores binárias ordenadas

Cada nó tem até 2 descendentes diretos, sendo que todos os elementos na sub-árvore esquerda de um nó qualquer
são menores do que ele (dada uma relação de ordem entre as informações do nó) e todos os elementos na sub-árvore
direita de um nó qualquer são maiores do que ele.

Com uma árvore construída desta maneira, podemos fazer uma busca que pode chegar a ser tão eficiente quanto a
busca binária em vetores. Comparamos o valor procurado com o conteúdo do nó raiz; se não for este o elemento
procurado, podemos descartar toda uma sub-árvore deste nó, esquerda ou direita conforme o elemento procurado seja
maior ou menor do que o conteúdo do nó examinado, e repetimos o procedimento considerando apenas a sub-árvore
restante.

typedef struct elemento {          // precisa ter um nome antes do typedef entrar em


vigor...

    int chave;
    struct elemento *esq, *dir;    // ... por causa desta auto-referência.
} no; 

no *busca (no *p, int x) {    // devolve NULL quando não encontra o elemento; ou um
ponteiro para o nó, caso encontre.

  if (p == NULL) return NULL;

  if (p->chave == x) return p;


  if (p->chave > x) return busca( p->esq, x );
  if (p->chave < x) return busca( p->dir, x );
}

// uma versão iterativa da mesma função


no *buscaI (no *p, int x) {
  no *q;

  while (p != NULL && p->chave != x)


    if (p->chave > x) p = p->esq;
    else p = p->dir;
  }

  return p;
}
Para garantirmos uma eficiência O(log2n) precisaríamos garantir que a árvore fosse a mais bem distribuída possível,
com cada nível de profundidade tendo todas as suas posições ocupadas antes de iniciar um nível mais profundo. Esta
seria uma árvore binária ordenada perfeitamente balanceada, e sua profundidade é log2n arredondado para cima (onde
n é o número total de elementos).

Uma outra possibilidade, seria uma árvore balanceada, na qual as duas sub-árvores de qualquer nó diferem, em
profundidade, de no máximo uma unidade - como é o caso das árvores AVL e das árvores rubro-negras. Neste caso, o
desempenho da busca poderia ser 40% pior do que no caso do balanceamento perfeito (pois sua profundidade máxima
é 1,4*log2n, mas ainda O(log2n).

Entretanto, a construção de árvores balanceadas e perfeitamente balanceadas é complexa e custosa. Pode valer a
pena quando o número de operações de busca a ser realizado for muito maior do que o número de operações
envolvidas na construção da árvore. Para uma árvore binária ordenada qualquer, sem restrição quanto à organização
das sub-árvores, o desempenho de caso médio também será O(log2n); mas o pior caso será O(n) - uma árvore
degenerada em lista linear (cada nó tem apenas um descendente).

Regra de formação

Consideremos a regra de formação de uma árvore binária ordenada simples. Continuamos com a suposição
simplificadora de que os elementos a serem tratados não se repetem (é uma suposição realista, já que frequentemente
trabalhamos com dados que têm um identificador único). Cada elemento novo é inserido sempre na extremidade do
caminho percorrido pela busca por ele mesmo (que ainda não está lá). Desta forma, a inserção é sempre uma busca
seguida da criação do nó como folha.

Para fazer a inserção de um nó em qualquer lista ligada, precisamos localizar o elemento imediatamente anterior. Não
é diferente para árvores. Então precisamos de uma adaptação da função de busca que localize o nó que será o pai do
novo elemento e então crie e ligue o nó. Como existe o caso do primeiro elemento a ser inserido, a função deve ser
capaz de alterar o valor do ponteiro para a raiz da árvore, que estará nulo e passará a apontar para este primeiro nó -
para isto, usaremos um ponteiro para ponteiro, **p, e quando quisermos usar o ponteiro normalmente, usamos a
notação *p, ao invés de p. É mais obscuro que o que fizemos no quadro, na sala de aula, mas trata melhor o caso do
nó inicial e, após simular a inserção de alguns nós, no papel, o entendimento fica fácil.

no *criaNo (int x) {
  no *q;

  q = (no *)malloc(sizeof(no));


  q->esq = q->dir = NULL;
  q->chave = x;

  return q;
}

void insere (no **p, int x) {


  if (*p == NULL) (*p) = criaNo(x);
  else {
        if ((*p)->chave == x) exit(ELEMENTO_JA_EXISTE);
        if ((*p)->chave > x)
 insere( (*p)->esq, x );
        if ((*p)->chave < x) insere( (*p)->dir, x );
  }
}

int main() {
  no *raiz;
  int novo_elemento;

  while(1) {
    // obtém próximo novo_elemento
    // testa condição de saída e faz break, se positivo
    insere(&raiz, novo_elemento);
  }

}  

Observe, na função insere,  que a recursão só para quando o *p é NULL, oiu seja, vale tanto para o caso da árvore
completamente vazia, inicial, quando para o caso das sub-árvores vazias de qualquer nó. O elemento é criado e a
atribuição de seu ponteiro (retorno da função criaNo) a (*p) atualiza o ponteiro que foi passado como parâmetro, seja
ele o raiz na main, seja o esq ou dir de um elemento já existente na árvore.

Veja que o resultado final variará conforme a ordem em que os elementos forem inseridos. Pode ocorrer de gerar uma
árvore degenerada em lista (cada nó tem apenas um filho, seja esquerdo ou direito). Nestes casos, a busca será ruim,
O(n), e a construção ainda um pouco pior do que isto. Se a árvore estiver sendo construída para ser usada em muitas
buscas, pode valer a pena gastar um pouco mais de recursos na construção de uma árvore balanceada, pois o custo
adicional será compensado pelas buscas mais rápidas.

Remoção

A remoção de elementos de uma árvore não é comum, quando se trata de uma árvore de busca, mas pode ser
necessária. Quando o elemento a ser removido é um nó folha, basta anular o ponteiro do pai para ele e liberar sua
memória. Quando ele tem uma sub-árvore, esquerda ou direita, devemos colocar esta sub-árvore no liugar do
elemento a ser removido, trocando o ponteiro do pai que era para ele. O caso mais complexo (mas não muito) é
quando o nó a ser removido possui as duas sub-árvores. Vimos uma solução em sala de aula: definimos uma das duas
para substituir o nó removido e penduramos a outra na extremidade adequada. Se colocarmos o filho esquerdo do nó
removido em seu lugar, percorremos esta subárvore sempre pela direita, até seu maior elemento e penduramos nele a
subárvore direita do nó removido. Se optarmos pelo outro lado, basta trocar esquerda por direita e maior por menor, na
frase anterior.

Esta solução funciona, mas acaba gerando uma árvore muito desbalanceada. Há uma solução simples e melhor,
baseada na anterior: substituir o elemento removido pelo maior elemento da sub-árvore esquerda (pois ele é maior do
que todos os outros desta sub-árvore e menor do que todos os da outra sub-árvore) ou pelo menor elemento da sub-
árvore direita.

A seguinte página ilustra esta solução: http://www.lcad.icmc.usp.br/~nonato/ED/Arvore_Binaria/node66.html

A implementação será deixada como exercício.

Remoção em árvores de busca binária

Para removermos um nó X de uma árvore de busca binária preservando suas propriedades, precisamos considerar três casos:

1. X não possui nenhum filho.


2. X possui apenas um filho.
3. X possui dois filhos.

No primeiro caso, basta substituir no nó pai de X o ponteiro que aponta para X por NULL.

No segundo caso, onde o nó possui apenas um filho, basta modificar o ponteiro no nó pai de X (que aponta para X) para que ele
ponte para o filho de X.

No terceiro caso, onde o nó possui dois filhos, substituimos o nó X pelo elemento mais a direita da sub-árvore da esquerda de X ou
pelo elemento mais a esquerda da sub-árvore da direita de X. (Qual a razao para selecionarmos estes elementos?)

A figura abaixo ilustra os três casos descritos acima :


Note que tanto a operação de inserção como a de remoção em uma árvore de busca binária possuem complexidade de O(h) onde h é
a altura da árvore.

Você também pode gostar