玩转双指针

一、算法解释

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

若两个指针指向同一数组,遍历方向相同且不会相交,则也称为滑动窗口(两个指针包围的区域即为当前的窗口),经常用于区间搜索。

若两个指针指向同一数组,但是遍历方向相反,则可以用来进行搜索,待搜索的数组往往是排好序的。


二、两数之和

2.1、两数之和 II - 输入有序数组

2.1.1、题目描述

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

给你一个下标从 1 开始的整数数组 numbers ,该数组已按 非递减顺序排列 ,请你从数组中找出满足相加之和等于目标数 target 的两个数。如果设这两个数分别是 numbers[index1]numbers[index2] ,则 1 <= index1 < index2 <= numbers.length

以长度为 2 的整数数组 [index1, index2] 的形式返回这两个整数的下标 index1index2

你可以假设每个输入 只对应唯一的答案 ,而且你 不可以 重复使用相同的元素。

你所设计的解决方案必须只使用常量级的额外空间。

2.1.2、输入输出示例

示例1:
输入: numbers = [2, 7, 11, 15], target = 9
输出: [1, 2]
解释: 2 与 7 之和等于目标数 9 。因此 index1 = 1, index2 = 2 。返回 [1, 2] 。

示例2:
输入: numbers = [2, 3, 4], target = 6
输出: [1, 3]
解释: 2 与 4 之和等于目标数 6 。因此 index1 = 1, index2 = 3 。返回 [1, 3] 。

示例3:
输入: numbers = [-1, 0], target = -1
输出: [1, 2]
解释: -1 与 0 之和等于目标数 -1 。因此 index1 = 1, index2 = 2 。返回 [1, 2] 。

2.1.3、题解

因为数组已经排好序,我们可以采用方向相反的双指针来寻找这两个数字,一个初始指向最小的元素,即数组最左边,向右遍历;一个初始指向最大的元素,即数组最右边,向左遍历。


如果两个指针指向元素的和等于给定值,那么它们就是我们要的结果。如果两个指针指向元素的和小于给定值,我们把左边的指针右移一位,使得当前的和增加一点。如果两个指针指向元素的和大于给定值,我们把右边的指针左移一位,使得当前的和减少一点。

class Solution {
    public int[] twoSum(int[] numbers, int target) {
        int left = 0, right = numbers.length - 1;
        while (left < right) {
            if (numbers[left] + numbers[right] == target) {
                break;
            }
            if (numbers[left] + numbers[right] > target) {
                right--;
            } else {
                left++;
            }
        }
        return new int[]{left + 1, right + 1};
    }
}

复杂度分析

  • 时间复杂度:O(n),其中 n 是数组的长度。两个指针移动的总次数最多为 n 次。
  • 空间复杂度:O(1)

三、数组合并

3.1、合并两个有序数组

3.1.1、题目描述

88. 合并两个有序数组

给你两个按 非递减顺序 排列的整数数组 nums1 和 nums2,另有两个整数 mn ,分别表示 nums1nums2 中的元素数目。

请你 合并 nums2 到 nums1 中,使合并后的数组同样按 非递减顺序 排列。

注意: 最终,合并后数组不应由函数返回,而是存储在数组 nums1 中。为了应对这种情况,nums1 的初始长度为 m + n,其中前 m 个元素表示应合并的元素,后 n 个元素为 0 ,应忽略。nums2 的长度为 n

3.1.2、输入输出示例

示例1:
输入: nums1 = [1, 2, 3, 0, 0, 0], m = 3, nums2 = [2, 5, 6], n = 3
输出: [1, 2, 2, 3, 5, 6]
解释: 需要合并 [1, 2, 3] 和 [2, 5, 6] 。 合并结果是 [1, 2, 2, 3, 5, 6] ,
    其中斜体加粗标注的为 nums1 中的元素。

示例2:
输入: nums1 = [1], m = 1, nums2 = [], n = 0
输出: [1]
解释: 需要合并 [1] 和 [] 。
    合并结果是 [1] 。

示例3:
输入: nums1 = [0], m = 0, nums2 = [1], n = 1
输出: [1]
解释: 需要合并的数组是 [] 和 [1] 。
    合并结果是 [1] 。
注意,因为 m = 0 ,所以 nums1 中没有元素。nums1 中仅存的 0 仅仅是为了确保合并结果可以顺利存放到 nums1 中。

3.1.3、题解

因为这两个数组已经排好序,我们可以把两个指针分别放在两个数组的末尾,即 nums1 的 m − 1 位和 nums2 的 n − 1 位。每次将较大的那个数字复制到 nums1 的后边,然后向前移动一位。因为我们也要定位 nums1 的末尾,所以我们还需要第三个指针,以便复制。


在以下的代码里,我们直接利用 m 和 n 当作两个数组的指针,再额外创立一个 pos 指针,起始位置为 nums1 的长度。每次向前移动 m 或 n 的时候,也要向前移动 pos。这里需要注意,如果 nums1 的数字已经复制完,不要忘记把 nums2 的数字继续复制;如果 nums2 的数字已经复制完,剩余 nums1 的数字不需要改变,因为它们已经被排好序。

class Solution {
    public void merge(int[] nums1, int m, int[] nums2, int n) {
        int pos = nums1.length;
        while (m > 0 && n > 0) {
            nums1[--pos] = nums1[m - 1] > nums2[n - 1] ? nums1[--m] : nums2[--n];
        }
        while (n > 0) {
            nums1[--pos] = nums2[--n];
        }
    }
}

复杂度分析

  • 时间复杂度:O(m+n),指针移动单调递减,最多移动 m+n 次,因此时间复杂度为 O(m+n)。
  • 空间复杂度:O(1),直接对数组 nums1 原地修改,不需要额外空间。

四、快慢指针

4.1、环形链表 II

4.1.1、题目描述

142. 环形链表 II

给定一个链表的头节点 head ,返回链表开始入环的第一个节点。 如果链表无环,则返回 null

如果链表中有某个节点,可以通过连续跟踪 next 指针再次到达,则链表中存在环。 为了表示给定链表中的环,评测系统内部使用整数 pos 来表示链表尾连接到链表中的位置 (索引从 0 开始)。如果 pos-1,则在该链表中没有环。注意:pos 不作为参数进行传递,仅仅是为了标识链表的实际情况。

不允许修改 链表。

4.1.2、输入输出示例

示例1:
在这里插入图片描述
输入: head = [3, 2, 0, -4], pos = 1
输出: 返回索引为 1 的链表节点
解释: 链表中有一个环,其尾部连接到第二个节点。

示例2:
在这里插入图片描述
输入: head = [1, 2], pos = 0
输出: 返回索引为 0 的链表节点
解释: 链表中有一个环,其尾部连接到第一个节点。

示例3:
在这里插入图片描述
输入: head = [1], pos = -1
输出: 返回 null
解释: 链表中没有环。

4.1.3、题解

对于链表找环路的问题,有一个通用的解法——快慢指针(Floyd 判圈法) 。给定两个指针,分别命名为 slow 和 fast,起始位置在链表的开头。每次 fast 前进两步, slow 前进一步。如果 fast 可以走到尽头,那么说明没有环路;如果 fast 可以无限走下去,那么说明一定有环路,且一定存在一个时刻 slow 和 fast 相遇。当 slow 和 fast 第一次相遇时,我们将 fast 重新移动到链表开头,并让 slow 和 fast 每次都前进一步。当 slow 和 fast 第二次相遇时,相遇的节点即为环路的开始点。


如下图所示,设链表中环外部分的长度为 a。slow 指针进入环后,又走了 b 的距离与 fast 相遇。此时,fast 指针已经走完了环的 n 圈,因此它走过的总距离为 a + n(b + c) + b = a + (n + 1)b + nc 。
在这里插入图片描述
根据题意,任意时刻,fast 指针走过的距离都为 slow 指针的 2 倍。因此,我们有
    a + (n + 1)b + nc = 2(a + b) ⟹ a = c + (n − 1)(b + c)
有了 a = c + (n - 1)(b + c) 的等量关系,我们会发现:从相遇点到入环点的距离加上 n−1 圈的环长,恰好等于从链表头部到入环点的距离。


因此,当发现 slow 与 fast 相遇时,我们再额外使用一个指针。起始,它指向链表头部;随后,它和 slow 每次向后移动一个位置。最终,它们会在入环点相遇。

class Solution {
    public ListNode detectCycle(ListNode head) {
        ListNode fast = head, slow = head;
        // 判断是否存在环形
        do {
            if (fast == null || fast.next == null) {
                return null;
            }
            fast = fast.next.next;
            slow = slow.next;
        } while (fast != slow);

        // 如果存在,查找环形节点
        fast = head;
        while (fast != slow) {
            fast = fast.next;
            slow = slow.next;
        }
        return fast;
    }
}

复杂度分析

  • 时间复杂度:O(m+n),其中 n 为链表中节点的数目。在最初判断快慢指针是否相遇时,slow 指针走过的距离不会超过链表的总长度;随后寻找入环点时,走过的距离也不会超过链表的总长度。因此,总的执行时间为 O(n)+O(n)=O(n)。
  • 空间复杂度:O(1),我们只使用了 fast, slow 指针。

五、滑动窗口

5.1、最小覆盖子串

5.1.1、题目描述

76. 最小覆盖子串

给你一个字符串 s 、一个字符串 t 。返回 s 中涵盖 t 所有字符的最小子串。如果 s 中不存在涵盖 t 所有字符的子串,则返回空字符串 ""

注意:

  • 对于 t 中重复字符,我们寻找的子字符串中该字符数量必须不少于 t 中该字符数量。
  • 如果 s 中存在这样的子串,我们保证它是唯一的答案。

5.1.2、输入输出示例

示例1:
输入: s = “ADOBECODEBANC”, t = “ABC”
输出: “BANC”

示例2:
输入: s = “a”, t = “a”
输出: “a”

示例3:
输入: s = “a”, t = “aa”
输出: “”
解释: t 中两个字符 ‘a’ 均应包含在 s 的子串中,
因此没有符合条件的子字符串,返回空字符串。

5.1.3、题解

本题使用滑动窗口求解,即两个指针 left 和 right 都是从最左端向最右端移动,且 lelft 的位置一定在 right 的左边或重合。另外在 for 循环里还出现了一个 while 循环, 主要负责移动 left 指针,目的在不影响结果的情况下获取最短子字符串,且 left 只会从左到右移动一次。

class Solution {
    public String minWindow(String s, String t) {
        // 统计t中字符情况
        HashMap<Character, Integer> tMap = new HashMap<>();
        for (int i = 0; i < t.length(); i++) {
            char c = t.charAt(i);
            tMap.put(c, tMap.getOrDefault(c, 0) + 1);
        }
        // 用于统计滑动窗口中的字符情况
        HashMap<Character, Integer> sMap = new HashMap<>();

        String minSubString = "";
        // 移动右指针,不断修改滑动窗口中的统计数据
        for (int left = 0, right = 0; right < s.length(); right++) {
            sMap.put(s.charAt(right), sMap.getOrDefault(s.charAt(right), 0) + 1);
            // 若目前滑动窗口已包含t中所有字符,则尝试移动左指针,在不影响结果的情况下获取最短子字符串
            while (checkSMap(sMap, tMap)) {
                String substring = s.substring(left, right + 1);
                minSubString = "".equals(minSubString) ? substring
                        : minSubString.length() < substring.length() ? minSubString : substring;
                sMap.put(s.charAt(left), sMap.get(s.charAt(left++)) - 1);
            }
        }
        return minSubString;
    }

    private boolean checkSMap(HashMap<Character, Integer> sMap, HashMap<Character, Integer> tMap) {
        for (Map.Entry<Character, Integer> entry : tMap.entrySet()) {
            if (sMap.getOrDefault(entry.getKey(), 0) < entry.getValue()) {
                return false;
            }
        }
        return true;
    }
}

复杂度分析

  • 时间复杂度:O(C⋅∣s∣+∣t∣),最坏情况下左右指针对 s 的每个元素各遍历一遍,哈希表中对 s 中的每个元素各插入、删除一次,对 t 中的元素各插入一次。每次检查是否可行会遍历整个 t 的哈希表,哈希表的大小与字符集的大小有关,设字符集大小为 C,则渐进时间复杂度为 O(C⋅∣s∣+∣t∣)。
  • 空间复杂度:O(C),这里用了两张哈希表作为辅助空间,每张哈希表最多不会存放超过字符集大小的键值对,我们设字符集大小为 C ,则渐进空间复杂度为 O(C) 。

六、练习

6.1、基础难度

6.1.1、平方数之和

6.1.1.1、题目描述

633. 平方数之和

给定一个非负整数 c ,你要判断是否存在两个整数 ab,使得 a2 + b2 = c

6.1.1.2、输入输出示例

示例1:
输入: c = 5
输出: true
解释: 1 * 1 + 2 * 2 = 5

示例2:
输入: c = 3
输出: false

6.1.1.3、题解

该题是2.1、两数之和 II - 输入有序数组的变形题之一,可以假设 a ≤ b。初始时 a=0,b = c \sqrt{c} c ,当 a b 平方数大于 c 时,把 a 的值加 1,当 a b 平方数小于 c 时,把 b 的值减 1 。

class Solution {
    public boolean judgeSquareSum(int c) {
        int left = 0, right = (int) Math.sqrt(c);
        while (left <= right) {
            int sum = (int) (Math.pow(left, 2) + Math.pow(right, 2));
            if (sum == c) {
                return true;
            }
            if (sum > c) {
                right--;
            } else {
                left++;
            }
        }
        return false;
    }
}

复杂度分析

  • 时间复杂度:O( c \sqrt{c} c ),最坏情况下 a 和 b 一共枚举了 0 到 c \sqrt{c} c 里的所有整数。
  • 空间复杂度:O(1)

6.1.2、验证回文字符串 Ⅱ

6.1.2.1、题目描述

680. 验证回文字符串 Ⅱ

给定一个非空字符串 s最多删除一个字符。判断是否能成为回文字符串。

6.1.2.2、输入输出示例

示例1:
输入: s = “aba”
输出: true

示例2:
输入: s = “abca”
输出: true
解释: 你可以删除c字符。

示例3:
输入: s = “abc”
输出: false

6.1.2.3、题解

如何判断一个字符串是否是回文串。常见的做法是使用双指针。定义左右指针,初始时分别指向字符串的第一个字符和最后一个字符,每次判断左右指针指向的字符是否相同,如果不相同,则不是回文串;如果相同,则将左右指针都往中间移动一位,直到左右指针相遇,则字符串是回文串。

在允许最多删除一个字符的情况下,同样可以使用双指针,通过贪心实现。初始化两个指针 left 和 right 分别指向字符串的第一个字符和最后一个字符。每次判断两个指针指向的字符是否相同,如果相同,则更新指针,将 left 加 1,right 减 1,然后判断更新后的指针范围内的子串是否是回文字符串。
如果两个指针指向的字符不同,则两个字符中必须有一个被删除,此时我们就分成两种情况:即删除左指针对应的字符,留下子串 s[left + 1, right],或者删除右指针对应的字符,留下子串 s[left, right - 1]。当这两个子串中至少有一个是回文串时,就说明原始字符串删除一个字符之后就以成为回文串。

class Solution {
    public boolean validPalindrome(String s) {
        int left = 0, right = s.length() - 1;
        while (left < right) {
            if (s.charAt(left) != s.charAt(right)) {
                return validPalindrome(s, left + 1, right) || validPalindrome(s, left, right - 1);
            }
            left++;
            right--;
        }
        return true;
    }

    private boolean validPalindrome(String s, int left, int right) {
        while (left < right) {
            if (s.charAt(left++) != s.charAt(right--)) {
                return false;
            }
        }
        return true;
    }
}

复杂度分析

  • 时间复杂度:O(n),其中 n 是字符串的长度。判断整个字符串是否是回文字符串的时间复杂度是 O(n),遇到不同字符时,判断两个子串是否是回文字符串的时间复杂度也都是 O(n)。
  • 空间复杂度:O(1)。只需要维护有限的常量空间。

6.1.3、通过删除字母匹配到字典里最长单词

6.1.3.1、题目描述

524. 通过删除字母匹配到字典里最长单词

给你一个字符串 s 和一个字符串数组 dictionary ,找出并返回 dictionary 中最长的字符串,该字符串可以通过删除 s 中的某些字符得到。

如果答案不止一个,返回长度最长且字母序最小的字符串。如果答案不存在,则返回空字符串。

6.1.3.2、输入输出示例

示例1:
输入: s = “abpcplea”, dictionary = [“ale”, “apple”, “monkey”, “plea”]
输出: “apple”

示例2:
输入: s = “abpcplea”, dictionary = [“a”, “b”, “c”]
输出: “a”

6.1.3.3、题解

通过遍历 dictionary 中的字符串,判断 word 是否是 s 的子序列,并维护当前长度最长且字典序最小,这里初始化两个指针 i 和 j,分别指向 s 和 word 的初始位置,进行逐个匹配,匹配成功则 i 和 j 同时右移,匹配 word 的下一个位置,匹配失败则 i 右移,j 不变,尝试用 s 的下一个字符匹配 word ,最终如果 j 移动到 word 的末尾,则说明 word 是 s 的子序列。

class Solution {
    public String findLongestWord(String s, List<String> dictionary) {
        String longestWord = "";
        for (String word : dictionary) {
            int i = 0, j = 0;
            while (i < s.length() && j < word.length()) {
                if (s.charAt(i) == word.charAt(j)) {
                    j++;
                }
                i++;
            }
            if (j == word.length()) {
                if (word.length() > longestWord.length()
                        || (word.length() == longestWord.length() && word.compareTo(longestWord) < 0)) {
                    longestWord = word;
                }
            }
        }
        return longestWord;
    }
}

复杂度分析

  • 时间复杂度:O(d×(m+n)),其中 d 表示 dictionary 的长度,m 表示 s 的长度,n 表示 dictionary 中字符串的平均长度。我们需要遍历 dictionary 中的 d 个字符串,每个字符串需要 O(n+m) 的时间复杂度来判断该字符串是否为 s 的子序列。
  • 空间复杂度:O(1)

6.2、进阶难度

6.2.1、至多包含K个不同字符的最长子串

6.2.1.1、题目描述

340. 至多包含 K 个不同字符的最长子串

给定一个字符串 s ,找出 至多 包含 k 个不同字符的最长子串 T

6.2.1.2、输入输出示例

示例1:
输入: s = “eceba”, k = 2
输出: 3
解释: 则 T 为 “ece”,所以长度为 3。

示例2:
输入: s = “aa”, k = 1
输出: 2
解释: 则 T 为 “aa”,所以长度为 2。

6.2.1.3、题解

寻找字符串满足某个条件的子串,一般都是考虑双指针+滑动窗口思想(右指针一直前进,当遇到某个条件成立/不成立,更新左指针,然后右指针接着前进)。首先左右指针从 0 开始,右指针步进,用 hashmap 存储俩指针区间内每个字符最新出现的下标,当 hashmap 中元素个数大于要求的 k 时,证明元素多了,上一个子串寻找结束,更新左指针开始下一个子串寻找(舍弃目前区间内最右下标最小的元素,以满足条件要求),直到到最后。

class Solution {
    public int lengthOfLongestSubstringKDistinct(String s, int k) {
        int ans = 0;
        Map<Character, Integer> map = new HashMap<>();

        for (int left = 0, right = 0; right < s.length(); right++) {
            map.put(s.charAt(right), right);
            // 当map中元素个数不满足大于要求的k时,移除区间内最右下标最小的元素,以满足条件
            if (map.size() > k) {
                int min = Collections.min(map.values());
                map.remove(s.charAt(min));
                left = min + 1;
            }
            ans = Math.max(ans, right - left + 1);
        }
        return ans;
    }
}

复杂度分析

  • 时间复杂度:O(n),其中 n 是字符串 s 的长度。两个指针移动的总次数最多为 n 次。
  • 空间复杂度:O(k)。这里用了哈希表作为辅助空间,存放题干要求的 k 个字符集大小的键值对。
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值