链表作为数据结构中的经典类型,在实际编程和算法面试中频繁出现。很多初学者在学习链表时,常常被指针操作绕得晕头转向。我自己最初接触链表时,也经历过无数次"段错误"的折磨。直到后来掌握了虚拟头节点(dummy node)的技巧,才真正理解了链表的精髓。
LeetCode 707题"设计链表"正是检验链表掌握程度的绝佳题目。它要求我们实现一个完整的链表类,包含以下基本操作:
这道题的难点在于如何处理各种边界条件,比如:
传统链表实现最让人头疼的就是处理头节点的特殊情况。每次插入或删除时,都需要判断是否涉及头节点,导致代码中充斥着if-else分支。虚拟头节点的出现完美解决了这个问题。
虚拟头节点的核心思想是:
这样带来的好处是:
在C语言中,我们可以这样定义单向链表的结构:
c复制// 链表节点结构
typedef struct ListNode {
int val;
struct ListNode *next;
} ListNode;
// 链表管理器结构
typedef struct {
int size; // 当前链表长度
ListNode *dummyHead; // 虚拟头节点
} MyLinkedList;
这里的关键点是:
双向链表在单向链表的基础上增加了前驱指针,使得可以双向遍历。我们进一步优化结构:
c复制// 双向链表节点
typedef struct DoubleListNode {
int val;
struct DoubleListNode *prev;
struct DoubleListNode *next;
} DoubleListNode;
// 双向链表管理器
typedef struct {
int size;
DoubleListNode *dummyHead; // 虚拟头
DoubleListNode *dummyTail; // 虚拟尾
} MyLinkedList;
双向链表的改进包括:
单向链表的初始化需要注意内存分配和初始状态设置:
c复制MyLinkedList* myLinkedListCreate() {
// 分配链表管理器内存
MyLinkedList* obj = (MyLinkedList*)malloc(sizeof(MyLinkedList));
if (!obj) return NULL;
// 分配虚拟头节点内存
obj->dummyHead = (ListNode*)malloc(sizeof(ListNode));
if (!obj->dummyHead) {
free(obj);
return NULL;
}
// 初始化状态
obj->dummyHead->next = NULL;
obj->size = 0;
return obj;
}
关键点:每次内存分配后都要检查是否成功,避免后续操作出现段错误。
双向链表的初始化更复杂些,需要设置头尾节点的相互指向:
c复制MyLinkedList* myLinkedListCreate() {
MyLinkedList* obj = (MyLinkedList*)malloc(sizeof(MyLinkedList));
if (!obj) return NULL;
// 分配并初始化虚拟头节点
obj->dummyHead = (DoubleListNode*)malloc(sizeof(DoubleListNode));
if (!obj->dummyHead) {
free(obj);
return NULL;
}
// 分配并初始化虚拟尾节点
obj->dummyTail = (DoubleListNode*)malloc(sizeof(DoubleListNode));
if (!obj->dummyTail) {
free(obj->dummyHead);
free(obj);
return NULL;
}
// 建立双向连接
obj->dummyHead->prev = NULL;
obj->dummyHead->next = obj->dummyTail;
obj->dummyTail->prev = obj->dummyHead;
obj->dummyTail->next = NULL;
obj->size = 0;
return obj;
}
单向链表的头插法非常简洁:
c复制void myLinkedListAddAtHead(MyLinkedList* obj, int val) {
if (!obj) return;
ListNode* newNode = (ListNode*)malloc(sizeof(ListNode));
if (!newNode) return;
newNode->val = val;
newNode->next = obj->dummyHead->next; // 新节点指向原第一个节点
obj->dummyHead->next = newNode; // 虚拟头指向新节点
obj->size++;
}
双向链表的头插法需要考虑prev指针的设置:
c复制void myLinkedListAddAtHead(MyLinkedList* obj, int val) {
if (!obj) return;
DoubleListNode* newNode = (DoubleListNode*)malloc(sizeof(DoubleListNode));
if (!newNode) return;
newNode->val = val;
// 保存原第一个真实节点
DoubleListNode* oldFirst = obj->dummyHead->next;
// 设置新节点的前后关系
newNode->prev = obj->dummyHead;
newNode->next = oldFirst;
// 更新周围节点的指针
obj->dummyHead->next = newNode;
oldFirst->prev = newNode;
obj->size++;
}
单向链表的尾插法需要遍历到末尾,时间复杂度为O(n):
c复制void myLinkedListAddAtTail(MyLinkedList* obj, int val) {
if (!obj) return;
ListNode* newNode = (ListNode*)malloc(sizeof(ListNode));
if (!newNode) return;
newNode->val = val;
newNode->next = NULL;
ListNode* curr = obj->dummyHead;
while (curr->next) {
curr = curr->next;
}
curr->next = newNode;
obj->size++;
}
双向链表借助dummyTail可以实现O(1)时间复杂度的尾插法:
c复制void myLinkedListAddAtTail(MyLinkedList* obj, int val) {
if (!obj) return;
DoubleListNode* newNode = (DoubleListNode*)malloc(sizeof(DoubleListNode));
if (!newNode) return;
newNode->val = val;
// 直接通过dummyTail找到最后一个真实节点
DoubleListNode* last = obj->dummyTail->prev;
// 插入新节点
newNode->prev = last;
newNode->next = obj->dummyTail;
last->next = newNode;
obj->dummyTail->prev = newNode;
obj->size++;
}
删除节点时需要特别注意:
单向链表的删除实现:
c复制void myLinkedListDeleteAtIndex(MyLinkedList* obj, int index) {
if (!obj || index < 0 || index >= obj->size) return;
// 找到待删除节点的前驱
ListNode* prev = obj->dummyHead;
for (int i = 0; i < index; i++) {
prev = prev->next;
}
ListNode* toDelete = prev->next;
prev->next = toDelete->next;
free(toDelete);
obj->size--;
}
双向链表的删除需要考虑前后节点的指针更新:
c复制void myLinkedListDeleteAtIndex(MyLinkedList* obj, int index) {
if (!obj || index < 0 || index >= obj->size) return;
// 找到待删除节点
DoubleListNode* curr = obj->dummyHead->next;
for (int i = 0; i < index; i++) {
curr = curr->next;
}
// 更新前后节点的指针
curr->prev->next = curr->next;
curr->next->prev = curr->prev;
free(curr);
obj->size--;
}
在链表操作中,选择不同的起始点会导致不同的遍历结果:
c复制ListNode* prev = obj->dummyHead;
for (int i = 0; i < index; i++) {
prev = prev->next;
}
// 循环结束后,prev指向第index节点的前驱
c复制ListNode* curr = obj->dummyHead->next;
for (int i = 0; i < index; i++) {
curr = curr->next;
}
// 循环结束后,curr指向第index节点
选择依据:
在链表操作中,指针修改的顺序至关重要。错误的顺序可能导致链表断裂或内存泄漏。以双向链表的插入为例:
正确顺序:
c复制// 1. 设置新节点的指针
newNode->prev = prevNode;
newNode->next = nextNode;
// 2. 更新前驱节点的next
prevNode->next = newNode;
// 3. 更新后继节点的prev
nextNode->prev = newNode;
错误示范:
c复制// 错误顺序可能导致链表断裂
prevNode->next = newNode;
nextNode->prev = newNode;
newNode->prev = prevNode;
newNode->next = nextNode;
在复杂的指针操作中,引入临时变量可以大大提高代码可读性和安全性。例如双向链表的头插法优化:
不使用防呆变量:
c复制newNode->next = obj->dummyHead->next;
newNode->prev = obj->dummyHead;
obj->dummyHead->next->prev = newNode;
obj->dummyHead->next = newNode;
使用防呆变量:
c复制DoubleListNode* oldFirst = obj->dummyHead->next;
newNode->prev = obj->dummyHead;
newNode->next = oldFirst;
obj->dummyHead->next = newNode;
oldFirst->prev = newNode;
防呆变量的优势:
每个malloc调用后都必须检查是否成功:
c复制ListNode* newNode = (ListNode*)malloc(sizeof(ListNode));
if (!newNode) {
// 处理内存分配失败
return;
}
单向链表的销毁需要遍历所有节点:
c复制void myLinkedListFree(MyLinkedList* obj) {
if (!obj) return;
ListNode* curr = obj->dummyHead;
while (curr) {
ListNode* temp = curr->next;
free(curr);
curr = temp;
}
free(obj);
}
双向链表的销毁类似,但可以从任意方向遍历:
c复制void myLinkedListFree(MyLinkedList* obj) {
if (!obj) return;
DoubleListNode* curr = obj->dummyHead;
while (curr) {
DoubleListNode* temp = curr->next;
free(curr);
curr = temp;
}
free(obj);
}
所有公共接口都应该检查输入有效性:
c复制int myLinkedListGet(MyLinkedList* obj, int index) {
if (!obj || index < 0 || index >= obj->size) {
return -1; // 错误码
}
// ...正常逻辑
}
| 操作 | 单向链表 | 双向链表 |
|---|---|---|
| 头插 | O(1) | O(1) |
| 尾插 | O(n) | O(1) |
| 随机插入 | O(n) | O(n) |
| 头删 | O(1) | O(1) |
| 尾删 | O(n) | O(1) |
| 随机删除 | O(n) | O(n) |
| 随机访问 | O(n) | O(n) |
双向链表比单向链表每个节点多一个指针的空间开销,但通常可以接受。
c复制void printLinkedList(MyLinkedList* obj) {
ListNode* curr = obj->dummyHead->next;
while (curr) {
printf("%d -> ", curr->val);
curr = curr->next;
}
printf("NULL\n");
}
c复制int checkListIntegrity(MyLinkedList* obj) {
int count = 0;
ListNode* curr = obj->dummyHead->next;
while (curr) {
count++;
if (count > obj->size) return 0; // 检测到环
curr = curr->next;
}
return count == obj->size;
}
全面的测试应该包括:
虽然LeetCode题目简化了实际场景,但核心思想是相通的。在工程实践中:
理解这些底层实现对我们设计高质量的数据结构大有裨益。