题库
100 道题 · 17 个专题

图解算法,读懂每一步

LeetCodeHot 100

Python 题解 · 分步图解 · 多解法对照

进入题库

100 道题17 个专题自主选题

算法、状态与 Python 的空间图解 浅色透视界面展示二分查找代码、七个数组格及二叉树。数组当前中点是下标3,值7小于目标9,下一步移到右半区间。 binary_search.py PYTHON 01020304050607080910 def lower_bound(nums, target): left, right = 0, len(nums) while left < right: mid = (left + right) // 2 if nums[mid] < target: left = mid + 1 else: right = mid return left # 缩小区间,保留答案所在的位置 二叉树 · 中序遍历 8412261014 二分查找target = 9 135791113 mid = 3 7 < 9 → left = mid + 1 → 只看右半区间 IDEA / STATE / CODEFIG. 001 — ALGORITHMS
把思路、图解与实现,连起来看。LeetCode Hot 100 Python 图解教程
← 返回题库
哈希01 / 100

1. 两数之和 简单

标签:数组 · 哈希表

力扣原题 ↗
01 / 理解题目

一句话题意

在整数数组中找到两个不同位置,使对应数值之和等于 target,返回这两个下标;题目保证唯一答案。

教学示例

nums=[4,3,7,3],target=6 → 返回 [1,3]。

生活化理解

你拿着一张面值为 target 的餐券,想用两件商品正好花完。每看到一件价格 x 的商品,就先问登记簿里有没有价格 target−x 的旧商品。登记簿存的是“价格→货架位置”,因为最后要交的是位置而不是价格。

02 / 讲透主解法

一次遍历哈希表

用补数查找把两层枚举化为一次扫描,先查后存也能直接解释为什么不会使用同一位置两次。

核心思路

把条件 x+y=target 改写为 y=target−x,原本需要枚举的第二个数变成了一次查找。遍历到下标 i 时,seen 只包含下标小于 i 的元素,键是数值,值是一个对应下标。这个不变量直接保证命中的两个位置不同。

必须先查补数、再登记当前元素。例如 [3,3]、target=6:第一个 3 查不到后登记,第二个 3 找到前面的下标 0。若先登记后查,第一次就可能把下标 0 用两遍。重复值覆盖旧下标不会破坏正确性:尚未找到答案时,保留任意一个旧位置即可。

每对答案总有一个较晚出现的位置;处理它时,较早那个位置已经在表里,因此不会漏解。哈希操作平均为常数时间,所以总时间是平均 O(n)。

不变量

在查询下标 i 时,seen 只包含严格早于 i 的元素位置,因此不会重复使用同一项。答案中较晚的元素被读到时,较早那个元素已经登记,故一次遍历不会漏解。

图解

1 / 9
nums = [4, 3, 7, 3] target = 640317233尚未读取任何元素seen:数值 → 旧下标补数查找target = 6need 尚未计算
seen
{}
target
6
选择步骤 9 步
变量与图例

观察每次“先查补数、再存当前值”:两个 3 数值相同,位置却不同。

i / x
当前下标及 nums[i] 的值
need
当前缺少的值 target−x
seen
已读数值映射到一个旧下标
target
两数之和的目标值
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 令 seen 为空,按原始顺序读取下标 i 和数值 x。
  2. 计算 need=target−x,在 seen 中查 need。
  3. 命中时返回 [seen[need], i];两个下标分别来自已读部分和当前位置。
  4. 未命中时登记 seen[x]=i,然后处理下一项。

代码(Python)

查看完整 Python 代码一次遍历哈希表 · 与上方图解对应

seen 仅记录已经读过的数值和下标。对当前 x 查询 target−x,把“找另一项”的线性扫描压成平均常数时间。

PYTHON 3
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)。
03 / 检查边界

易错点与边界

  • 两个值可以相同,两个位置必须不同;[3,3] 是合法答案。
  • 先查表再插入当前项,避免把自己当作补数。
  • 含负数和零时补数公式仍然有效,不能假设数组有序。
  • 返回原数组中两个不同位置的下标,不是两项数值。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

双重枚举(基线)时间 O(n²),最多检查 n(n−1)/2 个下标对。

固定第一个下标 i,只枚举 j>i,直接检查所有不同位置组合。它不保存补数表,适合先理解题意或用小数据核对优化结果;代价是最多检查 n(n−1)/2 对,大数组会比哈希法慢很多。

  1. 依次枚举第一个位置 i。
  2. 让 j 从 i+1 扫到末尾,保证两个位置不同且同一对不重复枚举。
  3. 若 nums[i]+nums[j]==target,立即返回 [i,j]。
PYTHON 3
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²) 对。面试时先确认输出是一个答案、所有下标对,还是去重后的数值对。

其他思路的边界

  • 返回下标,不是两项数值;排序会改变位置,需要保留原下标。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 把枚举第二项改为查询 target−x。
  2. 哈希表保存下标,且先查询再登记,保证两项位置不同。
  3. 重复数值可以构成答案;不应直接对输入去重。
← 返回题库
哈希02 / 100

49. 字母异位词分组 中等

标签:数组 · 哈希表 · 字符串 · 排序

力扣原题 ↗
01 / 理解题目

一句话题意

将字符串数组分组:同组字符串的字母种类及每个字母的出现次数完全相同,字符顺序可以不同。

教学示例

strs=["ab","ba","aa","a"] → [["ab","ba"],["aa"],["a"]](组间顺序不限)。

生活化理解

把单词看作装字母积木的袋子。袋子里积木的摆放顺序不重要,但每种积木的数量必须一样。排序签名相当于把积木排好队拍照;计数签名则是填写一张 26 格库存单。

02 / 讲透主解法

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 维频次数组,桶内所有词的该签名完全相同。字母顺序不改变频次,字母数量改变则至少一个维度不同,因此同桶恰好等价于互为异位词。

图解

1 / 9
strs = ["ab", "ba", "aa", "a"]ab0ba1aa2a3每个词都从新的 26 个零开始计数counts:a…z 的完整 26 维频次进入第一个单词后才创建 countsgroups:完整签名作键,原始单词进入同一桶
groups
{}
counts
尚未创建
选择步骤 9 步
变量与图例

先观察 ab 怎样逐字符计数,再比较 ba、aa、a 为什么进入不同的桶。

word
当前输入词
ch
当前计数的字符
counts
当前词中 a…z 各自出现次数
groups
频次 tuple → 具有此签名的原词列表
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 建立 groups=defaultdict(list),依次读取每个 word。
  2. 为当前词重新创建 counts=[0]*26,逐字符增加相应字母的频次。
  3. 词内计数完成后,以 tuple(counts) 为键,把原 word 追加到对应桶。
  4. 全部单词处理完成后,返回 list(groups.values())。

代码(Python)

查看完整 Python 代码26 维计数签名 · 与上方图解对应

counts 保存每个小写字母的频次;tuple(counts) 是不可变键。总字符数较大时省掉排序,但固定 26 格只适用于当前字符集约束。

PYTHON 3
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)。
03 / 检查边界

易错点与边界

  • 不能用 set(word) 作为签名,它丢失字母出现次数。
  • 保留重复的输入字符串,分组不是去重。
  • 计数键要转成 tuple,不能直接把 list 当字典键。
  • 测试应规范化组顺序后比较,题目允许任意输出顺序。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

排序签名时间 O(n+Σ |s| log(|s|+1));若 n 个词、最大词长 m,可写 O(n+nm log(m+1)),空串仍需遍历和分桶。

把每个词的字符排序,得到规范代表 key:eat 与 tea 都变成 aet,ab 与 aab 则不同。排序签名相同当且仅当字符多重集合相同,因此按 key 分桶不会误分或漏分。它写法短,也能用于更广泛字符集;与计数主解相比,每个词多一次排序。groups[key] 仍保存原词和重复项,空串的签名为空串。

  1. 建立 groups=defaultdict(list),依次读取 word。
  2. 计算 key=''.join(sorted(word)),保留重复字符,生成排序签名。
  3. 把原 word 追加到 groups[key],不把排序后的签名当作输出词。
  4. 返回 list(groups.values()),无需再对答案排序。
PYTHON 3
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 后对键值对排序生成不可变签名。不要只把单词字符的哈希值相加,那样不同的字符多重集合可能产生同一个签名;规范化键需要保留足够信息,字典自身会处理普通哈希碰撞。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 异位词比较字母的频次,而非字符顺序或字符集合。
  2. 每词重置 counts;分组表 groups 则贯穿整个遍历。
  3. 用不可变 tuple 作键,桶中保留原始字符串。
← 返回题库
哈希03 / 100

128. 最长连续序列 中等

标签:并查集 · 数组 · 哈希表

力扣原题 ↗
01 / 理解题目

一句话题意

从无序整数数组中找出数值连续的最长序列长度,序列不要求在原数组中相邻;目标时间为 O(n)。

教学示例

nums=[6,2,1,3,2,8] → 返回 3;最长连续数值序列为 [1,2,3]。

生活化理解

居民门牌散落在一堆卡片中,要找最长一段没有断号的街区。只有门牌 x−1 不在卡堆时,x 才是街区起点;从每条街的门口走一次,就不会从每户门前重复走到街尾。

02 / 讲透主解法

哈希集合,只从段首扩展

只让连续段的起点发起扩展,可以直接证明所有扩展合计为线性时间,满足题目的时间要求。

核心思路

集合 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,所以去重后每段只完整扫描一次。

图解

1 / 10
nums = [6,2,1,3,2,8]:先去重,不要求输入有序602112332485values 的本次遍历次序:1,2,3,6,8(次序不影响答案)12368段首扩展x = 尚未读取y = 尚未创建 ;best = 0本帧检查连续段按数值,不按原数组位置
values
{1,2,3,6,8}
x
尚未读取
y
尚未赋值
best
0
选择步骤 10 步
变量与图例

区分原数组位置与集合中的数值:y 是试探的数值,绝不是数组下标。

values
输入去重后的哈希集合
x
外层当前数值;无前驱时才作为段首
y
内层下一个待检查的数值
best
已完成连续段的最大长度
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 建立 values=set(nums) 消除重复值,初始化 best=0。
  2. 遍历 values 中的 x,只在 x−1 不存在时启动扩展。
  3. 令 y=x,只要 y 在 values 中就让 y 加一,直到找到首个缺失值。
  4. 用 y−x 更新 best;所有集合成员处理完后返回 best,空集合保留初值 0。

代码(Python)

查看完整 Python 代码哈希集合,只从段首扩展 · 与上方图解对应

S 存所有不同值;x−1 不在 S 是启动扫描的唯一条件。y 指向当前待检查的数,best 保存已完成连续段的最大长度。

PYTHON 3
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)。
03 / 检查边界

易错点与边界

  • 连续序列不是原数组中的连续子数组,不能用窗口按原位置找。
  • 从每一个数向后扩展会使 [1,2,...,n] 达到 O(n²)。
  • 遍历集合而非原数组,避免重复段首触发重复长扫描。
  • 重复值不延长序列;空数组答案为 0,负数同样按差为 1 判断。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

排序后去重扫描(比较基线)时间 O(n log n),排序主导;扫描 O(n)。

排序副本 a 使连续数值相邻出现,current 表示当前连续段长度。相等值不增加长度,差为 1 则续接,否则从 1 重新计数;best 保留历史最大值。这一方法适合区分“重复值”和“连续段断点”,但排序需要 O(n log n),不满足本题 O(n) 的目标。

  1. 输入为空时返回 0;否则建立排序副本 a,令 current=best=1。
  2. 从第二个元素开始扫描;若 a[i]==a[i−1],直接跳过重复值。
  3. 若 a[i]==a[i−1]+1,让 current 加一;否则令 current=1。
  4. 每次处理非重复值后更新 best,最后返回 best。
PYTHON 3
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)。若数据持续插入并需每次立刻回答,静态“找段首再扫描”会重复工作,可以讨论维护区间两端长度的哈希表;静态题无需引入这套更新机制。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. set 去重后,连续性依然由数值加 1 决定,不依赖原位置。
  2. 前驱不存在才扩展,是平均 O(n) 的关键。
  3. y 停在首个缺口,长度用 y−x,避免多算一项。
← 返回题库
双指针04 / 100

283. 移动零 简单

标签:数组 · 双指针

力扣原题 ↗
01 / 理解题目

一句话题意

原地把数组中的所有零移到末尾,保持非零元素原有相对顺序,不返回新数组。

教学示例

nums=[0,1,0,3,12] → 原地变为 [1,3,12,0,0],函数返回 None。

生活化理解

一排储物格里有空盒子(零)和有物品的盒子(非零)。read 负责按顺序检查,write 指向下一格该放物品的位置。物品按被发现的顺序摆到前面,剩余位置统一留空,因此物品之间不会换序。

02 / 讲透主解法

稳定压缩后补零

先保留非零前缀、再补齐尾部零,两个阶段清楚,便于说明原地修改与相对顺序为何成立。

核心思路

读取 nums[read] 前,nums[0:write] 已经按原顺序收集了读过的所有非零值,且 write≤read。write 是下一个非零元素的目标位置:遇到非零值就写入 nums[write] 并推进 write。因为写入位置不在尚未读取区域,原始后续数据不会被提前破坏。

第一次遍历结束后,write 等于非零元素总数,前 write 项已经正确,尾部只是待处理旧数据。再把 [write,n) 全部写成 0 即可。循环顺序保证稳定性:先读到的非零值总是占据更靠左的位置。处理过程中尾部可以暂时保留旧值的副本,不要求它提前全为零。

不变量

每轮读取结束后,nums[:write] 恰好是所有已读非零值,且顺序与原输入一致。write 始终不超过下一待读位置,写入不会覆盖还没读过的数据;第二遍只将此前缀之外的位置补零。

图解

1 / 9
原地稳定压缩,再逐格补零00110233124write还没有收集到非零元素稳定非零前缀 nums[:write][]长度 write = 0尾部状态前缀外可能仍有旧值或重复值只有补零结束后才是最终数组
nums
[0, 1, 0, 3, 12]
write
0
选择步骤 9 步
变量与图例

留意第一遍结束时的 [1,3,12,3,12]:尾部副本尚未补零,不是最终答案。

read
第一遍当前读取下标
write
非零前缀长度,也是下次写入下标
i
第二遍补零的下标
nums
正在原地修改的同一列表
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 初始化 write=0,read 从左到右扫描所有下标。
  2. 当前值非零时,把它填到 write,随后 write 加一。
  3. 当前值为零时只推进 read,不改变 write。
  4. 扫描结束后把 write 及其后的所有位置填零;原列表就地修改,函数自然返回 None。

代码(Python)

查看完整 Python 代码稳定压缩后补零 · 与上方图解对应

write 是下一个非零元素的目标位置。第一遍只压缩非零元素,第二遍清零尾部;逻辑阶段明确、容易证明。

PYTHON 3
from typing import List

class Solution:
    def moveZeroes(self, nums: List[int]) -> None:
        write = 0
        for read in range(len(nums)):
            if nums[read] != 0:
                nums[write] = nums[read]
                write += 1
        for i in range(write, len(nums)):
            nums[i] = 0
运行示例与边界断言

把下面断言接在上方代码之后,在本地 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) 辅助空间;原地修改,无新输出数组。
03 / 检查边界

易错点与边界

  • 不能只给局部变量 nums 绑定新列表,那不会修改调用者的原数组。
  • 要求保持非零值顺序,不能随意把末尾非零项换到开头。
  • 覆盖法第一遍尾部可能有旧非零值,必须补零。
  • 全零、无零、单元素都自然适用,不需要额外分支。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

快慢指针交换时间 O(n),每个元素检查一次。

read 搜索非零值,write 是非零前缀后的第一个可用位置。它还维护 nums[write:read] 全是零:遇到非零值时交换 write 与 read,当前非零值归位,旧零移到后方,因此不需要主解的第二遍补零。非零值仍按读取顺序进入前缀;write==read 时跳过自交换,减少原本紧凑前缀上的无效写入。

  1. 令 write=0,read 从左到右遍历 nums。
  2. 读到零时跳过;读到非零值且 write!=read 时,交换 nums[write] 与 nums[read]。
  3. 每读到一个非零值都让 write 加一,无论是否发生交换。
  4. 遍历完成后原数组已形成非零前缀与全零尾部,无需再补零。
PYTHON 3
from typing import List

class Solution:
    def moveZeroes(self, nums: List[int]) -> None:
        write = 0
        for read in range(len(nums)):
            if nums[read] != 0:
                if write != read:
                    nums[write], nums[read] = nums[read], nums[write]
                write += 1

复杂度

时间
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) 空间,实际写入次数取决于零的分布,不能仅凭一遍或两遍判断谁更快。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 稳定性来自按 read 的原顺序追加非零值。
  2. 压缩阶段只保证 nums[:write];不要误把尾部副本当成错误。
  3. 最后补零并原地完成,不需要额外数组。
← 返回题库
双指针05 / 100

11. 盛最多水的容器 中等

标签:贪心 · 数组 · 双指针

力扣原题 ↗
01 / 理解题目

一句话题意

从表示竖线高度的数组中选两条竖线,与横轴围出容器,求最大面积;面积等于两线距离乘较短线高度。

教学示例

height=[2,4,1,5,3] → 返回 9;最优下标为 1、4。

生活化理解

用两块高度不同的挡板围水池,水位由矮板决定,池宽是挡板间距。固定矮板并把另一块板往里挪,水位上限不变、宽度变小,绝不可能比当前更好;所以要尝试换掉矮板。

02 / 讲透主解法

相向双指针

每轮只计算一次面积并移动短板,排除候选的理由明确,线性时间和常数空间也容易复现。

核心思路

设左右下标为 left、right,面积为 (right−left)×min(height[left],height[right])。初始选最宽区间。若左板较矮,对任意 left<j<right,固定左板的面积不超过 height[left]×(j−left),又不超过当前面积。因此所有以 left 为左端、右端位于当前区间内的候选都可以一次排除。

对称地,右板较矮时可以排除 right。我们并不是断言移动短板一定使面积变大,而是证明不移动它所剩的候选都不值得检查。best 保存已经计算的最大面积;在每轮计算之后丢弃一个已证明不会改进 best 的端点,最终不会遗漏最优值。

两端相等时,丢弃任意一端都成立。边界条件必须是 left<right,因为容器需要两条不同的线。中间的线不会阻挡容器,这是本题和接雨水模型的重要区别。

不变量

best 保存所有已经结算候选的最大面积。每轮舍弃较矮挡板:所有保留该挡板、另一端向内的候选都更窄且水位不更高,因而不可能改进答案。

图解

1 / 10
height = [2, 4, 1, 5, 3]0123452041125334leftright候选 / 尚未更新 best宽 = 4−0 = 4水位 = min(2,3)面积 = 8best = 0两端挡板分别是高度 2 与 3
left
0
right
4
best
0
选择步骤 10 步
变量与图例

每轮先看水位由哪块短板限制,再看舍弃它后宽度与 best 怎样变化。

left / right
当前两块边界挡板的下标
height
每块挡板的高度
当前面积(图示)
(right−left)×两端较小高度,不是额外代码变量
best
已经计算过的面积最大值
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. left=0、right=n−1,best=0。
  2. 按当前宽度和较短高度计算面积并更新 best。
  3. 左高度较小或相等则 left 加一,否则 right 减一。
  4. 左右指针相遇后结束,返回 best。

代码(Python)

查看完整 Python 代码相向双指针 · 与上方图解对应

每次计算当前容器后移动短板,通过上界证明一次排除一组不可能更好的容器。left 和 right 总共移动 n−1 次。

PYTHON 3
from typing import List

class Solution:
    def maxArea(self, height: List[int]) -> int:
        left, right = 0, len(height) - 1
        best = 0
        while left < right:
            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) 辅助空间。
03 / 检查边界

易错点与边界

  • 面积的宽度是 right−left,不是两端之间的元素数量 right−left+1。
  • 应移动较短的一边,移动较长边不能突破短板的水位上界。
  • 移动后面积可能减小,不影响算法正确;best 必须保存历史最优。
  • 高度可以为零,两个下标必须不同;中间高线不参与本题面积计算。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

双指针跳过不更高的挡板时间 O(n),两端仍只各自单向经过每个位置一次;可减少面积计算次数。

先记录当前短板高度 low 并计算面积,再从两端连续跳过高度≤low 的挡板。后续保留这些挡板形成的容器更窄、有效高度不超过 low,无法改进刚算过的面积。它与主解使用同一个排除理由,但一轮可以跳过多个位置,减少面积计算次数;两个指针仍只向内移动,总时间仍为 O(n)。

  1. 令 left=0、right=n−1、best=0。
  2. 在 left<right 时取 low=min(height[left],height[right]),用 (right−left)×low 更新 best。
  3. 保持 left<right,连续右移左端直到其高度大于 low;再连续左移右端直到其高度大于 low。
  4. 对剩余候选重复上述过程,指针相遇后返回 best。
PYTHON 3
from typing import List

class Solution:
    def maxArea(self, height: List[int]) -> int:
        left, right = 0, len(height) - 1
        best = 0
        while left < right:
            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),适合作为小规模验证器。第二个实现是同一排除原理的批量跳过版本,并非新的复杂度级别。若高度更新后需多次查询,当前线性算法没有直接的常数时间更新结构,不能仅维护最高的两条线:距离也是面积的一部分。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 面积同时取决于宽度和短板高度,不能只找最高两根柱子。
  2. 先结算再移动短板,依据是候选被支配的排除证明。
  3. 最终返回历史 best,不是指针相遇前最后一次计算的面积。
← 返回题库
双指针06 / 100

15. 三数之和 中等

标签:数组 · 双指针 · 排序

力扣原题 ↗
01 / 理解题目

一句话题意

找出数组中所有和为零的三元组,三个元素来自不同下标,结果按数值去重。

教学示例

nums=[-1,2,-1,0,1,2,-2] → [[-2,0,2],[-1,-1,2],[-1,0,1]](组间顺序不限)。

生活化理解

从预算表里挑三笔收支让总账为零。先锁定一笔,再在排好序的其余账目里找两笔补齐。总和太小就换掉较小的一笔,总和太大就换掉较大的一笔;相同面额的账目不必反复生成同一种组合。

02 / 讲透主解法

排序与相向双指针

排序让指针移动方向和按值去重都清楚,能直接生成唯一三元组,无需额外的结果去重集合。

核心思路

先排序为 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 不可能再命中;命中后跳过相同值,只去除重复值组合,不会漏掉不同组合。

图解

1 / 14
排序后的 a = [-2,-1,-1,0,1,2,2]-20-11-1203142526后续下标均属于 a;原 nums 的次序不参与指针扫描result:已确认的不重复三元组[]当前状态尚未固定 itotal 尚未计算L / R 表示移动后的当前指针
i
尚未读取
left
尚未赋值
right
尚未赋值
total(最近计算)
尚未计算
result
[]
选择步骤 14 步
变量与图例

把“根据和值移动”和“命中后跳过重复值”分开看;图上指针显示每次操作后的实际位置。

a
排序后的输入副本;图示下标均指向它
i
固定第一项的下标
left / L
第二项指针
right / R
第三项指针
total
最近一次检查的三数和;移动后尚未重算
result
已记录的不同值三元组
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 建立排序副本 a 和空 result,枚举固定位置 i;与前一个固定值相同则跳过。
  2. 若 a[i]>0,立即结束外层;否则令 left=i+1、right=n−1。
  3. 在 left<right 时计算 total=a[i]+a[left]+a[right];总和偏小移动 left,偏大移动 right。
  4. 总和为零时保存结果,两端各移动一步,再分别跳过与刚用过的值相同的元素。
  5. 当前后缀搜索结束后继续下一个固定位置,最后返回 result。

代码(Python)

查看完整 Python 代码排序与相向双指针 · 与上方图解对应

有序数组让和值变化方向可预测。跳过重复固定值,并在找到答案后跳过两端重复值,从生成过程直接消除重复结果。

PYTHON 3
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 为三元组数。
03 / 检查边界

易错点与边界

  • 题目去重的是三元组数值组合,不是三个原下标组合。
  • 不能预先对 nums 去重,[-1,-1,2] 和 [0,0,0] 都可能需要重复值。
  • 找到答案后必须移动指针,否则 while 原地循环。
  • 相向双指针的移动规则以排序为前提;先建立有序数组,再根据总和偏小或偏大移动指针。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

固定一项与哈希补数时间 平均 O(n²),每轮后缀哈希扫描;排序三个元素是 O(1)。

每轮固定 i,在原数组后缀中用 seen 查找此前出现的补数 need=−nums[i]−nums[j]。seen 只保存当前固定项之后、j 之前的值,先查后存保证三个位置不同。输入无需排序;每个命中的三元组单独排序成 tuple,再放进 result 集合按值去重。与主解相比,它把去重交给结果集合,适合从两数之和的哈希方法迁移理解。

  1. 建立空结果集合 result,逐个固定位置 i。
  2. 每轮重建 seen,令 j 从 i+1 向右扫描。
  3. 计算 need=−nums[i]−nums[j];若 need 在 seen 中,把三个数排序成 tuple 加入 result。
  4. 查询之后再把 nums[j] 加入 seen,供后续位置配对。
  5. 遍历完成后,将 result 中每个 tuple 转成列表并返回。
PYTHON 3
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. 排序提供和值的单调性,先固定一项,再把三数问题变成两数问题。
  2. 固定项去重与命中后两端去重承担不同职责。
  3. 按值组合去重,不按元素去重;两个不同位置的 −1 都可能需要。
← 返回题库
双指针07 / 100

42. 接雨水 困难

标签:栈 · 数组 · 双指针 · 动态规划 · 单调栈

力扣原题 ↗
01 / 理解题目

一句话题意

把非负数组看成宽度为 1 的柱子,计算下雨后所有柱子间能存下的总水量。

教学示例

height=[3,0,2,0,4,1] → 返回 7;各列水量 [0,3,1,3,0,0]。

生活化理解

街道两侧竖着高低不一的挡墙。站在某个位置,左边最高墙和右边最高墙中较矮的一堵决定水位;脚下已有的砖块不能算水。前后缀法逐户查两边最高墙,双指针法则从已能确定水位的一侧逐户结账。

02 / 讲透主解法

双指针与两侧最高值

按列结算水量,同时用两个历史最高值确定较低一侧的最终水位,线性时间且只需常数空间。

核心思路

一列能装多少水,取决于它左右最高挡板中较低的一侧,再减去当前柱高;只看紧邻柱子不够,水位可能由远处的高墙支撑。双指针保留尚未结算的区间 [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。每轮先更新两端最高值,再结算最高值较小的一端:另一侧已存在足够高的挡板,因此这个较小最高值就是当前列的最终水位。

图解

1 / 8
height = [3, 0, 2, 0, 4, 1]0123430·01·22·03·44·15·leftright边界最高值与累计水量left_max = 0right_max = 0total = 0蓝色:已结算的水底部:各列水量,· 表示待算完整输入保留;开始时所有列均待结算
left
0
right
5
left_max
0
right_max
0
total
0
各列已结算水量(图示)
['·', '·', '·', '·', '·', '·']
选择步骤 8 步
变量与图例

每帧只给真正结算的列加水;观察较小的边界最高值如何确定那一列的水位。

left / right
尚未结算区域的两端下标;图上为移动后的实际值
left_max
从左端探测到的最高柱高
right_max
从右端探测到的最高柱高
total
已结算列的水量之和
底部水量(图示)
逐列记录教学过程;· 代表未结算,并非代码额外数组
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 初始化 left=0、right=n−1,left_max=right_max=total=0。
  2. 只要 left≤right,就先把 height[left] 和 height[right] 纳入对应最高值。
  3. 若 left_max≤right_max,累加 left_max−height[left],再让 left 加一。
  4. 否则累加 right_max−height[right],再让 right 减一。
  5. 当 left>right,所有列结算完成,返回 total。

代码(Python)

查看完整 Python 代码双指针与两侧最高值 · 与上方图解对应

每轮用较小的历史最高值结算对应端点。right_max 为左侧提供足够高的右墙见证,left_max 为右侧提供对称见证;因此无需知道未扫描区域的最高墙。

PYTHON 3
from typing import List

class Solution:
    def trap(self, height: List[int]) -> int:
        left, right = 0, len(height) - 1
        left_max = right_max = 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) 辅助空间。
03 / 检查边界

易错点与边界

  • 本题累加每一格上方的水,不是只选择两条线求一个容器面积。
  • 最高值包含当前位置,水量是水位减柱高,不是水位本身。
  • 单调上升、单调下降、等高数组都无积水;单个柱子同样为 0。
  • 本双指针实现每轮先更新 left_max、right_max,再比较两者,按较小的最高值结算对应端点。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 2

左右最高值数组时间 O(n),两次最高值扫描加一次求和。

先用两张表保存每列的完整边界:left[i]=max(height[0:i+1]),right[i]=max(height[i:n]),再累加 min(left[i],right[i])−height[i]。最高值包含自身,差不会为负,左右端点的水量自然为零。它与主解都按竖列计水,但先计算全部前后缀信息,最容易检查公式;代价是两张长度 n 的辅助数组。

  1. 建立长度 n 的 left、right 两张表,令 high=0。
  2. 从左到右更新 high=max(high,height[i]),写入 left[i]。
  3. 重新令 high=0,从右到左更新最高值并写入 right[i]。
  4. 对每个位置累加 min(left[i],right[i])−height[i],返回总和。
PYTHON 3
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)。

  1. 建立空 stack 和 total=0,依次读取右墙下标 right 及高度 h。
  2. 只要栈非空且 h 高于栈顶柱,弹出 bottom;若弹出后栈为空,结束本轮补水。
  3. 取新栈顶为 left,用较低墙高减凹底高度得到 layer,把 (right−left−1)×layer 加入 total。
  4. 继续处理更高水层,循环结束后将当前 right 入栈。
  5. 所有柱子扫描完后返回 total。
PYTHON 3
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) 时间:前后缀法便于导出每列水量,双指针最省空间,栈法适合解释水槽形成过程。二维接雨水无法只比较一行两端,因为水可能沿其它方向流走,通常需要边界最小堆扩张;一维证明不能原样搬过去。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 比较两侧已经掌握的最高值,并非只比较当前两根柱子。
  2. 本列积水等于最终水位减柱高;累计值应随逐列结算变化。
  3. left≤right 让相遇的最后一列也被处理,每列只结算一次。
← 返回题库
滑动窗口08 / 100

3. 无重复字符的最长子串 中等

标签:哈希表 · 字符串 · 滑动窗口

力扣原题 ↗
01 / 理解题目

一句话题意

求字符串中不含重复字符的最长连续子串长度;子串必须保持原位置连续。

教学示例

s="abbaec" → 返回 4,最长无重复子串为 "baec"。

生活化理解

一段观景车厢只允许每种颜色的乘客出现一次。右门不断进人,若新乘客颜色与车内某人重复,左门就让乘客依次下车,直到旧的同色乘客离开。车厢对应连续窗口,最大乘客数对应答案。

02 / 讲透主解法

记录最近位置,左边界跳跃

记录字符最近下标即可直接越过冲突点,每个字符处理一次,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 排除窗口内冲突,同时避免被窗口外旧记录拉回。

图解

1 / 8
s = "abbaec":last 保留窗口之外的旧记录a0b1b2a3e4c5leftleft=0;best=0;last 为空last:字符 → 全局最近下标当前合法窗口空窗口best = 0
left
0
best
0
last
{}
选择步骤 8 步
变量与图例

重点比较第二个 b 与第二个 a:同样在 last 中,却只有窗口内的旧位置需要收缩。

right / ch
当前读取的下标及字符
left
当前合法窗口的起点,只前进不后退
last
各字符在整个已读前缀中的最近下标
best
所有合法窗口的最大长度
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. left=0、best=0,last 为空。
  2. 按顺序读取 right 和 ch;如果 ch 出现过,用 left=max(left,last[ch]+1) 跳过窗口内的旧位置。
  3. 登记 last[ch]=right,记录当前字符的最新位置。
  4. 用 right−left+1 更新 best,完成扫描后返回 best。

代码(Python)

查看完整 Python 代码记录最近位置,左边界跳跃 · 与上方图解对应

last 覆盖整个已扫描前缀,left 划定当前有效窗口。max 保证 left 单调不减;每次重复直接跳过冲突点。

PYTHON 3
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,Σ)),Σ 为可出现的字符种类数,字典存每种字符的最近位置。
03 / 检查边界

易错点与边界

  • 子串必须连续,不能跳着选字符形成子序列。
  • last 版本需要 max 防止 left 回退,abba 是关键测试。
  • 长度为 right−left+1;空字符串返回 0,空格也算普通字符。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

集合窗口逐步收缩时间 平均 O(n),每个字符至多入集合一次、离开集合一次。

seen 只保存当前无重复窗口里的字符,不保存它们在全部已读前缀中的最近位置。新字符若已存在,就用 while 逐个移出左端字符并推进 left,直到冲突消失,再加入新字符。它与主解的直接跳跃得到相同的合法边界,更贴近“扩右端、缩左端”的滑动窗口过程。虽然有内层 while,两个指针都只向右,每个字符最多进入和离开集合各一次,总时间仍为平均 O(n)。

  1. 建立空 seen,令 left=best=0。
  2. 逐个读取 right 和 ch;只要 ch 已在 seen 中,就移出 s[left] 并让 left 加一。
  3. 冲突消失后,将 ch 加入 seen,此时 [left,right] 内没有重复字符。
  4. 用 right−left+1 更新 best,完成扫描后返回 best。
PYTHON 3
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 种不同字符,把集合改成频次表并维护种类数,重复字符本身不再是违规条件。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. last 是全局最近位置表,不等同于当前窗口的字符集合。
  2. left=max(left,last[ch]+1) 同时解决窗口内冲突与边界倒退。
  3. 先恢复窗口合法,再更新长度最大值。
← 返回题库
滑动窗口09 / 100

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

标签:哈希表 · 字符串 · 滑动窗口

力扣原题 ↗
01 / 理解题目

一句话题意

返回 s 中所有与 p 互为字母异位词的连续子串起始下标;匹配窗口长度必须等于 p 的长度。

教学示例

s="cabaab",p="ab" → 返回 [1,2,4];对应窗口 "ab"、"ba"、"ab"。

生活化理解

用一台固定宽度的扫描仪逐格扫字母传送带。订单 p 规定每种字母的数量,扫描框里的库存单必须和订单完全一致。框右移一格时只需要加入右侧新字母、减去左侧旧字母,不必重新数整框。

02 / 讲透主解法

固定窗口比较 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。

图解

1 / 10
s = "cabaab" p = "ab" m = 2c0a1b2a3a4b5先建订单 need;window 尚为空abcd…z 每项need1100window0000已记录起点 result[]窗口为空
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
[]
选择步骤 10 步
变量与图例

跟踪 a、b、c 频次的增减,特别留意“先加入后移出”的临时超长窗口。

right / R
当前读入字符的下标
L(图示)
实际窗口起点;移出前临时保持旧起点
m
目标长度 len(p)=2
need
目标字符串的 26 维频次
window
当前窗口的 26 维频次
result
已命中的窗口起点
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 令 m=len(p);若 m>len(s),直接返回空列表。建立全零的 need、window,统计 p 的频次并初始化 result。
  2. 右端依次读入 ch,把 window 中对应字母的频次加一。
  3. 若 right≥m,把 s[right−m] 的频次减一,恢复长度不超过 m 的窗口。
  4. 若 right≥m−1 且 window==need,把起点 right−m+1 追加到 result。
  5. 扫描结束后返回 result。

代码(Python)

查看完整 Python 代码固定窗口比较 26 维频次 · 与上方图解对应

need 存订单,window 存当前窗口。每步比较两个长度 26 的列表,代码短且常数开销可控。

PYTHON 3
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 为匹配窗口数量。
03 / 检查边界

易错点与边界

  • 只比较字符集合不够,aab 与 abb 的字母种类相同但频次不同。
  • 必须等窗口长度达到 m 后才判断,否则前缀长度不足。
  • 移出的下标是 right−m,不是当前窗口起点 right−m+1。
  • 匹配可重叠,例如 abab 中的 ab 有三个答案,找到一个后不能跳过 m 格。
  • p 为非空小写字符串,m 大于 s 长度时直接返回空列表。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

差值数组与非零项计数时间 O(n+m+26),每步只改至多两个频次项。

仍然使用固定长度窗口,但改为维护 diff[c]=窗口频次−目标频次,以及非零项数量 nonzero。当 nonzero=0 时,每个字符的数量都恰好匹配,不必每轮比较全部 26 项。change 只修改一个字母:先扣除旧值对非零项数的贡献,修改差值,再加上新值的贡献。不能只统计窗口中出现了多少种目标字母;该思路可推广到较大字符集,但当前代码的数组与 ord 映射仍针对 26 个小写字母。

  1. 若 m=len(p) 大于 len(s),返回空列表;否则建立全零 diff,把 p 中每个字母的差值减一,并统计初始 nonzero。
  2. 在 change(ch,delta) 中,先按 diff[index] 的旧值调整 nonzero,再更新差值,最后计入新值是否非零。
  3. 每读入一个右端字符调用 change(ch,1);若 right≥m,再调用 change(s[right−m],−1) 移出旧字符。
  4. 窗口已满且 nonzero==0 时,记录 right−m+1;全部扫描后返回结果。
PYTHON 3
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 方案的价值在于把“所有维度匹配”变成一个可增量维护的计数,尤其适合字符集更大时。若题目只问是否存在匹配,第一次命中即可返回,不需要保存所有起点。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 频次必须完全相等,长度相等或字符集合相同都不够。
  2. 固定窗口每次先加新字符,再减 right−m 位置的旧字符。
  3. 匹配起点可重叠,逐个右端点检查即可自然覆盖。
← 返回题库
子串10 / 100

560. 和为 K 的子数组 中等

标签:数组 · 哈希表 · 前缀和

力扣原题 ↗
01 / 理解题目

一句话题意

统计整数数组中和恰好为 k 的非空连续子数组数量,数组可包含负数和零。

教学示例

nums=[1,-1,0,1],k=0 → 返回 4;区间下标 [0,1]、[0,2]、[2,2]、[1,3]。

生活化理解

每天记录账户累计净收入。想知道某一连续时段净收入是否为 k,只要比较结束时累计数与开始前累计数,差值等于 k 就成功。相同累计数可能在不同日期出现,每个日期都对应一个不同的开始点,因此账簿要记次数。

02 / 讲透主解法

前缀和与频次哈希表

把每个右端点的合法起点数化为一次前缀差查询,既适用于负数,也能正确统计重复前缀。

核心思路

令 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 的位置恰好确定一个非空子数组;先累加旧频次、再登记当前前缀,即可同时避免空区间与漏计重复起点。

图解

1 / 10
nums = [1,-1,0,1] k = 010-110213P[0]=0 出现一次;尚未读取任何数组元素freq:前缀和值 → 出现次数0 → 1已登记前缀位置数 = 1本次配对 / 仅用旧前缀prefix−k = 0;answer = 0本次没有新增区间
freq
{0: 1}
prefix
0
answer
0
选择步骤 10 步
变量与图例

每个数分成查询和登记两帧,观察 prefix=0 再出现时为何一次增加两个答案。

prefix
已经读入的所有元素之和
freq
旧前缀和值 → 对应前缀位置的数量
answer
已处理右端点对应的合法非空子数组总数
k
目标子数组和
i(图示)
当前元素位置;原代码直接遍历 x,i 仅用于标注区间
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 初始化 prefix=0、answer=0、freq={0:1}。
  2. 依次读入 x,令 prefix 加上 x。
  3. 把 freq 中 prefix−k 的出现次数加到 answer。
  4. 将当前 prefix 的次数增加 1,供后续结束点使用。
  5. 扫描结束后返回 answer。

代码(Python)

查看完整 Python 代码前缀和与频次哈希表 · 与上方图解对应

把每个右端点的问题化为查找旧前缀差。freq 保存次数,先查询再登记以保证只计入非空区间。

PYTHON 3
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 个不同前缀和。
03 / 检查边界

易错点与边界

  • freq 必须记录次数;相同前缀和对应多个不同的合法起点。
  • 初始的 {0:1} 不可省略,它代表数组开始前的位置。
  • 先查询再登记当前前缀,尤其 k=0 时避免计入空区间。
  • 连续子数组按位置计数,即使数值相同也可能是不同答案。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

固定左端点,递增区间和(基线)时间 O(n²),检查全部 n(n+1)/2 个非空连续区间;大输入可能超时。

固定 left 后,让 right 向右扩展并增量维护 total,直接检查每个非空连续区间。它不保存前缀表,也不对子数组反复切片求和,把朴素 O(n³) 求和枚举降到 O(n²)。这种写法适合小数据对照,完整大规模输入应使用哈希主解;即使当前 total 已超过 k,也不能因含负数而提前停止扩展。

  1. 令 answer=0,逐个枚举左端点 left。
  2. 每换一个 left 就令 total=0,让 right 从 left 开始向右扩展。
  3. 把 nums[right] 累加到 total;每当 total==k 时让 answer 加一。
  4. 完成所有左端点的扩展后返回 answer。
PYTHON 3
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²),这部分输出成本无法用哈希消除。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 把连续区间和转成两个前缀和的差,负数不会破坏等式。
  2. freq={0:1} 表示开始前的前缀位置,覆盖从下标 0 起步的答案。
  3. 频次保存多个旧起点;先查后存避免 k=0 时计入空子数组。
← 返回题库
子串11 / 100

239. 滑动窗口最大值 困难

标签:队列 · 数组 · 滑动窗口 · 单调队列 · 堆(优先队列) · 区间最值查询

力扣原题 ↗
01 / 理解题目

一句话题意

给定数组和窗口长度 k,按从左到右的顺序返回每个连续窗口的最大值。

教学示例

nums=[4,2,3,1,5,2],k=3 → [4,3,5,5]

生活化理解

像查看最近 k 天的最高气温。候选榜单中的旧记录若温度不超过今天,就永远不可能再夺冠:今天更热,而且比它更晚离开最近 k 天的范围。队列存日期下标,温度由数组查询,队首就是当前冠军。

02 / 讲透主解法

单调双端队列

队首处理过期、队尾淘汰劣势候选,能用清楚的不变量解释线性时间。

核心思路

队列 q 保存仍有机会成为最大值的下标,下标从前到后递增,对应数值严格递减。下标而非数值使我们既能比较大小,又能判断记录是否过期。处理位置 i 时,窗口左端为 i-k+1

先移走队首小于左端的下标。再把队尾所有不大于 nums[i] 的记录移出:这些记录更早过期、数值也不更大,在包含它们的后续窗口里,i 都能替代它们。相等时也保留更新的下标。把 i 入队后,队首就是已读窗口的最大候选;只有窗口凑足 k 项才输出。

看似 while 套在 for 里,每个下标却只入队一次、出队至多一次,总操作数是线性的。这个论证叫摊还分析;不能仅凭代码嵌套层数判成 O(n²)。使用 deque 可以从两端直接移出元素。

不变量

每轮完成入队后,q 的下标递增且都未过期,对应的值严格递减。被队尾淘汰的旧值总有一个更大或相等、又更晚过期的替代者,因此队首就是完整窗口的最大值。

图解

1 / 10
nums = [4,2,3,1,5,2],k = 3402132135425绿色是仍有资格的候选;琥珀色在窗口内但已被淘汰。q:队首 → 队尾(下标:值)还未读取元素answer:已经完整的窗口[]
i
尚未开始
L
尚未开始
q
[]
候选值
[]
answer
[]
选择步骤 10 步
变量与图例

观察队列的两端:队首按下标过期,队尾按数值淘汰;这两种移出有不同的理由。

i / R
本轮读入的右端下标;入队前的中间帧可能尚未包含它。
L
过期阈值 i-k+1;前两轮阈值为负,图中已读片段从下标 0 开始。
q
双端队列,保存候选下标;图中写成 下标:值。
answer
从左到右已经形成的完整窗口的最大值列表。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 建立空双端队列 q 和答案列表,依次遍历右端 i。
  2. 从队首清理所有下标小于 i-k+1 的过期候选。
  3. 从队尾移走数值不大于 nums[i] 的下标,再把 i 加入队尾。
  4. 当 i≥k-1 时,将 nums[q[0]] 记为本窗口答案;扫描结束返回全部结果。

代码(Python)

查看完整 Python 代码单调双端队列 · 与上方图解对应

队首负责过期,队尾负责淘汰较小候选;使用 deque 才能 O(1) 从左端移出。相等时保留更新的下标,队列更短。

PYTHON 3
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)。
03 / 检查边界

易错点与边界

  • 过期条件是下标 < i-k+1,窗口两端都包含。
  • 必须等窗口凑足 k 个元素后再输出。
  • k=1 时输出原序列的值;k=n 时仅一个结果。
  • 本主解使用 nums[q[-1]]<=当前值 淘汰队尾,相等值只保留更新的下标;过期仍按下标判断。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 2

最大堆与过期清理时间 O(n log n),堆最坏含 O(n) 项,不能直接写 O(n log k)。

用堆维护最大候选,Python 最小堆保存 (-值, 下标)。堆顶若过期就弹出,直到顶部属于窗口;这是熟悉优先队列时容易复现的办法。堆内部的过期项不会立即清理,堆可能增长到 n,因此该实现的时间是 O(n log n),不能按活动窗口大小写成 O(n log k)。

  1. 建立空堆和答案列表,逐个读取下标 i 与当前值。
  2. 把 (-当前值, i) 压入堆,使最大值对应最小的负数。
  3. 只要堆顶下标≤i-k,就连续弹出过期堆顶。
  4. 窗口凑足 k 项后,取堆顶负值的相反数加入答案;最终返回答案列表。
PYTHON 3
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 的表。

  1. 创建长度为 n 的 left、right 数组。
  2. 从左向右计算 left:块起点直接取 nums[i],其他位置与前一格最大值合并。
  3. 从右向左计算 right:数组末尾或块终点直接取原值,其他位置与后一格最大值合并。
  4. 枚举窗口左端 L,令 R=L+k-1,输出 max(right[L], left[R])。
PYTHON 3
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 随查询任意变化,单一窗口队列不适用,可考虑稀疏表或线段树(拓展)。堆解法的关键追问是隐藏的过期元素,不应把活动窗口大小误当成堆大小。

其他思路的边界

  • 相同值可全部保留或只保留最新下标,但比较条件和过期逻辑应一致。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 存下标同时解决“比较数值”和“判断过期”。
  2. 队首移出看窗口位置,队尾移出看支配关系,两个条件不能混用。
  3. 每个下标至多进出一次,嵌套 while 的总工作量仍是 O(n)。
← 返回题库
子串12 / 100

76. 最小覆盖子串 困难

标签:哈希表 · 字符串 · 滑动窗口

力扣原题 ↗
01 / 理解题目

一句话题意

在 s 中找到包含 t 全部字符及其出现次数的最短连续子串;不存在则返回空串。

教学示例

s="AAABC",t="AABC" → "AABC"

生活化理解

采购清单 t 可能写着“两瓶 A、一瓶 B、一瓶 C”,街上一排商店的货物就是 s。右指针往前采购,左指针尝试放弃最早几家店;只有清单仍全部满足时才能缩短路线。清单的数量不能退化成“来过哪些店”的集合。

02 / 讲透主解法

字符缺口滑动窗口

用缺少的字符总份数判断覆盖,能直接处理重复字符,并清楚说明扩展与收缩的条件。

核心思路

need 只保存 t 中的字符,need[c] 等于目标所需份数减去当前窗口中的份数;正数是缺口,零是刚好,负数是富余。missing 是所有正缺口的总和,初始为 len(t),重复字符分别计数。右端加入目标字符前,若其缺口为正,就让 missing 减一;随后递减该字符计数。无关字符不改变这两个状态。

missing=0,闭区间 [left,right] 覆盖了目标。先记录合法窗口,再不断右移 left:移出目标字符时把其计数加一,若计数变正,就失去了一份必需字符,让 missing 加一并停止收缩。移出富余字符或无关字符仍可继续缩短。

固定右端时,收缩不断检查更短的合法窗口。左端只向右移动;已经舍弃的更早起点即使留给未来右端,也只会形成更长窗口,因此不会遗漏更优答案。startbest 保存最佳起点和长度,最后只切片一次;循环结束时的当前窗口不一定合法,不能直接将它返回。

不变量

need[c] 始终等于目标所需份数减去当前窗口中的份数,missing 等于所有正缺口之和。因此 missing=0 恰好表示覆盖;每次收缩前保存合法候选,再移动 left,能比较固定右端下的所有可能短窗口。

图解

1 / 9
s = AAABC,t = AABCA0A1A2B3C4left当前窗口 s[0:0] = “”;图中绿色字符已移出窗口。need:正数缺少,负数富余A: 2 B: 1 C: 1missing = 4 份;窗口为空已记录的最短覆盖start=0,best=6答案候选:未找到
left
0
right
尚未开始
need
{"A":2,"B":1,"C":1}
missing
4
start
0
best
6
选择步骤 9 步
变量与图例

留意 need[A] 从正数到负数再回到正数:富余字符可以移走,刚好够用的字符再移走就会破坏覆盖。

left / right
当前窗口的左右闭区间下标;初始化尚无 right。
need
仅为目标字符维护差额:正数是缺少、0 是刚好、负数是富余。
missing
尚未满足的字符总份数;重复字符分别计数。
start / best
目前最短合法窗口的起点与长度;best=6 是未找到的初值。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 用 Counter(t) 建立 need,令 missing=len(t),初始化 left、start 和未找到答案时的 best=len(s)+1。
  2. 右端读入字符;仅当它属于 need 时,先按加入前的正缺口决定是否减少 missing,再递减 need 中的计数。
  3. 当 missing 为零时,先用当前窗口更新 start、best,再移出左端字符。
  4. 移出的目标字符使 need 变正时,missing 加一;每次移出都让 left 加一,直到重新出现缺口。
  5. 扫描结束,若 best 未更新则返回空串,否则返回 s[start:start+best]。

代码(Python)

查看完整 Python 代码字符缺口滑动窗口 · 与上方图解对应

missing 统计尚缺的字符总份数,而不是种类数;负的 need 是富余。此实现只为 t 中字符维护计数,避免保存无关字符。

PYTHON 3
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(答案长度)。
03 / 检查边界

易错点与边界

  • t 含重复字符时必须按份数满足,set(t) 不够。
  • 记录答案应发生在移出左端之前,否则可能记录失效窗口。
  • 题设 s、t 非空;t 比 s 长或者字符不足时返回空串。
  • 主解的字符窗口两端都包含,窗口长度按 right-left+1 计算。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

过滤无关字符后按满足种类收缩时间 O(m+n),过滤扫描整个 s;过滤后的左右指针仍是线性。

先把 s 中属于 t 的字符保存为 (原下标, 字符),再在 filtered 上移动窗口。need 保存固定需求,have 记录窗口计数,formed 统计已经达到需求的字符种类数;它与主解的 missing 总份数含义不同。无关字符较多时,过滤后的窗口扫描更集中,但仍需先扫描整个 s 并保存过滤列表。答案长度必须用原始下标计算。

  1. 统计 Counter(t),并按原顺序生成只含目标字符的 filtered 列表,同时保留原下标。
  2. 遍历 filtered 中的新字符,增加 have;计数恰好达到 need 时,让 formed 加一。
  3. 当 formed 等于 len(need) 时,用当前原下标减去 filtered[left] 的原下标再加一,更新最短窗口。
  4. 减少左端字符的 have;若跌破需求就让 formed 减一,再将 left 右移,直到窗口不再满足全部种类。
  5. 根据保存的原字符串起点与长度返回切片;未找到合法窗口时返回空串。
PYTHON 3
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) 更低。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 缺口计“份数”,目标中的重复字符不能按一种字符算一次。
  2. 每次先保存合法窗口,再移出左端;负缺口变为 0 时仍然合法。
  3. 最终当前窗口可能已失效,返回保存的 start 与 best。
← 返回题库
普通数组13 / 100

53. 最大子数组和 中等

标签:数组 · 分治 · 动态规划

力扣原题 ↗
01 / 理解题目

一句话题意

从非空整数数组中选择一个非空连续子数组,使元素和最大,返回这个和。

教学示例

nums=[-2,3,-1,2,-5,4] → 4(例如 [3,-1,2])

生活化理解

把每天收益排成一列,你要挑连续营业的一段时间。到今天结束的最佳计划只有两种:延续昨天的计划,或者今天重新开张。若昨天累计亏损,背着亏损重新开始显然不如直接从今天算起。

02 / 讲透主解法

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 的最佳结尾,两个备选已经穷尽。

图解

1 / 7
nums = [-2,3,-1,2,-5,4]-2031-1223-5445iend 必须以 i 结尾;best 可以来自之前任意位置。本轮两个备选初始化为 nums[0] = -2旧 end=无 → 新 end=-2全局 best-2可以不以当前 i 结尾
教学记录显示每个结尾的最优值;“·”尚未计算,实际代码只保留一个 end。
结束下标012345
nums[i]-23-12-54
end(教学记录)-2·····
i
0(初始化位置)
end
-2
best
-2
选择步骤 7 步
变量与图例

每个位置只做“从这里重开”与“接上前段”两选一;同时区分局部 end 和全局 best。

i
本轮必须包含的结尾下标;初始化先处理下标 0。
end
以当前 i 结尾的最大和;转移右侧读取更新前的旧 end。
best
所有已处理结尾中的最大和。
end 教学记录
为了看懂依赖展示的历史值;代码不分配这张表。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 以首元素初始化 end 和 best,保留全负数组的非空答案。
  2. 从第二个元素开始,比较独立开头 nums[i] 与接上前段 end+nums[i],更新 end。
  3. 用新的 end 更新全局 best。
  4. 所有结尾都处理完后,返回 best。

代码(Python)

查看完整 Python 代码Kadane 动态规划 · 与上方图解对应

只保存上一个结尾状态,空间压缩后是两个整数。不要使用 nums[1:] 遍历,否则 Python 会额外复制 O(n) 个引用。

PYTHON 3
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 数组。
03 / 检查边界

易错点与边界

  • 全负数不能返回 0,因为子数组要求非空。
  • end 是以当前位置结尾的最优值,best 是全局最优值。
  • 子数组必须连续,不能简单把所有正数相加。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 2

前缀和减去历史最小前缀时间 O(n),逐个处理前缀。

把区间和写成 prefix[j]-prefix[i],其中 prefix[t] 表示前 t 项之和。固定右端 j 时,应减去此前最小的前缀。代码用 prefix 保存当前前缀和、lowest 保存当前右端之前的最小前缀,适合从区间和公式理解问题。必须先更新答案再更新 lowest,否则会把当前前缀减自身,错误地允许空子数组。

  1. 令 prefix=lowest=0,以首元素初始化 best。
  2. 每读入一个值,就累加到 prefix,得到当前右端的前缀和。
  3. 用 prefix-lowest 更新 best,此时 lowest 只包含当前右端之前的前缀。
  4. 再用当前 prefix 更新 lowest,供后续右端使用;最终返回 best。
PYTHON 3
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) 合并。

每个区间返回总和、最大前缀和、最大后缀和、最大子数组和四项信息。最优子数组要么完全在左段、完全在右段,要么跨中点;跨中点时等于左段最大后缀加右段最大前缀。这个可合并的表示适合继续理解区间查询,代码每次合并只做常数工作,不会反复扫描中线。

  1. 用下标递归处理闭区间;只剩一个元素时,四项信息都等于该元素。
  2. 把区间一分为二,递归取得左右两段的四项信息。
  3. 总和相加;最大前缀在左段前缀与左段总和加右段前缀之间取最大,最大后缀对称计算。
  4. 最大子数组和在左段最优、右段最优、左后缀加右前缀三者中取最大,连同其余信息返回。
  5. 返回整个数组对应四元组中的最大子数组和。
PYTHON 3
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 改善时才更新最终边界。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. end 限定“必须在当前下标结束”,best 才是最终答案。
  2. 旧 end 为负时重开,不为负时延长;不能跳过中间元素。
  3. 以首元素初始化可正确处理全负数组,两个整数即可完成 O(n) 扫描。
← 返回题库
普通数组14 / 100

56. 合并区间 中等

标签:数组 · 排序 · 快速排序

力扣原题 ↗
01 / 理解题目

一句话题意

合并所有有交集的闭区间,返回彼此不重叠且覆盖范围相同的区间集合。

教学示例

intervals=[[8,10],[1,4],[2,3],[4,6],[12,13]] → [[1,6],[8,10],[12,13]]

生活化理解

像把预约日历中连在一起的占用时段涂成一条色带。先按开始时间摆好,接下来只需拿新预约和最右边那条色带比较;若接得上就延长,否则另起一条。两个闭区间碰到同一个端点,也算接得上。

02 / 讲透主解法

按起点排序后线性合并

排序让每次只需比较结果末段,合并条件与不变量都容易在面试中说明。

核心思路

排序后,后续区间的起点永远不会比当前更小。answer 中的区间互不重叠且按起点递增,覆盖范围等于已处理区间的并集;只有最后一段可能继续向右扩展。

若新起点 start≤answer[-1][1],两区间相交,合并后的右端是 max(旧右端,end)。取最大值能处理新区间完全包含在末段内的情况;闭区间共享端点也要合并。若 start 更大,连最后一段都碰不到,它更不可能碰到更早已结束的区间,可以放心新建一段。

实现遍历 sorted(intervals) 得到的排序副本,每次追加新的 [start,end],随后只更新结果末段。这既保留输入列表原有顺序,也不会让结果与输入共用内层可变区间。

不变量

处理完扫描前缀后,answer 已按起点排列、互不相交,且覆盖范围等于这些已处理区间的并集。因为下一段起点不会变小,它最多只需要与 answer 的末段合并。

图解

1 / 8
原输入的全部 5 段0123456789101112130[8,10]1[1,4]2[2,3]3[4,6]4[12,13]answer:合并后的区间闭区间:两个端点相等也算相交;扫描只与 answer 的末段比较。
intervals
[[8,10],[1,4],[2,3],[4,6],[12,13]]
answer
[]
选择步骤 8 步
变量与图例

沿同一条数轴看区间:包含时终点不缩短,端点相接时继续延伸,中间有空隙时才另起一段。

start / end
排序后当前区间的左右闭端点。
answer
已经合并的区间列表;只有最后一段可能继续延伸。
扫描序号
图左侧的 0–4 表示排序副本中的位置,不是区间坐标。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 建立空 answer,按区间起点排序后依次读取 start、end。
  2. 结果为空,或 start 大于最后一段右端时,追加新的 [start,end]。
  3. 否则把最后一段右端更新为旧右端与 end 的最大值,起点不变。
  4. 扫描完成,返回这些按起点排列、互不相交的合并区间。

代码(Python)

查看完整 Python 代码按起点排序后线性合并 · 与上方图解对应

只比较结果末段。sorted 保留输入顺序,结果为新建的二元列表,不与输入的内层列表共用可变对象。

PYTHON 3
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)。
03 / 检查边界

易错点与边界

  • 闭区间端点相等时也应合并,分离条件为 start>旧右端。
  • 包含关系时不能直接把右端改为 end,否则会把大区间缩短。
  • 未排序就只比较最后一段不成立。
  • [x,x] 是合法的单点闭区间,不能因为长度为零就跳过它。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

端点事件扫描线时间 O(n log n),排序 2n 个事件。

把区间转换成开始和结束事件,用 active 记录当前覆盖层数:开始使它加一,结束使它减一。代码将开始编码为类型 0、结束编码为类型 1,因此同一位置先处理开始,让共享闭端点的区间保持连通。它便于理解覆盖次数的变化,但比主解需要更多事件记录。

  1. 每个区间生成 (start,0) 与 (end,1) 两个事件,按位置再按类型排序。
  2. 初始化 active=0 和空答案;遇到开始事件,若 active 原为零就记录 begin,然后 active 加一。
  3. 遇到结束事件先让 active 减一;若回到零,就把 [begin,当前位置] 加入答案。
  4. 处理完全部事件后返回答案;同点开始优先保证相接区间合成一段。
PYTHON 3
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),应重新定义端点相接是否合并以及同点事件优先级,不能照搬闭区间判断。扫描线更容易扩展到统计最大同时覆盖数,普通合并则排序扫描更简洁。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 先排序才能把“与所有旧段比较”压缩为“只看末段”。
  2. 有交集就取右端最大值,包含关系不能让末段变短。
  3. 闭区间用严格大于判断分离;排序 O(n log n),扫描 O(n)。
← 返回题库
普通数组15 / 100

189. 轮转数组 中等

标签:数组 · 数学 · 双指针

力扣原题 ↗
01 / 理解题目

一句话题意

将数组中的元素向右轮转 k 个位置,并原地修改数组。

教学示例

nums=[1,2,3,4,5,6],k=8 → 原地变为 [5,6,1,2,3,4];返回 None

生活化理解

一排座位要把末尾 k 位同学搬到前面。把整排同学掉头后,两个大分组的前后顺序已经交换,但每组内部站反了;再让前 k 位和后面那组各自掉头,就得到正确队形。

02 / 讲透主解法

三次原地反转

把右移分解成整体与两段反转,证明直观,并直接满足常数辅助空间要求。

核心思路

先取 k%=n,因为每右移 n 步会回到原位。把原数组记为 A+B,其中末尾 Bk。目标是 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。

图解

1 / 10
右移 k=8;n=6,所以实际 k=2102132435465leftright指针显示交换并移动后的下一次检查位置;紫色标出刚交换的两格。本次操作前 → 操作后[1,2,3,4,5,6][1,2,3,4,5,6]① 整体反转本帧尚未交换当前反转范围 [0,5]
n
6
k
2
left
0
right
5
nums
[1,2,3,4,5,6]
选择步骤 10 步
变量与图例

把最后两项看成 B,其余看成 A;每次观察一对交换怎样把 A+B 最终变成 B+A。

n / k
数组长度与取模后的右移步数;这里 n=6,k 从 8 化为 2。
left / right
当前 reverse 调用的两端指针;交换后相向移动。
反转范围
本次调用固定的起止下标,不随内部指针移动。
nums
被直接修改的同一个列表。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 读取 n=len(nums),把 k 化为 k%n。
  2. 用两端交换、指针相向移动的 reverse,原地反转 [0,n-1]。
  3. 用相同操作反转 [0,k-1],恢复前方 B 段的内部顺序。
  4. 反转 [k,n-1],恢复 A 段的内部顺序;原列表已变成 B+A,函数结束。

代码(Python)

查看完整 Python 代码三次原地反转 · 与上方图解对应

用双指针交换而非切片;返回 None,调用者原来的列表对象直接变化。k=0 时第二段为空,首尾两次全数组反转抵消。

PYTHON 3
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),没有列表切片。
03 / 检查边界

易错点与边界

  • 必须修改调用者传入的列表;nums=新列表只改变局部变量绑定。
  • k 可能大于 n,要先取模。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 2

环状替换时间 O(n),所有环合计访问 n 个元素;gcd 计算 O(log n) 不改变总界。

直接沿目标映射 i→(i+k)%n 搬运元素,carry 暂存即将被覆盖的旧值。这个映射是一个置换,分为 gcd(n,k) 个独立环;只从 0 走一圈不一定覆盖所有位置。它适合希望每个位置只写入一次的场景,仍为原地算法,但要讲清环的覆盖与搬运顺序。

  1. 读取 n 并令 k%=n,依次选择 0 到 gcd(n,k)-1 作为各个环的起点。
  2. 进入一个环时,令 current=start,carry=nums[start]。
  3. 计算 target=(current+k)%n,把 carry 写入 nums[target],同时把目标位置的旧值存入 carry。
  4. 令 current=target,继续搬运,直到 current 回到 start;完成所有环后,原数组轮转结束。
PYTHON 3
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) 辅助空间。

  1. 把 k 化为 k%len(nums)。
  2. 按 nums[-k:] + nums[:-k] 构造“末段在前、前段在后”的新序列;k=0 时前一片为全部、后一片为空。
  3. 将拼接结果赋给 nums[:],更新原列表内容,函数返回 None。
PYTHON 3
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 步,先明确方向再套同一个实现。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 先 k%=n,完整转一圈不改变结果。
  2. 三段调用顺序为整体、前 k 项、后 n-k 项,对应 B逆+A逆 到 B+A。
  3. 交换只需常数临时空间;原列表被修改,返回值是 None。
← 返回题库
普通数组16 / 100

238. 除了自身以外数组的乘积 中等

标签:数组 · 前缀和

力扣原题 ↗
01 / 理解题目

一句话题意

不使用除法,在 O(n) 时间内计算每个位置之外所有元素的乘积。

教学示例

nums=[2,3,0,4] → [0,0,24,0]

生活化理解

每位同学都想拿到“除自己以外所有人的印章组合”。给每人一张卡,先从左向右盖上他左边所有人的印章,再从右向左盖上右边所有人的印章;左右两组刚好覆盖其他所有人,谁也不会盖到自己的章。

02 / 讲透主解法

输出数组复用前缀乘积

左右两侧乘积的含义直接对应答案,复用输出再配合一个滚动后缀就能满足空间要求。

核心思路

答案 answer[i] 等于左侧元素乘积乘以右侧元素乘积,两部分都严格排除 nums[i]。空侧的乘积为 1,这是乘法单位元,保证首尾位置不需要特殊分支,也自然处理数组中的零。

第一趟写入严格位于 i 左边的乘积,因此 answer[0]=1answer[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],并为下一格建立正确的后缀。

图解

1 / 10
nums = [2,3,0,4];前缀先写入 answer,再乘滚动后缀20310243answer 的真实内容10111213前缀阶段:初始化左边没有元素的乘积 = 1灰色的 1 是尚未写入的初值绿色是本阶段已经处理的格子前缀不含 nums[i],后缀也不含 nums[i];两边相乘自然避开当前位置。
n
4
answer
[1,1,1,1]
选择步骤 10 步
变量与图例

先观察 answer 存的“左边”,再看 right 从右向左滚动;关键是先乘旧 right,后把当前数纳入它。

i
前缀阶段向右、后缀阶段向左的当前下标。
answer
先暂存左侧乘积,后缀阶段再逐格变成最终答案。
right
一个滚动后缀乘积;图中明确区分用于本格的旧值与更新后的新值。
本格原前缀
本轮 answer[i] 更新前的值,用于展示乘法依赖。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 把长度 n 的答案数组初始化为全 1。
  2. 从 i=1 向右扫描,令 answer[i]=answer[i-1]*nums[i-1],写入严格左侧乘积。
  3. 令 right=1,从最右端开始向左扫描,先执行 answer[i]*=right。
  4. 每格答案写好后再执行 right*=nums[i],为下一格准备后缀;最终返回 answer。

代码(Python)

查看完整 Python 代码输出数组复用前缀乘积 · 与上方图解对应

前缀直接放在结果中;right 只保留一个滚动后缀乘积,避免另建长度 n 的后缀表。

PYTHON 3
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) 输出数组。
03 / 检查边界

易错点与边界

  • 题目禁止除法;总乘积除以当前元素还会遇到零。
  • 右侧乘积应先用于当前答案,再乘 nums[i],否则包含了自己。
  • 前缀表从 1 开始而不是 0,否则所有结果都会变零。
  • 一个零时仅零所在位置可能非零;两个零时所有结果都是零。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

显式前缀表与后缀表时间 O(n),三趟线性处理。

单独建立 prefix 和 suffix 两张表:prefix[i] 是前 i 项的乘积,suffix[i] 是从 i 到末尾的乘积。于是 answer[i]=prefix[i]*suffix[i+1],左右范围一眼可见,适合先理解公式再考虑主解中的空间压缩;代价是 O(n) 辅助空间。

  1. 创建长度 n+1、初值均为 1 的 prefix 和 suffix,保留两端的空乘积。
  2. 从左向右计算 prefix[i+1]=prefix[i]*nums[i]。
  3. 从右向左计算 suffix[i]=suffix[i+1]*nums[i]。
  4. 逐个生成 prefix[i]*suffix[i+1],把当前位置从左右两侧都排除,返回结果。
PYTHON 3
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 的返回列表说成真的只分配常数内存。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 每个答案拆成不含自身的左侧乘积与右侧乘积。
  2. 后缀滚动时先用旧 right,再更新它;交换两句顺序会把自身乘进去。
  3. 不使用除法,零和多个零自然适用;输出数组外只需一个 right。
← 返回题库
普通数组17 / 100

41. 缺失的第一个正数 困难

标签:数组 · 哈希表

力扣原题 ↗
01 / 理解题目

一句话题意

在未排序整数数组中找最小缺失正整数,要求 O(n) 时间和 O(1) 额外空间。

教学示例

nums=[3,4,-1,1] → 2;归位后数组为 [1,-1,3,4]

生活化理解

有 n 个编号为 1 到 n 的储物柜,每个正整数 x 应去第 x 号柜,也就是下标 x-1。把范围内的物品送回自己的柜子后,从 1 号柜开始找第一个没有对应物品的柜号;负数和超过 n 的号码与这次盘点无关。

02 / 讲透主解法

原地置换归位

把有效数值与数组位置一一对应,容易说明正确性与线性交换次数,并满足原地要求。

核心思路

n 个元素最多填满 1n,所以答案必在 [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,因此从左找到的首个错位就是最小缺失正数。

图解

1 / 9
把数值 x 放到下标 x-1;输入 [3,4,-1,1]3041-1213itarget绿色表示值已归位;每次交换都显示同一完整数组。这些位置应当存放的号码(教学参照)10213243交换前3 的目标是下标 2当前 nums = [3,4,-1,1]
n
4
i
0
target
2
nums
[3,4,-1,1]
选择步骤 9 步
变量与图例

把下标 i 看成号码 i+1 的固定座位;每次交换后,观察当前位置收到的新号码是否还要继续搬。

n
数组长度;最小缺失正数一定在 1..n+1 内。
i
归位阶段反复处理的当前位置;随后重用为查找下标。
target
交换前缓存的 nums[i]-1,交换语句执行后暂时仍保留该缓存值。
nums
同时充当数据和“号码是否在座”的记录;会被原地重排。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 读取 n=len(nums),只给范围 1 到 n 的数安排下标 x-1。
  2. 逐个处理 i;只要 nums[i] 在范围内,就先缓存 target=nums[i]-1。
  3. 若目标已有相同值就结束当前位置的搬运;否则交换 nums[i] 与 nums[target],继续检查同一个 i。
  4. 归位结束后再次从左扫描,返回首个 nums[i]≠i+1 对应的 i+1;全部匹配则返回 n+1。

代码(Python)

查看完整 Python 代码原地置换归位 · 与上方图解对应

把数组作为值到位置的映射。缓存 target 后再交换,避免把会随赋值变化的 nums[i]-1 直接写成左侧目标而产生求值顺序陷阱。

PYTHON 3
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),会改变输入数组的排列。
03 / 检查边界

易错点与边界

  • 归位法必须判断目标已存在相同值,防止重复数字引发死循环。
  • 小于 1 或大于 n 的数无需归位,答案最多为 n+1。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

原地符号标记时间 O(n),标准化、标记、查找三次线性扫描。

利用下标 x-1 记录号码 x 是否出现,但不把值搬到对应位置,而是把该位置变为负数。先把原非正数替换为 n+1,区分原负号与标记;扫描时取绝对值,避免已写入的标记干扰后续读数。重复号码用 -abs(...) 重复设负,不会把负号翻回正号。适合用“存在标记”理解原地记录的方法。

  1. 读取 n,把所有非正数替换为 n+1,使数组起初全部为正。
  2. 逐格取 value=abs(nums[i]);若 value≤n,就把 nums[value-1] 设为 -abs(nums[value-1])。
  3. 再次从左向右扫描,第一个仍为正数的位置表示对应号码未出现,返回 i+1。
  4. 若全部位置都被标成负数,说明 1 到 n 齐全,返回 n+1。
PYTHON 3
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) 内存,应明确说明约束变化。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 值 x 的目标位置是 x-1,只有 1..n 需要安置。
  2. 交换后继续处理当前位置;缓存 target,并在目标已有同值时停止。
  3. 归位后第一个 nums[i]≠i+1 的位置给出答案,否则返回 n+1。
← 返回题库
矩阵18 / 100

73. 矩阵置零 中等

标签:数组 · 哈希表 · 矩阵

力扣原题 ↗
01 / 理解题目

一句话题意

把矩阵中原本为零的元素所在整行、整列全部置零,原地修改矩阵。

教学示例

matrix=[[1,2,0],[4,5,6],[7,0,9]] → 原地变为 [[0,0,0],[4,0,0],[0,0,0]];返回 None

生活化理解

仓库的一个格子漏水,就要停用它的整排与整列。必须先按原始漏水位置贴标签,再统一停用;若把刚停用的格子也当成新漏水点,就会连锁扩散。第一行和第一列可以借来当每列、每行的标签栏。

02 / 讲透主解法

首行首列充当标记

先记录再改写的两阶段逻辑清楚,复用首行首列还能直接满足常数辅助空间要求。

核心思路

在扫描内部格子之前,先保存 first_rowfirst_col,分别记录第一行、第一列原本是否包含零。因为接下来它们会被写入标签,不能再从修改后的内容判断它们是否原本需要整行或整列清零。

遇到内部零 matrix[r][c] 时,令 matrix[r][0]=0matrix[0][c]=0,分别表示第 r 行和第 c 列需要清零。首行、首列原有的零也自然构成对应列、行的标签。第一遍只写边缘标签,不立即改内部其他格子,原始零的发现就不会被新增零污染。

标记结束后,内部格子只要对应行标签或列标签为零就清零。这个阶段只读标签,不再添加标签。最后依据两个布尔值处理第一行、第一列,让标签在所有读取完成前保持有效;全部操作直接修改原矩阵。

不变量

第一遍仅把内部原始零映射为首行首列标签,不清零内部的其他格;两个布尔值保留首行首列原来是否含零。第二遍读取稳定标签来改写内部,全部读完后再清首行首列,避免新写入的零扩散成错误依据。

图解

1 / 9
原矩阵:首行和内部各有一个零c=0c=1c=2r=0r=1r=2120456709先保存的原始状态first_row = Truefirst_col = False首行 / 首列标签列标签:[2,0]行标签:[4,7]仅首行 c≥1、首列 r≥1这一步读取的依据首行原本有 0,所以 first_row=True首列原本没有 0,所以 first_col=False琥珀色是本步依据,紫色是本步检查或写入的格子。收集标签时先不清零整行整列;原来的零才决定要清哪些位置。
matrix
[[1,2,0],[4,5,6],[7,0,9]]
first_row
True
first_col
False
阶段
保存原始状态
选择步骤 9 步
变量与图例

先看原始零怎样变成首行首列标签,再看第二遍怎样只读这些标签;最后才处理首行和首列本身。

r / c
内部扫描的行列下标,两个阶段都从 1 开始。
first_row / first_col
修改前首行、首列是否含零,保存后不再重算。
matrix[r][0]
第 r 行的标签;r≥1 时为 0 表示该行应清零。
matrix[0][c]
第 c 列的标签;c≥1 时为 0 表示该列应清零。
matrix
同时存原数据、标签与最终结果,图中明确区分处理阶段。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 读取矩阵行列数,记录 first_row、first_col,保存首行首列原本是否有零。
  2. 从 r=1、c=1 开始扫描内部格子;遇到零就在对应首列、首行位置写零标签。
  3. 第二遍扫描内部区域,若行标签或列标签为零,就把当前格置零。
  4. 内部处理完后,分别按 first_row、first_col 清零第一行、第一列,函数返回 None。

代码(Python)

查看完整 Python 代码首行首列充当标记 · 与上方图解对应

两个布尔值解决首行首列自身状态的冲突;其余行列标签复用 matrix,不另开长度 m+n 的数组。

PYTHON 3
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 接收生成器,不构建行列副本。
03 / 检查边界

易错点与边界

  • 不能边发现零边把整行整列改零,否则新增零会错误传播。
  • 首行首列是否原本含零必须在标记前记录。
  • 先处理内部再处理边缘,避免提前毁掉标签。
  • 只有一行或一列时内部循环为空,边缘逻辑仍需正确工作。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

集合记录零所在的行列时间 期望 O(mn),集合查询期望 O(1)。

用 rows、cols 两个集合分别保存原始零的行号和列号,第一遍只观察,第二遍再改写。它与主解都遵循“先记录再执行”,但把标记放在矩阵外,首行首列可以像其他位置一样处理,逻辑更直观,代价是 O(m+n) 辅助空间。

  1. 建立空集合 rows 和 cols。
  2. 完整扫描原矩阵;发现零时,将其行号加入 rows、列号加入 cols,此时不改写矩阵。
  3. 再次遍历全部格子,只要行号在 rows 中或列号在 cols 中,就将当前格置零。
  4. 第二遍完成后,原矩阵已经符合要求,函数返回 None。
PYTHON 3
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)。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 先保存首行首列的原始状态,再复用它们存标签。
  2. 第一遍写标签,第二遍读标签清内部,不能让结果零反过来产生新标签。
  3. 首行首列最后处理,两个布尔值让额外空间保持 O(1)。
← 返回题库
矩阵19 / 100

54. 螺旋矩阵 中等

标签:数组 · 矩阵 · 模拟

力扣原题 ↗
01 / 理解题目

一句话题意

按从左上角开始、顺时针向内盘旋的顺序返回矩阵所有元素。

教学示例

matrix=[[1,2,3],[4,5,6],[7,8,9]] → [1,2,3,6,9,8,7,4,5]

生活化理解

像从外向内剥一圈圈洋葱皮:先走最上排,再沿右边向下,再走底边和左边。走完一条边就把它从待访问矩形中收起来,下一次只关心里面更小的一圈。

02 / 讲透主解法

四边界逐层收缩

按上、右、下、左读取并收缩边界,能直接对应螺旋路径,也无需访问标记表。

核心思路

topbottomleftright 在每条边开始前圈定尚未访问的闭矩形。读取一条边期间边界保持不动,整条边完成后才收缩;已排除的格子不再进入后续循环。

先从左到右读 top 行,再令 top+=1;接着从更新后的 top 向下读 right 列,再令 right-=1。下一条边使用更新后的范围,自然跳过已读的角。读下边前检查 top≤bottom,读左边前检查 left≤right,配合可能为空的循环,处理最后只剩单行、单列或单格的情况。

外层循环在 top≤bottomleft≤right 时继续。每次完成一圈,边界指向内层;所有边累计覆盖每个格子恰好一次,总时间为 O(mn)。输出之外只需四个边界,不需要裁剪矩阵或另存访问表。

不变量

在每条边开始前,四个闭区间边界圈定剩余待处理的矩形;正在读取的这条边结束后才收缩。每次排除已读边,使角点不会被下一条边重复访问;下边、左边的存在性检查处理最后的单行、单列或单格。

图解

1 / 9
四条边界圈住尚未完成的层;走完一条边才收缩c=0c=1c=2r=0r=1r=2123456789当前闭区间边界top=0,bottom=2left=0,right=2虚线框:当前边界(当前边可正在读取)本步访问 / 收缩下一条边:上边,从左向右尚未输出任何格子answer(· 表示尚未输出的位置,绿色箭头表示已走路径)·0·1·2·3·4·5·6·7·8
top
0
bottom
2
left
0
right
2
answer
[]
选择步骤 9 步
变量与图例

绿色路径记录已读位置,虚线框显示边界;重点看读完一条边后,下一条边如何跳过已读的角。

top / bottom
当前可处理矩形的上、下行号,均为闭边界。
left / right
当前可处理矩形的左、右列号,均为闭边界。
r / c
当前边上改变的行号或列号;另一维固定在对应边界。
answer
累计输出顺序;图中“·”只是未输出位置的占位,不属于真实列表。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 将 top、bottom 设为首末行,left、right 设为首末列,并建立空 answer。
  2. 在上下、左右边界都合法时开始一圈:从左到右读上边,整边结束后 top 加一。
  3. 从更新后的 top 到 bottom 读右边,再将 right 减一。
  4. 若 top≤bottom,从右到左读下边,随后 bottom 减一。
  5. 若 left≤right,从下到上读左边,随后 left 加一;继续处理内层,矩形为空时返回 answer。

代码(Python)

查看完整 Python 代码四边界逐层收缩 · 与上方图解对应

每走完一边就排除它,方向自然由四个 for 循环表达。下边和左边的补充判断防止退化矩形重复访问。

PYTHON 3
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) 结果列表。
03 / 检查边界

易错点与边界

  • 只有一行或一列时,一圈中的某些边根本不存在,不能无条件重复读取。
  • 闭区间 range 的末端需要 +1;反向遍历要以 left-1 或 top-1 为排除端。
  • 收缩边界应紧跟该边输出,下一条边使用更新后的范围。
  • 输入可为长方形,不能把行数和列数混用。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

方向数组与访问标记模拟时间 O(mn),固定走 mn 步。

用 directions 保存右、下、左、上四个方向,visited 记录哪些格子已访问。每次先输出当前格,再试探下一格;下一格越界或访问过时顺时针转向。它按行走过程直接模拟,固定执行 mn 次保证输出全部元素,但需要 O(mn) 访问表。

  1. 创建全 False 的 visited,设置右、下、左、上四个方向,从左上角朝右出发。
  2. 固定执行 m*n 轮:把当前格加入答案,并标记为已访问。
  3. 按当前方向计算下一格;若越界或访问过,就把方向编号加一并对 4 取模。
  4. 按选定方向更新行列下标;下一轮再读取该位置,完成规定轮数后直接返回答案。
PYTHON 3
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 改成写入递增数值即可。只读地输出遍历时,四边界足够;若形状有洞或路线受障碍影响,矩形边界不再完整描述未访问区域,需要更一般的状态。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 每走完一边立刻收缩,下一边使用更新后的闭区间。
  2. 下边和左边先判断是否存在,尤其关注最后的单行、单列、单格。
  3. 每格追加一次,总时间 O(mn),输出以外只需四个边界。
← 返回题库
矩阵20 / 100

48. 旋转图像 中等

标签:数组 · 数学 · 矩阵

力扣原题 ↗
01 / 理解题目

一句话题意

将 n×n 方阵顺时针旋转 90 度,必须直接在原矩阵中修改。

教学示例

matrix=[[1,2,3],[4,5,6],[7,8,9]] → 原地变为 [[7,4,1],[8,5,2],[9,6,3]];返回 None

生活化理解

像给方形照片转方向。可以先沿左上到右下的对角线照镜子,再让每一行左右调头。原来左上角的像素就来到右上角;两次简单镜像组合成一次顺时针旋转。

02 / 讲透主解法

转置后逐行反转

把旋转拆成转置与行反转两次原地变换,坐标证明和代码都简洁清楚。

核心思路

顺时针旋转的坐标关系是 (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),恰好满足顺时针旋转映射;两阶段都直接修改原矩阵。

图解

1 / 8
准备:先转置,再逐行反转本步操作前本步操作后c=0c=1c=2r=0r=1r=2123456789c=0c=1c=2r=0r=1r=2123456789原地变换本步位置关系顺时针目标:(r,c) → (c,2-r)本步仅取 n=3;矩阵尚未改变
n
3
阶段
初始化
matrix
[[1,2,3],[4,5,6],[7,8,9]]
选择步骤 8 步
变量与图例

左右两张矩阵分别显示本步前后:先跟随每对对称位置的交换,再观察三行依次反转。

n
方阵边长,本例为 3。
r / c
转置时的行列下标,始终只枚举 c>r。
row
逐行反转时直接引用 matrix 中的一行;reverse 修改该行本身。
行号
图中用于说明当前 row 属于哪一行的教学标注,代码无需另存它。
matrix
两阶段共用的原矩阵;左图保存本步前状态仅用于教学。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 读取方阵边长 n。
  2. 遍历行 r,只让列 c 取 r+1 到 n-1,交换 matrix[r][c] 与 matrix[c][r],完成转置。
  3. 依次取得每一个原行列表 row,调用 row.reverse() 做左右反转。
  4. 所有行处理完成后,原元素已从 (r,c) 到达 (c,n-1-r),原矩阵旋转完成。

代码(Python)

查看完整 Python 代码转置后逐行反转 · 与上方图解对应

把复杂旋转拆成两个可独立验证的原地变换。list.reverse() 原地操作,不像 row[::-1] 那样分配行副本。

PYTHON 3
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 都不建立矩阵副本。
03 / 检查边界

易错点与边界

  • 转置只能扫描对角线一侧,否则交换两次会抵消。
  • 输入是方阵;奇数边长的中心不动,n=1 时无需交换。
  • 主解先沿主对角线转置,再逐行左右反转,得到顺时针旋转结果。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

逐层四点轮换时间 O(n²),每个非中心格子搬运一次。

直接沿旋转映射把每圈拆成四点环,暂存顶部值后依次执行左→上、下→左、右→下、暂存→右。layer 是当前外圈距边缘的层数,last 是本层最后下标,offset 是上边当前位置距左端的偏移。它适合按每个元素的目标位置理解旋转;顶边不含最后一个角,避免重复处理,奇数方阵中心保持不动。

  1. 依次处理 layer=0 到 n//2-1,令 last=n-1-layer,确定当前方形外圈。
  2. 让顶边列 c 从 layer 遍历到 last-1,计算 offset=c-layer,并暂存 top=matrix[layer][c]。
  3. 把左侧 matrix[last-offset][layer] 搬到顶部,再把底部 matrix[last][last-offset] 搬到左侧。
  4. 把右侧 matrix[c][last] 搬到底部,最后把暂存的 top 写到右侧,完成四点轮换。
  5. 完成本圈所有起点后进入内圈;所有层结束,原矩阵即为顺时针旋转结果。
PYTHON 3
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,当前二维列表的原地要求需要重新定义;不要直接把方阵下标公式套到长方形。

其他思路的边界

  • 转置后左右翻转是顺时针;转置后上下翻转是逆时针。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 顺时针映射 (r,c)→(c,n-1-r) 可以拆成转置再左右反转。
  2. 转置只扫描主对角线一侧,避免同一对交换两次。
  3. row.reverse() 修改原行,时间 O(n²)、额外空间 O(1)。
← 返回题库
矩阵21 / 100

240. 搜索二维矩阵 II 中等

标签:数组 · 二分查找 · 分治 · 矩阵

力扣原题 ↗
01 / 理解题目

一句话题意

判断目标值是否存在于每行从左到右、每列从上到下升序的矩阵中。

教学示例

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])。

生活化理解

把矩阵看作图书馆书架:同一排越往右书号越大,同一列越往下也越大。站在右上角,就像同时握住“本排最大”和“本列最小”两条线索;比较一次可以整排或整列排除。

02 / 讲透主解法

右上角阶梯搜索

用行列单调性每步排除一整行或一整列,移动方向清楚且只需两个下标。

核心思路

r=0c=列数−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 的候选矩形中。当前格是该矩形首行最大、末列最小;按比较结果排除整行或整列不会丢失目标。

图解

1 / 8
从右上角读取 15淡色格已排除;紫色格是本轮读取的位置0147111512581219236916223101314172401234r = 0,c = 4候选区域目标 target = 13行 0..3,列 0..4value = 15剩余 20 格下方数字是列下标;每一步都保留完整矩阵。
r
0
c
4
value
15
候选
行 0..3 × 列 0..4
选择步骤 8 步
变量与图例

追踪候选矩形的右上角:大了删一列,小了删一行,淡色区域不会再进入候选。

r
候选首行下标,只向下增加。
c
候选最右列下标,只向左减小。
value
本轮读取的 matrix[r][c]。
target
固定目标值 13。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 初始化 r=0、c=列数−1,使当前位置位于整个候选矩形的右上角。
  2. 当 r 仍在矩阵内且 c 非负时,读取 value=matrix[r][c];相等立即返回 True。
  3. value 大于目标时令 c 减一,排除当前候选列;否则令 r 加一,排除当前候选行。
  4. 循环因越界结束时,候选区域已空,返回 False。

代码(Python)

查看完整 Python 代码右上角阶梯搜索 · 与上方图解对应

r、c 指向候选矩形的右上角。每个失败比较都会永久排除一行或一列,没有回溯,也不修改矩阵。

PYTHON 3
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) 额外空间。
03 / 检查边界

易错点与边界

  • 不能从左上角比较后任意选择右移或下移:两个方向都变大,无法排除一边。
  • 本题只有行列各自有序,不能套用展开后整体二分。
  • 单行、单列同样适用;相等必须立即返回。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

逐行二分时间 O(m log(n+1)):m 行分别二分 n 个元素;加 1 表示单列仍需比较。

每行单独维护闭区间 [lo,hi],只利用该行升序这一事实。它适合从一维二分直接迁移,但没有利用列有序来跨行排除候选。本题不保证上一行末项小于下一行首项,因此不能像第 74 题那样把整个矩阵展平成一个有序数组后整体二分;逐行二分则不需要这一额外条件。

  1. 依次取出每一行,将 lo、hi 初始化为该行首尾下标。
  2. 在 lo 不超过 hi 时计算 mid,若 row[mid] 等于目标就返回 True。
  3. 中点值小于目标时令 lo=mid+1,否则令 hi=mid−1,继续查找当前行。
  4. 当前行候选为空后再查下一行;所有行都未命中才返回 False。
PYTHON 3
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。

面试追问与解法取舍

为什么左下角也能做?它是本行最小、本列最大,过小就向右,过大就向上。长宽很不均衡时可以比较阶梯法与沿短维枚举、长维二分的成本;“常数空间”并不意味着一定选同一种遍历路线。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 右上角同时提供“行最大、列最小”两条排除依据。
  2. r 只增、c 只减,总移动数不超过行数加列数。
  3. 行列各自有序不代表展开后整体有序,不能直接套一维二分。
← 返回题库
链表22 / 100

160. 相交链表 简单

标签:哈希表 · 链表 · 双指针

力扣原题 ↗
01 / 理解题目

一句话题意

返回两条无环单链表的首个共享节点;不存在则返回 None,不能改变链表结构。

教学示例

A: A1(4)→A2(1)→C1(8)→C2(9);B: B1(5)→B2(6)→B3(1)→C1(8)→C2(9)。预期返回共享节点 C1,非仅返回数值 8。

生活化理解

两个人沿不同支路走向一条公共街道。两条支路长度不一样,直接同时出发未必能在入口碰面;各自走完后再走对方的路线,相当于把各自多走的支路长度补齐。街道交汇指的是同一个路口,而非两个路口恰好同名。

02 / 讲透主解法

双指针换路

通过互换遍历路线抵消长度差,以常数额外空间找到共享节点且不修改输入链。

核心思路

pheadA 出发,qheadB 出发。每轮分别移动一次:当前位置非空就沿 next 前进;当前位置已经是 None 才换到另一条链的头。图中从末节点走到 None 与从 None 换头是两个不同轮次,不能把它们合并成一次移动。

设 A、B 的独有前缀长度分别为 ab,共享尾段长度为 c。如果第一趟尚未相遇,换路后双方到公共入口都经历了各自前缀、共享尾段和对方前缀,路程中的节点段总长都是 a+b+c;代码跨过 None 的额外换头移动也对称。因此原来的长度差被抵消,两者会同步抵达共享入口。如果更早指向同一对象,直接结束即可。

循环比较的是 p is not q,即节点对象身份,而不是数值。图中 B3 与 A2 的值都为 1,仍不能判定相交;直到二者同时指向 C1 才返回。无环链表一旦共享节点,其后继路径必然相同;如果没有共享尾段,两条互补路线会使双方最终同时成为 None,返回值便表示没有交点。整个过程只移动局部引用,不改写任何 next

不变量

p 按 A 后 B 的路线前进,q 按 B 后 A 的路线前进,每轮各执行一次移动。两条互补路线会抵消独有前缀的长度差;共享后缀存在时会在其入口同步,不存在时会一起到 None。

图解

1 / 9
从两个不同头出发圆内字母是节点身份;节点间箭头是 next;带名箭头是引用A14A21B15B26B31C18C29NonepqA1、B1 是两个输入头;C1 与 C2 只有一份节点对象。
p
A1
q
B1
同步移动次数
0
p is q
False
选择步骤 9 步
变量与图例

盯住两个指针的路线与 None 停留帧:走完自己的链后,下一轮才换到对方头。

p / q
当前所指的节点对象;相等使用 is 比较身份。
headA / headB
固定输入头 A1、B1,换路时使用。
None
当前路径已到尾后的空引用,下一轮触发换头。
C1 / C2
两条输入链实际共用的节点,不是值相同的两份节点。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 令 p=headA、q=headB,并以两个引用是否指向同一对象作为循环条件。
  2. p 非空时令 p=p.next;p 已为空时令 p=headB。
  3. q 按相同规则前进,但为空时换到 headA;两个指针每轮各移动一次。
  4. 身份相同时返回 p:它可能是共享入口,也可能是表示不相交的 None。

代码(Python)

查看完整 Python 代码双指针换路 · 与上方图解对应

p 走 A 再走 B,q 走 B 再走 A,用相同总路程消掉长度差。判断使用 is,返回共享节点本身。

PYTHON 3
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) 额外空间。
03 / 检查边界

易错点与边界

  • 相交是节点身份相同,值相同不代表相交。
  • 换路条件是指针为 None;随意提前在尾节点换路会破坏对应路程。
  • 不能拼接链表或反转输入来制造交点,本题要求保留结构。
  • 头节点本身可以相交;无交点时应让两指针在 None 处相等退出。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

节点集合时间 O(m+n) 期望时间,节点集合查询期望 O(1)。

seen 保存第一条链上实际访问过的节点对象,再沿第二条链寻找第一个属于 seen 的节点。无环链表的共享部分是同一条后缀,因此 B 上首次命中的对象就是公共入口。原 ListNode 没有重写相等或哈希规则,集合按对象身份区分节点,不会把数值相等的不同对象合并。该方法用保存 A 节点的额外空间换取直接的成员判断。

  1. 建立空集合 seen,沿 A 的 next 逐个加入节点对象。
  2. 从 B 的头开始遍历,在每次前进之前判断当前节点是否属于 seen。
  3. 遇到第一个已记录的对象就返回该节点;B 遍历结束仍无命中则返回 None。
PYTHON 3
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)。面试时先说清两条链无环;如果放宽为有环输入,需要分类讨论两个环入口和是否共享同一环,不能直接复用此代码。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 相交是对象共享,不能比较 val 判断。
  2. 到 None 与换头是两次循环中的两个动作。
  3. 交换行走顺序抵消长度差,全程不改 next。
← 返回题库
链表23 / 100

206. 反转链表 简单

标签:递归 · 链表

力扣原题 ↗
01 / 理解题目

一句话题意

把单链表的 next 方向全部反转,返回新头节点,复用原节点。

教学示例

A(1)→B(2)→C(3)→D(4)→None → 返回 D,结果 D(4)→C(3)→B(2)→A(1)→None。

生活化理解

把一队通过“指向下一位”保持顺序的人调头。每次只处理队首一个人:先记住原来后面是谁,再让他指向已经调头的队伍。prev 是已调头队首,cur 是尚未调头队首,nxt 是防止丢队的临时联系人。

02 / 讲透主解法

三指针迭代

每轮只反转一条连接,三个引用的职责明确,适合在面试中逐步推导且没有递归栈开销。

核心思路

每轮开始,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,就把一个节点从后缀移入反转前缀而不丢节点。

图解

1 / 10
初始化两段圆内字母是节点身份;节点间箭头是 next;带名箭头是引用A1B2C3D4NoneNoneprevcur两个 None 标记都表示空引用;节点 A、B、C、D 从未换值或换身份。
prev
None
cur
A
nxt
尚未赋值
选择步骤 10 步
变量与图例

观察每次 cur.next 改写前的 nxt:它是断边后仍能找到未处理后缀的关键。

prev
已反转前缀的头,初始为 None。
cur
本轮需要翻转 next 的原节点。
nxt
改写前保存的 cur.next,保证还能进入后缀。
head
原头 A;反转后它仍是 A,但已成为尾节点。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 初始化 prev=None、cur=head,分别表示已反转前缀和待处理后缀。
  2. cur 非空时,先用 nxt 保存原来的 cur.next。
  3. 令 cur.next=prev,把当前节点接到反转前缀前面。
  4. 同时令 prev=cur、cur=nxt;重复到 cur 为空,返回 prev。

代码(Python)

查看完整 Python 代码三指针迭代 · 与上方图解对应

prev 是已反转前缀的头,cur 是待处理节点,nxt 保存原后继。每轮只改一条 next,不分配新节点。

PYTHON 3
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) 额外空间。
03 / 检查边界

易错点与边界

  • 改 cur.next 之前必须保存原后继。
  • 迭代版本结束时返回 prev,不是仍指向原头对象的 head;旧头已成为新尾。
  • 空链返回 None,单节点反转后仍返回原节点;本迭代方法没有递归深度限制。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

递归反转后缀时间 O(n):每层处理一个节点。

递归契约是 reverseList(head) 返回这段链表反转后的新头。先让 head.next 开始的后缀完成反转;返回后,head.next 仍指原第二个节点,而它已经成为反转后缀的尾。用 head.next.next=head 将旧头接到尾部,再用 head.next=None 断开旧方向,最后一路返回同一个 new_head。该写法有助于理解递归返回值与旧引用的区别,但调用深度随链长增加,长链可能超过 Python 默认递归深度。

  1. 当前链为空或只有一个节点时,直接返回 head。
  2. 递归反转 head.next 开始的后缀,保存其新头 new_head。
  3. 让原第二个节点的 next 指向 head,再把 head.next 设为 None,使旧头成为新尾。
  4. 返回 new_head;当前层只补接旧头,不改变递归已经确定的新头。
PYTHON 3
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),而不是使用这里的原地版本。如果只反转区间,应额外保存区间前驱与区间后继;三指针核心仍然相同,但需要在结束后重新接回两端。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 先保存后继,再改箭头,最后移动两个段头。
  2. 改 next 改变结构;移动指针变量本身不改变结构。
  3. 结束条件是 cur=None,返回 prev 而不是原 head。
← 返回题库
链表24 / 100

234. 回文链表 简单

标签:栈 · 递归 · 链表 · 双指针

力扣原题 ↗
01 / 理解题目

一句话题意

判断单链表的节点值序列是否正反相同;进阶做到 O(n) 时间、O(1) 额外空间。

教学示例

A(1)→B(2)→C(2)→D(1)→None → True;返回前恢复同一组原节点的 A→B→C→D 连接。

生活化理解

像检查一列纸牌能否沿中线对折重合。单链表只能向前看,无法直接从右往左取牌;于是先找到中线,把右半列暂时调头,两个人从两半起点同步核对,最后再把纸牌摆回原样。

02 / 讲透主解法

快慢指针 + 反转并恢复

用常数额外空间把对称位置变成同向遍历,并在返回前恢复输入链,适合讲清链表综合操作。

核心思路

空链或单节点直接为回文。其余情况让 slowfast 从头开始,仅在 fast.nextfast.next.next 都存在时分别走一步、两步。结束时,偶数长度的 slow 位于前半段末尾,奇数长度则位于中央节点,因此 slow.next 统一表示需要反转的后半段起点。

调用局部 reverse 得到后半段的新头 second。helper 内部的 prevnodenxt 负责保存后继和逐边反转,外层 slow 保持不动。图中 C→D 被改成 D→C,原来的 B.next 仍指 C;D 改由 second 保存。然后令 left=headright=second 同速比较,每次对应原链的一对对称位置。由 right 控制比较次数,刚好比较后半段长度,奇数链的中央节点不必比较。

result 记录已比较的对称值是否全部相等。遇到不等只记录 False 并退出比较循环,不能立即返回,因为还需执行 slow.next=reverse(second)second 始终保留恢复所需的反转后半入口,不随 right 前进;第二次反转返回恢复后的后半头,再接回 slow.next。无论结果真伪,返回前全部原连接都已复原。

不变量

slow 停在前半段尾后保持不动;将后半段反转后,left 与 right 的同速移动对应原链的对称位置。比较只覆盖后半段长度,结果先存入 result;随后再反转一次并接回,恢复全部原连接。

图解

1 / 12
从头寻找前半段尾圆内字母是节点身份;节点间箭头是 next;带名箭头是引用A1B2C2D1NoneNoneslow / fastnode / prev / nxt 是 reverse 内部变量;slow 始终保留前半段尾 B。
slow
A
fast
A
阶段
找中点
选择步骤 12 步
变量与图例

分清三组指针:slow 找分界,reverse 的局部指针改箭头,left/right 比较镜像位置。

slow / fast
定位前半段尾 B;fast 每轮走两步。
node / prev / nxt
reverse 内部的当前、已反转头、保存的后继;每次调用重新初始化。
second
第一次反转返回的头 D,比较和恢复都从它开始。
left / right
前半段与反向后半段的比较位置。
result
值比较结果,先保存答案,恢复后再返回。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 空链或单节点返回 True;否则用指定快慢指针条件找到前半段尾 slow。
  2. 反转 slow.next 开始的后半段,将返回的新头保存在 second;slow 保持不动。
  3. 令 left=head、right=second、result=True,逐对比较值并同步前进,以 right 为空作为完成条件。
  4. 若某对不等,将 result 记为 False 并结束比较;仍继续执行恢复步骤。
  5. 把 reverse(second) 的返回值接回 slow.next,然后返回 result。

代码(Python)

查看完整 Python 代码快慢指针 + 反转并恢复 · 与上方图解对应

slow 定位前半尾,second 保留反转后半的入口。比较失败只 break,不直接 return,保证非回文链表也能恢复。

PYTHON 3
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) 额外空间,反转使用迭代,无递归栈。
03 / 检查边界

易错点与边界

  • 判断回文比较 val,不比较节点身份;一条正常链表两端本就是不同对象。
  • 奇数长度的中点不需要与其他节点配对。
  • 若要恢复链表,发现不等不能直接 return False。
  • 第二次反转必须从保存的 second 开始,不能使用已走到末尾的 right。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

复制值后双指针比较时间 O(n):提取 n 个值,再比较至多 n/2 对。

先把节点值顺序复制到 values,再让 i、j 从数组两端向内比较。回文只要求对称数值相等,不要求对称位置是同一节点对象。它把链表问题转为数组随机访问,代价是额外保存全部值;整个过程中不修改输入连接,所以不需要主解的反转与恢复阶段。

  1. 沿链表遍历,将每个 head.val 依次追加到 values。
  2. 初始化 i=0、j=len(values)−1。
  3. 当 i 小于 j 时比较两端值,不等立即返回 False;相等则 i 加一、j 减一。
  4. 两指针相遇或交错表示所有对称位置都相等,返回 True。
PYTHON 3
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) 空间答案。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 偶数链的 slow 是前半段尾,后半段从 slow.next 开始。
  2. 反转只改 next,第二个反转恢复结构;second 仍保留第一次返回的节点身份。
  3. 发现非回文也先保存 False、退出比较,再执行恢复,不能提前绕过恢复。
← 返回题库
链表25 / 100

141. 环形链表 简单

标签:哈希表 · 链表 · 双指针 · Floyd 判圈算法

力扣原题 ↗
01 / 理解题目

一句话题意

判断沿 next 遍历是否会再次回到已经访问过的节点,存在环返回 True。

教学示例

A(3)→B(2)→C(0)→D(-4)→E(7),E.next=B → True;第 4 轮在 E 相遇。

生活化理解

操场跑道前连着一条进场通道。慢跑者每拍跑一段,快跑者每拍跑两段;若道路终止,快跑者先看到终点。若进入环形跑道,快跑者相对慢跑者每拍多追一段,迟早从后面追上。

02 / 讲透主解法

Floyd 快慢指针

用两个引用完成常数空间判环,相对速度为一步使相遇证明直观。

核心思路

slow 每轮沿 next 走一步,fast 每轮走两步。在移动前检查 fastfast.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 会到空引用;若有环,两者进入环后相对距离每轮改变一格,最终必然相遇。

图解

1 / 6
共同从头出发圆内字母是节点身份;节点间箭头是 next;带名箭头是引用A3B2C0D-4E7slow / fast固定回边 E→B;入口是 B,本例相遇点是 E。
slow
A
fast
A
完成轮数
0
B→C→D→E→B
选择步骤 6 步
变量与图例

观察 fast 如何跨过 E→B 回边;两个指针改变位置,链表本身的边始终不变。

slow
每轮沿 next 前进一个节点。
fast
每轮沿 next 前进两个节点。
slow is fast
移动后判断两指针是否为同一个节点对象。
环入口 B
输入结构的第一处环节点,与相遇点 E 不同。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 令 slow=fast=head,不在初始化位置直接判定相遇。
  2. 只要 fast 与 fast.next 都非空,就让 slow 前进一步、fast 前进两步。
  3. 移动完成后比较身份,若 slow is fast 则返回 True。
  4. 若循环因快指针遇到终点而结束,返回 False。

代码(Python)

查看完整 Python 代码Floyd 快慢指针 · 与上方图解对应

fast 探路判断是否存在终点,slow 与 fast 的环内相对速度保证相遇。身份比较用 is。

PYTHON 3
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) 额外空间。
03 / 检查边界

易错点与边界

  • 不能以 val 相等判定相遇,测试中多个值为 1 的不同节点仍可无环。
  • fast.next.next 之前必须让循环条件保证 fast.next 存在。
  • 先移动再比较,避免把初始 slow=fast 错当有环。
  • 相遇点不一定是入口;单节点自环也是环,单节点指向 None 则不是。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

记录访问过的节点时间 O(n) 期望时间:n 为不同可达节点数,集合操作期望 O(1)。

seen 表示沿 next 路线此前经过的节点对象。每次访问前先判断当前对象是否已出现,若再次出现就说明路线回到了旧节点,存在环;若走到 None 则无环。应保存对象而非 val,因为不同节点可以拥有同一个值。这种写法以保存访问记录的空间换取直接判断,也容易扩展成返回第一个重复对象。

  1. 初始化空集合 seen,从 head 开始沿 next 遍历。
  2. 若当前节点已经属于 seen,立即返回 True。
  3. 否则将当前节点加入 seen,再前进一步。
  4. 遍历到 None 时返回 False。
PYTHON 3
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 题的第二阶段。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 先移动再比较,初始 slow=fast 不证明有环。
  2. 使用对象身份判断相遇,不比较节点值。
  3. Floyd 判环不改链表;相遇点不必是入口。
← 返回题库
链表26 / 100

142. 环形链表 II 中等

标签:哈希表 · 链表 · 双指针 · Floyd 判圈算法

力扣原题 ↗
01 / 理解题目

一句话题意

返回链表开始入环的那个节点对象;无环返回 None,并保持输入连接不变。

教学示例

A(3)→B(2)→C(0)→D(4)→E(7)→F(6),F.next=C → 返回入口节点 C。阶段一在 E 相遇,阶段二在 C 相遇。

生活化理解

先让快慢跑者在环形跑道上会合,确认确实有环。随后请一名向导从通道起点出发,另一名从会合点出发,两人都每拍走一步;通道的长度刚好对应会合点到入口的环上剩余距离,最终会在入口碰面。

02 / 讲透主解法

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 在此阶段保持不动。

图解

1 / 9
阶段一:从头判环圆内字母是节点身份;节点间箭头是 next;带名箭头是引用A3B2C0D4E7F6slow / fast环外 A→B→C 共 2 步;环 C→D→E→F→C 长度为 4。
阶段
判环
slow
A
fast
A
选择步骤 9 步
变量与图例

先看快慢追赶在哪里相遇,再看 finder 从头、slow 从相遇点同速走;这两个位置不要混为一谈。

slow / fast
阶段一分别走一步、两步;相遇于 E。
finder
阶段二新设在 head=A,每轮走一步。
阶段二 slow
从 E 出发,每轮走一步,与 finder 同速。
入口 / 相遇点
本例入口 C、首次追赶相遇点 E,是两个不同对象。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 从 head 启动快慢指针,在每轮移动后检查是否相遇;遇到链表终点则返回 None。
  2. 相遇时保留 slow 的位置,创建 finder=head;此后不再移动 fast。
  3. 只要 finder 与 slow 不是同一对象,便让二者各沿 next 前进一步。
  4. 身份相等时返回 finder,这个节点是环入口而非第一阶段的一般会合点。

代码(Python)

查看完整 Python 代码Floyd 判环后同步定位 · 与上方图解对应

先用二倍速度找会合点,再把 finder 放回头节点,以相同速度前进。代码返回具体对象,完全不改写 next。

PYTHON 3
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) 额外空间。
03 / 检查边界

易错点与边界

  • 返回节点对象,不返回 val、布尔值或构造一个相同值的新节点。
  • 第一阶段会合点一般不是入口,必须继续第二阶段。
  • 第二阶段两个指针都走一步,不能让其中一个继续走两步。
  • 入口可能就是 head,自环时答案也是该节点。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

节点集合找首次重复时间 O(n) 期望时间:每个不同节点最多插入集合一次。

沿唯一的 next 路线前进,用 seen 记录节点身份。进入环前不会重访任何对象;进入后绕环一周,第一次再次访问的恰好是入口。因此与只返回布尔值的集合判环不同,这里在检测重复时直接返回当前节点。它无需距离推导,但需要为所有已访问节点保存集合记录,重复数值并不影响判断。

  1. 初始化空集合 seen,从 head 开始读取节点。
  2. 若当前节点已在 seen 中,返回当前节点对象,它就是路线中首次重复的入口。
  3. 否则记录该节点并沿 next 前进。
  4. 若先遇到 None,说明没有环,返回 None。
PYTHON 3
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) 空间,但额外多绕一圈。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 第一次相遇只证明有环,不能直接作为入口返回。
  2. 第二阶段重置 finder 到头,保留 slow 在相遇点,二者改为同速。
  3. 用模环长的路程关系解释入口对齐,不依赖节点值。
← 返回题库
链表27 / 100

21. 合并两个有序链表 简单

标签:递归 · 链表

力扣原题 ↗
01 / 理解题目

一句话题意

将两条升序链表合并为一条升序链表,通过改接原节点组成结果并返回头节点。

教学示例

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]。

生活化理解

两个检票口各排着一列已按号码排序的人,要合成一列仍按号码排序的队伍。每次只比较两队最前面的人,号码较小者去总队尾;某一队清空后,另一队可以整队接上,不必继续逐个比较。

02 / 讲透主解法

哨兵 + 迭代归并

以两个队首挑选最小剩余节点,哨兵统一首次接入,便于逐步解释原节点的归并。

核心思路

dummy 是不属于答案的哨兵,tail 指向已经选定的结果前缀末尾;list1list2 分别指向两条链未选部分的头。每轮开始,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 指向各自未选的有序后缀。两头中较小者必是所有未选节点中的最小者,把它接入并推进对应输入头可保持有序;一方耗尽后可整段接上另一方。

图解

1 / 9
哨兵后还没有已选节点圆内字母是节点身份;节点间箭头是 next;带名箭头是引用S哨兵A11A23A35B11B22B36Nonelist1list2tail上排是原 A 链,下排是原 B 链;节点坐标固定,只改变箭头。
list1
A1
list2
B1
tail
S
已选前缀
选择步骤 9 步
变量与图例

观察 tail.next 从原链后继改接到另一条链;输入头保留未处理后缀,已归并前缀只确认到 tail。

dummy / S
结果前的固定哨兵,返回 dummy.next。
tail
已选前缀尾;接上剩余整段后代码不再移动它。
list1 / list2
两条尚未选择的有序后缀的头。
A1、B1 等
原输入节点身份;值相等时仍能区分对象。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 创建 dummy,令 tail=dummy,保留 list1、list2 作为各自未选后缀的入口。
  2. 两条输入都非空时比较队首,将较小节点接入 tail.next;相等时按代码优先取 list1。
  3. 移动刚取用的输入指针,再令 tail=tail.next,确认结果前缀扩展一个节点。
  4. 一方耗尽后,将另一方剩余后缀整体接到 tail.next。
  5. 返回 dummy.next,哨兵不进入答案,所有结果数据节点沿用输入对象。

代码(Python)

查看完整 Python 代码哨兵 + 迭代归并 · 与上方图解对应

tail 管理已选前缀,两个输入指针管理剩余部分。哨兵统一首次接入和后续接入的写法,最后一次连接可以整段接上剩余链。

PYTHON 3
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 个输入节点。
03 / 检查边界

易错点与边界

  • 先移动被选中链的指针,再继续下一轮,不能丢掉它原本的后继。
  • 结果头是 dummy.next,不是遍历后处于尾部的 tail。
  • 空链可以直接接另一条;不要遗漏主循环之后的剩余链。
  • 当前实现复用输入节点,会改变其 next;dummy 是唯一新增的辅助节点,不在返回链中。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

递归选择结果头时间 O(m+n):每层选择一个节点,直到其中一条链为空。

每次调用直接决定当前结果链的头:两队首中较小的节点必然最先出现,它的 next 应接到剩余部分的递归合并结果。空链时直接返回另一条链。该方法同样复用并改写原节点,但不用 dummy、tail,而靠递归返回值接回后缀;递归调用深度可能随两链总长度增长,长链需要考虑 Python 递归深度。

  1. 若 list1 为空则返回 list2;若 list2 为空则返回 list1。
  2. 当 list1.val 不大于 list2.val,递归合并 list1.next 与 list2,并把返回值赋给 list1.next。
  3. 否则递归合并 list1 与 list2.next,把返回值赋给 list2.next。
  4. 返回本层选中的较小队首,作为当前整段合并结果的头。
PYTHON 3
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) 输出空间。

其他思路的边界

  • 复用节点意味着输入连接会改变;若要求保留原链,应另建结果节点并付出输出空间。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 每次从两条有序后缀的头中选更小者。
  2. 复用节点时 tail.next 可以暂留旧边,已选前缀只确认到 tail。
  3. 哨兵统一首节点接入;相等用 <= 时先取 list1。
← 返回题库
链表28 / 100

2. 两数相加 中等

标签:递归 · 链表 · 数学

力扣原题 ↗
01 / 理解题目

一句话题意

两条非空链表以低位在前的顺序存储非负整数,返回同样逆序存储的两数之和链表。

教学示例

l1=[9,9,9] 表示 999,l2=[1] 表示 1 → 新链 R1(0)→R2(0)→R3(0)→R4(1),即 [0,0,0,1] 表示 1000。

生活化理解

把两个数竖式相加,每个链表节点是一列数位,不过个位放在最前面。tail 像记账员当前写到的位置,carry 是从上一列带来的“满十兑换券”;这一列写 total 的个位,把十位交给下一列。

02 / 讲透主解法

逐位迭代进位

链表已按低位在前排列,逐列加法与竖式直接对应,循环即可处理不等长和连续进位。

核心思路

l1l2 指向当前输入数位,carry 保存从上一列带来的进位;dummy 提供固定输出入口,tail 指向最后一个新建结果节点。输入顺序已经是个位、十位、百位,所以从头向后恰好按竖式从低位到高位计算,无需反转或先转成整数。

每列读取 xy,已耗尽的一方按 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,因此逐列保持十进制加法关系。

图解

1 / 9
初始化空结果与进位 0圆内字母是节点身份;节点间箭头是 next;带名箭头是引用A19A29A39NoneB11NoneS哨兵Nonel1l2tailA/B 是输入节点;R1..R4 只在执行 ListNode(digit) 后出现。当前位计算 / 最近写入未计算carry = 0输入链保持原连接
l1
A1
l2
B1
x / y
未读取 / 未读取
carry
0
digit
未计算
tail
S
已输出
[]
选择步骤 9 步
变量与图例

每列先算 carry 与 digit,再创建一个结果节点;两输入耗尽后,剩余进位还会驱动最后一轮。

l1 / l2
当前尚未处理的低位节点,耗尽后该输入位取 0。
x / y
本轮从输入节点读取的位值;输入为空时为 0。
carry
divmod 后是传给下一位的新进位;计算右侧时读的是上一轮进位。
digit
当前列和除以 10 的余数,要写进新节点。
tail / S
结果尾与固定哨兵;R 节点是新建的,不复用输入。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 创建 dummy,令 tail=dummy、carry=0。
  2. 只要 l1、l2 至少一个非空,或 carry 非零,就继续处理当前一列。
  3. 读取两位 x、y,缺位按 0;用 divmod(x+y+旧 carry,10) 得到新 carry 和 digit。
  4. 新建 ListNode(digit) 接到 tail.next,再推进 tail;仍非空的输入指针各前进一步。
  5. 所有输入位和进位都处理完后返回 dummy.next。

代码(Python)

查看完整 Python 代码逐位迭代进位 · 与上方图解对应

total 是当前列之和,carry、digit=divmod(total,10)。尾插法保持低位在前,结果节点与输入节点独立。

PYTHON 3
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)),应单独计入。
03 / 检查边界

易错点与边界

  • 链表是逆序存储,2→4→3 代表 342,输出也必须低位在前。
  • 循环要用 or,不能要求两条链同时非空。
  • 最高位的最后进位不能漏掉,例如 9+1 应得到 0→1。
  • 合法输入用单个 0 节点表示零,不是空链。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

递归传递进位时间 O(max(m,n)):每层计算一位。

add(a,b,carry) 的契约是返回从当前数位开始、已经包含传入进位的结果链。每层读取当前两位,用 next_carry 与 digit 区分传向高位的进位和本层数字;创建本位节点,再把高位递归返回的链接到 node.next。它同样只读输入并新建输出,差别在于进位通过参数传递、未完成的连接保存在调用栈中;调用深度随较长输入的位数增长。

  1. 以 add(l1,l2,0) 启动递归;只有两输入都空且 carry 为 0 时返回 None。
  2. 读取当前 x、y,缺位取 0,计算 next_carry、digit=divmod(x+y+carry,10)。
  3. 创建保存 digit 的 node,对两输入的下一位和 next_carry 发起递归。
  4. 将递归结果接到 node.next,并返回 node 作为本层结果头。
PYTHON 3
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) 辅助空间。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 低位在前让链表顺序与竖式加法方向一致。
  2. divmod 同时给出下一位进位与本位数字,别混用新旧 carry。
  3. 循环条件包含进位,输入长度不同和最终溢出用同一循环处理。
← 返回题库
链表29 / 100

19. 删除链表的倒数第 N 个结点 中等

标签:链表 · 双指针

力扣原题 ↗
01 / 理解题目

一句话题意

从链表中移除倒数第 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 节;当前面到最后一节时,后面的恰好站在要摘下车厢的前一节。哨兵相当于车头前额外挂的站台,连第一节车厢也能统一处理。

02 / 讲透主解法

固定间距双指针

利用固定间隔直接定位目标前驱,哨兵使目标在头部时仍使用同一改接步骤。

核心思路

建立 dummy.next=headfastslow 都从 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。

图解

1 / 8
在哨兵处初始化圆内字母是节点身份;节点间箭头是 next;带名箭头是引用S哨兵A1B2C3D4E5Noneslow / fastS 是哨兵;领先 n=2 条边,配合 fast.next 判尾。
slow
S
fast
S
n
2
间距
0
slow.next
A
选择步骤 8 步
变量与图例

先追踪 fast 与 slow 之间的两条边,再看 fast 停在尾节点时,slow 为什么正好是 D 的前驱。

dummy / S
指向原头的哨兵,让原头也有可修改的前驱。
slow
定位结束时指向待移除节点的前驱 C。
fast
先领先 n 步,然后同步到尾 E。
n
倒数位置 2,也是同步前建立的领先边数。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 创建 dummy=ListNode(0,head),令 slow=fast=dummy。
  2. 仅让 fast 前进 n 步,建立 n 条 next 边的间距。
  3. 只要 fast.next 非空,就让 slow、fast 各前进一步,直到 fast 停在尾节点。
  4. 把 slow.next 改为 slow.next.next,跳过目标节点。
  5. 返回 dummy.next,使原头被跳过的情况也得到正确结果头。

代码(Python)

查看完整 Python 代码固定间距双指针 · 与上方图解对应

fast 先走 n 步,同步到 fast 位于尾节点,slow 就是目标前驱。哨兵统一处理移除头节点。

PYTHON 3
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) 额外空间。
03 / 检查边界

易错点与边界

  • 领先 n 步配合 fast.next 判尾;若改成 fast 判空,前驱位置也要重新推导。
  • 返回 dummy.next,原头可能已经被跳过。
  • n=1 移除尾节点;n=L 移除头节点;L=1 时结果为空。
  • 仅让局部变量指向下一个节点不会改变链表,必须改前驱的 next。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

两次遍历:先数长度时间 O(L):第一次 L 步,第二次至多 L−1 步。

先用 node 计算 length=L,再把倒数排名转换为正向位置:目标是第 L−n+1 个节点,前驱是第 L−n 个位置。第二趟从第 0 个位置 dummy 出发走 length−n 步,prev 就是目标前驱。它不维持两个指针的动态间隔,推导更直接,但需要重新从头遍历一段;最终同样只改前驱的 next,并复用保留下来的节点。

  1. 从 head 开始用 node 遍历链表,逐个累加 length。
  2. 创建指向 head 的 dummy,令 prev=dummy。
  3. 让 prev 前进 length−n 步,停在目标之前。
  4. 令 prev.next=prev.next.next,返回 dummy.next。
PYTHON 3
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)。若只给待移除节点而不给头,常见复制后继值技巧不适用于尾节点,也改变节点值语义,与本题保留节点身份的前驱改接不同。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. “领先几步”与“停在尾还是尾后”要一起推导。
  2. 要改目标前驱的 next,不能只移动局部变量。
  3. 哨兵统一移除原头;返回 dummy.next 才能拿到正确新头。
← 返回题库
链表30 / 100

24. 两两交换链表中的节点 中等

标签:递归 · 链表

力扣原题 ↗
01 / 理解题目

一句话题意

将链表相邻节点每两个一组交换,交换节点连接而非节点值;最后不足两个的节点保持原位。

教学示例

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。换的是人本身,胸牌上的数字不改。

02 / 讲透主解法

哨兵 + 局部三次改接

固定一组两个节点后逐条改接,操作数量固定,容易按图讲清且只需常数额外空间。

核心思路

dummy.next=headprev 指向已经交换完前缀的尾节点;第一组前面用 dummy 充当这个前驱。每轮开始,prev 后面仍是原顺序的未处理后缀。只有 prev.nextprev.next.next 都存在,才保存 a=prev.nextb=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,保持已处理前缀完整且各组不重叠。

图解

1 / 10
哨兵位于第一组之前圆内字母是节点身份;节点间箭头是 next;带名箭头是引用S哨兵A1B2C3D4E5Noneprev同一列的上下节点是一组;固定坐标让每次 next 改写都能逐帧追踪。
prev
S
a / b
尚未赋值
结果头
A
选择步骤 10 步
变量与图例

每组依次看三条箭头:a 接后缀、b 接 a、prev 接 b;最后把 prev 移到这一组的新尾 a。

dummy / S
第一组之前的哨兵,最终返回它的 next。
prev
本组前驱,也是已处理前缀尾;完成后移到旧 a。
a
本组原第一个节点,交换后成为组尾。
b
本组原第二个节点,交换后成为组头。
rest / 后缀
改接前的 b.next,本例先为 C、再为 E;原代码直接读 b.next。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 创建指向 head 的 dummy,令 prev=dummy,表示第一组的前驱。
  2. 确认 prev 后至少有两个节点,固定 a=prev.next、b=a.next。
  3. 先执行 a.next=b.next 保留原后缀,再执行 b.next=a 完成组内反向连接。
  4. 执行 prev.next=b,把新组头接回前缀;随后令 prev=a,准备处理下一组。
  5. 不足两个节点时停止,返回 dummy.next,末尾单节点保持不变。

代码(Python)

查看完整 Python 代码哨兵 + 局部三次改接 · 与上方图解对应

prev 维护组前驱,a、b 固定本组节点对象。先保存通往后缀的连接,再反向连接本组,最后把组前驱接到新头。

PYTHON 3
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) 额外空间,结果复用输入节点。
03 / 检查边界

易错点与边界

  • 不能只交换 val,题目要求移动节点,身份顺序也必须变化。
  • a.next 要先指向后缀,再改 b.next,否则容易丢失后续链或成环。
  • 完成一组后 prev=a;若停在 b,下一轮会错误地重叠处理。
  • 奇数长度最后一个节点不动,空链与单节点不需要交换。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

递归交换后缀时间 O(n):每层处理两个节点。

swapPairs(head) 返回当前整段两两交换后的新头。保存 second=head.next,先递归处理 second.next 后面的剩余节点,让 head.next 接到返回的已交换后缀,再令 second.next=head,组成 second→head→交换后后缀。它把“已处理前缀的连接”交给递归返回值管理,不使用 prev 哨兵循环;每次递归消费两个节点,调用栈随节点对数增长,长链需考虑 Python 递归深度。

  1. 若 head 为空或没有后继,当前段无需交换,直接返回 head。
  2. 保存 second=head.next,递归交换 second.next 开始的后缀。
  3. 将递归返回的新后缀头赋给 head.next。
  4. 令 second.next=head,返回 second 作为本段的新头。
PYTHON 3
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 不变。只比较输出数值不足以区分交换节点与交换值的实现。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 三次改接各有职责:保后缀、反向连组内、接回组前驱。
  2. 局部 a、b 保存身份,改 next 不会自动移动这些引用。
  3. 完成后 prev=a,下一组不重叠;不足两个节点就停止。
← 返回题库
链表31 / 100

25. K 个一组翻转链表 困难

标签:递归 · 链表

力扣原题 ↗
01 / 理解题目

一句话题意

把链表每连续 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。先确认整组到齐,再解开车钩,否则尾部半组会被错误翻转。

02 / 讲透主解法

迭代:探测组尾后原地反转

先确认完整组再反转,接线边界清楚,且只需固定数量指针,便于面试复现。

核心思路

group_prev 是已处理前缀的尾节点,也是待翻组的前驱。每轮先让 kth 从它向后走 k 步;若途中到达 None,剩余节点不足一整组,直接返回 dummy.next。这一轮尚未改动任何 next,因此尾部自然保持原序。哨兵 dummy 让第一组和后续组共用同样的接线方式。

确认组尾后,保存 group_next=kth.next,反转范围为半开区间 [group_prev.next, group_next)。令 prev=group_nextcur=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 个的尾部自然原样返回。

图解

1 / 12
哨兵统一第一组的入口节点内:身份 / 值;实线:next;上方箭头:变量引用dummyDgroup_prev1A2B3C4E5F当前接线 / 执行位置待处理:A→B→C→E→F;完整组大小 k=2
k
2
group_prev
D
链表
D→A(1)→B(2)→C(3)→E(4)→F(5)→∅
选择步骤 12 步
变量与图例

盯住三种边:cur.next 的逐次反向边、旧组头通向后缀的边、group_prev.next 的组间重接边。

group_prev
已处理前缀的尾节点;当前待翻组的前驱。
kth / group_next
本组第 k 个节点 / 本组之后的第一个节点。
prev / cur
已翻部分的头 / 下一待改 next 的节点。
nxt
改边前保存的旧后继,保证未处理节点不丢失。
old_head
本组旧头,翻转后作为新尾和下一轮 group_prev。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 建立 dummy.next=head,并令 group_prev=dummy。
  2. 令 kth 从 group_prev 向后走 k 步;遇到 None 就返回 dummy.next,保留不足一组的尾部。
  3. 保存 group_next=kth.next,令 prev=group_next、cur=group_prev.next。
  4. 在 cur 到达 group_next 前,依次保存 nxt、反向连接 cur.next,再同时推进 prev 和 cur。
  5. 保存 old_head=group_prev.next,将 group_prev.next 接到 kth,再把 group_prev 移到 old_head,继续探测下一组。

代码(Python)

查看完整 Python 代码迭代:探测组尾后原地反转 · 与上方图解对应

group_prev 划分已处理前缀和未处理后缀;prev 初值是组后节点,使翻转过程同时完成尾部接线。只持有固定数量指针。

PYTHON 3
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) 额外空间;复用原节点。
03 / 检查边界

易错点与边界

  • 必须先确认有 k 个节点再改指针,不能翻到一半发现数量不足。
  • 保存 nxt 后再写 cur.next,否则丢失剩余链表入口。
  • 旧组头变成组尾;下一轮的 group_prev 应移动到旧组头。
  • k=1 与 k=n 都合法;交换 val 无法满足原地改变节点连接的要求。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

递归:一组作为一个子问题时间 O(n),每个节点进行常数次访问。

每个调用负责翻转以 head 开始的一整组,并返回这一组的新头。stop 向后走 k 步确定排除边界;不足 k 个时原样返回 head。翻转后,旧头 head 成为组尾,用 head.next 接上递归处理后的 stop 后缀。它把组间连接交给返回值,比主解少了 group_prev 的外层推进,但每组占一个调用层,k=1 时仍可能达到 n 层。

  1. 从 head 开始让 stop 前进 k 步;若提前为空,返回未改动的 head。
  2. 令 prev=stop、cur=head,逐个反转半开区间 [head,stop),每次先保存旧后继。
  3. 递归调用 reverseKGroup(stop,k),得到剩余链表处理后的新头。
  4. 把旧组头 head.next 接到递归结果,返回 prev 作为当前整段的新头。
PYTHON 3
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),区别只是边界组织方式。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 先探测 k 个节点,后改边;末尾不足 k 个不会被破坏。
  2. prev 初值是 group_next,使旧组头第一次改边就接好后缀。
  3. 组内逐点反转后,只改 group_prev.next,再把前驱移到旧组头。
← 返回题库
链表32 / 100

138. 随机链表的复制 中等

标签:哈希表 · 链表

力扣原题 ↗
01 / 理解题目

一句话题意

深拷贝带 next 和 random 指针的链表,副本指针必须只指向副本节点或空。

教学示例

输入 [[7,2],[13,0],[11,2],[10,null]](第二项为 random 的 0 基下标);输出关系相同的新链,且原链恢复。

生活化理解

像复制一份员工通讯录:next 是名册顺序,random 是紧急联系人。不能只抄名字后继续引用旧员工卡;每张新卡的联系人也必须指向新卡。同名员工仍是不同的人,所以映射依据是节点身份。

02 / 讲透主解法

三遍穿插与拆分

三遍各自承担一种接线任务,能清楚展示如何用相邻副本建立映射,并达到常数额外空间。

核心思路

深拷贝要求每个原节点都有独立副本,并保留 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 都保留,返回链不共享旧节点。

图解

1 / 14
原链的 random 与 next 无关实线 next;琥珀虚线 random;A 与 A′ 是两个不同对象random=∅A7curB13C11D10旧新对应与本轮变量原链:A(7)→B(13)→C(11)→D(10);尚未创建任何副本
cur
A
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′。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 若 head 为空则返回 None;否则从 head 开始第一遍遍历。
  2. 为每个原节点创建 clone,插在原节点之后,再经 clone.next 前进到下一个原节点。
  3. 将 cur 重置到 head,第二遍用 cur.random.next 找到 random 目标的副本,空目标仍设为空。
  4. 保存 result=head.next,再重置 cur;第三遍先恢复原节点 next,然后将 clone.next 接到下一原节点的副本。
  5. 最后一个副本的 next 设为 None,返回 result;此时原链已恢复,副本链只引用新节点。

代码(Python)

查看完整 Python 代码三遍穿插与拆分 · 与上方图解对应

第一遍让 cur.next 充当 cur 的映射,第二遍补齐 random,第三遍同时恢复原链和连接副本链。O(1) 是不计必需输出节点的额外空间。

PYTHON 3
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) 输出节点;处理期间暂时修改输入。
03 / 检查边界

易错点与边界

  • 复制节点值并保留旧 random 是浅拷贝,身份检查会失败。
  • 必须先穿插全部副本,再连接 random,不能假设目标已经处理过。
  • random 可以为空、自指或与其他节点形成环;遍历主线仍是无环 next。
  • 穿插法第三遍要恢复原链,不能把原链留在交错状态。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

两遍哈希映射时间 O(n),n 为节点数,两次遍历。

copies[原节点]=新节点 用节点身份建立一一对应,两个值相同的原节点也有不同副本。第一遍只创建节点,保证任意 random 目标的副本都已存在;第二遍通过映射连接 next 和 random。copies[None]=None 统一处理空指针。它不修改输入链,适合不能临时穿插副本的场景,代价是额外保存 O(n) 个映射项。

  1. 建立 copies={None:None},从 head 沿 next 遍历原链。
  2. 对每个原节点 cur 创建 Node(cur.val),记录 copies[cur],不能按 val 建键。
  3. 重新从 head 遍历,把 copies[cur].next 设为 copies[cur.next],把 random 设为 copies[cur.random]。
  4. 返回 copies[head];空头通过预设映射自然返回 None,原链始终不变。
PYTHON 3
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 构成普通链表这一前提。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 穿插节点.next 就是一张隐式旧→新映射,不按节点值区分对象。
  2. 新 random=旧 random 的紧邻副本;自指与跨节点引用使用同一公式。
  3. 拆分同时恢复旧 next 和串好新 next,须提前保存新入口。
← 返回题库
链表33 / 100

148. 排序链表 中等

标签:链表 · 双指针 · 分治 · 排序 · 归并排序

力扣原题 ↗
01 / 理解题目

一句话题意

将单链表按值升序排序,进阶要求 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]。

生活化理解

把一叠乱序收据拆成小叠,先让每小叠有序,再像合并两条排队队伍那样只比较队首。链表不能便宜地随机访问中间位置,但移动车钩就能把节点接到新队伍末尾,所以归并比数组式下标交换自然。

02 / 讲透主解法

自顶向下归并

分成两半、分别排好、再合并的递归结构直接,快慢指针与两链归并都容易单独解释。

核心思路

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 区分已确认部分与旧连接。

图解

1 / 13
快慢指针开始找拆分点节点内:身份 / 值;实线:next;上方箭头:变量引用4Aslow2Bfast1C3D当前接线 / 执行位置调用:sort(A→B→C→D);四条 next 此时均未改动
head
A(4)
slow
A(4)
fast
B(2)
选择步骤 13 步
变量与图例

先看真正断开的 next,再看归并时 a、b 保住两条剩余链,tail 后的边怎样逐次改写。

slow / fast
快慢指针;slow 最终停在左半段尾。
right
断开 slow.next 之前保存的右半入口。
a / b
当前 merge 两条有序链的未处理头。
dummy / tail
本次合并的哨兵 / 已确认前缀最后节点。
M1 / M2 / M3
三次不同 merge 调用创建的哨兵身份。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 空链或单节点直接返回;否则初始化 slow=head、fast=head.next。
  2. 快指针每次走两步、慢指针走一步,直到 slow 停在左半段尾部。
  3. 保存 right=slow.next,再断开 slow.next;分别递归排序左半 head 和右半 right。
  4. 建立合并哨兵与 tail,反复比较两条有序链头,将较小节点接入;相等时优先左链。
  5. 一侧耗尽后接上另一侧,返回合并哨兵的 next,作为当前递归的有序结果。

代码(Python)

查看完整 Python 代码自顶向下归并 · 与上方图解对应

fast 从 head.next 起步,确保双节点时 slow 停在第一个节点;左右链分离后分别排序。merge 为迭代实现,避免合并阶段额外 O(n) 递归。

PYTHON 3
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),复用原节点。
03 / 检查边界

易错点与边界

  • 分割后必须切断左段尾巴,确保递归子问题缩小,也防止合并时串入后续段。
  • 递归归并的调用栈是 O(log n),不是 O(1)。
  • 稳定性依赖相等时选择左链;本题通常只要求有序,但保留稳定性成本很低。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

自底向上归并:常数额外空间时间 O(n log n)。每轮分段、合并和定位尾部各扫描 O(n),轮数 O(log n)。

先统计长度 n,从 size=1 的有序段开始,每轮合并相邻两段,再将 size 加倍。split(head,size) 截下一段并返回后续入口;prev 是本轮已合并部分的尾部,cur 保存尚未分段的入口。merge 仍按两链头大小循环接线。该实现合并后沿 prev.next 找到新尾,每轮总扫描仍为线性;它不使用递归、切片或节点数组,适合严格常数额外空间的要求。

  1. 沿 next 统计节点总数 n,创建 dummy.next=head,并设 size=1。
  2. 每轮令 prev=dummy、cur=dummy.next;轮初各段长度至多 size 且已经有序。
  3. 令 left=cur,用 split(left,size) 截出右段入口,再用 split(right,size) 保存下一对段的入口 cur。
  4. 把 merge(left,right) 的结果接到 prev.next,再沿合并结果将 prev 推进到段尾。
  5. 本轮所有相邻段处理完后令 size 加倍;size≥n 时返回 dummy.next。
PYTHON 3
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);不能冒充常数空间。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 先保存 right 再断 slow.next,递归才能真正缩小链段。
  2. 递归保证两半有序,merge 只需比较两个当前头。
  3. tail 标记已确认前缀边界,尾后旧边可改写;剩余链由 a、b 保存。
← 返回题库
链表34 / 100

23. 合并 K 个升序链表 困难

标签:链表 · 分治 · 堆(优先队列) · 归并排序 · 锦标赛排序

力扣原题 ↗
01 / 理解题目

一句话题意

把 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 个售票窗口各有一列按号码升序排队的人。全场下一个最小号码一定在某列队首;每放行一人,只需让那一列的下一人进入候选区。最小堆就是能快速找到最小队首的候选牌架。

02 / 讲透主解法

最小堆:维护 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 在值相等时打破平局,整条链始终只有一个候选在堆中。

图解

1 / 11
只把三个链头放入最小堆A/C 的值同为 1;堆项用 (值, 来源编号, 节点) 决定顺序来源 0 的未处理后缀A:1B:4来源 1 的未处理后缀C:1D:3来源 2 的未处理后缀E:2F:6最小堆:父项 ≤ 子项(1,0,A)heap[0](1,1,C)heap[1](2,2,E)heap[2]堆数组(真实内部顺序)[0] (1, 0, A)[1] (1, 1, C)[2] (2, 2, E)已接入的有序前缀:止于 tail(下方另示 tail.next 的真实值)dummy=tail;前缀为空本轮 node=尚未弹出;tail.next=None(灰色边是原后继,未必已被选择)
heap
[(1, 0, 'A'), (1, 1, 'C'), (2, 2, 'E')]
tail
dummy
已接入前缀
[]
选择步骤 11 步
变量与图例

每次先从堆顶拿走一个链头,再只补回同一条链的后继;下方前缀只确认到 tail。

heap
真实最小堆数组,项为 (节点值, 原链编号, 节点引用)。
i / node
刚弹出节点的来源编号与身份。
tail
已确认有序前缀的最后节点。
node.next
弹出节点的原后继;仅这个后继需要补入堆。
未处理后缀
图中的教学记录;代码由堆项与 next 保存入口,并未修改 lists 数组。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 从每条非空链取头节点,组成 (值,链编号,节点) 元组列表并执行 heapify。
  2. 建立 dummy=tail,表示结果前缀暂时为空。
  3. 弹出堆顶的最小候选,把它接到 tail.next,再令 tail 指向该节点。
  4. 若该节点有 next,只将这个后继连同相同来源编号压回堆。
  5. 重复直到堆空,返回 dummy.next;所有输入链均为空时自然返回 None。

代码(Python)

查看完整 Python 代码最小堆:维护 k 个队首 · 与上方图解对应

heap 只放各链未消费部分的头,i 是该链编号。每次输出一个节点,仅更新一个候选,适合多路持续输入的思路。代码复用输入节点。

PYTHON 3
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) 堆空间;输出复用节点。
03 / 检查边界

易错点与边界

  • 堆元素值相同时需要稳定的可比较次关键字,不能直接让 Python 比较 ListNode。
  • N 是所有链表节点数之和,不能把单链长度误作总规模。
  • 空输入列表、列表里全是空链都应返回 None。
  • 这些实现会重新连接原节点;若调用者要求原链保留,需要另外复制节点。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

迭代分治:平衡合并时间 O(k + N log(k+1));每层每个节点至多参与一次合并。

step 表示当前链组的跨度,lists[i] 保存从 i 开始的已合并链组。先两两合并,再让跨度按 1、2、4…翻倍,避免不断向同一条越来越长的总链接入新链。每个节点最多参与 O(log k) 层合并;merge 使用 a、b、tail 逐点重接,不创建输出节点数组。该代码直接更新输入 lists 中的头指针。

  1. 输入头指针列表为空时返回 None,否则设 step=1。
  2. 按间隔 2×step 枚举 i,把 lists[i] 与 lists[i+step] 两个链组送入 merge。
  3. merge 每次接入较小链头,相等时优先左侧;一侧耗尽后整体接上另一侧。
  4. 将合并头写回 lists[i];本轮没有配对的末尾链组保留到后续轮次。
  5. 每轮令 step 加倍,直到覆盖全部输入链,返回 lists[0]。
PYTHON 3
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 仍由堆持有。

面试追问与解法取舍

若要求在线不断取出最小值,堆的状态最直观;若所有链都已给定且追求少量额外空间,迭代分治很合适。总长度相差很大时还可按链长优先合并短链来减少重复处理,但需要额外记录长度与调度成本,通常不是本题首选。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 每条链只放一个当前头,堆空间 O(k),每个节点一次弹出和至多一次入堆。
  2. 相同值靠来源编号打破平局,不要求 ListNode 支持大小比较。
  3. 已接入前缀以 tail 为界;尾后的旧 next 不能当作已确认输出。
← 返回题库
链表35 / 100

146. LRU 缓存 中等

标签:设计 · 哈希表 · 链表 · 双向链表

力扣原题 ↗
01 / 理解题目

一句话题意

设计容量固定的 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]。

生活化理解

把常用书放在书桌上的一列书架:每次取书或更新书,就把它挪到最左端;书架满了,新书到来便把最右端那本收走。字典是“书名→书所在位置”的索引,双向链表负责从任何位置抽出书并放回最左端。

02 / 讲透主解法

哈希表 + 手写双向链表

把按键定位和使用顺序分开维护,能直接说明 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 就是应淘汰者,且字典和链表一起更新后不会留下可命中的旧键。

图解

1 / 13
建立容量为 2 的空缓存上箭头 next →;下箭头 ← prev;H/T 是哨兵;capacity=2HHTT双向空链:H ⇄ Tnodes 字典:0 项{}本步结果 / 指针含义靠 H 为最近使用;靠 T 为最久未使用
capacity
2
nodes
{}
主链
H⇄T
选择步骤 13 步
变量与图例

同时看字典“key 指向哪个节点”和双向链“谁更近用”;每次命中都把同一个节点移到 H 后。

nodes
key→Node 的字典;值对象与链表中的是同一个节点。
head / tail
图中的 H/T 哨兵;H 后为 MRU,T 前为 LRU。
node
本次命中或新建的数据节点。
prev / next
一个节点的前后指针,使任意位置摘接只需常数操作。
victim
超容量时选出的 tail.prev。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 保存 capacity、初始化 nodes,并将 head 与 tail 两个哨兵双向相连。
  2. get 未命中返回 −1;命中则用 nodes 找到节点,调用 _unlink 和 _front 后返回 value。
  3. put 已有键时更新原节点 value 并摘下;新键时建立 Node(key,value) 并加入 nodes。
  4. 将该节点统一插到 head 后,使它成为最近使用节点。
  5. 若 len(nodes)>capacity,取 tail.prev,先摘链,再按 victim.key 从字典移出。

代码(Python)

查看完整 Python 代码哈希表 + 手写双向链表 · 与上方图解对应

_unlink 只摘下链表节点,_front 只插到 head 后;get 和 put 组合这两个小操作。节点同时保存 key 和 value,才能在淘汰尾节点时找到应移除的字典项。

PYTHON 3
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)。
03 / 检查边界

易错点与边界

  • get 命中也要刷新顺序;遗漏后就会误变成接近 FIFO 的缓存。
  • 更新已有键不是新增节点,不能错误占用第二份容量。
  • 淘汰时链表和字典必须同步,否则缓存会返回已经淘汰的键。
  • 约定好哪端是 MRU、哪端是 LRU;两种代码方向不同,不能混用。
  • 题目容量为正数;单槽容量足以暴露头尾连接和重复更新问题。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

OrderedDict:标准库维护访问顺序时间 get、put 平均 O(1)。

OrderedDict 同时提供键查找和顺序维护,省去手写节点与接线。本实现将最近使用项放在右端,与主解头部最近的方向相反;move_to_end(key) 刷新访问次序,popitem(last=False) 从左端淘汰最久未使用项。普通的值更新并不足以表达刷新顺序,所以 put 后仍显式调用 move_to_end。

  1. 保存 capacity,创建空的 OrderedDict 作为 data。
  2. get 未命中返回 −1;命中则 move_to_end(key),再返回对应值。
  3. put 先设置 data[key]=value,然后将这个键移到右端作为最近使用项。
  4. 若长度超过容量,调用 popitem(last=False) 淘汰左端最久未使用项。
PYTHON 3
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,通常还需要按频次组织多个链表。真实缓存的并发与过期策略属于另一层需求,不包含在本题设计里。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 字典解决定位,双向链表解决最近使用次序;两者共享节点对象。
  2. 命中与更新都先摘开再插到头部,更新同一 key 不增加节点数。
  3. 容量溢出淘汰 tail.prev,并同步更新 nodes;哨兵统一首尾接线。
← 返回题库
二叉树36 / 100

94. 二叉树的中序遍历 简单

标签:栈 · 树 · 深度优先搜索 · 二叉树

力扣原题 ↗
01 / 理解题目

一句话题意

按左子树、当前节点、右子树的顺序返回二叉树全部节点值。

教学示例

树的层序表示 [4,2,5,1,3];输出中序 [1,2,3,4,5]。

生活化理解

把每个节点当作一间有左右两条走廊的展厅:先逛完左侧所有展厅,再给当前展厅盖章,最后逛右侧。栈就是回程便签,提醒你“左边逛完以后,还有这间展厅没盖章”。

02 / 讲透主解法

显式栈:模拟未完成的递归

用入栈等待、出栈访问明确表达左根右顺序,也能避免深树触发 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 同时为空才表示没有未处理节点。

图解

1 / 12
从根 4 开始,答案和栈均为空42513stack(右端是栈顶)[]栈顶:空本帧变量 / 返回结果cur=4刚输出=无ans=[]入栈表示“等左边完成”;只有出栈时才加入 ans。cur:待入栈
cur
4
stack
[]
ans
[]
本帧输出节点
选择步骤 12 步
变量与图例

区分“压栈等待”和“弹栈输出”:栈中的节点还没有被写入 ans。

cur
下一次沿左路探索的节点;出栈输出后会转到该节点的右孩子。
stack
待左子树完成后再输出的节点,右端为栈顶。
ans
已经按左根右确定的输出序列。
本帧输出节点
图中单独标记刚弹栈输出者,避免与已转向的 cur 混淆。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 初始化空 ans 与 stack,令 cur=root。
  2. 只要 cur 非空,就把当前节点压栈并令 cur=cur.left,一直下降到空。
  3. 弹出栈顶放回 cur,此时它的左子树已完成,将 cur.val 追加到 ans。
  4. 令 cur=cur.right,转入右子树;只要 cur 或 stack 非空就重复上述过程。
  5. 二者同时为空后返回 ans,空树自然返回空列表。

代码(Python)

查看完整 Python 代码显式栈:模拟未完成的递归 · 与上方图解对应

stack 留住等待访问的祖先;cur 控制当前要探索的子树。算法无需修改树,适合深链避免语言递归限制。

PYTHON 3
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) 输出。
03 / 检查边界

易错点与边界

  • 循环条件是 cur 非空或 stack 非空,用 and 会漏掉待处理祖先。
  • 不能在压栈时输出,否则顺序变成根先于左子树。
  • 只在二叉搜索树中,中序才保证按值排序;普通二叉树没有此保证。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

递归:直接表达左根右时间 O(n),每节点访问一次。

walk(node) 的契约是把以 node 为根的整个子树按中序追加到同一个 ans。先 walk(node.left),左侧返回后追加 node.val,再 walk(node.right),由调用栈保存未完成的祖先。它最直接地表达遍历定义;闭包共享 ans,避免逐层拼接返回列表,但深树可能触发 Python 递归限制。

  1. 创建 ans,并定义向这个列表追加结果的 walk(node)。
  2. node 为 None 时直接返回,不输出任何值。
  3. 先递归遍历左子树,返回后追加当前节点值,再递归遍历右子树。
  4. 从 root 调用 walk,全部调用完成后返回 ans。
PYTHON 3
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) 空间。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 入栈是延期访问,出栈才真正输出。
  2. 每进入一棵右子树,都重新沿其左路下降。
  3. 循环条件要保留 cur 非空或 stack 非空的任一情形;每节点一次入栈、一次出栈。
← 返回题库
二叉树37 / 100

104. 二叉树的最大深度 简单

标签:树 · 深度优先搜索 · 广度优先搜索 · 二叉树

力扣原题 ↗
01 / 理解题目

一句话题意

求从根到最远叶子路径包含的节点数;空树深度为 0。

教学示例

层序输入 [3,9,20,null,null,15,7];最大深度 3,例如路径 3→20→15 含 3 个节点。

生活化理解

把树当作一座分岔矿井:每经过一层平台深度加一,向左右岔路分别探测,最深那条路线决定总深度。这里数的是平台数量,也就是节点数,不是平台之间的梯子数量。

02 / 讲透主解法

递归:子树高度

空树为零、父高度取左右最大值加一,状态含义与代码完全对应,适合先建立树递归模型。

核心思路

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(左返回,右返回) 就仍满足同一定义。返回值沿调用栈逐层向上合成,所以访问一个节点并不意味着其高度已经完成。

图解

1 / 9
进入根 3,先求左子树高度3920157调用栈(外层 → 内层)[3]当前 root=3本帧变量 / 返回结果左返回=待计算右返回=待计算返回=尚未返回h 按节点数计:空树 0,叶子 1;左右数值表示递归子调用的返回值。h=待返回h=待返回h=待返回h=待返回h=待返回
当前 root
3
调用栈
[3]
当前节点左返回
待计算
当前节点右返回
待计算
本帧返回
尚未返回
已知高度
{}
选择步骤 9 步
变量与图例

先看空孩子返回 0,再看叶子返回 1;父节点只取左右返回值中的较大者再加自己。

root
当前这一次递归调用的根,每个栈帧都有自己的 root。
调用栈
尚未返回的递归调用,按外层到内层显示。
左返回 / 右返回
当前节点两次递归子调用的观察结果;原代码直接在表达式中使用。
h
该节点最终返回的高度,按节点数计,待计算时不填 0。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 若当前 root 为 None,返回空子树高度 0。
  2. 递归求 root.left 的高度,保留该返回值。
  3. 递归求 root.right 的高度,等待两个子结果都完成。
  4. 返回两者最大值加 1;最外层返回值就是整棵树的最大深度。

代码(Python)

查看完整 Python 代码递归:子树高度 · 与上方图解对应

两个子问题返回的是局部高度,取最大值再加 1。空树返回 0 同时处理了叶子的孩子。

PYTHON 3
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 递归深度。
03 / 检查边界

易错点与边界

  • 本题数节点,单节点深度为 1;直径题通常数边,口径不同。
  • 树很宽时 BFS 队列会大;树很深时递归栈会深,选择取决于形状。
  • 空子树在递归入口返回 0;不能把当前节点距整树根的深度当成它的子树高度。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

层序遍历:数完整层时间 O(n),每节点入队和出队各一次。

queue 保存等待按层处理的节点,depth 记录已经完成的层数。range(len(queue)) 在本轮开始时固定当前层的节点数量,循环中新入队的孩子留到下一轮。一整层处理完才将 depth 加一,而不是每弹出一个节点就加一。它避开递归深度限制,空间取决于最大层宽。

  1. 空树返回 0;否则令 queue 包含 root,depth=0。
  2. 在每轮开始用 len(queue) 固定这一层需要处理的节点数。
  3. 恰好弹出这些节点,将各自非空的左、右孩子追加队尾。
  4. 整层完成后令 depth 加一,继续处理下一层。
  5. 队列清空后返回 depth。
PYTHON 3
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 相比,更适合宽而浅的树。最大深度必须看所有分支,不能遇到第一个叶子就返回。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 空子树返回 0,叶子用同一公式自然得到 1。
  2. 父节点等两个返回值,再取 max 加自己;返回高度按节点数计。
  3. 每个节点计算一次,时间 O(n),递归栈空间由树高 h 决定。
← 返回题库
二叉树38 / 100

226. 翻转二叉树 简单

标签:树 · 深度优先搜索 · 广度优先搜索 · 二叉树

力扣原题 ↗
01 / 理解题目

一句话题意

把二叉树每个节点的左右子树交换,返回翻转后的根节点。

教学示例

层序输入 [4,2,7,1,3,6,9];输出 [4,7,2,9,6,3,1];根及七个节点对象均为原对象。

生活化理解

给整棵家族树照镜子:每个分岔口的左右两条分支互换,但每位成员的身份和值不变。不是只交换最顶层两支;分支内部的每个分岔口也都要左右对调。

02 / 讲透主解法

迭代 DFS:栈保存待交换节点

每个节点只做一次左右引用交换,显式待办栈与图中处理顺序一致,便于观察原地修改。

核心思路

翻转的局部操作是交换 node.leftnode.right。交换的是整棵子树的入口引用,节点值和身份都不变;如果对每个节点恰做一次,任意原路径中的左、右方向都会互换,得到完整镜像。只交换根还不够,根下面的内部节点也必须处理。

stack 保存已经发现但尚未完成交换的节点。每轮弹出 node,先同时交换它的两个孩子,再将交换后的非空 left、right 依次压栈。由于后进先出,新的 right 会先被处理:图中根 4 交换后压入 [7,2],下一节点就是 2。这是当前代码的确切顺序;它不需要等待孩子完成后再回到父节点。

每个非根节点由父节点在交换后压入一次,因此最终每个节点只交换一次,不会重复抵消。叶子的两个空引用交换后仍为空,也不再增加待办。图为追踪身份固定节点位置,实际结构要按边上的 L/R 标签读取。栈空后返回原 root 对象;最终根 4 的左侧是 7→(9,6),右侧是 2→(3,1)。

不变量

每轮完成当前节点的处理时,它的两个孩子引用已经交换,非空孩子也进入了待办栈。树中每个节点只有一个父节点,因此每个非空节点恰处理一次。所有节点完成局部交换后,任一原来的左路径都对应相同长度的右路径,整树成为镜像。

图解

1 / 9
只把根 4 放入待交换栈节点位置固定;实际 left/right 以边上 L/R 为准LRLRLR4待处理2待处理7待处理1待处理3待处理6待处理9待处理stack(右端先弹出)[4]node=尚无当前节点的真实孩子left=Noneright=None已处理 0/7 个压栈次序:先交换后的 left,再 right;因此 right 先被弹出。原根的左右对象都保留;只交换 left/right 引用,不改节点值。
stack
[4]
根的 left/right
(2,7)
已交换节点
[]
选择步骤 9 步
变量与图例

图中固定节点身份和位置,请跟随边上的 L/R:它们才是每个节点真正的 left/right。

stack
等待交换孩子的节点,图中右端为下一次弹出者。
node
本帧刚从栈弹出并交换的节点。
left / right
真实孩子引用;图中用 L/R 边标签表示,屏幕位置固定以便追踪身份。
root
原根对象,方法原地修改引用后返回它。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 非空树令 stack=[root],空树令 stack=[]。
  2. 弹出一个 node,同时交换 node.left 与 node.right。
  3. 先将交换后的非空 left 压栈,再将非空 right 压栈,因此下一次优先处理新的右侧。
  4. 继续直到栈空,使所有节点各完成一次局部交换。
  5. 返回原 root;空树也直接返回 None。

代码(Python)

查看完整 Python 代码迭代 DFS:栈保存待交换节点 · 与上方图解对应

先交换,再把当前两个孩子压栈。顺序不影响最终镜像,因为每次操作只改当前节点的两条出边。

PYTHON 3
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) 显式栈上界,不使用递归,也不创建新树。
03 / 检查边界

易错点与边界

  • 只交换根的左右孩子无法完成整个镜像。
  • 不能先 root.left=root.right,再把 root.right=root.left,原左指针已经丢失。
  • 本迭代代码先交换当前节点,再压入交换后的非空 left 和 right;每个节点只能入栈处理一次。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

递归:返回镜像子树时间 O(n),每节点交换一次。

invertTree(root) 的契约是原地完成当前子树的镜像并返回原根。代码使用同时赋值,先计算原右子树的递归结果,再计算原左子树的递归结果,最后分别赋给 root.left 和 root.right。父节点的两个入口在右侧求值期间尚未被覆盖,所以第二次调用仍能取得原左子树;这与主解先交换父节点再压入孩子的执行顺序不同。

  1. root 为 None 时返回 None。
  2. 先递归翻转原 root.right,得到应放在新左侧的子树根。
  3. 再递归翻转原 root.left,得到应放在新右侧的子树根。
  4. 右侧两次调用都返回后,同时赋值给 root.left、root.right,返回原 root。
PYTHON 3
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)。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 翻转交换节点引用,不交换节点值,也不新建一棵树。
  2. 只换根不够,每个内部节点的 left/right 都要交换。
  3. 栈先压 left 再压 right 时,right 先处理;遍历次序与正确性分开理解。
← 返回题库
二叉树39 / 100

101. 对称二叉树 简单

标签:树 · 深度优先搜索 · 广度优先搜索 · 二叉树

力扣原题 ↗
01 / 理解题目

一句话题意

判断二叉树的左右两半是否在结构和值上互为镜像。

教学示例

层序输入 [1,2,2,3,4,4,3];左侧身份 A=2、C=3、D=4,右侧 B=2、E=4、F=3;输出 True。

生活化理解

像把纸上的树沿中线对折:左边靠外的枝条必须对上右边靠外的枝条,左边靠内的枝条对上右边靠内的枝条。不仅数字要相同,缺枝的位置也必须对应。

02 / 讲透主解法

队列:逐对检查

队列明确保留镜像位置的节点对,值和空位置一起检查,容易讲清为什么逐层回文不够。

核心思路

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 中每一项都是尚需验证的镜像位置对。每次取出时,空与空通过、仅一空立即失败;非空值相等后再追加两组交叉孩子。所有待办对都处理完,就说明左右子树每个对应位置的值和结构都匹配。

图解

1 / 9
把根左右子树作为一对入队1O2A2B3C4D4E3Fqueue:队首 → 队尾(A,B)当前比较尚未取出根的左右子树等待比较字母表示节点身份;两个值为 2 的节点 A、B 不是同一对象。
queue
[(A,B)]
节点值
A=2,B=2,C=3,D=4,E=4,F=3
结果
尚未决定
选择步骤 9 步
变量与图例

把队列的每项看成“必须互为镜像的一对”;外侧对外侧、内侧对内侧,空孩子也要配对。

queue
必须互为镜像的节点引用对,队首先处理,包含 (None,None)。
a / b
当前从队列取出的两个镜像位置。
外侧 / 内侧
(a.left,b.right) / (a.right,b.left),两种都必须检查。
A…F
图中相同值节点的独立身份,避免把值相等当作同一节点。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 空根返回 True;否则把根的左右孩子组成一对放入 queue。
  2. 从队首取出 a、b;若至少一侧为空,两个都空则继续,仅一侧为空则返回 False。
  3. 两者都非空时比较值,不同则返回 False。
  4. 按外侧、内侧的顺序追加 (a.left,b.right) 和 (a.right,b.left),保留空位置。
  5. 重复直到队列为空,返回 True。

代码(Python)

查看完整 Python 代码队列:逐对检查 · 与上方图解对应

queue 中每项是必须互为镜像的一对子树根。空节点也要成对参与检查,不能先过滤所有 None 再只比较值。

PYTHON 3
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 为最大层宽量级的待比较节点对数;避开递归上限。
03 / 检查边界

易错点与边界

  • 必须交叉配对子树,同方向比较得到的是“相同”,而非“对称”。
  • 每层值是回文并不足够,省略空位置会把结构不同的树误判为对称。
  • 两个 None 匹配,一个 None 与一个节点不匹配。
  • 空树和单节点树都对称;重复值不能代替结构比较。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

递归:定义两树镜像关系时间 O(n) 最坏时间;发现不匹配时可提前返回。

mirror(a,b) 返回两棵子树是否互为镜像。至少一侧为空时返回 a is b,只有同时为空才成立;两侧非空时,值相等且两组交叉子树都互为镜像才成立。and 按从左到右短路,一旦值或某组子树失败,就不再继续另一组。它用调用栈承接子条件,空间随树高增长,深树可能触发递归限制。

  1. 空根直接返回 True,否则调用 mirror(root.left,root.right)。
  2. mirror 内先处理 None:两个都空返回 True,仅一个空返回 False。
  3. 非空节点先要求 a.val==b.val。
  4. 先递归比较 a.left 与 b.right,再比较 a.right 与 b.left;三个条件以 and 连接并返回。
PYTHON 3
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。成对递归会直接抓到这一结构差异。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 镜像比较走交叉孩子,同方向递归比较的是相同树。
  2. None 不能先过滤:两空匹配,一空一节点立即失败。
  3. 队列存节点对而不是一层纯值,值与结构在同一流程里验证。
← 返回题库
二叉树40 / 100

543. 二叉树的直径 简单

标签:树 · 深度优先搜索 · 二叉树 · 树形 DP

力扣原题 ↗
01 / 理解题目

一句话题意

求二叉树任意两节点间最长简单路径的边数,这条路径不一定经过根。

教学示例

树:1.left=2;2.left=3,2.right=5;3.left=4;5.right=6,其余孩子为空。输出直径 4,路径 4→3→2→5→6 不经过根 1。

生活化理解

把树当成分岔步道网络,想找不回头重复走的最长线路。在一个岔口,完整候选线路可以从左边最远处来到这里,再走到右边最远处;但若要接到父岔口,就只能携带左、右中较长的一条,不能带着一个分叉往上走。

02 / 讲透主解法

后序递归:返回单支,更新双支

一次后序遍历同时得到子树高度和全局直径,能直接解释返回值与答案为什么不同。

核心思路

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 更新,它按边数记录允许在此转折的双支路径。任意最长路径都有一个最高转折点,因此后序检查所有节点能找到不经过根的答案。

图解

1 / 12
进入根 1,best 从 0 开始123456调用栈:外层 → 内层[1]当前 node=1单支返回 / 双支候选left=待返回,right=待返回候选 left+right=待计算返回高度=待返回全局 best=0 条边h 按节点数;left+right 与 best 按边数;返回给父节点只能是一条单支。h=待返回h=待返回h=待返回h=待返回h=待返回h=待返回
node
1
调用栈
[1]
left
待返回
right
待返回
当前候选(边)
待计算
best(边)
0
本次返回高度(节点)
待返回
选择步骤 12 步
变量与图例

逐层看两种不同的数:向父亲返回的单支高度,以及用左右两支更新的全局最长边数。

node / 调用栈
当前节点及尚未返回的祖先,树上紫色表示本帧的计算位置。
left / right
左右子树返回的节点高度。
返回高度 h
1+max(left,right),只带一支向父节点延伸。
left+right
经过当前节点的候选路径边数;不一定是全局最大。
best
所有已完成节点中的最大候选边数,单调不减。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 在方法内初始化 best=0,定义返回节点高度的 height(node)。
  2. 空节点返回 0;非空节点先递归求左高 left,再求右高 right。
  3. 左右结果都就绪后,用 left+right 更新全局 best。
  4. 向父调用返回 1+max(left,right),只携带可以继续延长的单支高度。
  5. 从根调用 height,整树后序处理结束后返回 best。

代码(Python)

查看完整 Python 代码后序递归:返回单支,更新双支 · 与上方图解对应

height 返回可继续向父亲延伸的单支长度,best 记录任意转折点的双支路径。统一使用节点高度,直径通过 l+r 自动换成边数。

PYTHON 3
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 递归深度限制。
03 / 检查边界

易错点与边界

  • 直径数边:单节点直径为 0,不是 1。
  • 答案可能藏在某个子树里,不能只计算根的左高加右高。
  • 向父亲返回单支高度,不能返回左右高度之和。
  • 每个节点重新从头计算左右深度,退化树会重复扫描达到 O(n²)。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

显式后序栈 + 高度表时间 O(n),每节点入栈两次,哈希访问按平均 O(1) 计。

stack 的 (node,expanded) 把首次进入与孩子完成后的返回阶段分开。expanded=False 时先预约 (node,True),再压入右孩子和左孩子,使左、右都在父节点返回阶段之前处理。expanded=True 时从 heights 读取两侧高度,更新 best,并保存当前节点的单支高度。heights 按节点身份建键,None 的高度预设为 0;它避开递归上限,但需要 O(n) 高度表。

  1. 空树返回 0;否则令 stack=[(root,False)]、heights={None:0}、best=0。
  2. 弹出未展开项时,先压回 (node,True),再依次压入非空右、左孩子的未展开项。
  3. 弹出 expanded=True 的项时,左右孩子均已处理,从 heights 读取它们的高度。
  4. 用左右高度之和更新 best,并把 1+max(left,right) 写入 heights[node]。
  5. 栈空后返回 best,不把根高度当作直径。
PYTHON 3
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) 时间,但需要保存路径选择信息。不要为求直径先计算所有节点对距离,那会把局部后序问题变成不必要的平方级搜索。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 空高 0、叶高 1,left+right 恰好换成经过当前节点的边数。
  2. 向上返回单支,更新答案用双支;两种状态不能合并。
  3. best 在所有节点更新,最长路径可以位于严格子树中而不经过根。
← 返回题库
二叉树41 / 100

102. 二叉树的层序遍历 中等

标签:树 · 广度优先搜索 · 二叉树

力扣原题 ↗
01 / 理解题目

一句话题意

把二叉树按深度从浅到深分组,每层按从左到右的顺序返回节点值。

教学示例

root = [3,9,20,null,null,15,7](层序);输出 [[3],[9,20],[15,7]]。

生活化理解

像学校按年级分批进礼堂:队列是等候区,一轮开始时先数清当前年级的人数;他们带来的下一年级学生排到队尾,但不能混进本轮。节点深度对应年级,孩子入队对应下一批报到。

02 / 讲透主解法

队列 BFS:按层截取

队列直接对应层序输出,用固定层宽划清边界,容易从图解复现代码。

核心思路

队列 queue 的一轮开始时只包含当前层,且顺序从左到右。固定 size = len(queue) 后恰好弹出 size 次,期间加入的孩子全部留给下一轮。这个“拍快照”的动作把连续队列切成了层;本轮队列长度虽然会变化,size 不变。

每次把出队节点的值加入本轮的 level,再先加入左孩子、后加入右孩子。当前层父节点本来从左到右有序,孩子也因此按从左到右的顺序进入下一层。完成 size 次处理后,队列恰好只剩下一层,重新满足循环不变量。

answer 只保存已经完整结束的层。每轮结束才把 level 追加到 answer,下一轮创建新的 level 列表;即使处理最后一个叶子后队列已空,这一层也必须先追加,再结束外层循环。使用 deque 的 popleft,出队无需搬移后面的元素。

不变量

每轮开始时 queue 恰好按从左到右保存当前层。固定 size 后只弹出原来的 size 个节点,后来入队的孩子留到下一轮;先左后右入队又保持下一层的顺序。

图解

1 / 11
根节点进入等候队列3920157queue(左端出队)[3]size = 尚未固定本层与已完成结果level = 尚未创建answer = []
queue
[3]
size
尚未固定
level
尚未创建
answer
[]
选择步骤 11 步
变量与图例

观察固定的 size 与不断变化的 queue:新入队的孩子为什么不会混入当前层?

queue
先进先出队列,图中左侧是队首。
size
本轮开始时的队列长度,本轮内不再变化。
node
这次刚从队首弹出的节点。
level
当前层已经弹出的节点值。
answer
已经完整结束的各层结果。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 空树返回 [];否则将根放入 deque,创建空 answer。
  2. 每轮开始固定 size=len(queue),并新建本层的 level=[]。
  3. 恰好出队 size 次:将节点值加入 level,非空孩子按左、右顺序入队。
  4. 将完整 level 追加到 answer;队列非空则开始下一层,最后返回 answer。

代码(Python)

查看完整 Python 代码队列 BFS:按层截取 · 与上方图解对应

size 是本轮边界,deque 的 popleft 为常数时间;用列表 pop(0) 会反复搬移元素。

PYTHON 3
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),无递归栈。
03 / 检查边界

易错点与边界

  • 必须固定本轮 size;动态处理队列直到空会把多层混成一层。
  • 先左后右入队,才能保持每层从左到右。
  • 空树输出 [],不是 [[]];单节点只有一层。
  • 本主解的队列峰值由树的最大层宽决定,不能按树高估算。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

DFS:用深度作为结果下标时间 O(n),每个节点访问一次。

用递归参数 depth 把节点值投递到 answer[depth],而不是用队列分批访问。depth==len(answer) 表示首次到达新深度,需要先创建该层列表;已有层继续追加。按根、左、右访问时,左侧分支在同一深度始终先于右侧分支,因此每层仍从左到右排列。DFS 的实际访问时间顺序与最终按层分组的输出顺序不必一致,适合已经在递归中维护深度的场景。

  1. 创建 answer=[],调用 visit(root,0)。
  2. 遇到空节点直接返回;若 depth 等于 answer 长度,先追加一个空层。
  3. 把 node.val 追加到 answer[depth]。
  4. 以 depth+1 先递归左孩子,再递归右孩子;遍历完成后返回 answer。
PYTHON 3
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)。若问锯齿形层序,可按层奇偶反转输出,孩子入队顺序保持不变。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 层边界来自循环开始时的 size 快照。
  2. 先左后右入队,把父节点顺序传给下一层。
  3. 答案在一整层处理完后追加,单个节点出队时只改 level。
← 返回题库
二叉树42 / 100

108. 将有序数组转换为二叉搜索树 简单

标签:树 · 二叉搜索树 · 数组 · 分治 · 二叉树

力扣原题 ↗
01 / 理解题目

一句话题意

把严格递增数组构造成高度平衡的二叉搜索树,允许有多个正确树形。

教学示例

nums = [-10,-3,0,5,9];本代码选偏左中点,输出树层序为 [0,-10,5,null,-3,null,9]。

生活化理解

像给按号码排好的住户分服务站:中间住户担任总站,左半区和右半区再各选中间人。数组左右顺序保证号码大小关系,反复对半分保证两边工作层数接近。

02 / 讲透主解法

递归分治:只传数组边界

中点划分同时表达搜索顺序与高度平衡,区间递归简洁且无需复制数组。

核心思路

子问题 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 与平衡性质。

图解

1 / 12
从整个数组区间开始nums-100-31025394当前区间[lo, hi) = [0,5)mid = —尚未创建节点调用栈:外 → 内build(0,5)节点坐标固定;边只在递归返回并赋值后出现。
区间
[0,5)
mid
调用栈
build(0,5)
已连接的边
[]
当前状态
尚未创建节点
选择步骤 12 步
变量与图例

同时看数组的半开区间与已连接的树:当前节点在子树返回后才接上孩子。

lo, hi
当前递归负责的半开区间 [lo,hi),hi 不含在内。
mid
偏左中点 (lo+hi-1)//2。
node
本次调用新建的根,子调用返回前不会接到父节点。
调用栈
图中从外到内列出尚未返回的 build 区间。
返回值
完成区间后返回的子树根,空区间返回 None。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 调用 build(0,len(nums)),用 [lo,hi) 表示当前子树负责的范围。
  2. 若 lo>=hi,返回 None;否则计算偏左中点 mid=(lo+hi-1)//2。
  3. 新建 node=TreeNode(nums[mid]),递归构建 [lo,mid),把返回根接到 node.left。
  4. 递归构建 [mid+1,hi),把返回根接到 node.right。
  5. 返回当前 node,最外层得到整棵平衡搜索树的根。

代码(Python)

查看完整 Python 代码递归分治:只传数组边界 · 与上方图解对应

lo、hi 精确描述当前子树拥有的值;不做切片,每个数组位置只创建一个节点。

PYTHON 3
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)。构造过程始终平衡,不会出现链形递归深度。
03 / 检查边界

易错点与边界

  • 平衡指每个节点的高度差至多一,并非所有叶子必须同层。
  • 偶数长度可以选任一中点;测试不应要求固定根。
  • [lo, hi) 的右边界不属于区间,递归边界要一致。
  • 本主解只传 lo、hi 下标;递归调用不创建数组切片。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

显式栈:保存待连接的区间时间 O(n),每个非空区间创建一个节点。

把递归调用改成 tasks 中的区间任务,(lo,hi,parent,side) 说明该区间构成的子树要接到哪个父节点的哪一侧。选中点的规则与主解一致,BST 与平衡性的依据也不变。区别在于创建节点后立即用 setattr(parent,side,node) 连接,再把孩子的构建任务压栈;显式记录 parent 和 side 代替递归返回时的赋值,便于观察待办任务。

  1. 空数组返回 None;否则用整个数组的偏左中点创建 root。
  2. 向 tasks 依次压入根的右区间任务和左区间任务,使左任务先出栈。
  3. 弹出 (lo,hi,parent,side);空区间跳过,保留默认的空孩子指针。
  4. 非空区间取偏左中点创建 node,并用 setattr 将它接到 parent 的 side 一侧。
  5. 依次压入 node 的右、左区间任务;任务处理完后返回 root。
PYTHON 3
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),本实现避免了这笔成本。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 明确半开区间,空区间由 lo>=hi 统一结束。
  2. 选中点兼顾搜索顺序与两侧高度,不需要插入后再旋转。
  3. 父节点接收的是递归返回的子树根,而不是尚未建完的局部节点。
← 返回题库
二叉树43 / 100

98. 验证二叉搜索树 中等

标签:树 · 深度优先搜索 · 二叉搜索树 · 二叉树

力扣原题 ↗
01 / 理解题目

一句话题意

判断整棵树是否满足严格二叉搜索树关系:任一节点的整个左子树更小、整个右子树更大。

教学示例

root = [5,1,8,null,null,4,9](层序);输出 false。4 虽小于父节点 8,却位于根 5 的右子树。

生活化理解

像层层转发的价格审批区间:总部说价格必须大于 5,分部再说必须小于 7,最终价格就只能落在 (5,7)。不能到了分部就忘记总部的限制;节点 4 虽小于父亲 7,却违反祖先 5 的要求。

02 / 讲透主解法

上下界递归

把祖先约束显式传给后代,能直接解释只比较父子为什么不足以验证 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)。进入左子树只收紧上界,进入右子树只收紧下界,因此更早祖先的约束会一路保留;任一节点不在区间内即可否定整棵树。

图解

1 / 9
根节点从无穷界限开始5(-∞, +∞)1849当前检查node = 5区间 (-∞,+∞)结果 = 待检查调用栈:外 → 内5 (-∞,+∞)9 的坐标始终保留;左分支失败后它不会被访问。
node
5
祖先开区间
(-∞,+∞)
调用栈
5 (-∞,+∞)
返回值
待检查
选择步骤 9 步
变量与图例

追踪从祖先继承的开区间:走到 4 时,下界仍必须是根节点的 5。

low, high
所有祖先共同传下来的严格上下界,不只是父节点值。
node
当前 check 的节点,None 是空子树。
调用栈
还在等待左右检查结果的祖先。
返回值
当前子树是否满足全部祖先界限。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 调用 check(root,-∞,+∞),将所有祖先约束表示为开区间。
  2. 空节点返回 True;非空节点若不满足 low<node.val<high,立即返回 False。
  3. 递归检查左子树,传入 (low,node.val)。
  4. 左侧通过后,再检查右子树,传入 (node.val,high);两侧都通过才返回 True。

代码(Python)

查看完整 Python 代码上下界递归 · 与上方图解对应

low、high 携带所有祖先留下的限制,使用无穷界避免把合法极值错误当作越界。

PYTHON 3
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 可能触发递归深度限制。
03 / 检查边界

易错点与边界

  • 不能只检验 node.left.val < node.val < node.right.val,祖先限制同样有效。
  • 不要用题目允许的最小整数充当排他的初始下界。
  • 节点值为负数不影响规则;树不平衡也可能是合法 BST。
  • 本主解要求 low < node.val < high;等于任一边界也不合法,因此重复值会失败。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

迭代中序:检测严格递增时间 O(n) 最坏时间,每个节点入栈、出栈各一次。

采用等价判据:BST 的中序序列必须严格递增。按左、根、右访问时,previous 保存上一个正式访问的值;只需检查相邻值,不需要把整段序列存下来。若全部相邻值递增,那么根之前的左子树值全部更小、之后的右子树值全部更大。stack 保存等待访问的祖先,previous=None 区分尚未访问任何值的初始状态;显式栈避免递归深度限制。

  1. 初始化 stack=[]、node=root、previous=None。
  2. 沿左孩子不断压栈,直到 node 为空。
  3. 弹出栈顶正式访问;若已有 previous 且 node.val<=previous,返回 False。
  4. 更新 previous=node.val,再转向当前节点的右子树,重复左行与出栈。
  5. 节点和栈都为空时,中序比较全部通过,返回 True。
PYTHON 3
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 中序:临时借用空指针形成回线。但检测到违规后不能立刻返回而留下回线,需要完成结构恢复。通常显式栈更容易保证实现清晰。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 验证 BST 要检查整棵子树相对于祖先的关系。
  2. 上下界使用严格不等式,重复值同样会失败。
  3. and 的短路会在左侧失败后跳过右侧检查;未访问不代表已通过。
← 返回题库
二叉树44 / 100

230. 二叉搜索树中第 K 小的元素 中等

标签:树 · 深度优先搜索 · 二叉搜索树 · 二叉树

力扣原题 ↗
01 / 理解题目

一句话题意

在合法二叉搜索树中返回第 k 小的节点值,k 从 1 开始且保证有效。

教学示例

root = [5,3,7,2,4,null,8](层序),k = 4;输出 5。中序顺序为 [2,3,4,5,7,8]。

生活化理解

把 BST 看成有序书库的分区索引:左馆的书号都比当前书小,右馆都更大。中序像按书号依次发号,数到第 k 位即可停,不必先把全馆的书都搬出来排序。

02 / 讲透主解法

迭代中序:计数并提前返回

借助 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 时当前值就是答案。

图解

1 / 9
初始化待访问的根537248迭代变量stack = []node = 5k = 4访问记录与输出已访问 = []返回值 = —栈:左为底、右为顶;绿色节点表示已弹出访问。
stack
[]
node
5
k
4
已访问
[]
返回值
选择步骤 9 步
变量与图例

观察“压栈”和“计数”发生在不同时间:沿左链暂存祖先,弹出节点时才消耗一个名次。

stack
等待中序访问的节点,图中左为栈底、右为栈顶。
node
内层循环待压栈的节点;弹出时也用它保存当前访问节点。
k
还需访问多少个节点,只有弹出访问时减 1。
已访问
教学记录,记录实际弹出顺序;原代码不保存此列表。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 初始化空 stack,将 node 指向 root,k 保留为待消耗的目标名次。
  2. 沿 node 的左孩子不断压栈,直到左链走空。
  3. 弹出栈顶正式访问,并令 k 减一。
  4. k 为零立即返回 node.val;否则转向 node.right,再重复左行与出栈。

代码(Python)

查看完整 Python 代码迭代中序:计数并提前返回 · 与上方图解对应

保存待访问祖先,真正访问时消耗一个名额;避免构建 n 个元素的结果表。

PYTHON 3
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)。
03 / 检查边界

易错点与边界

  • 节点入栈不等于已访问,计数必须发生在出栈时。
  • O(k) 忽略了到达首个节点的树高成本,应写 O(h+k)。
  • 题目保证树非空且 1≤k≤n,无需增加无效 k 的业务分支。
  • 本主解把 k 当作从 1 开始的剩余名次,出栈减到 0 就返回,不建立结果数组。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

递归中序:完整有序列表时间 O(n),无论 k 多小都遍历整棵树;最后索引 O(1)。

用递归按左、根、右访问整棵 BST,把值依次追加到 values,得到完整升序列表,然后返回 values[k-1]。这里 k 始终是原始的一基名次,不在递归中递减,也不提前终止;列表下标从 0 开始,因此要减一。写法直观,适合同时需要整份有序结果的场景,但单次查询也会遍历并保存全部 n 个值。

  1. 创建 values=[],定义访问函数 visit。
  2. 空节点直接返回;非空节点先递归左子树。
  3. 把当前 node.val 追加到 values,再递归右子树。
  4. 从根完成整棵树的遍历后,返回 values[k-1]。
PYTHON 3
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)。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 压左链只是记录返回路径,中序访问发生在弹栈时。
  2. BST 性质把访问次序转化成从小到大的排名。
  3. k 归零立即返回,示例中的右子树 7、8 无需遍历。
← 返回题库
二叉树45 / 100

199. 二叉树的右视图 中等

标签:树 · 深度优先搜索 · 广度优先搜索 · 二叉树

力扣原题 ↗
01 / 理解题目

一句话题意

返回从右侧观察二叉树时每层最靠右的节点值,按深度由浅到深排列。

教学示例

root = [1,2,3,4,null,null,null,5](层序);输出 [1,3,4,5]。节点 3 没有孩子,深层由左子树补入。

生活化理解

像给看台每一排拍右侧剪影:同一排只露出最右边的人,下一排可以由左侧看台补位。树的右视图按“每层”决定,不能只跟着根的右孩子走到底。

02 / 讲透主解法

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 指针行走。

图解

1 / 10
从根节点开始12345当前层队列queue = [1]size = 尚未固定i = —右视图已确认answer = []4、5 来自左子树,但在各自深度没有更右的节点。
queue
[1]
size
尚未固定
i
answer
[]
选择步骤 10 步
变量与图例

每层只在 i==size-1 时记录;观察浅层的右子树结束后,更深层的左侧节点仍然可见。

queue
当前等待处理的节点,左端为队首。
size
本轮固定的层宽。
i
本层弹出序号,从 0 开始。
answer
每层已经确定的最右节点值。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 空树返回 [];否则将根放入 queue,创建 answer=[]。
  2. 每轮固定 size=len(queue),按 i=0 到 size-1 处理当前层。
  3. 出队一个节点;仅当 i==size-1 时,将它的值写入 answer。
  4. 把该节点的非空孩子按左、右顺序入队,让下一层保持从左到右。
  5. 队列清空后返回 answer。

代码(Python)

查看完整 Python 代码BFS:每层最后一个节点 · 与上方图解对应

仍按从左到右处理整层,只在 i=size-1 时记录;层内最后位置与全树右链是不同概念。

PYTHON 3
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 为层数,无递归栈。
03 / 检查边界

易错点与边界

  • 右视图不等于右孩子链,右侧较矮时要显示左子树更深处。
  • BFS 记录最后一个值时必须使用本层固定 size。
  • 空树没有可见节点;单链每个节点都可见,不论链接向左还是向右。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

右优先 DFS:每层第一次到达时间 O(n),仍须访问左子树,因为其更深层可能可见。

改用根、右、左的访问顺序,同一深度的右侧分支会先被访问。depth==len(answer) 表示首次到达尚未记录的新层,此时当前节点必是这一层最右的节点,记录一次即可;之后到达相同深度不再覆盖。右分支访问结束后仍要递归左分支,因此右侧较浅时,左子树可以补上更深层的可见节点。它不维护队列或 size,而用递归深度识别层。

  1. 创建 answer=[],从 visit(root,0) 开始递归。
  2. 遇到空节点直接返回。
  3. 若 depth==len(answer),首次进入该层,将 node.val 追加到 answer。
  4. 以 depth+1 先访问右孩子,再访问左孩子;已有层的值保持不变。
  5. 根调用结束后返回 answer。
PYTHON 3
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 记录每层首尾即可,无需运行两次完整遍历。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 右视图按深度选最右节点,不要求沿右孩子形成一条链。
  2. 固定层宽后用 i==size-1 精确识别最后一个节点。
  3. 先左后右入队是“最后弹出即最右”的前提。
← 返回题库
二叉树46 / 100

114. 二叉树展开为链表 中等

标签:栈 · 树 · 深度优先搜索 · 链表 · 二叉树

力扣原题 ↗
01 / 理解题目

一句话题意

原地把二叉树改为前序顺序的右指针链,所有 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。

生活化理解

像把一本嵌套文件夹目录摊成连续走廊:先经过当前文件夹,再走完整个左区,最后进入原来的右区。把原右区的入口接到左区的最右空出口,就能先插入左区而不丢掉后续目录。

02 / 讲透主解法

原地接线:寻找左子树右边界

用局部接线保留前序顺序,只移动原节点引用,满足常数辅助空间的面试追问。

核心思路

对当前节点 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,后续继续展开即可保持“根、左子树、右子树”的前序顺序;操作复用所有原节点。

图解

1 / 12
从原节点 A 开始LRLRRA·1curB·2C·5D·3E·4F·6实际指针(A·1 表示身份 A、值 1)A: L=B R=CB: L=D R=EC: L=∅ R=FD: L=∅ R=∅E: L=∅ R=∅F: L=∅ R=∅当前游标cur = Apredecessor = 尚未赋值节点位置与身份固定;L/R 是 left/right,∅ 表示 None。
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链
尚未展开完成
选择步骤 12 步
变量与图例

盯住节点身份和 L/R 指针:先保存原右子树的接入口,再把左子树移到 right,最后清空 left。

cur
正在处理的节点,处理后沿 cur.right 前進。
predecessor
从 cur.left 出发,沿 right 找到的右边界节点;并非一定是左子树前序最后一个节点。
A…F
原节点身份,整个演示不创建新节点、不移动图中坐标。
L / R
节点实际的 left/right 指针,图中允许展示赋值间的临时别名。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 令 cur=root,沿最终展开方向逐个处理节点。
  2. 若 cur.left 非空,从 cur.left 出发沿 right 找到 predecessor,使其 right 为空。
  3. 先执行 predecessor.right=cur.right,保留原右子树入口。
  4. 再执行 cur.right=cur.left、cur.left=None,完成当前节点的前序接线。
  5. 无论是否接线,都令 cur=cur.right;到 None 时结束,结果保存在原树中。

代码(Python)

查看完整 Python 代码原地接线:寻找左子树右边界 · 与上方图解对应

predecessor 为原右子树找到临时接入口,cur 每轮向右推进;只使用几个节点引用,满足 O(1) 辅助空间追问。

PYTHON 3
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 个已有节点,无递归栈。
03 / 检查边界

易错点与边界

  • 要求修改原节点,复制值新建链表即使顺序正确也不满足原地语义。
  • 覆盖 cur.right 之前先接好原右子树,否则会失去其入口。
  • 每个 left 都必须清空;仅观察右链值不能完整验证结果。
  • 值可以重复,测试必须比较对象身份 is,不能仅比 val。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

显式栈前序:按访问顺序连接时间 O(n),每个节点入栈、出栈一次。

stack 明确保存待访问节点,prev 始终指向已经展开的链尾。每次出栈得到下一个前序节点 node,先保存它的原孩子,再将 prev.right 接到 node、prev.left 设空;右孩子先压、左孩子后压,使后进先出的栈接下来先处理左侧。这种写法直接把前序访问序列变成链,容易理解,但需要显式栈,不具备主解的常数辅助空间。所有连接仍使用原节点对象。

  1. 空树直接结束;否则初始化 stack=[root]、prev=None。
  2. 弹出 node,先将其原右孩子入栈,再将原左孩子入栈。
  3. 若 prev 非空,将 prev.left 设为 None、prev.right 设为 node,把当前节点接到链尾。
  4. 令 prev=node,继续出栈,直到所有原节点都按前序访问。
  5. 将最终 prev 的 left、right 都设为 None,完成右链末尾。
PYTHON 3
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) 辅助空间。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 接线顺序是:右边界接旧右子树、right 接左子树、left 清空。
  2. 值序列相同还不够,原地题要保留原节点身份与连接。
  3. 沿新 right 链继续处理,每个尚未展开的左子树都会被纳入前序顺序。
← 返回题库
二叉树47 / 100

105. 从前序与中序遍历序列构造二叉树 中等

标签:树 · 数组 · 哈希表 · 分治 · 二叉树

力扣原题 ↗
01 / 理解题目

一句话题意

给定同一棵树的前序与中序序列,节点值互不重复,重建原二叉树。

教学示例

preorder = [3,9,20,15,7],inorder = [9,3,15,20,7];输出树层序 [3,9,20,null,null,15,7]。

生活化理解

像用两份座位资料复原家族合影:前序名单先报家长,再报左支和右支;中序座位表把家长夹在左支与右支中间。先从报到名单拿到家长,再在座位表切开两支,反复做就能恢复关系。

02 / 讲透主解法

哈希表 + 中序区间递归

前序选根、中序划范围的职责清楚,位置表与区间下标让重建保持线性工作量。

核心思路

前序的第一个未消费值就是当前子树的根。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 指向该子树的前序根。消耗根后按左、右顺序递归,与前序顺序一致;用根在中序中的位置分开两侧,空区间不消耗前序元素。

图解

1 / 12
建立中序索引表并进入完整区间preorder309120215374next_preinorder903115220374构建状态next_pre = 0[lo,hi) = [0,5)mid = —尚未创建根调用栈:外 → 内build(0,5)节点坐标固定;边只在递归返回并赋值后出现。
next_pre
0
中序区间
[0,5)
mid
调用栈
build(0,5)
当前状态
尚未创建根
选择步骤 12 步
变量与图例

前序游标只向前移动;中序区间决定当前根的左右子树究竟包含哪些节点。

next_pre
下一个尚未使用的前序索引;非空调用创建根时才加 1。
lo, hi
当前子树的中序半开区间 [lo,hi)。
positions / mid
中序索引表;mid 是当前根在中序中的位置。
调用栈
按外层到内层显示尚未完成的 build 区间。
连接边
只在子调用返回、node.left/right 赋值完成后显示。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 建立中序值到下标的 positions 表,令 next_pre=0,从完整中序区间开始 build。
  2. 若 lo>=hi,直接返回 None,不读取或推进前序游标。
  3. 读取 preorder[next_pre] 并推进游标,在 positions 中查到 mid,创建当前根 node。
  4. 先构建 [lo,mid) 并接到 node.left,再构建 [mid+1,hi) 并接到 node.right。
  5. 返回 node;最外层返回完整树根,此时前序元素恰好全部被消费。

代码(Python)

查看完整 Python 代码哈希表 + 中序区间递归 · 与上方图解对应

positions 用于 O(1) 定位,next_pre 顺序消费前序;只传边界,不创建子数组。

PYTHON 3
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 递归深度限制。
03 / 检查边界

易错点与边界

  • 仅凭前序不能唯一恢复普通二叉树,中序的根位置提供左右划分。
  • 共享前序游标时必须先左后右构造。
  • 节点值唯一是本题条件,重复值会使 value→index 失去唯一性。
  • 输出树可以高度退化;序列合法并不意味着递归深度安全。
  • 本主解的线性工作量依赖 positions 直接定位和仅传区间下标,二者都属于这份代码的复杂度前提。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

迭代栈:用中序识别左侧完成时间 O(n),每个节点入栈、出栈至多一次,无线性 index 和数组切片。

按前序逐个创建节点,用 stack 保存尚未完成中序确认的节点,i 指向下一个待确认的中序值。栈顶值不等于 inorder[i],说明它在中序出现前仍有左侧节点,当前新节点接为它的左孩子;相等时则连续弹出已完成左侧的节点并推进 i,新节点接到最后弹出者的右侧。连续弹栈相当于递归从已完成的左侧向祖先返回;它不需要 positions 表,但要理解前序创建顺序和中序完成信号之间的关系。

  1. 空前序返回 None;否则用 preorder[0] 创建 root,初始化 stack=[root]、i=0。
  2. 从前序下标 1 起逐个创建 child,先读取当前栈顶 node。
  3. 若 node.val!=inorder[i],把 child 接为 node.left。
  4. 否则,只要栈顶等于 inorder[i] 就弹栈并推进 i;把 child 接为最后弹出节点的右孩子。
  5. 将 child 压入栈,继续处理前序剩余值;创建完成后返回 root。
PYTHON 3
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 与切片版不具有同样保证。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 前序找根,中序定子树范围;二者职责不能互换。
  2. 递归必须先左后右,才能配合单向移动的前序游标。
  3. 半开空区间直接返回且不消耗前序值,避免索引错位。
← 返回题库
二叉树48 / 100

437. 路径总和 III 中等

标签:树 · 深度优先搜索 · 二叉树

力扣原题 ↗
01 / 理解题目

一句话题意

统计树中和为 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]。

生活化理解

像沿山路记累计海拔变化:当前累计读数减去某个祖先之前的读数,就是中间一段路的变化量。前缀频率表是“当前这条上山路线的路标册”,下山退回分叉口时必须撤销刚经过的路标,不能拿左路的路标去量右路。

02 / 讲透主解法

前缀和频率 + 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 时,就得到一条以当前节点结尾的向下路径;退出时减回当前前缀频率,使兄弟分支重新只看到共同祖先。

图解

1 / 11
初始化虚拟起点A·1B·-1E·0C·1D·0frequency(实际字典){0:1}尚未进入任何节点调用栈(外 → 内)targetSum = 1;零频次键保留在字典里,但不贡献匹配数。
frequency
{0:1}
当前prefix
入口参数 0
当前局部count
尚未创建
调用栈
[]
当前变化
尚未进入任何节点
选择步骤 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["退出根:恢复空前缀状态"]

算法步骤

  1. 初始化 frequency={0:1},从 visit(root,0) 开始;空节点返回计数 0。
  2. 将当前节点值加入 prefix,先令 count=frequency.get(prefix-targetSum,0)。
  3. 把当前 prefix 的频率加一,使后代能使用这个起点边界。
  4. 依次递归左右孩子,将各自返回的路径数量累加到当前局部 count。
  5. 退出当前节点前将 frequency[prefix] 减一,然后返回 count;根调用结果即总路径数。

代码(Python)

查看完整 Python 代码前缀和频率 + DFS 回溯 · 与上方图解对应

prefix 只沿当前根到节点路径更新;先查再加,返回前减一。保留零频率键的版本仍是线性空间,不虚报 O(h)。

PYTHON 3
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 递归深度限制。
03 / 检查边界

易错点与边界

  • 路径只能沿父到子,不能把左子树和右子树连成一条路径。
  • 前缀表必须在离开节点时减回去,否则不同分支会互相污染。
  • frequency 存次数;全零树会在同一前缀值上累积多个合法起点。
  • 先查询再登记当前前缀,防止 target=0 时计入空路径。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

枚举每个起点:向下累计路径和时间 O(nh),h 为树高;平衡树 O(n log n),链形树最坏 O(n²),因为每个起点会重复访问后代。

使用两层递归分开选择起点与向下延伸。all_starts(node) 枚举当前子树内的所有起点;starts_here(node,remain) 则固定一个起点,只沿后代向下走,remain 表示距离目标和还差多少。减去当前值后 remain==0 就得到一条路径,但命中后仍要继续,因为零值或负值可能让更长路径再次命中,也不能按 remain 的正负剪枝。它直接贴合路径定义,代价是不同起点会重复访问同一后代,链形树最坏为 O(n²)。

  1. 定义 starts_here:空节点返回 0;非空节点先令 remain-=node.val。
  2. 把 int(remain==0) 与左右孩子的 starts_here 返回值相加,得到这个固定起点的路径数。
  3. 定义 all_starts:空节点返回 0,否则先计算 starts_here(node,targetSum)。
  4. 继续累加 all_starts(node.left) 和 all_starts(node.right),枚举其余起点。
  5. 返回 all_starts(root),每条向下路径恰由它自己的起点和终点确定。
PYTHON 3
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)。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 相同前缀可能重复,必须保存频率而不只是集合。
  2. 先查询再登记当前前缀,避免 target=0 时把空路径计入。
  3. count 是每个调用的局部累计;frequency 共享且必须在退出时撤销。
← 返回题库
二叉树49 / 100

236. 二叉树的最近公共祖先 中等

标签:树 · 深度优先搜索 · 二叉树 · 最近公共祖先 · Binary Lifting

力扣原题 ↗
01 / 理解题目

一句话题意

在普通二叉树中找到同时包含指定节点 p、q 的最深祖先,节点可以是自己的祖先。

教学示例

root = [3,5,1,6,2,0,8](层序);p 是节点 D=6,q 是节点 E=2(按节点身份)。输出节点 B=5。

生活化理解

像两名员工向上寻找最近共同主管:部门只需向上汇报“这里找到了哪位员工或共同主管”。若两个不同子部门各找到一位,当前主管就是汇合点;若两人都在同一部门,更高主管直接转交那份结论。

02 / 讲透主解法

后序递归:合并左右线索

用子树返回的节点线索判断最低汇合点,无需额外建立父指针结构。

核心思路

search(node) 返回一份节点线索:None 表示这棵子树没有找到目标;非空结果可能是一个目标,也可能是这棵子树内已经确定的最近公共祖先。遇到 node is pnode 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 都存在于树中。

图解

1 / 10
从根 A 开始寻找两个目标3A5B1C6D = p2E = q0F8G当前调用的返回线索node = A=3left = 待计算right = 待计算返回 = —调用栈:外 → 内AD、E 是节点对象;B 为它们的 LCA,向上返回时身份保持不变。
node
A=3
left
待计算
right
待计算
返回值
调用栈
A
选择步骤 10 步
变量与图例

先看左右子调用各返回哪个节点,再判断是在此处分叉,还是把已找到的线索原样向上传。

p, q
目标节点对象 D、E;判断使用 is 而不是只比较值。
left, right
当前节点的左右递归返回值,可为 None、目标节点或已找到的 LCA。
调用栈
从外到内显示尚未完成 search 的节点。
返回值
向父节点上交的节点身份,不是布尔值。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 从 search(root) 开始;空节点或命中 p、q 时,直接返回当前节点对象。
  2. 对其他节点先递归左子树得到 left,再递归右子树得到 right。
  3. 若 left、right 都非空,返回当前 node,表示两条目标线索在此最低汇合。
  4. 否则返回非空的一侧;两侧都为空时返回 None。
  5. 最外层的返回对象就是最近公共祖先。

代码(Python)

查看完整 Python 代码后序递归:合并左右线索 · 与上方图解对应

将 None、目标对象和已确认的祖先统一作为返回信息;两侧非空才在当前节点形成新的汇合。

PYTHON 3
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 递归深度限制。
03 / 检查边界

易错点与边界

  • 这不是 BST 题,不能用值大小决定向左还是向右搜索。
  • 祖先包含自身,p 的后代中有 q 时答案就是 p。
  • 返回的是原节点引用,值相等不是通用的对象相同判据。
  • 递归提前返回依赖两目标都存在;缺失目标的拓展需要额外信息。
  • 两侧线索都存在才返回当前节点,单侧已有 LCA 必须继续上传。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

父指针表 + 祖先集合时间 平均 O(n),父表构建最坏访问整棵树,再上行 O(h);哈希查找平均常数时间。

先用显式遍历记录 parent[child]=node,直到 p、q 都已登记,再把 p 到根的所有节点对象放入 ancestors。从 q 沿 parent 向上,第一个属于 ancestors 的节点就是最低的公共祖先:更靠近 q 的位置均不是 p 的祖先。这里不通过后序返回值合并结果,而把两条向根的路径转成集合相交问题;所用 TreeNode 保留默认对象哈希与身份相等语义,代价是额外保存父表与祖先集合。

  1. 初始化 parent={root:None}、stack=[root],明确根没有父节点。
  2. 在 p 或 q 尚未登记时弹出一个节点,为其非空左右孩子记录父节点并入栈。
  3. 从 p 沿 parent 上行到 None,把沿途节点对象加入 ancestors。
  4. 从 q 开始沿 parent 上行,直到当前节点属于 ancestors。
  5. 返回这个首次命中的原节点对象。
PYTHON 3
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),单次查询通常没必要引入该结构。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 返回值携带节点线索,不能只用是否找到的布尔值替代。
  2. 两侧非空才在当前节点汇合;一侧非空就原样上传。
  3. 命中 p 或 q 会直接返回该对象,涵盖一个目标本身就是祖先的情况。
← 返回题库
二叉树50 / 100

124. 二叉树中的最大路径和 困难

标签:树 · 深度优先搜索 · 动态规划 · 二叉树 · 树形 DP

力扣原题 ↗
01 / 理解题目

一句话题意

求非空二叉树中任意非空简单路径的最大节点值和,路径可在某节点连接左右两支。

教学示例

root = [-10,9,20,-6,null,15,7](层序);输出 42,最佳路径为 15→20→7。gain(-10)=25 只是内部单支返回值,不是最终答案。

生活化理解

像规划一条不分叉的登山游线:站在某个山口,可以把左坡和右坡接成完整游线作为候选;但若还要把路线接给更高山口,只能带走一条坡道。带着两条再接父亲会形成三叉路,已经不是一条路径。

02 / 讲透主解法

后序递归:单支返回、双支更新

明确区分向父节点延伸的单支收益和完整路径候选,是树形路径最值的常用模板。

核心思路

定义 gain(node) 为必须从 node 出发、向下只走一支的最大和。空节点返回 0。先递归计算左右孩子的收益,再分别与 0 取最大,得到代码中的 leftright;负收益分支不选。向父节点只能返回 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;每条简单路径都有唯一最高点,因此所有候选覆盖最优路径。

图解

1 / 12
以负无穷初始化全局答案-109-620157当前合并node = -10left = 待计算;right = 待计算候选 = —单支返回 = —全局答案best = -∞调用栈:外 → 内gain(-10)箭头旁“↑”记录实际 gain 返回值;父节点再用 max(0, gain) 截断负支。
node
-10
left
待计算
right
待计算
双支候选
best
-∞
单支返回
调用栈
gain(-10)
选择步骤 12 步
变量与图例

分别看两条数值:双支候选更新全局 best,单支收益返回给父节点;负收益在父节点处截成 0。

gain(node)
向父节点返回的单支路径和,可以为负;父节点再决定是否舍弃。
left, right
当前节点收到左右 gain 后分别与 0 取最大,可不选负贡献。
双支候选
node.val+left+right,可同时连接两侧,仅用于更新 best。
best
所有已处理最高点的最大完整路径和,初始化为 -∞。
调用栈
还在等待孩子收益或准备合并的 gain 调用,从外到内。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 将 best 初始化为负无穷,调用 gain(root);空节点的 gain 返回 0。
  2. 先递归左孩子再递归右孩子,各自与 0 取最大,得到可选的 left、right 收益。
  3. 用 node.val+left+right 更新 best,比较以当前节点为最高点的完整路径。
  4. 向父调用只返回 node.val+max(left,right),保留单支延伸条件。
  5. 根调用完成后返回全局 best,不返回 gain(root) 的单支值。

代码(Python)

查看完整 Python 代码后序递归:单支返回、双支更新 · 与上方图解对应

gain 的返回值和 best 的候选表达式含义不同:一个供父节点延伸,一个代表已封闭的完整路径。

PYTHON 3
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 可能超出递归深度限制。
03 / 检查边界

易错点与边界

  • 向父节点只能传一支;传 node.val+L+R 会在父层制造三叉结构。
  • best 初始化为 0 会让全负树错误返回空路径。
  • 最佳路径可以只含一个节点,也可以完全位于某个子树内。
  • 不能强制从根出发,也不能强制路径两端都是叶子。
  • 本题树非空;负支可以不选,但当前候选始终必须包含当前节点。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 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 递归深度。

  1. 初始化 best=-∞、gains={}、stack=[(root,False)]。
  2. 弹出首次事件时,先压回 (node,True),再依次压入非空右孩子、左孩子的首次事件。
  3. 弹出 ready=True 事件时,从 gains 取得左右孩子的收益,空孩子按 0 处理,并分别截断负贡献。
  4. 用 node.val+left+right 更新 best,再把 node.val+max(left,right) 存入 gains[node]。
  5. 所有事件处理完成后返回 best。
PYTHON 3
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) 总时间。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 父节点接收单支收益,最高点用双支和更新答案,两个值不能混用。
  2. 负收益可由父节点舍弃,但候选始终包含当前节点,best 不能从 0 开始。
  3. 最佳路径不必经过根;最后返回 best,而不是 gain(root)。
← 返回题库
图论51 / 100

200. 岛屿数量 中等

标签:深度优先搜索 · 广度优先搜索 · 并查集 · 数组 · 矩阵

力扣原题 ↗
01 / 理解题目

一句话题意

统计字符网格中由上下左右相连的陆地组成的岛屿数量。

教学示例

grid = ["1100", "1001", "0011"] → 2

生活化理解

给散落的村庄划行政区:陆地格是村庄,四邻通路是道路。每发现一个尚未登记的村庄,就把能走到的整片村庄登记为同一个区,区数只增加一次。

02 / 讲透主解法

队列 BFS

把每次发现新岛和整岛的队列遍历分开,入队即标记的规则容易说明,也不会受递归深度限制。

核心思路

外层逐格扫描负责发现新连通块,内层 BFS 负责把这一块走完。ans 只在扫描遇到仍为 '1' 的格子时增加,然后把起点改成 '0',放入当前岛的队列 q。从它出发只能走上、下、左、右四个方向;对角相接不算连通。

q 保存已经标记、尚待扩展的陆地坐标。每次用 popleft() 取出最早发现的格子,检查四邻;邻居在范围内且仍为 '1' 时,先改成 '0',再加入队尾。入队即标记能避免多条道路汇入同格时重复入队;这些新邻居属于当前岛,不能再增加 ans。

队列清空时,该起点能到达的所有陆地都已标记,外层扫描再遇到 '1' 才会发现另一座岛。因此每座岛恰好计数一次。这里只统计连通块,不求距离,无需固定层大小。本实现原地改变 grid;如果调用方需要保留地图,可以对副本运行,复制需额外 O(mn) 空间。

不变量

q 中每个坐标都已标记,但尚未扩展;同一格最多入队一次。每次 ans 增加后,BFS 会标记整个连通块,因此下一次增加必然属于另一座岛。

图解

1 / 10
从完整地图开始c=0c=1c=2c=3r=01100r=11001r=20011当前岛队列尚未创建[]此刻的状态ans = 0外层扫描 = (0, 0)扩展格 = None已访问的陆地:1 改成 0四邻连通;标记格仍留在原坐标,原水域没有「已访」标签。
ans
0
外层 (r,c)
(0, 0)
最近出队 (x,y)
尚未弹出
q
尚未创建
grid
[["1", "1", "0", "0"], ["1", "0", "0", "1"], ["0", "0", "1", "1"]]
选择步骤 10 步
变量与图例

观察陆地在入队时从 1 变成 0,以及队列清空后外层扫描才发现下一座岛。

grid
原地地图:原陆地入队时由 1 改成 0。
ans
外层扫描发现的新连通块数量。
q
当前岛已标记、待扩展坐标,左端先出。
(r,c)
外层扫描位置;内层 BFS 期间不移动。
(x,y)
本次从 q 弹出的扩展坐标。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 初始化 ans=0,按行扫描 grid;不是 '1' 的格子直接跳过。
  2. 发现未访问陆地时,令 ans 加一,把起点改成 '0',建立 q=deque([(r,c)])。
  3. 只要 q 非空,就用 popleft 取出 (x,y),检查上、下、左、右四邻。
  4. 对范围内仍为 '1' 的邻居,先改成 '0',再追加到 q,继续遍历当前岛。
  5. 当前队列清空后恢复外层扫描,全部格子检查完成后返回 ans。

代码(Python)

查看完整 Python 代码队列 BFS · 与上方图解对应

q 保存从起点逐层发现的陆地;此题不求距离,无需显式分层,连通块计数与 DFS 相同。

PYTHON 3
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。
03 / 检查边界

易错点与边界

  • 格值是字符串 "1",不是整数 1。
  • 只沿上下左右扩展,不含对角线。
  • 只在启动一次新遍历时加岛数,不能每发现陆地就加。
  • 代码会把已访问陆地改成水,重复调用需重新构造输入。
  • 陆地加入队列前就要改成 "0",否则多条道路可能让同一格重复入队。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

显式栈 DFS时间 O(mn),m、n 是行列数,每格检查常数条边。

同样由外层扫描发现新岛,但把待扩展陆地放入 stack,用后进先出的 pop 优先处理最近加入的格子。stack 内坐标也已经标记,因此每格至多入栈一次;遍历顺序不同不会改变四邻连通块的数量。显式栈避免细长岛屿触及 Python 递归深度限制。与 BFS 代码一样,它原地把陆地改成 '0';若改为用 visited 集合保存访问状态,可保留地图,但要额外占用 O(mn) 空间。

  1. 按行扫描 grid,遇到仍为 '1' 的格子时令 ans 加一。
  2. 立即把起点改成 '0',建立 stack=[(r,c)]。
  3. 只要 stack 非空,就 pop 一个坐标,检查四邻未访问陆地。
  4. 邻居入栈前先改成 '0';栈清空表示本岛已遍历完,再继续外层扫描。
  5. 全部扫描结束后返回 ans。
PYTHON 3
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 更直接。如果题目变成逐次新增陆地并询问岛数,并查集才更有优势。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 外层找连通块入口,内层遍历完整连通块。
  2. 标记与入队绑定,避免同一格被多条道路重复发现。
  3. 岛屿标记永久保留;与需要撤销访问状态的路径搜索不同。
← 返回题库
图论52 / 100

994. 腐烂的橘子 中等

标签:广度优先搜索 · 数组 · 矩阵

力扣原题 ↗
01 / 理解题目

一句话题意

每分钟腐烂橘子同时感染四邻新鲜橘子,求全部腐烂的最少分钟数,无法完成返回 -1。

教学示例

grid = [[2,1,1],[1,1,0],[0,1,2]] → 2

生活化理解

多处火源同时向相邻房间蔓延:最初所有火源在第 0 分钟出发,新燃房间只能在下一分钟继续传播。队列的层就是统一的时钟。

02 / 讲透主解法

多源 BFS:固定层大小

所有腐烂源同时入队,再用固定层大小表示一分钟,能直接对应同步传播的题意。

核心思路

把全部初始腐烂格同时放入 qfresh 统计新鲜格。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 时,刚结束的分钟就是最早全部腐烂时间。

图解

1 / 10
两个感染源同时处于第零分钟c=0c=1c=2r=02t=011r=1110r=2012t=0队列 q:左端先出(0,0) → (2,2)此刻的状态minutes = 0;fresh = 5即将固定本层大小 = 2t 是该格首次腐烂分钟绿色已腐烂;紫色正扩展数值 0=空格,1=新鲜,2=腐烂;t 标签是教学记录,不是源码变量。
minutes
0
fresh
5
q
[[0, 0], [2, 2]]
当前扩展
None
本层进度
即将固定本层大小 = 2
grid
[[2, 1, 1], [1, 1, 0], [0, 1, 2]]
选择步骤 10 步
变量与图例

盯住队列的层界线:这一分钟新腐烂的格子入队,但下一分钟才扩展。

q
待传播的腐烂格,左端先出;一轮结束后恰是下一层。
fresh
网格内仍为 1 的格子数。
minutes
已经完整结束的传播轮数。
len(q)
每轮 for 开始时固定的本层大小,非动态增长的循环上限。
t 标签
图上记录的首次腐烂时刻,用于识别层次。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 扫描网格,把所有值为 2 的坐标加入 q,同时计数值为 1 的格子得到 fresh,令 minutes=0。
  2. 在 q 非空且 fresh>0 时,以本轮开始的 len(q) 固定要处理的格子数量。
  3. 逐个弹出本层坐标,检查四邻;新鲜邻居立即改成 2、fresh 减一,并加入队尾。
  4. 完整处理固定的这一层后令 minutes 加一,新入队的格子留待下一轮。
  5. 循环结束后,fresh==0 返回 minutes,否则返回 −1。

代码(Python)

查看完整 Python 代码多源 BFS:固定层大小 · 与上方图解对应

minutes 表示已完成的扩散轮数;while q and fresh 防止全部完成后多记一分钟。

PYTHON 3
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) 队列,原地修改网格。
03 / 检查边界

易错点与边界

  • 所有初始腐烂橘子必须同时入队。
  • 空格阻断传播,不能穿过。
  • 入队时减少 fresh,不能重复减。
  • 无新鲜橘子返回 0;有新鲜橘子但无初始源返回 -1。
  • 每轮固定本层 len(q),整层结束才增加 minutes;fresh 归零后无需为清空剩余队列再多计一分钟。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

多源 BFS:携带时间戳时间 O(mn),哈希操作按均摊 O(1) 计。

把首次到达时间直接放入队列元素 (r,c,t),不需要层长循环。dist 同时记录感染时刻和访问状态:所有初始腐烂格登记为 0,新鲜邻居第一次发现时登记为 t+1。网格保持原样,必须用“坐标尚不在 dist”来阻止重复计数。answer 在每次出队时取已到达时间的最大值,队列处理完后便得到最晚感染时间;fresh 仍大于零则表示存在不可达新鲜格。

  1. 扫描网格,把所有初始腐烂坐标以 (r,c,0) 入队并记入 dist,另统计 fresh。
  2. 令 answer=0;逐个弹出 (r,c,t),用 t 更新 answer。
  3. 检查四邻,只访问范围内、值为 1 且不在 dist 中的格子。
  4. 新访问格登记 dist[x,y]=t+1,fresh 减一,并将 (x,y,t+1) 加入队尾。
  5. 队列清空后,fresh==0 返回 answer,否则返回 −1。
PYTHON 3
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。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 多源同时入队,全部初始源属于第零层。
  2. 固定层大小隔开旧感染源与新感染格。
  3. fresh 决定能否完成;队列耗尽仍有新鲜格才返回 -1。
← 返回题库
图论53 / 100

207. 课程表 中等

标签:深度优先搜索 · 广度优先搜索 · 图 · 拓扑排序 · 有向无环图

力扣原题 ↗
01 / 理解题目

一句话题意

给定先修依赖,判断是否可以完成全部课程,即有向图是否无环。

教学示例

numCourses=4, prerequisites=[[1,0],[2,0],[3,1],[3,2]] → True

生活化理解

选课像一道道审批:入度是尚未完成的前置审批数量。没有待办前置条件的课程才能被放入可学队列;完成它后,对后续课程解除一项条件。

02 / 讲透主解法

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 恰好说明全部课程可完成。

图解

1 / 11
建立依赖图和初始入度0indeg=01indeg=12indeg=13indeg=2ready:左端先出尚未初始化入度与已处理课程indeg = [0, 1, 1, 2]done = 尚未初始化出队顺序 = []u = 尚未出队边 pre → course;绿色虚线表示这条依赖已处理,图的位置始终不变。
indeg
[0, 1, 1, 2]
ready
尚未初始化
done
尚未初始化
u
尚未出队
选择步骤 11 步
变量与图例

关注汇合点课程 3:它必须等两条入边都被处理,不能只完成一门先修就入队。

graph
有向邻接表,先修课 pre 指向依赖它的 course。
indeg
每门课尚未处理的先修边数量。
ready
全部先修已满足、尚未出队的课程。
done
已经出队的课程数,不是队列中课程数。
u / v
正在完成的课程及其当前后继。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 建立 graph 和 indeg;对每个 [course,pre] 添加 pre→course,并给 indeg[course] 加一。
  2. 把所有入度为 0 的课程放入 ready,令 done=0。
  3. 从 ready 弹出 u,done 加一,随后处理 graph[u] 中的每个 v。
  4. 每处理一条 u→v 就令 indeg[v] 减一;变为 0 时将 v 加入 ready。
  5. ready 清空后返回 done==numCourses。

代码(Python)

查看完整 Python 代码Kahn 拓扑排序 · 与上方图解对应

indeg 只表示未完成的前置条件;ready 保存所有当前可学课程,适合课程数很大的 Python 输入。

PYTHON 3
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) 邻接表、入度数组和队列。
03 / 检查边界

易错点与边界

  • [a,b] 表示先 b 后 a。
  • 不能只遍历从课程 0 可达的部分,图可能不连通。
  • 没有依赖时全部课程都能完成;自依赖直接成环。
  • 后续课程的入度降到 0 才能入队;只完成其中一个先修时不能提前学习它。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

DFS 三色判环时间 O(V+E),每条边只在所属起点首次 DFS 时扫描。

它不模拟完成课程,而是直接检查有向图是否有环。color=0 表示尚未访问,1 表示仍在当前递归链上,2 表示其全部后继已经检查完且无环。遇到颜色 1 是回到祖先,形成环;遇到颜色 2 只是共享了一个已检查的后继,不是环。所有后继都通过后才把当前节点设为 2。递归链过长可能触及 Python 栈深限制,这时可用 Kahn 主解。

  1. 按 pre→course 建立邻接表,初始化所有 color 为 0。
  2. visit(u) 遇到 color[u]==1 返回 False;遇到 color[u]==2 直接返回 True。
  3. 首次处理 u 时置 color[u]=1,逐个递归检查它的后继;任一后继返回 False 就传播失败。
  4. 全部后继无环时置 color[u]=2 并返回 True。
  5. 对所有课程调用 visit,覆盖不连通部分;全部返回 True 才表示可以完成。
PYTHON 3
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 过程中任何时刻出现多个可选课程,就存在多个合法顺序;这一结论以最终无环为前提。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 先确定边方向:先修课指向后续课。
  2. 入度归零才入队,不能用「已遇到一个先修」替代。
  3. 处理数量不足总数说明有课程被环阻塞;不必显式找出环。
← 返回题库
图论54 / 100

208. 实现 Trie (前缀树) 中等

标签:设计 · 字典树 · 哈希表 · 字符串

力扣原题 ↗
01 / 理解题目

一句话题意

实现插入单词、完整单词查询与前缀查询的 Trie。

教学示例

依次 insert("app"), search("ap"), startsWith("ap"), insert("ape"), search("ape"), search("app") → [None, False, True, None, True, True]

生活化理解

把单词存进按字母分岔的文件柜:每一层抽屉对应一个字符,多词共用前缀抽屉;终点标签表示柜子里真的登记过这个完整单词。

02 / 讲透主解法

字典 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 区分完整单词。

图解

1 / 12
空 Trie 只有根节点rootend=False当前操作与指针Trie()node → root已完成的操作返回值[]双圆 = 完整词尾单圆 = 路径节点
operation
Trie()
node 前缀
root
已存在前缀
[""]
end=True
[]
已完成返回值
[]
选择步骤 12 步
变量与图例

观察 node 每次沿哪个字符移动,以及有路径的 ap 为什么仍不是完整单词。

root
所有查询与插入的起点,表示空前缀。
node
当前操作所指向的前缀节点;每次操作重新从根开始。
children
下一字符到子节点的字典,如 ap 下有 p 与 e 两条边。
end
该前缀是否曾作为完整单词插入。
_walk
逐字沿边行走,缺边返回 None;成功则返回末节点。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 初始化 root;新 Node 的 children={}、end=False。
  2. insert 从 root 逐字符行走,缺少 ch 时创建 node.children[ch],然后移动到该子节点。
  3. 插入的字符全部读完后,只给最终节点设置 end=True。
  4. 查询通过 _walk 从 root 沿字符字典行走,缺失分支立即返回 None。
  5. search 检查返回节点存在且 end=True;startsWith 仅检查返回节点是否存在。

代码(Python)

查看完整 Python 代码字典 children 节点 · 与上方图解对应

每个 Node 维护稀疏字典和 end,_walk 复用两种查询的字符遍历。

PYTHON 3
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)。
03 / 检查边界

易错点与边界

  • 终点标记不能由“没有子节点”代替,app 和 apple 可以同时存在。
  • search 与 startsWith 的最后判断不同。
  • 重复插入不应破坏更长单词。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

26 槽数组节点时间 查询 O(L),插入 O(26L)=O(L),字母表固定为 26。

把每个节点的 children 换成长度 26 的数组,children[ord(ch)−ord('a')] 直接对应一个小写字母。词尾 end 和共享前缀的语义与主解相同,search 仍检查完整路径及词尾,startsWith 仍只检查路径。它利用本题固定字符集换取直接索引,但每个节点都分配 26 个槽,分支稀疏时会保留较多空槽。

  1. 建立根节点,每个 Node 初始化 children=[None]*26 和 end=False。
  2. 插入时将每个字符换算为下标 i;该槽为 None 就创建节点,随后移动到 children[i]。
  3. 单词全部插入后,把最终节点的 end 设为 True。
  4. _walk 用相同字符下标访问子节点,遇到 None 就返回 None,否则返回走完后的节点。
  5. 完整查询同时检查节点与 end;前缀查询只检查节点是否存在。
PYTHON 3
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 字符可用字典节点;数组方案需要重新设计字母到槽位的映射。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 前缀通过路径存在判断,整词还必须检查末节点 end。
  2. 每次操作从 root 开始,共享前缀仅在缺边时创建。
  3. 同一个字符在不同前缀下是不同节点,不能按字符全局合并。
← 返回题库
回溯55 / 100

46. 全排列 中等

标签:数组 · 回溯

力扣原题 ↗
01 / 理解题目

一句话题意

给定互不相同的整数,返回全部排列;每个元素在每个排列中恰好使用一次。

教学示例

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 是每个人的座位登记表。试坐一个人、安排后续、撤销试坐,再换下一个人。

02 / 讲透主解法

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 时复制路径,因此每个排列恰被收集一次。

图解

1 / 12
三个数字都尚未使用选 1选 2选 3选 2选 3选 3选 2选 1选 3选 3选 1选 1选 2选 2选 1[][1][2][3][1,2][1,2,3][1,3][1,3,2][2,1][2,1,3][2,3][2,3,1][3,1][3,1,2][3,2][3,2,1]灰色未到达;紫色当前路径;绿色已经收集的叶子。当前递归状态path = []used = [False, False, False]未被占用元素 = [1, 2, 3]已收集 ans(按真实顺序)[]
path
[]
used
[False, False, False]
未被占用元素
[1, 2, 3]
ans
[]
选择步骤 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

算法步骤

  1. 初始化空 ans、path 和全 False 的 used,调用 dfs()。
  2. 每次进入 dfs,若 len(path)==n,就保存 path.copy() 并返回。
  3. 否则枚举全部 i,跳过已使用下标;把 used[i] 设为 True,并将 nums[i] 追加到 path。
  4. 递归填写下一位置;返回后执行 path.pop() 和 used[i]=False,再试同层下一候选。
  5. 根调用的所有分支结束后返回 ans。

代码(Python)

查看完整 Python 代码used 数组回溯 · 与上方图解对应

path 保存当前顺序,used 按下标跟踪使用情况。每一层重新扫描 nums,叶子复制完整排列。

PYTHON 3
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!)。
03 / 检查边界

易错点与边界

  • 保存 path 本身会导致所有答案共享对象,必须复制。
  • 不能用 start 限制后续只能选更大下标,否则会生成组合而非排列。
  • 元素互异是当前去重逻辑的前提;含重复值是另一个问题。
  • 每个分支返回后先 path.pop(),再将 used[i] 恢复为 False,下一分支才能重新选择该元素。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

原地交换固定前缀时间 O(n·n!),叶子复制长度 n 的排列主导总时间。

用数组自身保存当前排列,first 左侧的所有位置已经固定,后缀就是当前还可选的候选。逐个把后缀元素交换到 first,再递归固定下一位置,无需 path 和 used。每个分支返回后必须用同一对下标换回,恢复后缀供下一候选使用;first 到数组长度时复制 nums 保存答案,函数结束后输入数组恢复原样。

  1. 建立空 ans,调用 dfs(0),从第一个位置开始固定。
  2. 若 first==len(nums),保存 nums.copy() 并返回。
  3. 枚举 i 从 first 到末尾,把 nums[i] 与 nums[first] 交换。
  4. 调用 dfs(first+1),返回后再次交换同一对位置,撤销本次固定。
  5. 全部排列生成后返回 ans。
PYTHON 3
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 个排列,则可用阶乘分块定位,不必生成此前所有答案。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 排列每层枚举所有下标,used 阻止同一路径重复用数。
  2. 选择与撤销必须同步维护 path 和 used。
  3. 叶子存副本;临时工作路径与输出结果是两个容器。
← 返回题库
回溯56 / 100

78. 子集 中等

标签:位运算 · 数组 · 回溯

力扣原题 ↗
01 / 理解题目

一句话题意

返回互不相同整数数组的全部子集,包含空集,答案不重复。

教学示例

nums=[1,2] → [[],[2],[1],[1,2]](按原代码先不选的顺序)

生活化理解

整理旅行清单:对每件物品只回答“带”或“不带”,一串二选一决定唯一背包。另一种整理法是每次只从后面的物品中继续选,避免同一背包按不同顺序登记。

02 / 讲透主解法

选与不选的回溯

每个元素对应选或不选两个互斥分支,能够直接解释全部子集、空集以及只在叶子收集的时机。

核心思路

二叉决策树的层数是处理过的元素数。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 时收集不会重复。

图解

1 / 12
先决定数字一是否入选不选 1选 1不选 2选 2不选 2选 2[][][1][][2][1][1,2]同样的 path 可以位于不同深度;收集只发生在 i=2 的叶子。当前递归状态i = 0path = []还未决定的数字 = [1, 2]已收集 ans(按真实顺序)[]
i
0
path
[]
未决定数字
[1, 2]
ans
[]
选择步骤 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

算法步骤

  1. 初始化空 ans 和 path,从 dfs(0) 开始。
  2. 进入 dfs(i) 时,若 i==len(nums),复制 path 到 ans 并返回。
  3. 先调用 dfs(i+1) 处理不选 nums[i] 的分支,保持当前 path。
  4. 不选分支返回后追加 nums[i],再调用 dfs(i+1);返回时 pop 撤销这次选择。
  5. 全部决策结束后返回 ans,其中包含空集。

代码(Python)

查看完整 Python 代码选与不选的回溯 · 与上方图解对应

i 决定处理哪个元素,与 path 长度不同;只在所有元素都决定后收集答案。

PYTHON 3
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 个列表。
03 / 检查边界

易错点与边界

  • 空集必须存在。
  • i 每层都加一,不能因为“不选”而停在同一元素。
  • 子集不区分顺序,不要用全排列模板。
  • 复制结果时需创建新列表。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

迭代倍增时间 O(n·2^n),列表拼接会复制旧子集。

ans 保存此前元素的全部子集。处理新元素 x 时,旧子集表示不选 x,把每个旧子集复制并附加 x 后得到 added,表示选择 x;这两部分不重叠,合并后便覆盖新前缀的全部子集。added 必须只根据本轮开始时的 ans 构造,不能边遍历同一个列表边持续追加。列表拼接会复制内容;n≥1 时所有输出子集的元素总数为 n·2^(n−1),不能忽略复制成本只报 O(2^n)。

  1. 以 ans=[[]] 开始,表示尚未处理任何元素时只有空集。
  2. 逐个读取 x,用当前 ans 中每个 subset 构造新列表 subset+[x],得到 added。
  3. added 全部构造完成后执行 ans.extend(added),同时保留旧子集。
  4. 所有元素处理结束后返回 ans。
PYTHON 3
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)。若输入允许重复,排序后按层跳过相同值或按重复次数分组,才能避免重复子集。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 子集按位置做二元决定,不需要排列题的 used 数组。
  2. 空路径不等于完成,必须用 i 判断是否到叶子。
  3. 本题没有限制条件,所有 2ⁿ 个选或不选叶子都要保留。
← 返回题库
回溯57 / 100

17. 电话号码的字母组合 中等

标签:哈希表 · 字符串 · 回溯

力扣原题 ↗
01 / 理解题目

一句话题意

给定仅含 2 至 9 的数字字符串,返回电话键盘上每位数字可映射出的所有字母串;空输入返回空列表。

教学示例

digits="23" → ["ad","ae","af","bd","be","bf","cd","ce","cf"]

生活化理解

每位数字是一道菜的备选口味:数字 2 提供 a/b/c,数字 3 提供 d/e/f。每道菜恰好选一种口味,组合成一整套套餐,顺序由数字位置固定。

02 / 讲透主解法

按数字位置回溯

每层只负责一位数字,列表路径逐字母加入和撤销,只在完整组合处拼接字符串,状态容易讲清。

核心思路

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) 才拼接收集,因此每个合法组合出现一次。

图解

1 / 12
从数字二的三个候选开始选 adef选 bdef选 cdef空串aadaeafbbdbebfccdcecf每层候选来自对应数字:2 → abc;3 → def。当前递归状态digits = "23";i = 0path = []本层候选 = abc已收集 ans(按真实顺序)[]
i
0
path
[]
本层候选
abc
ans
[]
选择步骤 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

算法步骤

  1. 若 digits 为空,直接返回 [];否则建立数字 2 到 9 的固定字母映射。
  2. 初始化空 ans、path,调用 dfs(0)。
  3. 若 i==len(digits),把 ''.join(path) 加入 ans 并返回。
  4. 否则逐个选择 mapping[digits[i]] 中的字母,追加到 path 后调用 dfs(i+1)。
  5. 每个分支返回时 pop 最后一个字母,再试下一候选;根调用结束后返回 ans。

代码(Python)

查看完整 Python 代码按数字位置回溯 · 与上方图解对应

path 列表使单步追加与撤销为 O(1),只在叶子 join,避免每层创建字符串。

PYTHON 3
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)。
03 / 检查边界

易错点与边界

  • 空输入返回 [],不是 [""]。
  • 7 和 9 各对应四个字母,不能统一按三叉树估算精确输出数。
  • 递归层由数字下标决定,每层恰好取一个字母。
  • 数字只来自 2 至 9,不需要编造 0、1 的映射。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

迭代笛卡尔积时间 O(nP),P 为最终组合数;每次 prefix+ch 都分配并复制字符串。

ans 维护已处理数字的所有字母前缀,每轮把每个旧前缀与当前数字的每个映射字母拼接,生成完整的下一层。内部用 [''] 作为启动拼接的单位元素,但空输入对外仍应返回 [],必须先单独处理。它不维护可变 path,也无需撤销;代价是同时保留一整层中间字符串,构造下一层时旧层仍存在,每次 prefix+ch 都会创建新字符串。

  1. digits 为空时返回 [];否则建立数字到字母的映射,令 ans=['']。
  2. 依次读取每个 digit,以本轮旧 ans 中的每个 prefix 配对 mapping[digit] 中的每个 ch。
  3. 将所有 prefix+ch 构成新列表,作为下一轮 ans。
  4. 全部数字处理完成后,返回完整组合列表 ans。
PYTHON 3
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),但完整输出的总时间仍不可省。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 递归深度对应数字位置,候选集合由这一位映射决定。
  2. 每层只撤销自己追加的字母,保留上层前缀。
  3. 没有额外冲突限制时完整枚举,不要套用排列题的 used。
← 返回题库
回溯58 / 100

39. 组合总和 中等

标签:数组 · 回溯

力扣原题 ↗
01 / 理解题目

一句话题意

给定互异正整数候选值,每个值可无限次使用,返回和为 target 的所有不重复组合。

教学示例

candidates=[2,3,5], target=5 → [[2,3],[5]]

生活化理解

用固定面额筹码凑出总额:path 是已拿筹码,remain 是还差的金额。为避免把“2 再 3”和“3 再 2”重复记账,约定只按面额不下降的顺序拿筹码。

02 / 讲透主解法

排序后循环回溯

start 同时解释组合去重和允许重复选取,排序后的剩余值剪枝也能直接由大小关系证明。

核心思路

先建立排序副本 numsdfs(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 能一次排除后续更大候选。

图解

1 / 11
排序后,从零下标与目标五开始选 2选 3选 5再选 2选 3[] / 5[2] / 3[2,2] / 1[2,3] / 0[3] / 2[5] / 0节点斜杠后是 remain;候选顺序固定为 [2,3,5]。当前递归状态path = []start = 0;remain = 5nums[start:] = [2, 3, 5]已选和 + remain = 0 + 5 = 5已收集 ans(按真实顺序)[]
path
[]
start
0
remain
5
候选 nums[start:]
[2, 3, 5]
ans
[]
选择步骤 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

算法步骤

  1. 建立 nums=sorted(candidates)、空 ans 和 path,调用 dfs(0,target)。
  2. 若 remain==0,保存 path.copy() 并立即返回。
  3. 从 start 起枚举 i,令 x=nums[i];x>remain 时 break 结束本层。
  4. 追加 x 后调用 dfs(i,remain−x),保留下标 i 以允许再次使用当前候选。
  5. 递归返回后 pop 本次追加值,再试同层下一候选;根调用结束后返回 ans。

代码(Python)

查看完整 Python 代码排序后循环回溯 · 与上方图解对应

start 防止组合次序重复,remain 支持单调剪枝,path.copy() 冻结当前解。sorted 保留原数组。

PYTHON 3
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) 上界。
03 / 检查边界

易错点与边界

  • 选中 i 后传 i,传 i+1 会错误禁止重复使用。
  • 递归重新从 0 枚举会让不同顺序的同一组合重复。
  • break 剪枝依赖已排序且候选为正数。
  • target 不可凑出时返回 [],不要把空路径当答案。
  • 若 target 很大且最小候选很小,递归深度会增长;Python 实现需留意栈深。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 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,后面的候选也无法使用,直接结束该状态。

  1. 建立排序副本 nums、空 ans 和 path,调用 dfs(0,target)。
  2. 先检查 remain==0,成立时复制 path 并返回;否则 i 到末尾或 nums[i]>remain 时返回。
  3. 追加 nums[i],调用 dfs(i,remain−nums[i]),表示继续使用当前候选。
  4. 选择分支返回后先 pop 恢复 path,再调用 dfs(i+1,remain),表示从此跳过当前候选。
  5. 根调用结束后返回 ans。
PYTHON 3
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;若候选输入本身还允许重复值,则需再加同层去重,这两项变化不能混为一谈。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. remain 控制目标和,start 控制组合的非递减选择顺序。
  2. 允许重复使用时递归传 i;传 i+1 会漏掉重复取数的解。
  3. 排序后首个过大候选触发 break,不必继续尝试更大数。
← 返回题库
回溯59 / 100

22. 括号生成 中等

标签:字符串 · 动态规划 · 回溯 · 括号序列

力扣原题 ↗
01 / 理解题目

一句话题意

生成由 n 对括号组成的全部合法括号字符串。

教学示例

n=2 → ["(())","()()"]

生活化理解

登记进出剧场的人数:左括号是入场,右括号是离场,任何时刻离场人数不能超过入场人数。最终两边都恰好 n 次,账目才能结清。

02 / 讲透主解法

按左右括号计数回溯

用已使用的左右括号数限制每一步选择,递归中直接保持合法前缀,无需生成后再筛选。

核心思路

leftright 分别表示当前已用左括号和右括号数,每个递归状态保持 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,直接收集就是合法字符串,不必事后过滤。

图解

1 / 12
空串只允许先加左括号加左加左加右加右加右加左加右空串(((()(()()((())()()约束:0 ≤ right ≤ left ≤ 2;根节点右括号候选被排除。当前递归状态path = ""left = 0;right = 0;n = 2合法下一字符 = ["("]未配对左括号 = left-right = 0已收集 ans(按真实顺序)[]
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

算法步骤

  1. 初始化空 ans、path,调用 dfs(0,0)。
  2. 若 len(path)==2*n,就把 ''.join(path) 加入 ans 并返回。
  3. 若 left<n,追加 '(',递归 dfs(left+1,right),返回后 pop。
  4. 再判断 right<left;若成立,追加 ')',递归 dfs(left,right+1),返回后 pop。
  5. 根调用结束后返回 ans。

代码(Python)

查看完整 Python 代码按左右括号计数回溯 · 与上方图解对应

left/right 让不合法前缀不进入递归。path 使用可变列表,叶子才构造字符串。

PYTHON 3
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)。
03 / 检查边界

易错点与边界

  • right<left 才能加右括号,不是 right<n 就行。
  • left<n 保证左括号总数受限。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 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 会保存各个较小规模的完整字符串。

  1. 创建 dp[0..n],将 dp[0] 设为 ['']。
  2. 按 pairs 从 1 到 n 计算结果,枚举 inside 从 0 到 pairs−1。
  3. 从 dp[inside] 取 a,从 dp[pairs−1−inside] 取 b,构造 '('+a+')'+b。
  4. 把每个构造结果追加到 dp[pairs];所有规模计算完后返回 dp[n]。
PYTHON 3
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 数规模的输出本身就不可避免。

其他思路的边界

  • 每层都复制整个字符串也能实现,但需计入字符串复制。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 加左只需 left<n,加右必须 right<left。
  2. 前缀约束保证叶子合法,避免先生成再过滤。
  3. 路径与参数随回溯恢复,已收集字符串保留。
← 返回题库
回溯60 / 100

79. 单词搜索 中等

标签:深度优先搜索 · 数组 · 字符串 · 回溯 · 矩阵

力扣原题 ↗
01 / 理解题目

一句话题意

判断能否通过网格中上下左右相邻的格子依次拼出 word,同一条路径不能重复使用同一格。

教学示例

board=["ABC","BDE"], word="ABC" → True;先失败于向下 B,再成功于向右 B

生活化理解

在字母地砖上踩出暗号:每踩一格读一个字母,一次尝试中不能重踩同一块地砖。走错就沿原路退回并撤销脚印,让其他路线仍能使用那些格子。

02 / 讲透主解法

路径坐标集合 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 中的坐标,因此一条路径不重复用格,而失败分支退出后其他路径仍可使用该格。

图解

1 / 12
完整棋盘,尚未选择任何格子c=0c=1c=2r=0ABCr=1BDE当前匹配路径(箭头也是选择顺序)[]此刻的状态i = 0;本次目标 = Aused = {}found / 返回值 = 尚未计算方向顺序:上 → 下 → 左 → 右绿色是当前 used;紫色是本次格子;红色是刚失败的尝试。
(r,c)
None
i
0
word[i]
A
匹配路径(教学标注)
[]
used
{}
found / 返回值
尚未计算
选择步骤 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"]

算法步骤

  1. 建立空 used,以每个棋盘坐标为候选起点调用 dfs(r,c,0),用外层 any 判断是否存在成功路径。
  2. 每次进入 dfs,先检查范围、当前坐标是否已在 used 中、字符是否等于 word[i];任一不满足就返回 False。
  3. 若 i 已是单词最后一位,直接返回 True,此时还没有把当前格加入 used。
  4. 否则把 (r,c) 加入 used,按上、下、左、右递归匹配 i+1,用 any 的结果记录 found。
  5. 先从 used 撤销当前坐标,再返回 found;成功与失败都执行这一步。

代码(Python)

查看完整 Python 代码路径坐标集合 DFS · 与上方图解对应

used 仅记录当前尝试中的格子,退出 dfs 时 remove;字符相同但坐标不同的格子可以同时使用。

PYTHON 3
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。
03 / 检查边界

易错点与边界

  • 不能永久 visited,否则某条失败路径会阻止其他合法路径。
  • 同字母不同坐标可以使用,同一坐标不可重用。
  • 只沿四邻移动,不含对角线。
  • 本题 word 非空;超长搜索路径可能遇到 Python 递归深度限制。
  • 普通匹配格必须先从 used 撤销,再返回 found,成功与失败都要恢复;末字符在 used.add 之前直接返回,因此不需要移除末格。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

原地标记并恢复的 DFS时间 设格数为 mn、单词长度为 L。宽松上界 O(mn·4·3^(L-1)):起点后首步最多四邻,此后不能走回前格,至多三分支;剪枝通常更快。

用棋盘自身暂存路径占用状态,不另建 used 集合。普通匹配格先保存 old,再改为不属于题目字母表的 '#';后续想重用该格时,会因字符不匹配被拒绝。found 收集四邻搜索结果之后,必须先把 old 写回,再返回,成功路径也要恢复。末字符同样在写标记之前直接成功,因此它无需恢复;函数返回时原棋盘保持原样。

  1. 逐格尝试起点,每次 dfs 先检查范围和 board[r][c] 是否等于 word[i];不匹配就返回 False。
  2. 当前字符是单词末位时直接返回 True,不写临时标记。
  3. 否则保存 old=board[r][c],把当前格设为 '#',阻止当前路径再次使用。
  4. 按四邻调用 dfs 匹配下一字符,用 any 得到 found。
  5. 无论 found 是真是假,都先恢复 board[r][c]=old,再返回 found;外层任一起点成功即返回 True。
PYTHON 3
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。

面试追问与解法取舍

可用字符频次先判定棋盘是否够用,并从单词首尾较稀少的一端开始搜索;反转单词不会改变是否存在路径,因为四邻边双向。只有搜索规模确实需要时再添加这些剪枝,核心正确性仍来自路径级访问状态。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 访问状态属于当前路径,失败返回必须撤销,不能沿用岛屿的永久标记。
  2. 候选按坐标判断,两个字母相同但位置不同的格子可以分别尝试。
  3. any 可以找到成功后短路,但当前层仍先恢复 used 再返回。
← 返回题库
回溯61 / 100

131. 分割回文串 中等

标签:字符串 · 动态规划 · 回溯

力扣原题 ↗
01 / 理解题目

一句话题意

把字符串完整切成若干非空回文子串,返回全部不同切法。

教学示例

s = "aab" → [["a","a","b"],["aa","b"]]。先逐格计算 pal,再完整走完所有切法。

生活化理解

像切一条带字母的面包:start 是下一刀的起点,end 是本刀终点,只有正反读相同的一片才能装进 path 盘子。

02 / 讲透主解法

回文 DP + 回溯

先把回文判断预处理好,再专注枚举切口,能清楚区分区间判断与路径搜索。

核心思路

先计算 pal[i][j],表示闭区间 s[i:j+1] 是否为回文。两端字符相等,且长度不超过 2 或内部 pal[i+1][j-1] 为真,当前区间才是回文。外层 i 从后往前枚举,确保需要读取的下一行已经计算;单字符与长度 2 的区间直接由短路条件处理。

进入 dfs(start) 时,path 恰好、无重叠地覆盖 s[:start],其中每片都是回文。枚举 endstartn-1,就是决定下一片的长度;只有 pal[start][end] 为真才加入该子串,再递归到 end+1。某个终点失败只跳过该候选,不能结束整层枚举,因为更长子串仍可能是回文。

start==n 才表示整条字符串用完,此时保存 path.copy()。递归返回后弹出本层刚加入的一片,使下一条分支重新面对相同的父路径,已保存的副本不受影响。每个合法分割对应唯一的一串切口,因此不会漏解或重复切法。查表判断为 O(1),但创建 Python 子串和复制答案仍有实际代价。

不变量

进入 dfs(start) 时,path 恰好覆盖 s[:start],每片都已经通过回文表。只有 start == n 才能收集;复制结果后再撤销当前片,其他切口仍可继续尝试。

图解

1 / 21
先准备区间判断a0a1b2先预处理回文表pal[i][j]:闭区间的回文判断0: a1: a2: b0: a···1: a··2: b·当前 path / 撤销[]切片覆盖字符数:0ans:已复制 0 条结果[](尚未走到字符串末尾)T 可以切;F 跳过。· 尚未计算,不能当成 F。
s
aab
n
3
已计算区间
0
选择步骤 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,跳过"]

算法步骤

  1. 建立 pal 表,倒序枚举起点 i、正序枚举终点 j,按两端字符与内部区间计算回文真假。
  2. 从 dfs(0) 开始,在当前 start 枚举下一片终点 end。
  3. 若 pal[start][end] 为真,将 s[start:end+1] 加入 path,再递归 dfs(end+1)。
  4. 到达 start==n 时把 path.copy() 加入 ans;每次递归返回都弹出本层所选片段。
  5. 全部切口探索完成后返回 ans。

代码(Python)

查看完整 Python 代码回文 DP + 回溯 · 与上方图解对应

pal 缓存所有回文区间;path 保存已选片段,start 指向未覆盖后缀,减少重复判回文。

PYTHON 3
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ⁿ)。
03 / 检查边界

易错点与边界

  • 保存 path 本身会让结果共享同一可变列表,必须复制。
  • end 是闭区间终点,切片右边界和下一次 start 都是 end+1。
  • 题设字符串非空;单字符必定回文。
  • DP 外层若正序,内部区间可能尚未计算。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

双指针判回文 + 回溯时间 O(n·2ⁿ) 最坏量级;枚举切口、区间判断与输出复制均计入,n 为字符串长度。

保留按 start、end 枚举切口的回溯树,每个候选区间改用 l、r 从两端向内比较,确认回文后才创建切片。它省去二维回文表,适合先理解搜索过程或希望减少辅助空间的场景,但相同区间可能随不同前缀路径被重复判断。path 仍只保存当前切法,完整答案必须另存副本。

  1. 建立 ans、path,从 start=0 开始递归枚举候选终点 end。
  2. 调用 is_pal(start,end),逐对比较两端字符;不同即返回 False,两指针相遇或交错则为 True。
  3. 候选为回文时,才切出 s[start:end+1] 加入 path,递归到 end+1。
  4. 覆盖完整字符串时复制 path 保存;每次递归返回后弹出刚加入的一片,继续尝试其他终点。
  5. 根调用结束后返回全部切法。
PYTHON 3
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,不必枚举指数个答案。若要求懒惰生成,可用生成器逐个产出路径副本,但总输出工作量不会消失。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. “分割”型回溯用 start 表示未覆盖后缀;合法片段的终点决定下一次 start。
  2. 回文 DP 的 i 倒序保证 pal[i+1][j−1] 已知;短串用长度条件直接判断。
  3. 收集用 path.copy(),撤销用 path.pop(),它们分别管理结果快照和当前搜索路径。
← 返回题库
回溯62 / 100

51. N 皇后 困难

标签:数组 · 回溯 · X 算法

力扣原题 ↗
01 / 理解题目

一句话题意

在 n×n 棋盘放 n 个互不同行、同列、同斜线的皇后,返回全部棋盘。

教学示例

n = 4 → 2 个棋盘;按行记录皇后列号分别为 [1,3,0,2]、[2,0,3,1]。图解覆盖全部合法落子和回退。

生活化理解

像给每层楼安排一束不能相撞的探照灯:每行放一盏,列集合记录竖线,r−c 和 r+c 分别编号两族斜线。

02 / 讲透主解法

集合记录列与斜线

按行放置配合三个占用集合,冲突来源和回溯恢复都能直观对应棋盘。

核心思路

按行递归自动保证每行恰好一个皇后。进入 dfs(r) 时,前 r 行已经合法摆放,colsdiag1diag2 只记录这些皇后占用的列与斜线。候选格 (r,c) 只有在 cr-cr+c 都未出现过时才能使用;同一条斜线上的坐标差或坐标和相同,所以三个集合完整表达已有皇后的攻击范围。

path 只保存已放皇后的列号,长度就是已经放好的行数。选中一列后,将列和两条斜线加入集合,把列号加入路径,再递归下一行。当前行如果所有候选都冲突,这条分支就自然返回,不会产生不完整的答案。

r==n 时再把每个列号转换为含一个 Q 的行字符串,保存完整棋盘,避免搜索途中反复复制整张棋盘。每次递归返回必须同时恢复 path 和三个占用集合,让下一候选面对同一个父状态;已经构造的答案字符串保留在 ans 中。枚举完所有行列选择后就得到全部解。

不变量

进入 dfs(r) 时,前 r 行恰好各有一个皇后,cols、diag1、diag2 精确记录它们占用的列及两类斜线。只有三个集合都不冲突才落子;返回时同步撤销,下一候选看到的就是进入本层前的棋盘。

图解

1 / 23
空棋盘,从第 0 行开始c=0c=1c=2c=3r=0····r=1····r=2····r=3····Q 已放;× 冲突;黄 · 可选撤:刚回退;绿 ·:已试完下一递归行 r = 0path = []cols = []diag1 (r−c) = []diag2 (r+c) = []当前行候选(从 c=0 向右枚举)c=0: 可选c=1: 可选c=2: 可选c=3: 可选自上一帧以来的真实选择 / 撤销初始化四个容器;下一行从 c=0 开始。
r
0
path
[]
cols
[]
diag1
[]
diag2
[]
已保存棋盘
0
选择步骤 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

算法步骤

  1. 初始化 ans、path,以及 cols、diag1、diag2 三个空集合,从 dfs(0) 开始。
  2. 在第 r 行枚举列 c;若 c、r-c、r+c 任一已被占用,就跳过当前候选。
  3. 合法时记录列与两条斜线,把 c 加入 path,递归 dfs(r+1)。
  4. 到 r==n 时按 path 构造棋盘字符串并加入 ans;返回父层后撤销本次路径和三项占用。
  5. 继续其余候选,所有分支完成后返回 ans。

代码(Python)

查看完整 Python 代码集合记录列与斜线 · 与上方图解对应

三个集合做均摊 O(1) 冲突检查,path 存每一行的列号;最直观,便于解释回溯恢复。

PYTHON 3
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²)。
03 / 检查边界

易错点与边界

  • r−c 可以为负数,集合可直接保存,不必平移。
  • 两个斜线集合必须分别保存,不能把和与差混到同一集合。
  • n=1 有一个解,n=2、3 无解;题设 n≥1。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

位运算枚举可用列时间 题设 n 很小、位操作视为 O(1) 时,上界 O(n! + S·n²),S 为解数;任意大 n 下 Python 大整数位运算还与位宽相关。

用第 c 位表示列 c,cols 记录已占列,left、right 记录当前行受两类斜线攻击的位置。available=mask & ~(cols|left|right) 只保留合法列,逐次取最低置位即可避免扫描冲突列。下一行的攻击位置通过左右移位得到;整数参数形成各递归层自己的状态,返回时只需恢复共享 path。它更紧凑,但要先讲清位与棋盘列、斜线移动的对应。

  1. 令 mask=(1<<n)-1,以 cols=left=right=0 开始递归,path 保存已选列号。
  2. 若 cols==mask,说明每行每列均已放好皇后,按 path 生成完整棋盘并保存。
  3. 计算 available=mask & ~(cols|left|right),用 bit=available & -available 取一个合法列,再从 available 中清掉该位。
  4. 将 bit.bit_length()-1 加入 path,递归传入 cols|bit、((left|bit)<<1)&mask、(right|bit)>>1。
  5. 递归返回后弹出本次列号,继续枚举 available 中的其他置位;根调用结束返回全部棋盘。
PYTHON 3
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 的中间列需单独统计,不能把全部结果机械乘二。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 按行递归把“每行一个皇后”变成结构保证;剩下只需验证列和两类斜线。
  2. 放置和撤销必须对称更新 path、cols、diag1、diag2;遗漏任何一个都会污染兄弟分支。
  3. 找到一解后继续回退搜索;N 皇后返回全部棋盘,不能在第一片叶子提前停止。
← 返回题库
二分查找63 / 100

35. 搜索插入位置 简单

标签:数组 · 二分查找

力扣原题 ↗
01 / 理解题目

一句话题意

在严格递增数组中寻找目标下标;不存在时返回保持有序的插入位置,要求 O(log n)。

教学示例

nums = [1,3,5,7,9,11,13],target = 8 → 4;插入后 8 位于 7 与 9 之间。

生活化理解

给按号码排序的书找插槽:L 左边的书都太小,R 及其右边的书都够大,最后两个边界合成唯一槽位。

02 / 讲透主解法

半开区间 lower_bound

统一查找与插入为第一个不小于目标的分界线,模板简短且自然覆盖首尾插槽。

核心思路

把问题统一成第一个大于等于 target 的位置,也就是 lower_bound[left,right) 表示尚未判定的元素范围;[0,left) 中的值都小于目标,[right,n) 中的值都大于等于目标。答案是两类元素之间的分界线,候选分界线位于闭范围 [left,right]

初始化 left=0right=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] 中,收缩到同一点时就是插入位置。

图解

1 / 8
初始化半开区间 [0,7)leftright10315273941151367 尾后待判定元素 [0, 7)target = 8本轮比较 / 不变量尚未分类的元素是下标 0…6。答案分界线允许在 0…7;∅ 是尾后位置。
left
0
right
7
target
8
选择步骤 8 步
变量与图例

把答案想成一条分界线:左边全部小于 8,右边全部大于等于 8。每轮减少尚未分类的元素。

left / right
待判定元素的半开区间边界;right 可以等于 n,不读取 nums[n]。
mid
本轮中点,只有 left<right 时才计算与读取。
target
要查找第一个 ≥ 它的元素位置;本例为 8。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 设 left=0、right=len(nums),用 [left,right) 表示待判定元素。
  2. 当 left<right 时,取 mid=(left+right)//2 并读取 nums[mid]。
  3. 若中点值小于 target,令 left=mid+1;否则令 right=mid,把相等值也划入右侧区域。
  4. 两边界相遇时返回 left,它是第一个不小于目标的位置,也可能等于数组长度。

代码(Python)

查看完整 Python 代码半开区间 lower_bound · 与上方图解对应

L 与 R 是分隔已知区域的边界,R 可以等于 n;不需要额外记录候选答案。

PYTHON 3
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),只存边界和中点。
03 / 检查边界

易错点与边界

  • 答案为 n 是合法插入尾部,不是越界错误。
  • 题设通常非空;两种模板也自然支持空数组返回0。
  • 本主解的待判定元素使用 [left,right),循环条件为 left<right;中点值不小于目标时令 right=mid,保留分界线 mid。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

闭区间二分时间 O(log(n+1)),n 为长度;闭区间每轮排除中点。

将未判定元素写成闭区间 [left,right],right 初始为 n-1,循环条件为 left≤right。中点值大于或等于目标时,确认它属于右侧已知区域,再令 right=mid-1;相等时仍继续向左找,因此不是命中就返回的查找。区间交错时,left 正好指向第一个不小于目标的位置。它与主解的目标相同,但循环条件和右边界更新必须整套对应。

  1. 设 left=0、right=len(nums)-1,采用闭区间候选元素。
  2. 当 left≤right 时取中点;中点值小于 target 就令 left=mid+1。
  3. 否则令 right=mid-1,继续寻找更靠左的合格位置。
  4. 区间为空时返回 left,得到已有目标的位置或插入下标。
PYTHON 3
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。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. lower_bound 找的是第一个 ≥ target 的分界线,所以命中相等值也要继续向左收缩。
  2. 半开区间更新为 left=mid+1 或 right=mid;后者保留 mid 作为可能的答案边界。
  3. 读数组前由 left<right 保证 mid<n,尾后位置只用于表示答案。
← 返回题库
二分查找64 / 100

74. 搜索二维矩阵 中等

标签:数组 · 二分查找 · 矩阵

力扣原题 ↗
01 / 理解题目

一句话题意

矩阵每行递增且下一行首元素大于上一行末元素,判断目标是否存在。

教学示例

matrix = [[1,3,5],[7,9,11],[13,15,17]],target = 16 → False。

生活化理解

把多层书架按行接成一排:虚拟编号 k 的书在第 k//列数 层、第 k%列数 个位置,无需真的搬书。

02 / 讲透主解法

虚拟一维二分

利用跨行整体有序的条件,只增加一次坐标换算就能直接复用普通二分。

核心思路

本题不仅每行递增,下一行的首项还大于上一行的末项,所以按行展开后整体有序。矩阵有 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];每轮比较都能按大小排除连续一半。

图解

1 / 10
初始化虚拟下标范围原矩阵(行、列从 0 开始)1(0,0)3(0,1)5(0,2)7(1,0)9(1,1)11(1,2)13(2,0)15(2,1)17(2,2)一维下标 ↔ 二维坐标本步尚未选定新的 mid行 = mid // cols列 = mid % cols虚拟展开:只换算下标,没有真的复制矩阵lr1031527394115136157178闭区间 [left,right] = [0,8];target = 16本轮定位 / 排除依据行末 5<下一行首 7,11<13。跨行也有序,才能统一二分。
rows
3
cols
3
left
0
right
8
选择步骤 10 步
变量与图例

把矩阵按行读成一条有序队伍;中点属于哪行哪列,用整除和取余找到座位。

rows / cols
行数与列数;本例都是 3,虚拟数组长度为 9。
left / right
仍可能包含目标的虚拟下标闭区间。
mid / value
中点下标及映射到 matrix[mid//cols][mid%cols] 的真实值。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 若矩阵为空或首行为空,返回 False;否则读取 rows、cols,设 left=0、right=rows*cols-1。
  2. 在 left≤right 时取中点 mid,通过 mid//cols、mid%cols 读取真实矩阵值。
  3. 命中 target 就返回 True;值偏小令 left=mid+1,值偏大令 right=mid-1。
  4. 闭区间交错且仍未命中时,返回 False。

代码(Python)

查看完整 Python 代码虚拟一维二分 · 与上方图解对应

保留原矩阵,通过 k//cols 和 k%cols 取值;闭区间只存仍可能命中的元素。

PYTHON 3
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),没有创建扁平数组。
03 / 检查边界

易错点与边界

  • 坐标映射除数是列数,不是行数。
  • 仅行列各自有序的矩阵不保证扁平有序,不能直接套用。
  • 单行单列均自然适用;题设非空矩阵,代码额外自然处理空矩阵。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

先定位行,再定位列时间 O(log rows+log cols),等价于 O(log(rows·cols))。

先对行首做半开 upper_bound,找到第一个首项大于 target 的行,其前一行 left-1 是唯一可能的候选行。若连首行首项都大于目标,就不存在候选行。随后在该行做半开 lower_bound,并检查返回位置确实等于目标。跨行不交错保证其他行无需搜索,适合把二维定位拆成两次熟悉的边界查找。

  1. 若矩阵为空或首行为空,返回 False;否则对行号区间 [0,rows) 二分。
  2. 若 matrix[mid][0]≤target,令 left=mid+1,否则令 right=mid;结束后设 row=left-1。
  3. 若 row<0,返回 False;否则重新以 [0,cols) 搜索该行第一个不小于 target 的列。
  4. 行内中点值小于目标就令 left=mid+1,否则令 right=mid。
  5. 列查找结束,只有 left<cols 且 matrix[row][left]==target 时返回 True。
PYTHON 3
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) 的阶梯搜索。当前题的强排序条件才是对数复杂度的来源。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 二维到一维:k = r*cols+c;一维到二维:r=k//cols、c=k%cols。
  2. 逻辑展平只做坐标换算,仍是 O(1) 额外空间,无须创建合并列表。
  3. 闭区间未命中时使用 mid±1;left>right 才表示目标不存在。
← 返回题库
二分查找65 / 100

34. 在排序数组中查找元素的第一个和最后一个位置 中等

标签:数组 · 二分查找

力扣原题 ↗
01 / 理解题目

一句话题意

在非递减数组中用 O(log n) 找到目标值第一次和最后一次出现的位置,不存在返回 [-1,-1]。

教学示例

nums = [1,2,2,2,2,4,6],target = 2 → [1,4]。先找第一个 ≥2,再找第一个 >2。

生活化理解

在按姓氏排列的名单里找一整段同姓人:先找这一姓氏的入口,再找下一姓氏的入口;第二个入口前一位就是末尾。

02 / 讲透主解法

两次半开边界查找

把连续重复值夹在两个分界线之间,只需改变等号归属就能复用同一个模板。

核心思路

重复值形成连续的一段,普通二分命中某个位置并不能证明它是端点。定义 first 为第一个值大于等于 target 的下标,after 为第一个值严格大于目标的下标。目标存在时,相等元素恰好位于半开区间 [first,after),题目要求的闭区间答案就是 [first,after-1]

bound(strict) 每次都从 [left,right)=[0,n) 开始。strict=False 时,左侧已确认小于目标,右侧已确认大于等于目标;相等值让 right=midstrict=True 时,左侧改为小于等于目标,右侧改为严格大于目标;相等值也让 left=mid+1。两次循环的差别只有等号划入哪边,边界相遇时返回分界线。

先执行 bound(False),并确认 first 在数组内且确实指向 target。下界也可能只是插入位置,若它等于 n 或值不相等,就返回 [-1,-1];空数组也在这一步得到正确结果。目标存在时,再执行 bound(True),将其返回值减一作为最后一次出现的位置。

不变量

bound(False) 保持左边已确认 <target、右边已确认 ≥target;bound(True) 把左边条件改为 ≤target、右边改为 >target。两个分界分别给出 first 和末次出现位置之后一格,因此右端答案要减 1。

图解

1 / 11
bound(False):找第一个 ≥2leftright102122232445667 尾后待判定元素 [0, 7)target = 2本轮比较 / 不变量相等时 right 收缩,保留当前 2。两次都从 [0,7) 开始。
strict
False
left
0
right
7
查找目标
第一个 ≥2
选择步骤 11 步
变量与图例

想象给所有 2 圈出一个半开区间:左切口在第一个 2 前,右切口在第一个大于 2 的元素前。

strict
False 查第一个 ≥target;True 查第一个 >target,决定相等时往哪边走。
left / right / mid
每次边界查找的半开区间与中点,两次查找均重新初始化。
first
左边界查找结果;确认它确实指向 target 后,再找右切口。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 实现半开边界查找 bound(strict),每次初始化 left=0、right=n。
  2. 中点值小于 target,或 strict=True 且值等于 target 时,令 left=mid+1;否则令 right=mid。
  3. 先调用 bound(False) 得到 first;若 first==n 或 nums[first]≠target,返回 [-1,-1]。
  4. 目标存在时,从完整区间重新调用 bound(True),得到第一个大于目标的分界线。
  5. 将第二次查找的返回值减一作为末下标,与 first 组合为答案。

代码(Python)

查看完整 Python 代码两次半开边界查找 · 与上方图解对应

bound(strict) 的 strict=False 求≥目标,strict=True 求>目标;相等元素朝哪边分配是唯一差别。

PYTHON 3
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),仅边界与返回的两个下标。
03 / 检查边界

易错点与边界

  • 命中后向左右线性扫描,全部相等时会退化到 O(n)。
  • after 是排他边界,返回末下标时要减1。
  • 空数组是合法输入,两种写法都返回 [-1,-1]。
  • 不要仅检查 first<n;还必须确认 nums[first]==target。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

闭区间命中后继续偏向一侧时间 O(log(n+1)),两个闭区间搜索各自缩半。

分别用闭区间查找左右端点,ans 保存已证实命中的候选。中点不等于目标时按普通二分排除;命中后先保存 mid,再继续向左或向右寻找更极端的命中。它不计算相等段外侧的分界线,最终直接返回两个实际端点;始终未命中时 ans 保持 -1。

  1. 为一次端点查找初始化 left=0、right=n-1、ans=-1,循环条件为 left≤right。
  2. 中点值小于 target 就令 left=mid+1,大于 target 就令 right=mid-1。
  3. 命中时令 ans=mid;找左端就继续令 right=mid-1,找右端就令 left=mid+1。
  4. 区间为空时返回 ans;分别执行向左、向右两次查找,组合成答案。
PYTHON 3
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),无需扫描区间。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 把一段相等值的范围转成两个单调分界,避免命中后向两侧线性扫描。
  2. 相等分支是两次二分的唯一关键区别:下界保留 mid,上界排除 mid。
  3. first==n 或 nums[first]!=target 表示目标不存在;先确认这一点,再组合左右答案。
← 返回题库
二分查找66 / 100

33. 搜索旋转排序数组 中等

标签:数组 · 二分查找

力扣原题 ↗
01 / 理解题目

一句话题意

在无重复值的递增数组旋转结果中,用 O(log n) 找到目标下标,不存在返回 −1。

教学示例

nums = [6,7,0,1,2,4,5],target = 0 → 下标 2。本例先识别右半有序,再识别左半有序。

生活化理解

一排编号书被从中间搬到最前面,整排虽然断开,却只有一个接缝;每次从中间分两段,至少一段仍按号码排好。

02 / 讲透主解法

一次二分识别有序半段

每轮识别有序的一半并判断目标归属,可在原数组上一次二分完成搜索。

核心思路

维护目标可能存在的闭区间 [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]。无重复元素的旋转数组中,中点两侧至少一半有序;用这一半的端点范围判断目标归属,才能合法丢弃另一半。

图解

1 / 9
旋转断点位于 7 与 0 之间leftright60710213244556候选下标 [0, 6]target = 0本轮比较 / 不变量整体不是递增序列;不能直接套普通二分方向。每轮先命中检查,再判断哪一半有序。
left
0
right
6
target
0
选择步骤 9 步
变量与图例

这条队伍只在一个地方折断了顺序。每次先找到没有折断的半边,再判断目标是否落在它的数值范围里。

left / right
目标仍可能位于的闭区间两端。
mid
本轮中点;先判断是否命中,再识别有序半段。
有序半段
nums[left]≤nums[mid] 时左半有序,否则右半有序。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 设 left=0、right=n-1,在 left≤right 时取 mid,命中目标就立即返回 mid。
  2. 比较 nums[left] 与 nums[mid],判断左半是否有序。
  3. 左半有序时,用 nums[left]≤target<nums[mid] 决定保留左边还是右边。
  4. 否则右半有序,用 nums[mid]<target≤nums[right] 决定保留右边还是左边。
  5. 按所选方向更新 right=mid-1 或 left=mid+1;候选为空仍未命中则返回 -1。

代码(Python)

查看完整 Python 代码一次二分识别有序半段 · 与上方图解对应

left、right 是闭区间候选;每轮先判断哪半有序,再决定目标所属,不需要真的旋转回来。

PYTHON 3
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) 辅助空间。
03 / 检查边界

易错点与边界

  • nums[left]≤nums[mid] 必须带等号,单元素半段也有序。
  • 判断目标落入有序段时,要区分已排除的 mid 和仍保留的端点。
  • 题设无重复且非空;若扩展为空,两份代码均会跳过循环并返回−1。
  • 若允许重复值,首中尾相等时不能判定断点,最坏会退化为 O(n)。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

找旋转点 + 虚拟有序数组二分时间 O(log n),找旋转点与搜索各一次二分,n 为题设非空数组长度。

先求最小值下标 pivot,再把数组视为从 pivot 起循环读取的有序序列。虚拟下标 mid 对应原数组 (mid+pivot)%n,后续便可使用普通二分。它把寻找接缝与查找目标分成两个阶段,便于复用;全过程只映射下标,不复制或实际旋转数组,最终返回原数组下标。

  1. 用闭区间 [0,n-1] 找最小值,循环条件 left<right;若 nums[mid]>nums[right] 就令 left=mid+1,否则令 right=mid。
  2. 两边界相遇后令 pivot=left,重新初始化虚拟搜索闭区间 [0,n-1]。
  3. 每轮计算虚拟中点 mid 和 actual=(mid+pivot)%n,读取 nums[actual]。
  4. 值等于 target 时返回 actual;值偏小向右二分,值偏大向左二分,均越过 mid。
  5. 虚拟候选区间为空时返回 -1。
PYTHON 3
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在哪边,只能逐步缩边;这是信息不足,而不是换一个中点公式就能解决。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 先命中判断,再识别有序半段,最后用目标是否落在该值域决定方向。
  2. 左半区间判断含 left、不含 mid;右半判断不含 mid、含 right,因为 mid 已确认不等。
  3. “至少一半有序”的直接判定依赖无重复元素;重复值可能让端点比较无法识别折点。
← 返回题库
二分查找67 / 100

153. 寻找旋转排序数组中的最小值 中等

标签:数组 · 二分查找

力扣原题 ↗
01 / 理解题目

一句话题意

在无重复值的旋转递增非空数组中,用 O(log n) 找到最小值。

教学示例

nums = [4,5,6,7,0,1,2] → 最小值 0(位于下标 4)。

生活化理解

把递增山坡剪开后交换两段:最低点是唯一向下跳后的起点。拿中点和右端比,能知道自己站在跳跃前还是跳跃后。

02 / 讲透主解法

与动态右端比较

始终保留包含最小值的区间,只需与当前右端比较,收敛条件和保留中点的理由明确。

核心思路

闭区间 [left,right] 始终包含最小值下标。参考值是当前的 nums[right],右边界移动后下一轮也要读取新的参考值。题设元素互不相同,且 left<right 时中点严格位于右端之前,因此中点值与右端值不会相等。

nums[mid]>nums[right],中点位于旋转后的高段,断点与最小值一定在中点右侧,令 left=mid+1。否则从 midright 没跨断点,这段自身递增,最小值可能正是 mid,也可能更靠左,令 right=mid。此处不能越过中点,因为它仍是答案候选。

循环只在 left<right 时继续。这里寻找一个一定存在的最小值,每轮都留下至少一个候选;两边界相遇时直接返回 nums[left],而不是返回下标或等待区间变空。未旋转数组同样适用:右边界会不断收缩到首项。

不变量

最小值的下标始终属于闭区间 [left,right]。nums[mid]>nums[right] 说明 mid 还在旋转后的高段,最小值严格在右侧;否则从 mid 到 right 有序,最小值只能在 mid 或它左边,所以保留 mid。

图解

1 / 8
初始化闭区间 [0,6]leftright40516273041526候选下标 [0, 6]本轮比较 / 不变量本轮右端参考值为 nums[6]=2。先找 mid,再与当前 right 的值比较。
left
0
right
6
右端值
2
选择步骤 8 步
变量与图例

用当前右端作为温度计:mid 比右端高,断崖还在右边;mid 不比右端高,最小值可能就是 mid。

left / right
包含最小值下标的闭区间,right 是动态边界。
mid
本轮比较位置;left<right 时 mid<right。
nums[right]
每轮都更新的右端参考值,不能偷偷固定为原数组末值。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 设 left=0、right=n-1,使最小值位于当前闭区间内。
  2. 当 left<right 时取中点,比较 nums[mid] 与当前 nums[right]。
  3. 中点值大于右端值,就令 left=mid+1;否则令 right=mid,保留中点作为候选。
  4. 只剩一个候选位置时,返回 nums[left]。

代码(Python)

查看完整 Python 代码与动态右端比较 · 与上方图解对应

right 不只是数组终点,也是当前候选区间内的参考值;与它比较可判断中点和最小值是否跨越断点。

PYTHON 3
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) 辅助空间。
03 / 检查边界

易错点与边界

  • R=mid 保留可能的最小值,写成 mid−1 会跳过答案。
  • 题目保证非空,空数组没有最小值,不能随意约定返回0。
  • 单元素与完全未旋转都是合法输入。
  • 当前依赖无重复条件;重复版 nums[mid]==nums[right] 时只能谨慎缩右端,最坏线性。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

固定首项,找布尔分界时间 O(log n),n 为非空数组长度;未旋转时 O(1)。

以 nums[0] 为固定基准。确实发生旋转时,数组先是值大于等于基准的高段,再是值小于基准的低段,低段第一个位置就是最小值。未旋转时不存在低段,所以先比较首尾并直接返回首项;之后才能在 [1,n-1] 内寻找这个保证存在的布尔分界。适合从单调条件理解二分,但不能省略未旋转分支。

  1. 若 nums[0]≤nums[-1],数组已递增或只有一项,直接返回 nums[0]。
  2. 否则设 left=1、right=n-1,在包含低段起点的闭区间内二分。
  3. 若 nums[mid]≥nums[0],中点仍在高段,令 left=mid+1;否则保留中点,令 right=mid。
  4. 两边界相遇时返回 nums[left],即第一个低于原首项的值。
PYTHON 3
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] 的替代解明确先排除未旋转情况,才获得单调判定。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 与搜索指定值不同,较小的 mid 可能就是最小值,不能一律 mid−1。
  2. 一支用 left=mid+1,另一支用 right=mid,两支都严格缩小候选闭区间。
  3. 循环用 left<right,结束时返回 nums[left];返回的是值,不是下标。
← 返回题库
二分查找68 / 100

4. 寻找两个正序数组的中位数 困难

标签:数组 · 二分查找 · 分治

力扣原题 ↗
01 / 理解题目

一句话题意

给定两个非递减数组且总长度非零,求合并后的中位数,目标时间 O(log(m+n))。

教学示例

nums1 = [1,2,3,4,20,21],nums2 = [5,6,7,8,9,10] → 6.5。分割线会先右移、再左移。

生活化理解

把两队已按身高排好的学生各划一条线,左组总人数固定为一半;只要左组最高的人不比右组最低的人高,中间身高就从两条线旁边读出。

02 / 讲透主解法

短数组上的分割线二分

固定左右人数后只检查四个边界值,能在短数组上用对数时间直接定位中位数。

核心思路

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≤brbl≤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,就能保证所有左侧值不大于所有右侧值。违反哪条交叉关系,就单调地移动短数组分割线。

图解

1 / 8
总共 12 个数,左半区固定取 6 个a10213243204215b5061728394105二分对象:a 的分割线 i ∈ [0,6];i+j 必须等于 half=60123456a 左侧末项al = ·a 右侧首项ar = ·b 左侧末项bl = ·b 右侧首项br = ·交叉检查:al ≤ br 且 bl ≤ ar还未选择 i、j,四个边界值待计算。i 可以从 0 到 6;j 总是 6−i。总长度 12,左半区固定 6 个;切口条:紫为本轮,黄为待试,灰为已排除。先更新候选范围,下一轮再计算 i、j 和四个边界值;· 表示尚未重算。
m
6
n
6
half
6
left
0
right
6
选择步骤 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,不是普通元素下标。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 将较短数组作为 a,读取 m、n,设 half=(m+n+1)//2,并初始化切口闭区间 [0,m]。
  2. 在 left≤right 时取 i=(left+right)//2,令 j=half-i,固定左组总人数。
  3. 读取 al、ar、bl、br,空左侧用负无穷,空右侧用正无穷。
  4. 若 al>br,令 right=i-1;否则若 bl>ar,令 left=i+1,下一轮重新计算切口与四个边界值。
  5. 否则分割合法:奇数长度返回左组最大值,偶数长度返回左组最大值与右组最小值的平均。

代码(Python)

查看完整 Python 代码短数组上的分割线二分 · 与上方图解对应

i 和 j 是进入左组的元素数量;四个边界哨兵统一处理一个数组为空、分割线位于端点的情况。

PYTHON 3
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),只交换引用并保存边界,不做切片或合并。
03 / 检查边界

易错点与边界

  • i=0 表示 A 左边为空;i=m 表示 A 右边为空,不可直接读 a[−1] 或 a[m]。
  • 总长度允许一奇一偶、也允许一个数组为空;两者同时为空不在题设内。
  • 一定在短数组上二分,否则 j 可能越界。
  • 相等边界是合法分割,比较应使用≤。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 2

迭代排除前缀,寻找第 k 小时间 O(log(m+n)),每轮将 k 缩半或耗尽一个数组;调用两次第 k 小不改变量级。

kth(k) 用 i、j 表示两个尚未排除后缀的起点,k 是这两个后缀合在一起后的第 k 小排名,从 1 开始。每轮比较各自至多 k//2 个元素的末项,较小末项所在的那段前缀可以先排除,再按实际排除数量减少 k。它把中位数推广成任意第 k 小查询,同样无需切片;若一个后缀不足 k//2 项,不能仍按 k//2 扣减。

  1. 一次 kth 调用初始化 i=j=0;若某数组耗尽,直接从另一个后缀取偏移 k-1 的元素。
  2. 若 k==1,返回两个剩余首项的较小值。
  3. 否则计算 ai=min(i+k//2,m)-1、bj=min(j+k//2,n)-1,确定两段候选前缀的末项。
  4. 若 a[ai]≤b[bj],将 k 减去 ai-i+1 并令 i=ai+1;否则按 bj-j+1 减少 k 并移动 j。
  5. 重复直到得到第 k 小;分别查询排名 (total+1)//2 与 (total+2)//2,取平均作为中位数。
PYTHON 3
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,偶数长度取二者平均。它直观且只需常数空间,但仍是线性时间,不满足本题的对数时间目标。

  1. 初始化两个读取下标 i、j,以及 previous、current。
  2. 执行 total//2+1 轮;每轮先令 previous=current。
  3. 若第一数组未耗尽,且第二数组耗尽或第一候选不大于第二候选,就读取第一数组并移动 i;否则读取第二数组并移动 j。
  4. 读取结束,奇数长度返回 current,偶数长度返回 (previous+current)/2。
PYTHON 3
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;可将该前缀作为较小元素先消费,重复值不会改变数值排名结果。若面试只要求正确结果,可先给归并基线,再说明如何利用有序性跳过成段元素实现对数复杂度。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 人数条件 i+j=half 与顺序条件两条交叉不等式缺一不可。
  2. al>br 时 i 过大,right=i−1;bl>ar 时 i 过小,left=i+1。
  3. 合法分割后,奇数长度取左侧最大值;偶数取左侧最大与右侧最小的均值。
← 返回题库
69 / 100

20. 有效的括号 简单

标签:栈 · 字符串 · 括号序列

力扣原题 ↗
01 / 理解题目

一句话题意

判断仅含三种括号的字符串能否按正确类型和嵌套顺序全部配对。

教学示例

s = "{[()]()}" → True。依次处理 8 个字符,可看到深层嵌套和同层的第二对 ()。

生活化理解

像打开套娃:最后打开的那一层必须最先合上。栈顶是最新尚未闭合的左括号,只和它配对才能保持嵌套。

02 / 讲透主解法

栈保存未匹配左括号

栈直接表达最后打开的括号必须先闭合,一次扫描同时约束类型和嵌套顺序。

核心思路

从左向右扫描,stack 从底到顶保存已读前缀中尚未匹配的左括号,顺序就是它们出现的顺序。pairs 把右括号映射为所需的左括号;题目输入只有六种括号,因此不在这个映射中的字符就是左括号,直接入栈。

读到右括号时,唯一允许与它匹配的是栈顶。若栈为空,说明它没有对应的左括号;否则弹出最近打开的左括号,与 pairs[ch] 比较。类型不同就立即返回 False,因为这个前缀已经违反嵌套顺序,后面的字符不能修复它;匹配时更早的一层自然成为新栈顶。

读完还要检查栈为空,否则有尚未闭合的左括号。计数只能保证数量相等,不能保证类型与顺序,例如 ([)] 各种括号数量平衡却交叉配对。栈保存的正是计数丢失的历史次序,每个括号至多入栈、出栈一次。

不变量

每次扫描后,stack 从底到顶恰好保存已读前缀尚未匹配的左括号。右括号只有与栈顶类型相同才能配对;否则这个前缀已违反嵌套顺序,后面的字符无法修复。

图解

1 / 10
完整输入与初始空栈{0[1(2)3]4(5)6}7栈保存尚未匹配的左括号;下方是栈底空栈 []栈底配对表:右括号 → 所需左括号')' → '('']' → '[''}' → '{'尚未读取操作前:[]操作后:[]下一个字符是 {。计数只记数量;栈还保留“最后打开的必须先闭合”的次序。
已读前缀
''
stack
[]
下一个下标
0
选择步骤 10 步
变量与图例

把栈看成“尚未关上的盒子”:最后打开的盒子在最上面,右括号只能关闭栈顶那一个。

ch
当前读取的括号;图中紫色输入格是本帧刚处理的字符。
stack
尚未匹配的左括号,按打开先后排列,末尾是栈顶。
pairs[ch]
当前右括号要求匹配的左括号类型。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 建立右括号到左括号的 pairs 映射,并初始化空 stack。
  2. 依次读取字符;若它是左括号,就压入栈顶。
  3. 若它是右括号且栈为空,立即返回 False;否则弹出栈顶,与 pairs[ch] 比较,类型不同也返回 False。
  4. 全部字符处理完后返回 not stack,只有没有遗留左括号才成功。

代码(Python)

查看完整 Python 代码栈保存未匹配左括号 · 与上方图解对应

pairs 将右括号映射为所需左括号;栈顶检查同时约束类型与嵌套顺序。

PYTHON 3
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。
03 / 检查边界

易错点与边界

  • 数量相等不是充分条件,([)] 就是反例。
  • 右括号到来时要先判断栈非空,不能直接读取空栈顶。
  • 结束时遗留左括号也应失败,不能只检查扫描中的右括号。
  • 题设字符只含括号;代码不添加额外字符处理。空串扩展下两种写法均返回 True。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

反复消去相邻合法对时间 最坏 O(n²),例如深度 n/2 的同类嵌套,每轮只消去最内层一对且重新扫描字符串。

有效嵌套的最内层必有相邻的 ()、[] 或 {},消去这些配对后,剩余结构仍然有效。反复归约到空串即可判定成功;若某轮完全没有变化却仍非空,就不存在可消去的合法内层。它适合演示嵌套结构的归约,但 Python 字符串不可变,每轮 replace 都重新扫描和创建字符串,深嵌套最坏需要 O(n²) 时间。

  1. 只要当前字符串非空,就依次用 replace 消去所有相邻的 ()、[]、{},得到 reduced。
  2. 若 reduced 与本轮输入相同,说明没有任何可消去的合法对,返回 False。
  3. 否则令 s=reduced,继续归约更外层的配对。
  4. 最终字符串变空时返回 True。
PYTHON 3
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 的 ) 时为何立即失败?

一点提示

此时栈顶是 [,而不是更早入栈的 (。

答案与理由

右括号 ) 要求栈顶为 (,实际弹出的是 [,类型不匹配。虽然每种括号总数相等,配对顺序却交叉,无法跳过栈顶去匹配更底层的括号。

面试追问与解法取舍

也可以让栈存期望的右括号:见 ( 就压入 ),遇右括号直接与栈顶比较。这与主解等价;单类型括号验证可退化成计数器,多类型仍需保存嵌套次序。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 栈记录尚未解决的嵌套次序;单纯计数不足以检查多类型括号。
  2. 右括号先检查栈是否为空,非空时再弹出并检查类型;不匹配即返回 False。
  3. 全部字符处理结束后仍需 not stack,防止遗留未闭合的左括号。
← 返回题库
70 / 100

155. 最小栈 中等

标签:栈 · 设计

力扣原题 ↗
01 / 理解题目

一句话题意

设计支持 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。

生活化理解

每叠上一只箱子,就附一张便条写下“从底到这里最轻箱子的重量”;拿走顶箱时,对应便条一起离开,下面那张立即恢复历史最小值。

02 / 讲透主解法

数据栈 + 稀疏最小栈

完整数据与最小值历史各司其职,重复最小值的图解能直接说明何时同步入栈和出栈。

核心思路

只保存一个全局最小值不够:弹出当前最小元素后,还需要知道之前的最小值。这里用 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],两栈同步弹出一份历史;重复最小值必须重复记录,才能正确恢复。

图解

1 / 13
先看完整操作序列,再逐个执行1. push(3)2. push(1)3. push(1)4. push(2)5. getMin()6. pop()7. getMin()8. pop()9. getMin()10. top()11. pop()12. getMin()values:完整数据栈mins:最小值历史空 []栈底空 []栈底当前接口返回值None(修改接口)可直接读取的摘要当前最小值:尚无值数据项数:0历史项数:0同步规则values 与 mins 都为空;下面将出现两个连续入栈的最小值 1。
values
[]
mins
[]
下一接口
push(3)
选择步骤 13 步
变量与图例

数据栈保存每件箱子,最小栈只保存“刷新或持平最小值”的历史事件。尤其观察两个 1 怎样各自保留一份记录。

values
完整数据栈,底部最早入栈,顶部是最近入栈元素。
mins
稀疏最小栈,只保存刷新或持平最小值的入栈事件。
val
当前 push 的值,或 pop 从数据栈弹出的值。
top / getMin
分别直接读 values[-1] 与 mins[-1],都不遍历栈。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 初始化完整数据栈 values 与稀疏最小栈 mins。
  2. push(val) 时先压入 values;若 mins 为空或 val≤mins[-1],再向 mins 压入一份 val。
  3. pop() 时先从 values 取出 val;仅当 val 等于 mins[-1] 时,让 mins 同步弹出一项。
  4. top() 返回 values[-1];getMin() 返回 mins[-1],两个查询均不修改栈。

代码(Python)

查看完整 Python 代码数据栈 + 稀疏最小栈 · 与上方图解对应

mins 仅记录刷新或持平最小值的入栈事件;出栈时按值决定是否同步撤销最小值事件。

PYTHON 3
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 项。
03 / 检查边界

易错点与边界

  • 最小栈入栈条件必须是≤,只用<会丢失重复最小值的历史。
  • 不能在 getMin 中每次调用 min(values),那会变成 O(n)。
  • 题设保证查询与出栈时非空;负数、重复值、出空后重新入栈都应正确。
  • 稀疏最小栈只有在弹出值等于 mins[-1] 时才同步弹出一份记录;弹出更大值时仅修改 values,mins 保持原样。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

每层保存值与历史最小值时间 抽象栈操作 O(1);Python list 的 push/pop 为摊还 O(1),top/getMin 为 O(1)。

每一栈层保存 (value,min_so_far),第二项是从栈底到该层的最小值。每次 push 都保存一份对应深度的最小值快照,较大的新值也会复制旧最小值;pop 整层后,下一层自带正确历史,无需按弹出值决定是否更新另一个栈。它便于理解逐层同步的摘要,代价是每个元素固定附带一份最小值,而不是只记录刷新或持平事件。

  1. 初始化一个空 stack,每项准备保存值与历史最小值的二元组。
  2. push(val) 时,空栈令 minimum=val,否则取 min(val,旧栈顶的第二项),再压入 (val,minimum)。
  3. pop() 直接弹出整个二元组,值与该层最小值快照一起撤销。
  4. top() 返回栈顶二元组的第一项,getMin() 返回第二项。
PYTHON 3
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() 弹出完整二元组,已经同时移除了值与该层历史最小值。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 把查询时的扫描成本提前到修改时,用历史摘要支持 O(1) 查询。
  2. 最小栈 push 使用 ≤,pop 用值相等判断同步;两个等号共同处理重复最小值。
  3. 稀疏并不代表常数空间;递减或全相等输入时,mins 仍可与数据栈一样长。
← 返回题库
71 / 100

394. 字符串解码 中等

标签:栈 · 递归 · 字符串

力扣原题 ↗
01 / 理解题目

一句话题意

展开形如 k[内容] 的合法编码,支持多位重复次数和任意合法嵌套。

教学示例

s="2[a3[b]]c" → "abbbabbbc"

生活化理解

像收拾套娃礼盒:进入内盒时,把外盒已经装好的礼物片段及“复制几份”的订单压在桌角;内盒装完,复制后接回外盒。栈保存暂停的外层,当前片段列表保存正在装的这一层。

02 / 讲透主解法

显式栈 + 片段列表

把每层的片段和重复次数显式保存,嵌套字符串的恢复顺序容易画清,也容易复现。

核心思路

一层只管理自己的内容。parts 是当前括号层已经解码的字符串片段列表,count 是正在读取的重复次数,stack 保存尚未完成的外层现场。数字通过 count = count * 10 + int(char) 累积,所以多位次数不会被拆成几次独立重复。

遇到 [,把当前的 (parts, count) 压栈,再用新的空列表与零次数进入内层。遇到 ],先把内层片段合成 body,再弹栈恢复外层 partsrepeat,最后把 body * repeat 作为一个完整片段接回外层。先保存内层结果再恢复外层,这个次序不能颠倒。

栈从底到顶恰好对应尚未闭合的左括号,因此后进先出会先完成最内层。图中的 2[a3[b]]c 先得到 bbb,接成 abbb 后才做外层的两次重复,尾部 c 只追加一次。列表收集片段避免逐字反复拼接长前缀,但每层的合并与重复仍会复制字符,不能忽略输出规模与嵌套复制的代价。

不变量

每次处理字符后,parts 保存当前最内层已经解码的片段。stack 从底到顶保存尚未完成的外层片段及其重复次数;右括号只弹最近一层,因而不会串层。

图解

1 / 13
嵌套解码:字符游标 + 当前层 + 外层栈帧20[1a233[4b5]6]7c8手头正在拼的这一层当前 parts = []当前字符串 = (空)count = 0stack:下方是栈底,上方是栈顶空栈读到 [ 时保存外层;读到 ] 时恢复外层。
parts
[]
stack
[]
count
0
选择步骤 13 步
变量与图例

把每对括号当作一张待结账的小票:先结内层,再把整笔小计按倍数记回外层。

char
当前字符;上方箭头指出它在完整输入中的下标。
parts
当前层已经解码的片段列表,不等同于一个字符列表。
count
正在读取的重复次数;进新层时清零。
stack
元素是 (外层 parts, 重复次数),栈顶是最近遇到的左括号。
body / repeat
遇到 ] 时得到的内层完整字符串,以及弹栈取出的次数。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 初始化 stack=[]、parts=[]、count=0,从左到右逐字符扫描。
  2. 遇到数字,用 count=count*10+int(char) 累积完整的重复次数。
  3. 遇到左括号,把 (parts,count) 压入栈,再令 parts=[]、count=0;遇到普通字母则直接追加到当前 parts。
  4. 遇到右括号,先 join 当前 parts 得到 body,再弹栈恢复外层 parts 和 repeat。
  5. 把 body*repeat 作为一个片段追加到恢复后的 parts,继续扫描同一输入。
  6. 扫描结束后,join 最外层 parts 并返回。

代码(Python)

查看完整 Python 代码显式栈 + 片段列表 · 与上方图解对应

stack 保存外层列表和重复数。恢复外层时仅追加一个完成的内层片段,避免使用 prefix += char 反复复制前缀。

PYTHON 3
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)。
03 / 检查边界

易错点与边界

  • 次数不是单个字符,必须十进制累积;进入新层后重置。
  • 外层前缀与内层文本分开保存,不能在左括号处丢掉前缀。
  • 题目保证语法有效、次数为正,不需要额外设计错误语法恢复。
  • str 的 += 和乘法都不能当成常数操作;嵌套会多次复制同一最终字符。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

递归下降解析时间 O(n+C),C 为递归各层构造中间字符串的复制总量;保守 O(n+dL)。

让 parse() 负责解出一层,使用调用栈保存外层现场。共享游标 i 指向尚未消费的字符;每次 parse() 返回时,i 停在本层右括号上,或者已经到达字符串末尾。遇到数字时,当前调用先读完整 repeat、跨过左括号,再递归取得 body;内层返回后由当前调用跨过对应右括号并追加 body*repeat。它适合从语法层次理解嵌套解析,但要留意递归深度;片段合并与重复的字符复制开销仍然存在。

  1. 令共享游标 i=0,定义 parse(),每次调用建立自己的 parts=[]。
  2. 只要 i 未到末尾且当前字符不是右括号,就继续处理当前层。
  3. 若遇到数字,循环累计完整 repeat 并推进 i,随后再推进一次,消费左括号。
  4. 递归调用 parse() 得到内层 body;返回后推进 i,消费刚才停留的右括号,再追加 body*repeat。
  5. 若遇到普通字母,追加到本层 parts,并把 i 加一。
  6. 遇到本层右括号或输入末尾时返回 join(parts);最外层调用的结果就是答案。
PYTHON 3
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 的深链以免重复遍历开销。常见两种逐层构造解法应诚实报告中间复制成本。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 栈保存“回来以后继续做什么”,嵌套结构才能从内到外完成。
  2. 先收拢 body,再恢复外层,再追加 body×repeat,三个动作不可乱序。
  3. 使用片段列表避免逐字符反复拼接;复杂度仍须计入真实解码输出的长度。
← 返回题库
72 / 100

739. 每日温度 中等

标签:栈 · 数组 · 单调栈

力扣原题 ↗
01 / 理解题目

一句话题意

对每天计算距离下一次严格更高温度的天数,若以后没有则填 0。

教学示例

temperatures=[73,71,71,74,72,75] → [3,2,1,2,1,0]

生活化理解

像一排等涨价通知的人:每个人记住登记日和当日价格,新价格出现时,只通知队尾那些价格更低的人。被通知者等待天数就是新日减登记日;还没被通知的人继续留在栈里。

02 / 讲透主解法

从左向右单调栈

把还没找到更暖天气的旧日期放入待办栈,新温度如何结算等待天数一目了然。

核心思路

stack 存下标而不是温度,因为答案需要的是相隔天数。栈内每个下标都代表“到目前为止还没有找到更暖的一天”,对应气温从栈底到栈顶单调不增。相同温度可以同时留在栈里,因为题目要求严格升温。

扫描到下标 i、温度 t 时,只要 t 严格大于栈顶旧温度,就弹出旧下标 j,写入 answer[j] = i - j。此时 i 就是最近的更暖日:如果此前已经出现过更暖的一天,j 早已被弹出,不会一直等到现在。

一次高温可能让多天连续出栈,所以必须反复检查新栈顶,最后才压入当前下标 i。处理中的答案 0 表示尚未结算;扫描结束后仍为 0,才表示以后没有更暖日。虽然有内层循环,每个下标只入栈一次、出栈至多一次,总操作数仍是线性的。

不变量

栈保存尚未找到更暖日的下标,对应气温从底到顶单调不增。当前温度能弹出的每个下标,都把当前日视作第一个更暖日,因此答案一旦写入就不必修改。

图解

1 / 13
完整气温数组:只有严格更暖的一天才能结算730711712743724755未结算单调栈:栈底 → 栈顶(存下标,旁注气温)answer:需要等待几天;尚未找到更暖时暂为 0000102030405这次比较 / 结算尚无出栈结算栈内下标:[]等温不会触发出栈绿色答案已固定;保留的下标继续向右等。
stack
[]
answer
[0, 0, 0, 0, 0, 0]
选择步骤 13 步
变量与图例

像一叠等通知的便签:新来的高温先通知最近的低温,通知完还要继续往下看。

i / t
当前天的下标与温度。
stack
待结算下标栈;图中同时标出对应气温。
j
本次弹出的旧下标。
answer
等待天数;处理过程中 0 表示待定,结束后 0 表示不存在。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 把 answer 初始化为与气温数组等长的全 0 列表,建立空下标栈 stack。
  2. 从左到右枚举当前下标 i 与温度 t。
  3. 当栈非空且 t 严格大于栈顶所指温度时,弹出 j,并写 answer[j]=i-j;重复检查新的栈顶。
  4. 无法继续结算后,将当前下标 i 压栈,等待后续更暖的一天。
  5. 扫描结束返回 answer,未能出栈日期对应的答案保持 0。

代码(Python)

查看完整 Python 代码从左向右单调栈 · 与上方图解对应

保存未解决日期的下标;新的一天负责结算若干旧日期。严格 > 保留相等温度。

PYTHON 3
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)。
03 / 检查边界

易错点与边界

  • 答案是下标差,不能用温度差。
  • 递减序列和最后一天都可能一直没有更暖日,保留 0。
  • 弹出的是旧日,不能把等待天数写入当前日。
  • 从左向右扫描时,相同温度不能结算;只有当前温度严格更高才弹出旧下标。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

从右向左单调栈时间 O(n),每个候选只进出栈一次。

从未来往今天扫描,stack 保存右侧仍有可能成为答案的候选下标。与主解结算旧日期不同,这个方法在处理 i 时直接填写 answer[i]。先弹掉温度小于等于今天的候选:它们既不能回答今天的问题,对于更左侧的日期又被更近且不冷的今天取代。弹完以后,若栈非空,栈顶就是最近的严格更暖日。这里的弹栈条件是 <=,不能照搬主解的 >。

  1. 初始化全 0 的 answer 与空栈,从最后一天下标向左扫描。
  2. 反复弹出温度小于等于 temperatures[i] 的栈顶候选。
  3. 若弹栈后仍有候选,写 answer[i]=stack[-1]-i;否则当前答案保持 0。
  4. 把 i 压栈,供更左侧的日期选择。
  5. 全部日期处理后返回 answer。
PYTHON 3
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] 更热。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 找右侧第一个更大元素时,把尚未解决的下标保存在单调栈里。
  2. 比较值,保存下标,出栈后才能计算 i−j。
  3. 区分 > 与 >= 对等值元素的处理;摊还分析给出 O(n) 时间。
← 返回题库
73 / 100

84. 柱状图中最大的矩形 困难

标签:栈 · 数组 · 单调栈 · 区间最值查询

力扣原题 ↗
01 / 理解题目

一句话题意

在宽度均为 1 的非负高度柱状图中,求被柱子完全覆盖的最大矩形面积。

教学示例

heights=[2,1,5,6,2,3] → 10(高度 5,覆盖下标 2、3)

生活化理解

像把一块水平招牌放在连续围栏上:招牌高度由覆盖范围里最矮的木板限制。每块木板都可以试做“最低板”,往两边扩到遇见更矮木板为止;单调栈帮我们等到右边界出现再统一结账。

02 / 讲透主解法

单调栈:下降时结算

在更矮柱出现时一次确定候选矩形的右边界,线性扫描与宽度公式都便于说明。

核心思路

最优矩形的高度可以取它覆盖范围中的最矮柱高,因此逐个检查柱高作为限制高度不会漏掉答案。stack 保存高度单调不减的下标,底部 -1 是虚拟左边界;best 保存已经结算的最大面积。较高柱暂时留在栈内,等待更矮的右边界出现。

当前高度 current 比栈顶矮时,弹出栈顶并取其高度 h。当前下标 i 在右边界外,弹栈后的新栈顶也在本次候选矩形左边界外,所以宽度是 i - stack[-1] - 1。用高度乘宽度更新 best,并继续检查是否还要弹出更高柱。

原实现只在高度严格更大时弹栈,等高柱可以共存。新栈顶因此是左侧不高于弹出柱的保留边界,不能一律称作“严格更小”。较右的等高柱可能先结算窄范围,较早的等高柱会继续承接更宽范围,最大值不会遗漏。最后额外处理一次虚拟高度 0,清算剩余正高度柱;它由 current 计算得到,不会修改原数组。

不变量

栈中的真实柱高单调不减;栈顶柱遇到更矮的 current 时,其右侧可扩展范围在 i−1 截止。弹栈后的新栈顶作为排除边界,配合 i−left−1 得到候选宽度;等高柱可由较早下标继续承接更宽范围。

图解

1 / 15
下降时结算:矩形左右边界都不包含在宽度里20115263243506(虚拟)stack:栈底 −1 是左边界哨兵,后面保存真实下标-1边界本次矩形怎么算当前没有出栈矩形best = 0left 是弹栈后的新栈顶right 是触发下降的当前 i−1 让最左矩形也能使用同一个宽度公式。原 heights 不变;最右 0 仅由 current 计算得到。
stack
[-1]
best
0
n
6
选择步骤 15 步
变量与图例

把较高柱当作尚未封口的帐篷:直到更矮的柱挡在右边,才知道这顶帐篷有多宽。

i / current
当前下标与高度;i=n 时 current=0 是虚拟右哨兵。
stack
保存下标,开头 -1 是左边界哨兵。
h
本次出栈柱的高度。
left / right
图中的教学名称:left=弹栈后的 stack[-1],right=i,都不包含在矩形里。
best
已经结算过的最大矩形面积。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 令 stack=[-1]、best=0,记录原柱子数量 n。
  2. 令 i 从 0 扫描到 n;i<n 时 current=heights[i],i=n 时 current=0,作为右侧虚拟哨兵。
  3. 当栈顶不是 -1 且栈顶柱高大于 current 时,弹出下标并取高度 h。
  4. 以弹栈后的 stack[-1] 为左排除边界,用 h*(i-stack[-1]-1) 更新 best,重复检查栈顶。
  5. 把当前 i 压栈,进入下一轮;处理完虚拟哨兵后返回 best。

代码(Python)

查看完整 Python 代码单调栈:下降时结算 · 与上方图解对应

用 -1 简化左侧没有柱子的情况,末尾虚拟 0 负责清算;相等高度保留,最左等高柱承担最大宽度。

PYTHON 3
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) 下标栈;不复制或修改输入数组。
03 / 检查边界

易错点与边界

  • 宽度必须减 1,左右边界柱不属于当前矩形。
  • 严格/非严格比较要与等高柱的归属方案一致,不能混用边界解释。
  • 单调递增输入也需要末尾清算,否则大矩形一直留在栈里。
  • 高度可以是 0,零高度仅分隔正高度矩形,面积也可能为 0。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

左右边界跳跃时间 O(n),两趟跳跃对应维护单调候选链,候选被绕过后不再回到后续链中。

先为每根柱子分别求左右两侧第一根严格更矮柱的位置,保存为 left[i] 与 right[i],再统一计算面积。求左边界时从 j=i-1 出发,若 heights[j]>=heights[i],就直接跳到已经算好的 left[j]:被跨过的整段都不比当前柱矮,不可能成为目标边界。右边界对称地从右向左计算,用 right[j] 跳跃。这种写法把每根柱子的完整边界显式保存,适合需要复用边界信息的场景;这里必须用 >= 跳过等高柱,才能得到严格更小边界。

  1. 建立 left=[-1]*n、right=[n]*n,分别用 -1 与 n 表示找不到更矮柱。
  2. 从左向右枚举 i,令 j=i-1;当 j 有效且 heights[j]>=heights[i] 时令 j=left[j],最后记录 left[i]=j。
  3. 从右向左枚举 i,令 j=i+1;当 j 有效且 heights[j]>=heights[i] 时令 j=right[j],最后记录 right[i]=j。
  4. 对每个 i 计算 heights[i]*(right[i]-left[i]-1),取最大值返回。
PYTHON 3
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. 出栈时再结算,是因为更矮的当前柱给出了右边界。
  2. 宽度用“右排除边界−左排除边界−1”,别把当前矮柱算进去。
  3. 左右哨兵统一边界处理;每个下标至多入栈、出栈一次,时间 O(n)。
← 返回题库
74 / 100

215. 数组中的第K个最大元素 中等

标签:数组 · 分治 · 快速选择 · 排序 · 堆(优先队列)

力扣原题 ↗
01 / 理解题目

一句话题意

返回数组排序后第 k 大的元素,重复值按出现次数计入排名。

教学示例

nums=[3,2,1,5,0,6,4], k=3 → 4

生活化理解

像选比赛前 k 名但只问最后一个入围分数:小根堆是一间只有 k 个座位的候选室,门口站着其中分数最低的人;快速选择则像按一条分数线把人分成低、等、高三组,只去包含目标排名的那一组继续找。

02 / 讲透主解法

大小为 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 名门槛”会正确随前缀更新。

图解

1 / 10
保留目前最大的 3 个数:小根堆的根是入围门槛30211253046546切片后的数组(尚未建堆)3下标 02下标 11下标 2当前 Top 3 候选保留值(降序观察):[3, 2, 1]已纳入扫描:nums[0:3]原始堆数组:[3, 2, 1]切片 [3,2,1] 包含 3 个候选,但根 3 还不是最小值。堆不是完整排序;只保证根最小。返回根就得到第 3 大。
k
3
heap
[3, 2, 1]
阶段
切片,尚未 heapify
选择步骤 10 步
变量与图例

把堆想成只有 3 个座位的决赛席:门口站着最弱的入围者,新人只需挑战它。

heap
Python heapq 的真实数组表示;根位于下标 0。
i
当前扫描的原数组下标,从 k 开始。
k
需要保留的候选个数,本例为 3。
Top 3 / 已处理前缀
图上的教学观察值;原代码无需另存一份排序列表。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 复制 nums 的前 k 个元素到 heap,再调用 heapify 建立小根堆。
  2. 从下标 k 开始,依次扫描剩余 nums[i]。
  3. 若 nums[i] 大于 heap[0],调用 heapreplace(heap,nums[i]) 淘汰堆根并纳入新数;否则保持堆不变。
  4. 完整扫描后返回 heap[0],它是保留的 k 个最大元素中的最小值。

代码(Python)

查看完整 Python 代码大小为 k 的小根堆 · 与上方图解对应

前 k 项建堆,之后只有比堆顶更大的值才替换门槛。重复元素作为不同出现保留,不先去重。

PYTHON 3
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 项;输入不变。
03 / 检查边界

易错点与边界

  • 第 k 大不是第 k 个不同值,不能先转 set。
  • 小根堆保留的是 k 个最大候选,答案是 heap[0];堆数组本身不是完整排序,不能按普通数组排名取值。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 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 的排列。

  1. 令 target=len(nums)-k,候选闭区间初始化为 lo=0、hi=len(nums)-1。
  2. 在当前区间随机选 pivot,令 lt=i=lo、gt=hi。
  3. 当 i<=gt:小于 pivot 的元素与 lt 交换并同时增加 lt、i;大于 pivot 的元素与 gt 交换并只减少 gt;等于 pivot 时只增加 i。
  4. 划分完成后,若 target<lt,令 hi=lt-1;若 target>gt,令 lo=gt+1。
  5. 若 target 落在 [lt,gt] 的等值段,直接返回 pivot;否则在缩小后的区间继续划分。
PYTHON 3
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) 最坏临时空间。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 求第 k 大可以保留 k 个最大值,用小根堆暴露最弱候选。
  2. heapreplace 既替换根又恢复堆序;堆数组本身并非全局有序。
  3. 建堆 O(k),后续每次替换 O(log k),额外空间 O(k)。
← 返回题库
75 / 100

347. 前 K 个高频元素 中等

标签:数组 · 哈希表 · 分治 · 桶排序 · 计数 · 快速选择 · 排序 · 堆(优先队列)

力扣原题 ↗
01 / 理解题目

一句话题意

统计整数出现频次,返回出现最频繁的 k 个不同元素,输出顺序不限。

教学示例

nums=[4,4,1,1,1,2,3], k=2 → [4,1](顺序可交换)

生活化理解

像票选热门菜:先把每道菜的所有选票合成一张计票单,再按票数放入编号抽屉。第 f 个抽屉装恰好 f 票的菜;从大编号往下取,拿够 k 道菜就结束。

02 / 讲透主解法

计数 + 小根堆

先把频次与数值分开,再复用 Top k 堆模板,能够清楚解释候选的保留和淘汰规则。

核心思路

先计数,再按频次选候选。counts = Counter(nums) 把每种不同数值映射到出现次数。随后遍历的是 counts.items():每种值只作为一个候选处理一次,而不是把它的每次出现都放进堆。

heap 存放 (frequency, value)。每轮先压入当前元组,若长度超过 k,就弹出最小元组。因为频次是第一关键字,堆根暴露的是最少见的候选;每轮结束后,堆保留已处理数值中频次最高的至多 k 项。

元组第二项只是 Python 在频次相等时用于比较的字段,不表示题目要求按数值大小排名。最后只提取元组的 value 返回,堆内顺序无需再排序。图中 4 出现 2 次、1 出现 3 次,频次更低的 23 会在堆超过容量时被弹出,最终得到 [4,1]

不变量

每处理完一种不同值,heap 保留已处理值中频次最大的至多 k 项。堆以 (frequency,value) 排序,超过 k 时弹出最小频次,因此不会丢失最终应留下的高频候选。

图解

1 / 9
次数优先:堆元素是 (frequency, value),不是原数值大小40411213142536真实小根堆:元组先比较频次空堆Counter 的全部统计(按首次出现顺序) 值 4:出现 2 次 值 1:出现 3 次 值 2:出现 1 次 值 3:出现 1 次heap = []4→2 次,1→3 次,2→1 次,3→1 次。
counts
{"4": 2, "1": 3, "2": 1, "3": 1}
k
2
选择步骤 9 步
变量与图例

把每种商品的销量写在名牌上,再让名牌参加 Top 2;商品编号大小不参与主排名。

counts
value→出现次数的完整统计表。
value / frequency
本轮处理的一种数及它的出现次数。
heap
真实的 (频次,数值) 元组堆;频次是第一关键字。
k
需要返回的不同值个数。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 用 Counter(nums) 汇总每个不同元素的出现次数,建立空堆 heap。
  2. 依次枚举 counts.items() 中的 value 与 frequency。
  3. 将 (frequency,value) 压入小根堆,确保频次位于元组第一项。
  4. 若 len(heap)>k,弹出一次堆根,恢复至多 k 个候选的容量。
  5. 所有不同元素处理后,提取堆中每个元组的 value,组成答案返回。

代码(Python)

查看完整 Python 代码计数 + 小根堆 · 与上方图解对应

计数表确保候选去重,堆只保留当前前 k 高频元素;无需对全部 m 个不同元素排序。

PYTHON 3
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)。
03 / 检查边界

易错点与边界

  • 堆接收的是计数后的不同元素,直接把 nums 入堆会重复选择。
  • 返回顺序不要求排序;原题唯一集合与扩展题允许并列任选应区分。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

计数 + 频次桶时间 O(n+m)=O(n),计数、分桶和倒序扫描均线性。

利用频次只能落在 1 到 n 之间这一性质,直接用频次当桶下标。counts 保存数值到次数的映射,buckets[f] 保存所有恰好出现 f 次的不同元素。填桶后从 n 向 1 扫描;进入某个桶时,所有更高频元素都已优先选过。逐个加入 answer,达到 k 个立即返回,避免把整个同频桶一起加入而超出数量。此法用 n+1 个桶换取线性扫描,适合从频次范围有限的角度理解本题。原题保证答案集合唯一,返回排列仍不作要求。

  1. 建立 counts=Counter(nums),再建立长度 len(nums)+1 的空列表桶数组 buckets。
  2. 对每个 value、frequency,把 value 追加到 buckets[frequency]。
  3. 建立 answer=[],按 frequency 从 len(nums) 递减到 1 扫描桶。
  4. 逐个把当前桶里的 value 加入 answer,每加入一个就检查数量。
  5. 当 len(answer)==k 时立即返回,不继续加入当前桶余下元素。
PYTHON 3
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 个计数项排序。频次实时变化时,静态堆条目不会自动更新,不能把这个离线实现直接当成动态排行榜。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 先压缩成频次表,再对不同值做 Top k。
  2. 堆元素的第一个字段决定排序目标,元组次序会改变算法含义。
  3. 若有 m 种不同值,计数 O(n),维护候选 O(m log(k+1));返回顺序无需有序。
← 返回题库
76 / 100

295. 数据流的中位数 困难

标签:设计 · 双指针 · 数据流 · 排序 · 堆(优先队列)

力扣原题 ↗
01 / 理解题目

一句话题意

设计支持不断添加整数并查询当前所有数中位数的数据结构。

教学示例

依次 addNum(5)、addNum(2)、addNum(8)、addNum(1),每次 findMedian() → [5,3.5,5,3.5]

生活化理解

像让两队人按身高站在门两侧:左队保存较矮的一半,门边能立即看到左队最高的人;右队保存较高的一半,门边看到右队最低的人。人数一样就平均两位门卫,左边多一人就由左门卫代表中位数。

02 / 讲透主解法

双堆维护两半

用两个堆顶保存中间分界,既适合持续插入,也能直接解释奇偶两种中位数查询。

核心思路

把数据分成左右两半,维护两个条件:左半每个真实值都不大于右半每个值;lowhigh 等大,或 low 多一个。low 用真实值的相反数模拟大根堆,所以左半最大值是 -low[0]high 是普通小根堆,右半最小值是 high[0]

插入 num 时先把 -num 压入 low,再弹出左半最大真实值并移入 high。这样新数不会破坏两半的值顺序。若右半数量更多,再把右半最小值移回左半。上述条件在一次插入全部完成后成立;图中搬移的中间步骤可能暂时失衡,不能在那个状态下解释中位数。

查询时,若左半多一个,它的最大值就是中位数;若两半等大,取两个堆顶真实值的平均。只需读取堆顶,因为它们正好位于两半分界处。堆数组自身不是完整排序,其他数组下标不能当作排名;同一个搬移流程也能处理重复值与负数。

不变量

每次 addNum 完成后,左半所有值都不大于右半所有值;low 与 high 等大,或 low 多一个。图会明确显示操作中暂时不满足这些条件的状态,只有平衡完成才查询中位数。

图解

1 / 12
两个堆:左半最大值 / 右半最小值,在根部相邻50218213low:逻辑大根堆,实际存负数high:普通小根堆,实际存正数空堆空堆Python 中真正保存的数组low = []high = []平衡状态与答案大小:0 / 0搬移中,尚未查询中位数每次插入完成后,low 与 high 等大,或 low 多 1 个。
low
[]
high
[]
已加入
[]
选择步骤 12 步
变量与图例

把排好队的人分成左右两半,只让左半最高者和右半最矮者站在门口,中位数就在两扇门之间。

num
这一次加入数据流的真实数值。
low
实际是负数小根堆;-low[0] 才是左半最大真实值。
high
右半普通小根堆,high[0] 是右半最小真实值。
大小 / 每次中位数
图上的数量与查询记录,用于观察完成状态;代码不另存历史。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 初始化 self.low=[]、self.high=[];low 存储真实值的相反数。
  2. 每次 addNum(num),先把 -num 压入 low。
  3. 从 low 弹出最小存储值并取相反数,将得到的左半最大真实值压入 high。
  4. 若 high 比 low 长,再弹出 high 的最小值,取相反数压回 low。
  5. findMedian() 时,若 low 更长则返回 -low[0];否则返回 (-low[0]+high[0])/2。

代码(Python)

查看完整 Python 代码双堆维护两半 · 与上方图解对应

固定让 low 多一个或两边一样大,避免查询时处理多种方向;负号只用于 low 的内部存储。

PYTHON 3
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),两个堆共同保存所有元素。
03 / 检查边界

易错点与边界

  • low 使用负数,取堆顶和跨堆转移都要恢复正确符号。
  • 这里的负数存储指对真实值取相反数;原值为负时,存入 low 的数反而为正。
  • 大小平衡不足以保证中位数正确,还必须保持左半所有值 <= 右半。
  • 偶数长度用 / 而不是 //,负数平均值也可能带 .5。
  • 题目保证查询前至少添加一个数;这里只实现规定接口。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

二分插入有序列表时间 添加最坏 O(n):二分 O(log n),后缀移动 O(n);查询 O(1)。

直接让 values 始终保持全局升序。addNum 使用 insort 找到合适位置并插入,findMedian 则根据 n 的奇偶读取中间一个元素或两个元素的平均。它没有双堆分界与平衡过程,适合作为小规模数据或推导时的直观基线。但二分查找位置不等于整个插入是对数时间:Python 列表还要移动后续元素,单次插入仍有 O(n) 代价。

  1. 初始化空有序列表 self.values。
  2. 每次 addNum(num),调用 insort(self.values,num),插入后列表仍保持升序。
  3. 查询时令 n=len(values)、middle=n//2。
  4. 若 n 为奇数,返回 values[middle];若为偶数,返回 values[middle-1] 与 values[middle] 的平均值。
PYTHON 3
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. 双堆同时维护“值有序分半”和“数量相差至多 1”两个条件。
  2. 负数存储实现大根堆;跨堆时要换符号,读取中位数时也要还原。
  3. 插入只做常数次堆操作,O(log n);查询只看根,O(1)。
← 返回题库
贪心算法77 / 100

121. 买卖股票的最佳时机 简单

标签:数组 · 动态规划

力扣原题 ↗
01 / 理解题目

一句话题意

只能先买后卖一次,求最大非负利润;没有赚钱机会时可以不交易。

教学示例

prices=[7,1,5,3,6,4] → 5(第 1 天买,第 4 天卖,下标从 0 开始)

生活化理解

像每天查看机票转卖价格,但只能买一次卖一次:决定今天卖时,只关心过去能买到的最低价。小本子上记 min_price,利润纪录记 best;未来价格不能回头成为今天的买入价。

02 / 讲透主解法

历史最低价贪心

以卖出日为枚举轴,把最优买入日压缩为历史最低价,两个变量就能讲清单笔交易。

核心思路

以“今天卖出”为枚举轴。每轮处理下标 i 之前,min_price 是此前所有日期的最低价格。对于任何今天卖出的交易,最好的买价必然是这个最低价,所以当天只需检查候选利润 prices[i] - min_price

best 保存已经考察过的卖出日里能够得到的最大利润,初值 0 对应不交易。先用当天候选更新 best,再把当天价格纳入 min_price,使买入日早于卖出日的含义直接体现在更新顺序中。

最低买价只会降低或不变,历史最高利润只会提高或不变,它们不是同一本账。图中价格跌到 1 时只是改善了未来的买价,并没有产生盈利;最后一天的候选利润回落到 3,也不会覆盖此前已经得到的最高利润 5

不变量

每轮开始,min_price 是今天之前的最低价;计算今天卖出的利润后,best 是截至今天的最优单笔利润。最后再把今天价格纳入最低价,交给未来的卖出日使用。

图解

1 / 8
今天卖出赚多少?历史最低买价与全局最高利润分开看7011523364457第0天1第1天5第2天3第3天6第4天4第5天买价与利润的两本账min_price = 7(第0天)best = 0本日价 − 旧最低价:本帧未重新比较还没有卖出日;允许不交易,所以 best=0。
min_price
7
best
0
最低价所在日
0
选择步骤 8 步
变量与图例

像随手记两本账:一本记至今最便宜的买价,另一本记已经见过的最高可赚金额。

i
当前被考虑为卖出日的下标。
min_price
截至上一天的最低买价;本轮末尾纳入今天。
best
已经考察过的所有卖出日中的最高利润。
最低价所在日 / 最佳交易
图中用于解释的日期标注,原实现不另存日期。
候选利润
prices[i]−min_price 的本次计算值,可能为负。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 令 min_price=prices[0]、best=0,从第二天开始枚举下标 i。
  2. 用之前的最低价计算当天卖出的候选利润 prices[i]-min_price,并更新 best。
  3. 再令 min_price=min(min_price,prices[i]),把今天纳入未来可选择的买入日。
  4. 全部日期处理后返回 best;若没有正利润,保留不交易对应的 0。

代码(Python)

查看完整 Python 代码历史最低价贪心 · 与上方图解对应

min_price 只总结历史买入候选,best 总结已经完成的交易。两个标量足够,无需列举买卖日期对。

PYTHON 3
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:] 切片。
03 / 检查边界

易错点与边界

  • 只能一笔交易,不能把所有正相邻差相加,那是允许多次交易的另一题。
  • 买点必须在卖点之前,全局最低价和最高价直接相减会忽略时间顺序。
  • 单天、全部下跌、全部相等都应返回 0。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

差分上的 Kadane时间 O(n),差分按需计算,不额外构造数组。

买入日 b 到卖出日 s 的利润,等于相邻价格差从 b+1 到 s 的连续区间和,因此可以转成最大连续子数组和。ending 表示以当前差分结尾的最优非负区间和,best 表示所有已见区间中的最大值;负的累计收益直接清零,相当于丢掉不值得继承的亏损前缀。代码直接计算 prices[i]-prices[i-1],不需要另建差分数组。这个视角适合迁移到最大子数组模板。

  1. 初始化 ending=best=0,从 i=1 开始处理相邻两天的价差。
  2. 令 ending=max(0,ending+prices[i]-prices[i-1]),负收益前缀在这里被清零。
  3. 用当前 ending 更新全局 best。
  4. 扫描结束返回 best;空交易允许结果为 0。
PYTHON 3
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 时保存当时买点和当前卖点。若改为最多两次交易,需要增加交易次数状态;单个历史最低价无法表达已经卖出后再买入的情形。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 枚举卖出日,最优买入日可压缩为此前最低价。
  2. 先比较今天卖出,再更新今天成为未来买价,含义更清楚。
  3. 全局答案与当天候选分开;允许不交易令 best 初始为 0。
← 返回题库
贪心算法78 / 100

55. 跳跃游戏 中等

标签:贪心 · 数组 · 动态规划

力扣原题 ↗
01 / 理解题目

一句话题意

从下标 0 出发,每格数字是最大向右跳长,判断是否能到达最后一格。

教学示例

nums=[2,3,1,1,0,2,0] → False(最远只能到下标 4)

生活化理解

像踩跳板过河:已经能够踩到的一段河面形成连续覆盖区,每块可达跳板都能把覆盖最右端推远。只要下一块跳板仍在覆盖内,就能借它继续扩展;若脚下已经越过覆盖,就再也没有桥能接过去。

02 / 讲透主解法

正向最远覆盖

可达位置能够压缩为连续前缀,只检查当前起点与最远边界就能说明成功或断点。

核心思路

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,就证明可达前缀已经中断。

图解

1 / 10
已能到达的范围20311213042506已确认可达 [0,0]far=0判断依据尚未扫描far=0先检查 i≤far,再使用 nums[i]只知道站在下标 0;尚未借用 nums[0] 的跳跃能力。
far
0
终点
6
选择步骤 10 步
变量与图例

像沿途接力扩展一张通行证:通行证没覆盖到的驿站,即使装备再好也借不到。

i / length
当前下标与从这里最多能跳的步数。
far
已经确认可达的最远下标,范围是闭区间 [0,far]。
本位置最远
教学显示的 i+length;只有起点可达时才可纳入 far。
终点
len(nums)−1,本例为 6。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 初始化 far=0,表示起点已经可达。
  2. 从左到右枚举 i 和 length=nums[i],先检查 i 是否超过 far;若超过,立即返回 False。
  3. 当前起点可达时,令 far=max(far,i+length) 扩展最远覆盖。
  4. 若 far>=len(nums)-1,立即返回 True。
  5. 若循环正常结束,按原实现返回 True。

代码(Python)

查看完整 Python 代码正向最远覆盖 · 与上方图解对应

只有可达位置才能扩展 far,先检查再更新至关重要。到终点即停止,无需寻找具体跳法。

PYTHON 3
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) 辅助空间。
03 / 检查边界

易错点与边界

  • 数值表示最多跳多远,不是必须跳这么远。
  • 不能让不可达下标的超大跳长更新 far。
  • 0 不一定导致失败,可能直接跳过;单元素 [0] 已在终点。
  • 只要覆盖 >= n-1 即成功,不要求恰好等于终点。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

反向移动目标时间 O(n),从倒数第二格扫描到起点。

从终点向左找能够接力的位置。goal 表示已扫描后缀中,已知能够通向终点的最左下标。若 i+nums[i]>=goal,当前位置可以选择跳到 goal,再沿已有接力通向终点,因此把 goal 左移到 i;否则保留旧目标。它始终维护一个确实能通向终点的位置,最后检查起点是否也被纳入,关注点与主解的前缀覆盖不同。

  1. 令 goal=len(nums)-1,先把终点本身设为可接力目标。
  2. 从倒数第二个位置向左枚举 i。
  3. 若 i+nums[i]>=goal,令 goal=i;否则保持 goal 不变。
  4. 扫描完成后返回 goal==0,判断起点是否已经能接上通往终点的链条。
PYTHON 3
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 的关键是“可选任意不超过上限的跳长”,可达集合连续;若只允许固定跳长或存在禁止落脚格,该不变量需重新分析。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 可达集合可压缩成连续前缀 [0,far],无需记录所有路径。
  2. 先验证当前起点可达,再扩展最远边界,顺序决定正确性。
  3. 遇到 0 不一定失败;只有当前扫描点越过所有已有覆盖时才失败。
← 返回题库
贪心算法79 / 100

45. 跳跃游戏 II 中等

标签:贪心 · 数组 · 动态规划

力扣原题 ↗
01 / 理解题目

一句话题意

在保证最后一格可达的前提下,求从下标 0 到终点的最少跳跃次数。

教学示例

nums=[2,3,1,1,2,1,1] → 3(一条最短路径:0→1→4→6)

生活化理解

像公交换乘圈:零次换乘只能站在起点,一次乘车能到一个覆盖圈;把这个圈里每个站能接上的线路都看完,就得到下一圈。只有扫完整圈,换乘次数才加一,不能看到更远线路就立刻加次数。

02 / 讲透主解法

贪心:压缩 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 层数增长。

图解

1 / 11
本轮边界 end 与已见最远 far 分开显示203112132415160 跳内已覆盖 [0,0]end = 0本轮已扫描的最远候选far = 0判断依据初始化jumps=0;end=0;far=0只有 i==end 才完成一层并加一次跳跃end=0 是零次跳跃的边界;先扩展,再在边界结算。
jumps
0
end
0
far
0
选择步骤 11 步
变量与图例

想象一队人同时向前探路:far 记录目前最远的报告,end 规定这一轮什么时候收齐报告。

i
本轮正在检查的起点,不代表实际路径一定落在这里。
end
当前已经完成的 jumps 次跳跃能覆盖的最右边界。
far
原代码中的变量名;记录本轮已见的最远下一层候选。
jumps
已经完成的扩层次数,也就是最少跳数。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 初始化 jumps=end=far=0,零次跳跃只覆盖起点。
  2. 用 range(len(nums)-1) 扫描到倒数第二个位置,每格先更新 far=max(far,i+nums[i])。
  3. 若 i 不等于 end,继续扫描当前层,暂不增加 jumps。
  4. 若 i==end,先把 jumps 加一,再将 end 更新为 far,完成一次扩层。
  5. 循环结束返回 jumps;终点已被所需的最少层数覆盖。

代码(Python)

查看完整 Python 代码贪心:压缩 BFS 层边界 · 与上方图解对应

end 锁定当前层,far 汇总下一层。两者不能随意合并;只有 end 被扫描完时才增加一次跳跃。

PYTHON 3
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),只保存层边界和计数。
03 / 检查边界

易错点与边界

  • 只遍历到 n-2,否则终点处可能多计一次跳跃。
  • 不能每次 far 变大就 jumps++,必须等整层扫描结束。
  • 本题明确保证可达,不把不可达数组混入接口预期。
  • 单元素数组答案为 0;最大跳长不代表应始终跳满。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

动态规划:枚举前驱时间 O(n²),每个目标位置枚举更早的起跳点;大输入可能超时,是比较基线。

用 dp[i] 显式表示到达下标 i 的最少跳数。对于每个 i,枚举所有 j<i;只要 j+nums[j]>=i,就存在从 j 再跳一次到 i 的转移,候选为 dp[j]+1。因为只能从更小下标向右转移,计算 i 时所需的 dp[j] 已经算完。n 作为初始大值,不会优于有效路径。这个方法适合先从“最后一步来自哪里”推导问题,再理解主解如何把大量前驱压缩成层边界,但枚举全部前驱需要二次时间。

  1. 令 n=len(nums),初始化 dp=[n]*n,并令 dp[0]=0。
  2. 从 i=1 到 n-1 依次计算到达每个位置的最少跳数。
  3. 对当前 i 枚举所有 j<i,检查 j+nums[j]>=i。
  4. 若 j 能跳到 i,令 dp[i]=min(dp[i],dp[j]+1)。
  5. 所有位置计算完成后返回 dp[-1]。
PYTHON 3
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 且未到终点时才能判定无法继续,不能在层中途误报。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 最短跳数可用 BFS 层边界压缩为 end、far、jumps。
  2. 更新 far 与推进 end 是两件事;只有扫到旧 end 才增加一次跳跃。
  3. 题目保证终点可达;循环到 n−2,避免在终点多记一跳。
← 返回题库
贪心算法80 / 100

763. 划分字母区间 中等

标签:贪心 · 哈希表 · 双指针 · 字符串

力扣原题 ↗
01 / 理解题目

一句话题意

把字符串切成尽可能多段,使每个字母最多出现在其中一段,返回各段长度。

教学示例

s="abacbde" → [5,1,1],对应 "abacb" | "d" | "e"

生活化理解

像把同一家人的行李打包到同一车厢:第一次看到某个姓氏,就知道必须等到这个姓氏最后一件行李到来才能封箱。等待期间遇到新姓氏,又可能把封箱时刻推迟;所有已见姓氏都到齐时,立即封箱才能装出最多车厢。

02 / 讲透主解法

末次位置 + 最早合法切分

最后出现位置给出片段必须等待的终点,尽早合法切分的贪心证明清楚且实现简短。

核心思路

先记 last[char] 为每个字符最后出现的下标。start 是当前段起点,扫描到 i 时,end 是当前段所有已见字符的末次位置最大值;它代表这些字符共同要求的最晚终点。每读到一个字符,就用 end = max(end, last[char]) 更新这个承诺。

i < end,至少有一个本段已出现字符还会出现在后面,此处切分必然让它跨段。若 i == end,当前段所有字符都已经出现完,后面不会再有这些字符,可以记录长度 i-start+1 并把 start 移到 i+1。不能只看段首字符的末位,也不能被后来字符较早的末位缩短已有边界。

最早合法切分会使段数最多。任何合法方案的第一刀都不能早于贪心得到的 end;若切得更晚,把它提前到这个边界不会让字符跨段,只会保留或增加后续切分机会。固定这一刀后,对剩余后缀重复同样的判断,就得到全局最优的分段。

不变量

在当前片段内,end 始终是所有已见字符最后出现位置的最大值。到 i==end 时,这一段涉及的每个字符都已出现完毕,因此可合法切开;尽早切分使片段数最多。

图解

1 / 12
当前片段必须包住它见过的每个字符的最后一次出现a0b1a2c3b4d5e6未封口 [0,0]startend=0last:完整末次位置表 a → 最后下标 2 b → 最后下标 4 c → 最后下标 3 d → 最后下标 5 e → 最后下标 6片段账本start=0;end=0已经封口的长度:[]下一段的字符不能再回到前段a 最后在 2,b 在 4,c 在 3,d 在 5,e 在 6。
last
{"a": 2, "b": 4, "c": 3, "d": 5, "e": 6}
start
0
end
0
answer
[]
选择步骤 12 步
变量与图例

把每个字母看成一个家庭:一段车厢里来了某家成员,就必须等这家的最后一个人也上车才能关门。

last
完整字符→末次下标映射,遍历前一次性建立。
i / char
当前扫描位置与字符。
start
尚未封口片段的起点,切分后移到 i+1。
end
当前片段所有已见字符要求覆盖的最远末次位置。
answer
已经封口的片段长度列表。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 遍历字符串建立每个字符的最后出现位置 last,初始化 answer=[]、start=end=0。
  2. 从左到右枚举 i 与 char,每步令 end=max(end,last[char])。
  3. 若 i<end,继续扫描当前段,等待所有已见字符的最后出现。
  4. 若 i==end,追加当前段长度 i-start+1,再令 start=i+1 开始下一段。
  5. 遍历结束返回所有片段长度 answer。

代码(Python)

查看完整 Python 代码末次位置 + 最早合法切分 · 与上方图解对应

last 汇总字符对片段右边界的要求;end 随扫描只会右移,达到它时立即切分。

PYTHON 3
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(段数)。
03 / 检查边界

易错点与边界

  • 片段长度为 end-start+1,区间两端都包含。
  • 只看段首字符的末次位置不够,中途出现的字符也会扩张边界。
  • 同一字符遍布整串时只得一段;所有字符不同则每字符一段。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

字符区间合并时间 O(n+Σ log Σ),Σ 是不同字符数;小写字母限制下为 O(n)。

把每个字符从第一次到最后一次出现的位置记成区间 ranges[char]=[first,last]。同一个字符的全部出现必须在一段里;两个这样的区间只要重叠,也必须属于同一片段。把所有字符区间按左端点排序,再合并重叠区间,得到的每个合并结果就是一个片段。start、end 保存当前合并区间,left、right 是下一个区间。它适合迁移到区间合并模板;相邻但不重叠的区间不必合并,所以条件是 left<=end,而不是 left<=end+1。

  1. 扫描字符串:首次见到字符时建立 [i,i],再次见到时只更新该字符区间的右端点。
  2. 将 ranges.values() 排序为 intervals,用第一个区间初始化 start、end,并建立 answer=[]。
  3. 依次处理剩余区间 left、right;若 left<=end,令 end=max(end,right) 合并重叠。
  4. 若 left>end,先记录 end-start+1,再用当前 left、right 开始新的合并区间。
  5. 循环结束后补记最后一个区间的长度,并返回 answer。
PYTHON 3
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 段,先算最大段数,再比较即可;需要更少段时,合并相邻合法段始终仍合法。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 先查清全局末次位置,局部扫描才能判断何时允许切分。
  2. 边界只取 max 扩张,不能被当前字符更早的末位缩回。
  3. i==end 是最早合法切分点;片段长度是 i−start+1。
← 返回题库
动态规划81 / 100

70. 爬楼梯 简单

标签:记忆化 · 数学 · 动态规划

力扣原题 ↗
01 / 理解题目

一句话题意

每次向上走 1 或 2 级,计算到达第 n 级楼梯的不同走法数,走法按步长顺序区分。

教学示例

n=6 → 13 条走法。教学表从 f[0]=1 一直推到 f[6]=13。

生活化理解

把每一级看成接收旅行路线的车站:到第 i 站的最后一班车只能来自 i−1 或 i−2,两个来源的路线互不重叠,直接相加。

02 / 讲透主解法

滚动动态规划

最后一步的分类容易证明,两个滚动变量就能在线性时间内完成计算,适合面试复现。

核心思路

f[i] 表示恰好到达第 i 级的走法数。最后一步只能走 1 级或 2 级:去掉最后一步后,分别对应到达第 i−1 级和第 i−2 级的走法。两类互不重叠且覆盖全部路线,所以 f[i]=f[i−1]+f[i−2]

边界是 f[0]=1f[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]。

图解

1 / 12
级数从左向右 →;· 尚未计算。整行仅用于回看,源码只存 a、b。
级数 k0123456
f[k](图解记录)11·····
n
6
a = f[0]
1
b = f[1]
1
选择步骤 12 步
变量与图例

每次先看琥珀色的两个旧值,再看紫色新格;下方的小表区分“右侧算好”和“写回变量”。

n
目标级数,本例为 6。
i
本轮刚要计算的级数,从 2 到 n。
a / b
循环开始时保存前两级路线数;写入后一起向前滚动一级。
f[k](讲解记号)
到达第 k 级的路线数;历史表并非源码中的数组。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 初始化 a=b=1,分别对应 f[0] 与 f[1]。
  2. 令 i 从 2 遍历到 n,读取本轮旧 a 与旧 b。
  3. 同时执行 a,b=b,a+b,让 b 成为第 i 级的走法数,a 保留第 i−1 级的值。
  4. 循环结束返回 b;n=1 时直接返回初始值 1。

代码(Python)

查看完整 Python 代码滚动动态规划 · 与上方图解对应

a、b 保存相邻的两个状态;递推只读这两个数,因此不必保留整张表。Python 的同时赋值保证两个新值都来自旧状态。

PYTHON 3
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) 位,无额外输出数组。
03 / 检查边界

易错点与边界

  • 不要把 f[0]=1 理解成额外增加了一种到终点的路线,它是递推边界。
  • 走 1、2 与走 2、1 顺序不同,因此是两种走法。
  • 分两行先更新 a 再计算 b 会污染旧状态,使用同时赋值。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 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) 指矩阵运算次数,大整数乘法本身仍有成本。

  1. 定义 mul,对两个 2×2 矩阵逐行逐列求乘积。
  2. 令 result 为单位矩阵,base=Q,power=n。
  3. power 的最低位为 1 时,将当前 base 乘入 result。
  4. 把 base 平方,再将 power 右移一位;重复直到 power 为 0。
  5. 返回 result[0][0],即 Qⁿ 的左上角。
PYTHON 3
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 通项不适合作为精确整数答案的通用实现。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 计数递推要说明来源不重叠:用最后一步区分路线。
  2. 滚动前先写清每个变量对应的下标,再推导更新顺序。
  3. 空路线 f[0]=1 是边界;最少步数则应取最小,不能继续加路线数。
← 返回题库
动态规划82 / 100

118. 杨辉三角 简单

标签:数组 · 动态规划

力扣原题 ↗
01 / 理解题目

一句话题意

生成杨辉三角的前 numRows 行;每行两端为 1,内部元素等于上一行相邻两个元素之和。

教学示例

numRows=5 → [[1],[1,1],[1,2,1],[1,3,3,1],[1,4,6,4,1]]。

生活化理解

像把筹码沿三角形分岔轨道往下送:每个位置收到左上和右上的筹码总和;边缘只有一条路线,所以始终为 1。

02 / 讲透主解法

按上一行构造二维结果

每个内部格都能直接对应上一行两个来源,输出本身保存状态,最容易讲清并逐格检查。

核心思路

从 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 的写入不改变这些来源;本行全部完成后才追加,下一轮继续成立。

图解

1 / 13
从上到下逐行,行内从左到右 →;· 尚未推导(新 row 源码先填 1),— 该行不存在此格。
行 r / 列 c01234
r=0·
r=1··
r=2···
r=3····
r=4·····
numRows
5
ans
[]
选择步骤 13 步
变量与图例

紫格永远从上一行的两个琥珀格收集数值;留意新 row 何时才被追加到 ans。

r
从 0 开始的当前行号,第 r 行有 r+1 个值。
c
内部列号,遍历 1 到 r−1;首尾不进入循环。
row
本轮新建的列表;源码初始全是 1,内部值随后覆盖。
ans
已经完成并保存的各行,是下轮读取的历史结果。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 创建空结果 ans,令行号 r 从 0 到 numRows−1。
  2. 为本行新建长度 r+1、全部为 1 的 row,使两端自然满足边界。
  3. 令内部列 c 从 1 到 r−1,读取 ans[r−1] 的 c−1、c 两格相加并写入 row[c]。
  4. 将完整 row 追加到 ans,再开始下一行。
  5. 所有行完成后返回 ans。

代码(Python)

查看完整 Python 代码按上一行构造二维结果 · 与上方图解对应

ans 存储已完成各行;row 是本次新建的行。只访问 ans[r−1],不需要额外 DP 数组,因为输出本身就保存了状态。

PYTHON 3
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)。
03 / 检查边界

易错点与边界

  • numRows=1 时只返回 [[1]],第 0 行已算第一行。
  • 不要用同一个可变 row 对象反复加入结果后继续修改,否则历史行会一起变化。
  • 内部列只有 1 到 r−1;边界不应读取上一行的越界元素。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 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)。先乘后整除由组合数恒等式保证结果精确,若先做除法可能被整数截断。这个方法适合解释杨辉三角与二项式系数的联系,仍需保存所有行作为输出。

  1. 创建 ans;每个新行 r 都从空 row 和 value=1 开始。
  2. 令 c 从 0 到 r,将当前 value=C(r,c) 追加到 row。
  3. 若 c<r,先乘 r−c,再整除 c+1,计算下一项 value。
  4. 将完成的 row 加入 ans,最后返回全部行。
PYTHON 3
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 的副本。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 先确定边界,再逐个填内部格;不要把初始化值误当推导结果。
  2. 来源都在上一行,行内更新不互相污染。
  3. 每轮 row 是新对象;全部行是输出所需空间。
← 返回题库
动态规划83 / 100

198. 打家劫舍 中等

标签:数组 · 动态规划

力扣原题 ↗
01 / 理解题目

一句话题意

给出一排房屋的非负金额,不能选择相邻房屋,求能取得的最大总金额。

教学示例

nums=[2,7,9,3,1] → 12;可偷下标 0、2、4,金额 2+9+1。

生活化理解

像排班时不能连上相邻两个夜班:决定接下今天的班,就只能接在前天以前的最优排班后;拒绝今天,则沿用昨天以前的最优方案。

02 / 讲透主解法

前缀 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] 覆盖,取最大既不漏方案也不允许相邻房屋。

图解

1 / 10
i 从 2 向右递增 →;· 尚未计算,源码对应槽位暂为 0。dp 的下标是房屋数量。
已考虑前 i 间012345
第 i 间价值27931
dp[i]02····
nums
[2, 7, 9, 3, 1]
dp(源码)
[0, 2, 0, 0, 0, 0]
选择步骤 10 步
变量与图例

每一间都展开两条分支:偷它要隔一间,不偷它就继承上一前缀;观察 3 被跳过、1 又被选上。

nums[i−1]
第 i 间的金额;nums 从 0 编号,前缀 dp 从房屋数量编号。
i
已考虑房屋的数量,而不是 nums 的当前下标。
dp[i]
前 i 间能取得的最大金额,不要求以“偷第 i 间”结尾。
偷 / 不偷(讲解分支)
同一 max 表达式的两个候选,不是源码额外保存的两个数组。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 令 n 为房屋数,建立 n+1 个状态,初始化 dp[0]=0、dp[1]=nums[0]。
  2. 令前缀长度 i 从 2 遍历到 n,当前房屋金额为 nums[i−1]。
  3. 比较不偷当前的 dp[i−1],与偷当前的 dp[i−2]+nums[i−1]。
  4. 把较大金额写入 dp[i],保留完整表供后续前缀使用。
  5. 返回整个前缀的最优金额 dp[n]。

代码(Python)

查看完整 Python 代码前缀 DP 表 · 与上方图解对应

dp 长度为 n+1,前缀长度与原数组下标相差 1。保留表方便追溯选择,也是理解滚动优化之前最直接的写法。

PYTHON 3
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 个前缀状态;返回单个金额。
03 / 检查边界

易错点与边界

  • dp[i] 不要求选择最后一间,不能把它与“以 i 结尾”的状态混用。
  • 选择当前房屋必须接 dp[i−2],接 dp[i−1] 可能偷到相邻房。
  • 不要固定偷奇数位或偶数位,[2,1,1,2] 的最优选择是两端。
  • 本题至少一间且金额非负,单间 0 合法,答案可以为 0。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

两个状态滚动时间 O(n),n 为房屋数。

每次转移只读相邻两个历史前缀,可以用 prev1 保存已处理前缀的最优金额,prev2 保存再少一间的前缀最优。两者都从 0 开始,统一处理首间。读当前金额 x 时,新前缀最优为 max(prev1,prev2+x);同时赋值让新 prev2 等于旧 prev1,新 prev1 等于刚算出的结果。这个写法把辅助空间降为常数,但两个金额本身不记录整条选择路径。

  1. 初始化 prev2=prev1=0,对应尚未取得任何金额的边界。
  2. 依次读取每间房屋的金额 x。
  3. 用两个旧值计算 max(prev1,prev2+x),比较不偷与偷当前两条分支。
  4. 同时执行 prev2,prev1=prev1,max(prev1,prev2+x),保证右侧都读取旧状态。
  5. 返回处理完全部房屋后的 prev1。
PYTHON 3
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。平局时可能存在多组最优方案,输出任意一组即可;仅靠滚动的两个数无法直接完成这个回溯。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 状态先定义成“前 i 间最优”,才能直接继承不偷分支。
  2. 偷当前必须读 i−2;仅比较相邻金额不是完整决策。
  3. 压缩为两个变量时仍需保存两个旧前缀,不能让更新后的值污染转移。
← 返回题库
动态规划84 / 100

279. 完全平方数 中等

标签:广度优先搜索 · 数学 · 动态规划 · 背包问题 · 完全背包

力扣原题 ↗
01 / 理解题目

一句话题意

把正整数 n 表示为若干完全平方数之和,同一个平方数可重复使用,求最少项数。

教学示例

n=6,平方数候选为 [1,4] → 3;例如 6=4+1+1。

生活化理解

像只有 1、4、9、16…这些面额的硬币,凑出金额 n,要求硬币张数最少;每拿一张平方数硬币,就走到更小的剩余金额。

02 / 讲透主解法

金额递增动态规划

用目标数拆分最后一个平方数,状态和转移都与最少项数直接对应,容易迁移到完全背包。

核心思路

定义 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]。

图解

1 / 11
x 从左到右 →;每格再按 q=1、4 比较。· 尚未开始,源码初值 n+1=7 表示待改进。
目标 x0123456
dp[x]0······
n
6
squares
[1, 4]
dp(源码)
[0, 7, 7, 7, 7, 7, 7]
选择步骤 11 步
变量与图例

同一紫格会被多次尝试:下表列出每个平方数的来源、加 1 的候选和当前最小值。

squares
不超过 n 的平方数,按升序为 [1,4]。
x
当前目标数,从 1 递增到 n。
q
本次假设的最后一个平方数;超过 x 后停止枚举。
dp[x]
当前尝试过的 q 中最少个数;本格枚举结束后才是最终最优。
n+1
源码的初始上界,本例为 7;不同于合法最优值。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 使用 isqrt(n) 生成所有不超过 n 的平方数 squares,按从小到大排列。
  2. 建立 dp,令 dp[0]=0,其余位置先为 n+1。
  3. 令目标 x 从 1 遍历到 n,再逐个枚举 squares 中的 q。
  4. 若 q>x,结束本格的平方数枚举;否则用 dp[x−q]+1 改进 dp[x]。
  5. 本格全部合法候选比较完后处理下一目标,最后返回 dp[n]。

代码(Python)

查看完整 Python 代码金额递增动态规划 · 与上方图解对应

squares 保存可重复选择的面额;dp[x] 记录最少项数。平方数列表有序,q>x 后直接结束内层循环。

PYTHON 3
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)。
03 / 检查边界

易错点与边界

  • 平方数可以重复使用,不能把每个 q 当成只能选一次的物品。
  • dp[0]=0 表示凑成 0 不需要任何项;它不是 1。
  • 贪心先取最大平方数对 12 失败。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

余数图的广度优先搜索时间 最坏 O(n√n),至多 n 个正余数节点,每个尝试 O(√n) 条边;提前找到 0 时可少访问一些状态。

把剩余数值当作图节点,从 n 出发,每条边减去一个合法平方数,边的代价都为 1。queue 保存待扩展余数,seen 合并到达同一余数的不同路径。每轮先增加 depth,再固定本层队列长度扩展;由本层产生的下一余数使用了 depth 个平方数。BFS 按使用个数从少到多搜索,第一次生成 0 时返回的 depth 必然最小。适合把本题解释为无权图最短路,可能在访问完整状态空间前结束。

  1. 生成升序 squares,初始化 queue=[n]、seen={n}、depth=0。
  2. 每轮令 depth 加 1,并以本轮开始时的 len(queue) 固定待扩展节点数。
  3. 弹出 remaining,依次枚举不大于它的平方数 q,计算 nxt=remaining−q。
  4. 若 nxt 为 0,立即返回 depth;若尚未访问,在入队前加入 seen,再追加到 queue。
  5. 继续逐层扩展;代码在队列耗尽后返回 −1,但本题正整数 n 总可由若干个 1 构成。
PYTHON 3
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 更适合只求一个终点的最短项数;二者都无需递归,也没有深递归栈问题。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 把“最后一个平方数”作为决策,每个来源读 dp[x−q]。
  2. 外层目标递增保障来源已知;同一平方数可重复使用。
  3. 本题取最少个数,不累加方案数;比较中的值要与最终值区分。
← 返回题库
动态规划85 / 100

322. 零钱兑换 中等

标签:广度优先搜索 · 数组 · 动态规划 · 背包问题 · 完全背包

力扣原题 ↗
01 / 理解题目

一句话题意

硬币面额均为正整数、每种数量无限,求恰好凑出 amount 的最少枚数;无法凑出返回 −1。

教学示例

coins=[1,3,4],amount=6 → 2(3+3);最大面值优先会得到 4+1+1,共 3 枚。

生活化理解

像制作一张精确金额的购物券,只能使用给定面额:dp[a] 是兑到 a 元的最少张数,选最后一张 c 元券后,前面必须恰好兑到 a−c 元。

02 / 讲透主解法

金额外层:枚举最后一枚

按金额逐格枚举最后一枚硬币,最优子结构直接可见,容易解释为何不能贪心选择最大面额。

核心思路

定义 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],无须作贪心选择。

图解

1 / 15
金额从小到大 →,币种按输入 [1,3,4] 枚举;· 未开始,源码初始值 best=7。
金额 a0123456
dp[a]0······
coins
[1, 3, 4]
amount
6
best
7
dp(源码)
[0, 7, 7, 7, 7, 7, 7]
选择步骤 15 步
变量与图例

盯住金额 6:先按最后一枚 1 算出 3,再被最后一枚 3 改进为 2,最后一枚 4 无法再改善。

coins
可用面值列表,每种可以重复使用;本例按 1、3、4 枚举。
a / c
当前要凑的金额 / 本次假设的最后一枚面值。
dp[a]
本格已尝试币种中的最小枚数;内层循环结束后为最终值。
best
源码的“还未找到解”初值 amount+1,本例为 7。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 设 best=amount+1,建立 amount+1 个状态,只有 dp[0] 初始化为 0。
  2. 令金额 a 从 1 到 amount 递增,逐个枚举 coins 中的面额 c。
  3. 仅在 c≤a 时,把最后一枚 c 的候选 dp[a−c]+1 与当前 dp[a] 取最小。
  4. 当前金额的所有硬币都比较完后,再计算下一个金额。
  5. 若目标格仍等于 best,返回 −1;否则返回 dp[amount]。

代码(Python)

查看完整 Python 代码金额外层:枚举最后一枚 · 与上方图解对应

best 是不可达哨兵,dp[a−c]+1 是选择最后一枚 c 的候选。内层硬币无需排序,超过金额时跳过即可。

PYTHON 3
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),保存每个金额的最少枚数。
03 / 检查边界

易错点与边界

  • 用 0 初始化所有状态会让不可达金额看起来免费可达。
  • 普通面额不保证贪心正确,[1,3,4] 凑 6 就是反例。
  • amount=0 的最少枚数为 0;没有刚好匹配的组合时返回 −1。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

面额外层:完全背包正序更新时间 O(A·m),A 是目标金额,m 是面额数量;大于 A 的面额不进入内层循环。

按已处理面额定义状态:一轮面额 c 结束后,dp[a] 是仅使用这些面额凑出 a 的最少枚数。更新 a 前的旧 dp[a] 不使用当前面额 c;正序扫描让较小的 dp[a−c] 已可包含本轮 c,再加一枚 c 就允许重复使用。金额若改为倒序,就会限制每种面额至多一枚,改变题意。这一写法适合比较完全背包与 0/1 背包的循环方向。

  1. 设 unreachable=amount+1,初始化 dp[0]=0,其余金额为 unreachable。
  2. 外层依次读取面额 c。
  3. 内层金额 a 从 c 正序增加到 amount,使 dp[a−c] 可以包含本轮面额。
  4. 执行 dp[a]=min(dp[a],dp[a−c]+1),保留不使用 c 或再使用一枚 c 的较优结果。
  5. 全部面额处理后,按目标格是否仍为 unreachable 返回 −1 或最少枚数。
PYTHON 3
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. 枚举最后一枚,把最少总枚数拆成已知来源再加 1。
  2. 无限硬币允许来源方案包含相同币种;与 0/1 背包的物品限制不同。
  3. 比较全部合法面值再定本格;哨兵未变才表示不可达。
← 返回题库
动态规划86 / 100

139. 单词拆分 中等

标签:字典树 · 记忆化 · 数组 · 哈希表 · 字符串 · 动态规划 · Brute-Force Search

力扣原题 ↗
01 / 理解题目

一句话题意

判断字符串 s 是否能被完整切分成字典中的若干非空单词,同一个单词可以重复使用。

教学示例

s="catsand",wordDict=["cat","cats","and"] → True;完整拆分为 "cats" + "and"。

生活化理解

像给一条没有空格的字幕重新加断句:dp[i] 表示前 i 个字符已经能被词典完整覆盖,只有前半段能覆盖且最后一段真是单词,才能认可新断点。

02 / 讲透主解法

前缀 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] 是单词,等价于在合法拆分后接一个合法单词;枚举所有可能最后单词的起点即可覆盖全部拆分。

图解

1 / 10
i 从左到右 →;T/F 是前缀可达性,· 未判定(源码槽位先为 False)。
前缀长度 k01234567
前缀 s[:k]空串ccacatcatscatsacatsancatsand
dp[k]T·······
切割点是字符之间的边界;紫色片段是本次单词候选c字符 0a字符 1t字符 2s字符 3a字符 4n字符 5d字符 6i=0边界 k 位于字符 k 之前;s[j:i] 包含 j,不包含 i。
s
catsand
words(集合)
['and', 'cat', 'cats']
longest
4
dp(源码)
[True, False, False, False, False, False, False, False]
选择步骤 10 步
变量与图例

边界下标数的是已取字符个数:绿色前缀必须先可达,紫色候选还必须完整出现在字典里。

dp[k]
s[:k] 这个前缀能否完整拆分;下标 k 是长度与边界,不是末字符下标。
i
本轮要判定的前缀右边界,s[:i] 不包含字符 i。
j
最后一个单词的左边界;前半段为 s[:j],候选为 s[j:i]。
words / longest
去重后的字典集合 / 最大词长,本例为 4,用于限制最左切口。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 将 wordDict 转成集合 words,计算最长单词长度 longest。
  2. 建立 len(s)+1 个布尔状态,只有 dp[0] 设为 True。
  3. 令前缀右边界 i 从 1 到 len(s),依次检查 max(0,i−longest) 到 i−1 的切口 j。
  4. 当 dp[j] 为 True 且 s[j:i] 在 words 中时,设置 dp[i]=True,并退出当前切口循环。
  5. 若本轮没有成功切口,dp[i] 保持 False;所有前缀判定后返回 dp[-1]。

代码(Python)

查看完整 Python 代码前缀 DP + 字典集合 · 与上方图解对应

words 用于词汇查找,longest 限制末词长度,dp[i] 记录前缀是否可完整拆分。and 的短路让不可达起点不产生切片。

PYTHON 3
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)。
03 / 检查边界

易错点与边界

  • 必须完整覆盖整个 s,找到其中某几个单词不代表可拆分。
  • dp[0] 必须为 True,否则第一个单词永远没有可达起点。
  • 同一词可重复使用;不要在使用一个词后把它从词典移除。
  • 最长词贪心不保证剩余部分可拆,必须保留所有可达断点。
  • Python set 查询只免去线性扫描词典,不能免去新切片的构造和首次哈希成本。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 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。

  1. 把每个字典单词逐字符插入 root,在终点将空字符串键设为 True,标记单词结束。
  2. 初始化 dp[0]=True,其余前缀为 False。
  3. 从左向右枚举起点 i;若 dp[i] 为 False,跳过这个起点。
  4. 对可达起点令 node=root,再让字符下标 j 从 i 向后移动;找不到字符边就停止本起点。
  5. 沿字符边前进后,若 node 含词尾标记,就设置 dp[j+1]=True,并继续尝试更长单词。
  6. 全部起点处理后返回 dp[n]。
PYTHON 3
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 回溯并切片。若要所有拆法,单个布尔状态只能帮助剪枝,还需保存多个前驱或回溯枚举;方案总量可能指数增长,不能承诺与判定题相同的输出时间。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. dp 下标是前缀长度,切片统一使用半开区间 [j,i)。
  2. 可达前缀与字典单词必须同时成立;中间 False 不能提前宣布全串失败。
  3. break 只结束当前 i 的切口搜索;字典最大长度可以缩小 j 的范围。
← 返回题库
动态规划87 / 100

300. 最长递增子序列 中等

标签:数组 · 二分查找 · 动态规划 · 最长上升子序列

力扣原题 ↗
01 / 理解题目

一句话题意

从数组中按原顺序选出若干元素,要求严格递增,返回最长子序列的长度;元素不必连续。

教学示例

nums=[3,1,2,2,4] → 3;可取下标 1、2、4 对应 [1,2,4]。

生活化理解

像为不同长度的爬山路线保留最低的终点:同样走过 k 个点,终点越低,之后越容易接到更高的新点;只需保留每种长度最有延展性的尾值。

02 / 讲透主解法

以每个位置结尾的 O(n²) DP

“必须以 i 结尾”的状态直接可证,转移容易复现,也便于在面试追问中恢复具体子序列。

核心思路

定义 dp[i] 为必须以 nums[i] 结尾的最长严格递增子序列长度。每个元素自己就能组成一条非空子序列,所以全部初始化为 1。这里“结尾固定在 i”是连接条件的依据,不等于任意前缀的全局最长长度。

按 i 从左到右,枚举此前每个位置 j。只有同时满足 j<inums[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]。

图解

1 / 13
i 从左向右 →,每个 i 再依次检查左侧 j;· 尚未扫描,源码 dp 初始化为 1(单元素子序列)。
下标 k01234
nums[k]31224
dp[k]·····
nums
[3, 1, 2, 2, 4]
dp(源码初值)
[1, 1, 1, 1, 1]
选择步骤 13 步
变量与图例

每个紫格都问“如果必须以这个数结尾,左边谁能接过来”;特别看第二个 2 为什么不能接第一个 2。

i
当前子序列结尾的下标。
j
当前枚举的左侧前驱,范围 0…i−1。
dp[i]
必须以 nums[i] 结尾的最长严格递增子序列长度。
nums[j]<nums[i]
能否接上当前元素的数值条件;相等不合法。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 建立长度 len(nums) 的 dp,全部设为 1,表示单元素子序列。
  2. 令当前结尾 i 从 0 到 len(nums)−1。
  3. 枚举 j=0…i−1;仅当 nums[j]<nums[i] 时允许接上当前元素。
  4. 对每个合法前驱,用 dp[j]+1 改进 dp[i],其余前驱跳过。
  5. 所有结尾计算完毕,返回 max(dp)。

代码(Python)

查看完整 Python 代码以每个位置结尾的 O(n²) DP · 与上方图解对应

dp[i] 只从严格更小的早期元素转移;每个位置保留一条最优长度。该版本方便添加前驱来恢复一条具体子序列。

PYTHON 3
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),保存每个结尾的最优长度。
03 / 检查边界

易错点与边界

  • 主解只有 nums[j] < nums[i] 时才能连接;相等的值不能让严格递增长度增加。
  • 子序列不要求相邻,不能在相邻下降时重置整个答案。
  • 基础 DP 的答案是 max(dp),最后一个元素不一定在最优序列里。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

贪心最小尾值 + 二分时间 O(n log n),n 为数组长度,每个元素在最长 O(n) 的 tails 上二分。

改按长度记录状态:tails[k] 是已扫描前缀中,长度为 k+1 的递增子序列的最小可能末尾。相同长度保留更小末尾,总不会更难接上未来元素;tails 严格递增,因而能用 bisect_left 找第一个不小于 x 的位置 p。存在该位置时降低对应长度的末尾,否则追加 x 扩展最长长度。相等值只替换,不延长。此法把时间改善到 O(n log n),但 tails 的各项可能来自不同历史序列,整个列表不保证是原数组的一条实际子序列;返回的是其长度。

  1. 初始化空列表 tails,依次读取原数组元素 x。
  2. 用 bisect_left(tails,x) 找第一个大于等于 x 的位置 p。
  3. 若 p 等于 len(tails),说明 x 大于现有全部尾值,将 x 追加以新增一个可行长度。
  4. 否则将 tails[p] 替换为 x,改善长度 p+1 的最小末尾而不改变最长长度。
  5. 全部元素处理后返回 len(tails),不把 tails 本身当作重建出的实际子序列。
PYTHON 3
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 的数量,单个最小尾值会丢掉多种历史路径,需维护长度与计数等额外状态。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. “以 i 结尾”让合法连接只需检查最后两个数。
  2. 先满足下标顺序与严格大小,再比较候选长度。
  3. 答案取所有结尾中的最大值;重复值最能检验 < 与 ≤ 的区别。
← 返回题库
动态规划88 / 100

152. 乘积最大子数组 中等

标签:数组 · 动态规划

力扣原题 ↗
01 / 理解题目

一句话题意

在非空整数数组中选取一个非空连续子数组,返回其元素乘积的最大值。

教学示例

nums=[-2,3,-4,0,-2,-3] → 24;最佳连续区间为前三个元素,乘积 (−2)×3×(−4)。

生活化理解

像带正负号的放大器串联:乘正数保持大小关系,乘负数会把最小值翻成最大候选,所以不仅要记“最赚钱的过去”,也要记“亏得最多的过去”。

02 / 讲透主解法

最大 / 最小结尾乘积滚动 DP

同时保留最大与最小结尾乘积,能直接解释负数翻转和零断点,在线性时间、常数空间内完成。

核心思路

源码维护三个标量:hilo 分别是以当前下标结尾的非空连续子数组最大、最小乘积,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。

图解

1 / 10
从左到右 →;· 此轮尚未完成。历史行是讲解记录,源码只保留 hi、lo、ans。
下标 k012345
nums[k]-23-40-2-3
以 k 结尾 hi-2·····
以 k 结尾 lo-2·····
历史最大 ans-2·····
hi
-2
lo
-2
ans
-2
选择步骤 10 步
变量与图例

负数帧先停在“交换后、乘法前”:追踪旧 lo 如何成为新 hi 的来源;遇零时比较结尾极值与历史答案。

x / i
当前位置的值与下标。
hi
每轮完成后的最大结尾乘积;负数交换后的中间值还不是新结果。
lo
每轮完成后的最小结尾乘积,可能在下一个负数处翻成最大。
ans
任意已处理结尾的最大乘积,经过零也保留。
旧值 / 工作值(讲解记录)
便于检查覆盖顺序的表格;源码通过交换并保留各自变量来读取正确来源。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 用 nums[0] 初始化 hi、lo、ans,覆盖只有一个元素的情况。
  2. 从下标 i=1 开始读取 x=nums[i];若 x<0,先交换 hi、lo。
  3. 计算 hi=max(x,hi*x),比较单独取 x 与延续当前最大候选。
  4. 计算 lo=min(x,lo*x),使用 lo 自己保留的乘法前来源,不读取新 hi。
  5. 以 hi 更新 ans,所有元素处理完后返回 ans。

代码(Python)

查看完整 Python 代码最大 / 最小结尾乘积滚动 DP · 与上方图解对应

hi、lo 总是同一结尾位置的两个极值。负数分支先交换,再做各自转移;计算 lo 时不读取刚更新的 hi。用下标遍历避免 nums[1:] 产生额外数组副本。

PYTHON 3
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),没有切片,没有保存所有结尾状态。
03 / 检查边界

易错点与边界

  • 负数会翻转大小关系,仅保存最大结尾乘积会丢失未来最优候选。
  • hi、lo 更新必须基于同一轮旧状态,不能把新 hi 再用于 lo。
  • 答案要求非空子数组,单个负数的答案仍是负数。
  • 零使当前乘积归零,但历史最大值必须保留。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

零分段的左右乘积扫描时间 O(n),n 是数组长度,一次循环同时更新左右两个扫描方向。

left 累计从当前无零段左端到扫描位置的乘积,right 同时从右端反向累计。无零整数的绝对值至少为 1:负数为偶数个时,整段乘积覆盖最优;负数为奇数个且存在正积候选时,可去掉截至第一个负数的前缀,或去掉从最后一个负数开始的后缀,两端扫描分别覆盖这些候选。单个负数仍由实际扫描值和 ans=nums[0] 处理。遇零当轮先把零纳入答案,下一轮相应乘积从 1 重启。这个常数空间写法依赖整数的性质,不能原样推广到绝对值小于 1 的任意小数。

  1. 初始化 left=right=1、ans=nums[0],记录数组长度 n。
  2. 令 i 从 0 到 n−1;若上一轮 left 为 0,先按乘法单位元 1 重启,再乘 nums[i]。
  3. 同样处理 right,并乘入右侧对应元素 nums[n−1−i]。
  4. 用本轮 left、right 与旧 ans 取最大;零本身也会在当轮参与比较。
  5. 扫描完两个方向覆盖的所有前缀和后缀后,返回 ans。
PYTHON 3
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。仅保存一个最大值起点不够,因为将来可能由最小乘积翻成最大乘积。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 乘积含负数时,最大与最小是一对必须同时维护的状态。
  2. 区分交换后的工作变量与本轮完成后的结尾极值。
  3. 零重置结尾乘积但不重置历史答案;ans 与 hi 解决不同范围。
← 返回题库
动态规划89 / 100

416. 分割等和子集 中等

标签:数组 · 动态规划 · 背包问题 · 0-1 背包

力扣原题 ↗
01 / 理解题目

一句话题意

判断能否把正整数数组中的所有元素分成两个和相等的子集;每个元素必须且只能分到一组。

教学示例

nums=[1,2,5],总和 8,目标 4 → False。正确可达和只有 {0,1,2,3}(不超过目标)。

生活化理解

像把一箱有固定重量的物品分给两人:只要能从中选出总重量一半,剩下的自然也是一半。每件物品只有一件,不能为了凑重量重复拿同一件。

02 / 讲透主解法

一维 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] 做或运算就准确覆盖“不选”和“选一次”。

图解

1 / 10
容量从大到小 ←;· 本轮尚未更新,内存仍保留上行旧值;k<x 不进入循环,直接沿用旧值。T/F 表示可达/不可达。
容量 k01234
进入本物品前(旧)TFFFF
本物品处理后的目标状态TFFFF
nums
[1, 2, 5]
total / target
8 / 4
dp(源码)
[True, False, False, False, False]
选择步骤 10 步
变量与图例

从右向左逐格走:下行的 · 在内存里仍保存上行旧值;重点看处理 2 时,容量 4 为什么读不到稍后才出现的新 dp[2]。

total / target
总和与它的一半;奇数总和会提前返回 False。
x
当前这一件物品的数值;相同数值的不同输入位置仍是不同件。
t
当前容量,从 target 倒序扫到 x。
dp[t]
已处理物品能否组成和 t;同一轮运行时右侧格是新值,左侧格仍为旧值。
旧层 / 目标行(讲解记录)
把一维数组的更新分成可见两行;源码本身没有复制这张二维表。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 计算 total;若为奇数返回 False,否则设 target=total//2。
  2. 建立 target+1 个 False 状态,只把 dp[0] 设为 True。
  3. 逐个读取输入元素 x,将容量 t 从 target 倒序遍历到 x。
  4. 执行 dp[t]=dp[t] or dp[t−x],让两个来源都保持当前物品之前的含义。
  5. 每件物品只处理一轮;全部完成后返回 dp[target]。

代码(Python)

查看完整 Python 代码一维 0/1 背包倒序更新 · 与上方图解对应

dp[t] 表示在当前已处理元素中是否有子集和 t。倒序让源 dp[t−x] 保持上一轮值,是“每个元素一次”的核心,不是可有可无的实现习惯。

PYTHON 3
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),返回单个布尔值。
03 / 检查边界

易错点与边界

  • 总和为奇数直接无解,不能用向下取整的一半继续判断。
  • 一维表正序会重复使用同一个元素,[1,2,5] 可产生错误的目标 4。
  • 重复数值仍是不同输入元素,例如两个 5 可以各选一次。
  • 空子集的和 0 必须可达;不能把所有布尔状态初始化为 False。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 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]。两个来源都明确在上一行,所以本行容量可以正序扫描,也不会重复使用当前元素。它用更多空间保存完整物品层,适合先推导背包状态,或在追问中回溯选了哪些元素。

  1. 检查总和奇偶性,设 target 为一半,并记录元素个数 n。
  2. 建立 (n+1)×(target+1) 的 False 表,只令 dp[0][0]=True。
  3. 令 i 从 1 到 n,读取当前元素 x=nums[i−1]。
  4. 容量 t 从 0 到 target:先复制 dp[i−1][t],表示不选 x。
  5. 若 t≥x,再与 dp[i−1][t−x] 做或运算,加入选一次 x 的可能。
  6. 返回 dp[n][target]。
PYTHON 3
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 先计算右侧,所以这次移位只使用更新前的可达集合,不会复用同一件。这个写法适合熟悉位集合后的简洁实现;原代码不裁剪高位,会保存到总和范围,大整数的时间与空间不能按一个固定机器整数估算。

  1. 求 total,若为奇数直接返回 False。
  2. 初始化 bits=1,表示空子集的和 0 可达。
  3. 逐个元素 x 执行 bits |= bits << x,把旧可达和与选入 x 后的和合并。
  4. 处理完后将 bits 右移 total//2 位,取最低位并转换为 bool。
PYTHON 3
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] 并将容量减去该值,余下元素组成补集。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 先用总和奇偶性把问题化成子集和 target。
  2. 倒序的理由是保留上一物品层,不是机械记忆循环方向。
  3. 与无限硬币区分:0/1 背包不允许来源刚用过当前这件物品。
← 返回题库
动态规划90 / 100

32. 最长有效括号 困难

标签:栈 · 字符串 · 动态规划 · 括号序列

力扣原题 ↗
01 / 理解题目

一句话题意

给出只含左右括号的字符串,求最长连续且括号完全匹配的子串长度,不能跳过中间字符。

教学示例

s="()(())(" → 6;最长有效连续子串为下标 [0,5] 的 "()(())",末尾左括号不计入。

生活化理解

像把括号片段拼成可以闭合的盒子:遇到右括号时,先跨过紧邻的完整盒子,检查其左边有没有能和当前右括号配对的左括号,再把更左侧的完整盒子一并接上。

02 / 讲透主解法

以右括号结尾的动态规划

用已知有效后缀长度定位匹配点,能把内部闭合与前段拼接逐一解释清楚,并保持线性时间。

核心思路

定义 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 左侧,按下标递增即可使用已知值。

图解

1 / 10
i 从左向右 →;· 尚未扫描,源码 dp 槽位已初始化为 0。答案 ans 是整行已确认值的最大值。
字符下标 k0123456
s[k]()(())(
dp[k]0······
候选匹配位置与连续片段(箭头指向字符下标)(0)1(2(3)4)5(6i=0dp[i] 只记录必须以当前字符结尾的有效连续长度。
s
()(())(
dp(源码)
[0, 0, 0, 0, 0, 0, 0]
ans
0
选择步骤 10 步
变量与图例

i=5 时先跨过内部 "()" 找 j,再用 j−1 接回开头 "()";最后一个左括号用于检验 dp 与 ans 的区别。

i
当前扫描的字符下标;循环从 1 开始。
dp[i]
必须以字符 i 结尾的最长有效连续子串长度。
j
跨过 dp[i−1] 个内部字符后,再左移一格的候选匹配下标。
dp[j−1]
外层左括号之前、恰好相邻的有效前段长度;j=0 时按 0。
ans
所有已处理结尾中的最大长度,遇到左括号也不清零。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 建立全为 0 的 dp,令 ans=0,从下标 i=1 开始扫描。
  2. 若 s[i] 是左括号,保持 dp[i]=0 并继续下一位置。
  3. 若前一字符是左括号,形成相邻的一对,写入 2 加上存在时的 dp[i−2]。
  4. 否则计算 j=i−dp[i−1]−1;仅在 j 合法且 s[j] 为左括号时,写入内部长度、当前一对与存在时的 dp[j−1] 之和。
  5. 用每个右括号位置的 dp[i] 更新 ans,扫描完成后返回 ans。

代码(Python)

查看完整 Python 代码以右括号结尾的动态规划 · 与上方图解对应

dp[i−1] 告诉我们要跨过多少个字符;j 是跨过完整有效后缀后紧挨着的字符。配对成功还必须加 dp[j−1],才能把 ()(()) 这种左右相接的结构合并。

PYTHON 3
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 保存每个结尾的最长长度。
03 / 检查边界

易错点与边界

  • dp 记录长度,j=i−dp[i−1]−1 才是候选左括号下标,不是 i−dp[i−1]。
  • 要检查 j≥0;Python 负索引会读到字符串尾部,悄悄造成错误匹配。
  • 配对后漏加 dp[j−1] 会把 ()(()) 的 6 错算为 4。
  • 空字符串、全左括号或全右括号的答案都是 0;未形成有效串时返回初始 ans。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 2

下标栈与非法边界时间 O(n),n 为字符串长,每个下标至多入栈、出栈一次。

stack 保存下标:栈底是当前可用区域左侧的非法边界,初始为 −1,边界上方保存尚未匹配的左括号下标。遇右括号先弹栈;若弹空,当前右括号没有可配的左括号,就把当前下标作为新边界。若栈仍非空,它的顶部是当前有效后缀左边紧邻的位置,因此 i−stack[-1] 是以 i 结尾的最长有效长度。此法适合从熟悉的括号匹配栈推导最长长度,必须保存下标而不是只保存括号字符。

  1. 初始化 stack=[−1]、ans=0,枚举每个字符 ch 与下标 i。
  2. 遇到左括号,将 i 压栈,等待后续匹配。
  3. 遇到右括号先弹出一个下标;若栈空,把当前 i 压入作为新的非法边界。
  4. 若弹栈后仍非空,用 i−stack[-1] 更新 ans。
  5. 扫描结束返回 ans。
PYTHON 3
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 在两遍之间保留。它用常数辅助空间换取两个方向的推导,适合面试中进一步追问空间优化。

  1. 初始化 ans=0、left=right=0,从左到右扫描并累计括号数量。
  2. 若 left==right,用 2×right 更新 ans;若 right>left,把两个计数清零。
  3. 第一遍结束后重新将 left、right 清零,保留 ans。
  4. 通过 reversed(s) 从右到左扫描;相等时用 2×left 更新 ans,left>right 时清零。
  5. 返回两遍得到的最大长度 ans。
PYTHON 3
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”替代答案,因为多余括号可能把匹配片段隔开。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. j=i−dp[i−1]−1:先跳过内部,再定位可能配对的左括号。
  2. 配对成功后再加 dp[j−1],才能把相邻有效块连起来。
  3. 检查 j≥0 防止负下标误配;返回 ans 而不是最后一个 dp。
← 返回题库
多维动态规划91 / 100

62. 不同路径 中等

标签:数学 · 动态规划 · 组合数学

力扣原题 ↗
01 / 理解题目

一句话题意

在 m 行 n 列的无障碍网格中,只向右或向下移动,求左上角到右下角的不同路径数。

教学示例

m = 4,n = 3;只能向右或向下,从左上角到右下角共有 10 条路径。

生活化理解

把每个路口看成邮件汇合站。到某站的邮件只可能来自上方站或左方站;每封邮件代表一条不同的行走历史,两路邮件相加就是该站的路径数。

02 / 讲透主解法

一维滚动动态规划

上方旧值与左方新值的含义明确,只保留较短边的一行计数即可完成递推,适合面试讲解与复现。

核心思路

到达某一格的最后一步只能来自上方或左方。按最后一步分类,两组路径互不重叠,又覆盖全部走法,因此当前格的路径数等于上方路径数加左方路径数。第一行只能一直向右,第一列只能一直向下,它们的路径数都是 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] 已经是本行左边格子的路径数。最后一步只能从上或左进入,两类路径互不重叠,所以相加即可。

图解

1 / 9
概念网格:首行与首列只有一种走法;· 表示尚未推演的格子
位置列 0列 1列 2
行 0111
行 11··
行 21··
行 31··
m
4
n
3
n > m
False
选择步骤 9 步
变量与图例

把窄数组看成一排会更新的计数牌:当前牌的旧数来自上方,左邻牌的新数来自本行。

m / n
网格行数 / 列数;必要时交换,使 dp 长度为较小边长。
_
外层循环的当前行号;源码用下划线命名,因为不需要拿它索引二维数组。
j
当前从左向右更新的列号。
dp[j]
更新前是上方旧值,更新后变成本行当前位置的路径数。
dp[j-1]
左方已经更新的新值;第 0 列始终为 1。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 若 n>m,交换 m、n,使滚动数组沿较短边展开。
  2. 初始化 dp=[1]*n,表示首行每格都只有一种走法。
  3. 从第 2 行开始逐行推进;每行第 0 列保持 1,令 j 从 1 遍历到 n−1。
  4. 读取 dp[j] 的上方旧值与 dp[j−1] 的本行左方新值,相加写回 dp[j]。
  5. 所有行完成后返回 dp[-1];只有一行或一列时,相关循环自然为空,答案仍为 1。

代码(Python)

查看完整 Python 代码一维滚动动态规划 · 与上方图解对应

把较短维度作为列,dp[j] 原地融合上方旧值和左方新值;利用网格转置对称性压缩空间。

PYTHON 3
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)) 空间。
03 / 检查边界

易错点与边界

  • 路径数量相加,不是取最大值或最小值。
  • 首行首列都为 1,不能把起点初始化为 0。
  • 滚动数组必须从左向右,才能使用本行已更新的左值。
  • 合法输入 m、n 均为正数;单行、单列都只有一条路径。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

二维动态规划时间 O(mn) 次加法;m、n 为行列数。按题目整数范围采用定长算术模型。

用 dp[i][j] 直接保存走到第 i 行、第 j 列的路径数,所有位置都保留,便于观察整张表和检查递推关系。源码先把整个 m×n 表初始化为 1,其中首行、首列是确定的边界,内部的 1 只是随后会被覆盖的初值。按行扫描内部格子,读取已完成的上方 dp[i−1][j] 与左方 dp[i][j−1] 相加;因为最后一步不同,两种来源可以直接相加。它与滚动主解计算相同的状态,但需要保存 O(mn) 个整数。

  1. 创建 m 行、n 列的 dp,并全部初始化为 1,使首行和首列满足边界。
  2. 令 i 从 1 到 m−1,再令 j 从 1 到 n−1,按行正序访问内部格子。
  3. 执行 dp[i][j]=dp[i−1][j]+dp[i][j−1],覆盖当前位置的初值。
  4. 返回右下角 dp[-1][-1];不需要枚举或保存具体路径。
PYTHON 3
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。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 先说明“最后一步来自哪里”,再写路径计数的加法。
  2. 滚动数组从左向右更新,才能同时拿到上方旧值和左方新值。
  3. 交换两条边不会改变路径数,却能把额外空间降到 O(min(m,n))。
← 返回题库
多维动态规划92 / 100

64. 最小路径和 中等

标签:数组 · 动态规划 · 矩阵

力扣原题 ↗
01 / 理解题目

一句话题意

在非负整数网格中只向右或向下移动,求从左上角到右下角、包含两端格子的最小路径和。

教学示例

grid = [[1,3,1],[1,5,1],[4,2,1]];最小路径和为 7。

生活化理解

把网格看成收费街区,每踏入一个格子就交该格费用。每个路口只记“到这里最省一共花多少”,之后的收费与来路无关,因此昂贵的历史路线可以舍弃。

02 / 讲透主解法

独立二维 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] 是从起点到当前格的最小累计和,包含当前格的费用。填表前,它依赖的上格与左格已经确定;取二者较小值再加当前费用,就不会漏掉任何合法最优路径。

图解

1 / 11
原始 grid:每格的过路费
位置列 0列 1列 2
行 0131
行 1151
行 2421
dp 教学视图:· 尚未推演;源码实际预分配为 0
位置列 0列 1列 2
行 0···
行 1···
行 2···
m / n
3 / 3
代码 dp 初值
3×3 个 0
选择步骤 11 步
变量与图例

每块地砖有过路费:来到当前格,只比较上方和左方已经付过的总费用,再付当前格的钱。

grid[i][j]
当前格原始费用,整个演示中不修改。
dp[i][j]
走到当前格的最小累计费用。
i / j
当前行 / 列,先完成首行,再逐行从左向右计算。
m / n
3 行、3 列;只能向右或向下移动。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 读取 m、n,创建全 0 的独立 dp 表,并令 dp[0][0]=grid[0][0]。
  2. 从左到右填写首行:dp[0][j]=dp[0][j−1]+grid[0][j]。
  3. 依次进入第 i 行,先写入 dp[i][0]=dp[i−1][0]+grid[i][0]。
  4. 在该行令 j 从 1 到 n−1,取上方、左方累计费用的较小值,再加当前格费用。
  5. 所有格子完成后返回 dp[-1][-1],原始 grid 保持不变。

代码(Python)

查看完整 Python 代码独立二维 DP · 与上方图解对应

dp 保存累计最小值,不改变 grid,便于保留原始成本或以后回溯路线。

PYTHON 3
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) 辅助空间,未修改输入。
03 / 检查边界

易错点与边界

  • 路径和包含起点和终点,起点不能置为 0。
  • 首行和首列只有一条来路,直接访问负索引会错误地读到末行或末列。
  • 本题网格非空,零费用是合法值,不代表无法到达。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

原地动态规划时间 O(mn),m、n 为行列数。

直接把 grid 已处理的格子改写成累计费用,省去独立 dp 表。按行从左到右访问时,当前格仍是原始价格,上方和左方已经是最优累计费用,因此可以就地执行加法。起点不更新;首行只能加左邻,首列只能加上邻,内部格再加两者的较小值。这个版本使用 O(1) 辅助空间,但会改变调用者的输入;如果还需要原网格,先复制再计算仍要付出 O(mn) 的复制空间。

  1. 读取网格尺寸,按 i 从 0 到 m−1、j 从 0 到 n−1 扫描原 grid。
  2. 遇到起点 (0,0) 直接跳过,保留它自己的费用。
  3. 处于首行时加左方累计费用;处于首列时加上方累计费用。
  4. 内部格把上方和左方累计费用的较小值加到自身,覆盖原始费用。
  5. 返回 grid[-1][-1];此时网格内容已经变成累计费用表。
PYTHON 3
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,从终点向较小的上方或左方前驱倒走,代价相等时任选一条。贪心每次选择较便宜的相邻格不正确,因为它没有比较后续累计费用。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 最小路径 DP 比较的是累计费用,不是下一步单个格子的价格。
  2. 首行与首列只有一个来源,先把这两类边界的含义说明白。
  3. 独立 dp 保留原始 grid;若改为原地算法,要明确输入会被累计费用覆盖。
← 返回题库
多维动态规划93 / 100

5. 最长回文子串 中等

标签:双指针 · 字符串 · 动态规划 · Manacher 算法

力扣原题 ↗
01 / 理解题目

一句话题意

找出字符串中最长的连续回文子串;若有多个同样长的答案,返回任意一个。

教学示例

s = "abba";最长回文子串是 "abba"。

生活化理解

回文像一面镜子:奇数长度把一个字符放在镜面上,偶数长度把镜面放在两个字符之间。左右两人同步向外走,只要看到的字符一样,镜子的覆盖范围就还能扩大。

02 / 讲透主解法

区间动态规划

按区间长度递增能清楚展示回文的内外依赖;时间与辅助空间均为 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。

startbest 分别保存已确认的最长回文起点和长度,初始为 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 始终记录已确认的最长回文。

图解

1 / 12
原字符串:观察连续的闭区间a0b1b2a3当前最长:s[0:1] = a
· 是尚未推演的教学记号,代码实际存 False;— 不会访问
左端 i / 右端 j0: a1: b2: b3: a
0: a····
1: b···
2: b··
3: a·
n
4
start / best
0 / 1
选择步骤 12 步
变量与图例

像检查套娃:两端字符要相同,里面那层也必须是回文;短区间先验好,长区间才有依据。

i / j
当前子串的左右端点,下标都包含在区间内。
length
本轮检查的区间长度,按 2、3、4 增长。
dp[i][j]
T 是回文,F 不是回文;· 未推演,— 为 i>j 的无用下三角。
start / best
当前最长回文的起点 / 长度,初始 0 / 1。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 令 n=len(s),创建全 False 的 n×n 表,初始化 start=0、best=1。
  2. 把每个单字符状态 dp[i][i] 设为 True。
  3. 令 length 从 2 到 n;对该长度的每个左端 i,计算右端 j=i+length−1。
  4. 只有两端字符相等,且 length≤2 或内部 dp[i+1][j−1] 为 True 时,才把 dp[i][j] 设为 True。
  5. 确认的回文长度超过 best 时,更新 start=i、best=length。
  6. 所有区间检查完后返回 s[start:start+best]。

代码(Python)

查看完整 Python 代码区间动态规划 · 与上方图解对应

按 length 递增保证内层区间已计算,dp 为真假表,start 和 best 记录最长真区间。

PYTHON 3
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) 返回字符串空间。
03 / 检查边界

易错点与边界

  • 区间 DP 依赖更短的内部区间;若按 i 正序、j 正序直接填写,内部可能尚未就绪。
  • 多个最长答案均可;测试 babad 应接受 bab 或 aba。
  • 合法字符串非空;单字符自身就是答案。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 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 表的场景。

  1. 定义 expand:从给定 left、right 出发,只要未越界且两端字符相等,就让 left 减 1、right 加 1。
  2. 扩展结束返回 left+1、right−left−1,分别是有效回文的起点与长度。
  3. 初始化全局 start=0、length=1,逐个枚举字符位置 i。
  4. 分别调用 expand(i,i) 和 expand(i,i+1),接收 begin、size。
  5. 若 size>length,用 begin、size 更新 start、length;扩展过程中不反复复制子串。
  6. 所有中心完成后,返回 s[start:start+length]。
PYTHON 3
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) 半径数组。不能仅凭“复用镜像”就省略边界截断的证明。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 回文子串要求连续;不能把最长回文子序列的转移套过来。
  2. 区间 DP 要先确定依赖的长度,再决定填表方向。
  3. 长度 2 只需两端相等,不能去访问并未定义的反向内区间。
← 返回题库
多维动态规划94 / 100

1143. 最长公共子序列 中等

标签:字符串 · 动态规划 · 最长公共子序列

力扣原题 ↗
01 / 理解题目

一句话题意

求两个字符串最长公共子序列的长度:字符可以不连续,但各自在原串中的相对顺序必须保留。

教学示例

text1 = "aba",text2 = "baa";最长公共子序列长度为 2,例如 "ba" 或 "aa"。

生活化理解

两位听众各记下一串歌曲,想找共同听过且顺序一致的最长歌单。可以跳过某首歌,但不能把听歌顺序倒过来;两本记录的前缀就是 DP 的两个坐标。

02 / 讲透主解法

二维前缀 DP

完整前缀表能直接对应相等与不等时的依赖来源,便于解释顺序约束和重复字符的处理。

核心思路

dp[i][j] 表示 text1 的前 i 个字符与 text2 的前 j 个字符的最长公共子序列长度。i、j 是前缀长度,末字符下标分别是 i-1j-1。任意字符串与空前缀的公共子序列长度都是 0,所以表格多放第 0 行、第 0 列作为边界。

末字符相同时,可以把这对字符作为最后一对匹配,在 dp[i-1][j-1] 后面加 1;存在使用这对末字符的最优方案。末字符不同时,它们不能配成同一对,至少舍弃其中一侧末字符,于是取 max(dp[i-1][j],dp[i][j-1])。子序列允许跳过字符,但相对顺序不能改变;不等时清零会变成另一种连续匹配问题。

源码按行从左到右填写完整二维表。进入当前格时,上方、左方、左上方都已完成,图中只高亮本分支实际读取的来源。相等分支使用左上角而非左方再加 1,可以避免把同一个末字符重复匹配。例如 ababaa 的右下角读取左上角 1,再接一个 a 得到 2,最终返回 dp[m][n]

不变量

dp[i][j] 是 text1 的前 i 个字符与 text2 的前 j 个字符的 LCS 长度,不是下标 i、j 两个字符的答案。按行从左到右计算时,上、左、左上三种可能来源都已完成。

图解

1 / 11
前缀表:ε 是空串;· 为未推演格,源码初值仍是 0
text1 / text20: ε1: b2: ba3: baa
0: ε0000
1: a0···
2: ab0···
3: aba0···
m / n
3 / 3
第 0 行 / 列
全部为 0
选择步骤 11 步
变量与图例

把两个前缀当成两叠已翻开的字牌:末尾相同就接到左上角答案后;不相同就比较丢掉哪一侧末尾更划算。

i / j
已取的前缀长度;对应末字符下标 i-1、j-1。
dp[i][j]
两个前缀的最长公共子序列长度,允许不连续但必须保持顺序。
ε
空前缀:任何字符串与空串的 LCS 长度都是 0。
上 / 左 / 左上
分别少取 text1 末尾、少取 text2 末尾、两边都少取末尾的状态。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 读取 m、n,创建 (m+1)×(n+1) 的全零 dp 表,第 0 行和第 0 列表示空前缀。
  2. 令 i 从 1 到 m,再令 j 从 1 到 n,比较 text1[i−1] 与 text2[j−1]。
  3. 末字符相同,写入 dp[i][j]=dp[i−1][j−1]+1。
  4. 末字符不同,写入 dp[i][j]=max(dp[i−1][j],dp[i][j−1])。
  5. 返回完整两个字符串对应的 dp[m][n]。

代码(Python)

查看完整 Python 代码二维前缀 DP · 与上方图解对应

二维表明确展现跳过左串末字符、跳过右串末字符和配对末字符三种关系。

PYTHON 3
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) 辅助空间。
03 / 检查边界

易错点与边界

  • 子序列可跳过字符但不可换序,子串则要求连续。
  • DP 坐标是前缀长度,字符索引要减 1。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 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;这些覆盖顺序正是压缩后必须额外维护的关系。

  1. 若 text2 比 text1 长,交换两串;建立长度 len(text2)+1 的全零 dp。
  2. 逐个处理 text1 中的字符 a,每行开始令 diagonal=0。
  3. 按 j 从 1 递增处理 text2 的字符 b,先保存 old_up=dp[j]。
  4. 若 a==b,令 dp[j]=diagonal+1;否则令 dp[j]=max(dp[j],dp[j−1])。
  5. 当前格写完后才执行 diagonal=old_up,继续下一列。
  6. 全部行完成后返回 dp[-1]。
PYTHON 3
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 的分治重建。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 先把表头写成“前几个字符”,避免 i 与 i-1 的混淆。
  2. 字符相等时用左上角加 1;不等时才比较上与左。
  3. 相同字符出现多次也不能反复拿同一个位置;前缀边界负责维持顺序。
← 返回题库
多维动态规划95 / 100

72. 编辑距离 中等

标签:字符串 · 动态规划

力扣原题 ↗
01 / 理解题目

一句话题意

允许插入、删除或替换一个字符,求把 word1 转换成 word2 所需的最少操作数。

教学示例

word1 = "abc",word2 = "ad";最少编辑 2 次,例如删除 b、把 c 替换为 d。

生活化理解

把 word1 当成待校对稿、word2 当成标准稿。校对员比较两段前缀:可以划掉原稿末字、补上标准稿末字,或把末字改对;每一步付一张操作票,DP 记最少用了多少票。

02 / 讲透主解法

二维编辑距离 DP

把删除、插入、替换分别放在上、左、左上三个来源中,完整表格能清楚解释最少操作数。

核心思路

dp[i][j] 是把 word1 前 i 个字符变成 word2 前 j 个字符的最少编辑次数,表格的行是源前缀,列是目标前缀。源前缀变为空串要删除 i 次,空串变成目标前缀要插入 j 次,因此 dp[i][0]=idp[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 个字符的最少操作数。每次操作都对应一个更短前缀问题,按行填表时这些依赖都已最优;取最小值保证当前前缀也最优。

图解

1 / 14
编辑距离:行前缀 → 列前缀;· 表示尚未推演,源码预分配为 0
word1 / word20: ε1: a2: ad
0: ε···
1: a···
2: ab···
3: abc···
m / n
3 / 2
合法操作
插入 / 删除 / 替换
选择步骤 14 步
变量与图例

把前缀变成目标前缀:删除从上面来,插入从左边来,替换从左上来;末尾相同则直接沿左上走。

i / j
源前缀 / 目标前缀的长度;行是源,列是目标。
dp[i][j]
将行前缀变成列前缀的最少编辑次数。
上方 + 1
删掉源末尾字符。
左方 + 1
在源末尾插入目标末尾字符。
左上方 + 1 / +0
两末尾不同则替换;相同则保留,免费沿用。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 读取 m、n,建立 (m+1)×(n+1) 的 dp 表。
  2. 先逐行填写 dp[i][0]=i,再逐列填写 dp[0][j]=j,建立空串边界。
  3. 按 i 从 1 到 m、j 从 1 到 n 比较两个前缀的末字符。
  4. 末字符相同,继承 dp[i−1][j−1];不相同,取上、左、左上三格的最小值再加 1。
  5. 返回 dp[m][n],即把完整 word1 变为完整 word2 的最少操作次数。

代码(Python)

查看完整 Python 代码二维编辑距离 DP · 与上方图解对应

完整表可记录每个前缀的最优编辑次数,也能用于进一步恢复操作序列。

PYTHON 3
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)) 辅助空间。
03 / 检查边界

易错点与边界

  • 空字符串属于合法输入,首行首列是实际操作次数。
  • 末字符相等时直接继承,不能固定加 1。
  • 上方代表删除原串字符,左方代表插入目标字符,解释操作时要统一转换方向。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

一维滚动编辑距离时间 O((m+1)(n+1)) 时间,包含空串边界。

只保留一行前缀费用。当前格的上方由旧 dp[j] 表示,左方是已经更新的 dp[j−1],diagonal 保存旧左上角。每进入一行,必须先令 diagonal=dp[0],再写 dp[0]=i;否则首个内部格会丢失旧左上角,或空目标边界仍停留在上一行。逐格处理前先用 up 留住旧 dp[j],更新后再把 up 交给 diagonal。代码把较短字符串放在列上以节省空间;本题插入与删除代价相同,交换源串与目标串不改变最小次数。

  1. 若 word2 比 word1 长,交换两串;初始化 dp=[0,1,…,len(word2)],表示空源串到各目标前缀的费用。
  2. 枚举 word1 的字符 a 及其前缀长度 i,每行先保存 diagonal=dp[0],再设 dp[0]=i。
  3. 从左到右枚举 word2 的字符 b 与列 j,保存 up=dp[j]。
  4. a==b 时令 dp[j]=diagonal;否则令 dp[j]=1+min(up,dp[j−1],diagonal)。
  5. 执行 diagonal=up,让下一列仍能读取旧左上角。
  6. 全部行完成后返回 dp[-1]。
PYTHON 3
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 一次替换即可。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 表的方向决定操作含义:行是源串时,上方表示删除、左方表示插入。
  2. 空串边界不是全 0:ε→长度 j 要插入 j 次,长度 i→ε 要删除 i 次。
  3. 末尾相同不付费,直接用左上角;不同才给三种候选统一加 1。
← 返回题库
技巧96 / 100

136. 只出现一次的数字 简单

标签:位运算 · 数组

力扣原题 ↗
01 / 理解题目

一句话题意

非空整数数组中恰有一个数出现一次,其余每个数都出现两次,找出这个唯一值。

教学示例

nums = [4,1,2,1,2,4,7];只出现一次的数字是 7。

生活化理解

异或像成对开关:同一编号的开关按两次就恢复原状,不同编号的操作可以交换顺序。所有成对数字都抵消,最终开关状态就由那个只按了一次的数字决定。

02 / 讲透主解法

异或抵消

直接利用成对元素抵消的题目条件,只需一个累加器和一次扫描,证明与实现都简洁。

核心思路

异或逐位比较:相同位得到 0,不同位得到 1,所以 x^x=0x^0=x。它还满足交换律和结合律,相等数字即使在数组中相隔很远,也可以在代数上配成一对抵消,不需要先排序。

初始化 result=0,每读入一个元素 x,就执行 result ^= x。循环不变量是:result 等于已经扫描的整个前缀的异或。它可能是几个输入值按位混合后的结果,中途不一定出现在输入中,更不一定已经是最终的单例;必须让全部成对数字都参与后才能返回。

图中 4^1^2 一度等于 7,但后面还要继续处理第二个 1、2、4,最后才真正留下单独的 7。图中的“未抵消集合”只是解释配对的辅助画法,源码没有建立集合。Python 对负整数的异或也满足同样的恒等式,本题无需人为截断位宽。

不变量

result 始终等于已经扫描的所有数字的按位异或。异或可交换、可结合,且 x^x=0、0^x=x,所以相同的两个数即使相隔很远,也会在最终结果中抵消。

图解

1 / 9
逐位异或:相同位得 0,不同位得 140112213244576教学用未抵消集合:空旧 result000b2 b1 b0十进制 0当前 xb2 b1 b0尚未读取新 result000b2 b1 b0十进制 0
result
0
二进制
000
选择步骤 9 步
变量与图例

异或像一组灯的开关:某一位按两次会恢复原样;成对数字最终全部抵消,只留下落单数字的灯光。

x
本轮刚读到的数组元素。
result
已经处理的前缀的异或值,并不是当前唯一出现一次的候选数。
b2 / b1 / b0
本例使用三位二进制:对应权重 4、2、1。
未抵消数字
仅为解释的奇数次出现集合;源码不存这个集合。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 把异或累加器 result 初始化为 0。
  2. 从左到右读取每个 x,执行 result ^= x,保持前缀异或不变量。
  3. 全部元素处理后,相同的两项按异或性质抵消为 0。
  4. 返回剩下的 result,它就是唯一只出现一次的数字。

代码(Python)

查看完整 Python 代码异或抵消 · 与上方图解对应

result 始终等于已扫描前缀的异或,无需存储访问集合,满足线性时间和常数辅助空间要求。

PYTHON 3
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) 位。
03 / 检查边界

易错点与边界

  • 本题明确其余值都出现两次;若出现三次,简单异或不再得到唯一值。
  • 异或运算符是 ^,不是 **,也不是逻辑 or。
  • 累加器从 0 开始,而不是从 1 开始。
  • 中间异或结果不必来自原数组;负数和 0 都可直接参与。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

哈希计数基线时间 定长整数模型平均 O(n),哈希操作按平均 O(1) 计;大整数还需计入哈希和比较的位数成本。

用 Counter(nums) 建立 counts,counts[x] 直接记录每个数字的出现次数,再遍历键值对寻找 count==1 的数字。它不依赖异或抵消的代数性质,适合先说明直观方案,也容易迁移到查找指定频次的题目;代价是要为不同数字保存计数。源码的 next 返回第一个满足条件的键,本题保证恰有一个单例,因此这个键就是答案。

  1. 用 Counter 扫描 nums,得到每个不同数字的出现次数 counts。
  2. 遍历 counts.items() 中的数字 x 与次数 count。
  3. 筛出 count==1 的数字,由 next 返回这个唯一的键,而非返回计数值。
PYTHON 3
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 取模;那是另一套不变量,不能沿用两次抵消的结论。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 异或按二进制位工作,中间值可能根本不在已扫描前缀中。
  2. 成对元素不必相邻;交换律和结合律允许在解释中重新配对。
  3. “其余数出现两次”是关键条件,出现三次或存在多个单例时不能直接套这个模板。
← 返回题库
技巧97 / 100

169. 多数元素 简单

标签:数组 · 哈希表 · 分治 · 计数 · 排序 · 摩尔投票算法

力扣原题 ↗
01 / 理解题目

一句话题意

给定非空数组,题目保证存在出现次数严格大于 n/2 的多数元素,返回该元素。

教学示例

nums = [2,1,1,2,3,2,2];多数元素为 2,出现 4 次,超过 7/2。

生活化理解

不同阵营的人两两离场,同阵营的人留下。人数超过一半的阵营,哪怕每次都被另一方带走一个,最终仍有人剩下;candidate 是当前留下的阵营,balance 是它暂时未被抵消的人数。

02 / 讲透主解法

Boyer–Moore 摩尔投票

异值配对抵消的证明能支撑一次扫描、常数空间的实现,便于解释候选与余额的区别。

核心思路

只维护 candidatebalance: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 的未抵消票。删除异值对不可能消灭整个数组的多数元素,因此题目保证多数存在时,最终留下的候选必是它。

图解

1 / 9
抵消的是两张不同的票20111223342526剩余票堆仅作解释,实际额外空间为 O(1)当前未抵消票堆candidate = 2balance = 0已抵消的异值对暂无尚未读取第一张票
candidate
2
balance
0
选择步骤 9 步
变量与图例

不同阵营的两张票两两作废;剩余票堆为空时,下一张票成为新候选。观察“剩余优势票数”,别把它当出现次数。

candidate
当前票堆的阵营;balance=0 时变量仍保留旧值,但票堆已空。
balance
未被异值票抵消的优势票数,不是候选在原数组的总次数。
x
本轮读入的一张票。
抵消对 / 剩余票堆
仅为图解展开的容器,源码不保存它们。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 初始化 candidate=nums[0]、balance=0,随后从数组第一项开始遍历。
  2. 读取当前 x;如果 balance==0,先令 candidate=x。
  3. 比较 x 与 candidate:相同则 balance 加 1,不同则减 1。
  4. 继续扫描,余额归零时保留变量的旧值,等待下一轮重选候选。
  5. 遍历结束后,根据本题多数一定存在的保证,直接返回 candidate。

代码(Python)

查看完整 Python 代码Boyer–Moore 摩尔投票 · 与上方图解对应

balance 记录配对消去后候选的剩余票数;借助题目多数存在的保证,一趟扫描即可确定答案。

PYTHON 3
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) 辅助空间。
03 / 检查边界

易错点与边界

  • 多数是严格超过一半,不是恰好一半,也不只是出现次数最多。
  • balance 不是原数组频率,不能把它与 n//2 比较验证多数。
  • 余额为 0 时要重新设候选,然后把当前元素的一票计进去。
  • 数组非空且保证多数存在;一般化版本要二次计数确认候选。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

哈希计数时间 平均 O(n),n 为数组长度;哈希操作按定长整数平均 O(1) 计。

用 counts=Counter(nums) 显式记录每个数字的真实频率,再用 max(counts,key=counts.get) 选出次数最大的键。key=counts.get 表示按频率比较,不是按数字本身大小比较。题目保证某个数字超过一半,所以频率最高的数字必是多数。这个方法省去抵消证明,也能保留完整频率供后续使用,但需要额外计数空间;若没有多数存在的保证,最高频率的键未必超过一半,仍须检查阈值。

  1. 调用 Counter(nums),统计所有数字的出现次数。
  2. 遍历 counts 的键,使用 counts.get 取得频率作为 max 的比较依据。
  3. 返回频率最高的数字;本题的多数存在保证使它就是答案。
PYTHON 3
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。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 先说明抵消模型,再解释 candidate 与 balance 的赋值顺序。
  2. balance 归零不会自动清空 candidate 变量;下一轮才用当前 x 重选。
  3. 题目保证多数存在才能直接返回;若没有这个保证,需再数一次候选出现次数。
← 返回题库
技巧98 / 100

75. 颜色分类 中等

标签:数组 · 双指针 · 冒泡排序 · 排序 · 快速排序

力扣原题 ↗
01 / 理解题目

一句话题意

数组只含 0、1、2,要求原地把相同颜色聚在一起,并按 0、1、2 的顺序排列。

教学示例

nums = [1,2,0,2,1,0];原地排序后为 [0,0,1,1,2,2],方法返回 None。

生活化理解

把一排混放的三色球分成四个仓位:左侧已整理的 0,中间已整理的 1,尚未检验的区域,右侧已整理的 2。扫描员 i 每检查一个球,就把它送去对应的仓位。

02 / 讲透主解法

荷兰国旗三指针

用明确的区间不变量解释每次交换,一次缩短未知区即可原地完成三色排序。

核心思路

维护四段区间:[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 左移,未知区严格缩短;未知区为空时,三个有序色区便首尾相接。

图解

1 / 8
一箱一箱缩短未知区102102231405low / ihigh只有未知区 [0,5] 非空0 区 [0,low)空区间[]1 区 [low,i)空区间[]未知 [i,high]下标 0…5[1, 2, 0, 2, 1, 0]2 区 (high,n)空区间[]
low / i / high
0 / 0 / 5
未知区长度
6
选择步骤 8 步
变量与图例

把仓库分成已确认的 0 区、1 区、未知区和 2 区;i 只负责拆未知区最左边的一箱。

low
下一块 0 应放入的位置,也是 1 区的左边界。
i
下一块待检查的位置;只有确认换入值后才可右移。
high
未知区的最右位置;high 右边已全为 2。
[a,b) / [a,b]
半开 / 闭区间;空区间表示当前没有这类已确认元素。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 初始化 low=0、i=0、high=len(nums)−1,使所有元素都位于未知区。
  2. 当 i≤high 时检查 nums[i]。若为 0,交换 nums[low] 与 nums[i],然后 low、i 都加 1。
  3. 若当前值为 1,只把 i 加 1,扩大已知 1 区。
  4. 若当前值为 2,交换 nums[i] 与 nums[high],只把 high 减 1,保留 i 继续检查换入值。
  5. i>high 时结束;原 nums 已排好,无需返回新数组。

代码(Python)

查看完整 Python 代码荷兰国旗三指针 · 与上方图解对应

low、i、high 划分四段,依据换入元素是否已知决定是否推进 i,是一遍原地算法。

PYTHON 3
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。
03 / 检查边界

易错点与边界

  • 与 high 交换后不能 i+=1,否则会跳过新换入的未知值。
  • 循环是 i<=high,使用严格小于会漏掉最后一格。
  • 与 low 交换后可推进 i,因为换回的是已知的 1 或自身。
  • 只给 nums 重新赋一个排序列表不会修改调用者的原列表;应原地写入。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

计数后回填时间 O(n),一次计数加一次回填;n 为数组长度。

值域固定为 0、1、2,可以先用三个槽位 counts 统计各色数量,再按颜色顺序覆盖原数组。第一次扫描必须先完成,第二次才开始写入,避免把尚未统计的输入覆盖。回填指针 i 表示下一个要写的位置,已经写完的前缀始终有序;每个 color 连续写 counts[color] 次即可。这个版本同样原地修改且只用常数辅助空间,但分成计数与回填两遍,适合用更直观的流程解释小值域计数排序。

  1. 创建 counts=[0,0,0],完整扫描 nums,对每个 x 执行 counts[x]+=1。
  2. 统计结束后把写指针 i 设为 0。
  3. 按 color=0、1、2 的顺序,分别重复 counts[color] 次。
  4. 每次令 nums[i]=color,再将 i 加 1,依次构造有序前缀。
  5. 回填完全部元素后函数自然结束,返回 None。
PYTHON 3
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 区,破坏分区不变式。

面试追问与解法取舍

计数回填适用于只有颜色值的数组。如果元素是带颜色的对象,不能只重写颜色而破坏对象信息;三路交换能移动对象,但通常不稳定。若要求同色对象保持原相对顺序,需要额外讨论稳定分区或借助额外空间,不能声称当前交换过程稳定。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 画清四段区间,比死记三个指针的移动规则更可靠。
  2. 换走 2 时只缩 high:从右端换来的元素仍未知。
  3. 换走 0 时可以同时移动 low 与 i:若 low<i,换回 i 的一定是已知的 1。
← 返回题库
技巧99 / 100

31. 下一个排列 中等

标签:数组 · 双指针

力扣原题 ↗
01 / 理解题目

一句话题意

原地把整数数组改为字典序紧邻的下一个排列;若已是最大排列,则改为最小排列。

教学示例

nums = [1,3,5,4,2,2];下一个排列为 [1,4,2,2,3,5],原地修改并返回 None。

生活化理解

把排列看成由左到右重要性递减的密码。要只涨一点,应尽量保住前面的位,只把最靠右且还能变大的那位调高一点,再把后面的数字摆成最小顺序。

02 / 讲透主解法

枢轴交换与双指针反转

抓住不递增后缀的结构,三次线性处理即可找到刚好更大的排列,且只需常数辅助空间。

核心思路

从右向左寻找第一个 nums[i]<nums[i+1] 的位置 i,把它作为枢轴。i 右侧是不递增后缀,已经是这些数字的最大排列,只重排它不可能让整体变大,必须提升 i 或更左的位置。选择最靠右的 i,才能保留尽可能长、优先级更高的前缀。

接着让枢轴增加得尽量少:从末尾往左找第一个严格大于 nums[i]nums[j]。由于后缀不递增,这就是可用的最小较大值。交换后,后缀仍不递增:j 左侧不小于旧的候选值,j 右侧不大于旧枢轴,因此直接反转就能让后缀变为最小的升序排列。

left=i+1right=n-1 指向后缀两端,每次交换后同时向内移动,已归位的两端不再参与处理。图中把枢轴 3 提升为 4 后,只反转右侧 [5,3,2,2],得到 [1,4,2,2,3,5]。若 i 最终为 −1,整个数组已是最大排列,此时同样的端点设置会反转全数组,得到最小排列。函数原地修改 nums,返回值为 None。

不变量

从右向左扫描时,i 右侧保持不递增,已是这些元素能组成的最大后缀。找到首个 nums[i]<nums[i+1] 后,用后缀中最小的更大值提升枢轴,再把仍不递增的后缀反转为升序,就得到最小的更大排列。

图解

1 / 14
最右一位自身构成不递增后缀103152432425ii 从 4 开始;先比较下标 4 和 5枢轴与后缀i = 4,枢轴值 = 2后缀 = [2]当前操作最右一位自身构成不递增后缀读图顺序:先找 i,再找 j,最后反转
i
4
已知后缀
[2]
选择步骤 14 步
变量与图例

像拨动数字密码:尽量晚地增加一位,再把它右边调成最小,才能只比原排列大一点点。

i
从右往左找的枢轴下标;-1 表示整个数组已不递增。
j
从末尾向左寻找严格大于 nums[i] 的交换对象。
left / right
反转后缀时向内靠拢的两个端点。
后缀
枢轴右侧区间;提升枢轴前不递增,最后应变为不递减。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 从 i=len(nums)−2 开始向左扫描;只要 i≥0 且 nums[i]≥nums[i+1] 就继续左移。
  2. 若 i≥0,从末尾令 j 向左移动,直到 nums[j] 严格大于枢轴 nums[i]。
  3. 交换 nums[i] 与 nums[j],让最靠右的可提升前缀刚好变大。
  4. 设置 left=i+1、right=len(nums)−1;i=−1 时,这两个端点覆盖整个数组。
  5. 当 left<right 时交换两端,再让 left 加 1、right 减 1,将后缀反转为升序。
  6. 原数组中留下下一个排列,函数自然返回 None。

代码(Python)

查看完整 Python 代码枢轴交换与双指针反转 · 与上方图解对应

利用后缀单调性直接反转,不排序、不复制,满足原地常数空间要求。

PYTHON 3
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) 辅助空间,原地改写数组。
03 / 检查边界

易错点与边界

  • 找枢轴时跳过 >= 的相邻对,重复值不算上升。
  • 交换对象必须严格大于枢轴,从右找才能得到最小的较大值。
  • 交换后还须把后缀变成最小,否则会越过正确的下一个排列。
  • 完全降序和全相等数组都需处理 i=−1;长度为1时自然保持不变。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 1

枢轴交换与后缀排序基线时间 使用通用排序的保守上界 O(n log n)。这里后缀非递增,Python Timsort 可识别单调段,实际该输入形态可为 O(n),但显式反转更直接。

前半段仍使用同样的字典序论证:找到最靠右的枢轴,用后缀里最小的严格较大值与它交换。区别在最后一步,把 nums[i+1:] 交给 sorted,再通过切片赋值写回,直接获得固定新前缀下的最小后缀。这样写法简短,但没有显式利用后缀已经不递增的性质,且切片与排序结果会占用额外空间。切片赋值仍修改同一个原列表;若没有枢轴,i=−1 会让排序范围自然成为全数组。

  1. 从右向左找第一个严格上升相邻对,令其左端下标为 i。
  2. 若存在枢轴,从末尾寻找第一个 nums[j]>nums[i] 的位置。
  3. 交换枢轴与候选,使尽可能长的前缀保留,只在最晚的位置提升。
  4. 执行 nums[i+1:]=sorted(nums[i+1:]),将整个后缀以升序写回。
  5. 函数自然结束并返回 None;无枢轴时同一步会把全数组排成最小排列。
PYTHON 3
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 也必须跨过去?

一点提示

交换相等的数字能否让排列变得严格更大?

答案与理由

不能。后缀允许相等,扫描条件必须是 >=;找交换对象也必须跳过 <= 枢轴的值,确保提升是严格的。若全数组不递增,则直接反转得到最小排列。

面试追问与解法取舍

“上一个排列”可对称实现:找最靠右的下降枢轴,交换为后缀里最大的较小值,再把后缀调为非递增。若反复调用本题算法可按字典序遍历所有不同排列,但单次求后继不应先生成全部排列再排序,那会把线性问题变成阶乘规模。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 先找到尽量靠右的可提升位置,保证较早前缀保持相同。
  2. 不递增后缀从右向左遇到的第一个更大值,就是最小的合法提升量。
  3. 交换后后缀仍不递增,原地双指针反转即可,无需 O(n log n) 的排序。
← 返回题库
技巧100 / 100

287. 寻找重复数 中等

标签:位运算 · 数组 · 双指针 · 二分查找 · Floyd 判圈算法 · 抽屉原理

力扣原题 ↗
01 / 理解题目

一句话题意

长度 n+1 的数组取值均在 [1,n],仅有一种数字重复且可重复多次;不修改数组、使用常数额外空间找出它。

教学示例

nums = [1,3,4,2,2];重复数是 2。以 f(i)=nums[i] 建图:0→1→3→2→4→2→…

生活化理解

把数组当成自动路牌:站在编号 i 的路口,就沿 nums[i] 跳到下一个路口。有限路口中不停走一定绕圈;重复数字就是两条不同来路指向的同一路口,形成进入环的入口。

02 / 讲透主解法

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 没有入边。有限站点上的连续前进必进入环;环入口同时接收链外和环内的入边,对应同一个值出现至少两次。

图解

1 / 10
值 → 下一站下标:数组视图与函数图是同一件事1下标 0slow / fast3下标 14下标 22下标 32下标 4下图圆点表示下标;圆点旁标明 nums[下标] 的下一站0nums[0]=1slow / fast1nums[1]=33nums[3]=22nums[2]=44nums[4]=2环:2 → 4 → 2初始位置:slow = fast = 0例如 nums[3]=2,因此边是下标 3 → 下标 2。两枚指针虽然重合,但必须先移动再判断相遇。
slow / fast
0 / 0
函数路线
0→1→3→2→4→2…
选择步骤 10 步
变量与图例

数组值被当作下一站的下标:慢车每轮一站,快车两站,先在环里碰头,再从起点与碰头点同步走向入口。

nums[i]
从下标 i 指向的下一站下标;不是任意链表的节点值。
slow / fast
第一阶段每轮分别跳 1 条 / 2 条边,存的是当前下标。
finder
第二阶段从下标 0 出发,每轮跳 1 条边。
a / L / b
教学距离:0 到入口 a=3,环长 L=2,入口沿环到首次相遇点 b=1。
当前操作已处理依赖 / 候选冲突 / 排除

算法步骤

  1. 以 f(i)=nums[i] 理解下一跳,并初始化 slow=fast=0。
  2. 在第一阶段先执行 slow=nums[slow]、fast=nums[nums[fast]],移动后相等才跳出循环。
  3. 初始化 finder=0,保留 slow 在刚才的相遇点。
  4. 只要 finder!=slow,就让两者各沿 nums 跳一条边。
  5. 第二阶段相遇的位置是环入口,返回它的下标 finder,也就是重复的数组值。

代码(Python)

查看完整 Python 代码Floyd 快慢指针找入口 · 与上方图解对应

第一阶段在函数图环内相遇,第二阶段用从0出发的 finder 与 slow 同步寻找入口,不改变任何数组元素。

PYTHON 3
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) 辅助空间;只读数组,不修改输入。
03 / 检查边界

易错点与边界

  • 数组值是下一节点下标,fast 每轮是 nums[nums[fast]],不是 fast+=2。
  • 第一阶段初始两个指针都为0,先移动再判断,不能直接判等后退出。
  • 第一阶段的相遇点不一定是入口,必须执行第二阶段。
  • 重复值可能出现三次及以上;用数组和减去1..n之和不能通用求解。
04 / 拓展复习

主解之后,再比较与迁移。

其他解法 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 多了二分轮次。

  1. 设置 left=1、right=len(nums)−1,让区间覆盖所有可能的重复值。
  2. 当 left<right 时取 mid=(left+right)//2。
  3. 扫描整个 nums,统计 count=sum(x<=mid for x in nums);比较使用的是全局值阈值 mid。
  4. 若 count>mid,重复数在 [left,mid],令 right=mid;否则令 left=mid+1。
  5. 区间收缩为一个值时返回 left,过程中只读原数组。
PYTHON 3
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) 都满足只读和常数空间,前者依赖函数图结构,后者更容易扩展到“计数超过容量”的问题。

模板迁移 / 相似题

试着不看代码,复述这三点

  1. 先写清 f(i)=nums[i],数组值是边的目的下标;答案是环入口下标,也正是重复的数组值。
  2. 第一阶段只负责确保进入同一个环;不能把任意相遇点当入口。
  3. 第二阶段成立于从 0 开始的同一套初始化;不要混抄从 nums[0] 开始的另一版本指针规则。
分步图解