DSA Interview Rehab

算法模板学习计划

按模板学习、按题型刷题、晚上复习默写。先把手感找回来,再逐步扩展到更多题型。

本周总览

7 天 / 7 个高频模板
Day 1数组快慢指针
Day 2哈希表
Day 3链表基础
Day 4快慢指针
Day 5
Day 6二分查找
Day 7滑动窗口

每日安排

每天 3 道必刷 + 晚间复习
Day 1

数组快慢指针

模板:原地修改数组 / 删除元素 / 保持相对顺序

识别信号

  • 原地修改
  • 返回新长度
  • 删除某元素
  • 把满足条件的元素放前面

必刷题

  1. 27. 移除元素
  2. 26. 删除有序数组中的重复项
  3. 283. 移动零

可选题

  • 80. 删除有序数组中的重复项 II
  • 905. 按奇偶排序数组
晚上:重做最卡的一题,默写快慢指针模板,总结“fast 读 / slow 写”。
Day 2

哈希表

模板:set / map 判重、计数、配对

识别信号

  • 是否存在
  • 查找配对
  • 统计频率
  • 找重复元素

必刷题

  1. 1. 两数之和
  2. 242. 有效的字母异位词
  3. 217. 存在重复元素

可选题

  • 202. 快乐数
  • 49. 字母异位词分组
晚上:默写 set 判重模板和 map 计数模板,区分“有没有”和“出现几次”。
Day 3

链表基础

模板:dummy 节点 / 遍历 / 指针修改

识别信号

  • 删除链表节点
  • 头节点边界处理
  • 反转链表
  • 合并链表

必刷题

  1. 206. 反转链表
  2. 21. 合并两个有序链表
  3. 83. 删除排序链表中的重复元素

可选题

  • 203. 移除链表元素
  • 24. 两两交换链表中的节点
晚上:不看答案手写反转链表,先画图再改指针。
Day 4

快慢指针

模板:找中点 / 判断有环 / 找倒数第 N 个

识别信号

  • 中间节点
  • 倒数第 N 个
  • 一次遍历解决

必刷题

  1. 876. 链表的中间结点
  2. 141. 环形链表
  3. 19. 删除链表的倒数第 N 个结点

可选题

  • 234. 回文链表
  • 142. 环形链表 II
晚上:复盘边界条件,默写找中点与找倒数节点的写法。
Day 5

模板:括号匹配 / 最近相关元素 / 后进先出

识别信号

  • 括号匹配
  • 配对合法性
  • 后进先出
  • 最近一个未处理元素

必刷题

  1. 20. 有效括号
  2. 155. 最小栈
  3. 232. 用栈实现队列

可选题

  • 150. 逆波兰表达式求值
  • 394. 字符串解码
晚上:重写有效括号,说明为什么括号题天然适合栈。
Day 6

二分查找

模板:标准二分 / 左右边界 / 有序查找

识别信号

  • 有序数组
  • O(log n)
  • 第一个 / 最后一个满足条件的位置
  • 单调性

必刷题

  1. 704. 二分查找
  2. 35. 搜索插入位置
  3. 34. 在排序数组中查找元素的第一个和最后一个位置

可选题

  • 33. 搜索旋转排序数组
  • 162. 寻找峰值
晚上:默写标准二分模板,重点检查 left/right/mid 和边界收缩。
Day 7

滑动窗口

模板:连续区间 / 动态维护状态 / 最长最短子串子数组

识别信号

  • 连续子串 / 子数组
  • 最长 / 最短
  • 满足某种条件
  • 窗口右扩左缩

必刷题

  1. 209. 长度最小的子数组
  2. 3. 无重复字符的最长子串
  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++;
    }
  }
}

刷题思路模板

  1. 先问:这题是不是要求 原地 处理?
  2. 再问:哪些元素应该被保留?
  3. fast 扫完整个数组
  4. 当元素应该保留时,写到 slow 位置
  5. 最后返回 slow,或者把 slow 前面的结果作为有效区间

常见坑点

  • slow 当成“当前元素下标”而不是“下一个写入位置”
  • 忘了题目要求是“原地修改”
  • 26 题里去重时,比较对象写错了
  • 283 题写完非零元素后,忘记处理后面的零,或者交换逻辑写乱
  • 返回数组而不是返回新长度

今天建议你这样练

  1. 先背下来一句话:fast 读,slow 写
  2. 直接做 27,理解最基础模板
  3. 再做 26,体会“有序数组 + 去重”的变种
  4. 最后做 283,体会“保序 + 原地交换”
  5. 晚上不看答案,手写这 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;
}

刷题思路模板

  1. 先判断题目是“判重”“计数”还是“找配对”
  2. 决定用 Set 还是 Map
  3. 遍历时同步维护出现状态
  4. 如果要找配对,先查再存,避免用到自己
  5. 如果要做频率匹配,遍历结束前就判断是否失衡

常见坑点

  • 两数之和里先存后查,导致元素重复使用自己
  • 字母异位词只统计一边,不做抵消校验
  • 忘了长度不同可以直接返回 false
  • Set 和 Map 用混,明明要次数却只判存在
  • 键值类型不同导致判等异常

今天建议你这样练

  1. 先背一句:有没有用 Set,多少次用 Map
  2. 先做 217,熟悉最简单判重
  3. 再做 242,理解计数与抵消
  4. 最后做 1,理解“补数 + 下标记录”
  5. 晚上默写两数之和和字母计数模板

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;
}

刷题思路模板

  1. 先画出 2~3 个节点的小图
  2. 明确本轮要改哪一条 next
  3. 改指针前先保存 next
  4. 考虑头节点会不会变化,决定是否加 dummy
  5. 每轮操作后检查链表有没有断

常见坑点

  • 改指针前没存 next,导致断链
  • 头节点变化却没用 dummy
  • while 条件写错,访问空节点
  • 以为链表题靠背,其实核心是画图
  • 合并链表后忘了接上剩余部分

今天建议你这样练

  1. 先画图理解反转链表的三指针变化
  2. 做 206,练最核心的改指针动作
  3. 再做 21,练 dummy 和尾部拼接
  4. 最后做 83,练重复节点删除
  5. 晚上不看答案重画反转和合并过程

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;
}

刷题思路模板

  1. 先问是“不同速度”还是“固定间隔”问题
  2. 确定两个指针从哪里出发
  3. 确定循环终止条件,避免空指针
  4. 如果涉及删除节点,优先加 dummy
  5. 重点画出 fast 每轮比 slow 多走了多少

常见坑点

  • while 条件漏写 fast.next
  • 删除倒数节点时,fast 先走的步数错一位
  • 忘了 dummy,删头节点会炸
  • slow / fast 初始位置不统一,结果偏移
  • 只会背模板,不会解释为什么能相遇

今天建议你这样练

  1. 先做 876,熟悉最简单快慢指针节奏
  2. 再做 141,理解为什么快的一定会追上慢的
  3. 最后做 19,练“间隔 + 删除节点”组合
  4. 晚上默写找中点和删倒数节点模板
  5. 口头讲清楚“快慢指针为什么有效”

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();
};

刷题思路模板

  1. 先问:是不是在处理“最近一个未完成状态”
  2. 定义清楚什么时候 push,什么时候 pop
  3. 考虑栈里存的是值、下标,还是状态
  4. 如果要维护额外信息,考虑辅助栈
  5. 最后检查栈是否为空,判断是否合法完成

常见坑点

  • 括号题最后忘了检查栈是否为空
  • 弹栈前没判断是否为空
  • 最小栈没有同步维护最小值
  • 队列题里 outStack 不为空时还重复搬运
  • 只知道用数组模拟栈,不知道栈里该存什么

今天建议你这样练

  1. 先做 20,快速建立括号匹配直觉
  2. 再做 155,理解辅助栈的作用
  3. 最后做 232,理解两个栈如何模拟队列
  4. 晚上默写括号题和最小栈核心逻辑
  5. 记住一句话:最近未处理的,优先想到栈

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;
}

刷题思路模板

  1. 先问:数组是否有序,或答案是否具有单调性
  2. 确认这题要找“值”还是“边界”
  3. 固定区间定义:[left, right] 还是 [left, right)
  4. 根据区间定义写更新规则,不能混用
  5. 最后单独验证边界:空数组、单元素、找不到

常见坑点

  • left <= rightleft < right 混用
  • mid 命中后边界移动错误
  • 找插入位置时返回错了 right
  • 找左右边界时没有统一半开半闭区间
  • 只会背一种模板,不知道为什么这样收缩区间

今天建议你这样练

  1. 先做 704,把标准版写到闭眼能默写
  2. 再做 35,理解“没找到时 left 的意义”
  3. 最后做 34,进入边界二分
  4. 晚上重点复盘区间定义和边界更新
  5. 别死记,必须说清楚每次舍弃了哪半边

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;
}

刷题思路模板

  1. 先确认是不是“连续区间”问题
  2. 定义窗口里要维护的状态:和、频率、种类数等
  3. 右指针每前进一步,就更新状态
  4. 条件不合法或可优化时,用 while 左缩
  5. 在合适时机更新答案,不要漏掉窗口变化后的结果

常见坑点

  • 把非连续问题错当滑窗
  • 右扩后忘了更新状态
  • 左缩时没有同步删除窗口状态
  • 答案更新时机不对,导致少算
  • while 缩窗条件和题意不匹配

今天建议你这样练

  1. 先做 209,熟悉最经典的右扩左缩
  2. 再做 3,理解“去重窗口”
  3. 最后做 438,练频率型滑窗
  4. 晚上默写滑窗通用框架和两个典型状态维护方式
  5. 重点区分:什么时候扩,什么时候缩,窗口里维护什么

进阶补充 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;
}

刷题思路模板

  1. 先问:这题是处理“节点本身”,还是处理“整层”
  2. 如果是单节点逻辑,多半是 DFS / 递归
  3. 如果是按层输出、最短步数,多半是 BFS
  4. 明确子问题:左子树和右子树各返回什么
  5. 最后检查空树、叶子节点、单边树这些边界

常见坑点

  • 把树题当链表题,只沿一条线思考
  • 递归返回值定义不清楚
  • 层序遍历时没有按层处理 size
  • 忘了先处理空节点
  • 前中后序顺序写混

今天建议你这样练

  1. 先做 104,建立“树高 = 子树高 + 1”的感觉
  2. 再做 226,理解递归如何自顶向下改结构
  3. 最后做 102,补上按层遍历思维
  4. 晚上默写前序遍历和层序遍历模板
  5. 重点练:这题到底该按节点做,还是按层做

进阶补充 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]);
  }
}

刷题思路模板

  1. 先判断你要“找所有可能”,还是“找最先到达”
  2. 如果要最短层数,优先 BFS
  3. 如果要深挖结构、递归子问题,优先 DFS
  4. 图题一定先想 visited 或原地标记
  5. 网格题默认先准备四个方向

常见坑点

  • 图题没做 visited,直接死循环
  • BFS 没分层,算不出真实步数
  • DFS 回退时状态没恢复
  • 网格边界判断漏写
  • 把 DFS 和递归混成一个黑盒,不理解访问顺序

今天建议你这样练

  1. 先做 200,练 DFS 淹没连通块
  2. 再做 111,理解为什么最短深度优先 BFS
  3. 然后口头总结 DFS 和 BFS 的使用场景
  4. 晚上默写网格 DFS 和队列 BFS 框架
  5. 记住:最短步数优先 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;
}

刷题思路模板

  1. 先写一句话定义函数含义
  2. 写出最小输入时的终止条件
  3. 假设子问题已经能解,思考当前层如何拼结果
  4. 检查参数每次是否更接近终止条件
  5. 最后验证是否会重复计算或爆栈

常见坑点

  • 函数定义没想清楚就开始写
  • 终止条件不完整,递归停不下来
  • 子问题结果不会组合
  • 参数没有朝 base case 收缩
  • 把递归当魔法,不去画调用树

今天建议你这样练

  1. 先做 100,练最基础双树递归
  2. 再做 101,理解镜像式递归
  3. 最后做 50,感受递归 + 分治
  4. 晚上自己说清楚:函数定义、base case、返回值
  5. 每道题都强迫自己先画递归树

进阶补充 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;
}

刷题思路模板

  1. 先画决策树:每一层在选什么
  2. 定义 path 表示当前已选结果
  3. 明确终止条件:什么时候收集答案,什么时候剪枝
  4. 循环里固定写:选 → 递归 → 撤销
  5. 根据题意决定下一层是 i + 1 还是 i

常见坑点

  • 忘了 pop(),状态污染后续分支
  • 答案里直接 push path,没拷贝数组
  • start 索引写错,导致重复或漏解
  • 剪枝条件不完整,效率爆炸
  • 分不清排列、组合、子集三种决策树结构

今天建议你这样练

  1. 先做 78,理解最基础的选 / 不选
  2. 再做 46,补上 used 数组的排列模型
  3. 最后做 39,理解 start 和剪枝
  4. 晚上默写统一回溯模板
  5. 重点区分:组合、排列、子集分别怎么写

后续内容如何设计优化

建议的数据结构

每个 Day 固定 6 个模块

  1. 模板定义:这类题在解决什么问题
  2. 识别信号:看到什么题意想到这个模板
  3. 通用代码模板:JS / 以后也可加 Python
  4. 例题拆解:1~3 道经典题的解法思路
  5. 坑点清单:边界、返回值、指针更新顺序
  6. 复习提示:晚上默写什么、复盘什么

建议的页面形态

  • 顶部保留周视图,方便总览
  • 每个 Day 增加“展开详解”区块
  • 代码块统一支持复制按钮(下一版可加 JS)
  • 每道题都能挂“我的思路 / 易错点 / 一句话总结”
  • 后面可加入本地打卡状态和已完成标记

最适合你的迭代顺序

  1. 先把 Day 1 ~ Day 7 全部补成这种详解版
  2. 再给每道题加题解思路
  3. 再补 JS 模板库总页
  4. 最后加本地打卡、折叠、筛选和搜索

每日执行方式

复健核心:模板驱动

白天

  1. 先看模板 10~15 分钟
  2. 搞清适用场景、识别信号、核心框架、易错点
  3. 刷 3 道必刷题
  4. 有余力再做 1~2 道可选题

晚上

  1. 重做当天最卡的 1~2 道题
  2. 不看答案默写模板
  3. 写一句总结:看到什么信号就该想到这个模板

最低完成标准

  • 每天 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. 找到字符串中所有字母异位词