双指针是一种重要的算法技巧,使用两个指针对数组或链表进行操作。
一、对撞指针
对撞指针是指两个指针从数组的两端向中间移动。
1. 经典问题:两数之和
给定一个已按照升序排列的数组,找出两个数使得它们的和等于目标数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| public int[] twoSum(int[] nums, int target) { int left = 0, right = nums.length - 1; while (left < right) { int sum = nums[left] + nums[right]; if (sum == target) { return new int[]{left, right}; } else if (sum < target) { left++; } else { right--; } } return new int[]{-1, -1}; }
|
解题思路:
- 在排序数组中,当sum > target时,右指针左移可以减小和
- 当sum < target时,左指针右移可以增大和
- 时间复杂度:O(n),比暴力解法O(n²)更优
2. 盛最多水的容器
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| public int maxArea(int[] height) { int left = 0, right = height.length - 1; int maxArea = 0; while (left < right) { int area = Math.min(height[left], height[right]) * (right - left); maxArea = Math.max(maxArea, area); if (height[left] < height[right]) { left++; } else { right--; } } return maxArea; }
|
解题思路:
- 面积计算:min(h[i], h[j]) * (j-i)
- 移动策略:
- 每次移动短板
- 因为宽度一定减小,只有通过增加高度才可能得到更大面积
- 移动长板只会使面积更小或不变
二、快慢指针(同向双指针)
两个指针同向移动,但速度不同。
1. 删除排序数组中的重复项
1 2 3 4 5 6 7 8 9 10 11
| public int removeDuplicates(int[] nums) { if (nums.length == 0) return 0; int slow = 0; for (int fast = 1; fast < nums.length; fast++) { if (nums[fast] != nums[slow]) { slow++; nums[slow] = nums[fast]; } } return slow + 1; }
|
原理分析:
- slow指针:指向当前无重复数组的末尾
- fast指针:探索新的不重复元素
- 时间复杂度:O(n),空间复杂度:O(1)
2. 移动零
将所有0移到数组末尾,同时保持非零元素的相对顺序。
1 2 3 4 5 6 7 8 9 10 11
| public void moveZeroes(int[] nums) { int nonZore = -1, zore = 0; while(zore < nums.length) { if (nums[zore] != 0 && ++nonZore != zore) { nums[zore] = nums[zore] ^ nums[nonZore]; nums[nonZore] = nums[zore] ^ nums[nonZore]; nums[zore] = nums[zore] ^ nums[nonZore]; } zore++; } }
|
算法思想:
- nonZero指针:指向下一个非零元素应该放置的位置
- i指针:遍历数组寻找非零元素
- 保证nonZero左侧都是非零元素,且保持原序
3. 求数组/链表上中点
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78
| /** * Definition for singly-linked list. * public class ListNode { * int val; * ListNode next; * ListNode(int x) { * val = x; * next = null; * } * }
public class FindMiddle { public ListNode findMiddle(ListNode head) { if (head == null) { return null; // 空链表 } ListNode slow = head; ListNode fast = head.next; // 从第二个节点开始
while (fast != null && fast.next != null) { slow = slow.next; // 慢指针每次移动一步 fast = fast.next.next; // 快指针每次移动两步 }
return slow; // 返回上中点 }
public static void main(String[] args) { FindMiddle finder = new FindMiddle(); // 测试用例 1: 空链表 ListNode test1 = null; System.out.println("Test 1: " + (finder.findMiddle(test1) == null ? "null" : finder.findMiddle(test1).val));
// 测试用例 2: 单个节点 ListNode test2 = new ListNode(1); System.out.println("Test 2: " + finder.findMiddle(test2).val); // 应返回 1
// 测试用例 3: 两个节点 ListNode test3 = new ListNode(1); test3.next = new ListNode(2); System.out.println("Test 3: " + finder.findMiddle(test3).val); // 应返回 1(上中点)
// 测试用例 4: 奇数个节点 ListNode test4 = new ListNode(1); test4.next = new ListNode(2); test4.next.next = new ListNode(3); System.out.println("Test 4: " + finder.findMiddle(test4).val); // 应返回 2
// 测试用例 5: 偶数个节点 ListNode test5 = new ListNode(1); test5.next = new ListNode(2); test5.next.next = new ListNode(3); test5.next.next.next = new ListNode(4); System.out.println("Test 5: " + finder.findMiddle(test5).val); // 应返回 2(上中点)
// 测试用例 6: 多个节点 ListNode test6 = new ListNode(1); test6.next = new ListNode(2); test6.next.next = new ListNode(3); test6.next.next.next = new ListNode(4); test6.next.next.next.next = new ListNode(5); System.out.println("Test 6: " + finder.findMiddle(test6).val); // 应返回 3
// 测试用例 7: 较长的链表 ListNode test7 = new ListNode(1); for (int i = 2; i <= 10; i++) { ListNode newNode = new ListNode(i); ListNode current = test7; while (current.next != null) { current = current.next; } current.next = newNode; } System.out.println("Test 7: " + finder.findMiddle(test7).val); // 应返回 5 } }
|
三、滑动窗口
滑动窗口是双指针的特殊形式,维护一个可变大小的窗口。
1. 最小覆盖子串
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| public String minWindow(String s, String t) { if (s == null || t == null || s.length() < t.length()) return ""; int[] map = new int[128]; for (char c : t.toCharArray()) { map[c]++; } int start = 0, minStart = 0, minLen = Integer.MAX_VALUE; int count = t.length(); for (int end = 0; end < s.length(); end++) { if (map[s.charAt(end)] > 0) { count--; } map[s.charAt(end)]--; while (count == 0) { if (end - start + 1 < minLen) { minLen = end - start + 1; minStart = start; } map[s.charAt(start)]++; if (map[s.charAt(start)] > 0) { count++; } start++; } } return minLen == Integer.MAX_VALUE ? "" : s.substring(minStart, minStart + minLen); }
|
滑动窗口原理:
- 扩展窗口:右指针右移,直到包含所有目标字符
- 收缩窗口:左指针右移,直到不满足条件
- 记录最优解:在每次找到可行解时更新
2. 最长无重复子串
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| public int lengthOfLongestSubstring(String s) { int[] lastIndex = new int[128]; Arrays.fill(lastIndex, -1); int maxLen = 0; int start = 0; for (int end = 0; end < s.length(); end++) { char c = s.charAt(end); start = Math.max(start, lastIndex[c] + 1); maxLen = Math.max(maxLen, end - start + 1); lastIndex[c] = end; } return maxLen; }
|
算法分析:
- start维护无重复子串的起始位置
- lastIndex记录每个字符最后出现的位置
- 当发现重复字符时,start跳转到重复字符上次出现位置的下一位
四、多指针技巧
1. 三数之和
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| public List<List<Integer>> threeSum(int[] nums) { List<List<Integer>> result = new ArrayList<>(); Arrays.sort(nums); for (int i = 0; i < nums.length - 2; i++) { if (i > 0 && nums[i] == nums[i - 1]) continue; int left = i + 1, right = nums.length - 1, target = -nums[i]; while (left < right) { int sum = nums[left] + nums[right]; if (sum == target) { result.add(Arrays.asList(nums[i], nums[left], nums[right])); while (left < right && nums[left] == nums[left + 1]) left++; while (left < right && nums[right] == nums[right - 1]) right--; left++; right--; } else if (sum < target) { left++; } else { right--; } } } return result; }
|
算法要点:
- 排序是关键:便于去重和移动指针
- 固定一个数,转化为两数之和
- 去重处理:
五、双指针的时间复杂度分析
对撞指针:
快慢指针:
滑动窗口:
- 时间复杂度:O(n)
- 空间复杂度:取决于具体问题,通常是O(1)或O(k)
多指针:
- 时间复杂度:通常是O(n²)
- 空间复杂度:除去输出,通常是O(1)
总结
双指针技巧的核心思想是:
- 通过控制两个或多个指针的移动来降低时间复杂度
- 在线性数据结构中寻找特定模式或关系
- 通常能将暴力解法的时间复杂度从O(n²)优化到O(n)
使用双指针时的关键考虑点:
- 指针的初始位置
- 指针的移动条件
- 结果的更新时机
- 特殊情况的处理
- 去重的必要性