跳到主要内容

常见算法思维

前言

算法题虽然千变万化,但背后的核心思想就那么几种。掌握这些思维模式,遇到新题时就能快速找到方向。


一、双指针

核心思想

两个指针在数组/链表上移动,通过指针的相对位置关系解决问题。

常见模式

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++;
}
}
双指针口诀
  • 有序数组找目标 → 左右指针
  • 链表找中点/环 → 快慢指针
  • 原地修改数组 → 读写指针

二、滑动窗口

核心思想

想象你有一个可以伸缩的框,框住数组或字符串中的一段连续元素。通过移动这个框的左右边界,高效地遍历所有满足条件的子数组/子串。

上图中 leftright 之间的绿色部分就是窗口,窗口内的元素是 [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 → 收缩左边界,再扩大

为什么滑动窗口高效?

关键:指针不回退

暴力法需要用双层循环枚举所有子串,时间复杂度 O(n2)O(n^2) 甚至 O(n3)O(n^3)

滑动窗口中 leftright 都只向右移动、不回退,每个元素最多被访问两次(进窗口一次、出窗口一次),所以时间复杂度是 O(n)O(n)

暴力法:O(n²) ~ O(n³)  ←  太慢!
滑动窗口:O(n) ← 快!

模板代码

sliding-window-template.ts
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 时收缩

经典例题:无重复字符的最长子串

longest-substring.ts
// 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
10a无重复,加入{a}011
21b无重复,加入{a,b}022
32c无重复,加入{a,b,c}033
43a重复!删 a,left→1{b,c,a}133
54b重复!删 b,left→2{c,a,b}233
65c重复!删 c,left→3{a,b,c}333
76b重复!删 a,b,left→5{c,b}523
87b重复!删 c,b,left→7{b}713

最终结果: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 的子数组最大和

max-sum-subarray.ts
// 求长度恰好为 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 的子数组
滑动窗口解题口诀
  1. 先想清楚窗口里维护什么数据(Set?Map?Sum?)
  2. 再想清楚什么时候收缩窗口(重复?超长?满足条件?)
  3. 最后想清楚在哪里更新结果(收缩前?收缩后?每步都更新?)

三、递归与分治

核心思想

大问题拆成小问题,小问题的解法和大问题相同。递归是算法中最重要的思维方式之一,理解递归是理解分治、回溯、动态规划、树操作的基础。

递归三要素

每一道递归题都必须想清楚这三个问题:

要素含义常见错误
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

→ 递归就是「递」下去 +「归」回来
理解递归的心法

不要试图在脑子里展开每一层递归。 只需要做到:

  1. 明确函数的定义(这个函数干什么?)
  2. 相信子递归能返回正确结果(不要去想子递归内部怎么执行)
  3. 利用子递归的结果,处理当前层的逻辑

比如"反转链表":只要相信 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;
}
递归注意事项
  1. 一定要有终止条件,否则无限递归导致栈溢出(Maximum call stack size exceeded
  2. 注意重复计算问题,可用记忆化优化(Map 缓存已算过的子问题)
  3. 递归深度过大会栈溢出,JS 默认调用栈约 1 万层。数据量大时考虑改为迭代
  4. 注意引用类型的参数传递,数组/对象在递归中共享,需要注意是否需要拷贝

递归题的解题思路

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;
}
自顶向下 vs 自底向上
  • 自顶向下(记忆化递归):写起来更直观,适合状态不好枚举的场景
  • 自底向上(迭代 dp 表):没有递归开销,更容易做空间优化,面试首选
  • 空间优化:当 dp[i] 只依赖前几个状态时,可以用滚动变量替代数组,将空间从 O(n)O(n) 降到 O(1)O(1)

常见 DP 分类

类型特征典型题目
线性 DPdp[i]dp[i-1]dp[i-2] 等推导爬楼梯、打家劫舍、最大子数组和
二维 DPdp[i][j] 需要两个维度最长公共子序列、编辑距离、路径数
背包 DP容量限制下的选择问题0-1 背包、完全背包、零钱兑换
区间 DPdp[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
DP 常见坑点
  1. 状态定义不精确:"dp[i] 到底是前 i 个的最优解,还是以第 i 个结尾的最优解?"——定义不同,转移方程完全不同
  2. 遍历顺序错误:0-1 背包正序遍历(二维)或逆序遍历(一维优化),完全背包正序遍历
  3. 初始值遗漏dp[0] 是 0 还是 1?dp[0][j] 怎么初始化?
  4. 忘记空间优化:面试中如果提到可以优化空间,加分

五、贪心算法

核心思想

每一步都选择当前最优,期望最终结果也是最优。贪心的关键在于证明局部最优能推出全局最优(贪心选择性质)。

贪心 vs 动态规划

对比项贪心动态规划
决策方式每步选局部最优,不回头穷举所有子问题,找全局最优
子问题无需回看之前的选择有重叠子问题
正确性需要证明贪心选择性质只要转移正确,保证最优
复杂度通常 O(n)O(n)O(nlogn)O(n \log n)通常 O(n2)O(n^2) 或更高
使用场景区间调度、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();
}
}
回溯必须注意的两个坑
  1. 收集结果时必须拷贝result.push([...path]),不能直接 push(path),否则所有结果指向同一个数组
  2. 去重时必须先排序:有重复元素的排列/组合,必须先 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(深度优先)
最短路径适合(无权图最短路径)不适合
层序遍历适合不适合
所有路径/排列组合不适合适合(回溯)
空间占用O(w)O(w),w 为最大宽度O(h)O(h),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 个不同元素)O(n!)O(n!)第一层 n 个选择,第二层 n-1,...
组合(n 选 k)O(C(n,k))O(C(n,k))O(n!k!(nk)!)O(\frac{n!}{k!(n-k)!})
子集(2^n 个)O(2n)O(2^n)每个元素选或不选
N 皇后O(n!)O(n!)(有剪枝)实际远小于 nnn^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 的最大利润
状态定义的验证方法

定义完状态后,检查:

  1. 能否写出转移方程?如果写不出来,可能状态定义不对
  2. 初始值能否确定?
  3. 最终答案是 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双指针反转链表、合并有序、移动零
3DFS/BFS二叉树遍历、岛屿数量、层序
4滑动窗口无重复最长子串、最小覆盖子串
5动态规划爬楼梯、零钱兑换、最长递增子序列
6排序+贪心合并区间、会议室
7回溯全排列、组合、子集
8栈/队列有效括号、最小栈、滑动窗口最大值
9二分查找旋转数组搜索、第 K 大元素
前端面试算法特点
  • 难度集中在 Easy ~ Medium,Hard 较少
  • 偏爱实际开发场景的题(LRU 缓存、扁平化、深度比较)
  • 手写题也算算法(Promise.all、防抖节流、深拷贝)
  • 重在思路清晰、代码规范,不追求极致优化

相关链接