双指针技术

高效遍历与区间处理的利器

核心主题: 双指针技术(Two Pointers)

主要内容: 双指针核心思想、快慢指针应用(链表环检测、中点查找)、对撞指针应用(两数之和、回文、三数之和、盛水容器)、滑动窗口双指针、时间复杂度与空间复杂度分析、暴力枚举性能对比

关键词: 双指针, 快慢指针, 对撞指针, 两数之和, 链表环, 三数之和, 盛水容器

一、双指针技术概述

双指针技术是一种极其重要的算法设计思想,其核心在于使用两个指针(变量)协同遍历线性数据结构(数组、链表、字符串等),以优化时间或空间复杂度。双指针并非某种特定的数据结构,而是一种解决问题的策略模式,通过巧妙地控制两个指针的移动方向和速度,在单次遍历中完成原本需要多重循环才能实现的计算任务。

核心思想:

  • 使用两个指针变量代替单指针或嵌套循环
  • 通过指针的移动方向移动速度的组合策略解决问题
  • 将时间复杂度从 O(n²) 降至 O(n),空间复杂度通常为 O(1)
  • 适用于数组、链表、字符串等线性结构的遍历与处理

双指针的三种基本模式

模式一:快慢指针(Floyd算法)

两个指针以不同速度遍历链表,常用于链表环检测、寻找链表中点、寻找链表倒数第k个节点等场景。快指针每次移动两步,慢指针每次移动一步。

模式二:左右对撞指针

两个指针分别从数组或字符串的两端向中间移动,常用于有序数组的搜索、回文判断、反转操作等。通常左指针从索引0开始,右指针从末尾开始,根据条件判断移动左指针或右指针。

模式三:滑动窗口双指针

两个指针维护一个窗口区间,右指针扩展窗口,左指针收缩窗口。常用于子串、子数组的最优解问题,如无重复字符的最长子串、最小覆盖子串等。

"双指针的本质是用两个坐标变量代替嵌套循环,将多维遍历降为一维遍历。理解双指针的关键在于理解指针移动的'条件'和'方向'。"

二、快慢指针

1. 链表中点查找

使用快慢指针找链表中点是最经典的应用之一。快指针每次走两步,慢指针每次走一步,当快指针到达链表末尾时,慢指针恰好在链表中点。

class ListNode { int val; ListNode next; ListNode(int x) { val = x; } } public ListNode findMiddle(ListNode head) { if (head == null) return null; ListNode slow = head; ListNode fast = head; while (fast != null && fast.next != null) { slow = slow.next; // 慢指针走一步 fast = fast.next.next; // 快指针走两步 } return slow; // 慢指针即为中点 }

应用场景: 链表的归并排序、二分搜索变体、回文链表判断(找到中点后再反转后半部分)。

2. 链表环检测(Floyd判圈算法)

Floyd判圈算法是快慢指针最著名的应用之一。如果链表中存在环,那么快指针最终会追上慢指针(二者相遇);如果无环,快指针会先到达null。

public boolean hasCycle(ListNode head) { if (head == null || head.next == null) { return false; } ListNode slow = head; ListNode fast = head.next; // 初始让fast领先一步 while (slow != fast) { if (fast == null || fast.next == null) { return false; // 遇到空指针,无环 } slow = slow.next; fast = fast.next.next; } return true; // 快慢指针相遇,有环 }

3. 环入口节点检测

在检测到环存在后,寻找环的入口节点是快慢指针的另一个经典应用。Floyd算法提供了巧妙的解法:将其中一个指针重置到头节点,两个指针以相同速度移动,再次相遇的位置就是环的入口。

public ListNode detectCycle(ListNode head) { ListNode slow = head; ListNode fast = head; // 第一步:检测环是否存在,并找到相遇点 boolean hasCycle = false; while (fast != null && fast.next != null) { slow = slow.next; fast = fast.next.next; if (slow == fast) { hasCycle = true; break; } } if (!hasCycle) return null; // 第二步:寻找环入口 // 将slow重置到头节点,fast保持在相遇点 // 二者以相同速度移动,再次相遇处即为环入口 slow = head; while (slow != fast) { slow = slow.next; fast = fast.next; } return slow; }

数学原理:

设链表头到环入口距离为 a,环入口到相遇点距离为 b,相遇点到环入口距离为 c(环周长为 b + c)。快慢指针相遇时:

  • 慢指针走的距离:a + b
  • 快指针走的距离:a + b + k*(b + c)(k为快指针绕环圈数,k ≥ 1)
  • 由快指针距离 = 2 * 慢指针距离:a + b + k*(b + c) = 2*(a + b)
  • 化简得:a = k*(b + c) - b = (k-1)*(b + c) + c
  • 因此从链表头到环入口的距离 a 等于从相遇点绕环 (k-1) 圈再到环入口的距离 c

4. 相交链表

判断两个链表是否相交,并找到相交的起始节点。双指针解法非常优雅:两个指针分别从两个链表头出发,当指针走到链表末尾时转而指向另一个链表的头部继续前进,相遇点即为相交节点。

public ListNode getIntersectionNode(ListNode headA, ListNode headB) { if (headA == null || headB == null) { return null; } ListNode pA = headA; ListNode pB = headB; // 两个指针各自遍历两个链表,走到末尾后交换路径 // 如果有相交,会在相交节点相遇 // 如果不相交,最终同时为null while (pA != pB) { pA = (pA == null) ? headB : pA.next; pB = (pB == null) ? headA : pB.next; } return pA; }

时间复杂度: O(m + n),其中 m 和 n 分别为两个链表的长度。空间复杂度: O(1)。

三、左右对撞指针

左右对撞指针(Two Sum 式双指针)要求数据具有某种有序性单调性。两个指针分别从数组两端向中间移动,根据当前左右指针指向元素的和或其他条件与目标值的比较结果,决定移动左指针还是右指针。

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

这是对撞指针最基础也最经典的问题。给定一个已按升序排列的数组,找到两个数使其和等于目标值。

public int[] twoSum(int[] numbers, int target) { int left = 0; int right = numbers.length - 1; while (left < right) { int sum = numbers[left] + numbers[right]; if (sum == target) { return new int[] {left + 1, right + 1}; } else if (sum < target) { left++; // 和太小,左指针右移使和增大 } else { right--; // 和太大,右指针左移使和减小 } } return new int[] {-1, -1}; }

正确性证明: 由于数组有序,当 sum < target 时,移动右指针只会使 sum 更小,因此只能移动左指针;反之亦然。该算法保证了不会错过任何可能的配对。

2. 回文字符串判断

对撞指针可以非常高效地判断一个字符串是否为回文,只需从两端向中间逐字符比较。

public boolean isPalindrome(String s) { if (s == null || s.length() == 0) { return true; } int left = 0; int right = s.length() - 1; while (left < right) { // 跳过非字母数字字符 while (left < right && !Character.isLetterOrDigit(s.charAt(left))) { left++; } while (left < right && !Character.isLetterOrDigit(s.charAt(right))) { right--; } if (Character.toLowerCase(s.charAt(left)) != Character.toLowerCase(s.charAt(right))) { return false; } left++; right--; } return true; }

3. 反转数组

使用对撞指针可以原地反转数组,不需要额外空间。

public void reverseArray(int[] arr) { int left = 0; int right = arr.length - 1; while (left < right) { // 交换左右指针指向的元素 int temp = arr[left]; arr[left] = arr[right]; arr[right] = temp; left++; right--; } }

4. 三数之和

三数之和是两数之和的进阶版,需要找到数组中所有三元组 [a, b, c] 使得 a + b + c = 0。通过固定一个元素,然后对剩下的部分使用对撞指针搜索,可以将 O(n³) 的暴力枚举降至 O(n²)。

public List<List<Integer>> threeSum(int[] nums) { List<List<Integer>> result = new ArrayList<>(); Arrays.sort(nums); // 先排序,O(n log n) for (int i = 0; i < nums.length - 2; i++) { // 跳过重复元素 if (i > 0 && nums[i] == nums[i - 1]) continue; int left = i + 1; int right = nums.length - 1; while (left < right) { int sum = nums[i] + nums[left] + nums[right]; if (sum == 0) { 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 < 0) { left++; // 和太小,左指针右移 } else { right--; // 和太大,右指针左移 } } } return result; }

去重技巧: 在外层固定元素和内层双指针移动时都需要跳过重复值,确保结果集中没有重复的三元组。

5. 盛最多水的容器

给定一个长度为 n 的整数数组 height,代表 n 条垂直线段,找出两条线段与 x 轴构成的容器能容纳最多的水。

public int maxArea(int[] height) { int left = 0; int right = height.length - 1; int maxWater = 0; while (left < right) { // 计算当前容器容量(短板效应) int width = right - left; int h = Math.min(height[left], height[right]); maxWater = Math.max(maxWater, width * h); // 移动较矮的那一侧指针 // 因为只有移动较矮的,才有可能让容器容量增加 if (height[left] < height[right]) { left++; } else { right--; } } return maxWater; }

盛水容器核心逻辑:

  • 容器的容量取决于较矮的那一侧(短板效应)
  • 移动较长的板不会增加容量(宽度减小,高度不变或更小)
  • 只有移动较短的板才有可能遇到更高的板,从而增加容量
  • 该策略确保了 O(n) 时间复杂度内找到最优解

四、滑动窗口双指针

滑动窗口是双指针技术的另一个重要分支,特别适用于子数组子串的优化问题。窗口的左右边界由两个指针维护,右指针负责扩展窗口以纳入新元素,左指针负责收缩窗口以排除不满足条件的元素。

无重复字符的最长子串

这是滑动窗口最经典的题目,要求找出给定字符串中不含重复字符的最长子串的长度。

public int lengthOfLongestSubstring(String s) { Set<Character> window = new HashSet<>(); int left = 0; int maxLen = 0; // right指针扩展窗口 for (int right = 0; right < s.length(); right++) { char c = s.charAt(right); // 如果字符重复,移动左指针直到不重复 while (window.contains(c)) { window.remove(s.charAt(left)); left++; } window.add(c); maxLen = Math.max(maxLen, right - left + 1); } return maxLen; }

滑动窗口解题模板

// 通用滑动窗口框架
int left = 0, right = 0;
while (right < array.length) {
    // 1. 扩大窗口:处理 right 指向的元素
    window.add(array[right]);
    right++;

    // 2. 收缩窗口:当窗口不再满足条件时
    while (window needs shrink) {
        window.remove(array[left]);
        left++;
    }

    // 3. 在合适的时机更新答案
    updateResult();
}
                

最小覆盖子串

给定两个字符串 s 和 t,在 s 中找到包含 t 中所有字符的最短子串。这是滑动窗口的高阶应用。

public String minWindow(String s, String t) { Map<Character, Integer> need = new HashMap<>(); Map<Character, Integer> window = new HashMap<>(); for (char c : t.toCharArray()) { need.put(c, need.getOrDefault(c, 0) + 1); } int left = 0, right = 0; int valid = 0; // 已满足条件的字符种类数 int start = 0, minLen = Integer.MAX_VALUE; while (right < s.length()) { char c = s.charAt(right); right++; // 更新窗口数据 if (need.containsKey(c)) { window.put(c, window.getOrDefault(c, 0) + 1); if (window.get(c).equals(need.get(c))) { valid++; } } // 窗口已满足条件,尝试收缩 while (valid == need.size()) { if (right - left < minLen) { start = left; minLen = right - left; } char d = s.charAt(left); left++; if (need.containsKey(d)) { if (window.get(d).equals(need.get(d))) { valid--; } window.put(d, window.get(d) - 1); } } } return minLen == Integer.MAX_VALUE ? "" : s.substring(start, start + minLen); }

五、时间复杂度与空间复杂度分析

各类双指针算法的复杂度汇总

算法/问题 双指针模式 时间复杂度 空间复杂度 前提条件
链表中点查找 快慢指针 O(n) O(1)
链表环检测 快慢指针 O(n) O(1)
环入口查找 快慢指针 O(n) O(1) 有环
相交链表 快慢指针变体 O(m+n) O(1)
两数之和 II 对撞指针 O(n) O(1) 有序数组
三数之和 对撞指针 O(n²) O(1) 排序(O(n log n))
盛最多水容器 对撞指针 O(n) O(1)
回文字符串 对撞指针 O(n) O(1)
无重复字符最长子串 滑动窗口 O(n) O(k)*
最小覆盖子串 滑动窗口 O(m+n) O(k)*

*k 为字符集大小,如果是 ASCII 字符集则为 O(1) 常数空间。

复杂度关键结论

  • 几乎所有双指针算法的时间复杂度都是 O(n),这是双指针最核心的价值所在
  • 空间复杂度通常为 O(1),仅使用常数个额外变量,不随输入规模增长
  • 三数之和需要 O(n log n) 的排序预处理,但搜索部分依然是 O(n²)
  • 滑动窗口如果需要记录字符频率(如最小覆盖子串),额外空间取决于字符集大小

六、双指针 vs 暴力枚举:性能对比

为了更好地理解双指针的优势,我们以几个典型问题为例,对比双指针与暴力枚举的性能差异。

对比一:寻找有序数组中两数之和

维度 暴力枚举(嵌套循环) 双指针(对撞) 性能提升
n = 10 100 次比较 最多 10 次比较 10x
n = 100 10,000 次比较 最多 100 次比较 100x
n = 1,000 1,000,000 次比较 最多 1,000 次比较 1,000x
n = 1,000,000 10¹² 次比较(不可行) 最多 1,000,000 次比较 1,000,000x

对比二:三数之和

维度 暴力枚举 O(n³) 排序+双指针 O(n²) 性能提升
n = 100 1,000,000 次 约 5,000 次 200x
n = 1,000 10⁹ 次(不可行) 约 500,000 次 2,000x
n = 10,000 10¹² 次(不可行) 约 50,000,000 次 20,000x

关键差异总结

  • 暴力枚举思路直接但效率低下,时间复杂度呈多项式(平方、立方)增长
  • 双指针利用数据的有序性或结构特性,以线性时间完成任务
  • 在大数据规模下(n > 10⁴),暴力枚举往往不可行,双指针则是唯一可行的方案
  • 双指针算法的额外空间消耗几乎为零,是一种"时间和空间双优"的策略

七、双指针的适用条件与局限

适用条件

局限性

如何判断是否使用双指针

  1. 检查数据是否为线性结构(数组、链表、字符串)
  2. 检查问题类型:两数之和类、区间处理类、子串子数组类、链表特殊位置类
  3. 检查能否从暴力枚举中找出冗余:嵌套循环中是否存在不必要的重复比较
  4. 检查数据是否具有有序性或单调性:这是对撞指针和滑动窗口的关键前提

八、双指针练习题推荐

以下是按照难度分级的双指针经典练习题,建议依次练习以巩固理解:

入门级

  1. 两数之和 II(LeetCode 167)- 对撞指针入门
  2. 反转字符串(LeetCode 344)- 基础对撞指针
  3. 移除元素(LeetCode 27)- 快慢指针
  4. 移动零(LeetCode 283)- 快慢指针

进阶级

  1. 三数之和(LeetCode 15)- 对撞指针进阶
  2. 盛最多水的容器(LeetCode 11)- 对撞指针应用
  3. 无重复字符的最长子串(LeetCode 3)- 滑动窗口
  4. 环形链表 II(LeetCode 142)- 快慢指针
  5. 验证回文串(LeetCode 125)- 对撞指针

挑战级

  1. 最小覆盖子串(LeetCode 76)- 滑动窗口高阶
  2. 四数之和(LeetCode 18)- 三数之和进阶
  3. 接雨水(LeetCode 42)- 双指针难题
  4. 删除排序数组中的重复项 II(LeetCode 80)- 快慢指针变体

九、核心要点总结

十、进一步思考

从双指针到更高级的算法思想

双指针技术看似简单,但其背后蕴含的算法设计思想可以延伸到更高级的领域:

  • 与二分查找的关系: 对撞指针和二分查找都利用了数据的有序性,但双指针更侧重于多元素的协同配合
  • 与分治算法的关系: 快慢指针找中点是归并排序的基础,体现了分治思想
  • 与单调队列/栈的关系: 滑动窗口双指针可以看作是一种特殊的队列操作
  • 与状态压缩的关系: 双指针本质上是对状态空间的压缩,舍弃了大量不可能达到最优的状态

掌握双指针技术不仅是为了应对面试,更重要的是培养一种"从冗余中寻找规律"的思维方式。在解决实际问题时,我们常常面临大量的可能解空间,双指针教会我们如何利用问题的内在约束来剪枝和优化,以最低的成本找到最优解。这种思想在数据库查询优化、网络流量控制、计算几何等众多领域都有广泛应用。

建议学习者在掌握基本模式后,重点训练识别双指针适用场景的能力。不断自问:"这个问题能否用两个指针来简化?指针的移动条件是什么?"随着练习量的增加,你会逐渐培养出对这类问题的敏感度,达到举一反三的效果。