写于2025年8月2日,目前还在缓慢更新......
这篇文章主要是记录了自己在实习面试当中被问到但没能很好回答出来的一些知识点,因此并不包含比较全面的前端基础知识。对于前端开发必会知识点的总结,建议阅读 @茶无味的一天 老师的文章。
需要注意,上述文章中也有一些不准确的知识,我也会在这篇文章中进行纠正。有误之处如下:
- 深拷贝与浅拷贝:错误的浅拷贝定义
- 事件循环:过于简化的解释
当然,本人也并没有多么高深的前端知识水平,本文的大部分内容都是通过网络查阅,或多或少存在个人未能发现的问题。因此,如有任何问题,欢迎随时指正!
JavaScript
数据类型
-
基本类型:
Undefined
、Null
、Boolean
、Number
、String
、BigInt
、Symbol
-
引用类型:统称为
Object
,常见的有Object
、Array
、Function
,其他包括Date
、Map
等
为何需要两种类型?它们之间的区别?
从内存分配上讲,基本类型数据保存在在栈内存 中,固定占用空间,由系统自动分配和释放。引用类型数据保存在堆内存中,动态分配,大小不固定,由垃圾回收机制(GC)管理。
引用类型数据的变量在栈中存储的是堆内存的地址,实际对象存储在堆中,例如:
txt栈内存(Stack) 堆内存(Heap) ┌────────────────┐ ┌────────────────┐ | obj: (address) | --> | {name: "John"} | └────────────────┘ └────────────────┘
设计两种类型的考虑在于数据的性质,有的数据复杂、有的数据简单,有的数据变动大、有的数据变动小......考虑到这些差异,可以刚好对应这两种内存分配方式:
- 存储在栈内存中的数据占用固定空间,访问速度快但容量有限,适合存储较为简单的数据。
- 存储在堆内存的数据由于能够动态分配,因此具有较强的可变性 和可扩展性,适合存储较为复杂的数据。
如果进一步深入探讨,那么就涉及到了计算机系统的概念,建议阅读《深入理解计算机系统》,也就是俗称的CSAPP。
相等比较和相同
JavaScript提供三种不同的比较运算:===
、==
和Object.is
三种比较运算的主要区别在于:
==
在比较时会首先进行隐式类型转换,再对值进行比较,并对NaN
、-0
和+0
进行特殊处理(因此NaN != NaN
,且-0 == +0
)。===
的比较与==
基本相同,但不进行类型转换,因此当类型不同时会返回false
。Object.is
与===
基本相同,但不对NaN
、-0
和+0
进行特殊处理(因此Object.is(NaN, NaN) === true
,且Object.is(+0, -0) === false
)。
引用类型数据的比较
前面提到,引用类型数据在栈内存中存储的是指向堆内存数据的地址,因此它的比较方式是"引用比较",也就是比较两个对象指向的是否是同一个地址。由于这一特性,上述三种比较运算的差异实际上只存在于基本类型之中,对于引用类型而言,
===
、==
和Object.is
没有区别。
上文提到,==
是宽松比较,会首先进行隐式类型转换再进行值的比较,那么类型转换有何规则呢?
null
和undefined
:两者宽松相等,且不与其他任何值相等。String
和Number
:转换为Number
后进行比较,其中""
转换为0
,非数字字符串转换为NaN
。Boolean
和其他类型:转换为Number
后进行比较,true
转换为1
,false
转换为0
。- 对象与基本类型:尝试依次调用对象的
valueOf()
和toString()
方法转换为基本类型,前者例如new Number(42) == 42
,后者例如{} == "[object Object]"
、[1,2] == "1,2"
等。
Number的结构与精度
JavaScript的Number
是双精度浮点数(64位),遵循IEEE 754标准。简单来说,Number
的存储结构分为三部分:
- 符号位(1位):
0
表示正数,1
表示负数。 - 指数位(11位):存储科学计数法的指数部分(偏移
1023
,存储值等于实际指数加1023
) - 尾数位(52位):存储小数部分(隐含
1.
开头)
内存布局:[ Sign (1) | Exponent (11) | Mantissa (52) ]
实际数值: <math xmlns="http://www.w3.org/1998/Math/MathML"> ( − 1 ) S i g n × ( 1. M a n t i s s a ) × 2 E x p o n e n t − 1023 (−1)^{Sign}×(1.Mantissa)×2^{Exponent−1023} </math>(−1)Sign×(1.Mantissa)×2Exponent−1023
例如,5.25
的二进制是101.01
,可以表示为 <math xmlns="http://www.w3.org/1998/Math/MathML"> 1.0101 × 2 2 1.0101×2^2 </math>1.0101×22(2进制的科学计数法),由于隐含1.
,因此尾数位实际存储的是0101
,又由于指数偏移1023
,因此指数位实际存储的是1025
。
非正规数、零与其他
上述所介绍的,基本是IEEE 754标准中双精度浮点数的"正规数",在该标准中还存在"非正规数"。非正规数用于表示极接近于零的数值,它的存储结构与正规数类似,但指数位全为
0
,但指数固定为-1022
,尾数位隐含0.
而非1.
因此它的实际数值为: <math xmlns="http://www.w3.org/1998/Math/MathML"> ( − 1 ) S i g n × ( 0. M a n t i s s a ) × 2 − 1022 (−1)^{Sign}×(0.Mantissa)×2^{−1022} </math>(−1)Sign×(0.Mantissa)×2−1022
在正规数中,指数位不能全为
0
或全为1
,因此实际指数范围为-1022
到+1023
。
- 指数位全为
0
的情况一种是非正规数,另一种则是用于表示±0
。正规数隐含1.
,显然不能表达0
,因此IEEE 754标准规定,指数和尾数全为0
表示0
(正负取决于符号位)。- 指数位全为
1
用于表示Infinity
和NaN
,前者的尾数全为0
,后者尾数非0
。
想要在计算机中表示小数,必须用二进制的小数表示方式,也就是只能精确表示 <math xmlns="http://www.w3.org/1998/Math/MathML"> 2 − n 2^{-n} </math>2−n的小数。因此,有些十进制小数无法精确转换为有限位二进制。
例如,0.1
的二进制近似是0.00011001100110011001100110...
,可以表示为 <math xmlns="http://www.w3.org/1998/Math/MathML"> 1.10011... × 2 − 4 1.10011...×2^{-4} </math>1.10011...×2−4,因此尾数位为10011...
(直到52位截断),指数位为1019
。
一个经典问题0.1 + 0.2 !== 0.3
也是因此而来。但是,在某种巧合下,二进制表示的小数之和能够抵消误差,使其运算结果精确。例如,0.2 + 0.3 === 0.5
。
最大安全整数
在JavaScript中,并不区分整数和小数,统一使用双精度浮点数的表示方法,例如
5
的二进制为101
,可以表示为 <math xmlns="http://www.w3.org/1998/Math/MathML"> 1.01 × 2 2 1.01×2^2 </math>1.01×22。因此,整数的精度也取决于指数和尾数的可表示范围,而且指数等于尾数的位数(如上例中尾数为2位的01
,指数为2
),指数能够最大表示+1023
,尾数只有52位,因此整数的表示范围取决于尾数。由于尾数只有52位,加上隐含的
1.
,能够表示的最大数值就是53位全1的情况,即为 <math xmlns="http://www.w3.org/1998/Math/MathML"> 2 53 − 1 2^{53}-1 </math>253−1(9007199254740991
),也就是最大安全整数(Number.MAX_SAFE_INTEGER
)。
深拷贝与浅拷贝
- 深拷贝:创建一个新的对象,完全复制对象及其嵌套的所有引用类型属性,新旧对象完全独立。
- 浅拷贝:创建一个新的对象,只复制对象的第一层属性,如果属性是基本类型,则复制的是值,如果属性是引用类型,则复制的是引用(内存地址)。
浅拷贝与引用拷贝
所谓引用拷贝,就是拷贝对象和原对象完全指向同一个引用,两者之中任意一方的修改都会影响到另一方。浅拷贝则是只有引用类型的属性才会指向同一个引用,而基本类型的属性是独立复制。
假设一个具有Number类型属性a和Object属性obj的对象,那么其浅拷贝的示意图如下:
txt原对象 内存 拷贝对象 ┌────────────────┐ ┌──────────────────────┐ ┌────────────────┐ | a ----------> 45 | | | | obj ----------> {name: "John"} <---------- obj | | | | 45 <---------- a | └────────────────┘ └──────────────────────┘ └────────────────┘
(图中将两个对象从内存中分离开,且简化了内存的结构,仅用于理解浅拷贝)
深拷贝与浅拷贝的实现:
- 深拷贝:
JSON.parse(JSON.stringify(obj))
(不适用于Undefined
、Symbol
、BigInt
和Function
类型的属性复制,且无法处理循环引用) - 浅拷贝:
Object.assign
、Array.prototype.slice
、Array.prototype.concat
、{...obj}/[...arr]
手写函数的实现:
javascript
// 深拷贝
function deepCopy(target, map = new WeakMap()) {
// 基本类型和null/undefined处理
if (typeof target !== "object" || target === null) 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);
if (target instanceof Set) {
const copy = new Set();
map.set(target, copy);
target.forEach(v => copy.add(deepCopy(v, map)));
return copy;
}
if (target instanceof Map) {
const copy = new Map();
map.set(target, copy);
target.forEach((v, k) => copy.set(k, deepCopy(v, map)));
return copy;
}
// 创建新对象
const copy = new target.constructor();
map.set(target, copy);
// 复制常规属性
for (const key in target) {
if (target.hasOwnProperty(key)) {
copy[key] = deepCopy(target[key], map);
}
}
// 复制Symbol属性
const symbolKeys = Object.getOwnPropertySymbols(target);
for (const symbolKey of symbolKeys) {
copy[symbolKey] = deepCopy(target[symbolKey], map);
}
return copy;
}
使用WeakMap处理循环引用
循环引用的处理方式就是记录已拷贝的对象,出现循环引用时不再深度递归,而是直接取出已存储过的对象。仅仅如此的话,
Map
也可以做到,但WeakMap
的优势在于弱引用,不会阻止垃圾回收。由于深拷贝过程中的对象映射是临时需求,拷贝完成后就不需要保留这些关系,这正好符合WeakMap
的特性,能够很好地防止内存泄漏的问题。
javascript
// 浅拷贝
function shallowCopy(target) {
if (typeof target !== "object" || target === null) {
return target;
}
// 数组或对象
const copy = Array.isArray(target) ? [] : {};
for (let key in target) {
// 避免复制原型链上的属性
if (target.hasOwnProperty(key)) {
copy[key] = target[key];
}
}
return copy;
}
引用拷贝的实现
在JavaScript中,对象的引用拷贝直接使用赋值等号就能实现,对于
const b = a
,a === b
的结果是true
。不过,基本类型的数据都是按值复制,难以实现引用拷贝。
拷贝的比较如之前所说,深拷贝和浅拷贝都会创建一个新的对象,而对象的比较是引用比较,因此比较总是返回
false
。引用拷贝则是引用同一内存地址,因此比较总之返回true
。
闭包
闭包(Closure)是一个被捆绑 的函数和声明该函数时的词法环境引用的组合。所谓捆绑可以理解为该函数是在另一个函数内部声明的,词法环境可以理解为声明该函数时的作用域。
闭包能够实现使用函数访问外部无法直接访问的变量,举个例子:
javascript
function getSayHello() {
var message = "hello, world!"; // 或let、const
function sayHello() {
// sayHello()是内层函数,创建了一个闭包
// sayHello()内部使用了在外层函数中声明的变量
console.log(message);
}
return sayHello;
}
const sayHello = getSayHello();
sayHello(); // 输出"hello, world!"
可以看到,通过在函数内声明函数,我们就创建了闭包,如果想在外层函数之外调用,只需要返回内层函数。调用sayHello()
时,尽管message
声明在sayHello()
之外,且getSayHello()
已经执行完毕,但sayHello()
将其引用到的message
存储在内存当中,因此能够正确输出。
闭包常用于创建私有变量、实现模块模式、函数工厂等,其主要缺陷在于闭包会保持对外部变量的引用,可能导致内存无法被回收,造成内存泄漏。
内存泄漏
内存泄漏指已动态分配的内存由于某种原因未能被正确释放或回收,导致该内存无法再被程序使用,造成系统内存的浪费。在JavaScript中,除了闭包外,可能导致内存泄漏的其他情况如下:
- 意外的全局变量
javascriptfunction leak() { leakedVar = "这是一个全局变量"; // 未声明的变量会自动成为全局变量 this.anotherLeak = "又一个全局变量"; // 非严格模式下this指向window }
- 被遗忘的定时器或回调函数
javascript// 定时器泄漏 const intervalId = setInterval(() => { // 不会被自动销毁,即使不再需要,定时器仍会保持运行 // 需要使用clearInterval、clearTimeout等方法清除定时器 }, 1000); // 事件监听器泄漏 element.addEventListener('click', onClick); // 不再需要后应该调用removeEventListener清除事件监听器
- 未清理的
Map
/Set
集合
javascriptconst objects = new Set(); // 或Map let obj = {}; objects.add(obj); // 即使不再需要obj,它仍然被Set保留 obj = null; // 对象仍在Set中,无法被GC // 解决方法:使用WeakMap/WeakSet替代,或手动删除Map或Set中的obj引用(delete方法)
词法环境
词法环境是JavaScript引擎内部用来管理变量和函数作用域的机制,它在每个函数被调用时所创建。词法环境包括:存储变量和函数声明的环境记录、指向父级词法环境的
outer
引用。
javascriptLexicalEnvironment = { EnvironmentRecord: { // 存储变量和函数声明 }, outer: <指向父级词法环境的引用> }
在上文的例子中,当调用
getSayHello()
时,创建了一个词法环境:
javascriptgetSayHelloLexEnv = { EnvironmentRecord: { message: "hello, world!", sayHello: function }, outer: globalLexEnv // 全局词法环境 }
当创建
sayHello()
时,其内部的[[Environmnet]]
属性会引用当前的词法环境(即getSayHelloLexEnv
)。当sayHello()
被调用时,会创建一个新的词法环境:
javascriptsayHelloLexEnv = { EnvironmentRecord: { // 没有内部变量或函数声明 }, outer: getSayHelloLexEnv // 指向定义时的环境 }
当
sayHello()
需要访问message
时,就会沿着这样一条作用域链去查找,类似于下文提到的原型链。关于词法环境的完整解释,可以看 @烟西 老师的这篇文章:
关键字之this
这小标题这么写是因为光写
this
它会默认首字母大写......
在面向对象语言中,this
表示当前对象的一个引用。但在JavaScript中,this
会随着所谓执行环境的改变而改变。下面这篇出自阮一峰老师之手的文章很详细地解释了原因。
了解原理之外,我们也可以记一些常见的this
指向:
- 对象方法内:表示该方法所属的对象。
- 单独使用 :表示全局对象,通常是
window
([object Window]
)。 - 函数内 :表示全局对象,但严格模式下为
undefined
。 - 事件内:表示接收事件的元素。
有时候,我们想将this
引用到其他对象上,通常采用Function.prototype
下的call
、apply
和bind
三种方法。
call
:以给定的this
值和逐个提供的参数调用该函数。apply
:以给定的this
值和作为数组提供的参数调用该函数。bind
:创建一个新函数,它会将this
绑定到指定对象并调用原始函数,可以预先设置一些参数。
其中,call
和apply
都是立刻调用函数,bind
则是返回创建的新函数;三者都可以传入参数,但call
和bind
都是逐个提供的参数,而apply
传入一个参数数组。
用call还是apply?
上述的
call
和apply
方法看起来除了参数的传递方式不同,没有什么区别,实际情况又如何呢?实际情况还真是没有本质区别,就是只有参数的传递方式不同,所以在你的使用环境下,call
和apply
根据方便性的原则进行选择就好。
根据上面的介绍,当你想长期使用一个改变了this
指向的函数时,应该用bind
。
javascript
const module = {
x: 42,
getX: function () {
return this.x;
},
};
const unboundGetX = module.getX;
console.log(unboundGetX()); // undefined
const boundGetX = unboundGetX.bind(module);
console.log(boundGetX()); // 42
此外,bind
还能够通过第二个及往后的参数预设函数的部分或全部参数,并且bind
只能设置this
值一次,后续的bind
调用无法改变已经绑定的this
值。
javascript
"use strict"; // 防止 this 被封装到到包装对象中,否则输出类似于:String {"this value"}
function log(...args) {
console.log(this, ...args);
}
// 第一次绑定:设置this为"this value",并预设前两个参数为1和2
const boundLog = log.bind("this value", 1, 2);
// 第二次绑定:尝试修改this和预设更多参数,此时预设参数为1, 2, 3, 4
const boundLog2 = boundLog.bind("new this value", 3, 4);
// 调用最终绑定的函数
boundLog(3, 4);
// 输出: "this value", 1, 2, 3, 4
boundLog2(5, 6);
// 输出: "this value", 1, 2, 3, 4, 5, 6
// this值未改变
原型与原型链
原型(prototype
)和原型链(prototype chain
)是JavaScript中实现对象继承机制的重要概念。这两个概念对于初学者而言比较复杂,直接从原型与原型链的定义出发其实不是一个好的选择,我认为 @月光也会跟着我 老师写的下面这篇文章非常通俗易懂,建议大家阅读。
看完上述文章,我们就知道:
prototype
的主要作用是为所有构造的对象提供共享方法和属性。__proto__
指向构造函数的prototype
,用于查找共享的方法和属性。
共享的prototype
上文说到,
prototype
用于为所有构造对象提供共享方法和属性,可以简单理解为一个"标准"。想象一个玻璃杯制造工厂,每个产品都应该按照某一个标准的原型去制造,每个产品都需要拥有原型产品所具有的弧度、高度、直径等参数(属性),也必须拥有原型产品所具有的盛水、握持等功能(方法)。需要注意的是,这个原型仅有一个,是共享的,当原型玻璃杯发生了改变,每个产品都应该跟着变。不过计算机层面的prototype
有所不同,一旦改变,所有指向该原型的实例所具有的共享方法也会立刻改变,无论已经创建还是后续创建。
事件循环
在JavaScript中,事件循环(Event Loop)负责执行代码、收集和处理事件以及执行队列中的子任务(MDN的表述)。
事件循环决定了代码的执行顺序,确保非阻塞 (Non-blocking)行为。由于 JavaScript 是单线程的,如果没有事件循环,长时间运行的同步任务会阻塞整个程序,导致页面卡死或无响应。
单线程与异步
不知道大家有没有想过,为什么作为单线程语言的JavaScript可以实现异步?实际上,异步任务本身不在JavaScript的主线程内执行,而是通过其宿主(浏览器环境的Web APIs,或Node.js的C++ APIs)所提供的线程所执行。
在其他线程所执行完成的异步任务显然需要某种方式将结果返回回来,这种方式就是回调函数(Callback)。异步任务通过宿主线程后,会返回附带结果的回调函数,此时JavaScript才能通过在主线程执行回调函数的方式,来实现异步任务请求完成后的后续操作。
正是因为有些操作执行快,而有些操作十分耗时,因此才需要分出同步任务和异步任务。
同步任务与异步任务
同步任务:在主线程按代码顺序执行,适用于即时计算(如数学运算)
异步任务:进入任务队列,不阻塞主线程,适用于耗时操作(如网络请求、文件读取)。
JavaScript中的任务分为宏任务和微任务,常见例子如下:
- 宏任务:
setTimeout
、setInterval
、I/O操作
- 微任务:
Promise.then (catch/finally)
、MutationObserver
、queueMicrotask
为何要区分宏任务与微任务?
本质原因在于任务之间亦有优先之分,微任务是一些优先级较高的任务,而宏任务的优先级较低。一般而言,我们希望需要立即响应、与上下文强相关的轻体量任务能够尽快返回结果,因此它们需要更高的优先级,也就是微任务;与此相反,不要求立即响应、相对独立的可能耗时更长的任务不急于返回结果,因此只需要低优先级。
在JavaScript的主线程中,有一个负责执行任务的执行栈 ,而在主线程之外,还存在任务队列 (Task Queue)和微任务队列(Microtask Queue)。事件循环的基本逻辑如下:
- 按顺序遍历每条语句,同步任务在执行栈中立即执行,宏任务和微任务通过宿主提供的API开始异步任务,完成后其回调函数会被分别放入任务队列(宏任务)和微任务队列(微任务)。
- 将微任务队列中的微任务回调放入执行栈中执行,直到清空微任务队列。
- 取出并执行宏任务队列中的首个宏任务回调,并放入执行栈中执行,完成后回到2。
这里的关键之处在于,宏任务队列并不是一次性清空,而是每次执行一个,然后重新清理微任务队列,这也是事件循环的循环之所在。每个任务在执行栈中执行时与步骤1相同,会立刻执行同步任务,而宏任务和微任务进入相应的队列,因此需要不断循环直到所有任务完成。所以我们也可以将步骤1理解为一个较大的宏任务,但这么表述容易混淆,因此不在此赘述。
事件循环与setTimeout
了解事件循环后,我们知道
setTimeout
这种宏任务首先是被"丢进" Web API环境(或Node.js的C++ API)中完成计时,计时结束后再被放入任务队列。因此有两个问题:
- 第一点是计时器的精确性问题,即使
setTimeout
能够在Web API中精确计时,但放入任务队列中不一定会直接进入执行栈中执行,可能还有其他微任务或宏任务优先执行,因此计时器的回调不能够精确地如期执行- 第二点是
setTimeout
的顺序问题,假如有两个setTimeout
,在前面的计时2000
(ms),在后面的计时100
(ms),那么应该是后者先放入任务队列,前者再放入任务队列。这虽然从结果上很符合直觉,但能够更好地让我们理解任务队列中放入的是回调函数,而非setTimeout
本身。因此,在相关题目中若出现两个setTimeout
,不能直接认为前者先放入任务队列,一定要注意设置的时间。
容易被误会的语句上文讲到
Promise.then (catch/finally)
是微任务,但单独写Promise.then
只是注册了Promise
对象的回调函数,只有当对象resolve
或reject
时,才会将回调放入微任务队列中。另一个容易被误会的点在于,创建
Promise
对象时,其中的executor
可以看作立即执行的同步任务。在下面这段代码中,就考察了上述两点。
javascriptconsole.log("start"); const promise1 = new Promise((resolve, reject) => { console.log("promise1"); setTimeout(() => { resolve("foo"); }, 0); }); promise1.then((res) => console.log(res)); console.log("end");
最终输出顺序为:
start
->promise1
->end
->foo
首先输出
start
,进入promise1
的executor
输出promise1
,setTimeout
经过Web API放入任务队列(因为时间为0
,因此相当于直接放入任务队列),然后promise1.then
注册回调函数(但不加入队列,因为仍在pending
),最后输出end
。到这里同步任务结束,执行栈为空,由于没有微任务,因此执行宏任务,也就是setTimeout
的回调,resolve
后promise1.then
的回调放入微任务队列,刚好第一个宏任务结束,开始清空微任务队列,输出foo
。
算法与编程
算法题只能长期刷LeetCode,可以先从简单题刷起,了解一些常见的算法和解题方法,也可以看CodeTop总结的面试题目。
本人的算法水平不是很好,这里主要记录一下自己遇到的一些题目。
广度优先搜索
- 层序遍历(相似题:N叉树的层序遍历)
给定一个tree
,返回其层序遍历的结果。对象tree
的示例结构和示例输出如下:
javascript
const tree = {
value: 1,
children: [
{ value: 2 },
{
value: 3,
children: [
{ value: 4 },
{ value: 5 },
],
}
]
}
// 输出:[[1], [2, 3], [4, 5]]
这题使用广度优先搜索(BFS)解决,没有太大难度,只需注意每次只遍历一层,所以需要记录queue
的长度。
javascript
function levelOrder(tree) {
if (!tree) return [];
const ans = [];
const queue = [tree];
while (queue.length) {
const cnt = queue.length; // 记录下一层的节点数
const level = [];
for (let i = 0; i < cnt; ++i) {
const currentNode = queue.shift(); // 出队
level.push(currentNode.value);
for (const child of currentNode.children) {
queue.push(child);
}
}
ans.push(level);
}
return ans;
};
深度优先搜索
给定一个正整数n
表示提供的括号对数,返回所有可能的且有效的括号组合。
示例:
txt
输入: n = 3
输出: ["((()))","(()())","(())()","()(())","()()()"]
这一题的逻辑并不复杂,一条路走到底,回溯再探新路。实际上就是"回溯法",本质上用的是深度优先搜索(DFS)。像这种可以一步一步尝试的题目,都可以用这种方法。
javascript
function generateParenthesis(n) {
let ans = [];
// left和right分别表示已经使用的左、右括号的数量
const backtrack = (stack, left, right) => {
if (stack.length == 2 * n) {
// 用尽所有左右括号
ans.push(stack.join(""));
return;
}
if (left < n) {
// 左括号未用完,由于先放左括号永远都有合法的情况,因此尝试这条路
stack.push("(");
backtrack(stack, left + 1, right);
stack.pop(); // 回溯(清除状态)
}
if (right < left) {
// 右括号用的比左括号少,说明有未闭合的左括号,放上右括号进行闭合显然合法,因此尝试这条路
stack.push(")");
backtrack(stack, left, right + 1);
stack.pop(); // 回溯
}
}
backtrack([], 0, 0); // 初始状态,还未用括号
return ans;
};
双指针
- 合并两个有序数组(相似题:合并两个有序数组)
给定两个按非递减顺序排列的整数数组a
和b
,返回a
和b
合并后的数组,该数组同样按非递减顺序排列。
这一题可以用简单的双指针方法来做,两个指针分别从a
和b
的第一个元素出发,依次比较两个数字的大小,将较小的数放入合并后的数组中。
javascript
function merge(a, b) {
const res = [];
let pa = 0, pb = 0;
while (pa < a.length || pb < b.length) {
if (pa === a.length) {
res.push(b[pb++]);
}
else if (pb === b.length) {
res.push(a[pa++]);
}
else if (a[pa] < b[pb]) {
res.push(a[pa++]);
}
else {
res.push(b[pb++]);
}
}
return res;
}
在上述提到的相似题中,还提供了逆向双指针的空间优化算法。简单来说,这一方法从末尾开始比较并放入较大的数,规避了数据覆盖的问题,因此可以直接在a
当中进行合并。这是一种就地算法,因此空间复杂度为 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( 1 ) O(1) </math>O(1)。
动态规划
给定一个正整数n
表示台阶的数量,每次能爬1
个或2
个台阶,计算抵达第n
阶的方法数量。
显然,爬n
阶的方法数量是n-1
阶和n-2
阶的总和,到这里我们就能够想到递归,但一般会考虑能否用通常性能更好的动态规划(DP)方法。由于每次计算只需要n-1
和n-2
的结果,因此可以使用滚动数组的空间优化方式。
javascript
function climbStairs(n) {
// curr = 1相当于是n = 0的情况(可以理解为不动,只有一种方法)
let lastTwo = 0, lastOne = 0, curr = 1;
for (let i = 1; i <= n; i++) {
// 向前滚动
lastTwo = lastOne;
lastOne = curr;
curr = lastTwo + lastOne;
}
return curr;
}
这题在LeetCode上还有两种数学解法,但和一般需要掌握的算法无关,因此不再赘述。
以上就是本文的主要内容,感谢阅读!如前所述,本文多少存在一些问题,因此随时欢迎指正!