链表节点定义(所有题目通用)
cpp
#include <stdio.h>
#include <stdlib.h>
// 定义链表节点
struct ListNode {
int val;
struct ListNode *next;
};
1. 删除链表中等于给定值 val 的所有结点
题目描述
删除链表中所有值为 val 的节点,返回修改后的链表头。
核心思路
• 虚拟头节点法:创建一个虚拟头节点 dummy,让它的 next 指向原链表头。这样可以统一处理头节点本身就是待删除节点的情况。
• 遍历链表,当发现当前节点的下一个节点值为 val 时,直接调整指针跳过该节点,并释放内存。
复杂度分析
• 时间复杂度:O(n),需要遍历整个链表一次。
• 空间复杂度:O(1),只使用了常数级的额外空间。
cpp
struct ListNode* removeElements(struct ListNode* head, int val) {
// 创建虚拟头节点,避免单独处理头节点被删除的情况
struct ListNode* dummy = (struct ListNode*)malloc(sizeof(struct ListNode));
dummy->next = head; // 虚拟头节点指向原链表头
struct ListNode* cur = dummy; // 用cur指针遍历链表
// 遍历链表,直到cur的下一个节点为空
while (cur->next != NULL) {
// 如果下一个节点的值等于val
if (cur->next->val == val) {
struct ListNode* temp = cur->next; // 保存待删除节点
cur->next = cur->next->next; // 跳过待删除节点
free(temp); // 释放待删除节点的内存
} else {
cur = cur->next; // 否则,cur指针后移
}
}
head = dummy->next; // 新的头节点是虚拟头节点的下一个节点
free(dummy); // 释放虚拟头节点的内存
return head; // 返回新的头节点
}
2. 反转一个单链表
题目描述
反转单链表,返回新的链表头。
核心思路
• 双指针迭代法:用 pre 保存前一个节点,cur 指向当前节点。每次循环中,先保存 cur->next,再将 cur->next 指向 pre,然后更新 pre 和 cur。
• 也可以用递归法,递归到链表末尾后反向调整指针。
复杂度分析
• 时间复杂度:O(n),每个节点被访问一次。
• 空间复杂度:O(1)(迭代法),递归法为 O(n)(递归栈深度)。
cpp
// 反转单链表,返回反转后的新头节点
struct ListNode* reverseList(struct ListNode* head) {
struct ListNode* pre = NULL; // 前驱节点:记录cur的前一个节点,初始为NULL(原链表头的前驱是空)
struct ListNode* cur = head; // 当前节点:从原链表头开始遍历
// 遍历链表,cur走到NULL时结束(所有节点都完成反转)
while (cur != NULL) {
// 【关键】先保存cur的下一个节点,否则反转后会丢失原链表的后续节点
struct ListNode* next = cur->next;
cur->next = pre; // 反转cur的next指针,指向pre(完成当前节点的反转)
pre = cur; // pre向后移动,更新为当前的cur(为下一个节点反转做准备)
cur = next; // cur向后移动,更新为之前保存的next(继续遍历下一个节点)
}
return pre; // 遍历结束,cur为NULL,pre停在原链表最后一个节点,即反转后新链表的头节点
}
核心步骤图解(以链表 1->2->3->NULL 为例)
-
初始状态:pre=NULL,cur=1,next未定义
-
第一次循环:
next=2 → 1->next=NULL → pre=1 → cur=2
链表变为:NULL<-1 2->3->NULL
- 第二次循环:
next=3 → 2->next=1 → pre=2 → cur=3
链表变为:NULL<-1<-2 3->NULL
- 第三次循环:
next=NULL → 3->next=2 → pre=3 → cur=NULL
链表变为:NULL<-1<-2<-3
- 循环结束:返回pre=3,即新头节点,反转完成。
代码关键避坑点
-
必须先保存next:如果先执行cur->next=pre,会直接丢失原cur->next的地址,无法继续遍历后续节点,这是新手最容易犯的错;
-
pre初始化为NULL:原链表的头节点反转后是尾节点,尾节点的next必须为NULL,符合链表规范;
-
返回pre而非cur:循环结束时cur=NULL,pre才是反转后新链表的有效头节点。
极简版写法
保留核心逻辑,面试时快速手写,编译器可正常运行:
cpp
struct ListNode* reverseList(struct ListNode* head) {
struct ListNode *pre = NULL, *cur = head, *next;
while (cur) {
next = cur->next;
cur->next = pre;
pre = cur;
cur = next;
}
return pre;
}
3. 返回链表的中间结点
题目描述
给定非空单链表,返回中间节点。如果有两个中间节点,返回第二个。
核心思路
• 快慢指针法:快指针 fast 每次走2步,慢指针 slow 每次走1步。当快指针走到链表末尾时,慢指针正好指向中间节点。
• 例如链表长度为偶数时,快指针会停在最后一个节点的 next(即 NULL),此时慢指针指向第二个中间节点。
复杂度分析
• 时间复杂度:O(n),快指针最多走 n/2 步,慢指针同步移动。
• 空间复杂度:O(1)。
cpp
struct ListNode* middleNode(struct ListNode* head) {
struct ListNode* slow = head; // 慢指针,每次走1步
struct ListNode* fast = head; // 快指针,每次走2步
// 快指针没走到末尾时继续循环
while (fast != NULL && fast->next != NULL) {
slow = slow->next; // 慢指针走1步
fast = fast->next->next; // 快指针走2步
}
return slow; // 快指针到末尾时,慢指针指向中间节点
}
4. 输出链表中倒数第 k 个结点
题目描述
输入一个链表,输出该链表中倒数第 k 个节点。
核心思路
• 快慢指针法:
-
先让快指针 fast 向前走 k 步。
-
然后快慢指针同时向前移动,直到快指针走到链表末尾。
-
此时慢指针 slow 指向的就是倒数第 k 个节点。
复杂度分析
• 时间复杂度:O(n),最多遍历链表两次。
• 空间复杂度:O(1)。
cpp
struct ListNode* getKthFromEnd(struct ListNode* head, int k) {
struct ListNode* fast = head; // 快指针
struct ListNode* slow = head; // 慢指针
// 先让快指针走k步
for (int i = 0; i < k; i++) {
fast = fast->next;
}
// 快慢指针同时走,直到快指针到末尾
while (fast != NULL) {
fast = fast->next;
slow = slow->next;
}
return slow; // 此时慢指针指向倒数第k个节点
}
5. 合并两个有序链表
题目描述
将两个升序链表合并为一个新的升序链表并返回。新链表是通过拼接给定的两个链表的所有节点组成的。
核心思路
• 虚拟头节点法:创建一个虚拟头节点 dummy,用 cur 指针遍历两个输入链表,每次选择较小的节点加入新链表。
• 当其中一个链表遍历完后,直接将另一个链表的剩余部分接在新链表末尾。
复杂度分析
• 时间复杂度:O(m+n),其中 m 和 n 是两个链表的长度。
• 空间复杂度:O(1),只使用了常数级的额外空间。
cpp
struct ListNode* mergeTwoLists(struct ListNode* list1, struct ListNode* list2) {
// 创建虚拟头节点,方便统一处理新链表的插入
struct ListNode* dummy = (struct ListNode*)malloc(sizeof(struct ListNode));
struct ListNode* cur = dummy; // cur指针用于构建新链表
// 当两个链表都不为空时
while (list1 != NULL && list2 != NULL) {
// 选择值较小的节点加入新链表
if (list1->val < list2->val) {
cur->next = list1;
list1 = list1->next;
} else {
cur->next = list2;
list2 = list2->next;
}
cur = cur->next; // cur指针后移
}
// 把剩下的节点直接接到新链表末尾
cur->next = list1 != NULL ? list1 : list2;
struct ListNode* res = dummy->next; // 新链表的头节点
free(dummy); // 释放虚拟头节点
return res;
}
6. 以给定值 x 为基准分割链表
题目描述
编写代码,以给定值 x 为基准将链表分割成两部分,所有小于 x 的节点排在大于或等于 x 的节点之前。
核心思路
• 拆分拼接法:
-
创建两个虚拟头节点 dummy1(存储小于 x 的节点)和 dummy2(存储大于等于 x 的节点)。
-
遍历原链表,将节点分别插入到两个虚拟链表中。
-
最后将 dummy2 的链表接在 dummy1 的链表末尾,并将 dummy2 链表的尾节点 next 置为 NULL(避免成环)。
复杂度分析
• 时间复杂度:O(n),遍历一次原链表。
• 空间复杂度:O(1),只使用了常数级的额外空间。
cpp
struct ListNode* partition(struct ListNode* head, int x) {
// 创建两个虚拟头节点,分别存储小于x和大于等于x的节点
struct ListNode* dummy1 = (struct ListNode*)malloc(sizeof(struct ListNode));
struct ListNode* dummy2 = (struct ListNode*)malloc(sizeof(struct ListNode));
struct ListNode* cur1 = dummy1; // 指向小于x的链表
struct ListNode* cur2 = dummy2; // 指向大于等于x的链表
// 遍历原链表
while (head != NULL) {
if (head->val < x) {
cur1->next = head; // 加入小于x的链表
cur1 = cur1->next;
} else {
cur2->next = head; // 加入大于等于x的链表
cur2 = cur2->next;
}
head = head->next; // 原链表指针后移
}
cur2->next = NULL; // 防止链表成环
cur1->next = dummy2->next; // 拼接两个链表
struct ListNode* res = dummy1->next; // 新链表的头节点
free(dummy1); // 释放虚拟头节点
free(dummy2);
return res;
}
7. 链表的回文结构
题目描述
判断一个链表是否为回文链表。
核心思路
• 三步法:
-
找中间节点:用快慢指针找到链表的中间节点。
-
反转后半部分:反转中间节点之后的链表。
-
比较前后两部分:同时遍历前半部分和反转后的后半部分,比较节点值是否相同。
-
(可选)恢复链表:如果题目要求不修改原链表,可再次反转后半部分并拼接回原链表。
复杂度分析
• 时间复杂度:O(n),找中间节点、反转、比较各需 O(n) 时间。
• 空间复杂度:O(1),只使用了常数级的额外空间。
cpp
bool isPalindrome(struct ListNode* head) {
// 空链表或只有一个节点时,直接是回文
if (head == NULL || head->next == NULL) return true;
// 1. 快慢指针找中间节点
struct ListNode* slow = head;
struct ListNode* fast = head;
while (fast->next != NULL && fast->next->next != NULL) {
slow = slow->next; // 慢指针走1步
fast = fast->next->next; // 快指针走2步
}
// 2. 反转后半部分链表
struct ListNode* pre = NULL;
struct ListNode* cur = slow->next;
while (cur != NULL) {
struct ListNode* next = cur->next;
cur->next = pre;
pre = cur;
cur = next;
}
// 3. 比较前后两部分
struct ListNode* p1 = head;
struct ListNode* p2 = pre;
bool res = true;
while (res && p2 != NULL) {
if (p1->val != p2->val) res = false;
p1 = p1->next;
p2 = p2->next;
}
// 4. 恢复原链表(可选,面试中如果不要求可以省略)
cur = pre;
pre = NULL;
while (cur != NULL) {
struct ListNode* next = cur->next;
cur->next = pre;
pre = cur;
cur = next;
}
slow->next = pre;
return res;
}
8. 找出两个链表的第一个公共结点
题目描述
输入两个链表,找出它们的第一个公共节点。
核心思路
• 双指针法:
-
两个指针 pA 和 pB 分别从两个链表头出发。
-
当 pA 走到链表末尾时,将其指向另一个链表的头节点;同理 pB 走到末尾时也指向另一个链表的头节点。
-
两个指针会在第一个公共节点处相遇(如果存在公共节点),或者同时走到 NULL(如果没有公共节点)。
复杂度分析
• 时间复杂度:O(m+n),其中 m 和 n 是两个链表的长度。
• 空间复杂度:O(1)。
cpp
struct ListNode *getIntersectionNode(struct ListNode *headA, struct ListNode *headB) {
struct ListNode* pA = headA;
struct ListNode* pB = headB;
// 当两个指针不相等时继续循环
while (pA != pB) {
// 如果pA走到末尾,就跳到另一个链表的头
pA = pA != NULL ? pA->next : headB;
// 如果pB走到末尾,就跳到另一个链表的头
pB = pB != NULL ? pB->next : headA;
}
// 相遇时就是第一个公共节点,或者都为NULL(没有公共节点)
return pA;
}
方法二核心思路总结
-
判断是否有公共节点:两个链表如果有公共节点,它们的尾节点一定是同一个,所以先遍历到尾节点进行判断。
-
消除长度差:计算两个链表的长度差,让长链表的指针先移动 gap 步,使两个指针从相同长度的位置开始遍历。
-
同步遍历找交点:两个指针同时移动,第一次相遇的节点就是第一个公共节点。
cpp
// 寻找两个链表的第一个公共节点
struct ListNode *getIntersectionNode(struct ListNode *headA, struct ListNode *headB) {
// 定义两个指针,分别指向两个链表的头节点
struct ListNode* curA = headA, *curB = headB;
// 初始化两个链表的长度,初始为1是因为cur已经指向了头节点
int lena = 1, lenb = 1;
// 遍历链表A,计算链表A的长度
while (curA->next) {
curA = curA->next;
lena++;
}
// 遍历链表B,计算链表B的长度
while (curB->next) {
curB = curB->next;
lenb++;
}
// 如果两个链表的尾节点不相同,说明没有公共节点,直接返回NULL
if (curA != curB) return NULL;
// 计算两个链表的长度差
int gap = abs(lena - lenb);
// 先默认链表A是长链表,链表B是短链表
struct ListNode* longlist = headA, *shortlist = headB;
// 如果链表B更长,就交换longlist和shortlist的指向
if (lenb > lena) {
longlist = headB;
shortlist = headA;
}
// 让长链表的指针先移动gap步,使两个指针处于"同一起跑线"
while (gap--) {
longlist = longlist->next;
}
// 两个指针同时向后移动,直到相遇,相遇点就是第一个公共节点
while (longlist != shortlist) {
longlist = longlist->next;
shortlist = shortlist->next;
}
// 返回相遇的节点(也可以返回longlist)
return shortlist;
}
9. 带随机指针链表的深度拷贝
题目描述
复制一个包含next指针和random随机指针的链表,新链表是完全独立的深度拷贝,新节点的next和random都要指向新链表的对应节点,而非原链表节点,且不能修改原链表结构。
核心思路
-
第一次遍历:创建原链表每个节点的拷贝节点,用哈希表(数组模拟,C语言无原生哈希表) 建立原节点→拷贝节点的映射关系,同时完成拷贝节点val的赋值和next的初步连接。
-
第二次遍历:通过哈希表的映射,给拷贝节点的random指针赋值(原节点的random指向A,拷贝节点的random就指向A的拷贝节点)。
-
最终返回拷贝链表的头节点,实现完全独立的深度拷贝。
cpp
// 深度拷贝带随机指针的链表:原地拼接+拆分法(空间复杂度O(1),时间O(n))
struct Node* copyRandomList(struct Node* head) {
// 定义遍历指针cur,初始指向原链表头节点
struct Node* cur = head;
// 第一步:拷贝每个原节点,将拷贝节点插入到原节点的正后方
while (cur) {
// 为拷贝节点分配内存,创建新节点
struct Node* copy = (struct Node*)malloc(sizeof(struct Node));
copy->val = cur->val; // 拷贝原节点的值
copy->next = cur->next; // 拷贝节点next先指向原节点的下一个节点
cur->next = copy; // 原节点next指向拷贝节点,完成插入
cur = copy->next; // 跳过拷贝节点,cur移到下一个原节点
}
// 第二步:为拷贝节点的random指针赋值,利用拼接结构直接映射
cur = head; // 重置cur到原链表头,重新遍历
while (cur) {
struct Node* copy = cur->next; // copy指向当前原节点的拷贝节点
if (cur->random == NULL) { // 原节点random为NULL
copy->random = NULL; // 拷贝节点random也置为NULL
} else {
// 核心:原节点random的下一个节点,就是其拷贝节点
copy->random = cur->random->next;
}
cur = copy->next; // 跳过拷贝节点,移到下一个原节点
}
// 第三步:拆分链表------将拷贝节点单独拆出成新链表,同时恢复原链表结构
struct Node* copyhead = NULL, *copytail = NULL; // 新链表的头/尾指针(尾插法)
cur = head; // 重置cur到原链表头
while (cur) {
struct Node* copy = cur->next; // copy指向当前原节点的拷贝节点
struct Node* next = copy->next; // next保存原链表的下一个原节点(防止丢失)
// 尾插法构建拷贝节点的新链表
if (copytail == NULL) { // 新链表为空时,初始化头/尾指针
copyhead = copytail = copy;
} else { // 新链表非空时,尾插并更新尾指针
copytail->next = copy;
copytail = copytail->next;
}
cur->next = next; // 恢复原节点的next指针,指向原本的下一个原节点
cur = next; // cur移到下一个原节点,继续拆分
}
// 返回拷贝链表的头节点,完成深度拷贝
return copyhead;
}
进阶优化思路(原地拼接法,空间复杂度O(1))
如果要求空间复杂度为O(1)(不使用哈希表),可以用原地拼接+拆分法,核心步骤:
-
遍历原链表,在每个原节点后插入其拷贝节点(原1→拷贝1→原2→拷贝2→...)。
-
赋值拷贝节点的random:拷贝节点->random = 原节点->random->next(原节点random的下一个就是其拷贝)。
-
拆分链表:将原节点和拷贝节点分离,恢复原链表,同时形成独立的拷贝链表。
关键注意点
-
深度拷贝必须为新节点分配独立内存,不能直接赋值指针(否则只是浅拷贝,指向原链表节点)。
-
处理random指针时,一定要先判断原节点random是否为NULL,避免空指针访问崩溃。
-
原地拼接法最后必须拆分并恢复原链表,这是面试的隐含要求(不能修改输入原链表)。
原地拼接法带注释代码
cpp
struct Node* copyRandomList(struct Node* head) {
if (head == NULL) return NULL;
// 步骤1:原地拼接,原节点后插入拷贝节点
struct Node* cur = head;
while (cur != NULL) {
struct Node* copy = (struct Node*)malloc(sizeof(struct Node));
copy->val = cur->val;
copy->next = cur->next; // 拷贝节点next指向原节点的下一个
cur->next = copy; // 原节点next指向拷贝节点
cur = copy->next; // 跳过拷贝节点,遍历下一个原节点
}
// 步骤2:赋值拷贝节点的random指针
cur = head;
while (cur != NULL) {
struct Node* copy = cur->next;
// 原节点random不为NULL时,拷贝节点random指向原random的拷贝
copy->random = cur->random == NULL ? NULL : cur->random->next;
cur = copy->next; // 跳过拷贝节点
}
// 步骤3:拆分链表,恢复原链表,生成拷贝链表
cur = head;
struct Node* newHead = head->next; // 拷贝链表头为第一个拷贝节点
while (cur != NULL) {
struct Node* copy = cur->next;
cur->next = copy->next; // 原节点next恢复为原下一个节点
// 拷贝节点next:如果原节点下一个不为NULL,指向其拷贝,否则为NULL
copy->next = cur->next == NULL ? NULL : cur->next->next;
cur = cur->next; // 遍历原链表下一个节点
}
return newHead;
}
10.判断链表是否有环
核心解法:快慢指针法(Floyd判圈算法)
空间复杂度O(1)(仅用两个指针)、时间复杂度O(n),面试最优解,核心逻辑:慢指针每次走1步,快指针每次走2步,若链表有环,快指针必会在环内追上慢指针;若无环,快指针会先走到NULL。
代码避坑关键细节
-
循环条件:必须同时判断fast != NULL && fast->next != NULL,否则快指针会出现fast->next->next空指针访问崩溃;
-
指针初始化:快慢指针都从head开始,不要让fast初始为head->next,会导致相遇判断出错;
-
边界处理:直接过滤空链表和单节点链表,这类情况不可能有环。
cpp
// 判断链表是否有环,有环返回1,无环返回0
int hasCycle(struct ListNode *head) {
// 边界条件:空链表/只有一个节点,直接无环
if (head == NULL || head->next == NULL) {
return 0;
}
// 初始化快慢指针,均从表头出发(避免初始相遇逻辑混乱)
struct ListNode* slow = head;
struct ListNode* fast = head;
// 循环条件:fast和fast->next非空,防止空指针解引用
while (fast != NULL && fast->next != NULL) {
slow = slow->next; // 慢指针走1步
fast = fast->next->next; // 快指针走2步
if (slow == fast) { // 快慢指针相遇,说明有环
return 1;
}
}
// 循环结束未相遇,快指针走到链表末尾,无环
return 0;
}
11. 寻找链表环的入口节点
题目描述
判断单链表是否有环,若有环返回入环的第一个节点;若无环返回NULL,要求空间复杂度O(1)(仅用指针,不借助哈希表),这是面试的核心考点。
核心思路
- 先判断链表是否有环
• 慢指针slow每次走1步,快指针fast每次走2步,同时从表头出发。
• 若链表无环:快指针会先走到NULL,直接返回NULL。
• 若链表有环:快慢指针一定会在环内相遇(快指针绕环追上慢指针)。
- 再找环的入口节点(核心推导)
• 相遇后,将其中一个指针重置到表头,另一个指针留在相遇点。
• 两个指针同时以1步/次的速度前进,第一次相遇的节点就是环的入口节点。
cpp
// 寻找链表环的入口节点,无环返回NULL
struct ListNode *detectCycle(struct ListNode *head) {
// 边界条件:空链表或只有一个节点,无环直接返回NULL
if (head == NULL || head->next == NULL) {
return NULL;
}
// 初始化快慢指针,均从表头出发(避免初始相遇问题)
struct ListNode* slow = head;
struct ListNode* fast = head;
// 第一步:快慢指针遍历,判断是否有环
// 循环条件:fast和fast->next非空(防止fast->next->next空指针访问)
while (fast != NULL && fast->next != NULL) {
slow = slow->next; // 慢指针走1步
fast = fast->next->next; // 快指针走2步
// 快慢指针相遇,说明有环,跳出循环找入口
if (slow == fast) {
break;
}
}
// 循环结束后,若快慢指针未相遇,说明无环(fast走到链表末尾)
if (slow != fast) {
return NULL;
}
// 第二步:找环的入口------将慢指针重置到表头,快慢指针均走1步
slow = head;
while (slow != fast) {
slow = slow->next;
fast = fast->next;
}
// 相遇点即为环的入口节点,返回即可
return slow;
}
关键推导
设:表头到环入口的距离为a,环入口到相遇点的距离为b,相遇点绕环回到入口的距离为c,环总长度L = b + c。
• 相遇时,慢指针走了 a + b 步,快指针走了 a + b + n*L 步(n为快指针绕环的圈数)。
• 因快指针速度是慢指针2倍,故 2*(a + b) = a + b + n*L → 化简得 a = n*L - b → a = (n-1)*L + c。
• 含义:表头到入口的距离a = 相遇点绕环走(n-1)圈再走c步的距离,因此两指针同速前进必会在入口相遇。
代码关键细节
-
循环条件:必须判断fast != NULL && fast->next != NULL,否则快指针会出现fast->next->next空指针解引用崩溃。
-
指针初始化:快慢指针均从head出发,而非slow=head、fast=head->next(会导致相遇判断逻辑混乱)。
-
无环判断:循环结束后必须校验slow == fast,否则快指针走到末尾时直接返回NULL。