金③银④ 分享一道曾让我栽在二面的面试题

前言

一转眼又到了跳槽季,是时候跟大家分享一波面试题了,通常来说大家在跳槽之前都会刷题,甚至说从不刷题的多年开发经验的大神在面试中可能干不过刷过各种题的面试者,导致了一顿内卷,这也侧面印证了看面试题的重要性。

我也看过很多的面试题文章,比较反感的是那种代码片段巨长或者牵扯到的知识点巨广抑或是与各种复杂的数学公式相关的还有就是过于底层的那种题:像什么手写Promise A+规范、手撕红黑树、用公式实现个什么Canvas xx特效、浏览器是怎么实现xxx API的、如果浏览器没有提供这个 API 的话要怎么去模仿实现该 API,Vue3 的 diff 算法和 React 的 diff 算法有什么区别、能不能手写个他俩的结合版 diff 算法、手写个 React 的时间切片……

相信大部分人也和我一样,每次看这种文章的时候,不是看着看着就没耐心往下继续看了、就是看到一半就忍不住翻到评论区看评论了,然后点赞、关注、收藏一键三连,收藏夹里都快积攒上千篇文章了,这种文章虽然技术含量很高,但是过于的枯燥乏味,抑或是牵扯到的知识点过广,要是其中哪个知识点自己不太熟的话,后续的内容也就都看不懂了。就像是上数学课一样,刚开始没认真听讲,落下了某个知识点没听到,再回过神来的时候发现已经听不懂了。

比如有一次看一篇文章是实现个什么非常炫酷的 Canvas 特效,看着看着突然冒出来了三角函数,虽然中学的时候也都学过这些,但经过这么多年后早就把什么 sin、cos、tan 这些符号的意思忘的差不多了,但也懒不想再打开浏览器一顿搜索一顿查,就继续往下看吧,看着看着又出现个什么矩阵算法,大学的时候其实也学过,总之看到最后实现出来的效果非常酷,但具体是怎么实现的心里也是云里雾里的。除非真的工作中要用到这个,才会仔细看文章去钻研,即使是工作中不会用到的同时还仔细钻研了一番,通常很快也就会忘记。

而另外一种文章则是非常立竿见影的:那就是讲述的知识点并不复杂,只是以前从未想过可以这样用,相当于是一种思路,抑或是自己以前不知道的一个 API,用起来很方便。这种文章看着也不会特别的枯燥乏味、并且还看的津津有味的,感叹:原来还可以这样用啊!自己以前怎么就没有想到呢?

这种文章看过了不会特别容易忘记、甚至在工作的过程中还会找机会去用一下试验试验。给大家举几个例子:

一定时间无操作时播放视频

当时处于刚刚入行的阶段,经验比较差,所以有些很普通的需求自己却没思路。当时做的是 Electron 项目,放在阳明古镇的一面墙上展示,需求是当用户十分钟都不操作界面的话就自动播放阳明古镇的宣传视频,当时脑子就像是卡住了一样:怎么才能知道用户十分钟都没有操作呢?为此还专门去查找有没有这样的 API,后来看到一篇文章让我大呼真妙!

原理也超级简单,就是在页面上设置一个十分钟的变量:

let minute = 10

然后设置个定时器每分钟 -1:

setInterval(() => {
  minute--
  if (minute <= 0) {
    // 播放视频
  }
 }, 1000 * 60)

当有事件发生时就代表用户在操作,需要还原变量:

window.addEventListener('click', () => minute = 10)

还可以监听 mousemove 或者键盘等事件,但那个项目是触摸大屏,没有鼠标或者键盘,所以监听点击事件就够了

短短几行代码就解决了我的燃眉之急,当然那时候也菜,这么简单的需求都没想出来,不过谁还不是从小白一步步走上来的呢?正是靠着这些文章一步步扩展了思路才会很快的进步。

Vue 性能优化

看了黄老师出品的 《揭秘 Vue.js 九个性能优化技巧》

才知道原来 computed 里面的函数是可以接收一个 this 参数的:

computed: {
  a () { return 1 },
  b ({ a }) {
      return a + 10
  }
}

这样就不会在组件刷新时重复获取 getter 了,以前从来没注意过这些。

纯 CSS 实现拖拽效果

以前我们做拖拽的时候基本都会用 JS 去实现,很麻烦,但看了阅文前端团队的《纯 CSS 也能实现拖拽效果》令我佩服的五体投地:

在传统 web 中,页面滚动是一个很常见交互,操作上就是利用鼠标滚轮或者直接拖动滚动条。但是,移动端可不一样,直接用手指拖动页面就可以滚动了。通常页面是要么垂直方向滚动,要么水平方向滚动,如果两个方向都可以滚动呢?例如:

.dragbox {
  width: 300px;
  height: 300px;
  overflow: auto
}
.dragcon {
  width: 500px;
  height: 500px;
}

只需要内部元素宽高都大于容器就实现两个方向的滚动了(记得设置overflow:auto),示意如下:

一般情况下,鼠标滚轮只能同时滚动一个方向(按住Shift可以滚动另一方向),但是移动端可以直接拖着内容任意滚动,如下所示:

image

现在,在内容中间添加一个元素,跟随内容区域一起滚动:

image

接下来,把后面的文本隐藏起来:

image

是不是有点拖拽的味道了?原理就是这么简单!

Vue3 的新语法

现在一搜 Vue3 出来的要不就是 Composition API 要么就是 新的响应式原理,这些东西讲起来都比较复杂,而且大家都忽略了好多其他的点,比如很多时候我们想要 CSS 也能是响应式的,比如曾经幻想过的语法:






不过由于 CSSJS 隶属不同上下文,这一点很难做到,但自从看了这篇《Vue超好玩的新特性:在CSS中引入JS变量》才发现原来还可以这么写:






this.color 发生变化时,css 也会一同做出响应。

还有就是《Vue超好玩的新特性:DOM传送门》,这些小技巧能够非常方便的提升我们的开发效率,但如今的 Vue3 相关文章却很少有人提及到这些。

九宫格面试题

这种面试题代码量不多,但却甚少人能够做对,这篇《千万别小瞧九宫格 一道题就能让候选人原形毕露!》给我们提供了很好的一个思路,因为在做这种九宫格时:

image

很多人以为只需要给每个格子加上一个边框即可,而实际上如果这么做的话会变成下面这样:

image

因为在给每个盒子加入了边框之后,相邻的两个边框就会贴合在一起,肉眼看起来就是一个两倍粗的边框。而《千万别小瞧九宫格 一道题就能让候选人原形毕露!》利用负边距轻轻松松的就解决了这个难题:

image

你不知道的 CSS 负值

提到负边距就让人想起这篇《你所不知道的 CSS 负值技巧与细节》:

image



真的是没想到这样就能实现加号。

题目

说了这么多有点跑题了,本意其实是想说明:本篇文章的算法题就像上面列举出来的文章那样,代码量不多、甚至还很简单,但重点就是考察你对技术的灵活运用程度,你的思维能不能转得过弯来。

当然也不是说那些代码量很多很复杂的文章不好,其实那些文章技术含量都很高,但毕竟大部分人没有心思那么仔细的钻研各种复杂的算法,不过你要去的如果是字节跳动百度阿里腾讯这类大厂去面试的话,钻研一下那些复杂的文章还是非常有必要的。

情景再现

面试那天我来到了一个看起来像是会议室的屋子里,面试官给了我几张卷子和一只笔,让我先写,然后他就出去了。我还在想:没人看着我难道就不怕我用手机搜索答案么?是不是有摄像头然后通过屏幕来观察我有没有用手机搜索答案,进而考察候选人的诚实与否…

当然我也没有想用手机

卷子有点像是中学考试那样:选择题 + 填空题 + 大题

大题就是手写代码,其实挺烦这种的… 一方面写大括号只能先写一半,因为不知道在大括号里会写多少行代码,不像是编辑器里那样,尾括号随着行数的增加会自动移动;另一方面是没有控制台,自己也不知道自己写的到底对不对,只能通过直觉来判断。

其中一道题目是:写一个函数,这个函数会返回一个数组,数组里面是 2 ~ 32 之间的随机整数(不能重复),这个函数还可以传入一个参数,参数是几,返回的数组长度就是几

就像这样:

刚看到题目的时候还在想这有啥难的,写呗!首先先来生成从 2 ~ 32 之间的随机数…怎么生成 2 ~ 32 之间的随机数呢?Math.random() * 32 ,可是这是生成 0 ~ 32 的,有了!先生成 0 ~ 30 之间的随机数然后再加上 2 不就得了:

const fn = num => {
  let arr = []

  for (let i = num; i-- > 0;) {
    arr.push(Math.round(Math.random() * 30 + 2))
  }
  
  return arr
}

这样写带来的问题就是,随机生成的数字会有重复:

这时我想到了 ES6 的新增数据结构 Set,它里面是可以保证没有重复值的,而且它还可以用 ... 操作符很方便的转为数组,于是继续写:

const fn = num => {
  let arr = []

  for (let i = num; i-- > 0;) {
    arr.push(Math.round(Math.random() * 30 + 2))
  }

  arr = [...new Set(arr)]

  return arr
}

这样写虽然解决了重复值的问题,但却带来了新的问题:如果有几个重复值数组的长度就会少几,就像这样:

当时我的思路是这样的:假如 fn(10) 传的参数是 10,如果最终出来的数组不为 10,那就用 10 减去数组的长度,就是相差的位数了。比如 fn(10) 导致 arr.length = 8,那么 10 - 8 就代表只需要再生成 2 个随机数就可以了,但随机的两个数也可能会和现有的 8 位数组重合,所以我们要把随机生成的两位数连接到原来的 8 位数组中去,然后再用 Set 数据结构去重,用 while 循环判断,如果传进来的参数 10 减去数组长度 arr.length 不等于 0 的话就证明依然还是有重复项,那就继续再生成随机数重复刚才的步骤,直到生成 10 位所有数字都不重复的数组就会自动跳出 while 循环,然后返回这个数组:

const fn = num => {
  let arr = []

  for (let i = num; i-- > 0;) {
    arr.push(Math.round(Math.random() * 30 + 2))
  }

  arr = [...new Set(arr)]
  
  let len = arr.length
      
  while (num - len > 0) {
    arr = [...new Set(arr.concat(fn(num - len)))]
    len = arr.length
  }

  return arr
}

运行结果:

当然我在笔试的过程中是看不到运行结果的,这是我回到家之后凭借着印象写出来的代码,想试验一下写的对不对。

二面加难度后的题

到了二面的时候(省略问的其他问题),面试官说那道题虽然你做对了,但其实有点像是暴力破解的感觉,效率很差。比如我在函数里传入 30,从 2 ~ 32 总共也就 30 个数,你想想生成随机数的这个方法会运行多少次,假如足够幸运,第一次运行函数就生成了 29 位不同数字的数组,那么还差一位就齐了,你想想最后这一位重复的几率有多大?是不是30分之29?重复一次就要再运行一遍、重复一次再运行… 每次都要新建数组然后再新建 Set 再转回数组,开销很大的,你有没有什么想优化的点?

此时我想的是不用 Set 来去重,想的是怎么复用原来的数组不让它重新生成,每次只返回一个数然后递归?还是加入第二个参数,传入原来生成的数组?

我把我的想法说给他听后,他并不满意,觉得我没有说到他想要的点上,于是他给了我点提示:假设生成随机数这个操作是特别费时的一项操作,你有没有办法只让他运行传进来的参数那么多次?就好比 fn(10),能不能只让 Math.random 这个函数只运行 10 次?

当时我一听头都大了,这怎么可能呢?既然是在一定范围内生成随机数,那么肯定无可避免的会有重复项,哪怕不用 Set 用别的方式去重,那也必须得运行超过 10 次啊!不过他既然这么问了就证明肯定有什么办法能够做到,于是我绞尽脑汁想啊想,最终还是钻了牛角尖:认为无论什么方法都无法避免生成重复项,即使是足够幸运运行一次就得到了想要的结果,那也不算是技术实现出来的,只能算是运气好,最后只好摊牌说自己没思路。

我本以为面试到这里就要结束了,没想到他居然主动跟我说了一下这道题的解法,但当时心里有些沮丧,他说的那一大堆都没听进去,只记住了他说要定义两个数组,在坐地铁回家的路上我一直在想:两个数组… 两个数组?

回到家打开电脑开始写代码,先把我原来的那个解法做个测试,看看性能到底有没有他说的那么差:

0.1 毫秒,也还行啊!可能是数字小了吧,如果是生成从 0 ~ 10000 之间的随机数应该就崩溃了吧?修改一下函数:

const fn = num => {
  let arr = []

  for (let i = num; i-- > 0;) {
    arr.push(Math.round(Math.random() * 10000))
  }

  arr = [...new Set(arr)]

  let len = arr.length

  while (num - len > 0) {
    arr = [...new Set(arr.concat(fn(num - len)))]
    len = arr.length
  }

  return arr
}

运行结果:

这回确实明显的感觉到卡顿了,两秒多钟才出来结果,在计算机运算里两千毫秒已经算得上是天文数字了。那再来试试他说的两个数组:我的理解是先事先定义一个里面装着从 2 ~ 32 范围内的所有整数,然后再定义一个空数组用来存放结果,在数组的长度(length)范围内随机生成整数,用这个生成出来的整数当作下标从那个数组中取出数字来放入空数组中,这样即使生成出来的随机数有重复项也没有关系,因为这两个数组不会有重复项:

const fn = num => {
  const allNums = Array.from({ length: 31 }, (_, i) => i + 2)
  const result = []

  for (let i = num; i-- > 0;) {
    result.push(allNums.splice(Math.floor(Math.random() * allNums.length), 1)[0])
  }

  return result
}

这回再来试一下:

没有任何毛病,那性能呢?来测一下:

确实是比以前快得多,再来试一下 0 ~ 10000 的随机数:

const fn = num => {
  const allNums = Array.from({ length: 10001 }, (_, i) => i)
  const result = []

  for (let i = num; i-- > 0;) {
    result.push(allNums.splice(Math.floor(Math.random() * allNums.length), 1)[0])
  }

  return result
}

运行结果:

这回差距就特别明显了:一个两千三百多毫秒,能够让人明显的感觉到卡顿、而另一个只需要七毫秒,人类的感觉就是按下回车就能够出结果。

其实这个函数封装的还不够彻底,因为生成从几到几的随机数完全是写死在函数内部的,如果不想要生成从 2 ~ 32 的随机数的话还需要去函数内部改代码,这明显是不符合开放封闭原则的,并且用起来也不够灵活,咱们来再次封装一下,令其成为一个更加通用的函数:

const fn = (len, from = 0, to = 100) => {
  const allNums = Array.from({ length: to - from }, (_, i) => i + from)
  const result = []

  for (let i = len; i-- > 0;) {
    result.push(allNums.splice(Math.floor(Math.random() * allNums.length), 1)[0])
  }

  return result
}

运行结果:

可以看到非常完美的运行出了我们想要的结果,不过生成十位从 2 到 12 的数字为什么没有 12 这个数呢?原来咱们封装的这个函数是为了符合程序中左闭右开的潜规则,细心的同学应该早就发现了在程序中左闭右开的这么一种现象,比方说我们用 substring 方法来举例:

'0123456'.substring(1, 5)

运行结果:

可以看到咱们传入的参数是从 1 到 5,但是最后的结果却包含 1 而不包括 5。咱们中学的时候就学过开区间闭区间的这么一种概念,开区间指的是不包括这个数,而闭区间是包括。想当年数学老师就一直反复强调过这个概念,所以程序中的左闭右开应该也是为了尽量符合数学规则而设计的吧!

但我觉得并不能说后面实现的这个随机数生成器就比前面的那个好,也是要分情况的,举个极端点的案例:假如从 0 ~ 10000 里随机生成 10 个不重复的数字,在范围这么大的情况下重复的几率是不是就很低了?所以第一种方案很可能只需要生成十个随机数就满足需求了。但如果是第二种方案的话:先要生成一个从 0 ~ 10000 的数组,这个数组太大了,但是却只需要其中的 10 个数,有点像是高射炮打蚊子、杀鸡焉用宰牛刀的感觉,只有在范围内占比越大的情况下,第二种函数才越合适。如果想要封装得更智能一点的话,可以给大家提供个思路:

用 to - from 除以 len 的值,就是比例了。比如从 0 ~ 10000 里获取 10 个数,就相当于 10 / 10000,也就是千分之一,这种情况下就用第一种函数去获取随机数、而如果是从 0 ~ 10000 里获取 3000 个数,就是十分之三的比例,此时用第二种函数会更加合适一点:

const fn = (len, from = 0, to = 100) => {
  const ratio = (to - from) / len
  let result = []
  
  if (ratio > 0.3) {
    const allNums = Array.from({ length: to - from }, (_, i) => i + from)

    for (let i = len; i-- > 0;) {
      result.push(allNums.splice(Math.floor(Math.random() * allNums.length), 1)[0])
    }
  } else {
    for (let i = len; i-- > 0;) {
      result.push(Math.round(Math.random() * to + from))
    }

    result = [...new Set(result)]

    let length = result.length

    while (len - length > 0) {
      result = [...new Set(result.concat(fn(len - length, from, to)))]
      length = result.length
    }
  }
  

  return result
}

当然这个函数还缺少很多判断:比如当 fromto 大的时候怎么办?传负数的话怎么办?传小数的话怎么办?bigintnumber混着传的时候该怎么办?lento - from 还大的时候怎么办?这些就不在这里浪费篇幅的去挨个封装了,大家感兴趣的话可以自己去封装一下。

用处

大家是不是觉得这个函数除了能当面试题几乎没有其他的用处了?还真不一定!做完这道题我立马就想起来以前做[青岛银行]文化体验桌时的夫子问答模块:

image

这个文化体验桌其实是让来到青岛银行办业务的朋友在等待的过程中不那么无聊,尤其是有那种带小孩来的顾客,在宣传山东文化的同时顺便插播两条广告赚点外快(是他们赚不是我赚),这个模块一开始后端让我从论语里挑二三十道题发给他,他录入到数据库里,然后我通过请求来随机获取 10 道题。后来他实在太忙了,说反正也没几道题,让我全写在前端自己随机获取吧!

于是随便找了些论语放到里面去,那么接下来就是算法了,用户肯定不希望每次进去都是完全相同的十道题,最起码得带点随机性吧?当时比较赶进度赶时间,没有好好写算法,只写了个简易版的:

const arr = ['题', '题', '题', ...'题']

const result = arr.filter(() => Math.random() > 0.5)

result.length = 10

这么写是实现了需求,但是并不严谨,导致的结果就是数组里面越靠前的题目越会经常出现,而越靠后的题目就越不容易出现,来测试一下:

const arr = [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20]

const fn = num => {
  const result = arr.filter(() => Math.random() > 0.5)
  result.length = num
  return result
}

运行结果:

image

可以看到越靠后的数字出现的几率越小,前几位数倒是经常会出现,0(第一题) 或 1(第二题) 几乎次次都出现,而最后一题在大部分情况下甚至连出场的机会都没有。

不过由于当时天天加班到凌晨,累得不行根本没精力想算法,找了找库,像 LodashUnderscore 等库里面也并没有发现能实现类似功能的方法,于是就先这样了,等测试说这里有问题的时候再改吧!先把软件做出来才是头等大事。不过后来也没人发现这个问题,只有我自己知道(现在你们也知道啦!),天知地知、你知我知,不要去青岛银行里跟别人说哦!

当然有了我们前面面试题做出来的那个函数,这一切都会迎刃而解,生成出来的随机题目将会非常均衡,不会出现前三题出场机会偏高,后三题靠碰运气才能遇到的情况啦!如果屏幕前的你是青岛人或者身处青岛地区的话可以去青岛银行看看,试一下夫子问答是不是会有这种情况。

如果你问我明明写出来算法了为什么不把青岛银行里的文化体验桌算法替换掉呢?因为已经离职了呀!我没有权限再碰这个项目了,而且在青岛驻场的那些同事们也都撤回来了,公司跟青岛银行的合同也已经结束了,验收的时候甲方领导也很满意,并未提出什么整改意见,于是这个项目也就这么圆满的落下帷幕了…

我只能把那个算法的缺陷埋藏在岁月之中,在这里跟你们倾诉一下了。

你可能感兴趣的:(金③银④ 分享一道曾让我栽在二面的面试题)