算法 --- 双指针
双指针思想
常见的双指针有两种形式,一种是对撞指针,一种是左右指针。
对撞指针:一般用于顺序结构中,也称左右指针。
对撞指针从两端向中间移动。一个指针从最左端开始,另一个从最右端开始,然后逐渐往中间逼近。
对撞指针的终止条件一般是两个指针相遇或者错开 〈也可能在循环内部找到结果直接跳出循环) ,也就是:
- left == right (两个指针指向同一个位置)
- left > right (两个指针错开)
快慢指针:又称为龟免赛跑算法,其基本思想是使用两个移动速度不同的指针在数组或链表等序列结构上移动。
这种方法对于处理环形链表或数组非常有用。
其实不单单是环形链表或者是数组,如果我们要处理的问题出现循环往复的情况时,均可考虑使用快慢指针的思想。(典型的判断成环形就是使用快慢指针)
快慢指针的实现方式有很多种,最常用的一种是:
在一次循环中,每次让慢的指针移动一位,而快的指针往后移动两位,实现一快一慢。
注意,指针并不是像:int* 这样的
对于数组,我们就可以利用数组下标来充当指针!
下面可以只看涂色部分!
283. 移动零 - 力扣(LeetCode)
其实这一道题可以成一类 --- 数组划分,数组分块:就是给一个数组,制定了一个标准/规则,让我们在这种规则下,将这个数组划分成若干个区间!
【数组分块】是非常常见的一种题型,主要就是根据一种划分方式,将数组的内容分成左右两部分。这种类型的题一般使用【双指针】来解决。
解法(快排的思想:数组划分区间 —— 数组分两块):
算法思路:
在本题中,可以使用一个 cur 指针来扫描整个数组,另一个 dest 指针用来记录非零数序列的最后一个位置。根据 cur 在扫描过程中遇到的不同情况,进行分类处理,从而实现数组的划分。
在 cur 遍历期间,确保 [0, dest] 的元素全部是非零元素,而 [dest + 1, cur - 1] 的元素全是零。
算法流程:
-
初始化 cur = 0(用来遍历数组),dest = -1(指向非零元素区的最后一个位置,因为刚开始不知道最后一个非零元素在什么位置,所以初始化为 -1)。
-
cur 依次往后遍历每个元素,遍历到的元素会有以下两种情况:
-
遇到的元素是 0,cur 直接 ++。因为目标是让 [dest + 1, cur - 1] 的元素全都是零,所以当 cur 遇到 0 的时候,直接 ++,就可以让 0 在 cur - 1 的位置上,从而在 [dest + 1,cur - 1] 内。
-
遇到的元素不是 0,dest++ 并且交换 dest 位置和 cur 位置的元素,之后让 cur++,扫描下一个元素。因为 dest 指向的是非零元素区间的最后一个位置,如果扫描到一个新的非零元素,那么它的位置应该在 dest + 1 的位置上,所以 dest 先自增 1。
-
-
dest++ 之后,dest 指向的位置是 0 元素(因为非零元素区间末尾的后一个元素就是 0),因此可以交换到 cur 所处的位置上,实现 [0, dest] 的元素全部是非零元素,而 [dest + 1, cur - 1] 的元素全是零。
cur:从左往右扫面数组,遍历数组
dest:以处理的区间内,非零元素的最后一个位置
算法总结:
这个方法是往后我们学习 「快排算法」的时候, 「数据划分」 过程的核心一步。如果将快排算法拆解的话,这一段小代码就是实现快排算法的「核心步骤」。
1089. 复写零 - 力扣(LeetCode)
解法(异地复写 - 双指针)
现根据“异地”操作,然后优化成双指针下的就地操作!
算法思路
如果采用「从前向后」的方式进行原地复写操作,由于 0 的复写会覆盖掉它后面的元素,从而导致尚未复写的数「被覆盖掉」。因此,选择「从后向前」的复写策略。
然而,在「从后向前」复写时,需要明确「最后一个复写的数」的具体位置。因此,整个算法流程分为两大步骤:
-
先找到最后一个复写的数;
-
然后从后向前进行复写操作。
算法流程
初始化指针:
初始化两个指针:cur = 0(用于遍历数组),dest = -1(用于记录复写位置)。
找到最后一个复写的数:
循环执行以下步骤:
-
判断 cur 位置的元素:
-
如果是 0:dest 向后移动两位;
-
如果不是 0:dest 向后移动一位。
-
-
判断 dest 是否已经到达结束位置:
-
如果到达结束位置,终止循环。
-
-
如果没有结束,cur++,继续判断。
处理越界情况:
判断 dest 是否越界到 n 的位置(n 为数组长度):
-
如果越界(dest == n),执行以下三步:
-
将 n - 1 位置的值修改成 0;
-
cur 向后移动一步;
-
dest 向前移动两步。
-
从后向前复写数组:
从 cur 位置开始往前遍历原数组,依次还原出复写后的结果数组:
-
判断 cur 位置的值:
-
如果是 0:
-
dest 以及 dest - 1 位置修改成 0;
-
dest -= 2;
-
-
如果不是 0:
-
dest 位置修改成 cur 位置的元素;
-
dest -= 1;
-
-
-
cur--,继续复写下一个位置。
202. 快乐数 - 力扣(LeetCode)
问题分析
为了方便叙述,将「对于一个正整数,每一次将该数替换为它每个位置上的数字的平方和」这一操作记为 x 操作。
题目告诉我们,当我们不断重复 x 操作的时候,计算一定会「死循环」,死的方式有两种:
-
情况一:一直在 1 中循环,即 1 -> 1 -> 1 -> 1......
-
情况二:在历史的数据中死循环,但始终变不到 1
由于上述两种情况只会出现一种,因此,只要我们能确定循环是在「情况一」中进行,还是在「情况二」中进行,就能得到结果。
简单证明
a. 经过一次变化之后的最大值为 9² × 10 = 810(2³¹-1 = 2147483647,但 9999999999 是比 2³¹-1 更大的数,不过其平方和也只有 9² × 10 = 810),也就是变化的区间在 [1, 810] 之间。
b. 根据「鸽巢原理」,一个数变化 811 次之后,必然会形成一个循环。
c. 因此,变化的过程最终会走到一个圈里面,因此可以用快慢指针来解决。
解法(快慢指针)
算法思路
我们可以用快慢指针来判断链表是否有环,思路是:快指针每次走两步,慢指针每次走一步。
而「快慢指针」有一个特性,就是在一个圆圈中,快指针总是会追上慢指针的,也就是说他们总会相遇在一个位置上。如果相遇位置的值是 1,那么这个数一定是快乐数;如果相遇位置的值不是 1 的话,那么就不是快乐数。
所以当我们面对某种循环问题的时候,我们就可以考虑一下快慢双指针!
补充知识:如何求一个数 n 每个位置上的数字的平方和
a. 把数 n 的每一位数字提取出来:
循环执行:
i. int t = n % 10 提取个位;
ii. n /= 10 干掉个位;
直到 n 的值变为 0。
b. 提取每一位的时候,用一个变量 tmp 记录这一位的平方与之前提取位数的平方和:
tmp = tmp + t × t
11. 盛最多水的容器 - 力扣(LeetCode)
解法一(暴力求解)(会超时)
算法思路
枚举出能构成的所有容器,找出其中容积最大的。
容器容积的计算方式:
设两指针 i,j,分别指向水槽的两板,此时围成的区域面积为:
S(i, j) = min(height[i], height[j]) × (j - i)
算法代码
class Solution {
public:int maxArea(vector<int>& height) {int n = height.size();int ret = 0;// 两层循环枚举所有容器for (int i = 0; i < n; i++) {for (int j = i + 1; j < n; j++) {// 计算当前容器的容积ret = max(ret, min(height[i], height[j]) * (j - i));}}return ret;}
};
解法二(对撞指针)--- 我们就可以利用单调性来使用双指针解决问题!
算法思路
设两个指针 left,right 分别指向容器的左右两个端点,此时容器的容积为:
v = (right - left) × min(height[right], height[left])。
容器的左边界为 height[left],右边界为 height[right]。
为了方便叙述,我们假设「左边边界」小于「右边边界」。
如果此时我们固定一个边界,改变另一个边界,水的容积会有如下变化形式:
-
容器的宽度一定变小。
-
由于左边界较小,决定了水的高度。如果改变左边界,新的水面高度不确定,但是一定不会超过右边的柱子高度,因此容器的容积可能会增大。
-
如果改变右边界,无论右边界移动到哪里,新的水面高度一定不会超过左边界,也就是不会超过现在的水面高度,因此容器的容积一定会变小。
由此可见,左边界和其余边界的组合情况都可以省略。所以我们可以 left++ 跳过这个边界,继续去判断下一个左右边界。
我们不断重复上述过程,每次都可以舍去大量不必要的枚举过程,直到 left 与 right 相遇。期间产生的所有的容积的最大值,就是最终答案。
611. 有效三角形的个数 - 力扣(LeetCode)
解法一(三层循环枚举三元组)
算法思路
三层 for 循环枚举三元组,并且判断是否能构成三角形。
虽然说是这样,但还是想优化一下:
-
判断三角形的优化:
-
如果能构成三角形,需要满足任意两边之和要大于第三边。但实际上只需让较小的两条边之和大于第三边即可。(重点!!!)
-
因此,我们可以先将原数组排序,然后从小到大枚举三元组,一方面减少枚举的数量,另一方面方便判断是否能构成三角形。
-
算法代码
3N^3 --- NlogN + N^3(提升)
class Solution {
public:int triangleNumber(vector<int>& nums) {sort(nums.begin(), nums.end());int n = nums.size(), ret = 0;for (int i = 0; i < n; i++) {for (int j = i + 1; j < n; j++) {for (int k = j + 1; k < n; k++) {// 判断是否能构成三角形if (nums[i] + nums[j] > nums[k]) {ret++;}}}}return ret;}
};
解法二(排序 + 双指针)--- 利用单调性,使用双指针解决
正常来说,有序就是二分算法的前提,但是这一题最好的解法是双指针!
算法思路
先将数组排序。
根据「解法一」中的优化思想,我们可以固定一个最长边 c,然后在比这条边小的有序数组中找出一个二元组,使这个二元组与最长边 c 能构成三角形。由于数组是有序的,我们可以利用双指针来优化。
设最长边枚举到位置 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++ 进入下轮循环。
-
LCR 179. 查找总价格为目标值的两个商品 - 力扣(LeetCode)
解法一( 暴力解法,会超时)
算法思路
两层 for 循环列出所有两个数字的组合,判断是否等于目标值。
算法流程
-
外层 for 循环:循环依次枚举第一个数 a;
-
内层 for 循环:循环依次枚举第二个数 b,让它与 a 匹配;
-
这里有个魔鬼细节:挑选第二个数时,从 a 的下一个数开始选,因为 a 前面的数已经在之前考虑过了;
-
-
然后将挑选的两个数相加,判断是否符合目标值。
算法代码
class Solution {
public:vector<int> twoSum(vector<int>& nums, int target) {int n = nums.size();for (int i = 0; i < n; i++) { // 第一层循环从前往后列举第一个数for (int j = i + 1; j < n; j++) { // 第二层循环从 i + 1 开始列举第二个数if (nums[i] + nums[j] == target) // 两个数的和等于目标值,说明我们已经找到结果return {nums[i], nums[j]};}}return {-1, -1};}
};
解法二(双指针 - 对撞指针)
算法思路
注意到本题是升序的数组,因此可以用「对撞指针」优化时间复杂度。
算法流程(附带算法分析,为什么可以使用对撞指针)
-
初始化 left,right 分别指向数组的左右两端(这里不是传统意义上的指针,而是数组的下标)。
-
当 left < right 的时候,一直循环:
-
如果 nums[left] + nums[right] == target 时,说明找到结果,记录结果,并且返回;
-
如果 nums[left] + nums[right] < target 时:
-
对于 nums[left] 而言,此时 nums[right] 相当于是 nums[left] 能碰到的最大值(别忘了,这里是升序数组哈~)。如果此时不符合要求,说明在这个数组里面,没有别的数符合 nums[left] 的要求了(最大的数都满足不了你,你已经没救了)。因此,我们可以大胆舍去这个数,让 left++,去比较下一组数据;
-
-
如果 nums[left] + nums[right] > target 时:
-
同理我们可以舍去 nums[right](最小的数都满足不了你,你也没救了)。让 right--,继续比较下一组数据,而 left 指针不变(因为他还是可以去匹配比 nums[right] 更小的数的)。
-
-
15. 三数之和 - 力扣(LeetCode)
解法(排序 + 双指针)
算法思路
本题与两数之和类似,是非常经典的面试题。与两数之和稍微不同的是,题目中要求找到所有「不重复」的三元组。我们可以利用在两数之和那里用的双指针思想,来对我们的暴力枚举做优化:
-
先排序;
-
然后固定一个数 a;
-
在这个数后面的区间内,使用「双指针算法」快速找到两个数之和等于 -a 即可。
但是要注意的是,这道题里面需要有「去重」操作:(没必要找出的结果进行排序,然后利用 set 去重,我们可以直接先排序)
-
找到一个结果之后,left 和 right 指针要「跳过重复」的元素;
-
当使用完一次双指针算法之后,固定的 a 也要「跳过重复」的元素。
算法流程
-
首先对数组进行排序。(不仅仅可以进行二数之和的解决,还解决了去重操作!)
-
枚举数组中的每个元素 nums[i] 作为三元组的第一个数。
-
对于每个 nums[i],使用双指针方法在 nums[i+1] 到 nums[n-1] 中找到和为 -target + nums[i] 的两个数。
-
在使用双指针时,跳过重复元素以避免找到重复的三元组。
-
返回所有找到的三元组。
我们目前就可以发现,对于三种“数”的问题,我们可以先固定一个数,然后利用双指针等直接解决!
18. 四数之和 - 力扣(LeetCode)
解法(排序 + 双指针)
算法思路
-
依次固定一个数 a;
-
在这个数 a 的后面区间上,利用「三数至和」找到三个数,使这三个数的和等于 target - a 即可。