Pilhas
Igor Machado Coelho
16/09/2020 - 13/04/2023
São requisitos para essa aula o conhecimento de:
A Pilha (do inglês Stack) é um Tipo Abstrato de Dado (TAD) que pode ser compreendida como vemos no cotidiano.
Em uma pilha de pratos, por exemplo:
Pilhas são estruturas fundamentais na própria computação.
Por exemplo, as chamadas de uma função recursiva podem ser feitas utilizando uma pilha!
… e é precisamente desta maneira que o sistema operacional consegue executar várias de suas funções internas!
Linguagens de programação como Java, C# e Python são implementadas através de operações em pilhas.
Uma Pilha é uma estrutura de dados linear (assim como estruturas de lista), consistindo de 3 operações básicas:
Seu comportamento é descrito como LIFO (last-in first-out), ou seja, o último elemento a entrar na pilha será o primeiro a sair.
De forma geral, uma pilha pode ser implementada utilizando uma lista linear, porém com acesso aos elementos restritos a uma única extremidade dessa lista.
⇄∣C∣B∣A∣
Em C/C++, os métodos esperados para uma pilha de tipo t
são: topo()
, empilha(t)
,
desempilha()
, tamanho()
.
As Pilhas Sequenciais utilizam um array para armazenar os dados. Assim, os dados sempre estarão em um espaço contíguo de memória.
Consideraremos uma pilha sequencial com, no máximo, MAXN
elementos do tipo caractere.
constexpr int MAXN = 100'000; // capacidade máxima da pilha
class PilhaSeq1
{
public:
char elementos [MAXN]; // elementos na pilha
int N; // num. de elementos na pilha
void cria () { ... } // inicializa agregado
void libera () { ... } // finaliza agregado
char topo () { ... }
void empilha (char dado){ ... };
char desempilha () { ... };
int tamanho() { ... };
};
Antes de completar as funções pendentes, utilizaremos a
PilhaSeq1
:
int main () {
PilhaSeq1 p;
p.cria();
p.empilha('A');
p.empilha('B');
p.empilha('C');
print("{}\n", p.topo());
print("{}\n", p.desempilha());
p.empilha('D');
while(p.tamanho() > 0)
print("{}\n", p.desempilha());
p.libera();
return 0;
}
Verifique as impressões em tela: C C D B A
A operação cria
inicializa a pilha para uso, e a função
libera
desaloca os recursos dinâmicos.
A operação empilha
em uma pilha sequencial adiciona um
novo elemento ao topo da pilha. A operação desempilha
em
uma pilha sequencial remove e retorna o último elemento da pilha.
A operação de topo em uma pilha sequencial retorna o último elemento empilhado.
class PilhaSeq1 {
...
char topo() { return this->elementos[this->N-1]; }
int tamanho() { return this->N; }
...
}
Desafio: O que aconteceria se a pilha
estivesse vazia e o topo()
fosse invocado? Como permitir
que o programa continue mesmo após situações inesperadas como
essa?
Dica: Retorne um char
opcional, com uma pequena modificação na função
topo()
. Exemplo:
std::optional<char> topo() { ... }
.
Considere uma pilha sequencial (MAXN=5
):
PilhaSeq1 p; p.cria();
p.N: | 0 | p.elementos: | | | | | |
0 1 2 3 4
Agora, empilhamos A
, B
e C
, e
depois desempilhamos uma vez.
p.N: | 1 | p.elementos: | A | | | | |
0 1 2 3 4
p.N: | 2 | p.elementos: | A | B | | | |
0 1 2 3 4
p.N: | 3 | p.elementos: | A | B | C | | |
0 1 2 3 4
p.N: | 2 | p.elementos: | A | B | | | |
0 1 2 3 4
Qual o topo atual da pilha?
A Pilha Sequencial tem a vantagem de ser bastante simples de implementar, ocupando um espaço constante (na memória) para todas operações.
Porém, existe a limitação física de MAXN
posições
imposta pela alocação estática, não permitindo que a pilha ultrapasse
esse limite.
Desafio: implemente uma Pilha Sequencial
utilizando alocação dinâmica para o vetor elementos
. Assim,
quando não houver espaço para novos elementos, aloque mais espaço na
memória (copiando elementos existentes para o novo vetor).
Dica: Experimente a estratégia de
dobrar a capacidade da pilha (quando necessário), e reduzir à
metade a capacidade (quando necessário). Essa estratégia é bastante
eficiente, mas requer alteração nos métodos cria
,
libera
, empilha
e desempilha
.
A implementação do TAD Pilha pode ser feito através de uma estrutura encadeada com alocação dinâmica de memória.
A vantagem é não precisar pre-determinar uma capacidade máxima da pilha (o limite é a memória do computador!). A desvantagem é depender de implementações ligeiramente mais complexas.
Consideraremos uma pilha encadeada, utilizando um agregado
NoPilha1
para conectar cada elemento da pilha:
Variável local do tipo Pilha Encadeada:
p.N: 0 p.inicio: 0 topo←ϵ
| | | | | | | | | | |
0 4 ... 100 104 108 112 116 ... 8GiB
void empilha(char v) {
auto* no = new NoPilha1{.dado = v, .prox = this->inicio};
this->inicio = no;
this->N++; // N = N + 1
}
p.empilha('A'); p.empilha('B');
p.N: 0 p.inicio: 0 topo←ϵ
| | | | | | | | | | |
0 4 ... 100 104 108 112 116 ... 8GiB
p.N: 1 p.inicio: 112 topo←A
| | | | | | | A | 0 | | |
0 4 ... 100 104 108 112 116 ... 8GiB
p.N: 2 p.inicio: 100 topo←B←A
| | | | B | 112 | | A | 0 | | |
0 4 ... 100 104 108 112 116 ... 8GiB
p.desempilha();
p.N: 2 p.inicio: 100 topo←B←A
| | | | B | 112 | | A | 0 | | |
0 4 ... 100 104 108 112 116 ... 8GiB
p.N: 1 p.inicio: 112 topo←A
| | | | | | | A | 0 | | |
0 4 ... 100 104 108 112 116 ... 8GiB
void libera() {
while (this->N > 0) {
NoPilha1* p = this->inicio->prox;
delete this->inicio; this->inicio = p; this->N--;
}
}
p.libera();
p.N: 2 p.inicio: 100 topo←B←A
| | | | B | 112 | | A | 0 | | |
0 4 ... 100 104 108 112 116 ... 8GiB
p.N: 1 p.inicio: 112 topo←A
| | | | | | | A | 0 | | |
0 4 ... 100 104 108 112 116 ... 8GiB
p.N: 0 p.inicio: 0 topo←ϵ
| | | | | | | | | | |
0 4 ... 100 104 108 112 116 ... 8GiB
Para uma implementação mais segura, é possível utilizar smart
pointers. Em especial, basta utilizar o
std::unique_ptr
. Para simplificar a sintaxe, consideramos o
seguinte “atalho” para o nome dos ponteiros únicos:
Dessa forma, basta escrever uptr<int>
para
representar um std::unique_ptr<int>
.
Consideraremos uma pilha encadeada, utilizando um agregado
NoPilha2
para conectar cada elemento da pilha:
A Pilha Encadeada é flexível em relação ao espaço de memória, permitindo maior ou menor utilização.
Como desvantagem tende a ter acessos de memória ligeiramente mais lentos, devido ao espalhamento dos elementos por toda a memória do computador (perdendo as vantagens de acesso rápido na memória cache, por exemplo).
Também é considerada como desvantagem o gasto de espaço extra com ponteiros em cada elemento, o que não acontece na Pilha Sequencial.
Uma implementação genérica da pilha sequencial pode ser feita utilizando templates, inclusive para o limite de capacidade (permitindo maior personalização caso a caso).
template<typename T, int MAXN>
class PilhaSeqX
{
public:
T elementos [MAXN]; // elementos na pilha
int N; // num. de elementos na pilha
void cria () { ... } // inicializa agregado
void libera () { ... } // finaliza agregado
T topo () { ... }
void empilha (T dado){ ... };
T desempilha () { ... };
int tamanho() { ... };
};
Antes de completar as funções pendentes, utilizaremos a
PilhaSeqX
:
int main () {
PilhaSeqX<char, 100'000> p;
p.cria();
p.empilha('A');
p.empilha('B');
p.empilha('C');
print("{}\n", p.topo());
print("{}\n", p.desempilha());
p.empilha('D');
while(p.tamanho() > 0)
print("{}\n", p.desempilha());
p.libera();
return 0;
}
Verifique as impressões em tela: C C D B A
PilhaTAD
em C++O conceito de pilha somente requer suas três operações
básicas. Como consideramos uma pilha genérica (pilha de
inteiro, char, etc), definimos um conceito genérico chamado
PilhaTAD
:
PilhaSeq1
satisfaz conceito
PilhaTAD
O static_assert
pode ser usado para assegurar a
corretude de implementação do conceito PilhaTAD
:
constexpr int MAXN = 100'000; // capacidade máxima da pilha
class PilhaSeq1 {
public:
char elementos [MAXN]; // elementos na pilha
int N; // num. de elementos na pilha
// implementa métodos da Pilha
// ...
};
// verifica se agregado PilhaSeq1 satisfaz conceito PilhaTAD
static_assert(PilhaTAD<PilhaSeq1, char>);
PilhaEnc1
satisfaz conceito
PilhaTAD
O static_assert
pode ser usado para assegurar a
corretude de implementação do conceito PilhaTAD
:
std::stack
Em C/C++, é possível utilizar implementações prontas do TAD Pilha. A vantagem é a grande eficiência computacional e amplo conjunto de testes, evitando erros de implementação.
Na STL, basta fazer #include<stack>
e usar métodos
push
, pop
e top
.
Como reverter uma string? Exemplo: rev("ESCOLA")
=>
"ALOCSE"
.
Solução com pilha (sem recursão):
std::stack
Desafio: escreva um conceito
(utilizando o recurso C++ Concepts) para o std::stack
da
STL, considerando operações push
, pop
e
top
.
Dica: Utilize o conceito
PilhaTAD
apresentado no curso, e faça os devidos ajustes.
Verifique se std::stack
passa no teste com
static_assert
.
Você pode compilar o código proposto (começando pelo slide
anterior em um arquivo chamado
material/3-pilhas/main_pilha_stl.cpp
) através do
comando:
g++ --std=c++20 main_pilha_stl.cpp -o appPilha
Fim parte de implementações.
Além da bibliografia do curso, recomendamos para esse tópico:
Em especial, agradeço aos colegas que elaboraram bons materiais, como o prof. Fabiano Oliveira (IME-UERJ), e o prof. Jayme Szwarcfiter cujos conceitos formam o cerne desses slides.
Estendo os agradecimentos aos demais colegas que colaboraram com a elaboração do material do curso de Pesquisa Operacional, que abriu caminho para verificação prática dessa tecnologia de slides.
Esse material de curso só é possível graças aos inúmeros projetos de código-aberto que são necessários a ele, incluindo:
Agradecimento especial a empresas que suportam projetos livres envolvidos nesse curso:
Esses slides foram escritos utilizando pandoc, segundo o tutorial ilectures:
Exceto expressamente mencionado (com as devidas ressalvas ao material cedido por colegas), a licença será Creative Commons.
Licença: CC-BY 4.0 2020
Igor Machado Coelho