1. 热身之前,先看这片题区到底在考什么
Hot 100 跑到 71-80,正好卡在一个很有意思的位置。前面那些题还在讲数组遍历、哈希表、链表反转这些“基本功”,到了这个区间,难度和题型都开始分层了:一部分题是面试现场的高频题,几乎是必刷的;另一部分则是经典的“看着不难、一写就错”,非常考验对算法细节的敏感度。
我先把这 10 道题按考点拆一遍:
| 题号 | 核心考点 | 题型归类 |
|---|---|---|
| 71 | 三指针原地重排 | 数组 / 双指针 |
| 72 | 滑动窗口 + 哈希计数 | 字符串 |
| 73 | 子集枚举 | 回溯 / 位运算 |
| 74 | 矩阵中的 DFS + 剪枝 | 回溯 / 搜索 |
| 75 | 单调栈求最大矩形 | 栈 / 单调栈 |
| 76 | 矩阵降维 + 复用 75 题结论 | 动态规划 / 单调栈 |
| 77 | 二叉树中序遍历的迭代写法 | 二叉树 / 栈 |
| 78 | 二叉搜索树合法性判断 | 二叉树 / 递归 |
| 79 | 二叉树镜像对称性判断 | 二叉树 / 递归 |
| 80 | 二叉树层序遍历 | 二叉树 / BFS |
从这张表能看出两件事。第一,这个区间是“数组/字符串 + 二叉树”的二分天下。第二,题目之间存在明显的递进关系:71 是双指针训练的入门,72 是滑动窗口的进阶版,75 和 76 又是同一套思路从一维到二维的延伸。建议有时间的同学把这几道相邻的题连起来做,比单独刷十道题收获大得多。
下面我按题型分组来拆,每道题都会讲清楚:核心思路、解法为什么这么选、实操中容易翻车的细节,以及第一遍做的时候最常见的错误。有些代码我会直接放出来,你可以照着敲,敲完再自己复述一遍思路,才算真正吃透。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数组双指针进阶:从“荷兰国旗”到“最小覆盖子串”
2.1 颜色分类:三指针的边界处理是灵魂
这道题就是经典的“荷兰国旗问题”。数组里只有 0、1、2 三种值,要求原地排序,不能用额外空间。我第一次做的时候,下意识想到的是计数排序:先数 0 有几个、1 有几个、2 有几个,再填回去。这个思路能过,但面试官大概率会追问一句“能不能做到只遍历一遍”。于是就有了标准的三指针解法。
核心思路是维护三个指针:
left:指向当前已经排好的 0 区间的下一个位置,初始为 0。right:指向当前已经排好的 2 区间的上一个位置,初始为len(nums) - 1。i:当前遍历指针,初始为 0。
遍历过程中,遇到 0 就和 left 交换,left 和 i 都右移;遇到 2 就和 right 交换,right 左移,但 i 不移动;遇到 1 直接跳过,i 右移。
这个逻辑最容易被忽略的点是:交换 2 的时候,i 不能急着往前走。因为从 right 位置换过来的那个值,可能是 0、1、2 中的任何一个,如果直接 i++,换过来的 0 或者 2 就会被漏处理。这一条几乎是我见过所有人第一次写这道题都会掉的坑。
python复制def sortColors(nums):
left, i, right = 0, 0, len(nums) - 1
while i <= right:
if nums[i] == 0:
nums[left], nums[i] = nums[i], nums[left]
left += 1
i += 1
elif nums[i] == 2:
nums[right], nums[i] = nums[i], nums[right]
right -= 1
# i 不移动,换过来的值需要重新判断
else:
i += 1
实操心得:写完后用 [2, 0, 2, 1, 1, 0] 这个用例手动走一遍,重点关注第一次交换 2 的时候 i 是否还停在原地。我身边的一位同学就是因为这里 i++ 写顺手了,调了半小时没发现。
2.2 最小覆盖子串:滑动窗口的完整框架
这道题是滑动窗口的“天花板”之一。题目要求找最短子串,让 t 中每个字符都出现在子串里,且数量不缺席。暴力做法是枚举所有子串,肯定超时;滑动窗口就是利用“两个指针夹出一个窗口,动态收缩”的方式,把复杂度压到 O(n)。
这里要做的准备工作有两部分。一是用哈希表 need 记录 t 中每个字符的需求数量,二是用一个变量 need_cnt 记录“还缺多少个字符”。窗口右移时,把右边字符纳入窗口,如果它正好补齐了某个字符的缺口,need_cnt 就减一;当 need_cnt == 0 时,说明当前窗口已经覆盖了 t,此时尝试左移指针收缩窗口,直到收缩到不再满足覆盖条件为止,记录收缩过程中出现的最短窗口。
注意一个细节:need_cnt 统计的是“缺的字符种类数”,不是“缺的字符个数”。need 中某字符数量从 1 减到 0,说明这个字符已经够了,种类数减一;但如果某个字符本来就多,窗口里多了也不会让 need_cnt 变化。这个设计能有效地避免每次都要重新遍历哈希表确认是否满足条件。
python复制def minWindow(s, t):
from collections import Counter
need = Counter(t)
need_cnt = len(need)
res = ""
left = 0
for right, ch in enumerate(s):
if ch in need:
need[ch] -= 1
if need[ch] == 0:
need_cnt -= 1
while need_cnt == 0:
if not res or right - left + 1 < len(res):
res = s[left:right+1]
if s[left] in need:
need[s[left]] += 1
if need[s[left]] > 0:
need_cnt += 1
left += 1
return res
实际跑这道题的时候,最容易出的问题是用 need_cnt = len(t),然后每次都判断窗口内字符数量是否齐全。这样做不是不行,但每次收缩左边界的时候,都要重新遍历整个 need,复杂度会退化到 O(m * n)。用“种类数”这个变量,能把判断降到常数时间。我用一个实际开发中的例子来类比:这就好比你要统计一个仓库里缺多少种货,而不是缺多少件货。只要货架上有这种货,哪怕只有一件,也算不缺;缺货种类的变化才是窗口是否合法的关键信号。
3. 回溯搜索:子集与单词搜索的套路化写法
3.1 子集:回溯模板 + 另一种偷懒解法
子集这道题,本质上是在问:一个集合的所有子集怎么枚举。回溯是标准解法,模板也非常固定,写得熟的话,后面遇到“组合总和”“全排列”都能套同一个框架。
回溯的核心是三步:选当前元素,递归下一层,撤销选择。对应到代码上:
python复制def subsets(nums):
res = []
path = []
def dfs(start):
res.append(path[:]) # 每个节点都是合法子集
for i in range(start, len(nums)):
path.append(nums[i])
dfs(i + 1)
path.pop()
dfs(0)
return res
这里有两个容易误解的点。第一,为什么每次递归进来都要 res.append(path[:]),而不是只在叶子节点收集?因为子集问题的定义是“所有可能的组合”,空集、单元素集、多元素集都是合法结果,所以每个递归节点都应该收集。第二,为什么循环从 start 开始而不是从 0 开始?因为子集不讲究顺序,[1, 2] 和 [2, 1] 是同一个子集,如果每次都从 0 开始遍历,就会产生大量重复组合。
如果你只是想快速 AC 这道题,还有一个不太有人提但非常好用的思路:位运算。数组长度 n,子集总数是 2^n,用二进制数 0 到 2^n - 1 的每一位表示对应元素取或不取。比如 nums = [1, 2, 3],二进制 101 就表示取第 0 个和第 2 个元素,也就是子集 [1, 3]。这种写法省去了回溯的递归栈开销,代码也短,但可读性稍差,面试时我一般先讲回溯,再提一句位运算作为补充方案。
3.2 单词搜索:DFS 最容易踩的坑是“状态回滚”
单词搜索要求在二维网格中找一条路径,路径上的字符顺序必须等于目标单词。看到“路径”两个字,第一反应就是 DFS;但网格搜索和普通树搜索有个本质区别:树结构天然没有环,而网格中一个格子有四个方向可以走,如果不加限制,很容易走回已经访问过的格子,导致死循环甚至错误匹配。
解决方案是“标记 + 回滚”。进入一个格子时,把它标记为已访问;递归完四个方向后,再把标记清除。我用一个额外的 visited 二维数组来标记,但更简洁的做法是:直接把当前格子改成特殊字符 #,递归回来再改回原字符,省掉一个二维数组的内存。代价是修改了原数组,所以如果后续还要用原数组,需要提前注意。
另一个容易漏的细节是剪枝顺序。每次递归前,先判断下标是否越界、字符是否匹配、是否已被访问,三个条件缺一不可。顺序上先做下标越界判断,再做字符匹配判断,可以减少不必要的哈希查找和数组访问,虽然性能提升微乎其微,但代码结构更清晰。
python复制def exist(board, word):
m, n = len(board), len(board[0])
def dfs(i, j, k):
if k == len(word):
return True
if not (0 <= i < m and 0 <= j < n):
return False
if board[i][j] != word[k]:
return False
temp = board[i][j]
board[i][j] = '#'
for di, dj in [(1,0),(-1,0),(0,1),(0,-1)]:
if dfs(i+di, j+dj, k+1):
return True
board[i][j] = temp
return False
for i in range(m):
for j in range(n):
if dfs(i, j, 0):
return True
return False
做题时我还发现一个实用性优化:如果 word 中某个字符的出现次数大于网格中该字符的出现次数,可以提前返回 False。这个优化常见于用 Counter 实现的预处理,在极端情况下能省掉一整轮 DFS。
4. 单调栈:从“柱状图最大矩形”到“二维最大矩形”
4.1 柱状图中最大的矩形:为什么非要用单调栈
这道题的朴素做法是:枚举每个柱子作为矩形的高度,然后向左向右分别找到第一个比它矮的柱子,这样就确定了矩形的左右边界。每个柱子的高度互不相同,最坏情况下每次找边界都要 O(n),整体 O(n^2)。数据量一大就超时。
单调栈的巧妙之处在于:它能在一次遍历中,同时维护所有柱子的左右边界信息。核心思想是维护一个递增的栈,栈中存的是柱子的下标。遍历每个柱子时,如果当前柱子高度小于等于栈顶柱子高度,说明栈顶柱子的右边界已经确定,可以弹出栈顶并结算面积。左边界就是弹出后新的栈顶下标(或者栈空时为 -1),右边界就是当前下标。
用公式表达就是:面积 = height[栈顶] * (当前下标 - 新栈顶下标 - 1)。这里有个细节,为什么用“小于等于”而不是“小于”?因为当高度相等时,如果不用等号,后面高度相同的柱子会重复计算,虽然结果一样,但会多几次无意义的操作。统一用“小于等于”会让代码逻辑更干净,也不容易漏边界。
python复制def largestRectangleArea(heights):
stack = []
max_area = 0
for i, h in enumerate(heights + [0]): # 末尾补0,强制清空栈
while stack and heights[stack[-1]] >= h:
idx = stack.pop()
left = stack[-1] if stack else -1
max_area = max(max_area, heights[idx] * (i - left - 1))
stack.append(i)
return max_area
我在实际测试中发现,heights + [0] 这个操作是很多解法里最容易忽略的“神来之笔”。如果不补一个高度为 0 的哨兵柱子,遍历结束后栈里可能还剩下若干没有结算的柱子,你还得再写一段循环去处理它们。补一个 0,能让 while 循环在最后统一把栈清空,代码瞬间简洁很多。类似的哨兵技巧在“接雨水”“每日温度”这类单调栈题里也广泛适用。
4.2 最大矩形:二维问题降维成一维问题
最大矩形这道题,是在一个 0/1 矩阵里找全为 1 的最大矩形面积。看起来比柱状图复杂很多,但如果你把矩阵的每一行当作柱状图的底部,就能把问题转化为“以每一行为底的柱状图最大矩形面积”。
具体做法是:维护一个高度数组 heights,长度等于矩阵列数。从上到下遍历每一行,遇到 1 就把对应位置 heights[j] += 1,遇到 0 就把 heights[j] = 0。然后对每一行的 heights 调用上一题的 largestRectangleArea,取所有行的最大值。
这个转化过程实际上是动态规划的思想:heights[j] 表示从当前行向上连续 1 的个数。换句话来说,它记录的是“以当前行作为底边时,每一列能提供的最大高度”。有同学会问,为什么遇到 0 要清零而不是保留累计值?因为一旦某一行某个位置是 0,从这一行往上数到任何包含该位置的矩形,高度都会被打断,所以必须清零。
python复制def maximalRectangle(matrix):
if not matrix:
return 0
n = len(matrix[0])
heights = [0] * n
max_area = 0
for row in matrix:
for j in range(n):
if row[j] == '1':
heights[j] += 1
else:
heights[j] = 0
max_area = max(max_area, largestRectangleArea(heights))
return max_area
这道题给我们的启发是:把二维问题降到一维,然后用一维题目的成熟解法去解决,是算法题里很常见的“化归”思路。如果你总是执着于直接想一个二维算法,往往会卡住;但如果能识别出它和某个一维经典题之间的关系,思路就顺了。
5. 二叉树组团题:四种遍历套路,一次过关
5.1 中序遍历迭代版:栈模拟递归,别背模板
71-80 里四道二叉树题,第一道就是中序遍历。递归写法非常简单,几乎每个写过二叉树的人都能默写:
python复制def inorder(root):
if not root:
return []
return inorder(root.left) + [root.val] + inorder(root.right)
但面试里经常会追问一句“能不能用迭代写”。迭代写法的本质是用显式的栈来模拟函数调用栈。递归函数执行时,是先一路走到底访问左子树,再访问根,再访问右子树。所以迭代的逻辑是:让指针一直往左走,把路径上的节点全部压栈;走到空节点后,弹出一个节点,记录它的值,然后把指针移到它的右子树,继续重复上述步骤。
python复制def inorderTraversal(root):
res = []
stack = []
cur = root
while cur or stack:
while cur:
stack.append(cur)
cur = cur.left
cur = stack.pop()
res.append(cur.val)
cur = cur.right
return res
我建议不要死记硬背这段代码,而是理解它的执行过程。可以拿一棵只有三个节点的满二叉树手动走一遍:根是 2,左子 1,右子 3。模拟一遍你会发现,while cur 负责“深入左子树”,pop 负责“回到根节点”,cur = cur.right 负责“转向右子树”。理解了这三步,前序和后序的迭代写法也就顺理成章了。
5.2 验证二叉搜索树:中序性质法与区间法
验证 BST 这道题,看起来只是递归判断“左子小于根、右子大于根”,但如果只判断当前节点和左右孩子的大小关系,是错的。因为 BST 的定义要求左子树所有节点都小于根节点,右子树所有节点都大于根节点,而不仅仅是直接的孩子节点。
举个例子:根为 5,左孩子为 3,左孩子的右孩子为 6。单看每个节点和其孩子的关系,左子树都小于根(3 < 5),右子树都大于左子树(6 > 3),但这棵树不是 BST,因为 6 出现在左子树却大于根。
所以需要一种能体现全局约束的方法。两种思路各有千秋:
- 中序遍历法:对 BST 做中序遍历,得到的结果是严格递增序列。把中序遍历的结果和一个“前一个值”变量比较,如果任何一处出现非递增,说明不是 BST。
- 区间法:递归时传递一个
(low, high)区间,每个节点必须满足low < val < high,左子递归时更新high为当前值,右子递归时更新low为当前值。
我推荐中序遍历法,因为代码更容易扩展到“找第 k 小元素”之类的变体。但注意一个细节:如果树中有相同值的节点,BST 通常要求严格大于/小于,所以判断条件必须是 >= 和 <=,不能用 > 和 < 的宽松版本。
5.3 对称二叉树和层序遍历:两个不同的队列应用场景
对称二叉树这道题,最直观的解法是递归:判断左子树的左子树是否等于右子树的右子树,左子树的右子树是否等于右子树的左子树。写成递归就是:
python复制def isSymmetric(root):
def check(left, right):
if not left and not right:
return True
if not left or not right:
return False
return (left.val == right.val
and check(left.left, right.right)
and check(left.right, right.left))
return check(root.left, root.right) if root else True
这里有个容易搞混的方向:镜像对称比较的是“左子树的左孩子 vs 右子树的右孩子”以及“左子树的右孩子 vs 右子树的左孩子”,而不是简单的左右孩子比较。我在纸上画了三个节点后,这种交叉比较就一目了然了。如果你也喜欢可视化,可以尝试在递归函数里打印每次比较的节点值,能直观看到遍历顺序。
层序遍历则是 BFS 的典型应用,用队列逐层保存节点。关键点在于:每次进入循环前,记录当前队列的 size,这个 size 就是当前层的节点数。然后只处理这 size 个节点,把它们的值收集到一个临时列表,再把它们的子节点入队。如果不提前记录 size,队列会混入下一层的节点,导致层次分不清。
python复制def levelOrder(root):
if not root:
return []
res = []
queue = [root]
while queue:
level = []
for _ in range(len(queue)):
node = queue.pop(0)
level.append(node.val)
if node.left:
queue.append(node.left)
if node.right:
queue.append(node.right)
res.append(level)
return res
代码里用 pop(0) 只是为了展示逻辑清晰,实际 Python 里应该用 collections.deque 的 popleft(),否则列表的头部弹出是 O(n) 操作,在大数据量下会变慢。这点在面试时提一嘴,能加分。
6. 高频坑点速查与刷题策略
6.1 这个题区最容易踩的 7 个坑
我把这一区间做题时最容易出错的地方汇总成一个速查表,刷题前后的自查都用得上:
| 题目 | 典型错误 | 正确做法 |
|---|---|---|
| 颜色分类 | 交换 2 后立刻 i++,漏处理换回的值 |
交换 2 后 i 停在原地 |
| 最小覆盖子串 | 用“字符个数”而非“种类数”判断窗口覆盖 | 维护 need_cnt 表示缺少的字符种类数 |
| 子集 | 只在叶子节点收集结果,丢掉中间子集 | 每个递归节点都收集当前路径 |
| 单词搜索 | 递归完忘记撤销访问标记 | 进入格子标记、退出时恢复原字符 |
| 柱状图最大矩形 | 遍历完栈中还剩柱子未结算 | 末尾补 0 强制清空栈 |
| 最大矩形 | 遇到 0 不清零高度数组 | 0 会打断向上连续 1 的高度,必须清零 |
| 验证 BST | 只比较当前节点和左右孩子的值 | 需要全局约束(中序递增或区间法) |
这张表里七条,每一条都对应着一类常见的“看似没问题但提交就是错”的情形。如果你第一遍做完发现某个用例没过,优先检查这几个位置,大概率能快速定位。
6.2 相邻题组联动刷法
如果只是按照题号一道一道刷,做完十道题可能还是觉得知识点很散。我建议按以下四条线重新组织二刷:
- 双指针线:颜色分类 → 三数之和 → 接雨水。这条线练的是“指针移动条件”的判断,核心是明确每个指针的含义和更新时机。
- 滑动窗口线:最小覆盖子串 → 无重复字符的最长子串 → 字符串排列。这条线的核心是窗口扩张和收缩的条件,建议重点理解
need_cnt的优化思路。 - 单调栈线:柱状图最大矩形 → 最大矩形 → 每日温度。这条线算是进阶版,理解了“单调递增栈维护左边界”这一条,整个系列都能串起来。
- 二叉树线:中序遍历 → 验证 BST → 对称二叉树 → 层序遍历。这四道是二叉树的基础套餐,递归和迭代的写法学一遍,后面做什么“最近公共祖先”“二叉树展开为链表”都会轻松很多。
一刷刷到能默写,二刷按这条线串联,三刷只看错误笔记。三轮下来,这个区间基本就焊死在脑子里了。
6.3 刷题节奏与时间分配建议
Hot 100 越往后题目越综合,建议不要一天刷太多道。71-80 里有两道比较难(最小覆盖子串和最大矩形),单独给它们留一天是合理的。其他八道按题型分组,每天 2-3 道即可。如果当天题目顺心,可以额外再做一道同类题巩固;如果不顺心,停下来看看题解,比硬磕两个小时收获大得多。
时间分配上,每道题控制在 30-40 分钟。超过这个时间还没有思路,就先看题解,看懂后合上书自己默写一遍。这个默写过程特别重要,它能暴露出很多“以为会了但写不出来”的地方。
我个人体会是,71-80 这个区间最值得反复做的是“最小覆盖子串”和“柱状图中最大的矩形”。这两道题分别代表了“滑动窗口”和“单调栈”两种最重要的算法思想,搞懂它们,比刷完整个 Hot 100 的后半段还有价值。而且它们在后续刷“接雨水”“最大矩形”“最短无序连续子数组”这些题时都会反复用到,今天多花的每一分钟,后面都会加倍还回来。
