双指针这个话题,我在面试和实际工程里都见过太多次了。很多人刷题时把它当成“灵光一现”的小技巧,今天能做对一道,明天换道题又卡住。在某个模拟项目里,我用双指针把一段 O(n²) 的数组扫描逻辑改成了 O(n),直接就省掉了一个大循环,那一刻我才意识到:双指针从来不是记题目,而是一套有章可循的降维打法。这篇文章我想把它拆开了讲清楚——三种基本模型、对应的 Java 最优解、每一步背后的为什么,以及我自己踩过的那些坑。不管你是准备面试的求职者,还是日常写业务代码想优化性能的开发者,这篇都值得你从头读完。
1. 双指针到底在解决什么问题
1.1 为什么我建议你系统学一遍双指针
先想一个问题:给你一个已经排好序的数组,找出两个数,让它们的和等于某个目标值。最直觉的做法是什么?两层 for 循环暴力枚举,时间 O(n²),空间 O(1)。数据量小无所谓,数据量一上来,比如一万个数,一亿次比较,放在线上接口里直接卡死。
双指针的核心价值,就是利用“数据本身的结构特征”,把两层循环降成一层。有序数组能告诉我们大小关系,链表的环形结构能告诉我们追击路径,子串的连续性则给我们滑动窗口的机会。这些都是一种“剔除不可能区域”的思维:不是把所有组合都试一遍,而是用一个指针移动一次,排掉一批确定不满足条件的候选。
所以我还是建议每一个 Java 开发者都系统学一遍双指针。它不光是面试题,在很多真实场景里都有用武之地:有序列表合并、日志时间窗口聚合、字符串模板匹配、数据流去重……理解了这套框架,你看到“数组 + 成对查找 + 有序”这个组合时,第一反应就不会是暴力枚举,而是“能不能用两个指针扫一遍”。
1.2 三种模型一张表看清
双指针表面花样多,实际上归结起来就三类,理解了这个分类,刷题就相当于开了地图。
| 模型 | 指针走向 | 典型场景 | 时间复杂度 |
|---|---|---|---|
| 对撞指针 | 左指针从头向右,右指针从尾向左,相向而行 | 有序数组两数之和、回文判断、反转数组 | O(n) |
| 快慢指针 | 一快一慢同向前进,速度不同 | 链表判环、找链表中点、找倒数第k个节点 | O(n) |
| 滑动窗口 | 左右指针同向移动,窗口动态伸缩 | 无重复最长子串、最小覆盖子串、子数组求和 | O(n) |
这三类模型的核心逻辑是一样的:维护两个指针,在每次移动时利用题目约束排除一部分不可能解。区别只在于移动方向和触发条件。
很多人学双指针卡住,就是因为脑子里只有“双指针”三个字,没有模型化。看到有序数组,就想对撞;看到链表,就想快慢;看到连续子串,就想滑动窗口。有了这个映射关系,再去做题,你不会觉得每一道都是新题,只会觉得它们都是同一套框架的变体。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 对撞指针:从有序数组触及 O(n) 的边界
2.1 有序数组的两数之和,为什么双指针就是最优解
这是双指针最经典的入门题,也是我认为最适合拿来建立“为什么”的题。题目描述很简单:一个升序排列的整数数组,找出两个数相加等于 target,返回它们的下标。力扣原题是 167 题,要求下标从 1 开始计数。
我先说结论:这道题的最优解就是左右对撞,时间 O(n),空间 O(1)。想明白它为什么正确,比背下代码重要得多。
假设 left 指向数组最左边,right 指向最右边,这时 sum = numbers[left] + numbers[right]。如果 sum == target,直接返回;如果 sum < target,说明什么?说明右边的数已经拉到最大了(它是数组最大值),整体还是太小,那把 left 往右移动一位,让左边的数变大一点。反过来,如果 sum > target,说明左边的数已经是最小值,整体还是太大,那就把 right 往左移动,让右边的数变小。
每一次移动,排除掉的不是一个元素,而是一整片区间。以 sum < target 为例,固定当前的 right,left 左边所有的数跟这个 right 组队都不可能满足条件,因为左边更小。所以直接左移 left 就够了。这就是对撞指针高效的根本原因。
Java 实现也极其简洁:
java复制public int[] twoSum(int[] numbers, int target) {
int left = 0, 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};
}
这段代码为什么说它是最优解?因为你不可能找到比 O(n) 更快的算法了——任何解法至少得看一眼每个元素,而只需要扫一遍就能出结果,已经是下界。
还有一点值得注意:这道题的前提是“数组有序”。如果没有这个前提,要想快只能先排序,但排序又丢了原下标位置,所以只能用哈希表做 O(n) 时间、O(n) 空间的解法。这恰好说明了一个道理:双指针不是万能的,它的前提是数据有结构可依。
2.2 三数之和的进阶:去重细节才是真正的分水岭
两数之和讲完,我们升一级:找出数组中所有三数之和等于 0 的三元组,要求不重复。这道题就是力扣 15 题,面试中出现频率极高,而且大概率不是让你写个暴力解,而是考察你能否在排序后用双指针优雅地完成。
核心思路是:先排序,然后固定一个数 nums[i],问题就退化成了“在 i 后面的区间里找两数之和等于 -nums[i]”——这正是我们刚才讲的对撞指针。外层固定 + 内层对撞,总复杂度 O(n²),空间 O(1)(不计答案空间)。
代码本身不难,真正难的是去重。我见过太多人的解法逻辑对,但输出结果包含重复三元组。去重有两个关键位置:
第一,外层 i 的去重。如果 nums[i] == nums[i-1],那以 nums[i] 为第一个数的所有组合,已经在上一轮枚举完了。注意比较对象是 i-1 而不是 i+1,这个细节很多人写反。
第二,找到一组有效答案后,left 和 right 也要跳过所有重复值,继续收缩区间。
java复制public List<List<Integer>> threeSum(int[] nums) {
Arrays.sort(nums);
List<List<Integer>> ans = new ArrayList<>();
int n = nums.length;
for (int i = 0; i < n - 2; i++) {
if (i > 0 && nums[i] == nums[i - 1]) continue;
int left = i + 1, right = n - 1;
int target = -nums[i];
while (left < right) {
int sum = nums[left] + nums[right];
if (sum == target) {
ans.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 ans;
}
这段代码里面还有一个隐藏优化点:当 nums[i] > 0 时,可以直接 break,因为后面的数全比 nums[i] 大,不可能凑出和为 0 的三元组。这个小剪枝在数据量大时提升很明显。
你可能会问:为什么排序后,用 O(n²) 的双指针比用哈希表更合适?因为哈希表处理去重非常麻烦,你得额外维护一个 Set<String> 或者拼接字符串做判重,空间开销也上去了。双指针排序方案在“可读性”和“空间复杂度”上是双双占优的,这才是面试官想看到的“最优解”。
2.3 对撞指针的其他用武之地
对撞指针远不止求和这一种玩法。我再举几个常见的应用场景,你就会发现它的本质是“利用有序性做区间排除”。
判断回文串就是最典型的例子。一个指针从头往后,一个从尾往前,逐个比较字符是否相等。Java 里的 StringBuilder.reverse() 固然能实现,但那是额外建了一个字符串副本,空间 O(n)。用双指针直接在原字符串上操作,空间一下变 O(1)。代码就不用贴了,逻辑就是两种:
java复制while (left < right) {
if (s.charAt(left) != s.charAt(right)) return false;
left++;
right--;
}
return true;
反转数组、反转字符串也是一样的套路。再复杂一点,力扣 11 题“盛最多水的容器”也是对撞指针:面积 = 底边宽 × 较矮的板的高度,每次移动高度较小的那一侧,因为固定矮板时移动高板只会让面积更小,移动矮板才有变大的可能。这道题能想明白,就说明你对对撞指针的“排除逻辑”已经真正入门了。
3. 快慢指针:链表环与中心定位的艺术
3.1 Floyd 判圈:为什么一定会相遇
说完了数组,我们换到链表。链表的麻烦在于它没有下标,你不能像数组那样直接跳到中间,只能靠 next 指针一个个走。这时候快慢指针就派上大用场了。
最经典的问题:判断一个链表有没有环。有环意味着什么?你沿着 next 永远走不到 null。如果用暴力解法,你得额外开一个 HashSet 记录访问过的节点,时间 O(n) 空间 O(n)。但快慢指针可以把空间降到 O(1):让一个慢指针每次走一步,快指针每次走两步。如果链表有环,它们一定会在环里相遇;如果没环,快指针会先走到 null。
问题来了:为什么一定能相遇?这个数学直觉必须建立起来。慢指针进环后,快指针已经先进去了。假设环的长度是 L,慢指针进环时,快指针在它前面某个位置,两者的“距离差”一定是一个 0 到 L-1 之间的数。快指针每次比慢指针多走一步,也就是两者的距离每次缩短 1。走几步之后,距离差变成 0,也就是相遇了。所以一定会相遇,而且最坏的情况是走 L 步。
Java 实现里有两个细节需要注意。一个是边界判断,另一个是快指针初始位置。我个人习惯让 slow 和 fast 都从 head 出发,然后先判断边界再走:
java复制public boolean hasCycle(ListNode head) {
ListNode slow = head, fast = head;
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
if (slow == fast) return true;
}
return false;
}
有环时快慢指针最终会指向同一个节点,你可以直接比较引用相等。这个代码看着简单,但你必须清楚为什么 fast.next 不能省:如果 fast.next 已经是 null,说明链表已经到终点,肯定无环,此时再取 fast.next.next 就会空指针异常。
延伸一步,如果要找环的入口节点,也就是力扣 142 题,就需要用到快慢指针的相遇点与入口点的数学关系。已知相遇后,把一个指针放回头节点,然后两个指针每次都走一步,再次相遇的位置就是环入口。这背后的推导涉及一步到位的距离关系,面试常问,建议自己画个图推一遍,比死记结论有用得多。
3.2 找链表中点和倒数第 K 个节点的统一解法
链表相关的快慢指针还有一个高频用法:找中间节点。
常规思路是:第一遍遍历数长度,第二遍走一半。时间复杂度 O(n) 没问题,但要遍历两遍。快慢指针可以一遍搞定:slow 每次走一步,fast 每次走两步,当 fast 到达链表末尾时,slow 正好在链表的中间。
java复制public ListNode middleNode(ListNode head) {
ListNode slow = head, fast = head;
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
}
return slow;
}
这里有个小细节值得说一下:快慢指针同时从 head 出发,如果链表长度是偶数,到底返回的是左边的中间节点还是右边的中间节点?这个代码返回的是右边那个(偏后的)。如果你要做二分查找或者归并排序的切分,可能需要返回左边那个,调整方法就是让 fast 从 head.next 出发,或者把 while 条件改成 fast.next != null && fast.next.next != null。每次用的时候想清楚你要哪个,不要无脑抄。
倒数第 K 个节点也可以用“快慢指针拉开距离”的思路。让 fast 先走 K 步,然后 slow 和 fast 同步走,当 fast 走到 null 时,slow 指向的就是倒数第 K 个。这就是一个“让两个指针保持固定间距”的套路,本质上和找中点、判环是同一套思维——用速度差异或者位置偏移,让两个指针建立某种对应关系。
这种思路放到工程场景里也常见。比如我要处理一个无限长的日志流,找最近 K 条记录里的某种中间状态,又不想用 O(K) 的额外存储,就可以考虑类似的“定距指针”方案。虽然大多数时候业务代码用集合就能解决,但遇到大数据量、内存受限的场景,这种一手技巧能救命。
4. 滑动窗口:把 O(n²) 压成 O(n) 的标准套路
4.1 无重复字符最长子串的窗口维护
如果说对撞指针处理“有序数组”,快慢指针处理“链表”,那滑动窗口就是处理“连续子串、子数组”的万能模板。我甚至觉得,它才是双指针家族里最工程化、最实用的一种。
先拿力扣 3 题说:给定一个字符串,找不含重复字符的最长子串长度。暴力解法是枚举所有子串,然后用 HashSet 检查有没有重复。数量级是 O(n³),性能差得离谱。
滑动窗口的思路是:维护 [left, right] 这个窗口,保证窗口内没有重复字符。right 不断向右扩展,一旦发现某个字符已经出现在窗口里,就把 left 右移收缩窗口,直到该字符不再重复。
这里有一个实现层面的关键选择:用什么数据结构记录窗口里的字符?很多人第一反应是 HashMap<Character, Integer> 或者 HashSet。但既然是字符,而且范围一般就是 ASCII 码 0~127,最省的做法是直接用 int[128] 数组记录每个字符最近一次出现的下标。数组访问比哈希计算快得多,尤其在数据量大时会有肉眼可见的差距。
java复制public int lengthOfLongestSubstring(String s) {
int[] lastIndex = new int[128];
Arrays.fill(lastIndex, -1);
int left = 0, maxLen = 0;
for (int right = 0; right < s.length(); right++) {
char c = s.charAt(right);
if (lastIndex[c] >= left) {
// c 在窗口内重复出现,left 直接跳到上一次出现位置的下一位
left = lastIndex[c] + 1;
}
lastIndex[c] = right;
maxLen = Math.max(maxLen, right - left + 1);
}
return maxLen;
}
这个解法的关键就是这个 if 判断:如果 lastIndex[c] >= left,说明这个字符上一次出现的位置在窗口内,那就重复了,需要把 left 拉到重复位置的下一位。否则的话,这个字符虽然出现过,但在窗口外,不影响当前窗口合法性。这个细节是很多人写错的根源:不是只要出现过就收缩窗口,而是“在窗口内出现过”才收缩。
时间复杂度 O(n),right 和 left 都只往一个方向走,不会回退。这是滑动窗口“最优解”的底气所在。
4.2 窗口收缩时机:以最小覆盖子串为例
无重复字符子串是“遇到重复就收缩”,逻辑比较简单。真正体现滑动窗口功力的题是“最小覆盖子串”:给定字符串 s 和 t,在 s 中找到包含 t 所有字符的最短连续子串。这是力扣 76 题,是一道 hard 题,但用滑动窗口模板写起来其实非常规整。
核心思路:right 不断扩展,维护一个“窗口内已覆盖 t 中多少个字符”的计数 count;当 count 等于 t 的长度时,说明窗口已经包含了 t 的所有字符,这时尝试收缩 left,直到窗口刚好不满足条件为止。每次满足条件时记录当前窗口的位置和长度。
java复制public String minWindow(String s, String t) {
if (s.length() < t.length()) return "";
int[] need = new int[128];
int[] have = new int[128];
for (char c : t.toCharArray()) need[c]++;
int left = 0, count = 0;
int minStart = 0, minLen = Integer.MAX_VALUE;
for (int right = 0; right < s.length(); right++) {
char rc = s.charAt(right);
if (need[rc] > 0) {
have[rc]++;
if (have[rc] <= need[rc]) count++;
}
while (count == t.length()) {
if (right - left + 1 < minLen) {
minLen = right - left + 1;
minStart = left;
}
char lc = s.charAt(left);
if (need[lc] > 0) {
have[lc]--;
if (have[lc] < need[lc]) count--;
}
left++;
}
}
return minLen == Integer.MAX_VALUE ? "" : s.substring(minStart, minStart + minLen);
}
这个模板非常经典,务必要理解而不是背。很多变题,比如“字符串的排列”“找到字符串中所有字母异位词”,都是在它基础上改:有的把“最短”改成“固定长度”,有的把“覆盖”改成“字符频次完全一致”,但窗口维护的基本动作——右扩、统计、左缩、更新答案——是完全一样的。
这里再分享一个我自己的心得:滑动窗口的 bug,百分之八十出在窗口内数据状态的维护上。你扩展 right 的时候更新了计数,收缩 left 的时候忘记更新,或者更新顺序错了,结果就全乱了。建议每道滑动窗口题都写完之后,拿一个短例子手动走一遍,确认每个字符进入窗口、离开窗口时,状态都同步更新了。
5. 实战排坑:双指针最容易踩的五个坑
5.1 死循环:指针没有前进
这是最隐蔽的坑,没有之一。任何双指针写法,都必须保证每次循环至少有一个指针在移动。如果在满足某个分支时忘了 left++ 或者 right--,程序就会死循环。我见过有人把对撞指针的 left++ 写在 else 里,结果遇到相等分支时就永远停在原地。
java复制// 错误示范:sum == target 时没有移动指针
while (left < right) {
int sum = nums[left] + nums[right];
if (sum < target) left++;
else if (sum > target) right--;
else {
// 这里不加 left++ 和 right-- 就是死循环
result.add(...);
}
}
写完之后扫一眼每个分支有没有指针移动,这是一个成本极低但回报极高的习惯。
5.2 边界比较符号:while (left < right) 还是 while (left <= right)
这两个符号使用频率极高,选错了结果就差很远。我的经验是:如果每次循环结束时你都必然让 left 和 right 不再指向同一个待检查位置,用 <;如果中间位置本身也可能是一个有效答案,需要在循环内被检查,那用 <=。
拿对撞指针的两数之和来说,两个数不可能是同一个元素(前提:题目默认不能用同一个元素),那 left < right 就是对的,因为 left 和 right 指向同一个位置时说明没有剩余元素了。但如果题目改成长度为 1 的正方形最大边长,孤立的中间元素可能正好就是答案,那就要用 <=。
链表快慢指针也同理,while (fast != null && fast.next != null) 和 while (slow != fast) 的选择直接决定了代码怎么写才安全。建议做题前先想清楚:循环终止时,指针到底应该停在哪个位置。
5.3 空指针和越界
链表题十有八九的空指针问题都出在“快指针走两步”上。写 fast = fast.next.next 之前,必须保证 fast != null 且 fast.next != null,否则就是灾难。数组题则是下标越界:当 left + 1 或者 right - 1 参与访问时,要确认当前位置不是边界。
我养成的习惯是:写链表时,第一步先把 head == null || head.next == null 这种边界提前拦掉;写数组时,循环条件里就把范围锁死。宁可多写一个 if,也不要让代码有机会碰 null 或者越界。
5.4 窗口内数据的维护顺序
滑动窗口的坑前面提过,这里再展开讲透。假设你要维护窗口内字符出现的频次,right 向右扩展时,新字符的频次必须立即加进统计;left 收缩时,离开窗口的字符频次必须立即减掉;然后才能基于新的统计结果去判断是否满足条件。
顺序错了会出现一种诡异现象:有些用例能过,有些用例答案差 1。为什么?因为你在判断窗口有效性时使用的状态是旧的——明明窗口已经收缩了,但统计里还残留着已经不在窗口里的字符信息。
分享一个自查模板:每道滑动窗口题,都盯着 left 移动的那几行代码,问自己一个问题——此刻窗口内还有这个字符吗?没有的话为什么统计里还有?
5.5 复杂度分析迷思:O(n²) 的双指针并不是最优
提到双指针就默认 O(n) 是误区。比如对字符串做双指针遍历,但是每次移动指针后都对窗口内数据做一次“全量重算”,那整体复杂度仍然是 O(n²),只不过形式上是两个指针在走。
典型的反例:最短无序连续子数组那道题,有人写双指针,但每次 right 移动后都对窗口内元素重新 min/max 扫描一遍,结果最坏情况还是 O(n²)。真正的 O(n) 解法需要你利用前缀最大值、后缀最小值这些信息,双指针只是最后“收缩边界”那一步的工具。
所以说,写代码之前先估算整体的操作次数。双指针本身不保证 O(n),它只是提供一种“单次遍历内解决问题”的可能性;你没有做额外的整体重扫描,才真的做到了 O(n)。
6. 写在最后的时间复杂度理论和选择思路
关于双指针,还有一个必须彻底搞明白的点:为什么这些技巧能大规模替代暴力枚举?本质上,暴力解法重复处理了许多“已经被排除的候选区间”,而双指针通过一次移动直接跨过这些区间。面试或者项目优化时,你不需要背下每道题的代码,而是要在看到问题时快速识别出“数据是否有序、是否有环、是否连续”,然后选择对应的指针模型。
我个人刷题和写工程的体会是:三指针或者多指针,其实都是从这三种模型演化的。比如三数之和,本质是“外层固定 + 内层对撞”;荷兰国旗问题是“三指针分区间”,你可以把它理解为两个方向的快排分区;还有一些题目是“双指针 + 哈希”混用,那说明题目里有一部分结构信息不满足指针移动的条件,只能用哈希表补上。模型之间组合使用,并不冲突。
面对一道数组题时,我的第一反应顺序是:能不能排序?排序之后能不能用对撞?如果不能排序,可不可以维护一个窗口?这个窗口里的数据是不是连续区间?如果问题发生在链表上,那优先想快慢指针有没有办法一步到位。这个思考框架,基本覆盖了我处理过的八成相关题目。
如果你刚开始学,我的建议是不要急着刷难题。先把 167、141、3、76 这几道基础题吃透,每道题都手动模拟一遍过程,搞清楚指针每个时刻的位置和理由。然后做变体题,比如两数之和 -> 三数之和 -> 四数之和,无重复字符子串 -> 最小覆盖子串,环检测 -> 环入口。每做一步,记录一下“这个改动是因为题目哪个条件变化而导致的”,你会发现自己对双指针的理解会瞬间通透——因为你的关注点从“背代码”变成了“理解约束”。这条路走完,再回头看你写过的暴力枚举代码,你会清楚地看到当初浪费在哪一步上。
