ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

前端面试手撕JS与算法题全拆解:高频手写代码与避坑指南

前端面试手撕JS与算法题全拆解:高频手写代码与避坑指南 如果你正在准备前端面试js手撕题和算法应该已经占据了你刷题列表的大半。过去一年多我前端招聘面试了上百位候选人发现一个很扎心的规律简历写得越漂亮越容易在手撕环节暴露基本功——new的原型链讲不清、防抖节流的this绑定一写就错、快排背过但分析不了时间复杂度。这篇文章就把我在真实面试中高频遇到的手写题和算法题完整拆解一遍不光给代码还会讲清楚每个关键步骤为什么这么写、哪些细节最容易翻车以及面试官到底想从手撕题里看到什么。如果你是准备校招或者社招跳槽的前端这篇文章应该能帮你省下不少自己盲目刷题的时间。1. 为什么前端面试总爱考手撕JS先看懂出题人的心思1.1 手撕题到底在考察什么很多人以为手撕题考的是背代码这是最大的误解。面试官让你手写new或者Promise真正想看的是这三层东西第一你对 JavaScript 语言核心机制的理解深度比如原型链、this绑定、事件循环、闭包这些不是背概念能糊弄过去的第二你在边界条件下的处理能力比如深拷贝遇到循环引用怎么办、二分查找数组为空怎么办代码跑不跑得通一看便知第三你的工程习惯和复杂度意识写排序算法时有没有分析过时间复杂度和空间复杂度写完后会不会主动考虑测试用例。另一个认知误区是手撕题只考死代码。实际上面试官更愿意看到你的思考过程哪怕最后没写完只要思路清晰、能说出当前方案的缺陷和优化方向印象分会远高于一个默默敲完但不解释的人。我面试时经常会追问如果数据量变成十万条这个写法还成立吗其实就是在考察你是否有意识地从工程角度审视代码。1.2 面试官是怎么给你打分的我内部复盘面试情况时对手撕题的打分一般分成五个维度正确性、边界处理、复杂度分析、代码风格、沟通表达。一个常见误区是候选人只顾着憋代码全程不讲话甚至写完后也不自测。面试不是笔试考试手撕环节本质上是一次技术沟通。比较理想的答题节奏是先花 30 秒到 1 分钟确认题目要求和边界条件再花 1 分钟左右说思路和复杂度预期然后动笔写写完再用 1-2 个测试用例快速走读一遍代码最后主动总结这道题可以怎么优化。整个过程 5-10 分钟完成是比较合适的。如果 15 分钟还没理出头绪通常说明这题准备不足或者思路偏了面试官会开始提示这时候一定要接住提示硬着头皮写不仅浪费时间还会扣分。2. 高频工具函数手写题逐个拆解2.1 手写 new原型链理解的试金石new几乎是前端面试必考题它考察的是 JavaScript 面向对象最底层的逻辑。new之所以能凭空创建一个对象是因为它内部做了四件事新建一个空对象、把这个对象的原型指向构造函数的prototype、把构造函数内部的this绑定到这个新对象上并执行构造函数、根据构造函数返回值决定最终返回什么。function myNew(Constructor, ...args) { // 1. 创建新对象继承构造函数的原型 const obj Object.create(Constructor.prototype); // 2. 执行构造函数将 this 绑定到新对象上 const result Constructor.apply(obj, args); // 3. 构造函数显式返回对象类型则返回该对象否则返回新建的 obj const isObject result ! null (typeof result object || typeof result function); return isObject ? result : obj; }这里最容易翻车的是第三步。很多人会忘记判断构造函数显式返回值的情况如果构造函数里return { name: xxx }这样一个对象new之后拿到的其实是这个显式返回的对象而不是内部新建的obj。只有返回基本类型值或者null的时候new才会忽略它并返回obj。这个细节我面试时几乎每次都会问到。用的时候可以验证一下function Person(name) { this.name name; }myNew(Person, 张三)之后实例的__proto__能访问到Person.prototype上的方法instanceof Person也返回true。理解了手写 new原型链、构造函数、Object.create这几个概念就被串起来了。2.2 手写 call、apply、bindthis 绑定全家桶call、apply、bind是面试官最喜欢组合打包考察的手写题因为它们考察的是this绑定的本质。核心思路其实一句话this本质上是谁调用就指向谁所以可以把函数挂到目标对象上再调用。Function.prototype.myCall function(context, ...args) { context context ?? window; const fn Symbol(fn); context[fn] this; const result context[fn](...args); delete context[fn]; return result; }; Function.prototype.myApply function(context, args) { context context ?? window; const fn Symbol(fn); context[fn] this; const result context[fn](...(args || [])); delete context[fn]; return result; }; Function.prototype.myBind function(context, ...bindArgs) { const original this; return function(...callArgs) { return original.apply(context, [...bindArgs, ...callArgs]); }; };手写这套题的坑不少。第一个坑是用Symbol作为挂载到目标对象上的键名而不是随便用一个普通字符串避免把目标对象上原本存在的属性覆盖掉。第二个坑是执行完要delete掉临时挂载的属性不然会给对象留下永久污染。第三个坑是myBind返回的函数如果被new调用this应该被忽略指向新对象完整版还要处理箭头函数不能绑定 this之类的争议这个先不展开。还有一种写法是用Object.defineProperty定义不可枚举的属性比直接赋值更严谨因为直接赋值后这个属性会在for...in中被遍历到。面试能讲到这一层基本是加分表现。2.3 手写深拷贝进阶功能的试炼场深拷贝这道题很经典因为它可以从简单的JSON.parse(JSON.stringify())一路进阶到处理各种边界情况的完整版。先明确一点JSON序列化的方式有硬伤undefined、function、Symbol、循环引用、Date对象、正则都会出问题所以面试官想看的通常是完整版本。function deepClone(obj, hash new WeakMap()) { if (obj null || typeof obj ! object) return obj; if (obj instanceof Date) return new Date(obj); if (obj instanceof RegExp) return new RegExp(obj.source, obj.flags); if (hash.has(obj)) return hash.get(obj); const clone Array.isArray(obj) ? [] : {}; hash.set(obj, clone); for (let key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { clone[key] deepClone(obj[key], hash); } } return clone; }这里最关键的是用WeakMap解决循环引用问题。如果不做处理const a {}; a.self a; deepClone(a)这个测试用例会直接栈溢出。WeakMap的好处是键是弱引用不会影响对象被垃圾回收不会造成内存泄漏同时它就是专门为这种恢复对象-副本对应关系的场景设计的。还有两个常见的优化点Map和Set类型的拷贝完整版会分别处理Object.getOwnPropertySymbols()可以用来拷贝 Symbol 键。不过面试中能把WeakMap循环引用、Date、RegExp、数组这四类处理好已经是个不错的答案了不用强求每一行都完美。2.4 手写 instanceof 与类型判断instanceof的核心逻辑是沿着原型链向上查找看看左边对象的原型链上能不能找到右边构造函数的prototype。function myInstanceof(left, right) { let proto Object.getPrototypeOf(left); const prototype right.prototype; while (proto) { if (proto prototype) return true; proto Object.getPrototypeOf(proto); } return false; }与之配套的是Object.prototype.toString.call()用于核验精确类型。为什么不用typeof因为typeof []返回的是objecttypeof null返回的也是object这种结果是没法区分真实类型的。用Object.prototype.toString能拿到[object Array]、[object Date]这类精确字符串再配合slice就能实现一个通用的类型判断函数。3. 异步难题Promise、防抖节流与事件总线3.1 从零手写一个 Promise手写Promise是很多人的噩梦因为状态流转、微任务时序、链式调用堆在一起就乱了。其实把它拆开看核心就是状态机加发布订阅pending、fulfilled、rejected三种状态一旦状态改变就不能再变then注册的回调等到状态确定后再执行。class MyPromise { constructor(executor) { this.state PENDING; this.value undefined; this.reason undefined; this.onFulfilledCallbacks []; this.onRejectedCallbacks []; const resolve (value) { if (this.state PENDING) { this.state FULFILLED; this.value value; this.onFulfilledCallbacks.forEach(fn fn()); } }; const reject (reason) { if (this.state PENDING) { this.state REJECTED; this.reason reason; this.onRejectedCallbacks.forEach(fn fn()); } }; try { executor(resolve, reject); } catch (err) { reject(err); } } then(onFulfilled, onRejected) { onFulfilled typeof onFulfilled function ? onFulfilled : value value; onRejected typeof onRejected function ? onRejected : reason { throw reason; }; const promise2 new MyPromise((resolve, reject) { const handleFulfilled () { setTimeout(() { try { const x onFulfilled(this.value); resolvePromise(promise2, x, resolve, reject); } catch (err) { reject(err); } }); }; const handleRejected () { setTimeout(() { try { const x onRejected(this.reason); resolvePromise(promise2, x, resolve, reject); } catch (err) { reject(err); } }); }; if (this.state FULFILLED) handleFulfilled(); else if (this.state REJECTED) handleRejected(); else { this.onFulfilledCallbacks.push(handleFulfilled); this.onRejectedCallbacks.push(handleRejected); } }); return promise2; } catch(onRejected) { return this.then(null, onRejected); } }上面代码里的resolvePromise用来处理then回调返回的值以及promise2之间的依赖关系完整实现要考虑回调返回值是promise2本身会形成死循环要报错、返回值可能是带then方法的thenable对象、返回值本身又是Promise要递归解析。这块代码我是在项目里踩过坑的当时手动实现了一个简化版结果在嵌套Promise.resolve的场景下直接卡死后来才意识到必须循环判断返回值类型。如果觉得完整版太难可以先写一个状态机加then链式调用的半成品再逐步补齐。面试官更看重的是你能清状态一旦改变不可逆和then 返回新 Promise 支持链式调用这两个设计要点。3.2 手写 Promise.all / Promise.racePromise.all的核心语义是所有成功才成功一个失败就失败。实现时注意两点结果数组的按序填充、空数组直接返回。Promise.myAll function(promises) { return new Promise((resolve, reject) { const result []; let count 0; if (promises.length 0) { resolve(result); return; } promises.forEach((p, index) { Promise.resolve(p).then((value) { result[index] value; count; if (count promises.length) resolve(result); }).catch(reject); }); }); }; Promise.myRace function(promises) { return new Promise((resolve, reject) { promises.forEach((p) { Promise.resolve(p).then(resolve, reject); }); }); };Promise.race就简单一些谁先改变状态就用谁的结果。面试中常见的追问是Promise.all中的失败会不会影响其他请求——会因为.catch(reject)已经注册了任何一个请求失败都会让整体的Promise进入rejected状态但其他的异步操作并不会被中断。3.3 手写防抖 debounce 与节流 throttle防抖和节流是前端高频题也是实际项目里用得最多的工具函数。防抖的核心是触发后等待一段时间期间再次触发则重新计时节流的核心是一段时间内最多执行一次。function debounce(fn, wait 300) { let timer null; return function(...args) { clearTimeout(timer); timer setTimeout(() { fn.apply(this, args); }, wait); }; } function throttle(fn, interval 300) { let lastTime 0; return function(...args) { const now Date.now(); if (now - lastTime interval) { lastTime now; fn.apply(this, args); } }; }这里有两个常见坑。第一个是this绑定问题如果内部直接写fn(...args)那么当返回的函数作为对象方法调用时this会丢失。第二个是防抖的clearTimeout要置空timer不然可能导致闭包变量混乱节流则要搞清楚是时间戳版还是定时器版时间戳版在第一次触发时会立即执行定时器版在结束后还会再执行一次各有适用场景。我实际处理搜索框输入的场景时用的是防抖 立即执行一次的变体防止用户输入第一个字符后要等很久才有反馈体验不好。这个变体实现起来也很简单用一个immediate参数判断是否立即执行即可。3.4 手写事件总线 EventEmitter事件总线在异步编程、模块通信里很常见本质是一个发布订阅模式。Vue里的$bus和Node.js里的EventEmitter都是这个思路。class EventEmitter { constructor() { this.events new Map(); } on(event, callback) { if (!this.events.has(event)) { this.events.set(event, []); } this.events.get(event).push(callback); } emit(event, ...args) { if (!this.events.has(event)) return; this.events.get(event).forEach(cb { cb(...args); }); } off(event, callback) { if (!this.events.has(event)) return; const index this.events.get(event).indexOf(callback); if (index -1) { this.events.get(event).splice(index, 1); } } once(event, callback) { const wrapper (...args) { callback(...args); this.off(event, wrapper); }; this.on(event, wrapper); } }once的实现是一个常见考点不能直接把原始回调存进去因为off的时候要能找到它所以要包一层wrapper在触发时先执行再取消订阅。这个设计我在实际开发中用过很多次比如页面埋点只上报一次、WebSocket 只监听首次连接消息等。4. 排序算法面试里绕不开的基本功4.1 必会三兄弟冒泡、选择、插入排序算法是算法题的基础很多人觉得考试不考就跳过但实际上手写排序能很好地反映你对循环和交换的熟练度。冒泡排序是最直观的每一轮让相邻元素两两比较大的往后移经过 n-1 轮之后数组有序。function bubbleSort(arr) { const len arr.length; for (let i 0; i len - 1; i) { let swapped false; for (let j 0; j len - 1 - i; j) { if (arr[j] arr[j 1]) { [arr[j], arr[j 1]] [arr[j 1], arr[j]]; swapped true; } } if (!swapped) break; } return arr; }选择排序每轮找最小值和当前轮次的位置交换插入排序则是把每个元素向前插入到已经有序的部分。这三个基础排序的时间里复杂度最坏都是 O(n²)但插入排序在近乎有序的情况下可以达到 O(n)所以实际工程中有些排序框架在数据量小时会用插入排序作为兜底。4.2 高频双雄快速排序与归并排序快排是面试最高频的排序算法核心是分治加原地分区。一个常见问题是很多人背了递归写法但说不清楚为什么最坏情况下会退化到 O(n²)以及如何避免——当每次基准都选到最大或最小值时分区极度不均衡递归深度就变成 n。通常的优化策略是取三数取中法或者随机选基准。function quickSort(arr) { if (arr.length 1) return arr; const pivot arr[Math.floor(arr.length / 2)]; const left []; const right []; const middle []; for (let num of arr) { if (num pivot) left.push(num); else if (num pivot) right.push(num); else middle.push(num); } return [...quickSort(left), ...middle, ...quickSort(right)]; }这个写法不是原地排序空间复杂度高一些但最容易理解和记忆。如果面试中追求原地快排需要额外实现partition函数选基准、双指针从两端向中间移动、交换元素。归并排序则是稳定的排序算法时间复杂度稳定为 O(n log n)适合需要稳定排序的场景。4.3 堆排序构建与调整的魔法堆排序在算法题中出现频率高手撕代码量也算中等偏难。核心是两步先构建一个最大堆再把堆顶元素和末尾元素交换缩小堆范围后重新调整堆。function heapSort(arr) { const n arr.length; for (let i Math.floor(n / 2) - 1; i 0; i--) { siftDown(arr, i, n); } for (let i n - 1; i 0; i--) { [arr[0], arr[i]] [arr[i], arr[0]]; siftDown(arr, 0, i); } return arr; } function siftDown(arr, i, len) { while (true) { let maxIndex i; const left 2 * i 1; const right 2 * i 2; if (left len arr[left] arr[maxIndex]) maxIndex left; if (right len arr[right] arr[maxIndex]) maxIndex right; if (maxIndex i) break; [arr[i], arr[maxIndex]] [arr[maxIndex], arr[i]]; i maxIndex; } }堆排序最重要的点是理解siftDown的下沉调整从某个节点出发把它和左右孩子中的较大者比较如果父节点小就交换然后继续向下调整。面试时建议先画一个数组的下标二叉树映射图把自己讲明白再写不然很容易在2 * i 1、2 * i 2这些边界上栽跟头。4.4 排序算法对比与选型算法平均时间复杂度最坏时间复杂度空间复杂度稳定性冒泡排序O(n²)O(n²)O(1)稳定选择排序O(n²)O(n²)O(1)不稳定插入排序O(n²)O(n²)O(1)稳定快速排序O(n log n)O(n²)O(log n)不稳定归并排序O(n log n)O(n log n)O(n)稳定堆排序O(n log n)O(n log n)O(1)不稳定面试中被问到两个排序都稳定且都是 O(n log n)怎么选本质是让你比较归并和堆排序。归并排序稳定但空间占用高堆排序不稳定但空间 O(1)实际工程中会结合数据规模、稳定性需求和内存限制综合决定。5. 高频算法题套路拆解5.1 二分查找边界条件千万别写错二分查找本身不难难的是边界条件。我用的是左右闭区间[left, right]的写法配合mid left ((right - left) 1)这样计算中间值时不容易整数溢出位运算也比除法更快。function binarySearch(nums, target) { let left 0; let right nums.length - 1; while (left right) { const mid left ((right - left) 1); if (nums[mid] target) { return mid; } else if (nums[mid] target) { left mid 1; } else { right mid - 1; } } return -1; }写二分最容易踩的坑是循环条件到底写left right还是left right以及更新边界时mid要不要加一减一。两种写法都能对但要保持一致left right配合left mid 1、right mid - 1left right时则要注意最后返回的是left还是right。进阶题型是找左侧边界、右侧边界以及在旋转数组中查找核心都是在理解普通二分的基础上去套模板。5.2 双指针处理数组和字符串的利器双指针算法在面试中出现概率极高尤其是以下几个方向有序数组两数之和用左右指针逼近链表找环用快慢指针去重和移除元素用快慢指针覆盖写最长无重复子串用滑动窗口。以最长无重复字符的子串为例滑动窗口加哈希表记录字符最后出现的位置是标准解法。这类题每次看到都会觉得思路不难但真正动手写时经常写错更新 left 边界的时机。核心是每次扩张右边界时如果新字符之前在窗口内出现过那么left应该跳到Math.max(left, map.get(char) 1)然后更新字符位置并计算窗口长度。双指针还有一个特殊场景就是跳跃游戏2这类贪心题维护当前可到达范围的右边界和下一步能到达的最远距离遍历时更新farthest当走到当前边界时就步数加一并把边界提升到farthest。这个解法不涉及双指针但我建议你把贪心加在双指针那一类里一起练因为它们都强调用最少的状态变量记录关键信息。var jump function(nums) { let jumps 0; let curEnd 0; let farthest 0; for (let i 0; i nums.length - 1; i) { farthest Math.max(farthest, i nums[i]); if (i curEnd) { jumps; curEnd farthest; } } return jumps; };5.3 深度优先DFS与广度优先BFSDFS 和 BFS 是算法题里最常见的两种遍历方式。DFS 一般用递归加回溯适合求路径总数、排列组合、岛屿数量这类问题BFS 用队列逐层扩展适合求最短路径、二叉树的层序遍历这类问题。DFS 的典型模板function dfs(node, visited) { if (visited.has(node)) return; visited.add(node); for (let neighbor of getNeighbors(node)) { dfs(neighbor, visited); } }BFS 的典型模板function bfs(root) { if (!root) return []; const queue [root]; const result []; 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; }注意 BFS 这里如果频繁用到shift性能上会有问题更严谨的做法是用双端队列模拟或者维护一个头指针。不过在面试手撕场景能用数组写出来并跑通测试用例已经是合格水平可以先不纠结性能细节。5.4 动态规划从暴力递归到递推填表动态规划在前端面试里没那么高频但像背包问题、爬楼梯、最长递增子序列、买卖股票这类经典题还是值得花时间准备的。核心就一句把大问题拆成子问题用数组或者哈希表存子问题的解避免重复计算。以经典爬楼梯为例dp[i]表示爬到第 i 阶有几种方法状态转移方程是dp[i] dp[i - 1] dp[i - 2]因为最后一次可以跨一阶也可以跨两阶。初始化时dp[0] 1dp[1] 1然后从 2 推到 n 即可。面试中遇到动态规划题我建议按这个顺序推进先写暴力递归再分析有没有重复子问题然后加上记忆化数组最后改写成迭代填表。这样每一步都有逻辑依据也方便面试官看到你的思考路径。如果一上来就写状态转移方程很容易卡壳。6. 数据结构实现与刷题方法论6.1 手写 LRU 缓存真实业务的常见考点LRU 缓存是面试中的高频手写题因为它既是算法题又是实际工程里 Redis、浏览器缓存都会用到的策略。用Map实现 LRU 是最简洁的写法因为Map天然保留了插入顺序迭代时按键值对的插入顺序输出。class LRUCache { constructor(capacity) { this.capacity capacity; this.map new Map(); } get(key) { if (!this.map.has(key)) return -1; const value this.map.get(key); this.map.delete(key); this.map.set(key, value); return value; } put(key, value) { if (this.map.has(key)) { this.map.delete(key); } this.map.set(key, value); if (this.map.size this.capacity) { this.map.delete(this.map.keys().next().value); } } }这里的关键操作是get时先把键删除再重新插入让键变成最新插入的从而调整它在Map中的迭代顺序。清理时取this.map.keys().next().value得到的就是最早的键。用双向链表加哈希表实现 LRU 是更进阶的版本面试官提出来时你要知道它的优势是删除节点是 O(1)但Map的写法在面试中已经足够惊艳而且代码量少得多。6.2 数组去重与扁平化等工具题工具函数是整个手撕题家族里最基础也最容易得分的一类。数组去重的最高级写法是利用Set一行搞定const unique arr [...new Set(arr)]。如果面试官追问如果元素是对象呢就要说明浅去重和深去重的区别对象是否完全相等要看JSON.stringify或者递归比较。数组扁平化可以用reduce配合concat实现const flatten (arr) arr.reduce((acc, cur) acc.concat(Array.isArray(cur) ? flatten(cur) : cur), [] );字符串相关的工具题里判断字符串是否包含某个字符可以直接用includes也可以用indexOf、正则test。这类题虽然简单但值得把不同方法的差异说清楚includes是 ES6 的语法indexOf能找到下标正则能匹配模式实际使用中按场景选择即可。6.3 基础数据结构实现要点前端手撕题虽不常要求你完整实现链表、栈、队列但对应的高频题型还是值得过一遍。栈常用数组模拟队列可以用数组加头指针链表则要掌握反转、环形检测、合并两个有序链表。哈希表在 JS 里直接就是Map和Object面试中遇到判断两个字符串是否为异位词、两数之和这类题核心就是用哈希表把查找从 O(n) 降到 O(1)。以手写一个简易的栈为例用数组就能实现但要注意pop和top的区别。很多算法题比如有效的括号、逆波兰表达式求值本质上都是在考栈的运用。如果你刷过 LeetCode 前 100 题会发现栈和队列的变形题特别多做熟了以后面试遇到基本秒过。6.4 刷题顺序与实践建议如果你时间有限给一个我推荐的刷题优先级先刷工具函数手写题把new、call/apply/bind、深拷贝、防抖节流、Promise 系列搞透再刷高频算法思想题重点是二分、双指针、哈希表、DFS/BFS、排序最后如果有余力再看动态规划和冷门算法。还有一点想提醒你KMP、Prim、EM 算法、DBSCAN 这类名字在热搜里很显眼但除非你投的是算法岗或者面试官明确要求否则前端手撕题基本不会考这么偏门的东西。我曾经花了一个周末研究 KMP 的 next 数组后来发现面试根本没考过性价比很低。把时间投入到高频题型上更划算。7. 手撕题常见问题排查与避坑指南7.1 常见边界错误速查常见问题原因排查方法深拷贝栈溢出循环引用未处理用 WeakMap 记录已拷贝对象debounce 中 this 丢失内部直接调用 fn没用 apply在返回函数里用 fn.apply(this, args)二分查找死循环边界更新写错检查 left/right 更新公式写完后跑空数组与单元素用例Promise.then 不执行状态已变但回调数组没被触发确认 resolve 后切换状态是否执行回调遍历快排结果错误分区逻辑处理相同元素出错引入 middle 数组或在 partition 中让相等元素正确分布数组扁平化溢出递归层数过深面试中说明可以用迭代加栈替代递归这张表是我复盘面试答题时整理的哪怕你在现场没写对如果能准确说出这个问题我不太确定但我知道应该用 XX 去排查也会比闷头改半天强得多。面试官看重的不是你一次写对而是你面对 bug 时的调试能力。7.2 一道看似简单但很容易翻车的案例我经常在面试中使用的一个题是实现一个函数输入一个数组和一个整数 k输出数组中第 k 大的元素。这题看起来简单但考察点很多直接排序取下标是最容易写出的方案但时间复杂度是 O(n log n)进阶方案是使用快速选择算法平均时间复杂度 O(n)再进阶是用堆只维护一个大小为 k 的小顶堆空间复杂度 O(k)。很多候选人卡在了一个细节上第 k 大是从大到小排序后的第 k 个不是从小到大。如果你直接对升序数组取arr[len - k]其实是正确的但如果你写成arr[k - 1]就错了。这种题目难吗不难但它能测试出你有没有先明确题干定义、有没有主动和面试官确认边界条件的意识。7.3 勤用测试用例自检别写一次性代码手撕题最怕的不是思路错而是写完就交付不检查。我建议每写完一个函数至少在脑海里跑两个用例一个正常用例一个边界用例。比如写了排序就跑一个[3, 1, 2]和一个[]写了二分就跑一个空数组和长度为 1 的数组。这一两分钟的自检时间能帮你避免大量低级错误也会让面试官对你的工程素养印象深刻。8. 写在最后作为面试者我的几点实在建议我个人在实际招聘中发现手撕题准备得扎实的候选人工程能力通常也不会差。因为手写题本质上是对语言细节、逻辑思维和代码习惯的综合检验你没真正理解某个概念是写不出来的背代码只能应付一时面试官稍微换个角度追问就露馅了。最后分享一个小技巧刷题的时候不要只看自己写出来的代码要把每一道题的复杂度分析和优化方向写在旁边形成自己的解题模板。比如看到两数之和就想到哈希表看到有序数组就想到二分或双指针看到最短路径就想到 BFS这些条件反射靠的就是大量重复训练。这篇内容后续你还可以这样扩展把手写 Promise 完整版补上Promise.resolve、finally、allSettled把 LRU 用双向链表加哈希表实现一遍再把 LeetCode 高频一百题按我给的优先级刷两轮。只要掌握好前面这些基础其实前端面试手撕题这一关没什么可怕的。祝你面试顺利。
返回列表