Quando começamos a programar, é comum aprender a criar variáveis, escrever funções e utilizar arrays para armazenar listas de informações. No início, isso costuma ser suficiente para resolver pequenos problemas. Porém, conforme desenvolvemos aplicações mais complexas, percebemos que organizar os dados corretamente pode fazer uma diferença enorme na qualidade do código.
Imagine uma aplicação de e-commerce que precisa armazenar milhares de produtos, localizar um usuário pelo identificador, controlar os pedidos pendentes e verificar quais itens estão disponíveis em estoque. Todas essas operações envolvem dados, mas cada uma possui necessidades diferentes. Em algumas situações, precisamos percorrer uma lista. Em outras, queremos encontrar uma informação rapidamente ou garantir que um valor não apareça repetido.
É justamente para resolver esse tipo de problema que existem as estruturas de dados.
Ao longo de mais de 10 anos trabalhando com desenvolvimento de software, aprendi que entender como os dados são organizados ajuda não apenas a escrever código mais eficiente, mas também a tomar decisões melhores durante a construção de sistemas. Nem sempre precisamos utilizar a estrutura mais sofisticada. Muitas vezes, um array ou um objeto resolve o problema perfeitamente. O importante é compreender as características de cada opção e saber quando utilizá-la.
Neste artigo, vamos explorar as principais estruturas de dados usando JavaScript. A ideia é apresentar os conceitos de maneira prática, explicar suas vantagens e limitações e mostrar como essas escolhas aparecem em situações reais de desenvolvimento.
1. O Que São Estruturas de Dados?
Estruturas de dados são maneiras de organizar e armazenar informações para que possamos acessá-las, modificá-las e processá-las de acordo com as necessidades de um programa.
Pense em uma biblioteca. Se os livros estiverem espalhados aleatoriamente pelo ambiente, encontrar uma obra específica pode levar bastante tempo. Quando os livros são organizados por categoria, autor ou código, localizar o que precisamos fica muito mais fácil.
Com os dados de uma aplicação acontece algo semelhante.
Podemos armazenar informações em uma lista, relacionar valores por meio de identificadores, organizar elementos em uma hierarquia ou representar conexões entre diferentes entidades. Cada abordagem facilita determinados tipos de operação.
Em JavaScript, você provavelmente já utiliza algumas estruturas de dados sem perceber que elas fazem parte de um conceito mais amplo.
const names = ["Carlos", "Ana", "Mariana"];
const user = {
id: 1,
name: "Carlos",
active: true
};
const uniqueIds = new Set([1, 2, 2, 3]);
const usersById = new Map([
[1, "Carlos"],
[2, "Ana"]
]);
Nesse exemplo, temos quatro maneiras diferentes de organizar informações:
Array: armazena uma sequência de elementos.Object: organiza informações por propriedades.Set: mantém valores únicos.Map: relaciona chaves a valores.
Embora essas estruturas possam parecer semelhantes em alguns cenários, cada uma possui características próprias.
A escolha depende do problema que precisamos resolver, das operações que serão realizadas com frequência e das garantias que esperamos obter dos dados.
2. Arrays: A Estrutura de Dados Mais Comum
Um array é uma coleção ordenada de elementos. Em JavaScript, podemos utilizá-lo para armazenar números, textos, objetos ou até outras coleções.
Veja um exemplo simples:
const products = [
{ id: 1, name: "Notebook", price: 3500 },
{ id: 2, name: "Mouse", price: 120 },
{ id: 3, name: "Keyboard", price: 250 }
];
console.log(products[0].name); // Notebook
Os índices dos arrays começam em zero. Isso significa que o primeiro elemento está na posição 0, o segundo na posição 1 e assim por diante.
Arrays são especialmente úteis quando precisamos manter uma coleção ordenada e percorrer seus elementos.
Por exemplo, podemos filtrar os produtos que custam mais de R$ 200:
const expensiveProducts = products.filter(
(product) => product.price > 200
);
console.log(expensiveProducts);
Também podemos transformar os elementos de uma coleção utilizando map:
const productNames = products.map(
(product) => product.name
);
console.log(productNames);
// ["Notebook", "Mouse", "Keyboard"]
Esses métodos tornam o código mais expressivo e facilitam a leitura das operações realizadas sobre os dados.
Quando utilizar arrays?
Arrays são uma boa escolha quando precisamos:
- Armazenar uma lista de elementos.
- Preservar a ordem dos itens.
- Percorrer todos os elementos.
- Filtrar ou transformar coleções.
- Trabalhar com resultados de consultas a bancos de dados ou APIs.
Entretanto, encontrar um elemento específico por uma propriedade geralmente exige percorrer a coleção.
const user = users.find(
(user) => user.id === 500
);
No pior caso, o método find precisa verificar todos os elementos. Para uma coleção com milhares ou milhões de registros, dependendo da frequência dessa operação, pode ser interessante utilizar outra abordagem.
Isso não significa que arrays sejam lentos ou inadequados. Significa apenas que precisamos considerar como os dados serão utilizados.
3. Objetos: Organizando Informações por Propriedades
Os objetos são fundamentais em JavaScript. Eles permitem agrupar informações relacionadas sob propriedades que possuem nomes.
Imagine que precisamos representar um usuário:
const user = {
id: 42,
name: "Carlos",
email: "[email protected]",
active: true
};
console.log(user.name);
console.log(user.email);
Em vez de armazenar cada informação em uma variável independente, reunimos os dados relacionados em uma única estrutura.
Isso facilita a organização e permite representar entidades comuns em aplicações, como usuários, produtos, pedidos e configurações.
Também podemos modificar propriedades:
user.active = false;
user.lastLogin = new Date();
console.log(user);
Nesse exemplo, alteramos uma propriedade existente e adicionamos outra.
Objetos como tabelas de consulta
Uma aplicação pode utilizar um objeto para associar identificadores a informações.
const usersById = {
"101": { name: "Ana" },
"102": { name: "Carlos" },
"103": { name: "Mariana" }
};
console.log(usersById["102"].name); // Carlos
Essa abordagem pode ser útil quando as chaves são strings ou símbolos e precisamos acessar valores diretamente por uma propriedade.
Em condições normais, o acesso a uma propriedade de objeto é muito eficiente. Entretanto, não devemos assumir que toda operação terá o mesmo custo em qualquer ambiente ou implementação.
Também precisamos tomar cuidado ao utilizar objetos comuns como tabelas de consulta, principalmente quando as chaves vêm de fontes externas. Nesses casos, vale considerar Map ou objetos criados com Object.create(null), além de validar os dados recebidos.
Quando utilizar objetos?
Utilize objetos quando precisar representar uma entidade com propriedades nomeadas ou agrupar informações relacionadas.
Eles são uma das ferramentas mais importantes da linguagem, mas não substituem todas as outras estruturas de dados.
4. Map: Relacionando Chaves e Valores
O Map é uma estrutura nativa do JavaScript que associa chaves a valores. Diferentemente dos objetos comuns, suas chaves podem ser de diferentes tipos, incluindo números, objetos e funções.
Veja um exemplo:
const usersById = new Map();
usersById.set(101, {
name: "Ana",
role: "admin"
});
usersById.set(102, {
name: "Carlos",
role: "developer"
});
console.log(usersById.get(102));
// { name: "Carlos", role: "developer" }
console.log(usersById.has(101)); // true
O método set adiciona ou atualiza uma associação. O método get recupera um valor, enquanto has verifica se determinada chave existe.
Também podemos remover elementos:
usersById.delete(101);
console.log(usersById.has(101)); // false
Uma vantagem do Map é que ele mantém a ordem de inserção das chaves e oferece uma propriedade chamada size, que informa quantas associações existem.
console.log(usersById.size);
Map ou objeto?
A escolha depende do uso.
Um objeto costuma ser adequado para representar uma entidade ou uma estrutura com propriedades conhecidas. Já o Map é uma boa opção quando precisamos de uma coleção dinâmica de associações entre chaves e valores.
Por exemplo, podemos utilizar um Map para armazenar resultados temporários de cálculos, manter informações relacionadas a objetos específicos ou implementar um cache simples em memória.
É importante lembrar que um Map armazenado na memória de um processo Node.js não é um banco de dados. Seus valores normalmente desaparecem quando o processo termina e não são compartilhados automaticamente entre diferentes instâncias da aplicação.
5. Set: Armazenando Valores Únicos
O Set é uma estrutura de dados que permite armazenar valores únicos. Quando tentamos adicionar novamente um valor que já existe, ele não cria uma segunda ocorrência desse mesmo valor.
Imagine que uma aplicação recebe uma lista de identificadores e precisa eliminar duplicatas.
const ids = [10, 20, 10, 30, 20, 40];
const uniqueIds = [...new Set(ids)];
console.log(uniqueIds);
// [10, 20, 30, 40]
A solução é simples: criamos um Set a partir do array e depois utilizamos o operador spread para criar outro array com os valores únicos.
Também podemos verificar se determinado valor está presente:
const permissions = new Set([
"read",
"write",
"delete"
]);
console.log(permissions.has("write")); // true
console.log(permissions.has("admin")); // false
Isso pode ser útil para verificar permissões, registrar identificadores já processados ou evitar que uma mesma referência seja adicionada repetidamente a uma coleção.
Uma observação importante
No caso de valores primitivos, como números e strings, o Set elimina duplicatas de acordo com as regras de igualdade da linguagem.
Para objetos, a comparação considera a identidade da referência, e não se dois objetos possuem as mesmas propriedades.
const users = new Set();
users.add({ id: 1 });
users.add({ id: 1 });
console.log(users.size); // 2
Embora os dois objetos tenham o mesmo conteúdo, eles são referências diferentes. Por isso, ambos são armazenados.
Se a intenção for eliminar usuários duplicados com base no identificador, será necessário implementar uma estratégia específica para comparar os IDs.
6. Pilhas: O Conceito LIFO
Uma pilha é uma estrutura de dados que segue o princípio LIFO, sigla para Last In, First Out. Em português, isso significa que o último elemento adicionado é o primeiro a ser removido.
Imagine uma pilha de pratos. Quando você coloca um prato sobre os outros, normalmente é esse prato que será retirado primeiro.
Na programação, esse comportamento aparece em diferentes situações, como operações de desfazer, avaliação de expressões e controle de chamadas de funções.
Podemos implementar uma pilha simples utilizando um array:
const stack = [];
stack.push("A");
stack.push("B");
stack.push("C");
console.log(stack.pop()); // C
console.log(stack.pop()); // B
console.log(stack.pop()); // A
O método push adiciona elementos ao final do array, enquanto pop remove e retorna o último elemento.
Um exemplo prático
Imagine que estamos construindo um sistema simples de histórico de ações e precisamos permitir que a última ação seja desfeita.
const history = [];
function recordAction(action) {
history.push(action);
}
function undoAction() {
return history.pop();
}
recordAction("Create user");
recordAction("Update profile");
recordAction("Change password");
console.log(undoAction());
// Change password
Esse exemplo ilustra o comportamento básico de uma pilha.
Um sistema real de desfazer ações precisaria armazenar informações suficientes para reverter cada operação, mas a estrutura LIFO continua sendo um conceito importante para esse tipo de solução.
7. Filas: O Conceito FIFO
Enquanto uma pilha remove primeiro o último elemento adicionado, uma fila normalmente segue o princípio FIFO, sigla para First In, First Out. Ou seja, o primeiro elemento que entra é o primeiro a sair.
Pense em uma fila de atendimento. Em condições normais, a primeira pessoa a chegar será atendida antes das pessoas que chegaram depois.
Na programação, filas são utilizadas para organizar tarefas pendentes, mensagens e operações que precisam ser processadas em determinada sequência.
Podemos criar uma implementação simples em JavaScript:
const queue = [];
queue.push("Task 1");
queue.push("Task 2");
queue.push("Task 3");
console.log(queue.shift()); // Task 1
console.log(queue.shift()); // Task 2
console.log(queue.shift()); // Task 3
Embora esse exemplo seja fácil de entender, existe um detalhe importante: remover elementos do início de um array com shift pode exigir reorganizar os índices dos elementos restantes. Em filas grandes e com muitas remoções, isso pode ser ineficiente.
Uma alternativa é utilizar um índice para acompanhar a próxima posição a ser processada:
const queue = [];
let head = 0;
function enqueue(task) {
queue.push(task);
}
function dequeue() {
if (head >= queue.length) {
return undefined;
}
const task = queue[head];
head++;
return task;
}
enqueue("Task 1");
enqueue("Task 2");
enqueue("Task 3");
console.log(dequeue()); // Task 1
console.log(dequeue()); // Task 2
Essa implementação evita remover fisicamente o primeiro elemento a cada operação. Entretanto, em uma aplicação real, seria importante gerenciar o espaço ocupado pelos elementos já processados.
Filas em aplicações Node.js
Filas são especialmente relevantes em sistemas que precisam executar tarefas fora do fluxo principal de uma requisição.
Imagine uma aplicação que envia e-mails, gera relatórios ou processa imagens. Em vez de executar todas essas operações durante a requisição HTTP, podemos registrar tarefas para processamento posterior.
Em sistemas maiores, ferramentas como Redis com BullMQ, Amazon SQS e RabbitMQ oferecem recursos para construir filas persistentes, controlar tentativas, gerenciar falhas e distribuir tarefas entre vários trabalhadores.
Essas ferramentas vão além de um array em memória, mas utilizam conceitos relacionados à organização e ao processamento de filas.
8. Listas Ligadas: Conectando Elementos
Uma lista ligada, também conhecida como linked list, é uma estrutura composta por nós. Cada nó armazena um valor e uma referência para outro nó, geralmente o próximo elemento da lista.
Diferentemente de um array convencional, em que os elementos são acessados por índices, uma lista ligada depende do encadeamento entre seus nós.
Uma implementação simples de uma lista ligada individual pode ser feita assim:
class Node {
constructor(value) {
this.value = value;
this.next = null;
}
}
const first = new Node("A");
const second = new Node("B");
const third = new Node("C");
first.next = second;
second.next = third;
let current = first;
while (current !== null) {
console.log(current.value);
current = current.next;
}
O resultado será:
A
B
C
Cada nó aponta para o próximo, permitindo percorrer a lista até chegar ao final.
Quando uma lista ligada é útil?
Listas ligadas podem ser úteis quando precisamos inserir ou remover elementos em posições conhecidas e queremos evitar deslocar os demais elementos de uma coleção baseada em posições contíguas.
Porém, existe uma diferença importante entre a teoria e a prática.
Encontrar um elemento específico em uma lista ligada normalmente exige percorrer os nós, operação que custa O(n). Além disso, arrays nativos do JavaScript são altamente otimizados e oferecem acesso por índice muito eficiente.
Por isso, listas ligadas não são automaticamente melhores do que arrays. Em JavaScript, sua implementação costuma fazer mais sentido quando existe uma necessidade específica ou quando estamos estudando os fundamentos das estruturas de dados.
9. Árvores: Organizando Dados em Hierarquias
Uma árvore é uma estrutura de dados que organiza elementos em uma hierarquia. Seus componentes são chamados de nós, e as conexões entre eles representam relações entre elementos.
Pense na estrutura de pastas de um computador. Uma pasta pode conter outras pastas, que por sua vez podem conter arquivos e novas pastas.
Essa organização hierárquica pode ser representada por uma árvore.
Um exemplo clássico na ciência da computação é a árvore binária de busca, na qual cada nó possui, no máximo, dois filhos e os valores são organizados de acordo com uma regra de comparação.
Uma implementação simplificada seria:
class TreeNode {
constructor(value) {
this.value = value;
this.left = null;
this.right = null;
}
}
const root = new TreeNode(10);
root.left = new TreeNode(5);
root.right = new TreeNode(15);
root.left.left = new TreeNode(3);
root.left.right = new TreeNode(7);
Nesse exemplo, o valor 10 está na raiz. Os valores menores estão à esquerda e os maiores, à direita.
Para encontrar um valor nessa árvore, podemos seguir as comparações:
function search(node, target) {
if (node === null) {
return false;
}
if (node.value === target) {
return true;
}
if (target < node.value) {
return search(node.left, target);
}
return search(node.right, target);
}
console.log(search(root, 7)); // true
console.log(search(root, 12)); // false
Em uma árvore de busca bem equilibrada, as operações de busca podem apresentar complexidade O(log n). Entretanto, se a árvore ficar muito desbalanceada, a busca pode chegar a O(n).
Árvores aparecem em mecanismos de busca, índices, sistemas de arquivos, compiladores e diversas outras áreas da computação.
10. Grafos: Representando Conexões
Um grafo é uma estrutura composta por vértices, também chamados de nós, e arestas, que representam conexões entre eles.
Enquanto uma árvore representa uma hierarquia, um grafo permite representar relações mais gerais. Um nó pode estar conectado a vários outros nós, inclusive formando ciclos.
Imagine uma rede social. Cada pessoa pode ser representada por um vértice, enquanto as amizades ou conexões entre pessoas podem ser representadas por arestas.
Podemos representar um grafo simples utilizando um Map cujos valores são arrays de vizinhos:
const graph = new Map();
graph.set("Ana", ["Carlos", "Mariana"]);
graph.set("Carlos", ["Ana", "João"]);
graph.set("Mariana", ["Ana"]);
graph.set("João", ["Carlos"]);
Nesse exemplo, Ana está conectada a Carlos e Mariana. Carlos, por sua vez, está conectado a Ana e João.
Podemos consultar as conexões de uma pessoa:
console.log(graph.get("Ana"));
// ["Carlos", "Mariana"]
Essa representação é conhecida como lista de adjacência.
Onde os grafos são utilizados?
Grafos são úteis em diferentes cenários:
- Redes sociais e sistemas de recomendação.
- Rotas e sistemas de navegação.
- Dependências entre pacotes.
- Relacionamentos entre serviços.
- Redes de computadores.
- Mecanismos de busca e análise de conexões.
Por exemplo, um sistema de navegação pode representar cidades como vértices e estradas como arestas. Se cada estrada possuir um custo ou uma distância, podemos utilizar algoritmos específicos para encontrar caminhos adequados.
O importante é reconhecer que nem todo problema precisa ser representado por uma lista simples ou por um objeto. Quando o problema envolve conexões entre diferentes entidades, grafos podem oferecer uma maneira mais natural de organizar os dados.
11. Complexidade: Como Escolher a Estrutura Mais Adequada
Conhecer as estruturas de dados é apenas parte do aprendizado. Também precisamos entender como elas influenciam o desempenho das operações.
É aqui que entra a análise de complexidade, frequentemente representada pela notação Big O.
A notação Big O descreve como o custo de um algoritmo cresce à medida que o volume de dados aumenta. Ela pode ser utilizada para analisar o tempo de execução ou o consumo de memória.
Algumas complexidades comuns são:
| Complexidade | Nome | Exemplo típico |
|---|---|---|
| O(1) | Constante | Acessar um elemento de um array por índice |
| O(log n) | Logarítmica | Buscar em uma árvore de busca equilibrada |
| O(n) | Linear | Percorrer todos os elementos de um array |
| O(n log n) | Linearítmica | Algoritmos eficientes de ordenação por comparação |
| O(n²) | Quadrática | Comparar todos os pares de elementos em um array |
Esses exemplos são representativos, não garantias para qualquer implementação. O custo real depende da operação, da estrutura utilizada, do algoritmo e das características dos dados.
Vamos comparar duas formas de localizar um usuário.
A primeira utiliza um array:
function findUserById(users, targetId) {
return users.find((user) => user.id === targetId);
}
No pior caso, essa função precisa percorrer todos os usuários. Sua complexidade de busca é O(n).
Agora, podemos construir um Map para relacionar os IDs aos usuários:
const usersById = new Map();
for (const user of users) {
usersById.set(user.id, user);
}
const user = usersById.get(targetId);
A construção do mapa exige percorrer a coleção. Depois disso, a busca por chave tem custo médio esperado O(1), conforme as características usuais de implementação do Map.
Isso pode ser vantajoso quando realizamos muitas consultas sobre a mesma coleção. Se fizermos apenas uma busca, o custo de construir o mapa pode não compensar.
Essa é uma decisão importante: não basta conhecer a complexidade de uma operação isolada. Precisamos considerar o fluxo completo da aplicação, a quantidade de dados, a frequência das consultas e o consumo de memória.
12. Como Escolher a Estrutura de Dados Certa
Não existe uma estrutura de dados que seja a melhor em todas as situações. A escolha depende do comportamento esperado e das operações que a aplicação precisa realizar.
Podemos começar com algumas perguntas simples:
Preciso armazenar uma lista ordenada de elementos?
Um array costuma ser uma boa escolha.
Preciso representar uma entidade com propriedades nomeadas?
Um objeto geralmente atende bem a essa necessidade.
Preciso associar chaves e valores em uma coleção dinâmica?
Considere utilizar Map.
Preciso garantir que os elementos de uma coleção sejam únicos?
O Set pode ser uma solução simples e eficiente.
Preciso processar elementos na ordem em que foram recebidos?
Uma fila pode ser adequada.
Preciso remover primeiro o elemento que foi adicionado por último?
Uma pilha representa naturalmente esse comportamento.
Preciso representar uma hierarquia?
Uma árvore pode ser a estrutura mais apropriada.
Preciso representar conexões entre diferentes entidades?
Considere um grafo.
Essas perguntas não substituem a análise do problema, mas ajudam a reduzir as opções.
Em projetos reais, também precisamos considerar outros fatores, como legibilidade, facilidade de manutenção, integração com bancos de dados, concorrência e requisitos de desempenho.
Minha recomendação é começar pela estrutura mais simples que atende corretamente às necessidades do sistema. Se medições ou novos requisitos mostrarem que ela não é suficiente, podemos avaliar alternativas com base em evidências.
Conclusão
Estruturas de dados fazem parte dos fundamentos da programação porque determinam como organizamos, acessamos e processamos informações. Entender essas estruturas ajuda a desenvolver soluções mais claras, escolher algoritmos adequados e identificar possíveis gargalos de desempenho.
Neste artigo, exploramos arrays, objetos, mapas, conjuntos, pilhas, filas, listas ligadas, árvores e grafos. Também vimos como a complexidade algorítmica influencia a escolha de uma estrutura e por que uma solução que funciona bem para poucos registros pode não ser a melhor opção quando o volume de dados aumenta.
Na minha experiência como desenvolvedor, o mais importante não é memorizar todas as estruturas de dados disponíveis. É compreender os problemas que cada uma resolve e saber reconhecer quando faz sentido utilizá-las.
Você não precisa implementar uma árvore ou um grafo em todos os projetos. Entretanto, conhecer esses conceitos amplia sua capacidade de analisar problemas e compreender como diversas ferramentas e bibliotecas funcionam internamente.
Se você está começando, pratique primeiro com arrays, objetos, Map e Set. Depois, avance para pilhas, filas, árvores e grafos. Procure implementar versões simples dessas estruturas e comparar o comportamento de diferentes soluções.
E você, qual estrutura de dados utiliza com mais frequência nos seus projetos? Deixe um comentário contando sua experiência ou compartilhe uma situação em que precisou escolher uma estrutura específica para resolver um problema. Vamos continuar essa conversa e aprender juntos.