Listas ligadas
Nesta aula, você aprenderá a implementar listas ligadas em C, desde a definição de nós com ponteiros até operações de inserção, remoção, travessia e liberação de memória. Inclui exemplos práticos, exercícios com respostas e referências para aprofundamento.
As listas ligadas são estruturas de dados fundamentais na programação, especialmente em C, onde o gerenciamento de memória é explícito. Diferentemente dos arrays, que ocupam um bloco contíguo de memória, as listas ligadas consistem em nós alocados dinamicamente, cada um contendo um valor e um ponteiro para o próximo nó. Isso permite inserções e remoções eficientes sem a necessidade de deslocar elementos, embora o acesso a um elemento específico exija percorrer a lista desde o início.
Nesta aula, vamos explorar a implementação de listas ligadas simples em C, cobrindo a definição de nós com ponteiros, as operações básicas de inserção e remoção, a travessia para percorrer a lista e, crucialmente, a liberação de memória para evitar vazamentos. Ao final, você terá uma base sólida para construir estruturas mais complexas, como pilhas, filas e listas duplamente ligadas.
Nós com ponteiros
O coração de uma lista ligada é o nó. Em C, um nó é uma estrutura que armazena um dado (por exemplo, um inteiro) e um ponteiro para o próximo nó. Esse ponteiro é fundamental para encadear os nós, formando a sequência. A definição típica é:
#include <stdio.h>
#include <stdlib.h>
// Definição do nó
typedef struct Node {
int data; // dado armazenado
struct Node* next; // ponteiro para o próximo nó
} Node;Observe que usamos struct Node* dentro da própria estrutura. Isso é necessário porque o ponteiro deve apontar para outro nó do mesmo tipo. O uso de typedef simplifica a declaração de variáveis: podemos escrever Node* head em vez de struct Node* head.
Uma lista ligada é geralmente representada por um ponteiro para o primeiro nó, chamado de head. Se a lista estiver vazia, head é NULL. Cada nó aponta para o próximo, e o último nó aponta para NULL, indicando o fim da lista. Essa estrutura permite que os nós estejam em qualquer lugar da memória, pois os ponteiros mantêm a ordem lógica.
Inserção e remoção
As operações de inserção e remoção são as mais comuns em listas ligadas. Podemos inserir no início, no final ou em uma posição específica. A inserção no início é a mais simples: criamos um novo nó, apontamos seu next para o antigo head e atualizamos head para o novo nó. Veja um exemplo:
// Insere um novo nó no início da lista
void insertAtBeginning(Node** head, int value) {
Node* newNode = (Node*)malloc(sizeof(Node));
if (newNode == NULL) {
printf("Erro: falha na alocação de memória.\n");
return;
}
newNode->data = value;
newNode->next = *head;
*head = newNode;
}Aqui, usamos um ponteiro para ponteiro (Node**) porque precisamos modificar o head original. Se passássemos apenas Node* head, a alteração não seria refletida fora da função (a menos que usássemos retorno). Essa técnica é comum em C para modificar ponteiros dentro de funções.
A inserção no final exige percorrer a lista até o último nó. Se a lista estiver vazia, basta definir head como o novo nó. Caso contrário, percorremos até encontrar um nó cujo next seja NULL e então o ligamos ao novo nó. A complexidade é O(n), pois precisamos percorrer a lista.
// Insere no final da lista
void insertAtEnd(Node** head, int value) {
Node* newNode = (Node*)malloc(sizeof(Node));
if (newNode == NULL) {
printf("Erro: falha na alocação de memória.\n");
return;
}
newNode->data = value;
newNode->next = NULL;
if (*head == NULL) {
*head = newNode;
return;
}
Node* temp = *head;
while (temp->next != NULL) {
temp = temp->next;
}
temp->next = newNode;
}A remoção também pode ser feita por valor ou por posição. Para remover o primeiro nó, basta atualizar head para o segundo nó e liberar a memória do antigo. Para remover um nó específico, precisamos localizá-lo e ajustar o ponteiro do nó anterior. Vamos implementar a remoção por valor:
// Remove o primeiro nó que contém o valor especificado
void removeByValue(Node** head, int value) {
if (*head == NULL) return;
Node* temp = *head;
Node* prev = NULL;
// Se o head for o nó a ser removido
if (temp != NULL && temp->data == value) {
*head = temp->next;
free(temp);
return;
}
// Procura o nó a ser removido
while (temp != NULL && temp->data != value) {
prev = temp;
temp = temp->next;
}
if (temp == NULL) {
printf("Valor %d não encontrado.\n", value);
return;
}
// Desencadeia o nó e libera memória
prev->next = temp->next;
free(temp);
}É importante sempre liberar a memória do nó removido usando free() para evitar vazamento de memória. Além disso, devemos atualizar corretamente os ponteiros para manter a integridade da lista.
Travessia
Percorrer uma lista ligada é uma operação comum para imprimir, buscar ou modificar elementos. A travessia é feita com um ponteiro temporário que começa no head e avança nó a nó até encontrar NULL. Vejamos como imprimir todos os elementos:
// Imprime todos os elementos da lista
void printList(Node* head) {
Node* temp = head;
while (temp != NULL) {
printf("%d ", temp->data);
temp = temp->next;
}
printf("\n");
}Essa função recebe apenas o ponteiro head por valor, pois não precisa alterá-lo. A cada iteração, temp avança para o próximo nó. Essa é a maneira mais simples de percorrer uma lista ligada. A complexidade é O(n), onde n é o número de nós.
Além de imprimir, podemos usar a travessia para buscar um valor, contar nós, ou até mesmo liberar a lista (como veremos na próxima seção). A travessia é a base de muitas operações.
Liberando memória
Como as listas ligadas usam alocação dinâmica, é crucial liberar toda a memória quando a lista não for mais necessária. Se não fizermos isso, teremos vazamentos de memória, que podem causar problemas em programas longos. A liberação deve ser feita percorrendo a lista e liberando cada nó individualmente.
// Libera toda a memória da lista
void freeList(Node** head) {
Node* temp = *head;
Node* next;
while (temp != NULL) {
next = temp->next;
free(temp);
temp = next;
}
*head = NULL; // Opcional, mas evita ponteiro pendente
}Na função acima, guardamos o próximo nó antes de liberar o atual, pois após free(temp) não podemos acessar temp->next (comportamento indefinido). Ao final, definimos *head como NULL para evitar que o ponteiro aponte para memória liberada.
É importante sempre chamar freeList() antes de o programa terminar, para garantir que toda a memória alocada seja devolvida ao sistema. Ferramentas como Valgrind podem ajudar a detectar vazamentos de memória em programas C.
Exemplo completo
Vamos juntar tudo em um programa completo que demonstra a criação, inserção, travessia e liberação de uma lista ligada:
#include <stdio.h>
#include <stdlib.h>
typedef struct Node {
int data;
struct Node* next;
} Node;
// Funções definidas anteriormente...
void insertAtBeginning(Node** head, int value);
void insertAtEnd(Node** head, int value);
void removeByValue(Node** head, int value);
void printList(Node* head);
void freeList(Node** head);
int main() {
Node* head = NULL;
insertAtBeginning(&head, 10);
insertAtBeginning(&head, 20);
insertAtEnd(&head, 30);
insertAtEnd(&head, 40);
printf("Lista: ");
printList(head);
removeByValue(&head, 20);
printf("Após remover 20: ");
printList(head);
freeList(&head);
return 0;
}Esse programa cria uma lista com os valores 20, 10, 30, 40 (na ordem de inserção), imprime, remove o 20 e imprime novamente. Ao final, libera toda a memória.
Boas práticas
- Sempre verifique se
mallocretornouNULLantes de usar o ponteiro. - Use ponteiro para ponteiro (
Node**) em funções que modificam ohead. - Nunca acesse um nó após liberá-lo com
free. - Mantenha a lista sempre consistente: após cada operação, os ponteiros devem estar corretos.
- Documente suas funções para facilitar a manutenção.
Referências
- Struct declaration - cppreference.com
- malloc - cppreference.com
- free - cppreference.com
- Linked List Introduction - GeeksforGeeks
- Linked List Algorithms - TutorialsPoint
- Linked list - Wikipedia
- Valgrind Quick Start
Exercícios
- Escreva uma função em C que conte o número de nós em uma lista ligada (recebe o
heade retorna o total).✓ Resposta:int countNodes(Node* head) { int count = 0; Node* temp = head; while (temp != NULL) { count++; temp = temp->next; } return count; } - Implemente uma função que insere um novo nó em uma posição específica (índice) da lista. Considere que o índice 0 é o início. Se o índice for inválido, não faça nada.✓ Resposta:
void insertAtPosition(Node** head, int value, int position) { if (position < 0) return; Node* newNode = (Node*)malloc(sizeof(Node)); if (newNode == NULL) return; newNode->data = value; newNode->next = NULL; if (position == 0) { newNode->next = *head; *head = newNode; return; } Node* temp = *head; for (int i = 0; i < position - 1; i++) { if (temp == NULL) { free(newNode); return; // posição inválida } temp = temp->next; } if (temp == NULL) { free(newNode); return; } newNode->next = temp->next; temp->next = newNode; } - Crie uma função que remova o último nó da lista e retorne o valor removido (ou -1 se a lista estiver vazia).✓ Resposta:
int removeLast(Node** head) { if (*head == NULL) return -1; Node* temp = *head; Node* prev = NULL; while (temp->next != NULL) { prev = temp; temp = temp->next; } int value = temp->data; if (prev == NULL) { // só havia um nó free(temp); *head = NULL; } else { prev->next = NULL; free(temp); } return value; } - Escreva uma função que receba a cabeça da lista e inverta a ordem dos nós, retornando o novo head. (Dica: use três ponteiros).✓ Resposta:
Node* reverseList(Node* head) { Node* prev = NULL; Node* current = head; Node* next = NULL; while (current != NULL) { next = current->next; current->next = prev; prev = current; current = next; } return prev; } - Escreva uma função que verifique se uma lista ligada possui ciclo (ou seja, se algum nó aponta para um nó anterior). Use o algoritmo de detecção de ciclo de Floyd (tartaruga e lebre).✓ Resposta:
int hasCycle(Node* head) { Node* slow = head; Node* fast = head; while (fast != NULL && fast->next != NULL) { slow = slow->next; fast = fast->next->next; if (slow == fast) return 1; } return 0; }
Observações finais
As listas ligadas são uma ferramenta poderosa, mas exigem cuidado com a memória e com os ponteiros. Pratique bastante implementando as operações básicas e depois avance para variações como listas duplamente ligadas e listas circulares. O entendimento profundo dessas estruturas é essencial para se tornar um programador C competente.