前言
链表是C语言中最重要的数据结构之一,也是面试中的高频考点。很多初学者在刚接触链表时,常常被指针、内存分配等概念搞得晕头转向。本文将以最通俗易懂的方式,带你从零开始学习链表,包含完整的代码示例和详细的注释说明。
读完本文,你将掌握:链表的基本概念、结构体定义、所有基本操作(创建/遍历/插入/删除/查找/销毁),以及常见的链表面试题解法。
第1章 链表的基本概念
1.1 什么是链表
链表是一种动态数据结构,由一系列节点(Node)组成,每个节点包含两部分:
• 数据域:存储实际的数据(如整型、字符型等)
• 指针域:存储下一个节点的内存地址
与数组不同,链表的节点在内存中不是连续存储的,而是通过指针相互连接,形成一个链式结构。就像一串糖葫芦,每颗山楂代表一个节点,连接山楂的竹签就是指针。
1.2 链表与数组的对比
为了更好地理解链表的优势,我们来对比一下链表和数组的区别:
|-------------|------------|------------|
| 对比项 | 数组 | 链表 |
| 内存存储 | 连续存储 | 离散存储 |
| 大小固定 | 是,声明时确定 | 否,动态分配 |
| 访问元素 | O(1),随机访问 | O(n),顺序访问 |
| 插入/删除 | O(n),需移动元素 | O(1),只需改指针 |
总结:数组适合频繁访问元素的场景,链表适合频繁插入删除的场景。
第2章 单链表的结构体定义
在C语言中,我们使用结构体来定义链表的节点。每个节点包含数据域和指针域。
2.1 节点结构体定义
// 定义链表节点结构体
typedef struct Node {
int data; // 数据域:存储数据
struct Node* next; // 指针域:指向下一个节点
} Node;
代码解释:
• typedef:给结构体起别名,方便使用
• int data:数据域,这里以int为例,也可以是其他类型
• struct Node* next:指针域,指向同类型的下一个节点
• 注意:结构体内部不能使用Node* next,因为Node还没定义完成
2.2 头指针的概念
链表通过一个头指针(head)来访问整个链表。头指针指向链表的第一个节点。如果链表为空,头指针的值为NULL。
// 定义头指针,初始化为空链表
Node* head = NULL;
第3章 链表的基本操作
本章将详细讲解链表的所有基本操作,每个操作都配有完整的代码和详细注释。
3.1 创建节点
在进行任何操作之前,我们首先需要学会如何创建一个新的节点。
// 创建新节点
Node* createNode(int data) {
// 1. 分配内存空间
Node* newNode = (Node*)malloc(sizeof(Node));
// 2. 检查内存分配是否成功
if (newNode == NULL) {
printf("内存分配失败!\n");
exit(1); // 退出程序
}
// 3. 初始化节点数据
newNode->data = data; // 设置数据域
newNode->next = NULL; // 指针域初始化为空
return newNode;
}
注意事项:
• 使用malloc分配内存后,一定要检查是否分配成功
• 新节点的next指针一定要初始化为NULL
• 用完节点后要用free释放内存,避免内存泄漏
3.2 遍历链表
遍历链表就是从头节点开始,依次访问每个节点,直到链表末尾(next为NULL)。
// 遍历打印链表
void printList(Node* head) {
// 检查链表是否为空
if (head == NULL) {
printf("链表为空!\n");
return;
}
// 使用临时指针遍历,不要直接修改head
Node* current = head;
printf("链表内容:");
// 循环遍历,直到当前节点为空
while (current != NULL) {
printf("%d -> ", current->data);
current = current->next; // 移动到下一个节点
}
printf("NULL\n");
}
关键点:
• 一定要用临时指针current遍历,不要直接修改头指针head
• 循环条件是current != NULL,不是current->next != NULL
• 遍历前先判断链表是否为空
3.3 插入操作
链表的插入操作有三种:头插法、尾插法、指定位置插入。
3.3.1 头插法
头插法就是将新节点插入到链表的最前面,成为新的头节点。
// 头插法:在链表头部插入节点
void insertAtHead(Node** head, int data) {
// 1. 创建新节点
Node* newNode = createNode(data);
// 2. 新节点的next指向原头节点
newNode->next = *head;
// 3. 更新头指针指向新节点
*head = newNode;
printf("成功头插:%d\n", data);
}
注意:这里使用二级指针Node** head,因为我们需要修改头指针本身的值。如果使用一级指针,修改的只是指针的副本,不会影响原来的头指针。
3.3.2 尾插法
尾插法就是将新节点插入到链表的最后面。
// 尾插法:在链表尾部插入节点
void insertAtTail(Node** head, int data) {
// 1. 创建新节点
Node* newNode = createNode(data);
// 2. 如果链表为空,新节点就是头节点
if (*head == NULL) {
*head = newNode;
printf("成功尾插:%d\n", data);
return;
}
// 3. 找到最后一个节点
Node* current = *head;
while (current->next != NULL) {
current = current->next;
}
// 4. 将新节点链接到最后
current->next = newNode;
printf("成功尾插:%d\n", data);
}
关键点:找尾节点的循环条件是current->next != NULL,这样current停在最后一个节点上,而不是NULL。
3.3.3 指定位置插入
在指定位置(第pos个位置,从1开始计数)插入新节点。
// 指定位置插入:在第pos个位置插入节点
void insertAtPosition(Node** head, int data, int pos) {
// 1. 位置合法性检查
if (pos < 1) {
printf("位置不合法!\n");
return;
}
// 2. 如果插入位置是1,相当于头插
if (pos == 1) {
insertAtHead(head, data);
return;
}
// 3. 创建新节点
Node* newNode = createNode(data);
Node* current = *head;
// 4. 找到第pos-1个节点(前驱节点)
for (int i = 1; i < pos - 1 && current != NULL; i++) {
current = current->next;
}
// 5. 检查位置是否超出链表长度
if (current == NULL) {
printf("位置超出链表长度!\n");
free(newNode); // 释放已分配的内存
return;
}
// 6. 完成插入:先连后,再连前
newNode->next = current->next;
current->next = newNode;
printf("在位置%d成功插入:%d\n", pos, data);
}
插入技巧口诀:先连后,再连前。这样就不会丢失后面的节点。
3.4 删除操作
链表的删除操作也有三种:头删、尾删、指定位置删除。
3.4.1 头删法
// 头删法:删除第一个节点
void deleteAtHead(Node** head) {
// 1. 检查链表是否为空
if (*head == NULL) {
printf("链表为空,无法删除!\n");
return;
}
// 2. 保存要删除的节点
Node* temp = *head;
// 3. 更新头指针
*head = (*head)->next;
// 4. 释放内存
printf("成功删除头节点:%d\n", temp->data);
free(temp);
}
3.4.2 尾删法
// 尾删法:删除最后一个节点
void deleteAtTail(Node** head) {
// 1. 检查链表是否为空
if (*head == NULL) {
printf("链表为空,无法删除!\n");
return;
}
// 2. 如果只有一个节点
if ((*head)->next == NULL) {
printf("成功删除尾节点:%d\n", (*head)->data);
free(*head);
*head = NULL;
return;
}
// 3. 找到倒数第二个节点
Node* current = *head;
while (current->next->next != NULL) {
current = current->next;
}
// 4. 删除最后一个节点
printf("成功删除尾节点:%d\n", current->next->data);
free(current->next);
current->next = NULL;
}
3.4.3 指定位置删除
// 指定位置删除:删除第pos个位置的节点
void deleteAtPosition(Node** head, int pos) {
// 1. 检查链表是否为空
if (*head == NULL) {
printf("链表为空,无法删除!\n");
return;
}
// 2. 位置合法性检查
if (pos < 1) {
printf("位置不合法!\n");
return;
}
// 3. 如果删除位置是1,相当于头删
if (pos == 1) {
deleteAtHead(head);
return;
}
// 4. 找到第pos-1个节点(前驱节点)
Node* current = *head;
for (int i = 1; i < pos - 1 && current->next != NULL; i++) {
current = current->next;
}
// 5. 检查位置是否超出链表长度
if (current->next == NULL) {
printf("位置超出链表长度!\n");
return;
}
// 6. 保存要删除的节点
Node* temp = current->next;
// 7. 重新链接,跳过要删除的节点
current->next = temp->next;
// 8. 释放内存
printf("在位置%d成功删除:%d\n", pos, temp->data);
free(temp);
}
3.5 查找节点
// 查找节点:查找值为data的节点是否存在
int searchNode(Node* head, int data) {
Node* current = head;
int position = 1;
while (current != NULL) {
if (current->data == data) {
printf("找到值%d,在位置%d\n", data, position);
return position; // 找到,返回位置
}
current = current->next;
position++;
}
printf("未找到值%d\n", data);
return -1; // 未找到,返回-1
}
3.6 销毁链表
链表使用完后一定要记得销毁,释放所有节点的内存,否则会造成内存泄漏!
// 销毁链表:释放所有节点的内存
void destroyList(Node** head) {
Node* current = *head;
Node* next;
while (current != NULL) {
next = current->next; // 先保存下一个节点
free(current); // 释放当前节点
current = next; // 移动到下一个节点
}
*head = NULL; // 头指针置空
printf("链表已成功销毁!\n");
}
3.7 完整可运行的测试程序
下面是一个完整的测试程序,包含了所有的操作,你可以直接复制运行:
#include <stdio.h>
#include <stdlib.h>
// 定义链表节点结构体
typedef struct Node {
int data;
struct Node* next;
} Node;
// 这里放入上面所有的函数声明...
int main() {
Node* head = NULL; // 创建空链表
printf("===== 链表测试程序 =====\n");
// 测试尾插
insertAtTail(&head, 10);
insertAtTail(&head, 20);
insertAtTail(&head, 30);
printList(head);
// 测试头插
insertAtHead(&head, 5);
printList(head);
// 测试指定位置插入
insertAtPosition(&head, 15, 3);
printList(head);
// 测试查找
searchNode(head, 15);
searchNode(head, 99);
// 测试删除
deleteAtPosition(&head, 3);
printList(head);
deleteAtHead(&head);
deleteAtTail(&head);
printList(head);
// 测试销毁
destroyList(&head);
printList(head);
return 0;
}
第4章 链表常见面试题总结
4.1 反转链表
题目:将单链表反转,例如 1->2->3->4->5 反转为 5->4->3->2->1。
解法:三指针迭代法。
Node* reverseList(Node* head) {
Node* prev = NULL; // 前一个节点
Node* curr = head; // 当前节点
while (curr != NULL) {
Node* next = curr->next; // 保存下一个节点
curr->next = prev; // 反转指针
prev = curr; // prev前移
curr = next; // curr前移
}
return prev; // prev成为新的头节点
}
4.2 判断链表是否有环
题目:判断链表中是否存在环。
解法:快慢指针法(龟兔赛跑算法)。慢指针每次走1步,快指针每次走2步,如果有环,它们一定会相遇。
int hasCycle(Node* head) {
if (head == NULL || head->next == NULL) {
return 0; // 没有环
}
Node* slow = head;
Node* fast = head;
while (fast != NULL && fast->next != NULL) {
slow = slow->next; // 慢指针走1步
fast = fast->next->next; // 快指针走2步
if (slow == fast) {
return 1; // 相遇,有环
}
}
return 0; // 没有环
}
4.3 求链表的中间节点
题目:找到链表的中间节点,如果有两个中间节点,返回第二个。
解法:还是快慢指针!快指针走两步,慢指针走一步,当快指针到末尾时,慢指针正好在中间。
Node* middleNode(Node* head) {
Node* slow = head;
Node* fast = head;
while (fast != NULL && fast->next != NULL) {
slow = slow->next;
fast = fast->next->next;
}
return slow;
}
4.4 删除链表的倒数第N个节点
题目:删除链表的倒数第n个节点,并返回链表的头节点。
解法:双指针法。先让快指针走n步,然后快慢指针一起走,当快指针到末尾时,慢指针正好指向倒数第n个节点的前驱。
Node* removeNthFromEnd(Node* head, int n) {
// 创建哑节点,简化头节点删除处理
Node dummy = {0, head};
Node* fast = &dummy;
Node* slow = &dummy;
// 快指针先走n+1步
for (int i = 0; i <= n; i++) {
fast = fast->next;
}
// 一起走
while (fast != NULL) {
slow = slow->next;
fast = fast->next;
}
// 删除节点
Node* temp = slow->next;
slow->next = slow->next->next;
free(temp);
return dummy.next;
}
4.5 合并两个有序链表
题目:将两个升序链表合并为一个新的升序链表。
Node* mergeTwoLists(Node* l1, Node* l2) {
// 创建哑节点
Node dummy = {0, NULL};
Node* current = &dummy;
while (l1 != NULL && l2 != NULL) {
if (l1->data <= l2->data) {
current->next = l1;
l1 = l1->next;
} else {
current->next = l2;
l2 = l2->next;
}
current = current->next;
}
// 接上剩余部分
current->next = (l1 != NULL) ? l1 : l2;
return dummy.next;
}
第5章 总结
通过本文的学习,相信你已经掌握了C语言链表的所有基本操作。让我们回顾一下重点:
• 链表是动态数据结构,适合频繁插入删除的场景
• 每个节点包含数据域和指针域,通过指针连接
• 插入操作口诀:先连后,再连前
• 删除操作:先保存,再链接,后释放
• 永远记得检查空指针的情况
• 使用完链表一定要销毁,避免内存泄漏
• 面试高频考点:反转链表、快慢指针判断环、找中间节点
链表是数据结构的基础,掌握了链表,后续学习栈、队列、树等数据结构就会容易很多。建议大家多动手写代码,不要只看不练,真正理解指针的运作原理。
------ 全文完 ------