一、什么是单链表
单链表是一系列节点通过指针连接而成的数据结构。每个节点包含两部分:
-
数据域:存储数据
-
指针域:指向下一个节点
最后一个节点的指针指向NULL,表示链表结束。
画个图:
text
head → [data|next] → [data|next] → [data|next] → NULL
与顺序表的对比:
-
顺序表:连续内存,随机访问快,插入删除慢
-
单链表:非连续内存,随机访问慢,插入删除快
二、节点的结构定义
2.1 结构体自引用
单链表的节点定义中,指针域指向的是同类型的节点,这叫结构体自引用。
c
typedef struct Node {
int data; // 数据域
struct Node *next; // 指针域,指向下一个节点
} Node, *PNode;
注意 :这里必须写 struct Node *next,不能写成 Node *next。因为在 typedef 还没生效的时候,Node 这个类型名还不存在。
2.2 链表结构
为了方便管理,我们还可以定义一个链表结构体:
c
typedef struct {
PNode head; // 头指针
int size; // 节点个数
} LinkedList;
当然,也可以只用一个 head 指针来管理链表。但加上 size 可以方便地获取长度,不用每次都遍历。
三、头结点(哑结点)的作用
链表的头指针有两种处理方式:
方式一:不带头结点
text
head → [节点1] → [节点2] → NULL
-
head直接指向第一个有效节点 -
空链表时
head = NULL
方式二:带头结点(哑结点)
text
head → [哑结点|next] → [节点1] → [节点2] → NULL
-
head指向一个不存数据的头结点 -
头结点的
next指向第一个有效节点 -
空链表时,头结点的
next = NULL
带头结点的好处:
-
统一操作:插入和删除第一个节点时,不用单独处理。代码更简洁。
-
空链表有固定状态 :不用判断
head == NULL,只需要判断head->next == NULL。
本专栏采用带头结点的方式,代码会更统一。
四、基本操作实现
4.1 初始化
c
void initList(LinkedList *list) {
// 创建头结点(哑结点)
list->head = (PNode)malloc(sizeof(Node));
if (list->head == NULL) {
printf("初始化失败\n");
exit(1);
}
list->head->next = NULL; // 空链表
list->size = 0;
}
4.2 创建新节点
插入操作中经常要创建新节点,写一个辅助函数:
c
PNode createNode(int value) {
PNode newNode = (PNode)malloc(sizeof(Node));
if (newNode == NULL) {
printf("创建节点失败\n");
return NULL;
}
newNode->data = value;
newNode->next = NULL;
return newNode;
}
4.3 头插法
在链表头部插入节点(第一个有效节点的位置)。
c
void insertAtHead(LinkedList *list, int value) {
PNode newNode = createNode(value);
if (newNode == NULL) return;
newNode->next = list->head->next; // 新节点指向原来的第一个节点
list->head->next = newNode; // 头结点指向新节点
list->size++;
}
画图理解:
text
插入前:
head → [哑结点] → [A] → [B] → NULL
插入新节点X:
1. X->next = A
2. 哑结点->next = X
结果:
head → [哑结点] → [X] → [A] → [B] → NULL
4.4 尾插法
在链表尾部插入节点。
c
void insertAtTail(LinkedList *list, int value) {
PNode newNode = createNode(value);
if (newNode == NULL) return;
// 找到最后一个节点
PNode cur = list->head;
while (cur->next != NULL) {
cur = cur->next;
}
cur->next = newNode;
list->size++;
}
尾插法需要遍历到末尾,时间复杂度O(n)。如果经常尾插,可以维护一个尾指针优化。
4.5 指定位置插入
在索引 pos 处插入(pos从0开始,0表示第一个有效节点)。
c
int insertAt(LinkedList *list, int pos, int value) {
if (pos < 0 || pos > list->size) {
printf("插入位置不合法\n");
return -1;
}
// 找到要插入位置的前一个节点
PNode prev = list->head;
for (int i = 0; i < pos; i++) {
prev = prev->next;
}
PNode newNode = createNode(value);
if (newNode == NULL) return -1;
newNode->next = prev->next;
prev->next = newNode;
list->size++;
return 0;
}
关键点 :插入操作的关键是找到前驱节点。在单链表中,只有知道了前驱节点,才能把新节点链进去。
4.6 删除指定位置
c
int deleteAt(LinkedList *list, int pos) {
if (pos < 0 || pos >= list->size) {
printf("删除位置不合法\n");
return -1;
}
// 找到要删除节点的前一个节点
PNode prev = list->head;
for (int i = 0; i < pos; i++) {
prev = prev->next;
}
PNode toDelete = prev->next;
int value = toDelete->data;
prev->next = toDelete->next;
free(toDelete);
list->size--;
return value;
}
画图理解删除:
text
删除前:
... → [prev] → [toDelete] → [next] → ...
操作:
prev->next = toDelete->next
free(toDelete)
结果:
... → [prev] → [next] → ...
4.7 按值查找
c
int find(LinkedList *list, int value) {
PNode cur = list->head->next; // 跳过哑结点
int pos = 0;
while (cur != NULL) {
if (cur->data == value) {
return pos;
}
cur = cur->next;
pos++;
}
return -1;
}
4.8 遍历打印
c
void print(LinkedList *list) {
PNode cur = list->head->next; // 跳过哑结点
printf("size=%d, [", list->size);
while (cur != NULL) {
printf("%d", cur->data);
if (cur->next != NULL) printf(" -> ");
cur = cur->next;
}
printf("]\n");
}
4.9 销毁链表
c
void destroyList(LinkedList *list) {
PNode cur = list->head;
while (cur != NULL) {
PNode temp = cur;
cur = cur->next;
free(temp);
}
list->head = NULL;
list->size = 0;
}
五、完整代码演示
c
#include <stdio.h>
#include <stdlib.h>
typedef struct Node {
int data;
struct Node *next;
} Node, *PNode;
typedef struct {
PNode head;
int size;
} LinkedList;
void initList(LinkedList *list) {
list->head = (PNode)malloc(sizeof(Node));
if (list->head == NULL) {
printf("初始化失败\n");
exit(1);
}
list->head->next = NULL;
list->size = 0;
}
PNode createNode(int value) {
PNode newNode = (PNode)malloc(sizeof(Node));
if (newNode == NULL) return NULL;
newNode->data = value;
newNode->next = NULL;
return newNode;
}
void insertAtHead(LinkedList *list, int value) {
PNode newNode = createNode(value);
if (newNode == NULL) return;
newNode->next = list->head->next;
list->head->next = newNode;
list->size++;
}
void insertAtTail(LinkedList *list, int value) {
PNode newNode = createNode(value);
if (newNode == NULL) return;
PNode cur = list->head;
while (cur->next != NULL) {
cur = cur->next;
}
cur->next = newNode;
list->size++;
}
int insertAt(LinkedList *list, int pos, int value) {
if (pos < 0 || pos > list->size) {
printf("插入位置不合法\n");
return -1;
}
PNode prev = list->head;
for (int i = 0; i < pos; i++) {
prev = prev->next;
}
PNode newNode = createNode(value);
if (newNode == NULL) return -1;
newNode->next = prev->next;
prev->next = newNode;
list->size++;
return 0;
}
int deleteAt(LinkedList *list, int pos) {
if (pos < 0 || pos >= list->size) {
printf("删除位置不合法\n");
return -1;
}
PNode prev = list->head;
for (int i = 0; i < pos; i++) {
prev = prev->next;
}
PNode toDelete = prev->next;
int value = toDelete->data;
prev->next = toDelete->next;
free(toDelete);
list->size--;
return value;
}
int find(LinkedList *list, int value) {
PNode cur = list->head->next;
int pos = 0;
while (cur != NULL) {
if (cur->data == value) return pos;
cur = cur->next;
pos++;
}
return -1;
}
void print(LinkedList *list) {
PNode cur = list->head->next;
printf("size=%d, [", list->size);
while (cur != NULL) {
printf("%d", cur->data);
if (cur->next != NULL) printf(" -> ");
cur = cur->next;
}
printf("]\n");
}
void destroyList(LinkedList *list) {
PNode cur = list->head;
while (cur != NULL) {
PNode temp = cur;
cur = cur->next;
free(temp);
}
list->head = NULL;
list->size = 0;
}
int main() {
LinkedList list;
initList(&list);
// 头插法
insertAtHead(&list, 10);
insertAtHead(&list, 20);
insertAtHead(&list, 30);
print(&list); // [30 -> 20 -> 10]
// 尾插法
insertAtTail(&list, 40);
insertAtTail(&list, 50);
print(&list); // [30 -> 20 -> 10 -> 40 -> 50]
// 指定位置插入
insertAt(&list, 2, 25);
print(&list); // [30 -> 20 -> 25 -> 10 -> 40 -> 50]
// 删除
int val = deleteAt(&list, 2);
printf("删除的值: %d\n", val);
print(&list); // [30 -> 20 -> 10 -> 40 -> 50]
// 查找
int pos = find(&list, 40);
printf("40的位置: %d\n", pos);
destroyList(&list);
return 0;
}
运行结果:
text
size=3, [30 -> 20 -> 10]
size=5, [30 -> 20 -> 10 -> 40 -> 50]
size=6, [30 -> 20 -> 25 -> 10 -> 40 -> 50]
删除的值: 25
size=5, [30 -> 20 -> 10 -> 40 -> 50]
40的位置: 3
六、头插法 vs 尾插法
| 方法 | 操作位置 | 时间复杂度 | 结果顺序 |
|---|---|---|---|
| 头插法 | 头部插入 | O(1) | 逆序(先插入的在后面) |
| 尾插法 | 尾部插入 | O(n) | 正序(先插入的在前面) |
如果输入序列是1,2,3:
-
头插法结果:3 → 2 → 1
-
尾插法结果:1 → 2 → 3
七、复杂度分析
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
| 头插 | O(1) | 直接修改指针 |
| 尾插 | O(n) | 需要遍历到末尾 |
| 中间插入 | O(n) | 需要找到前驱节点 |
| 删除 | O(n) | 需要找到前驱节点 |
| 按值查找 | O(n) | 需要遍历 |
| 按索引访问 | O(n) | 没有随机访问能力 |
八、小结
这一篇我们实现了单链表,要点总结:
| 要点 | 说明 |
|---|---|
| 节点结构 | 数据域 + 指针域,结构体自引用 |
| 头结点 | 哑结点,统一操作,简化代码 |
| 头插法 | O(1)时间复杂度,结果逆序 |
| 尾插法 | O(n)时间复杂度,结果正序 |
| 插入/删除 | 关键是找到前驱节点 |
| 优缺点 | 插入删除快,但无随机访问 |
下一篇我们会讲单链表的经典面试题:反转链表、找中间节点、判断环等。
九、思考题
-
带头结点和不带头结点的链表,在插入第一个节点时有什么区别?
-
如果要在链表的指定位置插入节点,为什么要先找到前驱节点,而不是直接找到目标位置?
-
头插法构建的链表和尾插法构建的链表,在遍历顺序上有什么不同?
-
写一个函数,计算链表的长度(要求时间复杂度O(n))。
欢迎在评论区讨论你的答案。