前言
大家好,我是一知。
前几天,在一个前端交(摸)流(鱼)群里,偶然看到一位群友分享的一道他刚被面试过的面试题,看到之后觉得还挺有意思的,于是思考了一下我的答案,动手写了我对这道题的解答过程,并借此机会分享一下我对这道题的一个思考。
我们先来看看这道题。
原题
前端团队的技术分享2周一次,一次2人,为了确定下一次由谁来分享,团队中引入了分享积分制,具体规则如下:
- 每个人都有一个积分,初始值是0;
- 每次分享后所有人摇一轮骰子,点数作为积分累加到各自的积分中,骰子范围是1-12;
- 积分最高的2个人将作为下一次的分享人;
- 为了避免连续分享,某个人分享后他的积分会被清零且跳过本次的摇骰子环节;
- 如果积分最高的人数超过2人,则相同分数的人继续摇骰子,直到决出2个积分最高的人。需要注意的是,这期间摇骰子的积分也同样累积。
请你写一个程序模拟寻找下一次分享人这个过程。
现在,你也可以花几分钟思考一下自己的思路。
好了,想必你现在应该已经有自己的思路了,其实题目并不难,只要我们一步步冷静分析,相信大家都能够解答出来。接下来我们就一起来解答这道题吧。
解题
基础版本
我们先以一种简单直接的方式来编写这道题的代码。
我们现在进入这个场景,假设该前端团队参与技术分享的伙伴共7人,分别是张三、李四、小张、小赵、老王、小白和阿花,在一开始他们每个人的积分都是0。因为题目要求我们模拟寻找下一次分享人的过程,所以我们可以假设现在刚刚结束上一次的分享,这两位分享者分别是老王和张三,并且此时每个人的积分分别是张三10积分、李四7积分、小张3积分、小赵5积分、老王12积分、小白1积分、阿花8积分。我们先将这些状态转化成代码如下:
// 前端团队内所有参与技术分享的伙伴
const allParticipants = [
'张三',
'李四',
'小张',
'小赵',
'老王',
'小白',
'阿花'
];
// 刚分享完的伙伴
let lastSharers = ['老王', '张三'];
// 上次的积分池
const recorder = {
'张三': 10,
'李四': 7,
'小张': 3,
'小赵': 5,
'老王': 12,
'小白': 1,
'阿花': 8,
};
复制代码
接下来是关键的下一次的分享者的确定过程,按照题目要求:
- 每次分享后所有人摇一轮骰子,点数作为积分累加到各自的积分中,骰子范围是1-12;
- 为了避免连续分享,某个人分享后他的积分会被清零且跳过本次的摇骰子环节;
这一步除了刚分享完的两位分享者(老王和张三)外,其他每个人都将进行一轮摇骰子,并将骰子的点数作为积分追加到自己的积分里,我们先实现一个摇骰子的模拟函数:
/**
* 模拟掷骰子
* 返回[1, 12]间的随机整数
*/
const dice = () => {
return Math.ceil(Math.random() * 12);
};
复制代码
接下来实现每个人进行摇骰子的过程模拟:
const diceEach = participants => {
participants.forEach(name => {
// 题目要求:为了避免连续分享,某个人分享后他的积分会被清零且跳过本次的摇骰子环节
if (lastSharers.includes(name)) {
recorder[name] = 0;
} else {
recorder[name] += dice();
}
});
};
复制代码
到这里我们摇完了一轮骰子,接下来要找出此时积分累计最高的两个人,并且需要注意题意:
- 如果积分最高的人数超过2人,则相同分数的人继续摇骰子,直到决出2个积分最高的人。需要注意的是,这期间摇骰子的积分也同样累积。
也就是说比如存在4个人都是最高分15分(或者1个15、3个14等情况),那么这4个人将被选出来再进行一轮摇骰子,并且将这轮摇得的积分也将累加到他们的总积分里,再从他们4个人中选取积分最高的2+
个人,如此递归,直到最终找到最高分的人只有2位,则这两位将作为下一次分享者。
那么我们要怎么找出积分最高的这2+个人呢?具体查找过程如下:
/**
* 找到积分池中积分值最高的2+位参与者
*/
const findMaxScoreGuys = participants => {
// 根据积分从高到低排列
participants.sort((a, b) => recorder[b] - recorder[a]);
// 排在前面的两位幸运儿肯定是跑不了的,直接逮进来
const list = participants.slice(0, 2);
// 然后我们再从第三位开始找,目的是看从第三位开始后面还有没有和排在第二位的幸运儿积分值相等的人
// 如果有,也把他逮进来
let i = 2;
let isEnd = false;
const len = participants.length;
while (!isEnd && i < len) {
const guy = participants[i];
// 如果和第二位积分值相等,把他逮进去
if (recorder[participants[1]] === recorder[guy]) {
list.push(guy);
i++;
} else {
isEnd = true;
}
}
return list;
};
复制代码
此时如果找到积分池中积分值最高的2+位参与者实际人数大于2,则进行递归即可。
整个模拟寻找下一次分享人的过程完整实现代码如下:
// 前端团队内所有参与技术分享的伙伴
const allParticipants = [
'张三',
'李四',
'小张',
'小赵',
'老王',
'小白',
'阿花'
];
// 刚分享完的伙伴
let lastSharers = ['老王', '张三'];
// 上次的积分池
const recorder = {
'张三': 10,
'李四': 7,
'小张': 3,
'小赵': 5,
'老王': 12,
'小白': 1,
'阿花': 8,
};
/**
* 找到积分池中积分值最高的2+位参与者
*/
const findMaxScoreGuys = (participants) => {
// 根据积分从高到低排列
participants.sort((a, b) => recorder[b] - recorder[a]);
// 排在前面的两位幸运儿肯定是跑不了的,直接逮进来
const list = participants.slice(0, 2);
// 然后我们再从第三位开始找,目的是看从第三位开始后面还有没有和排在第二位的幸运儿积分值相等的人
// 如果有,也把他逮进来
let i = 2;
let isEnd = false;
const len = participants.length;
while (!isEnd && i < len) {
const guy = participants[i];
// 如果和第二位积分值相等,把他逮进去
if (recorder[participants[1]] === recorder[guy]) {
list.push(guy);
i++;
} else {
isEnd = true;
}
}
return list;
};
/**
* 模拟掷骰子
* 返回[1, 12]间的随机整数
*/
const dice = () => {
return Math.ceil(Math.random() * 12);
};
const diceEach = participants => {
participants.forEach(name => {
// 为了避免连续分享,某个人分享后他的积分会被清零且跳过本次的摇骰子环节
if (lastSharers.includes(name)) {
recorder[name] = 0;
} else {
recorder[name] += dice();
}
});
};
const run = (participants) => {
diceEach(participants);
const maxScoreGuys = findMaxScoreGuys(participants);
// 如果积分最高的人数超过2人,则相同分数的人继续摇骰子,直到决出2个积分最高的人
return maxScoreGuys.length > 2
? run(maxScoreGuys)
: maxScoreGuys;
};
const findLuckyGuys = () => {
return lastSharers = run(allParticipants);
};
console.log(`下一次的分享人是:${findLuckyGuys().join('、')}`);
复制代码
以上这种代码实现,简单直接,也基本满足了题目的要求,但个人认为可能存在以下几个不太好的点:
- 面向过程的思维和编码方式;
- 代码较散乱,不便管理和复用;
- 不是一个逻辑闭环的程序,只是单个过程的模拟,一些前置状态(如所有参与者、上次的分享者、积分池状态)需要我们自己进行假设;
- 代码中没有将抽取人数2考虑作为一个可变的因素,如果某一天需要调整为3个人、4个人...,那么我们基本需要从头到尾重新捋一遍代码逻辑,然后修改较多处的代码;
我们思考一下,假如我们自己是面试官,我们会希望看到一种怎样的题目解答,换句话说,我们会希望通过面试者的代码看出面试者的哪些能力?我觉得至少有以下几点:
- 理解能力,能够准确理解面试题的意思;
- 逻辑思维能力,能够思考出问题的解答逻辑;
- 问题的抽象能力,能够将实际问题转化为程序模型;
- 代码设计和封装能力,封装不变和变化,以及扩展性的考虑;
- 基础的算法功底;
- 代码规范和编码风格;
所以说,解出答案只是最基本的一点,而如果我们想要获得更好的面试评价,或许还应该思考如何解答地更好。所以接下来我们再实现一个比这个版本更优的版本。
给自己加戏版本
在开始编码前,我们首先对这个场景进行理解,然后提取几个关键信息:
- 分享活动的所有参与者
- 上次的分享者
- 分享人抽取规则
- 抽取人数
我们分析哪些东西是变化的,哪些是不变的。一般情况下,我们会根据自己技术团队的人员情况来决定哪些人参加以及每次分享的人数,所以我们认为活动的所有参与者和每次抽取的分享人数是变化的;而就这个分享活动来说,活动的规则一般是不会轻易改变的;至于上次的分享者是由我们程序运行产生的结果。
那么接下来我们就该考虑如何设计我们的代码,我们可以将这个技术分享的活动抽象为一个程序类,该类接收一个参加活动的所有参与者名字的数组,并由该类内部负责实现参与者信息和积分池的记录、摇骰子的模拟、分享人抽取等逻辑,为该类提供一个抽取分享人的实例方法,而外部(即使用该程序的人)只需做两件事:
- 确定分享活动的所有参与者名单;
- 确定下一次要抽取的人数并调用抽取下一次分享人的方法。
首先,我们声明一个ShareGame
类,该类的构造函数接收一个活动所有参与者姓名的数组participants
,并给该类添加三个实例属性participants
,lastSharers
,recorder
用于记录程序内部相关的一些状态,然后声明一个_init()
方法用于处理程序的初始化逻辑(初始化积分池):
class ShareGame {
// 所有活动参与者的姓名数组
participants;
// 上一次分享的伙伴,用于抽取下一次的分享者时判断
lastSharers = [];
// 积分池
recorder = Object.create(null);
constructor(participants) {
this.participants = participants;
this._init();
}
_init() {
const { recorder } = this;
// 根据活动参与者数据初始化积分池
this.participants.forEach(name => {
recorder[name] = {
// 每个人初始积分为0
score: 0
};
});
}
}
复制代码
再接下来就是程序中的关键的抽取分享人逻辑,抽取的逻辑与上面的基础版本在思路上是基本一致的,只是将上面的抽取人数2作为了一个可配置的参数count
,具体一些过程和说明可以看下面的完整实现代码:
class ShareGame {
// 所有活动参与者的姓名数组
participants;
// 上一次分享的伙伴,用于抽取下一次的分享者时判断
lastSharers = [];
// 积分池
recorder = Object.create(null);
constructor(participants) {
this.participants = participants;
this._init();
}
_init() {
const { recorder } = this;
// 根据活动参与者数据初始化积分池
this.participants.forEach(name => {
// 处于扩展考虑,使用一个对象包裹
recorder[name] = {
// 每个人初始积分为0
score: 0
};
});
}
/**
* 抽取下次的分享者
* @param {*} count 抽取的人数
* @returns 抽取结果,如['李四', '小白']
*/
findLuckyGuys(count) {
return this.lastSharers = this._run(this.participants, count);
}
/**
* 运行抽取分享者逻辑
* @param {*} participants
* @param {*} count 抽取人数
* @returns 抽取结果,如['李四', '小白']
*/
_run(participants, count) {
// 每个人进行一轮掷骰子
this._diceEach(participants, count);
// 如果要抽取的人数大于等于参与者人数,则所有人将参与本次分享
if (count >= participants.length) {
return participants;
}
const maxScoreGuys = this._findMaxScoreGuys(participants, count /* k,积分最高的k个参与者 */);
// 如果积分最高的人数超过count,则相同分数的人继续摇骰子,直到决出count个积分最高的人
return maxScoreGuys.length > count
? this._run(maxScoreGuys, count)
: maxScoreGuys;
}
/**
* 模拟掷骰子
* 返回[1, 12]间的随机整数
*/
_dice() {
return Math.ceil(Math.random() * 12);
}
_diceEach(participants, count) {
const { recorder, lastSharers } = this;
// 这种情况下上次分享过的人有可能需要连续分享
const isSpecial = count + lastSharers.length > participants.length;
participants.forEach(name => {
if (!isSpecial && lastSharers.includes(name)) {
recorder[name].score = 0;
} else {
recorder[name].score += this._dice();
}
});
}
/**
* 找到积分最高的k个参与者
* @param participants
* @param k 查找人数
* @returns ['李四', '小白',...]
*/
_findMaxScoreGuys(participants, k) {
const { recorder } = this;
// 根据积分从高到低排列
participants.sort((a, b) => recorder[b].score - recorder[a].score);
// 排在前面的k位幸运儿肯定是跑不了的,直接逮进来
const list = participants.slice(0, k);
// 然后我们再从第k+1位(此处k+1非索引,而是语义上的第一位、第二位...第k+1位)的那位开始找,
// 目的是看从第k+1位开始后面还有没有和排在第k位的幸运儿积分值相等的人
// 如果有,也把他逮进来
// 查找开始位置索引为k,即语义上的第k+1位
let i = k;
let isEnd = false;
const len = participants.length;
let guy;
while (!isEnd && i < len) {
guy = participants[i];
// 如果和第k-1位积分值相等,把他逮进去
if (recorder[participants[k - 1]].score === recorder[guy].score) {
list.push(guy);
i++;
} else {
isEnd = true;
}
}
return list;
}
}
const participants = [
'张三',
'李四',
'小张',
'小赵',
'老王',
'小白',
'阿花'
];
const shareGame = new ShareGame(participants);
console.log(`下一次的分享人是:${shareGame.findLuckyGuys(2).join('、')}`); // 下一次的分享人是:小赵、小白
console.log(`下一次的分享人是:${shareGame.findLuckyGuys(4).join('、')}`); // 下一次的分享人是:老王、小张、李四、张三
复制代码
上面的代码中score
之所以包一层对象,是处于扩展性的考虑,目前看来的确有点多余,但是如果说你想给程序加个需求:统计一下每个人分别分享过多少次。那么就可以在这个对象中扩展一个与score
同级的count
属性用于记录这个人分享的次数,而不用再创建另一个类似的recorder
对象去映射每个人的分享次数。
至此,我们实现了一个“给自己加戏的版本”,相较于第一个版本,我们做了一些改进:
- 我们将整个活动抽象成了一个程序,并实现了活动程序的逻辑闭环;
- 面向对象的编程方式,便于复用,比如多个前端团队都可以通过这个程序进行自己的分享活动;
- 灵活性更强,题目仅要求抽取2人,而我们可以支持抽取人数自定义;
- 可扩展性更高,比如想统计每个人分享的次数等功能也可以比较方便地进行扩展;
当然,第二个版本肯定也不是最优的解答,我相信还有其他更优的解答,但相对于第一个版本,我觉得这个版本应该会是一个更优的答案,因为它更能体现你对问题的延伸和抽象能力、以及代码设计封装等能力。这些应该也会是很多面试官更希望看到的,如果在真实的面试中,相信会对我们的面试有一定程度地加分。
总结
从把一件事情做完到把一件事情做好,是我们进阶中高级前端的必备能力之一。我们在平时的工作和生活中也应该多思考如何把一件事情做好,而不只是做完而已。最后,本文并非想告诉你一定要按照这种模板来解题,而是提供一个我们在解答面试题时可能会对你有帮助的一个思路:在实际条件允许的前提下,不局限于面试题的基本要求,可以做一些合理的延伸和思考,来帮你的面试加分。
最后,祝大家金三银四都能找到一个满意的好工作!