【算法修炼】two pointers技巧+常见题型(滑动窗口),搞懂双指针看这一篇就够啦。

本文详细介绍了双指针技术及其在不同场景的应用,包括快慢指针解决链表问题,以及滑动窗口解决字符串匹配问题。文章通过具体实例讲解了双指针的基本原理、常见类型及解题技巧。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

一、什么是双指针?

双指针主要用于遍历数组,两个指针指向不同的元素,从而协同完成任务。也可以延伸到多个数组的多个指针。

  • 若两个指针指向同一数组,遍历方向相同且不会相交,则也称为滑动窗口(两个指针包围的区域即为当前的窗口),经常用于区间搜索。因为可以框出一个个窗口
  • 若两个指针指向同一数组,但是遍历方向相反,则可以用来进行搜索,待搜索的数组往往是排好序的,即为常见的two pointers用法。最普通、常见的双指针,一前一后
  • 之后还会介绍两个指针速度不同的用法,属于快慢指针,常用于解决链表问题。

1.1两数之和Ⅱ(简单)(直接用双指针)

在这里插入图片描述
已经排好序,利用双指针的思想,定一找一,一个指针从头开始,另一个指针从尾部开始,相反方向遍历整个数组,判断两个指针指向数组元素之和,=就跳出循环,>说明尾部指针大了,j–,<说明头部指针小了,i++。

class Solution {
    public int[] twoSum(int[] numbers, int target) {
        // 递增序列
        int i = 0;
        int j = numbers.length - 1;
        while(i < j){
            if(numbers[i] + numbers[j] == target){
                break;
            }else if(numbers[i] + numbers[j] > target){
                j--;
            }else{
                i++;
            }
        }
        return new int[] {i+1, j+1};
    }
}

1.2反转字符串(简单)(原地)(直接用双指针)

一个指针指向字符串开始位置,另一个指向字符串结束位置,指针只需要走到字符串的中间位置即可
在这里插入图片描述

class Solution {
    public void reverseString(char[] s) {
        int length = s.length;
        int i = 0;
        int j = length - 1;
        length /= 2;
        while(i != length){
            char tmp = s[i];
            s[i] = s[j];
            s[j] = tmp;
            i++;
            j--;
        }
    }
}

1.3盛最多水的容器(中等)

在这里插入图片描述
通过示例可以知道,水的多少=min(height[left], height[right]) * (right - left),我们每次只需让小的那一边移动即可,在移动过程中记录每次水的多少,找到其中的最大容量。

class Solution {
    public int maxArea(int[] height) {
        int left = 0;
        int right = height.length - 1;
        int ans = -1;
        while (left < right) {
            int area = Math.min(height[left], height[right]) * (right - left);
            ans = Math.max(ans, area);
            if (height[left] < height[right]) {
                left++;
            } else {
                right--;
            }
        }
        return ans;
    }
}

1.4二分查找(本质上也是双指针)

二分模板

    static int binarysearch(int[] arr, int target){
        int begin = 0;
        int end = arr.length - 1;
        while(begin <= end){
            int mid = begin + (end - begin) / 2;
            if(arr[mid] == target){
                return mid;
            }else if(arr[mid] > target){
                end = mid - 1;
            }else{
                begin = mid + 1;
            }
        }
        return -1;
    }

1.5移除元素(简单)

在这里插入图片描述
按照题目要求,需要原地修改数组中元素,并返回数组长度,原地修改说明只能在原数组中进行修改,考虑使用双指针(快慢指针),快指针先遍历数组中元素,遇到不等于val的就继续遍历,慢指针负责存储不等于val的元素。

class Solution {
    public int removeElement(int[] nums, int val) {
        int slow = 0;
        int fast = 0;
        for (fast = 0; fast < nums.length; fast++) {
            if (nums[fast] != val) {
                nums[slow++] = nums[fast];
            }
        }
        return slow;
    }
}

1.6删除有序数组的重复元素(简单)

在这里插入图片描述
还是一样的快慢指针,就是要注意怎么去重,这里的去重方式和回溯专题里的一样。

class Solution {
    public int removeDuplicates(int[] nums) {
        // 注意是有序数组去重
        int slow = 0;
        int fast = 0;
        for (fast = 0; fast < nums.length; fast++) {
            if (fast > 0 && nums[fast] == nums[fast - 1]) {
                continue;
            }
            nums[slow++] = nums[fast];
        }
        return slow;
    }
}

※1.7比较含退格的字符串(简单)

在这里插入图片描述
这道题看似简单,但如果要求只用o(n)时间复杂度,并且o(1)空间复杂度就不那么简单了,只能考虑使用双指针,为了方便处理#,我们应该从尾部开始遍历,当是#时就统计#的个数,当不是#时就移动指针,相当于是删除元素。当不是#,且#个数为0时,就可以拿去比较。

class Solution {
    public boolean backspaceCompare(String s, String t) {
        int i = s.length() - 1;
        int j = t.length() - 1;
        int skips = 0;
        int skipt = 0;
        while (i >=0 || j >= 0) {
            while (i >= 0) {
                if (s.charAt(i) == '#') {
                    skips++;
                    i--;
                } else if (skips > 0) {
                    skips--;
                    i--;
                } else {
                    break;
                }
            }
            while (j >= 0) {
                if (t.charAt(j) == '#') {
                    skipt++;
                    j--;
                } else if (skipt > 0) {
                    skipt--;
                    j--;
                } else {
                    break;
                }
            }
            // 注意最后为false的返回情况
            if (i >= 0 && j >= 0) {
                if (s.charAt(i) != t.charAt(j)) {
                    return false;
                }
            } else if (i >= 0 || j >= 0) {
                return false;
            }
            i--;
            j--;
        }
        return true;
    }
}

※二、合并两个有序数组(简单)(三指针、逆向指针)

在这里插入图片描述
方法一:将两个数组直接合并再排序
方法二:三个指针,从两个数组的开始进行扫描,存入中间数组,再把中间数组的值给nums1,难点在于三指针遍历。

class Solution {
    public void merge(int[] nums1, int m, int[] nums2, int n) {
        // 递增排列序列,合并排序后仍然递增
        // 从头部开始扫描,比较存入第三个数组
        int[] nums3 = new int[m + n];
        int i = 0, j = 0, pos = 0;
        while(i < m && j < n){
            if(nums1[i] < nums2[j]){
                nums3[pos] = nums1[i];
                pos++;
                i++;
            }else{
                nums3[pos] = nums2[j];
                pos++;
                j++;
            }
        }
        // 有一个数组先遍历完,但不知道是哪一个
        while(i < m){
            nums3[pos] = nums1[i];
            i++;
            pos++;
        }
        while(j < n){
            nums3[pos] = nums2[j];
            j++;
            pos++;
        }
        for(i = 0; i < m + n; i++){
            nums1[i] = nums3[i];
        }
    }
}

方法二:三个指针,从两个数组的结束进行扫描,逐个存入nums1的末尾位置(因为给出的nums1大小为n+m),难就难在不好想,一般指针都是从头开始扫描,不会想到从尾部开始扫描

class Solution {
    public void merge(int[] nums1, int m, int[] nums2, int n) {
        // 递增排列序列,合并排序后仍然递增
        // 指向nums1中的最后一个位置,从最后开始放就不会修改nums1已有的值
        int pos = m + n - 1;
        // 直接用m、n作为指针,从最后开始遍历
        m--;
        n--;
        while(m >= 0 && n >= 0){
            if(nums1[m] > nums2[n]){
                nums1[pos] = nums1[m];
                pos--;
                m--;
            }else{
                nums1[pos] = nums2[n];
                pos--;
                n--;
            }
        }
        // 总有一个数组先遍历完,如果nums2没有遍历完,说明剩下的值都小于nums1中的值,直接放完就行
        // nums1的话不需要,本身就是以nums1为准
        while(n >= 0){
            nums1[pos] = nums2[n];
            pos--;
            n--;
        }
    }
}

三、快慢指针(简单)(环形链表、回文链表),特有题型

基本思路:
1、定义快慢指针fast和slow,起始均位于链表头部。规定fast每次后移2步,slow后移1步;
2、若fast遇到null节点,则表示链表无环,结束;
3、若链中有环,fast和slow一定会再次相遇;
4、当fast和slow相遇时,额外创建指针ptr,并指向链表头部,且每次后移1步,最终slow和ptr会在入环点相遇。
转自:https://zhuanlan.zhihu.com/p/361049436

常用于判断链表是否有环,fast指针一次移动两格,slow指针一次移动一格。如果存在环路,slow和fast指针最后一定会相遇。 是的没看错,是一定会相遇!我们只需要处理好特殊情况即可,遇到带环链表的判断,可以直接用快慢指针。
在这里插入图片描述
注意:没有结点,或者只有一个结点都认为是无环的

public class Solution {
    public boolean hasCycle(ListNode head) {
        ListNode slow = head;
        ListNode fast = head;
        while (fast != null && fast.next != null) {
            // 快慢指针
            slow = slow.next;
            fast = fast.next.next;
            if (slow == fast) return true;
        }
        return false;
    }
}

3.1环形链表Ⅱ(中等)

在这里插入图片描述
在上一题的基础上增加了对入环第一个结点的求解,fast和slow指针相遇后,让其中一个指针从head出发,都以一步进行移动,此时再相遇即为入环第一个结点。(需要自己在纸上推导一下过程)注意上述结论成立的条件是快慢指针都从head出发!

public class Solution {
    public ListNode detectCycle(ListNode head) {
        ListNode fast = head;
        ListNode slow = head;
        boolean flag = false;
        while (fast != null && fast.next != null) {
            slow = slow.next;
            fast = fast.next.next;
            if (slow == fast) {
                flag = true;
                break;
            }
        }
        // 有环才判断
        if (flag == false) return null;
        fast = head;
        while (fast != slow) {
            fast = fast.next;
            slow = slow.next;
        }
        return fast;
    }
}

解法二:用Hash Set,单指针一步步遍历,将每个结点存入set中,如果当前结点已经在set中,说明当前结点就是入环第一个结点,同时也判断了当前链表是否有环。

public class Solution {
    public ListNode detectCycle(ListNode head) {
        ListNode pos = head;
        Set<ListNode> visited = new HashSet<ListNode>();
        while (pos != null) {
            if (visited.contains(pos)) {
                return pos;
            } else {
                visited.add(pos);
            }
            pos = pos.next;
        }
        return null;
    }
}

3.2回文链表(简单)

在这里插入图片描述
有了前面两道题的铺垫,会发现在普通链表中使用快慢指针可以帮助快速找到链表中点结点(slow就指向中点结点),所以这道题的思路便是,先找到链表中点结点,然后翻转后半部分链表,再用翻转后的链表与前半部分链表比较,注意处理好特殊情况,以及结点个数为奇偶时的判断情况。

解题步骤:
1、找到链表中点
2、翻转后一半链表
3、比较两半链表值

这里我们的快慢指针初始化都为head。同时注意,没有结点和只有一个结点都认为是回文链表。

1 2 3 4:slow = 2
1 2 3 4 5:slow = 3
由上面两个例子可以知道在奇偶长度下slow的位置,我们取slow的下一个结点和其之后的结点进行翻转,再与前一半结点相比较,前一半的长度一定>=后一半,所以我们以后一半翻转过后的链表长度为基准即可。

链表翻转模板:,本质上也是用到了双指针的思想,只不过这里用了三个指针prev、curr、next。

    static ListNode reverseList(ListNode head) {
        ListNode prev = null;
        ListNode curr = head;
        while (curr != null) {
            // 它们之间的交换是有规律的
            ListNode next = curr.next;
            curr.next = prev;
            prev = curr;
            curr = next;
        }
        return prev;
    }

回文链表代码:

class Solution {
    public boolean isPalindrome(ListNode head) {
        // 无节点、1个节点都是回文
        if (head.next == null || head == null) return true;
        // 先找中间节点
        ListNode slow = head;
        ListNode fast = head;
        // 快慢指针找链表中点
        while (fast != null && fast.next != null) {
            slow = slow.next;
            fast = fast.next.next;
        }
        // slow就是中点(偶数节点:中间靠右)
        // 翻转后半部分链表
        ListNode tmp = reverseList(slow);
        while (tmp != null) {
            if (tmp.val != head.val) return false;
            tmp = tmp.next;
            head = head.next;
        }
        return true;
    }
    ListNode reverseList(ListNode head) {
        ListNode prev = null;
        ListNode cur = head;
        while (cur != null) {
            ListNode next = cur.next;
            cur.next = prev;
            prev = cur;
            cur = next;
        }
        return prev;
    }
}

3.3删除链表的倒数第N个结点(中等)

在这里插入图片描述
(如果是数组,很简单可以从尾部扫描,但是链表不能够,怎么办?)
用快慢指针,快指针先走n步,然后快慢指针同时一步步走,当快指针走到null时,慢指针指向被删除结点的前一个结点。用快慢指针实现类似于对链表的尾部扫描!
在这里插入图片描述
图转自:https://leetcode-cn.com/problems/remove-nth-node-from-end-of-list/solution/shan-chu-lian-biao-de-dao-shu-di-nge-jie-dian-b-61/

class Solution {
    public ListNode removeNthFromEnd(ListNode head, int n) {
        // 用快慢指针,快指针先走n格,然后快慢指针一起一步步走,当快指针走到null时,慢指针即为要删除的
        ListNode slow = head;
        ListNode fast = head;
        while (n != 0) {
            fast = fast.next;
            n--;
        }
        // 说明删除的是头结点
        if(fast == null){
            return head.next;
        }
        while(true){
            // 保证slow指向被删除结点的前一个结点
            if(fast == null || fast.next == null){
                break;
            }
            fast = fast.next;
            slow = slow.next;
        }
        // 删除当前结点,注意这些修改都会直接修改head代表的链表,它们只是指向了同一个链表的不同位置
        slow.next = slow.next.next;
        return head;
    }
}

总结一下,快慢指针常常使用于链表题目中,可以用于判断环,找成环节点,找链表的中点,对于复杂的题目,找链表中点常常作为其中的基础算法部分,以此为基础构建代码。

四、※高阶用法:滑动窗口(背模板)

滑动窗口本质上也是用双指针实现的,两个指针一前一后框出了一个窗口,利用这个窗口来解决问题。
特点:滑动窗口的两个指针是同向移动,之前简单的双指针的两个指针是相对移动。

4.1最小覆盖子串(困难)(滑动窗口)

在这里插入图片描述
思路:
1、一前一后两个指针框出一个窗口,先让后指针移动,当窗口中包含所有目标串中的字符时(没有要求顺序,只需要统计当前窗口的字符数>=目标串中对应的字符数)停止移动后指针,此时的窗口是一个可行解
2、开始移动前指针,往后逼近,每次移动一步判断当前窗口是否还满足条件,此时即为寻找可行解中的最优解。 当移动前指针到窗口不满足条件时,记录下上一个满足条件的窗口,停止移动前指针。
3、开始移动后指针,直到窗口满足条件,停止移动后指针,跳转到第2步,继续执行。
4、一直有一个变量存储长度最小的满足条件的窗口,返回它即可。

public class 滑动窗口 {
    public static void main(String[] args) {
        String s = "ADOBECODEBANC";
        String t = "ABC";
        // substring是取[0,1)
//        System.out.println(s.substring(0, 1));
        String res = minWindow(s, t);
        System.out.println(res);
    }
    static String minWindow(String s, String t){
        // 统计目标子串 t 中的词频
        Map<Character, Integer> freq = new HashMap<>();
        for (int i = 0; i < t.length(); i++) {
// 第一步:统计目标串中各字符个数
            // 注意getOrDefault指如果没找到char就把该char个数初始化为0
            freq.put(t.charAt(i), freq.getOrDefault(t.charAt(i), 0) + 1);
        }
        // 统计滑动窗口中的词频
        Map<Character, Integer> window = new HashMap<>();
        // 记录当前窗口中有多少个字符已经满足要求
        int success = 0;
        // 记录最小覆盖子串的起始位置和长度
        int start = 0;
        int len = Integer.MAX_VALUE;
        // 开始滑动窗口
        int left = 0;
        int right = 0;
// 第二步:开始滑动窗口(先动right指针)
        while(right < s.length()){
            char c = s.charAt(right);
            // 目标子串中包含 c
// 第三步:如果当前指向的字符在目标串中也有,加入window中
            if(freq.containsKey(c)){
                // 只往窗口中记录子串中需要的字符
                // 把s[right]加入窗口
                window.put(c, window.getOrDefault(c, 0) + 1);
// 第四步:判断window中当前字符的个数是否满足目标串要求,满足就意味着成功一次
// 成功次数是整个目标串中不同的字符
                // 判断窗口中字符 c 的数量是否达到要求
                if(freq.get(c).equals(window.get(c))){
                    success++;
                }
            }
            // 窗口向右扩展
            right++;
// 第五步,缩小window,移动左指针
            // 如果窗口覆盖了 t 中的所有字符,左边指针移动缩小窗口范围
            while(success == freq.size()){
                // 更新结果(只要最小的覆盖子串)
                if(right - left < len){
                    start = left;
                    // 因为使用substring所以不用+1
                    len = right - left;
                }
                // 缩小窗口范围
                char tmp = s.charAt(left);
                // 如果子串中不包含tmp那就没有保留的必要
                if(freq.containsKey(tmp)){
                    // 发现窗口中的该字符数和子串中的需求个数一样
                    // 还是让它左移,跳出循环,让右指针再移动去找其它可行解
                    if(window.get(tmp).equals(freq.get(tmp))){
                        success--;
                    }
                    // 缩小了window,里面对应的字符个数-1
                    window.put(tmp, window.get(tmp) - 1);
                }
                left++;
            }
        }
        return len == Integer.MAX_VALUE ? "" : s.substring(start, start + len);
    }
}

在这里插入图片描述
在这里插入图片描述
可以看到上面两部分是对称的,这类题型的模板可以总结如下:
参考自:https://labuladong.github.io/algo/1/11/

static void slidingWindow(String s, String t){
        int[] need = new int[128];
        int[] have = new int[128];
        // 统计目标子串各字符个数
        for (int i = 0; i < t.length(); i++) {
            need[t.charAt(i)]++;
        }
        int left = 0, right = 0, count = 0;
        // 开始滑动窗口
        while(right < s.length()){
            // 右移窗口
            char c = s.charAt(right);
            right++;
            // 对窗口内数据进行需要的更新
            ...

            // 判断窗口是否可以从左边开始收缩(具体条件根据题意来)
            while(window needs shrink){
                // 记录要移出去的字符
                char d = s.charAt(left);
                left++;
                // 对窗口内数据进行需要的更新
                ...
            }
        }
    }

如果不喜欢用HashMap也可以用Int数组进行模拟,代码如下:
用Int数组模拟速度要快于HashMap,但适用性不高,数组有其局限性,建议用统一的框架模板去写题,也方便自己更加熟悉框架。

class Solution {
    public String minWindow(String s, String t) {
        int[] need = new int[128];
        int[] have = new int[128];
        // 统计目标串中的字符个数
        for (int i = 0; i < t.length(); i++) {
            need[t.charAt(i)]++;
        }
        int left = 0, right = 0, len = Integer.MAX_VALUE, start = 0, count = 0;
        // 开始滑动窗口
        while(right < s.length()){
            // 右指针右移扩大窗口
            char c = s.charAt(right);
            right++;
            // 目标串需要右移到的字符,加入到window中
            if(need[c] != 0){
                have[c]++;
                // 统计当前窗口中满足目标串中字符的个数
                if(have[c] <= need[c]){
                    count++;
                }
            }
            // 左指针往右缩小窗口
            while(count == t.length()){
                // 更新结果
                if(right - left < len){
                    len = right - left;
                    start = left;
                }
                c = s.charAt(left);
                left++;
                // 目标串需要左移掉的字符
                if(need[c] != 0){
                    if(have[c] == need[c]){
                        count--;
                        // 减掉了,count != t.length(),这个循环要进来必须要相等
                    }
                    // 窗口中拥有的字符对应--
                    have[c]--;
                }
            }
        }
        return len == Integer.MAX_VALUE ? "" : s.substring(start, start + len);
    }
}

使用HashMap和Int数组模拟的区别:
HashMap:
在这里插入图片描述
Int数组:
在这里插入图片描述
int数组是统计了满足目标串中所有字符的个数,而HashMap统计的是种类!

搞懂了滑动窗口后,下面练习几道经典题目

4.2字符串的排列(中等)(滑动窗口)

在这里插入图片描述
上面一道题只需要窗口中的字符个数满足即可,没有要求字符要连续,本题中需要是子串的排列,子串中的各字符一定是连接在一起的,不能间隔开,怎么办呢?

思路:还是用上一题的模板,当窗口中的字符数满足要求,且长度也等于目标串的长度时,那么此时窗口中的串一定就是目标串的排列。

class Solution {
    public boolean checkInclusion(String s1, String s2) {
        // s2中必须包含s1的子串,而不是上一题(只需要包含字符就行)
        // s2 = eidbha s1 = ab就不行,因为bha被隔开了
        int[] need = new int[128];
        int[] have = new int[128];
        for (int i = 0; i < s1.length(); i++) {
            need[s1.charAt(i)]++;
        }
        int left = 0, right = 0, cnt = 0, len = Integer.MAX_VALUE;
        // 开始滑动窗口
        while(right < s2.length()){
            char c = s2.charAt(right);
            right++;
            if(need[c] != 0){
                // 窗口元素更新
                have[c]++;
                if(have[c] <= need[c]){
                    cnt++;
                }
            }
            // 缩小窗口
            while(cnt == s1.length()){
                // 这里不需要再记录最短长度,我们只用比较每次窗口中的长度是否等于目标串的长度
                len = right - left;
                // 如果结果的长度恰好 = 目标串的长度
                if(len == s1.length()){
                    return true;
                }
                c = s2.charAt(left);
                left++;
                if(need[c] != 0){
                    if(have[c] == need[c]) {
                        cnt--;
                    }
                    have[c]--;
                }
            }
        }
        return false;
    }
}

4.3找到字符串中所有字母异位词(中等)

本质和上题一样,也是找目标串的排列,只不过本题需要记录下source串中target串排列的开始的index。
在这里插入图片描述

class Solution {
    public List<Integer> findAnagrams(String s, String p) {
        // 这道题和上题一样是找子串的排列,只不过这次需要记录下在source中的开始索引
        // 还是先按模板写出滑动窗口的模板
        int[] need = new int[128];
        int[] have = new int[128];
        // 用于存放答案(source串中target串的开始index)
        List<Integer> ans = new ArrayList<>();
        // 统计目标串中各字符数
        for (int i = 0; i < p.length(); i++) {
            need[p.charAt(i)]++;
        }
        int left = 0, right = 0, cnt = 0, len = Integer.MAX_VALUE;
        // 开始滑动窗口
        while(right < s.length()){
            // 右指针右移扩大窗口
            char c = s.charAt(right);
            right++;
            // 更新窗口中字符
            if(need[c] != 0){
                have[c]++;
                if(have[c] <= need[c]){
                    // 记录满足目标串字符个数
                    cnt++;
                }
            }
            // 左指针右移缩小窗口
            // 只是说当前窗口中的字符个数满足目标串要求,但字符顺序不能保证
            while(cnt == p.length()){
                // 更新结果
                len = right - left;
                if(len == p.length()){
                    ans.add(left);
                }
                c = s.charAt(left);
                left++;
                // 更新窗口中字符
                if(need[c] != 0){
                    if(have[c] == need[c]){
                        cnt--;
                    }
                    have[c]--;
                }
            }
        }
        return ans;
    }
}

要注意窗口中两个长度的含义,cnt统计的是当前窗口中满足目标串的字符数(不是种类,是数量,如果用HashMap统计的就是种类),而len(right - left),是当前窗口的长度,len可能大于cnt,代表窗口中包含了多余的(不是目标串中字符)字符,存在可能的压缩机会。而当len=cnt时,代表窗口中的字符串是目标串的排列。

※注意,滑动窗口不能保证窗口中的字符串一定按照目标串中字符的顺序,只能保证是目标串的排列,如果题目要求一定要和目标串顺序一致,无间隔,那就直接用substring。

4.4※无重复字符的最长子串(滑动窗口缩减版)

在这里插入图片描述
这道题其实要比之前的题简单,只有一个source字符串,没有目标字符串,只让找无重复字符的最长子串,所以我们只需要记录窗口内各字符的个数即可,窗口内各字符个数一定要<=1。还是先右指针移动扩展窗口,当右移指针后包括进来后,窗口中该字符的个数==2,说明有重复,再往后走没有意义了,这个时候就可以开始右移左指针缩小窗口,当缩小过后的窗口没有重复字符了就继续扩展窗口。(注意答案的更新位置)

class Solution {
    public int lengthOfLongestSubstring(String s) {
        int right = 0, left = 0, len = 0;
        int[] have = new int[128];
        while(right < s.length()){
            char c = s.charAt(right);
            right++;
            have[c]++;
            // 当前窗口中已经有重复的,停止右移,开始缩小窗口(确定什么条件才缩小窗口)
            while(have[c] == 2){
                // 缩小了窗口就需要更新窗口内数据
                char d = s.charAt(left);
                left++;
                have[d]--;
            }
            // 注意答案更新位置
            len = Math.max(len, right - left);
        }
        return len;
    }
}

4.5※串联所有单词的子串(困难)(以不同起点构建滑动窗口)

在这里插入图片描述
本题相当于是在目标串中找几个单词的排列(必须按一个个单词排列,并且单词间不能有间隔,连续),之前滑动窗口的题目一直都是以一个个字符为单位进行考虑(没有考虑顺序),这里要求了单词必须有序,该怎么办? 我们可以以一个个单词为单位来扩展、缩小窗口,这样就保障了单词的连续性。

可以先按照滑动窗口的模板写出下面代码:

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

public class Main {
    public static void main(String[] args) {
        String s = "barfoothefoobarman";
        String[] words = {"foo", "bar"};
        System.out.println(findSubstring(s, words));
    }
    static List<Integer> findSubstring(String s, String[] words) {
        List<Integer> ans = new ArrayList<>();
        // 一个记录words需要的情况,一个记录window中已有的情况
        Map<String, Integer> need = new HashMap<>();
        int words_len = 0;
        // 以单词为单位
        for(String str : words){
            need.put(str, need.getOrDefault(str, 0) + 1);
            // 可能有重复单词的情况,所以得统计words_len
            words_len += str.length();
        }
        // 每个单词的长度是一致的
        int len = words[0].length();
        int left = 0, cnt = 0, right = 0;
        Map<String, Integer> have = new HashMap<>();
        // 以单词的长度为单位滑动窗口
        while(right + len <= s.length()){
            // 注意,本题中的比较单位是一个个word
            String tmp = s.substring(right, right + len);
            // 按照单词长度扩展窗口
            right += len;
            // 是需要的word(注意word需要保持字符顺序)
            if (need.containsKey(tmp)) {
                // 加入window中
                have.put(tmp, have.getOrDefault(tmp, 0) + 1);
                // 看当前单词是否满足需求(整个单词类别满足需求),满足了需求就cnt++
                if(need.get(tmp).equals(have.get(tmp))) {
                    cnt++;
                }
            }
            while(cnt == need.size()) {
                // 在满足目标串需求的情况下长度也一致
                if(right - left == words_len){
                    ans.add(left);
                }
                tmp = s.substring(left, left + len);
                // 按照单词大小缩小窗口
                left += len;
                // 当前移动的单词正好需要
                if(need.containsKey(tmp)) {
                    if(need.get(tmp).equals(have.get(tmp))) {
                        cnt--;
                    }
                    // 因为window中只存了need中需要的word
                    have.put(tmp, have.get(tmp) - 1);
                }
            }
        }
        return ans;
    }
}

上面的代码肯定是不对的,为什么呢?我们只考虑了从source串中第0个字符开始构建滑动窗口,并且不管扩展、缩小窗口都是以一个word的长度为单位,导致有些情况被忽略了,如下图所示:
在这里插入图片描述
如何解决?可以考虑在第0号位置采样后,再在后面紧接着的位置进行采样,就可以保证source串中的所有情况被遍历,并且再采样word.length次就可以保证。
在这里插入图片描述
如上图可知,当采样word.length次后,就可以保证所有的情况被考虑。最后还要考虑words中还有可能有单词重复的情况。

最终实现代码:大体的形式还是按照之前滑动窗口的模板,只是细节、构建窗口的次数进行了调整。

class Solution {
    public List<Integer> findSubstring(String s, String[] words) {
        List<Integer> ans = new ArrayList<>();
        // 一个记录words需要的情况,一个记录window中已有的情况
        Map<String, Integer> need = new HashMap<>();
        int words_len = 0;
        // 以单词为单位
        for(String str : words){
            need.put(str, need.getOrDefault(str, 0) + 1);
            // 可能有重复单词的情况,所以得统计words_len
            words_len += str.length();
        }
        // 每个单词的长度是一致的
        int len = words[0].length();
        // 前面的for循环是为了保证能够遍历所有的子串情况
        // 以0 - 单词长度-1 为开始索引分别构建滑动窗口
        for (int i = 0; i < len; i++) {
            int left = i, cnt = 0, right = i;
            Map<String, Integer> have = new HashMap<>();
            while(right + len <= s.length()){
                // 注意,本题中的比较单位是一个个word
                String tmp = s.substring(right, right + len);
                right += len;
                // 是需要的word(注意word需要保持字符顺序)
                if (need.containsKey(tmp)) {
                    // 加入window中
                    have.put(tmp, have.getOrDefault(tmp, 0) + 1);
                    // 看当前单词是否满足需求(整个单词类别满足需求),满足了需求就cnt++
                    if(need.get(tmp).equals(have.get(tmp))) {
                        cnt++;
                    }
                }
                while(cnt == need.size()) {
                    // 在满足目标串需求的情况下长度也一致
                    if(right - left == words_len){
                        ans.add(left);
                    }
                    tmp = s.substring(left, left + len);
                    // 当前移动的单词正好需要
                    if(need.containsKey(tmp)) {
                        if(need.get(tmp).equals(have.get(tmp))) {
                            cnt--;
                        }
                        // 因为window中只存了need中需要的word
                        have.put(tmp, have.get(tmp) - 1);
                    }
                    left += len;
                }
            }
        }
        return ans;
    }
}

最后,关于此题再说几句,这道题关键是保证滑动窗口遍历了所有可能情况,这点一直不知道如何解决,通过画图才明白,对source串采样单个word长度的次数就可以(每个单词长度一致)。

4.6长度最小的子数组(中等)(简化的滑动窗口)

在这里插入图片描述

class Solution {
    public int minSubArrayLen(int target, int[] nums) {
        int left = 0, right = 0, len = Integer.MAX_VALUE, sum = 0;
        while (right < nums.length) {
            sum += nums[right];
            right++;
            while (sum >= target) {
                if (right - left < len) {
                    len = right - left;
                }
                sum -= nums[left];
                left++;
            }
        }
        return len == Integer.MAX_VALUE ? 0 : len;
    }
}

4.7考试的最大困扰度(中等)(滑动窗口)

在这里插入图片描述
本来还在思考DP,发现没思路,一看居然是滑动窗口。

class Solution {
    public int maxConsecutiveAnswers(String answerKey, int k) {
        return Math.max(getLen(answerKey, 'T', k), getLen(answerKey, 'F', k));
    }
    // 可以分为替换T为F,和替换F为T两种情况,
    // 以替换T为F为例,也就是找当前窗口中T的个数(而F就不用管,因为是将T替换为F)
    // 因为只能替换k次,所以一旦T的个数大于k了,就得开始缩小窗口
    // 直到窗口中的T的个数等于k了就可以求最值了
    static int getLen(String str, char c, int k) {
        int i = 0, j = 0;
        int n = str.length();
        int have = 0;
        int ans = 1;
        // 滑动窗口,两个指针同向往后移动框出一个窗口
        while (j < n) {
            if (str.charAt(j) == c) {
                have++;
            }
            j++;
            // 缩小窗口
            while (have > k) {
                if (str.charAt(i) == c) {
                    have--;
                }
                // 不管左边是不是c都要移动左指针
                i++;
            }
            // j最后还会加到n,所以结果只用统计j - i,不用+1
            ans = Math.max(ans, j - i);
        }
        return ans;
    }
}

双指针题目总结

双指针可以大致分为快慢指针和普通双指针(三指针),快慢指针多用于解决链表问题(有环链表、回文链表、删除链表倒数结点等),而普通双指针多用于解决数组问题(字符串问题、二分)。

滑动窗口,主要解决字符串中子串、子串的排列等问题(步骤):
1、统计目标串中的字符个数
2、开始滑动窗口
3、右指针先移动,同时更新窗口内数据
4、当窗口内的字符数==目标串的字符数,左指针开始移动,缩小窗口
5、缩小窗口的同时更新窗口内数据(大部分题目都是在缩小窗口时进行的操作或记录的结果不同,其余都是一致的)
滑动窗口的维护,都是定一移一,先让一个指针动,再让另一个指针动,同一时间只有一个指针动,指针什么时候动要根据题目意思来,一般是右指针先动(扩展窗口),然后满足某个条件后再让左指针动(缩小窗口)

遇到链表类问题可以往快慢指针想,遇到数组、字符串问题可以往双指针(滑动窗口解决子串匹配问题)想。

区别双指针和滑动窗口,双指针是一前一后,而滑动窗口是同向移动,都是从最左边开始往右边移动,滑动窗口可以构造一个区间,来满足题目意思,或者利用这个区间干其他的事。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

@u@

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

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

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

打赏作者

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

抵扣说明:

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

余额充值