分享|双指针刷题笔记总结
4174
2021.11.23
发布于 未知归属地

前言

本文记录我学习双指针的一些笔记。

原理

双指针并不是一个具体的具体的算法,而是一种思路,是相对于两次遍历而言的。通常可以把两重 for 循环优化到一次遍历。

双指针的范围非常的广,通常在链表,数组,字符串中使用比较广泛。

双指针本质上还是一个搜索算法,相对于穷举,很多优化都是剪枝,压缩搜索空间。二分、滑动窗口就是很典型的双指针,它们基本都是通过一些技巧进行剪枝从而达到优化的目的的。

根据指针的移动步长,移动方式,对应的时间复杂度也不同,如果步长是 2,或者像二分那样对撞并且跳着移动,就是 ,常规的移动方式则是 O(n),而空间复杂度是 O(1)。

由于双指针的空间复杂度为 ,因此在一些原地算法中,双指针就比较常见。

分析

  1. 快、慢指针指向什么?
  2. 双指针移动方向是什么?
  3. 起始位置是什么?
  4. 什么时候移动指针?
  5. 怎么移动指针?
心得:
很多时候我们刷题容易陷入这种情况:不知道为什么出了错,一个测试过不了,
就单步或者手动模拟这个测试的执行过程,但是总是有测试出了问题,根据测试数据修改代码,修修改改的,
即使最后 ac l,也感觉自己好像并没有学到些什么,下一次遇到还是不会,总感觉是硬凑出来的。

我就经常遇到这种情况。

根据我的经验:这是因为自己对这个题理解不够深,没有从合适的角度去分析和理解这个题,要知道
刷题是需要动脑子的,上面那种一直单步调试似乎很努力,但是一点作用都没有,因为我们的大脑根本
没有动起来。

这是正常的,大脑本来就会主动去寻找消耗低的路径。

那么要怎么改善呢?
其实很简单,就是样对一个题型养成思维定势,然后固定几个问题,拿到问题就先从这些角度分析,而不是
动手就写代码。

这是我刷滑动窗口的经验,每拿到一个题我就会问滑动窗口的一些要点,比如这题窗口内的状态是什么?
什么时候扩展?什么时候收缩?窗口是固定大小的吗?就这样一步一步的细化题目,之后分析清楚后,
再开始刷题。

而最近刷二分我又陷入那种低效率的方式,反思一下发现自己并没有养成思维定势那样去分析,甚至
有些题动手就刷,有种为了 ac 而 ac 的怠卷期了。这时如果比较累了,建议先休息一下,比较刷题
动脑是非常重的。

分类

根据指针移动方向是否相同

移动的方式:跳跃还是移动,两者相同还是不同,每次一样还是不同等等。

快慢指针:不定长滑动窗口,链表反转,环,倒数第 k 个

对撞指针(左右指针):反转,数组和,有序,二分

固定间隔指针:固定滑动窗口

  • 快慢指针(前后按不同步调的两个指针),快指针在前,慢指针在后,利用两者间的距离解决问题
  • 前后双端指针(一个在首,一个在尾部,向中间靠拢),用来界定范围,或者两个碰撞点
  • 固定间隔的指针(以 i, i + k 的间距的两个指针)

模板

快慢指针

// 1.快慢指针
l = 
r = 
while 没有遍历完
  if 一定条件
    l += 
  r += 
return 合适的值

对撞指针

//2. 左右端点指针
l = 0
r = n - 1
while l < r
  if 找到了
    return 找到的值
  if 一定条件
    l += 
  else if  一定条件
    r -= 
return 没找到

固定间隔指针

//3. 固定间距指针
l = 0
r = k
while 没有遍历完
  自定义逻辑
  l += 
  r += 
return 合适的值

题型

固定间隔的指针

快慢指针

  • list

    141. 环形链表

    392. 判断子序列

    题意

    判断一个字符串是否是另一个字符串的子序列

    分析

    子序列就是不连续的子串,但是这题字符要顺序相同,如 abc 就是 sssagsbasc 的子序列。

    那我们开两个指针,分别指向两字符串的当前字符,然后遍历判断即可。

    优化

    效率

    时间复杂度:

    空间复杂度:

    代码

    class Solution {
    public:
        bool isSubsequence(string s, string t) {
            int i=0;
            int j=0;
    
            for (;j<t.size();j++){
                if (s[i]==t[j]){
                    i++;
                }
                if (i==s.size()){
                    break;
                }
            }
            
            return i==s.size() || j != t.size();
        }
    };

    58. 最后一个单词的长度

    题意

    求最后一个字符的长度

    分析

    开两个指针,从后往前遍历,找到单词边界即可。

    优化

    效率

    时间复杂度:

    空间复杂度:

    代码

    class Solution {
    public:
        int lengthOfLastWord(string s) {
            int l=0;
            int r=s.size()-1;
    
            while(r>=0 && s[r]==' '){
                r--;
            }
            l=r;
            while(l>=0 && s[l]!=' '){
                l--;
            }
    
            return r-l;
        }
    };

    面试题 01.06. 字符串压缩

    题意

    将连续字符变成字符+出现次数

    分析

    要将字符变成连续字符,那么需要找到连续字符的次数,故开快慢指针,快指针扫描计算次数,慢指针为字符,统计结束后转换为字符+数字即可。

    优化

    本题需要注意几个坑:

    1. 由于字符出现次数可能操作 9,故不能直接 '1'+出现次数转换为出现次数的字符,因为可能不只一个字符,故需要直接将 int 转换为 string
    2. 生成结果字符串时,使用 res=res+a 会超时+内存溢出,而使用 += 则不会,这是因为 += 相当于 append,直接在原字符串后增加字符,而 = + 则是新开内存

    效率

    时间复杂度:

    空间复杂度:

    代码

    class Solution {
    public:
        string compressString(string s) {
            if (s.empty()){
                return s;
            }
            string res;
            int l=0;
            int r=0;
            
            while(r<s.size()-1) {
                if (s[r]==s[r+1]) {
                    r++;
                    continue;
                }
                res+=s[l];            
                res+=to_string(r-l+1); 
                r++;
                l=r;
            }
            res+=s[l];
            res+=to_string(r-l+1);
    
            return res.size()<s.size()?res:s;
        }
    };

    844. 比较含退格的字符串

    题意

    比较去除退格字符后的字符是否相等

    分析

    由于退格字符时,前面的字符可能会被删掉,所以不能从前往后看,故开双指针,从后往前扫描。

    基本思想也很简单:

    从后往前扫描,分别找到第一个非退格字符即可,如果是退格字符,则需要记录一下个数,然后跳过相应个数的普通字符,之后在比较即可。

    上面是基本扫描一遍,问题在于,可能只有一个字符被扫描结束了,而另一字符可能没有扫描完,故还要继续判断。由于这个字符串已经扫描完,故如果要对比成功,那么另一个字符串余下的字符必然全部会被退格清掉。

    则有下面这些情况:

    1. 如果两者都扫描完了,则成功
    2. 如果 i 没有扫描完,且第一个字符不是退格字符,前面也没有积累退格字符,那么就必然不可能对比成功,故返回失败
    3. 同理 j 没有扫描完且第一个不是退格字符
    4. 如果 i 没有扫描完,就扫描判断,如果最后退格字符大于等于 0,说明普通字符被清空了,则对比成功
    5. 同理 j

    优化

    效率

    时间复杂度:

    空间复杂度:

    代码

    class Solution {
    public:
        bool backspaceCompare(string s, string t) {
            int i=s.size()-1;
            int j=t.size()-1;
            bool iFast=false;
            int cnti=0;
            int cntj=0;
    
            while(i>-1&&j>-1){
                if (s[i]=='#'){
                    i--;
                    cnti++;
                    continue;
                }
                if (cnti>0){
                    i--;
                    cnti--;
                    continue;
                }
                if (t[j]=='#'){
                    j--;
                    cntj++;
                    continue;
                }
                if (cntj>0){
                    j--;
                    cntj--;
                    continue;
                }
                if (s[i]!=t[j]){
                    return false;
                }
                i--;
                j--;
            }
    
    				// 如果两者都扫描完了,则成功
            if (i==-1 && j==-1){
                return true;
            }
    
    				// 第一个字符不是退格字符,前面也没有积累退格字符
            if ((j==-1) && (cnti==0) && (s[i]!='#')){
                return false;
            }
            if ((i==-1) && (cntj==0) && (t[j]!='#')){
                return false;
            }
    
            if (i==-1){
                iFast=true;
            }
    
            while(i>-1){
                if (s[i]=='#'){                
                    cnti++;
                }else {
                    cnti--;
                }
                i--;
            }
            while(j>-1){
                if (t[j]=='#'){
                    cntj++;
                } else {
                    cntj--;
                }
                j--;            
            }
    
    				// 如果最后退格字符大于等于 0,说明普通字符被清空了
            if (iFast){
                return cntj>=0;
            } else {
                return cnti>=0;
            }
        }
    };

    696. 计数二进制子串

    题意

    求连续 0 和 1 数量相等的子串

    分析

    要求 0 和 1 数量相等的子串,那么假设子串是由 n 个 0 和 m 个 1 组成的,即 0001111 或 111110000 等,那么显然,满足条件的子串长度等于 min(n,m).

    换一句话说,我们只要知道相邻 0 和 1 个数的较小值,就是最长的子串长度。如是 000111,那么这个字符串中满足条件的子串有 01,0011,000111,即长度的一半,也就是相邻 0 和 1 个数的较小值。

    那么我们只需要计算出相邻 0,1 的个数,再计算相邻两者的较小值之和即可。如 00110001 ,个数数组就是 2231,值就为 min(2,2)+min(2,3)+min(3,1).

    故我们预先计算出该数组,再遍历一次即可。

    优化

    根据上面的分析,我们知道其实某一时刻计算时,我们只需要知道相邻两数的个数即可,而和其它个数无关,那么我们可以用快慢指针来维护这两个值。快指针是当前种类的个数,慢指针是前一个种类的个数,然后遍历即可。

    效率

    时间复杂度:

    空间复杂度:优化前 ,优化后

    代码

    class Solution {
    public:
        int countBinarySubstrings(string s) {
            vector<int> nums;
            int cnt;
            int res=0;
            
            for (int i=0;i<s.size();i++){
                cnt=1;
                
                while (s[i]==s[i+1]) {
                    cnt++;
                    i++;
                }
                nums.push_back(cnt);
            }
            
            for (int i=0;i<nums.size()-1;i++) {
                res+=(min(nums[i],nums[i+1]));
            }
            
            return res;
        }
    };
    class Solution {
    public:
        int countBinarySubstrings(string s) {
            int cur=0;
            int pre=0;
            int cnt=0;
            int res=0;
            
            for (int i=0;i<s.size();i++){
                cnt=1;
                
                while (s[i]==s[i+1]) {
                    cnt++;
                    i++;
                }
                pre=cur;
                cur=cnt; 
                res+=(min(cur,pre));
            }
            
            return res;
        }
    };

    88. 合并两个有序数组

    题意

    合并两个有序数组

    分析

    由于最后的结果数组在 nums1 中,故可能会涉及到 nums1 中数组的移动,如果从前后往后遍历的话,就会出现移动的情况,因此开双指针从后往前移动,一个开始指向 m-1,一个指向 n-1,再开一个指向 nums1 中当前应该存放的位置,那么每次把双指针中大的一个放到指定位置中即可。

    最后,可能 nums2 数组并没有遍历完,因为里面的元素可能比 nums1[0] 的元素还要小,故最后再遍历余下的 nums2 数组,存放到指定位置即可。

    优化

    效率

    时间复杂度:

    空间复杂度:

    代码

    class Solution {
    public:
        void merge(vector<int>& nums1, int m, vector<int>& nums2, int n) {
            int i=m-1;
            int j=n-1;
            int k=nums1.size()-1;    
                    
            while(i>-1 && j>-1){
                if (nums1[i]>nums2[j]){
                    nums1[k]=nums1[i];
                    i--;
                } else {
                    nums1[k]=nums2[j];
                    j--;
                }
                k--;
            }
            
            while(j!=-1){
                nums1[k]=nums2[j];
                k--;
                j--;
            }
        }
    };

    31. 下一个排列

    题意

    将 nums 中的数全排列,问当前排序的下一个排序是什么

    分析

    首先,我们要知道什么是字典序全排序:对于数字来说,字典序全排序就是从小到大的数字。

    如 1,2,3,4 组成的全排列就是

    1234
    1243
    1324
    1342
    ...

    我们要找任意一个数的下一个排列,自然需要知道两个排列之间的关系。

    先说结论,那就是对于相邻的两排列,我们一定能找到某个分界点,使得小的排列分界点右边是最大值,而新的排列分界点右边是最小值。

    无效的图片地址

    如排列 5,1,4,3,2 和 5,2,1,3,4

    无效的图片地址

    我们能找到分割点 i,使得数 a 的右边是数字 4,3,2 组成的最大值 432,而 b 的右边是组成的最小值 1,2,3.

    知道这个性质之后,这个题就很简单了。其实这个性质也很好理解,就是类似于数字进位,进位前低位必然是最大值,进位后低位自然是最小值。而分割点就是进的位。

    首先,显然我们需要找到这个分界点,对于数 a 来说,分界点 i 右边的数显然是单调递减的,所以我们从右边开始遍历,找到第一个非递增的元素即可,即数 1。数 1 要进位的话,自然是进到刚刚比 1 大的数。

    故我们要在分割点右边,即 1 的右边三个数 1,3,4 中,找到第一个比分割点 1 大的数。而这就是一个在递减数组中找第一个大于的数,是个典型的二分操作。

    找到数后,在这是 2,然后和分割点那个数交换,即进位。

    无效的图片地址

    无效的图片地址

    交换后,我们再把分割点右边的数变成组合的最小值,在这就是 134.最简单的 sort 递增排列一下即可。不过注意观察,我们知道原先右边的数是递递减的,交换分割点后,其实仍没有改变递减的顺序。所以要变成最小值,即递增的,之间反转 reverse 下即可。

    所以总结一下,算法步骤就是:

    1. 从右向左找到第一个递减的元素
    2. 在右边二分该元素,找到第一个大于它的元素
    3. 交换两元素的值
    4. 反转右部

    需要注意的是,就是原先数组完全递增,是排列的最大值了。就找不到递减的元素,所以要判断一下,直接反转即可。

    优化

    效率

    时间复杂度:

    空间复杂度:

    代码

    class Solution {
    public:
        void nextPermutation(vector<int>& nums) {
            int i=nums.size()-1;
            
            // 从右向左找到第一个递减的元素
            while(i-1>-1 && nums[i]<=nums[i-1]){
                i--;
            }
            i--;
            
            int l=i+1;
            int r=nums.size()-1;
            int mid;
    
            // 原先数组完全递增,直接反转
            if (i==-1){
                reverse(nums.begin(),nums.end());
                return;
            }
            
            // 在右边二分该元素,找到第一个大于它的元素
            while(l<r) {
                mid=(r-l)/2+l;
                
                if (nums[mid]>nums[i]) {
                    l=mid+1;
                } else {
                    r=mid;
                }
            }
            
            if (nums[l]<=nums[i]){
                l--;
            }
               
            // 交换两元素的值
            swap(nums[i],nums[l]);        
            // 反转右部
            reverse(nums.begin()+i+1,nums.end());        
        }
    };

    556. 下一个更大元素 III

    题意

    将 nums 中的数全排列,问当前排序的下一个排序是什么

    分析

    类似于 31,只是给定的数不是数组,而是一个数的各个位,并且最大全排列不是继续循环,而是返回 -1.同时溢出还要注意一下而已。

    参考 31

    优化

    效率

    时间复杂度:

    空间复杂度:

    代码

    class Solution {
    public:
        int nextGreaterElement(int n) {
            vector<int> nums;
            int m;
            
            while (n!=0){
                m=n%10;
                n=n/10;
                nums.insert(nums.begin(),m);
            }
    
            int i=nums.size()-1;
            
            while(i-1>-1 && nums[i]<=nums[i-1]){
                i--;
            }
            i--;
            
            int l=i+1;
            int r=nums.size()-1;
            int mid;
                    
            if (i==-1){
                return -1;
            }
            
            while(l<r) {
                mid=(r-l)/2+l;
                
                if (nums[mid]>nums[i]) {
                    l=mid+1;
                } else {
                    r=mid;
                }
            }
            
            if (nums[l]<=nums[i]){
                l--;
            }
         
            swap(nums[i],nums[l]);        
            reverse(nums.begin()+i+1,nums.end());   
    
            long long res=0;
            
            for (int i=0;i<nums.size();i++){            
                res=res*10+nums[i];
            }
            
            if (res<=INT_MAX){
                return (int)res;
            }
            
            return -1;
        }
    };

对撞指针

  • list

    922. 按奇偶排序数组 II

    题意

    使数组 i 和 nums[i] 同奇偶

    分析

    对撞指针,左指针扫奇数,找到不满足的。右指针扫偶数,找到不满足的,交换即可。

    优化

    效率

    时间复杂度:

    空间复杂度:

    代码

    class Solution {
    public:
        vector<int> sortArrayByParityII(vector<int>& nums) {
            int i=0;
            int j=nums.size()-1;
            
            while(i<nums.size() && j>-1){
                if (nums[i]%2==0){
                    i+=2;
                    continue;
                }
                if (nums[j]%2!=0){
                    j-=2;
                    continue;
                }
                swap(nums[i],nums[j]);
    
                i=i+2;
                j=j-2;
            }
                            
            return nums;
        }
    };

    面试题 16.16. 部分排序

    题意

    问是否存在一个子数组,使得排列这个数组后,整个数组就是有序的。求子数组的最短长度。

    分析

    这题有点麻烦,没有思路,主要参考这个题解

    这个题虽然没给条件,不过测试下来数据就是升序,故按升序来算。

    实际上,这个题只有两种情况,即找得到或找不到。当子数组为空,或者已经升序以后,就找不到,或者说不用找。否则一定找得到答案。

    如果数组有序的话,那么 nums[i] 左边的元素一定比 nums[i] 小,右边的元素一定比 nums[i] 大。这一点没有问题。

    那么反过来,我们怎么判断一个数是否应该被排序呢?很简单,只要满足上面的条件即可。而由于我们要找子数组,那么理论上就能分成三个部分:

    无效的图片地址

    b 就是要找的数组,而 a,c 已经有序。我们要找的,应该就是数组 b 的两个端点。

    我们说过,只要一个数是拍好序的,那么它左边的元素一定比它小,右边的元素一个比它大。换句话说,只有存在左边元素比它大,或者存在右边元素比它小,这个元素就不满足有序,就应该被排序。

    而存在左边元素比 nums[i] 大,等价于 max(left) > nums[i],存在右边元素比它小,等价于 min(right) < nums[i] .

    也就是说,对于一个元素 nums[i],只要 max(left) < nums[i] 或者 min(right) > nums[i],那么它就应该被排序。那么要找到未排序的两个边界,只需遍历一遍,然后找最大和最小的两个即可。

    但实际上,我们还能优化一下。因为满足 max(left)>nums[i] 中最左边的值,一定小于等于 min(right)<nums[i] 最左边的值。满足 min(right)<nums[i] 中最右边的值,一定大于 max(left)>nums[i] 最右边的值。

    故,我们只需找到,满足 max(left)>nums[i] 中最右边的值,就是右边界,和满足 min(left)<nums[i] 中最左边的值,就是左边界。

    算法步骤如下:

    1. 从左遍历,维护 nums[i] 左边的最大值
    2. 如果 max(left)<nums[i],则更新右端点。
    3. 从左遍历,维护 nums[j] 右边的最小值
    4. 如果 min(right)>nums[j],则更新左端点。

    优化

    效率

    时间复杂度:

    空间复杂度:

    代码

    class Solution {
    public:
        vector<int> subSort(vector<int>& nums) {
            if (nums.empty()){
                return {-1,-1};
            }
            
            int leftMax=nums[0];
            int rightMin=nums[nums.size()-1];
            int l=-1;
            int r=-1;
            
            for (int i=0;i<nums.size();i++){
                if (leftMax>nums[i]){
                    r=i;
                } else {
                    leftMax=nums[i];
                }
            }
            
            for (int j=nums.size()-1;j>-1;j--){
                if (rightMin<nums[j]){
                    l=j;
                } else {
                    rightMin=nums[j];
                }
            }
            
            return {l,r};
        }
    };

原地反转

原地反转通常用于反转字符串,即在 空间复杂度的基础上操作

  • list

    344. 反转字符串

    题意

    原地反转字符串

    分析

    我们直到所有的数据结构最终在内存中都只有两个组织方式:

    1. 顺序存储
    2. 链式存储

    而对于这两种组织方式中,我们反转这些元素,就是反转字符串和反转链表这两题。

    而要在原地反转的话,那么对于每一个字符,它至少应该从源地址 → 目标地址,比如第一个字符和最后一个字符,第二个字符和倒数第二个字符,那么,这就是一个典型的双指针使用场景,可以说,这种思路就是一个简单的模拟题,遍历所有字符,然后对于该字符,它应该怎么移动,直接模拟即可。

    优化

    这题已经空间复杂度为 ,那么能优化的就只有时间复杂度了,而这题前后指针移动时,显然没有什么可优化的,所以只有交换元素可以考虑优化一下。

    而交换元素一般有两种方式:

    1. 临时变量
    2. 异或位运算

    临时变量很好理解,而异或运算其实也很简单:

    s[l]^=s[r]; // s[l]=s[l]^s[r]
    s[r]^=s[l]; // s[r]=s[r]^s[l]^s[r]=s[l]
    s[l]^=s[r]; // s[l]=s[l]^s[r]^s[l]=s[r]

    当然,也可以使用库函数。

    效率

    时间复杂度:

    空间复杂度:

    代码

    class Solution {
    public:
        void reverseString(vector<char>& s) {
            int l=0;
            int r=s.size()-1;
    
            while(l<r) {
                swap(s[l],s[r]);
                l++;
                r--;
            }
        }
    };
    class Solution {
    public:
        void reverseString(vector<char>& s) {
            int l=0;
            int r=s.size()-1;
            char t;
    
            while(l<r) {
                t=s[l];
                s[l]=s[r];
                s[r]=t;
    
                l++;
                r--;
            }
        }
    };
    class Solution {
    public:
        void reverseString(vector<char>& s) {
            int l=0;
            int r=s.size()-1;
    
            while(l<r) {
                s[l]^=s[r];
                s[r]^=s[l];
                s[l]^=s[r];
    
                l++;
                r--;
            }
        }
    };

    917. 仅仅反转字母

    题意

    反转非字母字符

    分析

    类似于 344,只是这题多了非字母字符限制,相对于 344,我们只需跳过非字符字符即可。即使用对撞指针,然后找到字母字符,再交换即可。

    优化

    效率

    时间复杂度:

    空间复杂度:

    代码

    class Solution {
    public:
        string reverseOnlyLetters(string s) {
            int l=0;
            int r=s.size()-1;
    
            while(l<r){
                while(l<r && !isChar(s[l])){
                    l++;
                }
                while(r>=0&&!isChar(s[r])){
                    r--;
                }
                if (l>s.size() || r<0 || l>=r){
                    return s;
                }
    
                swap(s[l],s[r]);
                l++;
                r--;        
            }
    
            return s;
        }
    
        bool isChar(char c) {
            return (c>='a' && c<='z')||(c>='A'&&c<='Z');
        }
    };

    345. 反转字符串中的元音字母

    题意

    反转元音字母

    分析

    类似于 917,只是要反转的字符变了而已,使用对撞指针,然后找到字母字符,再交换即可。甚至直接 copy 917 的改下条件就能跑。

    优化

    效率

    时间复杂度:

    空间复杂度:

    代码

    class Solution {
    public:
        string reverseVowels(string s) {
            int l=0;
            int r=s.size()-1;
    
            while(l<r){
                while(l<r && !isChar(s[l])){
                    l++;
                }
                while(r>=0&&!isChar(s[r])){
                    r--;
                }
                if (l>s.size() || r<0 || l>=r){
                    return s;
                }
    
                swap(s[l],s[r]);
                l++;
                r--;        
            }
    
            return s;
        }
    
        bool isChar(char c) {
            return ((c=='a')||(c=='e')||(c=='i')||(c=='o')||(c=='u')||(c=='A')||(c=='E')||(c=='I')||(c=='O')||(c=='U'));
        }
    };

    541. 反转字符串 II

    题意

    每隔 k 个字符,翻转 k 个字符,最后小于 k 个也反转。

    分析

    每隔 k 个字符反转 k 个,那么我们可以每次移动 2k 个字符,如果没有范围,那么就反转 k 个即可。

    优化

    效率

    时间复杂度:

    空间复杂度:

    代码

    class Solution {
    public:
        string reverseStr(string s, int k) {
            int l=0;
            int r=2*k;
    
            while(r<s.size()) {
                reverse(s.begin()+l,s.begin()+l+k);
                l+=2*k;
                r+=2*k;
            }
    
            if (s.size()-l < k) {
                reverse(s.begin()+l,s.end());
            } else {
                reverse(s.begin()+l,s.begin()+l+k);
            }
    
            return s;
        }
    };
    class Solution {
    public:
        string reverseStr(string s, int k) {
            for (int i=0;i<s.size();i+=2*k){
                if (i+k>s.size()){
                    reverse(s.begin()+i,s.end());
                    return s;
                }
                reverse(s.begin()+i,s.begin()+i+k);
            }
    
            return s;
        }
    };

    557. 反转字符串中的单词 III

    题意

    反转字符串中的单词

    分析

    首先反转单词这个参考 344,原地反转,以 344 为基础。

    然后这题要反转所有单词,那么自然首先需要找到每个单词的起始位置,然后反转即可,故最简单的就是遍历一遍,然后找到为空字符的索引,显然两个索引就是单词,反转即可。我们可以边扫描边反转,没必要扫两边。

    优化

    起始我们没有必要引入数组来存索引,因为每次我们要反转的其实只有一个单词,也就是,每一时刻只需直到两个索引即可,而用数组来存的话,那些已经被反转过的索引就没有用了,显然再存起来就很浪费了,所以我们可以用两个前后指针,分别存当前单词的前后索引即可。

    至于怎么寻找也很简单,右指针找到空字符,反转了左指针直接跳到右指针处即可。

    效率

    时间复杂度:优化前后都是

    空间复杂算:优化前 ,优化后

    代码

    class Solution {
    public:
        string reverseWords(string s) {
            vector<int> bank(1,-1);
            int j=0;
    
            for (int i=0;i<s.size();i++){         
                if (s[i]==' '){
                    bank.push_back(i);
                    reverse(s.begin()+bank[j]+1,s.begin()+bank[j+1]);
                    j++;
                }
            }
    
            reverse(s.begin()+bank[j]+1,s.end());
    
            return s;
        }
    };
    class Solution {
    public:
        string reverseWords(string s) {
            int l=0;
            int r=0;
    
            while(r<s.size()){
                if (s[r]!=' '){
                    r++;
                    continue;
                }
                reverse(s.begin()+l,s.begin()+r);                  
                r++;
                l=r;
            }
    
            reverse(s.begin()+l,s.end());
            return s;
        }
    };

    151. 翻转字符串里的单词

    题意

    反转单词并去除多余的空格

    分析

    这题只是在 557 的基础上增加了多余的空格操作,因此,问题就在于,怎么消除多余的空格,消除多余空格之后,就和 557 一样,先反转整个字符,然后找到单词,再部分反转。

    问题在于怎么消除空格呢?

    如果使用自带的 erase 函数,那么是很容易消除的,只需找到连续两个空格,然后 erase 一个即可。需要注意 erase 一个之后,索引就变短了,因此如果是从左向右遍历,需要自减一次,而如果从右往左遍历,则没有这个限制。

    但是需要注意的是,erase 函数的时间复杂度就已经是 的了,因此,总的复杂度就变成 了,这比较慢。

    优化

    既然 erase 这么慢,而且调用库函数也没有提高编程能力,那么我们就来想办法优化并手动实现一下。

    问题在于,我们该怎么做呢?使用 erase 时,我们每找到一次就删除一次,那么后面的元素都要前移,所以整个复杂度才那么高,实际上我们真的有必要每找到一个空格就删除一次吗?

    其实没有必要,我们可以把空格全部集中在一起,然后之后在一次性删除即可。这样就减少了元素整体移动的情况,那么我们该怎么做呢?

    其实很简单,类似于冒泡排序,我们把空格全部冒到后面即可,之后把后面的所有空格删除就行了。做法也很简单,用对撞指针,分别找到空格和非空格字符即可,而这,就是 27 题的做法。并且也使用到了双指针。

    效率

    时间复杂度:优化前由于 erase 是 的,故总 ,优化后

    空间复杂度:

    代码

    优化前

    class Solution {
    public:
        string reverseWords(string s) {
    				// 消除空格,除了第一个和最后一个
            for (int i=s.size()-1;i>0;i--){
                if (s[i]==' ' && s[i-1]==' '){
                    s.erase(s.begin()+i);
                }
            }
    				// 消除第一个空格
            if (s[0]==' '){
                s.erase(s.begin());
            }
    
    				// 防止全为空格
    				if (s.empty()) {
                return "";
            }
    
    				// 消除最后一个空格
            if (s[s.size()-1]==' ') {
                s.erase(s.end()-1);
            }
    
    				// 反转整个字符串
            reverse(s.begin(),s.end());
    
            int l=0;
            int r=0;
    				
    				// 反转部分字符串
            while(r<s.size()){
                if (s[r]!=' '){
                    r++;
                    continue;
                }
                reverse(s.begin()+l,s.begin()+r);
                l=r+1;
                r++;
            }
            reverse(s.begin()+l,s.end());
            
            return s;
        }
    };

    优化后

    class Solution {
    public:
        string reverseWords(string s) {
            remove(s);        
            if (s.empty()){
                return s;
            }
            reverseW(s);
            return s;
        }
    
        void remove(string &s){
    				int l=0;
            int r=0;
            
            while(r<s.size() && s[r]==' '){
                r++;
            }
            
            if (r==s.size()){                
                s.resize(0);
                return;
            }
            
            while(r<s.size()){
                if (r>0 && s[r]==s[r-1] && s[r]==' '){ // 如果遇到两个空格,则跳过
                    r++;
                    continue;
                }
                s[l]=s[r]; // 如果不是两个空格,则将其前移
                l++;
                r++;
            }
            if (s[l-1]==' '){
                l--;
            }
            s.resize(l);        
        }
    
        void reverseW(string &s){
            int l=0;
            int r=0;
    
            reverse(s.begin(),s.end());		
            while(r<s.size()){
                if (s[r]!=' '){
                    r++;
                    continue;
                }
                reverse(s.begin()+l,s.begin()+r);
                l=r+1;
                r++;
            }
            reverse(s.begin()+l,s.end());
        }
    };

    剑指 Offer 58 - I. 翻转单词顺序

    同 151

    剑指 Offer 58 - II. 左旋转字符串

    题意

    将前 k 个字符反转到后方

    分析

    只需全部反转字符串,然后分别反转前 k 个字符和后面所有字符即可。

    优化

    效率

    时间复杂度:

    空间复杂度:

    代码

    class Solution {
    public:
        string reverseLeftWords(string s, int n) {
            reverse(s.begin(),s.begin()+n);
            reverse(s.begin()+n,s.end());
            reverse(s.begin(),s.end());
            return s;
        }
    };

    2000. 反转单词前缀

    题意

    反转第一个 ch 字符前的字符

    分析

    找到第一个字符的索引,然后反转即可。

    优化

    效率

    时间复杂度:

    空间复杂度:

    代码

    class Solution {
    public:
        string reversePrefix(string word, char ch) {
            int i=0;
            while(i<word.size()&& word[i]!=ch){
                i++;
            }
            if (i<word.size()){
                reverse(word.begin(),word.begin()+i+1);
            }
    
            return word;
        }
    };

    796. 旋转字符串

    题意

    分析

    提示说得很清楚了,字符串 xy 经过旋转得到 yx,那么 xy+xy=xyxy 就必然包含了 yx。但是包含了 yx 就一定成立吗?必要性呢?这个不知道怎么证明。赞踩回复分享举报编辑删除

    优化

    效率

    时间复杂度:

    空间复杂度:

    代码

    class Solution {
    public:
        bool rotateString(string s1, string s2) {
            if (s1.size()!=s2.size()){
                return false;
            }
            return (s1+s1).find(s2)!=-1;
        }
    };

    面试题 01.09. 字符串轮转

    同 796

    459. 重复的子字符串

    题意

    分析

    优化

    效率

    时间复杂度:

    空间复杂度:

    代码

    class Solution {
    public:
        bool repeatedSubstringPattern(string s) {         
            return -1!=(s+s).substr(1,s.size()*2-2).find(s);
        }
    };

回文串

主要是检测回文串问题,主要有对撞指针和中心探测两种方法,而两者都是双指针的典型应用。

  • list

    125. 验证回文串

    题意

    只考虑数字和字母,并且忽略大小写,判断是否是回文串。

    分析

    最暴力的自然是从回文串的定义着手,即逆转后判断,但是这样空间复杂度太高,可以优化。

    优化的思路也很简单,我们不是直接比较字符串,而是比较字符,即找到对称的两个字符,然后比较两者是否相等即可。

    而对称的字符也有两种方式,即从串首位开始往中心走,或者中心往两边走。第一种即对撞指针,第二种即中心探测。

    对撞指针

    对撞指针非常简单,需要注意的点有两个:

    1. 非数字、字母字符要跳过
    2. 要忽略大小写

    跳过字符这个好判断,而忽略大小写可以有多种方式,最简单的是利用大小写字符间 ASCII 码的规律,即 字符 & 0b11011111= 对应的大写字符。

    class Solution {
    public:
        bool isPalindrome(string s) {
            int l=0;
            int r=s.size()-1;
            
            while(l<=r) {                        
                if (!isChar(s[l])) {
                    l++;
                    continue;
                }
                if (!isChar(s[r])) {
                    r--;
                    continue;
                }
                            
                if ((s[l] & 0b11011111) != (s[r] & 0b11011111)){
                    return false;
                }
                l++;
                r--;
            }
            
            return true;
        }
        
        bool isChar(char c){
            return (c>='0' && c<='9') || (c>='a' && c<='z') || (c>='A' && c<='Z');
        }
    };

    对撞指针的时间复杂度为 ,空间复杂度为

    中心探测

    中心探测是指从字符串中心开始向两侧探测判断,由于字符串分奇偶,故中心也可能有两个。

    无效的图片地址

    故我们编写分别从 l,r 向两侧拓展的函数,然后根据奇偶设置中心 l、r。

    对了,由于中心探测需要提前知道字符串长度奇偶,而原题又要跳过特殊字符,比较麻烦,故提前预处理了下。去掉特殊字符。

    class Solution {
    public:
        bool isPalindrome(string s) {        
            s=regex_replace(s,regex("[^0-9a-zA-Z]"),"");
    
            if (s.size()%2==0){
                return expandAroundCenter(s,s.size()/2-1,s.size()/2);
            } else {
                return expandAroundCenter(s,s.size()/2,s.size()/2);
            }
        }
        
        bool expandAroundCenter(string s,int l,int r){
            while (l>-1 && r<s.size()){            
                if ((s[l] & 0b11011111) != (s[r] & 0b11011111)){
                    return false;
                }
                l--;
                r++;
            }
            
            return true;
        }
    };

    中心探测的时间复杂度为 ,空间复杂度为

    680. 验证回文字符串 Ⅱ

    题意

    删除一个字符后,判断是不是回文串

    分析

    先对撞指针,找到不等的两字符后,拆分成两个字符串,再分别判断即可。判断方式有对撞指针和中心探测。

    优化

    效率

    时间复杂度:

    空间复杂度:

    代码

    中心探测

    class Solution {
    public:
        bool validPalindrome(string s) {
            int l=0;
            int r=s.size()-1;
            int cnt=0;
            
            while(l<=r) { 
                if (s[l]==s[r]){
                    l++;
                    r--;
                    continue;
                }
                return isPalindrome(s.substr(l,r-l))||(isPalindrome(s.substr(l+1,r-l)));
            }
            
            return true;        
        }
        
        bool isPalindrome(string s) {        
            if (s.size()%2==0){
                return expandAroundCenter(s,s.size()/2-1,s.size()/2);
            } else {
                return expandAroundCenter(s,s.size()/2,s.size()/2);
            }
        }
        
        bool expandAroundCenter(string s,int l,int r){
            while (l>-1 && r<s.size()){            
                if ((s[l] & 0b11011111) != (s[r] & 0b11011111)){
                    return false;
                }
                l--;
                r++;
            }
            
            return true;
        }
    };

    对撞指针

    class Solution {
    public:
        bool validPalindrome(string s) {
            int l=0;
            int r=s.size()-1;
            int cnt=0;
            
            while(l<=r) { 
                if (s[l]==s[r]){
                    l++;
                    r--;
                    continue;
                }
    
                return expandAroundCenter(s,l+1,r) || expandAroundCenter(s,l,r-1);
            }
            
            
            return true;        
        }
    
        
        bool expandAroundCenter(string s,int l,int r){
            while (l<r){            
                if (s[l] != s[r]){
                    return false;
                }
                l++;
                r--;
            }
            
            return true;
        }
    };

    面试题 01.04. 回文排列

    题意

    判断字符串是否是回文串的排列。

    分析

    回文串要么奇数长度,要么偶数长度。奇数长度时,只有中心个数为 1,其它都是对称的。偶数所有字符都是对称的。所以,回文串中,字符是奇数的个数要么是 1,或者 0.

    那最简单的自然是统计奇数字符个数了,直接开 map 即可。

    优化

    我们实际上根本不需要知道字符的个数,没必要统计个数再判断奇偶。我们只需在遍历的过程中就维护字符奇偶性即可。而奇偶性只有两种状态,故我们可用位图 bitset 来维护。0 表示偶数,1 表示计数。

    那么每增加一个字符,它的状态就在 0 和 1 间交替。即 0←→1取反操作。故我们每增加一个字符,将其对应位取反即可。

    最后再统计计数个数,即 1 的个数是否小于 2 即可。

    效率

    时间复杂度:

    空间复杂度:

    代码

    map

    class Solution {
    public:
        bool canPermutePalindrome(string s) {
            int cnt=0;
            unordered_map<char,int> m;        
            
            for (int i=0;i<s.size();i++){
                m[s[i]]++;
            }
            
            for (auto k:m){
                cnt+=(k.second%2);
            }
            
            return cnt<=1;
        }
    };

    优化

    class Solution {
    public:
        bool canPermutePalindrome(string s) {
            bitset<127> b;        
            
            for (int i=0;i<s.size();i++){
                b.flip(s[i]);
            }
            
            return b.count()<2;
        }
    };

    409. 最长回文串

    题意

    在字符串中任取字符,问最长能组成的回文串长度是多少

    分析

    这题和 面试题 01.04. 回文排列 类似,都是考回文串的性质,即:

    回文串要么奇数长度,要么偶数长度。奇数长度时,只有中心个数为 1,其它都是对称的。偶数所有字符都是对称的。所以,回文串中,字符是奇数的个数要么是 1,或者 0.

    那么我们要取字符的话,最后要么都取偶数,要么再取一个奇数。

    思路有了后,实现就很简单了,由于要知道字符是奇偶数,所以用 map 计算下奇偶(并且由于要用到个数,所以不能像 01.04 那样用位图了)。

    然后取所有的偶数,如果有奇数,再从所有奇数里面取余下一个,最后再从奇数里去一个,这就是偶数情况。如果没有奇数,那就取所有偶数就行了。

    优化

    效率

    时间复杂度:

    空间复杂度:

    代码

    class Solution {
    public:
        int longestPalindrome(string s) {
            unordered_map<char,int> m;  
            int res=0;
            int hasOdd=0;
            
            for (int i=0;i<s.size();i++){
                m[s[i]]++;
            }
            
            for (auto k:m){
                if (k.second%2==0){
                    res+=(k.second);
                } else {
                    res+=(k.second-1);
                    hasOdd=1;
                }
            }
            
            return res+hasOdd;
        }
    };

    1332. 删除回文子序列

    题意

    每次删除一个回文子序列,问多少次可以删光

    分析

    这个题在题意上有点坑,因为是回文子序列而不是子串,也就是说,可以不连续。

    那么一共三种情况:

    1. 如果字符串本身为空,那自然不需要删,删除次数为 0
    2. 如果字符串是回文,那么一次就删除完,次数为 1
    3. 如果字符串不是回文,由于一共两种字符,而全为 a 或全为 b 也是子串,所以我们可以每次删除同一种字符,比如先把所有 a 删除,再把余下的 b 删了,次数就为 2.

    综上,这题就是个判断回文操作而已,使用简单的对撞指针即可。

    优化

    效率

    时间复杂度:

    空间复杂度:

    代码

    class Solution {
    public:
        int removePalindromeSub(string s) {
            if (s.empty()){
                return 0;
            }
            
            int l=0;
            int r=s.size()-1;
            
            while(l<r){
                if (s[l]!=s[r]){
                    return 2;
                }
                l++;
                r--;
            }
            
            return 1;
        }
    };

    1616. 分割两个字符串得到回文串

    题意

    相同索引分割两字符串,问两字符串前后缀分开组成的新字符串是否有回文串

    分析

    最简单的自然是把所有生成的字符串枚举出来,然后判断是否有回文串,判断的方式用对撞指针或中心探测都行。

    不过数量级在 10^5,显然暴力 会超时。

    优化

    优化的思想也很简单,充分利用到了回文串的回文特性,即:从中心到两边探测,生成的子串也是回文串。反过来一样的,要是回文串,那么中心到两侧的子串需要是回文串才行。

    先两字符串两侧往中间判断

    无效的图片地址

    假设这时 s1[l]≠s2[r]

    无效的图片地址

    而最终要形成回文串,显然只有 xmy 或 xny 这两种情况

    无效的图片地址

    现在 x 和 y 已经对称,那么要使得总的字符串成回文,必有中间的子串 n 或 m 是回文。所以这时我们再判断 m 或 n 是否是回文即可。

    上面是 s1 取前缀,s2 取后缀,反过来也是一样的。

    效率

    时间复杂度:暴力 优化

    空间复杂度:

    代码

    暴力

    class Solution {
    public:
        bool checkPalindromeFormation(string s1, string s2) {
    				// 枚举生成的字符串
            for (int i=0;i<s1.size();i++){
                if (isPalindrome(s1.substr(0,i)+s2.substr(i,s1.size()-i)) || isPalindrome(s2.substr(0,i)+s1.substr(i,s1.size()-i))){
                    return true;
                }
            }
            
            return false;
        }    
        
    		// 中心探测判断回文串
        bool isPalindrome(string s) {        
            if (s.size()%2==0){
                return expandAroundCenter(s,s.size()/2-1,s.size()/2);
            } else {
                return expandAroundCenter(s,s.size()/2,s.size()/2);
            }
        }
        
        bool expandAroundCenter(string s,int l,int r){
            while (l>-1 && r<s.size()){            
                if (s[l] != s[r]){
                    return false;
                }
                l--;
                r++;
            }
            
            return true;
        }
    };

    优化

    class Solution {
    public:
        bool checkPalindromeFormation(string s1, string s2) {
            return check(s1,s2) || check(s2,s1);
        }
        
        bool check(string s1,string s2){
            int l=0;
            int r=s1.size()-1;
            
    				// 先两侧判断
            while(l<r && s1[l]==s2[r]){
                l++;
                r--;
            }
            
    				// 再判断中间的子串是否是回文
            return isPalindrome(s1,l,r) || isPalindrome(s2,l,r);
        }
        
        // 对撞指针法判断回文串
        bool isPalindrome(string s,int l,int r){
            while(l<r){
                if (s[l]!=s[r]){
                    return false;
                }
                l++;
                r--;
            }
            
            return true;
        }
    };

    5. 最长回文子串

    题意

    找到字符串的最长回文子串

    分析

    最暴力的解法自然是枚举每个字符串,然后判断是否是回文子串。不过枚举的复杂度太高,我们没必要枚举字符串,根据判断回文子串的两种方式,即对撞指针和中心探测,我们枚举边界或者中心即可。枚举边界的话,显然复杂度为 ,而枚举中心只要 ,故我们枚举中心。

    枚举中心,然后用中心探测法找到最长的回文子串即可。需要注意的是,由于可能有两个中心的情况,故当相邻两字符相等时,则要判断双中心的情况。

    优化

    我们可以利用回文串的特性来优化,即马拉车算法。todoing

    效率

    时间复杂度:

    空间复杂度:

    代码

    class Solution {
    public:
        string longestPalindrome(string s) {
            string res;
            string even,odd;
            
            for (int i=0;i<s.size();i++){
                even=expandAroundCenter(s,i,i);
                if (res.size()<even.size()){
                    res=even;
                }
                if (i+1<s.size() && s[i]==s[i+1]){
                    odd = expandAroundCenter(s,i,i+1);
                }
                if (res.size()<odd.size()){
                    res=odd;
                }
            }        
            
            return res;
        }
        
        string expandAroundCenter(string s,int l,int r){
            while (l-1>-1 && r+1<s.size() && s[l-1]==s[r+1]){           
                l--;
                r++;
            }
            
            return s.substr(l,r-l+1);
        }
    };

    647. 回文子串

    题意

    找到字符串中子串是回文串的数量

    分析

    这个题就是 5 题+696 题的结合,即枚举中心,然后用中心探测法求最长回文子串,而最长回文子串又包含 length/2 个回文子串,最后求和即可,需要注意一下双中心的情况。

    优化

    效率

    时间复杂度:

    空间复杂度:

    代码

    class Solution {
    public:
        int countSubstrings(string s) {
            int res=0;
            int even,odd;
            
            for (int i=0;i<s.size();i++){
                even=expandAroundCenter(s,i,i);
                res+=even;
                if (i+1<s.size() && s[i]==s[i+1]){
                    odd = expandAroundCenter(s,i,i+1);
                    res+=odd;
                }            
            }        
            
            return res;
        }
            
        int expandAroundCenter(string s,int l,int r){
            int res=1;
            while (l-1>-1 && r+1<s.size() && s[l-1]==s[r+1]){             
    						res++;
                l--;
                r++;
            }
            
            return res;
        }
    };

元素移动

元素移动即要求移动元素中的元素,并且通常要求原地移动。那么双指针分别指向两个元素然后交换元素,移动的元素和双指针的移动策略相同,是典型的双指针用法。

元素移动题非常典型,对撞指针和快慢指针都很常见。

很多需要数组扩充的题,基本都是后面扩充,然后从后往前遍历。这样避免整体的元素移动,而且交换元素时也比较方便。

  • list

    27. 移除元素

    题意

    移除等于 val 的元素

    分析

    这题其实不应该叫移除 ,因为实际上只需前面的元素没有 val 即可,也不看元素的位置。而且真正意义上的移除也无法原地修改。

    实际上,既然这题只需前面没有 val 的话,那我们直接冒泡,把它冒到后面去即可。

    所以开两个指针,一个从前扫描找到 val,一个从后扫描找到非 val 的,然后交换即可。

    一个小技巧:

    当外部一个循环,要在内部查到特定元素时,没必要内部也开一个循环,只需判断条件,不符合就 continue 即可,这样代码要精简许多,而且也不用遗漏条件。

    优化

    实际上我们没有必要交换元素,因为后面的元素等于什么根本不重要,所以只需把前面的 val 更改为后面非 val 元素即可。

    效率

    时间复杂度:

    空间复杂度:

    代码

    class Solution {
    public:
        int removeElement(vector<int>& nums, int val) {
            int l=0;
            int r=nums.size()-1;
            int res=0;
        
            while(l<=r){
                if (nums[l]!=val){
                    l++;
                    continue;
                }
                if (nums[r]==val){
                    r--;
                    continue;
                }
    						// swap(nums[l],nums[r]);
                nums[l]=nums[r];
                res++;  
                l++;
                r--;    
            }
    
            return l;
        }
    };

    剑指 Offer 05. 替换空格

    题意

    将空格替换成 %20

    分析

    最简单的自然是遍历然后用 replace 库函数了,当然也可以直接实现 replace 函数。

    优化

    replace 函数时间复杂度 ,效率太低,考虑优化。

    由于原题要求原地修改,那么就需要扩充字符串,所以我们统计 0 的个数,然后在后面增加空格。

    比如 a_b( _ 是空格,无视真正的空格),就应该扩充为 a_b_ _

    然后显然对于非空格元素,需要右移,那么我们开两个指针,从后往前扫描。快指针扫描字符,慢指针用于定位替换的位置,如果是非空格,那么快指针元素后移,如果是空格,则更改为 %20.

    a_b_ _ ,快指针 i 指向扩充前的元素末尾 b,慢指针 j 指向扩张后元素末尾 _

    步骤 1
      i  j
    a_b_ _
    
    步骤 2,由于快指针指向非空格字符,故和慢指针元素元素交换(当然其实没必要交换,只更改慢指针指向的元素即可)
    
      i  j
    a_b_ b
    
    步骤 3,快慢指针左移
     i  j
    a_b_ b
    
    步骤 4.由于快指针指向空格,故慢指针前 3 元素替换为 %20
     i  j
    a%20b
    
    步骤 5,快指针左移 1 次,慢指针由于替换过了,故左移 3j
    i
    a%20b
    
    步骤 6.结束

    效率

    时间复杂度:

    空间复杂度:

    代码

    class Solution {
    public:
        string replaceSpace(string s) {
            for (int i=0;i<s.size();i++){
                if (s[i]==' '){
                    s.replace(i,1,"%20");
                }
            }
    
            return s;
        }
    };
    class Solution {
    public:
        string replaceSpace(string s) {
            int cnt=0;
            
            for (int i=0;i<s.size();i++){
                if (s[i]==' '){
                    cnt++;
                }
            }
    
            int l=s.size()-1;
            int r=s.size()+2*cnt-1;
            s.resize(s.size()+2*cnt);
    
            while(l>=0){
                if (s[l]!=' '){
                    s[r]=s[l];
                    l--;
                    r--;
                    continue;
                }
                s[r]='0';
                s[r-1]='2';
                s[r-2]='%';
                l--;
                r-=3;
            }
    
            return s;
        }
    };

    1089. 复写零

    题意

    将数组中的零复制一遍

    分析

    这题增加 val 元素,27 题则删除 val 元素。

    这题要增加元素,虽然最后结果不管后面的数组,但是为了方便编程,故我们先把整个复写的数组弄出来,然后再裁剪数组。

    要扩充数组,需要先统计扩展的长度,故统计原数组中 0 的个数,然后再扩充。要在原数组后扩充,这样可以避免整体元素的后移。

    然后由于要增加元素,就涉及元素整体右移,所以有两种移动方式:

    1. 从左扫描
    2. 从右扫描

    如果是从左扫描的话,那么后面的所有元素都要后移,这样总体移动的次数太多,故我们从右向左扫描,可以减少移动次数。

    则开快慢指针,快指针从原数组末尾开始,慢指针从新数组末尾开始,快指针扫描,遇到非 0 元素则赋值到慢指针处,遇到 0,则慢指针前两个元素都赋值为 0.

    比如 a0b

    1. 先扩充为 a0b_
    						            lr
    2.  设置快慢指针       a0b_          lr        r
    3. 快指针指向非 0,则慢指针赋值      a0bb       l
    4. 快指针指向 0,则慢指针赋值两个 0             a00b

    总体来说,这题跟 剑指 Offer 05. 替换空格 题差不多,先向后扩展,然后从后往前快慢指针移动元素。

    优化

    效率

    时间复杂度:

    空间复杂度:

    代码

    class Solution {
    public:
        void duplicateZeros(vector<int>& nums) {
            int cnt=0;
            for (int i=0;i<nums.size();i++){
                if (nums[i]==0){
                    cnt++;
                }
            }
            
            int l=nums.size()-1;
            int r=l+cnt;
            nums.resize(nums.size()+cnt);
            
            while(l>-1){
                if (nums[l]!=0){
                    nums[r]=nums[l];
                    r--;
                    l--;
                    continue;
                }
                nums[r]=0;
                nums[r-1]=0;
                r-=2;
                l--;
            }
    
            nums.resize(nums.size()-cnt);
        }
    };

    26. 删除有序数组中的重复项

    题意

    删除重复元素,使得元素只出现一次

    分析

    已知数组有序,那么判断重复元素就很简单了,相邻相等即有重复元素。那么,从左到右,遇到有重复元素的必然就要替换掉。

    因此,我们开快慢指针,慢指针指向需要被替换的元素,快指针则指向比替换的元素。

    由于最后要消除重复元素,那么得到的序列必然严格单调递增,故快指针指向的替换元素必然比慢指针指向的被替换元素大。

    那么慢指针依次指针,快指针找到比慢指针前面的元素大于的元素即可。(是比慢指针前一个元素大,不是慢指针指向的元素,比如 2,2,2,3,3,重复元素大于 2 次时,替换到

    2,3,2,2,3,3 的时候,慢指针还是指向第二个 2,但是实际上我们要寻找的元素缺应该比前一个元素 3 更大)

    优化

    效率

    时间复杂度:

    空间复杂度:

    代码

    class Solution {
    public:
        int removeDuplicates(vector<int>& nums) {
            if (nums.empty()){
                return 0;
            }
            int l=1;        
            int r=1;                               
    
            while(r<nums.size()){            
                if (nums[r]<=nums[l-1]){
                    r++;
                    continue;
                }
                nums[l]=nums[r];
                l++;
                r++;
            }    
    
            return l;
        }
    };

    80. 删除有序数组中的重复项 II

    题意

    删除重复元素,使得元素只出现一次

    分析

    26 题的进阶版,重复次数变成了 2.

    要原地修改,又典型的元素移动题,故开快慢指针。

    慢指针指向什么?

    nums[slow] 表示 slow 指向的字符该什么处理,而前面的字符全都处理结束了。

    快指针指向什么?

    和慢指针元素相比较,用来寻找后面需要前移的元素。

    快指针什么时候移动?

    当 fast=slow,且 nums[fast]==nums[slow-2] 时,说明 slow 前两个元素重复,而快指针要移动,只需继续重复,故 fast++ 后 nums[fast]==nums[slow-2] 仍然满足,则快指针移动。

    慢指针什么时候移动?

    当 nums[fast]==nums[slow-2] 不满足时,说明重复的字符到底了,所以需要更新字符,故 slow++;

    快慢指针初始值应该是什么?

    由于要和 slow 前两个元素比较,故初始值为重复次数 2

    优化

    引申:重复次数变成 n,只需 nums[fast]==nums[slow-n] 比较即可,即快指针和慢指针前面第 n 的个数比较即可。

    数组有序,相同元素保留前 k 位,快慢指针都从 k 开始从左向右遍历

    每次快指针和慢指针左边第 k 个元素比较,如果相等,则快指针继续右移,否则慢指针直接赋值为快指针的值。

    效率

    时间复杂度:

    空间复杂度:

    代码

    class Solution {
    public:
        int removeDuplicates(vector<int>& nums) {
            if (nums.size()<=2){
                return nums.size();
            }
            int fast=2;
            int slow=2;
            
            while(fast<nums.size()){
                if (nums[fast]==nums[slow-2]){
                    fast++;
                    continue;
                }
                nums[slow]=nums[fast];
                fast++;
                slow++;
            }
            
            return slow;
        }
    };

    283. 移动零

    题意

    将元素中的 0 移动末尾

    分析

    开两个指针,快指针前移找到非 0 的,那么这时慢指针和快指针间就一定是元素 0,于是两元素交换,再同时前移,一直这么做即可。

    重点就是:快慢指针间任意时刻都是元素 0,所以交换元素才不影响相对位置。

    优化

    效率

    时间复杂度:

    空间复杂度:

    代码

    class Solution {
    public:
        void moveZeroes(vector<int>& nums) {
            int l=0;
            int r=0;
    
            while(r<nums.size()){
                if (nums[r]==0){
                    r++;
                    continue;
                }
                swap(nums[l],nums[r]);
                l++;
                r++;
            }
        }
    };

    777. 在 LR 字符串中交换相邻字符

    题意

    判断一个字符串能否经过移动成为零一个字符串

    分析

    根据题意,可用一个 "LX" 替换一个 "XL",或者用一个 "XR" 替换一个 "RX" 。换句话说,就是 X 可用视为空格,而 L 只能右移,R 只能左移。

    那么两字符串中,字符 X 一定个数相等,这是第一点。

    并且由于只是和 X 交换,故 L 和 R 的相对位置一定不变,这是第二点。

    L 只能右移,R 只能左移,这是第三点。

    根据上面三点,我们可以如此规划:

    1. 开两个指针,分别指向两字符串当前字符,假设原始字符串为 i,结果为 j
    2. 都从左开始右移,直到当前字符不为 X 为止,同时统计 X 的个数
    3. 如果两者不相等,那么返回 false(根据第二点,L 和 R 相对位置不变)
    4. 如果指向 L,且 i<j ,则返回 false(L 只能右移)
    5. 如果指向 R,且 i>j,则返回 false(R 只能左移)
    6. 遍历结束后,再把 i 和 j 余下可能没有遍历结束的那个,继续遍历,并且统计余下 X 的个数
    7. 最后比较两者间 X 的个数,根据结果返回

    优化

    效率

    时间复杂度:

    空间复杂度:

    代码

    class Solution {
    public:
        bool canTransform(string start, string end) {
            int i=0;
            int ix=0;
            int j=0;
            int jx=0;
    
            while(i<start.size() && j<end.size()){
                if (start[i]=='X'){
                    i++;
                    ix++;
                    continue;
                }
                if (end[j]=='X'){
                    j++;
                    jx++;
                    continue;
                }
                if (start[i]!=end[j]){
                    return false;
                }
                if (start[i]=='L' && i<j){
                    return false;
                }
                if (start[i]=='R' && i>j){
                    return false;
                }
    
                i++;
                j++;
            }
    
            while (i<start.size() && start[i]=='X'){
                ix++;
                i++;
            }
            while (j<end.size() && end[j]=='X'){
                jx++;
                j++;
            }
    
            return ix==jx;
        }
    };

    剑指 Offer 21. 调整数组顺序使奇数位于偶数前面

    题意

    移动元素使得奇数在左边,偶数在右边

    分析

    开对撞指针,从左找偶数,从右找奇数,然后交换即可。

    优化

    效率

    时间复杂度:

    空间复杂度:

    代码

    class Solution {
    public:
        vector<int> exchange(vector<int>& nums) {
            int l=0;
            int r=nums.size()-1;
    
            while(l<r) {
                if (nums[l] %2 != 0){
                    l++;
                    continue;
                }
                if (nums[r] %2 == 0){
                    r--;
                    continue;
                }            
                swap(nums[l],nums[r]);
            }
    
            return nums;
        }
    };

    905. 按奇偶排序数组

    题意

    将偶数移动到前面,奇数移动到后面

    分析

    开对撞指针,从左找奇数,从右找偶数,然后交换即可。

    优化

    效率

    时间复杂度:

    空间复杂度:

    代码

    class Solution {
    public:
        vector<int> sortArrayByParity(vector<int>& nums) {
            int l=0;
            int r=nums.size()-1;
            
            while(l<r){
                if (nums[l]%2==0){
                    l++;
                    continue;
                }
                
                if (nums[r]%2!=0){
                    r--;
                    continue;
                }
                
                swap(nums[l],nums[r]);
            }
            
            return nums;
        }
    };

n 数和、差

利用数组的有序性,(或者预排序),然后前后指针

有序:对撞指针或二分

无序:哈希表

如果要去重的话,对撞指针比较简单,哈希较麻烦。而如果不去重,是统计个数的话,哈希表则要简单一些。

  • list

    1. 两数之和

    题意

    无序数组中找到和等于 target 的两数索引

    分析

    由于原数组无序,因此无法二分,更无法双指针,二分即固定一数,二分另一个数,复杂度为 ,而双指针则同样需要有序,使用对撞指针 nums[l]+nums[r] 与 target 比较进行剪枝,复杂度为 。但是这题无序,当然可以先排序,排序后复杂度就是

    或者也可以暴力双重遍历,

    优化

    实际上,要寻找 nums[i]+nums[j]=target,固定 i 后寻找 j,是一个典型的存在性问题,我们可以利用哈希表来查询,可以优化到 。这种思想在前缀和题型中使用也非常广泛,前缀和很多时候也是固定 presum[i] 求 presum[j]。

    效率

    时间复杂度:

    空间复杂度:

    代码

    class Solution {
    public:
        vector<int> twoSum(vector<int>& nums, int target) {
            unordered_map<int,int> m;
            vector<int> res;
            
            for (int i=0;i<nums.size();i++){
                if (m.count(target-nums[i])>0){
                    res.push_back(m[target-nums[i]]);
                    res.push_back(i);
                    return res;
                }
                m[nums[i]]=i;
            }
            
            return res;
        }
    };

    167. 两数之和 II - 输入有序数组

    题意

    有序数组中找到和等于 target 的两数索引

    分析

    和 1 题类似,不过这题已经有序了,1 题无序使用哈希表,这个题自然也可以使用哈希表,不过没有使用到有序的特性,故更改思路。

    有序自然也能二分,不过复杂度有点高,再换一个思路。

    我们可以使用对撞指针来剪枝,复杂度最低。

    使用 l,r 分别从两边往中间移动,这时通过它们的和来判断该怎么移动。 即 nums[l]+nums[r] 与 target 的大小。

    如果 nums[l]+nums[r]==target,自然找到结果。

    如果 nums[l]+nums[r] > target,说明和太大,要减小。但是注意,l 在最左端,只能右移,而 r 在最右端,只能左移。而数组又有序,所以,nums[l] 是单调递增了,而 nums[r] 是单调递减的。所以和太大的话,就只能 r 左移了。

    同理,nums[l]+nums[r] < target,说明和太小,要增加,只能 l 右移了。

    需要注意的点,就是返回的不是索引,而是索引+1.

    优化

    效率

    时间复杂度:

    空间复杂度:

    代码

    class Solution {
    public:
        vector<int> twoSum(vector<int>& nums, int target) {
            int l=0;
            int r=nums.size()-1;
            int sum;
            vector<int> res;
            
            while(l<r){
                sum=nums[l]+nums[r];
                if (sum==target){
                    res.push_back(l+1);
                    res.push_back(r+1);
                    return res;
                } else if (sum >target){
                    r--;
                } else if (sum <target){
                    l++;
                }            
            }
                    
            return res;
        }
    };

    剑指 Offer 57. 和为 s 的两个数字

    题意

    有序数组中找到和等于 target 的两数索引

    分析

    同 167

    优化

    效率

    时间复杂度:

    空间复杂度:

    代码

    class Solution {
    public:
        vector<int> twoSum(vector<int>& nums, int target) {
            int l=0;
            int r=nums.size()-1;
            vector<int> res;
            int sum=0;
            
            while(l<r){
                sum=nums[l]+nums[r];
                if (sum==target){
                    res.push_back(nums[l]);
                    res.push_back(nums[r]);
                    return res;
                } else if (sum <target){
                    l++;
                } else {
                    r--;
                }
            }
            
            return res;
        }
    };

    面试题 16.24. 数对和

    题意

    有序数组中找到和等于 target 的两数

    分析

    同 167

    优化

    效率

    时间复杂度:

    空间复杂度:

    代码

    class Solution {
    public:
        vector<vector<int>> pairSums(vector<int>& nums, int target) {
            int l=0;
            int r=nums.size()-1;
            vector<vector<int>> res;
            int sum=0;
            
            sort(nums.begin(),nums.end());
    
            while(l<r){
                sum=nums[l]+nums[r];
                if (sum==target){
                    res.push_back({nums[l],nums[r]});
                    l++;
                    r--;
                } else if (sum <target){
                    l++;
                } else {
                    r--;
                }
            }
            
            return res;
        }
    };

    1679. K 和数对的最大数目

    题意

    无序数组中找到和等于 target 的两数

    分析

    同 1,167 题

    优化

    效率

    时间复杂度:

    空间复杂度:

    代码

    class Solution {
    public:
        int maxOperations(vector<int>& nums, int k) {
            int res=0;
            int l=0;
            int r=nums.size()-1;
            int sum;
            
            sort(nums.begin(),nums.end());
            
            while(l<r){
                sum=nums[l]+nums[r];
                if (sum==k){
                    res++;
                    l++;
                    r--;
                } else if (sum<k){
                    l++;
                } else {
                    r--;
                }
            }
            
            return res;
        }
    };

    15. 三数之和

    题意

    无序数组中寻找三个数,使得和为 target

    分析

    最简单的暴力自然是三重循环,不过显然会超时。并且还需要去重。

    优化

    对于求数组和,基本上也就对撞指针,哈希表和二分几种用法。

    要求 a+b+c=target,即求 a+b=target-c,所以我们可以先固定 c,然后就可以将其转换为两数和问题,问题就在于,怎么固定这个 c。

    当然,我们可以遍历 c,然后求两数和的话,显然方法就很多了,暴力就是三重暴力,无序可以用哈希表,当然也可以双指针,复杂度分别为 .

    而哈希和双指针需要有序数组,故我们需要先预排序一下。

    效率最高的自然是双指针,故我们可以使用双指针。双指针的具体原理看 167 即可,问题就在于,怎么去重。

    由于数组已经有序,并且,我们固定遍历 a,然后双指针求 b+c,所以最后的结果 (a,b,c) 中,每个元素 a 或 b 或 c 都是递增的。

    所以如果结果重复的话,那么必然是 a 或 b 或 c 相邻。那么我们要去重的话,只需分别判断 a 或 b 或 c 相邻重复即可,重复就跳过,那么必然结果就不会重复。

    而如果使用哈希表的话,原理见第 1 题,只是固定 nums[i],target 变成 0-nums[i] 了而已。nums[i]+nums[j]+m[] = 0 → nums[j]+m[]=0-nums[i].

    同样的要注意去重,nums[i] 每次遍历使用的哈希表应该都是新的,而且每找到答案后,后面的重复元素要跳过,当前已经使用的元素都要从哈希表中去除。

    效率

    时间复杂度:

    空间复杂度:

    代码

    暴力

    class Solution {
    public:
        vector<vector<int>> threeSum(vector<int>& nums) {
            vector<vector<int>> res;
            
            for (int i=0;i<nums.size();i++){
                for (int j=i+1;j<nums.size();j++){
                    for (int k=j+1;k<nums.size();k++){
                        if (nums[i]+nums[j]+nums[k]==0){
                            res.push_back(vector<int>{nums[i],nums[j],nums[k]});
                        }
                    }
                }
            }
                        
            return res;
        }
    };

    哈希表

    a+b+c,a 遍历,b 用哈希表暂存,c 遍历

    class Solution {
    public:
        vector<vector<int>> threeSum(vector<int>& nums) {        
            int l;
            int r;
            int target;
            vector<vector<int>> res;
            unordered_map<int,int> m;
            
            if (nums.size()<3){
                return res;
            }
            sort(nums.begin(),nums.end());
            if (nums[nums.size()-1]<0){
                return res;
            }
            
            for (int i=0;i<nums.size();i++){            
                if (nums[i]>0){
                    return res;
                }
                
                if ((i>0) && (nums[i-1]==nums[i])){
                    continue;
                }
    
                target=0-nums[i];        
                l=i+1;
                r=nums.size()-1;
                m.clear();
    
                for (int j=i+1;j<nums.size();j++){
                    if (m.count(target-nums[j])>0){
                        res.push_back({nums[i],nums[j],target-nums[j]}); 
                        m.erase(target-nums[j]);
                        
                        while (j+1<nums.size() && nums[j]==nums[j+1]){
                            j++;
                        }
                    }
                    m[nums[j]]=j;
                    
                }            
            }
            
            return res;
        }
    };

    双指针

    a+b+c,a 遍历,而 b和c 用双指针求

    class Solution {
    public:
        vector<vector<int>> threeSum(vector<int>& nums) {        
            int l;
            int r;
            vector<vector<int>> res;
            vector<int> v;
            
            if (nums.size()<3){
                return res;
            }
            sort(nums.begin(),nums.end());
            if (nums[nums.size()-1]<0){
                return res;
            }
            
            for (int i=0;i<nums.size();i++){
                if (nums[i]>0){
                    return res;
                }
                
                if ((i>0) && (nums[i-1]==nums[i])){
                    continue;
                }
                
                l=i+1;
                r=nums.size()-1;
                
                while(l<r){                    
                    if (nums[l]+nums[r]+nums[i]<0) {
                        l++;
                    } else if (nums[l]+nums[r]+nums[i]>0){
                        r--;
                    } else {
                        res.push_back({nums[i],nums[l],nums[r]});                    
                        while (l<r && nums[l]==nums[l+1]){
                            l++;
                        }
                        while (l<r && nums[r]==nums[r-1]){
                            r--;
                        }
    
                        l++;
                        r--;
                    }
                }
            }
            
            return res;
        }
    };

    16. 最接近的三数之和

    题意

    找到三个数,使之和最接近 target

    分析

    类似于 15 题,不过这题只需找到一个结果即可,因此也没有 15 去重那种复杂的操作。

    最简单的的自然是三重循环,不过显然会超时。

    优化

    要求 a+b+c =sum,即 b+c=sum-a,故我们可以固定 a,就可以将之转换为两数和问题,而两数和问题则根据有序无序,可以使用哈希表,对撞指针来做。

    为了便捷,我们将之排序,然后用对撞指针即可。即根据 b+c 和 sum-a 的大小关系,以及 b,c 的单调性,就可以固定移动策略。然后每次更新一下和即可。当和等于 target 的时候,显然可以直接剪枝返回。

    效率

    时间复杂度:

    空间复杂度:

    代码

    class Solution {
    public:
        int threeSumClosest(vector<int>& nums, int target) {
            sort(nums.begin(),nums.end());
            
            int l=0;
            int r=0;
            int sum=nums[0]+nums[1]+nums[2];
            int res=sum;
            int t;
                
            for (int i=0;i<nums.size();i++){
                l=i+1;
                r=nums.size()-1;
                
                while(l<r){
                    sum=nums[i]+nums[l]+nums[r];
                    t=target-sum;
                    
                    if (abs(target-res)>abs(t)) {
                        res=sum;
                    }
                                    
                    if (t==0){
                        return target;
                    } else if (t > 0){
                        l++;
                    } else if (t < 0){
                        r--;
                    }                
                }                    
            }
            
            return res;
        }
    };

    923. 三数之和的多种可能

    题意

    找到所有 a+b+c =target,不去重。

    分析

    和 15 类似,不过 15 要去重,而这题要找到所有的可能。最简单的自然是暴力,不过显然会超时。

    优化

    参考 15 题,a+b+c=target →b+c=target-a ,固定 a,然后对撞指针求 b 和 c。基本思路和 15,16 题都差不多,不过在找到 a+b+c=target 之后的处理有所不同。15 要去重,所以 b 重复元素,c 重复元素都要剔除。而 16 题则计算具体,本题则是同样要计算 b,c 附近的重复元素,不过不是去重,而是排列组合。

    如果 b,c 不等,如 2,2,2,3,3 要统计 (2,3) 的个数,显然要分别统计 2 和 3 的个数,然后相乘。

    如果 b,c 相等,如 1,1,1,1,1,1 要统计 (1,1) 的个数,则要统计 1 的个数,然后 .

    本题同样有一个剪枝的点,如果 a 大于 target 后,由于排好序了,b 和 c 都比 a 大,那么一定后面都不可能 a+b+c=target,故直接剪枝。

    同样本题还有一个剪枝的易错点,即 a+b+c=target,固定 a 后,统计上一次 b c 组合的个数,下一次 a 重复,然后也等于上一次的个数。如 1,1,2,2,3,3,要统计 (1,2,3),第一个 1 满足的个数是 4,然后直接第二个个数也是 4.虽然看上去好像正确,但是像 0,0,0,0,0 这种统计 (0,0,0) 的个数,下一次 0 的统计个数就比上一次少,故不能这样剪枝。

    效率

    时间复杂度:

    空间复杂度:

    代码

    class Solution {
    public:
        int threeSumMulti(vector<int>& nums, int target) {
            long res=0;
            int l;
            int r;
            int sum;
            int cntl;
            int cntr;
            bool b;
            
            sort(nums.begin(),nums.end());
            
            for (int i=0;i<nums.size();i++){
                if (nums[i]>target){
                    return res;
                }
                
                l=i+1;
                r=nums.size()-1;
                
                while(l<r){
                    sum=nums[i]+nums[l]+nums[r];
                                                  
                    if (sum==target){
                        b=false;
                        
                        cntl=1;
                        cntr=1;
                        
                        if (nums[l]==nums[r]){
                            b=true;
                        }
                        
                        while ((l+1<r) && (l+1<nums.size()) && (nums[l]==nums[l+1])){
                            l++;
                            cntl++;
                        }
                        while ((l+1<r) && (r+1>-1) && (nums[r]==nums[r-1])){
                            r--;
                            cntr++;
                        }
                                           
                        if (b){
                            res+=((cntl*(cntl+1))/2);
                        } else {
                            res+=(cntl*cntr);
                        }
                                            
                        l++;
                        r--;
                        
                    } else if (sum<target){
                        l++;
                    } else if (sum>target){
                        r--;
                    }
                }
            }
            
            return res%1000000007;
        }
    };

    18. 四数之和

    题意

    求出所有 a+b+c+d=target.并且去重。

    分析

    类似于 15 三数求和,这题也是一样的,遍历 a 和 b,然后 c d 用对撞指针求,可以把复杂度降低一个维度。

    去重也是一样的,遍历 a 时,如果相邻重复,跳过即可,b,c,d 同理。

    优化

    需要注意的是几个剪枝操作,在三数和时,a+b+c,当我们判断 a > target 时,就可以判定这个数不可能成立。

    但是在四数和中,a+b+c+d,我们缺不能直接根据 a>target 或者 a+b>target 就剪枝。

    比如 [-5,-4,-3,-2,1,3,3,5] -11 中,当 a=-5 时就 > -11,但是如果直接返回的话,就遗漏了 (-5,-4,-3,1) 这个解,这是因为 a 虽然大于 target,但是 a+b 却不一定大于 target.

    每一个数都递增,它们的和却不一定递增,这就是为什么不能这样剪枝。

    要剪枝的话,直接判断相邻 4 个数是否大于 target,因为相邻 4 数才是最大值。

    还需要注意的是就是防止溢出,可以把 a+b+c+d=target →a+b=target-c-d.

    效率

    时间复杂度:

    空间复杂度:

    代码

    class Solution {
    public:
        vector<vector<int>> fourSum(vector<int>& nums, int target) {
            vector<vector<int>> res;
            
            sort(nums.begin(),nums.end());
            
            for (int i=0;i<nums.size();i++){
                // 剪枝错误,如 [-5,-4,-3,-2,1,3,3,5]
                // if (nums[i]>target){
                //     cout<<nums[i]<<endl;
                //     return res;
                // }
                if (i>0 && nums[i]==nums[i-1]){
                    continue;
                }
                
                for (int j=i+1;j<nums.size();j++){    
                    // 剪枝错误,如 [-3,-2,-1,0,0,1,2,3]
                    // if (nums[i]+nums[j]>target){                    
                    //     return res;
                    // }
                    if (j>i+1 && nums[j]==nums[j-1]){
                        continue;
                    }
                    
                    int l=j+1;
                    int r=nums.size()-1;
                    
                    while(l<r){                    
                        if (nums[i]+nums[j]==target-nums[r]-nums[l]){
                            res.push_back({nums[i],nums[j],nums[l],nums[r]});
                            
                            while(l<r && nums[l]==nums[l+1]){
                                l++;
                            }
                            while(l<r && nums[r]==nums[r-1]){
                                r--;
                            }
                            l++;
                            r--;                        
                        } else if (nums[i]+nums[j]<target-nums[r]-nums[l]){
                            l++;
                        } else if (nums[i]+nums[j]>target-nums[r]-nums[l]){
                            r--;
                        }                    
                    }
                }
            }
            
            return res;
        }
    };
    class Solution {
    public:
        vector<vector<int>> fourSum(vector<int>& nums, int target) {
            vector<vector<int>> res;
            
            sort(nums.begin(),nums.end());
            
            for (int i=0;i<nums.size();i++){            
                if ((i+3<nums.size()) && (nums[i+2]+nums[i+3])>(target-nums[i]-nums[i+1])){
                    return res;
                }
                if (i>0 && nums[i]==nums[i-1]){
                    continue;
                }
                
                for (int j=i+1;j<nums.size();j++){                    
                    if ((j+2<nums.size()) && (nums[j+1]+nums[j+2]>(target-nums[i]-nums[j]))){                    
                        break;
                    }
                    if (j>i+1 && nums[j]==nums[j-1]){
                        continue;
                    }
                    
                    int l=j+1;
                    int r=nums.size()-1;
                    
                    while(l<r){                    
                        if (nums[i]+nums[j]==target-nums[r]-nums[l]){
                            res.push_back({nums[i],nums[j],nums[l],nums[r]});
                            
                            while(l<r && nums[l]==nums[l+1]){
                                l++;
                            }
                            while(l<r && nums[r]==nums[r-1]){
                                r--;
                            }
                            l++;
                            r--;                        
                        } else if (nums[i]+nums[j]<target-nums[r]-nums[l]){
                            l++;
                        } else if (nums[i]+nums[j]>target-nums[r]-nums[l]){
                            r--;
                        }                    
                    }
                }
            }
            
            return res;
        }
    };

    454. 四数相加 II

    题意

    求四个数组中,a+b+c+d=target 的个数

    分析

    无序数组,又要求和的个数,显然要用哈希表了,类似于 1 题,哈希表预处理一半,然后余下的一半遍历即可。而 a+b+c+d 四个数,预处理几个数放进哈希表呢?显然两个最好,这样复杂度只有 ,放 1 个或 3 个都是 .

    优化

    效率

    时间复杂度:

    空间复杂度:

    代码

    class Solution {
    public:
        int fourSumCount(vector<int>& nums1, vector<int>& nums2, vector<int>& nums3, vector<int>& nums4) {
            unordered_map<int,int> m;
            int res=0;
            int i,j;            
            
            for (i=0;i<nums1.size();i++){
                for (j=0;j<nums2.size();j++){
                    m[nums1[i]+nums2[j]]++;
                }
            }
            
            for (i=0;i<nums3.size();i++){
                for (j=0;j<nums4.size();j++){
                    res+=m[-nums3[i]-nums4[j]];
                }
            }            
            
            return res;
        }
    };

    面试题 16.06. 最小差

    题意

    在两数组中寻找最接近的两个数

    分析

    最简单的自然是暴力 了,不过数量级太大会超时,故得换个思路。

    优化

    要求最接近的数,又无序,显然只能暴力,要优化只能排序了。

    自然的想法,可以对 nums1 排序,然后遍历 nums2,再对 nums1 二分找点。并且要找两个点。

    当然也可以用有序的数据结构,如红黑树来维持有序性,原理和二分差不多。这两种方式复杂度显然应该是排序 加上二分 .

    实际上可以两个数组都排序,然后快慢指针来做,可以优化到线性的复杂度。

    假设 i 指向数组 a,j 指向数组 b,那么就只有两种移动策略,要么 i++,要么 b++。

    i
    a1,a2,a3,a4
    
    j
    b1,b2,b3,b4

    当前具体为 abs(a1-b1),下一个距离有 abs(a1-b2),abs(a2-b1) 两种可能,那么选择小的可能去移动指针。谁的下一个更小,就哪个指针移动。

    需要注意的是这个题有溢出问题,由于要计算距离,涉及 a-b 的值,如果下溢的话,说明距离超出了 INT_MAX,由由于结果范围最大就是 INT_MAX,故设为 INT_MAX 即可。

    效率

    时间复杂度:排序 ,二分 ,快慢指针 .

    空间复杂度:

    代码

    class Solution {
    public:
        int smallestDifference(vector<int>& nums1, vector<int>& nums2) {
            sort(nums1.begin(),nums1.end());
            sort(nums2.begin(),nums2.end());
            
            int i=0;
            int j=0;
            long a1,a2;
            int res=2147483647;
            
            while(i<nums1.size() && j<nums2.size()){                       
                int t=nums1[i]-nums2[j];
                if (t==INT_MIN){ // 如果下溢,则距离大于 INT_MAX,使之为 INT_MAX 即可。
                    t=INT_MAX;
                } else {
                    t=abs(t);
                }
                
                res=min(res,t);
                
                if (res==0){
                    return 0;
                }
    
                if (j+1<nums2.size()){
                    a1=abs((long)nums1[i]-nums2[j+1]);
                } else {
                    a1=abs(nums1[i]-nums2[nums2.size()-1]);
                }
                
                if (i+1<nums1.size()){
                    a2=abs(nums1[i+1]-nums2[j]);
                } else {                
                    a2=abs(nums1[nums1.size()-1]-nums2[j]);
                }        
                
                if (a1>a2){
                    i++;
                } else {
                    j++;
                }                        
            }
                    
            return res;
        }
    };

接雨水

接雨水问题也是典型的对撞指针使用常见,题型不多,但是面试频率非常的高。

  • list

    11. 盛最多水的容器

    题意

    在数组中找到两数 nums[i],nums[j] 使得 min(nums[i],num[j])*(j-i) 最大

    分析

    最简单的思路,自然是两层暴力枚举,只是复杂度有点高。

    优化

    由于面积有高和宽两个变量,故为了降维优化,我们将其中一个变量规律化,然后再找另一个变量的最优解。

    思路如下:让宽从最大值开始,每次递减,然后寻找高的最大值。

    具体实现就是,开对撞指针,然后每次移动一个指针。由于只移动一个指针,故无论谁移动都是宽减少 1,而我们的最终目的是找到面积的最大值,故前后指针移动策略应该是,使得移动后的面积更大。即低的一边移动。

    虽然我们使用了双指针,但是移动指针时,是一个贪心思想,即局部求面积最大值。但是贪心也有问题,即怎么证明局部最优等于总体最优呢?即证明证明每次得到的面积最大值,一定是最后的最大值呢?

    换句话说,剪枝掉的解,有可能包含最优解吗?

    答案是,不可能。因为我们移动的是低的一方向高的一方移动,假设是高的一方移动,面积不可能比低的一行移动大。因为高的一方移动时,高已经被低的一方确定了,无论证明移动,高也不会增加。

    效率

    时间复杂度:

    空间复杂度:

    代码

    class Solution {
    public:
        int maxArea(vector<int>& nums) {
            int res=0;
            int l=0;
            int r=nums.size()-1;
            
            while(l<r){
                res=max(res,min(nums[l],nums[r])*(r-l));
                
                if (nums[l]<nums[r]) {
                    l++;
                } else {
                    r--;
                }
            }
            
            return res;
        }
    };

    42. 接雨水

    题意

    什么时候才能接到雨水?显然只有两边的柱子比当前柱子高时,才能接到雨水。而要求的就是中间凹下去那部分的面积。

    分析

    这个题看上去比较复杂,为了简化问题,我们先来从暴力思考,如果我们要暴力去做,那么该怎么做?

    我们可以对每一根柱子进行分析,看这根柱子能接到多少水。什么意思呢,就是指,最后水都接完了之后,看水在这个柱子的高度是多少,如果比柱子要高,那么高出的部分就是接到的雨水。

    那么问题来了,什么时候这个水在这根柱子的高度比这跟柱子要高呢?假设最终水高如图所示:

    无效的图片地址

    而中间的某根柱子,显然只有两种情况,要么比水高,要么比水低

    无效的图片地址

    而我们又要求水比柱子高的情况,显然只有第一种符合条件。即柱子高度比水低时,而水的高度又是谁来决定的?显然是两边界的高度。

    无效的图片地址

    而要作为边界,显然它们应该是两边界中最高的,不然

    无效的图片地址

    如果两边有更高的柱子,那么水高度显然要变化。

    如果我们可以下结论:一根柱子要能接到水的前提是,这根柱子两边的柱子中的最大值都比这个柱子要高。

    现在假设柱子能接到水,第二个问题是:这跟柱子能够接到的水高度是多少?

    无效的图片地址

    如上图所示,显然应该是水高-柱子高度。而水高又是由两边最高柱子中,比较小的一个决定的。

    无效的图片地址

    那么现在我们能下结论了:

    对于每一个柱子接的水,那么它能接的水 = min (左右两边最高柱子)- 当前柱子高度

    转换成代码的话,对于 nums[i] 接到的水,就是要找到 nums[i] 两边中最大值的较小值,最后再减去 nums[i],即 value=min(leftMax,rightMax)-nums[i].

    暴力

    上面我们已经知道怎么求每一根柱子的接水量,而问题的重点就是,怎么找到 nums[i] 的 leftMax 和 rightMax.

    如果暴力的话,显然就是两边枚举找最值。所以我们枚举柱子,再枚举找两边最值即可。

    class Solution {
    public:
        int trap(vector<int>& nums) {
            int leftMax;
            int rightMax;
            int res=0;
            
    				// 枚举柱子 nums[i]
            for (int i=0;i<nums.size();i++){
                leftMax=0;
                rightMax=0;
                
    						// 找到 leftMax
                for (int j=0;j<=i;j++){
                    leftMax=max(leftMax,nums[j]);
                }
    
    						// 找到 rightMax            
                for (int k=i;k<nums.size();k++){
                    rightMax=max(rightMax,nums[k]);
                }            
                
                if ((leftMax>nums[i]) && (rightMax>nums[i])){
                    res+=(min(leftMax,rightMax)-nums[i]);
                }
            }
            
            return res;
        }
    };

    显然时间复杂度为 ,空间复杂度为 .由于数量级在 10^5 级别,故暴力会超时。

    动态规划、预处理

    上面我们再找最大值时,是每次重新计算的,实际上,我们进行了一些重复的比较,我们没有必要重新计算,可以提前把两边的最大值先求出来,类似于前缀和数组和后缀和数组,只是不再是和,而是最大值而已。

    我们开 leftMax 和 rightMax 数组,leftMax[i] 表示 nums[0]~nums[i] 的最大值,rightMax[i] 表示 nums[i]~nums[length] 的最大值。

    最大值数组的求法显然就是个递推的过程,即 leftMax[i]=max(leftMax[i-1],nums[i])。

    class Solution {
    public:
        int trap(vector<int>& nums) {
            vector<int> leftMax(nums.size());
            vector<int> rightMax(nums.size());
            int res=0;
            
            leftMax[0]=nums[0];
            rightMax[nums.size()-1]=nums[nums.size()-1];
            
    				// 提前计算最大值数组
            for (int i=1;i<nums.size()-1;i++){
                leftMax[i]=max(leftMax[i-1],nums[i]);
                rightMax[nums.size()-1-i]=max(rightMax[nums.size()-1-i+1],nums[nums.size()-1-i]);
            }
            
            for (int i=0;i<nums.size();i++){
                if (leftMax[i]>nums[i] && rightMax[i]>nums[i]){
                    res+=(min(leftMax[i],rightMax[i])-nums[i]);
                }
            }
            
            return res;
        }
    };

    显然时间复杂度为 ,空间复杂度也为 .

    双指针

    在动态规划中,我们提前计算了 leftMax 数组和 rightMax 数组,但是实际上,我们真正对于 nums[i] 用到的只有 leftMax[i] 和 rightMax[i],前面的值已经没用了,这稍微有些浪费,而实际上我们可以只维护某点的 leftMax 和 rightMax 即可。不过不是一个柱子的,而是两个柱子的。

    假设两柱子分别为 i,j,且 j>i。那么就有 iLeftMax,iRightMax,jLeftMx,jRightMax 这个变量。

    无效的图片地址

    无效的图片地址

    由上图可知,由于 j>i ,故 jLeftMax>=iLeftMax,iRigthMax>=jRightMax. 即子区间的最大值大于 m 后,那么区间的最大值肯定也大于 m。

    无效的图片地址

    那么,如果 iLeftMax>jRightMax,则必有 jLeftMax >= jRightMax,所以我们能接 j 点的水。即 jRightMax-nums[j].这时 j 柱子满足条件,故 j 柱子左移。

    如果 jRightMax>iLeftMax,则必有 iRightMax >= iLeftMax,所以我们能接 i 点的水。即 iLeftMax-nums[i].这时 i 柱子满足条件,故 i 柱子右移。

    而上面我们实际上只用到了 iLeftMax,jRightMax 两个变量,故我们维护这两个即可。根据上图,那么只要我们开对撞指针往中间走,就能覆盖所有的柱子。

    class Solution {
    public:
        int trap(vector<int>& nums) {
            int l=0;
            int r=nums.size()-1;
            int lLeftMax=nums[l];
            int rRightMax=nums[r];
            int res=0;
            
            while(l<r){
                lLeftMax=max(lLeftMax,nums[l]);
                rRightMax=max(rRightMax,nums[r]);
                
                if (lLeftMax<rRightMax){
                    res+=(lLeftMax-nums[l]);
                    l++;
                } else {
                    res+=(rRightMax-nums[r]);
                    r--;
                }
            }        
            
            return res;
        }
    };

    单调队列

    其实要维护两边的最值,是一个典型的滑动窗口最大值问题,维护两个单调递减队列即可,复杂度和 dp 差不多。

    单调栈

    单调栈和上面几种方法都不一样,主要是计算接水量的方法不一样,单调栈是横着计算的,而上面是竖着计算的。

    面试题 17.21. 直方图的水量

    同 42.

    407. 接雨水 II

    题意

    分析

    优化

    效率

    时间复杂度:

    空间复杂度:

    代码

    84. 柱状图中最大的矩形

    题意

    找到柱状图中,矩形的最大面积。

    分析

    类似于 42 题,我们也通过遍历每根柱子,然后以找到以这根柱子为高度的面积。显然,我们需要左右拓展,找到宽。

    而最简单的思路,自然是遍历柱子的时候,再两边拓展。

    优化

    实际上,我们在两边寻找柱子的时候,两边柱子只有两种情况,即比当前柱子 nums[i] 高,或者低。而高的话,显然能够形成矩阵,故继续。而如果比要 nums[i] 柱子低,说明矩形的边界就在这跟柱子。所以,换一句话说,我们就是从 nums[i] 向两侧拓展时,要找到 nums[i] 两边,第一个比 nums[i] 的数。而这,是典型的单调栈的使用场景。

    效率

    时间复杂度:

    空间复杂度:

    代码

    class Solution {
    public:
        int largestRectangleArea(vector<int>& nums) {
            int leftIndex=0;
            int rightIndex=0;
            int j,k;
            int res=0;
            
            for (int i=0;i<nums.size();i++){              
                // 找到 nums[i] 矩阵左边界
                j=i;
                while(j > 1 && nums[j-1] >= nums[i]){
                    j--;
                }
                if (j==1 && nums[0]>=nums[i]){
                    j=0;
                }
            
                // 找到 nums[i] 矩阵右边界
                k=i;
                while(k+1 < nums.size() && nums[k+1] >= nums[i]){
                    k++;
                }            
                
                res=max(res,nums[i]*(k-j+1));
            }
            
            return res;
        }
    };

总结

双指针总的来说并不复杂,思想也比较简单。但是效率较高,通常能将时间复杂度降一个维度,空间复杂度也为 ,根据移动方向又分成快慢指针和对撞指针,前者有典型应用场景,如滑动窗口,字符移动等,而否则有二分,回文串检测,字符串反转,接雨水等典型应用场景。做双指针的题时,要搞清楚两个指针的指向什么东西,什么时候移动等问题,搞懂则几个问题之后,双指针就迎刃而解了。

参考

【91算法-基础篇】05.双指针

双指针

算法 - 双指针问题解决思路

双指针算法 | 8 月更文挑战

双指针总结

『算法』—— 双指针算法 —— 快慢指针 / 对撞指针 / 滑动窗口 (快慢指针的一种)

分享|2021 秋招算法总结 7 - 双指针篇

评论 (0)