目录
前言
一、双指针法的定义:所谓双指针法,就是利用两个指针(不一定是真指针,能储存相应元素的位置就行)分别标识两个位置,然后通过指针所指元素的性质对数组(或者链表结点)进行修改,同时移动指针完成目标的方法。
二、双指针法的四种使用方法:
- 用快指针指向符合条件的数据填充慢指针指向的位置;
- 两个指针指向的元素相互比较,选出符合条件 的元素填入指定容器;
- 解决一遍遍历解决不了但两遍或多次遍历能解决的问题;
- 通过快慢指针的移动以及两者的相遇选出的特定条件;
三、暴力算法和双指针算法有什么区别:
- 暴力算法每次在第二层遍历的时候,是会重新开始(第二层循环的 j 会回溯的初始位置),然后再遍历下去。
- 双指针算法:由于某种单调性,每次再第二层遍历的时候,不需要回溯到初始位置(单调性),而是在满足要求的位置继续走下去或者更新掉。
多说无益,我们接下来用五个具体的例子来展示如何运用双指针法。
例题
1.移除元素
力扣https://leetcode.cn/problems/remove-element/
暴力解法
class Solution {
public:
int removeElement(vector<int>& nums, int val) {
int size=nums.size();
for(i=0;i<size;i++)
{
if(nums[i]==val)
{
for(j=i+1;j<size;j++)
{
nums[j-1]=nums[j]; //数组不能像链表一样删除元素,只能进行覆盖
}
i--; //因为下标i以后的数值都向前移了一位,所以i也要向前移动一位
size--; //删除了一个元素,此时数组的长度-1
}
}
return size;
}
};
- 时间复杂度:O(n)。
- 空间复杂度:O(1)。
双指针法
通过一个快指针和一个慢指针在一个for循环内完成两个for循环的工作。
移除元素的过程如图所示:
class Solution {
public:
int removeElement(vector<int>& nums, int val) {
int slowindex=0;
for(int fastindex=0;i<nums.size();fastindex++)
{
if(val!=nums[fastindex]) //当快指针指向被删元素时,if内语句不执行,快指针比慢指针
{ //快一步则用快指针指向的元素覆盖慢指针指向的元素。
nums[slowindex++]=nums[fastindex];
}
}
return slowindex;
}
};
- 时间复杂度:O(n)。
- 空间复杂度:O(1)。
2.长度最小的数组
力扣https://leetcode.cn/problems/minimum-size-subarray-sum/
思路(滑动窗口)
本题需要运用一种双指针法的特殊用法——滑动窗口。
所谓滑动窗口,就是不断的调节子数组的起始位置和终止位置,从而得出我们想要的结果。以题目描述中的情况为例,查找的过程如图所示:
- 窗口内的元素:保持窗口内数值总和大于或等于s的长度最小的连续子数组;
- 移动窗口的起始位置:如果当前窗口的值大于s。则窗口向前移动(也就是窗口该缩小了);
- 移动窗口的结束位置:窗口的结束位置就是for循环遍历数组 的指针;
class Solution {
public:
int minSubArrayLen(int target, vector<int>& nums) {
int result=INT32_MAX;
int sum=0; //滑动窗口的数值之和
int i=0; //滑动窗口的起始位置
int length=0; //滑动窗口的长度
for(int j=0;i<nums.size();j++)
{
sum+=nums[j];
//注意这里使用while,每次更新i以及最小的长度
while(sum>=s)
{
length=j-i+1;
result=result<length?result:length;
//这就是滑动窗口的精髓之处,不断变更i(子数组起始位置)
sum-=nums[i++];
}
}
//如果result没有被赋值,说明无符合条件的子数组
return result==INT32_MAX?0:result;
}
};
- 时间复杂度:O(n)。
- 空间复杂度:O(1)。
3.反转链表
力扣https://leetcode.cn/problems/reverse-linked-list/
思路
如果定义一个新的链表实现链表元素的反转,则是对内存空间的浪费。其实只需要改变链表next指针的指向,直接将链表反转即可,不需要重新定义一个新的链表,如图所示:
之前链表的头结点是元素1,反转之后头结点就是元素5,这里并没有添加或者删除结点,而只是改变了next指针的方向。
接下来我们看看如何利用双指针法来实现链表的反转。
- 首先定义一个cur指针,指向头结点,再定义一个pre指针,指向NULL;
- 首先使用temp指针保存cur->next结点,原因是反转操作会改变cur以及pre的指向,temp可以储存下一次反转操作cur指向的位置;
- 反转操作:cur->next指向pre;
- 更新cur和pre结点:pre=cur,cur=temp;
- 然后循环执行以上逻辑,最后cur指向NULL,循环结束,链表也反转完毕。此时pre指向的就是反转之后的新头结点;
代码如下:
class Solution {
public:
ListNode* reverseList(ListNode* head) {
ListNode *temp; //保存cur的下一个结点
ListNode *cur=head;
ListNode *pre=NULL;
while(cur)
{ //保存cur的下一个结点,因为接下来要改变cur->next的指向了
temp=cur->next;
cur->next=pre; //反转操作
//更新pre和cur指针
pre=cur;
cur=temp;
}
return pre;
}
};
4.删除倒数第n个结点
力扣https://leetcode.cn/problems/remove-nth-node-from-end-of-list/
思路
本题是双指针用法的经典应用,如果要删除倒数第n个结点,则让fast移动n步,然后让fast和slow同时移动,直到fast指向链表末尾,删除slow所指向的结点就可以了。
删除链表中的倒数第n个结点分为如下几个步骤:
- 推荐使用虚拟头结点,这样方便处理删除实际头结点的逻辑;
- 定义fast指针和slow指针,初始值为虚拟头结点(dummyhead),删除导数第n个结点;
- fast先向前移动n+1步,原因是只有这样,fast和slow同时移动的时候slow才能指向删除结点的上一个结点(方便做删除操作);
- fast和slow同时移动,直至fast指向末尾;
- 删除slow指向的下一个结点;
代码如下:
class Solution {
public:
ListNode* removeNthFromEnd(ListNode* head, int n) {
if(head->next==NULL)
return NULL;
ListNode *dummyhead=new ListNode(0); //创建虚拟头结点
dummyhead->next=head;
ListNode *fast=dummyhead;
ListNode *slow=dummyhead;
while(n--&&fast!=nullptr)
{
fast=fast->next;
}
//fast再向前移动一步,因为需要让slow指向删除结点的上一个结点
fast=fast->next;
while(fast!=NULL)
{ //fast和slow指针同时移动
fast=fast->next;
slow=slow->next;
}
//删除slow的下一个结点,并返回原头结点
slow->next=slow->next->next;
head=dummyhead->next;
return head;
}
};
5.环形链表
力扣https://leetcode.cn/problems/linked-list-cycle-ii/
思路
本题的关键就在于两点:
- 判断链表是否有环;
- 如果有环,那么如何找到这个环的入口;
如何判断链表是否有环?
可以利用快慢指针,分别定义fast和slow指针,从头结点出发,fast指针每次移动两个结点,slow指针每次移动一个结点,如果fast和slow指针在途中相遇,则说明这个链表有环。
如何寻找环的入口?
这里需要运用一些数学知识,假设头结点到环的入口结点的结点数为x,环的入口结点到fast指针和slow指针相遇结点的结点数为y,从相遇结点到环的入口结点的结点数为z,如图所示:
当fast和slow指针相遇时,slow指针移动的节点数为x+y,fast指针移动的结点数为x+y+n(y+z),n的含义为fast指针在环内移动了n圈才遇到slow指针,y+z为一圈内的结点数。
由于fast是一次移动两步,而slow指针是一次移动一步,所以fast指针移动的结点数=2×slow指针移动的结点数,即(x+y)×2=x+y+n(y+z)。
因为要找环的入口,所以计算的是x。最后整理的公式为:x=(n-1)(y+z)+z
如何来理解这个公式呢?
当n=1的时候,x=z。这就意味着一个指针从头结点出发,另一个指针从相遇结点出发,这两个指针每次一起移动一个结点,那么这两个指针相遇的结点就是环入口结点。
其实当n>1的情况也是一样的,无非是从相遇处出发的指针多转了n-1圈,然后再遇到从头结点出发的指针。
代码如下:
class Solution {
public:
ListNode *detectCycle(ListNode *head) {
ListNode *fast=head;
ListNode *slow=head;
while(fast!=NULL&&fast->next!=NULL)
{
slow=slow->next;
fast=fast->next->next;
if(fast==slow)
{
ListNode *index1=fast;
ListNode *index2=head;
//快慢指针相遇
while(index1!=index2)
{ //从头结点和相遇点同时开始查找直至相遇
index2=index2->next;
index1=index1->next;
}
return index2; //返回环的入口
}
}
return NULL;
}
};
总结
双指针法相对于数组,在处理单链表方面的问题更具有优势。因为,要处理单链表中的某个结点,只能通过从头遍历的方式来处理,要想处理指针所指的前一个结点,只能再次从头遍历,这往往会增加算法的时间复杂度,如果利用双指针同时进行相应的移动操作,那么可以大大提高算法的效率。
总之,双指针法的精髓就在于可以时刻进行更新操作,例如滑动窗口以及反转链表的操作。