大厂真题 / 美团

美团 4.18 笔试真题 - 算法岗

本场考试概述

考试时间:2026年4月18日 考试岗位:算法岗 难度评级:中等偏难

考点分析

  1. 第一题:排序 + 模拟(难度简单)
  2. 第二题:高斯过程回归 GPR(难度困难,ML实现题)
  3. 第三题:分组贪心(难度中等)
  4. 第四题:贪心 + 树状数组求字典序最大 LCS(难度困难)

建议策略

  1. 第一题排序签到,直接拿满分
  2. 第二题 ML 实现题,需要了解 GPR 闭式公式和 RBF 核,NumPy 向量化一步到位
  3. 第三题需要理清”翻倍操作”的数学本质——不改变奇数因子,按奇数因子分组后贪心匹配
  4. 第四题是经典难题”字典序最大 LCS”,将 LCS 转化为 LIS 后用树状数组维护后缀 LIS 长度

第一题:清除残留数据

题目描述

给定长度为 n 的序列,需要删除其中最小的 m 个元素(若有并列,优先删除位置靠左的),输出剩余元素按原顺序排列的结果。

多组测试数据,T 组,保证所有测试数据的 n 之和不超过 200000。

样例

输入

3
5 2
3 1 4 1 5
3 0
1 2 3
5 4
5 4 3 2 1

输出

3 4 5
1 2 3
5

思路分析

第一步:确定删除策略

题目要求删除最小的 m 个元素,遇到值相同时优先删最左边的。这等价于:按 (值, 下标) 升序排序所有元素,标记排序后前 m 个元素为”被删除”。

第二步:为什么按 (值, 下标) 排序就够了?

因为值相同时,下标小的排在前面,恰好满足”并列时优先删左边”的规则。标记前 m 个即可把题意中”最小的 m 个、左优先”精确对应。

第三步:输出未被删除的元素

用一个布尔数组记录哪些下标被删除,最后按原顺序遍历输出未标记的元素。

题解代码

import sys
input = sys.stdin.readline

def solve():
    n, m = map(int, input().split())
    arr = list(map(int, input().split()))
    # 按 (值, 下标) 排序,前 m 个标记为删除
    indices = sorted(range(n), key=lambda i: (arr[i], i))
    removed = [False] * n
    for i in range(m):
        removed[indices[i]] = True
    result = [str(arr[i]) for i in range(n) if not removed[i]]
    print(' '.join(result))

T = int(input())
for _ in range(T):
    solve()

复杂度分析

时间复杂度:O(n log n),排序为瓶颈。 空间复杂度:O(n)。


第二题:数据分析师

题目描述

实现高斯过程回归(Gaussian Process Regression, GPR)。给定训练数据和测试数据,使用 RBF 核(也称径向基核 / 平方指数核)进行预测。超参数固定为:长度尺度 l = 1.0,信号方差 $\sigma_f^2 = 1.0$,噪声方差 $\sigma_n^2 = 0.1$。

输入为单行 JSON,格式为 {"train": [[x1, x2, ..., y], ...], "test": [[x1, x2, ...], ...]},其中 train 每行的最后一个值为标签 y。

输出预测均值的 JSON 数组,保留 6 位小数。

样例

输入

{"train": [[1,-1,1],[1,1,1]], "test": [[-1,1],[0,1],[1,1]]}

输出

[-0.896337, 0.0, 0.896337]

思路分析

第一步:理解 GPR 的直觉——用”相似度”做加权预测

高斯过程回归的核心思想可以用一句话概括:测试点的预测值 = 训练标签的加权平均,权重由测试点与各训练点的”相似度”决定

这里的”相似度”由核函数定义。RBF 核 $k(x, x’) = \sigma_f^2 \exp\left(-\frac{|x - x’|^2}{2l^2}\right)$ 在两点距离近时值接近 1(非常相似),距离远时值趋近 0(无关)。直觉上:离测试点近的训练样本对预测贡献大,远的贡献小。

第二步:为什么不能直接用核值做权重?

如果训练点之间彼此离得很近,它们提供的信息是冗余的——一个点就能代表一群。直接用核值做权重会过度倾斜向这类密集区域。

GPR 通过构造并求逆矩阵 $K = K(X_{train}, X_{train}) + \sigma_n^2 I$ 来”去除训练点之间的冗余影响”。$K^{-1}$ 的作用就是:如果两个训练点高度相关,它会自动压低它们各自的权重,避免重复计数。加入 $\sigma_n^2 I$ 则表示我们承认观测有噪声,不要求完美拟合训练数据。

第三步:闭式预测公式

GPR 的预测均值有优雅的闭式解:

\[\mu_* = k_*^T (K + \sigma_n^2 I)^{-1} y\]

其中:

  • $K$ 是训练点之间的核矩阵(n x n)
  • $k_*$ 是测试点与所有训练点的核向量(n x 1,每个测试点一个)
  • $y$ 是训练标签向量

实际计算中,设 $\alpha = (K + \sigma_n^2 I)^{-1} y$(只需解一次线性系统),则每个测试点的预测就是 $k_*^T \alpha$。

第四步:RBF 核的向量化计算

RBF 核需要计算所有点对之间的欧几里得距离平方。利用恒等式:

\[\|x - x'\|^2 = \|x\|^2 + \|x'\|^2 - 2 x \cdot x'\]

可以用 NumPy 的矩阵运算一步完成,避免 for 循环。

题解代码

import json
import numpy as np

line = input()
data = json.loads(line)

train_raw = data["train"]
test_raw = data["test"]

# 解析训练数据:每行最后一个是标签 y
train_arr = np.array(train_raw, dtype=np.float64)
X_train = train_arr[:, :-1]
y = train_arr[:, -1]

X_test = np.array(test_raw, dtype=np.float64)

# 超参数
l = 1.0
sigma_f2 = 1.0
sigma_n2 = 0.1

# RBF 核函数(向量化)
def rbf_kernel(A, B):
    # A: (m, d), B: (n, d) -> K: (m, n)
    sq_A = np.sum(A ** 2, axis=1, keepdims=True)  # (m, 1)
    sq_B = np.sum(B ** 2, axis=1, keepdims=True)  # (n, 1)
    dist_sq = sq_A + sq_B.T - 2.0 * A @ B.T      # (m, n)
    return sigma_f2 * np.exp(-dist_sq / (2.0 * l ** 2))

# 训练点之间的核矩阵 + 噪声
n_train = X_train.shape[0]
K = rbf_kernel(X_train, X_train) + sigma_n2 * np.eye(n_train)

# 求解 alpha = K^{-1} y
alpha = np.linalg.solve(K, y)

# 测试点与训练点的核向量
K_star = rbf_kernel(X_test, X_train)  # (n_test, n_train)

# 预测均值
y_pred = K_star @ alpha

# 输出
result = [round(float(v), 6) for v in y_pred]
print(json.dumps(result))

复杂度分析

时间复杂度:O(n^3 + n * m),其中 n 为训练样本数(线性系统求解 O(n^3)),m 为测试样本数。 空间复杂度:O(n^2 + n * m),存储核矩阵。


第三题:神秘工坊

题目描述

给定长度为 n 的数组 a 和 b。你可以执行以下操作:选择一个值 v,将数组 a 中所有等于 v 的元素同时乘以 2(即翻倍)。这算作一次操作。

问:最少需要多少次操作,使得 a 的多重集等于 b 的多重集?如果无法达成,输出 -1。

样例

输入

3
3
1 2 3
2 4 3
4
1 1 2 2
4 2 2 1
2
3 5
3 9

输出

2
2
-1

思路分析

第一步:翻倍操作的数学本质——只改变 2 的幂次

将任意正整数写成 $x = \text{odd}(x) \times 2^{k}$ 的形式,其中 $\text{odd}(x)$ 是 x 的奇数因子(不断除以 2 直到为奇数)。翻倍操作将 x 变为 $\text{odd}(x) \times 2^{k+1}$,只有 2 的幂次增加了 1,奇数因子不变

这意味着:

  • 如果 a 中某元素的奇数因子为 p,经过任何次数操作后它的奇数因子仍然是 p
  • 操作只能增加 2 的幂次,不能减少

因此,若 b 中存在一个元素的奇数因子在 a 中找不到对应,或者 b 中某元素的 2 的幂次比 a 中对应元素的幂次还小,则答案为 -1。

第二步:按奇数因子分组

将 a 和 b 的元素都按奇数因子分组。对于每个奇数因子 p,a 中有若干个形如 $p \times 2^{k_1}, p \times 2^{k_2}, \ldots$ 的元素,b 中也有若干个形如 $p \times 2^{j_1}, p \times 2^{j_2}, \ldots$ 的元素。

若某组中 a 和 b 的元素个数不同,直接返回 -1。

第三步:组内贪心匹配

在同一组内,将 a 的幂次序列和 b 的幂次序列分别排序。排序后一一配对(最小对最小)是最优的,因为:

  • 操作是”选择当前值为 v 的所有元素同时翻倍”,即同一层的所有元素一起升一级
  • 将 a 的幂次从 $k_i$ 升到 $j_i$($j_i \geq k_i$,否则无解)需要经过 $j_i - k_i$ 个不同的”层”

第四步:计算操作次数——统计经过的不同层

一次操作是”选择值相同的所有元素翻倍”。值相同意味着奇数因子相同且当前 2 的幂次相同。所以对于同一组(奇数因子 = p),一次操作可以将所有当前为 $p \times 2^t$ 的元素一起升到 $p \times 2^{t+1}$。

匹配后,对于每对 $(k_i, j_i)$,元素需要经过层 $k_i, k_i+1, \ldots, j_i-1$ 的翻倍。操作次数 = 该组中所有这些中间层的去重数量(因为同一层只需操作一次)。

实现上,收集所有需要经过的区间 $[k_i, j_i)$,统计这些区间的并集覆盖了多少个整数点即可。

题解代码

import sys
from collections import defaultdict
input = sys.stdin.readline

def solve():
    n = int(input())
    a = list(map(int, input().split()))
    b = list(map(int, input().split()))

    def decompose(x):
        """分离奇数因子和 2 的幂次"""
        k = 0
        while x % 2 == 0:
            x //= 2
            k += 1
        return x, k  # (奇数因子, 2的幂次)

    # 按奇数因子分组
    groups_a = defaultdict(list)
    groups_b = defaultdict(list)
    for x in a:
        odd, pw = decompose(x)
        groups_a[odd].append(pw)
    for x in b:
        odd, pw = decompose(x)
        groups_b[odd].append(pw)

    # 检查分组是否对应
    if set(groups_a.keys()) != set(groups_b.keys()):
        print(-1)
        return
    for key in groups_a:
        if len(groups_a[key]) != len(groups_b[key]):
            print(-1)
            return

    total_ops = 0
    for odd_part in groups_a:
        pa = sorted(groups_a[odd_part])
        pb = sorted(groups_b[odd_part])
        # 一一配对:最小对最小
        # 收集所有需要经过的层
        layers = set()
        for ka, kb in zip(pa, pb):
            if kb < ka:
                print(-1)
                return
            for t in range(ka, kb):
                layers.add(t)
        total_ops += len(layers)

    print(total_ops)

T = int(input())
for _ in range(T):
    solve()

复杂度分析

时间复杂度:O(n log V),其中 V 为元素最大值。分解每个元素 O(log V),组内排序 O(n log n),收集层数在最坏情况下为 O(n log V)。 空间复杂度:O(n log V)。


第四题:最长公共子序列3

题目描述

给定两个长度为 n 的排列(1 到 n 的全排列)p 和 q,求它们的字典序最大的最长公共子序列(LCS)。

样例

输入

5
5 3 4 1 2
3 5 4 2 1

输出

5 4 1

思路分析

第一步:LCS 转 LIS——经典排列技巧

两个排列的 LCS 可以转化为 LIS(最长递增子序列)问题。设 pos[v] = v 在 q 中的位置,构造序列 c[i] = pos[p[i]],则 p 和 q 的 LCS 等价于 c 的 LIS(严格递增子序列)。

但这里需要的是字典序最大的 LCS,对应到 c 上就是字典序最大的 LIS(元素按原始值排序,不是按位置排序)。

第二步:贪心策略——从大到小尝试选取

要构造字典序最大的 LCS,贪心思路为:在每一步尽可能选当前能选的最大值。

具体地:维护一个”当前位置下界”(在 p 和 q 中都要在之前选的元素后面),从值 n 开始从大到小扫描每个值 v:如果选了 v 之后,剩余部分仍然能凑够剩余的 LCS 长度,就选它。

第三步:树状数组维护后缀 LIS 长度

关键子问题是:从位置 i 开始(c 数组中),且值严格大于某个阈值,能得到的最长递增子序列长度是多少?

从右往左处理 c 数组,用树状数组维护:以位置 j 开头的 LIS 长度,按 c[j] 的值做索引。查询”c 值 > threshold 的位置中,LIS 长度的最大值”就是一个后缀最大值查询。

第四步:贪心构造过程

  1. 预处理:计算整体 LCS 长度 L
  2. 从大值到小值遍历,维护当前在 p 中的位置下界 ip 和 q 中的位置下界 iq
  3. 对于值 v,若 v 在 p 中的位置 >= ip 且在 q 中的位置 >= iq,检查从该位置之后的后缀 LIS 长度是否 >= 还需要选的个数
  4. 若满足条件,选入答案,更新位置下界

题解代码

import sys
input = sys.stdin.readline

def solve():
    n = int(input())
    p = list(map(int, input().split()))
    q = list(map(int, input().split()))

    # pos_q[v] = v 在 q 中的位置 (0-indexed)
    pos_q = [0] * (n + 1)
    for i, v in enumerate(q):
        pos_q[v] = i

    # pos_p[v] = v 在 p 中的位置 (0-indexed)
    pos_p = [0] * (n + 1)
    for i, v in enumerate(p):
        pos_p[v] = i

    # c[i] = pos_q[p[i]],LCS(p,q) 等价于 c 的 LIS
    c = [pos_q[p[i]] for i in range(n)]

    # 树状数组(后缀最大值):翻转索引实现 "查询值域 >= x 的最大值"
    bit = [0] * (n + 2)

    def bit_update(x, val):
        x = n - x
        while x <= n:
            if bit[x] < val:
                bit[x] = val
            x += x & (-x)

    def bit_query(x):
        """查询值域 >= x 的最大 LIS 长度"""
        x = n - x
        res = 0
        while x > 0:
            if bit[x] > res:
                res = bit[x]
            x -= x & (-x)
        return res

    # 从右到左计算 lis_from[i]:以 c[i] 开头的 LIS 长度
    lis_from = [0] * n
    for i in range(n - 1, -1, -1):
        cv = c[i] + 1  # 映射到 1-indexed
        best = bit_query(cv + 1)  # 严格大于 c[i] 的后缀最大值
        lis_from[i] = best + 1
        bit_update(cv, lis_from[i])

    L = max(lis_from)  # LCS 长度

    # 贪心构造:从大到小扫描值 v
    # 关键观察:lis_from[pos_p[v]] 计算的是以 c[pos_p[v]] 开头的 LIS 长度,
    # 后续元素的 c 值都严格大于 c[pos_p[v]] = pos_q[v] > iq_bound,
    # 因此 iq_bound 的约束被自动满足,无需额外数据结构。
    result = []
    remain = L
    ip_bound = -1
    iq_bound = -1

    for v in range(n, 0, -1):
        if remain == 0:
            break
        pi = pos_p[v]
        qi = pos_q[v]
        if pi > ip_bound and qi > iq_bound and lis_from[pi] >= remain:
            result.append(v)
            remain -= 1
            ip_bound = pi
            iq_bound = qi

    print(' '.join(map(str, result)))

solve()

复杂度分析

时间复杂度:O(n log n)。预处理 lis_from 使用树状数组 O(n log n);贪心构造遍历所有值 O(n)。 空间复杂度:O(n)。


小结

  • 第一题排序签到,按 (值, 下标) 排序后标记前 m 个元素删除,再按原顺序输出剩余
  • 第二题高斯过程回归是 ML 实现题,核心公式 $\mu_* = k_^T (K + \sigma_n^2 I)^{-1} y$,理解三步:RBF 核度量相似度、$K^{-1}$ 去除训练点间冗余、$k_$ 按相似度加权
  • 第三题利用翻倍只改变 2 的幂次、不改变奇数因子的性质,按奇数因子分组后排序配对,统计需要经过的不同层数即为操作次数
  • 第四题字典序最大 LCS 是经典难题,通过排列的位置映射将 LCS 转 LIS,再利用后缀 LIS 长度数组贪心地从大到小选取可行元素