前言
2024年已经开工大吉了,有些公司新的一年规划已落地,HC也即将释放,又是一年轰轰烈烈的金三银四跳槽季即将开始了,在这个跳槽季还未开启之前,我特此撰文与各位读者分享一些自己在过去两年学习数据结构与算法的经验或心得,以及我对前端面试笔试题的一些看法,希望能够助力读者在新的一年更上一层楼。
为什么要学数据结构与算法?
这是一个在论坛上争论了很久的问题,支持学习数据结构与算法(后文统一简称为算法)的人认为,算法能够改善自己的思维方式,提高编程的灵感,考虑问题的边界条件更全面,对整体技术能力的提升有较大的帮助。反对学习算法的人认为,算法太偏理论了,实际编程根本用不到,纯粹是属于面试造火箭,工作拧螺丝的尴尬境地,学习成本与收获不成正比,还有一个方面是因为算法难,必须理解的去记忆,并且还需要长久的练习,形成题感,让工作退化成了高考的题海战术式的应试考试了。
我个人属于辩证的看待这个问题的,我既不是完全无底线的支持学习算法,也不是完全否定学习算法。我先说一下我的成长经历,我是自学计算机出生的,在大学的时候有过一段时间看过一些简单的数据结构和算法(虽然大学也有数据结构这门课,但是普通的学校学生的掌握程度,懂的都懂嘛),我在2021年的时候运气爆棚,误打误撞的进入了一家知名互联网公司(当时面试题考察的算法题刚好是我掌握的),但是进入到了公司之后我过的非常艰难,因为在小公司要求较低嘛,反正写几个bug发一个hotfix也正常,但是进了那家公司之后我们团队的业务一部分是小程序,小程序如果有bug就会牵涉到重新发版,而正常一个星期只有一个发布版本,如果程序出bug,就会走增发流程,会让大领导审批,这无疑是拉低了整个团队的专业性,那年夏天,我是真的觉得过得无比艰难(被直属领导和间接领导1on1了好几次,得亏我的内心强大脸皮厚,不然真的就得卷铺盖走人了😂)。
后来,我就通过学习浙江大学陈越,何钦铭老师主讲的数据结构_浙江大学_中国大学MOOC(慕课) (icourse163.org)这门慕课课程,配合刷了一些Leetcode的题目,使得我考虑问题边界的能力得到了快速的提升,虽然最后我被那家公司裁员了,但是从此以后我的编程能力提高了一个台阶。在后来就职的公司,至于遇到客服或者测试提出一个问题时,我能很自信的分析十有八九不是我的疏忽导致的bug。
我在目前的公司也常常作为面试官考察一些求职者,我也比较喜欢考察求职者常见的算法,我个人的看法是八股文可以靠背诵,而算法不一定,掌握算法的求职者,他的毅力或者能力必有其一(聪明,看一眼就知道是什么,这类人肯定是佼佼者,这肯定是公司想要的人才;笨的人,但是知道笨鸟先飞,能够愿意花时间折腾,用时间去克服自己先天的不足,这类人能够兢兢业业的为公司奉献,也是公司想要的人)。
好了,说了一些题外话,马上给大家说一下,我觉得前端对于算法的掌握度看法。前端不像后端对于算法和数据结构的要求那么高,毕竟我们很多的场景基本上都是在和界面打交道,而后端更专注和数据打交道,我们写的代码效率低一些,在现在如此高配置的硬件条件下运行的话,用户也是感觉不到卡顿的。所以,我个人觉得前端对算法的掌握度大致就是能够把大学《数据结构与算法》在不借助搜索引擎的条件下能写出个80%+就可以了。
最后,对于一些支持学习算法的同学,我谈一些自己的观点,比如一些普通的前端不太常用的知识点(比如单调栈,线段树,并查集,动态规划,最短路算法等),这些方面的知识点,我觉得没必要死磕。我们一定要学会取舍,比如之前我在脉脉上看到过一个帖子说他面试的求职者全军覆没迪杰斯特拉算法,还说这大概就是前端已死的理由吧。我个人的观点是这样的面试官是不成熟的,面试是对求职者能力的全面考察,每个人都有自己擅长与不擅长的领域,你不能拿一个你刚看过的知识点来筛选求职者。遇到这样的面试官,也没必要跟他杠,杠起来反而拉低了我们自己的素质,反正都是双向选择,我知道这家公司是个坑,继续换下一家面试即可。
讲个笑话,面试最后的环节不是一般面试官会问求职者有没有别的问题呢,如果求职者直接把你刚才问的问题让你写一下,你写不出来,那岂不是很尴尬?哈哈哈哈
另外还有脉友说面试字节跳动,上来就直接是一道困难的题目,这种场景,你可能得反思自己的简历?要么你已经是在简历里面告诉了面试官,你是资深Leetcode选手,要么就是你的项目根本和目标岗位不匹配,面试官不好直接挂掉你,给你出个难题让你知难而退。从这个场景也说明,算法题不是笔试的全部。之前和朋友们聊天时,有在字节工作的前同事说,目前很多求职者刷题都刷魔怔了,给他们出一个接雨水的题不假思索的就做出来了,反而出一个简单的根据有规律的数据构造树型结构的题目(这题后面会提到)反而不会了。
我曾经遇到过的笔试题
因为保密的关系,原谅我不向大家透露公司的名称,不过这些公司都是国内知名的公司,不必担心可能是小公司出的题过于简单,所以大家可以根据我的一些经历放心的准备算法。
2020年及以前
2020年以前基本上我面试的都是一些小公司,不过也有小公司考察了算法。
- 公司A :两数之和 II - 输入有序数组(后面二分章节有这道题的链接)
- 公司B :二分查找 ,类似mustache模板字符串编译(栈的章节有这题的一种解法)
公司B不算小公司,但是仅仅只能在西南还能算叫的上名。
结果:均挂
2021年面试
公司A:
- 一面:二分查找
- 二面:二叉树层序遍历,业务编程题
- 三面:综合场景题
结果:offer
公司B:
- 一面:二叉树层序遍历
- 二面:无,
结果:二面挂(技术栈不匹配,二面问了一个服务端怎么区分是浏览器还是postman发送的请求就把我挂了,哈哈哈,没什么诚意)
2022年面试
以下是2022年第一次被裁员找工作遇到的面试题。
公司A:
- 一面:数组构造树形结构(在下一篇文章中给出)
- 二面:综合场景题
- 三面:无编程题
结果:offer
公司B:
- 一面:快速排序
结果:薪资不符合预期,主动终止流程
公司C:
- 一面:数组flatten,用多种实现方式
- 二面:实现compose函数,实现currying函数
结果:二面过了,三面面试官说我的匹配度太低,终止了流程(你就出个题把我劝退也行嘛,别那么直接嘛,哈哈哈,害我还一直在嘀咕面试开始了面试官怎么还不上线,最后忐忑的去问HR,结果是不辞而别)。
2022年公司C和2021年的公司B是同一家公司,果然大家都对它的评价不高呢,名不虚传呢(国内第一梯队,福利待遇应该是垫底的,大家猜猜看,哈哈哈)。
以下是2022年第二次被裁员时找工作遇到的面试题
- 一面:10进制转26进制 ,求斐波拉契数列
- 二面:综合素质面
结果:offer
好了,废话就跟大家聊这么多。接下来,我将根据我之前的面试经验,以及我个人的招聘经验向大家列举一下前端面试需要掌握的算法的知识点。
注:有些题我也没有做过,但是因为在论坛上看到的频率较高,因此收集在了本文中。
数组
重要指数:⭐️⭐️⭐️⭐️⭐️
难度指数:⭐️⭐️⭐️
数组是考察范围最广的知识点,也是变化最多的题目,可以与二分查找,双指针,哈希表,深度优先遍历,广度优先遍历,并查集,贪心算法,排序,动态规划结合在一起考察。对于数组,我们主要掌握二分查找和双指针以及数组其它一些常见的算法即可。
二分查找和双指针算法的技巧性比较强,所以大家一定要熟悉这些算法常见的应用场景。
二分查找
重要指数:⭐️⭐️⭐️⭐️⭐️
难度指数:⭐️⭐️⭐️⭐️
二分查找又叫做折半查找,通过找到中间值,确定待查找的值在左半区还是在右半区,进而缩小查找的范围。每次查找的范围都折半,因此二分查找的时间复杂度是O(LogN)
(关于这个时间复杂度是怎么推导出来的,我可以做一些解释,比如2^10=1024,我们对1024折半10次得到的结果就是2,所以看起来就是Log2 N,实际上为了方便表示,我们就没有写这个以2为底的底数,因此直接写成了O(LogN)
),在平衡二叉树,跳跃链表都是利用了二分查找的思想。
能使用二分查找解决的问题一定要注意的是数组一定是有序的,这个需要根据题目场景酌情判断。
二分查找标准模板 ⭐️⭐️⭐️⭐️⭐️
二分查找的标准模板作为很多公司的一面编程题,我已经遇到过好几次了,这是一个简单且容易掌握的算法。
js
/**
* 二分查找法
* @param {Array<Number>} arr 需要查找的序列
* @param {Number} target 需要查找的数据
* @returns {Number} 查找成功返回数据所在的下标索引,查找失败,返回-1
*/
function binarySearch(arr, target) {
if (!Array.isArray(arr) || arr.length == 0) {
console.log('empty array')
return -1;
}
// 初始化开始指针
let low = 0;
// 初始化结束指针
let high = arr.length - 1
// 初始化中间位置标记
let mid = Math.floor((low + high) / 2)
// 定义初始的位置
let pos = -1;
while (low <= high) {
// 如果找到了,则不再进行查找,跳出循环
if (arr[mid] === target) {
pos = mid;
break
}
// 如果当前值在中间值的左侧,说明从中间值往左的元素,都是不大于target的 缩小查找范围,因此从mid的前一位查找
if (arr[mid] > target) {
high = mid - 1
}
// 如果当前值在中间值的右侧,说明中间值往右的元素,都是不小于target的 缩小查找范围,因此从mid的后一位查找
else if (arr[mid] < target) {
low = mid + 1
}
// 重新划分中间值
mid = Math.floor((low + high) / 2)
}
return pos
}
搜索旋转的排序数组 ⭐️⭐️⭐️
搜索二维矩阵 ⭐️⭐️⭐️
对于二分查找还有很多题,有兴趣的同学可以在Leetcode进行专项学习。
双指针
重要指数:⭐️⭐️⭐️⭐️⭐️
难度指数:⭐️⭐️⭐️
在数组上的双指针算法一般有两种,一种是初始化的时候分别申明两个变量,一个指向数组的开头,一个指向数组的结尾,根据条件,左边的指针向右边移动,右边的指针向左边移动,当两个指针相遇的时候,求解问题结束。
另一类问题,初始化时,左指针还是指向数组的开头,然后在处理时,向右边不断地找可能出现的结果集;然后左指针向右边移动一位,继续初始化右指针,再次向右边不断的寻找结果集,从而解决问题。
在链表上还有一种双指针,叫做快慢指针,即一个指针跑的快,一个指针跑的慢(比如,一个指针每次跑1步,另外一个指针每次跑两步),在后面的小节我们继续聊
回文序列 ⭐️⭐️⭐️⭐️
回文序列,就是以某个位置插入一个对称轴,左半部分和右半部分对称。比如AABBAA,在两个B之间插入一个对称轴,AAB和BAA对称,比如122131221,在数字3上插入一个对称轴,1221和1221对称。
回文序列最简单的办法是用双指针做,即两个指针,一个从左边开始出发,一个从右边开始出发,分别开始比较,然后进行位移。
除了使用双指针的办法,还可以用位运算的技巧方法,不过这个对于计算机底层原理要足够的清楚,理由就是如果一个数字A,对同一个数字B进行两次异或,等于对A什么都没有做。
给大家举一个非常简单的例子,三国杀这个卡牌游戏很多同学应该都玩儿过,曹丕有个技能叫做放逐,使用之后他需要把自己的武将牌翻面,如果你是忠臣,拿着一把诸葛连弩一直对着曹丕突突突,曹丕对你连续放逐2次,那么它就相当于什么事儿都没有(除了掉血),位运算的异或就是这种效果。
比较版本号 ⭐️⭐️⭐️⭐️⭐️
两数之和 II - 输入有序数组 ⭐️⭐️⭐️⭐️⭐️
合并两个(K个)有序数组 ⭐️⭐️⭐️⭐️⭐️
这道题是双指针里面我个人认为最重要的一题,因为它的思想非常精妙,代码简洁,并且这个题里面的思想能够应用在很多场景,比如大数加法。
合并两个有序数组作为归并排序中最重要的一个基础,合并两个有序数组,结合二路归并,可以推广到合并K个有序数组(小米某年的面试题)
合并两个有序数组:
js
/**
* 合并2个有序数组
* @param {number[]} arr1
* @param {number[]} arr2
*/
function merge(arr1, arr2) {
let offset1 = 0;
let offset2 = 0;
let offset = 0;
let newArr = [];
// 当两个数组都还没有处理完成的时候
while (offset1 < arr1.length && offset2 < arr2.length) {
let val1 = arr1[offset1];
let val2 = arr2[offset2];
if (val1 >= val2) {
newArr[offset++] = arr2[offset2++];
} else {
newArr[offset++] = arr1[offset1++];
}
}
/**
* 这两个while不可能同时成立,只有可能成立一个,将数组长度较大的剩余部分拷贝给新数组
*/
while (offset1 < arr1.length) {
newArr[offset++] = arr1[offset1++];
}
while (offset2 < arr2.length) {
newArr[offset++] = arr2[offset2++];
}
return newArr;
}
合并K个有序数组:
js
/**
* 合并2个有序数组
* @param {number[]} arr1
* @param {number[]} arr2 可选参数,若不传递该参数,则相当于将原数组copy一份
*/
function merge(arr1, arr2 = []) {
let offset1 = 0;
let offset2 = 0;
let offset = 0;
let newArr = [];
// 当两个数组都还没有处理完成的时候
while (offset1 < arr1.length && offset2 < arr2.length) {
let val1 = arr1[offset1];
let val2 = arr2[offset2];
if (val1 >= val2) {
newArr[offset++] = arr2[offset2++];
} else {
newArr[offset++] = arr1[offset1++];
}
}
/**
* 这两个while不可能同时成立,只有可能成立一个,将数组长度较大的剩余部分拷贝给新数组
*/
while (offset1 < arr1.length) {
newArr[offset++] = arr1[offset1++];
}
while (offset2 < arr2.length) {
newArr[offset++] = arr2[offset2++];
}
return newArr;
}
/**
* 合并K个有序数组
* @param {number[][]} arrs
*/
function mergeKArray(arrs) {
let mergedArr = arrs;
// 如果归并结果大于1,则需要继续进行归并
while (mergedArr.length > 1) {
// 本轮的归并结果
const mergePassArr = [];
for (let i = 0; i < mergedArr.length; i += 2) {
// 得到二路归并的结果
const newArr = merge(mergedArr[i], mergedArr[i + 1]);
mergePassArr.push(newArr);
}
// 将本轮的归并结果给最终的合并结果,使之可以继续下一轮归并
mergedArr = mergePassArr;
}
// 如果归并0个数组,则返回空,否则返回正常的归并结果
return mergedArr.length ? mergedArr[0] : [];
}
三数之和 ⭐️⭐️⭐️
这个题,以及后面的两个题都是字节跳动常考的面试题,经常在掘金或者脉脉能够刷到。
四数之和 ⭐️⭐️⭐️
最接近的三数之和 ⭐️⭐️⭐️
其它
数组去重 ⭐️⭐️⭐️⭐️⭐️
有些人背了几年的Set
去重,可能都不明白Set
去重的底层发生了什么,稍微的对题目进行一下变形,估计就噶了。
Set
是一个Key-Value
一致的Map
,我在这篇文章中阐述了Map
做哈希的一些关键点:为什么我推荐你用Map作哈希?深入浅出ES6之Map对象 在那篇文章我给出了这个去重的原理实现,篇幅考虑,本文就不再赘述。
大数加法 ⭐️⭐️⭐️
大数加法应该是前几年腾讯或者阿里的一道面试题?,这题主要就是坑就坑在边界条件的处理上。记忆这题,最关键的两个测试用例就是1+99999999999,然后就是99999999999+1,这样就能够避免两个数组一个比较短,另一个比较长(跟前面提到过的合并两个有序数组的思路一模一样),也能够避免进位的问题。
js
export function add(a: string, b: string) {
let res = "";
let digitListA = a.split("");
let digitListB = b.split("");
// 是否需要进位的标记
let isAddNext = false;
// 当两个数字的数位都还没有用完的时候
while (digitListA.length && digitListB.length) {
const digitA = digitListA.pop()!;
const digitB = digitListB.pop()!;
let accumulate = Number.parseInt(digitA) + Number.parseInt(digitB);
// 如果需要进位
if (isAddNext) {
accumulate += 1;
// 进位完成之后,把进位标记移除
isAddNext = false;
}
// 如果大于10,标记进位标记,并且将剩余的数字加到当前的结果上
if (accumulate >= 10) {
isAddNext = true;
res = accumulate - 10 + res;
} else {
res = accumulate + res;
}
}
// 以下两个while只会执行一个,处理逻辑也是一致
while (digitListA.length) {
const digitA = digitListA.pop()!;
let accumulate = Number.parseInt(digitA);
if (isAddNext) {
accumulate += 1;
isAddNext = false;
}
if (accumulate >= 10) {
isAddNext = true;
res = accumulate - 10 + res;
} else {
res = accumulate + res;
}
}
while (digitListB.length) {
const digitB = digitListB.pop()!;
let accumulate = Number.parseInt(digitB);
if (isAddNext) {
accumulate += 1;
isAddNext = false;
}
if (accumulate >= 10) {
isAddNext = true;
res = accumulate - 10 + res;
} else {
res = accumulate + res;
}
}
// 不要忘了最后还有一个判断,比如1+99这种场景
if (isAddNext) {
res = "1" + res;
}
return res;
}
求两个数组的交集,并集,差集 ⭐️⭐️⭐️⭐️
如果你在实际面试中遇到,那可真是你的福气呢,哈哈哈。
js
// 定义计算交集的函数
function intersection(arr1, arr2) {
// 使用 Set 对象来存储第二个数组的元素,然后使用 filter 方法找出第一个数组中也存在于第二个数组中的元素
return arr1.filter(value => new Set(arr2).has(value));
}
// 定义计算并集的函数
function union(arr1, arr2) {
// 首先合并两个数组,然后使用 Set 对象来去除重复的元素
return Array.from(new Set([...arr1, ...arr2]));
}
// 定义计算差集的函数(arr1 中有但 arr2 中没有的元素)
function difference(arr1, arr2) {
// 使用 Set 对象存储第二个数组的元素,然后使用 filter 方法找出只存在于第一个数组中的元素
return arr1.filter(value => !new Set(arr2).has(value));
}
上述的实现,利用的是原生的API,如果面试中需要自己手动实现的话,那就用哈希表。
随机打乱数组 ⭐️⭐️
js
/**
* 随机化数组
* @param {number[]} arr 待随机化数组
*/
function shuffle(arr) {
for (let i = arr.length - 1; i >= 0; i--) {
// 因为JS的随机数范围是[0, 1),对其取floor之后,随机数范围则变成了[0, i - 1], 所以为了保证,每个数都有机会被选取到,生成随机索引时,要传入i+1,
// 使得生成的随机数索引范围在[0, i]之间
const rndIdx = Math.floor(Math.random() * (i + 1));
// 将随机选中的数交换到当前处理的位置上
let tmp = arr[i];
arr[i] = arr[rndIdx];
arr[rndIdx] = tmp;
// 完成交换之后,数据规模递减,直到完成所有的处理
}
}
两数之和 ⭐️⭐️⭐️
这题是难者不会,会者不难,只要是有系统的学习过算法方面的知识点的人都能写的出来,因此,很多面试官喜欢用这道题来考察求职者的算法积累。如果是我作为面试官,筛选3年经验及以上候选人不知道使用哈希表进行优化的话,我是必定挂掉他的,所以,还不会的同学引起重视。
链表
重要指数:⭐️⭐️⭐️⭐️⭐️
难度指数:⭐️⭐️
我想对大家说,链表无难题 (后半句是回溯无Easy),有的人觉得我在吹牛,这是真的(LRU
,LFU
,跳跃链表
只要把理论搞清楚了,就是实现的问题了,实际面试可能写不到用例100%通过,但是确实比起某些题直接都不知道怎么做要好很多吧,哈哈哈),链表这个章节里面它的技巧性的东西很少,只要你把常见的范式搞明白,基本上就够用了,不像别的题。
关于链表的基础知识点,我曾经写过一篇,如果你不明白链表的基本操作的话,可以先移步->积跬步,以至千里------你应该掌握这些链表的基础知识
常见问题
反转链表 ⭐️
谁要是面试字节跳动遇到了这题,请一定相信是你的过往经历得到了面试官的青睐,哈哈哈。
排序链表 ⭐️⭐️
简单的,可以在遍历链表的过程中,利用哈希表将处理成类似双向链表那样的场景,结合插入排序即可完成。
设计链表 ⭐️⭐️⭐️
合并两个(K个)有序链表 ⭐️⭐️⭐️
跟合并数组是一样的,不过就是遍历链表要稍微显得麻烦一些。
双指针(快慢指针)
重要指数:⭐️⭐️⭐️⭐️⭐️
难度指数:⭐️⭐️⭐️
这也是之前提到双指针时就聊过的一种场景,放在这一节向大家展示例题。
链表的中间节点 ⭐️⭐️
快指针走完全程,慢指针刚好走一半,慢指针最后指向的节点就是答案。
环形链表
这题的快慢指针不容易想得到,如果链表中不存在环的话,那么最终快指针就指向null
了,而一旦有环的话,那就会出现一个问题,经过足够的时间之后,快指针跑两圈,慢指针跑一圈。它们总会在一个时刻相遇,即快慢指针指向同一个节点。
双向链表
LRU ⭐️⭐️⭐️⭐️⭐️
这题非常重要,因为缓存在很多场景下都可以用到它,可以说是万能的设计,不管是前端后端还是客户端,比如VueRouter就用到了它,之前我有一篇文章解释过关于LRU的知识点,有兴趣的同学可以移步->我们好像在哪儿见过?------从LRUCache到Vue内置组件KeepAlive
队列
重要指数:⭐️⭐️⭐️⭐️⭐️
难度指数:⭐️
队列,JS是提供了原生数据结构支持的,对于一个数组,我们只用它的push方法和shift方法,就可以实现入队和出队的效果。
队列,根据实际的使用场景来说,就是记录一些未来需要做的事儿,比如在Promise
的实现中,用来记录异步的任务,比如将异步API改造成看起来是同步的API。
队列另外的使用场景是在广度优先的遍历中用于记录下一圈要遍历的数据。
队列是一种工具,单独说它很简单,它一旦和别的知识点结合起来就可以完成非常神奇的工作,所以重要的点是应用,每个前端都应该掌握队列的用法。
Promise的实现
关于Promise的实现内容太多,占用的篇幅太长,我就不在本文贴出来了,有兴趣的同学可以参考我的个人博客。
Promise | awesome-frontend-code (sicau-hsuyang.github.io)
异步API改造成同步API
在之前的这篇文章中,有用到队列来记录异步任务的操作,大家可以参考这篇文章:设计模式在前端开发中的实践(十一)------发布订阅模式
二叉树的层序遍历(广度优先遍历)
js
/**
* 二叉树的层序遍历
* @param {TreeNode} tree 树的根节点
*/
function treeLevelTraverse(tree) {
if (!tree) {
console.log("empty tree");
return;
}
let node = tree;
let list = [];
// 将跟节点入队
list.push(node);
// 如果队列不为空,则进行遍历
while (list.length > 0) {
// 从队首取出一个元素用以处理
const curNode = list.shift();
console.log(curNode.data);
// 如果存在左子树,将左子树入队
if (curNode.left) {
list.push(curNode.left);
}
// 如果存在右子树,将右子树入队
if (curNode.right) {
list.push(curNode.right);
}
}
/**
* 因为队列先入先出的特性,所以最后的打印顺序总是按层从上至下,每层从左到右的顺序输出
*/
}
栈
重要指数:⭐️⭐️⭐️⭐️⭐️
难度指数:⭐️⭐️
对于栈的应用场景的阐述,在之前我写过一篇文章,有兴趣的同学可以移步学习:实力加自信就是一把坚韧不摧的利剑------栈与栈的应用。
栈的使用场景主要有三大类:逆序 ,词法分析 ,模拟系统堆栈
栈的应用之逆序
两数相加 ⭐️⭐️⭐️
利益栈的逆序的能力,将其转换成预期的大数相加算法的模式。
迷宫寻路(广度优先实现) ⭐️⭐️⭐️⭐️
广度优先的寻路过程是最终找到了终点,而一个一个的倒推回最终的起点,这就需要逆序,而逆序就可以使用到栈。
js
/**
* 以BFS的形式找迷宫的出口
* @param {number[]} matrix
*/
function findPath(matrix) {
const height = matrix.length;
const width = matrix[0].length;
/* 定义一个哈希表,用于记住经过的路径 */
const pathMap = new Map();
// 定义一个队列
const queue = [];
// 定义一个标记数组
const maker = Array.from({
length: height,
}).map(() => {
return Array.from({
length: width,
}).fill(0);
});
// 先将开始节点加入到队列中去
queue.push({
node: { x: 0, y: 0 },
/* 入口的父节点为空 */
parent: null,
});
// 将起始节点标记为已处理
maker[0][0] = true;
let distNode = null;
while (queue.length) {
const { node, parent } = queue.shift();
/* 将当前节点记录在到终点的路径上 */
pathMap.set(node, parent);
const { x, y } = node;
if (x === width - 1 && y === height - 1) {
distNode = node;
break;
}
// 上边的点,存在且没有被访问过,并且不是障碍物
const topPoint =
isExist(matrix, x, y - 1) && !maker[y - 1][x] && matrix[y - 1][x] !== 1
? { x, y: y - 1 }
: null;
if (topPoint) {
queue.push({
node: topPoint,
parent: node,
});
maker[y - 1][x] = true;
}
// 右边的点,存在且没有被访问过,并且不是障碍物
const rightPoint =
isExist(matrix, x + 1, y) && !maker[y][x + 1] && matrix[y][x + 1] !== 1
? { x: x + 1, y }
: null;
if (rightPoint) {
queue.push({
node: rightPoint,
parent: node,
});
maker[y][x + 1] = true;
}
// 下边的点,存在且没有被访问过,并且不是障碍物
const bottomPoint =
isExist(matrix, x, y + 1) && !maker[y + 1][x] && matrix[y + 1][x] !== 1
? { x, y: y + 1 }
: null;
if (bottomPoint) {
queue.push({
node: bottomPoint,
parent: node,
});
maker[y + 1][x] = true;
}
// 左边的点 存在且没有被访问过,并且不是障碍物
const leftPoint =
isExist(matrix, x - 1, y) && !maker[y][x - 1] && matrix[y][x - 1] !== 1
? { x: x - 1, y }
: null;
if (leftPoint) {
queue.push({
node: leftPoint,
parent: node,
});
maker[y][x - 1] = true;
}
}
/* 本来正常的做法是需要使用栈记录逆序的路径,但是我们直接利用JS的方法反向插入最终得到的即可是一个正序的路径,可以少一个循环 */
/* 注意不要把distNode记录掉了 */
const path = [distNode];
let parent = pathMap.get(distNode);
/* 直到找到入口节点,循环终止 */
while (parent) {
path.unshift(parent);
distNode = parent;
parent = pathMap.get(distNode);
}
return path.map((node) => {
return [node.x, node.y];
});
}
/**
* 判断当前元素是否存在于迷宫中
* @param {number[][]} matrix
* @param {number} x
* @param {number} y
*/
function isExist(matrix, x, y) {
return Array.isArray(matrix[y]) && typeof matrix[y][x] !== "undefined";
}
栈的应用之词法分析
表达式求值 ⭐️⭐️⭐️⭐️⭐️
这题是在项目中可以实际应用的,利用表达式求值的原理,可以用来开发公式编辑器。
还有直接的,就是普通的表达式求值,也需要用到栈。
20. 有效的括号 - 力扣(LeetCode),这道题是百度的面试题,也是栈作为词法分析的用途入门级的题目,非常简单。
四则运算,华子的OD机试题。
mustache模板字符串编译 ⭐️⭐️⭐️⭐️
这个算法是最有实际意义的,如果你是一个有技术追求的人,一定要掌握这类算法,像Vue的template-compiler
,babel
底层,浏览器解析HTML文档做词法分析就是用的这种方式。
以下算法展示的是一个简单的解析mustache语法的算法:
js
function parseMustache(expression) {
let stack = []; // 用于存储括号的栈
let result = ""; // 存储最终的结果
let isInside = false; // 标记是否在双大括号内部
for (let char of expression) {
if (char === '{') {
stack.push(char);
// 如果栈的长度为2,说明遇到了 "{{"
if (stack.length === 2) {
isInside = true;
continue;
}
} else if (char === '}') {
stack.pop();
// 如果栈为空,说明遇到了 "}}"
if (stack.length === 0) {
isInside = false;
continue;
}
}
// 如果在 "{{" 和 "}}" 之间,则将字符添加到结果中
if (isInside) {
result += char;
}
}
// 如果栈不为空,说明有未闭合的括号
if (stack.length > 0) {
throw new Error("Expression is not valid.");
}
return result;
}
类似的,日期格式化,比如按YYYY-MM-DD
这种形式格式化日期也可以采用这种方式(正则表达式可能匹配的不准,比如MMDD连在一起的时候)。
栈的应用之模拟系统堆栈
在二叉树(或N叉树)的遍历,我们使用递归遍历非常简单,但是如果树高特别高的话,就有可能出现最大堆栈调用的错误,我们可以把它改造成循环的迭代方式,这种时候就可以使用栈来模拟系统的堆栈。
二叉树的前序遍历(DFS,非递归) ⭐️⭐️
js
/**
* 先序非递归遍历二叉树
* @param {TreeNode<number>} tree
*/
function treePreOrder(tree) {
if (!tree) {
console.log("empty tree!");
return;
}
// 定义一个栈用于模拟系统提供的堆栈
let stack = [];
// 让node指向树的跟节点,准备开始遍历
let node = tree;
// 如果树不空,或者栈中还有内容,则应该继续进行遍历
while (stack.length > 0 || node) {
// 如果node节点不为空的话,不断的向左压栈,直到为空
while (node) {
stack.push(node);
console.log(node.data);
node = node.left;
}
// 向左走到头了,若当前栈中还有内容,则从栈中取出一个内容,从当前内容的右子树继续遍历
if (stack.length > 0) {
node = stack.pop();
node = node.right;
}
}
}
二叉树的中序遍历(DFS,非递归) ⭐️⭐️
js
/**
* 二叉树非递归中序遍历
* @param {TreeNode<number>} tree 树的根节点
*/
function treeInOrderTraverse(tree) {
if (!tree) {
console.log("empty tree");
return;
}
let stack = [];
let node = tree;
while (stack.length > 0 || node) {
// 压栈的时候不能立即输出节点
while (node) {
stack.push(node);
node = node.left;
}
if (stack.length > 0) {
// 当从栈中取出节点时,方可以输出节点,接着再从当前节点的右子树进行遍历
node = stack.pop();
console.log(node.data);
node = node.right;
}
}
}
二叉树的后序遍历(DFS,非递归,双栈法) ⭐️⭐️⭐️
js
/**
* 二叉树非递归后序遍历
* @param {TreeNode} tree 树的根节点
*/
function treePostTraverse(tree) {
if (!tree) {
console.log("empty tree");
return;
}
// 栈1用于遍历
let stack1 = [];
// 栈2用于保持输出顺序
let stack2 = [];
let node = tree;
stack1.push(node);
while (stack1.length > 0) {
node = stack1.pop();
// 将根节点加入栈2,先加入的后输出
stack2.push(node);
// 如果左子树存在,将左子树节点加入到栈1中
if (node.left != null) {
stack1.push(node.left);
}
// 如果右子树存在,将右子树节点加入到栈1中
if (node.right != null) {
stack1.push(node.right);
}
/**
* 因为先加入栈1的节点,会后输出,但是再加入栈2,又会先输出,所以这儿要先处理左子树,才能处理右子树
*/
}
while (stack2.length > 0) {
let tempNode = stack2.pop();
console.log(tempNode.data);
}
}
因为二叉树的递归遍历非常简单,所以我猜想面试官可能为了防止求职者钻空子,层序遍历的考察频率才会那么高了。
总的来说,关于栈的知识点,词法分析的用法是最重要的面试场景,各位读者可以重视一下。
结语
因为算法考察的知识点特别的广,一篇文章难以涵盖(所以准备将其分为上中下三篇文章进行阐述),所以本文就先阐述数组(二分,双指针)、链表、队列、栈相关的算法考察点,在下一章节我们继续阐述树相关的算法考察点,以及DFS,BFS和图的一些算法及排序算法的应用,在最后一篇文章阐述哈希表,递归,分治,动态规划相关的算法考察点。
本文的很多题目我是没有给出示例算法的,有兴趣的同学可以在评论区一起讨论,对于这些题目的实现有困难的也可以在评论区留言。
对于本文阐述的内容有任何疑问的同学可以在评论区留言或私信我。
如果大家喜欢我的文章,可以多多点赞收藏加关注,你们的认可是我最好的更新动力,😁。