大厂真题 / 美团

美团 3.28 笔试真题 - 算法岗

本场考试概述

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

考点分析

  • 第一题:贪心 + 排序(难度中等,与研发岗第一题相同)
  • 第二题:机器学习 One-Class SVM 异常检测(难度中等)
  • 第三题:折半搜索 / Meet in the Middle(难度困难)
  • 第四题:分治 + RMQ(难度困难,与研发岗第三题相同)

建议策略

  • 第一题想清楚 op2 固定减少、op1 贪心取最大收益,排序即可
  • 第二题是标准 ML pipeline,熟悉 scikit-learn 即可稳拿
  • 第三题折半搜索是经典指数级优化思路,3^n 变 2*3^(n/2)
  • 第四题分治 + RMQ 需要较强的算法功底,建议最后攻克

第一题:风不吹雨

题目描述

给定长度为 n 的正整数数组 x,以及三个非负整数 a、b、k。

可以对数组执行两种操作:

  • 操作一:选择一个元素 x[i],将其变为 floor(x[i] / 2)。该操作最多执行 a 次。
  • 操作二:选择一个元素 x[i],将其变为 x[i] - k。该操作最多执行 b 次。

目标:使数组元素之和尽可能小。求操作后的最小元素和。

样例

输入

5 2 3 3
10 20 15 8 12

输出

35

思路分析

两种操作的本质区别决定了解题策略:

操作二的收益是固定的。每次操作二都让总和减少恰好 k,执行 b 次共减少 b * k。无论对哪个元素执行,效果完全相同,因此操作二不需要任何决策。

操作一的收益取决于元素大小。对元素 x 执行操作一,总和减少 ceil(x / 2)(即 x - floor(x / 2))。元素越大,收益越大。因此操作一应该贪心地优先用在当前最大的元素上。

但要注意操作一和操作二的交互:对同一个元素,先执行操作二再执行操作一,和先执行操作一再执行操作二,效果可能不同。由于操作二是减去固定值 k,而操作一是除以 2,先减再除会让操作一的”绝对收益”变小。因此最优策略是先贪心地执行所有操作一,再统一减去操作二的总收益 b * k

具体做法:用一个最大堆(或排序后模拟),每次取最大值执行 floor(x/2),重复 a 次。最后总和再减去 b * k。

但更简洁的思路是:枚举每个元素执行操作一带来的收益 ceil(x[i] / 2),将所有收益排序取前 a 大的。

时间复杂度:O(n log n)(排序)或 O(n + a log n)(堆)。

import sys
import heapq
input = sys.stdin.readline

def main():
    n, a, b, k = map(int, input().split())
    x = list(map(int, input().split()))

    # 操作二固定减少 b * k
    total = sum(x)
    total -= b * k

    # 操作一:贪心,每次对当前最大元素执行 floor(x/2)
    # 用最大堆模拟
    heap = [-v for v in x]
    heapq.heapify(heap)

    for _ in range(a):
        val = -heapq.heappop(heap)
        reduced = val // 2
        total -= (val - reduced)
        heapq.heappush(heap, -reduced)

    print(total)

main()

第二题:SVM异常检测

题目描述

给定一份数据集,包含正常样本和异常样本(通过标签列区分)。要求使用 One-Class SVM 完成异常检测任务:

  1. 将数据分为正常数据和异常数据
  2. 对正常数据进行 train_test_split(test_size=0.25, random_state=42)
  3. 仅在训练集上进行标准化(StandardScaler),并将同一变换应用到测试集和异常数据
  4. 对 OneClassSVM(kernel=’rbf’)进行网格搜索,搜索 nu 和 gamma 参数
  5. 在验证集(正常测试集 + 异常数据)上计算 AUC 和 F1
  6. 选择最优参数组合:AUC 降序 > F1 降序 > nu 升序 > gamma 升序
  7. 用最优参数在全部正常数据上重新训练,对测试数据进行预测

思路分析

这是一道标准的机器学习 pipeline 实现题,考察对 scikit-learn API 的熟练程度以及对 One-Class SVM 原理的理解。

为什么用 One-Class SVM?

传统的二分类 SVM 需要正负两类样本来训练。但在异常检测场景中,异常样本往往极少且形态多样,难以学到有效的决策边界。One-Class SVM 只用正常样本训练,学习一个”紧致”的边界包围正常数据的分布,落在边界外的点判定为异常。

关键参数

  • nu:控制异常比例的上界和支持向量比例的下界,值越大边界越松
  • gamma:RBF 核函数的宽度参数,值越大模型越复杂

Pipeline 详解

  1. 数据划分:先分离正常/异常数据。对正常数据做 75/25 划分,75% 用于训练 One-Class SVM,25% 作为正常验证集
  2. 标准化:StandardScaler 只在训练集上 fit,然后 transform 训练集、验证集和异常集。这是防止数据泄漏的标准做法
  3. 网格搜索:遍历所有 (nu, gamma) 组合,对每组参数训练 One-Class SVM 并在验证集上评估
  4. 评估指标:验证集由正常测试集(标签 1)和异常数据(标签 -1)组成。AUC 衡量排序质量,F1 衡量分类质量
  5. 参数选择:多级排序保证结果唯一确定
  6. 最终模型:用最优参数在全部正常数据上重新训练,充分利用所有正常样本
import numpy as np
import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.svm import OneClassSVM
from sklearn.metrics import roc_auc_score, f1_score

def solve(data, test_data, nu_list, gamma_list):
    """
    data: DataFrame,含特征列和 'label' 列(1=正常, -1=异常)
    test_data: DataFrame,待预测数据(无标签)
    nu_list: list of float,nu 候选值
    gamma_list: list of float/str,gamma 候选值
    """
    # Step 1: 分离正常样本与异常样本
    normal = data[data['label'] == 1].drop(columns=['label'])
    anomaly = data[data['label'] == -1].drop(columns=['label'])

    # Step 2: 划分正常数据为训练集和验证集
    X_train, X_val = train_test_split(
        normal, test_size=0.25, random_state=42
    )

    # Step 3: 标准化(仅在训练集上 fit)
    scaler = StandardScaler()
    X_train_scaled = scaler.fit_transform(X_train)
    X_val_scaled = scaler.transform(X_val)
    X_anomaly_scaled = scaler.transform(anomaly)

    # 构造验证集:正常验证集 + 异常数据
    X_eval = np.vstack([X_val_scaled, X_anomaly_scaled])
    y_eval = np.array([1] * len(X_val) + [-1] * len(anomaly))

    # Step 4 & 5: 网格搜索
    results = []
    for nu in nu_list:
        for gamma in gamma_list:
            clf = OneClassSVM(kernel='rbf', nu=nu, gamma=gamma)
            clf.fit(X_train_scaled)

            # decision_function 值越大越"正常"
            scores = clf.decision_function(X_eval)
            y_pred = clf.predict(X_eval)

            auc = roc_auc_score(y_eval, scores)
            f1 = f1_score(y_eval, y_pred)
            results.append((auc, f1, nu, gamma))

    # Step 6: 选择最优参数
    # AUC 降序, F1 降序, nu 升序, gamma 升序
    results.sort(key=lambda r: (-r[0], -r[1], r[2], r[3]))
    best_auc, best_f1, best_nu, best_gamma = results[0]

    print(f"Best params: nu={best_nu}, gamma={best_gamma}")
    print(f"AUC={best_auc:.4f}, F1={best_f1:.4f}")

    # Step 7: 用全部正常数据重新训练
    scaler_final = StandardScaler()
    X_all_normal = scaler_final.fit_transform(normal)
    X_test_scaled = scaler_final.transform(test_data)

    final_model = OneClassSVM(
        kernel='rbf', nu=best_nu, gamma=best_gamma
    )
    final_model.fit(X_all_normal)

    predictions = final_model.predict(X_test_scaled)
    return predictions

第三题:红黑树浇水

题目描述

花园里有一棵红树和一棵黑树。共有 n 天,每天可以选择以下三种操作之一:

  • 给红树浇水,红树长高 a[i]
  • 给黑树浇水,黑树长高 b[i]
  • 什么都不做(跳过这一天)
初始时两棵树高度均为 0。最多可以跳过 k 天。目标:最小化 红树高度 - 黑树高度

样例

输入

3 0
1 2 3
2 2 2

输出

1

样例解释:不能跳过任何一天(k=0),最优方案为:第 1 天浇红树(+1),第 2 天浇黑树(+2),第 3 天浇红树(+3),红树高度 4,黑树高度 2,差值为 2。或者:第 1 天浇黑树(+2),第 2 天浇红树(+2),第 3 天浇黑树(+2),红树高度 2,黑树高度 4,差值也是 2。但最优是:第 1 天浇红树(+1),第 2 天浇红树(+2),第 3 天浇黑树(+2),红树高度 3,黑树高度 2,差值为 1。

思路分析

为什么暴力不可行?

每天有 3 种选择(浇红、浇黑、跳过),n 天共有 3^n 种方案。n 较大时(比如 n=40),3^40 约为 10^19,暴力枚举完全不可行。

为什么折半搜索(Meet in the Middle)可行?

折半搜索的核心思想是:将 n 天分成前后两半,分别枚举所有方案。每半的枚举量为 3^(n/2),当 n=40 时约为 3^20 ≈ 3.5 * 10^9,虽然仍然很大但已经接近可行范围;当 n=36 时为 3^18 ≈ 3.9 * 10^8,完全可行。

关键观察:将每天的选择对差值的影响建模。令 diff = 红树高度 - 黑树高度:

  • 浇红树:diff += a[i]
  • 浇黑树:diff -= b[i]
  • 跳过:diff 不变,跳过次数 +1
最终要最小化 diff

折半搜索步骤

  1. 分割:将 n 天分为前半 [0, n/2) 和后半 [n/2, n)

  2. 枚举前半:遍历前半所有 3^(n/2) 种方案,对每种方案记录 (diff, skip_count)。按 skip_count 分组,同一 skip_count 下将所有 diff 值收集并排序

  3. 枚举后半并合并:遍历后半所有 3^(n/2) 种方案得到 (diff2, skip2)。要找前半的 (diff1, skip1) 使得 skip1 + skip2 <= k 且 diff1 + diff2 最小。对于每个合法的 skip1 值(即 skip1 <= k - skip2),在对应的排序数组中二分查找最接近 -diff2 的值
  4. 取全局最优:所有合法组合中 diff1 + diff2 的最小值即为答案

时间复杂度:O(3^(n/2) * n * log(3^(n/2))),其中排序和二分查找贡献 log 因子。

import sys
from bisect import bisect_left, insort
input = sys.stdin.readline

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

    def enumerate_half(start, end):
        """
        枚举 [start, end) 天的所有方案。
        返回字典:skip_count -> sorted list of diff values
        """
        # states: list of (diff, skip_count)
        states = [(0, 0)]
        for i in range(start, end):
            new_states = []
            for diff, skips in states:
                # 选择 1:浇红树
                new_states.append((diff + a[i], skips))
                # 选择 2:浇黑树
                new_states.append((diff - b[i], skips))
                # 选择 3:跳过
                if skips < k:
                    new_states.append((diff, skips + 1))
            states = new_states

        # 按 skip_count 分组并排序
        from collections import defaultdict
        groups = defaultdict(list)
        for diff, skips in states:
            groups[skips].append(diff)
        for skips in groups:
            groups[skips].sort()
        return groups

    mid = n // 2
    left_groups = enumerate_half(0, mid)
    right_groups = enumerate_half(mid, n)

    ans = float('inf')

    for skip_r, diffs_r in right_groups.items():
        for diff2 in diffs_r:
            target = -diff2  # 希望 diff1 尽量接近 -diff2
            # 前半跳过次数 skip_l 需满足 skip_l + skip_r <= k
            max_skip_l = k - skip_r
            for skip_l in range(max_skip_l + 1):
                if skip_l not in left_groups:
                    continue
                arr = left_groups[skip_l]
                # 二分查找最接近 target 的值
                pos = bisect_left(arr, target)
                # 检查 pos 和 pos-1 两个候选位置
                for idx in (pos - 1, pos):
                    if 0 <= idx < len(arr):
                        ans = min(ans, abs(arr[idx] + diff2))

            if ans == 0:
                print(0)
                return

    print(ans)

main()

优化提示:上述代码在最坏情况下仍可能较慢。实际比赛中可以进一步优化:将同一 skip_r 对应的所有 diff_r 也排序处理,或者将前半的分组做前缀合并(skip_count <= s 的所有 diff 合并为一个排序数组),这样后半每个状态只需查一次。


第四题:AK机的区间

题目描述

给定长度为 n 的正整数数组 a。求满足以下条件的区间 [l, r](l < r)的个数:

\[\max(a[l], a[l+1], \ldots, a[r]) < a[l] + a[r]\]

即区间最大值严格小于首尾两元素之和。

样例

输入

5
3 1 4 1 5

输出

4

思路分析

暴力思路:枚举所有 O(n^2) 个区间,对每个区间求最大值并检查条件。即使用 Sparse Table 做 O(1) 区间最大值查询,总复杂度仍为 O(n^2),无法通过大数据。

分治 + RMQ 的优化思路

核心观察:对于一个区间 [l, r],设区间最大值位置为 m,则条件变为 a[m] < a[l] + a[r]。分治时以区间最大值位置为分割点,分别处理跨越最大值的区间。

分治过程

  1. 预处理 Sparse Table,支持 O(1) 查询任意区间最大值的位置

  2. 对区间 [L, R] 分治:
    • 找到最大值位置 m
    • 递归处理 [L, m-1] 和 [m+1, R]
    • 统计跨越 m 的合法区间 [l, r],其中 l <= m <= r
  3. 对于跨越 m 的区间,a[m] 就是区间最大值。条件简化为 a[m] < a[l] + a[r],即 a[l] > a[m] - a[r] 或 a[r] > a[m] - a[l]

  4. 枚举较短的一侧(保证复杂度),对较长的一侧用排序+二分统计满足条件的个数

为什么枚举较短一侧?

这是经典的”启发式合并”思想。设 m 将 [L, R] 分为长度 s1 和 s2 的两段,枚举较短段的复杂度为 O(min(s1, s2) * log(max(s1, s2)))。通过主定理可以证明总复杂度为 O(n log^2 n)。

时间复杂度:O(n log^2 n)。

import sys
from math import log2
input = sys.stdin.readline

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

    if n <= 1:
        print(0)
        return

    # Sparse Table 预处理:区间最大值的位置
    LOG = int(log2(n)) + 1 if n > 0 else 1
    sp = [[0] * n for _ in range(LOG + 1)]

    for i in range(n):
        sp[0][i] = i

    j = 1
    while (1 << j) <= n:
        i = 0
        while i + (1 << j) - 1 < n:
            l_idx = sp[j - 1][i]
            r_idx = sp[j - 1][i + (1 << (j - 1))]
            sp[j][i] = l_idx if a[l_idx] >= a[r_idx] else r_idx
            i += 1
        j += 1

    def query_max_pos(l, r):
        """返回 a[l..r] 中最大值的位置"""
        if l > r:
            return -1
        k = int(log2(r - l + 1))
        li = sp[k][l]
        ri = sp[k][r - (1 << k) + 1]
        return li if a[li] >= a[ri] else ri

    from bisect import bisect_left

    ans = 0

    def solve(L, R):
        nonlocal ans
        if L >= R:
            return

        m = query_max_pos(L, R)
        # 递归处理两侧
        solve(L, m - 1)
        solve(m + 1, R)

        # 统计跨越 m 的合法区间 [l, r],l in [L, m], r in [m, R]
        # 条件:a[m] < a[l] + a[r],即 a[l] > a[m] - a[r] 或等价地 a[r] > a[m] - a[l]
        left_len = m - L + 1
        right_len = R - m + 1

        if left_len <= right_len:
            # 枚举左侧 l,在右侧二分
            # 收集右侧的 a 值并排序
            right_vals = sorted(a[i] for i in range(m, R + 1))
            for l in range(L, m + 1):
                # 需要 a[r] > a[m] - a[l],即 a[r] >= a[m] - a[l] + 1
                threshold = a[m] - a[l] + 1
                # 右侧中 >= threshold 的个数
                pos = bisect_left(right_vals, threshold)
                ans += len(right_vals) - pos
        else:
            # 枚举右侧 r,在左侧二分
            left_vals = sorted(a[i] for i in range(L, m + 1))
            for r in range(m, R + 1):
                threshold = a[m] - a[r] + 1
                pos = bisect_left(left_vals, threshold)
                ans += len(left_vals) - pos

    # 增加递归深度限制
    sys.setrecursionlimit(300000)
    solve(0, n - 1)
    print(ans)

main()

小结

  • 第一题贪心排序:操作二收益固定直接扣除,操作一用最大堆贪心取最大收益
  • 第二题 ML pipeline:掌握 One-Class SVM 的原理和 scikit-learn 标准流程,注意标准化只在训练集 fit、验证集构造、多级排序选参
  • 第三题折半搜索:经典的指数级优化技巧,3^n 拆成 2 * 3^(n/2),关键在于按跳过次数分组后二分合并
  • 第四题分治 + RMQ:以区间最大值为分割点分治,枚举较短侧配合二分统计,总复杂度 O(n log^2 n)