双指针不仅仅是一种技巧,更是一种降维打击 的思维方式。它的核心在于:通过两个指针的协同移动,剪枝掉大量无效的搜索空间。
在 LeetCode 的中等(Medium)难度题目中,双指针和滑动窗口占据了半壁江山,尤其是在字符串处理和子数组查找问题上。
一、 左右指针:对撞模式
适用场景:
- 有序数组:利用有序性进行二分查找式的收缩。
- 字符串反转:典型的首尾交换。
- 盛水容器 :利用贪心策略从两端向中间逼近。
核心逻辑 :
定义两个指针left = 0和right = n - 1,根据题目特定的条件,决定是left++还是right--,直到left == right或left > right停止。
1.1 实战例题:两数之和 II - 输入有序数组
题目链接 :LeetCode 167. Two Sum II - Input Array Is Sorted
题目描述 :给定一个下标从 1 开始的整数数组 numbers ,该数组已按非递减顺序排列,请你从数组中找出满足相加之和等于目标数 target 的两个数。
思路解析 :虽然可以用 HashMap 解决( O ( N ) O(N) O(N) 空间),但题目强调"有序"且要求 O ( 1 ) O(1) O(1) 空间。
- 如果
sum > target:说明和太大了,需要变小。因为数组有序,left已经是最小的了,只能移动right让大数变小 ( → \rightarrow →right--)。 - 如果
sum < target:说明和太小了,需要变大。只能移动left让小数变大 ( → \rightarrow →left++)。
假设推演:
假设 numbers = [2, 7, 11, 15], target = 9。
| 步骤 | 指针位置 (L, R) | 对应值 (vL, vR) | 当前和 (Sum) | 判断逻辑 | 操作 |
|---|---|---|---|---|---|
| 1 | L=0, R=3 | 2, 15 | 17 | 17 > 9 (太大了) | right-- |
| 2 | L=0, R=2 | 2, 11 | 13 | 13 > 9 (还是大) | right-- |
| 3 | L=0, R=1 | 2, 7 | 9 | 9 == 9 (命中) | 返回 [1, 2] |
Java 代码:
java
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) {
// 题目要求下标从 1 开始
return new int[]{left + 1, right + 1};
} else if (sum < target) {
// 和太小,左指针右移
left++;
} else {
// 和太大,右指针左移
right--;
}
}
return new int[]{-1, -1};
}
1.2 实战例题:盛最多水的容器
题目链接 :LeetCode 11. Container With Most Water
题目描述 :给定一个长度为 n 的整数数组 height 。有 n 条垂线,找出其中的两条线,使得它们与 x 轴共同构成的容器可以容纳最多的水。
思路解析 :这道题的难点在于理解**"为什么要移动较短的那根柱子"**。
- 面积公式: A r e a = ( r i g h t − l e f t ) × min ( h e i g h t [ l e f t ] , h e i g h t [ r i g h t ] ) Area = (right - left) \times \min(height[left], height[right]) Area=(right−left)×min(height[left],height[right])
- 当前状态 :假设
height[left] < height[right]。 - 决策 :
- 如果移动高柱子 (
right--):宽度减小,且高度不可能 超过原来的短板(受限于height[left])。所以面积一定变小。 - 如果移动短柱子 (
left++):宽度减小,但新遇到的柱子可能 比原来高,从而提升整体高度。面积有可能变大。
- 如果移动高柱子 (
- 结论:为了寻找可能的最大值,必须舍弃当前的短板,去寻找更高的可能性。
逻辑证明(反证法思想) :
双指针法本质上是在剪枝 (Pruning)。暴力解法需要计算所有 C ( n , 2 ) C(n, 2) C(n,2) 种组合,即 O ( N 2 ) O(N^2) O(N2)。
当我们因为 height[left] < height[right] 而决定 left++ 时,实际上我们舍弃 了 (left, right-1), (left, right-2), ... (left, left+1) 这一系列的所有组合。
- 为什么可以安全舍弃?
- 因为这些被舍弃的组合,其宽度都比当前
right - left小,而高度受限于height[left](短板) 也不可能超过当前高度。 - 因此,这些组合的面积必然小于当前面积,不需要再计算。
- 这就是为什么 O ( N ) O(N) O(N) 能找到最优解的原因。
Java 代码:
java
public int maxArea(int[] height) {
int left = 0;
int right = height.length - 1;
int maxRes = 0;
while (left < right) {
// 计算当前面积
int currentHeight = Math.min(height[left], height[right]);
int currentWidth = right - left;
maxRes = Math.max(maxRes, currentHeight * currentWidth);
// 移动较短的一边
if (height[left] < height[right]) {
left++;
} else {
right--;
}
}
return maxRes;
}
1.3 进阶思考:对撞指针的本质与扩展
掌握了基础的对撞指针后,可以总结这类问题的通用规律,以及如何处理更复杂的变体(如 3Sum)。
- 为什么是
while (left < right)而不是<=?
在大多数对撞指针问题中(如 Two Sum, Container),我们需要两个不同 的元素来构成结果。- 当
left == right时,两个指针指向同一个元素。对于"两数之和"而言,这通常意味着同一个数被用了两次(除非题目允许),或者是找到了对角线上的点。 - 对于"盛水容器",宽度为 0,面积为 0,没有计算意义。
- 特例 :如果是"二分查找"或"反转字符串(含中间字符)",有时需要处理
left == right的情况,此时可能需要<=。但在双指针解题中,90% 的情况使用<。
- 当
- 从 2Sum 到 3Sum / 4Sum
面试中常考的 3Sum (LeetCode 15) 本质上是对撞指针的降维应用 。-
暴力法 :三层循环, O ( N 3 ) O(N^3) O(N3)。
-
双指针法 : O ( N 2 ) O(N^2) O(N2)。
- 步骤 1:先对数组排序。
- 步骤 2 :固定一个数字
i。 - 步骤 3 :问题转化为"在
i后面的数组中,寻找target = -nums[i]的 Two Sum 问题"。 - 步骤 4 :利用双指针在 O ( N ) O(N) O(N) 内解决 Two Sum。
- 总复杂度 :外层循环 N N N × \times × 内层双指针 N N N = O ( N 2 ) O(N^2) O(N2)。
-
- 唯一的难点:如何去重?
在对撞指针中,去重是一个核心考点。例如输入[-2, 0, 0, 2, 2],求 3Sum。-
指针跳过法 :当
left移动后,如果nums[left] == nums[left-1],说明这个数刚才已经处理过了,为了避免重复结果,必须left++直到遇到不同的数。 -
代码片段示例 :
java// 在找到一组解后,继续移动指针并去重 while (left < right && nums[left] == nums[left + 1]) left++; // 跳过左侧重复 while (left < right && nums[right] == nums[right - 1]) right--; // 跳过右侧重复 left++; right--;
-
二、 快慢指针:追击模式
适用场景:
- 原地修改数组 :在不使用额外空间 ( O ( 1 ) O(1) O(1)) 的前提下,利用指针覆盖数据,完成去重、移除元素、移动零。
- 链表特性探测:利用遍历速度的差异(相对位移),检测链表是否有环、寻找链表中点。
核心逻辑:
- 快指针 (
fast):作为遍历指针,负责扫描整个数组或链表,寻找满足特定条件的新元素。 - 慢指针 (
slow) :作为维护指针,指向当前已处理好的序列的末尾,或者用于标记特定的位置(如中点)。
2.1 实战例题:删除有序数组中的重复项
题目链接 :LeetCode 26. Remove Duplicates from Sorted Array
题目描述:给你一个升序排列的数组 nums ,请你原地删除重复出现的元素,使每个元素只出现一次 ,返回删除后数组的新长度。
思路解析 :
题目要求原地修改,意味着不能通过新建数组来去重。利用快慢指针,可以将数组逻辑上分为两个区间:
[0, slow]:有效区间。该区间内的元素不重复。[fast, n-1]:待处理区间。
算法流程:
- 初始状态:
slow = 0,fast = 1。 - 比较
nums[fast]和nums[slow]:- 如果相等:说明
fast指向的是重复元素,不需要保留,fast继续后移。 - 如果不等:说明
fast指向了一个新元素。需要将这个新元素加入到有效区间 。- 操作:先
slow++(扩充有效区间),再nums[slow] = nums[fast](赋值)。
- 操作:先
- 如果相等:说明
状态演变推演 (输入 [1, 1, 2]):
| 步骤 | 指针位置 (fast, slow) | 比较逻辑 | 数组状态 | 说明 |
|---|---|---|---|---|
| Init | fast=1, slow=0 | nums[1] (1) == nums[0] (1) |
[1, 1, 2] |
重复,忽略 fast |
| 1 | fast=2, slow=0 | nums[2] (2) != nums[0] (1) |
[1, 1, 2] |
发现不同,执行赋值 |
| 2 | fast=2, slow=1 | 赋值操作 | [1, 2, 2] |
nums[slow] 更新为 2 |
| End | - | - | [1, 2, ...] |
返回 slow+1 = 2 |
Java 代码:
java
public int removeDuplicates(int[] nums) {
if (nums.length == 0) return 0;
// slow 维护的是 [0...slow] 这个无重复区间的边界
int slow = 0;
// fast 从 1 开始,因为第 0 个肯定是不重复的
for (int fast = 1; fast < nums.length; fast++) {
// 当发现 fast 指向的元素与 slow 不同时
if (nums[fast] != nums[slow]) {
// 扩充有效区间
slow++;
// 将新元素覆盖写入
nums[slow] = nums[fast];
}
}
// 返回有效区间的长度(长度 = 索引 + 1)
return slow + 1;
}
2.2 实战例题:环形链表
题目链接 :LeetCode 141. Linked List Cycle
题目描述:给你一个链表的头节点 head ,判断链表中是否有环。
思路解析(Floyd 判圈算法) :
利用两个指针遍历链表,slow 步长为 1,fast 步长为 2。
- 无环情况 :
fast步长更大,会率先到达链表尾部 (null),直接返回false。 - 有环情况 :
fast到达尾部后会重新进入环。此时,问题转化为两个指针在环上的追击问题。
逻辑证明(为什么一定会相遇?) :
假设进入环时,fast 落后 slow 的距离为 N N N 个节点。
每次迭代:
slow移动 1 步。fast移动 2 步。- 两者间距变化: D i s t a n c e = N − ( 2 − 1 ) = N − 1 Distance = N - (2 - 1) = N - 1 Distance=N−(2−1)=N−1。
由此可见,每次迭代两者的距离缩短 1。经过 N N N 次迭代后,距离变为 0,即fast追上slow。
注:如果fast步长为 3,相对速度为 2,若 N N N 为奇数则可能跳过slow导致无法立即相遇,因此步长为 2 是最严谨的解法。
Java 代码:
java
public boolean hasCycle(ListNode head) {
// 边界条件处理
if (head == null || head.next == null) return false;
ListNode slow = head;
ListNode fast = head;
// 循环条件:fast 当前节点和下一节点都必须存在
while (fast != null && fast.next != null) {
slow = slow.next; // 步长 1
fast = fast.next.next; // 步长 2
// 必须先移动,再判断。如果重合,说明有环。
if (slow == fast) {
return true;
}
}
return false;
}
2.3 进阶:快慢指针的通用扩展
掌握快慢指针的逻辑后,可以解决一系列链表位置计算问题,其核心在于控制两个指针的间隔。
- 寻找链表中点 (LeetCode 876):
- 原理 :
fast速度是slow的 2 倍。当fast到达末尾时,slow恰好走过了一半的距离。 - 应用:归并排序链表、判断回文链表的前置步骤。
- 原理 :
- 寻找倒数第 K 个节点 (LeetCode 19):
- 原理 :先让
fast移动 K K K 步,此时fast与slow间隔 K K K。 - 同步移动 :然后
fast和slow以相同速度移动。当fast到达末尾 (null) 时,slow所在位置即为倒数第 K K K 个节点。
- 原理 :先让
三、 滑动窗口:子串/子数组的核心
滑动窗口是双指针的一种特定应用模式,专门用于解决 "连续子串" (Substring) 或 "连续子数组" (Subarray) 问题。
核心原理 :
暴力解法通常需要两层循环枚举所有子串( O ( N 2 ) O(N^2) O(N2))。滑动窗口通过维护两个指针 left 和 right,定义一个闭区间(或半开区间)作为"窗口"。
- 扩张 (Expand) :移动
right指针,将新元素纳入窗口,直到满足某种特定条件。 - 收缩 (Shrink) :移动
left指针,将旧元素移出窗口,直到窗口恢复合法状态或不再满足条件。
通过这两个操作的交替进行,保证了每个元素最多"进窗"一次、"出窗"一次,将时间复杂度降低至 O ( N ) O(N) O(N)。
3.1 通用解题模板
大多数滑动窗口题目都遵循以下代码结构的变体。
Java 伪代码模板:
java
/* 滑动窗口通用模板 */
public void slidingWindow(String s) {
// 定义维护窗口状态的数据结构 (如 HashMap, int[] 计数器, 或数值 sum)
// 对于字符集有限的情况,优先使用 int[] window = new int[128];
Map<Character, Integer> window = new HashMap<>();
int left = 0, right = 0;
// 主循环:右指针主动扩张
while (right < s.length()) {
// c 是将要移入窗口的字符
char c = s.charAt(right);
right++;
// TODO: 进行窗口内数据的更新 (如 window.put(c, count+1))
// 子循环:左指针被动收缩
// 判断条件通常是:窗口不再满足题目限制 (求最长时) 或 窗口已经满足条件尝试优化 (求最短时)
while (/* 窗口需要收缩的条件 */) {
// d 是将要移出窗口的字符
char d = s.charAt(left);
left++;
// TODO: 进行窗口内数据的更新 (如 window.put(d, count-1))
}
// TODO: 根据题意,在扩张或收缩后更新最终结果 (如 res = Math.max/Min(...))
}
}
3.2 场景一:求最长子串(不定长窗口)
题目链接 :LeetCode 3. Longest Substring Without Repeating Characters
题目描述 :给定一个字符串 s ,请你找出其中不含有重复字符的 最长子串 的长度。
思路解析 :
本题的目标是找到满足"无重复"条件的最大窗口。
- 状态定义 :窗口
[left, right)内必须没有重复字符。 - 扩张逻辑 :
right向右移动,将字符加入计数器。 - 违例处理(收缩) :一旦当前字符的计数 > 1,说明窗口内出现了重复。此时必须移动
left,并不断从计数器中移除左侧字符,直到窗口内那个重复字符的计数恢复为 1 为止。 - 结果更新 :在每次窗口恢复合法状态后,计算当前长度
right - left并更新最大值。
优化点 :使用int[128]数组代替 HashMap 统计 ASCII 字符,性能提升巨大(Java 中 Map 操作较重)。
Java 代码:
java
public int lengthOfLongestSubstring(String s) {
// 性能优化:使用数组代替 HashMap 统计 ASCII 字符
int[] window = new int[128];
int left = 0, right = 0;
int maxLen = 0;
while (right < s.length()) {
char c = s.charAt(right);
right++;
// 入窗更新,记录字符出现的次数
window[c]++;
// 收缩条件:如果当前字符 c 的数量 > 1,说明重复
while (window[c] > 1) {
char d = s.charAt(left);
left++;
// 出窗更新
window[d]--;
}
// 3. 此时窗口一定是合法的(无重复),尝试更新最大长度
maxLen = Math.max(maxLen, right - left);
}
return maxLen;
}
3.3 场景二:求最短子数组(不定长窗口)
题目链接 :LeetCode 209. Minimum Size Subarray Sum
题目描述:找出该数组中满足其和 ≥ target 的长度最小的 连续子数组。
思路解析 :
本题与上一题相反,目标是找到满足"和 >= target"条件的最小窗口。
- 扩张逻辑 :
right向右移动,累加数值到sum。 - 达标处理(收缩) :一旦
sum >= target,说明当前窗口合法。为了寻找更短 的解,我们尝试移动left缩小窗口。 - 循环收缩 :这是一个
while循环,因为可能移除一个左侧元素后,剩余窗口的和依然>= target。我们需要不断尝试收缩,直到sum < target为止。 - 结果更新:在每次满足条件时(收缩前),更新最小长度。
Java 代码:
java
public int minSubArrayLen(int target, int[] nums) {
int left = 0, right = 0;
int sum = 0;
// 初始化为最大整数,用于后续比较
int minLen = Integer.MAX_VALUE;
while (right < nums.length) {
// 入窗:累加
sum += nums[right];
right++;
// 收缩条件:只要 sum 达标,就尝试缩小窗口以获取最小长度
while (sum >= target) {
// 更新结果:注意此时 right 已经自增过,当前窗口长度为 right - left
minLen = Math.min(minLen, right - left);
// 出窗:减去左侧元素
sum -= nums[left];
left++;
}
}
return minLen == Integer.MAX_VALUE ? 0 : minLen;
}
3.4 场景三:固定窗口 / 异位词问题
题目链接 :LeetCode 438. Find All Anagrams in a String
题目描述 :给定两个字符串 s 和 p,找到 s 中所有是 p 的异位词的子串,返回这些子串的起始索引。
异位词:指字母相同,但排列不同的字符串。
思路解析 :
这就相当于在一个长字符串 s 中,找一个长度和 p 一样,且字符计数也一样的子串。
- 窗口大小 :此题隐含了一个限制:窗口长度必须固定 ,等于
p.length()。
- 状态定义:需要比较"窗口内的字符分布"与"p 的字符分布"是否一致。
- 扩张逻辑 :
right向右移动,更新窗口内字符计数。 - 固定窗口逻辑 :当
right - left(当前窗口大小)超过p.length()时,必须移动left,保持窗口大小恒定。 - 匹配检查 :当窗口大小达到
p.length()时,检查窗口内的计数数组是否与p的计数数组完全一致。即:维护一个window计数器和一个needs计数器。当window中的字符数量完全匹配needs时,记录结果。
Java 代码:
java
public List<Integer> findAnagrams(String s, String p) {
List<Integer> res = new ArrayList<>();
if (s.length() < p.length()) return res;
// 预处理:统计 p 的字符分布
int[] needs = new int[26];
for (char c : p.toCharArray()) needs[c - 'a']++;
// 定义窗口计数器
int[] window = new int[26];
int left = 0, right = 0;
while (right < s.length()) {
// 入窗
char c = s.charAt(right);
window[c - 'a']++;
right++;
// 收缩:当窗口大小 > p.length() 时,必须收缩,保持窗口固定大小
if (right - left > p.length()) {
char d = s.charAt(left);
window[d - 'a']--;
left++;
}
// 检查是否匹配:Arrays.equals 可以直接比较两个数组的内容
// 在 right - left == p.length() 时检查, 这个判断可以省略。
if (right - left == p.length() && Arrays.equals(window, needs)) {
res.add(left);
}
}
return res;
}
(注:对于极高性能要求的场景,可以引入一个 valid 变量来记录"已匹配字符种类的数量",从而避免每次循环都调用 Arrays.equals,但在面试中,上述写法因逻辑清晰、代码简短而更受青睐。)
收到。这一部分我将剔除"利剑"等修辞,直接从模式识别 、算法效率本质 以及工程实现细节三个维度进行总结,使其更贴近技术面试的评分标准。
以下是修改后的 第四部分:总结与最佳实践。
四、 总结
双指针与滑动窗口是解决线性结构(数组、链表、字符串)问题的核心技巧,其本质是将嵌套循环的暴力枚举优化为单次遍历。
4.1 模式识别:如何选择?
在看到题目时,可以通过以下特征快速定位算法模式:
- 对撞指针 (Two Pointers - Collision)
- 特征 :输入数据有序(或部分有序)。
- 目标:寻找两个数使其满足特定条件(如和为 target),或进行数组/字符串反转。
- 典型题:Two Sum II, 盛水容器, 验证回文串。
- 快慢指针 (Two Pointers - Fast & Slow)
- 特征 :链表操作,或数组原地修改。
- 目标:检测环、找中点、倒数第 K 个节点;原地去重、移除元素。
- 典型题:环形链表, 移除元素, 移动零。
- 滑动窗口 (Sliding Window)
- 特征 :题目明确涉及 "连续子串" (Substring) 或 "连续子数组" (Subarray)。
- 目标 :求满足条件的 "最长 / 最短 / 定长" 子结构。
- 典型题:无重复最长子串, 最小覆盖子串, 异位词查找。
4.2 复杂度分析
- 时间复杂度 :O ( N ) O(N) O(N) 。
虽然滑动窗口代码中包含while(收缩) 嵌套在while(扩张) 内部,但从全局来看,每个元素最多被right指针访问加入窗口一次,被left指针访问移出窗口一次。总操作次数是 2 N 2N 2N,属于线性复杂度。 - 空间复杂度 :O ( 1 ) O(1) O(1) 或 O ( C ) O(C) O(C) 。
通常只需要固定大小的辅助空间(如int[128]数组或常数个变量)来存储窗口状态,与输入规模 N N N 无关。
4.3 实现细节
- 区间定义 :
建议始终维护 左闭右开区间[left, right)。- 初始时
left = right = 0,窗口为空。 - 这样定义的优势在于:窗口的长度始终为
right - left,无需处理+1 / -1的下标偏移,减少边界错误。
- 初始时
- 结果更新时机 :
不同类型的题目,更新结果(Answer Update)的位置不同,需严格区分:- 求最长子串 (如 LC 3):
- 先扩张 (
right++)。 - 再收缩直到窗口合法 (
valid)。 - 收缩结束后 ,此时窗口一定是合法的最长状态,更新
maxLen。
- 先扩张 (
- 求最短子数组 (如 LC 209):
- 先扩张 (
right++)。 - 一旦窗口达标,进入收缩循环。
- 在收缩过程中 ,每次左移前都可能是一个解,因此在收缩循环内部更新
minLen。
- 先扩张 (
- 求最长子串 (如 LC 3):
- 容器选择 :
- 在处理字符串字符统计时(如 ASCII 字符),优先使用
int[]数组 代替HashMap。 - 原因:数组在内存中连续分布,CPU 缓存命中率极高,且省去了哈希计算和对象封装的开销。在 Java 中,这种优化通常能带来运行效率的提升。
- 在处理字符串字符统计时(如 ASCII 字符),优先使用