Estrutura de Dados: computação na prática com Java
23 min23 minutos de leitura
23 min23 minutos de leitura
Autor(a)
Akemi Alice
Akemi faz parte do Scuba Team na Escola de Programação & DevOps da Alura, com foco em Java, e é técnica em Informática pelo Instituto Federal de São Paulo (IFSP).
Inscreva-se em nossa Newsletter
Fique por dentro de conteúdos, insights e oportunidades do universo tech. Receba novidades e lançamentos direto no seu e-mail.
Introdução
Imagine administrar uma grande quantidade de dados em seu programa, você com certeza procuraria utilizar a forma mais benéfica e eficiente, porém, como escolher a solução mais viável?
Estrutura de dados é a forma de organizar e guardar dados, ela existe para que determinado dado possa ser utilizado de maneira eficiente, possibilitando uma melhor administração. O objetivo deste artigo é entender como as estruturas de dados funcionam por baixo dos panos, discutir as vantagens e desvantagens de cada uma e ver, em diferentes situações, qual é o tempo de execução e performance dessas estruturas. Esse conhecimento é importante para podermos optar por uma delas em nosso programa, ou seja, escolhendo a solução mais viável. Para isso, vamos ver na prática usando como base um projeto em Java.
Armazenamento sequencial e Vetores
Com o Eclipse aberto, vamos começar o nosso primeiro projeto de estrutura de dados. Como exemplo, estaremos trabalhando com uma universidade, onde precisamos guardar e recuperar dados dos alunos. Ou seja, vamos adicioná-lo no fim ou no meio de uma lista, removê-lo, achá-lo a partir de seu número e assim por diante.
O primeiro passo nesse projeto é modelar a Classe "Aluno". Para isso criamos um novo projeto e dentro dele a classe Aluno, que é onde guardaremos o nome do aluno, que receberemos no próprio Construtor da Classe.
Em seguida, vamos criar o getter e implementar os métodos "equals" e "toString", que serão muito importantes. O "equals" é o método que serve para comparar dois objetos, no caso alunos. Faremos um casting do object para aluno. O "toString" retorna o nome do aluno:
@Override
public String toString() {
return nome;
}
}
Ao fazer isso, a primeira estrutura de dados que veremos é o Armazenamento Sequencial. A ideia dessa estrutura é armazenar um aluno atrás do outro. Teremos um conjunto de espaços (Array), sendo que: o primeiro aluno fica no primeiro espaço, o segundo aluno no segundo espaço, e assim por diante.
Sabendo disso, vamos criar uma nova Classe, chamada "Vetor", na qual é preciso implementar a estrutura de armazenamento sequencial. Além disso, precisamos inserir um array com 100 posições e implementar os métodos dos comportamentos desse array:
package ed;
publicclassVetor {
privateAluno[] alunos = newAluno[100];
publicvoidadiciona(Aluno aluno) {
//recebe um aluno
}
publicAlunopega(int posicao) {
//recebe uma posição e devolve o alunoreturnnull;
}
publicvoidremove(int posicao) {
//remove pela posição
}
publicbooleancontem(Aluno aluno) {
//descobre se o aluno está ou não na listareturnfalse;
}
public int tamanho() {
//devolve a quantidade de alunosreturn0;
}
publicStringtoString() {
//facilitará na impressãoreturnArrays.toString(alunos);
}
}
Os return's já foram inseridos para que possamos compilar o código. Antes de implementar os comportamentos, iremos escrever o método main para testar o Vetor, antes mesmo do código existir. Para isso vamos criar a Classe "VetorTeste":
Serão 100 null's, então o método "adiciona" está funcionando. Então vamos implementá-lo? A ideia é percorrer todo o array e, assim que encontrar uma posição nula, o aluno da vez é armazenado nela:
public void adiciona(Aluno aluno) {
for(int i = 0; i < alunos.length; i++) {
if(alunos[i] == null) {
alunos[i] = aluno;
break;
}
}
}
Rodando novamente o teste, ele vai retornar:
[Joao, Jose, null, null, null...]
Agora os dois alunos foram inseridos no array. Mas perceba que o algoritmo que implementamos não é muito performático, pois quanto maior o número de alunos inseridos no array, mais demorado será o método, uma vez que o laço irá percorrer várias vezes os espaços já preenchidos. Vamos tentar melhorá-lo para que não fique dependente da quantidade de elementos na lista. Para isso, vamos usar do seguinte código:
A cada iteração ele retorna o tamanho da lista de alunos preenchida.
Método contem
Vamos implementar o método "contem". Queremos "perguntar" para a lista se um aluno específico está ou não nela.
public boolean contem(Aluno aluno) {
for(int i = 0; i < totalDeAlunos; i++) {
if(aluno.equals(alunos[i])) {returntrue;
}
}
returnfalse;
}
Para testar o "true", adicionamos no *main:
System.out.println(lista.contem(a1));
Rodando:
012[Joao, Jose, null, null, null...]true
Para testar o "false" criamos um aluno que não será adicionado na lista:
Aluno a3 = new Aluno("Danilo");
System.out.println(lista.contem(a3));
Rodando:
012[Joao, Jose, null, null, null...]truefalse
Método pega
Para implementar este método - que nos retorna o nome do aluno na posição que perguntamos - fazemos:
public Aluno pega(int posicao){
return alunos[posicao];
}
Lembre-se que nosso array possui 100 posições. O que aconteceria se perguntássemos sobre o aluno na posição 200? Vamos testar pelo main:
Aluno x = lista.pega(1);
System.out.println(x);
O programa retorna o "Jose", pois é ele que está na posição de número 1. Se escolhermos a posição 200, o programa retorna um erro com a mensagem "ArrayIndexOutOfBounds", ou seja, estamos tentando acessar uma posição do array que não existe.
Vamos começar a pensar na validação dos dados que vamos passar para o programa. Precisamos que ele retorne, por exemplo, uma mensagem mais amigável, ao invés de um erro. Criaremos um método auxiliar que irá dizer se uma determinada posição está ocupada ou não:
Essa parte é muito importante, pois é nossa responsabilidade a implementação da estrutura para garantir que ela trate bem qualquer dado errado passado pelo usuário.
Outro método adiciona
Vamos implementar um outro método que, diferentemente do "adiciona" que já vimos, insere um aluno em qualquer posição do array:
publicvoidadiciona(int posicao, Aluno aluno){
}
Vamos pensar como construir esse método. Vamos imaginar, no nosso array de 100, que as primeiras dez posições já estão preenchidas. Queremos inserir um aluno na terceira posição, como na imagem abaixo:
Para isso, vamos arrastar todos os alunos da terceira posição em diante para a direita e colocamos aquele aluno no buraco que ficou, como podemos observar na imagem a seguir:
public void adiciona(int posicao, Aluno aluno) {
if(!posicaoValida(posicao)) {
throw new IllegalArgumentException("posicao invalida");
}
for(int i = totalDeAlunos - 1; i >= posicao; i-=1) {
alunos[i+1] = alunos[i];
}
alunos[posicao] = aluno;
totalDeAlunos++;
}
Método remove
O nosso próximo desafio é o método "remove", que será parecido com o "adiciona", porém pensando inversamente: retiramos o aluno da posição n e empurramos para a esquerda todos aqueles que vinham depois dele:
publicvoidremove(int posicao){
for(int i = posicao; i < this.totalDeAlunos; i++) {
this.alunos[i] = this.alunos[i+1];
}
totalDeAlunos--;
}
Testando:
lista.remove(1);
System.out.println(lista);
Antes estava assim:
[Jose, Danilo, Jose, null, null...]
E agora:
[Joao, Jose, null, null, null...]
Redimensionando o array
Já implementamos os principais métodos do nosso Vetor. Porém, perceba que o tamanho do array é constante, valendo 100. Nós queremos que ele seja mutável de acordo com o número de alunos.
Em Java não conseguimos mudar o tamanho de um array, então teremos que criar um novo maior e copiar tudo que está no antigo para este. Criamos o método "garanteEspaço":
private void garanteEspaco() {
if(totalDeAlunos == alunos.length) {
Aluno[] novoArray = new Aluno[alunos.length*2];
for(int i = 0; i < alunos.length; i++) {
novoArray[i] = alunos[i];
}
this.alunos= novoArray;
}
}
Agora se adicionar mais elementos do que o tamanho do antigo array, ele será redimensionado em um novo array.
Para testar essa implementação vamos criar um laço no main que vai adicionar 300 alunos:
for(int i = 0; i < 300; i++) {
Aluno y = new Aluno("Joao" + i);
lista.adiciona(y);
}
System.out.println(lista);
O programa, de fato, retornará uma lista de 300 elementos:
[Joao, Jose, Joao 0, Joao 1, Joao 2, Joao 3...]
Nesse exemplo, perceba que houve dois redimensionamentos:
Quando passou de 100, dobrando o array para 200 posições;
Quando passou de 200, dobrando o array para 400 posições (tendo 100 delas valores null).
O ArrayList
O Java já tem uma implementação de Vetor, é a classe conhecida por "ArrayList". Ela é bem parecida com tudo o que fizemos até agora e funciona como um armazenamento sequencial, possuindo os métodos implementados nesta aula:
ArrayList<Aluno> listaDoJava = new ArrayList<Aluno>();
Apesar dela existir e facilitar nossa vida, foi importante aprendermos como e o que implementar para criarmos uma estrutura de dados.
Listas ligadas
Utilizamos vetores e vimos que são boas estruturas de dados para diversos casos, como: adicionar elementos no fim do vetor; pegar um elemento aleatório; remover elementos.
Porém, outros métodos já não eram tão simples como, por exemplo, inserir um elemento no meio do vetor, esta que é uma atividade computacionalmente cara e com processo de execução lento.
Já vimos o Vetor e observamos seus prós e contras e agora vamos aprender sobre uma outra lista. Com ela tentaremos melhorar o código para que essa adição de elementos no meio do array seja um processo mais rápido.
A essa lista nós damos o nome de lista ligada. A diferença dela para o Vetor é que neste os elementos estão um do lado do outro na memória, enquanto que na lista ligada eles estão em lugares diferentes, porém um aponta para o outro indicando o próximo.
Então, é dessa forma que iremos desenhar a estrutura, na qual um elemento também conhecerá o endereço do próximo. Para isso, vamos criar uma Classe "Celula" que possuirá um objeto e seu seguinte (do tipo "Celula"). Para facilitar, vamos também criar um Construtor e getters (para o elemento) e setters (para o elemento e para a Celula):
Vamos começar imaginando que já temos uma lista com células apontando uma para outra. Para uma nova Célula entrar no começo do array ela deve apontar para sua próxima, ou seja, a primeira do array atual. Então devemos ter um atributo chamado "primeira". Como a lista começa vazia, essa célula aponta para null:
Na lista vazia, ao adicionarmos uma célula na primeira posição do array, ela deverá apontar para null. Já quando acrescentamos uma próxima, também no começo, esta apontará para a anterior; e soma-se 1 ao total de elementos:
Para Listas Ligadas, este método é um pouco mais complexo. O que nos diz se um elemento é o último do array é se ele apontar para um null. Para isso é necessário varrer toda a lista. Vamos resolver o problema criando uma seta para o último elemento (da mesma forma que fizemos para o primeiro):
Com essa mudança teremos que arrumar algumas coisas no método "adicionaNoComeco". Se a lista está vazia, o primeiro elemento também será o último:
public void adicionaNoComeco(Object elemento) {
Celula nova = new Celula(elemento, primeira);
this.primeira = nova;
if(this.totalDeElementos == 0) {
this.ultima = this.primeira;
}
this.totalDeElementos++;
}
Voltemos ao desafio de inserir no final. Criamos uma nova célula cujo próximo elemento é null, afinal ela está sendo adicionada no final do array. Precisamos fazer com que a última atual aponte para essa nova.
Imaginemos agora, mais uma vez, que já possuímos uma lista onde um elemento aponta para o outro. O elemento da esquerda deve apontar para o novo, e este para o da direita. Então, em código, fazemos:
Dessa forma pegamos a Célula da esquerda (anterior) e a nova no lugar da próxima (anterior.getProximo). Por fim, basta fazer com que a anterior seja a nova e somar 1 no total de elementos:
O elemento na primeira posição (Guilherme) foi removido.
Listas duplamente ligadas
Já aprendemos sobre Listas ligadas, cuja ideia era a de que uma célula estava ligada à sua próxima em um array. Ela nos facilitou em relação à implementação e velocidade de execução.
Agora, vamos conhecer as Listas duplamente ligadas, cujos elementos não apenas apontam para seu próximo, mas também para seu anterior.
Então, voltando à nossa Classe Celula, vamos criar um novo parâmetro com seu getter e setter:
Já aprendemos sobre listas ligadas e duplamente ligadas. Tais listas possuíam células que apontavam para outras, anteriores e posteriores. Vimos nos exercícios que o Java já tem tudo isso implementado por meio da Classe LinkedList.
Neste momento, veremos uma outra estrutura de dados cuja principal diferença, em relação aos outros tipos de estruturas de dados, é guardar os diversos estados de uma aplicação para que no futuro, se necessário, seja possível voltar a estes estados. A essa estrutura damos o nome de Pilha.
Vamos criar um pacote e, dentro dele, a Classe "Pilha". As operações que teremos nessa pilha são:
A Pilha segue a regra de inserção de elementos um após o outro e a remoção funciona da mesma forma, do último para o primeiro elemento. Para começar a implementar, não começamos do zero. Já temos uma parte do código feita, pois a fizemos nos estudos de listas. Vamos utilizar a implementação que o Java nos oferece.
O que imprime [Mauricio, Marcelo]. E para remover:
stack.pop();
System.out.println(stack);
O quê imprime [Mauricio].
Método peek
Como vimos, o pop remove o último elemento da pilha. O método peek trabalha em cima desse elemento também, porém sem removê-lo, já que ele apenas o retorna. Portanto, se temos a pilha [Mauricio, Marcelo],
String nome = stack.peek();
System.out.println(nome);
Nos retorna Marcelo.
Usabilidade das pilhas
O conceito de pilhas é amplamente utilizado por compiladores e autômatos, portanto, podemos afirmar que essa estrutura de dados tem muita usabilidade em ciência da computação. O próprio, e muito conhecido, comando "Desfazer" dos editores de texto, de código, de imagens, etc. tem como base as pilhas. Podemos também brincar com palavras e inverter a ordem de suas letras utilizando as pilhas.
Filas
Agora vamos conhecer as Filas, que se estruturam de modo parecido com as pilhas. Porém, diferente das pilhas, na qual o primeiro elemento a entrar é o último a sair, em filas o primeiro a entrar é o primeiro a sair.
Criemos a Classe "Fila", que será suportada pelo LinkedList, e terá alguns métodos e o toString.
Neste artigo, vimos na prática vetores, lista ligada, lista duplamente ligada, pilha e fila. É muito importante compreender como uma estrutura funciona por baixo dos panos e, por isso, o estudo de estrutura de dados é uma parte fundamental na programação e na formação de profissionais da área. Aprendendo isso, você estará preparado para optar pela melhor solução.
Se esse conteúdo te interessou, você pode acessar os links abaixo para potencializar sua aprendizagem: