Sacos
Igor Machado Coelho
29/03/2021-02/11/2026
São requisitos para essa aula o conhecimento de:
Materiais complementares:
O Saco ou Bolsa (do inglês Bag) é um Tipo Abstrato de Dado (TAD) que serve para agregar elementos de um tipo pré-definido (estrutura homogênea).
Em um saco de produtos, por exemplo:
O Saco é também conhecido como multiset e alguns autores não consideram operações de remoção. 1
Sacos são estruturas fundamentais na própria computação, como o
conceito de multiset. Diferente de um set, não
se exige que elementos sejam únicos.
Em Python, a classe Counter representa um tipo de saco.
Em Java, a interface Bag (da Apache Commons
Collections) representa um saco. Em C++, pode-se considerar
containers como std::list e std::vector, que
são sequências, e também em
std::unordered_multiset (saco sem ordem específica, com
repetições).
Mas antes de definir um Saco, precisamos revisar outros componentes básicos: Iterador (que é uma Vista) e Percorrível.
O Saco será um Percorrível: seu
iterador() permite visitar todos os elementos.
Lembre-se que uma Vista basicamente tem um método .le()
que retorna uma cópia do elemento da origem.
Um Saco é um tipo abstrato de dado (TAD) Percorrível que carrega seus elementos e consiste de 4 operações básicas:
Também inclui sua operação Percorrível (de range/iterador):
Note que o Saco não tem conceito de indexação nem uma ordem específica de percurso / ordenação dos elementos. Elementos podem ser repetidos.
Veja operações:
adiciona('c'); adiciona('a'); adiciona('b'); adiciona('a');
Podemos responder as perguntas: ‘a’ existe? ‘d’ existe?
Quais estruturas de dados podem ser usadas para implementar as operações de um Saco?
Apresentaremos inicialmente a implementação do TAD Saco através de Listas Encadeadas, embora também possa ser feito através de Arrays.
O conceito de saco somente requer suas operações básicas.
Como consideramos um saco genérica (saco de inteiro, char,
etc), definimos um conceito genérico chamado
SacoTAD:
Antes de completar as funções pendentes, utilizaremos um
SacoTAD:
int main () {
SacoTAD<char> auto s = ...; // alguma implementação
s.cria();
s.adiciona('c');
s.adiciona('a');
s.adiciona('b');
s.adiciona('a');
println("{}", s.busca('a').terminou()); // 0
println("{}", s.busca('d').terminou()); // 1
for (auto it = s.iterador(); !it.terminou(); it.proximo())
println("{}", it.le()); // a b a c (em qual ordem?)
s.remove(s.busca('b'));
s.libera();
...
}As listas encadeadas possibilitam a inclusão de elementos em quantidade arbitrária (limitada à memória do computador), através de nós de encadeamento.
Veja operações:
adiciona('c'); adiciona('a'); adiciona('b'); adiciona('a');
[inicio→][a∣→][b∣→][a∣→][c∣→][S∣→0]
O nó S é uma sentinela: marca o fim da lista e sempre existe, mesmo com a lista vazia.
Consideraremos uma lista encadeada de caracteres. Para tal, definimos um agregado que contenha elementos, e outro que represente um iterador.
struct ListaEnc1 {
NoEnc1* inicio; // inicio da lista
int N; // num. de elementos na lista
void cria(); // inicializa agregado
void libera(); // finaliza agregado
void adiciona(char dado);
IteradorNoEnc1 iterador();
IteradorNoEnc1 busca(char dado);
IteradorNoEnc1 remove(IteradorNoEnc1 it);
int tamanho();
};
// verifica se agregado ListaEnc1 satisfaz conceito SacoTAD
static_assert(SacoTAD<ListaEnc1, char>);Antes de completar as funções pendentes, utilizaremos a
ListaEnc1:
int main () {
ListaEnc1 l;
l.cria();
l.adiciona('c');
l.adiciona('a');
l.adiciona('b');
l.adiciona('a');
println("{}", l.busca('a').terminou());
println("{}", l.busca('d').terminou());
for(auto it = l.iterador(); !it.terminou(); it.proximo()) {
println("{}", it.le());
}
l.libera();
...
}Verifique as impressões em tela: 0 1 A B A C
A operação cria inicializa a estrutura para uso, e a
função libera desaloca os recursos dinâmicos.
A operação adiciona em uma lista encadeada adiciona um
novo elemento na “cabeça” da lista (no início).
O iterador terminou quando chega à sentinela, o único nó sem próximo.
struct IteradorNoEnc1 {
using Tipo = char; // tipo associado
NoEnc1* no;
char le() {
return no->dado;
}
bool terminou() {
return no->proximo == 0;
}
void proximo() {
no = no->proximo;
}
};
// verifica se agregado IteradorNoEnc1 satisfaz conceito IteradorTAD
static_assert(IteradorTAD<IteradorNoEnc1, char>);A operação iterador em uma lista encadeada retorna o
iterador da lista, posicionado no começo.
A operação busca em uma lista encadeada utiliza o iterador da lista, posicionado no começo, para encontrar o elemento, retornando o iterador atualizado na posição correspondente.
struct ListaEnc1 {
...
IteradorNoEnc1 busca(char dado) {
auto it = iterador();
while (!it.terminou())
{
if (it.le() == dado)
return it;
it.proximo();
}
return it;
}
...
};Questão: O que acontece quando nenhum elemento é encontrado? Qual o valor de ‘it’ correspondente?
A busca pode ser feita de forma recursiva também:
A remoção de um elemento ocorre a partir de um iterador.
Após remove, todos os iteradores do
saco ficam inválidos, exceto o retornado, que aponta
para o elemento seguinte. Assim, para remover todas as ocorrências de um
elemento, continuamos a partir dele:
Desafio: qual o custo desse laço na lista encadeada?
E se remove não retornasse um iterador?
A implementação de listas encadeadas tipicamente permite acesso
bidirecional, com um ponteiro anterior além de um
proximo. Este tipo de lista é chamado de Lista
Duplamente Encadeada.
Desafio: Implemente as operações da lista!
Dica: para simplificar operações nos extremos da lista, pode-se utilizar um nó sentinela de Cabeça e Cauda.
É possível transformar a implementação da ListaEnc1 para
uso em for do tipo range, em C++. Exemplo:
int main() {
auto s = ListaEnc1();
s.cria();
s.adiciona('c');
s.adiciona('a');
s.adiciona('b');
s.adiciona('a');
println("{}", s.busca('a').terminou()); // 0
println("{}", s.busca('d').terminou()); // 1
println("{}", buscarec(s.iterador(), 'd').terminou()); // 1
for (auto x : s)
println("{}", x); // a b a c
...
}Por padrão, a linguagem C++ exige os métodos begin() e
end() para início e fim do processo de iteração, no
agregado ListaEnc1. Como nosso iterador sabe sozinho quando
terminou, o end() retorna apenas um marcador de
fim (std::default_sentinel, do C++20).
Tópico Avançado: os métodos terminou(),
proximo() e le() são substituídos pelos
operadores ==, ++ e *, no
agregado IteradorNoEnc1.
Fim parte de implementações de listas.
Os vetores podem ser utilizados como sacos, basta incluir os elementos sequencialmente à medida que forem adicionados.
Veja operações:
adiciona('c'); adiciona('a'); adiciona('b'); adiciona('a');
∣c∣a∣b∣a∣ 0123
Consideraremos um vetor de caracteres pré-alocado com capacidade
MAX_N.
constexpr int MAX_N = 10000;
struct SacoVetor1 {
char elementos[MAX_N]; // elementos
int N; // num. de elementos
void cria(); // inicializa agregado
void libera(); // finaliza agregado
void adiciona(char dado);
IteradorVetor1 iterador();
IteradorVetor1 busca(char dado);
IteradorVetor1 remove(IteradorVetor1 it);
int tamanho();
};
// verifica se agregado SacoVetor1 satisfaz conceito SacoTAD
static_assert(SacoTAD<SacoVetor1, char>);Antes de completar as funções pendentes, utilizaremos a
SacoVetor1:
int main () {
auto s = SacoVetor1();
s.cria();
s.adiciona('c');
s.adiciona('a');
s.adiciona('b');
s.adiciona('a');
println("{}", s.busca('a').terminou());
println("{}", s.busca('d').terminou());
for (auto it = s.iterador(); !it.terminou(); it.proximo())
println("{}", it.le()); // c a b a (nesta ordem)
s.libera();
...
}A operação cria inicializa a estrutura para uso, e a
função libera não desaloca nada.
A operação adiciona insere no final do vetor.
O iterador do vetor é o mesmo IteradorVetorInt dos
conceitos, agora para char.
struct IteradorVetor1 {
using Tipo = char; // tipo associado
const char* atual; // posição atual
const char* fim; // após o último
char le() { return *atual; }
bool terminou() { return atual == fim; }
void proximo() { atual++; }
};
// verifica se agregado IteradorVetor1 satisfaz conceito IteradorTAD
static_assert(IteradorTAD<IteradorVetor1, char>);A operação iterador retorna o início do vetor e a
posição logo após o último elemento.
A busca no vetor é idêntica à da lista: só usa o iterador!
Como o Saco não tem ordem, basta trazer o último elemento para a posição removida: custo O(1)!
struct SacoVetor1 {
...
IteradorVetor1 remove(IteradorVetor1 it) {
int i = it.atual - elementos; // posição removida
elementos[i] = elementos[N-1]; // traz o último para cá
N--;
return {.atual = elementos + i, .fim = elementos + N};
}
...
};Desafio: por que o iterador retornado aponta para a
mesma posição? E por que precisa de um novo
fim?
Fim parte de implementações.
remove garante sobre os
iteradores?| Operação | Lista (sentinela) | Vetor (MAX_N) |
|---|---|---|
cria |
O(1) (aloca a sentinela) | O(1) |
libera |
O(n) (libera cada nó) | O(1) |
adiciona |
O(1) (no início) | O(1) (no fim, se houver espaço) |
iterador, tamanho |
O(1) | O(1) |
busca |
O(n) | O(n) |
remove |
O(1) (copia o seguinte) | O(1) (traz o último) |
remover todos os x |
O(n) | O(n) |
| espaço | n+1 nós | MAX_N fixo |
Reflexão: as duas remoções são O(1) por caminhos diferentes, e ambas só funcionam porque o Saco não tem ordem. A busca é O(n) nas duas: nas próximas aulas faremos melhor!
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