目录
本系列文章仅是 GitHub 大神 @halfrost 的刷题笔记 《LeetCode Cookbook》的提纲以及示例、题集的 C++转化。原书请自行下载学习。
本篇文章涉及新手应该优先刷的几道经典链表算法题,以后会更新“二刷”“三刷”等等。考虑到上篇文章过长,链表专题将分几篇文章完成。
LeetCode #21: Merge Tow Sorted Lists 合并两个有序链表
将两个升序链表合并为一个新的升序链表并返回。新链表是通过拼接给定的两个链表的所有节点组成的。
迭代解法
这是最基本的链表技巧。遍历两条链表,每次循环都要比较一下链表上对应节点的大小,将较小的节点接到结果链上。
值得注意的是,当我们需要创建一条新链表(当然也包括分解链表的情况)的时候,可以使用「虚拟头节点」简化边界情况的处理,也就是 dummy
节点。如果不使用 dummy
节点,我们需要额外处理新链表的指针 p
为空的情况;而有了 dummy
这一占位符,可以避免处理空指针的情况,降低代码复杂性。
class Solution {
public:
ListNode* mergeTwoLists(ListNode* list1, ListNode* list2) {
ListNode* dummy = new ListNode(-1), *p = dummy;
while (list1 != nullptr && list2 != nullptr) {
if (list1->val < list2->val) {
p->next = list1;
list1 = list1->next;
} else {
p->next = list2;
list2 = list2->next;
}
p = p->next;
}
// 合并后 l1 和 l2 最多只有一个还未被合并完,直接将链表末尾指向未合并完的链表
p->next = list1 == nullptr ? list2 : list1;
return dummy->next;
}
};
假设 n
,m
分别为两条链表的长度,则该算法的时间复杂度为
O
(
n
+
m
)
\ O(n+m)
O(n+m) ,空间复杂度为
O
(
1
)
\ O(1)
O(1) 。
递归解法
我们要比较 list1
和 list2
两条链表的节点大小,递归地决定下一个添加到结果链的节点。
class Solution {
public:
ListNode* mergeTwoLists(ListNode* list1, ListNode* list2) {
if (list1 == nullptr) return list2;
else if (list2 == nullptr) return list1;
else if (list1->val < list2->val) {
list1->next = mergeTwoLists(list1->next, list2);
return list1;
} else {
list2->next = mergeTwoLists(list1, list2->next);
return list2;
}
}
};
假设 n
,m
分别为两条链表的长度,则该算法的时间复杂度为
O
(
n
+
m
)
\ O(n+m)
O(n+m) ,递归调用需要消耗栈空间,栈空间大小取决于递归深度,因此空间复杂度为
O
(
n
+
m
)
\ O(n+m)
O(n+m) 。
更多例子
给你一个链表的头节点 head
和一个特定值 x
,请你对链表进行分隔,使得所有小于 x
的节点都出现在大于或等于 x
的节点之前。
你应当保留两个分区中每个节点的初始相对位置。
依旧利用「虚拟头节点」的技巧,我们可以将原链表分解为二,一条链表中的元素大小都小于 x
,另一条则都大于等于 x
,最后再将两条子链表连接在一起,就得到了符合题意的结果链。
这种情况下,我们需要把原链表的的节点连接到新的链表上(并非 new
新节点来组成新链表),断开节点与原链表的连接是必要的,否则结果链将包含一个环,不符合题意。
class Solution {
public:
ListNode* partition(ListNode* head, int x) {
//小于 x 的子链
ListNode* dummy1 = new ListNode(-1), *p1 = dummy1;
//大于等于 x 的子链
ListNode* dummy2 = new ListNode(-1), *p2 = dummy2;
while (head != nullptr) {
if (head->val < x) {
p1->next = head;
p1 = p1->next;
}
else {
p2->next = head;
p2 = p2->next;
}
//断开原链表中每个节点的 next 指针
ListNode* temp = head->next;
head->next = nullptr;
head = temp;
}
//连接两个子链
p1->next = dummy2->next;
return dummy1->next;
}
};
LeetCode #19:Remove Nth Node From End of List 删除链表的倒数第 N 个节点
给你一个链表,删除链表的倒数第 n
个节点,并且返回链表的头节点。
从前往后寻找单链表的第 k
个节点很简单,一个 for
循环遍历过去就找到了,但是如何寻找从后往前数的第 k
个节点呢?
单链表是单向性的,一般只会给出一个 ListNode
头节点代表一整条单链表,最容易想出的解法是先遍历一遍链表求出其长度 n
,然后再遍历一遍链表计算第 n - k + 1
个节点,总共两次遍历。
然而,我们完全可以利用双指针技巧,只需要一次遍历即可达成目的。思路如下:
- 先让一个指针
p1
指向链表的头节点head
,走k
步; - 此时的
p1
只需要再走n - k
步即可走到链表末尾的空指针。我们再用新的指针p2
指向head
; - 让
p1
和p2
同时走,p1
走到链表末尾的空指针时恰好走了n - k
步,而p2
则走了n - k + 1
步,即恰好走到链表的倒数第k
个节点上。
代码实现是这样的:
// 返回链表的倒数第 k 个节点
ListNode* findFromEnd(ListNode* head, int k) {
ListNode* p1 = head;
// p1 先走 k 步
for (int i = 0; i < k; i++) {
p1 = p1 -> next;
}
ListNode* p2 = head;
// p1 和 p2 同时走 n - k 步
while (p1 != nullptr) {
p2 = p2 -> next;
p1 = p1 -> next;
}
// p2 现在指向第 n - k + 1 个节点,即倒数第 k 个节点
return p2;
}
那么,对于 #19 题,我们通过一次遍历就可删除倒数第 n
个节点:
class Solution {
public:
ListNode* removeNthFromEnd(ListNode* head, int n) {
ListNode* dummy = new ListNode(-1);
dummy->next = head;
//删除倒数第 n 个,要先找倒数第 n + 1 个节点
ListNode* x = findFromEnd(dummy, n + 1);
//删掉倒数第 n 个节点
x->next = x->next->next;
return dummy->next;
}
private:
ListNode* findFromEnd(ListNode* head, int k) {
ListNode* p1 = head;
// p1 先走 k 步
for (int i = 0; i < k; i++) {
p1 = p1 -> next;
}
ListNode* p2 = head;
// p1 和 p2 同时走 n - k 步
while (p1 != nullptr) {
p2 = p2 -> next;
p1 = p1 -> next;
}
// p2 现在指向第 n - k + 1 个节点,即倒数第 k 个节点
return p2;
}
};
要删除倒数第 n
个节点,就得获得倒数第 n + 1
个节点的引用,可以用我们实现的 findFromEnd
来操作。
我们又使用了「虚拟头节点」的技巧,也是为了防止出现空指针的情况,比如说链表总共有 5 个节点,删除倒数第 5 个节点,也就是第一个节点,那么就应该首先找到倒数第 6 个节点。但第一个节点前面已经没有节点了,此时就会出错;但有了 dummy
虚拟节点的存在,很好地规避了这一情况的发生。
LeetCode #206:Reverse Linked List 反转链表
迭代解法
这一解法非常容易,只需要在遍历列表的同时,把每个节点的 next
指向它的前驱节点(需要两个指针,p
指向前驱节点,q
指向当前节点),并利用新的指针 temp
来存储当前节点的后继节点,最后两个指针 p
和 q
同时后移……以此类推,实现整条链表的反转。
class Solution {
public:
ListNode* reverseList(ListNode* head) {
ListNode* p = nullptr, *q = head;
while (q) {
ListNode* temp = q->next;
q->next = p;
p = q;
q = temp;
}
return p; // q 指向 nullptr,返回 p 作为新的头指针
}
};
该算法的时间复杂度为 O ( n ) \ O(n) O(n),空间复杂度为 O ( 1 ) \ O(1) O(1) 。
递归解法
链表是一种兼具递归和迭代性质的数据结构,这一算法便是说明递归方法的简洁优美的经典案例。针对 head->next
进行递归,使得当前节点( head->next
)的后继节点指向当前节点(此时当前节点与后继节点互相指向),并断开当前节点指向后继节点的指针。考虑到单链表的末尾为空指针,我们应当加入 base case
,递归到最后一个节点时返回其自身,避免栈溢出。
class Solution {
public:
ListNode* reverseList(ListNode* head) {
if (head == nullptr || head->next == nullptr) return head;
ListNode* last = reverseList(head->next);
head->next->next = head;
head->next = nullptr;
return last;
}
};
更多例子之反转链表前 N 个节点
解决思路与反转整个链表差不多,具体有以下区别:
base case
变为n == 1
,反转一个元素就是它本身,同时要存储后继节点successor
。- 现在
head
节点在递归反转之后不一定是最后一个节点了,所以要记录后继successor
(第n + 1
个节点),反转之后与head
连接。
ListNode* successor = nullptr;
//反转以 head 为起点的 n 个节点,返回新的头节点
ListNode* reverseN(ListNode* head, int n) {
if (n == 1) {
//记录第 n + 1 个节点
successor = head->next;
return head;
}
//以 head->next 为起点,需要反转前 n - 1 个节点
ListNode* last = reverseN(head->next, n - 1);
head->next->next = head;
//让反转之后的 head 节点和后面的节点连起来
head->next = successor;
return last;
}
更多例子之反转链表的一部分
给你单链表的头指针 head
和两个整数 left
和 right
,其中 left <= right
。请你反转从位置 left
到位置 right
的链表节点,返回反转后的链表。
此题相当于给出索引区间(令 head
的索引为 1
)
[
l
e
f
t
,
r
i
g
h
t
]
\ [left, right]
[left,right] ,仅仅反转这一区间内的链表:
- 如果
left == 1
,相当于反转前 N 个元素,与之前的例子reverseN
实现的功能一致。 - 如果
left != 1
,以head->next
的索引为1
,反转区间应当从left - 1
开始,……,直到相对于head->next->...->next
,反转区间从1
开始,此时套用left == 1
的递归模式即可。
class Solution {
public:
ListNode* reverseBetween(ListNode* head, int left, int right) {
// base case
if (left == 1) {
return reverseN(head, right);
}
//前进到反转的起点触发 base case
head->next = reverseBetween(head->next, left - 1, right - 1);
return head;
}
private:
ListNode* successor = nullptr;
//反转以 head 为起点的 n 个节点,返回新的头节点
ListNode* reverseN(ListNode* head, int n) {
if (n == 1) {
//记录第 n + 1 个节点
successor = head->next;
return head;
}
ListNode* last = reverseN(head->next, n - 1);
head->next->next = head;
head->next = successor;
return last;
}
};
LeetCode #25:Reverse Nodes in k-Group - K 个一组翻转链表
给你链表的头节点 head
,每 k
个节点一组进行翻转,请你返回修改后的链表。
k
是一个正整数,它的值小于或等于链表的长度。如果节点总数不是 k
的整数倍,那么请将最后剩余的节点保持原有顺序。
你不能只是单纯的改变节点内部的值,而是需要实际进行节点交换。
这也是链表递归的问题。假设我们有 reverseKGroup
函数来实现如题的功能,我们对这个链表调用 reverseKGroup(head, k)
将前 k
个节点反转,后面的节点也构成一条子链表(子问题);随后把原先的 head
指针移动到后面一段子链表的开头,由于子问题与原问题结构上完全一致,形成递归性质,就可以反复递归调用 reverseKGroup(head, k)
,从而解决问题。因此,大致的算法流程如下:
- 先反转以
head
开头的前k
个元素; - 将第
k + 1
个元素作为head
递归调用reverseKGroup(head, k)
函数; - 将上述两个过程的结果连接起来。
而这道题的 base case
,就是如果最后剩下的元素不足 k
个,则保持不变。每次递归时,我们需要遍历子问题中前 k
个节点(新建指针 temp
),保证有足够的节点来进行反转操作,如果不够则直接返回 head
。进一步地,我们也可以利用先前反转链表前 n
个元素的函数 reverseN
。
class Solution {
public:
ListNode* reverseKGroup(ListNode* head, int k) {
if (head == nullptr) return nullptr;
//遍历前 k 个元素,检查是否有足够节点
ListNode* temp = head;
for (int i = 0; i < k; i++) {
// base case
if (temp == nullptr) return head;
temp = temp->next;
}
//反转前 k 个元素
ListNode* newHead = reverseN(head, k);
//递归反转后续链表并连接起来
head->next = reverseKGroup(temp, k);
return newHead;
}
private:
ListNode* successor = nullptr;
//反转以 head 为起点的 n 个节点,返回新的头节点
ListNode* reverseN(ListNode* head, int n) {
if (n == 1) {
//记录第 n + 1 个节点
successor = head->next;
return head;
}
ListNode* last = reverseN(head->next, n - 1);
head->next->next = head;
head->next = successor;
return last;
}
};