算法沉淀——双指针算法
- 01.移动零
- 02.复写零
- 03.快乐数
- 04.盛最多水的容器
- 05.有效三角形的个数
- 06.和为s的两个数字
- 07.三数之和
- 08.四数之和
双指针算法(Two Pointer Algorithm)是一种常用于数组(或链表)操作的算法技巧。它的核心思想是通过维护两个指针,在数组中高效地解决一些问题,这里的指针不一定是真实的指针,是一种抽象的概念,比如数组的下标,C++的迭代器等等。这两个指针可以分别指向数组的不同位置,也可以分别指向数组的开始和结束。
常见的双指针算法有两种类型:快慢指针和左右指针。
- 快慢指针:
- 用于解决一些查找或判断问题,比如判断链表是否有环、找到链表的中间节点等。
- 快指针每次移动两步,慢指针每次移动一步。
- 当两个指针相遇时,说明存在环,或者慢指针所指位置即为中间节点。
- 左右指针(又称对撞指针):
- 用于解决一些区间或子数组的问题,比如在有序数组中找到两个数使其和等于目标值、判断一个字符串是否为回文等。
- 左指针和右指针分别指向数组的两端,根据问题的要求,移动其中一个或两个指针。
- 根据移动规则,可以有效地缩小问题的搜索范围,提高算法的效率。
下面我们通过实际算法题来进行讲解
01.移动零
题目链接:https://leetcode.cn/problems/move-zeroes/description/
给定一个数组 nums
,编写一个函数将所有 0
移动到数组的末尾,同时保持非零元素的相对顺序。
请注意 ,必须在不复制数组的情况下原地对数组进行操作。
示例 1:
输入: nums = [0,1,0,3,12]
输出: [1,3,12,0,0]
示例 2:
输入: nums = [0]
输出: [0]
思路
核心思路是使用两个指针,一个指针(cur
)用于遍历数组,另一个指针(dest
)用于指示当前非零元素的插入位置。通过遍历数组,当发现非零元素时,将其与dest
位置的元素进行交换,同时递增dest
指针,确保非零元素的相对顺序保持不变。
下面是算法的详细流程:
- 初始化两个指针:
cur
和dest
,初始值为0和-1,因为cur指针先走。- 从左到右遍历数组,用
cur
指针指向当前元素。- 如果当前元素不为零,将其与
dest
位置的元素进行交换,并递增dest
指针。- 继续遍历,重复步骤3,直到数组遍历完毕。
- 此时,所有非零元素已经移动到数组的前面,而
dest
指针的位置之后都是零。因为dest
初始值为-1,所以dest + 1
即为第一个零的位置。- 遍历完成后,数组中所有零已经被移动到数组的末尾。
这个算法的优点在于它只需要遍历一次数组,并且在原地进行操作,不需要额外的空间。这使得它在处理大规模数组时非常高效,时间复杂度只有O(n)。
以下是对应的C++代码:
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]);
}
}
};
02.复写零
题目链接:https://leetcode.cn/problems/duplicate-zeros/
给你一个长度固定的整数数组 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]
思路
这里我们能够想到最简单的一种算法就是,我们再创建一个容器数组逐个遍历,进行零的重写,最后直接覆盖或交换原数组,但是这种写法花费的时间和特别是空间代价太大了,所以我们这种时候利用双指针的算法可以很好的解决这个问题。
算法的核心思想是通过两个步骤实现对数组中的零的复制:
- 统计零的个数: 通过遍历数组,统计其中零的个数,并在遍历过程中确定是否需要额外的位置来容纳复制的零。
- 从后向前复制零: 从数组的末尾开始向前复制元素,遇到非零元素直接复制,遇到零则复制两个零。这样的方式确保了零的复制不会影响到原数组中的元素。
下面是算法的详细步骤:
- 初始化变量
count
表示数组中零的个数,变量i
表示当前遍历的位置。 - 第一次遍历数组,统计零的个数,并在遍历过程中判断是否需要额外的位置。同时记录遍历结束的位置
i
。 - 初始化变量
r
表示从后向前复制元素的位置,如果复制零后的数组长度超过了原数组长度,补充一个零到最后。 - 从位置
i
开始,从后向前遍历数组,根据数组中的元素值进行复制操作,确保零被复制两次,非零元素直接复制。 - 完成从后向前的复制后,原数组
arr
中的零已经被复制一份,实现了题目的要求。
以下是对应的C++代码:
class Solution {
public:
void duplicateZeros(vector<int>& arr) {
int n=arr.size(),count=0,i=0;
for(i=0;i<n;i++){
if(arr[i]==0) count++;
if(i+count+1>=n) break;//题目规定不能超过数组固定长度
}
int r=n-1;
if(i+count+1>n){ //处理边界情况,算出复写后位置大于n就说明最后那个位置一定是0
arr[r--]=0;
i--;
}
//从后向前通过i和r的下标相对位置完成复写0
while(i>=0){
if(arr[i]) arr[r--]=arr[i--];
else{
arr[r--]=0;
arr[r--]=0;
i--;
}
}
}
};
03.快乐数
题目链接:https://leetcode.cn/problems/happy-number/description/
编写一个算法来判断一个数 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
思路
这道题其实我们可以根据一个数学知识知道如果经过不断计算无线循环的话,其中是一定会有重复的数字的,其实这里最好的解法应该是使用哈希思想,但是在这里我们使用双指针也可以有很简单的写法,我们先来看看双指针的写法
解法一:快慢指针
- 定义一个函数
nextSum
,该函数用于计算一个数每位数字的平方和。通过对输入数字每位取模得到个位数,然后累加每位数字的平方。 - 使用两个指针
slow
和fast
,初始时都指向输入的数n
。在循环中,slow
每次计算一次nextSum
,而fast
每次计算两次nextSum
,这就相当于在一个链表中slow
每次走一步,fast
每次走两步。 - 如果存在一个循环,那么
slow
和fast
最终都会进入这个循环,并相遇。如果slow
最终等于1,则这个数是快乐数;如果slow
和fast
相遇但不等于1,说明存在循环,这个数不是快乐数。
以下是对应的C++代码:
class Solution {
public:
int nextSum(int n){
int sum=0;
while(n){
int tmp=n%10;
sum+=tmp*tmp;
n/=10;
}
return sum;
}
bool isHappy(int n) {
int slow=n,fast=nextSum(n);
while(slow!=fast){
slow=nextSum(slow);
fast=nextSum(nextSum(fast));
}
return slow==1;
}
};
解法二:哈希
这个算法也是通过计算每位数字的平方和,然后不断迭代,检测是否能够最终得到1。不同之处在于,这个算法使用了一个哈希集合 unordered_set
来记录已经出现过的数字,以便检测循环。
以下是对这个算法的核心思想和流程的详细解释:
- 初始化: 创建一个空的哈希集合
seen
,用于记录已经出现过的数字。 - 循环检测: 在一个循环中,检测当前的数字是否等于1,并且是否已经在
seen
集合中出现过。如果是,则说明这个数是快乐数,直接返回true
。 - 迭代计算: 如果当前数字不等于1且未出现在
seen
中,那么将当前数字加入到seen
中,并计算下一个数字的平方和。这个过程通过迭代计算每位数字的平方和,然后将结果赋值给n
。 - 循环继续: 继续在循环中执行上述步骤,直到
n
等于1或者n
已经在seen
中出现过。 - 返回结果: 最终判断
n
是否等于1,如果是则返回true
,否则返回false
。
这个算法使用了哈希集合来检测循环,避免了在链表中进行双指针遍历的方式。这样的实现在空间上相对较高效,因为哈希集合的查找和插入操作都是平均 O(1) 的时间复杂度。算法的时间复杂度取决于是否存在循环,最坏情况下是 O(logN)。
以下是对应的C++代码:
class Solution {
public:
bool isHappy(int n) {
// 使用一个集合来存储已经出现过的数字,以检测循环
unordered_set<int> seen;
while (n != 1 && seen.find(n) == seen.end()) {
seen.insert(n);
int sum = 0;
while (n > 0) {
int digit = n % 10;
sum += digit * digit;
n /= 10;
}
n = sum;
}
return n == 1;
}
};
04.盛最多水的容器
题目链接:https://leetcode.cn/problems/container-with-most-water/
给定一个长度为 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
思路
首先看到这种题的第一种想法可能都是暴力枚举每一种情况,但是这样做的复杂度太大,是会超时的,其实这里我们可以通过对撞指针的方式不断改变一边的高度,进而更新最大容量,直至指针相遇。
核心思想和流程的详细解释:
- 初始化: 使用两个指针
left
和right
分别指向数组的起始和结束位置,同时初始化变量ret
为0,用来记录最大的容器面积。 - 循环迭代: 在一个循环中,计算当前
left
和right
指向的两个线段之间的容器面积,并将其与ret
中记录的最大面积进行比较。然后,将left
或right
中较小的那个指针向内移动一步。 - 移动指针: 移动指针的规则是选择两个线段中较短的那个向内移动。这是因为如果移动较长的线段,容器的宽度减小,而高度最大不会增加;而如果移动较短的线段,有可能找到更高的线段,从而提高容器的高度。
- 更新最大面积: 在每次计算完容器的面积后,更新
ret
的值为当前面积与ret
中记录的最大面积的较大者。 - 循环继续: 重复上述步骤,直到
left
和right
指针相遇。 - 返回结果: 返回
ret
,即找到的最大容器面积。
这个算法的核心在于通过不断移动指针,选择较短的线段向内移动,以期望找到更高的线段来提高容器的高度。这样的策略保证了在寻找最大容器面积的过程中,不漏过可能的最优解。算法的时间复杂度是 O(n)。
以下是对应的C++代码:
class Solution {
public:
int maxArea(vector<int>& height) {
int left=0,right=height.size()-1,ret=0;
while(left<right){
int v=min(height[left],height[right])*(right-left);
ret=max(ret,v);
if(height[left]<height[right]) left++;
else right--;
}
return ret;
}
};
05.有效三角形的个数
题目链接:https://leetcode.cn/problems/valid-triangle-number/description/
给定一个包含非负整数的数组 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
思路
这里使用暴力枚举同样会超时,所以这里同样可以使用双指针的方式来解决
核心思想是通过排序数组,然后使用双指针的方式,依次选择一个较大的元素作为三角形的最大边,然后在左边的子数组中使用双指针寻找满足三角形条件的组合。
以下是这个算法的详细解释:
- 排序数组: 首先对输入数组
nums
进行排序,以便于使用双指针的方式进行查找。 - 遍历最大边: 从数组的最后一个元素开始,即
i = n-1
,向前遍历数组。这个元素作为三角形的最大边。 - 双指针查找: 使用两个指针
left
和right
分别指向当前最大边的前两个元素,即left = 0
和right = i-1
。 - 判断是否构成三角形: 在一个循环中,判断
nums[left] + nums[right] > nums[i]
是否成立。如果成立,说明当前的组合(nums[left], nums[right], nums[i])
可以构成一个三角形。此时,由于数组已经排序,那么左边的元素nums[left]
和任何nums[k]
(其中left < k < right
)都能够构成三角形。因此,ret += right - left
表示当前left
对应的元素与right
之间的元素均能够构成三角形,加到结果中。 - 更新指针: 根据当前判断的结果,更新指针的位置,即
right--
或left++
。 - 循环继续: 继续上述步骤,直到
left >= right
,然后将i
减一,进入下一轮循环。 - 返回结果: 最终得到的
ret
即为满足条件的三角形组合的数量。
以下是对应的C++代码:
class Solution {
public:
int triangleNumber(vector<int>& nums) {
sort(nums.begin(),nums.end());
int ret=0;
int n=nums.size();
for(int i=n-1;i>=2;i--){
int left=0;
int right=i-1;
while(left<right){
if(nums[left]+nums[right]>nums[i]){
ret+=right-left;
right--;
}else left++;
}
}
return ret;
}
};
06.和为s的两个数字
题目链接:https://leetcode.cn/problems/he-wei-sde-liang-ge-shu-zi-lcof/description/
购物车内的商品价格按照升序记录于数组 price
。请在购物车中找到两个商品的价格总和刚好是 target
。若存在多种情况,返回任一结果即可。
示例 1:
输入:price = [3, 9, 12, 15], target = 18
输出:[3,15] 或者 [15,3]
示例 2:
输入:price = [8, 21, 27, 34, 52, 66], target = 61
输出:[27,34] 或者 [34,27]
思路
同样暴力枚举会超时,因为这里是升序数组,我们使用双指针的解法可以非常轻松解决
- 初始化指针: 使用两个指针
left
和right
分别指向数组的第一个元素和最后一个元素。 - 循环查找: 在一个循环中,计算当前
left
和right
指向的两个元素的和sum
。如果sum > target
,说明当前和过大,需要将right
向左移动一步;如果sum < target
,说明当前和过小,需要将left
向右移动一步;如果sum == target
,则找到了满足条件的两个元素,返回结果{nums[left], nums[right]}
。 - 循环继续: 继续上述步骤,直到
left >= right
,循环结束。 - 返回结果: 如果循环结束时仍未找到满足条件的两个元素,则返回一个空数组。
这个算法的核心思想是利用有序数组的特性,通过双指针在数组中夹逼的方式,不断调整指针的位置,直到找到满足条件的两个元素或者循环结束。
这个算法的时间复杂度是 O(n)。因为每次循环中,left
或 right
至少有一个向中间移动一步,所以总体的时间复杂度是线性的。
以下是对应的C++代码:
class Solution {
public:
vector<int> twoSum(vector<int>& nums, int target) {
vector<int> ret;
for(int left=0,right=nums.size()-1;left<right;){
int sum=nums[left]+nums[right];
if(sum>target) right--;
else if(sum<target) left++;
else return {nums[left],nums[right]};
}
return {};
}
};
07.三数之和
给你一个整数数组 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 。
思路
其实这道题和两数之和解法类似,还是使用双指针的方式,只不过这里需要我们先排序,具体思路如下
- 排序: 对输入数组
nums
进行升序排序,这样可以使得相同的元素相邻,方便后续的去重操作。 - 遍历: 使用一个外层循环,固定一个数
nums[i]
作为三元组的第一个元素。 - 双指针: 在内层循环中,使用两个指针
left
和right
,分别指向当前数nums[i]
的下一个元素和数组的最后一个元素。计算当前三个数的和sum
。 - 判断和与目标值的关系:
- 如果
sum > 0
,说明右侧的数太大,将right
向左移动一步。 - 如果
sum < 0
,说明左侧的数太小,将left
向右移动一步。 - 如果
sum == 0
,说明找到了一个满足条件的三元组,加入结果集中,并进行去重操作。
- 如果
- 去重操作: 在找到一个满足条件的三元组后,需要对
left
和right
进行去重操作,即跳过相同的元素,避免结果中出现重复的三元组。 - 去重 i: 在外层循环中,找到一个满足条件的三元组后,也需要对
i
进行去重操作,即跳过相同的数,避免结果中出现相同的三元组。 - 返回结果: 最终返回所有满足条件的三元组组成的结果集。
以下是对应的C++代码:
class Solution
{
public:
vector<vector<int>> threeSum(vector<int>& nums)
{
vector<vector<int>> ret;
sort(nums.begin(), nums.end());
int n = nums.size();
for (int i = 0; i < n; )
{
if (nums[i] > 0) break; //没有负数或等于0的情况和不可能为0故直接跳出
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.push_back({ 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;
}
};
08.四数之和
题目链接:https://leetcode.cn/problems/4sum/description/
给你一个由 n
个整数组成的数组 nums
,和一个目标值 target
。请你找出并返回满足下述全部条件且不重复的四元组 [nums[a], nums[b], nums[c], nums[d]]
(若两个四元组元素一一对应,则认为两个四元组重复):
0 <= a, b, c, d < n
a
、b
、c
和d
互不相同nums[a] + nums[b] + nums[c] + nums[d] == target
你可以按 任意顺序 返回答案 。
示例 1:
输入:nums = [1,0,-1,0,-2,2], target = 0
输出:[[-2,-1,1,2],[-2,0,0,2],[-1,0,0,1]]
示例 2:
输入:nums = [2,2,2,2,2], target = 8
输出:[[2,2,2,2]]
思路
看到这个题目时,我们不难发现,可以用双指针的算法进行求解,这道题目还是使用类似三数之和的操作,只不过这里多了一个操作数,我们只需再加一层循环嵌套,以及一些去重优化即可,具体思路如下:
- 排序: 对输入数组
nums
进行升序排序,这样可以使得相同的元素相邻,方便后续的去重操作。 - 遍历: 使用两个外层循环,分别固定两个数
nums[i]
和nums[j]
作为四元组的前两个元素。 - 双指针: 在内层循环中,使用两个指针
left
和right
,分别指向当前数nums[i]
和nums[j]
的下一个元素。计算当前四个数的目标和aim
。 - 判断和与目标值的关系:
- 首先这里不管是aim值还是sum值都要注意一个溢出的问题,所以我们这里都要使用
long long
进行类型的提升 - 如果
sum > aim
,说明右侧的数太大,将right
向左移动一步。 - 如果
sum < aim
,说明左侧的数太小,将left
向右移动一步。 - 如果
sum == aim
,说明找到了一个满足条件的四元组,加入结果集中,并进行去重操作。
- 首先这里不管是aim值还是sum值都要注意一个溢出的问题,所以我们这里都要使用
- 去重操作: 在找到一个满足条件的四元组后,需要对
left
、right
、i
、j
进行去重操作,即跳过相同的元素,避免结果中出现重复的四元组。 - 返回结果: 最终返回所有满足条件的四元组组成的结果集。
class Solution {
public:
vector<vector<int>> fourSum(vector<int>& nums, int target) {
sort(nums.begin(),nums.end());
vector<vector<int>> ret;
int n = nums.size();
for(int i=0;i<n;){
for(int j=i+1;j<n;){
int left=j+1,right=n-1;
long long aim=(long long)target-nums[i]-nums[j];
while(left<right){
long long sum=(long long)nums[left]+nums[right];
if(sum>aim) right--;
else if(sum<aim) left++;
else{
ret.push_back({nums[i],nums[j],nums[left++],nums[right--]});
while(left<right && nums[left]==nums[left-1]) left++;
while(left<right && nums[right]==nums[right+1]) right--;
}
}
j++;
while(j<n && nums[j]==nums[j-1]) j++;
}
i++;
while(i<n && nums[i]==nums[i-1]) i++;
}
return ret;
}
};