一、算法解释
双指针主要用于遍历数组,两个指针指向不同的元素,从而协同完成任务。也可以延伸到多个数组的多个指针。
若两个指针指向同一数组,遍历方向相同且不会相交,则也称为滑动窗口(两个指针包围的区域即为当前的窗口),经常用于区间搜索。
若两个指针指向同一数组,但是遍历方向相反,则可以用来进行搜索,待搜索的数组往往是排好序的。
二、两数之和
2.1、两数之和 II - 输入有序数组
2.1.1、题目描述
167. 两数之和 II - 输入有序数组
给你一个下标从 1 开始的整数数组numbers
,该数组已按 非递减顺序排列 ,请你从数组中找出满足相加之和等于目标数target
的两个数。如果设这两个数分别是numbers[index1]
和numbers[index2]
,则1 <= index1 < index2 <= numbers.length
。
以长度为 2 的整数数组[index1, index2]
的形式返回这两个整数的下标index1
和index2
。
你可以假设每个输入 只对应唯一的答案 ,而且你 不可以 重复使用相同的元素。
你所设计的解决方案必须只使用常量级的额外空间。
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
,另有两个整数m
和n
,分别表示nums1
和nums2
中的元素数目。
请你 合并 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
,你要判断是否存在两个整数a
和b
,使得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 个字符集大小的键值对。