最近开始系统练算法,第一个专题是双指针。
看完定义感觉懂了,但遇到新题还是会卡——不知道该用哪种,也不确定自己的直觉对不对。后来意识到,问题不在于"记住了几道题",而是没有建立识别机制。这篇文章是我当前阶段的学习笔记,核心目的是搭一个框架,让后续每道新题都能对号入座,持续积累。
一、为什么"双指针"这个名字会让人困惑
"双指针"听起来像一个具体技巧,但实际上它是三种思路不同的模式的统称:
| 模式 | 方向 | 核心意图 |
|---|---|---|
| 快慢指针 | 同向 | 一个探索,一个确认 |
| 对撞指针 | 相向 | 从两端压缩可能性 |
| 分离双指针 | 各自独立 | 两个数组步调协同 |
它们唯一的共同点是:同时维护两个位置状态,避免嵌套循环。
但识别信号、使用场景完全不同。把它们混在一起理解,是我早期困惑的根源。
二、三种模式的认知框架
模式一:快慢指针(同向)
意象: 两个人走同一条路,一个走得快(负责探路),一个走得慢(负责记录)。快指针扫描每一个值,慢指针只在"值得写下来"的时候才往前走一步。
识别信号:
- 一个数组,原地修改
- 过滤掉某些元素(重复值、指定值)
- 链表找中点 / 找环(走1步 vs 走2步)
真实场景: 前端虚拟列表(Virtual List)。渲染 10 万条数据时不能真的创建 10 万个 DOM 节点,实际只渲染可视区域内的若干条。滚动时 writer 决定往哪个位置写入新节点,reader 决定从数据源读哪条记录——本质上就是快慢指针的职责模型。
代码范式:
// 场景:原地过滤 / 去重
// 核心问题:不能开新数组,必须在原数组上"写入有效值"
//
// 两个指针的职责(用语义更清晰的名字来理解):
// reader(即 fast)→ 负责"读":每轮都走,扫描所有元素,判断要不要保留
// writer(即 slow)→ 负责"写":只在"值得保留"时才往前走一步,标记下一个写入位
//
// 题目里习惯叫 slow/fast,但在数组过滤场景下,
// "快慢"容易误导——真正的区别是职责,不是速度。
// slow/fast 这两个名字来自链表场景(走1步 vs 走2步),
// 移植到数组后保留了叫法,但核心是 writer/reader 的分工。
//
// 不变式:writer 左边(含 writer-1)永远是"已处理的干净区域"
// writer 右边是"还没处理的脏区域"
let writer = 0; // 即 slow:下一个有效值的写入位
for (let reader = 0; reader < arr.length; reader++) { // 即 fast
if (/* ← 只有这一行是变量,不同题目换不同条件
去重:arr[reader] !== arr[writer - 1]
移除指定值:arr[reader] !== val
其他过滤:换成对应判断 */) {
arr[writer] = arr[reader]; // 把 reader 探到的有效值,写入 writer 的位置
writer++; // writer 向前推进,干净区扩张一格
}
// 条件不满足时:reader 继续走,writer 原地等待
// 效果:被跳过的元素会在后续被 reader 带来的有效值覆盖
}
return writer; // writer 的值 = 有效元素的个数
reader 每轮都走,writer 只在满足条件时才走。这个"职责分离"是这个模式的核心,slow/fast 只是它在算法题里的惯用名。
整个范式只有 if 里的判断条件是变量,骨架固定。后续遇到同类题,只需要想清楚"什么条件算值得保留",套进去就能用。
例题:LeetCode 26 — 删除有序数组中的重复项
给你一个升序数组 nums,请原地删除重复出现的元素,返回删除后数组的新长度。
输入:nums = [1, 1, 2]
输出:2,nums 变为 [1, 2, _]
输入:nums = [0,0,1,1,1,2,2,3,3,4]
输出:5,nums 变为 [0, 1, 2, 3, 4, _, _, _, _, _]
为什么是快慢指针? 一个数组 + 原地修改 + 过滤重复值,三个信号同时命中。
思路:
slow指向"当前已处理区域的最后一位"fast扫描前方,遇到和slow不同的值,就写入slow+1
/**
* 环境:浏览器 / Node.js
* LeetCode 26 - Remove Duplicates from Sorted Array
* @param {number[]} nums
* @return {number}
*/
function removeDuplicates(nums) {
if (nums.length === 0) return 0;
let slow = 0;
for (let fast = 1; fast < nums.length; fast++) {
// fast 探索到一个"新值"
if (nums[fast] !== nums[slow]) {
slow++;
nums[slow] = nums[fast]; // 写入 slow 的下一位
}
// 否则 fast 继续走,slow 原地等待
}
return slow + 1; // 有效长度 = 最后一个 slow 的下标 + 1
}
console.log(removeDuplicates([1, 1, 2])); // 2
console.log(removeDuplicates([0,0,1,1,1,2,2,3,3,4])); // 5
执行过程拆解(以 [1,1,2] 为例):
初始:slow=0, fast=1
[1, 1, 2]
^ ^
s f
第1轮:nums[1]===nums[0],重复,fast 继续走,slow 不动
slow=0, fast=2
[1, 1, 2]
^ ^
s f
第2轮:nums[2]!==nums[0],新值!slow++,写入
slow=1, nums[1]=2
[1, 2, 2]
^
s
返回 slow+1 = 2
模式二:对撞指针(相向)
意象: 两个人分别站在数组两端,同时向中间走。每一步都在缩小"还需要搜索的范围",直到相遇。
识别信号:
- 有序数组
- 找满足条件的两个数(之和、之差)
- 回文验证
- 容量 / 面积类问题
真实场景: 表单输入的回文校验。身份证号、对称验证码等场景,用对撞指针从两端向中间逐字符比对,比转成数组再 reverse 对比性能更好,也不需要额外空间。
代码范式:
// 场景:有序数组,找满足条件的两个位置
let left = 0;
let right = arr.length - 1;
while (left < right) {
const result = compute(arr[left], arr[right]);
if (result === target) {
// 找到答案
break;
} else if (result < target) {
left++; // 需要更大的值,左指针右移
} else {
right--; // 需要更小的值,右指针左移
}
}
对撞指针之所以能工作,依赖一个前提:数组有序。有序才能保证"左移/右移"的方向是有意义的。
例题:LeetCode 167 — 两数之和 II(输入有序数组)
给你一个下标从 1 开始的升序数组 numbers,找出两个数使它们之和等于目标数 target,返回它们的下标。
输入:numbers = [2, 7, 11, 15], target = 9
输出:[1, 2](numbers[1] + numbers[2] = 2 + 7 = 9)
输入:numbers = [2, 3, 4], target = 6
输出:[1, 3]
为什么是对撞指针? 有序数组 + 找两个数之和 = 对撞指针的教科书场景。
思路:
- 左右各一个指针,计算当前两数之和
- 和太小 → 左指针右移(换更大的数)
- 和太大 → 右指针左移(换更小的数)
- 利用有序性,每次移动都是有效的排除
/**
* 环境:浏览器 / Node.js
* LeetCode 167 - Two Sum 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) {
return [left + 1, right + 1]; // 题目要求下标从 1 开始
} else if (sum < target) {
left++; // 和太小,换更大的左值
} else {
right--; // 和太大,换更小的右值
}
}
return []; // 题目保证有解,理论上不会到这里
}
console.log(twoSum([2, 7, 11, 15], 9)); // [1, 2]
console.log(twoSum([2, 3, 4], 6)); // [1, 3]
执行过程拆解(以 [2,7,11,15], target=9 为例):
初始:left=0, right=3
[2, 7, 11, 15]
^ ^
l r
第1轮:sum = 2+15 = 17 > 9,right--
left=0, right=2
[2, 7, 11, 15]
^ ^
l r
第2轮:sum = 2+11 = 13 > 9,right--
left=0, right=1
[2, 7, 11, 15]
^ ^
l r
第3轮:sum = 2+7 = 9 === target,返回 [1, 2]
例题:LeetCode 125 — 验证回文串
给定一个字符串 s,只考虑字母和数字,忽略大小写,判断它是否是回文串。
输入:s = "A man, a plan, a canal: Panama"
输出:true(忽略非字母数字后为 "amanaplanacanalpanama")
输入:s = "race a car"
输出:false
为什么是对撞指针? 回文验证的本质是"两端字符是否对称",天然是从两端向中间逼近的结构。
思路:
- 预处理:转小写,过滤非字母数字字符
left从头,right从尾,逐一比对- 有不同则直接返回
false,全部匹配返回true
/**
* 环境:浏览器 / Node.js
* LeetCode 125 - Valid Palindrome
* @param {string} s
* @return {boolean}
*/
function isPalindrome(s) {
// 预处理:转小写 + 只保留字母和数字
const cleaned = s.toLowerCase().replace(/[^a-z0-9]/g, '');
let left = 0;
let right = cleaned.length - 1;
while (left < right) {
if (cleaned[left] !== cleaned[right]) return false;
left++;
right--;
}
return true;
}
console.log(isPalindrome("A man, a plan, a canal: Panama")); // true
console.log(isPalindrome("race a car")); // false
console.log(isPalindrome(" ")); // true(空串视为回文)
实战注意事项
① 边界条件陷阱:while 条件永远写 left < right
初学时容易想当然写成:
偶数长度字符串会失效。比如 "abba",length / 2 = 2,left 到 1、right 到 2 时条件就不满足了,但实际还没比完中间两个字符。
而正确写法只需要一个条件:
// 错误写法
while (left < s.length / 2 && right > s.length / 2) { ... }
// 正确写法
while (left < right) { ... }
left < right 的语义是"两个指针还没相遇",天然覆盖奇数和偶数长度,不需要额外处理。
② 字符串预处理的正则备忘:/[^a-z0-9]/g vs \W
两种写法看起来都能过滤"非字母数字",但有一个隐藏的区别:
// 写法 A:明确指定保留范围
s.replace(/[^a-z0-9]/g, '')
// 写法 B:用 \W 过滤"非单词字符"
s.replace(/\W/g, '')
区别在于下划线 _ :
\W等价于[^a-zA-Z0-9_],它不过滤下划线,因为\w的定义包含了_/[^a-z0-9]/g会过滤下划线,因为_不在a-z和0-9的范围内
LC 125 的测试用例包含 "_" 这类边缘输入,用 \W 会导致下划线被保留进入比对逻辑,结果出错。
结论:回文校验用 /[^a-z0-9]/g,不要用 \W。
模式三:分离双指针(两个数组)
意象: 两个人分别走在两条平行轨道上,每次比较"谁走得更慢",慢的那个往前走一步。两条轨道同时走完,任务结束。
识别信号:
- 两个有序数组
- 合并、求交集、求并集
真实场景: git diff 的底层逻辑。比对两个文件版本时,两个指针分别扫描旧版本和新版本的行,逐行比较决定是"保留"、"新增"还是"删除"——正是分离双指针的协同推进模型。前端里合并两个有序时间线(如日历事件、日志流)也是同理。
代码范式:
// 场景:合并两个有序数组
let i = 0; // 指向数组 A
let j = 0; // 指向数组 B
const result = [];
while (i < a.length && j < b.length) {
if (a[i] <= b[j]) {
result.push(a[i]);
i++;
} else {
result.push(b[j]);
j++;
}
}
// 处理剩余部分
while (i < a.length) result.push(a[i++]);
while (j < b.length) result.push(b[j++]);
两个指针完全独立,没有快慢之分,只有"谁当前更小,谁先走"的比较逻辑。
例题:LeetCode 88 — 合并两个有序数组
给你两个有序整数数组 nums1 和 nums2,将 nums2 合并到 nums1 中,使 nums1 成为一个有序数组。
输入:nums1 = [1,2,3,0,0,0], m = 3
nums2 = [2,5,6], n = 3
输出:[1,2,2,3,5,6]
nums1末尾有n个 0 占位,实际有效长度是m。
为什么是分离双指针? 两个有序数组 + 合并操作,经典匹配。
思路(从尾部写入,避免覆盖):
- 从
nums1的末尾开始填充,比较两个数组当前最大值 - 谁大谁先放,对应指针往前退一格
/**
* 环境:浏览器 / Node.js
* LeetCode 88 - Merge Sorted Array
* @param {number[]} nums1
* @param {number} m
* @param {number[]} nums2
* @param {number} n
* @return {void}
*/
function merge(nums1, m, nums2, n) {
let i = m - 1; // nums1 有效区末尾
let j = n - 1; // nums2 末尾
let k = m + n - 1; // nums1 整体末尾(写入位)
while (i >= 0 && j >= 0) {
if (nums1[i] >= nums2[j]) {
nums1[k] = nums1[i];
i--;
} else {
nums1[k] = nums2[j];
j--;
}
k--;
}
// nums2 还有剩余(nums1 剩余部分已经在原位,不需要处理)
while (j >= 0) {
nums1[k] = nums2[j];
j--;
k--;
}
}
const nums1 = [1, 2, 3, 0, 0, 0];
merge(nums1, 3, [2, 5, 6], 3);
console.log(nums1); // [1, 2, 2, 3, 5, 6]
为什么从尾部写? 如果从头部写入,会覆盖 nums1 还没被读取的元素。从尾部写入时,写入位始终在两个读取位的后面,不会发生冲突。
三、识别流程
遇到新题时,我目前的判断路径:
这道题涉及几个数组?
│
├── 两个有序数组 → 分离双指针
│
└── 一个数组
│
├── 有序 + 找两个满足条件的数 → 对撞指针
│
└── 原地修改 / 过滤 / 链表操作 → 快慢指针
还有一个辅助信号:题目说"原地",几乎一定是快慢指针。原地修改意味着不能开新数组,需要用 slow 指针标记写入位。
四、容易混淆的边界
对撞指针 vs 二分法
两者都用 left 和 right,第一眼看起来长得很像,我自己也混淆过。
// 二分法
while (left <= right) {
const mid = Math.floor((left + right) / 2);
if (arr[mid] === target) return mid;
else if (arr[mid] < target) left = mid + 1; // 只动一个边界
else right = mid - 1; // 只动一个边界
}
// 对撞指针
while (left < right) {
const sum = arr[left] + arr[right];
if (sum === target) return [left, right];
else if (sum < target) left++; // 只动一个边界
else right--; // 只动一个边界
}
单步看都是"只动一个边界",但决策依据完全不同:
| 比较的是什么 | 目的 | |
|---|---|---|
| 二分法 | arr[mid] 和 target | 定位单个目标 |
| 对撞指针 | arr[left] + arr[right] 和 target | 找满足条件的一对值 |
本质区别在于问题形状:
二分法 → "这个值在哪里?" 答案是一个下标
对撞指针 → "哪两个值满足条件?" 答案是两个下标的组合
二分每次看中间,对撞每次看两端的组合。left / right 只是变量名的巧合,背后的思维模型不同。
遇到 left / right 的题时,可以先问自己:答案是一个位置,还是两个位置的组合? 这个问题能直接区分两者。
延伸:二分法是独立的算法思想,不属于双指针范畴,后续会单独开一篇记录。
小结
这篇文章是我在只做了两道题的情况下写的,所以更多是框架性的理解,而不是经验总结。
现阶段我自己觉得最有用的一个认知是:双指针的本质不是"两个变量",而是"两种职责" 。快慢是探索与记录,对撞是两端压缩,分离是并行推进。搞清楚每种模式在"做什么决策",比背代码模板更管用。
后续每做完一道新题,会回来往第三节里填——这篇文章会持续更新。