双指针技巧 (Two Pointers)
1. 双指针核心概览
双指针并不是一种具体的“算法”(如排序或 DFS),而是一种编程技巧。它的核心思想是:在遍历数组、字符串或链表时,不再使用单一的变量 i 去循环,而是同时维护两个指向不同位置的索引(指针),通过它们之间的协同移动来完成特定的搜索、比较或原地修改操作。
1.1 双指针的四大黄金应用模型
掌握双指针,本质上是掌握它在不同场景下的移动轨迹。绝大多数双指针问题都可以归类为以下四种模型:
| 模型分类 | 指针初始位置 | 移动轨迹与特征 | 典型应用场景 |
|---|---|---|---|
| 对撞指针 (左右指针) | 一左一右 (left=0, right=len-1) | 两人相向而行,直到相遇 (left >= right) 停止。 | 反转字符串、回文串判定、有序数组求和/二分查找。 |
| 快慢指针 | 同一起点 (fast=0, slow=0) | 两人同向而行,快指针探路,慢指针在后方记录或修改数据。 | 数组原地去重、移除元素、链表找中点/判断环。 |
| 滑动窗口 | 同一起点 (left=0, right=0) | 同向而行。右指针主动扩张窗口,左指针在满足条件时收缩窗口。 | 求满足特定条件的连续子数组/子串(最长/最短)。 |
| 双数组指针 | 分别在两个数组的起点 | 根据比较结果,各自独立移动。 | 合并两个有序数组、求两个数组的交集。 |
2. 经典双指针模型与 JavaScript 实现
2.1 对撞指针:两数之和 II (有序数组)
题目描述: 给你一个下标从 1 开始的整数数组 numbers,该数组已按非递减顺序排列。请你从数组中找出满足相加之和等于目标数 target 的两个数。 (如果是无序数组,应该用哈希表;但既然是有序的,双指针是空间复杂度 $O(1)$ 的最优解)。
核心逻辑: 既然数组是有序的,左指针指向最小的数,右指针指向最大的数。 如果两数之和大了,说明右边的数太大了,右指针左移(变小);如果和太小,左指针右移(变大)。
JavaScript 代码实现
js
/**
* 两数之和 II - 对撞指针
* @param {number[]} numbers (已排序)
* @param {number} target
* @return {number[]}
*/
function twoSum(numbers, target) {
let left = 0;
let right = numbers.length - 1;
while (left < right) { // 不能等于,因为要找的是两个不同的数
const sum = numbers[left] + numbers[right];
if (sum === target) {
// 题目要求下标从 1 开始,所以加 1
return [left + 1, right + 1];
} else if (sum < target) {
left++; // 和太小,左边的指针往右走(找更大的数)
} else {
right--; // 和太大,右边的指针往左走(找更小的数)
}
}
return [];
}2.2 快慢指针:原地移除元素 (Remove Element)
题目描述: 给你一个数组 nums 和一个值 val,你需要 原地 移除所有数值等于 val 的元素,并返回移除后数组的新长度。不能使用额外的数组空间。
核心逻辑: fast 指针负责在前面“探路”扫描整个数组,slow 指针负责在后面“驻扎”,只记录那些不等于 val 的有效元素。
JavaScript 代码实现
js
/**
* 原地移除元素 - 快慢指针
* @param {number[]} nums
* @param {number} val
* @return {number} 新数组的长度
*/
function removeElement(nums, val) {
let slow = 0; // 慢指针:指向下一个即将被合法元素覆盖的位置
// 快指针:负责遍历所有元素
for (let fast = 0; fast < nums.length; fast++) {
// 只有当快指针找到“有效元素”时,才把它交接给慢指针
if (nums[fast] !== val) {
nums[slow] = nums[fast]; // 覆盖
slow++; // 慢指针前进一步
}
// 如果 nums[fast] === val,快指针直接跳过,慢指针原地待命
}
// 最终 slow 的值恰好就是有效元素的个数
return slow;
}3. 常见问题深度剖析 (FAQ)
| 常见疑惑 | 深度解析与应对策略 |
|---|---|
| 1. 怎么判断一道题到底是用“对撞指针”还是“同向指针”? | 🔹 对撞(左右)指针的暗号: 题目中有**“有序数组”(如两数之和)、“反转/回文”(从两头向中间靠拢)、“面积/盛水”(如接雨水题)。 🔹 同向(快慢/滑动窗口)的暗号: 题目要求“原地修改/去重”、求“连续的子数组/子串”**。 |
| 2. 双指针的时间复杂度真的是 $O(n)$ 吗? | 通常是的,但要小心隐性循环。 比如对撞指针,左右指针总共把数组走了一遍,绝对是 $O(n)$。再看上面的“滑动窗口”代码,里面嵌套了一个 while 循环,看起来像 $O(n^2)$。错! 虽然有两层循环,但不管是 right 还是 left,它们都只前进,绝不后退(即不走回头路)。所以每个字符最多被 right 访问一次,被 left 踢出一次,总操作次数最多是 $2n$,去掉常数后依然是严格的 $O(n)$。 |
| 3. JavaScript 中字符串是不可变的 (Immutable),怎么用双指针进行“原地反转”? | 这是 JS 新手极易踩坑的地方。在 C++ 或 Java 中,你可以直接操作 str[left] = str[right]。但在 JS 中,字符串的字符是只读的,无法直接原地修改。解决方案: 必须先将字符串打散成数组 let arr = s.split('');,在数组上用双指针完成交换操作后,再重新拼接 return arr.join('');。虽然这消耗了 $O(n)$ 的空间,但在 JS 中这是唯一标准的做法。 |
| 4. 双指针和链表结合时要注意什么? | 在单向链表中,不可能使用对撞(左右)指针,因为链表不能从后往前遍历。链表双指针永远是快慢指针。最经典的应用是:找链表的中点(快指针走两步,慢指针走一步,快指针到底时慢指针正好在中点)、判断链表是否有环(龟兔赛跑,如果快慢指针相遇,说明有环)。 |