解数组题的利器之双指针法
双指针法的应用
双指针法有两种应用形式
一种是左右指针法,应用在有序数组中,按照题目条件移动左指针或右指针,从而找到符合题意的解;另一种是快慢指针法,可以根据题目条件改变数组结构,快指针定位需要做出修改的数组位置,慢指针定位符合题目要求的数组位置,通过快慢指针配合只需一遍遍历就能改造出符合题目要求的数组并且不需要额外的存储空间,快慢指针的另一个作用是按位置查找和定位元素。接下来将分别对两种指针法做详细的描述。
一、左右指针法
1、二分查找
废话少说,我们直接看二分查找的框架代码。
int binarySearch(int[] nums, int target) {
int left = 0;
int right = nums.length - 1; // 注意
while(left <= right) {
int mid = left + (right - left) / 2;
if(nums[mid] == target)
return mid;
else if (nums[mid] < target)
left = mid + 1; // 注意
else if (nums[mid] > target)
right = mid - 1; // 注意
}
return -1;
}
二分查找是典型的左右指针法的应用,通过和目标值比较,移动左右指针缩小搜索区间,每次循环将查找范围缩小为原来的一半,最坏情况下的时间复杂度为log(n)。除查找一个确定值外,二分查找还能寻找数组的左右边界,之后将出一篇二分查找专题文章,敬请期待。
2、多数之和
关于多数之和的应用场景,大家可以跟我一起做力扣15题,题目如下:
给你一个包含 n 个整数的数组 nums,判断 nums 中是否存在三个元素 a,b,c ,使得 a + b + c = 0 ?
请你找出所有满足条件且不重复的三元组。
注意:答案中不可以包含重复的三元组。
分析:
刚开始想到的是暴力破解法,用三重for循环枚举所有情况,时间复杂度是O(n3),运行超时。
后来查看官方题解,应用左右指针法在规定时间内成功解出该题。代码如下:
public class DoublePointer {
public List<List<Integer>> threeSum(int[] nums) {
Arrays.sort(nums);
int len = nums.length;
List<List<Integer>> resultList = new ArrayList<>();
for (int i = 0; i < len - 2; i++) {
if (i != 0 && nums[i] == nums[i - 1]) {
continue;
}
int p = i + 1;
int q = len - 1;
int target = -nums[i];
while (p<q){
if(target == nums[p]+nums[q]){
List<Integer> answerList = new ArrayList<>();
answerList.add(nums[i]);
answerList.add(nums[p]);
answerList.add(nums[q]);
resultList.add(answerList);
//左指针跳过相同值
while (p<q&&nums[p+1]==nums[p]){
p++;
}
//右指针跳过相同值
while (p<q&&nums[q-1]==nums[q]){
q--;
}
p++;
q--;
}
if(p<q&&nums[p]+nums[q]>target){
q--;
}
if(p<q&&nums[p]+nums[q]<target){
p++;
}
}
}
return resultList;
}
}
应用左右指针算法后,算法的时间复杂度为O(n2),远远超过暴力破解方法。之所以能够如此优化算法是因为我们应用了左右指针进行了巧妙的枚举,当两指针值之和小于目标值时,移动左指针,反之移动右指针,这样通过对数组的一次遍历就能挑选出所有合适的元素对。
3、反转数组
反转数组也是左右指针的一个常见用法,代码如下:
void reverse(int[] nums) {
int left = 0;
int right = nums.length - 1;
while (left < right) {
// 反转数组
int temp = nums[left];
nums[left] = nums[right];
nums[right] = temp;
left++; right--;
}
}
用左右指针反转数组仅需要O(1)的额外空间,整个反转过程都是在原数组所在的空间完成。
4、滑动窗口算法
滑动窗口也是左右指针的一个常见用法,由于滑动窗口内容较多,将在后序文章中补充,敬请期待。
二、快慢指针法
快慢指针的用法可以分为两类:一类用于根据条件改变数组的结构,此种应用下快指针找出所有满足题意的元素赋值给慢指针;另一类用于按位置条件查找数组或链表中的元素。
1、根据条件改变数组结构
(1)我们先来看力扣第27题移除元素
给你一个数组 nums 和一个值 val,你需要 原地 移除所有数值等于 val 的元素,并返回移除后数组的新长度。
不要使用额外的数组空间,你必须仅使用 O(1) 额外空间并原地修改输入数组。
元素的顺序可以改变。你不需要考虑数组中超出新长度后面的元素。
示例1:
给定 nums = [3,2,2,3], val = 3,
函数应该返回新的长度 2, 并且 nums 中的前两个元素均为 2。
你不需要考虑数组中超出新长度后面的元素。
题目明确要求在数组原地修改,所以不能使用额外的数组空间进行操作。我们看官方给出的题解:
class Solution {
public int removeElement(int[] nums, int val) {
int i = 0;
for(int j=0;j<nums.length;j++){
if(nums[j]!=val){
nums[i] = nums[j];
i++;
}
}
return i;
}
}
代码非常的简单,题目中的慢指针是i,快指针是j,用j找出所有符合题意的元素赋值给i。
(2)我们再来看力扣26题删除排序数组中的重复项
给定一个排序数组,你需要在 原地 删除重复出现的元素,使得每个元素只出现一次,返回移除后数组的新长度。
不要使用额外的数组空间,你必须在 原地 修改输入数组 并在使用 O(1) 额外空间的条件下完成。
示例1:
给定数组 nums = [1,1,2],
函数应该返回新的长度 2, 并且原数组 nums 的前两个元素被修改为 1, 2。
你不需要考虑数组中超出新长度后面的元素。
和移除元素的思路完全一致,题解如下:
class Solution {
public int removeDuplicates(int[] nums) {
int i = 0;
for(int j = 1;j < nums.length;j++){
if(nums[j] != nums[i]){
nums[i + 1] = nums[j];
i++;
}
}
return i + 1;
}
}
2、按位置查找数组或链表中的元素
(1)判断链表中是否有环
单链表的特点是每个节点只知道下一个节点,所以一个指针的话无法判断链表中是否含有环的。
如果链表中不含环,那么这个指针最终会遇到空指针 null 表示链表到头了,这还好说,可以判断该链表不含环。
boolean hasCycle(ListNode head) {
while (head != null)
head = head.next;
return false;
}
但是如果链表中含有环,那么这个指针就会陷入死循环,因为环形数组中没有 null 指针作为尾部节点。
经典解法就是用两个指针,一个每次前进两步,一个每次前进一步。
如果不含有环,跑得快的那个指针最终会遇到 null,说明链表不含环;
如果含有环,快指针最终会超慢指针一圈,和慢指针相遇,说明链表含有环。
boolean hasCycle(ListNode head){
ListNode fast,slow;
fast = slow = head;
while(fast!=null&&fast.next!=null){
fast = fast.next.next;
slow = slow.next;
if(fast == slow){
return true;
}
}
return false;
}
(2)已知链表中含有环,返回这个环的起始位置
先直接看代码:
LinkedList detectCycle(LinkedList head){
LinkedList fast,slow;
fast = slow = head;
while (fast != null && fast.next != null){
fast = fast.next.next;
slow = slow.next;
if(fast == slow){
break;
}
slow = head;
while (slow != fast){
fast = fast.next;
slow = slow.next;
}
}
return slow;
}
可以看到,当快慢指针相遇时,让其中任一个指针重新指向头节点,然后让它俩以相同速度前进,
再次相遇时所在的节点位置就是环开始的位置。这是为什么呢?
第一次相遇时,假设慢指针 slow 走了 k 步,那么快指针 fast 一定走了 2k 步,
也就是说比 slow 多走了 k 步(也就是环的长度)。
设相遇点距环的起点的距离为 m,那么环的起点距头结点 head 的距离为 k - m,
也就是说如果从 head 前进 k - m 步就能到达环起点。
巧的是,如果从相遇点继续前进 k - m 步,也恰好到达环起点。
所以,只要我们把快慢指针中的任一个重新指向 head,然后两个指针同速前进,k - m 步后就会相遇,相遇之处就是环的起点了。
(3)寻找链表的中点
类似上面的思路,我们还可以让快指针一次前进两步,慢指针一次前进一步,当快指针到达链表尽头时,慢指针就处于链表的中间位置。
ListNode slow, fast;
slow = fast = head;
while (fast != null && fast.next != null) {
fast = fast.next.next;
slow = slow.next;
}
// slow 就在中间位置
return slow;
当链表的长度是奇数时,slow 恰巧停在中点位置;如果长度是偶数,slow 最终的位置是中间偏右:
寻找链表中点的一个重要作用是对链表进行归并排序。
回想数组的归并排序:求中点索引递归地把数组二分,最后合并两个有序数组。
对于链表,合并两个有序链表是很简单的,难点就在于二分。
但是现在你学会了找到链表的中点,就能实现链表的二分了。关于归并排序的具体内容本文就不具体展开了。
(4)寻找链表的倒数第 k 个元素
我们的思路还是使用快慢指针,让快指针先走 k 步,然后快慢指针开始同速前进。
这样当快指针走到链表末尾 null 时,
慢指针所在的位置就是倒数第 k 个链表节点(为了简化,假设 k 不会超过链表长度):
ListNode slow, fast;
slow = fast = head;
while (k-- > 0)
fast = fast.next;
while (fast != null) {
slow = slow.next;
fast = fast.next;
}
return slow;