双指针技巧——初级
情景一:相向双指针
问题一:反转字符串
先从一个简单的经典问题入手:
这题我们直接上代码:
class Solution {
public:
void reverseString(vector<char>& s) {
int l=0,r=s.size()-1;
while(l<r)
{
swap(s[l++],s[r--]);
}
}
};
思路很简单,左右指针分别指向左右两端,然后分别交换所指向的值,再移动两个指针,注意while里的条件,l<r和l<=r都是可行的,如果出现l<=r说明数组长度为奇数,否则为偶数,但无论奇偶都不会影响最终结果。
这种思路当然不止用于反转,还可以用于判断是否为回文串之类的问题
问题二:输入有序数组
好了,我们现在提高点难度
letcode的翻译有时候会有一点小毛病,这里的非递减的意思就是升序排列。题目的大概意思就是要我们从数组中找到两个数,让这两个数的和为目标值target,然后返回两个数所在的下标,注意题目的小标是从1开始的,所以使用c++的时候下标还要+1。题目只有一个答案,也降低了难度。好了,题目解读完毕,开始思考题目如何做。
方法1——二分查找
在数组中找到两个数,使得它们的和等于目标值,可以首先固定一个数,然后寻找第二个数,第二个数等于target减去被固定的数。利用数组的有序性质,可以通过二分查找的方法寻找第二个数。为了避免重复寻找,在寻找第二个数时,只在第一个数的右侧寻找,查找的时候我们以左闭右闭为循环不变量。
不清楚二分循环不变量是什么的话,可以看看这篇文章二分查找总结二
代码如下:
class Solution {
public:
vector<int> twoSum(vector<int>& numbers, int target) {
for (int i = 0; i < numbers.size(); ++i) {
int low = i + 1, high = numbers.size() - 1;//在固定数的右侧进行查找
while (low <= high) //循环不变量,左闭右闭
{
int mid = (high - low) / 2 + low;
if (numbers[mid] == target - numbers[i]) {
return {i + 1, mid + 1};
} else if (numbers[mid] > target - numbers[i]) {
high = mid - 1;
} else {
low = mid + 1;
}
}
}
return {-1, -1};
}
};
方法2——双指针
我们注意到,二分的方法时间复杂度为nlgn,我们是否能设计出一个算法,让时间复杂度降低到n呢?这就是我们的双指针方法,我本来想要用逐渐深入的方法来讨论为什么可以用双指针,但发现都有点牵强,这里就直接将讲思路,各位自行理解其中的可行性。如果实在觉得难以理解可以画图来模拟一下。
初始时两个指针分别指向第一个元素位置和最后一个元素的位置。每次计算两个指针指向的两个元素之和,并和目标值比较。如果两个元素之和等于目标值,则发现了唯一解。如果两个元素之和小于目标值,我们需要将两数之和变大,也就是将左侧指针右移一位。如果两个元素之和大于目标值,我们需要将两数之和变小,则将右侧指针左移一位。移动指针之后,重复上述操作,直到找到答案
大致想法就是,我们不断更新两数之和,如果两数之和大于目标值,减小两数之和,反之增加。
代码如下:
class Solution {
public:
vector<int> twoSum(vector<int>& numbers, int target) {
int low = 0, high = numbers.size() - 1;
while (low < high) {
int sum = numbers[low] + numbers[high];
if (sum == target) {
return {low + 1, high + 1};
} else if (sum < target) {
++low;
} else {
--high;
}
}
return {-1,-1};
}
};
最终优化
上面两个代码都是官方代码,但是我们还可以把第二个代码优化一下,举个例子,我们现在得到了两数之和大于target,我们需要移动左指针直到出现两数之和小于等于target,这相当于一个遍历查找的过程,既然是查找,那我们可以自然地将其转换为二分查找,这样最好情况下时间复杂度lgn,最坏是n,代码如下:
class Solution {
public:
vector<int> twoSum(vector<int>& numbers, int target) {
int l = 0, r = numbers.size() - 1;
while (l < r) {
int m = l+(r-l)/2;
if (numbers[l] + numbers[m] > target)
{
r = m - 1;
}
else if (numbers[m] + numbers[r] < target)
{
l = m + 1;
}
else if (numbers[l] + numbers[r] > target)
{
r--;
}
else if (numbers[l] + numbers[r] < target)
{
l++;
}
else
{
break;
}
}
return {l+1,r+1};
}
};
问题三:有效数组的平方
其实相向双指针不止上面的从两端向中心缩小范围,还有一种从中心向两端扩展的情况,我们下面这个问题就是这个情况
方法1——暴力
最简单的方法就是平方后排序,遍历平方时间n,排序nlgn,时间复杂度为nlgn,因为排序要用到lgn的栈空间,所以空间复杂度为lgn
class Solution {
public:
vector<int> sortedSquares(vector<int>& nums) {
vector<int> ans;
for (int num: nums) {
ans.push_back(num * num);
}
sort(ans.begin(), ans.end());
return ans;
}
};
方法2——中心向两端扩展的双指针
方法一中没有使用的一个条件是数组已经按升序排列,注意平方后原来的值越接近0的数越小,设数组长度为len,那么我们是否就可以定义两个指针left和right,将left和right放到一个指定位置,使得[0,left]的区间内都是负数,[right,len-1]都是非负数,或者让左区间都是非正数,右区间都是正数呢?这一步操作完之后,注意我们现在平方后的数组,从left出发向左递增,从right出发向右递增,这就是我们熟悉的归并了,只不过指针在一个数组且不同向罢了。
class Solution {
public:
vector<int> sortedSquares(vector<int>& nums) {
int n = nums.size();
int negative = -1;
for (int i = 0; i < n; ++i) {
if (nums[i] < 0) {
negative = i;
} else {
break;
}
}
vector<int> ans;
int i = negative, j = negative + 1;
while (i >= 0 || j < n) {
if (i < 0) {
ans.push_back(nums[j] * nums[j]);
++j;
}
else if (j == n) {
ans.push_back(nums[i] * nums[i]);
--i;
}
else if (nums[i] * nums[i] < nums[j] * nums[j]) {
ans.push_back(nums[i] * nums[i]);
--i;
}
else {
ans.push_back(nums[j] * nums[j]);
++j;
}
}
return ans;
}
};
方法3——两端向中心缩小的双指针
同样地,我们可以使用两个指针分别指向位置 0 和 len-1,每次比较两个指针对应的数,选择较大的那个逆序放入答案并移动指针。这种方法无需处理某一指针移动至边界的情况,比方法二更加优越
class Solution {
public:
vector<int> sortedSquares(vector<int>& nums) {
int n = nums.size();
vector<int> ans(n);
for (int i = 0, j = n - 1, pos = n - 1; i <= j;) {
if (nums[i] * nums[i] > nums[j] * nums[j]) {
ans[pos] = nums[i] * nums[i];
++i;
}
else {
ans[pos] = nums[j] * nums[j];
--j;
}
--pos;
}
return ans;
}
};
情景二:同向双指针
相对于情景一来说,情景二的同向双指针出现的频率也不小
老规矩,我们先从简单的题入手
问题四:移除元素
方法1——暴力解法
有的同志可能说,多余的元素,删掉不就得了。
要知道数组的元素在内存地址中是连续的,不能单独删除数组中的某个元素,只能覆盖
注意题目的意思,这里的移除并不是真正意义上的移除,而是将和val值相同的元素丢到数组后面去,再通过返回长度,实现模拟移除。
这个题目暴力的解法就是两层for循环,一个for循环遍历数组元素 ,第二个for循环更新数组。
这是个简单题,用letcode的难度和两个有关,能否解题和时间限制,这是个简单题,对时间要求不高,所以暴力能过
class Solution {
public:
int removeElement(vector<int>& nums, int val) {
int size = nums.size();
for (int i = 0; i < size; i++) {
if (nums[i] == val) { // 发现需要移除的元素,就将数组集体向前移动一位
for (int j = i + 1; j < size; j++) {
nums[j - 1] = nums[j];
}
i--; // 因为下标i以后的数值都向前移动了一位,所以i也向前移动一位
size--; // 此时数组的大小-1
}
}
return size;
}
};
方法2——快慢指针
好了,我们现在使用双指针解题,同向双指针中,我们使用的基本都是快慢双指针,快慢双指针一般包括三种:
- 慢指针行走收受到条件限制比快指针苛刻导致慢指针落后或者等于快指针
- 慢指针的步长比快指针小导致慢指针落后
- 慢指针一开始就落后快指针且步长相同
无论是前面哪个情况,注意我们使用双指针的原因:通过一个快指针和慢指针在一个for循环下完成两个for循环的工作。
我们这里是第一种情况:
定义快慢指针
- 快指针:寻找新数组的元素 ,新数组就是不含有目标元素的数组
- 慢指针:指向更新 新数组下标的位置
这里我们再次强调循环不变量,可以这么说,抓住循环不变量,你的代码通过率会大幅度提升,没抓住,最常见的就是溢出和死循环了。
我们这里可以抓两种循环不变量:
- 我们取[0,l]为我们的新数组区间,那么我们l的初始值就是-1,因为初始的时候新数组中没有任何一个元素,fast从0开始寻找可以加入新数组的元素
- 取[0,l)为新数组区间,那么l初始值为0,fast一样
OK,分析到这里,其余细节我们在代码中理会
第一种循环不变量
class Solution {
public:
int removeElement(vector<int>& nums, int val) {
int low=-1,fast=0,len=nums.size();
while(fast<len)
{
if(nums[fast]!=val)
{
swap(nums[++low],nums[fast]);
}
fast++;
}
return low+1;
}
};
第二种循环不变量
class Solution {
public:
int removeElement(vector<int>& nums, int val) {
int low,fast,len=nums.size();
low=fast=0;
while(fast<len)
{
if(nums[fast]!=val)
{
nums[low++]=nums[fast];
}
fast++;
}
return low;
}
};
方法3——相向双指针
题目中指出可以改变元素相对位置,那么我们还可以采用相向指针求解,下面给出代码
class Solution {
public:
int removeElement(vector<int>& nums, int val) {
int l=0,r=nums.size()-1;
while(l<=r)
{
if(nums[l]==val&&nums[r]!=val)
{
nums[l]=nums[r];
r--;
l++;
}
else
{
if(nums[r]==val)
r--;
if(nums[l]!=val)
l++;
}
}
return l;
}
};
快慢指针的其他两种情况
快慢指针还有两种情况,此处给出两个题目及其代码,不做过多解释
问题五:链表的中间节点
这是快慢指针的第二种情况,步长不一样,我们这里要求中间节点,那么就让步长比为2:1
/**
* 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* middleNode(ListNode* head) {
ListNode *low=head,*fast=head;
while(fast!=nullptr&&fast->next!=nullptr)
{
low=low->next;
fast=fast->next->next;
}
return low;
}
};
问题六:删除链表的倒数第n个节点
在此之前,我们讲一个小技巧,以后做题可能遇见
链表的哑节点技巧
在对链表进行操作时,一种常用的技巧是添加一个哑节点(dummy node),它的 next 指针指向链表的头节点。这样一来,我们就不需要对头节点进行特殊的判断了。
可能有同学就会说,这不就是带头结点的链表嘛!可能在很多参考书上都是会把这种带哑节点的链表叫做带头结点的链表,不带的叫做不带头结点的链表,但是在实际使用链表的时候,我们基本使用的都是不带的,也就是没有以上带不带头结点一说。
例如,在本题中,如果我们要删除节点 p,我们需要知道节点 p的前驱节点 q,并将 q 的指针指向 p的后继节点。但由于头节点不存在前驱节点,因此我们需要在删除头节点时进行特殊判断。但如果我们添加了哑节点,那么头节点的前驱节点就是哑节点本身,此时我们就只需要考虑通用的情况即可。
在c++等一些语言中,动态开辟的内存需要自己释放,虽然做题的时候一般无伤大雅,但这是一个很好的习惯,避免内存泄露,人人有责。但是下面的代码没有释放,各位写的时候注意一下这个问题,如果对自己有要求的还是建议释放了。
当然,如果考虑释放,代码书写顺序就要注意了,有些讲究,可以研究下怎么写更加简洁,这个问题留待各位思考
方法1:两次遍历
我们最容易想到的是先遍历一次求得链表长度,再遍历一次求得链表的倒数第n个节点,最后删除即可
class Solution {
public:
int getLength(ListNode* head) {
int length = 0;
while (head) {
++length;
head = head->next;
}
return length;
}
ListNode* removeNthFromEnd(ListNode* head, int n) {
ListNode* dummy = new ListNode(0, head);
int length = getLength(head);
ListNode* cur = dummy;
for (int i = 1; i < length - n + 1; ++i) {
cur = cur->next;
}
cur->next = cur->next->next;
ListNode* ans = dummy->next;
delete dummy;
return ans;
}
};
方法2:距离恒定的双指针
这就是双指针的第三种情况了。
这也是双指针的经典应用,如果要删除倒数第n个节点,让fast移动n步,然后让fast和slow同时移动,直到fast指向链表末尾。删掉slow所指向的节点就可以了。
思路是这样的,注意一些细节就行了。
class Solution {
public:
ListNode* removeNthFromEnd(ListNode* head, int n) {
ListNode* dummyHead = new ListNode(0);
dummyHead->next = head;
ListNode* slow = dummyHead;
ListNode* fast = dummyHead;
while(n-- && fast != NULL) {
fast = fast->next;
}
fast = fast->next; // fast再提前走一步,因为需要让slow指向删除节点的上一个节点
while (fast != NULL) {
fast = fast->next;
slow = slow->next;
}
slow->next = slow->next->next;
return dummyHead->next;
}
};