笔试20250812


Summary

药明生物的线上前端笔试:4 道题 1.5h

  1. 求最短连续子数组(leetcode697:数组的度)【简单】
  2. 求时间段内的推文计数(leetcode1348:推文计数)【中等】
  3. 是一个 sql 题,找出部门里薪资最高的员工(leetcode184:部门工资最高的员工)【中等】
  4. 写一个 bash 脚本,打印 txt 文件中的第 10 行(leetcode195:第十行)【简单】

数组的度

题目

给定一个非空且只包含非负数的整数数组 nums,数组的 度 的定义是指数组里任一元素出现频数的最大值。

你的任务是在 nums 中找到与 nums 拥有相同大小的度的最短连续子数组,返回其长度。

解析

题目看起来读不懂,但是看例子就知道要干嘛了:

例子 1:数字串 [1,2,2,3,1]

第一步:最多出现次数是 2(1 和 2 都出现 2 次)。
第二步:找符合要求的段:
要包含 “出现 2 次的 1”:得从第一个 1(开头)到最后一个 1(结尾),这段是 [1,2,2,3,1],有 5 个数字;
要包含 “出现 2 次的 2”:从第一个 2(第 2 个)到最后一个 2(第 3 个),这段是 [2,2],只有 2 个数字;
显然 2 更短,所以答案就是 2。

例子 2:数字串 [1,1,2,2,2,1]

第一步:最多出现次数是 3(1 出现 3 次,2 也出现 3 次)。
第二步:找段:
包含 “3 次 1”:得从第一个 1 到最后一个 1,这段是整串,6 个数字;
包含 “3 次 2”:从第一个 2 到最后一个 2,这段是 [2,2,2],3 个数字;
最短的是 3,答案就是 3。

例子 3:数字串 [1,2,3,4]

第一步:每个数都只出现 1 次,所以 “最多出现次数” 是 1。
第二步:只要找一段里有个数字出现 1 次就行(其实随便一段都满足,因为所有数都只出现 1 次)。最短的就是单个数字(比如 [1]),所以答案是 1。

解题

var findShortestSubArray = function (nums) {
  let count = new Map(); // 记录频数
  let firstIndex = new Map(); // 记录第一次出现位置
  let lastIndex = new Map(); // 记录最后一次出现位置

  // 遍历数组,收集信息
  nums.forEach((num, i) => {
    if (!firstIndex.has(num)) firstIndex.set(num, i);
    lastIndex.set(num, i);
    count.set(num, (count.get(num) || 0) + 1);
  });

  // 数组的度
  let degree = Math.max(...count.values());
  let minLen = nums.length;

  // 找出频率等于度的元素,计算区间长度
  for (let [num, freq] of count.entries()) {
    if (freq === degree) {
      let len = lastIndex.get(num) - firstIndex.get(num) + 1;
      minLen = Math.min(minLen, len);
    }
  }

  return minLen;
};

// 测试
console.log(findShortestSubArray([1, 2, 2, 3, 1])); // 2
console.log(findShortestSubArray([1, 2, 2, 3, 1, 4, 2])); // 6

分析一

// 遍历数组,收集信息
nums.forEach((num, i) => {
  if (!firstIndex.has(num)) firstIndex.set(num, i);
  lastIndex.set(num, i);
  count.set(num, (count.get(num) || 0) + 1);
});
  1. nums.forEach((num, i) => {…})
    forEach 是 JavaScript 数组的方法,用于对数组的每个元素执行一次提供的函数。
    num 是当前遍历到的元素,i 是当前元素的索引。
  2. if (!firstIndex.has(num)) firstIndex.set(num, i);
    firstIndex 是一个 Map,用于记录每个元素第一次出现的索引。
    firstIndex.has(num) 检查当前元素 num 是否已经存在于 firstIndex 中。
    如果不存在(即第一次出现),则使用 firstIndex.set(num, i) 将元素 num 和它的索引 i 存入 firstIndex。
  3. lastIndex.set(num, i);
    lastIndex 也是一个 Map,用于记录每个元素最后一次出现的索引。
    每次遇到元素 num,都更新 lastIndex,因此它最终会存储每个元素的最后一次出现的位置。
  4. count.set(num, (count.get(num) || 0) + 1);
    count 是一个 Map,用于记录每个元素出现的频率。
    count.get(num) 获取元素 num 的当前频率。如果 num 还未记录,则返回 undefined,我们用 || 0 来确保在这种情况下返回 0。
    然后将当前频率加 1,并更新到 count 中。

总结这段代码的作用是:
对每个元素的第一次出现索引、最后一次出现索引和出现频率进行记录。
结果是通过遍历一遍数组,收集到所有需要的信息,以便后续计算数组的度和最短子数组的长度。

分析二

forEach 完了之后,nums 还是原来的 nums 吗?
是的,forEach 完成后,nums 仍然是原来的数组。forEach 方法只是对数组中的每个元素执行指定的函数,并不会修改原数组的内容。

具体来说:
forEach 只是迭代数组,执行回调函数。
回调函数内的操作(如更新 firstIndex、lastIndex 和 count)不会影响 nums 本身。

所以,在 forEach 执行前后,nums 依旧是原来的数组,没有做任何修改。

分析三

let degree = Math.max(...count.values());
  1. count.values()
    count 是一个 Map,用于存储每个元素及其出现的频率。
    count.values() 返回一个可迭代对象,包含 count 中所有值(即每个元素的频率)。
  2. …(展开运算符)
    展开运算符 … 用于将可迭代对象转换为单个参数列表。
    在这里,它将 count.values() 中的所有频率提取出来,作为 Math.max 的参数。
  3. Math.max(…)
    Math.max 函数接受任意数量的参数,并返回其中的最大值。
    在这里,它将接收到的所有频率进行比较,返回最大的频率。

这行代码的整体作用是:
计算数组 nums 的度,即出现频率最高的元素的次数,并将结果存储在变量 degree 中。

分析四:解构赋值

目的是方便地遍历 Map 中的每个元素和它的频率,以便后续进行频率的比较和最短子数组长度的计算。通过解构赋值,可以更清晰地处理每个键值对。这样可以直接使用 num 和 freq,而不需要再通过索引访问。

for…of 循环是 JavaScript 中的一种用于遍历可迭代对象(如数组、字符串、Map、Set 等)的方法。它提供了一种简单而清晰的方式来访问可迭代对象的每个元素。

语法
for (const element of iterable) {
  // 处理 element
}
示例
  1. 遍历数组
const fruits = ['apple', 'banana', 'cherry'];

for (const fruit of fruits) {
  console.log(fruit);
}
// 输出:
// apple
// banana
// cherry
  1. 遍历字符串
const word = 'hello';

for (const char of word) {
  console.log(char);
}
// 输出:
// h
// e
// l
// l
// o
  1. 遍历 Map
const map = new Map([
  ['a', 1],
  ['b', 2],
  ['c', 3],
]);

for (const [key, value] of map) {
  console.log(`${key}: ${value}`);
}
// 输出:
// a: 1
// b: 2
// c: 3
  1. 遍历 Set
const set = new Set([1, 2, 3, 4]);

for (const value of set) {
  console.log(value);
}
// 输出:
// 1
// 2
// 3
// 4

分析五:count.entries()

  • count 是一个 Map,用于存储每个元素及其出现的频率。
  • count.entries() 返回一个迭代器,该迭代器包含 count 中所有键值对的数组形式,每个键值对都是一个数组。
  • 例如,如果 count 是 {1: 2, 2: 3},那么 count.entries() 返回的迭代器会包含 [[1, 2], [2, 3]]。

推文计数

题目

一家社交媒体公司正试图通过分析特定时间段内出现的推文数量来监控其网站上的活动。这些时间段可以根据特定的频率( 每分钟 、每小时 或 每一天 )划分为更小的 时间段 。

例如,周期 [10,10000] (以 秒 为单位)将被划分为以下频率的 时间块 :

每 分钟 (60 秒 块): [10,69], [70,129], [130,189], …, [9970,10000]
每 小时 (3600 秒 块):[10,3609], [3610,7209], [7210,10000]
每 天 (86400 秒 块): [10,10000]
注意,最后一个块可能比指定频率的块大小更短,并且总是以时间段的结束时间结束(在上面的示例中为 10000 )。

设计和实现一个 API 来帮助公司进行分析。

实现 TweetCounts 类:
TweetCounts() 初始化 TweetCounts 对象。
存储记录时间的 tweetName (以秒为单位)。
List getTweetCountsPerFrequency(String freq, String tweetName, int startTime, int endTime) 返回一个整数列表,表示给定时间 [startTime, endTime] (单位秒)和频率频率中,每个 时间块 中带有 tweetName 的 tweet 的数量。
freq 是 “minute” 、 “hour” 或 “day” 中的一个,分别表示 每分钟 、 每小时 或 每一天 的频率。

解析

假设我们记录了以下推文:
“tweet1” 在 10 秒时发布
“tweet1” 在 70 秒时发布
“tweet1” 在 130 秒时发布
“tweet2” 在 3600 秒时发布

统计每分钟的推文数量:
如果我们询问在 10 秒到 100 秒之间每分钟的推文数量:
时间块:[10, 69] -> 1 条推文(在 10 秒)
时间块:[70, 129] -> 1 条推文(在 70 秒)
时间块:[130, 189] -> 1 条推文(在 130 秒)
返回结果:[1, 1, 1]

统计每小时的推文数量:
如果我们询问在 10 秒到 4000 秒之间每小时的推文数量:
第一个时间块:[10, 3609] -> 2 条推文(在 10 秒和 3600 秒)
第二个时间块:[3610, 7209] -> 0 条推文
返回结果:[2, 0]

统计每天的推文数量:
如果我们询问在 10 秒到 10000 秒之间每天的推文数量:
只有一个时间块:[10, 10000] -> 4 条推文
返回结果:[4]

解题

// 使用函数表达式定义TweetCounts
const TweetCounts = function () {
  this.tweetMap = {}; // 存储推文时间的对象
};

// 记录推文时间的方法
TweetCounts.prototype.recordTweet = function (tweetName, time) {
  // 如果推文名称不存在,初始化为一个空数组
  if (!this.tweetMap[tweetName]) {
    this.tweetMap[tweetName] = [];
  }
  this.tweetMap[tweetName].push(time); // 将推文时间添加到对应的推文名称数组中
};

// 获取指定频率下的推文数量的方法
TweetCounts.prototype.getTweetCountsPerFrequency = function (
  freq,
  tweetName,
  startTime,
  endTime
) {
  const intervals = this.getIntervals(freq, startTime, endTime);
  const counts = new Array(intervals.length).fill(0); // 初始化计数数组

  if (!this.tweetMap[tweetName]) return counts; // 如果推文名称不存在,返回全为 0 的计数数组

  for (const time of this.tweetMap[tweetName]) {
    // 检查推文时间是否在指定范围内
    if (time >= startTime && time <= endTime) {
      // 遍历时间块,查找对应的块
      for (let i = 0; i < intervals.length; i++) {
        const [start, end] = intervals[i];
        if (time >= start && time <= end) {
          counts[i]++;
          break; // 一旦计数,跳出循环,不再检查后续时间块
        }
      }
    }
  }
  return counts;
};

// 辅助方法:获取时间块
TweetCounts.prototype.getIntervals = function (freq, startTime, endTime) {
  const intervals = [];
  let intervalSize;

  switch (freq) {
    case 'minute':
      intervalSize = 60;
      break;
    case 'hour':
      intervalSize = 3600;
      break;
    case 'day':
      intervalSize = 86400;
      break;
    default:
      throw new Error('Invalid frequency');
  }

  for (let start = startTime; start <= endTime; start += intervalSize) {
    const end = Math.min(start + intervalSize - 1, endTime);
    intervals.push([start, end]);
  }

  return intervals;
};

分析一:TweetCounts.prototype

prototype 是函数特有的属性,用于存放所有实例共享的方法和属性。当通过 new TweetCounts() 创建实例时,所有实例都会继承 prototype 上的方法,这样可以节省内存(不需要为每个实例单独创建相同的方法)。

分析二:const counts = new Array(intervals.length).fill(0)

假设有三个时间块(intervals.length 为 3),执行这行代码后,counts 数组的内容将是:
counts = [0, 0, 0];


部门工资最高的员工

题目

表: Employee

列名 类型
id int
name varchar
salary int
departmentId int

在 SQL 中,id 是此表的主键。departmentId 是 Department 表中 id 的外键(在 Pandas 中称为 join key)。
此表的每一行都表示员工的 id、姓名和工资。它还包含他们所在部门的 id。

表: Department

列名 类型
id int
name varchar

在 SQL 中,id 是此表的主键列。此表的每一行都表示一个部门的 id 及其名称。

Aim: 查找出每个部门中薪资最高的员工。按 任意顺序 返回结果表。


查询结果格式如下例所示:

输入:
Employee 表:

id name salary departmentId
1 Joe 70000 1
2 Jim 90000 1
3 Henry 80000 2
4 Sam 60000 2
5 Max 90000 1

Department 表:

id name
1 IT
2 Sales

输出:

Department Employee Salary
IT Jim 90000
Sales Henry 80000
IT Max 90000

解释:Max 和 Jim 在 IT 部门的工资都是最高的,Henry 在销售部的工资最高。

解析

表结构:

Employee 表
| id | name | salary | departmentId |

  • id 是主键,表示员工编号
  • departmentId 是外键,指向 Department.id

Department 表
| id | name |

  • id 是主键,表示部门编号

要求
返回 每个部门薪资最高的员工(可能有并列)。
结果包含:Department(部门名)、Employee(员工名)、Salary(工资)。

解题:子查询 + JOIN 经典解法

SELECT d.name AS Department,
       e.name AS Employee,
       e.salary AS Salary
FROM Employee e
JOIN Department d
    ON e.departmentId = d.id
WHERE (e.departmentId, e.salary) IN (
    SELECT departmentId, MAX(salary)
    FROM Employee
    GROUP BY departmentId
);

分析

  1. 子查询:SELECT departmentId, MAX(salary) FROM Employee GROUP BY departmentId → 找出每个部门的最高工资。
  2. 外层 WHERE (departmentId, salary) IN (...) → 找出工资等于该部门最高工资的员工。
  3. 关联 Department 表,得到部门名。

第十行

题目

给定一个文本文件 file.txt,请只打印这个文件中的第十行。

示例:
假设 file.txt 有如下内容:
Line 1
Line 2
Line 3
Line 4
Line 5
Line 6
Line 7
Line 8
Line 9
Line 10

你的脚本应当显示第十行:
Line 10

说明:

  1. 如果文件少于十行,你应当输出什么?
  2. 至少有三种不同的解法,请尝试尽可能多的方法来解题。

解题

法一 sed(最简洁)

sed -n '10p' file.txt

思路

  • -n 取消默认打印
  • 10p 只打印第 10 行
  • 如果文件不足 10 行,什么都不输出

法二:awk

awk 'NR==10' file.txt

思路

  • NR 表示当前行号
  • NR==10 时,输出该行
  • 如果文件不足 10 行,不会触发条件 → 输出空

法三:head + tail

head -n 10 file.txt | tail -n 1

思路

  • head -n 10 → 取前 10 行
  • tail -n 1 → 从这 10 行里取最后一行
  • 如果不足 10 行:head 会输出所有行,tail -n 1 会取到最后一行(⚠️ 注意:这种情况下会返回最后一行,而不是空)。

所以如果要和 sed / awk 保持一致(不足 10 行时输出空),可以加判断:

[ $(wc -l < file.txt) -ge 10 ] && head -n 10 file.txt | tail -n 1

其中:
1️⃣ wc -l < file.txt

  • wc -l:统计文件的行数
  • < file.txt:把 file.txt 的内容作为输入(避免输出文件名)
  • 举例:file.txt 有 12 行 → wc -l < file.txt 输出 12

2️⃣ $( ... )

  • 命令替换:把括号里的命令执行结果替换出来
  • 所以 $(wc -l < file.txt) → 得到一个数字,比如 12

3️⃣ [ ... -ge 10 ]

  • [ 是 test 命令 的别名(在 Bash 里就是 /usr/bin/[
  • -ge 表示 greater than or equal(大于等于)
  • 所以这句判断是:文件行数是否大于等于 10

4️⃣ &&

  • 逻辑与:如果前一个命令返回成功(状态码 0),才执行后面的命令
  • 如果判断失败(状态码非 0),后面的命令不会执行

文章作者: Citrus
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Citrus
  目录