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 |
|---|---|
| push | Insere um novo elemento no topo da pilha. |
| 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(const PilhaEnc *p) { return p->topo == NULL; } int push(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 pop(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(const PilhaEnc *p, Produto *valor) { if (estaVazia(p)) return 0; *valor = p->topo->dado; return 1; } int tamanho(const 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; push(&p, simbolo); } else if (c == ')') { if (!pop(&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
|
Exercícios
-
Explique, com suas próprias palavras, o que significa a sigla LIFO e como ela se relaciona com a operação
pop. - Por que a pilha implementada sobre lista encadeada usa o início da lista como topo, e não o final?
- 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?
-
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? -
Implemente uma função
int inverterVetor(Produto vetor[], int n)que utilize umaPilhaEncpara inverter a ordem dos elementos de um vetor deProduto(empilhando todos os elementos e depois desempilhando de volta no próprio vetor). -
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). -
Implemente a pilha por vetor. Defina a struct
PilhaVet, com um vetor de capacidade fixa (CAPACIDADE) e um índicetopo, e implemente as funçõesinicializa,estaVazia,pushepop(tratando tanto overflow quanto underflow). Explique, com base na sua implementação, por que todas as operações continuam sendoO(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 pop: 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 (!push(&p, vetor[i])) return 0; // falha na alocação de algum nodo } for (i = 0; i < n; i++) { pop(&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; push(&p, simbolo); } else if (c == ')' || c == ']' || c == '}') { if (!pop(&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(const PilhaVet *p) { return p->topo == -1; } int push(PilhaVet *p, Produto valor) { if (p->topo == CAPACIDADE - 1) // overflow return 0; p->topo++; p->dados[p->topo] = valor; return 1; } int pop(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.
Questões teóricas
-
Se adicionássemos um campo
int tamanhoà structPilhaEnc, qual seria o impacto nas funções já implementadas? Em que situações esse campo valeria a pena, e o que seria necessário manter consistente? -
A implementação por lista encadeada coloca o topo no início da lista; a implementação por vetor (
PilhaVet) usa o índice mais alto como topo. Por que ambas as escolhas são O(1) para push e pop, mesmo sendo opostas em direção? -
Por que a operação
topo(peek) não é implementada simplesmente como umpopseguido de umpushdo valor removido? O que se perderia em termos de robustez e semântica? -
Trace manualmente o estado da pilha durante a execução de
parentesesBalanceados("(a(b)c)"). Mostre o conteúdo da pilha após o processamento de cada caractere, e indique o valor retornado ao final. -
A pilha de chamadas de função do C é LIFO. O que aconteceria concretamente se ela fosse FIFO? Considere a cadeia de chamadas
main→f1→f2: qual função retornaria primeiro, e qual seria o problema prático disso? -
Suponha que você precise de uma estrutura que acesse eficientemente tanto o elemento inserido mais recentemente quanto o mais antigo. Seria suficiente usar apenas uma
PilhaEnc? Justifique, e indique qual outra estrutura seria mais adequada.
Questões de implementação
Nas questões abaixo, todas as soluções devem usar a PilhaEnc como cliente: utilize apenas as funções do TAD (inicializa, push, pop, topo, estaVazia) — sem acessar nem modificar os campos internos da struct diretamente.
-
Implemente
void decimalParaBinario(int n)que, usando umaPilhaEnc, imprima a representação binária den: divida repetidamente por 2, empilhando cada resto; ao final, desempilhe e imprima os restos na ordem correta (do bit mais significativo para o menos significativo). Use o campocoddeProdutopara guardar cada bit (0 ou 1). -
Implemente
int ehPalindromo(char *str, int n)que use umaPilhaEncpara verificar sestr(de comprimenton) é um palíndromo: empilhe os primeirosn/2caracteres; compare, desempilhando, com osn/2últimos caracteres. Use o campocodpara guardar cada caractere (int). -
Implemente
int avaliarPosFixa(char *expr)que avalie uma expressão em notação pós-fixa contendo dígitos ('0'–'9') e operadores ('+','-','*'). Ao percorrer a string: se for dígito, empilhe o valor numérico; se for operador, desempilhe dois operandos, calcule e empilhe o resultado. Retorne o valor no topo ao final. Exemplo:"23+4*"→(2+3)*4 = 20. -
Implemente
void imprimirPilhaIntacta(PilhaEnc *p)que imprima os elementos do topo ao fundo sem modificar a pilha ao final. Use uma segundaPilhaEncauxiliar: transfira tudo para ela (imprimindo no caminho), depois transfira de volta — a pilha original deve terminar com os mesmos elementos e na mesma ordem. -
Implemente um sistema simples de undo com duas funções:
void executarAcao(PilhaEnc *historico, Produto acao), que empilha a ação realizada; eint desfazer(PilhaEnc *historico, Produto *ultima), que desempilha a última ação em*ultimae retorna1, ou retorna0se não houver nada para desfazer. Escreva também um pequenomainde exemplo executando 3 ações e depois 2 desfazer. -
Implemente
int sequenciaPopValida(int n, int saida[], int m)que determine se a sequênciasaida[0..m-1]é uma sequência de pops válida supondo que produtos decod = 1, 2, ..., nsão empilhados em ordem crescente (um de cada vez, podendo intercalar pops). Use umaPilhaEncpara simular: para cada valor emsaida, empilhe novos elementos enquanto o topo não for o esperado; se precisar desempilhar de uma pilha vazia ou o topo não bater, a sequência é inválida.
Sugestões de Respostas — Questões teóricas e de implementação
Questão teórica 1
Seria necessário incrementar tamanho em push, decrementá-lo em pop bem-sucedido, e zerá-lo em inicializa. O campo permitiria uma função tamanho em O(1) (hoje ela percorre a lista, O(n)). Vale a pena quando o tamanho da pilha é consultado com frequência; o custo é ter mais estado para manter consistente — qualquer path de código que altere a pilha precisa lembrar de atualizar o campo.
Questão teórica 2
Na lista encadeada, inserir e remover no início são O(1) — basta atualizar o ponteiro topo. Inserir ou remover no fim seria O(n) (percorrer até o último nodo). Por isso o topo fica no início.
No vetor, o índice topo aumenta a cada push e diminui a cada pop — ambas as operações mexem apenas nessa posição e no índice, sem deslocar nenhum elemento. Por isso o topo efetivo é a posição mais alta do vetor, que cresce e encolhe pelo lado "direito". Ambas as estratégias são O(1) por razões simétricas: cada uma explora a extremidade de fácil acesso de sua estrutura subjacente.
Questão teórica 3
Porque pop pode falhar (se a pilha estiver vazia) e push pode falhar (se malloc retornar NULL). Implementar topo como "pop + push" arriscaria: (a) remover o elemento e depois não conseguir recolocá-lo, corrompendo a pilha; (b) retornar sucesso falso para uma pilha vazia, ou vice-versa. A implementação direta (ler p->topo->dado sem remover) é mais simples, mais segura e mais eficiente.
Questão teórica 4
Expressão: ( a ( b ) c )
↑ ↑ ↑ ↑ ↑ ↑ ↑
'(' → push('(') pilha: ['(']
'a' → nada pilha: ['(']
'(' → push('(') pilha: ['(', '('] ← topo: segundo '('
'b' → nada pilha: ['(', '(']
')' → pop pilha: ['(']
'c' → nada pilha: ['(']
')' → pop pilha: []
pilha vazia → retorna 1 (balanceado)
Questão teórica 5
Com uma pilha FIFO, a primeira função a retornar seria a mais antiga ainda na "pilha" — ou seja, main retornaria antes de f1 e f2. O problema prático é que f1 chamou f2 e ainda está esperando seu retorno; se main retornar antes, os recursos alocados por f1 e f2 (variáveis locais, parâmetros) continuariam ocupando memória sem que nenhuma função os libere corretamente — além de o programa encerrar antes de f2 ter a chance de retornar seu valor para f1.
Questão teórica 6
Não. Uma PilhaEnc dá acesso O(1) apenas ao topo (o mais recente); acessar o elemento mais antigo (o fundo) exigiria esvaziar toda a pilha — O(n). A estrutura adequada para acesso eficiente a ambas as extremidades é uma fila de dupla entrada (deque), ou a combinação de uma fila convencional com uma pilha.
Questão de implementação 1
void decimalParaBinario(int n) { PilhaEnc p; Produto bit, removido; inicializa(&p); if (n == 0) { printf("0\n"); return; } while (n > 0) { bit.cod = n % 2; push(&p, bit); n /= 2; } while (!estaVazia(&p)) { pop(&p, &removido); printf("%d", removido.cod); } printf("\n"); }
Questão de implementação 2
int ehPalindromo(char *str, int n) { PilhaEnc p; Produto c, removido; int i; inicializa(&p); for (i = 0; i < n / 2; i++) { c.cod = str[i]; push(&p, c); } // se n é ímpar, pula o caractere do meio for (i = (n + 1) / 2; i < n; i++) { pop(&p, &removido); if (removido.cod != str[i]) return 0; } return 1; }
Questão de implementação 3
int avaliarPosFixa(char *expr) { PilhaEnc p; Produto op1, op2, res; int i; inicializa(&p); for (i = 0; expr[i] != '\0'; i++) { if (expr[i] >= '0' && expr[i] <= '9') { res.cod = expr[i] - '0'; push(&p, res); } else { pop(&p, &op2); // segundo operando (topo) pop(&p, &op1); // primeiro operando if (expr[i] == '+') res.cod = op1.cod + op2.cod; else if (expr[i] == '-') res.cod = op1.cod - op2.cod; else if (expr[i] == '*') res.cod = op1.cod * op2.cod; push(&p, res); } } pop(&p, &res); return res.cod; }
Questão de implementação 4
void imprimirPilhaIntacta(PilhaEnc *p) { PilhaEnc aux; Produto val; inicializa(&aux); // transfere para auxiliar e imprime while (!estaVazia(p)) { pop(p, &val); printf("cod=%d\n", val.cod); push(&aux, val); } // devolve para a original (restaura a ordem) while (!estaVazia(&aux)) { pop(&aux, &val); push(p, val); } }
Questão de implementação 5
void executarAcao(PilhaEnc *historico, Produto acao) { push(historico, acao); } int desfazer(PilhaEnc *historico, Produto *ultima) { return pop(historico, ultima); } // Exemplo de uso: int main() { PilhaEnc historico; Produto a1 = {1, "Inserir A", 0}; Produto a2 = {2, "Inserir B", 0}; Produto a3 = {3, "Deletar C", 0}; Produto ultima; inicializa(&historico); executarAcao(&historico, a1); executarAcao(&historico, a2); executarAcao(&historico, a3); desfazer(&historico, &ultima); // desfaz a3 printf("Desfeito: %s\n", ultima.nome); desfazer(&historico, &ultima); // desfaz a2 printf("Desfeito: %s\n", ultima.nome); return 0; }
Questão de implementação 6
int sequenciaPopValida(int n, int saida[], int m) { PilhaEnc p; Produto val, topo_val; int proximo_push = 1; // próximo cod a empilhar int i; inicializa(&p); for (i = 0; i < m; i++) { // empilha até o topo ser o esperado ou acabar os pushes while ((estaVazia(&p) || (topo(&p, &topo_val) && topo_val.cod != saida[i])) && proximo_push <= n) { val.cod = proximo_push++; push(&p, val); topo(&p, &topo_val); } if (estaVazia(&p) || topo_val.cod != saida[i]) return 0; // não é possível gerar saida[i] pop(&p, &val); } return 1; }
Exemplo: n=3, saida=[3,2,1] → válida (empilha 1,2,3 → pop 3 → pop 2 → pop 1). saida=[3,1,2] → inválida (após pop 3 e pop 1, o 2 não está no topo — o 1 já foi removido).
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.zip — pilhaEnc.h, pilhaEnc.c e main.c.
|