本周总览
7 天 / 7 个高频模板每日安排
每天 3 道必刷 + 晚间复习数组快慢指针
模板:原地修改数组 / 删除元素 / 保持相对顺序
识别信号
- 原地修改
- 返回新长度
- 删除某元素
- 把满足条件的元素放前面
必刷题
- 27. 移除元素
- 26. 删除有序数组中的重复项
- 283. 移动零
可选题
- 80. 删除有序数组中的重复项 II
- 905. 按奇偶排序数组
哈希表
模板:set / map 判重、计数、配对
识别信号
- 是否存在
- 查找配对
- 统计频率
- 找重复元素
必刷题
- 1. 两数之和
- 242. 有效的字母异位词
- 217. 存在重复元素
可选题
- 202. 快乐数
- 49. 字母异位词分组
链表基础
模板:dummy 节点 / 遍历 / 指针修改
识别信号
- 删除链表节点
- 头节点边界处理
- 反转链表
- 合并链表
必刷题
- 206. 反转链表
- 21. 合并两个有序链表
- 83. 删除排序链表中的重复元素
可选题
- 203. 移除链表元素
- 24. 两两交换链表中的节点
快慢指针
模板:找中点 / 判断有环 / 找倒数第 N 个
识别信号
- 中间节点
- 环
- 倒数第 N 个
- 一次遍历解决
必刷题
- 876. 链表的中间结点
- 141. 环形链表
- 19. 删除链表的倒数第 N 个结点
可选题
- 234. 回文链表
- 142. 环形链表 II
栈
模板:括号匹配 / 最近相关元素 / 后进先出
识别信号
- 括号匹配
- 配对合法性
- 后进先出
- 最近一个未处理元素
必刷题
- 20. 有效括号
- 155. 最小栈
- 232. 用栈实现队列
可选题
- 150. 逆波兰表达式求值
- 394. 字符串解码
二分查找
模板:标准二分 / 左右边界 / 有序查找
识别信号
- 有序数组
- O(log n)
- 第一个 / 最后一个满足条件的位置
- 单调性
必刷题
- 704. 二分查找
- 35. 搜索插入位置
- 34. 在排序数组中查找元素的第一个和最后一个位置
可选题
- 33. 搜索旋转排序数组
- 162. 寻找峰值
滑动窗口
模板:连续区间 / 动态维护状态 / 最长最短子串子数组
识别信号
- 连续子串 / 子数组
- 最长 / 最短
- 满足某种条件
- 窗口右扩左缩
必刷题
- 209. 长度最小的子数组
- 3. 无重复字符的最长子串
- 438. 找到字符串中所有字母异位词
可选题
- 567. 字符串的排列
- 76. 最小覆盖子串
Day 1 详解:数组快慢指针
可扩展内容样板这一类题到底在做什么
本质是在 一次遍历 里,把“该保留的数据”稳定地写回数组前部。fast 负责扫描原数组,slow 负责写入下一个有效位置。
- fast:一直往前读,遍历每个元素
- slow:只在遇到有效元素时写入并前进
- 返回值:通常返回新数组长度
slow
通用识别口诀
- 看到“原地修改数组” → 先想快慢指针
- 看到“删除某元素但不能开新数组” → fast 读、slow 写
- 看到“去重 / 压缩 / 移零 / 保序” → 快慢指针高频出现
- 看到“返回新长度” → slow 大概率就是答案
JS 通用模板
function compactArray(nums, isValid) {
let slow = 0;
for (let fast = 0; fast < nums.length; fast++) {
if (isValid(nums[fast], fast, nums)) {
nums[slow] = nums[fast];
slow++;
}
}
return slow;
}
适用:移除元素、保留满足条件的值、把有效元素收紧到前面。
LeetCode 27:移除元素
function removeElement(nums, val) {
let slow = 0;
for (let fast = 0; fast < nums.length; fast++) {
if (nums[fast] !== val) {
nums[slow] = nums[fast];
slow++;
}
}
return slow;
}
LeetCode 26:删除有序数组中的重复项
function removeDuplicates(nums) {
if (nums.length === 0) return 0;
let slow = 1;
for (let fast = 1; fast < nums.length; fast++) {
if (nums[fast] !== nums[fast - 1]) {
nums[slow] = nums[fast];
slow++;
}
}
return slow;
}
LeetCode 283:移动零
function moveZeroes(nums) {
let slow = 0;
for (let fast = 0; fast < nums.length; fast++) {
if (nums[fast] !== 0) {
[nums[slow], nums[fast]] = [nums[fast], nums[slow]];
slow++;
}
}
}
刷题思路模板
- 先问:这题是不是要求 原地 处理?
- 再问:哪些元素应该被保留?
- 让
fast扫完整个数组 - 当元素应该保留时,写到
slow位置 - 最后返回
slow,或者把slow前面的结果作为有效区间
常见坑点
- 把
slow当成“当前元素下标”而不是“下一个写入位置” - 忘了题目要求是“原地修改”
- 26 题里去重时,比较对象写错了
- 283 题写完非零元素后,忘记处理后面的零,或者交换逻辑写乱
- 返回数组而不是返回新长度
今天建议你这样练
- 先背下来一句话:fast 读,slow 写
- 直接做 27,理解最基础模板
- 再做 26,体会“有序数组 + 去重”的变种
- 最后做 283,体会“保序 + 原地交换”
- 晚上不看答案,手写这 3 题的核心循环
Day 2 详解:哈希表
判重 / 计数 / 配对这一类题到底在做什么
哈希表的核心价值是把“查找某个值是否存在 / 出现过几次 / 是否能快速配对”从线性扫描降到接近 O(1)。面试里它经常出现在判重、词频统计、两数配对、分组问题。
- Set:只关心有没有出现过
- Map:关心某个值对应的位置、次数或补数
- 目标:用空间换时间,避免重复扫描
通用识别口诀
- 看到“是否存在重复” → 先想 Set
- 看到“统计出现次数” → 先想 Map
- 看到“找某个补数 / 配对值” → 先想 Map
- 看到“异位词 / 分组 / 频率匹配” → 哈希很高频
JS 通用模板
function countByMap(nums) {
const map = new Map();
for (const x of nums) {
map.set(x, (map.get(x) || 0) + 1);
}
return map;
}
function hasDuplicate(nums) {
const seen = new Set();
for (const x of nums) {
if (seen.has(x)) return true;
seen.add(x);
}
return false;
}
适用:判重、计数、匹配、记录下标。
LeetCode 1:两数之和
function twoSum(nums, target) {
const map = new Map();
for (let i = 0; i < nums.length; i++) {
const need = target - nums[i];
if (map.has(need)) {
return [map.get(need), i];
}
map.set(nums[i], i);
}
}
LeetCode 242:有效的字母异位词
function isAnagram(s, t) {
if (s.length !== t.length) return false;
const map = new Map();
for (const ch of s) {
map.set(ch, (map.get(ch) || 0) + 1);
}
for (const ch of t) {
if (!map.has(ch) || map.get(ch) === 0) return false;
map.set(ch, map.get(ch) - 1);
}
return true;
}
LeetCode 217:存在重复元素
function containsDuplicate(nums) {
const seen = new Set();
for (const num of nums) {
if (seen.has(num)) return true;
seen.add(num);
}
return false;
}
刷题思路模板
- 先判断题目是“判重”“计数”还是“找配对”
- 决定用
Set还是Map - 遍历时同步维护出现状态
- 如果要找配对,先查再存,避免用到自己
- 如果要做频率匹配,遍历结束前就判断是否失衡
常见坑点
- 两数之和里先存后查,导致元素重复使用自己
- 字母异位词只统计一边,不做抵消校验
- 忘了长度不同可以直接返回 false
- Set 和 Map 用混,明明要次数却只判存在
- 键值类型不同导致判等异常
今天建议你这样练
- 先背一句:有没有用 Set,多少次用 Map
- 先做 217,熟悉最简单判重
- 再做 242,理解计数与抵消
- 最后做 1,理解“补数 + 下标记录”
- 晚上默写两数之和和字母计数模板
Day 3 详解:链表基础
dummy / 遍历 / 改指针这一类题到底在做什么
链表题的核心不是背 API,而是理解 指针关系怎么变。你要一直明确:当前节点是谁、前一个是谁、下一步会不会断链、头节点是否需要额外处理。
- dummy:解决头节点变动问题
- cur:当前处理到的节点
- next:改指针前先存下来,避免断链
通用识别口诀
- 看到头节点可能变化 → 先想 dummy
- 看到反转 / 删除 / 合并 → 先画指针图
- 看到链表边界处理麻烦 → dummy 往往能简化
- 看到“原地改链表” → 每次只改一小步
JS 通用模板:dummy + 遍历
function removeWithDummy(head, target) {
const dummy = new ListNode(0, head);
let cur = dummy;
while (cur.next) {
if (cur.next.val === target) {
cur.next = cur.next.next;
} else {
cur = cur.next;
}
}
return dummy.next;
}
适用:删除节点、跳过某类节点、统一头节点边界。
LeetCode 206:反转链表
function reverseList(head) {
let prev = null;
let cur = head;
while (cur) {
const next = cur.next;
cur.next = prev;
prev = cur;
cur = next;
}
return prev;
}
LeetCode 21:合并两个有序链表
function mergeTwoLists(list1, list2) {
const dummy = new ListNode(0);
let cur = dummy;
while (list1 && list2) {
if (list1.val <= list2.val) {
cur.next = list1;
list1 = list1.next;
} else {
cur.next = list2;
list2 = list2.next;
}
cur = cur.next;
}
cur.next = list1 || list2;
return dummy.next;
}
LeetCode 83:删除排序链表中的重复元素
function deleteDuplicates(head) {
let cur = head;
while (cur && cur.next) {
if (cur.val === cur.next.val) {
cur.next = cur.next.next;
} else {
cur = cur.next;
}
}
return head;
}
刷题思路模板
- 先画出 2~3 个节点的小图
- 明确本轮要改哪一条
next - 改指针前先保存
next - 考虑头节点会不会变化,决定是否加
dummy - 每轮操作后检查链表有没有断
常见坑点
- 改指针前没存
next,导致断链 - 头节点变化却没用 dummy
- while 条件写错,访问空节点
- 以为链表题靠背,其实核心是画图
- 合并链表后忘了接上剩余部分
今天建议你这样练
- 先画图理解反转链表的三指针变化
- 做 206,练最核心的改指针动作
- 再做 21,练 dummy 和尾部拼接
- 最后做 83,练重复节点删除
- 晚上不看答案重画反转和合并过程
Day 4 详解:快慢指针
找中点 / 判环 / 倒数第 N 个这一类题到底在做什么
快慢指针的核心是让两个指针以 不同速度或间隔 前进,从而在一次遍历里拿到中点、倒数位置,或判断链表里是否有环。
- slow / fast:速度不同,制造位置差
- gap:也可以先拉开距离,再一起走
- 目标:一次遍历解决定位问题
通用识别口诀
- 看到“中间节点” → slow 一步、fast 两步
- 看到“是否有环” → Floyd 判环
- 看到“倒数第 N 个” → 先拉开 n 步距离
- 看到“只能遍历一次” → 很可能是快慢指针
JS 通用模板:找中点
function findMiddle(head) {
let slow = head;
let fast = head;
while (fast && fast.next) {
slow = slow.next;
fast = fast.next.next;
}
return slow;
}
适用:中点、前后半部分分割、回文链表预处理。
LeetCode 876:链表的中间结点
function middleNode(head) {
let slow = head;
let fast = head;
while (fast && fast.next) {
slow = slow.next;
fast = fast.next.next;
}
return slow;
}
LeetCode 141:环形链表
function hasCycle(head) {
let slow = head;
let fast = head;
while (fast && fast.next) {
slow = slow.next;
fast = fast.next.next;
if (slow === fast) return true;
}
return false;
}
LeetCode 19:删除链表的倒数第 N 个结点
function removeNthFromEnd(head, n) {
const dummy = new ListNode(0, head);
let fast = dummy;
let slow = dummy;
for (let i = 0; i < n; i++) {
fast = fast.next;
}
while (fast.next) {
fast = fast.next;
slow = slow.next;
}
slow.next = slow.next.next;
return dummy.next;
}
刷题思路模板
- 先问是“不同速度”还是“固定间隔”问题
- 确定两个指针从哪里出发
- 确定循环终止条件,避免空指针
- 如果涉及删除节点,优先加 dummy
- 重点画出 fast 每轮比 slow 多走了多少
常见坑点
- while 条件漏写
fast.next - 删除倒数节点时,fast 先走的步数错一位
- 忘了 dummy,删头节点会炸
- slow / fast 初始位置不统一,结果偏移
- 只会背模板,不会解释为什么能相遇
今天建议你这样练
- 先做 876,熟悉最简单快慢指针节奏
- 再做 141,理解为什么快的一定会追上慢的
- 最后做 19,练“间隔 + 删除节点”组合
- 晚上默写找中点和删倒数节点模板
- 口头讲清楚“快慢指针为什么有效”
Day 5 详解:栈
后进先出 / 配对 / 最近相关这一类题到底在做什么
栈的核心是 后进先出(LIFO)。凡是“最近一个还没处理完的东西”需要被优先处理,栈几乎都会出现,比如括号匹配、表达式求值、路径回退。
- push:压入待处理信息
- pop:处理最近加入的元素
- peek:看栈顶但不弹出
通用识别口诀
- 看到“括号是否合法” → 栈
- 看到“最近一个相关元素” → 栈
- 看到“撤销 / 回退 / 逆序处理” → 栈
- 看到“配对关系嵌套” → 栈非常常见
JS 通用模板
function processWithStack(items) {
const stack = [];
for (const item of items) {
if (shouldPush(item, stack)) {
stack.push(item);
} else {
stack.pop();
}
}
return stack;
}
适用:括号匹配、抵消、最近状态维护。
LeetCode 20:有效括号
function isValid(s) {
const stack = [];
const map = {
')': '(',
']': '[',
'}': '{'
};
for (const ch of s) {
if (!(ch in map)) {
stack.push(ch);
} else {
if (stack.pop() !== map[ch]) return false;
}
}
return stack.length === 0;
}
LeetCode 155:最小栈
var MinStack = function() {
this.stack = [];
this.minStack = [];
};
MinStack.prototype.push = function(val) {
this.stack.push(val);
if (!this.minStack.length || val <= this.minStack[this.minStack.length - 1]) {
this.minStack.push(val);
}
};
MinStack.prototype.pop = function() {
const val = this.stack.pop();
if (val === this.minStack[this.minStack.length - 1]) {
this.minStack.pop();
}
};
LeetCode 232:用栈实现队列
var MyQueue = function() {
this.inStack = [];
this.outStack = [];
};
MyQueue.prototype.push = function(x) {
this.inStack.push(x);
};
MyQueue.prototype.move = function() {
if (!this.outStack.length) {
while (this.inStack.length) {
this.outStack.push(this.inStack.pop());
}
}
};
MyQueue.prototype.pop = function() {
this.move();
return this.outStack.pop();
};
刷题思路模板
- 先问:是不是在处理“最近一个未完成状态”
- 定义清楚什么时候 push,什么时候 pop
- 考虑栈里存的是值、下标,还是状态
- 如果要维护额外信息,考虑辅助栈
- 最后检查栈是否为空,判断是否合法完成
常见坑点
- 括号题最后忘了检查栈是否为空
- 弹栈前没判断是否为空
- 最小栈没有同步维护最小值
- 队列题里 outStack 不为空时还重复搬运
- 只知道用数组模拟栈,不知道栈里该存什么
今天建议你这样练
- 先做 20,快速建立括号匹配直觉
- 再做 155,理解辅助栈的作用
- 最后做 232,理解两个栈如何模拟队列
- 晚上默写括号题和最小栈核心逻辑
- 记住一句话:最近未处理的,优先想到栈
Day 6 详解:二分查找
有序 / 单调 / 边界定位这一类题到底在做什么
二分的本质不是“数组中间取值”,而是利用 有序性或单调性,每次淘汰一半不可能答案的区域。关键不是会不会写,而是能不能把边界写对。
- 标准二分:找某个目标值
- 边界二分:找第一个 / 最后一个满足条件的位置
- 单调判定:答案空间也可以二分
通用识别口诀
- 看到有序数组 → 先想二分
- 看到要求 O(log n) → 高度怀疑二分
- 看到“第一个 / 最后一个满足条件” → 边界二分
- 看到答案具有单调性 → 可以二分答案
JS 通用模板:标准二分
function binarySearch(nums, target) {
let left = 0;
let right = nums.length - 1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
if (nums[mid] === target) return mid;
if (nums[mid] < target) left = mid + 1;
else right = mid - 1;
}
return -1;
}
先固定一套模板,再从这个基础上推边界版。
LeetCode 704:二分查找
function search(nums, target) {
let left = 0;
let right = nums.length - 1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
if (nums[mid] === target) return mid;
if (nums[mid] < target) left = mid + 1;
else right = mid - 1;
}
return -1;
}
LeetCode 35:搜索插入位置
function searchInsert(nums, target) {
let left = 0;
let right = nums.length - 1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
if (nums[mid] < target) left = mid + 1;
else right = mid - 1;
}
return left;
}
LeetCode 34:查找第一个和最后一个位置
function lowerBound(nums, target) {
let left = 0;
let right = nums.length;
while (left < right) {
const mid = Math.floor((left + right) / 2);
if (nums[mid] < target) left = mid + 1;
else right = mid;
}
return left;
}
刷题思路模板
- 先问:数组是否有序,或答案是否具有单调性
- 确认这题要找“值”还是“边界”
- 固定区间定义:
[left, right]还是[left, right) - 根据区间定义写更新规则,不能混用
- 最后单独验证边界:空数组、单元素、找不到
常见坑点
left <= right和left < right混用- mid 命中后边界移动错误
- 找插入位置时返回错了 right
- 找左右边界时没有统一半开半闭区间
- 只会背一种模板,不知道为什么这样收缩区间
今天建议你这样练
- 先做 704,把标准版写到闭眼能默写
- 再做 35,理解“没找到时 left 的意义”
- 最后做 34,进入边界二分
- 晚上重点复盘区间定义和边界更新
- 别死记,必须说清楚每次舍弃了哪半边
Day 7 详解:滑动窗口
连续区间 / 右扩左缩 / 动态维护这一类题到底在做什么
滑动窗口的核心是在一个 连续区间 上动态维护状态。右指针负责扩张窗口,左指针负责在条件不满足或已经满足但还能优化时收缩窗口。
- right:把新元素纳入窗口
- left:缩小窗口,维持约束
- 状态:窗口内的计数、和、种类数等
通用识别口诀
- 看到连续子串 / 子数组 → 先想滑窗
- 看到最长 / 最短满足条件 → 很可能是滑窗
- 看到“窗口内维护某种状态” → 滑窗高频
- 如果是非连续子序列,就别乱套滑窗
JS 通用模板
function slidingWindow(nums) {
let left = 0;
for (let right = 0; right < nums.length; right++) {
add(nums[right]);
while (needShrink()) {
remove(nums[left]);
left++;
}
updateAnswer(left, right);
}
}
核心是先右扩加入状态,再在 while 里左缩。
LeetCode 209:长度最小的子数组
function minSubArrayLen(target, nums) {
let left = 0;
let sum = 0;
let ans = Infinity;
for (let right = 0; right < nums.length; right++) {
sum += nums[right];
while (sum >= target) {
ans = Math.min(ans, right - left + 1);
sum -= nums[left];
left++;
}
}
return ans === Infinity ? 0 : ans;
}
LeetCode 3:无重复字符的最长子串
function lengthOfLongestSubstring(s) {
let left = 0;
let ans = 0;
const set = new Set();
for (let right = 0; right < s.length; right++) {
while (set.has(s[right])) {
set.delete(s[left]);
left++;
}
set.add(s[right]);
ans = Math.max(ans, right - left + 1);
}
return ans;
}
LeetCode 438:找到字符串中所有字母异位词
function findAnagrams(s, p) {
const need = new Map();
const window = new Map();
const res = [];
let left = 0;
let valid = 0;
for (const ch of p) {
need.set(ch, (need.get(ch) || 0) + 1);
}
for (let right = 0; right < s.length; right++) {
const ch = s[right];
if (need.has(ch)) {
window.set(ch, (window.get(ch) || 0) + 1);
if (window.get(ch) === need.get(ch)) valid++;
}
while (right - left + 1 > p.length) {
const d = s[left];
if (need.has(d)) {
if (window.get(d) === need.get(d)) valid--;
window.set(d, window.get(d) - 1);
}
left++;
}
if (valid === need.size) res.push(left);
}
return res;
}
刷题思路模板
- 先确认是不是“连续区间”问题
- 定义窗口里要维护的状态:和、频率、种类数等
- 右指针每前进一步,就更新状态
- 条件不合法或可优化时,用 while 左缩
- 在合适时机更新答案,不要漏掉窗口变化后的结果
常见坑点
- 把非连续问题错当滑窗
- 右扩后忘了更新状态
- 左缩时没有同步删除窗口状态
- 答案更新时机不对,导致少算
- while 缩窗条件和题意不匹配
今天建议你这样练
- 先做 209,熟悉最经典的右扩左缩
- 再做 3,理解“去重窗口”
- 最后做 438,练频率型滑窗
- 晚上默写滑窗通用框架和两个典型状态维护方式
- 重点区分:什么时候扩,什么时候缩,窗口里维护什么
进阶补充 1:树基础
二叉树 / 遍历 / 层次结构这一类题到底在做什么
树题的核心是理解 节点之间的层级关系。和数组、链表不同,树不是线性的,你在做题时要明确当前节点、左子树、右子树各自承担什么信息。
- 当前节点:本层要处理的对象
- 左 / 右子树:递归或遍历的下一级问题
- 典型目标:遍历、统计高度、判断性质、构造结果
通用识别口诀
- 看到父子关系、左右孩子 → 树
- 看到“每个节点都要处理一次” → 遍历思维
- 看到“层级 / 最短层数 / 每层结果” → BFS
- 看到“路径 / 高度 / 子树信息” → DFS / 递归很常见
JS 通用模板:前序遍历
function preorderTraversal(root) {
const result = [];
function dfs(node) {
if (!node) return;
result.push(node.val);
dfs(node.left);
dfs(node.right);
}
dfs(root);
return result;
}
前序:根 → 左 → 右。适合先处理当前节点,再处理子树。
LeetCode 104:二叉树的最大深度
function maxDepth(root) {
if (!root) return 0;
return Math.max(maxDepth(root.left), maxDepth(root.right)) + 1;
}
LeetCode 226:翻转二叉树
function invertTree(root) {
if (!root) return null;
const temp = root.left;
root.left = invertTree(root.right);
root.right = invertTree(temp);
return root;
}
LeetCode 102:二叉树的层序遍历
function levelOrder(root) {
if (!root) return [];
const result = [];
const queue = [root];
while (queue.length) {
const size = queue.length;
const level = [];
for (let i = 0; i < size; i++) {
const node = queue.shift();
level.push(node.val);
if (node.left) queue.push(node.left);
if (node.right) queue.push(node.right);
}
result.push(level);
}
return result;
}
刷题思路模板
- 先问:这题是处理“节点本身”,还是处理“整层”
- 如果是单节点逻辑,多半是 DFS / 递归
- 如果是按层输出、最短步数,多半是 BFS
- 明确子问题:左子树和右子树各返回什么
- 最后检查空树、叶子节点、单边树这些边界
常见坑点
- 把树题当链表题,只沿一条线思考
- 递归返回值定义不清楚
- 层序遍历时没有按层处理 size
- 忘了先处理空节点
- 前中后序顺序写混
今天建议你这样练
- 先做 104,建立“树高 = 子树高 + 1”的感觉
- 再做 226,理解递归如何自顶向下改结构
- 最后做 102,补上按层遍历思维
- 晚上默写前序遍历和层序遍历模板
- 重点练:这题到底该按节点做,还是按层做
进阶补充 2:DFS / BFS
遍历框架 / 图搜索 / 层级搜索这一类题到底在做什么
DFS 是一路走到底再回退,适合枚举路径、深入结构、递归处理;BFS 是一层一层扩散,适合最短步数、层级遍历、最先到达问题。
- DFS:深度优先,常和递归绑定
- BFS:广度优先,通常配队列
- visited:图题里常用来防止重复访问
通用识别口诀
- 看到“所有路径 / 所有方案 / 深入到底” → DFS
- 看到“最短步数 / 最少转换次数 / 一层层扩散” → BFS
- 看到网格、图、岛屿 → DFS/BFS 高频
- 看到需要防止重复访问 → visited 很关键
JS 通用模板:DFS
function dfs(node, visited) {
if (!node || visited.has(node)) return;
visited.add(node);
for (const next of node.neighbors) {
dfs(next, visited);
}
}
适用:图连通性、岛屿问题、路径枚举、树深度遍历。
JS 通用模板:BFS
function bfs(start) {
const queue = [start];
const visited = new Set([start]);
while (queue.length) {
const cur = queue.shift();
for (const next of cur.neighbors) {
if (visited.has(next)) continue;
visited.add(next);
queue.push(next);
}
}
}
适用:最短路径层数、图层级遍历、状态扩散。
LeetCode 200:岛屿数量(DFS)
function numIslands(grid) {
const m = grid.length;
const n = grid[0].length;
let count = 0;
function dfs(i, j) {
if (i < 0 || i >= m || j < 0 || j >= n || grid[i][j] !== '1') return;
grid[i][j] = '0';
dfs(i + 1, j);
dfs(i - 1, j);
dfs(i, j + 1);
dfs(i, j - 1);
}
for (let i = 0; i < m; i++) {
for (let j = 0; j < n; j++) {
if (grid[i][j] === '1') {
count++;
dfs(i, j);
}
}
}
return count;
}
LeetCode 111:二叉树的最小深度(BFS)
function minDepth(root) {
if (!root) return 0;
const queue = [[root, 1]];
while (queue.length) {
const [node, depth] = queue.shift();
if (!node.left && !node.right) return depth;
if (node.left) queue.push([node.left, depth + 1]);
if (node.right) queue.push([node.right, depth + 1]);
}
}
刷题思路模板
- 先判断你要“找所有可能”,还是“找最先到达”
- 如果要最短层数,优先 BFS
- 如果要深挖结构、递归子问题,优先 DFS
- 图题一定先想 visited 或原地标记
- 网格题默认先准备四个方向
常见坑点
- 图题没做 visited,直接死循环
- BFS 没分层,算不出真实步数
- DFS 回退时状态没恢复
- 网格边界判断漏写
- 把 DFS 和递归混成一个黑盒,不理解访问顺序
今天建议你这样练
- 先做 200,练 DFS 淹没连通块
- 再做 111,理解为什么最短深度优先 BFS
- 然后口头总结 DFS 和 BFS 的使用场景
- 晚上默写网格 DFS 和队列 BFS 框架
- 记住:最短步数优先 BFS,枚举结构优先 DFS
进阶补充 3:递归
子问题 / 终止条件 / 返回值定义这一类题到底在做什么
递归的核心不是“函数调用自己”,而是把大问题拆成 结构相同的更小子问题。你必须明确三件事:终止条件是什么、递归函数返回什么、本层如何利用子问题答案。
- 终止条件:什么时候停
- 函数定义:这个函数到底返回什么
- 合并逻辑:当前层如何拼子结果
通用识别口诀
- 看到树、分治、嵌套结构 → 先想递归
- 看到“当前问题 = 若干子问题结果组合” → 递归味很浓
- 写不出递归,通常是函数定义不清
- 递归题先写终止条件,再写递归关系
JS 通用模板
function solve(node) {
if (isBaseCase(node)) {
return baseAnswer;
}
const left = solve(node.left);
const right = solve(node.right);
return combine(left, right, node);
}
适用:树高、分治、结构递归、很多 DFS 子问题。
LeetCode 100:相同的树
function isSameTree(p, q) {
if (!p && !q) return true;
if (!p || !q) return false;
if (p.val !== q.val) return false;
return isSameTree(p.left, q.left) && isSameTree(p.right, q.right);
}
LeetCode 101:对称二叉树
function isSymmetric(root) {
function check(left, right) {
if (!left && !right) return true;
if (!left || !right) return false;
if (left.val !== right.val) return false;
return check(left.left, right.right) && check(left.right, right.left);
}
return check(root.left, root.right);
}
LeetCode 50:Pow(x, n)
function myPow(x, n) {
if (n === 0) return 1;
if (n < 0) return 1 / myPow(x, -n);
const half = myPow(x, Math.floor(n / 2));
if (n % 2 === 0) return half * half;
return half * half * x;
}
刷题思路模板
- 先写一句话定义函数含义
- 写出最小输入时的终止条件
- 假设子问题已经能解,思考当前层如何拼结果
- 检查参数每次是否更接近终止条件
- 最后验证是否会重复计算或爆栈
常见坑点
- 函数定义没想清楚就开始写
- 终止条件不完整,递归停不下来
- 子问题结果不会组合
- 参数没有朝 base case 收缩
- 把递归当魔法,不去画调用树
今天建议你这样练
- 先做 100,练最基础双树递归
- 再做 101,理解镜像式递归
- 最后做 50,感受递归 + 分治
- 晚上自己说清楚:函数定义、base case、返回值
- 每道题都强迫自己先画递归树
进阶补充 4:回溯
决策树 / 试探 / 撤销选择这一类题到底在做什么
回溯本质是在一棵 决策树 上做 DFS:做选择、往下试、发现不行就撤销。它特别适合全排列、子集、组合、切割、棋盘类约束搜索。
- path:当前已经做出的选择
- choices:当前层还能选什么
- backtrack:撤销上一步,回到现场
通用识别口诀
- 看到“所有组合 / 所有排列 / 所有切分” → 回溯
- 看到“枚举全部合法答案” → 回溯高频
- 看到“选择 → 递归 → 撤销” → 就是回溯模板
- 如果要求一个个列出方案,而不是只求个数,很可能回溯
JS 通用模板
function backtrack(nums) {
const result = [];
const path = [];
function dfs(start) {
if (shouldCollect(path)) {
result.push([...path]);
}
for (let i = start; i < nums.length; i++) {
path.push(nums[i]);
dfs(i + 1);
path.pop();
}
}
dfs(0);
return result;
}
核心固定三步:做选择 → 递归 → 撤销选择。
LeetCode 46:全排列
function permute(nums) {
const result = [];
const path = [];
const used = new Array(nums.length).fill(false);
function dfs() {
if (path.length === nums.length) {
result.push([...path]);
return;
}
for (let i = 0; i < nums.length; i++) {
if (used[i]) continue;
used[i] = true;
path.push(nums[i]);
dfs();
path.pop();
used[i] = false;
}
}
dfs();
return result;
}
LeetCode 78:子集
function subsets(nums) {
const result = [];
const path = [];
function dfs(start) {
result.push([...path]);
for (let i = start; i < nums.length; i++) {
path.push(nums[i]);
dfs(i + 1);
path.pop();
}
}
dfs(0);
return result;
}
LeetCode 39:组合总和
function combinationSum(candidates, target) {
const result = [];
const path = [];
function dfs(start, sum) {
if (sum === target) {
result.push([...path]);
return;
}
if (sum > target) return;
for (let i = start; i < candidates.length; i++) {
path.push(candidates[i]);
dfs(i, sum + candidates[i]);
path.pop();
}
}
dfs(0, 0);
return result;
}
刷题思路模板
- 先画决策树:每一层在选什么
- 定义 path 表示当前已选结果
- 明确终止条件:什么时候收集答案,什么时候剪枝
- 循环里固定写:选 → 递归 → 撤销
- 根据题意决定下一层是
i + 1还是i
常见坑点
- 忘了
pop(),状态污染后续分支 - 答案里直接 push path,没拷贝数组
- start 索引写错,导致重复或漏解
- 剪枝条件不完整,效率爆炸
- 分不清排列、组合、子集三种决策树结构
今天建议你这样练
- 先做 78,理解最基础的选 / 不选
- 再做 46,补上 used 数组的排列模型
- 最后做 39,理解 start 和剪枝
- 晚上默写统一回溯模板
- 重点区分:组合、排列、子集分别怎么写
后续内容如何设计优化
建议的数据结构每个 Day 固定 6 个模块
- 模板定义:这类题在解决什么问题
- 识别信号:看到什么题意想到这个模板
- 通用代码模板:JS / 以后也可加 Python
- 例题拆解:1~3 道经典题的解法思路
- 坑点清单:边界、返回值、指针更新顺序
- 复习提示:晚上默写什么、复盘什么
建议的页面形态
- 顶部保留周视图,方便总览
- 每个 Day 增加“展开详解”区块
- 代码块统一支持复制按钮(下一版可加 JS)
- 每道题都能挂“我的思路 / 易错点 / 一句话总结”
- 后面可加入本地打卡状态和已完成标记
最适合你的迭代顺序
- 先把 Day 1 ~ Day 7 全部补成这种详解版
- 再给每道题加题解思路
- 再补 JS 模板库总页
- 最后加本地打卡、折叠、筛选和搜索
每日执行方式
复健核心:模板驱动白天
- 先看模板 10~15 分钟
- 搞清适用场景、识别信号、核心框架、易错点
- 刷 3 道必刷题
- 有余力再做 1~2 道可选题
晚上
- 重做当天最卡的 1~2 道题
- 不看答案默写模板
- 写一句总结:看到什么信号就该想到这个模板
最低完成标准
- 每天 3 道必刷题
- 每天复习 1 道错题
- 每天默写 1 个模板
- 一周完成 21 道核心题
本周核心题单
21 道- 27. 移除元素
- 26. 删除有序数组中的重复项
- 283. 移动零
- 1. 两数之和
- 242. 有效的字母异位词
- 217. 存在重复元素
- 206. 反转链表
- 21. 合并两个有序链表
- 83. 删除排序链表中的重复元素
- 876. 链表的中间结点
- 141. 环形链表
- 19. 删除链表的倒数第 N 个结点
- 20. 有效括号
- 155. 最小栈
- 232. 用栈实现队列
- 704. 二分查找
- 35. 搜索插入位置
- 34. 在排序数组中查找元素的第一个和最后一个位置
- 209. 长度最小的子数组
- 3. 无重复字符的最长子串
- 438. 找到字符串中所有字母异位词