Hot100 算法笔记
LeetCode 上 Hot100 算法笔记
LeetCode 算法笔记
通用思维套路总览
看到什么特征 → 用什么算法
| 题目特征 | 首选算法 | 典型题目 |
|---|---|---|
| 有序数组 + 查找 | 二分查找 | 33, 34, 35, 74, 153 |
| 求「连续子数组」最值 | 滑动窗口 / 前缀和 | 3, 53, 209, 239 |
| 两个有序结构合并 | 双指针 | 11, 88, 167 |
| O(n) 查找 / 去重 / 分组 | 哈希表 | 1, 49, 128, 560 |
| 链表找中点 / 环 / 倒数第 N | 快慢指针 | 19, 141, 142, 876 |
| 树的遍历 / 层序 | DFS 递归 / BFS 队列 | 94, 102, 104, 199 |
| 括号匹配 / 表达式求值 | 栈 | 20, 32, 394, 735 |
| 「下一个更大/更小」 | 单调栈 | 84, 739, 239 |
| 求所有方案 / 组合 / 排列 | 回溯 | 17, 22, 39, 46, 51, 78 |
| 最优解 + 子问题重叠 | 动态规划 | 53, 62, 70, 72, 198, 300 |
| 每步取局部最优 → 全局最优 | 贪心 | 11, 45, 55, 121, 135 |
| Top K / 中位数 | 堆 | 23, 215, 295, 347 |
| 二维网格搜索 / 连通分量 | BFS / DFS | 200, 207, 547, 994 |
| 字符串前缀匹配 | Trie | 208 |
| 区间合并 / 插入 | 排序 + 遍历 | 56, 763 |
DP 识别套路
| 关键词 | DP 类型 | 状态定义 |
|---|---|---|
| 最长 / 最短 / 最少 / 最多 | 优化型 DP | dp[i] = 前 i 个元素的最优值 |
| 能否 / 是否 | 可行性 DP | dp[i] = 前 i 个元素是否可行 |
| 方案数 | 计数型 DP | dp[i] = 前 i 个元素的方案数 |
| 背包 / 凑数 | 背包 DP | dp[j] = 容量 j 时的最优值 |
| 两个序列 | 双序列 DP | dp[i][j] = s1 前 i 与 s2 前 j 的结果 |
| 矩阵路径 | 矩阵 DP | dp[i][j] = 到达 (i,j) 的最优值 |
链表万能套路
| 场景 | 套路 |
|---|---|
| 反转 | 三指针(pre, cur, next) |
| 找中点 | 快慢指针(fast 走 2 步,slow 走 1 步) |
| 找倒数第 N | 快指针先走 N 步,再同步走 |
| 判环 | 快慢指针,相遇则有环 |
| 找环入口 | 相遇后,一个回 head,同步走到再相遇 |
| 交点 | 双指针走完自己走对方的,必在交点相遇 |
| 删节点 | dummy 哨兵 + pre 指针 |
一、数组与哈希表
0001. 两数之和 Easy
题意:数组中找两个数使其和为 target,返回下标。
核心思路:遍历时用哈希表记录已遍历的值和下标,对每个 nums[i] 查 target-nums[i] 是否在表中。
func twoSum(nums []int, target int) []int { mp := map[int]int{} for i, val := range nums { if index, exist := mp[target-val]; exist { return []int{index, i} // 找到配对 } mp[val] = i // 记录当前值 } return []int{}}- 核心破题点:用哈希表把「找配对」从 O(n) 降到 O(1),总复杂度 O(n)
- 避坑指南:先查再存,避免 nums[i] 自己和自己配对(如 target=6, nums=[3,…])
运行示例:nums = [2,7,11,15], target = 9
| 步骤 | i | nums[i] | 查找 9-nums[i] | 哈希表 | 操作 |
|---|---|---|---|---|---|
| 1 | 0 | 2 | 查 7 → 不存在 | {} | 存入 {2:0} |
| 2 | 1 | 7 | 查 2 → 存在(下标0) | {2:0} | 返回 [0,1] |
- ⚠️ 常见失败原因:先写入 map 再查找,导致
val == target-val时自己匹配自己(如nums=[3,2,4], target=6返回[0,0]而非[1,2])。必须先查再存
0013. 罗马数字转整数 Easy
题意:罗马数字转整数(IV=4, VI=6)。
核心思路:遍历时如果当前值 < 下一个值(如 IV 中的 I),就减去当前值;否则加上。
func romanToInt(s string) int { ans := 0 for i := range s { value := symbolValues[s[i]] if i < n-1 && value < symbolValues[s[i+1]] { ans -= value // 小的在前,减去(如 IV 的 I) } else { ans += value } } return ans}- 核心破题点:罗马数字中小的出现在大的前面表示减法(IV=5-1=4)
- 避坑指南:只需比较相邻两个字符的大小关系
0041. 缺失的第一个正数 Hard
题意:O(n) 时间 O(1) 空间找未排序数组中缺失的最小正整数。
核心思路:原地哈希——把值 v 放到下标 v-1 的位置。遍历后第一个 nums[i] != i+1 的位置就是答案。
for i := 0; i < n; i++ { for nums[i] >= 1 && nums[i] <= n && nums[i] != nums[nums[i]-1] { nums[nums[i]-1], nums[i] = nums[i], nums[nums[i]-1] // 交换到正确位置 }}// 找第一个不在位置的for i := 0; i < n; i++ { if nums[i] != i+1 { return i + 1 }}return n + 1- 核心破题点:利用数组本身当哈希表,值 v 对应下标 v-1
- 避坑指南:交换条件必须是
nums[i] != nums[nums[i]-1](防重复值死循环),而不是nums[i] != i+1
运行示例:nums = [3,4,-1,1],目标是把值 v 放到下标 v-1
| 步骤 | i | nums[i] | 操作 | 数组状态 |
|---|---|---|---|---|
| 1 | 0 | 3 | 3∈[1,4], 交换nums[0]↔nums[2] | [-1,4,3,1] |
| 2 | 0 | -1 | -1不在[1,4], 跳过 | [-1,4,3,1] |
| 3 | 1 | 4 | 4∈[1,4], 交换nums[1]↔nums[3] | [-1,1,3,4] |
| 4 | 1 | 1 | 1∈[1,4], 交换nums[1]↔nums[0] | [1,-1,3,4] |
| 5 | 1 | -1 | 跳过 | [1,-1,3,4] |
| 6 | 2 | 3 | 3已在位置2, 跳过 | [1,-1,3,4] |
| 7 | 3 | 4 | 4已在位置3, 跳过 | [1,-1,3,4] |
扫描结果:nums[1]=-1 ≠ 2,返回 2
graph TD
A["遍历每个位置 i"] --> B{"nums[i] 在 [1,n] 且
nums[i] ≠ nums[nums[i]-1]?"}
B -->|是| C["交换 nums[i] ↔ nums[nums[i]-1]"]
C --> B
B -->|否| D["i++"]
D --> E{"i < n?"}
E -->|是| B
E -->|否| F["扫描找第一个 nums[i] ≠ i+1"]
style C fill: #fff9c4, color: #1a1a1a
style F fill: #c8e6c9, color: #1a1a1a0049. 字母异位词分组 Medium
题意:把字母组成相同但顺序不同的单词分到一组。
核心思路:对每个字符串排序后作为哈希 key,相同 key 归为一组。
for _, str := range strs { arr := strings.Split(str, "") sort.Strings(arr) key := strings.Join(arr, "") // 排序后的字符串作 key m[key] = append(m[key], str)}- 核心破题点:排序后字母异位词变成相同字符串,天然适合做哈希 key
- 避坑指南:也可用字符计数数组([26]int 转字符串)做 key,O(k) 而非 O(k log k)
0128. 最长连续序列 Medium
题意:O(n) 找未排序数组中最长连续数字序列长度。
核心思路:全部放入哈希集合,只从「序列起点」(num-1 不在集合中)开始往后数。
mp := map[int]bool{}for _, v := range nums { mp[v] = true }for key := range mp { if !mp[key-1] { // 只从起点开始数 count := 1 for mp[key+1] { count++; key++ } res = max(res, count) }}- 核心破题点:只从序列起点(num-1 不存在)开始计数,保证每个数字最多被访问 2 次
- 避坑指南:如果从每个数字都开始数会 O(n²),必须跳过非起点
执行流程(nums = [100,4,200,1,3,2]):
| 检查数字 | num-1 在集合中? | 是否为起点 | 向后数 | 序列长度 |
|---|---|---|---|---|
| 100 | 99不在 → 是 | ✅ 起点 | 100→(101不在) | 1 |
| 4 | 3在 → 否 | ❌ 跳过 | - | - |
| 200 | 199不在 → 是 | ✅ 起点 | 200→(201不在) | 1 |
| 1 | 0不在 → 是 | ✅ 起点 | 1→2→3→4→(5不在) | 4 ✓max |
| 3 | 2在 → 否 | ❌ 跳过 | - | - |
| 2 | 1在 → 否 | ❌ 跳过 | - | - |
结果 = 4(序列 [1,2,3,4])。只从 3 个起点开始计数,其余 3 个跳过
0136. 只出现一次的数字 Easy
题意:O(n) 时间 O(1) 空间找只出现一次的数(其余出现两次)。
核心思路:全部异或,成对的抵消为 0,剩下就是答案。
single := 0for _, num := range nums { single ^= num }return single- 核心破题点:异或自反性
a ^ a = 0,a ^ 0 = a - 避坑指南:初始值必须为 0
0169. 多数元素 Easy
题意:O(n) 时间 O(1) 空间找出现次数 > n/2 的元素。
核心思路:Boyer-Moore 投票——候选人 + 计数器,遇到相同的 +1,不同的 -1,归零换人。
count := 0; maj := nums[0]for i := 1; i < n; i++ { if count == 0 { maj = nums[i] } // 换候选人 if nums[i] == maj { count++ } else { count-- }}- 核心破题点:多数元素过半,抵消后一定是最后活下来的
- 避坑指南:count 归零时先换人再判断
0238. 除了自身以外数组的乘积 Medium
题意:不能用除法,O(n) 求 answer[i] = 除 nums[i] 外所有元素的乘积。
核心思路:两次遍历——左到右存左累积积,右到左乘右累积积。
answer[0] = 1for i := 1; i < n; i++ { answer[i] = answer[i-1] * nums[i-1] } // 左累积temp := 1for i := n-1; i >= 0; i-- { answer[i] *= temp // 乘右累积 temp *= nums[i] // 更新右累积}- 核心破题点:answer[i] = 左侧所有乘积 × 右侧所有乘积,分两次遍历搞定
- 避坑指南:第二遍从右到左时 temp 初始为 1,先更新 answer 再更新 temp
0283. 移动零 Easy
题意:把数组中所有 0 移到末尾,保持非零元素相对顺序。
l := 0for r := 0; r < len(nums); r++ { if nums[r] != 0 { nums[l], nums[r] = nums[r], nums[l]; l++ }}- 核心破题点:慢指针标记非零元素应该放的位置
0560. 和为 K 的子数组 Medium
题意:统计和为 k 的连续子数组个数。
核心思路:前缀和 + 哈希表。遍历到位置 j 时,pre[j] - k 在表里出现过几次,就有几个以 j 结尾、和为 k 的子数组。
为什么用前缀和:连续子数组 nums[i..j] 的和 = pre[j] - pre[i-1]。要它等于 k,即 pre[i-1] = pre[j] - k。所以遍历到 j 时,只要查「前面有多少个前缀和等于 pre[j] - k」即可。
m := map[int]int{0: 1}; pre := 0; count := 0for _, num := range nums { pre += num count += m[pre-k] // 之前有多少个前缀和 = pre-k m[pre]++ // 记录当前前缀和出现次数}运行示例(nums = [1,1,1], k = 2):
| 步骤 | num | pre | 查 pre-k=pre-2 | count 累加 | 哈希表 m |
|---|---|---|---|---|---|
| 1 | 1 | 1 | 查 -1 → 0 | 0 | {0:1, 1:1} |
| 2 | 1 | 2 | 查 0 → 1 | 1 | {0:1, 1:1, 2:1} |
| 3 | 1 | 3 | 查 1 → 1 | 2 | {0:1, 1:1, 2:1, 3:1} |
第 2 步 pre=2,查到 1 个 pre=0(子数组 nums[0..1]=[1,1]);第 3 步 pre=3,查到 1 个 pre=1(子数组 nums[1..2]=[1,1])。共 2 个
- 核心破题点:前缀和之差 = k,即找之前有多少个前缀和等于
pre - k - 避坑指南:初始化
m[0]=1处理「从头开始的子数组」(当 pre 恰好 = k 时 pre-k=0,要能查到);数组含负数,不能用滑动窗口,必须用前缀和 + 哈希表
0724. 寻找数组的中心下标 Easy
题意:找下标使左侧和等于右侧和。
for _, num := range nums { right += num }for i, num := range nums { right -= num // 先减当前元素 if left == right { return i } left += num // 再加到左侧}- 核心破题点:用总和减去当前元素得到右侧和,无需额外数组
1207. 独一无二的出现次数 Easy
题意:判断每个数的出现次数是否互不相同。
mp := map[int]int{}for _, v := range arr { mp[v]++ } // 第一次:统计频次mp2 := map[int]bool{}for _, v := range mp { // 第二次:检查频次是否重复 if mp2[v] { return false } mp2[v] = true}1431. 拥有最多糖果的孩子 Easy
题意:给额外糖果后是否能达到最大值。
maxVal := candies[0]for _, v := range candies { if v > maxVal { maxVal = v } }for i, v := range candies { res[i] = v + extraCandies >= maxVal }1679. K 和数对的最大数目 Medium
题意:每步选和为 k 的两个数移出,求最大操作数。
cnt := map[int]int{}for _, x := range nums { if cnt[k-x] > 0 { cnt[k-x]--; ans++ } // 找到配对 else { cnt[x]++ }}1732. 找到最高海拔 Easy
题意:gain[i] 表示从海拔 i 到 i+1 的变化量,从海拔 0 出发,返回途中的最高海拔。
h := 0for _, g := range gain { h += g; ans = max(ans, h) }2215. 找出两数组的不同 Easy
题意:返回两个列表,分别是 nums1 中不在 nums2 的元素和 nums2 中不在 nums1 的元素(去重)。
核心思路:两个哈希集合,互相求差集。
func findDifference(nums1 []int, nums2 []int) [][]int { m1 := make(map[int]bool) for _, num := range nums1 { m1[num] = true } // nums1 → 集合 m2 := make(map[int]bool) for _, num := range nums2 { m2[num] = true } // nums2 → 集合 ans := make([][]int, 2) for k := range m1 { if !m2[k] { ans[0] = append(ans[0], k) } } // nums1 独有 for k := range m2 { if !m1[k] { ans[1] = append(ans[1], k) } } // nums2 独有 return ans}- 核心破题点:哈希集合 O(1) 查找,天然适合求差集
- 避坑指南:map 的 key 自动去重,无需额外处理
2352. 相等行列对 Medium
题意:n×n 矩阵中行和列元素完全相同的对数。
核心思路:行序列化为字符串存哈希表,列查询匹配次数。
func equalPairs(grid [][]int) int { mp := make(map[string]int) for i := 0; i < len(grid); i++ { // 每行序列化 s := "" for j := 0; j < len(grid[0]); j++ { s += strconv.Itoa(grid[i][j]) + " " } mp[s]++ } ans := 0 for j := 0; j < len(grid[0]); j++ { // 每列查询 s := "" for i := 0; i < len(grid); i++ { s += strconv.Itoa(grid[i][j]) + " " } ans += mp[s] // 加上匹配的行数 } return ans}- 核心破题点:行和列的比较转化为字符串匹配,哈希表 O(1) 查找
- 避坑指南:序列化时元素间加分隔符(如空格),防止 [1,23] 和 [12,3] 误匹配
3345. 最小可整除数位乘积 I Easy
题意:找 >= n 的最小整数,其各数位乘积能被 t 整除。
func smallestNumber(n int, t int) int { for { n1 := 1; num := n for num > 0 { n1 = num % 10 * n1 // 取每一位相乘 num /= 10 if n1 == 0 { break } // 含 0 则乘积为 0,一定能被整除 } if n1 % t == 0 { return n } n++ }}- 核心破题点:数位含 0 时乘积为 0,直接满足条件;数据范围小直接枚举
3731. 找出缺失的元素 Easy
题意:连续整数范围内缺失的数字,返回有序列表。
func findMissingElements(nums []int) []int { slices.Sort(nums) // 排序 ans := []int{} for i := 0; i < len(nums)-1; i++ { for j := nums[i]; j != nums[i+1]-1; j++ { // 填充缺失的间隙 ans = append(ans, j+1) } } return ans}运行示例:nums = [1,4,2,5]
| 步骤 | nums[i] | nums[i+1] | 缺失区间 | ans |
|---|---|---|---|---|
| 1 | 1 | 2 | 无缺失 | [] |
| 2 | 2 | 4 | 3 | [3] |
| 3 | 4 | 5 | 无缺失 | [3] |
- 核心破题点:排序后检查相邻元素差值 > 1 的间隙
二、双指针
0011. 盛最多水的容器 Medium
题意:两条竖线 + 底边构成容器,求最大容量。
核心思路:左右指针从两端向中间,矮的那侧移动(因为移动高的一侧不可能让面积变大)。
l, r := 0, len(height)-1for l < r { if height[l] < height[r] { res = max(res, (r-l)*height[l]); l++ } else { res = max(res, (r-l)*height[r]); r-- }}- 核心破题点:面积 = 底 × 高,底在缩小,只有让高变大才可能增加面积 → 移动矮的一侧
- 避坑指南:两边等高时移动哪个都行
graph LR
A[左右指针从两端开始] --> B{哪边矮?}
B -->|左边矮| C[计算面积, 左指针右移]
B -->|右边矮| D[计算面积, 右指针左移]
C --> E{l < r?}
D --> E
E -->|是| B
E -->|否| F[返回最大面积]0015. 三数之和 Medium
题意:找所有和为 0 的不重复三元组。
核心思路:排序后固定一个数,再用双指针找另外两个。去重:固定的数和前一个相同就跳过。
sort.Ints(nums)for l := 0; l < n; l++ { if nums[l] > 0 { break } // 最小值 > 0 不可能有解 if l > 0 && nums[l] == nums[l-1] { continue } // 去重 m, r := l+1, n-1 for m < r { sum := nums[l] + nums[m] + nums[r] if sum == 0 { res = append(res, []int{nums[l], nums[m], nums[r]}) m++; r-- for m < r && nums[m] == nums[m-1] { m++ } // 去重 for m < r && nums[r] == nums[r+1] { r-- } // 去重 } else if sum > 0 { r-- } else { m++ } }}- 核心破题点:排序 + 固定一个 + 双指针找另外两个,三重循环降为 O(n²)
- 避坑指南:三层去重(固定数、左指针、右指针都要跳过重复值)
执行流程(nums = [-1,0,1,2,-1,-4],排序后 = [-4,-1,-1,0,1,2]):
| 固定 l | nums[l] | m | r | sum | 动作 |
|---|---|---|---|---|---|
| 0 | -4 | 1 | 5 | -4+(-1)+2=-3<0 | m++ |
| 0 | -4 | 2 | 5 | -4+(-1)+2=-3<0 | m++ |
| 0 | -4 | 3 | 5 | -4+0+2=-2<0 | m++ |
| 0 | -4 | 4 | 5 | -4+1+2=-1<0 | m++,m≥r 退出 |
| 1 | -1 | 2 | 5 | -1+(-1)+2=0 ✅ | 找到[-1,-1,2],m++,r— |
| 1 | -1 | 3 | 4 | -1+0+1=0 ✅ | 找到[-1,0,1],m++,r— |
| 1 | -1 | 4 | 3 | m≥r 退出 | - |
| 2 | -1(重复) | 跳过 | l=2, nums[2]==nums[1] | ||
| 3 | 0 | 4 | 5 | 0+1+2=3>0 | r—,r<m 退出 |
结果 = [[-1,-1,2], [-1,0,1]]
- ⚠️ 常见失败原因:在
sum == 0分支中错误地执行了l++(外层循环变量)而非m++(内层左指针),导致跳过有效解
0027. 移除元素 Easy
题意:原地移除所有值为 val 的元素。
cur, lst := 0, len(nums)-1for cur <= lst { if nums[lst] == val { lst-- } else if nums[cur] == val { nums[cur] = nums[lst]; lst-- } else { cur++ }}return lst + 1- 核心破题点:双端指针,左边找 val 右边找非 val,交换
- 为什么交换后
cur不前进:交换是nums[cur] = nums[lst],从右边换过来的元素可能也是 val(比如nums=[3,2,2,3], val=3)。如果此时cur++,这个换过来的 val 就被留在了前面,没被移除。所以交换分支里只把lst--,让cur停在原地等下一轮重新检查新换来的元素;只有确认nums[cur] != val的「else 分支」才cur++ - ⚠️ 常见失败原因:当
lst <= 0(数组长度为1)时直接返回 0,但若nums[0] != val应返回 1;交换后错误地cur++,导致换过来的 val 残留
0042. 接雨水 Hard
题意:柱子高度数组,计算能接多少雨水。
核心思路:双指针 + 左右最大值。较矮的一侧决定了该位置的水位。
关键直觉(为什么矮侧决定水位):位置 i 能接的雨水 = min(左边最高墙, 右边最高墙) - height[i](木桶原理,水从矮的一侧溢出)。双指针的妙处在于:当 height[l] < height[r] 时,位置 l 的右侧一定存在一堵 ≥ height[r] 的墙(至少是 height[r] 自己),所以 l 的「右最大」必然 ≥ height[r] > height[l] ≥ maxLeft,于是 l 处的水量只由 maxLeft 决定,根本不用管右边具体多高!同理 height[l] ≥ height[r] 时 r 处水量只由 maxRight 决定。这就是为什么可以让矮侧先走。
l, r := 0, len(height)-1maxLeft, maxRight := 0, 0for l < r { maxLeft = max(maxLeft, height[l]) // 先更新当前位置左侧最大值 maxRight = max(maxRight, height[r]) // 先更新当前位置右侧最大值 if height[l] < height[r] { res += maxLeft - height[l]; l++ // 左侧矮 → 水位由 maxLeft 决定 } else { res += maxRight - height[r]; r-- // 右侧矮/相等 → 水位由 maxRight 决定 }}- 核心破题点:每个位置雨水量 = min(左最大, 右最大) - 自身高度;双指针让矮侧先走
- 避坑指南:先更新 max 再算雨水(用最新的 maxLeft/maxRight,包含当前 height[l]/height[r])
柱子与雨水可视化(height = [0,1,0,2,1,0,1,3,2,1,2,1]):
黑色 = 柱子高度,蓝色 = 接住的雨水。总雨水量 = 6
运行示例(双指针过程):
| 步骤 | left | right | maxL | maxR | h[l]<h[r]? | 本步雨水 | 总计 |
|---|---|---|---|---|---|---|---|
| 1 | 0 | 11 | 0 | 1 | 是 | 0-0=0 | 0 |
| 2 | 1 | 11 | 1 | 1 | 否 | 1-1=0 | 0 |
| 3 | 1 | 10 | 1 | 2 | 是 | 1-1=0 | 0 |
| 4 | 2 | 10 | 1 | 2 | 是 | 1-0=1 | 1 |
| 5 | 3 | 10 | 2 | 2 | 否 | 2-2=0 | 1 |
| 6 | 3 | 9 | 2 | 1 | 否 | 1-1=0 | 1 |
| 7 | 3 | 8 | 2 | 2 | 否 | 2-2=0 | 1 |
| 8 | 3 | 7 | 2 | 3 | 是 | 2-2=0 | 1 |
| 9 | 4 | 7 | 2 | 3 | 是 | 2-1=1 | 2 |
| 10 | 5 | 7 | 2 | 3 | 是 | 2-0=2 | 4 |
| 11 | 6 | 7 | 2 | 3 | 是 | 2-1=1 | 5 |
| 12 | 7 | 7 | 3 | 3 | 相遇 | 3-3=0 | 6 |
graph TD
S["初始化: l=0, r=n-1, maxL=0, maxR=0"] --> C{"height[l] < height[r] ?"}
C -->|"是: 左更矮
右侧必有 ≥ height[r] 的墙
→ 右侧最大 > 左侧最大"| L["位置 l 的水量只由 maxL 决定
res += maxL - height[l]
l++, 更新 maxL"]
C -->|"否: 右更矮或相等
左侧必有 ≥ height[l] 的墙
→ 左侧最大 ≥ 右侧最大"| R["位置 r 的水量只由 maxR 决定
res += maxR - height[r]
r--, 更新 maxR"]
L --> C
R --> C
C -->|"l >= r 时结束"| E["返回总雨水量 = 6"]
style S fill: #e3f2fd, color: #1a1a1a
style E fill: #c8e6c9, color: #1a1a1a0088. 合并两个有序数组 Easy
题意:把 nums2 合并到 nums1 中(nums1 末尾有空位)。
核心思路:从后往前填充,大的放后面。
// p1 指向 nums1 有效元素末尾,p2 指向 nums2 末尾,tail 指向合并后写入位置for p1, p2, tail := m-1, n-1, m+n-1; p1 >= 0 || p2 >= 0; tail-- { if p1 == -1 { // nums1 已用完,只能填 nums2 nums1[tail] = nums2[p2]; p2-- } else if p2 == -1 { // nums2 已用完,只能填 nums1 nums1[tail] = nums1[p1]; p1-- } else if nums1[p1] > nums2[p2] { // 两数组都还有,谁大填谁(从后往前保证有序) nums1[tail] = nums1[p1]; p1-- } else { nums1[tail] = nums2[p2]; p2-- }}- 核心破题点:从后往前填,不会覆盖 nums1 还没处理的元素
0189. 轮转数组 Medium
题意:数组右轮转 k 位(末尾 k 个元素移到最前面),原地操作。
核心思路:三次反转——整体反转 → 前 k 个反转 → 后 n-k 个反转。
怎么想到反转的:右轮转 k 位 = 把末尾 k 个元素整体搬最前面。反转有「对称」性质:先整体反转,末尾 k 个就被翻到了最前面(但顺序是反的);再把前 k 个和后 n-k 个分别反转回正序,就得到正确结果。三次反转刚好避免用额外数组。
k %= len(nums) // 防止 k > n,轮转 k 等价于轮转 k%nreverse(nums) // ① 整体反转:末尾 k 个被翻到最前(顺序反了)reverse(nums[:k]) // ② 前 k 个再反转回正序reverse(nums[k:]) // ③ 后 n-k 个再反转回正序例子(nums = [1,2,3,4,5,6,7], k = 3):
| 步骤 | 操作 | 数组变化 |
|---|---|---|
| 初始 | - | [1,2,3,4,5,6,7] |
| ① | 整体反转 | [7,6,5,4,3,2,1] |
| ② | 前 3 反转 | [5,6,7,4,3,2,1] |
| ③ | 后 4 反转 | [5,6,7,1,2,3,4] ✓ |
末尾 3 个 (5,6,7) 成功移到最前面
- 核心破题点:
reverse(nums)+reverse(nums[:k])+reverse(nums[k:])三步等价于右轮转 k
0345. 反转字符串中的元音字母 Easy
题意:反转字符串中的所有元音字母(aeiou,不区分大小写)。
l, r := 0, len(s)-1b := []byte(s)for l < r { for l < r && !vowels[b[l]] { l++ } for l < r && !vowels[b[r]] { r-- } b[l], b[r] = b[r], b[l]; l++; r--}0392. 判断子序列 Easy
题意:判断 s 是否为 t 的子序列。
i, j := 0, 0for j < len(t) { if i < len(s) && s[i] == t[j] { i++ } j++ if i == len(s) { return true }}- 核心破题点:t 的指针只前进不回退
0763. 划分字母区间 Medium
题意:划分字符串使每个字母只出现在一个片段中。
核心思路:先记录每个字母最后出现的位置,遍历时不断扩展右边界到「当前片段内所有字母的最终出现位置」;一旦遍历到片段右边界,说明该字母不会再出现,可以切一刀。
mp := map[rune]int{}for idx, ch := range s { mp[ch] = idx } // ① 记录每个字母最后出现的下标start, end := 0, 0for idx, ch := range s { if mp[ch] > end { end = mp[ch] } // ② 扩展右边界:当前字母还会更靠后,片段必须延伸到那 if idx == end { // ③ 到边界了:片段内字母都不会再出现,可以切分 ans = append(ans, idx+1-start) // 长度 = 右边界 - 左边界 + 1 start = idx + 1 // 下一个片段从左边界+1 开始 }}- 核心破题点:片段右边界 = 片段内所有字母「最终出现位置」的最大值;到边界即切分,保证同一字母只落在一个片段
- 为什么有效:只要右边界还在扩展,就说明当前片段还没「收口」,里面的字母还有后续,必须继续;一旦 idx 追上 end,此片段已自洽,切掉不影响后面
1768. 交替合并字符串 Easy
题意:将 word1 和 word2 交替合并,剩余部分追加到末尾。
i, j := 0, 0for i < m && j < n { sb.WriteByte(word1[i]); sb.WriteByte(word2[j]); i++; j++ }if m > n { sb.WriteString(word1[i:]) } else if n > m { sb.WriteString(word2[j:]) }三、滑动窗口
通用模板:右指针扩展窗口 → 判断窗口合法性 → 左指针收缩窗口 → 更新答案
graph LR
A["right 扩展窗口"] --> B{"窗口不合法?"}
B -->|是| C["left 收缩窗口"]
C --> B
B -->|否| D["更新答案"]
D --> A0003. 无重复字符的最长子串 Medium
题意:找无重复字符的最长子串长度。
核心思路:滑动窗口 + 哈希表记录字符位置。遇到重复字符时左指针跳到重复字符下一位。
mp := map[byte]int{}; l, res := 0, 0for r := 0; r < len(s); r++ { if idx, exists := mp[s[r]]; exists && idx >= l { l = idx + 1 // 左指针跳到重复字符之后 } mp[s[r]] = r res = max(res, r-l+1)}- 核心破题点:左指针不是一步一步走,而是直接跳到重复字符之后
- 避坑指南:要判断
idx >= l,因为哈希表里的位置可能在左指针之前(已不在窗口内) - ⚠️ 常见失败原因:更新结果时写成
res = max(res, r-1-l)而非r-l+1,off-by-one 导致少算 1
0076. 最小覆盖子串 Hard
题意:在 s 中找包含 t 所有字符(含足够数量)的最短子串,返回它。
核心思路:滑动窗口 + count 变量跟踪还需匹配的字符种类数。
tmap := map[byte]int{} // t 中各字符需要的数量for _, ch := range t { tmap[ch]++ }count := len(tmap) // 还需要满足的字符种类数smap := map[byte]int{}l, ansL, ansR := 0, 0, len(s)for r := 0; r < len(s); r++ { smap[s[r]]++ if smap[s[r]] == tmap[s[r]] { count-- } // 这种字符刚凑够 → 少一种待满足 for count == 0 { // 窗口合法,尽量往里收缩 if r-l < ansR-ansL { ansL, ansL = l, r } // 更新最短 smap[s[l]]-- if smap[s[l]] < tmap[s[l]] { count++ } // 收缩后这种字符不够了 → 待满足+1 l++ }}例子(s = “ADOBECODEBANC”, t = “ABC”):count 初始 = 3(A/B/C 三种)。右扩到 r=5 (“ADOBEC”) 时 A、B、C 都凑够 → count=0,窗口合法;左缩到 l=3 (“BEC”) 仍合法且更短;再缩 l=4 (“EC”) 缺 A → count=1,停止收缩,继续右扩。最终最短 = “BANC”。
- 核心破题点:用
count一个变量(而非每轮遍历哈希表)O(1) 判断窗口合法性 - 避坑指南:收缩时判断「是否破坏合法性」的条件是
smap[s[l]] < tmap[s[l]](数量从够变不够),不是==
0239. 滑动窗口最大值 Hard
题意:大小为 k 的滑动窗口,依次返回每个窗口内的最大值。
核心思路:单调递减队列,存下标,队首永远是当前窗口最大值。
为什么是单调队列(关键直觉):窗口右移时,旧元素若比新元素 val 还小,那它永远不可能再当最大值——因为 val 更大、且更靠右(在窗口里待得更久)。所以一遇到新元素,就把所有比它小的旧元素从队尾弹出,保证队列从队首到队尾单调递减。队首一旦超出窗口左边界就过期弹出。
q := []int{} // 存下标(不是存值!方便判断过期)for i, val := range nums { // ① 弹出队尾所有 ≤ 当前值的旧下标:它们以后都不可能当最大值 for len(q) > 0 && nums[q[len(q)-1]] <= val { q = q[:len(q)-1] } q = append(q, i) // ② 当前下标入队尾 if q[0] < i-k+1 { q = q[1:] } // ③ 队首下标已滑出窗口,弹出 if i >= k-1 { ans = append(ans, nums[q[0]]) } // ④ 窗口满,队首即最大值}- 核心破题点:单调递减队列——比新元素小的旧元素被永久淘汰,队首恒为窗口最大值
- 避坑指南:队列存下标而非值,才能用
q[0] < i-k+1判断是否滑出窗口
执行流程(nums = [1,3,-1,-3,5,3,6,7], k = 3):
| 步骤 | i | nums[i] | 队列(下标) | 队列(值) | 过期检查 | 输出 |
|---|---|---|---|---|---|---|
| 1 | 0 | 1 | [0] | [1] | i<2, 不输出 | - |
| 2 | 1 | 3 | [1] | [3] | i<2, 不输出 | - |
| 3 | 2 | -1 | [1,2] | [3,-1] | q[0]=1≥0, OK | nums[1]=3 |
| 4 | 3 | -3 | [1,2,3] | [3,-1,-3] | q[0]=1≥1, OK | nums[1]=3 |
| 5 | 4 | 5 | [4] | [5] | q[0]=4≥2, OK | nums[4]=5 |
| 6 | 5 | 3 | [4,5] | [5,3] | q[0]=4≥3, OK | nums[4]=5 |
| 7 | 6 | 6 | [6] | [6] | q[0]=6≥4, OK | nums[6]=6 |
| 8 | 7 | 7 | [7] | [7] | q[0]=7≥5, OK | nums[7]=7 |
输出 = [3,3,5,5,6,7],每次弹出比新元素小的旧元素,保持队列单调递减
0438. 找到字符串中所有字母异位词 Medium
题意:异位词 = 字符种类和数量完全相同、只是排列不同的字符串(如 “abc” 和 “cba”)。返回 s 中所有长度等于 len(p)、且是 p 的异位词的子串起始下标。
核心思路:固定长度窗口 + 差值计数。用 cnt[ch] = 窗口内该字符数 - p 中该字符数,diff = 不为 0 的字符种数。diff == 0 说明窗口与 p 字符完全一致,即异位词。
cnt := [26]int{}; diff := 0// ① 初始化:先把 s 前 len(p) 个字符的窗口与 p 比较for i := 0; i < len(p); i++ { cnt[s[i]-'a']++ // 窗口多一个 cnt[p[i]-'a']-- // p 多一个(相减得到差值)}for i := 0; i < 26; i++ { if cnt[i] != 0 { diff++ } }if diff == 0 { res = append(res, 0) }// ② 窗口右滑:左出右进,增量更新 cnt 和 difffor i := len(p); i < len(s); i++ { out := s[i-len(p)] - 'a' // 滑出的字符 cnt[out]-- if cnt[out] == 0 { diff-- } else if cnt[out] == -1 { diff++ } // 由不平衡→平衡 / 由平衡→更缺 in := s[i] - 'a' // 滑入的字符 cnt[in]++ if cnt[in] == 0 { diff-- } else if cnt[in] == 1 { diff++ } // 由多→平衡 / 由平衡→更多 if diff == 0 { res = append(res, i-len(p)+1) } // 平衡即异位词}- 核心破题点:用差值数组 + 非零个数
diff,避免每滑一步都比较 26 个字母(O(1) 判断) - ⚠️ 常见失败原因:
diff更新逻辑错误——只有当cnt在「0 ↔ 非0」之间跨越时才改diff,从 1→2 或 -2→-1 不算(本来就不平衡,数量变化不改变平衡性)
0643. 子数组最大平均数 I Easy
题意:找长度为 k 的连续子数组的最大平均值。
for i := 0; i < k; i++ { sum += nums[i] }ans := float64(sum) / float64(k)for i := k; i < len(nums); i++ { sum += nums[i] - nums[i-k]; ans = max(ans, float64(sum)/float64(k)) }1004. 最大连续1的个数 III Medium
题意:把数组中最多 k 个 0 翻成 1,求翻转后能得到的最长连续 1 子数组的长度。等价于:找一个最多含 k 个 0 的最长窗口。
例子:nums = [1,1,1,0,0,0,1,1,1,1,0], k = 2 → 把中间 3 个 0 中的任意 2 个翻成 1,连上右边的 1,得到最长连续 1 长度 = 6(如下标 0~5 全为 1)。
核心思路:滑动窗口,窗口内 0 的个数 ≤ k 就合法;0 超了就收缩左边界。
for right := 0; right < n; right++ { if nums[right] == 0 { zeroCnt++ } for zeroCnt > k { if nums[left] == 0 { zeroCnt-- }; left++ } maxLen = max(maxLen, right-left+1)}- 核心破题点:「最多翻转 k 个 0」=「窗口内最多 k 个 0」
1456. 定长子串中元音的最大数目 Medium
for i := 0; i < k; i++ { if vowels[s[i]] { count++ } }ans := countfor i := k; i < len(s); i++ { if vowels[s[i-k]] { count-- } if vowels[s[i]] { count++ } ans = max(ans, count)}- ⚠️ 常见失败原因:循环条件写成
i < len(s)-k而非i <= len(s)-k,遗漏最后一个窗口。如s="aeiou", k=5时循环不执行返回 0
1493. 删掉一个元素以后全为 1 的最长子数组 Medium
核心思路:窗口内最多 1 个 0,答案为窗口长度 - 1(必须删一个)。
for right := 0; right < n; right++ { if nums[right] == 0 { zeroCnt++ } for zeroCnt > 1 { if nums[left] == 0 { zeroCnt-- }; left++ } maxLen = max(maxLen, right-left) // 注意是 right-left 不是 +1}- 核心破题点:必须删一个元素,所以答案是
right - left而非right - left + 1
四、二分查找
通用模板:
left + (right-left)/2防溢出,循环条件left <= right
什么时候能用二分(前提:单调):二分能「每次砍掉一半」的根本前提是——待搜索空间具有单调性,存在一个临界点把区间分成「满足某性质」和「不满足」两段(左段都满足、右段都不满足,或反之)。
- 数组本身升序是最直观的单调(如 0035、0074);
- 更广义的「二分答案」要求目标函数随自变量单调,例如 0875 中速度越大耗时越小(
total(k)随k递减),于是「能否在 h 小时内吃完」存在清晰的「左非法右合法」边界。- 若数据本身无序、且找不到任何随下标单调的关系,二分就没有依据,会漏解——这种情况下不能硬套二分。
为什么有时用
left < right:
left <= right(闭区间模板):[left, right]始终是一个有效闭区间,mid可能就是答案,命中即返回;循环退出时left > right(两者交错)。适合「找某个确切等于 target 的下标 / 元素确实存在」的场景(如 0033、0287 之外的普通查找)。left < right(边界/下界模板):循环到left == right才退出,此时left(即right)就是答案,不必再额外判断。常用于「找第一个满足某性质的位置」——如 0034 的左边界leftBound、0035 的插入位置。注意这种写法mid取left + (right-left)/2(向下取整),且更新时通常right = mid(保留mid这个候选),否则left = mid + 1,避免死循环。- 一句话记忆:要「命中某个值」用
<=;要「逼近某个边界/下界」用<。
0033. 搜索旋转排序数组 Medium
题意:旋转排序数组中找 target。
核心思路:二分时判断哪半边有序,再判断 target 在不在有序的那半边。
left, right := 0, n-1for left <= right { mid := left + (right-left)/2 if nums[mid] == target { return mid } if nums[mid] >= nums[left] { // 左半有序 if target >= nums[left] && target < nums[mid] { right = mid - 1 } else { left = mid + 1 } } else { // 右半有序 if target > nums[mid] && target <= nums[right] { left = mid + 1 } else { right = mid - 1 } }}- 核心破题点:旋转数组必有一半是有序的,判断 target 在不在有序的那半边
- 避坑指南:
nums[mid] >= nums[left]用>=因为 mid 可能等于 left
执行流程(nums = [4,5,6,7,0,1,2], target = 0):
| 轮次 | left | right | mid | nums[mid] | 判断 | 动作 |
|---|---|---|---|---|---|---|
| 1 | 0 | 6 | 3 | 7 | 7≥4,左半有序 | target=0 不在[4,7),left=4 |
| 2 | 4 | 6 | 5 | 1 | 1<4,右半有序 | target=0 不在(1,2],right=4 |
| 3 | 4 | 4 | 4 | 0 | 0==target | ✅ 返回 4 |
graph TD
A["二分 mid"] --> B{"nums[mid] == target?"}
B -->|是| C["返回 mid"]
B -->|否| D{"nums[mid] >= nums[left]?"}
D -->|是| E["左半有序"]
D -->|否| F["右半有序"]
E --> G{"target 在左半?"}
F --> H{"target 在右半?"}
G -->|是| I["right = mid-1"]
G -->|否| J["left = mid+1"]
H -->|是| J
H -->|否| I0034. 在排序数组中查找元素的第一个和最后一个位置 Medium
题意:升序数组中找到 target 的起始下标和结束下标(都包含),不存在返回 [-1,-1]。
核心思路:两次二分——找「第一个 ≥ target」的位置(左边界)和「第一个 > target」的位置(右边界 = 该位置−1)。
如何维护边界(二分模板):用 left = 第一个 ≥ target 的标准写法:当 nums[mid] < target 时收缩左半(left = mid+1),否则收缩右半(right = mid-1)。结束时 left 指向第一个 ≥ target 的下标。
// 左边界 = 第一个 >= target 的位置leftBound := func() int { l, r := 0, len(nums)-1 for l <= r { mid := l + (r-l)/2 if nums[mid] < target { l = mid+1 } else { r = mid-1 } } return l // 第一个 >= target}// 右边界 = 第一个 > target 的位置 - 1 = 第一个 >= target+1 的位置 - 1rightBound := func() int { l, r := 0, len(nums)-1 for l <= r { mid := l + (r-l)/2 if nums[mid] <= target { l = mid+1 } else { r = mid-1 } // 注意是 <= } return l - 1}例子(nums = [5,7,7,8,8,10], target = 8):
左边界:第一个 ≥ 8 → 下标 3;右边界:第一个 > 8(即第一个 ≥ 9)→ 下标 5,再 −1 = 4。结果 [3,4] ✓
核心破题点:排序 + O(log n) → 必须二分;左右边界各一次二分,避免找到后线性扫描退化为 O(n)
避坑指南:两遍二分的条件不同——左边界用
nums[mid] < target,右边界用nums[mid] <= target
0035. 搜索插入位置 Easy
题意:在有序数组中找 target,找到返回下标,没找到返回应插入的位置。
for left <= right { mid := left + (right-left)/2 if nums[mid] == target { return mid } else if nums[mid] > target { right = mid - 1 } else { left = mid + 1 }}return left // left 就是插入位置- 核心破题点:二分结束时
left恰好指向插入位置
0074. 搜索二维矩阵 Medium
题意:矩阵每行从左到右升序,且每行的第一个元素 > 上一行的最后一个元素(即整体可看作一个升序一维数组)。判断 target 是否在其中。
核心思路:两次二分——把矩阵当成一维升序数组,先二分找「target 可能所在行」,再在该行内二分找 target。
// 把 (r,c) 映射到一维下标 idx = r*cols + c,整体二分m, n := len(matrix), len(matrix[0])l, r := 0, m*n-1for l <= r { mid := l + (r-l)/2 val := matrix[mid/n][mid%n] // 一维下标还原成二维坐标 if val == target { return true } else if val < target { l = mid+1 } else { r = mid-1 }}return false为什么能当成一维:因为「每行首 > 上行列尾」的条件,矩阵的字典序和拼成一维后的大小序完全一致,所以整体二分等效于先找行、再找列。
- 核心破题点:矩阵满足条件时等价于一维升序数组,直接整体二分更简洁
- 避坑指南:用
mid/n(行)和mid%n(列)还原坐标;注意越界检查
0153. 寻找旋转排序数组中的最小值 Medium
题意:一个原本升序的数组在某个「轴点」处被旋转(例如 [0,1,2,4,5,6,7] → [4,5,6,7,0,1,2])。请找出其中的最小元素。本版本保证数组中没有重复元素。
核心思路:旋转后,数组被最小值切成两段——左半段都 >= 原数组首元素,右半段都 < 原数组首元素。用二分时,比较 nums[mid] 与 nums[left] 就能判断 mid 落在哪一段:
nums[mid] >= nums[left]→mid在左半段(有序段),最小值一定在mid右边(含mid+1起),所以left = mid + 1,并把nums[left]当作候选最小值。nums[mid] < nums[left]→mid在右半段(无序段),最小值一定在mid左边(可能就是mid),所以right = mid - 1,并把nums[mid]当作候选最小值。
if nums[mid] >= nums[left] { left = mid + 1; ans = min(ans, nums[left]) }else { right = mid - 1; ans = nums[mid] }例子 nums = [3,4,5,1,2](首元素 nums[0]=3,最小值应为 1):
| left | right | mid | nums[mid] 与 nums[left] | 判断 | 动作 |
|---|---|---|---|---|---|
| 0 | 4 | 2 | 5 >= 3 | 左段有序 | ans=min(3,3)=3,left=3 |
| 3 | 4 | 3 | 1 >= 1 | 左段有序 | ans=min(3,1)=1,left=4 |
| 4 | 4 | 4 | 2 >= 2 | 左段有序 | ans=min(1,2)=1,left=5 → 退出 |
返回 ans = 1。
- 核心破题点:旋转数组的「有序性」只缺一处(旋转点),二分每次决定砍掉有序的一半即可
- 避坑指南:本题无重复才能用
nums[left]比较;若数组含重复(154 题)必须改用nums[right]比较,否则会判错
0240. 搜索二维矩阵 II Medium
题意:在 m×n 矩阵中找 target,每行从左到右递增,每列从上到下递增。
核心思路:从右上角开始,大就左移,小就下移。
r, c := 0, len(matrix[0])-1for r < len(matrix) && c >= 0 { if matrix[r][c] == target { return true } if matrix[r][c] > target { c-- } else { r++ }}- 核心破题点:右上角有「左小下大」的 BST 性质,每次排除一行或一列
- 避坑指南:不能用左上角,因为右和下都更大无法确定方向
- ⚠️ 常见失败原因:逐行二分时条件写成
target < matrix[r][0]和target > matrix[r][0],当target == matrix[r][0]时两个条件都不满足,该行被跳过。应改为>=
0287. 寻找重复数 Medium
题意:给定长度 n+1 的数组 nums,元素取值范围是 [1, n](即值域比索引范围少一个 0)。只有一个数字重复(但可能重复多次)。找出这个重复的数。要求:不能修改原数组、只用 O(1) 额外空间、运行时间优于 O(n²)。
什么是判圈法(Floyd 判圈法 / 龟兔赛跑):
把数组当成一张「有向图」:i → nums[i] 表示从下标 i 指向值 nums[i] 所在的下标。因为元素值域是 [1,n]、而索引有 0..n,从 0 出发必然走进某个环——而那个环的入口,正是重复出现的那个数。
为什么一定成环、且环入口=重复数?
- 由于值域只有
1..n,除0外每个值都对应一个合法下标,路径不可能无限发散,必然进入循环。 - 重复的那个数
dupe至少被两个不同的下标指向(因为有两个位置的值都是dupe),于是dupe这个节点有两条入边 → 它就是环的入口。
判圈法分两个阶段:
- 找相遇点(slow 走 1 步、fast 走 2 步):
slow = nums[slow],fast = nums[nums[fast]]。只要存在环,二者必在环内某点相遇。 - 找环入口:一个指针从起点
head=0出发,slow 从相遇点出发,两者每次都走 1 步,再次相遇处即为环入口 = 重复数。
数学直觉:设「起点到入口」距离为 a,「入口到相遇点」距离为 b,环周长为 c。相遇时 slow 走了 a+b,fast 走了 2(a+b)=a+b+k·c,解得 a+b=k·c。这意味从相遇点再走 a 步正好回到入口(绕了 k 圈);而从起点走 a 步也到入口,所以两指针必在入口相遇。
slow, fast := 0, 0for { slow = nums[slow]; fast = nums[nums[fast]]; if slow == fast { break } }head := 0for slow != head { slow = nums[slow]; head = nums[head] }return slow例子 nums = [1,3,4,2,2](重复数是 2):
指针路径:0 → 1 → 3 → 2 → 4 → 2 → 4 → …(2→4→2 成环,入口是节点 2)
- 阶段一:slow=0,fast=0 → slow=1,fast=3 → slow=3,fast=4 → slow=2,fast=4 → slow=4,fast=4(相遇于节点 4)
- 阶段二:head=0,slow=4 → head=1,slow=2 → head=3,slow=4 → head=2,slow=2(相遇于节点 2)
返回 2,正是重复数。
- 核心破题点:把「值」当作「指向下标的指针」,重复值 = 环的入口
- 避坑指南:不能对数组排序或用哈希表(违反「不改数组 / O(1) 空间」约束),判圈法是唯一同时满足两条约束的做法
0374. 猜数字大小 Easy
题意:猜 1 到 n 之间的数字,调用 guess() 获取提示(-1 更小 / 0 猜中 / 1 更大),返回猜中的数字。
for { mid := left + (right-left)/2 res := guess(mid) if res == 0 { return mid } else if res == 1 { left = mid + 1 } else { right = mid - 1 }}0875. 爱吃香蕉的珂珂 Medium
题意:有 n 堆香蕉,piles[i] 是第 i 堆的数量。珂珂要在 h 小时内吃完所有香蕉,每小时她选一堆吃,吃 k 根(不足 k 根则吃完这堆),之后这一小时不能再吃别的堆。求能吃完的最小速度 k。
为什么可以二分:关键性质是「速度越大,总耗时越短」——耗时 total(k) 是关于 k 的单调递减函数。这带来两个结论:
- 存在一个阈值
K:当k ≥ K时total(k) ≤ h(吃得完);当k < K时total(k) > h(吃不完)。 - 既然「答案左侧都不合法、右侧都合法」的边界单调清晰,就能用二分直接逼近这个临界点
K,而不是从 1 开始逐个试。
搜索范围:k 最小是 1,最大是 maxPiles(再以更慢的速度也能吃,但没必要更大)。
left, right := 1, maxPile // 速度范围 [1, 最大堆]for left <= right { mid := left + (right-left)/2 total := 0 for _, p := range piles { total += (p + mid - 1) / mid } // 向上取整 if total <= h { ans = mid; right = mid - 1 } else { left = mid + 1 }}例子 piles = [3,6,7,11], h = 8:
| 尝试速度 k | 各堆耗时 ⌈p/k⌉ | 总耗时 | 能否 8h 吃完 |
|---|---|---|---|
| 4 | ⌈3/4⌉+⌈6/4⌉+⌈7/4⌉+⌈11/4⌉ = 1+2+2+3 | 8 | 刚好可以 |
| 3 | 1+2+3+4 | 10 | 不行 |
最小合法速度是 4。
- 核心破题点:吃香蕉耗时随速度单调递减 → 答案具有「左非法右合法」的边界 → 二分答案(binary search on answer)
- 避坑指南:每堆耗时必须向上取整
(p+mid-1)/mid,不能直接p/mid,否则会少算一堆的时间
0004. 寻找两个正序数组的中位数 Hard
题意:两个正序数组找中位数,要求 O(log(m+n))。
核心思路:中位数 = 第 k 小问题。二分每次各看 k/2 个,较小的那半段不可能包含第 k 小,直接跳过。
func getKth(a, b []int, k int) int { if len(a) == 0 { return b[k-1] } // a 耗尽 if len(b) == 0 { return a[k-1] } // b 耗尽 if k == 1 { return min(a[0], b[0]) } // 只剩1个 midA := min(k/2-1, len(a)-1) // 防越界 midB := min(k/2-1, len(b)-1) if a[midA] < b[midB] { return getKth(a[midA+1:], b, k-(midA+1)) // a 前半段跳过 } else { return getKth(a, b[midB+1:], k-(midB+1)) // b 前半段跳过 }}
func findMedianSortedArrays(a, b []int) float64 { total := len(a) + len(b) if total%2 == 1 { return float64(getKth(a, b, (total+1)/2)) // 奇数取中间 } return float64(getKth(a,b,total/2)+getKth(a,b,total/2+1)) / 2.0 // 偶数取平均}运行示例:a = [1,3], b = [2],总长3,找第2小
| 步骤 | a | b | k | midA | midB | a[midA] | b[midB] | 操作 |
|---|---|---|---|---|---|---|---|---|
| 1 | [1,3] | [2] | 2 | 0 | 0 | 1 | 2 | 1 < 2,跳过a[0],k=1 |
| 2 | [3] | [2] | 1 | - | - | - | - | k=1,返回min(3,2)=2 |
- 核心破题点:中位数转化为第 k 小问题,每次排除 k/2 个元素 → O(log(m+n))
- 避坑指南:
midA = min(k/2-1, len(a)-1)防止数组长度不足 k/2 时越界
五、链表
链表题万能起手式:加 dummy 哨兵节点,避免处理头节点的特殊情况。
0002. 两数相加 Medium
题意:两个逆序存储的链表(每个节点存一位数字,个位在前),代表两个非负整数。返回它们相加的结果链表,同样逆序存储。例如 l1 = 2→4→3 表示 342,l2 = 5→6→4 表示 465,结果应为 807 → 7→0→8。
它在干什么(逐位模拟小学加法):
dummy哨兵节点:结果链表的「假头」,这样不用特殊处理「第一个节点」的赋值,tail始终指向已构造部分的末尾,新节点接到tail.Next。最后返回dummy.Next才是真正的头。carry进位:每一位相加可能产生进位(如 7+8=15,个位留 5、进 1),carry带到下一位。- 循环条件
l1 != nil || l2 != nil:只要两条链表还有任意一条没走完(或还有进位)就继续;不足位补 0。
dummy := &ListNode{}; tail := dummy; carry := 0for l1 != nil || l2 != nil { sum := carry if l1 != nil { sum += l1.Val; l1 = l1.Next } if l2 != nil { sum += l2.Val; l2 = l2.Next } sum, carry = sum%10, sum/10 // 当前位 = sum%10,进位 = sum/10 tail.Next = &ListNode{Val: sum}; tail = tail.Next}if carry > 0 { tail.Next = &ListNode{Val: carry} } // 最后还有进位,补一位return dummy.Next逐位演算 l1 = 2→4→3(342),l2 = 5→6→4(465),正确和 807:
| 轮次 | l1.Val | l2.Val | carry进 | 原始和 | 当前位(和%10) | 新carry(和/10) | 结果接的节点 |
|---|---|---|---|---|---|---|---|
| 1 | 2 | 5 | 0 | 7 | 7 | 0 | 7 |
| 2 | 4 | 6 | 0 | 10 | 0 | 1 | 0 |
| 3 | 3 | 4 | 1 | 8 | 8 | 0 | 8 |
| 4 | - | - | 0 | 结束 | - | - | - |
得到 7→0→8(即 807),正确。
- 核心破题点:dummy 哨兵统一头节点处理 + carry 进位贯穿每一位
- 避坑指南:循环条件别写成
l1 != nil && l2 != nil,否则长度不等时长的那条剩余位会被丢掉;且结束后要检查carry是否还有进位
0019. 删除链表的倒数第 N 个结点 Medium
核心思路:快指针先走 N 步,再同步走。快指针到尾时慢指针在倒数第 N+1 个。
dummy := &ListNode{Next: head}left, right := dummy, headfor i := 0; i < n; i++ { right = right.Next }for right != nil { left = left.Next; right = right.Next }left.Next = left.Next.Next // 删除return dummy.Next- 核心破题点:快指针先走 N 步制造差距,dummy 防止删头节点
0021. 合并两个有序链表 Easy
dummy := &ListNode{}; tail := dummyfor l1 != nil && l2 != nil { if l1.Val < l2.Val { tail.Next = l1; l1 = l1.Next } else { tail.Next = l2; l2 = l2.Next } tail = tail.Next}if l1 != nil { tail.Next = l1 } else { tail.Next = l2 }return dummy.Next0023. 合并 K 个升序链表 Hard
核心思路:分治——两两合并,类似归并排序。
for len(lists) > 1 { var merged []*ListNode for i := 0; i < len(lists); i += 2 { if i+1 >= len(lists) { merged = append(merged, lists[i]); continue } merged = append(merged, mergeTwo(lists[i], lists[i+1])) } lists = merged}- 核心破题点:两两合并把 K 个链表的合并降到 O(N log K)
- 避坑指南:也可用最小堆每次取最小节点
分治合并示意(K=4 个链表):
graph TD
subgraph "第0轮"
L1["链表1: 1→4→5"]
L2["链表2: 1→3→4"]
L3["链表3: 2→6"]
L4["链表4: 3→7"]
end
subgraph "第1轮: 两两合并"
M12["合并1+2: 1→1→3→4→4→5"]
M34["合并3+4: 2→3→6→7"]
end
subgraph "第2轮: 最终合并"
F["合并M12+M34: 1→1→2→3→3→4→4→5→6→7"]
end
L1 --> M12
L2 --> M12
L3 --> M34
L4 --> M34
M12 --> F
M34 --> F
style F fill: #c8e6c9, color: #1a1a1a每轮链表数减半,共 log K 轮,每轮总比较次数 O(N)
0024. 两两交换链表中的节点 Medium
题意:每两个相邻节点交换位置,返回头节点。
dummy := &ListNode{Next: head}; pre := dummyfor head != nil && head.Next != nil { first, second := head, head.Next pre.Next = second first.Next = second.Next second.Next = first pre = first; head = first.Next}return dummy.Next0025. K 个一组翻转链表 Hard
核心思路:每 k 个为一组,断开 → 反转 → 接回。用 pre 和 tail 标记每组的前驱和尾。
dummy := &ListNode{Next: head}; pre, tail := dummy, dummyfor { for i := 0; i < k; i++ { tail = tail.Next if tail == nil { return dummy.Next } // 不够 k 个 } next := tail.Next; groupHead := pre.Next tail.Next = nil reversedHead := reverseList(groupHead) // 反转 pre.Next = reversedHead groupHead.Next = next // 原头变尾,接下一组 pre, tail = groupHead, groupHead}- 核心破题点:断开 → 反转 → 接回三步走,pre 和 tail 是连接桥梁
0138. 随机链表的复制 Medium
题意:深拷贝一个带 random 指针的链表,返回新链表头节点。
核心思路:原地交叉复制法(O(1) 空间)。
// 1. 在每个节点后插入复制节点 A→A'→B→B'// 2. 设置 random: now.Next.Random = now.Random.Next// 3. 断开分离- 核心破题点:交叉复制后,
原节点.Random.Next就是 random 对应的复制节点
0141. 环形链表 Easy
题意:判断链表是否有环。
fast, slow := head, headfor fast != nil && fast.Next != nil { fast = fast.Next.Next; slow = slow.Next if fast == slow { return true }}return false- 核心破题点:快慢指针在环内必然相遇
0142. 环形链表 II Medium
题意:找到链表环的入口节点,无环返回 null。
核心思路:快慢指针相遇后,一个回 head 同步走,再次相遇即环入口。
// 第一阶段:找相遇点// 第二阶段:head 和 slow 同速走for head != slow { head = head.Next; slow = slow.Next }return head- 核心破题点:数学证明
a = c(头到入口 = 相遇点到入口)
指针追踪(链表: 3→2→0→-4→(回到2),环入口=节点2):
graph LR
H["head(3)"] -->|" a=1步 "| E["入口(2)"]
E -->|" b=1步 "| N0["(0)"]
N0 -->|" 1步 "| M["相遇点(-4)"]
M -->|" c=1步 "| E
style E fill: #c8e6c9, stroke: #333, color: #1a1a1a
style M fill: #fff9c4, stroke: #333, color: #1a1a1a执行流程:
| 阶段 | fast | slow | 说明 |
|---|---|---|---|
| 初始 | 3 | 3 | 都从 head 出发 |
| 第1步 | 2→0 | 2 | fast走2步, slow走1步 |
| 第2步 | -4→2→0 | 0 | fast走2步, slow走1步 |
| 第3步 | -4 | -4 | ✅ 在-4相遇 |
| 第二阶段 | head=3 | slow=-4 | 一个回head,同速走 |
| 第1步 | 2 | 2 | ✅ 在节点2相遇=环入口 |
关键:
a = c,所以从 head 和相遇点同速走,必在入口相遇
0146. LRU 缓存 Medium
题意:实现 O(1) 的 get 和 put 的 LRU 缓存,超容量时淘汰最久未使用的。
核心思路:哈希表 + 双向链表。哈希表 O(1) 查找,双向链表 O(1) 调整顺序。
type LRUCache struct { cache map[int]*DLinkedNode head, tail *DLinkedNode // 虚拟头尾 capacity int}// Get: 查到后移到头部// Put: 新节点加头部,超容淘汰尾部- 核心破题点:哈希表负责定位,双向链表负责顺序,两者结合 = O(1) LRU
- 避坑指南:淘汰尾节点后必须同时从 map 中 delete;虚拟头尾节点简化边界处理
双向链表示意(capacity=2,操作序列: put(1,1), put(2,2), get(1), put(3,3)):
graph LR
subgraph "put(1,1)后"
H1["head⟷"] --> N1["key=1,val=1"] --> T1["⟷tail"]
end
subgraph "put(2,2)后"
H2["head⟷"] --> N2A["key=2,val=2"] --> N2B["key=1,val=1"] --> T2["⟷tail"]
end
subgraph "get(1)后: 1移到头部"
H3["head⟷"] --> N3A["key=1,val=1"] --> N3B["key=2,val=2"] --> T3["⟷tail"]
end
subgraph "put(3,3)后: 淘汰尾部key=2"
H4["head⟷"] --> N4A["key=3,val=3"] --> N4B["key=1,val=1"] --> T4["⟷tail"]
end
style N1 fill: #e3f2fd, color: #1a1a1a
style N2A fill: #e3f2fd, color: #1a1a1a
style N3A fill: #c8e6c9, color: #1a1a1a
style N4A fill: #fff9c4, color: #1a1a1a绿=刚访问移到头部;黄=新插入;蓝=普通节点。头部=最近使用,尾部=最久未用
0148. 排序链表 Medium
题意:对链表排序,要求 O(n log n) 时间。
核心思路:归并排序——快慢指针找中点,递归排序,合并。
slow, fast := head, head.Next // fast 从 head.Next 出发for fast != nil && fast.Next != nil { slow = slow.Next; fast = fast.Next.Next }rightHead := slow.Next; slow.Next = nil // 断开return merge(sortList(head), sortList(rightHead))- 核心破题点:链表归并天然适合——找中点用快慢指针,合并用双指针
- 避坑指南:fast 初始必须是
head.Next而非head,否则偶数长度分割不均
0160. 相交链表 Easy
题意:找两个单链表相交的起始节点,不相交返回 null。
核心思路:双指针走完自己走对方的,总路程相同,必在交点相遇。
a, b := headA, headBfor a != b { if a == nil { a = headB } else { a = a.Next } if b == nil { b = headA } else { b = b.Next }}return a- 核心破题点:两指针走
lenA + lenB步后必然同步到达交点或 nil
0206. 反转链表 Easy
var pre *ListNode; cur := headfor cur != nil { temp := cur.Next // 暂存 cur.Next = pre // 反转 pre = cur // 前进 cur = temp}return pre- 核心破题点:暂存 next 防断链,三指针逐步推进
0234. 回文链表 Easy
核心思路:转数组 + 双指针比较(简单版)。进阶:快慢找中点 + 反转后半段 + 比较。
0328. 奇偶链表 Medium
odd := head; even := head.Next; evenHead := evenfor even != nil && even.Next != nil { odd.Next = odd.Next.Next; even.Next = even.Next.Next odd = odd.Next; even = even.Next}odd.Next = evenHead // 奇链表接偶链表2095. 删除链表的中间节点 Medium
slow, fast := head, head; var pre *ListNodefor fast != nil && fast.Next != nil { fast = fast.Next.Next; pre = slow; slow = slow.Next}pre.Next = pre.Next.Next2130. 链表最大孪生和 Medium
题意:将链表前半与反转的后半对应位置配对,求各对之和的最大值。
核心思路:快慢找中点 → 反转后半段 → 双指针同步走求最大和。
// 1. 快慢找中点// 2. 反转后半段// 3. x, y := head, reversedHead; 求 max(x.Val + y.Val)六、栈与单调栈
0020. 有效的括号 Easy
m := map[byte]byte{')': '(', ']': '[', '}': '{'}stk := []byte{}for i := 0; i < len(s); i++ { if cur, exist := m[s[i]]; exist { // 右括号 if len(stk) == 0 || stk[len(stk)-1] != cur { return false } stk = stk[:len(stk)-1] } else { stk = append(stk, s[i]) } // 左括号入栈}return len(stk) == 00032. 最长有效括号 Hard
核心思路:栈存下标,栈底始终保存最后一个不匹配的位置。
stack := []int{-1} // 初始 -1 作为基准for i, c := range s { if c == '(' { stack = append(stack, i) } else { stack = stack[:len(stack)-1] // 弹出 if len(stack) == 0 { stack = append(stack, i) } // 栈空,更新基准 else { ans = max(ans, i-stack[len(stack)-1]) } // 计算长度 }}- 核心破题点:栈底保存「最后一个不匹配位置」,当前下标减栈顶就是有效长度
- 避坑指南:栈空时要把当前右括号下标入栈作为新基准
- ⚠️ 常见失败原因:用
ans累加所有匹配括号对的总数,而非追踪最长连续有效子串长度。如"()(()"返回 4 而非正确答案 2
0084. 柱状图中最大的矩形 Hard
核心思路:单调递增栈。当前柱子比栈顶矮时弹出栈顶,以弹出柱子为高计算面积。
stack := []int{}for i := 0; i <= n; i++ { for len(stack) > 0 && (i == n || heights[i] < heights[stack[len(stack)-1]]) { h := heights[stack[len(stack)-1]]; stack = stack[:len(stack)-1] left := -1; if len(stack) > 0 { left = stack[len(stack)-1] } ans = max(ans, h*(i-left-1)) // 宽 = i - left - 1 } stack = append(stack, i)}- 核心破题点:每个柱子为高的最大矩形由左右第一个比它矮的柱子决定,单调栈 O(1) 找到
- 避坑指南:遍历结束后要清算栈中剩余(右边界为 n);加哨兵 heights[n]=0 简化
柱状图可视化(heights = [2,1,5,6,2,3]):
执行流程(heights = [2,1,5,6,2,3],哨兵 heights[6]=0):
| 步骤 | i | 当前柱高 | 栈(下标) | 动作 | 计算面积 |
|---|---|---|---|---|---|
| 1 | 0 | 2 | [] | 入栈 | - |
| 2 | 1 | 1 | [0] | 1<2,弹出0 | 2×(1-(-1)-1)=2 |
| 3 | 1 | 1 | [] | 入栈 | - |
| 4 | 2 | 5 | [1] | 5>1,入栈 | - |
| 5 | 3 | 6 | [1,2] | 6>5,入栈 | - |
| 6 | 4 | 2 | [1,2,3] | 2<6,弹出3 | 6×(4-2-1)=6 |
| 7 | 4 | 2 | [1,2] | 2<5,弹出2 | 5×(4-1-1)=10 ✓max |
| 8 | 4 | 2 | [1] | 2>1,入栈 | - |
| 9 | 5 | 3 | [1,4] | 3>2,入栈 | - |
| 10 | 6 | 0(哨兵) | [1,4,5] | 0<3,弹出5 | 3×(6-4-1)=3 |
| 11 | 6 | 0 | [1,4] | 0<2,弹出4 | 2×(6-1-1)=8 |
| 12 | 6 | 0 | [1] | 0<1,弹出1 | 1×(6-(-1)-1)=6 |
最大面积 = 10(柱子高5,宽2,即下标2-3的5和6)
0155. 最小栈 Medium
题意:实现 push/pop/top/getMin 均为 O(1) 的栈。
核心思路:辅助栈同步维护最小值。
func Push(value int) { num = append(num, value) minNum = append(minNum, min(value, minNum[top])) // 同步压入当前最小}func GetMin() int { return minNum[top] }0394. 字符串解码 Medium
题意:解码 “3[a2[c]]” → “accaccacc”,数字表示后面方括号内字符串的重复次数。
核心思路:遇到 [ 压栈保存上下文,遇到 ] 弹栈恢复并拼接。
if s[i] == '[' { strStack = append(strStack, currStr) // 保存当前字符串 numStack = append(numStack, currNum) // 保存重复次数 currStr = ""; currNum = 0 // 重置} else if s[i] == ']' { prev := strStack[len(strStack)-1]; strStack = strStack[:len(strStack)-1] times := numStack[len(numStack)-1]; numStack = numStack[:len(numStack)-1] currStr = prev + strings.Repeat(currStr, times) // 拼接}- 核心破题点:
[入栈保存上下文,]出栈恢复上下文 - 避坑指南:数字可能多位,需
currNum = currNum*10 + int(s[i]-'0')
执行流程(s = “3[a2[c]]”):
| 步骤 | 字符 | 动作 | currStr | currNum | strStack | numStack |
|---|---|---|---|---|---|---|
| 1 | ‘3’ | 累积数字 | "" | 3 | [] | [] |
| 2 | ’[’ | 压栈保存上下文,重置 | "" | 0 | [""] | [3] |
| 3 | ‘a’ | 拼入currStr | “a” | 0 | [""] | [3] |
| 4 | ‘2’ | 累积数字 | “a” | 2 | [""] | [3] |
| 5 | ’[’ | 压栈保存上下文,重置 | "" | 0 | ["",“a”] | [3,2] |
| 6 | ‘c’ | 拼入currStr | “c” | 0 | ["",“a”] | [3,2] |
| 7 | ’]’ | 弹栈:prev=“a”, times=2 → “a”+“cc”=“acc” | “acc” | 0 | [""] | [3] |
| 8 | ’]’ | 弹栈:prev="", times=3 → ""+“accaccacc”=“accaccacc” | “accaccacc” | 0 | [] | [] |
结果 = “accaccacc”
0735. 小行星碰撞 Medium
题意:行星数组,正数向右、负数向左,同方向不碰,相向碰撞时大的消灭小的、等大同时消灭,返回碰撞后剩余的行星。
核心思路:栈模拟。只有「栈顶向右 + 当前向左」才碰撞。
for _, a := range asteroids { alive := true for alive && a < 0 && len(st) > 0 && st[len(st)-1] > 0 { alive = st[len(st)-1] < -a // 当前是否存活 if st[len(st)-1] <= -a { st = st[:len(st)-1] } // 栈顶爆炸 } if alive { st = append(st, a) }}- 核心破题点:只有方向相反(右←左)才碰撞,同向或反向不碰
0739. 每日温度 Medium
题意:对每天温度,找之后第一个更高温度距当前隔几天,没有则填 0。
核心思路:单调递减栈(存下标),遇到更高温度时弹出填值。
for i := 0; i < len(T); i++ { for len(idx) > 0 && T[i] > T[idx[len(idx)-1]] { ans[idx[len(idx)-1]] = i - idx[len(idx)-1] idx = idx[:len(idx)-1] } idx = append(idx, i)}- 核心破题点:栈存「还没找到更高温度」的下标,遇到更大就弹出
graph TD
A["i=0, T[0]=73
栈空, 入栈"] --> B["i=1, T[1]=74 > 73
弹出73, ans[0]=1
入栈74"]
B --> C["i=2, T[2]=75 > 74
弹出74, ans[1]=1
入栈75"]
C --> D["i=3, T[3]=71 < 75
直接入栈"]
D --> E["i=4, T[4]=69 < 71
直接入栈"]
E --> F["i=5, T[5]=72 > 69
弹出69, ans[4]=1
72 > 71, 弹出71, ans[3]=2
72 < 75, 入栈72"]
F --> G["i=6, T[6]=76 > 72
弹出72, ans[5]=1
76 > 75, 弹出75, ans[2]=4
入栈76"]
style A fill: #e8f5e9, color: #1a1a1a
style G fill: #fff9c4, color: #1a1a1a2390. 从字符串中移除星号 Medium
题意:字符串中星号 * 删除左侧最近的非星号字符,返回最终字符串。
核心思路:栈模拟——遇到 * 弹出栈顶,遇到字母压栈。
func removeStars(s string) string { var res []rune for _, c := range s { if c != '*' { res = append(res, c) // 字母入栈 } else { res = res[:len(res)-1] // 星号弹出栈顶 } } return string(res)}运行示例:s = “leet**cod*e”
| 步骤 | 字符 | 操作 | 栈状态 |
|---|---|---|---|
| 1 | l | 入栈 | l |
| 2 | e | 入栈 | le |
| 3 | e | 入栈 | lee |
| 4 | t | 入栈 | leet |
| 5 | * | 弹出t | lee |
| 6 | * | 弹出e | le |
| 7 | c | 入栈 | lec |
| 8 | o | 入栈 | leco |
| 9 | d | 入栈 | lecod |
| 10 | * | 弹出d | leco |
| 11 | e | 入栈 | lecoe |
- 核心破题点:星号删除左侧字符 = 栈的弹出操作,天然适合栈模拟
七、二叉树
树题万能套路:想清楚三件事——①当前节点做什么 ②左子树递归返回什么 ③右子树递归返回什么
graph TD
A["树题类型判断"] --> B{"要遍历整棵树?"}
B -->|是| C["DFS/BFS 遍历"]
B -->|否| D{"需要左右子树信息?"}
D -->|是| E["后序遍历
return 值给父节点"]
D -->|否| F["前序遍历
传值给子节点"]
C --> G{"需要按层?"}
G -->|是| H["BFS 层序"]
G -->|否| I["DFS 递归"]0094. 二叉树的中序遍历 Easy
func dfs(node *TreeNode, res *[]int) { if node == nil { return } dfs(node.Left, res) // 左 *res = append(*res, node.Val) // 根 dfs(node.Right, res) // 右}- 核心破题点:中序 = 左→根→右,BST 中序遍历是升序序列
0098. 验证二叉搜索树 Medium
核心思路:递归 + 范围限制。每个节点必须在 (lower, upper) 开区间内。
func helper(node *TreeNode, lower, upper int) bool { if node == nil { return true } if node.Val <= lower || node.Val >= upper { return false } return helper(node.Left, lower, node.Val) && helper(node.Right, node.Val, upper)}- 核心破题点:BST 约束是整棵子树的范围,不是仅父子比较
- 避坑指南:区间是开区间,条件用
<=和>= - ⚠️ 常见失败原因:初始上下界用
-1<<31和1<<31-1(int32 范围),但节点值范围恰好是[-2^31, 2^31-1],导致边界值被误判。应使用math.MinInt64/math.MaxInt64
0101. 对称二叉树 Easy
题意:判断一棵二叉树是否「轴对称」——即整棵树以中轴线左右镜像对称(不是判断左右子树结构相同,而是要求互为镜像)。
为什么最后要两个互换比较:判断「对称」不能直接比较左子树 == 右子树,而要比较左子树的左孩子 ↔ 右子树的右孩子(外侧对外侧)、以及左子树的右孩子 ↔ 右子树的左孩子(内侧对内侧)。这就是 check(left.Left, right.Right) 与 check(left.Right, right.Left) 的「交错」递归——把左子树「翻转」后再和右子树比。
func check(left, right *TreeNode) bool { if left == nil && right == nil { return true } if left == nil || right == nil { return false } return left.Val == right.Val && check(left.Left, right.Right) && // 交错比较 check(left.Right, right.Left)}镜像比较示意(节点值标注,箭头表示比较配对):
1 / \ 2 2 ← 比较 2 和 2 的值 / \ / \ 3 4 4 3 ← 左2的左3 ↔ 右2的右3(外侧) ← 左2的右4 ↔ 右2的左4(内侧)- 核心破题点:对称 = 左右子树互为镜像,递归时「左左配右右、左右配右左」交错比较
- 避坑指南:容易写成
check(left, right)直接比两棵子树(那是判断「相等」而非「对称」);务必把Left/Right交叉配对
0102. 二叉树的层序遍历 Medium
核心思路:BFS 用队列,或 DFS 传层级参数。
// DFS 版func level(node *TreeNode, l int) { if node == nil { return } if l >= len(res) { res = append(res, []int{}) } // 新层 res[l] = append(res[l], node.Val) level(node.Left, l+1) level(node.Right, l+1)}0104. 二叉树的最大深度 Easy
func maxDepth(root *TreeNode) int { if root == nil { return 0 } return max(maxDepth(root.Left), maxDepth(root.Right)) + 1}- 核心破题点:
max(左深度, 右深度) + 1
0105. 从前序与中序遍历序列构造二叉树 Medium
核心思路:前序第一个是根,在中序中找根位置分左右。
root := &TreeNode{Val: preorder[0]}i := indexOf(inorder, preorder[0])root.Left = buildTree(preorder[1:1+i], inorder[:i])root.Right = buildTree(preorder[1+i:], inorder[i+1:])- 核心破题点:前序定根,中序分左右
- 避坑指南:切片边界容易错,左子树前序范围
preorder[1 : 1+左子树长度]
0108. 将有序数组转换为二叉搜索树 Easy
func bst(l, h int) *TreeNode { if l > h { return nil } mid := (l + h) / 2 return &TreeNode{Val: nums[mid], Left: bst(l, mid-1), Right: bst(mid+1, h)}}- 核心破题点:取中点为根天然保证平衡
0114. 二叉树展开为链表 Medium
题意:把一棵二叉树「原地」展开成一个只有右指针的单链表,且节点顺序保持原树的先序遍历顺序(根→左→右)。要求原地修改,不能新建节点。
展开前后对照(先序遍历应为 1→2→3→4→5→6):
展开前: 展开后(仅右指针的链表): 1 1 / \ \ 2 5 2 / \ \ \3 4 6 3 \ 4 \ 5 \ 6怎么做:对当前节点 cur,若它有左子树,就:
- 找到左子树里最右的节点
pre(它是左子树先序遍历的最后一个); - 把
cur的原右子树接到pre.Right上(保住右边); - 把
cur.Right改成cur.Left(左子树接到右边),并把cur.Left置空; cur向右走一步,重复直到所有左子树被「搬」到右子树链上。
cur := rootfor cur != nil { if cur.Left != nil { pre := cur.Left for pre.Right != nil { pre = pre.Right } // 找左子树最右节点 pre.Right = cur.Right // 接上原右子树 cur.Right = cur.Left // 左变右 cur.Left = nil } cur = cur.Right}- 核心破题点:类似 Morris 遍历,把左子树插入到当前节点和右子树之间,保持先序顺序
- 避坑指南:循环条件是
cur != nil不是cur.Left != nil - ⚠️ 常见失败原因:循环条件写成
for cur.Left != nil,当当前节点无左子树时循环退出,但右子树深处可能仍有左子树需要展开
0124. 二叉树中的最大路径和 Hard
核心思路:递归返回「单边最大值」,同时在每个节点计算「完整路径」更新答案。
func dfs(root *TreeNode) int { if root == nil { return 0 } left := max(dfs(root.Left), 0) // 负分支舍弃 right := max(dfs(root.Right), 0) ans = max(ans, left+right+root.Val) // 完整路径(以当前为顶点) return max(left, right) + root.Val // 单边路径(传给父节点)}- 核心破题点:返回值和答案更新是两回事——返回单边(可拼接),答案取双边(完整路径)
- 避坑指南:负值分支用
max(x, 0)舍弃
单边 vs 双边路径示意:
graph TD
subgraph "返回值=单边(传给父节点)"
A["节点(10)"] --> B["左子:max(左,0)+10"]
A --> C["右子:max(右,0)+10"]
A --> D["返回: max(B,C)"]
end
subgraph "答案=双边(完整路径,以当前为顶点)"
E["节点(10)"] --> F["左分支"]
E --> G["右分支"]
F --> H["ans更新: 左+右+10"]
end
style D fill: #e3f2fd, color: #1a1a1a
style H fill: #c8e6c9, color: #1a1a1a蓝色 = 返回给父节点的单边路径(只能选左或右);绿色 = 在当前节点计算的双边路径(左右都走,更新全局答案)
0199. 二叉树的右视图 Medium
题意:从二叉树右侧看过去,返回每层最右节点的值(从上到下)。
核心思路:层序遍历取每层最后一个节点。
// BFS 每层最后一个就是右视图res = append(res, values[len(values)-1])0226. 翻转二叉树 Easy
root.Left, root.Right = root.Right, root.LeftinvertTree(root.Left)invertTree(root.Right)return root0230. 二叉搜索树中第 K 小的元素 Medium
核心思路:BST 中序遍历是升序,第 k 小 = 中序第 k 个。
func dfs(node *TreeNode) bool { if node == nil { return false } if dfs(node.Left) { return true } k-- if k == 0 { res = node.Val; return true } // 找到就终止 return dfs(node.Right)}0236. 二叉树的最近公共祖先 Medium
题意:在二叉树中找两个节点 p 和 q 的最近公共祖先(LCA)。
if root == nil || root == p || root == q { return root }left := lowestCommonAncestor(root.Left, p, q)right := lowestCommonAncestor(root.Right, p, q)if left != nil && right != nil { return root } // 左右都找到 → 当前是 LCAif left == nil { return right }return left- 核心破题点:p 和 q 分别在左右子树时当前节点就是 LCA
递归返回值示意(root=3, p=5, q=1):
graph TD
N3["3 (root)"] --> N5["5"]
N3 --> N1["1"]
N5 --> N6["6"]
N5 --> N2["2"]
N1 --> N0["0"]
N1 --> N8["8"]
style N5 fill: #c8e6c9, stroke: #333, color: #1a1a1a
style N1 fill: #fff9c4, stroke: #333, color: #1a1a1a
style N3 fill: #ffcdd2, stroke: #333, color: #1a1a1a| 节点 | 左返回 | 右返回 | 判断 |
|---|---|---|---|
| 6 | nil | nil | 返回 nil |
| 2 | nil | nil | 返回 nil |
| 5 | nil | nil | 5==p → 返回 5 |
| 0 | nil | nil | 返回 nil |
| 8 | nil | nil | 返回 nil |
| 1 | nil | nil | 1==q → 返回 1 |
| 3 | 5(非nil) | 1(非nil) | 左右都找到 → 3 是 LCA |
绿=p,黄=q,红=LCA。p 和 q 分别在左右子树 → 根节点就是 LCA
0437. 路径总和 III Medium
题意:二叉树里,找出路径和等于 targetSum 的路径数目。这里的「路径」不要求从根开始、也不要求到叶子结束——只要是一条从上往下、连续、不拐弯的链(任意节点起、任意节点止)即可。
前缀和思路(类比数组里的「两数和」):
- 设
s(X)= 从根到节点X沿路所有节点值之和(前缀和)。 - 若某条路径从祖先
A的子节点一直连到X,其路径和 =s(X) - s(A)(A 是 X 的某个祖先,s(A)是到 A 为止的前缀和)。 - 我们要求
s(X) - s(A) == targetSum,即s(A) == s(X) - targetSum。所以:以 X 结尾、和为 targetSum 的路径数 = 当前路径上「前缀和等于s(X)-targetSum」的祖先个数,用哈希表cnt实时统计。
为什么要回溯:cnt 只应记录「从根到当前 X 这一条路径上」出现过的前缀和。DFS 访问完 X 的左子树要转向右子树时,必须把 X 的贡献 cnt[s]-- 撤掉——否则别的兄弟分支的祖先会被错误计入。
cnt := map[int]int{0: 1} // 0:1 处理「从当前节点本身开始」的路径func dfs(node *TreeNode, s int) { s += node.Val ans += cnt[s-targetSum] // 几个祖先的前缀和 = s-targetSum,就有几条合法路径 cnt[s]++ dfs(node.Left, s); dfs(node.Right, s) cnt[s]-- // 回溯!离开本节点,撤销它对 cnt 的贡献}例子 targetSum = 8,树:10 / \ 5 -3 / \ \ 3 2 11 / \ 3 -2 1
到节点
3(左子树,路径 10→5→3,前缀和s=18):cnt中找18-8=10,根节点前缀和正好是 10 → 计 1 条(路径 5→3,和 8)。到叶子
3(10→5→3→3,s=21):找21-8=13,曾经出现 13(根10+左5)→ +1(路径 5→3→3,和 8)。到
11(前缀和10-3+11=18):找18-8=10→ +1(路径 -3→11,和 8)。等等。最终合法路径共 3 条。核心破题点:前缀和差 = targetSum;哈希表存祖先前缀和;回溯撤销计数防止跨子树误计
避坑指南:
cnt初始放{0:1}是为了覆盖「路径从当前节点自身开始」的情况;忘记回溯会让不同分支的祖先互相串扰
0543. 二叉树的直径 Easy
核心思路:经过每个节点的最长路径 = 左深度 + 右深度。
func depth(node *TreeNode) int { if node == nil { return 0 } l := depth(node.Left); r := depth(node.Right) ans = max(ans, l+r) // 更新直径 return max(l, r) + 1 // 返回深度}- 核心破题点:直径不一定过根节点,每个节点都要算
0872. 叶子相似的树 Easy
func find(cur *TreeNode) { if cur == nil { return } if cur.Left == nil && cur.Right == nil { leaf = append(leaf, cur.Val); return } find(cur.Left); find(cur.Right)}1161. 最大层内元素和 Medium
题意:给定一棵二叉树,返回「层内节点值之和最大」的那一层的层号(root 是第 0 层)。若有多个层的和相同且都是最大,返回最小的层号。
核心思路:BFS 层序遍历,每弹出一整层时把该层所有节点值求和,记录最大值及其层号。
func maxLevelSum(root *TreeNode) int { q := []*TreeNode{root} bestLevel, bestSum := 0, root.Val level := 0 for len(q) > 0 { size := len(q) sum := 0 for i := 0; i < size; i++ { // 一次性处理当前层 node := q[0]; q = q[1:] sum += node.Val if node.Left != nil { q = append(q, node.Left) } if node.Right != nil { q = append(q, node.Right) } } if sum > bestSum { bestSum = sum; bestLevel = level } // 用 > 保证并列取最小层号 level++ } return bestLevel}例子 树:
1 (层0, 和=1) / \ 7 0 (层1, 和=7) / \ 7 -8 (层2, 和=7-8=-1)层0=1,层1=7,层2=-1 → 最大层内和为 7,出现在层 1,返回 1。
- 核心破题点:BFS 按层处理,
size控制「一次只取一整层」再求和 - 避坑指南:比较用严格
>而非>=,并列时才返回最小层号;初始bestSum要设为第 0 层的值而非 0(否则全负数会错)
1372. 二叉树中的最长交错路径 Medium
核心思路:DFS 记录方向和长度,同方向重置为 1,交替方向 +1。
func dfs(node *TreeNode, toLeft bool, length int) { if node == nil { return } ans = max(ans, length) if toLeft { dfs(node.Right, false, length+1) // 交替 dfs(node.Left, true, 1) // 同方向重置 } else { dfs(node.Left, true, length+1) dfs(node.Right, false, 1) }}1448. 统计二叉树中好节点的数目 Medium
题意:从根到该节点路径上没有比它更大的值则为好节点,统计好节点总数。
func dfs(node *TreeNode, curmax int) { if node == nil { return } if node.Val >= curmax { curmax = node.Val; ans++ } // >= 等值也是好节点 dfs(node.Left, curmax); dfs(node.Right, curmax)}八、图与 BFS/DFS
图题万能套路:建邻接表 → DFS/BFS 遍历 → visited 数组防重复
0200. 岛屿数量 Medium
核心思路:遍历网格,遇到 ‘1’ 计数 +1,DFS 把连通的陆地全改成 ‘0’(沉岛法)。
func find(r, c int) { if r < 0 || r >= m || c < 0 || c >= n || grid[r][c] == '0' { return } grid[r][c] = '0' // 沉岛 find(r-1, c); find(r+1, c); find(r, c-1); find(r, c+1)}// 主循环if grid[i][j] == '1' { ans++; find(i, j) }- 核心破题点:沉岛法——原地修改网格代替 visited 数组
- 避坑指南:
grid[r][c] = '0'必须在四向递归之前
网格可视化(4×5 网格,2 个岛屿):
执行流程:
- 遍历到 (0,0)=‘1’ → ans=1,DFS 沉岛 → (0,0)(0,1)(1,1)(1,2)(2,2) 全改 ‘0’
- 遍历到 (2,4)=‘1’ → ans=2,DFS 沉岛 → (2,4)(3,4) 全改 ‘0’
- 剩余全是 ‘0’ → 结果 = 2
0207. 课程表 Medium
题意:共有 numCourses 门课(编号 0..n-1),prerequisites 里每项 [a, b] 表示「要修课 a 必须先修完课 b」。问:是否能按顺序修完所有课(即不存在「先修依赖环」)。
如何转换成图:每门课是一个节点,依赖关系是一条有向边。关键在边的方向——[a, b] 表示「b 是 a 的先修」,即「先有 b 才能到 a」,所以边是 b → a(从先修课指向后续课)。构建邻接表:
graph := make([][]int, numCourses)for _, p := range prerequisites { a, b := p[0], p[1] // 修 a 需要先修 b graph[b] = append(graph[b], a) // 边:b → a}核心思路:拓扑排序检测有向图是否有环。三色标记法:0=未访问,1=访问中,2=已完成。
func dfs(cur int) bool { // 返回 true 表示有环 if status[cur] == 1 { return true } // 访问中遇到 → 有环 if status[cur] == 2 { return false } // 已完成 → 无环 status[cur] = 1 for _, next := range graph[cur] { if dfs(next) { return true } } status[cur] = 2 return false}- 核心破题点:遇到「访问中」的节点说明存在环
- 避坑指南:status 设为 1 要在递归之前,设为 2 要在递归之后
如何建边的直观对照:
| prerequisites 项 | 含义 | 有向边 |
|---|---|---|
[1, 0] | 修 1 前先修 0 | 0 → 1 |
[2, 1] | 修 2 前先修 1 | 1 → 2 |
[3, 2] | 修 3 前先修 2 | 2 → 3 |
[1, 3] | 修 1 前先修 3 | 3 → 1 |
执行流程(numCourses=4, prerequisites=[[1,0],[2,1],[3,2],[1,3]]):
图结构:0→1→2→3→1(存在环 1→2→3→1)
| 步骤 | DFS节点 | status变化 | 邻居 | 结果 |
|---|---|---|---|---|
| 1 | 0 | 0→1 | [1] | 递归1 |
| 2 | 1 | 0→1 | [2,3] | 递归2 |
| 3 | 2 | 0→1 | [3] | 递归3 |
| 4 | 3 | 0→1 | [1] | 递归1 |
| 5 | 1 | status==1! | - | ⚠️ 遇到访问中 → 有环! |
graph TD
A["未访问 (0)"] -->|" 开始 DFS "| B["访问中 (1)"]
B -->|" 遇到访问中节点 "| C["有环! 返回 false"]
B -->|" 所有邻居完成 "| D["已完成 (2)"]
D --> E["无环, 返回 true"]
style C fill: #f99, color: #1a1a1a
style E fill: #9f9, color: #1a1a1a0547. 省份数量 Medium
核心思路:DFS 求连通分量数量。
func dfs(from int) { vis[from] = true for to, conn := range isConnected[from] { if conn == 1 && !vis[to] { dfs(to) } }}for i := range vis { if !vis[i] { ans++; dfs(i) } }0841. 钥匙和房间 Medium
题意:有 n 个房间,编号 0..n-1。一开始只有 0 号房间是开着的。每个房间 i 里放着一串钥匙 rooms[i](一组房间号),拿到钥匙就能打开对应的房间。问:能否从 0 出发,打开并进入所有房间?
核心思路:把「房间」当节点、「钥匙能开的房间」当出边,这本质是从 0 出发的图遍历。只要从 0 做 DFS/BFS 能覆盖全部节点,就说明能进所有房间。
func canVisitAllRooms(rooms [][]int) bool { n := len(rooms) visited := make([]bool, n) var dfs func(int) dfs = func(u int) { visited[u] = true for _, v := range rooms[u] { // rooms[u] 是房间 u 里钥匙能开的房间 if !visited[v] { dfs(v) } } } dfs(0) for _, ok := range visited { // 检查是否每个房间都进过 if !ok { return false } } return true}例子 rooms = [[1],[2],[3],[]]:
- 0 号房有钥匙
[1]→ 打开 1 - 1 号房有钥匙
[2]→ 打开 2 - 2 号房有钥匙
[3]→ 打开 3 - 3 号房空
访问顺序 0→1→2→3,全部 4 个房间都进过 → 返回 true。
若 rooms = [[1],[2],[],[1]](房间 0 只能到 1、1 到 2,但 3 谁也开不了)→ 漏掉房间 3 → 返回 false。
- 核心破题点:钥匙即邻接边,问题等价于「从 0 出发能否遍历全图」
- 避坑指南:
visited标记要在进入节点时设置,避免同一房间被重复入栈;别漏掉最后「是否全访问」的判定
0994. 腐烂的橘子 Medium
题意:网格中腐烂橘子每分钟感染上下左右四邻的新鲜橘子,求全部腐烂的最少分钟数,不可能则返回 -1。
核心思路:多源 BFS,每轮同时感染。必须先标记再统一更新,避免本轮新腐烂的继续感染。
- 核心破题点:多源 BFS = 所有初始腐烂橘子同时入队,逐层扩展
- 避坑指南:用标记数组先记录再统一更新
0649. Dota2 参议院 Medium
核心思路:双队列模拟。下标小的先出手,胜者下标 +n 进入下一轮。
for len(radiant) > 0 && len(dire) > 0 { if radiant[0] < dire[0] { radiant = append(radiant, radiant[0]+n) // 进入下一轮 } else { dire = append(dire, dire[0]+n) } radiant = radiant[1:]; dire = dire[1:]}- 核心破题点:用下标比较决定谁先出手,胜者加 n 入队表示进入下一轮
- ⚠️ 常见失败原因:仅统计 R 和 D 的数量取多数获胜,完全忽略 banning 的顺序策略。如
"DDRRR"中 D=2<R=3 但 D 方先手连续禁令 R 方,实际 D 方获胜
3310. 移除可疑的方法 Medium
题意:方法 k 有 bug,需删除 k 及其直接/间接调用的所有方法。但只有当这组方法没有被外部调用时才能删除。
核心思路:①DFS 标记 k 可达的所有可疑方法 ②检查是否存在「非可疑 → 可疑」的调用边 ③无外部调用则删除,否则全部保留。
func remainingMethods(n int, k int, invocations [][]int) []int { graph := make([][]int, n) // 建邻接表 for _, edge := range invocations { graph[edge[0]] = append(graph[edge[0]], edge[1]) }
isFault := make([]bool, n) // 可疑标记 var dfs func(int) dfs = func(cur int) { if isFault[cur] { return } isFault[cur] = true for _, next := range graph[cur] { dfs(next) } // 标记所有调用链 } dfs(k) // 从 k 开始标记
canRemove := true for _, edge := range invocations { // 检查外部调用 if !isFault[edge[0]] && isFault[edge[1]] { canRemove = false; break } // 外部→可疑 }
var res []int if !canRemove { // 不能删,返回全部 for i := 0; i < n; i++ { res = append(res, i) } } else { // 可以删,返回非可疑 for i := 0; i < n; i++ { if !isFault[i] { res = append(res, i) } } } return res}运行示例:n=5, k=0, invocations=[[1,2],[0,2],[0,1],[3,4]]
调用图:0→2, 0→1, 1→2, 3→4可疑集合(从0可达):{0, 1, 2}检查外部调用:3→4(3非可疑,4非可疑)→ 无外部→可疑的边可以删除!返回 [3, 4]graph TD
A["DFS 从 k 出发
标记可疑集合"] --> B["遍历所有调用边"]
B --> C{"存在 非可疑→可疑 的边?"}
C -->|是| D["不能删除
返回全部方法"]
C -->|否| E["可以删除
返回非可疑方法"]
style D fill: #ffcdd2, color: #1a1a1a
style E fill: #c8e6c9, color: #1a1a1a- 核心破题点:不是有环就不能删,而是有「外部节点调用可疑节点」就不能删
- 避坑指南:DFS 标记和外部调用检查是两个独立步骤,缺一不可
九、动态规划
DP 解题四步走:①定义状态 ②写转移方程 ③初始化 ④确定遍历顺序
0005. 最长回文子串 Medium
核心思路:中心扩展法。每个位置分奇偶两种情况向外扩展。
for i := 0; i < n; i++ { // aba 型(奇数长度) for l, r := i-1, i+1; l >= 0 && r < n && s[l] == s[r]; l, r = l-1, r+1 {} // abba 型(偶数长度) for l, r := i, i+1; l >= 0 && r < n && s[l] == s[r]; l, r = l-1, r+1 {}}- 核心破题点:回文中心可能是 1 个字符(奇)或 2 个字符(偶)
0042. 接雨水 Hard(DP 解法)
核心思路:位置 i 的雨水量 = min(左边最高墙, 右边最高墙) - height[i]。先预处理出每个位置左侧、右侧的最大值数组,再逐个位置求和。
n := len(height)// 1. 预处理:每个位置左侧(含自己)的最大高度leftMax := make([]int, n)leftMax[0] = height[0]for i := 1; i < n; i++ { leftMax[i] = max(leftMax[i-1], height[i]) }// 2. 预处理:每个位置右侧(含自己)的最大高度rightMax := make([]int, n)rightMax[n-1] = height[n-1]for i := n-2; i >= 0; i-- { rightMax[i] = max(rightMax[i+1], height[i]) }// 3. 累加每个位置能接的雨水res := 0for i := 0; i < n; i++ { res += max(0, min(leftMax[i], rightMax[i]) - height[i]) }运行示例(height = [0,1,0,2,1,0,1,3,2,1,2,1]):
| i | height | leftMax | rightMax | min(l,r) | 雨水 = min-height |
|---|---|---|---|---|---|
| 0 | 0 | 0 | 3 | 0 | 0 |
| 1 | 1 | 1 | 3 | 1 | 0 |
| 2 | 0 | 1 | 3 | 1 | 1 |
| 3 | 2 | 2 | 3 | 2 | 0 |
| 4 | 1 | 2 | 3 | 2 | 1 |
| 5 | 0 | 2 | 3 | 2 | 2 |
| 6 | 1 | 2 | 3 | 2 | 1 |
| 7 | 3 | 3 | 3 | 3 | 0 |
| 8 | 2 | 3 | 2 | 2 | 0 |
| 9 | 1 | 3 | 2 | 2 | 1 |
| 10 | 2 | 3 | 2 | 2 | 0 |
| 11 | 1 | 3 | 1 | 1 | 0 |
总雨水 = 1+1+2+1+1 = 6(位置 8、10、11 的 rightMax 比 leftMax 小,体现「右侧矮」)
- 时间 O(n),空间 O(n);也可用双指针 O(1) 空间(见双指针章节)
0045. 跳跃游戏 II Medium
题意:数组每个元素表示最大跳跃步数,求到末尾的最少跳跃次数。
核心思路:贪心 + BFS 层次思想。维护当前跳跃边界和最远可达。
for i := 0; i < n; i++ { pos = max(pos, nums[i]+i) if i == end { end = pos; ans++; if end >= n-1 { break } }}- 核心破题点:把跳跃看作 BFS 的层次遍历,到达边界就跳一步
- ⚠️ 常见失败原因:当
n=1(只有一个元素)时,i==end立即触发ans++返回 1,但正确答案是 0(已在终点无需跳跃)。应加if n == 1 { return 0 }特判
0053. 最大子数组和 Medium
题意:找连续子数组使其和最大,返回最大和。
核心思路:dp[i] = max(nums[i], dp[i-1]+nums[i]),前缀和为负就抛弃。
res := nums[0]for i := 1; i < n; i++ { if nums[i-1] > 0 { nums[i] += nums[i-1] } // 原地 DP res = max(res, nums[i])}- 核心破题点:以 i 结尾的最大和 = max(自己, 自己 + 前面最大和)
- 避坑指南:res 初始为
nums[0]而非 0(数组可能全负)
执行流程(nums = [-2,1,-3,4,-1,2,1,-5,4]):
| i | nums[i]原始 | 前面和>0? | nums[i]更新后 | res |
|---|---|---|---|---|
| 0 | -2 | - | -2 | -2 |
| 1 | 1 | -2≤0, 不加 | 1 | 1 |
| 2 | -3 | 1>0, 加 | 1+(-3)=-2 | 1 |
| 3 | 4 | -2≤0, 不加 | 4 | 4 |
| 4 | -1 | 4>0, 加 | 4+(-1)=3 | 4 |
| 5 | 2 | 3>0, 加 | 3+2=5 | 5 |
| 6 | 1 | 5>0, 加 | 5+1=6 | 6 ✓max |
| 7 | -5 | 6>0, 加 | 6+(-5)=1 | 6 |
| 8 | 4 | 1>0, 加 | 1+4=5 | 6 |
结果 = 6(子数组 [4,-1,2,1])。前缀和为正就延续,为负就从头开始
0062. 不同路径 Medium
题意:m×n 网格从左上到右下有多少条路径,只能向右或向下走。
for i := 0; i < m; i++ { dp[i][0] = 1 }for j := 0; j < n; j++ { dp[0][j] = 1 }for i := 1; i < m; i++ { for j := 1; j < n; j++ { dp[i][j] = dp[i-1][j] + dp[i][j-1] }}- 核心破题点:首行首列只有一条路径
0064. 最小路径和 Medium
题意:m×n 网格从左上到右下的路径最小和,只能向右或向下走。
// 原地修改for i := 1; i < m; i++ { grid[i][0] += grid[i-1][0] }for j := 1; j < n; j++ { grid[0][j] += grid[0][j-1] }for i := 1; i < m; i++ { for j := 1; j < n; j++ { grid[i][j] += min(grid[i-1][j], grid[i][j-1]) }}0070. 爬楼梯 Easy
题意:每次爬 1 或 2 阶,爬到第 n 阶有多少种方法。
p, q := 1, 2 // f(1)=1, f(2)=2for i := 3; i <= n; i++ { p, q = q, p+q }return q- 核心破题点:本质是斐波那契,用滚动变量省空间
0072. 编辑距离 Medium
题意:将 word1 变成 word2 的最少操作次数(插入/删除/替换一个字符)。
核心思路:dp[i][j] = word1 前 i 个变成 word2 前 j 个的最少操作数。
// 初始化: dp[i][0]=i, dp[0][j]=jif word1[i-1] == word2[j-1] { dp[i][j] = dp[i-1][j-1] } // 相同不用操作else { dp[i][j] = min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]) + 1 } // 删/插/替换- 核心破题点:dp 维度是 (m+1)×(n+1),第 0 行/列表示空串
- 避坑指南:不要用 m×n 的 dp 数组,边界处理会出错
DP 状态表(word1=“horse”, word2=“ros”):
| ∅ | r | o | s | |
| ∅ | 0 | 1 | 2 | 3 |
| h | 1 | 1 (替换h→r) | 2 | 3 |
| o | 2 | 2 | 1 (o==o,继承) | 2 |
| r | 3 | 2 (r==r,继承) | 2 | 3 |
| s | 4 | 3 | 3 | 2 (s==s,继承) |
| e | 5 | 4 | 4 | 3 (删除e) |
绿色 = 字符相同直接继承;黄色 = 替换操作;红色 = 最终答案。结果 = 3(horse → rorse → rose → ros)
graph TD
A["word1[i-1] == word2[j-1]"] -->|是| B["dp[i][j] = dp[i-1][j-1]"]
A -->|否| C["三种操作取最小+1"]
C --> D["删: dp[i-1][j] + 1"]
C --> E["插: dp[i][j-1] + 1"]
C --> F["替换: dp[i-1][j-1] + 1"]- ⚠️ 常见失败原因:第一行/列初始化用
mark累加器,找到匹配字符后就不再递增,导致后续删除/插入代价被忽略。应直接令dp[i][0]=i,dp[0][j]=j
0097. 交错字符串 Medium
题意:判断 s3 是否由 s1 和 s2 交错组成(保持各字符串内部相对顺序)。
核心思路:dp[i][j] = s1 前 i 个和 s2 前 j 个能否交错组成 s3 前 i+j 个。
if s1[i-1] == s3[i+j-1] { dp[i][j] = dp[i-1][j] }if s2[j-1] == s3[i+j-1] { dp[i][j] = dp[i][j] || dp[i][j-1] } // 注意 ||- 核心破题点:最后一步来自 s1 还是 s2,两种情况是「或」关系
0118. 杨辉三角 Easy
ans[i][0], ans[i][i] = 1, 1for j := 1; j < i; j++ { ans[i][j] = ans[i-1][j] + ans[i-1][j-1] }0121. 买卖股票的最佳时机 Easy
题意:只能买卖一次,求最大利润。
minPrice := math.MaxInt32; ans := 0for _, p := range prices { minPrice = min(minPrice, p) ans = max(ans, p - minPrice)}- 核心破题点:维护前缀最小值,每天尝试卖出
0139. 单词拆分 Medium
题意:判断字符串 s 能否由字典 wordDict 中的单词拼接而成。
dp := make([]bool, n+1); dp[0] = truefor i := 1; i <= n; i++ { for _, word := range wordDict { l := len(word) if l <= i && dp[i-l] && s[i-l:i] == word { dp[i] = true; break } }}- 核心破题点:
dp[0] = true是递推起点
0152. 乘积最大子数组 Medium
核心思路:同时维护最大值和最小值(负数 × 负数 = 正数)。
mem[i] = max(mem[i-1]*nums[i], nums[i], mim[i-1]*nums[i]) // 最大mim[i] = min(mem[i-1]*nums[i], nums[i], mim[i-1]*nums[i]) // 最小ans = max(ans, mem[i])- 核心破题点:负数让最小变最大,所以必须同时维护 max 和 min
- 避坑指南:ans 初始为
nums[0] - ⚠️ 常见失败原因:
ans初始化为-10而非nums[0],当数组长度为 1 时循环不执行,直接返回 -10。即使数组更长,若nums[0]是最大乘积也会返回错误值
0198. 打家劫舍 Medium
题意:相邻房屋不能同时偷,求能偷到的最大金额。
prev2 := nums[0]; prev1 := max(nums[0], nums[1])for i := 2; i < n; i++ { current := max(prev2+nums[i], prev1) // 偷 or 不偷 prev2 = prev1; prev1 = current}- 核心破题点:偷当前家 + 前 i-2 的收益,或不偷取前 i-1 的收益
- 避坑指南:prev1 初始是
max(nums[0], nums[1])不是nums[1]
执行流程(nums = [2,7,9,3,1]):
| i | nums[i] | 偷(i): prev2+nums[i] | 不偷(i): prev1 | current | 说明 |
|---|---|---|---|---|---|
| 0 | 2 | - | - | 2 | 初始 prev2 |
| 1 | 7 | - | - | 7 | prev1=max(2,7) |
| 2 | 9 | 2+9=11 | 7 | 11 | 偷2+9 > 不偷7 |
| 3 | 3 | 7+3=10 | 11 | 11 | 不偷(11) > 偷(10) |
| 4 | 1 | 11+1=12 | 11 | 12 | 偷11+1 > 不偷11 |
结果 = 12(偷第0,2,4家:2+9+1=12)
- ⚠️ 常见失败原因:
prev1初始化为nums[1]而非max(nums[0], nums[1]),当nums[0] > nums[1]时(如[2,1,1,2])取了较小值导致后续全错
0279. 完全平方数 Medium
题意:给你一个正整数 n,问最少需要多少个完全平方数(1, 4, 9, 16, …)相加能得到 n。(如 n=12 → 4+4+4 用 3 个;n=13 → 4+9 用 2 个)
如何转为完全背包:把每个完全平方数 j*j 看成一种「硬币」,面值就是 j*j;n 看成要凑的「金额」。因为同一个平方数可以用任意多次(比如 12 用了三次 4),这正是完全背包(硬币可无限取)。目标是「用最少的硬币数」凑出金额 n。
dp[i]= 凑出金额i最少需要的完全平方数个数- 转移:对每个可用平方数
j*j ≤ i,dp[i] = min(dp[i], dp[i - j*j] + 1) - 为什么是完全背包:外层
i从小到大、内层枚举平方数,计算dp[i]时dp[i - j*j]已经包含「用过该平方数」的情况,于是同一平方数可重复累加 → 等价于硬币无限取。若改成内层i逆序就是 0-1 背包(每种只能用一次)。
dp := make([]int, n+1); dp[0] = 0for i := 1; i <= n; i++ { dp[i] = n // 初始化为最大值(最坏每个 1 累加) for j := 1; j*j <= i; j++ { dp[i] = min(dp[i], dp[i-j*j]+1) }}例子 n = 12:
dp[4] = 1(用一个 4);dp[8] = 2(4+4);dp[12] = min(..., dp[12-4]+1=dp[8]+1=3)→ 3 个(4+4+4)。dp[12]=3。核心破题点:完全平方数 = 可无限使用的硬币,目标是「最少个数」→ 完全背包求最小值
避坑指南:
dp[0]=0是基石(金额 0 需 0 个硬币);初始化dp[i]用足够大的值(如n)而非 0,否则min永远取 0
0300. 最长递增子序列 Medium
题意:找最长严格递增子序列的长度。
dp[i] = 1 // 初始化for j := 0; j < i; j++ { if nums[i] > nums[j] { dp[i] = max(dp[i], dp[j]+1) }}ans = max(dp...) // 答案是 dp 数组最大值,不是 dp[n-1]- 核心破题点:dp[i] 定义为「以 nums[i] 结尾」的 LIS,不是「前 i 个」
- 避坑指南:答案是 dp 数组最大值;进阶用二分 + 贪心做到 O(n log n)
执行流程(nums = [10,9,2,5,3,7,101,18]):
| i | nums[i] | 检查所有 j<i | dp[i] | 说明 |
|---|---|---|---|---|
| 0 | 10 | 无 | 1 | 初始 |
| 1 | 9 | 9<10 跳过 | 1 | 没有比9小的前驱 |
| 2 | 2 | 2<10, 2<9 跳过 | 1 | 没有比2小的前驱 |
| 3 | 5 | 5>2→dp[2]+1=2 | 2 | [2,5] |
| 4 | 3 | 3>2→dp[2]+1=2 | 2 | [2,3] |
| 5 | 7 | 7>2→2, 7>5→3, 7>3→3 | 3 | [2,5,7]或[2,3,7] |
| 6 | 101 | 101>所有→取dp[5]+1=4 | 4 | [2,5,7,101] |
| 7 | 18 | 18>2→2, 18>5→3, 18>3→3, 18>7→4 | 4 | [2,5,7,18] |
结果 = 4(LIS = [2,5,7,101] 或 [2,5,7,18])
0322. 零钱兑换 Medium
题意:给定不同面额的硬币 coins 和一个总金额 amount,计算凑成总金额所需的最少硬币个数。每种硬币可以无限次使用。若无法凑出则返回 -1。
如何转为完全背包:把 coins 看作「物品」,每个物品有重量=面值、价值=1(用一个硬币计 1 次);amount 是「背包容量」。因为每种硬币能用任意多次,这正是完全背包。目标是「装满容量为 amount 的背包,最少价值(最少硬币数)」。
dp[i]= 凑出金额i的最少硬币数- 转移:
dp[i] = min(dp[i], dp[i - coin] + 1),对每个coin ≤ i - 为什么是完全背包:外层
i从小到大遍历金额,计算dp[i]时dp[i-coin]已含「用过多枚该 coin」的状态,所以同一面额可重复取 → 硬币无限用。若内层i改成逆序,就成了 0-1 背包(每种硬币仅一次)。
dp := make([]int, amount+1); dp[0] = 0for i := 1; i <= amount; i++ { dp[i] = amount + 1 // 不可达标记(比最大可能硬币数还多) for _, coin := range coins { if coin <= i { dp[i] = min(dp[i], dp[i-coin]+1) } }}if dp[amount] > amount { return -1 } // 仍不可达 → 凑不出例子 coins = [1,2,5], amount = 11:
dp[5]=1(一个 5);dp[10]=2(两个 5);dp[11] = min(..., dp[11-1]+1=dp[10]+1=3, dp[11-5]+1=dp[6]+1=...)→ 最少 3 个(5+5+1 或 5+2+2+2? 那是4,所以取 5+5+1=3)。dp[11]=3。核心破题点:完全背包,每个硬币可无限使用,求最少个数 →
dp取 min避坑指南:不可达时返回 -1(用
amount+1做哨兵);初始化值不能太大以免+1溢出;求「最少」用min、求「最多」才用max
0416. 分割等和子集 Medium
题意:能否把一个数组 nums 分成两个子集,使两个子集的元素之和相等。
如何转为 0-1 背包:
- 先判总和
sum:若sum为奇数,根本无法平分 → 直接false;否则目标target = sum/2。 - 问题变成:能否从数组中选出若干个数,使它们的和恰好等于
target。这正是 0-1 背包——每个数是「一件物品」,重量=数值、价值=数值;背包容量=target;问能否恰好装满。每个数只能用一次(0-1 背包核心)。 dp[j]表示「能否凑出和为j」。对当前数num,要么不选(dp[j]不变),要么选(dp[j-num]为真则dp[j]变真):dp[j] = dp[j] || dp[j-num]。
为什么内层要逆序(从 target 倒着到 num):
- 若正序遍历
j,计算dp[j]时用到的dp[j-num]已经是本轮刚更新过的值(已经把当前num算进去了),于是同一个num会被反复累加 → 退化成「完全背包」(每种数能用无限次),错误。 - 逆序遍历能保证
dp[j-num]仍是上一轮(还没考虑当前num)的状态,从而保证每个num在本轮最多参与一次选择 → 正确的 0-1 背包。
target := sum / 2dp := make([]bool, target+1); dp[0] = true // 和为 0 必然可达for _, num := range nums { for j := target; j >= num; j-- { // 逆序!保证每个数只用一次 dp[j] = dp[j] || dp[j-num] } if dp[target] { return true } // 提前命中可剪枝}例子 nums = [1,5,11,5],sum=22,target=11:
处理 1:
dp[1]=true处理 5:
dp[5]=true, dp[6]=true(1+5)处理 11:
dp[11]=true→ 找到和为 11 的子集(单个 11)→ 返回true(另一半 [1,5,5] 和也是 11)核心破题点:等和分割 = 能否用 0-1 背包「恰好凑出 sum/2」
避坑指南:sum 为奇数直接 false;内层必须逆序防重复选同一元素;
dp[0]=true是初始化基石
0746. 使用最小花费爬楼梯 Easy
dp := make([]int, n+1) // dp[0]=dp[1]=0for i := 2; i <= n; i++ { dp[i] = min(dp[i-1]+cost[i-1], dp[i-2]+cost[i-2]) }- 核心破题点:dp 长度 n+1,楼顶在数组之后一位
1137. 第 N 个泰波那契数 Easy
t0, t1, t2 := 0, 1, 1for i := 3; i <= n; i++ { t0, t1, t2 = t1, t2, t0+t1+t2 }return t2- ⚠️ 常见失败原因:当
n=0/1/2时循环条件i:=3; i<=n不满足,ans保持初始值 0 被返回。但T(1)=1, T(2)=1,需特判或正确初始化基例
1143. 最长公共子序列 Medium
// dp[i][j] = text1 前 i 个与 text2 前 j 个的 LCSif text1[i-1] == text2[j-1] { dp[i][j] = dp[i-1][j-1] + 1 }else { dp[i][j] = max(dp[i-1][j], dp[i][j-1]) }- 核心破题点:相同取对角线 +1,不同取上/左最大值
- ⚠️ 常见失败原因:匹配时用
max(dp[i+1][j], dp[i][j+1], dp[i+1][j+1]) + 1而非dp[i+1][j+1] + 1,导致重复计数——同一个字符被算了两次
十、回溯
回溯万能模板:做选择 → 递归 → 撤销选择
func backtrack(路径, 选择列表) { if 满足结束条件 { 结果.add(路径的拷贝); return } for 选择 in 选择列表 { 做选择 backtrack(路径, 选择列表) 撤销选择 }}0017. 电话号码的字母组合 Medium
maps := map[string]string{"2":"abc","3":"def",...}fun = func(idx int) { if idx == len(digits) { ans = append(ans, tmp); return } for _, ch := range maps[string(digits[idx])] { tmp += string(ch); fun(idx+1); tmp = tmp[:len(tmp)-1] // 回溯 }}0022. 括号生成 Medium
核心思路:左括号数 >= 右括号数,剩余数相等时只能加左括号。
fun = func(left, right int, tmp string) { if right == 0 { ans = append(ans, tmp); return } if left == right { fun(left-1, right, tmp+"(") } // 只能加左 else { if left > 0 { fun(left-1, right, tmp+"(") } // 可以加左 fun(left, right-1, tmp+")") // 可以加右 }}- 核心破题点:合法括号序列中任意前缀左括号数 >= 右括号数
0039. 组合总和 Medium
核心思路:选/不选模式。选了可以继续选同一个(可重复),不选跳到下一个。
if rest-num >= 0 { tem = append(tem, num) if rest-num == 0 { ans = append(ans, append([]int{}, tem...)) } else { find(cand, rest-num) } // 继续选当前 tem = tem[:len(tem)-1] // 回溯}find(cand[1:], rest) // 不选当前- 核心破题点:选了之后递归仍传
cand(而非cand[1:])实现重复选取 - 避坑指南:保存结果必须深拷贝
回溯树(candidates = [2,3,6,7], target = 7):
graph TD
R["rest=7"] --> A2["选2 rest=5"]
R --> A3["选3 rest=4"]
R --> A6["选6 rest=1"]
R --> A7["选7 rest=0 ✅ [7]"]
A2 --> B2["选2 rest=3"]
A2 --> B3["选3 rest=2"]
A2 --> B6["选6 rest=-1 ✂剪枝"]
B2 --> C2["选2 rest=1"]
B2 --> C3["选3 rest=0 ✅ [2,2,3]"]
C2 --> D2["选2 rest=-1 ✂剪枝"]
C2 --> D3["选3 rest=-2 ✂剪枝"]
A3 --> E3["选3 rest=1"]
E3 --> F3["选3 rest=-2 ✂剪枝"]
E3 --> F6["选6 rest=-5 ✂剪枝"]
style A7 fill: #c8e6c9, color: #1a1a1a
style C3 fill: #c8e6c9, color: #1a1a1a绿色 = 找到有效组合。结果 = [[7], [2,2,3]]
0046. 全排列 Medium
题意:给定无重复数字 nums = [1,2,3],返回所有全排列。
核心思路:回溯——选一个未使用的数字放入 path,递归到底后撤销选择。
for i, b := range onPath { if !b { path = append(path, nums[i]); onPath[i] = true t() // 递归 onPath[i] = false; path = path[:len(path)-1] // 回溯 }}- 核心破题点:
onPath布尔数组标记已选元素,回溯时撤销 - 避坑指南:
path = path[:len(path)-1]回溯弹出,不能遗漏
回溯决策树(nums = [1,2,3]):
graph TD
R["开始 path=[]"] --> A1["选1 path=[1]"]
R --> A2["选2 path=[2]"]
R --> A3["选3 path=[3]"]
A1 --> B1["选2 path=[1,2]"]
A1 --> B2["选3 path=[1,3]"]
A2 --> B3["选1 path=[2,1]"]
A2 --> B4["选3 path=[2,3]"]
A3 --> B5["选1 path=[3,1]"]
A3 --> B6["选2 path=[3,2]"]
B1 --> C1["选3 path=[1,2,3] ✅"]
B2 --> C2["选2 path=[1,3,2] ✅"]
B3 --> C3["选3 path=[2,1,3] ✅"]
B4 --> C4["选1 path=[2,3,1] ✅"]
B5 --> C5["选2 path=[3,1,2] ✅"]
B6 --> C6["选1 path=[3,2,1] ✅"]
style C1 fill: #c8e6c9, color: #1a1a1a
style C2 fill: #c8e6c9, color: #1a1a1a
style C3 fill: #c8e6c9, color: #1a1a1a
style C4 fill: #c8e6c9, color: #1a1a1a
style C5 fill: #c8e6c9, color: #1a1a1a
style C6 fill: #c8e6c9, color: #1a1a1a每层选一个未用过的数字,到达叶子节点即一个完整排列。共 3!=6 种。
0051. N 皇后 Hard
核心思路:逐行放置,用三个数组标记列和对角线。
if !col[i] && !diag1[idx-i+n-1] && !diag2[idx+i] { col[i], diag1[idx-i+n-1], diag2[idx+i] = true, true, true find(idx + 1) // 递归下一行 col[i], diag1[idx-i+n-1], diag2[idx+i] = false, false, false // 回溯}- 核心破题点:用
row-col和row+col标识两条对角线 - 避坑指南:主对角线索引
+n-1偏移避免负数
对角线标记示意(N=4,放置在第0行第1列):
行col: 0 1 2 3 0: . Q . . ← 放置(0,1) 1: x . . x ← diag1标记 / diag2标记 2: . x . . 3: . . x .| 标记类型 | 公式 | (0,1)的值 | 被标记的位置 |
|---|---|---|---|
| 列 col | col=1 | 1 | (0,1)(1,1)(2,1)(3,1) |
| 主对角线 diag1 | row-col+n-1 | 0-1+3=2 | (0,1)(1,2)(2,3) |
| 副对角线 diag2 | row+col | 0+1=1 | (0,1)(1,0) |
同一条主对角线上
row-col相同;同一条副对角线上row+col相同。+n-1避免负索引
0078. 子集 Medium
题意:返回不含重复元素的整数数组的所有子集(幂集)。
核心思路:每个元素选/不选,到达末尾保存结果。
dfs = func(cur int) { if cur == len(nums) { ans = append(ans, append([]int(nil), set...)); return } set = append(set, nums[cur]); dfs(cur+1) // 选 set = set[:len(set)-1]; dfs(cur+1) // 不选}0079. 单词搜索 Medium
题意:在 m×n 字符网格中搜索单词是否存在,相邻格子上下左右连通,同一格不能重复使用。
核心思路:DFS + 原地标记。把当前格子改为 ’#’ 防重复使用,递归后恢复。
temp := board[m][n]; board[m][n] = '#' // 标记res := dfs(m+1,n,idx+1) || dfs(m,n+1,idx+1) || dfs(m-1,n,idx+1) || dfs(m,n-1,idx+1)board[m][n] = temp // 恢复- 核心破题点:原地标记代替 visited 数组
0131. 分割回文串 Medium
题意:将字符串分成若干子串使每个都是回文串,返回所有分割方案。
核心思路:枚举切分点,只有回文子串才递归。
for i := idx+1; i <= n; i++ { if isPalindrome(s[idx:i]) { res = append(res, s[idx:i]) sub(i) // 递归 res = res[:len(res)-1] // 回溯 }}0216. 组合总和 III Medium
题意:找出所有「和为 n、且恰好由 k 个不同数字组成」的组合,数字只能从 1..9 里取且每个最多用一次。返回所有满足条件的组合。如 k=3, n=7 → [[1,2,4]](1+2+4=7)。
回溯思路(标准的「选 / 不选」两种分支):
start:当前正在考虑的数字(从start到 9,避免重复组合、保证递增)。target:还差多少和才凑到n。path:已选的数字;k - len(path)是「还需要选几个」。- 两种递归分支:
- 不选
start:dfs(start+1, target)(往后看更大的数字)。 - 选
start:path加入start,dfs(start+1, target-start)(和减去 start),返回后撤销选择path = path[:len(path)-1]。
- 不选
- 剪枝:
start > 9(数字用尽)或target < 0(和超了)直接返回;当还需选的个数 = 0 时,若target == 0说明凑齐,记录答案。
dfs = func(start, target int) { if k-len(path) == 0 { if target == 0 { ans = append(ans, copy(path)) }; return } if start > 9 || target < 0 { return } // 剪枝 dfs(start+1, target) // 不选 start path = append(path, start); dfs(start+1, target-start); path = path[:len(path)-1] // 选 start + 回溯}例子 k=3, n=7 的部分搜索树(path | target):
start=1: 不选 → (1,7) 选1 → path=[1] target=6 start=2: 选2 → [1,2] target=4 start=3: 选3 → [1,2,3] target=1 → 还需0个但target≠0 丢弃 start=4: 选4 → [1,2,4] target=0 → 还需0个且target=0 ✅ 记录- 核心破题点:每个数字「选/不选」两分支回溯;
start递增避免重复组合 - 避坑指南:记录答案要
copy(path)而非path(否则后续回溯会改掉已存结果);剪枝条件target<0别漏
十一、贪心
0031. 下一个排列 Medium
题意:找数组的下一个字典序排列(比当前大的最小排列),原地修改,已是最大则升序排列。
核心思路:从后往前找第一个降序位置 cur,再从后往前找第一个大于 nums[cur] 的交换,最后反转 cur 之后的部分。
// 1. 从后往前找第一个非降序位置cur := n-1; for cur > 0 && nums[cur] <= nums[cur-1] { cur-- }; cur--// 2. 找比 nums[cur] 大的最小值交换if cur >= 0 { m := n-1; for nums[cur] >= nums[m] { m-- }; nums[cur], nums[m] = nums[m], nums[cur] }// 3. 反转 cur 之后reverse(nums[cur+1:])- 核心破题点:从后往前找第一个可以变大的位置
- ⚠️ 常见失败原因:找到位置后直接交换
nums[cur]和nums[pre],但正确做法是在pre之后的降序后缀中找到大于nums[pre]的最小元素交换,再反转后缀。如[1,3,2]期望[2,1,3],错误代码输出[3,1,2]
0055. 跳跃游戏 Medium
题意:数组每个元素表示该位置最大跳跃步数,判断能否到达最后一个位置。
m := 0for i := 0; i <= m; i++ { // 只在可达范围内遍历 m = max(m, i+nums[i]) if m >= n-1 { return true }}return false- 核心破题点:循环条件
i <= m,只在能到达的范围内走
0135. 分发糖果 Hard
题意:每个孩子至少 1 颗糖,评分高的孩子比相邻孩子糖多,求最少糖果总数。
核心思路:两次遍历——左到右保证右边大的糖果多,右到左保证左边大的糖果多,取 max。
// 左到右if ratings[i] > ratings[i-1] { left[i] = left[i-1] + 1 } else { left[i] = 1 }// 右到左if ratings[i] > ratings[i+1] { right[i] = right[i+1] + 1 } else { right[i] = 1 }// 取 maxans = sum(max(left[i], right[i]))- 核心破题点:一次遍历无法同时满足左右约束,拆成两次单向再取 max
- 避坑指南:右到左遍历时 i 从 n-2 到 0,不要搞反方向
执行流程(ratings = [1,0,2]):
| 下标 | ratings | 左→右 left[] | 右→左 right[] | max(left,right) |
|---|---|---|---|---|
| 0 | 1 | 1(初始) | 2(0<1→right[0]=right[1]+1=2) | 2 |
| 1 | 0 | 1(0<1→left[1]=1) | 1(初始) | 1 |
| 2 | 2 | 2(2>0→left[2]=left[1]+1=2) | 1(初始) | 2 |
总糖果 = 2+1+2 = 5(每个孩子至少1颗,相邻评分高的糖果更多)
0334. 递增的三元子序列 Medium
核心思路:维护最小值和次小值,出现比次小值大的就找到了。
i, j := math.MaxInt, math.MaxIntfor _, v := range nums { if v < i { i = v } else if v > i && v < j { j = v } else if v > j { return true }}- 核心破题点:只需维护两个值,更新 i 不影响正确性(之前已有更大的 j 前驱)
0605. 种花问题 Easy
题意:花床 flowerbed 用 0(空)/ 1(已种)表示,规则是任何两朵花不能种在相邻地块。问能否在不违反规则的前提下再种下 n 朵花。
if flowerbed[i] == 0 { leftEmpty := i == 0 || flowerbed[i-1] == 0 // 左边界或左边为空 rightEmpty := i == l-1 || flowerbed[i+1] == 0 // 右边界或右边为空 if leftEmpty && rightEmpty { flowerbed[i] = 1; n-- } // 能种就种,并就地标记}- 核心破题点:能种就种,贪心不会比晚种差
十二、堆与优先队列
0215. 数组中的第K个最大元素 Medium
核心思路:快速选择——快排 partition 每次只递归一侧。
pivot := rand.Intn(n)arr[0], arr[pivot] = arr[pivot], arr[0]pivotVal := arr[0]; l, r := 1, n-1for l <= r { for l <= r && arr[l] >= pivotVal { l++ } for l <= r && arr[r] <= pivotVal { r-- } if l < r { arr[l], arr[r] = arr[r], arr[l] }}arr[0], arr[r] = arr[r], arr[0]if r == target { return arr[r] }if r > target { return find(arr[:r], target) }return find(arr[r+1:], target-r-1)- 核心破题点:快速选择每次只递归一侧,O(n) 期望
- 避坑指南:递归到右半部分要更新 target 偏移量;简单题直接
sort.Slice更快
0295. 数据流的中位数 Hard
核心思路:双堆——左堆(大顶堆)存较小一半,右堆(小顶堆)存较大一半。
// Go 没有大顶堆,用存负数模拟if left.Len() == right.Len() { heap.Push(&left, -right.pushPop(num)) // 先进右堆筛最小,放左堆} else { heap.Push(&right, -left.pushPop(-num)) // 先进左堆筛最大,放右堆}// 中位数: 左堆顶(奇数)或两堆顶平均(偶数)- 核心破题点:中位数 = 较小半部分最大值 与 较大半部分最小值
- 避坑指南:Go 大顶堆用存负数模拟;pushPop 优化避免两次堆操作
- ⚠️ 常见失败原因:平衡逻辑只处理了
m > n(右堆过长),完全没有处理n > m+1(左堆过长超过 1 个),连续插入较小数字时左堆无限增长导致中位数错误
graph LR
subgraph 双堆维护中位数
L["大顶堆 (左)
存较小一半
3,1,2"] --- R["小顶堆 (右)
存较大一半
5,7,6"]
end
M["中位数 = (左堆顶 + 右堆顶) / 2"]
L --> M
R --> M0347. 前 K 个高频元素 Medium
核心思路:哈希表统计频次 + 快速选择找前 k 个。
mp := map[int]int{}for _, num := range nums { mp[num]++ }// 转为 [数字, 频次] 数组,快速选择找前 k 个- 核心破题点:转化为「频次数组的第 k 大」,用快速选择避免全排序
十三、设计题
0146. LRU 缓存 Medium
题意:实现 O(1) 的 get 和 put 的 LRU 缓存,超容量时淘汰最久未使用的。
(见链表章节)
核心思路:哈希表 + 双向链表,虚拟头尾节点简化边界。
- 避坑指南:淘汰尾节点后必须从 map 中 delete
0155. 最小栈 Medium
题意:实现 push/pop/top/getMin 均为 O(1) 的栈。
(见栈章节)
核心思路:辅助栈同步维护最小值。
0208. 实现 Trie (前缀树) Medium
核心思路:每个节点有 26 个子节点指针和 isEnd 标记。
type Trie struct { children [26]*Trie isEnd bool}// SearchPrefix 共用:遍历字符路径// Search 额外检查 isEnd// StartsWith 只检查路径存在- 核心破题点:search 和 startsWith 的唯一区别是是否检查 isEnd
0295. 数据流的中位数 Hard
(见堆章节)
0933. 最近的请求次数 Easy
核心思路:队列,每次 ping 时从队首弹出过期请求。
func Ping(t int) int { q = append(q, t) for q[0] < t-3000 { q = q[1:] } // 弹出过期 return len(q)}十四、矩阵
0048. 旋转图像 Medium
核心思路:两次翻转——上下水平翻转 + 主对角线转置。
// 1. 上下翻转for i := 0; i < n/2; i++ { matrix[i], matrix[n-1-i] = matrix[n-1-i], matrix[i] }// 2. 主对角线转置for i := 0; i < n; i++ { for j := 0; j < i; j++ { matrix[i][j], matrix[j][i] = matrix[j][i], matrix[i][j] } }- 核心破题点:旋转 90° = 水平翻转 + 对角线转置
- 避坑指南:对角线翻转只遍历下三角
j < i
旋转过程可视化(3×3 矩阵):
0054. 螺旋矩阵 Medium
题意:按顺时针螺旋顺序返回 m×n 矩阵的所有元素。
核心思路:四边界收缩,按右→下→左→上遍历。
for left <= right && up <= bottom { // 右: left→right // 下: up+1→bottom if left < right && up < bottom { // 防单行/单列重复 // 左: right-1→left // 上: bottom-1→up+1 } left++; right--; up++; bottom--}- 核心破题点:
if left < right && up < bottom防止只剩一行/一列时回头重复遍历 - 避坑指南:每次走完四条边后都要收缩边界(left++, right—, up++, bottom—)
螺旋遍历示意(3×3 矩阵):
graph LR
subgraph "第1圈"
A1["(0,0)=1 →右"] --> A2["(0,1)=2 →右"] --> A3["(0,2)=3 ↓转"]
A3 --> A4["(1,2)=6 ↓"] --> A5["(2,2)=9 ←转"]
A5 --> A6["(2,1)=8 ←"] --> A7["(2,0)=7 ↑转"]
A7 --> A8["(1,0)=4 ↑"]
end
subgraph "第2圈(单元素)"
B1["(1,1)=5"]
end
style A3 fill: #ffcdd2, color: #1a1a1a
style A5 fill: #fff9c4, color: #1a1a1a
style A7 fill: #c8e6c9, color: #1a1a1a
style B1 fill: #e3f2fd, color: #1a1a1a输出顺序:1→2→3→6→9→8→7→4→5。红=右行终点,黄=下行终点,绿=左行终点,蓝=中心
边界收缩流程:
graph TD
S["left=0, right=n-1, up=0, bottom=m-1"] --> R["→ 右: left到right"]
R --> D["↓ 下: up+1到bottom"]
D --> C{"left < right 且 up < bottom?"}
C -->|是| L["← 左: right-1到left"]
C -->|否| E["跳过(只剩一行/一列)"]
L --> U["↑ 上: bottom-1到up+1"]
E --> F["收缩边界"]
U --> F
F --> N{"left ≤ right 且 up ≤ bottom?"}
N -->|是| R
N -->|否| END["结束"]- ⚠️ 常见失败原因:只剩一行/一列时,“从右到左”和”从下到上”仍会执行,导致元素被重复添加。如
[[1,2,3]]结果为[1,2,3,2]而非[1,2,3]。应加if up < bottom和if left < right判断
0073. 矩阵置零 Medium
题意:矩阵中元素为 0 则将其所在行和列全置 0,要求 O(1) 额外空间。
核心思路:用第一行第一列当标记数组,O(1) 空间。
// 1. 记录首行首列是否有 0// 2. 用首行首列标记内部 0 的位置// 3. 根据标记置零内部// 4. 最后处理首行首列- 核心破题点:矩阵本身的第一行第一列当标记数组
- 避坑指南:先记录首行首列原始状态再写标记
- ⚠️ 常见失败原因:最后置零时行和列的逻辑写反了——
zeroline检测的是第一行但有 0,置零时却把第一列置零;zerorow同理。两个 for 循环的赋值目标应互换
十五、字符串
0151. 反转字符串中的单词 Medium
核心思路:从右往左扫描,遇到空格切分单词。
t := nfor i := n-1; i >= 0; i-- { if s[i] == ' ' { if t-i > 1 { sb.WriteString(s[i+1:t]); sb.WriteByte(' ') } t = i }}if t > 0 { sb.WriteString(s[:t]) }- ⚠️ 常见失败原因:每个单词追加尾部空格,且首单词前可能包含前导空格,导致结果有多余空格。如
" hello world "返回"world hello "而非"world hello"
0345. 反转字符串中的元音字母 Easy
题意:反转字符串中的所有元音字母(aeiou,不区分大小写)。
(见双指针章节)
0443. 压缩字符串 Medium
核心思路:读写双指针,数字逆序写入后反转。
if read == n-1 || ch != chars[read+1] { chars[write] = ch; write++ if num > 1 { // 数字按位逆序写入,再反转 }}1071. 字符串的最大公因子 Easy
核心思路:str1+str2 == str2+str1 是存在公因子的充要条件,长度取 GCD。
if str1+str2 != str2+str1 { return "" }return str1[:gcd(len(str1), len(str2))]1657. 确定两个字符串是否接近 Medium
核心思路:两个充要条件——字符集相同 + 频次多重集相同。
if m != n { return false }// 字符集必须相同for k := range mp1 { if _, ok := mp2[k]; !ok { return false } }// 频次排序后比较slices.Sort(cnt1); slices.Sort(cnt2)1768. 交替合并字符串 Easy
题意:将 word1 和 word2 交替合并,剩余部分追加到末尾。
(见双指针章节)
最后的话:算法的本质不是背代码,而是建立「特征 → 算法」的条件反射。看到有序数组就想到二分,看到连续子数组就想到滑动窗口,看到所有方案就想到回溯。多做几遍这些题,每次关注「为什么这么想」而非「代码怎么写」,自然就能快速找到思路。