双指针综合通关:模式识别、算法选型与面试高频模式总结

摘要

本文是双指针专栏的收官之作。不再讲解新题目,而是从”如何在面试中快速识别和应用双指针”出发,系统整理三大模型的判断决策树、常见错误模式的根因分析、各题目的思维捷径,以及面试现场应对不确定性的心理模型。有了这套体系,在面试中遇到未见过的双指针题时,也能有条不紊地推导出解法。


第 1 章 双指针全景回顾

1.1 三大模型速查表

模型适用场景前提条件时间复杂度
对撞指针有序数组/字符串,找满足条件的元素对或从两端消解数组有序(或可先排序)(含排序
快慢指针链表(环检测、找中点)、数组/函数的隐式链表存在”下一步”函数,步幅 2:1
滑动窗口数组/字符串,找满足条件的连续子数组/子串窗口内统计量具有单调性(通常要求元素非负)

1.2 专栏覆盖的 LeetCode 题目总览

篇次覆盖题目难度模型
02Two Sum II (167)、Valid Palindrome (125)、Reverse String (344)简单/简单/简单对撞指针
033Sum (15)、3Sum Closest (16)、4Sum (18)中等/中等/中等排序+对撞指针
04Container With Most Water (11)、Trapping Rain Water (42)中等/困难对撞指针
05Linked List Cycle (141)、Linked List Cycle II (142)、Happy Number (202)、Middle of the Linked List (876)简单/中等/简单/简单快慢指针
06Remove Nth Node From End of List (19)、Intersection of Two Linked Lists (160)、Palindrome Linked List (234)中等/简单/简单距离双指针/等长对齐
07Maximum Average Subarray I (643)、Minimum Size Subarray Sum (209)简单/中等滑动窗口
08Longest Substring Without Repeating Characters (3)、Permutation in String (567)中等/中等滑动窗口+哈希
09Minimum Window Substring (76)、Find All Anagrams (438)、Substring with Concatenation of All Words (30)困难/中等/困难滑动窗口+formed

第 2 章 模式识别决策树

2.1 完整决策流程

面试中遇到新题,按以下流程判断:

第一步:题目涉及哪种数据结构?
    → 链表 → 第二步 A
    → 数组/字符串 → 第二步 B

第二步 A(链表):问题类型是什么?
    → 判断是否有环 → 快慢指针(Floyd 算法)LeetCode 141
    → 找环的入口 → 快慢指针+数学 LeetCode 142
    → 找中间节点 → 快慢指针(步幅 2:1)LeetCode 876
    → 找倒数第 K 个节点 → 距离双指针(先走 K+1 步)LeetCode 19
    → 找两链表交点 → 等长对齐(走完跳转到另一条)LeetCode 160
    → 判断是否回文 → 快慢指针找中点 + 反转后半段 LeetCode 234

第二步 B(数组/字符串):找什么?
    → 找两个元素(组合/配对)
        → 数组有序(或可以先排序)?
            → 是 → 对撞指针
                → 两数之和/差/积 → 标准对撞指针 LeetCode 167
                → 三数之和 → 排序+外层循环+对撞指针 LeetCode 15
                → 最大容积/最优面积 → 对撞指针+贪心论证 LeetCode 11
            → 否 → 哈希表(不是双指针场景)LeetCode 1
    → 找连续子数组/子串
        → 固定长度 → 定长滑动窗口
        → 可变长度
            → 元素非负,找最短满足条件 → 可变滑动窗口(收缩时更新答案)LeetCode 209
            → 元素非负,找最长满足条件 → 可变滑动窗口(扩张后更新答案)LeetCode 3
            → 元素可能为负 → 前缀和+哈希表(不是滑动窗口场景)

2.2 三个典型的误判场景

误判一:无序数组用对撞指针

症状:题目给无序数组,直接套两端指针,在某些测试用例上输出错误。

根因:对撞指针的正确性依赖有序性(见第 02 篇的证明)。无序时,“和偏大就移动右端”的推论失效。

修正:先排序,或者改用哈希表。

误判二:有负数的子数组问题用滑动窗口

症状:滑动窗口代码逻辑看似正确,但在含负数的测试用例上漏掉答案。

根因:可变滑动窗口的正确性依赖”加入元素后窗口统计量单调增大”。有负数时,加入新元素可能使和减小,左端 left* 可能需要回退,算法不再正确。

修正:改用前缀和+哈希表。

误判三:滑动窗口的收缩/更新方向写反

症状:代码结构像滑动窗口,但某些情况下漏掉或重复记录答案。

根因:最短满足条件(答案在收缩时更新)和最长满足条件(答案在外层 for 末尾更新)搞混了。

修正:根据题意明确是”找最短”还是”找最长”,对应不同的答案更新位置。


第 3 章 核心代码模板速查

3.1 对撞指针

// 有序数组,找两数之和
int left = 0, right = n - 1;
while (left < right) {
    int sum = nums[left] + nums[right];
    if (sum == target) { /* 找到,处理答案 */ }
    else if (sum < target) left++;
    else right--;
}

3.2 快慢指针(环检测)

ListNode slow = head, fast = head;
while (fast != null && fast.next != null) {
    slow = slow.next;
    fast = fast.next.next;
    if (slow == fast) { /* 有环 */ break; }
}

3.3 距离双指针(倒数第 K 个节点的前驱)

ListNode dummy = new ListNode(0); dummy.next = head;
ListNode fast = dummy, slow = dummy;
for (int i = 0; i < k + 1; i++) fast = fast.next;
while (fast != null) { slow = slow.next; fast = fast.next; }
// slow 是倒数第 k 个节点的前驱
slow.next = slow.next.next;

3.4 定长滑动窗口

int windowSum = 0;
for (int i = 0; i < k; i++) windowSum += nums[i];
// 处理初始窗口
for (int right = k; right < n; right++) {
    windowSum += nums[right];
    windowSum -= nums[right - k];
    // 处理当前窗口
}

3.5 可变滑动窗口(最短满足条件)

int left = 0;
// state = 窗口统计量(和、哈希表等)
for (int right = 0; right < n; right++) {
    // 加入 nums[right] 到 state
    while (/* 窗口满足条件 */) {
        // 更新答案(记录当前窗口长度)
        // 移除 nums[left] 出 state
        left++;
    }
}

3.6 可变滑动窗口(最长满足条件)

int left = 0;
// state = 窗口统计量
for (int right = 0; right < n; right++) {
    // 加入 nums[right] 到 state
    while (/* 窗口不满足条件 */) {
        // 移除 nums[left] 出 state
        left++;
    }
    // 更新答案(当前窗口是以 right 为右端的最长满足窗口)
    answer = Math.max(answer, right - left + 1);
}

第 4 章 常见错误模式的根因分析

4.1 链表指针的 NPE 陷阱

错误模式:访问 node.next.next 前只判断了 node.next != null,没有判断 node != null

// 错误
while (fast.next != null && fast.next.next != null)
 
// 正确(先判 fast,再判 fast.next)
while (fast != null && fast.next != null)

实际上,两种写法的循环条件含义不同:

  • fast != null && fast.next != null:快指针可以安全访问 fast.next,即快指针在链表末尾节点或之前
  • fast.next != null && fast.next.next != null:快指针的下一个节点存在,即快指针在末尾前一个节点或之前

后者比前者少走一步。对于”找中间节点”问题(LeetCode 876),两者找到的中点不同(见第 06 篇分析)。使用前确认自己要哪种中点。

4.2 3Sum 去重方向写反

错误模式:外层去重写成 nums[i] == nums[i+1](看下一个)而不是 nums[i] == nums[i-1](看上一个)。

前者会跳过第一次出现的值,漏掉所有包含该值的答案;后者跳过重复处理,正确。

记忆方法:“我已经处理过了,跳过”——比较的是上一个i-1),而不是”下面还有相同的,跳过”(那是比较 i+1,逻辑相反)。

4.3 Integer == 比较陷阱

错误模式:在 Minimum Window Substring 中用 == 比较 window.get(c) == need.get(c),在大值输入时失败。

Integer 对象在 -128127 之间会被 JVM 缓存,== 比较正确;超出范围时创建新对象,== 比较引用,返回 false

修正:所有 Integer 对象的值比较必须使用 .equals(),或者先解包为 int

// 错误
if (window.get(c) == need.get(c))
 
// 正确
if (window.get(c).equals(need.get(c)))
// 或
if ((int) window.get(c) == (int) need.get(c))

4.4 多数之和的整数溢出

错误模式:4Sum 中,两个 int 相加未转 long,在边界用例下溢出。

// 错误(可能溢出)
if (nums[i] + nums[i+1] + nums[i+2] + nums[i+3] > target) break;
 
// 正确
if ((long) nums[i] + nums[i+1] + nums[i+2] + nums[i+3] > target) break;

通用原则:凡是多个 int 求和,且和的量级可能接近或超过 ,就应该用 long

4.5 哈希表中频次为 0 时忘记删除 key

错误模式:滑动窗口收缩时,将某字符频次减为 0 但没有从哈希表中删除,导致 map.size() 偏大,收缩条件判断错误。

// 正确:减到 0 时删除
window.put(d, window.get(d) - 1);
if (window.get(d) == 0) window.remove(d);

第 5 章 面试心理模型

5.1 面试中遇到不确定题目的应对策略

遇到不确定能否用双指针的题目,按以下步骤推进:

  1. 先问自己:暴力解法是什么?时间复杂度是多少?
    明确暴力解法是 还是 ,确定优化空间。

  2. 有没有单调性?
    连续子数组问题:加入/移除元素后,统计量是单调的吗?(正整数数组一般是;含负数一般不是)
    有序数组/两端配对问题:移动一端后,能否明确另一端的移动方向?

  3. 从例子出发,手动模拟双指针
    在草稿纸上用一个简单例子模拟,确认每步操作是否排除了不可能的候选,是否保证了最优解不被遗漏。

  4. 先写出来,再分析正确性
    很多时候,写出来能帮助自己发现问题。面试中先提交一个大致正确的解,再补充边界条件,总好过一直没有代码。

5.2 双指针题的时间管理建议

题目类型建议时间分配
链表快慢指针(141、876、19)5-8 分钟(模板题,快速写出)
有序数组对撞指针(167、15)8-12 分钟(注意去重逻辑)
复杂对撞指针(11、42)10-15 分钟(需要贪心论证)
基础滑动窗口(3、209)8-12 分钟
进阶滑动窗口(76、30)15-20 分钟

5.3 讲解思路的结构化表达

面试中讲解解法,推荐这个结构:

  1. 观察(1-2 句):“这道题是在有序数组中找两数之和,具有有序性。”
  2. 暴力(1 句):“暴力解法是 的两重循环。”
  3. 优化核心(2-3 句):“利用有序性,可以用对撞指针:左指针指向最小值,右指针指向最大值。和偏大就移动右指针,和偏小就移动左指针,每步排除一整行或一整列。”
  4. 复杂度(1 句):“时间复杂度 ,空间复杂度 。”
  5. 写代码:先写主干逻辑,再处理边界条件(如 3Sum 的去重),边写边解释关键行。

第 6 章 综合练习路径

6.1 按难度分级的刷题顺序

第一阶段:掌握基础模板(约 1 周)

题目知识点
LeetCode 167 Two Sum II对撞指针基础
LeetCode 141 Linked List Cycle快慢指针基础
LeetCode 876 Middle of the Linked List快慢指针找中点
LeetCode 643 Maximum Average Subarray I定长滑动窗口
LeetCode 209 Minimum Size Subarray Sum可变滑动窗口最短
LeetCode 3 Longest Substring Without Repeating Characters可变滑动窗口最长

第二阶段:提升去重和状态维护能力(约 1 周)

题目知识点
LeetCode 15 3Sum排序+对撞指针+三处去重
LeetCode 142 Linked List Cycle IIFloyd 环入口数学推导
LeetCode 19 Remove Nth Node From End距离双指针+哑节点
LeetCode 160 Intersection of Two Linked Lists等长对齐
LeetCode 567 Permutation in Stringformed 计数器+定长窗口
LeetCode 438 Find All Anagramsformed 计数器+记录所有位置

第三阶段:攻克困难题(约 1 周)

题目知识点
LeetCode 11 Container With Most Water对撞指针贪心证明
LeetCode 42 Trapping Rain Water对撞指针+前后缀等价性
LeetCode 234 Palindrome Linked List快慢指针+链表反转
LeetCode 76 Minimum Window Substringformed 计数器+可变窗口最短
LeetCode 18 4Sum多层降维+全局去重

第四阶段:拓展变体(可选)

题目知识点
LeetCode 977 Squares of a Sorted Array对撞指针的非求和变体
LeetCode 992 Subarrays with K Different Integers”恰好 K 个” = “至多 K 个” - “至多 K-1 个”
LeetCode 30 Substring with Concatenation of All Words单词级滑动窗口
LeetCode 395 Longest Substring with At Least K Repeating Characters分治+滑动窗口(进阶)

第 7 章 双指针与其他算法的边界再梳理

7.1 双指针 vs 动态规划

接雨水(LeetCode 42)同时有双指针和动态规划(前后缀预处理)两种解法,后者更直觉,前者空间更优。

一般而言:

  • 双指针解法往往是动态规划的空间优化版本——当 DP 状态只依赖”局部的最大/最小值”时,可以用一个指针维护这个局部信息
  • 如果 DP 状态需要全局信息(如所有之前状态的最大值),双指针可能无法模拟

7.2 双指针 vs 单调栈

接雨水同样有单调栈解法( 时间, 空间)。单调栈的优势在于:逐行计算水量,适合流式场景或需要可视化的场景。双指针的优势在于 空间。

对于 Container With Most Water,单调栈不适用(单调栈适合”每个元素找左/右第一个更大元素”,而盛水问题需要找全局最优对)。

7.3 双指针 vs 二分查找

Minimum Size Subarray Sum(LeetCode 209)有 的滑动窗口解法,也有 的前缀和+二分查找解法。前者明显更优,但了解后者能加深对”单调性”的理解:前缀和数组是单调递增的(所有元素为正),因此可以用二分查找目标位置。


小结

本文是双指针专栏的总结与提升篇。核心收益:

  1. 决策树:从数据结构出发,逐步收窄到具体模型,避免凭感觉套模板
  2. 错误模式:5 类常见错误(NPE、去重方向、Integer ==、整数溢出、哈希表 key 残留)的根因与修正
  3. 代码模板:6 个核心模板,可以直接在面试中作为起点
  4. 练习路径:三阶段渐进式刷题路径,从基础到困难有章可循

双指针算法的本质是搜索空间的系统性压缩:利用问题固有的单调性,每一步移动都永久消除一批不可能的候选。掌握了这个底层逻辑,任何新的双指针变体题,都可以通过”这道题有什么单调性可以利用?“这一问题来切入,从而独立推导出解法,而不只是靠背题。