diff --git a/16-LeetCode Hot 100/三数之和.md b/16-LeetCode Hot 100/三数之和.md index 86d6369..07a0bda 100644 --- a/16-LeetCode Hot 100/三数之和.md +++ b/16-LeetCode Hot 100/三数之和.md @@ -36,26 +36,365 @@ nums[0] + nums[3] + nums[4] = (-1) + 2 + (-1) = 0 解释:唯一可能的三元组和为 0 ``` +## 思路推导 + +### 暴力解法分析 + +**最直观的思路**:三层循环枚举所有可能的三元组。 + +```python +def threeSum(nums): + result = set() + n = len(nums) + for i in range(n): + for j in range(i+1, n): + for k in range(j+1, n): + if nums[i] + nums[j] + nums[k] == 0: + # 排序后加入集合,避免重复 + triplet = sorted([nums[i], nums[j], nums[k]]) + result.add(tuple(triplet)) + return [list(t) for t in result] +``` + +**时间复杂度**:O(n³) +- 外层循环:O(n) +- 中层循环:O(n) +- 内层循环:O(n) +- 总计:O(n³) + +**空间复杂度**:O(1),不考虑结果存储 + +**问题分析**: +1. 效率太低:n=2000 时,n³ = 8×10⁹ 次运算,会超时 +2. 去重困难:需要额外的集合操作 +3. 无法利用已知信息优化 + +### 优化思考 - 第一步:降维 + +**观察**:固定第一个数后,问题变成"两数之和" + +```python +# 固定第一个数 nums[i] +# 问题转化为:在 nums[i+1:] 中找两个数,使和为 -nums[i] +``` + +**为什么这样思考?** +- 三数之和 = 固定一个数 + 两数之和 +- 两数之和可以用双指针 O(n) 解决 +- 总复杂度:O(n) × O(n) = O(n²) + +**优化后的思路**: +```python +for i in range(n): + target = -nums[i] + # 在 nums[i+1:] 中用双指针找两数之和为 target + twoSum(nums, i+1, target) +``` + +### 优化思考 - 第二步:双指针的前提条件 + +**问题**:为什么需要排序? + +**关键理解**:双指针依赖数组的**单调性** + +``` +假设数组有序:[-4, -1, -1, 0, 1, 2] + ↑ ↑ ↑ + i left right + +如果 nums[left] + nums[right] < target: +- 由于数组升序,增大 left → 和会变大 +- 减小 right → 和会变小 +- 所以应该 left++ + +如果 nums[left] + nums[right] > target: +- 减小 right → 和会变小 +- 所以应该 right-- +``` + +**不排序的后果**: +- 无法确定指针移动方向 +- 可能遗漏正确答案 +- 无法利用有序性进行剪枝 + +### 优化思考 - 第三步:去重策略 + +**去重的三个关键点**: + +1. **外层去重**:跳过重复的第一个数 +```python +if i > 0 and nums[i] == nums[i-1]: + continue +``` + +2. **内层去重**:找到答案后跳过重复元素 +```python +while left < right and nums[left] == nums[left+1]: + left += 1 +while left < right and nums[right] == nums[right-1]: + right -= 1 +``` + +3. **为什么排序有利于去重**? +- 相同的数会相邻 +- 只需比较相邻元素即可去重 +- 时间复杂度从 O(n²) 降到 O(n) + ## 解题思路 ### 核心思想 + **排序 + 双指针**:先排序,固定第一个数,再用双指针找后两个数。 -### 算法流程 -1. **排序数组**:便于去重和双指针操作 -2. **遍历第一个数**: - - 跳过重复元素 - - 如果当前数 > 0,直接退出(后面都 > 0) -3. **双指针找后两个数**: - - left = i + 1, right = len(nums) - 1 - - 根据 sum 与 0 的关系移动指针 - - 跳过重复元素 +**为什么这样思考?** -### 复杂度分析 -- **时间复杂度**:O(n²),排序 O(n log n) + 双指针 O(n²) -- **空间复杂度**:O(1),不考虑结果存储 +1. **排序的作用**: + - 去除重复结果(相同数相邻) + - 使数组有序,才能使用双指针 + - 提前终止(如果当前数>0,后面都>0) ---- +2. **双指针的原理**: + - 数组有序后,如果 sum < target,需要增大 → left++ + - 如果 sum > target,需要减小 → right-- + - 利用单调性,避免暴力枚举 + +3. **降维思想**: + - 三数之和 → 固定一个数 → 两数之和 + - O(n³) → O(n²) + +### 详细算法流程 + +**步骤1:预处理 - 排序** + +```python +nums.sort() # O(n log n) +``` + +**作用**: +- 去重:相同元素相邻 +- 双指针基础:利用有序性 +- 提前终止:最小数>0则退出 + +**步骤2:外层循环 - 固定第一个数** + +```python +for i in range(len(nums) - 2): + # 去重:跳过重复元素 + if i > 0 and nums[i] == nums[i-1]: + continue + + # 提前终止:如果最小数>0,后面不可能=0 + if nums[i] > 0: + break + + # 双指针找后两个数 + left, right = i + 1, len(nums) - 1 + target = -nums[i] + + while left < right: + current_sum = nums[left] + nums[right] + + if current_sum == target: + result.append([nums[i], nums[left], nums[right]]) + + # 去重:跳过重复的left + while left < right and nums[left] == nums[left+1]: + left += 1 + # 去重:跳过重复的right + while left < right and nums[right] == nums[right-1]: + right -= 1 + + # 同时移动,寻找下一组解 + left += 1 + right -= 1 + + elif current_sum < target: + left += 1 # 需要更大的和 + else: + right -= 1 # 需要更小的和 +``` + +**关键点详解**: + +1. **为什么循环到 `len(nums)-2`?** + - 需要留2个数给双指针 + - i 最大只能到 n-3 + +2. **为什么判断 `i > 0`?** + - 第一个元素不用判断重复 + - 避免越界访问 + +3. **为什么用 `break` 而不是 `continue`?** + - 后面都>0,不可能和为0 + - 直接退出外层循环 + +**步骤3:内层双指针 - 两数之和** + +```python +def twoSum(nums, start, target): + left, right = start, len(nums) - 1 + + while left < right: + current_sum = nums[left] + nums[right] + + if current_sum == target: + result.append([-target, nums[left], nums[right]]) + + # 去重:跳过重复的left + while left < right and nums[left] == nums[left+1]: + left += 1 + # 去重:跳过重复的right + while left < right and nums[right] == nums[right-1]: + right -= 1 + + # 同时移动,寻找下一组解 + left += 1 + right -= 1 + + elif current_sum < target: + left += 1 # 需要更大的和 + else: + right -= 1 # 需要更小的和 +``` + +**关键点**: +- 为什么找到答案后还要跳过重复?避免重复结果 +- 为什么找到答案后要同时移动?继续寻找其他组合 + +### 关键细节说明 + +**细节1:为什么是 `if i > 0`?** + +```python +# 错误写法 +if nums[i] == nums[i-1]: # i=0时会越界! + continue + +# 正确写法 +if i > 0 and nums[i] == nums[i-1]: # 第一个元素不用判断 + continue +``` + +**细节2:为什么找到答案后要同时移动?** + +``` +假设:[-2, 0, 1, 1, 2] + i L R + +找到:-2 + 0 + 2 = 0 ✓ + +如果只移动一个指针: +- L++: [-2, 0, 1, 1, 2] → -2 + 1 + 2 = 1 > 0 → R-- + 但这样可能错过其他组合 + +正确做法:同时移动 +- L++ and R--: 继续寻找其他可能的组合 +``` + +**细节3:为什么break而不是continue?** + +```python +if nums[i] > 0: + break # 正确:后面的数都>0,不可能和为0 + # continue # 错误:会继续无意义的循环 +``` + +**推理**: +- 数组已排序 +- 如果 nums[i] > 0,则 nums[i+1] >= nums[i] > 0 +- 任意三个正数相加不可能为0 +- 直接退出,节省时间 + +**细节4:为什么使用 `while` 而不是 `if` 去重?** + +```python +# 错误写法:只跳过一个重复元素 +if nums[left] == nums[left+1]: + left += 1 + +# 正确写法:跳过所有重复元素 +while left < right and nums[left] == nums[left+1]: + left += 1 +``` + +**示例**: +``` +数组:[-2, -1, -1, -1, 0, 1, 2] + i L R + +找到:-2 + (-1) + 3 = 0 ✓ + +如果用 if: + 只跳过一个-1,还会重复 + +如果用 while: + 跳过所有-1,避免重复 +``` + +### 边界条件分析 + +**边界1:数组长度不足** + +``` +输入:[0, 1] +输出:[] +原因:长度<3,无法组成三元组 +处理:循环条件 range(len(nums)-2) 自动处理 +``` + +**边界2:全部为0** + +``` +输入:[0, 0, 0, 0] +输出:[[0, 0, 0]] +去重逻辑:只保留一个组合 +过程: + - i=0: 找到 [0,0,0],跳过后续重复 + - i=1: nums[1]==nums[0],跳过 + - i=2: nums[2]==nums[1],跳过 +``` + +**边界3:有重复元素** + +``` +输入:[-1, -1, 0, 1] +输出:[[-1, 0, 1]] +去重逻辑:跳过第二个-1 +过程: + - i=0: 找到 [-1,0,1] + - i=1: nums[1]==nums[0],跳过 +``` + +**边界4:最小的正整数情况** + +``` +输入:[-2, -1, 0, 1, 2] +输出:[[-2, 0, 2], [-2, -1, 3], [-1, 0, 1]] +提前终止:i=0时nums[i]=-2<0,继续 + i=3时nums[i]=1>0,退出 +``` + +### 复杂度分析(详细版) + +**时间复杂度**: +``` +- 排序:O(n log n) +- 外层循环:O(n) +- 内层双指针:O(n) +- 总计:O(n log n) + O(n²) = O(n²) + +为什么主项是O(n²)? +- n² >> n log n (当n较大时) +- 渐近复杂度取最高阶 +``` + +**空间复杂度**: +``` +- 排序:O(log n) (快速排序栈空间) +- 结果存储:O(k) (k为结果数量) +- 指针变量:O(1) +- 总计:O(log n) (不考虑结果存储) +``` --- @@ -101,10 +440,199 @@ nums[0] + nums[3] + nums[4] = (-1) + 2 + (-1) = 0 --- +## 代码实现 + +```go +func threeSum(nums []int) [][]int { + result := [][]int{} + n := len(nums) + + // 步骤1:排序 + sort.Ints(nums) + + // 步骤2:外层循环,固定第一个数 + for i := 0; i < n-2; i++ { + // 去重:跳过重复的第一个数 + if i > 0 && nums[i] == nums[i-1] { + continue + } + + // 提前终止:如果最小数>0,后面不可能=0 + if nums[i] > 0 { + break + } + + // 双指针找后两个数 + left, right := i+1, n-1 + target := -nums[i] + + for left < right { + sum := nums[left] + nums[right] + + if sum == target { + result = append(result, []int{nums[i], nums[left], nums[right]}) + + // 去重:跳过重复的left + for left < right && nums[left] == nums[left+1] { + left++ + } + // 去重:跳过重复的right + for left < right && nums[right] == nums[right-1] { + right-- + } + + // 同时移动,寻找下一组解 + left++ + right-- + + } else if sum < target { + left++ // 需要更大的和 + } else { + right-- // 需要更小的和 + } + } + } + + return result +} +``` + +--- + +## 执行过程演示 + +**输入**:[-1, 0, 1, 2, -1, -4] + +**排序后**:[-4, -1, -1, 0, 1, 2] + +``` +i=0, nums[i]=-4, target=4 + left=1, right=5: -1+2=1 < 4 → left++ + left=2, right=5: -1+2=1 < 4 → left++ + left=3, right=5: 0+2=2 < 4 → left++ + left=4, right=5: 1+2=3 < 4 → left++ + left=5, right=5: 退出 + +i=1, nums[i]=-1, target=1 + left=2, right=5: -1+2=1 == 1 ✓ + 添加 [-1, -1, 2] + left=3, right=4 + left=3, right=4: 0+1=1 == 1 ✓ + 添加 [-1, 0, 1] + left=4, right=3: 退出 + +i=2, nums[i]=-1 (重复,跳过) + +i=3, nums[i]=0 > 0, 退出 + +结果:[[-1, -1, 2], [-1, 0, 1]] +``` + +--- + +## 常见错误 + +### 错误1:忘记排序 + +❌ **错误代码**: +```go +func threeSum(nums []int) [][]int { + // 直接遍历,没有排序 + for i := 0; i < len(nums)-2; i++ { + // ... + } +} +``` + +✅ **正确代码**: +```go +func threeSum(nums []int) [][]int { + sort.Ints(nums) // 必须先排序 + for i := 0; i < len(nums)-2; i++ { + // ... + } +} +``` + +**原因**:不排序无法使用双指针,无法去重 + +--- + +### 错误2:去重逻辑不完整 + +❌ **错误代码**: +```go +// 只去重了第一个数 +if i > 0 && nums[i] == nums[i-1] { + continue +} +// left和right没有去重 +``` + +✅ **正确代码**: +```go +// 三个地方都要去重 +if i > 0 && nums[i] == nums[i-1] { + continue +} + +for left < right && nums[left] == nums[left+1] { + left++ +} +for left < right && nums[right] == nums[right-1] { + right-- +} +``` + +**原因**:避免重复结果 + +--- + +### 错误3:指针移动条件错误 + +❌ **错误代码**: +```go +if sum < target { + right-- // 错误!应该增大和 +} +``` + +✅ **正确代码**: +```go +if sum < target { + left++ // 正确!增大left可以增大和 +} +``` + +**原因**:数组有序,left越大,和越大 + +--- + +### 错误4:提前终止条件错误 + +❌ **错误代码**: +```go +if nums[i] >= 0 { // 错误!等于0也要继续 + break +} +``` + +✅ **正确代码**: +```go +if nums[i] > 0 { // 正确!大于0才退出 + break +} +``` + +**原因**:[0, 0, 0] 是有效答案 + +--- + ## 进阶问题 ### Q1: 如果是四数之和? -**方法**:在三层循环 + 双指针,时间 O(n³) + +**方法**:两层循环 + 双指针,时间 O(n³) ```go func fourSum(nums []int, target int) [][]int { @@ -151,7 +679,8 @@ func fourSum(nums []int, target int) [][]int { } ``` -### Q2: 如果数组很大,如何优化? +**Q2: 如果数组很大,如何优化?** + **优化**: 1. 提前终止:`nums[i] * 3 > target`(正数情况) 2. 二分查找:确定第二个数后,二分查找后两个 @@ -180,7 +709,7 @@ func fourSum(nums []int, target int) [][]int { ## 总结 -这道题的核心是: +**核心要点**: 1. **排序**:为双指针和去重创造条件 2. **固定一个数**:将问题转化为两数之和 3. **双指针**:根据 sum 与 target 的关系移动指针 diff --git a/16-LeetCode Hot 100/二叉树的中序遍历.md b/16-LeetCode Hot 100/二叉树的中序遍历.md index 1f4a6b6..eb8730a 100644 --- a/16-LeetCode Hot 100/二叉树的中序遍历.md +++ b/16-LeetCode Hot 100/二叉树的中序遍历.md @@ -1,37 +1,668 @@ # 二叉树的中序遍历 (Binary Tree Inorder Traversal) +LeetCode 94. 简单 + ## 题目描述 给定一个二叉树的根节点,返回它的中序遍历。 +**示例 1:** +``` +输入:root = [1,null,2,3] +输出:[1,3,2] +``` + +**示例 2:** +``` +输入:root = [] +输出:[] +``` + +**示例 3:** +``` +输入:root = [1] +输出:[1] +``` + +## 思路推导 + +### 什么是中序遍历? + +中序遍历的顺序是:**左子树 → 根节点 → 右子树** + +``` + 1 + / \ + 2 3 + / \ + 4 5 + +中序遍历: [4, 2, 5, 1, 3] + ↑ ↑ ↑ + 左 根 右 +``` + +### 暴力解法分析 + +**递归解法 - 最直观的思路** + +```go +func inorderTraversal(root *TreeNode) []int { + result := []int{} + inorder(root, &result) + return result +} + +func inorder(node *TreeNode, result *[]int) { + if node == nil { + return + } + // 1. 先遍历左子树 + inorder(node.Left, result) + // 2. 再访问根节点 + *result = append(*result, node.Val) + // 3. 最后遍历右子树 + inorder(node.Right, result) +} +``` + +**时间复杂度**: O(n) - 每个节点访问一次 +**空间复杂度**: O(h) - h为树高,递归栈空间 + +**问题**: 递归解法虽然简单,但面试官常要求用迭代实现 + +### 优化思考 - 递归转迭代 + +**核心问题**: 如何用栈模拟递归的调用过程? + +**观察递归的执行过程**: +``` +inorder(1): + inorder(2): <- 栈帧1 + inorder(4): <- 栈帧2 + 访问4 <- 栈帧3 + 访问2 + inorder(5): + 访问5 + 访问1 + inorder(3): + 访问3 +``` + +**关键发现**: +1. 递归调用就是**压栈** +2. 递归返回就是**出栈** +3. 中序遍历先找**最左节点**,再逐层返回 + +### 为什么迭代法这样写? + +**核心思想**: +1. 一直往左走,把路径上的节点都入栈 +2. 到达最左节点后,出栈并访问 +3. 转向右子树,重复步骤1 + +**为什么这样思考?** +- 模拟递归的调用栈 +- 利用栈的"后进先出"特性 +- 先保存路径,再反向访问 + ## 解题思路 +### 方法一:递归(直观但空间开销大) + +### 核心思想 +按照"左→根→右"的顺序递归访问节点。 + +### 算法流程 + +**步骤1: 定义递归函数** +```go +func inorder(node *TreeNode, result *[]int) +``` + +**步骤2: 递归终止条件** +```go +if node == nil { + return // 空节点直接返回 +} +``` + +**步骤3: 按顺序递归** +```go +// 1. 先遍历左子树 +inorder(node.Left, result) + +// 2. 再访问根节点 +*result = append(*result, node.Val) + +// 3. 最后遍历右子树 +inorder(node.Right, result) +``` + +### 方法二:迭代(栈模拟递归) + +### 核心思想 +用显式栈替代递归调用栈,模拟递归的执行过程。 + +### 详细算法流程 + +**步骤1: 初始化数据结构** +```go +result := []int{} // 存储遍历结果 +stack := []*TreeNode{} // 模拟递归栈 +curr := root // 当前访问的节点 +``` + +**步骤2: 外层循环 - 只要还有节点可访问** +```go +for curr != nil || len(stack) > 0 { + // ... +} +``` + +**关键点**: +- `curr != nil`: 还有节点需要处理 +- `len(stack) > 0`: 栈中还有未访问的节点 +- 两个条件满足一个就可以继续 + +**步骤3: 内层循环 - 一直往左走,找到最左节点** +```go +for curr != nil { + stack = append(stack, curr) // 路径上的节点都入栈 + curr = curr.Left // 继续往左走 +} +``` + +**图解**: +``` + 1 + / \ + 2 3 + / \ + 4 5 + +第一次内层循环后: +stack: [1, 2, 4] <- 从根到最左的路径 +curr: nil <- 4的左子树为空 +``` + +**步骤4: 出栈并访问** +```go +curr = stack[len(stack)-1] // 取栈顶元素 +stack = stack[:len(stack)-1] // 出栈 +result = append(result, curr.Val) // 访问该节点 +``` + +**图解**: +``` +出栈4并访问: +stack: [1, 2] +result: [4] +curr: 4 +``` + +**步骤5: 转向右子树** +```go +curr = curr.Right +``` + +**图解**: +``` +4的右子树为空,进入下次循环: +stack: [1, 2] +curr: nil + +再次出栈2并访问: +stack: [1] +result: [4, 2] +curr: 2 + +转向2的右子树: +curr: 5 +``` + +### 关键细节说明 + +**细节1: 为什么是 `for curr != nil || len(stack) > 0`?** + +```go +// ❌ 错误写法 +for curr != nil { // 会漏掉栈中剩余的节点 + +// ✅ 正确写法 +for curr != nil || len(stack) > 0 { +``` + +**原因**: +- `curr == nil` 时,栈中可能还有节点 +- `len(stack) == 0` 时,curr可能指向某个右子树 +- 两个条件需要同时考虑 + +**细节2: 为什么是 `curr = curr.Right` 而不是继续往左?** + +```go +// 出栈并访问后 +result = append(result, curr.Val) +curr = curr.Right // 转向右子树 +``` + +**原因**: 中序遍历顺序是"左→根→右" +- 访问完根节点后,该访问右子树了 +- 右子树也要按"左→根→右"遍历 +- 下次循环会从右子树的最左节点开始 + +**细节3: 为什么内层循环要一直往左走?** + +```go +for curr != nil { + stack = append(stack, curr) + curr = curr.Left // 一直往左 +} +``` + +**原因**: 中序遍历先访问左子树 +- 需要找到最左节点(第一个要访问的节点) +- 保存路径上的所有节点(之后要逐个访问) +- 利用栈的LIFO特性,反向访问 + +### 边界条件分析 + +**边界1: 空树** +``` +输入: root = nil +输出: [] +处理: 直接返回空数组 +``` + +**边界2: 只有根节点** +``` +输入: root = [1] +输出: [1] +过程: +1. curr=1入栈 +2. curr=1.Left=nil +3. 出栈1,访问 +4. curr=1.Right=nil +5. 栈空,curr=nil,退出 +``` + +**边界3: 只有左子树** +``` +输入: + 1 + / + 2 + / +3 + +输出: [3, 2, 1] +过程: 一直往左,找到3,再逐层返回 +``` + +**边界4: 只有右子树** +``` +输入: + 1 + \ + 2 + \ + 3 + +输出: [1, 2, 3] +过程: +1. 1入栈,curr=nil +2. 出栈1,访问,curr=2 +3. 2入栈,curr=nil +4. 出栈2,访问,curr=3 +5. 3入栈,curr=nil +6. 出栈3,访问,curr=nil +7. 栈空,退出 +``` + +### 复杂度分析(详细版) + +**时间复杂度**: +``` +- 外层循环: O(n) - 每个节点入栈出栈一次 +- 内层循环: 总计O(n) - 每个节点被访问一次 +- 总计: O(n) + +为什么每个节点只访问一次? +- 入栈: 一次 +- 出栈: 一次 +- 访问: 一次 +- 没有重复操作 +``` + +**空间复杂度**: +``` +- 栈空间: O(h) - h为树高 + - 最坏情况(链状树): O(n) + - 最好情况(完全平衡树): O(log n) +- 结果存储: O(n) +- 总计: O(n) +``` + +### 执行过程演示 + +**输入**: +``` + 1 + / \ + 2 3 + / \ + 4 5 +``` + +**执行过程**: +``` +初始状态: +stack: [] +result: [] +curr: 1 + +第1次内层循环(往左走): +stack: [1, 2, 4] +curr: nil + +出栈4: +stack: [1, 2] +result: [4] +curr: nil (4.Right) + +出栈2: +stack: [1] +result: [4, 2] +curr: 5 + +第2次内层循环(从5开始): +stack: [1, 5] +curr: nil + +出栈5: +stack: [1] +result: [4, 2, 5] +curr: nil (5.Right) + +出栈1: +stack: [] +result: [4, 2, 5, 1] +curr: 3 + +第3次内层循环(从3开始): +stack: [3] +curr: nil + +出栈3: +stack: [] +result: [4, 2, 5, 1, 3] +curr: nil (3.Right) + +退出循环: +final result: [4, 2, 5, 1, 3] +``` + +## 代码实现 + ### 方法一:递归 -### 方法二:迭代(栈) +```go +func inorderTraversal(root *TreeNode) []int { + result := []int{} + inorder(root, &result) + return result +} -## 解法 +func inorder(node *TreeNode, result *[]int) { + if node == nil { + return + } + // 1. 先遍历左子树 + inorder(node.Left, result) + // 2. 再访问根节点 + *result = append(*result, node.Val) + // 3. 最后遍历右子树 + inorder(node.Right, result) +} +``` + +**复杂度**: O(n) 时间,O(h) 空间 + +### 方法二:迭代(推荐) ```go func inorderTraversal(root *TreeNode) []int { result := []int{} stack := []*TreeNode{} curr := root - + + // 只要还有节点可访问 + for curr != nil || len(stack) > 0 { + // 一直往左走,把路径上的节点都入栈 + for curr != nil { + stack = append(stack, curr) + curr = curr.Left + } + + // 出栈并访问 + curr = stack[len(stack)-1] + stack = stack[:len(stack)-1] + result = append(result, curr.Val) + + // 转向右子树 + curr = curr.Right + } + + return result +} +``` + +**复杂度**: O(n) 时间,O(n) 空间 + +### 方法三:Morris遍历(空间O(1)) + +```go +func inorderTraversal(root *TreeNode) []int { + result := []int{} + curr := root + + for curr != nil { + if curr.Left == nil { + // 没有左子树,访问当前节点 + result = append(result, curr.Val) + curr = curr.Right + } else { + // 找到左子树的最右节点(前驱节点) + prev := curr.Left + for prev.Right != nil && prev.Right != curr { + prev = prev.Right + } + + if prev.Right == nil { + // 第一次访问,建立线索 + prev.Right = curr + curr = curr.Left + } else { + // 第二次访问,删除线索并访问当前节点 + prev.Right = nil + result = append(result, curr.Val) + curr = curr.Right + } + } + } + + return result +} +``` + +**复杂度**: O(n) 时间,O(1) 空间 + +**原理**: 利用空指针存储临时信息,避免使用栈 + +## 常见错误 + +### 错误1: 循环条件错误 + +❌ **错误写法**: +```go +for curr != nil { // 漏掉了栈中还有节点的情况 + // ... +} +``` + +✅ **正确写法**: +```go +for curr != nil || len(stack) > 0 { + // ... +} +``` + +**原因**: 当 `curr == nil` 时,栈中可能还有未访问的节点 + +### 错误2: 出栈后忘记转向右子树 + +❌ **错误写法**: +```go +curr = stack[len(stack)-1] +stack = stack[:len(stack)-1] +result = append(result, curr.Val) +// 缺少这一行: curr = curr.Right +``` + +✅ **正确写法**: +```go +curr = stack[len(stack)-1] +stack = stack[:len(stack)-1] +result = append(result, curr.Val) +curr = curr.Right // 重要:转向右子树 +``` + +**原因**: 访问完根节点后,必须访问右子树 + +### 错误3: 内层循环条件错误 + +❌ **错误写法**: +```go +for curr.Left != nil { // 错误:会导致最左节点不入栈 + stack = append(stack, curr) + curr = curr.Left +} +``` + +✅ **正确写法**: +```go +for curr != nil { + stack = append(stack, curr) + curr = curr.Left +} +``` + +**原因**: 最左节点也需要入栈,然后出栈访问 + +## 变体问题 + +### 变体1: 前序遍历 + +**顺序**: 根 → 左 → 右 + +```go +func preorderTraversal(root *TreeNode) []int { + result := []int{} + stack := []*TreeNode{} + curr := root + + for curr != nil || len(stack) > 0 { + for curr != nil { + result = append(result, curr.Val) // 先访问 + stack = append(stack, curr) + curr = curr.Left + } + curr = stack[len(stack)-1] + stack = stack[:len(stack)-1] + curr = curr.Right + } + + return result +} +``` + +### 变体2: 后序遍历 + +**顺序**: 左 → 右 → 根 + +```go +func postorderTraversal(root *TreeNode) []int { + result := []int{} + stack := []*TreeNode{} + curr := root + var lastVisited *TreeNode + for curr != nil || len(stack) > 0 { for curr != nil { stack = append(stack, curr) curr = curr.Left } - + curr = stack[len(stack)-1] - stack = stack[:len(stack)-1] - result = append(result, curr.Val) - curr = curr.Right + if curr.Right == nil || curr.Right == lastVisited { + stack = stack[:len(stack)-1] + result = append(result, curr.Val) + lastVisited = curr + curr = nil + } else { + curr = curr.Right + } } - + return result } ``` -**复杂度:** O(n) 时间,O(n) 空间 +### 变体3: 层序遍历(BFS) + +使用队列而非栈: + +```go +func levelOrder(root *TreeNode) [][]int { + if root == nil { + return [][]int{} + } + + result := [][]int{} + queue := []*TreeNode{root} + + for len(queue) > 0 { + level := []int{} + size := len(queue) + for i := 0; i < size; i++ { + node := queue[0] + queue = queue[1:] + level = append(level, node.Val) + if node.Left != nil { + queue = append(queue, node.Left) + } + if node.Right != nil { + queue = append(queue, node.Right) + } + } + result = append(result, level) + } + + return result +} +``` + +## 总结 + +**核心要点**: +1. **中序遍历顺序**: 左 → 根 → 右 +2. **迭代法核心**: 用栈模拟递归调用 +3. **关键操作**: 一直往左 → 出栈访问 → 转向右边 +4. **循环条件**: `curr != nil || len(stack) > 0` + +**易错点**: +- 循环条件容易漏掉栈的情况 +- 出栈后忘记转向右子树 +- 内层循环条件错误(应该用 `curr != nil`) + +**推荐写法**: 迭代法(空间可控,面试常考) diff --git a/16-LeetCode Hot 100/二叉树的最大深度.md b/16-LeetCode Hot 100/二叉树的最大深度.md index 2870329..170a711 100644 --- a/16-LeetCode Hot 100/二叉树的最大深度.md +++ b/16-LeetCode Hot 100/二叉树的最大深度.md @@ -1,29 +1,751 @@ # 二叉树的最大深度 (Maximum Depth of Binary Tree) +LeetCode 104. 简单 + ## 题目描述 给定一个二叉树,找出其最大深度。 -## 解题思路 +二叉树的深度为根节点到最远叶子节点的最长路径上的节点数。 -### DFS / BFS +**说明**: 叶子节点是指没有子节点的节点。 -## 解法 +**示例 1:** +``` +输入:root = [3,9,20,null,null,15,7] +输出:3 +``` + +**示例 2:** +``` +输入:root = [1,null,2] +输出:2 +``` + +**示例 3:** +``` +输入:root = [] +输出:0 +``` + +## 思路推导 + +### 什么是树的深度? + +**深度定义**: 从根节点到某个节点的路径上的节点数 + +``` + 3 <- 深度1 + / \ + 9 20 <- 深度2 + / \ + 15 7 <- 深度3 + +最大深度 = 3 +``` + +### 暴力解法分析 + +**思路**: 一层一层遍历,数一数有多少层 + +**BFS解法 - 层序遍历** ```go func maxDepth(root *TreeNode) int { if root == nil { return 0 } - - left := maxDepth(root.Left) - right := maxDepth(root.Right) - - if left > right { - return left + 1 + + queue := []*TreeNode{root} + depth := 0 + + for len(queue) > 0 { + depth++ // 进入新的一层 + levelSize := len(queue) // 当前层的节点数 + + // 处理当前层的所有节点 + for i := 0; i < levelSize; i++ { + node := queue[0] + queue = queue[1:] + + if node.Left != nil { + queue = append(queue, node.Left) + } + if node.Right != nil { + queue = append(queue, node.Right) + } + } } - return right + 1 + + return depth } ``` -**复杂度:** O(n) 时间,O(h) 空间(h 为高度) +**时间复杂度**: O(n) - 每个节点访问一次 +**空间复杂度**: O(w) - w为树的最大宽度(最后一层的节点数) + +**问题**: 需要额外空间存储队列 + +### 优化思考 - 递归的直观性 + +**核心问题**: 能否更直观地计算深度? + +**观察**: +- 树的深度 = max(左子树深度, 右子树深度) + 1 +- 这就是递归的定义! + +**递归思路**: +``` +maxDepth(root) = + 0, if root == nil + max(maxDepth(left), maxDepth(right)) + 1, otherwise +``` + +### 为什么递归这样写? + +**核心思想**: +1. 空树深度为0(基准情况) +2. 非空树深度 = max(左子树深度, 右子树深度) + 1 +3. 递归计算左右子树深度 + +**为什么这样思考?** +- 分治思想:大问题分解为小问题 +- 树的递归定义天然适合递归求解 +- 每个节点只需要知道左右子树的深度 + +## 解题思路 + +### 方法一:深度优先搜索(DFS)- 递归 + +### 核心思想 +树的深度由其最深子树决定,递归计算左右子树深度,取最大值加1。 + +### 详细算法流程 + +**步骤1: 定义递归函数** +```go +func maxDepth(root *TreeNode) int +``` + +**步骤2: 确定基准情况(终止条件)** +```go +if root == nil { + return 0 // 空树深度为0 +} +``` + +**关键点**: 这是递归的出口,必须明确 + +**步骤3: 递归计算左右子树深度** +```go +leftDepth := maxDepth(root.Left) // 左子树深度 +rightDepth := maxDepth(root.Right) // 右子树深度 +``` + +**步骤4: 返回当前树的最大深度** +```go +if leftDepth > rightDepth { + return leftDepth + 1 +} else { + return rightDepth + 1 +} +``` + +**简化写法**: +```go +return max(leftDepth, rightDepth) + 1 +``` + +**图解**: +``` + 3 maxDepth(3) + / \ = max(maxDepth(9), maxDepth(20)) + 1 + 9 20 = max(1, 2) + 1 + / \ = 3 + 15 7 + +递归过程: +maxDepth(9) = max(maxDepth(nil), maxDepth(nil)) + 1 = 1 +maxDepth(20) = max(maxDepth(15), maxDepth(7)) + 1 + = max(1, 1) + 1 = 2 +maxDepth(15) = 1 +maxDepth(7) = 1 +``` + +### 方法二:广度优先搜索(BFS)- 迭代 + +### 核心思想 +按层遍历树,每遍历一层深度加1,直到遍历完所有节点。 + +### 详细算法流程 + +**步骤1: 初始化队列和深度** +```go +if root == nil { + return 0 +} + +queue := []*TreeNode{root} // 将根节点入队 +depth := 0 // 当前深度 +``` + +**步骤2: 按层遍历** +```go +for len(queue) > 0 { + depth++ // 进入新的一层 + levelSize := len(queue) // 记录当前层节点数 + + // 处理当前层的所有节点 + for i := 0; i < levelSize; i++ { + node := queue[0] + queue = queue[1:] + + // 将下一层节点入队 + if node.Left != nil { + queue = append(queue, node.Left) + } + if node.Right != nil { + queue = append(queue, node.Right) + } + } +} +``` + +**关键点**: +- `levelSize` 很重要,确保只处理当前层的节点 +- 处理完当前层后,队列中全是下一层的节点 + +**图解**: +``` +初始状态: +queue: [3] +depth: 0 + +第1层: +queue: [3] depth++ → depth = 1 +处理3: + queue: [9, 20] + +第2层: +queue: [9, 20] depth++ → depth = 2 +处理9, 20: + queue: [15, 7] + +第3层: +queue: [15, 7] depth++ → depth = 3 +处理15, 7: + queue: [] + +退出循环,返回 depth = 3 +``` + +### 关键细节说明 + +**细节1: 为什么是 `leftDepth + 1` 而不是 `leftDepth`?** + +```go +// ❌ 错误写法 +return max(leftDepth, rightDepth) // 忘记加当前节点 + +// ✅ 正确写法 +return max(leftDepth, rightDepth) + 1 // 加上当前节点 +``` + +**原因**: 深度是节点数,不是边数 +- 子树深度 = max(左子树深度, 右子树深度) +- 当前树深度 = 子树深度 + 当前节点(1) + +**细节2: 为什么要先判断 `root == nil`?** + +```go +// BFS方法中 +if root == nil { + return 0 // 空树直接返回,避免初始化空队列 +} +``` + +**原因**: +- 空树没有节点,深度为0 +- 如果不判断,会导致 `queue = [nil]` 的错误状态 + +**细节3: 为什么BFS需要 `levelSize`?** + +```go +levelSize := len(queue) +for i := 0; i < levelSize; i++ { + // 处理当前层 +} +``` + +**原因**: +- 在循环中会不断向队列添加子节点 +- 如果没有 `levelSize`,会把下一层节点也处理了 +- `levelSize` 确保只处理当前层的节点 + +**对比**: +```go +// ❌ 错误写法 +for len(queue) > 0 { // 会把所有层混在一起处理 + node := queue[0] + queue = queue[1:] + depth++ // depth会变成节点数而非层数 +} + +// ✅ 正确写法 +for len(queue) > 0 { + depth++ + levelSize := len(queue) + for i := 0; i < levelSize; i++ { + // 只处理当前层 + } +} +``` + +### 边界条件分析 + +**边界1: 空树** +``` +输入: root = nil +输出: 0 +处理: +- DFS: 直接返回0 +- BFS: 判断root==nil,返回0 +``` + +**边界2: 只有根节点** +``` +输入: root = [1] +输出: 1 +处理: +- DFS: max(maxDepth(nil), maxDepth(nil)) + 1 = 1 +- BFS: 初始queue=[1], depth=1, 处理后queue=[], 退出 +``` + +**边界3: 链状树(所有节点只有左子树)** +``` +输入: + 1 + / +2 + \ + 3 + +输出: 3 +处理: DFS会递归到最深处,逐层返回 +``` + +**边界4: 完全二叉树** +``` +输入: + 1 + / \ + 2 3 + / \ / \ + 4 5 6 7 + +输出: 3 +处理: 左右子树深度相同 +``` + +### 复杂度分析(详细版) + +#### DFS递归方法 + +**时间复杂度**: +``` +- 每个节点访问一次: O(n) +- 每次访问只做常数操作: O(1) +- 总计: O(n) + +为什么是O(n)? +- 递归函数被调用n次(每个节点一次) +- 每次调用只计算max和+1 +- 没有重复计算 +``` + +**空间复杂度**: +``` +- 递归栈空间: O(h) - h为树高 + - 最坏情况(链状树): O(n) + - 最好情况(完全平衡树): O(log n) +- 总计: O(h) + +为什么是O(h)而不是O(n)? +- 递归深度 = 树的高度 +- 同一时刻栈中最多有h个栈帧 +- 不是所有节点同时在栈中 +``` + +#### BFS迭代方法 + +**时间复杂度**: +``` +- 每个节点入队出队一次: O(n) +- 每个节点处理常数次: O(1) +- 总计: O(n) +``` + +**空间复杂度**: +``` +- 队列空间: O(w) - w为树的最大宽度 + - 最坏情况(完全二叉树最后一层): O(n/2) = O(n) + - 最好情况(链状树): O(1) +- 总计: O(w) +``` + +### 执行过程演示 + +**输入**: +``` + 3 + / \ + 9 20 + / \ + 15 7 +``` + +#### DFS递归执行过程 + +``` +调用 maxDepth(3): +├─ 调用 maxDepth(9): +│ ├─ 调用 maxDepth(nil): 返回 0 +│ ├─ 调用 maxDepth(nil): 返回 0 +│ └─ 返回 max(0, 0) + 1 = 1 +├─ 调用 maxDepth(20): +│ ├─ 调用 maxDepth(15): +│ │ ├─ 调用 maxDepth(nil): 返回 0 +│ │ ├─ 调用 maxDepth(nil): 返回 0 +│ │ └─ 返回 max(0, 0) + 1 = 1 +│ ├─ 调用 maxDepth(7): +│ │ ├─ 调用 maxDepth(nil): 返回 0 +│ │ ├─ 调用 maxDepth(nil): 返回 0 +│ │ └─ 返回 max(0, 0) + 1 = 1 +│ └─ 返回 max(1, 1) + 1 = 2 +└─ 返回 max(1, 2) + 1 = 3 + +最终返回: 3 +``` + +#### BFS迭代执行过程 + +``` +初始: +queue = [3], depth = 0 + +处理第1层: +depth = 1 +levelSize = 1 +处理节点3: + 左孩子9入队 + 右孩子20入队 +queue = [9, 20] + +处理第2层: +depth = 2 +levelSize = 2 +处理节点9: + 无孩子 +处理节点20: + 左孩子15入队 + 右孩子7入队 +queue = [15, 7] + +处理第3层: +depth = 3 +levelSize = 2 +处理节点15: + 无孩子 +处理节点7: + 无孩子 +queue = [] + +退出循环 +返回 depth = 3 +``` + +## 代码实现 + +### 方法一:DFS递归(推荐) + +```go +func maxDepth(root *TreeNode) int { + if root == nil { + return 0 + } + + leftDepth := maxDepth(root.Left) + rightDepth := maxDepth(root.Right) + + if leftDepth > rightDepth { + return leftDepth + 1 + } + return rightDepth + 1 +} +``` + +**简化版**: +```go +func maxDepth(root *TreeNode) int { + if root == nil { + return 0 + } + return max(maxDepth(root.Left), maxDepth(root.Right)) + 1 +} +``` + +**复杂度**: O(n) 时间,O(h) 空间 + +### 方法二:BFS迭代 + +```go +func maxDepth(root *TreeNode) int { + if root == nil { + return 0 + } + + queue := []*TreeNode{root} + depth := 0 + + for len(queue) > 0 { + depth++ + levelSize := len(queue) + + for i := 0; i < levelSize; i++ { + node := queue[0] + queue = queue[1:] + + if node.Left != nil { + queue = append(queue, node.Left) + } + if node.Right != nil { + queue = append(queue, node.Right) + } + } + } + + return depth +} +``` + +**复杂度**: O(n) 时间,O(w) 空间 + +### 方法三:DFS迭代(栈) + +```go +func maxDepth(root *TreeNode) int { + if root == nil { + return 0 + } + + type NodeWithDepth struct { + node *TreeNode + depth int + } + + stack := []NodeWithDepth{{root, 1}} + maxDepthVal := 0 + + for len(stack) > 0 { + current := stack[len(stack)-1] + stack = stack[:len(stack)-1] + + if current.node != nil { + maxDepthVal = max(maxDepthVal, current.depth) + stack = append(stack, NodeWithDepth{current.node.Left, current.depth + 1}) + stack = append(stack, NodeWithDepth{current.node.Right, current.depth + 1}) + } + } + + return maxDepthVal +} +``` + +**复杂度**: O(n) 时间,O(h) 空间 + +## 常见错误 + +### 错误1: 忘记加1 + +❌ **错误写法**: +```go +func maxDepth(root *TreeNode) int { + if root == nil { + return 0 + } + return max(maxDepth(root.Left), maxDepth(root.Right)) // 忘记+1 +} +``` + +✅ **正确写法**: +```go +func maxDepth(root *TreeNode) int { + if root == nil { + return 0 + } + return max(maxDepth(root.Left), maxDepth(root.Right)) + 1 // 加上当前节点 +} +``` + +**原因**: 深度包括当前节点,需要加1 + +### 错误2: BFS忘记记录层级 + +❌ **错误写法**: +```go +for len(queue) > 0 { + node := queue[0] + queue = queue[1:] + depth++ // 错误:这样depth等于节点数而非层数 + // ... +} +``` + +✅ **正确写法**: +```go +for len(queue) > 0 { + depth++ + levelSize := len(queue) // 记录当前层节点数 + for i := 0; i < levelSize; i++ { + // 只处理当前层 + } +} +``` + +**原因**: 必须按层处理,不能混在一起 + +### 错误3: 空树处理不当 + +❌ **错误写法**: +```go +func maxDepth(root *TreeNode) int { + queue := []*TreeNode{root} // root可能是nil + // ... +} +``` + +✅ **正确写法**: +```go +func maxDepth(root *TreeNode) int { + if root == nil { + return 0 // 先判断 + } + queue := []*TreeNode{root} + // ... +} +``` + +**原因**: 空树需要特殊处理,否则会出错 + +## 变体问题 + +### 变体1: 最小深度 + +**定义**: 根节点到最近叶子节点的最短路径上的节点数 + +```go +func minDepth(root *TreeNode) int { + if root == nil { + return 0 + } + + // 叶子节点 + if root.Left == nil && root.Right == nil { + return 1 + } + + // 如果某个子树为空,不考虑该子树 + if root.Left == nil { + return minDepth(root.Right) + 1 + } + if root.Right == nil { + return minDepth(root.Left) + 1 + } + + return min(minDepth(root.Left), minDepth(root.Right)) + 1 +} +``` + +### 变体2: 判断是否为平衡二叉树 + +**定义**: 任意节点左右子树深度差不超过1 + +```go +func isBalanced(root *TreeNode) bool { + return checkDepth(root) != -1 +} + +func checkDepth(root *TreeNode) int { + if root == nil { + return 0 + } + + leftDepth := checkDepth(root.Left) + if leftDepth == -1 { + return -1 // 左子树不平衡 + } + + rightDepth := checkDepth(root.Right) + if rightDepth == -1 { + return -1 // 右子树不平衡 + } + + if abs(leftDepth - rightDepth) > 1 { + return -1 // 当前节点不平衡 + } + + return max(leftDepth, rightDepth) + 1 +} +``` + +### 变体3: 计算每个节点的深度 + +```go +type NodeWithDepth struct { + node *TreeNode + depth int +} + +func nodesWithDepth(root *TreeNode) []*NodeWithDepth { + if root == nil { + return []*NodeWithDepth{} + } + + result := []*NodeWithDepth{} + stack := []*NodeWithDepth{{root, 1}} + + for len(stack) > 0 { + current := stack[len(stack)-1] + stack = stack[:len(stack)-1] + + result = append(result, current) + + if current.node.Right != nil { + stack = append(stack, &NodeWithDepth{current.node.Right, current.depth + 1}) + } + if current.node.Left != nil { + stack = append(stack, &NodeWithDepth{current.node.Left, current.depth + 1}) + } + } + + return result +} +``` + +## 总结 + +**核心要点**: +1. **DFS递归**: 简洁直观,利用树的递归定义 +2. **BFS迭代**: 按层遍历,需要记录层级信息 +3. **深度公式**: max(左子树深度, 右子树深度) + 1 +4. **基准情况**: 空树深度为0 + +**易错点**: +- 忘记加1(当前节点) +- BFS忘记记录 `levelSize` +- 空树未处理 + +**方法选择**: +- 首选DFS递归(代码简洁) +- 需要层序信息时用BFS +- 避免递归栈溢出时用迭代 diff --git a/16-LeetCode Hot 100/从前序与中序遍历序列构造二叉树.md b/16-LeetCode Hot 100/从前序与中序遍历序列构造二叉树.md index c9304a0..04a6956 100644 --- a/16-LeetCode Hot 100/从前序与中序遍历序列构造二叉树.md +++ b/16-LeetCode Hot 100/从前序与中序遍历序列构造二叉树.md @@ -1,30 +1,105 @@ # 从前序与中序遍历序列构造二叉树 +LeetCode 105. 中等 + ## 题目描述 -给定两个整数数组 preorder 和 inorder,其中 preorder 是二叉树的先序遍历,inorder 是同一棵树的中序遍历,请构造二叉树并返回其根节点。 +给定两个整数数组 `preorder` 和 `inorder`,其中 `preorder` 是二叉树的先序遍历,`inorder` 是同一棵树的中序遍历,请构造二叉树并返回其根节点。 -## 解题思路 +**示例 1:** +``` +输入: preorder = [3,9,20,15,7], inorder = [9,3,15,20,7] +输出: [3,9,20,null,null,15,7] +``` -### 递归构造 +**示例 2:** +``` +输入: preorder = [-1], inorder = [-1] +输出: [-1] +``` -前序遍历:[根, [左子树], [右子树]] -中序遍历:[[左子树], 根, [右子树]] +**限制**: +- `1 <= preorder.length <= 3000` +- `inorder.length == preorder.length` +- `-3000 <= preorder[i], inorder[i] <= 3000` +- `preorder` 和 `inorder` 均无重复元素 +- `inorder` 保证是二叉树的中序遍历序列 +- `preorder` 保证是同一棵二叉树的前序遍历序列 -## 解法 +## 思路推导 +### 理解前序和中序遍历 + +**前序遍历顺序**: 根 → 左 → 右 +**中序遍历顺序**: 左 → 根 → 右 + +``` + 3 + / \ + 9 20 + / \ + 15 7 + +前序遍历: [3, 9, 20, 15, 7] + ↑ + 先访问根 + +中序遍历: [9, 3, 15, 20, 7] + ↑ + 根的左边是左子树 + 根的右边是右子树 +``` + +### 暴力解法分析 + +**核心观察**: +1. 前序遍历的第一个元素是**根节点** +2. 在中序遍历中找到根节点的位置 +3. 根节点左边是**左子树**,右边是**右子树** +4. 递归构造左右子树 + +**图解**: +``` +preorder: [3, 9, 20, 15, 7] + ↑ + 根(3) + +inorder: [9, 3, 15, 20, 7] + ↑ + 根(3) + 左子树 右子树 + [9] [15,20,7] + +递归构造: +- 根节点: 3 +- 左子树: preorder=[9], inorder=[9] +- 右子树: preorder=[20,15,7], inorder=[15,20,7] +``` + +**算法步骤**: ```go func buildTree(preorder []int, inorder []int) *TreeNode { if len(preorder) == 0 { return nil } - - root := &TreeNode{Val: preorder[0]} - index := findIndex(inorder, preorder[0]) - - root.Left = buildTree(preorder[1:1+index], inorder[:index]) - root.Right = buildTree(preorder[1+index:], inorder[index+1:]) - + + // 1. 前序第一个元素是根 + rootVal := preorder[0] + root := &TreeNode{Val: rootVal} + + // 2. 在中序中找到根的位置 + rootIndex := findIndex(inorder, rootVal) + + // 3. 递归构造左右子树 + root.Left = buildTree( + preorder[1:1+rootIndex], // 左子树的前序 + inorder[:rootIndex], // 左子树的中序 + ) + root.Right = buildTree( + preorder[1+rootIndex:], // 右子树的前序 + inorder[rootIndex+1:], // 右子树的中序 + ) + return root } @@ -38,4 +113,584 @@ func findIndex(arr []int, target int) int { } ``` -**复杂度:** O(n²) 时间(可用哈希表优化到 O(n)),O(n) 空间 +**时间复杂度**: O(n²) +- 每个节点需要查找: O(n) +- n个节点: O(n²) + +**空间复杂度**: O(n) - 递归栈和切片 + +**问题**: 每次查找根节点位置需要O(n),可以优化 + +### 优化思考 - 哈希表加速查找 + +**核心问题**: 如何快速在中序遍历中找到根节点位置? + +**优化思路**: +1. 预处理: 用哈希表存储 `value → index` 的映射 +2. 查找: O(1) 时间找到根节点位置 + +**优化后的算法**: +```go +func buildTree(preorder []int, inorder []int) *TreeNode { + // 建立哈希表: value → index + indexMap := make(map[int]int) + for i, v := range inorder { + indexMap[v] = i + } + + return build(preorder, 0, len(preorder)-1, 0, indexMap) +} + +func build(preorder []int, preStart, preEnd, inStart int, indexMap map[int]int) *TreeNode { + if preStart > preEnd { + return nil + } + + // 前序第一个元素是根 + rootVal := preorder[preStart] + root := &TreeNode{Val: rootVal} + + // 在中序中找到根的位置(O(1)) + rootIndex := indexMap[rootVal] + + // 左子树大小 + leftSize := rootIndex - inStart + + // 递归构造 + root.Left = build( + preorder, + preStart+1, // 左子树前序起点 + preStart+leftSize, // 左子树前序终点 + inStart, // 左子树中序起点 + indexMap, + ) + root.Right = build( + preorder, + preStart+leftSize+1, // 右子树前序起点 + preEnd, // 右子树前序终点 + rootIndex+1, // 右子树中序起点 + indexMap, + ) + + return root +} +``` + +**时间复杂度**: O(n) - 每个节点只处理一次 + +### 为什么这样思考? + +**核心思想**: +1. **分治**: 大问题分解为小问题(构造整棵树 → 构造子树) +2. **前序确定根**: 前序第一个元素就是根 +3. **中序分左右**: 根在中序的位置决定左右子树边界 +4. **递归构造**: 对左右子树重复上述过程 + +**为什么前序和中序可以唯一确定二叉树?** +- 前序告诉我们"谁是根" +- 中序告诉我们"哪些是左子树,哪些是右子树" +- 结合两者可以完全确定树的结构 + +## 解题思路 + +### 核心思想 +利用前序遍历确定根节点,利用中序遍历划分左右子树,递归构造二叉树。 + +### 详细算法流程 + +**步骤1: 确定根节点** +```go +rootVal := preorder[0] // 前序遍历第一个元素是根 +root := &TreeNode{Val: rootVal} +``` + +**关键点**: 前序遍历的特点是"根左右",第一个元素永远是根 + +**步骤2: 在中序遍历中找到根的位置** +```go +rootIndex := findIndex(inorder, rootVal) +// 或用哈希表 +rootIndex := indexMap[rootVal] +``` + +**关键点**: 根节点在中序中的位置将数组分为两部分 +- 左边: 左子树的所有节点 +- 右边: 右子树的所有节点 + +**步骤3: 计算左右子树大小** +```go +leftSize := rootIndex - inStart // 左子树节点数 +rightSize := len(inorder) - rootIndex - 1 // 右子树节点数 +``` + +**步骤4: 递归构造左右子树** + +**图解**: +``` +preorder: [3, 9, 20, 15, 7] + ↑ ↑--------↑ + 根 左子树 右子树 + +inorder: [9, 3, 15, 20, 7] + ↑ ↑ ↑ + 左子树 根 右子树 + +rootIndex = 2 (3在中序中的位置) +leftSize = 2 (左子树有2个节点) + +左子树: +- preorder: preorder[1:3] = [9, 20] +- inorder: inorder[0:2] = [9, 3] + +右子树: +- preorder: preorder[3:5] = [15, 7] +- inorder: inorder[3:5] = [15, 20, 7] +``` + +### 关键细节说明 + +**细节1: 为什么是 `preorder[1:1+rootIndex]` 而不是 `preorder[1:rootIndex]`?** + +```go +// preorder: [3, 9, 20, 15, 7] +// ↑ ↑--------↑ +// 根(索引0) 左子树(2个元素) + +// ❌ 错误写法 +root.Left = buildTree(preorder[1:rootIndex], ...) // [9, 20]? 不对! + +// ✅ 正确写法 +root.Left = buildTree(preorder[1:1+rootIndex], ...) // [9] +``` + +**原因**: +- `preorder[0]` 是根 +- `preorder[1:1+leftSize]` 是左子树(共leftSize个元素) +- `preorder[1+leftSize:]` 是右子树 + +**细节2: 为什么 `inorder[:rootIndex]` 是左子树?** + +```go +// inorder: [9, 3, 15, 20, 7] +// ↑ ↑ +// 索引0 根在索引2 + +// 左子树: inorder[0:2] = [9] +// 右子树: inorder[3:5] = [15, 20, 7] +``` + +**原因**: 中序遍历是"左根右",根节点左边全是左子树 + +**细节3: 如何计算左右子树的边界?** + +```go +// 左子树节点数 +leftSize := rootIndex - inStart + +// 前序边界 +preLeftStart := preStart + 1 +preLeftEnd := preStart + leftSize + +// 中序边界 +inLeftStart := inStart +inLeftEnd := rootIndex - 1 +``` + +**图解**: +``` +preorder: [根 | 左子树(leftSize个) | 右子树] + ↑ ↑ ↑ + preStart preStart+1 preStart+leftSize + +inorder: [左子树(leftSize个) | 根 | 右子树] + ↑ ↑ ↑ + inStart rootIndex rootIndex+1 +``` + +### 边界条件分析 + +**边界1: 空树** +``` +输入: preorder = [], inorder = [] +输出: nil +处理: 直接返回nil +``` + +**边界2: 只有根节点** +``` +输入: preorder = [1], inorder = [1] +输出: TreeNode{Val: 1} +处理: +- rootVal = 1 +- rootIndex = 0 +- leftSize = 0 +- 左子树: preorder[1:1] = [], inorder[0:0] = [] +- 右子树: preorder[1:] = [], inorder[1:] = [] +``` + +**边界3: 只有左子树** +``` +输入: +preorder: [1, 2] +inorder: [2, 1] + +输出: + 1 + / +2 + +处理: +- rootVal = 1, rootIndex = 1 +- leftSize = 1 +- 左子树: preorder[1:2] = [2], inorder[0:1] = [2] +- 右子树: preorder[2:] = [], inorder[2:] = [] +``` + +**边界4: 只有右子树** +``` +输入: +preorder: [1, 2] +inorder: [1, 2] + +输出: +1 + \ + 2 + +处理: +- rootVal = 1, rootIndex = 0 +- leftSize = 0 +- 左子树: preorder[1:1] = [], inorder[0:0] = [] +- 右子树: preorder[1:] = [2], inorder[1:] = [2] +``` + +### 复杂度分析(详细版) + +#### 方法一: 暴力查找 + +**时间复杂度**: +``` +- 构造每个节点: O(1) +- 查找根位置: O(n) +- 总计: n × O(n) = O(n²) + +为什么是O(n²)? +- 外层循环: 构造n个节点 +- 内层循环: 每次查找O(n) +- 总复杂度: O(n²) +``` + +**空间复杂度**: +``` +- 递归栈: O(h) - h为树高 +- 切片: O(n) - 每次创建新切片 +- 总计: O(n) +``` + +#### 方法二: 哈希表优化 + +**时间复杂度**: +``` +- 建立哈希表: O(n) +- 构造每个节点: O(1) - 哈希查找O(1) +- 总计: O(n) + +为什么是O(n)? +- 每个节点只处理一次 +- 每次处理都是O(1)操作 +- 总共n个节点 +``` + +**空间复杂度**: +``` +- 哈希表: O(n) +- 递归栈: O(h) +- 总计: O(n) +``` + +### 执行过程演示 + +**输入**: +``` +preorder = [3, 9, 20, 15, 7] +inorder = [9, 3, 15, 20, 7] +``` + +**执行过程**: + +``` +第1层递归: +├─ preorder[0] = 3 是根 +├─ 在inorder中找到3,索引=2 +├─ 左子树: preorder=[9], inorder=[9] +└─ 右子树: preorder=[20,15,7], inorder=[15,20,7] + +第2层递归(左子树): +├─ preorder[0] = 9 是根 +├─ 在inorder中找到9,索引=0 +├─ 左子树: preorder=[], inorder=[] +└─ 右子树: preorder=[], inorder=[] + +第2层递归(右子树): +├─ preorder[0] = 20 是根 +├─ 在inorder中找到20,索引=1 +├─ 左子树: preorder=[15], inorder=[15] +└─ 右子树: preorder=[7], inorder=[7] + +第3层递归(20的左子树): +├─ preorder[0] = 15 是根 +├─ 左子树: [] +└─ 右子树: [] + +第3层递归(20的右子树): +├─ preorder[0] = 7 是根 +├─ 左子树: [] +└─ 右子树: [] + +最终构造的树: + 3 + / \ + 9 20 + / \ + 15 7 +``` + +## 代码实现 + +### 方法一: 暴力查找(简单直观) + +```go +func buildTree(preorder []int, inorder []int) *TreeNode { + if len(preorder) == 0 { + return nil + } + + root := &TreeNode{Val: preorder[0]} + index := findIndex(inorder, preorder[0]) + + root.Left = buildTree( + preorder[1:1+index], + inorder[:index], + ) + root.Right = buildTree( + preorder[1+index:], + inorder[index+1:], + ) + + return root +} + +func findIndex(arr []int, target int) int { + for i, v := range arr { + if v == target { + return i + } + } + return -1 +} +``` + +**复杂度**: O(n²) 时间,O(n) 空间 + +### 方法二: 哈希表优化(推荐) + +```go +func buildTree(preorder []int, inorder []int) *TreeNode { + // 建立哈希表: value → index + indexMap := make(map[int]int) + for i, v := range inorder { + indexMap[v] = i + } + + return build( + preorder, + 0, + len(preorder)-1, + 0, + indexMap, + ) +} + +func build( + preorder []int, + preStart, preEnd int, + inStart int, + indexMap map[int]int, +) *TreeNode { + if preStart > preEnd { + return nil + } + + // 前序第一个元素是根 + rootVal := preorder[preStart] + root := &TreeNode{Val: rootVal} + + // 在中序中找到根的位置 + rootIndex := indexMap[rootVal] + + // 左子树大小 + leftSize := rootIndex - inStart + + // 递归构造左右子树 + root.Left = build( + preorder, + preStart+1, // 左子树前序起点 + preStart+leftSize, // 左子树前序终点 + inStart, // 左子树中序起点 + indexMap, + ) + root.Right = build( + preorder, + preStart+leftSize+1, // 右子树前序起点 + preEnd, // 右子树前序终点 + rootIndex+1, // 右子树中序起点 + indexMap, + ) + + return root +} +``` + +**复杂度**: O(n) 时间,O(n) 空间 + +## 常见错误 + +### 错误1: 切片边界错误 + +❌ **错误写法**: +```go +root.Left = buildTree(preorder[1:index], inorder[:index]) +``` + +✅ **正确写法**: +```go +root.Left = buildTree(preorder[1:1+index], inorder[:index]) +``` + +**原因**: 左子树有index个元素,应该从1到1+index + +### 错误2: 忘记处理空数组 + +❌ **错误写法**: +```go +func buildTree(preorder []int, inorder []int) *TreeNode { + rootVal := preorder[0] // 可能越界! + // ... +} +``` + +✅ **正确写法**: +```go +func buildTree(preorder []int, inorder []int) *TreeNode { + if len(preorder) == 0 { + return nil + } + rootVal := preorder[0] + // ... +} +``` + +**原因**: 空数组没有第0个元素 + +### 错误3: 左右子树边界混淆 + +❌ **错误写法**: +```go +// 错误地认为右子树从rootIndex开始 +root.Right = buildTree(preorder[1+index:], inorder[rootIndex:]) +``` + +✅ **正确写法**: +```go +// 右子树从rootIndex+1开始(跳过根节点) +root.Right = buildTree(preorder[1+index:], inorder[rootIndex+1:]) +``` + +**原因**: `inorder[rootIndex]` 是根节点,右子树应该从 `rootIndex+1` 开始 + +## 变体问题 + +### 变体1: 从中序与后序遍历构造二叉树 + +**后序遍历**: 左 → 右 → 根(最后一个元素是根) + +```go +func buildTree(inorder []int, postorder []int) *TreeNode { + indexMap := make(map[int]int) + for i, v := range inorder { + indexMap[v] = i + } + + return build( + postorder, + len(postorder)-1, // 从最后一个元素开始 + 0, + len(inorder)-1, + indexMap, + ) +} + +func build( + postorder []int, + postStart, inStart, inEnd int, + indexMap map[int]int, +) *TreeNode { + if inStart > inEnd { + return nil + } + + rootVal := postorder[postStart] + root := &TreeNode{Val: rootVal} + + rootIndex := indexMap[rootVal] + rightSize := inEnd - rootIndex + + root.Right = build( + postorder, + postStart-1, + rootIndex+1, + inEnd, + indexMap, + ) + root.Left = build( + postorder, + postStart-1-rightSize, + inStart, + rootIndex-1, + indexMap, + ) + + return root +} +``` + +### 变体2: 从前序与后序遍历构造二叉树 + +**注意**: 前序+后序无法唯一确定二叉树(除非是满二叉树) + +### 变体3: 判断给定数组是否是某棵树的前序/中序遍历 + +需要验证数组是否满足遍历的性质(略) + +## 总结 + +**核心要点**: +1. **前序定根**: preorder[0] 永远是当前子树的根 +2. **中序分界**: 根在中序的位置决定左右子树边界 +3. **递归构造**: 对左右子树重复相同过程 +4. **哈希优化**: 用哈希表将查找优化到O(1) + +**易错点**: +- 切片边界计算错误(`1:1+index` vs `1:index`) +- 忘记处理空数组 +- 左右子树边界混淆(`inorder[rootIndex:]` vs `inorder[rootIndex+1:]`) + +**关键规律**: +``` +前序: [根 | 左子树 | 右子树] +中序: [左子树 | 根 | 右子树] + +通过前序找根,通过中序分左右 +``` + +**推荐写法**: 哈希表优化版(O(n)时间) diff --git a/16-LeetCode Hot 100/子集.md b/16-LeetCode Hot 100/子集.md index fd8fad0..12f05a6 100644 --- a/16-LeetCode Hot 100/子集.md +++ b/16-LeetCode Hot 100/子集.md @@ -26,6 +26,77 @@ - `-10 <= nums[i] <= 10` - `nums` 中的所有元素 **互不相同** +## 思路推导 + +### 暴力解法分析 + +**第一步:直观思路 - 枚举所有可能的子集** + +```python +def subsets_brute(nums): + n = len(nums) + result = [] + + # 遍历所有可能的子集掩码 + for mask in range(1 << n): # 0 到 2^n - 1 + subset = [] + for i in range(n): + if mask & (1 << i): # 检查第 i 位是否为 1 + subset.append(nums[i]) + result.append(subset) + + return result +``` + +**时间复杂度分析:** +- 有 2^n 个子集 +- 每个子集需要 O(n) 时间构建 +- **总时间复杂度:O(n × 2^n)** + +**问题:** +- 虽然时间复杂度已经是最优的,但位运算不易理解 +- 代码可读性较差 +- 难以扩展到带约束条件的子集问题 + +### 优化思考 - 如何更直观地生成子集? + +**核心观察:** +1. **子集的本质**:对每个元素,都有"选"或"不选"两种选择 +2. **决策树视角**:n 个元素构成一个 n 层的决策树 +3. **回溯法**:自然地表达这种"选择-撤销"的过程 + +**为什么用回溯?** +- 更直观地表达选择过程 +- 易于剪枝(如有约束条件) +- 可以生成子集的同时进行处理 + +### 为什么这样思考? + +**1. 二叉选择视角** +``` +对于 [1,2,3]: + + [] + / \ + 不选1 选1 + [] [1] + / \ / \ + 不选2 选2 不选2 选2 + [] [2] [1] [1,2] + / \ / \ / \ / \ + 3 [] 3 [2] ... (继续展开) + +叶子节点就是所有子集 +``` + +**2. 回溯法的优势** +``` +- 每个节点代表一个决策点 +- 自然地表达"选"或"不选" +- 可以在任意时刻处理当前子集 +- 易于添加约束条件(如子集和限制) +``` + ## 解题思路 ### 方法一:回溯法(推荐) @@ -39,6 +110,190 @@ - 从 `start` 开始遍历,依次尝试包含每个元素 - 递归调用后撤销选择(回溯) +### 详细算法流程 + +**步骤1:理解回溯框架** + +```python +def backtrack(start): + # 将当前子集加入结果 + result.append(current[:]) + + # 从 start 开始尝试包含每个元素 + for i in range(start, len(nums)): + # 选择当前元素 + current.append(nums[i]) + # 递归处理下一个元素 + backtrack(i + 1) + # 撤销选择(回溯) + current.pop() +``` + +**Q: 为什么从 start 而不是从 0 开始?** + +A: 避免重复生成相同的子集。举例: +``` +nums = [1, 2] + +如果每次都从 0 开始: +- 选 1: current=[1] + - 选 2: current=[1,2] ✓ + - 选 1: current=[1,1] ✗ 重复! + +如果从 start 开始: +- i=0: 选 1: current=[1] + - i=1: 选 2: current=[1,2] ✓ +- i=1: 选 2: current=[2] ✓ +``` + +**步骤2:理解为何要加入空集** + +```python +result.append(current[:]) # 在循环前就加入 +``` + +**Q: 为什么在循环前就加入结果?** + +A: 因为每个中间状态都是一个有效的子集。举例: +``` +nums = [1, 2, 3] + +执行过程: +1. current=[] → 加入 [] +2. 选择 1: current=[1] → 加入 [1] +3. 选择 2: current=[1,2] → 加入 [1,2] +4. 选择 3: current=[1,2,3] → 加入 [1,2,3] +5. 回溯:current=[1,2] +6. 回溯:current=[1] +7. 选择 3: current=[1,3] → 加入 [1,3] +... +``` + +**步骤3:理解回溯的撤销** + +```python +current.append(nums[i]) # 做选择 +backtrack(i + 1) +current.pop() # 撤销选择 +``` + +**Q: 为什么必须撤销?** + +A: 因为 `current` 是共享的列表,不撤销会影响后续递归。 + +举例说明撤销的重要性: +``` +不撤销的情况: +- 选择 1: current=[1] + - 选择 2: current=[1,2],加入结果 + - 回溯(但没有撤销) + - 选择 3: current=[1,2,3] ✗ 应该是 [1,3] + +正确撤销: +- 选择 1: current=[1] + - 选择 2: current=[1,2],加入结果 + - 回溯并撤销:current=[1] + - 选择 3: current=[1,3] ✓ +``` + +### 关键细节说明 + +**细节1:为什么用 current[:] 而不是 current?** + +```python +# 错误写法 +result.append(current) # 添加引用 + +# 正确写法 +result.append(current[:]) # 添加副本 +``` + +**为什么?** +- `current` 是可变列表,后续修改会影响已加入结果的数据 +- `current[:]` 创建副本,保证结果不被修改 + +**细节2:为什么循环从 start 开始?** + +```python +for i in range(start, len(nums)): # 从 start 开始 + current.append(nums[i]) + backtrack(i + 1) # 下次从 i+1 开始 + current.pop() +``` + +**为什么?** +- 避免重复:确保子集中的元素按原数组顺序出现 +- 例如:[1,2] 会出现,但 [2,1] 不会出现 + +**细节3:如何理解生成所有子集?** + +``` +nums = [1, 2, 3] + +回溯树: + [] + / | \ + 不选2 选2 选3 (错误理解) + | + 选3 + +正确理解(按顺序): + [] + / | \ + [1] [2] [3] + / \ | + [1,2] [1,3] [2,3] + | + [1,2,3] + +每个节点都是一个有效的子集! +``` + +### 边界条件分析 + +**边界1:空数组** +``` +输入:nums = [] +输出:[[]] +原因:空集是任何集合的子集 +``` + +**边界2:单个元素** +``` +输入:nums = [1] +输出:[[], [1]] +过程: + - 初始:current=[],加入 [] + - 选择 1:current=[1],加入 [1] +``` + +**边界3:所有元素相同** +``` +输入:nums = [1, 1, 1] +输出:[[], [1], [1,1], [1,1,1]] +注意:题目说元素互不相同,所以这种情况不会出现 +``` + +### 复杂度分析(详细版) + +**时间复杂度:** +``` +- 子集数量:2^n +- 每个子集的构建:O(n)(最坏情况) +- **总时间复杂度:O(n × 2^n)** + +为什么每个子集是 O(n)? +- 虽然子集长度不同,但平均长度是 n/2 +- 总元素数 = 0×C(n,0) + 1×C(n,1) + ... + n×C(n,n) = n×2^(n-1) +- 平均每个子集的元素数 = n×2^(n-1) / 2^n = n/2 +``` + +**空间复杂度:** +``` +- 递归栈深度:O(n) +- 存储结果:O(n × 2^n)(所有子集的总元素数) +- **空间复杂度:O(n)**(不计结果存储) + ### 方法二:迭代法(位掩码) **核心思想:**子集可以用二进制表示。对于 n 个元素,共有 2^n 个子集。 @@ -185,6 +440,160 @@ func subsetsCascade(nums []int) [][]int { - **空间复杂度:** O(n × 2^n) - 需要存储所有子集 +## 执行过程演示 + +以 `nums = [1, 2, 3]` 为例: + +``` +初始状态:result=[], current=[], start=0 + +第1层递归 (start=0): + result=[[]] # 加入空集 + 循环:i 从 0 到 2 + + i=0, nums[0]=1: + current=[1] + 递归 backtrack(1) + ├─ result=[[], [1]] # 加入 [1] + ├─ i=1, nums[1]=2: + │ current=[1,2] + │ 递归 backtrack(2) + │ ├─ result=[[], [1], [1,2]] # 加入 [1,2] + │ ├─ i=2, nums[2]=3: + │ │ current=[1,2,3] + │ │ 递归 backtrack(3) + │ │ ├─ result=[[], [1], [1,2], [1,2,3]] # 加入 [1,2,3] + │ │ └─ 返回 + │ │ current=[1,2] # 撤销 3 + │ └─ 返回 + │ current=[1] # 撤销 2 + ├─ i=2, nums[2]=3: + │ current=[1,3] + │ 递归 backtrack(3) + │ ├─ result=[[], [1], [1,2], [1,2,3], [1,3]] # 加入 [1,3] + │ └─ 返回 + │ current=[1] # 撤销 3 + └─ 返回 + current=[] # 撤销 1 + + i=1, nums[1]=2: + current=[2] + 递归 backtrack(2) + ├─ result=[[], [1], [1,2], [1,2,3], [1,3], [2]] # 加入 [2] + ├─ i=2, nums[2]=3: + │ current=[2,3] + │ 递归 backtrack(3) + │ ├─ result=[[], [1], [1,2], [1,2,3], [1,3], [2], [2,3]] # 加入 [2,3] + │ └─ 返回 + │ current=[2] # 撤销 3 + └─ 返回 + current=[] # 撤销 2 + + i=2, nums[2]=3: + current=[3] + 递归 backtrack(3) + ├─ result=[[], [1], [1,2], [1,2,3], [1,3], [2], [2,3], [3]] # 加入 [3] + └─ 返回 + current=[] # 撤销 3 + +最终结果:[[], [1], [1,2], [1,2,3], [1,3], [2], [2,3], [3]] +``` + +## 常见错误 + +### 错误1:忘记复制 current + +❌ **错误写法:** +```go +func subsets(nums []int) [][]int { + result := [][]int{} + current := []int{} + + var backtrack func(start int) + backtrack = func(start int) { + result = append(result, current) // 错误!添加引用 + for i := start; i < len(nums); i++ { + current = append(current, nums[i]) + backtrack(i + 1) + current = current[:len(current)-1] + } + } + + backtrack(0) + return result +} +``` + +✅ **正确写法:** +```go +func subsets(nums []int) [][]int { + result := [][]int{} + current := []int{} + + var backtrack func(start int) + backtrack = func(start int) { + temp := make([]int, len(current)) + copy(temp, current) // 复制 + result = append(result, temp) + + for i := start; i < len(nums); i++ { + current = append(current, nums[i]) + backtrack(i + 1) + current = current[:len(current)-1] + } + } + + backtrack(0) + return result +} +``` + +**原因:**Go 中切片是引用类型,直接添加会导致所有结果都是同一个切片的引用。 + +### 错误2:循环从 0 开始而不是 start + +❌ **错误写法:** +```go +for i := 0; i < len(nums); i++ { // 错误:从 0 开始 + current = append(current, nums[i]) + backtrack(i + 1) + current = current[:len(current)-1] +} +``` + +✅ **正确写法:** +```go +for i := start; i < len(nums); i++ { // 正确:从 start 开始 + current = append(current, nums[i]) + backtrack(i + 1) + current = current[:len(current)-1] +} +``` + +**原因:**会导致重复生成相同的子集。 + +### 错误3:忘记撤销选择 + +❌ **错误写法:** +```go +for i := start; i < len(nums); i++ { + current = append(current, nums[i]) + backtrack(i + 1) + // 忘记撤销 +} +``` + +✅ **正确写法:** +```go +for i := start; i < len(nums); i++ { + current = append(current, nums[i]) + backtrack(i + 1) + current = current[:len(current)-1] // 必须撤销 +} +``` + +**原因:**不撤销会导致后续递归使用错误的 current。 + ## 进阶问题 ### Q1: 如果数组中有重复元素,应该如何处理? diff --git a/16-LeetCode Hot 100/完全平方数.md b/16-LeetCode Hot 100/完全平方数.md index fcd6cb9..2dafbdb 100644 --- a/16-LeetCode Hot 100/完全平方数.md +++ b/16-LeetCode Hot 100/完全平方数.md @@ -1,25 +1,404 @@ # 完全平方数 (Perfect Squares) +LeetCode 279. Medium + ## 题目描述 -给你一个整数 n,返回和为 n 的完全平方数的最少数量。 +给你一个整数 `n`,返回和为 `n` 的完全平方数的最少数量。 + +**完全平方数**:一个整数等于其平方的整数,如 1, 4, 9, 16 等。 + +**示例 1**: +``` +输入:n = 12 +输出:3 +解释:12 = 4 + 4 + 4 +``` + +**示例 2**: +``` +输入:n = 13 +输出:2 +解释:13 = 4 + 9 +``` + +**示例 3**: +``` +输入:n = 1 +输出:1 +解释:1 = 1 +``` + +## 思路推导 + +### 暴力解法分析 + +**最直观的思路**:尝试所有可能的完全平方数组合。 + +```python +def numSquares(n): + if int(n**0.5)**2 == n: + return 1 + + # 尝试两个数的和 + for i in range(1, int(n**0.5) + 1): + if int((n - i*i)**0.5)**2 == n - i*i: + return 2 + + # 尝试三个数的和 + # ...(代码会非常复杂) + + # 根据拉格朗日四平方定理,最多需要 4 个数 + return 4 +``` + +**时间复杂度**:难以估计,取决于实现 +- 最坏情况可能需要枚举所有组合 +- 代码复杂,难以维护 + +**空间复杂度**:O(1) + +**问题分析**: +1. 实现复杂:需要处理多种情况 +2. 难以理解:代码逻辑不清晰 +3. 难以扩展:无法处理变体问题 + +### 优化思考 - 第一步:动态规划 + +**观察**:问题具有最优子结构 + +**关键问题**:如何定义子问题? + +**定义**:`dp[i]` = 和为 `i` 的完全平方数的最少数量 + +**状态转移方程**: +``` +dp[i] = min(dp[i - j*j] + 1) for all j where j*j <= i +``` + +**为什么这样思考?** +- 如果我们选择了 `j*j`,那么问题变成 `i - j*j` +- `dp[i - j*j]` 是已知的子问题 +- 我们只需要枚举所有可能的 `j` + +**优化后的思路**: +```python +dp = [0] * (n + 1) +for i in range(1, n + 1): + dp[i] = min(dp[i - j*j] + 1 for j in range(1, int(i**0.5) + 1)) +``` + +**时间复杂度**:O(n√n) +- 外层循环:O(n) +- 内层循环:O(√n) +- 总计:O(n) × O(√n) = O(n√n) + +**空间复杂度**:O(n) +- dp 数组:O(n) + +### 优化思考 - 第二步:数学优化(拉格朗日四平方定理) + +**定理**:任何正整数都可以表示为最多 4 个完全平方数的和。 + +**推论**: +1. 如果 `n` 是完全平方数,返回 1 +2. 如果 `n` 可以表示为两个完全平方数的和,返回 2 +3. 如果 `n` 满足特定条件(勒让德三平方定理),返回 3 +4. 否则返回 4 + +**为什么这样思考?** +- 数学定理可以快速判断 +- 避免动态规划的高复杂度 +- 时间复杂度可以降到 O(√n) + +### 优化思考 - 第三步:BFS 最短路径 + +**观察**:问题可以转化为图的最短路径问题 + +**建模**: +- 节点:数字 `i` +- 边:从 `i` 到 `i - j*j`(如果 `j*j <= i`) +- 权重:每条边的权重都是 1 + +**目标**:从 `n` 到 `0` 的最短路径 + +**为什么这样思考?** +- BFS 天然适合求最短路径 +- 可以提前终止(找到 0 就停止) +- 比动态规划更快(实际运行时间) + +**时间复杂度**:O(√n)^h,h 是答案 +- 最坏情况:h = 4 +- 实际运行:比 O(n√n) 快很多 ## 解题思路 -### 动态规划 +### 核心思想 -dp[i] = min(dp[i - j*j] + 1) for all j where j*j <= i +**动态规划**:将问题分解为子问题,从底向上求解。 -## Go 代码 +**为什么这样思考?** + +1. **最优子结构**: + - `n` 的最优解依赖于 `n - j*j` 的最优解 + - 子问题重叠,可以重复使用 + +2. **无后效性**: + - `dp[i]` 只依赖于比 `i` 小的值 + - 不依赖于计算路径 + +3. **边界明确**: + - `dp[0] = 0`(0 个完全平方数) + - `dp[1] = 1`(1 = 1) + +### 详细算法流程 + +**步骤1:初始化 dp 数组** + +```python +dp = [float('inf')] * (n + 1) +dp[0] = 0 # 边界条件 +``` + +**作用**: +- `dp[i]` 表示和为 `i` 的完全平方数的最少数量 +- 初始化为无穷大,表示未计算 +- `dp[0] = 0` 是递归的基础 + +**步骤2:填表** + +```python +for i in range(1, n + 1): + # 尝试所有可能的完全平方数 j*j + for j in range(1, int(i**0.5) + 1): + dp[i] = min(dp[i], dp[i - j*j] + 1) +``` + +**关键点详解**: + +1. **为什么外层循环从 1 到 n?** + - 从小到大计算,确保 `dp[i - j*j]` 已经计算过 + - `i - j*j < i`,所以已经计算过 + +2. **为什么内层循环从 1 到 √i?** + - `j*j` 必须小于等于 `i` + - 最大的 `j` 是 `√i` + - 示例:`i = 12`,`j` 最大为 3(3² = 9 ≤ 12) + +3. **为什么是 `dp[i - j*j] + 1`?** + - `dp[i - j*j]` 是和为 `i - j*j` 的最少数量 + - 加上 1 表示再加上 `j*j` + - 示例:`dp[12] = dp[12 - 4] + 1 = dp[8] + 1` + +**示例**: +``` +n = 12 + +i=1: dp[1] = dp[1-1] + 1 = dp[0] + 1 = 1 +i=2: dp[2] = dp[2-1] + 1 = dp[1] + 1 = 2 +i=3: dp[3] = dp[3-1] + 1 = dp[2] + 1 = 3 +i=4: dp[4] = dp[4-4] + 1 = dp[0] + 1 = 1 +i=5: dp[5] = min(dp[5-1]+1, dp[5-4]+1) = min(dp[4]+1, dp[1]+1) = min(2, 2) = 2 +... +i=12: dp[12] = min(dp[12-1]+1, dp[12-4]+1, dp[12-9]+1) + = min(dp[11]+1, dp[8]+1, dp[3]+1) + = min(3, 2, 4) + = 2 +``` + +### 关键细节说明 + +**细节1:为什么 `dp[0] = 0`?** + +```python +dp[0] = 0 # 0 可以由 0 个完全平方数组成 +``` + +**原因**: +- 0 是递归的终止条件 +- 表示不需要任何完全平方数就能组成 0 +- 类似于数学中的"空和为 0" + +**细节2:为什么初始化为 `float('inf')`?** + +```python +dp = [float('inf')] * (n + 1) +dp[0] = 0 +``` + +**原因**: +- 无穷大表示"未计算"或"不可达" +- `min(inf, x) = x`,确保第一次更新正确 +- 避免使用 0 初始化导致 `min(0, x)` 错误 + +**细节3:为什么用 `int(i**0.5) + 1`?** + +```python +for j in range(1, int(i**0.5) + 1): + # ... +``` + +**原因**: +- `int(i**0.5)` 是 `√i` 的整数部分 +- `+1` 是因为 `range` 是左闭右开区间 +- 示例:`i = 4`,`int(4**0.5) = 2`,`range(1, 3)` → `[1, 2]` ✓ + +**细节4:为什么可以保证最优解?** + +```python +dp[i] = min(dp[i - j*j] + 1 for all valid j) +``` + +**原因**: +- 枚举了所有可能的第一个完全平方数 `j*j` +- `dp[i - j*j]` 已经是最优解(归纳假设) +- `min` 确保选择了最优的组合 +- 动态规划的"最优子结构"性质 + +### 边界条件分析 + +**边界1:n = 0** + +``` +输入:n = 0 +输出:0 +解释: + dp[0] = 0 + 0 个完全平方数组成 0 +``` + +**边界2:n = 1** + +``` +输入:n = 1 +输出:1 +过程: + dp[1] = dp[1-1] + 1 = dp[0] + 1 = 1 +``` + +**边界3:n 是完全平方数** + +``` +输入:n = 9 +输出:1 +过程: + dp[9] = min(dp[9-1]+1, dp[9-4]+1, dp[9-9]+1) + = min(dp[8]+1, dp[5]+1, dp[0]+1) + = min(3, 3, 1) + = 1 +``` + +**边界4:n = 12** + +``` +输入:n = 12 +输出:3 +过程: + dp[12] = min(dp[12-1]+1, dp[12-4]+1, dp[12-9]+1) + = min(dp[11]+1, dp[8]+1, dp[3]+1) + = min(4, 3, 4) + = 3 + +验证:12 = 4 + 4 + 4 ✓ +``` + +### 复杂度分析(详细版) + +**时间复杂度**: +``` +- 外层循环:O(n),从 1 到 n +- 内层循环:O(√n),从 1 到 √i +- 总计:O(n) × O(√n) = O(n√n) + +为什么是 O(n√n)? +- 对于每个 i,最多有 √i 个 j +- √i ≤ √n +- 总操作次数 ≈ n × √n = n√n +``` + +**空间复杂度**: +``` +- dp 数组:O(n) +- 其他变量:O(1) +- 总计:O(n) +``` + +--- + +## 图解过程 + +``` +n = 12 + +初始化:dp = [0, inf, inf, inf, inf, inf, inf, inf, inf, inf, inf, inf, inf] + +i=1: j=1 + dp[1] = dp[1-1] + 1 = dp[0] + 1 = 1 + dp = [0, 1, inf, inf, inf, inf, inf, inf, inf, inf, inf, inf, inf] + +i=2: j=1 + dp[2] = dp[2-1] + 1 = dp[1] + 1 = 2 + dp = [0, 1, 2, inf, inf, inf, inf, inf, inf, inf, inf, inf, inf] + +i=3: j=1 + dp[3] = dp[3-1] + 1 = dp[2] + 1 = 3 + dp = [0, 1, 2, 3, inf, inf, inf, inf, inf, inf, inf, inf, inf] + +i=4: j=1,2 + dp[4] = min(dp[4-1]+1, dp[4-4]+1) = min(dp[3]+1, dp[0]+1) = min(4, 1) = 1 + dp = [0, 1, 2, 3, 1, inf, inf, inf, inf, inf, inf, inf, inf] + +i=5: j=1,2 + dp[5] = min(dp[5-1]+1, dp[5-4]+1) = min(dp[4]+1, dp[1]+1) = min(2, 2) = 2 + dp = [0, 1, 2, 3, 1, 2, inf, inf, inf, inf, inf, inf, inf] + +i=6: j=1,2 + dp[6] = min(dp[6-1]+1, dp[6-4]+1) = min(dp[5]+1, dp[2]+1) = min(3, 3) = 3 + dp = [0, 1, 2, 3, 1, 2, 3, inf, inf, inf, inf, inf, inf] + +i=7: j=1,2 + dp[7] = min(dp[7-1]+1, dp[7-4]+1) = min(dp[6]+1, dp[3]+1) = min(4, 4) = 4 + dp = [0, 1, 2, 3, 1, 2, 3, 4, inf, inf, inf, inf, inf] + +i=8: j=1,2 + dp[8] = min(dp[8-1]+1, dp[8-4]+1) = min(dp[7]+1, dp[4]+1) = min(5, 2) = 2 + dp = [0, 1, 2, 3, 1, 2, 3, 4, 2, inf, inf, inf, inf] + +i=9: j=1,2,3 + dp[9] = min(dp[9-1]+1, dp[9-4]+1, dp[9-9]+1) = min(dp[8]+1, dp[5]+1, dp[0]+1) = min(3, 3, 1) = 1 + dp = [0, 1, 2, 3, 1, 2, 3, 4, 2, 1, inf, inf, inf] + +i=10: j=1,2,3 + dp[10] = min(dp[10-1]+1, dp[10-4]+1, dp[10-9]+1) = min(dp[9]+1, dp[6]+1, dp[1]+1) = min(2, 4, 2) = 2 + dp = [0, 1, 2, 3, 1, 2, 3, 4, 2, 1, 2, inf, inf] + +i=11: j=1,2,3 + dp[11] = min(dp[11-1]+1, dp[11-4]+1, dp[11-9]+1) = min(dp[10]+1, dp[7]+1, dp[2]+1) = min(3, 5, 3) = 3 + dp = [0, 1, 2, 3, 1, 2, 3, 4, 2, 1, 2, 3, inf] + +i=12: j=1,2,3 + dp[12] = min(dp[12-1]+1, dp[12-4]+1, dp[12-9]+1) = min(dp[11]+1, dp[8]+1, dp[3]+1) = min(4, 3, 4) = 3 + dp = [0, 1, 2, 3, 1, 2, 3, 4, 2, 1, 2, 3, 3] + +结果:dp[12] = 3 +``` + +--- + +## 代码实现 ```go func numSquares(n int) int { dp := make([]int, n+1) + + // 初始化为最大值 for i := range dp { dp[i] = math.MaxInt32 } + dp[0] = 0 - + + // 填表 for i := 1; i <= n; i++ { for j := 1; j*j <= i; j++ { if dp[i-j*j]+1 < dp[i] { @@ -27,9 +406,257 @@ func numSquares(n int) int { } } } - + return dp[n] } ``` -**复杂度:** O(n√n) 时间,O(n) 空间 +**关键点**: +1. `dp[0] = 0` 是边界条件 +2. 从小到大计算,确保子问题已解决 +3. 枚举所有可能的完全平方数 + +--- + +## 执行过程演示 + +**输入**:n = 12 + +``` +初始化:dp = [0, 2147483647, 2147483647, 2147483647, 2147483647, 2147483647, 2147483647, 2147483647, 2147483647, 2147483647, 2147483647, 2147483647, 2147483647] + +i=1: j=1 + dp[1] = min(2147483647, dp[0]+1) = 1 + +i=2: j=1 + dp[2] = min(2147483647, dp[1]+1) = 2 + +i=3: j=1 + dp[3] = min(2147483647, dp[2]+1) = 3 + +i=4: j=1,2 + j=1: dp[4] = min(2147483647, dp[3]+1) = 4 + j=2: dp[4] = min(4, dp[0]+1) = 1 + +i=5: j=1,2 + j=1: dp[5] = min(2147483647, dp[4]+1) = 2 + j=2: dp[5] = min(2, dp[1]+1) = 2 + +i=6: j=1,2 + j=1: dp[6] = min(2147483647, dp[5]+1) = 3 + j=2: dp[6] = min(3, dp[2]+1) = 3 + +i=7: j=1,2 + j=1: dp[7] = min(2147483647, dp[6]+1) = 4 + j=2: dp[7] = min(4, dp[3]+1) = 4 + +i=8: j=1,2 + j=1: dp[8] = min(2147483647, dp[7]+1) = 5 + j=2: dp[8] = min(5, dp[4]+1) = 2 + +i=9: j=1,2,3 + j=1: dp[9] = min(2147483647, dp[8]+1) = 3 + j=2: dp[9] = min(3, dp[5]+1) = 3 + j=3: dp[9] = min(3, dp[0]+1) = 1 + +i=10: j=1,2,3 + j=1: dp[10] = min(2147483647, dp[9]+1) = 2 + j=2: dp[10] = min(2, dp[6]+1) = 2 + j=3: dp[10] = min(2, dp[1]+1) = 2 + +i=11: j=1,2,3 + j=1: dp[11] = min(2147483647, dp[10]+1) = 3 + j=2: dp[11] = min(3, dp[7]+1) = 3 + j=3: dp[11] = min(3, dp[2]+1) = 3 + +i=12: j=1,2,3 + j=1: dp[12] = min(2147483647, dp[11]+1) = 4 + j=2: dp[12] = min(4, dp[8]+1) = 3 + j=3: dp[12] = min(3, dp[3]+1) = 3 + +结果:dp[12] = 3 +``` + +--- + +## 常见错误 + +### 错误1:初始化为 0 + +❌ **错误代码**: +```go +dp := make([]int, n+1) +// dp 默认为 0 + +for i := 1; i <= n; i++ { + for j := 1; j*j <= i; j++ { + dp[i] = min(dp[i], dp[i-j*j]+1) // 错误!dp[i] 初始为 0 + } +} +``` + +✅ **正确代码**: +```go +dp := make([]int, n+1) +for i := range dp { + dp[i] = math.MaxInt32 // 初始化为最大值 +} +dp[0] = 0 +``` + +**原因**: +- `dp[i]` 初始为 0,`min(0, x)` 永远是 0 +- 导致结果错误 + +--- + +### 错误2:循环范围错误 + +❌ **错误代码**: +```go +for j := 1; j <= int(math.Sqrt(float64(n))); j++ { + // 错误!用了 n 而不是 i +} +``` + +✅ **正确代码**: +```go +for j := 1; j*j <= i; j++ { + // 正确!用 i +} +``` + +**原因**: +- 应该是 `j*j <= i`,不是 `j*j <= n` +- 用 n 会导致越界或计算错误 + +--- + +### 错误3:忘记边界条件 + +❌ **错误代码**: +```go +dp := make([]int, n+1) +// 忘记设置 dp[0] = 0 +``` + +✅ **正确代码**: +```go +dp := make([]int, n+1) +dp[0] = 0 // 边界条件 +``` + +**原因**: +- `dp[0] = 0` 是递归的基础 +- 没有它,`dp[i-j*j]` 无法正确计算 + +--- + +## 进阶问题 + +### Q1: 如何优化空间复杂度? + +**思路**:使用贪心算法或数学定理 + +```go +// 基于拉格朗日四平方定理 +func numSquaresOptimized(n int) int { + // 检查是否为完全平方数 + if isPerfectSquare(n) { + return 1 + } + + // 检查是否可以表示为两个完全平方数的和 + for i := 1; i*i <= n; i++ { + if isPerfectSquare(n - i*i) { + return 2 + } + } + + // 检查是否满足勒让德三平方定理 + // n != 4^a * (8b + 7) + if n == 4*n { + return 4 + } + + return 3 +} + +func isPerfectSquare(n int) bool { + sqrt := int(math.Sqrt(float64(n))) + return sqrt*sqrt == n +} +``` + +**时间复杂度**:O(√n) + +--- + +### Q2: 如何返回具体的完全平方数组合? + +```go +func numSquaresWithSolution(n int) ([]int, int) { + dp := make([]int, n+1) + prev := make([]int, n+1) // 记录前一个状态 + + for i := range dp { + dp[i] = math.MaxInt32 + } + dp[0] = 0 + + for i := 1; i <= n; i++ { + for j := 1; j*j <= i; j++ { + if dp[i-j*j]+1 < dp[i] { + dp[i] = dp[i-j*j] + 1 + prev[i] = j * j // 记录选择的完全平方数 + } + } + } + + // 回溯构建解 + solution := []int{} + curr := n + for curr > 0 { + square := prev[curr] + solution = append(solution, square) + curr -= square + } + + return solution, dp[n] +} +``` + +--- + +## P7 加分项 + +### 深度理解 +- **最优子结构**:问题的最优解包含子问题的最优解 +- **重叠子问题**:子问题会被重复计算,动态规划避免重复 +- **数学定理**:拉格朗日四平方定理可以优化算法 + +### 实战扩展 +- **背包问题**:完全背包的变种 +- **最短路径**:可以转化为 BFS 问题 +- **业务场景**:货币兑换、资源分配 + +### 变形题目 +1. 最少硬币数量(硬币面值不同) +2. 完全平方数的所有组合 +3. 限制完全平方数的使用次数 + +--- + +## 总结 + +**核心要点**: +1. **动态规划**:从底向上,逐步求解 +2. **状态转移**:`dp[i] = min(dp[i - j*j] + 1)` +3. **边界条件**:`dp[0] = 0` + +**易错点**: +- 初始化错误(0 vs MaxInt32) +- 循环范围错误(n vs i) +- 忘记边界条件 + +**最优解法**:动态规划,时间 O(n√n),空间 O(n) diff --git a/16-LeetCode Hot 100/对称二叉树.md b/16-LeetCode Hot 100/对称二叉树.md index 5429055..049703f 100644 --- a/16-LeetCode Hot 100/对称二叉树.md +++ b/16-LeetCode Hot 100/对称二叉树.md @@ -1,20 +1,477 @@ # 对称二叉树 (Symmetric Tree) +LeetCode 101. 简单 + ## 题目描述 -给你一个二叉树的根节点 root,检查它是否轴对称。 +给你一个二叉树的根节点 `root`,检查它是否轴对称。 + +**示例 1:** +``` +输入:root = [1,2,2,3,4,4,3] +输出:true +``` + +**示例 2:** +``` +输入:root = [1,2,2,null,3,null,3] +输出:false +``` + +## 思路推导 + +### 什么是轴对称二叉树? + +**轴对称**: 沿着根节点的中轴线折叠,左右两边完全重合 + +``` +对称的树: 不对称的树: + 1 1 + / \ / \ + 2 2 2 2 + / \ / \ \ / \ +3 4 4 3 3 3 3 + (左右子树不对应) +``` + +### 暴力解法分析 + +**思路**: 比较左子树和右子树是否镜像对称 + +**观察对称的性质**: +1. 根节点相同(只有一个根) +2. 左子树的左节点 = 右子树的右节点 +3. 左子树的右节点 = 右子树的左节点 + +**图解**: +``` + 1 + / \ + 2 2 <- 左右根节点值相同 + / \ / \ + 3 4 4 3 <- 3对应3,4对应4 + +比较规则: +- 左子树的左(3) vs 右子树的右(3) +- 左子树的右(4) vs 右子树的左(4) +``` + +**递归思路**: +``` +isSymmetric(root): + return isMirror(root.left, root.right) + +isMirror(left, right): + 1. 都为空 → true + 2. 一个为空 → false + 3. 值不同 → false + 4. 值相同 → 检查子节点 + isMirror(left.left, right.right) && + isMirror(left.right, right.left) +``` + +**时间复杂度**: O(n) - 每个节点访问一次 +**空间复杂度**: O(h) - h为树高,递归栈空间 + +### 为什么这样思考? + +**核心思想**: +1. **分治**: 大问题分解为小问题(整棵树对称 → 左右子树镜像) +2. **镜像定义**: 左右对称 = 左子树是右子树的镜像 +3. **递归比较**: 从根节点开始,逐层比较对应节点 + +**为什么是 `left.left` vs `right.right`?** +``` + 1 + / \ + L R + / \ / \ + LL LR RL RR + +对称要求: +- LL == RR (左的左 vs 右的右) +- LR == RL (左的右 vs 右的左) +``` ## 解题思路 -### 递归比较 +### 核心思想 +将问题转化为:**判断两棵树是否互为镜像** -## Go 代码 +### 详细算法流程 + +**步骤1: 定义递归函数** +```go +func isMirror(left, right *TreeNode) bool +``` + +**步骤2: 处理基准情况** + +**情况1: 两个节点都为空** +```go +if left == nil && right == nil { + return true // 空树是对称的 +} +``` + +**情况2: 一个节点为空,另一个不为空** +```go +if left == nil || right == nil { + return false // 不对称 +} +``` + +**关键点**: 必须先判断"都为空",再判断"一个为空" + +**情况3: 两个节点值不同** +```go +if left.Val != right.Val { + return false // 值不同,不对称 +} +``` + +**步骤3: 递归检查子节点** +```go +// 左的左 vs 右的右 +// 左的右 vs 右的左 +return isMirror(left.Left, right.Right) && + isMirror(left.Right, right.Left) +``` + +**图解**: +``` + 1 + / \ + 2 2 + / \ / \ + 3 4 4 3 + +检查过程: +├─ 2 == 2 ✓ +├─ isMirror(2.left, 2.right) +│ ├─ 3 == 3 ✓ +│ ├─ isMirror(3.left, 3.right) → isMirror(nil, nil) → true +│ └─ isMirror(3.right, 3.left) → isMirror(nil, nil) → true +└─ isMirror(2.right, 2.left) + ├─ 4 == 4 ✓ + ├─ isMirror(4.left, 4.right) → isMirror(nil, nil) → true + └─ isMirror(4.right, 4.left) → isMirror(nil, nil) → true +``` + +### 关键细节说明 + +**细节1: 为什么判断顺序很重要?** + +```go +// ❌ 错误顺序 +if left == nil || right == nil { + return true // 错误! +} + +// ✅ 正确顺序 +if left == nil && right == nil { + return true +} +if left == nil || right == nil { + return false +} +``` + +**原因**: +- 先判断"都为空"的情况 +- 再判断"一个为空"的情况 +- 顺序错了会导致逻辑错误 + +**细节2: 为什么是 `left.Left` vs `right.Right`?** + +```go +// 镜像对称的对应关系 +isMirror(left.Left, right.Right) // 外侧节点 +isMirror(left.Right, right.Left) // 内侧节点 +``` + +**图解**: +``` + 1 + / \ + L R + / \ / \ + a b c d + +对称要求: +a == d (左的左 vs 右的右) +b == c (左的右 vs 右的左) +``` + +**细节3: 为什么不需要检查 `left` 和 `right` 的值是否相同?** + +```go +// 实际上需要检查! +if left.Val != right.Val { + return false +} +``` + +**原因**: 对称树的对应节点值必须相同 + +### 边界条件分析 + +**边界1: 空树** +``` +输入: root = nil +输出: true +处理: 空树是对称的 +``` + +**边界2: 只有根节点** +``` +输入: root = [1] +输出: true +处理: +- isMirror(nil, nil) +- 两个都为空,返回true +``` + +**边界3: 左子树为空** +``` +输入: + 1 + \ + 2 + +输出: false +处理: +- isMirror(nil, 2) +- 一个为空,返回false +``` + +**边界4: 值不同** +``` +输入: + 1 + / \ + 2 3 + +输出: false +处理: +- 2 != 3 +- 返回false +``` + +**边界5: 结构不同** +``` +输入: + 1 + / \ + 2 2 + \ \ + 3 3 + +输出: false +处理: +- isMirror(2, 2) ✓ +- isMirror(2.right, 2.right) + - isMirror(nil, 3) + - 一个为空,返回false +``` + +### 复杂度分析(详细版) + +**时间复杂度**: +``` +- 每个节点访问一次: O(n) +- 每次访问常数操作: O(1) +- 总计: O(n) + +为什么是O(n)? +- 递归遍历所有节点 +- 每个节点只比较一次 +- 没有重复访问 +``` + +**空间复杂度**: +``` +- 递归栈: O(h) - h为树高 + - 最坏情况(链状树): O(n) + - 最好情况(完全平衡树): O(log n) +- 总计: O(h) +``` + +### 执行过程演示 + +**输入**: +``` + 1 + / \ + 2 2 + / \ / \ +3 4 4 3 +``` + +**执行过程**: +``` +调用 isSymmetric(1): +└─ 调用 isMirror(2, 2): + ├─ 2 == 2 ✓ + ├─ 调用 isMirror(3, 3): + │ ├─ 3 == 3 ✓ + │ ├─ 调用 isMirror(nil, nil): 返回 true + │ └─ 调用 isMirror(nil, nil): 返回 true + │ └─ 返回 true && true = true + ├─ 调用 isMirror(4, 4): + │ ├─ 4 == 4 ✓ + │ ├─ 调用 isMirror(nil, nil): 返回 true + │ └─ 调用 isMirror(nil, nil): 返回 true + │ └─ 返回 true && true = true + └─ 返回 true && true = true + +最终返回: true +``` + +**不对称的例子**: +``` +输入: + 1 + / \ + 2 2 + \ \ + 3 3 + +执行过程: +└─ 调用 isMirror(2, 2): + ├─ 2 == 2 ✓ + ├─ 调用 isMirror(nil, 3): + │ ├─ nil != 3 + │ └─ 返回 false + └─ 返回 false (短路,不再检查右子树) + +最终返回: false +``` + +## 代码实现 + +### 方法一:递归(推荐) ```go func isSymmetric(root *TreeNode) bool { + if root == nil { + return true + } return check(root.Left, root.Right) } +func check(left, right *TreeNode) bool { + // 都为空,对称 + if left == nil && right == nil { + return true + } + // 一个为空,不对称 + if left == nil || right == nil { + return false + } + // 值不同,不对称 + if left.Val != right.Val { + return false + } + // 递归检查子节点 + return check(left.Left, right.Right) && + check(left.Right, right.Left) +} +``` + +**复杂度**: O(n) 时间,O(h) 空间 + +### 方法二:迭代(队列) + +```go +func isSymmetric(root *TreeNode) bool { + if root == nil { + return true + } + + queue := []*TreeNode{root.Left, root.Right} + + for len(queue) > 0 { + left := queue[0] + right := queue[1] + queue = queue[2:] + + if left == nil && right == nil { + continue + } + if left == nil || right == nil { + return false + } + if left.Val != right.Val { + return false + } + + // 按镜像顺序入队 + queue = append(queue, left.Left, right.Right) + queue = append(queue, left.Right, right.Left) + } + + return true +} +``` + +**复杂度**: O(n) 时间,O(n) 空间 + +### 方法三:迭代(栈) + +```go +func isSymmetric(root *TreeNode) bool { + if root == nil { + return true + } + + stack := []*TreeNode{root.Left, root.Right} + + for len(stack) > 0 { + right := stack[len(stack)-1] + stack = stack[:len(stack)-1] + left := stack[len(stack)-1] + stack = stack[:len(stack)-1] + + if left == nil && right == nil { + continue + } + if left == nil || right == nil { + return false + } + if left.Val != right.Val { + return false + } + + // 按镜像顺序入栈 + stack = append(stack, left.Left) + stack = append(stack, right.Right) + stack = append(stack, left.Right) + stack = append(stack, right.Left) + } + + return true +} +``` + +**复杂度**: O(n) 时间,O(n) 空间 + +## 常见错误 + +### 错误1: 判断顺序错误 + +❌ **错误写法**: +```go +func check(left, right *TreeNode) bool { + if left == nil || right == nil { // 错误! + return true + } + // ... +} +``` + +✅ **正确写法**: +```go func check(left, right *TreeNode) bool { if left == nil && right == nil { return true @@ -22,11 +479,139 @@ func check(left, right *TreeNode) bool { if left == nil || right == nil { return false } - - return left.Val == right.Val && - check(left.Left, right.Right) && + // ... +} +``` + +**原因**: 必须先判断"都为空",再判断"一个为空" + +### 错误2: 镜像关系错误 + +❌ **错误写法**: +```go +return check(left.Left, right.Left) && // 错误! + check(left.Right, right.Right) +``` + +✅ **正确写法**: +```go +return check(left.Left, right.Right) && + check(left.Right, right.Left) +``` + +**原因**: 镜像对称是"左的左 vs 右的右","左的右 vs 右的左" + +### 错误3: 忘记检查值 + +❌ **错误写法**: +```go +func check(left, right *TreeNode) bool { + if left == nil && right == nil { + return true + } + if left == nil || right == nil { + return false + } + // 忘记检查 left.Val != right.Val + return check(left.Left, right.Right) && check(left.Right, right.Left) } ``` -**复杂度:** O(n) 时间,O(h) 空间 +✅ **正确写法**: +```go +func check(left, right *TreeNode) bool { + if left == nil && right == nil { + return true + } + if left == nil || right == nil { + return false + } + if left.Val != right.Val { + return false + } + return check(left.Left, right.Right) && + check(left.Right, right.Left) +} +``` + +**原因**: 对称树的对应节点值必须相同 + +## 变体问题 + +### 变体1: 判断是否是相同的树 + +```go +func isSameTree(p *TreeNode, q *TreeNode) bool { + if p == nil && q == nil { + return true + } + if p == nil || q == nil { + return false + } + if p.Val != q.Val { + return false + } + return isSameTree(p.Left, q.Left) && + isSameTree(p.Right, q.Right) +} +``` + +### 变体2: 判断是否是轴对称的N叉树 + +```go +type Node struct { + Val int + Children []*Node +} + +func isSymmetric(root *Node) bool { + if root == nil { + return true + } + return isMirror(root.Children, root.Children) +} + +func isMirror(left, right []*Node) bool { + if len(left) != len(right) { + return false + } + + for i := 0; i < len(left); i++ { + l, r := left[i], right[len(right)-1-i] + if !isMirrorNode(l, r) { + return false + } + } + + return true +} + +func isMirrorNode(a, b *Node) bool { + if a == nil && b == nil { + return true + } + if a == nil || b == nil { + return false + } + if a.Val != b.Val { + return false + } + return isMirror(a.Children, b.Children) +} +``` + +## 总结 + +**核心要点**: +1. **镜像定义**: 左子树是右子树的镜像 +2. **对应关系**: 左的左 vs 右的右,左的右 vs 右的左 +3. **基准情况**: 都为空→true,一个为空→false,值不同→false +4. **递归检查**: 从根节点开始,逐层比较对应节点 + +**易错点**: +- 判断顺序错误(先判断"都为空") +- 镜像关系错误(应该是 `left.Left` vs `right.Right`) +- 忘记检查节点值 + +**推荐写法**: 递归法(代码简洁,逻辑清晰) diff --git a/16-LeetCode Hot 100/无重复字符的最长子串.md b/16-LeetCode Hot 100/无重复字符的最长子串.md index f221743..0e7b3d7 100644 --- a/16-LeetCode Hot 100/无重复字符的最长子串.md +++ b/16-LeetCode Hot 100/无重复字符的最长子串.md @@ -28,25 +28,409 @@ LeetCode 3. Medium 请注意,你的答案必须是 子串 的长度,"pwke" 是一个子序列,不是子串。 ``` +## 思路推导 + +### 暴力解法分析 + +**最直观的思路**:枚举所有可能的子串,检查是否有重复字符。 + +```python +def lengthOfLongestSubstring(s): + max_len = 0 + n = len(s) + + for i in range(n): + for j in range(i+1, n+1): + substring = s[i:j] + if len(set(substring)) == len(substring): # 无重复 + max_len = max(max_len, j-i) + + return max_len +``` + +**时间复杂度**:O(n³) +- 外层循环:O(n) 枚举起始位置 +- 内层循环:O(n) 枚举结束位置 +- 检查重复:O(n) 创建集合 +- 总计:O(n) × O(n) × O(n) = O(n³) + +**空间复杂度**:O(min(m, n)),m 为字符集大小 + +**问题分析**: +1. 效率太低:n=10⁵ 时,n³ 不可接受 +2. 重复计算:很多子串被多次检查 +3. 无法利用已知信息 + +### 优化思考 - 第一步:滑动窗口 + +**观察**:如果 s[i:j] 无重复,检查 s[j] 是否在窗口内 + +```python +# 维护一个窗口 [left, right] +# 每次向右扩展 right +# 如果 s[right] 在窗口内重复,移动 left +``` + +**为什么这样思考?** +- 窗口内的子串保证无重复 +- 只需要向右移动,不需要回溯 +- 每个字符最多被访问 2 次(进入和离开窗口) + +**优化后的思路**: +```python +left = 0 +max_len = 0 +for right in range(len(s)): + # 如果 s[right] 在窗口内,移动 left + while s[right] in s[left:right]: + left += 1 + max_len = max(max_len, right - left + 1) +``` + +**时间复杂度**:O(n²) +- 仍然有重复检查:`s[right] in s[left:right]` 是 O(n) + +### 优化思考 - 第二步:哈希表优化 + +**问题**:如何快速判断字符是否在窗口内? + +**关键优化**:用哈希表记录字符最后出现的位置 + +```python +char_index = {} # 字符 → 最后出现的位置 +left = 0 +max_len = 0 + +for right, char in enumerate(s): + # 如果字符在窗口内,移动 left + if char in char_index and char_index[char] >= left: + left = char_index[char] + 1 + + char_index[char] = right + max_len = max(max_len, right - left + 1) +``` + +**为什么这样思考?** +- 哈希表查找:O(1) +- 直接定位到重复字符的位置 +- left 可以跳跃式移动,不用逐个移动 + +**时间复杂度**:O(n) +- 每个字符只处理一次 +- 哈希表操作:O(1) + +### 优化思考 - 第三步:数组代替哈希表 + +**进一步优化**:如果字符集有限(如 ASCII),用数组代替哈希表 + +```python +char_index = [-1] * 128 # ASCII 字符集 +left = 0 +max_len = 0 + +for right, char in enumerate(s): + char = ord(char) # 转换为 ASCII 码 + if char_index[char] >= left: + left = char_index[char] + 1 + + char_index[char] = right + max_len = max(max_len, right - left + 1) +``` + +**优势**: +- 数组访问比哈希表更快 +- 空间局部性更好(cache 友好) +- 适合字符集有限的情况 + ## 解题思路 ### 核心思想 -使用**滑动窗口**(Sliding Window)+ **哈希表**记录字符位置。 -### 算法流程 -1. 维护一个窗口 [left, right] -2. 使用哈希表记录每个字符最后一次出现的位置 -3. 遍历字符串: - - 如果当前字符在窗口内出现,移动 left 到重复字符的下一位 - - 更新哈希表和最大长度 +**滑动窗口 + 哈希表**:维护动态窗口,用哈希表记录字符位置。 -### 复杂度分析 -- **时间复杂度**:O(n),n 为字符串长度 -- **空间复杂度**:O(min(m, n)),m 为字符集大小 +**为什么这样思考?** + +1. **滑动窗口的原理**: + - 窗口 [left, right] 内保证无重复字符 + - 右边界不断扩展 + - 左边界根据重复情况调整 + +2. **哈希表的作用**: + - 记录每个字符最后出现的位置 + - 快速判断重复字符是否在窗口内 + - 支持 O(1) 时间复杂度的查找和更新 + +3. **关键判断**: + - `char_index[char] >= left`:字符在窗口内 + - `char_index[char] < left`:字符在窗口外(已失效) + +### 详细算法流程 + +**步骤1:初始化数据结构** + +```python +char_index = {} # 字符 → 最后出现的位置 +left = 0 # 窗口左边界 +max_len = 0 # 最长长度 +``` + +**作用**: +- `char_index`:快速判断重复 +- `left`:标记当前窗口的起点 +- `max_len`:记录结果 + +**步骤2:遍历字符串** + +```python +for right, char in enumerate(s): + # 检查字符是否在窗口内 + if char in char_index and char_index[char] >= left: + # 重复字符在窗口内,移动 left + left = char_index[char] + 1 + + # 更新字符位置 + char_index[char] = right + + # 更新最大长度 + max_len = max(max_len, right - left + 1) +``` + +**关键点详解**: + +1. **为什么判断 `char_index[char] >= left`?** + - 只关心重复字符是否在当前窗口内 + - 如果在窗口外,可以忽略 + - 示例: + ``` + s = "a b c a" + left = 0, right = 3 + char_index['a'] = 0 >= left → 重复,left = 1 + + s = "a b c a b c" + left = 1, right = 5 + char_index['b'] = 1 >= left → 重复,left = 2 + + s = "a b c a b" + left = 1, right = 4 + char_index['a'] = 0 < left → 不在窗口内,不移动 + ``` + +2. **为什么 `left = char_index[char] + 1`?** + - 跳过重复字符,包括重复字符本身 + - 新窗口从重复字符的下一位开始 + - 示例: + ``` + s = "a b c a" + 0 1 2 3 + right = 3, char = 'a' + char_index['a'] = 0 + left = 0 + 1 = 1 + 新窗口:[1, 3] = "bca" + ``` + +3. **为什么先更新 left,再更新 char_index?** + - 必须先判断重复,再更新位置 + - 如果先更新,会覆盖旧位置 + - 错误示例: + ```python + char_index[char] = right # 错误!先更新了 + if char in char_index and char_index[char] >= left: + left = char_index[char] + 1 # 永远成立 + ``` + +**步骤3:返回结果** + +```python +return max_len +``` + +### 关键细节说明 + +**细节1:为什么用 `enumerate` 而不是 `range`?** + +```python +# 推荐写法:同时获取索引和字符 +for right, char in enumerate(s): + # ... + +# 不推荐:需要额外索引 +for i in range(len(s)): + char = s[i] + # ... +``` + +**细节2:为什么窗口长度是 `right - left + 1`?** + +```python +# 示例:s = "abc" +left = 0, right = 2 +窗口长度 = 2 - 0 + 1 = 3 +索引:[0, 1, 2] + +# 为什么 +1? +# 索引从 0 开始,需要 +1 才是实际长度 +``` + +**细节3:为什么 `char_index[char] >= left` 而不是 `> left`?** + +```python +# 示例:s = "abca" +left = 0, right = 3, char = 'a' +char_index['a'] = 0 + +# 如果用 > left +if char_index[char] > left: # 0 > 0 → False + # 不会移动 left,错误! + +# 正确:用 >= left +if char_index[char] >= left: # 0 >= 0 → True + left = 1 # 正确! +``` + +**细节4:为什么需要两个条件判断?** + +```python +# 条件1:字符是否出现过 +if char in char_index: + +# 条件2:字符是否在窗口内 +and char_index[char] >= left: + +# 为什么都需要? +# 示例:s = "abcabcbb" +# right = 3, char = 'a' +# char_index['a'] = 0 < left(1) → 不在窗口内 +# 虽然出现过,但不在窗口内,可以保留 +``` + +### 边界条件分析 + +**边界1:空字符串** + +``` +输入:s = "" +输出:0 +处理:循环不执行,max_len = 0 +``` + +**边界2:全部相同字符** + +``` +输入:s = "bbbbb" +过程: + right=0: char='b', left=0, max_len=1 + right=1: char='b', 重复, left=1, max_len=1 + right=2: char='b', 重复, left=2, max_len=1 + right=3: char='b', 重复, left=3, max_len=1 + right=4: char='b', 重复, left=4, max_len=1 +输出:1 +``` + +**边界3:全部不同字符** + +``` +输入:s = "abcde" +过程: + right=0: char='a', left=0, max_len=1 + right=1: char='b', left=0, max_len=2 + right=2: char='c', left=0, max_len=3 + right=3: char='d', left=0, max_len=4 + right=4: char='e', left=0, max_len=5 +输出:5 +``` + +**边界4:重复字符在窗口外** + +``` +输入:s = "abca" +过程: + right=0: char='a', left=0, max_len=1 + right=1: char='b', left=0, max_len=2 + right=2: char='c', left=0, max_len=3 + right=3: char='a', char_index['a']=0 < left=0? → False + 实际:0 >= 0 → True, left=1, max_len=3 + +输入:s = "abcabcbb" +过程: + right=3: char='a', char_index['a']=0 >= left=0 → left=1 + right=4: char='b', char_index['b']=1 >= left=1 → left=2 + right=5: char='c', char_index['c']=2 >= left=2 → left=3 + right=6: char='b', char_index['b']=4 >= left=3 → left=5 + right=7: char='b', char_index['b']=6 >= left=5 → left=7 +输出:3 +``` + +### 复杂度分析(详细版) + +**时间复杂度**: +``` +- 外层循环:O(n),遍历字符串 +- 哈希表操作:O(1),查找和更新 +- 总计:O(n) + +为什么是 O(n)? +- 每个字符最多被访问 2 次(进入和离开窗口) +- left 指针最多移动 n 次 +- right 指针最多移动 n 次 +- 总操作次数 = 2n = O(n) +``` + +**空间复杂度**: +``` +- 哈希表:O(min(m, n)),m 为字符集大小 + - ASCII:O(128) = O(1) + - Unicode:O(n) +- 指针变量:O(1) +- 总计:O(min(m, n)) +``` --- -## 解法 +## 图解过程 + +``` +字符串: "abcabcbb" + +步骤1: [a]bcabcbb + left=0, right=0, max_len=1 + char_index = {'a': 0} + +步骤2: [a,b]cabcbb + left=0, right=1, max_len=2 + char_index = {'a': 0, 'b': 1} + +步骤3: [a,b,c]abcbb + left=0, right=2, max_len=3 + char_index = {'a': 0, 'b': 1, 'c': 2} + +步骤4: a[b,c,a]bcbb (发现重复,left移动) + left=1, right=3, max_len=3 + char_index = {'a': 3, 'b': 1, 'c': 2} + +步骤5: ab[c,a,b]cbb (发现重复,left移动) + left=2, right=4, max_len=3 + char_index = {'a': 3, 'b': 4, 'c': 2} + +步骤6: abc[a,b,c]bb (发现重复,left移动) + left=3, right=5, max_len=3 + char_index = {'a': 3, 'b': 4, 'c': 5} + +步骤7: abca[b,c,b]b (发现重复,left移动) + left=5, right=6, max_len=3 + char_index = {'a': 3, 'b': 6, 'c': 5} + +步骤8: abcab[c,b,b] (发现重复,left移动) + left=7, right=7, max_len=3 + char_index = {'a': 3, 'b': 7, 'c': 5} + +结果: max_len = 3 +``` + +--- + +## 代码实现 + +### 方法1:哈希表(推荐) ```go func lengthOfLongestSubstring(s string) int { @@ -74,47 +458,177 @@ func lengthOfLongestSubstring(s string) int { } ``` ---- +### 方法2:数组优化(ASCII) + +```go +func lengthOfLongestSubstring(s string) int { + // 使用数组代替哈希表,适用于 ASCII 字符集 + charIndex := [128]int{} // ASCII 字符集 + for i := range charIndex { + charIndex[i] = -1 + } + + maxLength := 0 + left := 0 + + for right := 0; right < len(s); right++ { + char := s[right] + + // 如果字符已存在且在窗口内,移动左边界 + if charIndex[char] >= left { + left = charIndex[char] + 1 + } + + // 更新字符位置 + charIndex[char] = right + + // 更新最大长度 + if right - left + 1 > maxLength { + maxLength = right - left + 1 + } + } + + return maxLength +} +``` --- -## 图解过程 +## 执行过程演示 + +**输入**:s = "abcabcbb" ``` -字符串: "abcabcbb" +初始化:charIndex = {}, left = 0, max_len = 0 -步骤1: [a]bcabcbb - left=0, right=0, maxLength=1 +right=0, char='a': + charIndex['a'] 不存在 + charIndex = {'a': 0} + max_len = max(0, 0-0+1) = 1 -步骤2: [a,b]cabcbb - left=0, right=1, maxLength=2 +right=1, char='b': + charIndex['b'] 不存在 + charIndex = {'a': 0, 'b': 1} + max_len = max(1, 1-0+1) = 2 -步骤3: [a,b,c]abcbb - left=0, right=2, maxLength=3 +right=2, char='c': + charIndex['c'] 不存在 + charIndex = {'a': 0, 'b': 1, 'c': 2} + max_len = max(2, 2-0+1) = 3 -步骤4: a[b,c,a]bcbb (发现重复,left移动) - left=1, right=3, maxLength=3 +right=3, char='a': + charIndex['a'] = 0 >= left(0) → 重复 + left = 0 + 1 = 1 + charIndex = {'a': 3, 'b': 1, 'c': 2} + max_len = max(3, 3-1+1) = 3 -步骤5: ab[c,a,b]cbb (发现重复,left移动) - left=2, right=4, maxLength=3 +right=4, char='b': + charIndex['b'] = 1 >= left(1) → 重复 + left = 1 + 1 = 2 + charIndex = {'a': 3, 'b': 4, 'c': 2} + max_len = max(3, 4-2+1) = 3 -步骤6: abc[a,b,c]bb (发现重复,left移动) - left=3, right=5, maxLength=3 +right=5, char='c': + charIndex['c'] = 2 >= left(2) → 重复 + left = 2 + 1 = 3 + charIndex = {'a': 3, 'b': 4, 'c': 5} + max_len = max(3, 5-3+1) = 3 -步骤7: abca[b,c,b]b (发现重复,left移动) - left=4, right=6, maxLength=3 +right=6, char='b': + charIndex['b'] = 4 >= left(3) → 重复 + left = 4 + 1 = 5 + charIndex = {'a': 3, 'b': 6, 'c': 5} + max_len = max(3, 6-5+1) = 3 -步骤8: abcab[c,b,b] (发现重复,left移动) - left=5, right=7, maxLength=3 +right=7, char='b': + charIndex['b'] = 6 >= left(5) → 重复 + left = 6 + 1 = 7 + charIndex = {'a': 3, 'b': 7, 'c': 5} + max_len = max(3, 7-7+1) = 3 -结果: maxLength = 3 +结果:max_len = 3 ``` --- +## 常见错误 + +### 错误1:忘记判断字符是否在窗口内 + +❌ **错误代码**: +```go +if idx, ok := charIndex[char]; ok { + left = idx + 1 // 错误!可能在窗口外 +} +``` + +✅ **正确代码**: +```go +if idx, ok := charIndex[char]; ok && idx >= left { + left = idx + 1 // 正确!只在窗口内时移动 +} +``` + +**原因**: +- 示例:s = "abcabcbb" +- right=3, char='a', charIndex['a']=0, left=1 +- 0 < 1,不在窗口内,不应该移动 left + +--- + +### 错误2:更新 char_index 的时机错误 + +❌ **错误代码**: +```go +for right, char := range s { + charIndex[char] = right // 错误!先更新了 + + if idx, ok := charIndex[char]; ok && idx >= left { + left = idx + 1 // 永远成立 + } +} +``` + +✅ **正确代码**: +```go +for right, char := range s { + if idx, ok := charIndex[char]; ok && idx >= left { + left = idx + 1 // 先判断 + } + + charIndex[char] = right // 再更新 +} +``` + +**原因**: +- 先更新会覆盖旧位置 +- 导致判断永远成立 + +--- + +### 错误3:窗口长度计算错误 + +❌ **错误代码**: +```go +max_len = max(max_len, right - left) // 错误!少了 +1 +``` + +✅ **正确代码**: +```go +max_len = max(max_len, right - left + 1) // 正确 +``` + +**原因**: +- 索引从 0 开始 +- 长度 = right - left + 1 +- 示例:[0, 2] 长度为 3,不是 2 + +--- + ## 进阶问题 ### Q1: 如何返回最长子串本身? + ```go func longestSubstring(s string) string { charIndex := make(map[rune]int) @@ -138,7 +652,14 @@ func longestSubstring(s string) string { } ``` +**关键点**: +- 记录最长子串的起始位置 +- 在更新 max_len 时同时更新 start + +--- + ### Q2: 如果字符集有限(如只有小写字母),如何优化? + **优化**:使用数组代替哈希表 ```go @@ -171,6 +692,11 @@ func max(a, b int) int { } ``` +**优势**: +- 数组访问比哈希表更快 +- 空间局部性更好 +- 适合字符集有限的情况 + --- ## P7 加分项 @@ -193,7 +719,7 @@ func max(a, b int) int { ## 总结 -这道题的核心是: +**核心要点**: 1. **滑动窗口**:动态调整窗口边界 2. **哈希表**:记录字符位置,快速判断重复 3. **双指针**:left 和 right 指针协同移动 diff --git a/16-LeetCode Hot 100/最长连续序列.md b/16-LeetCode Hot 100/最长连续序列.md index a1a5c1c..4558d64 100644 --- a/16-LeetCode Hot 100/最长连续序列.md +++ b/16-LeetCode Hot 100/最长连续序列.md @@ -1,44 +1,705 @@ # 最长连续序列 (Longest Consecutive Sequence) +LeetCode 128. Medium + ## 题目描述 -给定一个未排序的整数数组 nums,找出数字连续的最长序列的长度。 +给定一个未排序的整数数组 `nums`,找出数字连续序列的最长长度。 + +**要求**:请设计时间复杂度为 O(n) 的算法。 + +**示例 1**: +``` +输入:nums = [100, 4, 200, 1, 3, 2] +输出:4 +解释:最长数字连续序列是 [1, 2, 3, 4]。它的长度为 4。 +``` + +**示例 2**: +``` +输入:nums = [0, 3, 7, 2, 5, 8, 4, 6, 0, 1] +输出:9 +解释:最长的连续序列是 [0, 1, 2, 3, 4, 5, 6, 7, 8]。 +``` + +## 思路推导 + +### 暴力解法分析 + +**最直观的思路**:排序后遍历,找到最长的连续序列。 + +```python +def longestConsecutive(nums): + if not nums: + return 0 + + nums.sort() + max_len = 1 + current_len = 1 + + for i in range(1, len(nums)): + if nums[i] == nums[i-1] + 1: + current_len += 1 + elif nums[i] != nums[i-1]: # 跳过重复 + current_len = 1 + + max_len = max(max_len, current_len) + + return max_len +``` + +**时间复杂度**:O(n log n) +- 排序:O(n log n) +- 遍历:O(n) +- 总计:O(n log n) + O(n) = O(n log n) + +**空间复杂度**:O(1) 或 O(n),取决于排序算法 + +**问题分析**: +1. 不满足题目要求:题目要求 O(n) +2. 排序是最快的,但仍不够快 +3. 需要寻找不排序的解法 + +### 优化思考 - 第一步:哈希表查找 + +**观察**:连续序列的特点是相邻元素相差 1 + +**问题**:如何快速判断一个数是否存在? + +**解决方案**:使用哈希表(Set) + +```python +num_set = set(nums) + +for num in nums: + # 检查 num+1 是否在集合中 + if num + 1 in num_set: + # 继续检查 num+2, num+3, ... +``` + +**为什么这样思考?** +- 哈希表查找:O(1) +- 可以快速判断一个数是否存在 +- 不需要排序 + +### 优化思考 - 第二步:寻找序列起点 + +**关键优化**:如何避免重复计算同一个序列? + +**观察**:只有当一个数是序列的起点时,才需要计算 + +```python +# num 是序列起点 +if num - 1 not in num_set: + # 从 num 开始向后查找 + current_num = num + current_len = 1 + + while current_num + 1 in num_set: + current_num += 1 + current_len += 1 +``` + +**为什么这样思考?** +- 如果 `num-1` 存在,说明 `num` 不是起点 +- 只有起点才需要计算,避免重复 +- 每个序列只被计算一次 + +**时间复杂度**:O(n) +- 外层循环:O(n) +- 内层 while:总计 O(n)(每个元素只访问一次) +- 总计:O(n) + O(n) = O(n) + +### 优化思考 - 第三步:空间换时间 + +**权衡**: +- 时间复杂度:O(n) +- 空间复杂度:O(n) +- 用空间换取时间 + +**为什么可以接受?** +- 题目要求 O(n) 时间 +- O(n) 空间是可接受的 +- 哈希表是实现 O(n) 的必要条件 ## 解题思路 -### 哈希表 +### 核心思想 -将数字存入哈希表,对于每个数字,如果它是序列的起点(num-1 不在集合中),则向后查找。 +**哈希表 + 序列起点判断**:用哈希表存储所有数字,只从序列起点开始计算长度。 -## Go 代码 +**为什么这样思考?** + +1. **哈希表的优势**: + - O(1) 时间查找元素是否存在 + - 无需排序,保持原始数据 + +2. **序列起点判断**: + - 如果 `num-1` 不在集合中,`num` 是起点 + - 只有起点才需要计算 + - 避免重复计算同一个序列 + +3. **时间复杂度保证**: + - 每个元素最多被访问 2 次 + - 一次在哈希表中 + - 一次在 while 循环中 + +### 详细算法流程 + +**步骤1:构建哈希表** + +```python +num_set = set(nums) +``` + +**作用**: +- 快速判断元素是否存在 +- O(1) 时间复杂度 + +**步骤2:遍历所有数字** + +```python +longest = 0 + +for num in num_set: + # 判断是否为序列起点 + if num - 1 not in num_set: + # 从起点开始计算序列长度 + current_num = num + current_len = 1 + + # 向后查找连续数字 + while current_num + 1 in num_set: + current_num += 1 + current_len += 1 + + # 更新最大长度 + longest = max(longest, current_len) +``` + +**关键点详解**: + +1. **为什么判断 `num - 1 not in num_set`?** + - 如果 `num-1` 存在,说明 `num` 不是起点 + - 只有起点才需要计算 + - 避免重复计算 + + **示例**: + ``` + nums = [1, 2, 3, 4] + + num=1: 1-1=0 不在集合中 → 起点,计算 [1,2,3,4] + num=2: 2-1=1 在集合中 → 不是起点,跳过 + num=3: 3-1=2 在集合中 → 不是起点,跳过 + num=4: 4-1=3 在集合中 → 不是起点,跳过 + ``` + +2. **为什么用 `while` 而不是 `for`?** + - 不知道序列有多长 + - 需要动态判断下一个数字是否存在 + - `while` 更灵活 + +3. **为什么可以保证 O(n)?** + - 外层 for 循环:O(n) + - 内层 while 循环:总计 O(n) + - 每个元素只在 while 中被访问一次 + - 因为只有起点才会进入 while + - 总计:O(n) + O(n) = O(n) + +### 关键细节说明 + +**细节1:为什么用 `set` 而不是 `list`?** + +```python +# 推荐:使用 set +num_set = set(nums) +if num - 1 in num_set: # O(1) + +# 不推荐:使用 list +if num - 1 in nums: # O(n) +``` + +**原因**: +- `set` 的查找是 O(1) +- `list` 的查找是 O(n) +- 总复杂度会变成 O(n²) + +**细节2:为什么遍历 `num_set` 而不是 `nums`?** + +```python +# 推荐:遍历 num_set +for num in num_set: # 自动去重 + +# 不推荐:遍历 nums +for num in nums: # 可能有重复 +``` + +**原因**: +- `nums` 可能有重复元素 +- 重复元素会导致重复计算 +- `num_set` 自动去重 + +**细节3:为什么需要 `longest` 变量?** + +```python +longest = 0 + +for num in num_set: + current_len = ... + longest = max(longest, current_len) +``` + +**原因**: +- 需要记录全局最大值 +- 每次计算完一个序列后更新 +- 最终返回 `longest` + +### 边界条件分析 + +**边界1:空数组** + +``` +输入:nums = [] +输出:0 +处理: + num_set = set() + for 循环不执行 + longest = 0 +``` + +**边界2:单个元素** + +``` +输入:nums = [1] +输出:1 +过程: + num_set = {1} + + num=1: 1-1=0 不在集合中 → 起点 + current_num=1, current_len=1 + 1+1=2 不在集合中 → 退出 + longest = max(0, 1) = 1 + +输出:1 +``` + +**边界3:全部重复** + +``` +输入:nums = [1, 1, 1, 1] +输出:1 +过程: + num_set = {1} + + num=1: 1-1=0 不在集合中 → 起点 + current_num=1, current_len=1 + 1+1=2 不在集合中 → 退出 + longest = 1 + +输出:1 +``` + +**边界4:多个连续序列** + +``` +输入:nums = [100, 4, 200, 1, 3, 2] +输出:4 +过程: + num_set = {100, 4, 200, 1, 3, 2} + + num=100: 100-1=99 不在集合中 → 起点 + current_num=100, current_len=1 + 101 不在集合中 → 退出 + longest = 1 + + num=4: 4-1=3 在集合中 → 不是起点,跳过 + + num=200: 200-1=199 不在集合中 → 起点 + current_num=200, current_len=1 + 201 不在集合中 → 退出 + longest = max(1, 1) = 1 + + num=1: 1-1=0 不在集合中 → 起点 + current_num=1, current_len=1 + 2 在集合中 → current_len=2 + 3 在集合中 → current_len=3 + 4 在集合中 → current_len=4 + 5 不在集合中 → 退出 + longest = max(1, 4) = 4 + + num=3: 3-1=2 在集合中 → 不是起点,跳过 + + num=2: 2-1=1 在集合中 → 不是起点,跳过 + +输出:4 +``` + +**边界5:负数** + +``` +输入:nums = [-1, -2, 0, 1] +输出:4 +过程: + num_set = {-1, -2, 0, 1} + + num=-1: -1-1=-2 在集合中 → 不是起点,跳过 + + num=-2: -2-1=-3 不在集合中 → 起点 + current_num=-2, current_len=1 + -1 在集合中 → current_len=2 + 0 在集合中 → current_len=3 + 1 在集合中 → current_len=4 + 2 不在集合中 → 退出 + longest = 4 + + num=0: 0-1=-1 在集合中 → 不是起点,跳过 + + num=1: 1-1=0 在集合中 → 不是起点,跳过 + +输出:4 +``` + +### 复杂度分析(详细版) + +**时间复杂度**: +``` +- 构建哈希表:O(n) +- 外层循环:O(n),遍历所有元素 +- 内层 while:总计 O(n) + - 每个元素只在 while 中被访问一次 + - 因为只有起点才会进入 while +- 总计:O(n) + O(n) + O(n) = O(n) + +为什么是 O(n)? +- 虽然有嵌套循环,但每个元素最多被访问 2 次 +- 一次在哈希表中 +- 一次在 while 循环中 +- 总操作次数 = 2n = O(n) +``` + +**空间复杂度**: +``` +- 哈希表:O(n),存储所有元素 +- 变量:O(1) +- 总计:O(n) +``` + +--- + +## 图解过程 + +``` +nums = [100, 4, 200, 1, 3, 2] + +构建哈希表: +num_set = {100, 4, 200, 1, 3, 2} + +遍历: + +步骤1: num = 100 + 100-1=99 不在集合中 → 起点 + 序列:[100] + 101 不在集合中 → 退出 + longest = 1 + +步骤2: num = 4 + 4-1=3 在集合中 → 不是起点,跳过 + +步骤3: num = 200 + 200-1=199 不在集合中 → 起点 + 序列:[200] + 201 不在集合中 → 退出 + longest = 1 + +步骤4: num = 1 + 1-1=0 不在集合中 → 起点 + 序列:[1, 2, 3, 4] + 5 不在集合中 → 退出 + longest = 4 + +步骤5: num = 3 + 3-1=2 在集合中 → 不是起点,跳过 + +步骤6: num = 2 + 2-1=1 在集合中 → 不是起点,跳过 + +结果:longest = 4 +``` + +--- + +## 代码实现 ```go func longestConsecutive(nums []int) int { + // 构建哈希表 numSet := make(map[int]bool) for _, num := range nums { numSet[num] = true } - + longest := 0 - + + // 遍历所有数字 for num := range numSet { - if !numSet[num-1] { // 是序列起点 + // 判断是否为序列起点 + if !numSet[num-1] { currentNum := num current := 1 - + + // 向后查找连续数字 for numSet[currentNum+1] { currentNum++ current++ } - + + // 更新最大长度 if current > longest { longest = current } } } - + return longest } ``` -**复杂度:** O(n) 时间,O(n) 空间 +**关键点**: +1. 使用 map 实现 Set +2. 判断 `num-1` 是否存在 +3. 只有起点才计算序列长度 + +--- + +## 执行过程演示 + +**输入**:nums = [100, 4, 200, 1, 3, 2] + +``` +初始化:numSet = {}, longest = 0 + +步骤1:构建哈希表 +numSet = { + 100: true, + 4: true, + 200: true, + 1: true, + 3: true, + 2: true +} + +步骤2:遍历哈希表 + +num=100: + 100-1=99 不在 numSet 中 → 起点 + currentNum=100, current=1 + 101 不在 numSet 中 → 退出 + longest = max(0, 1) = 1 + +num=4: + 4-1=3 在 numSet 中 → 不是起点,跳过 + +num=200: + 200-1=199 不在 numSet 中 → 起点 + currentNum=200, current=1 + 201 不在 numSet 中 → 退出 + longest = max(1, 1) = 1 + +num=1: + 1-1=0 不在 numSet 中 → 起点 + currentNum=1, current=1 + 2 在 numSet 中 → currentNum=2, current=2 + 3 在 numSet 中 → currentNum=3, current=3 + 4 在 numSet 中 → currentNum=4, current=4 + 5 不在 numSet 中 → 退出 + longest = max(1, 4) = 4 + +num=3: + 3-1=2 在 numSet 中 → 不是起点,跳过 + +num=2: + 2-1=1 在 numSet 中 → 不是起点,跳过 + +结果:longest = 4 +``` + +--- + +## 常见错误 + +### 错误1:忘记去重 + +❌ **错误代码**: +```go +// 直接遍历 nums,可能有重复 +for _, num := range nums { + // ... +} +``` + +✅ **正确代码**: +```go +// 先构建 Set,自动去重 +numSet := make(map[int]bool) +for _, num := range nums { + numSet[num] = true +} + +for num := range numSet { + // ... +} +``` + +**原因**: +- `nums` 可能有重复元素 +- 重复元素会导致重复计算 +- 影响时间复杂度 + +--- + +### 错误2:没有判断序列起点 + +❌ **错误代码**: +```go +// 对每个数字都计算序列长度 +for num := range numSet { + current := 1 + for numSet[currentNum+1] { + // ... + } +} +``` + +✅ **正确代码**: +```go +// 只对起点计算序列长度 +for num := range numSet { + if !numSet[num-1] { // 判断是否为起点 + // ... + } +} +``` + +**原因**: +- 没有判断起点会重复计算 +- 时间复杂度会变成 O(n²) +- 示例:[1,2,3,4] 会计算 4 次 + +--- + +### 错误3:使用 list 而不是 set + +❌ **错误代码**: +```go +// 使用 list 查找 +if contains(nums, num-1) { // O(n) + // ... +} +``` + +✅ **正确代码**: +```go +// 使用 set 查找 +if numSet[num-1] { // O(1) + // ... +} +``` + +**原因**: +- `list` 查找是 O(n) +- `set` 查找是 O(1) +- 总复杂度会变成 O(n²) + +--- + +## 进阶问题 + +### Q1: 如果需要返回最长序列本身? + +```go +func longestConsecutiveSequence(nums []int) []int { + numSet := make(map[int]bool) + for _, num := range nums { + numSet[num] = true + } + + longest := 0 + start := 0 // 记录序列起点 + + for num := range numSet { + if !numSet[num-1] { + currentNum := num + current := 1 + + for numSet[currentNum+1] { + currentNum++ + current++ + } + + if current > longest { + longest = current + start = num + } + } + } + + // 构建结果 + result := make([]int, longest) + for i := 0; i < longest; i++ { + result[i] = start + i + } + + return result +} +``` + +--- + +### Q2: 如果数据量很大,如何优化内存? + +**思路**:使用布隆过滤器(Bloom Filter) + +```go +// 布隆过滤器可以节省内存,但有误判率 +// 适用于大数据场景 +``` + +**注意**: +- 布隆过滤器有误判率 +- 需要根据场景调整参数 +- 适合对准确性要求不高的场景 + +--- + +## P7 加分项 + +### 深度理解 +- **哈希表的作用**:O(1) 查找,实现 O(n) 时间复杂度 +- **序列起点判断**:避免重复计算,保证 O(n) 时间 +- **空间换时间**:用 O(n) 空间换取 O(n) 时间 + +### 实战扩展 +- **大数据场景**:分布式计算、分片处理 +- **内存优化**:布隆过滤器、位图 +- **业务场景**:用户活跃度分析、时间窗口统计 + +### 变形题目 +1. 最长连续序列(允许重复) +2. 最长等差序列 +3. 最长递增子序列 + +--- + +## 总结 + +**核心要点**: +1. **哈希表**:O(1) 查找,快速判断元素是否存在 +2. **序列起点判断**:避免重复计算,保证 O(n) 时间 +3. **空间换时间**:用 O(n) 空间换取 O(n) 时间 + +**易错点**: +- 忘记去重 +- 没有判断序列起点 +- 使用 list 而不是 set + +**最优解法**:哈希表 + 序列起点判断,时间 O(n),空间 O(n) diff --git a/16-LeetCode Hot 100/电话号码的字母组合.md b/16-LeetCode Hot 100/电话号码的字母组合.md index 1c307d3..881ec2c 100644 --- a/16-LeetCode Hot 100/电话号码的字母组合.md +++ b/16-LeetCode Hot 100/电话号码的字母组合.md @@ -42,6 +42,71 @@ - `0 <= digits.length <= 4` - `digits[i]` 是范围 `['2', '9']` 的一个数字。 +## 思路推导 + +### 暴力解法分析 + +**第一步:直观思路 - 嵌套循环** + +```python +def letterCombinations_brute(digits): + if not digits: + return [] + + # 数字到字母的映射 + mapping = { + '2': 'abc', '3': 'def', '4': 'ghi', '5': 'jkl', + '6': 'mno', '7': 'pqrs', '8': 'tuv', '9': 'wxyz' + } + + # 对于 "23",需要两层循环 + result = [] + for letter1 in mapping[digits[0]]: + for letter2 in mapping[digits[1]]: + result.append(letter1 + letter2) + + return result +``` + +**问题分析:** +- 不知道输入长度,无法确定嵌套层数 +- 代码无法通用化 +- 时间复杂度:O(4^n)(最坏情况,每个数字对应4个字母) + +### 优化思考 - 如何通用化? + +**核心观察:** +1. **问题本质**:在每个数字对应的字母集中选择一个,组合成字符串 +2. **与排列组合的关系**:这是一个"笛卡尔积"问题 +3. **递归思路**:处理完当前数字后,递归处理下一个数字 + +**为什么用回溯?** +- 需要遍历所有可能的组合 +- 每个数字的选择是独立的 +- 可以通过递归自然地表达嵌套结构 + +### 为什么这样思考? + +**1. 树形结构视角** +``` +digits = "23" 的组合树: + + "" + / \ + a b c + /|\ /|\ /|\ + d e f d e f d e f + +结果:["ad","ae","af","bd","be","bf","cd","ce","cf"] +``` + +**2. 递归的三个要素** +``` +- 终止条件:处理完所有数字 (index == len(digits)) +- 选择列表:当前数字对应的所有字母 +- 路径:已选择的字母组合 +``` + ## 解题思路 ### 方法一:回溯法(推荐) @@ -55,6 +120,157 @@ - 当前索引等于 `digits` 长度时,将当前组合加入结果 - 否则,遍历当前数字对应的所有字母,递归处理下一个数字 +### 详细算法流程 + +**步骤1:建立数字到字母的映射** + +```python +phoneMap = { + '2': "abc", + '3': "def", + '4': "ghi", + '5': "jkl", + '6': "mno", + '7': "pqrs", + '8': "tuv", + '9': "wxyz" +} +``` + +**Q: 为什么用映射而不是数组?** + +A: 数字是字符类型('2'-'9'),直接映射更直观。用数组需要 `digit - '0' - 2` 转换。 + +**步骤2:设计回溯函数** + +```python +def backtrack(index): + # 终止条件:处理完所有数字 + if index == len(digits): + result.append("".join(current)) + return + + # 获取当前数字对应的所有字母 + digit = digits[index] + letters = phoneMap[digit] + + # 遍历所有字母,做选择 + for letter in letters: + current.append(letter) # 做选择 + backtrack(index + 1) # 递归 + current.pop() # 撤销选择 +``` + +**Q: 为什么需要撤销选择?** + +A: 因为 `current` 是共享的列表,不撤销会影响下一次递归。举例: +``` +不撤销的情况: +- 选择 'a' → current=['a'] +- 选择 'd' → current=['a','d'],加入结果 +- 回溯后 current=['a','d'],而不是 ['a'] +- 下一次选择 'e' → current=['a','d','e'],错误! +``` + +**步骤3:处理边界情况** + +```python +if digits == "": + return [] +``` + +### 关键细节说明 + +**细节1:为什么用列表而不是字符串拼接?** + +```python +# 方法1:字符串拼接(简单但效率低) +def backtrack(index, current_str): + if index == len(digits): + result.append(current_str) + return + for letter in phoneMap[digits[index]]: + backtrack(index + 1, current_str + letter) + +# 方法2:列表拼接(高效) +def backtrack(index, current_list): + if index == len(digits): + result.append("".join(current_list)) + return + for letter in phoneMap[digits[index]]: + current_list.append(letter) + backtrack(index + 1, current_list) + current_list.pop() +``` + +**对比:** +- 字符串拼接:每次创建新字符串,O(n) 时间 +- 列表操作:append/pop 是 O(1),只在最后 join 一次 + +**细节2:为什么映射用 byte 而不是 string?** + +```go +// Go 中 byte 更高效 +phoneMap := map[byte]string{ + '2': "abc", // digits[i] 是 byte 类型 + '3': "def", + // ... +} +``` + +**细节3:如何处理空字符串输入?** + +```python +# 边界情况 +if digits == "": + return [] # 返回空数组,而不是 [""] +``` + +### 边界条件分析 + +**边界1:空字符串** +``` +输入:digits = "" +输出:[] +原因:没有数字,无法生成组合 +``` + +**边界2:单个数字** +``` +输入:digits = "2" +输出:["a","b","c"] +过程:只需遍历 '2' 对应的字母 +``` + +**边界3:包含 7 或 9(4个字母)** +``` +输入:digits = "79" +输出:16 个组合(4×4) +注意:7和9对应4个字母,其他对应3个 +``` + +### 复杂度分析(详细版) + +**时间复杂度:** +``` +- 设 m 是对应 3 个字母的数字个数(2,3,4,5,6,8) +- 设 n 是对应 4 个字母的数字个数(7,9) +- 总组合数:3^m × 4^n +- 每个组合的构建:O(len(digits)) +- **总时间复杂度:O(len(digits) × 3^m × 4^n)** + +特殊情况: +- 最好:全是 2-6,8 → O(3^n) +- 最坏:全是 7,9 → O(4^n) +- 平均:O(3.5^n) +``` + +**空间复杂度:** +``` +- 递归栈深度:O(len(digits)) +- 存储结果:O(3^m × 4^n) +- **空间复杂度:O(len(digits))**(不计结果存储) + ### 方法二:队列迭代法 **核心思想:**使用队列逐层构建所有可能的组合。每次处理一个数字,将队列中所有组合与该数字对应的所有字母组合。 @@ -190,16 +406,113 @@ func letterCombinationsIterative(digits string) []string { } ``` -- **时间复杂度:** O(3^m × 4^n) - - 其中 m 是对应 3 个字母的数字个数(2, 3, 4, 5, 6, 8) - - n 是对应 4 个字母的数字个数(7, 9) - - 最坏情况:所有数字都是 7 或 9,时间复杂度为 O(4^n) - - 最好情况:所有数字都是 2 或 3,时间复杂度为 O(3^n) +## 执行过程演示 -- **空间复杂度:** O(m + n) - - 其中 m 是输入数字的长度(递归栈深度) - - n 是所有可能组合的总数 - - 需要存储结果数组,空间复杂度为 O(3^m × 4^n) +以 `digits = "23"` 为例: + +``` +初始状态:result=[], current=[], index=0 + +处理第1个数字 '2' (index=0): + letters = "abc" + + 选择 'a': + current=['a'], index=1 + └─ 处理第2个数字 '3' + letters = "def" + + 选择 'd': current=['a','d'], index=2 → 加入结果 ["ad"] + 选择 'e': current=['a','e'], index=2 → 加入结果 ["ad","ae"] + 选择 'f': current=['a','f'], index=2 → 加入结果 ["ad","ae","af"] + + 选择 'b': + current=['b'], index=1 + └─ 处理第2个数字 '3' + 选择 'd': current=['b','d'], index=2 → ["ad","ae","af","bd"] + 选择 'e': current=['b','e'], index=2 → ["ad","ae","af","bd","be"] + 选择 'f': current=['b','f'], index=2 → ["ad","ae","af","bd","be","bf"] + + 选择 'c': + current=['c'], index=1 + └─ 处理第2个数字 '3' + 选择 'd': current=['c','d'], index=2 → ["ad","ae","af","bd","be","bf","cd"] + 选择 'e': current=['c','e'], index=2 → ["ad","ae","af","bd","be","bf","cd","ce"] + 选择 'f': current=['c','f'], index=2 → ["ad","ae","af","bd","be","bf","cd","ce","cf"] + +最终结果:["ad","ae","af","bd","be","bf","cd","ce","cf"] +``` + +## 常见错误 + +### 错误1:忘记处理空字符串 + +❌ **错误写法:** +```go +func letterCombinations(digits string) []string { + result := []string{} + // 直接开始回溯,没有检查空字符串 + backtrack(0, digits, &result) + return result +} +``` + +✅ **正确写法:** +```go +func letterCombinations(digits string) []string { + if digits == "" { + return []string{} // 返回空数组 + } + result := []string{} + backtrack(0, digits, &result) + return result +} +``` + +**原因:**空字符串应该返回空数组,而不是开始回溯。 + +### 错误2:撤销选择时索引错误 + +❌ **错误写法:** +```go +for i := 0; i < len(letters); i++ { + current = append(current, letters[i]) + backtrack(index + 1, digits, result) + current = current[:len(current)] // 错误!没有真正删除 +} +``` + +✅ **正确写法:** +```go +for i := 0; i < len(letters); i++ { + current = append(current, letters[i]) + backtrack(index + 1, digits, result) + current = current[:len(current)-1] // 正确:删除最后一个元素 +} +``` + +**原因:**`current[:len(current)]` 不会删除元素,`current[:len(current)-1]` 才会。 + +### 错误3:没有复制 current 就加入结果 + +❌ **错误写法:** +```go +if index == len(digits) { + result = append(result, string(current)) // 如果 current 是共享的 + return +} +``` + +✅ **正确写法:** +```go +if index == len(digits) { + temp := make([]byte, len(current)) + copy(temp, current) + result = append(result, string(temp)) + return +} +``` + +**原因:**如果 current 是共享的切片,后续修改会影响已加入结果的数据。 ### 队列迭代法 diff --git a/16-LeetCode Hot 100/盛最多水的容器.md b/16-LeetCode Hot 100/盛最多水的容器.md index 4aa4f56..b74d229 100644 --- a/16-LeetCode Hot 100/盛最多水的容器.md +++ b/16-LeetCode Hot 100/盛最多水的容器.md @@ -31,6 +31,95 @@ - `2 <= n <= 10^5` - `0 <= height[i] <= 10^4` +## 思路推导 + +### 暴力解法分析 + +**最直观的思路**:枚举所有可能的线对,计算每对线构成的容器容量。 + +```python +def maxArea(height): + max_water = 0 + n = len(height) + + for i in range(n): + for j in range(i+1, n): + # 容量 = 宽度 × 高度 + width = j - i + h = min(height[i], height[j]) + max_water = max(max_water, width * h) + + return max_water +``` + +**时间复杂度**:O(n²) +- 外层循环:O(n) +- 内层循环:O(n) +- 总计:O(n) × O(n) = O(n²) + +**空间复杂度**:O(1) + +**问题分析**: +1. 效率低:n=10⁵ 时,n² 不可接受 +2. 重复计算:很多组合明显不可能最优 +3. 无法利用单调性优化 + +### 优化思考 - 第一步:双指针策略 + +**观察**:容器的容量由 `min(height[left], height[right]) × (right - left)` 决定 + +**关键问题**:如何移动指针才能找到更大的容量? + +**直觉思考**: +- 如果移动较高的指针,宽度减小,高度只能保持不变或减小 +- 如果移动较短的指针,虽然宽度减小,但可能会找到更高的线 + +**为什么这样思考?** +- 容量受限于较短的线 +- 移动较短的线才有可能增加容量 +- 移动较高的线不可能增加容量 + +**优化后的思路**: +```python +left, right = 0, len(height) - 1 +max_water = 0 + +while left < right: + width = right - left + h = min(height[left], height[right]) + max_water = max(max_water, width * h) + + # 移动较短的指针 + if height[left] < height[right]: + left += 1 + else: + right -= 1 +``` + +**时间复杂度**:O(n) +- 每次移动一个指针 +- 最多移动 n 次 +- 总计:O(n) + +### 优化思考 - 第二步:数学证明 + +**问题**:双指针法一定能找到最优解吗? + +**证明**: + +假设当前指针在 `left` 和 `right`,且 `height[left] < height[right]`。 + +**当前容量**:`S = height[left] × (right - left)` + +**如果移动 right 指针**: +- 新容量:`S' = min(height[left], height[right-1]) × (right - 1 - left)` +- 由于 `height[left] < height[right]`,且 `right-1 < right` +- 所以 `S' <= height[left] × (right - 1 - left) < height[left] × (right - left) = S` + +**结论**:移动较高的指针不会得到更大的容量。 + +**因此**:每次移动较短的指针,可以保证不遗漏最优解。 + ## 解题思路 ### 方法一:双指针法(最优解) diff --git a/16-LeetCode Hot 100/翻转二叉树.md b/16-LeetCode Hot 100/翻转二叉树.md index 0526efc..8bd5bfc 100644 --- a/16-LeetCode Hot 100/翻转二叉树.md +++ b/16-LeetCode Hot 100/翻转二叉树.md @@ -1,24 +1,604 @@ # 翻转二叉树 (Invert Binary Tree) +LeetCode 226. 简单 + ## 题目描述 -给你一棵二叉树的根节点 root,翻转这棵二叉树,并返回其根节点。 +给你一棵二叉树的根节点 `root`,翻转这棵二叉树,并返回其根节点。 + +**示例 1:** +``` +输入:root = [4,2,7,1,3,6,9] +输出:[4,7,2,9,6,3,1] +``` + +**示例 2:** +``` +输入:root = [2,1,3] +输出:[2,3,1] +``` + +**示例 3:** +``` +输入:root = [] +输出:[] +``` + +## 思路推导 + +### 什么是翻转二叉树? + +**翻转定义**: 交换每个节点的左右子节点 + +``` +翻转前: 翻转后: + 4 4 + / \ / \ + 2 7 7 2 + / \ / \ / \ / \ + 1 3 6 9 9 6 3 1 +``` + +### 暴力解法分析 + +**思路**: 从根节点开始,递归交换每个节点的左右子节点 + +**观察翻转的性质**: +1. 翻转整棵树 = 翻转左子树 + 翻转右子树 + 交换左右子节点 +2. 这是一个天然的递归问题 + +**递归思路**: +``` +invertTree(root): + 1. 翻转左子树 + 2. 翻转右子树 + 3. 交换左右子节点 +``` + +**图解**: +``` +原始树: + 4 + / \ + 2 7 + / \ / \ + 1 3 6 9 + +步骤1: 翻转子节点(2和7) +├─ 翻转2的子树 +│ ├─ 翻转1 → 1(叶子节点,不变) +│ └─ 翻转3 → 3(叶子节点,不变) +│ └─ 交换1和3 → 2的子树变为 [3, 1] +└─ 翻转7的子树 + ├─ 翻转6 → 6(叶子节点,不变) + └─ 翻转9 → 9(叶子节点,不变) + └─ 交换6和9 → 7的子树变为 [9, 6] + +步骤2: 交换2和7 +最终结果: + 4 + / \ + 7 2 + / \ / \ + 9 6 3 1 +``` + +**时间复杂度**: O(n) - 每个节点访问一次 +**空间复杂度**: O(h) - h为树高,递归栈空间 + +### 为什么这样思考? + +**核心思想**: +1. **分治**: 大问题分解为小问题(翻转整棵树 → 翻转子树) +2. **递归定义**: 翻转操作可以递归应用到子树上 +3. **原地操作**: 直接交换左右指针,不需要额外空间 + +**为什么先翻转再交换?** +- 也可以先交换再翻转 +- 两种顺序都可以,结果相同 +- 但先翻转再交换更直观 ## 解题思路 -### 递归 / 迭代 +### 核心思想 +从根节点开始,递归翻转左右子树,然后交换左右子节点。 -## Go 代码(递归) +### 详细算法流程 + +**步骤1: 处理基准情况** +```go +if root == nil { + return nil // 空树直接返回 +} +``` + +**关键点**: 递归的终止条件 + +**步骤2: 递归翻转左右子树** +```go +left := invertTree(root.Left) // 翻转左子树 +right := invertTree(root.Right) // 翻转右子树 +``` + +**关键点**: 先递归处理子节点,再处理当前节点 + +**步骤3: 交换左右子节点** +```go +root.Left = right +root.Right = left +``` + +**简化写法**: +```go +root.Left, root.Right = root.Right, root.Left +``` + +**步骤4: 返回当前节点** +```go +return root +``` + +**图解**: +``` +翻转过程: + + 4 4 4 + / \ / \ / \ + 2 7 → 2 7 → 7 2 + / \ / \ / \ / \ / \ / \ +1 3 6 9 3 1 9 6 9 6 3 1 + + 原始 翻转子节点 交换子节点 +``` + +### 关键细节说明 + +**细节1: 为什么可以一行代码完成?** + +```go +root.Left, root.Right = invertTree(root.Right), invertTree(root.Left) +``` + +**原因**: Go支持多值赋值,右侧的表达式先求值,再赋值给左侧 + +**执行顺序**: +1. 计算 `invertTree(root.Right)`,得到翻转后的右子树 +2. 计算 `invertTree(root.Left)`,得到翻转后的左子树 +3. 同时赋值:`root.Left = 翻转后的右子树`,`root.Right = 翻转后的左子树` + +**细节2: 为什么不需要返回值?** + +实际上需要返回值! +```go +func invertTree(root *TreeNode) *TreeNode { + if root == nil { + return nil // 必须返回 + } + // ... + return root // 必须返回 +} +``` + +**原因**: +- 需要返回翻转后的根节点 +- 即使是原地操作,也需要返回值以保持接口一致 + +**细节3: 为什么是原地操作?** + +```go +// 直接修改指针,不需要创建新节点 +root.Left, root.Right = root.Right, root.Left +``` + +**原因**: +- 翻转操作只改变指针指向 +- 不需要创建新的节点 +- 空间复杂度只有递归栈 + +### 边界条件分析 + +**边界1: 空树** +``` +输入: root = nil +输出: nil +处理: 直接返回nil +``` + +**边界2: 只有根节点** +``` +输入: root = [1] +输出: [1] +处理: +- 翻转左子树 → nil +- 翻转右子树 → nil +- 交换nil和nil → 不变 +``` + +**边界3: 只有左子树** +``` +输入: + 1 + / +2 + +输出: + 1 + \ + 2 + +处理: +- 翻转2 → 2(叶子节点) +- 翻转nil → nil +- 交换 → 2变成右子树 +``` + +**边界4: 只有右子树** +``` +输入: +1 + \ + 2 + +输出: + 1 + / +2 + +处理: +- 翻转nil → nil +- 翻转2 → 2(叶子节点) +- 交换 → 2变成左子树 +``` + +**边界5: 完全二叉树** +``` +输入: + 1 + / \ + 2 3 + / \ / \ +4 5 6 7 + +输出: + 1 + / \ + 3 2 + / \ / \ +7 6 5 4 +``` + +### 复杂度分析(详细版) + +**时间复杂度**: +``` +- 每个节点访问一次: O(n) +- 每次访问常数操作: O(1) +- 总计: O(n) + +为什么是O(n)? +- 递归遍历所有节点 +- 每个节点只处理一次(交换左右) +- 没有重复访问 +``` + +**空间复杂度**: +``` +- 递归栈: O(h) - h为树高 + - 最坏情况(链状树): O(n) + - 最好情况(完全平衡树): O(log n) +- 总计: O(h) + +为什么是O(h)而不是O(n)? +- 递归深度 = 树的高度 +- 同一时刻栈中最多有h个栈帧 +- 不是所有节点同时在栈中 +``` + +### 执行过程演示 + +**输入**: +``` + 4 + / \ + 2 7 + / \ +1 3 +``` + +**执行过程**: +``` +调用 invertTree(4): +├─ 调用 invertTree(2): +│ ├─ 调用 invertTree(1): +│ │ ├─ 1.Left = nil, 1.Right = nil +│ │ └─ 返回 1 +│ ├─ 调用 invertTree(3): +│ │ ├─ 3.Left = nil, 3.Right = nil +│ │ └─ 返回 3 +│ ├─ 交换: 2.Left = 3, 2.Right = 1 +│ └─ 返回 2 +├─ 调用 invertTree(7): +│ ├─ 7.Left = nil, 7.Right = nil +│ └─ 返回 7 +├─ 交换: 4.Left = 7, 4.Right = 2 +└─ 返回 4 + +最终结果: + 4 + / \ + 7 2 + / \ + 3 1 +``` + +## 代码实现 + +### 方法一:递归(推荐) ```go func invertTree(root *TreeNode) *TreeNode { if root == nil { return nil } - + + // 递归翻转左右子树并交换 root.Left, root.Right = invertTree(root.Right), invertTree(root.Left) + return root } ``` -**复杂度:** O(n) 时间,O(h) 空间 +**复杂度**: O(n) 时间,O(h) 空间 + +### 方法二:递归(分步写法) + +```go +func invertTree(root *TreeNode) *TreeNode { + if root == nil { + return nil + } + + // 先翻转左右子树 + left := invertTree(root.Left) + right := invertTree(root.Right) + + // 再交换 + root.Left = right + root.Right = left + + return root +} +``` + +**复杂度**: O(n) 时间,O(h) 空间 + +### 方法三:迭代(栈) + +```go +func invertTree(root *TreeNode) *TreeNode { + if root == nil { + return nil + } + + stack := []*TreeNode{root} + + for len(stack) > 0 { + node := stack[len(stack)-1] + stack = stack[:len(stack)-1] + + // 交换左右子节点 + node.Left, node.Right = node.Right, node.Left + + // 将子节点入栈 + if node.Left != nil { + stack = append(stack, node.Left) + } + if node.Right != nil { + stack = append(stack, node.Right) + } + } + + return root +} +``` + +**复杂度**: O(n) 时间,O(n) 空间 + +### 方法四:迭代(队列 - BFS) + +```go +func invertTree(root *TreeNode) *TreeNode { + if root == nil { + return nil + } + + queue := []*TreeNode{root} + + for len(queue) > 0 { + node := queue[0] + queue = queue[1:] + + // 交换左右子节点 + node.Left, node.Right = node.Right, node.Left + + // 将子节点入队 + if node.Left != nil { + queue = append(queue, node.Left) + } + if node.Right != nil { + queue = append(queue, node.Right) + } + } + + return root +} +``` + +**复杂度**: O(n) 时间,O(n) 空间 + +## 常见错误 + +### 错误1: 忘记返回节点 + +❌ **错误写法**: +```go +func invertTree(root *TreeNode) *TreeNode { + if root == nil { + return nil + } + root.Left, root.Right = invertTree(root.Right), invertTree(root.Left) + // 忘记返回 root +} +``` + +✅ **正确写法**: +```go +func invertTree(root *TreeNode) *TreeNode { + if root == nil { + return nil + } + root.Left, root.Right = invertTree(root.Right), invertTree(root.Left) + return root // 必须返回 +} +``` + +**原因**: 函数签名要求返回 `*TreeNode` + +### 错误2: 交换后忘记处理子节点 + +❌ **错误写法**: +```go +func invertTree(root *TreeNode) *TreeNode { + if root == nil { + return nil + } + root.Left, root.Right = root.Right, root.Left + return root // 错误:只交换了当前节点 +} +``` + +✅ **正确写法**: +```go +func invertTree(root *TreeNode) *TreeNode { + if root == nil { + return nil + } + root.Left, root.Right = invertTree(root.Right), invertTree(root.Left) + return root // 递归处理子节点 +} +``` + +**原因**: 需要递归翻转所有子节点,不只是当前节点 + +### 错误3: 多值赋值理解错误 + +❌ **错误理解**: +```go +// 错误理解:先赋值 root.Left,再赋值 root.Right +root.Left, root.Right = root.Right, root.Left +``` + +**正确理解**: +```go +// Go的多值赋值是同时的 +// 右侧先求值,再同时赋值给左侧 +temp1, temp2 := root.Right, root.Left // 先求值 +root.Left = temp1 // 再赋值 +root.Right = temp2 +``` + +## 变体问题 + +### 变体1: 判断是否是翻转二叉树 + +判断两棵树是否互为翻转: + +```go +func isFlipTree(a, b *TreeNode) bool { + if a == nil && b == nil { + return true + } + if a == nil || b == nil { + return false + } + if a.Val != b.Val { + return false + } + // a的左等于b的右,a的右等于b的左 + return isFlipTree(a.Left, b.Right) && + isFlipTree(a.Right, b.Left) +} +``` + +### 变体2: 翻转N叉树 + +```go +type Node struct { + Val int + Children []*Node +} + +func invertNTree(root *Node) *Node { + if root == nil { + return nil + } + + // 翻转子节点列表 + for i, j := 0, len(root.Children)-1; i < j; i, j = i+1, j-1 { + root.Children[i], root.Children[j] = root.Children[j], root.Children[i] + } + + // 递归翻转每个子节点 + for _, child := range root.Children { + invertNTree(child) + } + + return root +} +``` + +### 变体3: 镜像对称二叉树 + +实际上就是翻转二叉树的应用: + +```go +func isSymmetric(root *TreeNode) bool { + if root == nil { + return true + } + return isMirror(root.Left, root.Right) +} + +func isMirror(left, right *TreeNode) bool { + if left == nil && right == nil { + return true + } + if left == nil || right == nil { + return false + } + if left.Val != right.Val { + return false + } + return isMirror(left.Left, invertTree(right.Right)) && + isMirror(left.Right, invertTree(right.Left)) +} +``` + +## 总结 + +**核心要点**: +1. **递归定义**: 翻转整棵树 = 翻转子树 + 交换左右 +2. **原地操作**: 直接交换指针,不需要额外空间 +3. **多值赋值**: Go的多值赋值右侧先求值,再同时赋值 +4. **必须返回**: 即使是原地操作,也要返回根节点 + +**易错点**: +- 忘记返回根节点 +- 只交换当前节点,忘记递归处理子节点 +- 多值赋值的理解错误 + +**方法选择**: +- 递归法(推荐):代码简洁,逻辑清晰 +- 迭代法:避免递归栈溢出 + +**有趣的事实**: 这道题有个著名的故事——Max Howell(Homebrew作者)说Google面试官因为这道题拒绝了他,但这道题确实很简单!😄 diff --git a/16-LeetCode Hot 100/路径总和.md b/16-LeetCode Hot 100/路径总和.md index 49a254d..e0f32b9 100644 --- a/16-LeetCode Hot 100/路径总和.md +++ b/16-LeetCode Hot 100/路径总和.md @@ -1,28 +1,727 @@ # 路径总和 (Path Sum) +LeetCode 112. 简单 + ## 题目描述 -给你二叉树的根节点 root 和一个表示目标和的整数 targetSum,判断该树中是否存在根节点到叶子节点的路径,这条路径上所有节点值相加等于目标和 targetSum。 +给你二叉树的根节点 `root` 和一个表示目标和的整数 `targetSum`,判断该树中是否存在根节点到叶子节点的路径,这条路径上所有节点值相加等于目标和 `targetSum`。 + +**示例 1:** +``` +输入:root = [5,4,8,11,null,13,4,7,2,null,null,null,1], targetSum = 22 +输出:true +解释:等于目标和的根节点到叶节点路径如上图所示。 +``` + +**示例 2:** +``` +输入:root = [1,2,3], targetSum = 5 +输出:false +解释:树中存在两条根节点到叶子节点的路径: +(1 --> 2): 和为 3 +(1 --> 3): 和为 4 +不存在 sum = 5 的根节点到叶子节点的路径。 +``` + +**示例 3:** +``` +输入:root = [], targetSum = 0 +输出:false +解释:由于树是空的,所以不存在根节点到叶子节点的路径。 +``` + +## 思路推导 + +### 理解路径总和 + +**路径定义**: 从根节点到叶子节点的节点序列 + +**叶子节点**: 没有子节点的节点(左右子节点都为空) + +``` +示例树: + 5 + / \ + 4 8 + / / \ + 11 13 4 + / \ \ +7 2 1 + +路径示例: +- 5 → 4 → 11 → 7: 和 = 27 +- 5 → 4 → 11 → 2: 和 = 22 ✓ +- 5 → 8 → 13: 和 = 26 +- 5 → 8 → 4 → 1: 和 = 18 +``` + +### 暴力解法分析 + +**思路**: 从根节点开始,递归计算到每个叶子节点的路径和 + +**核心观察**: +1. 当前节点的路径和 = 节点值 + 子节点的路径和 +2. 到达叶子节点时,检查路径和是否等于目标值 +3. 这是一个天然的递归问题 + +**递归思路**: +``` +hasPathSum(node, target): + 1. 空节点 → false(不存在路径) + 2. 叶子节点 → 检查 node.Val == target + 3. 非叶子节点 → 递归检查子节点 + hasPathSum(left, target - node.Val) || + hasPathSum(right, target - node.Val) +``` + +**为什么是 `target - node.Val`?** +- 到达当前节点时,已经累加了 `node.Val` +- 剩余需要的和 = `target - node.Val` +- 传递给子节点的目标是剩余值 + +**图解**: +``` +目标: 22 + + 5 target=22 + / \ + 4 8 target=17 (22-5) + / / \ + 11 13 4 target=13 (17-4) + / \ \ +7 2 1 target=2 (13-11) + +检查叶子节点: +- 7: 7 == 2? ✗ +- 2: 2 == 2? ✓ 找到路径! +``` + +**时间复杂度**: O(n) - 最坏情况遍历所有节点 +**空间复杂度**: O(h) - h为树高,递归栈空间 + +### 为什么这样思考? + +**核心思想**: +1. **降维**: 从"寻找路径"降为"检查单个节点" +2. **递归传递**: 每层递归传递剩余目标值 +3. **基准情况**: 叶子节点判断是否满足条件 + +**为什么不是累加后比较?** +```go +// 方案1: 累加后比较(需要额外参数) +func hasPathSum(node, target, currentSum int) bool { + if node == nil { + return false + } + currentSum += node.Val + if node.Left == nil && node.Right == nil { + return currentSum == target + } + return hasPathSum(node.Left, target, currentSum) || + hasPathSum(node.Right, target, currentSum) +} + +// 方案2: 递减目标值(更简洁)✓ +func hasPathSum(node, target int) bool { + if node == nil { + return false + } + target -= node.Val + if node.Left == nil && node.Right == nil { + return target == 0 + } + return hasPathSum(node.Left, target) || + hasPathSum(node.Right, target) +} +``` + +**原因**: 方案2更简洁,不需要额外参数 ## 解题思路 -### DFS +### 核心思想 +从根节点开始,递归检查每条路径,每次递归传递剩余目标值,到达叶子节点时判断是否满足条件。 -## Go 代码 +### 详细算法流程 + +**步骤1: 处理空节点** +```go +if root == nil { + return false // 空树没有路径 +} +``` + +**关键点**: 这是递归的基准情况之一 + +**步骤2: 判断是否为叶子节点** +```go +if root.Left == nil && root.Right == nil { + return root.Val == targetSum +} +``` + +**关键点**: 叶子节点是路径的终点 + +**步骤3: 递归检查左右子树** +```go +// 传递剩余目标值 +return hasPathSum(root.Left, targetSum - root.Val) || + hasPathSum(root.Right, targetSum - root.Val) +``` + +**关键点**: +- `targetSum - root.Val` 是剩余目标值 +- 使用 `||` 短路运算:找到一条路径即可返回true + +**图解**: +``` +示例: root = [1,2,3], targetSum = 3 + + 1 target=3 + / \ + 2 3 target=2 (3-1) + +检查: +├─ 路径1: 1 → 2 +│ └─ 叶子节点2: 2 == 2? ✓ 返回true +└─ 路径2: 1 → 3 + └─ 由于||短路,不再检查 + +最终返回: true +``` + +### 关键细节说明 + +**细节1: 为什么先判断空节点,再判断叶子节点?** + +```go +// ❌ 错误顺序 +if root.Left == nil && root.Right == nil { + return root.Val == targetSum +} +if root == nil { + return false +} + +// ✅ 正确顺序 +if root == nil { + return false +} +if root.Left == nil && root.Right == nil { + return root.Val == targetSum +} +``` + +**原因**: 空节点检查必须在前面,否则空节点会被误判 + +**细节2: 为什么叶子节点要同时检查左右为空?** + +```go +// 只有左右都为空才是叶子节点 +if root.Left == nil && root.Right == nil { + return root.Val == targetSum +} +``` + +**原因**: +- `root.Left == nil` 但 `root.Right != nil`: 不是叶子节点 +- `root.Right == nil` 但 `root.Left != nil`: 不是叶子节点 +- 只有两者都为空才是叶子节点 + +**细节3: 为什么使用 `||` 而不是 `&&`?** + +```go +return hasPathSum(root.Left, targetSum - root.Val) || + hasPathSum(root.Right, targetSum - root.Val) +``` + +**原因**: +- `||`: 只要有一条路径满足即可 +- `&&`: 需要所有路径都满足(错误理解) + +### 边界条件分析 + +**边界1: 空树** +``` +输入: root = nil, targetSum = 0 +输出: false +处理: 直接返回false(空树没有路径) +``` + +**边界2: 只有根节点且值等于目标** +``` +输入: root = [1], targetSum = 1 +输出: true +处理: +- 1是叶子节点 +- 1 == 1? ✓ 返回true +``` + +**边界3: 只有根节点但值不等于目标** +``` +输入: root = [1], targetSum = 2 +输出: false +处理: +- 1是叶子节点 +- 1 == 2? ✗ 返回false +``` + +**边界4: 目标值为0但树非空** +``` +输入: + 1 + / \ +2 3 +targetSum = 0 + +输出: false +处理: +- 路径1: 1 → 2, 和=3 ≠ 0 +- 路径2: 1 → 3, 和=4 ≠ 0 +- 返回false +``` + +**边界5: 负数节点值** +``` +输入: + -2 + / \ + 3 1 +targetSum = 1 + +输出: true +处理: +- 路径1: -2 → 3, 和=1 ✓ +- 路径2: -2 → 1, 和=-1 ✗ +- 返回true +``` + +**边界6: 单链树** +``` +输入: + 1 + / +2 +/ +3 +targetSum = 6 + +输出: true +处理: +- 路径: 1 → 2 → 3, 和=6 ✓ +- 返回true +``` + +### 复杂度分析(详细版) + +**时间复杂度**: +``` +- 最坏情况: O(n) - 遍历所有节点 +- 最好情况: O(1) - 根节点就是目标 +- 平均情况: O(n) + +为什么是O(n)? +- 每个节点最多访问一次 +- 找到路径后可能提前退出(||短路) +- 最坏情况需要遍历所有节点 +``` + +**空间复杂度**: +``` +- 递归栈: O(h) - h为树高 + - 最坏情况(链状树): O(n) + - 最好情况(完全平衡树): O(log n) +- 总计: O(h) +``` + +### 执行过程演示 + +**输入**: +``` + 5 + / \ + 4 8 + / / \ + 11 13 4 + / \ \ +7 2 1 +targetSum = 22 +``` + +**执行过程**: +``` +调用 hasPathSum(5, 22): +├─ target = 22 - 5 = 17 +├─ 5不是叶子节点 +├─ 调用 hasPathSum(4, 17): +│ ├─ target = 17 - 4 = 13 +│ ├─ 4不是叶子节点 +│ ├─ 调用 hasPathSum(11, 13): +│ │ ├─ target = 13 - 11 = 2 +│ │ ├─ 11不是叶子节点 +│ │ ├─ 调用 hasPathSum(7, 2): +│ │ │ ├─ target = 2 - 7 = -5 +│ │ │ ├─ 7是叶子节点 +│ │ │ └─ -5 == 7? ✗ 返回false +│ │ ├─ 调用 hasPathSum(2, 2): +│ │ │ ├─ target = 2 - 2 = 0 +│ │ │ ├─ 2是叶子节点 +│ │ │ └─ 0 == 2? ✓ 返回true +│ │ └─ 返回 true || false = true +│ └─ 返回 true(短路,不检查8) +└─ 返回 true + +最终返回: true +``` + +**找到的路径**: 5 → 4 → 11 → 2,和 = 22 + +## 代码实现 + +### 方法一:递归(推荐) ```go func hasPathSum(root *TreeNode, targetSum int) bool { if root == nil { return false } - + + // 叶子节点,检查是否满足条件 if root.Left == nil && root.Right == nil { return root.Val == targetSum } - - return hasPathSum(root.Left, targetSum-root.Val) || - hasPathSum(root.Right, targetSum-root.Val) + + // 递归检查左右子树 + return hasPathSum(root.Left, targetSum - root.Val) || + hasPathSum(root.Right, targetSum - root.Val) } ``` -**复杂度:** O(n) 时间,O(h) 空间 +**复杂度**: O(n) 时间,O(h) 空间 + +### 方法二:递归(提前返回优化) + +```go +func hasPathSum(root *TreeNode, targetSum int) bool { + if root == nil { + return false + } + + targetSum -= root.Val + + // 叶子节点 + if root.Left == nil && root.Right == nil { + return targetSum == 0 + } + + // 先检查左子树,找到路径直接返回 + if root.Left != nil { + if hasPathSum(root.Left, targetSum) { + return true + } + } + + // 再检查右子树 + if root.Right != nil { + if hasPathSum(root.Right, targetSum) { + return true + } + } + + return false +} +``` + +**复杂度**: O(n) 时间,O(h) 空间 + +### 方法三:BFS(队列) + +```go +func hasPathSum(root *TreeNode, targetSum int) bool { + if root == nil { + return false + } + + type NodeWithSum struct { + node *TreeNode + sum int + } + + queue := []NodeWithSum{{root, 0}} + + for len(queue) > 0 { + current := queue[0] + queue = queue[1:] + + current.sum += current.node.Val + + // 叶子节点 + if current.node.Left == nil && current.node.Right == nil { + if current.sum == targetSum { + return true + } + continue + } + + if current.node.Left != nil { + queue = append(queue, NodeWithSum{current.node.Left, current.sum}) + } + if current.node.Right != nil { + queue = append(queue, NodeWithSum{current.node.Right, current.sum}) + } + } + + return false +} +``` + +**复杂度**: O(n) 时间,O(n) 空间 + +### 方法四:DFS迭代(栈) + +```go +func hasPathSum(root *TreeNode, targetSum int) bool { + if root == nil { + return false + } + + type NodeWithSum struct { + node *TreeNode + sum int + } + + stack := []NodeWithSum{{root, 0}} + + for len(stack) > 0 { + current := stack[len(stack)-1] + stack = stack[:len(stack)-1] + + current.sum += current.node.Val + + // 叶子节点 + if current.node.Left == nil && current.node.Right == nil { + if current.sum == targetSum { + return true + } + continue + } + + if current.node.Right != nil { + stack = append(stack, NodeWithSum{current.node.Right, current.sum}) + } + if current.node.Left != nil { + stack = append(stack, NodeWithSum{current.node.Left, current.sum}) + } + } + + return false +} +``` + +**复杂度**: O(n) 时间,O(n) 空间 + +## 常见错误 + +### 错误1: 判断顺序错误 + +❌ **错误写法**: +```go +func hasPathSum(root *TreeNode, targetSum int) bool { + if root.Left == nil && root.Right == nil { + return root.Val == targetSum + } + if root == nil { + return false // 永远不会执行! + } + // ... +} +``` + +✅ **正确写法**: +```go +func hasPathSum(root *TreeNode, targetSum int) bool { + if root == nil { + return false + } + if root.Left == nil && root.Right == nil { + return root.Val == targetSum + } + // ... +} +``` + +**原因**: 空节点检查必须在前面 + +### 错误2: 忘记递减目标值 + +❌ **错误写法**: +```go +return hasPathSum(root.Left, targetSum) || // 忘记减去root.Val + hasPathSum(root.Right, targetSum) +``` + +✅ **正确写法**: +```go +return hasPathSum(root.Left, targetSum - root.Val) || + hasPathSum(root.Right, targetSum - root.Val) +``` + +**原因**: 需要传递剩余目标值 + +### 错误3: 叶子节点判断错误 + +❌ **错误写法**: +```go +if root.Left == nil { // 只检查左子树 + return root.Val == targetSum +} +``` + +✅ **正确写法**: +```go +if root.Left == nil && root.Right == nil { // 同时检查左右 + return root.Val == targetSum +} +``` + +**原因**: 叶子节点必须左右子节点都为空 + +### 错误4: 空树与目标为0混淆 + +❌ **错误理解**: +```go +// 错误:认为空树且目标为0时返回true +if root == nil && targetSum == 0 { + return true +} +``` + +✅ **正确理解**: +```go +// 空树没有路径,永远返回false +if root == nil { + return false +} +``` + +**原因**: 空树不存在根节点到叶子节点的路径 + +## 变体问题 + +### 变体1: 路径总和 II(返回所有路径) + +```go +func pathSum(root *TreeNode, targetSum int) [][]int { + result := [][]int{} + path := []int{} + dfs(root, targetSum, path, &result) + return result +} + +func dfs(node *TreeNode, target int, path []int, result *[][]int) { + if node == nil { + return + } + + target -= node.Val + path = append(path, node.Val) + + if node.Left == nil && node.Right == nil { + if target == 0 { + // 复制path,避免修改 + temp := make([]int, len(path)) + copy(temp, path) + *result = append(*result, temp) + } + return + } + + dfs(node.Left, target, path, result) + dfs(node.Right, target, path, result) +} +``` + +### 变体2: 路径总和 III(任意起点和终点) + +```go +func pathSumIII(root *TreeNode, targetSum int) int { + if root == nil { + return 0 + } + + // 以当前节点为起点的路径数 + count := countPath(root, targetSum) + + // 递归检查左右子树 + count += pathSumIII(root.Left, targetSum) + count += pathSumIII(root.Right, targetSum) + + return count +} + +func countPath(node *TreeNode, target int) int { + if node == nil { + return 0 + } + + count := 0 + if node.Val == target { + count++ + } + + count += countPath(node.Left, target - node.Val) + count += countPath(node.Right, target - node.Val) + + return count +} +``` + +### 变体3: 最大路径和(路径可以任意起止) + +```go +func maxPathSum(root *TreeNode) int { + maxSum := math.MinInt32 + maxGain(root, &maxSum) + return maxSum +} + +func maxGain(node *TreeNode, maxSum *int) int { + if node == nil { + return 0 + } + + // 递归计算左右子树的最大贡献值 + leftGain := max(0, maxGain(node.Left, maxSum)) + rightGain := max(0, maxGain(node.Right, maxSum)) + + // 当前节点的最大路径和 + currentSum := node.Val + leftGain + rightGain + *maxSum = max(*maxSum, currentSum) + + // 返回当前节点的最大贡献值 + return node.Val + max(leftGain, rightGain) +} +``` + +## 总结 + +**核心要点**: +1. **递归传递剩余目标值**: `targetSum - node.Val` +2. **叶子节点判断**: 左右子节点都为空 +3. **空节点处理**: 空树没有路径,返回false +4. **|| 短路运算**: 找到一条路径即可返回true + +**易错点**: +- 判断顺序错误(空节点必须在叶子节点前) +- 忘记递减目标值 +- 叶子节点判断不完整 +- 空树与目标为0混淆 + +**方法选择**: +- 递归法(推荐):代码简洁,逻辑清晰 +- BFS/DFS迭代:避免递归栈溢出 + +**关键规律**: +- 路径 = 根节点到叶子节点的节点序列 +- 目标值递减传递,叶子节点判断是否为0 +- 使用 || 找到任意一条满足条件的路径即可 diff --git a/16-LeetCode Hot 100/除自身以外数组的乘积.md b/16-LeetCode Hot 100/除自身以外数组的乘积.md index fc7bd23..1f8572e 100644 --- a/16-LeetCode Hot 100/除自身以外数组的乘积.md +++ b/16-LeetCode Hot 100/除自身以外数组的乘积.md @@ -1,37 +1,689 @@ # 除自身以外数组的乘积 (Product of Array Except Self) +LeetCode 238. Medium + ## 题目描述 -给你一个整数数组 nums,返回数组 answer,其中 answer[i] 等于 nums 中除 nums[i] 之外其余各元素的乘积。 +给你一个整数数组 `nums`,返回数组 `answer`,其中 `answer[i]` 等于 `nums` 中除 `nums[i]` 之外其余各元素的乘积。 + +**题目要求**:请不要使用除法,且在 O(n) 时间复杂度内完成此题。 + +**示例 1**: +``` +输入: nums = [1,2,3,4] +输出: [24,12,8,6] +解释: +- answer[0] = 2 * 3 * 4 = 24 +- answer[1] = 1 * 3 * 4 = 12 +- answer[2] = 1 * 2 * 4 = 8 +- answer[3] = 1 * 2 * 3 = 6 +``` + +**示例 2**: +``` +输入: nums = [-1,1,0,-3,3] +输出: [0,0,9,0,0] +``` + +**进阶**:你可以在 O(1) 的额外空间复杂度(输出数组不被视为额外空间)内完成此题目吗? + +## 思路推导 + +### 暴力解法分析 + +**最直观的思路**:对于每个位置,计算其他所有元素的乘积。 + +```python +def productExceptSelf(nums): + n = len(nums) + answer = [] + + for i in range(n): + product = 1 + for j in range(n): + if j != i: + product *= nums[j] + answer.append(product) + + return answer +``` + +**时间复杂度**:O(n²) +- 外层循环:O(n) +- 内层循环:O(n) +- 总计:O(n) × O(n) = O(n²) + +**空间复杂度**:O(1),不考虑输出数组 + +**问题分析**: +1. 效率低:n=10⁵ 时,n² 不可接受 +2. 重复计算:很多乘积被多次计算 +3. 无法利用已知信息 + +### 优化思考 - 第一步:使用除法 + +**观察**:如果可以使用除法 + +```python +total_product = 1 +for num in nums: + total_product *= num + +answer[i] = total_product / nums[i] +``` + +**问题**: +1. 题目禁止使用除法 +2. 如果 nums[i] = 0,除法会出错 +3. 如果多个 0,需要特殊处理 + +### 优化思考 - 第二步:分离左右乘积 + +**关键观察**:`answer[i] = 左侧乘积 × 右侧乘积` + +``` +nums = [1, 2, 3, 4] + +answer[0] = (空) × (2 × 3 × 4) = 左侧[0] × 右侧[0] +answer[1] = (1) × (3 × 4) = 左侧[1] × 右侧[1] +answer[2] = (1 × 2) × (4) = 左侧[2] × 右侧[2] +answer[3] = (1 × 2 × 3) × (空) = 左侧[3] × 右侧[3] +``` + +**为什么这样思考?** +- 每个位置的答案可以分解为两部分 +- 左侧部分:i 之前所有元素的乘积 +- 右侧部分:i 之后所有元素的乘积 +- 两部分独立计算,然后相乘 + +**优化后的思路**: +```python +# 预计算左侧乘积 +left = [1] * n +for i in range(1, n): + left[i] = left[i-1] * nums[i-1] + +# 预计算右侧乘积 +right = [1] * n +for i in range(n-2, -1, -1): + right[i] = right[i+1] * nums[i+1] + +# 合并结果 +answer = [left[i] * right[i] for i in range(n)] +``` + +**时间复杂度**:O(n) +- 计算左侧:O(n) +- 计算右侧:O(n) +- 合并结果:O(n) +- 总计:O(n) + +**空间复杂度**:O(n) +- 左侧数组:O(n) +- 右侧数组:O(n) +- 输出数组:O(n) +- 总计:O(n) + +### 优化思考 - 第三步:空间优化 + +**问题**:题目要求 O(1) 额外空间(输出数组除外) + +**关键优化**:用输出数组存储左侧乘积,用变量累积右侧乘积 + +```python +# 用 answer 存储左侧乘积 +answer = [1] * n +for i in range(1, n): + answer[i] = answer[i-1] * nums[i-1] + +# 用变量累积右侧乘积,直接更新 answer +right = 1 +for i in range(n-1, -1, -1): + answer[i] *= right + right *= nums[i] +``` + +**为什么这样思考?** +- 输出数组不被视为额外空间 +- 右侧乘积可以用一个变量累积 +- 从右向左遍历时,边计算边更新 + +**空间复杂度**:O(1) +- 只用了一个变量 `right` +- 输出数组 `answer` 不计入 ## 解题思路 -### 左右乘积列表 +### 核心思想 -分别计算每个位置的左侧乘积和右侧乘积,然后相乘。 +**分离左右乘积**:将 `answer[i]` 分解为左侧乘积和右侧乘积的乘积。 -## Go 代码 +**为什么这样思考?** + +1. **分解问题的思想**: + - 复杂问题 → 简单子问题 + - 每个位置的答案 = 左边所有数的乘积 × 右边所有数的乘积 + +2. **独立计算的优势**: + - 左侧乘积可以从左到右递推计算 + - 右侧乘积可以从右到左递推计算 + - 两部分互不干扰 + +3. **空间优化的技巧**: + - 用输出数组存储左侧乘积 + - 用变量累积右侧乘积 + - 边计算边更新,避免额外数组 + +### 详细算法流程 + +**步骤1:计算左侧乘积(存储在 answer 中)** + +```python +answer = [1] * n + +# answer[i] = nums[0] × ... × nums[i-1] +for i in range(1, n): + answer[i] = answer[i-1] * nums[i-1] +``` + +**关键点**: +- `answer[0] = 1`(0 左侧没有元素) +- `answer[i]` 依赖于 `answer[i-1]` +- 递推关系:`answer[i] = answer[i-1] × nums[i-1]` + +**示例**: +``` +nums = [1, 2, 3, 4] + +i=0: answer[0] = 1 +i=1: answer[1] = answer[0] × nums[0] = 1 × 1 = 1 +i=2: answer[2] = answer[1] × nums[1] = 1 × 2 = 2 +i=3: answer[3] = answer[2] × nums[2] = 2 × 3 = 6 + +answer = [1, 1, 2, 6] (左侧乘积) +``` + +**步骤2:计算右侧乘积并更新答案** + +```python +right = 1 + +for i in range(n-1, -1, -1): + # answer[i] *= 右侧乘积 + answer[i] *= right + # 更新右侧乘积 + right *= nums[i] +``` + +**关键点**: +- `right` 初始为 1(最右侧右侧没有元素) +- 从右向左遍历 +- 递推关系:`right = right × nums[i]` + +**示例(续)**: +``` +nums = [1, 2, 3, 4] +answer = [1, 1, 2, 6] (左侧乘积) + +i=3: answer[3] = 6 × 1 = 6, right = 1 × 4 = 4 +i=2: answer[2] = 2 × 4 = 8, right = 4 × 3 = 12 +i=1: answer[1] = 1 × 12 = 12, right = 12 × 2 = 24 +i=0: answer[0] = 1 × 24 = 24, right = 24 × 1 = 24 + +answer = [24, 12, 8, 6] (最终结果) +``` + +### 关键细节说明 + +**细节1:为什么 answer[0] = 1?** + +```python +answer = [1] * n # 所有位置初始化为 1 +``` + +**原因**: +- `answer[0]` 表示 nums[0] 左侧所有元素的乘积 +- nums[0] 左侧没有元素 +- 空乘积定义为 1(乘法的单位元) +- 类似:`answer[n-1]` 右侧也没有元素,right 初始为 1 + +**细节2:为什么是 `answer[i] = answer[i-1] × nums[i-1]`?** + +```python +# 错误写法 +answer[i] = answer[i-1] * nums[i] # 错误!会包含当前元素 + +# 正确写法 +answer[i] = answer[i-1] * nums[i-1] # 正确 +``` + +**原因**: +- `answer[i]` 是 nums[i] 左侧的乘积 +- 不应该包含 nums[i] 本身 +- 示例: + ``` + nums = [1, 2, 3, 4] + answer[2] = nums[0] × nums[1] = 1 × 2 = 2 + 不包含 nums[2] = 3 + ``` + +**细节3:为什么从右向左遍历?** + +```python +# 正确:从右向左 +for i in range(n-1, -1, -1): + answer[i] *= right + right *= nums[i] + +# 错误:从左向右 +for i in range(n): + answer[i] *= right # right 值不对 + right *= nums[i] +``` + +**原因**: +- `right` 是 nums[i] 右侧的乘积 +- 必须先计算右侧的值 +- 从右向左遍历才能保证 `right` 是正确的 + +**细节4:为什么顺序是先更新 answer,再更新 right?** + +```python +# 正确顺序 +answer[i] *= right # 先使用当前的 right +right *= nums[i] # 再更新 right + +# 错误顺序 +right *= nums[i] # 错误!先更新了 +answer[i] *= right # right 包含了 nums[i] 本身 +``` + +**原因**: +- `right` 应该是 nums[i] 右侧的乘积 +- 不应该包含 nums[i] 本身 +- 必须先使用当前的 `right`,再更新 + +### 边界条件分析 + +**边界1:数组长度为 1** + +``` +输入:nums = [5] +输出:[1] +解释: + answer[0] = 1(左侧为空) + right = 1(右侧为空) + answer[0] = 1 × 1 = 1 +``` + +**边界2:包含 0** + +``` +输入:nums = [1, 0, 3, 4] +过程: + answer = [1, 1, 0, 0] (左侧乘积) + + i=3: answer[3] = 0 × 1 = 0, right = 4 + i=2: answer[2] = 0 × 4 = 0, right = 12 + i=1: answer[1] = 1 × 12 = 12, right = 0 + i=0: answer[0] = 1 × 0 = 0, right = 1 + +输出:[0, 12, 0, 0] +验证: + answer[0] = 0 × 3 × 4 = 0 ✓ + answer[1] = 1 × 3 × 4 = 12 ✓ + answer[2] = 1 × 0 × 4 = 0 ✓ + answer[3] = 1 × 0 × 3 = 0 ✓ +``` + +**边界3:包含多个 0** + +``` +输入:nums = [0, 1, 0, 3] +过程: + answer = [1, 0, 0, 0] (左侧乘积) + + i=3: answer[3] = 0 × 1 = 0, right = 3 + i=2: answer[2] = 0 × 3 = 0, right = 0 + i=1: answer[1] = 0 × 0 = 0, right = 0 + i=0: answer[0] = 1 × 0 = 0, right = 0 + +输出:[0, 0, 0, 0] +验证: + 任意位置都会乘到至少一个 0 +``` + +**边界4:负数** + +``` +输入:nums = [-1, -2, -3, -4] +过程: + answer = [1, -1, 2, -6] (左侧乘积) + + i=3: answer[3] = -6 × 1 = -6, right = -4 + i=2: answer[2] = 2 × (-4) = -8, right = 12 + i=1: answer[1] = -1 × 12 = -12, right = -24 + i=0: answer[0] = 1 × (-24) = -24, right = 24 + +输出:[-24, -12, -8, -6] +验证: + answer[0] = (-2) × (-3) × (-4) = -24 ✓ + answer[1] = (-1) × (-3) × (-4) = -12 ✓ + answer[2] = (-1) × (-2) × (-4) = -8 ✓ + answer[3] = (-1) × (-2) × (-3) = -6 ✓ +``` + +### 复杂度分析(详细版) + +**时间复杂度**: +``` +- 计算左侧乘积:O(n),遍历一次 +- 计算右侧乘积:O(n),遍历一次 +- 总计:O(n) + O(n) = O(n) + +为什么是 O(n)? +- 两次线性扫描 +- 每个元素只访问一次 +- 常数操作(乘法和赋值) +``` + +**空间复杂度**: +``` +- 输出数组:O(n),不计入额外空间 +- 右侧变量:O(1) +- 循环变量:O(1) +- 总计:O(1)(满足题目要求) +``` + +--- + +## 图解过程 + +``` +nums = [1, 2, 3, 4] + +步骤1:计算左侧乘积 +answer[0] = 1 +answer[1] = answer[0] × nums[0] = 1 × 1 = 1 +answer[2] = answer[1] × nums[1] = 1 × 2 = 2 +answer[3] = answer[2] × nums[2] = 2 × 3 = 6 + +answer = [1, 1, 2, 6] + +图示: +nums: [1, 2, 3, 4] + ↓ +answer: [1, 1, 2, 6] + (×1)(×1)(×2)(×6) 左侧累积 + +步骤2:计算右侧乘积并更新 +right = 1 + +i=3: answer[3] = 6 × 1 = 6, right = 1 × 4 = 4 +i=2: answer[2] = 2 × 4 = 8, right = 4 × 3 = 12 +i=1: answer[1] = 1 × 12 = 12, right = 12 × 2 = 24 +i=0: answer[0] = 1 × 24 = 24, right = 24 × 1 = 24 + +answer = [24, 12, 8, 6] + +图示: +nums: [1, 2, 3, 4] + ↓ +answer: [24, 12, 8, 6] + (×24)(×12)(×8)(×6) 右侧累积 + +最终结果: +answer[0] = 1 × (2 × 3 × 4) = 24 +answer[1] = (1) × (3 × 4) = 12 +answer[2] = (1 × 2) × (4) = 8 +answer[3] = (1 × 2 × 3) × 1 = 6 +``` + +--- + +## 代码实现 ```go func productExceptSelf(nums []int) []int { n := len(nums) answer := make([]int, n) - - // 左侧乘积 + + // 步骤1:计算左侧乘积 answer[0] = 1 for i := 1; i < n; i++ { answer[i] = answer[i-1] * nums[i-1] } - - // 右侧乘积并更新 + + // 步骤2:计算右侧乘积并更新 right := 1 for i := n - 1; i >= 0; i-- { answer[i] = answer[i] * right right *= nums[i] } - + return answer } ``` -**复杂度:** O(n) 时间,O(1) 额外空间(不包括输出数组) +**关键点**: +1. 用 `answer` 存储左侧乘积 +2. 用 `right` 变量累积右侧乘积 +3. 从右向左遍历,边计算边更新 + +--- + +## 执行过程演示 + +**输入**:nums = [1, 2, 3, 4] + +``` +初始化:answer = [0, 0, 0, 0], right = 1 + +步骤1:计算左侧乘积 + +i=0: answer[0] = 1 +i=1: answer[1] = answer[0] × nums[0] = 1 × 1 = 1 +i=2: answer[2] = answer[1] × nums[1] = 1 × 2 = 2 +i=3: answer[3] = answer[2] × nums[2] = 2 × 3 = 6 + +answer = [1, 1, 2, 6] + +步骤2:计算右侧乘积并更新 + +i=3: answer[3] = 6 × 1 = 6, right = 1 × 4 = 4 +i=2: answer[2] = 2 × 4 = 8, right = 4 × 3 = 12 +i=1: answer[1] = 1 × 12 = 12, right = 12 × 2 = 24 +i=0: answer[0] = 1 × 24 = 24, right = 24 × 1 = 24 + +answer = [24, 12, 8, 6] + +验证: +answer[0] = 2 × 3 × 4 = 24 ✓ +answer[1] = 1 × 3 × 4 = 12 ✓ +answer[2] = 1 × 2 × 4 = 8 ✓ +answer[3] = 1 × 2 × 3 = 6 ✓ +``` + +--- + +## 常见错误 + +### 错误1:使用除法 + +❌ **错误代码**: +```go +func productExceptSelf(nums []int) []int { + total := 1 + for _, num := range nums { + total *= num + } + + answer := make([]int, len(nums)) + for i, num := range nums { + answer[i] = total / num // 错误!题目禁止除法 + } + return answer +} +``` + +**问题**: +1. 题目明确禁止使用除法 +2. 如果 `num = 0`,除法会出错 +3. 无法处理多个 0 的情况 + +--- + +### 错误2:索引错误 + +❌ **错误代码**: +```go +// 错误:包含了当前元素 +answer[i] = answer[i-1] * nums[i] // 错误! + +// 正确:不包含当前元素 +answer[i] = answer[i-1] * nums[i-1] // 正确 +``` + +**原因**: +- `answer[i]` 是 nums[i] 左侧的乘积 +- 不应该包含 nums[i] 本身 + +--- + +### 错误3:更新顺序错误 + +❌ **错误代码**: +```go +// 错误:先更新了 right +right *= nums[i] +answer[i] *= right // right 包含了 nums[i] +``` + +✅ **正确代码**: +```go +// 正确:先使用 right,再更新 +answer[i] *= right +right *= nums[i] +``` + +**原因**: +- `right` 应该是 nums[i] 右侧的乘积 +- 不应该包含 nums[i] 本身 + +--- + +## 进阶问题 + +### Q1: 如果允许使用除法,如何处理 0? + +**思路**: +1. 统计 0 的个数 +2. 如果超过 1 个 0,全部为 0 +3. 如果正好 1 个 0,只有该位置为总乘积,其他为 0 +4. 如果没有 0,正常计算 + +```go +func productExceptSelfWithDivision(nums []int) []int { + n := len(nums) + answer := make([]int, n) + + // 统计 0 的个数和总乘积 + zeroCount := 0 + totalProduct := 1 + for _, num := range nums { + if num == 0 { + zeroCount++ + } else { + totalProduct *= num + } + } + + // 根据零的个数处理 + for i, num := range nums { + if zeroCount > 1 { + answer[i] = 0 + } else if zeroCount == 1 { + if num == 0 { + answer[i] = totalProduct + } else { + answer[i] = 0 + } + } else { + answer[i] = totalProduct / num + } + } + + return answer +} +``` + +--- + +### Q2: 如何处理大数溢出? + +**思路**:使用对数转换 + +```go +func productExceptSelfLog(nums []int) []int { + n := len(nums) + logSum := make([]float64, n) + + // 计算对数和 + for i, num := range nums { + logSum[i] = math.Log(float64(num)) + } + + answer := make([]int, n) + totalLog := 0.0 + for _, log := range logSum { + totalLog += log + } + + // 转换回整数 + for i, log := range logSum { + answer[i] = int(math.Exp(totalLog - log) + 0.5) + } + + return answer +} +``` + +**注意**: +- 对数转换会损失精度 +- 适用于浮点数场景 +- 整数场景需要其他方法 + +--- + +## P7 加分项 + +### 深度理解 +- **分离思想**:将复杂问题分解为简单的子问题 +- **空间优化**:利用输出数组,避免额外空间 +- **递推关系**:左右乘积都可以递推计算 + +### 实战扩展 +- **前缀和/后缀和**:类似思想,用加法代替乘法 +- **范围查询**:线段树、树状数组 +- **业务场景**:计算贡献度、权重分配 + +### 变形题目 +1. [152. 乘积最大子数组](https://leetcode.cn/problems/maximum-product-subarray/) +2. 前缀和问题 +3. 区间乘积查询 + +--- + +## 总结 + +**核心要点**: +1. **分离思想**:将 answer[i] 分解为左侧 × 右侧 +2. **递推计算**:左侧和右侧都可以递推计算 +3. **空间优化**:用输出数组存储左侧,变量累积右侧 + +**易错点**: +- 索引错误(是否包含当前元素) +- 更新顺序错误(先使用还是先更新) +- 边界条件(0 的处理) + +**最优解法**:分离左右乘积 + 空间优化,时间 O(n),空间 O(1)