一、前言
双指针是一种应用很广泛且基础的算法,严格来说双指针不是算法更像是一种思想。双指针中的“指针” 不仅仅是大家所熟知的C/C++里面的地址指针,还是索引、游标。本文将会简单介绍双指针及双指针的四种常用模型,用于双指针入门参考学习。
二、算法核心思想
双指针是指在遍历对象时,使用两个或多个指针进行遍历及相应的操作。大多用于数组操作,这利用了数组连序性的特点。双指针常用来降低算法的时间复杂度,因为使用两个指针可以避免多层循环。
双指针的三个关键点
- 指针的起始位置的选取
- 指针的移动方向
- 指针的移动速度
这三个关键点是双指针算法的核心也是难点,
三、算法模型
1.对撞指针
对撞指针:左右两个指针,向中间靠拢。
1.
https://leetcode-cn.com/problems/binary-search/
704.二分查找
给定一个 n 个元素有序的(升序)整型数组 nums 和一个目标值 target ,写一个函数搜索 nums 中的 target,如果目标值存在返回下标,否则返回 -1。
示例 :
输入:nums= [-1,0,3,5,9,12],
target= 9
输出: 4
解释: 9 出现在 nums中并且下标为 4
在数组有序的前提下用双指针进行二分查找,双指针的作用在于"二分"。首先左右两个指针l r,分别指向数组的首元素和尾元素,判断左右指针中间数组下标mid所对应的数组值与目标值的大小关系,共有如下三种情况:
- nums[mid] == target 找到目标值,记录数组下标,结束
- nums[mid] > target 中间的值大于目标值,应当在区间 [ l, mid-1 ] 中继续查找
- nums[mid] < target中间值小于目标值,应当在区间 [ mid+1 , r ] 中继续查找
3 < 9 l = mid + 1
找到目标值
代码如下:
class Solution { public int search(int[] nums, int target) { //数组长度 int len = nums.length; int l = 0 ,r = len - 1; //记录目标值下标 int index = -1; while(l <= r){ //求出中间下标 (数据类型) int mid = (r-l) / 2 + l; //大于目标值 移动右指针 if(nums[mid] > target){ r = mid - 1; } else if(nums[mid] < target){ //小于目标值 移动左指针 l = mid + 1; }else{ //等于目标值 记录下标值 结束循环 index = mid; break; } } return index; } }
2.
网络异常,图片无法展示|15.三数之和
难度中等3979
给你一个包含
n
个整数的数组nums
,判断nums
中是否存在三个元素 a,b,c ,使得 a + b + c = 0 ?请你找出所有和为0
且不重复的三元组。注意:答案中不可以包含重复的三元组。
示例 :
输入:nums = [-1,0,1,2,-1,-4]
输出:[[-1,-1,2],[-1,0,1]]
首先对nums数组进行排序,题目要求找三个和为0的且不重复的元素组。我们可以先选定一个元素a,然后用双指针查找符合条件的元素b 和c,(注意去重) 假设 sum = a+b+c,则有如下三种情况:
- sum == 0符和条件,将这三个数加入到加入到集合中
- sum > 0 左移右指针 使得元素c变小
- sum < 0 右移做指针 使得元素b变大
代码如下:
class Solution { public List<List<Integer>> threeSum(int[] nums) { List<List<Integer>> res = new ArrayList<List<Integer>>(); int n = nums.length; //对数组进行排序 Arrays.sort(nums); //记录上一个元素,用于去重 int index = -1; for(int i = 0; i < n;i++ ) { //去重 if(index != -1 && nums[index] == nums[i]) continue; //左右指针 int l = i + 1, r = n - 1; while(l < r) { // 三个数的和 int num = nums[i] + nums[l]+nums[r]; //和为0 符合题意 记录在集合中 if(num == 0) { List<Integer> list = new ArrayList<Integer>(); list.add(nums[i]); list.add(nums[l]); list.add(nums[r]); res.add(list); //二次去重 int temp = nums[l]; while(l < r && temp == nums[l]) l++; }else if(num < 0) { //和小于0 右移左指针 l++; }else { //和大于0 左移右指针 r--; } } //记录上一次第一个元素的下标 index = i; } return res; } }
双指针正确性论证:首先数组可以用双指针,排序后,先选定一个元素 a 用i指向这个元素,然后用指针l指向元素b,指针r指向元素c。sum为三个元素的和,sum只有上述的三种情况,
如果第一种情况成立即 sum = 0 符合条件;
当sum > 0时,左移右指针r,而不是左移r且右移l,原因在于此时sum > 0,无论如何右移指针l sum都会大于0,r所指的元素没有用的,而l所指的元素可能和区间 [l,r-1] 中的某个元素符合条件,如果l r都移动的话 会丢掉一些可能的情况。后退就更不可能因为是在重复之前的操作。
当sum < 0时,右移左指针l ,而不是后退右指针r,原因在于首先指针移动到这一步说明之前遍历过的元素是不符合条件的,那上一步可能是两种可能 sum > 0 sum < 0,而右指针r 左移到这一步的位置一定是sum > 0,所以只有一种可能就是sum > 0,如果是sum > 0 那么是左移右指针r得到的这一步,再后退就是在重复操作。
2.快慢指针
快慢指针:左右两个指针,一块一慢
1.
给定一个头结点为
head
的非空单链表,返回链表的中间结点。如果有两个中间结点,则返回第二个中间结点。
示例 :
输入:[1,2,3,4,5]
输出:此列表中的结点 3 (序列化形式:[3,4,5]) 返回的结点值为 3 。 (测评系统对该结点序列化表述是 [3,4,5])。 注意,我们返回了一个 ListNode 类型的对象 ans,这样: ans.val = 3, ans.next.val = 4, ans.next.next.val = 5, 以及 ans.next.next.next = NULL.
寻找单链表的中间结点,我们可以简单的进行循环遍历求出单链表的结点数量,然后再循环遍历找到中间的结点。但是如果用双指针的思路该怎么办?
还记得双指针的三个关键点吗,双指针初始位置的选取,因为我们不知道单链表的尾部,因此首尾双指针显然是行不通的,只能一端进行,方向从头到尾,那么应用双指针的价值何在?
这个时候就要看第三个关键点了,双指针移动的速度。我们不妨设想下如果两个人从同一位置直线出发,一个人速度是另一个人的两倍,那么相同时间这个人的路程就是另一个人的两倍。我们设置两个指针,一个移动速度是另一个的两倍,那么第一个到达终点,第二个不就恰好到达中间位置吗
结构体定义为:
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 middleNode(ListNode head) { ListNode p = head, q = head; while(q != null && q.next != null){ q = q.next.next; p = p.next; } return p; } }
2.
网络异常,图片无法展示|难度简单545
给定字符串 s 和 t ,判断 s 是否为 t 的子序列。
字符串的一个子序列是原始字符串删除一些(也可以不删除)字符而不改变剩余字符相对位置形成的新字符串。(例如,
"ace"
是"abcde"
的一个子序列,而"aec"
不是)示例 :
输入:s = "abc", t = "ahbgdc"
输出:true
判断字符串s是否是字符串t的子序列,首先要清楚子序列的定义,是字符串t中的字符在不改变相对位置的前提下能够取出一个与s相同的字符串,那么s就是t的子序列。我们遍历t看是否存在s的第一个字符,如果存在就以此位置为基础继续遍历找s的第二个字符,依次进行下去,如果s全部找完那么就说明s是t的字序列。
我们设置两个指针 i , j,i从s的第一个字符开始,j从t的第一个字符开始,当相等时,i和j都右移,不相等则仅j右移,最后如果i等于s的长度,那么说明完全匹配成功,s是t的子序列。
代码如下:
class Solution { public boolean isSubsequence(String s, String t) { int n = s.length(), m =t.length(); if (n > m ) { return false; } int i = 0 ,j = 0; while(i< n && j<m ) { if(s.charAt(i) == t.charAt(j)) i++; j++; } return i == n; } }
3.滑动窗口
滑动窗口:左右两个指针组成一个"窗口",右指针不断扩张,左指针按条件收缩。
网络异常,图片无法展示|给定一个由若干
0
和1
组成的数组A
,我们最多可以将K
个值从 0 变成 1 。返回仅包含 1 的最长(连续)子数组的长度。
示例 :
输入:A = [1,1,1,0,0,0,1,1,1,1,0], K = 2
输出:6
解释:
[1,1,1,0,0,1,1,1,1,1,1]
粗体数字从 0 翻转到 1,最长的子数组长度为 6。
本题求最大连续1的个数,就是在小于等于k个0的基础上求最长符合条件区间的长度。我们使用双指针l r,代表窗口的两边,从最初开始遍历数组,如果值为1或者此时区间 [ l, r ] 中0的个数 <= k,我们就向右移指针r扩大窗口,并更新0的个数,当0的个数大于k,我们就需要更新最大符合条件的区间长度。然后右移l缩小窗口,更新0的个数,继续重复直至右指针超出数组下标界限,遍历结束,此时记录最大区间长度的值即为所求。
收缩窗口
代码如下:
class Solution { public int longestOnes(int[] nums, int k) { int n = nums.length; int l = 0, r = 0; int count = 0; //记录0的个数 int res = 0; //记录结果 while(r < n){ //当nums[r]为1 或者 子区间零的个数少于k if(nums[r] == 1 || count < k){ //是否为0,若为0则count++ count += nums[r] == 0 ? 1 : 0; r++; continue; } res = Math.max(res,r-l); //nums[r] 为 0 count++; r++; //如果子区间0的个数 大于 k ,缩小窗口 l < n防止数组下标越界 while(count > k ){ count -= nums[l] == 0 ? 1 : 0; l++; } } res = Math.max(res,r-l); return res; } }
4.归并排序
网络异常,图片无法展示|给你两个按 非递减顺序 排列的整数数组 nums1 和 nums2,另有两个整数 m 和 n ,分别表示 nums1 和 nums2 中的元素数目。
请你 合并 nums2 到 nums1 中,使合并后的数组同样按 非递减顺序 排列。
注意:最终,合并后数组不应由函数返回,而是存储在数组 nums1 中。为了应对这种情况,nums1 的初始长度为 m + n,其中前 m 个元素表示应合并的元素,后 n 个元素为 0 ,应忽略。nums2 的长度为 n 。
示例 :
输入:nums1 = [1,2,3,0,0,0], m = 3, nums2 = [2,5,6], n = 3
输出:[1,2,2,3,5,6]
解释:需要合并 [1,2,3] 和 [2,5,6] 。
合并结果是 [1,2,2,3,5,6] 。
合并两个有序数组,不能使用额外的数组空间,合并后的数组就是nums1。与之前的字符串的子序列类似,不同的是,我们不能从头开始遍历比较两个数组元素,原因在于此时比较之后存储在nums1中,那么nums1原本的元素可能需要后移一位,类似于数组的添加操作时间复杂度为O(N),如果nums2最小的元素都比nums1中最大的元素大,那么nums1元素讲一直后移进行nums2元素个数次O(N)操作,将是O(N^2),如何优化?
我们遇到上述问题的原因是,我们从已存储有效数据的一端开始,这个时候进行移动一定会影响到左右的元素,那么我们换个思路,从尾部开始比较,也就是说从最大的数可以比较,存储在nums1尾部。
代码如下:
class Solution { public void merge(int[] nums1, int m, int[] nums2, int n) { //l指向nums1尾部元素,r指向nums2尾部元素 ,index指向nums1要存储比较后数据的位置 int l = m - 1, r = n -1 , index = m + n - 1; while(l >= 0 && r >= 0){ //如果l指向的元素值大 左移l if(nums1[l] > nums2[r]){ nums1[index] = nums1[l]; --l; }else{ //如果r指向的元素值大 左移r nums1[index] = nums2[r]; --r; } //存储一个元素,左移index指向下一个待存储元素的位置 --index; } // 如果此时 nums2还有元素没有比较,就全部依次存储在nums1中 while(r >= 0) nums1[index--] = nums2[r--]; } }
四、总结
双指针是解决复杂算法题的常用基础之一,应用广泛,单独考察双指针的题相对来说比较简单。双指针作为一种重要的算法基础,不仅是降低算法时间复杂度的一种方法,还可以为解决问题提供一种新的思路。熟练掌握双指针,不仅要了解双指针的常用模型,还要多练习相关算法题,讲所学的知识应用于实践中。