文章目录
双指针算法可以和字符串、数组、链表各类数据结构进行配合解题,需要深刻理解其思想,笔者结合自己的刷题路径,进行相关题目的总结。
双指针可以只针对一个对象,比如字符串、数组、链表,在上面用两个指针进行遍历操作,可以在一趟遍历中完成去重、判断回文、反转等动作,同时还可以定义「快慢指针」,解决复杂的题目。其中的指针在题目中不一定是指向地址的(cpp的定义),也可以是数组或者字符串的下标索引。
双指针也可以针对两个对象,在每个对象上定义一个指针,比如两个字符串指针或者两个链表指针等等,可以完成合并等操作。
双指针的应用范围十分广泛,如果能够掌握,一定可以给解题能力带来质的提升。
「滑动窗口」本质上也是两个下标索引left、right
间的操作,也很像双指针。但由于滑动窗口的题目特征通常要求最长/最短的子串(必须连续)、子序列,且需要满足「窗口中的res
随right右移
的递增性」,因此和双指针的普适性还是略有区别。有关「滑动窗口」可以参考我写的另一篇文章算法学习-滑动窗口。
相关题目
单对象双指针
26.删除有序数组中的重复项
left
指向当前不重复数字的最后一个,如果不重复一直往后追加,如果重复则right++
忽略,需要明确当且仅当nums[left]==nums[right],应该略过当前的nums[right]。追加的策略是先++left
,然后将nums[right]
补上,最终还是指向最后一个不重复元素。特别的,由于初始化left为0、right为0
,第一个必然重复,因此进行忽略保留nums[left]
,然后right++
,下次交换也是先++left
。
class Solution {
public int removeDuplicates(int[] nums) {
int len=nums.length;
int left=0;
for(int right=0;right<len;right++){
if(nums[left]!=nums[right]){
//left指向当前不重复数字的最后一个,如果不重复一直往后追加,如果重复则right++忽略,针对第一个位置也有效
//应该是++left,先+1再存
nums[++left]=nums[right];
}
}
return left+1;
}
}
80.删除有序数组中的重复项II
相比于26.删除有序数组中的重复项只能保留一个不重复数字,该题最多能够保存重复数字两次。left
定义为满足条件的最后一个元素,需要明确当且仅当nums[left-1]==nums[right],应该略过当前的nums[right],由于有序的特点,这种情况下一定有nums[left-1]==nums[left]==nums[right]
。当数字长度小于等于2时,一定可以保留。
也可以参考三叶姐的题解,其中left定义不同。
class Solution {
public int removeDuplicates(int[] nums) {
int len=nums.length;
if(len<=2) return len;
int left=1;
//left定义为满足要求的最后一个位置
for(int right=2;right<len;right++){
if(nums[left-1]!=nums[right]){
nums[++left]=nums[right];
}
}
//返回数组长度
return left+1;
}
}
6.排序数组中两个数字之和
这题用双指针解答,主要是要理解排序数组中,双指针的单向移动,可以很好地覆盖所有可能取值。假设数组中存在且只存在一对符合条件的数字,同时一个数字不能使用两次。
同时这也是我开始用cpp刷的第一题,cpp刷题的入门知识同步更新于我的另一篇文章算法学习-以刷题为导向需要掌握的C++知识。
class Solution {
public:
vector<int> twoSum(vector<int>& numbers, int target) {
int i=0;
int j=numbers.size()-1;
while(i<j){
int sum=numbers[i]+numbers[j];
if(sum==target){
return {i,j};
}else if(sum>target){
j--;
}else{
i++;
}
}
//前面一定会返回答案
return {0,0};
}
};
java解法:
class Solution {
public int[] twoSum(int[] numbers, int target) {
int left=0;
int right=numbers.length-1;
while(left<right){
int sum=numbers[left]+numbers[right];
if(sum==target){
return new int[]{left,right};
}else if(sum>target){
right--;
}else{
left++;
}
}
return new int[0];
}
}
15.三数之和
求组合,隐藏的意思就是一个组内部的顺序无关,并且题目要求输出的顺序也没有关系。
但是答案中不可以包含重复的三元组合,这就需要用到排序去重了,原因在于排序之后,对于相同的数字,前面的情况必然已经包含了后面的所有情况,因此可以在碰到相同的数字之后直接跳过。基本思想如上面的排序数组中的两数之和,可以利用双指针的单调性选出所有的两个数之和,来匹配target。
整体时间复杂度为 O ( N 2 ) O(N^2) O(N2),之前考虑用回溯做,其实本质上还是一个三重循环暴力搜索 O ( N 3 ) O(N^3) O(N3)
class Solution {
public List<List<Integer>> threeSum(int[] nums) {
List<List<Integer>> res=new ArrayList<>();
Arrays.sort(nums); //先排序
int n=nums.length;
for(int i=0;i<n-2;i++){
if(i>=1&&nums[i]==nums[i-1]) continue; // 第一个数字进行去重
int target=-nums[i];
int left=i+1;
int right=n-1;
while(left<right){
int sum=nums[left]+nums[right];
if(sum>target){
right--;
}else if(sum<target){
left++;
}else{
res.add(Arrays.asList(nums[i],nums[left],nums[right]));
left++;
right--;
// 后面两个数字进行去重
while(i<left&&left<right&&nums[left]==nums[left-1])left++;
while(right<n-1&&left<right&&nums[right]==nums[right+1])right--;
}
}
}
return res;
}
}
18.四数之和
思路类似三数之和,外层套两次循环,同样需要排序去重。
class Solution {
public static List<List<Integer>> fourSum(int[] nums, int target) {
List<List<Integer>> res = new ArrayList<>();
int n = nums.length;
Arrays.sort(nums); // 排序去重
for(int i=0;i<n-3;i++){
if(i>0&&nums[i]==nums[i-1]) continue; // a去重
for(int j=i+1;j<n-2;j++){
if(j>i+1&&nums[j]==nums[j-1]) continue; // b去重
long cur=target-(long)(nums[i]+nums[j]); //做差注意越界
int left=j+1;
int right=n-1;
while(left<right){
long temp=(long)(nums[left]+nums[right]);
if(temp>cur){
right--;
}else if(temp<cur){
left++;
}else{
res.add(Arrays.asList(nums[i],nums[j],nums[left],nums[right]));
left++;
right--;
// c,d去重
while(left>j+1&&left<right&&nums[left]==nums[left-1]) left++;
while(right<n-1&&left<right&&nums[right]==nums[right+1]) right--;
}
}
}
}
return res;
}
}
18.有效的回文串
class Solution {
public boolean isPalindrome(String s) {
int i=0;
int j=s.length()-1;
while(i<=j){
if(!Character.isLetterOrDigit(s.charAt(i))){
i++;
continue;
}
if(!Character.isLetterOrDigit(s.charAt(j))){
j--;
continue;
}
char a=Character.toLowerCase(s.charAt(i));
char b=Character.toLowerCase(s.charAt(j));
if(a!=b) return false;
i++;
j--;
}
return true;
}
}
19.最多删除一个字符得到回文
如果双指针不匹配,则尝试删除左指针元素或者右指针元素,再分别判断回文串。
class Solution {
//如果双指针不匹配,则尝试删除左指针元素或者右指针元素,再分别判断回文串
public boolean validPalindrome(String s) {
int i=0;
int j=s.length()-1;
while(i<=j){
if(s.charAt(i)==s.charAt(j)){
i++;
j--;
}else{
return isValid(s,i+1,j)||isValid(s,i,j-1);
}
}
return true;
}
public boolean isValid(String s,int i,int j){
while(i<=j){
if(s.charAt(i)==s.charAt(j)){
i++;
j--;
}else{
return false;
}
}
return true;
}
}
20.回文字符串的个数
可以采用两种做法,一种是双指针暴力枚举O(N^2)
,但是枚举的过程中不断记录正向和反向的字符串,比较字符串是否相等O(1)
,而不需要额外来一次回文比较O(N)
,空间换时间。
class Solution {
public int countSubstrings(String s) {
int len=s.length();
int cnt=0;
for(int i=0;i<len;i++){
String s1="";
String s2="";
for(int j=i;j<len;j++){
s1+=s.charAt(j);
s2=s.charAt(j)+s2;
if(s1.equals(s2)) cnt++;
}
}
return cnt;
}
}
还有一种是中心拓展法,不用担心会不会记录重复的串,由于左右边界如果要展开都是双向同时展开的,串的一部分有重复不会影响最后整个串重复。考虑奇偶的展开是不一样的情况。
class Solution {
public int countSubstrings(String s) {
int cnt=0;
int len=s.length();
for(int k=0;k<=len-1;k++){
cnt += countValid(s,k,k);
cnt += countValid(s,k,k+1);
}
return cnt;
}
public int countValid(String s,int i,int j){
int cnt=0;
while(i>=0&&j<=s.length()-1){
if(s.charAt(i--)==s.charAt(j++)){
cnt++;
}else break;
}
return cnt;
}
}
20.链表的倒数第K个节点
快慢指针初始化都指向头节点,先让快指针走k步,然后快慢指针同步走,最后快指针为null的时候,慢指针指向的节点就是答案。比如1->2->3->null,找倒数第一个节点为3,快指针先指向2,然后2->3->null,慢指针为1->2->3,最终为3.
/**
* Definition for singly-linked list.
* public class ListNode {
* int val;
* ListNode next;
* ListNode(int x) { val = x; }
* }
*/
class Solution {
public ListNode getKthFromEnd(ListNode head, int k) {
ListNode fast=head;
ListNode slow=head;
while(k-->0){
fast=fast.next;
}
while(fast!=null){
slow=slow.next;
fast=fast.next;
}
return slow;
}
}
21.删除链表的倒数第n个结点
快慢指针法,快指针先提前走n步,然后快慢指针同步走,快指针走到最后一个节点,慢指针刚好到删除节点的前一个节点。
/**
* 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 removeNthFromEnd(ListNode head, int n) {
ListNode dummyHead=new ListNode(0);
dummyHead.next=head;
ListNode fast=dummyHead;
ListNode slow=dummyHead;
while(n--!=0){
fast=fast.next;
}
while(fast.next!=null){
slow=slow.next;
fast=fast.next;
}
slow.next=slow.next.next;
return dummyHead.next;
}
}
22.链表中环的入口节点
快慢指针的运用,快指针每次走两步,慢指针每次走一步,能相遇则一定有环否则无环。设链表共有 a+b
个节点,其中 链表头部到链表入口 有 a
个节点(不计链表入口节点), 链表环 有 b
个节点,快指针步数fast=2*slow
,fast=slow+n*b
,两者相遇得slow=n*b
。所有指针走到链表入口节点时的步数是k=a+nb
,在慢指针走了nb
步的情况下,再走a
步就可以了,这正是链表头开始走,可以和慢指针slow碰头的步数。
/**
* Definition for singly-linked list.
* class ListNode {
* int val;
* ListNode next;
* ListNode(int x) {
* val = x;
* next = null;
* }
* }
*/
public class Solution {
public ListNode detectCycle(ListNode head) {
ListNode fast=head;
ListNode slow=head;
while(fast!=null&&fast.next!=null){
fast=fast.next.next;
slow=slow.next;
if(fast==slow){
ListNode cur=head;
while(cur!=slow){
cur=cur.next;
slow=slow.next;
}
return cur;
}
}
return null;
}
}
658.找到K个最接近的元素
采用双指针的排除法,首先意识到每次删除的元素一定在数组边界(想象x在数组两侧以及中间会产生的结果),然后就利用指针碰撞的思想,缩小与目标x绝对值差值较大的边界。
class Solution {
public List<Integer> findClosestElements(int[] arr, int k, int x) {
int len=arr.length;
int removeNum=len-k;
int left=0;
int right=len-1;
while(removeNum>0){
if(Math.abs(arr[left]-x)>Math.abs(arr[right]-x)){
left++;
}else{
right--;
}
removeNum--;
}
ArrayList<Integer> ans=new ArrayList<>();
for(int i=left;i<=right;i++) ans.add(arr[i]);
return ans;
}
}
870.优势洗牌
需要根据nums2的位置调整nums1的排列,因此需要先将nums2的索引存起来,可以根据其索引上的值进行大小排序(有点像建立索引堆的比较大小)。田忌赛马的思想,先是硬碰硬,用两个数组的最大数字进行比较,对于nums2中的最大数比nums1中的最大数还要大,怎么比都比不过,直接放弃,则用nums1的最小的数放在nums2的该位置;如果nums2中的最大数比nums1中的最大数要小,那就将这个nums1最大值用上就好,其中双指针是用来分别指向nums1中最大最小的位置的。
class Solution {
public int[] advantageCount(int[] nums1, int[] nums2) {
int len=nums2.length;
Integer[] idxs=new Integer[len];
for(int i=0;i<len;i++){
idxs[i]=i;
}
// 将nums2的序号根据其值降序排列
Arrays.sort(idxs,(a,b)->nums2[b]-nums2[a]);
// nums1只要有序排列就可以
Arrays.sort(nums1);
int left=0;
int right=len-1;
int[] ans=new int[len];
for(int idx:idxs){
if(nums2[idx]>=nums1[right]){
ans[idx]=nums1[left++];
}else{
ans[idx]=nums1[right--];
}
}
return ans;
}
}
Python解法:
class Solution:
def advantageCount(self, nums1: List[int], nums2: List[int]) -> List[int]:
n=len(nums2)
nums1.sort()
ans,left,right=[-1]*n,0,n-1
# 直接在取序号的时候,就是根据nums2从大到小取的
for idx in sorted(range(n),key = lambda x:-nums2[x]):
if nums2[idx]>=nums1[right]:
ans[idx]=nums1[left]
left +=1
else:
ans[idx]=nums1[right]
right -=1
return ans
双对象双指针
2.二进制加法
有两种做法,其一是参考lilyunoke大神的加法模板,题解如下:
class Solution {
public String addBinary(String a, String b) {
int i=a.length()-1;
int j=b.length()-1;
int carry=0;
StringBuilder sb=new StringBuilder();
while(i>=0||j>=0){
int digitA=i>=0?a.charAt(i)-'0':0;
int digitB=j>=0?b.charAt(j)-'0':0;
int sum=digitA+digitB+carry;
carry=sum/2;
int digit=sum%2;
sb.append(digit);
i--;
j--;
}
if(carry!=0) sb.append(carry);
return sb.reverse().toString();
}
}
其中可以总结出来的加法模板是,这个模板可以解决很多逐位相加运算的问题,关注A当前位、B当前位、进位三要素:
while ( A 没完 || B 没完){
取到A 的当前位(A完了需要补位0)
取到B 的当前位(B完了需要补位0)
和 = A 的当前位 + B 的当前位 + 进位carry
当前位 = 和 % 2(10);
进位 = 和 / 2(10);
加入结果集
A左移调整
B左移调整
}
判断进位是否为0,不为0额外加上
将结果集反转
或者参考负雪明烛大佬的题解,不同的是循环条件,需要注意的是while循环
结束条件,注意需要遍历完两个「加数」,以及进位不为0。
class Solution {
public String addBinary(String a, String b) {
int i=a.length()-1;
int j=b.length()-1;
int carry=0;
StringBuilder sb=new StringBuilder();
while(i>=0||j>=0||carry!=0){
int digitA=i>=0?a.charAt(i)-'0':0;
int digitB=j>=0?b.charAt(j)-'0':0;
int sum=digitA+digitB+carry;
carry=sum>=2?1:0;
int digit=sum>=2?sum-2:sum;
sb.append(digit);
i--;
j--;
}
return sb.reverse().toString();
}
}
415.字符串相加
方法同上,只不过改成了十进制加法
class Solution {
public String addStrings(String num1, String num2) {
int i=num1.length()-1;
int j=num2.length()-1;
int carry=0;
StringBuilder sb=new StringBuilder();
while(i>=0||j>=0){
int digitA=i>=0?num1.charAt(i)-'0':0;
int digitB=j>=0?num2.charAt(j)-'0':0;
int sum=digitA+digitB+carry;
int digit=sum%10;
carry=sum/10;
sb.append(digit);
i--;
j--;
}
if(carry!=0) sb.append(carry);
return sb.reverse().toString();
}
}
989.数组形式的整数加法
class Solution {
public List<Integer> addToArrayForm(int[] num, int k) {
int i=num.length-1;
int carry=0;
LinkedList<Integer> ans=new LinkedList<>();
while(i>=0||k!=0){
int digitA=i>=0?num[i]:0;
int digitB=k!=0?k%10:0;
int sum=digitA+digitB+carry;
int digit=sum%10;
carry=sum/10;
//直接加到首位
ans.add(0,digit);
i--;
k/=10;
}
if(carry!=0) ans.add(0,carry);
return ans;
}
}
2.两数相加
和我的另一篇文章算法学习-位运算以及进制表示有关的问题,让脑袋像机器一样思考得到光荣进化采用同样的加法模板,不过这里是在链表上进行了应用,包含了链表的创建操作。这题是逆序存储,链表头寸数字最低位,直接从头往后加就行了。
/**
* 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 addTwoNumbers(ListNode l1, ListNode l2) {
int carry=0;
ListNode dummyHead=new ListNode(0);
ListNode cur=dummyHead;
while(l1!=null||l2!=null){
int digitA=l1!=null?l1.val:0;
int digitB=l2!=null?l2.val:0;
int sum=digitA+digitB+carry;
int digit=sum%10;
carry=sum/10;
cur.next=new ListNode(digit);
cur=cur.next;
if(l1!=null) l1=l1.next;
if(l2!=null) l2=l2.next;
}
if(carry!=0) cur.next=new ListNode(carry);
return dummyHead.next;
}
}
445.两数相加II
这题链表头存的是最高位,为了从最低位开始加起,需要先拿链表反存一下,由于先算出来的是最低位结果,又需要采用头插法存储结果。
/**
* 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 addTwoNumbers(ListNode l1, ListNode l2) {
Stack<Integer> st1=new Stack<>();
Stack<Integer> st2=new Stack<>();
while(l1!=null){
st1.push(l1.val);
l1=l1.next;
}
while(l2!=null){
st2.push(l2.val);
l2=l2.next;
}
int carry=0;
ListNode head=null;
while(!st1.isEmpty()||!st2.isEmpty()){
int digitA=!st1.isEmpty()?st1.peek():0;
int digitB=!st2.isEmpty()?st2.peek():0;
int sum=digitA+digitB+carry;
int digit=sum%10;
carry=sum/10;
ListNode newNode=new ListNode(digit);
newNode.next=head;
head=newNode;
if(!st1.isEmpty())st1.pop();
if(!st2.isEmpty())st2.pop();
}
if(carry!=0){
ListNode newNode=new ListNode(carry);
newNode.next=head;
head=newNode;
}
return head;
}
}
165.比较版本号
参考了加法模板,对两个字符串中的数字进行遍历比较,不够的补0.
class Solution {
public int compareVersion(String version1, String version2) {
String[]v1=version1.split("\\.");
String[]v2=version2.split("\\.");
int len1=v1.length;
int len2=v2.length;
int i=0;
int j=0;
while(i<len1||j<len2){
int a=i<len1?Integer.valueOf(v1[i]):0;
int b=j<len2?Integer.valueOf(v2[j]):0;
if(a==b){
i++;
j++;
continue;
}
return Integer.compare(a,b);
}
return 0;
}
}
809.情感丰富的文字
参考了比较模板,这题的思路主要是两条字符串都从前往后遍历,将没法扩展的情况直接return false
。至于能否拓展,主要还是看两者的字符以及对应的数量,比较好的实现方式是,直接把每个字符串中相同字符的数量也统计出来,从而判断拓展性。
class Solution {
public int expressiveWords(String s, String[] words) {
if(s.length()==0||words.length==0) return 0;
int cnt=0;
for(String w:words){
if(isStrechy(s,w)) cnt++;
}
return cnt;
}
//判断b能否拓展成a
public boolean isStrechy(String a,String b){
int len1=a.length();
int len2=b.length();
int i=0;
int j=0;
while(i<len1&&j<len2){
char c1=a.charAt(i);
char c2=b.charAt(j);
int cnt1=0;
int cnt2=0;
while(i<len1&&a.charAt(i)==c1){
i++;
cnt1++;
}
while(j<len2&&b.charAt(j)==c2){
j++;
cnt2++;
}
if(c1!=c2||cnt1<cnt2||cnt1!=cnt2&&cnt1<3) return false;
}
//当一个字符串中出现另一个字符串中没有出现的字符时,也没法扩张
return i==len1&&j==len2;
}
}
2337.移动片段得到字符串
参考灵神的视频讲解,简单地来说就是比较两个字符串L、R的数量和相对位置,只要满足位置条件,就不用关心究竟是什么方式换的,一定就可以通过交换转变过来。需要理解的是,L只能往左移,R只能往右移,虽然题目说的是在空格两侧交换,但其实不关心实际操作的时候究竟和哪个空格交换最后变成什么字符串,只需要在遇到相同字符的时候,target的L一定在end的左边或者本身的位置,target的R一定在end的右边或者本身的位置。双指针不同于双循环,时间复杂度为O(N)
。
class Solution:
def canChange(self, start: str, target: str) -> bool:
# 去除'_'对应序列一定一致,包括相对位置和数目
if start.replace('_','')!=target.replace('_',''):
return False
j=0
for i,c in enumerate(start):
if c=='_': continue
while target[j]=='_': j+=1
# 找到的一定是start[i]==target[j]
if target[j]=='L':
if i<j: return False
if target[j]=='R':
if i>j: return False
j +=1
return True
777.在LR字符串中交换相邻字符
解法同上一题。
class Solution:
def canTransform(self, start: str, end: str) -> bool:
# 去除X对应序列一定一致
if start.replace('X','')!=end.replace('X',''):
return False
j=0
for i,c in enumerate(start):
if c=='X':continue
while end[j]=='X':j+=1
if end[j]=='L':
if i<j: return False
if end[j]=='R':
if i>j: return False
j+=1
return True