本文介绍了双指针技巧在链表、数组以及字符串中的使用,给出了大量大厂常见面试手撕题目的思路及代码,不仅适合完全不了解双指针技巧的读者,也适合老司机复习拓展。
考察过该技巧的公司有阿里巴巴、腾讯、美团、拼多多、百度等大厂。
我相信,友好的讨论交流会让彼此快速进步!文章难免有疏漏之处,十分欢迎大家在评论区中批评指正。
食用指南: ⭐️为一刷必做题,便于快速理解双指针技巧;无星题目可在刷完⭐️题目后再做,用于拓展学习双指针技巧如何与其他算法知识结合使用。
双指针技巧是什么?
双指针技巧指的就是在遍历链表、数组或者字符串的过程中,不是使用一个指针来进行访问,而是使用两个指针(甚至可以多个,合并 k 个有序链表中将会用到)进行遍历访问。
以链表为例,两个指针要么同方向访问两个链表、要么同方向访问一个链表(快慢指针)、要么相反方向遍历一个链表(左右指针),数组和字符串也是如此。
下面,先来看看双指针技巧在链表问题中的应用。我们以题目入手,从易到难,逐步感受双指针技巧的精妙之处。
几乎所有题目都提供了使用双指针技巧的思路,以及动画示意图,希望可以通过这一篇文章让你彻底弄懂双指针技巧。
双指针秒杀链表问题
⭐️合并两个有序链表(力扣21)
题目描述
![力扣-21-合并两个有序链表](https://cdn.jsdelivr.net/gh/mrpersimmon/myblog@master/img/202305042052248.png)
思路(双指针)
题目中两个链表已经排好序,我们可以使用两个指针同向访问两个链表,每次比较两个指针的元素,谁小谁加到新的链表上。
![合并两个有序链表](https://cdn.jsdelivr.net/gh/mrpersimmon/myblog@master/img/202305032331487.gif)
参考代码
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)
题目描述
![力扣-86-分隔链表](https://cdn.jsdelivr.net/gh/mrpersimmon/myblog@master/img/202305040007027.png)
思路(双指针)
在上一题中,合并两个有序链表,是将两个有序链表合二为一。而本题是将原链表一分为二,然后再合并成一条链表。具体来说,就是将原链表分成两条小链表,一条链表的元素都小于 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)
题目描述
![力扣-23-合并 K 个升序链表](https://cdn.jsdelivr.net/gh/mrpersimmon/myblog@master/img/202305051038520.png)
思路(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();
// 连接到结果链表上