THE PROBLEM COLLECTION
题库100
按专题查找,沿一种思路读透。
试试其他关键词,或重置筛选。
使用与运行说明
图示和交互可离线使用。按 / 搜索;选择具体题目进入阅读。图解可切步、重播及放大。代码与进阶材料可展开。
网页展示完整 Python 3 源码;复制到本地解释器即可运行,导入及节点定义已包含。把示例断言接在代码之后可检查结果,网页不执行 Python。
题单采用2026-09-11核对的力扣官方 Hot 100。题意为原创概括;教学输入在各题注明。
1. 两数之和 简单
一句话题意
在整数数组中找到两个不同位置,使对应数值之和等于 target,返回这两个下标;题目保证唯一答案。
nums=[4,3,7,3],target=6 → 返回 [1,3]。
生活化理解
你拿着一张面值为 target 的餐券,想用两件商品正好花完。每看到一件价格 x 的商品,就先问登记簿里有没有价格 target−x 的旧商品。登记簿存的是“价格→货架位置”,因为最后要交的是位置而不是价格。
一次遍历哈希表
用补数查找把两层枚举化为一次扫描,先查后存也能直接解释为什么不会使用同一位置两次。核心思路
把条件 x+y=target 改写为 y=target−x,原本需要枚举的第二个数变成了一次查找。遍历到下标 i 时,seen 只包含下标小于 i 的元素,键是数值,值是一个对应下标。这个不变量直接保证命中的两个位置不同。
必须先查补数、再登记当前元素。例如 [3,3]、target=6:第一个 3 查不到后登记,第二个 3 找到前面的下标 0。若先登记后查,第一次就可能把下标 0 用两遍。重复值覆盖旧下标不会破坏正确性:尚未找到答案时,保留任意一个旧位置即可。
每对答案总有一个较晚出现的位置;处理它时,较早那个位置已经在表里,因此不会漏解。哈希操作平均为常数时间,所以总时间是平均 O(n)。
在查询下标 i 时,seen 只包含严格早于 i 的元素位置,因此不会重复使用同一项。答案中较晚的元素被读到时,较早那个元素已经登记,故一次遍历不会漏解。
图解
- seen
- {}
- target
- 6
- i
- 0
- x
- 4
- need
- 2
- seen
- {}
- i
- 0
- need
- 2
- seen
- {4: 0}
- i
- 1
- x
- 3
- need
- 3
- seen
- {4: 0}
- i
- 1
- need
- 3
- seen
- {4: 0, 3: 1}
- i
- 2
- x
- 7
- need
- -1
- seen
- {4: 0, 3: 1}
- i
- 2
- need
- -1
- seen
- {4: 0, 3: 1, 7: 2}
- i
- 3
- x
- 3
- need
- 3
- seen
- {4: 0, 3: 1, 7: 2}
- i
- 3
- seen
- {4: 0, 3: 1, 7: 2}
- 返回值
- [1, 3]
选择步骤 9 步
变量与图例
观察每次“先查补数、再存当前值”:两个 3 数值相同,位置却不同。
- i / x
- 当前下标及 nums[i] 的值
- need
- 当前缺少的值 target−x
- seen
- 已读数值映射到一个旧下标
- target
- 两数之和的目标值
算法步骤
- 令 seen 为空,按原始顺序读取下标 i 和数值 x。
- 计算 need=target−x,在 seen 中查 need。
- 命中时返回 [seen[need], i];两个下标分别来自已读部分和当前位置。
- 未命中时登记 seen[x]=i,然后处理下一项。
代码(Python)
查看完整 Python 代码一次遍历哈希表 · 与上方图解对应
seen 仅记录已经读过的数值和下标。对当前 x 查询 target−x,把“找另一项”的线性扫描压成平均常数时间。
from typing import List
class Solution:
def twoSum(self, nums: List[int], target: int) -> List[int]:
seen = {}
for i, x in enumerate(nums):
need = target - x
if need in seen:
return [seen[need], i]
seen[x] = i
return []
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
for nums, target in [([2, 7, 11, 15], 9), ([3, 3], 6), ([-3, 4, 3, 90], 0), ([3, 2, 4], 6)]:
pair = Solution().twoSum(nums, target)
assert len(pair) == 2 and pair[0] != pair[1]
assert nums[pair[0]] + nums[pair[1]] == target
复杂度
- 时间
- 平均 O(n),n 为数组长度;依赖哈希查找的平均常数复杂度。
- 空间
- O(n) 辅助哈希表,输出两个下标为 O(1)。
易错点与边界
- 两个值可以相同,两个位置必须不同;[3,3] 是合法答案。
- 先查表再插入当前项,避免把自己当作补数。
- 含负数和零时补数公式仍然有效,不能假设数组有序。
- 返回原数组中两个不同位置的下标,不是两项数值。
主解之后,再比较与迁移。
其他解法 1
双重枚举(基线)时间 O(n²),最多检查 n(n−1)/2 个下标对。
固定第一个下标 i,只枚举 j>i,直接检查所有不同位置组合。它不保存补数表,适合先理解题意或用小数据核对优化结果;代价是最多检查 n(n−1)/2 对,大数组会比哈希法慢很多。
- 依次枚举第一个位置 i。
- 让 j 从 i+1 扫到末尾,保证两个位置不同且同一对不重复枚举。
- 若 nums[i]+nums[j]==target,立即返回 [i,j]。
from typing import List
class Solution:
def twoSum(self, nums: List[int], target: int) -> List[int]:
for i in range(len(nums)):
for j in range(i + 1, len(nums)):
if nums[i] + nums[j] == target:
return [i, j]
return []
复杂度
- 时间
- O(n²),最多检查 n(n−1)/2 个下标对。
- 空间
- O(1) 辅助空间和输出空间。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
for nums, target in [([2, 7, 11, 15], 9), ([3, 3], 6), ([-3, 4, 3, 90], 0), ([3, 2, 4], 6)]:
pair = Solution().twoSum(nums, target)
assert len(pair) == 2 and pair[0] != pair[1]
assert nums[pair[0]] + nums[pair[1]] == target
预测练习
读到下标 1 的第一个 3 时,能立即返回 [1,1] 吗?
一点提示
看这一帧查询前的 seen 是否已经包含数值 3。
答案与理由
不能。此时 seen={4:0},补数 3 尚未出现;先查后存会等到下标 3 的另一个 3 才返回 [1,3]。
面试追问与解法取舍
如果输入已经有序,左右指针能在 O(n) 时间、O(1) 辅助空间求解;无序输入不能直接套用。如果要求返回所有下标对,单个下标不够,要保存同一值的多个位置,而且输出本身可能有 O(n²) 对。面试时先确认输出是一个答案、所有下标对,还是去重后的数值对。
其他思路的边界
- 返回下标,不是两项数值;排序会改变位置,需要保留原下标。
模板迁移 / 相似题
试着不看代码,复述这三点
- 把枚举第二项改为查询 target−x。
- 哈希表保存下标,且先查询再登记,保证两项位置不同。
- 重复数值可以构成答案;不应直接对输入去重。
49. 字母异位词分组 中等
一句话题意
将字符串数组分组:同组字符串的字母种类及每个字母的出现次数完全相同,字符顺序可以不同。
strs=["ab","ba","aa","a"] → [["ab","ba"],["aa"],["a"]](组间顺序不限)。
生活化理解
把单词看作装字母积木的袋子。袋子里积木的摆放顺序不重要,但每种积木的数量必须一样。排序签名相当于把积木排好队拍照;计数签名则是填写一张 26 格库存单。
26 维计数签名
题目限定小写英文字母,26 个频次就能完整表示字母组成,并在线性字符扫描中完成分组。核心思路
异位词的字符顺序可以不同,但每个字母的出现次数必须相同。题目只含小写英文字母,因此为每个词建立长度 26 的 counts:第 k 项记录字母 chr(ord('a')+k) 的数量。ab 与 ba 的计数相同;aa 与 a 虽然都只含 a,计数却不同,不能用字符集合代替频次。
每次进入一个新词,都从 26 个零重新计数;扫描 ch 时增加 counts[ord(ch)-ord('a')]。完成整个词后,把 counts 转成 tuple 作为字典键。列表不可作为哈希键,而元组保留了全部字母的对应位置和重复次数。
groups[tuple(counts)] 存这个签名下已经读到的全部原字符串。每加入一个词,桶内成员的完整频次始终相同,因而恰好互为异位词;每个输入词入桶一次,所以不会漏分。保留原字符串和重复项,空串对应全零元组;返回所有桶即可,组内及组间均不需要额外排序。
每处理完一个词,groups 的每个键都是完整的 26 维频次数组,桶内所有词的该签名完全相同。字母顺序不改变频次,字母数量改变则至少一个维度不同,因此同桶恰好等价于互为异位词。
图解
- groups
- {}
- counts
- 尚未创建
- word
- ab
- counts
- [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
- groups
- {}
- word
- ab
- ch
- a
- counts
- [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
- groups
- {}
- word
- ab
- ch
- b
- counts
- [1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
- groups
- {}
- word
- ab
- counts
- [1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
- groups
- {'(1,1,0,…,0)': ['ab']}
- word
- ba
- counts
- [1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
- groups
- {'(1,1,0,…,0)': ['ab', 'ba']}
- word
- aa
- counts
- [2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
- groups
- {'(1,1,0,…,0)': ['ab', 'ba'], '(2,0,0,…,0)': ['aa']}
- word
- a
- counts
- [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
- groups
- {'(1,1,0,…,0)': ['ab', 'ba'], '(2,0,0,…,0)': ['aa'], '(1,0,0,…,0)': ['a']}
- groups
- {'(1,1,0,…,0)': ['ab', 'ba'], '(2,0,0,…,0)': ['aa'], '(1,0,0,…,0)': ['a']}
- 返回值
- [['ab', 'ba'], ['aa'], ['a']]
选择步骤 9 步
变量与图例
先观察 ab 怎样逐字符计数,再比较 ba、aa、a 为什么进入不同的桶。
- word
- 当前输入词
- ch
- 当前计数的字符
- counts
- 当前词中 a…z 各自出现次数
- groups
- 频次 tuple → 具有此签名的原词列表
算法步骤
- 建立 groups=defaultdict(list),依次读取每个 word。
- 为当前词重新创建 counts=[0]*26,逐字符增加相应字母的频次。
- 词内计数完成后,以 tuple(counts) 为键,把原 word 追加到对应桶。
- 全部单词处理完成后,返回 list(groups.values())。
代码(Python)
查看完整 Python 代码26 维计数签名 · 与上方图解对应
counts 保存每个小写字母的频次;tuple(counts) 是不可变键。总字符数较大时省掉排序,但固定 26 格只适用于当前字符集约束。
from collections import defaultdict
from typing import List
class Solution:
def groupAnagrams(self, strs: List[str]) -> List[List[str]]:
groups = defaultdict(list)
for word in strs:
counts = [0] * 26
for ch in word:
counts[ord(ch) - ord('a')] += 1
groups[tuple(counts)].append(word)
return list(groups.values())
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
def normalized(groups):
return sorted(tuple(sorted(g)) for g in groups)
assert normalized(Solution().groupAnagrams(["eat", "tea", "tan", "ate", "nat", "bat"])) == normalized([["eat", "tea", "ate"], ["tan", "nat"], ["bat"]])
assert normalized(Solution().groupAnagrams(["", ""])) == [("", "")]
assert normalized(Solution().groupAnagrams(["a", "ab", "ba", "a"])) == [("a", "a"), ("ab", "ba")]
复杂度
- 时间
- O(L+26n),L 为所有字符串的总长度,n 为字符串数;固定字母表下为 O(L+n)。
- 空间
- O(26g+n),g 为组数;26g 为签名,n 为分组中的字符串引用,单个临时计数 O(26)。
易错点与边界
- 不能用 set(word) 作为签名,它丢失字母出现次数。
- 保留重复的输入字符串,分组不是去重。
- 计数键要转成 tuple,不能直接把 list 当字典键。
- 测试应规范化组顺序后比较,题目允许任意输出顺序。
主解之后,再比较与迁移。
其他解法 1
排序签名时间 O(n+Σ |s| log(|s|+1));若 n 个词、最大词长 m,可写 O(n+nm log(m+1)),空串仍需遍历和分桶。
把每个词的字符排序,得到规范代表 key:eat 与 tea 都变成 aet,ab 与 aab 则不同。排序签名相同当且仅当字符多重集合相同,因此按 key 分桶不会误分或漏分。它写法短,也能用于更广泛字符集;与计数主解相比,每个词多一次排序。groups[key] 仍保存原词和重复项,空串的签名为空串。
- 建立 groups=defaultdict(list),依次读取 word。
- 计算 key=''.join(sorted(word)),保留重复字符,生成排序签名。
- 把原 word 追加到 groups[key],不把排序后的签名当作输出词。
- 返回 list(groups.values()),无需再对答案排序。
from collections import defaultdict
from typing import List
class Solution:
def groupAnagrams(self, strs: List[str]) -> List[List[str]]:
groups = defaultdict(list)
for word in strs:
key = ''.join(sorted(word))
groups[key].append(word)
return list(groups.values())
复杂度
- 时间
- O(n+Σ |s| log(|s|+1));若 n 个词、最大词长 m,可写 O(n+nm log(m+1)),空串仍需遍历和分桶。
- 空间
- O(n+Σ不同签名的长度),含分组引用和字典键;单词排序临时 O(m),原字符串不复制。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
def normalized(groups):
return sorted(tuple(sorted(g)) for g in groups)
assert normalized(Solution().groupAnagrams(["eat", "tea", "tan", "ate", "nat", "bat"])) == normalized([["eat", "tea", "ate"], ["tan", "nat"], ["bat"]])
assert normalized(Solution().groupAnagrams(["", ""])) == [("", "")]
assert normalized(Solution().groupAnagrams(["a", "ab", "ba", "a"])) == [("a", "a"), ("ab", "ba")]
预测练习
处理 aa 后,接着读 a 时能沿用刚才的 counts 而不清零吗?
一点提示
aa 已把 a 的计数设为 2;考虑再加一次会得到什么签名。
答案与理由
不能。沿用会错误得到 a=3;应重新建 26 个零,再得到 a=1,创建独立的桶。
面试追问与解法取舍
若字符集变成任意 Unicode,排序签名仍可直接使用;计数法可改成 Counter 后对键值对排序生成不可变签名。不要只把单词字符的哈希值相加,那样不同的字符多重集合可能产生同一个签名;规范化键需要保留足够信息,字典自身会处理普通哈希碰撞。
模板迁移 / 相似题
试着不看代码,复述这三点
- 异位词比较字母的频次,而非字符顺序或字符集合。
- 每词重置 counts;分组表 groups 则贯穿整个遍历。
- 用不可变 tuple 作键,桶中保留原始字符串。
128. 最长连续序列 中等
一句话题意
从无序整数数组中找出数值连续的最长序列长度,序列不要求在原数组中相邻;目标时间为 O(n)。
nums=[6,2,1,3,2,8] → 返回 3;最长连续数值序列为 [1,2,3]。
生活化理解
居民门牌散落在一堆卡片中,要找最长一段没有断号的街区。只有门牌 x−1 不在卡堆时,x 才是街区起点;从每条街的门口走一次,就不会从每户门前重复走到街尾。
哈希集合,只从段首扩展
只让连续段的起点发起扩展,可以直接证明所有扩展合计为线性时间,满足题目的时间要求。核心思路
集合 values 去重并提供平均 O(1) 的成员判断。对每个 x,先检查 x−1:若存在,x 属于更早的连续段,跳过;若不存在,x 是唯一的段首,逐个查 x、x+1、x+2,直到缺失。y 停在第一个不属于集合的数,所以该段长度为 y−x;best 保存已完成连续段的最大长度。
外层虽然嵌着 while,但内层只从段首启动。不同连续段互不重叠,每个不同元素只在某一次扩展中被经过,因此总扩展次数不超过集合大小。必须遍历去重后的集合;如果遍历原数组,很多重复段首都可能重复扫描同一长段。
答案与原数组顺序无关,连续指的是整数差为 1。集合的遍历顺序也不影响结果:一个内部成员即使比段首更早被遍历,只要前驱存在就跳过,完整连续段会在段首被处理时统一计数。
只从不存在前驱 x−1 的段首扩展;段内成员由同一个段首覆盖。扩展结束时 y 是首个缺失值,[x,y) 全部存在,长度正好 y−x,所以去重后每段只完整扫描一次。
图解
- values
- {1,2,3,6,8}
- x
- 尚未读取
- y
- 尚未赋值
- best
- 0
- values
- {1,2,3,6,8}
- x
- 1
- y
- 1
- best
- 0
- values
- {1,2,3,6,8}
- x
- 1
- y
- 2
- best
- 0
- values
- {1,2,3,6,8}
- x
- 1
- y
- 3
- best
- 0
- values
- {1,2,3,6,8}
- x
- 1
- y
- 4
- best
- 0
- values
- {1,2,3,6,8}
- x
- 1
- y
- 4
- best
- 3
- values
- {1,2,3,6,8}
- x
- 3
- y
- 4
- best
- 3
- values
- {1,2,3,6,8}
- x
- 6
- y
- 7
- best
- 3
- values
- {1,2,3,6,8}
- x
- 8
- y
- 9
- best
- 3
- values
- {1,2,3,6,8}
- x
- 8
- y
- 9
- best
- 3
选择步骤 10 步
变量与图例
区分原数组位置与集合中的数值:y 是试探的数值,绝不是数组下标。
- values
- 输入去重后的哈希集合
- x
- 外层当前数值;无前驱时才作为段首
- y
- 内层下一个待检查的数值
- best
- 已完成连续段的最大长度
算法步骤
- 建立 values=set(nums) 消除重复值,初始化 best=0。
- 遍历 values 中的 x,只在 x−1 不存在时启动扩展。
- 令 y=x,只要 y 在 values 中就让 y 加一,直到找到首个缺失值。
- 用 y−x 更新 best;所有集合成员处理完后返回 best,空集合保留初值 0。
代码(Python)
查看完整 Python 代码哈希集合,只从段首扩展 · 与上方图解对应
S 存所有不同值;x−1 不在 S 是启动扫描的唯一条件。y 指向当前待检查的数,best 保存已完成连续段的最大长度。
from typing import List
class Solution:
def longestConsecutive(self, nums: List[int]) -> int:
values = set(nums)
best = 0
for x in values:
if x - 1 not in values:
y = x
while y in values:
y += 1
best = max(best, y - x)
return best
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert Solution().longestConsecutive([100, 4, 200, 1, 3, 2]) == 4
assert Solution().longestConsecutive([]) == 0
assert Solution().longestConsecutive([0, -1, 1, 1, 2]) == 4
assert Solution().longestConsecutive([7, 7, 7]) == 1
复杂度
- 时间
- 平均 O(n),n 为输入长度;所有段的扩展次数合计 O(n)。
- 空间
- O(n) 集合;其余变量 O(1)。
易错点与边界
- 连续序列不是原数组中的连续子数组,不能用窗口按原位置找。
- 从每一个数向后扩展会使 [1,2,...,n] 达到 O(n²)。
- 遍历集合而非原数组,避免重复段首触发重复长扫描。
- 重复值不延长序列;空数组答案为 0,负数同样按差为 1 判断。
主解之后,再比较与迁移。
其他解法 1
排序后去重扫描(比较基线)时间 O(n log n),排序主导;扫描 O(n)。
排序副本 a 使连续数值相邻出现,current 表示当前连续段长度。相等值不增加长度,差为 1 则续接,否则从 1 重新计数;best 保留历史最大值。这一方法适合区分“重复值”和“连续段断点”,但排序需要 O(n log n),不满足本题 O(n) 的目标。
- 输入为空时返回 0;否则建立排序副本 a,令 current=best=1。
- 从第二个元素开始扫描;若 a[i]==a[i−1],直接跳过重复值。
- 若 a[i]==a[i−1]+1,让 current 加一;否则令 current=1。
- 每次处理非重复值后更新 best,最后返回 best。
from typing import List
class Solution:
def longestConsecutive(self, nums: List[int]) -> int:
if not nums:
return 0
a = sorted(nums)
current = best = 1
for i in range(1, len(a)):
if a[i] == a[i - 1]:
continue
if a[i] == a[i - 1] + 1:
current += 1
else:
current = 1
best = max(best, current)
return best
复杂度
- 时间
- O(n log n),排序主导;扫描 O(n)。
- 空间
- O(n),sorted 创建副本且 Python 排序有 O(n) 最坏工作空间。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert Solution().longestConsecutive([100, 4, 200, 1, 3, 2]) == 4
assert Solution().longestConsecutive([]) == 0
assert Solution().longestConsecutive([0, -1, 1, 1, 2]) == 4
assert Solution().longestConsecutive([7, 7, 7]) == 1
预测练习
从 1 扩展到 y=4 停下后,外层读到 x=2,为什么不再从 2 扫到 4?
一点提示
检查 x−1 是否已经在 values 中。
答案与理由
因为 1 存在,2 不是段首;[2,3] 已被从 1 开始的扫描包含。若每个成员都重新扫描,长连续段会产生二次复杂度。
面试追问与解法取舍
若要求返回这条序列,记录刷新 best 时的段首,再输出 start 到 start+best−1,额外输出 O(best)。若数据持续插入并需每次立刻回答,静态“找段首再扫描”会重复工作,可以讨论维护区间两端长度的哈希表;静态题无需引入这套更新机制。
模板迁移 / 相似题
试着不看代码,复述这三点
- set 去重后,连续性依然由数值加 1 决定,不依赖原位置。
- 前驱不存在才扩展,是平均 O(n) 的关键。
- y 停在首个缺口,长度用 y−x,避免多算一项。
283. 移动零 简单
一句话题意
原地把数组中的所有零移到末尾,保持非零元素原有相对顺序,不返回新数组。
nums=[0,1,0,3,12] → 原地变为 [1,3,12,0,0],函数返回 None。
生活化理解
一排储物格里有空盒子(零)和有物品的盒子(非零)。read 负责按顺序检查,write 指向下一格该放物品的位置。物品按被发现的顺序摆到前面,剩余位置统一留空,因此物品之间不会换序。
稳定压缩后补零
先保留非零前缀、再补齐尾部零,两个阶段清楚,便于说明原地修改与相对顺序为何成立。核心思路
读取 nums[read] 前,nums[0:write] 已经按原顺序收集了读过的所有非零值,且 write≤read。write 是下一个非零元素的目标位置:遇到非零值就写入 nums[write] 并推进 write。因为写入位置不在尚未读取区域,原始后续数据不会被提前破坏。
第一次遍历结束后,write 等于非零元素总数,前 write 项已经正确,尾部只是待处理旧数据。再把 [write,n) 全部写成 0 即可。循环顺序保证稳定性:先读到的非零值总是占据更靠左的位置。处理过程中尾部可以暂时保留旧值的副本,不要求它提前全为零。
每轮读取结束后,nums[:write] 恰好是所有已读非零值,且顺序与原输入一致。write 始终不超过下一待读位置,写入不会覆盖还没读过的数据;第二遍只将此前缀之外的位置补零。
图解
- nums
- [0, 1, 0, 3, 12]
- write
- 0
- read
- 0
- write
- 0
- nums
- [0, 1, 0, 3, 12]
- read
- 1
- write
- 1
- nums
- [1, 1, 0, 3, 12]
- read
- 2
- write
- 1
- nums
- [1, 1, 0, 3, 12]
- read
- 3
- write
- 2
- nums
- [1, 3, 0, 3, 12]
- read
- 4
- write
- 3
- nums
- [1, 3, 12, 3, 12]
- write
- 3
- i
- 3
- nums
- [1, 3, 12, 0, 12]
- write
- 3
- i
- 4
- nums
- [1, 3, 12, 0, 0]
- nums
- [1, 3, 12, 0, 0]
- write
- 3
- 返回值
- None(原地修改)
选择步骤 9 步
变量与图例
留意第一遍结束时的 [1,3,12,3,12]:尾部副本尚未补零,不是最终答案。
- read
- 第一遍当前读取下标
- write
- 非零前缀长度,也是下次写入下标
- i
- 第二遍补零的下标
- nums
- 正在原地修改的同一列表
算法步骤
- 初始化 write=0,read 从左到右扫描所有下标。
- 当前值非零时,把它填到 write,随后 write 加一。
- 当前值为零时只推进 read,不改变 write。
- 扫描结束后把 write 及其后的所有位置填零;原列表就地修改,函数自然返回 None。
代码(Python)
查看完整 Python 代码稳定压缩后补零 · 与上方图解对应
write 是下一个非零元素的目标位置。第一遍只压缩非零元素,第二遍清零尾部;逻辑阶段明确、容易证明。
from typing import List
class Solution:
def moveZeroes(self, nums: List[int]) -> None:
write = 0
for read in range(len(nums)):
if nums[read] != 0:
nums[write] = nums[read]
write += 1
for i in range(write, len(nums)):
nums[i] = 0
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
for original, expected in [([0, 1, 0, 3, 12], [1, 3, 12, 0, 0]), ([0], [0]), ([1, 2], [1, 2]), ([0, 0], [0, 0]), ([2, 0, -1, 0, 2], [2, -1, 2, 0, 0])]:
a = original.copy()
alias = a
assert Solution().moveZeroes(a) is None
assert a is alias and alias == expected
复杂度
- 时间
- O(n),两遍处理的总操作量线性。
- 空间
- O(1) 辅助空间;原地修改,无新输出数组。
易错点与边界
- 不能只给局部变量 nums 绑定新列表,那不会修改调用者的原数组。
- 要求保持非零值顺序,不能随意把末尾非零项换到开头。
- 覆盖法第一遍尾部可能有旧非零值,必须补零。
- 全零、无零、单元素都自然适用,不需要额外分支。
主解之后,再比较与迁移。
其他解法 1
快慢指针交换时间 O(n),每个元素检查一次。
read 搜索非零值,write 是非零前缀后的第一个可用位置。它还维护 nums[write:read] 全是零:遇到非零值时交换 write 与 read,当前非零值归位,旧零移到后方,因此不需要主解的第二遍补零。非零值仍按读取顺序进入前缀;write==read 时跳过自交换,减少原本紧凑前缀上的无效写入。
- 令 write=0,read 从左到右遍历 nums。
- 读到零时跳过;读到非零值且 write!=read 时,交换 nums[write] 与 nums[read]。
- 每读到一个非零值都让 write 加一,无论是否发生交换。
- 遍历完成后原数组已形成非零前缀与全零尾部,无需再补零。
from typing import List
class Solution:
def moveZeroes(self, nums: List[int]) -> None:
write = 0
for read in range(len(nums)):
if nums[read] != 0:
if write != read:
nums[write], nums[read] = nums[read], nums[write]
write += 1
复杂度
- 时间
- O(n),每个元素检查一次。
- 空间
- O(1) 辅助空间,交换临时变量为常数。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
for original, expected in [([0, 1, 0, 3, 12], [1, 3, 12, 0, 0]), ([0], [0]), ([1, 2], [1, 2]), ([0, 0], [0, 0]), ([2, 0, -1, 0, 2], [2, -1, 2, 0, 0])]:
a = original.copy()
alias = a
assert Solution().moveZeroes(a) is None
assert a is alias and alias == expected
预测练习
第一遍处理完 12 后,数组为何是 [1,3,12,3,12],能马上结束吗?
一点提示
第一遍只承诺 nums[:write] 正确,看看 write 之后的位置。
答案与理由
不能结束。write=3,前 3 项已稳定压缩,但下标 3、4 仍有旧值;第二遍必须把它们改为 0。
面试追问与解法取舍
问“最少写入”时需要说明成本模型:交换两个位置通常算两次写,覆盖加补零约写 n 次;但无零数组中带自交换判断的版本可做到零写入。两者都是 O(n) 时间、O(1) 空间,实际写入次数取决于零的分布,不能仅凭一遍或两遍判断谁更快。
模板迁移 / 相似题
试着不看代码,复述这三点
- 稳定性来自按 read 的原顺序追加非零值。
- 压缩阶段只保证 nums[:write];不要误把尾部副本当成错误。
- 最后补零并原地完成,不需要额外数组。
11. 盛最多水的容器 中等
一句话题意
从表示竖线高度的数组中选两条竖线,与横轴围出容器,求最大面积;面积等于两线距离乘较短线高度。
height=[2,4,1,5,3] → 返回 9;最优下标为 1、4。
生活化理解
用两块高度不同的挡板围水池,水位由矮板决定,池宽是挡板间距。固定矮板并把另一块板往里挪,水位上限不变、宽度变小,绝不可能比当前更好;所以要尝试换掉矮板。
相向双指针
每轮只计算一次面积并移动短板,排除候选的理由明确,线性时间和常数空间也容易复现。核心思路
设左右下标为 left、right,面积为 (right−left)×min(height[left],height[right])。初始选最宽区间。若左板较矮,对任意 left<j<right,固定左板的面积不超过 height[left]×(j−left),又不超过当前面积。因此所有以 left 为左端、右端位于当前区间内的候选都可以一次排除。
对称地,右板较矮时可以排除 right。我们并不是断言移动短板一定使面积变大,而是证明不移动它所剩的候选都不值得检查。best 保存已经计算的最大面积;在每轮计算之后丢弃一个已证明不会改进 best 的端点,最终不会遗漏最优值。
两端相等时,丢弃任意一端都成立。边界条件必须是 left<right,因为容器需要两条不同的线。中间的线不会阻挡容器,这是本题和接雨水模型的重要区别。
best 保存所有已经结算候选的最大面积。每轮舍弃较矮挡板:所有保留该挡板、另一端向内的候选都更窄且水位不更高,因而不可能改进答案。
图解
- left
- 0
- right
- 4
- best
- 0
- left
- 0
- right
- 4
- 当前面积(图示)
- 8
- best
- 8
- left
- 1
- right
- 4
- best
- 8
- left
- 1
- right
- 4
- 当前面积(图示)
- 9
- best
- 9
- left
- 1
- right
- 3
- best
- 9
- left
- 1
- right
- 3
- 当前面积(图示)
- 8
- best
- 9
- left
- 2
- right
- 3
- best
- 9
- left
- 2
- right
- 3
- 当前面积(图示)
- 1
- best
- 9
- left
- 3
- right
- 3
- best
- 9
- left
- 3
- right
- 3
- best
- 9
- 返回值
- 9
选择步骤 10 步
变量与图例
每轮先看水位由哪块短板限制,再看舍弃它后宽度与 best 怎样变化。
- left / right
- 当前两块边界挡板的下标
- height
- 每块挡板的高度
- 当前面积(图示)
- (right−left)×两端较小高度,不是额外代码变量
- best
- 已经计算过的面积最大值
算法步骤
- left=0、right=n−1,best=0。
- 按当前宽度和较短高度计算面积并更新 best。
- 左高度较小或相等则 left 加一,否则 right 减一。
- 左右指针相遇后结束,返回 best。
代码(Python)
查看完整 Python 代码相向双指针 · 与上方图解对应
每次计算当前容器后移动短板,通过上界证明一次排除一组不可能更好的容器。left 和 right 总共移动 n−1 次。
from typing import List
class Solution:
def maxArea(self, height: List[int]) -> int:
left, right = 0, len(height) - 1
best = 0
while left < right:
best = max(best, (right - left) * min(height[left], height[right]))
if height[left] <= height[right]:
left += 1
else:
right -= 1
return best
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert Solution().maxArea([1,8,6,2,5,4,8,3,7]) == 49
assert Solution().maxArea([1,1]) == 1
assert Solution().maxArea([0,0,0]) == 0
assert Solution().maxArea([4,3,2,1,4]) == 16
复杂度
- 时间
- O(n),n 为竖线数量;每次减少一个候选端点。
- 空间
- O(1) 辅助空间。
易错点与边界
- 面积的宽度是 right−left,不是两端之间的元素数量 right−left+1。
- 应移动较短的一边,移动较长边不能突破短板的水位上界。
- 移动后面积可能减小,不影响算法正确;best 必须保存历史最优。
- 高度可以为零,两个下标必须不同;中间高线不参与本题面积计算。
主解之后,再比较与迁移。
其他解法 1
双指针跳过不更高的挡板时间 O(n),两端仍只各自单向经过每个位置一次;可减少面积计算次数。
先记录当前短板高度 low 并计算面积,再从两端连续跳过高度≤low 的挡板。后续保留这些挡板形成的容器更窄、有效高度不超过 low,无法改进刚算过的面积。它与主解使用同一个排除理由,但一轮可以跳过多个位置,减少面积计算次数;两个指针仍只向内移动,总时间仍为 O(n)。
- 令 left=0、right=n−1、best=0。
- 在 left<right 时取 low=min(height[left],height[right]),用 (right−left)×low 更新 best。
- 保持 left<right,连续右移左端直到其高度大于 low;再连续左移右端直到其高度大于 low。
- 对剩余候选重复上述过程,指针相遇后返回 best。
from typing import List
class Solution:
def maxArea(self, height: List[int]) -> int:
left, right = 0, len(height) - 1
best = 0
while left < right:
low = min(height[left], height[right])
best = max(best, (right - left) * low)
while left < right and height[left] <= low:
left += 1
while left < right and height[right] <= low:
right -= 1
return best
复杂度
- 时间
- O(n),两端仍只各自单向经过每个位置一次;可减少面积计算次数。
- 空间
- O(1) 辅助空间。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert Solution().maxArea([1,8,6,2,5,4,8,3,7]) == 49
assert Solution().maxArea([1,1]) == 1
assert Solution().maxArea([0,0,0]) == 0
assert Solution().maxArea([4,3,2,1,4]) == 16
预测练习
在 left=1、right=4 时,面积为 9;下一步应移动哪一端?
一点提示
比较高度 4 和 3,而不是比较下标或挑最高的柱子。
答案与理由
移动 right。右挡板高 3 更矮;保留它并移动左端只会让宽度小于 3、水位至多 3,无法超过 9。
面试追问与解法取舍
最直观的对照法是枚举所有 i<j,时间 O(n²)、空间 O(1),适合作为小规模验证器。第二个实现是同一排除原理的批量跳过版本,并非新的复杂度级别。若高度更新后需多次查询,当前线性算法没有直接的常数时间更新结构,不能仅维护最高的两条线:距离也是面积的一部分。
模板迁移 / 相似题
试着不看代码,复述这三点
- 面积同时取决于宽度和短板高度,不能只找最高两根柱子。
- 先结算再移动短板,依据是候选被支配的排除证明。
- 最终返回历史 best,不是指针相遇前最后一次计算的面积。
15. 三数之和 中等
一句话题意
找出数组中所有和为零的三元组,三个元素来自不同下标,结果按数值去重。
nums=[-1,2,-1,0,1,2,-2] → [[-2,0,2],[-1,-1,2],[-1,0,1]](组间顺序不限)。
生活化理解
从预算表里挑三笔收支让总账为零。先锁定一笔,再在排好序的其余账目里找两笔补齐。总和太小就换掉较小的一笔,总和太大就换掉较大的一笔;相同面额的账目不必反复生成同一种组合。
排序与相向双指针
排序让指针移动方向和按值去重都清楚,能直接生成唯一三元组,无需额外的结果去重集合。核心思路
先排序为 a,固定下标 i,余下问题是从 [i+1,n−1] 找两个数,使和为 −a[i]。left、right 指向这个后缀两端。若总和小于零,固定 left 并减小 right 只会更小,所以可以排除 left;总和大于零时对称地排除 right。这依赖有序性。
当总和为零时记录 [a[i],a[left],a[right]],随后两边都移动,并跳过刚用过的相同值。三个下标满足 i<left<right,保证不同位置;对 i 的相同值也跳过,保证同一种数值三元组不因重复固定值再生成。
去重是按“值”而非按“位置”:[-1,-1,2] 需要两个不同位置的 −1,不能先把整个输入转成集合。排序后若 a[i]>0,剩余数都为正,立即结束;这个剪枝来自零目标和有序性,不能随意用于其它目标。
对每个不重复的固定值 a[i],只搜索其右侧的有序后缀。和偏小时旧 left 不可能再命中,和偏大时旧 right 不可能再命中;命中后跳过相同值,只去除重复值组合,不会漏掉不同组合。
图解
- i
- 尚未读取
- left
- 尚未赋值
- right
- 尚未赋值
- total(最近计算)
- 尚未计算
- result
- []
- i
- 0
- left
- 1
- right
- 6
- total(最近计算)
- 尚未计算
- result
- []
- i
- 0
- left
- 2
- right
- 6
- total(最近计算)
- -1
- result
- []
- i
- 0
- left
- 3
- right
- 6
- total(最近计算)
- -1
- result
- []
- i
- 0
- left
- 4
- right
- 5
- total(最近计算)
- 0
- result
- [[-2, 0, 2]]
- i
- 0
- left
- 4
- right
- 4
- total(最近计算)
- 0
- result
- [[-2, 0, 2]]
- i
- 1
- left
- 3
- right
- 5
- total(最近计算)
- 0
- result
- [[-2, 0, 2], [-1, -1, 2]]
- i
- 1
- left
- 3
- right
- 4
- total(最近计算)
- 0
- result
- [[-2, 0, 2], [-1, -1, 2]]
- i
- 1
- left
- 4
- right
- 3
- total(最近计算)
- 0
- result
- [[-2, 0, 2], [-1, -1, 2], [-1, 0, 1]]
- i
- 2
- left
- 4
- right
- 3
- total(最近计算)
- 0
- result
- [[-2, 0, 2], [-1, -1, 2], [-1, 0, 1]]
- i
- 3
- left
- 4
- right
- 5
- total(最近计算)
- 3
- result
- [[-2, 0, 2], [-1, -1, 2], [-1, 0, 1]]
- i
- 3
- left
- 4
- right
- 4
- total(最近计算)
- 3
- result
- [[-2, 0, 2], [-1, -1, 2], [-1, 0, 1]]
- i
- 4
- left
- 4
- right
- 4
- total(最近计算)
- 3
- result
- [[-2, 0, 2], [-1, -1, 2], [-1, 0, 1]]
- i
- 4
- left
- 4
- right
- 4
- total(最近计算)
- 3
- result
- [[-2, 0, 2], [-1, -1, 2], [-1, 0, 1]]
选择步骤 14 步
变量与图例
把“根据和值移动”和“命中后跳过重复值”分开看;图上指针显示每次操作后的实际位置。
- a
- 排序后的输入副本;图示下标均指向它
- i
- 固定第一项的下标
- left / L
- 第二项指针
- right / R
- 第三项指针
- total
- 最近一次检查的三数和;移动后尚未重算
- result
- 已记录的不同值三元组
算法步骤
- 建立排序副本 a 和空 result,枚举固定位置 i;与前一个固定值相同则跳过。
- 若 a[i]>0,立即结束外层;否则令 left=i+1、right=n−1。
- 在 left<right 时计算 total=a[i]+a[left]+a[right];总和偏小移动 left,偏大移动 right。
- 总和为零时保存结果,两端各移动一步,再分别跳过与刚用过的值相同的元素。
- 当前后缀搜索结束后继续下一个固定位置,最后返回 result。
代码(Python)
查看完整 Python 代码排序与相向双指针 · 与上方图解对应
有序数组让和值变化方向可预测。跳过重复固定值,并在找到答案后跳过两端重复值,从生成过程直接消除重复结果。
from typing import List
class Solution:
def threeSum(self, nums: List[int]) -> List[List[int]]:
a = sorted(nums)
result = []
for i in range(len(a) - 2):
if i > 0 and a[i] == a[i - 1]:
continue
if a[i] > 0:
break
left, right = i + 1, len(a) - 1
while left < right:
total = a[i] + a[left] + a[right]
if total < 0:
left += 1
elif total > 0:
right -= 1
else:
result.append([a[i], a[left], a[right]])
left += 1
right -= 1
while left < right and a[left] == a[left - 1]:
left += 1
while left < right and a[right] == a[right + 1]:
right -= 1
return result
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
def normalized(triples):
result = [tuple(sorted(t)) for t in triples]
assert len(result) == len(set(result))
return sorted(result)
assert normalized(Solution().threeSum([-1,0,1,2,-1,-4])) == [(-1,-1,2),(-1,0,1)]
assert normalized(Solution().threeSum([0,0,0,0])) == [(0,0,0)]
assert Solution().threeSum([0,1,1]) == []
assert normalized(Solution().threeSum([-2,0,0,2,2])) == [(-2,0,2)]
复杂度
- 时间
- O(n²),每个固定值对应 O(n) 指针移动,排序 O(n log n)。
- 空间
- O(n) 辅助空间,包括排序副本及 Python 排序工作空间;输出 O(k),k 为三元组数。
易错点与边界
- 题目去重的是三元组数值组合,不是三个原下标组合。
- 不能预先对 nums 去重,[-1,-1,2] 和 [0,0,0] 都可能需要重复值。
- 找到答案后必须移动指针,否则 while 原地循环。
- 相向双指针的移动规则以排序为前提;先建立有序数组,再根据总和偏小或偏大移动指针。
主解之后,再比较与迁移。
其他解法 1
固定一项与哈希补数时间 平均 O(n²),每轮后缀哈希扫描;排序三个元素是 O(1)。
每轮固定 i,在原数组后缀中用 seen 查找此前出现的补数 need=−nums[i]−nums[j]。seen 只保存当前固定项之后、j 之前的值,先查后存保证三个位置不同。输入无需排序;每个命中的三元组单独排序成 tuple,再放进 result 集合按值去重。与主解相比,它把去重交给结果集合,适合从两数之和的哈希方法迁移理解。
- 建立空结果集合 result,逐个固定位置 i。
- 每轮重建 seen,令 j 从 i+1 向右扫描。
- 计算 need=−nums[i]−nums[j];若 need 在 seen 中,把三个数排序成 tuple 加入 result。
- 查询之后再把 nums[j] 加入 seen,供后续位置配对。
- 遍历完成后,将 result 中每个 tuple 转成列表并返回。
from typing import List
class Solution:
def threeSum(self, nums: List[int]) -> List[List[int]]:
result = set()
for i in range(len(nums) - 2):
seen = set()
for j in range(i + 1, len(nums)):
need = -nums[i] - nums[j]
if need in seen:
result.add(tuple(sorted((nums[i], need, nums[j]))))
seen.add(nums[j])
return [list(triple) for triple in result]
复杂度
- 时间
- 平均 O(n²),每轮后缀哈希扫描;排序三个元素是 O(1)。
- 空间
- O(n+k),seen 为 O(n),去重集合为 O(k),返回列表也为 O(k)。
这个方法的边界
- 双指针移动规则以排序为前提;哈希版本也必须先查补数再加入当前值。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
def normalized(triples):
result = [tuple(sorted(t)) for t in triples]
assert len(result) == len(set(result))
return sorted(result)
assert normalized(Solution().threeSum([-1,0,1,2,-1,-4])) == [(-1,-1,2),(-1,0,1)]
assert normalized(Solution().threeSum([0,0,0,0])) == [(0,0,0)]
assert Solution().threeSum([0,1,1]) == []
assert normalized(Solution().threeSum([-2,0,0,2,2])) == [(-2,0,2)]
预测练习
记录 [-2,0,2] 并把 L、R 移到 4、5 后,为什么 R 还要再移到 4?
一点提示
比较 a[5] 和刚用过的 a[6]。
答案与理由
a[5]=a[6]=2,继续使用下标 5 的 2 只会重复刚用过的右值。跳过它后 L=R=4,当前固定项扫描结束。
面试追问与解法取舍
推广到四数之和,可固定两层再做双指针,时间 O(n³);一般 kSum 递归固定到 2Sum 时收束。若需要返回下标,排序副本须携带原下标,同时要重新定义“重复答案”的含义。当前哈希替代法虽然也是平均 O(n²),但会重复发现组合并占用结果去重集合,通常主解更直接。
模板迁移 / 相似题
试着不看代码,复述这三点
- 排序提供和值的单调性,先固定一项,再把三数问题变成两数问题。
- 固定项去重与命中后两端去重承担不同职责。
- 按值组合去重,不按元素去重;两个不同位置的 −1 都可能需要。
42. 接雨水 困难
一句话题意
把非负数组看成宽度为 1 的柱子,计算下雨后所有柱子间能存下的总水量。
height=[3,0,2,0,4,1] → 返回 7;各列水量 [0,3,1,3,0,0]。
生活化理解
街道两侧竖着高低不一的挡墙。站在某个位置,左边最高墙和右边最高墙中较矮的一堵决定水位;脚下已有的砖块不能算水。前后缀法逐户查两边最高墙,双指针法则从已能确定水位的一侧逐户结账。
双指针与两侧最高值
按列结算水量,同时用两个历史最高值确定较低一侧的最终水位,线性时间且只需常数空间。核心思路
一列能装多少水,取决于它左右最高挡板中较低的一侧,再减去当前柱高;只看紧邻柱子不够,水位可能由远处的高墙支撑。双指针保留尚未结算的区间 [left,right],total 已经准确累加区间外每一列的水量。
每轮先把当前两端纳入最高值:left_max 是从开头到 left 的最高柱,right_max 是从 right 到末尾的最高柱。若 left_max≤right_max,left 左侧的最高值已经完整已知,右侧也确实有一堵不低于它的墙。中间即使还有更高的右墙,水位仍受较低的 left_max 限制,因此可以准确累加 left_max−height[left],再让 left 加一。另一侧完全对称。
最高值包含当前柱,所减出的水量不会为负。每轮只结算并移走一个端点,其余未结算列留到后续处理;使用 left≤right 会处理最后相遇的一列。区间耗尽时,每列恰好计算一次,total 就是全部积水量。
已移出 [left,right] 的列,其积水量已经准确计入 total。每轮先更新两端最高值,再结算最高值较小的一端:另一侧已存在足够高的挡板,因此这个较小最高值就是当前列的最终水位。
图解
- left
- 0
- right
- 5
- left_max
- 0
- right_max
- 0
- total
- 0
- 各列已结算水量(图示)
- ['·', '·', '·', '·', '·', '·']
- left
- 0
- right
- 4
- left_max
- 3
- right_max
- 1
- total
- 0
- 各列已结算水量(图示)
- ['·', '·', '·', '·', '·', 0]
- left
- 1
- right
- 4
- left_max
- 3
- right_max
- 4
- total
- 0
- 各列已结算水量(图示)
- [0, '·', '·', '·', '·', 0]
- left
- 2
- right
- 4
- left_max
- 3
- right_max
- 4
- total
- 3
- 各列已结算水量(图示)
- [0, 3, '·', '·', '·', 0]
- left
- 3
- right
- 4
- left_max
- 3
- right_max
- 4
- total
- 4
- 各列已结算水量(图示)
- [0, 3, 1, '·', '·', 0]
- left
- 4
- right
- 4
- left_max
- 3
- right_max
- 4
- total
- 7
- 各列已结算水量(图示)
- [0, 3, 1, 3, '·', 0]
- left
- 5
- right
- 4
- left_max
- 4
- right_max
- 4
- total
- 7
- 各列已结算水量(图示)
- [0, 3, 1, 3, 0, 0]
- left
- 5
- right
- 4
- left_max
- 4
- right_max
- 4
- total
- 7
- 各列已结算水量(图示)
- [0, 3, 1, 3, 0, 0]
选择步骤 8 步
变量与图例
每帧只给真正结算的列加水;观察较小的边界最高值如何确定那一列的水位。
- left / right
- 尚未结算区域的两端下标;图上为移动后的实际值
- left_max
- 从左端探测到的最高柱高
- right_max
- 从右端探测到的最高柱高
- total
- 已结算列的水量之和
- 底部水量(图示)
- 逐列记录教学过程;· 代表未结算,并非代码额外数组
算法步骤
- 初始化 left=0、right=n−1,left_max=right_max=total=0。
- 只要 left≤right,就先把 height[left] 和 height[right] 纳入对应最高值。
- 若 left_max≤right_max,累加 left_max−height[left],再让 left 加一。
- 否则累加 right_max−height[right],再让 right 减一。
- 当 left>right,所有列结算完成,返回 total。
代码(Python)
查看完整 Python 代码双指针与两侧最高值 · 与上方图解对应
每轮用较小的历史最高值结算对应端点。right_max 为左侧提供足够高的右墙见证,left_max 为右侧提供对称见证;因此无需知道未扫描区域的最高墙。
from typing import List
class Solution:
def trap(self, height: List[int]) -> int:
left, right = 0, len(height) - 1
left_max = right_max = total = 0
while left <= right:
left_max = max(left_max, height[left])
right_max = max(right_max, height[right])
if left_max <= right_max:
total += left_max - height[left]
left += 1
else:
total += right_max - height[right]
right -= 1
return total
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert Solution().trap([0,1,0,2,1,0,1,3,2,1,2,1]) == 6
assert Solution().trap([4,2,0,3,2,5]) == 9
assert Solution().trap([3,0,1,3]) == 5
assert Solution().trap([1]) == 0
assert Solution().trap([0,1,2,3]) == 0
assert Solution().trap([2,2,2]) == 0
复杂度
- 时间
- O(n),每轮恰好结算一个下标。
- 空间
- O(1) 辅助空间。
易错点与边界
- 本题累加每一格上方的水,不是只选择两条线求一个容器面积。
- 最高值包含当前位置,水量是水位减柱高,不是水位本身。
- 单调上升、单调下降、等高数组都无积水;单个柱子同样为 0。
- 本双指针实现每轮先更新 left_max、right_max,再比较两者,按较小的最高值结算对应端点。
主解之后,再比较与迁移。
其他解法 2
左右最高值数组时间 O(n),两次最高值扫描加一次求和。
先用两张表保存每列的完整边界:left[i]=max(height[0:i+1]),right[i]=max(height[i:n]),再累加 min(left[i],right[i])−height[i]。最高值包含自身,差不会为负,左右端点的水量自然为零。它与主解都按竖列计水,但先计算全部前后缀信息,最容易检查公式;代价是两张长度 n 的辅助数组。
- 建立长度 n 的 left、right 两张表,令 high=0。
- 从左到右更新 high=max(high,height[i]),写入 left[i]。
- 重新令 high=0,从右到左更新最高值并写入 right[i]。
- 对每个位置累加 min(left[i],right[i])−height[i],返回总和。
from typing import List
class Solution:
def trap(self, height: List[int]) -> int:
n = len(height)
left, right = [0] * n, [0] * n
high = 0
for i in range(n):
high = max(high, height[i])
left[i] = high
high = 0
for i in range(n - 1, -1, -1):
high = max(high, height[i])
right[i] = high
return sum(min(left[i], right[i]) - height[i] for i in range(n))
复杂度
- 时间
- O(n),两次最高值扫描加一次求和。
- 空间
- O(n) 辅助空间,两张长度 n 的表;求和生成器不建额外列表。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert Solution().trap([0,1,0,2,1,0,1,3,2,1,2,1]) == 6
assert Solution().trap([4,2,0,3,2,5]) == 9
assert Solution().trap([3,0,1,3]) == 5
assert Solution().trap([1]) == 0
assert Solution().trap([0,1,2,3]) == 0
assert Solution().trap([2,2,2]) == 0
单调栈逐层补水时间 O(n),每个下标至多入栈一次、出栈一次。
这个方法改为按横层补水。stack 保存高度不递增的柱子下标;下标用于计算左右墙间的宽度。遇到更高的右墙时弹出凹底 bottom,新栈顶若存在就是左墙,新增层高为 min(height[left],height[right])−height[bottom],宽度为 right−left−1。多次弹栈计算的是逐层抬高的新增水量;没有剩余左墙时不能围成水槽。每个下标最多入栈和出栈各一次,因此总时间仍为 O(n)。
- 建立空 stack 和 total=0,依次读取右墙下标 right 及高度 h。
- 只要栈非空且 h 高于栈顶柱,弹出 bottom;若弹出后栈为空,结束本轮补水。
- 取新栈顶为 left,用较低墙高减凹底高度得到 layer,把 (right−left−1)×layer 加入 total。
- 继续处理更高水层,循环结束后将当前 right 入栈。
- 所有柱子扫描完后返回 total。
from typing import List
class Solution:
def trap(self, height: List[int]) -> int:
stack = []
total = 0
for right, h in enumerate(height):
while stack and h > height[stack[-1]]:
bottom = stack.pop()
if not stack:
break
left = stack[-1]
layer = min(height[left], h) - height[bottom]
total += (right - left - 1) * layer
stack.append(right)
return total
复杂度
- 时间
- O(n),每个下标至多入栈一次、出栈一次。
- 空间
- O(n) 最坏栈空间,如高度单调不增时。
这个方法的边界
- 单调栈弹出底部后若栈空,没有左墙,不能计算这一层。
- 栈法宽度为 right−left−1;双指针若比较历史最高值,就要配套先更新最高值。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert Solution().trap([0,1,0,2,1,0,1,3,2,1,2,1]) == 6
assert Solution().trap([4,2,0,3,2,5]) == 9
assert Solution().trap([3,0,1,3]) == 5
assert Solution().trap([1]) == 0
assert Solution().trap([0,1,2,3]) == 0
assert Solution().trap([2,2,2]) == 0
预测练习
第一次更新得到 left_max=3、right_max=1,为什么先结算下标 5,而不是下标 0?
一点提示
当前确定的是哪一边较低的最高值?
答案与理由
右侧最高值 1 更小,左侧已有高度 3 的挡板,足以支撑右侧水位 1。下标 5 的柱高也是 1,因此先结算 0 单位水,并把 right 移到 4。
面试追问与解法取舍
三种方法都为 O(n) 时间:前后缀法便于导出每列水量,双指针最省空间,栈法适合解释水槽形成过程。二维接雨水无法只比较一行两端,因为水可能沿其它方向流走,通常需要边界最小堆扩张;一维证明不能原样搬过去。
模板迁移 / 相似题
试着不看代码,复述这三点
- 比较两侧已经掌握的最高值,并非只比较当前两根柱子。
- 本列积水等于最终水位减柱高;累计值应随逐列结算变化。
- left≤right 让相遇的最后一列也被处理,每列只结算一次。
3. 无重复字符的最长子串 中等
一句话题意
求字符串中不含重复字符的最长连续子串长度;子串必须保持原位置连续。
s="abbaec" → 返回 4,最长无重复子串为 "baec"。
生活化理解
一段观景车厢只允许每种颜色的乘客出现一次。右门不断进人,若新乘客颜色与车内某人重复,左门就让乘客依次下车,直到旧的同色乘客离开。车厢对应连续窗口,最大乘客数对应答案。
记录最近位置,左边界跳跃
记录字符最近下标即可直接越过冲突点,每个字符处理一次,max 的作用也能用 abba 清楚说明。核心思路
每轮处理结束后,窗口 [left,right] 内的字符互不重复。读入新字符 ch=s[right] 时,若它的旧位置仍在窗口内,就让 left 越过那个位置;窗口中的其它字符原本没有重复,因此这样便能恢复合法窗口。
last[ch] 存字符在此前扫描部分的最近下标,可能位于当前窗口之外。因此跳跃必须是 left=max(left,last[ch]+1),不能无条件回退。例如 abba:处理最后一个 a 时,旧 a 在 0,left 已经是 2,不能退回 1,否则窗口 bba 含重复。确定新 left 后再登记 last[ch]=right,保证查询用的是旧位置。
right 固定时,恢复后的 left 是能保持当前窗口合法的最靠左边界,因此 right−left+1 是以该 right 结尾的最长合法子串长度。best 记录这些长度的最大值;枚举所有右端点就覆盖全局答案。每次只查询和更新一个字符的最近位置,总时间为平均 O(n)。
每轮更新完成后,s[left:right+1] 没有重复字符,left 只会前进。last 保存全局最近位置,因此重复判断必须结合 left;用 max 排除窗口内冲突,同时避免被窗口外旧记录拉回。
图解
- left
- 0
- best
- 0
- last
- {}
- right
- 0
- ch
- a
- left
- 0
- last
- {'a': 0}
- 当前窗口
- a
- best
- 1
- right
- 1
- ch
- b
- left
- 0
- last
- {'a': 0, 'b': 1}
- 当前窗口
- ab
- best
- 2
- right
- 2
- ch
- b
- left
- 2
- last
- {'a': 0, 'b': 2}
- 当前窗口
- b
- best
- 2
- right
- 3
- ch
- a
- left
- 2
- last
- {'a': 3, 'b': 2}
- 当前窗口
- ba
- best
- 2
- right
- 4
- ch
- e
- left
- 2
- last
- {'a': 3, 'b': 2, 'e': 4}
- 当前窗口
- bae
- best
- 3
- right
- 5
- ch
- c
- left
- 2
- last
- {'a': 3, 'b': 2, 'e': 4, 'c': 5}
- 当前窗口
- baec
- best
- 4
- left
- 2
- right
- 5
- last
- {'a': 3, 'b': 2, 'e': 4, 'c': 5}
- best
- 4
- 返回值
- 4
选择步骤 8 步
变量与图例
重点比较第二个 b 与第二个 a:同样在 last 中,却只有窗口内的旧位置需要收缩。
- right / ch
- 当前读取的下标及字符
- left
- 当前合法窗口的起点,只前进不后退
- last
- 各字符在整个已读前缀中的最近下标
- best
- 所有合法窗口的最大长度
算法步骤
- left=0、best=0,last 为空。
- 按顺序读取 right 和 ch;如果 ch 出现过,用 left=max(left,last[ch]+1) 跳过窗口内的旧位置。
- 登记 last[ch]=right,记录当前字符的最新位置。
- 用 right−left+1 更新 best,完成扫描后返回 best。
代码(Python)
查看完整 Python 代码记录最近位置,左边界跳跃 · 与上方图解对应
last 覆盖整个已扫描前缀,left 划定当前有效窗口。max 保证 left 单调不减;每次重复直接跳过冲突点。
class Solution:
def lengthOfLongestSubstring(self, s: str) -> int:
last = {}
left = best = 0
for right, ch in enumerate(s):
if ch in last:
left = max(left, last[ch] + 1)
last[ch] = right
best = max(best, right - left + 1)
return best
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert Solution().lengthOfLongestSubstring("abcabcbb") == 3
assert Solution().lengthOfLongestSubstring("") == 0
assert Solution().lengthOfLongestSubstring("bbbbb") == 1
assert Solution().lengthOfLongestSubstring("abba") == 2
assert Solution().lengthOfLongestSubstring("pwwkew") == 3
assert Solution().lengthOfLongestSubstring("一二一三") == 3
复杂度
- 时间
- 平均 O(n),n 为字符数,每个字符处理一次。
- 空间
- O(min(n,Σ)),Σ 为可出现的字符种类数,字典存每种字符的最近位置。
易错点与边界
- 子串必须连续,不能跳着选字符形成子序列。
- last 版本需要 max 防止 left 回退,abba 是关键测试。
- 长度为 right−left+1;空字符串返回 0,空格也算普通字符。
主解之后,再比较与迁移。
其他解法 1
集合窗口逐步收缩时间 平均 O(n),每个字符至多入集合一次、离开集合一次。
seen 只保存当前无重复窗口里的字符,不保存它们在全部已读前缀中的最近位置。新字符若已存在,就用 while 逐个移出左端字符并推进 left,直到冲突消失,再加入新字符。它与主解的直接跳跃得到相同的合法边界,更贴近“扩右端、缩左端”的滑动窗口过程。虽然有内层 while,两个指针都只向右,每个字符最多进入和离开集合各一次,总时间仍为平均 O(n)。
- 建立空 seen,令 left=best=0。
- 逐个读取 right 和 ch;只要 ch 已在 seen 中,就移出 s[left] 并让 left 加一。
- 冲突消失后,将 ch 加入 seen,此时 [left,right] 内没有重复字符。
- 用 right−left+1 更新 best,完成扫描后返回 best。
class Solution:
def lengthOfLongestSubstring(self, s: str) -> int:
seen = set()
left = best = 0
for right, ch in enumerate(s):
while ch in seen:
seen.remove(s[left])
left += 1
seen.add(ch)
best = max(best, right - left + 1)
return best
复杂度
- 时间
- 平均 O(n),每个字符至多入集合一次、离开集合一次。
- 空间
- O(min(n,Σ)),seen 只存当前无重复窗口。
这个方法的边界
- 集合版本必须用 while:移出一个无关字符并不保证重复冲突已消失。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert Solution().lengthOfLongestSubstring("abcabcbb") == 3
assert Solution().lengthOfLongestSubstring("") == 0
assert Solution().lengthOfLongestSubstring("bbbbb") == 1
assert Solution().lengthOfLongestSubstring("abba") == 2
assert Solution().lengthOfLongestSubstring("pwwkew") == 3
assert Solution().lengthOfLongestSubstring("一二一三") == 3
预测练习
读到 right=3 的 a 时,last['a']=0、left=2。若直接令 left=1,会出什么错?
一点提示
检查 s[1:4] 是不是无重复字符串。
答案与理由
会得到 "bba",把已经排除的下标 1 的 b 又放回窗口。正确值是 max(2,0+1)=2,窗口仍为 "ba"。
面试追问与解法取舍
若需要返回子串,best 变大时另存 best_left,最后切片 s[best_left:best_left+best];切片会额外占 O(best) 输出空间。若要求至多 k 种不同字符,把集合改成频次表并维护种类数,重复字符本身不再是违规条件。
模板迁移 / 相似题
试着不看代码,复述这三点
- last 是全局最近位置表,不等同于当前窗口的字符集合。
- left=max(left,last[ch]+1) 同时解决窗口内冲突与边界倒退。
- 先恢复窗口合法,再更新长度最大值。
438. 找到字符串中所有字母异位词 中等
一句话题意
返回 s 中所有与 p 互为字母异位词的连续子串起始下标;匹配窗口长度必须等于 p 的长度。
s="cabaab",p="ab" → 返回 [1,2,4];对应窗口 "ab"、"ba"、"ab"。
生活化理解
用一台固定宽度的扫描仪逐格扫字母传送带。订单 p 规定每种字母的数量,扫描框里的库存单必须和订单完全一致。框右移一格时只需要加入右侧新字母、减去左侧旧字母,不必重新数整框。
固定窗口比较 26 维频次
等长窗口配合两张 26 维频次表,匹配条件直观,固定字母表下的逐项比较也保持线性总时间。核心思路
异位词必须等长且每种字符频次相等,因此候选窗口长度固定为 m=len(p)。need 保存 p 的 26 维频次,window 保存当前窗口的频次。窗口每次移动只影响两个字符:进入字符加一、离开字符减一。比较两张完整频次表便能判断匹配;26 是常数,所以每步比较仍是常数开销。
扫描到 right 时,先加入 s[right];若 right≥m,再移出 s[right−m]。完成这两个动作后,window 精确表示以 right 结尾、长度至多 m 的后缀;先移入造成的短暂超长状态还不能拿来判断答案。只有 right≥m−1 时窗口才已填满,此时若 window==need,就记录起点 right−m+1。
p 可能含重复字母,频次比较自然区分“种类相同”和“每种数量相同”。每个长度 m 的连续窗口都在其右端点被检查一次,匹配窗口即使重叠也有不同的起点,都应保留;若 m>len(s),则没有足够长的候选窗口。
完成每轮移入与移出后,window 精确描述以 right 结束、长度至多 m 的后缀。长度达到 m 且完整 26 维频次与 need 相等,当且仅当该窗口是 p 的异位词,因此可记录其起点 right−m+1。
图解
- right
- 尚未读取
- m
- 2
- L(图示起点)
- 0
- need
- [1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
- window
- [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
- result
- []
- right
- 0
- m
- 2
- L(图示起点)
- 0
- need
- [1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
- window
- [0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
- result
- []
- right
- 1
- m
- 2
- L(图示起点)
- 0
- need
- [1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
- window
- [1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
- result
- []
- right
- 2
- m
- 2
- L(图示起点)
- 0
- need
- [1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
- window
- [1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
- result
- []
- right
- 2
- m
- 2
- L(图示起点)
- 1
- need
- [1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
- window
- [1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
- result
- [1]
- right
- 3
- m
- 2
- L(图示起点)
- 1
- need
- [1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
- window
- [2, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
- result
- [1]
- right
- 3
- m
- 2
- L(图示起点)
- 2
- need
- [1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
- window
- [1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
- result
- [1, 2]
- right
- 4
- m
- 2
- L(图示起点)
- 3
- need
- [1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
- window
- [2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
- result
- [1, 2]
- right
- 5
- m
- 2
- L(图示起点)
- 4
- need
- [1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
- window
- [1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
- result
- [1, 2, 4]
- right
- 5
- m
- 2
- L(图示起点)
- 4
- need
- [1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
- window
- [1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
- result
- [1, 2, 4]
选择步骤 10 步
变量与图例
跟踪 a、b、c 频次的增减,特别留意“先加入后移出”的临时超长窗口。
- right / R
- 当前读入字符的下标
- L(图示)
- 实际窗口起点;移出前临时保持旧起点
- m
- 目标长度 len(p)=2
- need
- 目标字符串的 26 维频次
- window
- 当前窗口的 26 维频次
- result
- 已命中的窗口起点
算法步骤
- 令 m=len(p);若 m>len(s),直接返回空列表。建立全零的 need、window,统计 p 的频次并初始化 result。
- 右端依次读入 ch,把 window 中对应字母的频次加一。
- 若 right≥m,把 s[right−m] 的频次减一,恢复长度不超过 m 的窗口。
- 若 right≥m−1 且 window==need,把起点 right−m+1 追加到 result。
- 扫描结束后返回 result。
代码(Python)
查看完整 Python 代码固定窗口比较 26 维频次 · 与上方图解对应
need 存订单,window 存当前窗口。每步比较两个长度 26 的列表,代码短且常数开销可控。
from typing import List
class Solution:
def findAnagrams(self, s: str, p: str) -> List[int]:
m = len(p)
if m > len(s):
return []
need, window = [0] * 26, [0] * 26
for ch in p:
need[ord(ch) - ord('a')] += 1
result = []
for right, ch in enumerate(s):
window[ord(ch) - ord('a')] += 1
if right >= m:
window[ord(s[right - m]) - ord('a')] -= 1
if right >= m - 1 and window == need:
result.append(right - m + 1)
return result
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert Solution().findAnagrams("cbaebabacd", "abc") == [0, 6]
assert Solution().findAnagrams("abab", "ab") == [0, 1, 2]
assert Solution().findAnagrams("aa", "aaa") == []
assert Solution().findAnagrams("baa", "aa") == [1]
assert Solution().findAnagrams("a", "a") == [0]
复杂度
- 时间
- O(m+26n),n=len(s)、m=len(p);固定 26 字母表下 O(n+m)。
- 空间
- O(26) 辅助空间,另有 O(k) 输出,k 为匹配窗口数量。
易错点与边界
- 只比较字符集合不够,aab 与 abb 的字母种类相同但频次不同。
- 必须等窗口长度达到 m 后才判断,否则前缀长度不足。
- 移出的下标是 right−m,不是当前窗口起点 right−m+1。
- 匹配可重叠,例如 abab 中的 ab 有三个答案,找到一个后不能跳过 m 格。
- p 为非空小写字符串,m 大于 s 长度时直接返回空列表。
主解之后,再比较与迁移。
其他解法 1
差值数组与非零项计数时间 O(n+m+26),每步只改至多两个频次项。
仍然使用固定长度窗口,但改为维护 diff[c]=窗口频次−目标频次,以及非零项数量 nonzero。当 nonzero=0 时,每个字符的数量都恰好匹配,不必每轮比较全部 26 项。change 只修改一个字母:先扣除旧值对非零项数的贡献,修改差值,再加上新值的贡献。不能只统计窗口中出现了多少种目标字母;该思路可推广到较大字符集,但当前代码的数组与 ord 映射仍针对 26 个小写字母。
- 若 m=len(p) 大于 len(s),返回空列表;否则建立全零 diff,把 p 中每个字母的差值减一,并统计初始 nonzero。
- 在 change(ch,delta) 中,先按 diff[index] 的旧值调整 nonzero,再更新差值,最后计入新值是否非零。
- 每读入一个右端字符调用 change(ch,1);若 right≥m,再调用 change(s[right−m],−1) 移出旧字符。
- 窗口已满且 nonzero==0 时,记录 right−m+1;全部扫描后返回结果。
from typing import List
class Solution:
def findAnagrams(self, s: str, p: str) -> List[int]:
m = len(p)
if m > len(s):
return []
diff = [0] * 26
for ch in p:
diff[ord(ch) - ord('a')] -= 1
nonzero = sum(value != 0 for value in diff)
def change(ch: str, delta: int) -> None:
nonlocal nonzero
index = ord(ch) - ord('a')
nonzero -= diff[index] != 0
diff[index] += delta
nonzero += diff[index] != 0
result = []
for right, ch in enumerate(s):
change(ch, 1)
if right >= m:
change(s[right - m], -1)
if right >= m - 1 and nonzero == 0:
result.append(right - m + 1)
return result
复杂度
- 时间
- O(n+m+26),每步只改至多两个频次项。
- 空间
- O(26) 辅助空间,输出 O(k)。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert Solution().findAnagrams("cbaebabacd", "abc") == [0, 6]
assert Solution().findAnagrams("abab", "ab") == [0, 1, 2]
assert Solution().findAnagrams("aa", "aaa") == []
assert Solution().findAnagrams("baa", "aa") == [1]
assert Solution().findAnagrams("a", "a") == [0]
预测练习
right=3 加入 a 后,频次暂为 a=2、b=1。下一步应移出哪个下标,最终记录什么?
一点提示
移出下标为 right−m;新起点为 right−m+1。
答案与理由
移出下标 1 的旧 a,a 从 2 回到 1;窗口变为下标 [2,3] 的 ba,记录起点 2。临时长度 3 时不能提前判断。
面试追问与解法取舍
如果字符集固定为 26,两张数组直接比较通常已足够清楚。nonzero 方案的价值在于把“所有维度匹配”变成一个可增量维护的计数,尤其适合字符集更大时。若题目只问是否存在匹配,第一次命中即可返回,不需要保存所有起点。
模板迁移 / 相似题
试着不看代码,复述这三点
- 频次必须完全相等,长度相等或字符集合相同都不够。
- 固定窗口每次先加新字符,再减 right−m 位置的旧字符。
- 匹配起点可重叠,逐个右端点检查即可自然覆盖。
560. 和为 K 的子数组 中等
一句话题意
统计整数数组中和恰好为 k 的非空连续子数组数量,数组可包含负数和零。
nums=[1,-1,0,1],k=0 → 返回 4;区间下标 [0,1]、[0,2]、[2,2]、[1,3]。
生活化理解
每天记录账户累计净收入。想知道某一连续时段净收入是否为 k,只要比较结束时累计数与开始前累计数,差值等于 k 就成功。相同累计数可能在不同日期出现,每个日期都对应一个不同的开始点,因此账簿要记次数。
前缀和与频次哈希表
把每个右端点的合法起点数化为一次前缀差查询,既适用于负数,也能正确统计重复前缀。核心思路
令 P[0]=0、P[j] 为前 j 个元素之和,则 nums[i:j] 的和为 P[j]−P[i]。要求这个差为 k,等价于找此前有多少个前缀满足 P[i]=P[j]−k。prefix 是当前结束点的前缀和,freq 存严格早于当前结束点的前缀和值及出现次数。
初始 freq={0:1} 表示数组开始前的空前缀;它让从下标 0 开始的合法子数组也能被统计。每读一个 x,先 prefix+=x,查询 freq[prefix−k] 并累加到 answer,然后再登记当前 prefix。查询必须在登记之前,否则 k=0 时会把当前前缀和自己配对,错误计入空子数组。
必须记录出现次数而非存在性。例如 [0,0,0]:每次读入后都查询前缀 0,分别得到 1、2、3 个起点,合计 6。因为有负数,扩大窗口时和不一定增大,缩小窗口时和也不一定减小,不能用普通“和大了就缩左边”的滑动窗口。
查询当前结束位置时,freq 只统计严格更早的前缀位置。每个满足旧前缀=prefix−k 的位置恰好确定一个非空子数组;先累加旧频次、再登记当前前缀,即可同时避免空区间与漏计重复起点。
图解
- freq
- {0: 1}
- prefix
- 0
- answer
- 0
- i(图示)
- 0
- x
- 1
- prefix
- 1
- freq(查询时)
- {0: 1}
- 本次增加
- 0
- answer
- 0
- i(图示)
- 0
- prefix
- 1
- freq
- {0: 1, 1: 1}
- answer
- 0
- i(图示)
- 1
- x
- -1
- prefix
- 0
- freq(查询时)
- {0: 1, 1: 1}
- 本次增加
- 1
- answer
- 1
- i(图示)
- 1
- prefix
- 0
- freq
- {0: 2, 1: 1}
- answer
- 1
- i(图示)
- 2
- x
- 0
- prefix
- 0
- freq(查询时)
- {0: 2, 1: 1}
- 本次增加
- 2
- answer
- 3
- i(图示)
- 2
- prefix
- 0
- freq
- {0: 3, 1: 1}
- answer
- 3
- i(图示)
- 3
- x
- 1
- prefix
- 1
- freq(查询时)
- {0: 3, 1: 1}
- 本次增加
- 1
- answer
- 4
- i(图示)
- 3
- prefix
- 1
- freq
- {0: 3, 1: 2}
- answer
- 4
- prefix
- 1
- freq
- {0: 3, 1: 2}
- answer
- 4
- 返回值
- 4
选择步骤 10 步
变量与图例
每个数分成查询和登记两帧,观察 prefix=0 再出现时为何一次增加两个答案。
- prefix
- 已经读入的所有元素之和
- freq
- 旧前缀和值 → 对应前缀位置的数量
- answer
- 已处理右端点对应的合法非空子数组总数
- k
- 目标子数组和
- i(图示)
- 当前元素位置;原代码直接遍历 x,i 仅用于标注区间
算法步骤
- 初始化 prefix=0、answer=0、freq={0:1}。
- 依次读入 x,令 prefix 加上 x。
- 把 freq 中 prefix−k 的出现次数加到 answer。
- 将当前 prefix 的次数增加 1,供后续结束点使用。
- 扫描结束后返回 answer。
代码(Python)
查看完整 Python 代码前缀和与频次哈希表 · 与上方图解对应
把每个右端点的问题化为查找旧前缀差。freq 保存次数,先查询再登记以保证只计入非空区间。
from typing import List
class Solution:
def subarraySum(self, nums: List[int], k: int) -> int:
freq = {0: 1}
prefix = answer = 0
for x in nums:
prefix += x
answer += freq.get(prefix - k, 0)
freq[prefix] = freq.get(prefix, 0) + 1
return answer
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert Solution().subarraySum([1,1,1], 2) == 2
assert Solution().subarraySum([1,2,3], 3) == 2
assert Solution().subarraySum([1,-1,0], 0) == 3
assert Solution().subarraySum([0,0,0], 0) == 6
assert Solution().subarraySum([5], 5) == 1
assert Solution().subarraySum([5], 0) == 0
复杂度
- 时间
- 平均 O(n),n 为数组长度,每个元素进行常数次哈希操作。
- 空间
- O(n) 辅助空间,最多 n+1 个不同前缀和。
易错点与边界
- freq 必须记录次数;相同前缀和对应多个不同的合法起点。
- 初始的 {0:1} 不可省略,它代表数组开始前的位置。
- 先查询再登记当前前缀,尤其 k=0 时避免计入空区间。
- 连续子数组按位置计数,即使数值相同也可能是不同答案。
主解之后,再比较与迁移。
其他解法 1
固定左端点,递增区间和(基线)时间 O(n²),检查全部 n(n+1)/2 个非空连续区间;大输入可能超时。
固定 left 后,让 right 向右扩展并增量维护 total,直接检查每个非空连续区间。它不保存前缀表,也不对子数组反复切片求和,把朴素 O(n³) 求和枚举降到 O(n²)。这种写法适合小数据对照,完整大规模输入应使用哈希主解;即使当前 total 已超过 k,也不能因含负数而提前停止扩展。
- 令 answer=0,逐个枚举左端点 left。
- 每换一个 left 就令 total=0,让 right 从 left 开始向右扩展。
- 把 nums[right] 累加到 total;每当 total==k 时让 answer 加一。
- 完成所有左端点的扩展后返回 answer。
from typing import List
class Solution:
def subarraySum(self, nums: List[int], k: int) -> int:
answer = 0
for left in range(len(nums)):
total = 0
for right in range(left, len(nums)):
total += nums[right]
if total == k:
answer += 1
return answer
复杂度
- 时间
- O(n²),检查全部 n(n+1)/2 个非空连续区间;大输入可能超时。
- 空间
- O(1) 辅助空间;不创建子数组切片。
这个方法的边界
- 出现负数后滑动窗口失去和值单调性;暴力法也不能因 total>k 就提前退出。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert Solution().subarraySum([1,1,1], 2) == 2
assert Solution().subarraySum([1,2,3], 3) == 2
assert Solution().subarraySum([1,-1,0], 0) == 3
assert Solution().subarraySum([0,0,0], 0) == 6
assert Solution().subarraySum([5], 5) == 1
assert Solution().subarraySum([5], 0) == 0
预测练习
读到下标 2 的 0 时,prefix=0、freq[0]=2。若先登记再查询,会比正确答案多算什么?
一点提示
先登记会让 freq[0] 从 2 变成 3;新增的那个前缀位置来自哪里?
答案与理由
会把当前结束位置的前缀与自身配对,错误计入长度 0 的空区间。应先增加 2,得到 answer=3,再令 freq[0]=3。
面试追问与解法取舍
若要求最长和为 k 的子数组,频次表应改成“前缀和第一次出现的位置”,因为同一个当前结束点搭配越早起点越长;若只求数量则必须保留次数。若返回所有区间,需要保存每个前缀和的所有位置,答案数量可能 O(n²),这部分输出成本无法用哈希消除。
模板迁移 / 相似题
试着不看代码,复述这三点
- 把连续区间和转成两个前缀和的差,负数不会破坏等式。
- freq={0:1} 表示开始前的前缀位置,覆盖从下标 0 起步的答案。
- 频次保存多个旧起点;先查后存避免 k=0 时计入空子数组。
239. 滑动窗口最大值 困难
一句话题意
给定数组和窗口长度 k,按从左到右的顺序返回每个连续窗口的最大值。
nums=[4,2,3,1,5,2],k=3 → [4,3,5,5]
生活化理解
像查看最近 k 天的最高气温。候选榜单中的旧记录若温度不超过今天,就永远不可能再夺冠:今天更热,而且比它更晚离开最近 k 天的范围。队列存日期下标,温度由数组查询,队首就是当前冠军。
单调双端队列
队首处理过期、队尾淘汰劣势候选,能用清楚的不变量解释线性时间。核心思路
队列 q 保存仍有机会成为最大值的下标,下标从前到后递增,对应数值严格递减。下标而非数值使我们既能比较大小,又能判断记录是否过期。处理位置 i 时,窗口左端为 i-k+1。
先移走队首小于左端的下标。再把队尾所有不大于 nums[i] 的记录移出:这些记录更早过期、数值也不更大,在包含它们的后续窗口里,i 都能替代它们。相等时也保留更新的下标。把 i 入队后,队首就是已读窗口的最大候选;只有窗口凑足 k 项才输出。
看似 while 套在 for 里,每个下标却只入队一次、出队至多一次,总操作数是线性的。这个论证叫摊还分析;不能仅凭代码嵌套层数判成 O(n²)。使用 deque 可以从两端直接移出元素。
每轮完成入队后,q 的下标递增且都未过期,对应的值严格递减。被队尾淘汰的旧值总有一个更大或相等、又更晚过期的替代者,因此队首就是完整窗口的最大值。
图解
- i
- 尚未开始
- L
- 尚未开始
- q
- []
- 候选值
- []
- answer
- []
- i
- 0
- L
- -2
- q
- [0]
- 候选值
- [4]
- answer
- []
- i
- 1
- L
- -1
- q
- [0,1]
- 候选值
- [4,2]
- answer
- []
- i
- 2
- L
- 0
- q
- [0]
- 候选值
- [4]
- answer
- []
- i
- 2
- L
- 0
- q
- [0,2]
- 候选值
- [4,3]
- answer
- [4]
- i
- 3
- L
- 1
- q
- [2]
- 候选值
- [3]
- answer
- [4]
- i
- 3
- L
- 1
- q
- [2,3]
- 候选值
- [3,1]
- answer
- [4,3]
- i
- 4
- L
- 2
- q
- []
- 候选值
- []
- answer
- [4,3]
- i
- 4
- L
- 2
- q
- [4]
- 候选值
- [5]
- answer
- [4,3,5]
- i
- 5
- L
- 3
- q
- [4,5]
- 候选值
- [5,2]
- answer
- [4,3,5,5]
选择步骤 10 步
变量与图例
观察队列的两端:队首按下标过期,队尾按数值淘汰;这两种移出有不同的理由。
- i / R
- 本轮读入的右端下标;入队前的中间帧可能尚未包含它。
- L
- 过期阈值 i-k+1;前两轮阈值为负,图中已读片段从下标 0 开始。
- q
- 双端队列,保存候选下标;图中写成 下标:值。
- answer
- 从左到右已经形成的完整窗口的最大值列表。
算法步骤
- 建立空双端队列 q 和答案列表,依次遍历右端 i。
- 从队首清理所有下标小于 i-k+1 的过期候选。
- 从队尾移走数值不大于 nums[i] 的下标,再把 i 加入队尾。
- 当 i≥k-1 时,将 nums[q[0]] 记为本窗口答案;扫描结束返回全部结果。
代码(Python)
查看完整 Python 代码单调双端队列 · 与上方图解对应
队首负责过期,队尾负责淘汰较小候选;使用 deque 才能 O(1) 从左端移出。相等时保留更新的下标,队列更短。
from collections import deque
class Solution:
def maxSlidingWindow(self, nums: list[int], k: int) -> list[int]:
q = deque()
answer = []
for i, value in enumerate(nums):
while q and q[0] < i - k + 1:
q.popleft()
while q and nums[q[-1]] <= value:
q.pop()
q.append(i)
if i >= k - 1:
answer.append(nums[q[0]])
return answer运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = Solution()
assert s.maxSlidingWindow([1,3,-1,-3,5,3,6,7], 3) == [3,3,5,5,6,7]
assert s.maxSlidingWindow([1], 1) == [1]
assert s.maxSlidingWindow([2,2,2], 2) == [2,2]
assert s.maxSlidingWindow([-4,-2,-5], 3) == [-2]
复杂度
- 时间
- O(n),n 为数组长度,每个下标至多进出队列各一次。
- 空间
- 辅助空间 O(k);输出占 O(n-k+1)。
易错点与边界
- 过期条件是下标 < i-k+1,窗口两端都包含。
- 必须等窗口凑足 k 个元素后再输出。
- k=1 时输出原序列的值;k=n 时仅一个结果。
- 本主解使用 nums[q[-1]]<=当前值 淘汰队尾,相等值只保留更新的下标;过期仍按下标判断。
主解之后,再比较与迁移。
其他解法 2
最大堆与过期清理时间 O(n log n),堆最坏含 O(n) 项,不能直接写 O(n log k)。
用堆维护最大候选,Python 最小堆保存 (-值, 下标)。堆顶若过期就弹出,直到顶部属于窗口;这是熟悉优先队列时容易复现的办法。堆内部的过期项不会立即清理,堆可能增长到 n,因此该实现的时间是 O(n log n),不能按活动窗口大小写成 O(n log k)。
- 建立空堆和答案列表,逐个读取下标 i 与当前值。
- 把 (-当前值, i) 压入堆,使最大值对应最小的负数。
- 只要堆顶下标≤i-k,就连续弹出过期堆顶。
- 窗口凑足 k 项后,取堆顶负值的相反数加入答案;最终返回答案列表。
import heapq
class Solution:
def maxSlidingWindow(self, nums: list[int], k: int) -> list[int]:
heap = []
answer = []
for i, value in enumerate(nums):
heapq.heappush(heap, (-value, i))
while heap[0][1] <= i - k:
heapq.heappop(heap)
if i >= k - 1:
answer.append(-heap[0][0])
return answer复杂度
- 时间
- O(n log n),堆最坏含 O(n) 项,不能直接写 O(n log k)。
- 空间
- 辅助空间 O(n),过期项可能滞留堆内;另有 O(n-k+1) 输出。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = Solution()
assert s.maxSlidingWindow([1,3,-1,-3,5,3,6,7], 3) == [3,3,5,5,6,7]
assert s.maxSlidingWindow([1], 1) == [1]
assert s.maxSlidingWindow([2,2,2], 2) == [2,2]
assert s.maxSlidingWindow([-4,-2,-5], 3) == [-2]
分块前后缀最大值时间 O(n),两次线性扫描与一次结果生成。
按长度 k 分块:left[i] 是本块开头到 i 的最大值,right[i] 是 i 到本块结尾的最大值。长度为 k 的窗口至多跨两个块,左端所在块的后缀与右端所在块的前缀正好覆盖窗口,所以答案是 max(right[L], left[R])。它同样是线性时间,适合完整数组上的离线批量计算,但要保存两张长度 n 的表。
- 创建长度为 n 的 left、right 数组。
- 从左向右计算 left:块起点直接取 nums[i],其他位置与前一格最大值合并。
- 从右向左计算 right:数组末尾或块终点直接取原值,其他位置与后一格最大值合并。
- 枚举窗口左端 L,令 R=L+k-1,输出 max(right[L], left[R])。
class Solution:
def maxSlidingWindow(self, nums: list[int], k: int) -> list[int]:
n = len(nums)
left = [0] * n
right = [0] * n
for i in range(n):
left[i] = nums[i] if i % k == 0 else max(left[i-1], nums[i])
j = n - 1 - i
right[j] = nums[j] if j == n-1 or (j+1) % k == 0 else max(right[j+1], nums[j])
return [max(right[i], left[i+k-1]) for i in range(n-k+1)]复杂度
- 时间
- O(n),两次线性扫描与一次结果生成。
- 空间
- 辅助空间 O(n),两个分块数组;输出 O(n-k+1)。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = Solution()
assert s.maxSlidingWindow([1,3,-1,-3,5,3,6,7], 3) == [3,3,5,5,6,7]
assert s.maxSlidingWindow([1], 1) == [1]
assert s.maxSlidingWindow([2,2,2], 2) == [2,2]
assert s.maxSlidingWindow([-4,-2,-5], 3) == [-2]
预测练习
在 i=3 时,能因为 4 仍然大于 3,就把队首 0:4 留下来吗?
一点提示
计算窗口左端 i-k+1,并检查下标 0。
答案与理由
不能。此时 L=1,0:4 已在窗口 [1,3] 之外;必须先从队首移出,才能正确输出 3。
面试追问与解法取舍
若数据持续到达,队列可在线逐条输出;分块法通常先准备完整数组。若 k 随查询任意变化,单一窗口队列不适用,可考虑稀疏表或线段树(拓展)。堆解法的关键追问是隐藏的过期元素,不应把活动窗口大小误当成堆大小。
其他思路的边界
- 相同值可全部保留或只保留最新下标,但比较条件和过期逻辑应一致。
模板迁移 / 相似题
试着不看代码,复述这三点
- 存下标同时解决“比较数值”和“判断过期”。
- 队首移出看窗口位置,队尾移出看支配关系,两个条件不能混用。
- 每个下标至多进出一次,嵌套 while 的总工作量仍是 O(n)。
76. 最小覆盖子串 困难
一句话题意
在 s 中找到包含 t 全部字符及其出现次数的最短连续子串;不存在则返回空串。
s="AAABC",t="AABC" → "AABC"
生活化理解
采购清单 t 可能写着“两瓶 A、一瓶 B、一瓶 C”,街上一排商店的货物就是 s。右指针往前采购,左指针尝试放弃最早几家店;只有清单仍全部满足时才能缩短路线。清单的数量不能退化成“来过哪些店”的集合。
字符缺口滑动窗口
用缺少的字符总份数判断覆盖,能直接处理重复字符,并清楚说明扩展与收缩的条件。核心思路
need 只保存 t 中的字符,need[c] 等于目标所需份数减去当前窗口中的份数;正数是缺口,零是刚好,负数是富余。missing 是所有正缺口的总和,初始为 len(t),重复字符分别计数。右端加入目标字符前,若其缺口为正,就让 missing 减一;随后递减该字符计数。无关字符不改变这两个状态。
当 missing=0,闭区间 [left,right] 覆盖了目标。先记录合法窗口,再不断右移 left:移出目标字符时把其计数加一,若计数变正,就失去了一份必需字符,让 missing 加一并停止收缩。移出富余字符或无关字符仍可继续缩短。
固定右端时,收缩不断检查更短的合法窗口。左端只向右移动;已经舍弃的更早起点即使留给未来右端,也只会形成更长窗口,因此不会遗漏更优答案。start 与 best 保存最佳起点和长度,最后只切片一次;循环结束时的当前窗口不一定合法,不能直接将它返回。
need[c] 始终等于目标所需份数减去当前窗口中的份数,missing 等于所有正缺口之和。因此 missing=0 恰好表示覆盖;每次收缩前保存合法候选,再移动 left,能比较固定右端下的所有可能短窗口。
图解
- left
- 0
- right
- 尚未开始
- need
- {"A":2,"B":1,"C":1}
- missing
- 4
- start
- 0
- best
- 6
- left
- 0
- right
- 0
- need
- {"A":1,"B":1,"C":1}
- missing
- 3
- start
- 0
- best
- 6
- left
- 0
- right
- 1
- need
- {"A":0,"B":1,"C":1}
- missing
- 2
- start
- 0
- best
- 6
- left
- 0
- right
- 2
- need
- {"A":-1,"B":1,"C":1}
- missing
- 2
- start
- 0
- best
- 6
- left
- 0
- right
- 3
- need
- {"A":-1,"B":0,"C":1}
- missing
- 1
- start
- 0
- best
- 6
- left
- 0
- right
- 4
- need
- {"A":-1,"B":0,"C":0}
- missing
- 0
- start
- 0
- best
- 6
- left
- 1
- right
- 4
- need
- {"A":0,"B":0,"C":0}
- missing
- 0
- start
- 0
- best
- 5
- left
- 2
- right
- 4
- need
- {"A":1,"B":0,"C":0}
- missing
- 1
- start
- 1
- best
- 4
- left
- 2
- right
- 4
- need
- {"A":1,"B":0,"C":0}
- missing
- 1
- start
- 1
- best
- 4
选择步骤 9 步
变量与图例
留意 need[A] 从正数到负数再回到正数:富余字符可以移走,刚好够用的字符再移走就会破坏覆盖。
- left / right
- 当前窗口的左右闭区间下标;初始化尚无 right。
- need
- 仅为目标字符维护差额:正数是缺少、0 是刚好、负数是富余。
- missing
- 尚未满足的字符总份数;重复字符分别计数。
- start / best
- 目前最短合法窗口的起点与长度;best=6 是未找到的初值。
算法步骤
- 用 Counter(t) 建立 need,令 missing=len(t),初始化 left、start 和未找到答案时的 best=len(s)+1。
- 右端读入字符;仅当它属于 need 时,先按加入前的正缺口决定是否减少 missing,再递减 need 中的计数。
- 当 missing 为零时,先用当前窗口更新 start、best,再移出左端字符。
- 移出的目标字符使 need 变正时,missing 加一;每次移出都让 left 加一,直到重新出现缺口。
- 扫描结束,若 best 未更新则返回空串,否则返回 s[start:start+best]。
代码(Python)
查看完整 Python 代码字符缺口滑动窗口 · 与上方图解对应
missing 统计尚缺的字符总份数,而不是种类数;负的 need 是富余。此实现只为 t 中字符维护计数,避免保存无关字符。
from collections import Counter
class Solution:
def minWindow(self, s: str, t: str) -> str:
need = Counter(t)
missing = len(t)
left = start = 0
best = len(s) + 1
for right, char in enumerate(s):
if char in need:
if need[char] > 0:
missing -= 1
need[char] -= 1
while missing == 0:
if right - left + 1 < best:
start, best = left, right - left + 1
old = s[left]
if old in need:
need[old] += 1
if need[old] > 0:
missing += 1
left += 1
return "" if best > len(s) else s[start:start+best]运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = Solution()
assert s.minWindow("ADOBECODEBANC", "ABC") == "BANC"
assert s.minWindow("a", "a") == "a"
assert s.minWindow("a", "aa") == ""
assert s.minWindow("AAABBC", "AABC") == "AABBC"
复杂度
- 时间
- O(m+n),m=len(s)、n=len(t),两个指针各单向移动。
- 空间
- 辅助 O(u),u 是 t 的不同字符数;最终字符串切片另占 O(答案长度)。
易错点与边界
- t 含重复字符时必须按份数满足,set(t) 不够。
- 记录答案应发生在移出左端之前,否则可能记录失效窗口。
- 题设 s、t 非空;t 比 s 长或者字符不足时返回空串。
- 主解的字符窗口两端都包含,窗口长度按 right-left+1 计算。
主解之后,再比较与迁移。
其他解法 1
过滤无关字符后按满足种类收缩时间 O(m+n),过滤扫描整个 s;过滤后的左右指针仍是线性。
先把 s 中属于 t 的字符保存为 (原下标, 字符),再在 filtered 上移动窗口。need 保存固定需求,have 记录窗口计数,formed 统计已经达到需求的字符种类数;它与主解的 missing 总份数含义不同。无关字符较多时,过滤后的窗口扫描更集中,但仍需先扫描整个 s 并保存过滤列表。答案长度必须用原始下标计算。
- 统计 Counter(t),并按原顺序生成只含目标字符的 filtered 列表,同时保留原下标。
- 遍历 filtered 中的新字符,增加 have;计数恰好达到 need 时,让 formed 加一。
- 当 formed 等于 len(need) 时,用当前原下标减去 filtered[left] 的原下标再加一,更新最短窗口。
- 减少左端字符的 have;若跌破需求就让 formed 减一,再将 left 右移,直到窗口不再满足全部种类。
- 根据保存的原字符串起点与长度返回切片;未找到合法窗口时返回空串。
from collections import Counter
class Solution:
def minWindow(self, s: str, t: str) -> str:
need = Counter(t)
filtered = [(i, c) for i, c in enumerate(s) if c in need]
have = Counter()
formed = left = start = 0
best = len(s) + 1
for index, char in filtered:
have[char] += 1
if have[char] == need[char]:
formed += 1
while formed == len(need):
begin, old = filtered[left]
if index - begin + 1 < best:
start, best = begin, index - begin + 1
have[old] -= 1
if have[old] < need[old]:
formed -= 1
left += 1
return "" if best > len(s) else s[start:start+best]复杂度
- 时间
- O(m+n),过滤扫描整个 s;过滤后的左右指针仍是线性。
- 空间
- 辅助 O(m+u),过滤列表最坏保存整个 s;输出切片占 O(答案长度)。
这个方法的边界
- 窗口长度是 right-left+1;过滤解法仍要使用原下标。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = Solution()
assert s.minWindow("ADOBECODEBANC", "ABC") == "BANC"
assert s.minWindow("a", "a") == "a"
assert s.minWindow("a", "aa") == ""
assert s.minWindow("AAABBC", "AABC") == "AABBC"
预测练习
从合法窗口 “AAABC” 移出第一个 A 后,还能继续收缩吗?再移出一个 A 会怎样?
一点提示
目标需要两个 A,先数窗口中有几份 A。
答案与理由
可以先移走一个富余 A,得到 “AABC”,need[A]=0 且 missing=0。再次移走 A 后只剩 “ABC”,need[A]=1、missing=1,因此记录长度 4 后停止收缩。
面试追问与解法取舍
若要求返回全部同样短的覆盖窗口,可记录最佳长度以及所有同长下标,再统一切片。过滤法在无关字符很多时减少窗口操作,但构建列表仍需扫描全串,也多占空间;它不会把最坏复杂度降到比 O(m+n) 更低。
模板迁移 / 相似题
试着不看代码,复述这三点
- 缺口计“份数”,目标中的重复字符不能按一种字符算一次。
- 每次先保存合法窗口,再移出左端;负缺口变为 0 时仍然合法。
- 最终当前窗口可能已失效,返回保存的 start 与 best。
53. 最大子数组和 中等
一句话题意
从非空整数数组中选择一个非空连续子数组,使元素和最大,返回这个和。
nums=[-2,3,-1,2,-5,4] → 4(例如 [3,-1,2])
生活化理解
把每天收益排成一列,你要挑连续营业的一段时间。到今天结束的最佳计划只有两种:延续昨天的计划,或者今天重新开张。若昨天累计亏损,背着亏损重新开始显然不如直接从今天算起。
Kadane 动态规划
只需区分当前结尾最优值与全局最优值,状态转移短且容易证明覆盖所有非空子数组。核心思路
end 表示必须以当前元素结尾的最大子数组和,best 表示已经看过的所有结尾中的最优值。这两个状态不能混用:best 可能结束在很早的位置,无法直接接上当前元素而仍保持连续。
对当前值 x,以当前位置结尾的非空子数组要么只有 x,要么由一个以前一位置结尾的非空子数组接上 x,所以 end=max(x,end+x),右侧读取的是旧 end。这穷尽了所有可能,并且接上同一个 x 时,前段越大越好。随后用新的 end 更新 best。
初始化 end=best=nums[0],确保答案非空且兼容全负数;从第二个元素开始转移。每个非空子数组都有一个结尾,扫描完所有结尾后返回 best 即可。代码只保留两个状态,并用下标遍历,避免 nums[1:] 带来的切片副本。
处理 i 后,end 是所有必须在 i 结尾的非空子数组的最大和,best 是截至 i 的全局最大和。以 i 结尾的子数组要么只有 nums[i],要么接上 i-1 的最佳结尾,两个备选已经穷尽。
图解
| 结束下标 | 0 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|
| nums[i] | -2 | 3 | -1 | 2 | -5 | 4 |
| end(教学记录) | -2 | · | · | · | · | · |
- i
- 0(初始化位置)
- end
- -2
- best
- -2
| 结束下标 | 0 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|
| nums[i] | -2 | 3 | -1 | 2 | -5 | 4 |
| end(教学记录) | -2 | 3 | · | · | · | · |
- i
- 1
- 旧end
- -2
- 单独开始
- 3
- 接上前段
- 1
- end
- 3
- best
- 3
| 结束下标 | 0 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|
| nums[i] | -2 | 3 | -1 | 2 | -5 | 4 |
| end(教学记录) | -2 | 3 | 2 | · | · | · |
- i
- 2
- 旧end
- 3
- 单独开始
- -1
- 接上前段
- 2
- end
- 2
- best
- 3
| 结束下标 | 0 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|
| nums[i] | -2 | 3 | -1 | 2 | -5 | 4 |
| end(教学记录) | -2 | 3 | 2 | 4 | · | · |
- i
- 3
- 旧end
- 2
- 单独开始
- 2
- 接上前段
- 4
- end
- 4
- best
- 4
| 结束下标 | 0 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|
| nums[i] | -2 | 3 | -1 | 2 | -5 | 4 |
| end(教学记录) | -2 | 3 | 2 | 4 | -1 | · |
- i
- 4
- 旧end
- 4
- 单独开始
- -5
- 接上前段
- -1
- end
- -1
- best
- 4
| 结束下标 | 0 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|
| nums[i] | -2 | 3 | -1 | 2 | -5 | 4 |
| end(教学记录) | -2 | 3 | 2 | 4 | -1 | 4 |
- i
- 5
- 旧end
- -1
- 单独开始
- 4
- 接上前段
- 3
- end
- 4
- best
- 4
| 结束下标 | 0 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|
| nums[i] | -2 | 3 | -1 | 2 | -5 | 4 |
| end(教学记录) | -2 | 3 | 2 | 4 | -1 | 4 |
- i
- 5
- end
- 4
- best
- 4
选择步骤 7 步
变量与图例
每个位置只做“从这里重开”与“接上前段”两选一;同时区分局部 end 和全局 best。
- i
- 本轮必须包含的结尾下标;初始化先处理下标 0。
- end
- 以当前 i 结尾的最大和;转移右侧读取更新前的旧 end。
- best
- 所有已处理结尾中的最大和。
- end 教学记录
- 为了看懂依赖展示的历史值;代码不分配这张表。
算法步骤
- 以首元素初始化 end 和 best,保留全负数组的非空答案。
- 从第二个元素开始,比较独立开头 nums[i] 与接上前段 end+nums[i],更新 end。
- 用新的 end 更新全局 best。
- 所有结尾都处理完后,返回 best。
代码(Python)
查看完整 Python 代码Kadane 动态规划 · 与上方图解对应
只保存上一个结尾状态,空间压缩后是两个整数。不要使用 nums[1:] 遍历,否则 Python 会额外复制 O(n) 个引用。
class Solution:
def maxSubArray(self, nums: list[int]) -> int:
end = best = nums[0]
for i in range(1, len(nums)):
end = max(nums[i], end + nums[i])
best = max(best, end)
return best运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = Solution()
assert s.maxSubArray([-2,1,-3,4,-1,2,1,-5,4]) == 6
assert s.maxSubArray([-3,-1,-2]) == -1
assert s.maxSubArray([1]) == 1
assert s.maxSubArray([0,0]) == 0
复杂度
- 时间
- O(n),n 为元素数。
- 空间
- 辅助空间 O(1),没有切片或 DP 数组。
易错点与边界
- 全负数不能返回 0,因为子数组要求非空。
- end 是以当前位置结尾的最优值,best 是全局最优值。
- 子数组必须连续,不能简单把所有正数相加。
主解之后,再比较与迁移。
其他解法 2
前缀和减去历史最小前缀时间 O(n),逐个处理前缀。
把区间和写成 prefix[j]-prefix[i],其中 prefix[t] 表示前 t 项之和。固定右端 j 时,应减去此前最小的前缀。代码用 prefix 保存当前前缀和、lowest 保存当前右端之前的最小前缀,适合从区间和公式理解问题。必须先更新答案再更新 lowest,否则会把当前前缀减自身,错误地允许空子数组。
- 令 prefix=lowest=0,以首元素初始化 best。
- 每读入一个值,就累加到 prefix,得到当前右端的前缀和。
- 用 prefix-lowest 更新 best,此时 lowest 只包含当前右端之前的前缀。
- 再用当前 prefix 更新 lowest,供后续右端使用;最终返回 best。
class Solution:
def maxSubArray(self, nums: list[int]) -> int:
prefix = lowest = 0
best = nums[0]
for value in nums:
prefix += value
best = max(best, prefix - lowest)
lowest = min(lowest, prefix)
return best复杂度
- 时间
- O(n),逐个处理前缀。
- 空间
- 辅助空间 O(1)。
这个方法的边界
- 前缀和法先更新答案再更新最小前缀,避免使用空区间。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = Solution()
assert s.maxSubArray([-2,1,-3,4,-1,2,1,-5,4]) == 6
assert s.maxSubArray([-3,-1,-2]) == -1
assert s.maxSubArray([1]) == 1
assert s.maxSubArray([0,0]) == 0
分治合并四种区间信息时间 O(n),二叉递归树 O(n) 个结点,每个结点 O(1) 合并。
每个区间返回总和、最大前缀和、最大后缀和、最大子数组和四项信息。最优子数组要么完全在左段、完全在右段,要么跨中点;跨中点时等于左段最大后缀加右段最大前缀。这个可合并的表示适合继续理解区间查询,代码每次合并只做常数工作,不会反复扫描中线。
- 用下标递归处理闭区间;只剩一个元素时,四项信息都等于该元素。
- 把区间一分为二,递归取得左右两段的四项信息。
- 总和相加;最大前缀在左段前缀与左段总和加右段前缀之间取最大,最大后缀对称计算。
- 最大子数组和在左段最优、右段最优、左后缀加右前缀三者中取最大,连同其余信息返回。
- 返回整个数组对应四元组中的最大子数组和。
class Solution:
def maxSubArray(self, nums: list[int]) -> int:
def solve(left, right):
if left == right:
x = nums[left]
return x, x, x, x
middle = (left + right) // 2
ls, lp, lq, lb = solve(left, middle)
rs, rp, rq, rb = solve(middle + 1, right)
return (ls + rs, max(lp, ls + rp),
max(rq, rs + lq), max(lb, rb, lq + rp))
return solve(0, len(nums)-1)[3]复杂度
- 时间
- O(n),二叉递归树 O(n) 个结点,每个结点 O(1) 合并。
- 空间
- 辅助 O(log n) 递归栈,使用下标递归而非切片。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = Solution()
assert s.maxSubArray([-2,1,-3,4,-1,2,1,-5,4]) == 6
assert s.maxSubArray([-3,-1,-2]) == -1
assert s.maxSubArray([1]) == 1
assert s.maxSubArray([0,0]) == 0
预测练习
处理 i=4 的 -5 后,end=-1、best=4。再读末尾 4 时应延长还是重开?
一点提示
分别算 -1+4 和单独取 4。
答案与理由
重新从末尾 4 开始,因为 4>-1+4=3。新 end=4,best 仍为 4;局部状态可以重开,全局最优不会因此丢失。
面试追问与解法取舍
若询问很多次不同区间的最大子数组和,分治的四元组能作为线段树结点信息,支持区间合并。若只计算一次,Kadane 更简单。若返回具体区间,在 end 选择单独 x 时重设候选起点,只有 best 改善时才更新最终边界。
模板迁移 / 相似题
试着不看代码,复述这三点
- end 限定“必须在当前下标结束”,best 才是最终答案。
- 旧 end 为负时重开,不为负时延长;不能跳过中间元素。
- 以首元素初始化可正确处理全负数组,两个整数即可完成 O(n) 扫描。
56. 合并区间 中等
一句话题意
合并所有有交集的闭区间,返回彼此不重叠且覆盖范围相同的区间集合。
intervals=[[8,10],[1,4],[2,3],[4,6],[12,13]] → [[1,6],[8,10],[12,13]]
生活化理解
像把预约日历中连在一起的占用时段涂成一条色带。先按开始时间摆好,接下来只需拿新预约和最右边那条色带比较;若接得上就延长,否则另起一条。两个闭区间碰到同一个端点,也算接得上。
按起点排序后线性合并
排序让每次只需比较结果末段,合并条件与不变量都容易在面试中说明。核心思路
排序后,后续区间的起点永远不会比当前更小。answer 中的区间互不重叠且按起点递增,覆盖范围等于已处理区间的并集;只有最后一段可能继续向右扩展。
若新起点 start≤answer[-1][1],两区间相交,合并后的右端是 max(旧右端,end)。取最大值能处理新区间完全包含在末段内的情况;闭区间共享端点也要合并。若 start 更大,连最后一段都碰不到,它更不可能碰到更早已结束的区间,可以放心新建一段。
实现遍历 sorted(intervals) 得到的排序副本,每次追加新的 [start,end],随后只更新结果末段。这既保留输入列表原有顺序,也不会让结果与输入共用内层可变区间。
处理完扫描前缀后,answer 已按起点排列、互不相交,且覆盖范围等于这些已处理区间的并集。因为下一段起点不会变小,它最多只需要与 answer 的末段合并。
图解
- intervals
- [[8,10],[1,4],[2,3],[4,6],[12,13]]
- answer
- []
- 排序副本
- [[1,4],[2,3],[4,6],[8,10],[12,13]]
- answer
- []
- start
- 1
- end
- 4
- answer
- [[1,4]]
- start
- 2
- end
- 3
- answer
- [[1,4]]
- start
- 4
- end
- 6
- answer
- [[1,6]]
- start
- 8
- end
- 10
- answer
- [[1,6],[8,10]]
- start
- 12
- end
- 13
- answer
- [[1,6],[8,10],[12,13]]
- 已处理
- 5
- answer
- [[1,6],[8,10],[12,13]]
选择步骤 8 步
变量与图例
沿同一条数轴看区间:包含时终点不缩短,端点相接时继续延伸,中间有空隙时才另起一段。
- start / end
- 排序后当前区间的左右闭端点。
- answer
- 已经合并的区间列表;只有最后一段可能继续延伸。
- 扫描序号
- 图左侧的 0–4 表示排序副本中的位置,不是区间坐标。
算法步骤
- 建立空 answer,按区间起点排序后依次读取 start、end。
- 结果为空,或 start 大于最后一段右端时,追加新的 [start,end]。
- 否则把最后一段右端更新为旧右端与 end 的最大值,起点不变。
- 扫描完成,返回这些按起点排列、互不相交的合并区间。
代码(Python)
查看完整 Python 代码按起点排序后线性合并 · 与上方图解对应
只比较结果末段。sorted 保留输入顺序,结果为新建的二元列表,不与输入的内层列表共用可变对象。
class Solution:
def merge(self, intervals: list[list[int]]) -> list[list[int]]:
answer = []
for start, end in sorted(intervals):
if not answer or start > answer[-1][1]:
answer.append([start, end])
else:
answer[-1][1] = max(answer[-1][1], end)
return answer运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = Solution()
assert s.merge([[1,3],[2,6],[8,10],[15,18]]) == [[1,6],[8,10],[15,18]]
assert s.merge([[1,4],[4,5]]) == [[1,5]]
assert s.merge([[1,4],[2,3]]) == [[1,4]]
assert s.merge([[2,2]]) == [[2,2]]
复杂度
- 时间
- O(n log n),n 为区间数量,扫描 O(n)。
- 空间
- 辅助 O(n):sorted 新列表和排序缓冲;返回结果另占 O(n)。
易错点与边界
- 闭区间端点相等时也应合并,分离条件为 start>旧右端。
- 包含关系时不能直接把右端改为 end,否则会把大区间缩短。
- 未排序就只比较最后一段不成立。
- [x,x] 是合法的单点闭区间,不能因为长度为零就跳过它。
主解之后,再比较与迁移。
其他解法 1
端点事件扫描线时间 O(n log n),排序 2n 个事件。
把区间转换成开始和结束事件,用 active 记录当前覆盖层数:开始使它加一,结束使它减一。代码将开始编码为类型 0、结束编码为类型 1,因此同一位置先处理开始,让共享闭端点的区间保持连通。它便于理解覆盖次数的变化,但比主解需要更多事件记录。
- 每个区间生成 (start,0) 与 (end,1) 两个事件,按位置再按类型排序。
- 初始化 active=0 和空答案;遇到开始事件,若 active 原为零就记录 begin,然后 active 加一。
- 遇到结束事件先让 active 减一;若回到零,就把 [begin,当前位置] 加入答案。
- 处理完全部事件后返回答案;同点开始优先保证相接区间合成一段。
class Solution:
def merge(self, intervals: list[list[int]]) -> list[list[int]]:
events = []
for start, end in intervals:
events.append((start, 0))
events.append((end, 1))
events.sort()
active = 0
answer = []
for position, kind in events:
if kind == 0:
if active == 0:
begin = position
active += 1
else:
active -= 1
if active == 0:
answer.append([begin, position])
return answer复杂度
- 时间
- O(n log n),排序 2n 个事件。
- 空间
- 辅助 O(n) 事件数组与排序缓冲;输出 O(n)。
这个方法的边界
- [x,x] 是合法零长度闭区间;扫描线必须正确安排同点事件。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = Solution()
assert s.merge([[1,3],[2,6],[8,10],[15,18]]) == [[1,6],[8,10],[15,18]]
assert s.merge([[1,4],[4,5]]) == [[1,5]]
assert s.merge([[1,4],[2,3]]) == [[1,4]]
assert s.merge([[2,2]]) == [[2,2]]
预测练习
处理 [4,6] 时,answer 末段为 [1,4]。start 等于末段终点,应该追加新段还是合并?
一点提示
区间两端都包含,位置 4 同时属于两段。
答案与理由
合并成 [1,6]。只有 start>answer[-1][1] 才能证明存在间隙;把条件写成 >= 会错误拆开共享端点的闭区间。
面试追问与解法取舍
若区间事先按起点排好,主解退化为 O(n)。若改为半开区间 [a,b),应重新定义端点相接是否合并以及同点事件优先级,不能照搬闭区间判断。扫描线更容易扩展到统计最大同时覆盖数,普通合并则排序扫描更简洁。
模板迁移 / 相似题
试着不看代码,复述这三点
- 先排序才能把“与所有旧段比较”压缩为“只看末段”。
- 有交集就取右端最大值,包含关系不能让末段变短。
- 闭区间用严格大于判断分离;排序 O(n log n),扫描 O(n)。
189. 轮转数组 中等
一句话题意
将数组中的元素向右轮转 k 个位置,并原地修改数组。
nums=[1,2,3,4,5,6],k=8 → 原地变为 [5,6,1,2,3,4];返回 None
生活化理解
一排座位要把末尾 k 位同学搬到前面。把整排同学掉头后,两个大分组的前后顺序已经交换,但每组内部站反了;再让前 k 位和后面那组各自掉头,就得到正确队形。
三次原地反转
把右移分解成整体与两段反转,证明直观,并直接满足常数辅助空间要求。核心思路
先取 k%=n,因为每右移 n 步会回到原位。把原数组记为 A+B,其中末尾 B 长 k。目标是 B+A;整体反转产生 reverse(B)+reverse(A),再分别反转两组即可恢复组内顺序。
reverse(left,right) 维护本次反转中尚未处理的闭区间,两端交换后各向中间移动。指针已经越过的位置都已得到该段的最终镜像值,直到 left≥right。每次调用都只交换给定范围内的元素,不会扰动范围外已经完成的部分。
依次反转整个数组、前 k 项、剩余 n-k 项,就在同一个列表中得到答案。双指针交换不分配切片,因此辅助空间为 O(1),函数返回 None。当 k=0 时,前段为空,另外两次全数组反转相互抵消,数组保持原状。
一次 reverse 中,指针外侧的元素已到该段反转后的目标位置,待处理区间只会缩小。整体反转得到 B逆+A逆,分别反转前 k 项与后 n-k 项后,两段内部方向都恢复,正好形成右移结果 B+A。
图解
- n
- 6
- k
- 2
- left
- 0
- right
- 5
- nums
- [1,2,3,4,5,6]
- k
- 2
- left
- 1
- right
- 4
- nums
- [6,2,3,4,5,1]
- k
- 2
- left
- 2
- right
- 3
- nums
- [6,5,3,4,2,1]
- k
- 2
- left
- 3
- right
- 2
- nums
- [6,5,4,3,2,1]
- k
- 2
- left
- 0
- right
- 1
- nums
- [6,5,4,3,2,1]
- k
- 2
- left
- 1
- right
- 0
- nums
- [5,6,4,3,2,1]
- k
- 2
- left
- 2
- right
- 5
- nums
- [5,6,4,3,2,1]
- k
- 2
- left
- 3
- right
- 4
- nums
- [5,6,1,3,2,4]
- k
- 2
- left
- 4
- right
- 3
- nums
- [5,6,1,2,3,4]
- k
- 2
- nums
- [5,6,1,2,3,4]
- 返回值
- None
选择步骤 10 步
变量与图例
把最后两项看成 B,其余看成 A;每次观察一对交换怎样把 A+B 最终变成 B+A。
- n / k
- 数组长度与取模后的右移步数;这里 n=6,k 从 8 化为 2。
- left / right
- 当前 reverse 调用的两端指针;交换后相向移动。
- 反转范围
- 本次调用固定的起止下标,不随内部指针移动。
- nums
- 被直接修改的同一个列表。
算法步骤
- 读取 n=len(nums),把 k 化为 k%n。
- 用两端交换、指针相向移动的 reverse,原地反转 [0,n-1]。
- 用相同操作反转 [0,k-1],恢复前方 B 段的内部顺序。
- 反转 [k,n-1],恢复 A 段的内部顺序;原列表已变成 B+A,函数结束。
代码(Python)
查看完整 Python 代码三次原地反转 · 与上方图解对应
用双指针交换而非切片;返回 None,调用者原来的列表对象直接变化。k=0 时第二段为空,首尾两次全数组反转抵消。
class Solution:
def rotate(self, nums: list[int], k: int) -> None:
n = len(nums)
k %= n
def reverse(left, right):
while left < right:
nums[left], nums[right] = nums[right], nums[left]
left += 1
right -= 1
reverse(0, n-1)
reverse(0, k-1)
reverse(k, n-1)运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = Solution()
a = [1,2,3,4,5,6,7]
assert s.rotate(a,3) is None
assert a == [5,6,7,1,2,3,4]
a = [-1,-100,3,99]
s.rotate(a,2)
assert a == [3,99,-1,-100]
a = [1]
s.rotate(a,10)
assert a == [1]
a = [1,2,3]
s.rotate(a,0)
assert a == [1,2,3]
a = [1,2,3,4,5,6]
s.rotate(a,4)
assert a == [3,4,5,6,1,2]
复杂度
- 时间
- O(n),三次翻转的总交换次数是线性的。
- 空间
- 辅助 O(1),没有列表切片。
易错点与边界
- 必须修改调用者传入的列表;nums=新列表只改变局部变量绑定。
- k 可能大于 n,要先取模。
主解之后,再比较与迁移。
其他解法 2
环状替换时间 O(n),所有环合计访问 n 个元素;gcd 计算 O(log n) 不改变总界。
直接沿目标映射 i→(i+k)%n 搬运元素,carry 暂存即将被覆盖的旧值。这个映射是一个置换,分为 gcd(n,k) 个独立环;只从 0 走一圈不一定覆盖所有位置。它适合希望每个位置只写入一次的场景,仍为原地算法,但要讲清环的覆盖与搬运顺序。
- 读取 n 并令 k%=n,依次选择 0 到 gcd(n,k)-1 作为各个环的起点。
- 进入一个环时,令 current=start,carry=nums[start]。
- 计算 target=(current+k)%n,把 carry 写入 nums[target],同时把目标位置的旧值存入 carry。
- 令 current=target,继续搬运,直到 current 回到 start;完成所有环后,原数组轮转结束。
from math import gcd
class Solution:
def rotate(self, nums: list[int], k: int) -> None:
n = len(nums)
k %= n
for start in range(gcd(n, k)):
current = start
carry = nums[start]
while True:
target = (current + k) % n
nums[target], carry = carry, nums[target]
current = target
if current == start:
break复杂度
- 时间
- O(n),所有环合计访问 n 个元素;gcd 计算 O(log n) 不改变总界。
- 空间
- 辅助 O(1),仅若干下标与一个暂存值。
这个方法的边界
- 环替换不可只走一个环;n=6、k=4 有两个环。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = Solution()
a = [1,2,3,4,5,6,7]
assert s.rotate(a,3) is None
assert a == [5,6,7,1,2,3,4]
a = [-1,-100,3,99]
s.rotate(a,2)
assert a == [3,99,-1,-100]
a = [1]
s.rotate(a,10)
assert a == [1]
a = [1,2,3]
s.rotate(a,0)
assert a == [1,2,3]
a = [1,2,3,4,5,6]
s.rotate(a,4)
assert a == [3,4,5,6,1,2]
切片重排时间 O(n),切片、拼接和覆写都是线性。
直接拼接末尾 k 项与开头其余项,再通过 nums[:] 覆写原列表,Python 写法简短,适合允许额外空间的版本。它保留调用者看到的列表对象,但切片和拼接会创建临时列表,需要 O(n) 辅助空间。
- 把 k 化为 k%len(nums)。
- 按 nums[-k:] + nums[:-k] 构造“末段在前、前段在后”的新序列;k=0 时前一片为全部、后一片为空。
- 将拼接结果赋给 nums[:],更新原列表内容,函数返回 None。
class Solution:
def rotate(self, nums: list[int], k: int) -> None:
k %= len(nums)
nums[:] = nums[-k:] + nums[:-k]复杂度
- 时间
- O(n),切片、拼接和覆写都是线性。
- 空间
- 辅助 O(n),切片和拼接产生临时列表。
这个方法的边界
- 切片版本 k=0 时 nums[-0:] 为整个数组,nums[:-0] 为空,结果仍正确。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = Solution()
a = [1,2,3,4,5,6,7]
assert s.rotate(a,3) is None
assert a == [5,6,7,1,2,3,4]
a = [-1,-100,3,99]
s.rotate(a,2)
assert a == [3,99,-1,-100]
a = [1]
s.rotate(a,10)
assert a == [1]
a = [1,2,3]
s.rotate(a,0)
assert a == [1,2,3]
a = [1,2,3,4,5,6]
s.rotate(a,4)
assert a == [3,4,5,6,1,2]
预测练习
整体反转后得到 [6,5,4,3,2,1]。只把前 2 项反转就能完成右移吗?
一点提示
比较目标中原前段 [1,2,3,4] 的内部顺序。
答案与理由
还不行。此时为 [5,6,4,3,2,1],后段仍是逆序;还要 reverse(2,5),才能得到 [5,6,1,2,3,4]。
面试追问与解法取舍
若面试官要求 O(1) 空间,优先解释三次翻转证明;若要求每个位置尽量少写,环替换每个元素恰写一次更合适。左轮转 k 步可转成右轮转 n-k%n 步,先明确方向再套同一个实现。
模板迁移 / 相似题
试着不看代码,复述这三点
- 先 k%=n,完整转一圈不改变结果。
- 三段调用顺序为整体、前 k 项、后 n-k 项,对应 B逆+A逆 到 B+A。
- 交换只需常数临时空间;原列表被修改,返回值是 None。
238. 除了自身以外数组的乘积 中等
一句话题意
不使用除法,在 O(n) 时间内计算每个位置之外所有元素的乘积。
nums=[2,3,0,4] → [0,0,24,0]
生活化理解
每位同学都想拿到“除自己以外所有人的印章组合”。给每人一张卡,先从左向右盖上他左边所有人的印章,再从右向左盖上右边所有人的印章;左右两组刚好覆盖其他所有人,谁也不会盖到自己的章。
输出数组复用前缀乘积
左右两侧乘积的含义直接对应答案,复用输出再配合一个滚动后缀就能满足空间要求。核心思路
答案 answer[i] 等于左侧元素乘积乘以右侧元素乘积,两部分都严格排除 nums[i]。空侧的乘积为 1,这是乘法单位元,保证首尾位置不需要特殊分支,也自然处理数组中的零。
第一趟写入严格位于 i 左边的乘积,因此 answer[0]=1,answer[i]=answer[i-1]*nums[i-1]。第二趟从右往左,right 在处理 i 之前表示 nums[i+1:] 的乘积。先把 right 乘进答案,再用 nums[i] 更新它,供左边下一格使用;顺序颠倒就会乘入自身。
输出数组同时担任前缀乘积表,只需额外保存一个滚动的 right。只讨论排除输出后的辅助空间时为 O(1);实际返回的长度 n 列表仍需要 O(n) 内存。全过程没有除法,即使遇到零,左右两部分的乘积定义仍然成立。
前缀扫描后,answer[i] 等于 i 左侧所有元素的乘积。后缀循环进入 i 时,right 等于 i 右侧所有元素的乘积;先相乘再更新 right,保证本格始终排除 nums[i],并为下一格建立正确的后缀。
图解
- n
- 4
- answer
- [1,1,1,1]
- i
- 1
- answer
- [1,2,1,1]
- 本格前缀
- 2
- i
- 2
- answer
- [1,2,6,1]
- 本格前缀
- 6
- i
- 3
- answer
- [1,2,6,0]
- 本格前缀
- 0
- i
- 即将从 3 开始
- answer
- [1,2,6,0]
- right
- 1
- i
- 3
- 本格原前缀
- 0
- 旧right
- 1
- right
- 4
- answer
- [1,2,6,0]
- i
- 2
- 本格原前缀
- 6
- 旧right
- 4
- right
- 0
- answer
- [1,2,24,0]
- i
- 1
- 本格原前缀
- 2
- 旧right
- 0
- right
- 0
- answer
- [1,0,24,0]
- i
- 0
- 本格原前缀
- 1
- 旧right
- 0
- right
- 0
- answer
- [0,0,24,0]
- answer
- [0,0,24,0]
- right
- 0
选择步骤 10 步
变量与图例
先观察 answer 存的“左边”,再看 right 从右向左滚动;关键是先乘旧 right,后把当前数纳入它。
- i
- 前缀阶段向右、后缀阶段向左的当前下标。
- answer
- 先暂存左侧乘积,后缀阶段再逐格变成最终答案。
- right
- 一个滚动后缀乘积;图中明确区分用于本格的旧值与更新后的新值。
- 本格原前缀
- 本轮 answer[i] 更新前的值,用于展示乘法依赖。
算法步骤
- 把长度 n 的答案数组初始化为全 1。
- 从 i=1 向右扫描,令 answer[i]=answer[i-1]*nums[i-1],写入严格左侧乘积。
- 令 right=1,从最右端开始向左扫描,先执行 answer[i]*=right。
- 每格答案写好后再执行 right*=nums[i],为下一格准备后缀;最终返回 answer。
代码(Python)
查看完整 Python 代码输出数组复用前缀乘积 · 与上方图解对应
前缀直接放在结果中;right 只保留一个滚动后缀乘积,避免另建长度 n 的后缀表。
class Solution:
def productExceptSelf(self, nums: list[int]) -> list[int]:
n = len(nums)
answer = [1] * n
for i in range(1, n):
answer[i] = answer[i-1] * nums[i-1]
right = 1
for i in range(n-1, -1, -1):
answer[i] *= right
right *= nums[i]
return answer运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = Solution()
assert s.productExceptSelf([1,2,3,4]) == [24,12,8,6]
assert s.productExceptSelf([-1,1,0,-3,3]) == [0,0,9,0,0]
assert s.productExceptSelf([0,0]) == [0,0]
assert s.productExceptSelf([2,3]) == [3,2]
复杂度
- 时间
- O(n),两趟扫描;按题设整数范围采用常数代价乘法模型。
- 空间
- 辅助 O(1),不计 O(n) 输出数组。
易错点与边界
- 题目禁止除法;总乘积除以当前元素还会遇到零。
- 右侧乘积应先用于当前答案,再乘 nums[i],否则包含了自己。
- 前缀表从 1 开始而不是 0,否则所有结果都会变零。
- 一个零时仅零所在位置可能非零;两个零时所有结果都是零。
主解之后,再比较与迁移。
其他解法 1
显式前缀表与后缀表时间 O(n),三趟线性处理。
单独建立 prefix 和 suffix 两张表:prefix[i] 是前 i 项的乘积,suffix[i] 是从 i 到末尾的乘积。于是 answer[i]=prefix[i]*suffix[i+1],左右范围一眼可见,适合先理解公式再考虑主解中的空间压缩;代价是 O(n) 辅助空间。
- 创建长度 n+1、初值均为 1 的 prefix 和 suffix,保留两端的空乘积。
- 从左向右计算 prefix[i+1]=prefix[i]*nums[i]。
- 从右向左计算 suffix[i]=suffix[i+1]*nums[i]。
- 逐个生成 prefix[i]*suffix[i+1],把当前位置从左右两侧都排除,返回结果。
class Solution:
def productExceptSelf(self, nums: list[int]) -> list[int]:
n = len(nums)
prefix = [1] * (n+1)
suffix = [1] * (n+1)
for i in range(n):
prefix[i+1] = prefix[i] * nums[i]
for i in range(n-1, -1, -1):
suffix[i] = suffix[i+1] * nums[i]
return [prefix[i] * suffix[i+1] for i in range(n)]复杂度
- 时间
- O(n),三趟线性处理。
- 空间
- 辅助 O(n),两张乘积表;另有 O(n) 输出。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = Solution()
assert s.productExceptSelf([1,2,3,4]) == [24,12,8,6]
assert s.productExceptSelf([-1,1,0,-3,3]) == [0,0,9,0,0]
assert s.productExceptSelf([0,0]) == [0,0]
assert s.productExceptSelf([2,3]) == [3,2]
预测练习
处理值为 0 的下标 2 时,若先执行 right*=nums[i],再更新 answer[i],会发生什么?
一点提示
进入这一轮时 answer[2]=6,right=4。
答案与理由
right 会先变为 0,导致 answer[2] 被错误算成 0;正确顺序先算 6×4=24,再把当前 0 乘入 right,供下标 1 使用。
面试追问与解法取舍
若允许除法,仍需分零的数量讨论:无零才可直接相除,一个零只计算零位置,多个零全为零。主解省掉了这套分支并满足题意。空间报告必须注明“输出不计”,不能把一个长度 n 的返回列表说成真的只分配常数内存。
模板迁移 / 相似题
试着不看代码,复述这三点
- 每个答案拆成不含自身的左侧乘积与右侧乘积。
- 后缀滚动时先用旧 right,再更新它;交换两句顺序会把自身乘进去。
- 不使用除法,零和多个零自然适用;输出数组外只需一个 right。
41. 缺失的第一个正数 困难
一句话题意
在未排序整数数组中找最小缺失正整数,要求 O(n) 时间和 O(1) 额外空间。
nums=[3,4,-1,1] → 2;归位后数组为 [1,-1,3,4]
生活化理解
有 n 个编号为 1 到 n 的储物柜,每个正整数 x 应去第 x 号柜,也就是下标 x-1。把范围内的物品送回自己的柜子后,从 1 号柜开始找第一个没有对应物品的柜号;负数和超过 n 的号码与这次盘点无关。
原地置换归位
把有效数值与数组位置一一对应,容易说明正确性与线性交换次数,并满足原地要求。核心思路
n 个元素最多填满 1 到 n,所以答案必在 [1,n+1]。这把无限正整数问题收缩为可用原数组下标表示的有限范围:数值 x 应放在下标 x-1。
处理下标 i 时,如果 nums[i] 在 [1,n],先缓存 target=nums[i]-1。若目标位置还不是相同值,就交换,把当前值送回对应位置。交换后 i 处来了新值,要继续处理同一位置。每次有效交换至少固定一个目标位置,且正确位置不会被不匹配值破坏,因此总交换次数为 O(n)。
重复值是关键:若目标已经有相同值,就停止当前搬运,否则交换不会改变数组而导致循环。缓存 target 也让交换目标不会受 nums[i] 赋值变化影响。归位后从左扫描,首个 nums[i]≠i+1 的位置说明号码 i+1 缺失;若全部匹配,答案是 n+1。
每次成功交换至少让一个 1..n 的值进入固定目标位置,并且不会把已经正确归位的目标值换走。归位结束后,凡是存在的有效号码 x 都在下标 x-1,因此从左找到的首个错位就是最小缺失正数。
图解
- n
- 4
- i
- 0
- target
- 2
- nums
- [3,4,-1,1]
- i
- 0
- target
- 2
- nums
- [-1,4,3,1]
- n
- 4
- i
- 1
- target
- 3
- nums
- [-1,4,3,1]
- i
- 1
- target
- 3
- nums
- [-1,1,3,4]
- n
- 4
- i
- 1
- target
- 0
- nums
- [-1,1,3,4]
- i
- 1
- target
- 0
- nums
- [1,-1,3,4]
- i
- 3
- target
- 3
- nums
- [1,-1,3,4]
- 阶段
- 查找
- i
- 0
- 期望值
- 1
- 实际值
- 1
- nums
- [1,-1,3,4]
- 阶段
- 查找
- i
- 1
- 期望值
- 2
- 实际值
- -1
- 返回值
- 2
选择步骤 9 步
变量与图例
把下标 i 看成号码 i+1 的固定座位;每次交换后,观察当前位置收到的新号码是否还要继续搬。
- n
- 数组长度;最小缺失正数一定在 1..n+1 内。
- i
- 归位阶段反复处理的当前位置;随后重用为查找下标。
- target
- 交换前缓存的 nums[i]-1,交换语句执行后暂时仍保留该缓存值。
- nums
- 同时充当数据和“号码是否在座”的记录;会被原地重排。
算法步骤
- 读取 n=len(nums),只给范围 1 到 n 的数安排下标 x-1。
- 逐个处理 i;只要 nums[i] 在范围内,就先缓存 target=nums[i]-1。
- 若目标已有相同值就结束当前位置的搬运;否则交换 nums[i] 与 nums[target],继续检查同一个 i。
- 归位结束后再次从左扫描,返回首个 nums[i]≠i+1 对应的 i+1;全部匹配则返回 n+1。
代码(Python)
查看完整 Python 代码原地置换归位 · 与上方图解对应
把数组作为值到位置的映射。缓存 target 后再交换,避免把会随赋值变化的 nums[i]-1 直接写成左侧目标而产生求值顺序陷阱。
class Solution:
def firstMissingPositive(self, nums: list[int]) -> int:
n = len(nums)
for i in range(n):
while 1 <= nums[i] <= n:
target = nums[i] - 1
if nums[target] == nums[i]:
break
nums[i], nums[target] = nums[target], nums[i]
for i in range(n):
if nums[i] != i + 1:
return i + 1
return n + 1运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = Solution()
assert s.firstMissingPositive([1,2,0]) == 3
assert s.firstMissingPositive([3,4,-1,1]) == 2
assert s.firstMissingPositive([7,8,9,11,12]) == 1
assert s.firstMissingPositive([1,1]) == 2
assert s.firstMissingPositive([2]) == 1
assert s.firstMissingPositive([1]) == 2
复杂度
- 时间
- O(n),每次交换固定至少一个数值的目标位置,另加两次线性扫描。
- 空间
- 辅助 O(1),会改变输入数组的排列。
易错点与边界
- 归位法必须判断目标已存在相同值,防止重复数字引发死循环。
- 小于 1 或大于 n 的数无需归位,答案最多为 n+1。
主解之后,再比较与迁移。
其他解法 1
原地符号标记时间 O(n),标准化、标记、查找三次线性扫描。
利用下标 x-1 记录号码 x 是否出现,但不把值搬到对应位置,而是把该位置变为负数。先把原非正数替换为 n+1,区分原负号与标记;扫描时取绝对值,避免已写入的标记干扰后续读数。重复号码用 -abs(...) 重复设负,不会把负号翻回正号。适合用“存在标记”理解原地记录的方法。
- 读取 n,把所有非正数替换为 n+1,使数组起初全部为正。
- 逐格取 value=abs(nums[i]);若 value≤n,就把 nums[value-1] 设为 -abs(nums[value-1])。
- 再次从左向右扫描,第一个仍为正数的位置表示对应号码未出现,返回 i+1。
- 若全部位置都被标成负数,说明 1 到 n 齐全,返回 n+1。
class Solution:
def firstMissingPositive(self, nums: list[int]) -> int:
n = len(nums)
for i in range(n):
if nums[i] <= 0:
nums[i] = n + 1
for i in range(n):
value = abs(nums[i])
if value <= n:
nums[value-1] = -abs(nums[value-1])
for i in range(n):
if nums[i] > 0:
return i + 1
return n + 1复杂度
- 时间
- O(n),标准化、标记、查找三次线性扫描。
- 空间
- 辅助 O(1),会修改输入数值及符号。
这个方法的边界
- 符号法读值时用 abs,重复标记用 -abs,不能简单取反。
- 符号标记前必须先处理原来的负数和零,否则无法区分“出现过”的标记。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = Solution()
assert s.firstMissingPositive([1,2,0]) == 3
assert s.firstMissingPositive([3,4,-1,1]) == 2
assert s.firstMissingPositive([7,8,9,11,12]) == 1
assert s.firstMissingPositive([1,1]) == 2
assert s.firstMissingPositive([2]) == 1
assert s.firstMissingPositive([1]) == 2
预测练习
把 4 从下标 1 交换到下标 3 后,数组为 [-1,1,3,4]。为什么不能立刻把 i 改成 2?
一点提示
看看下标 1 新收到的值 1 是否在自己的座位。
答案与理由
1 应放在下标 0。需要保持 i=1,再算 target=0 并交换,得到 [1,-1,3,4];否则会漏掉这次归位。
面试追问与解法取舍
哈希集合能更直观地做到期望 O(n) 时间,但需要 O(n) 额外空间,不满足本题要求。若输入必须保留,这两种原地方案都不适合直接调用;复制数组需要额外 O(n) 内存,应明确说明约束变化。
模板迁移 / 相似题
试着不看代码,复述这三点
- 值 x 的目标位置是 x-1,只有 1..n 需要安置。
- 交换后继续处理当前位置;缓存 target,并在目标已有同值时停止。
- 归位后第一个 nums[i]≠i+1 的位置给出答案,否则返回 n+1。
73. 矩阵置零 中等
一句话题意
把矩阵中原本为零的元素所在整行、整列全部置零,原地修改矩阵。
matrix=[[1,2,0],[4,5,6],[7,0,9]] → 原地变为 [[0,0,0],[4,0,0],[0,0,0]];返回 None
生活化理解
仓库的一个格子漏水,就要停用它的整排与整列。必须先按原始漏水位置贴标签,再统一停用;若把刚停用的格子也当成新漏水点,就会连锁扩散。第一行和第一列可以借来当每列、每行的标签栏。
首行首列充当标记
先记录再改写的两阶段逻辑清楚,复用首行首列还能直接满足常数辅助空间要求。核心思路
在扫描内部格子之前,先保存 first_row 和 first_col,分别记录第一行、第一列原本是否包含零。因为接下来它们会被写入标签,不能再从修改后的内容判断它们是否原本需要整行或整列清零。
遇到内部零 matrix[r][c] 时,令 matrix[r][0]=0 与 matrix[0][c]=0,分别表示第 r 行和第 c 列需要清零。首行、首列原有的零也自然构成对应列、行的标签。第一遍只写边缘标签,不立即改内部其他格子,原始零的发现就不会被新增零污染。
标记结束后,内部格子只要对应行标签或列标签为零就清零。这个阶段只读标签,不再添加标签。最后依据两个布尔值处理第一行、第一列,让标签在所有读取完成前保持有效;全部操作直接修改原矩阵。
第一遍仅把内部原始零映射为首行首列标签,不清零内部的其他格;两个布尔值保留首行首列原来是否含零。第二遍读取稳定标签来改写内部,全部读完后再清首行首列,避免新写入的零扩散成错误依据。
图解
- matrix
- [[1,2,0],[4,5,6],[7,0,9]]
- first_row
- True
- first_col
- False
- 阶段
- 保存原始状态
- r
- 1
- c
- 2
- matrix
- [[1,2,0],[4,5,6],[7,0,9]]
- first_row
- True
- first_col
- False
- r
- 2
- c
- 1
- matrix
- [[1,0,0],[4,5,6],[0,0,9]]
- first_row
- True
- first_col
- False
- r
- 2
- c
- 2
- matrix
- [[1,0,0],[4,5,6],[0,0,9]]
- first_row
- True
- first_col
- False
- r
- 1
- c
- 1
- 行标签
- 4
- 列标签
- 0
- matrix
- [[1,0,0],[4,0,6],[0,0,9]]
- r
- 1
- c
- 2
- 行标签
- 4
- 列标签
- 0
- matrix
- [[1,0,0],[4,0,0],[0,0,9]]
- r
- 2
- c
- 2
- 行标签
- 0
- matrix
- [[1,0,0],[4,0,0],[0,0,0]]
- first_row
- True
- first_col
- False
- matrix
- [[0,0,0],[4,0,0],[0,0,0]]
- first_row
- True
- first_col
- False
- matrix
- [[0,0,0],[4,0,0],[0,0,0]]
- 返回值
- None
选择步骤 9 步
变量与图例
先看原始零怎样变成首行首列标签,再看第二遍怎样只读这些标签;最后才处理首行和首列本身。
- r / c
- 内部扫描的行列下标,两个阶段都从 1 开始。
- first_row / first_col
- 修改前首行、首列是否含零,保存后不再重算。
- matrix[r][0]
- 第 r 行的标签;r≥1 时为 0 表示该行应清零。
- matrix[0][c]
- 第 c 列的标签;c≥1 时为 0 表示该列应清零。
- matrix
- 同时存原数据、标签与最终结果,图中明确区分处理阶段。
算法步骤
- 读取矩阵行列数,记录 first_row、first_col,保存首行首列原本是否有零。
- 从 r=1、c=1 开始扫描内部格子;遇到零就在对应首列、首行位置写零标签。
- 第二遍扫描内部区域,若行标签或列标签为零,就把当前格置零。
- 内部处理完后,分别按 first_row、first_col 清零第一行、第一列,函数返回 None。
代码(Python)
查看完整 Python 代码首行首列充当标记 · 与上方图解对应
两个布尔值解决首行首列自身状态的冲突;其余行列标签复用 matrix,不另开长度 m+n 的数组。
class Solution:
def setZeroes(self, matrix: list[list[int]]) -> None:
m, n = len(matrix), len(matrix[0])
first_row = any(matrix[0][c] == 0 for c in range(n))
first_col = any(matrix[r][0] == 0 for r in range(m))
for r in range(1, m):
for c in range(1, n):
if matrix[r][c] == 0:
matrix[r][0] = 0
matrix[0][c] = 0
for r in range(1, m):
for c in range(1, n):
if matrix[r][0] == 0 or matrix[0][c] == 0:
matrix[r][c] = 0
if first_row:
for c in range(n):
matrix[0][c] = 0
if first_col:
for r in range(m):
matrix[r][0] = 0运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = Solution()
a = [[1,1,1],[1,0,1],[1,1,1]]
assert s.setZeroes(a) is None
assert a == [[1,0,1],[0,0,0],[1,0,1]]
a = [[0,1,2,0],[3,4,5,2],[1,3,1,5]]
s.setZeroes(a)
assert a == [[0,0,0,0],[0,4,5,0],[0,3,1,0]]
a = [[1,0,3]]
s.setZeroes(a)
assert a == [[0,0,0]]
a = [[1],[0]]
s.setZeroes(a)
assert a == [[0],[0]]
复杂度
- 时间
- O(mn),m 为行数,n 为列数。
- 空间
- 辅助 O(1),any 接收生成器,不构建行列副本。
易错点与边界
- 不能边发现零边把整行整列改零,否则新增零会错误传播。
- 首行首列是否原本含零必须在标记前记录。
- 先处理内部再处理边缘,避免提前毁掉标签。
- 只有一行或一列时内部循环为空,边缘逻辑仍需正确工作。
主解之后,再比较与迁移。
其他解法 1
集合记录零所在的行列时间 期望 O(mn),集合查询期望 O(1)。
用 rows、cols 两个集合分别保存原始零的行号和列号,第一遍只观察,第二遍再改写。它与主解都遵循“先记录再执行”,但把标记放在矩阵外,首行首列可以像其他位置一样处理,逻辑更直观,代价是 O(m+n) 辅助空间。
- 建立空集合 rows 和 cols。
- 完整扫描原矩阵;发现零时,将其行号加入 rows、列号加入 cols,此时不改写矩阵。
- 再次遍历全部格子,只要行号在 rows 中或列号在 cols 中,就将当前格置零。
- 第二遍完成后,原矩阵已经符合要求,函数返回 None。
class Solution:
def setZeroes(self, matrix: list[list[int]]) -> None:
rows, cols = set(), set()
for r in range(len(matrix)):
for c in range(len(matrix[0])):
if matrix[r][c] == 0:
rows.add(r)
cols.add(c)
for r in range(len(matrix)):
for c in range(len(matrix[0])):
if r in rows or c in cols:
matrix[r][c] = 0复杂度
- 时间
- 期望 O(mn),集合查询期望 O(1)。
- 空间
- 辅助 O(m+n),用于行号与列号集合。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = Solution()
a = [[1,1,1],[1,0,1],[1,1,1]]
assert s.setZeroes(a) is None
assert a == [[1,0,1],[0,0,0],[1,0,1]]
a = [[0,1,2,0],[3,4,5,2],[1,3,1,5]]
s.setZeroes(a)
assert a == [[0,0,0,0],[0,4,5,0],[0,3,1,0]]
a = [[1,0,3]]
s.setZeroes(a)
assert a == [[0,0,0]]
a = [[1],[0]]
s.setZeroes(a)
assert a == [[0],[0]]
预测练习
第一遍结束时 matrix[2][0] 已被写成 0,是否应该因此把 first_col 改为 True?
一点提示
这个 0 是原始数据,还是刚写入的行标签?
答案与理由
不应该。它是原始 (2,1)=0 写出的行标签。原首列 [1,4,7] 没有零,first_col 应保持 False,所以结果中的 (1,0)=4 必须保留。
面试追问与解法取舍
一个布尔值也能实现常数空间:让 matrix[0][0] 专门标记首行,另一个布尔值标记首列,但遍历顺序更容易出错。两个布尔值同样是 O(1),表达更清晰。若数据只读,集合方案可用于生成新的结果矩阵,但输出本身需 O(mn)。
模板迁移 / 相似题
试着不看代码,复述这三点
- 先保存首行首列的原始状态,再复用它们存标签。
- 第一遍写标签,第二遍读标签清内部,不能让结果零反过来产生新标签。
- 首行首列最后处理,两个布尔值让额外空间保持 O(1)。
54. 螺旋矩阵 中等
一句话题意
按从左上角开始、顺时针向内盘旋的顺序返回矩阵所有元素。
matrix=[[1,2,3],[4,5,6],[7,8,9]] → [1,2,3,6,9,8,7,4,5]
生活化理解
像从外向内剥一圈圈洋葱皮:先走最上排,再沿右边向下,再走底边和左边。走完一条边就把它从待访问矩形中收起来,下一次只关心里面更小的一圈。
四边界逐层收缩
按上、右、下、左读取并收缩边界,能直接对应螺旋路径,也无需访问标记表。核心思路
top、bottom、left、right 在每条边开始前圈定尚未访问的闭矩形。读取一条边期间边界保持不动,整条边完成后才收缩;已排除的格子不再进入后续循环。
先从左到右读 top 行,再令 top+=1;接着从更新后的 top 向下读 right 列,再令 right-=1。下一条边使用更新后的范围,自然跳过已读的角。读下边前检查 top≤bottom,读左边前检查 left≤right,配合可能为空的循环,处理最后只剩单行、单列或单格的情况。
外层循环在 top≤bottom 且 left≤right 时继续。每次完成一圈,边界指向内层;所有边累计覆盖每个格子恰好一次,总时间为 O(mn)。输出之外只需四个边界,不需要裁剪矩阵或另存访问表。
在每条边开始前,四个闭区间边界圈定剩余待处理的矩形;正在读取的这条边结束后才收缩。每次排除已读边,使角点不会被下一条边重复访问;下边、左边的存在性检查处理最后的单行、单列或单格。
图解
- top
- 0
- bottom
- 2
- left
- 0
- right
- 2
- answer
- []
- top
- 0
- bottom
- 2
- left
- 0
- right
- 2
- answer
- [1,2]
- top
- 1
- bottom
- 2
- left
- 0
- right
- 2
- answer
- [1,2,3]
- top
- 1
- bottom
- 2
- left
- 0
- right
- 2
- answer
- [1,2,3,6]
- top
- 1
- bottom
- 2
- left
- 0
- right
- 1
- answer
- [1,2,3,6,9]
- top
- 1
- bottom
- 1
- left
- 0
- right
- 1
- answer
- [1,2,3,6,9,8,7]
- top
- 1
- bottom
- 1
- left
- 1
- right
- 1
- answer
- [1,2,3,6,9,8,7,4]
- top
- 2
- bottom
- 1
- left
- 1
- right
- 1
- answer
- [1,2,3,6,9,8,7,4,5]
- top
- 2
- bottom
- 1
- left
- 1
- right
- 0
- answer
- [1,2,3,6,9,8,7,4,5]
选择步骤 9 步
变量与图例
绿色路径记录已读位置,虚线框显示边界;重点看读完一条边后,下一条边如何跳过已读的角。
- top / bottom
- 当前可处理矩形的上、下行号,均为闭边界。
- left / right
- 当前可处理矩形的左、右列号,均为闭边界。
- r / c
- 当前边上改变的行号或列号;另一维固定在对应边界。
- answer
- 累计输出顺序;图中“·”只是未输出位置的占位,不属于真实列表。
算法步骤
- 将 top、bottom 设为首末行,left、right 设为首末列,并建立空 answer。
- 在上下、左右边界都合法时开始一圈:从左到右读上边,整边结束后 top 加一。
- 从更新后的 top 到 bottom 读右边,再将 right 减一。
- 若 top≤bottom,从右到左读下边,随后 bottom 减一。
- 若 left≤right,从下到上读左边,随后 left 加一;继续处理内层,矩形为空时返回 answer。
代码(Python)
查看完整 Python 代码四边界逐层收缩 · 与上方图解对应
每走完一边就排除它,方向自然由四个 for 循环表达。下边和左边的补充判断防止退化矩形重复访问。
class Solution:
def spiralOrder(self, matrix: list[list[int]]) -> list[int]:
top, bottom = 0, len(matrix)-1
left, right = 0, len(matrix[0])-1
answer = []
while top <= bottom and left <= right:
for c in range(left, right+1):
answer.append(matrix[top][c])
top += 1
for r in range(top, bottom+1):
answer.append(matrix[r][right])
right -= 1
if top <= bottom:
for c in range(right, left-1, -1):
answer.append(matrix[bottom][c])
bottom -= 1
if left <= right:
for r in range(bottom, top-1, -1):
answer.append(matrix[r][left])
left += 1
return answer运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = Solution()
assert s.spiralOrder([[1,2,3],[4,5,6],[7,8,9]]) == [1,2,3,6,9,8,7,4,5]
assert s.spiralOrder([[1,2,3,4],[5,6,7,8],[9,10,11,12]]) == [1,2,3,4,8,12,11,10,9,5,6,7]
assert s.spiralOrder([[1,2,3]]) == [1,2,3]
assert s.spiralOrder([[1],[2],[3]]) == [1,2,3]
assert s.spiralOrder([[7]]) == [7]
复杂度
- 时间
- O(mn),m 行 n 列,每个格子输出一次。
- 空间
- 辅助 O(1),不计 O(mn) 结果列表。
易错点与边界
- 只有一行或一列时,一圈中的某些边根本不存在,不能无条件重复读取。
- 闭区间 range 的末端需要 +1;反向遍历要以 left-1 或 top-1 为排除端。
- 收缩边界应紧跟该边输出,下一条边使用更新后的范围。
- 输入可为长方形,不能把行数和列数混用。
主解之后,再比较与迁移。
其他解法 1
方向数组与访问标记模拟时间 O(mn),固定走 mn 步。
用 directions 保存右、下、左、上四个方向,visited 记录哪些格子已访问。每次先输出当前格,再试探下一格;下一格越界或访问过时顺时针转向。它按行走过程直接模拟,固定执行 mn 次保证输出全部元素,但需要 O(mn) 访问表。
- 创建全 False 的 visited,设置右、下、左、上四个方向,从左上角朝右出发。
- 固定执行 m*n 轮:把当前格加入答案,并标记为已访问。
- 按当前方向计算下一格;若越界或访问过,就把方向编号加一并对 4 取模。
- 按选定方向更新行列下标;下一轮再读取该位置,完成规定轮数后直接返回答案。
class Solution:
def spiralOrder(self, matrix: list[list[int]]) -> list[int]:
m, n = len(matrix), len(matrix[0])
visited = [[False] * n for _ in range(m)]
directions = [(0,1),(1,0),(0,-1),(-1,0)]
r = c = direction = 0
answer = []
for _ in range(m*n):
answer.append(matrix[r][c])
visited[r][c] = True
dr, dc = directions[direction]
nr, nc = r+dr, c+dc
if not (0 <= nr < m and 0 <= nc < n) or visited[nr][nc]:
direction = (direction+1) % 4
dr, dc = directions[direction]
r, c = r+dr, c+dc
return answer复杂度
- 时间
- O(mn),固定走 mn 步。
- 空间
- 辅助 O(mn) 访问表;结果另占 O(mn)。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = Solution()
assert s.spiralOrder([[1,2,3],[4,5,6],[7,8,9]]) == [1,2,3,6,9,8,7,4,5]
assert s.spiralOrder([[1,2,3,4],[5,6,7,8],[9,10,11,12]]) == [1,2,3,4,8,12,11,10,9,5,6,7]
assert s.spiralOrder([[1,2,3]]) == [1,2,3]
assert s.spiralOrder([[1],[2],[3]]) == [1,2,3]
assert s.spiralOrder([[7]]) == [7]
预测练习
最后一圈读完中心 5 后,top=2、bottom=1。还需要再读取这一圈的下边吗?
一点提示
检查 top<=bottom 是否成立。
答案与理由
不需要。top>bottom 表明行范围已经为空,下边应跳过。右边循环也为空,随后 right 变成 0,左边判断也为假,5 只输出一次。
面试追问与解法取舍
若要求从 1 填到 mn 生成螺旋矩阵(拓展题 59),保留四边界结构,把 append 改成写入递增数值即可。只读地输出遍历时,四边界足够;若形状有洞或路线受障碍影响,矩形边界不再完整描述未访问区域,需要更一般的状态。
模板迁移 / 相似题
试着不看代码,复述这三点
- 每走完一边立刻收缩,下一边使用更新后的闭区间。
- 下边和左边先判断是否存在,尤其关注最后的单行、单列、单格。
- 每格追加一次,总时间 O(mn),输出以外只需四个边界。
48. 旋转图像 中等
一句话题意
将 n×n 方阵顺时针旋转 90 度,必须直接在原矩阵中修改。
matrix=[[1,2,3],[4,5,6],[7,8,9]] → 原地变为 [[7,4,1],[8,5,2],[9,6,3]];返回 None
生活化理解
像给方形照片转方向。可以先沿左上到右下的对角线照镜子,再让每一行左右调头。原来左上角的像素就来到右上角;两次简单镜像组合成一次顺时针旋转。
转置后逐行反转
把旋转拆成转置与行反转两次原地变换,坐标证明和代码都简洁清楚。核心思路
顺时针旋转的坐标关系是 (r,c)→(c,n-1-r)。转置先把 (r,c) 变成 (c,r),再逐行左右反转,把此时的列号 r 变成 n-1-r,两步合起来恰好得到目标位置。
转置只访问主对角线一侧,即 c>r,每对对称格只交换一次。若遍历整个矩阵,每对会交换两次,最终回到原状;对角线位置本身无需交换。转置结束后,原来的行变为列。
随后对 matrix 中每一行调用 row.reverse(),直接反转原行列表。已处理的行不会再改变,全部完成后原左上角元素来到右上角,与顺时针方向一致。两个阶段都直接修改原矩阵,不建立矩阵或行切片副本,辅助空间为 O(1),函数返回 None。
转置阶段只访问 c>r,每对主对角线对称格恰好交换一次,完成后旧 (r,c) 位于 (c,r)。随后逐行左右反转使其到达 (c,n-1-r),恰好满足顺时针旋转映射;两阶段都直接修改原矩阵。
图解
- n
- 3
- 阶段
- 初始化
- matrix
- [[1,2,3],[4,5,6],[7,8,9]]
- 阶段
- 转置
- r
- 0
- c
- 1
- matrix
- [[1,4,3],[2,5,6],[7,8,9]]
- 阶段
- 转置
- r
- 0
- c
- 2
- matrix
- [[1,4,7],[2,5,6],[3,8,9]]
- 阶段
- 转置
- r
- 1
- c
- 2
- matrix
- [[1,4,7],[2,5,8],[3,6,9]]
- 阶段
- 逐行反转
- 行号
- 0
- row
- [7,4,1]
- matrix
- [[7,4,1],[2,5,8],[3,6,9]]
- 阶段
- 逐行反转
- 行号
- 1
- row
- [8,5,2]
- matrix
- [[7,4,1],[8,5,2],[3,6,9]]
- 阶段
- 逐行反转
- 行号
- 2
- row
- [9,6,3]
- matrix
- [[7,4,1],[8,5,2],[9,6,3]]
- 阶段
- 完成
- matrix
- [[7,4,1],[8,5,2],[9,6,3]]
- 返回值
- None
选择步骤 8 步
变量与图例
左右两张矩阵分别显示本步前后:先跟随每对对称位置的交换,再观察三行依次反转。
- n
- 方阵边长,本例为 3。
- r / c
- 转置时的行列下标,始终只枚举 c>r。
- row
- 逐行反转时直接引用 matrix 中的一行;reverse 修改该行本身。
- 行号
- 图中用于说明当前 row 属于哪一行的教学标注,代码无需另存它。
- matrix
- 两阶段共用的原矩阵;左图保存本步前状态仅用于教学。
算法步骤
- 读取方阵边长 n。
- 遍历行 r,只让列 c 取 r+1 到 n-1,交换 matrix[r][c] 与 matrix[c][r],完成转置。
- 依次取得每一个原行列表 row,调用 row.reverse() 做左右反转。
- 所有行处理完成后,原元素已从 (r,c) 到达 (c,n-1-r),原矩阵旋转完成。
代码(Python)
查看完整 Python 代码转置后逐行反转 · 与上方图解对应
把复杂旋转拆成两个可独立验证的原地变换。list.reverse() 原地操作,不像 row[::-1] 那样分配行副本。
class Solution:
def rotate(self, matrix: list[list[int]]) -> None:
n = len(matrix)
for r in range(n):
for c in range(r+1, n):
matrix[r][c], matrix[c][r] = matrix[c][r], matrix[r][c]
for row in matrix:
row.reverse()运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = Solution()
a = [[1,2,3],[4,5,6],[7,8,9]]
assert s.rotate(a) is None
assert a == [[7,4,1],[8,5,2],[9,6,3]]
a = [[1,2],[3,4]]
s.rotate(a)
assert a == [[3,1],[4,2]]
a = [[7]]
s.rotate(a)
assert a == [[7]]
a = [[1,2,3,4],[5,6,7,8],[9,10,11,12],[13,14,15,16]]
for _ in range(4):
s.rotate(a)
assert a == [[1,2,3,4],[5,6,7,8],[9,10,11,12],[13,14,15,16]]
复杂度
- 时间
- O(n²),n 为方阵边长,转置与反转都线性于格子数。
- 空间
- 辅助 O(1),交换临时值与原地 reverse 都不建立矩阵副本。
易错点与边界
- 转置只能扫描对角线一侧,否则交换两次会抵消。
- 输入是方阵;奇数边长的中心不动,n=1 时无需交换。
- 主解先沿主对角线转置,再逐行左右反转,得到顺时针旋转结果。
主解之后,再比较与迁移。
其他解法 1
逐层四点轮换时间 O(n²),每个非中心格子搬运一次。
直接沿旋转映射把每圈拆成四点环,暂存顶部值后依次执行左→上、下→左、右→下、暂存→右。layer 是当前外圈距边缘的层数,last 是本层最后下标,offset 是上边当前位置距左端的偏移。它适合按每个元素的目标位置理解旋转;顶边不含最后一个角,避免重复处理,奇数方阵中心保持不动。
- 依次处理 layer=0 到 n//2-1,令 last=n-1-layer,确定当前方形外圈。
- 让顶边列 c 从 layer 遍历到 last-1,计算 offset=c-layer,并暂存 top=matrix[layer][c]。
- 把左侧 matrix[last-offset][layer] 搬到顶部,再把底部 matrix[last][last-offset] 搬到左侧。
- 把右侧 matrix[c][last] 搬到底部,最后把暂存的 top 写到右侧,完成四点轮换。
- 完成本圈所有起点后进入内圈;所有层结束,原矩阵即为顺时针旋转结果。
class Solution:
def rotate(self, matrix: list[list[int]]) -> None:
n = len(matrix)
for layer in range(n // 2):
last = n - 1 - layer
for c in range(layer, last):
offset = c - layer
top = matrix[layer][c]
matrix[layer][c] = matrix[last-offset][layer]
matrix[last-offset][layer] = matrix[last][last-offset]
matrix[last][last-offset] = matrix[c][last]
matrix[c][last] = top复杂度
- 时间
- O(n²),每个非中心格子搬运一次。
- 空间
- 辅助 O(1),一份顶部值和少量下标。
这个方法的边界
- 四点轮换必须暂存第一个值,不能让赋值覆盖后续还需读取的旧值。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = Solution()
a = [[1,2,3],[4,5,6],[7,8,9]]
assert s.rotate(a) is None
assert a == [[7,4,1],[8,5,2],[9,6,3]]
a = [[1,2],[3,4]]
s.rotate(a)
assert a == [[3,1],[4,2]]
a = [[7]]
s.rotate(a)
assert a == [[7]]
a = [[1,2,3,4],[5,6,7,8],[9,10,11,12],[13,14,15,16]]
for _ in range(4):
s.rotate(a)
assert a == [[1,2,3,4],[5,6,7,8],[9,10,11,12],[13,14,15,16]]
预测练习
交换 (0,1) 的 2 与 (1,0) 的 4 后,为什么不能在后面又以 r=1、c=0 再交换这两个位置?
一点提示
同一对位置交换两次后,数值会在哪里?
答案与理由
会回到原状,抵消转置。内层只枚举 c>r,保证每对恰好交换一次;主对角线无需交换。
面试追问与解法取舍
若改为逆时针,可转置后上下翻转,或连续做三次顺时针但常数更大。若输入是 m×n 非方阵,输出形状会变为 n×m,当前二维列表的原地要求需要重新定义;不要直接把方阵下标公式套到长方形。
其他思路的边界
- 转置后左右翻转是顺时针;转置后上下翻转是逆时针。
模板迁移 / 相似题
试着不看代码,复述这三点
- 顺时针映射 (r,c)→(c,n-1-r) 可以拆成转置再左右反转。
- 转置只扫描主对角线一侧,避免同一对交换两次。
- row.reverse() 修改原行,时间 O(n²)、额外空间 O(1)。
240. 搜索二维矩阵 II 中等
一句话题意
判断目标值是否存在于每行从左到右、每列从上到下升序的矩阵中。
matrix=[[1,4,7,11,15],[2,5,8,12,19],[3,6,9,16,22],[10,13,14,17,24]],target=13 → True(坐标 [3,1])。
生活化理解
把矩阵看作图书馆书架:同一排越往右书号越大,同一列越往下也越大。站在右上角,就像同时握住“本排最大”和“本列最小”两条线索;比较一次可以整排或整列排除。
右上角阶梯搜索
用行列单调性每步排除一整行或一整列,移动方向清楚且只需两个下标。核心思路
令 r=0、c=列数−1,从右上角开始。每轮开始,尚未排除的候选区域都是第 r 到最后一行、第 0 到 c 列的矩形;若目标存在,就一定还在这块区域里。代码中的 value=matrix[r][c] 是候选首行的最大值,同时是候选最右列的最小值。
如果 value 大于目标,本列下方的数只会更大,整列都不可能命中,因此令 c 减一。如果它小于目标,本行左侧的数只会更小,整行都不可能命中,因此令 r 加一。两种移动都保留其余可能位置,并且不会重新进入已经排除的行列。
相等就返回 True;走出矩阵边界则候选区域为空,返回 False。图中寻找 13 的读取顺序为 15、11、12、16、9、14、13,恰好体现“大了左移、小了下移”。每步减少一行或一列,所以无需逐格访问整个矩阵。
每轮开始,若目标存在,它必在行 r..m−1、列 0..c 的候选矩形中。当前格是该矩形首行最大、末列最小;按比较结果排除整行或整列不会丢失目标。
图解
- r
- 0
- c
- 4
- value
- 15
- 候选
- 行 0..3 × 列 0..4
- r
- 0
- c
- 3
- value
- 11
- 候选
- 行 0..3 × 列 0..3
- r
- 1
- c
- 3
- value
- 12
- 候选
- 行 1..3 × 列 0..3
- r
- 2
- c
- 3
- value
- 16
- 候选
- 行 2..3 × 列 0..3
- r
- 2
- c
- 2
- value
- 9
- 候选
- 行 2..3 × 列 0..2
- r
- 3
- c
- 2
- value
- 14
- 候选
- 行 3..3 × 列 0..2
- r
- 3
- c
- 1
- value
- 13
- 候选
- 行 3..3 × 列 0..1
- r
- 3
- c
- 1
- value
- 13
- 候选
- 行 3..3 × 列 0..1
选择步骤 8 步
变量与图例
追踪候选矩形的右上角:大了删一列,小了删一行,淡色区域不会再进入候选。
- r
- 候选首行下标,只向下增加。
- c
- 候选最右列下标,只向左减小。
- value
- 本轮读取的 matrix[r][c]。
- target
- 固定目标值 13。
算法步骤
- 初始化 r=0、c=列数−1,使当前位置位于整个候选矩形的右上角。
- 当 r 仍在矩阵内且 c 非负时,读取 value=matrix[r][c];相等立即返回 True。
- value 大于目标时令 c 减一,排除当前候选列;否则令 r 加一,排除当前候选行。
- 循环因越界结束时,候选区域已空,返回 False。
代码(Python)
查看完整 Python 代码右上角阶梯搜索 · 与上方图解对应
r、c 指向候选矩形的右上角。每个失败比较都会永久排除一行或一列,没有回溯,也不修改矩阵。
class Solution:
def searchMatrix(self, matrix, target):
r, c = 0, len(matrix[0]) - 1
while r < len(matrix) and c >= 0:
value = matrix[r][c]
if value == target:
return True
if value > target:
c -= 1
else:
r += 1
return False
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = Solution()
m = [[1,4,7,11],[2,5,8,12],[3,6,9,16]]
assert s.searchMatrix(m, 5) is True
assert s.searchMatrix(m, 10) is False
assert s.searchMatrix([[1]], 1) is True
assert s.searchMatrix([[1]], 0) is False
assert s.searchMatrix([[1,2,3]], 3) is True
assert s.searchMatrix([[1],[3],[5]], 4) is False
assert m == [[1,4,7,11],[2,5,8,12],[3,6,9,16]]
复杂度
- 时间
- O(m+n):m 行 n 列,每步使行号增大或列号减小。
- 空间
- O(1) 额外空间。
易错点与边界
- 不能从左上角比较后任意选择右移或下移:两个方向都变大,无法排除一边。
- 本题只有行列各自有序,不能套用展开后整体二分。
- 单行、单列同样适用;相等必须立即返回。
主解之后,再比较与迁移。
其他解法 1
逐行二分时间 O(m log(n+1)):m 行分别二分 n 个元素;加 1 表示单列仍需比较。
每行单独维护闭区间 [lo,hi],只利用该行升序这一事实。它适合从一维二分直接迁移,但没有利用列有序来跨行排除候选。本题不保证上一行末项小于下一行首项,因此不能像第 74 题那样把整个矩阵展平成一个有序数组后整体二分;逐行二分则不需要这一额外条件。
- 依次取出每一行,将 lo、hi 初始化为该行首尾下标。
- 在 lo 不超过 hi 时计算 mid,若 row[mid] 等于目标就返回 True。
- 中点值小于目标时令 lo=mid+1,否则令 hi=mid−1,继续查找当前行。
- 当前行候选为空后再查下一行;所有行都未命中才返回 False。
class Solution:
def searchMatrix(self, matrix, target):
for row in matrix:
lo, hi = 0, len(row) - 1
while lo <= hi:
mid = (lo + hi) // 2
if row[mid] == target:
return True
if row[mid] < target:
lo = mid + 1
else:
hi = mid - 1
return False
复杂度
- 时间
- O(m log(n+1)):m 行分别二分 n 个元素;加 1 表示单列仍需比较。
- 空间
- O(1) 额外空间,没有复制行或切片。
这个方法的边界
- 闭区间二分要使用 lo≤hi,否则会漏掉最后一个候选。
- 每换到新的一行,都要重新初始化 lo=0、hi=该行末下标,不能沿用上一行已经耗尽的区间。
- 一行查找失败不代表整个矩阵没有目标,必须继续检查其余行。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = Solution()
m = [[1,4,7,11],[2,5,8,12],[3,6,9,16]]
assert s.searchMatrix(m, 5) is True
assert s.searchMatrix(m, 10) is False
assert s.searchMatrix([[1]], 1) is True
assert s.searchMatrix([[1]], 0) is False
assert s.searchMatrix([[1,2,3]], 3) is True
assert s.searchMatrix([[1],[3],[5]], 4) is False
assert m == [[1,4,7,11],[2,5,8,12],[3,6,9,16]]
预测练习
读到 matrix[2][3]=16 时,为什么不能向下走到 17?
一点提示
看 16 在当前最右列中的大小地位。
答案与理由
16 是该候选列的最小值,16、17 都大于 13,应向左排除整列。向下只会看到更大的数,还会漏掉左侧可能存在的 13。
面试追问与解法取舍
为什么左下角也能做?它是本行最小、本列最大,过小就向右,过大就向上。长宽很不均衡时可以比较阶梯法与沿短维枚举、长维二分的成本;“常数空间”并不意味着一定选同一种遍历路线。
模板迁移 / 相似题
试着不看代码,复述这三点
- 右上角同时提供“行最大、列最小”两条排除依据。
- r 只增、c 只减,总移动数不超过行数加列数。
- 行列各自有序不代表展开后整体有序,不能直接套一维二分。
160. 相交链表 简单
一句话题意
返回两条无环单链表的首个共享节点;不存在则返回 None,不能改变链表结构。
A: A1(4)→A2(1)→C1(8)→C2(9);B: B1(5)→B2(6)→B3(1)→C1(8)→C2(9)。预期返回共享节点 C1,非仅返回数值 8。
生活化理解
两个人沿不同支路走向一条公共街道。两条支路长度不一样,直接同时出发未必能在入口碰面;各自走完后再走对方的路线,相当于把各自多走的支路长度补齐。街道交汇指的是同一个路口,而非两个路口恰好同名。
双指针换路
通过互换遍历路线抵消长度差,以常数额外空间找到共享节点且不修改输入链。核心思路
p 从 headA 出发,q 从 headB 出发。每轮分别移动一次:当前位置非空就沿 next 前进;当前位置已经是 None 才换到另一条链的头。图中从末节点走到 None 与从 None 换头是两个不同轮次,不能把它们合并成一次移动。
设 A、B 的独有前缀长度分别为 a、b,共享尾段长度为 c。如果第一趟尚未相遇,换路后双方到公共入口都经历了各自前缀、共享尾段和对方前缀,路程中的节点段总长都是 a+b+c;代码跨过 None 的额外换头移动也对称。因此原来的长度差被抵消,两者会同步抵达共享入口。如果更早指向同一对象,直接结束即可。
循环比较的是 p is not q,即节点对象身份,而不是数值。图中 B3 与 A2 的值都为 1,仍不能判定相交;直到二者同时指向 C1 才返回。无环链表一旦共享节点,其后继路径必然相同;如果没有共享尾段,两条互补路线会使双方最终同时成为 None,返回值便表示没有交点。整个过程只移动局部引用,不改写任何 next。
p 按 A 后 B 的路线前进,q 按 B 后 A 的路线前进,每轮各执行一次移动。两条互补路线会抵消独有前缀的长度差;共享后缀存在时会在其入口同步,不存在时会一起到 None。
图解
- p
- A1
- q
- B1
- 同步移动次数
- 0
- p is q
- False
- p
- A2
- q
- B2
- 同步移动次数
- 1
- p is q
- False
- p
- C1
- q
- B3
- 同步移动次数
- 2
- p is q
- False
- p
- C2
- q
- C1
- 同步移动次数
- 3
- p is q
- False
- p
- None
- q
- C2
- 同步移动次数
- 4
- p is q
- False
- p
- B1
- q
- None
- 同步移动次数
- 5
- p is q
- False
- p
- B2
- q
- A1
- 同步移动次数
- 6
- p is q
- False
- p
- B3
- q
- A2
- 同步移动次数
- 7
- p is q
- False
- p
- C1
- q
- C1
- 同步移动次数
- 8
- p is q
- True
选择步骤 9 步
变量与图例
盯住两个指针的路线与 None 停留帧:走完自己的链后,下一轮才换到对方头。
- p / q
- 当前所指的节点对象;相等使用 is 比较身份。
- headA / headB
- 固定输入头 A1、B1,换路时使用。
- None
- 当前路径已到尾后的空引用,下一轮触发换头。
- C1 / C2
- 两条输入链实际共用的节点,不是值相同的两份节点。
算法步骤
- 令 p=headA、q=headB,并以两个引用是否指向同一对象作为循环条件。
- p 非空时令 p=p.next;p 已为空时令 p=headB。
- q 按相同规则前进,但为空时换到 headA;两个指针每轮各移动一次。
- 身份相同时返回 p:它可能是共享入口,也可能是表示不相交的 None。
代码(Python)
查看完整 Python 代码双指针换路 · 与上方图解对应
p 走 A 再走 B,q 走 B 再走 A,用相同总路程消掉长度差。判断使用 is,返回共享节点本身。
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
class Solution:
def getIntersectionNode(self, headA, headB):
p, q = headA, headB
while p is not q:
p = p.next if p is not None else headB
q = q.next if q is not None else headA
return p
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
def build(values):
nodes = [ListNode(v) for v in values]
for a, b in zip(nodes, nodes[1:]):
a.next = b
return (nodes[0] if nodes else None), nodes
def collect(head):
nodes = []
while head is not None:
assert head not in nodes, "unexpected cycle"
nodes.append(head)
head = head.next
return nodes
shared, tail = build([8,4,5])
a, aa = build([4,1]); b, bb = build([5,6,1])
aa[-1].next = shared; bb[-1].next = shared
links = [(node, node.next) for node in aa + bb + tail]
assert Solution().getIntersectionNode(a, b) is shared
assert all(node.next is nxt for node, nxt in links)
x, _ = build([1,2]); y, _ = build([1,2])
assert Solution().getIntersectionNode(x, y) is None
assert Solution().getIntersectionNode(shared, shared) is shared
assert Solution().getIntersectionNode(None, y) is None
复杂度
- 时间
- O(m+n):m、n 为两条链表长度,每个指针最多走两条链。
- 空间
- O(1) 额外空间。
易错点与边界
- 相交是节点身份相同,值相同不代表相交。
- 换路条件是指针为 None;随意提前在尾节点换路会破坏对应路程。
- 不能拼接链表或反转输入来制造交点,本题要求保留结构。
- 头节点本身可以相交;无交点时应让两指针在 None 处相等退出。
主解之后,再比较与迁移。
其他解法 1
节点集合时间 O(m+n) 期望时间,节点集合查询期望 O(1)。
seen 保存第一条链上实际访问过的节点对象,再沿第二条链寻找第一个属于 seen 的节点。无环链表的共享部分是同一条后缀,因此 B 上首次命中的对象就是公共入口。原 ListNode 没有重写相等或哈希规则,集合按对象身份区分节点,不会把数值相等的不同对象合并。该方法用保存 A 节点的额外空间换取直接的成员判断。
- 建立空集合 seen,沿 A 的 next 逐个加入节点对象。
- 从 B 的头开始遍历,在每次前进之前判断当前节点是否属于 seen。
- 遇到第一个已记录的对象就返回该节点;B 遍历结束仍无命中则返回 None。
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
class Solution:
def getIntersectionNode(self, headA, headB):
seen = set()
while headA is not None:
seen.add(headA)
headA = headA.next
while headB is not None:
if headB in seen:
return headB
headB = headB.next
return None
复杂度
- 时间
- O(m+n) 期望时间,节点集合查询期望 O(1)。
- 空间
- O(m) 额外空间,存储第一条链的节点引用。
这个方法的边界
- seen 应保存节点对象,不保存 val;数值相等的两个节点不等于相交。
- 先完整记录 A,再按 B 的 next 顺序寻找首次命中;B 遍历到 None 仍未命中才返回 None。
- 集合解法不换路,也不改写任何 next;不能把双指针换头条件搬进本方法。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
def build(values):
nodes = [ListNode(v) for v in values]
for a, b in zip(nodes, nodes[1:]):
a.next = b
return (nodes[0] if nodes else None), nodes
def collect(head):
nodes = []
while head is not None:
assert head not in nodes, "unexpected cycle"
nodes.append(head)
head = head.next
return nodes
shared, tail = build([8,4,5])
a, aa = build([4,1]); b, bb = build([5,6,1])
aa[-1].next = shared; bb[-1].next = shared
links = [(node, node.next) for node in aa + bb + tail]
assert Solution().getIntersectionNode(a, b) is shared
assert all(node.next is nxt for node, nxt in links)
x, _ = build([1,2]); y, _ = build([1,2])
assert Solution().getIntersectionNode(x, y) is None
assert Solution().getIntersectionNode(shared, shared) is shared
assert Solution().getIntersectionNode(None, y) is None
预测练习
p=B3、q=A2 时,两者的值都为 1,下一步应返回 1 吗?
一点提示
题目问的是相交节点,代码用 is。
答案与理由
不能。B3 与 A2 是不同对象,还要各走一步,二者才同时指向同一对象 C1(8),并返回 C1。
面试追问与解法取舍
也可以先计算长度差,让较长链的指针先走 |m−n| 步,再同步前进,时间 O(m+n)、空间 O(1)。面试时先说清两条链无环;如果放宽为有环输入,需要分类讨论两个环入口和是否共享同一环,不能直接复用此代码。
模板迁移 / 相似题
试着不看代码,复述这三点
- 相交是对象共享,不能比较 val 判断。
- 到 None 与换头是两次循环中的两个动作。
- 交换行走顺序抵消长度差,全程不改 next。
206. 反转链表 简单
一句话题意
把单链表的 next 方向全部反转,返回新头节点,复用原节点。
A(1)→B(2)→C(3)→D(4)→None → 返回 D,结果 D(4)→C(3)→B(2)→A(1)→None。
生活化理解
把一队通过“指向下一位”保持顺序的人调头。每次只处理队首一个人:先记住原来后面是谁,再让他指向已经调头的队伍。prev 是已调头队首,cur 是尚未调头队首,nxt 是防止丢队的临时联系人。
三指针迭代
每轮只反转一条连接,三个引用的职责明确,适合在面试中逐步推导且没有递归栈开销。核心思路
每轮开始,prev 指向已经反转的前缀头,cur 指向尚未处理的后缀头。这两段合起来恰好包含全部原节点,已反转段的末尾指向 None。初始化 prev=None,表示新链还为空;cur=head 表示全部节点都待处理。
先保存 nxt=cur.next,再令 cur.next=prev。图中的第一轮先保留 B,再把 A 的箭头改向 None;此时原后缀虽然不再从 A 可达,仍由 nxt 持有。随后执行 prev, cur = cur, nxt,把当前节点纳入已反转前缀,再开始处理原后继。保存、改接、移动引用各有职责,顺序不能混淆。
每轮结束都恢复上述两段不变量,未处理后缀少一个节点。cur 变成 None 时,prev 就是包含全部节点的反转链头;空链也会自然返回 None。算法复用原节点并永久改写它们的 next,旧 head 仍指原来的对象,而该对象已成为尾节点,因此应从返回的新头访问结果。
每轮开始,prev 指向已反转前缀的头,cur 指向未反转后缀的头,两段合起来覆盖原节点。先保存 nxt,再翻转 cur.next,最后同时移动 prev、cur,就把一个节点从后缀移入反转前缀而不丢节点。
图解
- prev
- None
- cur
- A
- nxt
- 尚未赋值
- prev
- None
- cur
- A
- nxt
- B
- prev
- None
- cur
- A
- nxt
- B
- A.next
- None
- prev
- A
- cur
- B
- nxt
- B
- prev
- A
- cur
- B
- nxt
- C
- B.next
- A
- prev
- B
- cur
- C
- nxt
- C
- prev
- B
- cur
- C
- nxt
- D
- C.next
- B
- prev
- C
- cur
- D
- nxt
- D
- prev
- C
- cur
- D
- nxt
- None
- D.next
- C
- prev
- D
- cur
- None
- nxt
- None
- 结果
- D→C→B→A→None
选择步骤 10 步
变量与图例
观察每次 cur.next 改写前的 nxt:它是断边后仍能找到未处理后缀的关键。
- prev
- 已反转前缀的头,初始为 None。
- cur
- 本轮需要翻转 next 的原节点。
- nxt
- 改写前保存的 cur.next,保证还能进入后缀。
- head
- 原头 A;反转后它仍是 A,但已成为尾节点。
算法步骤
- 初始化 prev=None、cur=head,分别表示已反转前缀和待处理后缀。
- cur 非空时,先用 nxt 保存原来的 cur.next。
- 令 cur.next=prev,把当前节点接到反转前缀前面。
- 同时令 prev=cur、cur=nxt;重复到 cur 为空,返回 prev。
代码(Python)
查看完整 Python 代码三指针迭代 · 与上方图解对应
prev 是已反转前缀的头,cur 是待处理节点,nxt 保存原后继。每轮只改一条 next,不分配新节点。
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
class Solution:
def reverseList(self, head):
prev, cur = None, head
while cur is not None:
nxt = cur.next
cur.next = prev
prev, cur = cur, nxt
return prev
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
def build(values):
nodes = [ListNode(v) for v in values]
for a, b in zip(nodes, nodes[1:]):
a.next = b
return (nodes[0] if nodes else None), nodes
def collect(head):
nodes = []
while head is not None:
assert head not in nodes, "unexpected cycle"
nodes.append(head)
head = head.next
return nodes
head, ns = build([1,2,3,4,5])
out = Solution().reverseList(head)
assert collect(out) == ns[::-1]
assert ns[0].next is None
assert [node.val for node in collect(out)] == [5,4,3,2,1]
assert Solution().reverseList(None) is None
single, ns = build([7])
assert Solution().reverseList(single) is single and single.next is None
复杂度
- 时间
- O(n):n 为节点数,每个节点改接一次。
- 空间
- O(1) 额外空间。
易错点与边界
- 改 cur.next 之前必须保存原后继。
- 迭代版本结束时返回 prev,不是仍指向原头对象的 head;旧头已成为新尾。
- 空链返回 None,单节点反转后仍返回原节点;本迭代方法没有递归深度限制。
主解之后,再比较与迁移。
其他解法 1
递归反转后缀时间 O(n):每层处理一个节点。
递归契约是 reverseList(head) 返回这段链表反转后的新头。先让 head.next 开始的后缀完成反转;返回后,head.next 仍指原第二个节点,而它已经成为反转后缀的尾。用 head.next.next=head 将旧头接到尾部,再用 head.next=None 断开旧方向,最后一路返回同一个 new_head。该写法有助于理解递归返回值与旧引用的区别,但调用深度随链长增加,长链可能超过 Python 默认递归深度。
- 当前链为空或只有一个节点时,直接返回 head。
- 递归反转 head.next 开始的后缀,保存其新头 new_head。
- 让原第二个节点的 next 指向 head,再把 head.next 设为 None,使旧头成为新尾。
- 返回 new_head;当前层只补接旧头,不改变递归已经确定的新头。
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
class Solution:
def reverseList(self, head):
if head is None or head.next is None:
return head
new_head = self.reverseList(head.next)
head.next.next = head
head.next = None
return new_head
复杂度
- 时间
- O(n):每层处理一个节点。
- 空间
- O(n) 递归栈;没有新结果节点,不能因只用几个局部变量就写 O(1)。
这个方法的边界
- 递归版若遗漏 head.next=None,会形成双向回指的小环。
- 返回的是新头 prev/new_head,而非旧 head。
- 空链与单节点直接保持;Python 递归版不适合直接处理超深链表。
- 本方法上文所说的新头是 new_head;prev 属于迭代方法,不是这段递归代码中的变量。
- head.next.next=head 要在后缀递归完成后执行,并随后断开 head.next;两次写入各有职责。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
def build(values):
nodes = [ListNode(v) for v in values]
for a, b in zip(nodes, nodes[1:]):
a.next = b
return (nodes[0] if nodes else None), nodes
def collect(head):
nodes = []
while head is not None:
assert head not in nodes, "unexpected cycle"
nodes.append(head)
head = head.next
return nodes
head, ns = build([1,2,3,4,5])
out = Solution().reverseList(head)
assert collect(out) == ns[::-1]
assert ns[0].next is None
assert [node.val for node in collect(out)] == [5,4,3,2,1]
assert Solution().reverseList(None) is None
single, ns = build([7])
assert Solution().reverseList(single) is single and single.next is None
预测练习
B.next 已改为 A,但 prev=A、cur=B 时,下一句能写 cur=cur.next 吗?
一点提示
此时 B.next 已不是原后继 C。
答案与理由
不能。cur.next 此时是 A,会走回已反转段。必须用提前保存的 nxt=C,让 prev、cur 同时变成 B、C。
面试追问与解法取舍
如果要求原链表保持原样,应新建节点形成反序副本,输出空间 O(n),而不是使用这里的原地版本。如果只反转区间,应额外保存区间前驱与区间后继;三指针核心仍然相同,但需要在结束后重新接回两端。
模板迁移 / 相似题
试着不看代码,复述这三点
- 先保存后继,再改箭头,最后移动两个段头。
- 改 next 改变结构;移动指针变量本身不改变结构。
- 结束条件是 cur=None,返回 prev 而不是原 head。
234. 回文链表 简单
一句话题意
判断单链表的节点值序列是否正反相同;进阶做到 O(n) 时间、O(1) 额外空间。
A(1)→B(2)→C(2)→D(1)→None → True;返回前恢复同一组原节点的 A→B→C→D 连接。
生活化理解
像检查一列纸牌能否沿中线对折重合。单链表只能向前看,无法直接从右往左取牌;于是先找到中线,把右半列暂时调头,两个人从两半起点同步核对,最后再把纸牌摆回原样。
快慢指针 + 反转并恢复
用常数额外空间把对称位置变成同向遍历,并在返回前恢复输入链,适合讲清链表综合操作。核心思路
空链或单节点直接为回文。其余情况让 slow、fast 从头开始,仅在 fast.next 和 fast.next.next 都存在时分别走一步、两步。结束时,偶数长度的 slow 位于前半段末尾,奇数长度则位于中央节点,因此 slow.next 统一表示需要反转的后半段起点。
调用局部 reverse 得到后半段的新头 second。helper 内部的 prev、node、nxt 负责保存后继和逐边反转,外层 slow 保持不动。图中 C→D 被改成 D→C,原来的 B.next 仍指 C;D 改由 second 保存。然后令 left=head、right=second 同速比较,每次对应原链的一对对称位置。由 right 控制比较次数,刚好比较后半段长度,奇数链的中央节点不必比较。
result 记录已比较的对称值是否全部相等。遇到不等只记录 False 并退出比较循环,不能立即返回,因为还需执行 slow.next=reverse(second)。second 始终保留恢复所需的反转后半入口,不随 right 前进;第二次反转返回恢复后的后半头,再接回 slow.next。无论结果真伪,返回前全部原连接都已复原。
slow 停在前半段尾后保持不动;将后半段反转后,left 与 right 的同速移动对应原链的对称位置。比较只覆盖后半段长度,结果先存入 result;随后再反转一次并接回,恢复全部原连接。
图解
- slow
- A
- fast
- A
- 阶段
- 找中点
- slow
- B
- fast
- C
- 后半段起点
- C
- slow
- B
- reverse.node
- C
- reverse.prev
- None
- reverse.nxt
- D
- slow
- B
- reverse.prev
- C
- reverse.node
- D
- reverse.nxt
- D
- slow
- B
- reverse.prev
- C
- reverse.node
- D
- reverse.nxt
- None
- D.next
- C
- slow
- B
- second
- D
- left
- A
- right
- D
- result
- True
- second
- D
- left
- B
- right
- C
- result
- True
- 已比较
- A(1) 与 D(1)
- second
- D
- left
- C
- right
- None
- result
- True
- 已比较
- A/D、B/C 均相等
- second
- D
- result
- True
- reverse.node
- D
- reverse.prev
- None
- reverse.nxt
- C
- second
- D
- result
- True
- reverse.prev
- D
- reverse.node
- C
- reverse.nxt
- None
- slow
- B
- second
- D
- reverse.prev
- C
- reverse.node
- None
- result
- True
- slow
- B
- slow.next
- C
- result
- True
- 恢复后
- A→B→C→D→None
选择步骤 12 步
变量与图例
分清三组指针:slow 找分界,reverse 的局部指针改箭头,left/right 比较镜像位置。
- slow / fast
- 定位前半段尾 B;fast 每轮走两步。
- node / prev / nxt
- reverse 内部的当前、已反转头、保存的后继;每次调用重新初始化。
- second
- 第一次反转返回的头 D,比较和恢复都从它开始。
- left / right
- 前半段与反向后半段的比较位置。
- result
- 值比较结果,先保存答案,恢复后再返回。
算法步骤
- 空链或单节点返回 True;否则用指定快慢指针条件找到前半段尾 slow。
- 反转 slow.next 开始的后半段,将返回的新头保存在 second;slow 保持不动。
- 令 left=head、right=second、result=True,逐对比较值并同步前进,以 right 为空作为完成条件。
- 若某对不等,将 result 记为 False 并结束比较;仍继续执行恢复步骤。
- 把 reverse(second) 的返回值接回 slow.next,然后返回 result。
代码(Python)
查看完整 Python 代码快慢指针 + 反转并恢复 · 与上方图解对应
slow 定位前半尾,second 保留反转后半的入口。比较失败只 break,不直接 return,保证非回文链表也能恢复。
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
class Solution:
def isPalindrome(self, head):
if head is None or head.next is None:
return True
def reverse(node):
prev = None
while node is not None:
nxt = node.next
node.next = prev
prev, node = node, nxt
return prev
slow = fast = head
while fast.next is not None and fast.next.next is not None:
slow = slow.next
fast = fast.next.next
second = reverse(slow.next)
left, right = head, second
result = True
while right is not None:
if left.val != right.val:
result = False
break
left, right = left.next, right.next
slow.next = reverse(second)
return result
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
def build(values):
nodes = [ListNode(v) for v in values]
for a, b in zip(nodes, nodes[1:]):
a.next = b
return (nodes[0] if nodes else None), nodes
def collect(head):
nodes = []
while head is not None:
assert head not in nodes, "unexpected cycle"
nodes.append(head)
head = head.next
return nodes
for values, expected in [([1,2,2,1],True),([1,2,3,2,1],True),([1,2],False),([1],True),([1,2,3,1],False)]:
head, ns = build(values)
links = [node.next for node in ns]
assert Solution().isPalindrome(head) is expected
assert collect(head) == ns
assert all(node.next is nxt for node, nxt in zip(ns, links))
assert [node.val for node in ns] == values
复杂度
- 时间
- O(n):找中点、反转、比较、恢复都至多线性遍历。
- 空间
- O(1) 额外空间,反转使用迭代,无递归栈。
易错点与边界
- 判断回文比较 val,不比较节点身份;一条正常链表两端本就是不同对象。
- 奇数长度的中点不需要与其他节点配对。
- 若要恢复链表,发现不等不能直接 return False。
- 第二次反转必须从保存的 second 开始,不能使用已走到末尾的 right。
主解之后,再比较与迁移。
其他解法 1
复制值后双指针比较时间 O(n):提取 n 个值,再比较至多 n/2 对。
先把节点值顺序复制到 values,再让 i、j 从数组两端向内比较。回文只要求对称数值相等,不要求对称位置是同一节点对象。它把链表问题转为数组随机访问,代价是额外保存全部值;整个过程中不修改输入连接,所以不需要主解的反转与恢复阶段。
- 沿链表遍历,将每个 head.val 依次追加到 values。
- 初始化 i=0、j=len(values)−1。
- 当 i 小于 j 时比较两端值,不等立即返回 False;相等则 i 加一、j 减一。
- 两指针相遇或交错表示所有对称位置都相等,返回 True。
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
class Solution:
def isPalindrome(self, head):
values = []
while head is not None:
values.append(head.val)
head = head.next
i, j = 0, len(values) - 1
while i < j:
if values[i] != values[j]:
return False
i += 1
j -= 1
return True
复杂度
- 时间
- O(n):提取 n 个值,再比较至多 n/2 对。
- 空间
- O(n) 额外数组空间;未使用 values[::-1],没有再生成反序副本。
这个方法的边界
- values 保存的是 val,对称位置比较数值即可,不要求节点身份相同。
- 循环使用 i<j,中央单个值无需配对;两指针相遇或交错就结束。
- 本方法只读输入连接,发现不等可以立即返回 False,不需要主解的反转恢复阶段。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
def build(values):
nodes = [ListNode(v) for v in values]
for a, b in zip(nodes, nodes[1:]):
a.next = b
return (nodes[0] if nodes else None), nodes
def collect(head):
nodes = []
while head is not None:
assert head not in nodes, "unexpected cycle"
nodes.append(head)
head = head.next
return nodes
for values, expected in [([1,2,2,1],True),([1,2,3,2,1],True),([1,2],False),([1],True),([1,2,3,1],False)]:
head, ns = build(values)
links = [node.next for node in ns]
assert Solution().isPalindrome(head) is expected
assert collect(head) == ns
assert all(node.next is nxt for node, nxt in zip(ns, links))
assert [node.val for node in ns] == values
预测练习
第一对 A 与 D 比较相等后,下一对是哪两个节点?比较结束能立刻 return True 吗?
一点提示
分别沿 A.next 和 D.next 前进,再看原解法的结构恢复约定。
答案与理由
下一对是 B(2) 与 C(2)。比较后还要把 D→C 重新反转成 C→D,并令 slow.next 指回 C,最后返回保存的 True。
面试追问与解法取舍
若输入链表由其他代码共享且要求整个过程只读,数组法更合适;恢复只保证函数返回时结构相同,并非执行期间从未修改。递归前后对照也能做,但需要 O(n) 调用栈,并存在 Python 深链递归限制,不能作为 O(1) 空间答案。
模板迁移 / 相似题
试着不看代码,复述这三点
- 偶数链的 slow 是前半段尾,后半段从 slow.next 开始。
- 反转只改 next,第二个反转恢复结构;second 仍保留第一次返回的节点身份。
- 发现非回文也先保存 False、退出比较,再执行恢复,不能提前绕过恢复。
141. 环形链表 简单
一句话题意
判断沿 next 遍历是否会再次回到已经访问过的节点,存在环返回 True。
A(3)→B(2)→C(0)→D(-4)→E(7),E.next=B → True;第 4 轮在 E 相遇。
生活化理解
操场跑道前连着一条进场通道。慢跑者每拍跑一段,快跑者每拍跑两段;若道路终止,快跑者先看到终点。若进入环形跑道,快跑者相对慢跑者每拍多追一段,迟早从后面追上。
Floyd 快慢指针
用两个引用完成常数空间判环,相对速度为一步使相遇证明直观。核心思路
slow 每轮沿 next 走一步,fast 每轮走两步。在移动前检查 fast 和 fast.next 是否存在;若其中一个为空,快指针已经触及链表终点,可以判定无环。二者初始化时都在 head,这种相等不算判环证据,所以代码先移动,再用 slow is fast 比较对象身份。
如果存在环,两指针最终都会进入环。设环长为 L,进入环后 fast 相对 slow 每轮多走一步,因此它们的相对位置每轮改变 1(模 L),至多再经过 L 轮便会重合。这个过程不需要知道环长,也不需要修改任何节点作为标记;即使节点值相同,只有指向同一对象才算相遇。
图中环为 B→C→D→E→B,两指针依次位于 B/C、C/E、D/C,最后在 E 相遇。E 不是入口 B,但本题只问是否有环,移动后的这次重合已足以返回 True。如果循环因空引用而结束,则返回 False;空链也由同一循环条件自然覆盖。
每轮 slow 前进一步,fast 前进两步,移动后才比较对象身份。若无环,fast 会到空引用;若有环,两者进入环后相对距离每轮改变一格,最终必然相遇。
图解
- slow
- A
- fast
- A
- 完成轮数
- 0
- 环
- B→C→D→E→B
- slow
- B
- fast
- C
- 完成轮数
- 1
- 环
- B→C→D→E→B
- slow
- C
- fast
- E
- 完成轮数
- 2
- 环
- B→C→D→E→B
- slow
- D
- fast
- C
- 完成轮数
- 3
- 环
- B→C→D→E→B
- slow
- E
- fast
- E
- 完成轮数
- 4
- 环
- B→C→D→E→B
- slow
- E
- fast
- E
- 完成轮数
- 4
- 环
- B→C→D→E→B
选择步骤 6 步
变量与图例
观察 fast 如何跨过 E→B 回边;两个指针改变位置,链表本身的边始终不变。
- slow
- 每轮沿 next 前进一个节点。
- fast
- 每轮沿 next 前进两个节点。
- slow is fast
- 移动后判断两指针是否为同一个节点对象。
- 环入口 B
- 输入结构的第一处环节点,与相遇点 E 不同。
算法步骤
- 令 slow=fast=head,不在初始化位置直接判定相遇。
- 只要 fast 与 fast.next 都非空,就让 slow 前进一步、fast 前进两步。
- 移动完成后比较身份,若 slow is fast 则返回 True。
- 若循环因快指针遇到终点而结束,返回 False。
代码(Python)
查看完整 Python 代码Floyd 快慢指针 · 与上方图解对应
fast 探路判断是否存在终点,slow 与 fast 的环内相对速度保证相遇。身份比较用 is。
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
class Solution:
def hasCycle(self, head):
slow = fast = head
while fast is not None and fast.next is not None:
slow = slow.next
fast = fast.next.next
if slow is fast:
return True
return False
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
def build(values):
nodes = [ListNode(v) for v in values]
for a, b in zip(nodes, nodes[1:]):
a.next = b
return (nodes[0] if nodes else None), nodes
def collect(head):
nodes = []
while head is not None:
assert head not in nodes, "unexpected cycle"
nodes.append(head)
head = head.next
return nodes
head, ns = build([3,2,0,-4])
ns[-1].next = ns[1]
links = [node.next for node in ns]
assert Solution().hasCycle(head) is True
assert all(node.next is nxt for node, nxt in zip(ns, links))
plain, _ = build([1,1,1])
assert Solution().hasCycle(plain) is False
single = ListNode(9); single.next = single
assert Solution().hasCycle(single) is True
assert Solution().hasCycle(ListNode(9)) is False
assert Solution().hasCycle(None) is False
复杂度
- 时间
- O(n):n 为从头可达的不同节点数,入环与追赶都线性。
- 空间
- O(1) 额外空间。
易错点与边界
- 不能以 val 相等判定相遇,测试中多个值为 1 的不同节点仍可无环。
- fast.next.next 之前必须让循环条件保证 fast.next 存在。
- 先移动再比较,避免把初始 slow=fast 错当有环。
- 相遇点不一定是入口;单节点自环也是环,单节点指向 None 则不是。
主解之后,再比较与迁移。
其他解法 1
记录访问过的节点时间 O(n) 期望时间:n 为不同可达节点数,集合操作期望 O(1)。
seen 表示沿 next 路线此前经过的节点对象。每次访问前先判断当前对象是否已出现,若再次出现就说明路线回到了旧节点,存在环;若走到 None 则无环。应保存对象而非 val,因为不同节点可以拥有同一个值。这种写法以保存访问记录的空间换取直接判断,也容易扩展成返回第一个重复对象。
- 初始化空集合 seen,从 head 开始沿 next 遍历。
- 若当前节点已经属于 seen,立即返回 True。
- 否则将当前节点加入 seen,再前进一步。
- 遍历到 None 时返回 False。
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
class Solution:
def hasCycle(self, head):
seen = set()
while head is not None:
if head in seen:
return True
seen.add(head)
head = head.next
return False
复杂度
- 时间
- O(n) 期望时间:n 为不同可达节点数,集合操作期望 O(1)。
- 空间
- O(n) 额外空间,存储已访问对象引用。
这个方法的边界
- seen 保存节点身份,不保存节点值;多个值相同的不同节点仍可能组成无环链。
- 每次先检查是否已在 seen,再加入当前节点;如果先加入再检查,就会把首次访问误判成重复。
- 本方法只沿 head.next 一步步访问,遇到 None 就返回 False,不使用快慢指针的相遇位置或速度条件。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
def build(values):
nodes = [ListNode(v) for v in values]
for a, b in zip(nodes, nodes[1:]):
a.next = b
return (nodes[0] if nodes else None), nodes
def collect(head):
nodes = []
while head is not None:
assert head not in nodes, "unexpected cycle"
nodes.append(head)
head = head.next
return nodes
head, ns = build([3,2,0,-4])
ns[-1].next = ns[1]
links = [node.next for node in ns]
assert Solution().hasCycle(head) is True
assert all(node.next is nxt for node, nxt in zip(ns, links))
plain, _ = build([1,1,1])
assert Solution().hasCycle(plain) is False
single = ListNode(9); single.next = single
assert Solution().hasCycle(single) is True
assert Solution().hasCycle(ListNode(9)) is False
assert Solution().hasCycle(None) is False
预测练习
slow=C、fast=E 时,下一轮 fast 会直接到 B 吗?此轮是否相遇?
一点提示
fast 每轮走两条 next 边。
答案与理由
fast 会走 E→B→C,slow 则走 C→D,因此在 D、C,还没有相遇;再一轮二者才同到 E。
面试追问与解法取舍
为什么不把访问过的节点 next 设为 None 来标记?那会破坏输入结构,而且使后续判断依据改变。若只需要布尔结果,快慢指针省空间;若还要入口,集合里第一个重复节点就是入口,或者接上第 142 题的第二阶段。
模板迁移 / 相似题
试着不看代码,复述这三点
- 先移动再比较,初始 slow=fast 不证明有环。
- 使用对象身份判断相遇,不比较节点值。
- Floyd 判环不改链表;相遇点不必是入口。
142. 环形链表 II 中等
一句话题意
返回链表开始入环的那个节点对象;无环返回 None,并保持输入连接不变。
A(3)→B(2)→C(0)→D(4)→E(7)→F(6),F.next=C → 返回入口节点 C。阶段一在 E 相遇,阶段二在 C 相遇。
生活化理解
先让快慢跑者在环形跑道上会合,确认确实有环。随后请一名向导从通道起点出发,另一名从会合点出发,两人都每拍走一步;通道的长度刚好对应会合点到入口的环上剩余距离,最终会在入口碰面。
Floyd 判环后同步定位
在快慢指针判环基础上增加同速阶段,以常数额外空间定位具体入口对象。核心思路
第一阶段让 slow 每轮走一步、fast 每轮走两步,并在移动后比较身份。快指针遇到 None 就返回 None;如果相遇,只能先确认存在环,不能立即把会合节点当作入口。图中第一阶段在 E 相遇,真正入口却是 C。
设头到入口距离为 a,环长为 L,slow 相遇前走了 t 步,fast 走了 2t 步。二者在环内同一点,路程差 t 必为环长的整数倍,即 t≡0(mod L)。slow 相对入口的位置为 (t−a) mod L,所以从相遇点再走 a 步必回到入口;这说的是模环长的关系,a 大时可能包含整圈,不能一概理解为两段最短距离恰好相等。
第二阶段设 finder=head,slow 留在相遇点,二者改为每轮各走一步;fast 不再参与移动。经过 a 步,finder 首次到入口,slow 也到入口;此前 finder 还在环外前缀,不可能与环内 slow 指向同一对象,所以第一次相等的位置恰是入口。若 a=0,第一阶段相遇点就是头,内层循环无需移动,直接返回该对象。
第一阶段相遇时,fast 比 slow 多走的距离是环长的整数倍,因此“头到入口”的距离与“相遇点沿环到入口”的距离模环长相等。把 finder 放回头并与 slow 同速前进,两者会在入口首次相遇;fast 在此阶段保持不动。
图解
- 阶段
- 判环
- slow
- A
- fast
- A
- 阶段
- 判环
- slow
- B
- fast
- C
- 阶段
- 判环
- slow
- C
- fast
- E
- 阶段
- 判环
- slow
- D
- fast
- C
- 阶段
- 判环相遇
- slow
- E
- fast
- E
- 相遇点
- E
- 阶段
- 定位入口
- finder
- A
- slow
- E
- fast
- E(停留)
- 阶段
- 定位入口
- finder
- B
- slow
- F
- fast
- E(停留)
- 阶段
- 定位入口
- finder
- C
- slow
- C
- fast
- E(停留)
- 阶段
- 完成
- 返回
- C(0)
- 相遇点
- E
- 入口
- C
选择步骤 9 步
变量与图例
先看快慢追赶在哪里相遇,再看 finder 从头、slow 从相遇点同速走;这两个位置不要混为一谈。
- slow / fast
- 阶段一分别走一步、两步;相遇于 E。
- finder
- 阶段二新设在 head=A,每轮走一步。
- 阶段二 slow
- 从 E 出发,每轮走一步,与 finder 同速。
- 入口 / 相遇点
- 本例入口 C、首次追赶相遇点 E,是两个不同对象。
算法步骤
- 从 head 启动快慢指针,在每轮移动后检查是否相遇;遇到链表终点则返回 None。
- 相遇时保留 slow 的位置,创建 finder=head;此后不再移动 fast。
- 只要 finder 与 slow 不是同一对象,便让二者各沿 next 前进一步。
- 身份相等时返回 finder,这个节点是环入口而非第一阶段的一般会合点。
代码(Python)
查看完整 Python 代码Floyd 判环后同步定位 · 与上方图解对应
先用二倍速度找会合点,再把 finder 放回头节点,以相同速度前进。代码返回具体对象,完全不改写 next。
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
class Solution:
def detectCycle(self, head):
slow = fast = head
while fast is not None and fast.next is not None:
slow = slow.next
fast = fast.next.next
if slow is fast:
finder = head
while finder is not slow:
finder = finder.next
slow = slow.next
return finder
return None
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
def build(values):
nodes = [ListNode(v) for v in values]
for a, b in zip(nodes, nodes[1:]):
a.next = b
return (nodes[0] if nodes else None), nodes
def collect(head):
nodes = []
while head is not None:
assert head not in nodes, "unexpected cycle"
nodes.append(head)
head = head.next
return nodes
head, ns = build([3,2,0,-4])
ns[-1].next = ns[1]
links = [node.next for node in ns]
assert Solution().detectCycle(head) is ns[1]
assert all(node.next is nxt for node, nxt in zip(ns, links))
plain, _ = build([1,1,1])
assert Solution().detectCycle(plain) is None
single = ListNode(9); single.next = single
assert Solution().detectCycle(single) is single
assert Solution().detectCycle(ListNode(9)) is None
assert Solution().detectCycle(None) is None
复杂度
- 时间
- O(n):n 为不同可达节点数,判环与第二阶段均为线性。
- 空间
- O(1) 额外空间。
易错点与边界
- 返回节点对象,不返回 val、布尔值或构造一个相同值的新节点。
- 第一阶段会合点一般不是入口,必须继续第二阶段。
- 第二阶段两个指针都走一步,不能让其中一个继续走两步。
- 入口可能就是 head,自环时答案也是该节点。
主解之后,再比较与迁移。
其他解法 1
节点集合找首次重复时间 O(n) 期望时间:每个不同节点最多插入集合一次。
沿唯一的 next 路线前进,用 seen 记录节点身份。进入环前不会重访任何对象;进入后绕环一周,第一次再次访问的恰好是入口。因此与只返回布尔值的集合判环不同,这里在检测重复时直接返回当前节点。它无需距离推导,但需要为所有已访问节点保存集合记录,重复数值并不影响判断。
- 初始化空集合 seen,从 head 开始读取节点。
- 若当前节点已在 seen 中,返回当前节点对象,它就是路线中首次重复的入口。
- 否则记录该节点并沿 next 前进。
- 若先遇到 None,说明没有环,返回 None。
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
class Solution:
def detectCycle(self, head):
seen = set()
while head is not None:
if head in seen:
return head
seen.add(head)
head = head.next
return None
复杂度
- 时间
- O(n) 期望时间:每个不同节点最多插入集合一次。
- 空间
- O(n) 额外空间。
这个方法的边界
- 检测到重复时返回当前节点对象,而不是它的 val,也不是新建的同值节点。
- 必须先判断当前节点是否已记录,再把首次出现的节点加入 seen。
- 沿唯一 next 路线第一次重复的对象已经是入口,可以直接返回,不需要 Floyd 的第二阶段。
- 入口在 head 或仅有一个自环节点时,绕回后仍返回该原对象。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
def build(values):
nodes = [ListNode(v) for v in values]
for a, b in zip(nodes, nodes[1:]):
a.next = b
return (nodes[0] if nodes else None), nodes
def collect(head):
nodes = []
while head is not None:
assert head not in nodes, "unexpected cycle"
nodes.append(head)
head = head.next
return nodes
head, ns = build([3,2,0,-4])
ns[-1].next = ns[1]
links = [node.next for node in ns]
assert Solution().detectCycle(head) is ns[1]
assert all(node.next is nxt for node, nxt in zip(ns, links))
plain, _ = build([1,1,1])
assert Solution().detectCycle(plain) is None
single = ListNode(9); single.next = single
assert Solution().detectCycle(single) is single
assert Solution().detectCycle(ListNode(9)) is None
assert Solution().detectCycle(None) is None
预测练习
第一阶段在 E 相遇后,把 finder 放在 A,下一轮 slow 应走到 C 还是 F?
一点提示
第二阶段已经改为同速,每次只跨一条边。
答案与理由
slow 应到 F,finder 到 B;再各走一步才一起到 C。若 slow 继续走两步到 C,就破坏了入口对齐。
面试追问与解法取舍
如何求环长?首次会合后固定一个指针,让另一个指针沿 next 绕到它,计步得到 L;再从头放两个指针,让一个领先 L 步,然后同速前进,也会在入口相遇。这个替代定位过程仍是 O(n) 时间、O(1) 空间,但额外多绕一圈。
模板迁移 / 相似题
试着不看代码,复述这三点
- 第一次相遇只证明有环,不能直接作为入口返回。
- 第二阶段重置 finder 到头,保留 slow 在相遇点,二者改为同速。
- 用模环长的路程关系解释入口对齐,不依赖节点值。
21. 合并两个有序链表 简单
一句话题意
将两条升序链表合并为一条升序链表,通过改接原节点组成结果并返回头节点。
list1: A1(1)→A2(3)→A3(5);list2: B1(1)→B2(2)→B3(6) → A1→B1→B2→A2→A3→B3,值为 [1,1,2,3,5,6]。
生活化理解
两个检票口各排着一列已按号码排序的人,要合成一列仍按号码排序的队伍。每次只比较两队最前面的人,号码较小者去总队尾;某一队清空后,另一队可以整队接上,不必继续逐个比较。
哨兵 + 迭代归并
以两个队首挑选最小剩余节点,哨兵统一首次接入,便于逐步解释原节点的归并。核心思路
dummy 是不属于答案的哨兵,tail 指向已经选定的结果前缀末尾;list1、list2 分别指向两条链未选部分的头。每轮开始,dummy 后直到 tail 的前缀始终有序,其所有值都不大于两个剩余队首。注意“已选前缀”只到 tail 为止:tail.next 可能暂时仍连着原链后缀,不能把从 dummy 可达的全部节点都认作已经完成归并。
由于两条剩余链分别有序,所有未选节点中最小者必在两个队首之一。把较小者接到 tail.next,先移动对应输入头,再让 tail=tail.next,就把一个最小剩余节点纳入前缀,保持不变量。代码使用 ≤,相等时先选 list1 的节点。图中 A1 与 B1 同为 1,先接 A1;随后改写 A1.next 接入 B1 时,A 链剩余入口仍由 list1 保存,不会丢失。
一条链耗尽后,另一条剩余链自身有序且不小于已选尾,直接整体接到 tail.next 即可。最后这次整段连接后无需再移动 tail,返回 dummy.next 就能取得完整结果。实现复用输入节点并改写它们的连接,dummy 是唯一辅助节点;这里沿用题目两条独立输入链的前提。
每轮开始,dummy 后到 tail 的已选前缀有序,list1、list2 指向各自未选的有序后缀。两头中较小者必是所有未选节点中的最小者,把它接入并推进对应输入头可保持有序;一方耗尽后可整段接上另一方。
图解
- list1
- A1
- list2
- B1
- tail
- S
- 已选前缀
- 空
- list1
- A2
- list2
- B1
- tail
- S
- 已选前缀
- 待确认 A1
- list1
- A2
- list2
- B1
- tail
- A1
- 已选前缀
- A1
- list1
- A2
- list2
- B2
- tail
- B1
- 已选前缀
- A1→B1
- list1
- A2
- list2
- B3
- tail
- B2
- 已选前缀
- A1→B1→B2
- list1
- A3
- list2
- B3
- tail
- A2
- 已选前缀
- A1→B1→B2→A2
- list1
- None
- list2
- B3
- tail
- A3
- 已选前缀
- A1→B1→B2→A2→A3
- list1
- None
- list2
- B3
- tail
- A3
- 已选前缀
- A1→B1→B2→A2→A3→B3
- list1
- None
- list2
- B3
- tail
- A3
- 已选前缀
- A1→B1→B2→A2→A3→B3
选择步骤 9 步
变量与图例
观察 tail.next 从原链后继改接到另一条链;输入头保留未处理后缀,已归并前缀只确认到 tail。
- dummy / S
- 结果前的固定哨兵,返回 dummy.next。
- tail
- 已选前缀尾;接上剩余整段后代码不再移动它。
- list1 / list2
- 两条尚未选择的有序后缀的头。
- A1、B1 等
- 原输入节点身份;值相等时仍能区分对象。
算法步骤
- 创建 dummy,令 tail=dummy,保留 list1、list2 作为各自未选后缀的入口。
- 两条输入都非空时比较队首,将较小节点接入 tail.next;相等时按代码优先取 list1。
- 移动刚取用的输入指针,再令 tail=tail.next,确认结果前缀扩展一个节点。
- 一方耗尽后,将另一方剩余后缀整体接到 tail.next。
- 返回 dummy.next,哨兵不进入答案,所有结果数据节点沿用输入对象。
代码(Python)
查看完整 Python 代码哨兵 + 迭代归并 · 与上方图解对应
tail 管理已选前缀,两个输入指针管理剩余部分。哨兵统一首次接入和后续接入的写法,最后一次连接可以整段接上剩余链。
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
class Solution:
def mergeTwoLists(self, list1, list2):
dummy = ListNode()
tail = dummy
while list1 is not None and list2 is not None:
if list1.val <= list2.val:
tail.next = list1
list1 = list1.next
else:
tail.next = list2
list2 = list2.next
tail = tail.next
tail.next = list1 if list1 is not None else list2
return dummy.next
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
def build(values):
nodes = [ListNode(v) for v in values]
for a, b in zip(nodes, nodes[1:]):
a.next = b
return (nodes[0] if nodes else None), nodes
def collect(head):
nodes = []
while head is not None:
assert head not in nodes, "unexpected cycle"
nodes.append(head)
head = head.next
return nodes
a, aa = build([1,2,4]); b, bb = build([1,3,4])
out = Solution().mergeTwoLists(a, b)
nodes = collect(out)
assert [node.val for node in nodes] == [1,1,2,3,4,4]
assert set(nodes) == set(aa + bb) and len(nodes) == 6
assert [node for node in nodes if node in aa] == aa
assert [node for node in nodes if node in bb] == bb
assert Solution().mergeTwoLists(None, None) is None
one, ns = build([0])
assert Solution().mergeTwoLists(None, one) is one
assert collect(one) == ns
复杂度
- 时间
- O(m+n):m、n 为输入长度,最坏需逐一选择几乎全部节点。
- 空间
- O(1) 额外空间;只分配一个哨兵,结果复用 m+n 个输入节点。
易错点与边界
- 先移动被选中链的指针,再继续下一轮,不能丢掉它原本的后继。
- 结果头是 dummy.next,不是遍历后处于尾部的 tail。
- 空链可以直接接另一条;不要遗漏主循环之后的剩余链。
- 当前实现复用输入节点,会改变其 next;dummy 是唯一新增的辅助节点,不在返回链中。
主解之后,再比较与迁移。
其他解法 1
递归选择结果头时间 O(m+n):每层选择一个节点,直到其中一条链为空。
每次调用直接决定当前结果链的头:两队首中较小的节点必然最先出现,它的 next 应接到剩余部分的递归合并结果。空链时直接返回另一条链。该方法同样复用并改写原节点,但不用 dummy、tail,而靠递归返回值接回后缀;递归调用深度可能随两链总长度增长,长链需要考虑 Python 递归深度。
- 若 list1 为空则返回 list2;若 list2 为空则返回 list1。
- 当 list1.val 不大于 list2.val,递归合并 list1.next 与 list2,并把返回值赋给 list1.next。
- 否则递归合并 list1 与 list2.next,把返回值赋给 list2.next。
- 返回本层选中的较小队首,作为当前整段合并结果的头。
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
class Solution:
def mergeTwoLists(self, list1, list2):
if list1 is None:
return list2
if list2 is None:
return list1
if list1.val <= list2.val:
list1.next = self.mergeTwoLists(list1.next, list2)
return list1
list2.next = self.mergeTwoLists(list1, list2.next)
return list2
复杂度
- 时间
- O(m+n):每层选择一个节点,直到其中一条链为空。
- 空间
- O(m+n) 最坏递归栈,不新建结果节点;Python 深链可能触发递归深度限制。
这个方法的边界
- 空链基例直接返回另一条链,不能返回 None 而丢掉其剩余节点。
- 返回本层选中的 list1 或 list2,不使用迭代方案的 dummy、tail;递归结果应赋给所选队首的 next。
- 本方法也复用并改写输入连接;不是通过复制节点来保留原链。
- 递归调用深度随输入链长增长,长链可能超过 Python 默认递归深度。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
def build(values):
nodes = [ListNode(v) for v in values]
for a, b in zip(nodes, nodes[1:]):
a.next = b
return (nodes[0] if nodes else None), nodes
def collect(head):
nodes = []
while head is not None:
assert head not in nodes, "unexpected cycle"
nodes.append(head)
head = head.next
return nodes
a, aa = build([1,2,4]); b, bb = build([1,3,4])
out = Solution().mergeTwoLists(a, b)
nodes = collect(out)
assert [node.val for node in nodes] == [1,1,2,3,4,4]
assert set(nodes) == set(aa + bb) and len(nodes) == 6
assert [node for node in nodes if node in aa] == aa
assert [node for node in nodes if node in bb] == bb
assert Solution().mergeTwoLists(None, None) is None
one, ns = build([0])
assert Solution().mergeTwoLists(None, one) is one
assert collect(one) == ns
预测练习
A1 已选、list1=A2(3)、list2=B1(1) 时,要改哪条边?改后 A2 会丢失吗?
一点提示
tail=A1;list1 本身仍持有 A2。
答案与理由
把 A1.next 改成 B1,再推进 list2、tail。A2 仍由 list1 引用,之后还能接回,不会丢失。
面试追问与解法取舍
面试可能要求稳定性:这里相等时先选 list1,且每条输入链内部的节点相对顺序保持不变。如果需要新链与原链完全独立,每次选择最小值后创建新节点;时间仍 O(m+n),但需要 O(m+n) 输出空间。
其他思路的边界
- 复用节点意味着输入连接会改变;若要求保留原链,应另建结果节点并付出输出空间。
模板迁移 / 相似题
试着不看代码,复述这三点
- 每次从两条有序后缀的头中选更小者。
- 复用节点时 tail.next 可以暂留旧边,已选前缀只确认到 tail。
- 哨兵统一首节点接入;相等用 <= 时先取 list1。
2. 两数相加 中等
一句话题意
两条非空链表以低位在前的顺序存储非负整数,返回同样逆序存储的两数之和链表。
l1=[9,9,9] 表示 999,l2=[1] 表示 1 → 新链 R1(0)→R2(0)→R3(0)→R4(1),即 [0,0,0,1] 表示 1000。
生活化理解
把两个数竖式相加,每个链表节点是一列数位,不过个位放在最前面。tail 像记账员当前写到的位置,carry 是从上一列带来的“满十兑换券”;这一列写 total 的个位,把十位交给下一列。
逐位迭代进位
链表已按低位在前排列,逐列加法与竖式直接对应,循环即可处理不等长和连续进位。核心思路
l1、l2 指向当前输入数位,carry 保存从上一列带来的进位;dummy 提供固定输出入口,tail 指向最后一个新建结果节点。输入顺序已经是个位、十位、百位,所以从头向后恰好按竖式从低位到高位计算,无需反转或先转成整数。
每列读取 x、y,已耗尽的一方按 0 处理。执行 carry, digit = divmod(x + y + carry, 10) 时,右侧先使用旧 carry 求和,然后把商保存为下一列的进位、余数保存为当前位 digit。新建该位节点并尾插后,结果链包含全部已处理低位的正确数字,carry 则是唯一留给高位的影响;因每位在 0 到 9 之间,carry 始终为 0 或 1。
循环条件包含两个输入是否非空以及 carry 是否非零。图中 999+1 连续生成三个 0 后,l1、l2 都已为空,但 carry 仍为 1;最后再按同一规则处理 0+0+1,才输出最高位 1。只有两个输入与进位都耗尽才结束。结果节点全部新建,输入指针只向后移动,原输入节点的值与连接均保持不变。
每轮结束,结果链已经保存处理过的低位,carry 是要加到下一列的进位。当前列用两个输入位(缺失取 0)与旧 carry 求和,divmod 产生本位 digit 与新 carry,因此逐列保持十进制加法关系。
图解
- l1
- A1
- l2
- B1
- x / y
- 未读取 / 未读取
- carry
- 0
- digit
- 未计算
- tail
- S
- 已输出
- []
- l1
- A1
- l2
- B1
- x / y
- 9 / 1
- carry
- 1
- digit
- 0
- tail
- S
- 已输出
- []
- l1
- A2
- l2
- None
- x / y
- 9 / 1
- carry
- 1
- digit
- 0
- tail
- R1
- 已输出
- [0]
- l1
- A2
- l2
- None
- x / y
- 9 / 0
- carry
- 1
- digit
- 0
- tail
- R1
- 已输出
- [0]
- l1
- A3
- l2
- None
- x / y
- 9 / 0
- carry
- 1
- digit
- 0
- tail
- R2
- 已输出
- [0,0]
- l1
- A3
- l2
- None
- x / y
- 9 / 0
- carry
- 1
- digit
- 0
- tail
- R2
- 已输出
- [0,0]
- l1
- None
- l2
- None
- x / y
- 9 / 0
- carry
- 1
- digit
- 0
- tail
- R3
- 已输出
- [0,0,0]
- l1
- None
- l2
- None
- x / y
- 0 / 0
- carry
- 0
- digit
- 1
- tail
- R3
- 已输出
- [0,0,0]
- l1
- None
- l2
- None
- x / y
- 0 / 0
- carry
- 0
- digit
- 1
- tail
- R4
- 已输出
- [0,0,0,1]
选择步骤 9 步
变量与图例
每列先算 carry 与 digit,再创建一个结果节点;两输入耗尽后,剩余进位还会驱动最后一轮。
- l1 / l2
- 当前尚未处理的低位节点,耗尽后该输入位取 0。
- x / y
- 本轮从输入节点读取的位值;输入为空时为 0。
- carry
- divmod 后是传给下一位的新进位;计算右侧时读的是上一轮进位。
- digit
- 当前列和除以 10 的余数,要写进新节点。
- tail / S
- 结果尾与固定哨兵;R 节点是新建的,不复用输入。
算法步骤
- 创建 dummy,令 tail=dummy、carry=0。
- 只要 l1、l2 至少一个非空,或 carry 非零,就继续处理当前一列。
- 读取两位 x、y,缺位按 0;用 divmod(x+y+旧 carry,10) 得到新 carry 和 digit。
- 新建 ListNode(digit) 接到 tail.next,再推进 tail;仍非空的输入指针各前进一步。
- 所有输入位和进位都处理完后返回 dummy.next。
代码(Python)
查看完整 Python 代码逐位迭代进位 · 与上方图解对应
total 是当前列之和,carry、digit=divmod(total,10)。尾插法保持低位在前,结果节点与输入节点独立。
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
class Solution:
def addTwoNumbers(self, l1, l2):
dummy = ListNode()
tail = dummy
carry = 0
while l1 is not None or l2 is not None or carry:
x = l1.val if l1 is not None else 0
y = l2.val if l2 is not None else 0
carry, digit = divmod(x + y + carry, 10)
tail.next = ListNode(digit)
tail = tail.next
if l1 is not None:
l1 = l1.next
if l2 is not None:
l2 = l2.next
return dummy.next
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
def build(values):
nodes = [ListNode(v) for v in values]
for a, b in zip(nodes, nodes[1:]):
a.next = b
return (nodes[0] if nodes else None), nodes
def collect(head):
nodes = []
while head is not None:
assert head not in nodes, "unexpected cycle"
nodes.append(head)
head = head.next
return nodes
for left, right, expected in [([2,4,3],[5,6,4],[7,0,8]),([9,9,9],[1],[0,0,0,1]),([0],[0],[0]),([1,8],[0],[1,8])]:
a, aa = build(left); b, bb = build(right)
links = [(node, node.next) for node in aa + bb]
out = Solution().addTwoNumbers(a, b)
nodes = collect(out)
assert [node.val for node in nodes] == expected
assert not set(nodes).intersection(aa + bb)
assert all(node.next is nxt for node, nxt in links)
assert [node.val for node in aa] == left
assert [node.val for node in bb] == right
复杂度
- 时间
- O(max(m,n)):m、n 为输入节点数,最多额外处理一个进位节点。
- 空间
- O(1) 辅助空间;输出链占 O(max(m,n)),应单独计入。
易错点与边界
- 链表是逆序存储,2→4→3 代表 342,输出也必须低位在前。
- 循环要用 or,不能要求两条链同时非空。
- 最高位的最后进位不能漏掉,例如 9+1 应得到 0→1。
- 合法输入用单个 0 节点表示零,不是空链。
主解之后,再比较与迁移。
其他解法 1
递归传递进位时间 O(max(m,n)):每层计算一位。
add(a,b,carry) 的契约是返回从当前数位开始、已经包含传入进位的结果链。每层读取当前两位,用 next_carry 与 digit 区分传向高位的进位和本层数字;创建本位节点,再把高位递归返回的链接到 node.next。它同样只读输入并新建输出,差别在于进位通过参数传递、未完成的连接保存在调用栈中;调用深度随较长输入的位数增长。
- 以 add(l1,l2,0) 启动递归;只有两输入都空且 carry 为 0 时返回 None。
- 读取当前 x、y,缺位取 0,计算 next_carry、digit=divmod(x+y+carry,10)。
- 创建保存 digit 的 node,对两输入的下一位和 next_carry 发起递归。
- 将递归结果接到 node.next,并返回 node 作为本层结果头。
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
class Solution:
def addTwoNumbers(self, l1, l2):
def add(a, b, carry):
if a is None and b is None and carry == 0:
return None
x = a.val if a is not None else 0
y = b.val if b is not None else 0
next_carry, digit = divmod(x + y + carry, 10)
node = ListNode(digit)
node.next = add(a.next if a is not None else None,
b.next if b is not None else None,
next_carry)
return node
return add(l1, l2, 0)
复杂度
- 时间
- O(max(m,n)):每层计算一位。
- 空间
- O(max(m,n)) 递归栈,另有 O(max(m,n)) 输出节点;超长链应使用迭代以避开 Python 递归深度限制。
这个方法的边界
- 递归只在 a、b 都为空且 carry==0 时结束;缺少 carry 条件会漏掉最高位。
- 一条输入先耗尽时将其数位按 0 处理,并把下一指针继续传为 None,不要访问 None.next。
- 当前 node 保存低位 digit,node.next 接高位递归结果,不能颠倒低位在前的输出顺序。
- 本方法新建输出节点而不复用输入节点;递归深度随位数增长,长输入需考虑 Python 递归深度限制。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
def build(values):
nodes = [ListNode(v) for v in values]
for a, b in zip(nodes, nodes[1:]):
a.next = b
return (nodes[0] if nodes else None), nodes
def collect(head):
nodes = []
while head is not None:
assert head not in nodes, "unexpected cycle"
nodes.append(head)
head = head.next
return nodes
for left, right, expected in [([2,4,3],[5,6,4],[7,0,8]),([9,9,9],[1],[0,0,0,1]),([0],[0],[0]),([1,8],[0],[1,8])]:
a, aa = build(left); b, bb = build(right)
links = [(node, node.next) for node in aa + bb]
out = Solution().addTwoNumbers(a, b)
nodes = collect(out)
assert [node.val for node in nodes] == expected
assert not set(nodes).intersection(aa + bb)
assert all(node.next is nxt for node, nxt in links)
assert [node.val for node in aa] == left
assert [node.val for node in bb] == right
预测练习
已经输出 [0,0,0],l1=l2=None、carry=1,下一轮会再读输入节点吗?输出什么?
一点提示
看 while 的 or carry 与缺失位的 0。
答案与理由
不访问空节点的 val,而是取 x=y=0。0+0+1 得 digit=1、carry=0,创建最后的 R4(1),结果 [0,0,0,1]。
面试追问与解法取舍
为什么不先把链表转换成 Python 整数?虽然 Python 支持大整数,但转换和运算成本随位数增长,面试核心是显式处理数位与进位。若改为高位在前,可以先将数位压入两个栈,再从栈顶取低位相加并头插结果;需要 O(m+n) 辅助空间。
模板迁移 / 相似题
试着不看代码,复述这三点
- 低位在前让链表顺序与竖式加法方向一致。
- divmod 同时给出下一位进位与本位数字,别混用新旧 carry。
- 循环条件包含进位,输入长度不同和最终溢出用同一循环处理。
19. 删除链表的倒数第 N 个结点 中等
一句话题意
从链表中移除倒数第 n 个节点并返回新头;题目保证 n 在链表长度范围内。
A(1)→B(2)→C(3)→D(4)→E(5),n=2 → A→B→C→E,值为 [1,2,3,5];仅 C.next 从 D 改为 E。
生活化理解
像两名列车检查员保持固定车厢间隔前进。前面的检查员比后面领先 n 节;当前面到最后一节时,后面的恰好站在要摘下车厢的前一节。哨兵相当于车头前额外挂的站台,连第一节车厢也能统一处理。
固定间距双指针
利用固定间隔直接定位目标前驱,哨兵使目标在头部时仍使用同一改接步骤。核心思路
建立 dummy.next=head,fast、slow 都从 dummy 出发,先只让 fast 前进 n 步。此后两者同步前进,始终相隔 n 条 next 边。图中 n=2,fast 先从哨兵 S 到 B,再与 slow 同速移动,直到分别位于尾节点 E 与节点 C。
同步循环检查的是 fast.next 非空,所以 fast 最终停在尾节点,不能走到尾后的 None。设实际链长为 L,并把 dummy 记作第 0 个位置,此时 fast 位于 L,slow 位于 L−n,因此 slow.next 正是从头数第 L−n+1 个、也就是倒数第 n 个节点。必须把起始间隔与这个停止条件配套理解,不能只记“快指针领先 n 步”。
单链表不能从目标倒退,故需要定位它的前驱 slow,再令 slow.next=slow.next.next,使返回链跳过目标。若 n=L,slow 就留在 dummy,改接 dummy.next 自然覆盖原头的情况。保留节点的身份和相对顺序不变;被跳过的 Python 对象并未被销毁,其原 next 也未改写,若仍有外部引用就仍可访问。代码沿用题目保证 n 有效的输入范围。
fast 先从哨兵领先 n 条边,同步阶段这段距离保持不变。以 fast.next 为空为停止条件,fast 停在尾节点,slow 就落在倒数第 n 个节点的前驱;随后只需改写 slow.next。
图解
- slow
- S
- fast
- S
- n
- 2
- 间距
- 0
- slow.next
- A
- slow
- S
- fast
- A
- n
- 2
- 间距
- 1
- slow.next
- A
- slow
- S
- fast
- B
- n
- 2
- 间距
- 2
- slow.next
- A
- slow
- A
- fast
- C
- n
- 2
- 间距
- 2
- slow.next
- B
- slow
- B
- fast
- D
- n
- 2
- 间距
- 2
- slow.next
- C
- slow
- C
- fast
- E
- n
- 2
- 间距
- 2
- slow.next
- D
- slow
- C
- fast
- E
- n
- 2
- 间距
- 改接前为 2
- slow.next
- E
- slow
- C
- fast
- E
- n
- 2
- 间距
- 定位阶段已结束
- slow.next
- E
选择步骤 8 步
变量与图例
先追踪 fast 与 slow 之间的两条边,再看 fast 停在尾节点时,slow 为什么正好是 D 的前驱。
- dummy / S
- 指向原头的哨兵,让原头也有可修改的前驱。
- slow
- 定位结束时指向待移除节点的前驱 C。
- fast
- 先领先 n 步,然后同步到尾 E。
- n
- 倒数位置 2,也是同步前建立的领先边数。
算法步骤
- 创建 dummy=ListNode(0,head),令 slow=fast=dummy。
- 仅让 fast 前进 n 步,建立 n 条 next 边的间距。
- 只要 fast.next 非空,就让 slow、fast 各前进一步,直到 fast 停在尾节点。
- 把 slow.next 改为 slow.next.next,跳过目标节点。
- 返回 dummy.next,使原头被跳过的情况也得到正确结果头。
代码(Python)
查看完整 Python 代码固定间距双指针 · 与上方图解对应
fast 先走 n 步,同步到 fast 位于尾节点,slow 就是目标前驱。哨兵统一处理移除头节点。
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
class Solution:
def removeNthFromEnd(self, head, n):
dummy = ListNode(0, head)
slow = fast = dummy
for _ in range(n):
fast = fast.next
while fast.next is not None:
slow = slow.next
fast = fast.next
slow.next = slow.next.next
return dummy.next
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
def build(values):
nodes = [ListNode(v) for v in values]
for a, b in zip(nodes, nodes[1:]):
a.next = b
return (nodes[0] if nodes else None), nodes
def collect(head):
nodes = []
while head is not None:
assert head not in nodes, "unexpected cycle"
nodes.append(head)
head = head.next
return nodes
head, ns = build([1,2,3,4,5])
out = Solution().removeNthFromEnd(head, 2)
assert collect(out) == [ns[0],ns[1],ns[2],ns[4]]
assert ns[2].next is ns[4]
head, ns = build([1])
assert Solution().removeNthFromEnd(head, 1) is None
head, ns = build([1,2])
assert Solution().removeNthFromEnd(head, 2) is ns[1]
head, ns = build([1,2,3])
out = Solution().removeNthFromEnd(head, 1)
assert collect(out) == ns[:2] and ns[1].next is None
复杂度
- 时间
- O(L):L 为链表长度,fast 总共走 L 步,slow 至多走 L−n 步。
- 空间
- O(1) 额外空间。
易错点与边界
- 领先 n 步配合 fast.next 判尾;若改成 fast 判空,前驱位置也要重新推导。
- 返回 dummy.next,原头可能已经被跳过。
- n=1 移除尾节点;n=L 移除头节点;L=1 时结果为空。
- 仅让局部变量指向下一个节点不会改变链表,必须改前驱的 next。
主解之后,再比较与迁移。
其他解法 1
两次遍历:先数长度时间 O(L):第一次 L 步,第二次至多 L−1 步。
先用 node 计算 length=L,再把倒数排名转换为正向位置:目标是第 L−n+1 个节点,前驱是第 L−n 个位置。第二趟从第 0 个位置 dummy 出发走 length−n 步,prev 就是目标前驱。它不维持两个指针的动态间隔,推导更直接,但需要重新从头遍历一段;最终同样只改前驱的 next,并复用保留下来的节点。
- 从 head 开始用 node 遍历链表,逐个累加 length。
- 创建指向 head 的 dummy,令 prev=dummy。
- 让 prev 前进 length−n 步,停在目标之前。
- 令 prev.next=prev.next.next,返回 dummy.next。
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
class Solution:
def removeNthFromEnd(self, head, n):
length = 0
node = head
while node is not None:
length += 1
node = node.next
dummy = ListNode(0, head)
prev = dummy
for _ in range(length - n):
prev = prev.next
prev.next = prev.next.next
return dummy.next
复杂度
- 时间
- O(L):第一次 L 步,第二次至多 L−1 步。
- 空间
- O(1) 额外空间,只创建一个哨兵。
这个方法的边界
- 从 dummy 出发应走 length−n 步定位前驱,不是走到目标位置 length−n+1。
- 改写 prev.next 才能改变返回链,只移动 prev 这个局部引用不能跳过目标。
- 返回 dummy.next,尤其 n=length 时原头已经被跳过;单节点输入移除后返回 None。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
def build(values):
nodes = [ListNode(v) for v in values]
for a, b in zip(nodes, nodes[1:]):
a.next = b
return (nodes[0] if nodes else None), nodes
def collect(head):
nodes = []
while head is not None:
assert head not in nodes, "unexpected cycle"
nodes.append(head)
head = head.next
return nodes
head, ns = build([1,2,3,4,5])
out = Solution().removeNthFromEnd(head, 2)
assert collect(out) == [ns[0],ns[1],ns[2],ns[4]]
assert ns[2].next is ns[4]
head, ns = build([1])
assert Solution().removeNthFromEnd(head, 1) is None
head, ns = build([1,2])
assert Solution().removeNthFromEnd(head, 2) is ns[1]
head, ns = build([1,2,3])
out = Solution().removeNthFromEnd(head, 1)
assert collect(out) == ns[:2] and ns[1].next is None
预测练习
fast=E、slow=C 后,如果再同步走一次到 fast=None,会改错哪一个节点?
一点提示
代码的停止条件是 fast.next,不是 fast。
答案与理由
slow 会从 C 到 D,之后改 slow.next 就会跳过 E,错误移除尾节点。领先 n 步必须与 fast.next 判尾这一停止位置配套。
面试追问与解法取舍
“一次遍历”通常指不先计算长度再重走链表;双指针方案虽然有两个指针,总工作量仍为 O(L)。若只给待移除节点而不给头,常见复制后继值技巧不适用于尾节点,也改变节点值语义,与本题保留节点身份的前驱改接不同。
模板迁移 / 相似题
试着不看代码,复述这三点
- “领先几步”与“停在尾还是尾后”要一起推导。
- 要改目标前驱的 next,不能只移动局部变量。
- 哨兵统一移除原头;返回 dummy.next 才能拿到正确新头。
24. 两两交换链表中的节点 中等
一句话题意
将链表相邻节点每两个一组交换,交换节点连接而非节点值;最后不足两个的节点保持原位。
A(1)→B(2)→C(3)→D(4)→E(5) → B(2)→A(1)→D(4)→C(3)→E(5),值为 [2,1,4,3,5]。
生活化理解
像把排队的人两两互换位置。队伍前面的领队 prev 要记住这一组的两人 a、b,以及后面的队伍入口;先让 a 接上后一组,再让 b 站到 a 前面,最后让领队指向 b。换的是人本身,胸牌上的数字不改。
哨兵 + 局部三次改接
固定一组两个节点后逐条改接,操作数量固定,容易按图讲清且只需常数额外空间。核心思路
令 dummy.next=head,prev 指向已经交换完前缀的尾节点;第一组前面用 dummy 充当这个前驱。每轮开始,prev 后面仍是原顺序的未处理后缀。只有 prev.next 和 prev.next.next 都存在,才保存 a=prev.next、b=a.next,准备把 prev→a→b→rest 改为 prev→b→a→rest。
三条连接依次改写:先令 a.next=b.next,把 a 接到原后缀 rest;再令 b.next=a,组成 b→a→rest;最后令 prev.next=b,将新组头接回前缀。图中第一条改接后 B 暂时不再从哨兵可达,但局部变量 b 仍指向它;变量 a、b 保存的是固定节点对象,不会随 next 改写而自动移动。只有第三次改接完成,当前组才完整接回整条链。
完成一组后,原来的 a 成为新组尾,因此令 prev=a,下一轮从它后面继续,既不遗漏也不重复交换。若最后剩一个节点,循环条件不成立,它保持原位。全过程不交换 val,只改节点连接;结果头可能改变,必须返回 dummy.next 才能访问完整交换结果。
每轮开始,prev 是已经交换完成前缀的尾,其后仍是未处理的原序后缀。固定 a、b 后,依次改 a.next、b.next、prev.next 将 prev→a→b→rest 变成 prev→b→a→rest,再令 prev=a,保持已处理前缀完整且各组不重叠。
图解
- prev
- S
- a / b
- 尚未赋值
- 结果头
- A
- prev
- S
- a
- A
- b
- B
- 后缀
- C→D→E
- prev
- S
- a
- A
- b
- B
- A.next
- C
- prev
- S
- a
- A
- b
- B
- 局部
- B→A→C
- prev
- S
- a
- A
- b
- B
- 从 S 可达
- S→B→A→C→D→E
- prev
- A
- a
- C
- b
- D
- 后缀
- E
- prev
- A
- a
- C
- b
- D
- C.next
- E
- prev
- A
- a
- C
- b
- D
- 局部
- D→C→E
- prev
- A
- a
- C
- b
- D
- 从 S 可达
- S→B→A→D→C→E
- prev
- C
- prev.next
- E
- prev.next.next
- None
- 结果
- B→A→D→C→E
选择步骤 10 步
变量与图例
每组依次看三条箭头:a 接后缀、b 接 a、prev 接 b;最后把 prev 移到这一组的新尾 a。
- dummy / S
- 第一组之前的哨兵,最终返回它的 next。
- prev
- 本组前驱,也是已处理前缀尾;完成后移到旧 a。
- a
- 本组原第一个节点,交换后成为组尾。
- b
- 本组原第二个节点,交换后成为组头。
- rest / 后缀
- 改接前的 b.next,本例先为 C、再为 E;原代码直接读 b.next。
算法步骤
- 创建指向 head 的 dummy,令 prev=dummy,表示第一组的前驱。
- 确认 prev 后至少有两个节点,固定 a=prev.next、b=a.next。
- 先执行 a.next=b.next 保留原后缀,再执行 b.next=a 完成组内反向连接。
- 执行 prev.next=b,把新组头接回前缀;随后令 prev=a,准备处理下一组。
- 不足两个节点时停止,返回 dummy.next,末尾单节点保持不变。
代码(Python)
查看完整 Python 代码哨兵 + 局部三次改接 · 与上方图解对应
prev 维护组前驱,a、b 固定本组节点对象。先保存通往后缀的连接,再反向连接本组,最后把组前驱接到新头。
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
class Solution:
def swapPairs(self, head):
dummy = ListNode(0, head)
prev = dummy
while prev.next is not None and prev.next.next is not None:
a = prev.next
b = a.next
a.next = b.next
b.next = a
prev.next = b
prev = a
return dummy.next
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
def build(values):
nodes = [ListNode(v) for v in values]
for a, b in zip(nodes, nodes[1:]):
a.next = b
return (nodes[0] if nodes else None), nodes
def collect(head):
nodes = []
while head is not None:
assert head not in nodes, "unexpected cycle"
nodes.append(head)
head = head.next
return nodes
head, ns = build([1,2,3,4])
out = Solution().swapPairs(head)
assert collect(out) == [ns[1],ns[0],ns[3],ns[2]]
assert [node.val for node in ns] == [1,2,3,4]
head, ns = build([1,2,3])
assert collect(Solution().swapPairs(head)) == [ns[1],ns[0],ns[2]]
assert ns[2].next is None
head, ns = build([7])
assert Solution().swapPairs(head) is head
assert Solution().swapPairs(None) is None
复杂度
- 时间
- O(n):每轮处理两个节点,总共 floor(n/2) 轮。
- 空间
- O(1) 额外空间,结果复用输入节点。
易错点与边界
- 不能只交换 val,题目要求移动节点,身份顺序也必须变化。
- a.next 要先指向后缀,再改 b.next,否则容易丢失后续链或成环。
- 完成一组后 prev=a;若停在 b,下一轮会错误地重叠处理。
- 奇数长度最后一个节点不动,空链与单节点不需要交换。
主解之后,再比较与迁移。
其他解法 1
递归交换后缀时间 O(n):每层处理两个节点。
swapPairs(head) 返回当前整段两两交换后的新头。保存 second=head.next,先递归处理 second.next 后面的剩余节点,让 head.next 接到返回的已交换后缀,再令 second.next=head,组成 second→head→交换后后缀。它把“已处理前缀的连接”交给递归返回值管理,不使用 prev 哨兵循环;每次递归消费两个节点,调用栈随节点对数增长,长链需考虑 Python 递归深度。
- 若 head 为空或没有后继,当前段无需交换,直接返回 head。
- 保存 second=head.next,递归交换 second.next 开始的后缀。
- 将递归返回的新后缀头赋给 head.next。
- 令 second.next=head,返回 second 作为本段的新头。
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
class Solution:
def swapPairs(self, head):
if head is None or head.next is None:
return head
second = head.next
head.next = self.swapPairs(second.next)
second.next = head
return second
复杂度
- 时间
- O(n):每层处理两个节点。
- 空间
- O(n) 递归栈,深度约 n/2;没有新结果节点,长链应优先迭代。
这个方法的边界
- 递归每次消费两个节点,先保存 second=head.next,再处理 second.next 开始的后缀。
- 先把 head.next 接到递归返回的已交换后缀,再令 second.next=head;漏掉前一步会丢失正确的后缀连接。
- 返回 second 作为本段的新头,不是旧 head;节点值不应被交换。
- 空链或单节点直接返回原头,因此奇数长度末节点保持不动;长链需考虑递归深度。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
def build(values):
nodes = [ListNode(v) for v in values]
for a, b in zip(nodes, nodes[1:]):
a.next = b
return (nodes[0] if nodes else None), nodes
def collect(head):
nodes = []
while head is not None:
assert head not in nodes, "unexpected cycle"
nodes.append(head)
head = head.next
return nodes
head, ns = build([1,2,3,4])
out = Solution().swapPairs(head)
assert collect(out) == [ns[1],ns[0],ns[3],ns[2]]
assert [node.val for node in ns] == [1,2,3,4]
head, ns = build([1,2,3])
assert collect(Solution().swapPairs(head)) == [ns[1],ns[0],ns[2]]
assert ns[2].next is None
head, ns = build([7])
assert Solution().swapPairs(head) is head
assert Solution().swapPairs(None) is None
预测练习
第一组做完 B.next=A,但还没做 S.next=B 时,从 S 出发能访问到 B 吗?
一点提示
此时 S.next 仍是 A,A.next 已经是 C。
答案与理由
不能。当前从 S 是 S→A→C→D→E,B 由局部变量 b 持有。必须完成第三次改接 S.next=B,第一组才接回完整链。
面试追问与解法取舍
如何证明没有偷偷交换值?测试应保存原节点列表,断言输出节点顺序变成 [原第2个, 原第1个, 原第4个, 原第3个],同时断言每个原对象的 val 不变。只比较输出数值不足以区分交换节点与交换值的实现。
模板迁移 / 相似题
试着不看代码,复述这三点
- 三次改接各有职责:保后缀、反向连组内、接回组前驱。
- 局部 a、b 保存身份,改 next 不会自动移动这些引用。
- 完成后 prev=a,下一组不重叠;不足两个节点就停止。
25. K 个一组翻转链表 困难
一句话题意
把链表每连续 k 个节点原地翻转,末尾不足 k 个保持原顺序,不能只交换节点值。
head=[1,2,3,4,5],k=2;节点身份 A=1、B=2、C=3、E=4、F=5;输出 [2,1,4,3,5]。
生活化理解
把一列车厢按每 k 节划为一组,在调车场改变组内车厢的连接方向;车厢本身就是节点,车钩就是 next。先确认整组到齐,再解开车钩,否则尾部半组会被错误翻转。
迭代:探测组尾后原地反转
先确认完整组再反转,接线边界清楚,且只需固定数量指针,便于面试复现。核心思路
group_prev 是已处理前缀的尾节点,也是待翻组的前驱。每轮先让 kth 从它向后走 k 步;若途中到达 None,剩余节点不足一整组,直接返回 dummy.next。这一轮尚未改动任何 next,因此尾部自然保持原序。哨兵 dummy 让第一组和后续组共用同样的接线方式。
确认组尾后,保存 group_next=kth.next,反转范围为半开区间 [group_prev.next, group_next)。令 prev=group_next、cur=group_prev.next。每次先把 cur.next 保存为 nxt,再令 cur.next=prev,最后推进 prev、cur。prev 指向已翻部分的头,cur 指向下一待处理节点;即使旧后继暂时不再从 dummy 可达,也仍由 nxt 或 cur 持有。
让 prev 从下一组入口开始,使旧组头在第一次改边时就接好后缀。组内反转完成后,group_prev.next 还指着旧组头,先将它保存为 old_head,再把前缀入口改接新组头 kth,并令 group_prev=old_head。至此,group_prev 及之前的完整组都已正确翻转,后缀仍未处理。图中第一组得到 B→A 后,前驱必须移到 A,而不是新组头 B。
每轮开始时,group_prev 及之前的完整组已经按要求翻转,后缀保持原有连接。组内循环让 prev 指向已翻部分,cur 指向未翻部分;先保存 nxt 才改 next,保证两部分都仍可访问。确认整组存在后再翻转,使不足 k 个的尾部自然原样返回。
图解
- k
- 2
- group_prev
- D
- 链表
- D→A(1)→B(2)→C(3)→E(4)→F(5)→∅
- kth
- B(2)
- 探测路径
- D→A→B
- group_prev
- D
- group_next
- C(3)
- prev
- C(3)
- cur
- A(1)
- nxt
- B(2)
- prev
- A(1)
- cur
- B(2)
- 刚改的边
- A.next=C
- nxt
- C(3)
- prev
- B(2)
- cur
- C(3)
- group_next
- C(3)
- old_head
- A(1)
- group_prev
- A(1)
- 前缀
- D→B→A
- group_prev
- A(1)
- kth
- E(4)
- 探测路径
- A→C→E
- group_next
- F(5)
- prev
- F(5)
- cur
- C(3)
- nxt
- E(4)
- prev
- C(3)
- cur
- E(4)
- 刚改的边
- C.next=F
- nxt
- F(5)
- prev
- E(4)
- cur
- F(5)
- group_next
- F(5)
- old_head
- C(3)
- group_prev
- C(3)
- 链表
- D→B→A→E→C→F
- 探测路径
- C→F→∅
- kth
- None
- 输出
- [2,1,4,3,5]
选择步骤 12 步
变量与图例
盯住三种边:cur.next 的逐次反向边、旧组头通向后缀的边、group_prev.next 的组间重接边。
- group_prev
- 已处理前缀的尾节点;当前待翻组的前驱。
- kth / group_next
- 本组第 k 个节点 / 本组之后的第一个节点。
- prev / cur
- 已翻部分的头 / 下一待改 next 的节点。
- nxt
- 改边前保存的旧后继,保证未处理节点不丢失。
- old_head
- 本组旧头,翻转后作为新尾和下一轮 group_prev。
算法步骤
- 建立 dummy.next=head,并令 group_prev=dummy。
- 令 kth 从 group_prev 向后走 k 步;遇到 None 就返回 dummy.next,保留不足一组的尾部。
- 保存 group_next=kth.next,令 prev=group_next、cur=group_prev.next。
- 在 cur 到达 group_next 前,依次保存 nxt、反向连接 cur.next,再同时推进 prev 和 cur。
- 保存 old_head=group_prev.next,将 group_prev.next 接到 kth,再把 group_prev 移到 old_head,继续探测下一组。
代码(Python)
查看完整 Python 代码迭代:探测组尾后原地反转 · 与上方图解对应
group_prev 划分已处理前缀和未处理后缀;prev 初值是组后节点,使翻转过程同时完成尾部接线。只持有固定数量指针。
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
class Solution:
def reverseKGroup(self, head, k):
dummy = ListNode(0, head)
group_prev = dummy
while True:
kth = group_prev
for _ in range(k):
kth = kth.next
if kth is None:
return dummy.next
group_next = kth.next
prev, cur = group_next, group_prev.next
while cur is not group_next:
nxt = cur.next
cur.next = prev
prev, cur = cur, nxt
old_head = group_prev.next
group_prev.next = kth
group_prev = old_head
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
def chain(values):
nodes = [ListNode(x) for x in values]
for a, b in zip(nodes, nodes[1:]):
a.next = b
return (nodes[0] if nodes else None), nodes
def inspect(head):
nodes = []
while head:
assert head not in nodes, "unexpected cycle"
nodes.append(head)
head = head.next
return nodes
for values, k, expected in [([1,2,3,4,5],2,[2,1,4,3,5]), ([1,2,3,4,5],3,[3,2,1,4,5]), ([1],1,[1]), ([1,2,3],3,[3,2,1])]:
head, before = chain(values)
result = inspect(Solution().reverseKGroup(head,k))
assert [x.val for x in result] == expected
assert set(result) == set(before)
remainder = len(values) % k
if remainder:
assert result[-remainder:] == before[-remainder:]
复杂度
- 时间
- O(n),n 为节点数,每组最多探测和翻转各一次。
- 空间
- O(1) 额外空间;复用原节点。
易错点与边界
- 必须先确认有 k 个节点再改指针,不能翻到一半发现数量不足。
- 保存 nxt 后再写 cur.next,否则丢失剩余链表入口。
- 旧组头变成组尾;下一轮的 group_prev 应移动到旧组头。
- k=1 与 k=n 都合法;交换 val 无法满足原地改变节点连接的要求。
主解之后,再比较与迁移。
其他解法 1
递归:一组作为一个子问题时间 O(n),每个节点进行常数次访问。
每个调用负责翻转以 head 开始的一整组,并返回这一组的新头。stop 向后走 k 步确定排除边界;不足 k 个时原样返回 head。翻转后,旧头 head 成为组尾,用 head.next 接上递归处理后的 stop 后缀。它把组间连接交给返回值,比主解少了 group_prev 的外层推进,但每组占一个调用层,k=1 时仍可能达到 n 层。
- 从 head 开始让 stop 前进 k 步;若提前为空,返回未改动的 head。
- 令 prev=stop、cur=head,逐个反转半开区间 [head,stop),每次先保存旧后继。
- 递归调用 reverseKGroup(stop,k),得到剩余链表处理后的新头。
- 把旧组头 head.next 接到递归结果,返回 prev 作为当前整段的新头。
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
class Solution:
def reverseKGroup(self, head, k):
stop = head
for _ in range(k):
if stop is None:
return head
stop = stop.next
prev, cur = stop, head
while cur is not stop:
nxt = cur.next
cur.next = prev
prev, cur = cur, nxt
head.next = self.reverseKGroup(stop, k)
return prev
复杂度
- 时间
- O(n),每个节点进行常数次访问。
- 空间
- O(n/k) 递归栈;k=1 时 O(n),长链表可能超过 Python 递归深度,优先迭代版。
这个方法的边界
- 当前组翻转后,旧组头 head.next 必须接递归调用的返回头;直接接原 stop 会绕过后缀翻转后的新头。
- 递归以整组为单位,k=1 时仍有 n 层调用,长链可能触发 Python 递归深度限制。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
def chain(values):
nodes = [ListNode(x) for x in values]
for a, b in zip(nodes, nodes[1:]):
a.next = b
return (nodes[0] if nodes else None), nodes
def inspect(head):
nodes = []
while head:
assert head not in nodes, "unexpected cycle"
nodes.append(head)
head = head.next
return nodes
for values, k, expected in [([1,2,3,4,5],2,[2,1,4,3,5]), ([1,2,3,4,5],3,[3,2,1,4,5]), ([1],1,[1]), ([1,2,3],3,[3,2,1])]:
head, before = chain(values)
result = inspect(Solution().reverseKGroup(head,k))
assert [x.val for x in result] == expected
assert set(result) == set(before)
remainder = len(values) % k
if remainder:
assert result[-remainder:] == before[-remainder:]
预测练习
第一组中 A.next=C 已改完,为什么还不能把 group_prev 移到 B?
一点提示
B 是将来的新组头,A 才是翻转后的组尾。
答案与理由
group_prev 要在整组接回后移到 A。移到 B 会使下一次探测从 B→A 开始,重新卷入已经处理的节点。
面试追问与解法取舍
面试若要求严格 O(1) 额外空间,应选择迭代版;递归写法虽然没有数组,却仍有调用栈。还可以先数总长度,然后只处理 n//k 组,省去每组的结束探测;两者都是 O(n),区别只是边界组织方式。
模板迁移 / 相似题
试着不看代码,复述这三点
- 先探测 k 个节点,后改边;末尾不足 k 个不会被破坏。
- prev 初值是 group_next,使旧组头第一次改边就接好后缀。
- 组内逐点反转后,只改 group_prev.next,再把前驱移到旧组头。
138. 随机链表的复制 中等
一句话题意
深拷贝带 next 和 random 指针的链表,副本指针必须只指向副本节点或空。
输入 [[7,2],[13,0],[11,2],[10,null]](第二项为 random 的 0 基下标);输出关系相同的新链,且原链恢复。
生活化理解
像复制一份员工通讯录:next 是名册顺序,random 是紧急联系人。不能只抄名字后继续引用旧员工卡;每张新卡的联系人也必须指向新卡。同名员工仍是不同的人,所以映射依据是节点身份。
三遍穿插与拆分
三遍各自承担一种接线任务,能清楚展示如何用相邻副本建立映射,并达到常数额外空间。核心思路
深拷贝要求每个原节点都有独立副本,并保留 next、random 的指向关系。穿插法先把副本 A′ 放在原节点 A 后面,使 A.next 暂时充当 A 的副本入口。第一遍中,clone=Node(cur.val,cur.next) 先保存原后继,再令 cur.next=clone;随后 cur=clone.next,跳到下一个原节点,而不是再次处理刚创建的副本。
全部副本穿插完成后,才开始第二遍。此时任一原节点 X 的副本都能通过 X.next 找到。因此若 cur.random 指向 B,就令副本 clone.random=cur.random.next,也就是 B′;原 random 为空时,副本也设为空。图中的 A→C、B→A、C→C 分别变成 A′→C′、B′→A′、C′→C′。处理 random 期间必须保留全部穿插关系,才能支持向前、向后和自指目标。
第三遍先保存 result=head.next。对每个原节点,先取 clone=cur.next,再用 cur.next=clone.next 恢复旧链;cur 移到下一个原节点后,它的 next 仍是自己的副本,于是可将 clone.next 接到那里,末尾则接 None。每轮同时延长恢复后的原链与新链,已设置的 random 不再改动。返回 result 时两条链对象完全分离,原链恢复;常数额外空间不包含必须创建的 n 个输出节点。
第一遍完成后,每个旧节点.next 恰是自己的副本,这就是不使用哈希表的旧→新对应。第二遍只借这个对应设置新 random;第三遍才恢复旧 next,并把新 next 串起。因此交叉指向、自指和空 random 都保留,返回链不共享旧节点。
图解
- cur
- A
- next
- A→B→C→D→∅
- random
- A→C,B→A,C→C,D→∅
- 刚创建 clone
- A′
- cur
- B
- 旧新对应
- A→A′
- 刚创建 clone
- B′
- cur
- C
- 旧新对应
- A→A′,B→B′
- 刚创建 clone
- C′
- cur
- D
- 旧新对应
- A→A′,B→B′,C→C′
- 刚创建 clone
- D′
- cur
- None
- 旧新对应
- A→A′,B→B′,C→C′,D→D′
- clone
- A′
- 旧 random
- A→C
- 新 random
- A′→C′
- cur
- B
- clone
- B′
- 旧 random
- B→A
- 新 random
- B′→A′
- cur
- C
- clone
- C′
- 旧 random
- C→C
- 新 random
- C′→C′
- cur
- D
- clone
- D′
- 旧 random
- D→None
- 新 random
- D′→None
- cur
- None
- result
- A′
- clone
- A′
- cur
- B
- 恢复的旧边
- A.next=B
- 新链边
- A′.next=B′
- result
- A′
- clone
- B′
- cur
- C
- 恢复的旧边
- B.next=C
- 新链边
- B′.next=C′
- result
- A′
- clone
- C′
- cur
- D
- 恢复的旧边
- C.next=D
- 新链边
- C′.next=D′
- result
- A′
- clone
- D′
- cur
- None
- 恢复的旧边
- D.next=None
- 新链边
- D′.next=None
- result
- A′
- 旧 next
- A→B→C→D→∅
- 新 next
- A′→B′→C′→D′→∅
- 新 random
- A′→C′,B′→A′,C′→C′,D′→∅
选择步骤 14 步
变量与图例
先观察旧节点和副本交错,再跟随 random 目标的 next 找到新目标,最后看两套 next 分离。
- cur
- 本遍正在处理的旧节点;循环尾部移动到下一个旧节点。
- clone
- cur 紧邻的副本 cur.next。
- 旧节点.next
- 穿插完成到拆分前,充当旧→新映射;拆分后恢复普通后继。
- random
- 任意节点引用,与 next 链顺序无关;节点内 r=∅ 表示当前 random 为空。
- result
- 拆分前保存的新链入口 A′。
算法步骤
- 若 head 为空则返回 None;否则从 head 开始第一遍遍历。
- 为每个原节点创建 clone,插在原节点之后,再经 clone.next 前进到下一个原节点。
- 将 cur 重置到 head,第二遍用 cur.random.next 找到 random 目标的副本,空目标仍设为空。
- 保存 result=head.next,再重置 cur;第三遍先恢复原节点 next,然后将 clone.next 接到下一原节点的副本。
- 最后一个副本的 next 设为 None,返回 result;此时原链已恢复,副本链只引用新节点。
代码(Python)
查看完整 Python 代码三遍穿插与拆分 · 与上方图解对应
第一遍让 cur.next 充当 cur 的映射,第二遍补齐 random,第三遍同时恢复原链和连接副本链。O(1) 是不计必需输出节点的额外空间。
class Node:
def __init__(self, x, next=None, random=None):
self.val = x
self.next = next
self.random = random
class Solution:
def copyRandomList(self, head):
if head is None:
return None
cur = head
while cur:
clone = Node(cur.val, cur.next)
cur.next = clone
cur = clone.next
cur = head
while cur:
clone = cur.next
clone.random = cur.random.next if cur.random else None
cur = clone.next
result = head.next
cur = head
while cur:
clone = cur.next
cur.next = clone.next
cur = cur.next
clone.next = cur.next if cur else None
return result
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
def check(values, random_indices):
original = [Node(v) for v in values]
for i, node in enumerate(original):
node.next = original[i+1] if i+1<len(original) else None
j = random_indices[i]
node.random = original[j] if j is not None else None
saved = [(x.next,x.random) for x in original]
copied = Solution().copyRandomList(original[0] if original else None)
clones=[]
while copied:
assert copied not in clones
clones.append(copied)
copied=copied.next
assert [x.val for x in clones] == values
assert set(clones).isdisjoint(original)
assert [(x.next,x.random) for x in original] == saved
for i,j in enumerate(random_indices):
assert clones[i].random is (clones[j] if j is not None else None)
check([7,13,11,10,1],[None,0,4,2,0])
check([7,7],[1,0])
check([1],[0])
check([],[])
复杂度
- 时间
- O(n),三次线性遍历。
- 空间
- O(1) 额外空间,另有 O(n) 输出节点;处理期间暂时修改输入。
易错点与边界
- 复制节点值并保留旧 random 是浅拷贝,身份检查会失败。
- 必须先穿插全部副本,再连接 random,不能假设目标已经处理过。
- random 可以为空、自指或与其他节点形成环;遍历主线仍是无环 next。
- 穿插法第三遍要恢复原链,不能把原链留在交错状态。
主解之后,再比较与迁移。
其他解法 1
两遍哈希映射时间 O(n),n 为节点数,两次遍历。
copies[原节点]=新节点 用节点身份建立一一对应,两个值相同的原节点也有不同副本。第一遍只创建节点,保证任意 random 目标的副本都已存在;第二遍通过映射连接 next 和 random。copies[None]=None 统一处理空指针。它不修改输入链,适合不能临时穿插副本的场景,代价是额外保存 O(n) 个映射项。
- 建立 copies={None:None},从 head 沿 next 遍历原链。
- 对每个原节点 cur 创建 Node(cur.val),记录 copies[cur],不能按 val 建键。
- 重新从 head 遍历,把 copies[cur].next 设为 copies[cur.next],把 random 设为 copies[cur.random]。
- 返回 copies[head];空头通过预设映射自然返回 None,原链始终不变。
class Node:
def __init__(self, x, next=None, random=None):
self.val = x
self.next = next
self.random = random
class Solution:
def copyRandomList(self, head):
copies = {None: None}
cur = head
while cur:
copies[cur] = Node(cur.val)
cur = cur.next
cur = head
while cur:
copies[cur].next = copies[cur.next]
copies[cur].random = copies[cur.random]
cur = cur.next
return copies[head]
复杂度
- 时间
- O(n),n 为节点数,两次遍历。
- 空间
- O(n) 映射额外空间,另有 O(n) 必需的输出节点。
这个方法的边界
- 不能把 val 当作映射键:重复值不代表同一个节点。
- 两遍哈希法必须先建齐全部 copies,再连接 next 与 random,才能处理指向后方或自身的目标。
- copies[None]=None 使空链和空 random 都能查到对应值;副本的指针必须指向映射结果,不能指回原对象。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
def check(values, random_indices):
original = [Node(v) for v in values]
for i, node in enumerate(original):
node.next = original[i+1] if i+1<len(original) else None
j = random_indices[i]
node.random = original[j] if j is not None else None
saved = [(x.next,x.random) for x in original]
copied = Solution().copyRandomList(original[0] if original else None)
clones=[]
while copied:
assert copied not in clones
clones.append(copied)
copied=copied.next
assert [x.val for x in clones] == values
assert set(clones).isdisjoint(original)
assert [(x.next,x.random) for x in original] == saved
for i,j in enumerate(random_indices):
assert clones[i].random is (clones[j] if j is not None else None)
check([7,13,11,10,1],[None,0,4,2,0])
check([7,7],[1,0])
check([1],[0])
check([],[])
预测练习
第二遍设置 B′.random 时,应连接 A 还是 A′,代码如何找到它?
一点提示
B.random=A;此时 A.next 仍指向 A′。
答案与理由
连接 A′。代码读取 B.random.next,也就是 A.next。若先拆掉 A 与 A′,这个常数空间映射就消失了,所以必须先接完所有 random 再拆分。
面试追问与解法取舍
若输入扩展为 next 本身也可能成环的任意图,沿 next 的简单 while 不再适用,需要访问表配合图遍历,这属于图克隆问题。当前题目只有 random 允许回指,穿插法依赖 next 构成普通链表这一前提。
模板迁移 / 相似题
试着不看代码,复述这三点
- 穿插节点.next 就是一张隐式旧→新映射,不按节点值区分对象。
- 新 random=旧 random 的紧邻副本;自指与跨节点引用使用同一公式。
- 拆分同时恢复旧 next 和串好新 next,须提前保存新入口。
148. 排序链表 中等
一句话题意
将单链表按值升序排序,进阶要求 O(n log n) 时间和 O(1) 额外空间。
输入 A(4)→B(2)→C(1)→D(3);输出 C(1)→B(2)→D(3)→A(4),值为 [1,2,3,4]。
生活化理解
把一叠乱序收据拆成小叠,先让每小叠有序,再像合并两条排队队伍那样只比较队首。链表不能便宜地随机访问中间位置,但移动车钩就能把节点接到新队伍末尾,所以归并比数组式下标交换自然。
自顶向下归并
分成两半、分别排好、再合并的递归结构直接,快慢指针与两链归并都容易单独解释。核心思路
sortList(head) 的契约是返回当前链表排序后的头节点,复用原节点并只改 next。空链或单节点已经有序,直接返回。否则令 slow=head、fast=head.next,通过一慢一快找到左半段尾部。fast 从第二个节点起步,使两节点输入时 slow 留在第一个节点,保证左右子问题都比原链短。
先保存 right=slow.next,再令 slow.next=None,真正拆开两条链。随后分别递归排序 head 和 right,把两个有序结果交给 merge。只是记住右半入口而不断开 next,会让左侧递归继续包含整条链,无法按预期缩小问题。图中 A(4)→B(2) 与 C(1)→D(3) 分别返回 B→A、C→D,然后才进入顶层合并。
merge 用 a、b 指向两条未消费有序链的头,tail 指向已经确认的输出前缀尾部。较小的头必然是所有剩余节点中的最小值,接到 tail 后只推进对应一侧;相等时用 ≤ 优先左侧,保持稳定性。注意已确认前缀只到 tail,tail.next 可能暂时仍是旧后继,下一轮会按比较结果改写。某一侧耗尽后,另一侧整体接上即可。分半使用递归,合并本身使用循环;因此调用栈是 O(log n),不是严格常数空间。
每次 sortList 真正把链切为更短的两段,单点返回构成递归终点。merge 的 a、b 分别指向两条有序剩余链,dummy 到 tail 是已确认的非递减前缀;只取更小的头即可保持这个事实。前缀尾后原有 next 可能尚未改写,因此用 tail 区分已确认部分与旧连接。
图解
- head
- A(4)
- slow
- A(4)
- fast
- B(2)
- slow
- B(2)
- fast
- D(3)
- fast.next
- None
- left/head
- A→B→∅
- right
- C→D→∅
- slow.next
- None
- 当前子问题
- sort(A→B)
- 左返回
- A(4)
- 右返回
- B(2)
- 比较
- 4 > 2
- a
- A(4)
- b
- None
- 已确认前缀
- M1→B
- b
- None
- tail
- B(2)
- 左半结果
- B(2)→A(4)→∅
- 当前子问题
- sort(C→D)
- 左返回
- C(1)
- 右返回
- D(3)
- 比较
- 1 ≤ 3
- a
- None
- b
- D(3)
- 已确认前缀
- M2→C
- 左半结果
- B(2)→A(4)
- 右半结果
- C(1)→D(3)
- tail
- C(1)
- 比较
- 2 > 1
- a
- B(2)
- b
- D(3)
- 已确认前缀
- C(1)
- 比较
- 2 ≤ 3
- a
- A(4)
- b
- D(3)
- 已确认前缀
- C(1)→B(2)
- 比较
- 4 > 3
- a
- A(4)
- b
- None
- 已确认前缀
- C(1)→B(2)→D(3)
- b
- None
- tail
- D(3)
- 输出身份
- C→B→D→A
- 输出值
- [1,2,3,4]
选择步骤 13 步
变量与图例
先看真正断开的 next,再看归并时 a、b 保住两条剩余链,tail 后的边怎样逐次改写。
- slow / fast
- 快慢指针;slow 最终停在左半段尾。
- right
- 断开 slow.next 之前保存的右半入口。
- a / b
- 当前 merge 两条有序链的未处理头。
- dummy / tail
- 本次合并的哨兵 / 已确认前缀最后节点。
- M1 / M2 / M3
- 三次不同 merge 调用创建的哨兵身份。
算法步骤
- 空链或单节点直接返回;否则初始化 slow=head、fast=head.next。
- 快指针每次走两步、慢指针走一步,直到 slow 停在左半段尾部。
- 保存 right=slow.next,再断开 slow.next;分别递归排序左半 head 和右半 right。
- 建立合并哨兵与 tail,反复比较两条有序链头,将较小节点接入;相等时优先左链。
- 一侧耗尽后接上另一侧,返回合并哨兵的 next,作为当前递归的有序结果。
代码(Python)
查看完整 Python 代码自顶向下归并 · 与上方图解对应
fast 从 head.next 起步,确保双节点时 slow 停在第一个节点;左右链分离后分别排序。merge 为迭代实现,避免合并阶段额外 O(n) 递归。
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
class Solution:
def sortList(self, head):
if head is None or head.next is None:
return head
slow, fast = head, head.next
while fast and fast.next:
slow, fast = slow.next, fast.next.next
right = slow.next
slow.next = None
return self.merge(self.sortList(head), self.sortList(right))
def merge(self, a, b):
dummy = tail = ListNode()
while a and b:
if a.val <= b.val:
tail.next, a = a, a.next
else:
tail.next, b = b, b.next
tail = tail.next
tail.next = a or b
return dummy.next
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
def chain(values):
nodes = [ListNode(x) for x in values]
for a, b in zip(nodes, nodes[1:]):
a.next = b
return (nodes[0] if nodes else None), nodes
def inspect(head):
nodes = []
while head:
assert head not in nodes, "unexpected cycle"
nodes.append(head)
head = head.next
return nodes
for values in ([4,2,1,3],[-1,5,3,4,0],[2,1,2,1],[],[7],[3,2,1]):
head, before = chain(values)
result = inspect(Solution().sortList(head))
assert [x.val for x in result] == sorted(values)
assert set(result) == set(before)
for value in set(values):
assert [x for x in result if x.val == value] == [x for x in before if x.val == value]
复杂度
- 时间
- O(n log n),每层合并 O(n),分半产生 O(log n) 层。
- 空间
- O(log n) 递归栈;合并额外 O(1),复用原节点。
易错点与边界
- 分割后必须切断左段尾巴,确保递归子问题缩小,也防止合并时串入后续段。
- 递归归并的调用栈是 O(log n),不是 O(1)。
- 稳定性依赖相等时选择左链;本题通常只要求有序,但保留稳定性成本很低。
主解之后,再比较与迁移。
其他解法 1
自底向上归并:常数额外空间时间 O(n log n)。每轮分段、合并和定位尾部各扫描 O(n),轮数 O(log n)。
先统计长度 n,从 size=1 的有序段开始,每轮合并相邻两段,再将 size 加倍。split(head,size) 截下一段并返回后续入口;prev 是本轮已合并部分的尾部,cur 保存尚未分段的入口。merge 仍按两链头大小循环接线。该实现合并后沿 prev.next 找到新尾,每轮总扫描仍为线性;它不使用递归、切片或节点数组,适合严格常数额外空间的要求。
- 沿 next 统计节点总数 n,创建 dummy.next=head,并设 size=1。
- 每轮令 prev=dummy、cur=dummy.next;轮初各段长度至多 size 且已经有序。
- 令 left=cur,用 split(left,size) 截出右段入口,再用 split(right,size) 保存下一对段的入口 cur。
- 把 merge(left,right) 的结果接到 prev.next,再沿合并结果将 prev 推进到段尾。
- 本轮所有相邻段处理完后令 size 加倍;size≥n 时返回 dummy.next。
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
class Solution:
def sortList(self, head):
n, cur = 0, head
while cur:
n += 1
cur = cur.next
dummy = ListNode(0, head)
size = 1
while size < n:
prev, cur = dummy, dummy.next
while cur:
left = cur
right = self.split(left, size)
cur = self.split(right, size)
prev.next = self.merge(left, right)
while prev.next:
prev = prev.next
size *= 2
return dummy.next
def split(self, head, size):
if head is None:
return None
for _ in range(size - 1):
if head.next is None:
break
head = head.next
rest = head.next
head.next = None
return rest
def merge(self, a, b):
dummy = tail = ListNode()
while a and b:
if a.val <= b.val:
tail.next, a = a, a.next
else:
tail.next, b = b, b.next
tail = tail.next
tail.next = a or b
return dummy.next
复杂度
- 时间
- O(n log n)。每轮分段、合并和定位尾部各扫描 O(n),轮数 O(log n)。
- 空间
- O(1) 峰值额外空间;临时哨兵逐次使用,不保存节点数组或递归栈。
这个方法的边界
- 长度不是二次幂时,最后一段不足 size 是正常情况。
- split 要实际截断当前段并保存 rest;第二段可能为空,不能假定每对段都有 size 个节点。
- 每轮合并后 prev 要走到结果段尾,cur 则保留下一对段的入口,二者不能混用。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
def chain(values):
nodes = [ListNode(x) for x in values]
for a, b in zip(nodes, nodes[1:]):
a.next = b
return (nodes[0] if nodes else None), nodes
def inspect(head):
nodes = []
while head:
assert head not in nodes, "unexpected cycle"
nodes.append(head)
head = head.next
return nodes
for values in ([4,2,1,3],[-1,5,3,4,0],[2,1,2,1],[],[7],[3,2,1]):
head, before = chain(values)
result = inspect(Solution().sortList(head))
assert [x.val for x in result] == sorted(values)
assert set(result) == set(before)
for value in set(values):
assert [x for x in result if x.val == value] == [x for x in before if x.val == value]
预测练习
顶层已经选 C(1) 后,C.next 原来指向 D(3),为什么下一步仍可以改接 B(2)?
一点提示
此刻 b=D、a=B,两个剩余入口都已另行保存。
答案与理由
D 仍由 b 持有,不会丢失。C 是已确认前缀的 tail,tail 后的旧 next 并非已排定结果;比较 2≤3 后应改 C.next=B。
面试追问与解法取舍
面试先用递归归并建立清晰正确的解法,再在严格空间追问下转换为自底向上。迭代版多一次寻找合并尾部仍是 O(n log n);也可以让 merge 同时返回头尾减少常数,但不必为了渐进复杂度额外设计复杂接口。
其他思路的边界
- 若把节点收进 Python 列表后排序,列表和 Timsort 工作区都可能占 O(n);不能冒充常数空间。
模板迁移 / 相似题
试着不看代码,复述这三点
- 先保存 right 再断 slow.next,递归才能真正缩小链段。
- 递归保证两半有序,merge 只需比较两个当前头。
- tail 标记已确认前缀边界,尾后旧边可改写;剩余链由 a、b 保存。
23. 合并 K 个升序链表 困难
一句话题意
把 k 条各自升序的链表合并为一条升序链表。
三条链:L0=A(1)→B(4),L1=C(1)→D(3),L2=E(2)→F(6);输出 A→C→E→D→B→F,即 [1,1,2,3,4,6]。
生活化理解
想象 k 个售票窗口各有一列按号码升序排队的人。全场下一个最小号码一定在某列队首;每放行一人,只需让那一列的下一人进入候选区。最小堆就是能快速找到最小队首的候选牌架。
最小堆:维护 k 个队首
只比较各条有序链的当前头,最小候选的来源明确,是容易复现的多路归并方法。核心思路
堆在每轮开始时保存各条非空剩余链的一个当前头,最多 k 个候选。由于每条链内部有序,藏在当前头后面的节点都不会更小,所以这些候选中的最小者就是全部未输出节点中的最小者。弹出它后,只补入同一条链的下一节点,即可恢复这个不变量。
堆项使用 (node.val,i,node),其中 i 是来源链编号。Python 比较元组时按字段依次比较;相同 val 先比较 i,避免直接比较不支持大小关系的节点对象。每条来源同时最多保留一个候选,因此 i 足以打破当前堆内的平局。heapify 建立的是堆,并不把内部数组完全排序,只有堆顶保证最小。
dummy 保存结果入口,tail 标记已确认有序前缀的末尾。每次将弹出的 node 接到 tail.next 并推进 tail,然后将 node.next 入堆。代码复用原节点,刚接入的 tail.next 可能仍指向它的原后继;图中灰色边表示这个真实指针,并不表示后继已经被选入前缀。下一轮会按堆顶结果改写这条边。堆空时所有来源都已耗尽,返回 dummy.next。
每轮开始时,堆中恰好保留每条未耗尽链的一个当前头,堆根就是所有剩余节点中的最小者。dummy 到 tail 是已接入的有序前缀;接入节点原有的 next 可能仍通向候选,随后允许改接。来源编号 i 在值相等时打破平局,整条链始终只有一个候选在堆中。
图解
- heap
- [(1, 0, 'A'), (1, 1, 'C'), (2, 2, 'E')]
- tail
- dummy
- 已接入前缀
- []
- 弹出的 (值,i,node)
- (1,0,A)
- heap
- [(1, 1, 'C'), (2, 2, 'E')]
- tail
- A
- 已接入前缀
- A
- node.next
- B
- 刚入堆
- (4,0,B)
- heap
- [(1, 1, 'C'), (2, 2, 'E'), (4, 0, 'B')]
- tail
- A
- 下次堆根
- C
- 弹出的 (值,i,node)
- (1,1,C)
- heap
- [(2, 2, 'E'), (4, 0, 'B')]
- tail
- C
- 已接入前缀
- A→C
- node.next
- D
- 刚入堆
- (3,1,D)
- heap
- [(2, 2, 'E'), (4, 0, 'B'), (3, 1, 'D')]
- tail
- C
- 下次堆根
- E
- 弹出的 (值,i,node)
- (2,2,E)
- heap
- [(3, 1, 'D'), (4, 0, 'B')]
- tail
- E
- 已接入前缀
- A→C→E
- node.next
- F
- 刚入堆
- (6,2,F)
- heap
- [(3, 1, 'D'), (4, 0, 'B'), (6, 2, 'F')]
- tail
- E
- 下次堆根
- D
- 弹出的 (值,i,node)
- (3,1,D)
- heap
- [(4, 0, 'B'), (6, 2, 'F')]
- tail
- D
- 已接入前缀
- A→C→E→D
- node.next
- None
- 弹出的 (值,i,node)
- (4,0,B)
- heap
- [(6, 2, 'F')]
- tail
- B
- 已接入前缀
- A→C→E→D→B
- node.next
- None
- 弹出的 (值,i,node)
- (6,2,F)
- heap
- []
- tail
- F
- 已接入前缀
- A→C→E→D→B→F
- node.next
- None
- heap
- []
- 输出身份
- A→C→E→D→B→F
- 输出值
- [1,1,2,3,4,6]
选择步骤 11 步
变量与图例
每次先从堆顶拿走一个链头,再只补回同一条链的后继;下方前缀只确认到 tail。
- heap
- 真实最小堆数组,项为 (节点值, 原链编号, 节点引用)。
- i / node
- 刚弹出节点的来源编号与身份。
- tail
- 已确认有序前缀的最后节点。
- node.next
- 弹出节点的原后继;仅这个后继需要补入堆。
- 未处理后缀
- 图中的教学记录;代码由堆项与 next 保存入口,并未修改 lists 数组。
算法步骤
- 从每条非空链取头节点,组成 (值,链编号,节点) 元组列表并执行 heapify。
- 建立 dummy=tail,表示结果前缀暂时为空。
- 弹出堆顶的最小候选,把它接到 tail.next,再令 tail 指向该节点。
- 若该节点有 next,只将这个后继连同相同来源编号压回堆。
- 重复直到堆空,返回 dummy.next;所有输入链均为空时自然返回 None。
代码(Python)
查看完整 Python 代码最小堆:维护 k 个队首 · 与上方图解对应
heap 只放各链未消费部分的头,i 是该链编号。每次输出一个节点,仅更新一个候选,适合多路持续输入的思路。代码复用输入节点。
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
import heapq
class Solution:
def mergeKLists(self, lists):
heap = [(node.val, i, node) for i, node in enumerate(lists) if node]
heapq.heapify(heap)
dummy = tail = ListNode()
while heap:
_, i, node = heapq.heappop(heap)
tail.next = node
tail = node
if node.next:
heapq.heappush(heap, (node.next.val, i, node.next))
return dummy.next
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
def chain(values):
nodes = [ListNode(x) for x in values]
for a, b in zip(nodes, nodes[1:]):
a.next = b
return (nodes[0] if nodes else None), nodes
def inspect(head):
nodes = []
while head:
assert head not in nodes, "unexpected cycle"
nodes.append(head)
head = head.next
return nodes
for rows in ([[1,4,5],[1,3,4],[2,6]],[],[[]],[[],[-2,-2],[0]],[[],[],[]]):
lists, originals=[],[]
for values in rows:
head,nodes=chain(values)
lists.append(head)
originals.extend(nodes)
result=inspect(Solution().mergeKLists(lists))
assert [x.val for x in result] == sorted(x.val for x in originals)
assert set(result) == set(originals)
复杂度
- 时间
- O(k + N log(k+1)),k 为链数,N 为节点总数;扫描链头 O(k)。
- 空间
- O(k) 堆空间;输出复用节点。
易错点与边界
- 堆元素值相同时需要稳定的可比较次关键字,不能直接让 Python 比较 ListNode。
- N 是所有链表节点数之和,不能把单链长度误作总规模。
- 空输入列表、列表里全是空链都应返回 None。
- 这些实现会重新连接原节点;若调用者要求原链保留,需要另外复制节点。
主解之后,再比较与迁移。
其他解法 1
迭代分治:平衡合并时间 O(k + N log(k+1));每层每个节点至多参与一次合并。
step 表示当前链组的跨度,lists[i] 保存从 i 开始的已合并链组。先两两合并,再让跨度按 1、2、4…翻倍,避免不断向同一条越来越长的总链接入新链。每个节点最多参与 O(log k) 层合并;merge 使用 a、b、tail 逐点重接,不创建输出节点数组。该代码直接更新输入 lists 中的头指针。
- 输入头指针列表为空时返回 None,否则设 step=1。
- 按间隔 2×step 枚举 i,把 lists[i] 与 lists[i+step] 两个链组送入 merge。
- merge 每次接入较小链头,相等时优先左侧;一侧耗尽后整体接上另一侧。
- 将合并头写回 lists[i];本轮没有配对的末尾链组保留到后续轮次。
- 每轮令 step 加倍,直到覆盖全部输入链,返回 lists[0]。
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
class Solution:
def mergeKLists(self, lists):
if not lists:
return None
step = 1
while step < len(lists):
for i in range(0, len(lists) - step, step * 2):
lists[i] = self.merge(lists[i], lists[i + step])
step *= 2
return lists[0]
def merge(self, a, b):
dummy = tail = ListNode()
while a and b:
if a.val <= b.val:
tail.next, a = a, a.next
else:
tail.next, b = b, b.next
tail = tail.next
tail.next = a or b
return dummy.next
复杂度
- 时间
- O(k + N log(k+1));每层每个节点至多参与一次合并。
- 空间
- O(1) 额外空间,原地更新 lists 数组与链表连接;若需保留头数组,先复制会增加 O(k)。
这个方法的边界
- step 表示已经合并的链组跨度,每轮加倍;不能把上一轮已经并入结果的链再次当独立输入合并。
- lists[i] 会被新合并头覆盖,原节点的 next 也会改动,不能依赖输入头指针数组保持原样。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
def chain(values):
nodes = [ListNode(x) for x in values]
for a, b in zip(nodes, nodes[1:]):
a.next = b
return (nodes[0] if nodes else None), nodes
def inspect(head):
nodes = []
while head:
assert head not in nodes, "unexpected cycle"
nodes.append(head)
head = head.next
return nodes
for rows in ([[1,4,5],[1,3,4],[2,6]],[],[[]],[[],[-2,-2],[0]],[[],[],[]]):
lists, originals=[],[]
for values in rows:
head,nodes=chain(values)
lists.append(head)
originals.extend(nodes)
result=inspect(Solution().mergeKLists(lists))
assert [x.val for x in result] == sorted(x.val for x in originals)
assert set(result) == set(originals)
预测练习
弹出 A(1) 并补入 B(4) 后,下一次应选 B(4) 还是 C(1)?
一点提示
此时堆里是 C(1)、E(2)、B(4)。
答案与理由
选 C(1)。tail=A 的旧 next 虽仍指 B,但有序前缀只到 A;下一轮会将 A.next 改成 C,B 仍由堆持有。
面试追问与解法取舍
若要求在线不断取出最小值,堆的状态最直观;若所有链都已给定且追求少量额外空间,迭代分治很合适。总长度相差很大时还可按链长优先合并短链来减少重复处理,但需要额外记录长度与调度成本,通常不是本题首选。
模板迁移 / 相似题
试着不看代码,复述这三点
- 每条链只放一个当前头,堆空间 O(k),每个节点一次弹出和至多一次入堆。
- 相同值靠来源编号打破平局,不要求 ListNode 支持大小比较。
- 已接入前缀以 tail 为界;尾后的旧 next 不能当作已确认输出。
146. LRU 缓存 中等
一句话题意
设计容量固定的 LRU 缓存,让 get 和 put 都达到平均 O(1),满容量时淘汰最久未使用的键。
capacity=2;操作 put(1,10), put(2,20), get(1), put(3,30), get(2), put(1,11), put(4,40), get(3), get(1);get 输出 [10,-1,-1,11]。
生活化理解
把常用书放在书桌上的一列书架:每次取书或更新书,就把它挪到最左端;书架满了,新书到来便把最右端那本收走。字典是“书名→书所在位置”的索引,双向链表负责从任何位置抽出书并放回最左端。
哈希表 + 手写双向链表
把按键定位和使用顺序分开维护,能直接说明 get、put 为何只需常数次指针操作。核心思路
nodes 把 key 映射到对应节点,负责平均 O(1) 定位;双向链表负责按使用时间排列同一批节点。head、tail 是哨兵,真实节点位于两者之间:head.next 最近使用,tail.prev 最久未使用。节点保存 key 与 value,淘汰尾部节点时才能反查应该移出的字典项。
_unlink(node) 让前后邻居互相接通,暂时摘下 node;_front(node) 把它插到 head 后面。命中的 get 先摘下、再前移并返回 value;未命中直接返回 −1,不改变顺序。put 更新旧键时复用原节点并前移,新键则创建节点、登记字典并插到头部。因此读取成功和写入都算一次使用。
在每次公开 get 或 put 完成时,字典与链表中的真实节点一一对应,数量不超过 capacity。操作内部允许短暂过渡:图中摘下 N1 时字典仍持有它,插入新键后也可能暂时超容量。新插入造成超额时,取 tail.prev 为 victim,同时从链表和字典淘汰,才恢复完整状态。更新已有键不会增加节点数,也不应触发额外淘汰。
每次公开 get/put 完成时,nodes 与 H、T 之间的数据节点一一对应,数量不超过 capacity。H 后是最近使用,T 前是最久未使用;命中或写入都把对应对象移到 H 后。因此超容量时 tail.prev 就是应淘汰者,且字典和链表一起更新后不会留下可命中的旧键。
图解
- capacity
- 2
- nodes
- {}
- 主链
- H⇄T
- nodes
- {1:N1}
- 主链
- H⇄N1⇄T
- 返回
- None
- nodes
- {1:N1,2:N2}
- MRU→LRU
- [2,1]
- 容量
- 2/2
- key
- 1
- node
- N1
- nodes
- {1:N1,2:N2}
- 主链
- H⇄N2⇄T
- node
- N1
- MRU→LRU
- [1,2]
- 返回
- 10
- nodes
- {1:N1,2:N2,3:N3}
- MRU→LRU
- [3,1,2]
- 容量
- 3>2
- tail.prev
- N2
- victim
- N2(key=2)
- nodes
- {1:N1,3:N3}
- MRU→LRU
- [3,1]
- 容量
- 2/2
- key
- 2
- nodes
- {1:N1,3:N3}
- MRU→LRU
- [3,1]
- 返回
- -1
- node
- 原来的 N1
- N1.value
- 11
- MRU→LRU
- [1,3]
- nodes
- {1:N1,3:N3}
- nodes
- {1:N1,3:N3,4:N4}
- MRU→LRU
- [4,1,3]
- 容量
- 3>2
- victim
- N3
- victim
- N3(key=3)
- nodes
- {1:N1,4:N4}
- MRU→LRU
- [4,1]
- key
- 3
- nodes
- {1:N1,4:N4}
- 返回
- -1
- MRU→LRU
- [4,1]
- node
- N1
- 返回
- 11
- MRU→LRU
- [1,4]
- 全部 get 输出
- [10,-1,-1,11]
选择步骤 13 步
变量与图例
同时看字典“key 指向哪个节点”和双向链“谁更近用”;每次命中都把同一个节点移到 H 后。
- nodes
- key→Node 的字典;值对象与链表中的是同一个节点。
- head / tail
- 图中的 H/T 哨兵;H 后为 MRU,T 前为 LRU。
- node
- 本次命中或新建的数据节点。
- prev / next
- 一个节点的前后指针,使任意位置摘接只需常数操作。
- victim
- 超容量时选出的 tail.prev。
算法步骤
- 保存 capacity、初始化 nodes,并将 head 与 tail 两个哨兵双向相连。
- get 未命中返回 −1;命中则用 nodes 找到节点,调用 _unlink 和 _front 后返回 value。
- put 已有键时更新原节点 value 并摘下;新键时建立 Node(key,value) 并加入 nodes。
- 将该节点统一插到 head 后,使它成为最近使用节点。
- 若 len(nodes)>capacity,取 tail.prev,先摘链,再按 victim.key 从字典移出。
代码(Python)
查看完整 Python 代码哈希表 + 手写双向链表 · 与上方图解对应
_unlink 只摘下链表节点,_front 只插到 head 后;get 和 put 组合这两个小操作。节点同时保存 key 和 value,才能在淘汰尾节点时找到应移除的字典项。
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):
self.capacity = capacity
self.nodes = {}
self.head, self.tail = Node(), Node()
self.head.next = self.tail
self.tail.prev = self.head
def _unlink(self, node):
node.prev.next = node.next
node.next.prev = node.prev
def _front(self, node):
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.nodes:
return -1
node = self.nodes[key]
self._unlink(node)
self._front(node)
return node.value
def put(self, key, value):
if key in self.nodes:
node = self.nodes[key]
node.value = value
self._unlink(node)
else:
node = Node(key, value)
self.nodes[key] = node
self._front(node)
if len(self.nodes) > self.capacity:
victim = self.tail.prev
self._unlink(victim)
self.nodes.pop(victim.key)
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
c=LRUCache(2)
c.put(1,1); c.put(2,2)
assert c.get(1)==1
c.put(3,3)
assert c.get(2)==-1
c.put(4,4)
assert c.get(1)==-1
assert c.get(3)==3 and c.get(4)==4
c=LRUCache(1)
c.put(1,7); c.put(1,8)
assert c.get(1)==8
c.put(2,9)
assert c.get(1)==-1 and c.get(2)==9
c=LRUCache(2)
c.put(1,10); c.put(2,20); c.put(1,11); c.put(3,30)
assert c.get(2)==-1 and c.get(1)==11
assert c.get(99)==-1
seen=[]
node=c.head
while node is not c.tail:
assert node.next.prev is node
node=node.next
if node is not c.tail:
seen.append(node)
assert len(seen)==len(c.nodes)==2
assert set(seen)==set(c.nodes.values())
复杂度
- 时间
- get、put 平均 O(1),哈希表按平均复杂度计,链表操作固定步数。
- 空间
- O(C),C 为缓存容量;两哨兵 O(1)。
易错点与边界
- get 命中也要刷新顺序;遗漏后就会误变成接近 FIFO 的缓存。
- 更新已有键不是新增节点,不能错误占用第二份容量。
- 淘汰时链表和字典必须同步,否则缓存会返回已经淘汰的键。
- 约定好哪端是 MRU、哪端是 LRU;两种代码方向不同,不能混用。
- 题目容量为正数;单槽容量足以暴露头尾连接和重复更新问题。
主解之后,再比较与迁移。
其他解法 1
OrderedDict:标准库维护访问顺序时间 get、put 平均 O(1)。
OrderedDict 同时提供键查找和顺序维护,省去手写节点与接线。本实现将最近使用项放在右端,与主解头部最近的方向相反;move_to_end(key) 刷新访问次序,popitem(last=False) 从左端淘汰最久未使用项。普通的值更新并不足以表达刷新顺序,所以 put 后仍显式调用 move_to_end。
- 保存 capacity,创建空的 OrderedDict 作为 data。
- get 未命中返回 −1;命中则 move_to_end(key),再返回对应值。
- put 先设置 data[key]=value,然后将这个键移到右端作为最近使用项。
- 若长度超过容量,调用 popitem(last=False) 淘汰左端最久未使用项。
from collections import OrderedDict
class LRUCache:
def __init__(self, capacity):
self.capacity = capacity
self.data = OrderedDict()
def get(self, key):
if key not in self.data:
return -1
self.data.move_to_end(key)
return self.data[key]
def put(self, key, value):
self.data[key] = value
self.data.move_to_end(key)
if len(self.data) > self.capacity:
self.data.popitem(last=False)
复杂度
- 时间
- get、put 平均 O(1)。
- 空间
- O(C),C 为容量,存储键值及有序连接。
这个方法的边界
- 本实现右端是 MRU、左端是 LRU;淘汰使用 popitem(last=False),不能套用手写链表的端点方向。
- 给已有键重新赋值后仍须 move_to_end(key),否则写入没有刷新最近使用顺序。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
c=LRUCache(2)
c.put(1,1); c.put(2,2)
assert c.get(1)==1
c.put(3,3)
assert c.get(2)==-1
c.put(4,4)
assert c.get(1)==-1
assert c.get(3)==3 and c.get(4)==4
c=LRUCache(1)
c.put(1,7); c.put(1,8)
assert c.get(1)==8
c.put(2,9)
assert c.get(1)==-1 and c.get(2)==9
c=LRUCache(2)
c.put(1,10); c.put(2,20); c.put(1,11); c.put(3,30)
assert c.get(2)==-1 and c.get(1)==11
assert c.get(99)==-1
预测练习
get(1) 返回 10 后再 put(3,30),应该淘汰 key=1 还是 key=2?
一点提示
get 命中会刷新顺序;此前 [2,1] 变成了 [1,2]。
答案与理由
淘汰 key=2。get(1) 已把 N1 放到头部,插入 N3 后为 [3,1,2],tail.prev 指向 N2;必须同时摘链并移出 nodes[2]。
面试追问与解法取舍
普通 Python dict 也保留插入顺序,但面试解释常数时间顺序移动时,OrderedDict 的接口更明确。LRU 只跟踪最近一次访问;若要求按访问次数淘汰,则变成 LFU,通常还需要按频次组织多个链表。真实缓存的并发与过期策略属于另一层需求,不包含在本题设计里。
模板迁移 / 相似题
试着不看代码,复述这三点
- 字典解决定位,双向链表解决最近使用次序;两者共享节点对象。
- 命中与更新都先摘开再插到头部,更新同一 key 不增加节点数。
- 容量溢出淘汰 tail.prev,并同步更新 nodes;哨兵统一首尾接线。
94. 二叉树的中序遍历 简单
一句话题意
按左子树、当前节点、右子树的顺序返回二叉树全部节点值。
树的层序表示 [4,2,5,1,3];输出中序 [1,2,3,4,5]。
生活化理解
把每个节点当作一间有左右两条走廊的展厅:先逛完左侧所有展厅,再给当前展厅盖章,最后逛右侧。栈就是回程便签,提醒你“左边逛完以后,还有这间展厅没盖章”。
显式栈:模拟未完成的递归
用入栈等待、出栈访问明确表达左根右顺序,也能避免深树触发 Python 递归限制。核心思路
中序要求先输出左子树,再输出当前节点,最后输出右子树。显式栈方法用 cur 指向下一棵要探索的子树,用 stack 保存已经遇见但尚未输出的待办节点,用 ans 依次收集结果。沿 cur 的左链不断入栈时,只是在记下“左侧完成后还要回来访问”,此时不向 ans 加值。
当 cur 走到 None,当前下降路径已无更深左节点,栈顶就是下一节点。弹出它并追加 cur.val,再令 cur=cur.right,重新执行沿左链下降的过程。这样,节点的左子树先完整输出,节点自身在出栈时输出,而右子树在它之后处理。图中根 4 最早入栈,却要等 [1,2,3] 输出完才轮到它。
外层条件必须是 cur is not None or stack。cur 为空只表示当前探索路径结束,栈中可能还有待访问节点;栈为空也可能仍有 cur 指向的右子树。只有两者同时为空,所有节点才已处理。每个节点恰好入栈、出栈一次,树结构始终不变;本例输出升序源于输入恰是搜索树,一般二叉树只保证左根右次序。
stack 保存已到达但自身尚未输出的节点,较早压入的祖先仍在等待当前子树完成,cur 指向即将探索的子树。沿左路到空后,栈顶的左子树已完成,所以弹出它并输出合法;再转入右子树重复。cur 和 stack 同时为空才表示没有未处理节点。
图解
- cur
- 4
- stack
- []
- ans
- []
- 本帧输出节点
- 无
- cur
- 2
- stack
- [4]
- ans
- []
- 本帧输出节点
- 无
- cur
- 1
- stack
- [4, 2]
- ans
- []
- 本帧输出节点
- 无
- cur
- None
- stack
- [4, 2, 1]
- ans
- []
- 本帧输出节点
- 无
- cur
- None
- stack
- [4, 2]
- ans
- [1]
- 本帧输出节点
- 1
- cur
- 3
- stack
- [4]
- ans
- [1, 2]
- 本帧输出节点
- 2
- cur
- None
- stack
- [4, 3]
- ans
- [1, 2]
- 本帧输出节点
- 无
- cur
- None
- stack
- [4]
- ans
- [1, 2, 3]
- 本帧输出节点
- 3
- cur
- 5
- stack
- []
- ans
- [1, 2, 3, 4]
- 本帧输出节点
- 4
- cur
- None
- stack
- [5]
- ans
- [1, 2, 3, 4]
- 本帧输出节点
- 无
- cur
- None
- stack
- []
- ans
- [1, 2, 3, 4, 5]
- 本帧输出节点
- 5
- cur
- None
- stack
- []
- ans
- [1, 2, 3, 4, 5]
- 本帧输出节点
- 无
选择步骤 12 步
变量与图例
区分“压栈等待”和“弹栈输出”:栈中的节点还没有被写入 ans。
- cur
- 下一次沿左路探索的节点;出栈输出后会转到该节点的右孩子。
- stack
- 待左子树完成后再输出的节点,右端为栈顶。
- ans
- 已经按左根右确定的输出序列。
- 本帧输出节点
- 图中单独标记刚弹栈输出者,避免与已转向的 cur 混淆。
算法步骤
- 初始化空 ans 与 stack,令 cur=root。
- 只要 cur 非空,就把当前节点压栈并令 cur=cur.left,一直下降到空。
- 弹出栈顶放回 cur,此时它的左子树已完成,将 cur.val 追加到 ans。
- 令 cur=cur.right,转入右子树;只要 cur 或 stack 非空就重复上述过程。
- 二者同时为空后返回 ans,空树自然返回空列表。
代码(Python)
查看完整 Python 代码显式栈:模拟未完成的递归 · 与上方图解对应
stack 留住等待访问的祖先;cur 控制当前要探索的子树。算法无需修改树,适合深链避免语言递归限制。
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
class Solution:
def inorderTraversal(self, root):
ans, stack = [], []
cur = root
while cur is not None or stack:
while cur is not None:
stack.append(cur)
cur = cur.left
cur = stack.pop()
ans.append(cur.val)
cur = cur.right
return ans
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
r=TreeNode(1,None,TreeNode(2,TreeNode(3)))
assert Solution().inorderTraversal(r)==[1,3,2]
assert Solution().inorderTraversal(None)==[]
assert Solution().inorderTraversal(TreeNode(8))==[8]
r=TreeNode(2,TreeNode(1),TreeNode(4,TreeNode(3)))
before=(r.left,r.right,r.right.left)
assert Solution().inorderTraversal(r)==[1,2,3,4]
assert (r.left,r.right,r.right.left)==before
复杂度
- 时间
- O(n),每节点入栈、出栈各一次。
- 空间
- O(h) 显式栈,另有 O(n) 输出。
易错点与边界
- 循环条件是 cur 非空或 stack 非空,用 and 会漏掉待处理祖先。
- 不能在压栈时输出,否则顺序变成根先于左子树。
- 只在二叉搜索树中,中序才保证按值排序;普通二叉树没有此保证。
主解之后,再比较与迁移。
其他解法 1
递归:直接表达左根右时间 O(n),每节点访问一次。
walk(node) 的契约是把以 node 为根的整个子树按中序追加到同一个 ans。先 walk(node.left),左侧返回后追加 node.val,再 walk(node.right),由调用栈保存未完成的祖先。它最直接地表达遍历定义;闭包共享 ans,避免逐层拼接返回列表,但深树可能触发 Python 递归限制。
- 创建 ans,并定义向这个列表追加结果的 walk(node)。
- node 为 None 时直接返回,不输出任何值。
- 先递归遍历左子树,返回后追加当前节点值,再递归遍历右子树。
- 从 root 调用 walk,全部调用完成后返回 ans。
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
class Solution:
def inorderTraversal(self, root):
ans = []
def walk(node):
if node is None:
return
walk(node.left)
ans.append(node.val)
walk(node.right)
walk(root)
return ans
复杂度
- 时间
- O(n),每节点访问一次。
- 空间
- O(h) 递归栈,另有 O(n) 输出;h 为树高,退化链时 h=n,可能触发 Python 递归深度限制。
这个方法的边界
- 递归里反复使用 left_list + [val] + right_list,退化树上会因复制达到 O(n²)。
- 递归的 node 为 None 时直接返回;访问当前值必须放在左递归之后、右递归之前。
- 共享 ans 避免了列表拼接,但没有消除调用栈;退化长链仍可能触发 Python 递归限制。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
r=TreeNode(1,None,TreeNode(2,TreeNode(3)))
assert Solution().inorderTraversal(r)==[1,3,2]
assert Solution().inorderTraversal(None)==[]
assert Solution().inorderTraversal(TreeNode(8))==[8]
r=TreeNode(2,TreeNode(1),TreeNode(4,TreeNode(3)))
before=(r.left,r.right,r.right.left)
assert Solution().inorderTraversal(r)==[1,2,3,4]
assert (r.left,r.right,r.right.left)==before
预测练习
输出 1 之后 cur=None,为什么不能结束?
一点提示
此时 stack=[4,2]。
答案与理由
栈里还有未访问的祖先;下一步弹出 2,输出后 cur 转向 3。只有 cur=None 且 stack=[] 才结束。
面试追问与解法取舍
若追问 O(1) 额外空间,可以讲 Morris 遍历:借用左子树最右节点的空右指针建立回到根的临时线索,第二次遇到根时恢复该指针并访问。它会暂时修改树,且必须完整恢复所有线索;普通递归或栈法通常更容易维护。输出数组自身仍需 O(n) 空间。
模板迁移 / 相似题
试着不看代码,复述这三点
- 入栈是延期访问,出栈才真正输出。
- 每进入一棵右子树,都重新沿其左路下降。
- 循环条件要保留 cur 非空或 stack 非空的任一情形;每节点一次入栈、一次出栈。
104. 二叉树的最大深度 简单
一句话题意
求从根到最远叶子路径包含的节点数;空树深度为 0。
层序输入 [3,9,20,null,null,15,7];最大深度 3,例如路径 3→20→15 含 3 个节点。
生活化理解
把树当作一座分岔矿井:每经过一层平台深度加一,向左右岔路分别探测,最深那条路线决定总深度。这里数的是平台数量,也就是节点数,不是平台之间的梯子数量。
递归:子树高度
空树为零、父高度取左右最大值加一,状态含义与代码完全对应,适合先建立树递归模型。核心思路
maxDepth(node) 返回以 node 为根的子树高度,按节点数计。空子树没有节点,返回 0;非空节点返回 1+max(左高,右高)。叶子的两个孩子都是空,公式自然给出 1,无需另写叶子分支。
代码先求左子调用,再求右子调用,两个返回值齐备后才能计算当前高度。它们表示当前节点两棵子树各自的高度,不是从整树根走到当前节点的深度。例如图中 9 返回 1 后,根 3 保留这个左高,再进入右子树 20;此时 20 的两个子树高度仍须分别计算,不能把根保存的 1 当成 20 的左高。
一条向下路径只能选择一个孩子方向,因此取 max,不能把左右高度相加;额外的 1 只计算当前节点本身。依靠“孩子正确返回自己的高度”这一契约,结果从叶子逐层成立。图中 15、7 都返回 1,20 返回 2,最后根 3 返回 1+max(1,2)=3。
maxDepth(root) 返回以 root 为起点向下的最长路径节点数,空树返回 0。左右子调用都满足这个定义时,1+max(左返回,右返回) 就仍满足同一定义。返回值沿调用栈逐层向上合成,所以访问一个节点并不意味着其高度已经完成。
图解
- 当前 root
- 3
- 调用栈
- [3]
- 当前节点左返回
- 待计算
- 当前节点右返回
- 待计算
- 本帧返回
- 尚未返回
- 已知高度
- {}
- 当前 root
- 9
- 调用栈
- [3, 9]
- 当前节点左返回
- 待计算
- 当前节点右返回
- 待计算
- 本帧返回
- 尚未返回
- 已知高度
- {}
- 当前 root
- 9
- 调用栈
- [3, 9]
- 当前节点左返回
- 0
- 当前节点右返回
- 待计算
- 本帧返回
- None → 0
- 已知高度
- {}
- 当前 root
- 9
- 调用栈
- [3, 9]
- 当前节点左返回
- 0
- 当前节点右返回
- 0
- 本帧返回
- 9 → 1
- 已知高度
- {'9': 1}
- 当前 root
- 20
- 调用栈
- [3, 20]
- 当前节点左返回
- 待计算
- 当前节点右返回
- 待计算
- 本帧返回
- 根左高=1(待右高)
- 已知高度
- {'9': 1}
- 当前 root
- 15
- 调用栈
- [3, 20, 15]
- 当前节点左返回
- 0
- 当前节点右返回
- 0
- 本帧返回
- 15 → 1
- 已知高度
- {'9': 1, '15': 1}
- 当前 root
- 7
- 调用栈
- [3, 20, 7]
- 当前节点左返回
- 0
- 当前节点右返回
- 0
- 本帧返回
- 7 → 1
- 已知高度
- {'9': 1, '15': 1, '7': 1}
- 当前 root
- 20
- 调用栈
- [3, 20]
- 当前节点左返回
- 1
- 当前节点右返回
- 1
- 本帧返回
- 20 → 2
- 已知高度
- {'9': 1, '15': 1, '7': 1, '20': 2}
- 当前 root
- 3
- 调用栈
- [3]
- 当前节点左返回
- 1
- 当前节点右返回
- 2
- 本帧返回
- 3 → 3(最终)
- 已知高度
- {'9': 1, '15': 1, '7': 1, '20': 2, '3': 3}
选择步骤 9 步
变量与图例
先看空孩子返回 0,再看叶子返回 1;父节点只取左右返回值中的较大者再加自己。
- root
- 当前这一次递归调用的根,每个栈帧都有自己的 root。
- 调用栈
- 尚未返回的递归调用,按外层到内层显示。
- 左返回 / 右返回
- 当前节点两次递归子调用的观察结果;原代码直接在表达式中使用。
- h
- 该节点最终返回的高度,按节点数计,待计算时不填 0。
算法步骤
- 若当前 root 为 None,返回空子树高度 0。
- 递归求 root.left 的高度,保留该返回值。
- 递归求 root.right 的高度,等待两个子结果都完成。
- 返回两者最大值加 1;最外层返回值就是整棵树的最大深度。
代码(Python)
查看完整 Python 代码递归:子树高度 · 与上方图解对应
两个子问题返回的是局部高度,取最大值再加 1。空树返回 0 同时处理了叶子的孩子。
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
class Solution:
def maxDepth(self, root):
if root is None:
return 0
return 1 + max(self.maxDepth(root.left), self.maxDepth(root.right))
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
r=TreeNode(3,TreeNode(9),TreeNode(20,TreeNode(15),TreeNode(7)))
assert Solution().maxDepth(r)==3
assert Solution().maxDepth(None)==0
assert Solution().maxDepth(TreeNode(1))==1
assert Solution().maxDepth(TreeNode(1,None,TreeNode(2,None,TreeNode(3))))==3
复杂度
- 时间
- O(n),每节点一次。
- 空间
- O(h) 递归栈;退化树 h=n,深树可能超过 Python 递归深度。
易错点与边界
- 本题数节点,单节点深度为 1;直径题通常数边,口径不同。
- 树很宽时 BFS 队列会大;树很深时递归栈会深,选择取决于形状。
- 空子树在递归入口返回 0;不能把当前节点距整树根的深度当成它的子树高度。
主解之后,再比较与迁移。
其他解法 1
层序遍历:数完整层时间 O(n),每节点入队和出队各一次。
queue 保存等待按层处理的节点,depth 记录已经完成的层数。range(len(queue)) 在本轮开始时固定当前层的节点数量,循环中新入队的孩子留到下一轮。一整层处理完才将 depth 加一,而不是每弹出一个节点就加一。它避开递归深度限制,空间取决于最大层宽。
- 空树返回 0;否则令 queue 包含 root,depth=0。
- 在每轮开始用 len(queue) 固定这一层需要处理的节点数。
- 恰好弹出这些节点,将各自非空的左、右孩子追加队尾。
- 整层完成后令 depth 加一,继续处理下一层。
- 队列清空后返回 depth。
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
from collections import deque
class Solution:
def maxDepth(self, root):
if root is None:
return 0
queue, depth = deque([root]), 0
while queue:
for _ in range(len(queue)):
node = queue.popleft()
if node.left:
queue.append(node.left)
if node.right:
queue.append(node.right)
depth += 1
return depth
复杂度
- 时间
- O(n),每节点入队和出队各一次。
- 空间
- O(w),w 为最大层宽;不受递归层数限制。
这个方法的边界
- BFS 不能把 while queue 的每次单节点弹出都算作一层。
- 空树应在入队前处理,避免对 None 访问孩子。
- range(len(queue)) 必须在每轮开始固定本层数量;过程中加入的孩子留到下一轮,完成本层后 depth 才加一。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
r=TreeNode(3,TreeNode(9),TreeNode(20,TreeNode(15),TreeNode(7)))
assert Solution().maxDepth(r)==3
assert Solution().maxDepth(None)==0
assert Solution().maxDepth(TreeNode(1))==1
assert Solution().maxDepth(TreeNode(1,None,TreeNode(2,None,TreeNode(3))))==3
预测练习
节点 20 已收到左右两个 1,为什么返回 2 而不是 3?
一点提示
最大深度是一条向下路径,不能同时走入两个孩子。
答案与理由
取一侧较深的高度 max(1,1)=1,再加节点 20 自己,返回 2。1+1+1 把两条分叉相加,求的就不是最大深度。
面试追问与解法取舍
还可用显式 DFS 栈保存 (节点, 当前深度),每弹出一项更新最大值,再压入深度加一的孩子。它避免递归上限,额外空间通常按高度计;与 BFS 相比,更适合宽而浅的树。最大深度必须看所有分支,不能遇到第一个叶子就返回。
模板迁移 / 相似题
试着不看代码,复述这三点
- 空子树返回 0,叶子用同一公式自然得到 1。
- 父节点等两个返回值,再取 max 加自己;返回高度按节点数计。
- 每个节点计算一次,时间 O(n),递归栈空间由树高 h 决定。
226. 翻转二叉树 简单
一句话题意
把二叉树每个节点的左右子树交换,返回翻转后的根节点。
层序输入 [4,2,7,1,3,6,9];输出 [4,7,2,9,6,3,1];根及七个节点对象均为原对象。
生活化理解
给整棵家族树照镜子:每个分岔口的左右两条分支互换,但每位成员的身份和值不变。不是只交换最顶层两支;分支内部的每个分岔口也都要左右对调。
迭代 DFS:栈保存待交换节点
每个节点只做一次左右引用交换,显式待办栈与图中处理顺序一致,便于观察原地修改。核心思路
翻转的局部操作是交换 node.left 与 node.right。交换的是整棵子树的入口引用,节点值和身份都不变;如果对每个节点恰做一次,任意原路径中的左、右方向都会互换,得到完整镜像。只交换根还不够,根下面的内部节点也必须处理。
stack 保存已经发现但尚未完成交换的节点。每轮弹出 node,先同时交换它的两个孩子,再将交换后的非空 left、right 依次压栈。由于后进先出,新的 right 会先被处理:图中根 4 交换后压入 [7,2],下一节点就是 2。这是当前代码的确切顺序;它不需要等待孩子完成后再回到父节点。
每个非根节点由父节点在交换后压入一次,因此最终每个节点只交换一次,不会重复抵消。叶子的两个空引用交换后仍为空,也不再增加待办。图为追踪身份固定节点位置,实际结构要按边上的 L/R 标签读取。栈空后返回原 root 对象;最终根 4 的左侧是 7→(9,6),右侧是 2→(3,1)。
每轮完成当前节点的处理时,它的两个孩子引用已经交换,非空孩子也进入了待办栈。树中每个节点只有一个父节点,因此每个非空节点恰处理一次。所有节点完成局部交换后,任一原来的左路径都对应相同长度的右路径,整树成为镜像。
图解
- stack
- [4]
- 根的 left/right
- (2,7)
- 已交换节点
- []
- node
- 4
- 交换前 (left,right)
- ('2', '7')
- 交换后 (left,right)
- ('7', '2')
- stack
- [7, 2]
- 已交换节点
- [4]
- node
- 2
- 交换前 (left,right)
- ('1', '3')
- 交换后 (left,right)
- ('3', '1')
- stack
- [7, 3, 1]
- 已交换节点
- [4, 2]
- node
- 1
- 交换前 (left,right)
- (None, None)
- 交换后 (left,right)
- (None, None)
- stack
- [7, 3]
- 已交换节点
- [4, 2, 1]
- node
- 3
- 交换前 (left,right)
- (None, None)
- 交换后 (left,right)
- (None, None)
- stack
- [7]
- 已交换节点
- [4, 2, 1, 3]
- node
- 7
- 交换前 (left,right)
- ('6', '9')
- 交换后 (left,right)
- ('9', '6')
- stack
- [9, 6]
- 已交换节点
- [4, 2, 1, 3, 7]
- node
- 6
- 交换前 (left,right)
- (None, None)
- 交换后 (left,right)
- (None, None)
- stack
- [9]
- 已交换节点
- [4, 2, 1, 3, 7, 6]
- node
- 9
- 交换前 (left,right)
- (None, None)
- 交换后 (left,right)
- (None, None)
- stack
- []
- 已交换节点
- [4, 2, 1, 3, 7, 6, 9]
- stack
- []
- node
- 最后弹出的 9
- root
- 原节点 4
- 按实际 L/R 层序读取
- [4,7,2,9,6,3,1]
选择步骤 9 步
变量与图例
图中固定节点身份和位置,请跟随边上的 L/R:它们才是每个节点真正的 left/right。
- stack
- 等待交换孩子的节点,图中右端为下一次弹出者。
- node
- 本帧刚从栈弹出并交换的节点。
- left / right
- 真实孩子引用;图中用 L/R 边标签表示,屏幕位置固定以便追踪身份。
- root
- 原根对象,方法原地修改引用后返回它。
算法步骤
- 非空树令 stack=[root],空树令 stack=[]。
- 弹出一个 node,同时交换 node.left 与 node.right。
- 先将交换后的非空 left 压栈,再将非空 right 压栈,因此下一次优先处理新的右侧。
- 继续直到栈空,使所有节点各完成一次局部交换。
- 返回原 root;空树也直接返回 None。
代码(Python)
查看完整 Python 代码迭代 DFS:栈保存待交换节点 · 与上方图解对应
先交换,再把当前两个孩子压栈。顺序不影响最终镜像,因为每次操作只改当前节点的两条出边。
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
class Solution:
def invertTree(self, root):
stack = [root] if root else []
while stack:
node = stack.pop()
node.left, node.right = node.right, node.left
if node.left:
stack.append(node.left)
if node.right:
stack.append(node.right)
return root
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
def shape(node):
return None if node is None else (node.val,shape(node.left),shape(node.right))
r=TreeNode(4,TreeNode(2,TreeNode(1),TreeNode(3)),TreeNode(7,TreeNode(6),TreeNode(9)))
before=shape(r)
left,right=r.left,r.right
result=Solution().invertTree(r)
assert result is r and r.left is right and r.right is left
assert shape(result)==(4,(7,(9,None,None),(6,None,None)),(2,(3,None,None),(1,None,None)))
assert shape(Solution().invertTree(result))==before
assert Solution().invertTree(None) is None
one=TreeNode(8)
assert Solution().invertTree(one) is one
复杂度
- 时间
- O(n),每节点入栈、出栈各一次。
- 空间
- O(h) 显式栈上界,不使用递归,也不创建新树。
易错点与边界
- 只交换根的左右孩子无法完成整个镜像。
- 不能先 root.left=root.right,再把 root.right=root.left,原左指针已经丢失。
- 本迭代代码先交换当前节点,再压入交换后的非空 left 和 right;每个节点只能入栈处理一次。
主解之后,再比较与迁移。
其他解法 1
递归:返回镜像子树时间 O(n),每节点交换一次。
invertTree(root) 的契约是原地完成当前子树的镜像并返回原根。代码使用同时赋值,先计算原右子树的递归结果,再计算原左子树的递归结果,最后分别赋给 root.left 和 root.right。父节点的两个入口在右侧求值期间尚未被覆盖,所以第二次调用仍能取得原左子树;这与主解先交换父节点再压入孩子的执行顺序不同。
- root 为 None 时返回 None。
- 先递归翻转原 root.right,得到应放在新左侧的子树根。
- 再递归翻转原 root.left,得到应放在新右侧的子树根。
- 右侧两次调用都返回后,同时赋值给 root.left、root.right,返回原 root。
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
class Solution:
def invertTree(self, root):
if root is None:
return None
root.left, root.right = self.invertTree(root.right), self.invertTree(root.left)
return root
复杂度
- 时间
- O(n),每节点交换一次。
- 空间
- O(h) 递归栈;退化树可能超过 Python 递归深度,无额外输出节点。
这个方法的边界
- 本递归代码先求原右子树结果,再求原左子树结果,两次调用完成后才同时给 root.left、root.right 赋值。
- 两个子调用各处理一个原子树;不要在原语句外再递归翻转同一子树,否则会把已完成的镜像抵消。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
def shape(node):
return None if node is None else (node.val,shape(node.left),shape(node.right))
r=TreeNode(4,TreeNode(2,TreeNode(1),TreeNode(3)),TreeNode(7,TreeNode(6),TreeNode(9)))
before=shape(r)
left,right=r.left,r.right
result=Solution().invertTree(r)
assert result is r and r.left is right and r.right is left
assert shape(result)==(4,(7,(9,None,None),(6,None,None)),(2,(3,None,None),(1,None,None)))
assert shape(Solution().invertTree(result))==before
assert Solution().invertTree(None) is None
one=TreeNode(8)
assert Solution().invertTree(one) is one
预测练习
根 4 交换后先压 7 再压 2,下一次为什么处理 2?
一点提示
stack 是后进先出,右端为栈顶。
答案与理由
栈为 [7,2],最后压入的 2 先弹出。它是交换后根的 right;DFS 先处理哪一侧不改变“每个节点都交换一次”的结果。
面试追问与解法取舍
镜像不是排序操作:一棵二叉搜索树翻转后通常不再满足左小右大。若面试要求不修改输入,递归返回 TreeNode(node.val, copy(node.right), copy(node.left)) 即可构造新树;要明确这是增加 O(n) 输出空间的另一项需求。
其他思路的边界
- 不要把同一原子树翻转两次;先交换再递归时应使用新的左右指针。
- 翻转改变输入树;如果要求原树保留,那是构造镜像副本,额外输出空间为 O(n)。
模板迁移 / 相似题
试着不看代码,复述这三点
- 翻转交换节点引用,不交换节点值,也不新建一棵树。
- 只换根不够,每个内部节点的 left/right 都要交换。
- 栈先压 left 再压 right 时,right 先处理;遍历次序与正确性分开理解。
101. 对称二叉树 简单
一句话题意
判断二叉树的左右两半是否在结构和值上互为镜像。
层序输入 [1,2,2,3,4,4,3];左侧身份 A=2、C=3、D=4,右侧 B=2、E=4、F=3;输出 True。
生活化理解
像把纸上的树沿中线对折:左边靠外的枝条必须对上右边靠外的枝条,左边靠内的枝条对上右边靠内的枝条。不仅数字要相同,缺枝的位置也必须对应。
队列:逐对检查
队列明确保留镜像位置的节点对,值和空位置一起检查,容易讲清为什么逐层回文不够。核心思路
queue 中每项都是一对必须互为镜像的子树根 (a,b),而不是两个独立节点。非空根先把 (root.left,root.right) 入队;空根直接对称。弹出一对后,若至少一侧为空,就只判断两侧是否同为空:两个 None 匹配并继续,只有一个 None 立即失败。
两侧都非空时,先比较 a.val 与 b.val;值不同就返回 False。值相同还不足够,必须把 (a.left,b.right) 与 (a.right,b.left) 依次加入队尾。镜像把左方向变为右方向,所以对应位置必须交叉配对;图中先比较 A、B,再比较外侧 C、F 与内侧 D、E。
这些孩子即使为空也要成对入队,才能继续核对结构,不能先过滤空节点再只看每层的值。每次取出一对,要么发现反例,要么通过局部检查并把剩余条件交给它的两个子配对。队列清空说明所有对应位置都已通过,才返回 True。它比较的是对称关系,不修改树,也不要求成对节点是同一个对象。
queue 中每一项都是尚需验证的镜像位置对。每次取出时,空与空通过、仅一空立即失败;非空值相等后再追加两组交叉孩子。所有待办对都处理完,就说明左右子树每个对应位置的值和结构都匹配。
图解
- queue
- [(A,B)]
- 节点值
- A=2,B=2,C=3,D=4,E=4,F=3
- 结果
- 尚未决定
- a
- A
- b
- B
- 刚比较的位置
- 根的左右子树
- queue
- [(C,F),(D,E)]
- 本对结果
- 匹配
- a
- C
- b
- F
- 刚比较的位置
- A.left / B.right
- queue
- [(D,E),(None,None),(None,None)]
- 本对结果
- 匹配
- a
- D
- b
- E
- 刚比较的位置
- A.right / B.left
- queue
- [(None,None),(None,None),(None,None),(None,None)]
- 本对结果
- 匹配
- a
- None
- b
- None
- 刚比较的位置
- C.left / F.right
- queue
- [(None,None),(None,None),(None,None)]
- 本对结果
- 匹配
- a
- None
- b
- None
- 刚比较的位置
- C.right / F.left
- queue
- [(None,None),(None,None)]
- 本对结果
- 匹配
- a
- None
- b
- None
- 刚比较的位置
- D.left / E.right
- queue
- [(None,None)]
- 本对结果
- 匹配
- a
- None
- b
- None
- 刚比较的位置
- D.right / E.left
- queue
- []
- 本对结果
- 匹配
- queue
- []
- 非空配对
- (A,B),(C,F),(D,E)
- 空配对
- 4 对均匹配
- 输出
- True
选择步骤 9 步
变量与图例
把队列的每项看成“必须互为镜像的一对”;外侧对外侧、内侧对内侧,空孩子也要配对。
- queue
- 必须互为镜像的节点引用对,队首先处理,包含 (None,None)。
- a / b
- 当前从队列取出的两个镜像位置。
- 外侧 / 内侧
- (a.left,b.right) / (a.right,b.left),两种都必须检查。
- A…F
- 图中相同值节点的独立身份,避免把值相等当作同一节点。
算法步骤
- 空根返回 True;否则把根的左右孩子组成一对放入 queue。
- 从队首取出 a、b;若至少一侧为空,两个都空则继续,仅一侧为空则返回 False。
- 两者都非空时比较值,不同则返回 False。
- 按外侧、内侧的顺序追加 (a.left,b.right) 和 (a.right,b.left),保留空位置。
- 重复直到队列为空,返回 True。
代码(Python)
查看完整 Python 代码队列:逐对检查 · 与上方图解对应
queue 中每项是必须互为镜像的一对子树根。空节点也要成对参与检查,不能先过滤所有 None 再只比较值。
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
from collections import deque
class Solution:
def isSymmetric(self, root):
if root is None:
return True
queue = deque([(root.left, root.right)])
while queue:
a, b = queue.popleft()
if a is None or b is None:
if a is not b:
return False
continue
if a.val != b.val:
return False
queue.append((a.left, b.right))
queue.append((a.right, b.left))
return True
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
r=TreeNode(1,TreeNode(2,TreeNode(3),TreeNode(4)),TreeNode(2,TreeNode(4),TreeNode(3)))
assert Solution().isSymmetric(r) is True
bad=TreeNode(1,TreeNode(2,None,TreeNode(3)),TreeNode(2,None,TreeNode(3)))
assert Solution().isSymmetric(bad) is False
assert Solution().isSymmetric(None) is True
assert Solution().isSymmetric(TreeNode(1)) is True
assert Solution().isSymmetric(TreeNode(1,TreeNode(2))) is False
r.right.right.val=9
assert Solution().isSymmetric(r) is False
复杂度
- 时间
- O(n),每对节点检查常数次。
- 空间
- O(w),w 为最大层宽量级的待比较节点对数;避开递归上限。
易错点与边界
- 必须交叉配对子树,同方向比较得到的是“相同”,而非“对称”。
- 每层值是回文并不足够,省略空位置会把结构不同的树误判为对称。
- 两个 None 匹配,一个 None 与一个节点不匹配。
- 空树和单节点树都对称;重复值不能代替结构比较。
主解之后,再比较与迁移。
其他解法 1
递归:定义两树镜像关系时间 O(n) 最坏时间;发现不匹配时可提前返回。
mirror(a,b) 返回两棵子树是否互为镜像。至少一侧为空时返回 a is b,只有同时为空才成立;两侧非空时,值相等且两组交叉子树都互为镜像才成立。and 按从左到右短路,一旦值或某组子树失败,就不再继续另一组。它用调用栈承接子条件,空间随树高增长,深树可能触发递归限制。
- 空根直接返回 True,否则调用 mirror(root.left,root.right)。
- mirror 内先处理 None:两个都空返回 True,仅一个空返回 False。
- 非空节点先要求 a.val==b.val。
- 先递归比较 a.left 与 b.right,再比较 a.right 与 b.left;三个条件以 and 连接并返回。
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
class Solution:
def isSymmetric(self, root):
def mirror(a, b):
if a is None or b is None:
return a is b
return (a.val == b.val
and mirror(a.left, b.right)
and mirror(a.right, b.left))
return root is None or mirror(root.left, root.right)
复杂度
- 时间
- O(n) 最坏时间;发现不匹配时可提前返回。
- 空间
- O(h) 递归栈;高度过大可能超过 Python 递归限制。
这个方法的边界
- 递归先处理 None 再读取 val;两个非空节点值相等后,两组交叉子调用仍必须都返回 True。
- 空位置的匹配靠 a is b,不能将两个非空节点也要求为同一个对象;它们通常是不同节点。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
r=TreeNode(1,TreeNode(2,TreeNode(3),TreeNode(4)),TreeNode(2,TreeNode(4),TreeNode(3)))
assert Solution().isSymmetric(r) is True
bad=TreeNode(1,TreeNode(2,None,TreeNode(3)),TreeNode(2,None,TreeNode(3)))
assert Solution().isSymmetric(bad) is False
assert Solution().isSymmetric(None) is True
assert Solution().isSymmetric(TreeNode(1)) is True
assert Solution().isSymmetric(TreeNode(1,TreeNode(2))) is False
r.right.right.val=9
assert Solution().isSymmetric(r) is False
预测练习
若 F(3) 被换成空,比较 (C,F) 时会发生什么?
一点提示
C 仍是节点,而另一边为 None,先检查空引用,再读 val。
答案与理由
立即返回 False:a 或 b 为空且 a is not b,两个镜像位置的存在性不同。即使其余节点值恰好成回文,也不能弥补这处结构缺口。
面试追问与解法取舍
反例可以取根的左右孩子都为 2,两个 2 都只有右孩子 3。逐层非空值是 [1]、[2,2]、[3,3],每层都回文,整树却不对称:左侧外侧为空,而右侧外侧是 3。成对递归会直接抓到这一结构差异。
模板迁移 / 相似题
试着不看代码,复述这三点
- 镜像比较走交叉孩子,同方向递归比较的是相同树。
- None 不能先过滤:两空匹配,一空一节点立即失败。
- 队列存节点对而不是一层纯值,值与结构在同一流程里验证。
543. 二叉树的直径 简单
一句话题意
求二叉树任意两节点间最长简单路径的边数,这条路径不一定经过根。
树:1.left=2;2.left=3,2.right=5;3.left=4;5.right=6,其余孩子为空。输出直径 4,路径 4→3→2→5→6 不经过根 1。
生活化理解
把树当成分岔步道网络,想找不回头重复走的最长线路。在一个岔口,完整候选线路可以从左边最远处来到这里,再走到右边最远处;但若要接到父岔口,就只能携带左、右中较长的一条,不能带着一个分叉往上走。
后序递归:返回单支,更新双支
一次后序遍历同时得到子树高度和全局直径,能直接解释返回值与答案为什么不同。核心思路
height(node) 返回从 node 向下的最长单支高度,按节点数计;best 则记录已经处理的所有转折点中,最长简单路径的边数。两者不是同一个量。空子树高度为 0,先递归取得 left=height(node.left) 和 right=height(node.right),再计算当前节点。
经过 node 的候选直径为 left+right。左子树的节点高度 left,恰好等于从 node 走到左侧最深节点的边数;右侧同理。用这个双支候选更新 best 后,向父节点只能返回 1+max(left,right),因为父节点要继续延伸的是一条单支。若把左右之和往上传,父节点会接上已经分叉的路径,形成不合法的三叉结构。
每条最长路径都有一个离根最近的转折节点,在那个节点计算 left+right 时必然被考虑,所以答案可能完全位于某个子树。图中节点 2 的左右高度均为 2,得到 4→3→2→5→6 共 4 条边;根 1 的候选只有 3 条边,不能覆盖 best=4。最外层调用 height(root) 是为遍历并更新 best,方法最终返回 best,而不是根的高度。
height(node) 只返回可向父节点继续延伸的单支高度,空树为 0,当前节点返回 1+max(left,right)。best 则在每个节点用 left+right 更新,它按边数记录允许在此转折的双支路径。任意最长路径都有一个最高转折点,因此后序检查所有节点能找到不经过根的答案。
图解
- node
- 1
- 调用栈
- [1]
- left
- 待返回
- right
- 待返回
- 当前候选(边)
- 待计算
- best(边)
- 0
- 本次返回高度(节点)
- 待返回
- node
- 2
- 调用栈
- [1, 2]
- left
- 待返回
- right
- 待返回
- 当前候选(边)
- 待计算
- best(边)
- 0
- 本次返回高度(节点)
- 待返回
- node
- 3
- 调用栈
- [1, 2, 3]
- left
- 待返回
- right
- 待返回
- 当前候选(边)
- 待计算
- best(边)
- 0
- 本次返回高度(节点)
- 待返回
- node
- 4
- 调用栈
- [1, 2, 3, 4]
- left
- 待返回
- right
- 待返回
- 当前候选(边)
- 待计算
- best(边)
- 0
- 本次返回高度(节点)
- 待返回
- node
- 4
- 调用栈
- [1, 2, 3, 4]
- left
- 0
- right
- 0
- 当前候选(边)
- 0
- best(边)
- 0
- 本次返回高度(节点)
- 1
- node
- 3
- 调用栈
- [1, 2, 3]
- left
- 1
- right
- 0
- 当前候选(边)
- 1
- best(边)
- 1
- 本次返回高度(节点)
- 2
- node
- 5
- 调用栈
- [1, 2, 5]
- left
- 待返回
- right
- 待返回
- 当前候选(边)
- 待计算
- best(边)
- 1
- 本次返回高度(节点)
- 待返回
- node
- 6
- 调用栈
- [1, 2, 5, 6]
- left
- 待返回
- right
- 待返回
- 当前候选(边)
- 待计算
- best(边)
- 1
- 本次返回高度(节点)
- 待返回
- node
- 6
- 调用栈
- [1, 2, 5, 6]
- left
- 0
- right
- 0
- 当前候选(边)
- 0
- best(边)
- 1
- 本次返回高度(节点)
- 1
- node
- 5
- 调用栈
- [1, 2, 5]
- left
- 0
- right
- 1
- 当前候选(边)
- 1
- best(边)
- 1
- 本次返回高度(节点)
- 2
- node
- 2
- 调用栈
- [1, 2]
- left
- 2
- right
- 2
- 当前候选(边)
- 4
- best(边)
- 4
- 本次返回高度(节点)
- 3
- node
- 1
- 调用栈
- [1]
- left
- 3
- right
- 0
- 当前候选(边)
- 3
- best(边)
- 4
- 本次返回高度(节点)
- 4
选择步骤 12 步
变量与图例
逐层看两种不同的数:向父亲返回的单支高度,以及用左右两支更新的全局最长边数。
- node / 调用栈
- 当前节点及尚未返回的祖先,树上紫色表示本帧的计算位置。
- left / right
- 左右子树返回的节点高度。
- 返回高度 h
- 1+max(left,right),只带一支向父节点延伸。
- left+right
- 经过当前节点的候选路径边数;不一定是全局最大。
- best
- 所有已完成节点中的最大候选边数,单调不减。
算法步骤
- 在方法内初始化 best=0,定义返回节点高度的 height(node)。
- 空节点返回 0;非空节点先递归求左高 left,再求右高 right。
- 左右结果都就绪后,用 left+right 更新全局 best。
- 向父调用返回 1+max(left,right),只携带可以继续延长的单支高度。
- 从根调用 height,整树后序处理结束后返回 best。
代码(Python)
查看完整 Python 代码后序递归:返回单支,更新双支 · 与上方图解对应
height 返回可继续向父亲延伸的单支长度,best 记录任意转折点的双支路径。统一使用节点高度,直径通过 l+r 自动换成边数。
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
class Solution:
def diameterOfBinaryTree(self, root):
best = 0
def height(node):
nonlocal best
if node is None:
return 0
left = height(node.left)
right = height(node.right)
best = max(best, left + right)
return 1 + max(left, right)
height(root)
return best
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
r=TreeNode(1,TreeNode(2,TreeNode(4),TreeNode(5)),TreeNode(3))
assert Solution().diameterOfBinaryTree(r)==3
assert Solution().diameterOfBinaryTree(TreeNode(1))==0
assert Solution().diameterOfBinaryTree(TreeNode(1,TreeNode(2)))==1
# Longest path stays strictly below the root: 4-3-2-5-6 has four edges.
r=TreeNode(1,TreeNode(2,TreeNode(3,TreeNode(4)),TreeNode(5,None,TreeNode(6))))
assert Solution().diameterOfBinaryTree(r)==4
assert Solution().diameterOfBinaryTree(None)==0
复杂度
- 时间
- O(n),每节点后序计算一次。
- 空间
- O(h) 递归栈;退化树可能达到 n 层并触发 Python 递归深度限制。
易错点与边界
- 直径数边:单节点直径为 0,不是 1。
- 答案可能藏在某个子树里,不能只计算根的左高加右高。
- 向父亲返回单支高度,不能返回左右高度之和。
- 每个节点重新从头计算左右深度,退化树会重复扫描达到 O(n²)。
主解之后,再比较与迁移。
其他解法 1
显式后序栈 + 高度表时间 O(n),每节点入栈两次,哈希访问按平均 O(1) 计。
stack 的 (node,expanded) 把首次进入与孩子完成后的返回阶段分开。expanded=False 时先预约 (node,True),再压入右孩子和左孩子,使左、右都在父节点返回阶段之前处理。expanded=True 时从 heights 读取两侧高度,更新 best,并保存当前节点的单支高度。heights 按节点身份建键,None 的高度预设为 0;它避开递归上限,但需要 O(n) 高度表。
- 空树返回 0;否则令 stack=[(root,False)]、heights={None:0}、best=0。
- 弹出未展开项时,先压回 (node,True),再依次压入非空右、左孩子的未展开项。
- 弹出 expanded=True 的项时,左右孩子均已处理,从 heights 读取它们的高度。
- 用左右高度之和更新 best,并把 1+max(left,right) 写入 heights[node]。
- 栈空后返回 best,不把根高度当作直径。
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
class Solution:
def diameterOfBinaryTree(self, root):
if root is None:
return 0
stack = [(root, False)]
heights = {None: 0}
best = 0
while stack:
node, expanded = stack.pop()
if not expanded:
stack.append((node, True))
if node.right:
stack.append((node.right, False))
if node.left:
stack.append((node.left, False))
else:
left, right = heights[node.left], heights[node.right]
best = max(best, left + right)
heights[node] = 1 + max(left, right)
return best
复杂度
- 时间
- O(n),每节点入栈两次,哈希访问按平均 O(1) 计。
- 空间
- O(n) 高度表加 O(h) 显式栈,总 O(n);不受递归调用深度限制。
这个方法的边界
- 后序迭代必须等孩子完成才读取高度,普通先序出栈不能直接计算。
- expanded=False 时先压回当前节点的返回阶段,再压孩子;只有 expanded=True 时才读取孩子高度。
- heights 按节点身份而不是 val 建键,重复值节点不能共用同一高度项;None 的高度预设为 0。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
r=TreeNode(1,TreeNode(2,TreeNode(4),TreeNode(5)),TreeNode(3))
assert Solution().diameterOfBinaryTree(r)==3
assert Solution().diameterOfBinaryTree(TreeNode(1))==0
assert Solution().diameterOfBinaryTree(TreeNode(1,TreeNode(2)))==1
# Longest path stays strictly below the root: 4-3-2-5-6 has four edges.
r=TreeNode(1,TreeNode(2,TreeNode(3,TreeNode(4)),TreeNode(5,None,TreeNode(6))))
assert Solution().diameterOfBinaryTree(r)==4
assert Solution().diameterOfBinaryTree(None)==0
预测练习
节点 2 的 left=2、right=2,为什么不能把 4 返回给根 1 当作高度?
一点提示
4 表示左右相连的双支路径,父节点只能延伸一支。
答案与理由
高度必须返回 1+max(2,2)=3。把 left+right=4 作为高度会让父节点把已含两条分支的路径再接一条,产生不是简单路径的三叉;4 只用来更新 best。
面试追问与解法取舍
若还要返回具体路径,可在计算高度时记录每个节点选择的较深孩子,并记住 best 对应的转折节点;最后分别沿两侧选择链回溯拼接。仍是 O(n) 时间,但需要保存路径选择信息。不要为求直径先计算所有节点对距离,那会把局部后序问题变成不必要的平方级搜索。
模板迁移 / 相似题
试着不看代码,复述这三点
- 空高 0、叶高 1,left+right 恰好换成经过当前节点的边数。
- 向上返回单支,更新答案用双支;两种状态不能合并。
- best 在所有节点更新,最长路径可以位于严格子树中而不经过根。
102. 二叉树的层序遍历 中等
一句话题意
把二叉树按深度从浅到深分组,每层按从左到右的顺序返回节点值。
root = [3,9,20,null,null,15,7](层序);输出 [[3],[9,20],[15,7]]。
生活化理解
像学校按年级分批进礼堂:队列是等候区,一轮开始时先数清当前年级的人数;他们带来的下一年级学生排到队尾,但不能混进本轮。节点深度对应年级,孩子入队对应下一批报到。
队列 BFS:按层截取
队列直接对应层序输出,用固定层宽划清边界,容易从图解复现代码。核心思路
队列 queue 的一轮开始时只包含当前层,且顺序从左到右。固定 size = len(queue) 后恰好弹出 size 次,期间加入的孩子全部留给下一轮。这个“拍快照”的动作把连续队列切成了层;本轮队列长度虽然会变化,size 不变。
每次把出队节点的值加入本轮的 level,再先加入左孩子、后加入右孩子。当前层父节点本来从左到右有序,孩子也因此按从左到右的顺序进入下一层。完成 size 次处理后,队列恰好只剩下一层,重新满足循环不变量。
answer 只保存已经完整结束的层。每轮结束才把 level 追加到 answer,下一轮创建新的 level 列表;即使处理最后一个叶子后队列已空,这一层也必须先追加,再结束外层循环。使用 deque 的 popleft,出队无需搬移后面的元素。
每轮开始时 queue 恰好按从左到右保存当前层。固定 size 后只弹出原来的 size 个节点,后来入队的孩子留到下一轮;先左后右入队又保持下一层的顺序。
图解
- queue
- [3]
- size
- 尚未固定
- level
- 尚未创建
- answer
- []
- queue
- [3]
- size
- 1
- level
- []
- answer
- []
- queue
- [9,20]
- size
- 1
- level
- [3]
- answer
- []
- queue
- [9,20]
- size
- 1
- level
- [3]
- answer
- [[3]]
- queue
- [9,20]
- size
- 2
- level
- []
- answer
- [[3]]
- queue
- [20]
- size
- 2
- level
- [9]
- answer
- [[3]]
- queue
- [15,7]
- size
- 2
- level
- [9,20]
- answer
- [[3]]
- queue
- [15,7]
- size
- 2
- level
- []
- answer
- [[3],[9,20]]
- queue
- [7]
- size
- 2
- level
- [15]
- answer
- [[3],[9,20]]
- queue
- []
- size
- 2
- level
- [15,7]
- answer
- [[3],[9,20]]
- queue
- []
- size
- 2
- level
- [15,7]
- answer
- [[3],[9,20],[15,7]]
选择步骤 11 步
变量与图例
观察固定的 size 与不断变化的 queue:新入队的孩子为什么不会混入当前层?
- queue
- 先进先出队列,图中左侧是队首。
- size
- 本轮开始时的队列长度,本轮内不再变化。
- node
- 这次刚从队首弹出的节点。
- level
- 当前层已经弹出的节点值。
- answer
- 已经完整结束的各层结果。
算法步骤
- 空树返回 [];否则将根放入 deque,创建空 answer。
- 每轮开始固定 size=len(queue),并新建本层的 level=[]。
- 恰好出队 size 次:将节点值加入 level,非空孩子按左、右顺序入队。
- 将完整 level 追加到 answer;队列非空则开始下一层,最后返回 answer。
代码(Python)
查看完整 Python 代码队列 BFS:按层截取 · 与上方图解对应
size 是本轮边界,deque 的 popleft 为常数时间;用列表 pop(0) 会反复搬移元素。
from collections import deque
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
class Solution:
def levelOrder(self, root):
if root is None:
return []
queue = deque([root])
answer = []
while queue:
size = len(queue)
level = []
for _ in range(size):
node = queue.popleft()
level.append(node.val)
if node.left is not None:
queue.append(node.left)
if node.right is not None:
queue.append(node.right)
answer.append(level)
return answer
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
r = TreeNode(3, TreeNode(9), TreeNode(20, TreeNode(15), TreeNode(7)))
assert Solution().levelOrder(r) == [[3], [9, 20], [15, 7]]
assert Solution().levelOrder(None) == []
assert Solution().levelOrder(TreeNode(1, None, TreeNode(2))) == [[1], [2]]
复杂度
- 时间
- O(n),n 为节点数,每个节点入队、出队各一次。
- 空间
- 辅助 O(w),w 为最大层宽;返回结果 O(n),无递归栈。
易错点与边界
- 必须固定本轮 size;动态处理队列直到空会把多层混成一层。
- 先左后右入队,才能保持每层从左到右。
- 空树输出 [],不是 [[]];单节点只有一层。
- 本主解的队列峰值由树的最大层宽决定,不能按树高估算。
主解之后,再比较与迁移。
其他解法 1
DFS:用深度作为结果下标时间 O(n),每个节点访问一次。
用递归参数 depth 把节点值投递到 answer[depth],而不是用队列分批访问。depth==len(answer) 表示首次到达新深度,需要先创建该层列表;已有层继续追加。按根、左、右访问时,左侧分支在同一深度始终先于右侧分支,因此每层仍从左到右排列。DFS 的实际访问时间顺序与最终按层分组的输出顺序不必一致,适合已经在递归中维护深度的场景。
- 创建 answer=[],调用 visit(root,0)。
- 遇到空节点直接返回;若 depth 等于 answer 长度,先追加一个空层。
- 把 node.val 追加到 answer[depth]。
- 以 depth+1 先递归左孩子,再递归右孩子;遍历完成后返回 answer。
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
class Solution:
def levelOrder(self, root):
answer = []
def visit(node, depth):
if node is None:
return
if depth == len(answer):
answer.append([])
answer[depth].append(node.val)
visit(node.left, depth + 1)
visit(node.right, depth + 1)
visit(root, 0)
return answer
复杂度
- 时间
- O(n),每个节点访问一次。
- 空间
- 辅助递归栈 O(h),h 为树高;输出 O(n)。链形树 h=n,可能超过 Python 递归深度限制。
这个方法的边界
- BFS 空间取决于宽度,DFS 栈空间取决于高度。
- DFS 必须先访问左子树再访问右子树,才能让 answer[depth] 保持从左到右。
- 只在 depth==len(answer) 时新建一层,不能每到一个节点都追加新层;链形树还可能超过 Python 递归深度。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
r = TreeNode(3, TreeNode(9), TreeNode(20, TreeNode(15), TreeNode(7)))
assert Solution().levelOrder(r) == [[3], [9, 20], [15, 7]]
assert Solution().levelOrder(None) == []
assert Solution().levelOrder(TreeNode(1, None, TreeNode(2))) == [[1], [2]]
预测练习
处理 20 后 queue=[15,7],为什么这两个节点不能继续写进 [9,20]?
一点提示
这一轮的 for range(size) 还剩几次?
答案与理由
本轮 size 固定为 2,9 和 20 已消耗两次循环。15、7 虽然已入队,却只能在下一轮的新 level 中处理。
面试追问与解法取舍
如果只需逐层消费而不保留完整结果,可以每轮 yield level,将结果存储从 O(n) 降为一层规模;队列仍需要 O(w)。若问锯齿形层序,可按层奇偶反转输出,孩子入队顺序保持不变。
模板迁移 / 相似题
试着不看代码,复述这三点
- 层边界来自循环开始时的 size 快照。
- 先左后右入队,把父节点顺序传给下一层。
- 答案在一整层处理完后追加,单个节点出队时只改 level。
108. 将有序数组转换为二叉搜索树 简单
一句话题意
把严格递增数组构造成高度平衡的二叉搜索树,允许有多个正确树形。
nums = [-10,-3,0,5,9];本代码选偏左中点,输出树层序为 [0,-10,5,null,-3,null,9]。
生活化理解
像给按号码排好的住户分服务站:中间住户担任总站,左半区和右半区再各选中间人。数组左右顺序保证号码大小关系,反复对半分保证两边工作层数接近。
递归分治:只传数组边界
中点划分同时表达搜索顺序与高度平衡,区间递归简洁且无需复制数组。核心思路
子问题 build(lo, hi) 只负责半开区间 nums[lo:hi],但代码仅传下标,不真的复制切片。它返回覆盖该区间全部元素的平衡搜索树。空区间返回 None;非空区间选中点 mid,新建值为 nums[mid] 的根。
数组严格递增,所以左区间所有值小于根,右区间所有值大于根。分别递归构建 [lo,mid) 与 [mid+1,hi),再接到 node.left、node.right,就保留了整棵子树的 BST 顺序。父节点收到的是子调用完成后返回的子树根,子树内部已经连接好的节点会一起接入。
每次近似对半划分,使左右区间长度最多差一;各子区间继续采用同样规则,最终每个节点的左右子树高度差至多一。代码用 mid=(lo+hi-1)//2 选择偶数长度区间的左中点,与图中 [-10,-3] 先建 -10 一致。合法答案的形状不唯一,但这份代码和图解始终采用同一中点规则。
build(lo,hi) 只使用有序数组的 [lo,hi) 区间,并返回覆盖该区间的平衡搜索树。偏左中点将剩余元素分成大小最多相差 1 的两段;左段全部更小、右段全部更大,递归保持 BST 与平衡性质。
图解
- 区间
- [0,5)
- mid
- —
- 调用栈
- build(0,5)
- 已连接的边
- []
- 当前状态
- 尚未创建节点
- 区间
- [0,5)
- mid
- 2
- 调用栈
- build(0,5)
- 已连接的边
- []
- 当前状态
- node = 0;等左子树
- 区间
- [0,2)
- mid
- 0
- 调用栈
- build(0,5) → build(0,2)
- 已连接的边
- []
- 当前状态
- node = -10;暂未接父节点
- 区间
- [0,0)
- mid
- —
- 调用栈
- build(0,5) → build(0,2)
- 已连接的边
- []
- 当前状态
- -10.left = None
- 区间
- [1,2)
- mid
- 1
- 调用栈
- build(0,5) → build(0,2) → build(1,2)
- 已连接的边
- []
- 当前状态
- node = -3;尚未返回
- 区间
- [0,2)
- mid
- 0
- 调用栈
- build(0,5) → build(0,2)
- 已连接的边
- [('-10', '-3')]
- 当前状态
- build(1,2) → -3
- 区间
- [0,5)
- mid
- 2
- 调用栈
- build(0,5)
- 已连接的边
- [('0', '-10'), ('-10', '-3')]
- 当前状态
- 0.left = -10
- 区间
- [3,5)
- mid
- 3
- 调用栈
- build(0,5) → build(3,5)
- 已连接的边
- [('0', '-10'), ('-10', '-3')]
- 当前状态
- node = 5;等左子树
- 区间
- [3,3)
- mid
- —
- 调用栈
- build(0,5) → build(3,5)
- 已连接的边
- [('0', '-10'), ('-10', '-3')]
- 当前状态
- 5.left = None
- 区间
- [4,5)
- mid
- 4
- 调用栈
- build(0,5) → build(3,5) → build(4,5)
- 已连接的边
- [('0', '-10'), ('-10', '-3')]
- 当前状态
- node = 9;尚未返回
- 区间
- [3,5)
- mid
- 3
- 调用栈
- build(0,5) → build(3,5)
- 已连接的边
- [('0', '-10'), ('-10', '-3'), ('5', '9')]
- 当前状态
- 5.right = 9
- 区间
- 已完成
- mid
- —
- 调用栈
- []
- 已连接的边
- [('0', '-10'), ('-10', '-3'), ('0', '5'), ('5', '9')]
- 当前状态
- 输出根 = 0
选择步骤 12 步
变量与图例
同时看数组的半开区间与已连接的树:当前节点在子树返回后才接上孩子。
- lo, hi
- 当前递归负责的半开区间 [lo,hi),hi 不含在内。
- mid
- 偏左中点 (lo+hi-1)//2。
- node
- 本次调用新建的根,子调用返回前不会接到父节点。
- 调用栈
- 图中从外到内列出尚未返回的 build 区间。
- 返回值
- 完成区间后返回的子树根,空区间返回 None。
算法步骤
- 调用 build(0,len(nums)),用 [lo,hi) 表示当前子树负责的范围。
- 若 lo>=hi,返回 None;否则计算偏左中点 mid=(lo+hi-1)//2。
- 新建 node=TreeNode(nums[mid]),递归构建 [lo,mid),把返回根接到 node.left。
- 递归构建 [mid+1,hi),把返回根接到 node.right。
- 返回当前 node,最外层得到整棵平衡搜索树的根。
代码(Python)
查看完整 Python 代码递归分治:只传数组边界 · 与上方图解对应
lo、hi 精确描述当前子树拥有的值;不做切片,每个数组位置只创建一个节点。
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
class Solution:
def sortedArrayToBST(self, nums):
def build(lo, hi):
if lo >= hi:
return None
mid = (lo + hi - 1) // 2
node = TreeNode(nums[mid])
node.left = build(lo, mid)
node.right = build(mid + 1, hi)
return node
return build(0, len(nums))
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
def values(node):
return values(node.left) + [node.val] + values(node.right) if node else []
def height(node):
if node is None:
return 0
left, right = height(node.left), height(node.right)
assert abs(left - right) <= 1
return max(left, right) + 1
for nums in ([-10, -3, 0, 5, 9], [1], [1, 2], list(range(16))):
result = Solution().sortedArrayToBST(nums)
assert values(result) == nums
assert height(result) >= 1
assert Solution().sortedArrayToBST([]) is None
复杂度
- 时间
- O(n),n 为数组长度,每个元素创建一次节点。
- 空间
- 辅助栈 O(log n),输出树 O(n)。构造过程始终平衡,不会出现链形递归深度。
易错点与边界
- 平衡指每个节点的高度差至多一,并非所有叶子必须同层。
- 偶数长度可以选任一中点;测试不应要求固定根。
- [lo, hi) 的右边界不属于区间,递归边界要一致。
- 本主解只传 lo、hi 下标;递归调用不创建数组切片。
主解之后,再比较与迁移。
其他解法 1
显式栈:保存待连接的区间时间 O(n),每个非空区间创建一个节点。
把递归调用改成 tasks 中的区间任务,(lo,hi,parent,side) 说明该区间构成的子树要接到哪个父节点的哪一侧。选中点的规则与主解一致,BST 与平衡性的依据也不变。区别在于创建节点后立即用 setattr(parent,side,node) 连接,再把孩子的构建任务压栈;显式记录 parent 和 side 代替递归返回时的赋值,便于观察待办任务。
- 空数组返回 None;否则用整个数组的偏左中点创建 root。
- 向 tasks 依次压入根的右区间任务和左区间任务,使左任务先出栈。
- 弹出 (lo,hi,parent,side);空区间跳过,保留默认的空孩子指针。
- 非空区间取偏左中点创建 node,并用 setattr 将它接到 parent 的 side 一侧。
- 依次压入 node 的右、左区间任务;任务处理完后返回 root。
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
class Solution:
def sortedArrayToBST(self, nums):
if not nums:
return None
mid = (len(nums) - 1) // 2
root = TreeNode(nums[mid])
tasks = [(mid + 1, len(nums), root, "right"),
(0, mid, root, "left")]
while tasks:
lo, hi, parent, side = tasks.pop()
if lo >= hi:
continue
mid = (lo + hi - 1) // 2
node = TreeNode(nums[mid])
setattr(parent, side, node)
tasks.append((mid + 1, hi, node, "right"))
tasks.append((lo, mid, node, "left"))
return root
复杂度
- 时间
- O(n),每个非空区间创建一个节点。
- 空间
- 辅助显式栈 O(log n),输出树 O(n);没有 Python 递归限制。
这个方法的边界
- 显式任务必须同时保存区间、parent 和 side;创建节点后要接到该父节点指定的一侧。
- 本代码右区间先压、左区间后压,使左侧先处理;偶数长度采用与主解一致的偏左中点。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
def values(node):
return values(node.left) + [node.val] + values(node.right) if node else []
def height(node):
if node is None:
return 0
left, right = height(node.left), height(node.right)
assert abs(left - right) <= 1
return max(left, right) + 1
for nums in ([-10, -3, 0, 5, 9], [1], [1, 2], list(range(16))):
result = Solution().sortedArrayToBST(nums)
assert values(result) == nums
assert height(result) >= 1
assert Solution().sortedArrayToBST([]) is None
预测练习
区间 [0,2) 对应 [-10,-3],这份代码会选谁作根?
一点提示
代入 (lo+hi-1)//2,而不是 (lo+hi)//2。
答案与理由
mid=(0+2-1)//2=0,选 -10,-3 成为右孩子。两节点高度差仍为 1;选 -3 也能构成合法树,但不是本时间线执行的代码。
面试追问与解法取舍
若输入改成有序链表,不能 O(1) 随机读中点。可以先求长度,用中序建树一边前进链表指针一边填值,在 O(n) 时间完成;这是第 109 题的拓展,不必先转换数组。
其他思路的边界
- Python nums[:mid] 会复制列表;整棵平衡递归树的切片总工作为 O(n log n),本实现避免了这笔成本。
模板迁移 / 相似题
试着不看代码,复述这三点
- 明确半开区间,空区间由 lo>=hi 统一结束。
- 选中点兼顾搜索顺序与两侧高度,不需要插入后再旋转。
- 父节点接收的是递归返回的子树根,而不是尚未建完的局部节点。
98. 验证二叉搜索树 中等
一句话题意
判断整棵树是否满足严格二叉搜索树关系:任一节点的整个左子树更小、整个右子树更大。
root = [5,1,8,null,null,4,9](层序);输出 false。4 虽小于父节点 8,却位于根 5 的右子树。
生活化理解
像层层转发的价格审批区间:总部说价格必须大于 5,分部再说必须小于 7,最终价格就只能落在 (5,7)。不能到了分部就忘记总部的限制;节点 4 虽小于父亲 7,却违反祖先 5 的要求。
上下界递归
把祖先约束显式传给后代,能直接解释只比较父子为什么不足以验证 BST。核心思路
只比较父子是局部检查,BST 却要求一个节点的整棵左子树更小、整棵右子树更大。递归参数 low, high 是所有祖先限制的交集,当前值必须严格位于开区间 (low,high)。根以负无穷和正无穷作为初始边界,不会把合法的整数极值排除在外。
check(node,low,high) 检查当前子树是否既满足 BST 结构,又遵守继承的范围。当前值合规后,左递归继承 low、把 high 收紧为 node.val;右递归把 low 收紧为 node.val、继承 high。因此每个后代会同时接受父节点和更早祖先的约束,相等也会失败。
图中 4 是 8 的左孩子,虽满足 4 小于 8,但 8 位于根 5 的右子树,因此传给 4 的范围是 (5,8),它必须失败。空子树返回 True;代码用 and 合并两侧结果,左侧失败时会跳过右侧检查。未访问的节点无需被判为正确,一处反例已经足以使整棵树返回 False。
check(node,low,high) 要求当前整棵子树的每个节点都严格位于 (low,high)。进入左子树只收紧上界,进入右子树只收紧下界,因此更早祖先的约束会一路保留;任一节点不在区间内即可否定整棵树。
图解
- node
- 5
- 祖先开区间
- (-∞,+∞)
- 调用栈
- 5 (-∞,+∞)
- 返回值
- 待检查
- node
- 1
- 祖先开区间
- (-∞,5)
- 调用栈
- 5 等左 → 1 (-∞,5)
- 返回值
- 待检查
- node
- None(1.left)
- 祖先开区间
- (-∞,1)
- 调用栈
- 5 等左 → 1 等右
- 返回值
- True
- node
- None(1.right)
- 祖先开区间
- (1,5)
- 调用栈
- 5 等左 → 1 合并结果
- 返回值
- True
- node
- 8
- 祖先开区间
- (5,+∞)
- 调用栈
- 5 等右 → 8 (5,+∞)
- 返回值
- 待检查
- node
- 4
- 祖先开区间
- (5,8)
- 调用栈
- 5 等右 → 8 等左 → 4 (5,8)
- 返回值
- 待比较
- node
- 4(刚返回)
- 祖先开区间
- (5,8)
- 调用栈
- 5 等右 → 8 等左
- 返回值
- False
- node
- 8(刚返回)
- 祖先开区间
- (5,+∞)
- 调用栈
- 5 等右
- 返回值
- False
- node
- 5(已返回)
- 祖先开区间
- (-∞,+∞)
- 调用栈
- []
- 返回值
- False
选择步骤 9 步
变量与图例
追踪从祖先继承的开区间:走到 4 时,下界仍必须是根节点的 5。
- low, high
- 所有祖先共同传下来的严格上下界,不只是父节点值。
- node
- 当前 check 的节点,None 是空子树。
- 调用栈
- 还在等待左右检查结果的祖先。
- 返回值
- 当前子树是否满足全部祖先界限。
算法步骤
- 调用 check(root,-∞,+∞),将所有祖先约束表示为开区间。
- 空节点返回 True;非空节点若不满足 low<node.val<high,立即返回 False。
- 递归检查左子树,传入 (low,node.val)。
- 左侧通过后,再检查右子树,传入 (node.val,high);两侧都通过才返回 True。
代码(Python)
查看完整 Python 代码上下界递归 · 与上方图解对应
low、high 携带所有祖先留下的限制,使用无穷界避免把合法极值错误当作越界。
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
class Solution:
def isValidBST(self, root):
def check(node, low, high):
if node is None:
return True
if not low < node.val < high:
return False
return (check(node.left, low, node.val)
and check(node.right, node.val, high))
return check(root, float("-inf"), float("inf"))
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert Solution().isValidBST(TreeNode(2, TreeNode(1), TreeNode(3)))
bad = TreeNode(5, TreeNode(1), TreeNode(7, TreeNode(4), TreeNode(8)))
assert not Solution().isValidBST(bad)
assert not Solution().isValidBST(TreeNode(2, TreeNode(2)))
assert Solution().isValidBST(TreeNode(-2147483648, None, TreeNode(2147483647)))
assert Solution().isValidBST(None)
复杂度
- 时间
- O(n) 最坏时间,n 为节点数;发现违规可提前结束。
- 空间
- O(h) 递归栈;h 为树高,退化链 h=n,Python 可能触发递归深度限制。
易错点与边界
- 不能只检验 node.left.val < node.val < node.right.val,祖先限制同样有效。
- 不要用题目允许的最小整数充当排他的初始下界。
- 节点值为负数不影响规则;树不平衡也可能是合法 BST。
- 本主解要求 low < node.val < high;等于任一边界也不合法,因此重复值会失败。
主解之后,再比较与迁移。
其他解法 1
迭代中序:检测严格递增时间 O(n) 最坏时间,每个节点入栈、出栈各一次。
采用等价判据:BST 的中序序列必须严格递增。按左、根、右访问时,previous 保存上一个正式访问的值;只需检查相邻值,不需要把整段序列存下来。若全部相邻值递增,那么根之前的左子树值全部更小、之后的右子树值全部更大。stack 保存等待访问的祖先,previous=None 区分尚未访问任何值的初始状态;显式栈避免递归深度限制。
- 初始化 stack=[]、node=root、previous=None。
- 沿左孩子不断压栈,直到 node 为空。
- 弹出栈顶正式访问;若已有 previous 且 node.val<=previous,返回 False。
- 更新 previous=node.val,再转向当前节点的右子树,重复左行与出栈。
- 节点和栈都为空时,中序比较全部通过,返回 True。
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
class Solution:
def isValidBST(self, root):
stack = []
node = root
previous = None
while node is not None or stack:
while node is not None:
stack.append(node)
node = node.left
node = stack.pop()
if previous is not None and node.val <= previous:
return False
previous = node.val
node = node.right
return True
复杂度
- 时间
- O(n) 最坏时间,每个节点入栈、出栈各一次。
- 空间
- O(h) 显式栈;不保存完整中序数组,无递归深度限制。
这个方法的边界
- 重复值不合法,比较符必须严格;中序出现相等就失败。
- previous 保存的是上一个出栈访问的值,不能在下探压栈时更新。
- previous=None 表示尚未访问节点,不能用可能出现在树中的整数值替代这个状态。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert Solution().isValidBST(TreeNode(2, TreeNode(1), TreeNode(3)))
bad = TreeNode(5, TreeNode(1), TreeNode(7, TreeNode(4), TreeNode(8)))
assert not Solution().isValidBST(bad)
assert not Solution().isValidBST(TreeNode(2, TreeNode(2)))
assert Solution().isValidBST(TreeNode(-2147483648, None, TreeNode(2147483647)))
assert Solution().isValidBST(None)
预测练习
节点 4 明明是 8 的左孩子且 4<8,为什么仍返回 False?
一点提示
它同时属于哪一个更早祖先的右子树?
答案与理由
它也在根 5 的右子树中,必须满足 5<4<8。左转到 4 只把上界改成 8,下界 5 不能丢掉。
面试追问与解法取舍
若追问 O(1) 辅助空间,可讨论 Morris 中序:临时借用空指针形成回线。但检测到违规后不能立刻返回而留下回线,需要完成结构恢复。通常显式栈更容易保证实现清晰。
模板迁移 / 相似题
试着不看代码,复述这三点
- 验证 BST 要检查整棵子树相对于祖先的关系。
- 上下界使用严格不等式,重复值同样会失败。
- and 的短路会在左侧失败后跳过右侧检查;未访问不代表已通过。
230. 二叉搜索树中第 K 小的元素 中等
一句话题意
在合法二叉搜索树中返回第 k 小的节点值,k 从 1 开始且保证有效。
root = [5,3,7,2,4,null,8](层序),k = 4;输出 5。中序顺序为 [2,3,4,5,7,8]。
生活化理解
把 BST 看成有序书库的分区索引:左馆的书号都比当前书小,右馆都更大。中序像按书号依次发号,数到第 k 位即可停,不必先把全馆的书都搬出来排序。
迭代中序:计数并提前返回
借助 BST 中序有序性逐个产生最小值,找到目标即可停止,适合单次排名查询。核心思路
BST 的中序访问顺序就是严格升序。因此核心是按 左 → 根 → 右 产生下一个最小值,无需再排序。node 指向当前准备下探的子树,stack 保存左侧尚未处理完、等待正式访问的祖先;沿左链走到空节点后,栈顶就是下一个应访问的节点。
k 在实现中表示还需访问多少个节点。压栈只是暂存,尚未轮到访问,不能减 k;只有出栈正式访问时才减一。k 减到零时,当前节点在升序序列中的名次正好是原题要求的第 k 小,立即返回其值。
若尚未找到目标,才执行 node=node.right,下一轮先走这棵右子树的左链。图中出栈 2、3、4 后,剩余 k 为 1;再出栈 5 就返回,右子树 7、8 不再访问。外层条件同时检查 node 与 stack,保证当前分支走空时还能回到待访问祖先。
BST 的中序访问顺序严格递增。stack 保存左侧尚未访问完的祖先,node 表示下一段待下探的子树;每次弹出恰好访问下一个最小值,因此 k 减到 0 时当前值就是答案。
图解
- stack
- []
- node
- 5
- k
- 4
- 已访问
- []
- 返回值
- —
- stack
- [5]
- node
- 3
- k
- 4
- 已访问
- []
- 返回值
- —
- stack
- [5,3]
- node
- 2
- k
- 4
- 已访问
- []
- 返回值
- —
- stack
- [5,3,2]
- node
- None
- k
- 4
- 已访问
- []
- 返回值
- —
- stack
- [5,3]
- node
- None(2.right)
- k
- 3
- 已访问
- [2]
- 返回值
- —
- stack
- [5]
- node
- 4
- k
- 2
- 已访问
- [2,3]
- 返回值
- —
- stack
- [5,4]
- node
- None
- k
- 2
- 已访问
- [2,3]
- 返回值
- —
- stack
- [5]
- node
- None(4.right)
- k
- 1
- 已访问
- [2,3,4]
- 返回值
- —
- stack
- []
- node
- 5
- k
- 0
- 已访问
- [2,3,4,5]
- 返回值
- 5
选择步骤 9 步
变量与图例
观察“压栈”和“计数”发生在不同时间:沿左链暂存祖先,弹出节点时才消耗一个名次。
- stack
- 等待中序访问的节点,图中左为栈底、右为栈顶。
- node
- 内层循环待压栈的节点;弹出时也用它保存当前访问节点。
- k
- 还需访问多少个节点,只有弹出访问时减 1。
- 已访问
- 教学记录,记录实际弹出顺序;原代码不保存此列表。
算法步骤
- 初始化空 stack,将 node 指向 root,k 保留为待消耗的目标名次。
- 沿 node 的左孩子不断压栈,直到左链走空。
- 弹出栈顶正式访问,并令 k 减一。
- k 为零立即返回 node.val;否则转向 node.right,再重复左行与出栈。
代码(Python)
查看完整 Python 代码迭代中序:计数并提前返回 · 与上方图解对应
保存待访问祖先,真正访问时消耗一个名额;避免构建 n 个元素的结果表。
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
class Solution:
def kthSmallest(self, root, k):
stack = []
node = root
while node is not None or stack:
while node is not None:
stack.append(node)
node = node.left
node = stack.pop()
k -= 1
if k == 0:
return node.val
node = node.right
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
r = TreeNode(5, TreeNode(3, TreeNode(2, TreeNode(1)), TreeNode(4)), TreeNode(6))
assert Solution().kthSmallest(r, 3) == 3
assert Solution().kthSmallest(r, 1) == 1
assert Solution().kthSmallest(r, 6) == 6
assert Solution().kthSmallest(TreeNode(-7), 1) == -7
复杂度
- 时间
- O(h+k),h 为树高;可能先走很长左链才能找到第一个值,最坏 O(n)。
- 空间
- O(h) 显式栈,无递归限制,返回值空间 O(1)。
易错点与边界
- 节点入栈不等于已访问,计数必须发生在出栈时。
- O(k) 忽略了到达首个节点的树高成本,应写 O(h+k)。
- 题目保证树非空且 1≤k≤n,无需增加无效 k 的业务分支。
- 本主解把 k 当作从 1 开始的剩余名次,出栈减到 0 就返回,不建立结果数组。
主解之后,再比较与迁移。
其他解法 1
递归中序:完整有序列表时间 O(n),无论 k 多小都遍历整棵树;最后索引 O(1)。
用递归按左、根、右访问整棵 BST,把值依次追加到 values,得到完整升序列表,然后返回 values[k-1]。这里 k 始终是原始的一基名次,不在递归中递减,也不提前终止;列表下标从 0 开始,因此要减一。写法直观,适合同时需要整份有序结果的场景,但单次查询也会遍历并保存全部 n 个值。
- 创建 values=[],定义访问函数 visit。
- 空节点直接返回;非空节点先递归左子树。
- 把当前 node.val 追加到 values,再递归右子树。
- 从根完成整棵树的遍历后,返回 values[k-1]。
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
class Solution:
def kthSmallest(self, root, k):
values = []
def visit(node):
if node is None:
return
visit(node.left)
values.append(node.val)
visit(node.right)
visit(root)
return values[k - 1]
复杂度
- 时间
- O(n),无论 k 多小都遍历整棵树;最后索引 O(1)。
- 空间
- values 为辅助 O(n),递归栈 O(h);链形树可能超过 Python 递归深度限制。
这个方法的边界
- k 是从 1 开始的排名;数组索引应为 k-1。
- 此递归版本会访问整棵树,不能套用主解找到第 k 个节点即停止的时间分析。
- values 保存全部 n 个值,另有递归栈;不能按主解只维护祖先栈的空间估算。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
r = TreeNode(5, TreeNode(3, TreeNode(2, TreeNode(1)), TreeNode(4)), TreeNode(6))
assert Solution().kthSmallest(r, 3) == 3
assert Solution().kthSmallest(r, 1) == 1
assert Solution().kthSmallest(r, 6) == 6
assert Solution().kthSmallest(TreeNode(-7), 1) == -7
预测练习
压入节点 4 时 k=2,能否立刻把它减为 1?
一点提示
压栈代表“已访问”还是“稍后再访问”?
答案与理由
不能。压栈只是暂存,必须确认左子树处理完并弹出节点 4 后,才把 k 从 2 减为 1。若节点仍有左孩子,提前计数会打乱排序。
面试追问与解法取舍
若频繁插入、删除并查询第 k 小,可在平衡 BST 节点维护 size=1+size(left)+size(right)。设左子树大小为 L:k≤L 向左,k=L+1 命中,否则向右查 k-L-1。必须同步维护平衡和 size,才能把每次操作稳定到 O(log n)。
模板迁移 / 相似题
试着不看代码,复述这三点
- 压左链只是记录返回路径,中序访问发生在弹栈时。
- BST 性质把访问次序转化成从小到大的排名。
- k 归零立即返回,示例中的右子树 7、8 无需遍历。
199. 二叉树的右视图 中等
一句话题意
返回从右侧观察二叉树时每层最靠右的节点值,按深度由浅到深排列。
root = [1,2,3,4,null,null,null,5](层序);输出 [1,3,4,5]。节点 3 没有孩子,深层由左子树补入。
生活化理解
像给看台每一排拍右侧剪影:同一排只露出最右边的人,下一排可以由左侧看台补位。树的右视图按“每层”决定,不能只跟着根的右孩子走到底。
BFS:每层最后一个节点
在层序模板上只改变记录条件,能清楚表达右视图是每个深度最右侧的节点。核心思路
右视图按深度选取最右侧节点,而不是只沿根的 right 指针前进。层序遍历令 queue 在每轮开始时按从左到右保存当前层,固定 size=len(queue) 后,本层最后出队的节点就是这一深度的可见节点。
i 是本层从 0 开始的出队序号,只有 i==size-1 时才将 node.val 加入 answer。不论是否记录当前节点,都要把非空孩子先左后右入队;这样下一轮仍按从左到右排列。本轮中新加入的孩子属于下一层,不能改变已经固定的 size。
图中第二层的 2、3 依次出队,记录最后的 3。虽然 3 没有孩子,2 的后代 4、5 仍分别是更深层的唯一节点,也就是各自层的最后一个,结果继续追加它们。因此来自左子树不妨碍节点进入右视图。
每轮开始时 queue 按从左到右保存同一深度的全部节点。固定 size 并先左后右入队后,本层最后弹出的节点就是该深度最右侧节点;右视图因此不等同于不断沿 right 指针行走。
图解
- queue
- [1]
- size
- 尚未固定
- i
- —
- answer
- []
- queue
- [1]
- size
- 1
- i
- 尚未弹出
- answer
- []
- queue
- [2,3]
- size
- 1
- i
- 0
- answer
- [1]
- queue
- [2,3]
- size
- 2
- i
- 上一轮 0;本轮未弹出
- answer
- [1]
- queue
- [3,4]
- size
- 2
- i
- 0
- answer
- [1]
- queue
- [4]
- size
- 2
- i
- 1
- answer
- [1,3]
- queue
- [4]
- size
- 1
- i
- 上一轮 1;本轮未弹出
- answer
- [1,3]
- queue
- [5]
- size
- 1
- i
- 0
- answer
- [1,3,4]
- queue
- [5]
- size
- 1
- i
- 上一轮 0;本轮未弹出
- answer
- [1,3,4]
- queue
- []
- size
- 1
- i
- 0
- answer
- [1,3,4,5]
选择步骤 10 步
变量与图例
每层只在 i==size-1 时记录;观察浅层的右子树结束后,更深层的左侧节点仍然可见。
- queue
- 当前等待处理的节点,左端为队首。
- size
- 本轮固定的层宽。
- i
- 本层弹出序号,从 0 开始。
- answer
- 每层已经确定的最右节点值。
算法步骤
- 空树返回 [];否则将根放入 queue,创建 answer=[]。
- 每轮固定 size=len(queue),按 i=0 到 size-1 处理当前层。
- 出队一个节点;仅当 i==size-1 时,将它的值写入 answer。
- 把该节点的非空孩子按左、右顺序入队,让下一层保持从左到右。
- 队列清空后返回 answer。
代码(Python)
查看完整 Python 代码BFS:每层最后一个节点 · 与上方图解对应
仍按从左到右处理整层,只在 i=size-1 时记录;层内最后位置与全树右链是不同概念。
from collections import deque
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
class Solution:
def rightSideView(self, root):
if root is None:
return []
queue = deque([root])
answer = []
while queue:
size = len(queue)
for i in range(size):
node = queue.popleft()
if i == size - 1:
answer.append(node.val)
if node.left is not None:
queue.append(node.left)
if node.right is not None:
queue.append(node.right)
return answer
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
r = TreeNode(1, TreeNode(2, None, TreeNode(5)), TreeNode(3, None, TreeNode(4)))
assert Solution().rightSideView(r) == [1, 3, 4]
r = TreeNode(1, TreeNode(2, TreeNode(4)), TreeNode(3))
assert Solution().rightSideView(r) == [1, 3, 4]
assert Solution().rightSideView(None) == []
assert Solution().rightSideView(TreeNode(9)) == [9]
复杂度
- 时间
- O(n),n 为节点数,每个节点处理一次。
- 空间
- 辅助 O(w),w 为最大层宽;输出 O(h),h 为层数,无递归栈。
易错点与边界
- 右视图不等于右孩子链,右侧较矮时要显示左子树更深处。
- BFS 记录最后一个值时必须使用本层固定 size。
- 空树没有可见节点;单链每个节点都可见,不论链接向左还是向右。
主解之后,再比较与迁移。
其他解法 1
右优先 DFS:每层第一次到达时间 O(n),仍须访问左子树,因为其更深层可能可见。
改用根、右、左的访问顺序,同一深度的右侧分支会先被访问。depth==len(answer) 表示首次到达尚未记录的新层,此时当前节点必是这一层最右的节点,记录一次即可;之后到达相同深度不再覆盖。右分支访问结束后仍要递归左分支,因此右侧较浅时,左子树可以补上更深层的可见节点。它不维护队列或 size,而用递归深度识别层。
- 创建 answer=[],从 visit(root,0) 开始递归。
- 遇到空节点直接返回。
- 若 depth==len(answer),首次进入该层,将 node.val 追加到 answer。
- 以 depth+1 先访问右孩子,再访问左孩子;已有层的值保持不变。
- 根调用结束后返回 answer。
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
class Solution:
def rightSideView(self, root):
answer = []
def visit(node, depth):
if node is None:
return
if depth == len(answer):
answer.append(node.val)
visit(node.right, depth + 1)
visit(node.left, depth + 1)
visit(root, 0)
return answer
复杂度
- 时间
- O(n),仍须访问左子树,因为其更深层可能可见。
- 空间
- 辅助递归栈 O(h),输出 O(h);链形树可触发 Python 递归深度限制。
这个方法的边界
- 右优先 DFS 才能首次记录;改成左优先却仍首次记录会得到左视图。
- 同一深度只在首次到达时追加,后续左侧节点不能覆盖已经记录的右侧值。
- 右子树访问结束后仍必须访问左子树,否则会漏掉左侧更深的可见节点。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
r = TreeNode(1, TreeNode(2, None, TreeNode(5)), TreeNode(3, None, TreeNode(4)))
assert Solution().rightSideView(r) == [1, 3, 4]
r = TreeNode(1, TreeNode(2, TreeNode(4)), TreeNode(3))
assert Solution().rightSideView(r) == [1, 3, 4]
assert Solution().rightSideView(None) == []
assert Solution().rightSideView(TreeNode(9)) == [9]
预测练习
第二层记录了 3,但 3 没有孩子,接下来还能看到 4 和 5 吗?
一点提示
右视图比较的是同一深度,还是要求每个节点都是右孩子?
答案与理由
能。深度 2 只有 4,深度 3 只有 5,它们分别是各自层的最后一个节点,因此结果继续追加 4、5。
面试追问与解法取舍
左视图只需把 DFS 改为先左后右,或 BFS 每层取第一个。若想同时得到左右视图,一次 BFS 记录每层首尾即可,无需运行两次完整遍历。
模板迁移 / 相似题
试着不看代码,复述这三点
- 右视图按深度选最右节点,不要求沿右孩子形成一条链。
- 固定层宽后用 i==size-1 精确识别最后一个节点。
- 先左后右入队是“最后弹出即最右”的前提。
114. 二叉树展开为链表 中等
一句话题意
原地把二叉树改为前序顺序的右指针链,所有 left 为 None,并保留原节点对象。
原树 root = [1,2,5,3,4,null,6]。节点身份 A=1、B=2、C=5、D=3、E=4、F=6;原地修改后 right 链为 A→B→D→E→C→F,值为 [1,2,3,4,5,6],所有 left=None;方法返回 None。
生活化理解
像把一本嵌套文件夹目录摊成连续走廊:先经过当前文件夹,再走完整个左区,最后进入原来的右区。把原右区的入口接到左区的最右空出口,就能先插入左区而不丢掉后续目录。
原地接线:寻找左子树右边界
用局部接线保留前序顺序,只移动原节点引用,满足常数辅助空间的面试追问。核心思路
对当前节点 cur,前序要求 cur → 左子树全部节点 → 原右子树全部节点。若 cur 有左子树,从 cur.left 出发沿 right 找到右指针为空的 predecessor。先让 predecessor.right 指向原右子树,再将 cur.left 移到 cur.right,最后将 cur.left 设为 None;这个顺序确保覆盖原 right 之前已保留其入口。
predecessor 不一定已经是最终展开链的尾节点,它自身的左子树可能仍未展开。关键在于它是当前左子树沿右边界走到的末端:原右子树接在这里后,随后处理 predecessor 的左子树时,还会把这些节点插到原右子树前面。因此局部接线保留整个前序相对顺序,无需先展开整棵左子树。
循环开始时,cur 之前的节点已经形成正确右链,left 全空;cur 及其后方尚未处理的结构仍保存剩余前序顺序。接线后沿新的 cur.right 继续,每次只移动引用,不创建替代节点。图中先接 E.right=C,再改 A.right=B、清空 A.left;中间暂时出现的两条入边是分步赋值的状态,完整三步之后才完成这处重排。
cur 之前的节点已经按前序顺序固定在 right 链上。若 cur 有左子树,先把原右子树接到左子树右边界,再把左子树放到 cur.right,后续继续展开即可保持“根、左子树、右子树”的前序顺序;操作复用所有原节点。
图解
- cur
- A
- predecessor
- 尚未赋值
- 指针
- A.L=B, A.R=C;B.L=D, B.R=E;C.L=None, C.R=F;D.L=None, D.R=None;E.L=None, E.R=None;F.L=None, F.R=None
- right链
- 尚未展开完成
- cur
- A
- predecessor
- B
- 指针
- A.L=B, A.R=C;B.L=D, B.R=E;C.L=None, C.R=F;D.L=None, D.R=None;E.L=None, E.R=None;F.L=None, F.R=None
- right链
- 尚未展开完成
- cur
- A
- predecessor
- E
- 指针
- A.L=B, A.R=C;B.L=D, B.R=E;C.L=None, C.R=F;D.L=None, D.R=None;E.L=None, E.R=None;F.L=None, F.R=None
- right链
- 尚未展开完成
- cur
- A
- predecessor
- E
- 指针
- A.L=B, A.R=C;B.L=D, B.R=E;C.L=None, C.R=F;D.L=None, D.R=None;E.L=None, E.R=C;F.L=None, F.R=None
- right链
- 尚未展开完成
- cur
- A
- predecessor
- E
- 指针
- A.L=B, A.R=B;B.L=D, B.R=E;C.L=None, C.R=F;D.L=None, D.R=None;E.L=None, E.R=C;F.L=None, F.R=None
- right链
- 尚未展开完成
- cur
- A
- predecessor
- E
- 指针
- A.L=None, A.R=B;B.L=D, B.R=E;C.L=None, C.R=F;D.L=None, D.R=None;E.L=None, E.R=C;F.L=None, F.R=None
- right链
- 尚未展开完成
- cur
- B
- predecessor
- D
- 指针
- A.L=None, A.R=B;B.L=D, B.R=E;C.L=None, C.R=F;D.L=None, D.R=None;E.L=None, E.R=C;F.L=None, F.R=None
- right链
- 尚未展开完成
- cur
- B
- predecessor
- D
- 指针
- A.L=None, A.R=B;B.L=D, B.R=E;C.L=None, C.R=F;D.L=None, D.R=E;E.L=None, E.R=C;F.L=None, F.R=None
- right链
- 尚未展开完成
- cur
- B
- predecessor
- D
- 指针
- A.L=None, A.R=B;B.L=D, B.R=D;C.L=None, C.R=F;D.L=None, D.R=E;E.L=None, E.R=C;F.L=None, F.R=None
- right链
- 尚未展开完成
- cur
- B
- predecessor
- D
- 指针
- A.L=None, A.R=B;B.L=None, B.R=D;C.L=None, C.R=F;D.L=None, D.R=E;E.L=None, E.R=C;F.L=None, F.R=None
- right链
- right:A → B → D → E → C → F
- cur
- C
- predecessor
- D
- 指针
- A.L=None, A.R=B;B.L=None, B.R=D;C.L=None, C.R=F;D.L=None, D.R=E;E.L=None, E.R=C;F.L=None, F.R=None
- right链
- 已固定前缀:A → B → D → E
- cur
- None
- predecessor
- D
- 指针
- A.L=None, A.R=B;B.L=None, B.R=D;C.L=None, C.R=F;D.L=None, D.R=E;E.L=None, E.R=C;F.L=None, F.R=None
- right链
- 结果:A·1 → B·2 → D·3 → E·4 → C·5 → F·6 → ∅
选择步骤 12 步
变量与图例
盯住节点身份和 L/R 指针:先保存原右子树的接入口,再把左子树移到 right,最后清空 left。
- cur
- 正在处理的节点,处理后沿 cur.right 前進。
- predecessor
- 从 cur.left 出发,沿 right 找到的右边界节点;并非一定是左子树前序最后一个节点。
- A…F
- 原节点身份,整个演示不创建新节点、不移动图中坐标。
- L / R
- 节点实际的 left/right 指针,图中允许展示赋值间的临时别名。
算法步骤
- 令 cur=root,沿最终展开方向逐个处理节点。
- 若 cur.left 非空,从 cur.left 出发沿 right 找到 predecessor,使其 right 为空。
- 先执行 predecessor.right=cur.right,保留原右子树入口。
- 再执行 cur.right=cur.left、cur.left=None,完成当前节点的前序接线。
- 无论是否接线,都令 cur=cur.right;到 None 时结束,结果保存在原树中。
代码(Python)
查看完整 Python 代码原地接线:寻找左子树右边界 · 与上方图解对应
predecessor 为原右子树找到临时接入口,cur 每轮向右推进;只使用几个节点引用,满足 O(1) 辅助空间追问。
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
class Solution:
def flatten(self, root):
cur = root
while cur is not None:
if cur.left is not None:
predecessor = cur.left
while predecessor.right is not None:
predecessor = predecessor.right
predecessor.right = cur.right
cur.right = cur.left
cur.left = None
cur = cur.right
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
nodes = [TreeNode(i) for i in range(1, 7)]
nodes[0].left, nodes[0].right = nodes[1], nodes[4]
nodes[1].left, nodes[1].right = nodes[2], nodes[3]
nodes[4].right = nodes[5]
assert Solution().flatten(nodes[0]) is None
current = nodes[0]
for expected in nodes:
assert current is expected
assert current.left is None
current = current.right
assert current is None
assert Solution().flatten(None) is None
single = TreeNode(8)
Solution().flatten(single)
assert single.left is None and single.right is None
a, b, c = TreeNode(2), TreeNode(2), TreeNode(2)
a.left, b.left = b, c
Solution().flatten(a)
assert a.right is b and b.right is c and c.right is None
assert a.left is None and b.left is None and c.left is None
复杂度
- 时间
- O(n),n 为节点数;外层处理每个节点,寻找左子树右边界的扫描总量为 O(n)。
- 空间
- O(1) 辅助空间;原地复用 n 个已有节点,无递归栈。
易错点与边界
- 要求修改原节点,复制值新建链表即使顺序正确也不满足原地语义。
- 覆盖 cur.right 之前先接好原右子树,否则会失去其入口。
- 每个 left 都必须清空;仅观察右链值不能完整验证结果。
- 值可以重复,测试必须比较对象身份 is,不能仅比 val。
主解之后,再比较与迁移。
其他解法 1
显式栈前序:按访问顺序连接时间 O(n),每个节点入栈、出栈一次。
stack 明确保存待访问节点,prev 始终指向已经展开的链尾。每次出栈得到下一个前序节点 node,先保存它的原孩子,再将 prev.right 接到 node、prev.left 设空;右孩子先压、左孩子后压,使后进先出的栈接下来先处理左侧。这种写法直接把前序访问序列变成链,容易理解,但需要显式栈,不具备主解的常数辅助空间。所有连接仍使用原节点对象。
- 空树直接结束;否则初始化 stack=[root]、prev=None。
- 弹出 node,先将其原右孩子入栈,再将原左孩子入栈。
- 若 prev 非空,将 prev.left 设为 None、prev.right 设为 node,把当前节点接到链尾。
- 令 prev=node,继续出栈,直到所有原节点都按前序访问。
- 将最终 prev 的 left、right 都设为 None,完成右链末尾。
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
class Solution:
def flatten(self, root):
if root is None:
return
stack = [root]
prev = None
while stack:
node = stack.pop()
if node.right is not None:
stack.append(node.right)
if node.left is not None:
stack.append(node.left)
if prev is not None:
prev.left = None
prev.right = node
prev = node
prev.left = None
prev.right = None
复杂度
- 时间
- O(n),每个节点入栈、出栈一次。
- 空间
- O(h) 辅助显式栈,h 为原树高,最坏 O(n);不创建新节点,无递归限制。
这个方法的边界
- 显式栈要先压右再压左,才能按前序出栈。
- 先把当前 node 的原孩子压栈,再修改 prev 的连接;未保存入口就覆盖指针可能漏节点。
- 原地修改原节点不代表常数辅助空间:本替代法仍使用显式栈,结束时还要将链尾 prev 的两个孩子指针设为空。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
nodes = [TreeNode(i) for i in range(1, 7)]
nodes[0].left, nodes[0].right = nodes[1], nodes[4]
nodes[1].left, nodes[1].right = nodes[2], nodes[3]
nodes[4].right = nodes[5]
assert Solution().flatten(nodes[0]) is None
current = nodes[0]
for expected in nodes:
assert current is expected
assert current.left is None
current = current.right
assert current is None
assert Solution().flatten(None) is None
single = TreeNode(8)
Solution().flatten(single)
assert single.left is None and single.right is None
a, b, c = TreeNode(2), TreeNode(2), TreeNode(2)
a.left, b.left = b, c
Solution().flatten(a)
assert a.right is b and b.right is c and c.right is None
assert a.left is None and b.left is None and c.left is None
预测练习
处理 A 时若先让 A.right=B,再执行 E.right=A.right,会发生什么?
一点提示
原来的右子树入口 C 还保存在 A.right 中吗?
答案与理由
不会了。A.right 已变成 B,再让 E.right=A.right 会连回 B,形成 B→E→B 的环,并丢失原右子树入口 C。因此必须先执行 E.right=C。
面试追问与解法取舍
还可用“右 → 左 → 根”的反向前序递归:维护已展开后缀 prev,让 node.right=prev、node.left=None,再更新 prev。它也是 O(n),但有 O(h) 调用栈;原地修改对象不等于 O(1) 辅助空间。
模板迁移 / 相似题
试着不看代码,复述这三点
- 接线顺序是:右边界接旧右子树、right 接左子树、left 清空。
- 值序列相同还不够,原地题要保留原节点身份与连接。
- 沿新 right 链继续处理,每个尚未展开的左子树都会被纳入前序顺序。
105. 从前序与中序遍历序列构造二叉树 中等
一句话题意
给定同一棵树的前序与中序序列,节点值互不重复,重建原二叉树。
preorder = [3,9,20,15,7],inorder = [9,3,15,20,7];输出树层序 [3,9,20,null,null,15,7]。
生活化理解
像用两份座位资料复原家族合影:前序名单先报家长,再报左支和右支;中序座位表把家长夹在左支与右支中间。先从报到名单拿到家长,再在座位表切开两支,反复做就能恢复关系。
哈希表 + 中序区间递归
前序选根、中序划范围的职责清楚,位置表与区间下标让重建保持线性工作量。核心思路
前序的第一个未消费值就是当前子树的根。positions[value] 给出它在中序数组中的位置 mid,于是当前中序半开区间 [lo,hi) 被切成左侧 [lo,mid) 和右侧 [mid+1,hi)。题目保证节点值唯一,所以根的位置及左右范围没有歧义;这些范围由中序位置决定,不由值的大小决定。
next_pre 指向下一项尚未用于建节点的前序值。非空调用先取这个值并让游标加一,再完整构建左子树,最后构建右子树,严格对应前序的根、左、右顺序。子调用返回时,它恰好消费当前区间包含的节点数,并返回这些节点组成的子树根。
空区间检查必须放在读取 preorder[next_pre] 之前:空孩子不应消费任何前序值。图中根 3 的左叶子 9 完成两个空子调用后,next_pre 仍为 2,恰好指向右子树根 20。位置表使每次定位平均为 O(1),递归只传数组边界,不创建子数组;每个前序值只被消费一次。
build(lo,hi) 恰好构建 inorder[lo:hi] 中的节点,next_pre 指向该子树的前序根。消耗根后按左、右顺序递归,与前序顺序一致;用根在中序中的位置分开两侧,空区间不消耗前序元素。
图解
- next_pre
- 0
- 中序区间
- [0,5)
- mid
- —
- 调用栈
- build(0,5)
- 当前状态
- 尚未创建根
- next_pre
- 1
- 中序区间
- [0,5)
- mid
- 1
- 调用栈
- build(0,5)
- 当前状态
- 根 3;左右区间待建
- next_pre
- 2
- 中序区间
- [0,1)
- mid
- 0
- 调用栈
- build(0,5) → build(0,1)
- 当前状态
- 局部根 9;待返回
- next_pre
- 2
- 中序区间
- [0,0)
- mid
- —
- 调用栈
- build(0,5) → build(0,1)
- 当前状态
- 9.left = None
- next_pre
- 2
- 中序区间
- [0,5)
- mid
- 1
- 调用栈
- build(0,5)
- 当前状态
- 3.left = 9
- next_pre
- 3
- 中序区间
- [2,5)
- mid
- 3
- 调用栈
- build(0,5) → build(2,5)
- 当前状态
- 局部根 20;待返回
- next_pre
- 4
- 中序区间
- [2,3)
- mid
- 2
- 调用栈
- build(0,5) → build(2,5) → build(2,3)
- 当前状态
- 局部根 15;待返回
- next_pre
- 4
- 中序区间
- [2,5)
- mid
- 3
- 调用栈
- build(0,5) → build(2,5)
- 当前状态
- 20.left = 15
- next_pre
- 5
- 中序区间
- [4,5)
- mid
- 4
- 调用栈
- build(0,5) → build(2,5) → build(4,5)
- 当前状态
- 局部根 7;next_pre 到尾后
- next_pre
- 5
- 中序区间
- [4,4)
- mid
- —
- 调用栈
- build(0,5) → build(2,5) → build(4,5)
- 当前状态
- 7.left = None
- next_pre
- 5
- 中序区间
- [2,5)
- mid
- 3
- 调用栈
- build(0,5) → build(2,5)
- 当前状态
- 20.right = 7
- next_pre
- 5
- 中序区间
- 已完成
- mid
- —
- 调用栈
- []
- 当前状态
- 输出根 = 3
选择步骤 12 步
变量与图例
前序游标只向前移动;中序区间决定当前根的左右子树究竟包含哪些节点。
- next_pre
- 下一个尚未使用的前序索引;非空调用创建根时才加 1。
- lo, hi
- 当前子树的中序半开区间 [lo,hi)。
- positions / mid
- 中序索引表;mid 是当前根在中序中的位置。
- 调用栈
- 按外层到内层显示尚未完成的 build 区间。
- 连接边
- 只在子调用返回、node.left/right 赋值完成后显示。
算法步骤
- 建立中序值到下标的 positions 表,令 next_pre=0,从完整中序区间开始 build。
- 若 lo>=hi,直接返回 None,不读取或推进前序游标。
- 读取 preorder[next_pre] 并推进游标,在 positions 中查到 mid,创建当前根 node。
- 先构建 [lo,mid) 并接到 node.left,再构建 [mid+1,hi) 并接到 node.right。
- 返回 node;最外层返回完整树根,此时前序元素恰好全部被消费。
代码(Python)
查看完整 Python 代码哈希表 + 中序区间递归 · 与上方图解对应
positions 用于 O(1) 定位,next_pre 顺序消费前序;只传边界,不创建子数组。
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
class Solution:
def buildTree(self, preorder, inorder):
positions = {value: i for i, value in enumerate(inorder)}
next_pre = 0
def build(lo, hi):
nonlocal next_pre
if lo >= hi:
return None
value = preorder[next_pre]
next_pre += 1
mid = positions[value]
node = TreeNode(value)
node.left = build(lo, mid)
node.right = build(mid + 1, hi)
return node
return build(0, len(inorder))
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
def preorder(node):
return [node.val] + preorder(node.left) + preorder(node.right) if node else []
def inorder(node):
return inorder(node.left) + [node.val] + inorder(node.right) if node else []
for pre, ino in (([3,9,20,15,7],[9,3,15,20,7]), ([1],[1]),
([1,2,3],[1,2,3]), ([3,2,1],[1,2,3]), ([],[])):
tree = Solution().buildTree(pre, ino)
assert preorder(tree) == pre
assert inorder(tree) == ino
复杂度
- 时间
- 平均 O(n),n 为节点数,哈希查找平均 O(1),每个节点只构建一次。
- 空间
- 辅助哈希表 O(n) 加递归栈 O(h),输出树 O(n);链形树可能超过 Python 递归深度限制。
易错点与边界
- 仅凭前序不能唯一恢复普通二叉树,中序的根位置提供左右划分。
- 共享前序游标时必须先左后右构造。
- 节点值唯一是本题条件,重复值会使 value→index 失去唯一性。
- 输出树可以高度退化;序列合法并不意味着递归深度安全。
- 本主解的线性工作量依赖 positions 直接定位和仅传区间下标,二者都属于这份代码的复杂度前提。
主解之后,再比较与迁移。
其他解法 1
迭代栈:用中序识别左侧完成时间 O(n),每个节点入栈、出栈至多一次,无线性 index 和数组切片。
按前序逐个创建节点,用 stack 保存尚未完成中序确认的节点,i 指向下一个待确认的中序值。栈顶值不等于 inorder[i],说明它在中序出现前仍有左侧节点,当前新节点接为它的左孩子;相等时则连续弹出已完成左侧的节点并推进 i,新节点接到最后弹出者的右侧。连续弹栈相当于递归从已完成的左侧向祖先返回;它不需要 positions 表,但要理解前序创建顺序和中序完成信号之间的关系。
- 空前序返回 None;否则用 preorder[0] 创建 root,初始化 stack=[root]、i=0。
- 从前序下标 1 起逐个创建 child,先读取当前栈顶 node。
- 若 node.val!=inorder[i],把 child 接为 node.left。
- 否则,只要栈顶等于 inorder[i] 就弹栈并推进 i;把 child 接为最后弹出节点的右孩子。
- 将 child 压入栈,继续处理前序剩余值;创建完成后返回 root。
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
class Solution:
def buildTree(self, preorder, inorder):
if not preorder:
return None
root = TreeNode(preorder[0])
stack = [root]
i = 0
for j in range(1, len(preorder)):
child = TreeNode(preorder[j])
node = stack[-1]
if node.val != inorder[i]:
node.left = child
else:
while stack and stack[-1].val == inorder[i]:
node = stack.pop()
i += 1
node.right = child
stack.append(child)
return root
复杂度
- 时间
- O(n),每个节点入栈、出栈至多一次,无线性 index 和数组切片。
- 空间
- 辅助显式栈 O(h),输出树 O(n);无需哈希表,不受 Python 递归深度限制。
这个方法的边界
- 连续弹栈后应把新节点接到最后弹出节点的 right,不能接到第一个弹出节点或当前栈顶。
- i 是下一个待确认的中序位置,j 是新节点的前序位置,两者推进条件不同。
- 本方法以显式栈替代递归,不继承主解的 Python 调用栈深度限制,但仍需保存待确认节点。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
def preorder(node):
return [node.val] + preorder(node.left) + preorder(node.right) if node else []
def inorder(node):
return inorder(node.left) + [node.val] + inorder(node.right) if node else []
for pre, ino in (([3,9,20,15,7],[9,3,15,20,7]), ([1],[1]),
([1,2,3],[1,2,3]), ([3,2,1],[1,2,3]), ([],[])):
tree = Solution().buildTree(pre, ino)
assert preorder(tree) == pre
assert inorder(tree) == ino
预测练习
构建叶子 9 的两个空子树时,next_pre 会从 2 继续增加吗?
一点提示
源码在空区间返回前,有没有读取 preorder[next_pre]?
答案与理由
不会。lo>=hi 时立即返回 None,next_pre 保持 2,因此接下来正确地取 preorder[2]=20 作为根 3 的右子树根。
面试追问与解法取舍
若改成中序与后序(拓展 106 题),后序最后一个值是根;使用从后向前的共享游标时,必须先构建右子树再构建左子树。只有前序和后序通常无法区分单个孩子到底在左还是右。
其他思路的边界
- 递归解的 O(n) 时间来自哈希表和区间参数,直接 index 与切片版不具有同样保证。
模板迁移 / 相似题
试着不看代码,复述这三点
- 前序找根,中序定子树范围;二者职责不能互换。
- 递归必须先左后右,才能配合单向移动的前序游标。
- 半开空区间直接返回且不消耗前序值,避免索引错位。
437. 路径总和 III 中等
一句话题意
统计树中和为 targetSum 的向下连续路径数量,起点终点任意,但不能向上或跨分支。
root = [1,-1,0,1,0](层序),targetSum=1。身份 A=根1、B=左-1、C=B.left=1、D=B.right=0、E=A.right=0;输出 4,路径为 [A]、[A,B,C]、[C]、[A,E]。
生活化理解
像沿山路记累计海拔变化:当前累计读数减去某个祖先之前的读数,就是中间一段路的变化量。前缀频率表是“当前这条上山路线的路标册”,下山退回分叉口时必须撤销刚经过的路标,不能拿左路的路标去量右路。
前缀和频率 + DFS 回溯
把每个终点的路径计数化为前缀差查询,避免从各起点反复扫描同一批后代。核心思路
设从根到当前节点的前缀和为 prefix,若此前某个位置的前缀为 prefix-targetSum,两者之间的向下路径和就是 targetSum。frequency[x] 表示当前祖先路径中前缀 x 的有效出现次数,不是整个遍历历史的次数。相同前缀可能对应多个不同起点,因此必须保存频率,不能只保存集合。
初始化 frequency={0:1},代表根之前的空前缀,让从根开始的路径也能计入。到达非空节点后先累加 node.val,再查询 frequency[prefix-targetSum],得到所有以当前节点结尾的合法路径数;然后才登记当前 prefix。先查再加,避免 targetSum=0 时把当前前缀与自身匹配,误计空路径。
count 是每个递归调用自己的局部计数:先记录以当前节点为终点的路径,再累加左右子调用的结果。不同终点由不同节点负责,因此相加不会重复。左右子树返回后把当前前缀频率减一,恢复父节点路径的有效计数,避免另一分支误读已经离开的前缀。本代码保留降到零的键,所以有效频率只属于当前路径,而已分配的字典条目最坏仍有 O(n) 个。
进入节点、查询前,frequency 只计数根之前的虚拟前缀 0 和当前节点的祖先前缀。当前 prefix 减去某个祖先前缀等于 targetSum 时,就得到一条以当前节点结尾的向下路径;退出时减回当前前缀频率,使兄弟分支重新只看到共同祖先。
图解
- frequency
- {0:1}
- 当前prefix
- 入口参数 0
- 当前局部count
- 尚未创建
- 调用栈
- []
- 当前变化
- 尚未进入任何节点
- frequency
- {0:1, 1:1}
- 当前prefix
- 1
- 当前局部count
- 1
- 调用栈
- A(p=1,c=1)
- 当前变化
- 查 1−1=0:命中 1
- frequency
- {0:2, 1:1}
- 当前prefix
- 0
- 当前局部count
- 0
- 调用栈
- A(p=1,c=1) → B(p=0,c=0)
- 当前变化
- 查 0−1=−1:命中 0
- frequency
- {0:2, 1:2}
- 当前prefix
- 1
- 当前局部count
- 2
- 调用栈
- A(p=1,c=1) → B(p=0,c=0) → C(p=1,c=2)
- 当前变化
- 查 1−1=0:命中 2
- frequency
- {0:2, 1:1}
- 当前prefix
- 0
- 当前局部count
- 2
- 调用栈
- A(p=1,c=1) → B(p=0,c=2)
- 当前变化
- 撤销 C:frequency[1]−1
- frequency
- {0:3, 1:1}
- 当前prefix
- 0
- 当前局部count
- 0
- 调用栈
- A(p=1,c=1) → B(p=0,c=2) → D(p=0,c=0)
- 当前变化
- 查 0−1=−1:命中 0
- frequency
- {0:2, 1:1}
- 当前prefix
- 0
- 当前局部count
- 2
- 调用栈
- A(p=1,c=1) → B(p=0,c=2)
- 当前变化
- 撤销 D:frequency[0]−1
- frequency
- {0:1, 1:1}
- 当前prefix
- 1
- 当前局部count
- 3
- 调用栈
- A(p=1,c=3)
- 当前变化
- 撤销 B:frequency[0]−1
- frequency
- {0:1, 1:2}
- 当前prefix
- 1
- 当前局部count
- 1
- 调用栈
- A(p=1,c=3) → E(p=1,c=1)
- 当前变化
- 查 1−1=0:只命中 1
- frequency
- {0:1, 1:1}
- 当前prefix
- 1
- 当前局部count
- 4
- 调用栈
- A(p=1,c=4)
- 当前变化
- 撤销 E;向 A 返回 1
- frequency
- {0:1, 1:0}
- 当前prefix
- A 退出前为 1
- 当前局部count
- 返回 4
- 调用栈
- []
- 当前变化
- A 返回 4;路径计数完成
选择步骤 11 步
变量与图例
频率表只服务当前祖先链:进入节点先查询再加入前缀,退出节点必须撤销,兄弟分支不能继承彼此的前缀。
- prefix
- 当前节点所在根路径的前缀和,每层递归有自己的局部值。
- frequency
- 当前祖先路径的前缀和频次;{0:1} 是根之前的虚拟起点。
- count
- 当前调用的局部结果:本节点结尾的匹配数,加左右子调用返回的计数。
- 调用栈
- 显示节点身份和局部状态,p=prefix、c=count;从外到内。
- 返回值
- 本子树中所有合法向下路径的计数,不是前缀和。
Mermaid 决策树源码
graph TD
A["进入根 1:登记前缀 1"] --> B["进入左 -1:匹配空前缀 0"]
B --> C["退出左分支:前缀 0 次数减一"]
C --> D["进入右 -1:只看根路径前缀"]
D --> E["退出右分支并恢复"]
E --> F["退出根:恢复空前缀状态"]算法步骤
- 初始化 frequency={0:1},从 visit(root,0) 开始;空节点返回计数 0。
- 将当前节点值加入 prefix,先令 count=frequency.get(prefix-targetSum,0)。
- 把当前 prefix 的频率加一,使后代能使用这个起点边界。
- 依次递归左右孩子,将各自返回的路径数量累加到当前局部 count。
- 退出当前节点前将 frequency[prefix] 减一,然后返回 count;根调用结果即总路径数。
代码(Python)
查看完整 Python 代码前缀和频率 + DFS 回溯 · 与上方图解对应
prefix 只沿当前根到节点路径更新;先查再加,返回前减一。保留零频率键的版本仍是线性空间,不虚报 O(h)。
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
class Solution:
def pathSum(self, root, targetSum):
frequency = {0: 1}
def visit(node, prefix):
if node is None:
return 0
prefix += node.val
count = frequency.get(prefix - targetSum, 0)
frequency[prefix] = frequency.get(prefix, 0) + 1
count += visit(node.left, prefix)
count += visit(node.right, prefix)
frequency[prefix] -= 1
return count
return visit(root, 0)
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
r = TreeNode(10, TreeNode(5, TreeNode(3, TreeNode(3), TreeNode(-2)),
TreeNode(2, None, TreeNode(1))),
TreeNode(-3, None, TreeNode(11)))
assert Solution().pathSum(r, 8) == 3
assert Solution().pathSum(None, 0) == 0
assert Solution().pathSum(TreeNode(0, TreeNode(0), TreeNode(0)), 0) == 5
assert Solution().pathSum(TreeNode(1, TreeNode(-1), TreeNode(-1)), 0) == 2
assert Solution().pathSum(TreeNode(-2, None, TreeNode(-3)), -5) == 1
assert Solution().pathSum(TreeNode(1, TreeNode(2), TreeNode(2)), 0) == 0
复杂度
- 时间
- 平均 O(n),n 为节点数,每节点执行常数次哈希操作。
- 空间
- 辅助频率字典 O(n)(保留零频率键),递归栈 O(h);链形树可能触发 Python 递归深度限制。
易错点与边界
- 路径只能沿父到子,不能把左子树和右子树连成一条路径。
- 前缀表必须在离开节点时减回去,否则不同分支会互相污染。
- frequency 存次数;全零树会在同一前缀值上累积多个合法起点。
- 先查询再登记当前前缀,防止 target=0 时计入空路径。
主解之后,再比较与迁移。
其他解法 1
枚举每个起点:向下累计路径和时间 O(nh),h 为树高;平衡树 O(n log n),链形树最坏 O(n²),因为每个起点会重复访问后代。
使用两层递归分开选择起点与向下延伸。all_starts(node) 枚举当前子树内的所有起点;starts_here(node,remain) 则固定一个起点,只沿后代向下走,remain 表示距离目标和还差多少。减去当前值后 remain==0 就得到一条路径,但命中后仍要继续,因为零值或负值可能让更长路径再次命中,也不能按 remain 的正负剪枝。它直接贴合路径定义,代价是不同起点会重复访问同一后代,链形树最坏为 O(n²)。
- 定义 starts_here:空节点返回 0;非空节点先令 remain-=node.val。
- 把 int(remain==0) 与左右孩子的 starts_here 返回值相加,得到这个固定起点的路径数。
- 定义 all_starts:空节点返回 0,否则先计算 starts_here(node,targetSum)。
- 继续累加 all_starts(node.left) 和 all_starts(node.right),枚举其余起点。
- 返回 all_starts(root),每条向下路径恰由它自己的起点和终点确定。
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
class Solution:
def pathSum(self, root, targetSum):
def starts_here(node, remain):
if node is None:
return 0
remain -= node.val
return (int(remain == 0)
+ starts_here(node.left, remain)
+ starts_here(node.right, remain))
def all_starts(node):
if node is None:
return 0
return (starts_here(node, targetSum)
+ all_starts(node.left)
+ all_starts(node.right))
return all_starts(root)
复杂度
- 时间
- O(nh),h 为树高;平衡树 O(n log n),链形树最坏 O(n²),因为每个起点会重复访问后代。
- 空间
- 辅助递归栈 O(h),外层与内层同时存在的栈深仍为 O(h);深链有 Python 递归限制。
这个方法的边界
- 有负数时,当前和过大不能剪枝;匹配成功也不能停止向下搜索。
- all_starts 枚举起点,starts_here 只从固定起点向下延伸;只调用 starts_here(root,targetSum) 会漏掉非根起点。
- 不同起点会重复扫描同一后代,链形树最坏 O(n²),不能套用前缀频率法的线性时间。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
r = TreeNode(10, TreeNode(5, TreeNode(3, TreeNode(3), TreeNode(-2)),
TreeNode(2, None, TreeNode(1))),
TreeNode(-3, None, TreeNode(11)))
assert Solution().pathSum(r, 8) == 3
assert Solution().pathSum(None, 0) == 0
assert Solution().pathSum(TreeNode(0, TreeNode(0), TreeNode(0)), 0) == 5
assert Solution().pathSum(TreeNode(1, TreeNode(-1), TreeNode(-1)), 0) == 2
assert Solution().pathSum(TreeNode(-2, None, TreeNode(-3)), -5) == 1
assert Solution().pathSum(TreeNode(1, TreeNode(2), TreeNode(2)), 0) == 0
预测练习
回到 A 再进入右孩子 E 时,为什么查询 prefix-targetSum=0 只能得到 1,而不是 2?
一点提示
左分支 B 的前缀 0 是否仍是 E 的祖先?
答案与理由
不是。退出 B 时 frequency[0] 已从 2 减回 1,只剩虚拟起点;E 只能匹配 [A,E]。若保留 B 的前缀,会多算跨越兄弟分支的不存在路径。
面试追问与解法取舍
递归栈超深时可改为显式事件栈,进入事件执行查询与登记,并压入离开事件;离开事件执行频率减一。这样仍保持同一条祖先路径语义。若在计数归零时移除该键,频率字典的同时占用可降到 O(h);这里为易读保留了零键,因此如实计 O(n)。
模板迁移 / 相似题
试着不看代码,复述这三点
- 相同前缀可能重复,必须保存频率而不只是集合。
- 先查询再登记当前前缀,避免 target=0 时把空路径计入。
- count 是每个调用的局部累计;frequency 共享且必须在退出时撤销。
236. 二叉树的最近公共祖先 中等
一句话题意
在普通二叉树中找到同时包含指定节点 p、q 的最深祖先,节点可以是自己的祖先。
root = [3,5,1,6,2,0,8](层序);p 是节点 D=6,q 是节点 E=2(按节点身份)。输出节点 B=5。
生活化理解
像两名员工向上寻找最近共同主管:部门只需向上汇报“这里找到了哪位员工或共同主管”。若两个不同子部门各找到一位,当前主管就是汇合点;若两人都在同一部门,更高主管直接转交那份结论。
后序递归:合并左右线索
用子树返回的节点线索判断最低汇合点,无需额外建立父指针结构。核心思路
search(node) 返回一份节点线索:None 表示这棵子树没有找到目标;非空结果可能是一个目标,也可能是这棵子树内已经确定的最近公共祖先。遇到 node is p 或 node is q 就返回自身。题目保证两个目标都存在,因此若另一个目标在它的后代中,这个目标节点自身就是答案,不必继续向下寻找。
对其他节点,先取得 left=search(node.left),再取得 right=search(node.right)。两侧都非空时,两个目标分处当前节点的左右子树,这里就是线索第一次向上汇合的位置,因而是最近公共祖先。只有一侧非空时原样上传该结果;两侧都空则返回 None,不能把当前节点强行当作答案。
图中 B 收到目标 D、E 后返回 B;根 A 仍按代码检查右子树 C,得到 None 后将 B 原样上传。这份实现没有“发现左侧已有 LCA 就停止搜索右侧”的额外状态。判断和返回都围绕原节点对象,使用 is 表达身份,不靠节点值大小决定去向。
search 的返回值是 None、发现的目标节点,或子树内已确定的最近公共祖先。两边都有非空线索时,当前节点是它们最低的汇合点;仅一侧非空时原样上交,不会被更高祖先替换。题目保证 p、q 都存在于树中。
图解
- node
- A=3
- left
- 待计算
- right
- 待计算
- 返回值
- —
- 调用栈
- A
- node
- B=5
- left
- 待计算
- right
- 待计算
- 返回值
- —
- 调用栈
- A 等左 → B
- node
- B=5
- left
- D(目标 p)
- right
- 待计算
- 返回值
- D → B.left
- 调用栈
- A 等左 → B 等右
- node
- B=5
- left
- D
- right
- E(目标 q)
- 返回值
- E → B.right
- 调用栈
- A 等左 → B 合并
- node
- B=5(刚返回)
- left
- D
- right
- E
- 返回值
- B → A.left
- 调用栈
- A 等右
- node
- C=1
- left
- 待计算
- right
- 待计算
- 返回值
- —
- 调用栈
- A 等右 → C
- node
- F=0(刚返回)
- left
- None
- right
- None
- 返回值
- None → C.left
- 调用栈
- A 等右 → C 等右
- node
- G=8(刚返回)
- left
- None
- right
- None
- 返回值
- None → C.right
- 调用栈
- A 等右 → C 合并
- node
- C=1(刚返回)
- left
- None
- right
- None
- 返回值
- None → A.right
- 调用栈
- A 合并
- node
- A=3(已返回)
- left
- B
- right
- None
- 返回值
- B(值 5)
- 调用栈
- []
选择步骤 10 步
变量与图例
先看左右子调用各返回哪个节点,再判断是在此处分叉,还是把已找到的线索原样向上传。
- p, q
- 目标节点对象 D、E;判断使用 is 而不是只比较值。
- left, right
- 当前节点的左右递归返回值,可为 None、目标节点或已找到的 LCA。
- 调用栈
- 从外到内显示尚未完成 search 的节点。
- 返回值
- 向父节点上交的节点身份,不是布尔值。
算法步骤
- 从 search(root) 开始;空节点或命中 p、q 时,直接返回当前节点对象。
- 对其他节点先递归左子树得到 left,再递归右子树得到 right。
- 若 left、right 都非空,返回当前 node,表示两条目标线索在此最低汇合。
- 否则返回非空的一侧;两侧都为空时返回 None。
- 最外层的返回对象就是最近公共祖先。
代码(Python)
查看完整 Python 代码后序递归:合并左右线索 · 与上方图解对应
将 None、目标对象和已确认的祖先统一作为返回信息;两侧非空才在当前节点形成新的汇合。
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
class Solution:
def lowestCommonAncestor(self, root, p, q):
def search(node):
if node is None or node is p or node is q:
return node
left = search(node.left)
right = search(node.right)
if left is not None and right is not None:
return node
return left if left is not None else right
return search(root)
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
p = TreeNode(5, TreeNode(6), TreeNode(2, TreeNode(7), TreeNode(4)))
q = TreeNode(1, TreeNode(0), TreeNode(8))
r = TreeNode(3, p, q)
assert Solution().lowestCommonAncestor(r, p, q) is r
assert Solution().lowestCommonAncestor(r, p, p.right.right) is p
assert Solution().lowestCommonAncestor(r, p.left, p.right.left) is p
chain = TreeNode(1, TreeNode(2, TreeNode(3)))
assert Solution().lowestCommonAncestor(chain, chain.left, chain.left.left) is chain.left
# 身份语义拓展:即使节点同值也应区分对象;力扣本题输入值唯一。
a, b = TreeNode(7), TreeNode(7)
same_values = TreeNode(0, a, b)
assert Solution().lowestCommonAncestor(same_values, a, b) is same_values
复杂度
- 时间
- O(n) 最坏时间,n 为节点数,每个节点至多检查一次。
- 空间
- 辅助递归栈 O(h),h 为树高;链形树可能触发 Python 递归深度限制。
易错点与边界
- 这不是 BST 题,不能用值大小决定向左还是向右搜索。
- 祖先包含自身,p 的后代中有 q 时答案就是 p。
- 返回的是原节点引用,值相等不是通用的对象相同判据。
- 递归提前返回依赖两目标都存在;缺失目标的拓展需要额外信息。
- 两侧线索都存在才返回当前节点,单侧已有 LCA 必须继续上传。
主解之后,再比较与迁移。
其他解法 1
父指针表 + 祖先集合时间 平均 O(n),父表构建最坏访问整棵树,再上行 O(h);哈希查找平均常数时间。
先用显式遍历记录 parent[child]=node,直到 p、q 都已登记,再把 p 到根的所有节点对象放入 ancestors。从 q 沿 parent 向上,第一个属于 ancestors 的节点就是最低的公共祖先:更靠近 q 的位置均不是 p 的祖先。这里不通过后序返回值合并结果,而把两条向根的路径转成集合相交问题;所用 TreeNode 保留默认对象哈希与身份相等语义,代价是额外保存父表与祖先集合。
- 初始化 parent={root:None}、stack=[root],明确根没有父节点。
- 在 p 或 q 尚未登记时弹出一个节点,为其非空左右孩子记录父节点并入栈。
- 从 p 沿 parent 上行到 None,把沿途节点对象加入 ancestors。
- 从 q 开始沿 parent 上行,直到当前节点属于 ancestors。
- 返回这个首次命中的原节点对象。
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
class Solution:
def lowestCommonAncestor(self, root, p, q):
parent = {root: None}
stack = [root]
while p not in parent or q not in parent:
node = stack.pop()
if node.left is not None:
parent[node.left] = node
stack.append(node.left)
if node.right is not None:
parent[node.right] = node
stack.append(node.right)
ancestors = set()
node = p
while node is not None:
ancestors.add(node)
node = parent[node]
node = q
while node not in ancestors:
node = parent[node]
return node
复杂度
- 时间
- 平均 O(n),父表构建最坏访问整棵树,再上行 O(h);哈希查找平均常数时间。
- 空间
- 辅助 O(n) 父表,加 O(h) 祖先集合和显式栈,总 O(n);无递归深度限制。
这个方法的边界
- parent 的键和 ancestors 的成员是节点对象;按节点值建表并不能通用表达身份。
- 建表循环要等 p、q 都已登记才能停止,不能找到其中一个就开始依赖另一方的 parent 链。
- 从 q 往根走,首次进入 ancestors 的节点才是最近公共祖先;继续上行会得到更高的公共祖先。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
p = TreeNode(5, TreeNode(6), TreeNode(2, TreeNode(7), TreeNode(4)))
q = TreeNode(1, TreeNode(0), TreeNode(8))
r = TreeNode(3, p, q)
assert Solution().lowestCommonAncestor(r, p, q) is r
assert Solution().lowestCommonAncestor(r, p, p.right.right) is p
assert Solution().lowestCommonAncestor(r, p.left, p.right.left) is p
chain = TreeNode(1, TreeNode(2, TreeNode(3)))
assert Solution().lowestCommonAncestor(chain, chain.left, chain.left.left) is chain.left
# 身份语义拓展:即使节点同值也应区分对象;力扣本题输入值唯一。
a, b = TreeNode(7), TreeNode(7)
same_values = TreeNode(0, a, b)
assert Solution().lowestCommonAncestor(same_values, a, b) is same_values
预测练习
根 A 收到 left=B、right=None 时,为什么返回 B 而不是 A?
一点提示
B 的两侧已经分别找到哪两个目标?
答案与理由
B 已是 D 与 E 的最低汇合点,两个目标都在根的左子树内。根只需原样传递 B,更高的 A 虽是公共祖先却不是最近的。
面试追问与解法取舍
若是 BST,可利用 p、q 的值与当前根比较,在两者分居两侧或命中目标时结束,时间 O(h)。若普通树要做大量静态 LCA 查询,可以预处理深度与倍增祖先:预处理 O(n log n)、每次查询 O(log n),单次查询通常没必要引入该结构。
模板迁移 / 相似题
试着不看代码,复述这三点
- 返回值携带节点线索,不能只用是否找到的布尔值替代。
- 两侧非空才在当前节点汇合;一侧非空就原样上传。
- 命中 p 或 q 会直接返回该对象,涵盖一个目标本身就是祖先的情况。
124. 二叉树中的最大路径和 困难
一句话题意
求非空二叉树中任意非空简单路径的最大节点值和,路径可在某节点连接左右两支。
root = [-10,9,20,-6,null,15,7](层序);输出 42,最佳路径为 15→20→7。gain(-10)=25 只是内部单支返回值,不是最终答案。
生活化理解
像规划一条不分叉的登山游线:站在某个山口,可以把左坡和右坡接成完整游线作为候选;但若还要把路线接给更高山口,只能带走一条坡道。带着两条再接父亲会形成三叉路,已经不是一条路径。
后序递归:单支返回、双支更新
明确区分向父节点延伸的单支收益和完整路径候选,是树形路径最值的常用模板。核心思路
定义 gain(node) 为必须从 node 出发、向下只走一支的最大和。空节点返回 0。先递归计算左右孩子的收益,再分别与 0 取最大,得到代码中的 left、right;负收益分支不选。向父节点只能返回 node.val+max(left,right),否则接上父节点后会产生三叉结构,不再是一条简单路径。
把当前 node 当作路径最高点时,可以同时使用左右两支,完整候选为 node.val+left+right,用它更新全局 best。任意简单路径在根的层级关系中都有唯一最高点,遍历每个节点作为最高点就覆盖了全部候选。图中节点 20 用 15、7 两支将 best 更新为 42,但只向根返回 20+15=35。
best 从负无穷开始,因为路径不能为空,全负数树也必须返回一个实际节点组成的路径。负叶子 -6 的递归返回仍为 -6,由父节点 9 决定将这条负支截为 0。最外层调用 gain(root) 只是为了完成这些候选比较,最终返回 best;根自身的单支返回值并不是整棵树的最大路径和。
gain(node) 返回必须包含当前节点、且最多向下选一侧的路径和,才能继续接给父节点。当前节点作为路径最高点时可同时使用两侧非负收益,以 node.val+left+right 更新 best;每条简单路径都有唯一最高点,因此所有候选覆盖最优路径。
图解
- node
- -10
- left
- 待计算
- right
- 待计算
- 双支候选
- —
- best
- -∞
- 单支返回
- —
- 调用栈
- gain(-10)
- node
- -6
- left
- 待计算
- right
- 待计算
- 双支候选
- —
- best
- -∞
- 单支返回
- —
- 调用栈
- -10 等左 → 9 等左 → -6
- node
- -6
- left
- 0
- right
- 0
- 双支候选
- -6+0+0 = -6
- best
- -6
- 单支返回
- 待返回 -6
- 调用栈
- -10 等左 → 9 等左 → -6 待返回
- node
- 9(刚返回)
- left
- max(0,-6)=0
- right
- 0
- 双支候选
- 9+0+0 = 9
- best
- 9
- 单支返回
- 9 → 根.left
- 调用栈
- -10 等右
- node
- 15
- left
- 待计算
- right
- 待计算
- 双支候选
- —
- best
- 9
- 单支返回
- —
- 调用栈
- -10 等右 → 20 等左 → 15
- node
- 15(刚返回)
- left
- 0
- right
- 0
- 双支候选
- 15+0+0 = 15
- best
- 15
- 单支返回
- 15 → 20.left
- 调用栈
- -10 等右 → 20 等右
- node
- 7
- left
- 待计算
- right
- 待计算
- 双支候选
- —
- best
- 15
- 单支返回
- —
- 调用栈
- -10 等右 → 20 等右 → 7
- node
- 7(刚返回)
- left
- 0
- right
- 0
- 双支候选
- 7+0+0 = 7
- best
- 15
- 单支返回
- 7 → 20.right
- 调用栈
- -10 等右 → 20 合并
- node
- 20
- left
- 15
- right
- 7
- 双支候选
- 20+15+7 = 42
- best
- 42
- 单支返回
- 尚未返回
- 调用栈
- -10 等右 → 20 待返回
- node
- 20(刚返回)
- left
- 15
- right
- 7
- 双支候选
- 已记入 best 的 42
- best
- 42
- 单支返回
- 20+max(15,7)=35
- 调用栈
- -10 合并
- node
- -10
- left
- 9
- right
- 35
- 双支候选
- -10+9+35 = 34
- best
- 42
- 单支返回
- 待返回 -10+35=25
- 调用栈
- -10 待返回
- node
- -10(已返回)
- left
- 9
- right
- 35
- 双支候选
- 根候选 34
- best
- 42
- 单支返回
- gain(root)=25(内部值)
- 调用栈
- []
选择步骤 12 步
变量与图例
分别看两条数值:双支候选更新全局 best,单支收益返回给父节点;负收益在父节点处截成 0。
- gain(node)
- 向父节点返回的单支路径和,可以为负;父节点再决定是否舍弃。
- left, right
- 当前节点收到左右 gain 后分别与 0 取最大,可不选负贡献。
- 双支候选
- node.val+left+right,可同时连接两侧,仅用于更新 best。
- best
- 所有已处理最高点的最大完整路径和,初始化为 -∞。
- 调用栈
- 还在等待孩子收益或准备合并的 gain 调用,从外到内。
算法步骤
- 将 best 初始化为负无穷,调用 gain(root);空节点的 gain 返回 0。
- 先递归左孩子再递归右孩子,各自与 0 取最大,得到可选的 left、right 收益。
- 用 node.val+left+right 更新 best,比较以当前节点为最高点的完整路径。
- 向父调用只返回 node.val+max(left,right),保留单支延伸条件。
- 根调用完成后返回全局 best,不返回 gain(root) 的单支值。
代码(Python)
查看完整 Python 代码后序递归:单支返回、双支更新 · 与上方图解对应
gain 的返回值和 best 的候选表达式含义不同:一个供父节点延伸,一个代表已封闭的完整路径。
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
class Solution:
def maxPathSum(self, root):
best = float("-inf")
def gain(node):
nonlocal best
if node is None:
return 0
left = max(0, gain(node.left))
right = max(0, gain(node.right))
best = max(best, node.val + left + right)
return node.val + max(left, right)
gain(root)
return best
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert Solution().maxPathSum(TreeNode(1, TreeNode(2), TreeNode(3))) == 6
r = TreeNode(-10, TreeNode(9), TreeNode(20, TreeNode(15), TreeNode(7)))
assert Solution().maxPathSum(r) == 42
assert Solution().maxPathSum(TreeNode(-3)) == -3
assert Solution().maxPathSum(TreeNode(-4, TreeNode(-2), TreeNode(-8))) == -2
assert Solution().maxPathSum(TreeNode(2, TreeNode(-1))) == 2
assert Solution().maxPathSum(TreeNode(0, TreeNode(0), TreeNode(0))) == 0
# 左子树已经用了两支,不能把该 6 当单支继续交给根 10。
r = TreeNode(10, TreeNode(1, TreeNode(2), TreeNode(3)), TreeNode(-100))
assert Solution().maxPathSum(r) == 14
复杂度
- 时间
- O(n),n 为节点数,每个节点合并两侧结果一次。
- 空间
- 辅助递归栈 O(h),h 为树高;退化链 h=n,Python 可能超出递归深度限制。
易错点与边界
- 向父节点只能传一支;传 node.val+L+R 会在父层制造三叉结构。
- best 初始化为 0 会让全负树错误返回空路径。
- 最佳路径可以只含一个节点,也可以完全位于某个子树内。
- 不能强制从根出发,也不能强制路径两端都是叶子。
- 本题树非空;负支可以不选,但当前候选始终必须包含当前节点。
主解之后,再比较与迁移。
其他解法 1
显式后序栈 + 收益表时间 平均 O(n),每个节点两次出栈,哈希表读写平均 O(1)。
用 (node,ready) 事件模拟递归的进入和返回。ready=False 时先把自己的 ready=True 事件放回栈,再按右、左顺序压入孩子,使左右孩子都先于当前节点完成;ready=True 时从 gains 读取已算好的单支收益。gains[node] 只保存 node.val+max(left,right),best 才比较 node.val+left+right。它保留相同的单支与双支区分,用收益表和事件栈换取不依赖 Python 递归深度。
- 初始化 best=-∞、gains={}、stack=[(root,False)]。
- 弹出首次事件时,先压回 (node,True),再依次压入非空右孩子、左孩子的首次事件。
- 弹出 ready=True 事件时,从 gains 取得左右孩子的收益,空孩子按 0 处理,并分别截断负贡献。
- 用 node.val+left+right 更新 best,再把 node.val+max(left,right) 存入 gains[node]。
- 所有事件处理完成后返回 best。
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
class Solution:
def maxPathSum(self, root):
best = float("-inf")
gains = {}
stack = [(root, False)]
while stack:
node, ready = stack.pop()
if not ready:
stack.append((node, True))
if node.right is not None:
stack.append((node.right, False))
if node.left is not None:
stack.append((node.left, False))
else:
left = max(0, gains.get(node.left, 0))
right = max(0, gains.get(node.right, 0))
best = max(best, node.val + left + right)
gains[node] = node.val + max(left, right)
return best
复杂度
- 时间
- 平均 O(n),每个节点两次出栈,哈希表读写平均 O(1)。
- 空间
- 辅助收益表 O(n),显式事件栈 O(h),总 O(n);无 Python 递归深度限制。
这个方法的边界
- ready=True 的事件必须等左右孩子都处理后才执行;先压自身完成事件,再压孩子,才能形成后序。
- gains[node] 只能存单支收益,双支候选只用于更新 best;把双支值放入收益表会在祖先处构造分叉路径。
- 显式事件栈避免递归深度限制,但 gains 保存各节点收益,辅助空间不能只按树高计算。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert Solution().maxPathSum(TreeNode(1, TreeNode(2), TreeNode(3))) == 6
r = TreeNode(-10, TreeNode(9), TreeNode(20, TreeNode(15), TreeNode(7)))
assert Solution().maxPathSum(r) == 42
assert Solution().maxPathSum(TreeNode(-3)) == -3
assert Solution().maxPathSum(TreeNode(-4, TreeNode(-2), TreeNode(-8))) == -2
assert Solution().maxPathSum(TreeNode(2, TreeNode(-1))) == 2
assert Solution().maxPathSum(TreeNode(0, TreeNode(0), TreeNode(0))) == 0
# 左子树已经用了两支,不能把该 6 当单支继续交给根 10。
r = TreeNode(10, TreeNode(1, TreeNode(2), TreeNode(3)), TreeNode(-100))
assert Solution().maxPathSum(r) == 14
预测练习
20 的完整候选为 42,为什么给根 -10 的返回值只能是 35?
一点提示
若把 15→20→7 整段再接上父节点,会让 20 连接几条路径分支?
答案与理由
42 已同时使用 20 的左右两侧,再接父节点会产生三叉,不能构成简单路径。向上只能返回 20+max(15,7)=35;全局 best 单独保留完整路径的 42。
面试追问与解法取舍
若追问返回路径本身,可在每个节点记录单支收益选择了哪个孩子,并在 best 更新时记录最高点和两侧入口。结束后沿记录重建两支并拼接,避免在每次递归中复制整段路径,从而保留 O(n) 总时间。
模板迁移 / 相似题
试着不看代码,复述这三点
- 父节点接收单支收益,最高点用双支和更新答案,两个值不能混用。
- 负收益可由父节点舍弃,但候选始终包含当前节点,best 不能从 0 开始。
- 最佳路径不必经过根;最后返回 best,而不是 gain(root)。
200. 岛屿数量 中等
一句话题意
统计字符网格中由上下左右相连的陆地组成的岛屿数量。
grid = ["1100", "1001", "0011"] → 2
生活化理解
给散落的村庄划行政区:陆地格是村庄,四邻通路是道路。每发现一个尚未登记的村庄,就把能走到的整片村庄登记为同一个区,区数只增加一次。
队列 BFS
把每次发现新岛和整岛的队列遍历分开,入队即标记的规则容易说明,也不会受递归深度限制。核心思路
外层逐格扫描负责发现新连通块,内层 BFS 负责把这一块走完。ans 只在扫描遇到仍为 '1' 的格子时增加,然后把起点改成 '0',放入当前岛的队列 q。从它出发只能走上、下、左、右四个方向;对角相接不算连通。
q 保存已经标记、尚待扩展的陆地坐标。每次用 popleft() 取出最早发现的格子,检查四邻;邻居在范围内且仍为 '1' 时,先改成 '0',再加入队尾。入队即标记能避免多条道路汇入同格时重复入队;这些新邻居属于当前岛,不能再增加 ans。
队列清空时,该起点能到达的所有陆地都已标记,外层扫描再遇到 '1' 才会发现另一座岛。因此每座岛恰好计数一次。这里只统计连通块,不求距离,无需固定层大小。本实现原地改变 grid;如果调用方需要保留地图,可以对副本运行,复制需额外 O(mn) 空间。
q 中每个坐标都已标记,但尚未扩展;同一格最多入队一次。每次 ans 增加后,BFS 会标记整个连通块,因此下一次增加必然属于另一座岛。
图解
- ans
- 0
- 外层 (r,c)
- (0, 0)
- 最近出队 (x,y)
- 尚未弹出
- q
- 尚未创建
- grid
- [["1", "1", "0", "0"], ["1", "0", "0", "1"], ["0", "0", "1", "1"]]
- ans
- 1
- 外层 (r,c)
- (0, 0)
- 最近出队 (x,y)
- 尚未弹出
- q
- [[0, 0]]
- grid
- [["0", "1", "0", "0"], ["1", "0", "0", "1"], ["0", "0", "1", "1"]]
- ans
- 1
- 外层 (r,c)
- (0, 0)
- 最近出队 (x,y)
- (0, 0)
- q
- [[1, 0], [0, 1]]
- grid
- [["0", "0", "0", "0"], ["0", "0", "0", "1"], ["0", "0", "1", "1"]]
- ans
- 1
- 外层 (r,c)
- (0, 0)
- 最近出队 (x,y)
- (1, 0)
- q
- [[0, 1]]
- grid
- [["0", "0", "0", "0"], ["0", "0", "0", "1"], ["0", "0", "1", "1"]]
- ans
- 1
- 外层 (r,c)
- (0, 0)
- 最近出队 (x,y)
- (0, 1)
- q
- []
- grid
- [["0", "0", "0", "0"], ["0", "0", "0", "1"], ["0", "0", "1", "1"]]
- ans
- 2
- 外层 (r,c)
- (1, 3)
- 最近出队 (x,y)
- (0, 1)
- q
- [[1, 3]]
- grid
- [["0", "0", "0", "0"], ["0", "0", "0", "0"], ["0", "0", "1", "1"]]
- ans
- 2
- 外层 (r,c)
- (1, 3)
- 最近出队 (x,y)
- (1, 3)
- q
- [[2, 3]]
- grid
- [["0", "0", "0", "0"], ["0", "0", "0", "0"], ["0", "0", "1", "0"]]
- ans
- 2
- 外层 (r,c)
- (1, 3)
- 最近出队 (x,y)
- (2, 3)
- q
- [[2, 2]]
- grid
- [["0", "0", "0", "0"], ["0", "0", "0", "0"], ["0", "0", "0", "0"]]
- ans
- 2
- 外层 (r,c)
- (1, 3)
- 最近出队 (x,y)
- (2, 2)
- q
- []
- grid
- [["0", "0", "0", "0"], ["0", "0", "0", "0"], ["0", "0", "0", "0"]]
- ans
- 2
- 外层 (r,c)
- (2, 3)
- 最近出队 (x,y)
- (2, 2)
- q
- []
- grid
- [["0", "0", "0", "0"], ["0", "0", "0", "0"], ["0", "0", "0", "0"]]
选择步骤 10 步
变量与图例
观察陆地在入队时从 1 变成 0,以及队列清空后外层扫描才发现下一座岛。
- grid
- 原地地图:原陆地入队时由 1 改成 0。
- ans
- 外层扫描发现的新连通块数量。
- q
- 当前岛已标记、待扩展坐标,左端先出。
- (r,c)
- 外层扫描位置;内层 BFS 期间不移动。
- (x,y)
- 本次从 q 弹出的扩展坐标。
算法步骤
- 初始化 ans=0,按行扫描 grid;不是 '1' 的格子直接跳过。
- 发现未访问陆地时,令 ans 加一,把起点改成 '0',建立 q=deque([(r,c)])。
- 只要 q 非空,就用 popleft 取出 (x,y),检查上、下、左、右四邻。
- 对范围内仍为 '1' 的邻居,先改成 '0',再追加到 q,继续遍历当前岛。
- 当前队列清空后恢复外层扫描,全部格子检查完成后返回 ans。
代码(Python)
查看完整 Python 代码队列 BFS · 与上方图解对应
q 保存从起点逐层发现的陆地;此题不求距离,无需显式分层,连通块计数与 DFS 相同。
from typing import List
from collections import deque
class Solution:
def numIslands(self, grid: List[List[str]]) -> int:
m, n = len(grid), len(grid[0])
ans = 0
for r in range(m):
for c in range(n):
if grid[r][c] != '1':
continue
ans += 1
grid[r][c] = '0'
q = deque([(r,c)])
while q:
x, y = q.popleft()
for a,b in ((x-1,y),(x+1,y),(x,y-1),(x,y+1)):
if 0 <= a < m and 0 <= b < n and grid[a][b] == '1':
grid[a][b] = '0'
q.append((a,b))
return ans运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert Solution().numIslands([list('110'),list('001')]) == 2
assert Solution().numIslands([['1']]) == 1
assert Solution().numIslands([list('00')]) == 0
复杂度
- 时间
- O(mn),每块陆地只入队一次。
- 空间
- 队列最坏 O(mn),原地标记不另建 visited。
易错点与边界
- 格值是字符串 "1",不是整数 1。
- 只沿上下左右扩展,不含对角线。
- 只在启动一次新遍历时加岛数,不能每发现陆地就加。
- 代码会把已访问陆地改成水,重复调用需重新构造输入。
- 陆地加入队列前就要改成 "0",否则多条道路可能让同一格重复入队。
主解之后,再比较与迁移。
其他解法 1
显式栈 DFS时间 O(mn),m、n 是行列数,每格检查常数条边。
同样由外层扫描发现新岛,但把待扩展陆地放入 stack,用后进先出的 pop 优先处理最近加入的格子。stack 内坐标也已经标记,因此每格至多入栈一次;遍历顺序不同不会改变四邻连通块的数量。显式栈避免细长岛屿触及 Python 递归深度限制。与 BFS 代码一样,它原地把陆地改成 '0';若改为用 visited 集合保存访问状态,可保留地图,但要额外占用 O(mn) 空间。
- 按行扫描 grid,遇到仍为 '1' 的格子时令 ans 加一。
- 立即把起点改成 '0',建立 stack=[(r,c)]。
- 只要 stack 非空,就 pop 一个坐标,检查四邻未访问陆地。
- 邻居入栈前先改成 '0';栈清空表示本岛已遍历完,再继续外层扫描。
- 全部扫描结束后返回 ans。
from typing import List
class Solution:
def numIslands(self, grid: List[List[str]]) -> int:
m, n = len(grid), len(grid[0])
ans = 0
for r in range(m):
for c in range(n):
if grid[r][c] != '1':
continue
ans += 1
grid[r][c] = '0'
stack = [(r, c)]
while stack:
x, y = stack.pop()
for a, b in ((x-1,y),(x+1,y),(x,y-1),(x,y+1)):
if 0 <= a < m and 0 <= b < n and grid[a][b] == '1':
grid[a][b] = '0'
stack.append((a,b))
return ans复杂度
- 时间
- O(mn),m、n 是行列数,每格检查常数条边。
- 空间
- 显式栈最坏 O(mn),返回值 O(1)。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert Solution().numIslands([list('110'),list('001')]) == 2
assert Solution().numIslands([['0']]) == 0
assert Solution().numIslands([list('1111')]) == 1
预测练习
弹出 (0,0) 后 q=[(1,0),(0,1)],此时 ans 应变成 3 吗?
一点提示
区分「发现一个格子」与「发现一个尚未登记的连通块」。
答案与理由
不应。两格都是 (0,0) 所在岛的邻居,ans 仍为 1;直到外层遇到 (1,3) 才加到 2。
面试追问与解法取舍
还可用并查集合并相邻陆地,并统计根的数量;静态网格上 BFS/DFS 更直接。如果题目变成逐次新增陆地并询问岛数,并查集才更有优势。
模板迁移 / 相似题
试着不看代码,复述这三点
- 外层找连通块入口,内层遍历完整连通块。
- 标记与入队绑定,避免同一格被多条道路重复发现。
- 岛屿标记永久保留;与需要撤销访问状态的路径搜索不同。
994. 腐烂的橘子 中等
一句话题意
每分钟腐烂橘子同时感染四邻新鲜橘子,求全部腐烂的最少分钟数,无法完成返回 -1。
grid = [[2,1,1],[1,1,0],[0,1,2]] → 2
生活化理解
多处火源同时向相邻房间蔓延:最初所有火源在第 0 分钟出发,新燃房间只能在下一分钟继续传播。队列的层就是统一的时钟。
多源 BFS:固定层大小
所有腐烂源同时入队,再用固定层大小表示一分钟,能直接对应同步传播的题意。核心思路
把全部初始腐烂格同时放入 q,fresh 统计新鲜格。BFS 第 t 层是最早在第 t 分钟腐烂的格子;因为每条四邻边耗时相同,首次到达时间就是最短传播时间。多个初始源都处于第 0 分钟,必须一起开始传播。
每轮先固定 len(q),只扩展本分钟开始时的那批橘子,整层结束才让 minutes 加一。新感染格加入队尾,留到下一轮扩散;不能本轮立刻继续扩展它,也不能每弹出一个橘子就增加一分钟。
发现值为 1 的新鲜邻居时,立即改成 2、减少 fresh,再入队,避免不同来源重复感染和重复计数。while q and fresh 只在仍有新鲜橘子时继续下一轮:若 fresh 在当前层中途归零,代码仍完成这一层,再增加 minutes。初始 fresh=0 时返回 0;队列耗尽仍有 fresh,则剩余橘子与所有初始源都不连通,返回 −1。
每轮开始时 q 的整层格子都在同一分钟腐烂,固定 len(q) 使新入队格不会提前扩展。每个新鲜格首次感染时立即改成 2 并令 fresh 减一,所以到 fresh=0 时,刚结束的分钟就是最早全部腐烂时间。
图解
- minutes
- 0
- fresh
- 5
- q
- [[0, 0], [2, 2]]
- 当前扩展
- None
- 本层进度
- 即将固定本层大小 = 2
- grid
- [[2, 1, 1], [1, 1, 0], [0, 1, 2]]
- minutes
- 0
- fresh
- 5
- q
- [[2, 2]]
- 当前扩展
- (0, 0)
- 本层进度
- 本层固定 2 格;正在处理第 1 格
- grid
- [[2, 1, 1], [1, 1, 0], [0, 1, 2]]
- minutes
- 0
- fresh
- 3
- q
- [[2, 2], [1, 0], [0, 1]]
- 当前扩展
- (0, 0)
- 本层进度
- 本层固定 2 格;第 1 格已扩展
- grid
- [[2, 2, 1], [2, 1, 0], [0, 1, 2]]
- minutes
- 0
- fresh
- 3
- q
- [[1, 0], [0, 1]]
- 当前扩展
- (2, 2)
- 本层进度
- 本层固定 2 格;正在处理第 2 格
- grid
- [[2, 2, 1], [2, 1, 0], [0, 1, 2]]
- minutes
- 0
- fresh
- 2
- q
- [[1, 0], [0, 1], [2, 1]]
- 当前扩展
- (2, 2)
- 本层进度
- 本层固定 2 格;第 2 格已扩展
- grid
- [[2, 2, 1], [2, 1, 0], [0, 2, 2]]
- minutes
- 1
- fresh
- 2
- q
- [[1, 0], [0, 1], [2, 1]]
- 当前扩展
- None
- 本层进度
- 下一层固定大小 = 3
- grid
- [[2, 2, 1], [2, 1, 0], [0, 2, 2]]
- minutes
- 1
- fresh
- 1
- q
- [[0, 1], [2, 1], [1, 1]]
- 当前扩展
- (1, 0)
- 本层进度
- 本层固定 3 格;第 1 格已扩展
- grid
- [[2, 2, 1], [2, 2, 0], [0, 2, 2]]
- minutes
- 1
- fresh
- 0
- q
- [[2, 1], [1, 1], [0, 2]]
- 当前扩展
- (0, 1)
- 本层进度
- 本层固定 3 格;第 2 格已扩展
- grid
- [[2, 2, 2], [2, 2, 0], [0, 2, 2]]
- minutes
- 1
- fresh
- 0
- q
- [[1, 1], [0, 2]]
- 当前扩展
- (2, 1)
- 本层进度
- 本层固定 3 格;第 3 格已扩展
- grid
- [[2, 2, 2], [2, 2, 0], [0, 2, 2]]
- minutes
- 2
- fresh
- 0
- q
- [[1, 1], [0, 2]]
- 当前扩展
- None
- 本层进度
- fresh = 0,while 条件不成立
- grid
- [[2, 2, 2], [2, 2, 0], [0, 2, 2]]
选择步骤 10 步
变量与图例
盯住队列的层界线:这一分钟新腐烂的格子入队,但下一分钟才扩展。
- q
- 待传播的腐烂格,左端先出;一轮结束后恰是下一层。
- fresh
- 网格内仍为 1 的格子数。
- minutes
- 已经完整结束的传播轮数。
- len(q)
- 每轮 for 开始时固定的本层大小,非动态增长的循环上限。
- t 标签
- 图上记录的首次腐烂时刻,用于识别层次。
算法步骤
- 扫描网格,把所有值为 2 的坐标加入 q,同时计数值为 1 的格子得到 fresh,令 minutes=0。
- 在 q 非空且 fresh>0 时,以本轮开始的 len(q) 固定要处理的格子数量。
- 逐个弹出本层坐标,检查四邻;新鲜邻居立即改成 2、fresh 减一,并加入队尾。
- 完整处理固定的这一层后令 minutes 加一,新入队的格子留待下一轮。
- 循环结束后,fresh==0 返回 minutes,否则返回 −1。
代码(Python)
查看完整 Python 代码多源 BFS:固定层大小 · 与上方图解对应
minutes 表示已完成的扩散轮数;while q and fresh 防止全部完成后多记一分钟。
from typing import List
from collections import deque
class Solution:
def orangesRotting(self, grid: List[List[int]]) -> int:
m, n = len(grid), len(grid[0])
q = deque()
fresh = 0
for r in range(m):
for c in range(n):
if grid[r][c] == 2:
q.append((r,c))
elif grid[r][c] == 1:
fresh += 1
minutes = 0
while q and fresh:
for _ in range(len(q)):
r,c = q.popleft()
for x,y in ((r-1,c),(r+1,c),(r,c-1),(r,c+1)):
if 0 <= x < m and 0 <= y < n and grid[x][y] == 1:
grid[x][y] = 2
fresh -= 1
q.append((x,y))
minutes += 1
return minutes if fresh == 0 else -1运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert Solution().orangesRotting([[2,1,1],[1,1,0],[0,1,1]]) == 4
assert Solution().orangesRotting([[0,2]]) == 0
assert Solution().orangesRotting([[1]]) == -1
assert Solution().orangesRotting([[2,1,1],[0,1,2]]) == 1
复杂度
- 时间
- O(mn),每个橘子至多入队一次。
- 空间
- O(mn) 队列,原地修改网格。
易错点与边界
- 所有初始腐烂橘子必须同时入队。
- 空格阻断传播,不能穿过。
- 入队时减少 fresh,不能重复减。
- 无新鲜橘子返回 0;有新鲜橘子但无初始源返回 -1。
- 每轮固定本层 len(q),整层结束才增加 minutes;fresh 归零后无需为清空剩余队列再多计一分钟。
主解之后,再比较与迁移。
其他解法 1
多源 BFS:携带时间戳时间 O(mn),哈希操作按均摊 O(1) 计。
把首次到达时间直接放入队列元素 (r,c,t),不需要层长循环。dist 同时记录感染时刻和访问状态:所有初始腐烂格登记为 0,新鲜邻居第一次发现时登记为 t+1。网格保持原样,必须用“坐标尚不在 dist”来阻止重复计数。answer 在每次出队时取已到达时间的最大值,队列处理完后便得到最晚感染时间;fresh 仍大于零则表示存在不可达新鲜格。
- 扫描网格,把所有初始腐烂坐标以 (r,c,0) 入队并记入 dist,另统计 fresh。
- 令 answer=0;逐个弹出 (r,c,t),用 t 更新 answer。
- 检查四邻,只访问范围内、值为 1 且不在 dist 中的格子。
- 新访问格登记 dist[x,y]=t+1,fresh 减一,并将 (x,y,t+1) 加入队尾。
- 队列清空后,fresh==0 返回 answer,否则返回 −1。
from typing import List
from collections import deque
class Solution:
def orangesRotting(self, grid: List[List[int]]) -> int:
m, n = len(grid), len(grid[0])
q = deque()
dist = {}
fresh = 0
for r in range(m):
for c in range(n):
if grid[r][c] == 2:
q.append((r,c,0))
dist[r,c] = 0
elif grid[r][c] == 1:
fresh += 1
answer = 0
while q:
r,c,t = q.popleft()
answer = max(answer,t)
for x,y in ((r-1,c),(r+1,c),(r,c-1),(r,c+1)):
if 0 <= x < m and 0 <= y < n and grid[x][y] == 1 and (x,y) not in dist:
dist[x,y] = t+1
fresh -= 1
q.append((x,y,t+1))
return answer if fresh == 0 else -1复杂度
- 时间
- O(mn),哈希操作按均摊 O(1) 计。
- 空间
- O(mn) 的距离字典和队列,保持输入不变。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
g=[[2,1,1],[1,1,0],[0,1,1]]
assert Solution().orangesRotting(g) == 4
assert g[0] == [2,1,1]
assert Solution().orangesRotting([[2,0,1]]) == -1
assert Solution().orangesRotting([[0]]) == 0
预测练习
当 (0,2) 被感染、fresh=0 时,图中 minutes 还是 1;应该返回 1 吗?
一点提示
minutes 在源码哪一行、哪个循环外更新?
答案与理由
应返回 2。当前第 2 分钟的传播层尚未记入 minutes,固定层处理完成后 minutes += 1;队尾两格不用再扩展。
面试追问与解法取舍
两种实现都基于多源 BFS,区别在时间表达和是否修改输入。若传播耗时因边而异,普通 BFS 的先到即最短不再成立,应转向多源 Dijkstra。
模板迁移 / 相似题
试着不看代码,复述这三点
- 多源同时入队,全部初始源属于第零层。
- 固定层大小隔开旧感染源与新感染格。
- fresh 决定能否完成;队列耗尽仍有新鲜格才返回 -1。
207. 课程表 中等
一句话题意
给定先修依赖,判断是否可以完成全部课程,即有向图是否无环。
numCourses=4, prerequisites=[[1,0],[2,0],[3,1],[3,2]] → True
生活化理解
选课像一道道审批:入度是尚未完成的前置审批数量。没有待办前置条件的课程才能被放入可学队列;完成它后,对后续课程解除一项条件。
Kahn 拓扑排序
维护尚未满足的先修数量,逐个完成当前可学课程,迭代流程直接且适合较大的课程依赖图。核心思路
对于先修关系 [a,b],建边 b→a,表示学完 b 才能学 a。graph[b] 保存 b 的直接后续课程,indeg[a] 记录尚未满足的先修数量。队列 ready 起初包含所有入度为 0 的课程,它们都可以作为当前学习顺序的下一门。
每次从 ready 取出课程 u,让 done 加一,再逐条处理 u 的出边。对于后续课程 v,只把 indeg[v] 减一;只有入度刚好归零才将它入队。例如一门课有两个先修,完成其中一个之后仍不能学习它。每条依赖只在其先修课程出队时处理一次。
最终若 done==numCourses,就找到了一种完成全部课程的顺序。若仍有课程未完成,剩余子图没有零入度点;沿前驱不断走必然重复某个点,因此存在环。反过来,有环时环内每个点始终至少保留一个环内前驱,无法全部出队。所以完成数是否等于课程总数,恰好判断能否学完。
每处理一条 u→v,indeg[v] 恰减一;它表示尚未完成的先修数量。ready 只含入度归零的课程,因此出队顺序始终满足依赖,最终 done==numCourses 恰好说明全部课程可完成。
图解
- indeg
- [0, 1, 1, 2]
- ready
- 尚未初始化
- done
- 尚未初始化
- u
- 尚未出队
- indeg
- [0, 1, 1, 2]
- ready
- [0]
- done
- 0
- u
- 尚未出队
- indeg
- [0, 1, 1, 2]
- ready
- []
- done
- 1
- u
- 0
- indeg
- [0, 0, 1, 2]
- ready
- [1]
- done
- 1
- u
- 0
- indeg
- [0, 0, 0, 2]
- ready
- [1, 2]
- done
- 1
- u
- 0
- indeg
- [0, 0, 0, 2]
- ready
- [2]
- done
- 2
- u
- 1
- indeg
- [0, 0, 0, 1]
- ready
- [2]
- done
- 2
- u
- 1
- indeg
- [0, 0, 0, 1]
- ready
- []
- done
- 3
- u
- 2
- indeg
- [0, 0, 0, 0]
- ready
- [3]
- done
- 3
- u
- 2
- indeg
- [0, 0, 0, 0]
- ready
- []
- done
- 4
- u
- 3
- indeg
- [0, 0, 0, 0]
- ready
- []
- done
- 4
- u
- 3
选择步骤 11 步
变量与图例
关注汇合点课程 3:它必须等两条入边都被处理,不能只完成一门先修就入队。
- graph
- 有向邻接表,先修课 pre 指向依赖它的 course。
- indeg
- 每门课尚未处理的先修边数量。
- ready
- 全部先修已满足、尚未出队的课程。
- done
- 已经出队的课程数,不是队列中课程数。
- u / v
- 正在完成的课程及其当前后继。
算法步骤
- 建立 graph 和 indeg;对每个 [course,pre] 添加 pre→course,并给 indeg[course] 加一。
- 把所有入度为 0 的课程放入 ready,令 done=0。
- 从 ready 弹出 u,done 加一,随后处理 graph[u] 中的每个 v。
- 每处理一条 u→v 就令 indeg[v] 减一;变为 0 时将 v 加入 ready。
- ready 清空后返回 done==numCourses。
代码(Python)
查看完整 Python 代码Kahn 拓扑排序 · 与上方图解对应
indeg 只表示未完成的前置条件;ready 保存所有当前可学课程,适合课程数很大的 Python 输入。
from typing import List
from collections import deque
class Solution:
def canFinish(self, numCourses: int, prerequisites: List[List[int]]) -> bool:
graph = [[] for _ in range(numCourses)]
indeg = [0]*numCourses
for course, pre in prerequisites:
graph[pre].append(course)
indeg[course] += 1
ready = deque(i for i in range(numCourses) if indeg[i] == 0)
done = 0
while ready:
u = ready.popleft()
done += 1
for v in graph[u]:
indeg[v] -= 1
if indeg[v] == 0:
ready.append(v)
return done == numCourses运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert Solution().canFinish(4,[[1,0],[2,0],[3,1],[3,2]])
assert not Solution().canFinish(2,[[0,1],[1,0]])
assert Solution().canFinish(3,[])
assert not Solution().canFinish(1,[[0,0]])
复杂度
- 时间
- O(V+E),V 为课程数,E 为依赖条数。
- 空间
- O(V+E) 邻接表、入度数组和队列。
易错点与边界
- [a,b] 表示先 b 后 a。
- 不能只遍历从课程 0 可达的部分,图可能不连通。
- 没有依赖时全部课程都能完成;自依赖直接成环。
- 后续课程的入度降到 0 才能入队;只完成其中一个先修时不能提前学习它。
主解之后,再比较与迁移。
其他解法 1
DFS 三色判环时间 O(V+E),每条边只在所属起点首次 DFS 时扫描。
它不模拟完成课程,而是直接检查有向图是否有环。color=0 表示尚未访问,1 表示仍在当前递归链上,2 表示其全部后继已经检查完且无环。遇到颜色 1 是回到祖先,形成环;遇到颜色 2 只是共享了一个已检查的后继,不是环。所有后继都通过后才把当前节点设为 2。递归链过长可能触及 Python 栈深限制,这时可用 Kahn 主解。
- 按 pre→course 建立邻接表,初始化所有 color 为 0。
- visit(u) 遇到 color[u]==1 返回 False;遇到 color[u]==2 直接返回 True。
- 首次处理 u 时置 color[u]=1,逐个递归检查它的后继;任一后继返回 False 就传播失败。
- 全部后继无环时置 color[u]=2 并返回 True。
- 对所有课程调用 visit,覆盖不连通部分;全部返回 True 才表示可以完成。
from typing import List
class Solution:
def canFinish(self, numCourses: int, prerequisites: List[List[int]]) -> bool:
graph = [[] for _ in range(numCourses)]
for course, pre in prerequisites:
graph[pre].append(course)
color = [0]*numCourses
def visit(u):
if color[u] == 1:
return False
if color[u] == 2:
return True
color[u] = 1
for v in graph[u]:
if not visit(v):
return False
color[u] = 2
return True
return all(visit(u) for u in range(numCourses))复杂度
- 时间
- O(V+E),每条边只在所属起点首次 DFS 时扫描。
- 空间
- O(V+E) 图和颜色,另有最坏 O(V) 递归栈。
这个方法的边界
- DFS 遇到已完成点不代表有环,只有当前路径上的点才构成回边。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert Solution().canFinish(4,[[1,0],[2,0],[3,1],[3,2]])
assert not Solution().canFinish(2,[[0,1],[1,0]])
assert Solution().canFinish(1,[])
assert not Solution().canFinish(1,[[0,0]])
预测练习
学习课程 1 后,为什么不能把课程 3 立刻放进 ready?
一点提示
此时 indeg[3] 的具体数值和另一条入边是什么?
答案与理由
indeg[3]=1,因为 2→3 还没处理。只有学习课程 2 后它才降到 0,才能入队。
面试追问与解法取舍
若要求给出一种选课顺序,可保存每次出队的课程。若要判断顺序是否唯一,Kahn 过程中任何时刻出现多个可选课程,就存在多个合法顺序;这一结论以最终无环为前提。
模板迁移 / 相似题
试着不看代码,复述这三点
- 先确定边方向:先修课指向后续课。
- 入度归零才入队,不能用「已遇到一个先修」替代。
- 处理数量不足总数说明有课程被环阻塞;不必显式找出环。
208. 实现 Trie (前缀树) 中等
一句话题意
实现插入单词、完整单词查询与前缀查询的 Trie。
依次 insert("app"), search("ap"), startsWith("ap"), insert("ape"), search("ape"), search("app") → [None, False, True, None, True, True]
生活化理解
把单词存进按字母分岔的文件柜:每一层抽屉对应一个字符,多词共用前缀抽屉;终点标签表示柜子里真的登记过这个完整单词。
字典 children 节点
用字符字典表达分支,再用独立的词尾标记区分完整词和前缀,结构直观且只创建实际需要的节点。核心思路
root 表示空前缀,沿字符边走到的节点表示已经读过的整个前缀。每个节点的 children 字典负责找到下一个字符分支,end 负责区分“路径存在”和“完整单词存在”:插入 app 后,ap 的路径存在,但 search('ap') 仍应为 False。
insert 从 root 出发,每读一个字符就复用已有子节点,或在对应字典键下新建节点;最终只在最后节点设置 end=True。不同单词共享公共前缀,再从不同字符处分支。重复插入只是再次标记同一个终点,不会使已有分支消失。
_walk 复用两种查询的字符遍历:缺少某条边就返回 None,走完则返回对应前缀节点。search 要求节点存在且 end 为真,startsWith 只要求节点存在。空前缀对应始终存在的 root,所以 startsWith('') 返回 True;本题常规参数非空,扩展到空字符串时,search('') 仅在 insert('') 标记根节点后才为 True。
从 root 走过的边依次拼成当前前缀,相同前缀共享同一个节点。end 仅在插入词的末节点置 True,因此 _walk 保证路径存在,而 search 再用 end 区分完整单词。
图解
- operation
- Trie()
- node 前缀
- root
- 已存在前缀
- [""]
- end=True
- []
- 已完成返回值
- []
- operation
- insert("app"):ch="a"
- node 前缀
- a
- 已存在前缀
- ["", "a"]
- end=True
- []
- 已完成返回值
- []
- operation
- insert("app"):ch="p"
- node 前缀
- ap
- 已存在前缀
- ["", "a", "ap"]
- end=True
- []
- 已完成返回值
- []
- operation
- insert("app"):ch="p"
- node 前缀
- app
- 已存在前缀
- ["", "a", "ap", "app"]
- end=True
- []
- 已完成返回值
- []
- operation
- insert("app") 完成
- node 前缀
- app
- 已存在前缀
- ["", "a", "ap", "app"]
- end=True
- ["app"]
- 已完成返回值
- ["insert app → None"]
- operation
- search("ap")
- node 前缀
- ap
- 已存在前缀
- ["", "a", "ap", "app"]
- end=True
- ["app"]
- 已完成返回值
- ["insert app → None", "search ap → False"]
- operation
- startsWith("ap")
- node 前缀
- ap
- 已存在前缀
- ["", "a", "ap", "app"]
- end=True
- ["app"]
- 已完成返回值
- ["insert app → None", "search ap → False", "startsWith ap → True"]
- operation
- insert("ape"):已读 "ap"
- node 前缀
- ap
- 已存在前缀
- ["", "a", "ap", "app"]
- end=True
- ["app"]
- 已完成返回值
- ["insert app → None", "search ap → False", "startsWith ap → True"]
- operation
- insert("ape"):ch="e"
- node 前缀
- ape
- 已存在前缀
- ["", "a", "ap", "ape", "app"]
- end=True
- ["app"]
- 已完成返回值
- ["insert app → None", "search ap → False", "startsWith ap → True"]
- operation
- insert("ape") 完成
- node 前缀
- ape
- 已存在前缀
- ["", "a", "ap", "ape", "app"]
- end=True
- ["ape", "app"]
- 已完成返回值
- ["insert app → None", "search ap → False", "startsWith ap → True", "insert ape → None"]
- operation
- search("ape")
- node 前缀
- ape
- 已存在前缀
- ["", "a", "ap", "ape", "app"]
- end=True
- ["ape", "app"]
- 已完成返回值
- ["insert app → None", "search ap → False", "startsWith ap → True", "insert ape → None", "search ape → True"]
- operation
- search("app")
- node 前缀
- app
- 已存在前缀
- ["", "a", "ap", "ape", "app"]
- end=True
- ["ape", "app"]
- 已完成返回值
- ["insert app → None", "search ap → False", "startsWith ap → True", "insert ape → None", "search ape → True", "search app → True"]
选择步骤 12 步
变量与图例
观察 node 每次沿哪个字符移动,以及有路径的 ap 为什么仍不是完整单词。
- root
- 所有查询与插入的起点,表示空前缀。
- node
- 当前操作所指向的前缀节点;每次操作重新从根开始。
- children
- 下一字符到子节点的字典,如 ap 下有 p 与 e 两条边。
- end
- 该前缀是否曾作为完整单词插入。
- _walk
- 逐字沿边行走,缺边返回 None;成功则返回末节点。
算法步骤
- 初始化 root;新 Node 的 children={}、end=False。
- insert 从 root 逐字符行走,缺少 ch 时创建 node.children[ch],然后移动到该子节点。
- 插入的字符全部读完后,只给最终节点设置 end=True。
- 查询通过 _walk 从 root 沿字符字典行走,缺失分支立即返回 None。
- search 检查返回节点存在且 end=True;startsWith 仅检查返回节点是否存在。
代码(Python)
查看完整 Python 代码字典 children 节点 · 与上方图解对应
每个 Node 维护稀疏字典和 end,_walk 复用两种查询的字符遍历。
class Node:
def __init__(self):
self.children = {}
self.end = False
class Trie:
def __init__(self):
self.root = Node()
def insert(self, word: str) -> None:
node = self.root
for ch in word:
if ch not in node.children:
node.children[ch] = Node()
node = node.children[ch]
node.end = True
def _walk(self, text):
node = self.root
for ch in text:
node = node.children.get(ch)
if node is None:
return None
return node
def search(self, word: str) -> bool:
node = self._walk(word)
return node is not None and node.end
def startsWith(self, prefix: str) -> bool:
return self._walk(prefix) is not None运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
t=Trie()
t.insert('apple')
assert t.search('apple') and not t.search('app')
assert t.startsWith('app') and not t.startsWith('b')
t.insert('app'); t.insert('app')
assert t.search('app')
assert t.startsWith('') and not t.search('')
t.insert('')
assert t.search('')
复杂度
- 时间
- 一次插入或查询 O(L),L 为参数字符数;字典按均摊 O(1) 计。
- 空间
- 累计 O(S),S 为所有插入字符总量的上界;单次查询辅助 O(1)。
易错点与边界
- 终点标记不能由“没有子节点”代替,app 和 apple 可以同时存在。
- search 与 startsWith 的最后判断不同。
- 重复插入不应破坏更长单词。
主解之后,再比较与迁移。
其他解法 1
26 槽数组节点时间 查询 O(L),插入 O(26L)=O(L),字母表固定为 26。
把每个节点的 children 换成长度 26 的数组,children[ord(ch)−ord('a')] 直接对应一个小写字母。词尾 end 和共享前缀的语义与主解相同,search 仍检查完整路径及词尾,startsWith 仍只检查路径。它利用本题固定字符集换取直接索引,但每个节点都分配 26 个槽,分支稀疏时会保留较多空槽。
- 建立根节点,每个 Node 初始化 children=[None]*26 和 end=False。
- 插入时将每个字符换算为下标 i;该槽为 None 就创建节点,随后移动到 children[i]。
- 单词全部插入后,把最终节点的 end 设为 True。
- _walk 用相同字符下标访问子节点,遇到 None 就返回 None,否则返回走完后的节点。
- 完整查询同时检查节点与 end;前缀查询只检查节点是否存在。
class Node:
def __init__(self):
self.children = [None]*26
self.end = False
class Trie:
def __init__(self):
self.root = Node()
def insert(self, word: str) -> None:
node = self.root
for ch in word:
i = ord(ch)-ord('a')
if node.children[i] is None:
node.children[i] = Node()
node = node.children[i]
node.end = True
def _walk(self, text):
node = self.root
for ch in text:
node = node.children[ord(ch)-ord('a')]
if node is None:
return None
return node
def search(self, word: str) -> bool:
node = self._walk(word)
return node is not None and node.end
def startsWith(self, prefix: str) -> bool:
return self._walk(prefix) is not None复杂度
- 时间
- 查询 O(L),插入 O(26L)=O(L),字母表固定为 26。
- 空间
- O(26S)=O(S) 累计节点槽位;查询辅助 O(1)。
这个方法的边界
- 数组方案依赖题目限定的小写英文字母。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
t=Trie()
t.insert('cat'); t.insert('car')
assert t.search('cat') and t.search('car')
assert t.startsWith('ca') and not t.search('ca')
assert not t.startsWith('cab')
assert t.startsWith('') and not t.search('')
t.insert('')
assert t.search('')
预测练习
插入 app 与 ape 后,search("ap") 会因为 ap 有两个孩子而变成 True 吗?
一点提示
子节点数量和 end 标记分别表达什么?
答案与理由
仍是 False。ap 的两个孩子只说明存在 app、ape 的路径,ap.end 仍为 False;只有再 insert("ap") 才会把它置 True。
面试追问与解法取舍
若问内存取舍,比较真实分支密度,而不是只看同为 O(S)。Unicode 字符可用字典节点;数组方案需要重新设计字母到槽位的映射。
模板迁移 / 相似题
试着不看代码,复述这三点
- 前缀通过路径存在判断,整词还必须检查末节点 end。
- 每次操作从 root 开始,共享前缀仅在缺边时创建。
- 同一个字符在不同前缀下是不同节点,不能按字符全局合并。
46. 全排列 中等
一句话题意
给定互不相同的整数,返回全部排列;每个元素在每个排列中恰好使用一次。
nums=[1,2,3] → [[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
生活化理解
给 n 位同学排成一列:path 是已经落座的前排,used 是每个人的座位登记表。试坐一个人、安排后续、撤销试坐,再换下一个人。
used 数组回溯
path 表示排列顺序、used 表示哪些下标已选,选择与撤销一一对应,便于复现完整搜索过程。核心思路
每一层决定排列的一个位置。进入递归时,used[i] 为真当且仅当 nums[i] 已出现在当前 path 中;每层重新枚举全部下标,只能选择尚未使用的元素。因此同一排列不会重复使用一个输入位置,到 path 长度为 n 时就恰好选完全部元素。
不同排列在某个最早位置选了不同的元素,对应不同分支;每层枚举全部未使用元素,所以既无重复又无遗漏。题目保证元素互异,不需要额外的同层去重。ans 只在长度达到 n 的叶子收集完整排列。
一次选择先设置 used[i]=True,再 append 当前值,随后递归;返回时先 pop,再把 used[i] 恢复为 False,才能让同层的下一分支重新使用这个元素。保存答案必须用 path.copy(),否则各答案会引用同一个最终恢复为空的列表。撤销的是工作路径和标记,已经保存的答案副本保留。
used[i] 为 True 当且仅当 nums[i] 已被当前 path 选入;每次追加和每次撤销都同步更新两者。每层枚举所有未用下标,长度达到 n 时复制路径,因此每个排列恰被收集一次。
图解
- path
- []
- used
- [False, False, False]
- 未被占用元素
- [1, 2, 3]
- ans
- []
- path
- [1]
- used
- [True, False, False]
- 未被占用元素
- [2, 3]
- ans
- []
- path
- [1, 2]
- used
- [True, True, False]
- 未被占用元素
- [3]
- ans
- []
- path
- [1, 2, 3]
- used
- [True, True, True]
- 未被占用元素
- []
- ans
- [[1, 2, 3]]
- path
- [1]
- used
- [True, False, False]
- 未被占用元素
- [2, 3]
- ans
- [[1, 2, 3]]
- path
- [1, 3]
- used
- [True, False, True]
- 未被占用元素
- [2]
- ans
- [[1, 2, 3]]
- path
- [1, 3, 2]
- used
- [True, True, True]
- 未被占用元素
- []
- ans
- [[1, 2, 3], [1, 3, 2]]
- path
- [2, 1, 3]
- used
- [True, True, True]
- 未被占用元素
- []
- ans
- [[1, 2, 3], [1, 3, 2], [2, 1, 3]]
- path
- [2, 3, 1]
- used
- [True, True, True]
- 未被占用元素
- []
- ans
- [[1, 2, 3], [1, 3, 2], [2, 1, 3], [2, 3, 1]]
- path
- [3, 1, 2]
- used
- [True, True, True]
- 未被占用元素
- []
- ans
- [[1, 2, 3], [1, 3, 2], [2, 1, 3], [2, 3, 1], [3, 1, 2]]
- path
- [3, 2, 1]
- used
- [True, True, True]
- 未被占用元素
- []
- ans
- [[1, 2, 3], [1, 3, 2], [2, 1, 3], [2, 3, 1], [3, 1, 2], [3, 2, 1]]
- path
- []
- used
- [False, False, False]
- 未被占用元素
- [1, 2, 3]
- ans
- [[1, 2, 3], [1, 3, 2], [2, 1, 3], [2, 3, 1], [3, 1, 2], [3, 2, 1]]
选择步骤 12 步
变量与图例
关注第一个排列收集后,path 与 used 怎样一起撤销,而 ans 怎样保持不变。
- path
- 当前尚未完成或刚收集的排列,后续会被 pop 修改。
- used
- 按输入下标记录本路径是否使用该数字。
- i
- 当前层正在枚举的 nums 下标,每层都从 0 开始。
- ans
- 完整路径的独立副本,撤销 path 不影响它。
- 未被占用元素
- 所有 used=False 的数字,不代表本层尚未尝试集合;实际下一候选还受 for 循环当前位置约束,例如回到 [1] 后 2 已试完,下一次选 3。
Mermaid 决策树源码
graph TD
R["[]"] --> A["[1]"]
R --> B["[2]"]
R --> C["[3]"]
A -->|选2| A2["[1,2]"]
A2 -->|选3 收集| A23["[1,2,3]"]
A23 -.->|撤销3再撤销2| A
A -->|选3| A3["[1,3]"]
A3 -->|选2 收集| A32["[1,3,2]"]
A -->|尝试再选1| X["used[0]=True:跳过"]
B --> B1["[2,1]"]
B1 -->|收集| B13["[2,1,3]"]
B --> B3["[2,3]"]
B3 -->|收集| B31["[2,3,1]"]
C --> C1["[3,1]"]
C1 -->|收集| C12["[3,1,2]"]
C --> C2["[3,2]"]
C2 -->|收集| C21["[3,2,1]"]
C21 -.->|撤销1 2 3| R算法步骤
- 初始化空 ans、path 和全 False 的 used,调用 dfs()。
- 每次进入 dfs,若 len(path)==n,就保存 path.copy() 并返回。
- 否则枚举全部 i,跳过已使用下标;把 used[i] 设为 True,并将 nums[i] 追加到 path。
- 递归填写下一位置;返回后执行 path.pop() 和 used[i]=False,再试同层下一候选。
- 根调用的所有分支结束后返回 ans。
代码(Python)
查看完整 Python 代码used 数组回溯 · 与上方图解对应
path 保存当前顺序,used 按下标跟踪使用情况。每一层重新扫描 nums,叶子复制完整排列。
from typing import List
class Solution:
def permute(self, nums: List[int]) -> List[List[int]]:
n = len(nums)
ans, path = [], []
used = [False]*n
def dfs():
if len(path) == n:
ans.append(path.copy())
return
for i in range(n):
if used[i]:
continue
used[i] = True
path.append(nums[i])
dfs()
path.pop()
used[i] = False
dfs()
return ans运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert sorted(Solution().permute([1,2,3])) == [[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
assert Solution().permute([7]) == [[7]]
r=Solution().permute([1,2]); r[0][0]=99
assert r[1] == [2,1]
复杂度
- 时间
- O(n·n!),n 为元素数;n! 个输出各复制 n 个元素,内部扫描也在此量级内。
- 空间
- 辅助 O(n) 路径、used、栈;输出 O(n·n!)。
易错点与边界
- 保存 path 本身会导致所有答案共享对象,必须复制。
- 不能用 start 限制后续只能选更大下标,否则会生成组合而非排列。
- 元素互异是当前去重逻辑的前提;含重复值是另一个问题。
- 每个分支返回后先 path.pop(),再将 used[i] 恢复为 False,下一分支才能重新选择该元素。
主解之后,再比较与迁移。
其他解法 1
原地交换固定前缀时间 O(n·n!),叶子复制长度 n 的排列主导总时间。
用数组自身保存当前排列,first 左侧的所有位置已经固定,后缀就是当前还可选的候选。逐个把后缀元素交换到 first,再递归固定下一位置,无需 path 和 used。每个分支返回后必须用同一对下标换回,恢复后缀供下一候选使用;first 到数组长度时复制 nums 保存答案,函数结束后输入数组恢复原样。
- 建立空 ans,调用 dfs(0),从第一个位置开始固定。
- 若 first==len(nums),保存 nums.copy() 并返回。
- 枚举 i 从 first 到末尾,把 nums[i] 与 nums[first] 交换。
- 调用 dfs(first+1),返回后再次交换同一对位置,撤销本次固定。
- 全部排列生成后返回 ans。
from typing import List
class Solution:
def permute(self, nums: List[int]) -> List[List[int]]:
ans = []
def dfs(first):
if first == len(nums):
ans.append(nums.copy())
return
for i in range(first, len(nums)):
nums[first], nums[i] = nums[i], nums[first]
dfs(first+1)
nums[first], nums[i] = nums[i], nums[first]
dfs(0)
return ans复杂度
- 时间
- O(n·n!),叶子复制长度 n 的排列主导总时间。
- 空间
- 辅助 O(n) 递归栈;输出 O(n·n!),不另建路径。
这个方法的边界
- 退出递归时要恢复 used 或交换。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
a=[1,2,3]
r=Solution().permute(a)
assert len(r) == 6 and len({tuple(p) for p in r}) == 6
assert all(sorted(p)==[1,2,3] for p in r)
assert a == [1,2,3]
assert Solution().permute([0]) == [[0]]
预测练习
从 [1,2,3] 回到 path=[1] 后,used 应是什么?为什么答案中的第一组还在?
一点提示
分别追踪两次 pop 配对的 used 复位,以及叶子使用的复制操作。
答案与理由
used=[True,False,False]。3 和 2 都已撤销,1 仍是固定前缀;ans 保存 path.copy(),所以 [1,2,3] 不受后续 pop 影响。
面试追问与解法取舍
若元素可以重复,常见做法是先排序,再以“相同值且前一个副本本层尚未使用”为条件跳过重复选择。若只需要第 k 个排列,则可用阶乘分块定位,不必生成此前所有答案。
模板迁移 / 相似题
试着不看代码,复述这三点
- 排列每层枚举所有下标,used 阻止同一路径重复用数。
- 选择与撤销必须同步维护 path 和 used。
- 叶子存副本;临时工作路径与输出结果是两个容器。
78. 子集 中等
一句话题意
返回互不相同整数数组的全部子集,包含空集,答案不重复。
nums=[1,2] → [[],[2],[1],[1,2]](按原代码先不选的顺序)
生活化理解
整理旅行清单:对每件物品只回答“带”或“不带”,一串二选一决定唯一背包。另一种整理法是每次只从后面的物品中继续选,避免同一背包按不同顺序登记。
选与不选的回溯
每个元素对应选或不选两个互斥分支,能够直接解释全部子集、空集以及只在叶子收集的时机。核心思路
二叉决策树的层数是处理过的元素数。dfs(i) 表示下标小于 i 的元素已经决定,path 恰好保存其中选中的元素;i 与 path 长度不同,不选一个元素也必须推进 i。对于 nums[i],不选和选两个分支覆盖且互斥,因此 n 个元素共有 2^n 个叶子。
代码先调用 dfs(i+1) 走不选分支,不改变 path;它返回之后,再 append(nums[i]),递归走选择分支,返回时 pop 恢复路径。每次递归结束都会恢复进入该层前的 path,下一分支继承的只有此前已经选定的元素。
只有 i==n,所有位置都决定完毕时,才将 path.copy() 放入 ans。空集对应一路都不选,也是正常叶子;父节点的空路径不会提前重复收集。输入元素互异,且始终按固定下标顺序决定是否入选,所以每个子集只对应一条决策路径。
进入 dfs(i) 时,前 i 个数字都已决定选或不选,path 恰好保存其中选中的数字。每次把 i 加一,故每条从根到叶的二元决定序列对应一个唯一子集,i=n 时收集不会重复。
图解
- i
- 0
- path
- []
- 未决定数字
- [1, 2]
- ans
- []
- i
- 1
- path
- []
- 未决定数字
- [2]
- ans
- []
- i
- 2
- path
- []
- 未决定数字
- []
- ans
- [[]]
- i
- 2
- path
- [2]
- 未决定数字
- []
- ans
- [[]]
- i
- 2
- path
- [2]
- 未决定数字
- []
- ans
- [[], [2]]
- i
- 0
- path
- []
- 未决定数字
- [1, 2]
- ans
- [[], [2]]
- i
- 1
- path
- [1]
- 未决定数字
- [2]
- ans
- [[], [2]]
- i
- 2
- path
- [1]
- 未决定数字
- []
- ans
- [[], [2], [1]]
- i
- 2
- path
- [1, 2]
- 未决定数字
- []
- ans
- [[], [2], [1]]
- i
- 2
- path
- [1, 2]
- 未决定数字
- []
- ans
- [[], [2], [1], [1, 2]]
- i
- 0
- path
- []
- 未决定数字
- [1, 2]
- ans
- [[], [2], [1], [1, 2]]
- i
- None
- path
- []
- 未决定数字
- []
- ans
- [[], [2], [1], [1, 2]]
选择步骤 12 步
变量与图例
同样是 path=[],根层与叶子含义不同:要同时观察 i 和 path。
- i
- 下一个要决定的下标,i=n 表示全部决定完毕。
- path
- 前 i 个数字中被选中的项。
- ans
- 只在叶子收集的路径副本。
- 未决定数字
- nums[i:];本题每个数字都允许选与不选,无冲突剪枝。
Mermaid 决策树源码
graph TD
R["i=0 path=[]"] -->|不选1| N["i=1 path=[]"]
R -->|选1| Y["i=1 path=[1]"]
N -->|不选2 收集| NN["i=2 []"]
N -->|选2 收集| NY["i=2 [2]"]
NY -.->|撤销2 返回根| R
Y -->|不选2 收集| YN["i=2 [1]"]
Y -->|选2 收集| YY["i=2 [1,2]"]
YY -.->|撤销2再撤销1| R算法步骤
- 初始化空 ans 和 path,从 dfs(0) 开始。
- 进入 dfs(i) 时,若 i==len(nums),复制 path 到 ans 并返回。
- 先调用 dfs(i+1) 处理不选 nums[i] 的分支,保持当前 path。
- 不选分支返回后追加 nums[i],再调用 dfs(i+1);返回时 pop 撤销这次选择。
- 全部决策结束后返回 ans,其中包含空集。
代码(Python)
查看完整 Python 代码选与不选的回溯 · 与上方图解对应
i 决定处理哪个元素,与 path 长度不同;只在所有元素都决定后收集答案。
from typing import List
class Solution:
def subsets(self, nums: List[int]) -> List[List[int]]:
ans, path = [], []
def dfs(i):
if i == len(nums):
ans.append(path.copy())
return
dfs(i+1)
path.append(nums[i])
dfs(i+1)
path.pop()
dfs(0)
return ans运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert sorted(Solution().subsets([1,2,3])) == [[],[1],[1,2],[1,2,3],[1,3],[2],[2,3],[3]]
assert sorted(Solution().subsets([0])) == [[],[0]]
r=Solution().subsets([1,2])
assert len({tuple(x) for x in r}) == 4
复杂度
- 时间
- O(n·2^n),n 为元素数,包含每个子集的复制。
- 空间
- 辅助 O(n) 栈与路径;输出 O(n·2^n),有 2^n 个列表。
易错点与边界
- 空集必须存在。
- i 每层都加一,不能因为“不选”而停在同一元素。
- 子集不区分顺序,不要用全排列模板。
- 复制结果时需创建新列表。
主解之后,再比较与迁移。
其他解法 1
迭代倍增时间 O(n·2^n),列表拼接会复制旧子集。
ans 保存此前元素的全部子集。处理新元素 x 时,旧子集表示不选 x,把每个旧子集复制并附加 x 后得到 added,表示选择 x;这两部分不重叠,合并后便覆盖新前缀的全部子集。added 必须只根据本轮开始时的 ans 构造,不能边遍历同一个列表边持续追加。列表拼接会复制内容;n≥1 时所有输出子集的元素总数为 n·2^(n−1),不能忽略复制成本只报 O(2^n)。
- 以 ans=[[]] 开始,表示尚未处理任何元素时只有空集。
- 逐个读取 x,用当前 ans 中每个 subset 构造新列表 subset+[x],得到 added。
- added 全部构造完成后执行 ans.extend(added),同时保留旧子集。
- 所有元素处理结束后返回 ans。
from typing import List
class Solution:
def subsets(self, nums: List[int]) -> List[List[int]]:
ans = [[]]
for x in nums:
added = [subset+[x] for subset in ans]
ans.extend(added)
return ans复杂度
- 时间
- O(n·2^n),列表拼接会复制旧子集。
- 空间
- 输出 O(n·2^n);added 的临时引用列表最坏 O(2^n),其中子列表随后成为输出,没有递归栈。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert sorted(Solution().subsets([1,2])) == [[],[1],[1,2],[2]]
assert sorted(Solution().subsets([-1])) == [[],[-1]]
r=Solution().subsets([1,2]); r[1].append(9)
assert r[3] == [1,2]
预测练习
根层 path=[] 和最左叶子的 path=[] 一样,为什么前者不立即收集?
一点提示
比较两个节点的 i。
答案与理由
根层 i=0,两个数字还未决定;叶子 i=2,已明确两次都不选。只在 i=n 收集才能让空集出现一次。
面试追问与解法取舍
还可枚举 0 到 2^n-1 的位掩码,第 i 位为 1 就取 nums[i],时间仍为 O(n·2^n)。若输入允许重复,排序后按层跳过相同值或按重复次数分组,才能避免重复子集。
模板迁移 / 相似题
试着不看代码,复述这三点
- 子集按位置做二元决定,不需要排列题的 used 数组。
- 空路径不等于完成,必须用 i 判断是否到叶子。
- 本题没有限制条件,所有 2ⁿ 个选或不选叶子都要保留。
17. 电话号码的字母组合 中等
一句话题意
给定仅含 2 至 9 的数字字符串,返回电话键盘上每位数字可映射出的所有字母串;空输入返回空列表。
digits="23" → ["ad","ae","af","bd","be","bf","cd","ce","cf"]
生活化理解
每位数字是一道菜的备选口味:数字 2 提供 a/b/c,数字 3 提供 d/e/f。每道菜恰好选一种口味,组合成一整套套餐,顺序由数字位置固定。
按数字位置回溯
每层只负责一位数字,列表路径逐字母加入和撤销,只在完整组合处拼接字符串,状态容易讲清。核心思路
dfs(i) 表示前 i 位数字已经各选了一个字母,path 长度始终等于 i。当前层只枚举 mapping[digits[i]],因此不会把后面数字的字母放到前面,也不会少选或多选。每位数字的选择独立,不需要 used 数组,同样的字母可以在不同位置出现。
每个完整字符串对应决策树中唯一的一条根到叶路径。到 i==len(digits) 时,使用 ''.join(path) 构造独立字符串并加入 ans;在此前只扩展前缀,不收集未完成的组合。path 的单步 append 和 pop 为 O(1),拼接集中在叶子进行。
每次追加一个候选字母后递归处理下一位,返回时 pop 撤销该字母,再试同一数字的下一个候选;父层已经选好的字母保留。空 digits 的题目语义是没有组合,所以在进入递归前直接返回 []。
进入 dfs(i) 时,path 长度恰为 i,且第 j 个字母来自 mapping[digits[j]]。每层完整枚举对应数字的字母,到 i=len(digits) 才拼接收集,因此每个合法组合出现一次。
图解
- i
- 0
- path
- []
- 本层候选
- abc
- ans
- []
- i
- 1
- path
- ["a"]
- 本层候选
- def
- ans
- []
- i
- 2
- path
- ["a", "d"]
- 本层候选
- 无:已到叶子
- ans
- ["ad"]
- i
- 2
- path
- ["a", "e"]
- 本层候选
- 无:已到叶子
- ans
- ["ad", "ae"]
- i
- 2
- path
- ["a", "f"]
- 本层候选
- 无:已到叶子
- ans
- ["ad", "ae", "af"]
- i
- 2
- path
- ["b", "d"]
- 本层候选
- 无:已到叶子
- ans
- ["ad", "ae", "af", "bd"]
- i
- 2
- path
- ["b", "e"]
- 本层候选
- 无:已到叶子
- ans
- ["ad", "ae", "af", "bd", "be"]
- i
- 2
- path
- ["b", "f"]
- 本层候选
- 无:已到叶子
- ans
- ["ad", "ae", "af", "bd", "be", "bf"]
- i
- 2
- path
- ["c", "d"]
- 本层候选
- 无:已到叶子
- ans
- ["ad", "ae", "af", "bd", "be", "bf", "cd"]
- i
- 2
- path
- ["c", "e"]
- 本层候选
- 无:已到叶子
- ans
- ["ad", "ae", "af", "bd", "be", "bf", "cd", "ce"]
- i
- 2
- path
- ["c", "f"]
- 本层候选
- 无:已到叶子
- ans
- ["ad", "ae", "af", "bd", "be", "bf", "cd", "ce", "cf"]
- i
- 0
- path
- []
- 本层候选
- abc
- ans
- ["ad", "ae", "af", "bd", "be", "bf", "cd", "ce", "cf"]
选择步骤 12 步
变量与图例
观察 i 决定哪一组字母可选;撤销第二位时第一位保留,换第一位时第二层重新从 d 开始。
- i
- 当前数字下标,也等于已选的字母数。
- mapping
- 数字到有序字母候选的映射,本例 2→abc、3→def。
- path
- 当前逐位选择的字母列表。
- ans
- 叶子拼接成的完整字符串列表。
Mermaid 决策树源码
graph TD
R["i=0 空路径;候选 abc"] -->|选a| A["i=1 a;候选 def"]
R -->|选b| B["i=1 b;候选 def"]
R -->|选c| C["i=1 c;候选 def"]
A -->|选d 收集| AD["ad"]
AD -.->|撤销d| A
A -->|选e 收集| AE["ae"]
A -->|选f 收集| AF["af"]
AF -.->|撤销f再撤销a| R
B -->|收集| BD["bd"]
B -->|收集| BE["be"]
B -->|收集| BF["bf"]
C -->|收集| CD["cd"]
C -->|收集| CE["ce"]
C -->|收集| CF["cf"]
CF -.->|撤销f再撤销c| R算法步骤
- 若 digits 为空,直接返回 [];否则建立数字 2 到 9 的固定字母映射。
- 初始化空 ans、path,调用 dfs(0)。
- 若 i==len(digits),把 ''.join(path) 加入 ans 并返回。
- 否则逐个选择 mapping[digits[i]] 中的字母,追加到 path 后调用 dfs(i+1)。
- 每个分支返回时 pop 最后一个字母,再试下一候选;根调用结束后返回 ans。
代码(Python)
查看完整 Python 代码按数字位置回溯 · 与上方图解对应
path 列表使单步追加与撤销为 O(1),只在叶子 join,避免每层创建字符串。
from typing import List
class Solution:
def letterCombinations(self, digits: str) -> List[str]:
if not digits:
return []
mapping = {'2':'abc','3':'def','4':'ghi','5':'jkl','6':'mno','7':'pqrs','8':'tuv','9':'wxyz'}
ans, path = [], []
def dfs(i):
if i == len(digits):
ans.append(''.join(path))
return
for ch in mapping[digits[i]]:
path.append(ch)
dfs(i+1)
path.pop()
dfs(0)
return ans运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert Solution().letterCombinations('23') == ['ad','ae','af','bd','be','bf','cd','ce','cf']
assert Solution().letterCombinations('') == []
assert Solution().letterCombinations('7') == ['p','q','r','s']
复杂度
- 时间
- O(nP),n 为数字长度,P=3^a·4^b,a/b 分别是映射为 3/4 个字母的数字个数。
- 空间
- 辅助 O(n) 栈与路径;输出 O(nP)。
易错点与边界
- 空输入返回 [],不是 [""]。
- 7 和 9 各对应四个字母,不能统一按三叉树估算精确输出数。
- 递归层由数字下标决定,每层恰好取一个字母。
- 数字只来自 2 至 9,不需要编造 0、1 的映射。
主解之后,再比较与迁移。
其他解法 1
迭代笛卡尔积时间 O(nP),P 为最终组合数;每次 prefix+ch 都分配并复制字符串。
ans 维护已处理数字的所有字母前缀,每轮把每个旧前缀与当前数字的每个映射字母拼接,生成完整的下一层。内部用 [''] 作为启动拼接的单位元素,但空输入对外仍应返回 [],必须先单独处理。它不维护可变 path,也无需撤销;代价是同时保留一整层中间字符串,构造下一层时旧层仍存在,每次 prefix+ch 都会创建新字符串。
- digits 为空时返回 [];否则建立数字到字母的映射,令 ans=['']。
- 依次读取每个 digit,以本轮旧 ans 中的每个 prefix 配对 mapping[digit] 中的每个 ch。
- 将所有 prefix+ch 构成新列表,作为下一轮 ans。
- 全部数字处理完成后,返回完整组合列表 ans。
from typing import List
class Solution:
def letterCombinations(self, digits: str) -> List[str]:
if not digits:
return []
mapping = {'2':'abc','3':'def','4':'ghi','5':'jkl','6':'mno','7':'pqrs','8':'tuv','9':'wxyz'}
ans = ['']
for digit in digits:
ans = [prefix+ch for prefix in ans for ch in mapping[digit]]
return ans复杂度
- 时间
- O(nP),P 为最终组合数;每次 prefix+ch 都分配并复制字符串。
- 空间
- 输出 O(nP),构造下一层时旧层仍存在,总峰值 O(nP)。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert Solution().letterCombinations('2') == ['a','b','c']
assert Solution().letterCombinations('') == []
r=Solution().letterCombinations('79')
assert len(r)==16 and r[0]=='pw' and r[-1]=='sz'
assert len(set(r)) == 16
预测练习
收集 af 后,下一组为什么是 bd,而不是 ba?
一点提示
回到根后第二位仍对应哪个数字?
答案与理由
撤销 f 和 a 后根层改选 b;新的 dfs(1) 仍处理数字 3,其候选从 d 开始。a 属于数字 2,不能作为本例第二位候选。
面试追问与解法取舍
若只需要组合数量,直接乘各数字对应字母数即可,不需要枚举。若输出要逐条消费,可把回溯改成生成器,将保留全部结果的空间降为路径与当前字符串的 O(n),但完整输出的总时间仍不可省。
模板迁移 / 相似题
试着不看代码,复述这三点
- 递归深度对应数字位置,候选集合由这一位映射决定。
- 每层只撤销自己追加的字母,保留上层前缀。
- 没有额外冲突限制时完整枚举,不要套用排列题的 used。
39. 组合总和 中等
一句话题意
给定互异正整数候选值,每个值可无限次使用,返回和为 target 的所有不重复组合。
candidates=[2,3,5], target=5 → [[2,3],[5]]
生活化理解
用固定面额筹码凑出总额:path 是已拿筹码,remain 是还差的金额。为避免把“2 再 3”和“3 再 2”重复记账,约定只按面额不下降的顺序拿筹码。
排序后循环回溯
start 同时解释组合去重和允许重复选取,排序后的剩余值剪枝也能直接由大小关系证明。核心思路
先建立排序副本 nums。dfs(start,remain) 只允许从下标 start 及后面选择,path 中的候选下标因此非递减,并保持 sum(path)+remain=target。选择下标 i 后仍递归到 i,表示当前候选还能重复使用;start 不后退则排除 [2,3] 与 [3,2] 这样的排列重复。
候选都是正整数,每次选择都会使 remain 减少,因此搜索会终止。枚举到 x=nums[i] 时,若 x>remain,排序保证后面的数也都太大,可以直接 break 结束本层。这是有序性带来的剪枝;不需要先走进一个负剩余值的分支再返回。
remain==0 时,复制 path 保存一个完整答案。每个合法组合都有唯一的非递减表示,且循环枚举了所有允许的下一个候选,所以既不重复也不遗漏。递归返回后只需 pop 本次追加的值;remain 是各调用自己的参数,父层原值随返回自然恢复,不需要手动加回。
每个 dfs(start,remain) 中,sum(path)+remain 始终等于 target,且路径按候选下标非递减。递归传 i 允许重复当前数,start 不后退排除排列重复;排序后 x>remain 能一次排除后续更大候选。
图解
- path
- []
- start
- 0
- remain
- 5
- 候选 nums[start:]
- [2, 3, 5]
- ans
- []
- path
- [2]
- start
- 0
- remain
- 3
- 候选 nums[start:]
- [2, 3, 5]
- ans
- []
- path
- [2, 2]
- start
- 0
- remain
- 1
- 候选 nums[start:]
- [2, 3, 5]
- ans
- []
- path
- [2, 2]
- start
- 0
- remain
- 1
- 候选 nums[start:]
- [2, 3, 5]
- ans
- []
- path
- [2, 3]
- start
- 1
- remain
- 0
- 候选 nums[start:]
- [3, 5]
- ans
- [[2, 3]]
- path
- [2]
- start
- 0
- remain
- 3
- 候选 nums[start:]
- [2, 3, 5]
- ans
- [[2, 3]]
- path
- [3]
- start
- 1
- remain
- 2
- 候选 nums[start:]
- [3, 5]
- ans
- [[2, 3]]
- path
- [3]
- start
- 1
- remain
- 2
- 候选 nums[start:]
- [3, 5]
- ans
- [[2, 3]]
- path
- [5]
- start
- 2
- remain
- 0
- 候选 nums[start:]
- [5]
- ans
- [[2, 3], [5]]
- path
- []
- start
- 0
- remain
- 5
- 候选 nums[start:]
- [2, 3, 5]
- ans
- [[2, 3], [5]]
- path
- []
- start
- 0
- remain
- 5
- 候选 nums[start:]
- [2, 3, 5]
- ans
- [[2, 3], [5]]
选择步骤 11 步
变量与图例
同时盯住 remain 与 start:一个限制还能补多少,另一个限制还能从哪些下标选。
- nums
- 排序后的候选 [2,3,5]。
- start
- 本层第一个允许枚举的下标,不是已经选的个数。
- remain
- 当前路径还需补足的和。
- path
- 当前非递减组合,共享列表,退出分支需 pop。
- ans
- remain=0 时复制收集的完整组合。
Mermaid 决策树源码
graph TD
R["[] start=0 remain=5"] -->|选2| A["[2] start=0 remain=3"]
A -->|再选2| AA["[2,2] remain=1"]
AA --> X["2大于1:break"]
X -.->|返回并撤销末尾2| A
A -->|选3 收集| AB["[2,3] remain=0"]
AB -.->|撤销3| A
A --> AX["下一候选5大于3:break"]
AX -.->|返回并撤销2| R
R -->|选3| B["[3] start=1 remain=2"]
B --> BX["候选仅3和5;3大于2:break"]
BX -.->|撤销3| R
R -->|选5 收集| C["[5] start=2 remain=0"]
C -.->|撤销5 返回答案| R算法步骤
- 建立 nums=sorted(candidates)、空 ans 和 path,调用 dfs(0,target)。
- 若 remain==0,保存 path.copy() 并立即返回。
- 从 start 起枚举 i,令 x=nums[i];x>remain 时 break 结束本层。
- 追加 x 后调用 dfs(i,remain−x),保留下标 i 以允许再次使用当前候选。
- 递归返回后 pop 本次追加值,再试同层下一候选;根调用结束后返回 ans。
代码(Python)
查看完整 Python 代码排序后循环回溯 · 与上方图解对应
start 防止组合次序重复,remain 支持单调剪枝,path.copy() 冻结当前解。sorted 保留原数组。
from typing import List
class Solution:
def combinationSum(self, candidates: List[int], target: int) -> List[List[int]]:
nums = sorted(candidates)
ans, path = [], []
def dfs(start, remain):
if remain == 0:
ans.append(path.copy())
return
for i in range(start, len(nums)):
x = nums[i]
if x > remain:
break
path.append(x)
dfs(i, remain-x)
path.pop()
dfs(0, target)
return ans运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert sorted(Solution().combinationSum([2,3,6,7],7)) == [[2,2,3],[7]]
assert Solution().combinationSum([2],1) == []
assert Solution().combinationSum([2],4) == [[2,2]]
assert sorted(Solution().combinationSum([2,3,5],8)) == [[2,2,2,2],[2,3,3],[3,5]]
复杂度
- 时间
- 设 n 为候选数,L=floor(target/min(candidates)),K 为答案数,T 为实际访问的前缀数。时间 O(n log n+nT+KL);保守有 T≤C(n+L,L),因此不能笼统当成 O(2^n)。
- 空间
- 辅助 O(n+L):排序副本与排序临时空间 O(n)、路径和栈 O(L);输出 O(KL) 上界。
易错点与边界
- 选中 i 后传 i,传 i+1 会错误禁止重复使用。
- 递归重新从 0 枚举会让不同顺序的同一组合重复。
- break 剪枝依赖已排序且候选为正数。
- target 不可凑出时返回 [],不要把空路径当答案。
- 若 target 很大且最小候选很小,递归深度会增长;Python 实现需留意栈深。
主解之后,再比较与迁移。
其他解法 1
选当前候选或跳过的二叉回溯时间 设 n 为候选数,L 为最多选取个数,K 为答案数,T 为访问的二叉状态数,时间 O(n log n+T+KL)。粗上界 T=O(2^(n+L)),这是便于证明的宽松界,不是紧确估计。
把循环枚举改成两个明确分支:继续用当前候选,或永远跳过当前候选。dfs(i,remain) 的选择分支保持 i,允许重复取 nums[i];跳过分支进入 i+1,之后不能再回头选择它。每个候选的使用次数由“选择若干次后跳过”唯一确定,因此组合不重复。排序后若当前候选已超过 remain,后面的候选也无法使用,直接结束该状态。
- 建立排序副本 nums、空 ans 和 path,调用 dfs(0,target)。
- 先检查 remain==0,成立时复制 path 并返回;否则 i 到末尾或 nums[i]>remain 时返回。
- 追加 nums[i],调用 dfs(i,remain−nums[i]),表示继续使用当前候选。
- 选择分支返回后先 pop 恢复 path,再调用 dfs(i+1,remain),表示从此跳过当前候选。
- 根调用结束后返回 ans。
from typing import List
class Solution:
def combinationSum(self, candidates: List[int], target: int) -> List[List[int]]:
nums = sorted(candidates)
ans, path = [], []
def dfs(i, remain):
if remain == 0:
ans.append(path.copy())
return
if i == len(nums) or nums[i] > remain:
return
path.append(nums[i])
dfs(i, remain-nums[i])
path.pop()
dfs(i+1, remain)
dfs(0, target)
return ans复杂度
- 时间
- 设 n 为候选数,L 为最多选取个数,K 为答案数,T 为访问的二叉状态数,时间 O(n log n+T+KL)。粗上界 T=O(2^(n+L)),这是便于证明的宽松界,不是紧确估计。
- 空间
- 辅助 O(n+L),递归链包含至多 L 次选择与 n 次跳过;输出 O(KL) 上界。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert sorted(Solution().combinationSum([2,3,6,7],7)) == [[2,2,3],[7]]
assert Solution().combinationSum([5],3) == []
assert Solution().combinationSum([3],9) == [[3,3,3]]
assert sorted(Solution().combinationSum([2,3],6)) == [[2,2,2],[3,3]]
预测练习
path=[3]、remain=2 时,为什么不能补一个 2?
一点提示
此时 start 是多少,另一个分支已收集什么?
答案与理由
start=1,候选从 nums[1]=3 开始。补下标 0 的 2 会生成 [3,2],与已收集 [2,3] 是同一组合;因此本层因 3>2 直接结束。
面试追问与解法取舍
输出可能非常多,复杂度应包含 K 和每个解的长度。若候选只允许使用一次,需要选中后进入 i+1;若候选输入本身还允许重复值,则需再加同层去重,这两项变化不能混为一谈。
模板迁移 / 相似题
试着不看代码,复述这三点
- remain 控制目标和,start 控制组合的非递减选择顺序。
- 允许重复使用时递归传 i;传 i+1 会漏掉重复取数的解。
- 排序后首个过大候选触发 break,不必继续尝试更大数。
22. 括号生成 中等
一句话题意
生成由 n 对括号组成的全部合法括号字符串。
n=2 → ["(())","()()"]
生活化理解
登记进出剧场的人数:左括号是入场,右括号是离场,任何时刻离场人数不能超过入场人数。最终两边都恰好 n 次,账目才能结清。
按左右括号计数回溯
用已使用的左右括号数限制每一步选择,递归中直接保持合法前缀,无需生成后再筛选。核心思路
left、right 分别表示当前已用左括号和右括号数,每个递归状态保持 0≤right≤left≤n。left<n 才能加左括号,right<left 才能加右括号;后一条件表示已有一个未闭合的左括号可供配对,因此任何前缀都不会出现多余右括号。
代码先尝试加左括号的分支,返回并 pop 后,再根据当前层的 right<left 尝试加右括号。两条 if 独立,某些前缀可以分别沿两种选择继续;每次递归后都要撤销自己追加的字符。left/right 是各层参数,返回时自然恢复,共享的 path 则通过 pop 恢复,ans 中已保存的字符串不撤销。
长度达到 2n 时,计数上界与前缀不变量共同强制 left=right=n,直接 join(path) 保存即可,无需再扫描验证。合法括号串的每个前缀都满足上述选择条件,因此剪枝不会漏掉合法答案。
所有进入递归的前缀都满足 0≤right≤left≤n,分别由两条选择条件维护。因此长度达到 2n 时必有 left=right=n,直接收集就是合法字符串,不必事后过滤。
图解
- path
- []
- left
- 0
- right
- 0
- 合法下一字符
- ["("]
- ans
- []
- return
- 尚未从顶层返回
- path
- ["("]
- left
- 1
- right
- 0
- 合法下一字符
- ["(", ")"]
- ans
- []
- return
- 尚未从顶层返回
- path
- ["(", "("]
- left
- 2
- right
- 0
- 合法下一字符
- [")"]
- ans
- []
- return
- 尚未从顶层返回
- path
- ["(", "(", ")"]
- left
- 2
- right
- 1
- 合法下一字符
- [")"]
- ans
- []
- return
- 尚未从顶层返回
- path
- ["(", "(", ")", ")"]
- left
- 2
- right
- 2
- 合法下一字符
- []
- ans
- ["(())"]
- return
- 尚未从顶层返回
- path
- ["("]
- left
- 1
- right
- 0
- 合法下一字符
- ["(", ")"]
- ans
- ["(())"]
- return
- 尚未从顶层返回
- path
- ["(", ")"]
- left
- 1
- right
- 1
- 合法下一字符
- ["("]
- ans
- ["(())"]
- return
- 尚未从顶层返回
- path
- ["(", ")", "("]
- left
- 2
- right
- 1
- 合法下一字符
- [")"]
- ans
- ["(())"]
- return
- 尚未从顶层返回
- path
- ["(", ")", "(", ")"]
- left
- 2
- right
- 2
- 合法下一字符
- []
- ans
- ["(())", "()()"]
- return
- 尚未从顶层返回
- path
- ["(", ")"]
- left
- 1
- right
- 1
- 合法下一字符
- ["("]
- ans
- ["(())", "()()"]
- return
- 尚未从顶层返回
- path
- []
- left
- 0
- right
- 0
- 合法下一字符
- ["("]
- ans
- ["(())", "()()"]
- return
- 尚未从顶层返回
- path
- []
- left
- 0
- right
- 0
- 合法下一字符
- ["("]
- ans
- ["(())", "()()"]
- return
- ["(())", "()()"]
选择步骤 12 步
变量与图例
观察 left-right:它是还没配对的左括号数;降到零时下一位不能加右括号。
- left
- 当前前缀已放入的左括号数量。
- right
- 当前前缀已放入的右括号数量。
- path
- 当前括号字符列表,返回父层时 pop 撤销。
- left-right
- 当前尚未匹配的左括号数。
- ans
- 长度达到 2n 时 join 得到的合法完整字符串。
Mermaid 决策树源码
graph TD
R["空串 left=0 right=0"] -->|加左| A["( left=1 right=0"]
R --> X["加右不满足0小于0:剪枝"]
A -->|加左| B["(( left=2 right=0"]
B --> BX["left=n:不能再加左"]
B -->|加右| C["(() left=2 right=1"]
C -->|加右 收集| D["(())"]
D -.->|撤销右 右 左| A
A -->|加右| E["() left=1 right=1"]
E -->|加左| F["()( left=2 right=1"]
F -->|加右 收集| G["()()"]
G -.->|撤销右再撤销左| E
E --> EX["right=left:不能再加右"]
EX -.->|撤销右再撤销左| R算法步骤
- 初始化空 ans、path,调用 dfs(0,0)。
- 若 len(path)==2*n,就把 ''.join(path) 加入 ans 并返回。
- 若 left<n,追加 '(',递归 dfs(left+1,right),返回后 pop。
- 再判断 right<left;若成立,追加 ')',递归 dfs(left,right+1),返回后 pop。
- 根调用结束后返回 ans。
代码(Python)
查看完整 Python 代码按左右括号计数回溯 · 与上方图解对应
left/right 让不合法前缀不进入递归。path 使用可变列表,叶子才构造字符串。
from typing import List
class Solution:
def generateParenthesis(self, n: int) -> List[str]:
ans, path = [], []
def dfs(left, right):
if len(path) == 2*n:
ans.append(''.join(path))
return
if left < n:
path.append('(')
dfs(left+1, right)
path.pop()
if right < left:
path.append(')')
dfs(left, right+1)
path.pop()
dfs(0, 0)
return ans运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert Solution().generateParenthesis(3) == ['((()))','(()())','(())()','()(())','()()()']
assert Solution().generateParenthesis(1) == ['()']
assert sorted(Solution().generateParenthesis(2)) == ['(())','()()']
复杂度
- 时间
- O(nC_n),C_n=(1/(n+1))·C(2n,n) 是第 n 个 Catalan 数;最终有 C_n 个长度 2n 的字符串。
- 空间
- 辅助 O(n) 路径与递归栈;输出 O(nC_n)。
易错点与边界
- right<left 才能加右括号,不是 right<n 就行。
- left<n 保证左括号总数受限。
主解之后,再比较与迁移。
其他解法 1
按第一个匹配括号分解的 DP时间 O(nC_n),计算各级共 O(Σ(kC_k))=O(nC_n),包含 Python 字符串拼接成本。
每个非空合法括号串都能唯一写成 (A)B:A 在第一个左括号的匹配范围内,B 是该匹配右括号后的部分,二者也都合法。dp[pairs] 保存 pairs 对括号的所有结果;枚举 A 的括号对数 inside,组合 dp[inside] 与 dp[pairs−1−inside]。第一个匹配位置唯一,所以不会重复生成。dp[0]=[''] 是拼接单位元,允许 A 或 B 为空;与回溯只保留当前前缀不同,DP 会保存各个较小规模的完整字符串。
- 创建 dp[0..n],将 dp[0] 设为 ['']。
- 按 pairs 从 1 到 n 计算结果,枚举 inside 从 0 到 pairs−1。
- 从 dp[inside] 取 a,从 dp[pairs−1−inside] 取 b,构造 '('+a+')'+b。
- 把每个构造结果追加到 dp[pairs];所有规模计算完后返回 dp[n]。
from typing import List
class Solution:
def generateParenthesis(self, n: int) -> List[str]:
dp = [[] for _ in range(n+1)]
dp[0] = ['']
for pairs in range(1, n+1):
for inside in range(pairs):
for a in dp[inside]:
for b in dp[pairs-1-inside]:
dp[pairs].append('('+a+')'+b)
return dp[n]复杂度
- 时间
- O(nC_n),计算各级共 O(Σ(kC_k))=O(nC_n),包含 Python 字符串拼接成本。
- 空间
- 总保留空间 O(nC_n),包括 dp 的较小规模字符串与最终输出;无递归栈。
这个方法的边界
- DP 中空串是组合的单位元;dp[0] 若设为空列表,后续全部结果都为空。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert sorted(Solution().generateParenthesis(3)) == ['((()))','(()())','(())()','()(())','()()()']
assert Solution().generateParenthesis(1) == ['()']
r=Solution().generateParenthesis(4)
assert len(r)==14 and len(set(r))==14
预测练习
前缀 () 时 right<n 仍成立,为什么不能继续加右括号?
一点提示
检查前缀需要满足的关系,而不只看最终总数上限。
答案与理由
因为 right=left=1,已经没有尚未匹配的左括号。再加右会得到 ()),违反 right≤left;正确条件是 right<left。
面试追问与解法取舍
若只问合法串数量,DP 可以存整数:count[n]=Σ count[k]·count[n-1-k],时间 O(n²)、空间 O(n),无需保留字符串。生成所有结果时,Catalan 数规模的输出本身就不可避免。
其他思路的边界
- 每层都复制整个字符串也能实现,但需计入字符串复制。
模板迁移 / 相似题
试着不看代码,复述这三点
- 加左只需 left<n,加右必须 right<left。
- 前缀约束保证叶子合法,避免先生成再过滤。
- 路径与参数随回溯恢复,已收集字符串保留。
79. 单词搜索 中等
一句话题意
判断能否通过网格中上下左右相邻的格子依次拼出 word,同一条路径不能重复使用同一格。
board=["ABC","BDE"], word="ABC" → True;先失败于向下 B,再成功于向右 B
生活化理解
在字母地砖上踩出暗号:每踩一格读一个字母,一次尝试中不能重踩同一块地砖。走错就沿原路退回并撤销脚印,让其他路线仍能使用那些格子。
路径坐标集合 DFS
用坐标集合直接表达当前路径的占用状态,失败重试与成功返回的撤销过程清楚,同时保持棋盘内容不变。核心思路
dfs(r,c,i) 尝试让格子 (r,c) 匹配 word[i]。入口先拒绝越界坐标、已经在 used 中的坐标,以及字符不匹配的格子。used 保存当前递归链已占用的祖先格,限制的是坐标而不是字母;两个不同位置即使字符相同,也可以分别参与尝试。
若当前格匹配的已经是末字符,直接返回 True;这条判断在 used.add 之前,所以末格无需加入和撤销。否则,把当前坐标加入 used,再按上、下、左、右尝试下一字符,用 any 得到 found。一旦某个邻居成功,any 停止后续尝试,但当前层仍先执行 used.remove((r,c)),再返回 found。
访问标记只属于当前路径,不能像岛屿遍历那样永久保留。无论当前分支成功还是失败,普通匹配格退出前都会撤销自己,恢复为父层原有的 used;这样既禁止一条路径重复用格,又允许另一条尝试使用先前失败分支的格子。外层从每个格子尝试起点,任一起点成功即返回 True,全部失败才返回 False,board 始终不被修改。
used 只包含当前递归路径中尚未退出、且不是终止末格的匹配坐标;进入普通匹配格时加入,返回时撤销。任何邻居尝试都会拒绝 used 中的坐标,因此一条路径不重复用格,而失败分支退出后其他路径仍可使用该格。
图解
- (r,c)
- None
- i
- 0
- word[i]
- A
- 匹配路径(教学标注)
- []
- used
- {}
- found / 返回值
- 尚未计算
- (r,c)
- (0, 0)
- i
- 0
- word[i]
- A
- 匹配路径(教学标注)
- [[0, 0]]
- used
- {(0, 0)}
- found / 返回值
- 等待邻居结果
- (r,c)
- (1, 0)
- i
- 1
- word[i]
- B
- 匹配路径(教学标注)
- [[0, 0], [1, 0]]
- used
- {(0, 0), (1, 0)}
- found / 返回值
- 等待 C
- (r,c)
- (0, 0)
- i
- 2
- word[i]
- C
- 匹配路径(教学标注)
- [[0, 0], [1, 0]]
- used
- {(0, 0), (1, 0)}
- found / 返回值
- 该邻居 False
- (r,c)
- (1, 1)
- i
- 2
- word[i]
- C
- 匹配路径(教学标注)
- [[0, 0], [1, 0]]
- used
- {(0, 0), (1, 0)}
- found / 返回值
- B(1,0) 的 found=False
- (r,c)
- (0, 0)
- i
- 0
- word[i]
- A
- 匹配路径(教学标注)
- [[0, 0]]
- used
- {(0, 0)}
- found / 返回值
- 下方分支 False;A 继续
- (r,c)
- (0, 1)
- i
- 1
- word[i]
- B
- 匹配路径(教学标注)
- [[0, 0], [0, 1]]
- used
- {(0, 0), (0, 1)}
- found / 返回值
- 等待 C
- (r,c)
- (0, 1)
- i
- 1
- word[i]
- B
- 匹配路径(教学标注)
- [[0, 0], [0, 1]]
- used
- {(0, 0), (0, 1)}
- found / 返回值
- 前三个邻居均 False
- (r,c)
- (0, 2)
- i
- 2
- word[i]
- C
- 匹配路径(教学标注)
- [[0, 0], [0, 1], [0, 2]]
- used
- {(0, 0), (0, 1)}
- found / 返回值
- C 调用返回 True
- (r,c)
- (0, 0)
- i
- 0
- word[i]
- A
- 匹配路径(教学标注)
- [[0, 0]]
- used
- {(0, 0)}
- found / 返回值
- B(0,1) 的 found=True 已返回
- (r,c)
- (0, 0)
- i
- 0
- word[i]
- A
- 匹配路径(教学标注)
- []
- used
- {}
- found / 返回值
- A(0,0) 的 found=True
- (r,c)
- None
- i
- None
- word[i]
- 完成
- 匹配路径(教学标注)
- []
- used
- {}
- found / 返回值
- True
选择步骤 12 步
变量与图例
观察同一个起点先走下方 B 失败,再撤销坐标、保留 A,转向右方 B 找到 C。
- (r,c), i
- 当前尝试格与待匹配的 word 下标,失败尝试不进入有效路径。
- used
- 当前调用链中已占用的坐标集合,按路径撤销。
- 匹配路径
- 图上的教学标注,表示已匹配的调用链;源码没有另建 path。
- found
- 当前层四邻递归结果的 any,成功时短路。
- 末字母
- 匹配到 i=len(word)-1 时直接返回,不执行 used.add。
Mermaid 决策树源码
graph TD
R["A(0,0), i=0;used={A}"] -->|上| O["越界 False"]
R -->|下 选B| D["B(1,0), i=1;used={A,B下}"]
D -->|上| X["A 已用:剪枝"]
D -->|下和左| O2["越界 False"]
D -->|右| E["D(1,1) 不等于 C:False"]
E -.->|四邻失败 撤销B下| R
R -->|左| O3["越界 False"]
R -->|右 选B| B["B(0,1), i=1;used={A,B右}"]
B -->|上 下 左| X2["越界 / D不符 / A已用:False"]
B -->|右| C["C(0,2), i=2;末字母返回True"]
C -.->|未add末格;B撤销自己| A2["used={A};向A返回True"]
A2 -.->|A撤销自己| T["used={};外层返回True"]算法步骤
- 建立空 used,以每个棋盘坐标为候选起点调用 dfs(r,c,0),用外层 any 判断是否存在成功路径。
- 每次进入 dfs,先检查范围、当前坐标是否已在 used 中、字符是否等于 word[i];任一不满足就返回 False。
- 若 i 已是单词最后一位,直接返回 True,此时还没有把当前格加入 used。
- 否则把 (r,c) 加入 used,按上、下、左、右递归匹配 i+1,用 any 的结果记录 found。
- 先从 used 撤销当前坐标,再返回 found;成功与失败都执行这一步。
代码(Python)
查看完整 Python 代码路径坐标集合 DFS · 与上方图解对应
used 仅记录当前尝试中的格子,退出 dfs 时 remove;字符相同但坐标不同的格子可以同时使用。
from typing import List
class Solution:
def exist(self, board: List[List[str]], word: str) -> bool:
m, n = len(board), len(board[0])
used = set()
def dfs(r,c,i):
if not (0 <= r < m and 0 <= c < n) or (r,c) in used or board[r][c] != word[i]:
return False
if i == len(word)-1:
return True
used.add((r,c))
found = any(dfs(x,y,i+1) for x,y in ((r-1,c),(r+1,c),(r,c-1),(r,c+1)))
used.remove((r,c))
return found
return any(dfs(r,c,0) for r in range(m) for c in range(n))运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
b=[list('ABCE'),list('SFCS'),list('ADEE')]
old=[r[:] for r in b]
assert Solution().exist(b,'ABCCED')
assert not Solution().exist(b,'ABCB')
assert b == old
assert Solution().exist([['A']],'A')
assert not Solution().exist([['A']],'B')
assert not Solution().exist([list('AB'),list('CD')],'ABA')
assert Solution().exist([list('AA')],'AA')
复杂度
- 时间
- O(mn·4·3^(L-1)) 的宽松上界,L 为单词长度;集合操作按均摊 O(1) 计。
- 空间
- 辅助 O(min(L,mn)) 路径集合和栈,不修改 board。
易错点与边界
- 不能永久 visited,否则某条失败路径会阻止其他合法路径。
- 同字母不同坐标可以使用,同一坐标不可重用。
- 只沿四邻移动,不含对角线。
- 本题 word 非空;超长搜索路径可能遇到 Python 递归深度限制。
- 普通匹配格必须先从 used 撤销,再返回 found,成功与失败都要恢复;末字符在 used.add 之前直接返回,因此不需要移除末格。
主解之后,再比较与迁移。
其他解法 1
原地标记并恢复的 DFS时间 设格数为 mn、单词长度为 L。宽松上界 O(mn·4·3^(L-1)):起点后首步最多四邻,此后不能走回前格,至多三分支;剪枝通常更快。
用棋盘自身暂存路径占用状态,不另建 used 集合。普通匹配格先保存 old,再改为不属于题目字母表的 '#';后续想重用该格时,会因字符不匹配被拒绝。found 收集四邻搜索结果之后,必须先把 old 写回,再返回,成功路径也要恢复。末字符同样在写标记之前直接成功,因此它无需恢复;函数返回时原棋盘保持原样。
- 逐格尝试起点,每次 dfs 先检查范围和 board[r][c] 是否等于 word[i];不匹配就返回 False。
- 当前字符是单词末位时直接返回 True,不写临时标记。
- 否则保存 old=board[r][c],把当前格设为 '#',阻止当前路径再次使用。
- 按四邻调用 dfs 匹配下一字符,用 any 得到 found。
- 无论 found 是真是假,都先恢复 board[r][c]=old,再返回 found;外层任一起点成功即返回 True。
from typing import List
class Solution:
def exist(self, board: List[List[str]], word: str) -> bool:
m, n = len(board), len(board[0])
def dfs(r, c, i):
if not (0 <= r < m and 0 <= c < n) or board[r][c] != word[i]:
return False
if i == len(word)-1:
return True
old = board[r][c]
board[r][c] = '#'
found = any(dfs(x,y,i+1) for x,y in ((r-1,c),(r+1,c),(r,c-1),(r,c+1)))
board[r][c] = old
return found
return any(dfs(r,c,0) for r in range(m) for c in range(n))复杂度
- 时间
- 设格数为 mn、单词长度为 L。宽松上界 O(mn·4·3^(L-1)):起点后首步最多四邻,此后不能走回前格,至多三分支;剪枝通常更快。
- 空间
- 辅助 O(min(L,mn)) 递归栈,不另建 visited;返回前恢复原棋盘。
这个方法的边界
- 提前 return True 前也要恢复当前格,否则会修改调用者的棋盘。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
b=[list('ABCE'),list('SFCS'),list('ADEE')]
old=[r[:] for r in b]
assert Solution().exist(b,'ABCCED')
assert b == old
assert Solution().exist(b,'SEE')
assert not Solution().exist(b,'ABCB')
assert b == old
assert Solution().exist([['A']],'A')
assert not Solution().exist([list('AB'),list('CD')],'ABA')
预测练习
C(0,2) 已匹配第三位时,used 为什么还只有 {(0,0),(0,1)}?
一点提示
看源码的末位判断和 used.add 哪个先执行。
答案与理由
末位匹配后先 return True,所以 C 没有加入 used。随后 B、A 各自 remove 自己,最终 used 恢复为空;成功路径仍是 A→B→C。
面试追问与解法取舍
可用字符频次先判定棋盘是否够用,并从单词首尾较稀少的一端开始搜索;反转单词不会改变是否存在路径,因为四邻边双向。只有搜索规模确实需要时再添加这些剪枝,核心正确性仍来自路径级访问状态。
模板迁移 / 相似题
试着不看代码,复述这三点
- 访问状态属于当前路径,失败返回必须撤销,不能沿用岛屿的永久标记。
- 候选按坐标判断,两个字母相同但位置不同的格子可以分别尝试。
- any 可以找到成功后短路,但当前层仍先恢复 used 再返回。
131. 分割回文串 中等
一句话题意
把字符串完整切成若干非空回文子串,返回全部不同切法。
s = "aab" → [["a","a","b"],["aa","b"]]。先逐格计算 pal,再完整走完所有切法。
生活化理解
像切一条带字母的面包:start 是下一刀的起点,end 是本刀终点,只有正反读相同的一片才能装进 path 盘子。
回文 DP + 回溯
先把回文判断预处理好,再专注枚举切口,能清楚区分区间判断与路径搜索。核心思路
先计算 pal[i][j],表示闭区间 s[i:j+1] 是否为回文。两端字符相等,且长度不超过 2 或内部 pal[i+1][j-1] 为真,当前区间才是回文。外层 i 从后往前枚举,确保需要读取的下一行已经计算;单字符与长度 2 的区间直接由短路条件处理。
进入 dfs(start) 时,path 恰好、无重叠地覆盖 s[:start],其中每片都是回文。枚举 end 从 start 到 n-1,就是决定下一片的长度;只有 pal[start][end] 为真才加入该子串,再递归到 end+1。某个终点失败只跳过该候选,不能结束整层枚举,因为更长子串仍可能是回文。
start==n 才表示整条字符串用完,此时保存 path.copy()。递归返回后弹出本层刚加入的一片,使下一条分支重新面对相同的父路径,已保存的副本不受影响。每个合法分割对应唯一的一串切口,因此不会漏解或重复切法。查表判断为 O(1),但创建 Python 子串和复制答案仍有实际代价。
进入 dfs(start) 时,path 恰好覆盖 s[:start],每片都已经通过回文表。只有 start == n 才能收集;复制结果后再撤销当前片,其他切口仍可继续尝试。
图解
- s
- aab
- n
- 3
- 已计算区间
- 0
- i
- 2
- j
- 2
- 当前子串
- 'b'
- 判断
- True
- i
- 1
- j
- 1
- 当前子串
- 'a'
- 判断
- True
- i
- 1
- j
- 2
- 当前子串
- 'ab'
- 判断
- False
- i
- 0
- j
- 0
- 当前子串
- 'a'
- 判断
- True
- i
- 0
- j
- 1
- 当前子串
- 'aa'
- 判断
- True
- i
- 0
- j
- 2
- 当前子串
- 'aab'
- 判断
- False
- start
- 0
- path
- []
- ans
- []
- start
- 1
- path
- ['a']
- ans
- []
- start
- 2
- path
- ['a', 'a']
- ans
- []
- start
- 3
- path
- ['a', 'a', 'b']
- ans
- [['a', 'a', 'b']]
- start
- 2
- end
- 2
- path
- ['a', 'a']
- ans
- [['a', 'a', 'b']]
- start
- 1
- end
- 1
- path
- ['a']
- ans
- [['a', 'a', 'b']]
- start
- 1
- end
- 2
- path
- ['a']
- 当前片段
- 'ab'
- start
- 0
- end
- 0
- path
- []
- ans
- [['a', 'a', 'b']]
- start
- 2
- path
- ['aa']
- ans
- [['a', 'a', 'b']]
- start
- 3
- path
- ['aa', 'b']
- ans
- [['a', 'a', 'b'], ['aa', 'b']]
- start
- 2
- end
- 2
- path
- ['aa']
- ans
- [['a', 'a', 'b'], ['aa', 'b']]
- start
- 0
- end
- 1
- path
- []
- ans
- [['a', 'a', 'b'], ['aa', 'b']]
- start
- 0
- end
- 2
- path
- []
- 当前片段
- 'aab'
- path
- []
- ans
- [['a', 'a', 'b'], ['aa', 'b']]
- 返回数量
- 2
选择步骤 21 步
变量与图例
看绿色括号覆盖了哪段前缀:递归只负责剩下的后缀,回退时只撤销最后一片。
- pal[i][j]
- 闭区间 s[i:j+1] 是否回文;图中 · 代表尚未计算。
- start / end
- 下一片的起点与正在尝试的终点,两端都包含。
- path
- 本条递归路径已选择的片段,按切分顺序存放。
- ans
- 已经复制保存的完整切法,回退不会改变这些副本。
Mermaid 决策树源码
graph TD
root["start=0,path=[]"] -->|"取 [0,0]: a"| a["start=1,path=[a]"]
a -->|"取 [1,1]: a"| aa["start=2,path=[a,a]"]
aa -->|"取 [2,2]: b"| aab["收集 [a,a,b]"]
aab -.->|"撤销 b"| aa
aa -.->|"撤销 a"| a
a -->|"尝试 [1,2]: ab"| bad1["pal=False,跳过"]
a -.->|"撤销第一个 a"| root
root -->|"取 [0,1]: aa"| pair["start=2,path=[aa]"]
pair -->|"取 [2,2]: b"| pairb["收集 [aa,b]"]
pairb -.->|"撤销 b,再撤销 aa"| root
root -->|"尝试 [0,2]: aab"| bad2["pal=False,跳过"]算法步骤
- 建立 pal 表,倒序枚举起点 i、正序枚举终点 j,按两端字符与内部区间计算回文真假。
- 从 dfs(0) 开始,在当前 start 枚举下一片终点 end。
- 若 pal[start][end] 为真,将 s[start:end+1] 加入 path,再递归 dfs(end+1)。
- 到达 start==n 时把 path.copy() 加入 ans;每次递归返回都弹出本层所选片段。
- 全部切口探索完成后返回 ans。
代码(Python)
查看完整 Python 代码回文 DP + 回溯 · 与上方图解对应
pal 缓存所有回文区间;path 保存已选片段,start 指向未覆盖后缀,减少重复判回文。
from typing import List
class Solution:
def partition(self, s: str) -> List[List[str]]:
n = len(s)
pal = [[False] * n for _ in range(n)]
for i in range(n - 1, -1, -1):
for j in range(i, n):
pal[i][j] = s[i] == s[j] and (j - i < 2 or pal[i + 1][j - 1])
ans, path = [], []
def dfs(start):
if start == n:
ans.append(path.copy())
return
for end in range(start, n):
if pal[start][end]:
path.append(s[start:end + 1])
dfs(end + 1)
path.pop()
dfs(0)
return ans运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = Solution()
assert sorted(s.partition("aab")) == [["a", "a", "b"], ["aa", "b"]]
assert s.partition("a") == [["a"]]
assert sorted(s.partition("aaa")) == sorted([["a","a","a"],["a","aa"],["aa","a"],["aaa"]])
复杂度
- 时间
- O(n²+n·2ⁿ),n 为字符串长度;含回文表、切片及复制全部结果的最坏代价。
- 空间
- 辅助 O(n²),包括回文表;递归和当前路径 O(n)。输出最坏 O(n·2ⁿ)。
易错点与边界
- 保存 path 本身会让结果共享同一可变列表,必须复制。
- end 是闭区间终点,切片右边界和下一次 start 都是 end+1。
- 题设字符串非空;单字符必定回文。
- DP 外层若正序,内部区间可能尚未计算。
主解之后,再比较与迁移。
其他解法 1
双指针判回文 + 回溯时间 O(n·2ⁿ) 最坏量级;枚举切口、区间判断与输出复制均计入,n 为字符串长度。
保留按 start、end 枚举切口的回溯树,每个候选区间改用 l、r 从两端向内比较,确认回文后才创建切片。它省去二维回文表,适合先理解搜索过程或希望减少辅助空间的场景,但相同区间可能随不同前缀路径被重复判断。path 仍只保存当前切法,完整答案必须另存副本。
- 建立 ans、path,从 start=0 开始递归枚举候选终点 end。
- 调用 is_pal(start,end),逐对比较两端字符;不同即返回 False,两指针相遇或交错则为 True。
- 候选为回文时,才切出 s[start:end+1] 加入 path,递归到 end+1。
- 覆盖完整字符串时复制 path 保存;每次递归返回后弹出刚加入的一片,继续尝试其他终点。
- 根调用结束后返回全部切法。
from typing import List
class Solution:
def partition(self, s: str) -> List[List[str]]:
ans, path = [], []
def is_pal(l, r):
while l < r:
if s[l] != s[r]:
return False
l += 1
r -= 1
return True
def dfs(start):
if start == len(s):
ans.append(path.copy())
return
for end in range(start, len(s)):
if is_pal(start, end):
path.append(s[start:end + 1])
dfs(end + 1)
path.pop()
dfs(0)
return ans复杂度
- 时间
- O(n·2ⁿ) 最坏量级;枚举切口、区间判断与输出复制均计入,n 为字符串长度。
- 空间
- 辅助 O(n),包括路径字符和递归深度;输出最坏 O(n·2ⁿ)。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = Solution()
assert sorted(s.partition("aab")) == [["a", "a", "b"], ["aa", "b"]]
assert s.partition("a") == [["a"]]
assert sorted(s.partition("aaa")) == sorted([["a","a","a"],["a","aa"],["aa","a"],["aaa"]])
预测练习
收集 ["a","a","b"] 后,为什么不能直接结束搜索?
一点提示
回到 start=0 时,还有长度为 2 的第一片没有尝试。
答案与理由
第一条叶子只对应一种切口组合。撤销 b、第二个 a、再撤销第一个 a 后,根节点还能选择 aa,最终得到 ["aa","b"]。
面试追问与解法取舍
若只求最少切割次数,可以在回文表上做前缀 DP,不必枚举指数个答案。若要求懒惰生成,可用生成器逐个产出路径副本,但总输出工作量不会消失。
模板迁移 / 相似题
试着不看代码,复述这三点
- “分割”型回溯用 start 表示未覆盖后缀;合法片段的终点决定下一次 start。
- 回文 DP 的 i 倒序保证 pal[i+1][j−1] 已知;短串用长度条件直接判断。
- 收集用 path.copy(),撤销用 path.pop(),它们分别管理结果快照和当前搜索路径。
51. N 皇后 困难
一句话题意
在 n×n 棋盘放 n 个互不同行、同列、同斜线的皇后,返回全部棋盘。
n = 4 → 2 个棋盘;按行记录皇后列号分别为 [1,3,0,2]、[2,0,3,1]。图解覆盖全部合法落子和回退。
生活化理解
像给每层楼安排一束不能相撞的探照灯:每行放一盏,列集合记录竖线,r−c 和 r+c 分别编号两族斜线。
集合记录列与斜线
按行放置配合三个占用集合,冲突来源和回溯恢复都能直观对应棋盘。核心思路
按行递归自动保证每行恰好一个皇后。进入 dfs(r) 时,前 r 行已经合法摆放,cols、diag1、diag2 只记录这些皇后占用的列与斜线。候选格 (r,c) 只有在 c、r-c、r+c 都未出现过时才能使用;同一条斜线上的坐标差或坐标和相同,所以三个集合完整表达已有皇后的攻击范围。
path 只保存已放皇后的列号,长度就是已经放好的行数。选中一列后,将列和两条斜线加入集合,把列号加入路径,再递归下一行。当前行如果所有候选都冲突,这条分支就自然返回,不会产生不完整的答案。
到 r==n 时再把每个列号转换为含一个 Q 的行字符串,保存完整棋盘,避免搜索途中反复复制整张棋盘。每次递归返回必须同时恢复 path 和三个占用集合,让下一候选面对同一个父状态;已经构造的答案字符串保留在 ans 中。枚举完所有行列选择后就得到全部解。
进入 dfs(r) 时,前 r 行恰好各有一个皇后,cols、diag1、diag2 精确记录它们占用的列及两类斜线。只有三个集合都不冲突才落子;返回时同步撤销,下一候选看到的就是进入本层前的棋盘。
图解
- r
- 0
- path
- []
- cols
- []
- diag1
- []
- diag2
- []
- 已保存棋盘
- 0
- r
- 1
- path
- [0]
- cols
- [0]
- diag1
- [0]
- diag2
- [0]
- 已保存棋盘
- 0
- r
- 2
- path
- [0, 2]
- cols
- [0, 2]
- diag1
- [-1, 0]
- diag2
- [0, 3]
- 已保存棋盘
- 0
- r
- 1
- path
- [0]
- cols
- [0]
- diag1
- [0]
- diag2
- [0]
- 已保存棋盘
- 0
- r
- 2
- path
- [0, 3]
- cols
- [0, 3]
- diag1
- [-2, 0]
- diag2
- [0, 4]
- 已保存棋盘
- 0
- r
- 3
- path
- [0, 3, 1]
- cols
- [0, 1, 3]
- diag1
- [-2, 0, 1]
- diag2
- [0, 3, 4]
- 已保存棋盘
- 0
- r
- 0
- path
- []
- cols
- []
- diag1
- []
- diag2
- []
- 已保存棋盘
- 0
- r
- 1
- path
- [1]
- cols
- [1]
- diag1
- [-1]
- diag2
- [1]
- 已保存棋盘
- 0
- r
- 2
- path
- [1, 3]
- cols
- [1, 3]
- diag1
- [-2, -1]
- diag2
- [1, 4]
- 已保存棋盘
- 0
- r
- 3
- path
- [1, 3, 0]
- cols
- [0, 1, 3]
- diag1
- [-2, -1, 2]
- diag2
- [1, 2, 4]
- 已保存棋盘
- 0
- r
- 4
- path
- [1, 3, 0, 2]
- cols
- [0, 1, 2, 3]
- diag1
- [-2, -1, 1, 2]
- diag2
- [1, 2, 4, 5]
- 已保存棋盘
- 1
- r
- 0
- path
- []
- cols
- []
- diag1
- []
- diag2
- []
- 已保存棋盘
- 1
- r
- 1
- path
- [2]
- cols
- [2]
- diag1
- [-2]
- diag2
- [2]
- 已保存棋盘
- 1
- r
- 2
- path
- [2, 0]
- cols
- [0, 2]
- diag1
- [-2, 1]
- diag2
- [1, 2]
- 已保存棋盘
- 1
- r
- 3
- path
- [2, 0, 3]
- cols
- [0, 2, 3]
- diag1
- [-2, -1, 1]
- diag2
- [1, 2, 5]
- 已保存棋盘
- 1
- r
- 4
- path
- [2, 0, 3, 1]
- cols
- [0, 1, 2, 3]
- diag1
- [-2, -1, 1, 2]
- diag2
- [1, 2, 4, 5]
- 已保存棋盘
- 2
- r
- 0
- path
- []
- cols
- []
- diag1
- []
- diag2
- []
- 已保存棋盘
- 2
- r
- 1
- path
- [3]
- cols
- [3]
- diag1
- [-3]
- diag2
- [3]
- 已保存棋盘
- 2
- r
- 2
- path
- [3, 0]
- cols
- [0, 3]
- diag1
- [-3, 1]
- diag2
- [1, 3]
- 已保存棋盘
- 2
- r
- 3
- path
- [3, 0, 2]
- cols
- [0, 2, 3]
- diag1
- [-3, 0, 1]
- diag2
- [1, 3, 4]
- 已保存棋盘
- 2
- r
- 2
- path
- [3, 1]
- cols
- [1, 3]
- diag1
- [-3, 0]
- diag2
- [2, 3]
- 已保存棋盘
- 2
- r
- 0
- path
- []
- cols
- []
- diag1
- []
- diag2
- []
- 已保存棋盘
- 2
- r
- 0
- path
- []
- cols
- []
- diag1
- []
- diag2
- []
- 已保存棋盘
- 2
选择步骤 23 步
变量与图例
每层只放一行:观察下一行哪些列被已有皇后封锁,死路出现后怎样把列与两条斜线一起还原。
- r / c
- r 是本层要放的行,c 从 0 到 n−1 枚举;下图行列均从 0 开始。
- path
- path[r] 是第 r 行皇后的列号;长度等于已放置行数。
- cols
- 已有皇后占用的列。
- diag1 / diag2
- 分别保存 r−c、r+c;值相等表示同一条斜线。
- ans
- 到达 r=n 时构造并保存的完整棋盘,后续回退不会修改字符串快照。
Mermaid 决策树源码
graph TD
qroot["path=[]"]
q0["path=[0]"]
qroot -->|"第 0 行选 c=0"| q0
q02["path=[0, 2];死路,撤销返回"]
q0 -->|"第 1 行选 c=2"| q02
q03["path=[0, 3]"]
q0 -->|"第 1 行选 c=3"| q03
q031["path=[0, 3, 1];死路,撤销返回"]
q03 -->|"第 2 行选 c=1"| q031
q1["path=[1]"]
qroot -->|"第 0 行选 c=1"| q1
q13["path=[1, 3]"]
q1 -->|"第 1 行选 c=3"| q13
q130["path=[1, 3, 0]"]
q13 -->|"第 2 行选 c=0"| q130
q1302["path=[1, 3, 0, 2];收集结果"]
q130 -->|"第 3 行选 c=2"| q1302
q2["path=[2]"]
qroot -->|"第 0 行选 c=2"| q2
q20["path=[2, 0]"]
q2 -->|"第 1 行选 c=0"| q20
q203["path=[2, 0, 3]"]
q20 -->|"第 2 行选 c=3"| q203
q2031["path=[2, 0, 3, 1];收集结果"]
q203 -->|"第 3 行选 c=1"| q2031
q3["path=[3]"]
qroot -->|"第 0 行选 c=3"| q3
q30["path=[3, 0]"]
q3 -->|"第 1 行选 c=0"| q30
q302["path=[3, 0, 2];死路,撤销返回"]
q30 -->|"第 2 行选 c=2"| q302
q31["path=[3, 1];死路,撤销返回"]
q3 -->|"第 1 行选 c=1"| q31
q02 -.->|"撤销 c=2,再尝试 c=3"| q0算法步骤
- 初始化 ans、path,以及 cols、diag1、diag2 三个空集合,从 dfs(0) 开始。
- 在第 r 行枚举列 c;若 c、r-c、r+c 任一已被占用,就跳过当前候选。
- 合法时记录列与两条斜线,把 c 加入 path,递归 dfs(r+1)。
- 到 r==n 时按 path 构造棋盘字符串并加入 ans;返回父层后撤销本次路径和三项占用。
- 继续其余候选,所有分支完成后返回 ans。
代码(Python)
查看完整 Python 代码集合记录列与斜线 · 与上方图解对应
三个集合做均摊 O(1) 冲突检查,path 存每一行的列号;最直观,便于解释回溯恢复。
from typing import List
class Solution:
def solveNQueens(self, n: int) -> List[List[str]]:
ans, path = [], []
cols, diag1, diag2 = set(), set(), set()
def dfs(r):
if r == n:
ans.append(["." * c + "Q" + "." * (n - c - 1) for c in path])
return
for c in range(n):
if c in cols or r - c in diag1 or r + c in diag2:
continue
cols.add(c)
diag1.add(r - c)
diag2.add(r + c)
path.append(c)
dfs(r + 1)
path.pop()
cols.remove(c)
diag1.remove(r - c)
diag2.remove(r + c)
dfs(0)
return ans运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = Solution()
expected = [[".Q..","...Q","Q...","..Q."],["..Q.","Q...","...Q",".Q.."]]
assert sorted(s.solveNQueens(4)) == sorted(expected)
assert s.solveNQueens(1) == [["Q"]]
assert s.solveNQueens(2) == []
assert s.solveNQueens(3) == []
复杂度
- 时间
- 保守上界 O(n·n! + S·n²),n 为边长,S 为解数;各搜索节点扫描 n 列,输出每张棋盘 n² 字符。
- 空间
- 辅助 O(n) 集合、路径及递归;输出 O(S·n²)。
易错点与边界
- r−c 可以为负数,集合可直接保存,不必平移。
- 两个斜线集合必须分别保存,不能把和与差混到同一集合。
- n=1 有一个解,n=2、3 无解;题设 n≥1。
主解之后,再比较与迁移。
其他解法 1
位运算枚举可用列时间 题设 n 很小、位操作视为 O(1) 时,上界 O(n! + S·n²),S 为解数;任意大 n 下 Python 大整数位运算还与位宽相关。
用第 c 位表示列 c,cols 记录已占列,left、right 记录当前行受两类斜线攻击的位置。available=mask & ~(cols|left|right) 只保留合法列,逐次取最低置位即可避免扫描冲突列。下一行的攻击位置通过左右移位得到;整数参数形成各递归层自己的状态,返回时只需恢复共享 path。它更紧凑,但要先讲清位与棋盘列、斜线移动的对应。
- 令 mask=(1<<n)-1,以 cols=left=right=0 开始递归,path 保存已选列号。
- 若 cols==mask,说明每行每列均已放好皇后,按 path 生成完整棋盘并保存。
- 计算 available=mask & ~(cols|left|right),用 bit=available & -available 取一个合法列,再从 available 中清掉该位。
- 将 bit.bit_length()-1 加入 path,递归传入 cols|bit、((left|bit)<<1)&mask、(right|bit)>>1。
- 递归返回后弹出本次列号,继续枚举 available 中的其他置位;根调用结束返回全部棋盘。
from typing import List
class Solution:
def solveNQueens(self, n: int) -> List[List[str]]:
mask = (1 << n) - 1
ans, path = [], []
def dfs(cols, left, right):
if cols == mask:
ans.append(["." * c + "Q" + "." * (n - c - 1) for c in path])
return
available = mask & ~(cols | left | right)
while available:
bit = available & -available
available ^= bit
path.append(bit.bit_length() - 1)
dfs(cols | bit, ((left | bit) << 1) & mask, (right | bit) >> 1)
path.pop()
dfs(0, 0, 0)
return ans复杂度
- 时间
- 题设 n 很小、位操作视为 O(1) 时,上界 O(n! + S·n²),S 为解数;任意大 n 下 Python 大整数位运算还与位宽相关。
- 空间
- 题设位宽下辅助 O(n) 栈与路径;输出 O(S·n²)。
这个方法的边界
- 位运算的 ~ 会产生无限符号扩展,必须用 mask 截到 n 位。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = Solution()
expected = [[".Q..","...Q","Q...","..Q."],["..Q.","Q...","...Q",".Q.."]]
assert sorted(s.solveNQueens(4)) == sorted(expected)
assert s.solveNQueens(1) == [["Q"]]
assert s.solveNQueens(2) == []
assert s.solveNQueens(3) == []
预测练习
path=[0,2] 时,第 2 行为什么一个皇后也放不下?
一点提示
逐列检查:列占用与斜线占用是不同的冲突来源。
答案与理由
c=0 被第 0 行占列;c=1 被 (1,2) 的斜线攻击;c=2 已占列且与 (0,0) 同斜线;c=3 被 (1,2) 攻击。必须撤销 (1,2),让第 1 行继续尝试 c=3。
面试追问与解法取舍
只求解的数量时可以不建棋盘,叶节点累加 1;还可以对首行利用镜像对称,但奇数 n 的中间列需单独统计,不能把全部结果机械乘二。
模板迁移 / 相似题
试着不看代码,复述这三点
- 按行递归把“每行一个皇后”变成结构保证;剩下只需验证列和两类斜线。
- 放置和撤销必须对称更新 path、cols、diag1、diag2;遗漏任何一个都会污染兄弟分支。
- 找到一解后继续回退搜索;N 皇后返回全部棋盘,不能在第一片叶子提前停止。
35. 搜索插入位置 简单
一句话题意
在严格递增数组中寻找目标下标;不存在时返回保持有序的插入位置,要求 O(log n)。
nums = [1,3,5,7,9,11,13],target = 8 → 4;插入后 8 位于 7 与 9 之间。
生活化理解
给按号码排序的书找插槽:L 左边的书都太小,R 及其右边的书都够大,最后两个边界合成唯一槽位。
半开区间 lower_bound
统一查找与插入为第一个不小于目标的分界线,模板简短且自然覆盖首尾插槽。核心思路
把问题统一成第一个大于等于 target 的位置,也就是 lower_bound。[left,right) 表示尚未判定的元素范围;[0,left) 中的值都小于目标,[right,n) 中的值都大于等于目标。答案是两类元素之间的分界线,候选分界线位于闭范围 [left,right]。
初始化 left=0、right=n,允许答案落在尾后插槽 n。只有 left<right 才计算中点,因此不会读取 nums[n]。若 nums[mid]<target,中点及其左侧都属于较小区域,令 left=mid+1;否则中点属于合格区域,令 right=mid,仍保留在它之前插入的可能。
每轮都缩小待判定元素范围,同时保持两侧的分类正确。left==right 时,没有未判定元素,两侧已知区域在这个位置相接,返回 left。它可以指向已有目标,也可以是目标不存在时的插入位置,不需要额外保存一次命中的下标。
区间 [left,right) 内的元素尚未判定;left 之前都小于 target,right 及其之后都大于等于 target。答案分界线始终在闭区间 [left,right] 中,收缩到同一点时就是插入位置。
图解
- left
- 0
- right
- 7
- target
- 8
- left
- 0
- right
- 7
- mid
- 3
- 中点值
- 7
- left
- 4
- right
- 7
- 待判定元素数
- 3
- left
- 4
- right
- 7
- mid
- 5
- 中点值
- 11
- left
- 4
- right
- 5
- 待判定元素数
- 1
- left
- 4
- right
- 5
- mid
- 4
- 中点值
- 9
- left
- 4
- right
- 4
- 待判定元素数
- 0
- left
- 4
- right
- 4
- 返回值
- 4
选择步骤 8 步
变量与图例
把答案想成一条分界线:左边全部小于 8,右边全部大于等于 8。每轮减少尚未分类的元素。
- left / right
- 待判定元素的半开区间边界;right 可以等于 n,不读取 nums[n]。
- mid
- 本轮中点,只有 left<right 时才计算与读取。
- target
- 要查找第一个 ≥ 它的元素位置;本例为 8。
算法步骤
- 设 left=0、right=len(nums),用 [left,right) 表示待判定元素。
- 当 left<right 时,取 mid=(left+right)//2 并读取 nums[mid]。
- 若中点值小于 target,令 left=mid+1;否则令 right=mid,把相等值也划入右侧区域。
- 两边界相遇时返回 left,它是第一个不小于目标的位置,也可能等于数组长度。
代码(Python)
查看完整 Python 代码半开区间 lower_bound · 与上方图解对应
L 与 R 是分隔已知区域的边界,R 可以等于 n;不需要额外记录候选答案。
from typing import List
class Solution:
def searchInsert(self, nums: List[int], target: int) -> int:
left, right = 0, len(nums)
while left < right:
mid = (left + right) // 2
if nums[mid] < target:
left = mid + 1
else:
right = mid
return left运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = Solution()
assert s.searchInsert([1,3,5,6], 5) == 2
assert s.searchInsert([1,3,5,6], 2) == 1
assert s.searchInsert([1,3,5,6], 7) == 4
assert s.searchInsert([1], 0) == 0
assert s.searchInsert([], 1) == 0
复杂度
- 时间
- O(log(n+1)),n 为数组长度;每轮将半开区间缩小约一半。
- 空间
- O(1),只存边界和中点。
易错点与边界
- 答案为 n 是合法插入尾部,不是越界错误。
- 题设通常非空;两种模板也自然支持空数组返回0。
- 本主解的待判定元素使用 [left,right),循环条件为 left<right;中点值不小于目标时令 right=mid,保留分界线 mid。
主解之后,再比较与迁移。
其他解法 1
闭区间二分时间 O(log(n+1)),n 为长度;闭区间每轮排除中点。
将未判定元素写成闭区间 [left,right],right 初始为 n-1,循环条件为 left≤right。中点值大于或等于目标时,确认它属于右侧已知区域,再令 right=mid-1;相等时仍继续向左找,因此不是命中就返回的查找。区间交错时,left 正好指向第一个不小于目标的位置。它与主解的目标相同,但循环条件和右边界更新必须整套对应。
- 设 left=0、right=len(nums)-1,采用闭区间候选元素。
- 当 left≤right 时取中点;中点值小于 target 就令 left=mid+1。
- 否则令 right=mid-1,继续寻找更靠左的合格位置。
- 区间为空时返回 left,得到已有目标的位置或插入下标。
from typing import List
class Solution:
def searchInsert(self, nums: List[int], target: int) -> int:
left, right = 0, len(nums) - 1
while left <= right:
mid = (left + right) // 2
if nums[mid] < target:
left = mid + 1
else:
right = mid - 1
return left复杂度
- 时间
- O(log(n+1)),n 为长度;闭区间每轮排除中点。
- 空间
- O(1) 辅助空间。
这个方法的边界
- 半开区间用 left<right、right=mid;闭区间用 left<=right、right=mid−1,不要混搭。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = Solution()
assert s.searchInsert([1,3,5,6], 5) == 2
assert s.searchInsert([1,3,5,6], 2) == 1
assert s.searchInsert([1,3,5,6], 7) == 4
assert s.searchInsert([1], 0) == 0
assert s.searchInsert([], 1) == 0
预测练习
把本例的 target 改为 20,right 初始仍为 7,最后会返回哪里?
一点提示
尾后分界线 n 本来就在允许答案范围内。
答案与理由
所有元素都小于 20,left 最终移动到 7,与 right 相遇;返回 7,表示追加到末尾,并不会读取 nums[7]。
面试追问与解法取舍
如果允许重复元素,本实现仍返回最左插入点。若要求插到所有相等值之后,将判定 nums[mid]<target 改为 nums[mid]<=target,即 upper_bound。
模板迁移 / 相似题
试着不看代码,复述这三点
- lower_bound 找的是第一个 ≥ target 的分界线,所以命中相等值也要继续向左收缩。
- 半开区间更新为 left=mid+1 或 right=mid;后者保留 mid 作为可能的答案边界。
- 读数组前由 left<right 保证 mid<n,尾后位置只用于表示答案。
74. 搜索二维矩阵 中等
一句话题意
矩阵每行递增且下一行首元素大于上一行末元素,判断目标是否存在。
matrix = [[1,3,5],[7,9,11],[13,15,17]],target = 16 → False。
生活化理解
把多层书架按行接成一排:虚拟编号 k 的书在第 k//列数 层、第 k%列数 个位置,无需真的搬书。
虚拟一维二分
利用跨行整体有序的条件,只增加一次坐标换算就能直接复用普通二分。核心思路
本题不仅每行递增,下一行的首项还大于上一行的末项,所以按行展开后整体有序。矩阵有 rows 行、cols 列时,虚拟下标 k 对应 matrix[k//cols][k%cols]。这里只换算下标,不创建真实的一维数组。
在虚拟下标的闭区间 [left,right]=[0,rows*cols-1] 上查找,目标若存在就始终留在区间中。取中点并映射到真实格子,若值等于 target 就返回 True;若值更小,中点及左侧都不可能命中,令 left=mid+1;若值更大,就令 right=mid-1。
循环条件是 left≤right,只剩一个候选时仍要比较。最终 left>right 表示所有候选都已排除,返回 False。这题要求确实存在相等元素,不能把区间结束时的插入位置当成命中;整体有序性让每轮排除一半候选成立。
由于每行递增且下一行首项大于上一行末项,按行展开后的序列整体有序。若目标存在,它的虚拟下标始终留在闭区间 [left,right];每轮比较都能按大小排除连续一半。
图解
- rows
- 3
- cols
- 3
- left
- 0
- right
- 8
- left
- 0
- right
- 8
- mid
- 4
- 行
- 1
- 列
- 1
- value
- 9
- left
- 5
- right
- 8
- 仍有候选
- True
- left
- 5
- right
- 8
- mid
- 6
- 行
- 2
- 列
- 0
- value
- 13
- left
- 7
- right
- 8
- 仍有候选
- True
- left
- 7
- right
- 8
- mid
- 7
- 行
- 2
- 列
- 1
- value
- 15
- left
- 8
- right
- 8
- 仍有候选
- True
- left
- 8
- right
- 8
- mid
- 8
- 行
- 2
- 列
- 2
- value
- 17
- left
- 8
- right
- 7
- 仍有候选
- False
- left
- 8
- right
- 7
- 返回值
- False
选择步骤 10 步
变量与图例
把矩阵按行读成一条有序队伍;中点属于哪行哪列,用整除和取余找到座位。
- rows / cols
- 行数与列数;本例都是 3,虚拟数组长度为 9。
- left / right
- 仍可能包含目标的虚拟下标闭区间。
- mid / value
- 中点下标及映射到 matrix[mid//cols][mid%cols] 的真实值。
算法步骤
- 若矩阵为空或首行为空,返回 False;否则读取 rows、cols,设 left=0、right=rows*cols-1。
- 在 left≤right 时取中点 mid,通过 mid//cols、mid%cols 读取真实矩阵值。
- 命中 target 就返回 True;值偏小令 left=mid+1,值偏大令 right=mid-1。
- 闭区间交错且仍未命中时,返回 False。
代码(Python)
查看完整 Python 代码虚拟一维二分 · 与上方图解对应
保留原矩阵,通过 k//cols 和 k%cols 取值;闭区间只存仍可能命中的元素。
from typing import List
class Solution:
def searchMatrix(self, matrix: List[List[int]], target: int) -> bool:
if not matrix or not matrix[0]:
return False
rows, cols = len(matrix), len(matrix[0])
left, right = 0, rows * cols - 1
while left <= right:
mid = (left + right) // 2
value = matrix[mid // cols][mid % cols]
if value == target:
return True
if value < target:
left = mid + 1
else:
right = mid - 1
return False运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = Solution()
a = [[1,3,5,7],[10,11,16,20],[23,30,34,60]]
assert s.searchMatrix(a, 3) is True
assert s.searchMatrix(a, 13) is False
assert s.searchMatrix([[1]], 1) is True
assert s.searchMatrix([[1]], 0) is False
assert s.searchMatrix([], 1) is False
assert s.searchMatrix([[]], 1) is False
复杂度
- 时间
- O(log(rows·cols)),每轮一次坐标映射和比较。
- 空间
- O(1),没有创建扁平数组。
易错点与边界
- 坐标映射除数是列数,不是行数。
- 仅行列各自有序的矩阵不保证扁平有序,不能直接套用。
- 单行单列均自然适用;题设非空矩阵,代码额外自然处理空矩阵。
主解之后,再比较与迁移。
其他解法 1
先定位行,再定位列时间 O(log rows+log cols),等价于 O(log(rows·cols))。
先对行首做半开 upper_bound,找到第一个首项大于 target 的行,其前一行 left-1 是唯一可能的候选行。若连首行首项都大于目标,就不存在候选行。随后在该行做半开 lower_bound,并检查返回位置确实等于目标。跨行不交错保证其他行无需搜索,适合把二维定位拆成两次熟悉的边界查找。
- 若矩阵为空或首行为空,返回 False;否则对行号区间 [0,rows) 二分。
- 若 matrix[mid][0]≤target,令 left=mid+1,否则令 right=mid;结束后设 row=left-1。
- 若 row<0,返回 False;否则重新以 [0,cols) 搜索该行第一个不小于 target 的列。
- 行内中点值小于目标就令 left=mid+1,否则令 right=mid。
- 列查找结束,只有 left<cols 且 matrix[row][left]==target 时返回 True。
from typing import List
class Solution:
def searchMatrix(self, matrix: List[List[int]], target: int) -> bool:
if not matrix or not matrix[0]:
return False
left, right = 0, len(matrix)
while left < right:
mid = (left + right) // 2
if matrix[mid][0] <= target:
left = mid + 1
else:
right = mid
row = left - 1
if row < 0:
return False
left, right = 0, len(matrix[0])
while left < right:
mid = (left + right) // 2
if matrix[row][mid] < target:
left = mid + 1
else:
right = mid
return left < len(matrix[0]) and matrix[row][left] == target复杂度
- 时间
- O(log rows+log cols),等价于 O(log(rows·cols))。
- 空间
- O(1),候选行只引用原数组。
这个方法的边界
- 行首 upper_bound 得到0时,目标小于全局最小值,应返回 False。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = Solution()
a = [[1,3,5,7],[10,11,16,20],[23,30,34,60]]
assert s.searchMatrix(a, 3) is True
assert s.searchMatrix(a, 13) is False
assert s.searchMatrix([[1]], 1) is True
assert s.searchMatrix([[1]], 0) is False
assert s.searchMatrix([], 1) is False
assert s.searchMatrix([[]], 1) is False
预测练习
为什么本题可以整体展开二分,而 240 题通常不能照搬?
一点提示
分别检查“同行相邻”与“跨行相邻”是否都保证有序。
答案与理由
本题保证下一行首项大于上一行末项,所以展开整体有序。240 只保证各行各列递增,跨行连接处可能下降,整体二分的排除依据就不成立。
面试追问与解法取舍
若矩阵只满足行列各自有序,可改用 O(rows+cols) 的阶梯搜索。当前题的强排序条件才是对数复杂度的来源。
模板迁移 / 相似题
试着不看代码,复述这三点
- 二维到一维:k = r*cols+c;一维到二维:r=k//cols、c=k%cols。
- 逻辑展平只做坐标换算,仍是 O(1) 额外空间,无须创建合并列表。
- 闭区间未命中时使用 mid±1;left>right 才表示目标不存在。
34. 在排序数组中查找元素的第一个和最后一个位置 中等
一句话题意
在非递减数组中用 O(log n) 找到目标值第一次和最后一次出现的位置,不存在返回 [-1,-1]。
nums = [1,2,2,2,2,4,6],target = 2 → [1,4]。先找第一个 ≥2,再找第一个 >2。
生活化理解
在按姓氏排列的名单里找一整段同姓人:先找这一姓氏的入口,再找下一姓氏的入口;第二个入口前一位就是末尾。
两次半开边界查找
把连续重复值夹在两个分界线之间,只需改变等号归属就能复用同一个模板。核心思路
重复值形成连续的一段,普通二分命中某个位置并不能证明它是端点。定义 first 为第一个值大于等于 target 的下标,after 为第一个值严格大于目标的下标。目标存在时,相等元素恰好位于半开区间 [first,after),题目要求的闭区间答案就是 [first,after-1]。
bound(strict) 每次都从 [left,right)=[0,n) 开始。strict=False 时,左侧已确认小于目标,右侧已确认大于等于目标;相等值让 right=mid。strict=True 时,左侧改为小于等于目标,右侧改为严格大于目标;相等值也让 left=mid+1。两次循环的差别只有等号划入哪边,边界相遇时返回分界线。
先执行 bound(False),并确认 first 在数组内且确实指向 target。下界也可能只是插入位置,若它等于 n 或值不相等,就返回 [-1,-1];空数组也在这一步得到正确结果。目标存在时,再执行 bound(True),将其返回值减一作为最后一次出现的位置。
bound(False) 保持左边已确认 <target、右边已确认 ≥target;bound(True) 把左边条件改为 ≤target、右边改为 >target。两个分界分别给出 first 和末次出现位置之后一格,因此右端答案要减 1。
图解
- strict
- False
- left
- 0
- right
- 7
- 查找目标
- 第一个 ≥2
- strict
- False
- left
- 0
- right
- 7
- mid
- 3
- 中点值
- 2
- strict
- False
- left
- 0
- right
- 3
- mid
- 1
- 中点值
- 2
- strict
- False
- left
- 0
- right
- 1
- mid
- 0
- 中点值
- 1
- strict
- False
- left
- 1
- right
- 1
- 返回切口
- 1
- strict
- True
- left
- 0
- right
- 7
- 查找目标
- 第一个 >2
- strict
- True
- left
- 0
- right
- 7
- mid
- 3
- 中点值
- 2
- strict
- True
- left
- 4
- right
- 7
- mid
- 5
- 中点值
- 4
- strict
- True
- left
- 4
- right
- 5
- mid
- 4
- 中点值
- 2
- strict
- True
- left
- 5
- right
- 5
- 返回切口
- 5
- first
- 1
- 右切口
- 5
- 返回区间
- [1,4]
选择步骤 11 步
变量与图例
想象给所有 2 圈出一个半开区间:左切口在第一个 2 前,右切口在第一个大于 2 的元素前。
- strict
- False 查第一个 ≥target;True 查第一个 >target,决定相等时往哪边走。
- left / right / mid
- 每次边界查找的半开区间与中点,两次查找均重新初始化。
- first
- 左边界查找结果;确认它确实指向 target 后,再找右切口。
算法步骤
- 实现半开边界查找 bound(strict),每次初始化 left=0、right=n。
- 中点值小于 target,或 strict=True 且值等于 target 时,令 left=mid+1;否则令 right=mid。
- 先调用 bound(False) 得到 first;若 first==n 或 nums[first]≠target,返回 [-1,-1]。
- 目标存在时,从完整区间重新调用 bound(True),得到第一个大于目标的分界线。
- 将第二次查找的返回值减一作为末下标,与 first 组合为答案。
代码(Python)
查看完整 Python 代码两次半开边界查找 · 与上方图解对应
bound(strict) 的 strict=False 求≥目标,strict=True 求>目标;相等元素朝哪边分配是唯一差别。
from typing import List
class Solution:
def searchRange(self, nums: List[int], target: int) -> List[int]:
def bound(strict):
left, right = 0, len(nums)
while left < right:
mid = (left + right) // 2
if nums[mid] < target or (strict and nums[mid] == target):
left = mid + 1
else:
right = mid
return left
first = bound(False)
if first == len(nums) or nums[first] != target:
return [-1, -1]
return [first, bound(True) - 1]运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = Solution()
assert s.searchRange([5,7,7,8,8,10], 8) == [3,4]
assert s.searchRange([5,7,7,8,8,10], 6) == [-1,-1]
assert s.searchRange([], 0) == [-1,-1]
assert s.searchRange([2,2,2], 2) == [0,2]
assert s.searchRange([1], 1) == [0,0]
复杂度
- 时间
- O(log(n+1)),n 为数组长度;最多两次二分。
- 空间
- O(1),仅边界与返回的两个下标。
易错点与边界
- 命中后向左右线性扫描,全部相等时会退化到 O(n)。
- after 是排他边界,返回末下标时要减1。
- 空数组是合法输入,两种写法都返回 [-1,-1]。
- 不要仅检查 first<n;还必须确认 nums[first]==target。
主解之后,再比较与迁移。
其他解法 1
闭区间命中后继续偏向一侧时间 O(log(n+1)),两个闭区间搜索各自缩半。
分别用闭区间查找左右端点,ans 保存已证实命中的候选。中点不等于目标时按普通二分排除;命中后先保存 mid,再继续向左或向右寻找更极端的命中。它不计算相等段外侧的分界线,最终直接返回两个实际端点;始终未命中时 ans 保持 -1。
- 为一次端点查找初始化 left=0、right=n-1、ans=-1,循环条件为 left≤right。
- 中点值小于 target 就令 left=mid+1,大于 target 就令 right=mid-1。
- 命中时令 ans=mid;找左端就继续令 right=mid-1,找右端就令 left=mid+1。
- 区间为空时返回 ans;分别执行向左、向右两次查找,组合成答案。
from typing import List
class Solution:
def searchRange(self, nums: List[int], target: int) -> List[int]:
def endpoint(find_left):
left, right, ans = 0, len(nums) - 1, -1
while left <= right:
mid = (left + right) // 2
if nums[mid] < target:
left = mid + 1
elif nums[mid] > target:
right = mid - 1
else:
ans = mid
if find_left:
right = mid - 1
else:
left = mid + 1
return ans
return [endpoint(True), endpoint(False)]复杂度
- 时间
- O(log(n+1)),两个闭区间搜索各自缩半。
- 空间
- O(1) 辅助空间。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = Solution()
assert s.searchRange([5,7,7,8,8,10], 8) == [3,4]
assert s.searchRange([5,7,7,8,8,10], 6) == [-1,-1]
assert s.searchRange([], 0) == [-1,-1]
assert s.searchRange([2,2,2], 2) == [0,2]
assert s.searchRange([1], 1) == [0,0]
预测练习
若第二次查找仍用 strict=False,会返回哪个错误区间?
一点提示
两次找到了同一条“第一个 ≥2”的切口。
答案与理由
两次都会返回 1,套用 [first,cut-1] 得到 [1,0]。右切口必须越过所有相等元素,所以 strict=True 时遇到等号要令 left=mid+1。
面试追问与解法取舍
如果要统计目标出现次数,存在时直接 after−first;若统计闭值域 [a,b] 内元素个数,用 upper_bound(b)−lower_bound(a),无需扫描区间。
模板迁移 / 相似题
试着不看代码,复述这三点
- 把一段相等值的范围转成两个单调分界,避免命中后向两侧线性扫描。
- 相等分支是两次二分的唯一关键区别:下界保留 mid,上界排除 mid。
- first==n 或 nums[first]!=target 表示目标不存在;先确认这一点,再组合左右答案。
33. 搜索旋转排序数组 中等
一句话题意
在无重复值的递增数组旋转结果中,用 O(log n) 找到目标下标,不存在返回 −1。
nums = [6,7,0,1,2,4,5],target = 0 → 下标 2。本例先识别右半有序,再识别左半有序。
生活化理解
一排编号书被从中间搬到最前面,整排虽然断开,却只有一个接缝;每次从中间分两段,至少一段仍按号码排好。
一次二分识别有序半段
每轮识别有序的一半并判断目标归属,可在原数组上一次二分完成搜索。核心思路
维护目标可能存在的闭区间 [left,right],每轮先检查 nums[mid]==target。原数组严格递增且只旋转一次,没有重复元素,中点两侧至少一半有序:若 nums[left]≤nums[mid],左半 [left,mid] 有序;否则右半 [mid,right] 有序。
有序的一半能用端点值判断目标归属。左半有序时,只有 nums[left]≤target<nums[mid] 才保留左侧,令 right=mid-1;否则令 left=mid+1。右半有序时,只有 nums[mid]<target≤nums[right] 才保留右侧,令 left=mid+1;否则令 right=mid-1。
中点已经确认不是目标,所以两个方向都可以越过它。每轮排除有依据的一半,真正的目标若存在始终留在候选闭区间中。即使局部区间仍跨旋转断点,下一轮重新识别有序半段即可;命中返回原下标,区间为空返回 -1。
若 target 存在,它的下标始终留在闭区间 [left,right]。无重复元素的旋转数组中,中点两侧至少一半有序;用这一半的端点范围判断目标归属,才能合法丢弃另一半。
图解
- left
- 0
- right
- 6
- target
- 0
- left
- 0
- right
- 6
- mid
- 3
- 中点值
- 1
- left
- 0
- right
- 6
- mid
- 3
- 有序半段
- 右半
- 目标属于该半段
- False
- left
- 0
- right
- 2
- 剩余值
- [6, 7, 0]
- left
- 0
- right
- 2
- mid
- 1
- 中点值
- 7
- left
- 0
- right
- 2
- mid
- 1
- 有序半段
- 左半
- 目标属于该半段
- False
- left
- 2
- right
- 2
- 剩余值
- [0]
- left
- 2
- right
- 2
- mid
- 2
- 中点值
- 0
- left
- 2
- right
- 2
- mid
- 2
- 返回值
- 2
选择步骤 9 步
变量与图例
这条队伍只在一个地方折断了顺序。每次先找到没有折断的半边,再判断目标是否落在它的数值范围里。
- left / right
- 目标仍可能位于的闭区间两端。
- mid
- 本轮中点;先判断是否命中,再识别有序半段。
- 有序半段
- nums[left]≤nums[mid] 时左半有序,否则右半有序。
算法步骤
- 设 left=0、right=n-1,在 left≤right 时取 mid,命中目标就立即返回 mid。
- 比较 nums[left] 与 nums[mid],判断左半是否有序。
- 左半有序时,用 nums[left]≤target<nums[mid] 决定保留左边还是右边。
- 否则右半有序,用 nums[mid]<target≤nums[right] 决定保留右边还是左边。
- 按所选方向更新 right=mid-1 或 left=mid+1;候选为空仍未命中则返回 -1。
代码(Python)
查看完整 Python 代码一次二分识别有序半段 · 与上方图解对应
left、right 是闭区间候选;每轮先判断哪半有序,再决定目标所属,不需要真的旋转回来。
from typing import List
class Solution:
def search(self, nums: List[int], target: int) -> int:
left, right = 0, len(nums) - 1
while left <= right:
mid = (left + right) // 2
if nums[mid] == target:
return mid
if nums[left] <= nums[mid]:
if nums[left] <= target < nums[mid]:
right = mid - 1
else:
left = mid + 1
else:
if nums[mid] < target <= nums[right]:
left = mid + 1
else:
right = mid - 1
return -1运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = Solution()
assert s.search([4,5,6,7,0,1,2], 0) == 4
assert s.search([4,5,6,7,0,1,2], 3) == -1
assert s.search([1], 0) == -1
assert s.search([1], 1) == 0
assert s.search([1,2,3], 3) == 2
assert s.search([3,1], 1) == 1
复杂度
- 时间
- O(log n),n 为非空数组长度;闭区间每轮排除一半。
- 空间
- O(1) 辅助空间。
易错点与边界
- nums[left]≤nums[mid] 必须带等号,单元素半段也有序。
- 判断目标落入有序段时,要区分已排除的 mid 和仍保留的端点。
- 题设无重复且非空;若扩展为空,两份代码均会跳过循环并返回−1。
- 若允许重复值,首中尾相等时不能判定断点,最坏会退化为 O(n)。
主解之后,再比较与迁移。
其他解法 1
找旋转点 + 虚拟有序数组二分时间 O(log n),找旋转点与搜索各一次二分,n 为题设非空数组长度。
先求最小值下标 pivot,再把数组视为从 pivot 起循环读取的有序序列。虚拟下标 mid 对应原数组 (mid+pivot)%n,后续便可使用普通二分。它把寻找接缝与查找目标分成两个阶段,便于复用;全过程只映射下标,不复制或实际旋转数组,最终返回原数组下标。
- 用闭区间 [0,n-1] 找最小值,循环条件 left<right;若 nums[mid]>nums[right] 就令 left=mid+1,否则令 right=mid。
- 两边界相遇后令 pivot=left,重新初始化虚拟搜索闭区间 [0,n-1]。
- 每轮计算虚拟中点 mid 和 actual=(mid+pivot)%n,读取 nums[actual]。
- 值等于 target 时返回 actual;值偏小向右二分,值偏大向左二分,均越过 mid。
- 虚拟候选区间为空时返回 -1。
from typing import List
class Solution:
def search(self, nums: List[int], target: int) -> int:
n = len(nums)
left, right = 0, n - 1
while left < right:
mid = (left + right) // 2
if nums[mid] > nums[right]:
left = mid + 1
else:
right = mid
pivot = left
left, right = 0, n - 1
while left <= right:
mid = (left + right) // 2
actual = (mid + pivot) % n
if nums[actual] == target:
return actual
if nums[actual] < target:
left = mid + 1
else:
right = mid - 1
return -1复杂度
- 时间
- O(log n),找旋转点与搜索各一次二分,n 为题设非空数组长度。
- 空间
- O(1),只做下标映射,不复制或排序数组。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = Solution()
assert s.search([4,5,6,7,0,1,2], 0) == 4
assert s.search([4,5,6,7,0,1,2], 3) == -1
assert s.search([1], 0) == -1
assert s.search([1], 1) == 0
assert s.search([1,2,3], 3) == 2
assert s.search([3,1], 1) == 1
预测练习
第一轮 nums[mid]=1 比 target=0 大,为什么不能仅凭这一点固定舍去整个右半?
一点提示
旋转后整个数组并非普通递增序列;想一想 target=7 会位于哪里。
答案与理由
单独比较中点与目标不提供全局方向。必须先识别 [mid,right]=[3,6] 的值 1…5 有序,再看 0 不在其中,才可舍去这一半;其他旋转位置需要重新判定。
面试追问与解法取舍
重复元素版为什么无法保证对数?例如大量1中藏一个0,观察首、中、尾都是1时无法断定0在哪边,只能逐步缩边;这是信息不足,而不是换一个中点公式就能解决。
模板迁移 / 相似题
试着不看代码,复述这三点
- 先命中判断,再识别有序半段,最后用目标是否落在该值域决定方向。
- 左半区间判断含 left、不含 mid;右半判断不含 mid、含 right,因为 mid 已确认不等。
- “至少一半有序”的直接判定依赖无重复元素;重复值可能让端点比较无法识别折点。
153. 寻找旋转排序数组中的最小值 中等
一句话题意
在无重复值的旋转递增非空数组中,用 O(log n) 找到最小值。
nums = [4,5,6,7,0,1,2] → 最小值 0(位于下标 4)。
生活化理解
把递增山坡剪开后交换两段:最低点是唯一向下跳后的起点。拿中点和右端比,能知道自己站在跳跃前还是跳跃后。
与动态右端比较
始终保留包含最小值的区间,只需与当前右端比较,收敛条件和保留中点的理由明确。核心思路
闭区间 [left,right] 始终包含最小值下标。参考值是当前的 nums[right],右边界移动后下一轮也要读取新的参考值。题设元素互不相同,且 left<right 时中点严格位于右端之前,因此中点值与右端值不会相等。
若 nums[mid]>nums[right],中点位于旋转后的高段,断点与最小值一定在中点右侧,令 left=mid+1。否则从 mid 到 right 没跨断点,这段自身递增,最小值可能正是 mid,也可能更靠左,令 right=mid。此处不能越过中点,因为它仍是答案候选。
循环只在 left<right 时继续。这里寻找一个一定存在的最小值,每轮都留下至少一个候选;两边界相遇时直接返回 nums[left],而不是返回下标或等待区间变空。未旋转数组同样适用:右边界会不断收缩到首项。
最小值的下标始终属于闭区间 [left,right]。nums[mid]>nums[right] 说明 mid 还在旋转后的高段,最小值严格在右侧;否则从 mid 到 right 有序,最小值只能在 mid 或它左边,所以保留 mid。
图解
- left
- 0
- right
- 6
- 右端值
- 2
- left
- 0
- right
- 6
- mid
- 3
- 中点值
- 7
- 右端值
- 2
- left
- 4
- right
- 6
- 右端值
- 2
- left
- 4
- right
- 6
- mid
- 5
- 中点值
- 1
- 右端值
- 2
- left
- 4
- right
- 5
- 右端值
- 1
- left
- 4
- right
- 5
- mid
- 4
- 中点值
- 0
- 右端值
- 1
- left
- 4
- right
- 4
- 右端值
- 0
- left
- 4
- right
- 4
- 最小值
- 0
选择步骤 8 步
变量与图例
用当前右端作为温度计:mid 比右端高,断崖还在右边;mid 不比右端高,最小值可能就是 mid。
- left / right
- 包含最小值下标的闭区间,right 是动态边界。
- mid
- 本轮比较位置;left<right 时 mid<right。
- nums[right]
- 每轮都更新的右端参考值,不能偷偷固定为原数组末值。
算法步骤
- 设 left=0、right=n-1,使最小值位于当前闭区间内。
- 当 left<right 时取中点,比较 nums[mid] 与当前 nums[right]。
- 中点值大于右端值,就令 left=mid+1;否则令 right=mid,保留中点作为候选。
- 只剩一个候选位置时,返回 nums[left]。
代码(Python)
查看完整 Python 代码与动态右端比较 · 与上方图解对应
right 不只是数组终点,也是当前候选区间内的参考值;与它比较可判断中点和最小值是否跨越断点。
from typing import List
class Solution:
def findMin(self, nums: List[int]) -> int:
left, right = 0, len(nums) - 1
while left < right:
mid = (left + right) // 2
if nums[mid] > nums[right]:
left = mid + 1
else:
right = mid
return nums[left]运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = Solution()
assert s.findMin([3,4,5,1,2]) == 1
assert s.findMin([4,5,6,7,0,1,2]) == 0
assert s.findMin([11,13,15,17]) == 11
assert s.findMin([1]) == 1
assert s.findMin([2,1]) == 1
复杂度
- 时间
- O(log n),n 为非空数组长度;每轮缩小仍包含答案的闭区间。
- 空间
- O(1) 辅助空间。
易错点与边界
- R=mid 保留可能的最小值,写成 mid−1 会跳过答案。
- 题目保证非空,空数组没有最小值,不能随意约定返回0。
- 单元素与完全未旋转都是合法输入。
- 当前依赖无重复条件;重复版 nums[mid]==nums[right] 时只能谨慎缩右端,最坏线性。
主解之后,再比较与迁移。
其他解法 1
固定首项,找布尔分界时间 O(log n),n 为非空数组长度;未旋转时 O(1)。
以 nums[0] 为固定基准。确实发生旋转时,数组先是值大于等于基准的高段,再是值小于基准的低段,低段第一个位置就是最小值。未旋转时不存在低段,所以先比较首尾并直接返回首项;之后才能在 [1,n-1] 内寻找这个保证存在的布尔分界。适合从单调条件理解二分,但不能省略未旋转分支。
- 若 nums[0]≤nums[-1],数组已递增或只有一项,直接返回 nums[0]。
- 否则设 left=1、right=n-1,在包含低段起点的闭区间内二分。
- 若 nums[mid]≥nums[0],中点仍在高段,令 left=mid+1;否则保留中点,令 right=mid。
- 两边界相遇时返回 nums[left],即第一个低于原首项的值。
from typing import List
class Solution:
def findMin(self, nums: List[int]) -> int:
if nums[0] <= nums[-1]:
return nums[0]
left, right = 1, len(nums) - 1
while left < right:
mid = (left + right) // 2
if nums[mid] >= nums[0]:
left = mid + 1
else:
right = mid
return nums[left]复杂度
- 时间
- O(log n),n 为非空数组长度;未旋转时 O(1)。
- 空间
- O(1) 辅助空间。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = Solution()
assert s.findMin([3,4,5,1,2]) == 1
assert s.findMin([4,5,6,7,0,1,2]) == 0
assert s.findMin([11,13,15,17]) == 11
assert s.findMin([1]) == 1
assert s.findMin([2,1]) == 1
预测练习
最后一轮 nums[mid]=0≤nums[right]=1,若误写 right=mid−1 会怎样?
一点提示
本题此分支没有证明 mid 不是答案。
答案与理由
mid=4 恰好就是最小值下标。right=3 会把它排除并破坏区间不变量;正确写法是 right=mid=4,使左右在答案处相遇。
面试追问与解法取舍
为什么不总是与左端比较?可以,但随着 L 移动,左端是否仍在原先较大段要重新论证。固定 nums[0] 的替代解明确先排除未旋转情况,才获得单调判定。
模板迁移 / 相似题
试着不看代码,复述这三点
- 与搜索指定值不同,较小的 mid 可能就是最小值,不能一律 mid−1。
- 一支用 left=mid+1,另一支用 right=mid,两支都严格缩小候选闭区间。
- 循环用 left<right,结束时返回 nums[left];返回的是值,不是下标。
4. 寻找两个正序数组的中位数 困难
一句话题意
给定两个非递减数组且总长度非零,求合并后的中位数,目标时间 O(log(m+n))。
nums1 = [1,2,3,4,20,21],nums2 = [5,6,7,8,9,10] → 6.5。分割线会先右移、再左移。
生活化理解
把两队已按身高排好的学生各划一条线,左组总人数固定为一半;只要左组最高的人不比右组最低的人高,中间身高就从两条线旁边读出。
短数组上的分割线二分
固定左右人数后只检查四个边界值,能在短数组上用对数时间直接定位中位数。核心思路
让 a 为较短数组,长度 m≤n。分割线 i 表示从 a 取多少个元素进入左组,取值是 0..m,共 m+1 个切口,并非选中第 i 个元素。令 half=(m+n+1)//2,另一数组取 j=half-i 个。这样左组人数固定,总长度为奇数时左组多一项;在短数组上二分也保证候选 i 对应的 j 落在 0..n 内。
只需读取四个邻边值:al=a[i-1]、ar=a[i]、bl=b[j-1]、br=b[j]。某数组左侧为空时对应左边界用负无穷,右侧为空时对应右边界用正无穷,统一处理端点切口与空短数组。两数组内部本来有序,所以只要 al≤br 且 bl≤ar,所有左组元素就都不大于所有右组元素。
候选切口用闭区间 [left,right]=[0,m]。若 al>br,说明 a 分给左组的数太多,令 right=i-1;继续增大 i 只会使 al 更大、br 更小,无法修复。若 bl>ar,则 a 给左组太少,令 left=i+1。每轮越过错误切口,合法分割始终保留。
两条交叉关系都通过时,人数与顺序同时正确。总长度为奇数,中位数是左组最大值 max(al,bl);为偶数,则是左组最大值与右组最小值 min(ar,br) 的平均。代码只交换数组引用、移动切口并读取边界,没有合并或复制数组。
每轮令 i+j=half,先保证左右人数正确;数组内部原本有序,只需交叉检查 al≤br 与 bl≤ar,就能保证所有左侧值不大于所有右侧值。违反哪条交叉关系,就单调地移动短数组分割线。
图解
- m
- 6
- n
- 6
- half
- 6
- left
- 0
- right
- 6
- left
- 0
- right
- 6
- i
- 3
- j
- 3
- al
- 3
- ar
- 4
- bl
- 7
- br
- 8
- left
- 4
- right
- 6
- 上轮i
- 3
- 上轮j
- 3
- left
- 4
- right
- 6
- i
- 5
- j
- 1
- al
- 20
- ar
- 21
- bl
- 5
- br
- 6
- left
- 4
- right
- 4
- 上轮i
- 5
- 上轮j
- 1
- left
- 4
- right
- 4
- i
- 4
- j
- 2
- al
- 4
- ar
- 20
- bl
- 6
- br
- 7
- i
- 4
- j
- 2
- 左侧最大值
- 6
- 右侧最小值
- 7
- 总长度
- 12
- al
- 4
- ar
- 20
- bl
- 6
- br
- 7
- 返回值
- 6.5
选择步骤 8 步
变量与图例
把两支有序队伍同时分成左右两组:左组人数固定,只调整 a 分给左组几人,b 的人数随之确定。
- a / b,m / n
- a 是较短数组(本例等长),m、n 分别为长度;只对 a 的切口 i 二分。
- half
- 左半区总人数 (m+n+1)//2;本例为 6。
- i / j
- a、b 分给左半区的元素数量;j=half−i。
- al / ar / bl / br
- 分割线两边最近的四个值;空侧用 −∞ 或 +∞ 作为边界。
- left / right
- 允许的 a 分割线数量区间,范围为 0…m,不是普通元素下标。
算法步骤
- 将较短数组作为 a,读取 m、n,设 half=(m+n+1)//2,并初始化切口闭区间 [0,m]。
- 在 left≤right 时取 i=(left+right)//2,令 j=half-i,固定左组总人数。
- 读取 al、ar、bl、br,空左侧用负无穷,空右侧用正无穷。
- 若 al>br,令 right=i-1;否则若 bl>ar,令 left=i+1,下一轮重新计算切口与四个边界值。
- 否则分割合法:奇数长度返回左组最大值,偶数长度返回左组最大值与右组最小值的平均。
代码(Python)
查看完整 Python 代码短数组上的分割线二分 · 与上方图解对应
i 和 j 是进入左组的元素数量;四个边界哨兵统一处理一个数组为空、分割线位于端点的情况。
from typing import List
class Solution:
def findMedianSortedArrays(self, nums1: List[int], nums2: List[int]) -> float:
a, b = nums1, nums2
if len(a) > len(b):
a, b = b, a
m, n = len(a), len(b)
half = (m + n + 1) // 2
left, right = 0, m
while left <= right:
i = (left + right) // 2
j = half - i
al = a[i - 1] if i else float('-inf')
ar = a[i] if i < m else float('inf')
bl = b[j - 1] if j else float('-inf')
br = b[j] if j < n else float('inf')
if al > br:
right = i - 1
elif bl > ar:
left = i + 1
else:
if (m + n) % 2:
return float(max(al, bl))
return (max(al, bl) + min(ar, br)) / 2.0运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = Solution()
assert s.findMedianSortedArrays([1,3], [2]) == 2.0
assert s.findMedianSortedArrays([1,2], [3,4]) == 2.5
assert s.findMedianSortedArrays([], [1]) == 1.0
assert s.findMedianSortedArrays([2], []) == 2.0
assert s.findMedianSortedArrays([0,0], [0,0]) == 0.0
assert s.findMedianSortedArrays([1,2,3,4], [10,11,12,13,14,15]) == 10.5
复杂度
- 时间
- O(log(min(m,n)+2)),m、n 是两个数组长度;二分短数组的分割线,空短数组只比较一次。
- 空间
- O(1),只交换引用并保存边界,不做切片或合并。
易错点与边界
- i=0 表示 A 左边为空;i=m 表示 A 右边为空,不可直接读 a[−1] 或 a[m]。
- 总长度允许一奇一偶、也允许一个数组为空;两者同时为空不在题设内。
- 一定在短数组上二分,否则 j 可能越界。
- 相等边界是合法分割,比较应使用≤。
主解之后,再比较与迁移。
其他解法 2
迭代排除前缀,寻找第 k 小时间 O(log(m+n)),每轮将 k 缩半或耗尽一个数组;调用两次第 k 小不改变量级。
kth(k) 用 i、j 表示两个尚未排除后缀的起点,k 是这两个后缀合在一起后的第 k 小排名,从 1 开始。每轮比较各自至多 k//2 个元素的末项,较小末项所在的那段前缀可以先排除,再按实际排除数量减少 k。它把中位数推广成任意第 k 小查询,同样无需切片;若一个后缀不足 k//2 项,不能仍按 k//2 扣减。
- 一次 kth 调用初始化 i=j=0;若某数组耗尽,直接从另一个后缀取偏移 k-1 的元素。
- 若 k==1,返回两个剩余首项的较小值。
- 否则计算 ai=min(i+k//2,m)-1、bj=min(j+k//2,n)-1,确定两段候选前缀的末项。
- 若 a[ai]≤b[bj],将 k 减去 ai-i+1 并令 i=ai+1;否则按 bj-j+1 减少 k 并移动 j。
- 重复直到得到第 k 小;分别查询排名 (total+1)//2 与 (total+2)//2,取平均作为中位数。
from typing import List
class Solution:
def findMedianSortedArrays(self, nums1: List[int], nums2: List[int]) -> float:
a, b = nums1, nums2
m, n = len(a), len(b)
def kth(k):
i = j = 0
while True:
if i == m:
return b[j + k - 1]
if j == n:
return a[i + k - 1]
if k == 1:
return min(a[i], b[j])
ai = min(i + k // 2, m) - 1
bj = min(j + k // 2, n) - 1
if a[ai] <= b[bj]:
k -= ai - i + 1
i = ai + 1
else:
k -= bj - j + 1
j = bj + 1
total = m + n
return (kth((total + 1) // 2) + kth((total + 2) // 2)) / 2.0复杂度
- 时间
- O(log(m+n)),每轮将 k 缩半或耗尽一个数组;调用两次第 k 小不改变量级。
- 空间
- O(1),以起点下标表示后缀,无切片、无递归栈。
这个方法的边界
- 第 k 小的 k 从1计,排除多少必须减多少,短数组不足 k//2 时尤其重要。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = Solution()
assert s.findMedianSortedArrays([1,3], [2]) == 2.0
assert s.findMedianSortedArrays([1,2], [3,4]) == 2.5
assert s.findMedianSortedArrays([], [1]) == 1.0
assert s.findMedianSortedArrays([2], []) == 2.0
assert s.findMedianSortedArrays([0,0], [0,0]) == 0.0
assert s.findMedianSortedArrays([1,2,3,4], [10,11,12,13,14,15]) == 10.5
归并到中间位置(线性基线)时间 O(m+n),仅归并到中间仍是线性量级,不满足题目 O(log(m+n)) 目标。
按归并的顺序从两个数组取较小的下一项,只处理到中间位置,不保存完整合并数组。i、j 指向下一候选,previous、current 保存最近取出的相邻两个数;奇数长度取 current,偶数长度取二者平均。它直观且只需常数空间,但仍是线性时间,不满足本题的对数时间目标。
- 初始化两个读取下标 i、j,以及 previous、current。
- 执行 total//2+1 轮;每轮先令 previous=current。
- 若第一数组未耗尽,且第二数组耗尽或第一候选不大于第二候选,就读取第一数组并移动 i;否则读取第二数组并移动 j。
- 读取结束,奇数长度返回 current,偶数长度返回 (previous+current)/2。
from typing import List
class Solution:
def findMedianSortedArrays(self, nums1: List[int], nums2: List[int]) -> float:
m, n = len(nums1), len(nums2)
i = j = 0
previous = current = 0
for _ in range((m + n) // 2 + 1):
previous = current
if i < m and (j == n or nums1[i] <= nums2[j]):
current = nums1[i]
i += 1
else:
current = nums2[j]
j += 1
if (m + n) % 2:
return float(current)
return (previous + current) / 2.0复杂度
- 时间
- O(m+n),仅归并到中间仍是线性量级,不满足题目 O(log(m+n)) 目标。
- 空间
- O(1),不保存合并数组,只保存两个最近取出的值。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = Solution()
assert s.findMedianSortedArrays([1,3], [2]) == 2.0
assert s.findMedianSortedArrays([1,2], [3,4]) == 2.5
assert s.findMedianSortedArrays([], [1]) == 1.0
assert s.findMedianSortedArrays([2], []) == 2.0
assert s.findMedianSortedArrays([0,0], [0,0]) == 0.0
assert s.findMedianSortedArrays([1,2,3,4], [10,11,12,13,14,15]) == 10.5
预测练习
第一轮 bl=7>ar=4 时,为何要增加 i,而不是减少 i?
一点提示
左半区里有一个太大的 7,右半区却还有更小的 4。
答案与理由
a 左边取少了、b 左边取多了。增加 i 会把 a 右边的较小元素移到左边,同时令 j 下降,让 b 左边的较大元素退回右边;相反方向只会加剧冲突。
面试追问与解法取舍
第 k 小为何能排除较小前缀?若两个探针各最多取 k//2 项,较小探针之前需要保留的总排名不足 k;可将该前缀作为较小元素先消费,重复值不会改变数值排名结果。若面试只要求正确结果,可先给归并基线,再说明如何利用有序性跳过成段元素实现对数复杂度。
模板迁移 / 相似题
试着不看代码,复述这三点
- 人数条件 i+j=half 与顺序条件两条交叉不等式缺一不可。
- al>br 时 i 过大,right=i−1;bl>ar 时 i 过小,left=i+1。
- 合法分割后,奇数长度取左侧最大值;偶数取左侧最大与右侧最小的均值。
20. 有效的括号 简单
一句话题意
判断仅含三种括号的字符串能否按正确类型和嵌套顺序全部配对。
s = "{[()]()}" → True。依次处理 8 个字符,可看到深层嵌套和同层的第二对 ()。
生活化理解
像打开套娃:最后打开的那一层必须最先合上。栈顶是最新尚未闭合的左括号,只和它配对才能保持嵌套。
栈保存未匹配左括号
栈直接表达最后打开的括号必须先闭合,一次扫描同时约束类型和嵌套顺序。核心思路
从左向右扫描,stack 从底到顶保存已读前缀中尚未匹配的左括号,顺序就是它们出现的顺序。pairs 把右括号映射为所需的左括号;题目输入只有六种括号,因此不在这个映射中的字符就是左括号,直接入栈。
读到右括号时,唯一允许与它匹配的是栈顶。若栈为空,说明它没有对应的左括号;否则弹出最近打开的左括号,与 pairs[ch] 比较。类型不同就立即返回 False,因为这个前缀已经违反嵌套顺序,后面的字符不能修复它;匹配时更早的一层自然成为新栈顶。
读完还要检查栈为空,否则有尚未闭合的左括号。计数只能保证数量相等,不能保证类型与顺序,例如 ([)] 各种括号数量平衡却交叉配对。栈保存的正是计数丢失的历史次序,每个括号至多入栈、出栈一次。
每次扫描后,stack 从底到顶恰好保存已读前缀尚未匹配的左括号。右括号只有与栈顶类型相同才能配对;否则这个前缀已违反嵌套顺序,后面的字符无法修复。
图解
- 已读前缀
- ''
- stack
- []
- 下一个下标
- 0
- 下标
- 0
- ch
- '{'
- 已读前缀
- '{'
- stack
- ['{']
- 下标
- 1
- ch
- '['
- 已读前缀
- '{['
- stack
- ['{', '[']
- 下标
- 2
- ch
- '('
- 已读前缀
- '{[('
- stack
- ['{', '[', '(']
- 下标
- 3
- ch
- ')'
- 已读前缀
- '{[()'
- stack
- ['{', '[']
- 下标
- 4
- ch
- ']'
- 已读前缀
- '{[()]'
- stack
- ['{']
- 下标
- 5
- ch
- '('
- 已读前缀
- '{[()]('
- stack
- ['{', '(']
- 下标
- 6
- ch
- ')'
- 已读前缀
- '{[()]()'
- stack
- ['{']
- 下标
- 7
- ch
- '}'
- 已读前缀
- '{[()]()}'
- stack
- []
- 已读字符数
- 8
- stack
- []
- 返回值
- True
选择步骤 10 步
变量与图例
把栈看成“尚未关上的盒子”:最后打开的盒子在最上面,右括号只能关闭栈顶那一个。
- ch
- 当前读取的括号;图中紫色输入格是本帧刚处理的字符。
- stack
- 尚未匹配的左括号,按打开先后排列,末尾是栈顶。
- pairs[ch]
- 当前右括号要求匹配的左括号类型。
算法步骤
- 建立右括号到左括号的 pairs 映射,并初始化空 stack。
- 依次读取字符;若它是左括号,就压入栈顶。
- 若它是右括号且栈为空,立即返回 False;否则弹出栈顶,与 pairs[ch] 比较,类型不同也返回 False。
- 全部字符处理完后返回 not stack,只有没有遗留左括号才成功。
代码(Python)
查看完整 Python 代码栈保存未匹配左括号 · 与上方图解对应
pairs 将右括号映射为所需左括号;栈顶检查同时约束类型与嵌套顺序。
class Solution:
def isValid(self, s: str) -> bool:
pairs = {')': '(', ']': '[', '}': '{'}
stack = []
for ch in s:
if ch not in pairs:
stack.append(ch)
elif not stack or stack.pop() != pairs[ch]:
return False
return not stack运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = Solution()
assert s.isValid("()[]{}") is True
assert s.isValid("([])") is True
assert s.isValid("(]") is False
assert s.isValid("([)]") is False
assert s.isValid("]") is False
assert s.isValid("((") is False
复杂度
- 时间
- O(n),n 为字符串长度,每个括号最多入栈、出栈各一次。
- 空间
- O(n),全部为左括号时栈达到长度 n。
易错点与边界
- 数量相等不是充分条件,([)] 就是反例。
- 右括号到来时要先判断栈非空,不能直接读取空栈顶。
- 结束时遗留左括号也应失败,不能只检查扫描中的右括号。
- 题设字符只含括号;代码不添加额外字符处理。空串扩展下两种写法均返回 True。
主解之后,再比较与迁移。
其他解法 1
反复消去相邻合法对时间 最坏 O(n²),例如深度 n/2 的同类嵌套,每轮只消去最内层一对且重新扫描字符串。
有效嵌套的最内层必有相邻的 ()、[] 或 {},消去这些配对后,剩余结构仍然有效。反复归约到空串即可判定成功;若某轮完全没有变化却仍非空,就不存在可消去的合法内层。它适合演示嵌套结构的归约,但 Python 字符串不可变,每轮 replace 都重新扫描和创建字符串,深嵌套最坏需要 O(n²) 时间。
- 只要当前字符串非空,就依次用 replace 消去所有相邻的 ()、[]、{},得到 reduced。
- 若 reduced 与本轮输入相同,说明没有任何可消去的合法对,返回 False。
- 否则令 s=reduced,继续归约更外层的配对。
- 最终字符串变空时返回 True。
class Solution:
def isValid(self, s: str) -> bool:
while s:
reduced = s.replace('()', '').replace('[]', '').replace('{}', '')
if reduced == s:
return False
s = reduced
return True复杂度
- 时间
- 最坏 O(n²),例如深度 n/2 的同类嵌套,每轮只消去最内层一对且重新扫描字符串。
- 空间
- O(n) 峰值辅助空间,Python replace 创建新字符串;累计分配量可达 O(n²)。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = Solution()
assert s.isValid("()[]{}") is True
assert s.isValid("([])") is True
assert s.isValid("(]") is False
assert s.isValid("([)]") is False
assert s.isValid("]") is False
assert s.isValid("((") is False
预测练习
把输入换为 "([)]",读到下标 2 的 ) 时为何立即失败?
一点提示
此时栈顶是 [,而不是更早入栈的 (。
答案与理由
右括号 ) 要求栈顶为 (,实际弹出的是 [,类型不匹配。虽然每种括号总数相等,配对顺序却交叉,无法跳过栈顶去匹配更底层的括号。
面试追问与解法取舍
也可以让栈存期望的右括号:见 ( 就压入 ),遇右括号直接与栈顶比较。这与主解等价;单类型括号验证可退化成计数器,多类型仍需保存嵌套次序。
模板迁移 / 相似题
试着不看代码,复述这三点
- 栈记录尚未解决的嵌套次序;单纯计数不足以检查多类型括号。
- 右括号先检查栈是否为空,非空时再弹出并检查类型;不匹配即返回 False。
- 全部字符处理结束后仍需 not stack,防止遗留未闭合的左括号。
155. 最小栈 中等
一句话题意
设计支持 push、pop、top、getMin 的栈,各操作要求常数时间。
依次调用 push(3), push(1), push(1), push(2), getMin(), pop(), getMin(), pop(), getMin(), top(), pop(), getMin()。查询返回依次为 1、1、1、1、3。
生活化理解
每叠上一只箱子,就附一张便条写下“从底到这里最轻箱子的重量”;拿走顶箱时,对应便条一起离开,下面那张立即恢复历史最小值。
数据栈 + 稀疏最小栈
完整数据与最小值历史各司其职,重复最小值的图解能直接说明何时同步入栈和出栈。核心思路
只保存一个全局最小值不够:弹出当前最小元素后,还需要知道之前的最小值。这里用 values 保存全部数据,用 mins 保存刷新或持平最小值的入栈事件;只要数据栈非空,mins[-1] 就是其中所有元素的最小值。
push(val) 总是把值加入 values;只有 mins 为空或 val≤mins[-1] 时,才把它也加入 mins。较大的新值下方已有更小的旧值,只要这个新值还在栈内,旧值就不可能先被弹出,所以它无需成为一份最小值记录。mins 因而从底到顶非递增,但不必与数据栈等长。
pop() 先从 values 弹出 val,只有它等于 mins[-1] 时,才同步弹出一份最小值记录。入栈判断中的等号很关键:连续压入两个最小值 1 要记录两份,弹掉一个后,另一个对应的 1 仍在最小栈顶;两份都弹出后才恢复更早的最小值。
top() 直接读取 values[-1],getMin() 直接读取 mins[-1],查询都不用扫描。递增输入中最小栈较短,递减或全部相等输入下它仍可能有 n 项,因此最坏空间仍为 O(n)。题设保证出栈和查询时数据栈非空。
values 保存完整数据;mins 记录每次入栈时小于等于旧最小值的元素,栈顶始终等于 values 的最小值。弹出值若等于 mins[-1],两栈同步弹出一份历史;重复最小值必须重复记录,才能正确恢复。
图解
- values
- []
- mins
- []
- 下一接口
- push(3)
- 接口
- push(3)
- values
- [3]
- mins
- [3]
- 返回值
- None
- 接口
- push(1)
- values
- [3, 1]
- mins
- [3, 1]
- 返回值
- None
- 接口
- push(1)
- values
- [3, 1, 1]
- mins
- [3, 1, 1]
- 返回值
- None
- 接口
- push(2)
- values
- [3, 1, 1, 2]
- mins
- [3, 1, 1]
- 返回值
- None
- 接口
- getMin()
- values
- [3, 1, 1, 2]
- mins
- [3, 1, 1]
- 返回值
- 1
- 接口
- pop()
- values
- [3, 1, 1]
- mins
- [3, 1, 1]
- 返回值
- None
- 接口
- getMin()
- values
- [3, 1, 1]
- mins
- [3, 1, 1]
- 返回值
- 1
- 接口
- pop()
- values
- [3, 1]
- mins
- [3, 1]
- 返回值
- None
- 接口
- getMin()
- values
- [3, 1]
- mins
- [3, 1]
- 返回值
- 1
- 接口
- top()
- values
- [3, 1]
- mins
- [3, 1]
- 返回值
- 1
- 接口
- pop()
- values
- [3]
- mins
- [3]
- 返回值
- None
- 接口
- getMin()
- values
- [3]
- mins
- [3]
- 返回值
- 3
选择步骤 13 步
变量与图例
数据栈保存每件箱子,最小栈只保存“刷新或持平最小值”的历史事件。尤其观察两个 1 怎样各自保留一份记录。
- values
- 完整数据栈,底部最早入栈,顶部是最近入栈元素。
- mins
- 稀疏最小栈,只保存刷新或持平最小值的入栈事件。
- val
- 当前 push 的值,或 pop 从数据栈弹出的值。
- top / getMin
- 分别直接读 values[-1] 与 mins[-1],都不遍历栈。
算法步骤
- 初始化完整数据栈 values 与稀疏最小栈 mins。
- push(val) 时先压入 values;若 mins 为空或 val≤mins[-1],再向 mins 压入一份 val。
- pop() 时先从 values 取出 val;仅当 val 等于 mins[-1] 时,让 mins 同步弹出一项。
- top() 返回 values[-1];getMin() 返回 mins[-1],两个查询均不修改栈。
代码(Python)
查看完整 Python 代码数据栈 + 稀疏最小栈 · 与上方图解对应
mins 仅记录刷新或持平最小值的入栈事件;出栈时按值决定是否同步撤销最小值事件。
class MinStack:
def __init__(self):
self.values = []
self.mins = []
def push(self, val: int) -> None:
self.values.append(val)
if not self.mins or val <= self.mins[-1]:
self.mins.append(val)
def pop(self) -> None:
val = self.values.pop()
if val == self.mins[-1]:
self.mins.pop()
def top(self) -> int:
return self.values[-1]
def getMin(self) -> int:
return self.mins[-1]运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = MinStack()
s.push(-2)
s.push(0)
s.push(-3)
assert s.getMin() == -3
s.pop()
assert s.top() == 0
assert s.getMin() == -2
s.push(-2)
s.pop()
assert s.getMin() == -2
s.pop()
assert s.top() == -2
s.pop()
s.push(4)
assert s.getMin() == 4 and s.top() == 4
复杂度
- 时间
- 抽象栈操作 O(1);Python list 的修改摊还 O(1),top/getMin 为 O(1)。
- 空间
- 最坏 O(n),数据栈 n 项;最小栈在递减或全部相等输入下也有 n 项。
易错点与边界
- 最小栈入栈条件必须是≤,只用<会丢失重复最小值的历史。
- 不能在 getMin 中每次调用 min(values),那会变成 O(n)。
- 题设保证查询与出栈时非空;负数、重复值、出空后重新入栈都应正确。
- 稀疏最小栈只有在弹出值等于 mins[-1] 时才同步弹出一份记录;弹出更大值时仅修改 values,mins 保持原样。
主解之后,再比较与迁移。
其他解法 1
每层保存值与历史最小值时间 抽象栈操作 O(1);Python list 的 push/pop 为摊还 O(1),top/getMin 为 O(1)。
每一栈层保存 (value,min_so_far),第二项是从栈底到该层的最小值。每次 push 都保存一份对应深度的最小值快照,较大的新值也会复制旧最小值;pop 整层后,下一层自带正确历史,无需按弹出值决定是否更新另一个栈。它便于理解逐层同步的摘要,代价是每个元素固定附带一份最小值,而不是只记录刷新或持平事件。
- 初始化一个空 stack,每项准备保存值与历史最小值的二元组。
- push(val) 时,空栈令 minimum=val,否则取 min(val,旧栈顶的第二项),再压入 (val,minimum)。
- pop() 直接弹出整个二元组,值与该层最小值快照一起撤销。
- top() 返回栈顶二元组的第一项,getMin() 返回第二项。
class MinStack:
def __init__(self):
self.stack = []
def push(self, val: int) -> None:
minimum = min(val, self.stack[-1][1]) if self.stack else val
self.stack.append((val, minimum))
def pop(self) -> None:
self.stack.pop()
def top(self) -> int:
return self.stack[-1][0]
def getMin(self) -> int:
return self.stack[-1][1]复杂度
- 时间
- 抽象栈操作 O(1);Python list 的 push/pop 为摊还 O(1),top/getMin 为 O(1)。
- 空间
- O(n),n 为当前栈元素数,每层存一个值和一个最小值。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = MinStack()
s.push(-2)
s.push(0)
s.push(-3)
assert s.getMin() == -3
s.pop()
assert s.top() == 0
assert s.getMin() == -2
s.push(-2)
s.pop()
assert s.getMin() == -2
s.pop()
assert s.top() == -2
s.pop()
s.push(4)
assert s.getMin() == 4 and s.top() == 4
预测练习
若 push 的条件把 val<=mins[-1] 改成严格小于,本例第一次弹出 1 后会错在哪里?
一点提示
第二个 1 入栈时就不会产生第二份最小值历史。
答案与理由
mins 只剩 [3,1]。第一次弹出一个 1 时会同步弹掉唯一的 1,变成 [3],但 values 仍有另一个 1,getMin 就会错误返回 3。
面试追问与解法取舍
若扩展 getMax,每层再保存前缀最大值即可。若追问一个栈存差值,可保存 val−old_min,并在负差值出栈时还原旧最小值;这种编码更难读,固定宽度语言还须考虑算术范围。常见面试优先用配对栈说明不变量。
其他思路的边界
- 下一条同步提醒的前提是:数据与最小值历史分开存储,并且本次弹出的是当前最小值,此时两份记录需要同步撤销。
- pop 不能只修改数据栈,否则 getMin 会读到已出栈元素。
- 稀疏主解仅在弹出值等于 mins[-1] 时同步,弹出更大值时 mins 不变;元组栈的 self.stack.pop() 弹出完整二元组,已经同时移除了值与该层历史最小值。
模板迁移 / 相似题
试着不看代码,复述这三点
- 把查询时的扫描成本提前到修改时,用历史摘要支持 O(1) 查询。
- 最小栈 push 使用 ≤,pop 用值相等判断同步;两个等号共同处理重复最小值。
- 稀疏并不代表常数空间;递减或全相等输入时,mins 仍可与数据栈一样长。
394. 字符串解码 中等
一句话题意
展开形如 k[内容] 的合法编码,支持多位重复次数和任意合法嵌套。
s="2[a3[b]]c" → "abbbabbbc"
生活化理解
像收拾套娃礼盒:进入内盒时,把外盒已经装好的礼物片段及“复制几份”的订单压在桌角;内盒装完,复制后接回外盒。栈保存暂停的外层,当前片段列表保存正在装的这一层。
显式栈 + 片段列表
把每层的片段和重复次数显式保存,嵌套字符串的恢复顺序容易画清,也容易复现。核心思路
一层只管理自己的内容。parts 是当前括号层已经解码的字符串片段列表,count 是正在读取的重复次数,stack 保存尚未完成的外层现场。数字通过 count = count * 10 + int(char) 累积,所以多位次数不会被拆成几次独立重复。
遇到 [,把当前的 (parts, count) 压栈,再用新的空列表与零次数进入内层。遇到 ],先把内层片段合成 body,再弹栈恢复外层 parts 和 repeat,最后把 body * repeat 作为一个完整片段接回外层。先保存内层结果再恢复外层,这个次序不能颠倒。
栈从底到顶恰好对应尚未闭合的左括号,因此后进先出会先完成最内层。图中的 2[a3[b]]c 先得到 bbb,接成 abbb 后才做外层的两次重复,尾部 c 只追加一次。列表收集片段避免逐字反复拼接长前缀,但每层的合并与重复仍会复制字符,不能忽略输出规模与嵌套复制的代价。
每次处理字符后,parts 保存当前最内层已经解码的片段。stack 从底到顶保存尚未完成的外层片段及其重复次数;右括号只弹最近一层,因而不会串层。
图解
- parts
- []
- stack
- []
- count
- 0
- char
- 2
- count
- 2
- parts
- []
- stack
- []
- char
- [
- parts
- []
- count
- 0
- stack
- [[[], 2]]
- char
- a
- parts
- ["a"]
- count
- 0
- stack
- [[[], 2]]
- char
- 3
- count
- 3
- parts
- ["a"]
- stack
- [[[], 2]]
- char
- [
- parts
- []
- count
- 0
- stack
- [[[], 2], [["a"], 3]]
- char
- b
- parts
- ["b"]
- count
- 0
- stack
- [[[], 2], [["a"], 3]]
- char
- ]
- body
- b
- repeat
- 3
- parts
- ["a"]
- stack
- [[[], 2]]
- char
- ]
- body
- b
- repeat
- 3
- parts
- ["a", "bbb"]
- stack
- [[[], 2]]
- char
- ]
- body
- abbb
- repeat
- 2
- parts
- []
- stack
- []
- char
- ]
- body
- abbb
- repeat
- 2
- parts
- ["abbbabbb"]
- stack
- []
- char
- c
- parts
- ["abbbabbb", "c"]
- count
- 0
- stack
- []
- parts
- ["abbbabbb", "c"]
- stack
- []
- 返回值
- abbbabbbc
选择步骤 13 步
变量与图例
把每对括号当作一张待结账的小票:先结内层,再把整笔小计按倍数记回外层。
- char
- 当前字符;上方箭头指出它在完整输入中的下标。
- parts
- 当前层已经解码的片段列表,不等同于一个字符列表。
- count
- 正在读取的重复次数;进新层时清零。
- stack
- 元素是 (外层 parts, 重复次数),栈顶是最近遇到的左括号。
- body / repeat
- 遇到 ] 时得到的内层完整字符串,以及弹栈取出的次数。
算法步骤
- 初始化 stack=[]、parts=[]、count=0,从左到右逐字符扫描。
- 遇到数字,用 count=count*10+int(char) 累积完整的重复次数。
- 遇到左括号,把 (parts,count) 压入栈,再令 parts=[]、count=0;遇到普通字母则直接追加到当前 parts。
- 遇到右括号,先 join 当前 parts 得到 body,再弹栈恢复外层 parts 和 repeat。
- 把 body*repeat 作为一个片段追加到恢复后的 parts,继续扫描同一输入。
- 扫描结束后,join 最外层 parts 并返回。
代码(Python)
查看完整 Python 代码显式栈 + 片段列表 · 与上方图解对应
stack 保存外层列表和重复数。恢复外层时仅追加一个完成的内层片段,避免使用 prefix += char 反复复制前缀。
class Solution:
def decodeString(self, s: str) -> str:
stack = []
parts = []
count = 0
for char in s:
if char.isdigit():
count = count * 10 + int(char)
elif char == '[':
stack.append((parts, count))
parts, count = [], 0
elif char == ']':
body = ''.join(parts)
parts, repeat = stack.pop()
parts.append(body * repeat)
else:
parts.append(char)
return ''.join(parts)运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert Solution().decodeString("3[a]2[bc]") == "aaabcbc"
assert Solution().decodeString("3[a2[c]]") == "accaccacc"
assert Solution().decodeString("12[a]b") == "a" * 12 + "b"
assert Solution().decodeString("abc") == "abc"
assert Solution().decodeString("2[ab3[c]]x") == "abcccabcccx"
复杂度
- 时间
- O(n+C),C 为各层 join 和重复的字符复制总量;保守 O(n+dL)。
- 空间
- O(n+L) 辅助空间:栈/片段引用和存活的中间字符串;最终字符串本身 O(L)。
易错点与边界
- 次数不是单个字符,必须十进制累积;进入新层后重置。
- 外层前缀与内层文本分开保存,不能在左括号处丢掉前缀。
- 题目保证语法有效、次数为正,不需要额外设计错误语法恢复。
- str 的 += 和乘法都不能当成常数操作;嵌套会多次复制同一最终字符。
主解之后,再比较与迁移。
其他解法 1
递归下降解析时间 O(n+C),C 为递归各层构造中间字符串的复制总量;保守 O(n+dL)。
让 parse() 负责解出一层,使用调用栈保存外层现场。共享游标 i 指向尚未消费的字符;每次 parse() 返回时,i 停在本层右括号上,或者已经到达字符串末尾。遇到数字时,当前调用先读完整 repeat、跨过左括号,再递归取得 body;内层返回后由当前调用跨过对应右括号并追加 body*repeat。它适合从语法层次理解嵌套解析,但要留意递归深度;片段合并与重复的字符复制开销仍然存在。
- 令共享游标 i=0,定义 parse(),每次调用建立自己的 parts=[]。
- 只要 i 未到末尾且当前字符不是右括号,就继续处理当前层。
- 若遇到数字,循环累计完整 repeat 并推进 i,随后再推进一次,消费左括号。
- 递归调用 parse() 得到内层 body;返回后推进 i,消费刚才停留的右括号,再追加 body*repeat。
- 若遇到普通字母,追加到本层 parts,并把 i 加一。
- 遇到本层右括号或输入末尾时返回 join(parts);最外层调用的结果就是答案。
class Solution:
def decodeString(self, s: str) -> str:
i = 0
def parse():
nonlocal i
parts = []
while i < len(s) and s[i] != ']':
if s[i].isdigit():
repeat = 0
while s[i].isdigit():
repeat = repeat * 10 + int(s[i])
i += 1
i += 1 # 消费左括号
body = parse()
i += 1 # 消费右括号
parts.append(body * repeat)
else:
parts.append(s[i])
i += 1
return ''.join(parts)
return parse()复杂度
- 时间
- O(n+C),C 为递归各层构造中间字符串的复制总量;保守 O(n+dL)。
- 空间
- O(n+L) 存活片段与中间字符串,含 O(d) 递归栈;返回值 O(L)。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert Solution().decodeString("3[a]2[bc]") == "aaabcbc"
assert Solution().decodeString("3[a2[c]]") == "accaccacc"
assert Solution().decodeString("12[a]b") == "a" * 12 + "b"
assert Solution().decodeString("abc") == "abc"
assert Solution().decodeString("2[ab3[c]]x") == "abcccabcccx"
预测练习
处理最里面的 ] 时,为什么恢复出的 parts 是 ["a"],而不是 []?
一点提示
回看读到第二个 [ 之前,当前层已经收集了哪个字母。
答案与理由
第二个 [ 把 (["a"],3) 保存到栈顶。内层 b 先重复为 bbb,再接到 a 后面,才能得到外层要重复两次的 abbb。
面试追问与解法取舍
若面试要求解释真正接近 O(n+L) 的展开,可先建立由“串联/重复/字面量”组成的语法树,再遍历向字符缓冲区输出;需处理次数为 1 的深链以免重复遍历开销。常见两种逐层构造解法应诚实报告中间复制成本。
模板迁移 / 相似题
试着不看代码,复述这三点
- 栈保存“回来以后继续做什么”,嵌套结构才能从内到外完成。
- 先收拢 body,再恢复外层,再追加 body×repeat,三个动作不可乱序。
- 使用片段列表避免逐字符反复拼接;复杂度仍须计入真实解码输出的长度。
739. 每日温度 中等
一句话题意
对每天计算距离下一次严格更高温度的天数,若以后没有则填 0。
temperatures=[73,71,71,74,72,75] → [3,2,1,2,1,0]
生活化理解
像一排等涨价通知的人:每个人记住登记日和当日价格,新价格出现时,只通知队尾那些价格更低的人。被通知者等待天数就是新日减登记日;还没被通知的人继续留在栈里。
从左向右单调栈
把还没找到更暖天气的旧日期放入待办栈,新温度如何结算等待天数一目了然。核心思路
stack 存下标而不是温度,因为答案需要的是相隔天数。栈内每个下标都代表“到目前为止还没有找到更暖的一天”,对应气温从栈底到栈顶单调不增。相同温度可以同时留在栈里,因为题目要求严格升温。
扫描到下标 i、温度 t 时,只要 t 严格大于栈顶旧温度,就弹出旧下标 j,写入 answer[j] = i - j。此时 i 就是最近的更暖日:如果此前已经出现过更暖的一天,j 早已被弹出,不会一直等到现在。
一次高温可能让多天连续出栈,所以必须反复检查新栈顶,最后才压入当前下标 i。处理中的答案 0 表示尚未结算;扫描结束后仍为 0,才表示以后没有更暖日。虽然有内层循环,每个下标只入栈一次、出栈至多一次,总操作数仍是线性的。
栈保存尚未找到更暖日的下标,对应气温从底到顶单调不增。当前温度能弹出的每个下标,都把当前日视作第一个更暖日,因此答案一旦写入就不必修改。
图解
- stack
- []
- answer
- [0, 0, 0, 0, 0, 0]
- i
- 0
- t
- 73
- stack
- [0]
- answer
- [0, 0, 0, 0, 0, 0]
- i
- 1
- t
- 71
- stack
- [0, 1]
- answer
- [0, 0, 0, 0, 0, 0]
- i
- 2
- t
- 71
- stack
- [0, 1, 2]
- answer
- [0, 0, 0, 0, 0, 0]
- i
- 3
- t
- 74
- j
- 2
- stack
- [0, 1]
- answer
- [0, 0, 1, 0, 0, 0]
- i
- 3
- t
- 74
- j
- 1
- stack
- [0]
- answer
- [0, 2, 1, 0, 0, 0]
- i
- 3
- t
- 74
- j
- 0
- stack
- []
- answer
- [3, 2, 1, 0, 0, 0]
- i
- 3
- t
- 74
- stack
- [3]
- answer
- [3, 2, 1, 0, 0, 0]
- i
- 4
- t
- 72
- stack
- [3, 4]
- answer
- [3, 2, 1, 0, 0, 0]
- i
- 5
- t
- 75
- j
- 4
- stack
- [3]
- answer
- [3, 2, 1, 0, 1, 0]
- i
- 5
- t
- 75
- j
- 3
- stack
- []
- answer
- [3, 2, 1, 2, 1, 0]
- i
- 5
- t
- 75
- stack
- [5]
- answer
- [3, 2, 1, 2, 1, 0]
- stack
- [5]
- answer
- [3, 2, 1, 2, 1, 0]
- 返回值
- [3, 2, 1, 2, 1, 0]
选择步骤 13 步
变量与图例
像一叠等通知的便签:新来的高温先通知最近的低温,通知完还要继续往下看。
- i / t
- 当前天的下标与温度。
- stack
- 待结算下标栈;图中同时标出对应气温。
- j
- 本次弹出的旧下标。
- answer
- 等待天数;处理过程中 0 表示待定,结束后 0 表示不存在。
算法步骤
- 把 answer 初始化为与气温数组等长的全 0 列表,建立空下标栈 stack。
- 从左到右枚举当前下标 i 与温度 t。
- 当栈非空且 t 严格大于栈顶所指温度时,弹出 j,并写 answer[j]=i-j;重复检查新的栈顶。
- 无法继续结算后,将当前下标 i 压栈,等待后续更暖的一天。
- 扫描结束返回 answer,未能出栈日期对应的答案保持 0。
代码(Python)
查看完整 Python 代码从左向右单调栈 · 与上方图解对应
保存未解决日期的下标;新的一天负责结算若干旧日期。严格 > 保留相等温度。
from typing import List
class Solution:
def dailyTemperatures(self, temperatures: List[int]) -> List[int]:
answer = [0] * len(temperatures)
stack = []
for i, t in enumerate(temperatures):
while stack and t > temperatures[stack[-1]]:
j = stack.pop()
answer[j] = i - j
stack.append(i)
return answer运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert Solution().dailyTemperatures([73,74,75,71,69,72,76,73]) == [1,1,4,2,1,1,0,0]
assert Solution().dailyTemperatures([70,70,71]) == [2,1,0]
assert Solution().dailyTemperatures([90,80,70]) == [0,0,0]
assert Solution().dailyTemperatures([30]) == [0]
复杂度
- 时间
- O(n),每个下标最多进出栈各一次。
- 空间
- O(n) 栈,返回答案另外 O(n)。
易错点与边界
- 答案是下标差,不能用温度差。
- 递减序列和最后一天都可能一直没有更暖日,保留 0。
- 弹出的是旧日,不能把等待天数写入当前日。
- 从左向右扫描时,相同温度不能结算;只有当前温度严格更高才弹出旧下标。
主解之后,再比较与迁移。
其他解法 1
从右向左单调栈时间 O(n),每个候选只进出栈一次。
从未来往今天扫描,stack 保存右侧仍有可能成为答案的候选下标。与主解结算旧日期不同,这个方法在处理 i 时直接填写 answer[i]。先弹掉温度小于等于今天的候选:它们既不能回答今天的问题,对于更左侧的日期又被更近且不冷的今天取代。弹完以后,若栈非空,栈顶就是最近的严格更暖日。这里的弹栈条件是 <=,不能照搬主解的 >。
- 初始化全 0 的 answer 与空栈,从最后一天下标向左扫描。
- 反复弹出温度小于等于 temperatures[i] 的栈顶候选。
- 若弹栈后仍有候选,写 answer[i]=stack[-1]-i;否则当前答案保持 0。
- 把 i 压栈,供更左侧的日期选择。
- 全部日期处理后返回 answer。
from typing import List
class Solution:
def dailyTemperatures(self, temperatures: List[int]) -> List[int]:
answer = [0] * len(temperatures)
stack = []
for i in range(len(temperatures) - 1, -1, -1):
while stack and temperatures[stack[-1]] <= temperatures[i]:
stack.pop()
if stack:
answer[i] = stack[-1] - i
stack.append(i)
return answer复杂度
- 时间
- O(n),每个候选只进出栈一次。
- 空间
- O(n) 候选栈,返回数组 O(n)。
这个方法的边界
- 左向右结算用 >;右向左清除无用候选用 <=,两者职责不同。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert Solution().dailyTemperatures([73,74,75,71,69,72,76,73]) == [1,1,4,2,1,1,0,0]
assert Solution().dailyTemperatures([70,70,71]) == [2,1,0]
assert Solution().dailyTemperatures([90,80,70]) == [0,0,0]
assert Solution().dailyTemperatures([30]) == [0]
预测练习
下标 2 的 71°C 到来时,能把下标 1 的 71°C 弹出并记 1 天吗?
一点提示
题目要“更高”,还是“至少相等”?
答案与理由
不能。71 不严格大于 71,两天都要等下标 3 的 74°C,最终分别等待 2 天和 1 天。
面试追问与解法取舍
右向左“答案跳跃”也很常见:若 t[j] 不够热就跳到 j+ans[j],ans[j]=0 代表这条链后面再无更热日。与栈相比,它把候选链接编码进答案数组;解释跳跃正确性时必须说明中间被略过的日期都不比 t[j] 更热。
模板迁移 / 相似题
试着不看代码,复述这三点
- 找右侧第一个更大元素时,把尚未解决的下标保存在单调栈里。
- 比较值,保存下标,出栈后才能计算 i−j。
- 区分 > 与 >= 对等值元素的处理;摊还分析给出 O(n) 时间。
84. 柱状图中最大的矩形 困难
一句话题意
在宽度均为 1 的非负高度柱状图中,求被柱子完全覆盖的最大矩形面积。
heights=[2,1,5,6,2,3] → 10(高度 5,覆盖下标 2、3)
生活化理解
像把一块水平招牌放在连续围栏上:招牌高度由覆盖范围里最矮的木板限制。每块木板都可以试做“最低板”,往两边扩到遇见更矮木板为止;单调栈帮我们等到右边界出现再统一结账。
单调栈:下降时结算
在更矮柱出现时一次确定候选矩形的右边界,线性扫描与宽度公式都便于说明。核心思路
最优矩形的高度可以取它覆盖范围中的最矮柱高,因此逐个检查柱高作为限制高度不会漏掉答案。stack 保存高度单调不减的下标,底部 -1 是虚拟左边界;best 保存已经结算的最大面积。较高柱暂时留在栈内,等待更矮的右边界出现。
当前高度 current 比栈顶矮时,弹出栈顶并取其高度 h。当前下标 i 在右边界外,弹栈后的新栈顶也在本次候选矩形左边界外,所以宽度是 i - stack[-1] - 1。用高度乘宽度更新 best,并继续检查是否还要弹出更高柱。
原实现只在高度严格更大时弹栈,等高柱可以共存。新栈顶因此是左侧不高于弹出柱的保留边界,不能一律称作“严格更小”。较右的等高柱可能先结算窄范围,较早的等高柱会继续承接更宽范围,最大值不会遗漏。最后额外处理一次虚拟高度 0,清算剩余正高度柱;它由 current 计算得到,不会修改原数组。
栈中的真实柱高单调不减;栈顶柱遇到更矮的 current 时,其右侧可扩展范围在 i−1 截止。弹栈后的新栈顶作为排除边界,配合 i−left−1 得到候选宽度;等高柱可由较早下标继续承接更宽范围。
图解
- stack
- [-1]
- best
- 0
- n
- 6
- i
- 0
- current
- 2
- stack
- [-1, 0]
- best
- 0
- i
- 1
- current
- 1
- h
- 2
- stack
- [-1]
- 宽度
- 1
- 面积
- 2
- best
- 2
- i
- 1
- current
- 1
- stack
- [-1, 1]
- best
- 2
- i
- 2
- current
- 5
- stack
- [-1, 1, 2]
- best
- 2
- i
- 3
- current
- 6
- stack
- [-1, 1, 2, 3]
- best
- 2
- i
- 4
- current
- 2
- h
- 6
- stack
- [-1, 1, 2]
- 宽度
- 1
- 面积
- 6
- best
- 6
- i
- 4
- current
- 2
- h
- 5
- stack
- [-1, 1]
- 宽度
- 2
- 面积
- 10
- best
- 10
- i
- 4
- current
- 2
- stack
- [-1, 1, 4]
- best
- 10
- i
- 5
- current
- 3
- stack
- [-1, 1, 4, 5]
- best
- 10
- i
- 6
- current
- 0
- h
- 3
- stack
- [-1, 1, 4]
- 宽度
- 1
- 面积
- 3
- best
- 10
- i
- 6
- current
- 0
- h
- 2
- stack
- [-1, 1]
- 宽度
- 4
- 面积
- 8
- best
- 10
- i
- 6
- current
- 0
- h
- 1
- stack
- [-1]
- 宽度
- 6
- 面积
- 6
- best
- 10
- i
- 6
- current
- 0
- stack
- [-1, 6]
- best
- 10
- stack
- [-1, 6]
- best
- 10
- 返回值
- 10
选择步骤 15 步
变量与图例
把较高柱当作尚未封口的帐篷:直到更矮的柱挡在右边,才知道这顶帐篷有多宽。
- i / current
- 当前下标与高度;i=n 时 current=0 是虚拟右哨兵。
- stack
- 保存下标,开头 -1 是左边界哨兵。
- h
- 本次出栈柱的高度。
- left / right
- 图中的教学名称:left=弹栈后的 stack[-1],right=i,都不包含在矩形里。
- best
- 已经结算过的最大矩形面积。
算法步骤
- 令 stack=[-1]、best=0,记录原柱子数量 n。
- 令 i 从 0 扫描到 n;i<n 时 current=heights[i],i=n 时 current=0,作为右侧虚拟哨兵。
- 当栈顶不是 -1 且栈顶柱高大于 current 时,弹出下标并取高度 h。
- 以弹栈后的 stack[-1] 为左排除边界,用 h*(i-stack[-1]-1) 更新 best,重复检查栈顶。
- 把当前 i 压栈,进入下一轮;处理完虚拟哨兵后返回 best。
代码(Python)
查看完整 Python 代码单调栈:下降时结算 · 与上方图解对应
用 -1 简化左侧没有柱子的情况,末尾虚拟 0 负责清算;相等高度保留,最左等高柱承担最大宽度。
from typing import List
class Solution:
def largestRectangleArea(self, heights: List[int]) -> int:
stack = [-1]
best = 0
n = len(heights)
for i in range(n + 1):
current = heights[i] if i < n else 0
while stack[-1] != -1 and heights[stack[-1]] > current:
h = heights[stack.pop()]
best = max(best, h * (i - stack[-1] - 1))
stack.append(i)
return best运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert Solution().largestRectangleArea([2,1,5,6,2,3]) == 10
assert Solution().largestRectangleArea([2,2]) == 4
assert Solution().largestRectangleArea([0]) == 0
assert Solution().largestRectangleArea([1,2,3,4]) == 6
assert Solution().largestRectangleArea([4,3,2,1]) == 6
复杂度
- 时间
- O(n),每根柱子最多入栈、出栈一次。
- 空间
- O(n) 下标栈;不复制或修改输入数组。
易错点与边界
- 宽度必须减 1,左右边界柱不属于当前矩形。
- 严格/非严格比较要与等高柱的归属方案一致,不能混用边界解释。
- 单调递增输入也需要末尾清算,否则大矩形一直留在栈里。
- 高度可以是 0,零高度仅分隔正高度矩形,面积也可能为 0。
主解之后,再比较与迁移。
其他解法 1
左右边界跳跃时间 O(n),两趟跳跃对应维护单调候选链,候选被绕过后不再回到后续链中。
先为每根柱子分别求左右两侧第一根严格更矮柱的位置,保存为 left[i] 与 right[i],再统一计算面积。求左边界时从 j=i-1 出发,若 heights[j]>=heights[i],就直接跳到已经算好的 left[j]:被跨过的整段都不比当前柱矮,不可能成为目标边界。右边界对称地从右向左计算,用 right[j] 跳跃。这种写法把每根柱子的完整边界显式保存,适合需要复用边界信息的场景;这里必须用 >= 跳过等高柱,才能得到严格更小边界。
- 建立 left=[-1]*n、right=[n]*n,分别用 -1 与 n 表示找不到更矮柱。
- 从左向右枚举 i,令 j=i-1;当 j 有效且 heights[j]>=heights[i] 时令 j=left[j],最后记录 left[i]=j。
- 从右向左枚举 i,令 j=i+1;当 j 有效且 heights[j]>=heights[i] 时令 j=right[j],最后记录 right[i]=j。
- 对每个 i 计算 heights[i]*(right[i]-left[i]-1),取最大值返回。
from typing import List
class Solution:
def largestRectangleArea(self, heights: List[int]) -> int:
n = len(heights)
left = [-1] * n
right = [n] * n
for i in range(n):
j = i - 1
while j >= 0 and heights[j] >= heights[i]:
j = left[j]
left[i] = j
for i in range(n - 1, -1, -1):
j = i + 1
while j < n and heights[j] >= heights[i]:
j = right[j]
right[i] = j
return max(heights[i] * (right[i] - left[i] - 1) for i in range(n))复杂度
- 时间
- O(n),两趟跳跃对应维护单调候选链,候选被绕过后不再回到后续链中。
- 空间
- O(n),两个边界数组;max 使用生成器,没有额外面积列表。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert Solution().largestRectangleArea([2,1,5,6,2,3]) == 10
assert Solution().largestRectangleArea([2,2]) == 4
assert Solution().largestRectangleArea([0]) == 0
assert Solution().largestRectangleArea([1,2,3,4]) == 6
assert Solution().largestRectangleArea([4,3,2,1]) == 6
预测练习
i=4、高度 2 到来时,先弹 6 再弹 5:高度 5 的宽度为什么是 2,而不是 3?
一点提示
此时新栈顶是下标 1;下标 1 与下标 4 都能进入高度 5 的矩形吗?
答案与理由
不能,它们分别高 1 和高 2。合法范围只有 [2,3],宽度为 4−1−1=2,面积 10。
面试追问与解法取舍
分治基线可以找区间最矮柱,比较“跨过它的矩形、左半最优、右半最优”。线性寻找最小值时,平衡划分约 O(n log n),单调输入退化 O(n²),递归深度还可能到 n;用单调栈可以直接避免这个退化。
模板迁移 / 相似题
试着不看代码,复述这三点
- 出栈时再结算,是因为更矮的当前柱给出了右边界。
- 宽度用“右排除边界−左排除边界−1”,别把当前矮柱算进去。
- 左右哨兵统一边界处理;每个下标至多入栈、出栈一次,时间 O(n)。
215. 数组中的第K个最大元素 中等
一句话题意
返回数组排序后第 k 大的元素,重复值按出现次数计入排名。
nums=[3,2,1,5,0,6,4], k=3 → 4
生活化理解
像选比赛前 k 名但只问最后一个入围分数:小根堆是一间只有 k 个座位的候选室,门口站着其中分数最低的人;快速选择则像按一条分数线把人分成低、等、高三组,只去包含目标排名的那一组继续找。
大小为 k 的小根堆
用堆顶表示前 k 大元素的入围门槛,思路稳定且容易复现,也能迁移到流式 Top k。核心思路
只保留目前最大的 k 个元素。把它们放进小根堆 heap,根 heap[0] 就是这批候选里最小的一个,也就是当前第 k 名的门槛。先复制前 k 个数并执行 heapify,此后堆的大小始终保持为 k。
扫描后续下标 i:如果 nums[i] > heap[0],新数可以挤进入围名单,就用 heapreplace 替换最弱候选并恢复堆序;否则直接跳过。每轮处理后,堆仍恰好保存已扫描前缀中最大的 k 个元素,这就是算法持续成立的不变量。
扫描完整数组后,最大的 k 个元素中最小的那个就是第 k 大,直接返回堆根。图中保留的最终候选为 [4,5,6],所以第 3 大是 4。堆数组不需要完整排序,重复值也照常占据排名,不能先去重。
处理每个新数之后,heap 恰好保留已扫描前缀中最大的 k 个元素,根是其中最小值。新数超过根就替换,否则跳过,因此这个“第 k 名门槛”会正确随前缀更新。
图解
- k
- 3
- heap
- [3, 2, 1]
- 阶段
- 切片,尚未 heapify
- k
- 3
- heap
- [1, 2, 3]
- heap根
- 1
- i
- 3
- 当前值
- 5
- heap
- [1, 2, 3]
- 比较
- 5>1
- i
- 3
- heap
- [2, 5, 3]
- heap根
- 2
- 已处理前缀
- [3, 2, 1, 5]
- i
- 4
- 当前值
- 0
- heap
- [2, 5, 3]
- 比较
- 0<=2
- i
- 5
- 当前值
- 6
- heap
- [2, 5, 3]
- 比较
- 6>2
- i
- 5
- heap
- [3, 5, 6]
- heap根
- 3
- 已处理前缀
- [3, 2, 1, 5, 0, 6]
- i
- 6
- 当前值
- 4
- heap
- [3, 5, 6]
- 比较
- 4>3
- i
- 6
- heap
- [4, 5, 6]
- heap根
- 4
- 已处理前缀
- [3, 2, 1, 5, 0, 6, 4]
- heap
- [4, 5, 6]
- k
- 3
- 返回值
- 4
选择步骤 10 步
变量与图例
把堆想成只有 3 个座位的决赛席:门口站着最弱的入围者,新人只需挑战它。
- heap
- Python heapq 的真实数组表示;根位于下标 0。
- i
- 当前扫描的原数组下标,从 k 开始。
- k
- 需要保留的候选个数,本例为 3。
- Top 3 / 已处理前缀
- 图上的教学观察值;原代码无需另存一份排序列表。
算法步骤
- 复制 nums 的前 k 个元素到 heap,再调用 heapify 建立小根堆。
- 从下标 k 开始,依次扫描剩余 nums[i]。
- 若 nums[i] 大于 heap[0],调用 heapreplace(heap,nums[i]) 淘汰堆根并纳入新数;否则保持堆不变。
- 完整扫描后返回 heap[0],它是保留的 k 个最大元素中的最小值。
代码(Python)
查看完整 Python 代码大小为 k 的小根堆 · 与上方图解对应
前 k 项建堆,之后只有比堆顶更大的值才替换门槛。重复元素作为不同出现保留,不先去重。
from typing import List
import heapq
class Solution:
def findKthLargest(self, nums: List[int], k: int) -> int:
heap = nums[:k]
heapq.heapify(heap)
for i in range(k, len(nums)):
if nums[i] > heap[0]:
heapq.heapreplace(heap, nums[i])
return heap[0]运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert Solution().findKthLargest([3,2,1,5,6,4],2) == 5
assert Solution().findKthLargest([3,2,3,1,2,4,5,5,6],4) == 4
assert Solution().findKthLargest([2,2,2],2) == 2
assert Solution().findKthLargest([-1],1) == -1
assert Solution().findKthLargest([1,2,3,4],4) == 1
复杂度
- 时间
- O(n log(k+1)) 上界,建堆 O(k),后续每次替换 O(log k)。
- 空间
- O(k),nums[:k] 会实际复制 k 项;输入不变。
易错点与边界
- 第 k 大不是第 k 个不同值,不能先转 set。
- 小根堆保留的是 k 个最大候选,答案是 heap[0];堆数组本身不是完整排序,不能按普通数组排名取值。
主解之后,再比较与迁移。
其他解法 1
随机三路快速选择时间 期望 O(n),最坏 O(n²);随机化不是最坏线性保证。
把第 k 大转换为升序目标下标 target=n-k,只继续处理包含该排名的区间。lo、hi 是候选闭区间,每轮随机选 pivot 并原地做三路划分:扫描中 [lo,lt) 小于 pivot,[lt,i) 等于 pivot,[i,gt] 未处理,(gt,hi] 大于 pivot。过大的值与 gt 交换后只能缩小 gt,不能增加 i,因为换来的元素尚未检查。划分结束后只保留 target 所在的一侧,若它落进等值段就返回 pivot。此法可一次跳过很多重复值,期望总扫描量为 O(n),但极端枢轴序列仍可达到 O(n²),并且会修改 nums 的排列。
- 令 target=len(nums)-k,候选闭区间初始化为 lo=0、hi=len(nums)-1。
- 在当前区间随机选 pivot,令 lt=i=lo、gt=hi。
- 当 i<=gt:小于 pivot 的元素与 lt 交换并同时增加 lt、i;大于 pivot 的元素与 gt 交换并只减少 gt;等于 pivot 时只增加 i。
- 划分完成后,若 target<lt,令 hi=lt-1;若 target>gt,令 lo=gt+1。
- 若 target 落在 [lt,gt] 的等值段,直接返回 pivot;否则在缩小后的区间继续划分。
from typing import List
from random import randint
class Solution:
def findKthLargest(self, nums: List[int], k: int) -> int:
target = len(nums) - k
lo, hi = 0, len(nums) - 1
while lo <= hi:
pivot = nums[randint(lo, hi)]
lt, i, gt = lo, lo, hi
while i <= gt:
if nums[i] < pivot:
nums[lt], nums[i] = nums[i], nums[lt]
lt += 1
i += 1
elif nums[i] > pivot:
nums[i], nums[gt] = nums[gt], nums[i]
gt -= 1
else:
i += 1
if target < lt:
hi = lt - 1
elif target > gt:
lo = gt + 1
else:
return pivot复杂度
- 时间
- 期望 O(n),最坏 O(n²);随机化不是最坏线性保证。
- 空间
- O(1) 额外空间,迭代实现无递归栈;会改动输入顺序。
这个方法的边界
- 升序目标是 n-k,不是 k-1。
- 三路划分把大元素换到右边后,i 暂时不能前进。
- 随机快速选择只有期望线性;原地版本会修改输入。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert Solution().findKthLargest([3,2,1,5,6,4],2) == 5
assert Solution().findKthLargest([3,2,3,1,2,4,5,5,6],4) == 4
assert Solution().findKthLargest([2,2,2],2) == 2
assert Solution().findKthLargest([-1],1) == -1
assert Solution().findKthLargest([1,2,3,4],4) == 1
预测练习
扫描到 0 时,为什么不需要比较堆里的 3 和 5?
一点提示
当时的根已经是候选中的最小值 2。
答案与理由
0 连最小候选 2 都无法超过,当然也无法挤掉其余两人;堆不变,继续扫描即可。
面试追问与解法取舍
若必须最坏 O(n),可讨论 BFPRT 的中位数的中位数,但常数与实现复杂度更高。若数据持续到达、不能原地划分全量数组,用 O(k) 小根堆更自然;只做一次查询也可排序,Python 排序需 O(n log n) 时间及 O(n) 最坏临时空间。
模板迁移 / 相似题
试着不看代码,复述这三点
- 求第 k 大可以保留 k 个最大值,用小根堆暴露最弱候选。
- heapreplace 既替换根又恢复堆序;堆数组本身并非全局有序。
- 建堆 O(k),后续每次替换 O(log k),额外空间 O(k)。
347. 前 K 个高频元素 中等
一句话题意
统计整数出现频次,返回出现最频繁的 k 个不同元素,输出顺序不限。
nums=[4,4,1,1,1,2,3], k=2 → [4,1](顺序可交换)
生活化理解
像票选热门菜:先把每道菜的所有选票合成一张计票单,再按票数放入编号抽屉。第 f 个抽屉装恰好 f 票的菜;从大编号往下取,拿够 k 道菜就结束。
计数 + 小根堆
先把频次与数值分开,再复用 Top k 堆模板,能够清楚解释候选的保留和淘汰规则。核心思路
先计数,再按频次选候选。counts = Counter(nums) 把每种不同数值映射到出现次数。随后遍历的是 counts.items():每种值只作为一个候选处理一次,而不是把它的每次出现都放进堆。
heap 存放 (frequency, value)。每轮先压入当前元组,若长度超过 k,就弹出最小元组。因为频次是第一关键字,堆根暴露的是最少见的候选;每轮结束后,堆保留已处理数值中频次最高的至多 k 项。
元组第二项只是 Python 在频次相等时用于比较的字段,不表示题目要求按数值大小排名。最后只提取元组的 value 返回,堆内顺序无需再排序。图中 4 出现 2 次、1 出现 3 次,频次更低的 2 和 3 会在堆超过容量时被弹出,最终得到 [4,1]。
每处理完一种不同值,heap 保留已处理值中频次最大的至多 k 项。堆以 (frequency,value) 排序,超过 k 时弹出最小频次,因此不会丢失最终应留下的高频候选。
图解
- counts
- {"4": 2, "1": 3, "2": 1, "3": 1}
- k
- 2
- counts
- {"4": 2, "1": 3, "2": 1, "3": 1}
- heap
- []
- k
- 2
- value
- 4
- frequency
- 2
- heap
- [(2, 4)]
- k
- 2
- value
- 1
- frequency
- 3
- heap
- [(2, 4), (3, 1)]
- k
- 2
- value
- 2
- frequency
- 1
- heap
- [(1, 2), (3, 1), (2, 4)]
- k
- 2
- value
- 2
- frequency
- 1
- 弹出元组
- (1, 2)
- heap
- [(2, 4), (3, 1)]
- value
- 3
- frequency
- 1
- heap
- [(1, 3), (3, 1), (2, 4)]
- k
- 2
- value
- 3
- frequency
- 1
- 弹出元组
- (1, 3)
- heap
- [(2, 4), (3, 1)]
- heap
- [(2, 4), (3, 1)]
- 返回值
- [4, 1]
选择步骤 9 步
变量与图例
把每种商品的销量写在名牌上,再让名牌参加 Top 2;商品编号大小不参与主排名。
- counts
- value→出现次数的完整统计表。
- value / frequency
- 本轮处理的一种数及它的出现次数。
- heap
- 真实的 (频次,数值) 元组堆;频次是第一关键字。
- k
- 需要返回的不同值个数。
算法步骤
- 用 Counter(nums) 汇总每个不同元素的出现次数,建立空堆 heap。
- 依次枚举 counts.items() 中的 value 与 frequency。
- 将 (frequency,value) 压入小根堆,确保频次位于元组第一项。
- 若 len(heap)>k,弹出一次堆根,恢复至多 k 个候选的容量。
- 所有不同元素处理后,提取堆中每个元组的 value,组成答案返回。
代码(Python)
查看完整 Python 代码计数 + 小根堆 · 与上方图解对应
计数表确保候选去重,堆只保留当前前 k 高频元素;无需对全部 m 个不同元素排序。
from typing import List
from collections import Counter
import heapq
class Solution:
def topKFrequent(self, nums: List[int], k: int) -> List[int]:
counts = Counter(nums)
heap = []
for value, frequency in counts.items():
heapq.heappush(heap, (frequency, value))
if len(heap) > k:
heapq.heappop(heap)
return [value for frequency, value in heap]运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
from collections import Counter
assert set(Solution().topKFrequent([1,1,1,2,2,3],2)) == {1,2}
assert Solution().topKFrequent([7],1) == [7]
assert set(Solution().topKFrequent([-1,-1,0,0,0],2)) == {-1,0}
# 并列扩展:不固定顺序,也不固定同频边界处选择谁。
data = [1,1,2,2,3,3,4]
out = Solution().topKFrequent(data,2)
count = Counter(data)
assert len(out) == len(set(out)) == 2
assert min(count[x] for x in out) >= max(count[x] for x in count if x not in out)
复杂度
- 时间
- O(n+m log(k+1)),m 是不同元素数;通常适合 k 远小于 m。
- 空间
- O(m+k),计数表不能只算作堆的 O(k);输出 O(k)。
易错点与边界
- 堆接收的是计数后的不同元素,直接把 nums 入堆会重复选择。
- 返回顺序不要求排序;原题唯一集合与扩展题允许并列任选应区分。
主解之后,再比较与迁移。
其他解法 1
计数 + 频次桶时间 O(n+m)=O(n),计数、分桶和倒序扫描均线性。
利用频次只能落在 1 到 n 之间这一性质,直接用频次当桶下标。counts 保存数值到次数的映射,buckets[f] 保存所有恰好出现 f 次的不同元素。填桶后从 n 向 1 扫描;进入某个桶时,所有更高频元素都已优先选过。逐个加入 answer,达到 k 个立即返回,避免把整个同频桶一起加入而超出数量。此法用 n+1 个桶换取线性扫描,适合从频次范围有限的角度理解本题。原题保证答案集合唯一,返回排列仍不作要求。
- 建立 counts=Counter(nums),再建立长度 len(nums)+1 的空列表桶数组 buckets。
- 对每个 value、frequency,把 value 追加到 buckets[frequency]。
- 建立 answer=[],按 frequency 从 len(nums) 递减到 1 扫描桶。
- 逐个把当前桶里的 value 加入 answer,每加入一个就检查数量。
- 当 len(answer)==k 时立即返回,不继续加入当前桶余下元素。
from typing import List
from collections import Counter
class Solution:
def topKFrequent(self, nums: List[int], k: int) -> List[int]:
counts = Counter(nums)
buckets = [[] for _ in range(len(nums) + 1)]
for value, frequency in counts.items():
buckets[frequency].append(value)
answer = []
for frequency in range(len(nums), 0, -1):
for value in buckets[frequency]:
answer.append(value)
if len(answer) == k:
return answer复杂度
- 时间
- O(n+m)=O(n),计数、分桶和倒序扫描均线性。
- 空间
- O(n+m)=O(n) 桶与计数表;输出 O(k)。
这个方法的边界
- 桶索引是频次而非元素值,负整数同样能被正确统计。
- 同一桶可能有多个元素,达到 k 后及时停止,不能多返回。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
from collections import Counter
assert set(Solution().topKFrequent([1,1,1,2,2,3],2)) == {1,2}
assert Solution().topKFrequent([7],1) == [7]
assert set(Solution().topKFrequent([-1,-1,0,0,0],2)) == {-1,0}
# 并列扩展:不固定顺序,也不固定同频边界处选择谁。
data = [1,1,2,2,3,3,4]
out = Solution().topKFrequent(data,2)
count = Counter(data)
assert len(out) == len(set(out)) == 2
assert min(count[x] for x in out) >= max(count[x] for x in count if x not in out)
预测练习
元组若误写成 (value,frequency),处理完所有数据会保留什么,为什么不对?
一点提示
小根堆会把元组第一项最小的候选淘汰。
答案与理由
它会偏向保留数值更大的 3、4,而把出现 3 次的 1 丢掉。频次必须放在第一项,才是在做“最常见”排名。
面试追问与解法取舍
如果 m 很小而 n 很大,长度 n+1 的桶会浪费不少空列表,可选堆或对 m 个计数项排序。频次实时变化时,静态堆条目不会自动更新,不能把这个离线实现直接当成动态排行榜。
模板迁移 / 相似题
试着不看代码,复述这三点
- 先压缩成频次表,再对不同值做 Top k。
- 堆元素的第一个字段决定排序目标,元组次序会改变算法含义。
- 若有 m 种不同值,计数 O(n),维护候选 O(m log(k+1));返回顺序无需有序。
295. 数据流的中位数 困难
一句话题意
设计支持不断添加整数并查询当前所有数中位数的数据结构。
依次 addNum(5)、addNum(2)、addNum(8)、addNum(1),每次 findMedian() → [5,3.5,5,3.5]
生活化理解
像让两队人按身高站在门两侧:左队保存较矮的一半,门边能立即看到左队最高的人;右队保存较高的一半,门边看到右队最低的人。人数一样就平均两位门卫,左边多一人就由左门卫代表中位数。
双堆维护两半
用两个堆顶保存中间分界,既适合持续插入,也能直接解释奇偶两种中位数查询。核心思路
把数据分成左右两半,维护两个条件:左半每个真实值都不大于右半每个值;low 与 high 等大,或 low 多一个。low 用真实值的相反数模拟大根堆,所以左半最大值是 -low[0];high 是普通小根堆,右半最小值是 high[0]。
插入 num 时先把 -num 压入 low,再弹出左半最大真实值并移入 high。这样新数不会破坏两半的值顺序。若右半数量更多,再把右半最小值移回左半。上述条件在一次插入全部完成后成立;图中搬移的中间步骤可能暂时失衡,不能在那个状态下解释中位数。
查询时,若左半多一个,它的最大值就是中位数;若两半等大,取两个堆顶真实值的平均。只需读取堆顶,因为它们正好位于两半分界处。堆数组自身不是完整排序,其他数组下标不能当作排名;同一个搬移流程也能处理重复值与负数。
每次 addNum 完成后,左半所有值都不大于右半所有值;low 与 high 等大,或 low 多一个。图会明确显示操作中暂时不满足这些条件的状态,只有平衡完成才查询中位数。
图解
- low
- []
- high
- []
- 已加入
- []
- num
- 5
- low
- [-5]
- high
- []
- 阶段
- 刚压入 low
- num
- 5
- low
- []
- high
- [5]
- 需要再平衡
- True
- 本次中位数
- 暂不查询
- num
- 5
- low
- [-5]
- high
- []
- 本次中位数
- 5
- num
- 2
- low
- [-5, -2]
- high
- []
- 阶段
- 刚压入 low
- num
- 2
- low
- [-2]
- high
- [5]
- 需要再平衡
- False
- 本次中位数
- 3.5
- num
- 8
- low
- [-8, -2]
- high
- [5]
- 阶段
- 刚压入 low
- num
- 8
- low
- [-2]
- high
- [5, 8]
- 需要再平衡
- True
- 本次中位数
- 暂不查询
- num
- 8
- low
- [-5, -2]
- high
- [8]
- 本次中位数
- 5
- num
- 1
- low
- [-5, -2, -1]
- high
- [8]
- 阶段
- 刚压入 low
- num
- 1
- low
- [-2, -1]
- high
- [5, 8]
- 需要再平衡
- False
- 本次中位数
- 3.5
- low
- [-2, -1]
- high
- [5, 8]
- 每次中位数
- [5, 3.5, 5, 3.5]
选择步骤 12 步
变量与图例
把排好队的人分成左右两半,只让左半最高者和右半最矮者站在门口,中位数就在两扇门之间。
- num
- 这一次加入数据流的真实数值。
- low
- 实际是负数小根堆;-low[0] 才是左半最大真实值。
- high
- 右半普通小根堆,high[0] 是右半最小真实值。
- 大小 / 每次中位数
- 图上的数量与查询记录,用于观察完成状态;代码不另存历史。
算法步骤
- 初始化 self.low=[]、self.high=[];low 存储真实值的相反数。
- 每次 addNum(num),先把 -num 压入 low。
- 从 low 弹出最小存储值并取相反数,将得到的左半最大真实值压入 high。
- 若 high 比 low 长,再弹出 high 的最小值,取相反数压回 low。
- findMedian() 时,若 low 更长则返回 -low[0];否则返回 (-low[0]+high[0])/2。
代码(Python)
查看完整 Python 代码双堆维护两半 · 与上方图解对应
固定让 low 多一个或两边一样大,避免查询时处理多种方向;负号只用于 low 的内部存储。
import heapq
class MedianFinder:
def __init__(self):
self.low = []
self.high = []
def addNum(self, num: int) -> None:
heapq.heappush(self.low, -num)
heapq.heappush(self.high, -heapq.heappop(self.low))
if len(self.high) > len(self.low):
heapq.heappush(self.low, -heapq.heappop(self.high))
def findMedian(self) -> float:
if len(self.low) > len(self.high):
return -self.low[0]
return (-self.low[0] + self.high[0]) / 2运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
m = MedianFinder()
m.addNum(1)
m.addNum(2)
assert m.findMedian() == 1.5
m.addNum(3)
assert m.findMedian() == 2
q = MedianFinder()
for value, expected in [(-3,-3),(-1,-2),(-1,-1),(5,-1),(5,-1),(5,2)]:
q.addNum(value)
assert q.findMedian() == expected
复杂度
- 时间
- 添加 O(log n),查询 O(1),n 为当前已添加元素数。
- 空间
- O(n),两个堆共同保存所有元素。
易错点与边界
- low 使用负数,取堆顶和跨堆转移都要恢复正确符号。
- 这里的负数存储指对真实值取相反数;原值为负时,存入 low 的数反而为正。
- 大小平衡不足以保证中位数正确,还必须保持左半所有值 <= 右半。
- 偶数长度用 / 而不是 //,负数平均值也可能带 .5。
- 题目保证查询前至少添加一个数;这里只实现规定接口。
主解之后,再比较与迁移。
其他解法 1
二分插入有序列表时间 添加最坏 O(n):二分 O(log n),后缀移动 O(n);查询 O(1)。
直接让 values 始终保持全局升序。addNum 使用 insort 找到合适位置并插入,findMedian 则根据 n 的奇偶读取中间一个元素或两个元素的平均。它没有双堆分界与平衡过程,适合作为小规模数据或推导时的直观基线。但二分查找位置不等于整个插入是对数时间:Python 列表还要移动后续元素,单次插入仍有 O(n) 代价。
- 初始化空有序列表 self.values。
- 每次 addNum(num),调用 insort(self.values,num),插入后列表仍保持升序。
- 查询时令 n=len(values)、middle=n//2。
- 若 n 为奇数,返回 values[middle];若为偶数,返回 values[middle-1] 与 values[middle] 的平均值。
from bisect import insort
class MedianFinder:
def __init__(self):
self.values = []
def addNum(self, num: int) -> None:
insort(self.values, num)
def findMedian(self) -> float:
n = len(self.values)
middle = n // 2
if n % 2:
return self.values[middle]
return (self.values[middle - 1] + self.values[middle]) / 2复杂度
- 时间
- 添加最坏 O(n):二分 O(log n),后缀移动 O(n);查询 O(1)。
- 空间
- O(n) 有序列表。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
m = MedianFinder()
m.addNum(1)
m.addNum(2)
assert m.findMedian() == 1.5
m.addNum(3)
assert m.findMedian() == 2
q = MedianFinder()
for value, expected in [(-3,-3),(-1,-2),(-1,-1),(5,-1),(5,-1),(5,2)]:
q.addNum(value)
assert q.findMedian() == expected
预测练习
插入 8 并从 low 搬到 high 后,为什么还不能返回 (2+5)/2?
一点提示
此时已经有几个数,两堆大小分别是多少?
答案与理由
已有 3 个数,但 low 只有 1 个、high 有 2 个。应把 high 的 5 搬回 low,让左边多 1 个,然后返回 5;3.5 是上一次只有两个数时的答案。
面试追问与解法取舍
若所有数都在 0 到 100,可用 101 个计数槽,插入 O(1),查询扫描累积频次 O(101)。若仅大部分值在该区间,区间外值仍要按顺序维护,不能丢弃;先比较查询与插入比例再决定结构。
模板迁移 / 相似题
试着不看代码,复述这三点
- 双堆同时维护“值有序分半”和“数量相差至多 1”两个条件。
- 负数存储实现大根堆;跨堆时要换符号,读取中位数时也要还原。
- 插入只做常数次堆操作,O(log n);查询只看根,O(1)。
121. 买卖股票的最佳时机 简单
一句话题意
只能先买后卖一次,求最大非负利润;没有赚钱机会时可以不交易。
prices=[7,1,5,3,6,4] → 5(第 1 天买,第 4 天卖,下标从 0 开始)
生活化理解
像每天查看机票转卖价格,但只能买一次卖一次:决定今天卖时,只关心过去能买到的最低价。小本子上记 min_price,利润纪录记 best;未来价格不能回头成为今天的买入价。
历史最低价贪心
以卖出日为枚举轴,把最优买入日压缩为历史最低价,两个变量就能讲清单笔交易。核心思路
以“今天卖出”为枚举轴。每轮处理下标 i 之前,min_price 是此前所有日期的最低价格。对于任何今天卖出的交易,最好的买价必然是这个最低价,所以当天只需检查候选利润 prices[i] - min_price。
best 保存已经考察过的卖出日里能够得到的最大利润,初值 0 对应不交易。先用当天候选更新 best,再把当天价格纳入 min_price,使买入日早于卖出日的含义直接体现在更新顺序中。
最低买价只会降低或不变,历史最高利润只会提高或不变,它们不是同一本账。图中价格跌到 1 时只是改善了未来的买价,并没有产生盈利;最后一天的候选利润回落到 3,也不会覆盖此前已经得到的最高利润 5。
每轮开始,min_price 是今天之前的最低价;计算今天卖出的利润后,best 是截至今天的最优单笔利润。最后再把今天价格纳入最低价,交给未来的卖出日使用。
图解
- min_price
- 7
- best
- 0
- 最低价所在日
- 0
- i
- 1
- min_price
- 7
- 当日候选利润
- -6
- best
- 0
- i
- 1
- min_price
- 1
- best
- 0
- 最低价所在日
- 1
- i
- 2
- min_price
- 1
- 当日候选利润
- 4
- best
- 4
- i
- 3
- min_price
- 1
- 当日候选利润
- 2
- best
- 4
- i
- 4
- min_price
- 1
- 当日候选利润
- 5
- best
- 5
- i
- 5
- min_price
- 1
- 当日候选利润
- 3
- best
- 5
- min_price
- 1
- best
- 5
- 最佳交易
- 第 1 天买,第 4 天卖
选择步骤 8 步
变量与图例
像随手记两本账:一本记至今最便宜的买价,另一本记已经见过的最高可赚金额。
- i
- 当前被考虑为卖出日的下标。
- min_price
- 截至上一天的最低买价;本轮末尾纳入今天。
- best
- 已经考察过的所有卖出日中的最高利润。
- 最低价所在日 / 最佳交易
- 图中用于解释的日期标注,原实现不另存日期。
- 候选利润
- prices[i]−min_price 的本次计算值,可能为负。
算法步骤
- 令 min_price=prices[0]、best=0,从第二天开始枚举下标 i。
- 用之前的最低价计算当天卖出的候选利润 prices[i]-min_price,并更新 best。
- 再令 min_price=min(min_price,prices[i]),把今天纳入未来可选择的买入日。
- 全部日期处理后返回 best;若没有正利润,保留不交易对应的 0。
代码(Python)
查看完整 Python 代码历史最低价贪心 · 与上方图解对应
min_price 只总结历史买入候选,best 总结已经完成的交易。两个标量足够,无需列举买卖日期对。
from typing import List
class Solution:
def maxProfit(self, prices: List[int]) -> int:
min_price = prices[0]
best = 0
for i in range(1, len(prices)):
best = max(best, prices[i] - min_price)
min_price = min(min_price, prices[i])
return best运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert Solution().maxProfit([7,1,5,3,6,4]) == 5
assert Solution().maxProfit([7,6,4,3,1]) == 0
assert Solution().maxProfit([5]) == 0
assert Solution().maxProfit([2,2,2]) == 0
assert Solution().maxProfit([2,4,1]) == 2
复杂度
- 时间
- O(n),n 为天数。
- 空间
- O(1),直接用下标遍历,不生成 prices[1:] 切片。
易错点与边界
- 只能一笔交易,不能把所有正相邻差相加,那是允许多次交易的另一题。
- 买点必须在卖点之前,全局最低价和最高价直接相减会忽略时间顺序。
- 单天、全部下跌、全部相等都应返回 0。
主解之后,再比较与迁移。
其他解法 1
差分上的 Kadane时间 O(n),差分按需计算,不额外构造数组。
买入日 b 到卖出日 s 的利润,等于相邻价格差从 b+1 到 s 的连续区间和,因此可以转成最大连续子数组和。ending 表示以当前差分结尾的最优非负区间和,best 表示所有已见区间中的最大值;负的累计收益直接清零,相当于丢掉不值得继承的亏损前缀。代码直接计算 prices[i]-prices[i-1],不需要另建差分数组。这个视角适合迁移到最大子数组模板。
- 初始化 ending=best=0,从 i=1 开始处理相邻两天的价差。
- 令 ending=max(0,ending+prices[i]-prices[i-1]),负收益前缀在这里被清零。
- 用当前 ending 更新全局 best。
- 扫描结束返回 best;空交易允许结果为 0。
from typing import List
class Solution:
def maxProfit(self, prices: List[int]) -> int:
ending = best = 0
for i in range(1, len(prices)):
ending = max(0, ending + prices[i] - prices[i - 1])
best = max(best, ending)
return best复杂度
- 时间
- O(n),差分按需计算,不额外构造数组。
- 空间
- O(1) 辅助空间。
这个方法的边界
- 差分解允许空收益 0,不能照搬要求非空子数组的初始化。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert Solution().maxProfit([7,1,5,3,6,4]) == 5
assert Solution().maxProfit([7,6,4,3,1]) == 0
assert Solution().maxProfit([5]) == 0
assert Solution().maxProfit([2,2,2]) == 0
assert Solution().maxProfit([2,4,1]) == 2
预测练习
最后一天价格为 4,候选利润只有 3,为什么答案仍然是 5?
一点提示
best 记录的是今天的候选,还是此前所有候选的最大值?
答案与理由
best 是历史最大值,max(5,3)=5。第 1 天买、第 4 天卖已经构成合法交易,今天回落不会撤销它。
面试追问与解法取舍
如果还要返回买卖下标,就在刷新 min_price 时保存买点,在刷新 best 时保存当时买点和当前卖点。若改为最多两次交易,需要增加交易次数状态;单个历史最低价无法表达已经卖出后再买入的情形。
模板迁移 / 相似题
试着不看代码,复述这三点
- 枚举卖出日,最优买入日可压缩为此前最低价。
- 先比较今天卖出,再更新今天成为未来买价,含义更清楚。
- 全局答案与当天候选分开;允许不交易令 best 初始为 0。
55. 跳跃游戏 中等
一句话题意
从下标 0 出发,每格数字是最大向右跳长,判断是否能到达最后一格。
nums=[2,3,1,1,0,2,0] → False(最远只能到下标 4)
生活化理解
像踩跳板过河:已经能够踩到的一段河面形成连续覆盖区,每块可达跳板都能把覆盖最右端推远。只要下一块跳板仍在覆盖内,就能借它继续扩展;若脚下已经越过覆盖,就再也没有桥能接过去。
正向最远覆盖
可达位置能够压缩为连续前缀,只检查当前起点与最远边界就能说明成功或断点。核心思路
far 是已扫描可达位置所能覆盖的最远下标。因为每个位置允许选择不超过 nums[i] 的合法步长,从可达位置扩展出来的是一段连续范围,所以只保存最右端就足够,不需要记录每条路径。数组范围内不超过 far 的位置都已确认可达。
扫描下标 i 时,必须先判断 i > far。若成立,前面所有可达起点都无法覆盖当前位置,立即失败;若不成立,才可以借用这里的跳跃能力,令 far = max(far, i + length),其中 length = nums[i]。
边界一旦达到 len(nums)-1 就可以成功。遇到零本身不等于失败,关键是已有覆盖能否越过它。图中在 i=5 时已经超过 far=4,所以即使 nums[5]=2 也不能被使用;先更新再检查会凭空越过这个断点。
far 表示已扫描可达位置所能覆盖的最远下标,并且 [0,far] 内没有空洞。每次先确认 i≤far,再用 i+nums[i] 扩展;一旦 i>far,就证明可达前缀已经中断。
图解
- far
- 0
- 终点
- 6
- i
- 0
- length
- 2
- far
- 0
- 可用当前跳跃
- True
- i
- 0
- length
- 2
- far
- 2
- 本位置最远
- 2
- i
- 1
- length
- 3
- far
- 2
- 可用当前跳跃
- True
- i
- 1
- length
- 3
- far
- 4
- 本位置最远
- 4
- i
- 2
- length
- 1
- far
- 4
- 本位置最远
- 3
- i
- 3
- length
- 1
- far
- 4
- 本位置最远
- 4
- i
- 4
- length
- 0
- far
- 4
- 本位置最远
- 4
- i
- 5
- length
- 2
- far
- 4
- 返回值
- False
- far
- 4
- 第一个不可达下标
- 5
- 返回值
- False
选择步骤 10 步
变量与图例
像沿途接力扩展一张通行证:通行证没覆盖到的驿站,即使装备再好也借不到。
- i / length
- 当前下标与从这里最多能跳的步数。
- far
- 已经确认可达的最远下标,范围是闭区间 [0,far]。
- 本位置最远
- 教学显示的 i+length;只有起点可达时才可纳入 far。
- 终点
- len(nums)−1,本例为 6。
算法步骤
- 初始化 far=0,表示起点已经可达。
- 从左到右枚举 i 和 length=nums[i],先检查 i 是否超过 far;若超过,立即返回 False。
- 当前起点可达时,令 far=max(far,i+length) 扩展最远覆盖。
- 若 far>=len(nums)-1,立即返回 True。
- 若循环正常结束,按原实现返回 True。
代码(Python)
查看完整 Python 代码正向最远覆盖 · 与上方图解对应
只有可达位置才能扩展 far,先检查再更新至关重要。到终点即停止,无需寻找具体跳法。
from typing import List
class Solution:
def canJump(self, nums: List[int]) -> bool:
far = 0
for i, length in enumerate(nums):
if i > far:
return False
far = max(far, i + length)
if far >= len(nums) - 1:
return True
return True运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert Solution().canJump([2,3,1,1,4]) is True
assert Solution().canJump([3,2,1,0,4]) is False
assert Solution().canJump([0]) is True
assert Solution().canJump([0,1]) is False
assert Solution().canJump([2,0,0]) is True
复杂度
- 时间
- O(n),最坏扫描每个位置一次。
- 空间
- O(1) 辅助空间。
易错点与边界
- 数值表示最多跳多远,不是必须跳这么远。
- 不能让不可达下标的超大跳长更新 far。
- 0 不一定导致失败,可能直接跳过;单元素 [0] 已在终点。
- 只要覆盖 >= n-1 即成功,不要求恰好等于终点。
主解之后,再比较与迁移。
其他解法 1
反向移动目标时间 O(n),从倒数第二格扫描到起点。
从终点向左找能够接力的位置。goal 表示已扫描后缀中,已知能够通向终点的最左下标。若 i+nums[i]>=goal,当前位置可以选择跳到 goal,再沿已有接力通向终点,因此把 goal 左移到 i;否则保留旧目标。它始终维护一个确实能通向终点的位置,最后检查起点是否也被纳入,关注点与主解的前缀覆盖不同。
- 令 goal=len(nums)-1,先把终点本身设为可接力目标。
- 从倒数第二个位置向左枚举 i。
- 若 i+nums[i]>=goal,令 goal=i;否则保持 goal 不变。
- 扫描完成后返回 goal==0,判断起点是否已经能接上通往终点的链条。
from typing import List
class Solution:
def canJump(self, nums: List[int]) -> bool:
goal = len(nums) - 1
for i in range(len(nums) - 2, -1, -1):
if i + nums[i] >= goal:
goal = i
return goal == 0复杂度
- 时间
- O(n),从倒数第二格扫描到起点。
- 空间
- O(1) 辅助空间。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert Solution().canJump([2,3,1,1,4]) is True
assert Solution().canJump([3,2,1,0,4]) is False
assert Solution().canJump([0]) is True
assert Solution().canJump([0,1]) is False
assert Solution().canJump([2,0,0]) is True
预测练习
如果在检查 i>far 前,先用 nums[5]=2 更新 far,会发生什么?
一点提示
到不了下标 5,还能使用从它出发的跳跃吗?
答案与理由
会错误地把 far 从 4 更新到 7 并判为成功,相当于凭空站到了不可达的位置。必须先检查起点可达性。
面试追问与解法取舍
用 reachable[i] 做动态规划也能逐位置枚举来源,最坏 O(n²)。贪心能压缩为 far 的关键是“可选任意不超过上限的跳长”,可达集合连续;若只允许固定跳长或存在禁止落脚格,该不变量需重新分析。
模板迁移 / 相似题
试着不看代码,复述这三点
- 可达集合可压缩成连续前缀 [0,far],无需记录所有路径。
- 先验证当前起点可达,再扩展最远边界,顺序决定正确性。
- 遇到 0 不一定失败;只有当前扫描点越过所有已有覆盖时才失败。
45. 跳跃游戏 II 中等
一句话题意
在保证最后一格可达的前提下,求从下标 0 到终点的最少跳跃次数。
nums=[2,3,1,1,2,1,1] → 3(一条最短路径:0→1→4→6)
生活化理解
像公交换乘圈:零次换乘只能站在起点,一次乘车能到一个覆盖圈;把这个圈里每个站能接上的线路都看完,就得到下一圈。只有扫完整圈,换乘次数才加一,不能看到更远线路就立刻加次数。
贪心:压缩 BFS 层边界
用当前层边界和下一层最远范围压缩广度优先搜索,能直接解释为什么跳数最少。核心思路
end 是当前 jumps 次跳跃以内已经能覆盖的最右边界,far 汇总扫描过的起点再跳一次能到达的最远位置。循环检查每个下标 i,先用 far = max(far, i + nums[i]) 收集它提供的下一层候选范围。
只有当 i == end,当前层所有可能的跳板才算检查完,才能执行 jumps += 1,并令 end = far。更新最远候选与完成一层是两件事:图中 i=1 时虽然 far 已扩到 4,旧边界仍是 2,此时还不能提前增加跳数。
每次扩层都考虑了当前跳数能够触及的全部起跳点,因此任何使用同样跳数的方案都不能越过得到的新边界。这正是广度优先搜索逐层求最短距离的含义,只是连续层被两个边界代替,不需要队列。原题保证终点可达;终点无需再次起跳,所以循环只到下标 n-2,避免额外多记一次。
end 是 jumps 次跳跃已经覆盖的右边界;扫描当前层时,far 汇总所有已扫描起点能提供的下一层最远位置。只有到 i==end 才增加 jumps 并推进 end,因此跳数按 BFS 层数增长。
图解
- jumps
- 0
- end
- 0
- far
- 0
- i
- 0
- jumps
- 0
- end
- 0
- far
- 2
- i
- 0
- jumps
- 1
- end
- 2
- far
- 2
- i
- 1
- jumps
- 1
- end
- 2
- far
- 4
- i
- 2
- jumps
- 1
- end
- 2
- far
- 4
- i
- 2
- jumps
- 2
- end
- 4
- far
- 4
- i
- 3
- jumps
- 2
- end
- 4
- far
- 4
- i
- 4
- jumps
- 2
- end
- 4
- far
- 6
- i
- 4
- jumps
- 3
- end
- 6
- far
- 6
- i
- 5
- jumps
- 3
- end
- 6
- far
- 6
- jumps
- 3
- end
- 6
- far
- 6
- 返回值
- 3
选择步骤 11 步
变量与图例
想象一队人同时向前探路:far 记录目前最远的报告,end 规定这一轮什么时候收齐报告。
- i
- 本轮正在检查的起点,不代表实际路径一定落在这里。
- end
- 当前已经完成的 jumps 次跳跃能覆盖的最右边界。
- far
- 原代码中的变量名;记录本轮已见的最远下一层候选。
- jumps
- 已经完成的扩层次数,也就是最少跳数。
算法步骤
- 初始化 jumps=end=far=0,零次跳跃只覆盖起点。
- 用 range(len(nums)-1) 扫描到倒数第二个位置,每格先更新 far=max(far,i+nums[i])。
- 若 i 不等于 end,继续扫描当前层,暂不增加 jumps。
- 若 i==end,先把 jumps 加一,再将 end 更新为 far,完成一次扩层。
- 循环结束返回 jumps;终点已被所需的最少层数覆盖。
代码(Python)
查看完整 Python 代码贪心:压缩 BFS 层边界 · 与上方图解对应
end 锁定当前层,far 汇总下一层。两者不能随意合并;只有 end 被扫描完时才增加一次跳跃。
from typing import List
class Solution:
def jump(self, nums: List[int]) -> int:
jumps = end = far = 0
for i in range(len(nums) - 1):
far = max(far, i + nums[i])
if i == end:
jumps += 1
end = far
return jumps运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert Solution().jump([2,3,1,1,4]) == 2
assert Solution().jump([2,3,0,1,4]) == 2
assert Solution().jump([0]) == 0
assert Solution().jump([1,1,1,1]) == 3
assert Solution().jump([4,0,0,0,0]) == 1
assert Solution().jump([1,2,0,1]) == 2
复杂度
- 时间
- O(n),每个位置扫描一次。
- 空间
- O(1),只保存层边界和计数。
易错点与边界
- 只遍历到 n-2,否则终点处可能多计一次跳跃。
- 不能每次 far 变大就 jumps++,必须等整层扫描结束。
- 本题明确保证可达,不把不可达数组混入接口预期。
- 单元素数组答案为 0;最大跳长不代表应始终跳满。
主解之后,再比较与迁移。
其他解法 1
动态规划:枚举前驱时间 O(n²),每个目标位置枚举更早的起跳点;大输入可能超时,是比较基线。
用 dp[i] 显式表示到达下标 i 的最少跳数。对于每个 i,枚举所有 j<i;只要 j+nums[j]>=i,就存在从 j 再跳一次到 i 的转移,候选为 dp[j]+1。因为只能从更小下标向右转移,计算 i 时所需的 dp[j] 已经算完。n 作为初始大值,不会优于有效路径。这个方法适合先从“最后一步来自哪里”推导问题,再理解主解如何把大量前驱压缩成层边界,但枚举全部前驱需要二次时间。
- 令 n=len(nums),初始化 dp=[n]*n,并令 dp[0]=0。
- 从 i=1 到 n-1 依次计算到达每个位置的最少跳数。
- 对当前 i 枚举所有 j<i,检查 j+nums[j]>=i。
- 若 j 能跳到 i,令 dp[i]=min(dp[i],dp[j]+1)。
- 所有位置计算完成后返回 dp[-1]。
from typing import List
class Solution:
def jump(self, nums: List[int]) -> int:
n = len(nums)
dp = [n] * n
dp[0] = 0
for i in range(1, n):
for j in range(i):
if j + nums[j] >= i:
dp[i] = min(dp[i], dp[j] + 1)
return dp[-1]复杂度
- 时间
- O(n²),每个目标位置枚举更早的起跳点;大输入可能超时,是比较基线。
- 空间
- O(n),保存每个位置的最少跳数。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert Solution().jump([2,3,1,1,4]) == 2
assert Solution().jump([2,3,0,1,4]) == 2
assert Solution().jump([0]) == 0
assert Solution().jump([1,1,1,1]) == 3
assert Solution().jump([4,0,0,0,0]) == 1
assert Solution().jump([1,2,0,1]) == 2
预测练习
扫描 i=1 时 far 已从 2 扩到 4,为什么此时 jumps 仍是 1?
一点提示
当前 end 还是 2,这一层还有哪个下标没扫描?
答案与理由
当前层 [1,2] 还剩下标 2,需要把它也扫描完。到 i=2 才统一结算第二跳,令 end=4;far 提前变远不等于已经多跳了一次。
面试追问与解法取舍
若需要返回一条最短路径,可在扫描每层时记录哪一个起跳点把下一层 far 推到最远,完成层时保存该选择,再接上终点。若取消可达保证,只有在层末 far==end 且未到终点时才能判定无法继续,不能在层中途误报。
模板迁移 / 相似题
试着不看代码,复述这三点
- 最短跳数可用 BFS 层边界压缩为 end、far、jumps。
- 更新 far 与推进 end 是两件事;只有扫到旧 end 才增加一次跳跃。
- 题目保证终点可达;循环到 n−2,避免在终点多记一跳。
763. 划分字母区间 中等
一句话题意
把字符串切成尽可能多段,使每个字母最多出现在其中一段,返回各段长度。
s="abacbde" → [5,1,1],对应 "abacb" | "d" | "e"
生活化理解
像把同一家人的行李打包到同一车厢:第一次看到某个姓氏,就知道必须等到这个姓氏最后一件行李到来才能封箱。等待期间遇到新姓氏,又可能把封箱时刻推迟;所有已见姓氏都到齐时,立即封箱才能装出最多车厢。
末次位置 + 最早合法切分
最后出现位置给出片段必须等待的终点,尽早合法切分的贪心证明清楚且实现简短。核心思路
先记 last[char] 为每个字符最后出现的下标。start 是当前段起点,扫描到 i 时,end 是当前段所有已见字符的末次位置最大值;它代表这些字符共同要求的最晚终点。每读到一个字符,就用 end = max(end, last[char]) 更新这个承诺。
若 i < end,至少有一个本段已出现字符还会出现在后面,此处切分必然让它跨段。若 i == end,当前段所有字符都已经出现完,后面不会再有这些字符,可以记录长度 i-start+1 并把 start 移到 i+1。不能只看段首字符的末位,也不能被后来字符较早的末位缩短已有边界。
最早合法切分会使段数最多。任何合法方案的第一刀都不能早于贪心得到的 end;若切得更晚,把它提前到这个边界不会让字符跨段,只会保留或增加后续切分机会。固定这一刀后,对剩余后缀重复同样的判断,就得到全局最优的分段。
在当前片段内,end 始终是所有已见字符最后出现位置的最大值。到 i==end 时,这一段涉及的每个字符都已出现完毕,因此可合法切开;尽早切分使片段数最多。
图解
- last
- {"a": 2, "b": 4, "c": 3, "d": 5, "e": 6}
- start
- 0
- end
- 0
- answer
- []
- i
- 0
- char
- a
- last当前字符
- 2
- start
- 0
- end
- 2
- answer
- []
- i
- 1
- char
- b
- last当前字符
- 4
- start
- 0
- end
- 4
- answer
- []
- i
- 2
- char
- a
- last当前字符
- 2
- start
- 0
- end
- 4
- answer
- []
- i
- 3
- char
- c
- last当前字符
- 3
- start
- 0
- end
- 4
- answer
- []
- i
- 4
- char
- b
- last当前字符
- 4
- start
- 0
- end
- 4
- answer
- []
- i
- 4
- start
- 5
- end
- 4
- answer
- [5]
- i
- 5
- char
- d
- last当前字符
- 5
- start
- 5
- end
- 5
- answer
- [5]
- i
- 5
- start
- 6
- end
- 5
- answer
- [5, 1]
- i
- 6
- char
- e
- last当前字符
- 6
- start
- 6
- end
- 6
- answer
- [5, 1]
- i
- 6
- start
- 7
- end
- 6
- answer
- [5, 1, 1]
- last
- {"a": 2, "b": 4, "c": 3, "d": 5, "e": 6}
- answer
- [5, 1, 1]
- 返回值
- [5, 1, 1]
选择步骤 12 步
变量与图例
把每个字母看成一个家庭:一段车厢里来了某家成员,就必须等这家的最后一个人也上车才能关门。
- last
- 完整字符→末次下标映射,遍历前一次性建立。
- i / char
- 当前扫描位置与字符。
- start
- 尚未封口片段的起点,切分后移到 i+1。
- end
- 当前片段所有已见字符要求覆盖的最远末次位置。
- answer
- 已经封口的片段长度列表。
算法步骤
- 遍历字符串建立每个字符的最后出现位置 last,初始化 answer=[]、start=end=0。
- 从左到右枚举 i 与 char,每步令 end=max(end,last[char])。
- 若 i<end,继续扫描当前段,等待所有已见字符的最后出现。
- 若 i==end,追加当前段长度 i-start+1,再令 start=i+1 开始下一段。
- 遍历结束返回所有片段长度 answer。
代码(Python)
查看完整 Python 代码末次位置 + 最早合法切分 · 与上方图解对应
last 汇总字符对片段右边界的要求;end 随扫描只会右移,达到它时立即切分。
from typing import List
class Solution:
def partitionLabels(self, s: str) -> List[int]:
last = {char: i for i, char in enumerate(s)}
answer = []
start = end = 0
for i, char in enumerate(s):
end = max(end, last[char])
if i == end:
answer.append(i - start + 1)
start = i + 1
return answer运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert Solution().partitionLabels("ababcbacadefegdehijhklij") == [9,7,8]
assert Solution().partitionLabels("eccbbbbdec") == [10]
assert Solution().partitionLabels("a") == [1]
assert Solution().partitionLabels("abc") == [1,1,1]
assert Solution().partitionLabels("abacbded") == [5,3]
复杂度
- 时间
- O(n),两次扫描字符串。
- 空间
- O(Σ) 末次位置表,Σ 为不同字符数;本题小写字母至多 26,辅助空间 O(1),输出 O(段数)。
易错点与边界
- 片段长度为 end-start+1,区间两端都包含。
- 只看段首字符的末次位置不够,中途出现的字符也会扩张边界。
- 同一字符遍布整串时只得一段;所有字符不同则每字符一段。
主解之后,再比较与迁移。
其他解法 1
字符区间合并时间 O(n+Σ log Σ),Σ 是不同字符数;小写字母限制下为 O(n)。
把每个字符从第一次到最后一次出现的位置记成区间 ranges[char]=[first,last]。同一个字符的全部出现必须在一段里;两个这样的区间只要重叠,也必须属于同一片段。把所有字符区间按左端点排序,再合并重叠区间,得到的每个合并结果就是一个片段。start、end 保存当前合并区间,left、right 是下一个区间。它适合迁移到区间合并模板;相邻但不重叠的区间不必合并,所以条件是 left<=end,而不是 left<=end+1。
- 扫描字符串:首次见到字符时建立 [i,i],再次见到时只更新该字符区间的右端点。
- 将 ranges.values() 排序为 intervals,用第一个区间初始化 start、end,并建立 answer=[]。
- 依次处理剩余区间 left、right;若 left<=end,令 end=max(end,right) 合并重叠。
- 若 left>end,先记录 end-start+1,再用当前 left、right 开始新的合并区间。
- 循环结束后补记最后一个区间的长度,并返回 answer。
from typing import List
class Solution:
def partitionLabels(self, s: str) -> List[int]:
ranges = {}
for i, char in enumerate(s):
if char not in ranges:
ranges[char] = [i, i]
else:
ranges[char][1] = i
intervals = sorted(ranges.values())
start, end = intervals[0]
answer = []
for left, right in intervals[1:]:
if left <= end:
end = max(end, right)
else:
answer.append(end - start + 1)
start, end = left, right
answer.append(end - start + 1)
return answer复杂度
- 时间
- O(n+Σ log Σ),Σ 是不同字符数;小写字母限制下为 O(n)。
- 空间
- O(Σ) 字典、排序列表及切片,Python sorted 与 intervals[1:] 都实际分配空间;输出 O(段数)。
这个方法的边界
- 相邻字符区间并不自动合并,只有重叠才必须合在同一段。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert Solution().partitionLabels("ababcbacadefegdehijhklij") == [9,7,8]
assert Solution().partitionLabels("eccbbbbdec") == [10]
assert Solution().partitionLabels("a") == [1]
assert Solution().partitionLabels("abc") == [1,1,1]
assert Solution().partitionLabels("abacbded") == [5,3]
预测练习
i=2 再次读到 a 时,last[a]=2,能把 end 从 4 缩回 2 并切开吗?
一点提示
这一段此前读到的 b 最后还会在哪里出现?
答案与理由
不能,b 最后在 4。end 必须是所有已见字符末位的最大值,保持为 4,等最后一个 b 也进入片段才能切开。
面试追问与解法取舍
如果改成在线流式输入且不知道未来,无法在看到当前位置时判断字符是否还会重现;本题贪心依赖预先知道 last。若只问能否切成至少 k 段,先算最大段数,再比较即可;需要更少段时,合并相邻合法段始终仍合法。
模板迁移 / 相似题
试着不看代码,复述这三点
- 先查清全局末次位置,局部扫描才能判断何时允许切分。
- 边界只取 max 扩张,不能被当前字符更早的末位缩回。
- i==end 是最早合法切分点;片段长度是 i−start+1。
70. 爬楼梯 简单
一句话题意
每次向上走 1 或 2 级,计算到达第 n 级楼梯的不同走法数,走法按步长顺序区分。
n=6 → 13 条走法。教学表从 f[0]=1 一直推到 f[6]=13。
生活化理解
把每一级看成接收旅行路线的车站:到第 i 站的最后一班车只能来自 i−1 或 i−2,两个来源的路线互不重叠,直接相加。
滚动动态规划
最后一步的分类容易证明,两个滚动变量就能在线性时间内完成计算,适合面试复现。核心思路
用 f[i] 表示恰好到达第 i 级的走法数。最后一步只能走 1 级或 2 级:去掉最后一步后,分别对应到达第 i−1 级和第 i−2 级的走法。两类互不重叠且覆盖全部路线,所以 f[i]=f[i−1]+f[i−2]。
边界是 f[0]=1 和 f[1]=1。前者表示站在起点的那条空路线,让直接跨两级的走法能够被递推计入。每次只需要前两级,源码用 a、b 保存它们;图中的整行 f 值只是历史记录,程序没有建立这个数组。
准备计算第 i 级时,a=f[i−2]、b=f[i−1]。执行 a, b = b, a + b,Python 先用两个旧值算完右侧,再一起写回;此后 a、b 分别对应第 i−1 级与第 i 级。按 i 从 2 递增到 n,这个对应关系持续成立,最后返回 b。n=1 时循环不执行,初始 b=1 就是答案。
准备计算第 i 级时,a=f[i−2]、b=f[i−1]。右侧先全部求值,再同时写入,因此新 b 精确等于两个旧状态之和;最后一次结束时 b=f[n]。
图解
| 级数 k | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| f[k](图解记录) | 1 | 1 | · | · | · | · | · |
- n
- 6
- a = f[0]
- 1
- b = f[1]
- 1
| 级数 k | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| f[k](图解记录) | 1 | 1 | · | · | · | · | · |
| 同时赋值 | a | b |
|---|---|---|
| 读取旧值 | 1 | 1 |
| 右侧先算好(尚未写入) | 1 | 2 |
- i
- 2
- a = f[0]
- 1
- b = f[1]
- 1
- 右侧 a+b
- 2
| 级数 k | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| f[k](图解记录) | 1 | 1 | 2 | · | · | · | · |
| 同时赋值 | a | b |
|---|---|---|
| 写入前 | 1 | 1 |
| 同时写入后 | 1 | 2 |
- i
- 2
- a = f[1]
- 1
- b = f[2]
- 2
| 级数 k | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| f[k](图解记录) | 1 | 1 | 2 | · | · | · | · |
| 同时赋值 | a | b |
|---|---|---|
| 读取旧值 | 1 | 2 |
| 右侧先算好(尚未写入) | 2 | 3 |
- i
- 3
- a = f[1]
- 1
- b = f[2]
- 2
- 右侧 a+b
- 3
| 级数 k | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| f[k](图解记录) | 1 | 1 | 2 | 3 | · | · | · |
| 同时赋值 | a | b |
|---|---|---|
| 写入前 | 1 | 2 |
| 同时写入后 | 2 | 3 |
- i
- 3
- a = f[2]
- 2
- b = f[3]
- 3
| 级数 k | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| f[k](图解记录) | 1 | 1 | 2 | 3 | · | · | · |
| 同时赋值 | a | b |
|---|---|---|
| 读取旧值 | 2 | 3 |
| 右侧先算好(尚未写入) | 3 | 5 |
- i
- 4
- a = f[2]
- 2
- b = f[3]
- 3
- 右侧 a+b
- 5
| 级数 k | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| f[k](图解记录) | 1 | 1 | 2 | 3 | 5 | · | · |
| 同时赋值 | a | b |
|---|---|---|
| 写入前 | 2 | 3 |
| 同时写入后 | 3 | 5 |
- i
- 4
- a = f[3]
- 3
- b = f[4]
- 5
| 级数 k | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| f[k](图解记录) | 1 | 1 | 2 | 3 | 5 | · | · |
| 同时赋值 | a | b |
|---|---|---|
| 读取旧值 | 3 | 5 |
| 右侧先算好(尚未写入) | 5 | 8 |
- i
- 5
- a = f[3]
- 3
- b = f[4]
- 5
- 右侧 a+b
- 8
| 级数 k | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| f[k](图解记录) | 1 | 1 | 2 | 3 | 5 | 8 | · |
| 同时赋值 | a | b |
|---|---|---|
| 写入前 | 3 | 5 |
| 同时写入后 | 5 | 8 |
- i
- 5
- a = f[4]
- 5
- b = f[5]
- 8
| 级数 k | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| f[k](图解记录) | 1 | 1 | 2 | 3 | 5 | 8 | · |
| 同时赋值 | a | b |
|---|---|---|
| 读取旧值 | 5 | 8 |
| 右侧先算好(尚未写入) | 8 | 13 |
- i
- 6
- a = f[4]
- 5
- b = f[5]
- 8
- 右侧 a+b
- 13
| 级数 k | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| f[k](图解记录) | 1 | 1 | 2 | 3 | 5 | 8 | 13 |
| 同时赋值 | a | b |
|---|---|---|
| 写入前 | 5 | 8 |
| 同时写入后 | 8 | 13 |
- i
- 6
- a = f[5]
- 8
- b = f[6]
- 13
| 级数 k | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| f[k](图解记录) | 1 | 1 | 2 | 3 | 5 | 8 | 13 |
- a
- 8
- b
- 13
- 返回值
- 13
选择步骤 12 步
变量与图例
每次先看琥珀色的两个旧值,再看紫色新格;下方的小表区分“右侧算好”和“写回变量”。
- n
- 目标级数,本例为 6。
- i
- 本轮刚要计算的级数,从 2 到 n。
- a / b
- 循环开始时保存前两级路线数;写入后一起向前滚动一级。
- f[k](讲解记号)
- 到达第 k 级的路线数;历史表并非源码中的数组。
算法步骤
- 初始化 a=b=1,分别对应 f[0] 与 f[1]。
- 令 i 从 2 遍历到 n,读取本轮旧 a 与旧 b。
- 同时执行 a,b=b,a+b,让 b 成为第 i 级的走法数,a 保留第 i−1 级的值。
- 循环结束返回 b;n=1 时直接返回初始值 1。
代码(Python)
查看完整 Python 代码滚动动态规划 · 与上方图解对应
a、b 保存相邻的两个状态;递推只读这两个数,因此不必保留整张表。Python 的同时赋值保证两个新值都来自旧状态。
class Solution:
def climbStairs(self, n: int) -> int:
a, b = 1, 1
for i in range(2, n + 1):
a, b = b, a + b
return b运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert Solution().climbStairs(5) == 8
assert Solution().climbStairs(1) == 1
assert Solution().climbStairs(2) == 2
assert Solution().climbStairs(45) == 1836311903
复杂度
- 时间
- O(n) 次整数加法;在本题数值范围内按常数时间算术计为 O(n)。若推广到任意大 n,结果有 Θ(n) 位,总位运算可达 O(n²)。
- 空间
- 固定整数范围下辅助空间 O(1);任意大 n 时两个整数共 O(n) 位,无额外输出数组。
易错点与边界
- 不要把 f[0]=1 理解成额外增加了一种到终点的路线,它是递推边界。
- 走 1、2 与走 2、1 顺序不同,因此是两种走法。
- 分两行先更新 a 再计算 b 会污染旧状态,使用同时赋值。
主解之后,再比较与迁移。
其他解法 1
整数矩阵快速幂时间 O(log n) 次固定维数矩阵乘法;这是整数运算次数,不是任意大整数的位复杂度,整数乘法成本随位数增长。
把相邻状态的推进写成矩阵乘法:Q=[[1,1],[1,0]] 将 [f[i],f[i−1]] 推到 [f[i+1],f[i]],Q 的 n 次幂左上角就是 f[n]。result 从单位矩阵开始,base 保存当前二进制位对应的矩阵幂,power 保存尚未处理的指数;始终有 result 乘 base 的 power 次幂等于原目标 Qⁿ。它适合进一步追问如何减少递推次数,使用整数运算也避免浮点通项公式的舍入问题;O(log n) 指矩阵运算次数,大整数乘法本身仍有成本。
- 定义 mul,对两个 2×2 矩阵逐行逐列求乘积。
- 令 result 为单位矩阵,base=Q,power=n。
- power 的最低位为 1 时,将当前 base 乘入 result。
- 把 base 平方,再将 power 右移一位;重复直到 power 为 0。
- 返回 result[0][0],即 Qⁿ 的左上角。
class Solution:
def climbStairs(self, n: int) -> int:
def mul(a, b):
return [[sum(a[i][k] * b[k][j] for k in range(2))
for j in range(2)] for i in range(2)]
result = [[1, 0], [0, 1]]
base = [[1, 1], [1, 0]]
power = n
while power:
if power & 1:
result = mul(result, base)
base = mul(base, base)
power >>= 1
return result[0][0]复杂度
- 时间
- O(log n) 次固定维数矩阵乘法;这是整数运算次数,不是任意大整数的位复杂度,整数乘法成本随位数增长。
- 空间
- O(1) 个整数;若推广到任意大 n,矩阵元素有 O(n) 位,辅助位空间 O(n)。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert Solution().climbStairs(5) == 8
assert Solution().climbStairs(1) == 1
assert Solution().climbStairs(2) == 2
assert Solution().climbStairs(45) == 1836311903
预测练习
到 i=4 时旧 a=2、b=3;若拆成 a=b 然后 b=a+b,会得到多少?
一点提示
第二行会读取已经改成 3 的 a。
答案与理由
会误算成 6,而正确的 f[4] 是旧 2+旧 3=5。问题不在递推式,而在旧状态被覆盖;同时赋值保证右侧来自同一轮。
面试追问与解法取舍
如果允许步长集合 {1,3,5},可改为 f[i]=Σf[i−step],只加非负下标的来源。若问“最少几步”,状态和值域都变了:应对来源取最小值再加 1,不能继续累加路线数。
其他思路的边界
- 无缓存递归会重复计算大量子问题;浮点 Binet 通项不适合作为精确整数答案的通用实现。
模板迁移 / 相似题
试着不看代码,复述这三点
- 计数递推要说明来源不重叠:用最后一步区分路线。
- 滚动前先写清每个变量对应的下标,再推导更新顺序。
- 空路线 f[0]=1 是边界;最少步数则应取最小,不能继续加路线数。
118. 杨辉三角 简单
一句话题意
生成杨辉三角的前 numRows 行;每行两端为 1,内部元素等于上一行相邻两个元素之和。
numRows=5 → [[1],[1,1],[1,2,1],[1,3,3,1],[1,4,6,4,1]]。
生活化理解
像把筹码沿三角形分岔轨道往下送:每个位置收到左上和右上的筹码总和;边缘只有一条路线,所以始终为 1。
按上一行构造二维结果
每个内部格都能直接对应上一行两个来源,输出本身保存状态,最容易讲清并逐格检查。核心思路
从 0 开始编号,第 r 行有 r+1 个数。两端固定为 1,内部第 c 个数等于上一行第 c−1 个数与第 c 个数之和。源码中的 ans 保存已经完成的各行,row 是本轮正在构造的新行。
进入第 r 行时,ans 已有完整的前 r 行。先建立 row=[1]*(r+1),保留首尾的 1;再令 c 从 1 到 r−1,逐格计算 row[c]=ans[r−1][c−1]+ans[r−1][c]。读取的始终是完整上一行,写入的始终是新 row,所以本行的更新不会污染来源。第 0、1 行没有内部格,直接使用边界即可。
本行计算完才执行 ans.append(row),下一轮重新建立独立列表。图中的内部“·”表示尚未推导,实际 row 中这些位置已先初始化为 1。全部行都是题目要求的输出,最终返回整个 ans;不能把只需上一行参与转移误解为只需输出最后一行。
进入第 r 行时,ans 的前 r 行已完整计算。内部每格只读 ans[r−1],新 row 的写入不改变这些来源;本行全部完成后才追加,下一轮继续成立。
图解
| 行 r / 列 c | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|
| r=0 | · | — | — | — | — |
| r=1 | · | · | — | — | — |
| r=2 | · | · | · | — | — |
| r=3 | · | · | · | · | — |
| r=4 | · | · | · | · | · |
- numRows
- 5
- ans
- []
| 行 r / 列 c | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|
| r=0 | 1 | — | — | — | — |
| r=1 | · | · | — | — | — |
| r=2 | · | · | · | — | — |
| r=3 | · | · | · | · | — |
| r=4 | · | · | · | · | · |
- r
- 0
- row
- [1]
- ans 已保存行数
- 1
| 行 r / 列 c | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|
| r=0 | 1 | — | — | — | — |
| r=1 | 1 | 1 | — | — | — |
| r=2 | · | · | · | — | — |
| r=3 | · | · | · | · | — |
| r=4 | · | · | · | · | · |
- r
- 1
- row
- [1, 1]
- ans 已保存行数
- 2
| 行 r / 列 c | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|
| r=0 | 1 | — | — | — | — |
| r=1 | 1 | 1 | — | — | — |
| r=2 | 1 | · | 1 | — | — |
| r=3 | · | · | · | · | — |
| r=4 | · | · | · | · | · |
- r
- 2
- row(源码实际值)
- [1, 1, 1]
- ans 已保存行数
- 2
| 行 r / 列 c | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|
| r=0 | 1 | — | — | — | — |
| r=1 | 1 | 1 | — | — | — |
| r=2 | 1 | 2 | 1 | — | — |
| r=3 | · | · | · | · | — |
| r=4 | · | · | · | · | · |
- r / c
- 2 / 1
- row
- [1, 2, 1]
- ans 已保存行数
- 3
| 行 r / 列 c | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|
| r=0 | 1 | — | — | — | — |
| r=1 | 1 | 1 | — | — | — |
| r=2 | 1 | 2 | 1 | — | — |
| r=3 | 1 | · | · | 1 | — |
| r=4 | · | · | · | · | · |
- r
- 3
- row(源码实际值)
- [1, 1, 1, 1]
- ans 已保存行数
- 3
| 行 r / 列 c | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|
| r=0 | 1 | — | — | — | — |
| r=1 | 1 | 1 | — | — | — |
| r=2 | 1 | 2 | 1 | — | — |
| r=3 | 1 | 3 | · | 1 | — |
| r=4 | · | · | · | · | · |
- r / c
- 3 / 1
- row
- [1, 3, 1, 1]
- ans 已保存行数
- 3
| 行 r / 列 c | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|
| r=0 | 1 | — | — | — | — |
| r=1 | 1 | 1 | — | — | — |
| r=2 | 1 | 2 | 1 | — | — |
| r=3 | 1 | 3 | 3 | 1 | — |
| r=4 | · | · | · | · | · |
- r / c
- 3 / 2
- row
- [1, 3, 3, 1]
- ans 已保存行数
- 4
| 行 r / 列 c | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|
| r=0 | 1 | — | — | — | — |
| r=1 | 1 | 1 | — | — | — |
| r=2 | 1 | 2 | 1 | — | — |
| r=3 | 1 | 3 | 3 | 1 | — |
| r=4 | 1 | · | · | · | 1 |
- r
- 4
- row(源码实际值)
- [1, 1, 1, 1, 1]
- ans 已保存行数
- 4
| 行 r / 列 c | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|
| r=0 | 1 | — | — | — | — |
| r=1 | 1 | 1 | — | — | — |
| r=2 | 1 | 2 | 1 | — | — |
| r=3 | 1 | 3 | 3 | 1 | — |
| r=4 | 1 | 4 | · | · | 1 |
- r / c
- 4 / 1
- row
- [1, 4, 1, 1, 1]
- ans 已保存行数
- 4
| 行 r / 列 c | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|
| r=0 | 1 | — | — | — | — |
| r=1 | 1 | 1 | — | — | — |
| r=2 | 1 | 2 | 1 | — | — |
| r=3 | 1 | 3 | 3 | 1 | — |
| r=4 | 1 | 4 | 6 | · | 1 |
- r / c
- 4 / 2
- row
- [1, 4, 6, 1, 1]
- ans 已保存行数
- 4
| 行 r / 列 c | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|
| r=0 | 1 | — | — | — | — |
| r=1 | 1 | 1 | — | — | — |
| r=2 | 1 | 2 | 1 | — | — |
| r=3 | 1 | 3 | 3 | 1 | — |
| r=4 | 1 | 4 | 6 | 4 | 1 |
- r / c
- 4 / 3
- row
- [1, 4, 6, 4, 1]
- ans 已保存行数
- 5
| 行 r / 列 c | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|
| r=0 | 1 | — | — | — | — |
| r=1 | 1 | 1 | — | — | — |
| r=2 | 1 | 2 | 1 | — | — |
| r=3 | 1 | 3 | 3 | 1 | — |
| r=4 | 1 | 4 | 6 | 4 | 1 |
- numRows
- 5
- ans
- [[1], [1, 1], [1, 2, 1], [1, 3, 3, 1], [1, 4, 6, 4, 1]]
选择步骤 13 步
变量与图例
紫格永远从上一行的两个琥珀格收集数值;留意新 row 何时才被追加到 ans。
- r
- 从 0 开始的当前行号,第 r 行有 r+1 个值。
- c
- 内部列号,遍历 1 到 r−1;首尾不进入循环。
- row
- 本轮新建的列表;源码初始全是 1,内部值随后覆盖。
- ans
- 已经完成并保存的各行,是下轮读取的历史结果。
算法步骤
- 创建空结果 ans,令行号 r 从 0 到 numRows−1。
- 为本行新建长度 r+1、全部为 1 的 row,使两端自然满足边界。
- 令内部列 c 从 1 到 r−1,读取 ans[r−1] 的 c−1、c 两格相加并写入 row[c]。
- 将完整 row 追加到 ans,再开始下一行。
- 所有行完成后返回 ans。
代码(Python)
查看完整 Python 代码按上一行构造二维结果 · 与上方图解对应
ans 存储已完成各行;row 是本次新建的行。只访问 ans[r−1],不需要额外 DP 数组,因为输出本身就保存了状态。
from typing import List
class Solution:
def generate(self, numRows: int) -> List[List[int]]:
ans = []
for r in range(numRows):
row = [1] * (r + 1)
for c in range(1, r):
row[c] = ans[r - 1][c - 1] + ans[r - 1][c]
ans.append(row)
return ans运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert Solution().generate(5) == [[1], [1,1], [1,2,1], [1,3,3,1], [1,4,6,4,1]]
assert Solution().generate(1) == [[1]]
r = Solution().generate(3)
assert len({id(row) for row in r}) == 3
复杂度
- 时间
- R=numRows,按本题固定范围算术模型为 O(R²),必须写出 R(R+1)/2 个元素。
- 空间
- 输出 O(R²) 个整数;row 最终属于输出,除输出外仅 O(1) 个索引和引用。若将未加入结果的新行算作工作区,则峰值工作区 O(R)。
易错点与边界
- numRows=1 时只返回 [[1]],第 0 行已算第一行。
- 不要用同一个可变 row 对象反复加入结果后继续修改,否则历史行会一起变化。
- 内部列只有 1 到 r−1;边界不应读取上一行的越界元素。
主解之后,再比较与迁移。
其他解法 1
逐行组合数递推时间 R=numRows,O(R²) 次整数运算;若推广至任意大 R,组合数位数增长,不能将每次乘除视为常数。
第 r 行第 c 项等于组合数 C(r,c),因此可以独立生成每一行,不读取上一行。value 初始为 C(r,0)=1;已输出 C(r,c) 后,用 value=value*(r−c)//(c+1) 得到 C(r,c+1)。先乘后整除由组合数恒等式保证结果精确,若先做除法可能被整数截断。这个方法适合解释杨辉三角与二项式系数的联系,仍需保存所有行作为输出。
- 创建 ans;每个新行 r 都从空 row 和 value=1 开始。
- 令 c 从 0 到 r,将当前 value=C(r,c) 追加到 row。
- 若 c<r,先乘 r−c,再整除 c+1,计算下一项 value。
- 将完成的 row 加入 ans,最后返回全部行。
from typing import List
class Solution:
def generate(self, numRows: int) -> List[List[int]]:
ans = []
for r in range(numRows):
row = []
value = 1
for c in range(r + 1):
row.append(value)
if c < r:
value = value * (r - c) // (c + 1)
ans.append(row)
return ans复杂度
- 时间
- R=numRows,O(R²) 次整数运算;若推广至任意大 R,组合数位数增长,不能将每次乘除视为常数。
- 空间
- 输出 O(R²) 个整数,额外 O(1) 个标量;任意大 R 的输出位数总量为 Θ(R³),这里常用的 O(R²) 按元素计。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert Solution().generate(5) == [[1], [1,1], [1,2,1], [1,3,3,1], [1,4,6,4,1]]
assert Solution().generate(1) == [[1]]
assert Solution().generate(6)[-1] == [1,5,10,10,5,1]
预测练习
计算第 4 行第 2 列时,应当读取哪两个 3?为什么不能读取本行刚算出的 4?
一点提示
直接对照公式中的行号 r−1。
答案与理由
读取 ans[3][1]=3 与 ans[3][2]=3,得到 6。本行的 4 是 row[1],不属于上层来源;读它会改变杨辉三角的递推关系。
面试追问与解法取舍
若只要第 R 行,可用一行数组从右向左更新,把辅助空间降到 O(R);或直接使用组合数递推。若要求前 R 行,本题的输出量已经是 Θ(R²),不能通过滚动数组把总存储降成 O(R)。
其他思路的边界
- 当前主解每行新建 row;下面的单行原地更新规则只适用于进一步改写的变体,不要求本代码把 c 改为倒序。
- 若采用单行原地更新,必须从右向左更新内部元素,并给输出加入 row 的副本。
模板迁移 / 相似题
试着不看代码,复述这三点
- 先确定边界,再逐个填内部格;不要把初始化值误当推导结果。
- 来源都在上一行,行内更新不互相污染。
- 每轮 row 是新对象;全部行是输出所需空间。
198. 打家劫舍 中等
一句话题意
给出一排房屋的非负金额,不能选择相邻房屋,求能取得的最大总金额。
nums=[2,7,9,3,1] → 12;可偷下标 0、2、4,金额 2+9+1。
生活化理解
像排班时不能连上相邻两个夜班:决定接下今天的班,就只能接在前天以前的最优排班后;拒绝今天,则沿用昨天以前的最优方案。
前缀 DP 表
完整前缀表能把偷与不偷的两条分支同时摆清楚,转移直观,也便于追溯最优选择。核心思路
定义 dp[i] 为前 i 间房屋,即下标 0 到 i−1,在不偷相邻房屋的条件下能取得的最大金额。这个状态不要求偷最后一间,它代表整个前缀的最优选择。设 n=len(nums),建立长度 n+1 的表;空前缀 dp[0]=0,单间前缀 dp[1]=nums[0]。
考虑第 i 间房屋时,所有合法方案分为两类:不偷它,金额最多是 dp[i−1];偷它,就必须跳过上一间,只能把当前金额 nums[i−1] 加到 dp[i−2] 上。因此 dp[i]=max(dp[i−1],dp[i−2]+nums[i−1])。两类覆盖全部合法选择,且偷的分支不会违反相邻约束。
按前缀长度 i 从 2 到 n 填表,两个来源都已计算完成。本格比较的是完整方案的金额,不是只比较相邻两间的价值,也不能固定只偷奇数位或偶数位。图解中前 4 间的最优保留 11,第 5 间又可把它提升到 12;最后返回 dp[n]。
dp[i] 始终是前 i 间所有合法选择中的最大金额。合法方案按是否偷第 i 间分成两类,分别由 dp[i−1] 和 dp[i−2]+nums[i−1] 覆盖,取最大既不漏方案也不允许相邻房屋。
图解
| 已考虑前 i 间 | 0 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|
| 第 i 间价值 | — | 2 | 7 | 9 | 3 | 1 |
| dp[i] | 0 | 2 | · | · | · | · |
- nums
- [2, 7, 9, 3, 1]
- dp(源码)
- [0, 2, 0, 0, 0, 0]
| 已考虑前 i 间 | 0 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|
| 第 i 间价值 | — | 2 | 7 | 9 | 3 | 1 |
| dp[i] | 0 | 2 | · | · | · | · |
| 本轮选择 | 读取的前缀状态 | 加上当前房屋 | 候选金额 |
|---|---|---|---|
| 不偷本间 | dp[1]=2 | 0 | 2 |
| 偷本间 | dp[0]=0 | nums[1]=7 | 7 |
- i
- 2
- 当前房屋金额
- 7
- 不偷候选
- 2
- 偷本间候选
- 7
- dp(写入前)
- [0, 2, 0, 0, 0, 0]
| 已考虑前 i 间 | 0 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|
| 第 i 间价值 | — | 2 | 7 | 9 | 3 | 1 |
| dp[i] | 0 | 2 | 7 | · | · | · |
| 本轮选择 | 读取的前缀状态 | 加上当前房屋 | 候选金额 |
|---|---|---|---|
| 不偷本间 | dp[1]=2 | 0 | 2 |
| 偷本间 | dp[0]=0 | nums[1]=7 | 7 |
- i
- 2
- dp[i]
- 7
- 选择
- 偷本间
- dp(源码)
- [0, 2, 7, 0, 0, 0]
| 已考虑前 i 间 | 0 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|
| 第 i 间价值 | — | 2 | 7 | 9 | 3 | 1 |
| dp[i] | 0 | 2 | 7 | · | · | · |
| 本轮选择 | 读取的前缀状态 | 加上当前房屋 | 候选金额 |
|---|---|---|---|
| 不偷本间 | dp[2]=7 | 0 | 7 |
| 偷本间 | dp[1]=2 | nums[2]=9 | 11 |
- i
- 3
- 当前房屋金额
- 9
- 不偷候选
- 7
- 偷本间候选
- 11
- dp(写入前)
- [0, 2, 7, 0, 0, 0]
| 已考虑前 i 间 | 0 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|
| 第 i 间价值 | — | 2 | 7 | 9 | 3 | 1 |
| dp[i] | 0 | 2 | 7 | 11 | · | · |
| 本轮选择 | 读取的前缀状态 | 加上当前房屋 | 候选金额 |
|---|---|---|---|
| 不偷本间 | dp[2]=7 | 0 | 7 |
| 偷本间 | dp[1]=2 | nums[2]=9 | 11 |
- i
- 3
- dp[i]
- 11
- 选择
- 偷本间
- dp(源码)
- [0, 2, 7, 11, 0, 0]
| 已考虑前 i 间 | 0 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|
| 第 i 间价值 | — | 2 | 7 | 9 | 3 | 1 |
| dp[i] | 0 | 2 | 7 | 11 | · | · |
| 本轮选择 | 读取的前缀状态 | 加上当前房屋 | 候选金额 |
|---|---|---|---|
| 不偷本间 | dp[3]=11 | 0 | 11 |
| 偷本间 | dp[2]=7 | nums[3]=3 | 10 |
- i
- 4
- 当前房屋金额
- 3
- 不偷候选
- 11
- 偷本间候选
- 10
- dp(写入前)
- [0, 2, 7, 11, 0, 0]
| 已考虑前 i 间 | 0 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|
| 第 i 间价值 | — | 2 | 7 | 9 | 3 | 1 |
| dp[i] | 0 | 2 | 7 | 11 | 11 | · |
| 本轮选择 | 读取的前缀状态 | 加上当前房屋 | 候选金额 |
|---|---|---|---|
| 不偷本间 | dp[3]=11 | 0 | 11 |
| 偷本间 | dp[2]=7 | nums[3]=3 | 10 |
- i
- 4
- dp[i]
- 11
- 选择
- 不偷本间
- dp(源码)
- [0, 2, 7, 11, 11, 0]
| 已考虑前 i 间 | 0 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|
| 第 i 间价值 | — | 2 | 7 | 9 | 3 | 1 |
| dp[i] | 0 | 2 | 7 | 11 | 11 | · |
| 本轮选择 | 读取的前缀状态 | 加上当前房屋 | 候选金额 |
|---|---|---|---|
| 不偷本间 | dp[4]=11 | 0 | 11 |
| 偷本间 | dp[3]=11 | nums[4]=1 | 12 |
- i
- 5
- 当前房屋金额
- 1
- 不偷候选
- 11
- 偷本间候选
- 12
- dp(写入前)
- [0, 2, 7, 11, 11, 0]
| 已考虑前 i 间 | 0 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|
| 第 i 间价值 | — | 2 | 7 | 9 | 3 | 1 |
| dp[i] | 0 | 2 | 7 | 11 | 11 | 12 |
| 本轮选择 | 读取的前缀状态 | 加上当前房屋 | 候选金额 |
|---|---|---|---|
| 不偷本间 | dp[4]=11 | 0 | 11 |
| 偷本间 | dp[3]=11 | nums[4]=1 | 12 |
- i
- 5
- dp[i]
- 12
- 选择
- 偷本间
- dp(源码)
- [0, 2, 7, 11, 11, 12]
| 已考虑前 i 间 | 0 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|
| 第 i 间价值 | — | 2 | 7 | 9 | 3 | 1 |
| dp[i] | 0 | 2 | 7 | 11 | 11 | 12 |
- dp
- [0, 2, 7, 11, 11, 12]
- 返回 dp[5]
- 12
选择步骤 10 步
变量与图例
每一间都展开两条分支:偷它要隔一间,不偷它就继承上一前缀;观察 3 被跳过、1 又被选上。
- nums[i−1]
- 第 i 间的金额;nums 从 0 编号,前缀 dp 从房屋数量编号。
- i
- 已考虑房屋的数量,而不是 nums 的当前下标。
- dp[i]
- 前 i 间能取得的最大金额,不要求以“偷第 i 间”结尾。
- 偷 / 不偷(讲解分支)
- 同一 max 表达式的两个候选,不是源码额外保存的两个数组。
算法步骤
- 令 n 为房屋数,建立 n+1 个状态,初始化 dp[0]=0、dp[1]=nums[0]。
- 令前缀长度 i 从 2 遍历到 n,当前房屋金额为 nums[i−1]。
- 比较不偷当前的 dp[i−1],与偷当前的 dp[i−2]+nums[i−1]。
- 把较大金额写入 dp[i],保留完整表供后续前缀使用。
- 返回整个前缀的最优金额 dp[n]。
代码(Python)
查看完整 Python 代码前缀 DP 表 · 与上方图解对应
dp 长度为 n+1,前缀长度与原数组下标相差 1。保留表方便追溯选择,也是理解滚动优化之前最直接的写法。
from typing import List
class Solution:
def rob(self, nums: List[int]) -> int:
n = len(nums)
dp = [0] * (n + 1)
dp[1] = nums[0]
for i in range(2, n + 1):
dp[i] = max(dp[i - 1], dp[i - 2] + nums[i - 1])
return dp[n]运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert Solution().rob([2,7,9,3,1]) == 12
assert Solution().rob([1,2,3,1]) == 4
assert Solution().rob([0]) == 0
assert Solution().rob([2,1,1,2]) == 4
复杂度
- 时间
- O(n),n 是房屋数量,每间房进行一次取舍。
- 空间
- 辅助空间 O(n),用于 n+1 个前缀状态;返回单个金额。
易错点与边界
- dp[i] 不要求选择最后一间,不能把它与“以 i 结尾”的状态混用。
- 选择当前房屋必须接 dp[i−2],接 dp[i−1] 可能偷到相邻房。
- 不要固定偷奇数位或偶数位,[2,1,1,2] 的最优选择是两端。
- 本题至少一间且金额非负,单间 0 合法,答案可以为 0。
主解之后,再比较与迁移。
其他解法 1
两个状态滚动时间 O(n),n 为房屋数。
每次转移只读相邻两个历史前缀,可以用 prev1 保存已处理前缀的最优金额,prev2 保存再少一间的前缀最优。两者都从 0 开始,统一处理首间。读当前金额 x 时,新前缀最优为 max(prev1,prev2+x);同时赋值让新 prev2 等于旧 prev1,新 prev1 等于刚算出的结果。这个写法把辅助空间降为常数,但两个金额本身不记录整条选择路径。
- 初始化 prev2=prev1=0,对应尚未取得任何金额的边界。
- 依次读取每间房屋的金额 x。
- 用两个旧值计算 max(prev1,prev2+x),比较不偷与偷当前两条分支。
- 同时执行 prev2,prev1=prev1,max(prev1,prev2+x),保证右侧都读取旧状态。
- 返回处理完全部房屋后的 prev1。
from typing import List
class Solution:
def rob(self, nums: List[int]) -> int:
prev2 = prev1 = 0
for x in nums:
prev2, prev1 = prev1, max(prev1, prev2 + x)
return prev1复杂度
- 时间
- O(n),n 为房屋数。
- 空间
- 辅助空间 O(1),只保留两个历史最优值。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert Solution().rob([2,7,9,3,1]) == 12
assert Solution().rob([1,2,3,1]) == 4
assert Solution().rob([0]) == 0
assert Solution().rob([2,1,1,2]) == 4
预测练习
第 4 间金额为 3 时,为什么不把前 3 间最优的 11 直接加 3 得到 14?
一点提示
金额 11 的方案是第 1、3 间,检查它与第 4 间是否相邻。
答案与理由
11 包含第 3 间的 9,再偷第 4 间会相邻。合法的偷分支应为 dp[2]+3=7+3=10,与不偷分支 dp[3]=11 比较后保持 11。
面试追问与解法取舍
若要求具体偷哪些房,保留 dp 后从末尾回溯:若 dp[i]=dp[i−1] 可以跳过当前房;否则选下标 i−1 并回到 i−2。平局时可能存在多组最优方案,输出任意一组即可;仅靠滚动的两个数无法直接完成这个回溯。
模板迁移 / 相似题
试着不看代码,复述这三点
- 状态先定义成“前 i 间最优”,才能直接继承不偷分支。
- 偷当前必须读 i−2;仅比较相邻金额不是完整决策。
- 压缩为两个变量时仍需保存两个旧前缀,不能让更新后的值污染转移。
279. 完全平方数 中等
一句话题意
把正整数 n 表示为若干完全平方数之和,同一个平方数可重复使用,求最少项数。
n=6,平方数候选为 [1,4] → 3;例如 6=4+1+1。
生活化理解
像只有 1、4、9、16…这些面额的硬币,凑出金额 n,要求硬币张数最少;每拿一张平方数硬币,就走到更小的剩余金额。
金额递增动态规划
用目标数拆分最后一个平方数,状态和转移都与最少项数直接对应,容易迁移到完全背包。核心思路
定义 dp[x] 为恰好凑出 x 所需的最少平方数个数。凑出 0 不需要任何数,所以 dp[0]=0;其他位置先设为 n+1。因为 1 本身是平方数,x 个 1 总能凑出 x,故 n+1 大于任何目标不超过 n 的最优答案,是一个等待改进的上界。
用 squares 按升序保存不超过 n 的平方数。对当前目标 x,枚举最后取的平方数 q:前面必须恰好凑出 x−q,候选个数为 dp[x−q]+1。逐项执行 dp[x]=min(dp[x],dp[x−q]+1);同一格在内层循环中只是已比较候选的最小值,全部合法 q 比较完才确定最终答案。
x 从 1 递增到 n,保证每个来源 x−q 都更小且已经最优。平方数可以重复选择,来源方案中也允许含 q;平方数有序,遇到 q>x 即可停止本格枚举。不能见到某个候选就提前定案:图中 x=4 先由 q=1 得到 4,再由 q=4 改进为 1。全部目标完成后返回 dp[n]。
处理目标 x 时,所有较小目标的 dp 已最优。枚举最后一个平方数 q 后,剩余 x−q 的最优解再加一个 q 就覆盖这一类方案;全部合法 q 取最小即可得到 dp[x]。
图解
| 目标 x | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| dp[x] | 0 | · | · | · | · | · | · |
- n
- 6
- squares
- [1, 4]
- dp(源码)
- [0, 7, 7, 7, 7, 7, 7]
| 目标 x | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| dp[x] | 0 | 1 | · | · | · | · | · |
| 最后一个平方 q | 依赖来源 | 候选个数 | 比较后 dp[x] |
|---|---|---|---|
| 1 | dp[0]=0 | 0+1=1 | min(7,1)=1 |
| 4 | 不适用 | q>x,遇到此项就 break | — |
- x / q
- 1 / 1
- 来源 dp[0]
- 0
- dp[x] 写入前 → 后
- 7 → 1
- dp(源码)
- [0, 1, 7, 7, 7, 7, 7]
| 目标 x | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| dp[x] | 0 | 1 | 2 | · | · | · | · |
| 最后一个平方 q | 依赖来源 | 候选个数 | 比较后 dp[x] |
|---|---|---|---|
| 1 | dp[1]=1 | 1+1=2 | min(7,2)=2 |
| 4 | 不适用 | q>x,遇到此项就 break | — |
- x / q
- 2 / 1
- 来源 dp[1]
- 1
- dp[x] 写入前 → 后
- 7 → 2
- dp(源码)
- [0, 1, 2, 7, 7, 7, 7]
| 目标 x | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| dp[x] | 0 | 1 | 2 | 3 | · | · | · |
| 最后一个平方 q | 依赖来源 | 候选个数 | 比较后 dp[x] |
|---|---|---|---|
| 1 | dp[2]=2 | 2+1=3 | min(7,3)=3 |
| 4 | 不适用 | q>x,遇到此项就 break | — |
- x / q
- 3 / 1
- 来源 dp[2]
- 2
- dp[x] 写入前 → 后
- 7 → 3
- dp(源码)
- [0, 1, 2, 3, 7, 7, 7]
| 目标 x | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| dp[x] | 0 | 1 | 2 | 3 | 4 | · | · |
| 最后一个平方 q | 依赖来源 | 候选个数 | 比较后 dp[x] |
|---|---|---|---|
| 1 | dp[3]=3 | 3+1=4 | min(7,4)=4 |
| 4 | dp[0] | · 尚未枚举 | · |
- x / q
- 4 / 1
- 来源 dp[3]
- 3
- dp[x] 写入前 → 后
- 7 → 4
- dp(源码)
- [0, 1, 2, 3, 4, 7, 7]
| 目标 x | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| dp[x] | 0 | 1 | 2 | 3 | 1 | · | · |
| 最后一个平方 q | 依赖来源 | 候选个数 | 比较后 dp[x] |
|---|---|---|---|
| 1 | dp[3]=3 | 3+1=4 | min(7,4)=4 |
| 4 | dp[0]=0 | 0+1=1 | min(4,1)=1 |
- x / q
- 4 / 4
- 来源 dp[0]
- 0
- dp[x] 写入前 → 后
- 4 → 1
- dp(源码)
- [0, 1, 2, 3, 1, 7, 7]
| 目标 x | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| dp[x] | 0 | 1 | 2 | 3 | 1 | 2 | · |
| 最后一个平方 q | 依赖来源 | 候选个数 | 比较后 dp[x] |
|---|---|---|---|
| 1 | dp[4]=1 | 1+1=2 | min(7,2)=2 |
| 4 | dp[1] | · 尚未枚举 | · |
- x / q
- 5 / 1
- 来源 dp[4]
- 1
- dp[x] 写入前 → 后
- 7 → 2
- dp(源码)
- [0, 1, 2, 3, 1, 2, 7]
| 目标 x | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| dp[x] | 0 | 1 | 2 | 3 | 1 | 2 | · |
| 最后一个平方 q | 依赖来源 | 候选个数 | 比较后 dp[x] |
|---|---|---|---|
| 1 | dp[4]=1 | 1+1=2 | min(7,2)=2 |
| 4 | dp[1]=1 | 1+1=2 | min(2,2)=2 |
- x / q
- 5 / 4
- 来源 dp[1]
- 1
- dp[x] 写入前 → 后
- 2 → 2
- dp(源码)
- [0, 1, 2, 3, 1, 2, 7]
| 目标 x | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| dp[x] | 0 | 1 | 2 | 3 | 1 | 2 | 3 |
| 最后一个平方 q | 依赖来源 | 候选个数 | 比较后 dp[x] |
|---|---|---|---|
| 1 | dp[5]=2 | 2+1=3 | min(7,3)=3 |
| 4 | dp[2] | · 尚未枚举 | · |
- x / q
- 6 / 1
- 来源 dp[5]
- 2
- dp[x] 写入前 → 后
- 7 → 3
- dp(源码)
- [0, 1, 2, 3, 1, 2, 3]
| 目标 x | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| dp[x] | 0 | 1 | 2 | 3 | 1 | 2 | 3 |
| 最后一个平方 q | 依赖来源 | 候选个数 | 比较后 dp[x] |
|---|---|---|---|
| 1 | dp[5]=2 | 2+1=3 | min(7,3)=3 |
| 4 | dp[2]=2 | 2+1=3 | min(3,3)=3 |
- x / q
- 6 / 4
- 来源 dp[2]
- 2
- dp[x] 写入前 → 后
- 3 → 3
- dp(源码)
- [0, 1, 2, 3, 1, 2, 3]
| 目标 x | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| dp[x] | 0 | 1 | 2 | 3 | 1 | 2 | 3 |
| 最后一个平方 q | 依赖来源 | 候选个数 | 比较后 dp[x] |
|---|---|---|---|
| 1 | dp[5]=2 | 2+1=3 | min(7,3)=3 |
| 4 | dp[2]=2 | 2+1=3 | min(3,3)=3 |
- dp
- [0, 1, 2, 3, 1, 2, 3]
- 返回值
- 3
选择步骤 11 步
变量与图例
同一紫格会被多次尝试:下表列出每个平方数的来源、加 1 的候选和当前最小值。
- squares
- 不超过 n 的平方数,按升序为 [1,4]。
- x
- 当前目标数,从 1 递增到 n。
- q
- 本次假设的最后一个平方数;超过 x 后停止枚举。
- dp[x]
- 当前尝试过的 q 中最少个数;本格枚举结束后才是最终最优。
- n+1
- 源码的初始上界,本例为 7;不同于合法最优值。
算法步骤
- 使用 isqrt(n) 生成所有不超过 n 的平方数 squares,按从小到大排列。
- 建立 dp,令 dp[0]=0,其余位置先为 n+1。
- 令目标 x 从 1 遍历到 n,再逐个枚举 squares 中的 q。
- 若 q>x,结束本格的平方数枚举;否则用 dp[x−q]+1 改进 dp[x]。
- 本格全部合法候选比较完后处理下一目标,最后返回 dp[n]。
代码(Python)
查看完整 Python 代码金额递增动态规划 · 与上方图解对应
squares 保存可重复选择的面额;dp[x] 记录最少项数。平方数列表有序,q>x 后直接结束内层循环。
from math import isqrt
class Solution:
def numSquares(self, n: int) -> int:
squares = [i * i for i in range(1, isqrt(n) + 1)]
dp = [0] + [n + 1] * n
for x in range(1, n + 1):
for q in squares:
if q > x:
break
dp[x] = min(dp[x], dp[x - q] + 1)
return dp[n]运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert Solution().numSquares(12) == 3
assert Solution().numSquares(13) == 2
assert Solution().numSquares(1) == 1
assert Solution().numSquares(16) == 1
assert Solution().numSquares(7) == 4
复杂度
- 时间
- O(n√n),n 个金额各枚举至多 ⌊√n⌋ 个平方数。
- 空间
- 辅助空间 O(n),包括 DP 表;平方数列表 O(√n)。
易错点与边界
- 平方数可以重复使用,不能把每个 q 当成只能选一次的物品。
- dp[0]=0 表示凑成 0 不需要任何项;它不是 1。
- 贪心先取最大平方数对 12 失败。
主解之后,再比较与迁移。
其他解法 1
余数图的广度优先搜索时间 最坏 O(n√n),至多 n 个正余数节点,每个尝试 O(√n) 条边;提前找到 0 时可少访问一些状态。
把剩余数值当作图节点,从 n 出发,每条边减去一个合法平方数,边的代价都为 1。queue 保存待扩展余数,seen 合并到达同一余数的不同路径。每轮先增加 depth,再固定本层队列长度扩展;由本层产生的下一余数使用了 depth 个平方数。BFS 按使用个数从少到多搜索,第一次生成 0 时返回的 depth 必然最小。适合把本题解释为无权图最短路,可能在访问完整状态空间前结束。
- 生成升序 squares,初始化 queue=[n]、seen={n}、depth=0。
- 每轮令 depth 加 1,并以本轮开始时的 len(queue) 固定待扩展节点数。
- 弹出 remaining,依次枚举不大于它的平方数 q,计算 nxt=remaining−q。
- 若 nxt 为 0,立即返回 depth;若尚未访问,在入队前加入 seen,再追加到 queue。
- 继续逐层扩展;代码在队列耗尽后返回 −1,但本题正整数 n 总可由若干个 1 构成。
from collections import deque
from math import isqrt
class Solution:
def numSquares(self, n: int) -> int:
squares = [i * i for i in range(1, isqrt(n) + 1)]
queue = deque([n])
seen = {n}
depth = 0
while queue:
depth += 1
for _ in range(len(queue)):
remaining = queue.popleft()
for q in squares:
if q > remaining:
break
nxt = remaining - q
if nxt == 0:
return depth
if nxt not in seen:
seen.add(nxt)
queue.append(nxt)
return -1复杂度
- 时间
- 最坏 O(n√n),至多 n 个正余数节点,每个尝试 O(√n) 条边;提前找到 0 时可少访问一些状态。
- 空间
- 辅助空间 O(n),用于队列和已访问集合,另有 O(√n) 平方数。
这个方法的边界
- BFS 按层统计项数,不是每弹出一个节点就增加 depth。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert Solution().numSquares(12) == 3
assert Solution().numSquares(13) == 2
assert Solution().numSquares(1) == 1
assert Solution().numSquares(16) == 1
assert Solution().numSquares(7) == 4
预测练习
x=4 先尝试 q=1 得到 4 后,为什么不能立即结束?
一点提示
查看另一个合法最后一步 q=4 的来源。
答案与理由
q=1 只能给出一个候选 4;再尝试 q=4 时,dp[0]+1=1,会把当前值从 4 改成 1。内层循环必须比较所有不超过 x 的平方数。
面试追问与解法取舍
若还要给出平方数分解,可在 dp[x] 改善时记住选中的 q,之后从 n 反复减 q 回溯到 0。DP 会算出全部较小金额的答案,BFS 更适合只求一个终点的最短项数;二者都无需递归,也没有深递归栈问题。
模板迁移 / 相似题
试着不看代码,复述这三点
- 把“最后一个平方数”作为决策,每个来源读 dp[x−q]。
- 外层目标递增保障来源已知;同一平方数可重复使用。
- 本题取最少个数,不累加方案数;比较中的值要与最终值区分。
322. 零钱兑换 中等
一句话题意
硬币面额均为正整数、每种数量无限,求恰好凑出 amount 的最少枚数;无法凑出返回 −1。
coins=[1,3,4],amount=6 → 2(3+3);最大面值优先会得到 4+1+1,共 3 枚。
生活化理解
像制作一张精确金额的购物券,只能使用给定面额:dp[a] 是兑到 a 元的最少张数,选最后一张 c 元券后,前面必须恰好兑到 a−c 元。
金额外层:枚举最后一枚
按金额逐格枚举最后一枚硬币,最优子结构直接可见,容易解释为何不能贪心选择最大面额。核心思路
定义 dp[a] 为恰好凑出金额 a 的最少硬币枚数。dp[0]=0,其余格先设为 best=amount+1。每枚硬币至少值 1,可行解的枚数最多为 amount,所以 best 用来表示还未找到解,不是一个实际答案。
外层金额 a 从 1 到 amount 递增,内层遍历每个面额 c。当 c≤a 时,假设最后一枚是 c,前面凑出 a−c 的最少枚数就是 dp[a−c],候选为它再加 1。执行 dp[a]=min(dp[a],dp[a−c]+1)。较小金额已求出最优值或确认不可达;不可达来源再加 1 仍大于 best,不会制造一个可行解。
任何硬币组合都有最后一枚,枚举全部合法 c 并取最小就覆盖了所有方案。面额无需排序,大于当前金额的项跳过即可;同一组合的不同顺序即使被考虑多次,也不会影响最小值。图中金额 6 的值先为 3,再由最后一枚 3 改进为 2。最终若 dp[amount]==best 就返回 −1,否则返回该格;amount=0 自然得到 0。
金额 a 开始时,所有更小金额都已求出最少枚数。任一组合必有某个最后币种 c,因此 dp[a−c]+1 枚举并覆盖全部方案;取最小后得到 dp[a],无须作贪心选择。
图解
| 金额 a | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| dp[a] | 0 | · | · | · | · | · | · |
- coins
- [1, 3, 4]
- amount
- 6
- best
- 7
- dp(源码)
- [0, 7, 7, 7, 7, 7, 7]
| 金额 a | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| dp[a] | 0 | 1 | · | · | · | · | · |
| 最后一枚 c | 已知剩余金额 | 候选总枚数 | 本格旧值 → 新值 |
|---|---|---|---|
| 1 | dp[0]=0 | 0+1=1 | 7 → 1 |
| 3 | c>a | 不能作最后一枚 | 跳过 |
| 4 | c>a | 不能作最后一枚 | 跳过 |
- a / c
- 1 / 1
- dp[0]
- 0
- dp[a]
- 1
- 待比较的合法币种
- []
- dp(源码)
- [0, 1, 7, 7, 7, 7, 7]
| 金额 a | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| dp[a] | 0 | 1 | 2 | · | · | · | · |
| 最后一枚 c | 已知剩余金额 | 候选总枚数 | 本格旧值 → 新值 |
|---|---|---|---|
| 1 | dp[1]=1 | 1+1=2 | 7 → 2 |
| 3 | c>a | 不能作最后一枚 | 跳过 |
| 4 | c>a | 不能作最后一枚 | 跳过 |
- a / c
- 2 / 1
- dp[1]
- 1
- dp[a]
- 2
- 待比较的合法币种
- []
- dp(源码)
- [0, 1, 2, 7, 7, 7, 7]
| 金额 a | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| dp[a] | 0 | 1 | 2 | 3 | · | · | · |
| 最后一枚 c | 已知剩余金额 | 候选总枚数 | 本格旧值 → 新值 |
|---|---|---|---|
| 1 | dp[2]=2 | 2+1=3 | 7 → 3 |
| 3 | dp[0] | · 尚未比较 | · |
| 4 | c>a | 不能作最后一枚 | 跳过 |
- a / c
- 3 / 1
- dp[2]
- 2
- dp[a]
- 3
- 待比较的合法币种
- [3]
- dp(源码)
- [0, 1, 2, 3, 7, 7, 7]
| 金额 a | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| dp[a] | 0 | 1 | 2 | 1 | · | · | · |
| 最后一枚 c | 已知剩余金额 | 候选总枚数 | 本格旧值 → 新值 |
|---|---|---|---|
| 1 | dp[2]=2 | 2+1=3 | 7 → 3 |
| 3 | dp[0]=0 | 0+1=1 | 3 → 1 |
| 4 | c>a | 不能作最后一枚 | 跳过 |
- a / c
- 3 / 3
- dp[0]
- 0
- dp[a]
- 1
- 待比较的合法币种
- []
- dp(源码)
- [0, 1, 2, 1, 7, 7, 7]
| 金额 a | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| dp[a] | 0 | 1 | 2 | 1 | 2 | · | · |
| 最后一枚 c | 已知剩余金额 | 候选总枚数 | 本格旧值 → 新值 |
|---|---|---|---|
| 1 | dp[3]=1 | 1+1=2 | 7 → 2 |
| 3 | dp[1] | · 尚未比较 | · |
| 4 | dp[0] | · 尚未比较 | · |
- a / c
- 4 / 1
- dp[3]
- 1
- dp[a]
- 2
- 待比较的合法币种
- [3, 4]
- dp(源码)
- [0, 1, 2, 1, 2, 7, 7]
| 金额 a | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| dp[a] | 0 | 1 | 2 | 1 | 2 | · | · |
| 最后一枚 c | 已知剩余金额 | 候选总枚数 | 本格旧值 → 新值 |
|---|---|---|---|
| 1 | dp[3]=1 | 1+1=2 | 7 → 2 |
| 3 | dp[1]=1 | 1+1=2 | 2 → 2 |
| 4 | dp[0] | · 尚未比较 | · |
- a / c
- 4 / 3
- dp[1]
- 1
- dp[a]
- 2
- 待比较的合法币种
- [4]
- dp(源码)
- [0, 1, 2, 1, 2, 7, 7]
| 金额 a | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| dp[a] | 0 | 1 | 2 | 1 | 1 | · | · |
| 最后一枚 c | 已知剩余金额 | 候选总枚数 | 本格旧值 → 新值 |
|---|---|---|---|
| 1 | dp[3]=1 | 1+1=2 | 7 → 2 |
| 3 | dp[1]=1 | 1+1=2 | 2 → 2 |
| 4 | dp[0]=0 | 0+1=1 | 2 → 1 |
- a / c
- 4 / 4
- dp[0]
- 0
- dp[a]
- 1
- 待比较的合法币种
- []
- dp(源码)
- [0, 1, 2, 1, 1, 7, 7]
| 金额 a | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| dp[a] | 0 | 1 | 2 | 1 | 1 | 2 | · |
| 最后一枚 c | 已知剩余金额 | 候选总枚数 | 本格旧值 → 新值 |
|---|---|---|---|
| 1 | dp[4]=1 | 1+1=2 | 7 → 2 |
| 3 | dp[2] | · 尚未比较 | · |
| 4 | dp[1] | · 尚未比较 | · |
- a / c
- 5 / 1
- dp[4]
- 1
- dp[a]
- 2
- 待比较的合法币种
- [3, 4]
- dp(源码)
- [0, 1, 2, 1, 1, 2, 7]
| 金额 a | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| dp[a] | 0 | 1 | 2 | 1 | 1 | 2 | · |
| 最后一枚 c | 已知剩余金额 | 候选总枚数 | 本格旧值 → 新值 |
|---|---|---|---|
| 1 | dp[4]=1 | 1+1=2 | 7 → 2 |
| 3 | dp[2]=2 | 2+1=3 | 2 → 2 |
| 4 | dp[1] | · 尚未比较 | · |
- a / c
- 5 / 3
- dp[2]
- 2
- dp[a]
- 2
- 待比较的合法币种
- [4]
- dp(源码)
- [0, 1, 2, 1, 1, 2, 7]
| 金额 a | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| dp[a] | 0 | 1 | 2 | 1 | 1 | 2 | · |
| 最后一枚 c | 已知剩余金额 | 候选总枚数 | 本格旧值 → 新值 |
|---|---|---|---|
| 1 | dp[4]=1 | 1+1=2 | 7 → 2 |
| 3 | dp[2]=2 | 2+1=3 | 2 → 2 |
| 4 | dp[1]=1 | 1+1=2 | 2 → 2 |
- a / c
- 5 / 4
- dp[1]
- 1
- dp[a]
- 2
- 待比较的合法币种
- []
- dp(源码)
- [0, 1, 2, 1, 1, 2, 7]
| 金额 a | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| dp[a] | 0 | 1 | 2 | 1 | 1 | 2 | 3 |
| 最后一枚 c | 已知剩余金额 | 候选总枚数 | 本格旧值 → 新值 |
|---|---|---|---|
| 1 | dp[5]=2 | 2+1=3 | 7 → 3 |
| 3 | dp[3] | · 尚未比较 | · |
| 4 | dp[2] | · 尚未比较 | · |
- a / c
- 6 / 1
- dp[5]
- 2
- dp[a]
- 3
- 待比较的合法币种
- [3, 4]
- dp(源码)
- [0, 1, 2, 1, 1, 2, 3]
| 金额 a | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| dp[a] | 0 | 1 | 2 | 1 | 1 | 2 | 2 |
| 最后一枚 c | 已知剩余金额 | 候选总枚数 | 本格旧值 → 新值 |
|---|---|---|---|
| 1 | dp[5]=2 | 2+1=3 | 7 → 3 |
| 3 | dp[3]=1 | 1+1=2 | 3 → 2 |
| 4 | dp[2] | · 尚未比较 | · |
- a / c
- 6 / 3
- dp[3]
- 1
- dp[a]
- 2
- 待比较的合法币种
- [4]
- dp(源码)
- [0, 1, 2, 1, 1, 2, 2]
| 金额 a | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| dp[a] | 0 | 1 | 2 | 1 | 1 | 2 | 2 |
| 最后一枚 c | 已知剩余金额 | 候选总枚数 | 本格旧值 → 新值 |
|---|---|---|---|
| 1 | dp[5]=2 | 2+1=3 | 7 → 3 |
| 3 | dp[3]=1 | 1+1=2 | 3 → 2 |
| 4 | dp[2]=2 | 2+1=3 | 2 → 2 |
- a / c
- 6 / 4
- dp[2]
- 2
- dp[a]
- 2
- 待比较的合法币种
- []
- dp(源码)
- [0, 1, 2, 1, 1, 2, 2]
| 金额 a | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| dp[a] | 0 | 1 | 2 | 1 | 1 | 2 | 2 |
| 最后一枚 c | 已知剩余金额 | 候选总枚数 | 本格旧值 → 新值 |
|---|---|---|---|
| 1 | dp[5]=2 | 2+1=3 | 7 → 3 |
| 3 | dp[3]=1 | 1+1=2 | 3 → 2 |
| 4 | dp[2]=2 | 2+1=3 | 2 → 2 |
- dp
- [0, 1, 2, 1, 1, 2, 2]
- dp[amount]
- 2
- best
- 7
- 返回值
- 2
选择步骤 15 步
变量与图例
盯住金额 6:先按最后一枚 1 算出 3,再被最后一枚 3 改进为 2,最后一枚 4 无法再改善。
- coins
- 可用面值列表,每种可以重复使用;本例按 1、3、4 枚举。
- a / c
- 当前要凑的金额 / 本次假设的最后一枚面值。
- dp[a]
- 本格已尝试币种中的最小枚数;内层循环结束后为最终值。
- best
- 源码的“还未找到解”初值 amount+1,本例为 7。
算法步骤
- 设 best=amount+1,建立 amount+1 个状态,只有 dp[0] 初始化为 0。
- 令金额 a 从 1 到 amount 递增,逐个枚举 coins 中的面额 c。
- 仅在 c≤a 时,把最后一枚 c 的候选 dp[a−c]+1 与当前 dp[a] 取最小。
- 当前金额的所有硬币都比较完后,再计算下一个金额。
- 若目标格仍等于 best,返回 −1;否则返回 dp[amount]。
代码(Python)
查看完整 Python 代码金额外层:枚举最后一枚 · 与上方图解对应
best 是不可达哨兵,dp[a−c]+1 是选择最后一枚 c 的候选。内层硬币无需排序,超过金额时跳过即可。
from typing import List
class Solution:
def coinChange(self, coins: List[int], amount: int) -> int:
best = amount + 1
dp = [best] * (amount + 1)
dp[0] = 0
for a in range(1, amount + 1):
for c in coins:
if c <= a:
dp[a] = min(dp[a], dp[a - c] + 1)
return -1 if dp[amount] == best else dp[amount]运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert Solution().coinChange([1,2,5], 11) == 3
assert Solution().coinChange([2], 3) == -1
assert Solution().coinChange([1], 0) == 0
assert Solution().coinChange([1,3,4], 6) == 2
复杂度
- 时间
- O(A·m),A=amount,m 为硬币种数。
- 空间
- 辅助空间 O(A),保存每个金额的最少枚数。
易错点与边界
- 用 0 初始化所有状态会让不可达金额看起来免费可达。
- 普通面额不保证贪心正确,[1,3,4] 凑 6 就是反例。
- amount=0 的最少枚数为 0;没有刚好匹配的组合时返回 −1。
主解之后,再比较与迁移。
其他解法 1
面额外层:完全背包正序更新时间 O(A·m),A 是目标金额,m 是面额数量;大于 A 的面额不进入内层循环。
按已处理面额定义状态:一轮面额 c 结束后,dp[a] 是仅使用这些面额凑出 a 的最少枚数。更新 a 前的旧 dp[a] 不使用当前面额 c;正序扫描让较小的 dp[a−c] 已可包含本轮 c,再加一枚 c 就允许重复使用。金额若改为倒序,就会限制每种面额至多一枚,改变题意。这一写法适合比较完全背包与 0/1 背包的循环方向。
- 设 unreachable=amount+1,初始化 dp[0]=0,其余金额为 unreachable。
- 外层依次读取面额 c。
- 内层金额 a 从 c 正序增加到 amount,使 dp[a−c] 可以包含本轮面额。
- 执行 dp[a]=min(dp[a],dp[a−c]+1),保留不使用 c 或再使用一枚 c 的较优结果。
- 全部面额处理后,按目标格是否仍为 unreachable 返回 −1 或最少枚数。
from typing import List
class Solution:
def coinChange(self, coins: List[int], amount: int) -> int:
unreachable = amount + 1
dp = [0] + [unreachable] * amount
for c in coins:
for a in range(c, amount + 1):
dp[a] = min(dp[a], dp[a - c] + 1)
return -1 if dp[amount] == unreachable else dp[amount]复杂度
- 时间
- O(A·m),A 是目标金额,m 是面额数量;大于 A 的面额不进入内层循环。
- 空间
- 辅助空间 O(A),只保留一维金额表。
这个方法的边界
- 面额外层时金额倒序会禁止重复使用该硬币,例 [2],4 就会算错。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert Solution().coinChange([1,2,5], 11) == 3
assert Solution().coinChange([2], 3) == -1
assert Solution().coinChange([1], 0) == 0
assert Solution().coinChange([1,3,4], 6) == 2
预测练习
金额 6 已由最后一枚 3 得到 2;接着尝试最后一枚 4,为什么还要保留 2?
一点提示
余下金额是 2,读取 dp[2]。
答案与理由
dp[2]=2,再加一枚 4 共 3 枚,min(2,3) 仍为 2。硬币面值更大并不代表完整方案更短,这正是 [1,3,4] / 6 对贪心的反例。
面试追问与解法取舍
若问题改为“有多少种组合”,外层硬币、内层金额正序会按面额阶段去重;外层金额、内层硬币则通常统计有序排列。当前题对候选取 min,所以两个循环顺序均可求最少枚数,不能把这个结论直接迁移到计数题。
模板迁移 / 相似题
试着不看代码,复述这三点
- 枚举最后一枚,把最少总枚数拆成已知来源再加 1。
- 无限硬币允许来源方案包含相同币种;与 0/1 背包的物品限制不同。
- 比较全部合法面值再定本格;哨兵未变才表示不可达。
139. 单词拆分 中等
一句话题意
判断字符串 s 是否能被完整切分成字典中的若干非空单词,同一个单词可以重复使用。
s="catsand",wordDict=["cat","cats","and"] → True;完整拆分为 "cats" + "and"。
生活化理解
像给一条没有空格的字幕重新加断句:dp[i] 表示前 i 个字符已经能被词典完整覆盖,只有前半段能覆盖且最后一段真是单词,才能认可新断点。
前缀 DP + 字典集合
前缀可达性与单词切口一一对应,集合查询写法短且容易复现,适合先讲清拆分的存在性。核心思路
定义 dp[i] 为半开前缀 s[:i] 能否完整拆成字典单词,下标 i 是前缀长度与右边界。dp[0]=True 表示空前缀无需单词。枚举最后一个单词的起点 j:只有 dp[j] 为真,且 s[j:i] 在字典中,才能令 dp[i]=True。
用 words 保存字典集合,longest 保存最长词长。i 从 1 向右递增,只检查 j=max(0,i−longest)…i−1,因为更长后缀不可能是单词。所有 j 都小于 i,依赖的前缀已经判定;and 的短路使不可达起点不产生切片,也不查询集合。找到一个合法切口便停止当前 i 的搜索,因为只问是否存在拆法。
某个短前缀成功或失败都不能直接决定整串结果。图中 cat 和 cats 都可达,但结尾只能接成 cats 与 and;需要读到 dp[len(s)] 才知道整串能否拆分。Python 的 s[j:i] 会创建新字符串,集合查询还要计算新串哈希,因此一次判词的成本随片段长度增长,不能把整段表达式视为常数时间。
判定 dp[i] 时,每个更短前缀 dp[j] 都已确定。dp[j] 为真且 s[j:i] 是单词,等价于在合法拆分后接一个合法单词;枚举所有可能最后单词的起点即可覆盖全部拆分。
图解
| 前缀长度 k | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|---|
| 前缀 s[:k] | 空串 | c | ca | cat | cats | catsa | catsan | catsand |
| dp[k] | T | · | · | · | · | · | · | · |
- s
- catsand
- words(集合)
- ['and', 'cat', 'cats']
- longest
- 4
- dp(源码)
- [True, False, False, False, False, False, False, False]
| 前缀长度 k | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|---|
| 前缀 s[:k] | 空串 | c | ca | cat | cats | catsa | catsan | catsand |
| dp[k] | T | F | · | · | · | · | · | · |
| 依次尝试 j | dp[j] | 候选 s[j:i](图示) | 条件判定 |
|---|---|---|---|
| 0 | T | c | 前缀可达,但候选不在字典 |
- i / 最后检查的 j
- 1 / 0
- dp[1]
- F
- dp(源码)
- [True, False, False, False, False, False, False, False]
| 前缀长度 k | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|---|
| 前缀 s[:k] | 空串 | c | ca | cat | cats | catsa | catsan | catsand |
| dp[k] | T | F | F | · | · | · | · | · |
| 依次尝试 j | dp[j] | 候选 s[j:i](图示) | 条件判定 |
|---|---|---|---|
| 0 | T | ca | 前缀可达,但候选不在字典 |
| 1 | F | a | 前缀不可达,短路:不切片、不查集合 |
- i / 最后检查的 j
- 2 / 1
- dp[2]
- F
- dp(源码)
- [True, False, False, False, False, False, False, False]
| 前缀长度 k | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|---|
| 前缀 s[:k] | 空串 | c | ca | cat | cats | catsa | catsan | catsand |
| dp[k] | T | F | F | T | · | · | · | · |
| 依次尝试 j | dp[j] | 候选 s[j:i](图示) | 条件判定 |
|---|---|---|---|
| 0 | T | cat | 在字典中,接上即可 |
- i / 最后检查的 j
- 3 / 0
- dp[3]
- T
- dp(源码)
- [True, False, False, True, False, False, False, False]
| 前缀长度 k | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|---|
| 前缀 s[:k] | 空串 | c | ca | cat | cats | catsa | catsan | catsand |
| dp[k] | T | F | F | T | T | · | · | · |
| 依次尝试 j | dp[j] | 候选 s[j:i](图示) | 条件判定 |
|---|---|---|---|
| 0 | T | cats | 在字典中,接上即可 |
- i / 最后检查的 j
- 4 / 0
- dp[4]
- T
- dp(源码)
- [True, False, False, True, True, False, False, False]
| 前缀长度 k | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|---|
| 前缀 s[:k] | 空串 | c | ca | cat | cats | catsa | catsan | catsand |
| dp[k] | T | F | F | T | T | F | · | · |
| 依次尝试 j | dp[j] | 候选 s[j:i](图示) | 条件判定 |
|---|---|---|---|
| 1 | F | atsa | 前缀不可达,短路:不切片、不查集合 |
| 2 | F | tsa | 前缀不可达,短路:不切片、不查集合 |
| 3 | T | sa | 前缀可达,但候选不在字典 |
| 4 | T | a | 前缀可达,但候选不在字典 |
- i / 最后检查的 j
- 5 / 4
- dp[5]
- F
- dp(源码)
- [True, False, False, True, True, False, False, False]
| 前缀长度 k | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|---|
| 前缀 s[:k] | 空串 | c | ca | cat | cats | catsa | catsan | catsand |
| dp[k] | T | F | F | T | T | F | F | · |
| 依次尝试 j | dp[j] | 候选 s[j:i](图示) | 条件判定 |
|---|---|---|---|
| 2 | F | tsan | 前缀不可达,短路:不切片、不查集合 |
| 3 | T | san | 前缀可达,但候选不在字典 |
| 4 | T | an | 前缀可达,但候选不在字典 |
| 5 | F | n | 前缀不可达,短路:不切片、不查集合 |
- i / 最后检查的 j
- 6 / 5
- dp[6]
- F
- dp(源码)
- [True, False, False, True, True, False, False, False]
| 前缀长度 k | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|---|
| 前缀 s[:k] | 空串 | c | ca | cat | cats | catsa | catsan | catsand |
| dp[k] | T | F | F | T | T | F | F | · |
| 依次尝试 j | dp[j] | 候选 s[j:i](图示) | 条件判定 |
|---|---|---|---|
| 3 | T | sand | 前缀可达,但候选不在字典 |
- i / j
- 7 / 3
- dp[j]
- T
- s[j:i]
- sand
- dp[i](源码)
- F
| 前缀长度 k | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|---|
| 前缀 s[:k] | 空串 | c | ca | cat | cats | catsa | catsan | catsand |
| dp[k] | T | F | F | T | T | F | F | T |
| 依次尝试 j | dp[j] | 候选 s[j:i](图示) | 条件判定 |
|---|---|---|---|
| 3 | T | sand | 前缀可达,但候选不在字典 |
| 4 | T | and | 在字典中,接上即可 |
- i / 最后检查的 j
- 7 / 4
- dp[7]
- T
- dp(源码)
- [True, False, False, True, True, False, False, True]
| 前缀长度 k | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|---|
| 前缀 s[:k] | 空串 | c | ca | cat | cats | catsa | catsan | catsand |
| dp[k] | T | F | F | T | T | F | F | T |
- dp
- ['T', 'F', 'F', 'T', 'T', 'F', 'F', 'T']
- 返回 dp[-1]
- True
- 一个有效切法
- cats | and
选择步骤 10 步
变量与图例
边界下标数的是已取字符个数:绿色前缀必须先可达,紫色候选还必须完整出现在字典里。
- dp[k]
- s[:k] 这个前缀能否完整拆分;下标 k 是长度与边界,不是末字符下标。
- i
- 本轮要判定的前缀右边界,s[:i] 不包含字符 i。
- j
- 最后一个单词的左边界;前半段为 s[:j],候选为 s[j:i]。
- words / longest
- 去重后的字典集合 / 最大词长,本例为 4,用于限制最左切口。
算法步骤
- 将 wordDict 转成集合 words,计算最长单词长度 longest。
- 建立 len(s)+1 个布尔状态,只有 dp[0] 设为 True。
- 令前缀右边界 i 从 1 到 len(s),依次检查 max(0,i−longest) 到 i−1 的切口 j。
- 当 dp[j] 为 True 且 s[j:i] 在 words 中时,设置 dp[i]=True,并退出当前切口循环。
- 若本轮没有成功切口,dp[i] 保持 False;所有前缀判定后返回 dp[-1]。
代码(Python)
查看完整 Python 代码前缀 DP + 字典集合 · 与上方图解对应
words 用于词汇查找,longest 限制末词长度,dp[i] 记录前缀是否可完整拆分。and 的短路让不可达起点不产生切片。
from typing import List
class Solution:
def wordBreak(self, s: str, wordDict: List[str]) -> bool:
words = set(wordDict)
longest = max(map(len, wordDict))
dp = [False] * (len(s) + 1)
dp[0] = True
for i in range(1, len(s) + 1):
for j in range(max(0, i - longest), i):
if dp[j] and s[j:i] in words:
dp[i] = True
break
return dp[-1]运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert Solution().wordBreak("leetcode", ["leet","code"]) is True
assert Solution().wordBreak("catsandog", ["cats","dog","sand","and","cat"]) is False
assert Solution().wordBreak("cars", ["car","ca","rs"]) is True
assert Solution().wordBreak("a", ["a"]) is True
assert Solution().wordBreak("aaaa", ["a"]) is True
复杂度
- 时间
- 令 n=len(s),L 为最长词长,K=min(n,L),D 为字典总字符数。Python 切片与新串哈希计入后,期望 O(D+nK²),不是仅 O(nK);未限制长度的同类写法最坏 O(n³)。
- 空间
- 辅助空间 O(n+m+K),m 为字典词数:set 保存输入单词引用,dp 占 O(n),瞬时切片 O(K);若连输入词汇存储一起计则另加 O(D)。
易错点与边界
- 必须完整覆盖整个 s,找到其中某几个单词不代表可拆分。
- dp[0] 必须为 True,否则第一个单词永远没有可达起点。
- 同一词可重复使用;不要在使用一个词后把它从词典移除。
- 最长词贪心不保证剩余部分可拆,必须保留所有可达断点。
- Python set 查询只免去线性扫描词典,不能免去新切片的构造和首次哈希成本。
主解之后,再比较与迁移。
其他解法 1
Trie + 可达起点 DP时间 令 D 为字典总字符数,L 为最长词长,n 为字符串长;字符字典查找按期望常数计,O(D+n·min(n,L)),每个起点最多沿 Trie 匹配 L 个字符再检查一次断边。
用字符字典建立 Trie,root 是根,node 是沿字符串移动的当前节点,空字符串键标记一个单词结束。dp 仍表示前缀可达性,但扫描方向改为从每个可达左边界 i 出发,沿原字符串逐字符查 Trie;命中词尾就令 dp[j+1]=True,断边才停止。它不创建子字符串,避免对每个候选片段重新切片和哈希,代价是额外存储 Trie。一个词结束后仍应继续查更长词,例如找到 cat 后还需要继续识别 cats。
- 把每个字典单词逐字符插入 root,在终点将空字符串键设为 True,标记单词结束。
- 初始化 dp[0]=True,其余前缀为 False。
- 从左向右枚举起点 i;若 dp[i] 为 False,跳过这个起点。
- 对可达起点令 node=root,再让字符下标 j 从 i 向后移动;找不到字符边就停止本起点。
- 沿字符边前进后,若 node 含词尾标记,就设置 dp[j+1]=True,并继续尝试更长单词。
- 全部起点处理后返回 dp[n]。
from typing import List
class Solution:
def wordBreak(self, s: str, wordDict: List[str]) -> bool:
root = {}
for word in wordDict:
node = root
for ch in word:
if ch not in node:
node[ch] = {}
node = node[ch]
node[""] = True
n = len(s)
dp = [False] * (n + 1)
dp[0] = True
for i in range(n):
if not dp[i]:
continue
node = root
for j in range(i, n):
if s[j] not in node:
break
node = node[s[j]]
if "" in node:
dp[j + 1] = True
return dp[n]复杂度
- 时间
- 令 D 为字典总字符数,L 为最长词长,n 为字符串长;字符字典查找按期望常数计,O(D+n·min(n,L)),每个起点最多沿 Trie 匹配 L 个字符再检查一次断边。
- 空间
- 辅助空间 O(D+n),Trie 最多 O(D) 个节点,dp 为 n+1 个布尔值;不创建子字符串。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert Solution().wordBreak("leetcode", ["leet","code"]) is True
assert Solution().wordBreak("catsandog", ["cats","dog","sand","and","cat"]) is False
assert Solution().wordBreak("cars", ["car","ca","rs"]) is True
assert Solution().wordBreak("a", ["a"]) is True
assert Solution().wordBreak("aaaa", ["a"]) is True
预测练习
已经知道 cat 可达,为什么不能据此判定 catsand 的最终答案?
一点提示
把 j=3 与 j=4 的两个切口分别写出来。
答案与理由
j=3 给出 cat | sand,后半不在字典;继续检查 j=4 才得到 cats | and。某个前缀先成功只证明它自身可达,不应贪心锁定整串的第一个单词。
面试追问与解法取舍
若只要一种拆法,在首次令 dp[i] 为真时记录前驱 j,最后沿 j 回溯并切片。若要所有拆法,单个布尔状态只能帮助剪枝,还需保存多个前驱或回溯枚举;方案总量可能指数增长,不能承诺与判定题相同的输出时间。
模板迁移 / 相似题
试着不看代码,复述这三点
- dp 下标是前缀长度,切片统一使用半开区间 [j,i)。
- 可达前缀与字典单词必须同时成立;中间 False 不能提前宣布全串失败。
- break 只结束当前 i 的切口搜索;字典最大长度可以缩小 j 的范围。
300. 最长递增子序列 中等
一句话题意
从数组中按原顺序选出若干元素,要求严格递增,返回最长子序列的长度;元素不必连续。
nums=[3,1,2,2,4] → 3;可取下标 1、2、4 对应 [1,2,4]。
生活化理解
像为不同长度的爬山路线保留最低的终点:同样走过 k 个点,终点越低,之后越容易接到更高的新点;只需保留每种长度最有延展性的尾值。
以每个位置结尾的 O(n²) DP
“必须以 i 结尾”的状态直接可证,转移容易复现,也便于在面试追问中恢复具体子序列。核心思路
定义 dp[i] 为必须以 nums[i] 结尾的最长严格递增子序列长度。每个元素自己就能组成一条非空子序列,所以全部初始化为 1。这里“结尾固定在 i”是连接条件的依据,不等于任意前缀的全局最长长度。
按 i 从左到右,枚举此前每个位置 j。只有同时满足 j<i 与 nums[j]<nums[i],才能把当前元素接到以 j 结尾的最优序列之后,得到候选 dp[j]+1。执行 dp[i]=max(dp[i],dp[j]+1),保留所有合法前驱中的最长长度;没有合法前驱时仍为 1。
开始处理 i 时,所有 j<i 的结尾状态都已最优;任意长度大于 1、以 i 结尾的递增子序列必有某个这样的前驱,因此转移不会遗漏。相等值不能连接,图中第二个 2 不会因前一个 2 而增加长度。最终答案是 max(dp),因为最优子序列不一定以数组最后一项结尾。
开始处理 i 时,每个 j<i 的 dp[j] 已是以 j 结尾的最优长度。合法前驱必须同时满足下标更早与数值严格更小;逐一取 dp[j]+1 的最大值,再与单元素长度 1 比较,就得到 dp[i]。
图解
| 下标 k | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|
| nums[k] | 3 | 1 | 2 | 2 | 4 |
| dp[k] | · | · | · | · | · |
- nums
- [3, 1, 2, 2, 4]
- dp(源码初值)
- [1, 1, 1, 1, 1]
| 下标 k | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|
| nums[k] | 3 | 1 | 2 | 2 | 4 |
| dp[k] | 1 | · | · | · | · |
- i
- 0
- nums[i]
- 3
- dp[i]
- 1
| 下标 k | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|
| nums[k] | 3 | 1 | 2 | 2 | 4 |
| dp[k] | 1 | 1 | · | · | · |
| 本次检查 | 是否严格更小 | 合法候选长度 | dp[i] 写前 → 写后 |
|---|---|---|---|
| j=0 → i=1 | 3 < 1:F | 不允许接上 | 1 → 1 |
- i / j
- 1 / 0
- nums[j] → nums[i]
- 3 → 1
- dp[j]
- 1
- dp(源码)
- [1, 1, 1, 1, 1]
| 下标 k | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|
| nums[k] | 3 | 1 | 2 | 2 | 4 |
| dp[k] | 1 | 1 | 1 | · | · |
| 本次检查 | 是否严格更小 | 合法候选长度 | dp[i] 写前 → 写后 |
|---|---|---|---|
| j=0 → i=2 | 3 < 2:F | 不允许接上 | 1 → 1 |
- i / j
- 2 / 0
- nums[j] → nums[i]
- 3 → 2
- dp[j]
- 1
- dp(源码)
- [1, 1, 1, 1, 1]
| 下标 k | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|
| nums[k] | 3 | 1 | 2 | 2 | 4 |
| dp[k] | 1 | 1 | 2 | · | · |
| 本次检查 | 是否严格更小 | 合法候选长度 | dp[i] 写前 → 写后 |
|---|---|---|---|
| j=1 → i=2 | 1 < 2:T | 2 | 1 → 2 |
- i / j
- 2 / 1
- nums[j] → nums[i]
- 1 → 2
- dp[j]
- 1
- dp(源码)
- [1, 1, 2, 1, 1]
| 下标 k | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|
| nums[k] | 3 | 1 | 2 | 2 | 4 |
| dp[k] | 1 | 1 | 2 | 1 | · |
| 本次检查 | 是否严格更小 | 合法候选长度 | dp[i] 写前 → 写后 |
|---|---|---|---|
| j=0 → i=3 | 3 < 2:F | 不允许接上 | 1 → 1 |
- i / j
- 3 / 0
- nums[j] → nums[i]
- 3 → 2
- dp[j]
- 1
- dp(源码)
- [1, 1, 2, 1, 1]
| 下标 k | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|
| nums[k] | 3 | 1 | 2 | 2 | 4 |
| dp[k] | 1 | 1 | 2 | 2 | · |
| 本次检查 | 是否严格更小 | 合法候选长度 | dp[i] 写前 → 写后 |
|---|---|---|---|
| j=1 → i=3 | 1 < 2:T | 2 | 1 → 2 |
- i / j
- 3 / 1
- nums[j] → nums[i]
- 1 → 2
- dp[j]
- 1
- dp(源码)
- [1, 1, 2, 2, 1]
| 下标 k | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|
| nums[k] | 3 | 1 | 2 | 2 | 4 |
| dp[k] | 1 | 1 | 2 | 2 | · |
| 本次检查 | 是否严格更小 | 合法候选长度 | dp[i] 写前 → 写后 |
|---|---|---|---|
| j=2 → i=3 | 2 < 2:F | 不允许接上 | 2 → 2 |
- i / j
- 3 / 2
- nums[j] → nums[i]
- 2 → 2
- dp[j]
- 2
- dp(源码)
- [1, 1, 2, 2, 1]
| 下标 k | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|
| nums[k] | 3 | 1 | 2 | 2 | 4 |
| dp[k] | 1 | 1 | 2 | 2 | 2 |
| 本次检查 | 是否严格更小 | 合法候选长度 | dp[i] 写前 → 写后 |
|---|---|---|---|
| j=0 → i=4 | 3 < 4:T | 2 | 1 → 2 |
- i / j
- 4 / 0
- nums[j] → nums[i]
- 3 → 4
- dp[j]
- 1
- dp(源码)
- [1, 1, 2, 2, 2]
| 下标 k | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|
| nums[k] | 3 | 1 | 2 | 2 | 4 |
| dp[k] | 1 | 1 | 2 | 2 | 2 |
| 本次检查 | 是否严格更小 | 合法候选长度 | dp[i] 写前 → 写后 |
|---|---|---|---|
| j=1 → i=4 | 1 < 4:T | 2 | 2 → 2 |
- i / j
- 4 / 1
- nums[j] → nums[i]
- 1 → 4
- dp[j]
- 1
- dp(源码)
- [1, 1, 2, 2, 2]
| 下标 k | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|
| nums[k] | 3 | 1 | 2 | 2 | 4 |
| dp[k] | 1 | 1 | 2 | 2 | 3 |
| 本次检查 | 是否严格更小 | 合法候选长度 | dp[i] 写前 → 写后 |
|---|---|---|---|
| j=2 → i=4 | 2 < 4:T | 3 | 2 → 3 |
- i / j
- 4 / 2
- nums[j] → nums[i]
- 2 → 4
- dp[j]
- 2
- dp(源码)
- [1, 1, 2, 2, 3]
| 下标 k | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|
| nums[k] | 3 | 1 | 2 | 2 | 4 |
| dp[k] | 1 | 1 | 2 | 2 | 3 |
| 本次检查 | 是否严格更小 | 合法候选长度 | dp[i] 写前 → 写后 |
|---|---|---|---|
| j=3 → i=4 | 2 < 4:T | 3 | 3 → 3 |
- i / j
- 4 / 3
- nums[j] → nums[i]
- 2 → 4
- dp[j]
- 2
- dp(源码)
- [1, 1, 2, 2, 3]
| 下标 k | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|
| nums[k] | 3 | 1 | 2 | 2 | 4 |
| dp[k] | 1 | 1 | 2 | 2 | 3 |
- dp
- [1, 1, 2, 2, 3]
- max(dp)
- 3
- 一条实际子序列
- [1,2,4]
选择步骤 13 步
变量与图例
每个紫格都问“如果必须以这个数结尾,左边谁能接过来”;特别看第二个 2 为什么不能接第一个 2。
- i
- 当前子序列结尾的下标。
- j
- 当前枚举的左侧前驱,范围 0…i−1。
- dp[i]
- 必须以 nums[i] 结尾的最长严格递增子序列长度。
- nums[j]<nums[i]
- 能否接上当前元素的数值条件;相等不合法。
算法步骤
- 建立长度 len(nums) 的 dp,全部设为 1,表示单元素子序列。
- 令当前结尾 i 从 0 到 len(nums)−1。
- 枚举 j=0…i−1;仅当 nums[j]<nums[i] 时允许接上当前元素。
- 对每个合法前驱,用 dp[j]+1 改进 dp[i],其余前驱跳过。
- 所有结尾计算完毕,返回 max(dp)。
代码(Python)
查看完整 Python 代码以每个位置结尾的 O(n²) DP · 与上方图解对应
dp[i] 只从严格更小的早期元素转移;每个位置保留一条最优长度。该版本方便添加前驱来恢复一条具体子序列。
from typing import List
class Solution:
def lengthOfLIS(self, nums: List[int]) -> int:
dp = [1] * len(nums)
for i in range(len(nums)):
for j in range(i):
if nums[j] < nums[i]:
dp[i] = max(dp[i], dp[j] + 1)
return max(dp)运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert Solution().lengthOfLIS([10,9,2,5,3,7,101,18]) == 4
assert Solution().lengthOfLIS([7,7,7,7]) == 1
assert Solution().lengthOfLIS([3,5,6,2,4]) == 3
assert Solution().lengthOfLIS([1]) == 1
复杂度
- 时间
- O(n²),n 为数组长度,枚举所有 j<i 的位置对。
- 空间
- 辅助空间 O(n),保存每个结尾的最优长度。
易错点与边界
- 主解只有 nums[j] < nums[i] 时才能连接;相等的值不能让严格递增长度增加。
- 子序列不要求相邻,不能在相邻下降时重置整个答案。
- 基础 DP 的答案是 max(dp),最后一个元素不一定在最优序列里。
主解之后,再比较与迁移。
其他解法 1
贪心最小尾值 + 二分时间 O(n log n),n 为数组长度,每个元素在最长 O(n) 的 tails 上二分。
改按长度记录状态:tails[k] 是已扫描前缀中,长度为 k+1 的递增子序列的最小可能末尾。相同长度保留更小末尾,总不会更难接上未来元素;tails 严格递增,因而能用 bisect_left 找第一个不小于 x 的位置 p。存在该位置时降低对应长度的末尾,否则追加 x 扩展最长长度。相等值只替换,不延长。此法把时间改善到 O(n log n),但 tails 的各项可能来自不同历史序列,整个列表不保证是原数组的一条实际子序列;返回的是其长度。
- 初始化空列表 tails,依次读取原数组元素 x。
- 用 bisect_left(tails,x) 找第一个大于等于 x 的位置 p。
- 若 p 等于 len(tails),说明 x 大于现有全部尾值,将 x 追加以新增一个可行长度。
- 否则将 tails[p] 替换为 x,改善长度 p+1 的最小末尾而不改变最长长度。
- 全部元素处理后返回 len(tails),不把 tails 本身当作重建出的实际子序列。
from bisect import bisect_left
from typing import List
class Solution:
def lengthOfLIS(self, nums: List[int]) -> int:
tails = []
for x in nums:
p = bisect_left(tails, x)
if p == len(tails):
tails.append(x)
else:
tails[p] = x
return len(tails)复杂度
- 时间
- O(n log n),n 为数组长度,每个元素在最长 O(n) 的 tails 上二分。
- 空间
- 辅助空间 O(n),tails 长度等于当前最优长度,最坏为 n;不保存实际答案序列。
这个方法的边界
- 严格递增用 < 和 bisect_left;非递减版本才允许相等并使用 bisect_right。
- tails 只是各长度的最小尾值集合,不能直接当作实际 LIS 返回。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert Solution().lengthOfLIS([10,9,2,5,3,7,101,18]) == 4
assert Solution().lengthOfLIS([7,7,7,7]) == 1
assert Solution().lengthOfLIS([3,5,6,2,4]) == 3
assert Solution().lengthOfLIS([1]) == 1
预测练习
i=3 时,前一个 2 的 dp[2]=2,能不能据此把第二个 2 算成长度 3?
一点提示
递增条件使用的是 <,不是 ≤。
答案与理由
不能,2<2 不成立。第二个 2 可以从值为 1 的位置接成长度 2,但不能把 [1,2] 再接一个 2 算作严格递增。
面试追问与解法取舍
若要恢复实际 LIS,给每个元素记录前驱下标,并让 tails 另存对应的末尾下标;扩展长度 p+1 时前驱指向当时长度 p 的末尾。最后从最长长度的末尾倒追。若问 LIS 的数量,单个最小尾值会丢掉多种历史路径,需维护长度与计数等额外状态。
模板迁移 / 相似题
试着不看代码,复述这三点
- “以 i 结尾”让合法连接只需检查最后两个数。
- 先满足下标顺序与严格大小,再比较候选长度。
- 答案取所有结尾中的最大值;重复值最能检验 < 与 ≤ 的区别。
152. 乘积最大子数组 中等
一句话题意
在非空整数数组中选取一个非空连续子数组,返回其元素乘积的最大值。
nums=[-2,3,-4,0,-2,-3] → 24;最佳连续区间为前三个元素,乘积 (−2)×3×(−4)。
生活化理解
像带正负号的放大器串联:乘正数保持大小关系,乘负数会把最小值翻成最大候选,所以不仅要记“最赚钱的过去”,也要记“亏得最多的过去”。
最大 / 最小结尾乘积滚动 DP
同时保留最大与最小结尾乘积,能直接解释负数翻转和零断点,在线性时间、常数空间内完成。核心思路
源码维护三个标量:hi 和 lo 分别是以当前下标结尾的非空连续子数组最大、最小乘积,ans 是所有已处理结尾的历史最大值。首个元素是唯一的非空选择,所以初始化 hi=lo=ans=nums[0],不能用 0 或 1 代替。
读取新元素 x=nums[i] 后,以 i 结尾的区间要么只含 x,要么接续上一结尾区间。若 x 非负,大小顺序不变;若 x<0,旧最小乘积反而会产生最大接续候选,故先交换旧 hi、lo。再分别执行 hi=max(x,hi*x)、lo=min(x,lo*x):两行各自读取乘法前保留在该变量中的来源,计算 lo 不会使用刚更新的 hi。
本轮两次转移完成后,hi、lo 才重新表示当前结尾的两个极值,再用 hi 更新 ans。图中的交换帧是乘法前的中间状态,历史表只是讲解记录,程序没有保存这些行。遇零时两个结尾乘积归零,但 ans 保留;下一元素仍可以通过单独取 x 重新开始。图中旧 lo=−6 乘 −4 得到 24,而尾部结尾最大值只有 6,因此最后返回的是 ans。
每轮完成后,hi、lo 分别是以当前位置结尾的最大和最小乘积。正数保序,负数逆序,因此负数时先交换再各自与单元素 x 比较;ans 始终保存所有已完成结尾中最大的 hi。
图解
| 下标 k | 0 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|
| nums[k] | -2 | 3 | -4 | 0 | -2 | -3 |
| 以 k 结尾 hi | -2 | · | · | · | · | · |
| 以 k 结尾 lo | -2 | · | · | · | · | · |
| 历史最大 ans | -2 | · | · | · | · | · |
- hi
- -2
- lo
- -2
- ans
- -2
| 下标 k | 0 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|
| nums[k] | -2 | 3 | -4 | 0 | -2 | -3 |
| 以 k 结尾 hi | -2 | 3 | · | · | · | · |
| 以 k 结尾 lo | -2 | -6 | · | · | · | · |
| 历史最大 ans | -2 | 3 | · | · | · | · |
| 本轮变量 | hi | lo | ans |
|---|---|---|---|
| 进入循环的旧值 | -2 | -2 | -2 |
| 乘法前的工作值 | -2 | -2 | -2 |
| 本轮写回 | 候选一 | 候选二 | 结果 |
|---|---|---|---|
| hi = max(...) | 3 | -2×3=-6 | 3 |
| lo = min(...) | 3 | -2×3=-6 | -6 |
| ans = max(旧 ans, hi) | 旧 ans=-2 | 新 hi=3 | 3 |
- i / x
- 1 / 3
- hi
- 3
- lo
- -6
- ans
- 3
| 下标 k | 0 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|
| nums[k] | -2 | 3 | -4 | 0 | -2 | -3 |
| 以 k 结尾 hi | -2 | 3 | · | · | · | · |
| 以 k 结尾 lo | -2 | -6 | · | · | · | · |
| 历史最大 ans | -2 | 3 | · | · | · | · |
| 本轮变量 | hi | lo | ans |
|---|---|---|---|
| 进入循环的旧值 | 3 | -6 | 3 |
| 乘法前的工作值 | -6 | 3 | 3 |
- i / x
- 2 / -4
- hi(交换后)
- -6
- lo(交换后)
- 3
- ans(尚未更新)
- 3
| 下标 k | 0 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|
| nums[k] | -2 | 3 | -4 | 0 | -2 | -3 |
| 以 k 结尾 hi | -2 | 3 | 24 | · | · | · |
| 以 k 结尾 lo | -2 | -6 | -12 | · | · | · |
| 历史最大 ans | -2 | 3 | 24 | · | · | · |
| 本轮变量 | hi | lo | ans |
|---|---|---|---|
| 进入循环的旧值 | 3 | -6 | 3 |
| 乘法前的工作值 | -6 | 3 | 3 |
| 本轮写回 | 候选一 | 候选二 | 结果 |
|---|---|---|---|
| hi = max(...) | -4 | -6×-4=24 | 24 |
| lo = min(...) | -4 | 3×-4=-12 | -12 |
| ans = max(旧 ans, hi) | 旧 ans=3 | 新 hi=24 | 24 |
- i / x
- 2 / -4
- hi
- 24
- lo
- -12
- ans
- 24
| 下标 k | 0 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|
| nums[k] | -2 | 3 | -4 | 0 | -2 | -3 |
| 以 k 结尾 hi | -2 | 3 | 24 | 0 | · | · |
| 以 k 结尾 lo | -2 | -6 | -12 | 0 | · | · |
| 历史最大 ans | -2 | 3 | 24 | 24 | · | · |
| 本轮变量 | hi | lo | ans |
|---|---|---|---|
| 进入循环的旧值 | 24 | -12 | 24 |
| 乘法前的工作值 | 24 | -12 | 24 |
| 本轮写回 | 候选一 | 候选二 | 结果 |
|---|---|---|---|
| hi = max(...) | 0 | 24×0=0 | 0 |
| lo = min(...) | 0 | -12×0=0 | 0 |
| ans = max(旧 ans, hi) | 旧 ans=24 | 新 hi=0 | 24 |
- i / x
- 3 / 0
- hi
- 0
- lo
- 0
- ans
- 24
| 下标 k | 0 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|
| nums[k] | -2 | 3 | -4 | 0 | -2 | -3 |
| 以 k 结尾 hi | -2 | 3 | 24 | 0 | · | · |
| 以 k 结尾 lo | -2 | -6 | -12 | 0 | · | · |
| 历史最大 ans | -2 | 3 | 24 | 24 | · | · |
| 本轮变量 | hi | lo | ans |
|---|---|---|---|
| 进入循环的旧值 | 0 | 0 | 24 |
| 乘法前的工作值 | 0 | 0 | 24 |
- i / x
- 4 / -2
- hi(交换后)
- 0
- lo(交换后)
- 0
- ans(尚未更新)
- 24
| 下标 k | 0 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|
| nums[k] | -2 | 3 | -4 | 0 | -2 | -3 |
| 以 k 结尾 hi | -2 | 3 | 24 | 0 | 0 | · |
| 以 k 结尾 lo | -2 | -6 | -12 | 0 | -2 | · |
| 历史最大 ans | -2 | 3 | 24 | 24 | 24 | · |
| 本轮变量 | hi | lo | ans |
|---|---|---|---|
| 进入循环的旧值 | 0 | 0 | 24 |
| 乘法前的工作值 | 0 | 0 | 24 |
| 本轮写回 | 候选一 | 候选二 | 结果 |
|---|---|---|---|
| hi = max(...) | -2 | 0×-2=0 | 0 |
| lo = min(...) | -2 | 0×-2=0 | -2 |
| ans = max(旧 ans, hi) | 旧 ans=24 | 新 hi=0 | 24 |
- i / x
- 4 / -2
- hi
- 0
- lo
- -2
- ans
- 24
| 下标 k | 0 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|
| nums[k] | -2 | 3 | -4 | 0 | -2 | -3 |
| 以 k 结尾 hi | -2 | 3 | 24 | 0 | 0 | · |
| 以 k 结尾 lo | -2 | -6 | -12 | 0 | -2 | · |
| 历史最大 ans | -2 | 3 | 24 | 24 | 24 | · |
| 本轮变量 | hi | lo | ans |
|---|---|---|---|
| 进入循环的旧值 | 0 | -2 | 24 |
| 乘法前的工作值 | -2 | 0 | 24 |
- i / x
- 5 / -3
- hi(交换后)
- -2
- lo(交换后)
- 0
- ans(尚未更新)
- 24
| 下标 k | 0 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|
| nums[k] | -2 | 3 | -4 | 0 | -2 | -3 |
| 以 k 结尾 hi | -2 | 3 | 24 | 0 | 0 | 6 |
| 以 k 结尾 lo | -2 | -6 | -12 | 0 | -2 | -3 |
| 历史最大 ans | -2 | 3 | 24 | 24 | 24 | 24 |
| 本轮变量 | hi | lo | ans |
|---|---|---|---|
| 进入循环的旧值 | 0 | -2 | 24 |
| 乘法前的工作值 | -2 | 0 | 24 |
| 本轮写回 | 候选一 | 候选二 | 结果 |
|---|---|---|---|
| hi = max(...) | -3 | -2×-3=6 | 6 |
| lo = min(...) | -3 | 0×-3=0 | -3 |
| ans = max(旧 ans, hi) | 旧 ans=24 | 新 hi=6 | 24 |
- i / x
- 5 / -3
- hi
- 6
- lo
- -3
- ans
- 24
| 下标 k | 0 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|
| nums[k] | -2 | 3 | -4 | 0 | -2 | -3 |
| 以 k 结尾 hi | -2 | 3 | 24 | 0 | 0 | 6 |
| 以 k 结尾 lo | -2 | -6 | -12 | 0 | -2 | -3 |
| 历史最大 ans | -2 | 3 | 24 | 24 | 24 | 24 |
- hi
- 6
- lo
- -3
- ans
- 24
- 最优实际区间
- nums[0:3]=[-2,3,-4]
选择步骤 10 步
变量与图例
负数帧先停在“交换后、乘法前”:追踪旧 lo 如何成为新 hi 的来源;遇零时比较结尾极值与历史答案。
- x / i
- 当前位置的值与下标。
- hi
- 每轮完成后的最大结尾乘积;负数交换后的中间值还不是新结果。
- lo
- 每轮完成后的最小结尾乘积,可能在下一个负数处翻成最大。
- ans
- 任意已处理结尾的最大乘积,经过零也保留。
- 旧值 / 工作值(讲解记录)
- 便于检查覆盖顺序的表格;源码通过交换并保留各自变量来读取正确来源。
算法步骤
- 用 nums[0] 初始化 hi、lo、ans,覆盖只有一个元素的情况。
- 从下标 i=1 开始读取 x=nums[i];若 x<0,先交换 hi、lo。
- 计算 hi=max(x,hi*x),比较单独取 x 与延续当前最大候选。
- 计算 lo=min(x,lo*x),使用 lo 自己保留的乘法前来源,不读取新 hi。
- 以 hi 更新 ans,所有元素处理完后返回 ans。
代码(Python)
查看完整 Python 代码最大 / 最小结尾乘积滚动 DP · 与上方图解对应
hi、lo 总是同一结尾位置的两个极值。负数分支先交换,再做各自转移;计算 lo 时不读取刚更新的 hi。用下标遍历避免 nums[1:] 产生额外数组副本。
from typing import List
class Solution:
def maxProduct(self, nums: List[int]) -> int:
hi = lo = ans = nums[0]
for i in range(1, len(nums)):
x = nums[i]
if x < 0:
hi, lo = lo, hi
hi = max(x, hi * x)
lo = min(x, lo * x)
ans = max(ans, hi)
return ans运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert Solution().maxProduct([2,3,-2,4]) == 6
assert Solution().maxProduct([-2,3,-4]) == 24
assert Solution().maxProduct([-2,0,-1]) == 0
assert Solution().maxProduct([-2]) == -2
assert Solution().maxProduct([0]) == 0
复杂度
- 时间
- O(n),n 是数组长度,按题目给定乘积范围使用常数时间整数运算。
- 空间
- 辅助空间 O(1),没有切片,没有保存所有结尾状态。
易错点与边界
- 负数会翻转大小关系,仅保存最大结尾乘积会丢失未来最优候选。
- hi、lo 更新必须基于同一轮旧状态,不能把新 hi 再用于 lo。
- 答案要求非空子数组,单个负数的答案仍是负数。
- 零使当前乘积归零,但历史最大值必须保留。
主解之后,再比较与迁移。
其他解法 1
零分段的左右乘积扫描时间 O(n),n 是数组长度,一次循环同时更新左右两个扫描方向。
left 累计从当前无零段左端到扫描位置的乘积,right 同时从右端反向累计。无零整数的绝对值至少为 1:负数为偶数个时,整段乘积覆盖最优;负数为奇数个且存在正积候选时,可去掉截至第一个负数的前缀,或去掉从最后一个负数开始的后缀,两端扫描分别覆盖这些候选。单个负数仍由实际扫描值和 ans=nums[0] 处理。遇零当轮先把零纳入答案,下一轮相应乘积从 1 重启。这个常数空间写法依赖整数的性质,不能原样推广到绝对值小于 1 的任意小数。
- 初始化 left=right=1、ans=nums[0],记录数组长度 n。
- 令 i 从 0 到 n−1;若上一轮 left 为 0,先按乘法单位元 1 重启,再乘 nums[i]。
- 同样处理 right,并乘入右侧对应元素 nums[n−1−i]。
- 用本轮 left、right 与旧 ans 取最大;零本身也会在当轮参与比较。
- 扫描完两个方向覆盖的所有前缀和后缀后,返回 ans。
from typing import List
class Solution:
def maxProduct(self, nums: List[int]) -> int:
left = right = 1
ans = nums[0]
n = len(nums)
for i in range(n):
left = (left if left != 0 else 1) * nums[i]
right = (right if right != 0 else 1) * nums[n - 1 - i]
ans = max(ans, left, right)
return ans复杂度
- 时间
- O(n),n 是数组长度,一次循环同时更新左右两个扫描方向。
- 空间
- 辅助空间 O(1),按索引从右向左访问,不创建反转切片。
这个方法的边界
- 双向扫描的论证依赖整数非零因子绝对值至少为 1,不能原样推广到任意小数。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert Solution().maxProduct([2,3,-2,4]) == 6
assert Solution().maxProduct([-2,3,-4]) == 24
assert Solution().maxProduct([-2,0,-1]) == 0
assert Solution().maxProduct([-2]) == -2
assert Solution().maxProduct([0]) == 0
预测练习
读取 −4 之前 hi=3、lo=−6。只保留 hi 会漏掉什么?
一点提示
分别把 −4 接到旧最大与旧最小上。
答案与理由
旧最大 3 乘 −4 得 −12,而旧最小 −6 乘 −4 得 24,后者才是答案。负数会翻转次序,所以两端极值都要保留,且计算必须使用同轮旧状态。
面试追问与解法取舍
若要求返回区间,可为 hi 和 lo 同时保存起点;交换极值时也交换起点,单独选择 x 时起点改为 i,更新 ans 时记录终点 i。仅保存一个最大值起点不够,因为将来可能由最小乘积翻成最大乘积。
模板迁移 / 相似题
试着不看代码,复述这三点
- 乘积含负数时,最大与最小是一对必须同时维护的状态。
- 区分交换后的工作变量与本轮完成后的结尾极值。
- 零重置结尾乘积但不重置历史答案;ans 与 hi 解决不同范围。
416. 分割等和子集 中等
一句话题意
判断能否把正整数数组中的所有元素分成两个和相等的子集;每个元素必须且只能分到一组。
nums=[1,2,5],总和 8,目标 4 → False。正确可达和只有 {0,1,2,3}(不超过目标)。
生活化理解
像把一箱有固定重量的物品分给两人:只要能从中选出总重量一半,剩下的自然也是一半。每件物品只有一件,不能为了凑重量重复拿同一件。
一维 0/1 背包倒序更新
一维可达表直接对应“每件只能用一次”,倒序来源清楚,兼顾面试常用写法与空间效率。核心思路
令 total=sum(nums)。总和为奇数就无法平分;否则设 target=total//2,只需判断能否选出和恰为 target 的子集,因为剩余元素的和也自动是 target。源码建立一个一维布尔数组 dp;处理某件物品之前,dp[t] 表示仅使用更早物品能否凑出和 t。初始只有空子集可达,即 dp[0]=True。
当前元素 x 只有不选和选一次两种情况。不选时保留原 dp[t];选它时,需要此前已能凑出 t−x。因此更新式为 dp[t]=dp[t] or dp[t−x]。关键是 t 从 target 倒序扫到 x:读取较小的 t−x 时,这个位置尚未被当前 x 更新,来源方案就不会已经使用过当前这件物品。
一轮中数组右侧是本件更新后的新值,左侧还保留旧值;一轮结束后,整个 dp 才表示包含当前物品在内的全部选择。图中把旧层与本轮目标行分开展示,是为了看清来源,源码没有二维数组。以 [1,2,5] 为例,处理 2 时先算容量 4,读到旧 dp[2]=False;随后才把容量 2 改成 True,避免同一件 2 被拿两次。最终返回 dp[target]。
处理每件 x 时,容量倒序扫描。读 dp[t−x] 时它的下标更小,还未被本件更新,所以来源方案只使用更早物品;与旧 dp[t] 做或运算就准确覆盖“不选”和“选一次”。
图解
| 容量 k | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|
| 进入本物品前(旧) | T | F | F | F | F |
| 本物品处理后的目标状态 | T | F | F | F | F |
- nums
- [1, 2, 5]
- total / target
- 8 / 4
- dp(源码)
- [True, False, False, False, False]
| 容量 k | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|
| 进入本物品前(旧) | T | F | F | F | F |
| 本物品处理后的目标状态 | T | · | · | · | F |
| 本轮两条来源 | 旧状态 | 是否选当前这件 | 贡献 |
|---|---|---|---|
| 不选 | dp[4]=F | 否 | F |
| 选一次 | dp[3]=F | 是,加 1 | F |
- 第几件 / x
- 1 / 1
- t
- 4
- 旧 dp[3]
- F
- dp(实际混合新旧)
- ['T', 'F', 'F', 'F', 'F']
| 容量 k | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|
| 进入本物品前(旧) | T | F | F | F | F |
| 本物品处理后的目标状态 | T | · | · | F | F |
| 本轮两条来源 | 旧状态 | 是否选当前这件 | 贡献 |
|---|---|---|---|
| 不选 | dp[3]=F | 否 | F |
| 选一次 | dp[2]=F | 是,加 1 | F |
- 第几件 / x
- 1 / 1
- t
- 3
- 旧 dp[2]
- F
- dp(实际混合新旧)
- ['T', 'F', 'F', 'F', 'F']
| 容量 k | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|
| 进入本物品前(旧) | T | F | F | F | F |
| 本物品处理后的目标状态 | T | · | F | F | F |
| 本轮两条来源 | 旧状态 | 是否选当前这件 | 贡献 |
|---|---|---|---|
| 不选 | dp[2]=F | 否 | F |
| 选一次 | dp[1]=F | 是,加 1 | F |
- 第几件 / x
- 1 / 1
- t
- 2
- 旧 dp[1]
- F
- dp(实际混合新旧)
- ['T', 'F', 'F', 'F', 'F']
| 容量 k | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|
| 进入本物品前(旧) | T | F | F | F | F |
| 本物品处理后的目标状态 | T | T | F | F | F |
| 本轮两条来源 | 旧状态 | 是否选当前这件 | 贡献 |
|---|---|---|---|
| 不选 | dp[1]=F | 否 | F |
| 选一次 | dp[0]=T | 是,加 1 | T |
- 第几件 / x
- 1 / 1
- t
- 1
- 旧 dp[0]
- T
- dp(实际混合新旧)
- ['T', 'T', 'F', 'F', 'F']
| 容量 k | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|
| 进入本物品前(旧) | T | T | F | F | F |
| 本物品处理后的目标状态 | T | T | · | · | F |
| 本轮两条来源 | 旧状态 | 是否选当前这件 | 贡献 |
|---|---|---|---|
| 不选 | dp[4]=F | 否 | F |
| 选一次 | dp[2]=F | 是,加 2 | F |
- 第几件 / x
- 2 / 2
- t
- 4
- 旧 dp[2]
- F
- dp(实际混合新旧)
- ['T', 'T', 'F', 'F', 'F']
| 容量 k | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|
| 进入本物品前(旧) | T | T | F | F | F |
| 本物品处理后的目标状态 | T | T | · | T | F |
| 本轮两条来源 | 旧状态 | 是否选当前这件 | 贡献 |
|---|---|---|---|
| 不选 | dp[3]=F | 否 | F |
| 选一次 | dp[1]=T | 是,加 2 | T |
- 第几件 / x
- 2 / 2
- t
- 3
- 旧 dp[1]
- T
- dp(实际混合新旧)
- ['T', 'T', 'F', 'T', 'F']
| 容量 k | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|
| 进入本物品前(旧) | T | T | F | F | F |
| 本物品处理后的目标状态 | T | T | T | T | F |
| 本轮两条来源 | 旧状态 | 是否选当前这件 | 贡献 |
|---|---|---|---|
| 不选 | dp[2]=F | 否 | F |
| 选一次 | dp[0]=T | 是,加 2 | T |
- 第几件 / x
- 2 / 2
- t
- 2
- 旧 dp[0]
- T
- dp(实际混合新旧)
- ['T', 'T', 'T', 'T', 'F']
| 容量 k | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|
| 进入本物品前(旧) | T | T | T | T | F |
| 本物品处理后的目标状态 | T | T | T | T | F |
- x
- 5
- target
- 4
- 内层 range
- range(4,4,-1),为空
- dp(源码)
- [True, True, True, True, False]
| 容量 k | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|
| 进入本物品前(旧) | T | T | T | T | F |
| 本物品处理后的目标状态 | T | T | T | T | F |
- 可达的和
- [0, 1, 2, 3]
- dp[target]
- False
- 返回值
- False
选择步骤 10 步
变量与图例
从右向左逐格走:下行的 · 在内存里仍保存上行旧值;重点看处理 2 时,容量 4 为什么读不到稍后才出现的新 dp[2]。
- total / target
- 总和与它的一半;奇数总和会提前返回 False。
- x
- 当前这一件物品的数值;相同数值的不同输入位置仍是不同件。
- t
- 当前容量,从 target 倒序扫到 x。
- dp[t]
- 已处理物品能否组成和 t;同一轮运行时右侧格是新值,左侧格仍为旧值。
- 旧层 / 目标行(讲解记录)
- 把一维数组的更新分成可见两行;源码本身没有复制这张二维表。
算法步骤
- 计算 total;若为奇数返回 False,否则设 target=total//2。
- 建立 target+1 个 False 状态,只把 dp[0] 设为 True。
- 逐个读取输入元素 x,将容量 t 从 target 倒序遍历到 x。
- 执行 dp[t]=dp[t] or dp[t−x],让两个来源都保持当前物品之前的含义。
- 每件物品只处理一轮;全部完成后返回 dp[target]。
代码(Python)
查看完整 Python 代码一维 0/1 背包倒序更新 · 与上方图解对应
dp[t] 表示在当前已处理元素中是否有子集和 t。倒序让源 dp[t−x] 保持上一轮值,是“每个元素一次”的核心,不是可有可无的实现习惯。
from typing import List
class Solution:
def canPartition(self, nums: List[int]) -> bool:
total = sum(nums)
if total % 2:
return False
target = total // 2
dp = [False] * (target + 1)
dp[0] = True
for x in nums:
for t in range(target, x - 1, -1):
dp[t] = dp[t] or dp[t - x]
return dp[target]运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert Solution().canPartition([1,5,11,5]) is True
assert Solution().canPartition([1,2,3,5]) is False
assert Solution().canPartition([1]) is False
assert Solution().canPartition([1,2,5]) is False
assert Solution().canPartition([1,2,3,6]) is True
复杂度
- 时间
- O(nT),n 为元素数,T 为总和的一半;这是对数值 T 的伪多项式复杂度。
- 空间
- 辅助空间 O(T),返回单个布尔值。
易错点与边界
- 总和为奇数直接无解,不能用向下取整的一半继续判断。
- 一维表正序会重复使用同一个元素,[1,2,5] 可产生错误的目标 4。
- 重复数值仍是不同输入元素,例如两个 5 可以各选一次。
- 空子集的和 0 必须可达;不能把所有布尔状态初始化为 False。
主解之后,再比较与迁移。
其他解法 2
二维表显式区分物品层时间 O(nT),n 是元素个数,T 是总和一半,每格只看上一行两个位置。
dp[i][t] 表示只使用前 i 个元素能否凑出 t。初始 dp[0][0]=True,其余 False;当前 x=nums[i−1] 的不选分支来自 dp[i−1][t],选一次分支来自 dp[i−1][t−x]。两个来源都明确在上一行,所以本行容量可以正序扫描,也不会重复使用当前元素。它用更多空间保存完整物品层,适合先推导背包状态,或在追问中回溯选了哪些元素。
- 检查总和奇偶性,设 target 为一半,并记录元素个数 n。
- 建立 (n+1)×(target+1) 的 False 表,只令 dp[0][0]=True。
- 令 i 从 1 到 n,读取当前元素 x=nums[i−1]。
- 容量 t 从 0 到 target:先复制 dp[i−1][t],表示不选 x。
- 若 t≥x,再与 dp[i−1][t−x] 做或运算,加入选一次 x 的可能。
- 返回 dp[n][target]。
from typing import List
class Solution:
def canPartition(self, nums: List[int]) -> bool:
total = sum(nums)
if total % 2:
return False
target = total // 2
n = len(nums)
dp = [[False] * (target + 1) for _ in range(n + 1)]
dp[0][0] = True
for i in range(1, n + 1):
x = nums[i - 1]
for t in range(target + 1):
dp[i][t] = dp[i - 1][t]
if t >= x:
dp[i][t] = dp[i][t] or dp[i - 1][t - x]
return dp[n][target]复杂度
- 时间
- O(nT),n 是元素个数,T 是总和一半,每格只看上一行两个位置。
- 空间
- 辅助空间 O(nT),保留全部物品层;不是输出空间。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert Solution().canPartition([1,5,11,5]) is True
assert Solution().canPartition([1,2,3,5]) is False
assert Solution().canPartition([1]) is False
assert Solution().canPartition([1,2,5]) is False
assert Solution().canPartition([1,2,3,6]) is True
Python 大整数位集合时间 设 S 为总和,w 为大整数内部每个数字块的有效位数;移位和按位或按块处理,时间上界 O(n·⌈S/w⌉),不是 O(n) 常数位操作。固定 w 的位复杂度记作 O(nS)。
将布尔可达表编码到一个整数中:bits 的第 t 位为 1 就表示和 t 可达,初始 bits=1 只置第 0 位。bits 左移 x 位把所有旧可达和统一加 x,再与旧 bits 按位或,分别对应选当前元素一次与不选。Python 先计算右侧,所以这次移位只使用更新前的可达集合,不会复用同一件。这个写法适合熟悉位集合后的简洁实现;原代码不裁剪高位,会保存到总和范围,大整数的时间与空间不能按一个固定机器整数估算。
- 求 total,若为奇数直接返回 False。
- 初始化 bits=1,表示空子集的和 0 可达。
- 逐个元素 x 执行 bits |= bits << x,把旧可达和与选入 x 后的和合并。
- 处理完后将 bits 右移 total//2 位,取最低位并转换为 bool。
from typing import List
class Solution:
def canPartition(self, nums: List[int]) -> bool:
total = sum(nums)
if total % 2:
return False
bits = 1
for x in nums:
bits |= bits << x
return bool((bits >> (total // 2)) & 1)复杂度
- 时间
- 设 S 为总和,w 为大整数内部每个数字块的有效位数;移位和按位或按块处理,时间上界 O(n·⌈S/w⌉),不是 O(n) 常数位操作。固定 w 的位复杂度记作 O(nS)。
- 空间
- 辅助空间 O(⌈S/w⌉) 个大整数数字块,即 O(S) 位;移位会暂时创建同阶大小的新整数。
这个方法的边界
- Python bits 是变长整数,不能因为代码只有一行就声称空间 O(1)。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert Solution().canPartition([1,5,11,5]) is True
assert Solution().canPartition([1,2,3,5]) is False
assert Solution().canPartition([1]) is False
assert Solution().canPartition([1,2,5]) is False
assert Solution().canPartition([1,2,3,6]) is True
预测练习
处理物品 2 时,若先更新 t=2 再更新 t=4,会发生什么错误?
一点提示
t=2 会由 dp[0]=T 变成 T,随后 t=4 会读到这个新值。
答案与理由
t=4 会错误地用“刚用过当前 2 的 dp[2]”再加同一个 2,变成 True。倒序先更新 4,读到的旧 dp[2]=False,因而避免同一件物品重复使用。
面试追问与解法取舍
如果元素可能为负数,0…T 的单向容量范围与倒序论证不再适用,需要重新定义和的取值范围。若要返回两个子集,二维表可以回溯:当前格若来自上一行同容量就不选,否则选 nums[i−1] 并将容量减去该值,余下元素组成补集。
模板迁移 / 相似题
试着不看代码,复述这三点
- 先用总和奇偶性把问题化成子集和 target。
- 倒序的理由是保留上一物品层,不是机械记忆循环方向。
- 与无限硬币区分:0/1 背包不允许来源刚用过当前这件物品。
32. 最长有效括号 困难
一句话题意
给出只含左右括号的字符串,求最长连续且括号完全匹配的子串长度,不能跳过中间字符。
s="()(())(" → 6;最长有效连续子串为下标 [0,5] 的 "()(())",末尾左括号不计入。
生活化理解
像把括号片段拼成可以闭合的盒子:遇到右括号时,先跨过紧邻的完整盒子,检查其左边有没有能和当前右括号配对的左括号,再把更左侧的完整盒子一并接上。
以右括号结尾的动态规划
用已知有效后缀长度定位匹配点,能把内部闭合与前段拼接逐一解释清楚,并保持线性时间。核心思路
定义 dp[i] 为必须以字符 s[i] 结尾的最长有效括号子串长度,初始化全为 0;ans 保存已处理结尾中的最大值。左括号不可能作为有效串的末尾,所以扫描到左括号时直接跳过。若当前为右括号且前一字符是左括号,就新形成一对,得到 dp[i]=2+dp[i−2];i<2 时不存在前段,按 0 处理。
若当前与前一字符都是右括号,dp[i−1] 已给出紧邻内部有效片段的长度。跨过这段后再向左一格,候选匹配下标为 j=i−dp[i−1]−1。只有 j≥0 且 s[j] 是左括号时才能闭合,新长度等于内部 dp[i−1]、当前这一对的 2,以及外层左括号之前相邻前段的 dp[j−1] 之和;j=0 时最后一项按 0 处理。
i 从左向右扫描,所有依赖都位于已处理区域。图中 i=5 找到 j=2,得到 dp[5]=dp[4]+2+dp[1]=6,其中最后一项把开头的 () 接了回来。每处理一个右括号后更新 ans;末尾再出现左括号只会让那个结尾长度为 0,不会清空历史答案。返回 ans,空串也自然得到 0。
dp[i] 是必须以 i 结尾的最长有效长度。右括号遇到相邻左括号就直接成对;遇到右括号则跨过已知有效后缀寻找左括号,配对后再连接左侧相邻有效前段。所有依赖都在 i 左侧,按下标递增即可使用已知值。
图解
| 字符下标 k | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| s[k] | ( | ) | ( | ( | ) | ) | ( |
| dp[k] | 0 | · | · | · | · | · | · |
- s
- ()(())(
- dp(源码)
- [0, 0, 0, 0, 0, 0, 0]
- ans
- 0
| 字符下标 k | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| s[k] | ( | ) | ( | ( | ) | ) | ( |
| dp[k] | 0 | 2 | · | · | · | · | · |
| 本次算式 | 内部长度 | 当前新的一对 | 更左前段 | 合计 |
|---|---|---|---|---|
| dp[1]=2+0 | 0(直接形成一对) | 2 | 0 | 2 |
- i
- 1
- 配对左括号下标
- 0
- dp[i]
- 2
- ans
- 2
| 字符下标 k | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| s[k] | ( | ) | ( | ( | ) | ) | ( |
| dp[k] | 0 | 2 | 0 | · | · | · | · |
- i
- 2
- s[i]
- (
- dp[i]
- 0
- ans
- 2
| 字符下标 k | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| s[k] | ( | ) | ( | ( | ) | ) | ( |
| dp[k] | 0 | 2 | 0 | 0 | · | · | · |
- i
- 3
- s[i]
- (
- dp[i]
- 0
- ans
- 2
| 字符下标 k | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| s[k] | ( | ) | ( | ( | ) | ) | ( |
| dp[k] | 0 | 2 | 0 | 0 | 2 | · | · |
| 本次算式 | 内部长度 | 当前新的一对 | 更左前段 | 合计 |
|---|---|---|---|---|
| dp[4]=2+0 | 0(直接形成一对) | 2 | 0 | 2 |
- i
- 4
- 配对左括号下标
- 3
- dp[i]
- 2
- ans
- 2
| 字符下标 k | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| s[k] | ( | ) | ( | ( | ) | ) | ( |
| dp[k] | 0 | 2 | 0 | 0 | 2 | · | · |
- i
- 5
- dp[i−1]
- 2
- j
- 2
- dp[i](尚未写)
- 0
- ans
- 2
| 字符下标 k | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| s[k] | ( | ) | ( | ( | ) | ) | ( |
| dp[k] | 0 | 2 | 0 | 0 | 2 | · | · |
| 本次算式 | 内部长度 | 当前新的一对 | 更左前段 | 合计 |
|---|---|---|---|---|
| dp[5] 的三个相邻部分 | 2 | 2 | 2 | 6 |
- i / j
- 5 / 2
- s[j]
- (
- 内部 dp[i−1]
- 2
- 前段 dp[j−1]
- 2
- ans(写入前)
- 2
| 字符下标 k | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| s[k] | ( | ) | ( | ( | ) | ) | ( |
| dp[k] | 0 | 2 | 0 | 0 | 2 | 6 | · |
| 本次算式 | 内部长度 | 当前新的一对 | 更左前段 | 合计 |
|---|---|---|---|---|
| dp[5]=2+2+2 | 2 | 2 | 2 | 6 |
- i / j
- 5 / 2
- dp[i]
- 6
- ans
- 6
- dp(源码)
- [0, 2, 0, 0, 2, 6, 0]
| 字符下标 k | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| s[k] | ( | ) | ( | ( | ) | ) | ( |
| dp[k] | 0 | 2 | 0 | 0 | 2 | 6 | 0 |
- i
- 6
- s[i]
- (
- dp[i]
- 0
- ans
- 6
| 字符下标 k | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| s[k] | ( | ) | ( | ( | ) | ) | ( |
| dp[k] | 0 | 2 | 0 | 0 | 2 | 6 | 0 |
- dp
- [0, 2, 0, 0, 2, 6, 0]
- dp[-1]
- 0
- ans / 返回值
- 6
选择步骤 10 步
变量与图例
i=5 时先跨过内部 "()" 找 j,再用 j−1 接回开头 "()";最后一个左括号用于检验 dp 与 ans 的区别。
- i
- 当前扫描的字符下标;循环从 1 开始。
- dp[i]
- 必须以字符 i 结尾的最长有效连续子串长度。
- j
- 跨过 dp[i−1] 个内部字符后,再左移一格的候选匹配下标。
- dp[j−1]
- 外层左括号之前、恰好相邻的有效前段长度;j=0 时按 0。
- ans
- 所有已处理结尾中的最大长度,遇到左括号也不清零。
算法步骤
- 建立全为 0 的 dp,令 ans=0,从下标 i=1 开始扫描。
- 若 s[i] 是左括号,保持 dp[i]=0 并继续下一位置。
- 若前一字符是左括号,形成相邻的一对,写入 2 加上存在时的 dp[i−2]。
- 否则计算 j=i−dp[i−1]−1;仅在 j 合法且 s[j] 为左括号时,写入内部长度、当前一对与存在时的 dp[j−1] 之和。
- 用每个右括号位置的 dp[i] 更新 ans,扫描完成后返回 ans。
代码(Python)
查看完整 Python 代码以右括号结尾的动态规划 · 与上方图解对应
dp[i−1] 告诉我们要跨过多少个字符;j 是跨过完整有效后缀后紧挨着的字符。配对成功还必须加 dp[j−1],才能把 ()(()) 这种左右相接的结构合并。
class Solution:
def longestValidParentheses(self, s: str) -> int:
dp = [0] * len(s)
ans = 0
for i in range(1, len(s)):
if s[i] != ")":
continue
if s[i - 1] == "(":
dp[i] = 2 + (dp[i - 2] if i >= 2 else 0)
else:
j = i - dp[i - 1] - 1
if j >= 0 and s[j] == "(":
dp[i] = dp[i - 1] + 2 + (dp[j - 1] if j > 0 else 0)
ans = max(ans, dp[i])
return ans运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert Solution().longestValidParentheses(")()())") == 4
assert Solution().longestValidParentheses("()(())") == 6
assert Solution().longestValidParentheses("(()") == 2
assert Solution().longestValidParentheses("") == 0
assert Solution().longestValidParentheses("(((") == 0
复杂度
- 时间
- O(n),n 为字符串长度,每个位置通过已知有效长度直接跳到匹配候选位置。
- 空间
- 辅助空间 O(n),dp 保存每个结尾的最长长度。
易错点与边界
- dp 记录长度,j=i−dp[i−1]−1 才是候选左括号下标,不是 i−dp[i−1]。
- 要检查 j≥0;Python 负索引会读到字符串尾部,悄悄造成错误匹配。
- 配对后漏加 dp[j−1] 会把 ()(()) 的 6 错算为 4。
- 空字符串、全左括号或全右括号的答案都是 0;未形成有效串时返回初始 ans。
主解之后,再比较与迁移。
其他解法 2
下标栈与非法边界时间 O(n),n 为字符串长,每个下标至多入栈、出栈一次。
stack 保存下标:栈底是当前可用区域左侧的非法边界,初始为 −1,边界上方保存尚未匹配的左括号下标。遇右括号先弹栈;若弹空,当前右括号没有可配的左括号,就把当前下标作为新边界。若栈仍非空,它的顶部是当前有效后缀左边紧邻的位置,因此 i−stack[-1] 是以 i 结尾的最长有效长度。此法适合从熟悉的括号匹配栈推导最长长度,必须保存下标而不是只保存括号字符。
- 初始化 stack=[−1]、ans=0,枚举每个字符 ch 与下标 i。
- 遇到左括号,将 i 压栈,等待后续匹配。
- 遇到右括号先弹出一个下标;若栈空,把当前 i 压入作为新的非法边界。
- 若弹栈后仍非空,用 i−stack[-1] 更新 ans。
- 扫描结束返回 ans。
class Solution:
def longestValidParentheses(self, s: str) -> int:
stack = [-1]
ans = 0
for i, ch in enumerate(s):
if ch == "(":
stack.append(i)
else:
stack.pop()
if not stack:
stack.append(i)
else:
ans = max(ans, i - stack[-1])
return ans复杂度
- 时间
- O(n),n 为字符串长,每个下标至多入栈、出栈一次。
- 空间
- 辅助空间 O(n),全为左括号时栈可保存 n+1 个下标。
这个方法的边界
- 栈中必须保存下标而非只保存括号字符,否则不能计算连续长度。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert Solution().longestValidParentheses(")()())") == 4
assert Solution().longestValidParentheses("()(())") == 6
assert Solution().longestValidParentheses("(()") == 2
assert Solution().longestValidParentheses("") == 0
assert Solution().longestValidParentheses("(((") == 0
双向计数扫描时间 O(n),n 为字符串长度,做两次线性扫描。
left、right 统计当前扫描段内的左右括号数。正向扫描时,计数相等就得到有效段长度 2×right;right>left 表示出现无法跨过的多余右括号,两个计数归零。只有这一遍会漏掉 (() 这样的左括号过多情形,因此清空计数后反向再扫:相等时仍更新长度,left>right 时归零。ans 在两遍之间保留。它用常数辅助空间换取两个方向的推导,适合面试中进一步追问空间优化。
- 初始化 ans=0、left=right=0,从左到右扫描并累计括号数量。
- 若 left==right,用 2×right 更新 ans;若 right>left,把两个计数清零。
- 第一遍结束后重新将 left、right 清零,保留 ans。
- 通过 reversed(s) 从右到左扫描;相等时用 2×left 更新 ans,left>right 时清零。
- 返回两遍得到的最大长度 ans。
class Solution:
def longestValidParentheses(self, s: str) -> int:
ans = 0
left = right = 0
for ch in s:
if ch == "(":
left += 1
else:
right += 1
if left == right:
ans = max(ans, 2 * right)
elif right > left:
left = right = 0
left = right = 0
for ch in reversed(s):
if ch == "(":
left += 1
else:
right += 1
if left == right:
ans = max(ans, 2 * left)
elif left > right:
left = right = 0
return ans复杂度
- 时间
- O(n),n 为字符串长度,做两次线性扫描。
- 空间
- 辅助空间 O(1);reversed(s) 是反向迭代器,不像 s[::-1] 那样复制整个字符串。
这个方法的边界
- 双向计数的第二遍要重置计数,且非法条件左右互换;空串和单一括号串答案均为 0。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
assert Solution().longestValidParentheses(")()())") == 4
assert Solution().longestValidParentheses("()(())") == 6
assert Solution().longestValidParentheses("(()") == 2
assert Solution().longestValidParentheses("") == 0
assert Solution().longestValidParentheses("(((") == 0
预测练习
i=5 时已经找到 s[2]="(",为何 dp[5] 不能只算 dp[4]+2=4?
一点提示
画出外层左括号 j=2 的左边两个字符。
答案与理由
左边下标 [0,1] 是一段相邻有效的 "()",长度 dp[1]=2。完整转移是内部 dp[4]=2,加当前一对 2,再加前段 dp[j−1]=2,得到 6。
面试追问与解法取舍
若还要返回最长子串,在最大值更新为 length 时记录区间 [i−length+1,i+1),DP 与栈都可直接得到结尾 i。双向计数能省空间,但下标解释不如 DP 与栈直观;不要用“匹配对数×2”替代答案,因为多余括号可能把匹配片段隔开。
模板迁移 / 相似题
试着不看代码,复述这三点
- j=i−dp[i−1]−1:先跳过内部,再定位可能配对的左括号。
- 配对成功后再加 dp[j−1],才能把相邻有效块连起来。
- 检查 j≥0 防止负下标误配;返回 ans 而不是最后一个 dp。
62. 不同路径 中等
一句话题意
在 m 行 n 列的无障碍网格中,只向右或向下移动,求左上角到右下角的不同路径数。
m = 4,n = 3;只能向右或向下,从左上角到右下角共有 10 条路径。
生活化理解
把每个路口看成邮件汇合站。到某站的邮件只可能来自上方站或左方站;每封邮件代表一条不同的行走历史,两路邮件相加就是该站的路径数。
一维滚动动态规划
上方旧值与左方新值的含义明确,只保留较短边的一行计数即可完成递推,适合面试讲解与复现。核心思路
到达某一格的最后一步只能来自上方或左方。按最后一步分类,两组路径互不重叠,又覆盖全部走法,因此当前格的路径数等于上方路径数加左方路径数。第一行只能一直向右,第一列只能一直向下,它们的路径数都是 1;起点也有那一条尚未移动的路径。
源码只保存一行 dp。若 n>m,先交换两条边,让 n 成为较短边;网格转置只是把向右与向下互换,路径数不变。初始化 dp=[1]*n,表示第一行已经完成。外层循环推进到下一行,内层令 j 从 1 正序递增:更新前 dp[j] 仍是上一行的上方旧值,而 dp[j-1] 已是本行的左方新值。执行 dp[j] += dp[j-1] 后,当前牌便代表本行当前格。
这个从左到右的顺序保证每次都读到正确的两种来源;若反向更新,左邻牌仍属于上一行,递推含义就变了。第 0 列不参与内层循环,始终保留唯一走法 1。图中的完整网格是概念展开,程序没有建立二维表;在 4×3 的例子中,最后一行得到 [1,4,10],返回最后一项 10。
进入每个内层循环时,dp[j] 仍是上一行到达第 j 列的路径数;dp[j-1] 已经是本行左边格子的路径数。最后一步只能从上或左进入,两类路径互不重叠,所以相加即可。
图解
| 位置 | 列 0 | 列 1 | 列 2 |
|---|---|---|---|
| 行 0 | 1 | 1 | 1 |
| 行 1 | 1 | · | · |
| 行 2 | 1 | · | · |
| 行 3 | 1 | · | · |
- m
- 4
- n
- 3
- n > m
- False
| 位置 | 列 0 | 列 1 | 列 2 |
|---|---|---|---|
| 行 0 | 1 | 1 | 1 |
| 行 1 | 1 | · | · |
| 行 2 | 1 | · | · |
| 行 3 | 1 | · | · |
| 下标 | 0 | 1 | 2 |
|---|---|---|---|
| dp | 1 | 1 | 1 |
- dp
- [1, 1, 1]
- 即将处理的行 _
- 1
| 位置 | 列 0 | 列 1 | 列 2 |
|---|---|---|---|
| 行 0 | 1 | 1 | 1 |
| 行 1 | 1 | 2 | · |
| 行 2 | 1 | · | · |
| 行 3 | 1 | · | · |
| 时刻 | dp[0] | dp[1] | dp[2] |
|---|---|---|---|
| 更新前 | 1 | 1 | 1 |
| 更新后 | 1 | 2 | 1 |
- 行 _ / 列 j
- 1 / 1
- 上方旧值
- 1
- 左方新值
- 1
- dp
- [1, 2, 1]
| 位置 | 列 0 | 列 1 | 列 2 |
|---|---|---|---|
| 行 0 | 1 | 1 | 1 |
| 行 1 | 1 | 2 | 3 |
| 行 2 | 1 | · | · |
| 行 3 | 1 | · | · |
| 时刻 | dp[0] | dp[1] | dp[2] |
|---|---|---|---|
| 更新前 | 1 | 2 | 1 |
| 更新后 | 1 | 2 | 3 |
- 行 _ / 列 j
- 1 / 2
- 上方旧值
- 1
- 左方新值
- 2
- dp
- [1, 2, 3]
| 位置 | 列 0 | 列 1 | 列 2 |
|---|---|---|---|
| 行 0 | 1 | 1 | 1 |
| 行 1 | 1 | 2 | 3 |
| 行 2 | 1 | 3 | · |
| 行 3 | 1 | · | · |
| 时刻 | dp[0] | dp[1] | dp[2] |
|---|---|---|---|
| 更新前 | 1 | 2 | 3 |
| 更新后 | 1 | 3 | 3 |
- 行 _ / 列 j
- 2 / 1
- 上方旧值
- 2
- 左方新值
- 1
- dp
- [1, 3, 3]
| 位置 | 列 0 | 列 1 | 列 2 |
|---|---|---|---|
| 行 0 | 1 | 1 | 1 |
| 行 1 | 1 | 2 | 3 |
| 行 2 | 1 | 3 | 6 |
| 行 3 | 1 | · | · |
| 时刻 | dp[0] | dp[1] | dp[2] |
|---|---|---|---|
| 更新前 | 1 | 3 | 3 |
| 更新后 | 1 | 3 | 6 |
- 行 _ / 列 j
- 2 / 2
- 上方旧值
- 3
- 左方新值
- 3
- dp
- [1, 3, 6]
| 位置 | 列 0 | 列 1 | 列 2 |
|---|---|---|---|
| 行 0 | 1 | 1 | 1 |
| 行 1 | 1 | 2 | 3 |
| 行 2 | 1 | 3 | 6 |
| 行 3 | 1 | 4 | · |
| 时刻 | dp[0] | dp[1] | dp[2] |
|---|---|---|---|
| 更新前 | 1 | 3 | 6 |
| 更新后 | 1 | 4 | 6 |
- 行 _ / 列 j
- 3 / 1
- 上方旧值
- 3
- 左方新值
- 1
- dp
- [1, 4, 6]
| 位置 | 列 0 | 列 1 | 列 2 |
|---|---|---|---|
| 行 0 | 1 | 1 | 1 |
| 行 1 | 1 | 2 | 3 |
| 行 2 | 1 | 3 | 6 |
| 行 3 | 1 | 4 | 10 |
| 时刻 | dp[0] | dp[1] | dp[2] |
|---|---|---|---|
| 更新前 | 1 | 4 | 6 |
| 更新后 | 1 | 4 | 10 |
- 行 _ / 列 j
- 3 / 2
- 上方旧值
- 6
- 左方新值
- 4
- dp
- [1, 4, 10]
| 位置 | 列 0 | 列 1 | 列 2 |
|---|---|---|---|
| 行 0 | 1 | 1 | 1 |
| 行 1 | 1 | 2 | 3 |
| 行 2 | 1 | 3 | 6 |
| 行 3 | 1 | 4 | 10 |
- dp
- [1, 4, 10]
- return dp[-1]
- 10
选择步骤 9 步
变量与图例
把窄数组看成一排会更新的计数牌:当前牌的旧数来自上方,左邻牌的新数来自本行。
- m / n
- 网格行数 / 列数;必要时交换,使 dp 长度为较小边长。
- _
- 外层循环的当前行号;源码用下划线命名,因为不需要拿它索引二维数组。
- j
- 当前从左向右更新的列号。
- dp[j]
- 更新前是上方旧值,更新后变成本行当前位置的路径数。
- dp[j-1]
- 左方已经更新的新值;第 0 列始终为 1。
算法步骤
- 若 n>m,交换 m、n,使滚动数组沿较短边展开。
- 初始化 dp=[1]*n,表示首行每格都只有一种走法。
- 从第 2 行开始逐行推进;每行第 0 列保持 1,令 j 从 1 遍历到 n−1。
- 读取 dp[j] 的上方旧值与 dp[j−1] 的本行左方新值,相加写回 dp[j]。
- 所有行完成后返回 dp[-1];只有一行或一列时,相关循环自然为空,答案仍为 1。
代码(Python)
查看完整 Python 代码一维滚动动态规划 · 与上方图解对应
把较短维度作为列,dp[j] 原地融合上方旧值和左方新值;利用网格转置对称性压缩空间。
class Solution:
def uniquePaths(self, m: int, n: int) -> int:
if n > m:
m, n = n, m
dp = [1] * n
for _ in range(1, m):
for j in range(1, n):
dp[j] += dp[j - 1]
return dp[-1]运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = Solution()
assert s.uniquePaths(3, 7) == 28
assert s.uniquePaths(3, 4) == 10
assert s.uniquePaths(1, 1) == 1
assert s.uniquePaths(8, 1) == 1
复杂度
- 时间
- O(mn) 次加法。若放宽范围至大整数,还需计入整数位数对应的加法成本。
- 空间
- O(min(m,n)) 个整数;题目定长算术模型下为 O(min(m,n)) 空间。
易错点与边界
- 路径数量相加,不是取最大值或最小值。
- 首行首列都为 1,不能把起点初始化为 0。
- 滚动数组必须从左向右,才能使用本行已更新的左值。
- 合法输入 m、n 均为正数;单行、单列都只有一条路径。
主解之后,再比较与迁移。
其他解法 1
二维动态规划时间 O(mn) 次加法;m、n 为行列数。按题目整数范围采用定长算术模型。
用 dp[i][j] 直接保存走到第 i 行、第 j 列的路径数,所有位置都保留,便于观察整张表和检查递推关系。源码先把整个 m×n 表初始化为 1,其中首行、首列是确定的边界,内部的 1 只是随后会被覆盖的初值。按行扫描内部格子,读取已完成的上方 dp[i−1][j] 与左方 dp[i][j−1] 相加;因为最后一步不同,两种来源可以直接相加。它与滚动主解计算相同的状态,但需要保存 O(mn) 个整数。
- 创建 m 行、n 列的 dp,并全部初始化为 1,使首行和首列满足边界。
- 令 i 从 1 到 m−1,再令 j 从 1 到 n−1,按行正序访问内部格子。
- 执行 dp[i][j]=dp[i−1][j]+dp[i][j−1],覆盖当前位置的初值。
- 返回右下角 dp[-1][-1];不需要枚举或保存具体路径。
class Solution:
def uniquePaths(self, m: int, n: int) -> int:
dp = [[1] * n for _ in range(m)]
for i in range(1, m):
for j in range(1, n):
dp[i][j] = dp[i - 1][j] + dp[i][j - 1]
return dp[-1][-1]复杂度
- 时间
- O(mn) 次加法;m、n 为行列数。按题目整数范围采用定长算术模型。
- 空间
- O(mn) 辅助空间。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = Solution()
assert s.uniquePaths(3, 7) == 28
assert s.uniquePaths(3, 4) == 10
assert s.uniquePaths(1, 1) == 1
assert s.uniquePaths(1, 8) == 1
预测练习
更新最后一行的 j=2 前,dp 是 [1,4,6]。结果应是 6+4,还是 6+3?
一点提示
dp[1] 已被这一行的 j=1 更新。
答案与理由
应为 6+4=10:6 来自上方,4 来自本行左边。使用上一行的 3 会漏掉经过本行左邻格的路径。
面试追问与解法取舍
每条路径恰有 m−1 次向下、n−1 次向右,所以也可用 math.comb(m+n−2, m−1)。它避免填表,但不能笼统称作 O(1):组合数计算涉及大整数乘除。若增加障碍,组合式通常失效,而 DP 只需把障碍格置 0。
模板迁移 / 相似题
试着不看代码,复述这三点
- 先说明“最后一步来自哪里”,再写路径计数的加法。
- 滚动数组从左向右更新,才能同时拿到上方旧值和左方新值。
- 交换两条边不会改变路径数,却能把额外空间降到 O(min(m,n))。
64. 最小路径和 中等
一句话题意
在非负整数网格中只向右或向下移动,求从左上角到右下角、包含两端格子的最小路径和。
grid = [[1,3,1],[1,5,1],[4,2,1]];最小路径和为 7。
生活化理解
把网格看成收费街区,每踏入一个格子就交该格费用。每个路口只记“到这里最省一共花多少”,之后的收费与来路无关,因此昂贵的历史路线可以舍弃。
独立二维 DP
把原始费用与累计最优费用分开,转移和边界容易核对,也能保留调用者的输入网格。核心思路
dp[i][j] 表示到达当前格的最小累计费用,已经包含 grid[i][j]。最后一步只能来自上方或左方,所以先比较两条最优前缀的费用,再支付当前格的费用:dp[i][j]=min(dp[i-1][j],dp[i][j-1])+grid[i][j]。最优路径的前缀也必须最优,否则换成更便宜的前缀,就能让整条路径更便宜。
原始网格 grid 只存每格价格,独立的 dp 才存累计账本。起点直接填 grid[0][0];首行只能从左来,要逐格累加。随后每进入一行,先算只能从上来的第 0 列,再从左到右计算内部格。这样每次读取的上、左来源都已完成,不会误用预分配的 0。
当前格只付费一次,比较的也是累计费用,而非两个邻居各自的原始价格。例如图中的中心格费用为 5,上方累计 4、左方累计 2,所以应填 min(4,2)+5=7。表格最终右下角为 7;源码返回这个最小费用,图中展示的路线只是解释结果,并未由代码额外保存。
dp[i][j] 是从起点到当前格的最小累计和,包含当前格的费用。填表前,它依赖的上格与左格已经确定;取二者较小值再加当前费用,就不会漏掉任何合法最优路径。
图解
| 位置 | 列 0 | 列 1 | 列 2 |
|---|---|---|---|
| 行 0 | 1 | 3 | 1 |
| 行 1 | 1 | 5 | 1 |
| 行 2 | 4 | 2 | 1 |
| 位置 | 列 0 | 列 1 | 列 2 |
|---|---|---|---|
| 行 0 | · | · | · |
| 行 1 | · | · | · |
| 行 2 | · | · | · |
- m / n
- 3 / 3
- 代码 dp 初值
- 3×3 个 0
| 位置 | 列 0 | 列 1 | 列 2 |
|---|---|---|---|
| 行 0 | 1 | 3 | 1 |
| 行 1 | 1 | 5 | 1 |
| 行 2 | 4 | 2 | 1 |
| 位置 | 列 0 | 列 1 | 列 2 |
|---|---|---|---|
| 行 0 | 1 | · | · |
| 行 1 | · | · | · |
| 行 2 | · | · | · |
- i / j
- 0 / 0
- 当前费用
- 1
- dp[0][0]
- 1
| 位置 | 列 0 | 列 1 | 列 2 |
|---|---|---|---|
| 行 0 | 1 | 3 | 1 |
| 行 1 | 1 | 5 | 1 |
| 行 2 | 4 | 2 | 1 |
| 位置 | 列 0 | 列 1 | 列 2 |
|---|---|---|---|
| 行 0 | 1 | 4 | · |
| 行 1 | · | · | · |
| 行 2 | · | · | · |
- i / j
- 0 / 1
- 当前费用
- 3
- dp[0][1]
- 4
| 位置 | 列 0 | 列 1 | 列 2 |
|---|---|---|---|
| 行 0 | 1 | 3 | 1 |
| 行 1 | 1 | 5 | 1 |
| 行 2 | 4 | 2 | 1 |
| 位置 | 列 0 | 列 1 | 列 2 |
|---|---|---|---|
| 行 0 | 1 | 4 | 5 |
| 行 1 | · | · | · |
| 行 2 | · | · | · |
- i / j
- 0 / 2
- 当前费用
- 1
- dp[0][2]
- 5
| 位置 | 列 0 | 列 1 | 列 2 |
|---|---|---|---|
| 行 0 | 1 | 3 | 1 |
| 行 1 | 1 | 5 | 1 |
| 行 2 | 4 | 2 | 1 |
| 位置 | 列 0 | 列 1 | 列 2 |
|---|---|---|---|
| 行 0 | 1 | 4 | 5 |
| 行 1 | 2 | · | · |
| 行 2 | · | · | · |
- i / j
- 1 / 0
- 当前费用
- 1
- dp[1][0]
- 2
| 位置 | 列 0 | 列 1 | 列 2 |
|---|---|---|---|
| 行 0 | 1 | 3 | 1 |
| 行 1 | 1 | 5 | 1 |
| 行 2 | 4 | 2 | 1 |
| 位置 | 列 0 | 列 1 | 列 2 |
|---|---|---|---|
| 行 0 | 1 | 4 | 5 |
| 行 1 | 2 | 7 | · |
| 行 2 | · | · | · |
- i / j
- 1 / 1
- 当前费用
- 5
- dp[1][1]
- 7
| 位置 | 列 0 | 列 1 | 列 2 |
|---|---|---|---|
| 行 0 | 1 | 3 | 1 |
| 行 1 | 1 | 5 | 1 |
| 行 2 | 4 | 2 | 1 |
| 位置 | 列 0 | 列 1 | 列 2 |
|---|---|---|---|
| 行 0 | 1 | 4 | 5 |
| 行 1 | 2 | 7 | 6 |
| 行 2 | · | · | · |
- i / j
- 1 / 2
- 当前费用
- 1
- dp[1][2]
- 6
| 位置 | 列 0 | 列 1 | 列 2 |
|---|---|---|---|
| 行 0 | 1 | 3 | 1 |
| 行 1 | 1 | 5 | 1 |
| 行 2 | 4 | 2 | 1 |
| 位置 | 列 0 | 列 1 | 列 2 |
|---|---|---|---|
| 行 0 | 1 | 4 | 5 |
| 行 1 | 2 | 7 | 6 |
| 行 2 | 6 | · | · |
- i / j
- 2 / 0
- 当前费用
- 4
- dp[2][0]
- 6
| 位置 | 列 0 | 列 1 | 列 2 |
|---|---|---|---|
| 行 0 | 1 | 3 | 1 |
| 行 1 | 1 | 5 | 1 |
| 行 2 | 4 | 2 | 1 |
| 位置 | 列 0 | 列 1 | 列 2 |
|---|---|---|---|
| 行 0 | 1 | 4 | 5 |
| 行 1 | 2 | 7 | 6 |
| 行 2 | 6 | 8 | · |
- i / j
- 2 / 1
- 当前费用
- 2
- dp[2][1]
- 8
| 位置 | 列 0 | 列 1 | 列 2 |
|---|---|---|---|
| 行 0 | 1 | 3 | 1 |
| 行 1 | 1 | 5 | 1 |
| 行 2 | 4 | 2 | 1 |
| 位置 | 列 0 | 列 1 | 列 2 |
|---|---|---|---|
| 行 0 | 1 | 4 | 5 |
| 行 1 | 2 | 7 | 6 |
| 行 2 | 6 | 8 | 7 |
- i / j
- 2 / 2
- 当前费用
- 1
- dp[2][2]
- 7
| 位置 | 列 0 | 列 1 | 列 2 |
|---|---|---|---|
| 行 0 | 1 | 3 | 1 |
| 行 1 | 1 | 5 | 1 |
| 行 2 | 4 | 2 | 1 |
| 位置 | 列 0 | 列 1 | 列 2 |
|---|---|---|---|
| 行 0 | 1 | 4 | 5 |
| 行 1 | 2 | 7 | 6 |
| 行 2 | 6 | 8 | 7 |
- 最优费用
- 7
- 示例路径费用
- 1 + 3 + 1 + 1 + 1 = 7
选择步骤 11 步
变量与图例
每块地砖有过路费:来到当前格,只比较上方和左方已经付过的总费用,再付当前格的钱。
- grid[i][j]
- 当前格原始费用,整个演示中不修改。
- dp[i][j]
- 走到当前格的最小累计费用。
- i / j
- 当前行 / 列,先完成首行,再逐行从左向右计算。
- m / n
- 3 行、3 列;只能向右或向下移动。
算法步骤
- 读取 m、n,创建全 0 的独立 dp 表,并令 dp[0][0]=grid[0][0]。
- 从左到右填写首行:dp[0][j]=dp[0][j−1]+grid[0][j]。
- 依次进入第 i 行,先写入 dp[i][0]=dp[i−1][0]+grid[i][0]。
- 在该行令 j 从 1 到 n−1,取上方、左方累计费用的较小值,再加当前格费用。
- 所有格子完成后返回 dp[-1][-1],原始 grid 保持不变。
代码(Python)
查看完整 Python 代码独立二维 DP · 与上方图解对应
dp 保存累计最小值,不改变 grid,便于保留原始成本或以后回溯路线。
from typing import List
class Solution:
def minPathSum(self, grid: List[List[int]]) -> int:
m, n = len(grid), len(grid[0])
dp = [[0] * n for _ in range(m)]
dp[0][0] = grid[0][0]
for j in range(1, n):
dp[0][j] = dp[0][j - 1] + grid[0][j]
for i in range(1, m):
dp[i][0] = dp[i - 1][0] + grid[i][0]
for j in range(1, n):
dp[i][j] = min(dp[i - 1][j], dp[i][j - 1]) + grid[i][j]
return dp[-1][-1]运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = Solution()
grid = [[1,3,1],[1,5,1],[4,2,1]]
before = [r[:] for r in grid]
assert s.minPathSum(grid) == 7
assert grid == before
assert s.minPathSum([[0]]) == 0
assert s.minPathSum([[1,2,3]]) == 6
assert s.minPathSum([[1],[2],[3]]) == 6
复杂度
- 时间
- O(mn),m、n 为网格行列数,每格计算一次。
- 空间
- O(mn) 辅助空间,未修改输入。
易错点与边界
- 路径和包含起点和终点,起点不能置为 0。
- 首行和首列只有一条来路,直接访问负索引会错误地读到末行或末列。
- 本题网格非空,零费用是合法值,不代表无法到达。
主解之后,再比较与迁移。
其他解法 1
原地动态规划时间 O(mn),m、n 为行列数。
直接把 grid 已处理的格子改写成累计费用,省去独立 dp 表。按行从左到右访问时,当前格仍是原始价格,上方和左方已经是最优累计费用,因此可以就地执行加法。起点不更新;首行只能加左邻,首列只能加上邻,内部格再加两者的较小值。这个版本使用 O(1) 辅助空间,但会改变调用者的输入;如果还需要原网格,先复制再计算仍要付出 O(mn) 的复制空间。
- 读取网格尺寸,按 i 从 0 到 m−1、j 从 0 到 n−1 扫描原 grid。
- 遇到起点 (0,0) 直接跳过,保留它自己的费用。
- 处于首行时加左方累计费用;处于首列时加上方累计费用。
- 内部格把上方和左方累计费用的较小值加到自身,覆盖原始费用。
- 返回 grid[-1][-1];此时网格内容已经变成累计费用表。
from typing import List
class Solution:
def minPathSum(self, grid: List[List[int]]) -> int:
m, n = len(grid), len(grid[0])
for i in range(m):
for j in range(n):
if i == 0 and j == 0:
continue
if i == 0:
grid[i][j] += grid[i][j - 1]
elif j == 0:
grid[i][j] += grid[i - 1][j]
else:
grid[i][j] += min(grid[i - 1][j], grid[i][j - 1])
return grid[-1][-1]复杂度
- 时间
- O(mn),m、n 为行列数。
- 空间
- O(1) 辅助空间;复用并修改输入网格。
这个方法的边界
- 原地算法会改输入;同一网格连续调用两次不会重算同一道原题。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = Solution()
grid = [[1,3,1],[1,5,1],[4,2,1]]
assert s.minPathSum(grid) == 7
assert grid == [[1,4,5],[2,7,6],[6,8,7]]
assert s.minPathSum([[0]]) == 0
assert s.minPathSum([[1,2,3]]) == 6
assert s.minPathSum([[1],[2],[3]]) == 6
预测练习
走到中心 (1,1) 时,上方累计 4、左方累计 2、当前费用 5,应填几?
一点提示
不要只挑原始相邻格的费用,要比较累计 dp。
答案与理由
填 min(4,2)+5=7。原图左邻费用是 1,但到达那里已经花了 2。
面试追问与解法取舍
只返回最小和时也可用 O(n) 滚动数组且保留输入。若要具体路线,可以保留二维 DP,从终点向较小的上方或左方前驱倒走,代价相等时任选一条。贪心每次选择较便宜的相邻格不正确,因为它没有比较后续累计费用。
模板迁移 / 相似题
试着不看代码,复述这三点
- 最小路径 DP 比较的是累计费用,不是下一步单个格子的价格。
- 首行与首列只有一个来源,先把这两类边界的含义说明白。
- 独立 dp 保留原始 grid;若改为原地算法,要明确输入会被累计费用覆盖。
5. 最长回文子串 中等
一句话题意
找出字符串中最长的连续回文子串;若有多个同样长的答案,返回任意一个。
s = "abba";最长回文子串是 "abba"。
生活化理解
回文像一面镜子:奇数长度把一个字符放在镜面上,偶数长度把镜面放在两个字符之间。左右两人同步向外走,只要看到的字符一样,镜子的覆盖范围就还能扩大。
区间动态规划
按区间长度递增能清楚展示回文的内外依赖;时间与辅助空间均为 O(n²),适合作为此专题的主线,空间更省的中心扩展完整保留在其他解法中。核心思路
定义 dp[i][j] 表示闭区间 s[i:j+1] 是否回文,左右端点都包含在区间中。若两端字符不同,整段一定不是回文;若两端相同,长度为 2 时已经成立,更长时还要确认去掉两端后的 dp[i+1][j-1] 为真。这里求的是连续子串,不能跳过中间字符来拼成回文。
先把所有单字符区间 dp[i][i] 设为 True,再按 length 从 2 到 n 检查区间。每个长度下枚举左端 i,由 j=i+length-1 算出右端。内部区间的长度小 2,因此处理外层前一定已算好;长度 2 则通过 length<=2 的短路条件成立,不会访问反向的内部区间。图中的 · 表示尚未推演,源码实际把这些布尔格预先存为 False。
start 与 best 分别保存已确认的最长回文起点和长度,初始为 0、1。每确认一个更长的回文才更新它们,最后统一切片返回。图中先确认 bb,再检查整段 abba:两端 a 相等,内部 dp[1][2] 已为真,于是把最佳长度从 2 提升到 4。
dp[i][j] 表示闭区间 s[i:j+1] 是否回文。按区间长度由短到长填表,检查长度大于 2 的区间时,内部 dp[i+1][j-1] 已经计算;start/best 始终记录已确认的最长回文。
图解
| 左端 i / 右端 j | 0: a | 1: b | 2: b | 3: a |
|---|---|---|---|---|
| 0: a | · | · | · | · |
| 1: b | — | · | · | · |
| 2: b | — | — | · | · |
| 3: a | — | — | — | · |
- n
- 4
- start / best
- 0 / 1
| 左端 i / 右端 j | 0: a | 1: b | 2: b | 3: a |
|---|---|---|---|---|
| 0: a | T | · | · | · |
| 1: b | — | · | · | · |
| 2: b | — | — | · | · |
| 3: a | — | — | — | · |
- i
- 0
- 子串
- a
- start / best
- 0 / 1
| 左端 i / 右端 j | 0: a | 1: b | 2: b | 3: a |
|---|---|---|---|---|
| 0: a | T | · | · | · |
| 1: b | — | T | · | · |
| 2: b | — | — | · | · |
| 3: a | — | — | — | · |
- i
- 1
- 子串
- b
- start / best
- 0 / 1
| 左端 i / 右端 j | 0: a | 1: b | 2: b | 3: a |
|---|---|---|---|---|
| 0: a | T | · | · | · |
| 1: b | — | T | · | · |
| 2: b | — | — | T | · |
| 3: a | — | — | — | · |
- i
- 2
- 子串
- b
- start / best
- 0 / 1
| 左端 i / 右端 j | 0: a | 1: b | 2: b | 3: a |
|---|---|---|---|---|
| 0: a | T | · | · | · |
| 1: b | — | T | · | · |
| 2: b | — | — | T | · |
| 3: a | — | — | — | T |
- i
- 3
- 子串
- a
- start / best
- 0 / 1
| 左端 i / 右端 j | 0: a | 1: b | 2: b | 3: a |
|---|---|---|---|---|
| 0: a | T | F | · | · |
| 1: b | — | T | · | · |
| 2: b | — | — | T | · |
| 3: a | — | — | — | T |
- length
- 2
- i / j
- 0 / 1
- dp[0][1]
- False
- start / best
- 0 / 1
| 左端 i / 右端 j | 0: a | 1: b | 2: b | 3: a |
|---|---|---|---|---|
| 0: a | T | F | · | · |
| 1: b | — | T | T | · |
| 2: b | — | — | T | · |
| 3: a | — | — | — | T |
- length
- 2
- i / j
- 1 / 2
- dp[1][2]
- True
- start / best
- 1 / 2
| 左端 i / 右端 j | 0: a | 1: b | 2: b | 3: a |
|---|---|---|---|---|
| 0: a | T | F | · | · |
| 1: b | — | T | T | · |
| 2: b | — | — | T | F |
| 3: a | — | — | — | T |
- length
- 2
- i / j
- 2 / 3
- dp[2][3]
- False
- start / best
- 1 / 2
| 左端 i / 右端 j | 0: a | 1: b | 2: b | 3: a |
|---|---|---|---|---|
| 0: a | T | F | F | · |
| 1: b | — | T | T | · |
| 2: b | — | — | T | F |
| 3: a | — | — | — | T |
- length
- 3
- i / j
- 0 / 2
- dp[0][2]
- False
- start / best
- 1 / 2
| 左端 i / 右端 j | 0: a | 1: b | 2: b | 3: a |
|---|---|---|---|---|
| 0: a | T | F | F | · |
| 1: b | — | T | T | F |
| 2: b | — | — | T | F |
| 3: a | — | — | — | T |
- length
- 3
- i / j
- 1 / 3
- dp[1][3]
- False
- start / best
- 1 / 2
| 左端 i / 右端 j | 0: a | 1: b | 2: b | 3: a |
|---|---|---|---|---|
| 0: a | T | F | F | T |
| 1: b | — | T | T | F |
| 2: b | — | — | T | F |
| 3: a | — | — | — | T |
- length
- 4
- i / j
- 0 / 3
- dp[0][3]
- True
- start / best
- 0 / 4
| 左端 i / 右端 j | 0: a | 1: b | 2: b | 3: a |
|---|---|---|---|---|
| 0: a | T | F | F | T |
| 1: b | — | T | T | F |
| 2: b | — | — | T | F |
| 3: a | — | — | — | T |
- start / best
- 0 / 4
- return s[0:4]
- abba
选择步骤 12 步
变量与图例
像检查套娃:两端字符要相同,里面那层也必须是回文;短区间先验好,长区间才有依据。
- i / j
- 当前子串的左右端点,下标都包含在区间内。
- length
- 本轮检查的区间长度,按 2、3、4 增长。
- dp[i][j]
- T 是回文,F 不是回文;· 未推演,— 为 i>j 的无用下三角。
- start / best
- 当前最长回文的起点 / 长度,初始 0 / 1。
算法步骤
- 令 n=len(s),创建全 False 的 n×n 表,初始化 start=0、best=1。
- 把每个单字符状态 dp[i][i] 设为 True。
- 令 length 从 2 到 n;对该长度的每个左端 i,计算右端 j=i+length−1。
- 只有两端字符相等,且 length≤2 或内部 dp[i+1][j−1] 为 True 时,才把 dp[i][j] 设为 True。
- 确认的回文长度超过 best 时,更新 start=i、best=length。
- 所有区间检查完后返回 s[start:start+best]。
代码(Python)
查看完整 Python 代码区间动态规划 · 与上方图解对应
按 length 递增保证内层区间已计算,dp 为真假表,start 和 best 记录最长真区间。
class Solution:
def longestPalindrome(self, s: str) -> str:
n = len(s)
dp = [[False] * n for _ in range(n)]
start, best = 0, 1
for i in range(n):
dp[i][i] = True
for length in range(2, n + 1):
for i in range(n - length + 1):
j = i + length - 1
if s[i] == s[j] and (length <= 2 or dp[i + 1][j - 1]):
dp[i][j] = True
if length > best:
start, best = i, length
return s[start:start + best]运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = Solution()
assert s.longestPalindrome("babad") in {"bab", "aba"}
assert s.longestPalindrome("cbbd") == "bb"
assert s.longestPalindrome("a") == "a"
assert s.longestPalindrome("aaaa") == "aaaa"
assert s.longestPalindrome("ac") in {"a", "c"}
复杂度
- 时间
- O(n²) 时间,n 为字符串长度;最终输出切片 O(L)。
- 空间
- O(n²) 辅助表,另有 O(L) 返回字符串空间。
易错点与边界
- 区间 DP 依赖更短的内部区间;若按 i 正序、j 正序直接填写,内部可能尚未就绪。
- 多个最长答案均可;测试 babad 应接受 bab 或 aba。
- 合法字符串非空;单字符自身就是答案。
主解之后,再比较与迁移。
其他解法 1
中心扩展时间 O(n²) 最坏时间,例如所有字符都相同;n 为字符串长度。最后切片为 O(L),L 为答案长度。
每个非空回文都有一个字符中心或字符间隙中心,枚举 (i,i) 与 (i,i+1) 就覆盖奇数、偶数两类长度。expand(left,right) 每次在界内且两端字符相同时向外走一步;已确认的内层保持回文,只有相等的外层字符才能把它继续扩大。停止时指针已越界或遇到不等字符,实际回文为 s[left+1:right],所以返回起点 left+1 与长度 right−left−1。外层用 start、length 保存全局最优,只在最后切片一次。它最坏仍需 O(n²) 时间,但辅助空间为 O(1),适合面试中进一步要求省去 DP 表的场景。
- 定义 expand:从给定 left、right 出发,只要未越界且两端字符相等,就让 left 减 1、right 加 1。
- 扩展结束返回 left+1、right−left−1,分别是有效回文的起点与长度。
- 初始化全局 start=0、length=1,逐个枚举字符位置 i。
- 分别调用 expand(i,i) 和 expand(i,i+1),接收 begin、size。
- 若 size>length,用 begin、size 更新 start、length;扩展过程中不反复复制子串。
- 所有中心完成后,返回 s[start:start+length]。
class Solution:
def longestPalindrome(self, s: str) -> str:
def expand(left, right):
while left >= 0 and right < len(s) and s[left] == s[right]:
left -= 1
right += 1
return left + 1, right - left - 1
start, length = 0, 1
for i in range(len(s)):
for left, right in ((i, i), (i, i + 1)):
begin, size = expand(left, right)
if size > length:
start, length = begin, size
return s[start:start + length]复杂度
- 时间
- O(n²) 最坏时间,例如所有字符都相同;n 为字符串长度。最后切片为 O(L),L 为答案长度。
- 空间
- O(1) 辅助空间;返回字符串切片占 O(L) 输出空间。
这个方法的边界
- 必须覆盖偶数中心,否则会漏掉 bb。
- 扩展退出后指针已在有效区间外,不能返回 s[left:right+1]。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = Solution()
assert s.longestPalindrome("babad") in {"bab", "aba"}
assert s.longestPalindrome("cbbd") == "bb"
assert s.longestPalindrome("a") == "a"
assert s.longestPalindrome("aaaa") == "aaaa"
assert s.longestPalindrome("ac") in {"a", "c"}
预测练习
检查整段 abba 时,仅知道两端都是 a 足够吗?应读表中哪一格?
一点提示
长度是 4,需要看去掉两端后的闭区间。
答案与理由
不够,还要读 dp[1][2],它对应 bb,已在长度 2 时算为 T;两端相等且内部回文,才可把 dp[0][3] 设为 T。
面试追问与解法取舍
中心扩展省空间,区间 DP 更方便复用任意区间的回文判定。追问线性时间时可讨论 Manacher:利用当前最右回文的镜像半径作为扩展下界,再把真正未知的边界继续向外推;它需要 O(n) 半径数组。不能仅凭“复用镜像”就省略边界截断的证明。
模板迁移 / 相似题
试着不看代码,复述这三点
- 回文子串要求连续;不能把最长回文子序列的转移套过来。
- 区间 DP 要先确定依赖的长度,再决定填表方向。
- 长度 2 只需两端相等,不能去访问并未定义的反向内区间。
1143. 最长公共子序列 中等
一句话题意
求两个字符串最长公共子序列的长度:字符可以不连续,但各自在原串中的相对顺序必须保留。
text1 = "aba",text2 = "baa";最长公共子序列长度为 2,例如 "ba" 或 "aa"。
生活化理解
两位听众各记下一串歌曲,想找共同听过且顺序一致的最长歌单。可以跳过某首歌,但不能把听歌顺序倒过来;两本记录的前缀就是 DP 的两个坐标。
二维前缀 DP
完整前缀表能直接对应相等与不等时的依赖来源,便于解释顺序约束和重复字符的处理。核心思路
dp[i][j] 表示 text1 的前 i 个字符与 text2 的前 j 个字符的最长公共子序列长度。i、j 是前缀长度,末字符下标分别是 i-1、j-1。任意字符串与空前缀的公共子序列长度都是 0,所以表格多放第 0 行、第 0 列作为边界。
末字符相同时,可以把这对字符作为最后一对匹配,在 dp[i-1][j-1] 后面加 1;存在使用这对末字符的最优方案。末字符不同时,它们不能配成同一对,至少舍弃其中一侧末字符,于是取 max(dp[i-1][j],dp[i][j-1])。子序列允许跳过字符,但相对顺序不能改变;不等时清零会变成另一种连续匹配问题。
源码按行从左到右填写完整二维表。进入当前格时,上方、左方、左上方都已完成,图中只高亮本分支实际读取的来源。相等分支使用左上角而非左方再加 1,可以避免把同一个末字符重复匹配。例如 aba 与 baa 的右下角读取左上角 1,再接一个 a 得到 2,最终返回 dp[m][n]。
dp[i][j] 是 text1 的前 i 个字符与 text2 的前 j 个字符的 LCS 长度,不是下标 i、j 两个字符的答案。按行从左到右计算时,上、左、左上三种可能来源都已完成。
图解
| text1 / text2 | 0: ε | 1: b | 2: ba | 3: baa |
|---|---|---|---|---|
| 0: ε | 0 | 0 | 0 | 0 |
| 1: a | 0 | · | · | · |
| 2: ab | 0 | · | · | · |
| 3: aba | 0 | · | · | · |
- m / n
- 3 / 3
- 第 0 行 / 列
- 全部为 0
| text1 / text2 | 0: ε | 1: b | 2: ba | 3: baa |
|---|---|---|---|---|
| 0: ε | 0 | 0 | 0 | 0 |
| 1: a | 0 | 0 | · | · |
| 2: ab | 0 | · | · | · |
| 3: aba | 0 | · | · | · |
- i / j
- 1 / 1
- 末字符
- a / b
- dp[1][1]
- 0
| text1 / text2 | 0: ε | 1: b | 2: ba | 3: baa |
|---|---|---|---|---|
| 0: ε | 0 | 0 | 0 | 0 |
| 1: a | 0 | 0 | 1 | · |
| 2: ab | 0 | · | · | · |
| 3: aba | 0 | · | · | · |
- i / j
- 1 / 2
- 末字符
- a / a
- dp[1][2]
- 1
| text1 / text2 | 0: ε | 1: b | 2: ba | 3: baa |
|---|---|---|---|---|
| 0: ε | 0 | 0 | 0 | 0 |
| 1: a | 0 | 0 | 1 | 1 |
| 2: ab | 0 | · | · | · |
| 3: aba | 0 | · | · | · |
- i / j
- 1 / 3
- 末字符
- a / a
- dp[1][3]
- 1
| text1 / text2 | 0: ε | 1: b | 2: ba | 3: baa |
|---|---|---|---|---|
| 0: ε | 0 | 0 | 0 | 0 |
| 1: a | 0 | 0 | 1 | 1 |
| 2: ab | 0 | 1 | · | · |
| 3: aba | 0 | · | · | · |
- i / j
- 2 / 1
- 末字符
- b / b
- dp[2][1]
- 1
| text1 / text2 | 0: ε | 1: b | 2: ba | 3: baa |
|---|---|---|---|---|
| 0: ε | 0 | 0 | 0 | 0 |
| 1: a | 0 | 0 | 1 | 1 |
| 2: ab | 0 | 1 | 1 | · |
| 3: aba | 0 | · | · | · |
- i / j
- 2 / 2
- 末字符
- b / a
- dp[2][2]
- 1
| text1 / text2 | 0: ε | 1: b | 2: ba | 3: baa |
|---|---|---|---|---|
| 0: ε | 0 | 0 | 0 | 0 |
| 1: a | 0 | 0 | 1 | 1 |
| 2: ab | 0 | 1 | 1 | 1 |
| 3: aba | 0 | · | · | · |
- i / j
- 2 / 3
- 末字符
- b / a
- dp[2][3]
- 1
| text1 / text2 | 0: ε | 1: b | 2: ba | 3: baa |
|---|---|---|---|---|
| 0: ε | 0 | 0 | 0 | 0 |
| 1: a | 0 | 0 | 1 | 1 |
| 2: ab | 0 | 1 | 1 | 1 |
| 3: aba | 0 | 1 | · | · |
- i / j
- 3 / 1
- 末字符
- a / b
- dp[3][1]
- 1
| text1 / text2 | 0: ε | 1: b | 2: ba | 3: baa |
|---|---|---|---|---|
| 0: ε | 0 | 0 | 0 | 0 |
| 1: a | 0 | 0 | 1 | 1 |
| 2: ab | 0 | 1 | 1 | 1 |
| 3: aba | 0 | 1 | 2 | · |
- i / j
- 3 / 2
- 末字符
- a / a
- dp[3][2]
- 2
| text1 / text2 | 0: ε | 1: b | 2: ba | 3: baa |
|---|---|---|---|---|
| 0: ε | 0 | 0 | 0 | 0 |
| 1: a | 0 | 0 | 1 | 1 |
| 2: ab | 0 | 1 | 1 | 1 |
| 3: aba | 0 | 1 | 2 | 2 |
- i / j
- 3 / 3
- 末字符
- a / a
- dp[3][3]
- 2
| text1 / text2 | 0: ε | 1: b | 2: ba | 3: baa |
|---|---|---|---|---|
| 0: ε | 0 | 0 | 0 | 0 |
| 1: a | 0 | 0 | 1 | 1 |
| 2: ab | 0 | 1 | 1 | 1 |
| 3: aba | 0 | 1 | 2 | 2 |
- dp[3][3]
- 2
- 可选公共子序列
- ba / aa
选择步骤 11 步
变量与图例
把两个前缀当成两叠已翻开的字牌:末尾相同就接到左上角答案后;不相同就比较丢掉哪一侧末尾更划算。
- i / j
- 已取的前缀长度;对应末字符下标 i-1、j-1。
- dp[i][j]
- 两个前缀的最长公共子序列长度,允许不连续但必须保持顺序。
- ε
- 空前缀:任何字符串与空串的 LCS 长度都是 0。
- 上 / 左 / 左上
- 分别少取 text1 末尾、少取 text2 末尾、两边都少取末尾的状态。
算法步骤
- 读取 m、n,创建 (m+1)×(n+1) 的全零 dp 表,第 0 行和第 0 列表示空前缀。
- 令 i 从 1 到 m,再令 j 从 1 到 n,比较 text1[i−1] 与 text2[j−1]。
- 末字符相同,写入 dp[i][j]=dp[i−1][j−1]+1。
- 末字符不同,写入 dp[i][j]=max(dp[i−1][j],dp[i][j−1])。
- 返回完整两个字符串对应的 dp[m][n]。
代码(Python)
查看完整 Python 代码二维前缀 DP · 与上方图解对应
二维表明确展现跳过左串末字符、跳过右串末字符和配对末字符三种关系。
class Solution:
def longestCommonSubsequence(self, text1: str, text2: str) -> int:
m, n = len(text1), len(text2)
dp = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(1, m + 1):
for j in range(1, n + 1):
if text1[i - 1] == text2[j - 1]:
dp[i][j] = dp[i - 1][j - 1] + 1
else:
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
return dp[m][n]运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = Solution()
assert s.longestCommonSubsequence("abcde", "ace") == 3
assert s.longestCommonSubsequence("abc", "abc") == 3
assert s.longestCommonSubsequence("abc", "def") == 0
assert s.longestCommonSubsequence("a", "aaaa") == 1
assert s.longestCommonSubsequence("abc", "ac") == 2
复杂度
- 时间
- O(mn),m、n 为两个字符串长度。
- 空间
- O(mn) 辅助空间。
易错点与边界
- 子序列可跳过字符但不可换序,子串则要求连续。
- DP 坐标是前缀长度,字符索引要减 1。
主解之后,再比较与迁移。
其他解法 1
滚动数组与旧左上角时间 O(mn),m、n 为两个字符串长度。
把前缀表压缩为一行,并让较短字符串作为列,从而只保存 O(min(m,n)) 个状态。处理当前字符 a 与第 j 个字符 b 时,未覆盖的 dp[j] 是上方旧值,已更新的 dp[j−1] 是本行左值,diagonal 则单独保存旧左上角。先用 old_up 留住旧 dp[j],再根据字符是否相等计算当前值,最后令 diagonal=old_up,为下一列准备正确的左上角。每行开始 diagonal=0,因为上一行的空前缀边界为 0;这些覆盖顺序正是压缩后必须额外维护的关系。
- 若 text2 比 text1 长,交换两串;建立长度 len(text2)+1 的全零 dp。
- 逐个处理 text1 中的字符 a,每行开始令 diagonal=0。
- 按 j 从 1 递增处理 text2 的字符 b,先保存 old_up=dp[j]。
- 若 a==b,令 dp[j]=diagonal+1;否则令 dp[j]=max(dp[j],dp[j−1])。
- 当前格写完后才执行 diagonal=old_up,继续下一列。
- 全部行完成后返回 dp[-1]。
class Solution:
def longestCommonSubsequence(self, text1: str, text2: str) -> int:
if len(text2) > len(text1):
text1, text2 = text2, text1
dp = [0] * (len(text2) + 1)
for a in text1:
diagonal = 0
for j, b in enumerate(text2, 1):
old_up = dp[j]
if a == b:
dp[j] = diagonal + 1
else:
dp[j] = max(dp[j], dp[j - 1])
diagonal = old_up
return dp[-1]复杂度
- 时间
- O(mn),m、n 为两个字符串长度。
- 空间
- O(min(m,n)) 辅助空间,只返回长度。
这个方法的边界
- 一维版的 dp[j−1] 已经更新,不能把它当旧左上角。
- 每行开始 diagonal 重置为 0;重复字符不能重复消费同一个下标。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = Solution()
assert s.longestCommonSubsequence("abcde", "ace") == 3
assert s.longestCommonSubsequence("abc", "abc") == 3
assert s.longestCommonSubsequence("abc", "def") == 0
assert s.longestCommonSubsequence("a", "aaaa") == 1
assert s.longestCommonSubsequence("abc", "ac") == 2
预测练习
算 dp[3][3],末尾都是 a,应使用上方 1、左方 2,还是左上角 1?
一点提示
相同的两个末尾可以接在哪个共同前缀结果之后?
答案与理由
使用左上角 dp[2][2]=1,再加当前这个 a,得到 2。不能把左方 dp[3][2]=2 再加 1:那一格已经可能用过 text1 的最后一个 a。
面试追问与解法取舍
若要输出实际公共子序列,可以在二维表从右下角回溯:末字符相等就收集并左上移动,否则走向上、左中值更大的一侧,最后反转收集结果。滚动数组只保留长度,不能直接回溯;若同时追求线性辅助空间与序列输出,可进一步研究 Hirschberg 的分治重建。
模板迁移 / 相似题
试着不看代码,复述这三点
- 先把表头写成“前几个字符”,避免 i 与 i-1 的混淆。
- 字符相等时用左上角加 1;不等时才比较上与左。
- 相同字符出现多次也不能反复拿同一个位置;前缀边界负责维持顺序。
72. 编辑距离 中等
一句话题意
允许插入、删除或替换一个字符,求把 word1 转换成 word2 所需的最少操作数。
word1 = "abc",word2 = "ad";最少编辑 2 次,例如删除 b、把 c 替换为 d。
生活化理解
把 word1 当成待校对稿、word2 当成标准稿。校对员比较两段前缀:可以划掉原稿末字、补上标准稿末字,或把末字改对;每一步付一张操作票,DP 记最少用了多少票。
二维编辑距离 DP
把删除、插入、替换分别放在上、左、左上三个来源中,完整表格能清楚解释最少操作数。核心思路
dp[i][j] 是把 word1 前 i 个字符变成 word2 前 j 个字符的最少编辑次数,表格的行是源前缀,列是目标前缀。源前缀变为空串要删除 i 次,空串变成目标前缀要插入 j 次,因此 dp[i][0]=i、dp[0][j]=j;这两条边界不能全部填 0。
若末字符相同,保留这对字符即可,直接继承 dp[i-1][j-1]。不同则比较三种最后一步:dp[i-1][j]+1 对应删除源末字符;dp[i][j-1]+1 对应先得到目标前 j−1 个字符,再插入目标末字符;dp[i-1][j-1]+1 对应替换末字符。三个更短前缀都已最优,取其中最小值就得到当前前缀的最少次数。
初始化边界后,源码按行从左到右计算内部格,每个来源都已确定。图中 abc 变为 ad 的最后一格,删除候选为 2、插入为 3、替换为 2,所以填 2。并列最优来源可以对应不同编辑路线;返回的 dp[m][n] 只表示最少次数,不额外保存路线。
dp[i][j] 表示把 word1 前 i 个字符变为 word2 前 j 个字符的最少操作数。每次操作都对应一个更短前缀问题,按行填表时这些依赖都已最优;取最小值保证当前前缀也最优。
图解
| word1 / word2 | 0: ε | 1: a | 2: ad |
|---|---|---|---|
| 0: ε | · | · | · |
| 1: a | · | · | · |
| 2: ab | · | · | · |
| 3: abc | · | · | · |
- m / n
- 3 / 2
- 合法操作
- 插入 / 删除 / 替换
| word1 / word2 | 0: ε | 1: a | 2: ad |
|---|---|---|---|
| 0: ε | 0 | · | · |
| 1: a | · | · | · |
| 2: ab | · | · | · |
| 3: abc | · | · | · |
- i
- 0
- j
- 0
- dp[0][0]
- 0
| word1 / word2 | 0: ε | 1: a | 2: ad |
|---|---|---|---|
| 0: ε | 0 | · | · |
| 1: a | 1 | · | · |
| 2: ab | · | · | · |
| 3: abc | · | · | · |
- i
- 1
- j
- 0
- dp[1][0]
- 1
| word1 / word2 | 0: ε | 1: a | 2: ad |
|---|---|---|---|
| 0: ε | 0 | · | · |
| 1: a | 1 | · | · |
| 2: ab | 2 | · | · |
| 3: abc | · | · | · |
- i
- 2
- j
- 0
- dp[2][0]
- 2
| word1 / word2 | 0: ε | 1: a | 2: ad |
|---|---|---|---|
| 0: ε | 0 | · | · |
| 1: a | 1 | · | · |
| 2: ab | 2 | · | · |
| 3: abc | 3 | · | · |
- i
- 3
- j
- 0
- dp[3][0]
- 3
| word1 / word2 | 0: ε | 1: a | 2: ad |
|---|---|---|---|
| 0: ε | 0 | 1 | · |
| 1: a | 1 | · | · |
| 2: ab | 2 | · | · |
| 3: abc | 3 | · | · |
- i
- 0
- j
- 1
- dp[0][1]
- 1
| word1 / word2 | 0: ε | 1: a | 2: ad |
|---|---|---|---|
| 0: ε | 0 | 1 | 2 |
| 1: a | 1 | · | · |
| 2: ab | 2 | · | · |
| 3: abc | 3 | · | · |
- i
- 0
- j
- 2
- dp[0][2]
- 2
| word1 / word2 | 0: ε | 1: a | 2: ad |
|---|---|---|---|
| 0: ε | 0 | 1 | 2 |
| 1: a | 1 | 0 | · |
| 2: ab | 2 | · | · |
| 3: abc | 3 | · | · |
- i / j
- 1 / 1
- 末字符
- a / a
- dp[1][1]
- 0
| word1 / word2 | 0: ε | 1: a | 2: ad |
|---|---|---|---|
| 0: ε | 0 | 1 | 2 |
| 1: a | 1 | 0 | 1 |
| 2: ab | 2 | · | · |
| 3: abc | 3 | · | · |
- i / j
- 1 / 2
- 末字符
- a / d
- dp[1][2]
- 1
| word1 / word2 | 0: ε | 1: a | 2: ad |
|---|---|---|---|
| 0: ε | 0 | 1 | 2 |
| 1: a | 1 | 0 | 1 |
| 2: ab | 2 | 1 | · |
| 3: abc | 3 | · | · |
- i / j
- 2 / 1
- 末字符
- b / a
- dp[2][1]
- 1
| word1 / word2 | 0: ε | 1: a | 2: ad |
|---|---|---|---|
| 0: ε | 0 | 1 | 2 |
| 1: a | 1 | 0 | 1 |
| 2: ab | 2 | 1 | 1 |
| 3: abc | 3 | · | · |
- i / j
- 2 / 2
- 末字符
- b / d
- dp[2][2]
- 1
| word1 / word2 | 0: ε | 1: a | 2: ad |
|---|---|---|---|
| 0: ε | 0 | 1 | 2 |
| 1: a | 1 | 0 | 1 |
| 2: ab | 2 | 1 | 1 |
| 3: abc | 3 | 2 | · |
- i / j
- 3 / 1
- 末字符
- c / a
- dp[3][1]
- 2
| word1 / word2 | 0: ε | 1: a | 2: ad |
|---|---|---|---|
| 0: ε | 0 | 1 | 2 |
| 1: a | 1 | 0 | 1 |
| 2: ab | 2 | 1 | 1 |
| 3: abc | 3 | 2 | 2 |
- i / j
- 3 / 2
- 末字符
- c / d
- dp[3][2]
- 2
| word1 / word2 | 0: ε | 1: a | 2: ad |
|---|---|---|---|
| 0: ε | 0 | 1 | 2 |
| 1: a | 1 | 0 | 1 |
| 2: ab | 2 | 1 | 1 |
| 3: abc | 3 | 2 | 2 |
- return dp[3][2]
- 2
- 示例路线
- abc → ac(删 b)→ ad(c 换 d)
选择步骤 14 步
变量与图例
把前缀变成目标前缀:删除从上面来,插入从左边来,替换从左上来;末尾相同则直接沿左上走。
- i / j
- 源前缀 / 目标前缀的长度;行是源,列是目标。
- dp[i][j]
- 将行前缀变成列前缀的最少编辑次数。
- 上方 + 1
- 删掉源末尾字符。
- 左方 + 1
- 在源末尾插入目标末尾字符。
- 左上方 + 1 / +0
- 两末尾不同则替换;相同则保留,免费沿用。
算法步骤
- 读取 m、n,建立 (m+1)×(n+1) 的 dp 表。
- 先逐行填写 dp[i][0]=i,再逐列填写 dp[0][j]=j,建立空串边界。
- 按 i 从 1 到 m、j 从 1 到 n 比较两个前缀的末字符。
- 末字符相同,继承 dp[i−1][j−1];不相同,取上、左、左上三格的最小值再加 1。
- 返回 dp[m][n],即把完整 word1 变为完整 word2 的最少操作次数。
代码(Python)
查看完整 Python 代码二维编辑距离 DP · 与上方图解对应
完整表可记录每个前缀的最优编辑次数,也能用于进一步恢复操作序列。
class Solution:
def minDistance(self, word1: str, word2: str) -> int:
m, n = len(word1), len(word2)
dp = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(m + 1):
dp[i][0] = i
for j in range(n + 1):
dp[0][j] = j
for i in range(1, m + 1):
for j in range(1, n + 1):
if word1[i - 1] == word2[j - 1]:
dp[i][j] = dp[i - 1][j - 1]
else:
dp[i][j] = 1 + min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1])
return dp[m][n]运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = Solution()
assert s.minDistance("horse", "ros") == 3
assert s.minDistance("intention", "execution") == 5
assert s.minDistance("", "abc") == 3
assert s.minDistance("abc", "") == 3
assert s.minDistance("", "") == 0
assert s.minDistance("same", "same") == 0
assert s.minDistance("ab", "ac") == 1
复杂度
- 时间
- O((m+1)(n+1)) 时间,m、n 为两串长度;非空时常写 O(mn)。
- 空间
- O((m+1)(n+1)) 辅助空间。
易错点与边界
- 空字符串属于合法输入,首行首列是实际操作次数。
- 末字符相等时直接继承,不能固定加 1。
- 上方代表删除原串字符,左方代表插入目标字符,解释操作时要统一转换方向。
主解之后,再比较与迁移。
其他解法 1
一维滚动编辑距离时间 O((m+1)(n+1)) 时间,包含空串边界。
只保留一行前缀费用。当前格的上方由旧 dp[j] 表示,左方是已经更新的 dp[j−1],diagonal 保存旧左上角。每进入一行,必须先令 diagonal=dp[0],再写 dp[0]=i;否则首个内部格会丢失旧左上角,或空目标边界仍停留在上一行。逐格处理前先用 up 留住旧 dp[j],更新后再把 up 交给 diagonal。代码把较短字符串放在列上以节省空间;本题插入与删除代价相同,交换源串与目标串不改变最小次数。
- 若 word2 比 word1 长,交换两串;初始化 dp=[0,1,…,len(word2)],表示空源串到各目标前缀的费用。
- 枚举 word1 的字符 a 及其前缀长度 i,每行先保存 diagonal=dp[0],再设 dp[0]=i。
- 从左到右枚举 word2 的字符 b 与列 j,保存 up=dp[j]。
- a==b 时令 dp[j]=diagonal;否则令 dp[j]=1+min(up,dp[j−1],diagonal)。
- 执行 diagonal=up,让下一列仍能读取旧左上角。
- 全部行完成后返回 dp[-1]。
class Solution:
def minDistance(self, word1: str, word2: str) -> int:
if len(word2) > len(word1):
word1, word2 = word2, word1
dp = list(range(len(word2) + 1))
for i, a in enumerate(word1, 1):
diagonal = dp[0]
dp[0] = i
for j, b in enumerate(word2, 1):
up = dp[j]
if a == b:
dp[j] = diagonal
else:
dp[j] = 1 + min(up, dp[j - 1], diagonal)
diagonal = up
return dp[-1]复杂度
- 时间
- O((m+1)(n+1)) 时间,包含空串边界。
- 空间
- O(min(m,n)+1) 辅助空间。
这个方法的边界
- 滚动数组先保护旧左上角,再覆盖;错误顺序会把本行数据当上一行数据。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = Solution()
assert s.minDistance("horse", "ros") == 3
assert s.minDistance("intention", "execution") == 5
assert s.minDistance("", "abc") == 3
assert s.minDistance("abc", "") == 3
assert s.minDistance("", "") == 0
assert s.minDistance("same", "same") == 0
assert s.minDistance("ab", "ac") == 1
预测练习
在 abc → ad 的右下角,删除、插入、替换三种候选各是多少?
一点提示
先读取 dp[2][2]=1、dp[3][1]=2、dp[2][1]=1。
答案与理由
删除为 1+1=2,插入为 2+1=3,替换为 1+1=2,答案 2。两条最优路线可以并存,只要求最少次数。
面试追问与解法取舍
如果插入、删除、替换有不同代价,就分别给三个来源加对应权重;此时交换两个字符串可能改变有向转换成本,不能无条件交换。若只能插入和删除,距离可以由 m+n−2×LCS 得到;允许替换时该公式不成立,例如 a→b 一次替换即可。
模板迁移 / 相似题
试着不看代码,复述这三点
- 表的方向决定操作含义:行是源串时,上方表示删除、左方表示插入。
- 空串边界不是全 0:ε→长度 j 要插入 j 次,长度 i→ε 要删除 i 次。
- 末尾相同不付费,直接用左上角;不同才给三种候选统一加 1。
136. 只出现一次的数字 简单
一句话题意
非空整数数组中恰有一个数出现一次,其余每个数都出现两次,找出这个唯一值。
nums = [4,1,2,1,2,4,7];只出现一次的数字是 7。
生活化理解
异或像成对开关:同一编号的开关按两次就恢复原状,不同编号的操作可以交换顺序。所有成对数字都抵消,最终开关状态就由那个只按了一次的数字决定。
异或抵消
直接利用成对元素抵消的题目条件,只需一个累加器和一次扫描,证明与实现都简洁。核心思路
异或逐位比较:相同位得到 0,不同位得到 1,所以 x^x=0、x^0=x。它还满足交换律和结合律,相等数字即使在数组中相隔很远,也可以在代数上配成一对抵消,不需要先排序。
初始化 result=0,每读入一个元素 x,就执行 result ^= x。循环不变量是:result 等于已经扫描的整个前缀的异或。它可能是几个输入值按位混合后的结果,中途不一定出现在输入中,更不一定已经是最终的单例;必须让全部成对数字都参与后才能返回。
图中 4^1^2 一度等于 7,但后面还要继续处理第二个 1、2、4,最后才真正留下单独的 7。图中的“未抵消集合”只是解释配对的辅助画法,源码没有建立集合。Python 对负整数的异或也满足同样的恒等式,本题无需人为截断位宽。
result 始终等于已经扫描的所有数字的按位异或。异或可交换、可结合,且 x^x=0、0^x=x,所以相同的两个数即使相隔很远,也会在最终结果中抵消。
图解
- result
- 0
- 二进制
- 000
- 本轮下标
- 0
- x
- 4
- result
- 4
- 二进制结果
- 100
- 本轮下标
- 1
- x
- 1
- result
- 5
- 二进制结果
- 101
- 本轮下标
- 2
- x
- 2
- result
- 7
- 二进制结果
- 111
- 本轮下标
- 3
- x
- 1
- result
- 6
- 二进制结果
- 110
- 本轮下标
- 4
- x
- 2
- result
- 4
- 二进制结果
- 100
- 本轮下标
- 5
- x
- 4
- result
- 0
- 二进制结果
- 000
- 本轮下标
- 6
- x
- 7
- result
- 7
- 二进制结果
- 111
- result
- 7
- 抵消关系
- 4^4 = 1^1 = 2^2 = 0
选择步骤 9 步
变量与图例
异或像一组灯的开关:某一位按两次会恢复原样;成对数字最终全部抵消,只留下落单数字的灯光。
- x
- 本轮刚读到的数组元素。
- result
- 已经处理的前缀的异或值,并不是当前唯一出现一次的候选数。
- b2 / b1 / b0
- 本例使用三位二进制:对应权重 4、2、1。
- 未抵消数字
- 仅为解释的奇数次出现集合;源码不存这个集合。
算法步骤
- 把异或累加器 result 初始化为 0。
- 从左到右读取每个 x,执行 result ^= x,保持前缀异或不变量。
- 全部元素处理后,相同的两项按异或性质抵消为 0。
- 返回剩下的 result,它就是唯一只出现一次的数字。
代码(Python)
查看完整 Python 代码异或抵消 · 与上方图解对应
result 始终等于已扫描前缀的异或,无需存储访问集合,满足线性时间和常数辅助空间要求。
from typing import List
class Solution:
def singleNumber(self, nums: List[int]) -> int:
result = 0
for x in nums:
result ^= x
return result运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = Solution()
assert s.singleNumber([2,2,1]) == 1
assert s.singleNumber([4,1,2,1,2]) == 4
assert s.singleNumber([0]) == 0
assert s.singleNumber([-1,2,2]) == -1
assert s.singleNumber([-7,0,-7]) == 0
复杂度
- 时间
- 定长整数模型 O(n),n 为数组长度;若整数最大位数为 B,Python 大整数模型为 O(nB) 位操作上界。
- 空间
- 定长整数模型 O(1) 辅助空间;任意精度整数累加器占 O(B) 位。
易错点与边界
- 本题明确其余值都出现两次;若出现三次,简单异或不再得到唯一值。
- 异或运算符是 ^,不是 **,也不是逻辑 or。
- 累加器从 0 开始,而不是从 1 开始。
- 中间异或结果不必来自原数组;负数和 0 都可直接参与。
主解之后,再比较与迁移。
其他解法 1
哈希计数基线时间 定长整数模型平均 O(n),哈希操作按平均 O(1) 计;大整数还需计入哈希和比较的位数成本。
用 Counter(nums) 建立 counts,counts[x] 直接记录每个数字的出现次数,再遍历键值对寻找 count==1 的数字。它不依赖异或抵消的代数性质,适合先说明直观方案,也容易迁移到查找指定频次的题目;代价是要为不同数字保存计数。源码的 next 返回第一个满足条件的键,本题保证恰有一个单例,因此这个键就是答案。
- 用 Counter 扫描 nums,得到每个不同数字的出现次数 counts。
- 遍历 counts.items() 中的数字 x 与次数 count。
- 筛出 count==1 的数字,由 next 返回这个唯一的键,而非返回计数值。
from collections import Counter
from typing import List
class Solution:
def singleNumber(self, nums: List[int]) -> int:
counts = Counter(nums)
return next(x for x, count in counts.items() if count == 1)复杂度
- 时间
- 定长整数模型平均 O(n),哈希操作按平均 O(1) 计;大整数还需计入哈希和比较的位数成本。
- 空间
- O(k) 个哈希条目,k 为不同值数量,最坏 O(n)。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = Solution()
assert s.singleNumber([2,2,1]) == 1
assert s.singleNumber([4,1,2,1,2]) == 4
assert s.singleNumber([0]) == 0
assert s.singleNumber([-1,2,2]) == -1
assert s.singleNumber([-7,0,-7]) == 0
预测练习
前 3 个数 [4,1,2] 的异或已经等于 7,能立刻返回吗?
一点提示
这个 7 甚至还没作为输入元素被扫描。
答案与理由
不能。4^1^2=7 只是三个数各位混合后的中间值;必须扫描全部元素,借助“其余数恰好出现两次”的条件才可返回。
面试追问与解法取舍
若有两个数字各出现一次,其余各出现两次,先异或得到 a XOR b,再取其中一个为 1 的位把数组分成两组,每组异或分别得到 a、b。若其余数字各出现三次,则可按位统计并对 3 取模;那是另一套不变量,不能沿用两次抵消的结论。
模板迁移 / 相似题
试着不看代码,复述这三点
- 异或按二进制位工作,中间值可能根本不在已扫描前缀中。
- 成对元素不必相邻;交换律和结合律允许在解释中重新配对。
- “其余数出现两次”是关键条件,出现三次或存在多个单例时不能直接套这个模板。
169. 多数元素 简单
一句话题意
给定非空数组,题目保证存在出现次数严格大于 n/2 的多数元素,返回该元素。
nums = [2,1,1,2,3,2,2];多数元素为 2,出现 4 次,超过 7/2。
生活化理解
不同阵营的人两两离场,同阵营的人留下。人数超过一半的阵营,哪怕每次都被另一方带走一个,最终仍有人剩下;candidate 是当前留下的阵营,balance 是它暂时未被抵消的人数。
Boyer–Moore 摩尔投票
异值配对抵消的证明能支撑一次扫描、常数空间的实现,便于解释候选与余额的区别。核心思路
只维护 candidate 与 balance:candidate 是当前未抵消票堆的阵营,balance 是这堆票的数量。每轮开始若余额为 0,就让当前值 x 成为新候选;随后同值加 1,异值减 1,相当于让一对不同数字抵消。处理过的前缀始终能拆成若干异值对,以及 balance 张全属于 candidate 的剩余票。
balance 不是 candidate 在前缀里的真实出现次数。它归零时,candidate 变量仍保留旧值,但已经没有剩余票;下一轮才根据当前 x 重选。源码虽然先令 candidate=nums[0],balance 仍是 0,循环也仍会处理第一项,不能把初始化误算成已经投过一票。
严格多数的数量 M 大于其他数字的总量 O。抵消一对不同数字时,若含多数,两边各少一个,优势不变;若不含多数,其他数字更少,优势反而增大,因此多数不可能被全部抵消。题目保证多数一定存在,扫描完便可返回候选。图中 2 实际出现 4 次,最后 balance 只有 1,正是其余三张 2 已参与抵消的结果。
扫描过的前缀可以拆成若干对不同数字,以及 balance 张全属于 candidate 的未抵消票。删除异值对不可能消灭整个数组的多数元素,因此题目保证多数存在时,最终留下的候选必是它。
图解
- candidate
- 2
- balance
- 0
- x
- 2
- candidate
- 2
- balance
- 1
- 是否重选候选
- True
- x
- 1
- candidate
- 2
- balance
- 0
- 是否重选候选
- False
- x
- 1
- candidate
- 1
- balance
- 1
- 是否重选候选
- True
- x
- 2
- candidate
- 1
- balance
- 0
- 是否重选候选
- False
- x
- 3
- candidate
- 3
- balance
- 1
- 是否重选候选
- True
- x
- 2
- candidate
- 3
- balance
- 0
- 是否重选候选
- False
- x
- 2
- candidate
- 2
- balance
- 1
- 是否重选候选
- True
- return candidate
- 2
- balance
- 1
- 2 的实际出现次数
- 4
选择步骤 9 步
变量与图例
不同阵营的两张票两两作废;剩余票堆为空时,下一张票成为新候选。观察“剩余优势票数”,别把它当出现次数。
- candidate
- 当前票堆的阵营;balance=0 时变量仍保留旧值,但票堆已空。
- balance
- 未被异值票抵消的优势票数,不是候选在原数组的总次数。
- x
- 本轮读入的一张票。
- 抵消对 / 剩余票堆
- 仅为图解展开的容器,源码不保存它们。
算法步骤
- 初始化 candidate=nums[0]、balance=0,随后从数组第一项开始遍历。
- 读取当前 x;如果 balance==0,先令 candidate=x。
- 比较 x 与 candidate:相同则 balance 加 1,不同则减 1。
- 继续扫描,余额归零时保留变量的旧值,等待下一轮重选候选。
- 遍历结束后,根据本题多数一定存在的保证,直接返回 candidate。
代码(Python)
查看完整 Python 代码Boyer–Moore 摩尔投票 · 与上方图解对应
balance 记录配对消去后候选的剩余票数;借助题目多数存在的保证,一趟扫描即可确定答案。
from typing import List
class Solution:
def majorityElement(self, nums: List[int]) -> int:
candidate, balance = nums[0], 0
for x in nums:
if balance == 0:
candidate = x
balance += 1 if x == candidate else -1
return candidate运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = Solution()
assert s.majorityElement([3,2,3]) == 3
assert s.majorityElement([2,2,1,1,1,2,2]) == 2
assert s.majorityElement([7]) == 7
assert s.majorityElement([-1,-1,2]) == -1
assert s.majorityElement([1,2,2]) == 2
复杂度
- 时间
- O(n),n 为数组长度,按定长整数比较计。
- 空间
- O(1) 辅助空间。
易错点与边界
- 多数是严格超过一半,不是恰好一半,也不只是出现次数最多。
- balance 不是原数组频率,不能把它与 n//2 比较验证多数。
- 余额为 0 时要重新设候选,然后把当前元素的一票计进去。
- 数组非空且保证多数存在;一般化版本要二次计数确认候选。
主解之后,再比较与迁移。
其他解法 1
哈希计数时间 平均 O(n),n 为数组长度;哈希操作按定长整数平均 O(1) 计。
用 counts=Counter(nums) 显式记录每个数字的真实频率,再用 max(counts,key=counts.get) 选出次数最大的键。key=counts.get 表示按频率比较,不是按数字本身大小比较。题目保证某个数字超过一半,所以频率最高的数字必是多数。这个方法省去抵消证明,也能保留完整频率供后续使用,但需要额外计数空间;若没有多数存在的保证,最高频率的键未必超过一半,仍须检查阈值。
- 调用 Counter(nums),统计所有数字的出现次数。
- 遍历 counts 的键,使用 counts.get 取得频率作为 max 的比较依据。
- 返回频率最高的数字;本题的多数存在保证使它就是答案。
from collections import Counter
from typing import List
class Solution:
def majorityElement(self, nums: List[int]) -> int:
counts = Counter(nums)
return max(counts, key=counts.get)复杂度
- 时间
- 平均 O(n),n 为数组长度;哈希操作按定长整数平均 O(1) 计。
- 空间
- O(k) 辅助空间,k 为不同值数,最坏 O(n)。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = Solution()
assert s.majorityElement([3,2,3]) == 3
assert s.majorityElement([2,2,1,1,1,2,2]) == 2
assert s.majorityElement([7]) == 7
assert s.majorityElement([-1,-1,2]) == -1
assert s.majorityElement([1,2,2]) == 2
预测练习
扫描到倒数第二个 2 后,candidate=3、balance=0,为什么下一张 2 可以改掉候选?
一点提示
此时前缀已全被拆成异值对,剩余票堆为空。
答案与理由
balance=0 表示没有未抵消票约束候选。下一张 x=2 先设置 candidate=2,再使 balance 变为 1;不是因为 3 的总出现次数为 0。
面试追问与解法取舍
排序后取 nums[n//2] 也是常见替代,但排序 O(n log n),Python sorted 还产生 O(n) 新数组。若追问出现次数大于 n/3 的元素,最多只有两个候选,需要两组余额并在末尾核验真实次数,不能只把本题阈值改为 n/3。
模板迁移 / 相似题
试着不看代码,复述这三点
- 先说明抵消模型,再解释 candidate 与 balance 的赋值顺序。
- balance 归零不会自动清空 candidate 变量;下一轮才用当前 x 重选。
- 题目保证多数存在才能直接返回;若没有这个保证,需再数一次候选出现次数。
75. 颜色分类 中等
一句话题意
数组只含 0、1、2,要求原地把相同颜色聚在一起,并按 0、1、2 的顺序排列。
nums = [1,2,0,2,1,0];原地排序后为 [0,0,1,1,2,2],方法返回 None。
生活化理解
把一排混放的三色球分成四个仓位:左侧已整理的 0,中间已整理的 1,尚未检验的区域,右侧已整理的 2。扫描员 i 每检查一个球,就把它送去对应的仓位。
荷兰国旗三指针
用明确的区间不变量解释每次交换,一次缩短未知区即可原地完成三色排序。核心思路
维护四段区间:[0,low) 全为 0,[low,i) 全为 1,[i,high] 尚未检查,(high,n) 全为 2。low 是下一块 0 要放的位置,i 是当前检查位置,high 是未知区最右端。循环条件 i<=high 表示未知区仍非空。
遇到 0,与 low 交换,再同时推进 low、i。若 low<i,被换回 i 的是已知 1 区里的元素;若 low=i,只是自交换,所以两种情况都可以跳过当前位置。遇到 1,只推进 i,它自然接到已知 1 区的末尾。
遇到 2,与 high 交换后只缩小 high,i 必须留下:从未知区右端换来的值还没有检查,可能是 0、1,也可能仍是 2。图中第一次从右边换来的是 0,下一轮就要把它移入左区。每次处理都使未知区长度减少 1;当 i 越过 high,未知区消失,三个有序颜色区连接完成。结果写回原数组,函数自然返回 None。
[0,low) 全是 0,[low,i) 全是 1,(high,n) 全是 2,只有 [i,high] 尚未检查。每轮要么 i 右移,要么 high 左移,未知区严格缩短;未知区为空时,三个有序色区便首尾相接。
图解
- low / i / high
- 0 / 0 / 5
- 未知区长度
- 6
- low / i / high
- 0 / 1 / 5
- 本轮原值
- 1
- 未知区长度
- 5
- low / i / high
- 0 / 1 / 4
- 本轮原值
- 2
- 未知区长度
- 4
- low / i / high
- 1 / 2 / 4
- 本轮原值
- 0
- 未知区长度
- 3
- low / i / high
- 2 / 3 / 4
- 本轮原值
- 0
- 未知区长度
- 2
- low / i / high
- 2 / 3 / 3
- 本轮原值
- 2
- 未知区长度
- 1
- low / i / high
- 2 / 4 / 3
- 本轮原值
- 1
- 未知区长度
- 0
- low / i / high
- 2 / 4 / 3
- nums
- [0, 0, 1, 1, 2, 2]
- 方法返回
- None
选择步骤 8 步
变量与图例
把仓库分成已确认的 0 区、1 区、未知区和 2 区;i 只负责拆未知区最左边的一箱。
- low
- 下一块 0 应放入的位置,也是 1 区的左边界。
- i
- 下一块待检查的位置;只有确认换入值后才可右移。
- high
- 未知区的最右位置;high 右边已全为 2。
- [a,b) / [a,b]
- 半开 / 闭区间;空区间表示当前没有这类已确认元素。
算法步骤
- 初始化 low=0、i=0、high=len(nums)−1,使所有元素都位于未知区。
- 当 i≤high 时检查 nums[i]。若为 0,交换 nums[low] 与 nums[i],然后 low、i 都加 1。
- 若当前值为 1,只把 i 加 1,扩大已知 1 区。
- 若当前值为 2,交换 nums[i] 与 nums[high],只把 high 减 1,保留 i 继续检查换入值。
- i>high 时结束;原 nums 已排好,无需返回新数组。
代码(Python)
查看完整 Python 代码荷兰国旗三指针 · 与上方图解对应
low、i、high 划分四段,依据换入元素是否已知决定是否推进 i,是一遍原地算法。
from typing import List
class Solution:
def sortColors(self, nums: List[int]) -> None:
low, i, high = 0, 0, len(nums) - 1
while i <= high:
if nums[i] == 0:
nums[low], nums[i] = nums[i], nums[low]
low += 1
i += 1
elif nums[i] == 1:
i += 1
else:
nums[i], nums[high] = nums[high], nums[i]
high -= 1运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = Solution()
for original, expected in [([2,0,2,1,1,0],[0,0,1,1,2,2]), ([2,0,1],[0,1,2]), ([0],[0]), ([2,2],[2,2]), ([1,0],[0,1]), ([1,1,1],[1,1,1])]:
nums = original[:]
identity = id(nums)
assert s.sortColors(nums) is None
assert nums == expected
assert id(nums) == identity
复杂度
- 时间
- O(n),每轮未知区长度恰好减少 1,n 为数组长度。
- 空间
- O(1) 辅助空间,原地修改输入,返回 None。
易错点与边界
- 与 high 交换后不能 i+=1,否则会跳过新换入的未知值。
- 循环是 i<=high,使用严格小于会漏掉最后一格。
- 与 low 交换后可推进 i,因为换回的是已知的 1 或自身。
- 只给 nums 重新赋一个排序列表不会修改调用者的原列表;应原地写入。
主解之后,再比较与迁移。
其他解法 1
计数后回填时间 O(n),一次计数加一次回填;n 为数组长度。
值域固定为 0、1、2,可以先用三个槽位 counts 统计各色数量,再按颜色顺序覆盖原数组。第一次扫描必须先完成,第二次才开始写入,避免把尚未统计的输入覆盖。回填指针 i 表示下一个要写的位置,已经写完的前缀始终有序;每个 color 连续写 counts[color] 次即可。这个版本同样原地修改且只用常数辅助空间,但分成计数与回填两遍,适合用更直观的流程解释小值域计数排序。
- 创建 counts=[0,0,0],完整扫描 nums,对每个 x 执行 counts[x]+=1。
- 统计结束后把写指针 i 设为 0。
- 按 color=0、1、2 的顺序,分别重复 counts[color] 次。
- 每次令 nums[i]=color,再将 i 加 1,依次构造有序前缀。
- 回填完全部元素后函数自然结束,返回 None。
from typing import List
class Solution:
def sortColors(self, nums: List[int]) -> None:
counts = [0, 0, 0]
for x in nums:
counts[x] += 1
i = 0
for color in range(3):
for _ in range(counts[color]):
nums[i] = color
i += 1复杂度
- 时间
- O(n),一次计数加一次回填;n 为数组长度。
- 空间
- O(1) 辅助空间,固定 3 个计数;原地修改输入。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = Solution()
for original, expected in [([2,0,2,1,1,0],[0,0,1,1,2,2]), ([2,0,1],[0,1,2]), ([0],[0]), ([2,2],[2,2]), ([1,0],[0,1]), ([1,1,1],[1,1,1])]:
nums = original[:]
identity = id(nums)
assert s.sortColors(nums) is None
assert nums == expected
assert id(nums) == identity
预测练习
第一次把 i=1 的 2 与 high=5 的 0 交换后,为什么 i 仍要停在 1?
一点提示
这次换入的 0 来自未知区,而不是已知的 1 区。
答案与理由
新 nums[1]=0 还需要放进 0 区。若把 i 直接加到 2,就会让 0 留在 1 区,破坏分区不变式。
面试追问与解法取舍
计数回填适用于只有颜色值的数组。如果元素是带颜色的对象,不能只重写颜色而破坏对象信息;三路交换能移动对象,但通常不稳定。若要求同色对象保持原相对顺序,需要额外讨论稳定分区或借助额外空间,不能声称当前交换过程稳定。
模板迁移 / 相似题
试着不看代码,复述这三点
- 画清四段区间,比死记三个指针的移动规则更可靠。
- 换走 2 时只缩 high:从右端换来的元素仍未知。
- 换走 0 时可以同时移动 low 与 i:若 low<i,换回 i 的一定是已知的 1。
31. 下一个排列 中等
一句话题意
原地把整数数组改为字典序紧邻的下一个排列;若已是最大排列,则改为最小排列。
nums = [1,3,5,4,2,2];下一个排列为 [1,4,2,2,3,5],原地修改并返回 None。
生活化理解
把排列看成由左到右重要性递减的密码。要只涨一点,应尽量保住前面的位,只把最靠右且还能变大的那位调高一点,再把后面的数字摆成最小顺序。
枢轴交换与双指针反转
抓住不递增后缀的结构,三次线性处理即可找到刚好更大的排列,且只需常数辅助空间。核心思路
从右向左寻找第一个 nums[i]<nums[i+1] 的位置 i,把它作为枢轴。i 右侧是不递增后缀,已经是这些数字的最大排列,只重排它不可能让整体变大,必须提升 i 或更左的位置。选择最靠右的 i,才能保留尽可能长、优先级更高的前缀。
接着让枢轴增加得尽量少:从末尾往左找第一个严格大于 nums[i] 的 nums[j]。由于后缀不递增,这就是可用的最小较大值。交换后,后缀仍不递增:j 左侧不小于旧的候选值,j 右侧不大于旧枢轴,因此直接反转就能让后缀变为最小的升序排列。
用 left=i+1、right=n-1 指向后缀两端,每次交换后同时向内移动,已归位的两端不再参与处理。图中把枢轴 3 提升为 4 后,只反转右侧 [5,3,2,2],得到 [1,4,2,2,3,5]。若 i 最终为 −1,整个数组已是最大排列,此时同样的端点设置会反转全数组,得到最小排列。函数原地修改 nums,返回值为 None。
从右向左扫描时,i 右侧保持不递增,已是这些元素能组成的最大后缀。找到首个 nums[i]<nums[i+1] 后,用后缀中最小的更大值提升枢轴,再把仍不递增的后缀反转为升序,就得到最小的更大排列。
图解
- i
- 4
- 已知后缀
- [2]
- i
- 3
- 不递增后缀
- [2, 2]
- i
- 2
- 不递增后缀
- [4, 2, 2]
- i
- 1
- 不递增后缀
- [5, 4, 2, 2]
- i
- 1
- 枢轴值
- 3
- 不递增后缀
- [5, 4, 2, 2]
- i / j
- 1 / 5
- nums[i] / nums[j]
- 3 / 2
- i / j
- 1 / 4
- 下一候选值
- 2
- i / j
- 1 / 3
- 下一候选值
- 4
- i / j
- 1 / 3
- 枢轴 / 替换值
- 3 / 4
- i / j
- 1 / 3
- nums
- [1, 4, 5, 3, 2, 2]
- left / right
- 2 / 5
- 待反转后缀
- [5, 3, 2, 2]
- left / right
- 3 / 4
- nums
- [1, 4, 2, 3, 2, 5]
- left / right
- 4 / 3
- nums
- [1, 4, 2, 2, 3, 5]
- nums
- [1, 4, 2, 2, 3, 5]
- left / right
- 4 / 3
- 返回值
- None
选择步骤 14 步
变量与图例
像拨动数字密码:尽量晚地增加一位,再把它右边调成最小,才能只比原排列大一点点。
- i
- 从右往左找的枢轴下标;-1 表示整个数组已不递增。
- j
- 从末尾向左寻找严格大于 nums[i] 的交换对象。
- left / right
- 反转后缀时向内靠拢的两个端点。
- 后缀
- 枢轴右侧区间;提升枢轴前不递增,最后应变为不递减。
算法步骤
- 从 i=len(nums)−2 开始向左扫描;只要 i≥0 且 nums[i]≥nums[i+1] 就继续左移。
- 若 i≥0,从末尾令 j 向左移动,直到 nums[j] 严格大于枢轴 nums[i]。
- 交换 nums[i] 与 nums[j],让最靠右的可提升前缀刚好变大。
- 设置 left=i+1、right=len(nums)−1;i=−1 时,这两个端点覆盖整个数组。
- 当 left<right 时交换两端,再让 left 加 1、right 减 1,将后缀反转为升序。
- 原数组中留下下一个排列,函数自然返回 None。
代码(Python)
查看完整 Python 代码枢轴交换与双指针反转 · 与上方图解对应
利用后缀单调性直接反转,不排序、不复制,满足原地常数空间要求。
from typing import List
class Solution:
def nextPermutation(self, nums: List[int]) -> None:
i = len(nums) - 2
while i >= 0 and nums[i] >= nums[i + 1]:
i -= 1
if i >= 0:
j = len(nums) - 1
while nums[j] <= nums[i]:
j -= 1
nums[i], nums[j] = nums[j], nums[i]
left, right = i + 1, len(nums) - 1
while left < right:
nums[left], nums[right] = nums[right], nums[left]
left += 1
right -= 1运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = Solution()
for original, expected in [([1,2,3],[1,3,2]), ([3,2,1],[1,2,3]), ([1,1,5],[1,5,1]), ([1],[1]), ([1,3,2],[2,1,3]), ([1,5,1],[5,1,1]), ([2,2],[2,2])]:
nums = original[:]
identity = id(nums)
assert s.nextPermutation(nums) is None
assert nums == expected
assert id(nums) == identity
复杂度
- 时间
- O(n),枢轴扫描、后继扫描与后缀反转各最多线性;n 为数组长度。
- 空间
- O(1) 辅助空间,原地改写数组。
易错点与边界
- 找枢轴时跳过 >= 的相邻对,重复值不算上升。
- 交换对象必须严格大于枢轴,从右找才能得到最小的较大值。
- 交换后还须把后缀变成最小,否则会越过正确的下一个排列。
- 完全降序和全相等数组都需处理 i=−1;长度为1时自然保持不变。
主解之后,再比较与迁移。
其他解法 1
枢轴交换与后缀排序基线时间 使用通用排序的保守上界 O(n log n)。这里后缀非递增,Python Timsort 可识别单调段,实际该输入形态可为 O(n),但显式反转更直接。
前半段仍使用同样的字典序论证:找到最靠右的枢轴,用后缀里最小的严格较大值与它交换。区别在最后一步,把 nums[i+1:] 交给 sorted,再通过切片赋值写回,直接获得固定新前缀下的最小后缀。这样写法简短,但没有显式利用后缀已经不递增的性质,且切片与排序结果会占用额外空间。切片赋值仍修改同一个原列表;若没有枢轴,i=−1 会让排序范围自然成为全数组。
- 从右向左找第一个严格上升相邻对,令其左端下标为 i。
- 若存在枢轴,从末尾寻找第一个 nums[j]>nums[i] 的位置。
- 交换枢轴与候选,使尽可能长的前缀保留,只在最晚的位置提升。
- 执行 nums[i+1:]=sorted(nums[i+1:]),将整个后缀以升序写回。
- 函数自然结束并返回 None;无枢轴时同一步会把全数组排成最小排列。
from typing import List
class Solution:
def nextPermutation(self, nums: List[int]) -> None:
i = len(nums) - 2
while i >= 0 and nums[i] >= nums[i + 1]:
i -= 1
if i >= 0:
j = len(nums) - 1
while nums[j] <= nums[i]:
j -= 1
nums[i], nums[j] = nums[j], nums[i]
nums[i + 1:] = sorted(nums[i + 1:])复杂度
- 时间
- 使用通用排序的保守上界 O(n log n)。这里后缀非递增,Python Timsort 可识别单调段,实际该输入形态可为 O(n),但显式反转更直接。
- 空间
- O(n) 辅助空间,来自后缀切片、sorted 结果及切片赋值;仍修改同一原列表。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = Solution()
for original, expected in [([1,2,3],[1,3,2]), ([3,2,1],[1,2,3]), ([1,1,5],[1,5,1]), ([1],[1]), ([1,3,2],[2,1,3]), ([1,5,1],[5,1,1]), ([2,2],[2,2])]:
nums = original[:]
identity = id(nums)
assert s.nextPermutation(nums) is None
assert nums == expected
assert id(nums) == identity
预测练习
寻找枢轴时,为什么相等的两个 2 也必须跨过去?
一点提示
交换相等的数字能否让排列变得严格更大?
答案与理由
不能。后缀允许相等,扫描条件必须是 >=;找交换对象也必须跳过 <= 枢轴的值,确保提升是严格的。若全数组不递增,则直接反转得到最小排列。
面试追问与解法取舍
“上一个排列”可对称实现:找最靠右的下降枢轴,交换为后缀里最大的较小值,再把后缀调为非递增。若反复调用本题算法可按字典序遍历所有不同排列,但单次求后继不应先生成全部排列再排序,那会把线性问题变成阶乘规模。
模板迁移 / 相似题
试着不看代码,复述这三点
- 先找到尽量靠右的可提升位置,保证较早前缀保持相同。
- 不递增后缀从右向左遇到的第一个更大值,就是最小的合法提升量。
- 交换后后缀仍不递增,原地双指针反转即可,无需 O(n log n) 的排序。
287. 寻找重复数 中等
一句话题意
长度 n+1 的数组取值均在 [1,n],仅有一种数字重复且可重复多次;不修改数组、使用常数额外空间找出它。
nums = [1,3,4,2,2];重复数是 2。以 f(i)=nums[i] 建图:0→1→3→2→4→2→…
生活化理解
把数组当成自动路牌:站在编号 i 的路口,就沿 nums[i] 跳到下一个路口。有限路口中不停走一定绕圈;重复数字就是两条不同来路指向的同一路口,形成进入环的入口。
Floyd 快慢指针找入口
将值域约束转成函数图后,可用线性时间与常数空间找出重复数,并保持数组不变。核心思路
定义 f(i)=nums[i]:下标是节点,数组值表示下一站的下标。长度为 n+1,所有值都在 1…n,所以从 0 出发不会被指回 0,有限节点上的连续前进必然先走过一段链,再进入环。入口同时接收环外前驱和环内前驱的两条入边,这两个不同下标存着同一个值,因此入口下标正是重复数。
第一阶段令 slow=fast=0。每轮 slow 走一条边,fast 走两条边,然后才比较它们所在的下标;进入同一环后,快指针每轮多走一步,一定会追上。初始重合不算相遇,否则尚未进入环就会结束。图中第一次相遇在下标 4,它只是环内位置,还不是答案。
第二阶段把 finder 放回 0,slow 留在相遇点,两者都每轮走一步。设链长为 a、环长为 L,首次相遇点沿环距入口 b 步;相遇时慢指针走 t 步,快指针走 2t 步,差值 t 必为 L 的倍数,又有 t≡a+b (mod L),所以相遇点再走 a 步就回到入口。finder 也恰好用 a 步到入口;此前它在环外,不会提前碰到 slow。图中的 a=3、L=2,最终二者在下标 2 汇合,返回 finder 本身,而非 nums[finder]。
每个下标只有一条出边 i→nums[i],值域 1…n 保证下一跳仍合法,且 0 没有入边。有限站点上的连续前进必进入环;环入口同时接收链外和环内的入边,对应同一个值出现至少两次。
图解
- slow / fast
- 0 / 0
- 函数路线
- 0→1→3→2→4→2…
- slow / fast
- 1 / 3
- 慢 / 快累计步数
- 1 / 2
- slow == fast
- False
- slow / fast
- 3 / 4
- 慢 / 快累计步数
- 2 / 4
- slow == fast
- False
- slow / fast
- 2 / 4
- 慢 / 快累计步数
- 3 / 6
- slow == fast
- False
- slow / fast
- 4 / 4
- 慢 / 快累计步数
- 4 / 8
- slow == fast
- True
- finder / slow
- 0 / 4
- 环长 L
- 2
- 链长 a / 环内距离 b
- 3 / 1
- finder / slow
- 1 / 2
- 同步步数
- 1
- finder == slow
- False
- finder / slow
- 3 / 4
- 同步步数
- 2
- finder == slow
- False
- finder / slow
- 2 / 2
- 同步步数
- 3
- finder == slow
- True
- return finder
- 2
- 重复位置
- 下标 3、4
- 重复值
- 2
选择步骤 10 步
变量与图例
数组值被当作下一站的下标:慢车每轮一站,快车两站,先在环里碰头,再从起点与碰头点同步走向入口。
- nums[i]
- 从下标 i 指向的下一站下标;不是任意链表的节点值。
- slow / fast
- 第一阶段每轮分别跳 1 条 / 2 条边,存的是当前下标。
- finder
- 第二阶段从下标 0 出发,每轮跳 1 条边。
- a / L / b
- 教学距离:0 到入口 a=3,环长 L=2,入口沿环到首次相遇点 b=1。
算法步骤
- 以 f(i)=nums[i] 理解下一跳,并初始化 slow=fast=0。
- 在第一阶段先执行 slow=nums[slow]、fast=nums[nums[fast]],移动后相等才跳出循环。
- 初始化 finder=0,保留 slow 在刚才的相遇点。
- 只要 finder!=slow,就让两者各沿 nums 跳一条边。
- 第二阶段相遇的位置是环入口,返回它的下标 finder,也就是重复的数组值。
代码(Python)
查看完整 Python 代码Floyd 快慢指针找入口 · 与上方图解对应
第一阶段在函数图环内相遇,第二阶段用从0出发的 finder 与 slow 同步寻找入口,不改变任何数组元素。
from typing import List
class Solution:
def findDuplicate(self, nums: List[int]) -> int:
slow = fast = 0
while True:
slow = nums[slow]
fast = nums[nums[fast]]
if slow == fast:
break
finder = 0
while finder != slow:
finder = nums[finder]
slow = nums[slow]
return finder运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = Solution()
for nums, expected in [([1,3,4,2,2],2), ([3,1,3,4,2],3), ([1,1],1), ([2,2,2,2,2],2), ([3,3,3,3],3), ([1,4,4,2,4],4)]:
before = nums[:]
assert s.findDuplicate(nums) == expected
assert nums == before
复杂度
- 时间
- O(n),n+1 为数组长度,入环路径与环的长度都不超过节点数。
- 空间
- O(1) 辅助空间;只读数组,不修改输入。
易错点与边界
- 数组值是下一节点下标,fast 每轮是 nums[nums[fast]],不是 fast+=2。
- 第一阶段初始两个指针都为0,先移动再判断,不能直接判等后退出。
- 第一阶段的相遇点不一定是入口,必须执行第二阶段。
- 重复值可能出现三次及以上;用数组和减去1..n之和不能通用求解。
主解之后,再比较与迁移。
其他解法 1
值域二分与抽屉原理时间 O(n log n),每轮统计扫描 n+1 项,二分值域约 log n 轮;n=1 时直接返回。
二分的是可能的答案值 [1,n],不是数组下标,输入也无需有序。对中点 mid 完整扫描数组,count 统计值≤mid 的元素数量。若重复数 d>mid,那么 1…mid 中每个值最多出现一次,count≤mid。若 d≤mid,设它共出现 r 次:数组长度为 n+1 且只有这一种重复值,整个 1…n 值域恰好缺失 r−2 个值;即使这些缺失全部落在前 mid 个值中,count 仍至少为 mid−(r−2)+(r−1)=mid+1。因此 count>mid 恰好表示 d≤mid,可用这个单调判断收缩值域。left、right 始终包住重复数;每轮重新扫描,只需要常数辅助空间,但比 Floyd 多了二分轮次。
- 设置 left=1、right=len(nums)−1,让区间覆盖所有可能的重复值。
- 当 left<right 时取 mid=(left+right)//2。
- 扫描整个 nums,统计 count=sum(x<=mid for x in nums);比较使用的是全局值阈值 mid。
- 若 count>mid,重复数在 [left,mid],令 right=mid;否则令 left=mid+1。
- 区间收缩为一个值时返回 left,过程中只读原数组。
from typing import List
class Solution:
def findDuplicate(self, nums: List[int]) -> int:
left, right = 1, len(nums) - 1
while left < right:
mid = (left + right) // 2
count = sum(x <= mid for x in nums)
if count > mid:
right = mid
else:
left = mid + 1
return left复杂度
- 时间
- O(n log n),每轮统计扫描 n+1 项,二分值域约 log n 轮;n=1 时直接返回。
- 空间
- O(1) 辅助空间;sum 使用生成器,不创建长度 n 的列表,也不修改输入。
这个方法的边界
- 二分的是值域,不要求数组有序;不能先原地排序或用符号标记破坏输入。
运行示例与边界断言
把下面断言接在上方代码之后,在本地 Python 中运行。
s = Solution()
for nums, expected in [([1,3,4,2,2],2), ([3,1,3,4,2],3), ([1,1],1), ([2,2,2,2,2],2), ([3,3,3,3],3), ([1,4,4,2,4],4)]:
before = nums[:]
assert s.findDuplicate(nums) == expected
assert nums == before
预测练习
第一阶段在下标 4 相遇,为什么不能直接返回 4?
一点提示
重复数对应的是环入口,而相遇点由速度与路程决定。
答案与理由
相遇点 4 在环内,但入口是 2。把 finder 放回 0,让它与仍在 4 的 slow 同步走 3 步,才会在入口 2 相遇并返回 2。
面试追问与解法取舍
如果允许 O(n) 空间,哈希集合能一遍发现第二次出现的数字;如果允许改输入,可讨论排序或原地标记,但这两条放宽都改变了本题要求。Floyd 的 O(n) 与值域二分的 O(n log n) 都满足只读和常数空间,前者依赖函数图结构,后者更容易扩展到“计数超过容量”的问题。
模板迁移 / 相似题
试着不看代码,复述这三点
- 先写清 f(i)=nums[i],数组值是边的目的下标;答案是环入口下标,也正是重复的数组值。
- 第一阶段只负责确保进入同一个环;不能把任意相遇点当入口。
- 第二阶段成立于从 0 开始的同一套初始化;不要混抄从 nums[0] 开始的另一版本指针规则。