INF01203 - Estruturas de Dados - Instituto de Informática (UFRGS) - Prof. Dennis Giovani Balreira



Aula 9a - Pilhas

Nesta aula estudamos o TAD Pilha, a primeira de três estruturas de dados especializadas que veremos nesta unidade (pilhas, filas e deques). Diferente das listas gerais vistas até agora, uma pilha restringe onde é possível inserir e remover elementos — e é exatamente essa restrição que a torna útil e eficiente para uma série de problemas.


1. O TAD Pilha

Uma pilha (stack) é uma lista em que todas as inserções e remoções ocorrem em uma única extremidade, chamada de topo. Isso faz com que o último elemento inserido seja sempre o primeiro a ser removido — comportamento conhecido pela sigla LIFO (Last-In, First-Out).

        +-------+
 topo -> |   C   |  (último a entrar, primeiro a sair)
        +-------+
        |   B   |
        +-------+
        |   A   |  (primeiro a entrar, último a sair)
        +-------+

A analogia mais comum é a de uma pilha de pratos: só é possível colocar um novo prato no topo, e só é possível retirar o prato que está no topo — nunca um prato do meio ou do fundo da pilha.

Operações

Operação Descrição
empilhar (push) Insere um novo elemento no topo da pilha.
desempilhar (pop) Remove e retorna o elemento do topo da pilha.
topo (peek / top) Retorna o elemento do topo, sem removê-lo.
estaVazia Indica se a pilha não possui elementos.
tamanho Retorna o número de elementos na pilha.
Terminologia. Tentar desempilhar (ou consultar o topo) de uma pilha vazia é chamado de underflow. Tentar empilhar em uma pilha que já atingiu sua capacidade máxima é chamado de overflow — algo que só ocorre em implementações com capacidade fixa, como veremos nos exercícios.

2. Implementação por lista encadeada

Implementamos a pilha sobre uma lista encadeada (Aula 4), sem limite de capacidade. A ideia central: o topo da pilha é sempre o início da lista — e empilhar/desempilhar tornam-se, respectivamente, inserir e remover no início:

typedef struct {
    int cod;
    char nome[50];
    float preco;
} Produto;

typedef struct str_Nodo Nodo;

struct str_Nodo {
    Nodo *prox;
    Produto dado;
};

typedef struct {
    Nodo *topo;
} PilhaEnc;
void inicializa(PilhaEnc *p) {
    p->topo = NULL;
}

int estaVazia(PilhaEnc *p) {
    return p->topo == NULL;
}

int empilha(PilhaEnc *p, Produto valor) {
    Nodo *novo = (Nodo*) malloc(sizeof(Nodo));
    if (novo == NULL)
        return 0;

    novo->dado = valor;
    novo->prox = p->topo; // aponta para o antigo topo
    p->topo = novo;        // o novo nodo passa a ser o topo
    return 1;
}

int desempilha(PilhaEnc *p, Produto *valorRemovido) {
    Nodo *removido;

    if (estaVazia(p)) // underflow
        return 0;

    removido = p->topo;
    *valorRemovido = removido->dado;
    p->topo = removido->prox;
    free(removido);
    return 1;
}

int topo(PilhaEnc *p, Produto *valor) {
    if (estaVazia(p))
        return 0;

    *valor = p->topo->dado;
    return 1;
}

int tamanho(PilhaEnc *p) {
    Nodo *aux = p->topo;
    int contador = 0;

    while (aux != NULL) {
        contador++;
        aux = aux->prox;
    }

    return contador;
}
Por que sempre no início, e não no fim? Em uma lista simplesmente encadeada (Aula 4), inserir e remover no início é O(1), mas no final seria O(n) (sem um ponteiro fim, como discutido na Aula 6a). Como a pilha só precisa de uma extremidade "rápida", faz todo sentido escolher o início.

Todas as operações desta implementação são O(1), e a pilha cresce dinamicamente sem nenhum limite predefinido — o único limite passa a ser a memória disponível no sistema. É por isso que adotamos a lista encadeada como implementação principal do TAD Pilha nesta disciplina.

E a implementação por vetor? Também é possível implementar uma pilha sobre um vetor de capacidade fixa (como fizemos com listas na Aula 2c), guardando apenas o índice do elemento no topo. Ela também alcança O(1) em todas as operações, mas introduz uma capacidade máxima e a possibilidade de overflow. Deixamos essa implementação como exercício ao final da aula.

3. Aplicações

Pilha de execução de funções

Já usamos uma pilha sem perceber: a própria pilha de chamadas de função (Material Extra — Conceitos de Linguagens de Programação) é uma pilha no sentido deste TAD. Cada chamada de função "empilha" um quadro de ativação, e o retorno da função "desempilha" esse quadro — sempre na mesma ordem LIFO: a última função chamada é a primeira a retornar.

Verificação de parênteses balanceados

Um uso clássico de pilhas é verificar se os parênteses (ou colchetes, chaves) de uma expressão estão corretamente balanceados. A ideia: percorrer a expressão da esquerda para a direita, empilhando cada abertura, e desempilhando a cada fechamento correspondente. Para isso, usamos o campo cod de um Produto apenas para guardar o caractere empilhado — os demais campos ficam sem uso nesta aplicação específica, já que a pilha desta aula é definida sobre Produto:

int parentesesBalanceados(char *expressao) {
    PilhaEnc p;
    int i;
    char c;
    Produto simbolo, removido;

    inicializa(&p);

    for (i = 0; expressao[i] != '\0'; i++) {
        c = expressao[i];

        if (c == '(') {
            simbolo.cod = c;
            empilha(&p, simbolo);
        } else if (c == ')') {
            if (!desempilha(&p, &removido)) // fechou sem ter aberto: desbalanceado
                return 0;
        }
    }

    // se sobrou algo na pilha, há aberturas sem fechamento correspondente
    return estaVazia(&p);
}

Por exemplo, "(a(b)c)" é balanceado (a pilha termina vazia), enquanto "(a(b)c" não é (sobra um ( na pilha) e "a)b(" também não é (o primeiro ) tenta desempilhar uma pilha já vazia).

Outras aplicações

  • Desfazer/refazer (undo/redo): cada ação do usuário é empilhada; "desfazer" desempilha a última ação.
  • Navegação "voltar" de um navegador: cada página visitada é empilhada; "voltar" desempilha a página atual e exibe a anterior.
  • Avaliação de expressões em notação pós-fixa (ex: calculadoras), e conversão de expressões infixas para pós-fixas — assuntos que costumam ser aprofundados em Análise e Projeto de Algoritmos I.

Resumo

  • Pilha (LIFO): inserções e remoções sempre no topo — o último elemento inserido é o primeiro removido.
  • Operações: empilha (push), desempilha (pop), topo (peek), estaVazia.
  • Implementação principal: lista encadeada (PilhaEnc), com o topo representado pelo início da lista — todas as operações são O(1), sem limite de capacidade.
  • Também é possível implementar por vetor (capacidade fixa, com risco de overflow) — veja o Exercício 7.
  • Aplicações: pilha de chamadas de função, parênteses balanceados, undo/redo, navegação "voltar", avaliação de expressões pós-fixas.

Exercícios

  1. Explique, com suas próprias palavras, o que significa a sigla LIFO e como ela se relaciona com a operação desempilha.
  2. Por que a pilha implementada sobre lista encadeada usa o início da lista como topo, e não o final?
  3. Explique por que a pilha de chamadas de função (Material Extra — Conceitos de Linguagens de Programação) é um exemplo de estrutura LIFO. O que corresponde a "empilhar" e o que corresponde a "desempilhar" nesse contexto?
  4. Na função parentesesBalanceados, o que aconteceria se trocássemos a pilha por uma fila (que veremos na próxima aula, com comportamento FIFO)? O algoritmo continuaria funcionando corretamente?
  5. Implemente uma função int inverterVetor(Produto vetor[], int n) que utilize uma PilhaEnc para inverter a ordem dos elementos de um vetor de Produto (empilhando todos os elementos e depois desempilhando de volta no próprio vetor).
  6. Estenda o exercício de parênteses balanceados: implemente int expressaoBalanceada(char *expressao), que verifique o balanceamento de três tipos de símbolos — (), [] e {} — garantindo também que eles fecham na ordem correta (por exemplo, "(a[b)c]" deve ser considerado desbalanceado, mesmo tendo a mesma quantidade de aberturas e fechamentos).
  7. Implemente a pilha por vetor. Defina a struct PilhaVet, com um vetor de capacidade fixa (CAPACIDADE) e um índice topo, e implemente as funções inicializa, estaVazia, empilha e desempilha (tratando tanto overflow quanto underflow). Explique, com base na sua implementação, por que todas as operações continuam sendo O(1), assim como na versão por lista encadeada.

Sugestões de Respostas dos Exercícios

Exercício 1

LIFO significa Last-In, First-Out — "o último a entrar é o primeiro a sair". Isso se relaciona diretamente com desempilha: essa operação sempre remove o elemento que foi inserido mais recentemente (o topo), nunca o elemento mais antigo da pilha.


Exercício 2

Porque, em uma lista simplesmente encadeada sem um ponteiro para o final (como vimos na Aula 4), inserir e remover no início são operações O(1), enquanto inserir e remover no final seriam O(n) (seria preciso percorrer a lista inteira para achar o último nodo). Como todas as operações da pilha acontecem sempre na mesma ponta, escolher o início garante que todas sejam O(1).


Exercício 3

Cada chamada de função empilha um novo quadro de ativação na pilha de execução, contendo seus parâmetros e variáveis locais. Quando a função retorna, esse quadro é desempilhado. Como o retorno de uma função sempre acontece antes do retorno de quem a chamou, a última função chamada é sempre a primeira a retornar — exatamente o comportamento LIFO.


Exercício 4

Não, o algoritmo pararia de funcionar corretamente. Uma fila (FIFO) removeria sempre a abertura mais antiga ainda não fechada, e não a mais recente. Para expressões com parênteses aninhados, como "(a(b)c)", isso faria o algoritmo fechar o parêntese errado — o primeiro ) deveria fechar o ( mais interno (o mais recente), e uma fila devolveria o mais externo (o mais antigo) no lugar.


Exercício 5
int inverterVetor(Produto vetor[], int n) {
    PilhaEnc p;
    int i;
    Produto valor;

    inicializa(&p);

    for (i = 0; i < n; i++) {
        if (!empilha(&p, vetor[i]))
            return 0; // falha na alocação de algum nodo
    }

    for (i = 0; i < n; i++) {
        desempilha(&p, &valor);
        vetor[i] = valor;
    }

    return 1;
}

Como a pilha inverte a ordem naturalmente (o último elemento empilhado é o primeiro desempilhado), basta empilhar o vetor inteiro e depois desempilhá-lo de volta nas mesmas posições.


Exercício 6
int expressaoBalanceada(char *expressao) {
    PilhaEnc p;
    int i;
    char c;
    Produto simbolo, removido;

    inicializa(&p);

    for (i = 0; expressao[i] != '\0'; i++) {
        c = expressao[i];

        if (c == '(' || c == '[' || c == '{') {
            simbolo.cod = c;
            empilha(&p, simbolo);
        } else if (c == ')' || c == ']' || c == '}') {
            if (!desempilha(&p, &removido))
                return 0; // fechou sem ter aberto nada

            // o símbolo desempilhado precisa ser exatamente a abertura correspondente
            if ((c == ')' && removido.cod != '(') ||
                (c == ']' && removido.cod != '[') ||
                (c == '}' && removido.cod != '{')) {
                return 0; // fechou o símbolo errado
            }
        }
    }

    return estaVazia(&p);
}

A diferença em relação a parentesesBalanceados é que, ao desempilhar, agora verificamos qual símbolo foi desempilhado, e não apenas se havia algo para desempilhar — garantindo que os fechamentos correspondam exatamente às aberturas mais recentes, na ordem correta.


Exercício 7
#define CAPACIDADE 50

typedef struct {
    Produto dados[CAPACIDADE];
    int topo; // índice do elemento no topo; -1 significa pilha vazia
} PilhaVet;

void inicializa(PilhaVet *p) {
    p->topo = -1;
}

int estaVazia(PilhaVet *p) {
    return p->topo == -1;
}

int empilha(PilhaVet *p, Produto valor) {
    if (p->topo == CAPACIDADE - 1) // overflow
        return 0;

    p->topo++;
    p->dados[p->topo] = valor;
    return 1;
}

int desempilha(PilhaVet *p, Produto *valorRemovido) {
    if (estaVazia(p)) // underflow
        return 0;

    *valorRemovido = p->dados[p->topo];
    p->topo--;
    return 1;
}

Todas as operações mexem apenas na posição p->topo (lendo, escrevendo, incrementando ou decrementando um único índice) — diferente do que acontecia com listas por contiguidade física (Aula 2c), aqui nunca é preciso deslocar elementos, pois inserções e remoções acontecem sempre na mesma ponta do vetor. Por isso, assim como na versão por lista encadeada, todas as operações são O(1) — a diferença prática é que esta versão tem uma capacidade máxima fixa (CAPACIDADE) e pode sofrer overflow, o que não acontece na versão dinâmica por lista encadeada.


Código para Download

O TAD PilhaEnc completo, exatamente como visto nesta aula, dividido em interface (pilhaEnc.h) e implementação (pilhaEnc.c), junto com um main.c de exemplo:

pilhaEnc.zippilhaEnc.h, pilhaEnc.c e main.c.