1. 链表基础概念与设计思路
链表是一种物理存储单元上非连续、非顺序的线性数据结构,由一系列节点(Node)组成。每个节点包含两个部分:数据域(存储数据元素)和指针域(存储下一个节点的地址)。与数组不同,链表不需要预先分配固定大小的存储空间,可以动态地进行内存分配。
1.1 链表的核心特性
链表的优势主要体现在动态内存管理上。当我们需要频繁插入和删除元素时,链表的时间复杂度仅为O(1),而数组则需要O(n)。这是因为链表只需修改指针指向,而数组需要移动大量元素。
但链表也有其局限性:随机访问效率低,需要从头节点开始逐个遍历,时间复杂度为O(n);需要额外的空间存储指针;缓存局部性较差,因为节点在内存中不是连续存储的。
1.2 常见链表类型对比
单链表是最基础的链表形式,每个节点只有一个指针指向下一个节点。双链表则在单链表基础上增加了一个指向前驱节点的指针,使得双向遍历成为可能。循环链表则将尾节点的指针指向头节点,形成一个环状结构。
在707题的设计链表问题中,我们主要关注单链表的实现。题目要求我们设计一个链表类,支持以下操作:
- get(index):获取链表中第index个节点的值
- addAtHead(val):在链表头部添加节点
- addAtTail(val):在链表尾部添加节点
- addAtIndex(index, val):在指定位置插入节点
- deleteAtIndex(index):删除指定位置的节点
2. 虚拟头节点的巧妙运用
2.1 为什么需要虚拟头节点
链表操作中最容易出错的就是处理头节点。因为头节点没有前驱节点,很多操作需要特殊处理。比如删除头节点时,需要单独修改链表的head指针;在头节点前插入时也需要特殊处理。
虚拟头节点(dummy node)技巧可以统一所有节点的操作逻辑。我们在链表头部添加一个不存储实际数据的节点,这样原头节点就变成了第二个节点,所有节点都有了前驱节点。
2.2 虚拟头节点的实现细节
在初始化链表时,我们创建一个值为0的dummy节点,并让head指针指向它。此时链表的实际结构是:
dummy -> node1 -> node2 -> ... -> nullptr
这样无论是对头节点的操作还是对其他节点的操作,都可以统一使用相同的逻辑:
cpp复制class MyLinkedList {
private:
struct Node {
int val;
Node* next;
Node(int x) : val(x), next(nullptr) {}
};
Node* dummyHead;
int size;
public:
MyLinkedList() {
dummyHead = new Node(0); // 虚拟头节点
size = 0;
}
// ... 其他方法实现
};
3. 链表基本操作的实现与优化
3.1 获取指定位置节点值
get(index)操作需要先检查index的有效性,然后从头节点开始遍历到指定位置。使用虚拟头节点后,我们可以从dummy->next开始遍历,逻辑更加清晰。
cpp复制int get(int index) {
if (index < 0 || index >= size) return -1;
Node* curr = dummyHead->next;
for (int i = 0; i < index; i++) {
curr = curr->next;
}
return curr->val;
}
3.2 头部插入与尾部插入
addAtHead(val)在虚拟头节点后插入新节点即可,时间复杂度O(1)。addAtTail(val)需要先遍历到链表末尾,时间复杂度O(n)。
cpp复制void addAtHead(int val) {
Node* newNode = new Node(val);
newNode->next = dummyHead->next;
dummyHead->next = newNode;
size++;
}
void addAtTail(int val) {
Node* curr = dummyHead;
while (curr->next != nullptr) {
curr = curr->next;
}
curr->next = new Node(val);
size++;
}
3.3 指定位置插入与删除
这两个操作都需要先找到目标位置的前驱节点。使用虚拟头节点后,无论插入/删除的是第一个节点还是其他节点,处理逻辑都完全一致。
cpp复制void addAtIndex(int index, int val) {
if (index > size) return;
if (index < 0) index = 0;
Node* prev = dummyHead;
for (int i = 0; i < index; i++) {
prev = prev->next;
}
Node* newNode = new Node(val);
newNode->next = prev->next;
prev->next = newNode;
size++;
}
void deleteAtIndex(int index) {
if (index < 0 || index >= size) return;
Node* prev = dummyHead;
for (int i = 0; i < index; i++) {
prev = prev->next;
}
Node* toDelete = prev->next;
prev->next = prev->next->next;
delete toDelete;
size--;
}
4. 链表设计中的常见陷阱与优化
4.1 边界条件处理
链表操作中最容易忽略边界条件。在实现时需要考虑:
- index为负数或超过链表长度时的处理
- 空链表时的特殊处理(使用虚拟头节点后可以避免大部分特殊情况)
- 内存泄漏问题(特别是删除节点时)
4.2 时间复杂度优化
虽然链表的基本操作时间复杂度已经比较理想,但我们可以通过维护尾指针来优化addAtTail操作,使其时间复杂度从O(n)降到O(1)。修改后的类定义如下:
cpp复制class MyLinkedList {
private:
struct Node {
int val;
Node* next;
Node(int x) : val(x), next(nullptr) {}
};
Node* dummyHead;
Node* tail; // 新增尾指针
int size;
public:
MyLinkedList() {
dummyHead = new Node(0);
tail = dummyHead; // 初始时尾指针指向dummy
size = 0;
}
// 更新后的addAtTail实现
void addAtTail(int val) {
tail->next = new Node(val);
tail = tail->next;
size++;
}
// 其他方法也需要相应调整...
};
4.3 内存管理最佳实践
在C++实现中,需要特别注意内存管理:
- 析构函数中应该释放所有节点内存
- 删除节点时要先保存next指针再delete
- 可以考虑实现拷贝构造函数和赋值运算符,防止浅拷贝问题
cpp复制~MyLinkedList() {
Node* curr = dummyHead;
while (curr != nullptr) {
Node* temp = curr;
curr = curr->next;
delete temp;
}
}
链表设计是数据结构基础中的重点,掌握好这些核心操作和优化技巧,不仅能够解决LeetCode 707这样的题目,更能为后续学习更复杂的链表应用(如LRU缓存、跳表等)打下坚实基础。在实际面试中,链表相关问题的出现频率非常高,建议反复练习直到能够熟练写出无bug的代码。
