前言
我在大厂, 经历过大大小小的面试, 这里说一下中大厂面试场景:
现在不管是前端还是后端, 大厂的一场面试基本1小时, 前面40分钟拷打八股和项目, 然后基本都会在面试末尾给一道题目, 一般会给25分钟左右的时间(包含做题和探讨), 本文主要介绍如何以通过面试为目的来做题
有一个小误区: 好多同学会以为只要最快的速度做完题目就行, 其实不对, 试想一下前面讲项目比较磕绊, 或者八股答得不好, 到了做题直接3分钟秒了, 面试官会觉得你是不是背过, 反而不合适 (说的就是你:接雨水)
正文
选择了一道lc算法题, 我以面试场景和结合代码解释来说明
题目描述
给你一个字符串
s,它仅包含字符'a'和'b',你可以删除s中任意数目的字符,使得s平衡 。当不存在下标对
(i,j)满足i < j,且s[i] = 'b'的同时s[j]= 'a',此时认为s是 平衡 的。 返回使s平衡 的 最少 删除次数。
选择道题有两个理由:
- 写文章的时候是2月7号, 是今天的每日一题, 比较随机, 贴合面试场景
- 解法可以有递进
题意翻译
一句话解释, 让字符串变成所有的a在前b在后, 比如aaabb, aa合法, ababb不合法, 需要删掉第一个b变成aabb
面试中怎么处理
说实话我自己做只是从暴力想到了前后缀处理, 动态规划是看了答案才会的, 面试的场景下, 一般面试官会给出一些长度比较小的case, 可以花5分钟快速暴力, 确保简单的case都能过, 这至少能说明有基本的理解能力和代码能力
1. 暴力方法
暴力都没思路的基本寄了
一般面试官给了两个case 例如 s = "aababbab", s = "bbaaaaabb"
分析完题目可知要让a在前b在后, 且删除掉的字符数最少, 针对第一个case, 只要把第三位b和第七位的a删了, 变成aaabbb即可, 第二个case删掉最前面两个b变成aaaaabb即可
思路: 就是遍历到索引i的时候, 统计i前面所有的b和i后面所有的a数量, 注意把a或者b全删掉也是一种合法情况
面试中一般console.log跑出来两个case答案是2就行
const fun = (s) => {
const l = s.length;
let res = Infinity;
for (let i = 0; i < s.length; i++) {
let cnt = 0;
for (let j = 0; j <= i; j++) {
const h = s[j];
if (h === "b") {
cnt++;
}
}
for (let j = i + 1; j < l; j++) {
const h = s[j];
if (h === "a") {
cnt++;
}
}
res = Math.min(res, cnt);
}
// 把其中一个字符全删了也是合法的
const cntA = s.split("").filter((v) => v === "a").length;
return Math.min(res, cntA, l - cntA);
};
s = "aababbab";
// s = "bbaaaaabb";
console.log(fun(s)); // 2
2. 优化暴力
注意到暴力法代码的第6行和第13行的两个内部for循环, 主要就是为了统计到索引i处, 前面有多少b和后面有多少a, 每次都计算, 造成了O(n^2)的时间复杂度, 可以针对这里优化, 可以提前用数组存储一下i前后的ab数量
以上这些话一定要和面试官说出来, 面试是需要交流的, 比闷头做题要更好
分别使用两个数组, 来计算到i位置时, 前面b的数量, 和后面a的数量, 注释写在代码中了
// 使用前后预处理来优化暴力解法
const fun = (s) => {
const count = [];
let c = 0;
const l = s.length;
for (let i = 0; i < l; i++) {
const g = s[i];
if (g === "b") {
c++;
}
count.push(c);
}
const rightCount = [];
c = 0;
for (let i = l - 1; i >= 0; i--) {
const g = s[i];
if (g === "a") {
c++;
}
rightCount.push(c);
}
rightCount.reverse();
let res = Infinity;
for (let i = 0; i < l; i++) {
let cnt = 0;
// count[i] = [0, i]中b的数量
cnt += count[i];
// rightCount[i + 1] = [i + 1, l - 1]中a的数量
cnt += rightCount[i + 1] ?? 0;
res = Math.min(res, cnt);
}
// 把其中一个字符全删了也是合法的
const cntA = s.split("").filter((v) => v === "a").length;
return Math.min(res, cntA, l - cntA);
};
3. 动态规划
一般前后缀预处理法写出来, 面试差不多可以了, 如果还能更进一步, 那这道算法题的面试价值就凸显出来了
对于s来说, 最后一个字符
- 如果是b, 不需要处理, s的答案就是s.slice(0, -1)这个字符串的答案
- 如果是a, 分两种情况, 两者取最小值
- 删掉, 答案是s.slice(0, -1)的答案 + 1, 删掉这个a需要加一次
- 不删掉, 答案是s.slice(0, -1)中b的数量, 为了保留这个a, 前面的b都得删
动态规划的几大要素
定义, 初始值, 递归公式
dp[i]表示[0, i]长度字符串的答案
第一个字符不管是a还是b都不需要删, dp[0] = 0, 就是说s如果只有一个字符的时候, 答案是0
如果s有两个及以上的字符, 要按照上面分析的情况开始递推
递推公式
如果s[i]是b, dp[i] = dp[i - 1]
如果s[i]是a, dp[i] = Math.min(dp[i - 1] + 1, 前面b的数量)
完整代码
const fun = (s) => {
const len = s.length;
// dp[i]表示[0, i]长度字符串的答案
// 对于第一个字符不管是a是b 都不需要删除 dp[0] = 0
const dp = new Array(len).fill(0)
let cntB = 0
for (let i = 0; i < len; i++) {
if (s[i] === 'b') {
cntB++
}
if (i > 0) {
if (s[i] === 'b') {
dp[i] = dp[i - 1]
} else {
dp[i] = Math.min(dp[i - 1] + 1, cntB)
}
}
}
return dp[len - 1]
}
4. 动态规划的空间优化
注意到dp[i]只与dp[i - 1]有关, 就可以进行空间的优化, 用一个变量简化
这个是dp空间优化的精髓, 即等号右边是过去的值, 左边是现在的值
一般如果能写出动态规划的常规版本, 面试中简单判断一下能不能空间优化, 如果能且保证不出问题, 可以快速用3分钟改进一下, 面试更加分
如果没把握改空间优化, 比如一些二维dp的题, dp[i][j]改成dp[j], 这种题不难, 但是面试那种场合容易改错, 可以直接和面试官说一下有空间优化的思路和方式
别一上来直接写空间最优版本, 既没有表现出思维有递进, 也不够稳, 然后简单的case跑不过, 那就寄了
切记: 面试的目的是通过面试本身, 原则就是一个稳字
最终答案, 多么简洁优美
const fun = (s) => {
const len = s.length;
let dp = 0
for (let i = 0, cntB = 0; i < len; i++) {
if (s[i] === 'b') {
cntB++
} else {
dp = Math.min(dp + 1, cntB)
}
}
return dp
};