双指针,指的是在遍历对象的过程中,不是普通的使用单个指针进行访问,而是使用两个相同方向(快慢指针)或者相反方向(对撞指针)的指针进行扫描,从而达到相应的目的。
1,快慢指针扫描过程中,一般有两种情况
1)快指针进行逐个扫描,慢指针依赖快指针进行更新,慢指针则作为一个记录变量辅助完成对快指针扫描到的元素的操作,或者说慢指针只是依赖于快指针单纯进行更新,等到循环的最后才会使用到更新完毕的慢指针的值。(即:快指针与慢指针之间有一个核心的关系,这个关系决定了慢指针如何更新)例如leetcode第206题:反转链表;leetcode第27题:移除元素
2)快指针扫描步长>慢指针扫描步长,从而达到一些目的
总之,快慢指针一般用于处理整个数组或者链表对象,最后使其形成新的对象。例如leetcode第142题:环形链表II
2,对撞指针适用于有序数组,一般用于从整个数组或者链表当中提取出符合条件的元组,即更加快速地提取出数组当中的数据。例如leetcode第15题:三数之和;leetcode第18题:四数之和;LeetCode第881题:救生艇
总结:双指针法充分使用了数组有序这一特征, 从而在某些情况下能够简化一些运算。
下面分别通过实践这些题目深入认识双指针算法。
给你单链表的头节点 head ,请你反转链表,并返回反转后的链表。
观察这个链表,我们发现只需要让每一个节点的指针的next指向都改变为指向他的前节点,该链表就可以完成反转。我们可以使用O(n)的方法来解决这道题,使用双指针的思路。
1,其中now指针沿着链表依次遍历每一个元素,而per指针则记录now指针的上一个元素。
2,在每次循环遍历到某个元素的时候,只需要让now->next指向per,就完成了此处的链表反转,然后再让新的per=now,处理下一个元素。
3,需要注意的是,下一次循环处理的下一个节点不能通过now->next找到了,因为我们在处理过程中已经改变了now->next的指向,因此在本次处理now元素之前,首先要用临时变量存储now->next。方便我们进行下一次循环。
(注意,题目中注释里面给的链表一个节点的样子,我们默认这个链表是不带头节点的)
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode() : val(0), next(nullptr) {}
* ListNode(int x) : val(x), next(nullptr) {}
* ListNode(int x, ListNode *next) : val(x), next(next) {}
* };
*/
class Solution {
public:
ListNode* reverseList(ListNode* head) {
ListNode* per=nullptr;
ListNode* now=head;
ListNode* tmp;
while(now!=nullptr){
tmp=now->next;
now->next=per;
per=now;
now=tmp;
}
head=per;
return head;
}
};
本题中使用到的双指针可以认为是快慢指针,其中快指针为now,而慢指针为per;快指针的目的是依次遍历链表元素,而慢指针记录了now->父亲的值,在本题中是为了辅助完成对now指针指向的元素的操作。
给你一个数组 nums 和一个值 val,你需要 原地 移除所有数值等于 val 的元素,并返回移除后数组的新长度。不要使用额外的数组空间,你必须仅使用 O(1) 额外空间并 原地 修改输入数组。元素的顺序可以改变。你不需要考虑数组中超出新长度后面的元素。
说明:
为什么返回数值是整数,但输出的答案是数组呢?
请注意,输入数组是以「引用」方式传递的,这意味着在函数里修改输入数组对于调用者是可见的。
你可以想象内部操作如下:
这道题目的要求比较简单,总的来说就是要求读者删除数组当中的指定元素,返回最后得到的数组长度以及数组内容即可。——使用暴力算法即可完成,算法复杂度为O(n^2)。需要注意的是,这是一个数组,数组的元素在内存地址中是连续的,不能单独删除数组中的某个元素,只能覆盖。
伪代码如下:
for(i第一层循环依次遍历数组元素到size)
{
找到要删除的那个元素;
for(j=i;从该元素开始)
{
将后面的所有元素向前面移动一个单位;(即覆盖了要删除的元素)
}
i--; // 因为下表i以后的数值都向前移动了一位,所以i也向前移动一位
size--;
}
暴力算法在leetcode上面是可以通过的,当然,其缺点也非常明显,他的算法复杂度为o(N^2)。那么,有没有办法重新设计一个算法复杂度为O(N)的算法呢?
我们思考这样一个问题:要删除数组当中的一个元素是必须得覆盖,但我们非要按照暴力解法的思想,每遇到要删除的元素都要将其后面的所有元素进行移位,然后再判断下一个元素要不要删除吗?其实不必,可以边遍历边进行元素覆盖,这就需要除了用于遍历的指针(快指针)之外的另一个指针(慢指针),该指针指向等待被覆盖的元素,当快指针遇到不用删除的元素,即可用其对慢指针所指向的位置进行覆盖。
class Solution {
public:
int removeElement(vector<int>& nums, int val) {
int slow=0;
for(int fast=0;fast<nums.size();fast++)
{
if(nums[fast]!=val)
{
nums[slow]=nums[fast];
slow++;
}
}
return slow;
}
};
本题中的快指针用于遍历,而慢指针用于记录快指针遍历过程中本次循环不能处理而必须等待下次循环处理的数,当快指针遇到可以处理上次出现问题的地方的元素的时候,用慢指针进行处理。
给定一个链表的头节点 head ,返回链表开始入环的第一个节点。 如果链表无环,则返回 null。如果链表中有某个节点,可以通过连续跟踪 next 指针再次到达,则链表中存在环。 为了表示给定链表中的环,评测系统内部使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。如果 pos 是 -1,则在该链表中没有环。注意:pos 不作为参数进行传递,仅仅是为了标识链表的实际情况。不允许修改链表。
这道题目不仅考察使用快慢指针判断链表中环路的操作,还考察一些数学知识以便找出环路的起始节点。
1,判断环路操作:
使用快慢指针,让快慢指针从头节点开始,快指针每一次往后走两步,慢指针每一次往后走一步。如果该链表中存在回路的话,那么快指针一定不会走到nullptr,并且由于链表中存在回路,即使快指针很快,因为他走了回路又走了回来,这就导致慢指针迟早在某一个节点处与快指针相遇,即慢指针追上了快指针。
2,判断回路的起始节点方法:
如图所示为该链表一个示意图,其中A点为链表开始的地方,B点为环形入口的地方,C点为快慢指针相遇的地方。
1)对于快指针来说,他可能从A开始,绕着这个环转了n圈之后被慢指针追上,在C点相遇,那么快指针所走的长度为x+n(y+z)
,而慢指针走过的长度为x+y
。
2)因为fast指针是slow指针走的节点数的2倍,所以有(x+y)*2=x+y+n(y+z)
化简这个式子为x+y=n(y+z)
,因为要求的是环的入口,即要求x的长度x=n(y+z)-y
.,再进行化简,即x=(n-1)(y+z)+z
。我们接下来就要去使用这个式子。
3)那么这个式子能够说明什么问题?
这个式子说明x的长度=z的长度+(n-1)圈环的长度。也就是说,如果用一个指针p1从该链表头节点出发,另一个指针p2从快慢指针相遇点出发,不用管p2沿着这个环转了多少圈,最终p1和p2指针终会在这个环的入口相遇,此时p1走过了x个节点,而p2走过了(n-1)(y+z)+z个节点。
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode(int x) : val(x), next(NULL) {}
* };
*/
class Solution {
public:
ListNode *detectCycle(ListNode *head) {
ListNode * fast=head;
ListNode * slow=head;
while(fast!=nullptr&&fast->next!=nullptr)
{
fast=fast->next->next;
slow=slow->next;
ListNode* yu;
if(slow==fast)//说明两者相遇
{
yu=fast;
ListNode* p=head;
while(p!=yu)
{
p=p->next;
yu=yu->next;
}
return p;
}
}
return nullptr;
}
};
该题中使用到了双指针中的快慢指针,这里的快指针不像前面的两道题一样是用于遍历的主指针,慢指针作为辅助;而是专门给两个指针分别定义了不同的速度,是名副其实的快慢指针,在本题中这种指针就达到了寻找环的功效。同时,为了判断该链表中的环的入口,我们具体分析了这个快慢指针的行走过程,通过分析数学过程找到了一个等式关系,这个等式关系帮助我们找到了环的入口,这个思想值得我们吸收。
对撞指针一般适用于有序数组,开始时两个指针分别指向数组中相对的最小值和最大值,在程序执行过程中根据要寻找的目标选择移动大指针向小的地方移动还是选择移动小指针向大的地方移动,具体看下面一个三数之和的例子。
给你一个包含 n 个整数的数组 nums,判断 nums 中是否存在三个元素 a,b,c ,使得 a + b + c = 0 ?请你找出所有和为 0 且不重复的三元组。
注意:答案中不可以包含重复的三元组。
如果用暴力算法,需要进行三次循环,算法复杂度为O(n^3),相当于遍历所有的可能出现的元素组合。而且题目要求剔除重复的三元组,因此去重是一个比较麻烦的问题。这里想到用双指针中的对撞指针。(双指针一般可以相对原暴力算法降低一个数量级的算法复杂度),该算法复杂度为O(n ^2)。
1,首先对nums数组由小到大进行排序;
2,从头到尾遍历数组,当遍历到第i个元素时:
1)令left指针为i+1;令right指针为nums.size()-1;
2)如果left
如果c<0;代表目前三数之和太大了可以尝试让left指针向右移动,然后返回2)再次比较c和0的大小。
如果c=0;说明我们目前遍历出来的这个三元组满足条件,保存该三元组即可,让后让right–,继续返回2)进行判断。(也可以让left++,甚至可以同时让left++与right–,总之这三个操作都是继续判断下一个三元组的操作,因为数组是有序的,原来的nums[i]+nms[left]+nums[right]已经=0了,而nums[right-1]<=nums[right],所以新的nums[i]+nms[left]+nums[right]一定要么还=0,这时候找到的三元组和上一个三元组一致,要么<0,不符合条件,指针接着移动;left同理;)
总结:读者阅读1,2步骤应该不会觉得困难,这个逻辑是完全可以找到三数之和满足条件的三元组的,关键是读者应该理解,这种方法同样也是遍历到了所有的三数组合,也就是说通过该方法找到的三元组是没有遗漏的。理解了这一点,接下来我们开始关注如何在搜寻满足条件的三元组的过程中对其进行去重。
去重思路如下:
首先最简单的思路是在搜寻到满足条件的三元组以后,和前面所有搜寻到的满足条件的三元组进行对比,如果已经有重复的,则不去保存。当然,这种去重思路在leetcode中会超时。那么我们如何针对我们处理这道题的算法逻辑进行去重呢?
1,首先,这个数组已经进行了升序排列,因此,如果我们遍历到nums[i]>0,既可以不用处理后面的逻辑,因为后面的不管是nums[left]或者nums[right],他们的和肯定不满足=0这个要求。
2,在处理第一个数为nums[i]这个元素三元组的这轮循环时,若nums[i]元素与nums[i-1]元素的大小一致,则此次循环要么找不到符合条件的三元组,要么找到的符合条件的三元组一定和上次循环得到的部分三元组一致,因此没必要处理此次循环,直接进入下一次循环。
3,当筛选出nums[i],nums[left],nums[right]这个三元组时,如果发现right左边的元素=right元素,或者left右边的元素=left元素,则程序接着运行下去的话仍然可能会找到已经找到的三元组,因此我们可以让程序继续执行之前先判断一下right左边的元素与left右边的元素,让right与left走到与原来right与left所指元素不同的位置上,然后继续程序。
class Solution {
public:
vector<vector<int>> threeSum(vector<int>& nums) {
vector<vector<int>> ans;
std::sort(nums.begin(),nums.end());
int a;
int b;
for(int i=0;i<nums.size();i++)
{
b=nums.size()-1;
a=i+1;
if(nums[i]>0) continue;
if(i>=1&&nums[i]==nums[i-1]) continue;
while(a!=b&&a<b)
{
int c=nums[i]+nums[a]+nums[b];
if(c<0)
a++;
else if(c>0)
b--;
else
{
vector<int>tmp;
tmp.push_back(nums[i]);
tmp.push_back(nums[a]);
tmp.push_back(nums[b]);
ans.push_back(tmp);
while(a<b&&nums[b]==nums[b-1]) b--;
while(a<b&&nums[a]==nums[a+1]) a++;
b--;
a++;
}
}
}
return ans;
}
};
这道题很好的诠释了对撞指针的使用场景,可以方便读者更加具体地了解对撞指针这种技巧,同时读者也可以吸收本道题目中如何去重的思想,即一定要深刻理解程序每一步是怎么运行,可能会有怎样的处理结果,哪些运行虽然会让程序继续遍历从而运行下去,但是运行的过程又和上一轮有多么相似,从而得到的结果也同样有部分一致,找到程序运行中什么情况可能会出现这样一段过程,我们就得考虑在这种情况下进行减枝。
补充:四数之和和三数之和的算法类似,区别就是四数之和外面又多嵌套了一次循环,为O(n^3)复杂度的算法,其中最外面的两层循环就是来帮助我们遍历前两个元素分别为nums[i]和nums[j]时的情况,最里面的循环则是使用双指针来帮助我们筛选后两个元素是哪两个。总体方法类似三数之和。
给定数组 people 。people[i]表示第 i 个人的体重 ,船的数量不限,每艘船可以承载的最大重量为 limit。
每艘船最多可同时载两人,但条件是这些人的重量之和最多为 limit。
返回 承载所有人所需的最小船数 。
这道题目要求一艘船最多只能载两个人,并且要尽可能一艘船载更大的重量,因此我们很自然的想到了给这个数组排序,并且只有将最重的人和最轻的人这样搭配,这样才能让尽可能重的人有可能满足两个人乘一艘船的条件。使用双指针。 这道题目用到了对撞指针来简化算法流程,主要是使用到了对撞指针固有的处理有序数组时,分别取最小最大进行选择的思想,而正好这种思想和这道题目或者说类似的题目不谋而合,这也是我们在学习算法的时候要多做练习的原因,只有练习地多了,见多了思路,见到题目时才能够正确梳理。另外关于这道题目我个人在编程的时候还遇到一个小问题,读者有兴趣可以继续阅读一下。 我们现实生活中遇到一大堆事情,很多人一般都是选择尽量先将最困难的事情先做了,然后再去做剩下简单的事情。这个思维可能会对我们设计算法产生一定的影响,比如对于我来说,当我在选择让这个目前最重的人(right)和最轻的人(left)一起走这个方案时,我会思考有没有可能最重的人(right)和次轻的人(left+1)也可以一起走呢?也就是说nums[right]+nums[left+1]同样
刚开始left指向索引0,right指向索引nums.size()-1,即left指向最轻个体,right指向最重个体。
当left<=right时,进行如下循环。
0,当left=right时,说明循环进行到最后了,left已经等于right了,即只剩下了这一个人,让他单独乘船即可。
1,如果nums[left]+nums[right]>limit,那就只能让right所指元素单独乘船离开,即right元素不可能和其中任何一个元素形成组合坐船。然后让right–;
2,如果nums[left]+nums[right]3.2.3 代码
class Solution {
public:
int numRescueBoats(vector<int>& people, int limit) {
sort(people.begin(),people.end());//先排序
int ans=0;
int left=0;
int right=people.size()-1;
while(left<=right)
{
if(left==right){
ans++;
break;
}
if(people[left]+people[right]>limit){
ans++;//让最重的人单独坐一条船
right--;
}
else{
ans++;
left++;
right--;
}
}
return ans;
}
};
3.2.4 总结
——————————————————————————————————————————
究其原因,我们之所以觉得有必要采用第二种思路的原因是我们考虑到现在如果条件允许,就尽可能让最重的right与次轻的left+1离开,这样后期如果我们遇到比较重的人,他或许本来和现在的left+1不能搭配,但是可以和目前这一步留下来的left进行搭配。实际上这种想法多余了,因为数组已经从小到大排列了,要出现这种意外情况,即说明到未来那一步的right和现在的left+1不能搭配才会出现意外,可是我们是在现在的left+1已经能够和目前最大的right搭配了的情况下考虑未来可能发生的意外,那就根本不存在未来的right可能和现在的left+1不搭配的情况。因为未来的right只可能比现在的right更小。这里其实在思考算法的过程中用到了反证法的思想。