UFLA

🎓 Aula 09

Ponteiros, Alocação Dinâmica, Busca Binária e Recursão

Prof. Paulo Rettore

Universidade Federal de Lavras

GAC124 — Introdução aos Algoritmos

🎯 O mapa da aula

  • Entender como variáveis ocupam a memória e como ponteiros guardam endereços.
  • Usar vetores e ponteiros com segurança.
  • Alocar e liberar memória com new/delete e conhecer malloc/free.
  • Comparar busca linear, busca linear em vetor ordenado e busca binária.
  • Construir e acompanhar funções recursivas usando uma pilha de execução.

A ideia central: primeiro entendemos onde os dados estão; depois, como procurar e processar esses dados.

🧠 Variáveis vivem na memória

Uma variável possui um valor, um tipo, um tamanho e um endereço.

Valor

numero = 25

É o dado que o programa utiliza.

Tipo

int

Define como interpretar os bits.

Endereço

&numero

Indica onde o valor está guardado.

int numero = 25;

cout << numero << endl;   // valor
cout << &numero << endl;  // endereço, por exemplo 0x7ffd...

O endereço muda entre execuções. O importante é entender que o computador consegue localizar a variável por esse endereço.

📍 O que é um ponteiro?

Um ponteiro é uma variável que armazena o endereço de outra variável.

int numero = 20;
int* ptrNumero = &numero;

cout << ptrNumero << endl;   // endereço de numero
cout << *ptrNumero << endl;  // valor guardado nesse endereço

&

Obtém o endereço de uma variável.

*

Desreferencia: acessa o valor apontado.

int*

Declara um ponteiro para int.

🔁 Indireção: alterar por meio do endereço

O ponteiro não cria uma cópia de numero. Ele oferece outro caminho até a mesma variável.

int numero = 5;
int* p = &numero;

cout << *p << endl;  // 5
*p = 8;                // altera numero por indireção
cout << numero << endl; // 8

1. Antes

numero = 5

2. Acesso

*p aponta para numero

3. Depois

numero = 8

🧩 Três declarações que parecem iguais

Preferida

int* p;

Destaca o tipo do ponteiro.

Também correta

int *p;

Destaca o nome da variável.

Evite confusão

int* p, q;

Apenas p é ponteiro; q é um int.

int *p1, *p2; // os dois são ponteiros
int* p3, p4;  // p3 é ponteiro; p4 é inteiro

Uma declaração por linha é uma boa prática quando o código ainda está sendo aprendido.

🔢 Vetores e ponteiros

Em muitas expressões, o nome de um vetor representa o endereço do seu primeiro elemento. O vetor não deixa de ser um vetor: ele apenas pode ser acessado por outro caminho.

int vetor[3] = {10, 20, 30};
int* p = vetor; // equivalente a &vetor[0]

cout << vetor[1] << endl; // 20
cout << p[1] << endl;     // 20
cout << *(p + 1) << endl; // 20

p[i] é uma forma conveniente de escrever *(p + i). A aritmética avança considerando o tamanho do tipo apontado.

↔️ Ponteiros em funções

Passar um ponteiro copia o endereço, mas permite alterar a variável original por indireção.

void dobro(int* valor) {
    if (valor != nullptr)
        *valor = 2 * (*valor);
}

int n = 7;
dobro(&n);
cout << n; // 14

Chamada

dobro(&n) envia o endereço de n.

Dentro da função

*valor acessa o próprio n.

📦 Ponteiros para registros

Quando um ponteiro aponta para uma estrutura, o operador -> acessa diretamente seus campos.

struct Ponto {
    int x, y;
};

Ponto p1{3, 4};
Ponto* ptr = &p1;

cout << p1.x << endl;
cout << (*ptr).y << endl;
cout << ptr->x << endl; // forma mais legível

ptr->x é equivalente a (*ptr).x.

🛡️ Ponteiro nulo é diferente de ponteiro válido

int* p = nullptr;

if (p != nullptr) {
    cout << *p;
} else {
    cout << "Ainda não aponta para um dado";
}

nullptr

Não aponta para uma variável válida.

&numero

Aponta para o endereço de numero.

*p

Só pode ser usado quando p é válido.

Nunca desreferencie um ponteiro não inicializado ou nulo.

⚠️ Erros comuns com ponteiros

Ponteiro não inicializado

int* p;
*p = 10; // comportamento indefinido

Endereço inventado

int* p;
p = 240; // não faça isso

Fora do limite

int v[3];
v[3] = 10; // último índice é 2

Uso após liberar

int* p = new int;
delete p;
*p = 10; // ponteiro pendente

Esses erros podem compilar e falhar somente durante a execução. Por isso, compilação bem-sucedida não garante acesso seguro à memória.

🧹 Vazamento e ponteiro pendente

int* p = new int(10);
int* copia = p;

delete p;
p = nullptr;
// copia ainda guarda o endereço liberado: ponteiro pendente

Ponteiro pendente

Continua guardando um endereço depois de delete.

Vazamento

O programa perde o último endereço de um bloco que ainda não foi liberado.

Ao liberar, invalide os ponteiros que possam ser reutilizados. Evite cópias de ponteiros sem definir quem será responsável pelo delete.

✅ Regras de segurança

  • Inicialize com nullptr ou com o endereço de uma variável válida.
  • Teste o ponteiro antes de desreferenciar quando ele puder ser nulo.
  • Não acesse índices fora dos limites.
  • Use delete para um objeto e delete[] para um vetor.
  • Depois de liberar, atribua nullptr ao ponteiro principal.
  • Defina claramente quem aloca e quem libera a memória.

🗂️ Alocação automática × dinâmica

Automática

  • O compilador gerencia o espaço.
  • A variável nasce dentro do escopo.
  • Sai de cena no fim do escopo.
  • Exemplo: int n;

Dinâmica

  • O programa solicita espaço durante a execução.
  • O tamanho pode depender de uma entrada.
  • O programador precisa liberar.
  • Exemplo: new int[n]

Alocação dinâmica oferece controle sobre o momento e o tamanho da reserva, mas exige responsabilidade.

❓ Por que não usar simplesmente VLA?

int tam;
cin >> tam;
int vetor[tam]; // parece resolver...

Essa declaração é conhecida como VLA (variable-length array).

Em C++, um vetor comum precisa ter tamanho conhecido em tempo de compilação. Alguns compiladores aceitam VLA como extensão, mas ele não faz parte do C++ padrão.

✅ O que usar no lugar do VLA?

int tam;
cin >> tam;

if (tam > 0) {
    int* vetor = new int[tam];
    // use vetor[0] até vetor[tam - 1]
    delete[] vetor;
    vetor = nullptr;
}

Padrão C++

new[] funciona com tamanho conhecido só em execução.

Stack

VLA ocuparia a pilha, que tem capacidade limitada.

Responsabilidade

Com new[], lembre do delete[].

🧭 Alocação dinâmica de um elemento

int* ptr = new int;
*ptr = 13;

cout << *ptr << endl;

delete ptr;
ptr = nullptr;

1. new

Reserva um bloco e devolve um endereço.

2. *ptr

Acessa o inteiro reservado.

3. delete

Libera o bloco reservado.

O elemento dinâmico não tem um nome próprio; usamos o ponteiro para encontrá-lo.

📦 Alocação dinâmica de vetor

int n;
cin >> n;

int* vetor = new int[n];
for (int i = 0; i < n; i++)
    cin >> vetor[i];

cout << vetor[0];
delete[] vetor;
vetor = nullptr;

new int[n]

Reserva posições contíguas para n inteiros.

delete[] vetor

Libera o bloco inteiro, não apenas a primeira posição.

🎞️ Crescer um vetor dinâmico

Um vetor criado com new[] não muda de tamanho sozinho. Para crescer, criamos outro bloco e copiamos os elementos.

int* maior = new int[novoTamanho];
for (int i = 0; i < tamanhoAtual; i++)
    maior[i] = vetor[i];

delete[] vetor;
vetor = maior;

🆚 new/delete e malloc/free

C++: new/delete

int* v = new int[n];
delete[] v;
  • Retorna ponteiro tipado.
  • Combina com delete ou delete[].

C: malloc/free

int* v = static_cast<int*>(
    malloc(n * sizeof(int)));
free(v);
  • Reserva quantidade de bytes.
  • Combina exclusivamente com free.

Não misture pares: memória de new não deve ser liberada com free; memória de malloc não deve ser liberada com delete.

🧰 Como funciona malloc()?

#include <cstdlib>

int n;
cin >> n;

int* vetor = static_cast<int*>(
    malloc(n * sizeof(int))
);

if (vetor == nullptr) {
    cout << "Falha na alocação";
} else {
    vetor[0] = 42;
    free(vetor);
    vetor = nullptr;
}
  • sizeof(int) informa quantos bytes um inteiro ocupa.
  • malloc retorna um endereço bruto, convertido para int*.
  • free devolve o bloco ao sistema.

🧪 Qual escolher?

Novo código C++

Prefira new/delete quando o objetivo é estudar alocação dinâmica manual.

Código C legado

Você pode encontrar malloc/free; reconheça o par correto.

Aplicações reais

Estruturas como std::vector automatizam várias dessas responsabilidades.

Nesta aula o foco é compreender a memória. Em projetos maiores, abstrações que gerenciam recursos reduzem erros.

🔎 O problema de busca em vetores

Dado um vetor V e uma chave K, queremos saber se K aparece e em qual índice.

V = {2, -3, 101, 104, 0, 1, 2, 3, -3, 0}
K = 104

// resposta: encontrado no índice 3

Sem ordenação

A busca linear funciona em qualquer vetor.

Com ordenação

Podemos descartar regiões inteiras.

Escolha

O formato dos dados orienta o algoritmo.

🔍 Busca linear: comparar um por um

int buscaLinear(const int v[], int n, int chave) {
    for (int i = 0; i < n; i++) {
        if (v[i] == chave)
            return i;
    }
    return -1;
}
  • Melhor caso: a chave está na primeira posição.
  • Pior caso: a chave está na última posição ou não aparece.
  • Não exige vetor ordenado.

🎞️ Busca linear: execução passo a passo

Vetor: {4, 9, 2, 7, 5, 1} · chave: 7.

Use Próximo para testar cada comparação ou Reproduzir para assistir à sequência.

⏩ Busca linear com parada antecipada

Se o vetor estiver ordenado, ainda podemos evitar comparações depois que o valor atual ultrapassa a chave.

int i = 0;
while (i < n and v[i] < chave)
    i++;

if (i < n and v[i] == chave)
    return i;
return -1;

Essa melhoria reduz trabalho em alguns casos, mas o pior caso ainda pode visitar quase todo o vetor.

🎞️ Busca linear ordenada: quando parar?

Vetor ordenado: {1, 4, 5, 10, 16, 19, 20, 27, 30} · chave: 26.

Ao encontrar 27, já sabemos que 26 não aparece depois. A busca pode parar.

⚡ Busca binária: metade por vez

A busca binária exige um vetor ordenado e mantém apenas o intervalo onde a chave ainda pode estar.

  1. Calcule o índice do meio.
  2. Compare chave com v[meio].
  3. Se a chave for menor, descarte a metade direita.
  4. Se for maior, descarte a metade esquerda.
  5. Repita até encontrar ou o intervalo ficar vazio.

Em cada passo, o tamanho do problema cai aproximadamente pela metade. O custo é proporcional a log₂(n).

🎞️ Busca binária: chave ausente

Vetor: {1, 4, 5, 10, 16, 19, 20, 27, 30} · chave: 26.

As células apagadas estão fora do intervalo atual. A busca termina quando não sobra posição possível.

🎞️ Busca binária: chave encontrada

Mesmo vetor, agora procurando a chave 27.

Observe a diferença: encontrar o meio encerra a busca imediatamente.

🧮 Busca binária iterativa

int buscaBinaria(const int v[], int n, int chave) {
    int inicio = 0;
    int fim = n - 1;

    while (inicio <= fim) {
        int meio = (inicio + fim) / 2;
        if (v[meio] == chave)
            return meio;
        if (chave < v[meio])
            fim = meio - 1;
        else
            inicio = meio + 1;
    }
    return -1;
}

inicio, fim e meio descrevem o intervalo que ainda precisa ser investigado.

🔁 Por que usar recursão?

Recursão é quando uma função chama a si mesma para resolver uma versão menor do mesmo problema.

Caso base

Uma situação simples que pode ser respondida sem nova chamada.

Caso geral

Reduz o problema e chama a função novamente.

problema grande
→ resolve um problema menor
→ resolve um problema ainda menor
→ caso base
→ retorna os resultados

Sem caso base ou sem redução, a função pode chamar a si mesma indefinidamente.

📐 Relação de recorrência: fatorial

A matemática já descreve o fatorial de forma recursiva:

Caso base

0! = 1

Caso geral

n! = n × (n - 1)!

Exemplo

4! = 4 × 3!

4! = 4 × 3 × 2 × 1 × 1 = 24

🧩 Função recursiva do fatorial

int fatorial(int n) {
    if (n == 0)          // caso base
        return 1;
    return n * fatorial(n - 1); // caso geral
}

Chamada

fatorial(4) ainda não sabe o resultado de fatorial(3).

Retorno

A multiplicação fica aguardando na pilha.

🎞️ Fatorial(4): pilha de chamadas

Primeiro acompanhamos a descida das chamadas; depois, os retornos resolvem a pilha.

O botão Próximo mostra uma nova chamada ou um retorno. A pilha cresce na descida e diminui na subida.

1️⃣ Fatorial(4): primeira chamada

Pilha

fatorial(4)

O que fica pendente?

return 4 * fatorial(3);

A função ainda não retorna 24: ela precisa descobrir o resultado de fatorial(3).

2️⃣ Fatorial(3): segunda chamada

Pilha

fatorial(4)
  fatorial(3)

Expressão pendente

4 * (3 * fatorial(2))

A chamada de número 3 é criada antes de qualquer produto ser finalizado.

3️⃣ Fatorial(2): terceira chamada

Pilha

fatorial(4)
  fatorial(3)
    fatorial(2)

Expressão pendente

4 * (3 * (2 * fatorial(1)))

4️⃣ Fatorial(1): quarta chamada

Pilha

fatorial(4)
  fatorial(3)
    fatorial(2)
      fatorial(1)

Expressão pendente

4 * (3 * (2 * (1 * fatorial(0))))

A próxima chamada será fatorial(0), o caso base.

🛑 Fatorial(0): caso base

Pilha

fatorial(4)
  fatorial(3)
    fatorial(2)
      fatorial(1)
        fatorial(0)

Expressão pendente

4 * (3 * (2 * (1 * fatorial(0))))

fatorial(0) é o caso base e devolve 1. A chamada será removida na próxima etapa.

↩️ Retorno: fatorial(0)

Pilha

fatorial(4)
  fatorial(3)
    fatorial(2)
      fatorial(1)

Expressão pendente

4 * (3 * (2 * (1 * 1)))
= 4 * (3 * (2 * 1))

A chamada fatorial(0) saiu da pilha. Seu retorno 1 ocupa o lugar da chamada na expressão.

↩️ Desempilhar: fatorial(1)

Pilha

fatorial(4)
  fatorial(3)
    fatorial(2)

Expressão pendente

4 * (3 * (2 * 1))
= 4 * (3 * 2)

fatorial(1) resolve 1 * 1 = 1, devolve 1 e também é removido da pilha.

↩️ Desempilhar: fatorial(2)

Pilha

fatorial(4)
  fatorial(3)

Expressão pendente

4 * (3 * 2)
= 4 * 6

fatorial(2) resolve 2 * 1 = 2, devolve 2 e deixa a expressão pronta para o próximo retorno.

↩️ Desempilhar: fatorial(3)

Pilha

fatorial(4)

Expressão pendente

4 * 6
= 24

fatorial(3) resolve 3 * 2 = 6, devolve 6 e é removido da pilha.

✅ Desempilhar: fatorial(4)

Pilha

vazia

Expressão pendente

4 * 6
= 24

fatorial(4) resolve 4 * 6 = 24. A pilha ficou vazia e o resultado pode ser enviado ao cout.

🌳 Árvore e pilha de recursão

Fatorial: uma corrente

fatorial(4)
└─ fatorial(3)
   └─ fatorial(2)
      └─ fatorial(1)
         └─ fatorial(0)

Regra de leitura

Na descida, cada chamada aguarda.

Na subida, cada chamada combina o resultado recebido com seu próprio valor.

Em problemas com mais de uma chamada recursiva, como Fibonacci, a estrutura deixa de ser uma corrente e vira uma árvore de chamadas.

🧠 Recursão: erros que aparecem nos testes

Sem caso base

int f(int n) {
    return f(n - 1);
}

Chamadas infinitas.

Sem reduzir

int f(int n) {
    if (n == 0) return 1;
    return f(n);
}

O parâmetro nunca se aproxima do caso base.

Outros problemas: caso base incorreto, retorno errado ou profundidade tão grande que esgota a pilha de execução.

🆚 Recursão e repetição

Recursiva

int fatorial(int n) {
    if (n == 0) return 1;
    return n * fatorial(n - 1);
}

Expressa diretamente a relação matemática.

Iterativa

int resultado = 1;
for (int i = 2; i <= n; i++)
    resultado *= i;

Usa uma única função e uma repetição.

A versão recursiva pode ser mais didática para alguns problemas, mas normalmente usa mais memória para guardar chamadas pendentes.

🧩 Busca binária recursiva

int buscaBinaria(const int v[], int inicio, int fim, int chave) {
    if (inicio > fim)
        return -1; // intervalo vazio

    int meio = (inicio + fim) / 2;
    if (v[meio] == chave)
        return meio;
    if (chave < v[meio])
        return buscaBinaria(v, inicio, meio - 1, chave);
    return buscaBinaria(v, meio + 1, fim, chave);
}

Casos base

Encontrou a chave ou o intervalo ficou vazio.

Caso geral

Chama a função para a metade possível.

🎞️ Busca binária recursiva: pilha

Vetor: {1, 4, 5, 10, 16, 19, 20, 27, 30} · chave: 27.

Chamadas

busca(0, 8)
→ busca(5, 8)
→ busca(7, 8)
→ encontra em 7

Meios

meio = 4 → 16
meio = 6 → 20
meio = 7 → 27

Cada chamada reduz [inicio, fim]; o retorno devolve o mesmo índice até a chamada inicial.

🧪 Teste de mesa: escreva a execução

Para conferir uma busca binária recursiva, registre uma linha por chamada.

Chamadainiciofimmeiov[meio]decisão
108416direita
258620direita
378727encontrou

O teste de mesa transforma o movimento do algoritmo em evidências: valores de entrada, decisão e próximo intervalo.

🧩 Prática orientada

  1. Alocar dinamicamente um vetor de n inteiros.
  2. Ler os valores em ordem crescente.
  3. Ler uma chave e executar busca binária iterativa.
  4. Repetir usando uma função recursiva.
  5. Registrar em uma tabela cada valor de inicio, fim e meio.
  6. Liberar o vetor com delete[].

Teste três situações: chave no primeiro trecho, chave ausente e chave no último índice.

📚 Referências e materiais

Use estas fontes para consultas pontuais depois da aula:

Os materiais completos, o Campus Virtual e o DREDD continuam sendo as fontes principais da disciplina.

📌 Resumo da aula

  • Ponteiros guardam endereços; & obtém endereço e * acessa o valor.
  • Vetores podem ser percorridos por índices ou por ponteiros.
  • VLA não é C++ padrão; new[]/delete[] formam um par.
  • malloc/free formam outro par e não devem ser misturados com new/delete.
  • Busca binária exige vetor ordenado e reduz o intervalo pela metade.
  • Recursão exige caso base, redução do problema e retorno bem definido.

PRÓXIMA AULA

⏭️ Próxima aula

Registros e vetores de registros

Prof. Paulo Rettore

GAC124 — Introdução aos Algoritmos