Day 5:双指针技巧(快慢指针、滑动窗口)
双指针技巧是处理许多算法问题时常用的技巧,尤其在数组或字符串中。双指针可以帮助我们在遍历过程中减少不必要的运算,从而优化时间复杂度。
📖 一、双指针概述
双指针技巧主要分为两种常见方式:
- 快慢指针(Floyd's Tortoise and Hare):适用于一些涉及到链表、循环、排序等问题。常见于快慢指针查找问题,如链表环问题、判断回文字符串等。
- 滑动窗口:适用于数组或字符串中涉及到子数组、子串问题。通过维护一个可变的窗口范围,避免了频繁的重复计算,从而提高效率。
📖 二、快慢指针(Floyd's Tortoise and Hare)
1. 快慢指针基础
快慢指针技巧最常用于链表问题,主要思想是让两个指针以不同速度进行遍历,快速指针每次移动两步,慢速指针每次移动一步,通常用于判断链表是否有环、链表中环的入口等。
2. 应用示例:判断链表是否有环
问题描述: 给定一个链表,判断该链表是否有环。
- 快指针每次走两步,慢指针每次走一步。
- 如果快指针追上慢指针,则链表有环;否则没有环。
代码实现(判断链表是否有环):
public class LinkedListCycle {// 链表节点定义class ListNode {int val;ListNode next;ListNode(int x) { val = x; }}public boolean hasCycle(ListNode head) {if (head == null) {return false;}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; // 快指针遍历到末尾,说明没有环}public static void main(String[] args) {LinkedListCycle solution = new LinkedListCycle();ListNode head = solution.new ListNode(1);head.next = solution.new ListNode(2);head.next.next = solution.new ListNode(3);head.next.next.next = solution.new ListNode(4);head.next.next.next.next = head.next; // 形成环boolean result = solution.hasCycle(head);System.out.println(result); // 输出 true}
}
3. 代码解析:
- 我们通过定义两个指针,快指针每次移动两步,慢指针每次移动一步。
- 如果链表有环,快指针和慢指针一定会相遇。
- 时间复杂度是 O(n),空间复杂度是 O(1),因为我们只使用了常数空间。
📖 三、滑动窗口
1. 滑动窗口简介
滑动窗口技巧常用于解决字符串或数组中的子串、子数组问题。主要思想是通过维护一个动态窗口,在窗口大小固定或变化的情况下,优化对数据的处理。
滑动窗口有两种类型:
- 固定窗口:窗口大小固定,滑动时,窗口的左右边界会同时变化。
- 可变窗口:窗口大小可变,滑动时,窗口的左边界和右边界根据某些条件变化。
2. 应用示例:移除元素
问题描述: 给定一个数组 nums
和一个值 val
,移除数组中所有等于 val
的元素,并返回移除后的新数组的长度。
思路与分析:
- 维护一个指针
i
,用于遍历数组nums
。 - 用另一个指针
k
来标记不等于val
的元素的位置。 - 遍历完数组后,返回
k
即为新数组的长度。
代码实现(移除元素):
public class RemoveElement {public int removeElement(int[] nums, int val) {int k = 0; // 用于标记新的数组长度for (int i = 0; i < nums.length; i++) {if (nums[i] != val) { // 如果当前元素不等于valnums[k] = nums[i]; // 将当前元素放到新数组的当前位置k++; // 更新新数组的长度}}return k; // 返回新数组的长度}public static void main(String[] args) {RemoveElement solution = new RemoveElement();int[] nums = {3, 2, 2, 3, 4, 5, 3};int val = 3;int newLength = solution.removeElement(nums, val);System.out.println("新数组长度: " + newLength); // 输出 4}
}
3. 代码解析:
- 这里使用两个指针:
i
用于遍历原数组,k
用于标记新数组的位置。 - 通过不断检查每个元素是否等于
val
,如果不等于val
,就将其保留在新数组中。 - 时间复杂度是 O(n),空间复杂度是 O(1),因为我们直接在原数组上修改。
4. 应用示例:最小覆盖子串
问题描述: 给定一个字符串 s
和一个字符串 t
,返回 s
中包含 t
所有字符的最小子串。如果 s
中没有包含 t
的子串,返回空字符串。
思路与分析:
- 使用滑动窗口的技巧。
- 定义两个指针:左指针和右指针。右指针扩展窗口,左指针收缩窗口。
- 窗口内的字符包含
t
的所有字符时,记录当前窗口的最小长度,并尝试收缩窗口。
代码实现(最小覆盖子串):
import java.util.HashMap;
import java.util.Map;public class MinWindowSubstring {public String minWindow(String s, String t) {if (s.length() < t.length()) return "";Map<Character, Integer> tMap = new HashMap<>();for (char c : t.toCharArray()) {tMap.put(c, tMap.getOrDefault(c, 0) + 1);}int left = 0, right = 0, valid = 0, minLength = Integer.MAX_VALUE, start = 0;Map<Character, Integer> window = new HashMap<>();while (right < s.length()) {char c = s.charAt(right);right++;if (tMap.containsKey(c)) {window.put(c, window.getOrDefault(c, 0) + 1);if (window.get(c).equals(tMap.get(c))) {valid++;}}while (valid == tMap.size()) {if (right - left < minLength) {minLength = right - left;start = left;}char d = s.charAt(left);left++;if (tMap.containsKey(d)) {if (window.get(d).equals(tMap.get(d))) {valid--;}window.put(d, window.get(d) - 1);}}}return minLength == Integer.MAX_VALUE ? "" : s.substring(start, start + minLength);}public static void main(String[] args) {MinWindowSubstring solution = new MinWindowSubstring();String s = "ADOBECODEBANC";String t = "ABC";String result = solution.minWindow(s, t);System.out.println("最小覆盖子串: " + result); // 输出 "BANC"}
}
5. 代码解析:
- 我们使用两个指针来维护一个滑动窗口,
right
扩展窗口,left
收缩窗口。 - 使用一个哈希表
tMap
记录目标字符串t
中每个字符的出现次数,使用另一个哈希表window
记录当前窗口中的字符计数。 - valid 记录当前窗口中已包含多少个字符的数量。
时间复杂度:
- O(n),其中
n
是字符串s
的长度。我们只需要遍历一次s
和t
。
📖 四、总结
双指针技巧常见应用:
- 快慢指针:用于链表循环检测、回文判断等。
- 滑动窗口:用于处理字符串/数组中的子串、子数组问题,特别是在要求优化时间复杂度时非常有效。
常见易错点:
- 快慢指针:常常忘记检查快指针是否为空,或者不小心错过了链表是否有环的判断。
- 滑动窗口:窗口范围的扩展和收缩条件要精确,容易遗漏边界条件,比如窗口中的字符是否包含目标字符串的所有字符。