滑动窗口和双指针

254 阅读12分钟

微信图片编辑_20230915110136.jpg

参考:

  1. 我写了首诗,把滑动窗口算法算法变成了默写题
  2. 滑动窗口算法延伸:Rabin Karp 字符匹配算法
  3. 双指针技巧秒杀七道数组题目
  4. 双指针技巧秒杀七道链表题目

滑动窗口

滑动窗口的题目,通常需要维护两个变量,一个是窗口,一个是结果值。比如求平均值,需要另外维护一个sum字段来计算总和,计算异位词,需要另外维护一个队列,来判断是否是异位词。

序号题目完成
窗口大小固定346. 数据流中的移动平均值
剑指 Offer II 041. 滑动窗口的平均值
3. 无重复字符的最长子串
剑指 Offer 48. 最长不含重复字符的子字符串
438. 找到字符串中所有字母异位词
567. 字符串的排列
窗口大小不固定76. 最小覆盖子串

数据流中的移动平均值

相同问题:剑指 Offer II 041. 滑动窗口的平均值

解法

其实很简单的。但是一开始有一个思维误区,只用一个窗口计算平均值,没想到可以再用一个专门的字段来记录sum。

class MovingAverage {
    LinkedList<Integer> window;
    double total = 0d;
    int size = 0;

    public MovingAverage(int size) {
        window = new LinkedList<>();
        this.size = size;
    }

    public double next(int val) {
        if (window.size() == size) {
            total -= window.pollFirst();
        }
        window.addLast(val);
        total += val;
        return total / window.size();
    }
}

无重复字符的最长子串

相同问题:剑指 Offer 48. 最长不含重复字符的子字符串

给定一个字符串,找出其中不含有重复字符的最长子串。 image.png

解法

  1. 用滑动窗口,右边加,左边减。
// 我自己写了一个:效率不是很高
// 执行耗时:6 ms,击败了42.52% 的Java用户
// 内存消耗:42.2 MB,击败了9.19% 的Java用户
class Solution {
    public int lengthOfLongestSubstring(String s) {
        LinkedList<Character> win = new LinkedList<>();
        Set<Character> all = new HashSet<>();
        int len = s.length();
        char[] chars = s.toCharArray();
        int max = 0;
        for (int i = 0; i < len; i++) {
            char cur = chars[i];
            // 满足条件,可以一直往窗口里放
            if (!all.contains(cur)) {
                all.add(cur);
                win.addLast(cur);
                // 在窗口扩容的时候记录最大值
                max = Math.max(max, win.size());
                continue;
            }
            // 不满足条件,从窗口踢出元素
            while (!win.isEmpty()) {
                if (win.getFirst() != cur) {
                    all.remove(win.getFirst());
                    win.removeFirst();
                } else {
                    all.remove(win.getFirst());
                    win.removeFirst();
                    break;
                }
            }
            // 踢完了,把当前的元素加到队尾
            win.addLast(cur);
            all.add(cur);
        }
        return max;
    }
}
// 参考了labuladong的题解,不过好像差距不大
// 执行耗时:6 ms,击败了34.72% 的Java用户
// 内存消耗:41.7 MB,击败了26.91% 的Java用户
class Solution {
    public int lengthOfLongestSubstring(String s) {
        Map<Character, Integer> window = new HashMap<>();

        int left = 0, right = 0;
        int res = 0; // 记录结果
        while (right < s.length()) {
            char c = s.charAt(right);
            right++;
            // 进行窗口内数据的一系列更新
            window.put(c, window.getOrDefault(c, 0) + 1);
            // 判断左侧窗口是否要收缩
            while (window.get(c) > 1) {
                char d = s.charAt(left);
                left++;
                // 进行窗口内数据的一系列更新
                window.put(d, window.get(d) - 1);
            }
            // 在这里更新答案
            res = Math.max(res, right - left);
        }
        return res;
    }
}

找到字符串中所有字母异位词

解法

这个题目,拿到手第一想法就是用一个窗口一直移动,然后每次都判断一下窗口里的字符是否是异位词,如果是就放入结果集。

如何判断异位词:用字符数组或者Map。

不过这种暴力解法效率不高。

class Solution {
    public List<Integer> findAnagrams(String s, String p) {
        int left = 0;
        // 窗口的大小就是p的大小
        int right = p.length() - 1;
        List<Integer> result = new ArrayList<>();
        while (right < s.length()) {
            if (isAnagrams(s, left, right, p)) {
                result.add(left);
            }
            left++;
            right++;
        }
        return result;
    }

    public boolean isAnagrams(String s, int left, int right, String p) {
        int[] chars = new int[26];
        for (int i = left; i <= right; i++) {
            chars[s.charAt(i) - 'a']++;
        }
        for (char pp : p.toCharArray()) {
            if (chars[pp - 'a'] == 0) {
                return false;
            }
            chars[pp - 'a']--;
        }
        for (int c : chars) {
            if (c < 0) {
                return false;
            }
        }
        return true;
    }
}
// 参考了labuladong的题解
class Solution {
    public List<Integer> findAnagrams(String s, String t) {
        int valid = 0;
        int left = 0, right = 0;
        Map<Character, Integer> need = new HashMap<>();
        Map<Character, Integer> win = new HashMap<>();
        for (char c : t.toCharArray()) {
            need.put(c, need.getOrDefault(c, 0) + 1);
        }
        int len = s.length();
        List<Integer> res = new ArrayList<>();
        while (right < len) {
            // 先不停的加,直到超出了t的长度
            char cur = s.charAt(right);
            right++;
            // 加这一步的意义是,当字符没有出现在need中的时候,直接进行下一个窗口的检查
            if (need.containsKey(cur)) {
                win.put(cur, win.getOrDefault(cur, 0) + 1);
                if (win.get(cur).equals(need.get(cur))) {
                    valid++;
                }
            }

            // 踢出
            while (right - left >= t.length()) {
                // 这里需要注意,必须是need的size,因为t中可能有重复字符
                if (valid == need.size()) {
                    res.add(left);
                }
                char out = s.charAt(left);
                if (need.containsKey(out)) {
                    if (need.get(out).equals(win.get(out))) {
                        valid--;
                    }
                    // 从窗口中删除这个元素
                    win.put(out, win.get(out) - 1);
                }
                left++;
            }
        }
        return res;
    }
}

字符串的排列

和438基本一样。

class Solution {
    public boolean checkInclusion(String s1, String s2) {
        Map<Character, Integer> win = new HashMap<>();
        Map<Character, Integer> need = new HashMap<>();
        for (char c : s1.toCharArray()) {
            need.put(c, need.getOrDefault(c, 0) + 1);
        }
        int left = 0, right = 0;
        int valid = 0;
        boolean rs = false;
        while (right < s2.length() && !rs) {
            char cur = s2.charAt(right);
            right++;
            if (need.containsKey(cur)) {
                win.put(cur, win.getOrDefault(cur, 0) + 1);
                if (win.get(cur).equals(need.get(cur))) {
                    valid++;
                }
            }

            while (!rs && right - left >= s1.length()) {
                // 找到一个匹配的就退出
                if (valid == need.size()) {
                    rs = true;
                    break;
                }
                char out = s2.charAt(left);
                left++;
                if (need.containsKey(out)) {
                    if (need.get(out).equals(win.get(out))) {
                        valid--;
                    }
                    win.put(out, win.getOrDefault(out, 0) - 1);
                }
            }
        }
        return rs;
    }
}

最小覆盖子串

解法
应该先找到把所有字段包含的窗口,然后尝试剔除元素,找到最小窗口。

复杂度
时间:O(n)
空间:O(n)

class Solution {
    public String minWindow(String s, String t) {
        if (s.length() < t.length()) {
            return "";
        }
        int left = 0;
        int right = 0;
        int min = Integer.MAX_VALUE;
        int minLeft = 0;
        int minRight = 0;
        Map<Character, Integer> need = new HashMap<>();
        Map<Character, Integer> win = new HashMap<>();
        int valid = 0;
        for (char c : t.toCharArray()) {
            need.put(c, need.getOrDefault(c, 0) + 1);
        }
        int len = s.length();
        while (right < len) {
            char cur = s.charAt(right);
            right++;
            if (need.containsKey(cur)) {
                win.put(cur, win.getOrDefault(cur, 0) + 1);
                if (win.get(cur).equals(need.get(cur))) {
                    valid++;
                }
            }
            while (valid >= need.size()) {
                if (right - left < min) {
                    min = right - left;
                    minLeft = left;
                    minRight = right;
                }
                char toDel = s.charAt(left);
                if (need.containsKey(toDel)) {
                    if (win.get(toDel).equals(need.get(toDel))) {
                        valid--;
                    }
                    win.put(toDel, win.get(toDel) - 1);
                }
                left++;
            }

        }
        return s.substring(minLeft, minRight);
    }
}

双指针数组

如何原地删除数组里的元素?

  1. 把要删除的元素和最后一个元素交换。
  2. 用两个指针,第一个指针蹲结果的位置,第二个指针找结果,从后往前覆盖。

如何判断数组里的重复元素?

  1. 常规思路,可以用map
  2. 如果规定了范围,比如都是小写字母,可以直接创建一个char[26]数组
  3. 如果重复元素都是相邻的,可以用快慢指针,快指针找到不相同的的即可
序号题目完成
26. 删除有序数组中的重复项
27. 移除元素
283. 移动零
344. 反转字符串
5. 最长回文子串
167. 两数之和 II - 输入有序数组
剑指 Offer 57. 和为s的两个数字
剑指 Offer II 006. 排序数组中两个数字之和
392. 判断子序列

删除有序数组中的重复项

要求

  1. 没有排过序
  2. 原地删除
  3. 删除后保持原顺序
class Solution {
    int removeDuplicates(int[] nums) {
        if (nums.length == 0) {
            return 0;
        }
        int slow = 0, fast = 0;
        while (fast < nums.length) {
            if (nums[fast] != nums[slow]) {
                slow++;
                // 维护 nums[0..slow] 无重复
                nums[slow] = nums[fast];
            }
            fast++;
        }
        // 数组长度为索引 + 1
        return slow + 1;
    }
}

移除元素

//leetcode submit region begin(Prohibit modification and deletion)
class Solution {
    public int removeElement(int[] nums, int val) {
        int left = 0;
        int right = 0;
        int n = nums.length;
        while (right < n) {
            if (nums[right] != val) {
                nums[left++] = nums[right];
            }
            right++;
        }
        return left;
    }
}
//leetcode submit region end(Prohibit modification and deletion)

移动零

class Solution {
    public void moveZeroes(int[] nums) {
        int left = 0;
        int right = 0;
        int n = nums.length;
        while (right < n) {
            if (nums[right] != 0) {
                nums[left++] = nums[right];
            }
            right++;
        }
        for (int i = left; i < n; i++) {
            nums[i] = 0;
        }
    }
}

以下参考:nSum问题

1. 两数之和 II - 输入有序数组
2. 剑指 Offer 57. 和为s的两个数字
3. 剑指 Offer II 006. 排序数组中两个数字之和

字符串处理

反转字符串

很简单,不用写了。

class Solution {
    public void reverseString(char[] s) {
        int left = 0;
        int right = s.length - 1;
        while (left < right) {
            char tmp = s[left];
            s[left] = s[right];
            s[right] = tmp;
            left++;
            right--;
        }
    }
}

最长回文子串

2024.2.21 终于自己写出来了。

解法

提到回文,首先想到的是找到字符串的中心,然后从中间往两边扩散。但是最长回文不一定包含中轴,可能出现在任何位置。

顺着这个思路继续思考,那么应该以每一个元素为轴,分别做扩散。

如何扩散呢?

应该有三种方式,假设当前轴的索引为i
(1)i作为中轴,...i-1, i, i+1...
(2)i作为右边,...i-1, i...
(3)i作为左边,...i, i+1...

针对这三种情况,分别计算,就可以得出结果了。

有没有优化空间?

看到这种多次的计算,自然会想到能否优化。这边确实可以优化。因为我们遍历字符串是从左往右走,i作为右边的情况,其实和i-1作为左边的是重复计算的。

举个例子就清楚了:
i=3,i作为右边,这时候去算(2,3) i=2,i作为左边,这时候去算的是(2,3)

复杂度

时间复杂度:O(n)
空间复杂度:O(1)

class Solution {
    int max = 0;
    int maxLeft = 0;
    int maxRight = 0;
    int len;

    public String longestPalindrome(String s) {
        len = s.length();
        for (int i = 0; i < len; i++) {
            findMax(i - 1, i + 1, s);
            findMax(i, i + 1, s);
        }
        return s.substring(maxLeft, maxRight + 1);
    }

    public void findMax(int left, int right, String s) {
        while (left >= 0 && right < len && s.charAt(left) == s.charAt(right)) {
            if (right - left > max) {
                maxRight = right;
                maxLeft = left;
                max = maxRight - maxLeft;
            }
            left--;
            right++;
        }
    }
}

判断子序列

s和t,判断s是否为t的子序列,比较简单。

class Solution {
    public boolean isSubsequence(String s, String t) {
        int i = 0, j = 0;
        while (i < s.length() && j < t.length()) {
            if (s.charAt(i) == t.charAt(j)) {
                i++;
            }
            j++;
        }
        return i == s.length();
    }
}

双指针链表

序号题目完成
141. 环形链表
142. 环形链表 II
160. 相交链表
19. 删除链表的倒数第 N 个结点
83. 删除排序链表中的重复元素
21. 合并两个有序链表
23. 合并K个升序链表
86. 分隔链表
328. 奇偶链表
876. 链表的中间结点

环形链表

判断一个链表中是否有环,这个题目非常经典,也很简单。

public class Solution {
    public boolean hasCycle(ListNode head) {
        ListNode slow = head;
        ListNode fast = head;
        while (fast != null && fast.next != null) {
            fast = fast.next.next;
            slow = slow.next;
            // 相遇的时候,node应该直接判断相等,不应该用val比较
            if (fast == slow) {
                return true;
            }
        }
        return false;
    }
}

环形链表 II

找到环的入口,用公式推导出 x = (n - 1) * (a + b) + b

说明在相遇之后,重新从头节点和相遇节点出发,这两个节点肯定会在入口的地方相遇。

public class Solution {
    public ListNode detectCycle(ListNode head) {
        ListNode slow = head;
        ListNode fast = head;
        while (fast != null && fast.next != null) {
            fast = fast.next.next;
            slow = slow.next;
            // 如果发现了环,先退出
            if (slow == fast) {
                break;
            }
        }
        // 如果fast走到最后一个节点都没发现环
        if (fast == null || fast.next == null) {
            return null;
        }
        ListNode nxt = head;
        // 找到环的入口,因为肯定有环,所以这里没有考虑NPE
        while (nxt != fast) {
            fast = fast.next;
            nxt = nxt.next;
        }
        return nxt;
    }
}

相交链表

相交链表的解法是:A再去遍历B,B遍历完再去遍历A,这两次遍历的长度是相等的,肯定会在相交点相遇。

public class Solution {
    public ListNode getIntersectionNode(ListNode headA, ListNode headB) {
        ListNode x1 = headA;
        ListNode x2 = headB;
        while (x1 != x2) {
            x1 = x1 == null ? headB : x1.next;
            x2 = x2 == null ? headA : x2.next;
        }
        // 如果没有相交此时x1和x2都是null
        return x1;
    }
}

链表的中间结点

快慢指针,fast的速度是slow的两倍。

  • 如果是奇数长度的链表:fast到最后一个的时候,slow正好走到中间位置,返回slow
  • 如果是偶数长度的链表:fast走到最后一个的后一个(相当于给长度+1),因为题目条件是返回中间位置的后一个,此时slow正好走到后一个的位置。
class Solution {
    public ListNode middleNode(ListNode head) {
        ListNode slow = head;
        ListNode fast = head;
        while (fast != null && fast.next != null) {
            fast = fast.next.next;
            slow = slow.next;
        }
        return slow;
    }
}

删除链表的倒数第 N 个结点

用快慢指针,快指针先走N步,然后快慢指针一起走,最后的目的是让慢指针停留在要删除的节点前一个位置。

class Solution {
    public ListNode removeNthFromEnd(ListNode head, int n) {
        // 只有一个节点,需要删除自身
        if (head.next == null) {
            return null;
        }
        ListNode slow = head;
        ListNode fast = head;
        ListNode result = head;
        ListNode pre = null;
        int k = n;
        while (k > 0 && fast != null) {
            fast = fast.next;
            k--;
        }
        // 说明是需要删除头节点
        if (fast == null) {
            return head.next;
        }
        while (fast != null) {
            pre = slow;
            fast = fast.next;
            slow = slow.next;
        }
        // slow就是倒数第N个节点,pre是前面一个节点
        if (slow != null) {
            pre.next = slow.next;
        }
        return head;
    }
}

删除排序链表中的重复元素

其实还是比较简单的,不过看了官方题解写的更好些。

class Solution {
    public ListNode deleteDuplicates(ListNode head) {
        // 队列为空
        if (head == null) {
            return head;
        }
        ListNode left = head;
        ListNode right = head;
        while (right != null) {
            if (right.val != left.val) {
                left.next = right;
                left = left.next;
            }
            right = right.next;
        }
        // 最后一串元素都相等的时候,left的next无法设置
        left.next = null;
        return head;
    }
}
class Solution {
    public ListNode deleteDuplicates(ListNode head) {
        // 队列为空
        if (head == null) {
            return head;
        }
        ListNode cur = head;
        while (cur.next != null) {
            if (cur.val == cur.next.val) {
                cur.next = cur.next.next;
            } else {
                cur = cur.next;
            }
        }
        return head;
    }
}

合并两个有序链表

image.png

这个题目每刷一遍都会遇到,每次都能写出来,刻在脑子里了。

class Solution {
    public ListNode mergeTwoLists(ListNode list1, ListNode list2) {
        ListNode pre = new ListNode(-1);
        ListNode ans = pre;
        while (list1 != null || list2 != null) {
            // 注意给的node的val的范围
            int v1 = list1 == null ? 101 : list1.val;
            int v2 = list2 == null ? 101 : list2.val;
            if (v1 < v2) {
                pre.next = new ListNode(v1);
                list1 = list1.next;
            } else {
                pre.next = new ListNode(v2);
                list2 = list2.next;
            }
            pre = pre.next;
        }
        if (list1 != null) {
            pre.next = list1;
        } else {
            pre.next = list2;
        }
        return ans.next;
    }
}

合并K个升序链表

22的升级版,用一个优先队列实现比较就可以。当然如果刷过TopK问题,也可以手写比较器,将优先队列替换掉。

class Solution {
    public ListNode mergeKLists(ListNode[] lists) {
        if (lists == null || lists.length == 0) {
            return null;
        }
        ListNode pre = new ListNode(-1);
        ListNode ans = pre;
        PriorityQueue<ListNode> queue = new PriorityQueue<>((o1, o2) -> {
            return o1.val - o2.val;
        });
        for (ListNode l : lists) {
            if (l != null) {
                //先把所有链表的头结点加入队列
                queue.offer(l);
                l = l.next;
            }
        }

        // 所有节点都需要遍历到
        while (!queue.isEmpty()) {
            ListNode cur = queue.poll();
            pre.next = new ListNode(cur.val);
            pre = pre.next;
            cur = cur.next;
            // 这个链表的头结点被选中了,需要从这个链表下一个节点补位上来
            if (cur != null) {
                queue.offer(cur);
            }
        }
        return ans.next;
    }
}

分隔链表

合并链表的对立问题,要把链表拆开。

class Solution {
    public ListNode partition(ListNode head, int x) {
        ListNode dummy1 = new ListNode(-1);
        ListNode dummy2 = new ListNode(-1);
        ListNode ans = dummy1;
        ListNode head2 = dummy2;
        ListNode p = head;
        while (p != null) {
            if (p.val < x) {
                dummy1.next = p;
                dummy1 = dummy1.next;
            } else {
                dummy2.next = p;
                dummy2 = dummy2.next;
            }
            // 把当前节点从链表里断开,避免循环依赖的问题
            ListNode tmp = p.next;
            p.next = null;
            p = tmp;
        }

        // 将两个链表连起来
        dummy1.next = head2.next;
        return ans.next;
    }
}

奇偶链表

和分隔链表的区别无非就是判断条件不一样。

class Solution {
    public ListNode oddEvenList(ListNode head) {
        ListNode dummy1 = new ListNode(-1);
        ListNode dummy2 = new ListNode(-1);
        ListNode ans = dummy1;
        ListNode head2 = dummy2;
        ListNode p = head;
        int step = 1;
        while (p != null) {
            if (step % 2 != 0) {
                // 奇数
                dummy1.next = p;
                dummy1 = dummy1.next;
            } else {
                // 偶数
                dummy2.next = p;
                dummy2 = dummy2.next;
            }
            // 把当前节点从链表里断开,避免循环依赖的问题
            ListNode tmp = p.next;
            p.next = null;
            p = tmp;

            step++;
        }

        // 将两个链表连起来
        dummy1.next = head2.next;
        return ans.next;
    }
}