ARTICLE DETAIL

资讯详情

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

前端面试手撕题与算法核心:从JavaScript机制到高频题解

前端面试手撕题与算法核心:从JavaScript机制到高频题解 我最近在帮组里做前端面试题复盘时发现一个特别有意思的现象候选人简历上都写着“熟悉JavaScript”可一旦到了手撕题环节能完整写出防抖节流并讲清楚原理的不超过三成能把快排写对且不超时的更少。这个现象在我自己几年前面试时也发生过——背了一堆手撕题模板结果面试官一换场景就卡壳。后来我把常见的js手撕题和算法按考察目的重新梳理了一遍才发现这类题真正考的不是代码默写而是对JS运行机制和数据结构的理解深度。这篇总结主要覆盖两部分高频JS手撕题防抖节流、深拷贝、call/apply/bind、new、Promise等和常见算法排序、二分、双指针、滑动窗口、动态规划/贪心、KMP等。适合正在准备前端面试的开发者也适合想查漏补缺、把JS基本功打扎实的工程师。内容会尽量说清楚每个实现背后的原理和面试表达方式不只是扔一段能跑的代码。1. 先回答最实际的困惑手撕题为什么年年考到底在考什么1.1 面试官想看到的东西跟你以为的不一样很多人觉得手撕题就是考“能不能写出来”其实不是。面试官坐在对面真正在观察的是三件事。第一编码习惯。变量命名是否清晰代码结构是否整洁有没有写无意义的注释。这能反映日常工作习惯。第二边界意识。入参为空怎么办传入的参数类型不对怎么办数组长度是0怎么办。新手通常写完主流程就停有经验的工程师会下意识补上边界处理。第三理解深度。这道题背后涉及哪些JS机制比如防抖背后的this绑定和闭包、Promise背后的微任务队列、深拷贝背后的引用类型和原型链。你能否把这些机制讲清楚比代码能不能跑更关键。举一个实际例子。面试“手写一个防抖函数”时我见过两类候选人的表现。第一类很快写出了setTimeout版本然后停了。第二类写完基础版本后主动补了一句“如果这个函数还需要返回值或者需要支持取消应该再处理一下。”后者在面试官心里的得分完全是两个层次。1.2 把常考的题分成四类备考效率完全不同手撕题不是一个笼统的概念按考察维度可以分四类。分类目的不是为了贴标签而是让你知道每一类该用什么方式准备。第一类API复现类。用原生JS实现内置方法比如Array.prototype.map/reduce/filter/flat、Function.prototype.call/apply/bind、String.prototype.includes。这类题考的是对原生API行为细节的掌握——比如reduce不传初始值时的行为、flat默认只扁平一层等。第二类工具函数类。防抖、节流、深拷贝、柯里化、发布订阅、数组去重。这类题在业务里真正用得最多面试出现频率也最高。准备重点是理解每种工具解决的核心问题和边界条件。第三类框架/机制原理类。手写一个简版Promise、手写一个new、实现LazyMan、实现一个简单的响应式系统。这类题考的是对JS语言机制和异步模型的理解。Promise手写几乎是高级前端岗位的保留题目。第四类算法与数据结构类。排序、二分查找、双指针、滑动窗口、动态规划、贪心、回溯、字符串匹配。这类题更多考察逻辑思维和数据结构的应用能力。分类之后你会发现备考逻辑清晰很多API复现类靠“熟”和“细”工具函数类靠“理解边界”机制原理类靠“对运行机制的理解”算法类靠“思路模板练习”。不要一上来就背代码先按这个框架判断某道题属于哪一种考察方向。1.3 一个很反直觉的判断面试官可能并不期待你写“最优解”我面试别人时经常会出一个二叉树的层序遍历候选人有写递归版的、有写BFS队列版的都能过。因为这类题考察的核心是你有没有清晰的遍历思路和代码组织能力而不是逼你写出时空复杂度最优的实现。但算法题例外——像“跳跃游戏2”这种题目能写出贪心当然最好但如果你能从暴力递归一步步优化到贪心面试官反而更满意。因为他们想看的是思维过程而不是背答案。这也是为什么我在后面的章节里会刻意带着大家走一遍从暴力到最优的推导过程。2. 高频JS手撕题的第一梯队防抖节流、深拷贝、call/apply/bind、new2.1 防抖与节流先想清楚它们各自解决什么问题防抖和节流是最常被拿来一起问的题目但如果只说“一个延迟执行、一个限频执行”是不够的。你得能讲清楚为什么需要这两种工具——本质都是在控制函数的执行频率减少不必要的计算和网络请求。防抖适合的场景是搜索框输入联想、窗口resize后重新计算布局。你希望用户停止操作后再执行而不是每敲一个字母就发一次请求。节流适合的场景是滚动监听里的懒加载、按钮点击后的限频提交。你希望在一段连续时间内只执行一次避免高频率触发导致的性能问题。先看防抖的实现。核心思路是用闭包保存定时器ID每次触发都清掉上一次的定时器重新计时function debounce(fn, wait 300, immediate false) { let timer null; let isInvoked false; return function(...args) { const context this; if (timer) clearTimeout(timer); if (immediate !isInvoked) { fn.apply(context, args); isInvoked true; return; // 第一次立即执行后继续走正常节流逻辑 } timer setTimeout(() { fn.apply(context, args); isInvoked fpalse; // 重置便于下次立即执行 timer null; }, wait); }; }注意两个细节。第一this的绑定是通过fn.apply(context, args)做的。很多人写防抖时直接用fn(args)这在调用方是普通函数时没问题但如果防抖后的函数被用作对象方法this就丢了。第二immediate参数控制是否第一次立即执行。一些搜索场景希望用户第一次输入时立即反馈之后才进入防抖逻辑这个参数就派上用场了。再看节流。两种实现方式很典型时间戳版和定时器版。// 时间戳版首次立即执行停止触发后不会再执行 function throttle(fn, wait 300) { let previous 0; return function(...args) { const now Date.now(); if (now - previous wait) { fn.apply(this, args); previous now; } }; } // 定时器版首次延迟执行停止触发后会再执行一次 function throttle(fn, wait 300) { let timer null; return function(...args) { if (!timer) { timer setTimeout(() { fn.apply(this, args); timer null; }, wait); } }; }两种版本行为差异很大时间戳版第一次立即执行但停止触发后最后那次不会执行定时器版第一次是延迟的但停止后末尾会补一次。面试时如果能把这两个差异讲清楚绝对加分。实际业务里经常需要的是“首次立即执行 末尾补一次”的合体版你可以自己课后实现一下。2.2 深拷贝少踩循环引用和特殊类型的坑深拷贝这道题从初级到高级都能考。最基础的回答是JSON.parse(JSON.stringify(obj))但紧接着就会被问这个方法有什么缺陷它处理不了undefined、Symbol、函数、正则、Date、循环引用NaN会变成nullBigInt会直接报错。所以手写深拷贝的关键是分类讨论。以下是一个覆盖常见类型的深拷贝实现。核心思路是递归 用WeakMap缓存已拷贝的对象解决循环引用function deepClone(target, map new WeakMap()) { if (target null || typeof target ! object) { return target; } if (map.has(target)) return map.get(target); // 处理特殊对象类型 if (target instanceof Date) return new Date(target); if (target instanceof RegExp) return new RegExp(target.source, target.flags); if (target instanceof Map) { const result new Map(); map.set(target, result); target.forEach((value, key) { result.set(deepClone(key, map), deepClone(value, map)); }); return result; } if (target instanceof Set) { const result new Set(); map.set(target, result); target.forEach(value { result.add(deepClone(value, map)); }); return result; } const result Array.isArray(target) ? [] : {}; map.set(target, result); // 不仅要遍历普通key还要遍历Symbol类型key Reflect.ownKeys(target).forEach(key { result[key] deepClone(target[key], map); }); return result; }这里有两个值得说的点。第一是Reflect.ownKeys而不是Object.keys因为Object.keys拿不到Symbol类型的键而很多对象内部自有属性可能是Symbol。第二是WeakMap的选择——它键是弱引用不会造成内存泄漏而且正好用来解决循环引用当拷贝一个对象时先把它放进map如果后面遇到指向同一个对象的引用直接从map取就不会无限递归了。面试中如果你能主动提到“循环引用会导致栈溢出所以需要WeakMap缓存”已经超越了大多数候选人。我自己的习惯是写完核心逻辑后补一句“对于函数浅引用即可因为函数本身不作为序列化目标而且大多数业务场景下不需要深拷贝函数”。2.3 call/apply/bind与new理解this绑定的本质这三个方法的实现是API复现类里最经典的一组。它们考察的是对this绑定规则、闭包、原型链的综合理解。先看call的实现。核心思路是把当前函数挂到目标对象上以对象方法的形式调用这样函数内部的this就指向了目标对象。为了避免污染对象调用完要删除这个临时属性。用Symbol作为属性名可以防止和对象原有属性冲突Function.prototype.myCall function(context, ...args) { if (context null) context globalThis; if (typeof context ! object typeof context ! function) { context Object(context); // 原始值包装为对象 } const key Symbol(temp); context[key] this; const result context[key](...args); delete context[key]; return result; };apply的区别只是参数是数组把...args换成args即可。bind要复杂一点因为它返回一个新函数而不是立即执行新函数被调用时this要和参数一并合并Function.prototype.myBind function(context, ...bindArgs) { const fn this; return function(...callArgs) { return fn.apply(context, [...bindArgs, ...callArgs]); }; };注意bind还要支持“用new调用返回的函数”的情况完整版会更复杂。面试时先写这个基础版本然后主动补一句“如果要支持new调用还得在内部判断this是否是新创建的对象。”这会让面试官知道你是懂原理的。new的实现同样要分清几步创建一个新对象、把新对象的原型指向构造函数的prototype、执行构造函数并把this绑定到新对象、如果构造函数返回了对象则返回该对象否则返回新对象function myNew(Constructor, ...args) { const obj Object.create(Constructor.prototype); const result Constructor.apply(obj, args); return (typeof result object result ! null) || typeof result function ? result : obj; }这组题目建议连在一起准备因为它们的核心都是this绑定的不同方式。平时业务里虽然不一定会手写这些但理解了实现过程对bind返回的新函数为什么可以预填参数、new为什么能改变构造函数的返回结果都会有更直观的认识。3. Promise手写不只是背模板要能讲清楚状态机3.1 为什么很多手写Promise的面试答案不能用于线上Promise手写是高级前端岗位的常见题目我自己也用它考过人。它考察的核心是三个机制状态机、异步回调队列、链式调用。你会发现网上很多精简版实现忽略了很多关键细节——比如then注册的回调没有放进微任务队列而是同步执行了比如resolve一个Promise对象时没有递归展开比如then返回的新Promise没有处理回调返回Promise的情况。这些问题是否重要取决于面试官的考察深度。如果他只问“能实现基本的resolve/reject/then”初级版本可以应付但如果他追问“为什么then注册的回调是异步的”“如果resolve传入一个Promise会发生什么”你就必须对完整机制有概念。我的建议是不要在面试时直接写一个非常复杂、几百行的完整Promise。那既容易出错也不利于讲清楚思路。正确策略是分步来——先实现状态机和基础的resolve/reject再实现then的链式调用和值穿透最后再补微任务和Promise解析过程。3.2 分步实现状态机、then链式、Promise解析过程第一步状态机。Promise有三个状态PENDING、FULFILLED、REJECTED。状态一旦从PENDING变为其他状态就不能再变。const PENDING PENDING; const FULFILLED FULFILLED; const REJECTED REJECTED; class MyPromise { constructor(executor) { this.state PENDING; this.value undefined; this.reason undefined; this.onFulfilledCallbacks []; this.onRejectedCallbacks []; const resolve (value) { if (this.state ! PENDING) return; this.state FULFILLED; this.value value; this.onFulfilledCallbacks.forEach(fn fn()); }; const reject (reason) { if (this.state ! PENDING) return; 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 : err { throw err; }; const promise2 new MyPromise((resolve, reject) { const handleFulfilled () { queueMicrotask(() { try { const x onFulfilled(this.value); resolvePromise(promise2, x, resolve, reject); } catch (e) { reject(e); } }); }; const handleRejected () { queueMicrotask(() { try { const x onRejected(this.reason); resolvePromise(promise2, x, resolve, reject); } catch (e) { reject(e); } }); }; if (this.state FULFILLED) { handleFulfilled(); } else if (this.state REJECTED) { handleRejected(); } else { this.onFulfilledCallbacks.push(handleFulfilled); this.onRejectedCallbacks.push(handleRejected); } }); return promise2; } }第二步resolvePromise——这是整个手写Promise里最容易忽略也最见功力的部分。它要做的是处理then回调返回值的解析逻辑如果返回值是一个MyPromise实例就等它落定如果是普通值直接resolve如果返回值又是一个“类Promise对象”有then方法的对象也要兼容。function resolvePromise(promise2, x, resolve, reject) { if (promise2 x) { reject(new TypeError(Chaining cycle detected for promise)); return; } if (x instanceof MyPromise) { x.then(resolve, reject); return; } if ((typeof x object x ! null) || typeof x function) { let called false; try { const then x.then; if (typeof then function) { then.call( x, y { if (called) return; called true; resolvePromise(promise2, y, resolve, reject); }, r { if (called) return; called true; reject(r); } ); } else { resolve(x); } } catch (e) { if (called) return; called true; reject(e); } return; } resolve(x); }还有一个很关键的细节为什么要用queueMicrotask而不直接同步执行回调因为在真实Promise规范中then注册的回调永远进入微任务队列。如果不这么做new Promise(resolve resolve(1)).then(console.log); console.log(2)的输出顺序会变成先1后2这就完全违背了JS的事件循环机制。面试时这个细节几乎必考你主动讲出来面试官就知道你是理解微任务的。3.3 边界与面试表达从简到深的分层回答面试手写Promise时我建议给自己设计三层回答第一层基础状态机 resolve/reject快速展示对Promise基本结构的理解。第二层then链式调用 异步执行微任务展示对链式编程和事件循环的掌握。第三层resolvePromise递归处理then返回值展示对thenable对象和Promise解析过程的深入理解。不必一上来就把第三层写完。可以先问面试官“您希望我写到什么程度”大多数面试官会让你先实现基础再根据需要追问。这样你既能展示深度又能控制代码复杂度。除了代码还要能回答几个常规追问为什么Promise可以解决回调地狱和回调函数相比它的核心优势是什么微任务和宏任务在Promise场景下是怎么配合的这些概念如果没提前准备很容易在写完代码后被问懵。4. 排序算法怎么准备才不白费快排、归并、堆排序的选择4.1 别按教科书顺序背先看数据规模很多人在准备排序时会从冒泡、选择、插入一路背到快排和归并结果面试一紧张全混在一起。我的建议是反向准备先搞清楚每种排序的适用场景再决定背哪几个。面试里高频率出现的是快排考察分治思想、归并考察合并和稳定性概念、堆排序考察堆结构和TopK问题。冒泡和插入虽然简单但出现的概率相对低可以作为热身题准备不需要花太多时间。为什么快排出镜率最高因为它实现简单、平均效果不错而且分治思想可以衍生出很多追问——比如时间复杂度是怎么算出来的、最坏情况是什么、如何优化。这些问题比排序本身更重要。数据规模对小规模排序影响也很大。V8引擎对数组排序的实现TimSort融合了插入排序就是因为在数据量小时插入排序常数小、速度快。面试官如果问“为什么实际排序不总用快排”你能回答出“小规模时插入排序性能更好、快排有递归栈开销且最坏情况O(n²)”印象分会好很多。4.2 手写快排与归并代码与边界快排的核心是分区partition。我推荐写原地分区版本因为面试官通常不想看一个每次都创建新数组的版本——那已经失去了“排序”的实用意义。原地分区最重要的是定义清楚区间边界。function quickSort(nums, left 0, right nums.length - 1) { if (left right) return; const pivotIndex partition(nums, left, right); quickSort(nums, left, pivotIndex - 1); quickSort(nums, pivotIndex 1, right); } function partition(nums, left, right) { // 以最右侧元素为基准把小于pivot的放到左边大于的放到右边 const pivot nums[right]; let i left; // i 是“下一个小于pivot元素应该放的位置” for (let j left; j right; j) { if (nums[j] pivot) { [nums[i], nums[j]] [nums[j], nums[i]]; i; } } [nums[i], nums[right]] [nums[right], nums[i]]; return i; }这里的边界要特别注意for循环只到right - 1因为right是基准最后交换基准和位置i。如果递归边界写错会无限递归或直接越界。我每次写完都会用[2, 1, 3]这样的小数组在脑子里过一遍。归并排序的套路更固定先拆到不能再拆再合并。中间最关键的是merge函数它需要额外空间来按大小合并两个有序数组function mergeSort(nums) { if (nums.length 1) return nums; const mid Math.floor(nums.length / 2); const left mergeSort(nums.slice(0, mid)); const right mergeSort(nums.slice(mid)); return merge(left, right); } function merge(left, right) { const result []; let i 0; let j 0; while (i left.length j right.length) { if (left[i] right[j]) { result.push(left[i]); i; } else { result.push(right[j]); j; } } while (i left.length) result.push(left[i]); while (j right.length) result.push(right[j]); return result; }归并的时间复杂度稳定在O(n log n)且是稳定排序这是它相对于快排的核心优势。题目如果明确要求“保持原顺序相同元素的相对位置不变”归并就是正确选择。面试时能主动提到这一点说明你不只是会背代码。4.3 堆排序如何用最少的代码写出可用版本堆排序代码思路其实不难难在细节。核心有两个操作建堆和调整。调整时从最后一个非叶子节点开始向下堆化sift down保证每个子树都满足堆性质。function heapSort(nums) { const n nums.length; // 建堆从最后一个非叶子节点开始向下调整 for (let i Math.floor(n / 2) - 1; i 0; i--) { siftDown(nums, i, n); } // 逐个把堆顶放到末尾 for (let i n - 1; i 0; i--) { [nums[0], nums[i]] [nums[i], nums[0]]; siftDown(nums, 0, i); } return nums; } function siftDown(nums, i, len) { while (true) { let largest i; const left 2 * i 1; const right 2 * i 2; if (left len nums[left] nums[largest]) largest left; if (right len nums[right] nums[largest]) largest right; if (largest i) break; [nums[i], nums[largest]] [nums[largest], nums[i]]; i largest; } }这类代码最大的坑是序号计算左子节点是2 * i 1右子节点是2 * i 2最后一个非叶子节点是Math.floor(n / 2) - 1。三个数字记错了任何一个堆都会乱。如果只是应付面试堆排序的实现优先级可以放低一点但“堆结构用于TopK问题”这个概念一定要理解。比如“从10万个数字中找出最大的K个数”最优解不是排序而是维护一个大小为K的最小堆。这道题的思路能说清楚比堆排序本身更能体现你的算法功底。5. 二分、双指针与滑动窗口JS里最常考的非暴力思路5.1 二分查找的变体才是考试重点二分查找本身很简单但面试几乎很少直接考最基础的版本。更多是考变体查找第一个等于目标值的位置、查找最后一个等于目标值的位置、在旋转排序数组中查找目标值、寻找峰值等。用左闭右闭区间写基础版是最稳妥的选择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; }注意几个容易错的细节循环条件是left right还是left right更新时是mid 1还是mid。这两个细节决定了会不会死循环。左闭右闭区间必须用left right和mid ± 1左闭右开区间则用left right和mid。查第一个等于目标值的位置要在nums[mid] target时不急于返回而是继续向左收缩function findFirst(nums, target) { let left 0; let right nums.length - 1; let result -1; while (left right) { const mid Math.floor((left right) / 2); if (nums[mid] target) { right mid - 1; if (nums[mid] target) result mid; } else { left mid 1; } } return result; }这种“不在循环里直接return而是用result变量暂存候选答案”的写法是我个人觉得最好理解和记忆的。它能避免很多复杂边界条件适合面试现场手写。旋转排序数组的搜索也可以套用这个思路只是判断落在哪半边时要多一步判断。5.2 双指针与滑动窗口记模板不如理解窗口的收缩条件双指针在JS算法面试里出现频率非常高最常见的是两类快慢指针用于链表检测环、寻找中点和左右指针用于有序数组的两数之和。这类题的核心价值是把O(n²)暴力解法优化到O(n)。滑动窗口是双指针的进阶形式典型的“连续子数组/子串满足某某条件”问题都可以往这个方向想。模板思路很固定右指针一直右移扩大窗口当窗口不满足条件时左指针右移缩小窗口直到窗口重新满足条件。过程中不断更新答案。function minWindow(s, t) { // 需求字典 const need new Map(); for (const c of t) need.set(c, (need.get(c) || 0) 1); let left 0; let right 0; let valid 0; let start 0; let len Infinity; const window new Map(); while (right s.length) { const c s[right]; right; if (need.has(c)) { window.set(c, (window.get(c) || 0) 1); if (window.get(c) need.get(c)) valid; } while (valid need.size) { if (right - left len) { start left; len right - left; } const d s[left]; left; if (need.has(d)) { if (window.get(d) need.get(d)) valid--; window.set(d, window.get(d) - 1); } } } return len Infinity ? : s.slice(start, start len); }这段代码是“最小覆盖子串”的经典模板。它有两个容易写错的地方一是右指针移动后要做的“窗口更新”和左指针移动后要做的“窗口收缩”看起来很像但方向相反二是valid计数更新的条件必须是窗口内某个字符数量刚好等于需求数量时才加一这样才能判断窗口是否已经覆盖了所有目标字符。我建议你比自己练几道滑动窗口题从“无重复字符的最长子串”开始再到“最小覆盖子串”。把模板理解透后很多窗口题都可以套用同一个骨架只是收缩条件不同。6. 动态规划与贪心以“跳跃游戏2”为例拆解从暴力到最优的演进6.1 先暴力再观察再贪心为什么这个顺序在面试里更重要“跳跃游戏2”是一道非常经典的题也直接出现在相关热搜词里。题目要求给定一个非负整数数组每个元素代表你在该位置可以跳跃的最大长度求跳到最后一个位置所需的最少跳跃次数。假设总能到达最后一个位置。大多数第一次看到这道题的人会尝试递归。思路很直观从位置0出发尝试跳1到nums[0]步然后递归计算从每个新位置跳到终点所需的最小步数取最小值加1。function jump(nums) { const n nums.length; const memo new Array(n).fill(-1); function dfs(pos) { if (pos n - 1) return 0; if (memo[pos] ! -1) return memo[pos]; let minSteps Infinity; const maxStep nums[pos]; for (let step 1; step maxStep; step) { minSteps Math.min(minSteps, dfs(pos step) 1); } memo[pos] minSteps; return minSteps; } return dfs(0); }这是带记忆化的暴力解法复杂度已经能应付一些小规模测试。面试时先写这一版目的有两个一是确认你理解题目二是为后续优化提供对比基线。这时候面试官通常会问“还能更快吗”于是你开始观察规律。关键观察是每次跳跃选的位置应该让它下一步能覆盖更远。也就是在“当前能跳到”的范围内找那个pos nums[pos]最大的位置。这个思路不是贪心硬想的而是从暴力过程里自然总结出来的——反正最后都要尽量跳得远那为什么不一步到位选择一个最优落脚点。6.2 最终版贪心实现边界管理和变量语义最终版本基于BFS层的思想把跳跃过程看成一层一层扩展。当前这一层能覆盖的区间是[currentEnd, maxReach]当遍历到currentEnd时跳跃次数加1并重置下一层的边界为当前达到的maxReach。function jump(nums) { const n nums.length; if (n 1) return 0; let jumps 0; let currentEnd 0; // 当前这一跳能达到的右边界 let maxReach 0; // 扫描过程中能达到的最远位置 for (let i 0; i n - 1; i) { maxReach Math.max(maxReach, i nums[i]); if (i currentEnd) { jumps; currentEnd maxReach; if (currentEnd n - 1) break; } } return jumps; }三个变量的语义必须清晰jumps是已跳的次数currentEnd是当前这一跳能覆盖的右边界maxReach是扫描过程中发现的最远可达位置。为什么循环只到n - 2因为到n - 1时已经到达终点不需要再跳。面试表达这道题时按“暴力DFS - 记忆化去重 - 发现贪心规律 - 写出贪心”的顺序讲是最容易让面试官认可的。多数候选人直接甩贪心答案反而显得像背题。你展示出从暴力到最优的演进过程说明你是真的想明白了。7. 字符串算法与KMP很多人的盲区但真正考到差距很大7.1 KMP的next数组到底在做什么字符串匹配是算法题里一个容易忽略的板块。很多人觉得JS里直接indexOf就行不需要手写KMP。但KMP考察的核心是“如何利用已匹配信息避免重复匹配”这个思想在其他场景也有用。KMP和暴力匹配的区别看一个例子就明白。主串ababcabcacbab模式串abcac。暴力匹配在某一轮匹配到第3个字符时失败了它会把模式串整体右移一位重新匹配KMP则知道主串中已经匹配过的部分中有一部分和模式串的前缀是重合的直接把模式串滑动到合适位置避免从头再比。这个“合适位置”由next数组决定。next[i]的含义是模式串[0, i]子串中最长的相同前缀后缀的长度。比如abcab前缀有ab后缀有ab所以next[4] 2。7.2 完整实现构建next和匹配主流程function buildNext(pattern) { const next new Array(pattern.length).fill(0); let j 0; // 当前最长相等前后缀长度 for (let i 1; i pattern.length; i) { while (j 0 pattern[i] ! pattern[j]) { j next[j - 1]; } if (pattern[i] pattern[j]) { j; } next[i] j; } return next; } function kmpSearch(text, pattern) { if (pattern.length 0) return 0; const next buildNext(pattern); let j 0; for (let i 0; i text.length; i) { while (j 0 text[i] ! pattern[j]) { j next[j - 1]; } if (text[i] pattern[j]) { j; } if (j pattern.length) { return i - pattern.length 1; } } return -1; }buildNext和kmpSearch的结构非常相似都是“不匹配时回退j到next[j-1]匹配时j加1”。这个相似性不是巧合——构建next数组本身就是在用模式串匹配它自身。面试时如果被考到KMP我不建议一上来就写代码。先花三十秒讲思路“我利用最长公共前后缀来避免主串指针回退模式串回退到前缀之后的位置整体时间复杂度O(nm)。”面试官听你先把原理说清楚再看你的代码容错率会高很多。一个常见的面试追问是“KMP的空间复杂度是多少”答案是O(m)m是模式串长度。和暴力匹配O(1)空间相比KMP是用空间换时间。这点要能解释清楚。8. 备考策略与踩坑复盘我刷题和面试过程中总结的经验8.1 学习顺序与优先级按频率和性价比排布手撕题和算法如果都从零开始准备很容易陷入“什么都想学、什么都没学透”的状态。我推荐按以下顺序准备第一优先级防抖节流、深拷贝、call/apply/bind、new、Promise。这五个是前端面试手撕题里的“基本盘”几乎必考且和日常开发关联紧密。第二优先级快排、二分及其变体、双指针、滑动窗口。这些算法覆盖了大多数中等难度题目性价比非常高。第三优先级动态规划背包类、路径类、贪心跳跃游戏类、回溯全排列、组合。这些是拉开差距的部分建议至少能写“跳跃游戏2”和“全排列”这类经典题。第四优先级KMP、堆排序、并查集、LRU等冷门但偶尔出现的题。有余力再准备。刷题量不需要追求很多。我的个人建议是手撕题准备15到20个高频实现算法题以LeetCode热题100为基准至少把“数组、字符串、链表、树、DP”五大类里各刷10道经典题。这样应付大多数前端岗位的面试足够。8.2 面试失败原因复盘我见过和踩过的坑我在实际面试别人和被别人面试的过程中总结出几个高频失败点写出来供你避坑。第一写代码前不开口讲思路。面试官面前你是一个黑盒他只能通过你的表达判断你的思考质量。哪怕思路不完美先说“我准备用双指针快指针负责探索慢指针负责记录结果”都比闷头写强很多。第二不写测试用例验证。代码写完后主动说“我用一个简单用例验证一下”是很加分的。比如写完快排在注释里写下[3, 1, 2]的排序过程写完二分写下[1, 2, 3, 4]查找2走一遍。这个习惯能帮你发现60%以上的低级错误。第三把记忆和原理混淆。面试最怕的是“代码能默写但被追问就慌”。比如防抖的this为什么这样绑定、Promise为什么用微任务、快排最坏情况是什么。这些问题比代码本身更能暴露真实水平。我的建议是每准备一道手撕题额外准备三个“为什么”。第四不会说“不会”。真实面试里你总会遇到没见过的题。正确策略是先尝试拆解把问题往你熟悉的模型上归并。比如没写过flat你可以说“我可以用递归处理任意层数的扁平化”没写过Promise.all你可以说“我知道它需要等待所有Promise都落定我可以先实现一个基础版”。即使当场写不出来你的思路也是有效沟通。8.3 建立自己的手撕题库比东拼西凑复习更有效最后分享一个我自己的复习方法准备一个文档按章节分类整理手写代码和易错点每条只写三行——题目、核心思路、一个坑点。比如防抖闭包保存timer每次触发清空重新计时。坑点this绑定和immediate参数。深拷贝递归复制WeakMap解决循环引用。坑点Reflect.ownKeys遍历Symbol键。KMPnext数组保存最长前后缀长度。坑点i从1开始、while回退j。面试前翻一遍这个文档比临时刷十道题更管用。我自己的体会是手撕题考的从来不是“背了多少”而是“理解了之后能不能清晰表达”。把那层原理想透代码写起来就顺了。
返回列表