Pilhas em Python com listas (stack)
Pilhas em Python são comumente criadas usando
listas, porque estamos interessados em manipular apenas a extremidade
do topo da estrutura (o final da lista). Isso nos garante
complexidade de tempo O(1)
(tempo constante) com métodos append
e
pop
.
Uma pilha é uma coleção de itens que segue o princípio LIFO (Last In First Out) , ou seja, o último item a entrar, será o primeiro a sair. A adição e remoção de novos itens ocorre sempre na mesma ponta da pilha, o final da lista. O final da lista (último índice), representa o topo da pilha, o começo da lista (índice 0) representa a base da pilha.
Itens mais novos são sempre adicionados no topo da pilha e se tornam mais antigos a medida que adicionamos outros itens. Quanto maior o índice na lista, mais novo é o item e mais cedo ele será removido da pilha.
Pilhas são estruturas de dados muito simples, mas podem ser usadas para representar várias coisas no mundo real, por exemplo: uma pilha de pratos, uma pilha de livros ou qualquer coisa que você coloque uma sobre a outra formando uma pilha. Além disso, programas também usam pilhas, como a pilha de chamadas para funções que já vimos anteriormente nesse blog.
Listas são pilhas muito genéricas em Python
Como pilhas só precisam de métodos para adicionar e remover itens do seu topo, as listas do Python já fazem esse trabalho com excelência e complexidade de tempo O(1), ou tempo constante. Então, é super rápido.
Porém, ou você só usa esses dois
métodos (append
e pop
), ou
você cria sua própria estrutura de dados chamada de
Stack
(uma classe que você verá ao final
desse post) para outros desenvolvedores não usarem sua
pilha de maneira genérica (como filas, por exemplo).
Se você quiser trabalhar diretamente com as listas, pode fazer o seguinte:
# Para Type annotation from typing import List # Pilha de livros com type annotation stack_of_books: List[str] = [] # {1} # Adicionando livros no topo da pilha stack_of_books.append('Livro 1') # {2} stack_of_books.append('Livro 2') # {2} stack_of_books.append('Livro 3') # {2} # Obtendo o elemento mais novo book = stack_of_books.pop() # {3} print(book) # Livro 3 {4}
Perceba que no código acima, criei uma pilha de livros
(stack_of_books
) como uma lista vazia {1}. Quando
precisei adicionar livros na pilha, usei o método
append
, que é padrão de listas em
Python para adicionar itens ao final da lista {2}. Além
disso, quando precisei obter o livro mais novo (do topo da pilha),
usei o método pop
, que também é
padrão de listas em Python para obter o último item
da lista (ou o elemento do topo da pilha) {3}.
Na verdade, o método pop
faz duas coisas.
Além de remover o item do topo da pilha, ele também
retorna este valor. Então, Isso me permitiu fazer duas
coisas em {3}: remover o item do topo da pilha e coletar seu valor
em uma variável.
Por fim {4}, imprimimos o valor de book, que foi o “Livro 3”.
Cuidados com pop
Note que, a cada execução do método
pop
, o item mais novo será removido da pilha.
Em algum momento, sua pilha poderá estar vazia e esse
método levantará a exceção
“IndexError: pop from empty list
“, ou
“Erro de índice: pop de uma lista vazia” (em tradução livre).
# Para Type annotation from typing import List # Pilha de livros com type annotation stack_of_books: List[str] = [] # {1} # Adicionando livros no topo da pilha stack_of_books.append('Livro 1') # {2} stack_of_books.append('Livro 2') # {2} stack_of_books.append('Livro 3') # {2} # Obtendo o elemento mais novo book_1 = stack_of_books.pop() # Livro 3 {3} book_2 = stack_of_books.pop() # Livro 2 {3} book_3 = stack_of_books.pop() # Livro 1 {3} # IndexError: pop from empty list book = stack_of_books.pop() # Não há mais livros {4}
Isso ocorre porque não há mais valores para serem removidos e recuperados da sua pilha {4}.
Para prevenir esse tipo de situação, podemos
envolver nosso código em um bloco try
e
except
, tratando a exceção
“IndexError
“. Veja:
# Para Type annotation from typing import List # Pilha de livros com type annotation stack_of_books: List[str] = [] # {1} # Adicionando livros no topo da pilha stack_of_books.append('Livro 1') # {2} stack_of_books.append('Livro 2') # {2} stack_of_books.append('Livro 3') # {2} try: # Obtendo o elemento mais novo book_1 = stack_of_books.pop() # Livro 3 {3} print(book_1) # Livro 3 book_2 = stack_of_books.pop() # Livro 2 {3} print(book_2) # Livro 2 book_3 = stack_of_books.pop() # Livro 1 {3} print(book_3) # Livro 1 # IndexError: pop from empty list book = stack_of_books.pop() # Não há mais livros {4} print(book) # Seu código não chegará aqui except IndexError: print('Trate o erro como preferir aqui.')
Assim, a partir do momento que a exceção ocorrer {4}, seu código pulará imediatamente para o bloco except. Se não houver exceções, seu código não executará o bloco except.
Não use listas como filas
Além das pilhas, outra estrutura de dados muito usada em programação são as filas (falamos sobre elas em outro artigo). Basicamente, filas trabalham no lado oposto das pilhas. Então, se nas pilhas você trabalha na extremidade final (adicionando e removendo itens do topo), nas filas você trabalha nas duas extremidades, enfileirando itens no final (topo) e desenfileirando itens do início (base).
As listas podem ser usadas para filas também, porém, não é recomendável. Isso torna sua complexidade de tempo O(n), ou tempo linear, o que torna as listas mais lentas para isso.
O método pop, pode receber o índice que você pretende remover da pilha, por exemplo (mas não faça isso):
# Para Type annotation from typing import List # Pilha de livros com type annotation stack_of_books: List[str] = [] # {1} # Adicionando livros no topo da pilha stack_of_books.append('Livro 1') # {2} stack_of_books.append('Livro 2') # {2} stack_of_books.append('Livro 3') # {2} book_1 = stack_of_books.pop(0) # {3} Livro 1 print(book_1) # Livro 1
Perceba que, no código acima, eu consigo passar um
índice para pop
descrevendo qual livro quero
remover da pilha {3}. Assim, isso me permite remover o item da
base da minha pilha (índice 0). Mas, o problema ao fazer
algo assim, é que como as listas não foram
otimizadas para tal, todos os outros itens da lista agora precisam
ter seus índices alterados. O que tinha índice 1,
passa a ter índice 0, o que tinha índice 2, passa a
ter índice 1 e assim por diante.
A complexidade de tempo O(n) significa que, na pior das hipóteses, quando eu removo um item da base da minha pilha (como nas filas), todos os outros items agora precisam ser modificados, e eu assumo que não era essa a sua intenção.
Nós já falamos sobre filas aqui, mas para resumir, use collections.deque para filas e listas para pilhas.
Iterando pilhas
Em Python a iteração dos iteráveis é
feita com for
. Se você não quer remover
nenhum elemento, mas apenas iterar sobre toda a pilha, faça
o seguinte:
# Para Type annotation from typing import List # Pilha de livros com type annotation stack_of_books: List[str] = [] # {1} # Adicionando livros no topo da pilha stack_of_books.append('Livro 1') # {2} stack_of_books.append('Livro 2') # {2} stack_of_books.append('Livro 3') # {2} # Laço for for book in stack_of_books[::-1]: # Faça o que preferir com o Livro print(book) """ Saída: Livro 3 Livro 2 Livro 1 """
Perceba que no código acima, na linha 13, temos um
laço for que vai iterar em todos os elementos da lista.
Então eu posso fazer o que preferir com a variável
book
.
Detalhe importante: para manter a ordem da pilha
(do topo para a base), eu preciso fazer uma iteração
em ordem reversa na pilha quando uso for
. Por isso
adicionei [::-1]
em stack_of_books
. Isso
inverte a ordem de listas em Python.
Um outro tipo de iteração sobre pilha, seria
removendo os elementos da pilha ao mesmo tempo que itero sobre
eles. Podemos fazer isso com o laço while
:
# Para Type annotation from typing import List # Pilha de livros com type annotation stack_of_books: List[str] = [] # {1} # Adicionando livros no topo da pilha stack_of_books.append('Livro 1') # {2} stack_of_books.append('Livro 2') # {2} stack_of_books.append('Livro 3') # {2} # Laço while while stack_of_books: book = stack_of_books.pop() print(book) """ Saída: Livro 3 Livro 2 Livro 1 """
Perceba que usando o laço while
com o
método pop
, a ordem é mantida.
Porém, é importante saber que estou removendo cada
um dos elementos da minha pilha.
Copiando pilhas
Trabalhar com dados mutáveis, como as listas, pode ser perigoso em qualquer linguagem de programação porque manipulamos o dado diretamente. Então, é uma boa ideia manter uma cópia do dado original sempre que o manipulamos (se for possível, é claro). Por outro lado, pode ser caro manter cópia de dados que são muito grandes, então você precisa ponderar as duas coisas: segurança e peso.
Manter a cópia de uma lista, pode ter uma complexidade de tempo linear O(n) se você fizer uma cópia rasa (shallow copy) ou mais do que isso se você mantiver uma cópia profunda (deep copy).
Shallow copy:
Esse tipo de cópia é um clone superficial dos itens da pilha. Ela tem complexidade de tempo linear – O(n) – o que significa que é necessário passar por todos os itens para realizar a cópia destes. A parte interessante desse tipo de cópia é que dados imutáveis (como str, int, float e bool) serão realmente copiados para uma nova pilha. Porém, dados mutáveis (como outras listas, dicionários e sets) não serão copiados, a nova pilha terá apenas uma referência para os dados mutáveis da pilha original.
Para realizar uma cópia do tipo shallow copy da sua pilha, faça o seguinte:
# Para Type annotation from typing import List # Pilha de livros com type annotation stack_of_books: List[str] = [] # {1} # Adicionando livros no topo da pilha stack_of_books.append('Livro 1') # {2} stack_of_books.append('Livro 2') # {2} stack_of_books.append('Livro 3') # {2} # A cópia (shallow copy) stack_of_books_copy = stack_of_books.copy() # Agora não estou mais alterando os dados # da pilha original. while stack_of_books_copy: print(stack_of_books_copy.pop()) # A original continua intacta print(stack_of_books) # ['Livro 1', 'Livro 2', 'Livro 3']
Deep copy
Quando falamos em pilhas que contém dados mutáveis, como por exemplo, outras listas dentro da nossa pilha. É preciso tomar cuidado com a shallow copy, porque ela não vai copiar esses elementos, vai fazer apenas uma referência apontando para os dados da lista original. Isso significa que meus dados ainda continuarão mutáveis internamente, mesmo na cópia.
A complexidade de tempo de uma deep copy vai depender da profundidade dos itens mutáveis da sua pilha, quanto maior a profundidade, maior a complexidade (mais tempo leva).
Imagine essa estrutura de dados:
# Para Type annotation from typing import List # Pilha de listas stack_of_lists: List[List[str]] = [] # Adicionando elementos stack_of_lists.append(['A', 'B']) stack_of_lists.append(['C', 'D']) stack_of_lists.append(['E', 'F'])
Esse é um tipo de estrutura que devo tomar cuidado, porque existem listas dentro da minha pilha.
Veja o problema:
# Para Type annotation from typing import List # Pilha de listas stack_of_lists: List[List[str]] = [] # Adicionando elementos stack_of_lists.append(['A', 'B']) stack_of_lists.append(['C', 'D']) stack_of_lists.append(['E', 'F']) # Shallow copy stack_of_lists_clone = stack_of_lists.copy() # Obtendo o elemento do topo da pilha # Isso não altera a lista original item = stack_of_lists_clone.pop() # Mas isso sim item[0] = 'ALTERANDO O DADO' # Veja o resultado na lista original print(stack_of_lists) """ Saída: [['A', 'B'], ['C', 'D'], ['ALTERANDO O DADO', 'F']] """
Os comentários do código acima, explicam o que ocorre em cada trecho. Mas, aqui vai uma breve explicação:
- Linha 13 – Faço uma shallow copy da minha pilha. Como te descrevi anteriormente, dados mutáveis NÃO são copiados de dentro da pilha original, eles passados apenas como referência. Isso significa que a lista cópia terá uma referência desses dados (você consegue acessá-los por ela) mas eles ainda são os dados da lista original e não uma cópia real;
- Linha 17 – Obtém o item do topo da lista (esse ainda é o dado da lista original);
- Linha 20 – Altero o item. Porém, como esse item é uma referência ao item da lista original, quem será alterado efetivamente será o item da lista original;
- Linha 23 – Percebo a besteira que fiz.
Para solucionar esse tipo de coisa, usamos a deep copy, que é um tipo de cópia recursiva. Com isso, todos os dados, mutáveis e imutáveis serão copiados para a lista cópia.
Veja:
# Para Type annotation from typing import List # Preciso importar deepcopy from copy import deepcopy # Pilha de listas stack_of_lists: List[List[str]] = [] # Adicionando elementos stack_of_lists.append(['A', 'B']) stack_of_lists.append(['C', 'D']) stack_of_lists.append(['E', 'F']) # Deep copy stack_of_lists_clone = deepcopy(stack_of_lists) # Obtendo o elemento do topo da pilha # Isso não altera a lista original item = stack_of_lists_clone.pop() # Mas isso sim item[0] = 'ALTERANDO O DADO' # Veja o resultado na lista original print(stack_of_lists) """ Saída: [['A', 'B'], ['C', 'D'], ['E', 'F']] """
Veja que as únicas coisas alteradas foram na linha 5, porque eu preciso importar “deepcopy” do módulo “copy”. E a linha 16, porque ao invés de “shallow copy”, agora estou fazendo uma “deep copy”.
Classe Stack
A maneira mais segura e correta de se trabalhar com listas como
pilhas em Python, é proteger o código para que os
métodos genéricos das listas não possam ser
chamados diretamente. Assim protegeremos o nosso código
para que outros desenvolvedores não possam chamar um
pop(0)
na nossa pilha, dentre vários outros.
Criando uma classe, podemos adicionar exclusivamente os métodos que queremos. Como append, pop e alguns métodos mágicos para iteração e visualização de dados.
from typing import List, Any from copy import deepcopy class Stack: """Uma classe representando uma pilha""" def __init__(self) -> None: # Essa stack é genérica, por isso # a lista poderá receber qualquer # tipo de dados self.__data: List[Any] = [] # Representa o índice para iterações # com for self.__index = 0 def append(self, item: Any) -> None: """Representa o append da lista""" self.__data.append(item) def pop(self) -> Any: """Representa o pop da lista sem parâmetros""" if not self.__data: return return self.__data.pop() def peek(self) -> Any: """Mostra o último elemento adicionado à pilha""" if not self.__data: return return self.__data[-1] def __repr__(self) -> str: """Representação dos dados""" return str(self.__data) def __iter__(self): """Para iteração com for""" self.__index = len(self.__data) return self def __next__(self): """Para iteração com for (next item)""" if self.__index == 0: raise StopIteration self.__index -= 1 return self.__data[self.__index] def __bool__(self): """Para iteração com while""" return bool(self.__data) if __name__ == "__main__": stack = Stack() stack.append('A') stack.append('B') stack.append('C') print('FOR:') for item in stack: print(item) print() print('POP:') last_item = stack.pop() print(stack, last_item) print() print('WHILE:') stack_copy = deepcopy(stack) while stack_copy: print(stack_copy.pop()) print() print('ORIGINAL STACK:') print(stack)
Resumo
Em Python, usamos as listas como pilhas (nunca como filas). Elas
são estruturas de dados bem simples que precisam de apenas
dois métodos para funcionarem corretamente. Um
método de inserção e outro de
remoção de valores (append
e
pop
). Geralmente, o método de
remoção (pop) também retorna o valor removido
e você pode usá-lo com o que preferir.
As pilhas trabalham apenas na extremidade final da lista, por isso têm comportamento LIFO (Last In First Out), ou, o último a entrar é o primeiro a sair. O último elemento adicionado é considerado o elemento do topo da pilha; o primeiro elemento adicionado é o elemento da base da pilha.
Podemos iterar sobre pilhas usando laços
for
(invertendo seus valores) ou laço
while
combinado com pop
.
É possível copiar listas utilizando shallow copy ou deep copy.
É só isso… Te vejo no próximo artigo.