吊打链表系列
PS:由于笔者近期工作较忙,所以更新进度较慢,但是请大家放心,我一定抽空更新,喜欢的还请点个赞、点个关注~
本文默认读者具有基础的链表知识,所以对基础知识部分一笔带过,本文的主要目的是为了以练促学,通过在做题过程中不断巩固知识点。
1 链表简介
链表是一种物理存储单元上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。链表由一系列结点(链表中每一个元素称为结点)组成,结点可以在运行时动态生成。
2 常用技巧
2.1 虚拟头指针
我们有时候需要对链表元素进行增删,而链表的增删操作是通过前驱节点完成的,当我们需要对头节点进行操作时,由于头节点没有前驱,所以需要额外增加处理逻辑。为了统一处理逻辑,我们在头节点之前增加一个虚拟头指针来指向第一个节点,这样,我们处理时便可通过头节点的前驱——虚拟头指针,来完成对头简单的操作。
2.2 穿针引线
2.3 快慢指针
2.4 切割链表
3 以练促学
3.1 反转链表系列
3.1.1 反转链表
题目链接: 206. 反转链表
- 思路:三指针—— curr, prev, next,其中 curr 一直在链表上滑动,在滑动过程中我们改变 curr 的指向,使其指向从后一个节点 (curr.next) 变为前一个节点 prev。注意在改变指向前要先保留相关节点信息,如 next。
- 代码:
class Solution {
public ListNode reverseList(ListNode head) {
if (head == null)
return null;
ListNode curr = head, prev = null, next = null;
while (curr != null) {
next = curr.next;
curr.next = prev;
prev = curr;
curr = next;
}
return prev;
}
}
- 复杂度:时间复杂度 O(N) 空间复杂度 O(1)
3.1.2 反转链表2
题目链接:92. 反转链表 II
与206题相比,本题是在某一个区间段内进行反转,其实 206 题可以看作本题的特例——在 [0, end-1] 范围内反转。
- 思路1:反转指定区间,然后再将反转后的区间与原链表未反转部分进行链接。注意我们都是使用前一个节点来完成链表连接的。
- 代码1
class Solution {
public ListNode reverseBetween(ListNode head, int left, int right) {
if (head == null || left == right)
return head;
if (left > right)
return null;
// 思路:
// 1. 找到 left 的前一个节点 beforeReverse,反转后的最后一个节点(即反转前的第一个节点) lastAfterReverse
// 2. 然后对 [left,right] 范围内反转
// 3. beforeReverse 连接反转后的头,lastAfterReverse 连接后半部分未反转的头(next 指针)
ListNode dummyHead = new ListNode();
dummyHead.next = head;
// 1. 先走 left-1 步,使得 curr 到达 left
ListNode curr = head, prev = dummyHead;
for (int i = 1; i < left; i++) {
prev = prev.next;
curr = curr.next;
}
// beforeReverse 为反转区间的前驱
// lastAfterReverse 为反转后的区间最后一个元素,也即反转前的区间第一个元素
// next 指针,用于记录当前元素的下一个
ListNode beforeReverse = prev, lastAfterReverse = curr, next = null;
prev = null;
for (int i = left; i <= right; i++) {
next = curr.next;
curr.next = prev;
prev = curr;
curr = next;
}
// 将反转后的区间与原链表未反转部分相连接
beforeReverse.next = prev;
lastAfterReverse.next = next;
return dummyHead.next;
}
}
- 思路2: 头插法:定义两个指针 guardian 和 curr ,首先移动 left - 1 步,将 guardian 和 curr 分别移动到第一个要反转的节点前面和第一个要反转的节点上。然后依次删除 curr 后的节点,并将该删除的节点添加到 guardian 和 guardian.next 之间,当删除 right - left 次之后,即完成了指定区间的反转。
- 代码2
class Solution {
public ListNode reverseBetween(ListNode head, int left, int right) {
if (head == null || left == right)
return head;
if (left > right)
return null;
// 思路:头插法
ListNode dummyHead = new ListNode();
dummyHead.next = head;
ListNode guardian = dummyHead, curr = head;
// 循环结束,guardian 来到要反转区域的前驱,curr 为待反转区域的第一个
for (int i = 1; i < left; i++) {
guardian = guardian.next;
curr = curr.next;
}
// 我们将反转区域除第一个节点外的每个节点都删除,并添加到 guardian 和 guardian.next 之间,即可完成反转
for (int i = left; i < right; i++) {
ListNode removed = curr.next;
curr.next = removed.next; // 断开 removed 节点
removed.next = guardian.next;
guardian.next = removed;
}
return dummyHead.next;
}
}
-
思路3:curr 走到待反转的第一个,然后反转 (right - left + 1) 个节点,与普通的反转链表几乎一摸一样,注意亮点:1、反转的次数;2、反转后,要与后面的链表连接起来。
-
代码3:
class Solution { public ListNode reverseBetween(ListNode head, int left, int right) { ListNode dummyHead = new ListNode(0, head); ListNode prev = dummyHead, curr = head; for (int i = 0; i < left - 1; i++) { prev.next = curr; prev = prev.next; curr = curr.next; } prev.next = reverse(curr, right - left + 1); return dummyHead.next; } // 从 head 节点开始,反转 k 个节点 private ListNode reverse(ListNode head, int k) { ListNode prev = null, curr = head, next = null; ListNode last = head; // 反转后的最后一个节点 while (k-- > 0) { next = curr.next; curr.next = prev; prev = curr; curr = next; } // 与后面的链表链接起来 last.next = curr; return prev; } }
3.1.3 两两交换链表中的节点
题目地址:24. 两两交换链表中的节点
- 思路1:递归。默认后面的节点都是已经交换好的,考虑两个节点之间的交换,然后将新的第二个节点与后面交换好的节点进行连接。
- 代码1:
class Solution {
public ListNode swapPairs(ListNode head) {
// 如果为空或者只有一个节点,直接返回
if (head == null || head.next == null) {
return head;
}
// head 为当前节点,next 为下一个待交换的节点,nextStart 为后面节点的开始
ListNode next = head.next, nextStart = head.next.next;
next.next = head;
// 递归返回后面反转后的头节点
head.next = swapPairs(nextStart);
return next;
}
}
- 思路2:K 个一组翻转链表的特殊版本——两个一组翻转
class Solution {
public ListNode swapPairs(ListNode head) {
if (head == null || head.next == null) return head;
int cnt = 0;
ListNode dummyHead = new ListNode(-1, head);
ListNode ll = dummyHead, rr = head;
while (rr != null) {
cnt++;
rr = rr.next;
if (cnt % 2 == 0) {
ll = reverseTwo(ll, rr);
}
}
return dummyHead.next;
}
private ListNode reverseTwo(ListNode ll, ListNode rr) {
ListNode prev = null, curr = ll.next, next = null;
ListNode last = curr; // 反转后的最后一个节点
while (curr != rr) {
next = curr.next;
curr.next = prev;
prev = curr;
curr = next;
}
ll.next = prev;
last.next = rr;
return last;
}
}
3.1.4 K 个一组翻转链表
题目链接:25. K 个一组翻转链表
给你一个链表,每 k 个节点一组进行翻转,请你返回翻转后的链表。
k 是一个正整数,它的值小于或等于链表的长度。
如果节点总数不是 k 的整数倍,那么请将最后剩余的节点保持原有顺序。
进阶:
你可以设计一个只使用常数额外空间的算法来解决此问题吗?
你不能只是单纯的改变节点内部的值,而是需要实际进行节点交换。
示例 1:
输入:head = [1,2,3,4,5], k = 2
输出:[2,1,4,3,5]
示例 2:
输入:head = [1,2,3,4,5], k = 3
输出:[3,2,1,4,5]
示例 3:
输入:head = [1,2,3,4,5], k = 1
输出:[1,2,3,4,5]
示例 4:
输入:head = [1], k = 1
输出:[1]
提示:
列表中节点的数量在范围 sz 内
1 <= sz <= 5000
0 <= Node.val <= 1000
1 <= k <= sz
来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/reverse-nodes-in-k-group
著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。
-
思路:由于进阶要求空间复杂度为 O(1),所以递归的方法就不行了。我们可以考虑利用“哨兵”节点进行 K 个一组的反转:reverse 函数的作用是反转链表中以 left.next 为起始节点的连续 K 个链表节点,并返回反转后的最后一个节点(作为下一段待反转链表段的左哨兵)。其中 left 为待反转的 K 个节点的前一个节点,right 为待反转的 K 个节点的后一个节点。
-
代码:
class Solution { public ListNode reverseKGroup(ListNode head, int k) { if (head == null || head.next == null || k < 2) return head; ListNode dummy = new ListNode(); dummy.next = head; ListNode left = dummy, right = head; int cnt = 0; while (right != null) { cnt++; right = right.next; if (cnt % k == 0) { left = reverse(left, right); } } return dummy.next; } // left 为待反转段的前继节点,right 为待反转段的后继节点 // 反转 [left + 1, right-1] 范围内的节点,并返最后一个节点 private ListNode reverse(ListNode left, ListNode right) { ListNode curr = left.next, prev = null, next = null; ListNode beforeFirst = left, last = curr;// beforeFirst 为待反转的前一个节点,last 为(反转前的第一个)反转后的最后一个 while (curr != right) { next = curr.next; curr.next = prev; prev = curr; curr = next; } // 反转后,前后都重新链接起来 beforeFirst.next = prev; last.next = curr; return last; } }
-
时间复杂度:每个节点最多访问两次(查看是否满足 K 个一组的时候遍历了一次,反转的时候遍历了一次),所以整体时间复杂度为 O(N)
-
空间复杂度:O(1)
3.2 合并链表系列
3.2.3 合并K个升序链表
题目链接:合并K个升序链表
给你一个链表数组,每个链表都已经按升序排列。
请你将所有链表合并到一个升序链表中,返回合并后的链表。
示例 1:
输入:lists = [[1,4,5],[1,3,4],[2,6]]
输出:[1,1,2,3,4,4,5,6]
解释:链表数组如下:
[
1->4->5,
1->3->4,
2->6
]
将它们合并到一个有序链表中得到。
1->1->2->3->4->4->5->6
示例 2:
输入:lists = []
输出:[]
示例 3:
输入:lists = [[]]
输出:[]
提示:
k == lists.length
0 <= k <= 10^4
0 <= lists[i].length <= 500
-10^4 <= lists[i][j] <= 10^4
lists[i] 按 升序 排列
lists[i].length 的总和不超过 10^4
来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/merge-k-sorted-lists
著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。
思路1:优先级队列
-
优先级队列(小根堆)—— 构造一个小根堆,堆顶为值最小的节点;然后将所有链表的非空头节点入堆,每次取堆顶的节点添加到最终返回的链表当中,然后将该堆顶节点的下一个非空节点继续入堆。依次类推,直至堆为空。整体示意图如下图,蓝色的代表堆,堆中存放的是没个链表当前遍历到的节点。
-
代码:
class Solution { public ListNode mergeKLists(ListNode[] lists) { if (lists == null || lists.length == 0) { return null; } ListNode dummyHead = new ListNode(), prev = dummyHead; PriorityQueue<ListNode> pq = new PriorityQueue<>((o1, o2) -> o1.val - o2.val); for (ListNode node : lists) { if (node != null) { pq.offer(node); } } while (!pq.isEmpty()) { ListNode curr = pq.poll(); prev.next = curr; prev = prev.next; if (curr.next != null) { pq.offer(curr.next); } } return dummyHead.next; } }
-
时间复杂度:假设有 K 个链表,每个链表有 N 个节点。建立小根堆的时间复杂度为 O(KlogK),合并链表的过程,每一个节点都会遍历到,对应的时间复杂度为 O(KN),且遍历的时候涉及出入堆的操作,出入堆的时间复杂度为 O(logK),所以整体的时间复杂度为 O(KNlogK)
-
空间复杂度:O(K)
思路2: 分治
其实类似于归并排序,整体思路如下图所示:
- 代码:
class Solution { public ListNode mergeKLists(ListNode[] lists) { // 分治 if (lists == null || lists.length == 0) { return null; } int n = lists.length; return mergeKLists(lists, 0, n - 1); } private ListNode mergeKLists(ListNode[] lists, int left, int right) { if (left < right) { int mid = left + ((right - left) >> 1); ListNode l = mergeKLists(lists, left, mid); ListNode r = mergeKLists(lists, mid + 1, right); return merge2Lists(l, r); } else if (left == right) { return lists[left]; } else { return null; } } private ListNode merge2Lists(ListNode l1, ListNode l2) { if (l1 == null) return l2; if (l2 == null) return l1; ListNode dummyHead = new ListNode(), prev = dummyHead; while (l1 != null && l2 != null) { if (l1.val <= l2.val) { prev.next = l1; l1 = l1.next; } else { prev.next = l2; l2 = l2.next; } prev = prev.next; } if (l1 != null) prev.next = l1; if (l2 != null) prev.next = l2; return dummyHead.next; } }
- 时间复杂度:
3.3 相交/成环系列
3.4 链表排序系列
3.4.1 分隔链表
题目链接:86. 分隔链表
给你一个链表的头节点 head 和一个特定值 x ,请你对链表进行分隔,使得所有 小于 x 的节点都出现在 大于或等于 x 的节点之前。
你应当 保留 两个分区中每个节点的初始相对位置。
示例 1:
输入:head = [1,4,3,2,5,2], x = 3
输出:[1,2,2,4,3,5]
示例 2:
输入:head = [2,1], x = 2
输出:[1,2]
提示:
链表中节点的数目在范围 [0, 200] 内
-100 <= Node.val <= 100
-200 <= x <= 200
来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/partition-list
著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。
-
思路:有点像合并链表的“逆操作”——一个链表拆成两个,小于 x 的链表和大于等于 x 的链表,然后再将大的连在小的后面。注意大小链表均需要虚拟头指针。
-
代码
class Solution { public ListNode partition(ListNode head, int x) { ListNode dummyLess = new ListNode(); ListNode dummyMore = new ListNode(); ListNode less = dummyLess, more = dummyMore; ListNode curr = head; while (curr != null) { if (curr.val < x) { less.next = curr; less = less.next; } else { more.next = curr; more = more.next; } curr = curr.next; } // 注意要断开链表,如例子中的 有 5 后面还跟着 2 more.next = null; less.next = dummyMore.next; return dummyLess.next; } }
3.4.2 重排链表
题目链接: 143. 重排链表
给定一个单链表 L 的头节点 head ,单链表 L 表示为:
L0 → L1 → … → Ln - 1 → Ln
请将其重新排列后变为:
L0 → Ln → L1 → Ln - 1 → L2 → Ln - 2 → …
不能只是单纯的改变节点内部的值,而是需要实际的进行节点交换。
示例 1:
输入:head = [1,2,3,4]
输出:[1,4,2,3]
示例 2:
输入:head = [1,2,3,4,5]
输出:[1,5,2,4,3]
来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/reorder-list
著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。
-
思路:1、从中间断开链表;2、反转右半段;3、将右半段间隔插入左半段
-
代码
class Solution { public void reorderList(ListNode head) { // 1. 找到中点,并断开链表 ListNode mid = getMid(head); ListNode left = head, right = mid.next; mid.next = null; // 断开链表 // 2. 反转右半段 right = reverse(right); // 3. 将反转的右半段间隔插入左半段 merge(left, right); } private ListNode getMid(ListNode head) { if (head == null || head.next == null) return head; ListNode slow = head, fast = head; while (fast != null && fast.next != null) { fast = fast.next.next; if (fast == null) break; slow = slow.next; } return slow; } private ListNode reverse(ListNode head) { if (head == null || head.next == null) return head; ListNode prev = null, curr = head, next = null; while (curr != null) { next = curr.next; curr.next = prev; prev = curr; curr = next; } return prev; } private void merge(ListNode left, ListNode right) { if (left == null || right == null) { return; } while (right != null) { ListNode nextRight = right.next; // 将 right 插入 left right.next = left.next; left.next = right; // 更新 left 和 right 指针 left = right.next; right = nextRight; } } }
3.4.3 排序链表
题目链接:148. 排序链表
给你链表的头结点 head ,请将其按 升序 排列并返回 排序后的链表 。
示例 1:
输入:head = [4,2,1,3]
输出:[1,2,3,4]
示例 2:
输入:head = [-1,5,3,4,0]
输出:[-1,0,3,4,5]
示例 3:
输入:head = []
输出:[]
提示:
链表中节点的数目在范围 [0, 5 * 104] 内
-105 <= Node.val <= 105
进阶:你可以在 O(n log n) 时间复杂度和常数级空间复杂度下,对链表进行排序吗?
来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/sort-list
著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。
- 思路:归并排序,自顶向下(递归实现)的时间复杂度为 O(NlogN) ,满足要求,但是空间复杂度为 O(logN) 不满足要求。所以我们需要用自底向上的归并排序。
- 代码
/** * Definition for singly-linked list. * public class ListNode { * int val; * ListNode next; * ListNode() {} * ListNode(int val) { this.val = val; } * ListNode(int val, ListNode next) { this.val = val; this.next = next; } * } */ class Solution { public ListNode sortList(ListNode head) { if (head == null) { return null; } ListNode curr = head; int length = 0; while (curr != null) { curr = curr.next; length++; } ListNode dummyHead = new ListNode(); dummyHead.next = head; for (int lenSub = 1; lenSub < length; lenSub <<= 1) { ListNode prev = dummyHead; curr = prev.next; while (curr != null) { // 找到第一段要合并的链表并与第二段要合并的链表断开 ListNode head1 = curr; // 第一段的链表头 // curr 走到第一段要合并链表的尾部 for (int i = 1; i < lenSub && curr != null && curr.next != null; i++) { curr = curr.next; } ListNode head2 = curr.next; // 断开第一条链表 curr.next = null; // 找到第二段要合并的链表并与后面下一次合并的链表断开 curr = head2; for (int i = 1; i < lenSub && curr != null && curr.next != null; i++) { curr = curr.next; } ListNode nextStart = null; if (curr != null) { // 如果第二条链表的结尾不是空,记录下一次进行合并的头部 nextStart = curr.next; curr.next = null; // 断开第二段 } // 合并两段 ListNode merged = merge(head1, head2); prev.next = merged; while (prev.next != null) { // 走到已合并链表的最后一个节点,准备连接后面的链表 prev = prev.next; } curr = nextStart; // 继续本轮(长度为 lenSub)后面链表的的排序 } } return dummyHead.next; } private ListNode merge(ListNode l1, ListNode l2) { ListNode dummyHead = new ListNode(), prev = dummyHead; while (l1 != null && l2 != null) { if (l1.val <= l2.val) { prev.next = l1; l1 = l1.next; } else { prev.next = l2; l2 = l2.next; } prev = prev.next; } if (l1 != null) prev.next = l1; if (l2 != null) prev.next = l2; return dummyHead.next; } }
- 复杂度
- 时间复杂度: O(NlogN)
- 空间复杂度: O(1)