双指针用法专题详解

双指针用法梳理

1.问题引入

以LeetCode的每日一题:剑指 Offer 52. 两个链表的第一个公共节点为例引入双指针算法。再对双指针算法进行后续的分析总结。

1.1 问题描述

输入两个链表,找出它们的第一个公共节点。

如下面的两个链表:
image-20210721125526785

在节点 c1 开始相交。

1.2 示例

image-20210721125610717

image-20210721125630144

注意:

  • 如果两个链表没有交点,返回 null.
  • 在返回结果后,两个链表仍须保持原有的结构。
  • 可假定整个链表结构中没有循环。
  • 程序尽量满足 O(n) 时间复杂度,且仅用 O(1) 内存。
  • 本题与主站 160 题相同:https://leetcode-cn.com/problems/intersection-of-two-linked-lists/

1.3 问题分析

对于这题就可以采用双指针的思想解决。

首先两个链表有公共节点的前提是两个链表都必须是非空,若一个为空,直接返回NULL:if(headA == NULL||headB == NULL) return NULL

在headA和headB都不为空的前提下我们才能进一步判断是否有公共的节点。做法如下:

创建两个指针ListNode* tempA = headA, ListNode* tempB = headB.

  • 每一步操作时同时更新tempAtempB
    • 对于每一步的更新需要做的是:如果tempA!=NULL,那么tempA = tempA->next,指针沿着链表后移;如果tempB!=NULLtempB = tempB->next,指针也跟着后移。
    • tempA==NULL,即移动到A的末尾了,那么tempA = headB,将tempA移动链表headB的头部。若tempB==NULL,即移动到B的末尾了,那么tempB = headA,将tempB移动到链表headA的头部
    • tempA == tempB时,返回他们的节点。

下面我们要说明的就是为什么经过上面的操作,返回的就一定是公共的节点(如果不含公共部分那么返回的是Null)

证明:

  • 情况一:两个链表相交

    设链表A长度为m,链表B长度为n,我们设A不与B公共的部分的长度为a个节点(在图中a=3),B不与A公共的部分的长度为b个节点(图中b=1),同时设A与B公共部分的长度为c(图中c=2)。那么有:a+c=m,b+c=n。

  • image-20210721164134639

​ 如果 a=b,则两个指针会同时到达两个链表的第一个公共节点,此时返回两个链表的第一个公共节点;

​ 如果 a≠b,则指针tempA会遍历完链表 headA,指针 tempB 会遍历完链表headB,两个指针不会同时到达链表的尾节点,然后指针 tempA移到链表headB 的头节点,指针tempB移到链表headA 的头节点,然后两个指针继续移动,在指针 tempA 移动了 a+c+b 次、指针 tempB 移动了 b+c+a 次之后,两个指针会同时到达两个链表的第一个公共节点,该节点也是两个指针第一次同时指向的节 点,此时返回两个链表的第一个公共节点。

  • 情况二:两个链表不相交

    还是保持上述的假设,A链表长为m,B链表长为n,如果m==n,那么二者同时到达尾部,返回NULL

    如果m≠n,则由于两个链表没有公共节点,两个指针也不会同时到达两个链表的尾节点,因此两个指针都会遍历完两个链表,在指针 tempA 移动了 m+n次、指针 tempB移动了 n+m 次之后,两个指针会同时变成空值NULL,此时返回 NULL

这就是对于上述的问题,使用双指针的解法。有效将时间复杂度避免成O( n 2 n^2 n2)

程序如下:

//双指针法 
class Solution {
public:
    ListNode *getIntersectionNode(ListNode *headA, ListNode *headB) {
        if(headA == NULL ||headB == NULL) return NULL;	//如果有一个是空的,那么直接返回NULL
		ListNode *tempA = headA, *tempB = headB;
		
		while(tempA!=tempB){	//两个指针比较就是比较他们指向的地址是否相同 
			tempA = tempA!=NULL?tempA->next:headB; 
			tempB = tempB!=NULL?tempB->next:headA;
		} 
		return tempA;
    }
};

2.双指针的其他用法

双指针是一种思想,在链表中运用较多,主要还有下面几种场合:

  • 快慢指针
    • 链表中点的计算
    • 判断链表中是否有环
    • 判断链表中环的起点
    • 求带环链表中环的长度
    • 求链表倒数第k个元素
  • 碰撞指针
    • 二分查找
    • n数之和
  • 滑动窗口
    • 字符串匹配
    • 子数组

2.1 快慢指针

2.1.1计算链表的中点
  • 问题描述

给定一个头结点为 head 的非空单链表,返回链表的中间结点。ps:如果有两个中间结点,则返回第二个中间结点。

  • 解法

双指针。其中一个是快指针:每次操作走两步,即quick = quick->next->next;另一个是慢指针:每次操作走一步,即slow = slow->next。等到快指针走到链表末尾时,慢指针刚好到链表中点。

程序如下:

class Solution{
public:
	ListNode* middleNode(ListNode* head) {
		ListNode *quickHead = head, *slowHead = head;
		while(quickHead != NULL){
			quickHead = quickHead->next->next;
			slowHead = slowHead->next;
		}
		return slowHead;
    }
};
2.1.2 环形链表
  • 问题描述

    给定一个链表,判断链表中是否有环。

    如果链表中有某个节点,可以通过连续跟踪 next 指针再次到达,则链表中存在环。 为了表示给定链表中的环,我们使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。 如果 pos 是 -1,则在该链表中没有环。注意:pos 不作为参数进行传递,仅仅是为了标识链表的实际情况。

    如果链表中存在环,则返回 true 。 否则,返回 false 。

  • 问题分析

    对于环形链表,一样利用快慢指针即可,如果快指针又再次和慢指针相遇那么链表含环。如果快指针直接走到了null,那么不含环。

    代码如下:

class Solution {
public:
    bool hasCycle(ListNode *head) {
        ListNode *quick = head, *slow = head;
        while(quick!=NULL&&quick->next!=NULL){
            quick=quick->next->next;
            slow = slow->next;
            if(quick == slow){
                return true;
            }
        }
        return false;
    }
};
2.1.3 带环链表的环长
  • 问题描述

    给定一个链表,返回链表开始入环的第一个节点。 如果链表无环,则返回 null。

    为了表示给定链表中的环,我们使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。 如果 pos 是 -1,则在该链表中没有环。注意,pos 仅仅是用于标识环的情况,并不会作为参数传递到函数中。

    说明:不允许修改给定的链表。

    进阶:

    你是否可以使用 O(1) 空间解决此题?

  • 问题分析

对于这个问题也是使用双指针来解决。具体的分析请见另一篇博客:

代码如下:

class Solition{
public:
    ListNode *detectCycle(ListNode *head) {
    	if(head == NULL){
    		return NULL;
		}
        ListNode *fast, *slow;
        fast = slow = head;
        while(true){
        	if(fast->next == NULL){
        		return NULL;
			} 
        	fast = fast->next->next;
        	slow = slow->next;
        	if( fast==NULL || slow == NULL ){	//如果根本没有环,那么结束程序,返回NULL 
        		return NULL;
			}
			if(fast == slow){	//指针相遇,跳出循环 
				break;
			}
		}
		fast = head;
		while(fast!=slow){
			fast = fast->next;
			slow = slow->next;
		}
		return slow;
    }
};
2.1.4 求链表倒数第k个元素
  • 问题描述

    输入一个链表,输出该链表中倒数第k个节点。为了符合大多数人的习惯,本题从1开始计数,即链表的尾节点是倒数第1个节点。

    例如,一个链表有 6 个节点,从头节点开始,它们的值依次是 1、2、3、4、5、6。这个链表的倒数第 3 个节点是值为 4 的节点。

  • 问题分析

倒数第k个节点和寻找链表的中点的思路是相似的,这里是使快指针比慢指针先走k步,之后保持fast和slow指针同步前进。当fast指针到达末尾时,slow指针刚好到达链表的倒数第k个节点处。

class Solution {
public:
    ListNode* getKthFromEnd(ListNode* head, int k) {
		ListNode *fast, *slow;
		fast = slow = head;
		int cnt = k;	//计数 
		while(cnt--){ 	//让快指针先走k步 
			if(fast == NULL){
			 	return NULL;
			}
			fast = fast->next;
		}
		while(fast!=NULL){
			fast = fast->next;
			slow = slow->next;
		}
		return slow;
    }
};

2.2 碰撞指针

2.2.1 二分查找

对于碰撞指针,二分查找是一种典型的碰撞的思想。这里二分查找不再介绍,直接介绍下一个n数之和。

2.2.2 n数之和

2.3滑窗法

2.3.1字符串匹配

3. 无重复字符的最长子串

  • 问题描述

给定一个字符串 s ,请你找出其中不含有重复字符的 最长子串 的长度

image-20210804132136317

  • 问题分析

对于最长子串,最长数组等问题,常用的就是双指针。对于KMP熟悉的话也能想到这一点。

思路:使用两个指针leftright,初始值都设定为字符串的初始位置,即left = right = 0right向前移动,每向前一步,判断[left, right]区间内有没有和str[left]重复的字符,若有重复的字符,首先记录下此时的right - leftmax_len,并则将right移动到区间内的重复字符的下一个位置。然后重复上述right向前移动并判重的工作。具体步骤如下图所示的①②③④。之后重复②③④直到right == str[str.size()-1]。此时的max就是最终的答案。

image-20210804143610317

在判重的时候这里使用了map,map<char, int>,将字符与他的下标联系起来存储。需要注意的就是移动left时注意将变化的区间内的字符从map中移出来。

程序如下:

#include<iostream>
#include<algorithm>
#include<map>

using namespace std;

class Solution{
public:
	int lengthOfLongestSubstring(string s) {
		if(s.size()==0){
			return 0;
		}
		int left,right;
		left = right = 0;
		int max = 1; 
		map<char, int> char2int;
		while(right<=s.size()-1&&left<=right){
			cout<<"left: "<<left<<"right: "<<right<<endl;
			if(char2int.count(s[right]) == 0){	//如果字符不在map中,说明没有重复字符
				char2int[s[right]] = right;	//那么加入map,和他的下标   map['s'] = right; 
			}else{//说明char2int[right]已经在map内
				max = max > (right - left) ? max : right-left; 	//更新max 
				cout<<"max: "<<max<<endl;
				for(int i = left; i < char2int[s[right]]; i++){
					char2int.erase(s[i]);
				}
				left = char2int[s[right]] + 1;	//重新移动left到重复字符的下一个位置 
				char2int[s[right]] = right;	//更新新的下标
				right++;
				continue;
			}
			if(right == s.size()-1){
				max = max > (right - left + 1) ? max : right-left+1;
			}
			right++;
		}
		return max; 
    }
};

int main()
{
	string str = "pwwekk";
	Solution s;
	cout<<s.lengthOfLongestSubstring(str);
	return 0;
}
2.3.2子数组

子数组问题常用的就是双指针。主要是判断出控制指针移动的条件,情况和对应的题目都比较多,慢慢补充。

例1:581. 最短无序连续子数组

给你一个整数数组 nums ,你需要找出一个 连续子数组 ,如果对这个子数组进行升序排序,那么整个数组都会变为升序排序。

请你找出符合题意的最短子数组,并输出它的长度。

  • 问题分析

题目要求找到最短的非升序子数组,并且是仅仅对这个子数组进行排序就能达到整个数组的有序。

我们可以使用两个指针:left = 0,right = nums.size()-1,两个指针相向而行:left++,right--。找到左端和右端的第一个不满足递增要求的数。假如有下面的数组:vector<int> vec = {2,7,3,1,10,6,8,9}

image-20210804093346456

我们使用left从左向右搜寻,所谓不满足递增要求,就是这个数的下一个数比这个数小。第一个不满足这样的要求的数是7(它的下一个数是3,破坏了递增性)。我们记录left = 1,num[left] = 7

对于right的搜索道理一样,我们这时候把右端看做起始端,要找的就是从右向左第一个不满足递减要求的数。即若这个数的下一个数(下一个数是从右向左看的下一个数,即坐标比它小1的数)破坏了递减性质,那么记录这个点为right。在上图中,我们记录right = 5,即nums[right] = 6

这样我们就得到了第一次搜索的一个区间:[left = 1, right = 5],即上图所示的红色区域。这个区间一定是要进行重排的数组。但是是否是对这个数组进行重排后整个数组就变得有序呢?答案是否的。因为这个区间外还有数字2比区间内的min值1大,还有数字8和9比区间内的最大值max小。仅仅对红色区域的数组进行排序是不够的,我们要在left以左和right以右对数组进行扩展

而扩展这个区间的原则如下:

先记录红色区间内的最大值max和红色区间内的最小值min,由于left是第一个违背递增原则的数字,所以left以左是严格递增的。left向左移动,找到最小的比min大的元素时停止。这里就是数字2,即left = 0。这样我们确定了左边的最大边界。

我们再确定右边的最大边界:同理right向右边移动,由于确定right时,right也是第一个违背规则的数字,所以right右边也是递增的。right不断向右移动,直到找到最大的小于max的元素时停止,这里就是数字9,即right = 7 ,nums[right] = 9。这样我们确定了右边的最大边界。

综上所述:我们得到了扩展后的边界:[left = 0,right = 7],即区域[0,7]就是最短无序连续子数组。

程序如下:

class Solution {
public:
    int findUnsortedSubarray(vector<int>& nums) {
        //两个指针,一个从左向右遍历,一个右向左遍历 
		int left = 0, right = nums.size() - 1;
		
		for(int i = 0; i<nums.size()-1&&nums[i]<=nums[i+1]; i++) left++;	//记录左边开始,第一个违反递增的元素
		for(int j = nums.size()-1; j>=1&&nums[j]>=nums[j-1]; j--) right--;	//记录从右边开始,第一个违犯递减的元素	
		if(left>=right){
			return 0;
		}
		//找到区间内的最大值和最小值 		[两个函数符合左闭右开原则]
		int max_num = *max_element(nums.begin()+left,nums.begin()+right+1); 
		int min_num = *min_element(nums.begin()+left,nums.begin()+right+1);
		
		//先扩展左边的区间,找到小于等于min_num的元素。返回其下标。
		//在扩展区间的过程中还要更新max元素。
		//同理扩展右边的区间,找到大于等于max_num的元素。返回其下标
		while(left>=0&&nums[left]>min_num){
			left--;
		}
		left++; 
		while(right<nums.size()&&nums[right]<max_num){
			right++;
		}
		right--;
		cout<<"left:"<<left<<" right:"<<right<<endl;
		return right - left + 1;
		
    }
};

注意到我们在进行左右边界的扩展的时候,并没有使用lower_bound(nums.begin(),nums.begin()+left, max_num)的形式,而是使用了循环指针左移的形式。这是因为如果采用了lower_bound()的形式,不仅非严格递增:即中间有相同数据的情况会出现程序错误,同时指针越界会造成一系列问题,所以使用while循环解决问题。

例2:1838. 最高频元素的频数

元素的 频数 是该元素在一个数组中出现的次数。

给你一个整数数组 nums 和一个整数 k 。在一步操作中,你可以选择 nums 的一个下标,并将该下标对应元素的值增加 1 。

执行最多 k 次操作后,返回数组中最高频元素的 最大可能频数 。

image-20210804164750149

  • 问题分析

这个问题也被归纳到双指针中。主要的流程就是先排序,然后从nums的末尾到开头双指针扫描,快指针只增不减,慢指针移动向前。程序如下:

class Solution {
public:
    int maxFrequency(vector<int>& nums, int k){
		sort(nums.begin(), nums.end());
		int left, right;
		left = right = nums.size()-1;
		long sum = nums.at(nums.size()-1);
		while(left>0){
			left = left - 1;
			sum += (long)nums.at(left);
			if(sum + k >= (long)nums.at(right) * (right - left + 1)){
			}
			else{
				sum = sum - nums.at(right);
				right = right - 1;
			}
		}
		return right - left + 1;
	}
};
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Blanche117

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值