Escolar Documentos
Profissional Documentos
Cultura Documentos
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.
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
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 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.
Strings
Como vimos antes, uma string é um vetor de caracteres (terminado por '\0').
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.
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.
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”.
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.
(esquema)
E usado assim:
int main () {
struct pessoa *p, *a;
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;
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;
};
char desempilha(pilha *p) { // tira o elemento do topo da pilha e devolve seu
conteúdo
char x;
struct elemento *e;
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;
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 (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.
#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;
int atende(fila *f) { // recebe uma fila e extrai o primeiro elemento, devolve o
conteúdo dele
int senha;
struct elemento *e;
return 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).
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;
};
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.
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.
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!)
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!!
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.
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.
(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,
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):
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.
for (i = 0; i < n; i++) printf("\t"); // imprime tantas tabulações quanto for a
profundidade
printf("%s\n", p->nome); // imprime o nome
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
}
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;
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 *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');
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.
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.
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.
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;
return q;
}
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.
Para removermos um nó X de uma árvore de busca binária preservando suas propriedades, precisamos considerar três casos:
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?)