Hot100 算法笔记 - MuxiaoWF跳到主要内容

Hot100 算法笔记

LeetCode 上 Hot100 算法笔记

周日 8月 09 2026
28995 字 · 142 分钟

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 / DFS200, 207, 547, 994
字符串前缀匹配Trie208
区间合并 / 插入排序 + 遍历56, 763

DP 识别套路

关键词DP 类型状态定义
最长 / 最短 / 最少 / 最多优化型 DPdp[i] = 前 i 个元素的最优值
能否 / 是否可行性 DPdp[i] = 前 i 个元素是否可行
方案数计数型 DPdp[i] = 前 i 个元素的方案数
背包 / 凑数背包 DPdp[j] = 容量 j 时的最优值
两个序列双序列 DPdp[i][j] = s1 前 i 与 s2 前 j 的结果
矩阵路径矩阵 DPdp[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

步骤inums[i]查找 9-nums[i]哈希表操作
102查 7 → 不存在{}存入 {2:0}
217查 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

步骤inums[i]操作数组状态
1033∈[1,4], 交换nums[0]↔nums[2][-1,4,3,1]
20-1-1不在[1,4], 跳过[-1,4,3,1]
3144∈[1,4], 交换nums[1]↔nums[3][-1,1,3,4]
4111∈[1,4], 交换nums[1]↔nums[0][1,-1,3,4]
51-1跳过[1,-1,3,4]
6233已在位置2, 跳过[1,-1,3,4]
7344已在位置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: #1a1a1a

0049. 字母异位词分组 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 在集合中?是否为起点向后数序列长度
10099不在 → 是✅ 起点100→(101不在)1
43在 → 否❌ 跳过--
200199不在 → 是✅ 起点200→(201不在)1
10不在 → 是✅ 起点1→2→3→4→(5不在)4 ✓max
32在 → 否❌ 跳过--
21在 → 否❌ 跳过--

结果 = 4(序列 [1,2,3,4])。只从 3 个起点开始计数,其余 3 个跳过


0136. 只出现一次的数字 Easy

题意:O(n) 时间 O(1) 空间找只出现一次的数(其余出现两次)。

核心思路:全部异或,成对的抵消为 0,剩下就是答案。

single := 0
for _, num := range nums { single ^= num }
return single
  • 核心破题点:异或自反性 a ^ a = 0a ^ 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] = 1
for i := 1; i < n; i++ { answer[i] = answer[i-1] * nums[i-1] } // 左累积
temp := 1
for i := n-1; i >= 0; i-- {
answer[i] *= temp // 乘右累积
temp *= nums[i] // 更新右累积
}
  • 核心破题点:answer[i] = 左侧所有乘积 × 右侧所有乘积,分两次遍历搞定
  • 避坑指南:第二遍从右到左时 temp 初始为 1,先更新 answer 再更新 temp

0283. 移动零 Easy

题意:把数组中所有 0 移到末尾,保持非零元素相对顺序。

l := 0
for 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 := 0
for _, num := range nums {
pre += num
count += m[pre-k] // 之前有多少个前缀和 = pre-k
m[pre]++ // 记录当前前缀和出现次数
}

运行示例(nums = [1,1,1], k = 2):

步骤numpre查 pre-k=pre-2count 累加哈希表 m
111查 -1 → 00{0:1, 1:1}
212查 0 → 11{0:1, 1:1, 2:1}
313查 1 → 12{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 := 0
for _, 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
112无缺失[]
2243[3]
345无缺失[3]
  • 核心破题点:排序后检查相邻元素差值 > 1 的间隙

二、双指针

0011. 盛最多水的容器 Medium

题意:两条竖线 + 底边构成容器,求最大容量。

核心思路:左右指针从两端向中间,矮的那侧移动(因为移动高的一侧不可能让面积变大)。

l, r := 0, len(height)-1
for 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])

固定 lnums[l]mrsum动作
0-415-4+(-1)+2=-3<0m++
0-425-4+(-1)+2=-3<0m++
0-435-4+0+2=-2<0m++
0-445-4+1+2=-1<0m++,m≥r 退出
1-125-1+(-1)+2=0 ✅找到[-1,-1,2],m++,r—
1-134-1+0+1=0 ✅找到[-1,0,1],m++,r—
1-143m≥r 退出-
2-1(重复)跳过l=2, nums[2]==nums[1]
30450+1+2=3>0r—,r<m 退出

结果 = [[-1,-1,2], [-1,0,1]]

  • ⚠️ 常见失败原因:在 sum == 0 分支中错误地执行了 l++(外层循环变量)而非 m++(内层左指针),导致跳过有效解

0027. 移除元素 Easy

题意:原地移除所有值为 val 的元素。

cur, lst := 0, len(nums)-1
for 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)-1
maxLeft, maxRight := 0, 0
for 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]):

0
1
0
2
1
0
1
3
2
1
2
1

黑色 = 柱子高度,蓝色 = 接住的雨水。总雨水量 = 6

运行示例(双指针过程):

步骤leftrightmaxLmaxRh[l]<h[r]?本步雨水总计
1011010-0=00
2111111-1=00
3110121-1=00
4210121-0=11
5310222-2=01
639211-1=01
738222-2=01
837232-2=01
947232-1=12
1057232-0=24
1167232-1=15
127733相遇3-3=06
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: #1a1a1a

0088. 合并两个有序数组 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%n
reverse(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)-1
b := []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, 0
for 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, 0
for 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, 0
for 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 --> A

0003. 无重复字符的最长子串 Medium

题意:找无重复字符的最长子串长度。

核心思路:滑动窗口 + 哈希表记录字符位置。遇到重复字符时左指针跳到重复字符下一位。

mp := map[byte]int{}; l, res := 0, 0
for 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)

步骤inums[i]队列(下标)队列(值)过期检查输出
101[0][1]i<2, 不输出-
213[1][3]i<2, 不输出-
32-1[1,2][3,-1]q[0]=1≥0, OKnums[1]=3
43-3[1,2,3][3,-1,-3]q[0]=1≥1, OKnums[1]=3
545[4][5]q[0]=4≥2, OKnums[4]=5
653[4,5][5,3]q[0]=4≥3, OKnums[4]=5
766[6][6]q[0]=6≥4, OKnums[6]=6
877[7][7]q[0]=7≥5, OKnums[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 和 diff
for 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 := count
for 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 的插入位置。注意这种写法 midleft + (right-left)/2(向下取整),且更新时通常 right = mid(保留 mid 这个候选),否则 left = mid + 1,避免死循环。
  • 一句话记忆:要「命中某个值」用 <=;要「逼近某个边界/下界」用 <

0033. 搜索旋转排序数组 Medium

题意:旋转排序数组中找 target。

核心思路:二分时判断哪半边有序,再判断 target 在不在有序的那半边。

left, right := 0, n-1
for 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)

轮次leftrightmidnums[mid]判断动作
106377≥4,左半有序target=0 不在[4,7),left=4
246511<4,右半有序target=0 不在(1,2],right=4
344400==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 -->|否| I

0034. 在排序数组中查找元素的第一个和最后一个位置 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 的位置 - 1
rightBound := 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-1
for 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):

leftrightmidnums[mid] 与 nums[left]判断动作
0425 >= 3左段有序ans=min(3,3)=3,left=3
3431 >= 1左段有序ans=min(3,1)=1,left=4
4442 >= 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])-1
for 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 这个节点有两条入边 → 它就是环的入口。

判圈法分两个阶段

  1. 找相遇点(slow 走 1 步、fast 走 2 步):slow = nums[slow]fast = nums[nums[fast]]。只要存在环,二者必在环内某点相遇。
  2. 找环入口:一个指针从起点 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, 0
for { slow = nums[slow]; fast = nums[nums[fast]]; if slow == fast { break } }
head := 0
for 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 ≥ Ktotal(k) ≤ h(吃得完);当 k < Ktotal(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+38刚好可以
31+2+3+410不行

最小合法速度是 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小

步骤abkmidAmidBa[midA]b[midB]操作
1[1,3][2]200121 < 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 := 0
for 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.Vall2.Valcarry进原始和当前位(和%10)新carry(和/10)结果接的节点
12507707
246010010
33418808
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, head
for 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 := dummy
for 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.Next

0023. 合并 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 := dummy
for 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.Next

0025. K 个一组翻转链表 Hard

核心思路:每 k 个为一组,断开 → 反转 → 接回。用 pre 和 tail 标记每组的前驱和尾。

dummy := &ListNode{Next: head}; pre, tail := dummy, dummy
for {
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, head
for 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

执行流程

阶段fastslow说明
初始33都从 head 出发
第1步2→02fast走2步, slow走1步
第2步-4→2→00fast走2步, slow走1步
第3步-4-4✅ 在-4相遇
第二阶段head=3slow=-4一个回head,同速走
第1步22✅ 在节点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, headB
for 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 := head
for 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 := even
for 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 *ListNode
for fast != nil && fast.Next != nil {
fast = fast.Next.Next; pre = slow; slow = slow.Next
}
pre.Next = pre.Next.Next

2130. 链表最大孪生和 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) == 0

0032. 最长有效括号 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])

2
1
5
6
2
3
↑ 红色柱(高6)以自己为高,向左扩展到高5,宽=2,面积=5×2=10(最大矩形)

执行流程(heights = [2,1,5,6,2,3],哨兵 heights[6]=0)

步骤i当前柱高栈(下标)动作计算面积
102[]入栈-
211[0]1<2,弹出02×(1-(-1)-1)=2
311[]入栈-
425[1]5>1,入栈-
536[1,2]6>5,入栈-
642[1,2,3]2<6,弹出36×(4-2-1)=6
742[1,2]2<5,弹出25×(4-1-1)=10 ✓max
842[1]2>1,入栈-
953[1,4]3>2,入栈-
1060(哨兵)[1,4,5]0<3,弹出53×(6-4-1)=3
1160[1,4]0<2,弹出42×(6-1-1)=8
1260[1]0<1,弹出11×(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]]”)

步骤字符动作currStrcurrNumstrStacknumStack
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: #1a1a1a

2390. 从字符串中移除星号 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”

步骤字符操作栈状态
1l入栈l
2e入栈le
3e入栈lee
4t入栈leet
5*弹出tlee
6*弹出ele
7c入栈lec
8o入栈leco
9d入栈lecod
10*弹出dleco
11e入栈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<<311<<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,若它有左子树,就:

  1. 找到左子树里最右的节点 pre(它是左子树先序遍历的最后一个);
  2. cur 的原右子树接到 pre.Right 上(保住右边);
  3. cur.Right 改成 cur.Left(左子树接到右边),并把 cur.Left 置空;
  4. cur 向右走一步,重复直到所有左子树被「搬」到右子树链上。
cur := root
for 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.Left
invertTree(root.Left)
invertTree(root.Right)
return root

0230. 二叉搜索树中第 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 } // 左右都找到 → 当前是 LCA
if 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
节点左返回右返回判断
6nilnil返回 nil
2nilnil返回 nil
5nilnil5==p → 返回 5
0nilnil返回 nil
8nilnil返回 nil
1nilnil1==q → 返回 1
35(非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 个岛屿)

1 1 0 0 0
0 1 1 0 0
0 0 1 0 1
0 0 0 0 1
1=陆地 0=水域

执行流程

  1. 遍历到 (0,0)=‘1’ → ans=1,DFS 沉岛 → (0,0)(0,1)(1,1)(1,2)(2,2) 全改 ‘0’
  2. 遍历到 (2,4)=‘1’ → ans=2,DFS 沉岛 → (2,4)(3,4) 全改 ‘0’
  3. 剩余全是 ‘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 前先修 00 → 1
[2, 1]修 2 前先修 11 → 2
[3, 2]修 3 前先修 22 → 3
[1, 3]修 1 前先修 33 → 1

执行流程(numCourses=4, prerequisites=[[1,0],[2,1],[3,2],[1,3]])

图结构:0→1→2→3→1(存在环 1→2→3→1)

步骤DFS节点status变化邻居结果
100→1[1]递归1
210→1[2,3]递归2
320→1[3]递归3
430→1[1]递归1
51status==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: #1a1a1a

0547. 省份数量 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 := 0
for 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]):

iheightleftMaxrightMaxmin(l,r)雨水 = min-height
000300
111310
201311
322320
412321
502322
612321
733330
823220
913221
1023220
1113110

总雨水 = 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])

inums[i]原始前面和>0?nums[i]更新后res
0-2--2-2
11-2≤0, 不加11
2-31>0, 加1+(-3)=-21
34-2≤0, 不加44
4-14>0, 加4+(-1)=34
523>0, 加3+2=55
615>0, 加5+1=66 ✓max
7-56>0, 加6+(-5)=16
841>0, 加1+4=56

结果 = 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)=2
for 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]=j
if 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”)

ros
0123
h11 (替换h→r)23
o221 (o==o,继承)2
r32 (r==r,继承)23
s4332 (s==s,继承)
e5443 (删除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, 1
for j := 1; j < i; j++ { ans[i][j] = ans[i-1][j] + ans[i-1][j-1] }

0121. 买卖股票的最佳时机 Easy

题意:只能买卖一次,求最大利润。

minPrice := math.MaxInt32; ans := 0
for _, p := range prices {
minPrice = min(minPrice, p)
ans = max(ans, p - minPrice)
}
  • 核心破题点:维护前缀最小值,每天尝试卖出

0139. 单词拆分 Medium

题意:判断字符串 s 能否由字典 wordDict 中的单词拼接而成。

dp := make([]bool, n+1); dp[0] = true
for 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])

inums[i]偷(i): prev2+nums[i]不偷(i): prev1current说明
02--2初始 prev2
17--7prev1=max(2,7)
292+9=11711偷2+9 > 不偷7
337+3=101111不偷(11) > 偷(10)
4111+1=121112偷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=124+4+4 用 3 个;n=134+9 用 2 个)

如何转为完全背包:把每个完全平方数 j*j 看成一种「硬币」,面值就是 j*jn 看成要凑的「金额」。因为同一个平方数可以用任意多次(比如 12 用了三次 4),这正是完全背包(硬币可无限取)。目标是「用最少的硬币数」凑出金额 n

  • dp[i] = 凑出金额 i 最少需要的完全平方数个数
  • 转移:对每个可用平方数 j*j ≤ idp[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] = 0
for 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])

inums[i]检查所有 j<idp[i]说明
0101初始
199<10 跳过1没有比9小的前驱
222<10, 2<9 跳过1没有比2小的前驱
355>2→dp[2]+1=22[2,5]
433>2→dp[2]+1=22[2,3]
577>2→2, 7>5→3, 7>3→33[2,5,7]或[2,3,7]
6101101>所有→取dp[5]+1=44[2,5,7,101]
71818>2→2, 18>5→3, 18>3→3, 18>7→44[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] = 0
for 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 背包

  1. 先判总和 sum:若 sum 为奇数,根本无法平分 → 直接 false;否则目标 target = sum/2
  2. 问题变成:能否从数组中选出若干个数,使它们的和恰好等于 target。这正是 0-1 背包——每个数是「一件物品」,重量=数值、价值=数值;背包容量=target;问能否恰好装满。每个数只能用一次(0-1 背包核心)。
  3. 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 / 2
dp := 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=22target=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]=0
for 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, 1
for 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 个的 LCS
if text1[i-1] == text2[j-1] { dp[i][j] = dp[i-1][j-1] + 1 }
else { dp[i][j] = max(dp[i-1][j], dp[i][j-1]) }
  • 核心破题点:相同取对角线 +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-colrow+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)的值被标记的位置
列 colcol=11(0,1)(1,1)(2,1)(3,1)
主对角线 diag1row-col+n-10-1+3=2(0,1)(1,2)(2,3)
副对角线 diag2row+col0+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) 是「还需要选几个」。
  • 两种递归分支:
    • 不选 startdfs(start+1, target)(往后看更大的数字)。
    • startpath 加入 startdfs(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 := 0
for 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 }
// 取 max
ans = sum(max(left[i], right[i]))
  • 核心破题点:一次遍历无法同时满足左右约束,拆成两次单向再取 max
  • 避坑指南:右到左遍历时 i 从 n-2 到 0,不要搞反方向

执行流程(ratings = [1,0,2])

下标ratings左→右 left[]右→左 right[]max(left,right)
011(初始)2(0<1→right[0]=right[1]+1=2)2
101(0<1→left[1]=1)1(初始)1
222(2>0→left[2]=left[1]+1=2)1(初始)2

总糖果 = 2+1+2 = 5(每个孩子至少1颗,相邻评分高的糖果更多)


0334. 递增的三元子序列 Medium

核心思路:维护最小值和次小值,出现比次小值大的就找到了。

i, j := math.MaxInt, math.MaxInt
for _, 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

题意:花床 flowerbed0(空)/ 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-1
for 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 --> M

0347. 前 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 矩阵)

原始矩阵
123
456
789
① 上下翻转
789
456
123
② 对角线转置
741
852
963
蓝=原始 橙=上下翻转后 绿=对角线转置后(最终结果:顺时针旋转90°)

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 < bottomif left < right 判断

0073. 矩阵置零 Medium

题意:矩阵中元素为 0 则将其所在行和列全置 0,要求 O(1) 额外空间。

核心思路:用第一行第一列当标记数组,O(1) 空间。

// 1. 记录首行首列是否有 0
// 2. 用首行首列标记内部 0 的位置
// 3. 根据标记置零内部
// 4. 最后处理首行首列
  • 核心破题点:矩阵本身的第一行第一列当标记数组
  • 避坑指南:先记录首行首列原始状态再写标记
  • ⚠️ 常见失败原因:最后置零时行和列的逻辑写反了——zeroline 检测的是第一行但有 0,置零时却把第一列置零;zerorow 同理。两个 for 循环的赋值目标应互换

十五、字符串

0151. 反转字符串中的单词 Medium

核心思路:从右往左扫描,遇到空格切分单词。

t := n
for 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 交替合并,剩余部分追加到末尾。

(见双指针章节)


最后的话:算法的本质不是背代码,而是建立「特征 → 算法」的条件反射。看到有序数组就想到二分,看到连续子数组就想到滑动窗口,看到所有方案就想到回溯。多做几遍这些题,每次关注「为什么这么想」而非「代码怎么写」,自然就能快速找到思路。


感谢您的阅读!如果可以,给俺点些关注吧~

Hot100 算法笔记

周日 8月 09 2026
28995 · 142 分钟
封面
示例歌曲
示例艺术家
封面
示例歌曲
示例艺术家
0:00 / 0:00