前言
大家好,我是程序员蒿里行。力扣有断断续续刷过100多题,但并未形成方法论和体系,尝试了很多方式始终效果不佳。可算法是求职面试中大厂最具性价比的事了,遂打算重新拾起来,目标达到cover中级水平。 算法面试考察的内容非常稳定,是真正的"一次学习、终生受用",它值得你投入时间。
前端工程师如果不是为了面试,那么不建议花大力气折腾算法(尤其是在业余时间本身非常有限的情况下),你应该考虑把更多的时间用来做工程。 -- 修言
下面就来回顾一下前端刷题必备知识
数据结构
事实上,在 JavaScript 数据结构中,数组几乎是"基石"一般的存在。先来了解一下数组常规操作
数组访问
javascript
arr[0] // 访问索引下标为0的元素
数组遍历
javascript
// for循环
const len = arr.length
for(let i=0;i<len;i++) {
// 输出数组的元素值,输出当前索引
console.log(arr[i], i)
}
// forEach循环
arr.forEach((item, index)=> {
// 输出数组的元素值,输出当前索引
console.log(item, index)
})
// map方法
const newArr = arr.map((item, index)=> {
// 输出数组的元素值,输出当前索引
console.log(item, index)
// 在当前元素值的基础上加1
return item+1
})
推荐如果没有特殊的需要,那么统一使用 for 循环来实现遍历。因为从性能上看,for 循环遍历起来是最快的。 矩阵就是二维数组。下面是二维数组的初始化和访问方式。
二维数组的初始化
javascript
const len = arr.length
for(let i=0;i<len;i++) {
// 将数组的每一个坑位初始化为数组
arr[i] = []
}
二维数组的访问
javascript
// 缓存外部数组的长度
const outerLen = arr.length
for(let i=0;i<outerLen;i++) {
// 缓存内部数组的长度
const innerLen = arr[i].length
for(let j=0;j<innerLen;j++) {
// 输出数组的值,输出数组的索引
console.log(arr[i][j],i,j)
}
}
栈和队列
栈(Stack)------只用 pop 和 push 完成增删的"数组"
栈是一种后进先出(LIFO,Last In First Out)的数据结构。
- 只允许从尾部添加元素
- 只允许从头部取出元素
javascript
const stack = [1,2,3]
stack.pop() // 出栈 此时stack为 [2,3]
start.push(4) // 入栈 此时stack为 [2,3,4]
队列(Queue)------只用 push 和 shift 完成增删的"数组"
队列是一种先进先出(FIFO,First In First Out)的数据结构。
- 只允许从尾部添加元素
- 只允许从头部移除元素
javascript
const queue = [1,2,3]
stack.push(4) // 入队列 此时queue为 [1,2,3,4]
start.shift(4) // 出队列 此时queue为 [1,2,3]
链表
链表和数组相似,它们都是有序的列表、都是线性结构(有且仅有一个前驱、有且仅有一个后继)。不同点在于,链表中,数据单位的名称叫做"结点",而结点和结点的分布,在内存中可以是离散 的。 在链表中,每一个结点的结构都包括了两部分的内容:数据域和指针域。 数据域存储的是当前结点所存储的数据值,而指针域则代表下一个结点(后继结点)的引用。
链表节点创建
javascript
// 创建链表结点,咱们需要一个构造函数:
function ListNode(val) {
this.val = val;
this.next = null;
}
//在使用构造函数创建结点时,传入 val (数据域对应的值内容)、指定 next (下一个链表结点)即可:
const node = new ListNode(1)
node.next = new ListNode(2)
链表节点添加
要想完成这个动作,我们需要变更的是前驱结点 和目标结点 的 next 指针指向,过程如下图: 插入前: 插入后:
javascript
// 如果目标结点本来不存在,那么记得手动创建
const node3 = new ListNode(3)
// 把node3的 next 指针指向 node2(即 node1.next)
node3.next = node1.next
// 把node1的 next 指针指向 node3
node1.next = node3
链表节点删除
javascript
node1.next = node3.next
// node3节点会被垃圾回收机制清除
在涉及链表删除操作的题目中,重点不是定位目标结点,而是定位目标结点的前驱结点。做题时,完全可以只使用一个指针(引用),这个指针用来定位目标结点的前驱结点。比如说咱们这个题里,其实只要能拿到 node1 就行了:
javascript
// 利用 node1 可以定位到 node3
const target = node1.next
node1.next = target.next
链表和数组的辨析
JS数组比较特别。如果我们在一个数组中只定义了一种类型的元素,比如:
javascript
const arr = [1,2,3,4]
它是一个纯数字数组,那么对应的确实是连续内存。 但如果我们定义了不同类型的元素:
javascript
const arr = ['haha', 1, {a:1}]
它对应的就是一段非连续的内存。此时,JS 数组不再具有数组的特征,其底层使用哈希映射分配内存空间,是由对象链表来实现的。 说起来有点绕口,但大家谨记"JS 数组未必是真正的数组 "即可。 何谓"真正的数组"?在各大教材(包括百科词条)对数组的定义中,都有一个"存储在连续的内存空间里"这样的必要条件。因此在本文中,我们描述的"数组"就是符合这个定义的数组。
链表的插入/删除效率较高O(1),而访问效率较低O(n);数组的访问效率较高O(1),而插入效率较低O(n)。
二叉树
理解树结构
上面的树反过来就是计算机中的树结构。 结合这张图,我们来讲解树的关键特性和重点概念。
- 树的层次计算规则:根结点所在的那一层记为第一层,其子结点所在的就是第二层,以此类推。
- 结点和树的"高度"计算规则:叶子结点高度记为1,每向上一层高度就加1,逐层向上累加至目标结点时,所得到的的值就是目标结点的高度。树中结点的最大高度,称为"树的高度"。
- "度"的概念:一个结点开叉出去多少个子树,被记为结点的"度"。比如我们上图中,根结点的"度"就是3。
- "叶子结点":叶子结点就是度为0的结点。在上图中,最后一层的结点的度全部为0,所以这一层的结点都是叶子结点。
理解二叉树结构
二叉树是指满足以下要求的树:
- 它可以没有根结点,作为一棵空树存在
- 如果它不是空树,那么必须由根结点、左子树和右子树组成,且左右子树都是二叉树 。如下图: 注意,二叉树不能被简单定义为每个结点的度都是2的树。普通的树并不会区分左子树和右子树,但在二叉树中,左右子树的位置是严格约定、不能交换的。对应到图上来看,也就意味着 B 和 C、D 和 E、F 和 G 是不能互换的。
二叉树的编码实现
在 JS 中,二叉树使用对象来定义。它的结构分为三块:
- 数据域
- 左侧子结点(左子树根结点)的引用
- 右侧子结点(右子树根结点)的引用
在定义二叉树构造函数时,我们需要把左侧子结点和右侧子结点都预置为空:
javascript
// 二叉树结点的构造函数
function TreeNode(val) {
this.val = val;
this.left = this.right = null;
}
当你需要新建一个二叉树结点时,直接调用构造函数、传入数据域的值就行了:
javascript
const node = new TreeNode(1)
如此便能得到一个值为 1 的二叉树结点,从结构上来说,它长这样: 以这个结点为根结点,我们可以通过给 left/right 赋值拓展其子树信息,延展出一棵二叉树。
二叉树先序遍历
javascript
// 所有遍历函数的入参都是树的根结点对象
function preorder(root) {
// 递归边界,root 为空
if(!root) {
return
}
// 输出当前遍历的结点值
console.log('当前遍历的结点值是:', root.val)
// 递归遍历左子树
preorder(root.left)
// 递归遍历右子树
preorder(root.right)
}
二叉树中序遍历
javascript
// 所有遍历函数的入参都是树的根结点对象
function inorder(root) {
// 递归边界,root 为空
if(!root) {
return
}
// 递归遍历左子树
inorder(root.left)
// 输出当前遍历的结点值
console.log('当前遍历的结点值是:', root.val)
// 递归遍历右子树
inorder(root.right)
}
二叉树后序遍历
javascript
function postorder(root) {
// 递归边界,root 为空
if(!root) {
return
}
// 递归遍历左子树
postorder(root.left)
// 递归遍历右子树
postorder(root.right)
// 输出当前遍历的结点值
console.log('当前遍历的结点值是:', root.val)
}
时间复杂度和空间复杂度
时间复杂度
T(n)表示代码执行次数,计算如下代码执行次数
javascript
function traverse(arr) {
var len = arr.length
for(var i=0;i<len;i++) {
console.log(arr[i])
}
}
上述代码执行次数为
scss
T(n) = 1 + n + 1 + (n+1) + n = 3n + 3
再来看看n*n的代码执行次数
javascript
function traverse(arr) {
var outLen = arr.length
for(var i=0;i<outLen;i++) {
var inLen = arr[i].length
for(var j=0;j<inLen;j++) {
console.log(arr[i][j])
}
}
}
继续来做个求总执行次数 T(n) 的加法看看:
javascript
T(n) = 1 + 1 + (n+1) + n + n + n + n*(n+1) + n*n + n*n = 3n^2 + 5n + 3
算法的时间复杂度,它反映的不是算法的逻辑代码到底被执行了多少次,而是随着输入规模的增大,算法对应的执行总次数的一个变化趋势。要想反映趋势,那就简单多了,直接抓主要矛盾就行。我们可以尝试对 T(n) 做如下处理:
- 若 T(n) 是常数,那么无脑简化为1
- 若 T(n) 是多项式,比如 3n^2 + 5n + 3,我们只保留次数最高那一项,并且将其常数系数无脑改为1。
这就叫做【大O表示法】。 常见的时间复杂度量级有:
空间复杂度
空间复杂度是对一个算法在运行过程中临时占用存储空间大小的量度。和时间复杂度相似,它是内存增长的趋势。 常见的空间复杂度有 O(1)、O(n) 和 O(n^2)。 理解空间复杂度,我们照样来看一个🌰:
javascript
function traverse(arr) {
var len = arr.length
for(var i=0;i<len;i++) {
console.log(arr[i])
}
}
在 traverse 中,占用空间的有以下变量:
css
arr
len
i
后面尽管咱们做了很多次循环,但是这些都是时间上的开销。循环体在执行时,并没有开辟新的内存空间。因此,整个 traverse 函数对内存的占用量是恒定的,它对应的空间复杂度就是 O(1)。 下面我们来看另一个🌰,此时我想要初始化一个规模为 n 的数组,并且要求这个数组的每个元素的值与其索引始终是相等关系,我可以这样写:
javascript
function init(n) {
var arr = []
for(var i=0;i<n;i++) {
arr[i] = i
}
return arr
}
在这个 init 中,涉及到的占用内存的变量有以下几个:
css
arr
i
注意这里这个 arr,它并不是一个一成不变的数组。arr最终的大小是由输入的 n 的大小决定的,它会随着 n 的增大而增大、呈一个线性关系。因此这个算法的空间复杂度就是 O(n)。 由此我们不难想象,假如需要初始化的是一个规模为 n*n 的数组,那么它的空间复杂度就是 O(n^2) 啦。