常见算法思维
前言
算法题虽然千变万化,但背后的核心思想就那么几种。掌握这些思维模式,遇到新题时就能快速找到方向。
一、双指针
核心思想
用两个指针在数组/链表上移动,通过指针的相对位置关系解决问题。
常见模式
1. 快慢指针
两个指针同向移动,一快一慢。
用途:链表找中点、判断环、删除倒数第N个节点
// 例:找链表中点
function findMiddle(head: ListNode): ListNode {
let slow = head;
let fast = head;
// 快指针每次走2步,慢指针每次走1步
// 快指针到终点时,慢指针正好在中点
while (fast && fast.next) {
slow = slow.next!;
fast = fast.next.next;
}
return slow;
}
2. 左右指针
两个指针从两端向中间移动。
用途:二分查找、两数之和(有序数组)、反转数组、判断回文
// 例:判断回文字符串
function isPalindrome(s: string): boolean {
let left = 0;
let right = s.length - 1;
while (left < right) {
if (s[left] !== s[right]) {
return false;
}
left++;
right--;
}
return true;
}
// "abcba" -> true
// "abcd" -> false
3. 读写指针
一个指针读取,一个指针写入。
用途:原地修改数组(移动零、删除重复项)
// 例:移动零
function moveZeroes(nums: number[]): void {
let writePos = 0; // 写指针
// 读指针遍历数组
for (let readPos = 0; readPos < nums.length; readPos++) {
if (nums[readPos] !== 0) {
nums[writePos] = nums[readPos];
writePos++;
}
}
// 剩余位置填0
while (writePos < nums.length) {
nums[writePos] = 0;
writePos++;
}
}
- 有序数组找目标 → 左右指针
- 链表找中点/环 → 快慢指针
- 原地修改数组 → 读写指针
二、滑动窗口
核心思想
想象你有一个可以伸缩的框,框住数组或字符串中的一段连续元素。通过移动这个框的左右边界,高效地遍历所有满足条件的子数组/子串。
上图中
left和right之间的绿色部分就是窗口,窗口内的元素是[3, 2, 5]。
两种窗口类型
| 类型 | 说明 | 示例 |
|---|---|---|
| 固定窗口 | 窗口大小不变,整体向右滑动 | 求所有长度为 k 的子数组的最大和 |
| 可变窗口 | 窗口大小根据条件动态伸缩 | 求不含重复字符的最长子串 |
固定窗口示意
步骤1: [1 3 2] 5 4 1 6 窗口大小=3,和=6
步骤2: 1 [3 2 5] 4 1 6 右移一格,和=10
步骤3: 1 3 [2 5 4] 1 6 右移一格,和=11
步骤4: 1 3 2 [5 4 1] 6 右移一格,和=10
步骤5: 1 3 2 5 [4 1 6] 右移一格,和=11
可变窗口示意
步骤1: [a] b c a b 窗口扩大 → 无重复
步骤2: [a b] c a b 窗口扩大 → 无重复
步骤3: [a b c] a b 窗口扩大 → 无重复,长度=3
步骤4: a [b c a] b 遇到重复a → 收缩左边界,再扩大
步骤5: a b [c a b] b 遇到重复b → 收缩左边界,再扩大
为什么滑动窗口高效?
暴力法需要用双层循环枚举所有子串,时间复杂度 甚至 。
滑动窗口中 left 和 right 都只向右移动、不回退,每个元素最多被访问两次(进窗口一次、出窗口一次),所以时间复杂度是 。
暴力法:O(n²) ~ O(n³) ← 太慢!
滑动窗口:O(n) ← 快!
模板代码
function slidingWindow(s: string): number {
let left = 0;
let right = 0;
let result = 0;
while (right < s.length) {
// 1. 扩大窗口:right 右移,更新窗口状态
const charIn = s[right];
right++;
// 更新窗口内数据...
// 2. 收缩窗口:当窗口需要收缩时
while (/* 需要收缩的条件 */) {
const charOut = s[left];
left++;
// 更新窗口内数据...
}
// 3. 更新结果
result = Math.max(result, right - left);
}
return result;
}
模板逐步解析
整个流程可以概括为 "扩 → 缩 → 更新" 三步循环:
| 步骤 | 代码 | 作用 |
|---|---|---|
| 初始化 | left = 0, right = 0 | 窗口从最左端开始,初始为空 |
| 扩大窗口 | right++ | 把 right 指向的元素纳入窗口 |
| 收缩窗口 | left++(在 while 内) | 当窗口不满足条件时,移出左侧元素 |
| 更新结果 | result = Math.max(...) | 每次窗口变化后,检查是否需要更新答案 |
不同题目的关键区别在于 收缩条件 不同:
- 无重复子串 → 窗口内有重复字符时收缩
- 最小覆盖子串 → 窗口已包含所有目标字符时收缩
- 长度为 k 的子数组 → 窗口大小超过 k 时收缩
经典例题:无重复字符的最长子串
// LeetCode 3. 无重复字符的最长子串
function lengthOfLongestSubstring(s: string): number {
const window = new Set<string>();
let left = 0;
let maxLen = 0;
for (let right = 0; right < s.length; right++) {
// 窗口中有重复字符,收缩左边界
while (window.has(s[right])) {
window.delete(s[left]);
left++;
}
// 将当前字符加入窗口
window.add(s[right]);
// 更新最大长度
maxLen = Math.max(maxLen, right - left + 1);
}
return maxLen;
}
// "abcabcbb" -> 3 ("abc")
// "bbbbb" -> 1 ("b")
完整模拟过程
以输入 "abcabcbb" 为例,逐步模拟窗口的变化:
| 步骤 | right | 字符 | 操作 | 窗口内容 | left | 窗口长度 | maxLen |
|---|---|---|---|---|---|---|---|
| 1 | 0 | a | 无重复,加入 | {a} | 0 | 1 | 1 |
| 2 | 1 | b | 无重复,加入 | {a,b} | 0 | 2 | 2 |
| 3 | 2 | c | 无重复,加入 | {a,b,c} | 0 | 3 | 3 |
| 4 | 3 | a | 重复!删 a,left→1 | {b,c,a} | 1 | 3 | 3 |
| 5 | 4 | b | 重复!删 b,left→2 | {c,a,b} | 2 | 3 | 3 |
| 6 | 5 | c | 重复!删 c,left→3 | {a,b,c} | 3 | 3 | 3 |
| 7 | 6 | b | 重复!删 a,b,left→5 | {c,b} | 5 | 2 | 3 |
| 8 | 7 | b | 重复!删 c,b,left→7 | {b} | 7 | 1 | 3 |
最终结果:3,对应最长无重复子串 "abc"。
图解步骤 1~4 的窗口变化(点击展开)
步骤1: right=0
[a] b c a b c b b window={a}, maxLen=1
↑
left,right
步骤2: right=1
[a b] c a b c b b window={a,b}, maxLen=2
↑ ↑
left right
步骤3: right=2
[a b c] a b c b b window={a,b,c}, maxLen=3
↑ ↑
left right
步骤4: right=3, 发现 'a' 重复!
→ 收缩:删除 s[left]='a', left 右移
a [b c a] b c b b window={b,c,a}, maxLen=3
↑ ↑
left right
经典例题:固定窗口 —— 大小为 k 的子数组最大和
// 求长度恰好为 k 的子数组的最大和
function maxSumSubarray(nums: number[], k: number): number {
let windowSum = 0;
let maxSum = -Infinity;
for (let right = 0; right < nums.length; right++) {
// 扩大窗口:加入右侧元素
windowSum += nums[right];
// 窗口大小达到 k 时
if (right >= k - 1) {
// 更新结果
maxSum = Math.max(maxSum, windowSum);
// 收缩窗口:减去左侧元素
windowSum -= nums[right - k + 1];
}
}
return maxSum;
}
// nums=[1,3,2,5,4,1,6], k=3
// 子数组和: [1,3,2]=6, [3,2,5]=10, [2,5,4]=11, [5,4,1]=10, [4,1,6]=11
// 最大和: 11
题目中出现以下关键词时,优先考虑滑动窗口:
- 连续子数组 / 连续子串
- 最长 / 最短
- 包含 / 至少包含
- 固定长度 k 的子数组
- 先想清楚窗口里维护什么数据(Set?Map?Sum?)
- 再想清楚什么时候收缩窗口(重复?超长?满足条件?)
- 最后想清楚在哪里更新结果(收缩前?收缩后?每步都更新?)
三、递归与分治
核心思想
把大问题拆成小问题,小问题的解法和大问题相同。递归是算法中最重要的思维方式之一,理解递归是理解分治、回溯、动态规划、树操作的基础。
递归三要素
每一道递归题都必须想清楚这三个问题:
| 要素 | 含义 | 常见错误 |
|---|---|---|
| 1. 终止条件 | 什么时候停下来?最小子问题是什么? | 忘记终止条件 → 无限递归 → 栈溢出 |
| 2. 递归逻辑 | 怎么把大问题拆成小问题?每层做什么? | 没有缩小问题规模 → 死循环 |
| 3. 返回值 | 每层递归返回什么?如何向上传递结果? | 返回值设计错误 → 结果不正确 |
递归模板
function recursion(问题规模) {
// 1. 终止条件(Base Case)
if (问题规模足够小) {
return 直接解决;
}
// 2. 拆分问题,递归求解(Recursive Case)
const 子问题结果 = recursion(更小的问题);
// 3. 合并结果
return 合并(子问题结果);
}
递归的执行过程
理解递归的关键是理解调用栈。以 factorial(4) 为例:
function factorial(n: number): number {
if (n <= 1) return 1; // 终止条件
return n * factorial(n - 1); // 递归逻辑
}
调用栈(先进后出):
factorial(4) ← 等待 factorial(3) 的结果
factorial(3) ← 等待 factorial(2) 的结果
factorial(2) ← 等待 factorial(1) 的结果
factorial(1) → 返回 1 ← 触发终止条件,开始回溯
factorial(2) → 返回 2 * 1 = 2
factorial(3) → 返回 3 * 2 = 6
factorial(4) → 返回 4 * 6 = 24
→ 递归就是「递」下去 +「归」回来
不要试图在脑子里展开每一层递归。 只需要做到:
- 明确函数的定义(这个函数干什么?)
- 相信子递归能返回正确结果(不要去想子递归内部怎么执行)
- 利用子递归的结果,处理当前层的逻辑
比如"反转链表":只要相信 reverseList(head.next) 能把后面的链表反转好,你只需要把当前节点接到末尾。
经典例题
例 1:反转链表
function reverseList(head: ListNode | null): ListNode | null {
// 1. 终止条件:空链表或只有一个节点
if (!head || !head.next) {
return head;
}
// 2. 相信递归:reverseList(head.next) 能把后面的链表反转
const newHead = reverseList(head.next);
// 3. 把当前节点接到反转后的链表末尾
head.next.next = head;
head.next = null;
return newHead;
}
// 1 -> 2 -> 3 -> null
// reverseList(2->3) 返回 3 -> 2 -> null
// 然后把 1 接到 2 后面:3 -> 2 -> 1 -> null
例 2:二叉树的最大深度
function maxDepth(root: TreeNode | null): number {
// 1. 终止条件
if (!root) return 0;
// 2. 递归求左右子树深度(相信子递归能算对)
const leftDepth = maxDepth(root.left);
const rightDepth = maxDepth(root.right);
// 3. 当前节点的深度 = 较大的子树深度 + 1
return Math.max(leftDepth, rightDepth) + 1;
}
例 3:合并两个有序链表(分治思想)
function mergeTwoLists(
l1: ListNode | null,
l2: ListNode | null,
): ListNode | null {
// 终止条件:一个为空,返回另一个
if (!l1) return l2;
if (!l2) return l1;
// 比较头节点,小的接上,剩余递归合并
if (l1.val <= l2.val) {
l1.next = mergeTwoLists(l1.next, l2);
return l1;
} else {
l2.next = mergeTwoLists(l1, l2.next);
return l2;
}
}
分治法
分治是递归的一种特定模式:分解 → 递归求解 → 合并。
分治三步:
1. Divide:把问题拆成 2 个或多个子问题
2. Conquer:递归解决每个子问题
3. Merge:合并子问题的解
// 归并排序(最典型的分治)
function mergeSort(arr: number[]): number[] {
// 终止条件
if (arr.length <= 1) return arr;
// 1. 分:从中间切开
const mid = Math.floor(arr.length / 2);
const left = arr.slice(0, mid);
const right = arr.slice(mid);
// 2. 治:递归排序左右两半
const sortedLeft = mergeSort(left);
const sortedRight = mergeSort(right);
// 3. 合:合并两个有序数组
return merge(sortedLeft, sortedRight);
}
function merge(left: number[], right: number[]): number[] {
const result: number[] = [];
let i = 0, j = 0;
while (i < left.length && j < right.length) {
if (left[i] <= right[j]) {
result.push(left[i++]);
} else {
result.push(right[j++]);
}
}
return [...result, ...left.slice(i), ...right.slice(j)];
}
递归与迭代的转换
任何递归都可以改写为迭代(用栈模拟调用栈)。面试中两种写法都要会:
// 递归版:二叉树前序遍历
function preorder(root: TreeNode | null): number[] {
if (!root) return [];
return [root.val, ...preorder(root.left), ...preorder(root.right)];
}
// 迭代版:用栈模拟
function preorderIterative(root: TreeNode | null): number[] {
if (!root) return [];
const result: number[] = [];
const stack: TreeNode[] = [root];
while (stack.length > 0) {
const node = stack.pop()!;
result.push(node.val);
// 先压右,再压左(栈是后进先出,所以左先出)
if (node.right) stack.push(node.right);
if (node.left) stack.push(node.left);
}
return result;
}
递归优化:记忆化
当递归存在重复子问题时,用缓存避免重复计算(递归 → 记忆化递归 → 动态规划):
// 斐波那契:朴素递归 O(2^n) → 记忆化 O(n)
function fib(n: number, memo = new Map<number, number>()): number {
if (n <= 1) return n;
if (memo.has(n)) return memo.get(n)!; // 缓存命中
const result = fib(n - 1, memo) + fib(n - 2, memo);
memo.set(n, result); // 存入缓存
return result;
}
- 一定要有终止条件,否则无限递归导致栈溢出(
Maximum call stack size exceeded) - 注意重复计算问题,可用记忆化优化(Map 缓存已算过的子问题)
- 递归深度过大会栈溢出,JS 默认调用栈约 1 万层。数据量大时考虑改为迭代
- 注意引用类型的参数传递,数组/对象在递归中共享,需要注意是否需要拷贝
递归题的解题思路
1. 明确函数定义:这个函数接收什么,返回什么?
2. 找终止条件:最小的、不需要递归就能解决的情况
3. 找递推关系:当前层和下一层的关系是什么?
4. 处理当前层:利用子递归结果,完成当前层逻辑
5. 验证:用最简单的例子(n=0, n=1)手动走一遍
四、动态规划(DP)
核心思想
把问题拆成重叠的子问题,保存子问题的解,避免重复计算。本质是用空间换时间。
DP 三要素
| 要素 | 说明 | 常见错误 |
|---|---|---|
| 状态定义 | dp[i] 代表什么? | 定义不清导致转移方程写不出来 |
| 状态转移 | dp[i] 怎么从之前的状态推导? | 漏掉某种情况 |
| 初始值 | dp[0]、dp[1] 是多少? | 初始值错误导致整体结果错误 |
解题五步法
1. 定义状态:明确 dp 数组的含义(最关键的一步!)
2. 找转移方程:当前状态如何从之前推导
3. 确定初始值和边界条件
4. 确定遍历顺序(从小到大 or 从大到小?)
5. 举例推导验证(手动模拟一遍 dp 表格)
两种实现方式
// 以 fibonacci 为例
// 方式 1:自顶向下(递归 + 记忆化)
function fibMemo(n: number, memo = new Map<number, number>()): number {
if (n <= 1) return n;
if (memo.has(n)) return memo.get(n)!;
const result = fibMemo(n - 1, memo) + fibMemo(n - 2, memo);
memo.set(n, result);
return result;
}
// 方式 2:自底向上(迭代 + dp 数组)
function fibDP(n: number): number {
if (n <= 1) return n;
const dp = [0, 1];
for (let i = 2; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}
// 方式 3:空间优化(滚动变量)
function fibOptimized(n: number): number {
if (n <= 1) return n;
let prev = 0, curr = 1;
for (let i = 2; i <= n; i++) {
[prev, curr] = [curr, prev + curr];
}
return curr;
}
- 自顶向下(记忆化递归):写起来更直观,适合状态不好枚举的场景
- 自底向上(迭代 dp 表):没有递归开销,更容易做空间优化,面试首选
- 空间优化:当
dp[i]只依赖前几个状态时,可以用滚动变量替代数组,将空间从 降到
常见 DP 分类
| 类型 | 特征 | 典型题目 |
|---|---|---|
| 线性 DP | dp[i] 由 dp[i-1]、dp[i-2] 等推导 | 爬楼梯、打家劫舍、最大子数组和 |
| 二维 DP | dp[i][j] 需要两个维度 | 最长公共子序列、编辑距离、路径数 |
| 背包 DP | 容量限制下的选择问题 | 0-1 背包、完全背包、零钱兑换 |
| 区间 DP | dp[i][j] 表示区间 [i,j] 的最优解 | 戳气球、最长回文子串 |
| 状态压缩 DP | 用二进制表示集合状态 | 旅行商问题(面试少见) |
| 树形 DP | 在树上做 DP,dp[node] | 打家劫舍 III、二叉树最大路径和 |
经典例题
1. 爬楼梯(线性 DP 入门)
// 每次爬 1 或 2 级,爬到第 n 级有几种方法?
function climbStairs(n: number): number {
// 状态定义:dp[i] = 爬到第 i 级的方法数
if (n <= 2) return n;
let dp1 = 1; // dp[1]
let dp2 = 2; // dp[2]
// 状态转移:dp[i] = dp[i-1] + dp[i-2]
// 要么从 i-1 爬 1 级上来,要么从 i-2 爬 2 级上来
for (let i = 3; i <= n; i++) {
[dp1, dp2] = [dp2, dp1 + dp2];
}
return dp2;
}
2. 最大子数组和(经典线性 DP)
function maxSubArray(nums: number[]): number {
// 状态定义:dp[i] = 以 nums[i] 结尾的最大子数组和
let dp = nums[0];
let maxSum = dp;
for (let i = 1; i < nums.length; i++) {
// 状态转移:要么接上前面的,要么自己单独开始
dp = Math.max(dp + nums[i], nums[i]);
maxSum = Math.max(maxSum, dp);
}
return maxSum;
}
// [-2,1,-3,4,-1,2,1,-5,4] -> 6 (子数组 [4,-1,2,1])
3. 零钱兑换(完全背包)
// 凑出金额 amount 所需的最少硬币数
function coinChange(coins: number[], amount: number): number {
// 状态定义:dp[i] = 凑出金额 i 所需的最少硬币数
const dp = new Array(amount + 1).fill(Infinity);
dp[0] = 0; // 凑出金额 0 需要 0 枚硬币
for (let i = 1; i <= amount; i++) {
for (const coin of coins) {
// 如果当前金额能使用这枚硬币
if (i >= coin && dp[i - coin] !== Infinity) {
dp[i] = Math.min(dp[i], dp[i - coin] + 1);
}
}
}
return dp[amount] === Infinity ? -1 : dp[amount];
}
// coins=[1,2,5], amount=11 -> 3 (5+5+1)
4. 最长递增子序列(二分优化)
// 基础 DP:O(n²)
function lengthOfLIS(nums: number[]): number {
// 状态定义:dp[i] = 以 nums[i] 结尾的最长递增子序列长度
const dp = new Array(nums.length).fill(1);
for (let i = 1; i < nums.length; i++) {
for (let j = 0; j < i; j++) {
if (nums[j] < nums[i]) {
dp[i] = Math.max(dp[i], dp[j] + 1);
}
}
}
return Math.max(...dp);
}
// 贪心 + 二分优化:O(n log n)
function lengthOfLIS_opt(nums: number[]): number {
// tails[i] 表示长度为 i+1 的递增子序列的最小末尾元素
const tails: number[] = [];
for (const num of nums) {
let lo = 0, hi = tails.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (tails[mid] < num) lo = mid + 1;
else hi = mid;
}
tails[lo] = num;
}
return tails.length;
}
// [10,9,2,5,3,7,101,18] -> 4 ([2,3,7,101])
5. 不同路径(二维 DP)
// m×n 网格,从左上到右下有多少条路径?(只能向右或向下走)
function uniquePaths(m: number, n: number): number {
// 状态定义:dp[i][j] = 到达 (i,j) 的路径数
// 转移方程:dp[i][j] = dp[i-1][j] + dp[i][j-1]
// 空间优化:只用一行
const dp = new Array(n).fill(1);
for (let i = 1; i < m; i++) {
for (let j = 1; j < n; j++) {
dp[j] += dp[j - 1]; // dp[j] 本身是上一行的值
}
}
return dp[n - 1];
}
// m=3, n=7 -> 28
- 题目问**"最值"**(最大、最小、最长、最短)
- 题目问**"方案数"**(多少种方法、多少条路径)
- 能拆成子问题,且子问题有重叠
- 递归暴力解有大量重复计算 → 就能用 DP 优化
DP 不适用的情况:
- 求具体方案(不只是个数/最值)→ 考虑回溯
- 无最优子结构 → 不能用 DP
- 状态定义不精确:"
dp[i]到底是前 i 个的最优解,还是以第 i 个结尾的最优解?"——定义不同,转移方程完全不同 - 遍历顺序错误:0-1 背包正序遍历(二维)或逆序遍历(一维优化),完全背包正序遍历
- 初始值遗漏:
dp[0]是 0 还是 1?dp[0][j]怎么初始化? - 忘记空间优化:面试中如果提到可以优化空间,加分
五、贪心算法
核心思想
每一步都选择当前最优,期望最终结果也是最优。贪心的关键在于证明局部最优能推出全局最优(贪心选择性质)。
贪心 vs 动态规划
| 对比项 | 贪心 | 动态规划 |
|---|---|---|
| 决策方式 | 每步选局部最优,不回头 | 穷举所有子问题,找全局最优 |
| 子问题 | 无需回看之前的选择 | 有重叠子问题 |
| 正确性 | 需要证明贪心选择性质 | 只要转移正确,保证最优 |
| 复杂度 | 通常 或 | 通常 或更高 |
| 使用场景 | 区间调度、Huffman 编码 | 背包、路径、子序列 |
贪心的证明思路
面试中不要求严格数学证明,但需要能说清楚为什么贪心有效:
常用证明方法:
1. 交换论证法:假设最优解和贪心解不同,交换后不会更差
2. 归纳法:证明每一步贪心选择后,剩余问题仍满足贪心性质
3. 反证法:假设贪心选择不是最优的,推出矛盾
面试时简单说:"选最早结束的活动,剩余时间最多,能安排更多活动"就够了
经典例题
1. 买卖股票的最佳时机 II(可以多次买卖)
function maxProfit(prices: number[]): number {
let profit = 0;
for (let i = 1; i < prices.length; i++) {
// 贪心:只要今天比昨天贵,就昨天买今天卖
if (prices[i] > prices[i - 1]) {
profit += prices[i] - prices[i - 1];
}
}
return profit;
}
// [7,1,5,3,6,4] -> 7(第2天买第3天卖赚4,第4天买第5天卖赚3)
把价格曲线想象成折线图,贪心策略就是收集所有上涨段的利润。任何一次"低买高卖"的利润,都可以分解为连续的每日差价之和。所以收集所有正差价 = 最大利润。
2. 跳跃游戏(能否到达终点)
function canJump(nums: number[]): boolean {
// 贪心:维护能跳到的最远位置
let maxReach = 0;
for (let i = 0; i < nums.length; i++) {
if (i > maxReach) return false; // 当前位置超过了最远距离
maxReach = Math.max(maxReach, i + nums[i]);
if (maxReach >= nums.length - 1) return true;
}
return true;
}
// [2,3,1,1,4] -> true
// [3,2,1,0,4] -> false(被 0 挡住)
3. 合并区间(排序 + 贪心)
function merge(intervals: number[][]): number[][] {
// 贪心策略:按左端点排序后,依次合并有重叠的区间
intervals.sort((a, b) => a[0] - b[0]);
const result: number[][] = [intervals[0]];
for (let i = 1; i < intervals.length; i++) {
const last = result[result.length - 1];
if (intervals[i][0] <= last[1]) {
// 有重叠,合并
last[1] = Math.max(last[1], intervals[i][1]);
} else {
result.push(intervals[i]);
}
}
return result;
}
// [[1,3],[2,6],[8,10],[15,18]] -> [[1,6],[8,10],[15,18]]
4. 分发饼干(排序 + 双指针贪心)
// 每个孩子有一个胃口值 g[i],每块饼干有大小 s[j]
// 只有 s[j] >= g[i] 才能满足孩子。求最多能满足几个孩子
function findContentChildren(g: number[], s: number[]): number {
// 贪心:用最小的饼干去满足胃口最小的孩子
g.sort((a, b) => a - b);
s.sort((a, b) => a - b);
let child = 0;
let cookie = 0;
while (child < g.length && cookie < s.length) {
if (s[cookie] >= g[child]) {
child++; // 这个孩子被满足了
}
cookie++; // 无论是否满足,饼干都消耗了
}
return child;
}
// g=[1,2,3], s=[1,1] -> 1
5. 无重叠区间(活动选择变体)
// 求需要移除的最少区间数,使剩余区间互不重叠
function eraseOverlapIntervals(intervals: number[][]): number {
// 贪心:按右端点排序,每次选结束最早的区间保留
intervals.sort((a, b) => a[1] - b[1]);
let count = 0;
let prevEnd = -Infinity;
for (const [start, end] of intervals) {
if (start >= prevEnd) {
prevEnd = end; // 保留这个区间
} else {
count++; // 移除这个区间
}
}
return count;
}
// [[1,2],[2,3],[3,4],[1,3]] -> 1(移除[1,3])
- 题目涉及区间调度(合并、选择、移除)→ 排序后贪心
- 题目问**"能否到达"、"最少/最多"** + 可以只看当前 → 尝试贪心
- 每步有明确的最优选择标准(最小、最早结束、最大性价比)→ 贪心
- 如果贪心行不通(比如 0-1 背包),退回到 DP
贪心不是万能的!有些问题局部最优 ≠ 全局最优:
// 0-1 背包:贪心选性价比最高的不一定最优
// 容量 10,物品:[重量6,价值6], [重量5,价值5], [重量5,价值5]
// 贪心选性价比最高的(都是1.0),选第一个后只剩容量4,总价值6
// 最优解:选后两个,总价值10
// 结论:0-1 背包必须用 DP
六、回溯算法
核心思想
穷举所有可能,走不通就退回上一步(撤销选择),尝试下一个选择。
回溯 = 递归 + 撤销 = 带剪枝的 DFS
回溯三部曲
| 步骤 | 说明 | 代码体现 |
|---|---|---|
| 做选择 | 将当前元素加入路径 | path.push(选择) |
| 递归 | 进入下一层决策 | backtrack(...) |
| 撤销选择 | 回退,尝试其他选择 | path.pop() |
通用模板
function backtrack(路径: any[], 选择列表: any[]) {
// 1. 终止条件
if (满足结束条件) {
result.push([...路径]); // ⚠️ 注意要拷贝!
return;
}
for (let i = startIndex; i < 选择列表.length; i++) {
// 2. 剪枝(跳过不合法的选择)
if (不满足条件) continue;
// 3. 做选择
路径.push(选择列表[i]);
// 4. 递归(注意下一层的起始位置!)
backtrack(路径, 选择列表);
// 5. 撤销选择(回溯)
路径.pop();
}
}
- 收集结果时必须拷贝:
result.push([...path]),不能直接push(path),否则所有结果指向同一个数组 - 去重时必须先排序:有重复元素的排列/组合,必须先
sort,然后跳过相同值
排列 vs 组合 vs 子集
面试中这三类题极高频,核心区别在于是否考虑顺序和起始位置:
| 题型 | 是否考虑顺序 | 下一层从哪开始 | 终止条件 |
|---|---|---|---|
| 排列 | 是([1,2] ≠ [2,1]) | 从 0 开始 + used 数组 | path.length === n |
| 组合 | 否([1,2] = [2,1]) | 从 i + 1 开始 | path.length === k |
| 子集 | 否 | 从 i + 1 开始 | 每个节点都收集 |
经典例题
1. 全排列
function permute(nums: number[]): number[][] {
const result: number[][] = [];
function backtrack(path: number[], used: boolean[]) {
if (path.length === nums.length) {
result.push([...path]);
return;
}
for (let i = 0; i < nums.length; i++) {
if (used[i]) continue;
path.push(nums[i]);
used[i] = true;
backtrack(path, used);
path.pop(); // 撤销选择
used[i] = false; // 撤销标记
}
}
backtrack([], new Array(nums.length).fill(false));
return result;
}
// [1,2,3] -> [[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
2. 组合(从 n 个数中选 k 个)
function combine(n: number, k: number): number[][] {
const result: number[][] = [];
function backtrack(start: number, path: number[]) {
if (path.length === k) {
result.push([...path]);
return;
}
// 剪枝:剩余元素不够凑齐 k 个时,提前终止
for (let i = start; i <= n - (k - path.length) + 1; i++) {
path.push(i);
backtrack(i + 1, path); // ⚠️ 组合从 i+1 开始,不回头
path.pop();
}
}
backtrack(1, []);
return result;
}
// n=4, k=2 -> [[1,2],[1,3],[1,4],[2,3],[2,4],[3,4]]
3. 子集
function subsets(nums: number[]): number[][] {
const result: number[][] = [];
function backtrack(start: number, path: number[]) {
result.push([...path]); // 每个节点都是答案(不需要终止条件才收集)
for (let i = start; i < nums.length; i++) {
path.push(nums[i]);
backtrack(i + 1, path);
path.pop();
}
}
backtrack(0, []);
return result;
}
// [1,2,3] -> [[],[1],[1,2],[1,2,3],[1,3],[2],[2,3],[3]]
4. 有重复元素的组合(去重)
// 组合总和 II:candidates 中有重复元素,每个数只能用一次
function combinationSum2(candidates: number[], target: number): number[][] {
const result: number[][] = [];
candidates.sort((a, b) => a - b); // 排序是去重的前提!
function backtrack(start: number, path: number[], sum: number) {
if (sum === target) {
result.push([...path]);
return;
}
if (sum > target) return; // 剪枝
for (let i = start; i < candidates.length; i++) {
// 关键去重:同一层中,跳过与前一个相同的元素
if (i > start && candidates[i] === candidates[i - 1]) continue;
path.push(candidates[i]);
backtrack(i + 1, path, sum + candidates[i]);
path.pop();
}
}
backtrack(0, [], 0);
return result;
}
// candidates=[10,1,2,7,6,1,5], target=8
// -> [[1,1,6],[1,2,5],[1,7],[2,6]]
5. N 皇后(棋盘搜索)
function solveNQueens(n: number): string[][] {
const result: string[][] = [];
const board: string[][] = Array.from({ length: n }, () =>
new Array(n).fill('.')
);
// 用 Set 记录已被攻击的列和对角线
const cols = new Set<number>();
const diag1 = new Set<number>(); // 主对角线:row - col
const diag2 = new Set<number>(); // 副对角线:row + col
function backtrack(row: number) {
if (row === n) {
result.push(board.map(r => r.join('')));
return;
}
for (let col = 0; col < n; col++) {
// 剪枝:检查是否被攻击
if (cols.has(col) || diag1.has(row - col) || diag2.has(row + col)) {
continue;
}
// 放置皇后
board[row][col] = 'Q';
cols.add(col);
diag1.add(row - col);
diag2.add(row + col);
backtrack(row + 1);
// 撤销
board[row][col] = '.';
cols.delete(col);
diag1.delete(row - col);
diag2.delete(row + col);
}
}
backtrack(0);
return result;
}
回溯的剪枝优化
剪枝是回溯的灵魂,好的剪枝可以让时间复杂度降低几个数量级:
// 常见剪枝技巧
function backtrackWithPruning(start: number, path: number[], sum: number) {
if (sum === target) { result.push([...path]); return; }
for (let i = start; i < nums.length; i++) {
// 剪枝 1:排序后,如果当前值已超过目标,后面更大的也不用看了
if (sum + nums[i] > target) break;
// 剪枝 2:同一层去重
if (i > start && nums[i] === nums[i - 1]) continue;
// 剪枝 3:剩余元素不够时提前终止
if (path.length + (nums.length - i) < k) break;
path.push(nums[i]);
backtrackWithPruning(i + 1, path, sum + nums[i]);
path.pop();
}
}
- 排列问题 → 从 0 开始 +
used数组 - 组合/子集问题 →
startIndex往后选 - 切割问题(回文分割等)→ 类似组合,
startIndex是切割位置 - 棋盘搜索(N 皇后、数独)→ 逐行/逐格尝试 + 约束检查
- 路径搜索(单词搜索、图路径)→ DFS + 访问标记
算法思维速查表
| 思维 | 关键词 | 典型题目 |
|---|---|---|
| 双指针 | 有序、原地修改、链表 | 两数之和、移动零、反转链表 |
| 滑动窗口 | 连续子串/子数组、最长/最短 | 无重复最长子串、最小覆盖子串 |
| 递归/分治 | 树、链表、分解 | 反转链表、二叉树遍历 |
| 动态规划 | 最值、方案数、重叠子问题 | 爬楼梯、背包、最长子序列 |
| 贪心 | 局部最优、区间问题 | 买卖股票、跳跃游戏 |
| 回溯 | 所有方案、排列组合 | 全排列、N皇后、子集 |
常见面试问题
Q1: 动态规划和贪心算法的区别是什么?怎么判断用哪个?
答案:
| 对比项 | 动态规划 | 贪心算法 |
|---|---|---|
| 决策方式 | 考虑所有子问题,找全局最优 | 每步选局部最优,不回头 |
| 子问题 | 有重叠子问题 | 无需回看之前的选择 |
| 最优性 | 保证全局最优 | 不一定全局最优 |
| 复杂度 | 通常更高 | 通常更低 |
判断方法:
- 如果问题具有最优子结构且贪心选择性质(局部最优能推出全局最优),用贪心
- 如果问题具有重叠子问题且无法用贪心保证全局最优,用 DP
- 拿不准时,先尝试贪心(简单),行不通再用 DP
// 贪心适用:活动选择问题(选结束时间最早的)
// DP 适用:0-1 背包(不能贪心选性价比最高的)
// 同一道题的不同变体可能用不同方法:
// 买卖股票(最多 1 次)→ 贪心
// 买卖股票(最多 k 次)→ DP
Q2: 什么时候用 BFS,什么时候用 DFS?
答案:
| 场景 | BFS(广度优先) | DFS(深度优先) |
|---|---|---|
| 最短路径 | 适合(无权图最短路径) | 不适合 |
| 层序遍历 | 适合 | 不适合 |
| 所有路径/排列组合 | 不适合 | 适合(回溯) |
| 空间占用 | ,w 为最大宽度 | ,h 为最大深度 |
| 实现方式 | 队列 | 递归/栈 |
口诀:
- 找最短 → BFS
- 找所有方案 → DFS(回溯)
- 树/图遍历 → 都行,看具体需求
Q3: 滑动窗口和双指针有什么关系?
答案:
滑动窗口本质上是双指针的一种特殊形式:
- 双指针是通用概念,两个指针可以同向、反向、快慢移动
- 滑动窗口是双指针的子集,特指两个指针同向移动且维护一个连续区间
// 双指针(左右指针) —— 两端向中间移动
function twoSumSorted(nums: number[], target: number): number[] {
let left = 0;
let right = nums.length - 1;
while (left < right) {
const sum = nums[left] + nums[right];
if (sum === target) return [left, right];
if (sum < target) left++;
else right--;
}
return [];
}
// 滑动窗口 —— 两个指针同向右移,维护窗口
function maxSumK(nums: number[], k: number): number {
let sum = 0;
let maxSum = -Infinity;
for (let right = 0; right < nums.length; right++) {
sum += nums[right];
if (right >= k - 1) {
maxSum = Math.max(maxSum, sum);
sum -= nums[right - k + 1];
}
}
return maxSum;
}
Q4: 回溯算法的时间复杂度怎么分析?
答案:
回溯的时间复杂度取决于搜索树的大小(节点数):
| 题型 | 时间复杂度 | 说明 |
|---|---|---|
| 全排列(n 个不同元素) | 第一层 n 个选择,第二层 n-1,... | |
| 组合(n 选 k) | 即 | |
| 子集(2^n 个) | 每个元素选或不选 | |
| N 皇后 | (有剪枝) | 实际远小于 |
// 分析方法:看搜索树的分支因子和深度
// 排列:分支因子递减 n × (n-1) × ... × 1 = n!
// 组合/子集:每个元素选或不选,2 × 2 × ... × 2 = 2^n
// 剪枝可以大幅降低实际运行时间,但不改变最坏复杂度
Q5: 什么是记忆化搜索?和动态规划有什么关系?
答案:
记忆化搜索是自顶向下的 DP,本质相同,实现方式不同:
| 对比项 | 记忆化搜索(自顶向下) | DP(自底向上) |
|---|---|---|
| 实现 | 递归 + 缓存 | 循环 + dp 数组 |
| 思路 | 从大问题拆到小问题 | 从小问题推到大问题 |
| 优点 | 直观,只计算需要的子问题 | 无递归开销,方便空间优化 |
| 缺点 | 有递归栈开销 | 可能计算不需要的子问题 |
| 面试建议 | 适合快速出解 | 适合优化空间 |
// 同一个问题的两种写法——最长递增子序列
// 记忆化搜索
function lisMemo(nums: number[]): number {
const memo = new Map<number, number>();
function dfs(i: number): number {
if (memo.has(i)) return memo.get(i)!;
let maxLen = 1;
for (let j = 0; j < i; j++) {
if (nums[j] < nums[i]) {
maxLen = Math.max(maxLen, dfs(j) + 1);
}
}
memo.set(i, maxLen);
return maxLen;
}
let result = 0;
for (let i = 0; i < nums.length; i++) {
result = Math.max(result, dfs(i));
}
return result;
}
// 自底向上 DP(完全等价)
function lisDP(nums: number[]): number {
const dp = new Array(nums.length).fill(1);
for (let i = 1; i < nums.length; i++) {
for (let j = 0; j < i; j++) {
if (nums[j] < nums[i]) dp[i] = Math.max(dp[i], dp[j] + 1);
}
}
return Math.max(...dp);
}
Q6: 如何设计 DP 的状态定义?
答案:
状态定义是 DP 最难的一步,常见套路:
1. 线性 DP:dp[i] = 以第 i 个元素结尾的最优解
或:dp[i] = 前 i 个元素的最优解
2. 二维 DP:
- dp[i][j] = 用前 i 个物品,容量为 j 的背包的最大价值(背包)
- dp[i][j] = s1 前 i 个字符和 s2 前 j 个字符的最优解(字符串)
- dp[i][j] = 区间 [i, j] 的最优解(区间 DP)
3. 状态需要额外信息时增加维度:
- dp[i][0/1] = 第 i 天持有/不持有股票的最大利润
- dp[i][j][k] = 第 i 天最多 j 次交易,状态 k 的最大利润
定义完状态后,检查:
- 能否写出转移方程?如果写不出来,可能状态定义不对
- 初始值能否确定?
- 最终答案是
dp[n]还是max(dp[...])?
Q7: 贪心算法怎么证明正确性?面试需要证明吗?
答案:
面试中不需要严格数学证明,但需要说清楚直觉:
常用论证方式:
1. 交换论证:"如果不选当前最优的,换成其他的只会更差或一样"
2. 反证法:"假设贪心选择不对,那最优解中用了什么?交换后呢?"
3. 直觉解释:"选最早结束的活动,剩余时间最多,能安排更多活动"
// 例:合并区间为什么按左端点排序?
// 答:排序后保证相邻区间的左端点递增,
// 只需看当前区间的左端点是否在前一个区间的右端点内,
// 就能判断是否重叠。如果不排序,需要两两比较 O(n²)
// 例:跳跃游戏为什么维护 maxReach?
// 答:只要最远能跳到的位置 >= 终点,就一定能到达。
// 不需要关心具体怎么跳,因为中间经过的每个位置都能贡献跳跃距离
Q8: 回溯算法中如何去重?
答案:
当输入包含重复元素时,回溯可能产生重复结果。核心思路:排序 + 同一层跳过相同元素。
// 关键代码(以组合为例)
candidates.sort((a, b) => a - b); // 步骤 1:排序
function backtrack(start: number, path: number[]) {
for (let i = start; i < candidates.length; i++) {
// 步骤 2:同一层中,跳过和前一个相同的元素
if (i > start && candidates[i] === candidates[i - 1]) continue;
path.push(candidates[i]);
backtrack(i + 1, path);
path.pop();
}
}
理解"同一层 vs 同一树枝":
candidates = [1, 1, 2], target = 3
[]
/ | \
1 1 2 ← 同一层:第二个 1 要跳过!
/ \ |
1 2 2 ← 不同层(树枝方向):第二个 1 不跳过
|
2
结果:[1,1,2](不会出现重复的 [1,2])
i > start(不是i > 0!):只在同一层去重,树枝方向的重复是允许的
Q9: DP 中的背包问题有哪些类型?怎么区分?
答案:
| 背包类型 | 每个物品 | 遍历顺序(一维优化) | 典型题目 |
|---|---|---|---|
| 0-1 背包 | 只能选 1 次 | 容量从大到小 | 分割等和子集 |
| 完全背包 | 可以选无限次 | 容量从小到大 | 零钱兑换 |
| 多重背包 | 有数量限制 | 二进制优化 | 面试少见 |
// 0-1 背包(一维优化)
function knapsack01(weights: number[], values: number[], capacity: number): number {
const dp = new Array(capacity + 1).fill(0);
for (let i = 0; i < weights.length; i++) {
for (let j = capacity; j >= weights[i]; j--) { // 从大到小!
dp[j] = Math.max(dp[j], dp[j - weights[i]] + values[i]);
}
}
return dp[capacity];
}
// 完全背包(一维优化)
function knapsackComplete(weights: number[], values: number[], capacity: number): number {
const dp = new Array(capacity + 1).fill(0);
for (let i = 0; i < weights.length; i++) {
for (let j = weights[i]; j <= capacity; j++) { // 从小到大!
dp[j] = Math.max(dp[j], dp[j - weights[i]] + values[i]);
}
}
return dp[capacity];
}
- 0-1 背包从大到小:保证每个物品只用一次(
dp[j-w]还是上一行的值) - 完全背包从小到大:允许重复使用(
dp[j-w]是本行已更新的值)
Q10: 递归和迭代怎么互相转换?
答案:
所有递归都可以改成迭代(用显式栈模拟调用栈),但不是所有场景都值得转换。
// 递归版前序遍历
function preorderRecursive(root: TreeNode | null): number[] {
if (!root) return [];
return [
root.val,
...preorderRecursive(root.left),
...preorderRecursive(root.right),
];
}
// 迭代版前序遍历(用栈模拟)
function preorderIterative(root: TreeNode | null): number[] {
if (!root) return [];
const result: number[] = [];
const stack: TreeNode[] = [root];
while (stack.length) {
const node = stack.pop()!;
result.push(node.val);
// 先压右子树,后压左子树(栈是后进先出)
if (node.right) stack.push(node.right);
if (node.left) stack.push(node.left);
}
return result;
}
转换原则:
- 尾递归 → 直接改成循环(最简单)
- 线性递归(如链表反转)→ 循环 + 几个变量
- 树递归(如二叉树遍历)→ 显式栈
- 复杂回溯 → 通常保持递归更清晰,不强行转换
Q11: 如何用滑动窗口解决子串/子数组问题?
答案:
滑动窗口的通用模板:
function slidingWindow(s: string): number {
const window = new Map<string, number>(); // 窗口内的统计
let left = 0;
let result = 0;
for (let right = 0; right < s.length; right++) {
const charIn = s[right];
// 1. 扩大窗口(右指针右移)
window.set(charIn, (window.get(charIn) || 0) + 1);
// 2. 收缩窗口(左指针右移,直到窗口合法)
while (窗口不满足条件) {
const charOut = s[left];
window.set(charOut, window.get(charOut)! - 1);
if (window.get(charOut) === 0) window.delete(charOut);
left++;
}
// 3. 更新结果
result = Math.max(result, right - left + 1);
}
return result;
}
关键判断:
- 求最长 → 在窗口合法时更新结果,
while收缩到合法为止 - 求最短 → 在窗口合法时收缩并更新结果
- 窗口什么时候合法/不合法 → 取决于题目条件
Q12: 快慢指针为什么能检测链表有环?
答案:
function hasCycle(head: ListNode | null): boolean {
let slow = head;
let fast = head;
while (fast && fast.next) {
slow = slow!.next;
fast = fast.next.next;
if (slow === fast) return true; // 相遇说明有环
}
return false; // fast 到了 null,说明无环
}
原理:想象两个人在环形跑道上跑步,快的人一定会追上慢的人(在环上套圈)。
数学证明:假设环长为 C,进入环时快指针在慢指针前方 k 步。每走一步,距离缩小 1。经过 k 步后,两者必然相遇。
找环入口:相遇后,一个指针回到 head,两指针都以步长 1 前进,再次相遇点就是环入口。原因:设头到环入口距离为 a,环入口到相遇点距离为 b,环长为 C,则 a = C - b。
Q13: 解算法题的一般思路是什么?如何在面试中展示?
答案:
面试做算法题的 5 步流程:
1. 理解题目(2 分钟)
- 复述题意确认理解
- 确认输入输出格式、边界条件
- "请问输入可以为空吗?有重复元素吗?"
2. 举例分析(1 分钟)
- 用小例子手动推演
- 找规律、找子问题
3. 说出思路(2 分钟)
- 先说暴力解,再说优化
- "暴力解是 O(n²),可以用 xxx 优化到 O(n)"
- 说清楚用什么数据结构、什么算法
4. 编码实现(10-15 分钟)
- 边写边解释
- 先写框架,再填细节
- 变量命名清晰
5. 测试验证(2 分钟)
- 用例子走一遍代码
- 考虑边界:空、单元素、全相同、最大值
加分行为:
- 主动分析时间和空间复杂度
- 提到暴力解后说"可以优化"
- 考虑边界情况
- 代码写完后自己测试
Q14: 二维 DP 怎么优化空间?
答案:
当 dp[i][j] 只依赖于上一行 dp[i-1][...] 时,可以压缩为一维数组:
// 二维 DP:最长公共子序列
function lcsOriginal(s1: string, s2: string): number {
const m = s1.length, n = s2.length;
const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (s1[i - 1] === s2[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
return dp[m][n]; // 空间 O(m×n)
}
// 空间优化为一维
function lcsOptimized(s1: string, s2: string): number {
const n = s2.length;
const dp = new Array(n + 1).fill(0);
for (let i = 1; i <= s1.length; i++) {
let prev = 0; // 保存 dp[i-1][j-1]
for (let j = 1; j <= n; j++) {
const temp = dp[j]; // 保存被覆盖前的值
if (s1[i - 1] === s2[j - 1]) {
dp[j] = prev + 1;
} else {
dp[j] = Math.max(dp[j], dp[j - 1]);
}
prev = temp;
}
}
return dp[n]; // 空间 O(n)
}
优化技巧总结:
dp[i]只依赖dp[i-1]→ 用两个变量滚动(如爬楼梯)dp[i][j]只依赖上一行 → 压缩为一维数组 +prev变量- 0-1 背包一维优化 → 逆序遍历容量
Q15: 前端面试中最常考的算法思维有哪些?
答案:
按频率排序:
| 排名 | 算法思维 | 高频题目 |
|---|---|---|
| 1 | 哈希表 | 两数之和、字符频次、去重 |
| 2 | 双指针 | 反转链表、合并有序、移动零 |
| 3 | DFS/BFS | 二叉树遍历、岛屿数量、层序 |
| 4 | 滑动窗口 | 无重复最长子串、最小覆盖子串 |
| 5 | 动态规划 | 爬楼梯、零钱兑换、最长递增子序列 |
| 6 | 排序+贪心 | 合并区间、会议室 |
| 7 | 回溯 | 全排列、组合、子集 |
| 8 | 栈/队列 | 有效括号、最小栈、滑动窗口最大值 |
| 9 | 二分查找 | 旋转数组搜索、第 K 大元素 |
- 难度集中在 Easy ~ Medium,Hard 较少
- 偏爱实际开发场景的题(LRU 缓存、扁平化、深度比较)
- 手写题也算算法(Promise.all、防抖节流、深拷贝)
- 重在思路清晰、代码规范,不追求极致优化