目录
什么是双指针算法
双指针算法是一种常用的算法,主要用于处理数组和链表中的问题。特别是需要在集合中查找、排序或计算时,可以通过两个指针来优化时间复杂度和空间复杂度
双指针有两种常见的形式:对撞指针 和快慢指针
对撞指针:
一般用于顺序结构中,也叫做 左右指针
对撞指针从两端向中间移动,一个指针从最左端开始,另一个指针从最右端开始,逐渐往中间靠近
对撞指针的终止条件一般是两个指针相遇(left == right) 或错开(left > right) ,或是在循环内部找到结果跳出循环
快慢指针:
使用两个移动速度不同的指针在数组或链表等序列结构上移动
这种方法很适合处理环形链表 或数组 ,且不只是环形链表或数组,若我们需要处理循环往复的情况,也可以考虑使用快慢指针
快慢指针的实现方式有很多种,最常见的一种是:
在一次循环中,每次让慢的指针向后移动一位 ,而快的指针向后移动两位 ,实现一快一慢
接下来,我们通过具体的练习来进一步体会双指针如何解决问题
移动零
题目链接
题目描述
给定一个数组 nums
,编写一个函数将所有 0
移动到数组的末尾,同时保持非零元素的相对顺序。
请注意 ,必须在不复制数组的情况下原地对数组进行操作。
示例1:
输入:nums = [0,1,0,3,12]
输出:[1, 3, 12, 0, 0]
示例2:
输入:nums = [0]
输出:[0]
思路分析
题目要求我们将数组中所有的0移到后面,所有非零元素移到前面,且要保持非零元素的相对位置,即,将数组分成两部分,左半部分元素非零,右半部分元素全0
上述问题是一个 **数组划分(分块)**问题,即通过题目给定的规则,将数组划分为不同的区域
而双指针是解决这类问题的常用方法
我们定义两个指针 cur 和 dest,
其中 cur 用于扫描数组中的元素,遍历数组
而 dest 用于吧 cur 已经扫描过的元素划分为两块,dest 所在的位置为非零元素中的最后一个位置
这样,通过这两个指针,我们将将数组划分为了三块:
[0, dest]:全为非零元素
[dest + 1, cur - 1]:全为0
[cur, len - 1]:未扫描元素
当 cur 扫描完整个数组,数组中的元素就被分为了两块:
[0, dest]:全为非零元素
[dest + 1, len - 1]:全为0
那么,我们该如何调整,让元素移动的同时,保持非零元素的相对位置?
在 cur 从前向后遍历的过程中:
若当前元素为 0 ,应划分到 [dest + 1, cur - 1] 区间,因此 cur++ 即可
若当前元素为非零元素,应该划分到 [0, dest] 区间,因此将 dest+1,然后再将 dest 位置的元素与 cur 位置元素交换,之后再让 cur向后移动(cur++)
那么,我们能否在dest++后将 dest 位置元素值修改为 cur 位置元素的值,cur 位置元素直接置为0呢?
但考虑下面这种情况:
此时,dest + 1 = cur ,即 [dest + 1, cur - 1] 区间不存在,若我们直接将 cur 位置元素值修改为0,则会让 dest 位置的值也变为0,也就直接让 dest 位置的值 "消失" 了
即,只有当 [dest + 1, cur - 1] 区间存在时,cur 位置元素才能置为0,因此,若要将 cur 位置元素置为0,需要先判断 [dest + 1, cur - 1] 区间是否存在
那么,cur 和 dest 的初始值应为多少呢?
cur 需要遍历整个数组,因此初始值为0
dest 为非零元素的最后一个位置,在初始时,不确定第一个元素是否为0,因此,初始值为 -1
在分析了解题思路之后,我们就可以尝试编写代码解决问题了
代码实现
java
class Solution {
public void moveZeroes(int[] nums) {
int len = nums.length;
for(int cur = 0, dest = -1; cur < len; cur++) {
if(nums[cur] != 0) {
// 当前元素为非零,dest++,
dest++;
// 交换 dest 和 cur 位置元素
int tmp = nums[cur];
nums[cur] = nums[dest];
nums[dest] = tmp;
}
}
}
}
覆写零
题目链接
题目描述
给你一个长度固定的整数数组 arr
,请你将该数组中出现的每个零都复写一遍,并将其余的元素向右平移。
注意:请不要在超过该数组长度的位置写入元素。请对输入的数组 就地进行上述修改,不要从函数返回任何东西。
示例 1:
输入:arr = [1,0,2,3,0,4,5,0]
输出:[1,0,0,2,3,0,0,4]
示例 2:
输入:
arr = [1,2,3]
输出:[1,2,3]
思路分析
题目要求我们就地 对数组进行覆写,我们先进行异地 覆写,模拟覆写过程,再想办法将其优化成 就地覆写
我们定义两个指针 cur 和 dest
**cur:**用于扫描原数组
**dest:**在新数组中进行覆写
接着,我们想办法将其优化为就地操作,若我们在原数组上从前向后覆写:
覆写过程中会覆盖原有的元素,导致后续覆写元素错误
既然从前向后覆写不行,那么我们就尝试从后向前覆写
最后一个要覆写的元素是 4,我们就从 4 开始向前进行覆写
覆写成功,因此,可以采取从后向前的方法进行覆写
那么,如何找到最后一个要进行覆写的元素呢?
在上述过程中,我们是通过使用两个数组从前向后模拟覆写操作找到的最后一个元素, 那么,我们就可以在原数组上模拟覆写的过程,但不进行覆写,从而找到最后一个元素
即:定义两个指针 cur dest
在 cur 从前向后扫描元素的过程中,若 cur 所在元素为非零,需要覆写原数值一次,即dest += 1;若 cur 所在的元素为0,则需要将0覆写两次,即 dest += 2
扫描过程为:
-
判断 cur 位置元素的值
-
决定 dest 向后移动一步还是两步
-
判断 dest 是否已经到达最后一个位置
-
cur++
cur 和 dest 的初始值应为多少呢?
cur 负责从前向后扫描元素,因此 cur 的值为0
而 dest 负责模拟覆写过程,上述在 cur 扫描到最后一个元素时,dest 越界了
为了让 dest 停留在数组的最后一个位置,我们将 dest 的初始值设置为 -1,每次先移动 dest,再进行覆写,而不是先写,再移动
此时,我们就一定能够保证 dest 不越界吗?
我们来看一种特殊的情况:
当最后一个需要覆写的元素为 0 时,此时 dest 需要向后移动两步,就可能会出现越界的情况
这是因为,当我们覆写最后一个元素时,可能此时只有一个位置,因此,虽然 0 需要覆写两次,但是也只能写入第一个 0
因此,我们要处理这个特殊情况,当 dest 越界时,直接将最后一个元素覆写为0,再将 dest 向前移动两步(dest -= 2),cur 向前移动一步(cur -= 1)
在找到了最后一个需要覆写的元素之后,我们就可以进行覆写操作了
接下来,我们就可以尝试编写代码解决问题了
代码实现
java
class Solution {
public void duplicateZeros(int[] arr) {
int len = arr.length;
int cur = 0;
int dest = -1;
while(cur < len) {
// 判断 cur 位置元素的值
if(arr[cur] != 0) {
dest++;
} else {
dest += 2;
}
// 判断 dest 是否到最后一个位置
if(dest >= len - 1) {
break;
}
cur++;
}
// 处理边界情况
if(dest == len) {
arr[len - 1] = 0;
dest -= 2;
cur--;
}
// 进行覆写
while(cur >= 0) {
if(arr[cur] != 0) {
arr[dest--] = arr[cur--];
} else {
arr[dest--] = 0;
arr[dest--] = 0;
cur--;
}
}
}
}
快乐数
题目链接
题目描述
编写一个算法来判断一个数 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,若出现1,则是快乐数;若不会出现1,则不是快乐数
例如:
我们可以发现,无论这个数是否是快乐数,最终都会形成一个环,若这个数是快乐数,则在 1 数值处循环,即环中只有1;若这个数不是快乐书,则形成的环中没有1
因此,我们可以使用 快慢指针 来解决这个问题
关于快慢指针如何解决出现环的问题,在 Java环形链表(图文详解)-CSDN博客 中详细介绍了,在这里就不再过多描述了
因此,判断一个数是否为快乐数:
定义快慢指针
慢指针每次向后移动一步,快指针每次向后移动两步
判断相遇时的值是否为1
代码实现
java
class Solution {
// 计算 n 每个位置上的数字的平方和
public int bitSum(int n) {
int sum = 0;
while(n > 0) {
sum += (n % 10) * (n % 10);
n /= 10;
}
return sum;
}
public boolean isHappy(int n) {
int slow = n;
int fast = bitSum(n);
while(slow != fast) {
slow = bitSum(slow);
fast = bitSum(bitSum(fast));
}
return slow == 1;
}
}
盛水最多的容器
题目链接
题目描述
定一个长度为 n
的整数数组 height
。有 n
条垂线,第 i
条线的两个端点是 (i, 0)
和 (i, height[i])
。
找出其中的两条线,使得它们与 x
轴共同构成的容器可以容纳最多的水。
返回容器可以储存的最大水量。
**说明:**你不能倾斜容器。
示例 1:
输入:[1,8,6,2,5,4,8,3,7]
输出:49
解释:图中垂直线代表输入数组 [1,8,6,2,5,4,8,3,7]。在此情况下,容器能够容纳水(表示为蓝色部分)的最大值为 49。
示例 2:
输入:height = [1,1]
输出:1
思路分析
数组中 i 位置元素值为 height[i],j 位置(j > i)元素值为 height[j],则此时容器能容纳的水为**(j - i)* min(height[i], height[j])**
题目要求我们找到容器可以储存的最大水量,即 (j - i)* min(height[i], height[j]) 的最大值
我们可以使用暴力枚举的方式,枚举处能构成的所有容器,再从中找到容器的最大值,但此时时间复杂度为 O(N^2),会超时
要求(j - i)* min(height[i], height[j]) 的最大值,我们要想 (j - i)和 min(height[i], height[j]) 都尽可能的大,因此,我们先让(j - i)最大 ,即i 和 j 分别位于左右两个端点处 ,此时容器的高度为min(height[i], height[j]) ,即由较小的值决定容器的高
当我们将 i 向后移动,或是将 j 向前移动,(j - i)都会减小,此时,我们就要想办法让 min(height[i], height[j]) 尽可能的大
此时 height[i] < height[j],我们固定一边,移动另一边
此时容器的宽度一定会减小
若我们移动 i,新的容器高度不确定,但一定不会超过 height[j],因此容器的容积可能会增大
若我们移动j,新的容器高度一定不会超过 height[i],也就是不会超过原有高度 ,但此时容器宽度一定会减小,因此容器的容积一定会减小
所以,移动 j 时容器容积一定会减小,直接舍去这种情况,即选择移动 i
接着,我们再次重复上述过程,每次都可以舍去大量不必要的枚举过程,直到 i 和 j 相遇
其中产生的所有容器的最大值,就是最终的结果,此时时间复杂度为 O(N)
接下来,我们就来尝试编写代码找到容器最大值
代码实现
java
class Solution {
public int maxArea(int[] height) {
int len = height.length;
int left = 0;
int right = len - 1;
int max = 0;
while(left < right) {
int high = height[left] < height[right] ? height[left] : height[right];
int width = right - left;
if(high * width > max) {
max = high * width;
}
if(high == height[left]) {
left++;
} else {
right--;
}
}
return max;
}
}
有效三角形的个数
题目链接
题目描述
给定一个包含非负整数的数组 nums
,返回其中可以组成三角形三条边的三元组个数。
示例 1:
输入: nums = [2,2,3,4]
输出: 3
解释:有效的组合是:
2,3,4 (使用第一个 2)
2,3,4 (使用第二个 2)
2,2,3
示例 2:
输入: nums = [4,2,3,4]
输出: 4
思路分析
题目要求我们找到可以构成三角形三条边的三元组个数,由示例1,我们可以发现,其中的三元组是可以重复的
要判断三个数值能否构成一个三角形,就要满足三角形的定义:任意两边之和大于第三边
但其实我们只需要满足较小的两边之和大于第三边,就可以满足任意两边之和大于第三边
因此,我们可以对数组进行排序 ,让数组中的值有序,这样,我们每次判断时就只需要判断一次,就可以判断出能否构成三角形
我们可以使用三层 for 循环枚举出所有的三元组,判断其能否构成三元组,但此时会超时,我们需要想其他的办法
由于此时数组有序,那么,我们就可以固定一个 最长边,然后在剩下的有序数组中找出一个二元组,若这个二元组之和大于第三边,则可以构成三角形
且由于数组是有序的,因此,我们可以使用对撞指针进行优化
我们定义两个指针 left 和 right,left 指向剩余数组的最左边,right 指向剩余数组的最右边
若 nums[left] + nums[right] <= nums[k](最长边),不能构成三角形, 则让 left++,增加较小两边的长度,继续判断
若 nums[left] + nums[right] > nums[k],此时可以构成三角形,且**[left, right - 1]**区间内的所有元素都能与 nums[right] 构成三角形
则可以构成三角形的个数为 right - left,此时再让 right--,继续判断
继续判断,直到 left = right
再移动最长边,继续上述判断
接下来,我们就可以尝试编写代码,找出有效三角形的个数了
代码实现
java
class Solution {
public int triangleNumber(int[] nums) {
int len = nums.length;
// 对数组进行排序
Arrays.sort(nums);
int sum = 0;
// 固定最长边
for(int k = len - 1; k > 1; k--) {
// 使用对撞指针找到有效三角形的个数
int left = 0;
int right = k - 1;
while(left < right) {
if(nums[left] + nums[right] <= nums[k]) {
left++;
} else if(nums[left] + nums[right] > nums[k]) {
sum += (right - left );
right--;
}
}
}
return sum;
}
}