双指针学习笔记 Vol.1:从模糊到有框架

0 阅读12分钟

最近开始系统练算法,第一个专题是双指针。

看完定义感觉懂了,但遇到新题还是会卡——不知道该用哪种,也不确定自己的直觉对不对。后来意识到,问题不在于"记住了几道题",而是没有建立识别机制。这篇文章是我当前阶段的学习笔记,核心目的是搭一个框架,让后续每道新题都能对号入座,持续积累。


一、为什么"双指针"这个名字会让人困惑

"双指针"听起来像一个具体技巧,但实际上它是三种思路不同的模式的统称:

模式方向核心意图
快慢指针同向一个探索,一个确认
对撞指针相向从两端压缩可能性
分离双指针各自独立两个数组步调协同

它们唯一的共同点是:同时维护两个位置状态,避免嵌套循环。

但识别信号、使用场景完全不同。把它们混在一起理解,是我早期困惑的根源。


二、三种模式的认知框架

模式一:快慢指针(同向)

意象: 两个人走同一条路,一个走得快(负责探路),一个走得慢(负责记录)。快指针扫描每一个值,慢指针只在"值得写下来"的时候才往前走一步。

识别信号:

  • 一个数组,原地修改
  • 过滤掉某些元素(重复值、指定值)
  • 链表找中点 / 找环(走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 = 2left 到 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-z0-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 — 合并两个有序数组

给你两个有序整数数组 nums1nums2,将 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 二分法

两者都用 leftright,第一眼看起来长得很像,我自己也混淆过。

// 二分法
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 的题时,可以先问自己:答案是一个位置,还是两个位置的组合? 这个问题能直接区分两者。

延伸:二分法是独立的算法思想,不属于双指针范畴,后续会单独开一篇记录。


小结

这篇文章是我在只做了两道题的情况下写的,所以更多是框架性的理解,而不是经验总结。

现阶段我自己觉得最有用的一个认知是:双指针的本质不是"两个变量",而是"两种职责" 。快慢是探索与记录,对撞是两端压缩,分离是并行推进。搞清楚每种模式在"做什么决策",比背代码模板更管用。

后续每做完一道新题,会回来往第三节里填——这篇文章会持续更新。


参考资料