双指针与滑动窗口方法总结

一、双指针介绍

双指针技巧:主要适用于链表、数组、字符串相关问题。

不知道在哪看到一个这样的口诀:

链表子串数组题,用双指针别犹豫。双指针家三兄弟,各个都是万人迷。
快慢指针最神奇,链表操作无压力。归并排序找中点,链表成环搞判定。
左右指针最常见,左右两端相向行。反转数组要靠它,二分搜索是弟弟。
滑动窗口老猛男,子串问题全靠它。左右指针滑窗口,一前一后齐头进。

快慢指针

解决主要解决链表中的问题,比如典型的判定链表中是否包含环。
快慢指针一般都初始化指向链表的头结点head,前进时快指针fast在前,慢指针slow在后,巧妙解决一些链表中的问题。

左右指针

主要解决数组或者字符串中的问题,比如二分查找。

滑动窗口

使用滑动窗口解题的场景:问题本身和能 单调性 建立关系,窗口内就是解决单调性的。看到题目能跟单调性扯上关系,那么立即想到滑动窗口、首尾指针法、单调栈、单调队列。

滑动窗口的实现可以基于双端队列,也可以基于左右指针,优先考虑左右指针更节省空间,但是如果需要对窗口中的元素进行一定的处理操作,那么选择双端队列实现。左右指针是通过左指针代表左边界,右指针代表右边界,二者同时向右移动的基础原理。双端队列是队头出数,队尾进数,不断的将元素入队和出队来实现的。

二、「滑动窗口」和「双指针」的区别

经过与官方的一些讨论,目前将计算过程仅与两端点相关的称为「双指针」,将计算过程与两端点表示的区间相关的称为「滑动窗口」。

下面说下自己的理解。

2.1、滑动窗口

「滑动窗口」是一个默认固定大小的窗口,在一些条件触发的情况下,可能会将其大小进行修改

所以滑动窗口在应用时可分为两种情况:

  • 一种固定大小, 左右边界一起移动
  • 一种先移动右,满足条件后再移动左,这是为了寻找满足条件的字串

为了更好的理解滑动窗口,我们再来看看 Leetcode 上都有哪些滑动窗口的题目。我们直接搜索「滑动窗口」这一关键词(而不是「滑动窗口」这一标签),可以得到下面的这些题目,我把它们分为了两类

第一类:

剑指 Offer II 014. 字符串中的变位词

剑指 Offer II 015. 字符串中的所有变位词

这一类,我们是使用固定长度的滑动窗口来解决问题,窗口的左右边界一起动

第二类:

剑指 Offer II 016. 不含重复字符的最长子字符串

剑指 Offer II 017. 含有所有字符的最短字符串

239. 滑动窗口最大值

480.滑动窗口中位数

这些题目都可以抽象成如下的一个模型:

给定一个长度为 n 的数组 A,我们需要进行 q 次询问,每次询问的是数组中的一个连续的子数组 A[l…r],希望获取该子数组的一些相关信息。如果第 i个询问的子数组的左端点为 li,那么必须满足 li≤li+1,即窗口是在向某一个方向(例如向右)进行滑动的。而不同的 ri之间不需要存在联系,即窗口是可以变长的。

因此,「滑动窗口」本身并不是解决问题的一种方法(或者说算法),它其实就是这个问题本身。我们需要做的是寻找合适的数据结构来「维护」这个「滑动窗口」。例如:

在剑指 Offer II 017. 含有所有字符的最短字符串中,我们使用两个指针来维护;

在 239. 滑动窗口最大值 中,我们使用单调队列进行维护;

在 480. 滑动窗口中位数 中,我们使用两个优先队列 / 两棵平衡树 / 一棵平衡树进行维护。

这样「滑动窗口」的概念就非常清晰了。

2.2、双指针

「双指针」是什么?如果理解了「滑动窗口」,那么「双指针」与其一个非常明显的区别就是:「双指针」不会成为这个问题本身,而是解决问题的一种方法。

双指针可以同向移动,也可以相向移动。

同向移动,例如:1208. 尽可能使字符串相等

给定一个长度为 n 的数组 A,我们需要找出其中一个最长(或者短)的连续子数组,满足题目中给定的要求。

常用的基于「双指针」的解决办法是:我们可以枚举子数组的左边界 l,随后尝试向右扩展右边界 r,使得 A[l…r] 满足(或者不满足)要求但是 A[l…r+1] 不满足(或者满足)。此时一个极大(或者小)的满足要求的区间就是 A[l…r],最终所有极大(或者小)区间的最大(或者小)长度就是所要求出的答案。

相向移动,例如:167. 两数之和 II - 输入有序数组,此题是两个指针,相向移动遍历数组,我们也可以将原数组复制一份并且进行反转,记为 B,那么原先的两个指针在数组 A 上相向移动,就等价于两个指针分别在数组 A 和 B上同向移动,那么这两种情况就是一致的了。

2.3、总结

可能上面的阐述和例子不太好理解,这里补充再说一些。

  • 第一是考虑 239. 滑动窗口最大值 这个问题,是无法使用双指针来解决的,所以同向移动的双指针和滑动窗口没有任何联系,只是它们碰巧长得比较像而已,不要望文生义。

  • 第二是考虑例如「打家劫舍问题」「股票问题」「跳跃游戏问题」之类的经典问题类型,可以发现「滑动窗口」和这些类型是类似的,所以「滑动窗口」是一类问题,其中不同的问题需要使用不同的算法和数据结构来解决。

很简单,就两句话。

「滑动窗口」是一类问题本身,「双指针」是解决一类二分查找问题的通用优化方法。 二者关联的问题之间没有任何关系。

三、双指针相关题

11. 盛最多水的容器

11. 盛最多水的容器 - 力扣(LeetCode) (leetcode-cn.com)

左右指针分别指向数组的开始与结束的位置。左指针递增,右指针递减

复杂度分析:

  • 时间复杂度 O(N)​ : 双指针遍历一次底边宽度 N​​ 。
  • 空间复杂度 O(1)​ : 变量 left,right ,result 使用常数额外空间。
class Solution {
    public int maxArea(int[] height) {
        int left = 0;
        int right = height.length - 1;
        int result = 0;
        while(left < right) {
            // 当前左右指针区间的面积
            int area = (right - left) * Math.min(height[left], height[right]);
            // 对比,保留最大的面积
            result = Math.max(result, area);

            // 移动高度较短的指针
            if(height[left] <= height[right]) {
                left++;
            } else {
                right--;
            }
        }
        return result;
    }
}

88. 合并两个有序数组

88. 合并两个有序数组 - 力扣(LeetCode) (leetcode-cn.com)

第一种:双指针:双指针p1 p2 分别指向两个数组的开端,然后依次比较,每次从两个数组头部取出比较小的数字放到结果中

复杂度分析

  • 时间复杂度:O(m+n)。指针移动单调递增,最多移动m+n 次,因此时间复杂度为O(m+n)。

  • 空间复杂度:O(m+n)。需要建立长度为 m+n 的中间数组。

class Solution {
    // 双指针p1 p2 分别指向两个数组的开端,然后依次比较,每次从两个数组头部取出比较小的数字放到结果中
    public void merge(int[] nums1, int m, int[] nums2, int n) {
        int p1 = 0;
        int p2 = 0;
        int k = 0;
        int[] arr = new int[m+n];

        while(p1 < m || p2 < n) {
            if(p1 == m) {
                // nums1空了,把nums2剩余的放进数组中
                arr[k++] = nums2[p2++];
            } else if(p2 == n) {
                // nums2空了,把nums1剩余的放进数组中
                arr[k++] = nums1[p1++];
            } else if(nums1[p1] < nums2[p2]){
                // nums1中的数较小,放进数组中
                arr[k++] = nums1[p1++];
            } else {
                arr[k++] = nums2[p2++];
            }
        }

        for(int i = 0; i < m+n; i++) {
            nums1[i] = arr[i];
        }
    }
}

第二种:逆双指针,与第一种相反,每次取最大的数,直接放进nums1中。

复杂度分析

  • 时间复杂度:O(m+n)。指针移动单调递减,最多移动m+n 次,因此时间复杂度为O(m+n)。

  • 空间复杂度:O(1)。直接对数组 nums1原地修改,不需要额外空间。

class Solution {
    // 逆双指针,先选大的放进nums1中
    public void merge(int[] nums1, int m, int[] nums2, int n) {
        int p1 = m-1;
        int p2 = n-1;
        int k = m+n-1;
        while(p1 >= 0 || p2 >= 0) {
            if(p1 == -1) {
                // nums1空了,把nums2剩余的放进数组中
                nums1[k--] = nums2[p2--];
            } else if(p2 == -1) {
                // nums2空了,把nums1剩余的放进数组中
                nums1[k--] = nums1[p1--];
            } else if(nums1[p1] < nums2[p2]){
                // nums2中的数较小,放进数组中
                nums1[k--] = nums2[p2--];
            } else {
                nums1[k--] = nums1[p1--];
            }
        }
    }
}

四、滑动窗口相关题

3. 无重复字符的最长子串

3. 无重复字符的最长子串剑指 Offer II 016. 不含重复字符的最长子字符串

复杂度分析

  • 时间复杂度:O(N),其中 N 是字符串的长度。左指针和右指针分别会遍历整个字符串一次。

  • 空间复杂度:O(∣Σ∣),其中 Σ 表示字符集(即字符串中可以出现的字符),∣Σ∣ 表示字符集的大小。

class Solution {
    public int lengthOfLongestSubstring(String s) {
        if(s.length() == 0) {
            return 0;
        }
		// 哈希表:存字符和下标
        HashMap<Character, Integer> map = new HashMap<>();

        int maxLength = 0;
        int left = 0;

        for(int right = 0; right < s.length(); right++) {
          /**
            1、首先,判断当前字符是否包含在map中,如果不包含,将该字符添加到map(字符,字符在数组下标),
             此时没有出现重复的字符,左指针不需要变化。此时不重复子串的长度为:right-left+1,与原来的maxLen比较,取最大值;

            2、如果当前字符 char 包含在 map中,此时有2类情况:
             2.1)当前字符包含在当前有效的子串中,如:abca,当我们遍历到第二个a,当前有效最长子串是 abc,我们又遍历到a,
             那么此时更新 left 为 map.get(a)+1=1,当前有效子串更新为 bca;
             
             2.2)当前字符不包含在当前最长有效子串中,如:abba,我们先添加a,b进map,此时left=0,我们再添加b,发现map中包含b,
      		而且b包含在最长有效子串中,就是2.1)的情况,我们更新 left=map.get(b)+1=2,此时子串更新为 b,而且map中仍然包含a,map.get(a)=0;
             随后,我们遍历到a,发现a包含在map中,且map.get(a)=0,如果我们像2.1)一样处理,就会发现 left=map.get(a)+1=1,实际上,left此时
             应该不变,left保持为2,子段变成 ba才对。

             为了处理以上2类情况,我们每次更新left时,需要判断当前left的值与更新后的值,取较大者,left=Math.max(left , map.get(char)+1).
             另外,更新left后,不管原来的字符【s.charAt(right)】是否在最长子段中,我们都要将字符【s.charAt(right)】 的位置更新为当前的right,
             因此此时新的 s.charAt(right) 已经进入到 当前最长的子段中!
             */

            char c = s.charAt(right);
            if(map.containsKey(c)) {
                left = Math.max(left, map.get(c)+1);
            }
            //不管是否更新left,都要更新 s.charAt(i) 的位置!保证这个字符出现的最新下标
            map.put(c, right);
            maxLength = Math.max(maxLength, right-left+1);
        }

        return maxLength;
    }
}

239.滑动窗口最大值

239. 滑动窗口最大值

时间O(N)

空间O(K),数据结构是双向的,因此「不断从队首弹出元素」保证了队列中最多不会有超过k+1 个元素

class Solution {
    public int[] maxSlidingWindow(int[] nums, int k) {
        // 单调队列, 队头是最大元素, 队尾最小,元素从队尾放入 ,保持队头最大即可 (存的是下标)
        int n = nums.length;
        // 队列
        Deque<Integer> queue = new LinkedList<Integer>();
        // 初始化前k个
        for (int i = 0; i < k; i++) {
            while (!queue.isEmpty() && nums[i] > nums[queue.peekLast()]) {
                // 当前下标对应元素 比 队尾对应元素较大,将队尾弹出,
                queue.pollLast();
            }
            // 队尾放入当前下标
            queue.offerLast(i);
        }

        // 结果数组,结果只会包含n-k+1个元素
        int[] ans = new int[n-k+1];
        // 可以先确认第一个较大的数
        ans[0] = nums[queue.peekFirst()];

        // 窗口开始滑动,比较剩余的元素
        for (int i = k; i < n; i++) {
            while (!queue.isEmpty() && nums[i] > nums[queue.peekLast()]) {
                // 当前下标对应元素 比 队尾对应元素较大,将队尾弹出,
                queue.pollLast();
            }
            // 队尾放入当前下标
            queue.offerLast(i);

            // 根据窗口大小,排除队列中不在窗口内的元素
            // 对头存的下标如果比滑动窗口左侧边界还小,就去掉
            while (queue.peekFirst() <= i-k) {
                queue.pollFirst();
            }

            // 此时队头就是最大元素的那个下标
            ans[i-k+1] = nums[queue.peekFirst()];
        }        
        return ans;
    }
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

悬浮海

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值