这篇笔记把链表从基础到进阶、从单链表到双链表、从基础操作到高频 LeetCode 题,一次性给你讲透,附带可直接跑的代码和易错点标注,帮你彻底啃下链表这块硬骨头。
一、链表基础:为什么链表这么重要?
链表是数据结构中最基础也最核心的线性结构之一,和数组相比,它的特点非常鲜明:
|---------|--------------------|----------------------|
| 特性 | 数组 | 链表 |
| 存储方式 | 连续内存空间 | 离散内存空间,通过指针连接 |
| 访问方式 | 随机访问(下标直接访问,O (1)) | 顺序访问(只能从头遍历,O (n)) |
| 插入 / 删除 | 需移动元素,O (n) | 仅修改指针,O (1)(已知结点位置时) |
| 空间开销 | 固定大小,易造成浪费 / 溢出 | 动态分配,每个结点额外存指针 |
而链表又分很多种,这是最常见的分类方式:
其中带头结点的单链表是我们学习和刷题中最常用的结构,它的优势非常明显:
-
头插、尾插、中间插入的逻辑可以统一处理,不需要单独判断头结点为空的情况
-
空链表和非空链表的操作方式完全一致,减少边界错误
二、单链表:从基础操作到核心原理
1. 结点定义与初始化
typedef int LDataType;
typedef struct LNode {
LDataType data;
struct LNode* next;
} LNode;
// 初始化带头结点的单链表
void ListInit(LNode** L) {
*L = (LNode*)malloc(sizeof(LNode));
assert(*L);
(*L)->next = NULL;
}
// 购买新结点(封装函数,避免重复写malloc)
LNode* BuyListNode(LDataType x) {
LNode* newNode = (LNode*)malloc(sizeof(LNode));
assert(newNode);
newNode->data = x;
newNode->next = NULL;
return newNode;
}
2. 基础操作全解析
(1)打印链表
void ListPrint(LNode* L) {
assert(L);
LNode* cur = L->next;
while (cur != NULL) {
printf("%d -> ", cur->data);
cur = cur->next;
}
printf("NULL\n");
}
核心:cur 从第一个有效结点(L->next)开始遍历,直到 NULL 结束。
(2)头插
void ListPushFront(LNode* L, LDataType x) {
assert(L);
LNode* newNode = BuyListNode(x);
newNode->next = L->next;
L->next = newNode;
}
易错点:必须先让新结点的 next 指向原头结点,再修改头结点的 next 指向新结点,否则会丢失原链表。
(3)尾插
void ListPushBack(LNode* L, LDataType x) {
assert(L);
LNode* tail = L;
while (tail->next != NULL) {
tail = tail->next;
}
LNode* newNode = BuyListNode(x);
tail->next = newNode;
}
易错点:尾结点的 next 必须是 NULL,否则会形成野指针。
(4)按位置插入
void ListInsert(LNode* L, int i, LDataType x) {
assert(L);
assert(i >= 0);
LNode* iNode = L;
int j = -1;
while (j < i - 1 && iNode != NULL) {
iNode = iNode->next;
++j;
}
assert(iNode); // 确保插入位置合法
LNode* newNode = BuyListNode(x);
newNode->next = iNode->next;
iNode->next = newNode;
}
关键:找到第 i 个结点的前一个结点(第 i-1 个),再修改指针。这也是带头结点的优势:头插时 i=0,iNode=L,逻辑和中间插入完全一致。
(5)头删
LDataType ListPopFront(LNode* L) {
assert(L);
assert(L->next); // 链表不能为空
LNode* first = L->next;
LDataType x = first->data;
L->next = first->next;
free(first);
return x;
}
(6)尾删
LDataType ListPopBack(LNode* L) {
assert(L);
assert(L->next);
LNode* prev = L;
LNode* cur = L->next;
while (cur->next != NULL) {
prev = cur;
cur = cur->next;
}
LDataType x = cur->data;
free(cur);
prev->next = NULL;
return x;
}
易错点:尾删必须找到尾结点的前一个结点,否则会导致链表断裂。
(7)销毁链表
void ListDestroy(LNode* L) {
assert(L);
LNode* cur = L->next;
while (cur != NULL) {
LNode* next = cur->next;
free(cur);
cur = next;
}
free(L);
}
三、单链表高频算法题(LeetCode 经典)
1. 链表的中间结点(LeetCode 876)
思路:快慢指针法
-
慢指针 slow 每次走 1 步,快指针 fast 每次走 2 步
-
当 fast 走到 NULL 时,slow 正好在中间结点
struct ListNode* middleNode(struct ListNode* head) {
struct ListNode *slow = head, *fast = head;
while (fast && fast->next) {
slow = slow->next;
fast = fast->next->next;
}
return slow;
}
2. 返回倒数第 k 个结点(剑指 Offer)
思路:快慢指针法
-
快指针先走 k 步,再和慢指针一起走
-
当快指针走到 NULL 时,慢指针就是倒数第 k 个结点
struct ListNode* getKthFromEnd(struct ListNode* head, int k) {
struct ListNode *slow = head, *fast = head;
while (k--) {
fast = fast->next;
}
while (fast) {
slow = slow->next;
fast = fast->next;
}
return slow;
}
3. 反转链表(LeetCode 206)
思路:头插法
- 新建一个空链表,遍历原链表,将每个结点头插到新链表
struct ListNode* reverseList(struct ListNode* head) {
struct ListNode* newHead = NULL;
struct ListNode* cur = head;
while (cur) {
struct ListNode* next = cur->next;
cur->next = newHead;
newHead = cur;
cur = next;
}
return newHead;
}
4. 合并两个有序链表(LeetCode 21)
思路:归并思想,取小尾插
- 新建哨兵结点,每次取两个链表中较小的结点尾插到新链表
struct ListNode* mergeTwoLists(struct ListNode* list1, struct ListNode* list2) {
struct ListNode* newHead = (struct ListNode*)malloc(sizeof(struct ListNode));
newHead->next = NULL;
struct ListNode* tail = newHead;
while (list1 && list2) {
if (list1->val < list2->val) {
tail->next = list1;
list1 = list1->next;
} else {
tail->next = list2;
list2 = list2->next;
}
tail = tail->next;
}
if (list1) tail->next = list1;
if (list2) tail->next = list2;
return newHead->next;
}
5. 链表相交(LeetCode 160)
思路:求长度差,长链表先走
-
先求两个链表的长度,计算长度差 gap
-
长链表先走 gap 步,再两个链表一起走,相遇点就是交点
struct ListNode* getIntersectionNode(struct ListNode *headA, struct ListNode *headB) {
int lenA = 1, lenB = 1;
struct ListNode* curA = headA;
struct ListNode* curB = headB;
while (curA->next) { curA = curA->next; lenA++; }
while (curB->next) { curB = curB->next; lenB++; }
if (curA != curB) return NULL;
int gap = abs(lenA - lenB);
struct ListNode *longList = headA, *shortList = headB;
if (lenA < lenB) { longList = headB; shortList = headA; }
while (gap--) { longList = longList->next; }
while (longList != shortList) {
longList = longList->next;
shortList = shortList->next;
}
return longList;
}
6. 环形链表(LeetCode 141 & 142)
判断是否有环:快慢指针法
- 慢指针每次 1 步,快指针每次 2 步,有环则一定会相遇
bool hasCycle(struct ListNode *head) {
struct ListNode *slow = head, *fast = head;
while (fast && fast->next) {
slow = slow->next;
fast = fast->next->next;
if (slow == fast) return true;
}
return false;
}
求环的入口点:相遇点与头结点同时出发,相遇点就是入口
struct ListNode *detectCycle(struct ListNode *head) {
struct ListNode *slow = head, *fast = head;
while (fast && fast->next) {
slow = slow->next;
fast = fast->next->next;
if (slow == fast) {
struct ListNode* meet = slow;
struct ListNode* cur = head;
while (cur != meet) {
cur = cur->next;
meet = meet->next;
}
return cur;
}
}
return NULL;
}
四、双向循环链表:更高效的链表结构
单链表的缺陷是无法直接访问前驱结点,而双向循环链表完美解决了这个问题,也是实际工程中最常用的链表结构。
1. 结点定义与初始化
typedef int DCListDataType;
typedef struct DCListNode {
DCListDataType data;
struct DCListNode* prev;
struct DCListNode* next;
} DCListNode;
// 初始化带头结点的双向循环链表
DCListNode* DCListInit() {
DCListNode* head = (DCListNode*)malloc(sizeof(DCListNode));
assert(head);
head->prev = head;
head->next = head;
return head;
}
// 购买新结点
DCListNode* BuyDCListNode(DCListDataType x) {
DCListNode* newNode = (DCListNode*)malloc(sizeof(DCListNode));
assert(newNode);
newNode->data = x;
newNode->prev = NULL;
newNode->next = NULL;
return newNode;
}
2. 核心操作
(1)尾插
void DCListPushBack(DCListNode* head, DCListDataType x) {
assert(head);
DCListNode* newNode = BuyDCListNode(x);
DCListNode* tail = head->prev;
tail->next = newNode;
newNode->prev = tail;
newNode->next = head;
head->prev = newNode;
}
(2)按位置插入
void DCListInsert(DCListNode* pos, DCListDataType x) {
assert(pos);
DCListNode* newNode = BuyDCListNode(x);
DCListNode* posPrev = pos->prev;
posPrev->next = newNode;
newNode->prev = posPrev;
newNode->next = pos;
pos->prev = newNode;
}
(3)删除指定结点
void DCListErase(DCListNode* pos) {
assert(pos);
DCListNode* posPrev = pos->prev;
DCListNode* posNext = pos->next;
posPrev->next = posNext;
posNext->prev = posPrev;
free(pos);
}
(4)打印链表
void DCListPrint(DCListNode* head) {
assert(head);
DCListNode* cur = head->next;
while (cur != head) {
printf("%d <-> ", cur->data);
cur = cur->next;
}
printf("HEAD\n");
}
五、链表易错点总结(避坑指南)
-
野指针问题:修改指针时一定要先保存下一个结点的地址,避免链表断裂
-
边界判断:空链表、头结点、尾结点的情况一定要单独考虑(带头结点的链表可以减少这类问题)
-
内存泄漏:malloc 的结点必须 free,尤其是在删除、销毁操作中
-
循环链表终止条件 :遍历循环链表时,终止条件是
cur != head,而不是cur != NULL -
快慢指针的循环条件 :
fast && fast->next,避免 fast->next->next 访问空指针
六、写在最后:链表的学习建议
链表的核心难点不是代码,而是指针操作的逻辑。建议你这样学习:
-
先画链表的逻辑图,搞清楚每个指针的指向变化,再写代码
-
从单链表的基础操作开始,把头插、尾插、插入、删除练熟
-
再刷 LeetCode 的链表题,重点掌握快慢指针、头插法、归并思想
-
最后学习双向循环链表,理解它和单链表的区别与优势