LeetCode · Two Pointers
完整教程 / 本地进度 / 独立离线阅读
已读 0%
ALGORITHM NOTEBOOK / 02

双指针:从移动规则
到正确性证明

13 道 LeetCode · Python 3 完整答案 · 5 个学习阶段
把“两个下标怎么动”,学成“为什么这样动不会漏解”。

13题目严格全覆盖
5循序渐进的学习阶段
OFFLINE内联资源 · 本地进度

每题的学习闭环:理解接口 → 定义指针 → 建立不变量 → 解释移动规则 → 推演边界 → 独立默写。先学能证明的思路,再追求代码熟练度。

题意为针对学习目标的转述,每题保留官方原题链接;解法、证明、图解及学习安排为本手册编写。阅读、复制、演示和进度保存无需网络,原题链接需联网。

学习路线:先区间,再证明

阶段题目顺序完成标准
1 · 相向指针入门344 → 125定义未处理区间,明确相遇时为什么停止。
2 · 双序列协作1768 → 392 → 88覆盖任一输入先结束,并解释从后写的原因。
3 · 同向读写与稳定划分27 → 26 → 283区分有效长度、物理长度与保序要求。
4 · 候选排除与有序搜索11 → 15证明短板移动与有序和的移动方向。
5 · 进阶边界与匹配75 → 42 → 28解释未知区、可结算位置与失配回退。

第 28 题虽然标为简单,但 KMP 需要单独理解前缀复用,因此放在最后。先完成朴素匹配,再进入进阶解法。阶段仅表示学习顺序,不改变原题难度。

基础知识:指针其实是边界

在 Python 数组与字符串题中,“指针”通常只是整数下标。两个位置相互协作:每次推进都确定一部分答案、消费一段输入,或排除一批不可能更优的候选。

相向指针:外侧已确定,收缩中间未知区间已确定已确定未处理区间 [left, right]leftright移动前先问:为什么这一侧可以安全排除?
同样的收缩动作,可以服务于交换、配对或最优候选排除。
类型含义与推进规则代表题
相向收缩left、right 围出未处理区间;由配对、排序或上界决定移动哪侧344、125、11、15、42
双序列协作各自消费输入;交替消费、相等时推进或每次选取最值1768、392、88
同向读写read 检查候选;write 只在保留时推进27、26、283
多边界划分边界分隔已知与未知区;看换回元素是否已知75
连续匹配文本位置与已匹配模式长度;朴素重试或前后缀回退28

写代码前的四个问题

  1. 每个指针代表什么:当前元素、下一位置、有效长度,还是已匹配长度?
  2. 哪些区间已确定:统一 [left,right] 或 [left,right),写出空区间条件。
  3. 为什么可以这样移动:用不变量或上界证明,避免仅凭示例猜规则。
  4. 每轮至少推进哪个量:未处理区间是否严格缩短?总推进次数是否有界?

Python 实现约定

  • 所有 Python 答案均包含独立 class Solution 和原题方法名;数组注解显式导入 List。一次只复制一个解法提交。
  • 区分原地修改并返回 None、返回有效长度、返回新结果三种接口。
  • 切片、sorted 会分配内存;nums.sort() 虽原地修改,最坏仍可能使用 O(n) 辅助空间。
  • 字符串不可变,拼接优先列表加 join;Python 负下标会访问末尾,必须显式保护边界。
  • 空间分析区分辅助空间和输出空间;双串题会单独说明 m、n。

核心模板:先选推进模型

下面是语言无关的伪代码,用于记忆控制结构,不可直接提交。每道题卡片均有完整的可提交 Python 代码。

模板 A · 相向收缩(伪代码)
left ← 0, right ← n−1
while left < right:
    先处理或评估当前边界
    依据证明推进 left 或 right(或同时推进)
    // 不要默认每轮都移动两边
模板 B · 双序列协作(伪代码)
i ← 0, j ← 0
while 任一序列仍有未消费元素:
    先判断各自边界
    按交替、匹配、最值规则消费元素
    至少推进一个消费游标
模板 C · 稳定过滤(伪代码)
write ← 0
for read 从 0 到 n−1:
    if 当前候选应保留:
        数组[write] ← 数组[read]
        write ← write + 1
返回 write;若题目要求完整尾部,再补尾部
模板 D · 未知区划分(伪代码)
维护:已知区 + 未知区 + 已知区
while 未知区非空:
    判断当前位置元素
    从已知区换回的值:按不变量推进
    从未知区换回的值:先重查,再决定推进
模板 E · KMP 失配复用(伪代码)
j ← 已匹配模式长度
while j > 0 且当前字符不匹配:
    j ← prefix[j−1]
若当前字符匹配:j ← j+1
若 j 达到模式长度:得到完整匹配

指针实验室:每一步都有依据

先预测下一步,再按按钮核对。演示与正文使用同样的移动规则,离线可运行。

STAGE 01 / 05相向指针入门

阶段目标:定义未处理区间,明确相遇时为什么停止。

01LEETCODE 344反转字符串

原题 ↗简单

核心知识点:相向指针 · 原地交换 · 闭区间

题意与接口

给定字符列表 s,直接修改这个列表,使字符顺序反转。函数不返回结果,额外空间必须为 O(1)。输入是可修改的列表,并非 Python 的不可变字符串。

解法一:两端交换(推荐)

解题思路与正确性
  1. 让 left、right 分别指向首尾。未处理区间为闭区间 [left, right]。
  2. 交换两端元素后,这两个位置就与最终答案一致;同时向中间移动。
  3. 当 left ≥ right 时没有需要交换的不同位置;奇数长度的中点无需操作。
Python 3 · 可独立提交
from typing import List

class Solution:
    def reverseString(self, s: List[str]) -> None:
        left, right = 0, len(s) - 1
        while left < right:
            s[left], s[right] = s[right], s[left]
            left += 1
            right -= 1
关键代码解释
  • while left < right:避免重复交换,也避免单元素自交换。
  • Python 多重赋值先计算右侧两个值,再写回左侧位置,不需要额外数组。
  • 方法隐式返回 None,判题机检查传入列表是否改变。
示例推演
轮次left / right交换后 s
初始0 / 4[h, e, l, l, o]
10 / 4[o, e, l, l, h]
21 / 3[o, l, l, e, h]
结束2 / 2原列表成为 [o, l, l, e, h];返回 None

时间复杂度
O(n):最多交换 ⌊n/2⌋ 次。

空间复杂度
O(1):仅使用两个下标和常数个临时引用。

选择与比较:手动交换能展示双指针;s.reverse() 也原地反转,但面试时应能写出其核心过程。

边界核对

输入预期结果
["a"]保持 ["a"],返回 None
["a","b"]变成 ["b","a"]
[](扩展边界)保持 [],循环不会进入

易错点

  • s = s[::-1] 只重绑定局部变量,外部列表没有改变。
  • s[:] = s[::-1] 虽能修改原列表,右侧切片仍需 O(n) 空间。
  • 不要把本题写成返回一个新字符串。
面试复述 先确认未处理区间,再决定是否还需要交换。

02LEETCODE 125验证回文串

原题 ↗简单

核心知识点:相向指针 · 字符过滤 · 大小写归一化

题意与接口

忽略非字母数字字符,并将字母视为小写后,判断字符串是否正反一致。只包含空格、标点时,过滤后的空串也算回文。原题字符范围为可打印 ASCII。

解法一:跳过无效字符(推荐)

解题思路与正确性
  1. 从首尾向内寻找有效的字母或数字,标点和空格直接越过。
  2. 将两端有效字符转为小写比较;一旦不等,立即返回 False。
  3. 匹配成功才收缩两侧;直到所有有效字符配对成功。
Python 3 · 可独立提交
class Solution:
    def isPalindrome(self, s: str) -> bool:
        left, right = 0, len(s) - 1
        while left < right:
            while left < right and not s[left].isalnum():
                left += 1
            while left < right and not s[right].isalnum():
                right -= 1
            if s[left].lower() != s[right].lower():
                return False
            left += 1
            right -= 1
        return True
关键代码解释
  • isalnum() 同时保留字母和数字,不能仅调用 isalpha()。
  • 内层循环也要检查 left < right,防止连续标点让下标越界。
  • 在原题 ASCII 范围内,每次 lower() 处理一个字符,成本是 O(1)。Unicode 的大小写折叠规则不属于本题范围。
示例推演
步骤:s = "A,b:a"left / right动作
初始0 / 4A 与 a:转小写相同
收缩并跳过1 / 3 → 2 / 2逗号、冒号不参与比较
结束2 / 2中点 b,无需配对;返回 True

时间复杂度
O(n):两个指针各自单调移动,嵌套 while 不会导致 O(n²)。

空间复杂度
O(1):没有构造过滤后的字符串。

选择与比较:适合严格常数空间;相较预处理,边界判断更多。

解法二:先过滤,再逆序比较

解题思路与正确性

将“字符选择”和“回文判断”分开。先形成只包含小写字母数字的序列,再与其逆序比较。它与双指针的比较规则相同,但显式保存了中间结果。

Python 3 · 可独立提交
class Solution:
    def isPalindrome(self, s: str) -> bool:
        cleaned = "".join(ch.lower() for ch in s if ch.isalnum())
        return cleaned == cleaned[::-1]
关键代码解释
  • join 一次性拼接,避免循环中反复 +=。
  • [::-1] 生成逆序副本,因此不能声称 O(1) 空间。
示例推演

"A,b:a" → "aba" → "aba" == "aba" → True。"0P" → "0p",逆序为 "p0",返回 False。

时间复杂度
O(n):过滤、逆序和比较均为线性。

空间复杂度
O(n):保存过滤串与逆序副本。

选择与比较:代码最简洁,适合快速验证;空间要求严格时选解法一。

边界核对

输入预期结果
" " 或 "!!!"True
"race a car"False
"A man, a plan, a canal: Panama"True
"0P"False

易错点

  • 数字不能忽略:"0P" 应为 False。
  • 过滤后为空串时返回 True。
  • 不能直接比较未经归一化的字符,也不要对原字符串直接逆序判断。
面试复述 不需要先生成过滤串:过滤也可以由指针移动完成。

STAGE 02 / 05双序列协作

阶段目标:覆盖任一输入先结束,并解释从后写的原因。

03LEETCODE 1768交替合并字符串

原题 ↗简单

核心知识点:双序列指针 · 尾部处理 · join

题意与接口

依次从 word1、word2 各取一个字符,从 word1 开始交替合并;某个字符串先结束时,保留另一个字符串的所有剩余字符,返回合并字符串。

解法一:两个独立游标

解题思路与正确性
  1. i、j 分别表示两个字符串中下一个未使用的位置。
  2. 只要任一字符串仍有字符,循环就继续;每轮先尝试 word1,再尝试 word2。
  3. 各自用边界判断保护取字符操作;这样某一方用尽后,另一方自然追加尾部。
Python 3 · 可独立提交
class Solution:
    def mergeAlternately(self, word1: str, word2: str) -> str:
        i = j = 0
        parts = []
        while i < len(word1) or j < len(word2):
            if i < len(word1):
                parts.append(word1[i])
                i += 1
            if j < len(word2):
                parts.append(word2[j])
                j += 1
        return "".join(parts)
关键代码解释
  • or 保证长字符串尾部不丢失。若使用 and,必须再补两个尾部循环。
  • 先处理 word1 的分支决定了交替起点。
  • 字符串不可变;使用列表加 join 让总拼接成本可控。
示例推演
轮次:ab / pqrsi / j(轮后)parts 合并视图
11 / 1ap
22 / 2apbq
32 / 3apbqr
42 / 4apbqrs → 返回 "apbqrs"

时间复杂度
O(m+n):m、n 分别是两个字符串长度。

空间复杂度
O(m+n):字符列表及最终结果;即使不计返回值,列表仍占线性空间。

选择与比较:两个指针最直观;同步前进时可合并成一个下标,但不改变复杂度。

边界核对

输入预期结果
"abc", "pqr""apbqcr"
"abcd", "pq""apbqcd"
"", "x"(扩展边界)"x"

易错点

  • 直接使用 zip(word1, word2) 会截断较长一方。
  • 反复拼接不可变字符串可能导致二次时间;优先列表加 join。
  • 不要把第二个 if 写为 elif,否则不能在同一轮依次取两边。
面试复述 每个序列一个游标,是否继续取值由各自边界决定。

04LEETCODE 392判断子序列

原题 ↗简单

核心知识点:双序列扫描 · 贪心匹配 · 多次查询

题意与接口

判断 s 的字符能否按原相对次序出现在 t 中,允许跳过 t 中的字符,不要求连续。例如 "ace" 是 "abcde" 的子序列,"aec" 不是。空串是任意字符串的子序列。

解法一:单次查询双指针(推荐)

解题思路与正确性
  1. i 表示 s 中已匹配字符数,也是下一个待匹配位置;t 的循环游标不断向右。
  2. 遇到下一个需要的字符就立即匹配,否则仅跳过 t 的当前字符。
  3. 扫描结束,检查 i 是否等于 s 的长度。
Python 3 · 可独立提交
class Solution:
    def isSubsequence(self, s: str, t: str) -> bool:
        i = 0
        for ch in t:
            if i < len(s) and s[i] == ch:
                i += 1
        return i == len(s)
关键代码解释
  • i < len(s) 必须放在 s[i] 前面,利用 and 短路避免空串或已匹配完成时越界。
  • 循环遍历 t,只有相等时才推进 s;两边都无条件前进会漏掉跳跃匹配。
  • 最终比较匹配长度;不能仅因为某一次匹配成功就返回 True。
示例推演
t = ahbgdc;s = abci(处理后)已匹配
a1a
h1a
b2ab
g、d2ab
c3abc;返回 True

时间复杂度
O(n):n = len(t),这份代码始终遍历整个 t;m = len(s)。

空间复杂度
O(1):仅保存匹配下标。

选择与比较:单次查询最合适;匹配完提前 return 可减少实际工作,但最坏时间仍为 O(n)。

解法二:位置表 + 二分(大量查询的基础)

解题思路与正确性

预先记录 t 中每个字符出现的有序下标列表。对 s 的每个字符,用二分找第一个不小于 next_pos 的位置,然后将可用位置推进到它之后。若没有可用位置,匹配失败。

Python 3 · 可独立提交
from bisect import bisect_left

class Solution:
    def isSubsequence(self, s: str, t: str) -> bool:
        positions = {}
        for i, ch in enumerate(t):
            positions.setdefault(ch, []).append(i)
        next_pos = 0
        for ch in s:
            indexes = positions.get(ch, [])
            k = bisect_left(indexes, next_pos)
            if k == len(indexes):
                return False
            next_pos = indexes[k] + 1
        return True
关键代码解释
  • bisect_left(indexes, next_pos) 找到第一个合法下标的列表位置,不是 t 中的实际位置。
  • next_pos = indexes[k] + 1 保证同一位置不能重复使用。
  • 每个下标只存一次,所以位置表总空间为 O(n)。
示例推演

t="ahbgdc" 的位置表中 a:[0]、b:[2]、c:[5]。查询 "abc" 依次找到 0、2、5,next_pos 为 1、3、6,返回 True;查询 "axc" 在 x 的空列表处返回 False。

时间复杂度
单次 O(n + m log(n+1));复用预处理后每次查询 O(m log(n+1)),预处理 O(n)。

空间复杂度
O(n):位置表;单次查询额外游标为 O(1)。

选择与比较:多次查询更有价值。固定小写字母表还可建 next 表,以 O(26n) 预处理和空间换取每次 O(m) 查询;本册保留位置表的完整实现。

边界核对

输入预期结果
"", "abc"True
"a", ""False
"aa", "ab"False
"axc", "ahbgdc"False

易错点

  • 子序列允许不连续,子串必须连续,不能混为一谈。
  • s 比 t 长时必定 False;不做长度预判也不影响正确性。
  • 同一字符的多个副本必须占用不同且递增的位置。
面试复述 在可行的匹配位置中取最早的一个,为后续字符留下最多空间。

05LEETCODE 88合并两个有序数组

原题 ↗简单

核心知识点:逆向双指针 · 额外写指针 · 防止覆盖

题意与接口

nums1 的总长度为 m+n,只有前 m 个元素有效,末尾 n 个位置用于容纳 nums2。将长度为 n 的 nums2 合并到 nums1,保持非递减顺序,原地修改并返回 None。末尾占位零不参与比较;有效数据中也可以有零。

解法一:从后向前填充(推荐)

解题思路与正确性
  1. 两条有序前缀的最大值一定在各自末端。i、j 指向末端,write 指向可写的最后位置。
  2. 每次把较大元素写到 write,再移动对应读指针和 write。
  3. 只需保证 nums2 全部放入;若 nums2 先耗尽,nums1 剩余前缀本来就在正确位置。
Python 3 · 可独立提交
from typing import List

class Solution:
    def merge(self, nums1: List[int], m: int,
              nums2: List[int], n: int) -> None:
        i, j, write = m - 1, n - 1, m + n - 1
        while j >= 0:
            if i >= 0 and nums1[i] > nums2[j]:
                nums1[write] = nums1[i]
                i -= 1
            else:
                nums1[write] = nums2[j]
                j -= 1
            write -= 1
关键代码解释
  • i >= 0 必须先判断,Python 负索引会读到末尾数据而非报错。
  • 当 i < 0 时,else 分支把 nums2 剩余元素依次放入。
  • 相等时从 nums2 取值不影响数值有序性;因为从后向前写,nums1 中的相同值会排在它前面。
示例推演
i / j / write(写入前)选取nums1 写入后的状态
2 / 2 / 56[1,2,3,0,0,6]
2 / 1 / 45[1,2,3,0,5,6]
2 / 0 / 33[1,2,3,3,5,6]
1 / 0 / 22(nums2)[1,2,2,3,5,6];j=-1 结束

时间复杂度
O(m+n):每次确定一个最终位置;n=0 时直接结束。

空间复杂度
O(1):没有新数组,也没有列表切片。

选择与比较:原地且线性;需要同时管理两个读位置和一个写位置。

解法二:正向合并到临时列表

解题思路与正确性

像归并排序的合并步骤一样,每次取两边尚未使用的最小值放入新列表,读完后整体写回 nums1。临时列表让读取与写入分离,避免覆盖。

Python 3 · 可独立提交
from typing import List

class Solution:
    def merge(self, nums1: List[int], m: int,
              nums2: List[int], n: int) -> None:
        i = j = 0
        merged = []
        while i < m or j < n:
            if j == n or (i < m and nums1[i] <= nums2[j]):
                merged.append(nums1[i])
                i += 1
            else:
                merged.append(nums2[j])
                j += 1
        nums1[:] = merged
关键代码解释
  • j == n 优先处理 nums2 已耗尽的情况。
  • nums1[:] = merged 修改原列表;不能写成 nums1 = merged。
示例推演

对 [1,2,3] 与 [2,5,6],依次选取 1、2(nums1)、2(nums2)、3、5、6;merged 成为 [1,2,2,3,5,6],最后写回 nums1。

时间复杂度
O(m+n)。

空间复杂度
O(m+n):临时合并结果。

选择与比较:逻辑更直观,适合作为归并模板;空间优于排序合并与否需单独分析,严格 O(1) 要用逆向法。

边界核对

输入预期结果
nums1=[0], m=0;nums2=[1], n=1nums1 → [1]
nums1=[1], m=1;nums2=[], n=0保持 [1]
nums1=[-1,0,0], m=2;nums2=[0], n=1nums1 → [-1,0,0]

易错点

  • 不能从前向后直接写 nums1,否则会覆盖还未读到的原数据。
  • m 和 n 表示有效数据个数,不能用 nums1 中零的位置识别占位区。
  • 题目要求修改 nums1,返回一个新列表无法满足接口。
面试复述 写入方向由空闲空间位置决定:尾部有空间,优先从尾部放大值。

STAGE 03 / 05同向读写与稳定划分

阶段目标:区分有效长度、物理长度与保序要求。

06LEETCODE 27移除元素

原题 ↗简单

核心知识点:读写指针 · 有效前缀 · 稳定性

题意与接口

原地处理 nums,返回不等于 val 的元素数量 k,并让前 k 个位置包含这些元素。原题允许改变元素顺序;k 之后的数据无需清空,列表物理长度无需缩短。

解法一:稳定过滤(通用模板)

解题思路与正确性
  1. read 扫描输入,write 是下一个有效元素写入的位置,也等于已保留元素个数。
  2. 若当前元素不等于 val,把它写入 nums[write],再增加 write;否则只移动 read。
  3. 返回 write;前 write 个位置就是答案,其余位置忽略。
Python 3 · 可独立提交
from typing import List

class Solution:
    def removeElement(self, nums: List[int], val: int) -> int:
        write = 0
        for read in range(len(nums)):
            if nums[read] != val:
                nums[write] = nums[read]
                write += 1
        return write
关键代码解释
  • 写入后才 write += 1,否则会遗漏下标 0。
  • 没有调用 remove 或 pop,不会造成反复移动后缀。
  • return write 返回长度,而非最后一个元素下标。
示例推演
read:nums=[3,2,2,3], val=3动作有效前缀
0跳过 3[]
1nums[0]=2;write=1[2]
2nums[1]=2;write=2[2,2]
3跳过 3;返回 2物理列表 [2,2,2,3],只看前 2 项

时间复杂度
O(n):每个元素读取一次。

空间复杂度
O(1)。

选择与比较:保持顺序、可复用;即使无须保持顺序,也通常优先写这个易证明的版本。

解法二:末尾替换(允许乱序时)

解题思路与正确性

维护仍待处理的半开区间 [i, end)。遇到 val 就用区间末尾元素覆盖它,再缩短有效范围;换来的元素没有被检查,所以 i 不动。只有遇到合法元素时才推进 i。

Python 3 · 可独立提交
from typing import List

class Solution:
    def removeElement(self, nums: List[int], val: int) -> int:
        i, end = 0, len(nums)
        while i < end:
            if nums[i] == val:
                end -= 1
                nums[i] = nums[end]
            else:
                i += 1
        return end
关键代码解释
  • end 是有效长度,末尾元素在 end - 1,所以先减再取。
  • 覆盖后不能无条件 i += 1,因为换来的数也可能是 val。
示例推演

[3,2,2,3]:i=0,end=4,先用末尾 3 覆盖并令 end=3;再用下标 2 的 2 覆盖并令 end=2,得到 [2,2,2,3]。随后 i 从 0 走到 2,返回 2。

时间复杂度
O(n)。

空间复杂度
O(1)。

选择与比较:写操作次数等于被移除元素数,val 很少时有优势;无法保留原有顺序,因此不能直接用于移动零或稳定去重。

边界核对

输入预期结果
[], val=1k=0
[2,2], val=2k=0
[1,2], val=3k=2,前缀 [1,2]

易错点

  • 不必真的删掉尾部元素;只返回 k 并保证前 k 项正确。
  • 遍历中调用 nums.remove 会改变下标关系,也可能退化为 O(n²)。
  • 末尾替换版在替换后必须重查当前位置。
面试复述 有效长度与列表物理长度是两个概念。

07LEETCODE 26删除有序数组中的重复项

原题 ↗简单

核心知识点:有序性 · 读写指针 · 相邻去重

题意与接口

给定非递减数组,原地保留每种值的一次出现,返回不同元素个数 k。前 k 个位置必须按原来的非递减顺序存储唯一值,k 之后内容忽略。

解法一:与有效前缀末尾比较

解题思路与正确性
  1. 有序数组中相同值必然连续,因此只需和最后一个保留值比较。
  2. 有效前缀为空,或当前值不同于最后保留值时,才执行写入。
  3. read 始终前进,write 只在遇到新值时增加;结束后 write 即唯一值个数。
Python 3 · 可独立提交
from typing import List

class Solution:
    def removeDuplicates(self, nums: List[int]) -> int:
        write = 0
        for read in range(len(nums)):
            if write == 0 or nums[read] != nums[write - 1]:
                nums[write] = nums[read]
                write += 1
        return write
关键代码解释
  • write == 0 or ... 先短路保护 nums[write-1],也自然兼容空列表。
  • 与“最后保留元素”比较比依赖原相邻下标更容易推广。
  • write ≤ read,所以覆盖发生在已读区或当前位置。
示例推演
read:nums=[1,1,2,2,3]判断有效前缀
0write=0,保留[1]
11 等于末尾 1,跳过[1]
22 不等于末尾 1,保留[1,2]
32 等于末尾 2,跳过[1,2]
43 不等于末尾 2,保留[1,2,3];k=3

时间复杂度
O(n)。

空间复杂度
O(1):没有集合或新列表。

选择与比较:有序性把“见过没有”简化成一次末尾比较。若变为每个值最多保留 K 次,保留条件可推广为 write < K 或 nums[read] != nums[write-K],但本题 K=1。

边界核对

输入预期结果
[1,1,1]k=1,前缀 [1]
[1,2,3]k=3,前缀不变
[-1,-1,0,0,2]k=3,前缀 [-1,0,2]
[](扩展边界)k=0

易错点

  • 若数组无序,相同元素不一定相邻,本方法不能用于全局去重。
  • 返回 write,不是 write-1。
  • 不能用 sorted(set(nums)) 替代要求的常数空间原地过程。
面试复述 读写指针解决保留位置,有序性解决重复判断。

08LEETCODE 283移动零

原题 ↗简单

核心知识点:稳定划分 · 非零前缀 · 原地交换

题意与接口

将所有 0 移到数组末尾,并保持非零元素原有相对顺序。必须原地修改 nums,返回 None;不能复制整个数组。

解法一:压缩非零,再补零

解题思路与正确性

先用稳定过滤把非零元素压到前面。与移除元素不同,本题要保留整个列表长度并指定尾部为零,所以扫描结束后还要把 [write, n) 全部设为 0。

Python 3 · 可独立提交
from typing import List

class Solution:
    def moveZeroes(self, nums: List[int]) -> None:
        write = 0
        for read in range(len(nums)):
            if nums[read] != 0:
                nums[write] = nums[read]
                write += 1
        for i in range(write, len(nums)):
            nums[i] = 0
关键代码解释
  • 只在读完后统一补零;提前清空尚未读取的位置会丢数据。
  • 末尾补零用循环,无需构造 [0] * k 临时列表。
示例推演
阶段:[0,1,0,3,12]write数组(含尚无意义的尾部)
读取 00[0,1,0,3,12]
读取 11[1,1,0,3,12]
读取 32[1,3,0,3,12]
读取 123[1,3,12,3,12]
补零3[1,3,12,0,0];返回 None

时间复杂度
O(n):两遍循环总迭代数不超过 2n。

空间复杂度
O(1)。

选择与比较:最好理解;数组即使无零,第一遍也会发生自赋值。

解法二:交换到首个空位(一次扫描)

解题思路与正确性

维护非零区 [0, write)、零区 [write, read) 与未读区 [read, n)。遇到非零元素时,把它与零区首位交换。若 write==read,说明中间没有零,直接跳过自交换。

Python 3 · 可独立提交
from typing import List

class Solution:
    def moveZeroes(self, nums: List[int]) -> None:
        write = 0
        for read in range(len(nums)):
            if nums[read] != 0:
                if write != read:
                    nums[write], nums[read] = nums[read], nums[write]
                write += 1
关键代码解释
  • 交换后原来的 read 位置变成零,不需要另补尾部。
  • 每次选中的是输入中下一个非零元素,因此非零顺序保持不变。
  • 跳过自交换可以减少无零前缀上的写入,但一次交换通常包含两次列表写入,并不意味着任何输入都比补零版写得少。
示例推演

[0,1,0,3,12] 依次交换 (0,1)、(1,3)、(2,4):[1,0,0,3,12] → [1,3,0,0,12] → [1,3,12,0,0]。write 最终为 3。

时间复杂度
O(n):一次 read 扫描。

空间复杂度
O(1)。

选择与比较:无需第二遍,能跳过自交换;与补零法在具体写操作次数上的优劣取决于数据分布。

边界核对

输入预期结果
[0,0,0][0,0,0]
[1,2,3]保持 [1,2,3]
[0,1,0,1][1,1,0,0]

易错点

  • “非零元素稳定”是硬要求,不能随意拿末尾元素替换零。
  • 只压缩非零但不补尾部,会留下旧的非零值。
  • 不能构造非零列表后再拼零冒充 O(1) 空间。
面试复述 移动零是稳定过滤再恢复尾部,也可以维护一段已知零区。

STAGE 04 / 05候选排除与有序搜索

阶段目标:证明短板移动与有序和的移动方向。

09LEETCODE 11盛最多水的容器

原题 ↗中等

核心知识点:相向指针 · 贪心排除 · 短板证明

题意与接口

height[i] 表示位于横坐标 i 的竖线高度。选两条竖线,与横轴构成容器,返回最大面积。宽度是下标差,内部其他线段不占据水的体积;这与接雨水的柱子模型不同。

解法一:枚举所有边界(对照用)

解题思路与正确性

完整枚举每一对不同下标,计算面积并取最大。它不会漏解,可用于小输入的正确性对照;原题大规模输入应使用下一种线性算法。

Python 3 · 可独立提交
from typing import List

class Solution:
    def maxArea(self, height: List[int]) -> int:
        best = 0
        for left in range(len(height)):
            for right in range(left + 1, len(height)):
                area = (right - left) * min(height[left], height[right])
                best = max(best, area)
        return best
关键代码解释
  • right 从 left+1 开始,保证宽度为正并避免重复枚举。
  • 面积只看两个端点与它们的距离。
示例推演

对 [1,2,1],三对边界 (0,1)、(0,2)、(1,2) 的面积依次为 1、2、1,最大值为 2。

时间复杂度
O(n²):共有 n(n−1)/2 对。

空间复杂度
O(1)。

选择与比较:易验证但可能超时,不能把它当作原题高效提交方案。

解法二:每次舍弃较短边(推荐)

解题思路与正确性
area(left, right) = (right − left) × min(height[left], height[right])
  1. 从最宽的两端开始,先计算当前面积。
  2. 若左边较短,就丢弃左边;否则丢弃右边。
  3. 持续取最大值,直到两指针相遇。
Python 3 · 可独立提交
from typing import List

class Solution:
    def maxArea(self, height: List[int]) -> int:
        left, right = 0, len(height) - 1
        best = 0
        while left < right:
            area = (right - left) * min(height[left], height[right])
            best = max(best, area)
            if height[left] <= height[right]:
                left += 1
            else:
                right -= 1
        return best
关键代码解释
  • 必须先算面积再移动,否则会跳过最外层候选。
  • 较高边保持不动是因为短边限制水高;只移动高边不能突破当前短板。
  • 数组无需有序,单调性来自候选面积的上界,而不是高度排序。
示例推演
height=[1,8,6,2,5,4,8,3,7]面积动作
left=0,right=88×1=8左短,left→1
left=1,right=87×7=49右短,right→7
left=1,right=76×3=18right→6
left=1,right=65×8=40等高,本实现 left→2
继续收缩到相遇后续无更大面积返回 49

时间复杂度
O(n):每轮 left 或 right 移动一次。

空间复杂度
O(1)。

选择与比较:线性且空间常数;关键能力是解释为什么舍弃短边不漏最优解。

边界核对

输入预期结果
[1,1]1
[0,0]0
[1,2,1]2
[4,3,2,1,4]16

易错点

  • 宽度为 right-left,不能加 1。
  • 面积不能用两边最大高度,必须用 min。
  • 遇到等高可以移动一边;不要漏掉当前面积。
面试复述 双指针的依据不是“向内试试”,而是每次证明整批候选不可能更好。

10LEETCODE 15三数之和

原题 ↗中等

核心知识点:排序 · 固定一个数 · 相向扫描 · 三层去重

题意与接口

找出 nums 中所有和为 0 的三元组,三个元素必须来自不同下标。答案按数值组合去重;相同值可来自不同位置,例如 [0,0,0] 是合法三元组。返回顺序不限。

解法一:排序 + 固定锚点 + 双指针(推荐)

解题思路与正确性
  1. 排序后固定 i,把问题变成:在右侧后缀中找两个和为 -nums[i] 的数。
  2. 总和偏小,增大 left;总和偏大,减小 right。有序性保证可按方向排除候选。
  3. 遇到 0 时记录答案,移动两边并跳过重复值;外层也跳过相同锚点。
Python 3 · 可独立提交
from typing import List

class Solution:
    def threeSum(self, nums: List[int]) -> List[List[int]]:
        nums.sort()
        answer = []
        n = len(nums)
        for i in range(n - 2):
            if nums[i] > 0:
                break
            if i > 0 and nums[i] == nums[i - 1]:
                continue
            left, right = i + 1, n - 1
            while left < right:
                total = nums[i] + nums[left] + nums[right]
                if total < 0:
                    left += 1
                elif total > 0:
                    right -= 1
                else:
                    answer.append([nums[i], nums[left], nums[right]])
                    left += 1
                    right -= 1
                    while left < right and nums[left] == nums[left - 1]:
                        left += 1
                    while left < right and nums[right] == nums[right + 1]:
                        right -= 1
        return answer
关键代码解释
  • nums[i] > 0 才能提前结束;等于 0 时仍可能有 [0,0,0]。
  • 成功后先移动,再用 left-1 与 right+1 跳过刚刚使用的值。
  • nums.sort() 会修改传入列表;若必须保留输入,改为 nums = sorted(nums),并计入复制空间。
  • 固定 i 的每次扫描 O(n),总计 O(n²);Python 的原地 sort 在最坏情况下仍可能使用 O(n) 辅助空间。
示例推演
排序后 [-4,-1,-1,0,1,2]left / right动作
i=0,值 -41 / 5 起所有和偏小,本轮无解
i=1,值 -12 / 5-1-1+2=0,记录 [-1,-1,2]
仍 i=13 / 4-1+0+1=0,记录 [-1,0,1]
i=2,值 -1—重复锚点跳过
i=3,值 04 / 5和为 3,缩右后相遇;最终两个组合

时间复杂度
O(n²):排序 O(n log n),n 次线性扫描占主导。

空间复杂度
辅助 O(n):按 Python 排序最坏空间计;双指针本身 O(1)。另有 O(k) 输出,k 为答案三元组数量,最坏 O(n²)。

选择与比较:推荐面试方案;若只分析扫描阶段,辅助空间为 O(1),但不能忽略 Python sort 的实现成本。

解法二:固定一个数 + 哈希查找

解题思路与正确性

固定 i,向右枚举 j,用 seen 存储 i 和 j 之间已扫描的数,寻找补数 need。将三元组规范成有序元组放入答案集合,从而去重。这里排序的永远只有 3 个数,是常数工作。

Python 3 · 可独立提交
from typing import List

class Solution:
    def threeSum(self, nums: List[int]) -> List[List[int]]:
        answer = set()
        for i in range(len(nums) - 2):
            seen = set()
            for j in range(i + 1, len(nums)):
                need = -nums[i] - nums[j]
                if need in seen:
                    answer.add(tuple(sorted((nums[i], need, nums[j]))))
                seen.add(nums[j])
        return [list(triple) for triple in answer]
关键代码解释
  • 先查询后加入 seen,保证补数来自更早的另一位置,避免重复使用 nums[j]。
  • seen 每个 i 都必须重新创建。
  • 答案用集合去重,最终转回列表以满足提交返回类型。
示例推演

nums=[-1,0,1,2,-1,-4]:固定首个 -1,j=1 时 seen={0};j=2 的值 1 需要 0,得到 (-1,0,1);j=4 的值 -1 需要 2,得到 (-1,-1,2)。其他轮次生成的相同元组由 answer 集合消除。

时间复杂度
平均 O(n²):假设集合查询与插入平均 O(1);哈希冲突严重时不保证同样界。

空间复杂度
辅助 O(n),另有 O(k) 答案集合与返回列表。

选择与比较:保留输入且易连接两数之和思路;答案集合与重复扫描带来额外常数,排序双指针更便于系统去重。

边界核对

输入预期结果
[0,0,0,0][[0,0,0]]
[0,1,1][]
[-2,0,0,2,2][[-2,0,2]]
[]、[1,2](扩展边界)[]

易错点

  • 不能先 set(nums),那会丢掉重复值的数量,例如 [-1,-1,2]。
  • 外层去重和成功后的左右去重各有职责,不能只做一种。
  • 成功后必须移动指针,否则陷入死循环。
  • 剪枝依赖已排序:排序前不能用 nums[i]>0 判断无解。
面试复述 排序提供移动方向,去重决定输出唯一性;这两件事要分别证明。

STAGE 05 / 05进阶边界与匹配

阶段目标:解释未知区、可结算位置与失配回退。

11LEETCODE 75颜色分类

原题 ↗中等

核心知识点:三路划分 · 荷兰国旗 · 未知区间

题意与接口

数组只包含 0、1、2,原地排列为所有 0 在前、1 居中、2 在后。不能调用内置 sort;进阶要求常数额外空间和一趟扫描,返回 None。

解法一:计数后写回

解题思路与正确性

先统计三个值的个数,再按 0、1、2 的顺序写回。值域恒为 3,所以计数数组是常数空间。它需要两阶段遍历,满足基础要求,不满足一趟扫描的进阶目标。

Python 3 · 可独立提交
from typing import List

class Solution:
    def sortColors(self, nums: List[int]) -> None:
        counts = [0, 0, 0]
        for value in nums:
            counts[value] += 1
        write = 0
        for value in range(3):
            for _ in range(counts[value]):
                nums[write] = value
                write += 1
关键代码解释
  • counts 长度固定为 3,与输入长度无关。
  • 写回的内层循环总次数为 n,不是 3n²。
示例推演

[2,0,2,1,1,0] → counts=[2,2,2] → 写两个 0、两个 1、两个 2,得到 [0,0,1,1,2,2]。

时间复杂度
O(n):统计 n 次、写回 n 次。

空间复杂度
O(1):只用三个计数。

选择与比较:最易实现和核验,适合对照;无法体现一趟扫描的区间维护。

解法二:三路划分(进阶推荐)

解题思路与正确性
[0, low): 0 | [low, current): 1 | [current, high]: 未知 | (high, n): 2
  1. 遇到 0:与 low 交换,0 区扩大;low 原先若在 current 之前,其值必为 1,换回来的值已经确定,因此 current 可推进。
  2. 遇到 1:它直接进入中间 1 区,current 前进。
  3. 遇到 2:与 high 交换并缩小 high;换回来的元素原属于未知区,必须留在当前位置重新检查。
Python 3 · 可独立提交
from typing import List

class Solution:
    def sortColors(self, nums: List[int]) -> None:
        low = current = 0
        high = len(nums) - 1
        while current <= high:
            if nums[current] == 0:
                nums[low], nums[current] = nums[current], nums[low]
                low += 1
                current += 1
            elif nums[current] == 1:
                current += 1
            else:
                nums[current], nums[high] = nums[high], nums[current]
                high -= 1
关键代码解释
  • current <= high 包含最后一个未知元素。
  • 与 high 交换后不能 current += 1,否则可能漏掉换来的 0。
  • low、current、high 是三条边界;“双指针专题”也包含由多个边界扩展的原地划分思想。
示例推演
low / current / high(处理前)动作数组(处理后)
0 / 0 / 52 换到尾部,high→4[0,0,2,1,1,2]
0 / 0 / 40 自交换,low/current→1[0,0,2,1,1,2]
1 / 1 / 40 自交换,low/current→2[0,0,2,1,1,2]
2 / 2 / 42 与尾端 1 交换,high→3[0,0,1,1,2,2]
2 / 2 / 3 → 2 / 3 / 3两次遇到 1,current→4未知区空,返回 None

时间复杂度
O(n):未知区间每轮缩短一个位置。

空间复杂度
O(1)。

选择与比较:一趟完成原地划分;区间定义比背诵 if 分支更重要。算法对带有额外身份信息的对象并不保证稳定。

边界核对

输入预期结果
[2,0,1][0,1,2]
[2,2,2]保持 [2,2,2]
[0]保持 [0]
[](扩展边界)保持 []

易错点

  • 遇到 2 只缩 high,不移动 current。
  • 不能把循环写成 current<high,否则最后一个未知位置未检查。
  • 一趟扫描不等于每个位置只访问一次,而是整体操作数线性、无需重新遍历整数组。
面试复述 交换对象来自哪个区域,决定当前指针能不能推进。

12LEETCODE 42接雨水

原题 ↗困难

核心知识点:前后缀极值 · 相向结算 · 单调栈

题意与接口

给定非负柱高数组,每根柱子宽为 1,求柱间能积存的总水量。柱子本身占据空间;每个位置的水量取决于两侧能形成的最高边界,而非只选两个端点。

解法一:前后缀最大值(理解公式)

解题思路与正确性
water[i] = min(max(height[0:i+1]), max(height[i:n])) − height[i]

分别预计算每个位置左侧(含自身)和右侧(含自身)的最高柱子。水位由较矮的边界决定,再减去本柱高度。因为最大值包含自身,差值天然非负。

Python 3 · 可独立提交
from typing import List

class Solution:
    def trap(self, height: List[int]) -> int:
        n = len(height)
        if n == 0:
            return 0
        left_max = [0] * n
        right_max = [0] * n
        left_max[0] = height[0]
        for i in range(1, n):
            left_max[i] = max(left_max[i - 1], height[i])
        right_max[-1] = height[-1]
        for i in range(n - 2, -1, -1):
            right_max[i] = max(right_max[i + 1], height[i])
        water = 0
        for i in range(n):
            water += min(left_max[i], right_max[i]) - height[i]
        return water
关键代码解释
  • left_max 与 right_max 都含当前位置,所以首尾贡献为 0。
  • 右侧扫描方向必须从 n-2 向 0,保证 right_max[i+1] 已计算。
  • 先判断空数组再访问首尾下标。
示例推演
height=[4,2,0,3,2,5]各位置值
left_max[4,4,4,4,4,5]
right_max[5,5,5,5,5,5]
逐列水量[0,2,4,1,2,0]
总水量9

时间复杂度
O(n):三次线性遍历。

空间复杂度
O(n):两个长度 n 的最大值数组。

选择与比较:公式直观,适合先掌握并作为双指针的正确性对照。

解法二:比较两侧最高边界(推荐)

解题思路与正确性
  1. 保留尚未结算的闭区间 [left, right];每轮先把当前边界柱计入两侧最高值。
  2. 若 left_max ≤ right_max,则左侧水位已经可以确定为 left_max,立即结算 left。
  3. 否则右侧水位确定为 right_max,结算 right。每轮只结算一个位置,直到区间为空。
Python 3 · 可独立提交
from typing import List

class Solution:
    def trap(self, height: List[int]) -> int:
        left, right = 0, len(height) - 1
        left_max = right_max = 0
        water = 0
        while left <= right:
            left_max = max(left_max, height[left])
            right_max = max(right_max, height[right])
            if left_max <= right_max:
                water += left_max - height[left]
                left += 1
            else:
                water += right_max - height[right]
                right -= 1
        return water
关键代码解释
  • 先更新 max,再做减法,水量不会为负。
  • 本实现比较 left_max 与 right_max,不要混搭另一种“比较当前柱高”的循环与更新顺序。
  • left <= right 恰好把每个位置结算一次;空数组 right=-1,不进入循环。
示例推演
left / right(结算前)left_max / right_max本次新增 → 累计
0 / 54 / 5左:4−4=0 → 0
1 / 54 / 5左:4−2=2 → 2
2 / 54 / 5左:4−0=4 → 6
3 / 54 / 5左:4−3=1 → 7
4 / 54 / 5左:4−2=2 → 9
5 / 55 / 5左:5−5=0 → 9;结束

时间复杂度
O(n):每轮结算一个位置。

空间复杂度
O(1):两个极值、两个下标和累计水量。

选择与比较:由前后缀公式压缩而来,达到常数空间;务必解释为何只需较小的一侧极值。

解法三:单调栈按层结算

解题思路与正确性

栈内保存高度非递增的柱子下标。当前柱比栈顶高时,弹出的柱子是凹槽底部;新的栈顶是左边界,当前柱是右边界,按水平层计算新增水量。继续弹栈可结算更高层。

新增面积 = (right − left − 1) × (min(height[left], height[right]) − height[bottom])
Python 3 · 可独立提交
from typing import List

class Solution:
    def trap(self, height: List[int]) -> int:
        stack = []
        water = 0
        for right, h in enumerate(height):
            while stack and h > height[stack[-1]]:
                bottom = stack.pop()
                if not stack:
                    break
                left = stack[-1]
                width = right - left - 1
                depth = min(height[left], h) - height[bottom]
                water += width * depth
            stack.append(right)
        return water
关键代码解释
  • 存下标才能同时计算横向距离和纵向高度。
  • 宽度减 1,排除左右两根边界柱。
  • 栈高度允许相等;相等柱暂不弹出,后续可能产生深度为 0 的层,不影响结果。
示例推演

对 [4,2,0,3,2,5]:right=3(高 3)弹出下标 2(底 0),左界 1(高 2),新增 1×2=2;再弹下标 1,左界 0,新增 2×1=2,累计 4。right=5 先形成 1×1=1,再形成 4×1=4,累计 9。最后弹出最左柱时栈空,不结算。

时间复杂度
O(n):每个下标入栈一次、出栈至多一次,内层 while 的总工作线性。

空间复杂度
O(n):最坏递减数组时栈保存所有下标。

选择与比较:适合联系单调栈与凹槽结构;空间不如双指针,但能按层解释积水形成。

边界核对

输入预期结果
[0,1,0,2,1,0,1,3,2,1,2,1]6
[4,2,0,3,2,5]9
[1,2,3] 或 [3,2,1]0
[2,0,2]2
[] 或 [1](含扩展边界)0

易错点

  • 不能把本题直接写成最大容器面积,内部柱子占空间。
  • 每列水位取两侧最高边界的 min,不能取 max。
  • 只看相邻两柱不够,远处高柱也可能形成边界。
  • 单调栈中计算宽度要减 1,弹栈后必须检查是否还有左边界。
面试复述 容器题排除候选边界,接雨水题结算已确定位置;移动方向相似,证明对象不同。

13LEETCODE 28找出字符串中第一个匹配项的下标

原题 ↗简单

核心知识点:连续匹配 · 朴素双游标 · KMP 前缀函数

题意与接口

给定 haystack 与 needle,返回 needle 在 haystack 中第一次完整连续出现的起始下标,找不到返回 -1。原题 needle 非空;代码额外约定空模式返回 0。本题要求连续,不能沿用子序列的跳跃匹配。

解法一:逐个起点尝试匹配

解题思路与正确性
  1. 按从小到大的顺序枚举起点 start,只有 start≤n-m 时后面才有足够空间。
  2. 用 j 同时推进模式下标和文本相对下标,逐个比较连续字符。
  3. 完整匹配就返回当前 start,天然保证第一次;失配则换下一个起点重新匹配。
Python 3 · 可独立提交
class Solution:
    def strStr(self, haystack: str, needle: str) -> int:
        n, m = len(haystack), len(needle)
        if m == 0:
            return 0
        for start in range(n - m + 1):
            j = 0
            while j < m and haystack[start + j] == needle[j]:
                j += 1
            if j == m:
                return start
        return -1
关键代码解释
  • range(n-m+1) 包含最后一个合法起点。m>n 时范围为空,自然返回 -1。
  • 不使用切片,所以每次匹配只需常数额外空间。
  • 失配必须重置 j,从新的起点重新开始;不能仅跳过当前坏字符。
示例推演

haystack="aab", needle="ab":start=0 时先匹配 a,第二位 a≠b,失败;start=1 时 a、b 均匹配,返回 1。若 needle="ac",两个合法起点都失败,返回 -1。

时间复杂度
m≤n 时 O((n−m+1)m),通常简记 O(nm);m>n 时 O(1)。

空间复杂度
O(1)。

选择与比较:结构直观,适合建立连续匹配概念;重复前缀会导致大量重比。

解法二:KMP 前缀函数(进阶)

解题思路与正确性

prefix[i] 表示 needle[0:i+1] 的最长相等真前缀与后缀的长度;真前缀不能等于整个串。扫描文本时 j 表示已匹配的模式长度。失配后,无须让文本指针回退,只需把 j 调整到上一段可复用的前后缀长度。

  1. 构建 prefix:对每个 i,若 needle[i] 无法接在当前前缀后,就反复 j=prefix[j-1]。能接上则 j 加一,记为 prefix[i]。
  2. 搜索前将 j 重置为 0,逐字符扫描 haystack。失配时用同样的前后缀链回退 j。
  3. j 达到 m 时,模式末尾位于 i,起点为 i-m+1;马上返回,因此得到第一次匹配。
Python 3 · 可独立提交
class Solution:
    def strStr(self, haystack: str, needle: str) -> int:
        m = len(needle)
        if m == 0:
            return 0
        prefix = [0] * m
        j = 0
        for i in range(1, m):
            while j > 0 and needle[i] != needle[j]:
                j = prefix[j - 1]
            if needle[i] == needle[j]:
                j += 1
            prefix[i] = j

        j = 0
        for i, ch in enumerate(haystack):
            while j > 0 and ch != needle[j]:
                j = prefix[j - 1]
            if ch == needle[j]:
                j += 1
            if j == m:
                return i - m + 1
        return -1
关键代码解释
  • 构建 prefix 时 i 从 1 开始,prefix[0]=0。
  • 回退是 j = prefix[j - 1],不是 j-=1,也不是 prefix[j]。
  • 回退用 while,不是 if,因为可能连续失配多次。
  • prefix 存的是长度;j 既是已匹配长度,也是下一待比较模式下标,达到 m 后立即返回,避免下次访问 needle[m]。
示例推演
needle="ababac"prefix / j含义或动作
前缀表[0,0,1,2,3,0]末尾 c 与 b、b、a 均无法形成前后缀
haystack="ababababac",i=0..4j=5已匹配 ababa
i=5,ch=bj:5→3→4先回退到长度 3,再匹配 b;文本 i 不回退
i=6,ch=aj=5延长匹配
i=7,ch=bj:5→3→4重复使用后缀 abab
i=8、9,ch=a、cj:4→5→6返回 9−6+1=4

时间复杂度
O(n+m):前缀表 O(m),文本扫描 O(n),总回退次数也为线性。

空间复杂度
O(m):保存前缀表。

选择与比较:重复前缀较多、文本较长时更稳定;实现与证明成本较高。工程上可用 haystack.find(needle),面试需先说明原理,不能给其内置实现随意标注统一 O(nm) 或 O(n+m)。

边界核对

输入预期结果
"sadbutsad", "sad"0
"leetcode", "leeto"-1
"abc", "c"2
"abc", "abcd"-1
"ababababac", "ababac"4
"abc", ""(扩展边界)0

易错点

  • 子串要求连续,不能像判断子序列那样跳过中间文本字符后继续累计。
  • 最后合法起点是 n-m,不能遗漏。
  • KMP 搜索前必须将 j 重置为 0。
  • 前缀函数是长度,不是最后下标;与使用 -1 哨兵的 next 数组写法不能混用。
面试复述 朴素匹配让起点重试,KMP 复用已匹配段的前后缀结构。

知识点与复杂度速查

下表选取推荐解法(无推荐标记则取首个实现);n、m 与输出空间的定义以题目卡片为准。

题目知识点时间空间
344 · 反转字符串相向指针 · 原地交换 · 闭区间O(n):最多交换 ⌊n/2⌋ 次。O(1):仅使用两个下标和常数个临时引用。
125 · 验证回文串相向指针 · 字符过滤 · 大小写归一化O(n):两个指针各自单调移动,嵌套 while 不会导致 O(n²)。O(1):没有构造过滤后的字符串。
1768 · 交替合并字符串双序列指针 · 尾部处理 · joinO(m+n):m、n 分别是两个字符串长度。O(m+n):字符列表及最终结果;即使不计返回值,列表仍占线性空间。
392 · 判断子序列双序列扫描 · 贪心匹配 · 多次查询O(n):n = len(t),这份代码始终遍历整个 t;m = len(s)。O(1):仅保存匹配下标。
88 · 合并两个有序数组逆向双指针 · 额外写指针 · 防止覆盖O(m+n):每次确定一个最终位置;n=0 时直接结束。O(1):没有新数组,也没有列表切片。
27 · 移除元素读写指针 · 有效前缀 · 稳定性O(n):每个元素读取一次。O(1)。
26 · 删除有序数组中的重复项有序性 · 读写指针 · 相邻去重O(n)。O(1):没有集合或新列表。
283 · 移动零稳定划分 · 非零前缀 · 原地交换O(n):两遍循环总迭代数不超过 2n。O(1)。
11 · 盛最多水的容器相向指针 · 贪心排除 · 短板证明O(n):每轮 left 或 right 移动一次。O(1)。
15 · 三数之和排序 · 固定一个数 · 相向扫描 · 三层去重O(n²):排序 O(n log n),n 次线性扫描占主导。辅助 O(n):按 Python 排序最坏空间计;双指针本身 O(1)。另有 O(k) 输出,k 为答案三元组数量,最坏 O(n²)。
75 · 颜色分类三路划分 · 荷兰国旗 · 未知区间O(n):未知区间每轮缩短一个位置。O(1)。
42 · 接雨水前后缀极值 · 相向结算 · 单调栈O(n):每轮结算一个位置。O(1):两个极值、两个下标和累计水量。
28 · 找出字符串中第一个匹配项的下标连续匹配 · 朴素双游标 · KMP 前缀函数m≤n 时 O((n−m+1)m),通常简记 O(nm);m>n 时 O(1)。O(1)。

面试决策与不变量对照

识别信号优先考虑证明重点
首尾比较或交换相向指针外侧已确定,中间无需重复处理
两个输入合并或匹配双序列游标每次消费下一合法字符或极值
原地过滤,前 k 位有效读写指针有效前缀正确,不覆盖未读数据
有序数组上的两数和排序与相向扫描和偏小舍左,和偏大舍右
最大容器移动短边固定短边的剩余候选均被当前面积支配
三种类别原地排序荷兰国旗从未知区换入的值要重查
两侧最高边界决定水位前后缀极值 → 双指针较小极值一侧已经可以结算
连续匹配且有重复前缀KMP失配后只保留合法的相等前后缀
三路划分的零区、一区、未知区、二区全是 0全是 1尚未检查全是 2lowcurrenthigh[0, low) [low, current) [current, high] (high, n)未知区换回的元素必须重查,已知 1 区换回的元素无需重查。
颜色分类的四区间不变量;high 仍属于未知区。
柱高四二零三二五,每列积水零二四一二零,总量九42+20+43+12+25蓝色为积水:2 + 4 + 1 + 2 = 9
按每列柱顶积水求和;必须扣除内部柱体占据的面积。

三个易混对照

对照关键区别
392 子序列 / 28 子串子序列允许跳跃;子串必须连续,失配需重试或回退模式
27 移除元素 / 283 移动零27 返回 k,尾部任意;283 返回 None,尾部必须零,非零保序
11 最大容器 / 42 接雨水11 求一对边界的最大面积;42 求所有柱顶水量总和并扣除柱体

5 阶段计划与间隔复习

安排任务闭卷自测
Day 1 · 45–60 分钟344、125;基础与模板 A相遇条件、连续标点、数字保留
Day 2 · 60–90 分钟1768、392、88;模板 B任一输入先结束;m=0/n=0;逆向合并证明
Day 3 · 60–90 分钟27、26、283;模板 C有效前缀不变量;返回 k 与返回 None
Day 4 · 90–120 分钟11、15;从枚举到排除短板证明;[0,0,0,0] 的三层去重
Day 5 · 120–150 分钟,可拆两天75、42、28;模板 D/E未知区、雨水结算、独立构建 KMP 前缀表
首次学习后 +1 / +3 / +7 天闭卷重做错误题与证明薄弱题先写不变量,再写代码,最后用最小反例核对

若仍需看答案才能解释移动规则,暂不标记掌握,记录失败用例并回到同模板基础题。建议闭卷重写 27、88、11、15、75、42,将 28 的 KMP 作为进阶复述题。

掌握程度自测

题目勾选表示能独立实现;自测检查跨题迁移能力,单独保存,不会自动计入题目进度。

闭卷纠错

两个嵌套循环,所以一定是 O(n²)?

应计算指针总移动次数。回文跳过标点的两指针不回退,总 O(n);三数之和每个锚点重扫后缀,才是 O(n²)。

颜色分类换到右端后,current 也应前进?

反例 [1,2,0]:2 换回 0 后若跳过当前位置,会留下 [1,0,2];未知元素必须重查。

Python 原地 sort,辅助空间必然 O(1)?

原地只表示结果写回原列表,内部仍可能使用 O(n) 缓冲。双指针扫描本身 O(1),排序与答案输出须另计。

移除元素后应把 nums[k:] 全设为零?

第 27 题只要求前 k 位合法,尾部任意;第 283 题才要求尾部全零,且返回 None。

使用说明与来源

严格覆盖输入的 13 道题,没有替换题目。每题包含题意、完整可提交代码、关键解释、推演、复杂度、边界与易错点。各解法可单独复制提交,图解全部内联。

题号、标题、难度和题目语义已对照各卡片中的力扣官方原题。动态页面的访问可能受网络或登录状态影响;完整约束请查原题。解法没有声称已在线提交,采用本地边界、穷举与随机对照验证。

进度与主题保存在当前浏览器对此文件的存储中,不保证跨浏览器、隐私窗口或文件移动后共享;存储禁用时会提示,当前会话仍可使用。打印可调用浏览器打印或另存 PDF。

参考:本地 leetcode_binary_tree_study_guide.html 的目录布局、代码样式、进度与主题机制。内容按双指针的学习依赖重新编排。版本:2026-10-27。