写在前面
正值金三银四,纵使疫情也阻挡不住各位奔(tiao)向(cao)大(zhang)厂(xin)的心。通往大厂道路的第一关,是笔试,而笔试,考的就是算法。
前端工程师,首先是一个软件工程师
数据结构与算法是软件工程师必备的技能之一,对于我们前端工程师而言也是如此。尽管你可能会说我在平时工作中根本用不到什么算法相关的知识,但是掌握算法,实际掌握的是它的逻辑思维能力,这点对于一个工程师而言是至关重要的。
所以,从今天起,我决定重学一下前端比较常用的数据结构与算法知识。
在此插播一条广告:前端算法小结 是我在阅读 修言大佬 的 前端算法与数据结构小册 后的读后感 + 自己一些相关积累结合而成的,强烈建议大家去订阅修言大佬的这本小册,生动形象,简明易懂。
(在线求催更...)
前端开(mian)发(shi)需要掌握的几种数据结构
先罗列一下前端开发所需要的掌握的数据结构:
- 数组
- 栈
- 队列
- 链表
- 树(二叉树)
本文将从前端开发者最熟悉的数组先说起。
数组
数组应该是日常开发中使用频率最高的数据结构之一了,也是大家最熟悉的一种。数组在数据结构中的应用十分广泛,从大家最熟悉的一维数组,到二维数组,再到栈和队列的实现,都需要依赖数组来完成。
创建数组
说到如何创建一个数组,大家脑海里应该已经浮现各种方法了,已创建一个空数组为例,就有下面这么多种:
// 1
const arr = [];
// 2
const arr = new Array()
// 3
const arr = Array.of()
// 4
const arr = Array.from([])
而在算法的场景中,往往需要我们创建一个指定长度的数组,同时为其填充初始值。常用的实现方式如下:
// 创建一个长度为3,元素值均为0的数组
const arr = (new Array(3)).fill(0) // [0, 0, 0]
访问数组
数组的访问复杂度是O(1),因为我们可以直接通过下标来访问到数组中的某个元素。
const arr = (new Array(3)).fill(0) // [0, 0, 0]
// 访问第二个元素
arr[1] // 0
遍历数组
JS中遍历数组的方法有十几种:for循环、forEach、for of、for in、map、filter、reduce、every、some、find 等等等等。
如果没有特殊的需要,那么建议使用for循环来遍历数组。因为从性能上看for循环是最优的。
二维数组
二维数组,顾名思义就是一维数组中的每个元素都是数组。
const arr = [
[0,0,0],
[1,1,1],
[2,2,2]
]
而在算法题中,二维数组又有着其他更为常见的名称:矩阵 or 棋盘。(所以只要看到这两个关键字,直接创建二维数组就完事了)
创建二维数组
那么如何创建一个二维数组?很多人可能会想到用创建一维数组的 fill 方法来实现。
const arr = (new Array(3)).fill([]) // [[], [], []]
看起来没啥问题,但如果想给某一个元素赋值,问题就来了:
// 为二维数组中第一行第一列元素赋值为1
arr[0][0] = 1;
console.log(arr); // [[1], [1], [1]]
从结果上看,我们只为二维数组中一个元素赋值,但是却改变了所有元素的值。这是 fill 的填充机制造成的:当你传给 fill 的参数是一个引用类型(比如上面传的空数组),那么 fill 其实填充的是它的引用。也就是填充后的三个空数组指向的是同一块内存空间,它们其实是同一个数组。
所以对于如何创建二维数组,建议还是通过for 循环创建:
for(let i=0; i<arr.length; i++) {
arr[i] = []
}
访问二维数组
访问二维数组和一维数组一样,都是通过下标访问。要准确访问二维数组中的某个元素,你需要访问它所在的行与所在的列:
arr[0][1]; // 访问二维数组arr中 第一行,第二列的元素
遍历二维数组
遍历一维数组需要一层for循环,那么遍历二维数组自然就是两层for循环了:
let len = arr.length;
for(let i=0; i<len; i++) {
for(let j=0; j<len; j++) {
console.log(arr[i][j]) // 访问第i行,第j个元素
}
}
一道面试题
这里给大家分享一道我亲身经历过的面试题,大致题意如下:
现在有一个8 * 8的棋盘,棋盘格子上如果是车就标记为 1,其他的标记为 0。
如果纵向或者横向上同时存在两个车,那就获胜,否则就是失败。
求输出结果
看到棋盘,转为二维数组就对了。所以可以很轻松的将这道题转化为:一个8*8的二维数组,如果它的某一行或者某一列上同时存在两个1,那就输出true,否则为false。
const func = (arr) => {
let len = arr.length;
// 遍历二维数组
for(let i=0; i<len; i++) {
let num1 = 0; // 记录横向出现1的个数
for(let j=0; j<len; j++) {
let num2 = 0; // 记录纵向出现1的个数
// arr[i][j] 表示横向
if(arr[i][j] === 1) {
num1++;
}
// arr[j][i] 横向坐标反转,表示纵向
if(arr[j][i] === 1) {
num2++;
}
if(num1 > 1) return true
if(num2 > 1) return true
}
}
return false
}
JS中的数组是真正的数组吗?
关于这个问题,首先要知道什么是真正的数组。在各种官方定义中,真正的数组有着一个必要条件:存储在连续的内存空间里。
而在JS中,大部分情况下数组是真正的数组,比如:
const arr = [1, 2, 3]
但是当数组中的元素不止一种类型时,它就不是一个真正的数组:
const arr = ['1', 2, true]
此时的数组对应着一段不连续的内存空间,其底层是使用哈希映射分配内存空间,用链表对象来实现。
不过在实际算法题中,大多数情况下我们用到的都是真正的数组。而正由于数组拥有存储在连续内存空间的特点,我们在数组中任意一个位置添加或删除元素,都需要移动到它后续的元素,因此数组的添加/删除操作对应的复杂度就是O(n)。
几款常见的数组算法题
(部分题目来源leetcode)
两数求和
给定一个整数数组 nums 和一个目标值 target,请你在该数组中找出和为目标值的那 两个 整数,并返回他们的数组下标
示例:
给定 nums = [2, 7, 11, 15], target = 9
因为 nums[0] + nums[1] = 2 + 7 = 9
所以返回 [0, 1]
敲黑板: 几乎所有求和问题,都可以转化为求差问题。
如果你是按照求和的思路来实现,那么很有可能就是通过两层 for循环,分别记录第一层的值为a,第二层的值为b,然后计算 a+b 是否等于 target。
很显然,两层循环的时间复杂度是 O(n²),这绝对不是一个最优解。
转化为求差问题后,这道题解法有很多种,下面我们来一一分析:
法1:利用 Map
思路: 在遍历数组时,利用 Map 来记录遍历过的元素以及它对应的索。;然后再遍历到新元素时,计算它与target的差值diff,再查找 Map 中是否已经存在key值为diff的元素。如果已经存在,则可输出答案
const twoSum = function(nums, target) {
let targetMap = new Map(),
len = nums.length;
for(let i=0; i<len; i++) {
let diff = target - nums[i];
if(targetMap.has(diff)) {
return [i, targetMap.get(diff)]
}
targetMap.set(nums[i], i);
}
return []
};
法2:对撞指针
所谓对撞指针,就是在数组头部与尾部设定两个指针,各自往中间靠拢。双指针对应的数组元素值相加如果大于target,那么需要右指针往左走;反之左指针往右走;等于target时,就是我们要的结果。
但是这个方法有一个很重要的前提,必须是有序数组。
// 假设 nums 有序
const twoSum = function(nums, target) {
let left = 0,
right = nums.length-1;
while(left < right) {
let sum = nums[left] + nums[right];
if(sum === target) {
return [left, right]
}
if(sum > target) {
right--;
}
if(sum < target) {
left++
}
}
return []
};
三数求和
给你一个包含 n 个整数的数组 nums,判断 nums 中是否存在三个元素 a,b,c ,使得 a + b + c = 0 ?请你找出所有满足条件且不重复的三元组。
示例:
给定数组 nums = [-1, 0, 1, 2, -1, -4],
满足要求的三元组集合为: [ [-1, 0, 1], [-1, -1, 2] ]
老套路,求和问题转化为求差问题。先固定一个值,再在剩下的元素中找到是否存在两个元素相加后与固定值的和为0。
这道题的解题思路可以看做上面那道题对撞指针解法的延伸:遍历数组,设定数组中遍历到的元素为固定元素,左指针设定在该元素后一个元素的位置上,右指针设定在数组尾部。三数相加如果大于0,右指针往左走;如果小于0,左指针往右走;等于0,则输出结果。
同时不要忘了双指针解法的一个重要前提,必须是有序数组
const threeSum = function(nums, target=0) {
// 排序
nums.sort((a,b) => a - b);
let len = nums.length,
res = []; // 结果数组
for(let i=0; i<len; i++) {
if(nums[i] > target) break
// 题干要求不重复
// 固定元素去重
if(i>0 && nums[i] === nums[i-1]) {
continue
}
let left = i+1,
right = len-1;
while(left < right) {
const sum = nums[i] + nums[left] + nums[right];
if(sum === target) {
res.push([nums[i], nums[left], nums[right]]);
// 左指针去重
while(left<right && nums[left] === nums[left+1]) {
left++;
}
// 右指针去重
while(left<right && nums[right] === nums[right-1]) {
right--;
}
left++;
right--;
} else if(sum < target) {
left++;
} else {
right--;
}
}
}
return res
}
合并两个有序数组
给你两个有序整数数组 nums1 和 nums2,请你将 nums2 合并到 nums1 中,使 nums1 成为一个有序数组。
说明:
初始化 nums1 和 nums2 的元素数量分别为 m 和 n 。 你可以假设 nums1 有足够的空间(空间大小大于或等于 m + n)来保存 nums2 中的元素。
示例:
输入:
nums1 = [1,2,3,0,0,0], m = 3
nums2 = [2,5,6], n = 3
输出: [1,2,2,3,5,6]
法1:JS合并数组再排序
对于熟悉JS数组操作的各位来说,一看到这道题第一想法想必是先合并数组,再排序。而题干中 nums1 有预留空间,所以应该使用 splice 操作将 nums2 插入到 nums1 中,具体实现如下:
const merge = function(nums1, m, nums2, n) {
nums1.splice(m, n, ...nums2); // nums2 插入到 nums1 中
let sortNums = nums1.sort((a, b) => a - b); // 排序
return sortNums.filter(i => i !== 0); // 预留空间 >= m+n,排序后需要过滤 0
}
法2:双指针
然而,这道题的标准解法(也就是面试官最想要看到的解法),其实是利用双指针来解决。
思路:
- 在两个数组的尾部各自设置一个指针
- 对比两个数组指针处元素大小,把较大的元素填充至容器数组nums1 尾部。从后往前填充元素的原因是:容器数组nums1头部是有元素的,而尾部是无元素的,也就是可以给我们填坑的
- 由于nums1和nums2有效元素长度不一定相等,所以当nums1遍历完毕时,如果nums2还有元素剩余,那么应该把剩余的nums2元素填充至nums1头部
- 反之,nums2遍历完毕,nums1还有元素剩余,则无需处理。因为nums1本身就是我们要得到的结果数组
const merge = function(nums1, m, nums2, n) {
let i = m-1,
j = n-2,
k = m + n -1; // k 表示容器数组nums1尾部索引
// 循环边界为其中一个数组遍历完毕
while(i>=0 && j>=0) {
// 向容器数组nums1尾部填充较大元素
if(nums1[i] >= nums2[j]) {
nums1[k] = nums1[i];
i--;
k--;
} else {
nums1[k] = nums2[j];
j--;
k--;
}
}
// 如果nums2还有元素剩余
while(j>=0) {
nums1[k] = nums2[j];
j--;
k--;
}
}
数组展开
实现一个数组的展开,即多维数组变为一维数组
输入:[1,[2,[3,4,2],2],5,[6]],
输出:[1,2,3,4,2,2,5,6]
相信这道题很多人都接触过,实现的方法也有很多,比如利用ES2019里面数组的新api —— flat
法1: Array.prototype.flat
let arr = [1,[2,[3,4,2],2],5,[6]];
console.log(arr.flat(Infinity)); // [1,2,3,4,2,2,5,6]
而如果要自己实现一个flat应该怎么做?如果你的脑海中已经形成了算法的思维,那么应该很快就会想到:利用递归实现。(这应该也是面试官更想看到的答案)
法2:递归
function flatten(arr){
var array = [];
arr.forEach(ele => {
if(Array.isArray(ele)){
// 如果元素为数组,递归
array.push(...flatten(ele));
} else {
array.push(ele);
}
})
return array;
}
写在最后
本文只是简单总结了数组与二维数组,以及一些比较常见的数组相关的算法题及其解题方法与思路。
修言老师在数组篇章有句话说的好:在 JavaScript 数据结构中,数组几乎是“基石”一般的存在。 事实上而关于数组在前端算法中的使用远远不止这些。比如前文提及过的栈和队列,到字符串相关的一些特殊操作,再到常见的排序查找算法,都是需要基于数组来实现的。这些我会在后续学习过程中一一进行总结。