链表五大经典面试题详解:双指针与基础操作实战
🎯 前言:为什么链表面试题如此重要?
链表是数据结构中最重要的基础结构之一,在各大公司的面试中,链表相关题目出现的频率极高!今天我们就来深入剖析五道经典的链表面试题,掌握解决这类问题的核心技巧------双指针、头插尾插、快慢指针。
通过这五道题目,你不仅能学会具体的解题方法,更重要的是掌握链表操作的核心思维!
📋 题目概览
| 题目 | 难度 | 核心技巧 | 链接 |
|---|---|---|---|
| 1. 移除链表元素 | 🟢 简单 | 尾插法、内存管理 | 203. Remove Linked List Elements |
| 2. 反转链表 | 🟢 简单 | 头插法、三指针法 | 206. Reverse Linked List |
| 3. 链表的中间节点 | 🟢 简单 | 快慢指针 | 876. Middle of Linked List |
| 4. 链表中倒数第k个节点 | 🟡 中等 | 快慢指针、边界处理 | 剑指 Offer 22 |
| 5. 合并两个有序链表 | 🟢 简单 | 归并思想、尾插法 | 21. Merge Two Sorted Lists |
🎯 第一题:原地移除链表元素
📝 题目描述
给你一个链表的头节点 head 和一个整数 val,请你删除链表中所有满足 Node.val == val 的节点,并返回新的头节点。
要求:
- 时间复杂度:O(N)
- 空间复杂度:O(1)
💡 核心思路:尾插法构建新链表
使用尾插法创建新链表,只保留值不等于val的节点,同时正确释放要删除节点的内存。
🎨 图解过程
原链表: 1 → 2 → 6 → 3 → 4 → 5 → 6 (val = 6)
新链表构建过程:
1 → 2 → 3 → 4 → 5 → NULL
⚡ 代码实现
c
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* struct ListNode *next;
* };
*/
struct ListNode* removeElements(struct ListNode* head, int val) {
struct ListNode* newHead = NULL, *tail = NULL;
struct ListNode* cur = head;
while(cur) {
if(cur->val != val) {
// 尾插到新链表
if(tail == NULL) {
newHead = tail = cur;
} else {
tail->next = cur;
tail = tail->next;
}
cur = cur->next;
} else {
// 删除当前节点
struct ListNode *next = cur->next;
free(cur);
cur = next;
}
// 确保新链表尾部指向NULL
if(tail) tail->next = NULL;
}
return newHead;
}
🔍 代码解析
- 尾插法构建 :创建
newHead和tail指针构建新链表 - 条件判断 :当前节点值不等于
val时进行尾插 - 内存管理 :遇到目标节点时使用
free释放内存 - 边界处理 :每次循环后确保链表尾部指向
NULL
🎯 第二题:反转单链表
📝 题目描述
给你单链表的头节点 head,请你反转链表,并返回反转后的链表。
💡 核心思路:头插法反转
遍历原链表,逐个将节点插入到新链表的头部,实现反转效果。
🎨 图解过程
原链表: 1 → 2 → 3 → 4 → 5 → NULL
反转过程:
第一步: 1 → NULL
第二步: 2 → 1 → NULL
第三步: 3 → 2 → 1 → NULL
最终: 5 → 4 → 3 → 2 → 1 → NULL
⚡ 代码实现
c
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* struct ListNode *next;
* };
*/
struct ListNode* reverseList(struct ListNode* head) {
struct ListNode* cur = head, *newhead = NULL;
while(cur) {
struct ListNode* next = cur->next;
// 头插操作
cur->next = newhead;
newhead = cur;
cur = next;
}
return newhead;
}
🔍 代码解析
- 三指针操作 :
cur:遍历原链表next:保存下一个节点地址newhead:新链表的头节点
- 头插操作 :当前节点的
next指向新链表头,然后更新链表头 - 迭代前进:移动到下一个待处理节点
🎯 第三题:链表的中间节点
📝 题目描述
给定一个头结点为 head 的非空单链表,返回链表的中间结点。如果有两个中间结点,则返回第二个中间结点。
💡 核心思路:快慢指针法
快指针每次走两步,慢指针每次走一步,当快指针到达末尾时,慢指针正好在中间位置。
🎨 图解过程
链表: 1 → 2 → 3 → 4 → 5
slow: ↑ ↑ ↑
fast: ↑ ↑ ↑
中间节点: 3
链表: 1 → 2 → 3 → 4 → 5 → 6
slow: ↑ ↑ ↑ ↑
fast: ↑ ↑ ↑ ↑
中间节点: 4
⚡ 代码实现
c
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* struct ListNode *next;
* };
*/
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;
}
🔍 代码解析
- 快慢指针初始化:都从链表头开始
- 移动规则 :
- 慢指针
slow:每次移动1步 - 快指针
fast:每次移动2步
- 慢指针
- 循环条件 :
fast && fast->next确保快指针可以安全移动 - 返回值:快指针到末尾时,慢指针即为中间节点
🎯 第四题:链表中倒数第k个节点
📝 题目描述
输入一个链表,输出该链表中倒数第k个结点。
💡 核心思路:快慢指针 + 先后出发
快指针先走k步,然后快慢指针同步移动,当快指针到达末尾时,慢指针即为倒数第k个节点。
🎨 图解过程
链表: 1 → 2 → 3 → 4 → 5, k = 2
步骤1: fast先走2步: fast在3, slow在1
步骤2: 同步移动: fast到5时, slow到4
结果: 节点4
⚡ 代码实现
c
struct ListNode* FindKthToTail(struct ListNode* pListHead, int k) {
struct ListNode* slow = pListHead, *fast = pListHead;
if(pListHead == NULL) {
return NULL;
}
// 快指针先走k步
while(k--) {
if(fast == NULL)
return NULL;
fast = fast->next;
}
// 快慢指针同步移动
while(fast) {
slow = slow->next;
fast = fast->next;
}
return slow;
}
🔍 代码解析
- 边界检查 :链表为空直接返回
NULL - 快指针先行:快指针先移动k步,检查链表长度是否足够
- 同步移动:两个指针以相同速度前进
- 终止条件:快指针到达末尾时,慢指针位置即为所求
🎯 第五题:合并两个有序链表
📝 题目描述
将两个升序链表合并为一个新的升序链表并返回。新链表是通过拼接给定的两个链表的所有节点组成的。
💡 核心思路:归并思想 + 尾插法
比较两个链表当前节点值,取较小的进行尾插,直到某个链表遍历完毕。
🎨 图解过程
链表1: 1 → 3 → 5
链表2: 2 → 4 → 6
合并过程:
1 → 2 → 3 → 4 → 5 → 6
⚡ 代码实现
c
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* struct ListNode *next;
* };
*/
struct ListNode* mergeTwoLists(struct ListNode* list1, struct ListNode* list2) {
if(list1 == NULL) return list2;
if(list2 == NULL) return list1;
struct ListNode *cur1 = list1, *cur2 = list2;
struct ListNode *head = NULL, *tail = NULL;
while (cur1 && cur2) {
if (cur1->val <= cur2->val) {
if (head == NULL) {
head = tail = cur1;
} else {
tail->next = cur1;
tail = tail->next;
}
cur1 = cur1->next;
} else {
if (head == NULL) {
head = tail = cur2;
} else {
tail->next = cur2;
tail = tail->next;
}
cur2 = cur2->next;
}
}
// 连接剩余部分
if(cur1) {
tail->next = cur1;
}
if(cur2) {
tail->next = cur2;
}
return head;
}
🔍 代码解析
- 边界处理:处理任一链表为空的情况
- 归并比较:比较两个链表当前节点,选择较小值
- 尾插构建:维护头尾指针构建新链表
- 剩余处理:将未遍历完的链表剩余部分直接连接
💎 核心技巧总结
| 技巧 | 适用场景 | 关键点 |
|---|---|---|
| 尾插法 | 删除元素、合并链表 | 维护尾指针,确保尾部指向NULL |
| 头插法 | 反转链表 | 当前节点指向新链表头,更新链表头 |
| 快慢指针 | 中间节点、倒数第k个节点 | 快指针速度是慢指针的2倍 |
| 归并思想 | 合并有序链表 | 比较两个链表当前节点,取较小值 |
🚀 进阶思考
- 环形链表检测:如何判断链表是否有环?找到环的入口?
- 复杂链表复制:包含随机指针的链表如何深拷贝?
- 链表排序:如何对链表进行O(nlogn)的排序?
💡 提示 :链表问题的核心在于指针操作和边界情况处理,多加练习才能熟练掌握!
后续逐渐提升难度,先理解单链表推荐文章:
数据结构实战:从顺序表到单链表,手把手实现C语言通讯录