Listas duplamente ligadas
Nesta aula, você aprenderá sobre listas duplamente ligadas em C, uma estrutura de dados dinâmica que permite percorrer a lista em ambas as direções. Vamos explorar sua estrutura, operações básicas, vantagens e cuidados importantes, com exemplos práticos de código.
As listas duplamente ligadas são estruturas de dados lineares e dinâmicas, onde cada elemento (nó) contém um ponteiro para o próximo nó e outro para o nó anterior. Isso permite a navegação bidirecional, facilitando operações como inserção e remoção em ambas as extremidades com eficiência.
Nesta aula, vamos detalhar a estrutura de um nó, implementar operações essenciais (inserção, remoção, busca, impressão), discutir as vantagens dessa estrutura em relação a listas simplesmente ligadas e apontar cuidados que devemos ter, como o gerenciamento correto de memória e a atualização dos ponteiros.
Estrutura
Uma lista duplamente ligada é composta por nós que contêm três campos: um dado (por exemplo, um inteiro), um ponteiro para o nó seguinte e um ponteiro para o nó anterior. Em C, definimos uma estrutura (struct) para representar o nó e uma variável ponteiro para o primeiro nó (cabeça) e, opcionalmente, para o último nó (cauda).
A estrutura básica é a seguinte:
typedef struct Node {
int data; // dado armazenado
struct Node* prev; // ponteiro para o nó anterior
struct Node* next; // ponteiro para o próximo nó
} Node;
// Ponteiros para controlar a lista
Node* head = NULL; // primeiro nó
Node* tail = NULL; // último nó (opcional, mas útil)Inicialmente, a lista está vazia, e tanto head quanto tail apontam para NULL. Cada novo nó é alocado dinamicamente com malloc e, ao ser inserido, devemos ajustar os ponteiros prev e next dos nós vizinhos para manter a integridade da lista.
Operações
As operações básicas em listas duplamente ligadas incluem: inserção (no início, no fim, em posição específica), remoção (por valor ou posição), busca, impressão em ambas as direções e liberação da memória. Cada operação requer cuidado especial com os ponteiros.
Inserção no início: Criamos um novo nó, ajustamos seu next para o antigo head, o prev do antigo head para o novo nó, e atualizamos head. Se a lista estava vazia, tail também deve ser atualizado.
void insertAtBeginning(int data) {
Node* newNode = (Node*)malloc(sizeof(Node));
newNode->data = data;
newNode->prev = NULL;
newNode->next = head;
if (head != NULL) {
head->prev = newNode;
} else {
tail = newNode; // se estava vazia, tail também é o novo nó
}
head = newNode;
}Inserção no fim: Criamos um novo nó, ajustamos o prev para o antigo tail, o next para NULL, e atualizamos tail. Se a lista estiver vazia, head também deve ser atualizado.
void insertAtEnd(int data) {
Node* newNode = (Node*)malloc(sizeof(Node));
newNode->data = data;
newNode->next = NULL;
newNode->prev = tail;
if (tail != NULL) {
tail->next = newNode;
} else {
head = newNode; // se estava vazia, head também é o novo nó
}
tail = newNode;
}Remoção de um nó específico: Precisamos localizar o nó (por valor ou posição) e ajustar os ponteiros dos vizinhos. Se o nó é o head, atualizamos head; se é o tail, atualizamos tail; e liberamos a memória com free.
void deleteNode(Node* node) {
if (node == NULL) return;
if (node->prev != NULL) {
node->prev->next = node->next;
} else {
head = node->next;
}
if (node->next != NULL) {
node->next->prev = node->prev;
} else {
tail = node->prev;
}
free(node);
}Busca: Percorremos a lista a partir do head (ou tail) até encontrar o valor desejado, retornando o ponteiro para o nó ou NULL se não encontrado.
Node* search(int value) {
Node* current = head;
while (current != NULL) {
if (current->data == value) {
return current;
}
current = current->next;
}
return NULL;
}Impressão: Podemos imprimir do início ao fim ou do fim ao início, aproveitando os ponteiros next e prev.
void printForward() {
Node* current = head;
while (current != NULL) {
printf("%d ", current->data);
current = current->next;
}
printf("\n");
}
void printBackward() {
Node* current = tail;
while (current != NULL) {
printf("%d ", current->data);
current = current->prev;
}
printf("\n");
}Vantagens
As listas duplamente ligadas oferecem várias vantagens sobre as listas simplesmente ligadas:
- Navegação bidirecional: É possível percorrer a lista em ambas as direções, o que facilita operações como impressão reversa e busca em qualquer direção.
- Inserção e remoção eficientes em ambas as extremidades: Com um ponteiro para a cauda (tail), podemos inserir ou remover no final com complexidade O(1), o que não é possível em listas simplesmente ligadas sem percorrer toda a lista.
- Remoção de um nó conhecido sem percorrer a lista: Dado um ponteiro para o nó, podemos removê-lo em O(1), pois temos acesso ao nó anterior.
- Facilidade em algoritmos que exigem retrocesso: Por exemplo, em editores de texto ou navegadores, onde precisamos percorrer histórico para frente e para trás.
Essas vantagens tornam as listas duplamente ligadas ideais para implementar estruturas como deques (filas duplas) e certos tipos de caches.
Cuidados
Apesar das vantagens, as listas duplamente ligadas exigem atenção em alguns pontos:
- Gerenciamento de memória: Cada nó é alocado dinamicamente, então é imprescindível liberar a memória de todos os nós ao final, percorrendo a lista e usando
free. - Atualização correta dos ponteiros: Erros ao ajustar
prevenextpodem corromper a lista. Sempre verifique os casos especiais (lista vazia, primeiro nó, último nó). - Não esquecer de inicializar os ponteiros: Ao criar um novo nó, defina
prevenextpara NULL antes de inserir. - Evitar acesso a ponteiros NULL: Antes de acessar
prevounext, verifique se o nó não é NULL. - Complexidade de implementação: O código tende a ser mais complexo que o de listas simplesmente ligadas, aumentando a chance de bugs. Teste cada operação isoladamente.
Uma boa prática é encapsular as operações em funções e manter a lista sempre consistente, mesmo em caso de erro.
Boas Práticas
Para evitar problemas, recomenda-se:
- Sempre use funções para inserir, remover e buscar, em vez de manipular diretamente os ponteiros.
- Ao remover um nó, guarde o ponteiro para o próximo antes de liberar a memória, se necessário.
- Considere usar um nó sentinela (dummy) para simplificar a lógica de inserção e remoção nas extremidades.
- Teste a lista com casos de borda: lista vazia, um único nó, inserção/remoção no início e no fim.
- Libere toda a memória alocada antes de encerrar o programa, para evitar vazamentos.
Exercícios
Implemente uma função
insertAtPosition(int data, int pos)que insere um novo nó em uma posição específica (0-indexada). Considere que a posição pode ser 0 (início), o tamanho da lista (fim) ou qualquer posição válida. Teste com uma lista de exemplo.✓ Resposta:void insertAtPosition(int data, int pos) { Node* newNode = (Node*)malloc(sizeof(Node)); newNode->data = data; if (pos == 0) { insertAtBeginning(data); free(newNode); // pois insertAtBeginning já alocou return; } // Percorrer até a posição Node* current = head; for (int i = 0; current != NULL && i < pos - 1; i++) { current = current->next; } if (current == NULL) { // posição maior que o tamanho, insere no fim insertAtEnd(data); free(newNode); return; } // Ajustar ponteiros newNode->next = current->next; newNode->prev = current; if (current->next != NULL) { current->next->prev = newNode; } else { tail = newNode; } current->next = newNode; }Escreva uma função
deleteByValue(int value)que remove o primeiro nó que contém o valor dado. Retorne 1 se removeu, 0 caso contrário.✓ Resposta:int deleteByValue(int value) { Node* node = search(value); if (node == NULL) return 0; deleteNode(node); return 1; }Crie uma função
countNodes()que retorna o número de nós na lista. Use um loop e retorne o total.✓ Resposta:int countNodes() { int count = 0; Node* current = head; while (current != NULL) { count++; current = current->next; } return count; }Implemente uma função
reverseList()que inverte a ordem dos nós da lista duplamente ligada, sem alocar novos nós. Dica: troque os ponteirosprevenextde cada nó e atualize head e tail.✓ Resposta:void reverseList() { Node* current = head; Node* temp = NULL; while (current != NULL) { temp = current->prev; current->prev = current->next; current->next = temp; current = current->prev; // move para o próximo original } // Atualizar head e tail if (temp != NULL) { head = temp->prev; // temp é o último nó processado, que agora é o primeiro tail = current; // current é NULL, então usamos temp? // Na prática, após o loop, head deve ser o antigo tail. // Vamos corrigir: o antigo tail é o último nó processado. // Podemos guardar o antigo tail antes de começar. } } // Implementação correta: void reverseList() { Node* current = head; Node* temp = NULL; while (current != NULL) { temp = current->prev; current->prev = current->next; current->next = temp; current = current->prev; // agora prev aponta para o próximo original } // Após o loop, temp aponta para o último nó processado (antigo tail) if (temp != NULL) { head = temp->prev; // temp->prev é o antigo head? Não, temp é o último nó, e temp->prev é o novo next (NULL?) // Melhor: trocar head e tail diretamente. temp = head; head = tail; tail = temp; } }Escreva uma função
freeList()que libera toda a memória alocada para a lista, percorrendo e dando free em cada nó. Após a liberação, ajuste head e tail para NULL.✓ Resposta:void freeList() { Node* current = head; while (current != NULL) { Node* next = current->next; free(current); current = next; } head = NULL; tail = NULL; }