链表操作与反转
一、链表的基本概念
链表是一种重要的线性数据结构,由一系列节点(Node)组成。每个节点通常包含两个主要部分:
- 数据域:存储实际的数据元素
- 指针域:包含指向其他节点的引用(指针)
链表根据指针的方向可以分为:
- 单向链表:每个节点只包含指向下一个节点的指针
- 双向链表:每个节点包含指向前一个和后一个节点的指针
- 循环链表:尾节点指向头节点形成环状结构
与数组在内存中的连续存储不同,链表的节点可以在内存中分散存储,通过指针将这些节点"链接"起来形成逻辑上的线性序列。这种存储方式带来了以下特点:
优势:
- 动态大小:可以方便地插入和删除节点,不需要预先分配固定空间
- 插入/删除效率高:在已知位置操作只需O(1)时间复杂度
- 内存利用率高:不需要连续的存储空间
劣势:
- 随机访问效率低:需要从头遍历,时间复杂度为O(n)
- 额外空间开销:需要存储指针信息
- 缓存不友好:分散存储可能导致缓存命中率低
应用场景:
- 实现栈、队列等抽象数据类型
- 浏览器历史记录(前进/后退)
- 音乐播放列表(上一曲/下一曲)
- 内存管理中的空闲内存块链表
- 哈希表中的冲突解决方法之一
示例操作:
-
插入:在节点A和B之间插入新节点C
- 创建新节点C
- 将C的next指向B
- 将A的next指向C
-
删除:删除节点B
- 找到B的前驱节点A
- 将A的next指向B的next
- 释放B的内存
二、单链表与双链表的结构
1. 单链表
在单链表(Singly Linked List)这种线性数据结构中,每个节点(Node)包含两个基本部分:
- 数据域(Data Field):存储实际的数据元素
- 指针域(Next Pointer):存储指向下一个节点的地址
具体特性如下:
- 单向性:每个节点仅保留对直接后继节点的引用
- 终止条件:尾节点的指针被显式设置为
nullptr
(C++)或NULL
(C),表示链表结束 - 遍历方向:只能从头节点开始顺序向后访问,无法逆向遍历
- 实现示例:
struct ListNode {int val; // 数据域ListNode *next; // 指针域ListNode(int x) : val(x), next(nullptr) {}
};
典型应用场景包括:
- 实现动态大小的栈(Stack)结构
- 操作系统中的进程调度队列
- 多项式代数运算的存储表示
- 浏览器访问历史记录的前进功能
与双链表的对比:
- 内存效率:单链表比双链表节省一个指针的存储空间(通常4/8字节)
- 操作限制:单链表不支持直接删除当前节点(需要知道前驱节点),而双链表可以
- 实现复杂度:单链表的插入/删除操作需要更谨慎的指针维护
注意事项:
- 遍历时需要检查
current->next != nullptr
防止越界 - 所有节点必须保证指针正确终止,否则会导致内存泄漏或无限循环
- 在C++11及以上标准中,推荐使用
nullptr
而非NULL
表示空指针
结构示意图:
head → [val1]→[val2]→[val3]→nullptr
2. 双链表
每个节点包含两个指针域:一个prev
指针指向其前驱节点(前一个节点),一个next
指针指向其后继节点(后一个节点)。这种双向链接的结构使得链表可以从任意节点向前或向后遍历。
具体特性如下:
- 头节点(第一个节点)的
prev
指针始终指向nullptr
,表示链表起始位置没有前驱节点 - 尾节点(最后一个节点)的
next
指针始终指向nullptr
,表示链表末尾没有后继节点 - 中间节点的两个指针都有效:
prev
指向前一个节点,next
指向后一个节点
例如在C++中,双向链表节点的典型定义如下:
struct ListNode {int val; // 节点存储的数据ListNode* prev; // 指向前驱节点的指针ListNode* next; // 指向后继节点的指针
};
这种双向链接结构使得插入和删除操作更加高效:
- 在指定节点前插入新节点时,只需修改相邻节点的4个指针
- 删除节点时,也只需调整相邻节点的2个指针
- 查找时可以双向遍历,在某些场景下能减少遍历时间
应用场景包括:
- 浏览器历史记录(前进/后退功能)
- 音乐播放器的播放列表(上一曲/下一曲)
- 需要频繁插入删除的缓存系统
结构示意图:
nullptr ←[val1]⇄[val2]⇄[val3]→ nullptr
三、基本操作(单链表为例)
1. 对比数组与链表
操作 | 数组(Array) | 链表(Linked List) |
---|---|---|
访问元素 | O(1)(下标直接访问) | O(n)(需从头遍历) |
插入/删除 | O(n)(需移动元素) | O(1)(只需修改指针) |
内存分配 | 连续内存,大小固定 | 分散内存,动态扩容 |
空间效率 | 可能有内存浪费 | 无内存浪费 |
2. 单链表基本操作实现
// 单链表类
class LinkedList {
private:ListNode* head; // 头节点public:LinkedList() : head(nullptr) {}// 1. 尾插法(在链表末尾插入)void push_back(int val) {ListNode* newNode = new ListNode(val);if (!head) { // 链表为空时head = newNode;return;}ListNode* cur = head;while (cur->next) cur = cur->next; // 找到尾节点cur->next = newNode;}// 2. 按值查找ListNode* find(int val) {ListNode* cur = head;while (cur && cur->val != val) {cur = cur->next;}return cur; // 找到返回节点,否则返回nullptr}// 3. 删除指定值的节点void remove(int val) {if (!head) return; // 空链表// 处理头节点是目标的情况if (head->val == val) {ListNode* temp = head;head = head->next;delete temp;return;}// 查找目标节点的前驱ListNode* cur = head;while (cur->next && cur->next->val != val) {cur = cur->next;}if (cur->next) { // 找到目标节点ListNode* temp = cur->next;cur->next = cur->next->next;delete temp;}}// 4. 修改节点值(将旧值改为新值)bool update(int oldVal, int newVal) {ListNode* node = find(oldVal);if (node) {node->val = newVal;return true;}return false;}
};
四、经典应用
1. 链表反转
链表反转的实现步骤与示例
基本概念
链表反转是指将链表的节点顺序完全倒置,例如原链表 1→2→3→null
经过反转后变为 3→2→1→null
。这一操作在数据结构和算法中非常常见,常用于优化某些操作(如从尾部遍历)或解决特定问题(如回文链表判断)。
方法1:迭代法
-
初始化指针
- 定义三个指针:
prev
(指向已反转部分的头节点,初始为null
)、current
(指向当前待反转节点,初始为链表头节点)、next
(临时保存当前节点的下一个节点)。 - 例如,初始时
prev = null
,current
指向节点1
。
- 定义三个指针:
-
遍历链表并反转指针
- 步骤 1:保存
current.next
到next
(防止断链)。- 若当前链表为
1→2→3→null
,则next = 2
。
- 若当前链表为
- 步骤 2:将
current.next
指向prev
,完成当前节点的反转。- 此时
1→null
,prev
更新为1
,current
移动到2
。
- 此时
- 步骤 3:重复上述过程,直到
current
为null
。- 第二次迭代后,链表变为
2→1→null
,prev
更新为2
。 - 第三次迭代后,链表变为
3→2→1→null
,prev
更新为3
。
- 第二次迭代后,链表变为
- 步骤 1:保存
-
终止条件
- 当
current
为null
时,prev
即为新链表的头节点,返回prev
。
- 当
应用场景
- 双向遍历优化:单链表反转后,可模拟从尾部向前遍历。
- 回文判断:通过反转后半部分链表与前半部分比较,判断是否为回文结构。
- LRU缓存淘汰:某些实现中需频繁调整链表顺序。
复杂度分析
- 时间复杂度:O(n),需遍历所有节点。
- 空间复杂度:迭代法为 O(1),递归法为 O(n)(栈空间)。
ListNode* reverseListIterative(ListNode* head) {ListNode* prev = nullptr; // 前驱节点ListNode* cur = head; // 当前节点while (cur) {ListNode* next = cur->next; // 保存后继节点cur->next = prev; // 反转指针prev = cur; // 移动前驱指针cur = next; // 移动当前指针}return prev; // 新的头节点
}
方法2:递归法(时间O(n),空间O(n),递归栈开销)
递归法是一种自上而下的解法,通过不断分解问题为子问题来实现链表的反转。具体步骤如下:
-
递归终止条件:当链表为空或只有一个节点时,直接返回当前节点。因为单节点链表本身就是反转后的结果。
-
递归调用:对于当前节点
head
,先递归处理其后续节点head.next
,这个递归调用会一直深入直到到达链表末尾。 -
节点反转:在递归返回的过程中,将当前节点的下一节点的
next
指针指向当前节点(即head.next.next = head
),实现局部反转。 -
断环处理:将当前节点的
next
指针置为null
,避免产生循环引用。这一步非常重要,否则链表会形成环状结构。 -
返回新头节点:递归的最深层会返回新的头节点(即原链表的尾节点),这个节点会在递归过程中一直传递回最外层。
示例代码:
public ListNode reverseList(ListNode head) {// 终止条件:空链表或单节点链表if (head == null || head.next == null) {return head;}// 递归处理后续节点ListNode newHead = reverseList(head.next);// 反转操作head.next.next = head;head.next = null;// 返回新的头节点return newHead;
}
应用场景:
- 适用于链表长度适中的情况(递归深度可控)
- 在函数式编程或递归思想优先的场景中使用
- 教学演示中常用于展示递归的运作原理
注意事项:
- 对于超长链表可能导致栈溢出(StackOverflowError)
- 递归调用会产生额外的内存开销(每次递归都需要保存现场)
- 在实际工程中,迭代法通常是更优的选择
2. 环检测(判断链表是否有环)
使用快慢指针法(Floyd判圈算法)检测链表中的环:
算法原理:
- 初始化两个指针:
- 慢指针(slow)每次移动1个节点
- 快指针(fast)每次移动2个节点
- 执行遍历:
- 在每次迭代中,快指针会比慢指针多移动一个节点
- 如果链表无环,快指针会先到达链表尾部(NULL节点)
- 环检测:
- 如果链表存在环,快指针最终会从后方追上慢指针
- 此时两指针指向同一个节点,可确认存在环结构
数学证明:
- 设环外长度为L,环长度为C
- 当慢指针进入环时,快指针已在环内移动了L步
- 快指针每次比慢指针多走1步,最多需要C次移动即可追上
应用场景:
- 链表环检测(如Leetcode 141题)
- 寻找环起点(相遇后重置慢指针到head,两指针同速移动)
- 计算环长度(第二次相遇时的移动次数)
算法特性:
- 时间复杂度:O(n)
- 空间复杂度:O(1)
- 优势:无需额外存储空间,仅用两个指针即可完成检测
边界条件处理:
- 空链表直接返回无环
- 单节点链表检查next是否为NULL
- 确保快指针每次移动时检查next和next.next是否存在
bool hasCycle(ListNode *head) {if (!head || !head->next) return false;ListNode* slow = head;ListNode* fast = head->next;while (slow != fast) {if (!fast || !fast->next) return false; // 快指针到达终点,无环slow = slow->next;fast = fast->next->next;}return true; // 相遇,有环
}
五、练习
// 测试代码
#include <iostream>
using namespace std;// 打印链表
void printList(ListNode* head) {ListNode* cur = head;while (cur) {cout << cur->val << "→";cur = cur->next;}cout << "null" << endl;
}int main() {// 构建链表:1→2→3→4→5→nullListNode* head = new ListNode(1);head->next = new ListNode(2);head->next->next = new ListNode(3);head->next->next->next = new ListNode(4);head->next->next->next->next = new ListNode(5);cout << "原链表:";printList(head);// 迭代法反转ListNode* reversed1 = reverseListIterative(head);cout << "迭代反转后:";printList(reversed1);// 递归法反转(再次反转回原顺序)ListNode* reversed2 = reverseListRecursive(reversed1);cout << "递归反转后(恢复原顺序):";printList(reversed2);return 0;
}
输出结果:
原链表:1→2→3→4→5→null
迭代反转后:5→4→3→2→1→null
递归反转后(恢复原顺序):1→2→3→4→5→null