‹ ScaleSync / LC Hot 100
LeetCode 热题 100 · 图解讲解 · 剑指 offer 结构

Hot 100 全解:按知识点串讲

100 题按类别组织。顶部导航先进入该类的规律、技巧与题目索引,再由你选择具体题目学习;每题走题目 → 思路(暴力→最优) → 图解 → 代码 → 复杂度 → 测试用例 → 考点·关联七段。

🧭 跨专题学习路线选下一类题或阶段复盘时再看默认收起 · 点击展开

这不是每题必读的前置知识。它只回答两个问题:当前专题学完后可以迁移到哪里,以及遇到陌生题时应从哪条主线开始判断。点击蓝色专题进入该类的规律与题目索引,点击 LC 题号直接进入代表题。

01 · 从“快速查找”到“连续区间”

先用哈希解决是否出现、出现几次;再用左右指针缩小候选;当目标变成最长或最短连续区间时升级为滑窗,字符频次类问题再叠加子串计数。

代表题:LC1LC15LC76LC560

02 · 从“一维位置”到“二维坐标”

数组题先盯住读写位置与原地覆盖;矩阵题是在此基础上再维护行列边界、方向和坐标映射。共同核心是:每个位置何时读、何时写、是否会破坏旧值。

代表题:LC189LC54LC48LC240

03 · 从“节点连接”到“搜索空间”

链表训练节点身份和指针重连;树把一个节点扩展成递归子问题;图再加入 visited 与环;回溯则在 DFS 上增加“做选择—递归—撤销选择”。

04 · 从“单调性”到“候选集合”

二分利用答案或数组的单调边界;单调栈维护尚未结算的候选;堆只保留当前最重要的一批候选;贪心则要证明局部选择不会堵死后续答案。

代表题:LC34LC84LC215LC45

05 · 从“重复子问题”到“状态转移”

发现暴力递归反复计算同一状态后,先写清状态含义、转移来源、初始值和遍历方向;状态只依赖一维历史时用普通 DP,同时依赖两个维度时进入多维 DP。

代表题:LC198LC416LC62LC72

06 · 跨专题技巧工具箱

位运算、投票计数、快慢指针和原地置换不是独立大体系,而是可以嵌入多类题的压缩工具。先判断它替代了哪个显式数据结构,再决定是否值得使用。

代表题:LC136LC169LC287
跨专题学习路线 ↘

哈希

哈希类的核心套路是空间换时间:把"查找某个值是否存在 / 出现几次 / 对应什么"从 O(n) 扫描降到 O(1) 命中。三种常见形态——用 dict 存"值→下标"(LC 1)、用规范化 key 分组(LC 49)、用 set 做 O(1) 归属判断(LC 128)。它和其它类别高度耦合:滑动窗口几乎都靠哈希计数维护窗口状态(LC 438/76),子串问题常用"前缀和 + 哈希"把两层循环压成一层(LC 560),双指针里 LC 1 的进阶 LC 15 也可用哈希实现。可以说哈希是很多高级技巧的底层零件。

两数之和

LC 1 ↗哈希 · Easy
1 · 题目

要完成的任务

给定整数数组 nums 和整数 target,找出两个不同位置,使这两个位置上的数之和等于 target,返回它们的下标。答案中的两个下标可按任意顺序排列。

字段与保证

nums[i] 是下标 i 处的整数;同一个数组元素不能重复使用。题目保证每组输入恰好存在一个有效答案。

全部示例

示例 1:nums=[2,7,11,15], target=9[0,1],因为 nums[0]+nums[1]=9

示例 2:nums=[3,2,4], target=6[1,2]

示例 3:nums=[3,3], target=6[0,1];两个 3 来自不同下标。

约束与进阶

  • 2 ≤ nums.length ≤ 104
  • -109 ≤ nums[i], target ≤ 109
  • 只存在一个有效答案;进阶要求设计时间复杂度低于 O(n²) 的算法。
2 · 思路(暴力→最优)

暴力:双层循环枚举所有下标对 (i,j),判 nums[i]+nums[j]==target,O(n²)。瓶颈在内层——每固定一个 x,都要重新扫一遍数组去找它的另一半。

正解:边扫描边建哈希表。
① 定义历史状态:seen 保存“已经扫描过的值 → 下标”,只代表当前位置左侧。
② 计算补数:当前值为 x 时,只需查询 need=target-x,把重复线性查找降为 O(1)。
③ 先查后存:命中就返回 [seen[need], index];未命中才写入当前值,因此同一元素不会自配。
④ 非平凡样例:[3,3], target=6 中,第一个 3 查空表后写入,第二个 3 才命中前一个下标。

取舍:哈希法用 O(n) 空间把时间从 O(n²) 降到 O(n);若数组已排序,也可双指针 O(1) 额外空间,但本题还要返回原下标,需额外保留下标映射。

⚠ 易错坑 顺序不能反:必须"先查 need,再存 x"。若先存后查,遇到 target=2x(如 [3,3],6)会把刚存进去的自己当成另一半,错误返回 [i,i]
哈希表存的是「值 → 下标」而非「下标 → 值」,因为要拿补数的去反查它的下标。
若数组有重复值,后写的下标会覆盖先写的——本题保证唯一解、只需任一组下标,无妨;但若要返回所有配对就不能简单覆盖。
3 · 图解
里程碑①  主例开始:nums=[2,7,11,15],target=9,seen={}
里程碑②  i=0,x=2:need=7 未命中;本轮结束 seen={2:0}
里程碑③  i=1,x=7:need=2 命中历史下标 0;返回 [0,1]
里程碑④  重复值例 [3,3]:第一个 3 先查空表,再写 seen={3:0}
里程碑⑤  第二个 3 查询时尚未写入自身,只会命中下标 0,不会得到 [1,1]
哈希扫描:主例展示循环主线,重复值例验证“先查后存”不会使用同一下标
阶段输入现场补数查询时 seen判定与动作
0主例初始化{}等待扫描
1主例 i=0,x=27{}未命中,轮末写入 {2:0}
2主例 i=1,x=72{2:0}命中并返回 [0,1]
3重复值例 i=0,x=33{}先查未命中,再写入 {3:0}
4重复值例 i=1,x=33{3:0}命中下标 0,返回 [0,1],不会自配
4 · 代码
class Solution:
    def twoSum(self, nums, target):
        # 1. seen 只保存当前位置之前的“值 → 下标”
        seen = {}

        # 2. 每轮先查补数,未命中再记录当前值
        for index, value in enumerate(nums):
            complement = target - value
            if complement in seen:
                return [seen[complement], index]
            seen[value] = index
        return []
5 · 复杂度

时间 O(n),每个元素做一次补数查询并至多写入哈希一次;空间 O(n),最坏存下几乎所有元素。相比暴力 O(n²) 用 O(n) 空间换来一个数量级的时间提升。

6 · 测试用例

功能[2,7,11,15],9 → [0,1]边界:最短两元素 [3,3],6 → [0,1](重复值也要能命中);特殊:含负数 [-1,-2,-3],-5 → [1,2]、目标依赖后面元素 [3,2,4],6 → [1,2](先查后存保证不选到自身)。

字母异位词分组

LC 49 ↗哈希 · Medium
1 · 题目

要完成的任务

给定字符串数组 strs,把互为字母异位词的字符串放进同一组并返回所有分组。字母异位词由完全相同的字符及次数组成,只是排列顺序可以不同;分组顺序和组内顺序均不作要求。

全部示例

示例 1:strs=["eat","tea","tan","ate","nat","bat"][["bat"],["nat","tan"],["ate","eat","tea"]]。其中 eat/tea/ate 同组,tan/nat 同组,bat 单独成组。

示例 2:strs=[""][[""]]

示例 3:strs=["a"][["a"]]

约束

  • 1 ≤ strs.length ≤ 104
  • 0 ≤ strs[i].length ≤ 100,因此空字符串是合法输入。
  • strs[i] 只包含小写英文字母。
2 · 思路(暴力→最优)

暴力:两两比较是否互为异位词(各自排序后判等),O(n²·k),n 个词、词长 k。

正解:规范化指纹 + 哈希分桶。
① 定义等价条件:异位词的字母及出现次数完全相同,只是排列顺序不同。
② 生成稳定指纹:排序法用 ''.join(sorted(word));仅含小写字母时也可用 tuple(counts)
③ 按指纹分桶:groups[key].append(word),最终返回所有桶,无需两两比较。
④ 非平凡样例:eat/tea/ate 都映射到 aettan/nat 映射到 ant,两组不会串桶。

取舍:排序指纹代码短,时间 O(n·k log k);26 位计数指纹时间 O(n·k),但只适用于字符集已知且固定的场景,并多维护一个计数数组。

⚠ 易错坑 key 必须可哈希:list 不能当 dict key——排序法要 ''.join(sorted(s)) 拼成字符串,计数法要 tuple(cnt) 转元组。
存进桶的是原词 s,指纹只是 key,别把"排序后的字符串"误存进结果。
空串 "" 是合法输入,其指纹为 "" / 全 0 元组,会与其它空串同桶、或单独成桶。
3 · 图解
strs=["eat","tea","tan","ate","nat","bat"]
里程碑①  groups={}
里程碑②  eat→aet,tea→aet:{aet:[eat,tea]}
里程碑③  tan→ant:{aet:[eat,tea], ant:[tan]}
里程碑④  ate→aet,nat→ant:{aet:[eat,tea,ate], ant:[tan,nat]}
里程碑⑤  bat→abt:形成第三桶;返回三个桶的列表
解法① 排序指纹:每个单词完成“生成指纹并入桶”后记录一次
wordkey本轮摘要groups(处理后)
0初始化{}
1eataet新建 aet 桶并加入{aet:[eat]}
2teaaet命中已有桶{aet:[eat,tea]}
3tanant新建 ant 桶{aet:[eat,tea],ant:[tan]}
4ate / nataet / ant分别加入已有桶{aet:[eat,tea,ate],ant:[tan,nat]}
5batabt新建第三桶三个分组完成
解法② 计数指纹:按单词汇总计数结果,不展开逐字符自增
word计数指纹(仅列非零项)本轮摘要groups(处理后)
0初始化{}
1eata1,e1,t1 = A建立 A 桶{A:[eat]}
2teaa1,e1,t1 = A同一计数元组,命中 A 桶{A:[eat,tea]}
3tana1,n1,t1 = B计数不同,新建 B 桶{A:[eat,tea],B:[tan]}
4空串全 0 = Z空串也有稳定指纹新增 Z 桶
4 · 代码
from collections import defaultdict

class Solution:
    def groupAnagrams(self, strs):
        # 1. 相同排序指纹共享同一个桶
        groups = defaultdict(list)
        for word in strs:
            signature = ''.join(sorted(word))
            groups[signature].append(word)
        return list(groups.values())
from collections import defaultdict

# 计数元组变体:字符集固定为 26 个小写字母
class Solution:
    def groupAnagrams(self, strs):
        groups = defaultdict(list)
        for word in strs:
            # 1. 为当前单词构造不可变的计数指纹
            counts = [0] * 26
            for character in word:
                counts[ord(character) - ord('a')] += 1

            # 2. list 不可哈希,转 tuple 后再归组
            signature = tuple(counts)
            groups[signature].append(word)
        return list(groups.values())
5 · 复杂度

时间 O(n·k log k),n 个词、每个排序 k log k(k 为词长);用计数元组当 key 可降到 O(n·k)。空间 O(n·k),存所有词。

6 · 测试用例

功能:上例 6 词分 3 组;边界:含空串 [""] → [[""]]、单词 ["a"] → [["a"]]特殊:全部互为异位词 ["abc","bca","cab"] → 一组、无任何异位词全是独立组。

最长连续序列

LC 128 ↗哈希 · Medium
1 · 题目

要完成的任务

给定未排序整数数组 nums,返回其中最长“连续整数序列”的长度。这里的连续指数值依次相差 1,元素不要求在原数组中相邻;目标算法需达到 O(n) 时间。

全部示例

示例 1:nums=[100,4,200,1,3,2]4,对应序列 [1,2,3,4]

示例 2:nums=[0,3,7,2,5,8,4,6,0,1]9,连续值为 0 到 8;重复的 0 不增加长度。

示例 3:nums=[1,0,1,2]3

约束

  • 0 ≤ nums.length ≤ 105,空数组答案为 0。
  • -109 ≤ nums[i] ≤ 109
  • 数组可以包含重复值,且输入没有预先排序。
2 · 思路(暴力→最优)

暴力:排序后线扫,相邻连续就 +1、断开就重置,O(n log n)。但题目要 O(n),排序这一步就超了。

正解:哈希集合 + 只从序列起点扩展。
① 建立集合:values=set(nums) 同时去重,并把成员查询降到均摊 O(1)。
② 识别起点:仅当 value-1 not in values 时,当前值才是一段连续序列的最左端。
③ 向右扩展:从起点不断检查 value+length,直到第一个缺口,再更新最长长度。
④ 非平凡样例:[100,4,200,1,3,2] 中只有 100、200、1 是起点;从 1 一次扩到 4,得到长度 4。

取舍:用 O(n) 集合空间换得均摊 O(n) 时间。虽然代码有嵌套 while,但非起点不会扩展,每个集合元素只属于一段、只被该段访问一次;最坏理论保证仍取决于哈希实现。

⚠ 易错坑 必须先转 set:一为 O(1) 查询,二为去重——[1,2,2,3] 里重复的 2 不该让长度虚增。
起点判据是 x−1 not in set,不能反用 x+1;只从最小端起数才不会从中间重复计数。
遍历用去重后的 set 而非原 nums(重复值会让外层多跑几次,虽结果对但白费);空数组要返回 0。
3 · 图解
nums=[100,4,200,1,3,2] → values={1,2,3,4,100,200}
里程碑①  100 的前驱 99 不在集合:起点,序列 [100],best=1
里程碑②  4 的前驱 3 在集合:不是起点,跳过
里程碑③  200 的前驱 199 不在集合:序列 [200],best 仍为 1
里程碑④  1 是起点,连续命中 2、3、4,遇 5 停止
里程碑⑤  本段长度 4,更新 best=4;2、3 后续均因有前驱而跳过
起点法:按候选值或完整序列汇总,不展开每次 length 自增
value前驱存在?阶段 / 本轮摘要本段长best
0建集合并初始化 best=00
110099 不存在扩展 [100]11
243 存在非起点,跳过1
3200199 不存在扩展 [200]11
410 不存在一次扩展完整段 [1,2,3,4]44
52 / 3前驱存在均非起点,跳过4
4 · 代码
class Solution:
    def longestConsecutive(self, nums):
        # 1. 去重,并支持均摊 O(1) 的成员查询
        values = set(nums)
        longest = 0

        # 2. 只有没有前驱的值才启动一次完整扩展
        for value in values:
            if value - 1 in values:
                continue

            length = 1
            while value + length in values:
                length += 1
            longest = max(longest, length)
        return longest
5 · 复杂度

时间 O(n),虽有内层 while,但只有起点才进入、每个数被"向上数"访问至多一次;空间 O(n) 存 set。别被双重循环误导成 O(n²)——非起点会被 continue 挡掉。

6 · 测试用例

功能[100,4,200,1,3,2] → 4边界:空数组 [] → 0、单元素 [5] → 1特殊:含重复 [1,2,2,3] → 3(set 去重)、含负数与断层 [0,-1,1] → 3

双指针

双指针用两个索引在数组上协同移动,把很多 O(n²) 的枚举压到 O(n)。两大形态:对撞指针(一头一尾向中间夹,靠单调性决定移谁,见 LC 11/15/42)和快慢指针(同向一读一写,做原地覆盖,见 LC 283)。它是几个类别的母题:滑动窗口就是"同向双指针 + 窗口状态"的变长特例(left/right 都只前进);子串里的 LC 76 本质是带哈希计数的滑窗;对撞指针能成立的前提往往是先排序(LC 15),这又和哈希解法(LC 1)形成对比——排序双指针省空间,哈希省时间。掌握"移动哪个指针 + 为什么"是关键。

移动零

LC 283 ↗双指针 · Easy
1 · 题目

要完成的任务

给定整数数组 nums,把所有 0 移到数组末尾,同时保持所有非零元素原有的相对顺序。必须直接修改输入数组,不能复制出另一份完整数组;返回值为 None,力扣展示的是修改后的 nums

全部示例

示例 1:nums=[0,1,0,3,12] → 修改为 [1,3,12,0,0]

示例 2:nums=[0][0]

约束与进阶

  • 1 ≤ nums.length ≤ 104
  • -231 ≤ nums[i] ≤ 231-1
  • 需要原地完成,并尽量减少写入或交换次数。
2 · 思路(暴力→最优)

暴力:新开一个数组,先按序放入所有非零元素、再补足 0,O(n) 时间但 O(n) 空间,违反"原地"要求。

正解:快慢(读写)指针。只抓住三步:
① 定义待填位:slow 永远指向“下一个非零数该放的位置”。
② 扫描并交换:fast 遇到非零数,就与 slow 位置交换,再让 slow += 1
③ 遇零跳过:fast 遇到 0 时只继续扫描,等待后面的非零数把它换走。

为什么保序:非零元素被 fast 访问的先后顺序,就是它们在原数组里的先后顺序;而每个非零都被依次放到单调递增的 slow 位置,故相对顺序不变。为什么末尾自然全是 0fast 扫完后 [0, slow) 装着全部非零,剩下的 [slow, n) 就只能是被换到后面的 0。

⚠ 易错坑 slow 只在写入一个非零之后+= 1;遇到 0 绝不推进 slow——否则 0 会占掉待填位,把顺序冲乱。
本实现用 swap,这样一趟扫描就把 0 换到后方;若改成 nums[slow] = nums[fast] 的覆盖写法,扫描结束后还必须再把尾部补成 0。
数组前缀没有 0 时,slowfast 同步前进,swap 是"自己和自己交换"的 no-op,这不是多余——它让不变量在两种情况下统一成立。
3 · 图解
样例:[0, 1, 0, 3, 12]      slow = 下一个非零待填位

① 遇到 0:跳过          [0, 1, 0, 3, 12]   slow=0
② 遇到 1:与位置0交换   [1, 0, 0, 3, 12]   slow=1
③ 遇到 3:与位置1交换   [1, 3, 0, 0, 12]   slow=2
④ 遇到12:与位置2交换   [1, 3, 12, 0, 0]   slow=3(完成)
每个 fast 位置只记一轮结果;交换内部的两次赋值不拆开
fast(值)nums[fast]≠0 ?动作数组现场slow
初始slow=0[0,1,0,3,12]0
10 (0)跳过[0,1,0,3,12]0
21 (1)交换位置 0、1[1,0,0,3,12]0→1
32 (0)跳过[1,0,0,3,12]1
43 (3)交换位置 1、3[1,3,0,0,12]1→2
54 (12)交换位置 2、4[1,3,12,0,0]2→3

扫完后 [0,3)=[1,3,12] 是保序非零,[3,5)=[0,0] 自然是 0。注意第 1、3 步 slow 停着不动,让 0 攒在 slow 位等着被后面的非零换走。

4 · 代码
class Solution:
    def moveZeroes(self, nums):
        # 1. slow 指向下一个非零数应该放入的位置
        slow = 0

        # 2. fast 按原顺序读取;只有读到非零数才写入并推进 slow
        for fast in range(len(nums)):
            if nums[fast] != 0:
                nums[slow], nums[fast] = nums[fast], nums[slow]
                slow += 1
5 · 复杂度

时间 O(n),单趟扫描;空间 O(1),原地交换、无额外数组。交换写法天然稳定(保序)。

6 · 测试用例

功能[0,1,0,3,12] → [1,3,12,0,0]边界:空 []、单元素 [0] / [1]特殊:无零 [1,2,3] 不变、全零 [0,0,0]、零在开头/结尾。

盛最多水的容器

LC 11 ↗双指针 · Medium
1 · 题目

要完成的任务

给定长度为 n 的整数数组 height。第 i 条竖线连接坐标 (i,0)(i,height[i])。选择两条线与 x 轴组成容器,返回能容纳的最大水量;容器不能倾斜。

两线下标为 leftright 时,宽度为 right-left,有效高度由较短线决定,因此面积为 min(height[left],height[right])×(right-left)

全部示例

示例 1:height=[1,8,6,2,5,4,8,3,7]49;下标 1 与 8 的高度分别为 8、7,宽度 7,面积 49。

盛最多水的容器示例九条竖线高度依次为 1、8、6、2、5、4、8、3、7;选择下标 1 和 8 的竖线,中间水面高度为 7,面积是 49。下标 1 与 8 围成面积 49 的容器
蓝色区域展示示例 1 的最大容器。

示例 2:height=[1,1]1

约束

  • n == height.length2 ≤ n ≤ 105
  • 0 ≤ height[i] ≤ 104
2 · 思路(暴力→最优)

暴力:枚举所有线对 (i, j) 求面积取最大,O(n²)。

正解:对撞指针。从最宽的左右两端开始:
① 计算当前面积:min(height[left], height[right]) × (right-left),较矮的线是容器短板。
② 移动短板:宽度下一步一定变小,只有换掉短板才可能用更高的边弥补宽度损失。
③ 保留最大值:每轮结算一次,直到两指针相遇。

为什么不漏解:每移动一次短板,等于一次性排除掉"以当前短板为一端的所有更窄组合"(它们的宽都比现在小、短板还不变,面积都不会超过刚结算的这个)。所以两指针相向收缩、每根线只经过一次,O(n) 扫完即得全局最优。

⚠ 易错坑 循环条件是 while left < right(严格小于),两指针相遇即停;写成 <= 会算出宽度为 0 的无效面积。
先结算面积、再移指针。顺序反了会漏掉起步时最宽的那次面积。
两线等高时移动哪一侧都行(左右都是短板,移任一都不会错过更优解),代码里归入 elseright 即可,不必特判。
3 · 图解
height = [1, 8, 6, 2, 5, 4, 8, 3, 7]

① left=0(1), right=8(7):面积 1×8=8   → 左边矮,left 右移
② left=1(8), right=8(7):面积 7×7=49  → 更新答案 49,right 左移
③ left=1(8), right=6(8):面积 8×5=40  → 仍小于 49
④ 后续宽度继续缩小,最大值不再改变

结论:最大容器由下标 1 和 8 围成,答案 49。
对撞指针走 [1,8,6,2,5,4,8,3,7]:右列是每步的面积、best、以及"谁矮 → 移谁"
left (h)right (h)面积 = min×宽best_area谁矮 → 移谁
10 (1)8 (7)1×8=88左矮,left→1
21 (8)8 (7)7×7=4949右矮,right→7
31 (8)7 (3)3×6=1849右矮,right→6
41 (8)6 (8)8×5=4049等高,right→5
51 (8)5 (4)4×4=1649右矮,right→4
61 (8)4 (5)5×3=1549右矮,right→3
71 (8)3 (2)2×2=449右矮,right→2
81 (8)2 (6)6×1=649right→1,结束

注意第 1 步后 left 一直停在 idx1(值 8,全场最高)没再动过——因为之后每一步都是右侧更矮,始终在移 right。移高侧只会更小,正是"移短板"贪心的体现。

4 · 代码
class Solution:
    def maxArea(self, height):
        # 1. 从宽度最大的两端开始
        left, right = 0, len(height) - 1
        best_area = 0

        while left < right:
            # 2. 结算当前容器,并保留目前最大面积
            width = right - left
            current_area = min(height[left], height[right]) * width
            best_area = max(best_area, current_area)

            # 3. 宽度必然缩小,所以只移动限制高度的短板
            if height[left] < height[right]:
                left += 1
            else:
                right -= 1

        return best_area
5 · 复杂度

时间 O(n),两指针共走 n 步;空间 O(1)。贪心正确性:移动高侧不可能变优,故只需移短侧。

6 · 测试用例

功能[1,8,6,2,5,4,8,3,7] → 49边界:两条线 [1,1] → 1特殊:递增 [1,2,3,4] → 4、全等高 [5,5,5] → 10、含 0。

三数之和

LC 15 ↗双指针 · Medium
1 · 题目

要完成的任务

给定整数数组 nums,找出所有三元组 [nums[i],nums[j],nums[k]],要求三个下标两两不同且三个数之和为 0。返回结果中不能出现重复三元组;三元组内部顺序与结果整体顺序均不作要求。

全部示例

示例 1:nums=[-1,0,1,2,-1,-4][[-1,-1,2],[-1,0,1]]。原数组虽然存在多组下标组合,但去重后只有这两个值三元组。

示例 2:nums=[0,1,1][],唯一三元组的和不为 0。

示例 3:nums=[0,0,0][[0,0,0]]

约束

  • 3 ≤ nums.length ≤ 3000
  • -105 ≤ nums[i] ≤ 105
  • 输入可以含重复值,但输出按三元组的值去重。
2 · 思路(暴力→最优)

暴力:三重循环枚举所有三元组,O(n³),还要额外去重。

正解:排序 + 固定一个数 + 对撞指针。
① 排序:让“左指针右移会变大、右指针左移会变小”成立。
② 固定 nums[index]把三数之和降成在右侧区间寻找两数之和 -nums[index]
③ 对撞查找:三数和太小就移动 left,太大就移动 right,等于 0 就收集答案。
④ 跳过重复:固定位置以及命中后的左右两端都要去重。

去重是本题灵魂:结果要求三元组之间不重复,而排序后重复值相邻,于是在三个位置各跳过重复——外层 index、命中后的 left、命中后的 right

⚠ 易错坑 三处去重缺一不可:外层 index > 0 and nums[index]==nums[index-1] 跳过;命中收集后 while nums[left]==nums[left-1] 跳左、while nums[right]==nums[right+1] 跳右。
去重要在"命中并收集之后"才跳,不能在收集前就跳,否则像 [0,0,0] 会漏掉第一个合法组合。
剪枝:排序后一旦 nums[index] > 0,最小数都为正、三数和不可能为 0,直接 break;外层 index 只需到 n-3(即 range(n-2))。
3 · 图解
排序后:[-4, -1, -1, 0, 1, 2]

① 固定 -4:右侧最大两数和为 1+2=3,连同 -4 仍只有 -1,整轮无解
② 固定 -1:left=-1,right=2  → 和为 0,收集 [-1,-1,2]
③ 继续内缩:left=0,right=1 → 和为 0,收集 [-1,0,1]
④ 下一个 -1 与前一个固定数重复,整轮跳过
⑤ 固定 0:最小组合 0+1+2>0,无解

答案:[[-1,-1,2], [-1,0,1]]
排序后走 [-4,-1,-1,0,1,2]:右两列是当前三数和 s 与"移哪个指针"的依据
index (值)left (值)right (值)current_sum判定 → 动作
10 (-4)1 (-1)5 (2)-3太小,left→2
20 (-4)2 (-1) → 3 (0)5 (2)-3 → -2连续两轮都太小,left 依次右移
30 (-4)4 (1)5 (2)-1本轮无解
41 (-1)2 (-1)5 (2)0收集 [-1,-1,2],两端内缩
51 (-1)3 (0)4 (1)0收集 [-1,0,1],本轮结束
62 (-1)与前一个固定数重复跳过整轮
73 (0)4 (1)5 (2)3太大,内缩后结束

index=0 那轮 left 一路右移始终凑不够(固定数太小),空手而归;index=2 因为和 index=1 同为 −1 被去重整轮跳过——正是这一步避免了重复的 [-1,-1,2]

4 · 代码
class Solution:
    def threeSum(self, nums):
        # 1. 排序后,双指针才具有可控的增减方向
        nums.sort()
        triplets = []

        # 2. 固定第一个数,把问题降为右侧区间的两数之和
        for index in range(len(nums) - 2):
            if nums[index] > 0:
                break
            if index > 0 and nums[index] == nums[index - 1]:
                continue

            left, right = index + 1, len(nums) - 1
            while left < right:
                current_sum = nums[index] + nums[left] + nums[right]

                # 3. 利用有序性调整当前和
                if current_sum < 0:
                    left += 1
                elif current_sum > 0:
                    right -= 1
                else:
                    triplets.append([nums[index], nums[left], nums[right]])
                    left += 1
                    right -= 1

                    # 4. 命中后跳过相同端点,避免重复三元组
                    while left < right and nums[left] == nums[left - 1]:
                        left += 1
                    while left < right and nums[right] == nums[right + 1]:
                        right -= 1

        return triplets
5 · 复杂度

时间 O(n²):排序 O(n log n) + 外层 n × 内层双指针 O(n)。空间 不计输出时,Python list.sort()(Timsort)最坏需要 O(n) 辅助空间;双指针扫描本身只用 O(1)。用哈希也能做,但排序双指针更好去重。

6 · 测试用例

功能[-1,0,1,2,-1,-4] → [[-1,-1,2],[-1,0,1]](组内与组间顺序按本实现排序后输出);边界:不足 3 个元素返回 []特殊:全零 [0,0,0,0] → [[0,0,0]](考去重)、无解 [1,2,3] → []、多重复值。

接雨水

LC 42 ↗双指针 · Hard
1 · 题目

要完成的任务

给定 n 个非负整数 height,每个数表示宽度为 1 的柱子高度。下雨后,水会停在柱子之间且不能高于左右挡板;返回整张高度图能够接住的雨水总量。

全部示例

示例 1:height=[0,1,0,2,1,0,1,3,2,1,2,1]6

接雨水示例高度图十二根宽度为 1 的柱子高度依次为 0、1、0、2、1、0、1、3、2、1、2、1,低洼处合计容纳 6 个单位雨水。蓝色低洼区域合计 6 个单位
示例 1 的柱形与蓄水结构示意。

示例 2:height=[4,2,0,3,2,5]9

约束

  • n == height.length1 ≤ n ≤ 2×104
  • 0 ≤ height[i] ≤ 105
2 · 思路(暴力→最优)

暴力:位置 i 能存的水 = min(左侧最高, 右侧最高) − height[i];对每个 i 都向左右各扫一遍求两个最高,O(n²)。

解法② 前缀数组。把"每个位置的左边最高、右边最高"预先算好:left_max[index] 从左往右扫(不降的左包络),right_max[index] 从右往左扫(不升的右包络)。之后每格 total_water += min(left_max[index], right_max[index]) − height[index],O(n) 时间但要 O(n) 额外空间存两条包络线。

正解①:对撞双指针。把两条前缀数组压缩成两个变量:
① 维护左右最高:left_maxright_max 分别记录已经走过区域的最高柱。
② 处理较矮一侧:若左端更矮,右侧已有更高柱兜底,左格水量可立即确定为 left_max-height[left];反之结算右格。
③ 向中间推进:每格只结算一次,直到指针相遇。

⚠ 易错坑 判据是比较两端柱高 height[left] vs height[right] 来决定处理哪侧,不是比较 left_maxright_max
先更新 left_max/right_max 再累加left_max = max(left_max, height[left]) 之后 total_water += left_max − height[left],保证非负(当前柱比历史最高还高时贡献 0)。
循环 while left < right,相遇即停;两端各只被处理一次,中间相遇位置无需重复累加。
3 · 图解
缩小例:[1, 0, 2, 0, 1]      答案 = 2

① 两端同高,先结算右端:水量 0,right→3
② 位置3高度0,右侧最高1:接 1 格水,累计 1
③ 左端高度1小于中间高度2:结算左端 0 格
④ 位置1高度0,左侧最高1:接 1 格水,累计 2
⑤ 指针在高度2处相遇;中间柱贡献必为0,无需单独结算

最终柱高+水位:[1,1,2,1,1](下标1、3两个凹槽各补 1 格)
解法① 对撞双指针:每轮合并记录“更新最高、结算水量、移动指针”
left (h)right (h)谁矮 → 处理left_max / right_max本格储水total_water
初始0 (1)4 (1)准备对撞0 / 00
10 (1)4 (1)等高,处理右端0 / 11−1=00
20 (1)3 (0)处理右侧凹槽0 / 11−0=11
30 (1)2 (2)处理左端1 / 11−1=01
41 (0)2 (2)处理左侧凹槽1 / 11−0=12

缩小例最终 total_water=2;上方原例按同样步骤得到 6。每轮严格按“更新 max → 累加 total_water → 移动一侧指针”的代码顺序执行。

解法② 前缀数组:按“建立左包络、建立右包络、逐格结算”三个阶段摘要
阶段heightleft_maxright_maxmin−height = 储水total_water
1全部[1,1,2,2,2]从左建立最高包络0
2全部[1,1,2,2,2][2,2,2,1,1]从右建立最高包络0
3位置1、31 / 22 / 1两个凹槽各贡献 12

缩小例两法答案都是 2(原例都是 6)。对撞双指针省掉两条包络数组,只保留 left_max/right_max 两个标量。

4 · 代码
class Solution:
    def trap(self, height):
        # 1. 从两端向中间走,并记录两侧已经见过的最高柱
        left, right = 0, len(height) - 1
        left_max = right_max = 0
        total_water = 0

        while left < right:
            # 2. 较矮一侧的封顶已经确定,可以立即结算该位置
            if height[left] < height[right]:
                left_max = max(left_max, height[left])
                total_water += left_max - height[left]
                left += 1
            else:
                right_max = max(right_max, height[right])
                total_water += right_max - height[right]
                right -= 1

        return total_water
# 解法二:前缀数组,O(n) 时间、O(n) 空间
class Solution:
    def trap(self, height):
        if not height:
            return 0

        # 1. 分别预处理每个位置两侧(含自身)的最高柱
        length = len(height)
        left_max = [0] * length
        right_max = [0] * length
        left_max[0] = height[0]
        for index in range(1, length):
            left_max[index] = max(left_max[index - 1], height[index])

        right_max[-1] = height[-1]
        for index in range(length - 2, -1, -1):
            right_max[index] = max(right_max[index + 1], height[index])

        # 2. 每格水位由左右包络中较低的一条决定
        total_water = 0
        for index in range(length):
            total_water += min(left_max[index], right_max[index]) - height[index]

        return total_water
5 · 复杂度

解法① 双指针:时间 O(n),空间 O(1);当 height[left]<height[right] 时右侧存在更高柱封顶,可安全结算左侧。解法② 前缀数组:时间 O(n),空间 O(n),额外保存 left_max/right_max 两条包络。

6 · 测试用例

功能[0,1,0,2,1,0,1,3,2,1,2,1] → 6边界:空 / 少于 3 根 [2,0] → 0特殊:单调递增或递减接不到水 [1,2,3] → 0、平台 [3,0,0,3] → 6

滑动窗口

滑动窗口是同向双指针的变长特例:left/right 都只前进,right 扩张纳入新元素、条件破坏时 left 收缩排出元素,从而 O(n) 枚举所有"合法连续区间"。窗口内部状态几乎都靠哈希计数维护(字符频次、和、种类数),所以它是双指针和哈希两个类别的交汇点。它和子串类别密不可分——LC 76 最小覆盖子串、LC 438 都是滑窗;区别在"求最长"时窗口不合法就缩、"求定长/覆盖"时按窗口大小或覆盖条件缩。判断"能否用滑窗"的信号是:连续子数组/子串 + 单调可扩可缩的判定条件

无重复字符的最长子串

LC 3 ↗滑动窗口 · Medium
1 · 题目

要完成的任务

给定字符串 s,返回其中不含重复字符的最长子串长度。子串必须由原字符串中连续的一段字符组成,不能跳过字符拼成子序列。

全部示例

示例 1:s="abcabcbb"3"abc""bca""cab" 都是长度 3 的合法答案。

示例 2:s="bbbbb"1,最长合法子串是单个 "b"

示例 3:s="pwwkew"3,可取 "wke""pwke" 不是连续子串,不能作为答案。

约束

  • 0 ≤ s.length ≤ 5×104,空字符串答案为 0。
  • s 可由英文字母、数字、符号和空格组成,并非只含小写字母。
2 · 思路(暴力→最优)

暴力起手:枚举所有 (i, j) 子串、检查内部有无重复,O(n³);即便用一个集合边扩边查也要 O(n²)。瓶颈在于每换一个左端点就把右边重扫一遍,完全没利用"上一个窗口已知无重复"这个信息。

正解:维护一个始终无重复的窗口。
① 右边界扩张:right 每次纳入一个新字符。
② 重复时跳过旧字符:若该字符上次出现的位置仍满足 last[c] >= left,说明旧字符还在窗口内,直接令 left=last[c]+1
③ 记录当前状态:更新 last[c],再用合法窗口长度 right-left+1 更新答案。
两个指针都只向右移动,因此每个字符至多进入和离开窗口一次,整体为 O(n)。

"abba" 走一遍。
① 读入 a:窗口为 "a"best=1
② 读入 b:窗口扩为 "ab"best=2
③ 再读 b:旧 b 的下标 1 仍在窗口内,所以 left 跳到 2,窗口缩成 "b"
④ 最后读 a:旧 a 的下标 0 已在窗口左侧,left 不动,窗口变成 "ba"。这一步说明判断条件不能只写 c in last

两种实现取舍。
① set 逐格缩:重复时逐个删除左侧字符,逻辑直观。
② dict 下标跳:利用最近下标让 left 一步越过旧字符;同为 O(n),但没有内层 while,本文采用这一版。

⚠ 易错坑 缩左判据必须是 c in last and last[c] >= left 两个条件同时成立;只判 c in last 会让 left 回退(见 "abba")。
best 的更新要放在 left 调整之后、用当前合法窗口长 right-left+1;放在缩左前会统计到含重复的非法窗口。
last[c] = right 这行要在缩左之后、对每个字符无条件更新,否则下次遇到同字符拿到的是过期下标。
3 · 图解
s = "a b b a"      下标 0 1 2 3      初始 left=0, last={}, best=0

r=0 后: [a]          last={a:0},             best=1
r=1 后: [a b]        last={a:0,b:1},         best=2
r=2 读到 b(旧 b=1 仍在窗口):
  执行 left=2 后: a b [b] a     只移动左边界,last 仍是 {a:0,b:1}
  执行 last[b]=2 后:             last={a:0,b:2},窗口仍为 "b"
r=3 读到 a(旧 a=0 已在窗口左侧):
  不执行 left 赋值;执行 last[a]=3 后,窗口为 [b a],best 仍为 2
dict 下标跳走 "abba":每个 right 一行,观察窗口何时收缩、何时保持
right纳入字符与处理left · last稳定窗口best / 判定
0纳入 a;窗口内无旧 a,记录最近位置left=0 · {a:0}"a" [0..0]best=1
1纳入 b;仍无重复,窗口扩为 "ab"left=0 · {a:0,b:1}"ab" [0..1]best=2 ← 最优
2b 在窗口内重复;left 跳过旧 b,再更新最近位置left=2 · {a:0,b:2}"b" [2..2]best=2
3旧 a 在窗口左侧;left 不动,只更新 a 的最近位置left=2 · {a:3,b:2}"ba" [2..3]返回 2
4 · 代码
class Solution:
    def lengthOfLongestSubstring(self, s):
        # 1. last 记录字符最近位置;[left, right] 始终保持无重复
        last = {}
        left = 0
        best = 0

        # 2. 右扩窗口;只有旧位置仍在窗口内时才移动 left
        for right, char in enumerate(s):
            if char in last and last[char] >= left:
                left = last[char] + 1

            # 3. 记录最新位置,再用当前合法窗口更新答案
            last[char] = right
            best = max(best, right - left + 1)

        return best
5 · 复杂度

时间 O(n),right 扫一遍、left 只前进;空间 O(min(n, Σ)),Σ 为字符集大小。用"下标跳跃"版比逐格排出的 while 版常数更小。

6 · 测试用例

功能"abcabcbb" → 3"pwwkew" → 3边界:空串 "" → 0、单字符 "a" → 1特殊:全相同 "bbbb" → 1、全不同 "abcdef" → 6、含空格/符号。

找到字符串中所有字母异位词

LC 438 ↗滑动窗口 · Medium
1 · 题目

要完成的任务

给定两个字符串 sp,找出 s 中所有长度等于 p.length、且与 p 互为字母异位词的连续子串,返回这些子串的起始下标。答案顺序不作要求。

字母异位词必须拥有完全相同的字符种类与出现次数,只允许排列顺序不同;不同匹配子串可以互相重叠。

全部示例

示例 1:s="cbaebabacd", p="abc"[0,6],对应子串是 "cba""bac"

示例 2:s="abab", p="ab"[0,1,2],三个匹配窗口依次为 "ab""ba""ab"

约束

  • 1 ≤ s.length, p.length ≤ 3×104
  • sp 只包含小写英文字母。
  • p.length > s.length 时不存在合法窗口,应返回空列表。
2 · 思路(暴力→最优)

暴力起手:对 s 中每个长度为 window_size=len(p) 的起点,取子串排序或计数后与 p 比较,共 n-window_size+1 个窗口、每个 O(window_size log window_size)~O(window_size),总 O(n·window_size)。浪费在相邻两窗口只差首尾一个字符,却每次从头重算整段计数。

正解:维护长度固定为 window_size 的字符计数。
① 建立目标:need 记录 p 中每个字符需要多少次。
② 平移窗口:每轮只让右侧字符计数 +1、离开的左侧字符计数 -1,中间字符无需重算。
③ 判断命中:当窗口长度为 window_size 且窗口计数与 need 完全相同时,记录窗口左端点。
相邻窗口只更新两个字符,因此扫描 s 一遍即可。

s="abab", p="ab" 走一遍。
① 初始窗口 "ab"计数为 {a:1,b:1},记录起点 0。
② 平移到 "ba"右进 a 后暂时超标,再移出旧 a,计数恢复相等,记录起点 1。
③ 平移到 "ab"右进 b、左出旧 b,计数再次恢复相等,记录起点 2。

两种判定方式。
① Counter 整表比较:每个窗口直接判断 window == need,代码短且不易出错;字符集固定时比较成本可视为 O(26)。
② matches 增量判定:matches 记录当前有多少种字符恰好达标,只有右进和左出的字符可能改变它;字符集较大时更省比较,但边界更容易写错。

matches 什么时候变化?只在计数跨过 need[c] 这个临界值时更新:
① 右进后刚好达标:matches += 1
② 右进后由达标变超标:matches -= 1
③ 左出后由超标恢复达标:matches += 1
④ 左出后由达标变不足:matches -= 1

⚠ 易错坑 定长窗口 left、right 同步走、大小恒为 window_size,别照搬 LC3 的"按条件 while 缩左"——这里从不因条件收缩,只平移。
解法①用 Counter 时,计数减到 0 必须 del 掉该键,否则 {a:0} != {} 会让 window==need 永远不成立。
解法②只在 need[c]>0 的字符上更新 matches,且加减要严格对应"跨过 need[c]"的四个时刻;对 need[c]==0 的多余字符不参与 matches。
记录起点是 right-window_size+1(窗口左端下标),别写成 right
3 · 图解
s = "a b a b"     p = "a b"     window_size=2     need={a:1,b:1}

初始: [a b] a b       window={a1,b1}           → result=[0]
right=2 先右进 a: [a b a] b   window={a2,b1}   (暂时 3 个字符)
        再左出 a:  a [b a] b   window={a1,b1}   → result=[0,1]
right=3 先右进 b:  a [b a b]  window={a1,b2}
        再左出 b:  a b [a b]  window={a1,b1}    → result=[0,1,2]

注意:右进和左出之间的“超长临时窗口”是真实中间态,不能直接画成平移后的最终窗口。
解法① Counter 走 "abab":每次窗口平移一行,临时超长态由上方可视化帧展示
阶段窗口操作稳定窗口windowresult / 判定
初始建立 need 与窗口 [0..1],计数相等[ab]ab{a1,b1}result=[0]
i=2右进 a、左出旧 a,窗口平移到 [1..2]a[ba]b{a1,b1}命中 → [0,1]
i=3右进 b、左出旧 b,窗口平移到 [2..3]ab[ab]{a1,b1}命中 → [0,1,2]
解法② need+matches 走 "abab":每个 right 一行,关注达标种类数如何随窗口平移恢复
right本轮概括window[a,b]matchesresult / 判定
0纳入 a:a 达标,窗口尚未满[1,0]1 / required=2result=[]
1纳入 b:a、b 都达标,记录起点 0[1,1]2result=[0]
2a 右进先超标,旧 a 左出后复位达标[1,1]2result=[0,1]
3b 右进先超标,旧 b 左出后复位达标[1,1]2返回 [0,1,2]
4 · 代码
from collections import Counter

class Solution:
    def findAnagrams(self, s, p):
        # 1. 窗口长度固定为 p 的长度;主串过短时直接结束
        window_size = len(p)
        if len(s) < window_size:
            return []

        # 2. 建立目标计数与第一个定长窗口
        need = Counter(p)
        window = Counter(s[:window_size])
        result = [0] if window == need else []

        # 3. 每轮右进一个字符、左出一个字符,窗口大小保持不变
        for right in range(window_size, len(s)):
            enter_char = s[right]
            leave_char = s[right - window_size]
            window[enter_char] += 1
            window[leave_char] -= 1

            # Counter 比较会保留零计数键,因此减到 0 时必须删除
            if window[leave_char] == 0:
                del window[leave_char]

            # 4. 频次完全相同,记录当前窗口的左端点
            if window == need:
                result.append(right - window_size + 1)

        return result
# 解法二:用 matches 记录“频次恰好达标”的字符种类数
class Solution:
    def findAnagrams(self, s, p):
        # 1. 初始化目标频次和固定窗口状态
        window_size = len(p)
        if len(s) < window_size:
            return []

        need = [0] * 26
        window = [0] * 26
        for char in p:
            need[ord(char) - ord('a')] += 1

        required = sum(1 for count in need if count > 0)
        matches = 0
        result = []

        # 2. 右侧字符进入;只在计数跨过目标值时修正 matches
        for right, char in enumerate(s):
            enter_index = ord(char) - ord('a')
            window[enter_index] += 1
            if need[enter_index] > 0:
                if window[enter_index] == need[enter_index]:
                    matches += 1
                elif window[enter_index] == need[enter_index] + 1:
                    matches -= 1

            # 3. 窗口超长时移出左侧字符,并修正它的达标状态
            if right >= window_size:
                leave_char = s[right - window_size]
                leave_index = ord(leave_char) - ord('a')
                window[leave_index] -= 1
                if need[leave_index] > 0:
                    if window[leave_index] == need[leave_index]:
                        matches += 1
                    elif window[leave_index] == need[leave_index] - 1:
                        matches -= 1

            # 4. 窗口已满且所有目标字符都达标,记录左端点
            if right >= window_size - 1 and matches == required:
                result.append(right - window_size + 1)

        return result
5 · 复杂度

时间 O(n)(字符集固定,Counter 比较视作 O(1)~O(26));空间 O(1),最多 26 个键。删零键是为了让 window==need 成立。

6 · 测试用例

功能s="cbaebabacd",p="abc" → [0,6]s="abab",p="ab" → [0,1,2]边界len(s)<len(p) 返回 []特殊:p 全相同 p="aa"、s 中无匹配、s 与 p 等长。

子串

"子串/子数组"类聚焦连续区间上的统计问题,三种主力工具:前缀和 + 哈希(求"和为 K 的子数组"数量,LC 560)、滑动窗口 + 哈希计数(求最短覆盖窗口,LC 76)、单调队列(求定长窗口极值,LC 239)。它是哈希滑动窗口两类的应用场:前缀和把"区间和"转成"两点之差",再用哈希 O(1) 查另一半(呼应 LC 1 两数之和);LC 76 本质是变长滑窗(呼应 LC 3);LC 239 用单调队列维护窗口最值(对比 LC 42 的对撞极值思想)。识别口诀:和 → 前缀和哈希;最值 → 单调队列;覆盖/最短 → 滑窗。

和为 K 的子数组

LC 560 ↗子串 · Medium
1 · 题目

要完成的任务

给定整数数组 nums 和整数 k,统计和恰好等于 k非空连续子数组数量并返回。不同起止下标算不同子数组,即使它们包含的值相同。

数组元素允许为负数、0 和正数,所以窗口和不会随边界单调变化,不能直接套只适用于正数数组的滑动窗口。

全部示例

示例 1:nums=[1,1,1], k=22,两个子数组分别覆盖下标 [0,1][1,2]

示例 2:nums=[1,2,3], k=32,合法子数组是 [1,2][3]

约束

  • 1 ≤ nums.length ≤ 2×104
  • -1000 ≤ nums[i] ≤ 1000
  • -107 ≤ k ≤ 107
2 · 思路(暴力→最优)

暴力起手:子数组由左右端点 (i,j) 确定,枚举所有区间再逐个求和是 O(n³);预处理前缀和让"求某段区间和"变 O(1),降到 O(n²)。但固定右端点后,仍要回头找所有满足条件的左端点——这一步能不能也变 O(1)?能,用哈希。

正解:前缀和 + 哈希计数。
① 把区间和改写成两点之差:若当前前缀和为 prefix,某段子数组和为 k,就必须存在更早的前缀和 prefix-k
② 查询历史:prefix_count[prefix-k] 表示有多少个合法左端点,这些出现次数都要加入答案。
③ 再记录当前前缀:必须先查后存,避免在 k=0 时把当前前缀和自己配对,误算一个空子数组。

nums=[1,2,3], k=3 走一遍。
① 初始:prefix=0,预置 prefix_count={0:1},代表空前缀。
② 读到 1:prefix=1,历史中没有 -2,答案仍为 0。
③ 读到 2:prefix=3,命中历史前缀 0,对应子数组 [1,2]
④ 读到 3:prefix=6,命中历史前缀 3,对应子数组 [3],最终答案为 2。

为什么不能用滑动窗口?数组含负数,右端加入新数后窗口和不一定增大,左端移走元素后也不一定减小;“和太大就缩左边”的单调性不存在,因此不能靠双指针排除区间。

⚠ 易错坑 prefix_count[0] = 1 必须预置。它代表"空前缀"(还没加任何元素时前缀和为 0),用来统计从下标 0 就开始的子数组:当某个 prefix 恰好等于 k 时 prefix − k = 0,靠这颗种子才能命中。漏了它会少算一整类答案。
顺序必须先查后存:先 answer += prefix_count.get(prefix−k, 0),再用 prefix_count[prefix] = prefix_count.get(prefix, 0) + 1 存当前前缀。若反过来,当 k = 0prefix − k = prefix,会把"当前这个前缀和自己"也数进去,等于把长度为 0 的空子数组算成一个答案。
查的是 prefix − k 不是 prefix + k:要找的是"更早的、使差恰为 k 的前缀和",用当前减 k,方向写反直接错。
3 · 图解
nums = [1, 2, 3],k = 3

第 1 步|起点:prefix=0,prefix_count={0:1},answer=0
第 2 步|读 1:prefix=1,找 -2,未命中              answer=0
第 3 步|读 2:prefix=3,找  0,命中 [1,2]          answer=1
第 4 步|读 3:prefix=6,找  3,命中 [3]            answer=2

每次找的是“更早的 prefix-k”;所以本轮必须先查询,再存入当前 prefix。
前缀和 + 哈希走 [1,2,3]:每个元素一行,观察“先查历史,再存当前”
阶段读入 / prefix查找 prefix-k命中子数组answer · prefix_count
初始化prefix=0空前缀种子0 · {0:1}
读 1prefix=1−2:0 次0 · {0:1,1:1}
读 2prefix=30:1 次[1,2]1 · {0:1,1:1,3:1}
读 3prefix=63:1 次[3]2 · {0:1,1:1,3:1,6:1}
4 · 代码
class Solution:
    def subarraySum(self, nums, k):
        # 1. 空前缀出现一次,用于统计从下标 0 开始的子数组
        prefix_count = {0: 1}
        prefix = 0
        answer = 0

        # 2. 先查询历史 prefix-k,再记录当前 prefix
        for number in nums:
            prefix += number
            answer += prefix_count.get(prefix - k, 0)
            prefix_count[prefix] = prefix_count.get(prefix, 0) + 1

        return answer
5 · 复杂度

时间 O(n),单趟扫描 + O(1) 哈希;空间 O(n) 存前缀和。count[0]=1 是易错点,用来统计"从下标 0 起就等于 k"的子数组。

6 · 测试用例

功能[1,1,1],2 → 2[1,2,3],3 → 2边界:单元素 [3],3 → 1 / [1],0 → 0特殊:含负数 [1,-1,0],0 → 3k=0、全为 0 数组(大量子数组)。

滑动窗口最大值

LC 239 ↗子串 · Hard
1 · 题目

要完成的任务

给定整数数组 nums 和窗口大小 k。窗口最初覆盖数组最左侧的 k 个元素,此后每次向右移动一格;返回每个窗口中的最大值所组成的数组。

全部示例

示例 1:nums=[1,3,-1,-3,5,3,6,7], k=3[3,3,5,5,6,7]。六个窗口依次为 [1,3,-1][3,-1,-3][-1,-3,5][-3,5,3][5,3,6][3,6,7]

示例 2:nums=[1], k=1[1]

约束

  • 1 ≤ nums.length ≤ 105
  • -104 ≤ nums[i] ≤ 104
  • 1 ≤ k ≤ nums.length,因此窗口始终有效。
2 · 思路(暴力→最优)

暴力起手:每个窗口从头扫一遍求最大,共 n−k+1 个窗口,O(n·k)。想更快,自然想到用大顶堆维护窗口元素、取最大 O(1);但元素滑出窗口时堆里删不掉,得存 (值, 下标) 惰性删除,复杂度 O(n log k) 且实现繁。

正解:用单调递减队列保存“仍可能成为最大值”的下标。
① 清理队尾:新元素进入时,队尾所有小于或等于它的旧元素都不可能再成为答案,直接弹出。
② 加入新下标:队列中的值因此始终从队头到队尾递减,队头就是当前最大值。
③ 清理过期队头:queue[0] <= right-k,说明该下标已经离开窗口。
④ 记录答案:窗口形成后,把 nums[queue[0]] 加入结果。

为什么存下标而不是值?值只能告诉我们谁大,不能判断它是否已经滑出窗口;下标既能判过期,又能通过 nums[index] 取回值。堆可以做到 O(n log k),单调队列把每个下标控制为至多入队、出队各一次,因此是 O(n)。

[7,2,4], k=2 看两种弹出。
① 窗口 [7,2]队列为 [7,2],最大值是队头 7。
② 新元素 4 进入:队尾 2 比 4 小,从队尾弹出。
③ 窗口右移:旧下标 0 的值 7 已过期,从队头弹出;队列只剩 4,第二个最大值为 4。

⚠ 易错坑 队列存的是下标不是值:判过期靠下标 queue[0],取值用 nums[queue[-1]] / nums[queue[0]],两者别搞混。
弹队尾用 nums[queue[-1]] <= number(含相等也弹):相等的旧元素更早过期、留着没用,弹掉能让队列更短;写成 < 也对但会残留无用重复。
判过期是 queue[0] <= right − k,每步至多一个队头过期(不用 while);记录答案要等窗口填满,即 right >= k − 1 才开始 append。
3 · 图解
nums = [7, 2, 4],k = 2        队列从左到右保持递减

第 1 步|读 7:queue=[7],窗口还没满
第 2 步|读 2:queue=[7,2],窗口 [7,2] 的最大值是 7
第 3 步|读 4:先从队尾淘汰 2,queue=[7,4]
第 4 步|窗口右移:7 从队头过期,queue=[4],最大值是 4

队尾弹出:新值更有竞争力;队头弹出:旧下标已经离开窗口。
单调队列走 [7,2,4]:每个输入元素一行,区分队尾淘汰与队头过期
right / 值队尾处理过期处理queue(下标→值)result
0 / 7队空,直接加入[0→7][]
1 / 27>2,保留队尾[0→7,1→2][7]
2 / 42≤4,淘汰下标 1下标 0 随后过期[2→4][7,4]
结束[2→4]返回 [7,4]
4 · 代码
from collections import deque

class Solution:
    def maxSlidingWindow(self, nums, k):
        # 1. queue 存下标,对应值从队头到队尾递减
        queue = deque()
        result = []

        for right, number in enumerate(nums):
            # 2. 淘汰队尾不可能再成为最大值的旧元素
            while queue and nums[queue[-1]] <= number:
                queue.pop()
            queue.append(right)

            # 3. 队头下标离开窗口时,将它移出
            if queue[0] <= right - k:
                queue.popleft()

            # 4. 窗口形成后,队头对应值就是当前最大值
            if right >= k - 1:
                result.append(nums[queue[0]])

        return result
5 · 复杂度

时间 O(n),每个下标最多入队出队各一次(均摊 O(1));空间 O(k),队列长度不超过窗口。比堆的 O(n log k) 更优。

6 · 测试用例

功能[1,3,-1,-3,5,3,6,7],3 → [3,3,5,5,6,7]边界k=1(返回原数组)、k=len(nums)(返回单个全局最大);特殊:递减数组 [5,4,3,2],2 → [5,4,3]、含相同值、含负数。

最小覆盖子串

LC 76 ↗子串 · Hard
1 · 题目

要完成的任务

给定字符串 st,在 s 中寻找最短连续子串,使它包含 t 的全部字符以及各自的重复次数。若不存在返回空字符串;测试数据保证非空答案唯一。

“覆盖”只要求字符计数不少于 t,不要求顺序相同;返回的是子串本身,不是长度或下标。

全部示例

示例 1:s="ADOBECODEBANC", t="ABC""BANC"

示例 2:s="a", t="a""a",整个 s 就是最短覆盖。

示例 3:s="a", t="aa""",因为 s 中只有一个 a,无法满足重复次数。

约束与进阶

  • m == s.lengthn == t.length,且 1 ≤ m,n ≤ 105
  • st 只由英文字母组成,大小写字符彼此不同。
  • 若存在符合条件的子串,题目保证最短答案唯一。
  • 进阶要求把时间复杂度做到 O(m+n)。
2 · 思路(暴力→最优)

暴力起手:枚举所有子串 s[i:j],每个都数一遍是否覆盖 t,O(n²·|Σ|)。观察到:当右端点右移使窗口恰好覆盖 t 后,再往右扩只会更长,此时该缩左端找更短——左右端点都只需单向前进,于是变长滑窗登场。

正解:右扩找可行窗口,左缩寻找最短答案。
① 右边界扩张:不断纳入字符,直到窗口覆盖 t 的全部字符及次数。
② 覆盖后更新答案:当前窗口是一个可行解,先与历史最短窗口比较。
③ 左边界收缩:移出左侧字符,窗口仍覆盖就继续缩;一旦缺少必需字符,再恢复右扩。
④ 两指针单向移动:leftright 都不会回退,总移动次数为 O(n)。

如何 O(1) 判断已经覆盖?
need[c]表示窗口还缺多少个字符 c;负数表示该字符有富余。
missing表示总共还缺多少个必需字符,初值为 len(t)
③ 纳入字符:只有纳入前 need[c]>0,才真正补上一个缺口并令 missing-=1
④ 判定覆盖:missing==0 时无需再比较整张计数表,窗口已经覆盖 t。

s="ABAAC", t="AAC" 走一遍。
① 右扩到 C:窗口 "ABAAC" 首次覆盖,长度为 5。
② 移出第一个 A:它是富余字符,窗口 "BAAC" 仍覆盖,长度降为 4。
③ 再移出 B:B 不在目标中,窗口 "AAC" 仍覆盖,长度降为 3。
④ 再移出 A:窗口开始缺 A,停止收缩,最短答案就是 "AAC"

⚠ 易错坑 need[c] 可为,表示窗口里该字符有富余;missing 在补上/丢失必需字符(need > 0 的那一刻)时才变。把"多余字符"也算进 missing 会永远覆盖不了。
纳入时先判 if need[c] > 0: missing−=1need[c]−=1;收缩时先 need[s[left]]+=1 再判 if need[s[left]] > 0: missing+=1——顺序反了会多算或少算。
只在 missing==0 时更新最短答案;记录的是长度 + 左右下标,最后切片 s[l:r+1] 别忘 +1;无解返回 ""(best 初值设 inf 判别)。
3 · 图解
s = A B A A C,t = "AAC"       need 初始 {A:2,C:1}

第 1 步|右扩到 C:[A B A A C]  首次覆盖,best="ABAAC"
第 2 步|移出富余 A:A [B A A C] 仍覆盖,best="BAAC"
第 3 步|移出无关 B:A B [A A C] 仍覆盖,best="AAC"
第 4 步|再移出 A: A B A [A C]  缺少一个 A,停止收缩

结果:最短覆盖窗口是下标 [2..4] 的 "AAC"。
变长滑窗走 "ABAAC":右扩按字符概括,覆盖后的每次有效收缩各占一行
阶段纳入 / 移出need 关键状态missing · left窗口 / best
初始化建立 t="AAC"{A:2,C:1}3 · 0空窗口 / ∞
right=0纳入 AA:1,C:12 · 0"A" / ∞
right=1纳入 B(无关字符)A:1,B:−1,C:12 · 0"AB" / ∞
right=2纳入第二个必需 AA:0,B:−1,C:11 · 0"ABA" / ∞
right=3纳入富余 AA:−1,B:−1,C:11 · 0"ABAA" / ∞
right=4纳入 C,首次覆盖A:−1,B:−1,C:00 · 0"ABAAC" / 5
收缩两次依次移出富余 A、无关 BA:0,B:0,C:00 · 2"AAC" / 3
停止收缩移出必需 A,重新缺 1 个A:1,B:0,C:01 · 3返回 "AAC"
4 · 代码
from collections import Counter

class Solution:
    def minWindow(self, s, t):
        # 1. need 表示各字符还缺多少;missing 表示总缺口
        need = Counter(t)
        missing = len(t)
        left = 0
        best = (float('inf'), 0, 0)

        # 2. 右扩窗口,只有真正补上缺口时才减少 missing
        for right, char in enumerate(s):
            if need[char] > 0:
                missing -= 1
            need[char] -= 1

            # 3. 已覆盖时持续缩左,并记录更短的合法窗口
            while missing == 0:
                window_length = right - left + 1
                if window_length < best[0]:
                    best = (window_length, left, right)

                leave_char = s[left]
                need[leave_char] += 1
                if need[leave_char] > 0:
                    missing += 1
                left += 1

        # 4. best 未更新说明不存在覆盖窗口
        if best[0] == float('inf'):
            return ""
        return s[best[1]:best[2] + 1]
5 · 复杂度

时间 O(|s| + |t|),left/right 各扫一遍;空间 O(|Σ|) 存计数。need[c] 可为负表示窗口内多余字符,missing 只在补/丢必需字符时变。

6 · 测试用例

功能s="ADOBECODEBANC",t="ABC" → "BANC"边界s=t="a" → "a"len(s)<len(t) 返回 ""特殊:t 有重复字符 t="aa"(需两个 a)、无解返回 ""、s 与 t 完全相同。

普通数组

这一类是数组基础功的集中考场:Kadane(一维DP)、区间合并(排序+扫描)、原地翻转(三次 reverse)、前缀积、原地哈希。核心心法是"用 O(1) 额外空间在原数组上做文章"。它向上关联"动态规划"(53 最大子数组和就是最简 DP,可延伸到 152 乘积最大子数组)、关联"排序/区间"(56 合并区间的排序扫描套路和会议室问题同源)、关联"技巧"(41 缺失的第一个正数用的原地哈希与 287 寻找重复数、448 找消失数字同一招)。看似杂题,实则每题都在训练"降维/降空间"的敏感度。

最大子数组和

LC 53 ↗普通数组 · Medium
1 · 题目

要完成的任务

给定整数数组 nums,从中选择一个至少包含一个元素的连续子数组,使其元素和最大,返回这个最大和。只返回数值,不需要返回子数组下标。

全部示例

示例 1:nums=[-2,1,-3,4,-1,2,1,-5,4]6,最大和子数组是 [4,-1,2,1]

示例 2:nums=[1]1

示例 3:nums=[5,4,-1,7,8]23,整个数组就是最优子数组。

约束与进阶

  • 1 ≤ nums.length ≤ 105
  • -104 ≤ nums[i] ≤ 104
  • 必须选择至少一个元素,因此全负数组不能把空子数组当成和 0。
  • 进阶要求在 O(n) 解法之外,再尝试分治解法。
2 · 思路(暴力→最优)

暴力:枚举所有 (i,j) 子数组求和取最大,O(n²)(朴素三重循环更是 O(n³))。

正解①:Kadane / 一维 DP。
① 定义局部状态:current_sum 表示“必须以当前位置结尾”的最大和。
② 决定接续或重开:current_sum=max(value,current_sum+value);负前缀只会拖累当前值时就丢弃。
③ 记录全局峰值:best_sum 保存所有结尾状态中的最大值,不能只返回最后一个 current_sum。
④ 具体例子:[-2,1,-3,4,-1,2,1,-5,4] 中,遇到 4 时重开,随后累加到 [4,-1,2,1] 得到峰值 6。

正解②:分治。把区间答案拆成“完全在左、完全在右、跨中点”三类;跨中点答案等于从中点向左的最大后缀和,加上从中点右侧向右的最大前缀和。

取舍:Kadane 一趟 O(n)/O(1),首选;分治 O(n log n)(每层把区间劈两半,答案 = max(左半最优, 右半最优, 跨中点最优))主要练"跨中点合并"的分治思维,比 Kadane 慢,面试写 Kadane 即可。

⚠ 易错坑 best_sum 初值不能取 0:全负数组(如 [-3,-1,-2])答案是 -1,若从 0 起会错误返回 0。要用 current_sum = best_sum = nums[0] 从首元素起。
current_sum 用 max(value, current_sum+value) 而非 max(current_sum, current_sum+value)——前者才有"丢弃负前缀、从当前值重开"的语义。
子数组至少含一个元素,不能返回空子数组的 0。
3 · 图解
nums=[-2,1,-3,4,-1,2,1,-5,4]

Kadane 里程碑1  i=0:current_sum=-2,best_sum=-2
里程碑2  i=1:丢弃负前缀,从 [1] 重开;current_sum=1
里程碑3  i=3:再次从 [4] 重开;current_sum=4
里程碑4  i=6:扩成 [4,-1,2,1];current_sum=best_sum=6
里程碑5  i=8:current_sum=5,但 best_sum 锁住 6

分治顶层 solve(0,8),middle=4:
左半最优=4,右半最优=4;跨中点左扩峰值=3,右扩峰值=3
合并三类候选:max(4,4,3+3)=6
解法① Kadane:右两列是现场状态——current_sum(以 i 结尾) 与 best_sum(全局最优)
inums[i]current_sumbest_sum说明
00-2-2-2首元素初始化
111max(1,-1)=11负前缀丢弃,重开
22-3max(-3,-2)=-21接上当前段
334max(4,2)=44重开并刷新峰值
44-134继续累加
55255刷新峰值
66166最优段 [4,-1,2,1]
77-516current_sum 回落,best_sum 锁住峰值
88456返回 best_sum=6
解法② 分治·顶层 solve(0,8):按递归结果与左右扩展阶段摘要
阶段i累加 s该侧峰值
1顶层切分mid=4[0..4] | [5..8]等待子问题
2左子问题返回0..4left_best=4
3右子问题返回5..8right_best=4
4从 middle 向左扩4→0-1,3,0,1,-1left_suffix_best=3
5从 middle+1 向右扩5→82,3,-2,2right_prefix_best=3
6合并三种候选4, 4, 3+3返回 6
4 · 代码
class Solution:
    def maxSubArray(self, nums: List[int]) -> int:
        # 1. 首元素初始化,保证全负数组也必须选一个元素
        current_sum = best_sum = nums[0]

        # 2. 每轮决定接续前段还是从当前值重开
        for value in nums[1:]:
            current_sum = max(value, current_sum + value)
            best_sum = max(best_sum, current_sum)
        return best_sum
# 解法二:分治 O(n log n)(理解分治的经典练习, 比 Kadane 的 O(n) 慢)
class Solution:
    def maxSubArray(self, nums: List[int]) -> int:
        def solve(left_index, right_index):
            # 1. 单元素区间是递归出口
            if left_index == right_index:
                return nums[left_index]

            # 2. 递归求完全位于左右半区的答案
            middle = (left_index + right_index) // 2
            left_best = solve(left_index, middle)
            right_best = solve(middle + 1, right_index)

            # 3. 从中点两侧向外扩,求跨中点答案
            running_sum = 0
            left_suffix_best = float('-inf')
            for index in range(middle, left_index - 1, -1):
                running_sum += nums[index]
                left_suffix_best = max(left_suffix_best, running_sum)

            running_sum = 0
            right_prefix_best = float('-inf')
            for index in range(middle + 1, right_index + 1):
                running_sum += nums[index]
                right_prefix_best = max(right_prefix_best, running_sum)

            # 4. 三类候选覆盖所有连续子数组
            return max(left_best, right_best, left_suffix_best + right_prefix_best)

        return solve(0, len(nums) - 1)
5 · 复杂度

Kadane:时间 O(n),空间 O(1),一次扫描且只用两个滚动变量。分治:时间 O(n log n),每层合计扫描 O(n)、共 O(log n) 层;空间 O(log n),来自递归调用栈。

6 · 测试用例

功能[-2,1,-3,4,-1,2,1,-5,4]→6边界:单元素 [1]→1、单负元素 [-1]→-1特殊:全负 [-3,-1,-2]→-1(不能返回0,必须选一个元素)、全正 [1,2,3]→6

合并区间

LC 56 ↗普通数组 · Medium
1 · 题目

要完成的任务

给定区间数组 intervals,每个元素 [start,end] 表示包含两个端点的闭区间。合并所有重叠区间,返回一组两两不重叠且覆盖范围相同的区间;输入顺序不保证有序。

全部示例

示例 1:[[1,3],[2,6],[8,10],[15,18]][[1,6],[8,10],[15,18]]

示例 2:[[1,4],[4,5]][[1,5]];闭区间在端点 4 相接也算重叠。

示例 3:[[4,7],[1,4]][[1,7]],也验证了需要先处理乱序输入。

约束

  • 1 ≤ intervals.length ≤ 104,且每项长度为 2。
  • 0 ≤ start ≤ end ≤ 104
  • 输出区间的顺序按实现产生即可,标准做法按起点升序返回。
2 · 思路(暴力→最优)

暴力:两两比较,能合并就合并,反复整轮扫描直到没有可合并的对,O(n²) 甚至更糟。

正解:排序 + 一次扫描。
① 按起点排序:排序后,可能继续当前合并段的区间会连续出现。
② 比较结果末尾:当前起点 start 若不大于末尾终点,说明重叠。
③ 扩展或新开:重叠时把末尾终点扩为两者最大值;否则追加新区间。
④ 具体例子:[1,3][2,6] 合为 [1,6];下一段 [8,10] 的起点已越过 6,只能新开。

取舍:排序 O(n log n) 主导,扫描 O(n);相比暴力反复整轮扫描,排序把"找可合并对象"从全局搜索降成"只看相邻"。

⚠ 易错坑 重叠判断用 s <= E 含等号:端点相接 [1,4],[4,5] 也要合并成 [1,5],写成 < 会漏。
扩展右端要 max(E, e):当前区间可能被完全包含([1,4][2,3]),直接赋 e 会把右端缩小。
别忘先排序:输入乱序时不排序直接扫会漏合并。
3 · 图解
输入:   [[1,3],[2,6],[8,10],[15,18]]
排序后: [[1,3],[2,6],[8,10],[15,18]]

里程碑1  结果为空,读 [1,3]:新开 → [[1,3]]
里程碑2  读 [2,6]:2≤3,扩展末尾 → [[1,6]]
里程碑3  读 [8,10]:8>6,新开 → [[1,6],[8,10]]
里程碑4  读 [15,18]:15>10,新开 → [[1,6],[8,10],[15,18]]

最终数轴: 1======6    8==10       15===18
排序与扫描:每个区间一行,记录该轮判定后的稳定结果
当前 [start,end]末尾右端是否重叠动作merged
0全部区间排序并初始化[]
1[1,3]无末尾新开区间[[1,3]]
2[2,6]3→62≤3 扩展末尾[[1,6]]
3[8,10]68≤6 新开区间[[1,6],[8,10]]
4[15,18]1015≤10 新开区间[[1,6],[8,10],[15,18]]
4 · 代码
class Solution:
    def merge(self, intervals: List[List[int]]) -> List[List[int]]:
        # 1. 按起点排序,让可能重叠的区间连续出现
        intervals.sort(key=lambda interval: interval[0])
        merged = []

        # 2. 只需与已合并结果的最后一段比较
        for start, end in intervals:
            if merged and start <= merged[-1][1]:
                merged[-1][1] = max(merged[-1][1], end)
            else:
                merged.append([start, end])
        return merged
5 · 复杂度

时间 O(n log n),排序主导;扫描 O(n)。空间 最坏 O(n),Python 的 Timsort 可能使用线性临时空间;扫描本身只用 O(1) 辅助状态,返回结果另占 O(n)。

6 · 测试用例

功能[[1,3],[2,6],[8,10],[15,18]]→[[1,6],[8,10],[15,18]]边界:单区间 [[1,4]]→[[1,4]]特殊仅端点相接 [[1,4],[4,5]]→[[1,5]]<= 不能写成 <)、被完全包含 [[1,4],[2,3]]→[[1,4]](终点取 max 才对)、乱序输入。

轮转数组

LC 189 ↗普通数组 · Medium
1 · 题目

要完成的任务

给定整数数组 nums 和非负整数 k,把数组整体向右轮转 k 个位置并直接修改 nums。向右轮转一步会把末尾元素移到开头;函数不需要返回新数组。

全部示例

示例 1:nums=[1,2,3,4,5,6,7], k=3[5,6,7,1,2,3,4];三步依次得到 [7,1,2,3,4,5,6][6,7,1,2,3,4,5]、最终结果。

示例 2:nums=[-1,-100,3,99], k=2[3,99,-1,-100];第一步得到 [99,-1,-100,3],第二步得到最终结果。

约束与进阶

  • 1 ≤ nums.length ≤ 105
  • -231 ≤ nums[i] ≤ 231-10 ≤ k ≤ 105
  • 题目鼓励比较至少三种方法,并进一步做到 O(1) 额外空间的原地轮转。
2 · 思路(暴力→最优)

暴力及局限:每次把末尾元素移到开头,重复 k 次需要 O(n·k) 时间;额外数组可用 new[(i+k)%n]=nums[i] 在 O(n) 时间完成,但要 O(n) 空间。

正解:三次翻转。
① 规范位数:k%=n,把完整轮转圈数消掉。
② 整体翻转:[1,2,3,4|5,6,7] 变为 [7,6,5|4,3,2,1],两段位置已经互换,但各自内部倒序。
③ 修正前段:翻转前 k 个元素,得到 [5,6,7|4,3,2,1]
④ 修正后段:翻转剩余元素,得到目标 [5,6,7|1,2,3,4]

具体例子:[1,2,3,4,5,6,7], k=3 连续执行“整体翻、前 3 个翻、后 4 个翻”,后 3 个元素恰好稳定移动到数组前方,同时两段内部顺序恢复。

取舍:三次翻转 O(n)/O(1),实现稳定;额外数组法同为 O(n) 时间但多用 O(n) 空间;环状替换也能 O(1) 空间,却要按 gcd(n,k) 处理多个置换环。

⚠ 易错坑 必须先做 k%=n,否则 k 大于数组长度时区间会错误。
三段区间依次为整体 [0,n-1]、前段 [0,k-1]、后段 [k,n-1]
本题是向右轮转;若改成向左,分界应换成 n-k
3 · 图解
nums=[1,2,3,4,5,6,7], k=3

里程碑1  初始分段:[1,2,3,4 | 5,6,7]
里程碑2  整体翻转:[7,6,5 | 4,3,2,1]
里程碑3  翻前 k 个:[5,6,7 | 4,3,2,1]
里程碑4  翻后 n-k 个:[5,6,7 | 1,2,3,4] ✓

每次交换使用 Python tuple swap,右侧旧值会先整体保存,
不存在“写一半导致旧值丢失”的可观察中间态。
三次翻转:按阶段汇总交换完成后的稳定数组
阶段 / 本轮摘要leftright数组现场
0读取输入06k=3;[1,2,3,4,5,6,7]
1k%=n,消掉完整轮转圈数有效位数仍为 3
2rev(0,6) 完整翻转全数组06[7,6,5,4,3,2,1]
3rev(0,2) 恢复前段内部顺序02[5,6,7,4,3,2,1]
4rev(3,6) 恢复后段内部顺序36[5,6,7,1,2,3,4]
4 · 代码
class Solution:
    def rotate(self, nums: List[int], k: int) -> None:
        length = len(nums)
        k %= length                         # 1. 去掉完整轮转圈数

        def reverse(left, right):
            while left < right:
                nums[left], nums[right] = nums[right], nums[left]
                left += 1
                right -= 1

        # 2. 整体翻转交换两段位置
        reverse(0, length - 1)
        # 3. 分别翻转两段,恢复各自内部顺序
        reverse(0, k - 1)
        reverse(k, length - 1)
5 · 复杂度

时间 O(n),三次翻转共约 n 次交换(合计 2n 次元素移动)。空间 O(1),纯原地。

6 · 测试用例

功能[1,2,3,4,5,6,7],k=3→[5,6,7,1,2,3,4]边界:单元素 [1],k=0k=0 不变;特殊k>n[1,2],k=3k%=n 后为1)、k==n(回到原样)。

除自身以外数组的乘积

LC 238 ↗普通数组 · Medium
1 · 题目

要完成的任务

给定整数数组 nums,返回同长度数组 answer,其中 answer[i] 等于 nums 中除 nums[i] 之外所有元素的乘积。要求 O(n) 时间,并且不能使用除法。

全部示例

示例 1:nums=[1,2,3,4][24,12,8,6]

示例 2:nums=[-1,1,0,-3,3][0,0,9,0,0];0 所在位置的答案是其余非零元素之积。

约束与进阶

  • 2 ≤ nums.length ≤ 105
  • -30 ≤ nums[i] ≤ 30;任意前缀或后缀的乘积都保证可用 32 位整数表示,且每个 answer[i] 也保证在 32 位整数范围内。
  • 进阶要求除输出数组外仅使用 O(1) 额外空间;返回的输出数组不计入额外空间。
2 · 思路(暴力→最优)

暴力:对每个 i 遍历乘其余元素,O(n²)。除法法ans[i]=总积/nums[i],但含 0 会崩(0 处、以及除以 0),且题目禁止除法

正解:前缀积 × 后缀积。
① 写入左积:answer[i] 先保存 i 左边所有元素的乘积;当前位置自身不参与。
② 初始化右积:right_product=1,表示最右位置右侧为空集。
③ 合并右积:从右向左先做 answer[i]*=right_product,再把 nums[i] 纳入 right_product。
④ 具体例子:[1,2,3,4] 的左积为 [1,1,2,6],再依次乘右积得到 [24,12,8,6]

取舍:两趟扫描 O(n)/O(1) 额外。相比"前缀数组 + 后缀数组"各开一个(也 O(n) 空间),复用输出数组把额外空间压到 O(1)。

⚠ 易错坑 两处都要"排除自身":左积用的是 nums[i-1](不含 i),右积在乘完 ans[i] 之后才更新——错位一格即出错。
含 0 不必特判:前缀/后缀积各自遇 0 自然把结果带成 0,唯独 0 所在位置的 ans 是其余非零元素之积;别为 0 单独加分支。
输出数组不算额外空间,但除它之外只能用常数个变量(仅 r),再开左右两个辅助数组就不达标了。
3 · 图解
nums = [1,2,3,4],answer 复用为左积存储,right_product 滚动保存右积

里程碑1  初始化 answer=[1,1,1,1]
里程碑2  左扫完成:answer=[1,1,2,6]
里程碑3  右扫 i=3,2:answer=[1,1,8,6],right_product=12
里程碑4  右扫 i=1:answer=[1,12,8,6],right_product=24
里程碑5  右扫 i=0:answer=[24,12,8,6] ✓

每轮必须先写 answer[i],再更新 right_product,
否则 nums[i] 会错误地乘进自己的答案。
前缀积与滚动后缀积:一张表按两趟扫描记录稳定状态
阶段index本轮计算right_productanswer
0初始化输出数组[1,1,1,1]
11写左积 1[1,1,1,1]
22写左积 2[1,1,2,1]
33写左积 6,左扫结束1[1,1,2,6]
43先乘右积 1,再纳入 44[1,1,2,6]
52先乘右积 4,再纳入 312[1,1,8,6]
61先乘右积 12,再纳入 224[1,12,8,6]
70先乘右积 24,再纳入 124[24,12,8,6]
4 · 代码
class Solution:
    def productExceptSelf(self, nums: List[int]) -> List[int]:
        length = len(nums)
        answer = [1] * length

        # 1. answer[i] 先保存 i 左边的全部乘积
        for index in range(1, length):
            answer[index] = answer[index - 1] * nums[index - 1]

        # 2. 从右向左乘入右积;先使用,再纳入 nums[index]
        right_product = 1
        for index in range(length - 1, -1, -1):
            answer[index] *= right_product
            right_product *= nums[index]
        return answer
5 · 复杂度

时间 O(n),两趟扫描。空间 O(1) 额外(输出数组不计)。

6 · 测试用例

功能[1,2,3,4]→[24,12,8,6]边界:两元素 [2,3]→[3,2]特殊含一个 0 [1,0,3]→[0,3,0](除自身外含0则为0,唯独0自身位置是其余非0元素之积)、含两个 0 [0,0,3]→[0,0,0]、含负数。

缺失的第一个正数

LC 41 ↗普通数组 · Hard
1 · 题目

要完成的任务

给定未排序整数数组 nums,返回其中没有出现的最小正整数。0、负数和大于数组长度的数都可能出现在输入中;目标复杂度为 O(n) 时间、O(1) 额外空间。

全部示例

示例 1:nums=[1,2,0]3,因为 1、2 都已出现。

示例 2:nums=[3,4,-1,1]2

示例 3:nums=[7,8,9,11,12]1

约束

  • 1 ≤ nums.length ≤ 105
  • -231 ≤ nums[i] ≤ 231-1
  • 数组可含重复值,算法需要原地工作且不能通过额外集合保存所有元素。
2 · 思路(暴力→最优)

暴力:排序后扫 O(n log n),或哈希集合 O(n) 时间但 O(n) 空间——都不满足"O(n) 时间 + O(1) 空间"。

正解:原地哈希(桶归位)。
① 缩小答案范围:长度为 n 时,答案必在 [1,n+1];负数、0 和大于 n 的值都不影响最小缺口。
② 把值放回桶:value 应放到下标 value-1,让 nums[value-1]=value
③ 连续归位:交换后当前位置会换入新值,所以必须用 while;目标桶已有相同值时停止,避免重复值死循环。
④ 扫描首个缺口:第一个 nums[i]!=i+1 的位置返回 i+1;若全对齐则返回 n+1

具体例子:[3,4,-1,1] 依次把 3、4、1 归位,稳定为 [1,-1,3,4],下标 1 没有值 2,所以答案是 2。

⚠ 易错坑 while 不是 if:换进来的新值可能还要继续归位。
循环条件写 nums[nums[i]-1] != nums[i]不能nums[i] != i+1——遇重复值(如 [1,1])会两个相同值反复交换死循环。
交换前先把目标下标 j=nums[i]-1 存好再换;直接写 nums[i],nums[nums[i]-1]=nums[nums[i]-1],nums[i] 在 Python 里因左侧求值顺序会用到已被改动的 nums[i] 而出错。
3 · 图解
nums=[3,4,-1,1], n=4;值 value 应在下标 value-1

里程碑1  初始:[3,4,-1,1]
里程碑2  i=0,把 3 交换到下标2:[-1,4,3,1]
里程碑3  i=1,把 4 交换到下标3:[-1,1,3,4]
里程碑4  i=1,换入的 1 继续归位:[1,-1,3,4]
里程碑5  扫描:位置0正确;位置1期望2却是-1 → 返回2

交换使用 tuple swap,旧值先整体保存;真正的风险是换入新值后
若只用 if 就不会继续归位,因此代码必须使用 while。
原地哈希:一张表概括连续归位与首次缺口扫描
阶段index当前值动作数组 / 结论
0初始化 n=4[3,4,-1,1]
103交换 0↔2,3 归位[-1,4,3,1]
214交换 1↔3,4 归位[-1,1,3,4]
311while 继续交换 1↔0[1,-1,3,4]
42..33,4均已就位,归位结束[1,-1,3,4]
501扫描期望值 1匹配,继续
61-1扫描期望值 2首次错位,返回 2
4 · 代码
class Solution:
    def firstMissingPositive(self, nums: List[int]) -> int:
        length = len(nums)

        # 1. 把 [1,length] 内的值反复交换到 value-1 位置
        for index in range(length):
            while 1 <= nums[index] <= length:
                target_index = nums[index] - 1
                if nums[target_index] == nums[index]:  # 重复值已占目标桶,避免死循环
                    break
                nums[index], nums[target_index] = nums[target_index], nums[index]

        # 2. 第一个值与下标不匹配的位置就是最小缺口
        for index, value in enumerate(nums):
            if value != index + 1:
                return index + 1
        return length + 1
5 · 复杂度

时间 O(n),每个元素最多被换到正确位置一次(均摊)。空间 O(1),原地交换。

6 · 测试用例

功能[3,4,-1,1]→2边界[1,2,0]→3、单元素 [1]→2[2]→1特殊全负/越界 [-5,-2]→1含重复 [1,1]→2(用 nums[nums[i]-1]!=nums[i] 判重防死循环)、[7,8,9,11,12]→1

矩阵

矩阵题的本质是二维下标的映射与遍历控制:置零要防"边改边读"的污染(用首行首列当标记)、螺旋要管好上下左右四条边界、旋转靠"转置+翻转"或"翻转组合"、有序矩阵搜索靠"从右上角走 Z 字"。它和"普通数组"同源——旋转图像就是 轮转数组的二维版(都靠翻转组合达成,O(1) 空间);搜索二维矩阵II的"消去一行/一列"是二分/双指针思想在二维的推广。掌握"下标怎么走"比记模板更重要。

矩阵置零

LC 73 ↗矩阵 · Medium
1 · 题目

要完成的任务

给定 m×n 整数矩阵 matrix。若某个元素原本为 0,就把它所在的整行和整列全部改为 0;必须直接修改输入矩阵,不返回另一张矩阵。

判断依据必须来自原始矩阵中的 0,不能让置零过程中产生的新 0 再触发额外行列。

全部示例

示例 1:[[1,1,1],[1,0,1],[1,1,1]][[1,0,1],[0,0,0],[1,0,1]]

示例 1:中心的原始 0 清空所在行和列 原矩阵中心位置是零,因此结果矩阵的中间行和中间列全部变为零。 原矩阵 结果矩阵 1 1 1 1 0 1 1 1 1 1 0 1 0 0 0 1 0 1
示例 1:中心的原始 0 清空所在行和列

示例 2:[[0,1,2,0],[3,4,5,2],[1,3,1,5]][[0,0,0,0],[0,4,5,0],[0,3,1,0]]

示例 2:首行两个原始 0 决定三条置零范围 原矩阵第一行的首尾各有一个零,因此结果的第一行、第一列和第四列全部变为零。 原矩阵 结果矩阵 0 1 2 0 3 4 5 2 1 3 1 5 0 0 0 0 0 4 5 0 0 3 1 0
示例 2:首行两个原始 0 决定三条置零范围

约束与进阶

  • m == matrix.lengthn == matrix[0].length,且 1 ≤ m,n ≤ 200
  • -231 ≤ matrix[i][j] ≤ 231-1
  • 直观方案需 O(mn) 标记空间,行列集合需 O(m+n);进阶要求常量额外空间。
2 · 思路(暴力→最优)

暴力起手不能边发现 0 边置零——你在 (i,j) 置了 0,往后扫到 (i,j+1) 时又会把这个"新 0"当成原始 0,整行整列被错误扩散。所以先扫一遍记住哪些行、哪些列要清,再统一写。朴素做法:两个集合 rowscols 存要清的行号列号,O(m+n) 额外空间。

正解:复用首行首列作为标记区。
① 保护原始信息:先用 first_row_zerofirst_col_zero 记录首行和首列原本是否含 0,避免后续标记覆盖旧值。
② 扫描内部:遇到 matrix[row][col]==0,先写 matrix[row][0]=0 标记整行,再写 matrix[0][col]=0 标记整列。
③ 回填内部:若某格对应的行标记或列标记为 0,就把该内部格清零。
④ 收尾首行首列:根据预存的两个布尔值决定是否清首行、首列,不能再从已被复用的标记格推断。
⑤ 非平凡样例:唯一 0 在 (1,2) 时,行 1 与列 2 被清零,但首行和首列本身不应整条清零。

取舍:集合标记法更直观但用 O(m+n) 空间;复用首行首列把额外空间降到 O(1),代价是必须严格遵守“先保存、再标记、后回填、最后收尾”的阶段顺序。

⚠ 易错坑 三个阶段的顺序不能乱:先打标记(扫内部)→ 再回填内部 → 最后清首行首列。若边打标记边回填,新写的 0 会污染尚未扫到的格。
first_row/first_col 必须在打标记之前预存。一旦开始打标记就会往首行首列写 0,之后再也分不清"本来就有的 0"和"标记写的 0"。
回填内部的两层循环从 1 起(range(1,m)/range(1,n)),不能从 0 起,否则会把标记位当普通数据读,把整行整列误清。
3 · 图解
例 matrix=[[1,1,1,1],[1,1,0,1],[1,1,1,1]]
里程碑①  保存首行/首列原始状态:first_row_zero=False,first_col_zero=False
里程碑②  写行标记 matrix[1][0]=0:[[1,1,1,1],[0,1,0,1],[1,1,1,1]]
            此时列标记尚未写,必须保留这个“只完成一半标记”的瞬时态
里程碑③  写列标记 matrix[0][2]=0:[[1,1,0,1],[0,1,0,1],[1,1,1,1]]
里程碑④  根据两类标记回填内部:[[1,1,0,1],[0,0,0,0],[1,1,0,1]]
里程碑⑤  两个首部标志均为 False,不再整清首行/首列,得到最终结果
O(1) 标记法:保留两次覆盖旧值后的必要瞬时态,其余按阶段汇总
阶段 / 本轮摘要关键位置首行首列矩阵 / 结论
0保存尺寸与首部原始状态首行 / 首列[1,1,1,1][1,1,1]两个标志均为 False
1扫描内部,命中原始 0(1,2)[1,1,1,1][1,1,1]准备分别写行、列标记
2写入行标记matrix[1][0]=0[1,1,1,1][1,0,1]列标记尚未写
3写入列标记matrix[0][2]=0[1,1,0,1][1,0,1]两类标记齐全
4扫描其余内部格其余位置[1,1,0,1][1,0,1]无新原始 0
5按标记回填全部内部格行1 / 列2[1,1,0,1][1,0,1][[1,1,0,1],[0,0,0,0],[1,1,0,1]]
6按两个布尔标志收尾首行 / 首列不整清不整清最终结果保持不变

最终 [[1,1,0,1],[0,0,0,0],[1,1,0,1]]:行1 靠"行标记"整清、(2,2) 靠"列标记"单清,两条标记各司其职 ✓。

4 · 代码
class Solution:
    def setZeroes(self, matrix: List[List[int]]) -> None:
        row_count, col_count = len(matrix), len(matrix[0])

        # 1. 标记区将覆盖旧值,先保存首行首列的原始信息
        first_row_zero = any(matrix[0][col] == 0 for col in range(col_count))
        first_col_zero = any(matrix[row][0] == 0 for row in range(row_count))

        # 2. 扫描内部;行标记与列标记是两次独立写入
        for row in range(1, row_count):
            for col in range(1, col_count):
                if matrix[row][col] == 0:
                    matrix[row][0] = 0
                    matrix[0][col] = 0

        # 3. 只根据标记回填内部,暂不改首行首列
        for row in range(1, row_count):
            for col in range(1, col_count):
                if matrix[row][0] == 0 or matrix[0][col] == 0:
                    matrix[row][col] = 0

        # 4. 最后依据最初保存的状态处理首行首列
        if first_row_zero:
            for col in range(col_count):
                matrix[0][col] = 0
        if first_col_zero:
            for row in range(row_count):
                matrix[row][0] = 0
5 · 复杂度

时间 O(m·n),每格常数次访问。空间 O(1),复用首行首列,仅两个布尔变量。

6 · 测试用例

功能[[1,1,1],[1,0,1],[1,1,1]]→[[1,0,1],[0,0,0],[1,0,1]]边界:单行 [[1,0,3]]→[[0,0,0]]、单列;特殊0 在首行/首列 [[0,1],[1,1]]→[[0,0],[0,1]](考验 first_row/first_col 逻辑)、全 0、无 0 不变。

螺旋矩阵

LC 54 ↗矩阵 · Medium
1 · 题目

要完成的任务

给定 m×n 整数矩阵 matrix,从左上角开始,按照顺时针螺旋顺序访问每个元素一次,返回访问顺序组成的数组。输入矩阵不需要被修改。

全部示例

示例 1:[[1,2,3],[4,5,6],[7,8,9]][1,2,3,6,9,8,7,4,5]

示例 2:[[1,2,3,4],[5,6,7,8],[9,10,11,12]][1,2,3,4,8,12,11,10,9,5,6,7]

螺旋矩阵的两个示例三乘三矩阵按 1、2、3、6、9、8、7、4、5 的顺序访问;三乘四矩阵先走外圈,再走内部的 6、7。3×31 → 2 → 34 5 ↓67 ← 8 ← 93×41 → 2 → 3 → 45 6 → 7 ↓89 ←10 ←11 ←12先完整走外圈,再进入剩余内圈。
箭头标出两个示例的顺时针访问方向。

约束

  • m == matrix.lengthn == matrix[i].length,且 1 ≤ m,n ≤ 10
  • -100 ≤ matrix[i][j] ≤ 100
2 · 思路(暴力→最优)

暴力起手:真的按方向"走一步、撞墙或撞到走过的格就右转",需要一个 visited 布尔矩阵判重,O(m·n) 额外空间。

正解:四边界逐圈收缩。
① 定义未访问区:top/bottom/left/right 始终框住尚未输出的矩形。
② 走上边与右边:依次从左到右读顶行、从上到下读右列,随后收缩 topright
③ 守卫剩余形状:仅当 top<=bottom 时走底边;仅当 left<=right 时走左边,避免单行或单列重复。
④ 重复收圈:每轮最多消去四条边,直到边界交错。
⑤ 非平凡样例:在 2×4 矩阵中,外圈走完上、右、下三条边后已无独立左边,第二个守卫会阻止重复读取。

取舍:方向模拟加 visited 更像实际行走,但需 O(m·n) 额外空间;四边界法只用 O(1) 状态,代价是要仔细维护收缩时机和两个防重复条件。

⚠ 易错坑 每走完一条边要立即收对应边界:顶行后 top+=1、右列后 right-=1…,方向和时机别错。
步3、步4 前必须判 top<=bottom / left<=right,否则单行/单列重复。
内层 range 方向:走左是 range(right, left-1, -1) 倒序、走上是 range(bottom, top-1, -1) 倒序,别写成正序。
3 · 图解
matrix=[[1,2,3],[4,5,6],[7,8,9]]
里程碑①  边界 (top,bottom,left,right)=(0,2,0,2),result=[]
里程碑②  走顶边与右边:result=[1,2,3,6,9],边界变为 (1,2,0,1)
里程碑③  守卫通过,走底边与左边:result=[1,2,3,6,9,8,7,4]
里程碑④  未访问区只剩中心 (1,1),加入 5
里程碑⑤  边界交错,返回 [1,2,3,6,9,8,7,4,5]
四边界收缩:按边或内圈阶段汇总结果与剩余矩形
阶段 / 本轮摘要本轮读到(top,bottom,left,right)result
0初始化未访问矩形(0,2,0,2)[]
1走顶边并收缩 top1,2,3(1,2,0,2)[1,2,3]
2走右边并收缩 right6,9(1,2,0,1)[1,2,3,6,9]
3守卫通过,走底边8,7(1,1,0,1)[1,2,3,6,9,8,7]
4守卫通过,走左边4(1,1,1,1)[1,2,3,6,9,8,7,4]
5中心圈只剩一个格,随后边界交错5退出[1,2,3,6,9,8,7,4,5]

结果 [1,2,3,6,9,8,7,4,5]。第 6 步两个 if 双双拦下——若少了它们,中心 5 那圈会把底行/左列重复读。

4 · 代码
class Solution:
    def spiralOrder(self, matrix: List[List[int]]) -> List[int]:
        # 1. 四条边界框住尚未访问的矩形
        top, bottom = 0, len(matrix) - 1
        left, right = 0, len(matrix[0]) - 1
        result = []

        # 2. 每轮按上、右、下、左收完一圈
        while top <= bottom and left <= right:
            for col in range(left, right + 1):
                result.append(matrix[top][col])
            top += 1

            for row in range(top, bottom + 1):
                result.append(matrix[row][right])
            right -= 1

            # 3. 收缩后再判断,防止单行或单列被重复读取
            if top <= bottom:
                for col in range(right, left - 1, -1):
                    result.append(matrix[bottom][col])
                bottom -= 1
            if left <= right:
                for row in range(bottom, top - 1, -1):
                    result.append(matrix[row][left])
                left += 1
        return result
5 · 复杂度

时间 O(m·n),每个元素恰好写入结果一次。空间 O(1) 额外(不计输出)。

6 · 测试用例

功能3×3→[1,2,3,6,9,8,7,4,5]边界:单行 [[1,2,3]]→[1,2,3]、单列 [[1],[2],[3]]→[1,2,3]特殊长条形 [[1,2,3,4],[5,6,7,8]](考验两个 if 边界判断)、1×1 [[7]]→[7]

旋转图像

LC 48 ↗矩阵 · Medium
1 · 题目

要完成的任务

给定 n×n 整数矩阵 matrix,把它表示的图像顺时针旋转 90°。必须直接修改原矩阵,不能分配另一张矩阵承接旋转结果;函数无需返回矩阵。

全部示例

示例 1:[[1,2,3],[4,5,6],[7,8,9]][[7,4,1],[8,5,2],[9,6,3]]

示例 1:3×3 矩阵顺时针旋转 90° 三乘三矩阵顺时针旋转九十度后,原第一列逆序成为新第一行,其余列遵循相同规律。 原矩阵 结果矩阵 1 2 3 4 5 6 7 8 9 7 4 1 8 5 2 9 6 3
示例 1:3×3 矩阵顺时针旋转 90°

示例 2:[[5,1,9,11],[2,4,8,10],[13,3,6,7],[15,14,12,16]][[15,13,2,5],[14,3,4,1],[12,6,8,9],[16,7,10,11]]

示例 2:4×4 矩阵顺时针旋转 90° 四乘四矩阵顺时针旋转九十度后,四条原列逆序成为四条新行。 原矩阵 结果矩阵 5 1 9 11 2 4 8 10 13 3 6 7 15 14 12 16 15 13 2 5 14 3 4 1 12 6 8 9 16 7 10 11
示例 2:4×4 矩阵顺时针旋转 90°

约束

  • n == matrix.length == matrix[i].length1 ≤ n ≤ 20
  • -1000 ≤ matrix[i][j] ≤ 1000
  • 矩阵保证为正方形,且必须原地旋转。
2 · 思路(暴力→最优)

暴力起手:另开一个矩阵,按坐标映射 new[j][n-1-i]=old[i][j] 逐格搬。正确但要 O(n²) 额外空间,题目却要求原地

正解:主对角线转置 + 每行反转。
① 推导目标坐标:顺时针旋转满足 result[row][col]=old[n-1-col][row]
② 原地转置:只遍历上三角 col>row,交换 (row,col)(col,row);每一对恰好交换一次。
③ 逐行反转:转置后每行左右翻转,坐标恰好变成目标映射。
④ 非平凡样例:3×3 矩阵先从 [[1,2,3],[4,5,6],[7,8,9]] 转置为 [[1,4,7],[2,5,8],[3,6,9]],再得到顺时针结果。

取舍:额外矩阵按坐标搬运最直观,但占 O(n²) 空间;转置加反转只用 O(1) 额外空间。逆时针旋转则应转置后做上下翻转,方向不能混用。

⚠ 易错坑 转置内层 j 必须从 i+1 起(上三角);从 0 起会把每对交换两次,等于没转。
顺时针 = 转置 + 翻转(row.reverse());逆时针 = 转置 + 上下翻转。两者别记混。
全程原地:交换用 a,b = b,a 元组同时赋值,别用临时变量分两步写以致覆盖。
3 · 图解
里程碑①  原矩阵:[[1,2,3],[4,5,6],[7,8,9]]
里程碑②  交换上三角对 (0,1)↔(1,0):[[1,4,3],[2,5,6],[7,8,9]]
里程碑③  其余上三角对交换完成:[[1,4,7],[2,5,8],[3,6,9]]
里程碑④  前两行反转:[[7,4,1],[8,5,2],[3,6,9]]
里程碑⑤  最后一行反转:[[7,4,1],[8,5,2],[9,6,3]] ✓
tuple swap 会先保存右侧旧值,不存在可观察的“半次交换”信息丢失态。
原地旋转:表格概括转置与逐行反转阶段,具体交换现场见上图
阶段 · 动作交换/翻转matrix 现场
0读取输入n=3[[1,2,3],[4,5,6],[7,8,9]]
1交换全部上三角对,完成主对角线转置(0,1)↔(1,0)、(0,2)↔(2,0)、(1,2)↔(2,1)[[1,4,7],[2,5,8],[3,6,9]]
2反转第 0 行[1,4,7]→[7,4,1][[7,4,1],[2,5,8],[3,6,9]]
3反转第 1 行[2,5,8]→[8,5,2][[7,4,1],[8,5,2],[3,6,9]]
4反转第 2 行并完成旋转[3,6,9]→[9,6,3][[7,4,1],[8,5,2],[9,6,3]]
4 · 代码
class Solution:
    def rotate(self, matrix: List[List[int]]) -> None:
        size = len(matrix)

        # 1. 只交换上三角,确保每个对称位置恰好处理一次
        for row in range(size):
            for col in range(row + 1, size):
                matrix[row][col], matrix[col][row] = matrix[col][row], matrix[row][col]

        # 2. 转置后逐行左右翻转,得到顺时针 90° 结果
        for row in matrix:
            row.reverse()
5 · 复杂度

时间 O(n²),转置 + 翻转各扫半个/整个矩阵。空间 O(1),原地交换。

6 · 测试用例

功能3×3→[[7,4,1],[8,5,2],[9,6,3]]边界:1×1 [[5]]→[[5]] 不变;特殊2×2 [[1,2],[3,4]]→[[3,1],[4,2]]4×4(验证转置 j>i 不重复交换,否则转回去)。

搜索二维矩阵 II

LC 240 ↗矩阵 · Medium
1 · 题目

要完成的任务

给定 m×n 整数矩阵 matrix 和整数 target,判断 target 是否出现在矩阵中。每一行都从左到右非递减,每一列都从上到下非递减;这不保证按行拼接后整体有序。

全部示例

两个示例使用同一矩阵:[[1,4,7,11,15],[2,5,8,12,19],[3,6,9,16,22],[10,13,14,17,24],[18,21,23,26,30]]

示例 1:target=5true,值 5 位于第 2 行第 2 列(零基坐标 (1,1))。

示例 2:target=20false

行列分别升序的五乘五矩阵矩阵每行向右递增、每列向下递增;数值 5 位于第二行第二列,而 20 不在矩阵中。向右递增 →14711152581219369162210131417241821232630向下递增高亮 5;20 不存在
两个示例共用的矩阵:5 被高亮,20 不在其中。

约束与保证

  • m == matrix.lengthn == matrix[i].length,且 1 ≤ m,n ≤ 300
  • -109 ≤ matrix[i][j], target ≤ 109
  • 每行从左到右非递减、每列从上到下非递减;元素是否重复不影响算法。
2 · 思路(暴力→最优)

暴力起手:全矩阵扫一遍 O(m·n),完全没用上"行列都有序"。改进一步:对每一行二分 target,O(m log n)——只用了"行内有序",还没用上"列也有序"。

正解:从右上角走阶梯。
① 选择枢纽角:右上角同时是当前行最大值、当前列最小值,向左变小、向下变大,两个方向单调性相反。
② 当前值过大:value>target 时,该列从当前位置往下都更大,可整列排除并左移。
③ 当前值过小:value<target 时,该行从当前位置往左都更小,可整行排除并下移。
④ 命中或越界:相等立即返回 True;行或列越界则搜索域为空,返回 False。
⑤ 非平凡样例:在题面 5×5 矩阵中找 5,路径为 15→11→7→4→5:前三次左移排除偏大的列,再下移排除偏小的首行。

取舍:逐行二分是 O(m log n),实现也清楚;阶梯法同时使用行列有序性,降到 O(m+n)。左下角也可对称实现,而左上/右下两个方向同增或同减,无法唯一决定排除方向。

⚠ 易错坑 起点必须选右上角或左下角这种"两方向单调相反"的角;左上/右下角不可用。
循环条件两边都要判:i<m and j>=0。左移会让 j-1、下移会让 im,任一越界即目标不存在。
方向别写反:v>target 左移(弃大列)、v<target 下移(弃小行)。
3 · 图解
使用题面 5×5 矩阵,target=5,起点为右上角 (0,4)
里程碑①  (0,4)=15>5:排除第 4 列,左移到 (0,3)
里程碑②  (0,3)=11>5:排除第 3 列,左移到 (0,2)
里程碑③  (0,2)=7>5:排除第 2 列,左移到 (0,1)
里程碑④  (0,1)=4<5:排除第 0 行,下移到 (1,1)
里程碑⑤  (1,1)=5:命中并返回 True
路径 15→11→7→4→5;每一步都永久消去一整行或一整列。
右上角阶梯搜索:每次比较与搜索域收缩合并为一轮
位置 (row,col)value比较本轮摘要 / 下一位置
0(0,4)1515>5排除第 4 列 → (0,3)
1(0,3)1111>5排除第 3 列 → (0,2)
2(0,2)77>5排除第 2 列 → (0,1)
3(0,1)44<5排除第 0 行 → (1,1)
4(1,1)55==5 返回 True

路径 15→11→7→4→5:前三步各砍掉一列、第四步砍掉一行,5 步命中,共走不超过 m+n 格。

4 · 代码
class Solution:
    def searchMatrix(self, matrix: List[List[int]], target: int) -> bool:
        # 1. 从“向左变小、向下变大”的右上角开始
        row, col = 0, len(matrix[0]) - 1

        # 2. 每轮依据比较结果排除一整列或一整行
        while row < len(matrix) and col >= 0:
            value = matrix[row][col]
            if value == target:
                return True
            if value > target:
                col -= 1
            else:
                row += 1
        return False
5 · 复杂度

时间 O(m+n),每步至少排除一行或一列。空间 O(1),只用两个指针。

6 · 测试用例

功能target=5→True边界:目标位于左下角或右上角、单行或单列矩阵;特殊不存在 target=20→False(走出边界)、小于全体最小 target=0→False大于全体最大

链表

链表题的本质是指针操作:核心套路只有三招——dummy 哨兵(统一头结点处理,删除/合并/翻转都靠它免去特判)、快慢指针(找环 141/142、找中点 876/234、倒数第 N 19,都是让两指针以不同步幅或不同起点走)、反转(三指针 206 是最高频子过程,24/25/234 内部都在反转)。它和其它类别的关联:快慢指针一族本质是双指针在链表上的形态;LRU 146 把链表当成哈希表 + 双向链表的复合数据结构(属于设计题);排序链表 148、合并 K 路 23 则是归并排序 / 堆在链表上的落地。更细的交互式分步图解(142/2/19 快慢指针动画)见 链表交互图解

相交链表

LC 160 ↗链表 · Easy
1 · 题目

要完成的任务

给定两个单链表的头节点 headAheadB,返回两条链表开始相交的第一个节点;若没有公共节点则返回 null。相交按节点对象相同判断,不是节点值相等。

输入字段与返回值

intersectVal 只是评测页面展示交点值;skipAskipB 表示从两条链表头分别走多少步到交点,这三个量都不会传给函数。函数实际只接收两个头节点,且返回后链表结构必须保持不变。

示例 1

输入:intersectVal=8, listA=[4,1,8,4,5], listB=[5,6,1,8,4,5], skipA=2, skipB=3

输出:值为 8 的公共节点。A 的前两个节点和 B 的前三个节点彼此独立;从 8 开始,两条链表共享同一批节点。

两条链表从值 8 的节点开始相交链表 A 为 4、1 后接公共的 8、4、5;链表 B 为 5、6、1 后也接到同一个值 8 的节点。AB41561845
示例 1:两条入边都指向同一个节点 8,此后的 4、5 也是共享尾部。

示例 2

输入:intersectVal=2, listA=[1,9,1,2,4], listB=[3,2,4], skipA=3, skipB=1

输出:值为 2 的公共节点。

示例 3

输入:intersectVal=0, listA=[2,6,4], listB=[1,5], skipA=3, skipB=2

输出:null,两条链表没有共享节点。值相同也不能据此认定相交。

约束与保证

  • A、B 的节点数分别为 mn,且 1 ≤ m,n ≤ 3×10⁴
  • 节点值满足 1 ≤ Node.val ≤ 10⁵0 ≤ skipA ≤ m0 ≤ skipB ≤ n
  • intersectVal==0 则两链表不相交;否则它等于 listA[skipA]listB[skipB] 的值。
  • 题目保证链表无环,且调用结束后原结构不可被破坏。进阶目标是 O(m+n) 时间、O(1) 额外空间。
2 · 思路(暴力→最优)

暴力:对 A 的每个结点,遍历整个 B 找是否有相同对象,O(m·n)。哈希集优化:把 A 全部结点放进 set,再遍历 B,第一个落在 set 里的结点即答案,O(m+n) 时间但要 O(m) 空间。

正解:双指针换路。关键规律是让两根指针都走完 A+B
① 各走本链:pa 从 A 出发,pb 从 B 出发。
② 到空换路:pa 到 None 后切到 B 头,pb 到 None 后切到 A 头。
③ 长度对齐:若 A、B 独有段分别长 xy,两条路线都是 x+y+共享段,长度差被补平。
④ 同一对象收尾:相交时两指针同步落在交点;不相交时同步落在 None,也会退出。

具体例子:A 为 a1→c,B 为 b1→b2→cpaa1,c,None,b1,b2,cpbb1,b2,c,None,a1,c,第 5 轮在同一个 c 相遇。

取舍:哈希集直观但费 O(m) 空间;双指针是 O(1) 空间的最优解,代价是要想清"走到头切换对方轨道"和"用 None 收尾"两个细节。

⚠ 易错坑 判相交要用 is对象身份,不是 == 比值——两个不同结点值相等不算相交。
切换轨道要"走到 None 了才切"(pa = pa.next if pa else headB):必须让指针真的落到 None 再跳对方头,两条路径长度才都恰好是 m+n;若在最后一个结点就提前切,会少走一步导致错位。
循环条件是 while pa is not pb不能while pa and pb——不相交时正是靠"两者同时为 None 而相等"来退出的。
3 · 图解
共享结构(c 只存在一个对象):
A: a1 --------\
                c -> None
B: b1 -> b2 --/

里程碑1  起点:pa=a1,pb=b1
里程碑2  A 先耗尽:pa=None,pb=c
里程碑3  两边完成换路:pa=b2,pb=a1
里程碑4  同步命中:pa=c,pb=c ✓

pa 路线: a1 -> c -> None -> b1 -> b2 -> c
pb 路线: b1 -> b2 -> c -> None -> a1 -> c
双指针换路:按每轮移动汇总,状态列记录轮末位置
动作pA 指向pB 指向pa is pb ?
0初始化两指针a1b1不同,开始换路
1两指针各前进一格cb2不同
2两指针各前进一格NonecA 已耗尽
3pa 切到 B 头,pb 前进b1NoneB 也耗尽
4pa 前进,pb 切到 A 头b2a1长度差已被抵消
5两指针同步前进cc同一对象,返回 c
4 · 代码
class Solution:
    def getIntersectionNode(self, headA, headB):
        # 1. 两指针分别从两条链出发
        pointer_a, pointer_b = headA, headB

        # 2. 到 None 后换到另一条链;相交时同点退出,不相交时同为 None
        while pointer_a is not pointer_b:
            pointer_a = pointer_a.next if pointer_a else headB
            pointer_b = pointer_b.next if pointer_b else headA

        return pointer_a
5 · 复杂度

时间 O(m+n),两指针各走一遍两链表;空间 O(1),只用两个指针。

6 · 测试用例

功能:A、B 在中段相交返回该结点;边界:其中一条为空返回 None、相交点即为某条的头;特殊:完全不相交(两指针同时落到 None,循环靠 pa is pb 终止不会死循环)。

反转链表

LC 206 ↗链表 · Easy
1 · 题目

要完成的任务

给定单链表头节点 head,把每条 next 边反向,并返回反转后的新头节点;空链表仍返回 null

输入与输出

输入、输出都是链表头节点。题面中的数组只是按 next 顺序展示节点值,不是函数参数。

示例 1

输入:head=[1,2,3,4,5]

输出:[5,4,3,2,1]

五节点链表反转前后原链表从 1 指向 5,反转后从 5 依次指向 1。反转前 1 → 2 → 3 → 4 → 5 → null反转后 5 → 4 → 3 → 2 → 1 → null
每条边都改变方向,因此原尾节点 5 成为新头。

示例 2

输入:head=[1,2]

输出:[2,1]

示例 3

输入:head=[]

输出:[]

约束

  • 链表节点数在 [0,5000] 内。
  • -5000 ≤ Node.val ≤ 5000
2 · 思路(暴力→最优)

直觉方案:把所有结点压栈,再按出栈顺序重接,能反转但要 O(n) 额外空间;原地改 next 才能降到 O(1)。

正解①:三指针原地反转。
① 保存后继:先令 next_node=cur.next,否则下一步掉头后会丢失余链。
② 当前掉头:cur.next=prev,把当前结点接到已反转段前面。
③ 双指针前进:prev=curcur=next_node,重复直到 cur 为空。
④ 返回新头:循环结束时 prev 停在原尾。

正解②:递归回溯。先反转 head.next 起的后段;返回后执行 head.next.next=head 让后继反指当前结点,再用 head.next=None 清掉旧边。若少最后一步,当前结点与后继会形成短环。

取舍:两者都 O(n) 时间。迭代 O(1) 空间、无栈溢出风险,是首选;递归 O(n) 栈空间但代码短,且是理解 24/25 递归解法的基础。

具体例子与优化:处理 1→2→3→4 时,完成节点 2 后现场是 prev=2→1cur=3,而尚未处理的 3→4 仍由预先保存的后继保持可达。继续同样动作即可得到 4→3→2→1;每步只保存三根指针,把栈方案的 O(n) 空间降为 O(1)。

⚠ 易错坑 迭代四行顺序不能乱:必须先 nxt = cur.nextcur.next = prev,反了就在掉头后丢失后继、断链。
循环结束返回 prev 而非 cur——退出时 cur 已是 None,prev 才停在原尾即新头。
递归里 head.next.next = head 之后必须 head.next = None,否则原头与其后继互相指向形成环。
3 · 图解
解法① 迭代,1 -> 2 -> 3 -> None

帧1  初始:prev=None | cur=1 -> 2 -> 3
帧2  保存 next_node=2,再写 1.next=None:
      1 -> None | next_node=2 -> 3   (余链由 next_node 保住)
帧3  反转 2 后:prev=2 -> 1 | cur=3
帧4  反转 3 后:prev=3 -> 2 -> 1 | cur=None

解法② 递归回溯时的短环:
帧1  出口 head=3:3 -> None
帧2  写 3.next=2 后:2↔3(临时短环);清 2.next 后:3->2
帧3  写 2.next=1 后:1↔2(临时短环);清 1.next 后:3->2->1
解法① 迭代:按一轮反转汇总,瞬时断链见上方 ASCII
阶段 / 本轮摘要previouscurrentnext_node链表现场
0初始化None1待处理 1->2->3
1保存 2、反转结点 1、双指针前移1221->None | 2->3
2保存 3、反转结点 2、双指针前移2332->1 | 3
3保存 None、反转结点 3、双指针前移3NoneNone3->2->1
4cur 为空,返回 prev3NoneNone新头为 3
解法② 递归:按递归出口与每层回溯汇总,短环细节见上方 ASCII
阶段 / 本轮摘要当前 head链表 / 引用现场newHead
1递归下探 1→2→333.next=None,命中出口3
2回到 head=2:反接 3→2,再清除 2→32稳定为 3->2->None3
3本层返回 newHead2后段已反转3
4回到 head=1:反接 2→1,再清除 1→21稳定为 3->2->13
5最外层返回 newHead13->2->1->None3
4 · 代码
class Solution:
    def reverseList(self, head):
        previous, current = None, head

        # 1. 每轮先保存后继,再让当前结点掉头
        while current:
            next_node = current.next        # 掉头前保住余链
            current.next = previous
            previous, current = current, next_node

        # 2. current 已到空,previous 正好是原尾
        return previous
# 解法二:递归(先反转后段,回溯时掉头)
class Solution:
    def reverseList(self, head):
        # 1. 空链或单结点已经完成反转
        if not head or not head.next:
            return head

        # 2. 先反转后段,回溯时把当前结点接到末尾
        new_head = self.reverseList(head.next)
        head.next.next = head
        head.next = None                  # 清旧边,否则当前结点与后继成环
        return new_head
5 · 复杂度

时间 O(n) 遍历一遍;空间 迭代 O(1),递归 O(n)(调用栈深度=链长)。

6 · 测试用例

功能1->2->33->2->1边界:空链表返回 None、单结点返回自身;特殊:确认原头结点 next 最终为 None(否则成环)。

回文链表

LC 234 ↗链表 · Easy
1 · 题目

要完成的任务

给定单链表头节点 head,判断从头到尾读出的节点值序列是否为回文,返回布尔值。

输入与输出

数组表示法按链表顺序列出节点值;回文要求正向与反向序列完全相同。

示例 1

输入:head=[1,2,2,1]

输出:true

回文链表的镜像配对链表 1、2、2、1 从两端向中间配对,外层两个 1 相等,内层两个 2 相等。1221两对都相等
示例 1:从两端向中间配对,得到 1=12=2

示例 2

输入:head=[1,2]

输出:false

约束

  • 节点数满足 1 ≤ n ≤ 10⁵
  • 0 ≤ Node.val ≤ 9
  • 进阶目标是 O(n) 时间和 O(1) 额外空间。
2 · 思路(暴力→最优)

暴力:把结点值依次倒进数组,再用首尾双指针判回文,O(n) 时间但 O(n) 空间。

正解:找中点 + 反转后半段 + 比对。
① 找中点:slow 每次走 1 步、fast 每次走 2 步;fast 到尾时 slow 停在中点或右中点。
② 反转后半:从 slow 开始原地反转,得到从尾部向中间可走的新头 right_head
③ 两端比对:left 从原头、rightright_head 同步前进,任一值不同立即返回 False。
④ 结构取舍:本法 O(1) 额外空间,但会改动链表;若调用方要求保留原状,比较后再反转一次还原。

为什么奇数长不用特判中点?这份代码让 slow 停在中点,并从 slow 开始反转,所以奇数长时中点包含在反转段里。比对最后一次恰好是“中点和同一个中点对象”相比,必然相等;随后 right 变 None,循环结束,不需要单独跳过或删除中点。

取舍:数组法好写但费 O(n) 空间;本法 O(1) 空间达进阶要求,代价是会改动链表(如需还原,比对后把后半再反转回去)。

具体例子与优化:1→2→3→2→1,快慢指针使 slow 停在 3;反转 3→2→1 后,从两端依次比较 1=12=23=3。只翻转后半段,不需要保存整个值数组。

⚠ 易错坑 找中点循环条件 while fast and fast.next:奇数长 fast 停在尾结点、偶数长 fast 变 None,两种都靠这个条件正确停下。
比对循环用 while right(反转段驱动)而非 while left;奇数长时反转段包含中点,最后会让中点与自身比较。
本法会破坏原链表结构;若题目要求保持原状,需在返回前把后半段再反转还原。
3 · 图解
例:1a -> 2a -> 3 -> 2b -> 1b -> None(a/b 表示不同对象)

帧1  快慢定位:slow 依次 1a→2a→3,停在中点 3
帧2  保存 2b 后写 3.next=None:
      左路 1a->2a->3->None | 余链 2b->1b->None(没有丢链)
帧3  后半反转稳定:right_head=1b->2b->3->None
帧4  比对:1a=1b,2a=2b,最后 3=3(同一中点对象)
帧5  right 耗尽,返回 True
回文链表:一张表概括找中点、反转后半与两端比对,断链瞬时态见上图
阶段动作slow / rightfast / left结构与判定
0初始化快慢指针slow=1afast=1a开始找中点
1快慢指针完成两轮移动slow=3fast=1bfast.next=None,中点为 3
2先反转结点 3right_head=3slow=2b3->None | 2b->1b
3继续反转 2b、1bright_head=1bslow=None1b->2b->3
4比较外侧 1a 与 1bright=2bleft=2a相等 ✓
5比较内侧 2a 与 2bright=3left=3相等 ✓
6比较同一中点并前移right=Noneleft=None全部相等,返回 True
4 · 代码
class Solution:
    def isPalindrome(self, head):
        # 1. 快慢指针定位中点或右中点
        slow = fast = head
        while fast and fast.next:
            slow = slow.next
            fast = fast.next.next

        # 2. 原地反转从 slow 开始的后半段
        right_head = None
        while slow:
            next_node = slow.next
            slow.next = right_head
            right_head, slow = slow, next_node

        # 3. 以后半段长度为准,从两端同步比对
        left, right = head, right_head
        while right:
            if left.val != right.val:
                return False
            left, right = left.next, right.next
        return True
5 · 复杂度

时间 O(n):找中点 + 反转 + 比对各 O(n);空间 O(1),只在原链表上反转。

6 · 测试用例

功能:偶数 1->2->2->1 True、非回文 1->2->3 False;边界:空/单结点均 True;特殊:奇数长时反转段包含中点,最后中点与自身比较。

环形链表

LC 141 ↗链表 · Easy
1 · 题目

要完成的任务

给定链表头节点 head,判断沿 next 不断前进是否会再次到达已经访问过的节点;有环返回 true,无环返回 false

字段含义

pos 表示尾节点连接到链表中第几个节点(从 0 开始),-1 表示尾节点指向 null。它只用于描述测试数据,不会传入函数。

示例 1

输入:head=[3,2,0,-4], pos=1

输出:true,尾节点 -4 指回索引 1 的节点 2。

尾节点指回节点 2 的环形链表3 指向 2,2 指向 0,0 指向负 4,负 4 再指回 2,形成环。320−4回到索引 1
示例 1:从节点 2 开始的 2→0→-4→2 会无限循环。

示例 2

输入:head=[1,2], pos=0

输出:true

示例 3

输入:head=[1], pos=-1

输出:false

约束

  • 节点数在 [0,10⁴] 内,-10⁵ ≤ Node.val ≤ 10⁵
  • pos-1 或链表中的有效下标。
  • 目标是使用 O(1) 额外空间。
2 · 思路(暴力→最优)

暴力:哈希集记录访问过的结点,再次遇到同一对象即有环,O(n) 时间 O(n) 空间。

正解:Floyd 快慢指针。
① 同头出发:slow、fast 都从 head 开始,但初始化相等不能算命中。
② 移动后判断:slow 每轮走 1 步,fast 每轮走 2 步,再比较对象身份。
③ 有环必相遇:两者进入环后,fast 每轮比 slow 多走 1 格,环上距离逐格缩短,最终为 0。
④ 无环会撞尾:fast 或 fast.next 为空时退出;全程只用两根指针。

具体例子与优化:3→2→0→-4→2 中,快慢指针进入 2→0→-4 环后,快指针每轮相对多走一格,二者最终在环内同一节点相遇。这个“相对距离每轮减 1”的规律替代了哈希表记录。

⚠ 易错坑 循环条件 while fast and fast.next:fast 一次跳两步,必须保证 fast.next 存在,否则 fast.next.next 空指针报错。
判相遇 slow is fast 要放在移动之后;初始 slow=fast=head 本就相等,若移动前判会立即误判成有环。
is 比对象身份;有环时是"指回同一个结点对象",不是值相等。
3 · 图解
结构:3 -> 2 -> 0 -> -4
             ^             |
             +-------------+     (-4.next 回到同一个结点 2)

帧1  初始化:slow=3, fast=3(尚未移动,不判命中)
帧2  第1轮后:slow=2, fast=0
帧3  第2轮后:slow=0, fast=2
帧4  第3轮后:slow=-4,fast=-4,同一对象 ✓
Floyd 判环:按每轮“移动完成后再判断”汇总
动作slowfastslow is fast ?
0初始化 slow=fast=head33移动前不判
1第1轮:slow 走1步,fast 走2步20不同
2第2轮:继续按 1/2 步幅移动02不同
3第3轮:两指针都落到 -4-4-4同一对象
4命中 slow is fast-4-4返回 True

无环:fast 每步领先, 会先走到 fast.next=None, 循环条件失败退出, 返回 False。

4 · 代码
class Solution:
    def hasCycle(self, head):
        slow = fast = head

        # 1. fast 可安全走两步时,再按 1/2 步幅移动
        while fast and fast.next:
            slow = slow.next
            fast = fast.next.next

            # 2. 必须移动后再判对象身份,避免初始化时误判
            if slow is fast:
                return True
        return False
5 · 复杂度

时间 O(n):无环走 n/2,有环进环后间距每步 -1 至多绕一圈相遇;空间 O(1)。

6 · 测试用例

功能:有环 True、无环 False;边界:空链表/单结点无环 False、单结点自环 True;特殊:环入口即头结点。

环形链表 II

LC 142 ↗链表 · Medium
1 · 题目

要完成的任务

给定链表头节点 head,若链表有环,返回从头出发第一次进入环的节点;无环返回 null。不得修改链表。

字段含义

pos 是尾节点所连接节点的 0-based 下标,-1 表示无环;它只描述测试数据,不作为函数参数。评测按返回的节点对象判断答案。

示例 1

输入:head=[3,2,0,-4], pos=1

输出:索引 1 的节点,即值为 2 的入环点。

环形链表及其入环点链表从 3 进入节点 2,随后经过 0、负 4 又回到 2;节点 2 是首个环内节点。32(入口)0−4
示例 1:从头到节点 2 的边不属于环,节点 2 本身是入环点。

示例 2

输入:head=[1,2], pos=0

输出:索引 0 的节点。

示例 3

输入:head=[1], pos=-1

输出:null

约束与保证

  • 节点数在 [0,10⁴] 内,-10⁵ ≤ Node.val ≤ 10⁵
  • pos-1 或链表中的有效下标。
  • 题目要求不修改链表;进阶目标是 O(1) 额外空间。
2 · 思路(暴力→最优)

直觉方案:用哈希集记录访问过的对象,第一个重复对象就是入口,O(n) 时间但要 O(n) 空间。

O(1) 空间方案在 141 判环基础上求入口。设 head 到入口距离 a、入口到相遇点距离 b、相遇点绕回入口距离 c(环长 = b+c)。

正解:相遇后再走一趟。
① 找相遇点:先按 LC141 用快慢指针在环内相遇。
② 推出同余:慢指针走 a+b,快指针多走整数圈,故 a=(k-1)(b+c)+c;从 head 到入口的距离,与相遇点沿环到入口的距离同余。
③ 双方同速:新指针 entry 从 head 出发,slow 留在相遇点,两者每次各走一步。
④ 入口相遇:第一次指向同一对象的位置就是环入口。

取舍:也可用哈希集记录首个重复访问结点,O(n) 空间但直白;本法 O(1) 空间,唯一门槛是这段同余推导。

⚠ 易错坑 第二阶段 entry 从 head 起,另一指针要接着留在相遇点继续走,不能把它也重置回 head。
入口即头结点(a=0)时同样成立:entry 与 slow 会在头处相遇,循环 while entry is not slow 天然处理。
复用第一次相遇时的 slow,别新建指针从头重走,那样破坏了"c 步同余"的前提。
3 · 图解
距离关系:head --a--> [入口X] --b--> [相遇点M]
                         ^                     |
                         +---------c-----------+   环长=b+c

具体链:1 -> 2 -> [3] -> 4 -> 5
                    ^              |
                    +--------------+   (5.next 回到同一个 3)

帧1  Floyd 起点:slow=1,fast=1
帧2  两轮后:slow=3,fast=5
帧3  第一次相遇:slow=fast=4(M)
帧4  entry 从 1、slow 从 4 同速走:1/4 → 2/5 → 3/3
帧5  同一对象 3,确定入口 ✓

本例 a=2;M=4 沿环到入口也走 4->5->3 共 2 步,
所以两指针恰好同步到 3。
Floyd 两阶段:先在环内相遇,再让 entry 与 slow 同速走到入口
阶段动作slowfast / entry判定
0初始化快慢指针1fast=1移动前不判
1Floyd 第1轮移动2fast=3不同
2Floyd 第2轮移动3fast=5不同
3Floyd 第3轮移动4fast=4在 M 相遇
4entry 从 head 出发4entry=1开始第二阶段
5两指针同速走1步5entry=2仍不同
6再同速走1步3entry=3同一对象,返回入口 3

验证 a=c:a=2(head→入口 3), c=2(相遇点 4 绕回入口:4→5→3), 恰好相等, 印证推导。

4 · 代码
class Solution:
    def detectCycle(self, head):
        # 1. Floyd 第一阶段:寻找环内相遇点
        slow = fast = head
        while fast and fast.next:
            slow = slow.next
            fast = fast.next.next
            if slow is fast:
                # 2. 一方从头、一方留在相遇点,同速走到入口
                entry = head
                while entry is not slow:
                    entry = entry.next
                    slow = slow.next
                return entry
        return None
5 · 复杂度

时间 O(n):找相遇 O(n) + 找入口至多再走 a 步;空间 O(1)。

6 · 测试用例

功能:环入口在中段返回该点;边界:无环返回 None、单结点自环入口=自身;特殊:入口即头结点(a=0,entry 与 slow 起点即相遇)。

合并两个有序链表

LC 21 ↗链表 · Easy
1 · 题目

要完成的任务

给定两个按非递减顺序排列的单链表 list1list2,把两条链表的现有节点拼接成一条同样有序的链表,并返回合并后头节点。

输入与输出

数组分别展示两条链表从头到尾的节点值;输出数组展示返回头节点沿 next 可访问到的值。

示例 1

输入:list1=[1,2,4], list2=[1,3,4]

输出:[1,1,2,3,4,4]

两条有序链表合并成一条有序链表输入链表分别为 1、2、4 和 1、3、4,输出按顺序为 1、1、2、3、4、4。list1 1 → 2 → 4list2 1 → 3 → 4结果 1 → 1 → 2 → 3 → 4 → 4
每次取两个当前头节点中较小者,重复后得到底部有序链。

示例 2

输入:list1=[], list2=[]

输出:[]

示例 3

输入:list1=[], list2=[0]

输出:[0]

约束与保证

  • 两条链表的节点数都在 [0,50] 内。
  • -100 ≤ Node.val ≤ 100
  • list1list2 都已按非递减顺序排列。
2 · 思路(暴力→最优)

直觉方案:收集两链所有值、排序后新建链表,时间 O((m+n)log(m+n))、空间 O(m+n),也浪费了输入已经各自有序的信息。

正解:双指针 + dummy。
① 建立结果尾:dummy 提供固定前驱,tail 始终指向已合并段末尾。
② 比较两头:每轮从 l1l2 当前头中选更小者接到 tail 后。
③ 前移来源:被选链表的指针前移,tail 跟到刚接入的结点。
④ 拼接余链:一方为空时,另一方剩余段本身有序,整段接上即可。

取舍:迭代 O(1) 空间(复用原结点,不新建);递归写法更短但有 O(m+n) 栈深。相比 LC23 合并 K 路,这里是最基础的两路合并子过程。

具体例子与优化:合并 1→2→41→3→4 时,当前头依次选 1(l1)、1(l2)、2、3、4(l1);此时 l1 为空,直接整段接上 l2 剩余的 4。利用已有序性后无需重新排序。

⚠ 易错坑 比较用 l1.val <= l2.val(含等号)保证相等元素稳定(l1 优先),也避免值相等时反复取同一边的无谓分支。
循环 while l1 and l2 退出后必须 tail.next = l1 or l2 把非空剩余段接上,漏了会丢尾巴。
返回 dummy.next 不是 dummy——dummy 是哨兵不属于结果。
3 · 图解
l1: 1a -> 3 -> None      l2: 2 -> None
结果:dummy -> None,tail=dummy

里程碑1  选择 1a:dummy->1a,l1 前移到 3
里程碑2  选择 2 时覆盖 1a 的旧边:
           dummy->1a->2,l1 仍指向 3(因此 3 没丢)
里程碑3  l2 耗尽:tail=2,剩余 l1=3
里程碑4  整段拼接:dummy->1a->2->3->None ✓
双指针归并:按一次选择与接入汇总,瞬时双重引用见上图
阶段 / 本轮摘要taill1 头l2 头结果 / 引用现场
0初始化 dummy 与 taildummy1a2结果为空
11≤2,接入 l1 当前头并前移1a321a
23>2,接入 l2 当前头并前移23None1a->2
3l2 耗尽,退出主循环23None剩余 l1 已有序
4整段接上非空剩余链23None1a->2->3
4 · 代码
class Solution:
    def mergeTwoLists(self, l1, l2):
        # 1. 哨兵统一处理结果链的第一个结点
        dummy = tail = ListNode()

        # 2. 每轮接入两个当前头中的较小者
        while l1 and l2:
            if l1.val <= l2.val:
                tail.next, l1 = l1, l1.next
            else:
                tail.next, l2 = l2, l2.next
            tail = tail.next

        # 3. 一方耗尽后,另一方剩余段可整段接入
        tail.next = l1 or l2
        return dummy.next
5 · 复杂度

时间 O(m+n),每个结点接一次;空间 O(1)(复用原结点,不新建)。递归写法栈深 O(m+n)。

6 · 测试用例

功能:交替合并;边界:一方为空返回另一方、两方都空返回 None;特殊:一方所有值都小于另一方(尾部整段拼接)、等值元素(<= 保证稳定)。

两数相加

LC 2 ↗链表 · Medium
1 · 题目

要完成的任务

两个非空链表分别以逆序数字表示两个非负整数:头节点是个位,每个节点保存一位。计算两数之和,并用同样的逆序链表返回。

输入与输出

l1l2 中的每个值都是十进制数字。除数字 0 本身外,输入整数没有前导零;输出也按“个位在头”的方式解释。

示例 1

输入:l1=[2,4,3], l2=[5,6,4]

输出:[7,0,8],因为 342+465=807

逆序数字链表逐位相加链表 2、4、3 表示 342,链表 5、6、4 表示 465,相加输出 7、0、8,表示 807。l1 2(个) → 4(十) → 3(百) = 342l2 5(个) → 6(十) → 4(百) = 465和 7(个) → 0(十) → 8(百) = 807
同一位置的节点直接相加,进位沿 next 方向传给下一位。

示例 2

输入:l1=[0], l2=[0]

输出:[0]

示例 3

输入:l1=[9,9,9,9,9,9,9], l2=[9,9,9,9]

输出:[8,9,9,9,0,0,0,1]

约束与保证

  • 每条链表的节点数在 [1,100] 内。
  • 0 ≤ Node.val ≤ 9
  • 题目保证链表表示的数没有前导零。
2 · 思路(暴力→最优)

直觉方案:先把两条链还原为整数再相加;但超长数字可能溢出,也没有利用“个位在头”的逐位处理顺序。

正解:逐位相加 + 进位。
① 读取本位:逆序链表头就是个位;某一侧耗尽时该位按 0 处理。
② 计算结果:carry,digit=divmod(a+b+carry,10),商是进位,余数是本位。
③ 追加结点:把 digit 新建为结果结点,tail 与输入指针一起前移。
④ 处理末进位:循环条件包含 carry;例如 5+5 在输入耗尽后还要再追加 1。

取舍:短链走空的一侧当作 0 参与相加(a = l1.val if l1 else 0),一套循环同时处理"不等长 + 收尾进位",无需分支特判。

⚠ 易错坑 循环条件必须含 or carry,否则 5+5 这类末位进位会丢最高位。
两链不等长时,走空一侧取值补 0,别对 None.val 取值报错。
divmod(s, 10) 返回 (商, 余)——商是进位、余是本位,别写反。
3 · 图解
输入:l1: 5 -> None    l2: 5 -> None
初始:dummy -> None,tail=dummy,carry=0

里程碑1  初始化:结果空,carry=0
里程碑2  5+5+0=10:追加 0,carry=1;两输入耗尽
里程碑3  carry 单独触发下一轮:0+0+1=1
里程碑4  追加 1:dummy->0->1,carry=0,结束 ✓
结果 0->1 以逆序表示整数 10。
逐位相加 5+5:按每一位的“取值→计算→追加”汇总
阶段 / 本轮摘要digit_a,digit_bcarry,digittail / 输入指针结果链
0初始化结果哨兵与进位0, —tail=dummy;l1=5,l2=5
1读取首位并计算 5+5+05, 51, 0输入尚未前移
2追加本位 0,并耗尽两输入5, 51, 0tail=0;l1=l2=None0
3carry 触发收尾轮:0+0+10, 00, 1tail=10->1
4输入为空且 carry=0,退出0, 00, 1l1=l2=None返回 0->1
4 · 代码
class Solution:
    def addTwoNumbers(self, l1, l2):
        dummy = tail = ListNode()
        carry = 0

        # 1. 输入或末尾进位尚未耗尽,就继续处理一位
        while l1 or l2 or carry:
            digit_a = l1.val if l1 else 0
            digit_b = l2.val if l2 else 0

            # 2. 商是下一位进位,余数是当前结果位
            carry, digit = divmod(digit_a + digit_b + carry, 10)
            tail.next = ListNode(digit)
            tail = tail.next

            # 3. 较短链耗尽后保持 None,后续按 0 参与
            l1 = l1.next if l1 else None
            l2 = l2.next if l2 else None
        return dummy.next
5 · 复杂度

时间 O(max(m,n)),逐位处理;空间 O(max(m,n)) 存结果链表(进位可能多一位)。

6 · 测试用例

功能:342+465=807;边界:一方明显更长(9->9 + 1)、其中一方为 0;特殊:最高位进位(5+5=100->1),全靠 carry 留在循环条件里。

删除链表的倒数第 N 个结点

LC 19 ↗链表 · Medium
1 · 题目

要完成的任务

给定单链表头节点 head 和整数 n,删除倒数第 n 个节点,并返回删除后的头节点。

输入与输出

n=1 表示删除尾节点,n 等于链表长度时表示删除头节点;题目保证 n 有效。

示例 1

输入:head=[1,2,3,4,5], n=2

输出:[1,2,3,5]

删除五节点链表的倒数第二个节点原链表为 1 到 5,倒数第二个节点是 4;删除后节点 3 直接指向 5。原链 1 → 2 → 3 → 4 → 5(删除 4)结果 1 → 2 → 3 ─────→ 5
示例 1:跳过节点 4,把它的前驱节点 3 直接连到节点 5。

示例 2

输入:head=[1], n=1

输出:[]

示例 3

输入:head=[1,2], n=1

输出:[1]

约束与保证

  • 链表节点数 sz 满足 1 ≤ sz ≤ 30
  • 0 ≤ Node.val ≤ 100,且 1 ≤ n ≤ sz
  • 进阶要求只扫描链表一次。
2 · 思路(暴力→最优)

暴力(两趟):先走一趟数出长度 L,再走到第 L-n 个结点(待删结点的前驱)改指针跳过。正确但扫两遍。

正解:快慢指针固定间距。
① 加哨兵:dummy 让头结点也拥有统一前驱。
② fast 先行:从 dummy 出发先走 n+1 步;多出的 1 步用于让 slow 最终停在目标前驱。
③ 同速到尾:fast、slow 一起走,fast 到 None 时 slow 正好指向待删结点的前驱。
④ 跳过目标:slow.next=slow.next.next,再返回 dummy.next

具体例子与优化:1→2→3→4→5n=2fast 从哨兵先走 3 步后停在 3;随后快慢同步,fast 走出链表时 slow 正好停在 3,即待删节点 4 的前驱。固定间距把“两趟计数”优化成一趟扫描。

⚠ 易错坑 fast 先行 n+1 步(从 dummy 起),不是 n 步——差这一格就变成停在待删结点本身。
fast、slow 都从 dummy 出发,别从 head 起,否则删头会漏前驱。
只有一个结点且 n=1 时,靠 dummy 删头后返回 dummy.next = None,正确。
3 · 图解
例:dummy -> 1 -> 2 -> 3 -> 4 -> None,n=2(删结点 3)

里程碑1  fast=slow=dummy
里程碑2  fast 先走 n+1=3 步:
      dummy -> 1 -> 2 -> 3 -> 4 -> None
        slow              fast
里程碑3  同步两轮后:slow=2,fast=None;slow 是 3 的前驱
      dummy -> 1 -> 2 -> 3 -> 4
                         ^
                        slow.next
里程碑4  写 slow.next=slow.next.next 后:
主链:dummy -> 1 -> 2 ----------> 4 -> None
脱链:                    3 ------> 4
                          (3 仍指 4,但已无主链前驱)
快慢指针一趟:先行阶段合并展示,同步阶段按轮次汇总
动作fastslow说明
0建立 dummy,快慢指针同起点dummydummy统一处理删头
1fast 连续先行 n+1=3 步3dummy固定领先3条边
2同步第1轮41间距不变
3同步第2轮None2slow 到待删前驱
4前驱跳过结点 3None2主链变为 1->2->4
5返回 dummy.next新头仍为 1
4 · 代码
class Solution:
    def removeNthFromEnd(self, head, n):
        # 1. 哨兵为头结点提供统一前驱
        dummy = ListNode(0, head)
        fast = slow = dummy

        # 2. fast 先走 n+1 步,让 slow 最终停在待删结点前驱
        for _ in range(n + 1):
            fast = fast.next
        while fast:
            fast, slow = fast.next, slow.next

        # 3. 前驱越过目标结点
        slow.next = slow.next.next
        return dummy.next
5 · 复杂度

时间 O(L) 一趟;空间 O(1)。

6 · 测试用例

功能:删中间结点;边界:删头结点(n=L,靠 dummy)、删尾结点(n=1);特殊:链表仅一个结点且 n=1(返回 None)。

两两交换链表中的节点

LC 24 ↗链表 · Medium
1 · 题目

要完成的任务

给定单链表头节点 head,每两个相邻节点交换一次,并返回交换后的头节点。只能改变节点之间的连接,不能只交换节点值。

输入与输出

数组按 next 顺序展示链表。若最后只剩一个节点,它没有配对对象,应原样留在尾部。

示例 1

输入:head=[1,2,3,4]

输出:[2,1,4,3]

四节点链表两两交换前后原顺序 1、2、3、4 按相邻两节点分组,交换后成为 2、1、4、3。原链 (1 → 2) (3 → 4)结果 (2 → 1) → (4 → 3)
两个完整分组分别翻转,组间仍保持原来的先后顺序。

示例 2

输入:head=[]

输出:[]

示例 3

输入:head=[1]

输出:[1]

示例 4

输入:head=[1,2,3]

输出:[2,1,3]

约束与保证

  • 节点数在 [0,100] 内。
  • 0 ≤ Node.val ≤ 100
  • 不得通过修改节点值完成交换。
2 · 思路(暴力→最优)

直觉方案:直接交换相邻结点的值最简单,但题目明确要求交换结点;新建链表则要 O(n) 空间。

正解①:dummy + 前驱指针迭代。
① 取出一对:first=pair_prev.nextsecond=first.next
② 保住后段:先写 first.next=second.next,让 first 越过 second 接回余链。
③ 交换并接回:second.next=first,再写 pair_prev.next=second
④ 前驱前移:pair_prev 移到交换后的组尾 first,继续下一对。

正解② 递归。定理:swapPairs(head) = "交换头两个 first、second,让 first.next 接上 swapPairs(剩余链),返回 second"。子问题独立,代码更短。

取舍:本题是 LC25 的 k=2 特例。迭代 O(1) 空间为首选;递归 O(n) 栈但直观。

⚠ 易错坑 重接顺序:a.next = b.next(保后段),再 b.next = a,再 pre.next = b;顺序错就断链。
pre 前移到 a(这对的尾)不是 b——b 现在是这对的头。
循环条件 pre.next and pre.next.next 确保还剩两个;奇数个时最后一个不成对、保持不动。
3 · 图解
解法① 迭代,交换第一对 1(a)、2(b)
帧1  dummy -> 1(a) -> 2(b) -> 3 -> 4,pre=dummy

写 a.next=b.next 后(a 越过 b;3 暂被 a、b 双重指向):
      dummy -> 1(a) ---------> 3 -> 4
                     2(b) ----^
                     (b 变量保住脱离主路径的结点 2)

写 b.next=a 后(交换对已成形,但尚未接回 pre):
      b 路径:2 -> 1 -> 3 -> 4
      主路径:dummy -> 1 -> 3 -> 4
      结点 1 此刻有 dummy 与 b 两个前驱引用

写 pre.next=b 后(稳定态):
帧4  dummy -> 2 -> 1 -> 3 -> 4,pre 前移到 1

帧5  第二对同法稳定:dummy -> 2 -> 1 -> 4 -> 3 -> None

解法② 递归回溯
帧1  递归先稳定后段:4 -> 3 -> None
帧2  回到首对:1.next=4,再令 2.next=1
帧3  最终:2 -> 1 -> 4 -> 3 -> None
解法① 迭代:按“定位一对→完成交换”汇总,三次改线瞬时帧见上图
阶段 / 本轮摘要firstsecondpair_prev从 dummy 可见 / 临时引用
0建立 dummy,pre 指向哨兵dummy1->2->3->4
1定位第一对 a=1、b=212dummy具备两个结点
2完成三次重连,pre 前移到 11212->1->3->4
3定位第二对 a=3、b=4341具备两个结点
4完成三次重连,pre 前移到 33432->1->4->3
5剩余不足一对,返回结果头32->1->4->3
解法② 递归:按一次递归调用/返回汇总,回溯重连细节见上图
阶段 / 本轮摘要first, second结构现场本层返回
1递归处理头对 1、21, 2等待后段结果待定
2递归处理头对 3、43, 4继续调用空后段待定
3空后段命中出口NoneNone
4回到 3、4:接后段并交换3, 44->3->None4
5回到 1、2:接上递归结果并交换1, 22->1->4->32
4 · 代码
class Solution:
    def swapPairs(self, head):
        dummy = pair_prev = ListNode(0, head)

        # 1. 剩余至少两个结点时,取出当前一对
        while pair_prev.next and pair_prev.next.next:
            first = pair_prev.next
            second = first.next

            # 2. 先让 first 接回后段,再交换本对并接回前驱
            first.next = second.next         # 此步保住后段,顺序不能后移
            second.next = first
            pair_prev.next = second

            # 3. first 已成为本对尾结点
            pair_prev = first
        return dummy.next
# 解法二:递归(交换头两个,后段递归)
class Solution:
    def swapPairs(self, head):
        # 1. 不足两个结点时保持尾段原样
        if not head or not head.next:
            return head

        # 2. first 接递归处理后的后段,second 再反指 first
        first, second = head, head.next
        first.next = self.swapPairs(second.next)
        second.next = first
        return second
5 · 复杂度

时间 O(n),每对常数次指针操作;空间 迭代 O(1),递归 O(n)。

6 · 测试用例

功能1->2->3->42->1->4->3边界:空/单结点原样返回;特殊:奇数个结点(最后一个不成对,保持不动)。

K 个一组翻转链表

LC 25 ↗链表 · Hard
1 · 题目

要完成的任务

给定单链表头节点 head 和整数 k,从头开始每 k 个节点组成一组并翻转,返回处理后的头节点;末尾不足 k 个的节点保持原顺序。

输入与输出

只能改变节点连接,不能修改节点值。题目中的数组按 next 顺序展示输入和输出链表。

示例 1

输入:head=[1,2,3,4,5], k=2

输出:[2,1,4,3,5]

五节点链表按两节点或三节点分组翻转当 k 为 2,两个完整组分别翻转,5 保留;当 k 为 3,前三个节点翻转,4、5 保留。原链 1 → 2 → 3 → 4 → 5k=2  2 → 1 → 4 → 3 → 5k=3  3 → 2 → 1 → 4 → 5
只有节点数达到 k 的完整分组才翻转。

示例 2

输入:head=[1,2,3,4,5], k=3

输出:[3,2,1,4,5]

约束与保证

  • 链表节点数为 n,且 1 ≤ k ≤ n ≤ 5000
  • 0 ≤ Node.val ≤ 1000
  • 末尾不足 k 个节点时必须保持原顺序,进阶目标是 O(1) 额外空间。
2 · 思路(暴力→最优)

直觉方案:每 k 个结点放进栈再倒序接出,逻辑直观,但最坏需要 O(k) 辅助空间;原地翻转可做到 O(1)。

正解:分组翻转(LC206 的推广)。
① 探测整组:group_prev 向后走 k 步找 kth;不足 k 个就保持原序并返回。
② 记住边界:group_next=kth.next,它既是下一组头,也是本组翻转的终止位置。
③ 反转本组:按三指针反转,且 prev 初值设为 group_next,让新组尾自动接回后段。
④ 接回前驱:原组头已变组尾;把 group_prev.next 指向 kth,再把 group_prev 移到新组尾。

为什么 prev 初值取 groupNext 而非 None:普通 206 反转到尾时 next 是 None;这里组尾要接的是下一组,所以把"翻转的终点"直接设成下一组头,翻完自动衔接,省一步手动接线。dummy 哨兵统一处理首组前驱。

具体例子与优化:1→2→3→4→5k=3,先确认第 3 个节点存在并记住 group_next=4;翻转时以 4 作为 prev 初值,稳定后直接得到 3→2→1→4→5。尾部只剩 2 个,不足一组便不再动边。

⚠ 易错坑 必须先探测够 k 个再翻转;不够就整组保持原序,不能翻一半。
组内翻转 prev 初值是 groupNext(下一组头)不是 None,否则组尾接不上后续。
重连要先 tmp = groupPrev.next(暂存原组头=新组尾),再 groupPrev.next = kth,最后 groupPrev = tmp;顺序错会丢失前移目标。
3 · 图解
例:dummy -> 1 -> 2 -> 3 -> None,k=2,groupPrev=dummy

帧1  探测够 2 个:kth=2,group_next=3
帧2  组内反转开始:prev=3,cur=1

保存旧后继 2,再写 1.next=3 后:
      dummy -> 1 ---------> 3
                 2 -------^     (3 暂被 1、2 双重指向)
next_node=2 保住尚未处理结点

保存旧后继 3,再写 2.next=1 后:
      cur/prev 路径:2 -> 1 -> 3
      主链仍是:dummy -> 1 -> 3
      (新组头 2 尚未接回 groupPrev)

帧4  接回前驱后:dummy -> 2 -> 1 -> 3 -> None
帧5  下一组只剩 3,不足 k;保持不动并返回 2->1->3
分组翻转:按“探测一组→组内翻转→组间重连”阶段汇总
阶段 / 本轮摘要kthpreviouscurrentgroup_prev / 链表现场
0建立 dummy 与 groupPrevdummy->1->2->3
1向后探测 k=2 个结点2本组足 k 个;groupNext=3
2组内第1轮反转结点 12121->3 | 2->3
3组内第2轮反转结点 22232->1->3,尚未接回 dummy
4前驱接新组头,前移到新组尾223dummy->2->1->3;groupPrev=1
5探测下一组None仅剩结点 3,不足 k
6保持不足组原序并返回None2->1->3
4 · 代码
class Solution:
    def reverseKGroup(self, head, k):
        dummy = group_prev = ListNode(0, head)

        while True:
            # 1. 先探测完整一组;不足 k 个时尾段保持原序
            kth = group_prev
            for _ in range(k):
                kth = kth.next
                if not kth:
                    return dummy.next
            group_next = kth.next

            # 2. 仅反转 [group_prev.next, group_next);prev 从后段头起
            previous, current = group_next, group_prev.next
            while current is not group_next:
                next_node = current.next
                current.next = previous
                previous, current = current, next_node

            # 3. 原组头已成为组尾,接回新组头并移动组前驱
            group_tail = group_prev.next
            group_prev.next = kth
            group_prev = group_tail
5 · 复杂度

时间 O(n),每个结点被探测 + 翻转常数次;空间 O(1) 迭代。

6 · 测试用例

功能:k=2/k=3 分组翻转;边界:k=1(原样)、k=链长(整体翻转);特殊:长度非 k 整数倍,末尾不足组保持原序。

随机链表的复制

LC 138 ↗链表 · Medium
1 · 题目

要完成的任务

给定一条特殊链表,其中每个节点除 next 外还有可指向链表任意节点或 nullrandom 指针。创建结构完全相同的深拷贝并返回新链表头。

输入表示与深拷贝含义

页面把每个节点写成 [val,random_index]:第二项是 random 所指节点的下标,null 表示不指向节点。新链表中的每个节点都必须是新对象,所有 next/random 也只能指向新链表内部。

示例 1

输入:head=[[7,null],[13,0],[11,4],[10,2],[1,0]]

输出:[[7,null],[13,0],[11,4],[10,2],[1,0]]

带随机指针链表的示例编码next 链为 7、13、11、10、1;random 下标依次为空、0、4、2、0。next 7 → 13 → 11 → 10 → 1 → null下标 0  1  2  3  4random null 0  4  2  0
上排是 next 主链,下排数字是各节点 random 指向的目标下标。

示例 2

输入:head=[[1,1],[2,1]]

输出:[[1,1],[2,1]]

示例 3

输入:head=[[3,null],[3,0],[3,null]]

输出:[[3,null],[3,0],[3,null]];值相同的节点仍是三个不同对象。

约束与保证

  • 节点数 n[0,1000] 内。
  • -10⁴ ≤ Node.val ≤ 10⁴
  • randomnull 或指向当前链表中的某个节点。
2 · 思路(暴力→最优)

难点:拷贝某结点时,它的 random 目标可能还没被创建,没法直接指过去。

正解①:哈希映射。
① 建对象:第一趟为每个原结点创建拷贝,记录 old_to_new[old]=copy
② 翻译引用:第二趟把原 next/random 的目标通过映射换成对应拷贝;所有对象已建齐,不怕前向 random。

正解②:原地交织。
① 插入拷贝:X' 放在 X 后,形成 A→A'→B→B'
② 连接 random:X.random=T,则 X'.random=T.next,因为 T' 就在 T 后。
③ 拆分两链:逐对恢复原 next,并把拷贝结点串起来,确保两条链完全解耦。

取舍:两者都 O(n) 时间。哈希法好写好想,是首选;交织法省下 O(n) 空间但要小心拆分时彻底还原原链的 next(否则破坏输入)。

具体例子与优化:若原节点 A 的 random 指向 C,交织后局部为 A→A'→…→C→C',所以无需哈希查找,直接令 A'.random=C.next=C'。这一固定邻接关系把映射空间从 O(n) 降为 O(1)。

⚠ 易错坑 交织法连 random 时 cur.random 可能为 None,要先判再 cur.next.random = cur.random.next
拆分阶段必须把原链的 next 完全还原,返回的拷贝链才与原链彻底解耦。
哈希法用 map.get(key) 而非 map[key],让 None 键(random/next 为空)自然映射到 None,不 KeyError。
3 · 图解
原链:A -> B -> None,A.random=B,B.random=A

解法② 原地交织
帧1  插入全部拷贝:A -> A' -> B -> B' -> None
帧2  利用“原目标的 next 就是目标拷贝”连接 random:
原 A.random ─────> B       所以 A'.random ─────> B'
原 B.random ─────> A       所以 B'.random ─────> A'

帧3  拆分 A/A':先写 A.next=B
      原入口 A -> B -> B'
      拷贝入口 A' -> B       (仍暂时跨回原链)
      再写 A'.next=B' 后:
      原入口 A -> B -> B'
      拷贝入口 A' -> B'      (拷贝链已局部独立)

帧4  拆分 B/B' 后:原链 A->B;拷贝链 A'->B',互不共享对象 ✓

解法① 哈希映射
第一趟:{A:A', B:B'}(只建新对象)
第二趟:A'.next=B',A'.random=B';B'.next=None,B'.random=A'
解法② 原地交织:按结点/阶段汇总,拆分瞬时跨链见上图
阶段 / 本轮摘要current / copy_node原链现场拷贝 / random 现场
0空链检查,cur 指向 Acur=AA->B尚无拷贝结点
1在 A 后插入 A',前进到 Bcur=BA->A'->BA'.next=B
2在 B 后插入 B',交织完成cur=NoneA->A'->B->B'A'、B' 已创建
3处理 A 的 randomcur=AA.random=BA'.random=B'
4处理 B 的 randomcur=BB.random=AB'.random=A'
5拆分 A/A',分别接到 B/B'cur=BA->B->B'A'->B'
6拆分 B/B',两条链彻底独立cur=NoneA->B->NoneA'->B'->None
7返回保存的 copy 头copy=A'原链已还原深拷贝 A'->B'
解法① 哈希映射:按两趟扫描及每个结点汇总
阶段 / 本轮摘要curold_to_new新链现场
0初始化映射与遍历指针A{}尚无新结点
1第一趟为 A 建立 A'A{A:A'}A' 指针暂空
2第一趟为 B 建立 B'B{A:A',B:B'}全部新结点建齐
3第二趟翻译 A 的 next/randomAmap[B]=B'A'.next=B';random=B'
4第二趟翻译 B 的 next/randomBmap[A]=A'B'.next=None;random=A'
5遍历结束,返回 head 的映射None映射完整A'->B'
4 · 代码
# 解法二:原地交织(O(1) 辅助空间,三趟)
class Solution:
    def copyRandomList(self, head):
        if not head:
            return None

        # 1. 在每个原结点后插入对应拷贝
        current = head
        while current:
            current.next = Node(current.val, current.next)
            current = current.next.next

        # 2. 原 random 目标的 next,就是该目标的拷贝
        current = head
        while current:
            if current.random:
                current.next.random = current.random.next
            current = current.next.next

        # 3. 同时还原原链并串起拷贝链
        current = head
        copy_head = head.next
        while current:
            copy_node = current.next
            current.next = copy_node.next
            copy_node.next = copy_node.next.next if copy_node.next else None
            current = current.next
        return copy_head
# 解法一:哈希表映射法(O(n) 空间,两趟)
class Solution:
    def copyRandomList(self, head):
        if not head:
            return None

        # 1. 第一趟只创建对象,保证所有引用目标都已有拷贝
        old_to_new = {}
        current = head
        while current:
            old_to_new[current] = Node(current.val)
            current = current.next

        # 2. 第二趟把 next 与 random 都翻译到新对象
        current = head
        while current:
            old_to_new[current].next = old_to_new.get(current.next)
            old_to_new[current].random = old_to_new.get(current.random)
            current = current.next
        return old_to_new[head]
5 · 复杂度

时间 O(n) 三趟;空间 交织法 O(1)(不含输出),哈希法 O(n)。

6 · 测试用例

功能:random 指向前/后结点均正确复制;边界:空链表返回 None、单结点 random 指自身;特殊:所有 random 为 None、random 成环。

排序链表

LC 148 ↗链表 · Medium
1 · 题目

要完成的任务

给定单链表头节点 head,按节点值从小到大排序,并返回排序后的链表头节点。

输入与输出

数组按 next 顺序展示节点值;输出仍是一条链表,而不是数组。

示例 1

输入:head=[4,2,1,3]

输出:[1,2,3,4]

链表排序前后节点顺序从 4、2、1、3 调整为 1、2、3、4。排序前 4 → 2 → 1 → 3排序后 1 → 2 → 3 → 4
节点对象仍组成同一条链,只是连接顺序变为升序。

示例 2

输入:head=[-1,5,3,4,0]

输出:[-1,0,3,4,5]

示例 3

输入:head=[]

输出:[]

约束

  • 节点数在 [0,5×10⁴] 内。
  • -10⁵ ≤ Node.val ≤ 10⁵
  • 进阶目标是 O(n log n) 时间和 O(1) 额外空间。
2 · 思路(暴力→最优)

直觉方案:把结点值放入数组排序再写回,时间 O(n log n)、空间 O(n),没有利用链表可通过断链和拼接完成原地归并。

为什么归并最契合链表:快排要按下标随机访问定位 pivot 两侧,链表做不到 O(1) 随机访问;而归并排序只需要"顺序遍历 + 断开/拼接指针",正是链表擅长的,且合并两段有序链不需要额外数组(复用 21 的双指针)。

正解:自顶向下归并。
① 找中点:slow 从 head、fast 从 head.next 出发,让 slow 停在前半末尾。
② 保存并断开:先保存 right_head=slow.next,再写 slow.next=None,保证两段都变短且不丢右段。
③ 递归排序:分别排序左右两段,直到长度 0/1。
④ 合并有序段:复用 LC21 的双指针归并。

为什么找中点 fasthead.next:这样偶数长时 slow 停在前半的最后一个slow.next 才能把链断成两段都非空;若 fast 从 head 起,两个结点的链会切成"整条 + 空"导致无限递归。

取舍:自顶向下递归有 O(log n) 栈空间;要真正 O(1) 空间得用自底向上(按子段长 1,2,4,… 迭代两两合并)。面试自顶向下足够,代码更清晰。

⚠ 易错坑 找中点用 slow=head, fast=head.next(fast 领先一步),保证偶数长能切成两段非空。
找到中点后必须 slow.next = None 把链真正断开,否则两段仍相连、递归不收敛。
合并用 <= 保稳定;进阶要求 O(1) 空间时需改自底向上,别只报自顶向下。
3 · 图解
输入:3 -> 1 -> 2 -> None

帧1  顶层保存 right_head=2,再写 slow.next=None:
      左:3 -> 1 -> None      右:2 -> None

帧2  左段继续分割:保存 1,再断开 3.next
      左左:3 -> None         左右:1 -> None

帧3  merge(3,1) 稳定返回:1 -> 3

帧4  最终 merge 接入 2 时覆盖旧边 1->3:
      dummy -> 1 -> 2 -> None      l1 -> 3
      (结点 3 没丢,由 l1 引用保住)
帧5  接上剩余段:dummy -> 1 -> 2 -> 3 -> None ✓
自顶向下归并:一张表概括递归分割、子问题返回与最终合并
阶段动作leftright稳定结构
1顶层找中点并断开3->12两段均变短
2左段再次断开31两个单结点
3单结点返回并合并左子问题311->3
4顶层右段命中出口1->32准备最终合并
5先接入较小头 132结果 1
6接入 2,覆盖 1 的旧 next3None1->2;left 保住 3
7接上剩余 left3None1->2->3
4 · 代码
class Solution:
    def sortList(self, head):
        # 1. 空链或单结点天然有序
        if not head or not head.next:
            return head

        # 2. fast 领先一步,使 slow 停在前半段末尾
        slow, fast = head, head.next
        while fast and fast.next:
            slow, fast = slow.next, fast.next.next
        right_head = slow.next             # 断链前先保存右半段
        slow.next = None

        # 3. 递归排序左右两段,再归并
        left = self.sortList(head)
        right = self.sortList(right_head)
        return self._merge(left, right)

    def _merge(self, left, right):
        # 4. 复用原结点归并两条有序链
        dummy = tail = ListNode()
        while left and right:
            if left.val <= right.val:
                tail.next, left = left, left.next
            else:
                tail.next, right = right, right.next
            tail = tail.next
        tail.next = left or right
        return dummy.next
5 · 复杂度

时间 O(n log n):log n 层,每层合并 O(n);空间 自顶向下 O(log n) 栈,自底向上 O(1)。

6 · 测试用例

功能:乱序 4->2->1->31->2->3->4边界:空/单结点、已有序、逆序;特殊:含重复值(<= 保稳定)、含负数。

合并 K 个升序链表

LC 23 ↗链表 · Hard
1 · 题目

要完成的任务

给定包含 k 条链表的数组 lists,每条链表都按升序排列。把所有节点合并成一条升序链表,并返回其头节点。

输入与输出

外层数组表示链表集合,内层数组按 next 顺序展示一条链表;空内层数组表示空链表,空外层数组表示没有任何链表。

示例 1

输入:lists=[[1,4,5],[1,3,4],[2,6]]

输出:[1,1,2,3,4,4,5,6]

三条输入链分别为 1→4→51→3→42→6,输出包含它们的全部节点并保持升序。

示例 2

输入:lists=[]

输出:[]

示例 3

输入:lists=[[]]

输出:[]

约束与保证

  • k==lists.length,且 0 ≤ k ≤ 10⁴
  • 每条链表长度在 [0,500] 内;-10⁴ ≤ Node.val ≤ 10⁴
  • 每条链表按升序排列,所有链表的节点总数不超过 10⁴
2 · 思路(暴力→最优)

暴力:逐条往结果里 merge,第 i 次合并代价约 O(i·avg),累计 O(k²·avg) = O(kN)。慢在"越合越长的结果被反复重扫"。

正解①:最小堆。
① 放入候选:每条非空链只把当前头放进堆。
② 弹出最小:堆顶一定是所有未合并结点中的最小可选值,把它接到结果尾。
③ 后继补位:若弹出结点还有 next,就把后继入堆;堆大小始终不超过 k。
④ 直到堆空:每个结点入堆、出堆各一次,时间 O(N log k)。

正解②:分治两两合并。把 k 条链按区间对半递归,左右各自合并后再做一次 LC21;共 log k 层,每层合计扫描 N 个结点。

取舍:堆法直观好写、空间 O(k);分治法空间 O(log k) 递归栈、常数更小。两者时间同阶,面试任选其一讲透即可。

具体例子与优化:[1,4,5][1,3,4][2,6],堆中最初只放三个链头 1、1、2;弹出第一个 1 后仅补入它的后继 4,而不是一次放入全部节点。堆大小始终不超过 k,单次选择从 O(k) 降为 O(log k)。

⚠ 易错坑 堆元素用三元组 (val, i, node),中间的 itie-breaker:val 相等时比 i,避免 Python 去比较 ListNode 对象而报 TypeError
空链表不入堆(入堆前判 if node);lists=[] 直接返回 None。
弹出结点后,只有 node.next 存在才把它入堆补位,否则该链已耗尽。
3 · 图解
解法① 最小堆,lists=[1a->4, 2b, 3c]
帧1  初始堆:[1a,2b,3c],结果为空
帧2  弹 1a 并补入 4:结果 1a,堆=[2b,3c,4]
帧3  弹 2b;tail.next=2b 会覆盖旧边 1a->4:
      结果:dummy -> 1a -> 2b -> None
      堆:[3c,4] -----------------> 4(所以旧后继没有丢)
帧4  弹 3c:结果 1a->2b->3c,堆=[4]
帧5  弹 4:结果 1a->2b->3c->4,堆空 ✓
解法① 最小堆:按一次“弹最小→接结果→补后继”汇总
阶段 / 本轮摘要nodetail堆内容 (val,index)结果 / 引用现场
0各非空链头入堆,建立结果哨兵dummy(1,0)(2,1)(3,2)结果空
1弹 1a、接入,并把后继 4 补入堆1a1a(2,1)(3,2)(4,0)1a
2弹 2b、接入;该链耗尽2b2b(3,2)(4,0)1a->2b
3弹 3c、接入;该链耗尽3c3c(4,0)1a->2b->3c
4弹 4、接入;该链耗尽441a->2b->3c->4
5堆为空,返回结果头41->2->3->4
解法② 分治两两合并, k=4: lists = [A, B, C, D]
第0层:  A    B     C    D
         \  /       \  /
         merge      merge          每次 merge 两条有序链
           |          |
第1层:    AB         CD             (剩 2 条)
             \      /
              merge                log2(4)=2 层
                |
              ABCD                 (剩 1 条, 完成)

递归: solve(0,3) = merge( solve(0,1), solve(2,3) )
每层所有 merge 合计遍历 O(N), 共 log k 层 → O(N log k)

具体 merge(1->3, 2) 的结构帧:
接 1:dummy -> 1 -> 3,变量 a 前移后仍指 3
接 2:dummy -> 1 -> 2;旧边 1->3 被覆盖,但 a 保住 3
接余段:dummy -> 1 -> 2 -> 3
解法② 分治:按递归调用/返回与一次 merge 轮次汇总
阶段 / 本轮摘要left_index,right_index / tailleftright结果现场
1solve(0,1) 拆成两个单链区间l=0,r=1,mid=0递归到出口
2两个出口分别返回原链头l=r1->32准备 merge
3取较小头 1,接入并前移 a/tailtail=1321
4取较小头 2,接入并前移 b/tailtail=23None1->2
5b 耗尽,整段接上 atail=23None1->2->3
6最外层返回 merge 结果1->2->3
4 · 代码
import heapq

class Solution:
    def mergeKLists(self, lists):
        # 1. 每条非空链只放入当前头;index 负责等值时兜底比较
        heap = []
        for index, node in enumerate(lists):
            if node:
                heapq.heappush(heap, (node.val, index, node))

        # 2. 每次输出全局最小头,并用其后继补位
        dummy = tail = ListNode()
        while heap:
            _, index, node = heapq.heappop(heap)
            tail.next = node
            tail = node
            if node.next:
                heapq.heappush(heap, (node.next.val, index, node.next))
        return dummy.next
# 解法二:分治两两合并,O(N log k) 时间 / O(log k) 递归栈
class Solution:
    def mergeKLists(self, lists):
        if not lists:
            return None

        def merge(left, right):
            # 1. 合并两条有序链
            dummy = tail = ListNode()
            while left and right:
                if left.val <= right.val:
                    tail.next, left = left, left.next
                else:
                    tail.next, right = right, right.next
                tail = tail.next
            tail.next = left or right
            return dummy.next

        def solve(left_index, right_index):
            # 2. 显式递归区间;单链是分治出口
            if left_index == right_index:
                return lists[left_index]
            middle = (left_index + right_index) // 2
            left_result = solve(left_index, middle)
            right_result = solve(middle + 1, right_index)
            return merge(left_result, right_result)

        return solve(0, len(lists) - 1)
5 · 复杂度

时间 O(N log k):N 个结点各入堆/出堆一次,堆大小 ≤ k;空间 O(k) 堆。分治法时间同,空间 O(log k) 栈。

6 · 测试用例

功能:3 条链交替合并;边界lists=[] 返回 None、含空链表(跳过不入堆)、k=1;特殊:等值元素(元组第二元 i 做 tie-breaker,避免比较 ListNode 报错)。

LRU 缓存

LC 146 ↗链表 · Medium
1 · 题目

要完成的任务

实现 LRUCache:构造时给定正整数容量 capacityget(key) 返回键对应的值,不存在则返回 -1put(key,value) 更新或插入键值。插入新键且容量已满时,删除最久未使用的键。

操作语义

一次成功的 get 或任意 put 都会把该键变成最近使用;更新已有键不增加缓存条目数。构造函数和 put 没有返回值,页面用 null 表示。

示例

输入:["LRUCache","put","put","get","put","get","put","get","get","get"]
[[2],[1,1],[2,2],[1],[3,3],[2],[4,4],[1],[3],[4]]

输出:[null,null,null,1,null,-1,null,-1,3,4]

容量为 2。读取键 1 后它成为最新;插入键 3 淘汰键 2。随后插入键 4 时淘汰较久未使用的键 1,最终键 3、4 可读。

约束与保证

  • 1 ≤ capacity ≤ 3000
  • 0 ≤ key ≤ 10⁴0 ≤ value ≤ 10⁵
  • 最多调用 2×10⁵getput
  • 两种操作都必须达到平均 O(1) 时间复杂度。
2 · 思路(暴力→最优)

暴力/单结构局限:只用哈希表能 O(1) 定位,却不知道谁最久未用;只用链表能记录新旧顺序,按 key 查找却要 O(n)。

正解:哈希表 + 双向链表。
① 定位结点:cache[key] O(1) 找到对象。
② 维护新旧:链表 head 后是最近使用,tail 前是最久使用。
③ 刷新访问:get 命中或 put 更新,都先摘除结点再插到头部。
④ 超容淘汰:删除 tail.prev,并同步删除哈希映射。

为什么必须双向:拿到中间结点后,双向链表能直接访问前驱并 O(1) 摘除;单向链表仍需从头找前驱。两端哨兵保证插入/删除时邻居始终存在。

具体例子与优化:容量 2 时依次 put(1,1)put(2,2) 后顺序为 2→1get(1) 把它移到头,变成 1→2;再插入 3 就从尾部 O(1) 淘汰键 2。哈希负责定位,双链表负责顺序,两种 O(1) 操作各司其职。

⚠ 易错坑 必须双向链表——单向无法 O(1) 找到待删结点的前驱,就退化成 O(n)。
get 命中也要把结点移到头(刷新),不只是 put;只读也算一次"使用"。
put 已存在的 key 时要更新原结点的 value,并执行“摘除 + 插头”刷新顺序,不增加 cache 大小、不触发淘汰;只有新 key 导致超容量时才删 tail.prev
3 · 图解
cap=2;H 后是最近,T 前是最久

帧1  初始化:H <=> T
帧2  put(1):插头中先改 T.prev=1,此时 H.next 仍是 T(双向暂不对称);
      再改 H.next=1,稳定为 H <=> 1 <=> T
帧3  put(2):H <=> 2 <=> 1 <=> T
帧4  get(1):摘除时先改 2.next=T,T.prev 暂仍是 1;补写 T.prev=2 后脱链;
      再插头,稳定为 H <=> 1 <=> 2 <=> T
帧5  put(3) 暂超容:H <=> 3 <=> 1 <=> 2 <=> T
      淘汰 2 后:H <=> 3 <=> 1 <=> T,keys={1,3} ✓
cap=2 的操作序列:按一次公开操作或一次摘除/插入阶段汇总
阶段 / 本轮摘要H.nextT.prev双链现场 / 临时不对称cache keys
0初始化 cache 与 H/T 哨兵THH<=>T{}
1put(1):建结点、入表并插到头部11H<=>1<=>T{1}
2put(2):新结点插到最近端21H<=>2<=>1<=>T{1,2}
3get(1) 命中,先从旧位置摘除22H<=>2<=>T;1 脱链{1,2}
4把结点 1 插回头部并返回值12H<=>1<=>2<=>T{1,2}
5put(3):插头后容量变为332H<=>3<=>1<=>2<=>T{1,2,3}
6定位并摘除尾前最久结点 231H<=>3<=>1<=>T{1,2,3}
7从 cache 删除 key=2313 最近,1 最久{1,3}
4 · 代码
class Node:
    def __init__(self, key=0, value=0):
        self.key, self.value = key, value
        self.prev = self.next = None

class LRUCache:
    def __init__(self, capacity):
        # 1. 哈希负责定位,双链负责维护最近/最久顺序
        self.capacity = capacity
        self.cache = {}
        self.head, self.tail = Node(), Node()
        self.head.next, self.tail.prev = self.tail, self.head

    def _remove(self, node):
        # 2. 双向引用让中间结点可 O(1) 摘除
        node.prev.next = node.next
        node.next.prev = node.prev

    def _add_front(self, node):
        # 3. 插到 head 后,表示最近使用
        node.prev, node.next = self.head, self.head.next
        self.head.next.prev = node
        self.head.next = node

    def get(self, key):
        if key not in self.cache:
            return -1
        node = self.cache[key]
        self._remove(node)
        self._add_front(node)
        return node.value

    def put(self, key, value):
        # 4. 已存在时原地更新并刷新,不增加容量
        if key in self.cache:
            node = self.cache[key]
            node.value = value
            self._remove(node)
            self._add_front(node)
            return

        node = Node(key, value)
        self.cache[key] = node
        self._add_front(node)

        # 5. 超容量时淘汰 tail 前的最久未用结点
        if len(self.cache) > self.capacity:
            least_recent = self.tail.prev
            self._remove(least_recent)
            del self.cache[least_recent.key]
5 · 复杂度

时间 get/put 均 O(1)(哈希定位 + 常数指针操作);空间 O(capacity)。

6 · 测试用例

功能:put/get 命中刷新顺序;边界:capacity=1(每次 put 新键都淘汰旧键)、get 不存在返回 -1;特殊:put 已存在的 key 更新值并刷新为最近(不算新增、不触发淘汰)。

二叉树

树是天然的递归结构:绝大多数题就是"对左右子树递归 + 在当前节点合并结果"。树的递归本质就是 DFS——前序(根左右)、中序(左根右)、后序(左右根)只是"访问根"这一步的时机不同;BST 的中序遍历天然升序(94↔98↔230)。层序遍历 = BFS,用队列一层层扩展,和图论 BFS 同源(102↔199)。求高度是最基础的后序递归,直径/最大路径和都是它的"顺手多做一件事"的衍生(104↔543↔124)。回溯 = 树的 DFS + 撤销选择,路径类题(437)就是在树上走路径。

二叉树的中序遍历

LC 94 ↗二叉树 · Easy
1 · 题目

要完成的任务

给定二叉树根节点 root,按“左子树 → 根节点 → 右子树”的中序顺序访问所有节点,并返回节点值列表。

输入表示

题面用层序数组展示树,null 表示对应孩子不存在;函数实际接收根节点,而不是数组。

示例 1

输入:root=[1,null,2,3]

输出:[1,3,2]

LC94 两个非空示例的树结构左侧示例根为 1,右孩子 2 的左孩子是 3;右侧较大示例根为 1,依次包含 2、3、4、5、8、6、7、9。示例 1123示例 2123458679
示例 1 的中序为 1、3、2;示例 2 的中序为 4、2、6、5、7、1、3、9、8。

示例 2

输入:root=[1,2,3,4,5,null,8,null,null,6,7,9]

输出:[4,2,6,5,7,1,3,9,8]

示例 3

输入:root=[]

输出:[]

示例 4

输入:root=[1]

输出:[1]

约束

  • 树中节点数在 [0,100] 内。
  • -100 ≤ Node.val ≤ 100
2 · 思路(暴力→最优)

暴力/直觉:递归直接按“左→根→右”遍历,正确且简短;局限是依赖系统调用栈,不容易在第 k 个节点处暂停,也不便展示递归栈现场。

正解 · 显式栈:
① 核心规律:当前节点非空时不断压入左链;左链到底后弹出栈顶访问,再转向它的右子树。栈顶始终是“左子树已完成、自己尚未访问”的最近祖先。
② 样例落地:在根 4 的非对称 7 节点树中,先压 4→2→1,依次访问 1、2;转入 2 的右子 3 后,再回到 4,随后处理右子树,核心动作会重复多轮。
③ 优化取舍:递归和迭代都是 O(n) 时间、O(h) 空间;迭代能随时暂停,LC230 可在弹到第 k 个节点时直接返回,因此面试更值得掌握。

⚠ 易错坑 外层条件必须是 cur or stack,两者都空才结束。
弹栈访问后必须转到 cur.right,否则会漏掉右子树。
栈顶在列表尾部,用 pop(),不要用 O(n) 的 pop(0)
3 · 图解
非对称 7 节点树
              4
            /   \
           2     6
          / \   /
         1   3 5
                \
                 7
中序目标:[1,2,3,4,5,7,6]

里程碑1|从根压左链到底
cur=∅,stack=[4,2,1],result=[]

里程碑2|完成左子树 2
依次弹 1、2;转入 2.right=3 后再弹 3
cur=∅,stack=[4],result=[1,2,3]

里程碑3|访问根并进入右子树
弹 4;从 6 向左压到 5
cur=∅,stack=[6,5],result=[1,2,3,4]

里程碑4|处理 5 的右分支
弹 5 后转入 7,再压入并弹出 7
cur=∅,stack=[6],result=[1,2,3,4,5,7]

里程碑5|弹出最后祖先 6
cur=∅,stack=[],result=[1,2,3,4,5,7,6]
解法① 迭代中序:按一次压左链与随后的访问阶段摘要
阶段处理范围stackcurresult
1压 4→2→1[4,2,1][]
2访问 1、2[4]3[1,2]
3完成节点3[4][1,2,3]
4访问4,压 6→5[6,5][1,2,3,4]
5访问5及其右子7[6][1,2,3,4,5,7]
6访问6并结束[][1,2,3,4,5,7,6]
解法② 递归中序:按稳定子树返回概括调用主线
阶段调用摘要已完成结构result下一步
1进入 inorder(4)先递归左子树2[]等待左返回
2完成节点12 的左子树[1]访问2
3完成子树21←2→3[1,2,3]回到根4
4访问4左半稳定[1,2,3,4]递归右子树6
5完成子树55→right 7[1,2,3,4,5,7]回到6
6完成根调用整树已访问[1,2,3,4,5,7,6]返回
4 · 代码
class Solution:
    def inorderTraversal(self, root):
        # 1. 显式保存祖先链、当前指针与访问结果
        result = []
        stack = []
        current = root

        # 2. 先压完整左链,再弹栈访问并转向右子树
        while current or stack:
            while current:
                stack.append(current)
                current = current.left

            current = stack.pop()
            result.append(current.val)
            current = current.right

        return result
# 解法二:递归中序,调用栈隐式保存祖先链
class Solution:
    def inorderTraversal(self, root):
        result = []

        def inorder(node):
            # 1. 空节点不产生访问结果
            if not node:
                return

            # 2. 严格按左子树、当前节点、右子树访问
            inorder(node.left)
            result.append(node.val)
            inorder(node.right)

        inorder(root)
        return result
5 · 复杂度

时间 O(n),每个节点进出栈各一次;空间 O(h),h 为树高,栈最多存一条从根到最左的路径(最坏退化链 O(n))。

6 · 测试用例

功能[1,null,2,3][1,3,2]边界:空树 [][]、单节点 [1][1]特殊:左斜链/右斜链验证栈的伸缩,BST 输入应输出升序。

二叉树的最大深度

LC 104 ↗二叉树 · Easy
1 · 题目

要完成的任务

给定二叉树根节点 root,返回最大深度,即从根节点到最远叶子节点的最长路径所包含的节点数;空树深度为 0。

输入表示

层序数组只是页面对树的展示,null 表示缺少孩子;函数实际接收根节点。

示例 1

输入:root=[3,9,20,null,null,15,7]

输出:3

最大深度为三的二叉树根节点 3 的左孩子为 9,右孩子 20 又有孩子 15 和 7,因此最长根到叶路径包含三个节点。3920157
最长路径可为 3→20→153→20→7,均有 3 个节点。

示例 2

输入:root=[1,null,2]

输出:2

约束

  • 树中节点数在 [0,10⁴] 内。
  • -100 ≤ Node.val ≤ 100
2 · 思路(暴力→最优)

暴力/直觉:可以 BFS 一层层计数,直到队列为空;它能得到每层节点,但若只求深度,会维护不必要的整层队列。

正解 · 后序求高度:
① 核心规律:depth(node)=1+max(depth(left), depth(right)),空节点返回 0。当前节点必须先拿到左右高度,因此天然是后序。
② 样例落地:示例中叶子 9、15、7 都返回 1;节点 20 收到 1/1 后返回 2;根 3 收到 1/2 后返回 3,公式在叶、内部节点、根连续应用。
③ 优化取舍:递归只保留一条根到当前节点的调用链,空间 O(h);BFS 空间 O(w),适合还要读取每层内容的题。高度骨架可直接扩展到 LC543、LC124。

⚠ 易错坑 空节点返回 0;叶子自然得到 1+max(0,0)=1
本题深度按节点数;LC543 的直径按边数,口径不要混。
退化链的递归栈深度为 n,极端输入有爆栈风险。
3 · 图解
初始树
          3
        /   \
       9     20
            / \
           15  7

帧1|空孩子先返回 0,三个叶子稳定为高度 1
       9[h=1]       15[h=1]   7[h=1]

帧2|节点 20 收到 l=1、r=1
          20[h=2]
          /    \
      15[h=1]  7[h=1]

帧3|根 3 收到左高 1、右高 2
          3[h=3]
        /        \
    9[h=1]      20[h=2]
                 /   \
            15[h=1] 7[h=1]

最终最深路径为 3→20→15(或 7),节点数深度 = 3。
后序求高度:按一次节点调用概括左右子树返回与本节点高度
阶段当前调用左右返回本节点返回现场
1maxDepth(3)先等待左右子树进入后序
2maxDepth(9)0 / 01左叶稳定
3maxDepth(15)0 / 0120 的左叶
4maxDepth(7)0 / 0120 的右叶
5maxDepth(20)1 / 12右子树稳定
6回到 maxDepth(3)1 / 21+max=3最大深度 3
4 · 代码
class Solution:
    def maxDepth(self, root):
        def depth(node):
            # 1. 空子树高度为 0
            if not node:
                return 0

            # 2. 后序取得两侧高度,再加上当前层
            left_depth = depth(node.left)
            right_depth = depth(node.right)
            return 1 + max(left_depth, right_depth)

        return depth(root)
5 · 复杂度

时间 O(n),每节点访问一次;空间 O(h),递归栈深度等于树高(最坏退化链 O(n))。

6 · 测试用例

功能[3,9,20,null,null,15,7]3边界:空树→0、单节点→1特殊:完全左斜链 n 个节点→深度 n。

翻转二叉树

LC 226 ↗二叉树 · Easy
1 · 题目

要完成的任务

给定二叉树根节点 root,对树中每个节点交换左、右孩子,返回翻转后树的根节点。

输入与输出

函数接收并返回根节点;层序数组只是页面展示树结构,null 表示对应孩子不存在。

示例 1

输入:root=[4,2,7,1,3,6,9]

输出:[4,7,2,9,6,3,1]

满二叉树翻转前后翻转前根 4 的左右子树根为 2 和 7;翻转后左右位置互换,且各子树内部也镜像。翻转前翻转后42713694729631
每个节点都执行左右互换,因此整棵树关于根的竖直轴镜像。

示例 2

输入:root=[2,1,3]

输出:[2,3,1],根的左右孩子交换。

示例 3

输入:root=[]

输出:[]

约束

  • 树中节点数在 [0,100] 内。
  • -100 ≤ Node.val ≤ 100
2 · 思路(暴力→最优)

暴力/直觉:层序遍历每个节点并交换左右孩子也能完成,需要 O(w) 队列;若只是递归定义变换,队列不是必需状态。

正解 · 分治翻转:
① 核心规律:翻转当前树等于“翻转原右子树作为新左子树 + 翻转原左子树作为新右子树”。空节点直接返回空。
② 样例落地:示例先把 2 的子树稳定为 2→(3,1),再把 7 的子树稳定为 7→(9,6),最后根 4 接成 4→(7,2);同一规则重复到内部节点和根。
③ 安全写回:先用局部变量保存两棵递归结果,再依次写 root.leftroot.right,可清楚避免覆盖原指针。递归空间 O(h),迭代版空间 O(w)。

⚠ 易错坑 不能先覆盖 root.left 后再从它读取原左子树;先保存递归结果。
每层要返回当前根,父调用才能接到翻转后的稳定子树。
空节点必须直接返回 None
3 · 图解
同一编号代表同一个节点;局部变量保存的是引用,不复制节点。

里程碑0|初始
          4
        /   \
       2     7
      / \   / \
     1   3 6   9

里程碑1|原左子树稳定翻转完成
          2
        /   \
       3     1

里程碑2|原右子树稳定翻转完成
          7
        /   \
       9     6

里程碑3|根写入新左边后的必要瞬时态
inverted_left=节点2,inverted_right=节点7
          4
        ↙   ↘
       7①   7①
节点2仍由 inverted_left 保住,因此没有丢链。

里程碑4|根写入新右边,整树稳定
          4
        /   \
       7     2
      / \   / \
     9   6 3   1
分治翻转:表格只记录稳定递归结果,根部瞬时双引用见上图
阶段递归范围稳定返回当前结构结论
1进入 invert(4)等待左右子树原树未改开始分治
2原左子树22→(3,1)左结果已保存稳定
3原右子树77→(9,6)右结果已保存稳定
4回到根4新左=7,新右=2写回两个局部结果根交换完成
5返回根44→(7,2)每个节点左右均已交换翻转完成
4 · 代码
class Solution:
    def invertTree(self, root):
        def invert(node):
            # 1. 空子树翻转后仍为空
            if not node:
                return None

            # 2. 先得到两棵稳定的翻转结果,避免覆盖原引用
            inverted_left = invert(node.left)
            inverted_right = invert(node.right)

            # 3. 原右结果接到左边,原左结果接到右边
            node.left = inverted_right
            node.right = inverted_left
            return node

        return invert(root)
5 · 复杂度

时间 O(n),每节点交换一次;空间 O(h),递归栈深度为树高。

6 · 测试用例

功能[4,2,7,1,3,6,9][4,7,2,9,6,3,1]边界:空树→空、单节点→自身不变;特殊:只有左孩子的链翻转后变右斜链。

对称二叉树

LC 101 ↗二叉树 · Easy
1 · 题目

要完成的任务

给定二叉树根节点 root,判断它是否关于根节点所在的竖直轴镜像对称,返回布尔值。

输入表示

层序数组用于展示树,null 表示缺少孩子。对称比较的是结构和对应节点值:左子树的左边应匹配右子树的右边,反之亦然。

示例 1

输入:root=[1,2,2,3,4,4,3]

输出:true

对称树和非对称树左侧树的节点值和结构沿中轴镜像;右侧树的两个值 3 都位于内侧而不是镜像位置,因此不对称。示例 1:对称示例 2:不对称122344312233
左图的每个外侧/内侧位置都有镜像对应;右图两个 3 的位置不能互相镜像。

示例 2

输入:root=[1,2,2,null,3,null,3]

输出:false

约束

  • 树中节点数满足 1 ≤ n ≤ 1000
  • -100 ≤ Node.val ≤ 100
2 · 思路(暴力→最优)

暴力/误区:把左右子树按“同侧”比较,只能判断结构相同;轴对称要求交叉配对,left.left 应与 right.right 比。

正解 · 成对镜像递归:
① 核心规律:mirror(a,b) 先处理“都空 / 仅一个空 / 值不同”,再同时验证外侧 a.left↔b.right 与内侧 a.right↔b.left
② 样例落地:对称 7 节点树中,2L/2R 值相等;外侧继续配 3L/3R,内侧继续配 4L/4R,两层都通过才返回 True。反例在 ∅/3R 处立即失败。
③ 优化取舍:递归只保留成对调用,空间 O(h);也可用队列按“外侧一对、内侧一对”入队,空间 O(w),适合避免递归。

⚠ 易错坑 两个都空才为 True,一空一非空必须为 False。
配对方向必须交叉,写成同侧比较会退化成“相同树”。
空树要单独返回 True;非空树从 mirror(root.left, root.right) 开始,避免同一组镜像关系被重复检查。
3 · 图解
对称例(L/R 后缀只为区分同值节点)
             1
          /     \
        2L       2R
       /  \     /  \
     3L   4L   4R   3R

帧1|根非空,直接从左右子树开始成对检查
入口调用 mirror(2L,2R),不需要把根与自己配对

帧2|外侧:mirror(3L,3R)
     3L  ⇄  3R       值相等,四个孩子都是 ∅,返回 True

帧3|内侧:mirror(4L,4R)
     4L  ⇄  4R       值相等,四个孩子都是 ∅,返回 True

帧4|两组都 True,信号回到根
             1
          ✓ / \ ✓
        2L     2R              整棵树对称

反例
             1
          /     \
        2L       2R
          \       \
          3L       3R
外侧应比较 2L.left=∅ 与 2R.right=3R:一空一非空,立刻 False。
对称例:按一次 mirror 调用概括“当前值→外侧→内侧→返回”
阶段调用当前判定子调用返回
1isSymmetric(1)根非空调用 mirror(2L,2R)等待
2mirror(2L,2R)2==2先比较外侧 3L/3R等待
3mirror(3L,3R)3==3,空孩子成对True / TrueTrue
4回到 2L/2R外侧已通过内侧 4L/4RTrue
5回到 2L/2R外侧与内侧均通过True / TrueTrue
6根调用结束所有镜像位置匹配无未决分支返回 True
反例:短路只需走到第一组不成对的外侧孩子
阶段调用配对位置判定返回
1isSymmetric(1)根非空调用 mirror(2L,2R)继续
2mirror(2L,2R)左子/右子值相等检查外侧
3mirror(∅,3R)2L.left / 2R.right一空一非空False
4短路返回根层内侧无需再查and 短路False
5isSymmetric 返回反例结构不镜像已确定False
4 · 代码
class Solution:
    def isSymmetric(self, root):
        def mirror(left_node, right_node):
            # 1. 先区分都空与仅一侧为空
            if not left_node and not right_node:
                return True
            if not left_node or not right_node:
                return False

            # 2. 当前值相等后,交叉验证外侧与内侧
            if left_node.val != right_node.val:
                return False
            return (mirror(left_node.left, right_node.right)
                    and mirror(left_node.right, right_node.left))

        # 3. 根无需与自己比较,只检查左右子树是否互为镜像
        if not root:
            return True
        return mirror(root.left, root.right)
5 · 复杂度

时间 O(n),每节点比较一次;空间 O(h),递归栈为树高。

6 · 测试用例

功能[1,2,2,3,4,4,3]true边界:空树/单节点→true特殊[1,2,2,null,3,null,3]false(结构不镜像)、值全相同但结构不对称也应为 false。

二叉树的直径

LC 543 ↗二叉树 · Easy
1 · 题目

要完成的任务

给定二叉树根节点 root,返回树的直径:任意两个节点之间最长路径所包含的边数。这条路径可能经过根,也可能完全位于某棵子树中。

输入表示

层序数组用于展示树,null 表示缺少孩子;函数实际接收根节点。路径中的节点不能重复。

示例 1

输入:root=[1,2,3,4,5]

输出:3,最长路径可为 4→2→1→35→2→1→3

直径为三条边的二叉树根 1 的左孩子 2 有孩子 4 和 5,右孩子为 3;从 4 或 5 经 2、1 到 3 的路径含三条边。12345
高亮路径从叶子 4 经 2、1 到节点 3,共跨过 3 条边。

示例 2

输入:root=[1,2]

输出:1

约束

  • 树中节点数满足 1 ≤ n ≤ 10⁴
  • -100 ≤ Node.val ≤ 100
2 · 思路(暴力→最优)

暴力:让每个节点当一次拐点,再分别重新计算左右高度,最坏退化树会重复扫描同一子树,时间 O(n²)。只算根的左右高度也不行,最长路径可能藏在子树中。

正解 · 后序高度 + 局部候选:
① 核心规律:每条路径都有唯一最高拐点;经过节点的候选边数是 left_height+right_height,而向父返回的只能是单支高度 1+max(left,right)
② 样例落地:叶 4、5 各返回高度 1;节点 2 得到候选 2、返回高度 2;根 1 收到 2/1 后把候选更新为 3。候选与返回值在两层连续使用且含义不同。
③ 优化取舍:一次后序同时计算高度和最大直径,将重复求高降为 O(n)。用闭包局部变量保存全局最佳,生命周期仅限本次调用,空间 O(h)。

⚠ 易错坑 直径按边数,所以候选是 left+right,不再加 1。
向父只能返回单支高度,不能返回左右之和。
空树和单节点的直径均为 0。
3 · 图解
初始树(高度按“节点数”,直径按“边数”)
          1
        /   \
       2     3
      / \
     4   5

帧1|叶子 4、5、3:左右空高都是 0
     4[h=1]   5[h=1]   3[h=1]        各自候选直径 0

帧2|节点 2 收到 l=1、r=1
          2[h=2]
         / \
     4[h1] 5[h1]
经过 2 的候选 = l+r = 2(路径 4—2—5),ans=2

帧3|根 1 收到左高 2、右高 1
          1[h=3]
        /        \
     2[h=2]      3[h=1]
     / \
  4[h1] 5[h1]
经过 1 的候选 = 2+1 = 3,ans 更新为 3
最终最长路径可为 4—2—1—3(或 5—2—1—3),共 3 条边。
后序高度主线:每个非空节点一行,合并左右返回、直径候选和向上高度
阶段节点左右高度ans向上返回
1初始化0从根进入后序
2叶 40 / 00高度 1
3叶 50 / 00高度 1
4节点 21 / 1max(0,2)=2高度 2
5叶 30 / 02高度 1
6根 12 / 1max(2,3)=3返回答案 3
4 · 代码
class Solution:
    def diameterOfBinaryTree(self, root):
        best_diameter = 0

        def height(node):
            nonlocal best_diameter
            # 1. 空子树高度为 0
            if not node:
                return 0

            # 2. 后序取得左右高度,让当前节点当一次拐点
            left_height = height(node.left)
            right_height = height(node.right)
            best_diameter = max(
                best_diameter,
                left_height + right_height
            )

            # 3. 向父节点只能返回一条单支高度
            return 1 + max(left_height, right_height)

        height(root)
        return best_diameter
5 · 复杂度

时间 O(n),一次后序遍历;空间 O(h),递归栈为树高。

6 · 测试用例

功能[1,2,3,4,5]3边界:单节点→0(无边);特殊:直斜链 n 节点→n-1、直径不过根(答案在子树内)的用例。

二叉树的层序遍历

LC 102 ↗二叉树 · Medium
1 · 题目

要完成的任务

给定二叉树根节点 root,按从上到下的层次遍历树;同一层从左到右收集节点值,返回每层一个子列表的二维数组。

输入表示

输入数组是树的层序展示,null 表示缺少孩子;函数实际接收根节点。输出外层数组按深度排列,内层数组按该层从左到右排列。

示例 1

输入:root=[3,9,20,null,null,15,7]

输出:[[3],[9,20],[15,7]]

三层二叉树的层序分组第一层只有 3,第二层是 9 和 20,第三层是 15 和 7。3920157第 0 层第 1 层第 2 层
按水平层切分后依次得到 [3][9,20][15,7]

示例 2

输入:root=[1]

输出:[[1]]

示例 3

输入:root=[]

输出:[]

约束

  • 树中节点数在 [0,2000] 内。
  • -1000 ≤ Node.val ≤ 1000
2 · 思路(暴力→最优)

暴力:对深度 d=0,1,2... 分别从根递归收集第 d 层,会反复经过上层节点;退化树最坏 O(n²)。

正解 · 分层 BFS:
① 核心规律:每轮开始先冻结 level_size=len(queue),本轮只弹出这些当前层节点;处理时加入的孩子自然留在下一轮。
② 样例落地:根层冻结 1,处理 3 后队列变为 [9,20];下一层冻结 2,即使 20 加入 15、7,本轮也只消费原来的 9、20;第三轮再处理 15、7。
③ 优化取舍:每个节点只入队、出队一次,时间 O(n),空间 O(w)。这套“冻结层宽”模板可直接复用到 LC199 右视图等按层取样题。

⚠ 易错坑 层宽必须在本轮开始时冻结,不能让新入队孩子混入当前层。
先左后右入队才能保留题目要求的层内顺序。
空树应直接返回空列表。
3 · 图解
初始树
          3
        /   \
       9     20
            / \
           15  7

帧0|q=[3],res=[]
帧1|冻结 size=1,只处理本层节点 3
      弹 3 → level=[3];孩子入队 → q=[9,20]
      res=[[3]]

帧2|冻结 size=2;本轮即使加入 15/7,也不会混进当前层
      弹 9  → level=[9]       q=[20]
      弹 20 → level=[9,20]    q=[15,7]
      res=[[3],[9,20]]

帧3|冻结 size=2
      弹 15、7 → level=[15,7] → q=[]
      res=[[3],[9,20],[15,7]]

关键:size 是每层开始时的队长;新入队孩子留给下一轮。
分层 BFS:每行是一整层,合并本层出队、收集和下一层入队
本层队列size本层结果下一层队列
0[3]1[3][9,20]
1[9,20]2[9,20][15,7]
2[15,7]2[15,7][]
3队列为空三层均稳定[]
4返回 res[[3],[9,20],[15,7]]层序完成
4 · 代码
from collections import deque

class Solution:
    def levelOrder(self, root):
        # 1. 空树没有任何层
        if not root:
            return []

        result = []
        queue = deque([root])

        # 2. 每轮冻结当前层长度,新入队孩子留到下一轮
        while queue:
            level_size = len(queue)
            level_values = []

            for _ in range(level_size):
                node = queue.popleft()
                level_values.append(node.val)
                if node.left:
                    queue.append(node.left)
                if node.right:
                    queue.append(node.right)

            result.append(level_values)

        return result
5 · 复杂度

时间 O(n),每节点入队出队各一次;空间 O(n),队列最多存一整层(最宽约 n/2)。

6 · 测试用例

功能[3,9,20,null,null,15,7][[3],[9,20],[15,7]]边界:空树→[]、单节点→[[1]]特殊:完全二叉树验证每层数量翻倍、斜链每层 1 个。

将有序数组转换为二叉搜索树

LC 108 ↗二叉树 · Easy
1 · 题目

要完成的任务

给定一个严格递增的整数数组 nums,构造一棵高度平衡的二叉搜索树并返回根节点。高度平衡指每个节点两棵子树的高度差不超过 1。

输出表示与答案范围

函数返回树根;题面用层序数组展示返回树,null 表示缺少孩子。满足 BST 与平衡条件的树可能不唯一,任意合法答案都接受。

示例 1

输入:nums=[-10,-3,0,5,9]

输出:[0,-3,9,-10,null,5][0,-10,5,null,-3,null,9] 也是合法答案。

升序数组可构造出的两棵平衡二叉搜索树两个示意树都包含负 10、负 3、0、5、9,中序遍历均等于输入数组,且每个节点左右高度差不超过一。合法结果 A合法结果 B0−39−1050−105−39
两棵树形状不同,但中序都还原为输入数组,并且都高度平衡。

示例 2

输入:nums=[1,3]

输出:[3,1][1,null,3] 也合法。

约束与保证

  • 1 ≤ nums.length ≤ 10⁴
  • -10⁴ ≤ nums[i] ≤ 10⁴
  • nums 严格递增。
2 · 思路(暴力→最优)

暴力/错误选择:若总取区间端点做根,仍能得到 BST,但会退化成单链,不满足高度平衡;若用数组切片递归,还会产生额外复制开销。

正解 · 中点分治:
① 核心规律:升序数组是 BST 的中序序列。闭区间 [left,right] 取中点做根,左半递归建左子树、右半递归建右子树,两侧规模最多差 1。
② 样例落地:六个元素 [-10,-3,0,5,9,12] 先取 0;左区间建成 -10→right -3,右区间以 9 为根并接 5、12,最后挂回根,区间拆分在两层重复。
③ 优化取舍:只传下标而不切片,每个元素仅创建一次,时间 O(n),递归栈 O(log n)。偶数区间选左中点或右中点都合法,只需前后一致。

⚠ 易错坑 left>right 才是空区间;单元素区间仍要建叶子。
子区间必须排除 mid,否则递归不收缩。
不要用切片替代下标区间,以免隐藏 O(n log n) 复制。
3 · 图解
nums=[-10,-3,0,5,9,12],使用闭区间并取左中点。

里程碑0|根区间切分
[0,5] → mid=2,根=0
左区间 [0,1],右区间 [3,5]

里程碑1|左子树稳定返回
      -10
         \
         -3
[0,1] 取 -10;其右侧单元素区间 [1,1] 建叶 -3。

里程碑2|右区间继续切分
[3,5] → mid=4,根=9
左区间 [3,3],右区间 [5,5]

里程碑3|右子树稳定返回
          9
        /   \
       5    12

里程碑4|两棵稳定子树挂回根
          0
        /   \
      -10    9
         \  / \
         -3 5  12
每一层都用同一规则近乎平分,左右高度差不超过1。
中点分治:每行概括一次非空区间及其稳定子树返回
阶段区间中点/根稳定结果返回给
1[0,5]2 / 0等待左右根调用
2[0,1]0 / -10左区间空,递归 [1,1]等待右返回
3[1,1]1 / -3叶子-3节点-10
4[0,1] 完成根 -10-10.right=-3根0的左边
5[3,5]4 / 9叶5、12返回;9→(5,12)根0的右边
6全部区间完成根00→(-10→-3, 9→5/12)平衡 BST
4 · 代码
class Solution:
    def sortedArrayToBST(self, nums):
        def build(left, right):
            # 1. 闭区间为空时返回空子树
            if left > right:
                return None

            # 2. 中点做根,使两侧元素数量尽量接近
            middle = (left + right) // 2
            root = TreeNode(nums[middle])

            # 3. 递归构造两个稳定子树,再挂回当前根
            root.left = build(left, middle - 1)
            root.right = build(middle + 1, right)
            return root

        return build(0, len(nums) - 1)
5 · 复杂度

时间 O(n),每个元素恰好建一个节点;空间 O(log n) 递归栈(树平衡,高度为 log n),输出树本身 O(n)。

6 · 测试用例

功能[-10,-3,0,5,9,12]→合法平衡 BST;边界:单元素→单节点、两元素→树高 2(按节点数);特殊:中序遍历输出应还原原数组(验证 BST 性质)、任意节点左右高差 ≤ 1。

验证二叉搜索树

LC 98 ↗二叉树 · Medium
1 · 题目

要完成的任务

给定二叉树根节点 root,判断它是否为有效二叉搜索树:对每个节点,左子树所有值都严格小于该节点值,右子树所有值都严格大于该节点值,并且左右子树本身也满足此规则。

输入表示

层序数组只是树结构展示,null 表示缺少孩子。比较是严格不等,因此重复值也会使 BST 无效。

示例 1

输入:root=[2,1,3]

输出:true

一个有效和一个无效的二叉搜索树示例左树根 2 的左右孩子为 1 和 3,合法;右树根 5 的右子树根为 4,而 4 小于 5,因此非法。示例 1:有效示例 2:无效21351436
节点 4 虽是节点 5 的右孩子,却小于祖先 5;只比较父子会漏掉这一错误。

示例 2

输入:root=[5,1,4,null,null,3,6]

输出:false,因为根的右子树中出现值 4,小于根值 5。

约束

  • 树中节点数满足 1 ≤ n ≤ 10⁴
  • -2³¹ ≤ Node.val ≤ 2³¹-1
2 · 思路(暴力→最优)

暴力/误区:只检查“左孩子<父<右孩子”只能保证局部关系,无法发现右子树深处出现小于祖先的节点;BST 约束作用于整棵子树。

两种线性正解:
① 上下界规律:每个节点携带合法开区间 (low,high);进左子树收紧上界为父值,进右子树收紧下界为父值。反例中节点 4 的区间是 (5,7),即使 4<7 也会因不大于祖先下界 5 而失败。
② 中序规律:BST 的中序序列必须严格递增。对同一反例,中序前缀从 1,5 走到 4 时出现 4<=prev,立即返回 False。
③ 优化取舍:上下界法用参数纵向传递祖先约束,最直接;中序法只保存显式栈和前驱值,便于复用到 LC230。两者时间 O(n)、空间 O(h),均可短路。

⚠ 易错坑 BST 不允许重复值,区间与中序比较都必须严格。
上下界使用开区间和无穷初值,避免整数极值哨兵冲突。
中序的 prev 要贯穿整次遍历,不能在每层重置。
3 · 图解
用能暴露“祖先约束”的反例
          5
        /   \
       1     7
            / \
           4   8
节点4与直接父节点7满足 4<7,但它位于根5的整棵右子树,必须同时满足 4>5,因此整树非法。

解法① 上下界帧
帧1  节点5允许区间 (-∞,+∞)       5 合法
帧2  左节点1区间 (-∞,5)         1 合法
帧3  右节点7区间 (5,+∞)         7 合法
帧4  节点4继承区间 (5,7)        4≤5,失败
区间把所有祖先约束一路带到后代,而不是只比较父子。

解法② 中序快照
压左链 [5,1] → 访问1:prev=1
弹5          → 访问5:prev=5
转右压 [7,4] → 下一访问4
比较 4≤prev(5) ✗;中序前缀 1,5,4 已下降,直接返回 False。
解法① 上下界递归:每行是一层继承后的有效区间决策
阶段节点允许开区间判定返回
15(-∞,+∞)合法,递归左右等待
21(-∞,5)合法,空孩子均 TrueTrue
37(5,+∞)合法,检查左子树等待
44(5,7)4 不大于祖先下界5False
5沿 7→5 短路返回节点8无需访问结论已确定False
解法② 迭代中序:每行概括一次压左链与随后的访问决策
轮次访问阶段stackprev判定
1从5压左链[5,1]-∞等待访问
2访问1[5]11>-∞ ✓
3访问5,转右[]55>1 ✓
4从7压左链[7,4]5下一节点4
5访问4[7]54≤5,返回 False
6循环提前结束剩余7/8无需访问5整树非法
4 · 代码
class Solution:
    def isValidBST(self, root):
        def valid(node, lower_bound, upper_bound):
            # 1. 空子树不违反任何祖先约束
            if not node:
                return True

            # 2. 当前值必须落在祖先传下来的严格开区间内
            if not (lower_bound < node.val < upper_bound):
                return False

            # 3. 左侧收紧上界,右侧收紧下界
            return (
                valid(node.left, lower_bound, node.val)
                and valid(node.right, node.val, upper_bound)
            )

        return valid(root, float("-inf"), float("inf"))
# 解法二:BST 的中序序列必须严格递增
class Solution:
    def isValidBST(self, root):
        stack = []
        previous_value = float("-inf")
        current = root

        # 1. 用显式栈执行中序遍历
        while current or stack:
            while current:
                stack.append(current)
                current = current.left

            current = stack.pop()

            # 2. 相等或下降都立即证明不是 BST
            if current.val <= previous_value:
                return False
            previous_value = current.val
            current = current.right

        return True
5 · 复杂度

时间 O(n),每节点访问一次;空间 O(h) 递归栈。中序法同量级。

6 · 测试用例

功能[2,1,3]true边界:单节点→true特殊[5,1,4,null,null,3,6]false(局部合法整体非法)、相等值 [2,2,2]false(需严格小于)、节点值等于 int 最值(用 ±inf 避免边界错)。

二叉搜索树中第K小的元素

LC 230 ↗二叉树 · Medium
1 · 题目

要完成的任务

给定二叉搜索树根节点 root 和整数 k,返回树中按值从小到大排列后的第 k 个值;k 从 1 开始计数。

输入表示

层序数组用于展示 BST,null 表示缺少孩子;函数实际接收根节点和 k。题目保证 k 不超过节点数。

示例 1

输入:root=[3,1,4,null,2], k=1

输出:1

LC230 两个二叉搜索树示例左树包含 1、2、3、4,第一个最小值为 1;右树包含 1 到 6,第三个最小值为 3。示例 1:k=1示例 2:k=33142536241
BST 中序值严格递增,因此左图第一次访问 1,右图第三次访问 3。

示例 2

输入:root=[5,3,6,2,4,null,null,1], k=3

输出:3

约束与进阶

  • 树中节点数为 n,且 1 ≤ k ≤ n ≤ 10⁴
  • 0 ≤ Node.val ≤ 10⁴
  • 若树经常增删且查询第 k 小也很频繁,可维护子树节点数以加速查询。
2 · 思路(暴力→最优)

暴力:完整中序得到长度 n 的升序数组,再取下标 k-1;正确但会访问目标之后的节点,并额外保存整个序列。

两种正解:
① 中序提前停:BST 中序严格递增,每弹出一个节点就把 k 减一;在示例中依次弹出 1、2、3,k 从 3 变为 0 时直接返回 3,节点 4 及更右部分无需访问。单次查询最坏 O(h+k)。
② 子树大小索引:若预存每个节点的子树规模,当前节点排名就是 left_size+1。示例根 3 的左子树有 2 个节点,因此根排名正好为 3;若 k 更小走左边,否则扣除左子树和当前根后走右边。
③ 优化取舍:单次查询用中序,状态少;频繁查询时先 O(n) 预处理 size,之后每次只走一条根到目标路径 O(h)。

⚠ 易错坑 k 是 1-indexed,弹出节点后先减一再判断是否为零。
当前节点排名是 left_size+1,别漏掉当前节点。
题目保证 k 有效;若封装成通用接口,应显式处理越界。
3 · 图解
7 节点 BST,求 k=4
              5
            /   \
           3     7
          / \   /
         2   4 6
        /
       1
中序:[1,2,3,4,5,6,7]

解法①|显式栈里程碑
压左链 [5,3,2,1]
弹1:k 4→3;弹2:k 3→2
弹3:k 2→1;转入右子4
弹4:k 1→0,返回4;节点5、6、7无需访问

解法②|子树大小
              5[size=7]
            /           \
       3[size=4]       7[size=2]
       /      \         /
  2[size=2] 4[1]      6[1]
   /
  1[1]
根5:left_size=4,k=4≤4 → 去左
节点3:left_size=2,当前排名3;k=4>3 → 扣3,去右且 k=1
节点4:left_size=0,left_size+1=1 → 返回4
解法① 中序提前终止:每行概括一次有序访问
阶段访问stackk结果
1压 5→3→2→1[5,3,2,1]4等待访问
2弹1[5,3,2]3第1小
3弹2[5,3]2第2小
4弹3,转右4[5]1第3小
5压入并弹4[5]0返回4
6提前结束5/6/7未访问0答案已定
解法② 子树大小索引:预处理与查询决策分阶段摘要
阶段位置left_sizek决策
1后序预处理叶子size1/4/6=1叶规模稳定
2合并内部节点size2=2,size3=4,size7=2根size=7
3根544k≤4,向左
4节点324扣3,右移,k=1
5节点401排名命中,返回4
4 · 代码
class Solution:
    def kthSmallest(self, root, k):
        stack = []
        current = root

        # 1. 中序弹出的节点按值从小到大排列
        while current or stack:
            while current:
                stack.append(current)
                current = current.left

            current = stack.pop()
            k -= 1

            # 2. 第 k 次访问后立即结束,不遍历更大节点
            if k == 0:
                return current.val
            current = current.right
# 解法二:预处理子树大小,适合对同一棵树频繁查询
class Solution:
    def kthSmallest(self, root, k):
        subtree_sizes = {}

        def count_nodes(node):
            # 1. 后序计算并缓存每棵子树的节点数
            if not node:
                return 0
            subtree_sizes[node] = (
                1 + count_nodes(node.left) + count_nodes(node.right)
            )
            return subtree_sizes[node]

        count_nodes(root)
        current = root

        # 2. 用左子树规模判断目标排名落在哪一侧
        while current:
            left_size = subtree_sizes.get(current.left, 0)
            current_rank = left_size + 1

            if k == current_rank:
                return current.val
            if k < current_rank:
                current = current.left
            else:
                k -= current_rank
                current = current.right
5 · 复杂度

解法① 时间 O(h + k),空间 O(h) 栈;解法② 预处理时间/空间 O(n),之后每次查询 O(h)(平衡 BST 为 O(log n))。

6 · 测试用例

功能[3,1,4,null,2], k=11边界:k=1(最小)、k=n(最大);特殊:单节点 k=1、左斜链验证提前终止正确性。

二叉树的右视图

LC 199 ↗二叉树 · Medium
1 · 题目

要完成的任务

给定二叉树根节点 root,设观察者站在树的右侧,返回从上到下能看到的节点值。每一层只有最靠右的节点可见。

输入表示

层序数组展示树结构,null 表示对应孩子不存在;函数实际接收根节点并返回一维值列表。

示例 1

输入:root=[1,2,3,null,5,null,4]

输出:[1,3,4]

右视图的两个非对称树示例左树每层最右值是 1、3、4;右树每层最右值是 1、3、4、5。示例 1:右视图 [1,3,4]示例 2:右视图 [1,3,4,5]1235412345
右视图按层取最右节点;更深层即使只来自左子树,也仍可能可见。

示例 2

输入:root=[1,2,3,4,null,null,null,5]

输出:[1,3,4,5]

示例 3

输入:root=[1,null,3]

输出:[1,3]

示例 4

输入:root=[]

输出:[]

约束

  • 树中节点数在 [0,100] 内。
  • -100 ≤ Node.val ≤ 100
2 · 思路(暴力→最优)

暴力/误区:只沿 right 指针向下会漏节点:某层没有右孩子时,左子树中的节点仍可能是该层最右。应按“每层最右”而不是“右链”定义问题。

两种正解:
① BFS 取末位:冻结每层长度,按左到右处理;本层最后弹出的节点就是右视图。非对称样例第 2 层最后是 3,第 3 层虽然来自左子树,最后仍是 5。
② DFS 取首达:按“根→右→左”递归,每个深度首次到达的节点必然最右;用 depth==len(result) 判断首次。样例先记录 1、3,回到左侧 2 时深度 1 已占用,再由 2 的右子 5 首次占用深度 2。
③ 优化取舍:BFS 空间 O(w),层定义直观;DFS 空间 O(h),代码更短,但顺序必须先右后左。两者都只访问每个节点一次。

⚠ 易错坑 DFS 若先左后右会得到左视图。
首次到达判断用严格相等 depth==len(result)
右视图不是只收集具有右孩子的节点。
3 · 图解
非对称 6 节点树
          1
        /   \
       2     3
        \   /
         5 4
        /
       6

里程碑1|第0层
BFS 本层 [1] → 末位1;DFS 首达 d0=1
result=[1]

里程碑2|第1层
BFS 本层 [2,3] → 末位3;DFS 先右到3
result=[1,3]

里程碑3|第2层
BFS 本层 [5,4] → 末位4;DFS 先到3.left=4
稍后到5时该深度已有值,不覆盖
result=[1,3,4]

里程碑4|第3层
右子树已结束,但左侧5.left=6是该层唯一节点
BFS 本层 [6];DFS 首次到 d3=6
result=[1,3,4,6]

里程碑5|从右侧看到
          1 ←
              \
               3 ←
              /
             4 ←
左侧更深处的 6 仍会在下一层被看到 → [1,3,4,6]
解法① 分层 BFS:每行是一层,记录本层末位和下一层队列
本层队列末位result下一层
0[1]1[1][2,3]
1[2,3]3[1,3][5,4]
2[5,4]4[1,3,4][6]
3[6]6[1,3,4,6][]
4队列为空各层末位已记录[1,3,4,6]返回
解法② 右优先 DFS:按首次到达新深度的关键调用摘要
阶段访问顺序首次深度result判定
11 / d0d0[1]记录
23 / d1d1[1,3]记录
34 / d2d2[1,3,4]记录
42、5均已占用[1,3,4]不覆盖
56 / d3d3[1,3,4,6]记录并完成
4 · 代码
from collections import deque

class Solution:
    def rightSideView(self, root):
        # 1. 空树没有可见节点
        if not root:
            return []

        result = []
        queue = deque([root])

        # 2. 每层最后弹出的节点就是该层最右节点
        while queue:
            level_size = len(queue)

            for index in range(level_size):
                node = queue.popleft()
                if index == level_size - 1:
                    result.append(node.val)

                if node.left:
                    queue.append(node.left)
                if node.right:
                    queue.append(node.right)

        return result
# 解法二:右优先 DFS,每个深度首次到达即最右
class Solution:
    def rightSideView(self, root):
        result = []

        def dfs(node, depth):
            # 1. 空节点不会产生新的可见层
            if not node:
                return

            # 2. 右优先保证首次到达当前深度的节点最靠右
            if depth == len(result):
                result.append(node.val)

            dfs(node.right, depth + 1)
            dfs(node.left, depth + 1)

        dfs(root, 0)
        return result
5 · 复杂度

时间 两种解法均 O(n),每节点处理一次。BFS 空间 O(w),w 为树的最大层宽,最坏 O(n);DFS 空间 O(h),h 为树高,来自递归栈(最坏退化链 O(n))。

6 · 测试用例

功能[1,2,3,null,5,null,4][1,3,4]边界:空树→[]、单节点→[1]特殊左斜链(右侧没节点时最左也被看到)、某层右子为空但左子存在。

二叉树展开为链表

LC 114 ↗二叉树 · Medium
1 · 题目

要完成的任务

给定二叉树根节点 root,在原树节点上把它展开成一条只沿 right 前进的链:链上顺序必须等于原树的前序遍历,且每个节点的 left 都要置为 null

输入与输出

函数原地修改树,不返回一条新链表。题面用层序数组展示修改前后的节点连接;输出中的连续 null 表示每个链上节点都没有左孩子。

示例 1

输入:root=[1,2,5,3,4,null,6]

输出:[1,null,2,null,3,null,4,null,5,null,6]

二叉树按前序原地展开前后原树根为 1,左子树 2 含 3、4,右子树 5 含右孩子 6;展开后仅由 right 指针连接成 1、2、3、4、5、6。原树125346展开后(全是 right)1 → 2 → 3→ 4 → 5 → 6left 全为 null
展开顺序正是原树前序 1,2,3,4,5,6,不创建替代节点。

示例 2

输入:root=[]

输出:[]

示例 3

输入:root=[0]

输出:[0]

约束与保证

  • 树中节点数在 [0,2000] 内。
  • -100 ≤ Node.val ≤ 100
  • 必须原地展开,并保持前序遍历顺序。
2 · 思路(暴力→最优)

暴力:先前序遍历保存全部节点,再按数组顺序重连 right 并清空 left;实现简单,但额外空间 O(n)。

正解 · 原地前驱法:
① 核心规律:前序是“根→左子树→右子树”。若当前节点有左子树,左子树的最右节点正是这段前序片段的末尾,应把原右子树接到它后面。
② 第一轮样例:在节点 1 处找到左子树最右节点 4;先做 4.right=5 保住原右链,再把节点 2 搬到 1.right,最后清空 1.left,稳定前缀为 1→2
③ 第二轮重复:前进到节点 2,找到前驱 3,把原右节点 4 接到 3 后,再搬左、清 left,稳定链变为 1→2→3→4→5→6
④ 优化取舍:显式栈法易写但需 O(n) 空间;前驱法原地 O(1) 空间。每条临时寻找路径会随结构改造成右链,总访问量仍为 O(n)。

⚠ 易错坑 必须先接 pre.right=cur.right,再覆盖 cur.right,否则原右子树会丢失。
搬完后必须把 cur.left 清空。
下一轮沿新的 cur.right 前进,不能跳到旧右子树。
3 · 图解
图例:①/② 表示“同一个节点被两条指针同时引用”,不是复制出两个节点。
目标前序:1,2,3,4,5,6

【0 · 初始】cur=1
            1
          /   \
         2     5
        / \     \
       3   4     6

【1A · 找前驱】从 cur.left=2 沿 right 走:2 → 4,所以 pre=4
            1
          /   \
         2     5
        / \     \
       3  [4]    6

【1B · pre.right = cur.right】4.right 先接原右子树 5
            1
          /   \
         2     5①
        / \     \
       3   4 ───→5① → 6
此刻 1.right 与 4.right 同指节点5,旧右边尚未清理。

【1C · cur.right = cur.left】左子树搬到右边,但 left 尚未清空
            1
          ↙   ↘
        2②   2②
       / \
      3   4 → 5 → 6
此刻 1.left 与 1.right 同指节点2。

【1D · cur.left = ∅】第1轮稳定;cur 沿 right 到 2
        1
         \
          2
         / \
        3   4 → 5 → 6

【2A · cur=2,pre=3;先做 3.right = 4】
        1
         \
          2
         / \
        3 ──→4① → 5 → 6
              ↑
              └──── 2.right 仍指同一个4①

【2B · 2.right = 3】搬左子树;左右暂时同指3
        1
         \
          2
        ↙   ↘
       3②   3② → 4 → 5 → 6

【2C · 2.left = ∅】第2轮稳定
right:1 → 2 → 3 → 4 → 5 → 6 → ∅
left :∅   ∅   ∅   ∅   ∅   ∅

【3~6 · 稳定扫描】cur 依次经过3、4、5、6;均无左子树,不再改边。
前驱法按 while 轮次概括;所有指针赋值后的瞬时结构由上方 1B~2C 展开
轮次cur关键决策稳定结构下一节点
11左子树存在,找到 pre=4尚未改边准备重连
214接5;2搬右;left清空1→2,2仍有左3/右4cur=2
32左子树存在,找到 pre=3尚未改本轮边准备重连
423接4;3搬右;left清空1→2→3→4→5→6cur=3
53,4,5,6均无左子树,只沿 right 前进链保持稳定cur=∅
6循环结束所有 left=∅1→2→3→4→5→6原地展开完成
4 · 代码
class Solution:
    def flatten(self, root):
        current = root

        # 1. 沿已经形成的右链逐节点处理
        while current:
            if current.left:
                # 2. 找到左子树前序片段的末尾节点
                predecessor = current.left
                while predecessor.right:
                    predecessor = predecessor.right

                # 3. 先保住原右子树,再搬左子树并清空旧左边
                predecessor.right = current.right
                current.right = current.left
                current.left = None

            current = current.right
5 · 复杂度

时间 O(n),每节点最多被"找前驱"访问常数次;空间 O(1),原地无递归栈(前驱法优于递归 O(h))。

6 · 测试用例

功能[1,2,5,3,4,null,6]→右斜链 1-2-3-4-5-6边界:空树→空、单节点→自身;特殊:只有左子树的树、已是右斜链的树(应保持不变)。

从前序与中序遍历序列构造二叉树

LC 105 ↗二叉树 · Medium
1 · 题目

要完成的任务

给你两个整数数组 preorderinorder。它们分别是同一棵二叉树的前序遍历和中序遍历。请根据这两份遍历结果还原二叉树,并返回根节点。

两个数组分别表示什么

preorder 按“根节点 → 左子树 → 右子树”的顺序记录节点。
inorder 按“左子树 → 根节点 → 右子树”的顺序记录节点。

示例 1

输入:preorder=[3,9,20,15,7]inorder=[9,3,15,20,7]

输出:[3,9,20,null,null,15,7]

根是 3,左孩子是 9,右孩子是 20;20 的左、右孩子分别是 15 和 7。

LC105 示例 1 对应的二叉树 根节点是 3,左孩子是 9,右孩子是 20;节点 20 的左孩子是 15,右孩子是 7。 3 9 20 15 7
示例 1 对应的树结构

输出为什么是数组?函数实际返回根节点;力扣只是用层序数组展示整棵树。null 表示对应位置没有孩子。

示例 2

输入:preorder=[-1]inorder=[-1]

输出:[-1]

只有一个值时,直接得到一棵单节点树。

约束与保证

  • 数组长度满足 1 ≤ preorder.length ≤ 3000,且 inorder.length == preorder.length
  • 每个节点值都在 [-3000,3000] 内。
  • preorderinorder 内部都没有重复值,且包含同一批节点值。
  • 两个数组保证是同一棵二叉树的合法前序遍历和中序遍历。
2 · 思路(暴力→最优)

1. 从最直观的做法开始

每次拿前序数组的第一个值当根,再去中序数组中找到它。根左边的值拿去建左子树,右边的值拿去建右子树;对子树重复同样的过程,就能把整棵树建出来。

局限:如果每层都用 inorder.index(root_value) 从头找根,斜树会重复扫描中序数组,最坏 O(n²)。如果每层还真的切出四个新数组,又会产生许多复制。正解不改变建树逻辑,只把“查找”和“切片”省掉。

2. 先用实际数值走一遍

① 找整棵树的根:前序是 [3,9,20,15,7],第一个值 3 一定是根。
② 用根拆中序:中序 [9,3,15,20,7] 被 3 分成左边 [9] 和右边 [15,20,7]。这两段就是左右子树的节点。

③ 用左子树人数拆前序:中序左边只有 1 个值,所以前序在根 3 之后也只取 1 个值给左子树,即 [9];剩下的 [20,15,7] 全部给右子树。

④ 递归处理左边:左边只有 9,因此直接得到节点 9,并把它挂到 3.left

⑤ 递归处理右边:右侧前序的第一个值 20 是右子树的根。中序 [15,20,7] 又被 20 分成 [15][7],所以 15、7 分别成为 20 的左右孩子。整棵树就还原完成了。

到这里,算法其实已经讲完了:前序负责告诉我们“根是谁”,中序负责告诉我们“左右两边各有哪些节点”,而中序左边的节点数量,负责告诉前序应该在哪里分开。

3. 为什么代码要传四个边界

上面的讲法像是在不断切出小数组,但真正切片会复制数据。代码保留原数组,只用 pre_left/pre_right 表示当前前序范围,用 in_left/in_right 表示当前中序范围。

4. 把“数左边人数”写成公式

① 左子树大小:left_subtree_size = in_root_index - in_left
② 左子树范围:前序 [pre_left+1, pre_left+left_subtree_size],中序 [in_left, in_root_index-1]

③ 右子树范围:前序 [pre_left+left_subtree_size+1, pre_right],中序 [in_root_index+1, in_right]。如果左边没有节点,左子树大小就是 0,这些公式仍然成立。

5. 用哈希表消除重复查找

提前建立 inorder_index,记录“节点值 → 中序下标”。以后每次找根的位置都是 O(1),每个节点只创建一次,总时间降为 O(n)。下面再用一个 7 节点非对称样例,检查这套规则在更深一层和空子树处仍然成立。

⚠ 易错坑 left_subtree_size 是“根在中序中的下标 − 当前中序区间左边界”,不是直接使用根的全局下标。
中序的左右区间都要跳过根;递归中只要 pre_left > pre_right,就说明当前子树为空。
题目保证节点值互不相同,才能用一个哈希下标唯一定位根;有重复值时不能直接套用。
3 · 图解
上面的官方示例用于讲懂规则;这里换成 7 个节点,验证更深递归和空子树。

第 1 步|先确定整棵树的根,并切出左右两组数据

前序:[8] [4,6,5] [12,10,11]     第一个数 8 是根
       根   左子树     右子树
中序:[4,5,6] [8] [10,11,12]     根的左边属于左子树,右边属于右子树
       左子树   根    右子树

第 2 步|递归构造左子树 [4,6,5]

4 是根;4 的左边为空,右边还剩 [5,6]
6 是右侧的根;5 在 6 的左边

得到左子树,再挂到 8.left:
        8
       /
      4
       \
        6
       /
      5


第 3 步|递归构造右子树 [12,10,11]

12 是根;12 的右边为空,左边还剩 [10,11]
10 是左侧的根;11 在 10 的右边

先得到右子树:
       12
       /
     10
       \
       11


第 4 步|把右子树挂到 8.right,整棵树完成

          8
        /   \
       4     12
        \    /
         6  10
        /     \
       5       11
按递归调用概括关键区间:上图负责展示“切分 → 构造左右子树 → 合并”的四步主线
阶段递归调用根与切分当前区间状态结论
1build(pre=[0,6], in=[0,6])根=8,left_size=3左右区间已确定左 [1,3],右 [4,6]
2build(pre=[1,3], in=[0,2])根=4,left_size=0左区间为空继续构造右区间
3build(pre=[2,3], in=[1,2])根=6,left_size=16.left=5子树 6→5 完成
4左子树返回根调用4.right=68.left=4整棵左子树稳定
5build(pre=[4,6], in=[4,6])根=12,left_size=2右区间为空先构造左区间
6build(pre=[5,6], in=[4,5])根=10,left_size=010.right=11子树 10→11 完成
7左子树返回 1212.left=10右区间为空整棵右子树稳定
8右子树返回根调用所有区间处理完毕8.right=12得到最终非对称树
4 · 代码
class Solution:
    def buildTree(self, preorder, inorder):
        # 1. 预处理:记录每个节点值在中序数组中的位置
        inorder_index = {value: index for index, value in enumerate(inorder)}

        def build(pre_left, pre_right, in_left, in_right):
            # 2. 递归终止:前序区间为空,说明这里没有节点
            if pre_left > pre_right:
                return None

            # 3. 前序区间的第一个元素就是当前子树的根
            root_value = preorder[pre_left]
            root = TreeNode(root_value)

            # 4. 根在中序中的位置决定左子树包含多少个节点
            in_root_index = inorder_index[root_value]
            left_subtree_size = in_root_index - in_left

            # 5. 用左子树大小切出两套数组中对应的左区间
            root.left = build(
                pre_left + 1,
                pre_left + left_subtree_size,
                in_left,
                in_root_index - 1,
            )

            # 6. 跳过“根 + 左子树”,剩余区间用于构造右子树
            root.right = build(
                pre_left + left_subtree_size + 1,
                pre_right,
                in_root_index + 1,
                in_right,
            )
            return root

        # 7. 初始调用覆盖两份遍历数组的完整区间
        node_count = len(preorder)
        return build(0, node_count - 1, 0, node_count - 1)
5 · 复杂度

时间 O(n),哈希查根 O(1),每节点建一次;空间 O(n),哈希表 + 递归栈 O(h)。

6 · 测试用例

功能pre=[8,4,6,5,12,10,11], in=[4,5,6,8,10,11,12]→上图非对称树;边界:单节点 pre=[1], in=[1]特殊:纯左斜链 pre=[1,2,3], in=[3,2,1] 与纯右斜链 pre=[1,2,3], in=[1,2,3],重点验证四个区间边界。

路径总和 III

LC 437 ↗二叉树 · Medium
1 · 题目

要完成的任务

给定二叉树根节点 root 和整数 targetSum,统计节点值之和恰好等于目标值的路径条数。路径必须沿父节点到子节点方向连续向下,但可以从任意节点开始,也可以在任意节点结束。

输入表示与计数规则

层序数组用于展示树,null 表示缺少孩子;函数实际接收根节点和目标值。起止节点不同的路径分别计数,路径不能逆着边向上走。

示例 1

输入:root=[10,5,-3,3,2,null,11,3,-2,null,1], targetSum=8

输出:3,三条路径为 5→35→2→1-3→11

目标和为 8 的三条向下路径树根 10,左子树根 5,右子树根负 3;和为 8 的路径分别是 5 到 3、5 到 2 到 1、负 3 到 11。105−332113−215→3  5→2→1  −3→11
三条合法路径都连续向下,但没有一条必须从根 10 开始。

示例 2

输入:root=[5,4,8,11,null,13,4,7,2,null,null,5,1], targetSum=22

输出:3

约束

  • 树中节点数在 [0,1000] 内。
  • -10⁹ ≤ Node.val ≤ 10⁹
  • -1000 ≤ targetSum ≤ 1000
2 · 思路(暴力→最优)

暴力:以每个节点为起点再做一次向下 DFS 累加,最坏 O(n²),大量路径前缀会被重复计算。

正解 · 树上前缀和:
① 核心规律:根到当前节点前缀和为 current_sum 时,若当前祖先链上出现过 current_sum-target,从那个前缀之后到当前节点就是一条目标路径;出现几次就新增几条。
② 样例落地:经典 target=8 的树中,沿 10→5→3 到达前缀 18 时查到祖先前缀 10,命中 5→3;沿 10→5→2→1 再命中一条,右支 -3→11 命中第三条。
③ 回溯约束:prefix_count 只能代表当前根到节点的祖先链;离开节点必须撤销当前前缀,否则左分支前缀会污染右分支。查询发生在加入自己之前,避免把空路径计入。
④ 优化取舍:每个节点只做常数次哈希操作,时间 O(n)、空间 O(h);相比暴力消除了对相同根路径前缀的重复求和。

⚠ 易错坑 初始 prefix_count[0]=1 才能统计从根开始的目标路径。
顺序必须是“累加→查询→加入→递归→撤销”。
节点值可为负,不能用“和超过 target 就剪枝”。
3 · 图解
target=8,经典分支样例
              10
            /    \
           5     -3
          / \      \
         3   2      11
        / \   \
       3  -2   1

里程碑1|进入根10
current_sum=10,先查 2 不存在,再加入10
prefix_count={0:1,10:1}

里程碑2|左支 10→5→3
到5时前缀15;到内部节点3时前缀18
查 18-8=10,命中祖先前缀10 → 路径 5→3,累计1

里程碑3|完成节点3子树并回溯,再走 10→5→2→1
离开上一分支后撤销其前缀;到1时前缀18
再次查到前缀10 → 路径 5→2→1,累计2

里程碑4|离开整棵5子树,撤销前缀15后进入右支
10→-3→11 的前缀依次为7、18
查到前缀10 → 路径 -3→11,累计3
左支前缀已撤销,不会与右支串成伪路径。

里程碑5|根调用结束
所有本次加入的前缀都已撤销
prefix_count 恢复为 {0:1};计数归零的键已删除,返回3。
前缀和 DFS:按节点进入、分支回溯与命中阶段摘要
阶段路径位置前缀/查询有效 prefix_count累计
1初始化sum=0,target=8{0:1}0
2进入10、510→15{0,10,15}0
3进入左侧内部318,查10前缀10出现1次1
4离开3的分支撤销18及其后代{0,10,15}1
5进入2→117→18,查10当前链命中2
6离开5子树撤销15及其后代{0,10}2
7进入-3→117→18,查10仅根前缀有效3
8根调用结束撤销前缀10{0:1}返回3
4 · 代码
from collections import defaultdict

class Solution:
    def pathSum(self, root, targetSum):
        prefix_count = defaultdict(int)
        prefix_count[0] = 1

        def dfs(node, current_sum):
            # 1. 空子树不新增路径
            if not node:
                return 0

            current_sum += node.val

            # 2. 先查询祖先前缀,再把当前前缀加入有效链
            path_count = prefix_count.get(current_sum - targetSum, 0)
            prefix_count[current_sum] += 1

            # 3. 两个分支共享当前祖先链,但彼此不能串扰
            path_count += dfs(node.left, current_sum)
            path_count += dfs(node.right, current_sum)

            # 4. 离开节点时撤销;删除零计数键,哈希表只保留当前祖先链
            prefix_count[current_sum] -= 1
            if prefix_count[current_sum] == 0:
                del prefix_count[current_sum]
            return path_count

        return dfs(root, 0)
5 · 复杂度

时间 O(n),每节点一次哈希查/更新;空间 O(h),哈希表最多存一条根到节点路径的前缀和 + 递归栈。

6 · 测试用例

功能[10,5,-3,3,2,null,11,3,-2,null,1], target=83边界:空树→0、单节点等于 target→1特殊负数节点target=0、同一路径多个解、必须回溯否则跨分支误计。

二叉树的最近公共祖先

LC 236 ↗二叉树 · Medium
1 · 题目

要完成的任务

给定二叉树根节点 root 以及树中的两个不同节点 pq,返回它们的最近公共祖先:同时是二者祖先且深度最大的节点。

输入表示与祖先定义

层序数组用于展示树,null 表示缺少孩子;函数得到的是 pq 的节点引用。按题目定义,一个节点也可以是它自己的祖先,因此当 pq 的祖先时,答案可以直接是 p

示例 1

输入:root=[3,5,1,6,2,0,8,null,null,7,4], p=5, q=1

输出:3,节点 5 和 1 分处根节点 3 的两侧。

最近公共祖先的三个官方示例主树中节点 5 和 1 的最近公共祖先是 3;节点 5 和 4 的最近公共祖先是 5。小树中节点 1 和 2 的最近公共祖先是 1。示例 1、2 共用的树35162087412示例 3LCA(5,1)=3  LCA(5,4)=5LCA(1,2)=1
例 2 中节点 5 本身就是节点 4 的祖先;例 3 同理,祖先节点可以直接成为答案。

示例 2

输入:root=[3,5,1,6,2,0,8,null,null,7,4], p=5, q=4

输出:5,因为节点可以是自身的祖先。

示例 3

输入:root=[1,2], p=1, q=2

输出:1

约束与保证

  • 树中节点数满足 2 ≤ n ≤ 10⁵
  • -10⁹ ≤ Node.val ≤ 10⁹,所有节点值互不相同。
  • p != q,且 pq 都一定存在于树中。
2 · 思路(暴力→最优)

暴力:分别保存“根→p”和“根→q”两条路径,再找最后一个公共节点;正确,但需要两次搜索和 O(n) 路径空间。

正解 · 后序信号上报:
① 核心规律:递归返回值表示“当前子树是否找到 p 或 q,找到就上报那个节点”。左右都非空时,当前节点是两路信号首次汇合的最低分叉点;只有一侧非空就原样上报。
② 分居样例:p=5、q=1 时,两侧分别上报 5 和 1,根 3 同时收到两路信号,因此返回 3。
③ 祖先样例:p=5、q=6 时,进入 5 就命中 p 并立即返回;根只收到左侧信号 5,最终仍返回 5。题设保证 q 存在,因此“命中目标即截断”与祖先关系自洽。
④ 优化取舍:一趟后序 O(n)、栈 O(h),不需要保存完整路径;若题目不保证两节点存在,则要额外统计命中数,不能直接复用本结论。

⚠ 易错坑 命中 p 或 q 时立即返回是算法定义,不应继续向下搜。
单侧命中要返回 left or right,不能返回当前无关节点。
本写法依赖 p、q 都在树中的题设。
3 · 图解
情况一|p=5、q=1,答案是分叉点3
          3
        /   \
       5     1
      / \   / \
     6   2 0   8

帧1  LCA(5) 进入即命中 p,向上返回信号 [5]
帧2  LCA(1) 进入即命中 q,向上返回信号 [1]
帧3  根3同时收到 left=5、right=1
          3  ← 两侧信号首次汇合,返回3
        /   \
      [5]   [1]

情况二|p=5、q=6,p 本身是 q 的祖先
          3
        /   \
       5(p)  1
      / \
   6(q)  2

帧A  调用到节点5时先命中 p,立即返回5;其孩子6不会在本次调用里继续搜索。
帧B  根3的左侧收到5,右侧返回∅:
          3
        /   \
      [5]   ∅
只有一个非空信号就原样上报5。题目保证 p、q 都在树中,所以被提前截断的 q=6 确实位于5子树内。
情况一:两侧各命中一个目标,信号第一次在根3汇合
阶段调用leftright返回
1LCA(3)递归左子树尚未求等待
2命中节点55提前返回5
3命中节点11提前返回1
4回到节点351两侧非空,返回3
5根调用结束分叉已确定无需更多搜索答案3
情况二:p 是 q 的祖先,命中 p 的提前返回与根层单信号上报分成两个阶段
阶段调用位置看到什么子树访问返回信号
1进入节点3既非p也非q先递归左等待
2进入节点5root==p不再访问孩子6/25
3递归节点1子树不含目标逐层到底None
4回到节点3left=5right=None唯一信号5
5执行 left or right5 or None向上返回5
6根调用结束p覆盖q所在子树题设保证q存在LCA=5
4 · 代码
class Solution:
    def lowestCommonAncestor(self, root, p, q):
        def find(node):
            # 1. 空节点或目标节点直接作为信号上报
            if not node or node == p or node == q:
                return node

            # 2. 后序收集左右子树的目标信号
            left_signal = find(node.left)
            right_signal = find(node.right)

            # 3. 两侧都命中时,当前节点是最低汇合点
            if left_signal and right_signal:
                return node

            # 4. 只有一侧命中时原样向父节点上报
            return left_signal or right_signal

        return find(root)
5 · 复杂度

时间 O(n),最坏遍历全树;空间 O(h),递归栈为树高。

6 · 测试用例

功能[3,5,1,6,2,0,8,null,null,7,4], p=5,q=13边界:同一棵树中 p、q 一为另一祖先(p=5,q=4)→5特殊:p、q 分居两侧最深处、根即 LCA、只有两个节点的树。

二叉树中的最大路径和

LC 124 ↗二叉树 · Hard
1 · 题目

要完成的任务

给定非空二叉树根节点 root,求任意合法路径的最大节点值总和。路径由若干不同节点组成,相邻节点之间必须有树边;同一节点最多出现一次,路径不必经过根,也不要求从上到下。

输入与输出

层序数组用于展示树,null 表示缺少孩子;函数实际接收根节点并返回一个整数。路径至少包含一个节点,所以全负树也必须选择其中某个节点。

示例 1

输入:root=[1,2,3]

输出:6,最优路径为 2→1→3

两棵树的最大路径和左树最优路径为 2、1、3,总和 6;右树最优路径为 15、20、7,总和 42,不经过根负 10。示例 1:2→1→3 = 6示例 2:15→20→7 = 42123−10920157
最优路径可以在某个节点把左、右贡献接起来;示例 2 的最优路径完全位于根的右子树。

示例 2

输入:root=[-10,9,20,null,null,15,7]

输出:42,最优路径为 15→20→7

约束

  • 树中节点数满足 1 ≤ n ≤ 3×10⁴
  • -1000 ≤ Node.val ≤ 1000
2 · 思路(暴力→最优)

暴力:枚举路径端点并计算两点间路径和会重复搜索大量子树,最坏 O(n²);只从根出发也会漏掉完全位于某个子树内的最佳路径。

正解 · 加权后序:
① 核心规律:gain(node) 只返回“从 node 向下的一条最佳单支”。节点作为本地最高点时,候选可同时接左右两支:node.val+left_gain+right_gain;向父上报时只能选较大一支。
② 样例落地:叶 15、7 分别上报 15、7;节点 20 用两支更新候选为 42,但只把 20+15=35 上报根;根 -10 得到候选 34,不覆盖 42。公式在内部节点和根连续应用。
③ 负值处理:子树 gain 为负时截为 0,表示不接该支;全局答案必须从负无穷开始,保证全负树仍选择至少一个节点。
④ 优化取舍:一次后序同时计算单支贡献和全局最佳,时间 O(n)、栈 O(h);闭包局部变量比不必要的实例状态更清楚地限定本次调用生命周期。

⚠ 易错坑 全局初值不能是 0,否则全负树会错误返回空路径。
本地候选可用两支,向父返回只能用一支。
左右负贡献要截为 0,但当前节点本身不能被跳过。
3 · 图解
初始树
         -10
        /   \
       9     20
            / \
           15  7

gain(node) 向父节点只能上报一条单支;ans 候选可在当前节点同时接左右两支。

帧1|三个叶子
9: l=r=0,候选=9,  gain=9
15:l=r=0,候选=15, gain=15
7: l=r=0,候选=7,  gain=7
此时 ans=15

帧2|节点20收到 l=15、r=7
          20
         /  \
       15    7
本地完整路径候选 = 15+20+7 = 42 → ans=42
向父节点只能选较大单支:gain(20)=20+max(15,7)=35
所以上报的是路径 15→20,而不是把 7 也带给父节点。

帧3|根-10收到 l=9、r=35
候选 = 9-10+35 = 34,小于全局 ans=42,不覆盖
向上 gain = -10+max(9,35)=25

最终保留的全局路径:
       15
        \
         20
        /
       7        即 15—20—7,和为42(路径无方向,图仅示连接)。
后序最大贡献:每行一个非空节点,合并左右 gain、本地候选、全局 ans 与单支返回
阶段节点左右非负 gainans向上 gain
1初始化-∞进入后序
2叶90 / 099
3叶150 / 01515
4叶70 / 0157
5节点2015 / 7max(15,42)=4235
6根-109 / 35max(42,34)=4225
7根调用结束最佳路径停在2042返回42
4 · 代码
class Solution:
    def maxPathSum(self, root):
        best_sum = float("-inf")

        def gain(node):
            nonlocal best_sum
            # 1. 空子树对向下路径没有贡献
            if not node:
                return 0

            # 2. 负贡献直接舍弃,等价于不接这一支
            left_gain = max(gain(node.left), 0)
            right_gain = max(gain(node.right), 0)

            # 3. 当前节点作最高点时,可同时接左右两支更新答案
            path_through_node = node.val + left_gain + right_gain
            best_sum = max(best_sum, path_through_node)

            # 4. 向父节点只能上报一条连续单支
            return node.val + max(left_gain, right_gain)

        gain(root)
        return best_sum
5 · 复杂度

时间 O(n),一次后序;空间 O(h) 递归栈。

6 · 测试用例

功能[-10,9,20,null,null,15,7]42边界:单节点(可能为负)→节点值本身、[1,2,3]6特殊全负 [-3]-3(答案初值必须 -inf 且路径至少含一个节点)、只有一支为正。

图论

图论题的核心是遍历:DFS(递归/栈,一条路走到底)和 BFS(队列,一圈一圈扩散)。网格类题(200 岛屿、994 腐烂橘子)本质是把二维格子当图,做 flood fill 灌水——从一个点出发把连通块全部染色/标记,DFS 和 BFS 都能做。994 用 BFS 是因为 BFS 天然按层扩散,层数就是最短时间;求"最短步数/最短时间"优先想 BFS。207 课程表是拓扑排序(Kahn 入度 BFS 或 DFS 判环),处理"依赖顺序 + 有没有环"。208 Trie 前缀树是一种多叉树结构,和图的多叉遍历同源,专治前缀匹配。关联到其它类别:网格 DFS 和回溯 LC79 单词搜索同一套模板(染色 + 撤销);BFS 分层与二分都追求"最短/最优"但手段不同。

岛屿数量

LC 200 ↗图论 · Medium
1 · 题目

要完成的任务

给你一个 m × n 的二维字符网格 grid,其中 '1' 表示陆地、'0' 表示水。请统计网格中互不连通的岛屿数量,并返回一个整数。

输入与连通规则

只有水平或竖直相邻的陆地格才属于同一座岛;斜对角接触不算连通。每座岛都被水包围,可以把网格四条边之外也看成水。

示例 1

输入:grid=[["1","1","1","1","0"],["1","1","0","1","0"],["1","1","0","0","0"],["0","0","0","0","0"]]

输出:1

所有陆地通过上下左右连成一个连通块,因此只有一座岛。

示例 2

输入:grid=[["1","1","0","0","0"],["1","1","0","0","0"],["0","0","1","0","0"],["0","0","0","1","1"]]

输出:3

左上、中央、右下三片陆地之间只隔水或斜向接触,互不连通。

约束与保证

  • m == grid.lengthn == grid[i].length
  • 1 ≤ m,n ≤ 300
  • 每个 grid[i][j] 只能是字符 '0''1'
2 · 思路(暴力→最优)

暴力:对每个陆地格重新搜索它所属的连通块,再用集合去重;同一座岛会被反复遍历,最坏接近 O((m·n)²)。

Flood Fill 正解:
① 核心规律:外层第一次遇到未访问陆地时,岛屿数加一,并从该点一次性淹没整个四连通块;之后这座岛不会再次触发计数。
② 样例落地:示例先从 (0,0) 覆盖 (0,0)、(1,0)、(0,1),计为岛 A;继续扫描到 (2,2) 再覆盖岛 B,最终得到 2。
③ 两种走法:DFS 用递归栈沿一条路深入;BFS 入队即标记,队列显式保存待扩展格。两者都只访问每格一次,时间 O(m·n);深网格优先 BFS,避免递归深度风险。

⚠ 易错坑 BFS 必须入队即标记,不能等出队,否则同一格可能重复入队。
只算上下左右四连通,对角线不连通。
边界判断必须先于访问 grid[row][col]
3 · 图解
示例 grid(1=陆地,0=水),四连通:

里程碑0|初始
1 1 0
1 0 0
0 0 1
island_count=0

里程碑1|扫描到 (0,0),发现岛 A
0 1 0
1 0 0
0 0 1
island_count=1
DFS:进入节点就置0;BFS:queue=[(0,0)],入队前已置0。

里程碑2|岛 A 整块覆盖完成
0 0 0
0 0 0
0 0 1
DFS 递归覆盖 (1,0)、(0,1)
BFS 队列曾稳定为 [(1,0),(0,1)],随后清空。

里程碑3|继续扫描到 (2,2),发现岛 B
0 0 0
0 0 0
0 0 0
island_count=2;从 (2,2) 启动第二次 flood fill。

里程碑4|最终稳定态
所有陆地都已标记,继续扫描不会再次计数,返回2。
解法一 · DFS:按“发现一座岛 → 淹没整座岛”记录稳定快照
阶段扫描/递归位置grid 变化island_count
初始化从 (0,0) 开始扫描尚未修改0
发现岛 A(0,0)(0,0) 立即置 0,进入 DFS1
淹没岛 A(1,0)、(0,1)两格依次置 0;越界/水域直接返回1
岛 A 稳定继续双层扫描左上三块陆地已全为 01
发现并淹没岛 B(2,2)最后一格置 02
结束扫描完成grid 中无未访问陆地返回 2
解法二 · BFS:入队即标记,队列只保留“已发现、待扩展”的陆地
阶段队列(队首→队尾)grid 变化island_count
初始化[]尚未修改0
发现岛 A[(0,0)](0,0) 入队前置 01
扩展岛 A[(1,0),(0,1)]相邻陆地入队前都置 01
岛 A 稳定[]三块陆地均已访问1
发现岛 B[(2,2)] → [](2,2) 置 0,弹出后无新邻居2
结束[]扫描完成返回 2
4 · 代码
class Solution:
    def numIslands(self, grid: list[list[str]]) -> int:
        # 1. 空网格直接返回,之后记录固定边界
        if not grid:
            return 0
        row_count = len(grid)
        col_count = len(grid[0])

        def flood(row, col):
            # 2. 越界、水域或已访问格都终止当前分支
            if (row < 0 or row >= row_count
                    or col < 0 or col >= col_count
                    or grid[row][col] != "1"):
                return

            grid[row][col] = "0"
            flood(row + 1, col)
            flood(row - 1, col)
            flood(row, col + 1)
            flood(row, col - 1)

        # 3. 每次从未访问陆地启动 flood,就发现一座新岛
        island_count = 0
        for row in range(row_count):
            for col in range(col_count):
                if grid[row][col] == "1":
                    island_count += 1
                    flood(row, col)

        return island_count
# 解法二:BFS 入队即标记,避免同一格重复进入队列
from collections import deque

class Solution:
    def numIslands(self, grid: list[list[str]]) -> int:
        if not grid:
            return 0

        row_count = len(grid)
        col_count = len(grid[0])
        directions = ((1, 0), (-1, 0), (0, 1), (0, -1))
        island_count = 0

        # 1. 外层扫描负责发现每个连通块的第一个格子
        for row in range(row_count):
            for col in range(col_count):
                if grid[row][col] != "1":
                    continue

                island_count += 1
                grid[row][col] = "0"
                queue = deque([(row, col)])

                # 2. 队列负责覆盖当前岛屿,邻居入队前立刻标记
                while queue:
                    current_row, current_col = queue.popleft()

                    for row_step, col_step in directions:
                        next_row = current_row + row_step
                        next_col = current_col + col_step

                        if (0 <= next_row < row_count
                                and 0 <= next_col < col_count
                                and grid[next_row][next_col] == "1"):
                            grid[next_row][next_col] = "0"
                            queue.append((next_row, next_col))

        return island_count
5 · 复杂度

时间 O(m·n),空间 O(m·n)。每个格子最多访问一次;把网格看作图,顶点数 V=m·n,边数 E≤4·m·n(每格连 4 邻),遍历 O(V+E)=O(m·n)。空间来自 DFS 递归栈最坏 O(m·n)(全是陆地时)。

6 · 测试用例

功能[["1","1","0"],["0","1","0"],["0","0","1"]] → 2;边界:全水 [["0"]] → 0,全陆地 [["1","1"],["1","1"]] → 1,单格 [["1"]] → 1;特殊:对角相邻不算连通 [["1","0"],["0","1"]] → 2(只算上下左右)。

腐烂的橘子

LC 994 ↗图论 · Medium
1 · 题目

要完成的任务

给你一个 m × n 整数网格 grid。每分钟,所有腐烂橘子会同时使上下左右相邻的新鲜橘子腐烂。返回网格中不再有新鲜橘子所需的最少分钟数;若永远无法全部腐烂,返回 -1

格子含义与时间规则

0 表示空格,1 表示新鲜橘子,2 表示腐烂橘子。一次传播只能跨一条水平或竖直边;同一分钟新腐烂的橘子要到下一分钟才能继续向外传播。

示例 1

输入:grid=[[2,1,1],[1,1,0],[0,1,1]]

输出:4

腐烂橘子示例网格三乘三网格,左上角是腐烂橘子,六个新鲜橘子沿四方向分四分钟依次腐烂,两个格子为空。2111100112=腐烂 1=新鲜 0=空
示例 1 初始网格;传播只沿上下左右发生

腐烂层按距离逐分钟向外扩展,最远的新鲜橘子在第 4 分钟腐烂。

示例 2

输入:grid=[[2,1,1],[0,1,1],[1,0,1]]

输出:-1

左下角橘子被空格隔断,任何腐烂传播都到不了它。

示例 3

输入:grid=[[0,2]]

输出:0

第 0 分钟就没有新鲜橘子,无需等待。

约束与保证

  • m == grid.lengthn == grid[i].length
  • 1 ≤ m,n ≤ 10
  • grid[i][j] 只能是 012
2 · 思路(暴力→最优)

暴力:每分钟重新扫描整个网格,把能被感染的新鲜橘子改为腐烂;最多扩散 m·n 分钟,最坏 O((m·n)²)。

正解 · 多源分层 BFS:
① 核心规律:所有初始腐烂橘子共同作为第 0 层;BFS 第 k 层表示最早在第 k 分钟被感染的位置,因此第一次感染就是最短时间。
② 样例落地:示例从 (0,0) 出发,第 1 分钟感染两格,第 2 分钟再感染两格,第 3、4 分钟各推进一格,fresh 在第 4 分钟降为 0。
③ 分层与终止:每轮冻结当前队列长度,只处理本分钟已有腐烂源;若队列耗尽后 fresh 仍大于 0,说明存在不可达新鲜橘子,返回 -1。整体时间、空间均为 O(m·n)。

⚠ 易错坑 所有初始腐烂橘子必须同时入队,不能逐个独立扩散。
当前层长度要在循环开始时冻结,避免下一层混入本分钟。
初始 fresh=0 时答案应为 0。
3 · 图解
grid(2=腐烂,1=新鲜,0=空),所有初始腐烂橘子同时作为第 0 层:

分钟 0                 分钟 1                 分钟 2
2 1 1                  2 2 1                  2 2 2
1 1 0   queue=[(0,0)]      2 1 0   queue=[(1,0),      2 2 0   queue=[(1,1),
0 1 1                  0 1 1      (0,1)]       0 1 1      (0,2)]
fresh_count: 6               fresh_count: 4               fresh_count: 2

分钟 3                 分钟 4(稳定)
2 2 2                  2 2 2
2 2 0   queue=[(2,1)]      2 2 0   queue=[(2,2)]
0 2 1                  0 2 2
fresh_count: 1               fresh_count: 0  → 答案 4

每轮先取 current_level_size=len(queue),只处理这些节点,因此“一层”精确对应“一分钟”。
多源 BFS:按分钟展示队列层与感染后的稳定网格
分钟本层腐烂源新感染fresh_count下一层队列
0扫描到 (0,0)6[(0,0)]
1(0,0)(1,0)、(0,1)4[(1,0),(0,1)]
2(1,0)、(0,1)(1,1)、(0,2)2[(1,1),(0,2)]
3(1,1)、(0,2)(2,1)1[(2,1)]
4(2,1)(2,2)0[(2,2)]
结束fresh_count 已清零无需再扩散0返回 4
4 · 代码
from collections import deque

class Solution:
    def orangesRotting(self, grid: list[list[int]]) -> int:
        row_count = len(grid)
        col_count = len(grid[0])
        queue = deque()
        fresh_count = 0

        # 1. 所有初始腐烂橘子共同组成第 0 层
        for row in range(row_count):
            for col in range(col_count):
                if grid[row][col] == 2:
                    queue.append((row, col))
                elif grid[row][col] == 1:
                    fresh_count += 1

        minutes = 0
        directions = ((1, 0), (-1, 0), (0, 1), (0, -1))

        # 2. 每轮只处理当前层,新增腐烂橘子留到下一分钟
        while queue and fresh_count > 0:
            minutes += 1
            current_level_size = len(queue)

            for _ in range(current_level_size):
                row, col = queue.popleft()

                for row_step, col_step in directions:
                    next_row = row + row_step
                    next_col = col + col_step

                    if (0 <= next_row < row_count
                            and 0 <= next_col < col_count
                            and grid[next_row][next_col] == 1):
                        grid[next_row][next_col] = 2
                        fresh_count -= 1
                        queue.append((next_row, next_col))

        # 3. 队列耗尽后仍有新鲜橘子,说明存在不可达区域
        return -1 if fresh_count > 0 else minutes
5 · 复杂度

时间 O(m·n),空间 O(m·n)。每个格子最多入队出队一次,V=m·n,E≤4·m·n,BFS 为 O(V+E)=O(m·n)。空间为队列最坏容纳全部格子 O(m·n)。

6 · 测试用例

功能[[2,1,1],[1,1,0],[0,1,1]] → 4;边界:没有新鲜橘子 [[0,2]] → 0,单个新鲜且无腐烂 [[1]] → -1;特殊:新鲜橘子被隔离 [[2,1,1],[0,1,1],[1,0,1]] → -1(左下角 (2,0) 烂不到)。易漏:初始就无新鲜必须返回 0 而非 minutes 累加。

课程表

LC 207 ↗图论 · Medium
1 · 题目

要完成的任务

本学期共有 numCourses 门课程,编号为 0numCourses-1。给定全部先修关系 prerequisites,判断能否完成所有课程;可以则返回 true,否则返回 false

先修关系表示什么

prerequisites[i]=[a,b] 表示学习课程 a 之前必须先学课程 b,也就是有向边 b → a。问题只要求判断可行性,不要求返回具体选课顺序。

示例 1

输入:numCourses=2, prerequisites=[[1,0]]

输出:true

先学课程 0,再学课程 1 即可完成。

示例 2

输入:numCourses=2, prerequisites=[[1,0],[0,1]]

输出:false

课程 0 和课程 1 互为先修,形成有向环,谁都无法先开始。

约束与保证

  • 1 ≤ numCourses ≤ 2000
  • 0 ≤ prerequisites.length ≤ 5000,每项长度为 2。
  • 0 ≤ a,b < numCourses
  • 所有先修课程对互不相同。
2 · 思路(暴力→最优)

暴力:尝试所有课程排列,再逐个验证先修关系,复杂度接近 O(V!·E),无法处理较多课程。建边 prerequisite→course 后,问题等价于判断有向图是否无环。

两种线性判环:
① Kahn 规律:入度为 0 的课程可立即学习;学完后删除它的出边,后继入度降为 0 再入队。若最终学完 V 门则无环,否则剩余节点被环卡住。
② Kahn 样例:示例初始队列 [0];学 0 后同时解锁 1、2,依次学完它们后解锁 3,最终 learned=4。
③ DFS 规律:白点未访问、灰点仍在当前递归路径、黑点已经验证安全;遇到灰色后继就是回边。示例 DAG 最终全黑,而反例 0→1→2→0 在 2 处撞到灰色 0。
④ 优化取舍:两者时间 O(V+E)、图存储 O(V+E)。Kahn 可直接得到拓扑序且无递归风险;DFS 状态紧凑,但递归栈最坏 O(V)。

⚠ 易错坑 [course, prerequisite] 应建边 prerequisite→course
Kahn 要比较 learned 与 V,队列为空不等于成功。
只有撞到灰点才成环,黑点是已完成的安全子图。
3 · 图解
numCourses=4,prerequisites=[[1,0],[2,0],[3,1],[3,2]]
边 b→a 表示“先学 b,才能学 a”:

        0
       / \
      1   2
       \ /
        3

解法一 · Kahn 拓扑排序(队列只放入度为 0 的课)
初始: in_degree=[0,1,1,2], available=[0], completed=0
学 0: in_degree=[0,0,0,2], available=[1,2], completed=1
学 1: in_degree=[0,0,0,1], available=[2],   completed=2
学 2: in_degree=[0,0,0,0], available=[3],   completed=3
学 3: in_degree=[0,0,0,0], available=[],    completed=4 → 全部可学

解法二 · DFS 三色标记(0=白,1=灰,2=黑)
进入 0: [灰,白,白,白]
0→1→3 完成: [灰,黑,白,黑]
0→2,看到 3 已黑: [灰,黑,灰,黑] → [灰,黑,黑,黑]
退出 0: [黑,黑,黑,黑] → 无环

若出现 0→1→2→0:
0 灰 → 1 灰 → 2 灰 → 再看到灰色 0,命中当前递归路径,立即判环。
解法一 · Kahn:每学完一课,集中更新它的所有后继
阶段弹出课程in_degreeavailablecompleted
初始化[0,1,1,2][0]0
学习 00[0,0,0,2][1,2]1
学习 11[0,0,0,1][2]2
学习 22[0,0,0,0][3]3
学习 33[0,0,0,0][]4
验收completed == numCourses[]返回 true
解法二 · DFS 三色:灰色节点代表“仍在当前递归路径中”
阶段当前边/节点state 快照 [0,1,2,3]判断
进入根0[灰,白,白,白]继续深入
完成分支0→1→3[灰,黑,白,黑]分支无环
进入另一分支0→2[灰,黑,灰,黑]3 已黑,无需重搜
退出根0[黑,黑,黑,黑]示例无环
反例进入0→1→2[灰,灰,灰]三点都在当前路径
发现回边2→00 仍为灰色立即返回 false
4 · 代码
from collections import deque

class Solution:
    def canFinish(self, numCourses: int, prerequisites: list[list[int]]) -> bool:
        adjacency = [[] for _ in range(numCourses)]
        in_degree = [0] * numCourses

        # 1. 建立“先修课 → 后继课”的有向图与入度
        for course, prerequisite in prerequisites:
            adjacency[prerequisite].append(course)
            in_degree[course] += 1

        available = deque(
            course for course in range(numCourses)
            if in_degree[course] == 0
        )
        completed = 0

        # 2. 每学完一门课,就删除它对所有后继的依赖
        while available:
            course = available.popleft()
            completed += 1

            for next_course in adjacency[course]:
                in_degree[next_course] -= 1
                if in_degree[next_course] == 0:
                    available.append(next_course)

        # 3. 环中的课程永远不会进入零入度队列
        return completed == numCourses
# 解法二:DFS 三色判环,0=白、1=灰、2=黑
class Solution:
    def canFinish(self, numCourses: int, prerequisites: list[list[int]]) -> bool:
        adjacency = [[] for _ in range(numCourses)]
        for course, prerequisite in prerequisites:
            adjacency[prerequisite].append(course)

        state = [0] * numCourses

        def has_no_cycle(course):
            # 1. 当前课程进入递归路径,先染成灰色
            state[course] = 1

            for next_course in adjacency[course]:
                # 2. 再遇灰点说明当前路径形成回边
                if state[next_course] == 1:
                    return False
                if (state[next_course] == 0
                        and not has_no_cycle(next_course)):
                    return False

            # 3. 所有后继都安全返回后染黑,后续可直接复用
            state[course] = 2
            return True

        # 4. 图可能不连通,需要从每个白点启动检查
        for course in range(numCourses):
            if state[course] == 0 and not has_no_cycle(course):
                return False

        return True
5 · 复杂度

时间 两种解法均为 O(V+E),空间 O(V+E)。V=numCourses,E=len(prerequisites):Kahn 中每个节点至多入队一次、每条边删除一次;DFS 中每个节点染色一次、每条边检查一次。邻接表占 O(V+E),其余入度/状态/队列或递归栈占 O(V)。

6 · 测试用例

功能2, [[1,0]] → True(先 0 后 1);边界:无先修 3, [] → True,单课 1, [] → True;特殊:直接成环 2, [[1,0],[0,1]] → False,自环 1, [[0,0]] → False(0 依赖自己)。

实现 Trie(前缀树)

LC 208 ↗图论 · Medium
1 · 题目

要完成的任务

实现前缀树类 Trie,支持初始化、插入单词、查询完整单词以及查询前缀四类操作。

四个接口的含义

Trie() 创建空前缀树;insert(word) 插入 wordsearch(word) 只在完整单词此前已插入时返回 truestartsWith(prefix) 只要存在已插入单词以 prefix 开头就返回 true。因此“路径存在”和“单词结束”是两种不同状态。

官方示例

输入操作:["Trie","insert","search","search","startsWith","insert","search"]
输入参数:[[],["apple"],["apple"],["app"],["app"],["app"],["app"]]

输出:[null,null,true,false,true,null,true]

插入 "apple" 后,完整查询 "apple" 为真;"app" 当时只是前缀,所以 search 为假而 startsWith 为真。再插入 "app" 后,完整查询才变为真。null 表示构造或写操作没有返回值。

约束与保证

  • 1 ≤ word.length,prefix.length ≤ 2000
  • wordprefix 只含小写英文字母。
  • insertsearchstartsWith 的调用总数不超过 3 × 10⁴
2 · 思路(暴力→最优)

暴力:集合可让完整单词查询接近 O(1),但前缀查询仍要扫描所有 N 个单词,最坏 O(N·L)。

正解 · Trie:
① 核心规律:公共前缀共享同一条字符路径;节点的 children 决定下一字符,is_end 只表示这里是否为完整单词结尾。
② 样例落地:插入 app 后末节点标星;插入 apple 复用整条 app 路径,只新增 l、e;插入 bat 才从根创建另一分支。
③ 查询差异:search("ap") 虽能走完路径,但终点没有 is_end,所以为 False;startsWith("ap") 只要求路径存在,返回 True。
④ 优化取舍:插入、完整查询、前缀查询都只走输入长度 L,时间 O(L);代价是每个不同字符边都可能创建节点,空间 O(字符总数)。

⚠ 易错坑 search 必须检查终点 is_end,前缀查询不用。
路径中任一字符不存在就立即失败。
给已有前缀补 is_end 不能覆盖其后续子树。
3 · 图解
依次 insert("app")、insert("apple")、insert("bat"):

里程碑0|空树
(root)

里程碑1|insert("app") 稳定
root
  └─a
     └─p
        └─p*
* 表示 is_end=True。

里程碑2|insert("apple") 复用 app,只新增 le
root
  └─a
     └─p
        └─p*
           └─l
              └─e*

里程碑3|insert("bat") 新建根下另一分支
root
  ├─a─p─p*─l─e*
  └─b─a─t*

里程碑4|完整单词查询
search("apple"):root→a→p→p*→l→e*,终点有 *,返回 True。
search("ap"):root→a→p,路径存在但终点没有 *,返回 False。

里程碑5|前缀查询
startsWith("ap"):root→a→p,路径存在即可,不要求终点有 *,返回 True。
insert("app"):每个字符的一行同时完成“必要时建点 + 向下移动”
阶段字符路径节点变化
开始rootnode 指向根
字符 1aroot→a创建 a 并移动
字符 2proot→a→p创建 p 并移动
字符 3proot→a→p→p创建 p 并移动
结束app末节点 is_end=True
search("apple"):逐字符沿 children 下行
阶段字符到达路径结果
开始root继续
字符 1aa存在
字符 2pap存在
字符 3papp*存在,继续匹配
字符 4lappl存在
字符 5eapple*存在
终点检查apple*is_end=True
同一路径,不同终点条件:search 与 startsWith
查询最终路径路径存在终点 is_end返回
search("apple")apple*TrueTrue
search("app")app*TrueTrue
search("ap")apFalseFalse
startsWith("ap")ap不要求True
startsWith("cat")c 即中断False
4 · 代码
class Trie:
    def __init__(self):
        # 每个节点保存字符边,以及“是否在此结束一个单词”
        self.children = {}
        self.is_end = False

    def insert(self, word: str) -> None:
        node = self

        # 1. 逐字符复用已有路径,缺失时才创建新节点
        for char in word:
            if char not in node.children:
                node.children[char] = Trie()
            node = node.children[char]

        # 2. 只标记终点,不覆盖终点已有的后续子树
        node.is_end = True

    def _walk(self, text: str):
        node = self

        # 查询共用的路径扫描:任一字符边缺失就失败
        for char in text:
            if char not in node.children:
                return None
            node = node.children[char]

        return node

    def search(self, word: str) -> bool:
        node = self._walk(word)
        # 完整单词还要求终点被标为单词结尾
        return node is not None and node.is_end

    def startsWith(self, prefix: str) -> bool:
        # 前缀查询只要求整条字符路径存在
        return self._walk(prefix) is not None
5 · 复杂度

时间 insert/search/startsWith 均 O(L),L 为本次字符串长度。空间 O(C),C 为所有已插入单词去重前缀后实际创建的 Trie 节点/边总数,最坏 O(N·L);本实现的 children 是字典,不会为每个节点预分配 26 个槽位。相比逐词扫描,前缀查询降到 O(L)。

6 · 测试用例

功能:insert("apple")后 search("apple")→True、search("app")→False、startsWith("app")→True;边界:最短单字符 insert("a") 后 search("a")→True,空 Trie 上 search("x")→False;特殊:前缀是完整单词(insert("app")+insert("apple")后 search("app")→True),共享前缀不互相污染 is_end。

回溯

回溯 = 树的 DFS + 撤销选择。把所有候选解想象成一棵决策树,每个节点是"已经做了的部分选择",每条边是"下一步选什么"。统一套路:做选择 → 递归进入下一层 → 撤销选择(把刚加的从 path 里 pop 回去),让状态回到分叉前,去试别的分支。三类剪枝要素:用 start 下标控制组合不重复(78 子集、39 组合总和)、用 used 数组控制排列不重用同一元素并对相同元素去重(46 全排列含重复时)、用可行性剪枝提前砍死不可能的分支(51 N皇后的列/对角线冲突、39 的和超标)。关联其它类别:网格回溯 LC79图论 DFS是同一套(染色 + 撤销);括号生成 LC22把"合法性约束"编进决策树,是约束满足型回溯的代表。会一道,其余都是换皮。

全排列

LC 46 ↗回溯 · Medium
1 · 题目

要完成的任务

给定一个元素互不相同的整数数组 nums,返回它的所有可能全排列。排列中必须恰好使用每个输入元素一次,答案顺序不限。

示例 1

输入:nums=[1,2,3]

输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]

示例 2

输入:nums=[0,1]

输出:[[0,1],[1,0]]

示例 3

输入:nums=[1]

输出:[[1]]

约束与保证

  • 1 ≤ nums.length ≤ 6
  • -10 ≤ nums[i] ≤ 10
  • nums 中所有整数互不相同。
2 · 思路(暴力→最优)

暴力:枚举 n 位序列再筛掉重复元素,候选数 nⁿ,绝大多数不是排列。

正解 · 使用状态回溯:
① 核心规律:决策层就是排列位置;used[index] 表示该元素是否已在当前路径,只有未使用元素才能进入下一位。
② 样例落地:[1,2,3] 先走 1→2→3 收集第一个排列;退回到路径 [1] 后改选 3、2,得到 [1,3,2]
③ 撤销含义:递归返回后同时 pop 路径并清除 used,状态恢复到选择前;这样兄弟分支看到的可选集合完全相同。
④ 优化取舍:每层仍从全部下标扫描,因为顺序不同就是不同排列;相比组合题不能用 start 禁止回头。输出规模 n!,时间 O(n·n!)、额外状态 O(n)。

⚠ 易错坑 path 与 used 必须成对撤销。
收集要复制路径,不能保存同一个列表引用。
空输入有一个空排列,结果是 [[]]
3 · 图解
nums=[1,2,3],used 与 path 共同描述当前决策路径。

里程碑0|根状态
path=[],used=[F,F,F]

里程碑1|连续选择 1、2、3
[] → [1] → [1,2] → [1,2,3]
used=[T,T,T],收集 [1,2,3]

里程碑2|撤销到最近分叉,再换兄弟选择
[1,2,3] --pop 3--> [1,2] --pop 2--> [1]
used 恢复为 [T,F,F]
改选 3、2 → [1,3,2],收集第二个排列

里程碑3|完整撤销首位1
path [1,3,2] 逐层返回到 []
used=[F,F,F];首位2现在重新可选1和3

里程碑4|首位2的稳定结果
[2,1,3]、[2,3,1]
每收集一个叶子,都撤销回对应分叉再试兄弟

里程碑5|首位3与最终结果
[3,1,2]、[3,2,1]
共6个排列;根状态最终恢复为 path=[]、used全False。
排列回溯:按决策分支摘要选择、叶子收集与撤销
阶段决策范围pathused结果
1初始化[][F,F,F]进入第0位
2首位选1,再选2、3[1,2,3][T,T,T]收集123
3撤销到[1],改选3、2[1,3,2][T,T,T]收集132
4撤销首位1[][F,F,F]状态复原
5首位选2的子树[2,1,3] / [2,3,1]分支内切换新增2个
6首位选3的子树[3,1,2] / [3,2,1]分支内切换新增2个
7根循环结束[][F,F,F]共6个排列
4 · 代码
class Solution:
    def permute(self, nums: list[int]) -> list[list[int]]:
        results = []
        path = []
        used = [False] * len(nums)

        def build(position):
            # 1. 所有位置都已填写时,保存当前排列快照
            if position == len(nums):
                results.append(path[:])
                return

            # 2. 当前位置可选择任意尚未出现在路径中的元素
            for index, value in enumerate(nums):
                if used[index]:
                    continue

                used[index] = True
                path.append(value)
                build(position + 1)

                # 3. 恢复路径与使用状态,再试当前层的下一个元素
                path.pop()
                used[index] = False

        build(0)
        return results
5 · 复杂度

时间 O(n·n!),空间 O(n)。共 n! 个排列,每个长度 n 需 O(n) 拷贝进结果;递归深度和 used/path 均 O(n)(结果本身不计入额外空间)。

6 · 测试用例

功能[1,2,3] → 6 个排列;边界[1][[1]][][[]]特殊[0,-1][[0,-1],[-1,0]](负数正常)。若数组含重复需用 LC47 的排序 + 去重逻辑。

子集

LC 78 ↗回溯 · Medium
1 · 题目

要完成的任务

给定一个元素互不相同的整数数组 nums,返回它的所有子集,也就是幂集。空集和数组本身都要包含;答案不能有重复子集,返回顺序不限。

示例 1

输入:nums=[1,2,3]

输出:[[],[1],[2],[1,2],[3],[1,3],[2,3],[1,2,3]]

三个元素各有“选/不选”两种决定,因此共有 2³=8 个子集。

示例 2

输入:nums=[0]

输出:[[],[0]]

约束与保证

  • 1 ≤ nums.length ≤ 10
  • -10 ≤ nums[i] ≤ 10
  • nums 中所有元素互不相同。
2 · 思路(暴力→最优)

直觉:用二进制位枚举“选/不选”可得到 2ⁿ 个子集,但需要额外做位到元素的转换;回溯可直接维护当前子集。

两种等价决策树:
① start 枚举规律:每次只从当前下标之后继续选,天然避免 [1,2][2,1] 重复;每个递归节点本身就是合法子集,因此进入函数立即收集。
② start 样例:[1,2,3] 从空集出发,沿 [1]→[1,2]→[1,2,3] 收集;撤销到 [1] 后再选 3 得到 [1,3]
③ 二叉规律:对每个下标依次走“不选”和“选”两支,只有处理完全部下标时收集;2ⁿ 个叶子与所有子集一一对应。
④ 优化取舍:start 法更短、每个节点收集;二叉法更贴近独立二选一。两者时间 O(n·2ⁿ)、递归和路径空间 O(n)。

⚠ 易错坑 start 法递归参数是刚选下标的 index+1
每次收集都要复制 path。
二叉法的 append/pop 只包围“选”分支。
3 · 图解
nums=[1,2,3];start 法在每个节点收集当前 path。

里程碑0|进入根
path=[],立即收集 []

里程碑1|沿首个分支深入
[1] → [1,2] → [1,2,3]
三个节点分别收集三个子集

里程碑2|撤销到 [1] 并换分支
[1,2,3] --pop3--> [1,2] --pop2--> [1]
再选3 → [1,3],收集后撤销回 [1]

里程碑3|撤销首位1,根改选2
path=[] → [2] → [2,3]
收集 [2]、[2,3]

里程碑4|根改选3
path=[3],收集 [3],随后 pop 恢复 []

里程碑5|最终8个子集
[], [1], [1,2], [1,2,3], [1,3], [2], [2,3], [3]
start 始终只向后推进,因此不会出现 [2,1]。
解法① start 枚举:按稳定路径节点与兄弟分支摘要
阶段选择/撤销startpath收集
1进入根0[][]
2选11[1][1]
3沿1→2→3深入3[1,2,3][1,2]、[1,2,3]
4撤销到[1],改选33[1,3][1,3]
5撤销首位1,改选22→3[2]→[2,3][2]、[2,3]
6根改选3再撤销3[3]→[][3]
7根循环结束[]共8个
解法② 二叉决策:每层先“不选”再“选”,叶子收集
阶段下标层决策摘要path产出
1index=0不选1[]进入右侧决策层
2index=1,2枚举2、3的选/不选[]/[2]/[3]/[2,3]4个叶子
3回到index=0选择1[1]进入另一半
4index=1,2再次枚举2、3[1]/[1,2]/[1,3]/[1,2,3]4个叶子
5逐层返回每个选分支都pop[]状态复原
6根调用结束2³个叶子[]共8个
4 · 代码
class Solution:
    def subsets(self, nums: list[int]) -> list[list[int]]:
        results = []
        path = []

        def build(start_index):
            # 1. 每个递归节点本身就是一个合法子集
            results.append(path[:])

            # 2. 只从当前位置之后选择,避免顺序重复
            for index in range(start_index, len(nums)):
                path.append(nums[index])
                build(index + 1)

                # 3. 撤销本次元素,让兄弟分支复用干净状态
                path.pop()

        build(0)
        return results
# 解法二:每个下标分别走“不选”和“选”两条分支
class Solution:
    def subsets(self, nums: list[int]) -> list[list[int]]:
        results = []
        path = []

        def decide(index):
            # 1. 全部下标都已决策时,保存一个叶子方案
            if index == len(nums):
                results.append(path[:])
                return

            # 2. 不选择当前元素
            decide(index + 1)

            # 3. 选择当前元素,返回后撤销这项选择
            path.append(nums[index])
            decide(index + 1)
            path.pop()

        decide(0)
        return results
5 · 复杂度

时间 O(n·2^n),空间 O(n)。共 2^n 个子集,每个平均长度 O(n) 用于拷贝;递归深度和 path 为 O(n)。

6 · 测试用例

功能[1,2,3] → 8 个子集;边界[][[]][0][[],[0]]特殊:确认含空集且含全集;含重复元素需用 LC90 排序 + i>start and nums[i]==nums[i-1] 去重。

电话号码的字母组合

LC 17 ↗回溯 · Medium
1 · 题目

要完成的任务

给定一个只含数字 29 的字符串 digits,按电话按键上的数字到字母映射,返回它能表示的所有字母组合,顺序不限。

按键映射

2→abc3→def4→ghi5→jkl6→mno7→pqrs8→tuv9→wxyz;数字 1 没有对应字母。

电话键盘数字与字母映射三列四行电话键盘,数字二到九分别对应 abc、def、ghi、jkl、mno、pqrs、tuv、wxyz。12 abc3 def4 ghi5 jkl6 mno7 pqrs8 tuv9 wxyz*0#
每一位数字独立选择该按键上的一个字母

示例 1

输入:digits="23"

输出:["ad","ae","af","bd","be","bf","cd","ce","cf"]

示例 2

输入:digits="2"

输出:["a","b","c"]

约束与保证

  • 1 ≤ digits.length ≤ 4
  • 每个 digits[i] 都在 '2''9' 之间。
2 · 思路(暴力→最优)

暴力/局限:固定层数的嵌套循环只能处理固定长度 digits;输入长度变化时循环层数也要变化。

正解 · 笛卡尔积回溯:
① 核心规律:递归深度对应数字下标,每层候选是该数字映射的字母;进入下一层就是处理下一个数字。
② 样例落地:"23" 先选 a,再在 d/e/f 中得到 ad、ae、af;撤销 a 后改选 b、c,重复同一层规则得到 9 个组合。
③ 撤销边界:每次字母递归返回后 pop,恢复到上一个数字层;当 index 等于 digits 长度时,路径长度恰好完整,拼接收集。
④ 优化取舍:每条输出必须生成,若共有 M 个组合、输入长度 d,时间 O(M·d),路径和递归空间 O(d)。不同层候选集合不同,不需要 used 或 start。

⚠ 易错坑 空 digits 应返回 [],不是 [""]
数字 7、9 各映射四个字母。
append 后必须在兄弟分支前 pop。
3 · 图解
digits="23",映射 2→abc,3→def。

里程碑0|第0层
index=0,path=[],候选={a,b,c}

里程碑1|首字母选a,进入第1层
path=[a],候选={d,e,f}
依次收集 ad、ae、af;每次叶子返回都撤销第二个字母

里程碑2|撤销首字母a
path=[a] --pop--> []
第0层恢复,可选择b

里程碑3|首字母选b
path=[b],第二层重复同一规则
收集 bd、be、bf,再撤销回根

里程碑4|首字母选c
收集 cd、ce、cf

里程碑5|完成
结果共3×3=9个,path 最终恢复为空。
笛卡尔积回溯:按首字母分支摘要深入与撤销
阶段数字层选择范围path产出
1index=0 / 2{a,b,c}[]进入首层
2选a,index=1 / 3{d,e,f}[a]ad、ae、af
3撤销a回到数字2[]状态复原
4选b,index=1 / 3{d,e,f}[b]bd、be、bf
5选c,index=1 / 3{d,e,f}[c]cd、ce、cf
6根调用结束所有首字母完成[]共9个
4 · 代码
class Solution:
    def letterCombinations(self, digits: str) -> list[str]:
        # 1. 空输入没有任何字母组合
        if not digits:
            return []

        digit_to_letters = {
            "2": "abc", "3": "def", "4": "ghi", "5": "jkl",
            "6": "mno", "7": "pqrs", "8": "tuv", "9": "wxyz"
        }
        results = []
        path = []

        def build(digit_index):
            # 2. 每个数字都已选择字母时,拼接完整结果
            if digit_index == len(digits):
                results.append("".join(path))
                return

            # 3. 当前层只枚举当前数字对应的字母集合
            for letter in digit_to_letters[digits[digit_index]]:
                path.append(letter)
                build(digit_index + 1)

                # 4. 撤销当前字母,继续试该数字的兄弟候选
                path.pop()

        build(0)
        return results
5 · 复杂度

时间 O(4^n·n),空间 O(n)。n=len(digits),每个数字最多 4 个字母,共最多 4^n 个组合,每个拼接 O(n);递归深度 O(n)。

6 · 测试用例

功能"23" → 9 个(ad..cf);边界""[]"2"["a","b","c"]特殊:含 7 或 9(4 个字母)"79" → 16 个,验证映射表正确。

组合总和

LC 39 ↗回溯 · Medium
1 · 题目

要完成的任务

给定无重复正整数数组 candidates 和目标值 target,返回所有元素和等于目标值的不同组合。每个候选数可以使用任意多次,组合内部及答案顺序不限。

什么算不同组合

只要至少有一个数字的使用次数不同,两种组合就不同;同一批数字仅排列顺序不同不应重复返回。

示例 1

输入:candidates=[2,3,6,7], target=7

输出:[[2,2,3],[7]]

2+2+3=7,单独使用 7 也能命中,除此之外没有组合。

示例 2

输入:candidates=[2,3,5], target=8

输出:[[2,2,2,2],[2,3,3],[3,5]]

示例 3

输入:candidates=[2], target=1

输出:[]

约束与保证

  • 1 ≤ candidates.length ≤ 30
  • 2 ≤ candidates[i] ≤ 40,且所有元素互不相同。
  • 1 ≤ target ≤ 40
  • 不同有效组合的数量保证少于 150 个。
2 · 思路(暴力→最优)

暴力:盲目枚举不同长度序列再检查和,既产生顺序重复,也会在和已超过 target 后继续搜索。

正解 · start + 剩余量:
① 核心规律:排序后只从 start 向后选,组合顺序固定;递归仍传当前 index,允许同一候选重复使用。
② 样例落地:[2,3,6,7], target=7 先走 2、2,剩余 3 时再选 3 得到 [2,2,3];撤销回根后直接选 7 得到第二个解。
③ 剪枝依据:若当前候选大于 remaining,排序保证后续候选更大,可直接 break;remaining 为 0 时收集并结束本枝。
④ 优化取舍:回溯复杂度取决于 target 与候选,常写作 O(n^(T/m)) 上界;排序成本 O(n log n),路径深度最多 T/m,其中 m 为最小候选。

⚠ 易错坑 递归传当前 index 才允许重复选自己。
break 剪枝依赖 candidates 已排序。
收集后立即返回,避免在 remaining=0 上继续扩展。
3 · 图解
candidates=[2,3,6,7],target=7,已排序。

里程碑0|根
path=[],remaining=7,start=0

里程碑1|重复选择2
[2] remaining=5
[2,2] remaining=3
当前 index 仍可从2开始,因此允许再次选2

里程碑2|剪掉过大的继续选择
[2,2,2] remaining=1
下一候选2>1,break;撤销最后一个2,恢复 [2,2], remaining=3

里程碑3|兄弟分支改选3
[2,2,3] remaining=0
收集第一个解;逐层撤销回根

里程碑4|根的3、6分支
选3后继续选3得到remaining=1,后续候选都过大并剪枝
选6后remaining=1,同样剪枝

里程碑5|根直接选7
[7] remaining=0,收集第二个解
最终结果 [[2,2,3],[7]],path 恢复为空。
组合总和:按 remaining 决策层概括重复选择、剪枝与撤销
阶段决策pathremaining结果
1初始化并排序[]7start=0
2连续选择2[2,2]3允许重复自己
3再选2后遇到候选过大[2,2,2]1break并撤销
4回到[2,2]改选3[2,2,3]0收集
5逐层撤销回根[]7试后续首项
6首项3、6分支[3,3] / [6]1候选过大,剪枝
7首项选择7[7]0收集[7]
8根循环结束[]7返回2个解
4 · 代码
class Solution:
    def combinationSum(
        self,
        candidates: list[int],
        target: int
    ) -> list[list[int]]:
        # 1. 排序后,候选过大时可剪掉整段后续分支
        sorted_candidates = sorted(candidates)
        results = []
        path = []

        def build(start_index, remaining):
            # 2. 剩余量恰好归零,保存当前组合并停止本枝
            if remaining == 0:
                results.append(path[:])
                return

            # 3. 只向后选择;递归仍传 index 以允许重复使用
            for index in range(start_index, len(sorted_candidates)):
                value = sorted_candidates[index]
                if value > remaining:
                    break

                path.append(value)
                build(index, remaining - value)

                # 4. 撤销当前候选,恢复 remaining 对应的路径状态
                path.pop()

        build(0, target)
        return results
5 · 复杂度

设 N 为候选数、T 为 target、M 为最小候选值、D=⌊T/M⌋。时间上界 O(N log N + D·N^D):先排序,再搜索最深 D 层,收集答案时还要复制至多 D 个元素。额外空间 O(N+D):排序副本为 O(N),路径与递归栈为 O(D)(不计结果)。

6 · 测试用例

功能[2,3,6,7], 7[[2,2,3],[7]]边界:无解 [2], 1[],target 恰为单个候选 [5], 5[[5]]特殊:单候选可多次凑 [2], 6[[2,2,2]]。若候选含重复用 LC40(start 传 i+1 + 同层去重)。

括号生成

LC 22 ↗回溯 · Medium
1 · 题目

要完成的任务

给定括号对数 n,返回所有由 n 个左括号和 n 个右括号组成、并且正确闭合的字符串,答案顺序不限。

示例 1

输入:n=3

输出:["((()))","(()())","(())()","()(())","()()()"]

示例 2

输入:n=1

输出:["()"]

约束与保证

  • 1 ≤ n ≤ 8
  • 每个结果长度固定为 2n,任意前缀中右括号数都不能超过左括号数。
2 · 思路(暴力→最优)

暴力:生成全部 2^(2n) 个括号字符串再验证,绝大多数前缀早已非法却仍会继续生成。

正解 · 约束型回溯:
① 核心规律:只在 open_count<n 时放左括号,只在 close_count<open_count 时放右括号;后者保证每个前缀都不会右括号过多。
② 样例落地:n=3 时先连续放三个左括号,再逐个闭合得到 ((()));撤销到 (( 后先闭合一对,再继续选择,得到 (()())
③ 剪枝含义:状态 open=close 时不能放右括号,例如空前缀绝不会扩展出 ) 起手的非法字符串。两个放置条件是并列分支。
④ 优化取舍:算法只生成 Catalan 数 Cₙ 个合法答案,构造输出时间 O(Cₙ·n),路径与递归空间 O(n)。

⚠ 易错坑 右括号条件必须严格为 close_count<open_count
两个 if 不能写成 elif。
两个分支各自 append 后都要 pop。
3 · 图解
n=3,状态只保留 path、open_count、close_count。

里程碑0|根
path="",open=0,close=0
不能放右括号,因为 close==open

里程碑1|先连续放满左括号
"(" → "((" → "((("
open=3,close=0;左括号达到上限

里程碑2|逐个闭合
"(((" → "((()" → "((())" → "((()))"
open=3,close=3,收集第一个答案

里程碑3|撤销到 "((" 后改走兄弟分支
从第三个左括号分支返回,状态恢复为 "(("
先放 ")",再放 "(",最终得到 "(()())"

里程碑4|继续撤销与换枝
同一前缀规则产生 "(())()"、"()(())"、"()()()"
任何 open==close 的状态都禁止再放 ")"

里程碑5|完成
共5个合法串;每次递归返回都pop,path 最终恢复为空。
约束型回溯:按前缀决策层摘要合法扩展与撤销
阶段前缀决策open/closepath结果
1初始化0 / 0""右分支被禁止
2连续放三个左括号3 / 0"((("左分支到上限
3逐个闭合3 / 3"((()))"收集
4撤销到"(("并换枝2 / 0"(("生成"(()())"
5继续处理"(("子树分支内变化"(())()"新增1个
6根的"()"子树前缀始终close≤open"()(())" / "()()()"新增2个
7根调用结束0 / 0""共5个
4 · 代码
class Solution:
    def generateParenthesis(self, n: int) -> list[str]:
        results = []
        path = []

        def build(open_count, close_count):
            # 1. 长度达到 2n 时,两个计数必然都等于 n
            if len(path) == 2 * n:
                results.append("".join(path))
                return

            # 2. 左括号尚未用满时,可以开启一个新配对
            if open_count < n:
                path.append("(")
                build(open_count + 1, close_count)
                path.pop()

            # 3. 仅当前缀中左括号更多时,才能合法闭合
            if close_count < open_count:
                path.append(")")
                build(open_count, close_count + 1)
                path.pop()

        build(0, 0)
        return results
5 · 复杂度

时间 O(4^n / √n),空间 O(n)。合法括号组合数为第 n 个卡塔兰数 Cₙ ≈ 4^n/(n^1.5),每个串构造 O(n);递归深度 2n=O(n)。

6 · 测试用例

功能n=3 → 5 个;边界n=1["()"]n=0[""]特殊:验证不出现 )( 这类非法串,验证个数等于卡塔兰数(n=4 → 14 个)。

单词搜索

LC 79 ↗回溯 · Medium
1 · 题目

要完成的任务

给定 m × n 字符网格 board 和字符串 word。若能从某个格子出发,按字符顺序沿水平或竖直相邻格拼出整个单词,返回 true;否则返回 false

路径限制

路径每一步只能走到上下左右相邻格,同一个格子在一条搜索路径中不能重复使用;大小写字母视为不同字符。

示例 1

输入:board=[["A","B","C","E"],["S","F","C","S"],["A","D","E","E"]], word="ABCCED"

输出:true

ABCCED 路径从左上角 A 出发,依次经过 B、C、下一行的 C、底行 E、D。ABCESFCSADEEABCCED 路径
从左上角 A 出发,依次经过 B、C、下一行的 C、底行 E、D。

示例 2

输入:board=[["A","B","C","E"],["S","F","C","S"],["A","D","E","E"]], word="SEE"

输出:true

SEE 路径从右侧第二行 S 向下到 E,再向左到另一个 E。ABCESFCSADEESEE 路径
从右侧第二行 S 向下到 E,再向左到另一个 E。

示例 3

输入:board=[["A","B","C","E"],["S","F","C","S"],["A","D","E","E"]], word="ABCB"

输出:false

ABCB 无合法路径A、B、C 可以走到,但下一个 B 只能回到已经使用过的格子,违反不可复用限制。ABCESFCSADEEABCB 无合法路径
A、B、C 可以走到,但下一个 B 只能回到已经使用过的格子,违反不可复用限制。

约束与保证

  • m == board.lengthn == board[i].length,且 1 ≤ m,n ≤ 6
  • 1 ≤ word.length ≤ 15
  • boardword 只含大小写英文字母。
2 · 思路(暴力→最优)

暴力:为 word 的每个字符在全网格独立找位置,无法保证相邻,也无法阻止同一格在一条路径中重复使用。

正解 · 网格 DFS 回溯:
① 核心规律:search(row,col,index) 只尝试用当前格匹配 word[index];匹配后临时标记占用,再向四邻搜索下一字符。
② 样例落地:标准网格中查 ABCCED,从左上 A 出发,沿右、右、下、左、左依次匹配,路径转弯两次并最终完成。
③ 撤销必要性:无论本起点成功或失败,返回前都恢复原字符;当前路径不能重用该格,但兄弟方向和其他起点仍可使用它。
④ 剪枝取舍:越界或字符不匹配立即失败;首步最多4个方向,之后来路已占用、每层最多3个新方向,时间 O(m·n·3^L),递归空间 O(L)。

⚠ 易错坑 标记发生在字符匹配后、递归四邻前。
返回前恢复保存的原字符,不能写死恢复值。
所有格子都可能是起点,必须双层扫描。
3 · 图解
board 与 word="ABCCED"
A B C E
S F C S
A D E E

里程碑0|扫描起点
从 (0,0) 的 A 启动;其他不匹配起点会立即失败

里程碑1|沿第一行匹配 A→B→C,已占用格临时标 #
# # # E
S F C S
A D E E
下一字符仍是 C,不能重用刚走过的格

里程碑2|向下进入 (1,2) 匹配第二个 C
# # # E
S F # S
A D E E

里程碑3|转向下一行匹配 E,再向左匹配 D
# # # E
S F # S
A # # E
六个字符全部匹配,下一层 index==6 返回 True

里程碑4|成功信号逐层返回,同时每层恢复自己的字符
先恢复 D、E、第二个C,再恢复第一行的C、B、A

里程碑5|函数返回前棋盘已完全复原
A B C E
S F C S
A D E E
因此其他起点或后续调用仍可安全使用原棋盘。
单词搜索:按匹配路径、方向选择与回溯恢复摘要
阶段匹配位置word进度占用路径结果
1(0,0)A / 1[(0,0)]向四邻继续
2(0,1)→(0,2)AB→ABC第一行前三格路径有效
3(1,2)ABCC加入第二个C向下一行
4(2,2)→(2,1)ABCCE→ABCCED完整6格字符全部匹配
5index==6完整word成功路径True
6逐层返回成功信号短路每格恢复原字符棋盘复原
7起点循环结束已找到原棋盘返回True
4 · 代码
class Solution:
    def exist(self, board: list[list[str]], word: str) -> bool:
        if not board or not board[0]:
            return False

        row_count = len(board)
        col_count = len(board[0])

        def search(row, col, word_index):
            # 1. 所有字符均已匹配,当前路径成功
            if word_index == len(word):
                return True

            # 2. 越界、字符不符或已占用格都会立即剪枝
            if (row < 0 or row >= row_count
                    or col < 0 or col >= col_count
                    or board[row][col] != word[word_index]):
                return False

            original_char = board[row][col]
            board[row][col] = "#"

            # 3. 从当前格向四邻寻找下一个字符,任一成功即可短路
            found = (
                search(row + 1, col, word_index + 1)
                or search(row - 1, col, word_index + 1)
                or search(row, col + 1, word_index + 1)
                or search(row, col - 1, word_index + 1)
            )

            # 4. 无论成功失败都恢复原字符,供其他路径继续使用
            board[row][col] = original_char
            return found

        # 5. 任意格都可能是单词起点
        for row in range(row_count):
            for col in range(col_count):
                if search(row, col, 0):
                    return True

        return False
5 · 复杂度

时间 O(m·n·3^L),空间 O(L)。L=len(word);每个起点首次可尝试 4 个方向,之后来路格已被标记、每层至多剩 3 个可继续扩展的方向,常数 4 被渐进复杂度吸收;递归深度 O(L),原地标记省去 visited。

6 · 测试用例

功能[["A","B","C","E"],["S","F","C","S"],["A","D","E","E"]], "ABCCED" → True;边界:单字符 [["A"]], "A" → True,word 长于格子数 → False;特殊:需重用格子才能拼出的(如 [["A","A"]], "AAA")→ False(不能重复用)。

分割回文串

LC 131 ↗回溯 · Medium
1 · 题目

要完成的任务

给定字符串 s,把它切分成若干连续子串,使每一段都是回文串;返回所有可能的完整切分方案。回文串正读和反读相同。

示例 1

输入:s="aab"

输出:[["a","a","b"],["aa","b"]]

既可以逐字符切,也可以把前两个 a 合成回文 "aa"

示例 2

输入:s="a"

输出:[["a"]]

约束与保证

  • 1 ≤ s.length ≤ 16
  • s 只含小写英文字母。
2 · 思路(暴力→最优)

暴力:枚举 n-1 个间隙的切或不切,再对完整方案中的每段统一验回文;很多方案早在第一段就非法,却仍继续切到底。

正解 · 切口回溯:
① 核心规律:从 start 枚举当前段右端 end;只有 s[start:end+1] 是回文时,才把该段加入 path 并从 end+1 继续切。
② 样例落地:"aab" 先切 a|a|b 得到一个方案;撤销到根后改切 aa,再接 b 得到 aa|b
③ 剪枝与撤销:aab 整段不是回文,根层直接跳过;每个合法子串递归返回后 pop,恢复到本次切口之前。
④ 优化取舍:当前代码用双指针即时验回文,易读;可预处理 O(n²) 回文表把单次判断降为 O(1)。输出主导时间至少 O(n·2ⁿ),递归空间 O(n)。

⚠ 易错坑 子串右端包含 end,应写 s[start:end+1]
下一段从 end+1 开始。
空字符串存在一个空分割方案 [[]]
3 · 图解
s="aab",start 表示下一段的起点。

里程碑0|根
start=0,path=[];尝试右端 end=0,1,2

里程碑1|连续切单字符
"a" 是回文 → path=["a"], start=1
再切 "a" → ["a","a"], start=2
再切 "b" → ["a","a","b"], start=3,收集

里程碑2|逐层撤销
pop "b",再pop第二个 "a",恢复 path=["a"]
尝试从 start=1 切 "ab",不是回文,直接跳过

里程碑3|撤销到根,改切 "aa"
"aa" 是回文 → path=["aa"], start=2
再切 "b" → ["aa","b"],收集第二个方案

里程碑4|根层剩余候选
整段 "aab" 不是回文,不进入递归

里程碑5|最终
结果 [["a","a","b"],["aa","b"]]
path=[],所有切口状态已恢复。
回文分割:按切口层摘要回文选择、剪枝与撤销
阶段切口决策startpath结果
1初始化0[]枚举首段
2连续切"a"、"a"、"b"3["a","a","b"]到达末尾
3收集后撤销到["a"]1["a"]方案1
4候选"ab"1["a"]非回文,剪枝
5根改切"aa",再切"b"3["aa","b"]到达末尾
6收集并回到根0[]方案2
7候选"aab"0[]非回文,结束
4 · 代码
class Solution:
    def partition(self, s: str) -> list[list[str]]:
        results = []
        path = []
        length = len(s)

        def is_palindrome(left, right):
            # 1. 闭区间两端向中间比较,任一不同即失败
            while left < right:
                if s[left] != s[right]:
                    return False
                left += 1
                right -= 1
            return True

        def build(start):
            # 2. 切口到达末尾时,保存完整分割方案
            if start == length:
                results.append(path[:])
                return

            # 3. 枚举当前段右端,仅回文段才进入下一切口
            for end in range(start, length):
                if not is_palindrome(start, end):
                    continue

                path.append(s[start:end + 1])
                build(end + 1)

                # 4. 撤销当前子串,继续尝试更长的同起点子串
                path.pop()

        build(0)
        return results
5 · 复杂度

时间 O(n·2^n),空间 O(n)。共有最多 2^(n-1) 种分割方案(每个间隙切或不切),每次回文判断与拷贝 O(n);递归深度 O(n)。用 DP 预处理回文可把单次判断降到 O(1)。

6 · 测试用例

功能"aab" → 2 种;边界:单字符 "a"[["a"]],空串 → [[]]特殊:全相同 "aaa" → 4 种(含 ["aaa"]["a","a","a"] 等),无回文分段时至少每个单字符都成回文所以总有解。

N 皇后

LC 51 ↗回溯 · Hard
1 · 题目

要完成的任务

n × n 国际象棋棋盘上放置 n 个皇后,使任意两个皇后都不能互相攻击。返回全部不同摆放方案。

输入与输出表示

皇后会攻击同一行、同一列或同一条斜线上的棋子。每个方案用包含 n 个字符串的列表表示;'Q' 是皇后,'.' 是空格。

示例 1

输入:n=4

输出:[[".Q..","...Q","Q...","..Q."],["..Q.","Q...","...Q",".Q.."]]

四皇后的两种合法摆放两个四乘四棋盘各放四个皇后,每行、每列和每条斜线最多一个皇后。QQQQ方案 1QQQQ方案 2
n=4 时恰有这两种不同方案;每个 Q 所在行、列、斜线均不冲突

四皇后共有两种不同方案。

示例 2

输入:n=1

输出:[["Q"]]

约束与保证

  • 1 ≤ n ≤ 9
  • 每个合法方案恰好每行、每列各一个皇后,且两条对角线方向也不能冲突。
2 · 思路(暴力→最优)

暴力:从 n² 个格子中任选 n 个再验证攻击关系,候选数 C(n²,n);大量方案在前几枚皇后时就已冲突。

正解 · 逐行放置与集合剪枝:
① 核心规律:每行只放一个皇后,天然排除同行;用 columns、主对角线 row-col、副对角线 row+col 三个集合 O(1) 判断冲突。
② 样例落地:n=4 从第0行第0列出发的分支最终无位置可放并回溯;改选第0行第1列后,可依次放在列 [1,3,0,2],得到第一组解。
③ 撤销含义:某行后续失败时,要同时删除列、两条对角线标识和 queens 中的列;缺任一项都会污染兄弟列。
④ 优化取舍:逐行后搜索上界约 O(n!),集合让每次冲突检查 O(1);位运算可进一步减常数,但集合版更适合教学和面试说明。

⚠ 易错坑 三个集合与 queens 必须完整撤销。
row-col 可能为负,set 可直接保存。
n=2、n=3 无解,应返回空列表。
3 · 图解
n=4,queens[row]=该行皇后列号。

里程碑0|空棋盘
....
....
....
....
columns、两组diagonals均为空

里程碑1|首行选列0,次行选列2;第2行无安全列
Q...
..Q.
....
....
该分支失败,撤销第1行列2

里程碑2|首行仍列0,次行改列3、第三行列1
Q...
...Q
.Q..
....
第3行仍无安全列;逐层撤销,最终移除首行列0

里程碑3|首行改列1,第二行选列3
.Q..
...Q
....
....
集合状态只包含列{1,3}及对应对角线

里程碑4|继续选第2行列0、第3行列2
.Q..
...Q
Q...
..Q.
row==4,收集方案 queens=[1,3,0,2]

里程碑5|完整回溯后得到镜像方案
..Q.
Q...
...Q
.Q..
queens=[2,0,3,1]
最终共2解;所有集合与queens恢复为空。
N皇后:按行决策与失败子树摘要集合剪枝和撤销
阶段行决策queens冲突/撤销结果
1初始化[]三个集合为空处理row0
2row0=0,row1=2[0,2]row2无安全列撤销row1
3row1改3,row2=1[0,3,1]row3无安全列撤销到根
4row0改1,row1=3[1,3]列/对角线均安全继续
5row2=0,row3=2[1,3,0,2]row==4方案1
6完整撤销首方案[]集合全部恢复试后续首列
7镜像分支[2,0,3,1]四行均安全方案2
8根循环结束[]集合为空返回2解
4 · 代码
class Solution:
    def solveNQueens(self, n: int) -> list[list[str]]:
        results = []
        used_columns = set()
        used_main_diagonals = set()
        used_anti_diagonals = set()
        queens = []

        def build(row):
            # 1. 每行都已放置皇后时,根据列记录生成棋盘
            if row == n:
                board = [
                    "." * col + "Q" + "." * (n - col - 1)
                    for col in queens
                ]
                results.append(board)
                return

            # 2. 当前行枚举列,用三个集合常数时间判断冲突
            for col in range(n):
                main_diagonal = row - col
                anti_diagonal = row + col

                if (col in used_columns
                        or main_diagonal in used_main_diagonals
                        or anti_diagonal in used_anti_diagonals):
                    continue

                used_columns.add(col)
                used_main_diagonals.add(main_diagonal)
                used_anti_diagonals.add(anti_diagonal)
                queens.append(col)

                build(row + 1)

                # 3. 完整撤销列、两条对角线及路径记录
                queens.pop()
                used_columns.remove(col)
                used_main_diagonals.remove(main_diagonal)
                used_anti_diagonals.remove(anti_diagonal)

        build(0)
        return results
5 · 复杂度

设合法方案数为 S。时间 O(n! + S·n²):搜索节点数以 O(n!) 为上界,每个合法叶子还要构造 n 行、每行长度 n 的棋盘。额外空间 O(n):三个集合、queens 与递归栈均为 O(n);结果中 S 张、每张 O(n²) 的棋盘不计入额外空间。

6 · 测试用例

功能n=4 → 2 个方案;边界n=1 → 1 个([["Q"]]);特殊n=2n=3 → 0 个(无解),验证对角线判定用 row±col 正确、撤销时三个集合都要移除。

二分查找

二分的灵魂是循环不变量:每一轮都保证"答案还在 [lo, hi] 区间内",靠不断折半把区间收缩到 1 个元素。写对二分的诀窍是循环条件和边界更新必须配套:用 while lo < hi 就配 hi=mid / lo=mid+1(收敛到单点,适合找边界);用 while lo <= hi 就配 lo=mid+1 / hi=mid-1(找到即返回,适合等值查找)。找左右边界的统一利器是 lowerBound(第一个 ≥ target 的位置)——34 找首末位置、35 找插入点都是它的应用。旋转数组(33153)是变体:数组虽整体无序,但一分为二后必有一半是有序的,判断 target 落在哪半继续二分。关联其它类别:二分和BFS都追求"最优/最短",但二分要求单调性作为收缩依据;LC4 中位数把二分升级到"二分分割点",是难度天花板。

搜索插入位置

LC 35 ↗二分查找 · Easy
1 · 题目

要完成的任务

给你一个按严格升序排列、元素互不相同的整数数组 nums 和目标值 target。若目标值存在,返回它的下标;否则返回它按顺序插入后所在的下标。算法必须达到 O(log n) 时间复杂度。

输入与返回值

nums 是升序且无重复的整数数组;返回值是目标值已有的下标,或保持升序时的插入位置。

示例 1

输入:nums=[1,3,5,6]target=5

输出:2

目标值已经存在于下标 2。

示例 2

输入:nums=[1,3,5,6]target=2

输出:1

2 插到 1 和 3 之间时,下标是 1。

示例 3

输入:nums=[1,3,5,6]target=7

输出:4

7 比所有元素都大,应插到数组末尾。

约束与保证

  • 1 ≤ nums.length ≤ 10⁴
  • -10⁴ ≤ nums[i] ≤ 10⁴,且 nums 按升序排列、元素互不相同。
  • -10⁴ ≤ target ≤ 10⁴
2 · 思路(暴力→最优)

暴力及局限。从左扫描到第一个不小于 target 的位置即可,但最坏要看完 n 个元素,时间 O(n)。

正解:二分寻找 lower bound。
① 单调判定:nums[index] >= target 沿升序数组只会从 False 变成 True 一次,答案就是第一个 True。
② 候选区间:使用半开区间 [lo, hi),初始 hi=len(nums),让“插到数组末尾”也成为合法答案。
③ 收缩方向:nums[mid] < target,排除 mid 及左侧;否则 mid 仍可能是第一个合格位置,必须保留。
④ 结束条件:lo==hi 时候选区间收敛到唯一插入位置。

具体例子 [1,3,5,6], target=2区间依次从 [0,4) 缩到 [0,2)[0,1),最后因 1<2 收敛到 [1,1)。相同判定连续执行三轮;若 target=7,所有元素都偏小,最终自然收敛到边界位置 4。

取舍。lower bound 一次搜索同时覆盖“命中”和“插入”两种结果,时间 O(log n)、空间 O(1);普通等值二分虽能找到已有元素,却还需额外逻辑计算不存在时的插入点。

⚠ 易错坑 初始 hi = n 不是 n-1:插入点可以等于 n(插到末尾)。写 n-1 时,target 比所有元素都大就会漏掉末尾位置。
两个分支不对称< targetlo=mid+1≥ targethi=mid(保留 mid)。若两边都 ±1,会把唯一答案跳过。
循环条件必须是 while lo < hi(严格小于)配 hi=mid。若误写 lo <= hi,当 lo==hi==midhi=mid 不推进 → 死循环
3 · 图解
nums=[1,3,5,6],target=2;候选答案始终在半开区间 [lo,hi):

① 初始候选
   [0,4) → [1,3,5,6]

② mid=2,nums[2]=5≥2,保留 mid
   [0,4) → [0,2)

③ mid=1,nums[1]=3≥2,再向左收缩
   [0,2) → [0,1)

④ mid=0,nums[0]=1<2,排除 mid 及左侧
   [0,1) → [1,1)  ← 插入位置 1

稳定结果:[1,(2),3,5,6];若 target 已存在,收敛点就是它的下标。
target=2:按二分轮次概括判定与半开区间收缩
代码动作判定来源mid / nums[mid][lo,hi) 赋值后
0初始化寻找首个 ≥2 的位置[0,4)
1第 1 轮5≥2,保留 mid=22 / 5[0,2)
2第 2 轮3≥2,保留 mid=11 / 3[0,1)
3第 3 轮1<2,排除 mid=00 / 1[1,1)
4区间收敛lo==hi==1插入位置 1
4 · 代码
class Solution:
    def searchInsert(self, nums: list[int], target: int) -> int:
        # 1. 半开区间允许答案落在 len(nums)
        lo, hi = 0, len(nums)

        # 2. 保持区间内始终包含“首个不小于 target 的位置”
        while lo < hi:
            mid = (lo + hi) // 2
            if nums[mid] < target:
                lo = mid + 1
            else:
                hi = mid

        return lo
5 · 复杂度

时间 O(log n),空间 O(1)。每轮区间减半,最多 log₂n 轮;只用常数变量。

6 · 测试用例

功能[1,3,5,6], 5 → 2;边界:插到最前 [1,3,5,6], 0 → 0,插到最后 [1,3,5,6], 7 → 4,空数组 [], 1 → 0;特殊:命中首/末元素 [1,3,5,6], 1 → 0、...,6 → 3。

搜索二维矩阵

LC 74 ↗二分查找 · Medium
1 · 题目

要完成的任务

给你一个 m × n 整数矩阵 matrix 和目标值 target,判断目标值是否出现在矩阵中。

矩阵满足什么条件

每一行都按非递减顺序排列;除第一行外,每一行的第一个整数都大于上一行的最后一个整数。因此按行首尾相接后,整个矩阵就是一个有序序列。

示例 1

输入:matrix=[[1,3,5,7],[10,11,16,20],[23,30,34,60]]target=3

输出:true

LC74 查找目标值 3 的矩阵 三行四列矩阵依次包含 1、3、5、7,10、11、16、20,23、30、34、60;目标值 3 位于第一行第二列。 1 3 5 7 10 11 16 20 23 30 34 603 位于第 1 行第 2 列
目标值 3 命中第一行第二列

示例 2

输入:matrix=[[1,3,5,7],[10,11,16,20],[23,30,34,60]]target=13

输出:false

LC74 查找目标值 13 的矩阵 三行四列矩阵依次包含 1、3、5、7,10、11、16、20,23、30、34、60;目标值 13 不在矩阵中。 1 3 5 7 10 11 16 20 23 30 34 6013 不在矩阵中
相邻值 11 与 16 之间没有 13

约束与保证

  • m == matrix.lengthn == matrix[i].length
  • 1 ≤ m,n ≤ 100
  • -10⁴ ≤ matrix[i][j], target ≤ 10⁴
  • 每行非递减,且下一行首元素严格大于上一行末元素。
2 · 思路(暴力→最优)

暴力及局限。逐行逐格扫描最坏访问全部 m·n 个元素,没有利用矩阵的整体单调性。

正解①:虚拟展平后二分。
① 整体顺序:每行升序且下一行首值大于上一行末值,因此按行拼接后就是完整升序数组。
② 下标映射:虚拟下标 mid 对应 row=mid//col_countcol=mid%col_count
③ 区间收缩:当前值偏小就保留右半,偏大就保留左半,命中立即返回。
④ 空输入:读取列数前先检查矩阵和首行,避免边界输入越界。

正解②:右上角阶梯搜索。
① 起点性质:右上角左侧都更小、下方都更大。
② 排除方向:当前值偏大就左移并排除当前列;偏小就下移并排除当前行。
③ 结束条件:命中返回 True,越过左边界或下边界说明不存在。

具体例子:展平二分查 3 时,虚拟区间经过 4 轮落到坐标 (0,1);阶梯法查 11 时,从 7 下移到 20,再连续左移到 16、11,同时覆盖“偏小下移”和“偏大左移”两种分支。

取舍。本题整体有序,展平二分 O(log(m·n)) 更优;阶梯法 O(m+n),但它还能处理仅保证每行、每列有序而整体不能展平的矩阵。

⚠ 易错坑 映射用 row = idx // ncol = idx % n,除数与模数都是列数 n(不是 m)。写反会越界或错位。
这是等值查找:用 while lo <= hi + lo=mid+1 / hi=mid-1。别和 lowerBound 的 hi=mid 模板混用,否则要另判存在性。
n = len(matrix[0]) 前要保证矩阵非空、首行非空(题目一般保证,但空矩阵/空行是边界)。
3 · 图解
matrix(row_count=3,col_count=4)
 1  3  5  7
10 11 16 20
23 30 34 60

解法① target=3,拉平成下标 0..11 后二分:
① [0,11] mid=5 → (1,1)=11  偏大 → [0,4]
② [0, 4] mid=2 → (0,2)=5   偏大 → [0,1]
③ [0, 1] mid=0 → (0,0)=1   偏小 → [1,1]
④ [1, 1] mid=1 → (0,1)=3   命中

解法② target=11,从右上角走:
① (0,3)=7  偏小 → 下移到 (1,3)=20
② (1,3)=20 偏大 → 左移到 (1,2)=16
③ (1,2)=16 偏大 → 左移到 (1,1)=11
④ (1,1)=11 命中
每次移动都稳定排除一整行或一整列。
解法① 一维二分:每轮合并下标映射、比较与区间收缩
代码动作来源 / 判定mid→(row,col)val[lo,hi] 现场
0初始化3×4 拉平为闭区间 0..11[0,11]
1第 1 轮11>3,舍弃 mid 及右侧5→(1,1)11[0,4]
2第 2 轮5>3,继续左缩2→(0,2)5[0,1]
3第 3 轮1<3,舍弃 mid 及左侧0→(0,0)1[1,1]
4第 4 轮命中val==target1→(0,1)3返回 True
解法② 右上角阶梯搜索:每轮合并读值、比较与单向移动
代码动作判定来源(r,c) 现场val
0从右上角出发target=11;7<11,排除第 0 行(0,3)7
1下移一行20>11,排除第 3 列(1,3)20
2左移一列16>11,排除第 2 列(1,2)16
3再次左移抵达下一个候选格(1,1)11
4当前格命中11==target(1,1)返回 True
4 · 代码
class Solution:
    def searchMatrix(self, matrix: list[list[int]], target: int) -> bool:
        # 1. 空矩阵没有可搜索元素
        if not matrix or not matrix[0]:
            return False

        row_count = len(matrix)
        col_count = len(matrix[0])
        lo, hi = 0, row_count * col_count - 1

        # 2. 在虚拟的一维闭区间上做等值二分
        while lo <= hi:
            mid = (lo + hi) // 2
            row, col = divmod(mid, col_count)
            value = matrix[row][col]
            if value == target:
                return True
            if value < target:
                lo = mid + 1
            else:
                hi = mid - 1

        return False
# 解法二:右上角阶梯搜索
class Solution:
    def searchMatrix(self, matrix: list[list[int]], target: int) -> bool:
        # 1. 建立右上角起点与合法边界
        if not matrix or not matrix[0]:
            return False
        row_count = len(matrix)
        col_count = len(matrix[0])
        row, col = 0, col_count - 1

        # 2. 每次比较稳定排除一整行或一整列
        while row < row_count and col >= 0:
            value = matrix[row][col]
            if value == target:
                return True
            if value > target:
                col -= 1
            else:
                row += 1

        return False
5 · 复杂度

解法① 展平二分:时间 O(log(m·n)),空间 O(1),对 m·n 个元素二分,即 log(m·n)=log m + log n 轮。解法② 右上角阶梯搜索:时间 O(m+n),空间 O(1),每步排除一行或一列。

6 · 测试用例

功能[[1,3,5,7],[10,11,16,20],[23,30,34,60]], 3 → True;边界:目标为左上 ...,1 → True、右下 ...,60 → True,单元素 [[5]], 5 → True;特殊:不存在 ...,13 → False,比全部都小 ...,0 → False。

在排序数组中查找元素的第一个和最后一个位置

LC 34 ↗二分查找 · Medium
1 · 题目

要完成的任务

给你一个按非递减顺序排列的整数数组 nums 和目标值 target,找出目标值的开始位置和结束位置。若不存在,返回 [-1,-1]。算法必须达到 O(log n) 时间复杂度。

返回值表示什么

返回长度为 2 的数组:第一个数是最左下标,第二个数是最右下标。数组允许包含重复元素,也可能为空。

示例 1

输入:nums=[5,7,7,8,8,10]target=8

输出:[3,4]

两个 8 分别位于下标 3 和 4。

示例 2

输入:nums=[5,7,7,8,8,10]target=6

输出:[-1,-1]

示例 3

输入:nums=[]target=0

输出:[-1,-1]

约束与保证

  • 0 ≤ nums.length ≤ 10⁵
  • -10⁹ ≤ nums[i] ≤ 10⁹,且 nums 按非递减顺序排列。
  • -10⁹ ≤ target ≤ 10⁹
2 · 思路(暴力→最优)

直观法及局限。先二分命中任意 target,再向左右线性扩展;当数组全是 target 时仍要扫描全部元素,退化为 O(n)。

正解:复用两次 lower bound。
① 左边界:lower_bound(target) 返回第一个不小于 target 的位置。
② 存在性:若该位置越界或值不等于 target,说明数组中不存在目标。
③ 右侧哨兵:lower_bound(target+1) 是第一个严格大于 target 的位置,减一就是最后一个 target。
④ 二分模板:每次调用都在半开区间 [lo,hi) 上收缩,偏小排左侧,否则保留 mid。

具体例子:[5,7,7,8,8,8,10] 查 8。第一次二分经过三轮收敛到下标 3;验证 nums[3] 后,第二次以阈值 9 再经过三轮收敛到下标 6,最终右边界为 5。重复值横跨三个位置,核心模板实际复用了两遍。

取舍。两次二分总时间仍是 O(log n)、空间 O(1)。单写 upper bound 也正确,但复用一个 lower-bound helper 更容易保持边界一致;Python 整数不会溢出,固定宽度语言使用 target+1 时需注意最大值。

⚠ 易错坑 右边界是 lowerBound(target+1) − 1,不是 lowerBound(target) 的结果;别忘了 −1
必须先做存在性判定:left == nnums[left] != target 就返回 [-1,-1]。否则数组里根本没有 target 时会返回一段错误区间。
当 target 是数组最大值时 lowerBound(target+1) == n,减一得 n-1 正好是末位,别担心越界;但用 target+1 在有溢出的语言里要留意。
3 · 图解
nums=[5,7,7,8,8,8,10],target=8;下标 0..6

① 左边界开始:lower_bound(8)
   [0,7) mid=3,value=8  → [0,3)

② 左边界继续收缩
   [0,3) mid=1,value=7  → [2,3)
   [2,3) mid=2,value=7  → [3,3)  得 left=3

③ 验证存在:nums[left]=8 ✓

④ 右侧哨兵开始:lower_bound(9)
   [0,7) mid=3,value=8  → [4,7)

⑤ 右侧哨兵继续收缩
   [4,7) mid=5,value=8  → [6,7)
   [6,7) mid=6,value=10 → [6,6)

⑥ 首个 >8 的位置为 6,right=6-1=5,答案 [3,5]。
两次 lower_bound:按轮次汇总左边界、存在性检查与右边界
代码动作来源 / 判定mid / nums[mid][lo,hi) / left 现场
0初始化左边界搜索threshold=8[0,7)
1左边界第 1 轮8≥8,保留 mid3 / 8[0,3)
2左边界第 2..3 轮两个 7 均偏小1 / 7;2 / 7left=3
3验证存在性nums[3]==83 / 8继续
4初始化右侧哨兵threshold=9[0,7)
5右侧第 1..2 轮两个 8 均偏小3 / 8;5 / 8[6,7)
6右侧第 3 轮10≥9,保留 mid6 / 10[6,6)
7换算并返回right=6−1left=3,right=5[3,5]
4 · 代码
class Solution:
    def searchRange(self, nums: list[int], target: int) -> list[int]:
        # 1. 统一 helper:寻找首个不小于 threshold 的位置
        def lower_bound(threshold):
            lo, hi = 0, len(nums)
            while lo < hi:
                mid = (lo + hi) // 2
                if nums[mid] < threshold:
                    lo = mid + 1
                else:
                    hi = mid
            return lo

        # 2. 先求左边界并验证目标确实存在
        left_boundary = lower_bound(target)
        if left_boundary == len(nums) or nums[left_boundary] != target:
            return [-1, -1]

        # 3. 首个严格更大值的前一格就是右边界
        right_boundary = lower_bound(target + 1) - 1
        return [left_boundary, right_boundary]
5 · 复杂度

时间 O(log n),空间 O(1)。两次独立二分各 O(log n);常数空间。

6 · 测试用例

功能[5,7,7,8,8,8,10], 8[3,5]边界:不存在 ...,6[-1,-1],空数组 [], 0[-1,-1],单个命中 [1], 1[0,0]特殊:全相同 [2,2,2], 2[0,2],target 在首/末 [8,8,10], 8[0,1]

搜索旋转排序数组

LC 33 ↗二分查找 · Medium
1 · 题目

要完成的任务

给你一个原本严格升序、元素互不相同,但可能在某个未知下标处旋转过的整数数组 nums,以及目标值 target。若目标存在,返回它的下标;否则返回 -1。算法必须达到 O(log n) 时间复杂度。

“旋转”是什么意思

存在某个 k0 ≤ k < nums.length),原数组被变成 [nums[k],...,nums[n-1],nums[0],...,nums[k-1]]k=0 表示没有改变。

示例 1

输入:nums=[4,5,6,7,0,1,2]target=0

输出:4

示例 2

输入:nums=[4,5,6,7,0,1,2]target=3

输出:-1

示例 3

输入:nums=[1]target=0

输出:-1

约束与保证

  • 1 ≤ nums.length ≤ 5000
  • -10⁴ ≤ nums[i], target ≤ 10⁴
  • nums 中的每个值都互不相同。
  • nums 保证由严格升序数组在某个下标处旋转得到,旋转位置也可以是 0。
2 · 思路(暴力→最优)

暴力及局限。线性扫描一定能找到目标,但需要 O(n),没有利用旋转数组仍保留的局部有序性。

正解:每轮识别有序的一半。
① 先查中点:nums[mid]==target 立即返回,之后的开闭边界都建立在 mid 已排除的前提上。
② 识别有序半:nums[lo]<=nums[mid] 表示左半有序,否则右半有序。
③ 判断目标归属:在有序半上用端点判断 target 是否落入其连续值域。
④ 收缩候选:目标在有序半就保留该半,否则转向另一半;每轮排除至少一半。

具体例子:[4,5,6,7,0,1,2] 查 0。第一轮左半 4..7 有序但不含 0,转到 [4,6];第二轮左半 0..1 有序且包含 0,缩到 [4,4];第三轮命中。这个过程连续两次重新识别有序半,并覆盖旋转点附近的单元素边界。

取舍。元素互异时方向判定明确,时间 O(log n)、空间 O(1);若允许重复,nums[lo]==nums[mid] 可能无法判断旋转点在哪侧,最坏会退化到 O(n)。

⚠ 易错坑 判有序用 nums[lo] <= nums[mid]带等号):区间只剩两个元素、mid==lonums[lo]==nums[mid],须算左半有序。漏等号会误判。
值域区间的开闭要精确:左半有序判 nums[lo] <= target < nums[mid](左闭右开,因 nums[mid]==target 已在开头 return)。右半对称写 nums[mid] < target <= nums[hi]
每轮开头先判 nums[mid]==target 立即返回,否则后面区间判断都建立在"mid 不是答案"的前提上。
3 · 图解
nums=[4,5,6,7,0,1,2],target=0;每轮至少有一半仍有序:

① 初始:[lo,hi]=[0,6]

② mid=3,value=7
  左半 [4,5,6,7] 有序,但 0 不在 [4,7) → 舍左,变 [4,6]

③ [lo,hi]=[4,6],mid=5,value=1
  左半 [0,1] 有序,且 0 在 [0,1)       → 保左,变 [4,4]

④ [lo,hi]=[4,4],mid=4,value=0         → 命中下标 4

关键不是猜旋转点,而是用有序半的边界判断 target 是否落在其中。
旋转数组二分:每轮合并识别有序半、判断目标归属与区间收缩
代码动作有序半 / 目标判定mid / nums[mid][lo,hi] 现场
0确认搜索规则闭区间内每轮识别一个有序半候选 0..6
1初始化整个旋转数组待搜索[0,6]
2第 1 轮左半 4..7 有序;0 不在其中3 / 7[4,6]
3第 2 轮左半 0..1 有序;0 落在其中5 / 1[4,4]
4第 3 轮命中nums[4]==target4 / 0返回 4
4 · 代码
class Solution:
    def search(self, nums: list[int], target: int) -> int:
        # 1. 闭区间保存所有仍可能命中的下标
        lo, hi = 0, len(nums) - 1

        # 2. 每轮先命中中点,再识别有序的一半
        while lo <= hi:
            mid = (lo + hi) // 2
            if nums[mid] == target:
                return mid

            if nums[lo] <= nums[mid]:
                target_in_sorted_half = nums[lo] <= target < nums[mid]
                if target_in_sorted_half:
                    hi = mid - 1
                else:
                    lo = mid + 1
            else:
                target_in_sorted_half = nums[mid] < target <= nums[hi]
                if target_in_sorted_half:
                    lo = mid + 1
                else:
                    hi = mid - 1

        return -1
5 · 复杂度

时间 O(log n),空间 O(1)。每轮排除一半;常数变量。注意含重复元素时(LC81)最坏退化到 O(n),本题无重复保证 O(log n)。

6 · 测试用例

功能[4,5,6,7,0,1,2], 0 → 4;边界:未旋转 [1,2,3], 2 → 1,单元素 [1], 1 → 0,空 [], 5 → -1;特殊:不存在 [4,5,6,7,0,1,2], 3 → -1,target 在旋转点两侧 ...,4 → 0、...,2 → 6。

寻找旋转排序数组中的最小值

LC 153 ↗二分查找 · Medium
1 · 题目

要完成的任务

给你一个元素互不相同、原本严格升序但被旋转过的整数数组 nums,返回其中的最小元素。算法必须达到 O(log n) 时间复杂度。

输入保证

长度为 n 的原升序数组会被旋转 1 到 n 次;旋转 n 次等价于保持原顺序。数组中没有重复值。

示例 1

输入:nums=[3,4,5,1,2]

输出:1

原数组 [1,2,3,4,5] 旋转 3 次得到该数组。

示例 2

输入:nums=[4,5,6,7,0,1,2]

输出:0

原数组 [0,1,2,4,5,6,7] 旋转 4 次得到该数组。

示例 3

输入:nums=[11,13,15,17]

输出:11

旋转 4 次后回到原顺序,最小值仍在首位。

约束与保证

  • n == nums.length,且 1 ≤ n ≤ 5000
  • -5000 ≤ nums[i] ≤ 5000,所有元素互不相同。
  • nums 由严格升序数组旋转 1 到 n 次得到。
2 · 思路(暴力→最优)

暴力及局限。线性扫描或直接调用 min 需要 O(n),没有使用旋转数组由两个升序段组成的结构。

正解:与候选区间右端点比较。
① 候选不变量:最小值始终位于闭区间 [lo,hi]
② 中点偏大:nums[mid]>nums[hi],mid 位于旋转前的大值段,最小值严格在右侧。
③ 中点偏小:否则 mid 已在包含最小值的小值段,最小值位于 mid 或其左侧,所以必须保留 mid。
④ 收敛返回:while lo<hi 结束时只剩一个候选,下标 lo 即旋转点。

具体例子:[4,5,6,7,0,1,2] 中,7>2 先把候选缩到 [4,6];随后 1<=2 保留 mid=5,再由 0<=1 保留 mid=4,三轮后稳定收敛到最小值 0。样例重复执行了“保留 mid”分支,也覆盖最小值正好等于中点的边界。

取舍。元素互异时比较结果能唯一确定方向,时间 O(log n)、空间 O(1);若含重复,nums[mid]==nums[hi] 无法判断方向,最坏退化到 O(n)。

⚠ 易错坑 nums[hi] 比,不是 nums[lo]:与左端点比在未旋转 / 前段内会误判。
nums[mid] < nums[hi] 分支走 hi=mid不是 mid-1):mid 本身可能就是最小值,减一会把答案丢掉。
while lo < hi(严格),不能 <=:配 hi=mid,若 lo==hi 还继续则 hi=mid 不推进 → 死循环。收敛到 lo==hi 即答案。
3 · 图解
nums=[4,5,6,7,0,1,2];候选区间始终包含最小值:

① 初始候选 [0,6]

② mid=3:nums[3]=7 > nums[6]=2
       mid 位于较大段,最小值严格在右侧 → [4,6]

③ [4,6] mid=5:nums[5]=1 ≤ nums[6]=2
       mid 可能就是最小值,保留 mid      → [4,5]

④ [4,5] mid=4:nums[4]=0 ≤ nums[5]=1
       再保留 mid                         → [4,4]

⑤ 稳定结果:区间收敛到下标 4,nums[4]=0。
与右端点比较:每轮合并取中、判断所在段与候选区间收缩
代码动作nums[mid] vs nums[hi]mid[lo,hi] 现场
0初始化闭区间覆盖整个数组[0,6]
1第 1 轮7>2,最小值严格在 mid 右侧3[4,6]
2第 2 轮1≤2,mid 可能为最小值5[4,5]
3第 3 轮0≤1,继续保留 mid4[4,4]
4区间收敛lo==hi==4返回 nums[4]=0
4 · 代码
class Solution:
    def findMin(self, nums: list[int]) -> int:
        # 1. 闭区间始终保留旋转后的最小值
        lo, hi = 0, len(nums) - 1

        # 2. 与右端点比较,判断 mid 位于大值段还是小值段
        while lo < hi:
            mid = (lo + hi) // 2
            if nums[mid] > nums[hi]:
                lo = mid + 1
            else:
                hi = mid

        return nums[lo]
5 · 复杂度

时间 O(log n),空间 O(1)。每轮区间减半;常数空间。含重复元素时(LC154)最坏 O(n),本题无重复保证 O(log n)。

6 · 测试用例

功能[4,5,6,7,0,1,2] → 0;边界:未旋转 [1,2,3] → 1,单元素 [1] → 1,两元素 [2,1] → 1;特殊:最小值在末尾 [2,3,1] → 1、旋转到最小值在开头 [0,1,2] → 0。

寻找两个正序数组的中位数

LC 4 ↗二分查找 · Hard
1 · 题目

要完成的任务

给你两个分别按非递减顺序排列的整数数组 nums1nums2,返回合并后数据集合的中位数。算法的时间复杂度必须是 O(log(m+n))。

中位数如何定义

总长度为奇数时,中位数是排序后正中间的数;总长度为偶数时,中位数是中间两个数的平均值。两个数组至少有一个非空。

示例 1

输入:nums1=[1,3]nums2=[2]

输出:2.00000

合并结果为 [1,2,3],中位数是 2。

示例 2

输入:nums1=[1,2]nums2=[3,4]

输出:2.50000

合并结果为 [1,2,3,4],中位数是 (2+3)/2=2.5

约束与保证

  • nums1.length == mnums2.length == n
  • 0 ≤ m,n ≤ 1000,且 1 ≤ m+n ≤ 2000
  • -10⁶ ≤ nums1[i], nums2[i] ≤ 10⁶
  • 两个输入数组都按非递减顺序排列。
2 · 思路(暴力→最优)

直观法及局限。合并两个有序数组再取中位数很直接,但要 O(m+n) 时间和额外数组,不满足对数级要求。

正解:在较短数组上二分分割点。
① 固定左半规模:令合并后左半共有 left_size=(m+n+1)//2 个元素;确定 short_cut 后,long_cut=left_size-short_cut 自动确定。
② 读取四个边界:分别取短数组和长数组分割线左右的最大/最小值;分割贴边时用正负无穷哨兵。
③ 检查交叉有序:合法分割必须满足 short_left_max<=long_right_minlong_left_max<=short_right_min
④ 调整分割点:短数组左侧最大值过大表示切多了,向左;长数组左侧最大值过大表示短数组切少了,向右。
⑤ 计算中位数:总长为奇数取左半最大值;偶数再与右半最小值求平均。

具体例子:短数组 [1,3]、长数组 [2,4,5,6]。第一次 short_cut=1 时,长数组左侧最大值 4 大于短数组右侧最小值 3,说明短数组切少;右移到 short_cut=2 后两组交叉条件同时成立,稳定分割为 {1,2,3}|{4,5,6},中位数 3.5。样例覆盖“向右调整”和“分割点落在数组末端”的哨兵边界。

取舍。只在较短数组二分,时间 O(log(min(m,n)))、空间 O(1);算法难点是四个边界和奇偶公式,但避免了真实合并。

⚠ 易错坑 必须在较短数组二分(m > n 先 swap)。否则 j = (m+n+1)//2 − i 可能为负 → nums2 下标越界。
j(m+n+1)//2 − i+1 让奇数时左半多放一个,从而奇数答案取 max(L1,L2);去掉 +1,奇偶取值公式就得跟着改。
边界必须用哨兵:i==0 → L1=-infi==m → R1=+inf、j 同理。漏哨兵会在 i 走到端点时下标越界。
3 · 图解
nums1=[1,3](短数组),nums2=[2,4,5,6];left_size=3

① 分割点候选:short_cut∈[0,2]

② 第一次尝试 short_cut=1,long_cut=2
   nums1: [1 | 3]          short_left_max=1, short_right_min=3
   nums2: [2,4 | 5,6]      long_left_max=4,  long_right_min=5
   4>3,短数组切少了 → 分割点右移

③ 第二次尝试 short_cut=2,long_cut=1
   nums1: [1,3 | ]         short_left_max=3, short_right_min=+∞
   nums2: [2 | 4,5,6]      long_left_max=2,  long_right_min=4

④ 两个交叉条件成立,稳定分割为 {1,2,3} | {4,5,6}
   偶数中位数 = (max(3,2)+min(+∞,4))/2 = 3.5
在短数组上二分分割点:每个候选分割合并展示 i/j、四边界与移动方向
代码动作来源 / 交叉判定当前值[lo,hi] / 分割现场
0选择短数组len(nums1)=2≤4,无需交换short_count=2,long_count=4short_cut∈[0,2]
1固定左半规模left_size=3long_cut=3-short_cut[lo,hi]=[0,2]
2第一次分割1/3 与 4/5;4>3short_cut=1,long_cut=2短数组切少 → [2,2]
3第二次分割3/+∞ 与 2/4;交叉有序short_cut=2,long_cut=1合法分割
4按偶数总长返回left_max=3,right_min=4(3+4)/23.5
4 · 代码
class Solution:
    def findMedianSortedArrays(self, nums1: list[int], nums2: list[int]) -> float:
        # 1. 始终在较短数组上二分分割点
        if not nums1 and not nums2:
            raise ValueError("输入数组不能同时为空")
        if len(nums1) > len(nums2):
            nums1, nums2 = nums2, nums1
        short_count = len(nums1)
        long_count = len(nums2)
        left_size = (short_count + long_count + 1) // 2
        lo, hi = 0, short_count

        # 2. 调整分割点,直到两组交叉边界都有序
        while lo <= hi:
            short_cut = (lo + hi) // 2
            long_cut = left_size - short_cut

            short_left_max = nums1[short_cut - 1] if short_cut else float('-inf')
            short_right_min = nums1[short_cut] if short_cut < short_count else float('inf')
            long_left_max = nums2[long_cut - 1] if long_cut else float('-inf')
            long_right_min = nums2[long_cut] if long_cut < long_count else float('inf')

            if short_left_max > long_right_min:
                hi = short_cut - 1
                continue
            if long_left_max > short_right_min:
                lo = short_cut + 1
                continue

            # 3. 合法分割下按总长度奇偶读取中位数
            left_max = max(short_left_max, long_left_max)
            if (short_count + long_count) % 2:
                return left_max
            right_min = min(short_right_min, long_right_min)
            return (left_max + right_min) / 2

        raise ValueError("未找到合法分割点")
5 · 复杂度

时间 O(log(min(m,n))),空间 O(1)。只在较短数组上二分分割点;用哨兵 ±inf 处理边界,无额外空间。

6 · 测试用例

功能[1,3],[2] → 2.0(奇),[1,2],[3,4] → 2.5(偶);边界:一个为空 [],[1] → 1.0,[2],[] → 2.0;特殊:不重叠 [1,2],[3,4,5,6] → 3.5,全相同 [1,1],[1,1] → 1.0,负数 [-5,3],[-2,7] → 0.5。

栈是"后进先出"的容器,两大套路:一是括号/配对匹配(LC 20,遇左入栈、遇右出栈对消);二是单调栈——维护一个单调递增/递减的栈,用来 O(n) 求"下一个更大/更小元素"(LC 739 每日温度、LC 84 柱状图、以及经典 LC 42 接雨水都是同一副骨架)。此外栈还能靠"辅助栈"携带附加信息(LC 155 最小栈同步存最小值),或做"递归的显式化"处理嵌套结构(LC 394 字符串解码)。它和 DFS 关联紧密:递归本质就是系统调用栈,回溯 = DFS + 栈式的"入/撤销"。

有效的括号

LC 20 ↗栈 · Easy
1 · 题目

要完成的任务

给你一个只包含圆括号、方括号和花括号的字符串 s,判断括号是否有效。

“有效”是什么意思

每个左括号必须由同类型的右括号闭合;左括号必须按正确顺序闭合;每个右括号都必须有对应的同类型左括号。

示例 1

输入:s="()"

输出:true

示例 2

输入:s="()[]{}"

输出:true

示例 3

输入:s="(]"

输出:false

圆括号不能由方括号闭合。

示例 4

输入:s="([])"

输出:true

示例 5

输入:s="([)]"

输出:false

闭合顺序错误:读到 ) 时,栈顶仍是 [

约束与保证

  • 1 ≤ s.length ≤ 10⁴
  • s 仅由 ()[]{} 这六种括号字符组成。
2 · 思路(暴力→最优)

暴力:反复扫描并删除相邻的合法括号对,直到不能删除;每轮都可能重建字符串,最坏 O(n²)。

正解 · 待闭合括号栈:
① 核心规律:最近出现且尚未闭合的左括号必须最先被右括号匹配,正好符合后进先出。
② 合法样例:([]) 依次压入 (、[,读到 ] 弹出 [,读到 ) 弹出 (,最终空栈。
③ 失败边界:([)] 读到 ) 时栈顶是 [,类型不匹配;] 起手时栈为空,必须短路而不能 pop。
④ 优化取舍:每个字符至多入栈、出栈一次,时间 O(n)、空间 O(n),无需修改原字符串。

⚠ 易错坑 右括号到来时先判空,利用短路避免空栈 pop。
遍历结束还要检查栈为空,否则剩余左括号未闭合。
必须匹配括号类型,不能只判断左右方向。
3 · 图解
栈底在左、栈顶在右;栈里只保存“仍待闭合”的左括号。

合法 s="([])"
读入前   []                     空栈
读 '('   [(]                    左括号入栈
读 '['   [(, []                 左括号入栈
读 ']'   [(]                    弹出 [,与 ] 匹配
读 ')'   []                     弹出 (,与 ) 匹配
结束     [] → True              没有未闭合括号

错序 s="([)]"
[] → [(] → [(, [] → 读 ')' 时弹出 [,但期望 ( → False

缺左括号 s="]"
读到 ] 时栈为空,or 短路,绝不会执行 stack.pop() → False
合法示例 "([])":每个字符处理后记录一次稳定栈
阶段字符动作栈(底→顶)判断
初始化建立空栈[]继续
字符 1(左括号入栈[(]继续
字符 2[左括号入栈[(, []继续
字符 3]弹出 [,匹配成功[(]继续
字符 4)弹出 (,匹配成功[]继续
结束检查栈为空[]返回 True
错序示例 "([)]":第一次不匹配立即失败
阶段字符动作栈(底→顶)判断
初始化建立空栈[]继续
字符 1(入栈[(]继续
字符 2[入栈[(, []继续
字符 3)弹出 [,但 matching_left[')'] 是 ([(]返回 False
停止] 未读取函数已返回不会继续
边界示例 "]":空栈保护 pop
阶段字符stack 判定动作结果
初始化stack=[]继续
读取]not stack=True触发 or 短路失败
保护]栈为空不执行 pop()避免异常
返回无需再检查return FalseFalse
终态stack=[]函数结束False
4 · 代码
class Solution:
    def isValid(self, s: str) -> bool:
        matching_left = {")": "(", "]": "[", "}": "{"}
        stack = []

        # 1. 左括号登记为待闭合;右括号只和最近的左括号匹配
        for char in s:
            if char not in matching_left:
                stack.append(char)
                continue

            # 2. 先判空再 pop,利用短路保护空栈
            if not stack or stack.pop() != matching_left[char]:
                return False

        # 3. 剩余左括号代表仍有结构未闭合
        return not stack
5 · 复杂度

时间 O(n),每个字符入栈/出栈各一次;空间 O(n),最坏全是左括号。

6 · 测试用例

功能"()[]{}"→true,"([)]"→false;边界:单字符 "("→false(收尾栈非空)、")"→false(开头栈空);特殊:全左括号 "((("→false,正确嵌套 "{[()]}"→true。

最小栈

LC 155 ↗栈 · Medium
1 · 题目

要完成的任务

设计一个支持压栈、弹栈、读取栈顶和读取最小元素的数据结构 MinStack,并让每个操作都在 O(1) 时间内完成。

接口含义

  • MinStack() 初始化栈。
  • push(val) 将元素 val 压入栈中。
  • pop() 删除栈顶元素。
  • top() 获取栈顶元素。
  • getMin() 获取栈中的最小元素。

示例

输入操作:["MinStack","push","push","push","getMin","pop","top","getMin"]

输入参数:[[],[-2],[0],[-3],[],[],[],[]]

输出:[null,null,null,null,-3,null,0,-2]

依次压入 -2、0、-3 后最小值是 -3;弹出 -3 后栈顶是 0,最小值恢复为 -2。

约束与保证

  • -2³¹ ≤ val ≤ 2³¹-1
  • poptopgetMin 总是在非空栈上调用。
  • pushpoptopgetMin 的总调用次数最多为 3×10⁴
2 · 思路(暴力→最优)

暴力:getMin 时遍历整个数据栈,查询 O(n),不满足题目要求的 O(1)。

正解 · 同步最小值栈:
① 核心规律:minimums[i] 保存数据栈前缀 values[0..i] 的最小值;两个栈等长,因此最小值栈顶永远对应当前数据范围。
② 样例落地:压入 -2、0、-3 后,最小值栈为 [-2,-2,-3];弹出 -3 时两栈同步回退,当前最小值自动恢复为 -2。
③ 重复最小值:本实现保持两栈等长,因此每次 push 都同步压入前缀最小值。若改用“只记录新最小值”的压缩辅助栈,也必须在 value<=current_minimum 时压入,不能漏掉重复最小值。
④ 优化取舍:四个公开操作都为 O(1),额外空间 O(n)。差值单栈可减少常数,但边界和可读性更差。

⚠ 易错坑 第一次 push 时最小值栈为空,当前值直接成为最小值。
pop 必须让两个栈同步弹出。
压缩辅助栈若只在严格更小时压入,会在重复最小值场景出错;条件至少要包含相等。
3 · 图解
values 保存真实元素;minimums 与它等长,保存对应前缀最小值。

里程碑0|初始化
values=[]                  minimums=[]

里程碑1|push(-2)
values=[-2]                minimums=[-2]

里程碑2|继续 push(0)、push(-3)
values=[-2,0]              minimums=[-2,-2]
values=[-2,0,-3]           minimums=[-2,-2,-3]
重复规则:新最小值 = min(新值, 旧 minimums 栈顶)

里程碑3|getMin()
两个栈均不变,直接读取 minimums[-1] = -3

里程碑4|pop() 必须同步
values:   [-2,0,-3] → [-2,0]
minimums: [-2,-2,-3] → [-2,-2]

里程碑5|再次 getMin()
minimums[-1] = -2;辅助栈已自动恢复上一范围的最小值。
双栈同步:一次公开操作一行,保留操作后的稳定状态
阶段操作valuesminimums返回/说明
初始化MinStack()[][]两栈对齐
1push(-2)[-2][-2]首个最小值
2push(0)[-2,0][-2,-2]最小值仍为 -2
3push(-3)[-2,0,-3][-2,-2,-3]最小值更新为 -3
4getMin()不变不变-3
5pop()[-2,0][-2,-2]两个栈同步弹出
6getMin()不变不变-2
4 · 代码
class MinStack:
    def __init__(self):
        # 两个栈始终等长,minimums[i] 对应 values[0..i] 的最小值
        self.values = []
        self.minimums = []

    def push(self, value: int) -> None:
        self.values.append(value)

        # 每次都同步保存前缀最小值,包括重复最小值
        current_minimum = (
            value if not self.minimums
            else min(value, self.minimums[-1])
        )
        self.minimums.append(current_minimum)

    def pop(self) -> None:
        # 同步回退数据范围及其对应最小值
        self.values.pop()
        self.minimums.pop()

    def top(self) -> int:
        return self.values[-1]

    def getMin(self) -> int:
        return self.minimums[-1]
5 · 复杂度

时间 每个操作 O(1);空间 O(n),额外一个等长辅助栈。

6 · 测试用例

功能:push 序列后 getMin 正确随 pop 回退;边界:只 push 一个元素后 getMin/top 一致;特殊:存在重复最小值(push -2,-2 后 pop 一次,getMin 仍为 -2,故辅助栈必须存值而非仅存一次最小)。

字符串解码

LC 394 ↗栈 · Medium
1 · 题目

要完成的任务

给你一个经过编码的字符串 s,返回它解码后的字符串。编码规则是 k[encoded_string]:方括号内的字符串要恰好重复 k 次,编码可以嵌套。

输入保证

k 是正整数;输入没有多余空格,方括号配对有效。原始字符串不含数字,所有数字都只表示重复次数,因此不会出现 3a2[4] 这类形式。

示例 1

输入:s="3[a]2[bc]"

输出:"aaabcbc"

示例 2

输入:s="3[a2[c]]"

输出:"accaccacc"

先把内层 2[c] 解成 cc,再把 acc 重复 3 次。

示例 3

输入:s="2[abc]3[cd]ef"

输出:"abcabccdcdcdef"

示例 4

输入:s="abc3[cd]xyz"

输出:"abccdcdcdxyz"

约束与保证

  • 1 ≤ s.length ≤ 30
  • s 由小写英文字母、数字和方括号组成,且保证是有效输入。
  • 所有整数都在 [1,300] 范围内。
  • 解码后字符串长度不超过 10⁵
2 · 思路(暴力→最优)

暴力:反复寻找最内层括号、展开并替换回字符串,需要多次扫描和重建,嵌套较深时开销大且状态混乱。

正解 · 保存外层现场:
① 核心规律:进入 [ 前,把外层已构造文本和重复次数压栈;遇到 ] 时,最近保存的现场先恢复,符合嵌套的后进先出。
② 样例落地:处理 3[a2[c]] 时先保存 ("",3),再保存 ("a",2);内层结束得到 "acc",外层结束得到 "accaccacc"
③ 字符规则:数字按十进制累积以支持 10[a];普通字母追加到当前文本;右括号执行 outer + repeat·current
④ 优化取舍:控制流程只扫描输入一次,但 Python 字符串不可变,追加与分层展开会复制文本;设输出长度为 N,本实现最坏 O(n·N),空间 O(n+N)。若改用字符列表分段累积,可降低纯文本反复追加的常数与复制开销。

⚠ 易错坑 遇左括号保存现场后,要重置当前文本和重复次数。
数字可能多位,不能逐位覆盖。
拼接顺序是外层前缀在前、重复后的内层在后。
3 · 图解
s="3[a2[c]]",frames 保存进入内层前的 (outer_text, repeat_count)。

里程碑0|读取 "3["
repeat_count=3
遇 [ 后:frames=[("",3)],current_text="",repeat_count=0

里程碑1|读取 "a2["
current_text 先变为 "a",repeat_count=2
遇 [ 后:frames=[("",3),("a",2)],current_text=""

里程碑2|读取 "c"
frames 不变,current_text="c"

里程碑3|关闭内层 "]"
弹出 ("a",2)
current_text = "a" + 2*"c" = "acc"
frames=[("",3)]

里程碑4|关闭外层 "]"
弹出 ("",3)
current_text = "" + 3*"acc" = "accaccacc"
frames=[],解析完成。
按输入字符循环:每行保留该字符处理后的解析现场
字符阶段frames(底→顶)current_textrepeat_count
3数字累积[]""3
[保存外层并重置[("",3)]""0
a追加字符[("",3)]"a"0
2数字累积[("",3)]"a"2
[保存外层并重置[("",3),("a",2)]""0
c追加字符[("",3),("a",2)]"c"0
]结算内层[("",3)]"acc"0
]结算外层[]"accaccacc"0
结束返回结果[]"accaccacc"0
4 · 代码
class Solution:
    def decodeString(self, s: str) -> str:
        frames = []
        current_text = ""
        repeat_count = 0

        # 1. 单次扫描按字符类型更新当前解析状态
        for char in s:
            if char.isdigit():
                repeat_count = repeat_count * 10 + int(char)

            elif char == "[":
                # 2. 进入内层前保存外层文本与重复次数
                frames.append((current_text, repeat_count))
                current_text = ""
                repeat_count = 0

            elif char == "]":
                # 3. 内层结束时恢复最近现场并完成一层展开
                outer_text, times = frames.pop()
                current_text = outer_text + times * current_text

            else:
                current_text += char

        return current_text
5 · 复杂度

设输入长度为 n、最终输出长度为 N。时间 本 Python 字符串实现最坏 O(n·N):cur += c 与每次闭括号拼接都会复制不可变字符串,字符可能在多层中被重复复制(长纯文本时即可能退化为 O(N²))。空间 O(n+N),来自嵌套栈与已构造字符串。

6 · 测试用例

功能"3[a]2[bc]"→"aaabcbc";边界:无括号 "abc"→"abc";特殊:深嵌套 "2[a2[b]]"→"abbabb"、多位数 "10[a]"→10 个 a(验证 num*10 逻辑)。

每日温度

LC 739 ↗栈 · Medium
1 · 题目

要完成的任务

给你一个整数数组 temperatures,其中 temperatures[i] 表示第 i 天的温度。返回数组 answeranswer[i] 表示还要等几天才会出现严格更高的温度;若之后都不会升温,则该位置为 0。

返回值要注意什么

答案记录的是“相隔天数”,不是下一次更高温度的值或下标;温度相同不算更暖。

示例 1

输入:temperatures=[73,74,75,71,69,72,76,73]

输出:[1,1,4,2,1,1,0,0]

示例 2

输入:temperatures=[30,40,50,60]

输出:[1,1,1,0]

示例 3

输入:temperatures=[30,60,90]

输出:[1,1,0]

约束与保证

  • 1 ≤ temperatures.length ≤ 10⁵
  • 30 ≤ temperatures[i] ≤ 100
2 · 思路(暴力→最优)

暴力:每一天都向后扫描第一个更高温,最坏 O(n²)。

正解 · 单调递减下标栈:
① 核心规律:栈保存“仍未等到更高温”的下标,对应温度从栈底到栈顶单调不增;当前温度一旦更高,就能结算所有更矮的栈顶日。
② 样例落地:前 75℃ 留在栈底;到第 5 天 72℃ 时连续结算 69℃、71℃,到第 6 天 76℃ 时再结算 72℃、75℃,一个当前值会触发多次弹栈。
③ 严格条件:只有 stack_top_temperature < current_temperature 才弹;相等不算更高,结尾仍在栈中的天数答案保持 0。
④ 优化取舍:每个下标入栈、出栈至多一次,时间 O(n)、空间 O(n)。存下标而非温度,才能计算距离。

⚠ 易错坑 栈必须存下标,答案需要当前位置差。
弹栈条件严格小于,相等温度不能结算。
必须用 while 连续弹栈,不能只弹一次。
3 · 图解
temperatures=[73,74,75,71,69,72,76,73]
栈保存 unresolved_days,下标对应温度单调不增。

里程碑0|开始
unresolved_days=[],answer=[0,0,0,0,0,0,0,0]

里程碑1|前三天连续升温
day0 入栈;day1 的74结算 day0→1天;day2 的75结算 day1→1天
栈=[2:75],answer=[1,1,0,0,0,0,0,0]

里程碑2|低温谷入栈
day3=71、day4=69 均不能结算75
栈=[2:75,3:71,4:69]

里程碑3|day5=72 连续结算
pop day4 → answer[4]=1
pop day3 → answer[3]=2
栈=[2:75,5:72],answer=[1,1,0,2,1,0,0,0]

里程碑4|day6=76 再连续结算
pop day5 → 1天;pop day2 → 4天
栈=[6:76],answer=[1,1,4,2,1,1,0,0]

里程碑5|day7=73 与结束
73 不高于栈顶76,入栈;栈内 day6、day7 后面都无更高温
最终 answer=[1,1,4,2,1,1,0,0]
每一天一行:同一天触发的连续弹栈合并展示
天/温度本日结算unresolved_daysanswer 快照
0 / 73[0:73][0,0,0,0,0,0,0,0]
1 / 74day0→1 天[1:74][1,0,0,0,0,0,0,0]
2 / 75day1→1 天[2:75][1,1,0,0,0,0,0,0]
3 / 71[2:75,3:71]不变
4 / 69[2:75,3:71,4:69]不变
5 / 72day4→1;day3→2[2:75,5:72][1,1,0,2,1,0,0,0]
6 / 76day5→1;day2→4[6:76][1,1,4,2,1,1,0,0]
7 / 73[6:76,7:73]不变
结束栈内下标保持 0[6:76,7:73][1,1,4,2,1,1,0,0]
4 · 代码
class Solution:
    def dailyTemperatures(
        self,
        temperatures: list[int]
    ) -> list[int]:
        answer = [0] * len(temperatures)
        unresolved_days = []

        # 1. 栈内下标对应的温度保持单调不增
        for current_day, current_temperature in enumerate(temperatures):
            # 2. 当前更高温会连续结算所有更矮的栈顶日
            while (unresolved_days
                    and temperatures[unresolved_days[-1]]
                    < current_temperature):
                previous_day = unresolved_days.pop()
                answer[previous_day] = current_day - previous_day

            unresolved_days.append(current_day)

        # 3. 仍在栈中的日期没有更高温,答案保留 0
        return answer
5 · 复杂度

时间 O(n),每个下标入栈出栈各一次(均摊);空间 O(n),栈最坏存全部下标(温度递减时)。

6 · 测试用例

功能[73,74,75,71,69,72,76,73][1,1,4,2,1,1,0,0]边界:单元素→[0]特殊:严格递减 [5,4,3][0,0,0]、全相同 [70,70,70][0,0,0](用严格 < 保证相等不结算)。

柱状图中最大的矩形

LC 84 ↗栈 · Hard
1 · 题目

要完成的任务

给你一个整数数组 heights,其中每个元素表示柱状图中一根宽度为 1 的柱子的高度。求能够在柱状图中勾勒出的最大矩形面积。

输入与面积

相邻柱子紧贴排列;一个矩形可以跨越连续若干根柱子,其高度不能超过这些柱子的最小高度,面积等于“连续宽度 × 可用高度”。

示例 1

输入:heights=[2,1,5,6,2,3]

输出:10

下标 2 和 3 的两根柱子高度为 5、6,可取高度 5、宽度 2 的矩形,面积为 10。

柱状图高度 2、1、5、6、2、3 高度为 5 的矩形横跨下标 2 到 3,最大面积为 10。 2 1 5 6 2 3 最大面积 5×2=10
最高面积矩形横跨高度 5 和 6 的两根柱子

示例 2

输入:heights=[2,4]

输出:4

柱状图高度 2、4 两根柱高分别为 2 和 4,最大矩形面积为 4。 2 4
单独选择高度为 4 的柱子即可得到面积 4

约束与保证

  • 1 ≤ heights.length ≤ 10⁵
  • 0 ≤ heights[i] ≤ 10⁴
2 · 思路(暴力→最优)

暴力:以每根柱为高度向左右扫描到第一根更矮柱,每根都可能扫描整个数组,最坏 O(n²)。

正解 · 单调不降下标栈:
① 核心规律:栈内对应高度保持不降;当前柱严格更矮时,它是被弹柱右侧第一根更矮柱,因此该柱的右扩范围此刻确定。
② 宽度公式:弹出后从新栈顶后一格到当前柱前一格都不低于被弹高度,宽度为 current_index-new_top_index-1。若新栈顶与被弹柱同高,更早的同高柱会在之后覆盖更宽范围,最大值不会遗漏。
③ 样例落地:[2,1,5,6,2,3] 中遇到高度 2 时连续弹出 6、5,分别得到面积 6 与 10;尾哨兵 0 再把剩余柱全部结算。
④ 优化取舍:首哨兵提供永不为空的左边界,尾哨兵统一收尾;每个下标至多入栈、出栈一次,时间 O(n)、空间 O(n)。

⚠ 易错坑 栈存下标,宽度依赖左右边界位置。
宽度公式末尾必须减 1。
尾哨兵不能漏,否则递增结尾不会被结算。
3 · 图解
heights=[2,1,5,6,2,3]
extended_heights=[0,2,1,5,6,2,3,0],栈存高度不降的下标。

里程碑0|首哨兵与第一根柱
stack=[0] → 处理 h=2 后 stack=[0,1],best_area=0

里程碑1|i=2,h=1 确定柱2的边界
pop index1:高2,宽=2-0-1=1,面积2
stack=[0,2],best_area=2

里程碑2|i=3、4 连续增高
依次压入高度5、6
stack=[0,2,3,4],尚未确定它们的右边界

里程碑3|i=5,h=2 连续结算
pop index4:6×1=6
pop index3:5×2=10
stack=[0,2,5],best_area=10

里程碑4|尾哨兵统一收尾
i=6,h=3 入栈;i=7,h=0 依次结算面积3、8、6
所有柱都已弹出,best_area 保持10。

最大矩形覆盖原下标2..3的高度5、6:min=5,宽2,面积10。
每个下标一行:同一当前柱触发的连续弹栈合并展示
i / 高度结算矩形nondecreasing_stackbest_area
初始化 / 0[0]0
1 / 2[0,1]0
2 / 12×1=2[0,2]2
3 / 5[0,2,3]2
4 / 6[0,2,3,4]2
5 / 26×1=6;5×2=10[0,2,5]10
6 / 3[0,2,5,6]10
7 / 03×1=3;2×4=8;1×6=6[0,7]10
结束返回最大值[0,7]10
4 · 代码
class Solution:
    def largestRectangleArea(self, heights: list[int]) -> int:
        # 1. 首哨兵提供左边界,尾哨兵统一触发剩余结算
        extended_heights = [0] + heights + [0]
        nondecreasing_stack = [0]
        best_area = 0

        # 2. 当前柱更矮时,被弹柱的左右边界同时确定
        for current_index in range(1, len(extended_heights)):
            while (extended_heights[nondecreasing_stack[-1]]
                    > extended_heights[current_index]):
                height = extended_heights[nondecreasing_stack.pop()]
                width = current_index - nondecreasing_stack[-1] - 1
                best_area = max(best_area, height * width)

            nondecreasing_stack.append(current_index)

        return best_area
5 · 复杂度

时间 O(n),每个下标入栈出栈各一次;空间 O(n),单调栈。

6 · 测试用例

功能[2,1,5,6,2,3]→10;边界:单柱 [5]→5、空[]→0;特殊:递增 [1,2,3]→4(高 2 宽 2)、全相同 [3,3,3]→9、递减 [3,2,1]→4。

堆(优先队列)能在 O(log n) 内取出/维护极值,是解决 TopK动态数据流问题的利器。两大套路:一是固定大小的堆求 TopK——求第 K 大用大小为 K 的小顶堆(LC 215、347),堆顶就是第 K 大/第 K 高频;二是对顶双堆求中位数(LC 295,大顶堆存较小一半、小顶堆存较大一半)。堆和排序、快速选择互为替代:TopK 也能用快速选择(quickselect,平均 O(n),与"分治"关联);而"每步取当前最优"的思路又和贪心相通(Dijkstra、任务调度都靠堆做贪心选择)。Python 用 heapq,它是小顶堆,求大顶堆时对值取负。

数组中的第K个最大元素

LC 215 ↗堆 · Medium
1 · 题目

要完成的任务

给定整数数组 nums 和整数 k,返回把数组按从大到小排列后位于第 k 位的元素。重复值要分别占据位次,因此求的是“第 k 大”,不是“第 k 个不同的值”。题目要求设计并实现时间复杂度为 O(n) 的算法;下文的随机快速选择以期望 O(n) 时间对应这一要求。

示例 1

输入:nums=[3,2,1,5,6,4]k=2

输出:5

降序前两位是 6、5,所以第 2 大是 5。

示例 2

输入:nums=[3,2,3,1,2,4,5,5,6]k=4

输出:4

两个 5 会占据两个位次,不能先去重。

约束

  • 1 ≤ k ≤ nums.length ≤ 105
  • -104 ≤ nums[i] ≤ 104
2 · 思路(暴力→最优)

暴力:整个数组排序后取倒数第 k 个——O(n log n)。但"第 k 大"只需知道"最大的 k 个里最小的那个",把 n 个数全排序是浪费。

正解①:小顶堆维护 Top-K。
① 只留 k 个候选:堆中始终保存目前见过的最大 k 个数。
② 超量就淘汰最小值:新数入堆后若大小超过 k,弹出堆顶;小顶堆的堆顶正好是最没资格留下的数。
③ 最后读取守门员:遍历结束,堆顶是“最大 k 个数中最小的一个”,也就是第 k 大。

为什么是小顶堆而不是大顶堆(最易想反)。贴着代码分两个阶段:阶段A heappush(heap, x) 把新数放进候选池;阶段B if len(heap) > k: heappop(heap) 把候选池里最弱的清掉。阶段B 弹的是守门线上最小的那个,所以要让堆顶=最小 → 小顶堆。对比钉死:大顶堆堆顶=候选里最大,一弹就把最该留的答案扔了 小顶堆堆顶=候选里最小,弹掉正合适 。回扣困惑:堆里存的是"候选集",堆顶是"下一个被踢的人",不是答案;答案要等遍历结束后才去读堆顶。[3,2,1,5,6,4], k=2 验证:依次弹掉 1、2、3、4 后堆剩 {5,6},堆顶 5 = 第 2 大,自洽。

正解② 快速选择 quickselect。借快排的 partition:一次划分把基准归位到它的最终下标 p,左侧都 ≤ 它、右侧都 > 它。"第 k 大"= 升序第 n-k 小,只要 p 命中这个目标下标就返回;否则只往 p 的一侧继续划分,无需管另一侧——期望 O(n)、最坏 O(n²),随机选基准只能降低连续退化的概率,不能消除最坏情况。

取舍数据流式、或 k 远小于 n → 用堆(O(n log k)、只占 O(k) 内存、天然支持在线);一次性求且追求均摊最快 → quickselect(平均 O(n),但会原地打乱数组、最坏 O(n²))。

⚠ 易错坑 大小顶堆别选反:求第 k 顶堆,求第 k 才用大顶堆。判据永远是"堆顶要放最该被淘汰的那个"。
Python heapq 只有小顶堆;本题要的正是小顶堆所以直接用,但若改求第 k 小需大顶堆,得对入堆的值取负模拟。
复杂度别记成 O(n log n):堆大小恒为 k,每次调整是 O(log k) 不是 O(log n),全程 O(n log k),k 小时明显优于全排序。
3 · 图解
nums = [3, 2, 1, 5, 6, 4],k=2

小顶堆:
① 读 3、2 → [2,3]          先装满 2 个候选
② 读 1   → [1,2,3] → [2,3]  1 最小,被淘汰
③ 读 5   → [2,3,5] → [3,5]  2 被淘汰
④ 读 6   → [3,5,6] → [5,6]  3 被淘汰
⑤ 读 4   → [4,5,6] → [5,6]  4 被淘汰;堆顶 5 即第 2 大

快速选择(随机基准恰好依次取 4、5 的一条可能轨迹):
一次 partition 后 4 落在下标3,只继续右区间;
再次 partition 后 5 落在目标下标4,直接返回。
解法① 小顶堆:每个输入数合并记录“入堆 + 必要时淘汰”的稳定结果
代码动作分支来源堆现场堆顶 / 弹出值
1读 3未超过 k[3]堆顶 3
2读 2刚好 k 个[2,3]堆顶 2
3读 1入堆后超量,淘汰 1[2,3]新顶 2
4读 5入堆后超量,淘汰 2[3,5]新顶 3
5读 6入堆后超量,淘汰 3[5,6]新顶 5
6读 4入堆后超量,淘汰 4[5,6]返回堆顶 5
解法② 快速选择:随机基准依次取 4、5 的一条可能轨迹
代码动作分支来源数组现场target / lo,hi / i,p
1确定目标下标第 2 大 = 升序下标 4[3,2,1,5,6,4]target=4,区间[0,5]
2第一次 partition本次基准取 4[3,2,1,4,6,5]pivot_index=3
3保留右区间3<target忽略下标 0..3区间[4,5]
4第二次 partition基准 5 落到目标位置[3,2,1,4,5,6]返回 5
4 · 代码
import heapq

class Solution:
    def findKthLargest(self, nums: list[int], k: int) -> int:
        # 1. 小顶堆只保留目前见过的最大 k 个数
        top_k = []
        for number in nums:
            heapq.heappush(top_k, number)
            if len(top_k) > k:
                heapq.heappop(top_k)

        # 2. 最大 k 个数中最小的那个,就是第 k 大
        return top_k[0]
# 解法二:快速选择 quickselect,平均 O(n)
import random

class Solution:
    def findKthLargest(self, nums: list[int], k: int) -> int:
        # 1. 第 k 大对应升序排列后的下标 len(nums)-k
        target_index = len(nums) - k
        left, right = 0, len(nums) - 1

        # 2. 每次划分后只保留包含目标下标的一侧
        while left <= right:
            pivot_index = self.partition(nums, left, right)
            if pivot_index == target_index:
                return nums[pivot_index]
            if pivot_index < target_index:
                left = pivot_index + 1
            else:
                right = pivot_index - 1

    def partition(self, nums, left, right):
        # 3. 随机选择基准,并暂存到区间末尾
        random_index = random.randint(left, right)
        nums[random_index], nums[right] = nums[right], nums[random_index]
        pivot = nums[right]

        # 4. 把不大于基准的元素集中到左侧,再让基准归位
        boundary = left
        for scan in range(left, right):
            if nums[scan] <= pivot:
                nums[boundary], nums[scan] = nums[scan], nums[boundary]
                boundary += 1
        nums[boundary], nums[right] = nums[right], nums[boundary]
        return boundary
5 · 复杂度

时间 O(n log k),n 次入堆、每次调整 O(log k);空间 O(k),堆只存 k 个。(快速选择:时间平均 O(n)、最坏 O(n²),空间 O(1)。)

6 · 测试用例

功能[3,2,3,1,2,4,5,5,6], k=4→4;边界k=1(返回最大值)、k=len(返回最小值);特殊:含重复 [3,3,3], k=2→3(按位次而非去重)、负数 [-1,-2], k=1→-1。

前K个高频元素

LC 347 ↗堆 · Medium
1 · 题目

要完成的任务

给定整数数组 nums 和整数 k,统计每个不同数字的出现次数,返回频率最高的 k 个数字。答案中的数字顺序不限;测试数据保证最终选中的元素集合唯一。

示例

示例 1:nums=[1,1,1,2,2,3]k=2,输出 [1,2]

示例 2:nums=[1]k=1,输出 [1]

示例 3:nums=[1,2,1,2,1,2,3,1,3,2]k=2,输出 [1,2]

约束与进阶

  • 1 ≤ nums.length ≤ 105-104 ≤ nums[i] ≤ 104
  • 1 ≤ k ≤ nums 中不同元素的数量。
  • k 高频元素的集合唯一;目标时间复杂度需优于 O(n log n)
2 · 思路(暴力→最优)

先用哈希表 Counter 统计每个值的频次,问题就变成"在 (频次,值) 里求频次最高的 k 个",本质与 LC215 相同,只是比较的键从"值本身"换成了"频次"暴力:把所有 (频次,值) 全排序取前 k——O(m log m),m 为不同元素数。

正解①:小顶堆维护频次 Top-K。
① 先统计频次:把原数组转换成 值 → 出现次数
② 只留 k 个候选:(频次, 值) 入小顶堆,超过 k 个就淘汰频次最低者。
③ 提取答案:遍历结束,堆中剩下的值就是前 k 个高频元素。

为什么还是小顶堆(和 LC215 同一根筋)。阶段A heappush(heap, (freq, val)) 新频次进候选;阶段B if len(heap) > k: heappop(heap) 弹掉候选里频次最低的。堆顶要放"下一个被淘汰者"=频次最低者 → 小顶堆。若用大顶堆会把最高频先弹掉,反了。拿 {1:4, 2:3, 3:2, 4:1}, k=2 走一遍:弹掉频次 2、1 的元素后堆剩 {(3,值2),(4,值1)} → 答案 [1,2],自洽。

正解② 桶排序,O(n)。频次的取值范围一定在 1..n以频次为下标建桶buckets[f] 装所有出现 f 次的值。从高频桶(下标 n)往低桶扫,取够 k 个即停——完全不用比较排序,时间 O(n),代价是 O(n) 个桶的空间。

取舍k 远小于 m 用堆(O(m log k)、内存省);追求线性、频次范围可控 用桶排序(O(n),拿空间换时间)。

⚠ 易错坑 方向别反:求"前 k 频"用顶堆(堆顶盯最低频,随时淘汰),不是大顶堆。判据仍是"堆顶=最该被淘汰的那个"。
入堆放 (freq, val) 元组,Python 按元组第一个元素 freq 比较;顺序不能写成 (val, freq),否则堆按值排就错了。
桶数组大小要开 n+1(下标 0..n),因为一个元素最多出现 n 次;从高到低扫,取够 k 个立刻返回,别扫完全部。
3 · 图解
nums = [1,1,1,1, 2,2,2, 3,3, 4],k=2
频次:1→4次,2→3次,3→2次,4→1次

小顶堆:
① 放入 (4,1)、(3,2)       → 候选值 {1,2}
② 放入 (2,3) 后超量,弹出它 → 候选仍为 {1,2}
③ 放入 (1,4) 后超量,弹出它 → 最终答案 {1,2}

桶排序:bucket[4]=[1],bucket[3]=[2];从高频向低频取两个即停。
解法① 频次小顶堆:按不同元素摘要候选集变化
代码动作分支来源堆现场堆顶 / 弹出值
1统计频次4 个不同元素[]{1:4,2:3,3:2,4:1}
2处理值 1频次 4[(4,1)]候选 {1}
3处理值 2频次 3[(3,2),(4,1)]候选 {1,2}
4处理值 3频次 2,入堆后被淘汰[(3,2),(4,1)]候选不变
5处理值 4频次 1,入堆后被淘汰[(3,2),(4,1)]返回 [2,1](顺序任意)
解法② 桶排序:按建桶与从高频取数两个阶段摘要
代码动作来源 / 扫描范围buckets 关键现场res / 结论
1统计频次Counter(nums)尚未建桶{1:4,2:3,3:2,4:1}
2按频次入桶处理全部不同元素b[4]=[1], b[3]=[2], b[2]=[3], b[1]=[4][]
3扫描频次 4最高非空桶b[4]=[1][1]
4扫描频次 3答案已取满 k 个b[3]=[2]返回 [1,2]
4 · 代码
import heapq
from collections import Counter

class Solution:
    def topKFrequent(self, nums: list[int], k: int) -> list[int]:
        # 1. 先把原数组转换为“值 → 频次”
        frequencies = Counter(nums)

        # 2. 小顶堆只保留频次最高的 k 个候选
        top_k = []
        for value, frequency in frequencies.items():
            heapq.heappush(top_k, (frequency, value))
            if len(top_k) > k:
                heapq.heappop(top_k)

        return [value for frequency, value in top_k]
# 解法二:桶排序,以"频次"为下标建桶,O(n)
from collections import Counter

class Solution:
    def topKFrequent(self, nums: list[int], k: int) -> list[int]:
        # 1. 频次最大为 len(nums),所以频次可以直接充当桶下标
        frequencies = Counter(nums)
        buckets = [[] for _ in range(len(nums) + 1)]
        for value, frequency in frequencies.items():
            buckets[frequency].append(value)

        # 2. 从高频桶向低频桶取值,答案满 k 个立即结束
        result = []
        for frequency in range(len(nums), 0, -1):
            for value in buckets[frequency]:
                result.append(value)
                if len(result) == k:
                    return result

        return result
5 · 复杂度

设 n 为数组长度、m 为不同元素数。解法① 频次小顶堆:时间 O(n + m log k)(先计数,再维护大小为 k 的堆),空间 O(m + k)=O(m)。解法② 桶排序:时间 O(n),空间 O(n+m)=O(n),其中长度 n+1 的桶数组不能省略不计。

6 · 测试用例

功能[1,1,1,2,2,3], k=2→[1,2];边界k=1→最高频单个、[1], k=1→[1];特殊:选中元素内部同频但答案集合唯一 [1,1,2,2,3], k=2→[1,2](顺序任意)、负数元素正常计数。

数据流的中位数

LC 295 ↗堆 · Hard
1 · 题目

要完成的任务

实现 MedianFinder:构造函数创建空数据结构,addNum(num) 加入一个数据流整数,findMedian() 返回目前所有数的中位数。有序列表长度为奇数时取中间值;为偶数时取中间两数的平均值,例如 [2,3,4] 的中位数是 3,[2,3] 的中位数是 2.5。

完整调用示例

输入操作:["MedianFinder","addNum","addNum","findMedian","addNum","findMedian"]

对应参数:[[],[1],[2],[],[3],[]]

输出:[null,null,null,1.5,null,2.0]

加入 1、2 后中位数为 1.5;再加入 3 后,有序序列为 [1,2,3],中位数为 2.0。null 表示构造或加入操作没有返回值。

约束与精度

  • -105 ≤ num ≤ 105
  • 调用 findMedian 前至少已经加入一个数。
  • addNumfindMedian 总调用次数不超过 5×104;误差不超过 10-5 的答案均可接受。
2 · 思路(暴力→最优)

暴力:每次 findMedian 前把所有数排序取中间——每次查询 O(n log n);或维护有序数组,插入要挪位 O(n)。数据流式、频繁查询,这都太慢。

正解:对顶双堆。中位数只和最中间的一两个数有关,因此把数据分成两半:较小的一半放大顶堆 lower_half,较大的一半放小顶堆 upper_half

每次加入一个数只做三步:
① 先放入较小半:新数进入 lower_half
② 修正大小关系:把较小半的最大值移到 upper_half,保证左半所有数都不大于右半。
③ 修正数量关系:若右半更长,再把右半最小值移回左半,最终两堆等长或左半多一个。

读取中位数:奇数个时取 lower_half 堆顶;偶数个时取两个堆顶平均。较小半使用大顶堆,是因为我们关心的是这半中最靠近中间的最大值,不是最小值。

⚠ 易错坑 Python heapq 只有小顶堆,lower_half 靠存负值模拟大顶堆;读取真实堆顶时要再次取负。
本实现允许 lower_halfupper_half 多一个,绝不允许右半更长。
偶数个时必须返回两个堆顶的平均值,不能只取其中一个。
3 · 图解
依次加入:1, 2, 3, 4
lower_half = 较小一半(大顶堆) | upper_half = 较大一半(小顶堆)

① add 1: [1]       | []       → 奇数,中位 1
② add 2: [1]       | [2]      → 偶数,中位 (1+2)/2 = 1.5
③ add 3: [2,1]     | [3]      → 奇数,中位 2
④ add 4: [2,1]     | [3,4]    → 偶数,中位 (2+3)/2 = 2.5

始终满足:左半所有数 ≤ 右半所有数;两堆等长或左半多一个。
每次 addNum 完成后记录两堆的稳定状态;内部常数次堆操作不拆成流水账
加入值本轮调整lower_half(实际值)upper_half中位数
初始两个空堆[][]
111 移到右半后再平衡回左半[↑1][]1
222 成为右半最小值[↑1][↑2]1.5
332 从右半移回左半[↑2,1][↑3]2
44两堆恢复等长[↑2,1][↑3,4]2.5
4 · 代码
import heapq

class MedianFinder:
    def __init__(self):
        # 较小一半用负数模拟大顶堆;较大一半使用普通小顶堆
        self.lower_half = []
        self.upper_half = []

    def addNum(self, num: int) -> None:
        # 1. 新数先进入左半,再把左半最大值移给右半,保证整体有序
        heapq.heappush(self.lower_half, -num)
        largest_in_lower = -heapq.heappop(self.lower_half)
        heapq.heappush(self.upper_half, largest_in_lower)

        # 2. 右半不能更长;必要时把右半最小值移回左半
        if len(self.upper_half) > len(self.lower_half):
            smallest_in_upper = heapq.heappop(self.upper_half)
            heapq.heappush(self.lower_half, -smallest_in_upper)

    def findMedian(self) -> float:
        # 3. 奇数个取左堆顶;偶数个取两个中间值的平均
        if len(self.lower_half) > len(self.upper_half):
            return -self.lower_half[0]
        return (-self.lower_half[0] + self.upper_half[0]) / 2
5 · 复杂度

时间 addNum O(log n)(常数次堆操作)、findMedian O(1);空间 O(n),两堆共存全部元素。

6 · 测试用例

功能:add 1,2,3 后 findMedian=2;边界:单元素 add 1→1、偶数个 add 1,2→1.5;特殊:乱序输入 add 5,15,1,3(中位随之更新为 4)、全相同 add 2,2,2→2、含负数。

贪心

贪心的核心信念是 "每步取局部最优,最终得到全局最优"——能贪就贪,不能贪就上 DP。判断能否贪心的关键是能否证明局部最优不会毁掉全局最优(有"贪心选择性质"和"最优子结构");证不出来往往就得退回动态规划穷举所有状态。它和 DP 是一对镜像:DP 保留所有子问题的解,贪心只保留一个当前最好的。本类别里 LC 121 买卖股票(维护历史最低价,也可视为一维 DP 的化简)、LC 55/45 跳跃游戏(维护"能到的最远边界")、LC 763 划分字母区间(维护每个字母最后出现位置的边界)都是"边扫边维护一个最优边界"的贪心典型。

买卖股票的最佳时机

LC 121 ↗贪心 · Easy
1 · 题目

要完成的任务

prices[i] 表示第 i 天的股票价格。最多完成一笔交易:选择一天买入,并在之后的另一天卖出。返回可获得的最大利润;如果所有合法交易都不赚钱,返回 0。卖出日必须晚于买入日,不能倒序交易。

示例

示例 1:输入 prices=[7,1,5,3,6,4],输出 5。第 2 天以 1 买入、第 5 天以 6 卖出,利润为 5。

示例 2:输入 prices=[7,6,4,3,1],输出 0。价格持续下降,选择不交易。

约束

  • 1 ≤ prices.length ≤ 105
  • 0 ≤ prices[i] ≤ 104
2 · 思路(暴力→最优)

暴力及局限。枚举买入日和更晚的卖出日需要两重循环,时间 O(n²)。

正解:固定卖出日,保留此前最低买价。
① 历史状态:lowest_price 保存当前日及以前的最低股价。
② 当日候选:假设今天卖出,利润就是 price-lowest_price
③ 全局更新:best_profit 汇总所有卖出日的候选最大值。
④ 合法顺序:最低价只来自今天及以前,因此不会出现未来买入、过去卖出的非法交易。

具体例子:[7,1,5,3,6,4] 中,7 建立首个最低价,1 将其改写;之后 5 得到利润 4,3 不改善,6 将最佳利润推进到 5,最后 4 仍保持。这个过程让“发现更低买点”和“刷新最佳卖点”都实际发生。

取舍。贪心把所有买入候选压成一个历史最低价,时间 O(n)、空间 O(1);它也可视为单次交易状态 DP 的化简,多次交易题则不能只保留这一标量。

⚠ 易错坑 维护的是历史最低买入价,不是历史最高价。
先更新 lowest_price 再算候选时,当天买当天卖只会贡献 0,不会产生非法负收益。
best_profit 初值必须是 0;全程下跌时选择不交易。
3 · 图解
prices=[7,1,5,3,6,4];稳定状态为 (lowest_price,best_profit):

① 初始并处理 day0: (∞,0) → (7,0)

② day1 price=1:发现更低买点 → (1,0)

③ day2..3:price=5 产生利润 4;price=3 不改善
   (1,0) → (1,4) → (1,4)

④ day4 price=6:6-1=5,刷新最佳利润 → (1,5)

⑤ day5 price=4:候选 3 不改善 → (1,5)

最低价只来自当前日及以前,因此所有候选都满足先买后卖。
贪心扫描:每个交易日一轮,合并最低价维护与当天卖出收益比较
代码动作候选来源lowest_pricebest_profit
0初始化尚未观察价格0
1day0,p=7最低价降为 7;当天利润 070
2day1,p=1发现更低买入价;利润仍 010
3day2,p=5按最低价 1 卖出可得 414
4day3,p=3候选利润 2 不改善答案14
5day4,p=6候选利润 5 刷新答案15
6day5,p=4 并返回候选利润 3,保持最佳 515
4 · 代码
class Solution:
    def maxProfit(self, prices: list[int]) -> int:
        # 1. 不交易时利润为 0;最低价等待首日写入
        lowest_price = float('inf')
        best_profit = 0

        # 2. 固定今天卖出,配对此前最低买价
        for price in prices:
            lowest_price = min(lowest_price, price)
            current_profit = price - lowest_price
            best_profit = max(best_profit, current_profit)

        return best_profit
5 · 复杂度

时间 O(n),单次遍历;空间 O(1),只存两个标量。

6 · 测试用例

功能[7,1,5,3,6,4]→5;边界:单天 [5]→0、空数组 []→0;特殊:严格递减 [7,6,4,3,1]→0(不交易)、递增 [1,2,3]→2、全相同 [3,3,3]→0。

跳跃游戏

LC 55 ↗贪心 · Medium
1 · 题目

要完成的任务

给定非负整数数组 nums,开始位于下标 0;nums[i] 是站在下标 i 时一次最多能向右跳的长度。判断是否存在某种跳法可以到达最后一个下标。

示例

示例 1:nums=[2,3,1,1,4],输出 true。可从 0 跳到 1,再由 1 跳到末尾。

示例 2:nums=[3,2,1,0,4],输出 false。任何路径都会被下标 3 的 0 卡住。

约束

  • 1 ≤ nums.length ≤ 104
  • 0 ≤ nums[i] ≤ 105
2 · 思路(暴力→最优)

暴力及局限。从下标 0 DFS/回溯枚举每种跳法会重复搜索,最坏指数级;DP 虽能用 reachable[i] 记录每格是否可达,但逐格向后标记仍需 O(n²) 时间。

正解:把整个可达集合压成最远右边界。
① 前缀结构:从任一可达位置可以选择跳 1 到 nums[i] 步,因此可达下标始终是无空洞的连续前缀。
② 不可达判定:index > farthest_reachable 表示当前格已经落到可达前缀之外,后面也无法继续扫描。
③ 扩张边界:当前格可达时,用 index + max_jump 更新 farthest_reachable,且边界只能取更大值。
④ 提前成功:边界一旦覆盖 n-1,就已经存在一条到终点的跳法。

具体例子:[3,2,1,0,4] 中,起点把边界扩到 3;下标 1、2、3 都无法继续扩张。扫描准备进入下标 4 时有 4>3,连续前缀在终点前断开,因此返回 false。这个最小非平凡过程同时展示了“边界扩张、停滞、断裂”。

取舍。贪心只保留一个右边界,时间 O(n)、空间 O(1);成立的关键是可达集合具有连续前缀结构。若每个位置只能跳固定距离、落点不连续,就不能直接套用。

⚠ 易错坑 判不可达的时机是 index > farthest_reachable,不是 nums[index]==0;如 [2,0,0] 仍可到达。
边界必须用 max 累积,不能被后面较小的跳力缩回。
单元素 [0] 起点即终点,应返回 true。
3 · 图解
nums=[3,2,1,0,4];farthest_reachable 是连续可达前缀的右端:

① 初始:farthest_reachable=0          [0] 1  2  3  4

② index=0:候选 0+3=3,扩张到       [0--1--2--3] 4

③ index=1..2:候选分别为 3、3,边界保持 [0--1--2--3] 4

④ index=3:候选 3+0=3,边界仍停在    [0--1--2--3] 4

⑤ index=4:4>farthest_reachable=3,终点不在可达前缀 → False
失败例:按循环轮次观察最远边界从扩张到停滞,最后在终点前断裂
轮次扫描位置候选边界farthest_reachable阶段结论
0初始化0起点可达
1index=00+3=33前缀扩到 [0..3]
2index=11+2=33边界不变
3index=22+1=33边界不变
4index=33+0=33扩张停滞
5index=44>3,当前格不可达3返回 False
4 · 代码
class Solution:
    def canJump(self, nums: list[int]) -> bool:
        # 1. 可达集合压缩为连续前缀的右端点
        farthest_reachable = 0

        # 2. 只有站得上当前位置,才能用它继续扩张边界
        for index, max_jump in enumerate(nums):
            if index > farthest_reachable:
                return False

            farthest_reachable = max(
                farthest_reachable, index + max_jump
            )
            if farthest_reachable >= len(nums) - 1:
                return True

        return True
5 · 复杂度

时间 O(n),一次遍历;空间 O(1),只维护 farthest_reachable

6 · 测试用例

功能[2,3,1,1,4]→true,[3,2,1,0,4]→false;边界:单元素 [0]→true(已在终点);特殊:开头为 0 且长度>1 [0,1]→false、含多个 0 但能跨越 [2,0,0]→true。

跳跃游戏 II

LC 45 ↗贪心 · Medium
1 · 题目

要完成的任务

给定长度为 n 的 0 索引整数数组 nums,开始位于下标 0。站在 i 时,可以向右跳 j 步,其中 0 ≤ j ≤ nums[i] 且不能越过数组末尾。返回到达下标 n-1 的最少跳跃次数;题目保证末尾可达。

示例

示例 1:nums=[2,3,1,1,4],输出 2,路径可为 0→1→4

示例 2:nums=[2,3,0,1,4],输出 2

约束与保证

  • 1 ≤ nums.length ≤ 1040 ≤ nums[i] ≤ 1000
  • 测试数据保证一定能到达最后一个下标。
2 · 思路(暴力→最优)

暴力及局限。从每个位置尝试所有可跳落点会形成大量重复分支;DP 用 min_jumps[i] 记录到达每格的最少步数仍需枚举转移,时间 O(n²)。

正解:把一次跳跃看作隐式 BFS 的一层。
① 当前层边界:current_end 表示用当前跳数能够覆盖到的最右下标。
② 下一层候选:扫描当前层内每个位置,以 index+nums[index] 更新 next_farthest
③ 层末结算:index==current_end,本层候选已经收齐,跳数加一,并令 current_end=next_farthest
④ 循环边界:只扫描到 n-2;终点是落点,不需要再从终点起跳。

具体例子:[2,3,1,1,4] 中,第一层只有下标 0,结算后覆盖到 2;第二层扫描下标 1、2,最远候选被下标 1 推到 4,第二次结算即覆盖终点,所以答案为 2。

取舍。贪心不保存实际路径,只保存两层的右边界,时间 O(n)、空间 O(1);若题目还要求输出某条最短跳跃路径,需要额外记录父节点或选择位置。

⚠ 易错坑 循环范围是 range(n-1)到倒数第二格就停)。若扫到最后一格 i==n-1 且它恰好 ==end,会多结算一步、答案偏大 1;到达末尾本身不需要再起跳。
jump_count+1 只在 index==current_end 时做,不是每次更新 next_farthest 都加。
单元素 [0] 返回 0(range(0) 空循环,本就在终点),别写成从 1 起步。
3 · 图解
nums=[2,3,1,1,4];状态为 (jump_count,current_end,next_farthest):

① 初始:(0,0,0);第 0 层只有起点             [0] 1  2  3  4

② 扫描 index=0,候选到 2;层末结算           [0 | 1--2] 3  4
   (0,0,2) → (1,2,2)

③ 扫描第 1 层 index=1..2,候选最远到 4       0 [1--2 | 3--4]
   index=1 给出 4,index=2 只给出 3

④ index=2 到达层末,第二次结算               0  1  2 [3--4]
   (1,2,4) → (2,4,4),current_end 已覆盖终点 → 2
贪心分层:按循环轮次汇总下一层候选,并只在当前层边界结算跳数
轮次扫描位置候选来源next_farthestjump_countcurrent_end
0初始化第 0 层只有起点000
1index=00+2=2;到达层末212
2index=11+3=4,扩张下一层412
3index=22+1=3;到达层末424
4index=33+1=4,保持边界424

循环到 index=n-2=3 结束(末尾 4 不再起跳),返回 jump_count=2

4 · 代码
class Solution:
    def jump(self, nums: list[int]) -> int:
        # 1. current_end 是本层边界,next_farthest 汇总下一层
        jump_count = 0
        current_end = 0
        next_farthest = 0

        # 2. 终点只作为落点,因此循环在 n-2 结束
        for index in range(len(nums) - 1):
            next_farthest = max(
                next_farthest, index + nums[index]
            )

            # 3. 本层扫描完毕时,统一结算一次跳跃
            if index == current_end:
                jump_count += 1
                current_end = next_farthest

        return jump_count
5 · 复杂度

时间 O(n),单次遍历(BFS 分层但不重复访问);空间 O(1)。

6 · 测试用例

功能[2,3,1,1,4]→2,[2,3,0,1,4]→2;边界:单元素 [0]→0(已在终点,循环不执行);特殊:全为 1 [1,1,1,1]→3、大步一跳到底 [5,1,1,1,1]→1。

划分字母区间

LC 763 ↗贪心 · Medium
1 · 题目

要完成的任务

把字符串 s 按原顺序切成尽可能多的连续片段,使任意一种字母最多出现在其中一个片段里。所有片段按顺序拼回去必须仍等于原字符串;返回每个片段的长度,而不是片段字符串本身。

示例

示例 1:输入 s="ababcbacadefegdehijhklij",输出 [9,7,8],对应片段为 "ababcbaca""defegde""hijhklij"。合并前两个片段虽然也合法,但片段数更少,不满足“尽可能多”。

示例 2:输入 s="eccbbbbdec",输出 [10]

约束

  • 1 ≤ s.length ≤ 500
  • s 只包含小写英文字母。
2 · 思路(暴力→最优)

暴力及局限。枚举切点组合,再检查每个字母是否跨段,既会产生指数级切法,也会重复扫描字符串;即使逐点试切也容易退化为 O(n²)。

正解:维护当前片段必须覆盖到的最远位置。
① 预处理末位:last_position[char] 记录每个字母最后一次出现的下标。
② 扩张片段:扫描到一个字符,就用它的末位更新 segment_end;当前片段必须至少延伸到这里。
③ 最早闭合:index==segment_end,片段内所有字符的末位都已被包含,此处是最早合法切点。
④ 记录并重启:加入长度 segment_end-segment_start+1,再令 segment_start=index+1

具体例子:"abac" 中,首字符 a 把边界拉到 2;扫描 b 时虽已到 b 的末位,却不能切,因为 a 还会出现。走到下标 2 才闭合长度 3,最后 c 自成长度 1,结果为 [3,1]

取舍。需要一趟预处理和一趟切分扫描,时间 O(n);小写字母表下额外空间 O(1)。若字符集不固定,映射空间应写为 O(k),其中 k 是不同字符数。

⚠ 易错坑 切分判据是 index==segment_end,不是当前字符恰好到达自己的末位。
segment_end 必须用 max 累积,不能被较小末位缩回。
切分后必须更新 segment_start=index+1,否则下一段长度会算错。
3 · 图解
s="ababcbacadefegdehijhklij";先记录每个字符的 last_position。

① 首段起点 0 遇 a:segment_end 0→8
   [0================8] 9...............23

② 扫描 1..8,b/c 的末位都不超过 8;index=8 闭合长度 9
   [0================8] | segment_start→9

③ 第二段 d/e 依次把边界推到 14、15;index=15 闭合长度 7
   0........8 [9==========15] | segment_start→16

④ 第三段 h/i/j 依次把边界推到 19、22、23
   0........15 [16=============23]

⑤ index=23 闭合长度 8;lengths=[9,7,8]
按闭合阶段归纳:每行汇总一个片段的边界扩张链与最终长度
阶段扫描范围边界来源segment_endsegment_startlengths
0预处理生成 last_position00[]
1index=0..8a:8;b/c 不再越界80→9[9]
2index=9..15d:14 → e:15159→16[9,7]
3index=16..23h:19 → i:22 → j:232316→24[9,7,8]
4返回所有片段均已闭合2324[9,7,8]
4 · 代码
class Solution:
    def partitionLabels(self, s: str) -> list[int]:
        # 1. 预处理每个字符最后一次出现的位置
        last_position = {
            char: index for index, char in enumerate(s)
        }

        # 2. 扫描时维护当前片段必须覆盖到的边界
        lengths = []
        segment_start = 0
        segment_end = 0
        for index, char in enumerate(s):
            segment_end = max(segment_end, last_position[char])

            # 3. 到达最早合法切点,记录长度并开启下一段
            if index == segment_end:
                lengths.append(segment_end - segment_start + 1)
                segment_start = index + 1

        return lengths
5 · 复杂度

时间 O(n),两次线性扫描;空间 O(1),题目限定小写字母,last_position 至多 26 项。

6 · 测试用例

功能"ababcbacadefegdehijhklij"→[9,7,8];边界:单字符 "a"→[1];特殊:全不同 "abc"→[1,1,1]、全相同 "aaaa"→[4]、首尾同字母 "abac"→[3,1](a 拉到下标2)。

动态规划

DP 的核心是把大问题拆成有重叠的子问题,用一张表记住子问题答案避免重复计算。上手任何 DP 题都问万能三问:①状态定义——dp[i] 到底表示什么("以 i 结尾"还是"前 i 个");②转移方程——dp[i] 怎么由更小的状态推出来;③base case——最小子问题的初值。本类别是一维 DP:线性递推(爬楼梯=斐波那契 LC70、打家劫舍 LC198)、背包(完全背包 LC322/279、0/1 背包 LC416)、子序列/子数组(LIS LC300、乘积最大 LC152)。它是多维 DP(LC62/64 路径、LC72 编辑距离、LC1143 LCS、LC5 回文)的降维版——多维只是把 dp[i] 换成 dp[i][j];也和贪心互为对照(能贪心时不必开表)。

爬楼梯

LC 70 ↗动态规划 · Easy
1 · 题目

要完成的任务

楼顶位于第 n 阶,每一步只能向上爬 1 阶或 2 阶。返回到达楼顶的不同步数组合数量;只要 1、2 的排列顺序不同,就算不同方法。

示例

示例 1:n=2,输出 2,分别是 1+12

示例 2:n=3,输出 3,分别是 1+1+11+22+1

约束

  • 1 ≤ n ≤ 45
2 · 思路(暴力→最优)

朴素递归及局限。直接写 ways(i)=ways(i-1)+ways(i-2) 很符合题意,但递归树会重复计算同一个台阶:求 ways(5) 的两条分支都会再次求 ways(3),时间最坏达到 O(2ⁿ)。

正解:把“到达某阶的方法数”保存下来。
① 状态含义:ways[i] 表示恰好到达第 i 阶的方法数。
② 边界状态:ways[0]=1 表示一种空走法,ways[1]=1 表示只迈一次 1 阶。
③ 转移来源:最后一步只能从 i-1 迈 1 阶,或从 i-2 迈 2 阶;两类方案互斥且完备,所以 ways[i]=ways[i-1]+ways[i-2]

用 n=5 重复套转移。
① 算第 2 阶:ways[2]=ways[1]+ways[0]=1+1=2,同时验证 ways[0]=1 这个边界。
② 算第 3 阶:ways[3]=ways[2]+ways[1]=2+1=3,同一公式开始复用已保存状态。
③ 推到目标:继续得到 ways[4]=5ways[5]=8,不再展开递归树。

空间优化与取舍。每次只读取前两格,代码用 two_steps_before/one_step_before 滚动即可把空间从 O(n) 压到 O(1);保留完整数组更便于教学,但对本题答案没有额外价值。

⚠ 易错坑 base 是 ways[0]=ways[1]=1不是 ways[0]=0——否则第 2 阶会被算成 1。
循环从第 2 阶跑到第 n 阶:初始 one_step_before 已代表 ways[1],多跑一次会算到 ways[n+1]
n=1 时循环体不执行,直接返回初值 one_step_before=1,这条边界路径必须成立。
3 · 图解
n=5;(two_steps_before, one_step_before) 对应 (ways[i-2], ways[i-1]):

起点 i=1 : (1,1) → ways 前缀 [1,1]
i=2        : (1,1) → (1,2) → [1,1,2]
i=3        : (1,2) → (2,3) → [1,1,2,3]
i=4        : (2,3) → (3,5) → [1,1,2,3,5]
i=5        : (3,5) → (5,8) → [1,1,2,3,5,8]
最终 one_step_before=8
滚动变量法:按楼梯层数概括,每轮用前两阶得到当前阶
动作currenttwo_steps_beforeone_step_beforeways 前缀
0初始化1 (=ways[0])1 (=ways[1])[1,1,_,_,_,_]
1递推 ways[2]1+1=212[1,1,2,_,_,_]
2递推 ways[3]1+2=323[1,1,2,3,_,_]
3递推 ways[4]2+3=535[1,1,2,3,5,_]
4递推 ways[5]3+5=858[1,1,2,3,5,8] → 返回
4 · 代码
class Solution:
    def climbStairs(self, n):
        # 1. 边界:分别表示 ways[0] 与 ways[1]
        two_steps_before = 1
        one_step_before = 1

        # 2. 从第 2 阶开始,只滚动保存相邻两个状态
        for _ in range(2, n + 1):
            current = two_steps_before + one_step_before
            two_steps_before, one_step_before = one_step_before, current

        # 3. n=1 时循环为空,初值也正好是答案
        return one_step_before
5 · 复杂度

时间 O(n),一趟递推;空间 O(1),只留两个变量。相比朴素递归的 O(2ⁿ) 靠"记住子问题"消除了指数级重复。

6 · 测试用例

功能n=3 → 3n=5 → 8边界n=1 → 1n=2 → 2特殊:大 n(如 45)验证不溢出、不超时(O(n) 稳过)。

杨辉三角

LC 118 ↗动态规划 · Easy
1 · 题目

要完成的任务

给定 numRows,生成杨辉三角的前 numRows 行并以二维数组返回。每行两端是 1,内部位置等于上一行左上方与右上方两个数之和。

示例 1

输入:numRows=5

输出:[[1],[1,1],[1,2,1],[1,3,3,1],[1,4,6,4,1]]

五行杨辉三角第一行是 1;之后每行两端为 1,内部数字由上一行相邻数字相加得到,依次形成 1 1、1 2 1、1 3 3 1、1 4 6 4 1。111121133114641
每个内部数字来自上一行斜上方的两个数字之和

示例 2 与约束

numRows=1 时输出 [[1]]

  • 1 ≤ numRows ≤ 30
2 · 思路(暴力→最优)

朴素构造及局限。可以按组合数公式逐格计算阶乘,但每格都重复做乘除,还要额外处理数值和边界;本题已经给出相邻行关系,直接复用上一行更自然。

正解:上一稳定行生成下一行。
① 状态含义:triangle[row][col] 表示第 row 行第 col 个数。
② 边界状态:每行首尾都固定为 1;长度 1、2 的前两行没有内部格。
③ 转移来源:内部格只读取上一行相邻两项,current_row[col_index]=previous_row[col_index-1]+previous_row[col_index]

用前五行重复生成。
① 建边界行:先得到 [1][1,1],内层循环为空,覆盖短行边界。
② 生成第 2 行:唯一内部格为 1+1=2,得到 [1,2,1]
③ 再复用一层:[1,2,1] 生成 [1,3,3,1],说明同一转移可连续应用。
④ 推到目标行:继续生成 [1,4,6,4,1],完整三角形即答案。

空间取舍。本题必须返回所有行,因此结果本身占 O(numRows²);若只求最后一行,才值得改成一维倒序更新。实现上每行先填 1,只计算内部格,可把边界逻辑集中处理。

⚠ 易错坑 内部列范围是 range(1, row_index),末位保持为 1;扩大到 row_index+1 会越界读取上一行。
转移引用的是 previous_row 而非正在写的 current_row;当前行稳定后才追加进 triangle
row_index=0/1 时内部列范围为空,会直接得到 [1][1,1]
3 · 图解
numRows=5;每轮从 previous_row 生成 current_row:

triangle=[]
  ↓ 追加 row0
            1
  ↓ 两端固定 1
          1   1
  ↓ 中间:1+1
        1   2   1
  ↓ 中间:1+2,2+1
      1   3   3   1
  ↓ 中间:1+3,3+3,3+1
    1   4   6   4   1

每一层只读取 previous_row;current_row 完成后才追加进 triangle。
numRows=5:按“生成一整行”归纳,每行展示来源与追加后的稳定三角形
代码动作转移来源current_rowtriangle
0初始化尚无上一行[]
1生成行 0首行只有边界 1[1][[1]]
2生成行 1两端固定为 1,无内部格[1,1]…,[1,1]
3生成行 2内部值 1+1=2[1,2,1]…,[1,2,1]
4生成行 3内部值 1+2=3,2+1=3[1,3,3,1]…,[1,3,3,1]
5生成行 4 并完成内部值 4、6、4[1,4,6,4,1]完整 5 行 → 返回
4 · 代码
class Solution:
    def generate(self, numRows):
        triangle = []

        # 1. 每轮先建立首尾为 1 的当前行
        for row_index in range(numRows):
            current_row = [1] * (row_index + 1)

            # 2. 只计算内部格;短行的范围自然为空
            for col_index in range(1, row_index):
                previous_row = triangle[row_index - 1]
                current_row[col_index] = (
                    previous_row[col_index - 1] + previous_row[col_index]
                )

            # 3. 当前行稳定后再追加,供下一轮读取
            triangle.append(current_row)

        return triangle
5 · 复杂度

时间 O(numRows²),三角形总格子数约 n²/2;空间 O(numRows²) 存结果(若不计输出本身则 O(1) 额外空间)。

6 · 测试用例

功能numRows=5 → [[1],[1,1],[1,2,1],[1,3,3,1],[1,4,6,4,1]]边界numRows=1 → [[1]]特殊numRows=2 → [[1],[1,1]](还没有中间元素,验证内层循环 range(1,1) 不执行)。

打家劫舍

LC 198 ↗动态规划 · Medium
1 · 题目

要完成的任务

nums[i] 是沿街第 i 间房里的金额。同一晚不能选择两间相邻房屋,否则会触发报警。返回在满足这一限制下能取得的最大总金额。

示例

示例 1:nums=[1,2,3,1],输出 4,选择金额 1 和 3。

示例 2:nums=[2,7,9,3,1],输出 12,选择 2、9、1。

约束

  • 1 ≤ nums.length ≤ 100
  • 0 ≤ nums[i] ≤ 400
2 · 思路(暴力→最优)

朴素枚举及局限。每家都选“偷/不偷”,再过滤相邻冲突,会产生 O(2ⁿ) 个选择分支;不同分支又会反复求同一个房屋前缀的最优收益。

正解:只保存相邻两个前缀最优值。
① 状态含义:best[i] 表示考虑到第 i 家时能得到的最大金额。
② 两个来源:不偷当前家就沿用 best[i-1];偷当前家必须跳过相邻家,得到 best[i-2]+nums[i]
③ 状态转移:best[i]=max(best[i-1], best[i-2]+nums[i]),两种来源互斥且覆盖全部最优选择。

用 [2,7,9,3,1] 验证重复转移。
① 前两家:处理 2 得 2,处理 7 时在“保留 2”和“偷 7”间选 7。
② 第三家翻盘:处理 9 时比较 72+9=11,选择隔家组合。
③ 第四家不偷:max(11,7+3)=11,说明状态不必每轮增长。
④ 最后一家再组合:max(11,11+1)=12,得到最终答案。

空间优化与取舍。转移只依赖前两项,用 two_houses_before/one_house_before 滚动即可降为 O(1) 空间;完整数组只在需要恢复具体偷窃方案时才有价值。

⚠ 易错坑 “偷当前”必须接 best[i-2],写成 best[i-1]+nums[i] 会允许偷相邻两家。
two_houses_beforeone_house_before 都从 0 开始,第一次比较会自然得到首家金额。
必须先算 current_best,再滚动两个旧状态;提前覆盖会污染本轮来源。
3 · 图解
nums=[2,7,9,3,1];(two_houses_before, one_house_before) 保存相邻两个前缀最优值

未看房屋 : (0,0)                         best 前缀 []
看 2      : max(0,0+2)=2   → (0,2)       [2]
看 7      : max(2,0+7)=7   → (2,7)       [2,7]
看 9      : max(7,2+9)=11  → (7,11)      [2,7,11]
看 3      : max(11,7+3)=11 → (11,11)     [2,7,11,11]
看 1      : max(11,11+1)=12→ (11,12)     [2,7,11,11,12]
                                               最优金额 12
滚动变量法:每家一轮,把“偷/不偷”决策与滚动后的稳定状态合并展示
代码动作候选:不偷 vs 偷two_houses_beforeone_house_beforecurrent_best
0初始化尚未看房屋00
1处理金额 2max(0,0+2)=2(偷)022
2处理金额 7max(2,0+7)=7(偷)277
3处理金额 9max(7,2+9)=11(偷)71111
4处理金额 3max(11,7+3)=11(不偷)111111
5处理金额 1 并完成max(11,11+1)=12(偷)111212 → 返回
4 · 代码
class Solution:
    def rob(self, nums):
        # 1. 两个局部状态分别对应 best[i-2] 与 best[i-1]
        two_houses_before = 0
        one_house_before = 0

        # 2. 每家比较“不偷当前”与“偷当前”两种来源
        for money in nums:
            current_best = max(
                one_house_before,
                two_houses_before + money,
            )
            two_houses_before, one_house_before = (
                one_house_before,
                current_best,
            )

        return one_house_before
5 · 复杂度

时间 O(n),一趟;空间 O(1),两个滚动变量。

6 · 测试用例

功能[2,7,9,3,1] → 12[1,2,3,1] → 4边界:单家 [5] → 5、空 [] → 0特殊:两家取大 [2,1] → 2、全相同 [3,3,3] → 6(隔一家偷)。

完全平方数

LC 279 ↗动态规划 · Medium
1 · 题目

要完成的任务

给定正整数 n,把它写成若干完全平方数之和,返回所需项数的最小值。完全平方数是整数的平方,例如 1、4、9、16;同一个平方数可以重复使用。

示例

示例 1:n=12,输出 3,因为 12=4+4+4

示例 2:n=13,输出 2,因为 13=4+9

约束

  • 1 ≤ n ≤ 104
2 · 思路(暴力→最优)

朴素 DFS 及局限。每层尝试减去一个平方数,直到余数为 0;同一余数会沿不同拆法反复出现,分支数指数增长。

正解:把“最后使用哪个平方数”转成转移。
① 状态含义:minimum_count[value] 表示凑出 value 的最少平方数个数。
② 边界状态:minimum_count[0]=0;其余先设为无穷,避免未计算状态伪装成最优值。
③ 转移来源:枚举所有 square≤value,最后用掉它时,候选为 minimum_count[value-square]+1

用 n=5 重复比较候选。
① 先处理小值:1、2、3 只能反复使用平方数 1,状态依次为 1、2、3。
② 关键值 4:候选既有 minimum_count[3]+1=4,也有 minimum_count[0]+1=1,选择后者。
③ 再处理 5:使用 1 或 4 都得到 2,说明同一转移在新状态上继续复用。

取舍。这是完全背包式最少个数 DP,平方数可以重复使用;内层只枚举到 √value,总时间 O(n√n)、空间 O(n)。数论解法更快,但 DP 更直接展示状态来源。

⚠ 易错坑 非 base 格必须初始化为 inf(不是 0):初值 0 会让 min 永远取到假的 0,得出错误答案。
枚举条件是 square_root*square_root≤value,否则会访问 minimum_count 的负下标。
转移里的 +1 代表本次使用的平方数;minimum_count[value-square] 必须是已算好的更小状态。
3 · 图解
n=5,minimum_count[value]=凑出 value 的最少平方数个数

里程碑 1|初态
[0,∞,∞,∞,∞,∞]

里程碑 2|处理完 value=1、2
[0,1,2,∞,∞,∞]              只能使用平方数 1

里程碑 3|处理完 value=3
[0,1,2,3,∞,∞]              同一转移再次复用

里程碑 4|处理 value=4
候选:minimum_count[3]+1=4,minimum_count[0]+1=1
[0,1,2,3,1,∞]              平方数 4 直接覆盖更优

里程碑 5|处理 value=5
候选:minimum_count[4]+1=2,minimum_count[1]+1=2
[0,1,2,3,1,2]              返回 2
n=5:按目标值 value 归纳,同一轮的多个平方数候选合并比较
(value,square)写入前候选 minimum_count[value-square]+1minimum_count 现场
0初始化尚无数组minimum_count[0]=0,其余 ∞[0,∞,∞,∞,∞,∞]
1(1,1)minimum_count[0]+1=1[0,1,∞,∞,∞,∞]
2(2,1)minimum_count[1]+1=2[0,1,2,∞,∞,∞]
3(3,1)minimum_count[2]+1=3[0,1,2,3,∞,∞]
4value=4min(minimum_count[3]+1=4, minimum_count[0]+1=1)[0,1,2,3,1,∞]
5value=5min(minimum_count[4]+1, minimum_count[1]+1)=2[0,1,2,3,1,2] → 返回

上方快照保留了 minimum_count[4] 从 4 被平方数 4 改写为 1 的关键比较;表格只概括每个目标值的稳定结果。

4 · 代码
class Solution:
    def numSquares(self, n):
        # 1. 只有 0 的答案已知,其余状态等待取最小值
        minimum_count = [0] + [float('inf')] * n

        # 2. 按目标值递增,保证被引用的更小状态已经完成
        for value in range(1, n + 1):
            square_root = 1
            while square_root * square_root <= value:
                square = square_root * square_root
                minimum_count[value] = min(
                    minimum_count[value],
                    minimum_count[value - square] + 1,
                )
                square_root += 1

        return minimum_count[n]
5 · 复杂度

时间 O(n·√n),外层 n、内层平方数约 √n 个;空间 O(n)。

6 · 测试用例

功能n=12 → 3n=13 → 2边界n=1 → 1(本身是平方数);特殊:完全平方数 n=4 → 1n=16 → 1,验证不会误拆成多个。

零钱兑换

LC 322 ↗动态规划 · Medium
1 · 题目

要完成的任务

coins 给出不同硬币面额,每种硬币都可以使用任意多枚。给定目标金额 amount,返回恰好凑出该金额所需的最少硬币数;无法凑出时返回 -1

示例

示例 1:coins=[1,2,5]amount=11,输出 3,可用 5+5+1

示例 2:coins=[2]amount=3,输出 -1

示例 3:coins=[1]amount=0,输出 0

约束

  • 1 ≤ coins.length ≤ 121 ≤ coins[i] ≤ 231-1
  • 0 ≤ amount ≤ 104
2 · 思路(暴力→最优)

朴素 DFS 与贪心的局限。DFS 会重复求相同剩余金额;“每次选最大面值”也不可靠,例如 [1,3,4] 凑 6,贪心得到 4+1+1 共 3 枚,而最优是 3+3 共 2 枚。

正解:按金额保存最少硬币数。
① 状态含义:minimum_coins[value] 表示凑出金额 value 的最少硬币数。
② 边界状态:minimum_coins[0]=0;其他位置先为无穷,表示暂不可达。
③ 转移来源:若最后放入硬币 coin,候选是 minimum_coins[value-coin]+1;枚举所有可用面值取最小。

用 coins=[1,2,5], amount=5 展开。
① 金额 1:只有硬币 1 可用,得到 1。
② 金额 2:先由 1+1 得 2,再由一枚 2 改善为 1。
③ 金额 3、4:继续复用较小金额,得到 2、2,证明转移可以重复一层以上。
④ 金额 5:使用 1 或 2 的候选都是 3,使用一枚 5 得 1,形成最终答案。

循环与取舍。外层按金额递增,确保 value-coin 已计算;硬币可重复使用,因此这是完全背包语义。状态只依赖更小金额,一维数组已足够,空间为 O(amount);若目标仍为无穷则返回 −1。

⚠ 易错坑 结束时若 minimum_coins[amount]==inf 要返回 −1,不能把无穷直接当答案。
只有 coin≤value 时才能读取 minimum_coins[value-coin],否则下标为负。
是求"最少硬币数"(min 转移),不是"凑法总数"(那要 += 且循环顺序不同)——两类背包别混。
3 · 图解
coins=[1,2,5], amount=5;minimum_coins[value] 的稳定里程碑

里程碑 1|初态
[0,∞,∞,∞,∞,∞]

里程碑 2|完成金额 1
[0,1,∞,∞,∞,∞]

里程碑 3|完成金额 2
候选 1+1→2 枚;直接用 2→1 枚
[0,1,1,∞,∞,∞]

里程碑 4|完成金额 3
[0,1,1,2,∞,∞]

里程碑 5|完成金额 4
候选 minimum_coins[3]+1=3;minimum_coins[2]+1=2
[0,1,1,2,2,∞]

里程碑 6|完成金额 5
硬币 1/2 的候选为 3;硬币 5 的候选为 1
[0,1,1,2,2,1] → 返回 1
amount=5:按金额 value 归纳,同一金额的所有可用硬币在一行比较
(value,coin)分支与写入前候选 minimum_coins[value-coin]+1minimum_coins 现场
0初始化尚无数组minimum_coins[0]=0,其余 ∞[0,∞,∞,∞,∞,∞]
1(1,1)1≤1;当前为 ∞minimum_coins[0]+1=1[0,1,∞,∞,∞,∞]
2value=2硬币 1、2 可用min(minimum_coins[1]+1, minimum_coins[0]+1)=1[0,1,1,∞,∞,∞]
3value=3硬币 1、2 可用两个候选都为 2[0,1,1,2,∞,∞]
4value=4硬币 1、2 可用min(3,2)=2[0,1,1,2,2,∞]
5value=5硬币 1、2、5 可用硬币 5 从 minimum_coins[0]+1 得 1[0,1,1,2,2,1] → 返回

同一格的连续覆盖移到上方快照展示;阶段表只保留每个金额比较完全部面值后的稳定结果。

4 · 代码
class Solution:
    def coinChange(self, coins, amount):
        # 1. 金额 0 需要 0 枚;其余状态先视为不可达
        minimum_coins = [0] + [float('inf')] * amount

        # 2. 按金额递增,枚举最后使用的硬币
        for value in range(1, amount + 1):
            for coin in coins:
                if coin <= value:
                    minimum_coins[value] = min(
                        minimum_coins[value],
                        minimum_coins[value - coin] + 1,
                    )

        # 3. 无穷表示所有面值组合都无法到达目标金额
        answer = minimum_coins[amount]
        return answer if answer != float('inf') else -1
5 · 复杂度

时间 O(amount × len(coins)),两层循环;空间 O(amount)。

6 · 测试用例

功能[1,2,5],11 → 3边界amount=0 → 0(不用硬币);特殊:凑不出 [2],3 → -1、贪心陷阱 [1,3,4],6 → 2(验证不是贪心)。

单词拆分

LC 139 ↗动态规划 · Medium
1 · 题目

要完成的任务

给定字符串 s 和字典列表 wordDict,判断能否把 s 从头到尾切成一个或多个字典单词。字典里的词不要求全部使用,同一个词可以重复使用。

示例

示例 1:s="leetcode"wordDict=["leet","code"],输出 true

示例 2:s="applepenapple"wordDict=["apple","pen"],输出 true,其中 apple 被重复使用。

示例 3:s="catsandog"wordDict=["cats","dog","sand","and","cat"],输出 false

约束与保证

  • 1 ≤ s.length ≤ 3001 ≤ wordDict.length ≤ 1000
  • 1 ≤ wordDict[i].length ≤ 20;所有字符串只包含小写英文字母。
  • wordDict 中的字符串互不相同。
2 · 思路(暴力→最优)

朴素 DFS 及局限。从字符串开头枚举第一段,再递归拆剩余后缀;同一个后缀会被不同切法反复进入,最坏形成指数级搜索树。

正解:判断每个前缀是否可达。
① 状态含义:breakable[end] 表示前缀 s[:end] 能否由字典词拼成。
② 边界状态:breakable[0]=True,空前缀作为第一段单词的合法起点。
③ 转移来源:枚举最后一段起点 start;只有 breakable[start] 为真且 s[start:end] 在字典中,当前前缀才可达。

用 "leetcode" 重复应用切分。
① 短前缀失败:end=1..3 找不到同时满足条件的切点,状态保持 False。
② 第一次命中:end=4,start=0,空前缀可达且 "leet" 在字典中,于是 breakable[4]=True
③ 中间前缀继续失败:end=5..7 没有合法最后一段。
④ 第二次命中:end=8,start=4,可达前缀后接 "code",整串变为 True。

优化与取舍。字典转成哈希集合后成员查询平均 O(1);当前前缀一旦找到一种拆法即可停止枚举。一维 breakable 只占 O(n) 空间。切片仍需 O(end-start) 时间,因此 Python 实现的严格上界可写 O(n³),若按哈希查询抽象模型常记 O(n²)。

⚠ 易错坑 breakable[0]=True 是递推根,漏设会让所有前缀恒为 False。
必须同时满足 breakable[start]s[start:end] in word_set,只判后者会误判。
s[start:end] 不含 end,而 breakable[end] 管前 end 个字符,不能错位。
3 · 图解
s="leetcode",word_set={"leet","code"};breakable[end]=前 end 个字符能否拆:

初始化              [T,F,F,F,F,F,F,F,F]
end=1..3 无合法切点 [T,F,F,F,F,F,F,F,F]
end=4:breakable[0] + "leet" [T,F,F,F,T,F,F,F,F]
                         └── leet ──┘
end=5..7 无新切点   [T,F,F,F,T,F,F,F,F]
end=8:breakable[4] + "code" [T,F,F,F,T,F,F,F,T]
                         └leet┘└code┘
最终由两个可达前缀首尾相接,breakable[8]=True。
按前缀长度分阶段:连续失败检查压缩,命中时直接展示该轮稳定状态
(end,start) / 动作分支来源breakable[end]breakable 现场
0初始化空前缀可拆breakable[0]=True[T,F,F,F,F,F,F,F,F]
1end=1..3所有 start 检查均失败breakable[1..3] 保持 F[T,F,F,F,F,F,F,F,F]
2end=4breakable[0]=T 且 "leet"∈word_set F → T[T,F,F,F,T,F,F,F,F]
3end=5..7所有可达切点均未匹配词典breakable[5..7]=F[T,F,F,F,T,F,F,F,F]
4end=8breakable[4]=T 且 "code"∈word_set F → Tbreakable[8]=True → 返回
4 · 代码
class Solution:
    def wordBreak(self, s, wordDict):
        word_set = set(wordDict)
        text_length = len(s)

        # 1. 空前缀可达,其他前缀等待找到合法最后一段
        breakable = [True] + [False] * text_length

        # 2. 枚举前缀终点,再枚举最后一个单词的起点
        for end in range(1, text_length + 1):
            for start in range(end):
                if breakable[start] and s[start:end] in word_set:
                    breakable[end] = True
                    break

        return breakable[text_length]
5 · 复杂度

时间 最坏 O(n³):共有 O(n²) 个切点候选,而 Python 的 s[start:end] 切片与哈希都按子串长度收费;若忽略切片/哈希开销才常简记为 O(n²)。空间 O(n + 字典总字符数),包括 breakable 与哈希集合。

6 · 测试用例

功能"leetcode",["leet","code"] → True边界:空串 "" → True特殊:可重复用词 "applepenapple",["apple","pen"] → True、拆不出 "catsandog",["cats","dog","sand","and","cat"] → False

最长递增子序列

LC 300 ↗动态规划 · Medium
1 · 题目

要完成的任务

给定整数数组 nums,返回最长严格递增子序列的长度。子序列可以删除任意元素但必须保持剩余元素的原相对顺序,不要求连续;“严格递增”意味着后一个值必须大于前一个值。

示例

示例 1:[10,9,2,5,3,7,101,18] 输出 4,例如 [2,3,7,101]

示例 2:[0,1,0,3,2,3] 输出 4

示例 3:[7,7,7,7,7,7,7] 输出 1,相等不算递增。

约束与进阶

  • 1 ≤ nums.length ≤ 2500-104 ≤ nums[i] ≤ 104
  • 进阶要求把时间复杂度降低到 O(n log n)
2 · 思路(暴力→最优)

朴素枚举及局限。枚举全部子序列再检查是否严格递增,需要 O(2ⁿ) 个候选,无法利用相同前缀之间的重叠信息。

正解一:固定结尾的 O(n²) DP。
① 状态含义:ending_length[i] 表示必须以 nums[i] 结尾的 LIS 长度。
② 边界状态:每个元素单独成序列,所以所有位置初始为 1。
③ 转移来源:枚举 previous<i;仅当 nums[previous]<nums[i] 时,当前元素才能接到该序列后,候选为 ending_length[previous]+1
④ 汇总答案:最长序列不一定以末元素结尾,因此返回整个状态数组的最大值。

用 [3,1,2,4] 比较两种状态演进。
① 处理 3:DP 状态为 1;最小尾数组为 [3]
② 处理 1:DP 仍为 1;二分解把尾值 3 替换成 1,长度不变但更容易延长。
③ 处理 2:DP 从前驱 1 得到长度 2;二分解在尾部追加,得到 [1,2]
④ 处理 4:两种解法都把长度推进到 3,关键转移至少连续复用两层。

正解二与取舍。minimum_tail[length-1] 保存该长度下最小的结尾值,数组保持递增,可用 bisect_left 在 O(log n) 内定位追加或替换位置。它把总时间降到 O(n log n),但只直接给长度;O(n²) DP 更易扩展为恢复具体序列。

⚠ 易错坑 答案是 max(ending_length),不是最后一格;最长序列可能在中途结束。
严格递增要求 nums[previous]<nums[current],二分版用 bisect_left,相等值只替换、不延长。
minimum_tail 只保证每种长度的尾值最小,并不一定是一条真实子序列。
3 · 图解
nums=[3,1,2,4];两个解法在相同输入上的 5 个稳定里程碑

里程碑 1|初态
ending_length=[1,1,1,1]    minimum_tail=[]

里程碑 2|读入 3
ending_length=[1,1,1,1]    minimum_tail=[3]

里程碑 3|读入 1
前面没有更小值,DP 保持 1;二分覆盖更大的尾值
ending_length=[1,1,1,1]    minimum_tail=[1]

里程碑 4|读入 2
2 接在 1 后得到长度 2
ending_length=[1,1,2,1]    minimum_tail=[1,2]

里程碑 5|读入 4
4 可接在长度 2 后,两个解法都得到长度 3
ending_length=[1,1,2,3]    minimum_tail=[1,2,4]
解法① O(n²) DP:每个 i 汇总对所有前驱 j 的比较
(current,previous)比较写入来源ending_length 现场
0初始化每个数自身长度为 1ending_length=[1]*4[1,1,1,1]
1current=1,value=1前驱 3<1 保持 1[1,1,1,1]
2current=2,value=23 不可接;1 可接 ending_length[1]+1=2[1,1,2,1]
3current=3,value=4三个前驱都更小,取最大来源max(2,2,3)=3[1,1,2,3]
4汇总答案max(ending_length)3最长递增子序列长度 3
解法② 贪心+二分:每个元素一轮,合并定位与追加/覆盖
value / 动作二分或分支来源positionminimum_tail 现场
0初始化minimum_tail=[][]
1value=3position=0,尾部追加0[3]
2value=1position=0,覆盖首个 ≥1 的尾值0[1]
3value=2position=1,尾部追加1[1,2]
4value=4position=2,尾部追加2[1,2,4] → 长度 3
4 · 代码
class Solution:
    def lengthOfLIS(self, nums):
        # 1. 每个元素自身都能组成长度为 1 的递增子序列
        ending_length = [1] * len(nums)

        # 2. 固定当前结尾,枚举所有严格更小的前驱
        for current in range(len(nums)):
            for previous in range(current):
                if nums[previous] < nums[current]:
                    ending_length[current] = max(
                        ending_length[current],
                        ending_length[previous] + 1,
                    )

        # 3. LIS 未必以最后一个元素结尾
        return max(ending_length)
from bisect import bisect_left

class Solution:
    def lengthOfLIS(self, nums):
        # 1. minimum_tail[k] 是长度 k+1 的序列能取得的最小尾值
        minimum_tail = []

        # 2. 严格递增使用 bisect_left:相等值只替换,不延长
        for value in nums:
            position = bisect_left(minimum_tail, value)
            if position == len(minimum_tail):
                minimum_tail.append(value)
            else:
                minimum_tail[position] = value

        return len(minimum_tail)
5 · 复杂度

时间 O(n²) 双层循环;空间 O(n)。二分优化版可达 O(n log n)

6 · 测试用例

功能[10,9,2,5,3,7,101,18] → 4边界:单元素 [7] → 1特殊:严格递增判定 [2,2,2] → 1(相等不算增)、递减 [5,4,3] → 1、递增 [1,2,3] → 3

乘积最大子数组

LC 152 ↗动态规划 · Medium
1 · 题目

要完成的任务

给定整数数组 nums,从中选择一个至少含一个元素的连续子数组,使元素乘积最大,并返回该乘积。只含一个元素也属于合法子数组;测试答案与所有子数组乘积都在 32 位整数范围内。

示例

示例 1:nums=[2,3,-2,4],输出 6,对应子数组 [2,3]

示例 2:nums=[-2,0,-1],输出 0[-2,-1] 不连续,不能作为答案。

约束与保证

  • 1 ≤ nums.length ≤ 2×104-10 ≤ nums[i] ≤ 10
  • 任意子数组的乘积保证是 32 位整数。
2 · 思路(暴力→最优)

朴素枚举及局限。枚举每个连续子数组并重新计算乘积需要 O(n³);固定左端后滚动乘积可降到 O(n²),但仍重复处理大量相同前缀。

正解:同时保存以当前位置结尾的最大值和最小值。
① 状态含义:max_ending/min_ending 分别表示以当前元素结尾的最大/最小乘积。
② 三个来源:当前元素可以独自重开,也可以接在上一轮最大或最小乘积后。
③ 负数翻转:乘负数后大小关系反转,因此遇负数先交换两种来源,再分别做 max/min。
④ 全局答案:最大子数组可能在中途结束,要用 best_product 记录历轮最大值。

用 [2,3,-2,4] 验证双状态。
① 连续正数:2、3 把 max_ending 推到 6,形成当前答案。
② 遇到 -2:交换来源后得到 max_ending=-2min_ending=-12;很负的 -12 被保留。
③ 再遇 4:当前最大重开为 4,最小延伸为 -48,答案仍是此前的 6。这个样例同时覆盖负数翻转和“答案不在末尾”。

空间取舍。只需上一轮最大/最小与全局答案,使用三个局部变量即可做到 O(1) 空间;显式三候选写法更直观,先交换写法更紧凑,两者等价。

⚠ 易错坑 必须同时维护 max_endingmin_ending,否则会丢掉遇负数后翻正的来源。
在更新前先判断 value<0 并交换两个旧来源,不能让本轮新值污染另一状态。
每次转移都要包含“从 value 独自重开”,否则遇 0 或正负交界处无法断开。
3 · 图解
nums=[2,3,-2,4];每轮稳定态为 (max_ending,min_ending,best_product):

首元素 value=2: (2,  2, 2)
value=3:        (6,  3, 6)       ← 2×3 形成当前答案

value=-2:负数会交换最大/最小来源
  乘以前:max_ending=6,min_ending=3
  交换源:max_ending=3,min_ending=6
  乘以后:max_ending=-2,min_ending=-12,best_product=6
                     ↑ 保留 -12,后续若再遇负数可翻成大正数

value=4: (4,-48,6)          ← 最终答案仍为 6
交换技巧法:按元素归纳;负数轮额外保留“交换来源”这一关键阶段
value / 动作计算来源max_endingmin_endingbest_product
0初始化首元素 2三个状态都取 nums[0]222
1value=3正数不换来源;max(3,2·3),min(3,2·3)636
2value=-2:交换来源旧 (max_ending,min_ending)=(6,3) → (3,6)366
3value=-2:完成本轮max(-2,3·-2)=-2;min(-2,6·-2)=-12-2-126
4value=4 并返回max(4,-8)=4;min(4,-48)=-484-486 → 返回

上方负数轮快照说明了 min_ending=-12 的来源;它不是废值,后续再乘负数时可能翻成新的最大值。

4 · 代码
class Solution:
    def maxProduct(self, nums):
        # 1. 首元素同时是当前最大、最小和全局答案
        max_ending = min_ending = best_product = nums[0]

        # 2. 负数会交换最大/最小来源,再决定延伸还是从当前值重开
        for value in nums[1:]:
            if value < 0:
                max_ending, min_ending = min_ending, max_ending

            max_ending = max(value, max_ending * value)
            min_ending = min(value, min_ending * value)
            best_product = max(best_product, max_ending)

        return best_product
5 · 复杂度

时间 O(n) 一趟;空间 O(1),只留 max_endingmin_endingbest_product

6 · 测试用例

功能[2,3,-2,4] → 6边界:单元素 [-2] → -2特殊:含 0 截断 [-2,0,-1] → 0、双负翻正 [-2,-3,-4] → 12(-2*-3=6,再*-4=-24,但 [-3,-4]=12)。

分割等和子集

LC 416 ↗动态规划 · Medium
1 · 题目

要完成的任务

给定一个非空正整数数组 nums,判断能否把所有元素恰好分到两个子集中,使两个子集的元素和相等。每个数组元素只能归入其中一个子集。

示例

示例 1:nums=[1,5,11,5],输出 true,可分为 [1,5,5][11]

示例 2:nums=[1,2,3,5],输出 false

约束

  • 1 ≤ nums.length ≤ 200
  • 1 ≤ nums[i] ≤ 100
2 · 思路(暴力→最优)

暴力:枚举每个数"选/不选"共 2ⁿ 种,看有没有一半的组合,指数级。

正解:0/1 背包可行性。两子集等和,等价于从数组中选一部分恰好凑出总和的一半;若总和为奇数,目标不存在,可直接返回 False。
① 状态含义:reachable[current_sum] 表示只用已经处理过的数,能否凑出 current_sum
② 初始边界:reachable[0]=True,因为一个数都不选就能凑出 0;其余和初始不可达。
③ 转移来源:处理 number 时,当前和可以“不选它”而保持原值,也可以“选它”并由 reachable[current_sum-number] 转来。
④ 循环方向:容量必须从 target_sum 倒序走到 number。这样读取的是本轮尚未覆盖的旧状态,每个数只会使用一次。

具体例子 [1,2,3]总和为 6,目标是 3。初态仅 0 可达;处理 1 后得到和 1;再处理 2 时,旧状态中的和 1 能转移到和 3,于是组合 1+2 已命中目标;处理 3 只会让目标继续保持可达。若处理 2 时正序扫描,就会先写出和 2,再错误地用同一个 2 写出更大容量。

空间优化与取舍:二维“前 i 个数能否凑出 j”可压成一维,把空间从 O(n×target) 降到 O(target);代价是必须严格记住 0/1 背包的一维数组要倒序更新。

⚠ 易错坑 一维 0/1 背包必须使用 range(target_sum, number-1, -1) 倒序更新;正序会重复使用同一数字。
总和为奇数要提前返回 False,否则整除会制造不存在的 target_sum
下界必须包含 current_sum=number,此时可从 reachable[0] 转来;漏掉会错过单个数字命中目标。
3 · 图解
nums=[1,2,3], target_sum=3;reachable[j]=能否用“已处理元素”凑出 j:

容量 j       0  1  2  3
初始化       T  F  F  F
处理 num=1  T  T  F  F   (j:3→1,1 只用一次)
处理 num=2  T  T  T  T
                         ↑ j=3 读取上一轮 reachable[1]=T,得到 1+2
处理 num=3  T  T  T  T   (目标已可达,保持 True)

若处理 num=1 时正序更新,会先写 reachable[1],再用本轮刚写出的 reachable[1] 写 reachable[2],继而误把 reachable[3] 也写成 True,等价于重复使用同一个 1;
倒序让右侧容量读取的始终是本轮尚未覆盖的旧状态。
0/1 背包:按物品归纳一整轮倒序容量扫描
代码动作写入前 / 来源reachable[0..3]
0检查总和并初始化total_sum=6,target_sum=3;reachable[0]=T[T,F,F,F]
1处理 number=1倒序扫描 3..1,仅 reachable[1] 由 reachable[0] 变真[T,T,F,F]
2处理 number=2reachable[3] 读旧 reachable[1];reachable[2] 读 reachable[0][T,T,T,T]
3处理 number=3reachable[3] 已真,仍保持可达[T,T,T,T]
4返回目标状态检查 reachable[target_sum]reachable[3]=True

关键的倒序读取关系已放进上方数组快照;表格只保留每个物品处理完后的稳定状态。

4 · 代码
class Solution:
    def canPartition(self, nums):
        total_sum = sum(nums)

        # 1. 奇数总和不可能平分
        if total_sum % 2:
            return False
        target_sum = total_sum // 2

        # 2. reachable[s]:能否用已处理数字凑出 s
        reachable = [True] + [False] * target_sum

        # 3. 每个数字只能用一次,因此容量倒序更新
        for number in nums:
            for current_sum in range(target_sum, number - 1, -1):
                reachable[current_sum] = (
                    reachable[current_sum]
                    or reachable[current_sum - number]
                )

        return reachable[target_sum]
5 · 复杂度

时间 O(n × target) = O(n × sum/2);空间 O(target),一维滚动数组。

6 · 测试用例

功能[1,5,11,5] → True[1,2,3,5] → False边界:单元素 [1] → False(无法两分);特殊:总和为奇 [1,2,4] → False(提前返回)、[2,2] → True

最长有效括号

LC 32 ↗动态规划 · Hard
1 · 题目

要完成的任务

给定只含 '('')' 的字符串 s,返回其中最长的、连续且括号格式正确的子串长度。这里求的是子串而非可跳过字符的子序列;每个左括号都必须由对应右括号闭合。

示例

示例 1:s="(()",输出 2,最长有效子串是 "()"

示例 2:s=")()())",输出 4,最长有效子串是 "()()"

示例 3:s="",输出 0

约束

  • 0 ≤ s.length ≤ 3×104
  • s[i] 只能是 '('')'
2 · 思路(暴力→最优)

暴力:枚举所有子串、逐个用计数器判是否合法,O(n³)。

正解①:以结尾位置定义 DP。有效括号串一定以 ')' 结尾,所以只在右括号处更新。
① 状态含义:ending_length[end] 表示恰好以 end 结尾的最长有效括号长度。
② 直接成对:s[end-1]=='(',当前两字符组成 (),再接上更左侧已有的有效段:ending_length[end]=ending_length[end-2]+2
③ 包住前段:s[end-1]==')',先跨过前一位结尾的有效段,令 matching_left=end-ending_length[end-1]-1;若该位置是 '(',就用当前右括号包住整段,并拼接它更左边的有效段。
④ 初始与答案:数组初始全 0;答案是所有结尾状态的最大值,不一定在字符串末尾。

具体例子 ")()(())"开头的 ')' 无法匹配;下标 2 先得到长度 2;下标 5 再得到内层 () 的长度 2;到下标 6 时,跨过这段找到下标 3 的 '(',并把更左边下标 2 结尾的 () 一并接上,最终得到 6。这个例子同时覆盖失配边界和 …)) 分支。

正解②:下标栈。
① 哨兵:栈底先放 -1,表示最近一个未匹配位置。
② 扫描:左括号压下标;右括号先弹栈,若栈空则把当前下标设为新断点,否则 end-unmatched_indices[-1] 就是当前有效段长度。
③ 原理:栈顶始终是当前有效段左边最近的未匹配位置,两下标相减即可得到连续长度。

取舍:两解都是 O(n) 时间、O(n) 空间。DP 靠 matching_left=end-ending_length[end-1]-1 跨过内层匹配外层,思路精巧但下标易错;栈解更直观、边界更好想,推荐面试写栈。

⚠ 易错坑 ending_length[end-2]ending_length[matching_left-1] 读取前要检查边界,否则 Python 负下标会悄悄读到数组末尾。
栈解栈底必须先垫 -1:它保证从头就有基准,且 pop 后栈空时能正确重置——漏掉会算错第一个有效段。
答案要全程维护 best_length,不能只看最后一格;有效段可能出现在中间。
3 · 图解
s=")()(())",下标 0..6;每个里程碑同时对照 DP 与栈:

字符               )  (  )  (  (  )  )
下标               0  1  2  3  4  5  6

① 开头失配
ending_length      [0, 0, 0, 0, 0, 0, 0]
unmatched_indices  [-1] → [0]                 (0 成为新断点)

② 完成第一对 "()"
ending_length      [0, 0, 2, 0, 0, 0, 0]
unmatched_indices  [0,1] → [0],长度 2-0=2

③ 读到嵌套的两个左括号
ending_length      [0, 0, 2, 0, 0, 0, 0]
unmatched_indices  [0,3,4]

④ 下标 5 闭合内层 "()"
ending_length      [0, 0, 2, 0, 0, 2, 0]
unmatched_indices  [0,3],当前长度 5-3=2

⑤ 下标 6 闭合外层,并连接左边的 "()"
matching_left = 6-ending_length[5]-1 = 3,s[3]='('
ending_length[6] = 2 + 2 + ending_length[2] = 6
ending_length      [0, 0, 2, 0, 0, 2, 6]
unmatched_indices  [0],当前长度 6-0=6
解法① DP:按 end 归纳匹配分支与本轮稳定状态
end / 动作分支与来源ending_lengthbest_length / matching_left
0初始化n=7,ending_length 全 0[0,0,0,0,0,0,0]0
1end=0..1开头 ')' 失配;'(' 不能结束有效段[0,0,0,0,0,0,0]0
2end=2,')'前一位 '(',接 ending_length[0]+2[0,0,2,0,0,0,0]2
3end=3..4连续两个 '(',状态保持[0,0,2,0,0,0,0]2
4end=5,')'前一位 '(',得到内层长度 2[0,0,2,0,0,2,0]2
5end=6,')'matching_left=3,包住前段并接 ending_length[2][0,0,2,0,0,2,6]6 → 返回
解法② 栈:每个字符一轮,合并本轮弹栈、重设基准或更新答案
end / 字符代码动作unmatched_indices(底→顶)best_length
0初始化栈底 -1 作为首个长度基准[-1]0
1end=0, ')'弹出 -1 后栈空,当前下标成为新基准[0]0
2end=1..2, "()"压入 1;遇 ')' 弹出并以栈顶 0 算长度[0]2
3end=3..4, "(("两个左括号依次等待匹配[0,3,4]2
4end=5, ')'弹出 4;5-unmatched_indices[-1]=2[0,3]2
5end=6, ')'弹出 3;6-unmatched_indices[-1]=6[0]6 → 返回
4 · 代码
class Solution:
    def longestValidParentheses(self, s):
        text_length = len(s)
        ending_length = [0] * text_length
        best_length = 0

        # 1. 只有右括号才能结束有效括号段
        for end in range(1, text_length):
            if s[end] != ')':
                continue

            # 2. 形如 ...():直接闭合当前括号对
            if s[end - 1] == '(':
                previous_block = ending_length[end - 2] if end >= 2 else 0
                ending_length[end] = previous_block + 2

            # 3. 形如 ...)):跨过前段,寻找可配对的左括号
            else:
                matching_left = end - ending_length[end - 1] - 1
                if matching_left >= 0 and s[matching_left] == '(':
                    previous_block = (
                        ending_length[matching_left - 1]
                        if matching_left >= 1 else 0
                    )
                    ending_length[end] = (
                        ending_length[end - 1] + 2 + previous_block
                    )

            best_length = max(best_length, ending_length[end])

        return best_length
# 解法二:下标栈
class Solution:
    def longestValidParentheses(self, s):
        # 1. 栈顶表示当前有效段左边最近的未匹配位置
        unmatched_indices = [-1]
        best_length = 0

        # 2. 左括号入栈,右括号尝试消去一个左括号
        for end, char in enumerate(s):
            if char == '(':
                unmatched_indices.append(end)
                continue

            unmatched_indices.pop()

            # 3. 栈空就重设断点,否则用断点计算连续长度
            if not unmatched_indices:
                unmatched_indices.append(end)
            else:
                current_length = end - unmatched_indices[-1]
                best_length = max(best_length, current_length)

        return best_length
5 · 复杂度

时间 O(n) 一趟;空间 O(n) 存 ending_length(栈解法同样 O(n))。

6 · 测试用例

功能")()())" → 4"(()" → 2边界:空串 "" → 0、单字符 "(" → 0特殊:嵌套 "()(())" → 6、全不合法 ")))" → 0

多维动态规划

多维 DP 是一维 DP 的自然升级:状态从 dp[i] 变成 dp[i][j],通常对应两个维度——网格的行列(LC62/64)、两个字符串的前缀长度(LC72 编辑距离、LC1143 LCS),或子串的左右端点(LC5 回文)。万能三问不变:①状态定义 dp[i][j] 表示什么;②转移方程——当前格由上/左/左上三个方向的邻格推出(这是二维 DP 最典型的依赖模式);③base case——第 0 行、第 0 列的初值。它和一维 DP一脉相承(LC62 就是 LC70 爬楼梯的二维版),常可用滚动数组O(mn) 空间压回 O(n);双串问题(72/1143)转移几乎共用一套模板,是本类别的重中之重。

不同路径

LC 62 ↗多维动态规划 · Medium
1 · 题目

要完成的任务

机器人位于 m×n 网格的左上角,要走到右下角。每一步只能向右或向下移动一格,返回所有不同路径的数量;路径经过的格子序列不同就算不同路径。

示例 1 与网格含义

m=3n=7 时输出 28

三行七列的机器人网格机器人从左上角 Start 出发,只能向右或向下,最终到达右下角 Finish。StartFinish
图中高亮线只是一条可行路径;其余向右、向下的组合也要计数

其余示例

示例 2:m=3,n=2,输出 3,三条方向序列是右下下、下下右、下右下。

示例 3:m=7,n=3,输出 28

示例 4:m=3,n=3,输出 6

约束与保证

  • 1 ≤ m,n ≤ 100
  • 答案保证不超过 2×109
2 · 思路(暴力→最优)

暴力:从 (0,0) 起 DFS,每格向右/向下两支走到右下角计数,指数级且大量子问题重复。

正解:网格路径 DP。到达任意内部格的最后一步只能来自上方或左方,两类路径互不重叠,因此可以直接相加。
① 状态含义:path_count[row][col] 表示从左上角到当前格的不同路径数。
② 初始边界:第一行只能一路向右,第一列只能一路向下,所以这些格都只有 1 条路径。
③ 转移来源:path_count[row][col]=path_count[row-1][col]+path_count[row][col-1],即“上方路径数 + 左方路径数”。
④ 填表顺序:逐行从左到右计算,确保上格和左格都已经就绪。

具体例子 3×3第一行是 [1,1,1];第二行依次得到 2、3;第三行先由上方 2 与左方 1 得到 3,再由上方 3 与左方 3 得到终点的 6。相同转移连续经过两层,边界格始终保持 1。

空间优化与取舍:当前格只依赖上一行同列和本行左侧,可压成一维 path_count[col]。从左到右更新时,旧 path_count[col] 是“上”,新 path_count[col-1] 是“左”,空间由 O(mn) 降到 O(n);代价是覆盖语义需要讲清楚。

⚠ 易错坑 一维滚动必须从左往右遍历 colpath_count[col-1] 已是本行新值,path_count[col] 仍是上行旧值。
第一行、第一列 base 是 1 不是 0;漏掉会让整表塌成 0。
mn 是行数、列数(格子数),不是坐标最大值;组合数公式 C(m+n-2, m-1) 的下标最容易差 1。
3 · 图解
m=3,n=3;path_count 覆盖前代表“上”,覆盖后的左邻代表“左”:

完整二维含义:       滚动数组真实变化:
      0  1  2        初始/第0行 [1,1,1]
row=0 1  1  1       第1行 col=1 [1,2,1]
row=1 1  2  3             col=2 [1,2,3]
row=2 1  3  6       第2行 col=1 [1,3,3]
                           col=2 [1,3,6] ← 答案

例如最后一格:旧 path_count[2]=3(上)+ 新 path_count[1]=3(左)=6。
一维滚动:小矩阵每个内部格就是一个主要转移,关键覆盖快照见上图
当前格 (row,col)写入前:上 + 左写入后path_count 现场
0初始化第一行path_count=[1]*3[1,1,1]
1(1,1)旧 path_count[1]=1 + 新 path_count[0]=12[1,2,1]
2(1,2)旧 path_count[2]=1 + 新 path_count[1]=23[1,2,3]
3(2,1)旧 path_count[1]=2 + 新 path_count[0]=13[1,3,3]
4(2,2) 右下角旧 path_count[2]=3 + 新 path_count[1]=36[1,3,6] → 返回
4 · 代码
class Solution:
    def uniquePaths(self, m, n):
        # 1. 第一行每个格都只有一条路径
        path_count = [1] * n

        # 2. 旧同列是“上”,新左邻是“左”
        for row in range(1, m):
            for col in range(1, n):
                path_count[col] += path_count[col - 1]

        return path_count[-1]
5 · 复杂度

时间 O(m·n) 填满网格;空间 O(n) 滚动数组(朴素二维为 O(m·n))。也有组合数公式 C(m+n-2, m-1) 的 O(min(m,n)) 解。

6 · 测试用例

功能m=3,n=7 → 28m=3,n=2 → 3边界:单行/单列 m=1,n=1 → 1m=1,n=5 → 1特殊:方形 m=3,n=3 → 6

最小路径和

LC 64 ↗多维动态规划 · Medium
1 · 题目

要完成的任务

给定装有非负整数的 m×n 网格 grid,从左上角走到右下角,每次只能向右或向下移动一格。返回所有合法路径中,经过格子数字总和的最小值;起点和终点的数字都计入。

示例 1

输入:grid=[[1,3,1],[1,5,1],[4,2,1]]

输出:7,最小路径为 1→3→1→1→1

最小路径和示例网格三乘三网格,从左上角的 1 沿上边向右,再向下经过 3、1、1 到右下角 1,总和为 7。131151421
高亮路径的数字和为 7,是示例中的最小值

示例 2 与约束

grid=[[1,2,3],[4,5,6]] 时输出 12

  • m==grid.lengthn==grid[i].length,且 1 ≤ m,n ≤ 200
  • 0 ≤ grid[i][j] ≤ 200
2 · 思路(暴力→最优)

暴力:DFS 枚举所有向右/向下路径求最小和,指数级。

正解:最小代价网格 DP。到当前格仍只可能来自上方或左方;路径数问题把两类相加,本题则选择累计代价更小的前驱。
① 状态含义:minimum_sum[row][col] 表示从左上角到当前格的最小路径和。
② 初始边界:第一行只能从左边来,第一列只能从上边来,因此都要做连续累加,不能套两个前驱的 min
③ 转移来源:minimum_sum[row][col]=grid[row][col]+min(上方最优, 左方最优)。任意到达当前格的路径必属于这两类之一。
④ 填表顺序:逐行从左到右,先更新本行首列,再更新内部格。

具体例子:[[1,3,1],[1,5,1],[4,2,1]],首行累加为 [1,4,5];第二行由“上/左较小者 + 当前值”得到 [2,7,6];第三行同样得到 [6,8,7],终点答案为 7。这个过程连续两行重复同一转移,并单独覆盖了首行、首列边界。

空间优化与取舍:只依赖上、左两格,可用一维 minimum_sum[col] 逐行覆盖,把空间从 O(mn) 降到 O(n)。为了不改输入网格,这里保留独立滚动数组;若允许原地修改,也可直接把 grid 当 DP 表。

⚠ 易错坑 第一行/第一列不能套用 min 转移(它们只有一个前驱),要单独用"累加"初始化;直接对边界格取 min 会读到未定义值。
一维滚动仍需从左往右:minimum_sum[col] 是上,minimum_sum[col-1] 是本行左。
每换一行都要先执行 minimum_sum[0]+=grid[row][0],否则第一列不会向下累加。
3 · 图解
grid=[[1,3,1],[1,5,1],[4,2,1]];minimum_sum 滚动覆盖:

① 首行边界累加
   [1,3,1] → [1,4,5]

② 第 1 行先更新首列(只能从上来)
   [1,4,5] → [2,4,5]

③ 第 1 行完成
   col=1: min(上4,左2)+5=7
   col=2: min(上5,左7)+1=6   → [2,7,6]

④ 第 2 行先更新首列
   [2,7,6] → [6,7,6]

⑤ 第 2 行完成并到达终点
   col=1: min(上7,左6)+2=8
   col=2: min(上6,左8)+1=7   → [6,8,7] ← 答案
一维滚动:按首行与后续每一整行概括,格内覆盖细节见上图
当前格 / 动作写入前来源写入后值minimum_sum 现场
0复制首行保留原值,准备做前缀和[1,3,1]
1完成首行只能从左侧到达,依次累加4、5[1,4,5]
2完成第 1 行首列向下;内部格取上/左较小者2、7、6[2,7,6]
3完成第 2 行同样从左到右覆盖6、8、7[6,8,7]
4返回右下角最后一个滚动格即终点7minimum_sum[-1]=7
4 · 代码
class Solution:
    def minPathSum(self, grid):
        row_count = len(grid)
        col_count = len(grid[0])
        minimum_sum = grid[0][:]

        # 1. 首行只有来自左侧这一条路径
        for col in range(1, col_count):
            minimum_sum[col] += minimum_sum[col - 1]

        # 2. 逐行更新:首列来自上方,内部格取上/左较小者
        for row in range(1, row_count):
            minimum_sum[0] += grid[row][0]
            for col in range(1, col_count):
                minimum_sum[col] = grid[row][col] + min(
                    minimum_sum[col],
                    minimum_sum[col - 1],
                )

        return minimum_sum[-1]
5 · 复杂度

时间 O(m·n);空间 O(n) 滚动数组。

6 · 测试用例

功能[[1,3,1],[1,5,1],[4,2,1]] → 7边界:单格 [[5]] → 5、单行 [[1,2,3]] → 6特殊:单列 [[1],[2],[3]] → 6(只有一条路径)。

最长回文子串

LC 5 ↗多维动态规划 · Medium
1 · 题目

要完成的任务

给定字符串 s,返回其中最长的回文子串。子串必须连续;回文表示从左向右和从右向左读取完全相同。若存在多个同样长的答案,返回其中任意一个即可。

示例

示例 1:s="babad",可输出 "bab""aba" 也正确。

示例 2:s="cbbd",输出 "bb"

约束

  • 1 ≤ s.length ≤ 1000
  • s 只由英文字母和数字组成。
2 · 思路(暴力→最优)

暴力:枚举所有 O(n²) 个子串、各花 O(n) 判回文,共 O(n³)。

正解①:区间 DP。一个子串是回文,当且仅当两端字符相等,并且去掉两端后的内部也是回文。
① 状态含义:is_palindrome[left][right] 表示闭区间 s[left..right] 是否为回文。
② 初始边界:所有长度为 1 的区间都是回文;长度 2 或 3 时,只要两端相等,内部至多一个字符,无需再查表。
③ 转移来源:长度至少为 4 时,需要同时满足 s[left]==s[right]is_palindrome[left+1][right-1]
④ 填表顺序:按区间长度从短到长计算,保证判断外层前,内部区间已经得到结果。

具体例子 "abacaba"长度 3 时先识别出 "aba""aca";长度 5 的 "bacab" 由内部 "aca" 转来;长度 7 的整串再由内部 "bacab" 转来。相同公式连续向外重复两层,同时长度 1/2 覆盖了最短边界。

正解②:中心扩展。每个回文都有一个中心:奇数长度以字符为中心,偶数长度以相邻字符之间的缝隙为中心。从每个中心向两侧扩,直到越界或字符不同,最后一个合法区间就是该中心能得到的最长回文;两类中心都必须检查。

空间优化与取舍:区间 DP 的状态与依赖最直观,时间 O(n²)、空间 O(n²);中心扩展仍是 O(n²) 时间,但只需 O(1) 额外空间且常数更小,面试通常更推荐。

⚠ 易错坑 必须按 window_length 递增(或让左端点倒序),否则 is_palindrome[left+1][right-1] 尚未就绪。
window_length<=3 的短区间特判不能漏,否则长度 2 会错误读取空内部区间。
中心扩展要跑两种中心;只跑奇中心会漏掉 "bb"、"abba" 这类偶回文。
expand 返回的是回退一步后的合法区间 l+1, r-1(循环退出时 l、r 已各越界一格)。
3 · 图解
s="abacaba",下标 0..6;区间从短到长稳定扩展:

① 长度 1:所有单字符为回文
   [0,0] [1,1] [2,2] [3,3] [4,4] [5,5] [6,6] 均为 T

② 长度 2/3:偶数边界无命中;识别三个长度 3 回文
   [0,2]="aba", [2,4]="aca", [4,6]="aba"

③ 长度 5:两端 b==b,内部 [2,4] 已为 T
   is_palindrome[1][5]=T  → "bacab"

④ 长度 7:两端 a==a,内部 [1,5] 已为 T
   is_palindrome[0][6]=T  → "abacaba"

⑤ 中心扩展走到中心 (3,3)
   "c" → "aca" → "bacab",每层两端都相等

⑥ 再扩一层得到 "abacaba",下一层越界后回退到 [0,6]
   偶中心 (3,4) 因 c!=a 立即停止;奇偶中心都已覆盖。
解法① 区间 DP:按区间长度归纳,内部状态先于外层区间就绪
window_length / 动作分支与来源is_palindromebest_start,best_length
0初始化7×7 全 False;答案先取首字符全 F(0,1)
1长度 1所有单字符区间为回文主对角线为 T(0,1)
2长度 2..3长度 2 无命中;长度 3 命中 aba/aca/aba[0,2]、[2,4]、[4,6]=T(0,3)
3长度 4..5[1,5] 两端相等且内部 [2,4] 为 T[1,5]=T(1,5)
4长度 6候选区间两端或内部不满足无新增 T(1,5)
5长度 7[0,6] 两端相等且内部 [1,5] 为 T[0,6]=T"abacaba"
解法② 中心扩展:按中心归纳,一轮包含扩张、回退与必要的答案更新
中心 / 代码动作判定或来源(l,r) / 返回区间(start,end)
0初始化答案先指向 s[0][0,0](0,0)
1中心 0奇中心仅 "a";偶中心 a≠b[0,0] / 空(0,0)
2中心 1奇中心扩出 "aba";偶中心 b≠a[0,2] / 空(0,2)
3中心 2奇中心只有 "a";偶中心 a≠c[2,2] / 空保持 (0,2)
4中心 3c → aca → bacab → abacaba[0,6](0,6)
5中心 4..6 并返回后续候选都不超过 7保持 [0,6]"abacaba"

偶回文靠偶中心:如 "cbbd" 的答案 "bb" 由中心 (1,2) 扩出——只跑奇中心会漏掉它。

4 · 代码
class Solution:
    def longestPalindrome(self, s):
        if not s:
            return ""

        text_length = len(s)
        is_palindrome = [[False] * text_length for _ in range(text_length)]
        best_start = 0
        best_length = 1

        # 1. 单字符区间是最短回文
        for index in range(text_length):
            is_palindrome[index][index] = True

        # 2. 按长度递增,保证内部区间已经计算
        for window_length in range(2, text_length + 1):
            for left in range(text_length - window_length + 1):
                right = left + window_length - 1
                same_ends = s[left] == s[right]
                short_window = window_length <= 3
                inner_palindrome = is_palindrome[left + 1][right - 1]

                if same_ends and (short_window or inner_palindrome):
                    is_palindrome[left][right] = True
                    if window_length > best_length:
                        best_start = left
                        best_length = window_length

        return s[best_start:best_start + best_length]
# 解法二:中心扩展
class Solution:
    def longestPalindrome(self, s):
        if not s:
            return ""

        # 1. 从指定奇/偶中心扩到最后一个合法闭区间
        def expand(left, right):
            while (
                left >= 0
                and right < len(s)
                and s[left] == s[right]
            ):
                left -= 1
                right += 1
            return left + 1, right - 1

        best_left = best_right = 0

        # 2. 每个位置都检查奇中心与偶中心
        for center in range(len(s)):
            for left, right in (
                expand(center, center),
                expand(center, center + 1),
            ):
                if right - left > best_right - best_left:
                    best_left, best_right = left, right

        return s[best_left:best_right + 1]
5 · 复杂度

时间 O(n²) 填半张表;空间 O(n²) 存 is_palindrome(中心扩展法可降到 O(1))。

6 · 测试用例

功能"babad" → "bab"/"aba""cbbd" → "bb"边界:单字符 "a" → "a"、空串 "" → ""特殊:整串回文 "aaaa" → "aaaa"、无长回文 "abc" → "a"

最长公共子序列

LC 1143 ↗多维动态规划 · Medium
1 · 题目

要完成的任务

给定两个字符串 text1text2,返回它们最长公共子序列的长度。子序列允许删除字符但不能改变剩余字符的相对顺序,不要求连续;公共子序列必须同时是两个字符串的子序列。不存在公共字符时返回 0。

示例

示例 1:"abcde""ace" 输出 3,公共子序列是 "ace"

示例 2:"abc""abc" 输出 3

示例 3:"abc""def" 输出 0

约束

  • 1 ≤ text1.length,text2.length ≤ 1000
  • 两个字符串只由小写英文字母组成。
2 · 思路(暴力→最优)

暴力:枚举 text1 的所有子序列(2ᵐ 个)看是否为 text2 的子序列,指数级。

正解:双串前缀 DP。每个状态只比较两个前缀的末字符,再把问题缩短到更小前缀。
① 状态含义:lcs_length[first_prefix][second_prefix] 表示 text1first_prefix 个字符与 text2second_prefix 个字符的 LCS 长度。
② 初始边界:任一前缀为空时没有公共子序列,所以第 0 行、第 0 列全为 0。多开这一行一列后,首字符也能安全读取左上状态。
③ 末字符相同:把这对相同字符接到双方各去掉末字符的 LCS 后,取左上角 +1
④ 末字符不同:两末字符不可能同时进入同一公共子序列,分别舍弃一边,取“上方”和“左方”较大值。

具体例子 "abc""ac"读入 a 时由左上 0 得到 1;读入 b 时与 a/c 都不同,只继承已有长度 1;读入 c 后,最后一格由左上 1 加一得到 2。相同/不同分支跨三行重复执行,并覆盖空前缀边界。

空间优化与取舍:完整二维表最容易解释和回溯具体 LCS,空间 O(mn);若只求长度,可用两行滚动压到 O(n),但覆盖时要保留左上角旧值。

⚠ 易错坑 前缀长度要减 1 才是字符下标:状态 lcs_length[first_prefix][second_prefix] 比较的是两串的 prefix-1 位置。
相等时取左上 +1,不是"上/左 max +1";把两个分支写混是最常见 bug。
base 行列必须显式为 0(列表推导初始化即可),别只填内部格。
3 · 图解
text1="abc"(逐行), text2="ac"(逐列);lcs_length 逐行形成:

初始化空串 base:
        ""  a  c
   ""    0  0  0

读入 a:a==a 走左上+1;a!=c 取上/左最大
   a     0  1  1

读入 b:与 a/c 都不同,继承上或左
   b     0  1  1

读入 c:先继承 1;遇 c==c,左上 1+1
   c     0  1  2  ← 答案 "ac"

四个稳定里程碑:[0,0,0] → [0,1,1] → [0,1,1] → [0,1,2]
二维 LCS:按 text1 的字符逐行归纳,每行汇总与 text2 的全部比较
格 / 字符分支与来源写入值lcs_length 现场
0初始化 4×3空串 base 全 0[[0,0,0],[0,0,0],[0,0,0],[0,0,0]]
1处理字符 aa/a 相等得 1;a/c 不等继承左侧 11、1第1行 [0,1,1]
2处理字符 bb 与 a/c 都不等,各取上/左最大1、1第2行 [0,1,1]
3处理字符 cc/a 继承 1;c/c 由左上 1+11、2第3行 [0,1,2]
4返回右下角两个完整字符串的 LCS2lcs_length[3][2]=2
4 · 代码
class Solution:
    def longestCommonSubsequence(self, text1, text2):
        first_length = len(text1)
        second_length = len(text2)

        # 1. 第 0 行/列表示空前缀,LCS 长度自然为 0
        lcs_length = [
            [0] * (second_length + 1)
            for _ in range(first_length + 1)
        ]

        # 2. 比较两个前缀的末字符
        for first_prefix in range(1, first_length + 1):
            for second_prefix in range(1, second_length + 1):
                if text1[first_prefix - 1] == text2[second_prefix - 1]:
                    lcs_length[first_prefix][second_prefix] = (
                        lcs_length[first_prefix - 1][second_prefix - 1] + 1
                    )
                else:
                    lcs_length[first_prefix][second_prefix] = max(
                        lcs_length[first_prefix - 1][second_prefix],
                        lcs_length[first_prefix][second_prefix - 1],
                    )

        return lcs_length[first_length][second_length]
5 · 复杂度

时间 O(m·n) 填满表;空间 O(m·n),可用两行滚动降到 O(n)。

6 · 测试用例

功能"abcde","ace" → 3边界:一方为空 "","abc" → 0特殊:无公共 "abc","def" → 0、完全相同 "abc","abc" → 3

编辑距离

LC 72 ↗多维动态规划 · Medium
1 · 题目

要完成的任务

给定 word1word2,每次可以插入一个字符、删除一个字符或替换一个字符。返回把 word1 完全变成 word2 所需的最少操作次数。

示例

示例 1:horse→ros 最少 3 步:把 h 换成 r,删除第二个 r,再删除 e。

示例 2:intention→execution 最少 5 步:可依次删除 t,把 i 换成 e、n 换成 x、n 换成 c,再插入 u。

约束

  • 0 ≤ word1.length,word2.length ≤ 500
  • 两个单词只由小写英文字母组成;空字符串是合法输入。
2 · 思路(暴力→最优)

暴力:递归枚举在每一步插入/删除/替换,分支爆炸。

正解:双串前缀 DP。把两个字符串都截成前缀,最后一次编辑会把问题缩成一个更小的前缀对。
① 状态含义:edit_distance[source_prefix][target_prefix] 表示把 word1source_prefix 个字符变成 word2target_prefix 个字符的最少操作数。
② 初始边界:源前缀变空串需要全部删除,所以第一列是 0..m;空串变目标前缀需要全部插入,所以第一行是 0..n
③ 末字符相同:无需新增操作,直接继承左上角。
④ 末字符不同:左上代表替换,上方代表删除,左方代表插入;从三个来源取最小值再加 1。

具体例子 "abc" → "adc"写完空串边界后,处理 a 得到行 [1,0,1,2];处理 b 时,b→d 由左上 0 加一次替换得到 1;最后两个 c 相同,右下角直接继承左上角 1。这样相等与不等分支都跨多行执行,答案为 1。

空间优化与取舍:完整表便于解释三种操作来源和回溯编辑序列,空间 O(mn);只求距离时可用两行滚动压到 O(n),但需额外保存左上旧值。

⚠ 易错坑 base 不是全 0:第 0 行/第 0 列是 j/i(空串要靠纯插/纯删补齐),忘了初始化会少算。
相等时是"免操作、直接继承左上",不要 +1;只有不等分支才 +1。
三个前驱别漏或记错方向:上 (i-1,j)=删、左 (i,j-1)=插、左上 (i-1,j-1)=替。
3 · 图解
word1="abc"(行), word2="adc"(列);edit_distance 逐行形成:

① 零矩阵:先为所有前缀对预留状态
   4×4 全 0

② 空串边界写完
        ""  a  d  c
   ""    0  1  2  3   (全插入)
   首列  0  1  2  3   (全删除)

③ 处理源字符 a
   a/a 相等继承左上 0;其余位置继续取三来源最小值
   当前行 [1,0,1,2]

④ 处理源字符 b
   b/d 不同:min(替0,删1,插1)+1=1
   当前行 [2,1,1,2]

⑤ 处理源字符 c
   最后一格 c/c 相同,继承左上 1
   当前行 [3,2,2,1] ← 只需把 b 替换为 d
编辑距离:先归纳两条 base,再按 word1 的字符逐行填表
格 / 代码动作写入前来源写入后值edit_distance 现场
0初始化零矩阵4×4,尚未写边界全 0edit_distance 全 0
1完成 base第0列全删除;第0行全插入列 [0,1,2,3],行 [0,1,2,3]边界就绪
2处理字符 aa/a 免操作;与 d/c 比较三种编辑0、1、2第1行 [1,0,1,2]
3处理字符 bb/d 由替换来源得到 11、1、2第2行 [2,1,1,2]
4处理字符 c 并返回c/c 相等,右下角继承左上 12、2、1第3行 [3,2,2,1]
4 · 代码
class Solution:
    def minDistance(self, word1, word2):
        source_length = len(word1)
        target_length = len(word2)
        edit_distance = [
            [0] * (target_length + 1)
            for _ in range(source_length + 1)
        ]

        # 1. 空目标靠删除,空源靠插入
        for source_prefix in range(source_length + 1):
            edit_distance[source_prefix][0] = source_prefix
        for target_prefix in range(target_length + 1):
            edit_distance[0][target_prefix] = target_prefix

        # 2. 末字符相同则继承;不同则比较替换、删除、插入
        for source_prefix in range(1, source_length + 1):
            for target_prefix in range(1, target_length + 1):
                if word1[source_prefix - 1] == word2[target_prefix - 1]:
                    edit_distance[source_prefix][target_prefix] = (
                        edit_distance[source_prefix - 1][target_prefix - 1]
                    )
                else:
                    replace_cost = edit_distance[source_prefix - 1][target_prefix - 1]
                    delete_cost = edit_distance[source_prefix - 1][target_prefix]
                    insert_cost = edit_distance[source_prefix][target_prefix - 1]
                    edit_distance[source_prefix][target_prefix] = 1 + min(
                        replace_cost,
                        delete_cost,
                        insert_cost,
                    )

        return edit_distance[source_length][target_length]
5 · 复杂度

时间 O(m·n) 填满表;空间 O(m·n),可滚动降到 O(n)。

6 · 测试用例

功能"horse","ros" → 3"intention","execution" → 5边界:一方为空 "","abc" → 3(全插)、"abc","" → 3(全删);特殊:相同 "abc","abc" → 0、纯替换 "cat","cut" → 1

技巧

这一类是"没套路就是套路"的巧解合集:位运算异或消对、Boyer-Moore 摩尔投票、三指针荷兰国旗、字典序下一个排列、快慢指针找环。它们的共同追求是 O(n) 时间 + O(1) 空间,靠的是对问题结构的洞察而非通用模板。跨类别关联很密:颜色分类双指针(三向切分,快排 partition 的近亲);寻找重复数把数组看成链表用快慢指针,与环形链表II同一个 Floyd 判圈;下一个排列的"找拐点+翻转"呼应矩阵/数组的翻转技巧。看到"要求 O(1) 空间"就往这些巧招上想。

只出现一次的数字

LC 136 ↗技巧 · Easy
1 · 题目

要完成的任务

给定非空整数数组 nums,其中恰有一个元素出现一次,其余每个元素都出现两次。返回那个只出现一次的值,并满足线性时间、常量额外空间。

示例

示例 1:[2,2,1] 输出 1

示例 2:[4,1,2,1,2] 输出 4

示例 3:[1] 输出 1

约束与保证

  • 1 ≤ nums.length ≤ 3×104-3×104 ≤ nums[i] ≤ 3×104
  • 除一个值出现一次外,其余值恰好出现两次。
2 · 思路(暴力→最优)

暴力:哈希表统计每个数出现次数,挑计数为 1 的那个——O(n) 时间但要 O(n) 空间存计数。题目卡死"O(1) 空间",得甩掉哈希。

核心洞察:把全部元素异或起来,累积值就是答案。靠异或的三条性质——x^x=0(同数抵消)、x^0=x(与 0 异或不变)、异或满足交换律与结合律(可任意重排、任意加括号)。锚到第 4 段那行 answer ^= number:它把所有元素串成一条异或链 a1^a2^…^an,遍历完 answer 即所求。

为什么成对的就自己消失了?——链的顺序看似乱,但交换律结合律允许把它任意重排、把相同的两两括起来。以 [4,1,2,1,2] 为例可重排成 4 ^ (1^1) ^ (2^2);每对 1^1=02^2=0,再 4^0^0=4,只剩单独的 4。更本质地逐位看:异或在第 k 位上的结果 = 所有数第 k 位上 1 的个数的奇偶性(偶数个 1 该位得 0)。出现两次的数在每一位都贡献偶数个 1、整位归零;单独的数只贡献一次,于是每一位都恰好保留它,answer 就是它。

取舍:哈希法通用(能处理"其余出现三次"等变体)但 O(n) 空间;异或法 O(1) 空间、一行搞定,代价是只吃"其余都出现偶数次"这一结构——若变成"其余出现三次",两两归零的前提就破了,得改用逐位统计每位 1 的个数再 % 3

⚠ 易错坑 若循环遍历全部 nums,answer 初值必须是 0;也可以令 answer=nums[0],但循环必须从 nums[1:] 开始,不能把第一个数异或两次。
异或法只对"其余元素出现偶数次"成立;出现奇数次(如三次)时成对抵消前提被打破,结论失效。
负数照样成立:异或按补码逐位操作,符号位也参与并正确抵消,不用特判。
3 · 图解
nums = [4, 1, 2, 1, 2]   逐位对齐(4 位二进制):
   4 = 0100
   1 = 0001
   2 = 0010
每一位上 1 的个数:  第0位=2(两个1)  第1位=2(两个2)  第2位=1(一个4)
取奇偶:            0            0            1          → 0100 = 4
异或法:answer 逐个纳入元素,右列是 answer 的实时二进制/十进制,看成对元素如何把彼此的位清零
动作本次元素answer 二进制answer 十进制
0初始化 answer=000000
1answer ^= 4010001004
2answer ^= 1000101015
3answer ^= 2001001117
4answer ^= 1(第二个 1)000101106 ← 第0位被清回 0
5answer ^= 2(第二个 2)001001004 ← 第1位清回 0,只剩 4
4 · 代码
class Solution:
    def singleNumber(self, nums: List[int]) -> int:
        # 1. 以 0 为异或单位元,第一项进入后仍保持原值
        answer = 0

        # 2. 相同元素最终两两抵消,遍历结束只留下单独元素
        for number in nums:
            answer ^= number

        return answer
5 · 复杂度

时间 O(n),一次扫描。空间 O(1),一个累加器。

6 · 测试用例

功能[4,1,2,1,2]→4[2,2,1]→1边界:单元素 [7]→7特殊单独数为负 [-1,-1,3,3,-5]→-5(异或对负数同样成立)、单独数为 0 [0,1,1]→0

多数元素

LC 169 ↗技巧 · Easy
1 · 题目

要完成的任务

给定长度为 n 的非空数组 nums,返回出现次数严格大于 ⌊n/2⌋ 的多数元素。测试数据保证这种元素一定存在;进阶目标是 O(n) 时间、O(1) 额外空间。

示例

示例 1:[3,2,3] 输出 3

示例 2:[2,2,1,1,1,2,2] 输出 2

约束与保证

  • n==nums.length1 ≤ n ≤ 5×104
  • -109 ≤ nums[i] ≤ 109,且多数元素保证存在。
2 · 思路(暴力→最优)

暴力:哈希表计数取最大 → O(n) 时间 O(n) 空间;或排序后取正中间元素(多数占一半以上,排完必落在下标 n/2)→ O(n log n)。都能过,但题目要 O(n) 时间 + O(1) 空间。

核心洞察:Boyer-Moore 摩尔投票。只维护两个变量——候选 candidate 与净票数 count。锚到第 4 段代码:if count==0: candidate=number(票空就改立当前元素为候选),随后 count += 1 if number==candidate else -1(同候选 +1、异候选 -1)。遍历完 candidate 就是多数元素。

凭什么抵消到最后就是多数?count -= 1 这一分支理解成"成对删除":当前元素与候选不同时,就把这一个当前元素和候选池里的一个候选同时丢掉(净票 -1)。每次成对删除,删的是两个不同的数——所以一次最多删掉一个多数元素。而多数元素个数 > ⌊n/2⌋,严格超过其余所有元素之和;无论怎样两两配对删,其余元素必先被删光,多数元素删不完,最后剩在候选池里的只能全是它。candidate 记的就是这个幸存者,count 是它剩下的净票数。

取舍:哈希法直观且不要求"多数一定存在"(能顺带判是否真过半);摩尔投票 O(1) 空间但依赖"多数元素一定存在"——若不保证,需回头再扫一遍数一下 candidate 真实次数是否 > n/2。推广到"找出现次数 > n/3 的数"时要用两个候选 + 两个计数。

⚠ 易错坑 "count==0 换候选"和"count 加减"是同一轮里的两步:先判空换候选,再必然执行加减——刚把 candidate 设成 number,这一步一定 +1,不会漏。
count 是"净票差"不是"出现次数",中途 candidate 可能被换掉好几次,只有最终候选才有意义,别中途下结论。
题目不保证多数存在时,返回前必须二次扫描验证 candidate 真的过半,否则会把"相对多数"误当"绝对多数"。
3 · 图解
nums = [2,2,1,1,1,2,2],候选相同 +1,不同 -1,票数归零就换候选

第 1 步|2,2:candidate=2,count=2
第 2 步|1,1:与两个 2 成对抵消,count=0
第 3 步|1:票数为空,改立 candidate=1,count=1
第 4 步|2:抵消后 count=0;最后一个 2 再被立为 candidate

多数元素比其余元素总数还多,成对删除不同元素后,它一定仍能幸存。
摩尔投票走 [2,2,1,1,1,2,2]:每个元素一行,换候选与本轮投票合并展示
位置x本轮动作candidatecount
02票数为空,改立 2;同候选 +121
12同候选 +122
21不同候选,成对抵消21
31再次抵消,票数归零20
41改立 1;同候选 +111
52不同候选,票数归零10
62改立 2;同候选 +121
4 · 代码
class Solution:
    def majorityElement(self, nums: List[int]) -> int:
        # 1. candidate 是当前候选,count 是它与其他元素抵消后的净票数
        candidate = None
        count = 0

        # 2. 票数为空时换候选;随后本轮元素仍要参与加减
        for number in nums:
            if count == 0:
                candidate = number
            count += 1 if number == candidate else -1

        return candidate
5 · 复杂度

时间 O(n),一次扫描。空间 O(1),仅候选与计数两个变量。

6 · 测试用例

功能[2,2,1,1,1,2,2]→2[3,2,3]→3边界:单元素 [1]→1、两元素 [5,5]→5特殊多数元素分散在两端 [6,5,5]→5(验证换候选逻辑)、含负数。

颜色分类

LC 75 ↗技巧 · Medium
1 · 题目

要完成的任务

nums 只含 0、1、2,分别表示红、白、蓝。必须原地把相同颜色放在一起,并按 0→1→2 排列,不能调用语言内置排序函数;进阶要求常量空间且只扫描一趟。

示例

示例 1:[2,0,2,1,1,0] 原地变成 [0,0,1,1,2,2]

示例 2:[2,0,1] 原地变成 [0,1,2]

约束

  • n==nums.length1 ≤ n ≤ 300
  • nums[i] 只能是 0、1 或 2。
2 · 思路(暴力→最优)

暴力:两趟计数排序——先数出 0/1/2 各几个,再按数量回填。它同样是原地 O(1) 空间,但要扫两遍;进阶目标是一趟扫完。

核心洞察:荷兰国旗三指针,用三个指针把数组切成四段并维持一组不变量。lowcurrenthigh 始终保证:[0, low) 全是 0、[low, current) 全是 1、(high, end] 全是 2,中间 [current, high]未探索区current 逐个检查未探索区的最左元素:遇 1 直接 current++(并入已知的 1 段);遇 0 与 low 交换、low++ current++;遇 2 与 high 交换、high--,但 current 不动

凭什么遇 0 后 current 能前进,遇 2 后 current 却必须原地?差别只在"换到 current 位置的那个值,检查过没有"。锚到代码:
· 遇 0 时和 nums[low] 交换。由不变量,low 落在 [low, current) 这段全是 1 的区间的最左端(或 low==current 时就是自己)。所以换到 current 位置的值要么是个已知的 1,要么是 current 自己——已经归过类,current 放心前进。
· 遇 2 时和 nums[high] 交换。high 属于未探索区 [current, high] 的右端,那个值 0/1/2 都可能,从没被看过;换到 current 位置后必须留在原地重新检查,一旦此时前进就会把可能的 0 漏在错误位置。

对比钉死(三种分支到底差在哪):

三条分支的差异:核心是"换到 current 的值来自已知段还是未探索区"
遇到和谁交换换到 current 的值来自已检查?current 动作
0nums[low][low,current) 全 1 段或 current 自身已知current++(连 low++)
1不交换就是 current 自己已知current++
2nums[high][current,high] 未探索区右端未知current 不动(仅 high--)

取舍:计数排序两趟、代码最简,但扫两遍;三指针一趟到位、O(1) 空间,是快排 3-way partition 的直接应用。

⚠ 易错坑 遇 2 交换后绝不能 current++——换来的值没查,前进会漏判,这是本题第一杀手。
循环条件是 current <= high(含等号):current==high 时那个位置仍在未探索区,必须处理;写成 current < high 会漏掉最后一个元素。
遇 0 交换后 lowcurrent 要一起 ++;只动 low 会导致 low > current,直接破坏 low <= current 不变量。
3 · 图解
nums = [2,0,2,1,1,0]      low=0,current=0,high=5

第 1 步|current 遇 2:与 high 的 0 交换
          [0,0,2,1,1,2]   high=4,current 仍为 0,要重查换来的 0
第 2 步|连续处理两个 0:
          [0,0,2,1,1,2]   low=2,current=2
第 3 步|current 又遇 2:与 high 的 1 交换
          [0,0,1,1,2,2]   high=3,current 仍为 2
第 4 步|连续越过两个 1:current=4 > high=3,结束

最终分区:[0,0] | [1,1] | [2,2]
三指针走 [2,0,2,1,1,0]:每轮处理一个未分类位置,重点观察遇 2 后 current 不前进
轮次current / 值本轮操作numslow · current · high
初始0 / 2全部属于未探索区[2,0,2,1,1,0]0 · 0 · 5
10 / 2与 high 的 0 交换;current 不动[0,0,2,1,1,2]0 · 0 · 4
20 / 00 与 low 交换;low、current 前进[0,0,2,1,1,2]1 · 1 · 4
31 / 0再次归位 0[0,0,2,1,1,2]2 · 2 · 4
42 / 2与 high 的 1 交换;current 不动[0,0,1,1,2,2]2 · 2 · 3
52、3 / 1连续越过两个 1[0,0,1,1,2,2]2 · 4 · 3
结束current>high未探索区为空[0,0,1,1,2,2]2 · 4 · 3
4 · 代码
class Solution:
    def sortColors(self, nums: List[int]) -> None:
        # 1. [0,low) 是 0,(high,end] 是 2,[current,high] 未分类
        low = 0
        current = 0
        high = len(nums) - 1

        while current <= high:
            # 2. 遇 0:放到左侧;换来的值已分类,所以两个指针都前进
            if nums[current] == 0:
                nums[low], nums[current] = nums[current], nums[low]
                low += 1
                current += 1
            # 3. 遇 2:放到右侧;换来的值未知,current 必须原地重查
            elif nums[current] == 2:
                nums[high], nums[current] = nums[current], nums[high]
                high -= 1
            else:
                # 4. 遇 1:位置已经正确,直接进入下一个未分类位置
                current += 1
5 · 复杂度

时间 O(n),currenthigh 相向而行,每轮至少缩小一次未探索区。空间 O(1),三个指针原地。

6 · 测试用例

功能[2,0,2,1,1,0]→[0,0,1,1,2,2]边界:单元素 [0]→[0]、已有序 [0,1,2]特殊逆序 [2,1,0]→[0,1,2]全同色 [1,1,1]、只有两种颜色 [2,0,0]→[0,0,2]

下一个排列

LC 31 ↗技巧 · Medium
1 · 题目

要完成的任务

排列是数组全部元素的一种线性顺序。请把 nums 原地变成字典序中紧随其后的、更大的排列;若当前已经是最大排列,就改成字典序最小的升序排列。只能使用常量额外空间。

示例

示例 1:[1,2,3]→[1,3,2]

示例 2:[3,2,1]→[1,2,3],因为不存在更大排列。

示例 3:[1,1,5]→[1,5,1]

约束

  • 1 ≤ nums.length ≤ 100
  • 0 ≤ nums[i] ≤ 100
2 · 思路(暴力→最优)

暴力:生成全部排列、排序、找当前排列的后一个,阶乘级 O(n!·n) 完全不可行。要的是只在原数组上直接算出下一个更大排列。

核心洞察:标准三步——找下降点、贴身换大、翻转后缀。锚到第 4 段代码逐步看:
· ① 从右找第一个"下降点" pivot(满足 nums[pivot] < nums[pivot+1])。为什么找它pivot 右侧的后缀是非递增的,非递增意味着这段后缀已经是它自己能排出的最大排列,不动 pivot 只重排后缀绝不可能变更大;所以要变大,必须动 pivot 这一位,且它是"还能变大"的最右一位。
· ② 从右找第一个 > nums[pivot] 的位置 successor,交换两者为什么从右找第一个更大的:后缀非递增,从右数第一个比 nums[pivot] 大的,正是后缀里"大于 nums[pivot] 的数中最小的那个"。把它换到第 pivot 位,能让这一高位尽可能小地变大——增幅最小才可能是"下一个"。
· ③ 翻转 pivot 之后的后缀。交换后后缀仍然非递增(下面证),翻转成非递减就是这批数字能排出的最小尾巴,使整体增量降到最低。

为什么交换后后缀还是非递增?successor 是从右第一个 > nums[pivot] 的位置,故 nums[successor+1..] ≤ nums[pivot] < nums[successor],且 nums[pivot+1..successor-1]> nums[pivot]。交换后原 nums[pivot] 落到 successor 位;它的左邻仍 它、右邻仍 它,非递增性不破。所以后缀翻转(而非重新排序)就够,O(n)。

取舍:若整趟找不到下降点,说明数组整体降序(已是字典序最大),此时后缀就是整个数组,pivot 走到 -1,直接跳过交换、整体翻转回最小的升序,正好符合题意的循环。

⚠ 易错坑 找下降点用 nums[pivot] >= nums[pivot+1] 才继续左移(含等号),遇重复值[1,1,5] 才不会误停;找 successornums[successor] <= nums[pivot] 才继续左移,保证换到的是最右侧的合法数、维持后缀非递增。
第 ③ 步翻转的起点是 pivot+1;若 pivot==-1(无下降点),起点变成 0 恰好整体翻转,同一段代码自然覆盖,不用额外特判。
三步顺序不能乱:必须先定 pivot、再找 successor 交换、最后翻转;漏掉翻转会得到一个更大但不是最小的排列(跳号)。
3 · 图解
nums = [1,3,5,4,2]

第 1 步|从右找下降点:3 < 5,所以 pivot=3(下标 1)
          [1, |3|, 5,4,2]
第 2 步|从右找最小的更大值:4 > 3,所以 successor=4
第 3 步|交换 3 和 4:
          [1, 4, 5,3,2]       高位只增加了最小可能幅度
第 4 步|翻转非递增后缀 [5,3,2]:
          [1, 4, 2,3,5]       后缀变成最小升序

这就是原排列之后紧邻的下一个排列。
标准三步走 [1,3,5,4,2]:搜索过程按阶段概括,交换和翻转各展示稳定结果
阶段动作位置 / 值nums
1从右扫描递减后缀pivot=1 / 值3[1,|3|,5,4,2]
2从右找第一个大于3的数successor=3 / 值4[1,3,5,|4|,2]
3交换 pivot 与 successor1 ↔ 3[1,4,5,3,2]
4翻转下标 [2..4] 的后缀left=2,right=4[1,4,2,3,5]
4 · 代码
class Solution:
    def nextPermutation(self, nums: List[int]) -> None:
        # 1. 从右找到仍能变大的最右位置 pivot
        pivot = len(nums) - 2
        while pivot >= 0 and nums[pivot] >= nums[pivot + 1]:
            pivot -= 1

        # 2. 从右找到比 pivot 大的最小值,并交换到高位
        if pivot >= 0:
            successor = len(nums) - 1
            while nums[successor] <= nums[pivot]:
                successor -= 1
            nums[pivot], nums[successor] = nums[successor], nums[pivot]

        # 3. 把非递增后缀翻转为最小非递减序;pivot=-1 时会翻转整个数组
        left = pivot + 1
        right = len(nums) - 1
        while left < right:
            nums[left], nums[right] = nums[right], nums[left]
            left += 1
            right -= 1
5 · 复杂度

时间 O(n),最多三趟线性扫描。空间 O(1),原地交换与翻转。

6 · 测试用例

功能[1,2,3]→[1,3,2]边界:单元素 [1]→[1]、两元素 [1,2]→[2,1]特殊已是最大 [3,2,1]→[1,2,3](找不到下降点,整体翻转)、含重复 [1,1,5]→[1,5,1][2,3,1]→[3,1,2]

寻找重复数

LC 287 ↗技巧 · Medium
1 · 题目

要完成的任务

nums 长度为 n+1,每个值都位于 [1,n]。其中只有一个数会重复出现,但它可能出现两次以上;其余整数各出现一次。返回这个重复数,并且不能修改原数组,只能使用 O(1) 额外空间。

示例

示例 1:[1,3,4,2,2] 输出 2

示例 2:[3,1,3,4,2] 输出 3

示例 3:[3,3,3,3,3] 输出 3

约束与进阶

  • 1 ≤ n ≤ 105nums.length==n+11 ≤ nums[i] ≤ n
  • 只有一个整数出现两次或更多,其余值各出现一次。
  • 进阶要求 O(n) 时间,并说明为什么重复值必然存在。
2 · 思路(暴力→最优)

暴力:哈希集合边扫边查重复 → O(n) 空间违规;排序后看相邻相等 → 改了数组违规。都被题目两条硬约束(不改数组、O(1) 空间)挡住。

核心洞察:把数组看成"函数图/链表",重复数就是环入口。规定"从下标 i 连一条边到下标 nums[i]"。因为值域是 [1,n] 全是合法下标,从下标 0 出发不断 x=nums[x] 永远不越界、无限延续;有限节点 + 无限步数,必然成环(鸽巢)。而那个重复的值意味着有两个不同下标的边同时指向它 → 它的入度 ≥ 2 → 它正是环的入口。于是问题化归为"带环链表找环入口",用 Floyd 快慢指针

为什么"相遇后一个回起点、同速再走"就停在环入口?设起点到入口距 a、入口到首次相遇点距 b、环长 c。相遇时 slow 走了 a+b,fast 走了两倍即 2(a+b),而 fast 比 slow 多走了整数圈:2(a+b) = (a+b) + k·c,化简得 a+b = k·c,即 a = k·c - b = (k-1)c + (c-b)。这里 c-b 恰是"从相遇点再往前走回到入口"的距离。所以:一个指针从起点a 步,另一个从相遇点(c-b)(外加若干整圈)步,二者同时到达入口——同速前进必在入口会合。与 LC142 环形链表II 完全同构。

取舍:还有二分值域解法——对答案 mid 数组内"≤mid 的个数"若 > mid 则重复数在左半,O(n log n)、同样不改数组 O(1) 空间;Floyd 是 O(n),更优但推导烧脑,面试可先讲二分兜底、再上 Floyd。

⚠ 易错坑 两指针起点是 nums[0](走一步后的位置),不是下标 0;下标 0 永不被任何边指向(值域从 1 起),它只是链的起点、不在环里。
阶段 1 的相遇判定要先走再判(do-while 语义):若一进循环就判 slow==fast,初始二者本就相等会立刻误退出。
阶段 2 把 slow 重置回 nums[0]、fast 留在相遇点不动,之后两者都每次只走一步;若 fast 仍走两步就不是找入口的逻辑了。
3 · 图解
nums = [1,3,4,2,2]   下标 i --边--> nums[i]:
 idx:  0 1 2 3 4
 val:  1 3 4 2 2

从下标0走: 0 → 1 → 3 → 2 → 4 → 2 → 4 → 2 ...
                        └───────┘  环 2→4→2, 入口=2 (即重复数)
阶段1 找相遇点:每轮同时展示 slow 一步、fast 两步后的稳定落点
slow = nums[slow](1步)fast = nums[nums[fast]](2步)slow==fast ?
初始化slow=nums[0]=1fast=nums[0]=1先移动再判断
第1轮1→31→3→23≠2,继续
第2轮3→22→4→2相遇点=2
阶段2 找环入口:slow 回起点、fast 留在相遇点,两者每轮各走一步
slow(从起点)fast(从相遇点)slow==fast ?
重置slow=nums[0]=1fast=21≠2,开始同速走
第1轮1→32→43≠4,继续
第2轮3→24→2在入口2相遇,返回2
4 · 代码
class Solution:
    def findDuplicate(self, nums: List[int]) -> int:
        # 1. 把 nums[index] 当作下一节点,快慢指针先在环内相遇
        slow = nums[0]
        fast = nums[0]
        while True:
            slow = nums[slow]
            fast = nums[nums[fast]]
            if slow == fast:
                break

        # 2. slow 回到链起点;两指针同速再走,相遇处就是环入口
        slow = nums[0]
        while slow != fast:
            slow = nums[slow]
            fast = nums[fast]

        return slow
5 · 复杂度

时间 O(n),两阶段各线性。空间 O(1),两个指针、不改数组。

6 · 测试用例

功能[1,3,4,2,2]→2[3,1,3,4,2]→3边界:最短 [1,1]→1特殊重复多次 [2,2,2,2,2]→2、重复数在开头/结尾、[1,1,2]→1