个人刷题对这两种常见技巧的总结
一,双指针
双指针并不是一种严格意义的有模板的算法,往往是对一种基础的解法的优化(在空间上的优化),具体可以分为同向双指针,两侧向中间双指针,不同速双指针等,一般涉及贪心,单调性(可以从其联想到双指针),或者答案与位置有关的时候可以联想双指针,更多的还是从题中去体会。
(1) leetcode 922.奇偶排序(下面的题如果没有特别标明都是leetcode)
给定一个非负整数数组 nums
, nums
中一半整数是 奇数 ,一半整数是 偶数 。
对数组进行排序,以便当 nums[i]
为奇数时,i
也是 奇数 ;当 nums[i]
为偶数时, i
也是 偶数 。
你可以返回 任何满足上述条件的数组作为答案 。
首先此题的实现非常简单,可以额外开两个数组遍历后奇数放在一个数组偶数放在一个数组,但用双指针更快,因为是两个独立的部分需要分别判断后区分开,且判断后要进行排序,也就是和数组中位置有关,很容易想到双指针的解法,用odd,even两个指针来指向为排序的位置(奇偶排序),而后永远只看最后一个位置,判断后送到odd指针或even指针处,再进行下一个判断即可,(odd/even+=2,到下一个为排序的位置),odd或even只要有一个越界就结束(因为不是奇数就是偶数),总体上是很简单的一道题,贴代码。
class Solution {
public:
vector<int> sortArrayByParityII(vector<int>& nums) {
int len=nums.size();
for(int odd=1,even=0;odd<len&&even<len;){
if(nums[len-1]&1){
swap(nums[len-1],nums[odd]);
odd+=2;
}else{
swap(nums[len-1],nums[even]);
even+=2;
}
}
return nums;
}
};
(2)287 寻找重复数
给定一个包含 n + 1
个整数的数组 nums
,其数字都在 [1, n]
范围内(包括 1
和 n
),可知至少存在一个重复的整数。
假设 nums
只有 一个重复的整数 ,返回 这个重复的数 。
你设计的解决方案必须 不修改 数组 nums
且只用常量级 O(1)
的额外空间。
最常规思路其实就是遍历,找到一个第一次找到的数字就存一下,找到重复的就break返回即可,但是要求不用额外空间,而且注意到一个特别的要求,数值的范围和下标范围是几乎一样的,所以容易联想到数值->位置的关系,用指针作为连接,可以从位置-》数值->该数值代表的位置->..这个大体的思路,继续好好完善一下不难得到一个双指针的思路,有点像(不对,就是)找单链表入环节点的套路,因为有一个重复数字就意味着他的下标去往下走一定可以找到一个数值和下标一样,这样就又回到了一开始出现的位置,快慢指针去走,快指针一次走两步,慢指针一次走一步,直到两个指针相遇,快指针回到起点,速度变为一次走一步,再次相遇就是入环节点(重复数字),贴代码。
class Solution {
public:
int findDuplicate(vector<int>& nums) {
int slow=nums[0],fast=nums[nums[0]];
while(slow!=fast){
slow=nums[slow];
fast=nums[nums[fast]];
}
fast=0;
while(slow!=fast){
slow=nums[slow];
fast=nums[fast];
}
return slow;
}
};
(3) 42 接雨水(一维)
给定 n
个非负整数表示每个宽度为 1
的柱子的高度图,计算按此排列的柱子,下雨之后能接多少雨水。
就问题分析,一个柱子上可以留下水要满足什么条件?如果把每个柱子上的水都算出来再求和就可以得出答案了,那么画图分析一下不难发现,一个柱子上留下的水取决于其左右侧分别最大值中较小的那一个(构成水平面),再与柱子本身的高度求差值就可以得出答案了。
那么一个非常简单的思路就形成了,就是暴力的维护两个数组代表从左到右和从右到左的最大值,遍历数组的时候就查询一下左侧最大值和右侧最大值,然后算出该位置的值,求和就可以了,具体看代码。
class Solution {
public:
int trap(vector<int>& height) {
//暴力版
int lmax[20005],rmax[20005];
long long ans=0;
int maxn=-1;
for(int i=0;i<height.size();i++){
maxn=max(maxn,height[i]);
lmax[i]=maxn;;
}
maxn=-1;
for(int i=height.size()-1;i>=0;i--){
maxn=max(maxn,height[i]);
rmax[i]=maxn;
}
for(int i=0;i<height.size()-1;i++){
ans+=min(lmax[i],rmax[i])>height[i]?min(lmax[i],rmax[i])-height[i]:0;
}
return ans;
}
};
这个方法的时间复杂度是o(n),空间复杂度也是o(n),可以用双指针在空间上进行优化,当在左右两侧各放一个指针的时候,lmax代表从最左侧到左指针最大值,rmax同理,那么假设lmax<rmax那么其实说明左指针所指向的位置其实已经可以清算了,因为右侧rmax不管如何更新,只可能变大,不可能变小,那么这个位置清算的水平线只和lmax有关了,如此我们不难得出指针前进的条件就是,哪一侧的l/r max更小就清算对应指针位置柱子上方的水,然后在让该指针前进,因为如果不让该侧的max变到比另一侧更大,就永远以该侧为基准清算,直到左右指针相遇,所有柱子都已经清算完了,那么就实现了一个o(1)空间的算法了,细节见代码。
class Solution {
public:
int trap(vector<int>& height) {
int ans=0;
int l=1,r=height.size()-2;
int lmax=height[0],rmax=height[height.size()-1];
while(l<=r){
if(lmax<=rmax){
ans+=max(0,lmax-height[l]);
lmax=max(lmax,height[l++]);
}else{
ans+=max(0,rmax-height[r]);
rmax=max(rmax,height[r--]);
}
}
return ans;
}
};
这题用单调栈也可以便捷的解决,但不属于这个博客要复习的知识 故略。
(4)881 救生艇
给定数组 people
。people[i]
表示第 i
个人的体重 ,船的数量不限,每艘船可以承载的最大重量为 limit
。
每艘船最多可同时载两人,但条件是这些人的重量之和最多为 limit
。
返回 承载所有人所需的最小船数 。
这题就是很容易想到一个贪心的思路,因为我们要求最小的船数,那么肯定希望最多的船可以载两个人,如果单个人的体重超过limit那无能为力只能自己一个人乘船,除去这些人以外,最容易两个人可以搭船的方案肯定是优先解决最困难的也就是体重最大的去找目前体重最小的,如果可以两人乘船那就直接让他们乘船,这样无疑是最优的方案,目前最重的人可以两人乘船,肯定是最赚的,那就很容易想到双指针的思路了,简而言之排序->除去个体超标->首尾双指针->直到指针相遇结束,过程中时刻维护ans即可,细节见代码。
class Solution {
public:
int numRescueBoats(vector<int>& people, int limit) {
sort(people.begin(),people.end());
int l=0,r=people.size()-1;
int ans=r;
while(people[r]>=limit){
r--;
}
ans-=r;
while(l<=r){
if(people[r]+people[l]<=limit&&l!=r) l++;
r--;
ans++;
}
return ans;
}
};
变式:如果两人体重之和必须是偶数,最小船数又怎么算?
两个数和是偶数,就这个问题而言,那就要求两个数必须是相同奇偶性的,那就把所有体重为奇数的和偶数的分开,再去分别用这个解决方法最后求和即可。
(5) 11 最多水的容器
给定一个长度为 n
的整数数组 height
。有 n
条垂线,第 i
条线的两个端点是 (i, 0)
和 (i, height[i])
。
找出其中的两条线,使得它们与 x
轴共同构成的容器可以容纳最多的水。
返回容器可以储存的最大水量。
说明:你不能倾斜容器。
这道题很容易想到暴力解法,就是把每个可能大小遍历然后记录最大值,但显然不现实,这并不是这道题希望我们想到的做法,所以我们需要想一些简化的方法,比如剪掉一部分的不可能出现最大值的分支,面对这道问题本身,我们首先要明确,给出一个左右两个端点下标,怎么计算此时的最大水量呢,显然是取左右两个柱子较低的那个,再乘以两个下标的差值(当然是绝对值),那么能不能进行一些剪枝呢,假如我们用双指针去做(从两边到中间移动指针),那么什么条件满足我们才去移动指针呢,可以简单设想一个例子,假设左3右5,那么水的高度肯定取决于左3,那么因为我们移动指针容器的长一定会变小,我们只能希望水高可以高一点,那么如果此时移动右指针,如果此后的柱子更高,没有任何意义因为还是水高为3,如果更矮,水高会变小,肯定不是优于一开始的解的,所以很明显应该移动左指针(即更矮的那个)来追求更高的水高,这个问题解决后就没有难题了,问题迎刃而解,一个o(n)的算法已经足够优秀了,细节看代码。
class Solution {
public:
int maxArea(vector<int>& height) {
int l=0,r=height.size()-1;
int maxn=-1;
while(l<=r){
maxn=max(maxn,(r-l)*min(height[l],height[r]));
if(height[l]<=height[r]){
l++;
}else r--;
}
return maxn;
}
};
(6) 41.缺失的第一个正整数
给你一个未排序的整数数组 nums
,请你找出其中没有出现的最小的正整数。
请你实现时间复杂度为 O(n)
并且只使用常数级别额外空间的解决方案。
这道题常规解法很简单,但是要求o(n)的时间复杂度就很困难了,也就是说我们在看到一个数的时候就要判断它对于最终答案的价值并加以处理,双指针的思路很不好想,如果没有限制的去做,我们肯定是希望找到1,2,3,....一直到第一个找不到的正整数就是答案,我们用o(n)的复杂度也应该尽量去还原这个思路。
我们可以用两边的双指针,l代表满足了我们要找的1,2,3,...的形式的区域的右边界,r代表无用的区域同时也代表最理想情况下答案的值,直到l,r相遇的时候就是完成了对整个数组的判断,此时l+1就是答案。
那么如何判断指针是否应该前进呢,首先最简单的情况就是直接满足了nums[l]=l+1的标准情况,其次就是当nums[l]的位置的值大于r(理想答案),肯定属于垃圾,swap到垃圾区域,r--,理想下降了,那么如果这个位置的值小于l+1,肯定属于无用的值,因为他要么和已经统计过(左侧的统计区)有重复,要么不是正整数。那么对于同处于最终可以统计到的答案区间内的数又该如何做到区分呢,巧妙的思路就是去到l对应值所对下标的位置(nums[nums[l]-1]),这样就可以找到一个可能有效的数据,让遍历继续下去,如果和l位置的值重复了,那肯定是垃圾因为我们不需要重复统计,如果是一个合理的数据(不重复)那就把他换过来,再对他进行判断,这样就可以做到,除非满足条件,否则l不会前进,再这个过程中还可以一直找到垃圾让r向左移动与l迫近,也就是成功的判断了整个数组,最终答案就是l+1,细节看代码。
class Solution {
public:
int firstMissingPositive(vector<int>& nums) {
int l=0,r=nums.size();
while(l<r){//经典双指针判断
if(nums[l]==l+1) {
l++;//唯一可以让统计区移动的方式就是找到了合理的数据
continue;
}
if(nums[l]>r||nums[l]<=l||nums[nums[l]-1]==nums[l]){
swap(nums[l],nums[--r]);//扩大垃圾区
} else swap(nums[l],nums[nums[l]-1]);//拉来一个合理区域的新数字进行判断
}
return l+1;//可以实现等差排列的最后一个数字自然就是答案
}
};
至此,双指针的题目暂时告一段落,可能还会有后续的补充,总的来说,双指针是一种很巧妙的优化算法,关键在于找到移动双指针的条件,并判断如果移动会不会可能遗漏了最终的答案来检验你定下的条件是否合理,通过移动指针实现了省去空间以及省去部分需要探究是否是最优解的可能,多多体会吧.....
二,滑动窗口
滑动窗口总体上去说也是一种双指针,他一般是用来解决连续结构上的存在或者最值问题,解题的常见思路就是用两个指针表示左闭右开的区间端点,滑动窗口直到达到合理位置然后进行判断(记录),再继续进行滑动,两个指针都不回退,也就是一个o(n)的算法,一般与单调性和贪心有关,或题中出现(子数组,子串)等情况时候可能用到滑动窗口,具体在题中去体会。
(1) 209.最短子数组长度
给定一个含有 n
个正整数的数组和一个正整数 target
。
找出该数组中满足其和 ≥ target
的长度最小的 连续子数组 [numsl, numsl+1, ..., numsr-1, numsr]
,并返回其长度。如果不存在符合条件的子数组,返回 0
。
这题常规思路就是o(n^2)找到一个子数组开头记录sum刚好满足条件的情况,也可以用前缀和加二分查找(官方题解),但切合主题,这道题我们用滑动窗口去做,首先如何满足最短的情况,我们一直向窗口里加入右边界的值,但是这时不一定是最短的,我们应该更新左侧一直缩短直到他再缩短就不合格了的时候结束,更新答案,一直到右边界越界,这样就确保了以每个元素为右边界时候的解一定被记录过(证明略),是一个o(n)复杂度的算法,细节看code。
class Solution {
public:
int minSubArrayLen(int target, vector<int>& nums) {
int i=0,j=0;
int minlen=INT_MAX;
long long sum=0;
int n=nums.size();
for(;j<n;j++){
sum+=nums[j];
while(sum-nums[i]>=target){//滑动窗口至临界满足条件
sum-=nums[i++];
}
if(sum>=target){//符合条件则清算
minlen=min(minlen,j-i+1);
}
}
return minlen==INT_MAX?0:minlen;
}
};
(2) 无重复字符的最长子串
给定一个字符串 s
,请你找出其中不含有重复字符的 最长子串 的长度。
又是一个涉及连续子串的问题,很容易想到用滑动窗口,那么如何更新窗口呢,才能做到不遗漏,首先对于任何一个可以进行结算的答案必须保证无重复字符,那我们就需要一个查询结构,可以用哈希表/集合来负责查询,找到一个未出现过的字符就滑动窗口并记录这个字符,如果找到了重复字符,就先清算,然后缩小窗口直到刚好将这个新出现的重复字符排除在外,在开始新的判断,一直到窗口出现越界,需要注意的是,越绝的时候当前窗口也有可能是最优解,但他不满足-遇到重复字符的清算条件,所以我们要在循环外再次清算,来确保没有遗漏,细节看code。
class Solution {
public:
int lengthOfLongestSubstring(string s) {
unordered_map <char,int> st;
int len=s.size();
int maxlen=0;
int i,j;
for( i=0,j=0;j<len;j++){
if(st[s[j]]==0){
st[s[j]]++;
}else{
maxlen=max(maxlen,j-i);
while(st[s[j]]==1){
st[s[i++]]--;
}
st[s[j]]++;
}
}
maxlen=max(maxlen,j-i);
return maxlen;
}
};
(3) 134. 加油站
在一条环路上有 n
个加油站,其中第 i
个加油站有汽油 gas[i]
升。
你有一辆油箱容量无限的的汽车,从第 i
个加油站开往第 i+1
个加油站需要消耗汽油 cost[i]
升。你从其中的一个加油站出发,开始时油箱为空。
给定两个整数数组 gas
和 cost
,如果你可以按顺序绕环路行驶一周,则返回出发时加油站的编号,否则返回 -1
。如果存在解,则 保证 它是 唯一 的。
分析题目很容易想到第一步处理就是生成一个代价数组,代表经过一个加油站要付出的代价,可能是正可能是负的,那么一个合格的答案一定需要满足在经过一圈的每一个加油站的时候,剩余的油量非负,由于我们要返回起点加油站,那么可以用滑动窗口从第一个位置开始滑动,同时用变量sum代表此时油量,当sum变为负数的时候说明当前窗口左侧(即起点)不合格,就滑动窗口直到sum非负,此时左侧才有可能是一个合格的起点,知道左右侧差距为加油站个数且sum非负才是合格答案,一个细节在于r大于加油站个数的时候可以用取余操作使得可以实现“圈”的操作,全部细节见code。
class Solution {
public:
int canCompleteCircuit(vector<int>& gas, vector<int>& cost) {
for(int i=0;i<gas.size();i++){
gas[i]-=cost[i];
}
int sum=0;
for(int l=0,r=0;r<=2*gas.size();r++){
sum+=gas[r%gas.size()];
while(sum<0){
sum-=gas[l%gas.size()];
l++;
}
if(r-l==gas.size()){
return l;
}
}
return -1;
}
};
滑动窗口 不是特别高端的算法所以简单总结几道题,首先他的优点可以动态清算答案,一般在连续结构的最优解(最小最大值)问题上可以应用滑动窗口简化求解。其关键在于判断什么时候停止扩大窗口,窗口缩小到什么时候停止,以及如何进行清算。
至此 双指针,滑动窗口算法个人的总结结束,本文个人根据网络视频和资料总结,仅供个人复习使用。