前言
来财财来,双指针算法介绍来了!
***当你在 LeetCode 上面对数组、链表类题目时,是否还在暴力嵌套循环导致超时?当面试官要求优化时间复杂度时,是否总卡在 O (n²) 的思维定式里?这篇文章将彻底改变你的解题方式!
双指针简单介绍
常见的双指针有两种形式,⼀种是对撞指针,⼀种是左右指针。
对撞指针:⼀般用于顺序结构中,也称左右指针。
对撞指针从两端向中间移动。⼀个指针从最左端开始,另一个从最右端开始,然后逐渐往中间逼近。对撞指针的终⽌条件⼀般是两个指针相遇或者错开(也可能在循环内部找到结果直接跳出循环),也就是:
◦ left == right (两个指针指向同⼀个位置)
left > right (两个指针错开)
快慢指针:
又称为鬼兔赛跑算法,其基本思想就是使⽤两个移动速度不同的指针在数组或链表等序列结构上移动。这种⽅法对于处理环形链表或数组非常有用。另外,其实不单单是环形链表或者是数组,如果我们要研究的问题出现循环往复的情况时,均可考虑使用快慢指针的思想。快慢指针的实现⽅式有很多种,最常用的⼀种就是:在⼀次循环中,每次让慢的指针向后移动⼀位,而快的指针往后移动两位,实现⼀快⼀慢。
下面我们通过例题详细解释双指针的用法。
例题
一.移动零
「数组分两块」是非常常见的⼀种题型,主要就是根据⼀种划分⽅式,将数组的内容分成左右两部 分。这种类型的题,⼀般就是使⽤「双指针」来解决。
1.题目链接:移动零
2. 题⽬描述:
给定⼀个数组 nums ,编写⼀个函数将所有 0 移动到数组的末尾,同时保持⾮零元素的相对顺序。请注意 ,必须在不复制数组的情况下原地对数组进⾏操作。 示例 1:
输⼊: nums = [0,1,0,3,12]
输出: [1,3,12,0,0]
⽰例 2:
输⼊: nums = [0]
输出: [0]
- 解法(快排的思想:数组划分区间 - 数组分两块):
***算法思路:
在本题中,我们可以⽤⼀个 cur 指针来扫描整个数组,另⼀个 dest 指针⽤来记录⾮零数序列 的最后⼀个位置。根据 cur 在扫描的过程中,遇到的不同情况,分类处理,实现数组的划分。 在 cur 遍历期间,使 [0, dest] 的元素全部都是⾮零元素, [dest + 1, cur - 1] 的 元素全是零。
算法流程:
a. 初始化 cur = 0 (⽤来遍历数组), dest = -1 (指向⾮零元素序列的最后⼀个位置。 因为刚开始我们不知道最后⼀个⾮零元素在什么位置,因此初始化为 -1 )
b. cur 依次往后遍历每个元素,遍历到的元素会有下⾯两种情况:
i. 遇到的元素是 0 , cur 直接 ++ 。因为我们的⽬标是让 [dest + 1, cur - 1] 内 的元素全都是零,因此当 cur 遇到 0 的时候,直接 ++ ,就可以让 0 在 cur - 1
的位置上,从⽽在 [dest + 1, cur - 1] 内;
ii. 遇到的元素不是 0 , dest++ ,并且交换 cur 位置和 dest 位置的元素,之后让cur++ ,扫描下⼀个元素。 • 因为 dest 指向的位置是⾮零元素区间的最后⼀个位置,如果扫描到⼀个新的⾮零元 素,那么它的位置应该在 dest + 1 的位置上,因此 dest 先⾃增 1 ; • dest++ 之后,指向的元素就是 0 元素(因为非零元素区间末尾的后⼀个元素就是 0 ),因此可以交换到 cur 所处的位置上,实现 [0, dest] 的元素全部都是⾮零 元素, [dest + 1, cur - 1] 的元素全是零。
代码示例:
//移动零
public void moveZeroes(int[] nums){
for(int cur = 0,dest = -1;cur< nums.length ;cur++){
if(nums[cur] != 0){
dest++;
int b = nums[cur];
nums[cur] = nums[dest];
nums[dest] =b;
}
}
}
二.复写零
-
题目链接:复写零
-
题目描述:
给你⼀个⻓度固定的整数数组 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]
- 解法(原地复写 - 双指针):
算法思路:
如果「从前向后」进⾏原地复写操作的话,由于 0 的出现会复写两次,导致没有复写的数「被覆盖掉」。因此我们选择「从后往前」的复写策略。 但是「从后向前」复写的时候,我们需要找到「最后⼀个复写的数」,因此我们的大体流程分两步:
i. 先找到最后⼀个复写的数;
ii. 然后从后向前进⾏复写操作。
算法流程:
a. 初始化两个指针 cur = 0 , dest = 0 ;
b. 找到最后⼀个复写的数:
i. 当 cur < n 的时候,⼀直执⾏下⾯循环: • 判断 cur 位置的元素: ◦ 如果是 0 的话, dest 往后移动两位; ◦ 否则, dest 往后移动⼀位。 • 判断 dest 时候已经到结束位置,如果结束就终⽌循环; • 如果没有结束, cur++ ,继续判断。
c. 判断 dest 是否越界到 n 的位置:
i. 如果越界,执⾏下⾯三步:
- n - 1 位置的值修改成 0 ;
- cur 向移动⼀步;
- dest 向前移动两步。
d. 从 cur 位置开始往前遍历原数组,依次还原出复写后的结果数组:
i. 判断 cur 位置的值: - 如果是 0 : dest 以及 dest - 1 位置修改成 0 , dest -= 2 ;
- 如果⾮零: dest 位置修改成 0 , dest -= 1 ;
ii. cur-- ,复写下⼀个位置。
代码示例:
public void duplicateZeros(int[] arr) {
int cur = 0, dest = -1, n = arr.length;
while (cur < n)
// 1. 先找到最后⼀个需要复写的数 7 while(cur < n)
{
if (arr[cur] == 0) dest += 2;
else dest += 1;
if (dest >= n - 1) {
break;
}
cur++;
}
// 2. 处理⼀下边界情况
if (dest == n) {
arr[n - 1] = 0;
cur--;
dest -= 2;
}
// 3. 从后向前完成复写操作
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
解释:
19 -> 1 * 1 + 9 * 9 = 82
82 -> 8 * 8 + 2 * 2 = 68
68 -> 6 * 6 + 8 * 8 = 100
100 -> 1 * 1 + 0 * 0 + 0 * 0 = 1
⽰例 2:输⼊: n = 2
输出: false
解释:(这⾥省去计算过程,只列出转换后的数)
2 -> 4 -> 16 -> 37 -> 58 -> 89 -> 145 -> 42 -> 20 -> 4 -> 16
往后就不必再计算了,因为出现了重复的数字,最后结果肯定不会是 1
- 题目分析:
为了⽅便叙述,将「对于⼀个正整数,每⼀次将该数替换为它每个位置上的数字的平⽅和」这⼀个 操作记为 x 操作; 题⽬告诉我们,当我们不断重复 x 操作的时候,计算⼀定会「死循环」,死的方式有两种:
▪ 情况⼀:⼀直在 1 中死循环,即 1 -> 1 -> 1 -> 1…
▪ 情况⼆:在历史的数据中死循环,但始终变不到 1 由于上述两种情况只会出现⼀种,因此,只要我们能确定循环是在「情况⼀」中进⾏,还是在「情 况⼆」中进⾏,就能得到结果。
- 简单证明:
a. 经过⼀次变化之后的最⼤值 9^2 * 10 = 810 ( 2^31-1=2147483647 。选⼀个更⼤的最 ⼤ 9999999999 ),也就是变化的区间在 [1, 810] 之间;
b. 根据「鸽巢原理」,⼀个数变化 811 次之后,必然会形成⼀个循环;
c. 因此,变化的过程最终会⾛到⼀个圈⾥⾯,因此可以⽤「快慢指针」来解决。
- 解法(快慢指针):
算法思路: 根据上述的题⽬分析,我们可以知道,当重复执⾏ x 的时候,数据会陷⼊到⼀个「循环」之中。 ⽽「快慢指针」有⼀个特性,就是在⼀个圆圈中,快指针总是会追上慢指针的,也就是说他们总会 相遇在⼀个位置上。如果相遇位置的值是 1 ,那么这个数⼀定是快乐数;如果相遇位置不是 1 的话,那么就不是快乐数
5.代码示例:
public int bitSum(int n)
// 返回 n 这个数每⼀位上的平⽅和
{
int sum = 0;
while (n != 0) {
int t = n % 10;
sum += t * t;
n /= 10;
}
return sum;
}
public boolean isHappy(int n) {
int slow = n, 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
3.解法(对撞指针):
算法思路:
设两个指针 left , right 分别指向容器的左右两个端点,此时容器的容积 :
v = (right - left) * min( height[right], height[left])
容器的左边界为 height[left] ,右边界为 height[right] 。
为了⽅便叙述,我们假设「左边边界」⼩于「右边边界」。
如果此时我们固定⼀个边界,改变另⼀个边界,⽔的容积会有如下变化形式:
◦ 容器的宽度⼀定变小。
◦ 由于左边界较小,决定了水的高度。如果改变左边界,新的水面高度不确定,但是⼀定不会超 过右边的柱子高度,因此容器的容积可能会增⼤。
◦ 如果改变右边界,⽆论右边界移动到哪⾥,新的⽔⾯的⾼度⼀定不会超过左边界,也就是不会 超过现在的水面高度,但是由于容器的宽度减小,因此容器的容积⼀定会变小的。 由此可见,左边界和其余边界的组合情况都可以舍去。所以我们可以left++ 跳过这个边界,继续去判断下⼀个左右边界
当我们不断重复上述过程,每次都可以舍去⼤量不必要的枚举过程,直到 left 与 right 相 遇。期间产⽣的所有的容积⾥⾯的最⼤值,就是最终答案。
4.代码示例:
public int maxArea(int[] height) {
int left = 0, right = height.length - 1, ret = 0;
while (left < right) {
int v = Math.min(height[left], height[right]) * (right - left);
ret = Math.max(ret, v);
if (height[left] < height[right]) left++;
else right--;
}
return ret;
}
五. 有效三角形的个数
1.题目链接:有效三角形的个数
2.题目描述:
给定⼀个包含⾮负整数的数组 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
解释:
4,2,3
4,2,4
4,3,4
2,3,4
3.解法(排序+双指针):
算法思路:
先将数组排序。 我们可以固定⼀个「最⻓边」,然后在⽐这条边小的有序数组中找 出⼀个⼆元组,使这个⼆元组之和⼤于这个最⻓边。由于数组是有序的,我们可以利⽤「对撞指针来优化。 设最⻓边枚举到 i 位置,区间 [left, right] 是 i 位置左边的区间(也就是比它小的区间: ◦ 如果 nums[left] + nums[right] > nums[i] :
▪ 说明 [left, right - 1] 区间上的所有元素均可以与 nums[right] 构成比nums[i] ⼤的⼆元组
▪ 满⾜条件的有 right - left 种 ▪ 此时 right 位置的元素的所有情况相当于全部考虑完毕, right-- ,进⼊下⼀轮判断
◦ 如果 nums[left] + nums[right] <= nums[i] :
▪ 说明 left 位置的元素是不可能与 [left + 1, right] 位置上的元素构成满足条件 的二元组
▪ left 位置的元素可以舍去,left++ 进⼊下轮循环
代码示例:
public int triangleNumber(int[] nums) {
// 1. 优化:排序
Arrays.sort(nums);
// 2. 利⽤双指针解决问题
int ret = 0, n = nums.length;
for (int i = n - 1; i >= 2; i--) // 先固定最⼤的数
{
// 利⽤双指针快速统计出符合要求的三元组的个数
int left = 0, right = i - 1;
while (left < right) {
if (nums[left] + nums[right] > nums[i]) {
ret += right - left;
right--;
} else {
left++;
}
}
}
return ret;
}
六.三数之和
1.题目链接:三数之和
2.题目描述:
给你⼀个整数数组 nums ,判断是否存在三元组 [nums[i], nums[j], nums[k]] 满⾜ i != j、i != k 且 j!= k ,同时还满⾜ nums[i] + nums[j] + nums[k] == 0 。请你返回所有和为 0 且不重复的三元组。 注意:答案中不可以包含重复的三元组。 ⽰例 1: 输⼊:nums = [-1,0,1,2,-1,-4]
输出:[[-1,-1,2],[-1,0,1]]
解释:
nums[0] + nums[1] + nums[2] = (-1) + 0 + 1 = 0 。
nums[1] + nums[2] + nums[4] = 0 + 1 + (-1) = 0 。
nums[0] + nums[3] + nums[4] = (-1) + 2 + (-1) = 0 。
不同的三元组是 [-1,0,1] 和 [-1,-1,2] 。 注意,输出的顺序和三元组的顺序并不重要。 ⽰例 2: 输⼊:nums = [0,1,1]
输出:[]
解释:唯⼀可能的三元组和不为 0 。
⽰例 3: 输⼊:nums = [0,0,0]
输出:[[0,0,0]]
解释:唯⼀可能的三元组和为 0 。
提示: 3 <= nums.length <= 3000
-10^5 <= nums[i] <= 10^5
- 解法(排序+双指针):
算法思路:
i. 先排序;
ii. 然后固定⼀个数 a :
iii. 在这个数后⾯的区间内,使⽤「双指针算法」快速找到两个数之和等于 -a 即可。 但是要注意的是,这道题⾥⾯需要有「去重」操作~
i. 找到⼀个结果之后, left 和 right 指针要「跳过重复」的元素;
ii. 当使⽤完⼀次双指针算法之后,固定的 a 也要「跳过重复」的元素。
代码示例:
public List<List<Integer>> threeSum(int[] nums) {
List<List<Integer>> ret = new ArrayList<>();
// 1. 排序 8 Arrays.sort(nums);
// 2. 利⽤双指针解决问题
int n = nums.length;
for (int i = 0; i < n; ) // 固定数 a
{
if (nums[i] > 0) break; // ⼩优化
int left = i + 1, right = n - 1, target = -nums[i];
while (left < right) {
int sum = nums[left] + nums[right];
if (sum > target) right--;
else if (sum < target) left++;
else {
ret.add(new ArrayList<Integer>(Arrays.asList(nums[i],
nums[left], nums[right]))
);
left++;
right--; // 缩⼩区间继续寻找
// 去重:left right
while (left < right && nums[left] == nums[left - 1]) left++;
while (left < right && nums[right] == nums[right + 1])
right--;
}
}
// 去重:i
i++;
while (i < n && nums[i] == nums[i - 1]) i++;
}
return ret;
}
总结
每个指针的移动都是智慧的抉择,每段代码的背后都藏着数学的优雅。当您能自如操控两个指针在数据中穿梭时,您将真正体会到 “代码即算法,算法即艺术” 的编程哲学。本文中主要涉及了对撞指针与快慢指针,并提供一些相关例题练习,要想真正进步,大家还需多多练习代码。
祝大家都能在学习路上披荆斩棘,拿到自己心意的offer
以上就是本文全部内容,感谢各位能够看到最后,创作不易,希望大家多多支持!
最后,大家再见!