一、双指针定义
常见的双指针有两种形式,⼀种是对撞指针 ,⼀种是快慢指针。
对撞指针:⼀般用于顺序结构中,也称左右指针。
- 对撞指针从两端向中间移动。⼀个指针从最左端开始,另⼀个从最右端开始,然后逐渐往中间逼近。
- 对撞指针的终止条件一般是两个指针相遇或者错开(也可能在循环内部找到结果直接跳出环),也就是:1、left == right(两个指针指向同⼀个位置) 2、left > right(两个指针错开)
**快慢指针:**又称为龟兔赛跑算法,其基本思想就是使用两个移动速度不同的指针在数组或链表等序列结构上移动。这种方法对于处理环形链表或数组非常有用。
其实不单单是环形链表或者是数组,如果我们要研究的问题出现循环往复的情况时,均可考虑使用快慢指针的思想。
快慢指针的实现方式有很多种,最常用的⼀种就是:在一次循环中,每次让慢的指针向后移动⼀位,而快的指针往后移动两位,实现⼀快⼀慢。
**双指针思想:**双指针思想并不是非要定义两个指针,我们也可以用下标代表双指针,也可以用具体元素代表双指针等等,来完成一系列操作。
二、应用
1、移动零
给定一个数组
nums
,编写一个函数将所有0
移动到数组的末尾,同时保持非零元素的相对顺序。请注意 ,必须在不复制数组的情况下原地对数组进行操作。
示例 1:
输入: nums = [0,1,0,3,12] 输出: [1,3,12,0,0]
示例 2:
输入: nums = [0] 输出: [0]
提示:
1 <= nums.length <=
<= nums[i] <=
- 1
这个题目思路之一是:开辟一个和原数组大小一样的新数组,遍历原数组如果不是0就直接插入新数组中,直到原数组遍历完,若新数组中还没满就全补0,否则就结束。
但这个思路是不可行的,因为题目有要求,"必须在不复制数组的情况下原地对数组进行操作",它要求我们不能开辟新数组,只能在原数组中进行数据操作。
那我们就需要另辟蹊径,这道题的最终要求就是:将非0划分到数组左边且顺序不能改变,将0全部划分到数组右边,所以它是一个数组划分问题。遇到数组划分问题,我们可以用双指针思想:
我们可以用dest和cur两个数组下标将整个数组分为3个部分,[0,dest]是处理好的非0元素(不改变原数组中的相对顺序的),[dest+1,cur-1]是处理好的0元素,[cur,n-1]是待处理的元素,如果待处理的元素个数为0,那么问题就解决了。 需要注意的是cur指向的是待处理元素的首个位置,dest指向的是非0元素的最后一个位置。
如何实现这一过程呢?
首先cur应该指向第一个元素,因为要从第一个元素开始处理数据,dest就给-1。
如果cur指向的元素是0,就++cur;如果cur指向的元素不是0,那么就交换dest+1和cur当前位置的值,++dest,++cur。因为dest+1按理说是第一个0元素,交换dest+1和cur当前位置的值就意味着将非0换到前面,0换到后面,然后更新dest,使dest指向的还是非0元素的最后一个位置,cur当前的元素就处理完毕了,然后就++cur。这样就能一直保证[0,dest]是处理好的非0元素,[dest+1,cur-1]是处理好的0元素,[cur,n-1]是待处理的元素,直到cur==n就结束了。如果数组中全是0,就一直++cur,直到cur==n。如果数组中没有0,那么dest+1和cur一直是同一个位置,交换也不影响相对顺序,所以上述解法可以完完全全解决问题。
我们结合示例1来分析一下:
dest起始状态指向-1,是为了保证[0,dest]是处理好的非0元素,[dest+1,cur-1]是处理好的0元素,[cur,n-1]是待处理的元素。
代码实现(C++):
cpp
class Solution {
public:
void moveZeroes(vector<int>& nums) {
int dest = -1;
int cur = 0;
while(cur < nums.size())
{
if(nums[cur] == 0)
++cur;
else
{
swap(nums[dest+1],nums[cur]);
++dest;
++cur;
}
}
}
};
简化代码:
cpp
class Solution {
public:
void moveZeroes(vector<int>& nums) {
for(int cur = 0,dest = -1;cur<nums.size();cur++)
{
if(nums[cur])
swap(nums[++dest],nums[cur]);
}
}
};
2、复写零
给你一个长度固定的整数数组
arr
,请你将该数组中出现的每个零都复写一遍,并将其余的元素向右平移。注意:请不要在超过该数组长度的位置写入元素。请对输入的数组 就地进行上述修改,不要从函数返回任何东西。
示例 1:
输入:arr = [1,0,2,3,0,4,5,0] 输出:[1,0,0,2,3,0,0,4] 解释:调用函数后,输入的数组将被修改为:[1,0,0,2,3,0,0,4]
示例 2:
输入:arr = [1,2,3] 输出:[1,2,3] 解释:调用函数后,输入的数组将被修改为:[1,2,3]
提示:
1 <= arr.length <=
0 <= arr[i] <= 9
这道题的思路之一是:新开一个和原数组等大的数组,遍历原数组,如果是非零元素就直接拷贝到新数组中,如果是零元素,就拷贝两份到新数组中,拷贝后如果新数组满了即使原数组没有遍历完,就结束。
这种思路也是不可行的,因为题目要求"对输入的数组 就地进行修改"。所以我们要换思路,对原数组中数据进行操作,我们要首先想到双指针思想,双指针思想不是凭空来的,它是先根据"异地"操作(我们上面的思路一),然后再此基础上优化成双指针下的"就地"操作,如果双指针思想解决不了问题再想其它办法。对于这道题也一样,我们先想到双指针思想:
因为拷贝是从起始位置拷贝的所以dest和cur初始状态都指向第一个位置。
在指针从前往后跑的场景下,dest会跑到cur的后面,那么在进行覆盖操作时就会覆盖后面的原始数据,这就会出现问题,那么双指针思想就不行了吗?别着急下结论,对于这道题从前往后这条路行不通,那我们可以试试从后往前。过程如下:
从后向前复写过程中,是从最后一个位置开始的,所以dest开始位置是指向数组中最后一个元素。
我们发现如果从后向前进行复写操作那么就可以解决问题,但有一个前提是,必须要先知道起始状态cur的指向,即数组中最后一个需要复写的数。
所以要想真正解决问题,我们需要完成的操作有:
1、先找到数组中最后一个要"复写"的数
2、"从后向前"完成复写操作
上面图片展示了"从后向前"完成复写操作的整个过程,那么现只需处理"先找到数组中最后一个要"复写"的数"这个问题,解决这个问题,可以用双指针。
基本步骤:
(1)先判断cur位置的值
(2)决定dest向后移动一步或者两步(dest的变化是根据cur指向的值进行的,如果cur指向的是0,则dest向前走两步,说明预留了两个空间用来复写0)
(3)判断一下dest是否已经到结束位置(最后一个元素的位置)
(4)++cur(在++cur之前,必须先进行第3步,如果dest到结束位置,那么不需要++cur,此时的cur就是最后一个需要复写的元素)
过程如下:
定义的两个变量(dest,cur),dest初始状态指向下标为-1的位置(表示开始时暂时没有复写空间),cur指向下标为0的位置。最终结束的条件是dest指向数组的最后元素的下标(表示能复写的空间已经满了),而此时cur指向的就是最后一个需要复写的元素。这样就可以不需要调整cur和dest的指向,直接进行"从后向前"完成复写操作。
到这里还没有完全结束,因为还有一种情况(dest可能越界):
针对越界这种情况,一定是cur最后的指向为0导致的,所以我们只需将数组的末尾元素修改为0,然后dest向前移动两步,cur向前移动一步,然后进行复写操作。
代码实现(C++):
cpp
class Solution {
public:
void duplicateZeros(vector<int>& arr) {
int dest = -1;
int cur = 0;
int n = arr.size();//记录数组中元素个数
//1、利用双指针思想,先找出最后一个需要复写的元素
while(cur < n)
{
if(arr[cur] != 0)
dest++;
else
dest+=2;
if(dest == n-1 || dest == n)
break;
else
++cur;
}
if(dest == n) //处理越界情况
{
arr[n-1] = 0;
dest-=2;
--cur;
}
//2、"从后向前"完成复写操作
while(cur >= 0)
{
if(arr[cur] != 0)
{
arr[dest] = arr[cur];
--dest;
--cur;
}
else
{
arr[dest] = 0;
arr[dest-1] = 0;
dest-=2;
--cur;
}
}
}
};
简化代码:
cpp
class Solution {
public:
void duplicateZeros(vector<int>& arr) {
int dest = -1,cur = 0,n = arr.size();
//1、利用双指针思想,先找出最后一个需要复写的元素
while(cur < n)
{
if(arr[cur])
dest++;
else
dest += 2;
if(dest >= n - 1)
break;
++cur;
}
if(dest == n) //处理越界情况
{
arr[n-1] = 0;
dest-=2;
--cur;
}
//2、"从后向前"完成复写操作
while(cur >= 0)
{
if(arr[cur])
arr[dest--] = arr[cur--];
else
{
arr[dest--] = 0;
arr[dest--] = 0;
--cur;
}
}
}
};
3、快乐数
编写一个算法来判断一个数
n
是不是快乐数。「快乐数」 定义为:
- 对于一个正整数,每一次将该数替换为它每个位置上的数字的平方和。
- 然后重复这个过程直到这个数变为 1,也可能是 无限循环 但始终变不到 1。
- 如果这个过程 结果为 1,那么这个数就是快乐数。
如果
n
是 快乐数 就返回true
;不是,则返回false
。示例 1:
输入:n = 19 输出:true 解释: 12 + 92 = 82 82 + 22 = 68 62 + 82 = 100 12 + 02 + 02 = 1
示例 2:
输入:n = 2 输出:false
提示:
1 <= n <=
- 1
我们先来看一下两个示例的具体过程:
第一个示例是最终可以变到1,第二个示例是会形成死循环,循环中不会出现1。
所以19是快乐数,而2不是快乐数。
那有没有一种情况,使n一直变下去,既不会变成1,也不会死循环呢?
答案是没有这种可能。
解释这个原因需要了解一下鸽巢原理(抽屉原理),**所谓鸽巢原理就是假设有n个巢穴,n+1个鸽子,那么至少一个巢中里面的鸽子数大于1,**这是显而易见的。
再来看这道题中n的取值范围,n取值最大为2147483647,约等于,我们假设它更大一点,为9999999999,所以n中的任意一个数经过一次操作后(每一次将该数替换为它每个位置上的数字的平方和),都要小于9999999999经过一次的操作数,9999999999经过一次操作后变为810,也就是说任意的n经过一次操作后,它的结果在[1,810)之间。现随便找一个数,让它变换811次,那必定会出现重复的数,所以一定会出现循环。
所以数n,无非就是情况一最终变到1,或是情况二最终形成死循环变不到1。
我们可以将这两种情况结合起来,可以将情况一理解为最终也会形成死循环,只不过循环中的元素都是1,将情况二理解为最终会形成死循环,只不过循环中的元素都不是1。像下面这样:
现在我们只需判断环中的数据是否是1,如果是1就是快乐数,如果不是1就不是快乐数。
如何判断环中的数据是否为1,那就需要用到快慢指针(slow,fast),它们起始都指向开头,慢指针一次走一步,快指针一次走两步,它们一定都会入环,且一定会在环中相遇。我们只需判断相遇点是否为1即可。
为什么一定会在环中相遇?
首先我们要清楚快指针一定必慢指针先入环,假设慢指针入环时与快指针的位置相差N,如图所示:
这时快指针走两步,慢指针走一步后,它们相距N-1,依次类推,N-2,N-3.....0,直到相距为0,就代表追上了。所以快指针和慢指针一定会在环中相遇。
对于这道题来说,快慢指针也是一种思想,慢指针一次走一步就是指该操作数操作一次,快指针一次走两步就是指该操作数连续操作两次。
代码实现(C++):
cpp
class Solution {
public:
//返回n这个数上的每一位数的平方和
int bitSum(int n)
{
int sum = 0; //记录总和
while(n)
{
int t = n % 10;
sum += t*t;
n /= 10;
}
return sum;
}
bool isHappy(int n) {
int slow = n; //慢指针
int fast = bitSum(n); //快指针,不让它为n是为了防止循环进不去的情况
while(slow != fast)
{
slow = bitSum(slow); //走一步
fast = bitSum(bitSum(fast));//走两步
}
//这时代表相遇,判断相遇值是否为1
if(slow == 1)
return true;
else
return false;
}
};
未完待续...