‹ 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²)。慢的根源:外层每固定一个数,内层都要重新把数组扫一遍去找它的另一半——前面扫过的元素明明都"见过",却没留下任何可直接查询的记录,同一批元素被一遍遍重扫。

① 关键转化——把"枚举数对"换成"枚举单数 + 查另一半":当前值 value 一固定,另一半根本不用枚举——它被等式唯一确定为 complement = target - value。于是每轮真正的问题只剩一个:complement 这个值之前出现过吗?在哪个下标?这正是字典「值 → 下标」的 O(1) 拿手活,n² 次数对验证退化成 n 次 O(1) 查询。这套「存我见过的、查我需要的」模式,也是 LC560 查「前缀和 − k」的底层零件。

② 只查左边为什么不漏:常见困惑——seen 只装当前位置左侧的元素,万一我的另一半在右边呢?不漏:设答案是 (i, j)i < j,扫到 j 时 i 必然已写入 seen。每一对都在它靠后的那个成员处被发现,恰好一次——所以每个数只需对"左边"负责,单向一趟就覆盖全部数对。

③ 三个量的身份:

身份表:谁存历史、谁当查询的 key、谁是当前候选
代码里的量它在题里是谁提供什么
seen当前位置左侧已扫元素的「值 → 下标」映射O(1) 回答"某值出现过没有、在哪";查询时刻不含当前值自己
complement = target - value当前值缺的那"另一半"的只当查询的 key,本身不写入表
index / value当前候选=配对里靠后的成员命中时与 seen[complement] 组成答案

④ "先查后存"从身份表直接推出:seen 的身份是"左侧世界",那么第 index 轮查询时它就不能含 value 自己——写入必须放在查询之后。这一个顺序同时解决了"同一元素不能用两次":当 target = 2 × value 时,查 complement 只可能命中左边的同值元素,绝不会命中自己;反过来"先存后查"就是把自己也放进了候选。

⑤ 用样例验证关键一步:[3,3], target=6:i=0 查 complement=3,seen 还是空表 → 未命中,写入 {3:0};i=1 再查 complement=3 → 命中下标 0,返回 [0,1]。两个 3 来自不同下标,顺序对了就天然不自配。多解法取舍:若数组已排序可用对撞双指针省掉 O(n) 空间,但本题输入未排序且要原下标,哈希一趟最直接(细节见段5追问)。

⚠ 易错坑 先存后查(本题最高频错序):循环里先 seen[value] = index 再查 complement。最小反例 [3,3], target=6:i=0 就命中刚写入的自己,错误返回 [0,0],真值 [0,1]
映射方向存反成「下标 → 值」(seen[index] = value):查 complement in seen 时对照的是下标集合。最小反例 [2,7], target=9:i=1 查"2 在不在 key 里",key 只有 {0},未命中,错误返回 [],真值 [0,1]
两趟法(先全存再查)忘排除自身:不判 seen[complement] != i。最小反例 [3,2,4], target=6:全存后 seen={3:0, 2:1, 4:2},i=0 查 complement=3 命中自己,错误返回 [0,0],真值 [1,2]。一趟"先查后存"天然免掉这个判断。
3 · 图解
里程碑①  主例开始:nums=[2,7,11,15],target=9,seen={}
里程碑②  i=0,value=2:complement=7 未命中;本轮结束 seen={2:0}
里程碑③  i=1,value=7:complement=2 命中历史下标 0;返回 [0,1]
里程碑④  重复值例 [3,3]:第一个 3 先查空表,再写 seen={3:0}
里程碑⑤  第二个 3 查询时尚未写入自身,只会命中下标 0,不会得到 [1,1]
哈希扫描:主例展示循环主线,重复值例验证“先查后存”不会使用同一下标
阶段输入现场补数查询时 seen判定与动作
0主例初始化{}等待扫描
1主例 i=0,value=27{}未命中,轮末写入 {2:0}
2主例 i=1,value=72{2:0}命中并返回 [0,1]
3重复值例 i=0,value=33{}先查未命中,再写入 {3:0}
4重复值例 i=1,value=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²) 里被重复扫描的时间。高频追问一:"数组已排序还用哈希吗?"——排序数组可对撞双指针,O(n) 时间、O(1) 额外空间;但本题要返回原下标,若自己排序就得先绑定 (值, 原下标) 再排,O(n) 空间并没省下来,未排序输入直接哈希一趟最划算。追问二:"哈希查询一定是 O(1) 吗?"——是均摊/期望 O(1),极端冲突下单次会退化,面试口径答"均摊 O(1)";要最坏保证可换平衡树,查询 O(log 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 log k)(n 个词、词长 k)。n ≤ 10⁴ 时约 5×10⁷ 个词对、每对还要排序比较,必然超时。慢的根源:"属于哪一组"每次都靠两两比较现场重算——eat、tea、ate 同组这件事被反复验证,却始终没有一个能直接查的组标识。

① 关键转化——把"两两比较"换成"各自算指纹":异位词的定义=字符及各自次数完全相同,只有排列顺序不同。顺序是唯一的自由度,那就把它抹掉:对词内字符排序,''.join(sorted(word))——eat、tea、ate 排序后都是 aet。于是"互为异位词"⇔"规范形相等",组籍判断从两两比较变成一次字典查询,问题退化成标准子问题:按 key 分桶。这与 LC1 同款——哈希 O(1) 命中,只是 key 从"值本身"升级成"自己设计的规范形"。

② 第二种指纹——把定义直接写成数据:等价条件既然是"26 个字母的次数都相同",也可跳过排序,统计出长度 26 的 counts,转成 tuple(counts) 当 key。取舍:排序法每词 O(k log k)、代码短;计数法每词 O(k),但前提是字符集已知且固定(这里是小写字母),任意 Unicode 输入时排序法更通用。

③ 谁当 key、谁进桶(本题最容易存错的一步):

身份表:指纹只是组标识,答案里的成员永远是原词
代码里的量它在题里是谁提供什么
word原词=答案分组里的成员只进桶存起来,不当 key
signature(排序串 / 计数元组)word 所在组的组标识:同组词算出同一个它只当 groups 的 key,绝不进结果
groups组标识 → 该组已收集的全部原词list(groups.values()) 即答案

④ key 为什么必须拼成 str / 转成 tuple:字典 key 要求可哈希(不可变),而 sorted(word) 返回 list、计数数组 counts 也是 list,直接当 key 会抛 TypeError。所以排序法必须 ''.join(...)、计数法必须 tuple(...)——这不是代码风格,是能不能运行的问题。

⑤ 常见困惑——指纹会不会"撞车"把不同组错并?不会。排序串和 26 位计数元组都是"字符及次数"的无损表示:从指纹能唯一还原每个字母各几个,因此指纹相等 ⇔ 字符及次数相同 ⇔ 互为异位词。至于字典内部的哈希冲突,Python 会用键判等自动化解,不影响分组正确性。

⑥ 用样例验证:["eat","tea","tan"]:eat→aet 新建桶;tea→aet 命中同桶;tan→ant,与 aet 不同,另开新桶——同组必聚、异组必分。空串 "" 也有稳定指纹("" / 全 0 元组),无需特判。

⚠ 易错坑 拿 list 当 key:groups[sorted(word)]groups[counts]。最小反例 ["a"]:第一个词就抛 TypeError: unhashable type: 'list',拿不到任何输出,真值 [["a"]]
把指纹当成员存进桶:groups[signature].append(signature)。最小反例 ["eat","tea"]:错误输出 [["aet","aet"]],真值 [["eat","tea"]]——指纹只是组标识,结果要的是原词。
计数法把次数无分隔拼成字符串当 key(''.join(map(str, counts))):最小反例 ["aaaaaaaaaaab","abbbbbbbbbbb"](11 个 a + 1 个 b vs 1 个 a + 11 个 b),两词的 key 都是 1110…0,被错并成一组;真值是两个独立组。数字之间要么加分隔符,要么直接用 tuple。
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:形成第三桶;返回三个桶的列表
解法① 排序指纹:每个单词完成“生成指纹并入桶”后记录一次
wordsignature本轮摘要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),所有词都要存进桶。高频追问一:"排序法和计数法怎么选?"——k ≤ 100 时两者都远快于两两比较,排序法代码短更稳;字符集固定且 k 大时计数法有渐进优势;字符集不固定(任意 Unicode)时 26 位计数元组失效,退回排序法或改用 (字符, 次数) 有序元组。追问二:"真正被卡掉的是什么?"——是 O(n²·k) 的两两比较:n=10⁴ 时约 5×10⁷ 对;指纹法把"跟谁同组"变成 O(1) 查桶,总量只有 10⁶ 级。

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)。直觉二:对每个数 x 用哈希集合向右数 x+1, x+2, …——但 [1,2,…,n] 这种整段输入下,段里每个成员都会把自己右边重数一遍,总步数 n + (n−1) + … = O(n²)。慢的根源:同一段连续序列被它内部的每个成员重复清点。

① 约束排除法:题目点名要 O(n),这一刀先杀掉排序路线(O(n log n) 绕不开)。剩下的只有"不排序也能 O(1) 回答某数在不在"→ 哈希集合 set(nums)。而直觉二离正解只差一步:别让段里每个成员都清点,给每段指定唯一的清点负责人

② 关键转化——只从段的最左端起数:一段连续序列 [a, a+1, …, b] 里,只有最左端 a 满足"a-1 不在集合";其余成员都有前驱。于是把枚举对象从"每个数"换成"每段的最左端":只有通过 value - 1 not in values 检查的值才向右清点,整段恰好被数一次。这与 LC84"每个矩形只记在最矮柱账上"是同一招——给每个答案单元指定唯一负责人,天然不重不漏

③ 各个量的身份(length 一身二职):

身份表:length 在同一时刻既是已确认段长,又是下一步探测的偏移
代码里的量它在题里是谁提供什么
values = set(nums)去重后的数值全集O(1) 回答"某数在不在";重复值只留一份,长度不会虚增
value(外层遍历量)候选的段最左端有前驱者当场淘汰,不做任何清点
length已确认的段长;同时 value + length 是下一个待探测的数探测失败的那一刻,length 定格为整段长度
longest所有段长的最大值最终答案;空数组时循环不执行,保持初值 0

④ 判据方向从直觉推导:识别一段只需认出它的一个端点。"无前驱(value-1 不在)+向右数",或"无后继(value+1 不在)+向左数",都对;错的是混搭——右端判据配向右数,每段只能数出 1(见坑①)。习惯取"无前驱 + 向右",与从小数到大的直觉一致。

⑤ 常见困惑——for 里套 while,凭什么还是 O(n)?while 只有段最左端能进入,清点的又恰是自己段内的成员:每个元素一生只被"它所在段的最左端"探测一次,所有 while 的总步数=各段长之和 ≤ n。这是摊还分析——内层预算全局共享,不能按"每轮最坏 × 轮数"相乘。另一个困惑——"不加起点判据答案也对,为什么必须加?"对,结果不会错,但退化成直觉二的 O(n²),10⁵ 规模直接超时:起点判据不是正确性开关,是复杂度开关。

⑥ 用样例验证:[100,4,200,1,3,2]values={1,2,3,4,100,200}。候选起点只有 100(99 不在)、200(199 不在)、1(0 不在);从 1 起探测 2、3、4 连续命中,5 缺席,length 定格 4,longest=4。而 4 因为前驱 3 在集合被一票淘汰——省下的正是"段内成员重复清点"那份冤枉工。

⚠ 易错坑 判据与方向混搭:写成 if value + 1 not in values 却仍向右 while value + length in values。最小反例 [1,2]:1 被跳过(2 在集合),2 通过判据但 3 不在,只数出 1——错误输出 1,真值 2
不转 set,直接在 list 上 inwhile value + length in nums):每次成员判断 O(n),最坏 O(n²)。输入 [0,1,2,…,99999] 时仅清点这一段就要约 10⁵×10⁵ 次比较——答案不错,但必然超时。
排序备胎忘了处理重复:线扫只写"差 1 加长、否则重置"。最小反例 [1,2,2,3]:排序后扫到第二个 2 时差 0 被当成断开而重置,错误输出 2,真值 3——相等应跳过而非重置(set 法因去重天然免疫)。
3 · 图解
nums=[100,4,200,1,3,2] → values={1,2,3,4,100,200}
里程碑①  100 的前驱 99 不在集合:起点,序列 [100],longest=1
里程碑②  4 的前驱 3 在集合:不是起点,跳过
里程碑③  200 的前驱 199 不在集合:序列 [200],longest 仍为 1
里程碑④  1 是起点,连续命中 2、3、4,遇 5 停止
里程碑⑤  本段长度 4,更新 longest=4;2、3 后续均因有前驱而跳过
起点法:按候选值或完整序列汇总,不展开每次 length 自增
value前驱存在?阶段 / 本轮摘要本段长longest
0建集合并初始化 longest=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 的总步数=各段长之和 ≤ n——摊还分析,双层循环不能按"每轮最坏 × 轮数"相乘,非起点会被 continue 挡掉。空间 O(n) 存 set。高频追问一:"最坏会退化吗?"——算法本身的步数严格 O(n),唯一的不确定来自哈希:set 查询是均摊/期望 O(1),对抗性哈希冲突下单次会退化,面试口径答"均摊 O(n)"。追问二:"能不能 O(1) 空间?"——不能同时保住 O(n) 时间:不给额外空间就只剩排序路线(原地排序 O(n log n) / O(1)),时间与空间在本题是二选一的取舍。

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) 空间。另一条直觉是"遇到 0 就删掉、往末尾 append":Python 的 list.pop(index) 每次要整体左移后面的元素,是 O(n),最坏退化 O(n²)。慢和费的共同根源:没有意识到每个非零数的最终位置其实当场就能确定。

① 约束排除法+关键转化:题目画了两支箭头。"原地"杀掉抄数组做法;"保持非零数相对顺序"杀掉"遇 0 就和末尾交换"这类不稳定分区(会把靠后的非零数提到前面)。两条路堵死后只剩一条:0 的最终位置取决于非零数有多少、不好直接定,但第 k 个非零数的最终位置就是下标 k-1,完全确定——于是视角从"把 0 往后挪"换成"把非零数往前搬",一趟扫描逐个搬到位。

② 读写双指针与三个区间的身份:fast 负责读、slow 负责指定写入位置,数组被它们切成三段:

身份表:两个指针与三个区间各自保证什么
代码里的量 / 区间它是谁保证什么
fast唯一的读头,按原序逐个检查只管"读到什么",不管放哪
slow下一个非零数的落点只在写入一个非零数之后才 +1
[0, slow)已完工区全非零,且保持原相对顺序
[slow, fast)攒下的 0 区slow 落后于 fast 的差距全是被跳过的 0
[fast, n)未读区保持原样,等待扫描

③ 为什么一次 swap 就够——从"0 区"直接推出:fast 读到非零数时,若 slow < fast,中间 [slow, fast) 全是 0,故 nums[slow] 必是 0:交换=把非零数填进空位、同时把一个 0 丢到 fast 处——一步办完两件事,不需要"扫完再补 0"的第二趟。若前缀还没出现过 0,则 slow == fast,交换是自己换自己的 no-op;这不是浪费,而是让三区不变量在"有 0 / 无 0"两种局面下统一成立,省掉特判。

④ 保序与末尾全 0 都是不变量的推论:非零数被 fast 读到的先后=它们在原数组的先后,而落点 slow 单调递增,先读到的落得更靠前,相对顺序自然不变;扫完后 [0, slow) 装下了全部非零数,[slow, n) 只剩被换出去的 0。

常见困惑——交换会不会把某个还没读到的非零数甩到后面、造成二次搬运把顺序弄乱?不会:被换到 fast 位置的永远是 nums[slow],它要么是 0(slow < fast 时),要么就是 fast 自己(slow == fast 时)——非零数从不向后移动,每个非零数至多被搬一次。

⑤ 用样例验证一步:[0,1,0,3,12] 走到 fast=3(读到 3)时 slow=1,此刻数组为 [1,0,0,3,12],中间 [1,3) 恰好是两个 0——正是"0 区"。交换位置 1、3 得 [1,3,0,0,12]:非零区扩成 [0,2),一个 0 被丢到位置 3,三区不变量继续成立。

⚠ 易错坑 遇 0 也推进 slow(最高频写错):slow 一旦跨过 0,0 就永久占住待填位。最小反例 [0,1]:错误版在 fast=0 读到 0 仍 slow += 1,fast=1 时变成自己换自己,输出 [0,1],真值 [1,0]
覆盖写法忘补尾 0:改用 nums[slow] = nums[fast] 后必须再把 [slow, n) 全部写 0。最小反例 [1,0,2]:只覆盖不补尾得 [1,2,2](位置 2 的旧值没人清理),真值 [1,2,0]
"遇 0 与末尾交换"破坏保序:最小反例 [0,1,2]:把位置 0 的 0 和末尾交换得 [2,1,0],非零顺序成了 2,1,真值 [1,2,0]
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),原地交换、无额外数组。交换写法天然稳定(保序)。高频追问一:"swap 版和'覆盖+补零'版哪个写入更少?"覆盖版固定写 n 格(非零数写前段 + 0 补尾);swap 版只在 slow < fast 时真正动数据,写入 = 2 × "第一个 0 之后的非零数个数"——0 少或靠后时 swap 几乎不写,但 0 在最前、后面全非零时(如 [0,1,1,…,1])swap 要写约 2n 格反而更多,两者按数据分布取舍。追问二:"为什么不遍历时 pop 掉 0 再 append 到尾部?"list.pop(index) 是 O(n),最坏 O(n²);而且边遍历边删会让下标跳过元素,正确性都难保证。

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),算 min(height[i], height[j]) × (j-i) 取最大,O(n²);n ≤ 10⁵ 时约 5×10⁹ 次计算,必然超时。慢的根源:绝大多数组合"生死早已注定"——某条线一旦注定当矮壁、宽度又只会更窄,它参与的一整批组合都不可能翻盘,但暴力仍然逐个去算,这种"可整批排除"的信息完全没被利用。

① 关键转化——从"逐对枚举"换成"从最宽开始、每步整批淘汰":把还没验证过的组合看成候选区间 [left, right]:从两端(宽度最大)出发,每结算一对,就证明"某条线剩下的全部组合都不如刚结算的这一对",然后让这条线永久退场。每步淘汰一条线,n 步恰好覆盖全部约 n²/2 个组合——这就是对撞指针。它是本类的母题:LC15 的内层、LC42 的双指针版都是同一套"夹逼淘汰"。

② 每个量的身份:注意两个指针各有双重身份,"不漏解"的证明全靠第二重:

身份表:指针既是当前两壁,也是候选区间的边界
代码里的量它在题里是谁提供什么
left / right当前结算容器的两壁;同时是"未淘汰候选区 [left, right]"的两端宽度 = right-left;指针退场=该线整批组合被淘汰
min(height[left], height[right])矮壁=容器短板高度上限:水只能装到矮壁顶,高壁再高也用不上
best_area已结算组合中的冠军循环结束即全局答案

③ 为什么移矮壁——从"宽度只减"这条直觉推:指针只能向内走,宽度必然变小,想让面积变大,唯一的希望是高度变大。而高度 = 两壁较矮者:留着矮壁,高度天花板就钉死在它头上——对面换得再高,min 也超不过矮壁,"宽减、高不增",面积只会缩水。所以只有换掉矮壁,高度才可能抬升。这一步还只是"值得试",下一步证明它一个都不漏。

常见困惑——移走矮壁 left 后,"以 left 为左壁、右壁在中间"的那些组合从没算过,凭什么不漏?height[left] ≤ height[right],任取中间一条线 kleft < k < right):组合 (left, k) 的面积 = min(height[left], height[k]) × (k-left),其中高度项 ≤ height[left](min 被矮壁封顶,k 多高都没用),宽度项 < right-left——两项都不占优,面积必然 ≤ 刚结算的 (left, right)。也就是说刚才那一次结算,已经替 left 把它全部剩余组合的最大可能"预支"了,left 可以安心退场。每一步都带着这个证明淘汰一条线,所以这不是碰运气的贪心,而是逐步整批排除的完整枚举。

④ 顺序与等高:其一,先结算面积、再移指针——循环第一轮结算的正是全场最宽的组合,先移后算会直接跳过它。其二,两壁等高时双方同为矮壁,上面的排除论证对两侧同时成立,移哪边都不漏;代码归入 else 统一移 right,无需特判。

⑤ 用样例验证第一步的"整批淘汰":[1,8,6,2,5,4,8,3,7]left=0(高 1)、right=8(高 7),结算 1×8=8。此举淘汰的是 (0,k),k=1..7 共 7 个组合:每个面积 ≤ 1×7=7 < 8——矮壁 1 封死高度、宽度又更窄,确实一个都翻不了盘。下一轮 left=1(高 8)立即结算出 7×7=49,即最终答案。

⚠ 易错坑 先移指针后结算面积:最小反例 [1,1]——唯一一对还没结算指针就相遇,输出 0,真值 1。
写成"移高壁":最小反例 [1,8,7]——首轮结算 min(1,7)×2=2 后移走高壁 7,只剩 (0,1) 得 1,输出 2;真值是 (1,2)min(8,7)×1=7。移矮壁则第二轮即得 7。
高度写成 max(height[left], height[right]):最小反例 [1,2]——算出 2×1=2,真值 min(1,2)×1=1;水从矮壁顶溢出,高壁多出的部分盛不住水。
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)。高频追问一:"这是贪心,怎么证明不漏解?"标准回答:每移一次矮壁=淘汰"以它为一端的全部剩余组合",这些组合高被矮壁封顶、宽更窄,都 ≤ 刚结算的值(见段2 的排除论证),n 步恰好淘汰完全部组合——本质是每步带证明的整批排除,不是启发式。追问二:"要求返回最优的两条线下标怎么改?"在 best_area 更新的同一分支里同步记录 (left, right) 即可,复杂度不变。

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 · 思路(暴力→最优)

暴力起手:三重循环枚举 (i, j, k) 判和为 0,O(n³);n ≤ 3000 时约 4.5×10⁹ 次,超时。慢的根源有两个:其一,固定前两个数后,第三个数只能是 -nums[i]-nums[j] 这一个值,却还在整个数组里盲找;其二,重复值会把同一个"值三元组"生成很多遍,最后还得靠集合兜底去重。

① 约束排除法——"不含重复三元组"指向排序:去重难在相同值散落各处、全局比对代价大。排序让重复值相邻,"跳过重复"退化成"和前一个比一下";同时数组有序后,"和太小往右换大的、太大往左换小的"才有方向可言。一次排序同时解决去重与查找两个难题——这就是正解第一行是 nums.sort() 的原因。

② 关键转化——固定一个数,三降二:枚举三元组中最小的那个数 nums[index](排序保证它排最前),剩下的任务变成:在右侧有序区间 [index+1, n-1] 里找两个数使和为 -nums[index]。这正是 LC1 两数之和的有序版——有序版不必用哈希,直接套 LC11 同款对撞收缩。

③ 三个下标的身份:

身份表:外层定目标,内层对撞,去重各守一岗
代码里的量它在题里是谁移动 / 跳过的时机
index本轮三元组的最小数,定下内层目标 -nums[index]逐个前进;与前一个值相同 → 整轮跳过
left / right有序区间 [index+1, n-1] 的对撞两端和太小 left 右移、太大 right 左移;命中后两端内缩再各自跳过重复值
current_sum三数之和=判定器<0 / >0 / =0 三个分支各对应一个动作

④ 内层对撞为什么不漏——LC11 的排除逻辑原样搬来:current_sum < 0 时,nums[left] 换更靠左的搭档只会更小,它已无翻身可能,永久出局(left 右移);> 0 时对称淘汰 nums[right]。每步至少淘汰一个候选,内层一轮 O(n) 扫完该区间全部两数组合。

⑤ 三道去重哨卡——位置与方向都从"排序后重复相邻"推出:
其一 外层与前一个比:index > 0 and nums[index] == nums[index-1] 才跳过,保证每个值只当一次固定数。
其二 命中后跳 left:收集完再 while nums[left] == nums[left-1]: left += 1,跳过会拼出同样三元组的左端。
其三 命中后跳 right:对称地 while nums[right] == nums[right+1]: right -= 1

常见困惑——外层跳过重复的 index,会不会把 [-1,-1,2] 这种自身就含重复值的答案漏掉?不会。哨卡跳的是"同一个值第二次当固定数":第一个 −1 当固定数时,它的搜索区间里还留着第二个 −1,[-1,-1,2] 在那一轮就已收集;轮到第二个 −1 再跑一遍,只会把同样的组合重收一次。"和前一个比"恰好保证:每个值当固定数有且只有一次,而且是搜索区间最大的那一次。

⑥ 用样例验证:[-1,0,1,2,-1,-4] 排序得 [-4,-1,-1,0,1,2]index=1(第一个 −1):left=2(−1)、right=5(2),和为 0 → 收 [-1,-1,2];内缩后 left=3(0)、right=4(1),和又为 0 → 收 [-1,0,1]index=2(第二个 −1)与前一个相同被整轮跳过——重复的 [-1,-1,2] 正是在这一岗被拦下。

⚠ 易错坑 外层去重方向写反:写成与后一个比(nums[index]==nums[index+1] 就跳)会把每个值的"第一次"跳掉、只留搜索区间更小的最后一次。最小反例(已排序)[-1,-1,0,2]:第一个 −1 被跳过,第二个 −1 的区间只剩 [0,2] 凑不出 0,输出 [],真值 [[-1,-1,2]]。完全不做外层去重则 [-1,-1,0,1] 会输出两份 [-1,0,1]
去重时机提前到命中前:进入内层循环就先跳重复再判和。最小反例 [0,0,0]:left 从 1 直接跳到 2 与 right 相遇,唯一解从未被收集,输出 [],真值 [[0,0,0]]。正确顺序:判和 → 命中收集 → 才跳重复。
剪枝条件写松:nums[index] > 0break(最小数为正则三数和必为正);写成 >= 0 会把 [0,0,0] 也剪掉,输出 [],真值 [[0,0,0]]
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)。高频追问一:"不排序、用哈希行不行?"行但不划算:内层用哈希找 -a-b 同样是 O(n²),可"按值去重"必须把每个三元组规范化(组内排序)后塞集合,代码更绕、常数更大;排序的 O(n log n) 被 O(n²) 吸收,不亏。追问二:"内层命中后又移指针又跳重复,会不会超 O(n)?"不会——left 只增、right 只减,跳重复消耗的也是同一份移动预算,每轮内层总移动不超过区间长度。

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 · 思路(暴力→最优)

一句话总纲:先把总水量拆成一列一列的账——每列接水量 = min(左侧最高, 右侧最高) − 自身柱高;再从两端向中间对撞,每轮把较矮一端的那一列当场结算并向内走一步;两指针相遇时所有列都已结算,累计值就是答案。

① 核心洞察——一列的水位由两侧最高柱中较矮的那根决定:整片水洼形状不规则,直接算无从下手,先只盯住某一列,问"它头顶能停多高的水"。水要停得住,左边必须有柱子挡着——水位最多齐平左侧最高柱,再高就从左边溢走;右边同理。所以这一列的水位 = min(左侧最高柱, 右侧最高柱),减去自身柱高就是本列水量,全题答案 = 逐列水量之和。(暴力做法就是对每列向左右各扫一遍找这两个"最高",O(n²);慢的根源是"左侧最高"在相邻两列之间只差一根柱子,却每列都从头重扫,信息毫无复用。)

② 先修一版可提交的解——按列预计算(段4 解法二):"每列答案 = 左侧统计量与右侧统计量取 min",这正是 LC238 除自身以外数组的乘积的套路:答案需要两侧信息,就各扫一趟、把每个方向"目前为止的最高"存进数组。left_max[index] 记下标 0..index(含自身)里的最高柱高,从左往右一趟递推;right_max[index] 对称地从右往左。这两条数组连起来看,就是罩在柱子上方、从两侧只升不降的"包络"轮廓线(段3、段4 沿用这个叫法)。最后逐列累加 min(left_max[index], right_max[index]) − height[index]。O(n) 时间、O(n) 空间,已是可提交的正解;代价是两条辅助数组。

③ 再省掉两条数组——对撞双指针(段4 解法一,主解):结算一列真正用到的只是那个 min:只要能确定哪一侧是短板,另一侧的精确值根本不重要。于是学 LC11 盛最多水的容器从两端向中间走:比较两端柱高,较矮的一端,它这一列的水位已经被"自家这侧的最高"钉死——因为对面正立着一根比它更高的柱子兜底,min 一定落在自家这侧。矮侧当场结算、向内走一步,两条包络数组就退化成 left_maxright_max 两个标量,空间 O(1)。(另有一条"横着按层结算"的单调栈路线:遇到右壁时弹栈、一层一层加水,与 LC84 柱状图中最大的矩形互为镜像;同为 O(n) 但要维护栈,面试首选上面两法,此处不展开。)

④ 每个量的身份:

身份表(双指针版):left_max / right_max 各自对哪段区间负责、保证什么
代码里的量它在题里是谁对结算提供什么
left / right两个待结算的候选列每轮恰好结算其中较矮的一列
left_max[0..left] 里的最高柱=左列自家一侧的挡板(这段区间全走过,值是确切的)结算左列时:水位 = left_max
right_max[right..n-1] 里的最高柱=右列自家一侧的挡板(同样是确切值)结算右列时:水位 = right_max
height[left] vs height[right]每轮的判定条件:矮的一侧,对面必有更高柱兜底决定这一轮结算哪侧、移动哪个指针

⑤ 循环体的顺序从"水量不能为负"推出:结算一列必须left_max = max(left_max, height[left]),后累加 left_max − height[left]。当前列若刷新了最高,先更新再相减恰好得 0——它自己就是新挡板,头顶无水;反过来先减后更会算出负数,把总量倒扣(坑②的反例)。

判据也别混:这套代码里每轮比较的是两端柱高 height[left]height[right],不是 left_maxright_max——两套判据各配一套写法,混搭会漏结算(坑①的反例)。

⑥ 常见困惑——right_max 只看过 [right..n-1],中间一大段还没扫,凭什么能替左列担保"右侧够高"?因为结算左列用的不是右侧的精确最高,而是一个下界height[right] 本人就站在左列右边,而能进左分支的前提正是 height[left] < height[right]——右侧"至少这么高",min 必然落在左侧手里,左列水位就是 left_max

再追一层:"万一 left_max 比右侧真实最高还高,min 取错边怎么办?"这种局面根本进不了左分支:左侧真有超高柱时,height[left] < height[right] 一直不成立,左指针被那根超高柱卡住不动,改由右指针带着自己确切的 right_max 一路结算过来。用 [5,1,3] 验证:left 全程卡在高 5 的柱上,凹槽(高 1)由右分支以 right_max=3 结算得 3−1=2,与真值 min(5,3)−1=2 一致。

⑦ 用原题示例走一遍:height=[0,1,0,2,1,0,1,3,2,1,2,1],初始 left=0right=11left_max=right_max=0,总量 0。

left=0(高 0)< right=11(高 1):结算左端,left_max→0,水 0,left→1。
left=1(高 1)不小于 right=11(高 1):结算右端,right_max→1,水 0,right→10。
left=1(高 1)< right=10(高 2):结算左列,left_max→1,水 0,left→2。
left=2(高 0)< right=10(高 2):left_max=1 不变,接 1−0=1 格,累计 1,left→3。
接着右侧连续结算:right=10(高 2)把 right_max 推到 2、水 0;right=9(高 1)接 2−1=1 格,累计 2;right=8(高 2)水 0,right→7。
此后 left=3..6(高 2,1,0,1)都小于 right=7(高 3):left_max 先被下标 3 推到 2,下标 4、5、6 依次接 1、2、1 格,累计 6。
left 与 right 在下标 7(全场最高柱)相遇,循环结束,输出 6,与示例一致。

⚠ 易错坑 判据混搭:在"比两端柱高"的代码结构里把条件改成 left_max < right_max(初始都为 0)。最小反例 [2,0,1]:首轮 0<0 不成立先动右侧,中间的凹槽 0 直到两指针相遇都没人结算,输出 0,真值 min(2,1)-0=1。要么比柱高结算矮侧(本代码),要么整体换成"先更新两侧 max 再比 max"的另一套写法,两套不能各拿一半。
先累加后更新 max:最小反例 [1,2]:本无水,却先加 left_max(0) - height[0](1) = -1 再更新,输出 −1,真值 0——每段上坡都会被倒扣。
前缀数组版"含不含自身"没对齐:段4 的 left_max[index] 含自身,min 减 height 天然非负;若改成"不含自身"的严格版又不加 max(0, ·) 截断,最小反例 [2,1,3]:三列贡献 (0-2)+(2-1)+(0-3) = -4,真值 1(仅中列接 1 格)。
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 两条包络。高频追问一:"while left < right 相遇的那一列没被结算,漏水了吗?"没有——每次移动的都是较矮一侧,谁也跨不过全场最高柱,两指针必在某根全场最高柱处相遇;该列水位 min(两侧最高) ≤ 自身柱高,水量为 0。追问二:"面试怎么展示优化层次?"逐列公式 O(n²) → 两趟包络 O(n)/O(n)(LC238 套路)→ 发现只需分辨短板侧、压成 O(1) 对撞——能讲全三层正是本题作为 Hard 的考察点。

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²)。慢的根源:左端点每右移一格,右侧那段刚验证过"无重复"的字符被整段重扫——相邻两次枚举本可共享的查重结论完全没有被复用。

① 关键转化——换枚举对象:不枚举 O(n²) 个子串,改成对每个右端点 right 只问一件事:以 right 结尾的无重复子串,左端最远能伸到哪?把这个最远左端记作 left,每轮用 right-left+1 更新答案。任何最长子串总得结尾在某个 right 上,所以 n 个右端点就覆盖了全部候选。剩下的唯一问题:right 前进后,left 怎么快速跟进。

② left 只进不退、且不漏解(滑窗 O(n) 的正确性核心):设上一轮 [left, right-1] 已是无重复窗口。right 前进后若出现重复,祸首只能是新字符 s[right]——旧的部分本来就互不相同,修复只需右移左端、把它的旧副本排出去。而 left 绝不需要回退:假如某个更靠左的 l < left 能与新 right 组成无重复窗口,那它的子串 [l, right-1] 也无重复,说明上一轮 left 根本不该停在这么靠右的位置,矛盾。所以"最远合法左端"随 right 单调右移,两个指针全程只前进——每个字符进窗口恰一次、出窗口至多一次,总动作 ≤ 2n。

③ 新 left 一步定位——记每个字符的最近下标:重复只可能由 s[right] 自己造成,所以只需知道它上一次出现在哪。用哈希表 last[c] 存字符 c 最近出现的下标:若旧副本还在窗口里(last[c] >= left),left 直接跳到 last[c]+1。这是 LC1 两数之和 的哈希套路换了个键值:那里存"值→下标"查补数,这里存"字符→最近下标"查旧副本。取舍:set 逐格缩版(发现重复后 while 逐个移出左侧字符)逻辑最直观,同为摊还 O(n);dict 下标跳版一步到位、没有内层循环,本文采用后者。

④ 各量身份(last[c] 同一个量、两种时刻两种身份):

身份表:谁划定窗口、谁只是档案
代码里的量它在题里是谁提供什么 / 不管什么
right本轮新纳入字符的下标=窗口右端唯一可能制造重复的新字符;窗口原有部分保证干净
left以 right 结尾的合法窗口的最远左端只前进不后退;由②保证不漏解
last[c]>= left 时)窗口内的旧副本=必须排出的障碍提供新 left 的落点 last[c]+1
last[c]< left 时)窗口外的过期档案只存不用;拿它移动 left 就是回退(WA 根源)
best历史所有合法窗口的最大长度只在 left 调整完、窗口重新合法后更新

⑤ 循环体三行的顺序从身份表直接推出:固定为"缩左 → 刷新 last[c]=right → 更新 best"。
先缩左再刷新:缩左要读的是旧副本的位置;若先写 last[c]=right,读到的就是自己(right >= left 恒成立),left 被挤到 right+1,窗口永远为空。
best 放最后:right-left+1 统计的必须是合法窗口,缩左之前窗口可能还带着重复。
刷新无条件执行:无论本轮有没有触发缩左,c 的最近位置都已变成 right;漏更新会留下过期下标,下次误判"旧副本不在窗口内"。

常见困惑——left 直接跳到 last[c]+1,被跳过的那些左端点不会漏掉答案吗?不会。任何满足 left ≤ l ≤ last[c] 的窗口 [l, right] 都同时框住下标 last[c]right 这两个相同字符,全部注定非法;而以这些 l 开头、结尾更早的窗口,在之前的 right 轮次里已经统计过。被跳过的只有"必然非法"的组合,一个合法候选都没丢。

⑥ 用样例验证最险的一步:"abba" 走到 right=3 读 a:此时 left=2(上一轮被重复 b 挤过来的),而 last[a]=0 < 2——旧 a 早已在窗口外,left 不动,窗口 "ba" 合法,best 保持 2。这一步正是判据必须带 last[c] >= left 的原因。

⚠ 易错坑 判据只写 c in last、漏掉 last[c] >= left:过期档案也会触发跳转,left 反而回退。最小反例 "abba":right=3 时 left 从 2 退回 1,把含重复 b 的 "bba" 计成长 3,错误输出 3,真值 2。
best 更新写在缩左之前:统计到含重复的非法窗口。最小反例 "aa":right=1 时先算出 1-0+1=2 才缩左,错误输出 2,真值 1。
last[c]=right 的时机错:写在缩左判断之前,缩左读到的"旧位置"变成自己,left 恒被挤到 right+1,最小反例 "ab" 错误输出 0(真值 2);或只在字符首次出现时记录,留下过期下标,最小反例 "abaa"——right=3 时 last[a] 仍是 0 < left=1,误判旧 a 不在窗内,把 "baa" 计成 3(真值 2)。
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):dict 下标跳版没有内层循环,每轮严格 O(1);set 逐格缩版有内层 while,但也不退化——left 只前进、全程最多走 n 步,每个字符进窗口恰一次、出窗口至多一次,总动作 ≤ 2n。这是摊还分析:while 的弹出预算全局共享只有 n 个,不能按"每轮最坏 × 轮数"相乘成 O(n²)。空间 O(min(n, Σ)),Σ 为字符集大小,last 至多存 Σ 个键。高频追问一:"全相同 "aaaa" 这种极端输入会退化吗?"不会:每轮 left 恰好跟着 right 走一步,窗口恒为 1,总移动仍是 2n。高频追问二:"空间能不能 O(1)?"字符集固定(ASCII/小写字母)时 Σ 就是常数,本身已是 O(1);字符集无界时必须记住"窗口里有哪些字符",哈希表规模省不掉。

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 · 思路(暴力→最优)

一句话总纲:先把 p 的字符频次数成一张目标计数表;再让一个长度固定为 len(p) 的窗口在 s 上从左到右逐格平移,每平移一步只更新"右端进一个、左端出一个"两个字符的计数;每当窗口计数与目标计数完全相同,就把窗口左端下标记入答案

① 核心洞察一——异位词就是频次表相等:字母异位词只许打乱顺序、不许增删字符,所以两串互为异位词 ⇔ 26 个小写字母的出现次数逐一相同。顺序信息可以整个扔掉,比较对象从"子串"降维成一张计数表。这正是 LC49 字母异位词分组 的判据——那里给静态字符串分组,这里要把同一判据装到会移动的窗口上。

② 核心洞察二——窗口定长,平移一步只换两个字符:合法子串的长度被 p 锁死,记 window_size=len(p)n=len(s)。起点相邻的两个窗口共享 window_size−1 个字符,真正不同的只有两个:右端新进来的 s[right],和左端刚离开的 s[right-window_size]。所以计数表不必推倒重建,每步只改这两个键,更新是 O(1)。这也是本题与 LC3 无重复最长子串 的区别:那里窗口变长,要靠"条件被破坏"触发缩左;这里左右两端步调一致,只平移、不判缩。起点 0..n−window_size 逐个被检查、每个恰好一次,匹配可重叠也不怕漏。(暴力对每个起点截取子串排序再比较是 O(n·window_size·log window_size),改成每段重数一遍频次也要 O(n·window_size)——慢就慢在把共享的 window_size−1 个字符的统计推倒重算,完全没用上"只有一进一出"这条关系。)

③ 解法①就此成型——每平移一步整表比较:先建好目标计数 need 和第一个窗口 s[0..window_size-1] 的计数 window;此后每轮右进一个、左出一个,计数减到 0 的键要删掉(否则和 need 判等会失败),再问一句 window == need,相等就记录左端。每轮判等最多比 26 个字母,成本 O(26)。

④ 记哪个下标、何时开始判,从"右减左加一"推出:窗口是闭区间 [left..right],长度 right-left+1=window_size,解出 left=right-window_size+1——命中时写进答案的是这个左端,不是 right。第一个完整窗口出现在 right=window_size-1(下标从 0 起,刚好凑满 window_size 个字符),所以判满条件是 right >= window_size-1,多写或少写 1 都会错过或错位一个窗口。

⑤ 用原题示例走一遍(解法①):s="cbaebabacd", p="abc"need={a:1,b:1,c:1},window_size=3。

初始窗口 [0..2]="cba"window={c:1,b:1,a:1},与 need 相等 → 命中,记左端 0。
right=3 进 e、出 c:窗口 "bae",多了 e、缺 c,不等。
right=4 进 b、出 b:窗口 "aeb",e 还在,不等。
right=5 进 a、出 a:窗口 "eba",仍不等。
right=6 进 b、出 e:窗口 "bab"{b:2,a:1},b 超、缺 c,不等。
right=7 进 a、出 b:窗口 "aba"{a:2,b:1},不等。
right=8 进 c、出 a:窗口 "bac"{b:1,a:1,c:1},与 need 相等 → 命中,记 8−3+1=6
right=9 进 d、出 b:窗口 "acd",不等。结果 [0,6]

⑥ 再省一步——用"达标种类数"免去整表比较(解法②):解法①每轮都把 26 个字母问一遍,可其实每轮只有进、出两个字符的计数动了,其余字母的达标状态不可能变。于是引入两个计数器:required=need 中计数为正的字符种类总数(固定不变);matches=此刻计数恰好等于需求(window[c]==need[c]need[c]>0)的字符种类数。命中判定简化成一句 matches==required,每轮只在进、出两个字符处各做一次修正。

⑦ 各量身份(一张固定不动、一张随窗口动,别混用):

身份表(解法②):每个量是谁、谁动谁不动
代码里的量它在题里是谁提供什么 / 不管什么
need[c]p 对字符 c 的需求量建好后只读不写;need[c]==0 的字符不参与 matches 统计
window[c]当前窗口里 c 的实际个数每轮只有进、出两个字符的计数会变
requiredneed 中计数为正的字符种类总数(固定不变)matches 的达成上限;matches==required ⇔ 全部达标
matches此刻恰好 window[c]==need[c](且 need[c]>0)的字符种类数只认"恰好相等";超标、不足都不算达标
right-window_size+1命中时窗口的左端下标写进答案的是它,不是 right

⑧ matches 只在计数跨过 need[c] 的瞬间变化:matches 数的是"恰好相等"的种类数,所以只有计数跨越 need[c] 这条线的那一步需要修正,其余增减(远未达标、早已超标)都与它无关。以更新后的 window[c] 为判据:
右进 c(计数 +1)后:window[c]==need[c],刚达标,matches+1;若 window[c]==need[c]+1,由达标转超标,matches−1。
左出 c(计数 −1)后:window[c]==need[c],从超标落回达标,matches+1;若 window[c]==need[c]-1,跌破需求,matches−1。
四条对称成两对;need[c]==0 的字符整个跳过这套逻辑。

⑨ 用小例验证最容易错的一步——先进后出的"临时超长态":s="abab", p="ab",看 right=2 这一轮。

右进 a 后:window={a:2,b:1},窗口临时含 3 个字符,a 超标,matches 由 2 降到 1。
左出 s[0]=a 后:window={a:1,b:1},a 落回达标,matches 回到 2。
此刻才判定:命中,记起点 2-2+1=1
超长的中间状态是真实存在的,命中判定必须放在左出完成之后,否则这一窗会被误判为"未达标"。

取舍:解法①每轮整表比较 window == need,成本 O(26)、几乎不会写错,小写字母场景首选;解法②用 matches 把每轮判定降到 O(1),字符集很大(如 Unicode)时优势明显,代价是⑧里的四个临界时刻容易写错一个。

常见困惑——matches==required 只考核了 p 里有的字符,窗口里混进 p 没有的字符怎么办?混不进来。窗口长度恒为 window_size;全部需求字符恰好达标时,它们合计已占 sum(need)=window_size 个位置,恰好填满窗口,没有空位留给多余字符。反过来,只要有一个多余字符占位,必有某个需求字符缺一个,matches 立刻小于 required。封死漏洞的是"长度锁定"+"全部恰好达标"两个条件的合力,单独哪个都不够。

⚠ 易错坑 命中时记 right 而不是左端 right-window_size+1:最小反例 s="ab", p="ab",命中发生在 right=1,错误输出 [1],真值 [0]
手写 dict 计数时零键不删:左出把计数减到 0 后残留 {a:0, b:1},与 {b:1} 判等失败。最小反例 s="ab", p="b":错误输出 [],真值 [1]。Counter 在 Python 3.10+ 判等会忽略零计数所以侥幸能过,但旧版本、其它语言、手写 dict 都会翻车——统一删零键最稳。
只在右进处更新 matches、左出忘了对称修正:最小反例 s="aab", p="ab"——right=2 时旧 a 离窗,a 本应从超标落回达标(matches 应回到 2),漏掉修正后 matches 停在 1,错误输出 [],真值 [1]
判满写成 right >= window_size:第一个完整窗口(right=window_size−1)永远不被检查。最小反例 s="ab", p="ab"(解法②):错误输出 [],真值 [0]
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

            # 减到 0 就删零键:手写 dict 判等时零键致败;Counter 仅 3.10+ 才忽略零计数
            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):每个字符右进窗口恰一次、左出至多一次——定长滑窗是"每元素进出各 ≤1 次"最规整的情形,连摊还论证都不需要。解法①每轮多一次 Counter 整表判等,严格说是 O(26·n),字符集固定视为常数;解法②靠 matches 把每轮判定压到 O(1)。空间 O(1):至多 26 个计数键。高频追问一:"零计数键能不能不删?"手写 dict 不行({a:0,b:1}{b:1} 判等失败);Counter 3.10+ 判等忽略零计数所以能过,但依赖版本行为、跨语言不可迁移,删零键才是通解。高频追问二:"字符集扩到 Unicode 还是 O(n) 吗?"解法①每轮判等涨到 O(|Σ|)、总 O(n·|Σ|);解法②的 matches 只做进出两个字符的临界检查,与 |Σ| 无关,仍是 O(n)——这正是解法②存在的意义。

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 · 思路(暴力→最优)

一句话总纲:从左到右扫一遍,边走边累加前缀和,并用哈希表记录每个前缀和出现过的次数;每到一个位置,查一次"等于 当前前缀和−k 的历史前缀和出现过几次",把次数累加进答案,扫完即得结果。

① 核心洞察——区间和就是两个前缀和之差:先约定记号:把 nums[0..j] 的总和记作前缀和 pre[j],并补一个空前缀 pre[−1]=0(一个元素都没加时的和)。任何子数组的和都能写成 sum(i..j) = pre[j] − pre[i−1]。于是"以 j 结尾、和为 k 的子数组有几个"等价于:j 之前出现过的前缀和里,值等于 pre[j] − k 的有几个。这正是 LC1 两数之和 的哈希套路——边扫边查"能与当前值配出目标的那个数出现过没有",只是这里要的是出现次数而非下标。

回看暴力慢在哪:枚举所有 (i,j) 再逐段求和是 O(n³);预处理前缀和数组后区间和 O(1) 可查,仍是 O(n²)——固定右端点 j 后,还要把每个左端点逐一试过。其实每个左端点对应的前缀和在扫描过程中都出现过一次,只是没有按值计数组织起来;把它们存进哈希表,"逐一试左端点"就压缩成一次查表。

② 常见困惑——为什么不能用滑动窗口?看到"连续子数组 + 和"很容易先想滑窗,但本题 nums[i] 可为负:右端纳入新数,窗口和不一定变大;左端移出元素,窗口和也不一定变小。"和超了就缩左、不够就扩右"的判断依据不存在,滑窗这条路被负数堵死。剩下的路只有一条:保留"枚举右端点",把"数合法左端点"这一步加速到 O(1)。

③ 各个量的身份:

身份表:扫描到位置 j 时每个量的含义
代码里的量它在题里是谁提供什么 / 不管什么
prefix当前前缀和 pre[j]nums[0..j] 的总和所有以 j 结尾的子数组共用的右端数据
prefix − k本轮要查的目标值=合法左端点前一格的前缀和 pre[i−1]它每出现一次=一个以 j 结尾、和为 k 的子数组;不关心它出现在哪个下标
prefix_count"前缀和值 → 出现次数"的哈希表(只收录 j 之前的前缀)O(1) 回答目标值出现过几次;只记次数不记位置——答案只要数量
预置的 {0:1}空前缀 pre[−1]=0 的那一次出现让"从下标 0 开始"的子数组也能查到配对的前缀

④ 查 prefix−k 还是 prefix+k,不用背:目标等式是 pre[j] − pre[i−1] = k,移项得 pre[i−1] = pre[j] − k——手里拿着当前值,要找的是"比自己小 k"的历史值,所以是减。{0:1} 同样不用背:当 prefix 本身恰好等于 k 时,子数组 [0..j] 明明合法,此时要查的目标值是 prefix−k = 0,对应"一个元素都没加"的空前缀——哈希表里若没有这一条,整类"从头开始"的答案全部丢失。

⑤ 先查后存的顺序:本轮能配对的必须是"j 之前"的前缀。若先把当前 prefix 存进哈希表再查,则 k=0 时目标值 prefix−k 恰好等于 prefix 自己,会查到刚存进去的自己——等于把"长度为 0 的空子数组"数成一个答案。循环体固定为:累加 → 查 prefix−k → 存 prefix

⑥ 常见困惑——哈希只存次数、不存下标,信息够吗?够。题目只问"有几个子数组":同一个前缀和值出现 3 次,就是 3 个不同的左端点,每个都独立贡献一个合法子数组——"出现次数"恰好就是要累加的量,位置从头到尾用不上。

⑦ 用原题示例 1 nums=[1,1,1], k=2 走一遍:

起点:prefix=0prefix_count={0:1}answer=0
nums[0]=1prefix=1,查 1−2=−1,0 次;存入后 {0:1, 1:1}
nums[1]=1prefix=2,查 2−2=0,命中空前缀 1 次——对应子数组 [0..1]answer=1;存入后 {0:1, 1:1, 2:1}
nums[2]=1prefix=3,查 3−2=1,命中"读完 nums[0] 那轮存入"的前缀 1——对应子数组 [1..2]answer=2
答案 2 ✓;两次命中的历史前缀都严格早于当前位置,⑤的顺序自洽。

⚠ 易错坑 漏预置 prefix_count = {0:1}:所有"从下标 0 开始且和恰为 k"的子数组集体失踪。最小反例 nums=[3], k=3prefix=30,空哈希表查不到,错误输出 0,真值 1(子数组 [3] 本身)。
先存后查:当 k=0 时当前前缀和自己配对。最小反例 nums=[1], k=0:先存后哈希表为 {0:1, 1:1},再查 1−0=1 命中自己,错误输出 1,真值 0(没有和为 0 的非空子数组)。
查成 prefix + k:方向反了。最小反例 nums=[1,2], k=1:错误版依次查 1+1=23+1=4 均未命中,错误输出 0;正确版在 prefix=1 时查 0 命中预置的空前缀,真值 1(子数组 [1])。
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(n):最坏情况下 n+1 个前缀和互不相同,账本存满。高频追问一:"能不能把空间压到 O(1)?"一般不能——负数杀死滑窗判据后,必须保留全部历史前缀的计数;只有当 nums 全为正(窗口和随两端移动严格单调)时,才能改用双指针滑窗做到 O(1) 空间。追问二:"如果只问'是否存在'而不是'有几个'?"哈希表退化成集合即可,框架不变——计数版与存在版的差别只在 value 存什么。

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 · 思路(暴力→最优)

一句话总纲:维护一个存下标的双端队列,保持对应值从队头到队尾递减:每读一个新元素,先从队尾删掉所有值不超过它的旧下标,再把它的下标入队,然后把已滑出窗口的队头删掉;窗口填满后,每走一步,队头下标对应的值就是当前窗口的最大值。

① 核心洞察——一批下标永远当不上最大值,可以当场删除:看窗口里的两个位置 i 和 j,j 在 i 右边(j > i)。若还满足 nums[j] ≥ nums[i],那么 i 从此出局:窗口是连续区间,今后任何还包含 i 的窗口一定也包含 j,而 j 的值不比 i 小——i 永远轮不到当最大值。把所有这样的出局下标当场删光,幸存下标恰好构成下标递增、值递减的序列,这就是单调队列。(暴力对每个窗口重扫求最大是 O(n·k),慢就慢在这些注定出局的下标每一轮都被重新比较一遍。)

② 为什么不用堆、为什么队列要双端:想 O(1) 取最大也可以用大顶堆,但窗口滑动时最左元素要离开,堆删不掉任意元素,只能存 (值, 下标) 做惰性删除(取堆顶时发现已滑出窗口才弹),O(n log k) 且实现繁琐——堆的根本问题是保管了所有窗口元素,包括已出局的。单调队列反过来只保留"还有机会"的下标。但下标除了被①的判据删除,还会因滑出窗口而失效:按值的删除发生在队尾(同 LC739 每日温度 的单调栈:新元素清掉所有不比自己大的旧元素),滑出窗口的删除发生在队头——两端都要能删,栈升级成双端队列。

③ 队列里每个位置的身份:

身份表:queue 存下标,对应值从队头到队尾递减
代码里的量它在题里是谁提供什么 / 不管什么
queue 中任一下标仍可能成为某个窗口最大值的下标:它右侧(窗口内)没有值 ≥ 它的位置等排在它前面的下标全部滑出窗口后,它就成为队头;已删除的下标不再关心
queue[0] 队头当前窗口最大值的下标提供答案 nums[queue[0]];也是唯一需要判"是否已滑出窗口"的位置
queue[-1] 队尾队列中值最小、下标最大的候选下标新元素只和它比较;一旦新元素值 ≥ 它,它被永久删除
right − k当前窗口左边界外的最后一个下标队头下标 ≤ 它即已滑出窗口,从队头删除——这一步只看下标,与值无关

④ 两种删除性质完全不同:队尾删除比的是——出现了更晚、值又不小于它的元素,它被永久淘汰、永不回归;队头删除比的是下标——它只是滑出了窗口范围,与值的大小无关。

⑤ 常见困惑——队列里为什么存下标、不存值?判"是否滑出窗口"只有下标能回答;只存值时,连相同的值是哪一次出现都分不清。存下标也不损失信息——nums[index] 随时取回值。一份下标,两种用途:比大小用 nums[index],判滑出用 index 本身。

⑥ 为什么队头永远是窗口最大值:先看删得合不合法:被队尾删除的下标 i,是因为出现了更右、值不小于它的下标 j——今后任何含 i 的窗口都含 j,i 的答案资格被 j 完全顶替,删掉零损失。再看会不会漏:当前窗口真正的最大值下标一定还在队列里——它没滑出窗口,也不可能被队尾删除(它右侧的窗口内没有比它大的值;若曾有相等值把它删掉,留下的那个值相同,答案不变)。队列的值从队头到队尾递减,所以队头就是窗口最大值的下标。与新元素相等的队尾同样可以删(顶替者值相同、更晚滑出),所以比较用 更省;写 < 不算错,只是队列里残留无用的重复值。

⑦ 两个边界不用背:窗口覆盖 [right−k+1, right],队头滑出即 queue[0] < right−k+1,整理成 queue[0] ≤ right−k;又因窗口每步只右移一格、至多送走一个下标,且队内下标严格递增,只需 if 不需 while。第一个填满的窗口右端是 k−1,故从 right ≥ k−1 才开始记答案。

⑧ 用原题示例 nums=[1,3,-1,-3,5,3,6,7], k=3 走一遍(queue 以"下标→值"展示):

right=0 读 1:队空,入队,queue=[0→1],窗口未满。
right=1 读 3:队尾值 1 ≤ 3,删下标 0;入队后 queue=[1→3],窗口未满。
right=2 读 −1:队尾值 3 > −1,直接排到队尾,queue=[1→3, 2→−1];窗口 [1,3,-1] 的答案=队头值 3。
right=3 读 −3:排到队尾,queue=[1→3, 2→−1, 3→−3];队头下标 1 > 3−3=0,未滑出,答案 3。
right=4 读 5:队尾 −3、−1、3 依次 ≤ 5,全部删除,queue=[4→5];答案 5。
right=5 读 3:排到队尾,queue=[4→5, 5→3];答案 5。
right=6 读 6:删掉值 3 和 5,queue=[6→6];答案 6。
right=7 读 7:删掉值 6,queue=[7→7];答案 7。
结果 [3,3,5,5,6,7] ✓。

注意本例中队头都是先被队尾删除,从没等到滑出窗口。队头滑出的分支用 [7,2,4], k=2 验证:
读 2 时:队尾值 7 > 2,排到队尾,queue=[0→7, 1→2],队头值 7 是窗口 [7,2] 的答案。
读 4 时:队尾值 2 ≤ 4 被永久删除;4 入队后队头下标 0 满足 0 ≤ 2−2,已滑出窗口,从队头删除,剩 queue=[2→4],窗口 [2,4] 的答案是 4。
两种删除在同一步先后发生,原因完全不同。

⚠ 易错坑 只淘汰队尾、漏了队头过期处理(存值而非下标时必犯——过期根本判不了):最小反例 [5,3,1], k=2:值递减谁也淘汰不了谁,队头永远是 5,错误输出 [5,5],真值 [5,3]
过期条件 off-by-one 写成 queue[0] < right−k:最小反例 [3,2,1], k=2:right=2 时队头下标 0 已在窗外,但 0 < 0 不成立没弹,错误输出 [3,3],真值 [3,2]
没等窗口填满就记答案(漏 right ≥ k−1 门槛):最小反例 [3,1], k=2:从 right=0 就 append,错误输出 [3,3](两个数),真值 [3]——答案个数必须是 n−k+1=1。
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):内层 while 看似嵌套,但每个下标一生至多入队 1 次、出队 1 次(队尾淘汰或队头过期二选一),全部轮次的弹出总量被 n 界住——摊还分析,不能按"每轮最坏弹 k 个 × n 轮"相乘误判成 O(n·k):弹出预算是全局共享的 n 个。空间 O(k):队列只存窗口内的候选下标。高频追问一:"什么输入让队列最长/最短?"递减数组队列长到 k(谁也淘汰不了谁,只能等队头逐个过期),递增数组队列始终 1 个(新元素每步淘汰队列里全部旧下标)——两个极端时间都是 O(n)。追问二:"堆的 O(n log k) 什么时候反而值得用?"当窗口不再规则滑动,或还要同时查第 2、第 3 大时——单调队列为了 O(n) 把"被压制者"信息永久丢弃,那些场景下丢不得。

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 上夹出一个窗口:右端不断吞入字符,直到窗口把 t 要求的字符(含重复次数)全部凑齐;一旦凑齐,立刻收缩左端并逐轮登记当前可行窗口,缩到覆盖刚好被破坏再继续右扩;扫完整个 s,登记过的最短窗口就是答案。

① 洞察一——覆盖只朝一个方向变化:先明确"覆盖"的含义:窗口里每种 t 需要的字符,个数都不少于它在 t 中的个数。往窗口加字符只会让计数变大,所以已覆盖的窗口继续右扩永远还是覆盖;从窗口删字符只会让计数变小,所以不覆盖的窗口再怎么右移左端也不会自己变覆盖。这条单调性把搜索空间砍掉一大半:固定左端点时,只有第一个达成覆盖的右端点值得看,再往右全是更长的可行解;而窗口一旦覆盖,就该立刻缩左端,把当前局面下的极小窗口逼出来,缩到刚好破坏覆盖为止。两个端点全程只单向前进——这就是变长滑窗,和 LC3 无重复最长子串 同一副骨架、方向相反:LC3 保持"合法"求最长,本题冲到"可行"再收缩求最短。

回看暴力慢在哪:枚举所有 O(m²) 个子串、每个重数一遍字符是 O(m²·|Σ|)。它慢就慢在没用上①的单调性:明知某窗口已经覆盖,还把同一左端点后面所有更长的子串一个个数了一遍;相邻子串只差一个字符,计数却每次从零重来。

② 洞察二——判"已覆盖"只需要一个数:滑窗每走一步都要问一次"覆盖了吗"。把窗口计数与 t 的计数整表对一遍是 O(|Σ|),总复杂度带上 |Σ| 因子,进阶要求的 O(m+n) 就没了。关键在于:判覆盖不需要知道每种字符各差多少,只需要知道总共还欠多少个。于是维护两个增量更新的量:一张记录"每种字符还欠几个"的表 need(用 Counter(t) 初始化),和一个总缺口数 missing(初值 len(t))。每吞入或移出一个字符只改 need 的一项、至多把 missing 加减 1;判覆盖变成一次整数比较 missing == 0

③ need 的三段身份(同一个计数器,符号不同身份不同):

身份表:need[c] 的三段值域各是什么,missing 只盯正数部分
量与时刻它是谁与 missing 的关系
need[c] > 0窗口还欠 t 的字符 c 这么多个纳入这样的 c 才算"补缺",missing −= 1
need[c] = 0c 恰好够数,再来就是多余此刻纳入或移出 c 都不改 missing
need[c] < 0绝对值=窗口里 c 多出来的个数(无关字符初值 0,一进窗即转负)移出多余的 c 只是减少盈余,missing 不动
missing所有还欠着的必需字符总数=正数 need 之和missing == 0 ⟺ 窗口覆盖 t,一次整数比较替代整表比对

④ 两处先后顺序从 missing 的定义直接推出:missing 是"正数 need 之和",所以它只在某个 need[c] 跨越 0 边界的瞬间变化。纳入 c:跨没跨界要看减 1 之前是否 > 0,故先判 if need[c] > 0: missing −= 1,再 need[c] −= 1;移出 c:要看加 1 之后是否变正,故先 need[c] += 1,再判 if need[c] > 0: missing += 1。两处方向相反但判据同一条——"看变化落在正区间的哪一侧",不用死记。

⑤ 常见困惑——计数为什么允许为负?无关字符会不会搅乱判定?允许为负正是省掉一切特判的关键。Counter(t) 对无关字符默认计 0:纳入前 need = 0 不满足 > 0,missing 纹丝不动;纳入后 need 转负,含义自动变成"窗口里它多了几个"。将来左缩移出它时 need 从负数加 1,也回不到正数——全程与 missing 绝缘。必需字符的多余份数同理:第二个同种字符进窗只把 need 压到 −1,记下"多一个",不碰 missing。

⑥ 答案为什么记在收缩循环里:while missing == 0 每一轮的开头,窗口都是一个可行解——先与历史最短比较,再移出左端字符。这样每个"极小可行窗口"(再缩一格就破的窗口)都恰好在被破坏之前登记过,最优解不会漏。

⑦ 用原题示例 1 s="ADOBECODEBANC", t="ABC" 走一遍:起点 need={A:1,B:1,C:1}missing=3left=0

right=0 吞 A:补缺,need[A] 1→0,missing 3→2。
right=1..4 吞 D、O、B、E:B 补缺(missing 2→1);D/O/E 是无关字符,need 各自 0→−1,missing 不动。
right=5 吞 C:missing 1→0,首次覆盖。登记 "ADOBEC"(长 6);移出左端 A,need[A] 0→1 回正,missing=1,收缩停止,left=1。
right=6..9 吞 O、D、E、B:第二个 B 进窗时 need[B] 已是 0,不算补缺,missing 保持 1——这正是④里"先判 need[c] > 0 再减"防住的高危一步。
right=10 吞 A:missing→0。收缩依次移出 D、O、B、E(B 的 need 由 −1 回到 0,不回正,missing 不动),窗口缩到 "CODEBA"(长 6)与最短持平、不更新;再移出 C,need[C] 0→1 回正,missing=1,停,left=6。
right=11 吞 N:无关字符,missing 不动。
right=12 吞 C:missing→0,再次收缩。
移出 O、D(need 各自由负回 0,missing 不动),窗口缩到 "EBANC"(长 5),刷新最短。
再移出 E,窗口缩到 "BANC"(长 4),再次刷新。
移出 B:need[B] 0→1 回正,missing=1,收缩停止。
扫描结束,最短登记为 "BANC" ✓,与示例 1 一致。

⚠ 易错坑 纳入时不判 need[c] > 0 就减 missing(把多余、无关字符也当补缺):最小反例 s="AB", t="AA":纳入 B 时 missing 被错误减到 0,误判覆盖,错误输出 "AB",真值 ""(s 只有一个 A,不可能覆盖两个 A)。
纳入端顺序写反(先 need[c] −= 1 再判 > 0):最小反例 s="A", t="A":need[A] 先降到 0,再判 0 > 0 失败,missing 永远是 1,错误输出 "",真值 "A"。收缩端同款反写(先判后加)则漏记"重新出现缺口",while 会缩过头出现空窗口。
切片忘 +1 写成 s[l:r]:最小反例 s="a", t="a",best=(1,0,0),错误输出 s[0:0]="",真值 "a";best 初值必须是 inf 哨兵,否则无解时(如 s="a", t="aa")分不清"没更新过"与"更新成短窗口"。
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(m+n):建 need 表 O(n);主循环里 right 走 m 步,left 只增不减也至多走 m 步——内层 while 的总轮数被 left 的总前进量界住,是摊还分析,不能按"每步最坏缩 m 次 × m 步"相乘误判成 O(m²)。空间 O(|Σ|):计数表至多字母表大小个键。高频追问一:"不用 missing,每次覆盖判断遍历 need 表行不行?"正确但每步 O(|Σ|),总 O(m·|Σ|),达不到进阶的 O(m+n)——missing 的意义就是把整表比较折叠成一个整数。追问二:"为什么滑窗在这里成立、在 LC560 就不行?"本题可行性(覆盖)随右扩单调不减、随左缩单调不增;LC560 元素含负数,"和"没有这种单调性。

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³)),在 n ≤ 10⁵ 下超时。慢的根源:以 j 结尾的那批子数组,和"以 j−1 结尾的那批"只差一个 nums[j]——绝大部分求和工作在相邻结尾位置之间被整批重复,却完全没有复用。

① 关键转化——换枚举对象,从"枚举两个端点"到"只枚举结尾":任何子数组都恰好以某个位置结尾,所以把全部子数组按结尾位置分组:只要知道"必须以 i 结尾的最大子数组和"(记作 current_sum),对所有结尾取最大就是答案。这一步把二维枚举 (i,j) 压成一维状态,是最简的一维 DP;"扫一遍、每个位置维护一个累积量"的思想与 LC238 前缀积 同款,只是这里的累积量允许"断开重开"。

② 递推从直觉推出——以 i 结尾的子数组只有两种形态:要么只含自己 [nums[i]],要么是"以 i−1 结尾的某段"再接上自己。接上时当然挑最好的那段,而"以 i−1 结尾的最好一段"正是 current_sum 的旧值——于是 current_sum = max(nums[i], current_sum + nums[i]),两个候选恰好对应两种形态,一个不多一个不少。直白判据:旧 current_sum > 0 就是助力、接上;≤ 0 就是拖累、丢弃重开。

③ 两个量的身份(本题最易混):

身份表:current_sum 与 best_sum 各管什么
代码里的量它是谁提供什么不管什么
current_sum「必须以当前位置结尾」的最大和递推的接力棒,供下一位置决定接续或重开不管历史峰值,可以回落甚至变负
best_sum所有已考察结尾位置中的历史最优最终答案;每步截流 max(best_sum, current_sum)不参与递推,只读不喂回

④ 常见困惑——递推只有"整段接上"或"完全重开"两个选项,接上前段的一部分呢?不会漏:"接上前段的一部分"="以 i−1 结尾的某个更短段 + 自己",而那些更短段全是 current_sum 定义里枚举过的对象,current_sum 已经是它们的最大值——max 只需和这一个代表比较。同理,最优子数组不一定以最后位置结尾也不用怕:best_sum 在每个结尾处都截过流,最优段在它自己的结尾位置被记录——这就是不能只返回最后一个 current_sum 的原因。

⑤ 样例验证关键一步:[-2,1,-3,4,-1,2,1,-5,4] 走到 i=2(值 −3):max(−3, 1+(−3))=−2,接上比重开好——接上一个负数后 current_sum 变负,但仍是"以 i=2 结尾"里最好的;随后 i=3max(4, −2+4)=4,负的 current_sum 被果断丢弃重开,此后累加到 [4,-1,2,1]best_sum=6i=7current_sum 回落到 1,best_sum 锁住 6 不动。

⑥ 正解②:分治(进阶要求)。核心洞察:任何子数组要么完全在左半、要么完全在右半、要么跨过中点——前两类递归求解;跨中点的段必穿过 mid | mid+1 这条缝,可独立拆成"以 mid 结尾的最大后缀和(left_suffix_best)+以 mid+1 开头的最大前缀和(right_prefix_best)",各自从缝向外扫一趟即得。三类候选取最大即区间答案。

取舍:Kadane 一趟 O(n)/O(1),面试首选;分治 O(n log n)/O(log n),价值在练"跨中点合并"的分治思维,也是线段树维护区间最大子段和的原型。

⚠ 易错坑 current_sum/best_sum 初值取 0(最高频):全负数组会错误返回 0。最小反例 [-1]:错误输出 0,真值 −1(必须至少选一个元素)。要用 current_sum = best_sum = nums[0] 从首元素起。
递推写成 max(current_sum, current_sum+nums[i])——丢了"从自己重开"的选项,current_sum 不再"必须以 i 结尾",语义崩坏。最小反例 [1,-2,3]:错误输出 4(把不连续的 1 和 3 硬拼在一起),真值 3。
返回 current_sum 而不是 best_sum:最优段结尾在中途时峰值早已回落。最小反例 [2,-1]:错误输出 1,真值 2。
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)——递推式 T(n)=2T(n/2)+O(n)(合并时从中点向两侧各扫一趟),每层合计 O(n)、共 O(log n) 层;空间 O(log n),来自递归调用栈。

高频追问一:"要求返回最优子数组的起止下标怎么改?"重开(max 取到 nums[i] 自身)时记录候选起点 start=ibest_sum 被刷新时锁定 (best_start, best_end)=(start, i)。仍是 O(n)/O(1)。追问二:"元素以数据流形式一个个到达,还能做吗?"能——Kadane 每步只依赖上一步的 current_sumbest_sum,天然在线,O(1) 状态即可持续维护。

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 · 思路(暴力→最优)

暴力起手:任取两个区间判重叠、能并就并;但一次合并可能制造新的重叠([1,3] 吞掉 [2,6] 之后才够得着 [5,8]),所以只能反复整轮扫描,直到一整轮无事发生,最坏 O(n²) 以上。慢的根源:输入乱序时"和谁重叠"毫无方向,每个区间都要与所有区间比;重叠关系散落全局,没有被组织起来。

① 关键转化——排序把"全局配对"压成"只看邻居":按起点排序后,可能与当前区间重叠的对象一定紧挨着出现在它前面;更强的性质是:当前区间只需要和"结果里最后一段"比一次(为什么够,见④)。这与 LC15 三数之和 的前置转化同款——花 O(n log n) 买有序性,把全局搜索换成一次线性扫描。

② 三个角色的身份:

身份表:扫描过程中每个量是谁
代码里的量它是谁提供什么 / 不管什么
start, end当前候选区间提供本轮判定的两端点;等待并入末段或新开一段
merged[-1]唯一还在生长的末段它的右端 merged[-1][1] 是目前伸得最远的覆盖边界,只有它可能再被扩展
merged[:-1]已封存的段当前起点已永远越过它们的右端,绝不再改动

③ 判据与更新从直觉推出:
重叠判据 start <= merged[-1][1] 含等号:闭区间在端点相接([1,4][4,5] 共享 4)也算重叠,写成严格 < 就漏了这一类。
扩展右端写 max(merged[-1][1], end) 而非直接赋 end排序只保证 start 有序,end 仍然乱序——当前区间可能被末段完全包含([1,4][2,3]),直接赋值会把右端缩回去。

④ 常见困惑——只和末段比一次,怎么保证不漏掉与更早段的重叠?正面推一遍:merged 里的段两两不相交且按起点递增,所以任何更早段的右端 < 末段的左端 末段的右端。若当前 start 大于末段右端,它就更大于所有更早段的右端——一个都够不着,新开即可;若不大于,并入末段即可。末段的右端代表了全部历史,一次比较就够。

⑤ 样例验证:[[1,3],[2,6],[8,10],[15,18]]:读 [2,6],2 ≤ 3 → 并入,右端取 max(3,6)=6,末段长成 [1,6];读 [8,10],8 > 6,且由④,8 也必然越过更早所有段 → 封存 [1,6]、新开 [8,10]

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

⚠ 易错坑 判据写成严格 <:端点相接漏合并。最小反例 [[1,4],[4,5]]:错误输出 [[1,4],[4,5]],真值 [[1,5]]
扩展直接写 merged[-1][1]=end:被包含的区间把右端改小。最小反例 [[1,4],[2,3]]:错误输出 [[1,3]],真值 [[1,4]]
忘记先排序:最小反例 [[4,7],[1,4]]:直接扫时 [1,4]1 ≤ 7 被"并入" [4,7],但左端不会更新,错误输出 [[4,7]],真值 [[1,7]]
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)。

高频追问一:"为什么只按起点排序就够,end 乱序不用管吗?"不用——end 的乱序由"右端取 max"兜底;同起点的两个区间无论谁先来,后到者都必与先到者重叠并被 max 合并,结果一致,不需要 (start, end) 双关键字。追问二:"区间流式到达、不能先整体排序怎么办?"用按起点组织的有序结构(平衡树 / SortedList)维护互不相交的段,每来一个区间向左右两侧局部合并,单次 O(log n + 被合并段数)——即 LC352 数据流区间的做法。

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) 额外空间。

① 约束排除法:进阶要求 O(1) 额外空间——新数组法出局;O(n·k) 又太慢——逐步挪出局。剩下的路只有一条:原地、且每个元素只动常数次。已知的两条原地路线:环状替换(沿 i → (i+k)%n 直接把每个元素送到终点,但要按 gcd(n,k) 处理多个置换环,易错)与本题主推的三次翻转。

② 关键转化——轮转=两个块交换,块交换=三次翻转:先看目标形态:向右轮转 k 后,数组恰是「后 k 个元素整块搬到前面、前 n−k 个整块搬到后面」。记前段 A=前 n−k 个、后段 B=后 k 个,目标就是 A·B → B·A。块交换没有 O(1) 空间的现成原语,但翻转有(首尾双指针交换);而整体翻转满足恒等式 rev(A·B) = rev(B)·rev(A)——两段位置已经互换,代价是各自内部倒序,再各翻一次抵消即可。这一招在 LC48 旋转图像 升级成二维:旋转 90° = 两次翻转的组合。

③ 每一步后两段的身份(同一段元素,不同时刻形态不同):[1,2,3,4,5,6,7], k=3(A=[1,2,3,4],B=[5,6,7])为例:

身份表:三次翻转中,前 k 格与后 n−k 格里各是谁
阶段数组现场前 k 格(3 格)是谁后 n−k 格(4 格)是谁
初始[1,2,3,4,5,6,7]A 的前半(分界其实在下标 4)形态 A·B,目标 B·A
整体翻转后[7,6,5,4,3,2,1]rev(B):B 已到位但内部倒序rev(A):A 已到位但内部倒序
翻前 k 格后[5,6,7,4,3,2,1]B:副作用已消rev(A):仍倒序
翻后 n−k 格后[5,6,7,1,2,3,4]BA——目标 B·A 达成

④ 区间边界与 k%=n 从直觉推出:
k%=n:轮转 n 步回到原样,只有 k mod n 有效;题目允许 k 大于数组长度,必须先消掉整圈。
三段区间:整体 [0,n-1];第二次翻转的范围就是 B 的新家——B 有 k 个元素、住进前 k 格,故 [0,k-1];剩余 [k,n-1]。最常见的误代入是把分界写成 n-k——那是向左轮转的分界(左转时搬到后面的是前 k 个)。

⑤ 常见困惑——翻转明明会打乱顺序,为什么翻两次反而"翻对"了?整体翻转同时做了两件事:交换两段位置(想要的)+颠倒每段内部(副作用)。翻转是自逆操作 rev(rev(X))=X,对每段再各翻一次,恰好只抵消副作用而不动段的位置——三次翻转=一次换位+两次消副作用。

⑥ 样例验证关键一步:验证"整体翻转后 B 恰好占住前 k 格":rev([1,2,3,4,5,6,7]) = [7,6,5,4,3,2,1],前 3 格 [7,6,5] 正是 rev([5,6,7])=rev(B),后 4 格 [4,3,2,1] 正是 rev(A)——与②的恒等式逐位吻合;再翻前 3 格、后 4 格,即得 [5,6,7,1,2,3,4]

取舍:三次翻转 O(n)/O(1),实现稳定,面试默写首选;额外数组法同为 O(n) 时间但多 O(n) 空间;环状替换也是 O(1) 空间且搬运次数最少,却要处理 gcd(n,k) 个置换环。

⚠ 易错坑 忘做 k%=n:k 可能大于数组长度。最小反例 [1,2], k=3:不取模时 reverse(0, k-1)=reverse(0,2) 访问 nums[2] 直接越界报错;取模后 k=1,真值 [2,1]
分界写成 n-k(把左转公式套进右转):最小反例 [1,2,3], k=1:整体翻得 [3,2,1],再翻前 n−k=2 格得 [2,3,1]、翻后 1 格不变,错误输出 [2,3,1],真值 [3,1,2]
三次翻转顺序反了(先翻两段再整体翻):结果变成向左轮转。最小反例 [1,2,3], k=1:翻前 1 格不变、翻后 2 格得 [1,3,2]、整体翻得 [2,3,1],错误输出 [2,3,1],真值 [3,1,2]
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),纯原地。

高频追问一:"还有别的 O(1) 空间做法吗?"环状替换:从每个未访问的起点沿 i → (i+k)%n 搬运一圈,共形成 gcd(n,k) 个环,总写入恰好 n 次,搬运最省,但环的起点计数与终止判断易错,面试默写推荐三次翻转。追问二:"能少于 n 次元素移动吗?"不能——k%n≠0 时每个元素的终点都不是原位,n 次写入是下界;三次翻转约 2n 次写入,是为实现简单付出的常数代价。

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²)。慢的根源:相邻两个位置的答案共享绝大部分因子(answer[2]answer[3] 的乘数只差两个元素),但每次都从头乘,公共部分完全没有复用。

除法法为什么不行:先算总积再 answer[i]=总积/nums[i],看似 O(n),但含 0 就崩——总积变 0,0 所在位置还要算 0/0;就算统计 0 的个数分类讨论能绕过去,题目也明令禁止除法。这条路的价值是逼出正解的问法:不用除法,怎么"去掉自己"?

① 关键转化——"除自身"="左半 × 右半":自己就是一条分界线:answer[i] = (i 左边全部的积) × (i 右边全部的积),两半都不含自己,拼起来恰好是"除自身以外的一切"。于是问题退化成标准子问题:给每个位置预计算前缀积和后缀积——这与 LC53 的前缀和同款,只是加法换乘法。

② 空间优化——输出数组先当左积容器:朴素做法开 prefix[]suffix[] 两个数组,O(n) 额外空间。观察:左积可以直接写进输出数组 answer(题目声明输出不计入额外空间);右积每个位置只用一次,用完即弃——不需要整个数组,一个滚动变量 right_product 从右往左边走边乘就够了。

③ 各个量的身份(本题核心:同一个量在不同时刻身份不同):

身份表:每个量在哪个时刻、代表什么
代码里的量哪个时刻它是谁
answer[i]左扫结束时「i 的左积」=i 左边全部元素的积(不含自己)
answer[i]右扫乘入后左积 × 右积 = 最终答案
right_product右扫到 i、乘入前「i 的右积」=i 右边全部的积(还不含 nums[i])
right_product乘入后、更新后纳入 nums[i],变身「i−1 的右积」(对 i−1 来说自己属于右边)

④ "先用后更新"的顺序从身份表直接推出:右扫循环体两句话的顺序不可交换——answer[i] *= right_product 必须发生在 right_product *= nums[i] 之前,因为乘给 answer[i] 的必须是"不含自己"的右积;更新放在后面,正是完成「i 的右积」→「i−1 的右积」的身份切换。左扫同理:answer[i] = answer[i-1] * nums[i-1] 用的是 nums[i-1] 不是 nums[i]——旧值 answer[i-1] 是「i−1 的左积」,把 i−1 自己纳入后才是「i 的左积」,自己永远不进自己的积。

⑤ 含 0 为什么天然正确:不用任何特判——0 落在谁的左半或右半,那一半的积自然是 0;唯独 0 自己的位置,左右两半都不含这个 0,拼出来恰是"其余元素之积"。用 [1,0,3] 验证:answer[0]=左空(1)×右积(0×3=0)=0;answer[1]=1×3=3;answer[2]=(1×0)×1=0 → [0,3,0] ✓。分界线切法自动把 0 分派到了正确的一侧。

⑥ 用样例走关键一步:[1,2,3,4] 左扫后 answer=[1,1,2,6]。右扫到 i=2:此刻 right_product=4(只含 nums[3],不含 nums[2]=3),answer[2]=2×4=8;随后 right_product=4×3=12,完成向「下标 1 的右积」的变身。

⚠ 易错坑 "先用后更新"写反(本题最高频 WA):先 right_product *= nums[i] 再乘给 answer,自己就乘进了自己的答案。最小反例 [1,2]:写反得 [2,2],真值 [2,1]。左扫同款错误是把 nums[i-1] 写成 nums[i],一样是"自己入积",错位一格即错。
含 0 别加分支:前缀/后缀积自然处理(见⑤);专门 if 0 反而容易把"0 位置=其余元素积"算错。
进阶只允许 answer+常数个变量:再开 prefix[]/suffix[] 就是 O(n) 额外空间,不达标;answer 能复用的前提是左扫阶段它还不是答案、只是左积容器——两个阶段身份不同,别在左扫阶段就拿它当结果用。
3 · 图解
nums = [1,2,3,4]
answer[i] = (i 左边全部的积) × (i 右边全部的积),自己是分界线,两半都不含自己

以 i=2 为例:   [1  2] │ 3 │ [4]
               左半积=2  ↑  右半积=4   →  answer[2] = 2 × 4 = 8
                       自己不参与

第一趟·从左往右:answer 先只装「左积」
  answer[0]=1(左边为空=乘法单位元 1)
  递推 answer[i] = answer[i-1] × nums[i-1]
       └ 旧值「i-1 的左积」,纳入 nums[i-1] 自己后变身「i 的左积」
  nums    [1, 2, 3, 4]
  answer  [1, 1, 2, 6]   ← 每格=左边全部的积

第二趟·从右往左:right_product 滚动装「右积」,乘进 answer 拼合两半
  i=3: answer[3] ×= 1 → 6    然后 right ×= 4 → 4
  i=2: answer[2] ×= 4 → 8    然后 right ×= 3 → 12
  i=1: answer[1] ×= 12 → 12  然后 right ×= 2 → 24
  i=0: answer[0] ×= 24 → 24
  answer  [24, 12, 8, 6] ✓

「先用后更新」的瞬间放大看(i=2):
  乘入前  right_product = 4        =「下标2 的右积」(不含 nums[2]=3)
  乘入后  right_product = 4×3 = 12 =「下标1 的右积」(3 对下标1 属于右边)
  同一个变量,一句话之隔身份不同——两句顺序反了,3 就乘进了自己的答案
两趟扫描:左扫合并为一行,右扫每个下标一行(先用右积,再纳入自己)
阶段本轮动作right_product 变化answer
初始化answer 全 1[1,1,1,1]
左扫 i=1..3逐位写左积 answer[i]=answer[i-1]×nums[i-1][1,1,2,6]
右扫 i=3乘右积 1(右边空)→ 6;纳入 41 → 4[1,1,2,6]
右扫 i=2先乘右积 4 → 8;纳入 3(顺序即正确性)4 → 12[1,1,8,6]
右扫 i=1乘右积 12 → 12;纳入 212 → 24[1,12,8,6]
右扫 i=0乘右积 24 → 2424[24,12,8,6] ✓
4 · 代码
class Solution:
    def productExceptSelf(self, nums: List[int]) -> List[int]:
        n = len(nums)
        answer = [1] * n   # 此阶段身份:左积容器,还不是答案

        # 1. 左扫:旧值 answer[i-1]=「i-1 的左积」,纳入 nums[i-1] 后变身「i 的左积」
        for i in range(1, n):
            answer[i] = answer[i - 1] * nums[i - 1]   # 用 nums[i-1] 不是 nums[i]:自己不入积

        # 2. 右扫:right_product 滚动维护「i 的右积」,乘入 answer 拼合左右两半
        right_product = 1   # 最右位置右边为空=乘法单位元 1
        for i in range(n - 1, -1, -1):
            answer[i] *= right_product   # 先用:此刻 right_product 还不含 nums[i]
            right_product *= nums[i]     # 后更新:纳入自己,变身「i-1 的右积」
        return answer
5 · 复杂度

时间 O(n):两趟线性扫描。空间 O(1) 额外:输出数组按题目声明不计,除它之外只有 right_product 一个变量。高频追问一:"能不能一趟扫完?"不能——answer[i] 需要 i 右边全部元素的信息,任何单向一趟扫到 i 时右边还没看过,两趟是信息依赖决定的下界。追问二:"如果允许除法?"仍要按 0 的个数分类(无 0→总积/自己;恰一个 0→仅 0 位置为其余积;两个以上→全 0),并不比两趟扫更简单。

6 · 测试用例

功能[1,2,3,4]→[24,12,8,6]边界:最短两元素 [2,3]→[3,2](各自的答案就是对方);特殊含一个 0 [1,0,3]→[0,3,0](唯独 0 位置非零,验证⑤的分界线逻辑)、含两个 0 [0,0,3]→[0,0,0](每个位置的左右两半至少含一个 0)、含负数 [-1,1,0,-3,3]→[0,0,9,0,0](0 位置=(-1)×1×(-3)×3=9,符号自然正确)。

缺失的第一个正数

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 · 思路(暴力→最优)

暴力起手:两条直觉路——排序后从 1 开始逐个核对,O(n log n);或把所有数塞进哈希集合,再从 1 起试第一个不在集合里的正整数,O(n) 时间 O(n) 空间。慢/费的根源:排序把力气花在"完全排好序"上,其实只需要知道 1..n 里谁出现过;哈希集合花整整 O(n) 空间,只为回答"值 v 出现过没有"这一种成员查询。

① 约束排除法——两个约束合起来只留一条路:题目要 O(n) 时间——排序出局;又要 O(1) 额外空间——哈希集合、布尔标记数组全出局。但成员查询总得有地方记录,O(1) 额外空间下唯一可写的 O(n) 存储,就是输入数组自己。出题人的箭头指向:把数组自身当哈希表用。这与 LC238 的"输出数组先当左积容器"同一精神——题目已经交到你手里的数组,就是免费存储。

② 先把答案范围钉死(抽屉原理):长度为 n 的数组就算装满,也最多覆盖 1..n 这 n 个正整数,此时答案是 n+1;只要有缺,答案 ≤ n。所以答案必在 [1, n+1],负数、0、大于 n 的值全是与答案无关的"垃圾值"。这一步同时回答了"桶只开 n 个够不够"——够,恰好一格对应一个候选值。

③ 先写"理想方案"——放宽空间约束,人人都会的签到表:由②,最后一步必然是"从 v=1 起逐个问:v 出现过吗?",所以真正需要的只是一张 n 格签到表:第 v 格记录"值 v 出现过没有",每格 1 bit。若允许 O(n) 空间,谁都会写——开布尔数组,扫一遍,对每个落在 [1,n] 的值 x 置 seen[x] = True,再从 1 起找第一个 False。注意"值当下标"在这一步已经出现且毫不费解:seen[x] = True 就是拿值 x 当下标去找它专属的格子。难点只剩一个——这张签到表的空间从哪来

④ 关键转化——签到表画在 nums 自己身上,交换代替写标记:由①,O(1) 空间下唯一可写的 n 格存储就是 nums 的下标 0..n−1,让下标 i 充当"值 i+1 的签到格"。新问题:格子里住着原始数据,直接写标记会把还没签到的值覆盖掉——信息永久丢失。破局是把"写标记"改成"搬位置":签到动作=把值 v 本人交换到它专属的格子 v−1,此后 nums[v-1] == v 恒真——它就是 seen[v] = True 在 O(1) 空间下的无损等价物:交换不销毁任何数,只让两个数换位置;被换出的旧住户回到手上继续投递(这正是后面必须 while 的来源)。归位结束后数组本身就成了 1..n 的签到表,再扫一遍找第一个没签到的格子。看到"值域(或有效值域)恰是 1..n"就要条件反射"值当下标":LC287 用它把数组读成隐式链表找重复,本题用它做原地桶签到——同一个信号,两种用法。

⑤ 各个量的身份(同一个格子在不同时刻身份不同):

身份表:每个量在哪个阶段、代表什么
代码里的量哪个时刻它是谁 / 管什么
nums[index]归位循环中手里待投递的值:家在下标 nums[index]-1;垃圾值无家,原地不动
target_index = nums[index]-1每次交换前待投递值的家:交换后 nums[target_index] 永久等于 target_index+1
nums[index]归位结束后签到格:值恰为 index+1 ⇔ 值 index+1 出现过
index+1扫描阶段正在核对的期望值;第一个失配位置就返回它

⑥ 循环写法从直觉推出:
为什么是 value−1:桶从下标 0 起、值从 1 起,错位一格——值 1 住下标 0。
为什么用 while 不是 if:交换是双向搬家——把手里的值送回家的同时,家里的旧住户被换到手上;新住户可能也不在家,得继续投递,直到手上是垃圾值或已就位的值。
为什么停机条件是 nums[target_index] == nums[index]目标桶已放着相同值(重复值)时再换只是原地打转;若把循环条件写成 nums[index] != index+1,两个相同值会互换到天荒地老。

⑦ 常见困惑——for 里套 while,还是 O(n) 吗?是。每发生一次交换,必有一个值回到自己家、且从此再不离家(想把它换走,手里必须持有同值,而停机条件恰好在此 break),所以全部 while 的交换总次数 ≤ n——预算全局共享,与 LC84 "每根柱子至多弹出一次"同款摊还。另一个隐忧"垃圾值会不会捣乱"也不会:垃圾值只会滞留在无人认领的格子里,而无人认领的格子恰是缺失值的位置,扫描读到垃圾值正好触发失配返回。

⑧ 样例验证关键一步:[3,4,-1,1]:index=0 手持 3 → 送回下标 2,换回 −1(垃圾,停),得 [-1,4,3,1];index=1 手持 4 → 送回下标 3 换回 1,手持 1 继续 → 送回下标 0 换回 −1(垃圾,停),得 [1,-1,3,4]——一趟 while 连续归位两个值,正是"必须 while"的现场。扫描:下标 0 签到 1 ✓;下标 1 期望 2 却是 −1 → 返回 2。垃圾值 −1 恰好停在缺失值 2 的格子上。

回看推导链(每一步都被上一步逼出来):答案必在 [1, n+1] → 只需一张 n 格签到表 → O(1) 空间下签到表只能借 nums 自身的下标 → 借来的格子里有原始数据、不能覆盖 → 用交换代替写标记 → 换出来的值还得继续归位 → 必须 while。正解不是灵光一现,而是被约束逼出的唯一活路;面试时讲清这条链,比背对交换代码更加分。

⚠ 易错坑 if 代替 while:换进来的值失去归位机会。最小反例 [3,4,-1,1]:if 版在 index=1 把 4 送回家后不再处理换回的 1,终态 [-1,1,3,4],扫描错误输出 1,真值 2。
循环条件写 nums[index] != index+1:重复值死循环。最小反例 [1,1]:index=1 时 nums[1]=1 != 2 进循环,两个 1 互换后数组不变、条件恒真——死循环;真值 2。
一行 tuple swap 里嵌 nums[nums[index]-1]:Python 先给左侧第一个目标赋值,再用新值算第二个目标的下标。最小反例 [2,1]nums[0],nums[nums[0]-1]=nums[nums[0]-1],nums[0] 先令 nums[0]=1,再算 nums[nums[0]-1]=nums[0]=2——数组变回 [2,1],条件恒真死循环;先存 target_index 再交换才对,真值 3。
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)。for 套 while 不能按"每轮最坏 × n 轮"相乘:每次交换都让一个值永久归位,家只有 n 个,所以全部 while 的交换总预算 ≤ n(摊还),加上两趟线性扫描仍是 O(n)。空间 O(1),原地交换。

高频追问一:"嵌套循环为什么还是线性?"标准回答即上面的摊还论证——看交换总量的全局预算(每次交换消灭一个"未归位"),不看单轮最坏。追问二:"不用交换还有别的 O(1) 做法吗?"符号标记法:先把 ≤0 与 >n 的垃圾值统一改写成 n+1,再对每个出现的 v ∈ [1,n]nums[v-1] 取负作"出现"标记,最后返回第一个仍为正数的下标 +1;同为 O(n)/O(1),但读值要用 abs 防重复取负,且同样依赖"允许改动数组"。

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 就当场清掉它所在的行和列。但你写下的新 0 会被后续扫描当成"原始 0"再次触发清零,连锁扩散(坑①有最小反例)。

这一步逼出了本题所有解法的公共骨架:必须分成"先只读、记下哪些行列要清"和"再统一写"两个阶段。最朴素的记法是两个集合 rows/cols:时间 O(m·n)、额外空间 O(m+n)。时间已经最优——每格至少读一次才能找全原始 0——所以出题人卡的不是时间,是空间:进阶要求 O(1)。

② 约束排除法——O(1) 空间逐个杀死直觉方案:

方案一·visited 副本矩阵:O(m·n) 空间,直接出局。

方案二·rows/cols 集合:O(m+n),也出局。

方案三·魔法数:把置零过程先写成某个"矩阵里不会出现的值",最后统一换回 0——但值域覆盖完整的 32 位整数,不存在保证不冲突的空闲值,出局。

三条路堵死后只剩一个方向:标记信息不放矩阵外面,就只能放矩阵里面。问题变成——放在哪儿,才不会毁掉还没读到的原始信息?

③ 关键转化——集合的本质是 m+n 个布尔位,矩阵里恰好有现成的容器:rows/cols 拆开看,它就是 m 个"第 i 行要不要清"加 n 个"第 j 列要不要清"的布尔位。而首列恰好有 m 个格、首行恰好有 n 个格:让"第 i 行要清"写成 matrix[i][0]=0、"第 j 列要清"写成 matrix[0][j]=0——首列当行标记数组、首行当列标记数组

代价:首行首列自己的原始值会被标记覆盖。补偿:首行/首列"整条要不要清"各自只需一个布尔就能描述——在动手打标记之前先算好,存进 first_row_zero/first_col_zero。O(m+n) 个布尔就这样被压缩成"借用的格子 + 2 个变量"。

身份表:同一个格子在不同阶段身份不同
代码里的量哪个时刻它是谁
matrix[i][0](i≥1)打标记前第 i 行首元素=普通原始数据
matrix[i][0](i≥1)打标记后「第 i 行要不要清」的行标记位
matrix[0][j](j≥1)打标记后「第 j 列要不要清」的列标记位
first_row_zero / first_col_zero全程首行/首列「整条要不要清」的备份——在标记覆盖之前抢救出来的原始信息
matrix[i][j](i,j≥1)回填阶段只被写、不再被信任——清不清只看两个标记位,不看它自己

④ 四个阶段的顺序是被依赖关系逼出来的,不用背:

阶段一·先备份两个布尔:打标记会往首行首列写 0,之后再也分不清"本来就有的 0"和"标记写的 0",所以备份必须发生在一切写入之前。

阶段二·扫内部打标记:只扫 i,j≥1,遇原始 0 就写 matrix[i][0]=0matrix[0][j]=0

阶段三·回填内部:必须等标记全部打完再动手,否则又回到"边读边写"的污染老路。

阶段四·首行首列最后清:它们是标记区,提前整清会抹掉标记位,回填内部时就无据可查(坑③的反例正是这么错的)。

一句话总结:同一块内存既当数据又当标记,使用顺序就必然有严格先后。

⑤ 常见困惑——回填时读到 matrix[i][0]==0,怎么区分它是"原始 0"还是"标记写的 0"?不用区分。如果它是原始 0,第 i 行本来就该整行清零——原始语义和标记语义在这一格说的是同一句话,两种 0 的结论一致。

真正会产生歧义的只有"首行/首列自身要不要整条清",而那已经被两个布尔提前备份。这也解释了标记区为什么选首行首列而不是随便哪一行:只有它们的"整条清不清"恰好能各压缩成一个布尔。

⑥ 用原题示例 1 走一遍:[[1,1,1],[1,0,1],[1,1,1]],唯一原始 0 在 (1,1)

备份:首行 [1,1,1]、首列 [1,1,1] 都不含 0,两个布尔均 False。
打标记:扫内部读到 (1,1) 的 0,写 matrix[1][0]=0(行标记)、matrix[0][1]=0(列标记)。
回填内部:(1,1)(1,2) 被行标记清零,(2,1) 被列标记清零,(2,2) 两个标记都没亮、保持 1。
收尾:两个布尔均 False,首行首列不整清,得 [[1,0,1],[0,0,0],[1,0,1]],与示例 1 输出一致 ✓。

注意标记写下的两个 0 留在了成品里且恰好正确(1,0) 位于要清的行 1 上、(0,1) 位于要清的列 1 上——标记格永远落在"被清行/列与首行首列的交点",标记本身就是答案的一部分,不需要擦除。

⚠ 易错坑 边发现边清零(连锁扩散):反例 [[0,1],[2,3]]——扫到 (0,0) 当场清行 0 列 0 得 [[0,0],[0,3]],接着扫到 (0,1) 的新 0 又把列 1 清掉,错误输出 [[0,0],[0,0]];真值 [[0,0],[0,3]]
两个布尔在打标记之后才计算:反例 [[1,1],[1,0]]——内部 0 在 (1,1),标记写下 matrix[0][1]=0matrix[1][0]=0;事后再 any() 会把标记当原始 0,错误地整清首行首列,输出 [[0,0],[0,0]];真值 [[1,0],[0,0]]
收尾顺序颠倒(先清首行首列、再回填内部):反例 [[0,1],[1,1]]——两布尔为 True,先整清首行首列得 [[0,0],[0,1]],回填再读 matrix[1][0]==0 时它已被收尾改写,(1,1) 被误清,输出 [[0,0],[0,0]];真值 [[0,0],[0,1]]
3 · 图解
例 matrix(3 行 4 列,唯一原始 0 在 (1,2);*0* = 刚写入的标记位)

① 初始,先保存首部原始状态:first_row_zero=False,first_col_zero=False
        列0  列1  列2  列3
   行0 [ 1    1    1    1 ]
   行1 [ 1    1    0    1 ]   ← (1,2) 是原始 0
   行2 [ 1    1    1    1 ]

② 扫到 (1,2)=0,先写行标记 matrix[1][0]=0    ‖ 瞬时态:列标记尚未写
   行0 [ 1    1    1    1 ]
   行1 [*0*   1    0    1 ]   ← 行1 的标记位亮起
   行2 [ 1    1    1    1 ]

③ 再写列标记 matrix[0][2]=0    ‖ 两类标记齐全
   行0 [ 1    1   *0*   1 ]   ← 列2 的标记位亮起
   行1 [*0*   1    0    1 ]
   行2 [ 1    1    1    1 ]

④ 第二遍扫内部:所在行或所在列有标记的内部格清零
   行0 [ 1    1   *0*   1 ]
   行1 [ 0    0    0    0 ]   ← 行1 整行清(行标记)
   行2 [ 1    1    0    1 ]   ← (2,2) 单点清(列2 标记)

⑤ 两个首部标志均为 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):打标记、回填各扫一遍,每格常数次读写;这也是时间下界——每格至少读一次才能找全原始 0。空间 O(1):标记借用首行首列,额外只有两个布尔。高频追问一:"如果不要求 O(1),你会怎么写?"答两个集合 rows/cols,O(m+n) 空间、没有阶段顺序陷阱,工程上更推荐——先说清这个取舍再写 O(1) 版是加分项。追问二:"能不能把新写的 0 先标成特殊值、最后统一换回?"不通用:值域覆盖完整 32 位整数,不存在保证不冲突的魔法数;Python 里塞 None 是利用动态类型钻空子,换成静态类型语言即失效。

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 的顺序访问;三乘四网格先走完外圈 1 到 4、8、12 到 9、5,再走内部的 6、7。箭头沿顺时针螺旋穿过每个格子。 3×3 123 456 789 3×4 1234 5678 9101112
顺时针螺旋箭头依次穿过访问的格子:先走完外圈,再进入剩余内圈。

约束

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

一句话总纲:先用 top / bottom / left / right 四条边界框住"还没读的矩形"→ 再一圈圈按"顶行→右列→底行→左列"读一条边、收一条边 → 当边界交错(矩形为空)时,结果数组就是答案。

① 暴力起手 + 费的根源:最贴近题意的做法是真的"走":保持一个方向前进,每走一步输出一格,撞到边界或撞到走过的格就右转,用 visited 布尔矩阵判重。时间 O(m·n) 没问题,费在空间:O(m·n) 的 visited 记录的是"哪些格走过了"。

但螺旋走过的区域根本不是任意形状——剥掉外圈之后,剩下的永远是一个规则矩形。用 m·n 个布尔去描述一个 4 个数字就能确定的矩形,这就是浪费的根源,也是正解的入口。

② 关键转化——不记"走过哪儿",改记"还剩哪儿":把视角从"访问过的格子集合"切换成"未访问的矩形区域"。矩形只需四条边界描述:top / bottom / left / right(都是闭区间行列号)。每输出完一条边,就把对应边界向内收一格——"走螺旋"退化成"反复剥矩形的四条边",visited 整个省掉。这与二分、双指针是同一族思想:用少量边界变量维护"候选区域",每步让它严格缩小。

身份表:四条边界与两道检查各自负责什么
代码里的量它是谁何时变化 / 生效
top / bottom未访问矩形的最上 / 最下行号(闭区间端点)走完顶行 top+=1;走完底行 bottom-=1
left / right未访问矩形的最左 / 最右列号(闭区间端点)走完左列 left+=1;走完右列 right-=1
while 条件进圈资格审查:矩形非空才开新一圈只在每圈开始时检查一次,圈中不自动生效
两个 if 守卫圈内复查:走完上、右两边后矩形是否已被掏空走底边前查 top<=bottom,走左边前查 left<=right

为什么只有底边、左边要守卫,顶边、右边不用?从执行顺序推:进入本圈时 while 刚确认过 top<=bottom and left<=right,走顶行、右列用的是"新鲜担保";但走完这两条边后 topright 已经收缩过,剩余矩形可能已被掏空(单行、单列时正是如此),担保过期——所以后两条边动身前必须重新验一次。守卫不是模板咒语,是"边界中途变过、担保过期要重验"的必然结论。

倒序 range 从"从哪走到哪"推:底边是从右往左读第 bottom 行:起点列 right、终点列 left,Python 的 range 不含 stop,所以写 range(right, left-1, -1);左列同理是 range(bottom, top-1, -1)。别背方向口诀,问自己"这条边从哪个角走到哪个角"即可现场写出。

用样例验证守卫真的在拦:单行 [[1,2,3]]

进圈走顶行:输出 1,2,3,top 收成 1。
走右列:range(1,1) 为空,安全。
走底边前:top=1>bottom=0,第一个守卫把底边拦下。
若没有它,底边会按 range(1,-1,-1) 把 2、1 倒着再读一遍。

⚠ 易错坑 漏第一个守卫(top<=bottom):单行反例 [[1,2,3]]——顶行读完后底边把同一行倒序重读,错误输出 [1,2,3,2,1];真值 [1,2,3]
漏第二个守卫(left<=right):单列反例 [[1],[2],[3]]——右列读完(right 已收到 -1)后左列仍按 range(1,0,-1) 把中段的 2 重读,错误输出 [1,2,3,2];真值 [1,2,3]
走完一条边忘了立即收缩:反例 [[1,2],[3,4]]——顶行读完忘写 top+=1,右列从 row=top=0 起步,把角上的 2 再读一遍,错误输出以 [1,2,2,4,…] 开头;真值 [1,2,4,3]
3 · 图解
matrix=[[1,2,3],[4,5,6],[7,8,9]],边界 (top,bottom,left,right)=(0,2,0,2)
记号:▸ = 本轮读取的格,· = 之前已读过的格

① 走顶行 →(读 1,2,3),top 0→1
   ▸1 ▸2 ▸3
    4  5  6      result=[1,2,3]
    7  8  9      剩余矩形 = 行1..2 × 列0..2

② 走右列 ↓(读 6,9),right 2→1
    ·  ·  ·
    4  5 ▸6      result=[1,2,3,6,9]
    7  8 ▸9      剩余矩形 = 行1..2 × 列0..1

③ 守卫 top<=bottom 通过,走底行 ←(读 8,7),bottom 2→1
    ·  ·  ·
    4  5  ·      result=[1,2,3,6,9,8,7]
   ▸7 ▸8  ·      剩余矩形 = 行1..1 × 列0..1

④ 守卫 left<=right 通过,走左列 ↑(读 4),left 0→1
    ·  ·  ·
   ▸4  5  ·      result=[1,2,3,6,9,8,7,4]
    ·  ·  ·      剩余矩形 = 行1..1 × 列1..1(只剩中心)

⑤ 新一圈走顶行(读 5),随后边界交错,退出
    ·  ·  ·
    · ▸5  ·      result=[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) 额外(不计输出):四个边界变量替代了整张 visited 矩阵。高频追问一:"不用 visited,怎么论证不重不漏?"答不变式——任何时刻 [top,bottom]×[left,right] 恰好等于"未输出集合":每条边输出后立即收缩边界维持不变式,两个守卫保证矩形空了就停止输出。追问二:"螺旋矩阵 II(LC59,把 1..n² 按螺旋序填入矩阵)怎么做?"同一套四边界模板,把"读 matrix"换成"写递增计数器"即可——模板的价值就在读写通用。

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 · 思路(暴力→最优)

一句话总纲:先沿主对角线转置——把每一行"躺倒"成同编号的列;再把每一行左右翻转——把列的左右次序倒过来。两步做完,矩阵就是顺时针旋转 90° 的结果;全程只有成对交换,天然原地。

① 暴力起手——先想清楚"每个格子要去哪":记矩阵边长为 n,盯住第 0 行。顺时针转 90° 后,它原封不动地在了最后一列,自上而下顺序不变。

推广开来:原第 i 行整行搬到新矩阵的第 n-1-i 列;行内排在第 j 个的元素,搬过去后从上往下数还是第 j 个。写成公式就是 new[j][n-1-i] = old[i][j]。照这个映射另开一张矩阵逐格搬,时间 O(n²)——每格至少动一次,时间已是下界。但另开一张矩阵要 O(n²) 空间,被题目的原地要求判死。卡点全在空间。

② 约束排除法——原地为什么不能直接按公式写:沿用同一公式在原矩阵上写,matrix[j][n-1-i] = matrix[i][j] 会覆盖 (j,n-1-i)还没搬走的旧值(坑③有完整反例)。

出路有两条。其一:找出"互相顶替"的四个格子做循环置换,一次转四格——可行,但下标公式一长串,现场容易写错。其二更省心:把旋转拆成若干次"成对互换"——一对格子互换时先存后写,谁也不覆盖谁,交换天然原地。这正是原地约束指向的方向。

③ 关键转化——转置+行翻转,从"第 0 行去哪了"推出来,不用背:

第一步·先转置:沿主对角线交换 (r,c)↔(c,r),原第 i 行躺倒成第 i 列,行内顺序恰好变成自上而下——"行变列"这一大步完成了。

第二步·找差距:目标要求原第 0 行去最后一列,转置只把它送到了第 0 列——列的左右次序整体反了。

第三步·每行左右翻转:把列序整体倒过来,第 0 列换到第 n-1 列,恰好补齐差距。

用下标复核整条链:转置 (i,j)→(j,i),行翻转 (j,i)→(j,n-1-i),复合正是目标 new[j][n-1-i]=old[i][j] ✓。

身份表:同一个格子 matrix[r][c] 在三个时刻装的是谁
时刻matrix[r][c] 装的是说明
初始old[r][c]原始数据
转置后old[c][r]行列互换:现在的第 r 行=原来的第 r 列
行翻转后old[n-1-c][r]列序再左右倒置,正是顺时针 90° 的目标映射 ✓

④ 上三角范围从"每对只能换一次"推:转置的本体是"对称对 (r,c)(c,r) 互换"。若内层 col 从 0 扫全行,每一对会在 (r,c)(c,r) 两处各被碰到一次、交换两次=原样放回。所以内层从 row+1 起、只扫上三角,每对恰好交换一次;对角线 (r,r) 是自己和自己换,天然不用碰。

⑤ 常见困惑——"转置、行翻转、上下翻转"能组合出顺/逆时针,现场怎么分辨?不背对照表,用"第 0 行去哪了"验证一步:顺时针要求第 0 行去最后一列——转置(送到第 0 列)+行翻转(列序倒置→最后一列)✓;换成转置+上下翻转,动的是行序不是列序,第 0 行留在第 0 列,得到的是逆时针 90°。先翻转后转置同样会串向——顺序和轴向共同决定旋向,拿一个具体元素走一遍即可分辨。

⑥ 用原题示例 1 走一遍:[[1,2,3],[4,5,6],[7,8,9]],全程盯第 0 行 [1,2,3]

转置:第 0 行躺倒成第 0 列(自上而下 1,2,3),矩阵变为 [[1,4,7],[2,5,8],[3,6,9]]
逐行翻转:[1,4,7]→[7,4,1][2,5,8]→[8,5,2][3,6,9]→[9,6,3]
第 0 行果然到了最后一列(自上而下仍是 1,2,3),结果 [[7,4,1],[8,5,2],[9,6,3]],与示例 1 输出一致 ✓。

⚠ 易错坑 转置内层从 0 扫全行——每对交换两次等于没转:反例 [[1,2],[3,4]],(0,1)↔(1,0) 在两处各换一次又换回去,"转置"后仍是原矩阵,再行翻转错误输出 [[2,1],[4,3]];真值 [[3,1],[4,2]]
翻转轴向记反(转置后做上下翻转):反例 [[1,2],[3,4]]——转置得 [[1,3],[2,4]],上下翻转错误输出 [[2,4],[1,3]],这是逆时针 90° 的结果;真值(顺时针)[[3,1],[4,2]]
原地直接按公式逐格写:反例 [[1,2],[3,4]]——按扫描序执行 matrix[j][n-1-i]=matrix[i][j],第一步 matrix[0][1]=1 就用 1 覆盖了还没搬走的 2,连锁污染后错误输出 [[3,1],[1,1]];真值 [[3,1],[4,2]]
3 · 图解
例 matrix=[[1,2,3],[4,5,6],[7,8,9]],转置只碰上三角三对:(0,1) (0,2) (1,2)
记号:▸ = 本轮刚被交换写入的格;tuple swap 先存后写,无“半次交换”丢值态

① 初始
        列0  列1  列2
   行0 [ 1    2    3 ]
   行1 [ 4    5    6 ]
   行2 [ 7    8    9 ]

② 转置第一对 (0,1)↔(1,0):2 与 4 互换
   行0 [ 1   ▸4    3 ]   ← 每对只在上三角遍历中被碰到一次,不会再换回去
   行1 [▸2    5    6 ]
   行2 [ 7    8    9 ]

③ 再换 (0,2)↔(2,0)、(1,2)↔(2,1),转置完成;对角线 1,5,9 全程不动
   行0 [ 1    4   ▸7 ]
   行1 [ 2    5   ▸8 ]   ← 原第 0 行 [1,2,3] 已躺倒成第 0 列(自上而下 1,2,3)
   行2 [▸3   ▸6    9 ]

④ 逐行左右翻转(行内对称位互换),得到最终结果
   行0 [ 7    4    1 ]
   行1 [ 8    5    2 ]   ← 原第 0 行竖到最后一列,自上而下仍是 1,2,3 ✓
   行2 [ 9    6    3 ]
   顺时针 90° 结果 [[7,4,1],[8,5,2],[9,6,3]],与示例 1 输出一致
原地旋转:表格概括转置与逐行反转阶段,具体交换现场见上图
阶段 · 动作交换/翻转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²):转置碰上三角约 n²/2 对,逐行翻转每行 n/2 对交换,均为常数次访问;每格至少动一次,这已是下界。空间 O(1):全程成对交换,无辅助矩阵。高频追问一:"能不能一趟完成、不做两次扫描?"能——四格循环置换 (i,j)→(j,n-1-i)→(n-1-i,n-1-j)→(n-1-j,i),只遍历左上四分之一区域、每次转 4 格;总移动次数更少但仍是 O(n²),且下标公式极易写错,面试先给转置+翻转、被追问再升级是稳妥策略。追问二:"旋转 180° / 逆时针 90° 呢?"180°=行翻转+上下翻转(不需转置);逆时针 90°=转置+上下翻转——现场用"第 0 行去哪了"推,不用背。

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 不在矩阵中。 向右递增 → 向下递增 → 1471115 2581219 3691622 1013141724 1821232630
两个示例共用的矩阵:target=5 命中高亮格,target=20 不在其中。

约束与保证

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

一句话总纲:站上矩阵的右上角——那个位置的值是本行最大、又是本列最小 → 每比较一次:比 target 大就排除一整列(左移一格),比 target 小就排除一整行(下移一格)→ 撞上 target 返回 True,走出边界返回 False。

① 暴力起手——慢在两条有序性没用上:全矩阵线性扫 O(m·n),"行有序""列有序"两个条件一个都没用上。

升级一步:对每一行二分 target,O(m·log n)——用上了"行内有序",但"列也有序"仍然闲置:每行的搜索互相独立,行与行之间的大小关系没有传递任何信息。要更快,就必须找到一个能同时动用两条有序性的比较位置——这是正解的入口。

② 关键转化——找一个"比较一次就能砍掉一整块"的出发点:候选自然是矩阵的四个角,逐个检验从它出发的两个前进方向:

左上角:向右变大、向下也变大——value<target 时右和下都有嫌疑,一个方向都排除不了。

右下角:向左变小、向上也变小,对称地失效。

右上角:向左变、向下变——两个方向单调性相反,比较结果天然二选一:value>target 说明本列往下只会更大,整列出局、左移;value<target 说明本行往左只会更小,整行出局、下移。

左下角:向右变大、向上变小,同样单调相反,可对称实现。

结论:可用的出发点必须是"两个方向单调性相反"的角。选定右上角后,每次比较都保证消去一整行或一整列——O(m+n) 就是这么来的。

身份表:右上角出发点,一个值两重身份
代码里的量它是谁一次动作说明什么
(row, col)未排除子矩阵 [row..m-1]×[0..col] 的右上角不变式:target 若存在,必在这块矩形里
matrix[row][col]剩余矩形中第 row 行的最大值,兼第 col 列的最小值一个值两重身份,一次比较两头都有话说
col -= 1value>target 时执行的动作第 col 列剩余部分最小值都比 target 大 → 整列出局
row += 1value<target 时执行的动作第 row 行剩余部分最大值都比 target 小 → 整行出局

③ 为什么不重不漏——从不变式推:初始时剩余矩形是整个矩阵,不变式显然成立;每次左移/下移排除的那一列/行都已被证明不含 target,所以 target 若存在仍在缩小后的矩形里。循环结束只有两种方式——命中返回 True,或矩形缩空(row==mcol<0)返回 False,不重不漏。

④ 常见困惑——行列都有序,为什么不能整体二分做到 O(log(m·n))?整体二分要求"逐行拼接后全局有序",即每行开头不小于上一行结尾——那是 LC74 的前提。本题只保证行内、列内各自有序:题面矩阵第 1 行末尾是 19,第 2 行开头是 3,拼起来立刻乱序。对任意单点做一次比较,能排除的只是一个角块,剩余区域是 L 形而不是"矩形的一半",标准二分砍不下去——所以本题的主流最优就是 O(m+n) 阶梯法。

⑤ 用原题示例 1 走一遍(找 target=5):从右上角 (0,4) 出发。

(0,4)=15,15>5:第 4 列出局,左移到 (0,3)。
(0,3)=11,11>5:第 3 列出局,左移到 (0,2)。
(0,2)=7,7>5:第 2 列出局,左移到 (0,1)。
(0,1)=4,4<5:第 0 行出局,下移到 (1,1)。
(1,1)=5:命中,返回 True。

路径 15→11→7→4→5,四次比较砍掉三列一行;每一步之后剩余区域仍是矩形,当前位置始终是它的右上角,身份表全程适用。

⚠ 易错坑 从左上/右下角出发:两方向同增(或同减),比较结果无法二选一。反例:左上角版规定"value<target 右移、value>target 下移",在题面矩阵找 3——路径 1→4→5→6→13→21 后越界返回 False;真值 True(3 在 (2,0),第一步右移就把它永久甩出了搜索域)。
循环条件漏判 col>=0:反例题面矩阵找 0——15>0 一路左移,col 到 -1 后 Python 负下标不报错而是回绕取到行末的 15,继续左移直到越出 -n 才抛 IndexError;真值应在 col<0 时立即返回 False。漏判 row<m 同理(找 20 时 row 会走到 m)。
方向写反(value>target 时下移):反例题面矩阵找 5——15>5 却下移,一路 15→19→22→24→30 全都更大,越界返回 False;真值 True。
3 · 图解
题面 5×5 矩阵找 target=5,从右上角 (0,4) 出发
记号:▸ = 当前所在格,(k) = 第 k 步足迹,· = 已被排除的格

① 起点 (0,4)=15:本行最大、兼本列最小,一次比较必砍掉一整行或一整列
         列0   列1   列2   列3   列4
   行0 [  1    4    7   11  ▸15 ]   ← 15>5:第 4 列往下只会更大,整列出局
   行1 [  2    5    8   12   19 ]
   行2 [  3    6    9   16   22 ]
   行3 [ 10   13   14   17   24 ]
   行4 [ 18   21   23   26   30 ]

② 连续三次“大→左移”:15>5、11>5、7>5,第 4、3、2 列依次整列出局
   行0 [  1   ▸4  (3)  (2)  (1) ]   ← 足迹(1)(2)(3);现站 (0,1)=4,4<5
   行1 [  2    5    ·    ·    · ]
   行2 [  3    6    ·    ·    · ]      · 区域=已排除:这三列剩余部分的
   行3 [ 10   13    ·    ·    · ]        最小值(列顶)都 > 5
   行4 [ 18   21    ·    ·    · ]

③ “小→下移”:4<5 说明第 0 行往左只会更小,整行出局,落到 (1,1)=5 命中
   行0 [  ·  (4)  (3)  (2)  (1) ]   ← 第 0 行整行出局
   行1 [  2   ▸5    ·    ·    · ]   ← (1,1)=5==target,返回 True ✓
   行2 [  3    6    ·    ·    · ]
   行3 [ 10   13    ·    ·    · ]      未排除区域收缩为行1..4 × 列0..1,
   行4 [ 18   21    ·    ·    · ]      ▸ 始终是它的右上角(不变式成立)

足迹路径 15→11→7→4→5:四次比较砍掉三列一行,总步数不超过 m+n。
右上角阶梯搜索:每次比较与搜索域收缩合并为一轮
位置 (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):row 只增、col 只减,row 最多加 m 次、col 最多减 n 次,总步数不超过 m+n。空间 O(1):只有两个指针。高频追问一:"能不能更快,比如 O(log(m·n))?"整体二分不行——逐行拼接后不是全局有序(见思路段的困惑),单点比较排除不掉一半;面试口径 O(m+n) 即主流最优,关键是答出"每步必砍一整行或一整列"的论证。追问二:"和 LC74 的区别?"74 额外保证每行开头大于上一行结尾,可摊平成一维直接二分 O(log(m·n));本题条件更弱——先辨认矩阵属于哪种有序,再选算法。

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 · 思路(暴力→最优)

一句话总纲:让两个指针分别从两条链的头同时出发、同速前进;谁先走完自己那条链,就从另一条链的头接着走。两条拼接路线一样长,所以若相交,第二趟必然同时踩到交点;若不相交,则同时落在 None——两种结局都靠"相等"退出循环。

① 暴力起手:对 A 的每个节点,把 B 从头到尾扫一遍,看有没有同一个对象,O(m·n)。慢的根源:两条链一旦相交,交点之后整段节点完全共享——每轮重扫都在重新验证这段"早已注定相同"的尾巴,共享结构这条信息一次都没被复用。

② 约束排除:顺着"记住见过谁"的直觉,把 A 的全部节点对象放进哈希集,再扫 B,第一个落在集合里的就是交点——O(m+n) 时间,但要 O(m) 空间。进阶要求 O(1) 空间,把"用容器记忆"这条路整个杀掉。不能靠记忆,就只能靠指针走路的方式本身携带信息。

③ 关键转化——用换路补平路程差:先给三段路起名:A 独有段长 x、B 独有段长 y、共享段长 s;再命名两个游标——pointer_a 从 headA 出发、pointer_b 从 headB 出发。两指针各走各的会永远错位:一个在第 x 步到交点,另一个在第 y 步到,时刻不同。改成换路:pointer_a 走完 A 后从 B 头继续,pointer_b 走完 B 后从 A 头继续。两条拼接路线的总长同为 x+s+1+y(各含一次 None 停留)——路程相等、同时出发、同速前进,则第二次经过交点必然同时。和 LC142 一个味道:不去测量差距,而是让双方把彼此的差距互相走掉。

身份表:两根指针与 None 各自负责什么
代码里的量它站在哪它负责什么 / 何时动
pointer_a(图解简写 pa)A→None→B 拼接路线上不变量:任意时刻与 pointer_b 已走路程相等;每轮一步,落到 None 的下一轮跳到 headB
pointer_b(简写 pb)B→None→A 拼接路线上与 pointer_a 完全对称;两条路线总长同为 x+s+1+y
None两条拼接路线中间的公共一站不是失败标志:既是换路的触发点,也是不相交时两指针的公共终点(靠"同时为 None 而相等"退出)

④ 为什么必须"落到 None 才切":读者直觉是"走到尾节点就该跳对面了"。但 None 这一步不是浪费:它让两条路线各含恰好一次停留,总步数才严格相等。更致命的是,不相交时全靠"双方同时落在 None"这个唯一等点退出循环——在尾节点提前切,不相交的两个指针永远错着相位打转,死循环。

⑤ 常见困惑——会不会在交点之前"碰巧相等"?不会。相等要求是同一对象,而交点之前 pointer_a 只可能站在某条链的独有段上,pointer_b 站在另一条的独有段上,独有段之间没有共享对象。第一趟里若两人同时进入共享段,只可能因为 x=y——那时第一个共同点恰是交点,提前返回也正确。

⑥ 用原题示例 1 走一遍:A=4→1→8→4→5,B=5→6→1→8→4→5,从节点 8 起共享(x=2、y=3、s=3),两条拼接路线各长 2+3+1+3=9 步。

第 1 轮末:pa=1(A 独有段),pb=6。
第 2 轮末:pa=8——第一次路过交点,但 pb 才走到 1,错过不算。
第 3~4 轮末:pa 走过共享段的 4、5;pb 走 8、4,始终慢一拍。
第 5 轮末:pa=None(停留一步),pb=5(共享段尾)。
第 6 轮末:pa 换到 B 头 5,pb=None。
第 7~8 轮末:pa 走 6、1;pb 换到 A 头,走 4、1。
第 9 轮末:pa=8,pb=8——同一对象,返回节点 8 ✓。第一趟错过、第二趟路程对齐后必然同到。

⚠ 易错坑 == 比值判相交:最小反例 listA=[1]listB=[1] 两条互不相干的链——值比较会在第一轮宣布"相交"返回节点,真值 null。必须 is 比对象身份。
在尾节点提前切换(不经过 None):最小反例 listA=[1]listB=[2] 不相交——pa 永远在 1↔2 之间打转、pb 反相位打转,二者永不相等,死循环超时;真值 null
循环条件写成 while pointer_a and pointer_b:最小反例 listA=[8]listB=[2,8](8 为共享节点)——第一轮后 pa=None、pb=8,循环立即退出返回 None,真值节点 8。正确条件 while pointer_a is not pointer_b 靠"同为 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),只用两个指针。高频追问一:"不换路,先各自数长度、长链先走差值行不行?"——行,三趟扫描同样 O(m+n)/O(1);换路法只是把"对齐长度差"编码进路线里,省掉一趟计数,两种都该会讲。追问二:"会不会死循环?"——不会:两条拼接路线都是有限长 x+s+1+y,最晚在共同终点 None 处相等退出,轮数上界约 m+n+2。

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) 信息,栈却把它存了 n 份。

① 关键转化——逐边掉头:扫描到某个节点时只干一件事:把它的 next 从"指后继"改写成"指前驱"。要安全完成这一笔,恰好需要两个帮手:前驱是谁(用 previous 记住,否则掉头没目标)、后继是谁(改写会覆盖 current.next,先存进 next_node,否则余链失联)。于是自然长出三指针滚动结构——不是背出来的,是"这一笔要覆盖谁"逼出来的。

身份表:三根指针在循环里各自守护什么
代码里的量它站在哪守护什么 / 何时动
previous已反转段的头不变量:从 previous 沿 next 走到 None 是"已完成掉头的前缀(逆序)";每轮在 current 掉头后前移一格
current两段交界处、本轮要掉头的节点不变量:从 current 沿原 next 走是"一根都没动过的后缀";掉头后跳到 next_node
next_node未处理段的头(备份)唯一使命:在 current.next 被覆盖前保住余链入口;每轮开头刷新

② 循环不变量串起全程:任意时刻整条链被分成两段——previous 领着的已反转段 + current 领着的原序段,两段合起来节点一个不少。循环体四行的顺序由"谁会被覆盖"唯一决定:current.next 是唯一会被写坏的数据,所以第一行必须先读它(next_node = current.next);随后 current.next = previous 掉头;最后两指针前移。

③ 递归版——信任后段已完工:reverseList(head.next) 返回后,后段已反转成 new_head→…→head.next,且 head.next 此刻正是反转段的。于是 head.next.next = head 把自己挂到段尾后面,再 head.next = None 抹掉旧边、让自己成为新尾。new_head 一路原样上传——因为原尾就是全局新头,每层都不该动它。

④ 常见困惑——为什么返回 previous 不是 current循环退出条件是 current 变 None:最后一个真实节点在退出前那一轮已经完成掉头、变成了 previous。current 此刻是"链外"的空位,previous 才是原尾=新头。

⑤ 用样例验证一轮:1→2→3 走到处理节点 2 那一轮:开局 previous=1(段 1→None)、current=2。先存 next_node=3;写 2.next=1 得段 2→1→None;前移后 previous=2current=3。中途 3 始终被 next_node/current 拎着,没有任何节点失联。

⚠ 易错坑 先写 current.next = previous 再读 current.next:最小反例 [1,2]——第一轮先掉头 1.next=None,再取 next_node=current.next 拿到的已是 None,循环下一轮直接结束,输出 [1],真值 [2,1](节点 2 整段丢失)。
返回 current:退出时它恒为 None——反例 [1,2] 输出 [],真值 [2,1]
递归版漏掉 head.next = None:反例 [1,2]——执行 1.next.next=1 后 2→1,而 1.next 仍指 2,形成 1↔2 短环,序列化死循环/超时,真值 [2,1]
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)(调用栈深度=链长)。高频追问一:"本题 n 最大 5000,递归版在 Python 里安全吗?"——默认递归深度限制约 1000,n=5000 会 RecursionError;生产和大 n 场景选迭代,这是选迭代为首选的硬理由,不只是风格偏好。追问二:"能不能省掉 next_node 只用两根指针?"——不能:覆盖 current.next 之前必须有人记住余链入口;Python 的元组同步赋值只是把三个引用写在一行,本质仍是三个。

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 与 1 相等,内层 2 与 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) 空间。费的根源:回文比对真正缺的只是"后半段能倒着读"这一个能力,前半段本来就能正着走;数组却把整条链都复制了一遍,冗余的恰是不需要复制的前半。

② 约束排除:进阶要求 O(1) 空间,数组、栈、递归缓存全部出局。单链表天生不能倒着走——想倒着读后半段,唯一的路是把后半段物理反转LC206 原封复用)。

③ 关键转化:回文 ⇔ 前半正读 == 后半倒读。于是拆成三个已学的子问题。先找中点:设两个游标 fastslow 同从头出发,fast 每轮 2 步、slow 每轮 1 步——fast 到尾时 slow 恰好走了半程、停在中点(LC141 家族的另一用法)。再从 slow 起反转后半(LC206)。最后双指针两端同步比值。

身份表:slow 两个阶段任务不同,其余各司其职
代码里的量哪个阶段它是谁 / 何时动
fast仅找中点阶段每轮走 2 步的指针,唯一使命是用"自己到尾"宣告 slow 已到中点;之后不再使用
slow找中点阶段每轮走 1 步,退出时停在正中点(奇数长)或右中点(偶数长)——正好都是该开始反转的位置
slow反转阶段改任反转循环里的当前节点(即 206 的 current),从中点一路掉头到尾
right_head反转完成后反转段新头=原链尾;从它出发等于"从尾往中间走"
left / right比对阶段两端读值指针;right 段更短或等长,由 while right 驱动循环

④ 循环条件从两种停法推出:while fast and fast.next 覆盖两种收尾——奇数长 fast 停在尾节点(fast.next 为 None 退出),偶数长 fast 直接变 None 退出;两种情形 slow 分别落在正中点和右中点,正好都是"该开始反转的位置"。

⑤ 常见困惑——奇数长的中点要不要跳过?不用,也不要手动跳。本写法从 slow 开始反转,奇数长时中点被划进反转段:比对的最后一轮是"中点和它自己"(同一对象)相比,必然相等;随后 right 走到 None 自然退出。任何"跳过中点"的特判反而制造错位(见坑③)。

⑥ 用原题示例 1 走一遍:1a→2a→2b→1b(值相同的节点用 a/b 区分对象)。

找中点第 1 轮:slow=2a,fast=2b。
找中点第 2 轮:slow=2b,fast 越过尾变 None——退出,slow 停在右中点 2b。
反转从 2b 起的后半:得 right_head = 1b→2b
比对第 1 轮:left=1a,right=1b,值相等 ✓。
比对第 2 轮:left=2a,right=2b,值相等 ✓。
right 走到 None,循环结束,返回 True。奇数长的情形(如 1→2→3→2→1)只多一轮"中点和自己比",其余完全相同。

⚠ 易错坑 比对循环写 while left:偶数长时 left 路线会穿过中缝多走进反转段——最小反例 [1,2,2,1]:前两轮比完后 right=None 本该结束,left 却还站在 2b 上继续,第三轮对 right.val 取值直接 AttributeError;真值 True。必须由更短的 right 段驱动。
把 LC148 的找中点写法(slow=head, fast=head.next)混进来:slow 会停在前半末尾而不是中点,反转段多含一个前半节点——最小反例 [1,1]:整条链被反转成 1b→1a,第二轮 left 走到 None 后仍被比对,报错或误判;真值 True。口径要配套:234 用 fast=head+"从 slow 反转",148 用 fast=head.next+"切断 slow 之后"。
奇数长手动跳过中点(反转前先 slow=slow.next):偶数长时会把右半第一个节点跳掉——最小反例 [1,2]:跳过后反转段为空,比对循环一轮不跑直接返回 True,真值 False。
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),只在原链表上反转。高频追问一:"这个解法改动了输入链表,怎么补救?"——比对结束后把 right_head 段再反转一次、接回前半末尾即可还原,多一趟 O(n) 不改变复杂度;面试主动提这一句是加分点。追问二:"用栈存前半再弹出来比不行吗?"——功能正确,但栈是 O(n) 额外空间,达不到进阶要求;它只是数组法换了容器。

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) 空间。费的根源:为了回答"走没走过",把每个走过的节点都存了一份;而题目要的只是一个布尔结论。

① 约束排除:进阶要求 O(1) 空间,哈希集出局;"改节点值做标记"也不该用(破坏输入,且值域内任何标记值都可能与真实值撞车)。不能留记忆,"有没有绕圈"这条信息就只能藏在两个游标的相对位置里。

② 关键转化——从"记忆判重"换成"追及判环":有环 ⇔ 一直走永远遇不到 None ⇔ 在环上跑的两个人如果速度不同,快的必然一圈圈追上慢的。让 fast 每轮 2 步、slow 每轮 1 步:双双入环后,fast 每轮净追近 1 格,环上间距逐轮减 1,有限轮内必到 0(相遇);无环则 fast 先撞到 None,循环自然结束。

身份表:两根指针各自守护什么
代码里的量它站在哪守护什么 / 何时动
slow从 head 出发,每轮 1 步被追者:入环后等着被 fast 逐格逼近;它的路程同时是后续 LC142 推导里的 a+b
fast从 head 出发,每轮 2 步双重身份:无环时是"撞尾探测器"(fast 或 fast.next 为 None 即宣告无环);有环时是"追击者"(与 slow 的环上间距每轮 −1)

③ "移动后再判"从初始状态推出:slow、fast 同从 head 出发,初始化的相等是出发点重叠,不是追上;只有移动之后的相等才唯一对应"fast 绕圈追上了 slow"。所以判定必须放在两次移动之后。

④ 常见困惑——fast 一次跳两格,会不会直接跨过 slow 永不相遇?不会。设某轮开始时 fast 在环上落后 slow 的距离为 d(沿前进方向),一轮后距离变 d−1:净接近恰好 1 格,距离序列是 d, d−1, …, 1, 0,逐一递减、不会从 1 跳到 −1。步幅选 1/2 正是为了让相对速度恰为 1,"跨过"在数学上不可能。

⑤ 用样例走一遍:3→2→0→-4,-4 指回 2:第 1 轮 slow=2、fast=0;第 2 轮 slow=0、fast=2;第 3 轮 slow=-4、fast=-4(fast 走 2→0→-4)——同一对象,返回 True。若把 -4 的回边去掉,fast 会在第 2 轮跳到 None,条件 fast and fast.next 失败退出,返回 False。

⚠ 易错坑 循环条件漏判:只写 while fast.next 或直接取 fast.next.next——最小反例无环链 [1,2]:第 1 轮后 fast=None,下一次访问 fast.next 即 AttributeError;真值 False。fast 一次两步,fastfast.next 都要判。
判相遇放在移动之前:初始 slow=fast=head 本就相等——最小反例无环单节点 [1]:立即返回 True,真值 False。
用值相等代替 is:最小反例无环链 [1,1,1]:第 1 轮 slow 停在第 2 个 1、fast 停在第 3 个 1,值相等即误判 True,真值 False。有环的本质是"再次踏上同一个对象"。
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):无环 fast 走 n/2 轮撞尾;有环时 slow 入环那一刻 fast 已在环内、落后间距至多 环长−1,每轮缩 1,slow 在环内走不满一圈就被追上。空间 O(1)。高频追问一:"为什么相遇保证发生在 slow 入环后第一圈内?"——就用上面这句间距论证,它同时给出 O(n) 的严格上界。追问二:"步幅换成 1 和 3 行不行?"——不保证:相对速度变 2,环上间距按 2 递减,若环长为偶数且初始间距为奇数,间距永远跳不到 0;1/2 步幅让相对速度恰为 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) 空间。费的根源同 LC141:为一个位置答案存下了全部足迹。

① 约束排除:进阶要 O(1) 空间,哈希出局;题面还写明不得修改链表,"改值做标记""逐个断链看断到哪"这类小聪明也被封死。剩下的只有 141 已经验证过的武器:快慢指针能在环内制造一个相遇点。问题变成——相遇点里藏着入口的什么信息?

② 把两个路程写成等式(本题核心,LC287 直接引用):设 head 到入口距离 a、入口沿环到相遇点 M 距离 b、M 沿环回到入口距离 c(环长 L=b+c)。相遇一刻:slow 走了 a+b;fast 速度是它两倍,走了 2(a+b);而 fast 的路线可以拆成"和 slow 相同的 a+b,再加在环里多绕的整数圈",即 2(a+b)=a+b+n(b+c)n≥1 是 fast 多绕的圈数)。两边消去 a+b

a+b = n(b+c) ⟹ a = n(b+c) − b = (n−1)(b+c) + c

③ 把等式翻译成走路方案:a = (n−1)(b+c) + c 读出来是一句话:从相遇点 M 出发走 a 步 = 先走 c 步回到入口,再绕整 (n−1) 圈——落点仍是入口。而从 head 出发走 a 步,按定义也到入口。所以让 entry 从 head、slow 留在 M,同速各走一步:走满 a 步时两者必然同站入口。且在此之前不会相等——a>0 时 entry 一直在环外的直线段上,slow 一直在环里,碰不到;第一次 entry is slow 就是入口。注意全程不需要知道 n 是几:多绕的 (n−1) 圈自动被"绕回原地"吸收。

身份表:slow 在两个阶段身份不同,用表钉死
代码里的量哪个阶段它是谁 / 何时动
fast仅阶段一2 倍速追击者:制造相遇点 M,并贡献等式左边的 2(a+b);相遇后退场
slow阶段一1 倍速被追者:相遇时恰走 a+b 步——等式的另一半
slow阶段二变身"留守 M 的行者":每轮 1 步,等式保证它走 a 步后落在入口;必须复用相遇现场,不能重置
entry仅阶段二从 head 出发的 1 倍速行者:走 a 步到入口,与留守者同步会师

④ 常见困惑——为什么偏偏是"再走 a 步"两边同时到?不是巧合,是把未知量 a 从等式里"走"出来:我们不知道 a 是多少,但等式保证"head 出发走 a 步"和"M 出发走 a 步"落点相同(都是入口);同速前进就是在同步枚举步数,第一次相等即第一次同落点。这也解释了为什么留守方必须从 M 出发——换成任何别的点,等式就不成立了。

⑤ 用样例验证:1→2→3→4→5,5 指回 3:a=2(1→2→3 前两步)、入口=3。阶段一在 4 相遇(M=4),b=1(3→4)、c=2(4→5→3)、L=3;检验等式:slow 走 a+b=3 步,fast 走 6 步、多绕 n=1 圈,a = 1×3 − 1 = 2 ✓。阶段二:entry 走 1,2,3;slow 走 4,5,3——第 2 步同落 3,正是入口。

⚠ 易错坑 第二阶段把两个指针都重置回 head:起点即相等,循环体一次不跑——最小反例 [1,2,3]、pos=1(入口是节点 2):错误返回节点 1,真值节点 2。留守方必须原地留在 M。
第二阶段留守方仍走 2 步:破坏"同步枚举步数"——反例 [1,2,3]、pos=1(M=3,入口=2):正确解第 1 步 entry=2、slow=2 会师;写错则 slow 两步跳回 3,最终在 3 相等,返回 3,真值 2。
相遇判定放在移动之前:初始 slow=fast=head 相等——最小反例无环单节点 [1]:被当成相遇进入第二阶段,返回节点 1,真值 null。判定必须在移动之后(同 141 坑②)。
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)(slow 入环后不满一圈即被追上)+ 找入口再走 a<n 步;空间 O(1)。高频追问一:"fast 多绕的圈数 n 到底是几,影响答案吗?"——不影响也不用算:(n−1) 圈在第二阶段表现为留守指针在入口"原地转整圈",落点不变,这正是等式改写成 (n−1)(b+c)+c 的意义。追问二:"为什么假设 slow 相遇前只走了 a+b、没多绕圈?"——slow 踏进入口时 fast 已在环内、间距至多 L−1,每轮缩 1,所以 slow 在环内走不满一圈(b<L)就被追上,路程恰是 a+b。

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) 空间。慢的根源:排序是在反复"重新发现"一个输入早已白送的事实——两条链各自有序,意味着全局最小值永远只会是两个当前头之一;对着两个候选做全量排序,就是把已知信息扔掉重算。

① 关键转化——归并视角:结果链从小到大逐个"认领"节点:每一步的候选只有 l1l2 的当前头(各自链里头就是最小),比一次、接一个、前移一格,一趟走完。这正是归并排序 merge 步的链表版——LC148LC23 都在反复调用它。

② dummy 哨兵——守住"头会被换掉"的场景:结果链的第一个节点来自 l1 还是 l2,要比过才知道——头节点无法预先确定(可能被换掉)的场景,就立一个 dummy 当"第 0 个节点"。它让"接第一个节点"和"接第任意个节点"共用同一行 tail.next=…,免去"结果还是空链"的分支;最后 dummy.next 才是真头。这套讲法在 2/19/24/25/148 里原样复用。

身份表:四个量各自守护什么
代码里的量它站在哪守护什么 / 何时动
dummy结果链头之前的第 0 位固定锚点,永不动、不属于结果;守住"真头尚未产生/可能来自任一链"的场景
tail已合并段的末尾不变量:dummy.next…tail 已排好,且值全部 ≤ 两链剩余的任何值;每接入一个节点前移一格
l1 / l2各自链"未认领部分"的头谁的头被认领谁前移;另一方原地不动

③ 收尾一行为什么合法:循环退出时一方为空,另一方剩余段自身有序,且(由 tail 的不变量)所有剩余值 ≥ 已合并段末尾——整段挂上即是正确答案,不需要继续逐个比。tail.next = l1 or l2 恰好取非空的那条。

④ 常见困惑——接走 l1 的头,会不会改坏 l1 链的内部结构?不会。tail.next=l1 只改写 tail 的出边,被认领节点自己的 next 原封不动;而 l1=l1.next 在同一条语句里先读后赋,余链入口稳稳交接。整个算法不新建任何节点,只是重新分配"谁指向谁"。

⑤ 用样例走一遍:1a→2→4a1b→3→4b:认领顺序 1a(<= 取 l1)、1b、2、3、4a;此时 l1 空,整段接上 l2 剩余的 4b,得 1,1,2,3,4,4。等值对 1a/1b 因 <= 保持 l1 优先的稳定顺序。

⚠ 易错坑 漏掉收尾 tail.next = l1 or l2:最小反例 l1=[1]l2=[2]——循环取走 1 后 l1 空退出,输出 [1],真值 [1,2](尾巴整段丢失)。
返回 dummy 而非 dummy.next:反例同上,输出 [0,1,2](哨兵默认值 0 混进结果),真值 [1,2]
循环条件写 while l1 or l2 而体内直接取 .val:最小反例 l1=[1]l2=[]——第一轮就对 None 取 l2.val,AttributeError;真值 [1]。"or 进循环"必须配空判断,不如 and + 收尾一行干净。
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)。高频追问一:"递归版怎么写?"——merge(l1,l2) = 较小头 + merge(其余),代码三五行,但栈深 O(m+n),链长上万时选迭代。追问二:"为什么比较用 <= 不用 <?"——输出值序列相同,但 <= 让等值元素保持 l1 优先的稳定性;它同时是 LC148 归并排序稳定性的来源,改成 < 后 148 就不再是稳定排序。

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 · 思路(暴力→最优)

暴力起手:把两条链各还原成整数、相加、再拆回链表。Python 大整数不怕溢出,但其它语言 100 位数字直接爆 long;更重要的是它绕了远路——还原和拆解各要一趟,而"逆序存储"本身就是出题人递来的箭头。

① 读约束里的信号:为什么偏偏"个位在头"?因为手算竖式加法就是从个位开始、进位往高位传——逆序链表让"沿 next 遍历"与"从低位到高位"两个方向天然重合。顺着走一遍、边走边加,才是这道题设计好的解法路径(正序版 LC445 反而要先做反转或用栈)。

② 关键转化:问题退化成竖式加法模拟:每一位 本位和 = 两个当前位 + 进位,其中 digit = 和 % 10 留下、carry = 和 // 10 上交下一位。链表只是数位容器,核心状态只有一个标量 carry

身份表:注意 l1/l2 耗尽后身份变化
代码里的量它是谁守护什么 / 何时动
l1 / l2各自数的当前位(低→高)每轮前移一格;耗尽后变身"贡献 0 的虚位"if l1 else 0),让不等长无需分支
carry低位上交的进位不变量:进入第 i 轮时,它是前 i−1 位相加的全部溢出,取值只会是 0 或 1;每轮由 divmod 刷新
digit本位的最终数字当轮新建为结果节点,用完即弃
dummy / tail结果链哨兵 / 已产出数位的末尾同 LC21 讲法:结果头(个位节点)第一轮才诞生——头未定,先立哨兵

③ 循环条件从"还有什么活没干完"推出:没干完的来源恰有三个——l1 没走完、l2 没走完、最后一位加完还欠着进位。三者取并集就是 while l1 or l2 or carry。想验证第三项的必要性,拿 999+1:两链都耗尽时 carry=1,必须再造一位,否则千位蒸发。

④ 常见困惑——carry 会不会大于 1、要不要循环借位?不会。单个数位最大 9,9+9+1=19,divmod 后 carry 最多是 1;这也是"进位只用一个 0/1 标量"的底气。

⑤ 用样例验证收尾轮:l1=[5]l2=[5]:第 1 轮 5+5+0=10 → digit=0、carry=1,两输入耗尽;第 2 轮仅由 carry 驱动,0+0+1=1 → 追加 1。结果 0→1,逆序读是 10 ✓。

⚠ 易错坑 循环条件漏 or carry:最小反例 [5]+[5]——输出 [0],真值 [0,1],最高位整位丢失。
不等长时直接取走空一侧的 .val:最小反例 [9,9]+[1]——第 2 轮 l2 已是 None,l2.val AttributeError;真值 [0,0,1](99+1=100)。
divmod 商余接反(写成 digit, carry = divmod(...)):最小反例 [5]+[5]——divmod(10,10)=(1,0),接反后本位记 1、进位 0,输出 [1],真值 [0,1]。记法:商上交(进位),余留下(本位)
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)) 存结果链表(进位可能多一位),不计输出则只有 carry 等常数个变量。高频追问一:"如果数字是正序存储(LC445)怎么办?"——加法只能从低位开始,先反转两链(或各压一个栈)再套本题模板,算完按需反转回去。追问二:"能不能不建新链、原地复用较长链的节点?"——可以,把结果写回较长链并在必要时借用短链节点,额外空间降为 O(1);但末位进位仍可能新建 1 个节点,且会破坏输入,面试要先声明。

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 步停在待删节点的前驱,改一条边跳过它。复杂度也是 O(L),慢的不是量级而是趟数——"倒数第 n"依赖总长 L,看起来必须先数一遍。进阶要求一趟扫完:得在不知道 L 的情况下判断"距尾还有多远"。

① 关键转化——把"与尾的距离"翻译成"与同伴的距离":单链表只能向前,无法从尾往回退;但"倒数第 n"本质是一个相对位置。让 fast 先走若干步、再与 slow 同速前进:同速移动中两者间距永远不变,于是"fast 撞到尾后的 None"那一刻,slow 距离链尾的步数恰好等于出发时的领先量——尾部信息被间距编码,一趟搞定。这是快慢指针家族里"固定间距"的用法(LC141 是"不同速度"的用法)。

② dummy 哨兵——守住"头会被换掉"的场景:删除动作发生在前驱身上,而头节点没有前驱,且 n=L 时被删的正是头。统一讲法:头节点可能被换掉的场景,就挂一个 dummy 当人造前驱,让"删头"和"删中间"共用同一行 slow.next = slow.next.next,最后返回 dummy.next

身份表:三个量各自守护什么
代码里的量它站在哪守护什么 / 何时动
dummyhead 之前的人造第 0 位给每个真实节点(含头)配前驱;守住 n=L 删头的场景,永不动
fast从 dummy 出发的测距先遣先行阶段独走 n+1 步;同步阶段与 slow 齐步;唯一使命:用"自己变 None"宣告 slow 到位
slow前驱定位器不变量:同步阶段始终落后 fast 恰 n+1 条边;fast=None 时 slow.next 即待删节点

③ 先行步数 n+1 从目标倒推:从 dummy 起给位置编号 0…L,fast 走到 None 相当于站上位置 L+1。此刻 slow 位置 = L+1−(先行步数 k);我们要它停在待删节点(倒数第 n=位置 L−n+1)的前驱,即位置 L−n。解 L+1−k = L−nk = n+1。直觉版:想停在"目标前一格",就要比 n 多领先一格。

④ 常见困惑——网上有的版本 fast 先走 n 步,谁对?都对,口径不同:先走 n 步的版本配的是"同步到 fast.next 为 None(fast 停在尾节点)就停",slow 同样落在前驱。错的是混搭:先走 n 步却同步到 fast=None,slow 就会停在待删节点本身。选定一套口径背到底:都从 dummy 出发、先行 n+1、走到 fast=None。

⑤ 用样例走一遍:dummy→1→2→3→4、n=2(删 3):fast 先行 3 步到节点 3;同步两轮:fast 4→None,slow 1→2。slow=2 正是 3 的前驱,2.next=4 后主链 1→2→4 ✓(与图解帧一致)。

⚠ 易错坑 口径混搭:先行 n 步却同步到 fast=None——最小反例 [1,2,3]、n=1:slow 最终停在节点 3(待删自身),执行 slow.next.nextslow.next 已是 None,AttributeError;真值 [1,2]
不加 dummy、双指针从 head 出发:删头场景崩——最小反例 [1,2]、n=2:fast 先行途中就越界(或 slow 落在待删节点上找不到前驱),真值 [2];dummy + 统一口径天然覆盖删头。
返回 head 而非 dummy.next:最小反例 [1]、n=1——删除后链为空,返回 head 仍是节点 1,输出 [1],真值 []。头被删时只有 dummy.next 知道新头。
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)。高频追问一:"两趟法也是 O(L),一趟法真正的优势在哪?"——量级相同;优势在数据只能顺序读一遍的场景(流式输入、磁盘顺序读),以及常数上少扫一遍。面试把两法都摆出来讲清 trade-off 比只写一趟法更加分。追问二:"如果题目不保证 n 合法怎么办?"——先行阶段里 fast 提前变 None 就说明 n > L,此时可直接返回原链或报错,一行判断即可兜底。

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,每对内部顺序颠倒,两对之间的先后顺序不变。交换前交换后12342143
两个完整分组(虚线框出的两对)分别翻转,组间仍保持原来的先后顺序。

示例 2

输入:head=[]

输出:[]

示例 3

输入:head=[1]

输出:[1]

示例 4

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

输出:[2,1,3]

约束与保证

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

一句话总纲:立 dummy 当第 0 个节点;只要前驱后面还够两个节点,就取出这相邻一对,按固定的三笔改线完成交换,再把前驱移到这一对交换后的新尾;剩下不足两个时停止,返回 dummy.next

① 暴力起手 + 约束排除:最省事的写法是隔一个换一次节点值——题目明文禁止,出题人要考的恰是指针重接本身;新建一条链按 2,1,4,3 的顺序抄一遍则费 O(n) 空间。两条捷径都被堵死,只剩原地改边。

② 关键转化——整链=一串相邻对:每对内部做一次"最小号的 LC206 反转"(段里只有两个节点),对与对之间靠一个前驱指针传递衔接。这正是 LC25 取 k=2 的特例:探测(还剩两个吗)→ 段内反转 → 缝合前驱。

③ dummy 哨兵——守住"头会被换掉"的场景:第一对交换后,新头是原来的第二个节点——头被换掉了。统一讲法:立 dummy 当第 0 个节点,让第一对和后面每一对都有现成前驱,返回 dummy.next

身份表:一对交换现场的三个量
代码里的量它站在哪它负责什么 / 何时动
pair_prev上一对的尾(初始为 dummy)不变量:dummy…pair_prev 段已全部交换完毕且连接正确;每完成一对,前移到该对新尾
first本对原第 1 个节点交换后降为对尾:负责用自己的 next 衔接后段;本对完成后就是新的 pair_prev
second本对原第 2 个节点交换后升为对头:等着被 pair_prev 接管

④ 三笔改线的顺序从"先断哪条会丢什么"推出:一对交换要写三条边:first.nextsecond.nextpair_prev.next。三条旧边里,唯一没有备份、写丢就找不回的信息是 second.next(后段入口)。

第一笔 first.next = second.next趁后段入口还在,先把后段挂到 first 身上。
第二笔 second.next = first对内掉头。若把这笔提前到第一笔之前,后段入口先被覆盖,first 只能指向 second 自己——自环(坑①)。
第三笔 pair_prev.next = second把新对头接回主链。它不覆盖任何还需要的信息,放最后最清晰。

⑤ 常见困惑——pair_prev 该前移到 first 还是 second?交换后本对顺序是 second→first对尾是 first,所以 pair_prev = first。凭"变量名叫 first 就该在前"的直觉移到 second,下一对会从错位的地方取节点(见坑②的反例)。

⑥ 用原题示例 1 走一遍:dummy→1→2→3→4

第一对:first=1、second=2。
第一笔 1.next=3:后段 3→4 挂到 1 身上,保住不丢。
第二笔 2.next=1:对内掉头。
第三笔 dummy.next=2:现场稳定为 dummy→2→1→3→4
pair_prev 前移到对尾 1,正好是下一对 (3,4) 的前驱 ✓。
第二对同法得 dummy→2→1→4→3;再往后不足一对,返回 [2,1,4,3] ✓。

⑦ 解法②(递归):信任 swapPairs(second.next) 已把后段交换完并返回其新头。本层只做两笔:first.next=递归结果(对应迭代的"保后段")、second.next=first(对内掉头),返回 second——"接回前驱"由上一层的这条赋值隐式完成。不足两个节点的尾段原样返回即递归出口。迭代 O(1) 空间为首选,递归胜在结构直白。

⚠ 易错坑 先写 second.next=first 再写 first.next=second.next:第二笔读到的 second.next 已是 first 自己——first.next=first 自环。最小反例 [1,2]:得 2→1→1→…,遍历死循环/超时,真值 [2,1]
pair_prev 前移到 second:最小反例 [1,2,3,4]——第一对完成后 pair_prev 停在 2(新对头),下一轮取到 first=1、second=3,把跨对的 1、3 交换,输出 [2,3,1,4],真值 [2,1,4,3]
循环条件只判 pair_prev.next:奇数长最后剩单节点也进循环——最小反例 [1,2,3]:处理完 (1,2) 后剩 3,second = first.next 为 None,写 second.next 时 AttributeError;真值 [2,1,3](不足一对保持原样)。
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/2)=O(n) 栈深。高频追问一:"递归版的信任边界在哪里?"——swapPairs(second.next) 返回"后段已完成交换的新头",本层只负责把 first 接上它、再让 second 反指 first;不足两个节点的尾段原样返回就是递归出口。追问二:"推广到每 k 个一组怎么改?"——加一步"先探测够不够 k 个",段内反转从两节点手工交换升级为 LC206 三指针,缝合逻辑不变——这就是 LC25。

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 分组翻转前后原链为 1、2、3、4、5,虚线框出前两个完整分组;k 等于 2 时两组各自翻转,得到 2、1、4、3,最后的 5 不足一组,保持原顺序。原链k=21234521435不足 k,保持原序
只有节点数达到 k 的完整分组(虚线框)才翻转,末尾的 5 原样保留。

示例 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 · 思路(暴力→最优)

一句话总纲:立 dummy 后逐组处理:每轮先从上一组末尾向后探测 k 步确认这组够数;够就用 LC206 的三指针只反转这一段,再把段的两端缝合回主链;探测中途撞到 None 说明尾段不足 k 个,按题意保持原序、立即返回。

① 暴力起手 + 约束排除:每 k 个节点压栈再倒序接出,直观但要 O(k) 辅助空间;抄值数组分组 reverse 再写回,既费空间又形同"只改值"。进阶要求 O(1) 空间,两条路都被排除——组内只能用 LC206 的三指针原地反转。

② 关键转化——拆成三个已学的子问题:先给两个关键位置起名:从上一组末尾走 k 步到达的节点记作 kth(本组最后一个节点),它的后继记作 group_next(下一组的头)。整题 = 探测(这组够不够 k 个?不够按题意整组保持原序——所以必须先数够再动手,不能翻一半再回滚)+ 段内反转(LC206 的区间版:终点从 None 参数化成 group_next)+ 组间缝合(前驱接新组头、新组尾接后段,同 LC24 的缝合逻辑)。dummy 统一讲法:第一组反转后头被换掉——立哨兵,返回 dummy.next

身份表:一轮"探测→反转→缝合"里六个量的分工
代码里的量它站在哪它负责什么 / 何时动
group_prev上一组的尾(初始 dummy)不变量:dummy…group_prev 已全部翻转并缝合完毕;每组收尾时前移到本组新尾
kth本组第 k 个节点探测阶段从 group_prev 走 k 步得到;反转后它就是新组头,等着被 group_prev 接管
group_next下一组的头(kth.next)双重身份:反转循环的停止位置(current 走到它就停),也是 previous 的初值(新组尾自动接它)
previous / currentLC206 三指针在区间上的复用与 206 唯一的差别:previous 初值不是 None 而是 group_next
group_tail原组头(反转前的 group_prev.next)反转后降为新组尾;提前保存它,收尾时 group_prev 要搬到这里

③ previous 初值从 206 的语义推出:206 里 previous 初值 None 的真实含义是"反转段翻完之后接什么"——整链反转,尾接 None。本题组尾要接的是下一组头,把这个"之后接什么"换成 group_next:第一个被掉头的节点(原组头)反转时自动指向后段,缝合右侧一步都不用写。参数化终点,而不是翻完再补线。

④ 常见困惑——反转会不会把下一组也卷进去?不会:循环条件 current is not group_next 用对象身份判停,恰好翻 k 个就停。

⑤ 常见困惑——缝合时 group_prev.next 为什么还能拿到原组头?反转只改组内节点的出边,没人动过 group_prev 的出边,它仍指向原组头。但一旦执行 group_prev.next = kth,这条线索就断了。所以缝合固定三小步: group_tail = group_prev.next 把原组头存好,改线 group_prev.next = kth最后 group_prev = group_tail 前移——先断线索会丢组尾位置(坑③)。

⑥ 用原题示例 2 走一遍:1→2→3→4→5、k=3。

探测:从 dummy 走 3 步到 kth=3,group_next=4。
反转 1,2,3(previous 从 4 起步):1 掉头指 4,2 掉头指 1,3 掉头指 2。
段稳定为 3→2→1→4→5——原组头 1 反转时自动接上后段 4,右侧缝合零成本。
缝合左侧:dummy.next=3;group_prev 前移到新组尾 1。
下一轮探测:从 1 走 3 步(4、5、None)撞到 None——只剩两个,不足一组。
尾段 4→5 保持原序,返回 [3,2,1,4,5] ✓。

⚠ 易错坑 不先探测、逐组盲翻:尾部不足 k 的组也被翻——最小反例 [1,2,3,4,5]、k=3:输出 [3,2,1,5,4],真值 [3,2,1,4,5](尾组 4,5 必须保持原序)。
previous 初值写 None(照抄 206):组尾断链——最小反例 [1,2,3,4]、k=2:第一组翻完 1.next=None,后段 3→4 失联,下一轮探测立即撞 None 提前返回,输出 [2,1],真值 [2,1,4,3]
缝合三步顺序错:先 group_prev.next=kth 再想读原组头——线索已断,group_prev 误移到新组头。最小反例 [1,2,3,4]、k=2:第二轮从错误位置取组,输出 [2,3,1,4] 之类的错序,真值 [2,1,4,3]。先存 group_tail,再改线,最后前移。
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):每个节点被探测走过 1 次、被翻转 1 次,合计常数次;空间 O(1) 迭代。高频追问一:"探测那层循环会不会把复杂度抬到 O(nk)?"——不会:每组探测走 k 步、翻转走 k 步,摊到每个节点是 2 次访问,总量 2n,这是摊还视角,不能按"组数 × 每组最坏"直乘再放大。追问二:"能不能不探测?"——可以先整趟数出总长 L,前 L//k 组放心翻——多一趟计数换掉逐组探测,两种写法都是 O(n),面试说清 trade-off 即可。

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,末尾指向 null;虚线弧线是 random 指针:13 指向 7,11 指向 1,10 指向 11,1 指向 7,节点 7 的 random 为 null。71311101nullnull
实线是 next 主链,虚线弧线是每个节点的 random 指向;节点 7 的 randomnull,对应下标编码 [[7,null],[13,0],[11,4],[10,2],[1,0]]

示例 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 → 拆开两条链并还原原链。

① 暴力起手:沿 next 逐个新建拷贝不难,卡壳在 random:拷贝到某个节点 X 时,X.random 指向的目标节点可能还没被创建(前向引用),没法直接指过去。硬来的办法:先数出 X.random 是原链第几个节点,再去拷贝链数同样多步——每条 random 都从头数一遍,O(n²)。慢的根源:"原节点 → 它的拷贝"这层对应关系每次都现场重算,从没被存下来。

② 关键转化(解法一)——把对应关系存成哈希表:old_to_new[原节点]=拷贝。它同时化解了前向引用的先后矛盾:第一趟只造对象不连线(保证所有引用目标的拷贝都已存在),第二趟统一翻译边——拷贝.next = map[原.next]拷贝.random = map[原.random]。"先建齐、再连线"是所有带内部引用结构深拷贝(含 LC133 克隆图)的通用节奏。

③ 约束升级(解法二)——把映射编码进结构位置:进阶想省掉 O(n) 哈希表,可"对应关系"总得存在某处——既然不能存在容器里,就存在链表结构本身里:把每个拷贝 X' 插在原件 X 身后,形成 A→A'→B→B'。此后"X 的拷贝"就是 X.next,位置约定替代了哈希查表:X.random=T ⟹ X'.random = T.next = T'。代价是最后要第三趟把两条链拆开,并且必须把原链完全还原(评测会校验输入未被破坏)。

身份表(交织法):同一位置在三趟里的身份
代码里的量它是谁它负责什么 / 何时动
current三趟共用的原链游标交织后每轮跳两格(current.next.next),永远只踩原节点,不踩拷贝
current.next(插入后)current 的拷贝不变量:"拷贝紧随原件"——整个算法唯一的映射依据;第三趟拆分时此不变量被逐对解除
copy_head拷贝链的头(head.next)必须在拆分之前保存——拆分会把 head.next 改回原后继,届时线索已断
copy_node拆分趟中的当前拷贝每轮同时做两件事:把原件的 next 还原、把自己接向下一个拷贝

④ 常见困惑——能不能边插入边连 random,两趟并一趟?不能。交织进行到一半时,current.random 指向的目标 T 若在后方,T 的拷贝还没被插入,T.next 还是原后继而不是 T'——位置约定尚未对全链生效。这和哈希法必须"先建齐再连线"是同一个矛盾的两种表现。

⑤ 用小样例验证 random 翻译公式:取原链 A→BA.random=BB.random=A(一条指后、一条指前)。

插入拷贝后:A→A'→B→B'
连 A' 的 random(目标在后方):A'.random = A.random.next = B.next = B' ✓。
连 B' 的 random(目标在前方):B'.random = B.random.next = A.next = A' ✓。
不管 random 指前指后,公式同一条:原目标的下一个节点,就是原目标的拷贝

⚠ 易错坑 交织法连 random 不判空:最小反例单节点 [[7,null]]——current.random.next 对 None 取 next,AttributeError;真值为一个 random=null 的拷贝节点。先 if current.random 再连。
拆分只串拷贝链、不还原原链:原链残留 A→A' 交织边——最小反例 [[1,null],[2,null]]:返回结果正确,但评测校验输入时发现原链被改,判 Wrong Answer(LeetCode 明确要求原链保持完好)。
哈希法用 map[key] 方括号取值:尾节点 next=None、random 为 null 时查 map[None]——最小反例单节点 [[7,null]]:KeyError 崩溃;真值正常返回。用 map.get(key) 让 None 键自然映射为 None。
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)。高频追问一:"交织法说 O(1) 空间,可是新建了 n 个拷贝节点,怎么算?"——拷贝是题目要求的输出本身,不计入额外空间;额外的只有几个游标变量。哈希法多出的 old_to_new 才是真正的辅助开销。追问二:"如果 random 可能指向链外的节点还能用交织法吗?"——不能:"拷贝紧随原件"的位置约定只覆盖本链节点,链外目标没有交织拷贝,只能退回哈希映射。

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。排序前排序后42131234
节点对象仍组成同一条链,只是连接顺序变为升序。

示例 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 · 思路(暴力→最优)

一句话总纲:用快慢指针找到前半末尾并物理断开成两条短链;递归把两半各自排好;再用合并两条有序链的写法(LC21)拼回一条——链缩到 0/1 个节点时天然有序,递归见底。

① 暴力起手:把节点值抄进数组、排序、再按序写回值域——O(n log n) 时间但 O(n) 空间,而且"只改值不改结构"在面试里常被追加禁止。慢/费的根源:完全没利用链表"断开一条边、拼接一条边都是 O(1)"的长处。

② 约束排除——在 O(n log n) 家族里挑适合链表的:目标 O(n log n),候选是快排、堆排、归并,逐个对着"链表没有 O(1) 随机访问"过筛:

· 堆排:靠下标算父子(2i+1),链表根本做不了——杀。
· 快排:分区可以做,但 pivot 只能取头(有序输入直接退化 O(n²)),随机化 pivot 又要 O(n) 走位——不划算。
· 归并:只需要"顺序遍历 + 断链 + 拼接",全是链表的强项;而且合并两条有序链可以原地复用节点(LC21 双指针),不像数组归并要 O(n) 辅助数组——链表归并反而比数组归并省空间。→ 选归并。

③ 关键转化——分治三件套:切、排、合。切:让游标 fast 每轮走 2 步、slow 每轮走 1 步,fast 到尾时 slow 停在切割点。排:递归调用自己处理两条短链。合:LC21 的双指针合并。递归出口:0/1 个节点天然有序。

身份表:切割阶段三个量各自负责什么
代码里的量它站在哪它负责什么 / 何时动
fasthead.next 出发、每轮 2 步起点偏移一格是本题核心细节:它让 slow 停在"前半末尾"而非"右中点"
slow从 head 出发、每轮 1 步退出时停在前半最后一个节点,slow.next 就是刀口
right_head后半段入口的备份先存后断:slow.next=None 之前必须把它保存下来,否则后半失联

④ fast 起点偏移从"递归必须收敛"推出:递归收敛要求每次切割后两段都严格变短。拿最小的偶数链 [a,b] 试:若 fast 从 head 出发(141/234 的口径),slow 会停在 b(右中点),切出"整条 + 空"——左半没变短,无限递归。让 fast 提前一格出发,slow 相应早停一格、落在前半末尾 a,切成 [a][b] ✓。口诀:141/234 要"中点",148 要"前半末尾",差一格,fast 起点就差一步(这正是 234 坑②的反向)。

⑤ 常见困惑——为什么必须真的写 slow.next=None递归参数只是头指针,不带长度;不断开的话 sortList(head) 拿到的仍是整条链,子问题规模没变小。切割的本质是"把长度信息物理写进结构",光在脑子里认为"左半到 slow 为止"没有用。

⑥ 用原题示例 1 走一遍:4→2→1→3

顶层切割:slow 从 4 出发、fast 从 2 出发;一轮后 slow=2、fast=3;fast.next 为 None 退出——slow 停在前半末尾 2。
先存 right_head=1,再断开 2.next:得 4→21→3
左半 4→2 再切成 42,合并得 2→4
右半 1→3 再切成 13,合并得 1→3
顶层合并 (2→4, 1→3):依次取 1、取 2、取 3——每次改结果尾的 next 前,两条待合并链的余下节点都由 left/right 指针拎着,不会丢。
接余段 4,结果 1→2→3→4 ✓。

⚠ 易错坑 忘写 slow.next=None:最小反例 [2,1]——切出的"左半"仍是整条 2→1sortList 参数原样递归,RecursionError;真值 [1,2]
fast 从 head 出发(照抄 141/234):最小反例 [2,1]——slow 停在 1,切成 [2,1][],左半没变短,同样无限递归;真值 [1,2]
递归出口只判 not head 漏了 not head.next:最小反例 [1]——单节点继续切出"自己 + 空",无限递归;真值 [1]。0/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 层,每层所有 merge 合计扫 O(n);空间 自顶向下 O(log n) 递归栈,自底向上 O(1)。高频追问一:"进阶的 O(1) 空间怎么达成?"——自底向上:子段长按 1,2,4,… 迭代,每轮从头把相邻两段切下来合并再接回,消掉递归栈;面试先写自顶向下,再口述这套改法即可。追问二:"为什么不用快排?"——链表快排能写(按值三路划分),但取头当 pivot 对已排序输入退化 O(n²),随机化 pivot 又需 O(n) 定位;归并稳定且稳保 O(n log n),是链表排序的标准答案。

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 · 思路(暴力→最优)

一句话总纲:先把每条非空链的当前头节点放进一个小顶堆;然后反复"弹出全局最小 → 接到结果链尾 → 用它的后继补位";堆空时结果链就是答案。另一条等价的路是分治两两合并,两条路的取舍放在末尾对比。

① 暴力起手——逐一合并:拿第 1 条链当结果,把第 2、3…条依次用 LC21 的双指针写法 merge 进去。设节点总数为 N,第 i 次合并都要把已经长起来的结果链从头重扫一遍,累计 O(k·N)。慢的根源:结果链的前缀被反复重扫——最早合入的节点要参与将近 k 轮比较,可它们"已经排好"的身份从没被利用。

② 解法一·小顶堆——每步真正要做的只是"k 个头里挑最小":每条链各自升序,所以下一个该输出的节点只可能是某条链尚未取走部分的第一个节点——其余节点都被自己链里更靠前的节点挡着,轮不到。于是每一步的工作不是"合并两条链",而是"从 k 个候选里取最小",这正是小顶堆的标准职责(LC215/LC347 同款结构)。堆里始终只放这 ≤k 个当前头:弹出最小、接到结果尾,再把它的后继放进堆补位。暴力慢就慢在没用上这条性质:它每一步重扫整条结果链,其实全局最小的候选从来只有 k 个。

动手前先交代一个 Python 细节:堆比较的是整个元素,若两个元素的节点值相等,比较就会落到下一个分量上;而 ListNode 没有定义 <,一旦轮到节点对象参与比较就直接报错。所以堆元素存成三元组 (节点值, 链下标, 节点),让链下标在值相等时顶上。各分量的职责如下表:

身份表(堆法):堆与三元组各分量的职责
代码里的量它是谁守护什么 / 何时动
heap每条未耗尽链的当前头组成的候选集合不变量:任意时刻恰好 ≤k 个元素、每链至多一个;弹一个就用其后继补位,链耗尽则不再补
三元组的 val该头节点的值,堆的排序键堆按它选全局最小
三元组的 index该头节点所在链的下标唯一作用:val 相等时代替节点参与比较——ListNode 之间没定义 <,比到节点就 TypeError
三元组的 node待接入结果链的节点本体弹出后接到 tail 之后,并把 node.next 放进堆补位
dummy / tail结果链哨兵 / 已合并段末尾同 LC21:结果头来自哪条链事先未定 → 先立哨兵

③ 解法二·分治两两合并:逐一合并的另一个改法:症结是第 1 条链被重扫了约 k−1 次,那就不让任何一条链当"常驻结果"。把 k 条链两两配对、层层合并(LC148 的分治骨架从"一条链切两半"横向搬到"k 条链分两组"),每条链只参与 log k 层合并;每一层所有 merge 加起来恰好把 N 个节点各扫一遍,总计 O(N log k)。

④ 三种做法取舍:

· 逐一合并:O(k·N) 时间,只在 k 很小时勉强可用,面试里当对照组。
· 小顶堆:O(N log k) 时间、O(k) 堆空间;代码短,且天然适合流式场景——k 路数据陆续到达时随到随插。
· 分治合并:O(N log k) 时间、O(log k) 递归栈;纯指针操作、不依赖堆库,辅助空间更小。
两个正解时间同阶,面试写熟一种、口述另一种。

⑤ 常见困惑——为什么链下标能保证轮不到比较节点?元组比较是逐位短路:值不等时第 1 位定胜负;值相等时第 2 位必不等——堆里任意时刻每条链至多一个头,不同头的链下标必不同——所以第 3 位的节点永远不会被比较。另一问:"弹出后为什么只补一个后继、不用重建堆?"——弹出只改变了它所在那条链的候选,其余链的头原封不动,push 一个新元素堆序就自动恢复。

⑥ 用原题示例 1 走一遍(堆法):lists=[[1,4,5],[1,3,4],[2,6]],三条链记为链0 1→4→5、链1 1→3→4、链2 2→6;堆元素简记为 (值, 链下标)

初始:三个头入堆,堆=[(1,0),(1,1),(2,2)]
(1,0):两个 1 平局,靠链下标 0<1 分先后。接入链0 的 1,补其后继 (4,0),堆=[(1,1),(2,2),(4,0)]
(1,1):接入链1 的 1,补 (3,1),结果 1→1
(2,2):接 2,补 (6,2),结果 1→1→2
(3,1):接 3,补 (4,1),堆=[(4,0),(4,1),(6,2)]
(4,0):又是平局,仍由链下标定序。接 4,补 (5,0)
(4,1):链1 耗尽,不补位,堆缩小为 [(5,0),(6,2)]
依次弹 (5,0)(6,2):链0、链2 相继耗尽,堆空。结果 1→1→2→3→4→4→5→6 ✓,与示例输出一致。

⚠ 易错坑 堆元素用二元组 (val, node):最小反例 lists=[[1],[1]]——两个 (1, node) 平局,Python 转比第二位 ListNode,TypeError: '<' not supported;真值 [1,1]
初始化不过滤空链:最小反例 lists=[[],[1]]——对空链取 node.val 入堆时 AttributeError;真值 [1]。入堆前判 if nodelists=[] 则直接返回 None。
分治的右半区间忘 +1(写成 solve(middle, right_index)):最小反例 lists=[[1],[2]]——solve(0,1) 里 middle=0,右半仍是 solve(0,1),无限递归 RecursionError;真值 [1,2]。左闭右闭区间的对半必须是 [left,mid][mid+1,right]
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) 递归栈。高频追问一:"为什么不是 O(N log N)?"——堆里永远只有 ≤k 个元素(每链一个头),单次操作 log k;把全部 N 个节点一次性入堆才会退化成 O(N log N) 时间 + O(N) 空间。追问二:"堆法和分治法怎么选?"——时间同阶;堆法代码短、天然适合流式场景(链表陆续到达时随到随插);分治法纯指针无依赖库、辅助空间更小。面试讲清一种、口述另一种。

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 · 思路(暴力→最优)

需求拆解起手:"get/put 都平均 O(1)"拆开是三种原子能力:(A) 按 key 定位并读写;(B) 每次访问后把该条目标记为"最新"——即把任意位置的元素移到序列一端;(C) 超容时找到并删除"最旧"——即从另一端删除。任何单一结构都要同时扛住这三项,先逐个候选过筛。

① 约束排除法——单一结构逐个杀掉:

· 只用哈希表:(A) ✓;但哈希没有顺序概念,"谁最旧"要 O(n) 全扫——(C) 杀。
· 哈希 + 时间戳:读写时记时间,找最旧仍要 O(n) 扫;换成按时间戳建小根堆,删最旧 O(log n)——不达 O(1),杀。
· 只用链表(按访问序排列):头插=标最新、尾删=淘汰,(B)(C) ✓;但按 key 找节点要 O(n) 顺链——(A) 杀。
· 数组 / 普通队列:中间元素挪到端点要整体搬移 O(n)——(B) 杀。
结论:没有全能选手,就组合——哈希扛 (A)(key 一步瞬移到节点对象),链表扛 (B)(C)(顺序全在连接关系里);桥接点是"哈希的值直接存链表节点对象"。

② 为什么链表必须"双向":能力 (B) 的完整动作是"把中间某节点摘下来插到头部"。摘除要改前驱的出边;哈希只能瞬移到节点自己,单向链表拿到节点却拿不到前驱,找前驱又得从头 O(n)——(B) 退化,整套白搭。给节点加 prev 指针,前驱一步可达,摘除四条边改完 O(1)。

③ 两端哨兵——dummy 讲法的双向版:插头、删尾、摘中间都要碰"邻居",而真头/真尾的邻居可能不存在,头尾还会被频繁换掉——统一讲法:头尾可能被换掉的场景就立哨兵。head/tail 两个哨兵让每个真实节点永远前后有邻居,"空表/单节点/头尾操作"的全部分支消失。

身份表:复合结构里每个成员的分工
代码里的量它是谁守护什么 / 何时动
self.cache(dict)key → 节点对象的传送门不变量:cache 的键集合 = 链上真实节点的键集合,两边任何增删必须同步
self.head 哨兵"最近端"门柱永不动;head.next 恒为最新条目,_add_front 只在它身后动手
self.tail 哨兵"最旧端"门柱永不动;tail.prev 恒为最旧条目,淘汰只看这里
node.key(存在节点里)反向门票淘汰从链表侧发起(拿到 tail.prev 节点),要同步删哈希必须知道它的 key——只能从节点身上读;漏存它就删不掉哈希项
_remove / _add_front唯二的结构原语"刷新最新"=先 _remove 再 _add_front 的固定组合,get 命中与 put 更新共用

④ 常见困惑——get 只是读,为什么也要搬到头部?LRU 的"使用"包括读:get 命中就是一次访问,不刷新的话该键会被冤枉淘汰(见坑①反例)。另外 _add_front 四条赋值的顺序也从"谁会被覆盖"推出:先写新节点自己的两条出边(node.prev=headnode.next=head.next),再改两个邻居的入边;若先改 head.next=node,随后 node.next=head.next 读到的就是 node 自己——自环。

⑤ 用样例走一遍(容量 2):put(1),put(2) 后顺序 2→1;get(1) 摘 1 插头,顺序 1→2;put(3):新 key 入表后 size=3 超容,删 tail.prev=节点 2,并凭 node.key=2 同步 del cache[2]——最终键 {1,3},与段 3 表格逐行吻合。

⚠ 易错坑 get 命中不刷新顺序:最小反例 cap=2,put(1,1),put(2,2),get(1),put(3,3),get(2)——正确答案 get(2)=-1(get(1) 已把 1 刷新,淘汰的是 2);不刷新则淘汰 1,get(2) 错误返回 2。
put 已存在的 key 当新增处理(新建节点插头、旧节点留在链上):最小反例 cap=2,put(1,1),put(2,2),put(1,10),put(3,3),get(1)——链上残留的旧节点 1 在 put(3) 时被当"最旧"淘汰,连带 del cache[1] 把新值误删,get(1) 错误返回 -1,真值 10。已存在的 key 必须原地更新 value + 摘除重插,不触发淘汰。
淘汰只摘链表节点、忘删 cache[least_recent.key]:最小反例 cap=1,put(1,1),put(2,2),get(1)——正确答案 -1;忘删哈希则 get(1) 仍命中返回 1,且 cache 大小只增不减、后续每次 put 都误判超容。
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)。高频追问一:"Python 的 OrderedDict(或 Java 的 LinkedHashMap)能直接做吗?"——能:move_to_end + popitem(last=False) 三五行搞定,它内部正是哈希+双向链表;面试通常仍要求手写以证明理解,主动点破等价性是加分项。追问二:"为什么说'平均'O(1)?"——哈希冲突最坏可退化,工程上用好的哈希函数与扩容策略摊平;链表侧的指针操作是严格 O(1),复合结构的瓶颈只在哈希。

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 · 思路(暴力→最优)

递归起手 + 点名局限:中序的定义本身就是递归——先走左子树、访问自己、再走右子树,三行代码正确且 O(n)。它的局限不在快慢,而在控制权:祖先链藏在系统调用栈里,没法在“访问到第 k 个节点”时暂停收工(↔ LC230 恰好需要这个能力),退化链还会逼近 Python 默认约 1000 层的递归深度限制。所以本题真正要学的是:把调用栈里“欠着没做的事”搬到自己手里。

① 关键转化——栈里存“欠账”:递归执行到某个节点时,调用栈里压着的是“左子树正在处理、自己还没被访问”的一串祖先。把它显式化,规则只剩两条:只要当前子树非空,就一路压左链(这些节点全部进入“欠一次访问”状态);左边到底就弹栈访问,然后把注意力交给它的右子树

② 两个量的身份(current 在不同时刻身份不同):

身份表:stack 与 current 各自代表什么
代码里的量哪个时刻它是谁
stack任意时刻「左子树进行中、自己还欠一次访问」的祖先链;栈顶=最近欠账的祖先
current内层 while 下探时下一棵待展开子树的根,沿 left 一路滑到底
current弹栈访问之后刚访问节点的右子树入口——右子树也是棵树,交还给同一套流程

③ 外层条件从直觉推导:什么时候才算真的做完?current 非空说明还有子树没展开;stack 非空说明还有祖先欠访问。任一存在都不能停,所以条件是 current or stack,不是只看栈。

④ 常见困惑——弹栈访问后为什么不接着弹,而是转向右子树?因为中序是“左→根→右”:弹出的节点左子树已完成、自己刚被访问,此刻欠的是它的右子树;右子树没处理干净之前,栈里更上层的祖先还轮不到。current = current.right 正是这笔账的体现——右子树为空时内层 while 直接跳过,下一轮自然轮到栈顶。

⑤ 用样例验证关键一步:7 节点树中弹出节点 2(此时 result=[1,2])后,current=2.right=3:压 3、弹 3 得 [1,2,3];3 无右子树,下一轮弹出栈顶根 4——“左子树全部完成后才轮到根”被严格保证。

⚠ 易错坑 外层条件漏掉 current、写成 while stack:初始栈是空的,循环一次都不进。最小反例 [1]:输出 [],真值 [1]
弹栈访问后忘写 current = current.right:右子树整棵被跳过。反例 [1,null,2]:压 1、弹 1 后栈空退出,输出 [1],真值 [1,2]
pop(0) 取元素:取的是栈底不是栈顶,结构直接变队列。反例 [2,1](根 2、左孩子 1):压 2、1 后先取出 2,输出 [2,1],真值 [1,2];而且 pop(0) 是 O(n),整体退化 O(n²)。
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))。高频追问一:“递归和迭代空间都是 O(h),为什么面试更看重迭代版?”——迭代把遍历变成可暂停的过程,能在第 k 次访问处直接 return(LC230),也不受语言递归深度限制。追问二:“能不能 O(1) 空间?”——Morris 遍历:借用左子树最右节点的空 right 指针存回程路,时间仍 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 · 思路(暴力→最优)

暴力起手 + 点名慢的根源:最直白的做法是枚举每一条“根到叶”路径、数出节点数取最大——DFS 沿路携带一个计数器就能写出来。它能过,但所有路径共享的公共前缀被反复走:同一个祖先出现在多少条路径里,就被数多少次,最坏 O(n·h)。慢的根源:“某棵子树有多深”这个信息算完就丢,没有被父节点复用

① 关键转化——深度是能自底向上合并的量:整棵树的深度其实只依赖两个数字:左子树多深、右子树多深,取大者加 1(加的 1 就是根自己这一层)。子树内部长什么样、最深的叶在哪,父节点一概不用知道。于是问题从“枚举路径”变成“每棵子树上报一个数”——这是树上递归最小的样板:函数返回什么(以 node 为根的子树高度)、怎么合并1+max)、何时终止(空树返回 0)。想清这三问,后面 ↔ LC543↔ LC124 都是在同一骨架上“顺手多做一件事”。

② 返回值的身份(单角色,钉死口径):depth(node) 返回的是“以 node 为根的子树里,从 node 到最深叶子共有几个节点”。它只对自己脚下的子树负责——不管兄弟、不管祖先;空子树没有任何节点,所以返回 0,叶子自然得到 1+max(0,0)=1,不需要单独特判叶子。

③ 常见困惑——深度是从上往下数,还是从下往上算?两种都对,是两套写法:自顶向下把“当前第几层”作为参数传下去、在叶子处打擂台;自底向上让子树把高度作为返回值报上来。本解用自底向上,因为它的返回值能被父节点直接复用——这正是暴力法缺失的那次复用,也是 543/124 系列的地基。

④ 用样例验证关键一步:[3,9,20,null,null,15,7] 中叶子 9、15、7 各返回 1;节点 20 收到左右 1/1,返回 1+max(1,1)=2;根 3 收到 1/2,返回 1+max(1,2)=3——同一公式在叶、内部节点、根连续套用三层,无一处特判。

⚠ 易错坑 终止条件写成 if not node: return 1:空子树被当成有一层。最小反例单节点 [1]:叶子算出 1+max(1,1)=2,真值 1
节点数口径与边数口径混用:本题按节点数。反例 [3,9,20,null,null,15,7]:按边数会得 2,真值 3;LC543 直径才用边数,两题公式差一个 1。
退化链爆栈:n=10⁴ 的左斜链递归深度即 10⁴,超过 Python 默认约 1000 层限制直接 RecursionError——要么 sys.setrecursionlimit,要么换 BFS/显式栈。
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)),除递归栈外无辅助结构。高频追问一:“空间为什么不是 O(n)?”——递归栈同一时刻只保存根到当前节点的一条链,不是整棵树;只有退化链时这条链才长到 n。追问二:“BFS 和 DFS 求深度怎么选?”——只要深度用 DFS(平衡树时栈 O(log n) 更省);还要读取每层内容时用 BFS,但队列是 O(w),完全二叉树底层宽约 n/2。

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 · 思路(暴力→最优)

直觉起手:“每个节点交换左右孩子”这件事对遍历顺序没有要求——BFS 逐个交换、DFS 先交换再递归、先递归再交换,全都对,都是 O(n)。所以本题不考“找更快的算法”,考的是两件更细的事:交换的是整棵子树的指针(不是两个孩子的值),以及读旧指针和写新指针的先后顺序——顺序错了子树会整棵丢失。

① 关键转化——把“全树翻转”写成递归定义:翻转以 node 为根的树 = 把“翻转后的原右子树”挂到左边 + 把“翻转后的原左子树”挂到右边;空树翻转还是空树。这与 ↔ LC104 同一副骨架(返回什么 / 怎么合并 / 何时终止),只是返回值从数字换成了子树根。它同时是 ↔ LC101 的构造版:101 判断“两棵子树互为镜像”,本题动手把镜像造出来。

② 各个量的身份(同一指针在赋值前后身份不同):

身份表:写回阶段每个量是谁
代码里的量哪个时刻它是谁
inverted_left / inverted_right两次递归返回后已经翻转完成、结构稳定的原左 / 原右子树的根
node.left写回之前原左子树的唯一入口(除局部变量外)
node.left写回之后新左子树=翻转后的原右子树;原左子树此刻只靠 inverted_left 保命

③ “先存后写”的顺序从身份表直接推出:一旦执行 node.left = ...,原左子树的入口就没了——所以两次递归必须发生在任何写回之前,结果先存进局部变量。Python 的同时赋值 node.left, node.right = invert(node.right), invert(node.left) 也安全(右侧先整体求值),但拆成两行就必须靠局部变量。

④ 常见困惑——先交换再递归(前序版)行不行?行:先 node.left, node.right = node.right, node.left,再对交换后的两个孩子递归。每个节点仍恰好被交换一次,前序/后序只是“交换自己”这一步的时机不同。要警惕的不是顺序,而是“读到已被覆盖的指针”这一种错(见坑①)。

⑤ 用样例验证关键一步:[4,2,7,1,3,6,9] 的根 4 处:inverted_left=翻转后的 2→(3,1)inverted_right=翻转后的 7→(9,6);写回后 4→(7,2)——左右两棵稳定结果整体互换,节点值从未移动,动的全是指针。

⚠ 易错坑 两行连续赋值不存局部变量:node.left = invert(node.right) 之后再写 node.right = invert(node.left),第二行读到的已是新左(原右子树),等于把原右子树翻了两次、原左子树整棵丢失。最小反例 [1,2,3]:输出 [1,3,3](同一节点 3 被挂两边、节点 2 消失),真值 [1,3,2]
递归函数忘了 return node:父调用拿到 None 往上挂。反例 [1,2,3]:根的两个孩子都被挂成空,输出 [1],真值 [1,3,2]
只交换孩子的 val 不交换指针:孩子层看似对了,孙子层原地不动。反例 [4,2,7,1,3,6,9]:交换值只得 [4,7,2,1,3,6,9](孙子没跟着搬家),真值 [4,7,2,9,6,3,1]
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),递归栈深度为树高,最坏退化链 O(n),无其他辅助结构。高频追问一:“迭代版怎么写、空间多少?”——用队列(或栈)逐个取节点交换其左右孩子再把孩子入队,BFS 空间 O(w)、DFS 栈 O(h),按树形选:完全树 w≈n/2 时递归更省,退化链时队列更省。追问二:“对 BST 执行翻转会怎样?”——中序序列整体反转,变成“镜像 BST”(左大右小),原有的查找代码全部失效,说明翻转是结构级变换而非视觉游戏。

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 · 思路(暴力→最优)

直觉起手 + 点名错在哪:第一反应通常是“判断左右子树是不是长得一样”——写一个 isSame(a,b) 同侧比较(a.leftb.left)。这判的是“相同”,不是“对称”:镜子里的左手对应的是现实的右手。最小反例 [1,2,2,3,null,null,3](左 2 的孩子 3 在左边、右 2 的孩子 3 在右边):它关于中轴完美镜像,真值 true,同侧比较却在 2L.left=32R.left=∅ 处报 false。错的根源:“对称”是二元关系,配对方向必须交叉

② 关键转化——一元问题变二元问题:“这棵树对称吗”没法直接递归(子树自己对不对称跟整树无关),改问“这两棵子树互为镜像吗”就能递归了:mirror(left_node, right_node)。它与 ↔ LC226 是一对:mirror 为真 ⟺ 一棵等于另一棵的翻转结果——但不要真去翻转再比较(会修改输入、翻两次才能还原、不幂等),交叉配对零修改就能验证,能不改输入就不改

③ 两个参数与两组子调用的身份:left_node 是左半边当前位置,right_node 是右半边与它镜像的位置;一次调用只负责验证这一对。往下拆成两组新的镜像对——外侧对 left_node.left ↔ right_node.right(离中轴同样远的外圈),内侧对 left_node.right ↔ right_node.left(贴着中轴的内圈)。两组必须同时成立;写成同侧就退化回“相同树”判断。

④ 判空顺序从直觉推导:镜像位置必须“同存在、同值”。所以先问“都空吗”(都空=这一对走到头,True),再问“只有一个空吗”(结构不镜像,False),最后比值、递归。这个顺序保证后面访问 .val 时两边都非空,不会崩溃。入口处根自己在中轴上不需要配对,直接从 mirror(root.left, root.right) 开始;空树单独返回 True。

⑤ 用样例验证关键一步:[1,2,2,3,4,4,3]mirror(2L,2R) 值相等,拆出外侧对 (3L,3R) 与内侧对 (4L,4R),两对各自“值等 + 四个孩子全空”返回 True。反例 [1,2,2,null,3,null,3]:外侧对是 (2L.left=∅, 2R.right=3),一空一非空立即 False——不用看内侧,and 短路。

⚠ 易错坑 同侧比较(把对称当相同):反例 [1,2,2,3,null,null,3],错误输出 false,真值 true——这棵树是镜像的,但两个 3 不在“同侧”。
单空情况写成 if not a or not b: return True(把“有一边空”也放行):反例 [1,2,2,null,null,3,null](左 2 是叶、右 2 有左孩子 3),内侧对 (∅,3) 被误判通过,错误输出 true,真值 false。
想用“中序序列是回文”偷懒:反例 [1,2,2,2,null,2,null](两个 2 都把孩子挂在左边),中序 [2,2,1,2,2] 是回文,错误输出 true,真值 false——序列丢掉了结构信息。
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),每个节点恰好参与一次配对(配对数约 n/2,每次 O(1));空间 O(h),递归栈为树高,最坏退化 O(n)。高频追问一:“迭代版怎么写?”——用队列成对入队:初始放 (root.left, root.right),每轮弹一对,验证后把外侧对 (a.left, b.right) 和内侧对 (a.right, b.left) 入队;空节点也要成对入队才能保住结构信息,空间 O(w)。追问二:“为什么不翻转一棵子树再比较相同?”——结果等价,但要么修改输入(副作用)、要么复制整棵子树(O(n) 额外空间),交叉配对是零修改零复制的验证。

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 · 思路(暴力→最优)

暴力起手 + 点名慢的根源:先想清答案长什么样:任何一条路径都有唯一的最高点(拐点),路径在拐点处“先上后下”。于是自然的暴力是:让每个节点当一次拐点,现场调 ↔ LC104height 算它的左右高度,候选=左高+右高。正确,但每个节点的高度在它每一个祖先当拐点时都被重算一次,退化链上 O(n²)。慢的根源:高度被重复计算——而后序遍历本来就会把每个节点的高度各算一次,只是暴力没有接住。

① 关键转化——在算高度的路上顺手结算拐点:不改变“枚举拐点”的思路,只是把时机挪进后序:height(node) 算左右高度时,这两个数正是 node 当拐点所需的全部信息——当场用 left_height + right_height 打一次擂台,再照常返回高度。一次后序,n 个拐点全部结算,O(n)。这就是 LC104 骨架上“顺手多做一件事”。

② 两条线的身份表(本题核心,混线即 WA):为什么需要“返回值”和“全局擂台”两条线?因为最长路径可能不过根,藏在某棵子树里——递归返回值只能给父节点“一条腿”(父的路径要继续向上延伸,只能借当前节点的一支),而完整路径是“两条腿”的,只能在拐点当场记入擂台,没法上报。

身份表:函数返回值线 vs 全局擂台线
代码里的量它是谁提供什么 / 不管什么
height(node) 返回值单边贡献:从 node 向下的最长单链高度(节点数)供父节点接一条腿用;不含“左右同时接”的情况
best_diameter全局擂台:所有拐点候选 left_height+right_height 的最大值当场结算“过本节点的完整路径”;向上传递

③ 公式从口径推导:直径按边数。高度按节点数时,从 node 沿左支到最深叶恰好走 left_height 条边(每层一条),右支同理,所以过 node 的路径边数=left_height + right_height不再 +1——常见误代入是把“节点数口径要 +1”的直觉带过来,那算的是路径节点数减一之外多出的根,重复了。向父返回则回到高度口径:1 + max(left_height, right_height),这个 1 是 node 自己。

④ 常见困惑——返回单支会不会漏掉大直径?不会。若最优路径拐点在 x,后序一定会走到 x,且那一刻 x 的左右高度都已算好,擂台当场记下完整值;父节点拿到的单支只是“路径还想往上长”时的合法延伸,两件事各走各的线。

⑤ 用样例验证关键一步:[1,2,3,4,5]:叶 4、5、3 各返回高度 1;节点 2 候选 1+1=2(路径 4—2—5),返回高度 2;根 1 候选 2+1=3(路径 4—2—1—3),擂台定格 3——注意节点 2 处“候选用双支 2、上报只带单支 2”数值凑巧相等但身份不同。

⚠ 易错坑 返回值写成 left_height + right_height(把双腿上报):高度链全线崩塌。最小反例 [1,2]:叶 2 返回 0+0=0,根收到 0,擂台最高 0,错误输出 0,真值 1。
候选写成 left+right+1(边数口径混入节点数 +1):最小反例单节点 [1]:候选 0+0+1=1,错误输出 1,真值 0(没有边)。
只算根的 left+right 不打擂台:反例“根 1 只有左孩子 2,2 的两个孩子 3、4 各自再有一个孩子 5、6”:根处候选 3+0=3,但真最长路径 5→3→2→4→6 有 4 条边,错误输出 3,真值 4——直径不过根。
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),只有递归栈、无辅助结构,最坏退化链 O(n)。高频追问一:“能不能不用 nonlocal/全局变量?”——能:让递归返回二元组 (height, best),父节点合并 best = max(左best, 右best, 左h+右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,… 各做一次从根出发的 DFS,只收集第 d 层的节点。正确,但收集第 d 层必须重新路过第 0..d-1 层的所有祖先——上层节点被重复经过的次数等于它下面还有多少层,退化链最坏 O(n²)。慢的根源:每一轮都从根重新出发,“上一层的节点手里就攥着下一层的入口”这个信息被扔掉了

① 关键转化——让处理顺序本身就是层序:不再按层反复扫描,而是维护一个先进先出的队列:处理一个节点时把它的孩子放进队尾。父层节点总比子层先入队,所以出队顺序天然“一层完了才轮到下一层”——这就是 BFS,和图论 ↔ LC994 的逐层扩散同源。剩下唯一的问题:队列里前后两层是连着的,层与层的边界在哪里

② 冻结层宽 + 各量身份(queue 在不同时刻身份不同):答案是一个不变量:每轮 while 开始时,队列里恰好是完整的一层。把这一刻的 len(queue) 冻结成 level_size,本轮就只弹这么多个。

身份表:queue 与 level_size 在每轮中的身份
代码里的量哪个时刻它是谁
queue每轮 while 开始时恰好完整的当前层(不变量,算法正确性的根基)
queuefor 循环进行中「当前层剩余 + 下一层已入队」的混合体,长度在变
level_size本轮全程开始时刻的层宽快照,冻结不变——本轮只允许消费这么多
level_values本轮全程当前层从左到右的值,轮末整体挂进 result

③ 为什么必须冻结,从直觉推导:弹节点的同时孩子在入队,队列长度边弹边涨;若循环条件用“当时的队列长度”,下一层的孩子就会被当成本层继续弹,层的边界消失。冻结快照后,本轮多入队的孩子刚好全部留给下一轮——下一轮开始时不变量自动恢复。

④ 常见困惑——不冻结行不行?行,还有两种等价写法:双列表交替(current_level 弹空后整体换成 next_level),或给每个节点捆一个深度标记按深度归组。三种写法都是 O(n);冻结 level_size 状态最少,是最常写的一种。

⑤ 用样例验证关键一步:[3,9,20,null,null,15,7] 第 1 层:轮始 queue=[9,20],冻结 level_size=2;弹 9(无孩子)后队列 [20],弹 20 时 15、7 入队、队列变 [15,7]——长度回到 2,但 for 只跑冻结的 2 次,15、7 一个都不会被本轮碰到。

⚠ 易错坑 不冻结层宽(内层写 while queue,或在循环里现取 len(queue)):孩子混进当前层。最小反例 [1,2](2 是 1 的左孩子):弹 1 时 2 入队又被同轮弹出,输出 [[1,2]],真值 [[1],[2]]
空树不特判:None 被直接入队,弹出后访问 node.val 抛 AttributeError。反例 root=[]:程序崩溃,真值 []
list.pop(0) 当出队:结果正确但每次 O(n),整体 O(n²)——本题 n≤2000 能混过,换到 10⁵ 级的图 BFS 必超时;养成 deque.popleft() 的手感。
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(w),w 为最大层宽——完全二叉树底层约 n/2,所以最坏 O(n)。高频追问一:“DFS 能做层序吗?”——能:带 depth 参数先序递归,往 result[depth] 追加(新深度先建空列表),先左后右保证层内顺序;时间同 O(n),空间换成递归栈 O(h)。追问二:“队列空间什么时候比递归栈亏?”——完全/茂密树 w≫h(约 n/2 对 log n)时 BFS 亏;退化链 w=1、h=n 时 BFS 反而省。按树形回答,不背结论。

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。但输入是升序的:每个新元素都比树里所有值大,永远插到最右边——得到一条右斜单链,高度 n,插入总代价 O(n²),且“高度平衡”这条硬约束直接不满足。这个约束是出题人画的箭头:树形不能被输入顺序牵着走,必须由我们主动决定“谁当根”

① 关键转化——升序数组就是中序序列:BST 的中序遍历是升序(↔ LC94),反过来看:中序序列已经给定,建 BST 剩下的自由度只有“每一步选谁当根”。选定根后,根左边的元素必须全进左子树、右边全进右子树——这和 ↔ LC105 是同一件事:105 由前序钦定根,本题没有前序,根由我们为平衡而选。要平衡 ⟸ 左右子树规模尽量相等 ⟸ 每步取中点,两半人数最多差 1,逐层均分下来高度自然是 O(log n)。

② 各个量的身份(单角色,融入正文):build(left, right)left/right 是当前子树在 nums 中的闭区间边界;middle=(left+right)//2 是本步选出的根的下标(偶数长度取左中点);返回值是这段区间建成的平衡子树的根。每个下标恰属于一次“当根”的调用,所以每个元素只建一个节点。

③ 终止与中点从直觉推导:闭区间为空 ⟺ left > right(不是 >=——left==right 还剩一个元素,要建叶子)。两个子区间必须都排除 middle:根已经用掉了这个元素,且不排除的那一侧区间不收缩、递归不终止。偶数长度取左中点还是右中点都合法——答案本就不唯一,判题只验证“是 BST + 平衡 + 中序还原输入”,同一份代码内保持一致即可。

④ 常见困惑——答案不唯一,怎么知道自己写对了?自测两条硬性质:对建出的树做中序遍历应精确还原 nums(BST 性质);对每个节点左右子树高度差 ≤ 1(平衡性质)。两条都过,形状随便。

⑤ 用样例验证关键一步:[-10,-3,0,5,9,12]:根区间 [0,5]middle=2、根 0;左区间 [0,1]middle=0、根 −10,其右侧单元素区间 [1,1] 建叶 −3;右区间 [3,5]middle=4、根 9,挂上叶 5、12。逐层近乎平分,高度 3 = ⌈log₂7⌉。

⚠ 易错坑 子区间没排除 middle(如 build(left, middle)):区间不收缩。最小反例 [1,2]build(0,1)middle=0 后左递归仍是 build(0,0)→build(0,0)→…,无限递归 RecursionError,真值应为两节点树。
终止写成 left >= right:单元素区间被当空区间。最小反例 [1,2]middle=0 建根 1 后,右区间 [1,1] 直接返回空,叶 2 丢失,输出单节点 [1],真值含节点 2。
用切片递归(build(nums[:middle]) 等):逻辑能对,但每层复制整段数组,额外 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) 不计入辅助空间)。高频追问一:“为什么这里敢写 O(log n),别的树题都写 O(h)?”——树题空间要看“递归栈 + 辅助结构”的最大项:一般树 h 不可控才写 O(h);本题树形是自己造的、保证平衡,h 就是 log n,没有其他辅助结构。追问二:“输入换成有序链表(LC109)怎么办?”——链表无法 O(1) 取中点:要么每层快慢指针找中点(O(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 的定义约束的是整棵子树:右子树里的每一个后代都要大于我,不只是右孩子。最小反例 [5,1,7,null,null,4,8]:每对父子都合格(1<5、7>5、4<7、8>7),但 4 藏在 5 的右子树深处却小于 5——父子检查输出 true,真值 false。错的根源:祖先的约束会穿透到任意深的后代,逐层检查把它弄丢了

① 关键转化 A——把祖先约束打包成区间下传:与其在每个节点回头看所有祖先,不如让祖先的约束随递归往下带:每个节点收到一个合法开区间 (lower_bound, upper_bound)——它是“所有祖先约束的交集”。走进左子树,孩子们都必须小于我,所以上界收紧为我的值;走进右子树,下界收紧为我的值。每个节点只需 O(1) 查一次区间,穿透问题消失。

② 关键转化 B——换判据,用序列性质:BST ⟺ 中序序列严格递增。于是可以完全复用 ↔ LC94 的显式栈中序,把“输出值”换成“和前一个访问值比大小”,一旦不升立即 False。两条路都 O(n)、都能短路,取舍见段5。

③ 关键量的身份表(两解法各自的“记忆”是什么):

身份表:上下界法与中序法各自靠什么记住祖先
代码里的量它是谁提供什么
lower_bound祖先链上最近一次“向右拐”的那个祖先的值(没有则 −∞)当前节点必须严格大于它
upper_bound祖先链上最近一次“向左拐”的那个祖先的值(没有则 +∞)当前节点必须严格小于它;二者合起来=全部祖先约束的交集
previous_value(中序法)中序序列里刚访问过的前一个值只需和它比——序列逐对递增即全局递增;必须贯穿整次遍历,不随子树重置

④ 常见困惑——只和一个 previous_value 比,凭什么能代表所有祖先?因为中序把“整棵树的约束”摊平成了“序列相邻对的约束”:序列每相邻两个都严格递增 ⟺ 整个序列递增 ⟺ 是 BST。祖先约束没有消失,而是被顺序编码进了访问次序里。

⑤ 用样例验证关键一步:反例树 [5,1,7,null,null,4,8]——上下界法:节点 4 从祖先继承区间 (5,7)(下界 5 来自“根 5 处向右拐”,上界 7 来自“节点 7 处向左拐”),4 ≤ 5 违反下界,立即 False,节点 8 无需访问。中序法:访问序 1、5、4——到 4 时 4 ≤ previous_value=5,同一位置报错。

⚠ 易错坑 只检查父子不传祖先约束:反例 [5,1,7,null,null,4,8],错误输出 true,真值 false(4 < 祖先 5)。
严格性写松(区间用 ≤,或中序只在“下降”时报错):BST 不允许相等。最小反例 [2,2](左孩子等于根):中序 [2,2]< 判下降不会触发,错误输出 true,真值 false——判据必须是 current.val <= previous_value 即 False。
用整数极值当哨兵初值:节点值域达 ±2³¹。最小反例单节点 [2147483647]:若初始上界取 2³¹-1,判 val < upper_bound 不成立,错误输出 false,真值 true。Python 用 float("±inf") 天然避开。
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)——上下界法是递归栈、中序法是显式栈,都无其他常驻结构,最坏退化链 O(n)。高频追问一:“两种解法面试怎么选?”——上下界法把约束讲得最直白,适合白板推导;中序法与 LC94/LC230 共用一套模板、迁移成本低,还能顺便输出序列。追问二:“能不能 O(1) 空间?”——Morris 中序可以把栈也省掉(借叶子空指针存回程路),时间仍 O(n),但遍历中会临时改树,只在明确允许时用。

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 · 思路(暴力→最优)

暴力起手 + 点名浪费在哪:BST 中序=升序(↔ LC94),所以完整中序存成数组、取下标 k-1 一定对,O(n) 时间 + O(n) 空间。它不算慢,但两处浪费很扎眼:目标之后的所有节点都被白白访问;整个序列都被白白保存。浪费的根源:没有利用“数到第 k 个就可以收工”这个事实——而递归中序恰恰很难中途收工,这正是 LC94 铺垫的显式栈迭代版兑现价值的地方。

① 关键转化——把中序当成一条可暂停的升序流:显式栈中序每弹出一个节点,就是升序流吐出下一个最小值。于是第 k 小=“流的第 k 个元素”:弹一个数一个,数到 k 立即 return,栈里剩下的更大节点一个不碰。

② 关键量的身份表(k 同一个量、不同时刻身份不同):

身份表:解法①的 k 与解法②的排名量
代码里的量哪个时刻它是谁
k(解法①)每次弹栈之前「还差几个才到目标」;弹出即消费一个,先减一再判零
k(解法②)进入右子树之后身份切换:变成「目标在右子树内部的排名」(左子树和根共 left_size+1 个已被扣除)
left_size(解法②)站在任一节点时左子树节点数=比当前节点小的本子树成员数
current_rankleft_size+1站在任一节点时当前节点在本子树内的名次——那个 +1 是它自己

③ 解法②的三分支从直觉推导:预处理每个节点的子树规模后,站在任一节点:比我小的本子树成员恰有 left_size 个,所以我的名次是 left_size+1。于是 k == current_rank → 就是我;k < current_rank → 目标更小,进左子树(k 不变,左子树内名次没变);k > current_rank → 进右子树,并执行 k -= current_rank 完成身份切换——右子树看不到被扣掉的那 left_size+1 个小值。

④ 常见困惑——为什么不直接用递归中序数数?可以,但递归里“数到第 k 个就停”要靠返回值层层携带“已找到”标志或抛异常穿透,写起来别扭;显式栈把控制权握在循环手里,return 一句话就停。这正是 94 里“迭代版可暂停”优势的第一次兑现。

⑤ 用样例验证关键一步:7 节点 BST(图解那棵,中序 1..7)求 k=4。解法①:弹 1、2、3 后 k=1,转入 3 的右子 4,弹 4 时 k 减到 0,返回 4——节点 5、6、7 全程未访问。解法②:根 5 处 left_size=4、名次 5,k=4 < 5 向左;节点 3 处名次 3,k=4 > 3 向右且 k=4-3=1;节点 4 处 left_size=0、名次 1 命中,返回 4。两条路答案一致。

⚠ 易错坑 先判零后减一(或判 k==1 的位置错):计数错位一格。最小反例 [1], k=1:弹 1 时判 k==0 不成立、再减一,循环结束函数落空返回 None,真值 1。口诀照身份表:弹出=消费,先减后判
解法②比较写成 k == left_size(忘了 +1 的自己):根被数漏。反例 [2,1,3], k=2:根 2 的 left_size=1,误判不中便扣除后进右子树,返回 3,真值 2(根自己就是第 2 小)。
弹栈后忘写 current = current.right:刚弹出的节点被反复压弹。反例 [2,1,3], k=3:弹 1 后 current 仍指节点 1,1 被再弹两次凑满 k,返回 1,真值 3。
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),之后每弹出一个节点至多再压入其右孩子的一段左链,k 次弹出的总压栈量摊还 O(k);空间 O(h) 栈。解法② 预处理 O(n) 时间与 O(n) 哈希空间(这里空间由哈希主导,不是 O(h)),之后每次查询只走一条根到目标的路径 O(h)。高频追问一:“k 很小/很大时哪个划算?”——单次查询用①(k 小时接近 O(h));同一棵树反复查询用②,预处理成本被摊薄。追问二:“树频繁插入删除还要查第 k 小怎么办?”——把 size 从外部哈希改成节点字段,插删沿途 ±1 维护,查询仍 O(h)——这就是顺序统计树的思想。

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 指针一路下行收集。错:右视图的定义是每层最靠右的节点,某层右侧缺席时,左子树里的节点会“补位”被看到。最小反例 [1,2,3,4](4 是 2 的左孩子):右链只走出 [1,3],真值 [1,3,4]——第 2 层整层只有 4,它虽然出身左子树,照样可见。错的根源:“看得见”是按层定义的,不是按指针链定义的

① 关键转化——右视图是层序问题的取样变形:“每层最右” = 把 ↔ LC102 的每层列表只留最后一个元素。于是解法①水到渠成:冻结层宽的分层 BFS,每层第 level_size-1 个弹出的节点(层内先左后右入队,末位即最右)进结果。

② 换遍历方向再省一点——DFS 首达判据:还可以不按层扫,按“根→右→左”的先序 DFS:这个顺序保证同一深度中最右的节点总是最先被到达。“第一次到达某个新深度”怎么判?靠一个巧妙的判据:depth == len(result)

身份表:DFS 解法里两个量与判据的含义
代码里的量它是谁提供什么
depth当前节点所在层号(根为 0)随递归 +1 下传,只描述位置
len(result)已经“看到”的层数第 0..len-1 层都已有代表入选
depth == len(result)“我是本层第一个到达者”的判据右优先序下,首达者=本层最右 → 入选;之后同层节点 depth < len(result),自动落选

③ 常见困惑——为什么“首达”就是“最右”?根→右→左的顺序意味着:到达深度 d 的所有路线里,“每一步都尽量向右”的那条最先被走完。first 到达 d 层的节点,正是 d 层里右边没有任何兄弟先于它的那个——即最右节点。若把递归顺序写反(先左后右),首达者变成最左节点,得到的是左视图。

④ 用样例验证关键一步:图解 6 节点树(根 1,左 2 右 3,2 的右孩子 5、3 的左孩子 4,5 的左孩子 6):DFS 依次首达 1(d0)、3(d1)、4(d2)——回头走左侧时节点 2 的 depth=1 < len(result)=3 落选,节点 5 同理;最后 6 在 d3 首达入选。result=[1,3,4,6],与 BFS 每层末位完全一致。

⚠ 易错坑 DFS 先左后右:首达者变最左。最小反例 [1,2,3]:输出 [1,2](左视图),真值 [1,3]
改用“覆盖式” result[depth]=node.val 却仍然右优先:同层后到的左节点会把先到的右节点覆盖掉。反例 [1,2,3]:d1 先写 3、后被 2 覆盖,输出 [1,2],真值 [1,3]——覆盖式必须配“先左后右”(让最右最后写入),首达式必须配“先右后左”,两套配对不能串。
沿 right 链收集或只收集“有右孩子的节点”:反例 [1,2,3,4](4 为 2 的左孩子):右链输出 [1,3],真值 [1,3,4]——层里只剩左子树后代时它照样可见。
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 为树的最大层宽,完全二叉树底层约 n/2,最坏 O(n);DFS 空间 O(h),来自递归栈,最坏退化链 O(n)。高频追问一:“BFS 和 DFS 空间到底谁优?”——由树形决定:茂密树 w≫h(n/2 对 log n),DFS 省;退化链 w=1、h=n,BFS 省。答“看 w 与 h 谁大”,别背死结论。追问二:“左视图/每层最大值怎么改?”——同一套骨架换取样规则:左视图=BFS 取每层首个(或 DFS 先左后右首达);每层最大值=层内打擂台。改动都只在“取样”一行。

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 · 思路(暴力→最优)

暴力起手 + 约束排除法:最省事的写法:先前序遍历把节点按顺序存进列表,再扫一遍列表逐个 右接下一个、left 清空——正确,O(n) 额外空间。题目的“原地展开”与进阶 O(1) 约束把这条路排除;递归/显式栈版本也还挂着 O(h) 的栈。约束是箭头:不许把顺序存下来,就只能边走边把“前序的下一个”直接缝进指针里

① 关键转化——在前序序列里找“右子树的前驱”:前序是“根→左子树→右子树”。站在任意节点 current:它的原右子树在前序里排在整个左子树之后,而左子树这段前序的最后一个节点,就是左子树的最右节点(一路先根再右走到底)。所以只要把原右子树接到这个前驱后面、再把整棵左子树搬到右侧,current 附近的前序顺序就永久正确了——这正是 Morris 遍历“借前驱空指针”的思想在前序上的应用。

② 三个量的身份表(current.right 改前改后身份不同):

身份表:原地重连时每个指针是谁
代码里的量哪个时刻它是谁
current每轮 while 开始已定稿右链的末端推进指针——它左侧(链上更早)的部分已是最终形态
predecessor内层找完时左子树的最右节点=原右子树在前序中的直接前驱,它的 right 此刻为空、正好补位
current.right改写之前原右子树的唯一入口——一旦覆盖且没先接走,整棵原右子树失联
current.right改写之后前序中 current 的下一个节点(原左子树的根)

③ 三步顺序从“谁会失联”推导:每轮固定三步——1 保右:predecessor.right = current.right(原右子树换个地方挂住,从此不会丢);2 搬左:current.right = current.left3 清左:current.left = None。顺序只由一件事决定:current.right 被覆盖前必须先有人接住它,所以“保右”必须第一。搬完后 current = current.right 前进——走的是刚缝好的新链。

④ 常见困惑——不用栈也不递归,凭什么能遍历完整棵树?因为每处理完一个节点,它的左子树已整体并入右链、左指针清空;从此这棵树在 current 之后的部分越来越接近一条纯右链,沿 right 前进就一个不漏。没有左子树的节点直接跳过三步,只前进。

⑤ 用样例验证关键一步:[1,2,5,3,4,null,6] 第一轮:current=1,沿左子树 2 一路向右找到 predecessor=44.right=5(原右链 5→6 保住)、1.right=21.left=∅——链首 1→2 定稿。第二轮 current=2predecessor=3,同样三步后整链 1→2→3→4→5→6,恰是原树前序。

⚠ 易错坑 三步顺序写反(先搬左再保右):current.right 先被覆盖,predecessor.right = current.right 接到的是刚搬来的左子树自己。最小反例 [1,2,3]1.right=2 后再执行 2.right=1.right2.right=2 自环——死循环,且节点 3 永久失联;真值 1→2→3
忘了 current.left = None:链是缝对了,但每个节点仍拖着旧左指针,同一子树被两条边引用,序列化/判题按“left 必须全空”直接判非法。反例 [1,2,3]1.left 残留指向 2。
前驱找错方向(在左子树里一路向找):接的位置不是前序末尾,后一轮会把它再次覆盖。反例根 1、左子树 2→(3,4)、右子树 5:第一轮错把 5 接到 3 后面,第二轮又执行 3.right=4 把 5 顶掉,输出 1→2→3→4(节点 5 失联),真值 1→2→3→4→5——前驱必须是左子树最右节点,它的 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)。高频追问一:“外层 while 里还套着找前驱的 while,为什么不是 O(n²)?”——摊还分析:找前驱走过的每条 right 边,走完这一轮就被并进定稿右链,之后 current 沿它只前进一次、不会再被任何后续“找前驱”扫到,每条边全程被访问 O(1) 次,总量 O(n)——不能按“每轮最坏 O(h) × n 轮”相乘。空间 O(1):前驱法无递归无栈,是三种写法(递归 O(h) / 显式栈 O(n) / 前驱法 O(1))中唯一满足进阶要求的。追问二:“递归版怎么写?”——按“右→左→根”的逆前序后序处理,维护 prev 头插:node.right=prev; node.left=None; prev=node,空间 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 · 思路(暴力→最优)

直觉起手 + 点名慢的根源:两份遍历各泄露一半信息——前序的第一个值一定是根(“根→左→右”嘛);拿这个值去中序里一找,根左边的就是左子树全体、右边的就是右子树全体(“左→根→右”)。对两半重复同样的过程,树就建出来了。直接翻译成代码:每层 inorder.index(root_value) 找根 + 切片切出四个小数组。正确,但慢的根源有两个:index 每次从头线性扫(斜树上 O(n²)),切片每层整段复制(又一份 O(n²) 的隐藏拷贝)。正解不改建树逻辑,只把“查找”和“切片”这两笔冤枉账省掉。

① 先用官方样例走一遍规则:

1 找根:前序 [3,9,20,15,7] 的第一个值 3 是整棵树的根。

2 拆中序:中序 [9,3,15,20,7] 被 3 分成左段 [9]、右段 [15,20,7]——这就是左右子树的成员名单。

3 用左子树人数拆前序:左段有 1 人,所以前序在根后取 1 个值 [9] 给左子树,剩下 [20,15,7] 全归右子树——中序数出的人数,是两份数组之间唯一的桥

4 递归两半:左半单值 9 直接成叶挂到 3.left;右半又以 20 为根、被拆成 [15][7],成为 20 的左右孩子。整树还原完成。

② 关键转化——从“切数组”到“传四个下标”:切片复制数据,但每次递归真正需要的只是“我负责原数组的哪一段”。所以保留原数组不动,用 pre_left/pre_rightin_left/in_right 两对下标描述当前子树;再预建哈希 inorder_index(值→中序下标)把找根降到 O(1)。这与 ↔ LC108 的“传闭区间不切片”同一招——108 没有前序、为平衡自选中点当根,本题的根由前序钦定。

③ 六个量的身份表(区间题的命根子):

身份表:每个下标量在当前递归调用里是谁
代码里的量它是谁提供什么
pre_left / pre_right当前子树占据的前序闭区间preorder[pre_left] 恒为当前根;区间空⟺子树空
in_left / in_right当前子树占据的中序闭区间供“数左边人数”定基准,本身不出根
in_root_index根在中序数组里的全局下标切分点;注意它是全局坐标,不是区间内偏移
left_subtree_size左子树人数=in_root_index - in_left两份数组之间唯一的桥:决定前序在哪里分段

④ 四条区间公式从“人数”推导:左子树在前序里紧跟根、连续占 left_subtree_size 个 → [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](跳过根)。左边没人时 left_subtree_size=0,左区间自动为空,公式无需特判。

⑤ 常见困惑——in_root_index 明明是全局下标,为什么能和区间混着用?因为四个边界本身也全是全局坐标——整套公式活在同一个全局坐标系里,从不换算“区间内第几个”。唯一要做减法的地方就是数人数:in_root_index - in_left,减掉的正是“区间起点前有多少人不归我管”。

⑥ 用样例验证关键一步:7 节点样例 pre=[8,4,6,5,12,10,11]in=[4,5,6,8,10,11,12] 的第二层调用 build(pre=[1,3], in=[0,2]):根 preorder[1]=4in_root_index=0left_subtree_size=0-0=0 → 左区间 [2,1] 为空(4 无左孩子 ✓),右区间前序 [2,3]、中序 [1,2],即 [6,5][5,6]——6 为根、5 是其左孩子 ✓。空子树与深层递归都被公式正确覆盖。

⚠ 易错坑 人数直接用全局下标(left_subtree_size = in_root_index,忘减 in_left):第一层侥幸不错(in_left=0),进右子树立刻爆。最小反例 pre=[2,1,3], in=[1,2,3]:右子树调用中根 3 的 in_root_index=2,误得人数 2,前序左区间越界到 [3,4]preorder[3] 直接 IndexError;真值为树 2→(1,3)
右子树前序起点忘 +1(写成 pre_left+left_subtree_size):根被重复消费。最小反例 pre=[1,2], in=[2,1]:左子树用掉 [1,1] 后,右区间误为 [1,1] 非空,节点 2 被再建一次挂到右边,输出三节点树,真值是左斜两节点树。
输入含重复值时照搬哈希:inorder_index 后写覆盖先写,定位错乱——本题靠“节点值互不相同”的题设撑住;若允许重复,必须改传“前序游标+值域边界”等其他判据,不能直接套用。
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):inorder_index 哈希表整场常驻 n 条记录,压过递归栈的 O(h)——树题空间看“递归栈+辅助结构”的最大项,这里由哈希主导,不能顺手写成 O(h)(对比 LC108 只有栈、且树自建保证平衡,才写 O(log n))。高频追问一:“不建哈希、每层 index() 现找会怎样?”——单次 O(n),斜树递归 n 层退化 O(n²);哈希是拿 O(n) 空间买回 O(n) 总时间。追问二:“前序+后序能唯一还原吗?”——不能:只有一个孩子时,无法区分它挂左还是挂右(如 pre=[1,2]post=[2,1] 对应两棵树);中序的作用正是用“根的位置”切开左右。

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:外层枚举每个节点当起点,内层从起点向下累加、每凑够 targetSum 计一条。正确,最坏 O(n²)。慢的根源:同一段祖先链被反复累加——节点 x 到它某个后代的和,在 x 的每个祖先当起点时都被整段重算,“根到各节点的累计和”这份信息没有被复用

① 关键转化——固定终点看起点,任意段和=两个前缀之差:这就是 ↔ LC560 和为K的子数组 从数组搬到树上。设根到当前节点的累计和为 current_sum,一条以当前节点为终点、以某祖先 a 的孩子为起点的路径,其和=current_sum − (根到 a 的前缀和)。要它等于 targetSum,即要求祖先链上存在前缀和=current_sum − targetSum——出现几次就有几条。于是每个节点只需一次哈希查询,把“枚举起点”变成“查前缀差”。

② 关键量的身份表(哈希表只代表一条链,不是全树):

身份表:前缀和三件套在树上的具体身份
代码里的量它是谁提供什么 / 不管什么
current_sum根到当前节点(含自己)的累计和随递归参数下传,回溯时天然还原,不需要手动撤销
prefix_count当前祖先链上各前缀和的出现次数只认从根到当前节点这条链;兄弟分支的前缀不算数——这是必须回溯的原因
prefix_count[0]=1空前缀(一步都没走时的和)代表“起点就是根”的那类路径;漏了它,从根开始凑够目标的路径全部查不到

③ “查→加→递归→撤”的顺序从身份推导:查在加前——查询的语义是“严格更早的祖先前缀里有几个 current_sum−targetSum”,先把自己加进去,targetSum=0 时会查到自己、凭空多出一条零长路径。递归夹在加与撤之间——左右孩子共享“根到我”这条链,理应看得到我的前缀。撤在离开时——回到父节点后,我的前缀对另一分支就是幻影,不撤销就会把“左分支下来、右分支上去”的折线当成合法向下路径。

④ 常见困惑——为什么撤销 prefix_count 却不用撤销 current_sum因为二者的传递方式不同:current_sum参数,每层一份拷贝,函数返回即自动作废;prefix_count共享的一个哈希表,所有调用写同一份,谁写谁负责擦。这不是两条规则,是“值传递 vs 共享可变状态”一条规则的两面。

⑤ 用样例验证关键一步:target=8 的经典树中,沿 10→5→3 到达内部节点 3:current_sum=18,查 18−8=10——祖先链前缀 {0,10,15} 中 10 出现 1 次 → 命中路径 5→3(起点正是“前缀 10 之后的第一个节点”)。随后右支 10→(-3)→11 的前缀 7、18 再查到 10,命中 -3→11——此时左支的前缀 15、18 都已撤销,不会伪造跨分支路径。

⚠ 易错坑 漏掉 prefix_count[0]=1:从根出发的达标路径全部丢失。最小反例 [5], target=5:查 5−5=0 无记录,输出 0,真值 1。
先加后查:自己配对自己。最小反例 [5], target=0:先写入 prefix_count[5]=1 再查 5−0=5 命中,输出 1(一条“零个节点”的假路径),真值 0。
忘记撤销:兄弟分支互相污染。最小反例根 1、左叶 2、右叶 3([1,2,3])、target=1:真值 1(根自己);不撤销时访问右叶 3 得 current_sum=4,查 4−1=3 命中左叶残留的前缀 3,多算一条“从左叶 2 之后到右叶 3”的不存在路径,输出 2。
用“current_sum > targetSum 就剪枝”:节点值可为负,和会跌回来。最小反例 [1,-1], target=0:在根处 1>0 被剪,输出 0,真值 1(路径 1→-1)。
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)——递归栈 O(h),而 prefix_count 因为“离开即撤销、零计数即删键”,任一时刻只存当前链的 O(h) 条前缀,两项同阶(最坏退化链 O(n));若不删零计数键,哈希会涨到 O(n) 但计数仍正确。高频追问一:“和 LC560 的关系?”——同一个“前缀和之差 + 哈希计数”模板,数组版一条线扫过去不用撤销;树上链会分叉,多出的“回溯撤销”正是树版的全部增量。追问二:“为什么不能对 targetSum 做正负剪枝?”——节点值域 ±10⁹ 含负数,任何“和已超标”的剪枝都可能错杀后面跌回来的路径(段2坑④的 [1,-1])。

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) 时间没问题,但要两趟搜索加 O(n) 路径存储。浪费的根源:我们并不需要两条完整路径,只需要它们的分岔点——路径里分岔点之前的公共段和之后的私有段都是多余信息。

① 关键转化——不存路径,让“找到了”这个信号自下而上冒泡:让每棵子树只回答一个极简问题:“你里面有 p 或 q 吗?”有就把找到的那个节点上报。信号向上冒泡的路上,第一个同时收到左右两路信号的节点,就是两条路径的最低分岔点=LCA。整个算法分两个阶段(进入时 / 返回时),函数体只有四行判断:

阶段一 · 进入时提前返回:node 为空 → 上报空;node 是 p 或 q → 立即上报自己,其子树不再搜索。为什么敢不搜?这是本题最妙的一步,见④。

阶段二 · 返回时后序合并:收左右两个信号——两路都非空 → p、q 分居两侧,我就是最低汇合点,从此上报我自己恰一路非空 → 原样转发那个信号;全空 → 上报空。

② 返回值的三态身份表(同一个返回值,不同阶段身份不同):

身份表:find(node) 的返回值在父节点眼里是谁
返回值它是谁父节点怎么用
None这棵子树里 p、q 一个都没有当作无信号,继续看另一侧
p(或 q)本身两种可能合一:子树里找到它;或它是另一目标的祖先(另一目标在它下面,没搜)不必区分哪种——见④,两种情况下这样上报都对
其他节点p、q 已在此汇合,它就是 LCA此后一路原样透传到根(另一侧必为空,不会再触发汇合)

③ 用样例验证两条路:分居样例 p=5、q=1:两个目标都在“进入时”被截住、各自上报,根 3 左右同时收到 5 和 1,汇合返回 3。祖先样例 p=5、q=4:进入 5 就命中 p 立即返回,4 根本没被访问;根 3 只收到左路信号 5、右路为空,原样透传,答案 5 ✓。

④ 常见困惑——命中 p 就不往下搜,万一 q 在 p 的子树里漏了怎么办?正面回答:不会漏,分两种情况看。若 q 真在 p 下面,那 p 同时是 q 的祖先(题目定义节点可以是自己的祖先),LCA 就是 p 本身——q 的具体位置根本不影响答案,不搜是纯赚;若 q 不在 p 下面,别的分支自会上报 q,照常汇合。这一步能成立,靠的是题设“p、q 都保证在树中”:返回的 p 永远不是误报。

⚠ 易错坑 把“命中即返回”搬到递归之后(先收子树信号、只转发子树结果):p 是 q 祖先时答案被错报成更深的目标。反例:根 1 的左孩子 2 有两个孩子 3、4,取 p=2、q=3:节点 2 先收到左路信号 3 就转发 3,根最终输出 3,真值 2——“我自己就是目标”必须在进门第一步判。
单侧命中时返回 node 而不是 left_signal or right_signal:每个单侧节点都冒充汇合点,信号被逐层偷换。反例 [3,5,1,6,2]、p=6、q=2:真汇合点 5 上报后,根 3 处右路为空、错误返回自己 3,真值 5。
题目不保证 p、q 都存在时照抄本解:q 不在树中时照样返回 p,而正确答案应是“不存在(null)”。反例 [1,2]、p=2、q=3(3 不在树里):本解输出 2,语义正确的实现应输出 null——需改成后序数命中次数、确认两个都找到才认账,且此时不能提前返回。
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),只有递归栈、无辅助结构,最坏退化链 O(n)。高频追问一:“如果树是 BST(LC235)怎么做?”——不需要后序:从根下行,p、q 都小于当前值就往左、都大于就往右,第一个把二者分开(或等于其一)的节点即 LCA,时间 O(h)、可写成迭代 O(1) 空间。追问二:“同一棵树要做百万次 LCA 查询呢?”——一趟预处理换快查询:倍增法(存每个节点的 2^k 级祖先,单次 O(log n))或 Euler 序 + RMQ;面试点到方案名和复杂度即可。

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²) 条路径且每条 O(n) 长;只从根出发算“向下延伸的最大和”也不行——最优路径可能整条藏在某棵子树里(示例 2 的 15→20→7 根本不碰根 −10)。慢的根源与 ↔ LC543 完全一样:每条路径的两条“腿”被反复重算,没有在后序里一次算好复用

① 关键转化——就是 543 换武器:任何路径都有唯一最高点(拐点),在拐点处“左腿+自己+右腿”。于是照搬 543 的骨架:后序遍历,每个节点当一次拐点、当场打擂台,向父只上报单边。区别只有一个字:——543 的“腿长”是边数、恒非负;本题的“腿重”是节点值和、可为负。负数带来两处新决策(见③),除此之外两题同构。

② 两条线的身份表(沿用 543,注意口径换成值和):

身份表:单边贡献线 vs 全局擂台线
代码里的量它是谁提供什么 / 不管什么
gain(node) 返回值单边贡献:从 node 出发向下一条链的最大和(必含 node 自己)供父节点接一条腿;不含“左右同时接”的形态;本身可为负,由父层决定接不接
left_gain / right_gain两个孩子的单边贡献,已截负为 00 的含义=“这条腿不要了”,路径到此不延伸
path_through_nodenode 为拐点的完整路径和=node.val+left_gain+right_gain只进擂台、不上报——带两条腿的形态没法再接父边
best_sum全局擂台,初值 -inf每个拐点结算一次;初值必须允许“最好答案也是负数”

③ 两处负值决策从“路径定义”推导:孩子的负贡献截 0——路径是我们自己选的,某条腿越接越亏就可以不接(路径在拐点处只往一边走、甚至一边都不走都合法),max(gain, 0) 正是“接或不接”二选一。当前节点不能截 0——拐点是路径的必经成员,node.val 再负也必须算进 path_through_node;想“跳过它”等于换了一个拐点,那是别的节点结算时的事。相应地擂台初值必须 -inf 而非 0:路径至少含一个节点,全负树也得选出最大的那个负数,0 会伪造一条“空路径”。

④ 常见困惑——返回值要不要也截成非负?两种写法都对:本文在父层统一 max(gain,0),返回值保持“必含自己的真实最大链和”语义;也有人在返回前就截(return max(0, ...)),语义变成“接我这条腿最多值多少,不划算就当 0”。选一种贯彻即可,混着写(父层截了、返回前又截)不会错但冗余;两处都不截才是坑③的 WA。

⑤ 用样例验证关键一步:[-10,9,20,null,null,15,7]:叶 15、7 各上报 15、7;节点 20 结算 15+20+7=42 进擂台,上报单边 20+max(15,7)=35;根 −10 结算 9+(-10)+35=34,不敌 42,上报 -10+35=25(已无人接收)。答案 42,路径整条住在右子树里——“擂台记完整路径、返回只带单腿”两条线各司其职。

⚠ 易错坑 擂台初值写 0:全负树选了“空路径”。最小反例 [-3]:输出 0,真值 −3(必须至少含一个节点)。
向父上报双腿(return node.val+left_gain+right_gain):父层把带分叉的形态又接上父边,拼出非法路径。反例根 0、左叶 1、右孩子 2(2 有叶 3、4):错误解在根处算出 0+1+9=10(对应“路径” 1—0—2—3—4,在节点 2 处分叉,非法),真值 9(路径 3—2—4)。
左右负贡献不截 0:亏本的腿被强行接上。最小反例 [1,-2,3]:根处算 1+(-2)+3=2,擂台最高只到 3(叶 3 单点),输出 3,真值 4(路径 1—3,左腿 −2 不接)。
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),只有递归栈、无辅助结构,最坏退化链 O(n)(n=3×10⁴ 的链在 Python 里还要注意递归深度限制)。高频追问一:“和 LC543 到底差在哪?”——同一副“单边上报 + 拐点擂台”模板:543 的腿是边数、恒非负,所以既不用截负也不用担心初值;124 的腿是可负的值和,于是多出“负腿截 0”与“擂台初值 −∞”两个决策——负数就是从 543 到 124 的全部增量。追问二:“要求输出最优路径本身呢?”——擂台刷新时记下拐点,结束后从拐点向下分别沿“被采用的那条腿”(gain 更大且为正的孩子)回溯拼出两半。

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 · 思路(暴力→最优)

一句话总纲:从左上到右下逐格扫描;每撞见一个还没被标记的陆地格,答案加一,并立刻把它所在的整座岛全部搜出来标成 '0'(flood fill 淹没);扫描结束时,累计的发现次数就是岛屿数量。

① 暴力起手 + 慢的根源:最直观的想法是"对每个陆地格,搜出它所属的整片陆地":从每个 '1' 出发做一次搜索得到连通块,再把所有连通块去重计数。正确,但一座岛有多少格,就被从头重搜多少遍,最坏 O((m·n)²)。慢的根源:搜完一座岛后,"这些格子已有归属"的结论被扔掉了,下一格又从零开始——归属信息完全没有被复用。

② 关键转化——不数岛,数"第一次发现":换个枚举对象:逐行逐列扫描,只在撞见未访问的陆地时计数加一,并当场把整座岛全部标记(flood fill 淹没)。每座岛只有"最先被扫到的那一格"能触发计数,其余格子在轮到自己之前就已被标记,再也不会触发——岛屿数=发现次数。问题退化成标准子问题:从一格出发整块标记四连通块。这就是 LC79 单词搜索 的网格 DFS 模板,区别是这里的标记永久生效、不需要回溯撤销。

③ 各个量的身份:

身份表:扫描、搜索、标记各管什么
代码里的量它在题里是谁提供什么
外层双重 for逐格扫描,撞见每座岛第一个未标记的格子触发 island_count+1 并启动一次 flood
flood() / BFS 队列从第一格出发、铺满这座岛其余格子的搜索把同岛格子提前标成 '0',保证不再触发计数
grid[r][c] = "0"visited 标记(复用原网格)天生的水和被淹的陆地此后身份相同:都不触发
island_count累计发现次数一次发现=一座岛,即最终答案

④ "一次发现=一座岛"为什么不重不漏:

为什么不漏——每座岛至少有一格,全网格扫描必然轮到其中最早被扫到的那一格;轮到它时它一定还是 '1',因为只有同一座岛被发现才会淹到它,而它就是本岛最早被扫到的格子。

为什么不重——第一次发现的同一时刻整岛已被淹成 '0',同岛其余格子再被扫到时条件不成立。计数对象从"格子"换成"第一次发现",重复计数就被结构性排除了。

⑤ 入队即标记的顺序从标记语义推导:标记的正确语义是"已有人负责你",不是"已处理完你"。BFS 里一个格子从被发现到被出队之间有时间差:若等出队才标记,这段时间里它的多个邻居都会把它当作还没人负责的格子重复入队。用 [[1,1],[1,1]] 验证:等出队才标记时,(1,1) 会被 (0,1) 和 (1,0) 各入队一次。DFS 递归版的对应写法是"进入函数第一行就置 0"。

⑥ DFS 还是 BFS——判断过程:先看题目要什么:只问"有几块",不问"从谁到谁多少步",所以用不上 BFS 的分层性质,任何能铺满整个连通块的遍历都对。真正的差别在工程上:网格最大 300×300,全是陆地时 DFS 沿蛇形路径可递归近 9 万层,远超 Python 默认递归上限 1000;BFS 的显式队列没有这个风险。两种都要会,大网格优先 BFS 或显式栈。若问的是"最短时间/最少步数"(如 LC994),就必须 BFS。

⑦ 用原题示例 2 走一遍:grid=[["1","1","0","0","0"],["1","1","0","0","0"],["0","0","1","0","0"],["0","0","0","1","1"]]

扫到 (0,0):是 '1' → 第 1 次发现,flood 淹没 (0,0)、(0,1)、(1,0)、(1,1)。
继续扫到 (0,1)、(1,0)、(1,1):都已是 '0',跳过不计数。
扫到 (2,2):是 '1' → 第 2 次发现;它与 (1,1) 只有斜角接触,四连通下不相连,正是"对角不算"的判例。
扫到 (3,3):是 '1' → 第 3 次发现,flood 顺带淹没 (3,4)。
扫描结束:island_count=3,与官方输出一致 ✓。

⚠ 易错坑 BFS 等出队才标记:输入 [["1","1"],["1","1"]],(1,1) 被 (0,1)、(1,0) 各入队一次(入队即标记则每格恰入队一次)。岛数仍算对,但大网格上重复入队成倍膨胀直至超时——错的是复杂度不是答案,更难被小样例发现。
把斜角当连通(用 8 方向):输入 [["1","0"],["0","1"]],错误输出 1,真值 2——题目只认水平/竖直相邻。
漏写 row < 0 判断:Python 负下标不报错,grid[-1][col] 静默读到最后一行。输入 [["1"],["0"],["1"]]:从 (0,0) 出发的 flood(-1,0) 会把第 2 行的岛也淹掉,错误输出 1,真值 2。越界判断必须先于任何 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)(全是陆地时蛇形深入)。高频追问一:"不允许修改输入 grid 怎么办?"——单独开 visited 布尔矩阵,空间仍 O(m·n) 但不再免费;面试可先声明"我原地修改省空间,若需保留输入就换 visited"。追问二:"DFS 会爆栈吗?"——会:300×300 全陆地时递归深度可达 9×10⁴,远超 Python 默认上限 1000,大网格换 BFS 或显式栈。

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 · 思路(暴力→最优)

一句话总纲:先把所有初始腐烂橘子一起放进队列,当作 BFS 的第 0 层;再一圈一圈向外传染,每处理完一层分钟数加一;传染停止后,新鲜橘子清零就返回累计分钟数,还有剩余就返回 -1。

① 暴力起手 + 慢的根源:照题面直译:每分钟扫一遍全网格,把"与腐烂橘子相邻的新鲜橘子"收集起来统一改为腐烂,直到某分钟没有任何变化。每分钟 O(m·n),传播最坏持续 O(m·n) 分钟,共 O((m·n)²)。慢的根源:每分钟真正可能发生传染的只有"刚腐烂、还没传染过"的那批橘子,暴力却每分钟把整个网格从头扫一遍——"腐烂边界现在推进到哪了"这个信息完全没有被复用。

② 关键转化——从模拟时间到计算距离:与其一分钟一分钟推演,不如反过来问每个新鲜橘子:"你离最近的腐烂源几步?"一格新鲜橘子在第 k 分钟腐烂 ⟺ 它到最近腐烂源的四连通距离是 k;答案就是所有新鲜橘子中这个距离的最大值。问题退化成标准子问题:无权网格上的多源最短距离

③ 为什么必须 BFS、不能 DFS(判断过程):要的是"最少分钟"=最短距离。DFS 一条路走到底,第一次到达某格时走的是"当前这条路径"的长度,完全不保证最短,想保证就得穷举所有路径取最小——退化回指数级。BFS 按"离源 0 步、1 步、2 步……"一圈圈向外推进,第一次碰到某格时走的必然是最短路(若有更短的路,早一层就该碰到它了)。判断口诀在本题的落地:只求覆盖/数块(LC200)→ DFS/BFS 均可;求最短/分层(本题)→ 只能 BFS。

④ 多源怎么办——全部当第 0 层:所有初始腐烂橘子是同时开始传染的,等价于把它们全部塞进初始队列,当作 BFS 的第 0 层:谁也不比谁早,每格新鲜橘子的距离自动按"离它最近的那个源"结算。若只从其中一个源出发扩散,其它源附近的腐烂时间会被高估:[[2,1,1,1,2]] 只从左端算,中间橘子距离 2、右端旁的橘子距离 3,得 3;两端同时扩散的真值是 2。

⑤ 各个量的身份:

身份表:队列存的是什么,每个计数器管什么
代码里的量它在题里是谁提供什么
queue已腐烂、但还没向四周传染过的橘子队列下一分钟的全部传染源;不含早已传染完毕的腐烂橘子
current_level_size本分钟开始时队列里的橘子数(冻结值)给"这一分钟"画句号:本轮只处理这么多个
minutes已经发生的传染轮数=当前层号处理完第 k 层时 minutes=k,即最终答案
fresh_count还没被传染的新鲜橘子数清零→提前收工;队列空仍 >0 → 有隔离橘子,返回 -1
grid[r][c] = 2(入队时写)"已被传染"标记,兼作 visited入队即标记:被传染的瞬间就有人负责,防重复入队

⑥ 层数=分钟数为什么成立(归纳推导):第 0 层=初始腐烂="第 0 分钟已腐烂"。假设第 k 层恰好是"第 k 分钟腐烂"的全部橘子:它们在第 k+1 分钟传染各自相邻的新鲜橘子;反过来,任何第 k+1 分钟腐烂的橘子也必与某个第 k 分钟腐烂的橘子相邻——两个集合正好相等,归纳成立。

归纳成立的前提是"同层同时处理":每轮先冻结 current_level_size = len(queue),本轮只处理这么多个;本轮新入队的橘子排在冻结额度之外,自然留给下一轮。

常见困惑——"同一分钟新腐烂的橘子会不会当场继续传染?"不会:它 append 在队尾,本轮的 for 循环数完冻结额度就停。这正是题面"新腐烂的要到下一分钟才传播"的代码化身。

⑦ 循环条件从语义推导:minutes += 1 放在循环体开头,语义是"即将发生一整分钟的传染",所以进入循环的前提必须是"确实还有人可传染"——条件写 while queue and fresh_count > 0。少了 fresh_count 检查,最后一层"只出队、无人可传"也会白白 +1(见坑①);初始就没有新鲜橘子时循环一次都不进,自然返回 0。

⑧ 用原题示例 1 走一遍:grid=[[2,1,1],[1,1,0],[0,1,1]],初始队列 [(0,0)],fresh_count=6。

第 1 分钟:(0,0) 传染 (1,0)、(0,1),fresh 6→4。
第 2 分钟:(1,0)、(0,1) 传染 (1,1)、(0,2),fresh 4→2。
第 3 分钟:(0,2) 的邻居 (1,2) 是空格传不动;(1,1) 传染 (2,1),fresh 2→1。
第 4 分钟:(2,1) 传染 (2,2),fresh 1→0,循环停止。
返回 minutes=4,与官方输出一致 ✓——离源最远的 (2,2) 距离正是 4,"最大距离=总时间"。

⚠ 易错坑 循环条件漏 fresh_count > 0(写成 while queue:minutes += 1):输入 [[2]],队列非空进循环、无人可传染,错误输出 1,真值 0。
不冻结层长:直接 for cell in queue 边遍历边 append,新腐烂的橘子混进本轮继续传染。输入 [[2,1,1]]:(0,1) 刚腐烂就在同一"分钟"传染 (0,2),错误输出 1,真值 2。
结束时不检查剩余新鲜、直接 return minutes:输入 [[2,0,1]],(0,2) 被空格隔离永远烂不到,队列耗尽后 fresh_count=1,错误输出 1,真值 -1。
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)。高频追问一:"不冻结层长还能怎么写?"——入队时带时间戳 (r, c, t),出队用 t 更新答案;或用"当前层/下一层"双队列轮换。三种写法等价,冻结 len(queue) 改动最小。追问二:"为什么不能 DFS?"——DFS 第一次到达不保证最短,要穷举所有路径取最小,指数级;BFS 按距离单调扩展、每格恰好结算一次,才有 O(V+E)。

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)。慢的根源:我们根本不需要找出具体顺序,只需要知道"合法顺序存不存在";无数排列失败的原因是同一个(某几门课互相等着对方),却被一遍遍重复验证。

① 关键转化——从"找顺序"到"判环":把每条 [a,b](修 a 前先修 b)画成有向边 b → a,箭头顺着时间走:先修完的指向被解锁的。合法顺序存在 ⟺ 图无环。为什么:若有环,环上每门课都在等环上前一门,谁都无法成为第一个开修的,无解;若无环,有限的有向图必有入度 0 的点(反证:若人人都有入边,沿入边一路回溯,有限个点必走回重复点,即出现环)——先修它,删掉它的出边后剩图仍无环,归纳可修完全部。这个反证顺便送来了算法:反复摘"入度 0"的课(Kahn 拓扑排序)。

② Kahn 里各个量的身份:

身份表:入度、队列、计数器各自管什么
代码里的量它在题里是谁提供什么
adjacency[b]学完 b 能解锁的后继课名单"我修完了,该通知谁"
in_degree[c]课 c 还剩几门没修完的先修课c 离"能修"还差几步
in_degree[c] == 0先修全部修完、现在就能修的课入队资格
available 队列当前随时可修的"就绪集合"下一门要修的课;取出顺序不影响可行性
in_degree[next] -= 1给 next 的通知:"你的一门先修课刚修完"恰好减到 0 的瞬间发放入队资格
completed已修完的课程数最终与 numCourses 比对的判据

③ 摘除流程从直觉推导:"学完一门课"对图意味着什么?它不再挡任何人——把它的所有出边删掉,即给每个后继的 in_degree 减 1;谁在这一刻恰好减到 0,谁就拿到"随时可修"资格入队。入度只减不增,"减到 0"对每门课至多发生一次,所以每门课至多入队一次,队列天然去重、不需要 visited。注意 available 的本质是就绪集合:换成栈、任意顺序取都对,只是得到不同的合法顺序——这里用队列不是为了"最短",与 LC994 用 BFS 求最短距离的动机不同。本题既不求最短也不只求覆盖,BFS/DFS 之争在这里其实是"逐层剥入度"与"沿路径找回边"两种判环视角之争,两者都对。

④ 判据从环的行为推导:为什么最后比较 completed == numCourses,而不是"队列空了就成功"?环上的课,其环内前驱永远修不完,in_degree 永远 ≥ 1,永远进不了队列——它们静静留在图里,队列照样会正常耗尽。队列空只说明"能修的都修完了";completed < numCourses 才暴露"有人被环卡死"。

⑤ DFS 三色——另一种判环视角:不逐层剥,直接沿边深走,找"指回当前路径的边"(回边)。关键是把"访问过"拆成两种完全不同的身份:

三色身份表:撞到不同颜色,含义天差地别
state[c] 取值它是谁撞到它意味着什么
白(0)还没考察过的课正常深入递归
灰(1)正在当前递归路径上、后代还没查完的课这条边指回了当前路径 → 环,立即 False
黑(2)从它出发的整棵搜索已确认无环前方是安全区,直接跳过不重搜

⑥ 预判困惑——两色为什么不够:只用一个 visited 集合、撞到"访问过"就报环?错:撞到"以前查过且安全"(黑)不是环。最小反例:3 门课 [[1,0],[2,0],[2,1]](边 0→1、0→2、1→2,菱形无环):先走 0→1→2 查完,回到 0 再看后继 2 时它已"访问过",两色版误报有环输出 false,真值 true。灰/黑之分钉死的正是"在当前路径上"与"以前查过且安全"的差别。另一个常见困惑——建边方向写反(照抄 [a,b]a→b)会怎样?本题碰巧不影响答案:所有边同时反向,环还是环、无环还是无环。但这是侥幸:追问版 LC210 要输出选课顺序,反向建边会把顺序整个输出成倒序(输入 2, [[1,0]],真值 [0,1],反向建边得 [1,0])。用"箭头顺着时间走"把方向的物理含义说死,别赌对称性。

⑦ 用样例验证关键一步:段3 例子 4 门课 [[1,0],[2,0],[3,1],[3,2]]in_degree=[0,1,1,2]。学 0 → 课 1、2 双双减到 0 入队;学 1 → 课 3 从 2 减到 1,不入队——它还有另一门先修 2 没修完,"入度=还剩几门先修"在这一步看得最清楚;学 2 → 课 3 减到 0 入队;completed=4=numCourses → true。

⚠ 易错坑 两色 DFS 判环(单一 visited,撞到就报环):输入 3, [[1,0],[2,0],[2,1]],菱形无环被误报,错误输出 false,真值 true。必须三色,撞黑点要放行。
把"队列耗尽"当成功、不比较 completed:输入 2, [[1,0],[0,1]],初始就没有入度 0 的课,队列空转结束,错误输出 true,真值 false(completed=0 ≠ 2)。
DFS 只从课程 0 启动:图可能不连通。输入 3, [[2,1],[1,2]],环在 1↔2 之间、0 是孤立点,只查 0 错误输出 true,真值 false——必须对每个白点各启动一次。
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)。高频追问一:"Kahn 的队列换成栈行不行?"——行:available 是就绪集合,取出顺序只影响得到哪个合法顺序,不影响可行性判断;要字典序最小的拓扑序就换最小堆,代价 O(E + V log V)。追问二:"递归 DFS 会爆栈吗?"——链式先修(0←1←2←…)时递归深度=V,本题 V 可达 2000,超 Python 默认上限 1000;答法:sys.setrecursionlimit、改迭代 DFS,或直接用 Kahn。

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 · 思路(暴力→最优)

一句话总纲:把所有插入过的单词按字符挂成一棵共享公共前缀的多叉树:插入时沿字符逐层下行、缺边才建新节点,走完在末节点标记"有单词在此结束";查前缀只要能沿路径走完就是真,查完整单词还要求终点带结束标记。

① 暴力起手 + 慢的根源:用哈希集合存所有插入过的单词:insert/search 都是 O(L)(哈希本身要扫一遍字符串),但 startsWith 塌了——集合以"整个单词"为键,想知道有没有单词以 "ap" 开头,只能把全部 N 个单词逐个取出来比前缀,O(N·L)。打补丁:再开一个集合,把每个单词的所有前缀也存进去,startsWith 降到 O(L)——但长为 L 的单词有 L 个前缀,空间膨胀到 O(N·L²) 字符。慢和费的根源是同一个:"app""apple" 的前三个字符明明完全相同,两种方案都把它们当互不相干的整体,公共前缀的共享结构被扔掉了

② 关键转化——把"单词集合"变成"字符路径树":让公共前缀在物理上只存一份:根代表空串,每条边挂一个字符,从根走到任一节点、沿途字符拼起来就是一个前缀——节点=前缀,边=追加一个字符"app""apple" 共享 root→a→p→p 一条路径,后者只多出 l、e 两个节点。于是 startsWith(prefix) 退化成"沿 prefix 逐字符下行,能走完就是存在",O(L) 且与词库大小 N 无关。结构上这是一棵多叉树,逐字符下行与 LC200 顺着邻居走的图遍历同源。

③ 补上缺口——路径存在 ≠ 单词存在:只插入 "apple" 时,root→a→p→p 路径确实存在,但从没有人插入过单词 "app"。所以每个节点还需要一个布尔标记 is_end:"有单词恰好在我这里画句号"。search = 走完路径终点 is_end 为真;startsWith = 走完路径即可。这就是官方示例里 search("app")=falsestartsWith("app")=true 的全部原因。

④ 各个量的身份:

身份表:"节点存在"与"is_end"回答的是两个不同问题
代码里的量它在题里是谁提供什么
node.children[ch]当前前缀追加字符 ch 后的新前缀节点键缺失=没有任何单词往这个方向长
node.is_end"有单词恰好在此结束"的标记节点存在只答"是不是某单词的前缀",is_end 才答"本身是不是单词"
node(游标)任一时刻=已匹配的那段前缀insert 与两种查询共用同一种"逐字符下行"
_walk 返回值None=路径中断;节点=前缀存在交给调用方决定还查不查 is_end

同一个节点的身份可以叠加:插入 "app""apple" 后,第二个 p 节点既是 apple 的中途站、又是单词 app 的终点——"是前缀"与"是单词"互不排斥,全靠 is_end 区分。

⑤ insert 的写法从直觉推导——"有路走路,没路修路":逐字符下行时,if char not in node.children 才新建节点,否则复用旧路。最常见的误写是无条件 node.children[char] = Trie()——这是"拆旧路重修",会把该字符下已有的整棵子树连根丢掉。走完整个单词后,只在末节点打 is_end = True,不碰它已有的 children:后插的短单词(app)不能伤到先插的长单词(apple),反之亦然。

⑥ 常见困惑——search 明明走到了 "app" 的节点,为什么返回 False?因为走到的只是"有单词以 app 开头"的证据(apple 修路时顺便造出的中途站),不是"有人插入过 app"的证据。两个命题分别由"节点存在"和"is_end"回答,恰好一一对应 startsWithsearch

⑦ 用官方示例走一遍:操作顺序:insert("apple")、search("apple")、search("app")、startsWith("app")、insert("app")、search("app")。

insert("apple"):从 root 逐字符新建 a→p→p→l→e,末节点 e 的 is_end 置 True。
search("apple"):沿路径走到 e,is_end=True → true。
search("app"):走到第二个 p,路径存在但 is_end=False → false。
startsWith("app"):同一条路径,走完即可、不看 is_end → true。
insert("app"):a、p、p 全程复用旧路、零新建,只把第二个 p 的 is_end 置 True。
再 search("app"):is_end=True → true。
输出序列 [null,null,true,false,true,null,true],与官方一致 ✓。

⚠ 易错坑 insert 无条件新建子节点(漏写 not in 判断):insert("apple") 后 insert("app"),字符 a 处旧节点被换新,p-p-l-e 子树连根丢失,search("apple") 错误输出 false,真值 true。
search 不检查终点 is_end:insert("apple") 后 search("app") 错误输出 true,真值 false;对称错误是 startsWith 多查了 is_end——startsWith("app") 错误输出 false,真值 true。
忘了 _walk 可能返回 None、直接写 return self._walk(word).is_end:空 Trie 上 search("a") 抛 AttributeError(NoneType 没有 is_end),真值应为 False——必须先判 node is not None
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 是树,边数=节点数−1,与节点同阶),最坏 O(N·L);本实现的 children 是字典,不为每个节点预分配 26 个槽位。高频追问一:"为什么不用哈希集合?"——search 同为 O(L),但 startsWith 要 O(N·L) 扫全库;把所有前缀预存进第二个集合虽降到 O(L),空间却膨胀到 O(N·L²),Trie 用共享路径把它压回 O(总字符数)。追问二:"children 用 dict 还是 26 长数组?"——数组下标定位常数更小、缓存友好,但每节点固定 26 槽,稀疏词库浪费;dict 按需分配、空间紧凑,常数略大。按字符集大小和稠密度做场景化选择。

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 的取值序列(每一位都从 nums 里任选一个),再筛掉用了重复元素的——候选有 nⁿ 条,合法的只有 n! 条。慢的根源:生成第 k 位时完全不记得"前面已经用过谁",注定失败的分支(比如第二位又选了 1)仍会长满整棵子树。另一条直路——写 n 层嵌套循环——在 n 不固定时根本写不出来。

① 关键转化——按位置填空的决策树:把"生成一个排列"拆成 n 个连续决策:第 0 位填谁、第 1 位填谁……画成树,第 k 层的节点是"前 k 位已定的前缀",分支是"下一位可以填的元素"。修复暴力只需一句话:每层的选择列表=全集减去已在前缀里的元素。沿这棵树做 DFS,走到叶子收一个排列,退回分叉换兄弟分支——这就是回溯:树的 DFS+撤销。

② 身份表(撤销撤的是什么,先钉死):整个递归只维护一份共享现场,每个量的职责如下:

身份表:全排列回溯中每个量是谁、提供什么
代码里的量它是谁提供什么 / 不管什么
path根到当前节点的已填前缀第 k 层长度恰为 k;所有分支共用同一个 list,不是每层一份
used[i]「nums[i] 是否已在前缀里」的记录替 path 回答 O(1) 的"用过没有",内容与 path 严格同步
position当前在填第几位=递归深度只用来判断"填满了没",数值上等于 len(path)
path[:](收集时)叶子处前缀的拷贝快照与 path 从此无关;path 本体马上要被 pop 回去

③ 为什么每层从下标 0 扫到底、不能用 start:组合题用 start 只向后选,是因为 [1,2][2,1] 算同一个答案;排列恰恰相反——顺序不同就是不同排列,"回头选前面的元素"必须允许。所以每层都从 0 扫全数组,靠 used 而不是 start 排除重复:start 删的是"顺序不同的重复",used 删的是"同一元素用两次",本题只需删后者。

④ 常见困惑一——递归返回后为什么要 path.pop() 并清 used?因为 path 和 used 是所有分支共享的同一份现场,不是传给下层的副本。递归钻进"第 1 位填 2"的子树时把现场改成了 [1,2];返回后若不撤销,兄弟分支"第 1 位填 3"看到的起点就是被污染的 [1,2] 而不是 [1]。撤销(pop+清 used,二者成对)就是把现场精确恢复到分叉前,保证每个兄弟分支从相同状态出发。

⑤ 常见困惑二——收集为什么写 results.append(path[:]) 而不是 append(path)?append(path) 存进去的是对同一个 list 对象的引用,不是当时的内容。回溯继续跑,path 会被反复 pop/append,最终退回空列表;结果里 6 个"答案"其实是同一个空 list 的 6 个别名。path[:] 做浅拷贝,把"此刻的内容"定格下来。

⑥ 样例验证撤销一步:[1,2,3] 收集完 [1,2,3] 后逐层返回:pop 3(清 used[2])、pop 2(清 used[1]),现场恢复为 path=[1]、used=[T,F,F]——正是"第 1 位分叉前"的状态;该层 for 继续走到下标 2 选 3,得 [1,3],再选唯一剩下的 2,收集 [1,3,2]。若刚才漏清 used[1],这里的 2 会被误判为"已用",第二个排列直接丢失。

⚠ 易错坑 收集写 results.append(path) 不拷贝:输入 [1,2,3],输出 [[],[],[],[],[],[]](6 个元素全是同一个已被清空的 list 的引用),真值是 6 个互不相同的排列。
撤销不成对——pop 了 path 却忘写 used[index]=False:输入 [1,2],首位选 1 的整棵子树结束后 used 停留在 [T,T],根层轮到 2 时被自己的旧标记跳过,输出只有 [[1,2]],真值 [[1,2],[2,1]]
循环写成 range(position, n)(把组合的 start 思路带进排列):输入 [1,2],首位选 2 之后第 1 位只能从下标 1 起扫、而下标 1 已被用,[2,1] 永远拼不出来,输出只有 [[1,2]],真值 2 个。
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)(结果本身不计入额外空间)。高频追问一:"为什么是 O(n·n!) 不是 O(n!)?"——搜索树内部节点总数约为 e·n! 级,真正主导的是每个叶子 O(n) 的拷贝,按"叶子数 × 单叶成本"给出 n·n!。追问二:"能不能不开 used 数组?"——可以用交换法:第 position 层把 nums[position]nums[index] 交换,nums[position:] 就隐式表示"剩余可选",递归后换回来;省掉 used 但输出顺序改变,且含重复元素时去重比"排序+used"难,面试先答 used 版再补这条。

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 · 思路(暴力→最优)

暴力起手:照搬 LC46 全排列的做法——每层从全数组里选一个没用过的、每个节点都收集——确实能碰到所有子集,但 [1,2][2,1] 会被各收一次。慢的根源:子集是无序的,一个大小为 k 的子集会以 k! 种顺序被重复走到,收完还得排序去重,白做几倍功。

① 关键转化——给每个子集指定唯一代表:重复的病根是"同一批元素、多种生成顺序"。修复:规定每个子集只按下标递增的那一种顺序生成——先选下标小的、再选下标大的。落到代码上就是一个 start 指针:本层只能从 start 往后选;选了下标 index,下一层从 index+1 起步。"回头选"被禁止,[2,1] 这条路径根本不存在,天然零重复。这与 LC46 恰成对照:排列要保留顺序差异(用 used),组合要消灭顺序差异(用 start)。

② 身份表:

身份表:start 法各个量是谁
代码里的量它是谁提供什么
path当前节点对应的子集本身既是一个完整答案,又是所有更长子集的前缀——双重身份是"进门即收集"的原因
start_index本层允许选择的最小下标保证路径上下标严格递增,负责去重
递归实参 index+1下一层的 start从"刚选的这个"右边继续;若传 index 就成了可重复选(那是 LC39)

③ 收集时机从身份表推出来:LC46 只在叶子收集,因为"长度不足 n 的前缀"不是排列;本题 path 的双重身份决定了每个节点都是合法子集——所以收集写在函数第一行,进门先收,不设终止条件:for 循环在 start 走到头时自然为空,递归自动见底。

④ 常见困惑——进门就收,会不会把同一个子集收两次?不会。每个子集只有一种下标递增的生成顺序,也就只对应决策树上唯一一个节点;节点互不相同、每个节点恰好收一次,2ⁿ 个子集与 2ⁿ 个节点一一对应。

⑤ 等价视角——二叉决策树:换个切法:逐个下标问"选不选 nums[index]",每层恰两个分支,处理完全部下标(index==n)才在叶子收集,2ⁿ 个叶子即 2ⁿ 个子集。取舍:start 法代码短、无终止条件;二叉法把"每个元素独立二选一"摆得更直白,也是位枚举(整数第 i 位表示选不选)的递归版。注意二叉法里 append/pop 只包住"选"分支——"不选"分支没动 path,无可撤销。

⑥ 样例验证 start 挡住重复:[1,2,3]path=[2] 的节点:它由根层选下标 1 得到,start 已推进到 2,本层只能再选下标 2(值 3)——下标 0 的 1 永远回不去,[2,1] 无法出现;该节点只延伸出 [2,3],与全部 8 个子集恰好对上。

⚠ 易错坑 递归传 start_index+1 而不是 index+1:本层选了靠后的下标时,下一层的 start 没跳过它。输入 [1,2]:根层选下标 1(值 2)后递归 start 仍=1,同一元素被再选一次,输出为 [[],[1],[1,2],[2],[2,2]](混入 [2,2]),真值 [[],[1],[2],[1,2]]
收集写 results.append(path) 不拷贝:输入 [1,2],输出 [[],[],[],[]](4 个引用指向同一个最终为空的 list),真值 [[],[1],[2],[1,2]]
二叉法把 append 提到两个分支之前(或漏掉 pop):输入 [1],"不选"分支也带着 1 走,输出 [[1],[1]],真值 [[],[1]]
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)。高频追问一:"三种写法怎么选?"——start 回溯、二叉选/不选、位枚举(mask 从 0 到 2ⁿ−1,第 i 位为 1 则取 nums[i])复杂度同为 O(n·2ⁿ);位枚举无递归、常数小,回溯版便于叠加剪枝和去重,面试可答"会三种、默认写 start 版"。追问二:"能低于 O(n·2ⁿ) 吗?"——不能:输出本身有 2ⁿ 个子集、总长度 n·2ⁿ⁻¹,这就是输出规模下界。

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 · 思路(暴力→最优)

一句话总纲:先特判空输入直接返回空列表;再从第 0 个数字开始逐位递归——每层枚举当前数字按键上的所有字母、把选中的拼进路径,路径长度等于数字个数时收集一个组合。

暴力起手:digits="23" 就写两层循环(外层 abc、内层 def),"234" 就写三层——循环层数等于输入长度,而长度要到运行时才知道,这种"变层数嵌套循环"根本写不出来。注意本题所有组合都必须输出,时间不可能低于输出规模;要解决的不是"快",而是"层数不定"这个结构问题。

① 关键转化——嵌套循环递归化:"n 层循环"的本质是 n 个连续决策:第 0 个数字选哪个字母、第 1 个数字选哪个字母……用递归表达就是:递归深度 digit_index=当前处理第几个数字,每层的选择列表=这个数字按键上的字母。层数由 len(digits) 在运行时决定,"写不出变层数循环"的结构问题就消失了。

② 各个量是谁:本题角色单一,不必列表。path 是已为前 digit_index 个数字选定的字母序列,长度恒等于 digit_index;终止条件 digit_index == len(digits) 即"每个数字都已配上字母",此时把 path 拼成字符串收集。

③ 常见困惑一——为什么这题既不用 used 也不用 start?三类回溯题的差别全在"每层的选择列表从哪来":

对比表:三种回溯每层选择列表的来源
每层选择列表需要的控制
LC46 全排列同一个集合,顺序敏感used 防止重用元素
LC78 子集同一个集合,顺序无关start 防止顺序重复
本题 LC17每层来自不同按键,天然互不相交什么都不用,选谁都不冲突

④ 常见困惑二——空输入为什么返回 [] 而不是 [""]?题目要的是"能表示的字母组合",没有数字就一个组合也表示不出来,答案是空列表。但递归天生不这么认为:不特判时 build(0) 进门就命中终止条件,把空 path 拼成 "" 收走,得到 [""]——所以函数第一行必须特判 if not digits: return []

⑤ 用示例 1 走一遍(重点看撤销):digits="23"

第 0 层(数字 2)选 'a':path=[a],进入第 1 层。
第 1 层(数字 3)选 'd':path=[a,d],digit_index=2 命中终止条件,收集 "ad"。
返回后 pop 掉 'd',同层改试 'e'、'f',得 "ae"、"af"。
第 1 层三个字母耗尽,返回第 0 层,pop 掉 'a',改选 'b',同法收集 "bd"、"be"、"bf"。
首字母最后换 'c' 再收 3 个——path 每层进出各一次,9 个组合恰是 3×3 的笛卡尔积。

⚠ 易错坑 忘记空串特判:输入 "",终止条件立即命中、收走空串,输出 [""],真值 []
映射表把 7 或 9 抄成 3 个字母(如 "wxy"):输入 "9",输出 ["w","x","y"],真值是含 "z" 的 4 个。
递归返回后忘 pop:输入 "23",path 只增不减,输出开头即是 ["ad","ade","adef",…] 且越拼越长,真值是 9 个长度为 2 的组合。
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)。高频追问一:"为什么按 4ⁿ 算不是 3ⁿ?"——7 和 9 各有 4 个字母,上界按每层最多分支数取;含 7/9 越多越接近 4ⁿ。追问二:"能不能不用递归?"——可以逐数字迭代扩展:结果列表初始为 [""],每读一个数字就把现有每个串与该键每个字母做一轮笛卡尔积;时间同为 O(4ⁿ·n),但中间要同时驻留整层组合,回溯 DFS 只占 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 · 思路(暴力→最优)

一句话总纲:先把候选数排序;再从起点下标只向后枚举,每选一个数就把"还差多少"减掉,并允许下一层继续选同一个数;剩余量减到 0 就收集当前组合,候选比剩余量还大时剪掉本层剩余分支。

暴力起手:枚举任意长度的候选序列、算和、验重,毛病有二。其一,[2,3][3,2] 是同一个组合却被各走一遍——大小 k 的组合重复 k! 次。其二,和已经超过 target 的分支还在往深处加数。正解的两处设计分别治这两个毛病:用"只能向后选"的起点下标消灭顺序重复;用"还差多少"的剩余量在超标时掐断分支。

① 骨架——LC78 的 start 枚举:组合不关心顺序,所以借用 LC78 子集的套路:递归带一个最小可选下标 start,每层只从 start 向后选。这样路径上的下标单调不减,[3,2] 这种"下标回头"的路径从结构上就不存在,顺序重复被消灭。

② 改动一——递归传 index 而不是 index+1:本题允许同一个数用任意多次,所以选了下标 index 之后,下一层的 start 仍传 index——自己还能再选自己。去重不受影响:range 仍从 index 起步,下标依旧单调不减。

③ 改动二——用剩余量代替"当前和":递归参数 remaining 表示还差多少凑够 target;每选一个数就减掉它,减到 0 即命中,收集 path。

④ 身份表(重点看"撤销方式"一列):

身份表:谁要手动撤销、谁自动回滚
代码里的量它是谁撤销方式
path已选进组合的数共享 list,必须手动 pop()
remaining还差多少凑到 target按值传参,每层各有一份,返回即自动"回滚",不写恢复代码
start_index本层最小可选下标(=上一层选的 index)同为参数,自动回滚

⑤ 常见困惑——为什么 path 要 pop 而 remaining 不用恢复?撤销的本质是恢复共享的可变现场。path 是所有层共用的同一个 list,改了就得改回来;remaining 和 start_index 是不可变的 int 实参,递归相当于复制一份带下去,下层怎么减都碰不到上层那一份。回溯题判断"要不要写撤销"就看这一条:改的是共享对象,还是自己层的局部值。

⑥ 排序+break 剪枝从直觉推出:想剪掉"和必然超标"的分支:若当前候选 value 已大于 remaining,选它必超。再把 candidates 先排序,则本层它后面的候选更大、全都必超——可以用 break 一刀切掉本层剩余全部分支。注意因果方向:break 的合法性建立在已排序上,不排序只能用 continue 逐个跳过。

⑦ 用示例 1 走一遍剪枝与回滚:candidates=[2,3,6,7],target=7(已排序)。

根层选 2:path=[2],remaining=5。
下一层 start 仍指向 2,再选 2:path=[2,2],remaining=3。
再选 2:path=[2,2,2],remaining=1。
本层第一个候选 2 > 1,break;pop 掉第三个 2 回到 path=[2,2],remaining 无须任何恢复动作、天然就是上一层那份 3(参数自动回滚)。
改选 3:remaining=0,收集 [2,2,3]
逐层撤销回根;根层选 3(再选 3 后 remaining=1)和选 6(remaining=1)的分支被同样剪掉。
根层选 7:remaining=0,收集 [7]。最终 [[2,2,3],[7]]

⚠ 易错坑 递归传 index+1(把"可重复选"写成了 LC40 的不可重复):输入 candidates=[2], target=4,选一次 2 后就再也不能选 2,输出 [],真值 [[2,2]]
没排序就用 break:输入 candidates=[3,2], target=2,本层第一个候选 3>2 直接 break,排在后面的 2 根本没被看到,输出 [],真值 [[2]]
递归时忘了减,写成 build(index, remaining):输入 candidates=[2], target=2,同一状态无限自我调用,RecursionError 栈溢出,真值 [[2]]
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)(不计结果)。高频追问一:"复杂度为什么只能给上界?"——搜索树形状由 target 与各候选值共同决定,没有闭式;答"深度至多 D、每层分支至多 N,剪枝后实际远小于上界"即可。追问二:"只要方案数、不要方案本身呢?"——立刻换完全背包 DP(LC322/LC416 一族),O(N·T) 出个数;回溯枚举全部方案的成本下界是输出规模,DP 靠不逐个输出方案绕开了它。

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 · 思路(暴力→最优)

暴力起手:2n 个位置每位放 (),共 2^(2n) 个串,逐个用计数法验合法。慢的根源:非法是前缀性质——串一旦以 ) 开头就没救了,可暴力仍会把这个死前缀的整棵子树(2^(2n−1) 个串)生成完再逐个否掉,做的全是注定失败的活。

① 关键转化——把"生成后检查"搬进"生成中":LC20 有效括号的栈判据看合法串:扫描时栈深不能为负、结束时栈空。括号只有一种,栈退化成一个计数——于是一个前缀能否活到最后,只由两个数决定:已放左括号数 open_count、已放右括号数 close_count。前缀合法且可补完 ⟺ open_count ≤ nclose_count ≤ open_count。把这两条翻译成"什么时候允许放下一个字符",就是本题全部:open_count<n 才许放 ((左括号还没用满);close_count<open_count 才许放 )(有未闭合的左括号可关)。决策树每层至多两个分支,非法前缀根本长不出来。

② 各量身份(角色简单,融入正文):open_count=已放的 ( 个数,还可再放 n−open_count 个;close_count=已放的 ) 个数;差值 open_count−close_count=当前未闭合的左括号数,正是 LC20 那个栈的深度——放 ) 的条件"close<open"就是"栈非空才能弹栈"。

③ 终止条件从两个约束推出:只判 len(path)==2n 就收集、不必再验 open==close:全程 close_count≤open_count≤n,而收集时 open+close=2n,代入得 open≥n 与 open≤n 同时成立,故 open=close=n 被约束自动保证。

④ 常见困惑——为什么两个 if 并列、不能写 elif?它们是决策树的两个兄弟分支,不是一个条件的两种情况:同一状态下常常既能放 ( 又能放 )(如前缀 "("),两条路都要走。elif 会在左分支成立时永久跳过右分支,整棵"先闭合再展开"的子树蒸发。同理注意右括号条件是严格 close_count<open_count:相等时未闭合数为 0,再放 ) 立即非法。

⑤ 样例验证一步:n=2:沿 ( → (( → (() → (()) 收第一个;退回前缀 "("(open=1、close=0),此处两个条件都成立,走右分支放 )"()",再放 (、放 ),收 "()()"——正是 elif 会丢掉的那一个。

⚠ 易错坑 两个 if 写成 elif:输入 n=2,前缀 "(" 处只走放 ( 的分支,输出 ["(())"],真值 ["(())","()()"]
右括号条件写成 close_count<n:输入 n=1,根状态就允许放 ),输出 ["()",")("],真值 ["()"]
终止条件写成 open_count==n 就收集:输入 n=1,放完 ( 即收,输出 ["("],真值 ["()"]
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)。高频追问一:"这个回溯有没有死枝?"——没有:两个放置条件保证任何中间前缀都能扩展成完整合法串,搜索树每个节点都通向至少一个答案,节点数 O(Cₙ·n),对比暴力 2^(2n) 是指数级差距。追问二:"为什么合法串数是卡塔兰数?"——按"与第一个 ( 配对的 ) 的位置"分类,得标准递推 f(n)=Σ f(i)·f(n−1−i);报出 n=1..4 → 1、2、5、14 即可佐证。

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 · 思路(暴力→最优)

一句话总纲:把每个格子都试作起点做 DFS——每层用当前格匹配 word 的当前字符,匹配上就把格子临时改成占用标记、再向四邻找下一个字符,返回前恢复原字符;word 全部字符匹配完即返回 true。

暴力起手:为 word 的每个字符各自在网格里找一个位置,再拼成序列——枚举量是 (mn)^L 级,而且"相邻"与"不重用"两条约束在独立选位里根本表达不出来。慢的根源:word 对应的是一条连续路径,第 k 个字符只能出现在第 k−1 个字符的四邻里,逐字符独立选位把这个最强的约束整个丢掉了。

① 关键转化——让递归结构替我们执行约束:search(row, col, word_index) 表示"word 的前 word_index 个字符已沿一条合法路径匹配完,现在轮到格 (row,col) 匹配 word[word_index]"。选择列表=当前格的四邻,递归深度=已匹配前缀长度——这就是把 LC46 那棵决策树铺到网格上,兄弟分支从"没用过的元素"换成"上下左右"。任何格子都可能是首字符,外层双重循环枚举全部起点。

② 身份表——board[r][c] 同一个格子、三个时刻三种身份:

身份表:占用标记的生命周期
时刻board[row][col] 是谁作用
递归进入前原字符word[word_index] 比对的候选
递归四邻期间"#"(占用标记)宣告"我在当前路径上";word 只含字母,"#" 永远匹配失败——占用检查免费搭在字符比较里,不必另开 visited
返回前恢复后原字符(由 original_char 暂存归还)把格子还给兄弟方向和其他起点

③ 常见困惑——LC200 岛屿数量同是网格 DFS,为什么它不撤销、这里必须撤销?看标记记录的信息与"走哪条路进来"有没有关系。LC200 问格子属于哪个连通块,无论从哪条路淹到它结论都一样,标记是全局事实,永久有效。本题问某条具体路径是否存在,"被占用"只对当前这条路径成立——这条路失败了,格子必须原样归还,否则别的路径被无辜堵死。一句话:标记记录路径状态就要撤销,记录全局事实就不撤销。成功时也顺手恢复(随后短路返回 True),保证函数退出时棋盘总是原样。

④ 用示例 3 走一遍占用检查:word="ABCB"

从 (0,0) 的 A 起步,匹配 word[0],(0,0) 标为 "#"
右邻 (0,1) 是 B,匹配 word[1],(0,1) 也标为 "#"
右邻 (0,2) 是 C,匹配 word[2],(0,2) 标为 "#"
第四个字符要找 B:(0,2) 的邻居里,(0,1) 已是 "#"、匹配失败;(1,2) 是 C、(0,3) 是 E,也都失败。
本枝失败,逐层返回并依次恢复 (0,2)、(0,1)、(0,0) 的原字符;其余起点同样拼不出,正确得到 false——而不是回头重用 (0,1) 那个 B。

⚠ 易错坑 失败返回时忘记恢复原字符:失败路径把摸过的格子永久变成 "#"。输入 board=[["A","A"],["A","B"]], word="AAA":从 (0,0) 起步的支路全部失败并污染 (0,0)、(0,1)、(1,0),轮到真正可行的起点 (0,1) 时它已是 "#",输出 false,真值 true(路径 (0,1)→(0,0)→(1,0))。
先递归四邻、后标记当前格(标记时机错):允许路径原地折返。输入 board=[["A","B"]], word="ABA":A→B 后 (0,0) 未被标记,B 的邻居里 A 再次匹配,输出 true,真值 false(只有两个格子,拼不出三步路径)。
越界检查漏掉 row<0/col<0:Python 负下标回绕到最后一行/列。输入 board=[["A"],["X"],["B"]], word="AB":从 (0,0) 向上走到 row=−1,board[-1][0] 读到底行的 B 并"匹配成功",输出 true,真值 false。
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。高频追问一:"为什么每层是 3 不是 4?"——来路那格已被标成 "#"、必然匹配失败,除第一步外每层至多 3 个新方向能走通。追问二:"还能怎么加速?"——搜索前统计字符频次:word 含棋盘没有的字符直接返回 false;若 word 首字符在棋盘中出现次数远多于末字符,把 word 反转再搜,起点数立减。

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 · 思路(暴力→最优)

一句话总纲:维护一个切口位置 start——从 start 出发枚举当前段的右端,这一段是回文才把它压入路径、递归切剩下的后缀;切口推进到串尾就收集一个完整方案,返回时弹出该段、改试更长的段。

暴力起手:字符串有 n−1 个间隙,每个间隙切或不切,2^(n−1) 种完整切法,逐一生成后再验每段回文。慢的根源:第一段就非法的方案(如 "ab|…")后面无论怎么切都白搭,但暴力仍会把这个坏开头的全部后续切法生成完、再逐个否掉——同一个非法前缀被重复宣判指数次。

① 关键转化——枚举对象从"间隙"换成"当前段":治"坏开头拖累全树"的办法是尽早检查:一段一段地切,只有当前段是回文才继续往下切。递归状态只有一个切口位置 start:从 start 出发枚举当前段右端 end(闭区间 [start, end]),是回文就把该段压入 path、递归处理剩余后缀 build(end+1)。这正是 LC78 子集的 start 骨架:那边 path 装元素、这边 path 装子串;非法段在第一次出现时就整棵剪掉。

② 身份表(两处 +1 是最高频笔误,先钉死):

身份表:切割回溯中的下标
代码里的量它是谁注意
start下一段的起点=已切定部分的总长start==length 即"切口恰好推到串尾",方案完整
end当前段的右端Python 切片右开,取段要写 s[start:end+1]
build(end+1) 的实参下一段起点=当前段右端的右邻两处 +1 同源:都因为 end 是闭端点
path已确认的回文段列表收集时照旧拷贝 path[:]

③ 常见困惑——跳过非回文段,会不会误剪掉可行方案?不会。continue 跳过的是"把非回文的 s[start:end+1] 当作一段"的所有方案;这一段本身已非法,后面切得再好也救不回来,剪掉的全是死枝。但它只对当前这个 end 生效"ab" 非回文不代表更长的 "aba" 也非回文,所以是 continue 继续试更长的段,绝不能 break。

④ 为什么不重不漏:一个完整切分方案唯一由它的切口序列决定;递归按"第一段右端、第二段右端…"逐层枚举,每个切口序列恰对应树上一条根到叶(start==length)的路径,一一对应故不重不漏。也因此收集时只需判 start==length——各段回文早在入栈时验过了。

⑤ 用示例 1 走一遍:s="aab"

根层 start=0:end=0 切出 "a",是回文,path=["a"],递归到 start=1。
start=1:end=1 切出 "a",是回文,path=["a","a"],递归到 start=2。
start=2:end=2 切出 "b",是回文,递归到 start=3——切口到达串尾,收集 ["a","a","b"]
逐层 pop 回到 start=1:end=2 切出 "ab",非回文,跳过;该层枚举完,pop 掉首段 "a" 回到根层。
根层 end=1 切出 "aa",是回文,再接 "b" 到串尾,收集 ["aa","b"]
根层 end=2 的 "aab" 非回文——共 2 个方案,与示例一致。

⚠ 易错坑 切片忘 +1 写成 s[start:end]:输入 "aab",end=start 时切出的全是空串,会收集到 ["","",""] 这类方案,真值 [["a","a","b"],["aa","b"]]
递归传 end 而非 end+1:切口原地不动。输入 "a"build(0) 无限自我调用,RecursionError 栈溢出,真值 [["a"]]
非回文时 break(照搬 LC39 的排序剪枝):回文性对段长不单调。输入 "aba":end=1 的 "ab" 非回文即 break,end=2 的 "aba" 再也试不到,输出 [["a","b","a"]],真值还含 ["aba"]
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)。高频追问一:"预处理怎么做?"——按区间长度递推 dp[i][j] = (s[i]==s[j]) and dp[i+1][j-1],O(n²) 建表后搜索中查表即可;n≤16 时现场双指针也够。追问二:"总复杂度能低于 O(2ⁿ) 吗?"——不能:全同字符(如 "aaaa")每段都是回文,方案数就是 2^(n−1),输出规模即下界;但若只问"最少切几刀",答案唯一,换区间 DP 可到 O(n²)。

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 · 思路(暴力→最优)

一句话总纲:逐行放皇后——第 row 层只给第 row 行挑一列,用三份占用记录(列、主对角线、副对角线)O(1) 判冲突;放满 n 行就按各行列号生成一张棋盘收集,返回时把路径和三份记录一起撤销。

暴力起手:从 n² 个格子里任选 n 个再验攻击关系,候选 C(n²,n)——n=8 时约 4.4×10⁹。慢的根源有二:冲突要到选完才检查,前两枚皇后就互相攻击的摆法仍被展开到底;而且完全没用上"每行必须恰好一个"这条结构信息。

① 结构化建模——按行决策,砍掉一整维:n 个皇后互不攻击 ⟹ 每行至多一个;又要放满 n 个、恰有 n 行 ⟹ 每行恰好一个。于是决策树定型:第 row 层给第 row 行选一列,深度=行号,选择列表=n 个列号。同行冲突从"要检查的条件"变成"结构上不可能"。剩下的约束只有列与两条对角线——本质上这是 LC46 全排列(列号的排列,列不能重用)再叠两条对角线约束。

② 对角线为什么是 row−col 与 row+col:沿主对角线(↘)走一步,行列同加 1,row−col 不变;沿副对角线(↙)走一步,行加 1 列减 1,row+col 不变。所以同一条主对角线上的所有格子共享同一个 row−col 值、同一条副对角线共享同一个 row+col 值——差与和就是两族对角线各自的编号,各用一个 set 记录已占用,冲突检查从"逐对皇后比较"降到 O(1) 查询。row−col 可以为负,set 存负数毫无问题,不必偏移。

③ 身份表(撤销要同步回滚 4 样东西):

身份表:四份占用记录与各自的撤销动作
代码里的量它是谁撤销动作
queenspath 本体:queens[row]=该行皇后列号,也是终局生成棋盘的蓝图pop()
used_columns已被占用的列remove(col)
used_main_diagonals已被占用的主对角线(键:row−col)remove(row−col)
used_anti_diagonals已被占用的副对角线(键:row+col)remove(row+col)

④ 常见困惑——为什么不用检查行冲突?因为"每行恰一个"是建模时定死的:一层递归只放一枚、放完就进入下一行,"同一行放第二枚"在决策树里没有对应路径。这是回溯设计的通用技巧:能编进树结构的约束,就不要留到运行时检查

⑤ 用 n=4 走一遍冲突判定:

第 0 行放列 0:columns={0},main={0−0=0},anti={0+0=0}。
第 1 行试列 0:列 0 已在 columns,拒绝。
试列 1:列不冲突,但主对角线键 1−1=0 已被占——(0,0) 与 (1,1) 正是同一条主对角线,O(1) 拒绝,与棋盘直觉一致。
试列 2:列 2、主键 1−2=−1、副键 1+2=3 都未被占用,放下并进入第 2 行。
每次判定只查三个 set,代替了"与前面每枚皇后逐对比较"。

⚠ 易错坑 撤销不全(如漏写 used_columns.remove(col)):失败分支泄漏的占用永久堵路。输入 n=4:第 0 行列 0 的失败子树把探索过的列全部留在记录里,而两个真解 [1,3,0,2][2,0,3,1] 都需要用到已泄漏的列 0,输出 [],真值 2 个方案。
两条对角线用了同一种键(如主对角线也误用 row+col):主对角线冲突查不出来。输入 n=2:(0,0) 与 (1,1) 的 row+col 分别为 0、2,误判无冲突,输出 [["Q.",".Q"]] 这个非法方案,真值 []
若改用逐对循环判对角线、条件忘加绝对值(写成 r1−r2 == c1−c2):副对角线漏判。输入 n=2:(0,1) 与 (1,0) 的差是 −1 与 1,不相等即误判合法,输出 [[".Q","Q."]],真值 []
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²) 的棋盘不计入额外空间。高频追问一:"上界为什么是 n! 不是 nⁿ?"——列不能重用,第 k 行可选列至多 n−k 个,纯列约束下的叶子恰是 n! 个列排列;对角线剪枝再砍掉绝大多数(n=8 时解只剩 92 个)。追问二:"还能更快吗?"——位运算版:三个 set 换成三个整数位掩码,available = ~(cols | ld | rd) & full_mask 一次算出本行全部安全列,再逐次取最低位 1 展开;渐进不变、常数大降,是本题的标准进阶答案。

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 的位置——这一格既是命中下标也是插入点。正确但 O(n)。慢的根源:数组明明有序,每次比较却只排除 1 个元素;有序意味着看一眼中间元素就能知道答案在哪一半,线性扫描把这个信息整个浪费了。

约束排除法:题目硬性要求 O(log n),等于宣判一切"逐个看"的做法出局——每一步必须扔掉一半候选,剩下的路只有二分。这也是整个二分专题的入口信号:看到"有序 + O(log n)",先想二分。

① 关键转化——两问合成一问:"存在返回下标"和"不存在返回插入点"看似两种情况,其实是同一个问题:找第一个 ≥ target 的位置(lowerBound)。数组无重复:target 存在时,第一个 ≥ target 的位置恰是它本人;不存在时,第一个 ≥ target 的位置正是"塞进去仍保持升序"的那一格。一次搜索两种情况通吃——这个函数在 LC34 会被原封不动复用两次。

② 判定的单调性——二分为什么可行:沿升序数组逐格问"nums[i] >= target?",回答序列必形如 F F … F T T … T,只从 False 翻转到 True 一次。答案=第一个 T。二分要收缩的就是这条 F/T 分界线。

③ 身份表——lo 和 hi 各守什么承诺:

lowerBound 的区间不变量:两道哨各守一边,答案被夹在中间
代码里的量它是谁守着什么承诺
loFalse 区的右前线下标 < lo 的位置已确认 nums < target,绝不可能是答案
hiTrue 区的左前线下标 ≥ hi 的位置已确认 nums ≥ target;初值 n="右侧为空、暂无确认",同时让"插到末尾"保持为合法候选
答案第一个 ≥ target 的位置始终被夹在 [lo, hi] 里;循环结束 lo==hi,两道前线会师处即答案

④ 收缩规则从承诺推导(保留哪边、为何不死循环):
nums[mid]:若 < target,mid 连同左侧全部确认不合格,lo=mid+1 放心跨过;
≥ target,mid 合格但可能正是第一个合格者,绝不能扔——只能 hi=mid,不是 mid-1。两个分支天生不对称,对称写必错(坑②)。
死循环风险自查:mid=(lo+hi)//2 向下取整,在 lo < hi 时必有 mid < hi,所以 hi=mid 每次都真的变小,循环必然推进;这也解释了循环条件必须是严格的 lo < hi——写 lo <= hi,当 lo==hi==midhi=mid 原地不动,直接卡死(坑③)。

⑤ 常见困惑——初始 hi 为什么取 n 不是 n−1?因为本题的答案空间是"插入位置"而不是"已有元素":n 个元素之间和两端共 n+1 个空位(0..n),n 本身(插到末尾)就是合法答案。hi 的承诺是"下标 ≥ hi 已确认合格",初值 n 恰好表示"还没排除任何位置";写 n−1 等于开局就把"插到末尾"开除了(坑①)。

⑥ 样例验证:[1,3,5,6], target=2[0,4) mid=2(5≥2,保留)→ [0,2) mid=1(3≥2,保留)→ [0,1) mid=0(1<2,排除)→ [1,1),lo==hi==1。自查承诺:左边只有 1(<2),从 1 号位起全 ≥2,插在 1 恰好保持升序 ✓。

⚠ 易错坑 初始 hi=n-1:反例 [1,3,5,6], target=7——lo=0,hi=3 → mid=1(3<7)→lo=2 → mid=2(5<7)→lo=3,收敛返回 3;真值 4(插到末尾)。
两分支对称写 hi=mid-1:反例 [1,3,5,6], target=5——mid=2 命中 5 却被 hi=1 扔掉,之后 mid=0(1<5)→lo=1,收敛返回 1;真值 2。答案一旦被跳过就再也找不回来。
while lo <= hihi=mid → 死循环:反例 [1], target=0——第一轮 hi=0 后 lo=hi=mid=0,每轮判 1≥0 → hi=0 原地踏步,永远卡在 lo=hi=mid=0。
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 轮;只用常数变量。高频追问一:"数组含重复值这套写法还对吗?"——对,lowerBound 天然返回最左的 ≥ target 位置,语义不变,这正是 LC34 直接复用它找左边界的原因。追问二:"mid=(lo+hi)//2 会溢出吗?"——Python 大整数不会;Java/C++ 里 lo+hi 可能超 int 上限,要写 lo+(hi-lo)/2

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 · 思路(暴力→最优)

一句话总纲:先把矩阵按行首尾相接看成一个一维升序数组;然后在虚拟下标 0..m·n−1 上做一次标准二分,每轮用"除以列数、模列数"把虚拟下标翻译回行列去读值;读到 target 返回 True,区间缩空返回 False。

① 暴力起手 + 慢的根源:逐行逐格扫,O(m·n),每次比较只排除 1 个格子。升级一档:每行内部有序,可以逐行二分,O(m·log n)——但这只用了"行内有序",而"下一行开头比上一行结尾还大"这个更强的条件仍然闲置。条件给得这么强,就是出题人画的箭头:别把它当矩阵看。

② 关键转化——它是"折行显示"的一维升序数组:每行升序 + 行间首尾衔接,意味着把各行按顺序接起来是一个完整升序序列:1,3,5,7,10,11,16,20,23,30,34,60。矩阵只是这个一维数组排版时的折行。于是问题退化成 LC35 同款的一维有序查找,一次二分 O(log(m·n))——而且不用真的展平复制,只需做下标翻译。

③ 翻译公式从直觉推导:虚拟下标 mid 前面铺了 mid 个元素,每行装 col_count 个,所以已铺满 mid // col_count 个整行(=行号),余数 mid % col_count 是行内偏移(=列号)。口诀:一行装几个,就按几折行——除数和模数都是列数。最高频的误代入是拿行数当除数,见坑①。

④ 身份表:

展平二分中各量的身份:虚拟一维下标与真实行列的对应
代码里的量它是谁提供什么
mid虚拟一维下标在"接起来的升序序列"里的位置;大小比较都在它对应的值上做
row = mid // col_countmid 前面铺满的整行数真实行号
col = mid % col_count不满一行的零头真实列号
lo, hi虚拟一维闭区间两端target 若存在,它的虚拟下标必在 [lo,hi] 内;区间缩空(lo>hi)=确认不存在

⑤ 常见困惑——为什么这里用 while lo <= hi + 两侧都 ±1,LC35 却用 lo < hi + hi=mid目标不同:本题是等值查找——命中当场 return,判完不等的 mid 可以彻底丢弃(两侧收缩都跨过 mid),区间也允许缩成空(空=不存在,返回 False)。LC35 找的是边界——mid 可能就是答案,必须 hi=mid 保留。循环条件、收缩步长、收敛形态是配套整体,拆开混搭要么丢答案要么死循环(坑②)。

⑥ 备选视角——右上角阶梯(为 LC240 铺路):不展平的话,站上右上角 (0, col_count-1):它是本行最大、本列最小,两个方向单调性相反,于是一次比较必能排除一整行或一整列——当前值 > target 就左移(本列下方只会更大,整列出局);< target 就下移(本行左侧只会更小,整行出局)。O(m+n)。本题整体有序,展平二分更快;但阶梯法只依赖"行、列各自有序",在不能展平的 LC240 里它才是主角。

⑦ 样例验证(原题示例 1,查 target=3):
第 1 轮:区间 [0,11],mid=5 → divmod(5,4)=(1,1),读到 11 > 3 → hi=4。
第 2 轮:区间 [0,4],mid=2 → (0,2),读到 5 > 3 → hi=1。
第 3 轮:区间 [0,1],mid=0 → (0,0),读到 1 < 3 → lo=1。
第 4 轮:区间 [1,1],mid=1 → (0,1),读到 3,命中,返回 True ✓。
翻译公式在 mid=5 处自查:前面 5 个元素铺满 1 整行余 1 个,正是第 1 行第 1 列的 11 ✓。

⚠ 易错坑 除数写成行数:divmod(mid, row_count)。反例:本题 3×4 矩阵查 60——mid=5 → (1,2)=16<60 → lo=6;mid=8 → (2,2)=34<60 → lo=9;mid=10 → divmod(10,3)=(3,1),第 3 行不存在,直接 IndexError 崩溃。
模板混搭死循环:保留 while lo <= hi 却把 hi=mid-1 写成 hi=mid。反例:查 target=0(比全体都小)——hi 沿 5→2→1→0 一路缩到 0 后,lo=hi=mid=0,每轮判 1>0 → hi=0 原地不动,死循环卡死在 lo=hi=mid=0。
先取 len(matrix[0]) 再判空:matrix=[]matrix[0] 直接 IndexError,空行 [[]] 则列数为 0 无格可查。必须先判 not matrix or not 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),每步排除一行或一列。高频追问一:"若只保证每行、每列各自升序(LC240),还能 O(log(m·n)) 吗?"——不能:行间不衔接,拼接后不是全局有序序列,展平二分的判据失效,改用阶梯法 O(m+n)。追问二:"阶梯法为什么必须从右上(或左下)角出发?"——左上、右下两个方向单调性相同(往两个方向都变大或都变小),一次比较排除不了任何行列,走不动。

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,再从命中点向左右逐格扩散找边界。命中是 O(log n),扩散却是线性的——数组全是 target 时(如 [8,8,…,8])要扩散 n 格,整体退化 O(n)。慢的根源:二分只回答了"有没有","边界在哪"仍靠逐格挪——关键的那一半问题根本没用上二分。

约束排除法:题目点名 O(log n),判处"命中后扩散"出局——边界必须直接用二分定位,而不是命中之后再找。

① 关键转化——两个边界=两次 lowerBound 询问:LC35 写好的 lower_bound(x)(第一个 ≥ x 的位置)就是现成的边界探测器:
左边界:开始位置=第一个 ≥ target 的位置=lower_bound(target)
右边界:结束位置="最后一个 ≤ target"="第一个 > target"的前一格;而整数世界里"第一个 > target"就是"第一个 ≥ target+1"=lower_bound(target+1)
同一个函数换个阈值调两次,全程对数级。

② 身份表——同一个 helper,两次返回值身份不同:

两次 lower_bound 返回值的身份:一个要验证,一个要减一
代码里的量它是谁要注意什么
lower_bound(target) 返回值第一个 ≥ target 的位置只是"候选"开始位置——可能停在更大的数上,也可能 ==n(全体都 < target);验证通过才配叫左边界
lower_bound(target+1) 返回值第一个 > target 的位置=右侧哨兵站在答案区间外面右边一格,减 1 才是右边界;它本身永远不是 target
helper 内部 lo/hi同 LC35 的两道前线lo 左侧已确认 < 阈值;hi 起已确认 ≥ 阈值;收敛处即第一个合格位置

③ 常见困惑——lower_bound(target) 停的位置一定是 target 吗?不一定。它的承诺只是"左边全 < target、自己 ≥ target":数组里没有 target 时它停在第一个更大的数上,甚至越界到 n。所以当左边界用之前必须验证两件事:left < nnums[left] == target,顺序还不能反——先判越界再取值(坑③)。反过来,验证一旦通过,右边界的减 1 就绝对安全:至少这一个 target 站在 lower_bound(target+1) 的左侧,减 1 后不会越出答案区间。

④ 样例验证:[5,7,7,8,8,8,10] 查 8。lower_bound(8):mid=3(8≥8)→hi=3;mid=1(7<8)→lo=2;mid=2(7<8)→lo=3 ⇒ left=3,验 nums[3]=8 ✓。lower_bound(9):mid=3(8<9)→lo=4;mid=5(8<9)→lo=6;mid=6(10≥9)→hi=6 ⇒ 哨兵=6,right=6−1=5。答案 [3,5]——三个 8 恰好占满下标 3..5 ✓。

⚠ 易错坑 忘了 −1,直接拿 lower_bound(target+1) 当右边界:反例 [8], target=8——lower_bound(9)=1,返回 [0,1],可下标 1 已经越界;真值 [0,0]。哨兵站在区间外面,必须减 1 才回到区间内。
漏存在性验证:反例 [5,7], target=6——lower_bound(6)=1、lower_bound(7)=1,直接组装返回 [1,0](左比右还大的荒谬区间);真值 [-1,-1]。
验证顺序写反:if nums[left] != target or left == n。反例 [5,7], target=9——lower_bound(9)=2==n,先取 nums[2] 直接 IndexError;必须靠短路先判 left == n 再取值。
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);常数空间。高频追问一:"数组全是 target 会退化吗?"——不会:lowerBound 不依赖命中后扩散,[2,2,2] 查 2 依旧对数轮收敛,这正是它相对"命中+扩散"的本质优势。追问二:"固定宽度整数语言里 target+1 会溢出吗?"——target==INT_MAX 时会;那就单写一个 upperBound(判定用 > target 而不是 ≥ target+1)绕开加一,Python 无此顾虑。

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 · 思路(暴力→最优)

一句话总纲:每轮三步——先看中点值 nums[mid] 是不是 target;不是,就比较 nums[lo]nums[mid] 找出完整有序的那半边;再用有序半边的值域判断 target 在不在其中,决定往哪半收缩。区间缩空仍没找到,返回 −1。

① 暴力起手 + 慢的根源:线性扫 O(n),每次比较排除 1 个元素。直接套标准二分行不通:旋转破坏了全局有序,nums[mid] 和 target 比大小不再指路——[4,5,6,7,0,1,2] 查 0,mid 值 7 > 0,标准二分说"往左",可 0 在右边。

② 约束排除法:O(log n) 要求钉死了必须二分,也就必须找回"一次比较可靠排除一半"的判据。而旋转的破坏是有限的:整个数组只被切了一刀。任取 mid 一分为二,这道切口只会落进其中一半——另一半必然完整有序。这就是出题人留的活路。

③ 关键转化——二分的本质不是"数组有序",而是"每轮能可靠排除一半":无序的那半看不懂没关系,有序半的值域是一段明确区间,能 O(1) 判断 target 在不在里面:在 → 收缩进有序半(退化成 LC35 标准二分);不在 → target 只可能在另一半,整个有序半出局。无论走哪个分支,每轮都稳定砍半。

④ 每轮固定三步(顺序不可换):
第一步 · mid 自查:nums[mid]==target 直接返回——后面所有值域判断都建立在"mid 不是答案"上;
第二步 · 找出有序半边:比较 nums[lo]nums[mid]nums[lo] <= nums[mid] ⇒ 左半 [lo..mid] 完整有序(切口不在其中);否则切口在左半,右半 [mid..hi] 完整有序;
第三步 · 判归属再收缩:只在有序半边上做值域判断,决定保留哪半。

⑤ 身份表:

三类量的身份:nums[lo] 与 nums[mid] 的比较不找答案,只判断哪半有序
代码里的量它是谁提供什么
lo, hi闭区间两端target 若存在必在 [lo,hi] 内;缩空(lo>hi)=确认不存在,返回 −1
nums[lo] vs nums[mid]判断"哪半有序"的一次比较只回答左半是否完整有序,与 target 无关——这次比较不找答案
有序半边的两端值该半边值域的上下界全数组唯一能判断"target 在不在这段"的依据;无序半边永远不做值域判断

⑥ 值域开闭从"mid 已排除"推导:左半有序时判 nums[lo] <= target < nums[mid]——左端带等号,因为 lo 还没被验过,target 可能就是 nums[lo];右端严格小于,因为 target==nums[mid] 已在第一步返回,此处不可能。右半对称:nums[mid] < target <= nums[hi]

⑦ 常见困惑一——判断左半有序的比较为什么带等号(<=)?区间只剩 2 个元素时 mid==lo,此时 nums[lo]==nums[mid] 是同一个数,"左半"只剩 mid 一个元素——单元素天然有序,必须走"左半有序"分支。写成严格 < 会把这一幕误判成"右半有序",见坑①的 [3,1] 反例。

⑧ 常见困惑二——target 不在有序半值域就转向另一半,万一另一半也没有呢?这不是赌:两半合起来是全部候选,"确认不在有序半"逻辑上就只剩另一半,这是排除法不是猜测。target 真不存在时,区间会一路缩空,lo > hi 退出返回 −1,不需要任何额外判断。

⑨ 样例验证(原题示例 1,[4,5,6,7,0,1,2] 查 0):
第 1 轮:区间 [0,6],mid=3,nums[3]=7 ≠ 0;nums[0]=4 ≤ 7 ⇒ 左半(值 4..7)有序;4≤0<7 不成立 ⇒ 0 只能在右半,lo=4。
第 2 轮:区间 [4,6],mid=5,nums[5]=1 ≠ 0;nums[4]=0 ≤ 1 ⇒ 左半(值 0..1)有序;0≤0<1 成立 ⇒ hi=4。
第 3 轮:区间 [4,4],mid=4,nums[4]=0 命中,返回 4 ✓。

⚠ 易错坑 判断有序半边时漏等号,写 nums[lo] < nums[mid]:反例 [3,1] 查 1——mid=0,3<3 不成立被误判"右半有序",再判 3<1≤1 也不成立 → hi=−1 退出返回 −1;真值 1(下标 1)。
值域左端漏等号,写 nums[lo] < target:反例 [4,5,6,7,0,1,2] 查 4——第一轮左半有序但 4<4 不成立,误转右半 lo=4,从此 4 被永久排除,最终返回 −1;真值 0。target 恰好是区间端点时最容易踩。
省掉第一步 mid 自查:反例 [1] 查 1——左半"有序",值域判 1≤1<1 不成立 → lo=1 退出返回 −1;真值 0。mid 两侧的值域判断天生不覆盖 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)为什么会退化?"——nums[lo]==nums[mid]==nums[hi] 时(如 [1,1,1,0,1])三个探针值相同,无法判断哪半有序,只能 lo+=1、hi−=1 逐格缩,最坏 O(n);本题元素互异保证每轮判定唯一。追问二:"能不能先用 LC153 找到旋转点,再挑对应有序段做标准二分?"——可以,两次二分仍是 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 · 思路(暴力→最优)

一句话总纲:先认清旋转数组的形状——前后两段各自升序、前段整体大于后段,最小值就是后段的第一个元素;然后拿候选区间右端的值 nums[hi] 当参照,每轮比较 nums[mid] 与它,判断 mid 落在前段还是后段,据此收缩区间;lo==hi 时收敛的位置就是最小值。

① 核心洞察——旋转只造出两段,一次比较就能定段:原升序数组从某处切一刀、把尾部接到前面,得到的形状是两段拼接:前一段(原数组的尾部)整段值更大,下称大值段;后一段(原数组的头部)以全局最小值开头、整段被压低,下称小值段。大值段每个元素都 > 小值段每个元素,最小值=小值段第一个元素。暴力 min(nums) 或线性扫 O(n) 慢就慢在:逐个看元素,把"只有两段、段位一比便知"这条结构信息全丢了。

② 约束排除法:O(log n) ⇒ 必须二分。但这题没有 target 可比——而二分真正需要的从来不是 target,是一个能把候选分成"左边全不合格、右边全合格"的单调判定。没有外部目标,就从数组内部找参照物。

③ 关键转化——用区间右端的值当参照:拿候选区间右端的 nums[hi] 来比:它必定停在小值段(这条不变量在身份表里说明),于是 nums[mid] > nums[hi] ⇔ mid 在大值段 ⇔ 最小值严格在 mid 右侧。沿数组从左到右逐格问"这个位置在小值段吗?",回答形如 F…F T…T 只翻转一次——这就是 LC35 lowerBound 的同款骨架,只是"合格"从"≥ target"换成了"≤ 参照值"。

④ 身份表:

lo、hi 与参照值的身份:hi 既是候选右端,又提供参照值
代码里的量它是谁全程保持的性质
lo已排除部分的右边界下标 < lo 的位置已确认在大值段,都不是最小值
hi候选区间右端,同时提供参照值 nums[hi]最小值必在 [lo,hi] 内;且 nums[hi] 永远停在小值段——初始 nums[n-1] 就是小值段末尾,之后 hi 只移到"已确认在小值段"的 mid 上
nums[mid] vs nums[hi]判断 mid 在哪一段的一次比较> ⇒ mid 在大值段(不是最小值);< ⇒ mid 在小值段(可能正是最小值本人;无重复不会相等)

⑤ 常见困惑——参照为什么必须是 nums[hi] 而不是 nums[lo]因为 nums[lo] 所在的段不稳定:数组未旋转时它是全局最小(在小值段),旋转后它是大值段开头——同一个比较结果对应两种相反的局面,判定失效(坑①)。而 nums[hi] 从头到尾都在小值段(见④的不变量)。单调判定能成立,靠的就是参照值全程停在同一段。

⑥ 收缩规则从段位推导:
nums[mid] > nums[hi]:mid 确认在大值段,本人不合格,lo=mid+1 放心跨过;
nums[mid] <= nums[hi]:mid 在小值段,段内升序意味着 mid 右侧同段元素都比它大——最小值在 mid 或其左,hi=mid 必须保留 mid,减一就可能把答案扔掉(坑②);
配套 while lo < hi:向下取整保证 mid < hihi=mid 必推进;写 <= 则 lo==hi 时 hi=mid 原地卡死(坑③)。收敛处 lo==hi 即旋转点。

⑦ 样例验证(原题示例 2,[4,5,6,7,0,1,2]):
第 1 轮:区间 [0,6],mid=3,nums[3]=7 > nums[6]=2 ⇒ mid 在大值段,lo=4。
第 2 轮:区间 [4,6],mid=5,nums[5]=1 < nums[6]=2 ⇒ mid 在小值段,hi=5。
第 3 轮:区间 [4,5],mid=4,nums[4]=0 < nums[5]=1 ⇒ mid 在小值段,hi=4。
lo==hi==4,返回 nums[4]=0 ✓。
全程 hi 停过的位置上 nums[hi] 依次是 2、1、0,始终在小值段——参照从未失效。

⚠ 易错坑 参照物用 nums[lo](判 nums[mid] >= nums[lo] 就往右):反例未旋转数组 [1,2,3]——mid=1,2≥1 → lo=2,收敛返回 nums[2]=3;真值 1。
小值段分支写 hi=mid-1:反例 [3,1,2]——mid=1,1≤2 → hi=0,收敛返回 nums[0]=3;真值 1。mid 自己可能就是最小值,不能跨过。
while lo <= hihi=mid → 死循环:反例 [2,1]——第一轮 mid=0,2>1 → lo=1;此后 lo=hi=mid=1,每轮 nums[1]>nums[1] 不成立 → hi=1 原地踏步,永远卡在 lo=hi=mid=1。
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)怎么办?"——nums[mid]==nums[hi] 时段位不明([1,0,1,1,1] 最小值在 mid 左侧,[1,1,1,0,1] 却在右侧),只能保守地 hi−=1 缩一格,最坏 O(n)。追问二:"要旋转点下标而不是最小值?"——收敛时的 lo 就是旋转点下标(也是旋转次数 mod n),可直接支撑 LC33 的"先找拐点、再分段二分"替代解法。

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 · 思路(暴力→最优)

一句话总纲:先把"找中位数"转成"找一条分割线":它把两个数组的全部 m+n 个数切成规模固定的左右两半,且左半整体 ≤ 右半整体;这条分割线在短数组上的落点可以二分——每个候选位置只读 4 个边界值,就能判断"切多了 / 切少了 / 正好";找到合法分割后按总长奇偶读出中位数。

① 暴力起手 + 慢的根源:双指针归并,数到第 (m+n)//2 个元素即可(甚至不用真的合并出新数组),O(m+n)。慢的根源:每步只推进 1 个元素——有序数组本可以"整块跳过",逐个数把这个能力浪费了。

② 约束排除法:题目点名 O(log(m+n)),判处一切线性归并出局,必须二分。可二分什么?对中位数的做值域二分再数排名也行,但绕远。出题人真正指的路:中位数由"一条分割线"唯一确定,而分割线的位置是可以折半枚举的。

③ 关键转化——找数变成找分割线:中位数的本质:把全体 m+n 个数分成左右两半,左半规模固定left_size=(m+n+1)//2,且左半最大 ≤ 右半最小;奇数总长取左半最大,偶数取(左半最大+右半最小)/2。这条分割线落到两个数组上是两个分割点 short_cutlong_cut,但两者之和恒等于 left_size——只有一个自由度。于是问题退化成:在短数组 0..m 共 m+1 个候选分割点里,找让"左右交叉有序"成立的那一个;验证一个分割点只看 4 个数,O(1)。

④ 为什么能二分——"切多了"沿分割点单调:short_cut 右移一格,短数组左半多收一个更大的数(short_left_max 升),同时 long_cut 左移一格,长数组右半的门槛降低(long_right_min 降)——所以"切多了"(short_left_max > long_right_min)一旦发生,再右移只会更严重;对称地,"切少了"(long_left_max > short_right_min)只在左侧发生。两种失败各占一头,合法分割点被夹在中间:切多 → hi=short_cut-1;切少 → lo=short_cut+1

⑤ 身份表:

分割点二分中各量的身份:只有 short_cut 是自由变量
代码里的量它是谁要注意什么
short_cut短数组的分割点=短数组给左半贡献的个数唯一的自由变量,候选 0..m 共 m+1 个(可以一个不给,也可以全给)
long_cut = left_size − short_cut长数组的分割点被 short_cut 唯一决定的从动量,不独立二分;在短数组上二分时它天然落在 [0,n] 内
short_left_max / short_right_min短数组分割点两侧的紧邻值分割点贴边时以 ∓inf 哨兵顶替="这一侧为空,不构成约束"
long_left_max / long_right_min长数组分割点两侧的紧邻值同上
交叉判据short_left_max ≤ long_right_minlong_left_max ≤ short_right_min只查交叉——同数组内部天然有序免检;两条同时成立 ⇔ 左半整体 ≤ 右半整体

⑥ left_size 的 +1 从奇偶统一推导:想让奇数总长时"中位数=左半最大",左半就得比右半多 1 个:(m+n+1)//2 恰好做到(m+n=5 ⇒ 左 3 右 2,排第 3 的就是中位数);偶数时 +1 被整除吞掉,左右各半。于是奇偶共用同一套边界读取,只在最后返回时分叉。去掉 +1 也能写对,但奇偶取值公式要整套跟着改——别混搭(坑②)。

⑦ 常见困惑——为什么必须在较短的数组上二分?不只是 O(log min(m,n)) 更快,更是合法性:short_cut 在 [0,m] 内任取时,long_cut=left_size−short_cut 恰好被夹在 [0,n] 内(由 m ≤ n 保证);反过来在长数组上二分,从动的分割点可能算出负数——见坑①的越界反例。交换让"从动量永不越界"成为结构保证,而不是运气。

⑧ 样例验证(原题示例 1,nums1=[1,3]nums2=[2]):
准备:len(nums1)=2 > len(nums2)=1,先交换——短数组 [2](m=1),长数组 [1,3](n=2),left_size=(1+2+1)//2=2,候选 short_cut∈[0,1]。
第 1 次:short_cut=0,long_cut=2:分割 ∅|21,3|∅;short_left_max=−∞,short_right_min=2,long_left_max=3,long_right_min=+∞。
交叉检查:long_left_max=3 > short_right_min=2 ⇒ 切少了 ⇒ lo=1。
第 2 次:short_cut=1,long_cut=1:分割 2|∅1|3;交叉 2≤3 ✓、1≤+∞ ✓ ⇒ 合法。
总长 3 是奇数,返回左半最大 max(2,1)=2 ✓(合并 [1,2,3] 居中正是 2)。偶数取平均的走法见段3 图解的 [1,3][2,4,5,6]

⚠ 易错坑 不先交换、在长数组上二分:反例 nums1=[1,2,3,4,5]、nums2=[6]——left_size=3,第一轮 cut=2 判"切少"后推进到 cut=4,另一侧分割点=3−4=−1,读 nums2[-2]:本例 n=1 直接 IndexError;n 稍大时 Python 负下标不报错、静默取错值,更难察觉。
left_size 忘 +1 却仍按"奇数取左半最大"返回:反例 [1,3],[2]——交换后短数组为 [2],left_size=(3)//2=1,short_cut=0、long_cut=1 即合法(−∞≤3、1≤2),返回左半最大 1;真值 2(合并 [1,2,3] 居中是 2)。
分割点贴边漏哨兵:short_cut=0 时读 nums1[-1],Python 静默返回末元素而不报错。反例 nums1=[7]、nums2=[1,2,3]——正确答案就在 short_cut=0(分割 ∅|71,2|3,中位数 2.5),漏哨兵时 short_left_max 被错读成 7,7>3 被误判"切多了" → hi=−1,合法分割永远找不到。
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 处理边界,无额外空间。高频追问一:"能推广到'两个有序数组第 k 小'吗?"——能:中位数只是 k=(m+n+1)//2 的特例;通用解法每轮比较两数组各自第 k//2 个元素,把较小一方的前 k//2 个整块砍掉,O(log(m+n))。追问二:"为什么比题目要求的 O(log(m+n)) 还快?"——二分对象是短数组的 m+1 个分割点位置,与 n 无关,所以是 O(log min(m,n))。

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²)。慢的根源:外层左括号在等内层消完的期间被一遍遍重扫——"现在还有哪些左括号在等着闭合、最近的是谁"这份信息每轮都被丢掉重算。

① 关键转化——右括号只认"最近未闭合"的左括号:合法性规则可以压缩成一句话:每个右括号必须闭合最近一个尚未闭合的左括号,且类型一致。"最近打开的最先被关闭"正是后进先出——把"尚未闭合的左括号"存进栈,栈顶天然就是"最近未闭合"。问题退化成一遍扫描:左括号入栈登记,右括号与栈顶对消。

② 各个量的身份:

身份表:一遍扫描中每个量在题里扮演谁
代码里的量它是谁提供什么 · 不管什么
stack仍未闭合的左括号清单,栈顶=最近一个告诉下一个右括号"你该配谁";已配对成功的字符与它无关
matching_left 映射key=右括号,value=它要求的左括号方向是"右找左";左括号不是 key,查不到,走入栈分支
stack.pop()被当前右括号结算的最近左括号弹出即配对;类型不符立即整体失败
收尾 return not stack终局判定栈空=所有左括号都闭合;非空=有左括号没等到右括号

③ 判定式从直觉推导:右括号失败只有两种方式——没人可配(栈空)或配错了人(栈顶类型不符),所以一行 if not stack or stack.pop() != matching_left[char] 恰好覆盖全部失败分支;or 的短路保证栈空时右半句的 pop 根本不会执行,不会抛异常。注意这一行只有右括号才走得到——左括号在上一个分支已经 append + continue

④ 常见困惑——栈中途变空会不会误判?比如 "[]()":前两个字符对消后栈已空,第三个字符 ( 会不会撞上 if not stack 被误判 False?不会。( 不在 matching_left 里(key 全是右括号),命中 not in 分支直接入栈 continue,根本走不到判空那一行。空栈不是错误状态,它只表示"此刻没有待闭合的括号";唯一的错误是"右括号来了、栈却是空的"。判空是给右括号的保护,不是给全体字符的门槛。

⑤ 用样例验证:"[]()"[ 入栈 → ] 弹出 [ 匹配,栈空 → ( 走入栈分支(不经过判空)→ ) 弹出 ( 匹配 → 收尾栈空,not stack 为 True ✓。中途两次栈空都安然通过。

⚠ 易错坑 映射方向写反(高频 WA 根因):把 key 写成左括号 {"(": ")", …},则左括号命中 key 走 pop 分支。最小反例 "()"——第一个字符 ( 就触发判空返回 False,真值 True。记牢:key=右括号,因为语义是"右括号来了,查它要配的左括号"。
不判空直接 stack.pop(),或把判空写在 pop 之后:最小反例 "]"——空栈 pop 直接抛 IndexError(运行时错误),真值应为 False。not stack 必须写在 or 左侧,靠短路挡住 pop。
扫完直接 return True,忘了收尾 return not stack:最小反例 "("——全程无冲突返回 True,真值 False(左括号没等到闭合)。
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),最坏全是左括号。高频追问一:"能不能 O(1) 空间?"——多种括号不能:必须记住任意深的未闭合顺序,计数器只记数量,反例 "([)]" 每类括号数量都配平却无效。追问二:"只有一种括号呢?"——可以退化成计数器:遇 ( 加一、遇 ) 减一,全程不为负且收尾为 0 即有效——计数器正是"栈大小"的影子,单类型时栈里内容无差别,只剩数量有意义。

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 · 思路(暴力→最优)

暴力起手:push/pop/top 用一个列表本来就是 O(1),唯独 getMin 每次遍历整栈找最小,O(n)。慢的根源:每次查询都从零重算,之前扫描时明明见过全部元素,"这段前缀的最小值是谁"却没有被存下来复用。

① 约束排除法:题目要求每个操作都 O(1),直接杀死"查询时现算"这条路;剩下的唯一方向是把答案在 push 时就预计算好存起来,让 getMin 只做一次读取。

② 第一直觉为什么不行——单变量存不住历史:最自然的做法是"列表 + 一个最小值(或其下标)":push 时新值更小就更新,push 阶段确实 O(1) 维护得住。但 pop 一旦弹走的恰是当前最小值,就需要"上一个最小值"——单个变量早把历史覆盖了,只能 O(n) 重扫。最小反例:push 5, push 2, push 7, pop, pop 之后 getMin 应为 5,而单下标方案还指着已被弹走的 2。看清本质:getMin 要的不是"历史全局最小",而是"当前还留在栈里的元素的最小"——它会随 pop 回退,所以必须存一列历史,而不是一个变量。

③ 关键转化——按前缀预存最小值:栈只从顶部增减,任何时刻栈里的元素恰是一段前缀 values[0..i]。那就给每个前缀预存一个答案:辅助栈 minimums 与主栈等长,minimums[i]values[0..i] 的最小值。于是"当前栈内最小"永远是 minimums[-1];pop 时两栈同步弹一格,露出的新栈顶自动就是上一个前缀的最小——回退零计算。这与前缀和/前缀积(LC53、LC238)同款思路,只是把加法/乘法换成了取 min。

④ 各个量的身份:

身份表:双栈各自负责什么
代码里的量它是谁提供什么 · 不管什么
values真实数据栈承担 push/pop/top;对最小值一无所知
minimums[i]前缀 values[0..i] 的最小值回答"若栈此刻只剩前 i+1 个元素,最小是谁"
minimums[-1]当前栈内最小值getMin 的即取答案,永远与 values 的当前范围对齐
两栈同步 pop撤销一层前缀弹掉的那格连同"它那时的答案"一起作废,新栈顶就是上一个答案

⑤ push 公式从直觉推导:新前缀只比旧前缀多一个元素,所以新前缀的最小值只有两个候选:min(value, minimums[-1]);第一次 push 没有旧前缀,新值自己就是最小。每次 push 都同步压入一格,包括与旧最小相等或更大的值——压的是"这个前缀的答案",不是"新纪录"。

⑥ 常见困惑——minimums 里一堆重复值(如 [-2,-2,-3]),浪费吗?不浪费,这正是"等长同步"的代价与保障:每一格都是对应前缀的完整答案,pop 时才能无脑同步弹、零分支判断。想省空间可用"压缩辅助栈"——只在 value <= minimums[-1] 时压入、弹出值等于辅助栈顶时才同步弹,但条件必须带等号,重复最小值一个都不能漏。

⑦ 用样例验证回退:push 5, push 2, push 7values=[5,2,7]minimums=[5,2,2]pop 两次同步弹到 values=[5]minimums=[5],此刻 getMin()=5 ✓——正是②里单下标方案答错(报 2)的那组输入,历史一列自动回退到位。

⚠ 易错坑 用单变量/单下标记最小(最高频的初始方案):push 维护得住,pop 丢历史。最小反例 push 5, push 2, push 7, pop, pop——getMin 报 2(已被弹走),真值 5。
pop 只弹 values 忘弹 minimums:两栈从此错位。最小反例 push 3, push 1, pop——之后 getMin 报 1,真值 3。
压缩辅助栈用严格 < 才压入:漏掉重复最小值。最小反例 push 2, push 2, pop——第二个 2 没进辅助栈,pop 时辅助栈顶 2 被同步弹掉、辅助栈已空,getMin 无值可读,真值 2。
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),额外一个等长辅助栈。高频追问一:"能不能不开第二个栈?"——可以,差值栈:主栈存 value − 当前最小,负差值标记"这里刷新过最小值",pop 遇负差值时用当前最小反推出上一个最小。本质没变:仍是把"上一个最小值"编码进栈里存历史,只是把两列压成一列,换来边界与溢出处理更繁琐。追问二:"pop 很多次后 getMin 会不会退化?"——不会,回退所需的答案在 push 时已预付,pop 只是揭开上一层,读栈顶永远 O(1)。

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 · 思路(暴力→最优)

直觉起手——单层线性扫描(高频 WA):"遇到数字→把字符收集到 ] 为止→展开→继续",无嵌套时看似可行,一嵌套就错:"3[a2[c]]" 从第一个 [ 收集到第一个 ],拿到的是 "a2[c",直接拿去重复必然输出乱串,真值 "accaccacc"。错的根源:嵌套意味着"后遇到的 [ 必须先处理完",单层扫描分不清哪个 ] 配哪个 [

修正版暴力——反复展开最内层:每轮找一对最内层括号(其中不再含 [)展开替换回原串,结果正确,但每轮整串重扫重建,嵌套深、输出长时开销大。慢的根源:外层"解析到一半"的状态每轮被丢弃重来,没有被保存。

① 关键转化——"后开的先闭合"就是栈:把"进入 [ 时解析到一半的外层现场"整体挂起压栈,腾空双手专心解内层;遇 ] 时弹出最近挂起的现场、把内层结果并回去。这和函数调用同构:[=调用(保存返回现场),]=return(带着结果回到外层)。也是 LC20 的升级:20 的栈只存"待闭合左括号"验证配对,本题的栈在配对之外还要携带现场数据——外层拼到一半的串和重复次数。

② 各个量的身份(current_text[ 两侧身份切换):

身份表:每个量是谁、在哪个时刻换身份
代码里的量它是谁关键动作时刻
frames被挂起的外层上下文;每个元素=(进入这层 [ 前已拼好的外层串, 这层的重复次数)[ 压入、] 弹出——后挂起的先恢复
current_text当前最内层正在拼的串,不含任何外层字符[ 它以"外层串"身份入栈、自己清空变新内层;遇 ] 它以"内层结果"身份被并回外层
repeat_count正在累积的数字=下一个 [ 的重复次数随现场入栈后立即清零,为下一个数字腾位
outer_text + times * current_text] 的结算式外层前缀在前、重复后的内层在后

③ 两个公式从直觉推导:多位数——读 "10" 先见 1 后见 0,若写 repeat_count = int(char) 次数会被 0 覆盖,必须十进制累积 repeat_count = repeat_count*10 + int(char)。拼接顺序——] 结算时,外层已拼好的部分本来就在左边,刚展开的内层接在右边,即 outer_text + times*current_text;写反成 times*current_text + outer_text"ab2[c]" 会得 "ccab",真值 "abcc"

④ 常见困惑——为什么遇 [ 要把 current_textrepeat_count 都清零?因为它们的旧值已经随现场压进 frames;不清零就会既留在栈里、又留在工作变量里,内层拼串时把外层字符重复计入。清零的含义是:从这一刻起,这两个变量代表全新的内层;外层的一切等 ] 弹栈时原样拿回。

⑤ 用样例验证嵌套结算:"3[a2[c]]":两次挂起后 frames=[("",3),("a",2)]current_text="c"。第一个 ] 弹出 ("a",2)current_text = "a" + 2×"c" = "acc";第二个 ] 弹出 ("",3)"" + 3×"acc" = "accaccacc" ✓——内层先结算、外层后结算,正是后进先出。

⚠ 易错坑 单层扫描"收集到第一个 ] 就展开"(高频 WA 根因):最小反例 "3[a2[c]]"——收集到 "a2[c" 就重复展开,输出乱串,真值 "accaccacc";嵌套必须用栈(或递归)保存外层。
多位数逐位覆盖 repeat_count = int(char):最小反例 "10[a]"——次数被 0 覆盖,输出空串,真值 10 个 a。
Python 机械坑:在 for i in range(n) 里手动 i += 1 想跳过已收集的字符无效——下一轮 i 被 range 迭代器重新赋值。最小反例 "12[a]"'2' 被读两遍,次数累积成 122,真值 12;需要手动控制下标就改用 while。另外用列表暂存现场时别写 res.append(res)——把列表自身塞进自己,得到无限自引用结构;应压入拼好的字符串或元组。
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),来自嵌套栈与已构造字符串。高频追问一:"能不能递归写?"——可以:遇 [ 递归解内层、遇 ] 返回内层结果,本栈解法就是把这套递归的调用栈显式化,两者同构。追问二:"输出很长时瓶颈在哪?"——不在栈操作(每字符至多入栈出栈一次)而在字符串复制;改用字符列表分段累积、最后 join,可显著压低复制开销。

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²)。慢的根源:一个高温日往往同时是前面一串日子的答案,暴力却让这些日子各自从头扫一遍——"谁还在等待、等到了没有"这份信息完全没有被共享。

① 关键转化——反过来问"我是谁的答案":不逐日问"我的更高温在哪",改成每个新温度到来时问:"我是前面哪些日子的答案?"答案是:所有仍在等待、且温度比我低的日子——它们可以在此刻一次性全部结算。于是需要一个容器存放"仍未等到更高温的日子",新温度从最近的等待者开始逐个对比:最近的最先被检查,后进先出,就是栈。这套"新元素结算栈中等待者"的骨架与 LC84 同源——84 等的是"右边第一根更矮柱",方向相反。

② 单调性是结果不是前提:不需要刻意"维护"单调栈——能留在栈里的日子都还没等到更高温,而每个新温度入栈前会把比自己低的全部结算弹掉,所以栈从底到顶温度天然单调不增。这条性质反过来保证:结算时从栈顶往下弹,一旦遇到不低于当前温度的就可以停,更深处只会更高。

③ 各个量的身份:

身份表:一遍扫描中每个量在题里扮演谁
代码里的量它是谁提供什么 · 不管什么
unresolved_days「更高温还没出现、暂时无法结算的日子」的下标底→顶温度单调不增;存下标不存温度,因为答案是天数差
current_day / current_temperature候选的"右边第一个更高温"触发 while 连环结算;结算完自己入栈,转身变成等待者
previous_day = pop()本轮被结算的等待日答案当场写死:current_day − previous_day
收尾仍在栈中的下标后面永远没有更高温的日子answer 预填的 0 就是它们的最终答案,无需再处理

④ 判定与公式从直觉推导:答案要"相隔天数",下标差就是天数差,所以写 current_day − previous_day;"更暖"是严格升温,所以弹栈条件是严格 <——相等不结算,继续等;一个新高温可能同时是好几个等待日的答案,所以必须 while 连环弹而不是 if 弹一次。

⑤ 常见困惑——while 套在 for 里会不会 O(n²)?"外层 n 轮 × 栈里最多压着 n 个未结算日"看起来像 O(n²),但内层 while 每轮实际执行的次数=本轮弹出的个数,不是栈的大小——栈里"存了多少"和内层"跑了多少"是两回事。每个下标一生只入栈 1 次、出栈至多 1 次,内层的总执行次数被总出栈数 ≤ n 封顶(摊还分析)。最能说明问题的恰是温度递减(倒序)的输入:栈被压到最满(空间最坏),内层却一次都不弹(时间最省)。"每轮最坏 × 轮数"的相乘估计错在:把全局共享、总共只有 n 次的弹出预算,按轮重复计费了。

⑥ 用样例验证连环结算:[73,74,75,71,69,72,76,73] 扫到 day5(72℃):栈顶 day4(69℃)<72 → 弹,answer[4]=5−4=1;新栈顶 day3(71℃)<72 → 再弹,answer[3]=5−3=2;再看栈顶 day2(75℃)不低于 72,停——72 自己入栈开始等待。一个新温度触发两次结算,然后立刻转换身份变成等待者。

⚠ 易错坑 栈里存温度不存下标:最小反例 [73,74]——读到 74 时只知道"有个 73℃ 该结算了",却算不出天数差、也不知道该写进 answer 的哪一格;距离必须由下标提供。
if 只弹一次:最小反例 [71,69,72]——72 只结算了 69,错误输出 [0,1,0],真值 [2,1,0];必须 while 连环弹。
弹栈条件写成 <=:最小反例 [70,70]——相同温度被当"更暖"结算,错误输出 [1,0],真值 [0,0];题目要求严格升温。
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):每个下标入栈 1 次、出栈至多 1 次,内层 while 的总执行次数被总出栈数 ≤ n 封顶——摊还分析,不能按"每轮最坏 × 轮数"相乘。空间 O(n):温度递减时栈存满全部下标。高频追问一:"栈里最多压着 n 个未结算下标,会不会退化 O(n²)?"——不会:内层每执行一次就消耗一次全局共享的弹出预算(总共 n 次),栈里"存了多少"≠内层"跑了多少";温度递减的输入栈最满但内层零弹出,空间最坏恰是时间最省的输入。追问二:"不用栈能做吗?"——可以:从右往左遍历,用 answer 自身跳跃找更高温(候选 j=i+1,不够高就 j += answer[j],直到更高或确认无解),同样是摊还 O(n),额外空间 O(1)。

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²),在 n ≤ 10⁵ 下超时。慢的根源:每根柱子独立扫描,"谁挡住了谁"这个信息完全没有被复用。

① 关键转化——换枚举对象:直接枚举"所有矩形"没有抓手,但注意最大矩形一定顶着某根柱子的高度(不然还能往上长)。于是改成枚举每根柱子作为高度提供者(= 矩形范围内的最矮柱),问它最宽能撑多远:面积(i) = heights[i] × (右边第一根更矮柱下标 − 左边第一根更矮柱下标 − 1)。问题退化成一个标准子问题:给每个元素找左右两侧第一个更矮的元素

② 这就是 LC739 换个方向:739 用递减栈等"右边第一个更高";这里对称地维护高度递增的栈(存下标)。栈里的柱子=「右边第一根更矮柱还没出现、暂时无法结算的柱子」;当前柱 i 比栈顶矮的那一刻,栈顶苦等的右边界到了——弹出结算。一句话:破坏单调性的那个新元素,就是栈顶苦等的答案

③ 弹出结算瞬间的三个下标身份(本题核心,串戏即 WA):结算发生在 while 循环体内,此刻同时在场的三个下标各司其职:

三个下标的身份表:谁提供高度、谁划定边界
代码里的量它在题里是谁提供什么
idx = stack.pop()矩形的最矮柱=主角高度 heights[idx],其余一概不管
left=弹后新栈顶(空则 −1)左侧第一根更矮柱=左挡板左边界,站在矩形外面,不入宽度
i=当前柱(触发弹出者)右侧第一根更矮柱=右挡板右边界,同样在矩形外面

④ 宽度公式——挡板不是边缘:"右减左加一"求闭区间长度没有错,错的是喂进去的下标。lefti开区间端点(挡板),矩形真正覆盖的闭区间是 [left+1, i−1],套"右减左加一"得 宽 = (i−1) − (left+1) + 1 = i − left − 1。而被弹柱自己的下标 idx 既不是左边缘也不是右边缘——它的矩形通常向右越过自己(idx+1 ~ i−1 是本轮先被弹掉的更高柱,位置全归它的宽度)。口诀:比它高的柱子不构成边界、只构成宽度;边界永远由"第一根更矮的"划定

⑤ 为什么"遇到更矮就弹"不会漏掉更宽的矩形:常见困惑——弹出下标 0(高 2)时,"从 0 出发、跨过更矮柱、更宽"的矩形不就丢了?没丢:那种矩形跨过更矮柱后,高度被压到更矮柱以下,"高 2"的身份已作废;它会记在它范围内最矮柱的账上,那根矮柱将来结算时宽度自然盖回下标 0。整套枚举=每个候选矩形由它的最矮柱负责结算 → 不重不漏。弹出只没收"高度提供者"资格,"宽度一部分"的资格保留在所有更矮柱未来的结算里。

⑥ 左边界为什么不用再找一遍:i 入栈前先弹光比它高的,弹完露出的栈顶恰是它左侧第一根更矮柱(中间的柱子全都 ≥ heights[i],已被弹掉)。栈不重排,这根柱子从此一直垫在 i 正下方——将来 i 被弹出时,低头读新栈顶就是左边界。递增栈的形状本身就是"左侧第一个更矮者"的索引结构:栈顶等着右边界,脚下垫着左边界,一次单向扫描双边信息全齐。

⑦ 用样例验证一步:[2,1,5,6,2,3] 扫到 i=4(高 2):先弹高 6(left=下标2,宽 4−2−1=1,面积 6);再弹高 5(left=下标1,宽 4−1−1=2,面积 10)——宽 2 盖住了刚被弹掉的高 6 的位置,正是④口诀的实例。

⚠ 易错坑 三角色张冠李戴(两版真实手写 WA 的根因):高度必须取被弹出柱heights[idx],不是新栈顶的 heights[left](反例 [2,1]:会声称"高 2 跨两列"算出 4,真值 2);宽度是 i−left−1,不是 idx−left+1(把挡板当边缘,反例 [2,5,3]:高 5 算出宽 2 得 10,真值 6)。落笔前先默写③的身份表。
栈空时 left−1(数组左端外面),写 0 会在连环弹空栈时宽度少 1;收尾不能只算"栈底高度 × 栈跨度"一个矩形,剩余每根都要按同一规则逐个结算——加尾哨兵 0 让循环自动完成(反例 [1,2,4]:只算栈底得 3,真值 4)。
等高柱用严格 <(等高不弹):一串等高柱里先弹的宽度偏窄,但最后弹的那根左边界越过全部等高兄弟、拿到全宽,最大值不受影响。
3 · 图解
heights=[2,1,5,6,2,3],尾部补哨兵 0(记作下标 6);栈存下标、高度递增

h=6            █
h=5         █  █
h=4         █  █
h=3         █  █        █
h=2   █     █  █     █  █
h=1   █  █  █  █  █  █  █
     ─────────────────────
下标   0  1  2  3  4  5  (6=哨兵0)

里程碑1|i=1,h=1:柱0(高2)的右挡板出现
  弹 idx0:left=-1(栈空=左边无更矮),宽=1-(-1)-1=1,面积2
  stack=[1]

里程碑2|i=2,3:高5、6 递增直接入栈(都在等右边界)
  stack=[1,2,3](对应高度 1,5,6 递增)

里程碑3|i=4,h=2:连环结算(决定性一步)
  先弹 idx3(高6):left=2,宽=4-2-1=1 → 面积6
  再弹 idx2(高5):left=1,宽=4-1-1=2 → 面积10 ★ 放大看这一瞬间:

            ┌─────┐
            │█   █│     ← 高5、宽2 的矩形,盖住下标2..3
            │█   █│       (高6已先被弹掉,位置仍归宽度:
            │█   █│         更高柱不构成边界,只构成宽度)
      ▓     │█   █│   ▓
    ────────┴─────┴────────
      1      2   3    4
      ↑               ↑
   左挡板 left=1(高1)  右挡板 i=4(高2)
   两块挡板都站在矩形外面,不入宽度
   宽 = i-left-1 = 4-1-1 = 2(= 闭区间[left+1, i-1]=[2,3] 的长度)
  stack=[1,4]

里程碑4|i=5(高3)入栈;i=6 哨兵0 清栈收尾
  弹 idx5(高3):left=4,宽=6-4-1=1 → 3
  弹 idx4(高2):left=1,宽=6-1-1=4 → 8(盖住下标2..5 四根柱)
  弹 idx1(高1):left=-1,宽=6-(-1)-1=6 → 6(盖住全部6根柱)
  best_area 保持 10

最大矩形=原下标2..3 上高5宽2 的那块,面积10。
单调栈一遍扫描:每个下标一行,同一当前柱触发的连环弹栈合并展示
i / 高度结算(主角高度×宽)stack(下标)best_area
0 / 2无,入栈[0]0
1 / 1弹0:2×1=2(left=-1)[1]2
2 / 5 · 3 / 6递增,只入栈[1,2,3]2
4 / 2弹3:6×1=6;弹2:5×2=10[1,4]10
5 / 3无,入栈[1,4,5]10
6 / 0哨兵弹5:3×1=3;弹4:2×4=8;弹1:1×6=6[]10
结束返回 10[]10
4 · 代码
class Solution:
    def largestRectangleArea(self, heights: list[int]) -> int:
        # 1. 栈存下标、高度递增:栈里 =「右边界还没出现、暂时无法结算的柱子」
        stack = []
        best_area = 0
        heights.append(0)   # 尾哨兵:比谁都矮,循环内强制清栈,免写第二段收尾

        for i, h in enumerate(heights):
            # 2. 当前柱更矮 → i 就是栈顶苦等的右挡板,连环结算
            while stack and h < heights[stack[-1]]:
                idx = stack.pop()                  # 主角:只提供高度
                left = stack[-1] if stack else -1  # 左挡板;栈空=左边无更矮,取 -1(不是 0)
                # 3. 宽 = 两块挡板之间的开区间长度 = i - left - 1
                best_area = max(best_area, heights[idx] * (i - left - 1))
            stack.append(i)

        heights.pop()   # 还原输入
        return best_area
5 · 复杂度

时间 O(n):每个下标入栈 1 次、出栈至多 1 次,内层 while 的总执行次数被总出栈数 ≤ n 界住——摊还分析,不能按"每轮最坏 × 轮数"相乘。空间 O(n):单调栈。高频追问"极端输入会不会退化 O(n²)":不会——递增输入循环内零弹出,只是栈存满 n 根(空间最坏、时间最省,结算全押给尾哨兵一次清完);递减输入每步恰弹一根、栈始终只有一根。栈里"存了多少"≠内层循环"跑了多少",弹出预算全局共享只有 n 个。

6 · 测试用例

功能[2,1,5,6,2,3]→10、[2,4]→4;边界:单柱 [5]→5、全相同 [3,3,3]→9(等高柱靠最后弹的那根拿全宽);特殊:递增 [1,2,3]→4(循环内零弹出、栈存满,全靠尾哨兵收尾结算)、递减 [3,2,1]→4(每步弹一根、栈最浅)、含 0 柱 [2,0,2]→2(0 像哨兵一样把左侧清栈)。

堆(优先队列)能在 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 的小顶堆装"目前见过的最大 k 个数";每读一个新数就先入堆、堆超过 k 个立刻弹掉堆顶;扫完整个数组,堆顶就是第 k 大。

① 核心洞察——"第 k 大"换个说法就有抓手:第 k 大就是最大的 k 个数当中最小的那个。所以不必给全部 n 个数排出名次,只要一路扫描、始终维护"目前见过的最大 k 个数"这一小撮候选(下文称候选池),扫完读池内最小者即可。问题退化成标准子问题:流式维护 Top-K 集合——每来一个新数,判断它能否入池、入池后该踢掉谁。最直观的暴力是全排序后取降序第 k 位,O(n log n);它慢就慢在没用上这条等价:落选的 n−k 个数之间谁大谁小根本没人问,这部分比较全是白做。

② 常见困惑——求"最大的 k 个",为什么用小顶堆?看候选池上高频发生的操作是什么:每来一个够大的新数,都要踢掉池内最小的那个数(它最没资格留下)。堆能 O(log k) 取删的只有堆顶,所以堆顶必须站着"下一个被踢的数"=池内最小者 → 小顶堆。堆的方向由"要高效删除谁"决定,不由"题目求大还是求小"决定。若换大顶堆,堆顶是池内最大的数,一弹就把当前最大值扔了。

③ 各个量的身份(注意堆顶在中途和结束时含义不同):

身份表:小顶堆=当前最大 k 个数的候选池,堆顶是它们中最小的
代码里的量它在题里是谁提供什么 / 不管什么
top_k(整个堆)目前见过的最大 k 个数组成的候选池只保证"这 k 个最大",池内谁第一谁第二不关心
top_k[0]·扫描中途池内最小的数,新数想留下就得挤掉它此刻它不是答案
top_k[0]·扫描结束最大 k 个中的最小者=第 k 大本人同一个位置,扫完那一刻含义切换成答案
heappop 弹出的值确定进不了前 k 的数弹掉即遗忘,不再影响答案

④ "先 push 再 pop"的顺序从直觉推出:直觉写法是"新数比池内最小者大才入堆",但堆没满 k 个时这个最小者还不存在(读 top_k[0] 会越界),得多写分支。统一写法:一律先入堆,堆超过 k 个就弹堆顶——新数最小时被弹的就是它自己,等价于"没进过池";新数够大时被弹的是原来的池内最小者。两种情况合成一条路径,还天然覆盖了"堆未满"阶段。

⑤ 用原题示例走一遍:nums=[3,2,1,5,6,4]k=2
读 3:入堆,堆=[3],未满 k 不弹。
读 2:入堆,堆=[2,3],刚好 k 个。
读 1:入堆成 [1,2,3],超量弹堆顶 1——新数自己被弹,池子没变。
读 5:入堆后弹 2,堆=[3,5]
读 6:入堆后弹 3,堆=[5,6]
读 4:入堆后弹的又是 4 自己,堆=[5,6]
扫完读堆顶得 5;对照降序 [6,5,4,3,2,1] 第 2 位正是 5,自洽。

⑥ 正解二 快速选择——沿"排序太浪费"另开一条路:先交代快排的基本动作 partition(划分):随机挑一个数当基准,把不大于它的换到左边、更大的换到右边,基准就落到了自己排序后的最终下标 p——一次划分真正产出的信息只有"基准归位"这一条。而"第 k 大"住在升序下标 n−k(从直觉推:第 1 大=末位下标 n−1,第 k 大再往前挪 k−1 格,即 n−1−(k−1)=n−k)。所以两侧不必都递归:p 命中 n−k 就返回 nums[p];否则只钻含目标下标的那一侧,另一侧整块不再碰。

⑦ 取舍:数据流式、或 k 远小于 n → 堆(O(n log k)、只占 O(k) 内存、天然在线);一次性静态数组、追求平均最快 → 快选(平均 O(n),但原地打乱数组、最坏 O(n²))。

⚠ 易错坑 堆方向选反(大顶堆+超量弹顶):弹掉的是池内最大的数。最小反例 [1,2], k=1:大顶堆先留 1,入 2 后超量弹堆顶 2,返回 1,真值 2。判据默写:堆顶=最该被淘汰的那个
想走"大顶堆整堆弹 k−1 次"路线时忘了 Python heapq 只有小顶堆:直接 heapify(nums) 再弹 k−1 次取堆顶,拿到的是第 k 。反例 [3,1,2], k=1:heapify 后堆顶 1,返回 1,真值 3。修正:取负入堆 [-3,-1,-2],弹 k−1=0 次后堆顶 −3,取负还原为 3。
快选目标下标 off-by-one:写成 k-1 求的是"升序第 k 个"=第 k 小。反例 [1,2], k=1:目标应为 n−k=1(返回 2),写成 k−1=0 返回 1,真值 2。
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 次入堆,堆大小恒为 k,每次调整是 O(log k) 而非 O(log n);空间 O(k)。解法二 快速选择:时间期望 O(n)、最坏 O(n²);空间 O(1)。高频追问一:"堆 O(n log k) 和快选平均 O(n) 怎么选?k 接近 n 时呢?"k 小时 log k 近似常数,堆几乎线性且省内存;k 接近 n 时 O(n log k) 退化到 O(n log n) 全排序量级——此时可对称翻转:第 k 大=第 n−k+1 小,改维护大小为 n−k+1 的大顶堆(取负模拟),堆大小总能压到 min(k, n−k+1);一次性场景快选平均仍最快,但它改写原数组、不支持流式。高频追问二:"快选凭什么说期望 O(n)?不是每轮 partition 最坏 O(n)、最多 O(n) 轮吗?"不能按"每轮最坏 × 轮数"相乘:随机基准期望把区间缩成常数比例,总代价是几何级数 n + n/2 + n/4 + … < 2n;最坏 O(n²) 仍然存在(每次都抽到极端基准),随机化只是让任何固定输入都无法必然触发它。

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 · 思路(暴力→最优)

一句话总纲:先扫一遍数组,统计出每个不同数字出现了几次;再把这些 (频次, 值) 记录逐条过一遍,用大小为 k 的小顶堆只留频次最高的 k 条;处理完全部记录,堆里剩下的 k 个值就是答案。

① 第一步绕不开——先计数:不数一遍,谁也不知道谁高频,所以先用哈希表 Counter 把数组压成 值 → 出现次数,这一步 O(n) 且省不掉。设不同元素有 m 个,此后原数组退场,只处理这 m 条记录。最直观的下一步是把 m 条记录按频次全排序取前 k,共 O(n + m log m)。慢的根源与 LC215 一模一样:只要最高频的 k 个,落选的 m−k 个元素之间排出精确名次全是白做的比较。

② 约束排除法:题目明说要优于 O(n log n)。元素几乎不重复时 m≈n,"计数后全排序"恰好就是 O(n log n)——最顺手的路被出题人亲手堵死,箭头指向两条不排全序的路:堆(O(m log k))或桶(O(n))。

③ 关键转化——LC215 换个比较键:计数完成后,问题变成"在 m 条 (频次, 值) 记录里找频次最大的 k 条",正是 LC215 的候选池套路原样搬来:小顶堆装 k 条候选,堆顶=池内频次最低者=下一个被淘汰的,超量就弹堆顶;唯一的变化是比较的键从"值本身"换成了"频次"

④ 各个量的身份:

身份表:频次表、候选池与比较键
代码里的量它在题里是谁提供什么 / 不管什么
frequencies(Counter)值 → 出现次数的频次表把 n 个原始数压成 m 条记录;此后原数组退场,只处理频次表
top_k(小顶堆)目前频次最高的 k 个值组成的候选池池内名次不关心,扫完 m 条记录后池子整体就是答案
top_k[0]池内频次最低的候选,下一个被淘汰的就是它本题最后不读它,读的是整个池
元组 (frequency, value)频次放在前面当比较键,值跟在后面随堆一起存取heapq 比较元组先比第一位,键必须放第一位

⑤ 元组顺序从"堆按什么比较"推出:heapq 没有 key 参数,它比较元组走字典序、先比第一个分量。想让堆按频次淘汰,频次就必须占第一位;写成 (value, frequency),堆会按"值最小先淘汰"运转,和频次彻底无关。

⑥ 常见困惑——两个候选同频时,Python 会接着比第二位的"值",会不会出错?不会影响答案:同频只决定这两个数谁先被弹,而题目保证前 k 高频的集合唯一——第 k 名和第 k+1 名绝不同频,边界上没有并列争位;池子中途按什么顺序踢人,不改变最终留下的集合。

⑦ 用原题示例走一遍:nums=[1,1,1,2,2,3]k=2,频次表 {1:3, 2:2, 3:1}
处理值 1:入堆 (3,1),堆未满不弹。
处理值 2:入堆 (2,2),刚好 k 条,堆顶是 (2,2)
处理值 3:入堆 (1,3) 后超量,弹堆顶——三条记录中频次最低的正是 (1,3) 自己,池子没变。
池剩值 {1, 2},与示例输出 [1,2] 一致。

⑧ 正解二 桶排序——利用"频次值域有限"彻底免比较:比较排序的 O(m log m) 下界只对"键可以是任意值"成立;本题的键(频次)必落在 1..n 之间,值域已知且有限——这是"按下标直接定位"的信号:以频次为下标建桶buckets[f] 装所有恰好出现 f 次的值,再从最高桶(下标 n)往低扫,取满 k 个立即停。全程零比较,时间 O(n),代价是 O(n) 的桶空间。

⑨ 取舍:k 远小于 m → 堆(O(n + m log k),额外内存只有 O(k));追求严格线性、不在乎 O(n) 桶空间 → 桶排序。

⚠ 易错坑 元组写成 (value, frequency):堆改按"值"淘汰。最小反例 [5,5,9], k=1:频次表 {5:2, 9:1},入 (5,2) 再入 (9,1),超量弹元组更小的 (5,2),返回 [9],真值 [5]。
桶数组开小一号:一个值最多出现 n 次,下标要覆盖 0..n,必须开 n+1 个桶。最小反例 [7], k=1:n=1,频次 1,桶只开 n 个则仅有下标 0,写 buckets[1] 直接越界报错。
桶扫描方向反了:从低频往高频扫先取到冷门值。最小反例 [1,1,2], k=1b[1]=[2]b[2]=[1],从下标 1 往上扫先取出 2,返回 [2],真值 [1]。必须从下标 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 的桶数组不能省略不计。高频追问一:"桶排序都 O(n) 了,为什么面试常先写堆?"桶的 O(n) 空间在 n 巨大而 m 很小时几乎全空着;堆额外只占 O(k),且候选池写法可直接迁移到流式/TopK 变种,k 远小于 m 时是空间与通用性的更优折中。高频追问二:"一行 Counter(nums).most_common(k) 行不行?"结果正确,k 小于 m 时其内部走的就是 heapq.nlargest,复杂度同为 O(m log k)——可以用,但要能说出它底层就是解法一的堆。

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 · 思路(暴力→最优)

一句话总纲:把已收到的数劈成两半——较小的一半放大顶堆、较大的一半放小顶堆,两个堆顶正好贴着中间的分界线;每次 addNum 走固定三步(先进左堆 → 左堆顶挪去右堆 → 右堆更长就搬一个回来)维持平衡;findMedian 时奇数个读左堆顶,偶数个取两个堆顶的平均。

① 核心洞察——中位数只由贴着分界线的一两个数决定:把已有的数按大小劈成两堆:较小的一半记为 lower_half、较大的一半记为 upper_half,只维护一条粗粒度的有序性——左半全体 ≤ 右半全体。这时中位数只由"左半最大值"和"右半最小值"这两个贴线的数决定,两半内部乱序完全无害。

回头看暴力慢在哪:每次 findMedian 把已有数排序取中间,单次查询 O(n log n);改良成"维护有序数组+二分找插入点",查询 O(1),但插入要整体挪位,仍是 O(n),5×10⁴ 次调用撑不住。它们慢就慢在维护了全序——"第 3 小和第 4 小谁先谁后"这种从没人查的信息也花了大代价。劈两半后,每一半只需支持"O(1) 读自己贴线的那个数、O(log n) 增删",这正是堆的本职。LC215 用一个堆守数组一端的极值;本题要守的"中间"=左半的右端+右半的左端,所以用两个堆对着拼——对顶堆。

② 常见困惑——存较小一半的堆,为什么反而是大顶堆?堆的方向由"这一半会被问什么"决定。左半只被问"你家最大的是谁"(它贴线),堆能 O(1) 交出的只有堆顶 → 堆顶必须是左半最大 → 大顶堆;右半只被问"你家最小的是谁" → 小顶堆。与 LC215 同一条判据:堆顶站着你高频要取的那个数,跟"这半存的数偏大还是偏小"无关。

③ 双堆各自守什么(身份表):Python 的 heapq 只提供小顶堆,左半的大顶堆靠"取负入堆、读取时再取负还原"来模拟。

身份表:两个堆、两个堆顶与取负技巧
代码里的量它在题里是谁守护 / 提供什么
lower_half(存负值)较小的一半守住"左半最大值";内部谁第二第三不关心
upper_half较大的一半守住"右半最小值";内部次序同样不关心
-lower_half[0]左半最大=分界线左边紧贴的数总数为奇数时它就是中位数本人(约定左半可多一个)
upper_half[0]右半最小=分界线右边紧贴的数总数为偶数时与左边那个求平均
入堆的 -num取负后的编码值,不是题目里的量取负让"实际最大"以"编码最小"浮到堆顶;读取时再取负还原

④ 三步插入从两条不变式推出:结构要一直满足有序不变式(左半全体 ≤ 右半全体)和数量不变式(两堆等长,或左半恰多一个)。新数直接扔进某一半可能破坏有序性——num 也许比右半最小值还大,却被扔进了左半。所以走一条不做预判的固定路径:

第 1 步·一律先进左半:num 不做任何比较直接入 lower_half——此刻有序性可能已被破坏。

第 2 步·左顶挪右,修有序:弹出左半最大者送进右半。妙在这一弹自动完成了 num 与左半原最大值的大小比较:num 更大则被挪走的就是 num 本人(它本来就属于右半),否则挪走的是原最大值——两种情况挪完都恢复 max(左) ≤ min(右)。

第 3 步·右多则搬回,修数量:第 2 步让右半净增一个,若右半比左半长,把右半最小搬回左半——搬的是右半贴线者,有序性不受影响。

三步合计常数次堆操作(第 3 步不触发时 3 次,触发时 5 次),总代价 O(log n)、只有一个 if;相比"先比较 num 与两个堆顶再决定进哪边"的多分支写法,不存在漏判空堆、漏平衡的岔路。

⑤ 用题面示例走一遍:依次 add 1, 2, 3
add 1:1 先进左半,随即被挪去右半;右半更长,又被搬回 → 左 {1}、右空,中位数 1。
add 2:2 先进左半;第 2 步弹左半最大 2 送右半 → 左 {1}、右 {2},等长不搬,中位数 (1+2)/2 = 1.5。
add 3(最能检验三步):3 先进左半,左半实际值变 {1,3}——有序性已破,3 比右半的 2 还大。
第 2 步弹左半最大 3 送右半 → 左 {1}、右 {2,3},有序恢复。
第 3 步右半更长,把右半最小 2 搬回 → 左 {1,2}、右 {3}
净效果=3 落右半、2 换边;findMedian 读左堆顶 2,与示例输出 2.0 一致。

⚠ 易错坑 取负没成对出现(入堆、读取各一次):入堆忘取负,lower_half 退化成小顶堆,第 2 步弹给右半的是左半最小而非最大。最小反例 add 1,2,3:逐步模拟后左半实际存 {1,3}、右半 {2},读堆顶得 1(若读取处保留取负则得 −1),真值 2。对称错误:入堆取了负、读取忘还原——add 1 后返回 lower_half[0] = -1,真值 1。
漏掉第 3 步"右多搬回":add 1 后左空、右 {1}findMedian 见左半不更长便走偶数分支,读 lower_half[0] 直接越界崩溃,真值 1。数量不变式必须在每次 addNum 末尾立刻恢复。
偶数个只读一边或用整除:findMedian 恒返回 -lower_half[0],最小反例 add 1, 2:返回 1,真值 1.5;写成 //2 同样把 1.5 截成 1——偶数时必须两个堆顶用 /2 求平均。
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)——常数次(3 到 5 次)堆操作;findMedian O(1)——只读堆顶。空间 O(n),两堆合计保存全部元素。高频追问一:"窗口会滑动、要删除过期元素怎么办(LC480 滑动窗口中位数)?"堆删不动中间元素,标准做法是延迟删除:哈希表记下"欠删"的值,等它浮到堆顶时才真正弹掉,同时另行维护两堆的有效元素数;或改用有序结构(SortedList/平衡树)。高频追问二:"如果 99% 的数落在 0~100 的小值域?"(本题官方进阶)改计数数组:addNum O(1) 自增计数,findMedian 沿计数累加找分位点,代价 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 · 思路(暴力→最优)

暴力起手:枚举买入日 i 和更晚的卖出日 j,两重循环取 prices[j]-prices[i] 的最大值,O(n²),在 n ≤ 10⁵ 下超时。慢的根源:内层循环其实在替每个卖出日重新回答同一个问题——"我前面最便宜是哪天"。卖出日右移一格,买入候选只多了一个,前 j-1 天的最小值上一轮刚算过,却每次都从头扫。被浪费的信息就是"前缀最小值"。

① 关键转化——换枚举对象:不枚举 (买, 卖) 二元组,改成只枚举卖出日:卖出日固定后 prices[j] 是常数,利润 prices[j]-prices[i] 最大 ⟺ 买入价 prices[i] 最小,最优买入日唯一确定=此前历史最低价。问题退化成标准子问题:扫描时滚动维护前缀最小值。这与 LC53 同款——枚举右端点,把左侧的全部可能压缩成一个可滚动更新的量。

② 贪心为什么不亏——更差候选被支配:同一个卖出日下,"按历史最低价买"的方案不劣于任何其它买法(利润只多不少);而且这些被淘汰的买入候选在今后任何卖出日也照样被最低价支配,可以永久丢弃。全局最优解总有一个确定的卖出日,每个卖出日的最优搭配都被算过一次,所以一遍扫描不重不漏。这不是赌运气的"局部最优",而是无损压缩:整个买入候选集合的信息,等价于一个数。

③ 三个量的身份:

身份表:扫描到第 i 天时每个量是谁
代码里的量它是谁提供什么 / 不管什么
lowest_price第 0..i 天的历史最低买入价(含今天)今天卖出时的最优买价;不记录它是哪天——反正一定不晚于今天,顺序天然合法
current_profit「今天卖出」这一分支的最优利润仅本轮有效的候选值,下一轮即作废
best_profit已考察的所有卖出日中的最好成绩最终答案;初值 0=「不交易」这个保底方案

④ 更新顺序从身份推出:循环体先 lowest_price = min(...) 再算利润,意味着 lowest_price 的身份是"含今天的最低价"——今天恰是最低点时会出现"当天买当天卖",利润 0,无害且保证 current_profit 永不为负。先算利润后更新("不含今天"版)同样正确,只是候选可能为负,要靠 best_profit 的 max 兜住。两种顺序都对,但要想清自己维护的是哪个身份,别混着写。

⑤ 常见困惑——为什么不能全局最低买、全局最高卖?因为最高价可能出现在最低价之前,顺序非法。而"固定卖出日往前找最低价"的合法性是结构自带的:lowest_price 只由今天及以前的价格组成,永远不会用到未来的买点。

⑥ 用样例验证关键一步:[7,1,5,3,6,4] 扫到 day4(价 6)时:lowest_price 仍是 day1 写入的 1(5 和 3 都没能改写它),候选 6-1=5 刷新 best_profit;最后的 4 只给出 3,答案定格 5。"发现更低买点"和"刷新最佳卖点"发生在不同天,正是要滚动维护而不能事后配对的原因。

⚠ 易错坑 两趟法"先找全局最低价,再在它后面找最高价":最优交易未必用到全局最低点。反例 [3,2,6,5,0,3]:全局最低 0 在下标 4,其后最高是 3,错误输出 3;真值是 2 买 6 卖 = 4
方向写反,维护历史最高价、算 highest-price:等于"过去卖、今天买",顺序非法。反例 [5,1]:错误输出 4,真值 0。
丢掉"可以不交易":best_profit 初值写成 prices[1]-prices[0] 或强制必须交易。反例 [7,6]:错误输出 −1,真值 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),只存两个标量。高频追问一:"这是贪心还是 DP?"——两种叙述等价:DP 写法是 best[i] = max(best[i-1], prices[i] - min(prices[0..i])),把前缀最小值滚动化、再把 best 数组压成标量就是本解;面试答"固定卖出日后,最优买入日被支配关系唯一确定"即可。追问二:"允许多次交易怎么改?"——变成 LeetCode 122:吃下每段上涨 sum(max(0, prices[i]-prices[i-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] 逐格向后传播,但每个位置要把自己的至多 nums[i] 个落点全标一遍,最坏 O(n²)。慢的根源:我们在逐格重复回答"这格可达吗",大量标记是在给早就确定可达的格子反复盖章——可达集合本身的形状完全没被利用。

① 关键转化——可达集合是实心前缀:注意题面写的是"最多nums[i] 步":能跳 k 步就能改跳任何更短的步数,所以能落到远处就必然也能落在中途每一格。于是从 0 出发的可达集合永远没有空洞,是一个实心区间 [0, 右端]。整张 reachable 布尔表被无损压缩成一个数——右端点 farthest_reachable。问题退化成标准子问题:一遍扫描滚动维护最大值。这与 LC121 把整个买入候选集压成一个最低价是同一动作:集合有支配结构时,只留支配者。

② 各个量的身份(index 有两重身份):

身份表:扫描到 index 时每个量是谁
代码里的量它是谁提供什么 / 不管什么
farthest_reachable已确认可达的实心前缀 [0..它] 的右端判定任何格子可达与否的唯一依据;不记录路径怎么跳
index(判定前)待审的格子index > farthest_reachable,它在前缀外=没人能跳到它
index(判定通过后)确认可达的新起跳点这时才有资格用 index + max_jump 竞标新边界

③ 为什么只留一个边界不会漏:两个方向都严丝合缝。往小看:任何跳法能到的位置都不超过某个可达格的 i + nums[i],自然 ≤ 边界;往大看:边界内每一格都真实可达(①的实心论证)。所以"可达集合"与"[0..farthest_reachable]"完全相等——较小的候选 i + nums[i] 被最大者支配(能到更远处就能到它前面每一格),丢掉零损失。这不是赌局部最优,而是等价改写。

④ 判定顺序从身份推出:循环体必须先审后用——先判 index > farthest_reachable,通过后才允许这格扩张边界。顺序反了,就是让一个"没人跳得到的格子"拿自己的跳力说话,凭空接通断路。

⑤ 常见困惑——遇到 0 是不是就死了?不是。nums[i]==0 只说明这格自己不提供扩张;只要之前的边界已经越过它(farthest_reachable > i),别的格子一步就能跨过去。真正的死局只有一种:扫描撞上 index > farthest_reachable,实心前缀在终点前断裂。

⑥ 用样例验证关键一步:[3,2,1,0,4]:index=0 把边界扩到 3;index=1..3 的候选 1+22+13+0 全是 3,边界停滞;准备审 index=4 时 4 > 3,前缀断在终点前 → False。对照 [2,3,1,1,4]:index=1 时边界就达 1+3=4 ≥ n-1,提前返回 True。

⚠ 易错坑 nums[index]==0 当失败判据:0 可以被跨过。反例 [2,0,0]:错误输出 false,真值 true(下标 0 一步跳到下标 2)。
漏掉或后置 index > farthest_reachable 判定,让不可达格也参与更新:反例 [1,0,3,0]:下标 2 根本到不了,先更新会把边界推到 2+3=5,错误输出 true,真值 false。
边界用赋值覆盖而不是 max 累积:后面的小跳力会把边界拉回。反例 [4,0,0,0,1,1]:正确解 index=0 时边界 4、index=4 推到 5 → true;覆盖版在 index=1 把边界改写成 1+0=1,index=2 误判断裂,错误输出 false。
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。高频追问一:"能不能顺便求最少跳数?"——能,把单边界升级成"本层边界 + 下层边界"两个量、到达层界统一结算,就是 LC45。追问二:"如果 nums[i] 表示恰好跳 nums[i] 步呢?"——可达集合不再是实心前缀(会出现空洞),单边界压缩失效,只能退回 BFS/DP 逐格判可达;这正是本题贪心成立的前提被抽掉的样子。

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 · 思路(暴力→最优)

暴力起手:把每个下标看作节点、每个落点连一条边,做逐层搜索或最短路:位置 i 有至多 nums[i] 条出边,边数最坏 O(n²);DP 写法 min_jumps[i] = 1 + min(所有能跳到 i 的 j) 同样要枚举 O(n²) 个转移。慢的根源:相邻起点的落点区间大量重叠,同一格被不同起点反复松弛——"谁先够到我"其实只取决于层的边界,逐点比较全是重复劳动。

① 关键转化——最少跳数=BFS 层号:跳一次=向外扩一层,"最少几跳到终点"就是"终点在 BFS 第几层"(与 LC994 的分层扩散同款)。而这张图有 LC55 同款的特殊结构:每个起点的落点是连续区间,所以每一层也是一段连续区间——BFS 队列里永远是一串连号下标。于是队列退化成两个标量:本层右端 current_end、下一层右端草稿 next_farthest。O(n²) 条边从未被展开,压成一遍扫描里的 n 次取 max。

② 四个量的身份(next_farthest 结算前后身份不同):

身份表:扫描到 index 时每个量是谁
代码里的量它是谁提供什么 / 不管什么
jump_count已结算的层号=进入当前层花掉的跳数只在层末 +1,不跟随 index 逐格移动
current_end当前层(第 jump_count 层)的右端=层末闸门index 走到它,说明本层成员已全部发过言
next_farthest(结算前)下一层右端的草稿收集本层每个成员 index + nums[index] 的竞标,只记最大值
next_farthest(结算时)转正为新的 current_end草稿在 index == current_end 那一刻定稿

③ 为什么整层只记一个最远点不丢最优:下一跳可以从本层任何一格出发,而层是连续区间——把下层右端推到 next_farthest 之后,比它近的落点自动都在下层里,被最远者支配。白话归纳:任何一种跳法第 k 跳后所在的位置,都不会超过贪心第 k 层的右端(第 1 跳显然;之后每跳的出发点不更远,落点也就不更远);所以贪心的层最先盖住终点,层号就是最少跳数。

④ 循环上界从"终点是落点"推出:扫描范围是 range(n-1),到倒数第二格为止——终点只需要被跳到,不需要再从它起跳。若让 index == n-1 也进循环,一旦终点恰好是某层层末(index == current_end),会再结算一次"从终点出发"的跳跃,答案多 1。

⑤ 常见困惑——在 index == current_end 处 +1,是说"我在这格又跳了一次"吗?不是。加的这一跳是"从本层某个格子(不一定是 index 这格)跳进下一层"的那一次;把 +1 放在层末,只是因为走到这里下一层的边界才收集完整,可以一次性定稿。跳数属于层与层之间,不属于任何单个下标——这也是它与"每步现挑一个落点"的模拟写法的本质区别。

⑥ 用样例验证关键一步:[2,3,1,1,4]:第 0 层只有 {0},index=0 竞标出 2 并触发层末 → jump_count=1, current_end=2(第 1 层=[1..2]);index=1 竞标 1+3=4,index=2 竞标 3 动摇不了草稿,且 2 == current_end 触发第二次结算 → jump_count=2, current_end=4 已盖住终点;循环在 index=3 后结束,返回 2。

⚠ 易错坑 循环写成 range(n) 扫到最后一格:终点恰逢层末时多结算一跳。最小反例 [1,1]:错误输出 2,真值 1(一跳直达)。
结算时把新层界写成 index + nums[index](用层末这格自己的跳力),忘了 next_farthest 是全层竞标的最大值:反例 [2,3,1,1,4]:下标 1 的竞标 4 被丢掉,层界只推进到 2+1=3,之后还要再跳,错误输出 3,真值 2。
jump_count 初值设成 1 或在循环前先"起跳"一次:单元素本就站在终点。反例 [0]:错误输出 1,真值 0(range(0) 空循环直接返回)。
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)。高频追问一:"和普通 BFS 什么关系,为什么不用队列?"——每层是连续区间,队列里永远是连号下标,只需记右端一个数;O(n²) 条边从未被显式展开,这正是复杂度从 O(n²) 降到 O(n) 的来源。追问二:"如果不保证终点可达怎么办?"——层末结算时若发现 next_farthest <= index(下一层推不出去),说明前缀断裂,返回 −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 · 思路(暴力→最优)

一句话总纲:先预存每个字母最后一次出现的位置;再从左到右扫描,不断用它扩大当前片段的右边界,下标追上右边界就切一刀

① 核心洞察——一个字母出现在哪个片段,是"被绑死"的:题目要求同一字母只能待在一个片段里。反过来想:片段里只要收进了字母 a 的任何一次出现,那 a 最后一次出现的位置就也必须被包进这个片段——不然 a 就同时出现在两段了。所以片段的右边界不是自由选的,而是被逼出来的:右边界 = 段内所有已见字母的"最后出现位置"的最大值。这个"边走边把右边界往外推"的手感,和 LC55 跳跃游戏 维护"最远可达位置"是同一个模子。(暴力枚举所有切法有 2^(n−1) 种,之所以完全不可行,正是因为没用上这条"绑死"关系。)

② 两步走(正好对应段4代码的两块):

第一步·预处理:扫一遍 s,记下每个字母最后一次出现的下标 last_position(后出现的覆盖先出现的,最终留下的自然是最后位置)。

第二步·扫描切分:维护 segment_start(当前段起点)和 segment_end(当前段至少要延伸到的位置)。每读一个字符就 segment_end = max(segment_end, last_position[char])——新字母可能把边界推得更远;当 index == segment_end,说明段内所有字母的最后一次出现都已经被包住了,此刻切一刀:记录长度,segment_start 跳到 index + 1

③ 各个变量是谁:

身份表:扫描到 index 时每个量的含义
代码里的量它是谁怎么变化
last_position[c]字母 c 最后一次出现的下标预处理一次,之后不变
segment_end当前片段至少要切到的位置=段内已见字母 last 的最大值只会往右推、不会回退(用 max 更新)
segment_start当前片段起点只用来算长度;切完一段才跳到 index + 1
index扫描游标切分条件是 index == segment_end,和当前字符自己的 last 无关

④ 用原题示例走一遍:s="ababcbacadefegdehijhklij"

index=0 读 a:last[a]=8,边界推到 8。
index=1 读 b:last[b]=5,不超过 8,边界不动。
index=4 遇到 c:last[c]=7,也不超过 8。
一路走到 index=8:8 == 8,切!第一段长度 8−0+1=9,start→9。
index=9 从 d 重新开始,重复同样过程,再切出长度 7 和 8。结果 [9,7,8]

⑤ 为什么"追上右边界"就能切,而且切出的段数最多?segment_end 只会被段内出现过的字母往右推。当 index 追上它,说明段内每个字母的最后一次出现都已经被包住了——此刻切一刀是安全的。同时它也是最早能切的位置:再早一步切,把边界推到 segment_end 的那个字母就会跨段。所以策略就是"能切就立刻切,绝不拖延":第一段切得尽量短,剩下的后缀就尽量长,后面能切出的段数只多不少;每一段都套用同一理由,贪心整体最多。

⑥ 常见困惑——一个字母走到了它自己的最后位置,能切吗?不一定。切分看的是整段segment_end,不是当前字符自己的 last。比如 "abac"(last = {a:2, b:1, c:3}):b 在 index=1 就到达了自己的末位,但 a 要求段延伸到 2——此时切开,a 会同时出现在两段。一个字母"自己到期"≠"全段到期"。

⚠ 易错坑 用"当前字符到达自己的 last"当切点:反例 "abac"——b 在 index=1 到期就切出 "ab",错误输出 [2,1,1],a 跨了两段(非法),真值 [3,1]
segment_end 用赋值覆盖而不是 max:后来的小 last 会把边界拉回。反例 "abba":index=1 读 b 时边界被 2 覆盖(丢掉 a 的 3),index=2 误切出 "abb",错误输出 [3,1] 且 a 跨段,真值 [4]
切完忘记 segment_start = index + 1:下一段长度从旧起点算起。最小反例 "ab":第二段长度算成 1−0+1=2,错误输出 [1,2](总和 3 > 串长 2,自相矛盾),真值 [1,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 项(字符集不固定时应答 O(k),k 为不同字符数)。高频追问一:"能不能用合并区间的思路做?"——能:给每个字母建区间 [首次出现, last],按 LC56 方式合并重叠区间,合并结果就是各片段;答案相同,但一遍扫描免排序更省。追问二:"为什么第一段切短了不会让总段数变少?"——切点是约束逼出的最早合法位置,任何合法方案的切点都不会更早;前缀越短、剩余后缀越长,后面段数只多不少(段2⑤的论证)。

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 · 思路(暴力→最优)

一句话总纲:先认定"到第 i 阶的方法数只由 i 决定",再从 ways[0]=ways[1]=1 出发、按 ways[i]=ways[i-1]+ways[i-2] 从小到大递推,算到 ways[n] 就是答案。

① 核心洞察——盯住"最后一步":要数清到第 n 阶的全部走法,不必追每一步怎么走,只看每种走法的最后一步。最后一步只能迈 1 阶或迈 2 阶:迈 1 阶的走法,去掉最后一步就是一种"到第 n-1 阶的走法",反过来任何到 n-1 的走法补上这一步也能变回来——两边一一对应;迈 2 阶的与"到 n-2 阶的走法"同理。记 ways(i)=到第 i 阶的方法数,就有 ways(n)=ways(n-1)+ways(n-2)。(把这条式子直接写成递归就是暴力解,慢在重复:展开 ways(5) 的递归树,ways(3) 被算 2 次、ways(2) 被算 3 次,总量 O(2ⁿ)——慢的根源正是"方法数只由台阶编号决定、与从哪条路径问到它无关"这条性质没被用来复用结果。)

② 关键转化——从递归到查表:既然 ways(i) 只由 i 决定,就给每个台阶留一个格子:先记忆化(算过就查表),再进一步发现依赖方向固定——大台阶只依赖小台阶——干脆从小到大顺序填表,递归彻底消失。这就是 DP 题的标准入口:暴力递归 → 画出重复子问题 → 记忆化 → 递推,本类别后面每道题都在重走这条路。

③ 状态口径钉死:ways[i]恰好站上第 i 阶的方法数。本题要数的就是"到达"这个事件,每种走法在站上第 i 阶那一刻恰好被数一次;转移里的 ways[i-1]ways[i-2] 也都是同一口径,口径统一加法才成立。代码里 two_steps_before/one_step_before 就是 ways[i-2]/ways[i-1] 的滚动别名。

④ 为什么加法不重、不漏:不重——两类方案的最后一步不同(迈 1 阶 vs 迈 2 阶),任何一种走法只落进其中一类;不漏——最后一步只有这两种可能,没有第三类。互斥且完备,计数问题按加法原理相加:ways[i]=ways[i-1]+ways[i-2]。读转移方程第一眼要分清:求方案数用加、求最优才用 max/min。

常见困惑——为什么 ways[0]=1 而不是 0?ways[0] 的身份是"站在地面(第 0 阶)的方法数":什么都不走本身就是一种合法状态。更实际的理由:ways[2]=ways[1]+ways[0] 里,ways[0] 那一项对应"从地面直接迈 2 阶"这条真实走法;若设成 0,这条走法就被数丢了。

⑤ 用示例 n=3 走一遍:
初值:ways[0]=1ways[1]=1
i=2:ways[2]=ways[1]+ways[0]=1+1=2,对应 1+12
i=3:ways[3]=ways[2]+ways[1]=2+1=3,对应 1+1+11+22+1,与示例 2 一致。

⚠ 易错坑 把 base 写成 ways[0]=0:最小反例 n=2,得 ways[2]=0+1=1,输出 1(只数出 1+1),真值 2——"直接迈 2 阶"整条方案被丢掉。
循环范围写成 range(2, n)(少一轮):反例 n=2,循环体不执行,返回初值 1,真值 2;正确范围是 range(2, n+1)
滚动更新拆成两句顺序写:先 two_steps_before = one_step_before,再 one_step_before = two_steps_before + one_step_before,第二句读到的已是覆盖后的新值。反例 n=3:第二轮算出 2+2=4,输出 4,真值 3。要么用元组同时赋值,要么先算 current 再滚动。
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ⁿ),靠"记住子问题"消除了指数级重复。高频追问一:"为什么可以只留两个变量?"转移最远只回看 ways[i-2],更早的状态永远不会再被读——滚动数组降维的判断标准就是"转移最远回看几格"。追问二:"还能更快吗?"把转移写成矩阵 [[1,1],[1,0]] 的幂,用快速幂可到 O(log n);n ≤ 45 时 O(n) 已绰绰有余,面试口头说清思路即可。

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]]

numRows=5 的杨辉三角五行金字塔,每个数占一个方框,逐行居中错位排列:1;1 1;1 2 1;1 3 3 1;1 4 6 4 1。第四行的 3 与它上一行左上方的 1、右上方的 2 一同被高亮,两条细线从这两个数指向 3,示意 1 加 2 等于 3。 1 11 121 1331 14641
每个内部数 = 左上 + 右上:高亮处第 4 行的 3 由上一行的 1 + 2 相加得到

示例 2 与约束

numRows=1 时输出 [[1]]

  • 1 ≤ numRows ≤ 30
2 · 思路(暴力→最优)

一句话总纲:逐行生成:每行先整行填 1,再用上一行相邻两数之和覆盖内部格;一行完全填好才追加进结果,填满 numRows 行即返回。

① 核心洞察——递推规则题面已经给了:每个内部数=上一行左上+右上。也就是说,上一行一旦稳定,本行每个格子只需一次加法。(另一条路是逐格独立算组合数 C(row, col)——这就是本题的"暴力":每格重算一串阶乘乘除、中间值巨大,而相邻格"只差一个因子"的复用关系完全没被用上。题面给的加法规则正是组合恒等式 C(row,col)=C(row-1,col-1)+C(row-1,col):上一行的现成结果加一次就是本格,一次乘除都不用。)

② 关键转化——逐行递推:把"生成整个三角形"拆成可重复的子问题"由稳定的上一行生成下一行"。这是 DP 填表的最直白形态:本题没有任何决策(没有 max/min),只是把"当前层只依赖上一层"的依赖关系按顺序执行一遍,和 LC70 的"当前格=前两格之和"同款,只是从一维格子换成一整行。

③ 各个量的身份:triangle[row][col]=第 row 行第 col 个数(都从 0 计);previous_row=已经追加进 triangle只读来源current_row=正在填写、尚未定稿的一行。两者必须分开:转移读的全是 previous_row,写的全是 current_row,读写不碰同一行,才不会用本行刚写的新值污染上一行的旧值(坑②就是读写混行的后果)。

④ 循环范围从直觉推导:row_index 行有 row_index+1 个数,下标 0..row_index;两端 0row_index 固定为 1,真正要算的内部格只有 1..row_index-1,即 range(1, row_index)。先整行填 1、再只改内部,边界不用写任何 if;前两行(长度 1、2)没有内部格,这个 range 自然为空——短行无需特判。

常见困惑——为什么可以一边循环一边引用 triangle[row_index-1]因为每行都是整行稳定后才追加:进入第 row_index 轮时,上一行已完整躺在 triangle 里,不存在"引用了算到一半的行"。若把追加提前、或就地修改上一行,这条依赖就断了。

⑤ 用示例第 4 行走一遍(生成 [1,3,3,1]):
上一行 previous_row=[1,2,1] 已稳定躺在 triangle 里。
先整行填 1:current_row=[1,1,1,1]
col=1:previous_row[0]+previous_row[1]=1+2=3[1,3,1,1]
col=2:previous_row[1]+previous_row[2]=2+1=3[1,3,3,1],两端的 1 全程未被触碰,与示例三角形一致。

⚠ 易错坑 内部列范围写成 range(1, row_index+1):反例 numRows=3,算下标 2 的行时 col_index=2 会读 previous_row[2],而上一行 [1,1] 只有下标 0、1,直接 IndexError;真值应正常输出 [[1],[1,1],[1,2,1]]
转移误读正在写的行:写成 current_row[col_index-1]+previous_row[col_index]。反例 numRows=4:最后一行 col 2 用了刚写的 3(3+1=4),输出 [1,3,4,1],真值 [1,3,3,1]
行长少一:current_row=[1]*row_index。反例 numRows=2,第二行只有 [1],输出 [[1],[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(numRows) 的当前行)。高频追问一:"只要第 k 行呢(LC119)?"用一行数组从右往左原地更新 row[col] += row[col-1]:右侧先写,才能保证读到的 row[col-1] 还是上一行旧值——与 LC416 0/1 背包一维倒序是同一个"读旧写新"原理,空间 O(k)。追问二:"单独一格能不能不建三角形直接算?"能,沿一行用 C(n,k)=C(n,k-1)×(n-k+1)/k 递推,O(row) 时间。

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 · 思路(暴力→最优)

一句话总纲:从左到右每家做一次比较:best[i]=max(best[i-1], best[i-2]+nums[i])——"不偷这家"继承前 i-1 家的最优,"偷这家"用前 i-2 家的最优叠加本家金额;扫完最后一家,best 的末值就是答案。

① 核心洞察——按"最后一家偷不偷"分类:看第 i 家时只有两种终局:不偷它(前 i-1 家怎么最优就怎么来),或偷它(相邻的第 i-1 家必须放弃,其余全部交给前 i-2 家的最优)。这和 LC70 是同一张骨架——dp[i] 只依赖前两项——区别只在本题两来源之间取 max 而不是相加:求方案数用加法,求最优用 max。(暴力解是 DFS 枚举每家偷/不偷共 2ⁿ 种组合再剔除相邻冲突,慢在不同分支反复求同一个子问题——"前 i 家能偷到的最优金额"只由 i 决定,与前面具体怎么选到这里无关,却被每条路径各算一遍。标准入口:暴力递归 → 发现重复子问题 → 记忆化 → 顺序递推。)

② 状态定义钉死:best[i]只考虑前 i 家(第 i 家可偷可不偷)能取得的最大金额。为什么不定义成"必偷第 i 家的最优"?那样也能做,但转移要枚举"上一家偷的是 i-2 还是 i-3"(可能隔多家),答案还得全数组取 max;"前 i 家"口径让两个来源恰好落在 best[i-1]best[i-2] 两格上,答案就是最后一格。代码用滚动变量代替数组:

身份表:滚动变量与两种决策来源的对应
代码里的量它是谁提供什么
one_house_beforebest[i-1]=前 i-1 家的最优"不偷第 i 家"的全部收益;与 i 相邻,不能叠加 nums[i]
two_houses_beforebest[i-2]=前 i-2 家的最优"偷第 i 家"的搭档:与 i 不相邻,可安全叠加 nums[i]
current_bestbest[i]=两来源取 max本轮结论;滚动后变成下一轮的 one_house_before

③ 为什么合法、为什么最优:合法——"偷第 i 家"这一支接的是 best[i-2],第 i-2 家与第 i 家不相邻,叠加 nums[i] 不会触发报警;"不偷"这一支原样继承 best[i-1],更不会冲突。最优——两支按"第 i 家偷不偷"划分,互斥且完备,每支内部又都建立在更小子问题的最优之上,取 max 后不会漏掉任何更优方案。

常见困惑——偷第 i 家为什么只看 best[i-2]?要是最优解隔了两三家(比如实际接的是 i-3)呢?不会漏:best[i-2] 的定义是"前 i-2 家的最优",其中已经包含"第 i-2 家也不偷、实际取到 i-3"的情形;且由定义有 best[i-2] ≥ best[i-3](多考虑一家不会更差),所以"跳更远"的方案永远不优于经由 best[i-2] 的方案。"隔几家"的枚举被状态定义整个吸收了——这正是"前 i 家最优"这种口径的价值。

④ 用示例 [2,7,9,3,1] 走一遍:
看 2:max(0, 0+2)=2,偷。
看 7:max(2, 0+7)=7,偷。
看 9:max(7, 2+9)=11——放弃相邻的 7,拿隔家组合 2+9。
看 3:max(11, 7+3)=11,不偷。
看 1:max(11, 11+1)=12,偷;答案 12,与示例一致。

⚠ 易错坑 "偷当前"接错来源,写成 best[i-1]+nums[i]:等于允许偷相邻两家。最小反例 [2,3]:输出 5(两家都偷),真值 3。
初值想当然设 one_house_before=nums[0] 却仍从第 0 家开始循环:首家被算两次。反例 [2,1]:第一轮 max(2, 0+2)=2 后滚动成 (2,2),第二轮 max(2, 2+1)=3,输出 3,真值 2。两个滚动变量都从 0 起("看了 0 家收益 0"),循环覆盖每一家即可。
先滚动后计算:先执行 two_houses_before = one_house_before 再算 current_best,本轮"偷"读到的实际是 best[i-1],又变成偷相邻。反例 [1,2,3]:输出 6(三家全偷),真值 4(1+3)。
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),两个滚动变量——转移最远只回看两格,是滚动数组降维的教科书场景。高频追问一:"房屋围成一圈怎么办(LC213)?"首尾相邻不能同偷,拆成"不偷第 0 家(只看下标 1..n-1)"与"不偷最后一家(只看下标 0..n-2)"两次线性 DP 取 max。追问二:"要输出具体偷了哪些家?"滚动变量丢失了决策痕迹,需保留完整 best 数组,从后往前比对 best[i]==best[i-1](不偷)还是等于 best[i-2]+nums[i](偷)来回溯路径——用 O(n) 空间换可解释性。

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 · 思路(暴力→最优)

一句话总纲:开一张表 minimum_count[value] 记"凑出 value 最少要几个平方数";按 value 从 1 到 n 递增,每个 value 枚举"最后放入的平方数"、在全部候选里取最小;填到 minimum_count[n] 就是答案。

① 核心洞察——固定"最后一个平方数":凑出 value 的任何方案都可以问一句:最后放入的那个平方数是谁?把它记作 square。固定 square 之后,剩下的部分必须以最少个数凑出 value-square——一个更小的同型子问题,候选即 minimum_count[value-square]+1(+1 就是 square 自己)。(暴力解是 DFS 每层从余数里减一个平方数、减到 0 统计层数,慢在同一个余数沿不同拆法反复出现——12−1−1−1 与 12−4 之后都可能走到余数 9,而"凑出 9 最少要几个"与怎么减到 9 无关,却被每条路径重复求解。标准入口:暴力递归 → 余数即重复子问题 → 记忆化 → 按余数从小到大递推。)

② 顺手排除贪心:"每次减掉不超过余数的最大平方数"不成立:n=12 贪心先减 9,剩 3 只能 1+1+1,共 4 个;真值 4+4+4=3 个。局部最大不等于全局最少——必须把所有"最后一个平方数"的选择都比一遍,这句话直接给出了转移方程的形状。

③ 状态定义钉死:minimum_count[value]恰好凑出 value 的最少平方数个数。为什么状态里没有"用了哪些平方数"这一维?因为每个平方数可无限次重复使用,"还剩哪些可用"永远不变,目标值本身就是全部状态——这正是它区别于 LC416(每个数只能用一次,要靠倒序循环隐式携带物品维度)的地方。

④ 为什么合法、为什么最少:合法——候选 minimum_count[value-square]+1 的两部分口径一致:前一半"恰好凑出 value−square",加上 square 自己就恰好凑出 value。最少——按"最后一个平方数是谁"分类,互斥(最后一个不同)且完备(方案总有最后一个),对所有 square ≤ value 的候选取 min,不会漏掉任何更省的方案。

常见困惑——为什么初值是 inf 而不是 0?状态参与的是 min 比较:0 的含义是"已经用 0 个凑出来了",任何真实候选都赢不过它,整张表会被假 0 锁死(坑①给出最小反例)。inf 的身份是"暂时凑不出",任何真实方案都能覆盖它。只有 minimum_count[0]=0 名副其实:凑出 0 确实一个都不用。

⑤ 用示例 n=12 走一遍(只列关键金额):
value=1..3:只能反复用 1,个数依次为 1、2、3。
value=4:候选 minimum_count[3]+1=4(最后放 1)与 minimum_count[0]+1=1(最后放 4),取 1——"大平方数一步到位"只有枚举全部候选才不会漏。
value=8:min(minimum_count[7]+1=5, minimum_count[4]+1=2)=2,即 4+4。
value=9:minimum_count[0]+1=1
value=12:候选 minimum_count[11]+1=4minimum_count[8]+1=3minimum_count[3]+1=4,取 3——正是示例的 4+4+4;贪心先减 9 得到的"4 个"在这里被正面比了下去。

⚠ 易错坑 非 base 格初始化成 0:反例 n=2min(0, minimum_count[1]+1) 永远取假 0,输出 0,真值 2(1+1)。
枚举条件把 <= 写成 <square_root*square_root < value):value 本身是完全平方数时漏掉"一步到位"。反例 n=4:只能用 1 凑,输出 4,真值 1。
外层写成 range(1, n) 漏掉 n 本身:反例 n=1,循环体不执行,minimum_count[1] 保持 inf 被返回,真值 1。
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)。高频追问一:"min 型完全背包的两层循环能交换吗?"能——先枚举平方数、再正序枚举目标值同样正确,min 不在乎同一方案被按不同物品顺序看到几遍;但计数型背包交换内外层会把组合数变排列数,两类不能混。追问二:"有更快的数学解吗?"拉格朗日四平方定理保证答案只可能是 1/2/3/4:先判 n 是否完全平方(答案 1),再枚举 n-i² 是否完全平方(答案 2),4ᵃ(8b+7) 型必为 4,否则 3——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 · 思路(暴力→最优)

一句话总纲:开一张表 minimum_coins[value] 记"凑出金额 value 最少几枚";按金额从小到大,每个金额枚举"最后放入的那枚硬币"取最小;最后看 minimum_coins[amount],仍是 inf 说明凑不出,返回 -1。

① 核心洞察——固定"最后一枚硬币":凑出 value 的任何方案都有最后放入的一枚,把它记作 coin。固定它,其余部分必须以最少枚数凑出 value-coin——同型的更小子问题,候选即 minimum_coins[value-coin]+1。(暴力解是 DFS 枚举每一步用哪枚硬币、余额减到 0 时统计枚数,慢在同一个剩余金额被不同的用币顺序反复走到——"凑出余额 v 最少几枚"只由 v 决定,与先用了哪几枚无关,却被每条路径重算。标准入口:暴力递归 → 余额即重复子问题 → 记忆化 → 按金额从小到大递推。)

② 顺手排除贪心:"优先用最大面值"对任意面额不成立:coins=[1,3,4] 凑 6,贪心 4+1+1 共 3 枚,真值 3+3 共 2 枚。日常货币(1/2/5/10)恰好让贪心成立,这个生活直觉正是本题最大的陷阱——必须把所有"最后一枚"的选择都比一遍。

③ 状态定义钉死:minimum_coins[value]恰好凑出金额 value 的最少硬币枚数。"恰好"两个字必须钉死:转移 minimum_coins[value-coin]+1 里,减去最后一枚后剩下的部分也必须是"恰好凑出",口径不统一整条递推链就断了。硬币可无限重复使用,状态无需"用过哪些硬币"这一维——与 LC279 是同一张表,物品从平方数换成 coins

④ 为什么合法、为什么最少:合法——minimum_coins[value-coin] 的口径同样是"恰好凑出",加上最后一枚 coin 恰好是 value;外层按金额递增,读到的 value-coin 都已算完。最少——按最后一枚分类,互斥且完备(方案总有最后一枚),对全部 coin ≤ value 取 min,不漏任何更省的方案。

常见困惑——为什么初值是 inf(常见写法 amount+1)而不是 0?转移做的是 min:初值 0 意味着"0 枚就凑出来了",任何真实候选都赢不了它,整张表被假 0 污染(坑①反例)。inf 的身份是"暂不可达"。工程上常用 amount+1 顶替 inf:全用面值 1 也只要 amount 枚,所以 amount+1 就代表"不可能",还能避开定长整型里 inf+1 溢出的问题(Python 的 float('inf') 无此顾虑)。

⑤ 用示例 coins=[1,2,5]、amount=11 走一遍(只列关键金额):
value=1..4:最少枚数依次 1、1、2、2。
value=5:候选 minimum_coins[4]+1=3minimum_coins[3]+1=3minimum_coins[0]+1=1,硬币 5 一步到位。
value=10:minimum_coins[5]+1=2,即 5+5。
value=11:候选 minimum_coins[10]+1=3(5+5 再加一枚 1)、minimum_coins[9]+1=4minimum_coins[6]+1=3(5+1 再加一枚 5),取 3——与示例答案 5+5+1 一致。

⚠ 易错坑 结尾不判 inf 直接返回:反例 coins=[2]amount=3,输出 inf(amount+1 版输出 4),真值 -1。
漏写 coin <= value 就读 minimum_coins[value-coin]:Python 负下标不报错、静默读到数组尾部。反例 coins=[5]amount=3:value=1 时下标 -4 读到 minimum_coins[0]=0,连锁推出 minimum_coins[3]=3,输出 3,真值 -1。
与"凑法计数"(LC518 型)混淆:求最少枚数是 min 转移、两层循环可交换;求组合数必须硬币在外层。反例:计数时把金额放外层,coins=[1,2] 凑 3 得 3(把 1+2 与 2+1 数成两种),组合真值 2。
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)。高频追问一:"这是多项式算法吗?"不是——amount 是数值而非输入长度,复杂度随其二进制位数指数增长,属于伪多项式,这是背包类问题的标准考点。追问二:"什么时候可以贪心?"面额系统"正规"(如 1/2/5/10 的货币体系)时最大面值优先恰好最优;一般面额(如 [1,3,4] 凑 6)会错。面试标准回答:任意面额只能 DP,贪心需要逐系统证明。

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 · 思路(暴力→最优)

一句话总纲:开一张布尔表 breakable[end] 记"s 的前 end 个字符能否拆成字典词序列";按前缀长度从小到大,每个前缀枚举最后一个单词从哪里开始切;填到 breakable[n],它为 True 即整串可拆。

① 核心洞察——固定"最后一个单词":若整串能拆开,方案里一定有一个收尾的单词。设它从位置 start 开始,就得到判据:整串能拆 ⟺ 存在切点 start,使最后一段 s[start:end] 是字典词,剩下的前缀 s[:start] 自己能拆——后者是同型的更小子问题。这和 LC322 是同一招"固定最后一件"——物品是字典词、消耗的是字符位置;差别在这里只问可行不可行,min 换成了 or,是完全背包的可行性版。(暴力解是从头枚举第一个单词、匹配上就递归拆剩余后缀,慢就慢在没用上"能否拆只由切点位置决定"这条性质:同一个后缀被不同的前段切法反复进入、每条路径各算一遍,最坏如 s="aaaa…ab"、字典 ["a","aa","aaa"],分支指数爆炸。标准入口:暴力递归 → 前缀/后缀即重复子问题 → 记忆化 → 按前缀长度从小到大递推。)

② 状态定义钉死:breakable[end]前 end 个字符(即 s[:end])能否拆成字典词序列。为什么定义在"前缀能否拆"而不是"以哪个词结尾"?因为拼接只发生在前缀边界上,布尔值天然可组合;"结尾是哪个词"信息更多却毫无用处,状态定义只钉转移真正需要的信息。

③ 下标口径钉死(偏移是本题最容易串的地方):

身份表:两个下标、一个切片、一个状态格
代码里的量它是谁提供什么
breakable[end]"前 end 个字符"(即 s[:end])能否拆长度口径而非下标口径;数组长 n+1
start候选最后一个单词的起点把前缀切成 s[:start]s[start:end] 两段
s[start:end]候选的最后一段(半开区间,不含 end)拿去查 word_set
breakable[start]剩余前缀"前 start 个字符"的结论已算好的更小状态,保证拼接合法

三者天然咬合:前 start 个字符 + s[start:end] = 前 end 个字符——全部用"长度/半开"口径,任何一处混入闭区间就会错位一格(坑③)。

常见困惑——breakable[0]=True 是说空串能拆?它的真实身份是"第一个单词前面不需要任何东西":当 start=0 时转移退化成"s[0:end] 整段是字典词",必须由 breakable[0]=True 这条递推根撑住;设成 False 则一切前缀恒 False(坑①)。

④ 用示例 s="leetcode"、wordDict=["leet","code"] 走一遍:
初值:breakable=[T,F,F,F,F,F,F,F,F](长度 n+1=9,含空前缀)。
end=1..3:任何切法都凑不出"可达前缀+字典词",保持 False。
end=4:start=0,breakable[0]=Trues[0:4]="leet" 在字典 → breakable[4]=True
end=5..7:唯一可达切点是 start=4,但 "c"、"co"、"cod" 都不在字典,保持 False——False 不代表失败,只是"此处不是单词边界"。
end=8:start=4,breakable[4]=Trues[4:8]="code" 在字典 → breakable[8]=True,返回 True,与示例 1 一致。

⚠ 易错坑 漏设 breakable[0]=True:反例 s="a"、字典 ["a"],唯一转移因 breakable[0]=False 失败,输出 False,真值 True。
只查词典不查前缀(条件少了 breakable[start]):反例 s="ab"、字典 ["b"],end=2、start=1 时 "b" 在字典 → 输出 True,真值 False(前缀 "a" 根本拆不出)。
返回 breakable[n-1](下标口径)而不是 breakable[n](长度口径):反例 s="ab"、字典 ["ab"],返回 breakable[1]=False,真值 True(breakable[2] 才是整串结论)。
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 与哈希集合。高频追问一:"怎么剪枝?"最后一段长度不超过字典最长词长 L,内层只需枚举 start ∈ [end-L, end),降到约 O(n·L²);找到一个可行切点立即 break 也是白赚的常数优化。追问二:"要输出所有拆法(LC140)怎么办?"布尔表只记"能不能";标准做法是先用本题 DP 判可行性剪枝,再对可行前缀做记忆化 DFS 拼出全部句子——先可行后方案的两段式。

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 · 思路(暴力→最优)

一句话总纲:解法一开一张表 ending_length[i] 记"必须以 nums[i] 结尾的最长递增子序列长度",每个位置枚举倒数第二个元素是谁、取最大,答案是全表 max,O(n²);解法二改为维护"每种长度的递增子序列能取到的最小结尾值",每个新数二分定位后追加或替换,这张表最终的长度就是答案,O(n log n)。

① 核心洞察——转移缺什么信息,就把什么钉进状态:先看一个失败尝试:直觉定义 dp[i]="前 i 个元素的 LIS 长度",转移立刻卡死:假设前 i-1 个元素的 LIS 长 3,新来的 nums[i] 能不能接上去?不知道——那条长度 3 的序列结尾是谁没被记录,无从比较大小。缺"结尾"就钉"结尾":ending_length[i]必须以 nums[i] 结尾的最长严格递增子序列长度。结尾钉死后,"能不能接"变成一次明确的比较 nums[previous] < nums[i]。代价是答案不再是最后一格,而要全数组取 max——最长的序列可能在中途就结束了。(暴力解是枚举全部 2ⁿ 个子序列逐个检查是否严格递增,慢就慢在没用上这个量:大量候选共享同一个结尾元素,"以它结尾最长能到多少"从没被复用,每条候选都从零验证一遍。标准入口:暴力枚举 → 按结尾元素归类出重复子问题 → 递推。)

② 转移从"倒数第二个是谁"推导:nums[i] 结尾的序列,要么只有它自己(长度 1,所以初值全 1),要么存在倒数第二个元素 nums[previous](须 previous<inums[previous]<nums[i]),此时长度=ending_length[previous]+1。枚举全部合法前驱取 max,O(n²)。

③ 还慢在哪 → 正解二:O(n²) 的内层在"全部前驱里找能接的最长者",但其实我们不关心前驱具体是谁,只关心:每种长度的递增子序列,结尾值最小能压到多少——结尾越小,未来越容易被接长。于是维护 minimum_tail

两个解法的核心量对比:一个按位置记长度,一个按长度记门槛
代码里的量它是谁不管什么
ending_length[i]nums[i] 结尾的 LIS 长度不管这条序列前面具体经过谁
minimum_tail[k]长度 k+1 的递增子序列能取到的最小结尾值不对应任何一条真实序列,只是"该长度的门槛"
position(bisect_left 结果)minimum_tail 中第一个 ≥ value 的位置等于数组末尾 → 延长;否则把该长度门槛压得更低

minimum_tail 始终严格递增(若某长度的最小尾值不小于更长长度的最小尾值,把更长的序列截短一个就矛盾),所以可二分:新值比全部尾值都大 → 追加,LIS 变长;否则替换第一个 ≥ 它的位置——长度不变,但那个长度的门槛降低,未来更容易延长。总时间 O(n log n)。

常见困惑——minimum_tail 是一条真实的子序列吗?不是。反例 [3,4,1]:处理完是 [1,4],可 1 在原数组中出现在 4 之后,它不是合法子序列;真实 LIS 是 [3,4],长度 2 与 len(minimum_tail) 一致。它只是"每种长度的最低门槛"合集:长度保真、内容不保真——要输出真实序列须用 O(n²) DP 记录前驱回溯。

④ 用原题示例 [10,9,2,5,3,7,101,18] 走一遍(解法二):
读 10:表空,追加 → [10]
读 9:替换第一个 ≥9 的尾值 10 → [9]
读 2:替换 9 → [2]
读 5:比全部尾值大,追加 → [2,5]
读 3:替换 5 → [2,3]——长度不变,长度 2 的门槛从 5 降到 3。
读 7:追加 → [2,3,7]
读 101:追加 → [2,3,7,101]
读 18:替换 101 → [2,3,7,18]。长度 4,与示例 1 答案一致。
解法一在同一输入上补验关键一格:算值 7 那格时,可接前驱只有 2、5、3,候选长度分别为 1+1、2+1、2+1,取 3——对应链条 2→5→7(或 2→3→7)。

⚠ 易错坑 返回 ending_length[-1] 而不是 max(ending_length):反例 [2,3,1],状态数组 [1,2,1],末位 1,真值 2。
严格递增写成 <=(二分版等价错误是用 bisect_right):相等元素被当成可延长。反例 [7,7]:输出 2,真值 1——bisect_left 让相等值定位到原位置、只替换不追加,正是"严格"的实现方式。
minimum_tail 当结果序列输出:反例 [3,4,1] 会给出 [1,4] 这个在原数组里顺序颠倒的假序列;它只承诺长度正确。
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 log n)(每元素一次二分);空间 均为 O(n)。高频追问一:"O(n²) 降到 O(n log n) 的关键是什么?"把内层"枚举全部前驱找最长可接者"换成"按长度维护最小尾值、二分定位"——利用 minimum_tail 的严格递增性,线性扫描变 log 查找。追问二:"替换操作为什么不会把长度算多?"替换不改变 minimum_tail 的长度,只把某长度的门槛压低;长度只在"新值大于全部尾值"时 +1,恰好对应一次真实的延长。

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 · 思路(暴力→最优)

一句话总纲:一趟扫描,同时滚动两条状态——以当前元素结尾的最大乘积和最小乘积;每个元素在"接上前面"与"从自己重开"之间取舍,遇到负数先把两条状态互换,全程另记历史最大值,扫完返回它。

① 核心洞察——乘负数把大小关系整个翻转:本题看似照搬 LC53 最大子数组和 即可:只维护"以 i 结尾的最大乘积" max_ending=max(value, max_ending×value)。反例 [-2,3,-4] 立刻击穿:走到 3 时 max_ending=3(-6 被丢掉),走到 -4 时 max(-4, 3×-4)=-4,全程最大 3;真值是整个数组 -2×3×-4=24。败因:当前最小(很负)的乘积,乘一个负数就变成最大。"没用"的极小值其实是翻盘储备,只留最大值等于亲手扔掉它。(暴力枚举所有连续子数组、逐个重算乘积是 O(n³),固定左端滚动乘可到 O(n²);慢的根源同样是"以同一位置结尾"的乘积信息没被压缩复用,而本题的教训是:压缩时必须把最小值一起带上,只压最大值会丢信息。)

② 关键转化——成对维护最大与最小:既然 max 可能由 min 翻转而来,就让两条状态一起滚动。各个量的身份(注意同一变量在一轮内有"更新前/更新后"两个时刻):

身份表:三个变量、两个时刻
代码里的量哪个时刻它是谁
max_ending本轮更新前上一个元素结尾的最大乘积,"接上去"的候选来源
max_ending本轮更新后当前元素结尾的最大乘积
min_ending全程以当前元素结尾的最小乘积=翻盘储备:越负越有价值,专等下一个负数
best_product全程历轮 max_ending 的最大值——答案可能在中途结束,不能只看最后一轮

③ 转移从"最后一步"推导:以当前元素结尾的子数组,要么只有它自己(重开),要么由"以上一元素结尾的某条子数组"延长而来;延长后要成为最大/最小,来源只可能是旧 max 或旧 min(中间值不可能更极端)。完整候选是三个:value、旧max×value、旧min×value。代码用先交换的写法压缩:value<0 时旧 max/min 乘 value 后大小互换,先 swap 再统一 max(value, max_ending×value)——两候选写法等价于三候选。

常见困惑——0 要不要特判?不用。value=0 时 max(0, 旧max×0)=0min(0, 旧min×0)=0,两条状态同时归零;下一个元素 v 走 max(v, 0×v)=v 自动重开。0 天然承担"截断点"的角色,加 if 反而容易错。

④ 用原题示例 [2,3,-2,4] 走一遍:
首元素 2:(max_ending, min_ending, best_product)=(2, 2, 2)
读 3:正数不交换;max(3, 2×3)=6min(3, 2×3)=3(6, 3, 6)
读 -2:负数先交换 (6,3)→(3,6);再算 max(-2, 3×-2)=-2min(-2, 6×-2)=-12(-2, -12, 6)。-12 被保留:若后面再来负数,它将翻成最大。
读 4:max(4, -2×4)=4——从自己重开;min(4, -12×4)=-48(4, -48, 6)
返回 best_product=6,与示例 1 一致——这个例子同时覆盖了"负数翻转"与"答案不在末尾"。

⚠ 易错坑 只维护 max 一条状态:反例 [-2,3,-4],输出 3,真值 24——负负得正的来源被提前丢弃。
用两候选写法却忘记先交换:反例 [2,-1,-3],走到 -3 时用旧 max=-1 算 max(-3, (-1)×(-3)=3)=3,输出 3,真值 6(正确流程先 swap,用旧 min=-2 得 (-2)×(-3)=6)。
漏掉"独自重开"候选、写成 max_ending×value:反例 [0,3],max_ending 被 0 粘住(0×3=0),输出 0,真值 3。
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 三个变量。高频追问一:"能不能取对数把乘法变加法、直接套 LC53?"不能——log 对负数无定义、对 0 发散,而负数与 0 恰是本题的全部难点;取绝对值又会丢掉符号信息。追问二:"要返回子数组本身怎么办?"给 max/min 各配一个当前区间起点:重开时起点改为当前下标,交换时起点随值一起交换,best_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 · 思路(暴力→最优)

一句话总纲:先把"分成两个等和子集"转成"能否选出若干数恰好凑出总和的一半"(总和为奇数直接 False);再做 0/1 背包可行性 DP——一维布尔表 reachable[s] 记"能否恰好凑出和 s",逐个处理数字、容量从大到小倒序更新;最后看 reachable[target_sum]

① 关键转化——两子集等和 ⟺ 选一部分凑出总和的一半:设总和 total_sum,两子集相等则各为一半;反过来只要能选出和恰为 target_sum=total_sum//2 的子集,剩下的数之和自动是 total_sum−target_sum=target_sum——另一半不需要验证。总和为奇数时目标不是整数,直接 False。问题就退化成标准 0/1 背包可行性:每个数至多用一次,能否恰好凑出 target——对照 LC322/LC279 的"无限次使用",这是背包的另一半版图。(暴力解是每个数"进子集 A / 进子集 B"两选、2ⁿ 种分法逐一验证等和,慢就慢在没用上转化后的子问题结构:不同分支反复到达同一个"用前 i 个数、已选部分和为 s"的局面,它与怎么分到这里无关,却被每条路径重算。标准入口:暴力递归 → (i, s) 即重复子问题 → 记忆化 → 递推填表。)

② 状态定义钉死:完整定义本是二维的:reachable[i][s]="只用前 i 个数能否凑出 s"。每行只依赖上一行(不选第 i 个数:reachable[i-1][s];选:reachable[i-1][s-number]),于是压成一维 reachable[s]。但"上一行/本行"的区别没有消失,只是改由扫描方向表达——同一个格子在一轮之内有两个时刻身份:

身份表:处理 number 的一轮中,一维数组同一格的两个时刻
量 · 时刻它是谁提供什么
reachable[s] · 被写入前上一轮状态="不含 number 时 s 的可达性"转移要读的旧行
reachable[s] · 被写入后本轮状态="已把 number 纳入考虑"本轮不应再被当作来源读取
reachable[s-number]"选 number"的搭档状态必须是旧行——number 只有一个,不能又当结论又当前提

③ 倒序从直觉推导:转移读小写大(读 s-number、写 s)。若容量从小到大正序扫,小容量先被写成新值,大容量再读它时读到的已是"本轮选过 number"的结果——同一个数被用了两次。从大到小倒序扫,写过的都在右边、要读的都在左边未动,读到的永远是旧行。一句话:一维 0/1 背包,更新方向必须与依赖方向相反。完全背包(LC322)恰好相反:物品可重复用,正序"读到新值"正是想要的语义。

常见困惑——只验证凑出一半,另一半怎么保证相等?见①:剩余元素之和=总和−一半=另一半,这是算术恒等式,不是需要搜索的第二个条件。

④ 用原题示例 [1,5,11,5] 走一遍(total_sum=22,target_sum=11):
初值:只有 reachable[0]=True
处理 1:可达和 {0, 1}。
处理 5:倒序扫 11..5,reachable[6] 读旧 reachable[1]reachable[5]reachable[0] → 可达和 {0, 1, 5, 6}。
处理 11:reachable[11]reachable[0]=True → 命中目标,可达和 {0, 1, 5, 6, 11}。
处理第二个 5:目标格 reachable[11] 已 True,结论不变(其余格另有新增,不影响结果)。
返回 True,对应 [11][1,5,5] 的分法,与示例 1 一致。(本例即使正序更新也恰好不出错,能暴露正序错误的最小反例见坑①。)

⚠ 易错坑 内层正序更新,同一个数被重复使用:反例 [1,2,5](total=8,target=4)——正序处理 1 时 reachable[1] 刚变 True 就被 reachable[2] 当来源,连锁把 reachable[3]、[4] 全推成 True,输出 True;真值 False(子集和只有 0/1/2/3/5/6/7/8,凑不出 4)。
不先判奇数总和:total_sum//2 向下取整制造假目标。反例 [1,2]:total=3、target 变成 1,reachable[1]=True → 输出 True,真值 False(1+2 无法平分)。
内层下界写成 range(target_sum, number, -1)(不含 number 本身):漏掉"单独用这个数凑出 s=number"的转移。反例 [2,2]:target=2,range(2,2,-1) 为空,reachable[2] 永远 False,输出 False,真值 True。
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),一维滚动数组。高频追问一:"这是多项式算法吗?"不是——target 随数值大小增长,属于伪多项式;子集和/分割问题本身 NP 完全,数值不大时 DP 才可行。追问二:"还能更快吗?"bitset 优化:把一行布尔压成一个大整数,bits |= bits << number 一次位运算完成整轮转移(左移天然"读旧写新",无需倒序),Python 大整数原生支持,复杂度除以机器字长。

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 · 思路(暴力→最优)

一句话总纲:解法一(DP):开一张表 ending_length[end] 记"恰好以下标 end 结尾的最长有效括号长度",只在右括号处按前一个字符分两支转移,答案全表取 max;解法二(栈):栈里存"未配对的下标"、栈底垫 -1,右括号弹栈后用"当前下标 − 栈顶"直接得到以当前位置结尾的有效长度,全程记最大。

① 核心洞察——把"结尾"钉进状态:求"最长连续段"的标准招式与 LC300 同理:ending_length[end]恰好以下标 end 结尾的最长有效括号长度。为什么必须"以 end 结尾"?因为有效段是连续子串,钉死右端点后"往左能延多长"才是一个可递推的量;答案再全数组取 max。又因为有效串必以 ')' 结尾,'(' 处状态恒 0,只需在右括号处更新。(暴力枚举全部 O(n²) 个子串、每个用计数器扫一遍判合法要 O(n³),慢就慢在没用上这个量:相邻子串的合法性高度重叠,"以某位置结尾的最长有效段"一旦算出,右边的判定本可直接站在它肩上,暴力却每个子串都从头验证。)

② 转移按"倒数第二个字符"分两支(从直觉推导):当前 ')' 的配对左括号在哪,取决于 s[end-1]
…() 型:s[end-1]=='(',两字符当场配对,再接上紧贴左边、以 end-2 结尾的有效段:ending_length[end]=ending_length[end-2]+2
…)) 型:s[end-1]==')',配对对象不在旁边——中间隔着"以 end-1 结尾的有效段"。跨过它,落点 matching_left=end-ending_length[end-1]-1;若那里是 '(',当前右括号把整段包住(+2),并把 matching_left 更左边的相邻有效段一并拼上(+ending_length[matching_left-1])。

③ 身份表——…)) 型同时在场的下标多,先钉死各自是谁:

身份表:…)) 分支里五个量各自是谁
代码里的量它是谁提供什么
end当前右括号正在找配对的主角
ending_length[end-1]紧贴在左的内层有效段长度要跨过的距离
matching_left跨过内层段后的候选配对位置是 '(' 才能闭合;是 ')' 或越界则 end 处记 0
ending_length[matching_left-1]配对左括号更左边的相邻有效段闭合后与新段连成一片,必须拼上
best_length历轮最大值最长段可能在中途,不能只看最后一格

常见困惑——…)) 型里为什么还要加 ending_length[matching_left-1]因为闭合出的新段可能与左边既有段无缝相连。反例 "()(())":end=5 闭合外层得到 (()) 长 4,但它左边紧贴着 ()——不加这一项输出 4,真值 6。漏这一项是本题最高频的逻辑缺口。

④ 正解二——下标栈,换个视角:栈里存"还没配对的位置",栈底垫 -1,身份是当前有效段左边最近的断点。左括号压下标等配对;右括号先弹栈——弹后栈空说明这个右括号自己失配、成为新断点;否则 end−unmatched_indices[-1] 就是以 end 结尾的连续有效长度(栈顶恰是断点或最近的未配对左括号)。两解都 O(n):DP 练"以结尾定义状态"的通用套路,栈解身份单一、边界更好想,面试推荐先写栈。

⑤ 用原题示例 s=")()())" 走一遍(DP):
end=0 读 ')':开头失配,记 0。
end=1 读 '(':左括号不能收尾,保持 0。
end=2 读 ')':前一位是 '(',…() 型:ending_length[0]+2=2
end=3 读 '(':保持 0。
end=4 读 ')':前一位 '(',…() 型:ending_length[2]+2=4——新括号对与左边的 () 无缝相连。
end=5 读 ')':前一位 ')',…)) 型:matching_left=5-ending_length[4]-1=0s[0]=')' 不是左括号 → 记 0。
全程最大 4,与示例 2 一致。
…)) 型闭合成功的关键一步再用 ")()(())" 补验:end=6 时 ending_length[5]=2(内层 ())、matching_left=6-2-1=3s[3]='(' 成立 → ending_length[6]=2+2+ending_length[2]=6——内层段、包住它的括号对、更左的 () 三段连成一片。

⚠ 易错坑 不判 matching_left >= 0 就读 s[matching_left]:Python 的 s[-1] 静默读到字符串末尾。反例 s="())(":end=2 时 matching_left=-1,s[-1]='(' 被误判配对成功,ending_length[2]=4,输出 4,真值 2。
栈解漏垫哨兵 -1:反例 s="()",end=1 弹出下标 0 后栈空、只能重设断点,best_length 始终 0,真值 2——哨兵就是第一个"断点",保证从头就有做减法的基准。
不维护 best_length、只看最后一格:反例 s="()("ending_length=[0,2,0],末位 0,真值 2。
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)(DP 数组或下标栈)。高频追问一:"能不能 O(1) 空间?"能——左右各扫一遍的计数法:从左往右维护 left/right 计数,right==left 时更新答案 2×left,right>left 时双双清零;再从右往左对称扫一遍,处理 "(()" 这类左多右少、正向永远等不到相等的情形。追问二:"DP 和栈选哪个?"栈解身份单一(栈顶=最近断点/未配对位置)、边界只有哨兵一处,手写更稳;DP 的价值在训练"以结尾定义状态+按前一字符分类"的通用套路。

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

三行七列的机器人网格机器人位于三乘七网格的左上角起点,每步只能向右或向下移动一格,最终到达右下角终点;图中高亮两条示例路径,这样的不同路径一共有 28 条。 起点终点
3×7 网格:机器人从左上角起点走到右下角终点,实线(先右后下)与虚线(先下后右)只是两条示例;所有仅向右/向下的走法共 28 条

其余示例

示例 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 · 思路(暴力→最优)

一句话总纲:给每个格子记一个数——从起点走到它的路径条数;第一行、第一列全是 1,其余每格=上格+左格,按行填完整张表,右下角那个数就是答案。

① 核心洞察——到一个格子的路径数,只由格子位置决定:最直观的做法是从 (0,0) 出发 DFS,每格向右、向下各分一支,走到右下角就计数 +1。但先看一条关键性质:"先右后下"和"先下后右"都到达格子 (1,1),而"到了 (1,1) 之后还能怎么走到终点"跟你是怎么来的完全无关。也就是说,"从起点到某格有几条路"是一个只由格子坐标 (row,col) 决定的量,算一次就够。暴力 DFS 慢就慢在没用上这条性质:同一个格子之后的计数被每条到达路径各重算一遍,递归树的叶子数就是路径总数——最多 C(m+n−2, m−1) 条,指数级。按 (row,col) 存表复用(记忆化),指数级立刻降到 O(mn);再观察依赖方向固定——每格只依赖上、左两个邻格——干脆不递归了,按行从上到下、行内从左到右直接填表,这就是递推 DP。

② 转移从"最后一步"推导:不枚举整条路径,只问"到 (row,col) 的最后一步从哪来"。规则只许向右/向下,所以最后一步要么从上格 (row−1,col) 下来,要么从左格 (row,col−1) 过来。把最后一步剪掉,剩下的恰是一条到上格或左格的完整路径;反之接上最后一步就还原。两类按最后一步区分,不重不漏,计数直接相加:path_count[row][col] = 上 + 左。这就是 LC70 爬楼梯的二维版——70 的最后一步是"跨 1 阶还是跨 2 阶",这里是"从上还是从左"。

③ 状态身份钉死:path_count[row][col] =「从左上角 (0,0) 走到 (row,col) 的不同路径条数」。base 从身份直接读出:第一行只能一路向右、第一列只能一路向下,各只有 1 条路径。

④ 常见困惑——起点 (0,0) 一步没走,凭什么算 1 条?因为表里存的是"方案数"不是"步数":什么都不做是一种(且仅一种)合法走法(空方案),它是整张表所有加法的种子;写成 0,加法只会把 0 传遍全表,最后返回 0。

⑤ 滚动压缩——同一个数组格的新旧双身份:转移只用"上一行同列"和"本行左邻",二维表可压成一行 path_count[col]。此时同一个格在一轮循环里先后扮演两个角色:

一维滚动时各量的身份(正在处理第 row 行、第 col 列的时刻)
代码里的量此刻它是谁提供什么
path_count[col](写入前)上一行同列的旧值"上"的路径数
path_count[col-1](已更新)本行左邻的新值"左"的路径数
path_count[col](写入后)本行当前格dp 新值,供右邻当"左"用

⑥ 遍历方向从身份推导:要让 path_count[col-1] 恰好是"本行新值"、path_count[col] 恰好是"上一行旧值",col 必须从左往右——左边先更新、右边还没碰。反过来从右往左,path_count[col-1] 还是上一行的值,等于把"左"偷换成"左上",递推悄悄变成杨辉三角。

⑦ 用原题示例走一遍:示例 4 的 3×3 网格(答案 6),看滚动数组逐步覆盖:

第一行 base:[1,1,1]
第二行 col=1:上 1+左 1=2 → [1,2,1]
第二行 col=2:上 1+左 2=3 → [1,2,3]
第三行 col=1:上 2+左 1=3 → [1,3,3]
第三行 col=2(终点):旧 path_count[2]=3(上)+新 path_count[1]=3(左)=6[1,3,6]
与组合数 C(4,2)=6 一致 ✓。

⚠ 易错坑 一维滚动 col 从右往左遍历:读到的 path_count[col-1] 是上一行旧值,转移变成"上+左上"。最小反例 m=2,n=3:初始 [1,1,1],倒序更新后 [1,2,2],错误输出 2,真值 3。
base 写 0:第一行/第一列是 1 不是 0。最小反例 m=1,n=1:错误返回 0,真值 1(原地不动即唯一方案)。
组合公式下标差一:总步数是 m+n−2(下 m−1 步、右 n−1 步),应为 C(m+n−2, m−1);误写 C(m+n, m)m=3,n=2C(5,3)=10,真值 3。
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)) 解。

高频追问一:"滚动数组为什么必须从左往右遍历?"——正序时 path_count[col-1] 已是本行新值(身份是"左")、path_count[col] 还是上一行旧值(身份是"上"),恰好各就各位;倒序会把"左"读成上一行的"左上",转移悄悄变成杨辉三角(反例见段2坑①:m=2,n=3 错得 2,真值 3)。追问二:"还能更省吗?"——纯计数可直接算组合数 C(m+n−2, m−1),O(min(m,n)) 时间、O(1) 空间;但网格一旦加障碍(LC63)或带权(LC64),组合公式失效,DP 才是通用解。

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、5、1,第三行 4、2、1;高亮路径从左上角的 1 向右经过 3、1,再向下经过 1 到右下角的 1,总和 7 是所有仅向右或向下路径中的最小值。 131 151 421
高亮路径 1→3→1→1→1(先右后下)的数字和为 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 · 思路(暴力→最优)

一句话总纲:给每个格子算"从起点走到它的最小花费";第一行、第一列只能沿唯一来路累加,其余每格=min(上, 左)+本格数字,按行填到右下角就是答案。

① 核心洞察——到一格的最小花费,只由格子位置决定:网格和走法与 LC62 完全相同,62 的那条关键性质也照搬得过来:不管沿哪条路走到 (row,col),"从起点到这一格最少花多少"都是同一个数,只由格子位置决定,算一次就够。暴力 DFS 枚举所有"只右/只下"的路径、沿途累加取最小,路径条数 C(m+n−2, m−1) 指数级——慢就慢在无数条前缀路径汇聚到同一格后,这个量被每条路径各重算一遍。按格记忆化存最优 → 依赖方向固定(只有上、左)→ 改成逐行递推填表。

② 关键转化——把 62 的"+"换成"min":62 问"到这格有几条路",两类路径(最后一步来自上/来自左)互斥完备,用加法合并;本题问"到这格最便宜是多少",同样按最后一步分两类,但合并方式变成各类先取最优、类间再取更小,最后加上本格必付的 grid[row][col]。为什么前缀也必须最优(最优子结构):若最优路径到前驱格的那段前缀不是最小和,换成更小的那条前缀,总和更小,与"最优"矛盾。

③ 状态身份钉死:minimum_sum[row][col] =「从 (0,0) 走到 (row,col)、起点终点数字都计入的最小路径和」。

④ 每类格子的合法前驱不同(本题与 62 的关键差别):62 的边界格答案恒为 1,写死即可;本题边界格是"没得选"而不是"免费",必须沿唯一前驱累加:

四类格子的身份表:谁有资格当前驱,转移分别是什么
格子合法前驱转移
起点 (0,0)grid[0][0]
首行 (0,col)只有左左 + grid[0][col](前缀和式累加)
首列 (row,0)只有上上 + grid[row][0]
内部格上、左都行min(上, 左) + grid[row][col]

⑤ 常见困惑——为什么不能全表统一套 min?因为边界格的"另一个前驱"根本不存在。若图省事把表初始化成 0、对所有格统一取 min(或靠 Python 负下标兜底),不存在的方向会以初始值 0 的假成本冒充"更优前驱",算出的路径和凭空变小,对应的是一条根本不存在的路径。反例见坑①。

⑥ 滚动压缩与方向:同 62,压成一行 minimum_sum[col]:写入前的 minimum_sum[col] 是"上",已更新的 minimum_sum[col-1] 是"左",col 必须从左往右。比 62 额外多一步:每换一行先执行 minimum_sum[0] += grid[row][0]——首列格只有"上"这一个前驱,不在内层循环覆盖范围里,必须手动累加。

⑦ 用原题示例走一遍:grid=[[1,3,1],[1,5,1],[4,2,1]],看滚动数组逐步覆盖:

首行只能从左来,前缀累加:[1,3,1] → [1,4,5]
第二行首列:1+1=2 → [2,4,5]
格 (1,1):min(上 4, 左 2)+5=7 → [2,7,5]
格 (1,2):min(上 5, 左 7)+1=6 → [2,7,6]
第三行首列:2+4=6 → [6,7,6]
格 (2,1):min(上 7, 左 6)+2=8 → [6,8,6]
格 (2,2):min(上 6, 左 8)+1=7[6,8,7]
终点 7,对应路径 1→3→1→1→1 ✓。

⚠ 易错坑 全表统一 dp[i][j]=grid[i][j]+min(dp[i-1][j], dp[i][j-1]) 不特判边界(Python 负下标读到最后一行/列的初始 0):最小反例 [[1,2],[3,4]]——格 (0,1) 处 min(0,1)=0 得 2(真应 3),最终错误输出 6,真值 7。
一维滚动换行时忘了 minimum_sum[0] += grid[row][0]:首列永远停在首行的值。最小反例单列 [[1],[2]]:内层循环为空,错误返回 1,真值 3。
一维滚动 col 倒序:minimum_sum[col-1] 还是上一行值,相当于允许"左上斜走"。最小反例 [[1,1,10],[10,1,1]]:倒序把上一行的 1 当左邻,错误输出 3,真值 4(路径 1→1→1→1)。
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) 滚动数组(朴素二维为 O(m·n))。

高频追问一:"滚动数组哪个方向遍历、为什么?"——col 从左往右:写入前的 minimum_sum[col] 恰是上一行同列("上"),已更新的 minimum_sum[col-1] 恰是本行左邻("左");倒序会把"左"读成上一行的左上,等于允许斜穿(反例见段2坑③)。追问二:"能不能 O(1) 额外空间?"——可以直接把 grid 原地当 DP 表覆盖,省掉滚动数组;代价是破坏输入,面试要先确认允许修改入参,不允许时 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 · 思路(暴力→最优)

一句话总纲:把"每个子串是不是回文"的判定结果存进二维表,按区间从短到长填——两端字符相等、且剥掉两端后的内部区间是回文,就记 True;每记一个 True 就顺手更新最长记录,填完表答案随之确定。(解法二换个枚举对象:从 2n−1 个中心向两侧扩展。)

① 核心洞察——长区间的回文判定,只比内部短区间多一步:判断一个串是不是回文,靠双指针从两端向中间夹逼。回文有天然的递归结构:s[left..right] 是回文 ⟺ 两端字符相等剥掉两端后的 s[left+1..right-1] 也是回文——长区间的判定结果,只差"内部区间的判定结果"一步。暴力做法(枚举全部 O(n²) 个子串、每个花 O(n) 夹逼判定,共 O(n³),n=1000 时约 10⁹ 次比较)慢就慢在这条性质完全没被利用:判 "bacab" 时,内部 "aca" 的回文性早在枚举短子串时就判过,却被所有包着它的长区间反复重判。把每个区间的判定结果存进二维表,重复劳动就全部消失——这是区间 DP。注意本题 dp 的两维不是 1143/72 那种"两个串的前缀长度",而是同一个串的左右端点,属于多维 DP 的另一大家族。

② 状态身份钉死:is_palindrome[left][right] =「闭区间 s[left..right](含两端)是否为回文」。它存的是布尔判定,不是长度——最长答案不在表里,而是填表时每命中一个 True 就拿 window_length 与当前最长比较,更新 best_start/best_length

③ 转移与 base 都从"剥两端"推导:

1. 两端不等:直接 False,内部再好也救不回来。
2. 两端相等、区间短(window_length ≤ 3):剥掉两端只剩空串或单字符,天然回文,无需查表,直接 True。
3. 两端相等、区间长(≥4):True 当且仅当内部 is_palindrome[left+1][right-1] 为 True。

④ 填表顺序从依赖方向推导:[left][right] 依赖的 [left+1][right-1] 是一个更短的区间,所以不能按普通行序扫——必须保证"算长区间之前,短区间已就绪":按 window_length 从 2 递增到 n(等价写法:left 倒序、right 正序)。

⑤ 常见困惑——长度 2 为什么必须并进特判?长度 2 时 left+1 > right-1is_palindrome[left+1][right-1] 读到的是一个"左端在右端右边"的倒置区间,值是初始 False——不特判,"bb" 会被判成非回文。特判条件写 window_length ≤ 3,顺带覆盖长度 3(剥两端剩 1 个字符)。

⑥ 正解②中心扩展——换枚举对象:DP 是"枚举区间、查表判定";反过来可以枚举回文的中心直接生长:每个回文有唯一中心——奇长度以字符为中心、偶长度以相邻字符的缝隙为中心,共 2n−1 个。从每个中心向两侧扩到失配或越界,最后一个合法区间就是该中心的最长回文。取舍:两者都是 O(n²) 时间;DP 花 O(n²) 空间,换来"任意区间是否回文"的可复用判据(LC131 分割回文串预处理的就是同款表);中心扩展 O(1) 空间、常数更小,只求最长时面试首选。

⑦ 常见困惑——expand 为什么返回 left+1, right-1while 退出的那一刻,left/right 已经各多走了一步(要么越界、要么两端失配),最后一个合法闭区间要回退一步。忘回退等于把失配的两个字符也算进回文。

⑧ 用样例验证"先短后长"这一步:原题示例 "babad" 里最长回文只有 3,全部落在短区间特判里,"查内部"分支跑不到;换 "abacaba" 才能看清关键一步:

长度 1 轮:7 个单字符区间全为 True。
长度 3 轮:[2,4]="aca" 两端 a==a 且长度 ≤3 → True。
长度 5 轮:[1,5]="bacab" 两端 b==b,查内部 [2,4] 已为 True → True。
长度 7 轮:[0,6] 整串两端 a==a,查内部 [1,5] 已为 True → True。
每层只做"比两端 + 查内部"两次 O(1) 操作,暴力里的重复夹逼全部消失。

⚠ 易错坑 left/right 双重正序填表:判长区间时内部还没算。最小反例 "abcba":判 [0,4][1,3]="bcb" 还是初始 False → 整串被误判非回文,错误输出 "bcb",真值 "abcba"
window_length ≤ 3 特判:长度 2 读到倒置区间的初始 False。最小反例 "bb":错误输出 "b",真值 "bb"
中心扩展只跑奇中心:偶回文全漏。最小反例 "cbbd":错误输出单字符(如 "c"),真值 "bb"
expand 忘回退、直接返回失配时的 left, right:区间两端各多含一个失配/越界字符。最小反例 "abc":中心 0 返回越界区间 [-1,1] 成为"最长",切片 s[-1:2] 输出空串 "",真值任一单字符(如 "a")。
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))。

高频追问一:"区间 DP 能不能像网格 DP 那样滚动压缩?"——不顺手:它的依赖方向是"短两格的内部区间"(表里的左下方向),不是"上一行";按长度遍历时理论上只需保留长度 L−2 那一批结果(两条对角线),但下标管理复杂、收益小——真要省空间直接换中心扩展。追问二:"有没有 O(n) 解?"——Manacher 算法:利用已求回文的对称性,让每个位置的扩展从"镜像位置已知半径"起步而不是从 0 开始,均摊 O(n);面试说清这个思想即可,极少要求手写。

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 · 思路(暴力→最优)

一句话总纲:建一张 (m+1)×(n+1) 的表,格 (i,j) 记「text1 前 i 个字符与 text2 前 j 个字符的最长公共子序列长度」;两个前缀的末字符相等就左上+1,不等就取 max(上, 左),逐行填到右下角就是答案。

① 核心洞察——公共子序列没法枚举,但它的"最后一步决策"只有三种:最直接的暴力是枚举 text1 的全部 2ᵐ 个子序列,逐个检查是否也是 text2 的子序列,指数级。关键洞察:不管公共子序列长什么样,它的构造总能归结为对两串末字符的一次决策——配对它们、舍弃 text1 的末字符、或舍弃 text2 的末字符;决策做完,剩下的是"两个更短前缀"的同款问题。所以「text1 前 i 个 vs text2 前 j 个」就足以当完整状态。暴力慢的根源正是没按状态归并:"先舍弃 text1 一个字符、再舍弃 text2 一个"与反过来,到达的都是同一对前缀 (i−1, j−1),同一子问题被指数多条决策路径重复展开;按 (i,j) 记忆化后状态总数只有 (m+1)(n+1) 个,再按依赖方向改成逐行递推填表。LC300 最长递增子序列是"单串子序列"的一维版;本题双串对齐,与 LC72 编辑距离共用同一张前缀表,只是 72 求"最少代价"用 min、本题求"最多配对"用 max。

② 状态身份逐字钉死:lcs_length[first_prefix][second_prefix] =「text1前 first_prefix 个字符text2前 second_prefix 个字符的最长公共子序列长度」。下标是"字符个数"不是"字符下标"——第 first_prefix 个字符的下标是 first_prefix−1

③ 常见困惑——第 0 行/列为什么要多开一圈?它代表"空前缀":任何串和空串的 LCS 自然是 0,这圈 0 就是全表递推的种子。不留这一圈,处理首字符时读 [i-1][j-1] 会越界(或被 Python 负下标偷偷指到表尾),只能到处写 if 特判。

④ 转移从"末字符的去向"推导:x = text1[first_prefix-1]y = text2[second_prefix-1]

1. x == y(配对):把这对字符接在"双方各去掉末字符"的 LCS 末尾,=左上 + 1。为什么最优解总可以这样配(交换论证):若某条最长公共子序列没有同时用上 x 和 y,把它的最后一个字符换成这对 x/y,长度不变、仍然合法——配对永远不亏。
2. x ≠ y(舍弃一边):公共子序列的最后一个字符不可能既是 x 又是 y,所以两者至少一个用不上:舍弃 x 得"上",舍弃 y 得"左",取 max(上, 左)。"两边都舍弃"(左上)不必单列——前缀变短 LCS 不会变长,左上 ≤ 上 且 ≤ 左,天然被 max 覆盖。

三个邻格的身份表:谁代表哪种决策
代码里的量它是谁哪个分支用
lcs_length[first_prefix-1][second_prefix-1](左上)双方都去掉末字符后的 LCSx==y:配对,+1
lcs_length[first_prefix-1][second_prefix](上)舍弃 text1 末字符后的 LCSx≠y:候选之一
lcs_length[first_prefix][second_prefix-1](左)舍弃 text2 末字符后的 LCSx≠y:候选之一

⑤ 必混点正面回答——相等时为什么必须走左上,不能写 max(上,左)+1看"上"的身份:它是 LCS(text1 去尾, text2 完整前缀)——这个值内部可能已经用掉了 y;在它基础上再 +1,等于让 y 配对两次。只有左上是"双方都去尾",保证 x、y 都还没被用,配对才合法。反例见坑①。

⑥ 用原题示例走一遍:text1="abcde"(逐行)、text2="ace"(逐列),每行给出该行填完后的 [空,a,c,e] 四格:

行 a:与 "a" 配对,左上 0+1=1;后两格不等,继承左侧 → [0,1,1,1]
行 b:b 与 a/c/e 都不等,整行只做 max 继承 → [0,1,1,1]
行 c:与 "c" 配对,左上 1+1=2 → [0,1,2,2]
行 d:无配对,继承 → [0,1,2,2]
行 e:与 "e" 配对,左上 2+1=3 → [0,1,2,3]
右下角 3,对应公共子序列 "ace" ✓。b、d 两行原样照抄上一行,正是"不等=至少一边用不上"的直观体现。

⚠ 易错坑 相等分支写成 max(上,左)+1:同一字符被配对两次。最小反例 text1="aa"text2="a":第二个 a 又在"上"=1 的基础上 +1,错误输出 2,真值 1——LCS 长度不可能超过短串长度。
前缀个数当字符下标用:写成 text1[first_prefix],当 first_prefix=first_length 时直接 IndexError 越界。记牢:状态下标是"个数",字符下标恒为"个数−1"。
压成一行滚动时不暂存左上:相等分支读到的 dp[j-1] 已是本行新值(身份是"左"),不是上一行的左上。最小反例 text1="ab"text2="bb":处理 b 行时 j=2 在本行新值 1 上 +1,错误输出 2,真值 1。
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)。

高频追问一:"压到一行时 j 什么方向遍历、为什么?"——必须正序,并用一个 prev 变量暂存左上:不等分支需要"左"是本行新值,只有正序能拿到;而左上的旧值会先被覆盖,所以每格写入前先把 dp[j] 旧值存进 prev(它就是下一格的左上)。若 j 倒序,"左"读到的还是上一行旧值,不等分支直接错;若正序但不存 prev,相等分支错(反例见段2坑③)。追问二:"怎么还原 LCS 本体?"——保留完整二维表,从右下角回溯:相等走左上并收下该字符,不等走 max 的来源格;这正是保留 O(mn) 表的价值,滚动压缩后无法回溯。

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 · 思路(暴力→最优)

暴力起手:递归模拟——每一步在 word1 上试插入/删除/替换三种操作,直到两串相同,取最少步数;每层三分支,指数级。慢的根源:操作顺序不同、结果殊途同归——"先删末字符再替换前面"与"先替换再删"最终面对的都是同一对"剩余前缀",同一对前缀的最优解被指数多条操作序列反复求解。按前缀对 (i,j) 记忆化 → 状态只有 (m+1)(n+1) 个 → 逐行递推填表。

① 关键转化——只盯末尾这一个字符:操作序列千变万化,但总可以整理成"从左到右对齐"的过程,把目光锁定在两个前缀的末字符上:word1 的第 i 个字符 x 最终要么与 word2 的第 j 个字符 y 对齐、要么被删掉;y 要么由对齐得来、要么靠插入造出。每种"最后一步"都把问题缩成一个更小的前缀对。这与 LC1143 最长公共子序列是同一张 (m+1)×(n+1) 前缀表:1143 求"最多配对"用 max,本题求"最少代价"用 min,且多一个"替换"来源。

② 状态身份逐字钉死:edit_distance[source_prefix][target_prefix] =「把 word1前 source_prefix 个字符变成 word2前 target_prefix 个字符的最少操作数」。方向是单向的:只对 word1 动手,别中途换视角。

③ 转移从"最后一步的选择"推导:x = word1[i-1]y = word2[j-1](第 i、j 个字符;i、j 即代码里的 source_prefixtarget_prefix):

1. x == y(免费对齐):让 x 直接充当 y,不花操作,剩余任务=前 i−1 → 前 j−1:直接继承左上,不加 1
2. x ≠ y,替换:把 x 改写成 y,两个末字符一次性同时了账,剩 (i−1, j−1):左上 + 1。
3. x ≠ y,删除:把 x 删掉,word2 的任务原封不动,剩 (i−1, j):上方 + 1。
4. x ≠ y,插入:在 word1 末尾插一个 y 去对齐 word2 的第 j 个字符,y 已解决而 x 还没着落,剩 (i, j−1):左方 + 1。

三种付费操作恰好对应三个已算好的邻格,谁便宜选谁:min(左上, 上, 左) + 1

三个来源的身份表:哪个邻格对应哪种编辑操作
代码里的量对应操作直觉读法
replace_cost=左上 [i-1][j-1]替换:把 x 改成 y双方各退一格——x、y 一次性互相了账
delete_cost=上 [i-1][j]删除:删掉 x只有 word1 退一格——x 没了,y 仍待解决
insert_cost=左 [i][j-1]插入:末尾补一个 y只有 word2 退一格——y 已造出,x 仍待解决

④ 必混点正面回答——[i-1][j] 到底对应删还是插?盯"谁的下标退了":i 退=word1 少处理一个字符——word1 的字符只会因"被删"而消失,所以上方=删除;j 退=word2 少一个字符要造——y 已被造出,而 word1 一个字符都没消耗,只能是插入造的,所以左方=插入。口诀:动 i 是在消耗 word1(删),动 j 是在完成 word2(插)。另一个易混视角:对 word1 插入等价于对 word2 删除——本题方向固定"改 word1",两种说法别混着用。

⑤ base 从身份读出来,不是背的:第 0 列=「前 i 个字符 → 空串」:只能逐个删,恰好 i 次,所以第 0 列是 0..m;第 0 行=「空串 → 前 j 个字符」:只能逐个插,恰好 j 次,所以第 0 行是 0..n。保持全 0 等于宣称"删光/插满不要钱"。

⑥ 用样例验证一步:"abc" → "adc":格 (2,2)=「"ab"→"ad"」:b≠d,三来源=替 [1][1]=0、删 [1][2]=1、插 [2][1]=1,min+1=1(把 b 替成 d);右下角 (3,3)c==c 免费继承左上 1 → 答案 1 ✓,与直觉"只需一次替换"一致。

⚠ 易错坑 base 忘初始化(保持全 0):最小反例 word1="abc"word2="":直接返回 edit_distance[3][0]=0,真值 3(删三次)。
不分支、统一 min(三来源)+1(相等也 +1):最小反例 "a" → "a":错得 min(0,1,1)+1=1,真值 0。
漏掉"插入"来源(只取替/删两个 min):最小反例 "a" → "ab":格 (1,2) 只看替 [0][1]=1、删 [0][2]=2,错得 2,真值 1(末尾插个 b 即可)。
一维滚动不暂存左上:相等分支读到本行已覆盖的新值。最小反例 "ab" → "b":处理 b 行时本应免费继承左上旧值 1,却读到新值 2,错误输出 2,真值 1(删掉 a)。
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)。

高频追问一:"滚动到一行怎么写、j 什么方向?"——保留一行 dp[0..n]:每行开头先 prev = dp[0]、再 dp[0] = i(第 0 列 base);j 正序遍历,每格先把 dp[j] 旧值存下(它是"上",也是下一格的 prev),用 prev 当左上、dp[j-1] 当左。为什么必须正序:"左"来源 [i][j-1] 要本行新值,倒序时它还没算出来;左上靠 prev 暂存解决,不靠遍历方向(忘存 prev 的反例见段2坑④)。追问二:"已知答案很小(≤k)能更快吗?"——能:距离 ≥ |i−j|,答案 ≤ k 时只需填主对角线两侧宽 k 的带状区域,O(k·min(m,n));带外格子的距离必大于 k,可直接视为无穷大。

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(n log n) 达不到线性。

约束排除法:题目钉死"线性时间 + 常量空间":O(1) 空间杀掉哈希表/集合这类"记住见过谁"的方案,线性时间杀掉排序。剩下唯一的路:用常数个变量边扫边把整个数组"消化"掉——需要一种聚合运算,能让成对的值自动抵消、只留下单身的那个。

关键转化——把"找单身"换成"选一种会自我抵消的运算":这个运算得满足三条:结果与顺序无关(交换律+结合律,因为成对的两个数不一定相邻);同一个数作用两次等于没作用(x^x=0);有单位元可当初值(x^0=x)。按位异或 ^ 恰好三条全中。于是第 4 段那行 answer ^= number 把全数组串成一条链 a1^a2^…^an,成对项全灭,剩下的就是答案。这和 LC169 多数元素 同属"让噪声互相抵消、留下幸存者"一族——那题抵消的是不同的元素,本题抵消的是相同的元素。

answer 的身份(角色单一,直接说清):循环中任一时刻,answer=已扫过元素的异或和=每个二进制位上一只"奇偶开关"——该位到目前见过奇数个 1 就是 1,偶数个就是 0。它不记谁出现过、也不记出现几次,只记每位的奇偶;正因为只需要奇偶,一个 int 就装下了哈希表里真正有用的那一小部分信息。

初值从直觉推导:"还没扫任何元素"时,异或和应当表示"什么都没有",即单位元 0——所以 answer=0 起步;第一个元素进来后 0^a1=a1 原样保留,第一项不需要任何特殊处理。

常见困惑——成对的两个数中间隔着别的数,也能消掉吗?能。交换律+结合律保证异或链可以任意重排、任意加括号[4,1,2,1,2] 可重排成 4^(1^1)^(2^2)=4^0^0=4,两个同伴隔多远都会"隔空相消"。另一个困惑——负数怎么办?异或按补码逐位进行,符号位也只是普通的一位,出现两次照样抵消,不用任何特判。

用样例逐位验证:[4,1,2,1,2],二进制 4=100、1=001、2=010。第 0 位共两个 1(两个 1 贡献)→ 偶数 → 0;第 1 位共两个 1(两个 2 贡献)→ 0;第 2 位只有一个 1(4 贡献)→ 1。拼回来 100=4 ✓,与"每位只留奇偶"的身份完全自洽。

⚠ 易错坑 初值写 answer=nums[0] 却仍遍历全部元素——第一个数被异或两次、自我抵消。最小反例 [2,2,1]:算出 2^2^2^1=3,真值 1。要么初值 0 扫全部,要么初值 nums[0] 从 nums[1:] 扫起。
把异或照搬去"其余出现三次"的变体——两两归零的前提破了。最小反例 [2,2,2,3]:全体异或=2^3=1,真值 3;该变体要逐位统计 1 的个数再 % 3
担心负数而先取 abs 预处理——符号信息被抹掉。最小反例 [-1,-1,-5]:abs 后异或得 5,真值 −5;补码逐位异或本来就正确,别画蛇添足。
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),一个累加器。高频追问一:"其余元素出现三次怎么办?"两两抵消失效,改为逐位统计所有数在该位上 1 的个数并对 3 取模,余数拼出答案,O(32n) 时间、仍 O(1) 空间。追问二:"如果有两个数各出现一次?"全体异或得到 x^y(必不为 0),取其中任意一个为 1 的位把数组分成"该位为 1 / 为 0"两组——两个单身数必被分开、成对数必同组,两组各自再异或即得两个答案。

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) 空间;或排序后取 nums[n//2](多数占一半以上,排完必然覆盖正中下标)——O(n log n)。慢的根源:哈希为每个不同值维护完整计数,可最后只需要一个赢家,绝大部分计数白记了;排序则为"全序"付费,我们只关心"谁过半"。

约束排除法:进阶钉死 O(n) 时间 + O(1) 空间:O(1) 空间杀掉哈希计数,O(n) 杀掉排序。只剩一条路:常数个变量一遍扫——信息必须边走边扔,只保留"当前的潜在赢家"和"它领先多少"。

关键转化——把"数谁最多"换成"成对删除":"出现次数 > ⌊n/2⌋"等价于多数元素比其余所有元素加起来还多。于是设计删除规则:每次删掉两个不同的元素。一次删除至多消耗一个多数元素,而它的存量严格超过其余元素总和——其余元素必先被耗光,多数元素删不完,最后幸存的必是它。这与 LC136 只出现一次的数字 同族:"让噪声互相抵消、留下幸存者";差别在 136 抵消相同的一对,本题抵消相异的一对。Boyer-Moore 摩尔投票就是这套删除规则的 O(1) 实现:count -= 1 那一步=把当前元素和候选池里的一个候选一起丢掉。

两个变量的身份(count 最易误读):

身份表:candidate / count 各自是谁、不负责什么
代码里的量它是谁它不是什么
candidate当前幸存的候选=还没被"成对删除"耗光的那个值中途值无意义,可能被换掉多次;只有扫完后的最终值才是答案
countcandidate 尚未被抵消的净票=已扫前缀里它比"异己"多出的个数不是出现次数;归零只说明前缀恰好两两删光,不说明 candidate 出局
count==0 的时刻已扫前缀被整对整对删空,问题在剩余后缀上"重启"不是"真多数已败"的证据——真多数在后缀里仍然过半

循环体两步的顺序从直觉推导:每轮先问"候选席空不空"(count==0 就把当前元素扶上候选席),然后无论如何执行加减——刚上任的候选遇到的第一个元素就是它自己,必然 +1。若把"换候选"和"加减"写成互斥分支,新候选上任那一轮就少 +1,票数永远起不来(见坑①)。

常见困惑——count 归零时把 candidate 扔了,万一扔掉的是真多数呢?可能真是它,但没关系:归零意味着这段前缀里 candidate 与异己恰好一比一删光,其中被删掉的多数元素至多占前缀的一半;而它在全数组过半,扣掉前缀里至多一半的损耗,它在剩余后缀里仍然过半——后面会重新当选。投票丢掉的只是"暂时的账面",丢不掉"总量优势"。

用样例验证这次"重启":[2,2,1,1,1,2,2] 扫到下标 3 时 count 归零——前缀 [2,2,1,1] 恰是两对 (2,1) 互删;剩余后缀 [1,2,2] 里 2 仍占 2/3 过半。虽然下标 4 一度改立 1,最后一个 2 在下标 6 把候选夺回,最终 candidate=2 ✓(逐轮过程见第 3 段表)。

⚠ 易错坑 把"换候选"与"加减"写成互斥分支(if count==0: candidate=number,本轮不再加票)。最小反例 [2,2,1]:三轮 count 始终为 0,每个元素轮流当候选,返回最后的 1,真值 2。
题目不保证多数存在时直接返回 candidate。最小反例 [1,2,3]:投票返回 3,但 3 只出现 1 次、并未过半——必须二次扫描数一遍 candidate 的真实次数再下结论。
count 当出现次数用。最小反例 [3,2,3]:结束时 count=1,而 3 实际出现 2 次——count 是净票差,别拿它做任何"次数"结论。
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(n)。空间 O(1),仅候选与净票两个变量。高频追问一:"题目不保证多数存在还能用吗?"能,但投票只给出"唯一可能的候选",必须二次扫描确认其次数真的 > ⌊n/2⌋,否则会把相对多数误当绝对多数。追问二:"找出现次数 > ⌊n/3⌋ 的所有元素?"这样的元素至多 2 个,维护两个候选+两个计数(删除规则变成"三个两两不同的一起删"),最后同样要回扫验证。

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 · 思路(暴力→最优)

暴力起手:最省事是调库排序,O(n log n),且题目明说不能用库函数。合规的直觉解是两趟计数排序:第一趟数出 0/1/2 各几个,第二趟按数量回填——O(n) 时间、O(1) 空间。慢的根源:读和放被拆成两趟,第一趟只收集"各有几个"、第二趟才动手写,每个元素被摸两遍;进阶要一趟,就必须看到一个元素当场把它送进最终区域。

约束排除法:禁调库杀掉 sort;"只扫一趟"杀掉计数排序;O(1) 空间杀掉开新数组按值分桶。剩下的路只有:原地交换——扫的过程中把 0 甩到左端、2 甩到右端、1 留在中间。

关键转化——不是"排序",是"分区":值只有 0/1/2 三种,排序退化成把数组切成三段的分区问题,这正是快排 partition 的三向切分(荷兰国旗)。挂钩已学题:LC283 移动零就是它的两段版(非零甩前、零留后);本题多一种值,就多一个指针、多守一段区域。

三个指针的身份(每个都由不变量定义):

身份表:low / current / high 各自是谁、守住哪段区域
代码里的量它是谁它保证什么
low下一个 0 的落座位置=0 区的右开边界[0,low) 全是 0;[low,current) 全是 1
current未探索区的最左端=正在检查的位置它左边的一切都已归类
high未探索区的最右端=下一个 2 的落座位置(high,end] 全是 2

三条分支从不变量直接推出:current 检查未探索区最左元素——遇 1 本就该留在中段,current++;遇 0 与 nums[low] 交换、low++ current++;遇 2 与 nums[high] 交换、high--,但 current 原地不动。三种待遇的差别只有一个判据:换到 current 位置的那个值,检查过没有

三条分支的差异:核心是"换到 current 的值来自已知段还是未探索区"
遇到和谁交换换到 current 的值来自已检查?current 动作
0nums[low][low,current) 全 1 段或 current 自身已知current++(连 low++)
1不交换就是 current 自己已知current++
2nums[high][current,high] 未探索区右端未知current 不动(仅 high--)

常见困惑——遇 0 交换后,为什么不怕换来一个没检查的值?由不变量推:low 要么落在 [low,current) 这段全是 1 的区间的最左端,换来的是个已知的 1;要么 low==current(前面还没出现过 1),交换的是自己。两种情况换来的都"已归类",current 放心前进。而 high 是未探索区的右端,那个值 0/1/2 都可能——这就是遇 2 后 current 必须原地重查的原因。

循环条件从区间语义推导:未探索区是闭区间 [current,high],非空条件是 current <= highcurrent==high 时还剩最后一个元素没看,必须处理,所以等号不能丢。

用样例验证"遇 2 原地重查":[2,0,2,1,1,0] 第一轮:current=0 遇 2,与 high=5 的 0 交换得 [0,0,2,1,1,2]、high=4——换来的是个 0,current 停在原地重查,下一轮走"遇 0"分支把它归位;若此时贸然 current++,这个 0 的归位就被跳过了。

⚠ 易错坑 遇 2 交换后顺手 current++(本题第一杀手)。最小反例 [1,2,0]:换来的 0 没被重查,输出 [1,0,2],真值 [0,1,2]
循环条件写成 current < high,漏掉未探索区最后一个元素。最小反例 [1,0]:current=1、high=1 时循环已退出,输出 [1,0],真值 [0,1]
遇 0 交换后只 low++current++。最小反例 [0,1]:第二轮 low(1) 与 current(0) 交叉,把已就位的 0 和 1 又对调成 [1,0],随后 low 越界——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):每轮要么 current++ 要么 high--,未探索区 [current,high] 每轮至少缩 1,至多 n 轮。空间 O(1),三个指针原地交换。高频追问一:"计数排序也是 O(n)+O(1),为什么还要三指针?"计数要两趟且第二趟凭计数重构数组,只适用于值可凭空构造的场景;三指针一趟完成且只靠交换,元素是复杂对象(只能 swap、不能重建)或数据只能流过一遍时依然可用。追问二:"能推广到 k 种颜色一趟吗?"三向切分是三种值的特例;k 更大时单调指针不够用,一般退回计数排序或多轮 partition。

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 · 思路(暴力→最优)

暴力起手:生成全部 n! 个排列、排序、找当前排列的后一个——O(n!·n),n=100 时是天文数字。慢的根源:绝大多数排列和答案毫无关系;"下一个"是个局部性质——答案和当前排列的左边一大段完全相同,只有靠右的一小段需要动。

先把主线钉成一句话:下一个排列=用同一批数字拼出的、比当前大的排列里最小的那个。后面三步全部服务于同一个目标——变大,但变得尽可能少

常见困惑——"字典序"到底是什么序?就是字典排单词的规则搬到数组上:逐位比较,第一个不同的位置定胜负。注意元素之间按数值比,所以数组 [2,…] < [10,…](因为 2<10),和字符串比较里 "10"<"2" 不同。把整个排列当成一个多位数看,字典序恰好就是数值从小到大——全部排列按此排开构成一本"排列字典",本题求的就是紧邻的下一本页码。由此"变得尽可能少"落成两条:改动的位置越靠右越好;被改的那一位增幅越小越好

关键转化——哪一位还能"变大"?某一位能靠重排"它右边的数字+它自己"变大,当且仅当它右边存在比它大的数。于是从右往左找第一个升序对的左端 pivot(满足 nums[pivot] < nums[pivot+1]):它就是最靠右的、右边还有更大数字可用的位——唯一动手处。它右边整段是降序的,降序=这段自身已是能排出的最大排列,内部怎么重排都变不大。(另一个常见困惑:"pivot 右边的后缀"指什么?后缀=从某位置到末尾的整段 nums[pivot+1..n-1],不含 pivot 本身。)

四个抽象量的身份:

身份表:三步算法里每个量在"变大但变得尽可能少"中的分工
代码里的量它是谁为"最小的变大"提供什么
pivot=从右第一个升序对的左端最靠右的、右边还有更大数字可用的位唯一动手处——动更右边不可能变大,动更左边变得太多
pivot 右侧的降序后缀已达自身最大、内部无法再变大的区域证明"必须动 pivot";也是末步要归零的材料
successor=从右第一个 > nums[pivot] 的元素后缀中大于 nums[pivot] 的数里最小的(降序从右往左看=从小到大,第一个即最小)把 pivot 位抬得尽可能少
末步反转后缀高位抬起后,右边整段从"最大"归零成"最小"低位取全局最小,整体才是"紧邻的下一个"

三个"白送"的性质(扫描过程免费附赠,不用二次检查):

① 后缀降序不用验证:找 pivot 的扫描每往左停留一步,就当场验证了一次 nums[pivot] >= nums[pivot+1]——扫描本身就是"右侧后缀降序"的证明。

② 交换不破坏后缀降序,反转即升序,不用 sort:由 successor 的选法,nums[successor+1..] ≤ nums[pivot] < nums[successor] ≤ nums[successor-1];换过去的旧 nums[pivot] 恰好仍夹在两侧邻居之间,降序不破。所以第三步只需 O(n) 反转,而不是 O(n log n) 排序。

③ 整体降序零特判:找不到升序对时 pivot=-1(整体降序=字典序最大),跳过交换,反转起点 pivot+1=0 恰好反转全数组、绕回最小排列——同一段代码天然覆盖题目要求的"回卷"。

两处比较的严格性方向相反(最易写反,判据其实同一条):找 pivot 时 nums[pivot] >= nums[pivot+1] 继续左移——相等不算升序对:抬一个相等的数上来数值不变,换汤不换药;找 successor 时用严格 >——换一个等于 nums[pivot] 的数上来同样不变大。两处共用一个判据:"这一步能不能让数值真的变大"。

用样例验证:[1,3,5,4,2]:从右扫 4≥25≥4 继续,3<5 停——pivot=1(值 3);从右找第一个 >3 的:2 不行、4 行——successor=3(值 4);交换得 [1,4,5,3,2],后缀 [5,3,2] 仍降序,反转得 [1,4,2,3,5] ✓。再验含重复的 [1,5,1]5≥1 继续、1<5 停——pivot=0;从右找第一个 >1 的:末位 1 不行(严格 >),5 行——交换得 [5,1,1],后缀 [1,1] 反转不变,511 正是 {1,1,5} 能拼出的比 151 大的最小数 ✓。

⚠ 易错坑 两处严格性写反。找 pivot 若"遇相等就停"(把相等当升序对),最小反例 [2,2,1]:pivot 误停在 0,可后缀里没有 >2 的数,找 successor 直接越界/乱换;真值应是整体反转得 [1,2,2]。找 successor 若允许换相等的(用 ),最小反例 [1,5,1]:和末位的 1 对换等于没换,反转后得 [1,1,5]=115 反而变小,真值 [5,1,1]
交换后漏掉反转。最小反例 [1,3,5,4,2]:只交换得 [1,4,5,3,2]=14532,但 14235 也比 13542 大且更小——跳号,不是"下一个"。
pivot=-1 写特判并直接 return(忘了还原升序)。最小反例 [3,2,1]:输出原样 [3,2,1],真值 [1,2,3];正解无需特判,反转起点自动退化为 0(见白送性质③)。
3 · 图解
nums = [1,3,5,4,2]

第 1 步|从右找升序对:3 < 5,pivot 停在下标 1(值 3)
          [1, |3|, 5,4,2]
第 2 步|从右找最小的更大值:4 > 3,successor 停在下标 3(值 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):找 pivot、找 successor、反转各至多一趟线性扫描。空间 O(1),原地交换与翻转。高频追问一:"第三步为什么敢用反转代替排序?"因为交换保持了后缀降序(白送性质②),降序段反转即升序——把 O(n log n) 省成 O(n)。追问二:"最坏输入会退化吗?"不会——整体降序(如 [3,2,1])时找 pivot 扫满全程、再整体反转,仍是两趟 O(n)。

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 log n));哈希集合边扫边查(O(n) 时间、O(n) 空间);把 nums[v-1] 取负当"到访标记"(O(n),看似 O(1) 空间)。

约束排除法(本题的约束就是路标):"不能修改数组"杀掉排序,也杀掉负号标记——取负就是修改;"O(1) 空间"杀掉哈希。三刀下来,所有"把历史存起来 / 写回数组"的方案全被封死,只能靠"走"出信息。合规的保底解是值域二分+抽屉计数:数一遍 ≤mid 的元素个数,若 > mid 说明重复数落在 [1,mid]——O(n log n)、不改数组、O(1) 空间;但进阶要 O(n),还得再挖。

关键转化——读出题面的刻意设计:长度 n+1、值域 [1,n],即值域 ⊆ 下标域 [0,n]:每个值都是一个合法下标。这不是巧合,是信号——值可以当下标用。规定"下标 i 的下一站是 nums[i]",数组就成了一条隐式链表:下标是节点、nums[i] 是 next 指针,每个节点出度恰为 1。LC41 缺失的第一个正数 读到的是同一个信号(那题可改数组,走原地桶归位;本题不可改,只能在链上走)。

枢纽等式——重复值为什么恰是环入口:一条指向节点 d 的边来自某个 nums[i]=d,也就是"值 d 出现了一次",所以节点 d 的入度=值 d 的出现次数——找重复数=找入度 ≥ 2 的节点。再看路径形状:值域从 1 起、不含 0 → 没有任何边指向节点 0 → 节点 0 入度为 0、必在环外;从 0 出发每步都合法、走不停,而节点有限 → 必兜进环;起点在环外 → 路径必是 ρ 形(非空尾巴+圈,不可能是纯圆形)。尾巴与圈的交界点(环入口)同时吃进两条边:尾巴上的前驱 u 和环内的前驱 v;首次走重之前所有节点两两不同,故 u≠v,而 nums[u]=nums[v]=入口——入口入度 ≥ 2,它就是重复值。问题就此化归为"带环链表找环入口",LC142 环形链表II 的 Floyd 两阶段原样搬来。

身份表:数组 → 隐式链表的翻译对照
代码里的量它在链表视角里是谁关键性质
下标 i节点编号(0..n 共 n+1 个)出度恒为 1:下一站唯一
nums[i]节点 i 的 next 指针值域 [1,n] 保证永不越界、永不指回节点 0
节点 0链表头=唯一有保证入度为 0 的节点必在环外——Floyd 必须从它出发
环入口尾巴与圈的交界=入度 ≥ 2 的节点=重复的那个值=要返回的答案
slow / fastLC142 的快慢指针,一步 / 两步初值 nums[0]=都已从节点 0 迈出第一步,同步不破

阶段二"回起点同速走"停在入口的公式(LC142 原样复用):设头到入口距离 a、入口到相遇点 b、环长 c。相遇时 fast 路程是 slow 的两倍:2(a+b)=(a+b)+kca+b=kca=(k-1)c+(c-b)——"从头走 a 步"与"从相遇点走 c−b 步再绕整圈"同时落在入口,同速前进必在入口会合。起点必须是节点 0:它是唯一有保证在环外的节点,公式里的"头"非它莫属。

常见困惑——nums[d]=d 的自环怎么办?自环就是长度为 1 的环,照常跳(下一站还是自己)。能从 0 走到的自环必有一条外部边指向它,加上自指共两条入边——重复实锤,Floyd 会正常停在这里。走不到的孤立自环(如 [1,1,2] 里节点 2 自指)与答案无关,永远不会被访问。另一个担心"图里环多了会不会走错环"也不存在:出度恒为 1,从 0 出发的路径唯一,进了环就出不来,全程只会遇到这一个环。

用样例走一遍(与第 3 段两张表一致):[1,3,4,2,2] 的链是 0→1→3→2→4→2→4…:尾巴 0→1→3,环 2→4→2,入口 2=重复值。阶段一(初值 slow=fast=nums[0]=1):第 1 轮 slow=3、fast=2;第 2 轮 slow=2、fast=2——相遇于 2。阶段二 slow 重回 nums[0]=1、fast 留在 2:第 1 轮 1→3、2→4;第 2 轮 3→2、4→2——会合于入口 2,返回 2 ✓。

⚠ 易错坑 起点随手换成别的下标。起点一旦落在环内,"头到入口距离 a"的推导整个失效。最小反例 nums=[1,2,3,1](链 0→1→2→3→1,重复值 1):从节点 2 出发,阶段一相遇于 2,阶段二一步没走就"会合"于 2,返回 2,真值 1。
阶段一先判后走。初始 slow==fast,进循环先判相等会立刻假相遇退出。最小反例 [1,3,4,2,2]:阶段一零步退出,阶段二 slow=1、fast=1 直接相等,返回 1,真值 2。必须"先各走完一轮再比"(do-while 语义)。
把阶段一的相遇点当答案,或阶段二 fast 仍走两步。相遇点只是环内某点,不保证是入口。最小反例 nums=[2,3,1,1](重复值 1):阶段一相遇于 3,直接返回相遇点得 3;阶段二若 fast 继续两步,也会停在 3——真值 1。
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):入环前至多 n 步;两指针都进环后,fast 每轮把相对距离缩 1,至多环长 c 轮必相遇;阶段二再走 a ≤ n 步。空间 O(1),两个指针、不改数组。高频追问一:"为什么一定有重复、一定有环?"抽屉原理:n+1 个值落进 [1,n] 只有 n 个抽屉,必有重复;每步 x=nums[x] 都落在合法下标、节点有限,走不停必兜环。追问二:"fast 一次跳两步,会不会跨过 slow 永不相遇?"不会——两者都进环后每轮相对距离恰缩 1,只会精确追上,不存在跨越。追问三:"如果允许改数组或 O(n) 空间?"哈希 O(n)/O(n)、负号标记 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