1. 先聊聊双指针到底是什么
双指针这个技巧,刷 LeetCode 的人基本都见过,但很多朋友对它的理解停留在“左右两边各放一个下标”这种表面印象。遇到具体题目时,要么想不到用它,要么用起来边界条件一堆 bug,调试半天心态爆炸。这篇文章我想从更本质的角度拆一遍双指针——它到底在优化什么、有哪些变体、每个变体对应什么场景,最后配合几道 LeetCode 经典题,把代码和易错点一次讲透。
先给个直观理解。双指针不是某种数据结构,而是一种遍历策略:在数组、链表这类线性结构上,用两个指针(可以是下标、节点引用)协同移动,把原本需要两层循环才能完成的枚举,压缩成一次或有限次的遍历。最典型的效果,是把时间复杂度从 O(n²) 降到 O(n),或者从 O(n³) 降到 O(n²)。空间上几乎不需要额外开销,所以它在算法面试里是性价比极高的优化手段。
适合看这篇文章的人,我默认是这么几类:准备算法面试、正在刷 LeetCode 但双指针题总是卡边界条件的;工作中写业务代码很少碰算法,想系统补一下基础套路的;还有刚学数据结构,想找一个比教科书更好懂的讲解的。我会尽量把“为什么这么做”讲清楚,而不是只丢模板。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从暴力解法推导出双指针:核心是“减少无效枚举”
2.1 暴力解法的痛点在哪里
要理解双指针为什么快,先要看暴力解法慢在哪。假设我们要在一个有序数组里找两个数,使它们的和等于某个目标值 target。最直接的想法是两层循环:外层固定第一个数,内层遍历后面的数,逐个检查相加是否等于 target。代码写出来很简单,但仔细想一下,这里面有大量无效计算。
举个例子,数组是 [1, 2, 3, 4, 5, 6, 7],target 是 10。外层固定 1 的时候,内层要把 2、3、4、5、6、7 全检查一遍;固定 2 的时候,又要把 3、4、5、6、7 检查一遍。但很多检查其实根本没有必要:当固定 1、遍历到 7 时,1 + 7 = 8 已经小于 target,后面已经没有更大的数了,说明固定 1 的情况下无解,可以提前结束。然而暴力循环感知不到这种“有序性带来的剪枝机会”,它只会机械地全部跑完。
在无序数组里,暴力解法还要先排序才能用双指针,但排序本身是 O(n log n),对于 n 较大的场景依然比 O(n²) 的内层枚举划算得多。所以双指针本质上是利用数据的有序性(或某种单调性),把“枚举所有组合”变成“精准地跳过那些不可能成立的组合”。
2.2 双指针如何削减复杂度
双指针的核心逻辑可以这样描述:维护两个指针,一个在左,一个在右,根据当前两个指针指向值的计算结果,决定移动哪个指针。每一次移动,都排除了“以某个元素为基准的一整类情况”。
还是上面的例子。左指针 left 初始指向 1,右指针 right 初始指向 7,相加等于 8,小于 target。因为数组是有序的,右指针已经是最大的元素了,左指针右边就算换成 2、3、4……和 7 相加也只会更大,但现在是 8 < 10,说明要让结果变大,只能让左指针向右移动,即排除掉 1 这个元素。于是 left++,指向 2。2 + 7 = 9,还是小于 10,继续 left++。3 + 7 = 10,命中。
整个过程只遍历了一次数组,指针最多移动 n 步,时间复杂度 O(n)。如果是最坏情况,两个指针相遇也没找到,那也只有 n 步。对比两层循环的 n²,优化幅度是巨大的。看明白这个例子,就抓住了双指针的第一个核心模型:左右对撞指针。
2.3 双指针的四种常见形态
双指针在实战中并不是只有“左右夹逼”这一种玩法。按指针移动方式和用途,我习惯把它分成四类:
| 指针形态 | 典型应用 | 核心特征 | 时间复杂度 |
|---|---|---|---|
| 左右对撞指针 | 两数之和、三数之和、回文判断 | 一左一右向中间移动,通常要求数据有序 | O(n) 或 O(n²) |
| 快慢指针 | 链表环检测、寻找中点、移除元素 | 一个走一步、一个走两步,速度不同 | O(n) |
| 滑动窗口指针 | 最长无重复子串、最小覆盖子串 | 左右指针同向移动,维护一个窗口 | O(n) |
| 归并双指针 | 合并两个有序数组、有序链表合并 | 各指向一个序列,按大小关系推进 | O(n) |
这只是个大框架,实际题目经常是混合使用。比如“字符串中第一个唯一字符”这种题,本质上也能用双指针思路来优化一轮遍历。理解了这四种形态,遇到新题时就可以先从形态入手归类,而不是拿到就懵。
3. 左右对撞指针:原理、模板与边界问题
3.1 模板代码与每一步的含义
左右对撞指针是所有双指针变体里最好理解、也最容易写错的。先给一个标准模板,用 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) {
return new int[]{left + 1, right + 1}; // 题意要求返回下标从1开始
} else if (sum < target) {
left++; // 和太小,需要更大的数,左指针右移
} else {
right--; // 和太大,需要更小的数,右指针左移
}
}
return new int[]{-1, -1};
}
几个需要特别注意的地方。循环条件是 left < right,不是 left <= right。因为 left 和 right 指向同一个元素时,代表只有一个数,而题目要求两个数,这个状态没有意义。如果写成 <=,某些场景会陷入死循环或产生错误答案。另外,当 sum < target 时,为什么是 left++ 而不是 right++?right 已经是最右了,没法再往右走,所以只能动 left;反过来说,数组有序,right-- 会让数值变小,left++ 会让数值变大,这是对撞指针能收敛的核心依据。
3.2 一个从小到大推导的例子
我们用具体例子走一遍流程。数组 [1, 3, 5, 7, 9, 11],target = 10。
- 初始 left = 0, right = 5:1 + 11 = 12 > 10,说明两个数太大了,右指针左移。
- left = 0, right = 4:1 + 9 = 10,命中,返回 [1, 5](按 1 开始计数的下标)。
几乎是一步命中,运气不错。但如果 target 是 14 呢?
- left = 0, right = 5:1 + 11 = 12 < 14,向左已经拉不动了,只能右边已经最大,所以 left++。
- left = 1, right = 5:3 + 11 = 14,命中。
从这个过程能看出来,每次指针移动都基于“有序性”——当前总和小于 target,说明 left 这个元素太小,left 左边的所有元素更小,它们与当前 right 的组合全部无效,直接跳过。这样一来,省掉了大量无效枚举。
3.3 回文判断中的对撞指针
回文判断是对撞指针的另一个经典场景。判断一个字符串是不是回文,可以一头一尾同时往中间遍历,一旦出现字符不相等就提前返回 false。
java复制public boolean isPalindrome(String s) {
int left = 0;
int right = s.length() - 1;
while (left < right) {
if (s.charAt(left) != s.charAt(right)) {
return false;
}
left++;
right--;
}
return true;
}
很多人第一次写这种题会想:直接把字符串反转,再比较两个字符串是否相等不就行了?功能上没错,但反转要额外 O(n) 空间,还需要一次完整拷贝。对撞指针在原地判断,空间 O(1),而且一旦遇到不匹配可以提前退出,不用遍历完整串。在 LeetCode 的字符串题目里,这个思维差异直接决定了你的代码能不能在极大数据量下跑出好性能。
4. 快慢指针与滑动窗口:让指针动起来有节奏
4.1 快慢指针为什么要设计成不同速度
快慢指针最常见的场景是链表环检测,也就是 Floyd 判圈算法。形象地说,两个人在环形跑道上跑步,一个跑得快、一个跑得慢,只要有环,快的迟早会追上慢的,两人再次相遇;如果没有环,快的会先到达终点。
代码实现一点也不复杂:
java复制public boolean hasCycle(ListNode head) {
if (head == null || head.next == null) {
return false;
}
ListNode slow = head;
ListNode fast = head.next;
while (slow != fast) {
if (fast == null || fast.next == null) {
return false;
}
slow = slow.next;
fast = fast.next.next;
}
return true;
}
细节点在于 fast 每次走两步,slow 每次走一步。为什么不是三步四步?因为快慢指针每次差距拉近 1 步(慢走 1、快走 2,相对距离减 1),这样在有环情况下一定能相遇;如果快指针每次走 3 步,慢走 1 步,相对距离每次减少 2,在某些环长度下可能跳过相遇点,导致检测出错误结果。当然,就算用三步,最终大概率也能相遇,但数学证明上,二倍速是最稳定、可证明的。
快慢指针还有别的用处,比如找链表中间节点。慢指针走一步,快指针走两步,快指针到末尾时,慢指针正好在中间。这个技巧在“重排链表”、“回文链表”题目中非常常用。
4.2 滑动窗口:同向双指针的优雅写法
滑动窗口本质上也是双指针,只是两个指针同向移动。窗口的左右边界就是 left 和 right,right 负责扩展窗口,left 负责收缩窗口。它特别适合解决“连续子数组/子串”的最值问题。
完整写出“最长无重复子串”的代码,并解释每一步:
java复制public int lengthOfLongestSubstring(String s) {
// 用哈希集合记录窗口内出现的字符
Set<Character> set = new HashSet<>();
int left = 0;
int maxLen = 0;
for (int right = 0; right < s.length(); right++) {
char ch = s.charAt(right);
// 如果窗口内已经存在该字符,不断收缩左边界
while (set.contains(ch)) {
set.remove(s.charAt(left));
left++;
}
set.add(ch);
maxLen = Math.max(maxLen, right - left + 1);
}
return maxLen;
}
关键逻辑在 while 循环里:每当要加入的字符已经在窗口内,就收缩左边界,直到该字符不再冲突。这保证了窗口内的字符永远不重复。right 指针每走一步,窗口就尝试一次“以 right 结尾的最长无重复子串”,最终取最大值。
这道题如果用暴力法,需要枚举每个子串并检查重复,复杂度 O(n²),而滑动窗口只需要每个字符最多进出窗口一次,复杂度 O(n)。实际刷题时,这类题特别考验对“窗口维护”的理解,比如 LeetCode 第 76 题“最小覆盖子串”,就是在滑动窗口的基础上,额外用一个计数数组记录字符覆盖情况,比单纯的去重复杂一些。
4.3 什么时候该用滑动窗口而不是对撞指针
判断依据其实很清晰:对撞指针适合“两个端点的状态可以互相决定”的问题,比如两端求和、回文判断;滑动窗口适合“连续区间上的约束最值”问题,比如最长、最短、恰好满足某个条件的子数组。如果你发现题目要求的结果是“连续的一段”,优先想滑动窗口;如果题目要求的是“两个元素的组合”,优先想对撞指针。这个分类方法我实测可以帮你快速定位思路。
5. LeetCode 经典例题拆解:三数之和、接雨水与最长回文子串
5.1 三数之和:对撞指针的进阶用法
两数之和会了,三数之和主要是多了一层固定一个数的循环。LeetCode 第 15 题要求找到数组中所有不重复的三元组,使得 a + b + c = 0。如果直接三重循环,时间复杂度 O(n³),并且去重逻辑极其麻烦。
正确的做法是:先排序,然后固定第一个数,再用对撞指针找剩下的两个数。排序在这里有两个作用:一是让对撞指针成立,二是方便去重。
java复制public List<List<Integer>> threeSum(int[] nums) {
List<List<Integer>> res = new ArrayList<>();
Arrays.sort(nums);
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;
int right = n - 1;
while (left < right) {
int sum = nums[i] + nums[left] + nums[right];
if (sum == 0) {
res.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 res;
}
这里最大的坑是去重。很多新手找到一组答案后,直接 left++ 或 right--,但没检查是否与之前的数重复,导致结果里出现重复三元组。正确的做法是找到一个结果后,必须把 left 右边、right 左边所有相同的数都跳过去,再移动指针。另外,外层循环固定第一个数时也要去重,但注意是“跳过连续相同”,不是“跳过所有重复”。因为第一次遇到某个值要使用,第二次遇到相同值才跳过。
时间复杂度的分析:外层循环 O(n),内层对撞指针 O(n),总计 O(n²)。相比暴力 O(n³),n 较大时差距非常明显。这道题是面试高频题,我建议你亲手写三遍以上,重点体会去重逻辑。
5.2 接雨水:对撞指针的思维升级
LeetCode 第 42 题“接雨水”是一道让人又爱又恨的题。给定一个非负整数数组表示柱子的高度,计算下雨后能接多少雨水。暴力解需要针对每个柱子,分别向左找最大值、向右找最大值,然后取较小值减去当前高度,累加。复杂度 O(n²)。
优化思路是用两个数组预处理每个位置的左侧最大值和右侧最大值,复杂度降到 O(n),但空间 O(n)。双指针的解法更进一步,不需要额外数组,在遍历过程中动态维护左右两边已知的最大值。
java复制public int trap(int[] height) {
if (height == null || height.length == 0) {
return 0;
}
int left = 0;
int right = height.length - 1;
int leftMax = 0;
int rightMax = 0;
int res = 0;
while (left < right) {
if (height[left] < height[right]) {
if (height[left] >= leftMax) {
leftMax = height[left];
} else {
res += leftMax - height[left];
}
left++;
} else {
if (height[right] >= rightMax) {
rightMax = height[right];
} else {
res += rightMax - height[right];
}
right--;
}
}
return res;
}
这个解法的精妙之处在于:对于某个位置来说,它能接的水量取决于它左右两侧最大高度的较小值。如果左边当前位置的高度小于右边指针的高度,那左边能接多少水,只由左边的最大高度决定,因为无论右边是什么,至少右边这个高度已经比当前高了,如果左侧最大高度更大,就能形成积水。反之亦然。这个“动态更新边界最大值”的思路,理解起来比暴力解法抽象,但一旦理解,你会觉得代码特别优雅。
5.3 最长回文子串:中心扩展法也是双指针思路
LeetCode 第 5 题“最长回文子串”的经典解法之一是中心扩展法。它本质上也是一种双指针:以某个字符或两个字符之间的空隙为中心,左右两个指针同时向外扩展,直到不能再扩展为止。
java复制public String longestPalindrome(String s) {
if (s == null || s.length() < 1) {
return "";
}
int start = 0, end = 0;
for (int i = 0; i < s.length(); i++) {
int len1 = expandAroundCenter(s, i, i); // 奇数长度回文中心
int len2 = expandAroundCenter(s, i, i + 1); // 偶数长度回文中心
int len = Math.max(len1, len2);
if (len > end - start) {
start = i - (len - 1) / 2;
end = i + len / 2;
}
}
return s.substring(start, end + 1);
}
private int expandAroundCenter(String s, int left, int right) {
while (left >= 0 && right < s.length() && s.charAt(left) == s.charAt(right)) {
left--;
right++;
}
return right - left - 1;
}
这里有两个中心:单个字符作为中心,对应奇数长度回文;两个相邻字符作为中心,对应偶数长度回文。仔细想想,“中心”也是两个指针的起始位置,向外扩展时,两个指针反向移动。找到一个回文后,再用整个字符串中的最长值。
这道题还有动态规划解法,但中心扩展法的实现更直观,空间复杂度 O(1),在很多公司面试中,面试官更喜欢听到这个解法,因为它展现了你对双指针的灵活运用。如果用暴力枚举所有子串再判断回文,复杂度 O(n³),数据量稍大就直接超时。中心扩展法每个中心扩展的复杂度是 O(n),总共有 2n - 1 个中心,所以整体 O(n²)。
5.4 合并两个有序数组:归并双指针的实战
归并双指针常见于“合并两个有序数组”这类题。LeetCode 第 88 题要求把两个有序数组合并到第一个数组中,并且不开辟额外空间。做法是从后往前填充,因为第一个数组尾部是空的,可以从后往前放置较大的元素。
java复制public void merge(int[] nums1, int m, int[] nums2, int n) {
int p1 = m - 1;
int p2 = n - 1;
int cur = m + n - 1;
while (p2 >= 0) {
if (p1 >= 0 && nums1[p1] > nums2[p2]) {
nums1[cur--] = nums1[p1--];
} else {
nums1[cur--] = nums2[p2--];
}
}
}
为什么从后往前?因为 nums1 后面有预留空间,如果从前往后填充,会覆盖掉 nums1 还没处理的元素,造成数据丢失。从后往前时,每次取两个数组当前末尾的较大者,填入 nums1 的末尾,逐步向前推进。当 p2 先变负,说明 nums2 已经全部合并完;如果 p1 先变负,而 p2 还有剩余,就继续把 nums2 剩余部分拷过去。代码里 while (p2 >= 0) 的条件保证了即使 p1 为负,也能继续处理 nums2 的剩余元素。
6. 双指针刷题中的常见 Bug 与排查技巧
6.1 死循环问题:指针不移动或移动方向反了
双指针最常见的 Bug 是死循环。我在初学阶段踩过一个特别经典的坑:在 while 循环内部,没有在找到答案后移动指针。比如三数之和找到一组解后,直接执行 return 或 res.add 却没有 left++、right--,代码会反复进入同一状态。排查方法很简单:在循环体内打印 left、right 和当前 sum,看状态是否一直没变。如果发现 left 和 right 的值从不更新,十有八九就是漏掉了移动逻辑。
还有一种情况是,指针移动方向反了。比如 sum < target 时,本应 left++,结果写成了 right++,数组越界或逻辑错乱立刻出现。方向反了的直接后果是最终 left 会越过 right,但 sum 一直在异常变化。这种问题最好在草稿纸上模拟一遍小规模数据,三分钟就能定位。
6.2 边界条件:left <= right 和 left < right 的选择
循环条件的选择直接决定了 Bug 率。我总结的经验是这样的:
- 如果目标是找两个不同元素,用 left < right,避免同一元素被使用两次。
- 如果目标是判断回文或某种“可接受相等”的场景,用 left <= right 也没错,但要注意中间元素是否会被重复处理。
- 如果目标是找“是否存在”,可以在 left 与 right 相遇后退出,用 left < right 更安全。
比如两数之和与三数之和,必须用 left < right。如果你用 left <= right,可能会出现 left 和 right 指向同一个下标,误把同一个数当成两个数相加的情况,尤其在数组长度为 1 时会直接返回错误结果。而回文判断用 left <= right 也没有实质影响,因为中间字符和自己比较总是相等,只是多了一次无意义比较。用 left < right 更推荐,省一次操作。
6.3 越界访问:指针移动速度与访问顺序
快慢指针和滑动窗口里,越界问题尤其突出。链表场景要特别注意 fast.next 是否为 null,因为 fast 一次跳两步,必须保证 fast 和 fast.next 都不为空,才能继续往后跳。很多超时或空指针异常都从这里来。
数组场景下,滑动窗口里 right 指针的移动要在循环内检查 right 是否已经越界。有些朋友在 while 里直接访问 nums[right],right 一旦走到数组末尾就会越界。规范写法是:在循环条件中判断 right < n,或者先检查再访问。这种小细节,在 LeetCode 上经常是“提交后提示数组越界”的元凶。
另一个越界场景是:对撞指针中,当 left 已经等于 right 时继续访问 nums[left+1],可能越界。所以在需要“跳过重复元素”的三数之和代码里,我在移动指针时额外加了 left < right 的条件,就是为了防止越界。这是一个必须养成的好习惯。
6.4 调试双指针题的三板斧
我个人调试双指针题,遵循一套固定流程,很省时间:
- 先用小规模数组手动模拟。比如 [1,2,3,4,5] 这种长度 5 的数组,把每一步的 left、right 和当前计算结果列出来,确认是否符合预期。
- 加打印语句。在 while 循环内打印 left、right、sum 或窗口内的状态,执行完一个测试用例后,立刻能看出哪个分支走岔了。
- 用极端用例测试。空数组、数组长度为 1、全相同元素、全递增、全递减,这些边界用例能暴露出 90% 的隐藏 Bug。
这个方法实测对新手很有效。有时候在纸上推演一遍,比你盯着屏幕看半天代码要快得多。
7. 从刷题到真正会用:双指针思维的落地心得
7.1 不要太依赖模板,要理解每一步的动机
刷了一段时间 LeetCode 后,我发现一个现象:很多人背了一堆双指针模板,但现场换一道变形题就懵了。原因在于,模板只是结果的骨架,不是推理过程。比如“有效回文”和“两数之和 II”都用了对撞指针,但一个判断字符相等、一个比较数值大小,具体逻辑完全不同。如果你只知道“左加右减”这种口诀,遇到“删除排序数组中的重复项”这种需要保留元素顺序的快慢指针题,就完全对不上号了。
我建议的做法是:每学一个模板,自己动手推导一遍“为什么这个指针这样移动”。推导一遍之后,再找两三道同类型题来验证,这个模板才算真正掌握。
7.2 双指针与其它算法思想的结合
双指针并不是孤立的。滑动窗口经常和哈希表配合,比如记录窗口内字符的频率;快慢指针经常和哈希集合配合检测环;对撞指针经常和排序配合,才有有序性可用。刷题时不要只盯着“这是双指针题”这一个标签,要看你手里的数据结构还能提供什么额外信息。
比如“寻找重复数”这道题,可以用快慢指针,但更快的思路是把它看成链表成环问题;再比如“旋转数组的最小数字”可以用对撞指针思想的双端逼近。当你把双指针当作一种思维工具,而不是题目分类,你的应变能力会明显提升。
7.3 实际工作里双指针用得上吗
很多人觉得算法题跟工作脱节,但双指针这方面的实用性其实不低。处理日志时间区间合并、两个有序数据集合并、字符串模板匹配、数据流中的窗口统计,很多场景都能看到双指针的影子。比如在实现一个“最近 N 秒内的请求计数”时,滑动窗口就是一个非常自然的选择;在合并两个有序的配置列表时,归并双指针也是常见做法。
当然,工作中多数语言的标准库已经帮你封装好了类似功能,但理解底层实现,能让你在选择数据结构和设计方案时更有底气。尤其是性能敏感的模块,一个双指针优化可能就让耗时从几百毫秒降到几毫秒。
7.4 最后分享一个我自己的刷题小技巧
每次做完一道双指针题,我都会在笔记里记录三行:题目给了什么有序性、两个指针各自代表什么含义、指针移动的条件是什么。这三行记下来,复习时扫一眼就能回忆起整道题的思路。如果遇到同类型但思路不同的题,我会把它们放在一起对比,找差异点。这个习惯让我在刷了大概一百道双指针题之后,基本能在一分钟内判断出一道新题该用哪种双指针形态。
刷题不在多,在于把每种形态的“为什么”吃透。把今天讲的四种形态和例题练熟,双指针这关就算真正过了。以后再遇到类似题目,你会发现自己能很自然地走同一条思维路径——从暴力解出发,找到哪里在浪费计算,然后用双指针精准砍掉那些无效枚举。这条路走通一次,后面的路就顺了。
