本文介绍了双指针技巧在链表、数组以及字符串中的使用,给出了大量大厂常见面试手撕题目的思路及代码,不仅适合完全不了解双指针技巧的读者,也适合老司机复习拓展。
考察过该技巧的公司有阿里巴巴、腾讯、美团、拼多多、百度等大厂。
我相信,友好的讨论交流会让彼此快速进步!文章难免有疏漏之处,十分欢迎大家在评论区中批评指正。
食用指南: ⭐️为一刷必做题,便于快速理解双指针技巧;无星题目可在刷完⭐️题目后再做,用于拓展学习双指针技巧如何与其他算法知识结合使用。
双指针技巧是什么?
双指针技巧指的就是在遍历链表、数组或者字符串的过程中,不是使用一个指针来进行访问,而是使用两个指针(甚至可以多个,合并 k 个有序链表中将会用到)进行遍历访问。
以链表为例,两个指针要么同方向访问两个链表、要么同方向访问一个链表(快慢指针)、要么相反方向遍历一个链表(左右指针),数组和字符串也是如此。
下面,先来看看双指针技巧在链表问题中的应用。我们以题目入手,从易到难,逐步感受双指针技巧的精妙之处。
几乎所有题目都提供了使用双指针技巧的思路,以及动画示意图,希望可以通过这一篇文章让你彻底弄懂双指针技巧。
双指针秒杀链表问题
⭐️合并两个有序链表(力扣21)
题目描述
思路(双指针)
题目中两个链表已经排好序,我们可以使用两个指针同向访问两个链表,每次比较两个指针的元素,谁小谁加到新的链表上。
参考代码
public ListNode mergeTwoLists(ListNode list1, ListNode list2) {
// 一条链表为空,必然返回另一条链表,即使另一条链表也为空
if (list1 == null) {
return list2;
}
if (list2 == null) {
return list1;
}
// 新建一个虚拟头节点(新链表),后面连接两条链表排序后的节点
ListNode dummy = new ListNode(-1);
// 三个指针分别指向虚拟头节点,list1,list2
ListNode p = dummy, p1 = list1, p2 = list2;
// 当两条链表都不为空的时候遍历
while (p1 != null && p2 != null) {
// 小的节点加在新链表后面
if (p1.val <= p2.val) {
p.next = p1;
// 只移动取值的指针
p1 = p1.next;
// 新链表指针也要移动
p = p.next;
} else {
p.next = p2;
// 只移动取值的指针
p2 = p2.next;
// 新链表指针也要移动
p = p.next;
}
}
// 哪条链表有剩余(剩余的值一定比前面都大),就直接连在新链表后面。
if (p1 != null) {
p.next = p1;
}
if (p2 != null) {
p.next = p2;
}
// 返回时去掉虚拟头结点
return dummy.next;
}
Tips
虚拟头节点,即代码中的
dummy
节点。使用虚拟头节点可以避免处理指针p
为空的情况,降低代码的复杂性。当我们需要创造一条新链表的时候,可以使用虚拟头节点简化边界情况的处理。 比如本题中两条链表要合并成一条新的有序链表,这时需要创造一条新链表就可以使用。再比如要将一条链表分解成两条链表,这时候要创建两条新链表,也可以使用。
时间复杂度
O ( n ) O(n) O(n)
最坏情况下,遍历两个链表一共 2 * N 个节点
空间复杂度
O ( 1 ) O(1) O(1)
无额外辅助空间,返回的链表是原有节点组装,是必要空间。
⭐️分隔链表(力扣86)
题目描述
思路(双指针)
在上一题中,合并两个有序链表,是将两个有序链表合二为一。而本题是将原链表一分为二,然后再合并成一条链表。具体来说,就是将原链表分成两条小链表,一条链表的元素都小于 x,另一条链表中的元素都大于等于 x,最后将两条链表再拼接到一起就可以了。两条小链表的构建过程就需要用到**「双指针」**技巧。
参考代码
public ListNode partition(ListNode head, int x) {
if (head == null) {
return head;
}
// 存放小于 x 的链表的虚拟头节点
ListNode dummyLess = new ListNode(-1);
// 存放大于等于 x 的链表的虚拟头节点
ListNode dummyMore = new ListNode(-2);
// p 指针负责遍历原链表,pLess 负责生成小于 x 的结果链表,pMore 负责生成大于等于 x 的结果链表
ListNode p = head, pLess = dummyLess, pMore = dummyMore;
while (p != null) { // 原链表不为空
if (p.val < x) {
pLess.next = p;
pLess = pLess.next;
} else {
pMore.next = p;
pMore = pMore.next;
}
// !!! 如果不断开,会成环报错,新手务必注意!
// 断开原链表中的每个节点的 next 指针
ListNode nxt = p.next; // 记录原链表中当前节点的下个节点
p.next = null; // 当前节点的 next 指针设为空
p = nxt;
}
// 小 x 链表连接大 x 链表
pLess.next = dummyMore.next;
// 返回时去掉虚拟头结点
return dummyLess.next;
}
Tips
在循环代码块中,必须断开原链表中每个节点的 next 指针,即将原链表节点的 next 指针设为 null,然后才能向后移动指针,否则就会连接成环从而导致答案错误。
时间复杂度
O ( n ) O(n) O(n)
最坏情况下,需要完整遍历一遍原链表。
空间复杂度
O ( 1 ) O(1) O(1)
使用了常数个指针,没有额外辅助空间。
合并 K 个升序链表(力扣23)
题目描述
思路(k个指针)
我们可以准备 k 个指针,分别放在 k 个链表头,每次取出最小的元素,加入到新的大链表中,指针后移,继续比较,每次出去的都是最小元素,就完成了题目的要求。
在 Java 中,我们可以借助优先级队列 PriorityQueue 来实现。优先级队列本质上是一种堆排序容器,根据传入的比较器确定是大根堆还是小根堆,默认是小根堆。小根堆的堆顶是容器中最小的元素,其余更大的元素在堆下方。想要了解这种结构,可以访问 Data Structure Visualizations 网站去查看小根堆的排序过程。
参考代码
public ListNode mergeKLists(ListNode[] lists) {
// 判空
if (lists == null || lists.length == 0) {
return null;
}
// 优先级队列,小根堆
PriorityQueue<ListNode> pq = new PriorityQueue<>((a, b) -> a.val - b.val);
// 将 k 个链表的头节点加入小根堆
for (ListNode head : lists) {
// 头节点不为空才能加入
if (head != null)
pq.add(head);
}
// 要新建一个结果链表,所以新建一个虚拟头节点,指针 p 用于构建结果链表
ListNode dummy = new ListNode(-1), p = dummy;
while (!pq.isEmpty()) {
// 取出最小元素的节点
ListNode cur = pq.poll();
// 连接到结果链表上
p.next = cur;
p = p.next;
// 如果被取出的节点后续有节点,加入到小根堆中
if (cur.next != null) {
pq.add(cur.next);
}
}
// 返回时去掉虚拟头节点
return dummy.next;
}
时间复杂度
O ( n l o g 2 k ) O(nlog_2k) O(nlog2k)
n 为所有链表总节点数,最坏要遍历所有节点,每次加入优先级队列排序需要 O ( l o g 2 k ) O(log_2k) O(log2k)。
空间复杂度
O ( k ) O(k) O(k)
优先队列的大小不会超过 k。
⭐️删除链表的倒数第 N 个结点(力扣19)
题目描述
思路(双指针)
我们直接讲进阶的实现方式,也就是使用双指针技巧,一趟扫描实现这道题目。
这道题目要用到「双指针」技巧的**「快慢指针」**,假设 n = 3,我们可以先让快指针走 3 步,然后慢指针此时从链表头结点出发,快慢指针同步前进,当快指针走到 null 时,慢指针正好指向要删除的节点。
由于要删除这个节点,我们还需准备一个虚拟头节点,以及一个指向虚拟头节点的指针(前置指针),前置指针与慢指针同时同步前进,当慢指针指向要删除节点的时候,前置指针正好指向要删除节点的前一个,然后将前置指针的 next 指针指向要删除节点的下一个节点即可。
参考代码
public ListNode removeNthFromEnd(ListNode head, int n) {
// 判空
if (head == null) {
return head;
}
// 快指针先走 n 步
ListNode fast = head;
for (int i = 0; i < n; i++) {
fast = fast.next;
}
// 给原链表添加一个虚拟头节点
ListNode dummy = new ListNode(-1);
dummy.next = head;
// 准备前置指针和慢指针
ListNode pre = dummy;
ListNode slow = head;
// 前置、快慢指针同步前进,当快指针为空时,慢指针就到了倒数第 n 个位置
while (fast != null) {
fast = fast.next;
slow = slow.next;
pre = pre.next;
}
// 删除慢指针处的节点(倒数第 n 个节点)
pre.next = slow.next; // help GC
// 返回时去掉表头
return dummy.next;
}
时间复杂度
O ( n ) O(n) O(n)
最坏情况下,遍历 n 个链表节点。
空间复杂度
O ( 1 ) O(1) O(1)
常数级指针变量,无额外辅助空间使用
⭐️链表的中间结点(力扣876)
题目描述
思路(双指针)
这道题用到了「双指针」技巧的 「快慢指针」技巧,我们快慢指针分别指向链表头节点 head。
快指针每次走两步,慢指针每次走一步,当快指针走到链表末尾时,慢指针正好指向链表的中点。
参考代码
public ListNode middleNode(ListNode head) {
if (head == null) {
return head;
}
// 快慢指针分别指向链表头节点 head
ListNode slow = head, fast = head;
// 快指针走到链表末尾时停止
while (fast != null && fast.next != null) {
// 快指针一次走两步,慢指针一次走一步
fast = fast.next.next;
slow = slow.next;
}
// 慢指针指向链表中点
return slow;
}
时间复杂度
O ( n ) O(n) O(n)
空间复杂度
O ( 1 ) O(1) O(1)
常数级指针变量,无额外辅助空间使用。
⭐️环形链表(力扣141)
题目描述
思路(双指针)
这道题用到的也是**「快慢指针」**技巧。环形链表的环一定在链表末尾,并且末尾没有 null 了。根据这个特点,我们使用快慢指针同向访问链表,因为速度不一致,如果有环的话,快慢指针一定会相遇;如果没有环的话,快指针一定会遇到空指针。
参考代码
public boolean hasCycle(ListNode head) {
// 判空
if (head == null || head.next == null) {
return false;
}
// 快慢双指针,初始指向链表头 head
ListNode slow = head, fast = head;
// 快指针走到末尾时停止
while (fast != null && fast.next != null) {
// 快慢指针移动
fast = fast.next.next;
slow = slow.next;
// 快慢指针相遇则有环
if (slow == fast) {
return true;
}
}
// 快指针走到末尾,则没有环
return false;
}
时间复杂度
O ( n ) O(n) O(n)
最坏情况下遍历链表 n 个节点。
空间复杂度
O ( 1 ) O(1) O(1)
仅使用了两个指针,没有额外辅助空间。
⭐️环形链表入口节点(力扣142)
题目描述
思路(双指针)
这道题也是使用「快慢指针」技巧,难点在于如何找到环的入口节点。我会先说如何解决这道题,理论推导过程我也会给出,如果觉得很难懂可以直接跳过,记住解题方法就可以了。
解法就是使用上一题(判断链表是否有环)的方法,找到相遇节点,然后慢指针留在相遇节点,快指针回到链表头节点,之后两个指针同步逐个元素地遍历链表,当两者再次相遇的节点就是环的入口节点。
为什么这样就能找到环的入口节点了呢?
假设在一个有环链表中。快指针在环中走了 n n n 圈,慢指针走了 m m m 圈,两者相遇。此时,链表头节点到环入口点距离为 x x x,环入口到相遇点距离为 y y y,相遇点到环入口点距离为 z z z。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-pTyYMyjv-1683703643358)(null)]
这个过程中,快指针一共走了 x + n ( y + z ) + y x + n(y + z) + y x+n(y+z)+y 步,慢指针一共走了 x + m ( y + z ) + y x + m(y + z) + y x+m(y+z)+y 步。
快指针走的步数是慢指针的两倍,则
x
+
n
(
y
+
z
)
+
y
=
2
(
x
+
m
(
y
+
z
)
+
y
)
x + n(y + z) + y = 2(x + m(y + z) + y)
x+n(y+z)+y=2(x+m(y+z)+y)
进一步推导
x
+
y
=
(
n
−
2
m
)
(
y
+
z
)
x+y=(n-2m)(y+z)
x+y=(n−2m)(y+z)
y
+
z
y+z
y+z 就是环的大小,这说明从链表头经过环入口到达相遇地方经过的距离(x + y)等于整数倍环的大小(y + z)。
那么此时,我们从头开始遍历到相遇位置(x + y),从相遇位置开始在环中遍历(y + z),会使用相同的步数,而双方最后都会经过入口到相遇位置这 y y y 个节点,说明这 y y y 个节点它们就是重叠遍历的,那它们从入口位置就相遇了,这样就找到了入口节点。
参考代码
public ListNode detectCycle(ListNode head) {
// 判空
if (head == null || head.next == null) {
return null;
}
// 定义快慢双指针,初始化指向链表头节点 head
ListNode fast = head, slow = head;
while (fast != null && fast.next != null) {
fast = fast.next.next;
slow = slow.next;
if (slow == fast) { // 相遇跳出循环
break;
}
}
// 跳出循环有两种可能
// 1. 快指针到末尾了,链表无环
// 2. 链表有环,快慢指针相遇了
// 情况 2
if (fast != null && fast.next != null) {
fast = head; // 快指针重新指向头结点
// 快慢指针同步遍历链表,再次相遇的节点就是环入口节点
while (fast != slow) {
fast = fast.next;
slow = slow.next;
}
return fast;
}
// 情况 1
return null;
}
时间复杂度
O ( n ) O(n) O(n)
最坏情况下遍历链表两次
空间复杂度
O ( 1 ) O(1) O(1)
使用了常数个指针,没有额外辅助空间
⭐️相交链表(力扣160)
题目描述
这道题就是要寻找到两个链表的第一个公共节点。
思路(双指针)
我们使用两个指针 p1、p2 分别在链表 1、链表 2 上同步前进,但是无法同时走到公共节点。因此,解决这道题的关键在于,如何能够让两个指针同时到达相交节点 6。
两个指针的速度是一样的,那么如何才能同时到达相同点呢?那么应该要让两个指针走同样的长度。
因此,两个指针一起遍历,如果 p1 先遍历完链表 1 的话,就从链表 2 的头节点继续遍历。同样的,如果 p2 先遍历完链表 2 的话,就从链表 1 的头节点继续遍历。也就是说,p1 和 p2 都会遍历链表 1 和链表 2。
两个指针,同样的速度,走完同样的长度(链表 1 + 链表 2),不管两条链表有无相同节点,都能够同时到达终点。
也就是说,如果有公共节点,两个指针一定会相遇,相遇节点就是第一个公共节点;如果没有公共节点,两个指针都会走到终点,此时都是 null,也算是相等了。
参考代码
public ListNode getIntersectionNode(ListNode headA, ListNode headB) {
// 判空
if (headA == null || headB == null) {
return null;
}
// 初始化 p1 和 p2 两个指针,分别指向两个链表头
ListNode p1 = headA, p2 = headB;
while (p1 != p2) {
// 如果 p1 不为空,前进一步,为空则从链表 B 头节点开始
if (p1 != null) {
p1 = p1.next;
} else {
p1 = headB;
}
// 如果 p2 不为空,前进一步,为空则从链表 A 头节点开始
if (p2 != null) {
p2 = p2.next;
} else {
p2 = headA;
}
}
return p1;
}
时间复杂度
O ( m + n ) O(m+n) O(m+n)
链表 1 和链表 2 的长度之和。
空间复杂度
O ( 1 ) O(1) O(1)
常数级指针变量,无额外辅助空间使用。
⭐️反转链表(力扣206)
题目描述
思路(三指针)
反转链表一定要自己在纸上画一遍!
初始化三个指针分别是 pre(黄色)、cur(红色)、nxt(绿色)。
- pre 指针用于指向已经反转好的链表的最后一个节点,最开始没有反转,所以为空。
- cur 指针指向待反转链表(原链表)的第一个节点,最开始第一个节点待反转,所以指向 head。
- nxt 指针指向待反转链表(原链表)的第二节点,用于保存链表,因为 cur 的 next 指针指向改变后,后面的链表就失效了,所以需要保存。
参考代码
public ListNode reverseList(ListNode head) {
if (head == null) {
return head;
}
// 初始化三个指针
ListNode pre = null, cur = head, nxt = null;
while (cur != null) {
nxt = cur.next; // 保存 cur 的下一个节点
cur.next = pre; // 反转
// 指针后移,操作下一个未反转链表的第一个节点
pre = cur; // 移动 pre 到 cur
cur = nxt; // 移动 cur 到 nxt
}
// 返回反转后的头节点
return pre;
}
时间复杂度
O ( n ) O(n) O(n)
最坏情况下,需要遍历整个链表。
空间复杂度
O ( 1 ) O(1) O(1)
常数级指针变量,无额外辅助空间使用。
⭐️链表内指定区间反转(力扣92)
题目描述
思路I(双指针)
先说最简单的一种方法,我们找到要反转的链表区间,将这个区间断开,然后利用上一道题的思路反转这个区间链表,然后再重新接上就可以了。这个部分的难点就是要记得保存好一些关键节点,待反转链表的前驱节点,后继节点,待反转链表自己的头节点。
参考代码I
public ListNode reverseBetween (ListNode head, int m, int n) {
if (head == null) {
return null;
}
// 定义虚拟头节点,便于处理要删除链表第一个节点的情况
ListNode dummy = new ListNode(-1);
dummy.next = head;
ListNode p = dummy;
// 寻找第 m 个节点的前一个节点(待反转链表头节点的前驱节点)
int i = 0;
for (; i < m - 1; i++) {
p = p.next;
}
// p 抵达第 m 个节点的前一个节点(待反转链表头节点的前驱节点)
ListNode pre = p;
// 寻找第 n 个节点
for (; i < n; i++) {
p = p.next;
}
// 保存第 n 个节点的下一个节点(待反转链表尾节点的后继节点)
ListNode succ = p.next;
// 切断待反转链表与后继节点的联系
p.next = null;
// 待反转链表的头节点
ListNode reverseHead = pre.next;
// 切断待反转链表头节点与前驱节点的联系
pre.next = null;
// 前驱节点连接反转后的链表
pre.next = reverse(reverseHead);
// 链表反转后,原来的头节点 reverseHead 变成了尾节点,连接后继节点
reverseHead.next = succ;
// 返回时去掉虚拟头节点
return dummy.next;
}
ListNode reverse(ListNode head) {
ListNode pre = null, nxt = null, cur = head;
while (cur != null) {
nxt = cur.next;
cur.next = pre;
pre = cur;
cur = nxt;
}
return pre;
}
时间复杂度I
O ( n ) O(n) O(n)
遍历一次链表
空间复杂度I
O ( 1 ) O(1) O(1)
常数级指针变量,无额外辅助空间使用。
思路II(双指针头插迭代法)
一定要在纸上画一遍,肯定能够理解代码!
使用双指针,一个指向第 m 个节点,一个指向前驱节点(指针始终不变)。所谓头插法,以图示的 2 到 4 为例,先让 3 号节点插到 2 号节点前面,再让 4 号节点插到 3 号节点前面,这样就反转成功了。
参考代码II
public ListNode reverseBetween (ListNode head, int m, int n) {
ListNode dummy = new ListNode(-1);
dummy.next = head;
ListNode pre = dummy; // 永远指向待反转区域的头结点的前驱节点(前一个节点),循环中永远不变
// 找到第 m 个节点的前驱节点
for (int i = 0; i < m - 1; i++) {
pre = pre.next;
}
ListNode cur = pre.next; // 指向待反转区间的头结点
ListNode nxt; // 永远指向 cur 节点的下一个节点
for (int i = m; i < n; i++) {
nxt = cur.next;
// 当前节点的下一个节点要前插
cur.next = nxt.next; // 当前节点的 next 要连接 nxt 的下一个节点
nxt.next = pre.next; // nxt 节点,前插到头部,连接原本的头结点(前驱节点的下一个节点)
pre.next = nxt; // 前驱节点连向 nxt 节点
}
// 返回时去掉虚拟头节点
return dummy.next;
}
时间复杂度II
O ( n ) O(n) O(n)
最坏情况下,遍历整个链表。
空间复杂度II
O ( 1 ) O(1) O(1)
常数级指针变量,无额外辅助空间使用。
K 个一组翻转链表(力扣25)
题目描述
思路(双指针+递归)
这道题思路很简单,我们只要每轮遍历链表 k 次,分出一组,不足 k 次,不用翻转直接返回这一组的头节点即可。
分出一组后,对这组链表进行翻转,原来的头变成了尾,然后拼接在一起就可以啦。
如何翻转 k 个链表节点呢?
还记得我们之前做过的「反转链表」题目吗?题目的函数签名是 ListNode reverseList(ListNode head)
,这个可以理解为「反转 head
到 null
之间的节点」。
public ListNode reverseList(ListNode head) {
ListNode pre = null, cur = head, succ = null;
while (cur != null) {
succ = cur.next;
cur.next = pre;
pre = cur;
cur = succ;
}
return pre;
}
那么如果要翻转 head
到 tail
之间的节点(一共 k 个节点),要如何做呢?
我们只要更改函数签名 ListNode reverseList(ListNode head, ListNode tail)
,将原本代码中的 null
改为 tail
即可。
// 反转 [head, tail) 的链表节点
public ListNode reverseList(ListNode head, ListNode tail) {
ListNode pre = null, cur = head, succ = null;
// 终止条件从 null 变成 tail
while (cur != tail) {
succ = cur.next;
cur.next = pre;
pre = cur;
cur = succ;
}
// 返回反转后的头节点
return pre;
}
完整过程如下。
参考代码
public ListNode reverseKGroup(ListNode head, int k) {
if (head == null) {
return head;
}
// 区间 [pHead, pTail) 有 k 个待反转的节点
ListNode pHead = head, pTail = head;
// 让 pTail 指向第 k 个节点
for (int i = 0; i < k; i++) {
// 不足 k 个节点,不需要反转,base case
if (pTail == null) {
return head;
}
pTail = pTail.next;
}
// 反转 k 个节点
ListNode newHead = reverseList(pHead, pTail);
// 递归反转后续链表并连接起来
pHead.next = reverseKGroup(pTail, k);
return newHead;
}
// 反转 [head, tail) 的链表节点
public ListNode reverseList(ListNode head, ListNode tail) {
ListNode pre = null, cur = head, succ = null;
// 终止条件从 null 变成 tail
while (cur != tail) {
succ = cur.next;
cur.next = pre;
pre = cur;
cur = succ;
}
// 返回反转后的头节点
return pre;
}
代码中 for 循环之后的部分可能需要再解释一下,如图所示:
递归反转并连接后的样子如下:
时间复杂度
O ( n ) O(n) O(n)
遍历链表所有节点
空间复杂度
O ( n ) O(n) O(n)
递归栈最大深度为 n / k n/k n/k
⭐️奇偶链表(力扣328)
题目描述
思路(双指针)
这道题直接使用两个指针同方向遍历链表,第一个节点索引为奇数,第二个节点索引为偶数,第三个节点索引为奇数。我们可以直接断掉节点 1 和节点 2 之间的连接,让节点 1 的 next 指向节点 3,偶数索引节点也是如此。
参考代码
public ListNode oddEvenList(ListNode head) {
// 如果链表为空,不用重排
if(head == null) {
return head;
}
// 奇数索引节点指针
ListNode odd = head;
// 偶数索引节点指针,可能为空
ListNode even = head.next;
// 标记偶数索引头节点,用于奇偶索引链表连接
ListNode evenHead = even;
while (even != null && even.next != null) {
// 奇数索引节点的 next 连向偶数索引节点的下一个
odd.next = even.next;
// 奇数索引指针后移
odd = odd.next;
// 偶数索引节点的 next 连向奇数索引节点的下一个
even.next = odd.next;
// 偶数索引指针后移
even = even.next;
}
// 偶数索引链表连在奇数索引链表后面
odd.next = evenHead;
return head;
}
时间复杂度
O ( n ) O(n) O(n)
遍历一次链表所有节点。
空间复杂度
O ( 1 ) O(1) O(1)
常数级指针变量,没有额外辅助空间。
更新日志
更新时间 | 更新内容 |
---|---|
2023.05.06 | 发布文章,详解双指针技巧,详解 9 道大厂常见手撕题目:合并两个有序链表、分割链表、合并 K 个升序链表、删除链表的倒数第 N 个节点、链表的中间节点、环形链表、环形链表的入口节点、相交链表、反转链表。 |
2023.05.09 | 新增题目:奇偶链表(力扣328)、指定区间反转(力扣92)。 |
2023.05.10 | 新增食用指南;新增题目:K 个一组翻转链表(力扣25) |
参考资料
- 牛客网
- 力扣网
- 算法基地 - github
- labuladong 的算法小抄
- 代码随想录
- draw.io