跳到主要内容
面试知识DEV CATALOGNOTE

前端面试算法高频题整理

面向国内前端工程师社招面试。每道题按「思路 → 代码 → 复杂度」整理,不追求覆盖全部 LeetCode,只保留前端面试高频题。

复习建议

  • 遇到一道题,先说清楚:暴力解法是什么、为什么慢、如何优化、时间和空间复杂度。
  • 重点掌握:哈希表、双指针、滑动窗口、快慢指针、递归、二分查找。

一、排序算法

快速排序

选一个基准值(pivot),把数组划分成「比它小」和「比它大」两部分,递归对两部分分别排序。

function quickSort(arr) {
if (arr.length <= 1) return arr;

const [pivot, ...rest] = arr;
const left = rest.filter(n => n < pivot);
const right = rest.filter(n => n >= pivot);

return [...quickSort(left), pivot, ...quickSort(right)];
}

平均时间复杂度 O(n log n),最坏情况(数组本身有序,每次都选到最小/最大值当基准)退化到 O(n²)。

归并排序

把数组不断二分到只剩一个元素,再两两合并成有序数组。

function mergeSort(arr) {
if (arr.length <= 1) return arr;

const mid = Math.floor(arr.length / 2);
const left = mergeSort(arr.slice(0, mid));
const right = mergeSort(arr.slice(mid));

return merge(left, right);
}

function merge(left, right) {
const result = [];
let i = 0, j = 0;

while (i < left.length && j < right.length) {
result.push(left[i] <= right[j] ? left[i++] : right[j++]);
}

return [...result, ...left.slice(i), ...right.slice(j)];
}

时间复杂度稳定 O(n log n),不会像快排一样因为数据分布退化,代价是需要额外的合并数组空间,空间复杂度 O(n)。

二、数组与哈希

两数之和

暴力解法是双重循环枚举两个数字,时间复杂度 O(n²)。优化方式是用 Map 保存已经遍历过的数字:遍历当前数字时计算 target - 当前数字,如果 Map 中已经存在这个值,说明找到答案——核心思想是用空间换时间。

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

return [];
}

时间复杂度 O(n),空间复杂度 O(n)。

移动零

使用快慢指针:fast 负责遍历数组寻找非零元素,slow 指向下一个非零元素应该放置的位置。找到非零元素后与 slow 位置交换,一次遍历完成原地修改。

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

return nums;
}

时间复杂度 O(n),空间复杂度 O(1)。

盛水最多的容器

给一个数组表示每根柱子的高度,任选两根柱子和 x 轴构成的容器,求能盛的最大水量。用双指针从两端向中间收拢:容器容量由较矮的柱子决定,所以每次移动较矮的那根指针——移动较高的那根,宽度变小、高度上限不变,面积只会变小,没有尝试的必要。

function maxArea(height) {
let left = 0, right = height.length - 1, max = 0;

while (left < right) {
const area = Math.min(height[left], height[right]) * (right - left);
max = Math.max(max, area);
height[left] < height[right] ? left++ : right--;
}

return max;
}

时间复杂度 O(n),空间复杂度 O(1)。

三、字符串与滑动窗口

最长无重复字符子串

暴力方式需要枚举所有子串,再判断是否存在重复字符。滑动窗口的思路是:right 不断扩大窗口,出现重复字符时移动 left 收缩窗口,保证窗口内没有重复字符——每个字符最多进入和移出窗口一次。

function lengthOfLongestSubstring(s) {
const set = new Set();
let left = 0;
let max = 0;

for (let right = 0; right < s.length; right++) {
while (set.has(s[right])) {
set.delete(s[left]);
left++;
}

set.add(s[right]);
max = Math.max(max, right - left + 1);
}

return max;
}

时间复杂度 O(n),空间复杂度 O(k)。

四、链表

反转链表

核心是修改链表指针方向,需要三个变量:prev(当前节点前一个节点)、curr(当前节点)、next(当前节点下一个节点)。步骤:先保存 next 避免断链,再把 curr.next 指向 prev,最后三个指针一起向后移动。

function reverseList(head) {
let prev = null;
let curr = head;

while (curr) {
const next = curr.next;
curr.next = prev;
prev = curr;
curr = next;
}

return prev;
}

时间复杂度 O(n),空间复杂度 O(1)。

判断链表是否有环

使用快慢指针:slow 每次走一步,fast 每次走两步。如果存在环,fast 最终会追上 slow;如果不存在环,fast 会先到达链表尾部。

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

时间复杂度 O(n),空间复杂度 O(1)。

五、二叉树

二叉树遍历

二叉树遍历本质是递归,区别只在于访问根节点的时机:前序是根→左→右,中序是左→根→右,后序是左→右→根。

function preorder(root, result = []) {
if (!root) return result;

result.push(root.val);
preorder(root.left, result);
preorder(root.right, result);

return result;
}

中序、后序只需调整 result.push(root.val) 这一行相对左右递归调用的位置。时间复杂度 O(n),空间复杂度 O(h)(h 为树高,对应递归调用栈深度)。

层序遍历

广度优先,用队列。levelSize 记录进入这一层循环之前队列里有多少个节点,用来按层分组输出,不这样做只能拿到打平的遍历结果。

function levelOrder(root) {
if (!root) return [];

const result = [];
const queue = [root];

while (queue.length) {
const levelSize = queue.length;
const level = [];

for (let i = 0; i < levelSize; 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;
}

时间复杂度 O(n),空间复杂度 O(n)(最坏情况下队列里要装下一整层节点)。

二叉树最大深度

递归定义:当前节点高度 = 1 + 左右子树最大高度。

function maxDepth(root) {
if (!root) return 0;

return Math.max(
maxDepth(root.left),
maxDepth(root.right)
) + 1;
}

时间复杂度 O(n),空间复杂度 O(h)。

六、递归与回溯

全排列

回溯三部曲:选一个可能性 → 递归探索基于这个选择的所有可能 → 撤销这个选择,换下一个可能性继续尝试。used 数组标记某个数字在当前路径里是否已经用过,避免重复使用同一个位置的数字。

function permute(nums) {
const result = [];
const path = [];
const used = new Array(nums.length).fill(false);

function backtrack() {
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.pop();
used[i] = false;
}
}

backtrack();
return result;
}

时间复杂度 O(n × n!),空间复杂度 O(n)(递归栈 + path,不含存储结果本身的开销)。

七、动态规划

斐波那契数列

暴力递归直接照搬定义 f(n) = f(n-1) + f(n-2),但 f(n-2) 会在算 f(n-1)f(n) 时被重复计算,且重复量随 n 增大指数级膨胀,时间复杂度 O(2ⁿ)。

function fibNaive(n) {
if (n <= 1) return n;
return fibNaive(n - 1) + fibNaive(n - 2);
}

优化思路是把算过的子问题存起来,不重复算。记忆化递归(自顶向下)用一个 Map 缓存已经算过的结果:

function fibMemo(n, memo = new Map()) {
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;
}

时间复杂度降到 O(n),空间复杂度 O(n)(缓存 + 递归栈)。更常用的是自底向上迭代版,只用两个变量滚动保存前两项,空间复杂度压到 O(1):

function fib(n) {
if (n <= 1) return n;

let prev2 = 0, prev1 = 1;

for (let i = 2; i <= n; i++) {
const curr = prev1 + prev2;
prev2 = prev1;
prev1 = curr;
}

return prev1;
}

被要求"手写斐波那契"时通常直接写迭代版;被问"怎么优化一个暴力递归"时,讲出暴力递归 → 记忆化 → 迭代这三步演进,比直接甩最优解更能体现对动态规划的理解。

爬楼梯

每次可以爬 1 或 2 级台阶,爬到第 n 级有多少种方法。状态转移方程 f(n) = f(n-1) + f(n-2),和上面斐波那契的递推关系完全一样,只是初始值不同,同样只用两个变量滚动保存前两个状态,空间复杂度压到 O(1)。

function climbStairs(n) {
if (n <= 2) return n;

let prev2 = 1, prev1 = 2;

for (let i = 3; i <= n; i++) {
const curr = prev1 + prev2;
prev2 = prev1;
prev1 = curr;
}

return prev1;
}

时间复杂度 O(n),空间复杂度 O(1)。

最长递增子序列(LIS)

dp[i] 定义为「以第 i 个元素结尾的最长递增子序列长度」,这是这道题的关键——枚举 i 之前的每个 j,如果 nums[j] < nums[i],说明 nums[i] 可以接在以 nums[j] 结尾的子序列后面。

function lengthOfLIS(nums) {
const dp = new Array(nums.length).fill(1);
let max = 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);
}
}

max = Math.max(max, dp[i]);
}

return max;
}

时间复杂度 O(n²),空间复杂度 O(n);存在基于二分查找的 O(n log n) 解法,被追问"能否优化"时可以提一句,不强制要求手写。

背包问题(了解思路即可)

0/1 背包:每件物品选或不选,求不超过背包容量下能装的最大价值。dp[i][w] 表示「考虑前 i 件物品、背包容量为 w 时能装的最大价值」,状态转移是「不装第 i 件 dp[i-1][w]」和「装第 i 件 dp[i-1][w-weight[i]] + value[i]」取较大值。前端面试很少要求现场手写完整实现,能识别出"这是背包问题的变体"、说得出这个模型即可。

八、二分查找

标准二分查找

适用于有序数组。每次比较中间元素:相等则返回结果,中间值小于目标则搜索右半部分,中间值大于目标则搜索左半部分——每次排除一半数据。

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

时间复杂度 O(log n),空间复杂度 O(1)。

九、栈与队列

有效括号

利用栈「后进先出」的特性天然匹配括号「最近打开的要最先闭合」这个规则。遍历字符串,遇到左括号就入栈,遇到右括号就检查栈顶是否是与之匹配的左括号。

function isValid(s) {
const stack = [];
const pairs = { ')': '(', ']': '[', '}': '{' };

for (const char of s) {
if (char === '(' || char === '[' || char === '{') {
stack.push(char);
} else {
if (stack.pop() !== pairs[char]) return false;
}
}

return stack.length === 0;
}

stack.pop() 在栈已经空时返回 undefined,自然会和任何 pairs[char] 都不相等,不需要额外判断栈是否为空。遍历完还有没闭合的左括号残留在栈里,也是不合法的,所以最后要检查 stack.length === 0。时间复杂度 O(n),空间复杂度 O(n)。

十、面试冲刺顺序

第一优先级

  1. 两数之和
  2. 移动零
  3. 最长无重复字符子串
  4. 反转链表
  5. 判断链表是否有环
  6. 二叉树遍历
  7. 二叉树最大深度
  8. 标准二分查找

第二优先级

  1. 盛水最多的容器
  2. 有效括号
  3. 层序遍历
  4. 斐波那契数列
  5. 爬楼梯
  6. 全排列

第三优先级(了解即可)

  • 快速排序
  • 归并排序
  • 最长递增子序列(LIS)
  • 背包问题